From fbf80e7308887e123c6afb702215c2c7f44fcd8c Mon Sep 17 00:00:00 2001 From: KIM SU BO Date: Thu, 8 Oct 2026 17:15:05 +0900 Subject: [PATCH] jupiter notebook --- ai_prompt/03-3_code.md | 172 + uploads/ADM01/ADM01_solution.ipynb | 2999 ++++++++++++++++ uploads/ADM01/ADM01_student.ipynb | 2981 ++++++++++++++++ uploads/ADM05/ADM05_workbook.ipynb | 982 ++++++ uploads/AMM02/AMM02_solution.ipynb | 2818 +++++++++++++++ uploads/AMM02/AMM02_student.ipynb | 2691 +++++++++++++++ uploads/C202/C202_solution.ipynb | 3186 +++++++++++++++++ uploads/C202/C202_student.ipynb | 3085 +++++++++++++++++ uploads/C204/C204_solution.ipynb | 3549 +++++++++++++++++++ uploads/C204/C204_student.ipynb | 3310 ++++++++++++++++++ uploads/C207/C207_workbook.ipynb | 967 ++++++ uploads/C210/C210_workbook.ipynb | 880 +++++ uploads/DAT06/DAT06_solution.ipynb | 2257 ++++++++++++ uploads/DAT06/DAT06_student.ipynb | 2205 ++++++++++++ uploads/DAT09/DAT09_solution.ipynb | 2383 +++++++++++++ uploads/DAT09/DAT09_student.ipynb | 2403 +++++++++++++ uploads/DAT10/DAT10_solution.ipynb | 2883 ++++++++++++++++ uploads/DAT10/DAT10_student.ipynb | 2809 +++++++++++++++ uploads/ISR01/ISR01_solution.ipynb | 3952 +++++++++++++++++++++ uploads/ISR01/ISR01_student.ipynb | 3854 +++++++++++++++++++++ uploads/ISR02/ISR02_solution.ipynb | 3772 ++++++++++++++++++++ uploads/ISR02/ISR02_student.ipynb | 3681 ++++++++++++++++++++ uploads/ISR03/ISR03_solution.ipynb | 4242 +++++++++++++++++++++++ uploads/ISR03/ISR03_student.ipynb | 4251 +++++++++++++++++++++++ uploads/ISR05/ISR05_workbook.ipynb | 915 +++++ uploads/ISR06/ISR06_workbook.ipynb | 957 ++++++ uploads/ISR07/ISR07_workbook.ipynb | 957 ++++++ uploads/ISR08/ISR08_workbook.ipynb | 887 +++++ uploads/LOG04/LOG04_workbook.ipynb | 967 ++++++ uploads/LOG05/LOG05_workbook.ipynb | 992 ++++++ uploads/LOG06/LOG06_workbook.ipynb | 1013 ++++++ uploads/LOG07/LOG07_workbook.ipynb | 820 +++++ uploads/MNT02/MNT02_solution.ipynb | 3097 +++++++++++++++++ uploads/MNT02/MNT02_student.ipynb | 3046 +++++++++++++++++ uploads/MNT03/MNT03_solution.ipynb | 3017 ++++++++++++++++ uploads/MNT03/MNT03_student.ipynb | 2976 ++++++++++++++++ uploads/MNT04/MNT04_solution.ipynb | 3057 +++++++++++++++++ uploads/MNT04/MNT04_student.ipynb | 2901 ++++++++++++++++ uploads/MNT06/MNT06_solution.ipynb | 2723 +++++++++++++++ uploads/MNT06/MNT06_student.ipynb | 2687 +++++++++++++++ uploads/MNT10/MNT10_solution.ipynb | 2912 ++++++++++++++++ uploads/MNT10/MNT10_student.ipynb | 2897 ++++++++++++++++ uploads/MOV01/MOV01_solution.ipynb | 3343 ++++++++++++++++++ uploads/MOV01/MOV01_student.ipynb | 3353 ++++++++++++++++++ uploads/MOV02/MOV02_solution.ipynb | 3240 ++++++++++++++++++ uploads/MOV02/MOV02_student.ipynb | 3200 +++++++++++++++++ uploads/MOV06/MOV06_solution.ipynb | 3276 ++++++++++++++++++ uploads/MOV06/MOV06_student.ipynb | 3236 ++++++++++++++++++ uploads/OPS01/OPS01_solution.ipynb | 3070 +++++++++++++++++ uploads/OPS01/OPS01_student.ipynb | 2921 ++++++++++++++++ uploads/OPS02/OPS02_solution.ipynb | 4108 ++++++++++++++++++++++ uploads/OPS02/OPS02_student.ipynb | 3889 +++++++++++++++++++++ uploads/OPS04/OPS04_solution.ipynb | 2717 +++++++++++++++ uploads/OPS04/OPS04_student.ipynb | 2616 ++++++++++++++ uploads/OPS05/OPS05_workbook.ipynb | 1014 ++++++ uploads/OPS07/OPS07_workbook.ipynb | 940 +++++ uploads/OPS08/OPS08_workbook.ipynb | 1062 ++++++ uploads/OPS10/OPS10_workbook.ipynb | 983 ++++++ uploads/PRC01/PRC01_solution.ipynb | 5101 ++++++++++++++++++++++++++++ uploads/PRC01/PRC01_student.ipynb | 4968 +++++++++++++++++++++++++++ uploads/PRC07/PRC07_solution.ipynb | 3900 +++++++++++++++++++++ uploads/PRC07/PRC07_student.ipynb | 3914 +++++++++++++++++++++ uploads/QLT02/QLT02_solution.ipynb | 2807 +++++++++++++++ uploads/QLT02/QLT02_student.ipynb | 2720 +++++++++++++++ uploads/QLT03/QLT03_solution.ipynb | 3921 +++++++++++++++++++++ uploads/QLT03/QLT03_student.ipynb | 3898 +++++++++++++++++++++ uploads/QLT04/QLT04_solution.ipynb | 3523 +++++++++++++++++++ uploads/QLT04/QLT04_student.ipynb | 3499 +++++++++++++++++++ uploads/QLT05/QLT05_solution.ipynb | 3469 +++++++++++++++++++ uploads/QLT05/QLT05_student.ipynb | 3399 ++++++++++++++++++ uploads/SEC01/SEC01_solution.ipynb | 3525 +++++++++++++++++++ uploads/SEC01/SEC01_student.ipynb | 3491 +++++++++++++++++++ uploads/SEC06/SEC06_workbook.ipynb | 922 +++++ uploads/SEC08/SEC08_workbook.ipynb | 1095 ++++++ uploads/SEC09/SEC09_workbook.ipynb | 1123 ++++++ uploads/STR01/STR01_solution.ipynb | 2352 +++++++++++++ uploads/STR01/STR01_student.ipynb | 2357 +++++++++++++ uploads/SUP02/SUP02_solution.ipynb | 4157 +++++++++++++++++++++++ uploads/SUP02/SUP02_student.ipynb | 4167 +++++++++++++++++++++++ uploads/TRN01/TRN01_solution.ipynb | 3144 +++++++++++++++++ uploads/TRN01/TRN01_student.ipynb | 2633 ++++++++++++++ uploads/TRN02/TRN02_solution.ipynb | 4011 ++++++++++++++++++++++ uploads/TRN02/TRN02_student.ipynb | 4021 ++++++++++++++++++++++ uploads/TRN03/TRN03_solution.ipynb | 4007 ++++++++++++++++++++++ uploads/TRN03/TRN03_student.ipynb | 3997 ++++++++++++++++++++++ uploads/TRN04/TRN04_workbook.ipynb | 1039 ++++++ uploads/TRN06/TRN06_workbook.ipynb | 964 ++++++ uploads/TRN08/TRN08_workbook.ipynb | 1123 ++++++ uploads/TRN10/TRN10_workbook.ipynb | 917 +++++ uploads/WEL01/WEL01_solution.ipynb | 2865 ++++++++++++++++ uploads/WEL01/WEL01_student.ipynb | 2791 +++++++++++++++ uploads/WEL03/WEL03_solution.ipynb | 3713 ++++++++++++++++++++ uploads/WEL03/WEL03_student.ipynb | 3353 ++++++++++++++++++ uploads/WEL07/WEL07_workbook.ipynb | 941 +++++ uploads/WEL08/WEL08_workbook.ipynb | 992 ++++++ uploads/WEL10/WEL10_workbook.ipynb | 985 ++++++ 96 files changed, 255905 insertions(+) create mode 100644 ai_prompt/03-3_code.md create mode 100644 uploads/ADM01/ADM01_solution.ipynb create mode 100644 uploads/ADM01/ADM01_student.ipynb create mode 100644 uploads/ADM05/ADM05_workbook.ipynb create mode 100644 uploads/AMM02/AMM02_solution.ipynb create mode 100644 uploads/AMM02/AMM02_student.ipynb create mode 100644 uploads/C202/C202_solution.ipynb create mode 100644 uploads/C202/C202_student.ipynb create mode 100644 uploads/C204/C204_solution.ipynb create mode 100644 uploads/C204/C204_student.ipynb create mode 100644 uploads/C207/C207_workbook.ipynb create mode 100644 uploads/C210/C210_workbook.ipynb create mode 100644 uploads/DAT06/DAT06_solution.ipynb create mode 100644 uploads/DAT06/DAT06_student.ipynb create mode 100644 uploads/DAT09/DAT09_solution.ipynb create mode 100644 uploads/DAT09/DAT09_student.ipynb create mode 100644 uploads/DAT10/DAT10_solution.ipynb create mode 100644 uploads/DAT10/DAT10_student.ipynb create mode 100644 uploads/ISR01/ISR01_solution.ipynb create mode 100644 uploads/ISR01/ISR01_student.ipynb create mode 100644 uploads/ISR02/ISR02_solution.ipynb create mode 100644 uploads/ISR02/ISR02_student.ipynb create mode 100644 uploads/ISR03/ISR03_solution.ipynb create mode 100644 uploads/ISR03/ISR03_student.ipynb create mode 100644 uploads/ISR05/ISR05_workbook.ipynb create mode 100644 uploads/ISR06/ISR06_workbook.ipynb create mode 100644 uploads/ISR07/ISR07_workbook.ipynb create mode 100644 uploads/ISR08/ISR08_workbook.ipynb create mode 100644 uploads/LOG04/LOG04_workbook.ipynb create mode 100644 uploads/LOG05/LOG05_workbook.ipynb create mode 100644 uploads/LOG06/LOG06_workbook.ipynb create mode 100644 uploads/LOG07/LOG07_workbook.ipynb create mode 100644 uploads/MNT02/MNT02_solution.ipynb create mode 100644 uploads/MNT02/MNT02_student.ipynb create mode 100644 uploads/MNT03/MNT03_solution.ipynb create mode 100644 uploads/MNT03/MNT03_student.ipynb create mode 100644 uploads/MNT04/MNT04_solution.ipynb create mode 100644 uploads/MNT04/MNT04_student.ipynb create mode 100644 uploads/MNT06/MNT06_solution.ipynb create mode 100644 uploads/MNT06/MNT06_student.ipynb create mode 100644 uploads/MNT10/MNT10_solution.ipynb create mode 100644 uploads/MNT10/MNT10_student.ipynb create mode 100644 uploads/MOV01/MOV01_solution.ipynb create mode 100644 uploads/MOV01/MOV01_student.ipynb create mode 100644 uploads/MOV02/MOV02_solution.ipynb create mode 100644 uploads/MOV02/MOV02_student.ipynb create mode 100644 uploads/MOV06/MOV06_solution.ipynb create mode 100644 uploads/MOV06/MOV06_student.ipynb create mode 100644 uploads/OPS01/OPS01_solution.ipynb create mode 100644 uploads/OPS01/OPS01_student.ipynb create mode 100644 uploads/OPS02/OPS02_solution.ipynb create mode 100644 uploads/OPS02/OPS02_student.ipynb create mode 100644 uploads/OPS04/OPS04_solution.ipynb create mode 100644 uploads/OPS04/OPS04_student.ipynb create mode 100644 uploads/OPS05/OPS05_workbook.ipynb create mode 100644 uploads/OPS07/OPS07_workbook.ipynb create mode 100644 uploads/OPS08/OPS08_workbook.ipynb create mode 100644 uploads/OPS10/OPS10_workbook.ipynb create mode 100644 uploads/PRC01/PRC01_solution.ipynb create mode 100644 uploads/PRC01/PRC01_student.ipynb create mode 100644 uploads/PRC07/PRC07_solution.ipynb create mode 100644 uploads/PRC07/PRC07_student.ipynb create mode 100644 uploads/QLT02/QLT02_solution.ipynb create mode 100644 uploads/QLT02/QLT02_student.ipynb create mode 100644 uploads/QLT03/QLT03_solution.ipynb create mode 100644 uploads/QLT03/QLT03_student.ipynb create mode 100644 uploads/QLT04/QLT04_solution.ipynb create mode 100644 uploads/QLT04/QLT04_student.ipynb create mode 100644 uploads/QLT05/QLT05_solution.ipynb create mode 100644 uploads/QLT05/QLT05_student.ipynb create mode 100644 uploads/SEC01/SEC01_solution.ipynb create mode 100644 uploads/SEC01/SEC01_student.ipynb create mode 100644 uploads/SEC06/SEC06_workbook.ipynb create mode 100644 uploads/SEC08/SEC08_workbook.ipynb create mode 100644 uploads/SEC09/SEC09_workbook.ipynb create mode 100644 uploads/STR01/STR01_solution.ipynb create mode 100644 uploads/STR01/STR01_student.ipynb create mode 100644 uploads/SUP02/SUP02_solution.ipynb create mode 100644 uploads/SUP02/SUP02_student.ipynb create mode 100644 uploads/TRN01/TRN01_solution.ipynb create mode 100644 uploads/TRN01/TRN01_student.ipynb create mode 100644 uploads/TRN02/TRN02_solution.ipynb create mode 100644 uploads/TRN02/TRN02_student.ipynb create mode 100644 uploads/TRN03/TRN03_solution.ipynb create mode 100644 uploads/TRN03/TRN03_student.ipynb create mode 100644 uploads/TRN04/TRN04_workbook.ipynb create mode 100644 uploads/TRN06/TRN06_workbook.ipynb create mode 100644 uploads/TRN08/TRN08_workbook.ipynb create mode 100644 uploads/TRN10/TRN10_workbook.ipynb create mode 100644 uploads/WEL01/WEL01_solution.ipynb create mode 100644 uploads/WEL01/WEL01_student.ipynb create mode 100644 uploads/WEL03/WEL03_solution.ipynb create mode 100644 uploads/WEL03/WEL03_student.ipynb create mode 100644 uploads/WEL07/WEL07_workbook.ipynb create mode 100644 uploads/WEL08/WEL08_workbook.ipynb create mode 100644 uploads/WEL10/WEL10_workbook.ipynb diff --git a/ai_prompt/03-3_code.md b/ai_prompt/03-3_code.md new file mode 100644 index 0000000..4efa49f --- /dev/null +++ b/ai_prompt/03-3_code.md @@ -0,0 +1,172 @@ +# 시나리오 코드(MD) ➔ Jupyter/Colab Notebook(.ipynb) 일괄 변환 및 Colab 배포 계획서 + +## 1. 개요 및 목적 + +본 계획은 [`scenario_md`](file:///d:/edunlab/milicontent2/scenario_md) 하위 60개 프로젝트 디렉토리에 수록된 `*_정답코드.md` 파일과 [`scenario_json`](file:///d:/edunlab/milicontent2/scenario_json)의 미션 메타데이터를 결합하여, **Google Colab(구글 코랩)** 및 로컬 Jupyter 환경에서 즉시 실행 가능한 **Jupyter Notebook (`.ipynb`)** 파일들을 일괄 생성하고 원클릭 실습 환경을 구축하는 것을 목표로 합니다. + +--- + +## 2. 데이터 소스 및 산출물 구조 + +### 2.1 소스 데이터 자산 + +| 데이터 자산 | 위치 / 경로 | 주요 내용 및 역할 | +| :------------------- | :--------------------------------------------------------------------------------------------- | :---------------------------------------------------------------------------------- | +| **정답 코드 MD** | [`scenario_md/{Project_Dir}/*_정답코드.md`](file:///d:/edunlab/milicontent2/scenario_md) | 60개 프로젝트(총 600개 미션)의 미션별 독립 실행 파이썬 코드 및 설명 | +| **프로젝트 JSON** | [`scenario_json/{ID}.json`](file:///d:/edunlab/milicontent2/scenario_json) | 프로젝트 및 10개 미션별 상세 메타데이터 (목표, 시나리오, 실습가이드, 평가기준) | +| **학생 코드 생성기** | [`scripts/student_generator.py`](file:///d:/edunlab/milicontent2/scripts/student_generator.py) | 정답 코드에서 핵심 로직을 추출하여 학생용 실습 스켈레톤(TODO 빈칸/힌트) 자동 생성기 | + +### 2.2 최종 산출물 체계 + +생성되는 노트북은 프로젝트별 독립 디렉토리(`uploads/{ProjectID}/`) 하위에 분류하여 강사용/참고용 **정답 노트북**, 학습자용 **학생 실습 노트북**, 그리고 SOP/모의훈련형 **실습 워크북 노트북**으로 체계적으로 관리합니다. + +```text +uploads/ +├── ADM01/ +│ ├── ADM01_solution.ipynb # 정답 및 완전 실행 노트북 +│ └── ADM01_student.ipynb # 학생 실습용 빈칸/힌트 노트북 +├── C207/ +│ └── C207_workbook.ipynb # 작전절차(SOP) 및 모의훈련 워크북 노트북 +├── OPS01/ +│ ├── OPS01_solution.ipynb +│ └── OPS01_student.ipynb +└── ... (총 60개 프로젝트 디렉토리) +``` + +--- + +## 3. Jupyter / Colab 노트북 셀 구조 설계 + +각 `.ipynb` 파일은 Colab 환경에서 가장 쾌적하게 실행될 수 있도록 아래와 같은 표준 셀 시퀀스로 구성됩니다. + +```mermaid +flowchart TD + Cell1[셀 1: 프로젝트 타이틀 & Colab 뱃지
Markdown 셀] --> Cell2[셀 2: Colab 환경 설정 & 패키지 설치
Code 셀] + Cell2 --> M01[미션 1: 설명 및 가이드 MD ➔ 코드 셀] + M01 --> M02[미션 2: 설명 및 가이드 MD ➔ 코드 셀] + M02 --> MDot[...] + MDot --> M10[미션 10: AAR 종합 분석 MD ➔ 코드 셀] +``` + +### 3.1 셀별 상세 구성 + +1. **Cell 1 (Header & Colab Badge) [Markdown]**: + - 프로젝트 ID, 제목, 도메인 분야, 학습 목표 요약 + - **`Open In Colab` 뱃지**: 클릭 시 본 노트북이 Colab에서 즉시 열리는 원클릭 링크 제공 + + ```markdown + # [ADM01] 결원·근무부하 추세 대시보드와 보충 필요량 예측 + + [![Open In Colab](https://colab.research.google.com/assets/colab-badge.svg)](https://colab.research.google.com/github/edunlab/milicontent2/blob/main/uploads/ADM01/ADM01_solution.ipynb) + + > **[안내]** 본 실습의 모든 데이터는 코드 내부에서 난수로 자동 생성되는 교육용 가상 데이터입니다. + ``` + +2. **Cell 2 (Environment Setup) [Code]**: + - 코랩 환경 필수 패키지 자동 설치 및 폰트/경고 설정 + + ```python + # Colab 환경 설정 및 필수 라이브러리 확인 + !pip install -q numpy pandas matplotlib scipy scikit-learn + + import sys + import numpy as np + import pandas as pd + import matplotlib.pyplot as plt + import warnings + warnings.filterwarnings('ignore') + + print("실행 환경 준비 완료: Python", sys.version.split()[0]) + ``` + +3. **Mission Cells (M01 ~ M10 반복 구성)**: + - **Mission Intro [Markdown]**: 미션 순번, 제목, 난이도, 소요시간, 문제 배경 및 수행 지침, 검증 기준 + - **Mission Code [Code]**: + - **정답 노트북 (`solution`)**: 완성된 파이썬 실행 코드 및 시각화/검증 로직 + - **학생 실습 노트북 (`student`)**: 핵심 알고리즘 및 데이터 가공 부위에 `### TODO: 코드 작성 ###` 및 힌트 주석 적용 + - **Mission Output/Check [Markdown]**: 예상 실행 콘솔 출력 및 PASS/FAIL 확인 지침 + +--- + +## 4. 학생 실습용 코드(Student Code) 자동화 규칙 + +[`scripts/student_generator.py`](file:///d:/edunlab/milicontent2/scripts/student_generator.py)의 지능형 변환 규칙을 적용하여 실습 몰입도를 높입니다: + +1. **데이터 생성 및 유틸리티 함수**: 기초 가상 데이터 생성 로직은 그대로 유지하여 즉시 실습 착수 가능하도록 구성 +2. **핵심 분석 / 모델링 / 계산 로직**: + - 주요 수식, Scikit-learn 모델 훈련/예측, 판정 로직을 `### TODO: [미션 지침에 맞게 구현] ###`으로 블록 처리 + - 주석으로 파라미터 힌트 및 반환값 규격 명시 +3. **평가 및 검증 Assert 로직**: 실습자가 작성한 코드가 올바른지 스스로 검증할 수 있도록 테스트 코드는 활성화 상태로 유지 + +--- + +## 5. 단계별 실행 계획 (Implementation Phases) + +```mermaid +flowchart TD + P1[Phase 1: 통합 빌더 스크립트 개발
scripts/build_all_notebooks.py] --> P2[Phase 2: 60개 시나리오 일괄 빌드
uploads/{ProjectID}/ 에 생성] + P2 --> P3[Phase 3: 무결성 및 구문 검증
nbformat 스키마 & AST 문법 검사] + P3 --> P4[Phase 4: Colab 배포 URL 매핑 & 문서화] +``` + +### [단계 1] 통합 빌더 스크립트 개발 (`scripts/build_all_notebooks.py`) + +- `scenario_md` 내 `*_정답코드.md` 및 `*_실습자료*.md` 파싱 모듈 +- `scenario_json/*.json` 메타데이터 연동 모듈 (미션 설명 및 가이드 삽입) +- `student_generator.py`를 활용한 학생용 코드 변환 모듈 결합 +- 표준 `.ipynb` (v4 format) JSON 구조 생성 및 파일 저장 + +### [단계 2] 60개 시나리오 일괄 빌드 실행 + +- 총 60개 프로젝트 폴더 생성 (`uploads/{ProjectID}/`) +- 코드형 35개 과제: `solution.ipynb` + `student.ipynb` (70개 파일) +- 워크북형 25개 과제: `workbook.ipynb` (25개 파일) +- **총 95개 `.ipynb` 노트북 생성 완료** + +### [단계 3] 노트북 스키마 및 문법 검증 (QA) + +- `nbformat`을 통한 Jupyter Notebook 표준 포맷 검증 +- 파이썬 `ast.parse()`를 통한 정답 코드 구문 오류 0건 검증 + +### [단계 4] Google Colab 배포 및 활용 가이드 + +- GitHub 저장소 배포 시 일괄 동작하는 Colab 링크 생성 +- `scenario_json`에 노트북 경로 또는 Colab URL 메타데이터 연계 지원 + +--- + +## 6. 변환 실행 및 검증 결과 (Execution Results) + +- **변환 실행 일시**: 2026-10-08 +- **빌드 실행 스크립트**: [`scripts/build_all_notebooks.py`](file:///d:/edunlab/milicontent2/scripts/build_all_notebooks.py) +- **산출물 저장 위치**: [`uploads/{ProjectID}/`](file:///d:/edunlab/milicontent2/uploads) (총 60개 폴더) + +### 6.1 산출물 생성 통계 + +| 실습 유형 | 프로젝트 수 | 생성 파일 유형 | 생성 파일 수 | 대표 프로젝트 | +| :-------------------- | :---------: | :-------------------------------------------- | :----------: | :----------------------------------- | +| **코드 기반 실습** | 35개 | `{ID}_solution.ipynb`
`{ID}_student.ipynb` | 70개 | ADM01, OPS01, ISR01, DAT06, MNT02 등 | +| **SOP/모의훈련 실습** | 25개 | `{ID}_workbook.ipynb` | 25개 | C207, C210, ISR05, LOG04, TRN04 등 | +| **합계** | **60개** | - | **95개** | 전 분야 60개 프로젝트 100% 완료 | + +### 6.2 품질 검증 (QA) 결과 + +| 검증 항목 | 검증 대상 | 검증 기준 | 결과 | +| :--------------------- | :------------------ | :-------------------------------------------- | :-------------: | +| **JSON/스키마 규격** | 95개 노트북 전체 | `nbformat` (v4) 표준 JSON 포맷 준수 여부 | **PASS (100%)** | +| **파이썬 문법성 검증** | 35개 솔루션 코드 셀 | `ast.parse()` 구문 분석 시 SyntaxError 여부 | **PASS (0건)** | +| **Colab 호환성** | 95개 노트북 전체 | `Open In Colab` 뱃지 및 환경설정 셀 포함 여부 | **PASS (100%)** | +| **미션 완결성** | 600개 미션 전체 | 미션 1~10 메타데이터 및 지침 바인딩 여부 | **PASS (100%)** | + +### 6.3 Colab 원클릭 실행 URL 형식 + +```text +# 정답 노트북 (Solution) +https://colab.research.google.com/github/edunlab/milicontent2/blob/main/uploads/{ProjectID}/{ProjectID}_solution.ipynb + +# 학생 실습용 노트북 (Student) +https://colab.research.google.com/github/edunlab/milicontent2/blob/main/uploads/{ProjectID}/{ProjectID}_student.ipynb + +# 작전절차/모의훈련 워크북 (Workbook) +https://colab.research.google.com/github/edunlab/milicontent2/blob/main/uploads/{ProjectID}/{ProjectID}_workbook.ipynb +``` diff --git a/uploads/ADM01/ADM01_solution.ipynb b/uploads/ADM01/ADM01_solution.ipynb new file mode 100644 index 0000000..70bf2ea --- /dev/null +++ b/uploads/ADM01/ADM01_solution.ipynb @@ -0,0 +1,2999 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# 🎯 [ADM01] 결원·근무부하 추세 대시보드와 보충 필요량 예측\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/ADM01/ADM01_solution.ipynb)\n", + "\n", + "---\n", + "\n", + "## 📌 1. 프로젝트 개요 및 배경\n", + "\n", + "| 항목 | 내용 |\n", + "| :--- | :--- |\n", + "| **과정 분류** | 행정·인사 (Level 3) |\n", + "| **학습 소요 시간** | 10개 미션 (5일 / 총 27.5시간) |\n", + "| **환경 사양** | Python 3.10+, numpy, pandas, matplotlib, scipy, scikit-learn |\n", + "\n", + "### 📖 프로젝트 목표\n", + "병력 감소와 전출·파견·휴가·환자·교육이 겹치면 부대 직무별 결원이 갑자기 커지고 남은 인원의 초과근무와 피로가 누적되어 전투력 유지가 어려워지지만, 현재원 표만으로는 향후 결원과 보충 필요량을 미리 보기 어렵다. 본 프로젝트는 러시아-우크라이나 전쟁에서 드러난 인력 소모·피로 관리 문제의식을 교육용 가상 자료로 재구성해, 가상 대대 4곳의 직무 6종 주간 인력 집계 자료로 결원·근무부하 추세를 보고 향후 3개월 보충 필요량을 예측하는 과제이다. 인력 자료 스키마와 근무일지 전처리, 피로도 Baseline, 13주 결원 예측과 위험 지수·신뢰도, 현황 대시보드, 인사관·지휘관 검토 워크플로우, 대량 결원 스트레스 시험, 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. 부대 인사/편제 데이터(현재원, 결원, 휴가, 환자, 교대주기) 데이터셋 스키마 설계\n", + "**소요시간**: 약 1.5시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "부대 인사 데이터(현재원, 결원, 휴가, 환자, 교대주기) 스키마를 설계한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **가상 부대의 직무별 인력 현황(편제·현재원·결원·휴가·환자·교육·보충·전출·초과근무·교대주기) 자료의 구조를 JSON 스키마로 정의하고, 정상 표본 검증 오류 0건과 필드 정의 완성도 100%를 확인한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **가상 대대 4곳과 직무 6종의 주간 집계 자료에서 필드 15개의 자료형·단위·허용 범위·설명·출처를 모두 정의하고 필드 간 논리 규칙(결원은 편제에서 현재원을 뺀 값, 휴가·환자·교육 합계는 현재원 이하) 작성하여 원인을 분석하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `data/schema/adm01_input_schema.json 및 docs/adm01_data_spec.md` 형태의 결과물을 완성한다.\n", + "\n", + "### 🔍 평가 및 검증 기준 (PASS 조건)\n", + "#### 미션 판정\n", + "\n", + "**평가 구분:** 필수\n", + "\n", + "**핵심 평가영역:** 행정·인사 실무 및 러시아-우크라이나 전쟁 전장 환경과의 군사적 부합성\n", + "\n", + "#### 미션 PASS 조건\n", + "\n", + "- 정량 기준: 스키마 필드 정의 완성도 100%, 샘플 데이터 검증 오류 0건\n", + "\n", + "\n", + "- 진행 기준: 입력 데이터 유효성 검증 스크립트 통과 및 명세서 문서화 완료\n", + "\n", + "\n", + "- 검증 방법: JSON Schema 검증 도구 및 샘플 데이터 파싱 단위 테스트\n", + "\n", + "\n", + "#### 채점자 판정 메모\n", + "\n", + "> 단순 코드 실행 여부뿐만 아니라 출력된 수치와 근거 자료가 군 실무 요구사항에 부합하는지 면밀히 확인한다.\n", + "\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "\"\"\"가상 부대 인력 현황(현재원·결원·휴가·환자·교육·보충·전출·초과근무·교대주기, 15개 필드)의 JSON 스키마와 검증 규칙을 정의하고, 정상 표본의 검증 오류 0건·스키마 필드 정의 완성도 100%를 확인하며 형식·범위·논리 규칙 위반 오류를 주입해 검출력과 개인 식별 정보 부재를 점검한다.\"\"\"\n", + "import json\n", + "import re\n", + "import numpy as np\n", + "import pandas as pd\n", + "import matplotlib.pyplot as plt\n", + "\n", + "# ---------------------------------------------------------------------------\n", + "# [교육용가상자료] 가상 부대 인력 현황 생성기 (실제 부대·편제·병력·개인 정보 없음)\n", + "# 가상 대대 4곳 x 직무 6종의 주간 집계 자료만 만든다. 개인 단위 정보(이름·군번 등)는 만들지 않는다.\n", + "# 인원은 직무별 집계 수이며, 결원·휴가·환자·교육·보충·전출을 반영한 현재원과 초과근무 시간으로 구성된다.\n", + "# ---------------------------------------------------------------------------\n", + "UNITS = [\"가상A대대\", \"가상B대대\", \"가상C대대\", \"가상D대대\"]\n", + "JOBS = [\"작전\", \"통신\", \"정비\", \"보급\", \"행정\", \"의무\"]\n", + "FIELDS = [\"unit\", \"job\", \"week\", \"authorized\", \"present\", \"vacancy\", \"leave\", \"sick\", \"training\", \"inflow\", \"outflow\", \"overtime_h\", \"night_ratio\", \"rotation_days\", \"fatigue_label\"]\n", + "HI_CUT = 55 # 고피로 판정 기준(가상 점수 55 이상)\n", + "\n", + "\n", + "def make_manning(seed=7, weeks=104, shock=None):\n", + " \"\"\"부대 x 직무 24개 셀의 주간 인력 자료. shock=(unit, job 또는 None(부대 전체), 시작주, 주간 추가 전출 평균 수) 한 건 또는 그 목록이면 그 셀에 대량 결원 요인을 넣는다(AAR·스트레스용).\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " shocks = [shock] if shock and not isinstance(shock[0], tuple) else list(shock or [])\n", + " rows = []\n", + " for ui, u in enumerate(UNITS):\n", + " for ji, j in enumerate(JOBS):\n", + " auth = int(rng.integers(14, 42) * (0.9 + 0.06 * ui))\n", + " work = rng.uniform(0.92, 1.12) # 직무별 업무량 계수\n", + " net = rng.uniform(-0.05, 0.03) # 구조적 보충 부족(음수)·과잉(양수) 경향(주당 인원)\n", + " present = int(auth * rng.uniform(0.82, 0.98))\n", + " rot = int(rng.choice([60, 90, 120])) # 교대·보충 주기(일)\n", + " for w in range(1, weeks + 1):\n", + " season = 1.0 + 0.9 * (abs(w % 52 - 22) < 4 or abs(w % 52 - 46) < 3) # 전출·전역 집중 시기\n", + " out = int(rng.poisson(0.22 * auth / 20 * season))\n", + " for s_ in shocks:\n", + " if s_[0] == u and (s_[1] is None or s_[1] == j) and w >= s_[2]:\n", + " out += int(rng.poisson(s_[3]))\n", + " inflow = int(rng.poisson(max(0.0, 0.2 * auth / 20 * (1 + net * 10)))) + (int(rng.integers(2, 6) * auth / 25) if w % (rot // 7) == 0 else 0)\n", + " present = int(np.clip(present - out + inflow, int(auth * 0.4), auth))\n", + " leave = int(rng.binomial(present, 0.05 + 0.05 * (abs(w % 52 - 31) < 3 or w % 52 >= 50)))\n", + " sick = int(rng.poisson(0.025 * present)); train = int(rng.binomial(present, 0.04 + 0.06 * (w % 13 == 6)))\n", + " avail = max(present - leave - sick - train, 1)\n", + " night = float(np.clip(rng.normal(0.25 + 0.05 * (j == \"작전\") + 0.05 * (j == \"의무\"), 0.05), 0.05, 0.6))\n", + " ot = float(np.clip(auth * 40 * work / avail - 40 + rng.normal(0, 1.5), 0, 40))\n", + " vac = auth - present\n", + " absent = (leave + sick + train) / max(present, 1)\n", + " fat = 100 * (0.38 * min(vac / auth / 0.25, 1.2) + 0.34 * min(ot / 20, 1.2) + 0.10 * min(night / 0.4, 1.2) + 0.18 * min(absent / 0.2, 1.2)) + rng.normal(0, 5)\n", + " rows.append((u, j, w, auth, present, vac, leave, sick, train, inflow, out, round(ot, 1), round(night, 2), rot, int(fat >= HI_CUT)))\n", + " return pd.DataFrame(rows, columns=FIELDS)\n", + "\n", + "\n", + "def cell_pivot(df, col=\"vacancy\"):\n", + " \"\"\"주 x (부대,직무) 표.\"\"\"\n", + " return df.pivot_table(index=\"week\", columns=[\"unit\", \"job\"], values=col)\n", + "\n", + "# 1) 스키마: 필드마다 자료형·단위·허용 범위·설명·출처를 모두 정의한다(완성도 점검 대상)\n", + "SCHEMA = {\n", + " \"unit\": {\"type\": \"string\", \"enum\": UNITS, \"unit\": \"-\", \"desc\": \"소속 가상 대대\", \"source\": \"인사 편제 집계\"},\n", + " \"job\": {\"type\": \"string\", \"enum\": JOBS, \"unit\": \"-\", \"desc\": \"직무 구분(가상)\", \"source\": \"인사 편제 집계\"},\n", + " \"week\": {\"type\": \"integer\", \"min\": 1, \"max\": 156, \"unit\": \"주\", \"desc\": \"기준 주차\", \"source\": \"주간 인사 보고\"},\n", + " \"authorized\": {\"type\": \"integer\", \"min\": 1, \"max\": 200, \"unit\": \"명\", \"desc\": \"편제 인원\", \"source\": \"인사 편제 집계\"},\n", + " \"present\": {\"type\": \"integer\", \"min\": 0, \"max\": 200, \"unit\": \"명\", \"desc\": \"현재원\", \"source\": \"주간 인사 보고\"},\n", + " \"vacancy\": {\"type\": \"integer\", \"min\": 0, \"max\": 200, \"unit\": \"명\", \"desc\": \"결원 = 편제 - 현재원\", \"source\": \"계산\"},\n", + " \"leave\": {\"type\": \"integer\", \"min\": 0, \"max\": 200, \"unit\": \"명\", \"desc\": \"휴가 인원\", \"source\": \"휴가 현황\"},\n", + " \"sick\": {\"type\": \"integer\", \"min\": 0, \"max\": 200, \"unit\": \"명\", \"desc\": \"환자(진료·입원) 인원, 개인 식별 없이 수만 집계\", \"source\": \"의무 집계\"},\n", + " \"training\": {\"type\": \"integer\", \"min\": 0, \"max\": 200, \"unit\": \"명\", \"desc\": \"교육·파견 인원\", \"source\": \"교육 계획\"},\n", + " \"inflow\": {\"type\": \"integer\", \"min\": 0, \"max\": 100, \"unit\": \"명\", \"desc\": \"주간 보충(전입) 인원\", \"source\": \"인사 명령 집계\"},\n", + " \"outflow\": {\"type\": \"integer\", \"min\": 0, \"max\": 100, \"unit\": \"명\", \"desc\": \"주간 전출·전역 인원\", \"source\": \"인사 명령 집계\"},\n", + " \"overtime_h\": {\"type\": \"number\", \"min\": 0, \"max\": 80, \"unit\": \"시간/인/주\", \"desc\": \"가용 인원 1명당 주간 초과근무 시간\", \"source\": \"근무일지 집계\"},\n", + " \"night_ratio\": {\"type\": \"number\", \"min\": 0, \"max\": 1, \"unit\": \"비율\", \"desc\": \"야간 근무 비율\", \"source\": \"근무일지 집계\"},\n", + " \"rotation_days\": {\"type\": \"integer\", \"min\": 7, \"max\": 365, \"unit\": \"일\", \"desc\": \"교대·보충 주기\", \"source\": \"인사 계획\"},\n", + " \"fatigue_label\": {\"type\": \"integer\", \"enum\": [0, 1], \"unit\": \"-\", \"desc\": \"고피로 여부(검증용 정답 라벨, 가상)\", \"source\": \"가상 평가 기준\"},\n", + "}\n", + "print(f\"[스키마] 필드 {len(SCHEMA)}개, 필수 속성(type·unit·desc·source) 모두 정의된 필드 {sum(all(k in v for k in ('type', 'unit', 'desc', 'source')) for v in SCHEMA.values())}개\")\n", + "assert list(SCHEMA) == FIELDS\n", + "PII = re.compile(r\"name|이름|군번|주민|rrn|phone|전화|address|주소\", re.I)\n", + "print(f\"개인 식별 필드 {sum(bool(PII.search(k + v['desc'])) for k, v in SCHEMA.items())}개(집계 인원 수만 사용)\")\n", + "\n", + "\n", + "def check(rec):\n", + " errs = []\n", + " for k, r in SCHEMA.items():\n", + " if k not in rec or rec[k] is None or (isinstance(rec[k], float) and np.isnan(rec[k])):\n", + " errs.append(f\"{k}: 필수 값 누락\"); continue\n", + " v = rec[k]\n", + " ok_t = {\"string\": isinstance(v, str), \"integer\": isinstance(v, (int, np.integer)) and not isinstance(v, bool), \"number\": isinstance(v, (int, float, np.number)) and not isinstance(v, bool)}[r[\"type\"]]\n", + " if not ok_t:\n", + " errs.append(f\"{k}: 자료형 오류({type(v).__name__})\"); continue\n", + " if \"enum\" in r and v not in r[\"enum\"]:\n", + " errs.append(f\"{k}: 허용되지 않은 값 '{v}'\")\n", + " if \"min\" in r and v < r[\"min\"]:\n", + " errs.append(f\"{k}: 최소값 {r['min']} 미만\")\n", + " if \"max\" in r and v > r[\"max\"]:\n", + " errs.append(f\"{k}: 최대값 {r['max']} 초과\")\n", + " if not errs: # 필드 간 논리 규칙\n", + " if rec[\"vacancy\"] != rec[\"authorized\"] - rec[\"present\"]:\n", + " errs.append(\"vacancy: 편제 - 현재원과 불일치\")\n", + " if rec[\"leave\"] + rec[\"sick\"] + rec[\"training\"] > rec[\"present\"]:\n", + " errs.append(\"leave+sick+training: 현재원 초과\")\n", + " if rec[\"present\"] > rec[\"authorized\"]:\n", + " errs.append(\"present: 편제 인원 초과\")\n", + " return errs\n", + "\n", + "\n", + "df = make_manning(7, 104)\n", + "sample = df[df[\"week\"].isin([10, 50])].to_dict(\"records\") # 24개 셀 x 2주 = 48건 + 전체 점검\n", + "recs = df.to_dict(\"records\")\n", + "bad_all = sum(bool(check(r)) for r in recs)\n", + "bad_sample = sum(bool(check(r)) for r in sample)\n", + "print(f\"[정상 자료 검증] 표본 {len(sample)}건 오류 {bad_sample}건, 전체 {len(recs)}건 오류 {bad_all}건 (목표 오류 0건: {'달성' if bad_all == 0 else '미달'})\")\n", + "\n", + "# 2) 오류 주입 시험\n", + "r0 = recs[100]\n", + "inj = {\"자료형 오류\": dict(r0, authorized=\"서른\"), \"허용값 오류\": dict(r0, job=\"미상직무\"), \"범위 오류(음수 초과근무)\": dict(r0, overtime_h=-4.0), \"논리 오류(결원 불일치)\": dict(r0, vacancy=r0[\"vacancy\"] + 3),\n", + " \"논리 오류(휴가+환자+교육>현재원)\": dict(r0, leave=r0[\"present\"] + 1), \"필드 누락\": {k: v for k, v in r0.items() if k != \"night_ratio\"}, \"결측값\": dict(r0, present=np.nan),\n", + " \"비율 범위 초과\": dict(r0, night_ratio=1.4)}\n", + "print(\"\\n[오류 주입 시험]\")\n", + "caught = 0\n", + "for name, rec in inj.items():\n", + " e = check(rec); caught += bool(e); print(f\" {name:22s}-> {'검출' if e else '미검출'}: {e[0] if e else ''}\")\n", + "print(f\"주입 오류 {len(inj)}종 중 {caught}종 검출\")\n", + "\n", + "# 3) 자료 구조 요약과 입출력 명세\n", + "print(\"\\n[자료 구조 요약] 셀(부대 x 직무) %d개 x 104주 = %d행\" % (df.groupby([\"unit\", \"job\"]).ngroups, len(df)))\n", + "s = df.assign(vr=df[\"vacancy\"] / df[\"authorized\"]).groupby(\"job\").agg(평균편제=(\"authorized\", \"mean\"), 결원율=(\"vr\", \"mean\"), 평균초과근무=(\"overtime_h\", \"mean\"), 고피로비율=(\"fatigue_label\", \"mean\"))\n", + "print(s.round(2).to_string())\n", + "js = json.dumps({\"title\": \"adm01_input_schema\", \"fields\": {k: {kk: vv for kk, vv in v.items() if kk != \"desc\"} for k, v in list(SCHEMA.items())[:3]}}, ensure_ascii=False)\n", + "print(\"스키마 JSON(앞 3개 필드):\", js[:150], \"...\")\n", + "print(\"[입출력] 입력: 주간 직무별 인력 집계 15필드 / 출력: 결원 예측, 피로도·위험 지수, 보충 필요량 추천, 검토·결재 이력\")\n", + "print(\"※ 가상 집계 자료이며 개인 단위 정보는 다루지 않는다. 실제 인사 자료의 외부 AI 서비스 입력은 금지된다.\")\n", + "\n", + "fig, ax = plt.subplots(1, 2, figsize=(10, 4))\n", + "v = df.assign(vr=df[\"vacancy\"] / df[\"authorized\"] * 100).groupby(\"week\")[\"vr\"].mean(); ax[0].plot(v.index, v.values); ax[0].set_title(\"Mean vacancy rate by week (%, synthetic)\"); ax[0].set_xlabel(\"week\")\n", + "ax[1].bar([str(i) for i in range(len(s))], s[\"고피로비율\"] * 100, color=\"tab:orange\"); ax[1].set_title(\"High-fatigue rate by job (index 0-5, %)\")\n", + "plt.tight_layout(); plt.show()\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 01 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + "[스키마] 필드 15개, 필수 속성(type·unit·desc·source) 모두 정의된 필드 15개\n", + "개인 식별 필드 0개(집계 인원 수만 사용)\n", + "[정상 자료 검증] 표본 48건 오류 0건, 전체 2496건 오류 0건 (목표 오류 0건: 달성)\n", + "\n", + "[오류 주입 시험]\n", + " 자료형 오류 -> 검출: authorized: 자료형 오류(str)\n", + " 허용값 오류 -> 검출: job: 허용되지 않은 값 '미상직무'\n", + " 범위 오류(음수 초과근무) -> 검출: overtime_h: 최소값 0 미만\n", + " 논리 오류(결원 불일치) -> 검출: vacancy: 편제 - 현재원과 불일치\n", + " 논리 오류(휴가+환자+교육>현재원) -> 검출: leave+sick+training: 현재원 초과\n", + " 필드 누락 -> 검출: night_ratio: 필수 값 누락\n", + " 결측값 -> 검출: present: 필수 값 누락\n", + " 비율 범위 초과 -> 검출: night_ratio: 최대값 1 초과\n", + "주입 오류 8종 중 8종 검출\n", + "\n", + "[자료 구조 요약] 셀(부대 x 직무) 24개 x 104주 = 2496행\n", + " 평균편제 결원율 평균초과근무 고피로비율\n", + "job \n", + "보급 28.00 0.18 18.81 0.52\n", + "의무 29.25 0.07 8.31 0.25\n", + "작전 35.75 0.06 12.69 0.34\n", + "정비 31.50 0.09 9.95 0.35\n", + "통신 25.75 0.14 16.21 0.50\n", + "행정 32.75 0.06 9.82 0.29\n", + "스키마 JSON(앞 3개 필드): {\"title\": \"adm01_input_schema\", \"fields\": {\"unit\": {\"type\": \"string\", \"enum\": [\"가상A대대\", \"가상B대대\", \"가상C대대\", \"가상D대대\"], \"unit\": \"-\", \"source\": \"인사 편제 집계\"} ...\n", + "[입출력] 입력: 주간 직무별 인력 집계 15필드 / 출력: 결원 예측, 피로도·위험 지수, 보충 필요량 추천, 검토·결재 이력\n", + "※ 가상 집계 자료이며 개인 단위 정보는 다루지 않는다. 실제 인사 자료의 외부 AI 서비스 입력은 금지된다.\n", + "```\n", + "
\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 02. 비정형 근무일지 파싱, 결원 사유 분류 및 근무 시간 정규화 전처리 모듈 구현\n", + "**소요시간**: 약 2.0시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "근무일지 파싱, 결원 사유 분류 및 근무 시간 정규화 전처리 모듈을 구현한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **서식이 제각각인 비정형 근무일지를 파싱해 결원 사유를 분류하고 근무 시간을 시간 단위로 정규화하는 전처리 모듈을 구현한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **시간 표기 5종('12시간 30분', '8h', '22:00~06:00', '오후 2시~익일 오전 6시', '총 9시간')이 섞인 가상 근무일지 6,000줄에서 부대·직무·근무 시간·상황·결원 사유(휴가·환자·교육·파견·전출·기타)를 정규식과 키워드로 뽑는다 과정을 진행하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `src/preprocess/adm01_preprocessor.py 및 전처리 전후 데이터셋` 형태의 결과물을 완성한다.\n", + "\n", + "### 🔍 평가 및 검증 기준 (PASS 조건)\n", + "#### 미션 판정\n", + "\n", + "**평가 구분:** 필수\n", + "\n", + "**핵심 평가영역:** 전술 환경 특유의 노이즈(스푸핑, 반사파, 센서 단절 등) 제거 타당성\n", + "\n", + "#### 미션 PASS 조건\n", + "\n", + "- 정량 기준: 데이터 정제 처리 속도 1,000건/초 이상, 전처리 오류율 0%\n", + "\n", + "\n", + "- 진행 기준: 정제 파이프라인 자동화 및 단위 테스트 케이스 5종 통과\n", + "\n", + "\n", + "- 검증 방법: pytest 기반 전처리 정합성 테스트 및 결측치 보정율 확인\n", + "\n", + "\n", + "#### 채점자 판정 메모\n", + "\n", + "> 단순 코드 실행 여부뿐만 아니라 출력된 수치와 근거 자료가 군 실무 요구사항에 부합하는지 면밀히 확인한다.\n", + "\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "\"\"\"서식이 제각각인 가상 근무일지 문장 6,000줄(시간 표기 5종·결원 사유 표현 다수)을 정규식으로 파싱해 부대·직무·근무 시간(시간 단위)·주야·결원 사유를 표준 필드로 만들고, 주간·야간·훈련주간별 임계값을 자료로 조정해 잡음(이상 값·중복·잘린 줄)을 걸러내며 처리 속도(건/초)와 오류율을 측정한다.\"\"\"\n", + "import re\n", + "import time\n", + "import numpy as np\n", + "import pandas as pd\n", + "import matplotlib.pyplot as plt\n", + "\n", + "UNITS = [\"가상A대대\", \"가상B대대\", \"가상C대대\", \"가상D대대\"]\n", + "JOBS = [\"작전\", \"통신\", \"정비\", \"보급\", \"행정\", \"의무\"]\n", + "REASONS = {\"휴가\": [\"휴가\", \"연가\", \"외박\"], \"환자\": [\"환자\", \"진료\", \"입원\", \"병가\"], \"교육·파견\": [\"교육\", \"파견\", \"훈련 참가\", \"학교\"], \"전출\": [\"전출\", \"전역\", \"전보\"]}\n", + "SAY = {\"휴가\": [\"휴가\", \"연가 사용\", \"외박\"], \"환자\": [\"환자 발생\", \"진료\", \"병가\", \"입원\"], \"교육·파견\": [\"교육 입교\", \"파견\", \"학교 교육\"], \"전출\": [\"전출\", \"전역 예정\", \"전보\"], \"기타\": [\"사유 불명\", \"확인 중\", \"-\"]}\n", + "\n", + "# ---------------------------------------------------------------------------\n", + "# [교육용가상자료] 근무일지 생성(실제 부대·인원 아님). 줄마다 정답(truth)을 함께 만들어 파싱 정확도를 점검한다.\n", + "# ---------------------------------------------------------------------------\n", + "rng = np.random.default_rng(12)\n", + "\n", + "\n", + "def make_logs(n=6000, noise=0.03):\n", + " lines, truth = [], []\n", + " for i in range(n):\n", + " u, j = UNITS[rng.integers(4)], JOBS[rng.integers(6)]\n", + " ctx = rng.choice([\"평시\", \"야간\", \"훈련주간\"], p=[.55, .3, .15])\n", + " base = {\"평시\": 9, \"야간\": 11, \"훈련주간\": 15}[ctx]\n", + " h = round(float(np.clip(rng.normal(base, 1.3), 4, 20)) * 2) / 2\n", + " reason = rng.choice(list(SAY), p=[.3, .2, .2, .15, .15])\n", + " say = rng.choice(SAY[reason])\n", + " fmt = i % 5\n", + " us = u if rng.random() < .6 else u.replace(\"가상\", \"\")\n", + " if fmt == 0:\n", + " t = f\"[근무일지] 2026-{rng.integers(1, 13):02d}-{rng.integers(1, 28):02d} {us} {j} 근무 {int(h)}시간{'30분' if h % 1 else ''}({ctx}) 결원사유: {say}\"\n", + " elif fmt == 1:\n", + " t = f\"{us}/{j}/W{rng.integers(1, 53)} 근무 {h:g}h {ctx} / 사유 {say}\"\n", + " elif fmt == 2:\n", + " st = int(rng.integers(0, 24)); en = (st + int(h)) % 24\n", + " t = f\"{us} {j} 근무 {st:02d}:00~{en:02d}:{'30' if h % 1 else '00'} [{ctx}] 결원: {say}\"\n", + " h = int(h) + (0.5 if h % 1 else 0)\n", + " elif fmt == 3:\n", + " st = int(rng.integers(1, 12)); ap = rng.choice([\"오전\", \"오후\"]); total = st + (12 if ap == \"오후\" else 0) + int(h); en = total % 24\n", + " ap2, en12 = (\"오후\" if en >= 12 else \"오전\"), (en % 12 or 12)\n", + " t = f\"{us} {j} {ap} {st}시~{'익일 ' if total >= 24 else ''}{ap2} {en12}시 근무({ctx}), 결원 원인 {say}\"\n", + " h = float(int(h))\n", + " else:\n", + " t = f\"{us} {j} {ctx} 근무 총 {h:g}시간 사유={say}\"\n", + " truth.append((u, j, h, ctx, reason))\n", + " lines.append(t)\n", + " k = int(n * noise)\n", + " for idx in rng.choice(n, k, replace=False): # 잡음 주입: 이상 값(99시간·0.5시간)·잘린 줄\n", + " has_h = bool(re.search(r\"\\d(?:시간|h\\b)\", lines[idx]))\n", + " if idx % 3 == 0 and has_h:\n", + " lines[idx] = re.sub(r\"\\d+(?:\\.\\d+)?(?=\\s*(?:시간|h\\b))\", \"99\", lines[idx], count=1); truth[idx] = None\n", + " elif idx % 3 == 1 and has_h:\n", + " lines[idx] = re.sub(r\"\\d+(?:\\.\\d+)?(?=\\s*(?:시간|h\\b))\", \"0.5\", lines[idx], count=1); truth[idx] = None\n", + " else:\n", + " lines[idx] = lines[idx][: len(lines[idx]) // 3]; truth[idx] = None\n", + " return lines, truth\n", + "\n", + "\n", + "lines, truth = make_logs()\n", + "lines += lines[:60]; truth += truth[:60] # 중복 줄 60개 추가\n", + "\n", + "# ---------------------------------------------------------------------------\n", + "# 파서: 부대·직무·근무 시간·상황·사유를 줄에서 뽑는다\n", + "# ---------------------------------------------------------------------------\n", + "RE_UNIT = re.compile(r\"(?:가상)?([A-D])대대\")\n", + "RE_JOB = re.compile(\"|\".join(JOBS))\n", + "RE_HM = re.compile(r\"(\\d+(?:\\.\\d+)?)\\s*시간(?:\\s*(\\d+)\\s*분)?\")\n", + "RE_H = re.compile(r\"(\\d+(?:\\.\\d+)?)\\s*h\\b\")\n", + "RE_CLK = re.compile(r\"(\\d{1,2}):(\\d{2})\\s*~\\s*(\\d{1,2}):(\\d{2})\")\n", + "RE_KO = re.compile(r\"(오전|오후)\\s*(\\d{1,2})시\\s*~\\s*(?:익일\\s*)?(오전|오후)\\s*(\\d{1,2})시\")\n", + "RE_CTX = re.compile(r\"훈련주간|야간|평시\")\n", + "\n", + "\n", + "def parse_hours(t):\n", + " m = RE_CLK.search(t)\n", + " if m:\n", + " a, b = int(m[1]) * 60 + int(m[2]), int(m[3]) * 60 + int(m[4])\n", + " return ((b - a) % 1440) / 60\n", + " m = RE_KO.search(t)\n", + " if m:\n", + " a = int(m[2]) % 12 + (12 if m[1] == \"오후\" else 0); e = int(m[4]) % 12 + (12 if m[3] == \"오후\" else 0)\n", + " return float((e - a) % 24)\n", + " m = RE_HM.search(t)\n", + " if m:\n", + " return float(m[1]) + (int(m[2]) / 60 if m[2] else 0)\n", + " m = RE_H.search(t)\n", + " if m:\n", + " return float(m[1])\n", + " raise ValueError(\"시간 표기 해석 불가\")\n", + "\n", + "\n", + "def classify(t):\n", + " tail = re.split(r\"결원사유|결원 원인|결원:|사유\", t)[-1]\n", + " for r, kws in REASONS.items():\n", + " if any(k in tail for k in kws):\n", + " return r\n", + " return \"기타\"\n", + "\n", + "\n", + "def parse(t):\n", + " u, j, c = RE_UNIT.search(t), RE_JOB.search(t), RE_CTX.search(t)\n", + " if not (u and j and c):\n", + " raise ValueError(\"필수 항목(부대·직무·상황) 추출 실패\")\n", + " return {\"unit\": f\"가상{u[1]}대대\", \"job\": j[0], \"hours\": parse_hours(t), \"ctx\": c[0], \"reason\": classify(t)}\n", + "\n", + "\n", + "# 1) 파싱 + 처리 속도\n", + "t0 = time.perf_counter()\n", + "ok, bad = [], []\n", + "for i, t in enumerate(lines):\n", + " try:\n", + " ok.append({**parse(t), \"i\": i})\n", + " except ValueError as e:\n", + " bad.append((i, str(e)))\n", + "dt = time.perf_counter() - t0\n", + "df = pd.DataFrame(ok)\n", + "print(f\"[파싱] {len(lines)}줄 중 성공 {len(df)}줄, 해석 실패(잘린 줄) {len(bad)}줄 -> 격리 / 속도 {len(lines) / dt:,.0f}건/초 (목표 1,000건/초 이상: {'달성' if len(lines) / dt >= 1000 else '미달'})\")\n", + "\n", + "# 2) 중복 정리와 잡음 제거: 상황별(평시·야간·훈련주간) 임계값을 자료의 중앙값±MAD로 조정\n", + "n0 = len(df)\n", + "df = df[~pd.Series([lines[i] for i in df[\"i\"]]).duplicated().to_numpy()]\n", + "print(f\"중복 줄 {n0 - len(df)}개 제거 -> {len(df)}줄\")\n", + "FIXED = 14.0 # 고정 임계값(모든 상황 공통): 14시간 초과는 이상\n", + "fixed_flag = df[\"hours\"] > FIXED\n", + "thr = {}\n", + "for c, g in df[df[\"hours\"] < 30].groupby(\"ctx\"):\n", + " med = g[\"hours\"].median(); mad = (g[\"hours\"] - med).abs().median() * 1.4826\n", + " thr[c] = (med - 4 * mad, med + 4 * mad)\n", + "dyn_flag = pd.Series([not (thr[c][0] <= h <= thr[c][1]) for c, h in zip(df[\"ctx\"], df[\"hours\"])], index=df.index) | (df[\"hours\"] >= 30)\n", + "print(\"상황별 동적 임계값(시간):\", \", \".join(f\"{c} {lo:.1f}~{hi:.1f}\" for c, (lo, hi) in thr.items()))\n", + "tr = [truth[i] for i in df[\"i\"]]\n", + "is_noise = np.array([t is None for t in tr])\n", + "for name, fl in [(\"고정 임계값(14시간)\", fixed_flag.to_numpy()), (\"상황별 동적 임계값\", dyn_flag.to_numpy())]:\n", + " tp = int((fl & is_noise).sum()); fp = int((fl & ~is_noise).sum())\n", + " print(f\" {name:16s}: 이상 판정 {int(fl.sum())}건, 실제 잡음 중 {tp}/{int(is_noise.sum())} 검출, 정상인데 이상 판정(오탐) {fp}건\")\n", + "\n", + "# 3) 파싱 정확도(정답과 비교): 잡음이 아닌 줄에서 시간·사유·부대·직무 일치율\n", + "clean = df[~dyn_flag].copy()\n", + "tc = [truth[i] for i in clean[\"i\"]]\n", + "errs = sum((c[\"unit\"], c[\"job\"], c[\"ctx\"], c[\"reason\"]) != (t[0], t[1], t[3], t[4]) or abs(c[\"hours\"] - t[2]) > 0.01 for (_, c), t in zip(clean.iterrows(), tc) if t is not None)\n", + "n_eval = sum(t is not None for t in tc)\n", + "print(f\"\\n[전처리 정확도] 정제 후 {len(clean)}줄(정답 대조 {n_eval}줄) 중 불일치 {errs}줄 -> 전처리 오류율 {errs / n_eval:.2%} (목표 0%: {'달성' if errs == 0 else '미달'})\")\n", + "print(\"[결원 사유 분류]\", clean[\"reason\"].value_counts().to_dict())\n", + "print(\"[근무 시간 정규화] 상황별 평균 근무 시간:\", clean.groupby(\"ctx\")[\"hours\"].mean().round(1).to_dict())\n", + "agg = clean.groupby([\"unit\", \"job\"]).agg(줄수=(\"hours\", \"size\"), 평균근무시간=(\"hours\", \"mean\")).round(1)\n", + "print(\"[전처리 후 부대·직무 집계 예시]\"); print(agg.head(4).to_string())\n", + "print(\"사람 확인 대상: 해석 실패 %d줄(예: %r), 사유 '기타' %d줄\" % (len(bad), lines[bad[0][0]][:24] if bad else \"\", int((clean[\"reason\"] == \"기타\").sum())))\n", + "print(\"※ 임계값은 상황별 중앙값±4MAD로 자료에서 조정하며, 이상 판정된 줄은 삭제하지 않고 격리해 담당자가 확인한다.\")\n", + "\n", + "plt.figure(figsize=(8, 4))\n", + "for c, g in clean.groupby(\"ctx\"):\n", + " plt.hist(g[\"hours\"], bins=np.arange(2, 22, 1), alpha=.5, label={\"평시\": \"normal\", \"야간\": \"night\", \"훈련주간\": \"training\"}[c])\n", + "plt.axvline(FIXED, color=\"k\", ls=\"--\"); plt.legend(); plt.xlabel(\"duty hours\"); plt.title(\"Normalized duty hours by context (dashed: fixed threshold)\"); plt.tight_layout(); plt.show()\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 02 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + "[파싱] 6060줄 중 성공 5953줄, 해석 실패(잘린 줄) 107줄 -> 격리 / 속도 56,271건/초 (목표 1,000건/초 이상: 달성)\n", + "중복 줄 118개 제거 -> 5835줄\n", + "상황별 동적 임계값(시간): 야간 5.1~16.9, 평시 3.1~14.9, 훈련주간 9.1~20.9\n", + " 고정 임계값(14시간) : 이상 판정 623건, 실제 잡음 중 38/75 검출, 정상인데 이상 판정(오탐) 585건\n", + " 상황별 동적 임계값 : 이상 판정 75건, 실제 잡음 중 75/75 검출, 정상인데 이상 판정(오탐) 0건\n", + "\n", + "[전처리 정확도] 정제 후 5760줄(정답 대조 5760줄) 중 불일치 0줄 -> 전처리 오류율 0.00% (목표 0%: 달성)\n", + "[결원 사유 분류] {'휴가': 1719, '환자': 1175, '교육·파견': 1161, '전출': 889, '기타': 816}\n", + "[근무 시간 정규화] 상황별 평균 근무 시간: {'야간': 11.0, '평시': 8.9, '훈련주간': 14.9}\n", + "[전처리 후 부대·직무 집계 예시]\n", + " 줄수 평균근무시간\n", + "unit job \n", + "가상A대대 보급 226 10.4\n", + " 의무 245 10.5\n", + " 작전 235 10.3\n", + " 정비 239 10.1\n", + "사람 확인 대상: 해석 실패 107줄(예: '가상D대대 통신 근무 2'), 사유 '기타' 816줄\n", + "※ 임계값은 상황별 중앙값±4MAD로 자료에서 조정하며, 이상 판정된 줄은 삭제하지 않고 격리해 담당자가 확인한다.\n", + "```\n", + "
\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 03. 직무별 결원율 및 초과근무 시간 기반 1차 피로도 산출 Baseline 모델 구현\n", + "**소요시간**: 약 2.5시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "직무별 결원율 및 초과근무 기반 피로도 산출 Baseline 모델을 구현한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **직무별 결원율과 초과근무 시간(야간 근무 비율 포함)으로 1차 피로도 점수를 계산하는 Baseline 모델을 만들고 가중치를 1차 튜닝해 정확도를 검증한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **결원율·초과근무·야간 비율을 한도로 나누어 가중합한 피로도 점수(0~100)를 정의하고, 앞 52주로 가중치와 기준 점수를 격자 탐색한 뒤 뒤 52주로 정확도·정밀도·재현율·F1을 시험 수행하고 검증하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `src/models/adm01_baseline_model.py 및 기본 추론 결과 보고서` 형태의 결과물을 완성한다.\n", + "\n", + "### 🔍 평가 및 검증 기준 (PASS 조건)\n", + "#### 미션 판정\n", + "\n", + "**평가 구분:** 필수\n", + "\n", + "**핵심 평가영역:** 알고리즘의 논리적 흐름 및 군사적 판단 기준의 투명성\n", + "\n", + "#### 미션 PASS 조건\n", + "\n", + "- 정량 기준: 기본 데이터셋 대상 탐지/처리 정확도 75% 이상 달성\n", + "\n", + "\n", + "- 진행 기준: 단위 기능 동작 확인 및 기본 시나리오 실행 로그 생성\n", + "\n", + "\n", + "- 검증 방법: 기준 벤치마크 데이터셋 대상 추론 결과 오차율 비교\n", + "\n", + "\n", + "#### 채점자 판정 메모\n", + "\n", + "> 단순 코드 실행 여부뿐만 아니라 출력된 수치와 근거 자료가 군 실무 요구사항에 부합하는지 면밀히 확인한다.\n", + "\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "\"\"\"직무별 결원율·초과근무 시간·야간 근무 비율로 1차 피로도 점수(0~100)를 계산하는 가중합 Baseline을 만들고, 초기 가중치와 격자 탐색으로 1차 튜닝한 가중치의 정확도·정밀도·재현율·판단 지연시간을 시험 기간 자료로 검증하며 부대·직무 셀별 피로도 순위와 판단 근거를 출력한다.\"\"\"\n", + "import time\n", + "import itertools\n", + "import numpy as np\n", + "import pandas as pd\n", + "import matplotlib.pyplot as plt\n", + "\n", + "# ---------------------------------------------------------------------------\n", + "# [교육용가상자료] 가상 부대 인력 현황 생성기 (실제 부대·편제·병력·개인 정보 없음)\n", + "# 가상 대대 4곳 x 직무 6종의 주간 집계 자료만 만든다. 개인 단위 정보(이름·군번 등)는 만들지 않는다.\n", + "# 인원은 직무별 집계 수이며, 결원·휴가·환자·교육·보충·전출을 반영한 현재원과 초과근무 시간으로 구성된다.\n", + "# ---------------------------------------------------------------------------\n", + "UNITS = [\"가상A대대\", \"가상B대대\", \"가상C대대\", \"가상D대대\"]\n", + "JOBS = [\"작전\", \"통신\", \"정비\", \"보급\", \"행정\", \"의무\"]\n", + "FIELDS = [\"unit\", \"job\", \"week\", \"authorized\", \"present\", \"vacancy\", \"leave\", \"sick\", \"training\", \"inflow\", \"outflow\", \"overtime_h\", \"night_ratio\", \"rotation_days\", \"fatigue_label\"]\n", + "HI_CUT = 55 # 고피로 판정 기준(가상 점수 55 이상)\n", + "\n", + "\n", + "def make_manning(seed=7, weeks=104, shock=None):\n", + " \"\"\"부대 x 직무 24개 셀의 주간 인력 자료. shock=(unit, job 또는 None(부대 전체), 시작주, 주간 추가 전출 평균 수) 한 건 또는 그 목록이면 그 셀에 대량 결원 요인을 넣는다(AAR·스트레스용).\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " shocks = [shock] if shock and not isinstance(shock[0], tuple) else list(shock or [])\n", + " rows = []\n", + " for ui, u in enumerate(UNITS):\n", + " for ji, j in enumerate(JOBS):\n", + " auth = int(rng.integers(14, 42) * (0.9 + 0.06 * ui))\n", + " work = rng.uniform(0.92, 1.12) # 직무별 업무량 계수\n", + " net = rng.uniform(-0.05, 0.03) # 구조적 보충 부족(음수)·과잉(양수) 경향(주당 인원)\n", + " present = int(auth * rng.uniform(0.82, 0.98))\n", + " rot = int(rng.choice([60, 90, 120])) # 교대·보충 주기(일)\n", + " for w in range(1, weeks + 1):\n", + " season = 1.0 + 0.9 * (abs(w % 52 - 22) < 4 or abs(w % 52 - 46) < 3) # 전출·전역 집중 시기\n", + " out = int(rng.poisson(0.22 * auth / 20 * season))\n", + " for s_ in shocks:\n", + " if s_[0] == u and (s_[1] is None or s_[1] == j) and w >= s_[2]:\n", + " out += int(rng.poisson(s_[3]))\n", + " inflow = int(rng.poisson(max(0.0, 0.2 * auth / 20 * (1 + net * 10)))) + (int(rng.integers(2, 6) * auth / 25) if w % (rot // 7) == 0 else 0)\n", + " present = int(np.clip(present - out + inflow, int(auth * 0.4), auth))\n", + " leave = int(rng.binomial(present, 0.05 + 0.05 * (abs(w % 52 - 31) < 3 or w % 52 >= 50)))\n", + " sick = int(rng.poisson(0.025 * present)); train = int(rng.binomial(present, 0.04 + 0.06 * (w % 13 == 6)))\n", + " avail = max(present - leave - sick - train, 1)\n", + " night = float(np.clip(rng.normal(0.25 + 0.05 * (j == \"작전\") + 0.05 * (j == \"의무\"), 0.05), 0.05, 0.6))\n", + " ot = float(np.clip(auth * 40 * work / avail - 40 + rng.normal(0, 1.5), 0, 40))\n", + " vac = auth - present\n", + " absent = (leave + sick + train) / max(present, 1)\n", + " fat = 100 * (0.38 * min(vac / auth / 0.25, 1.2) + 0.34 * min(ot / 20, 1.2) + 0.10 * min(night / 0.4, 1.2) + 0.18 * min(absent / 0.2, 1.2)) + rng.normal(0, 5)\n", + " rows.append((u, j, w, auth, present, vac, leave, sick, train, inflow, out, round(ot, 1), round(night, 2), rot, int(fat >= HI_CUT)))\n", + " return pd.DataFrame(rows, columns=FIELDS)\n", + "\n", + "\n", + "def cell_pivot(df, col=\"vacancy\"):\n", + " \"\"\"주 x (부대,직무) 표.\"\"\"\n", + " return df.pivot_table(index=\"week\", columns=[\"unit\", \"job\"], values=col)\n", + "\n", + "df = make_manning(7, 104)\n", + "df[\"vac_rate\"] = df[\"vacancy\"] / df[\"authorized\"]\n", + "tr, te = df[df[\"week\"] <= 52], df[df[\"week\"] > 52] # 앞 52주 학습·튜닝 / 뒤 52주 시험\n", + "X = lambda d: np.column_stack([np.minimum(d[\"vac_rate\"] / 0.25, 1.2), np.minimum(d[\"overtime_h\"] / 20, 1.2), np.minimum(d[\"night_ratio\"] / 0.4, 1.2)])\n", + "\n", + "\n", + "def score(d, w):\n", + " \"\"\"피로도 점수 = 100 x (w1 x 결원 부담 + w2 x 초과근무 부담 + w3 x 야간 부담).\"\"\"\n", + " return np.minimum(100 * X(d) @ np.asarray(w), 100)\n", + "\n", + "\n", + "def metrics(y, p):\n", + " tp, fp, fn = int(((p == 1) & (y == 1)).sum()), int(((p == 1) & (y == 0)).sum()), int(((p == 0) & (y == 1)).sum())\n", + " pr, rc = tp / max(tp + fp, 1), tp / max(tp + fn, 1)\n", + " return {\"acc\": float((p == y).mean()), \"prec\": pr, \"rec\": rc, \"f1\": 2 * pr * rc / max(pr + rc, 1e-9)}\n", + "\n", + "\n", + "W0, CUT0 = (1 / 3, 1 / 3, 1 / 3), 50 # 초기(동일 가중) 휴리스틱\n", + "rule0 = ((te[\"vac_rate\"] >= 0.20) | (te[\"overtime_h\"] >= 15)).astype(int).to_numpy() # 가장 단순한 규칙: 결원율 20% 이상 또는 초과근무 15시간 이상\n", + "y_tr, y_te = tr[\"fatigue_label\"].to_numpy(), te[\"fatigue_label\"].to_numpy()\n", + "m0 = metrics(y_te, (score(te, W0) >= CUT0).astype(int))\n", + "trivial = float((y_te == 0).mean())\n", + "print(f\"[Baseline] 학습 {len(tr)}행 / 시험 {len(te)}행, 고피로 비율 {y_te.mean():.1%} (모두 '정상'이라 답해도 정확도 {trivial:.1%})\")\n", + "mr = metrics(y_te, rule0)\n", + "print(f\"단순 규칙(결원율 20% 이상 또는 초과근무 15시간 이상): 정확도 {mr['acc']:.1%}, 정밀도 {mr['prec']:.1%}, 재현율 {mr['rec']:.1%}, F1 {mr['f1']:.2f}\")\n", + "print(f\"초기 가중(동일 1/3, 기준 {CUT0}점): 정확도 {m0['acc']:.1%}, 정밀도 {m0['prec']:.1%}, 재현율 {m0['rec']:.1%}, F1 {m0['f1']:.2f}\")\n", + "\n", + "# 1차 튜닝: 가중치(합 1)와 기준 점수를 학습 기간에서 F1이 최대가 되게 격자 탐색\n", + "best = None\n", + "Xtr = X(tr)\n", + "for w1, w2 in itertools.product(np.arange(0.0, 1.01, 0.1), repeat=2):\n", + " if w1 + w2 > 1.0001:\n", + " continue\n", + " w = np.array([w1, w2, 1 - w1 - w2]); s = 100 * Xtr @ w\n", + " for cut in range(35, 71, 5):\n", + " f = metrics(y_tr, (s >= cut).astype(int))[\"f1\"]\n", + " if best is None or f > best[0]:\n", + " best = (f, tuple(float(x) for x in np.round(w, 1)), cut)\n", + "W1, CUT1 = best[1], best[2]\n", + "m1 = metrics(y_te, (score(te, W1) >= CUT1).astype(int))\n", + "print(f\"튜닝 가중 {W1}(결원·초과근무·야간), 기준 {CUT1}점 (학습 F1 {best[0]:.2f})\")\n", + "print(f\"시험 기간: 정확도 {m1['acc']:.1%}, 정밀도 {m1['prec']:.1%}, 재현율 {m1['rec']:.1%}, F1 {m1['f1']:.2f} (목표 정확도 75% 이상: {'달성' if m1['acc'] >= 0.75 else '미달'}, 단 단순 기준선 {trivial:.1%}보다 {'높음' if m1['acc'] > trivial else '낮음'})\")\n", + "\n", + "# 판단 지연시간: 1건씩 계산(실시간 조회 상황)\n", + "rec = te.to_dict(\"records\")\n", + "t0 = time.perf_counter()\n", + "for r in rec[:1000]:\n", + " s = min(100 * (W1[0] * min(r[\"vac_rate\"] / .25, 1.2) + W1[1] * min(r[\"overtime_h\"] / 20, 1.2) + W1[2] * min(r[\"night_ratio\"] / .4, 1.2)), 100); _ = s >= CUT1\n", + "lat = (time.perf_counter() - t0) / 1000 * 1000\n", + "print(f\"판단 지연시간 평균 {lat:.4f}ms/건\")\n", + "\n", + "# 2) 직무별·셀별 피로도 순위 (최근 12주 평균)\n", + "last = df[df[\"week\"] > 92].copy(); last[\"fatigue\"] = score(last, W1)\n", + "by_job = last.groupby(\"job\")[\"fatigue\"].mean().sort_values(ascending=False)\n", + "print(\"\\n[직무별 평균 피로도(최근 12주)]\", \", \".join(f\"{k} {v:.0f}\" for k, v in by_job.items()))\n", + "cell = last.groupby([\"unit\", \"job\"]).agg(피로도=(\"fatigue\", \"mean\"), 결원율=(\"vac_rate\", \"mean\"), 초과근무=(\"overtime_h\", \"mean\"), 야간=(\"night_ratio\", \"mean\")).sort_values(\"피로도\", ascending=False)\n", + "print(\"[피로도 상위 5개 셀과 판단 근거(기여 점수)]\")\n", + "for (u, j), r in cell.head(5).iterrows():\n", + " c = 100 * np.array(W1) * np.minimum(np.array([r[\"결원율\"] / .25, r[\"초과근무\"] / 20, r[\"야간\"] / .4]), 1.2)\n", + " print(f\" {u} {j}: 피로도 {r['피로도']:.0f} = 결원 {c[0]:.0f} + 초과근무 {c[1]:.0f} + 야간 {c[2]:.0f} (결원율 {r['결원율']:.0%}, 초과근무 {r['초과근무']:.1f}h)\")\n", + "n_hi = int((cell[\"피로도\"] >= CUT1).sum())\n", + "print(f\"기준 {CUT1}점 이상 셀 {n_hi}/24개 -> 인사관이 우선 확인. 점수는 판단 보조이며 휴식·교대 조정은 지휘관·인사관이 결정한다.\")\n", + "\n", + "# 3) 오분류 분석 (튜닝 가중치 시험 기간)\n", + "pred = (score(te, W1) >= CUT1).astype(int)\n", + "fn = te[(pred == 0) & (y_te == 1)]\n", + "print(f\"\\n[미탐 {len(fn)}건 특징] 평균 결원율 {fn['vac_rate'].mean():.0%}, 초과근무 {fn['overtime_h'].mean():.1f}h, 야간 {fn['night_ratio'].mean():.0%} -> 점수 경계 부근 사례(라벨에는 휴가·환자·교육 인원 비율과 잡음도 반영되어 이 3개 변수만으로는 설명되지 않음)\")\n", + "\n", + "plt.figure(figsize=(7, 4))\n", + "plt.scatter(te[\"vac_rate\"] * 100, te[\"overtime_h\"], c=te[\"fatigue_label\"], cmap=\"coolwarm\", s=8, alpha=.6)\n", + "plt.xlabel(\"vacancy rate (%)\"); plt.ylabel(\"overtime hours/person/week\"); plt.title(\"High-fatigue label (red) by vacancy and overtime\"); plt.tight_layout(); plt.show()\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 03 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + "[Baseline] 학습 1248행 / 시험 1248행, 고피로 비율 34.8% (모두 '정상'이라 답해도 정확도 65.2%)\n", + "단순 규칙(결원율 20% 이상 또는 초과근무 15시간 이상): 정확도 92.7%, 정밀도 96.7%, 재현율 81.8%, F1 0.89\n", + "초기 가중(동일 1/3, 기준 50점): 정확도 89.7%, 정밀도 78.9%, 재현율 95.9%, F1 0.87\n", + "튜닝 가중 (0.3, 0.6, 0.1)(결원·초과근무·야간), 기준 55점 (학습 F1 0.91)\n", + "시험 기간: 정확도 94.1%, 정밀도 91.9%, 재현율 91.0%, F1 0.91 (목표 정확도 75% 이상: 달성, 단 단순 기준선 65.2%보다 높음)\n", + "판단 지연시간 평균 0.0051ms/건\n", + "\n", + "[직무별 평균 피로도(최근 12주)] 보급 68, 통신 64, 정비 52, 작전 51, 의무 44, 행정 42\n", + "[피로도 상위 5개 셀과 판단 근거(기여 점수)]\n", + " 가상B대대 보급: 피로도 100 = 결원 36 + 초과근무 72 + 야간 6 (결원율 57%, 초과근무 40.0h)\n", + " 가상C대대 통신: 피로도 100 = 결원 36 + 초과근무 72 + 야간 7 (결원율 49%, 초과근무 39.9h)\n", + " 가상A대대 정비: 피로도 96 = 결원 34 + 초과근무 71 + 야간 6 (결원율 28%, 초과근무 23.6h)\n", + " 가상A대대 보급: 피로도 83 = 결원 20 + 초과근무 61 + 야간 6 (결원율 17%, 초과근무 20.3h)\n", + " 가상D대대 통신: 피로도 82 = 결원 19 + 초과근무 68 + 야간 6 (결원율 16%, 초과근무 22.5h)\n", + "기준 55점 이상 셀 10/24개 -> 인사관이 우선 확인. 점수는 판단 보조이며 휴식·교대 조정은 지휘관·인사관이 결정한다.\n", + "\n", + "[미탐 39건 특징] 평균 결원율 7%, 초과근무 11.7h, 야간 26% -> 점수 경계 부근 사례(라벨에는 휴가·환자·교육 인원 비율과 잡음도 반영되어 이 3개 변수만으로는 설명되지 않음)\n", + "```\n", + "
\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 04. 시계열 분석 기반 부서별 향후 3개월 결원 추세 및 보충 필요 인력 예측 모델 고도화\n", + "**소요시간**: 약 3.0시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "시계열 분석 기반 향후 3개월 결원 추세 및 보충 필요량 예측 모델을 고도화한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **시계열 분석으로 부대·직무별 향후 3개월(13주) 결원 추세를 예측하고 보충 필요 인원을 산출하는 모델을 고도화해 보충 필요 셀 탐지 재현율을 검증한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **24개 셀의 주간 결원율을 단순 예측, 릿지 회귀(특징 재설계 전후), 교대 보충·전출 집중기를 반영한 재고-흐름 모의(300경로)로 예측해 4개 시점 롤링 검증으로 오차를 비교 수행하고 검증하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `src/models/adm01_advanced_detector.py 및 예외 탐지 테스트 결과` 형태의 결과물을 완성한다.\n", + "\n", + "### 🔍 평가 및 검증 기준 (PASS 조건)\n", + "#### 미션 판정\n", + "\n", + "**평가 구분:** 필수\n", + "\n", + "**핵심 평가영역:** 기만/교란 공격에 대한 방어 로직의 현실성 및 신뢰성\n", + "\n", + "#### 미션 PASS 조건\n", + "\n", + "- 정량 기준: 복합 위협 탐지 재현율(Recall) 85% 이상, 처리 지연 200ms 이하\n", + "\n", + "\n", + "- 진행 기준: 복합 위협 시나리오 10종에 대한 탐지 테스트 통과\n", + "\n", + "\n", + "- 검증 방법: 합성 위협 데이터셋 주입 시 이상치 탐지 정확도 검증\n", + "\n", + "\n", + "#### 채점자 판정 메모\n", + "\n", + "> 단순 코드 실행 여부뿐만 아니라 출력된 수치와 근거 자료가 군 실무 요구사항에 부합하는지 면밀히 확인한다.\n", + "\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "\"\"\"부대·직무 24개 셀의 주간 결원율을 단순 예측·릿지 회귀(특징 재설계 v1→v2)·재고-흐름 모의(교대 보충·전출 집중기 반영, 300경로)로 향후 13주(3개월) 예측해 4개 시점 롤링 검증으로 비교하고, 보충 필요 셀 탐지의 재현율·정밀도 균형(확률 기준 선택)과 처리 지연을 측정해 보충 필요 인원을 산출한다.\"\"\"\n", + "import time\n", + "import numpy as np\n", + "import pandas as pd\n", + "import matplotlib.pyplot as plt\n", + "\n", + "# ---------------------------------------------------------------------------\n", + "# [교육용가상자료] 가상 부대 인력 현황 생성기 (실제 부대·편제·병력·개인 정보 없음)\n", + "# 가상 대대 4곳 x 직무 6종의 주간 집계 자료만 만든다. 개인 단위 정보(이름·군번 등)는 만들지 않는다.\n", + "# 인원은 직무별 집계 수이며, 결원·휴가·환자·교육·보충·전출을 반영한 현재원과 초과근무 시간으로 구성된다.\n", + "# ---------------------------------------------------------------------------\n", + "UNITS = [\"가상A대대\", \"가상B대대\", \"가상C대대\", \"가상D대대\"]\n", + "JOBS = [\"작전\", \"통신\", \"정비\", \"보급\", \"행정\", \"의무\"]\n", + "FIELDS = [\"unit\", \"job\", \"week\", \"authorized\", \"present\", \"vacancy\", \"leave\", \"sick\", \"training\", \"inflow\", \"outflow\", \"overtime_h\", \"night_ratio\", \"rotation_days\", \"fatigue_label\"]\n", + "HI_CUT = 55 # 고피로 판정 기준(가상 점수 55 이상)\n", + "\n", + "\n", + "def make_manning(seed=7, weeks=104, shock=None):\n", + " \"\"\"부대 x 직무 24개 셀의 주간 인력 자료. shock=(unit, job 또는 None(부대 전체), 시작주, 주간 추가 전출 평균 수) 한 건 또는 그 목록이면 그 셀에 대량 결원 요인을 넣는다(AAR·스트레스용).\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " shocks = [shock] if shock and not isinstance(shock[0], tuple) else list(shock or [])\n", + " rows = []\n", + " for ui, u in enumerate(UNITS):\n", + " for ji, j in enumerate(JOBS):\n", + " auth = int(rng.integers(14, 42) * (0.9 + 0.06 * ui))\n", + " work = rng.uniform(0.92, 1.12) # 직무별 업무량 계수\n", + " net = rng.uniform(-0.05, 0.03) # 구조적 보충 부족(음수)·과잉(양수) 경향(주당 인원)\n", + " present = int(auth * rng.uniform(0.82, 0.98))\n", + " rot = int(rng.choice([60, 90, 120])) # 교대·보충 주기(일)\n", + " for w in range(1, weeks + 1):\n", + " season = 1.0 + 0.9 * (abs(w % 52 - 22) < 4 or abs(w % 52 - 46) < 3) # 전출·전역 집중 시기\n", + " out = int(rng.poisson(0.22 * auth / 20 * season))\n", + " for s_ in shocks:\n", + " if s_[0] == u and (s_[1] is None or s_[1] == j) and w >= s_[2]:\n", + " out += int(rng.poisson(s_[3]))\n", + " inflow = int(rng.poisson(max(0.0, 0.2 * auth / 20 * (1 + net * 10)))) + (int(rng.integers(2, 6) * auth / 25) if w % (rot // 7) == 0 else 0)\n", + " present = int(np.clip(present - out + inflow, int(auth * 0.4), auth))\n", + " leave = int(rng.binomial(present, 0.05 + 0.05 * (abs(w % 52 - 31) < 3 or w % 52 >= 50)))\n", + " sick = int(rng.poisson(0.025 * present)); train = int(rng.binomial(present, 0.04 + 0.06 * (w % 13 == 6)))\n", + " avail = max(present - leave - sick - train, 1)\n", + " night = float(np.clip(rng.normal(0.25 + 0.05 * (j == \"작전\") + 0.05 * (j == \"의무\"), 0.05), 0.05, 0.6))\n", + " ot = float(np.clip(auth * 40 * work / avail - 40 + rng.normal(0, 1.5), 0, 40))\n", + " vac = auth - present\n", + " absent = (leave + sick + train) / max(present, 1)\n", + " fat = 100 * (0.38 * min(vac / auth / 0.25, 1.2) + 0.34 * min(ot / 20, 1.2) + 0.10 * min(night / 0.4, 1.2) + 0.18 * min(absent / 0.2, 1.2)) + rng.normal(0, 5)\n", + " rows.append((u, j, w, auth, present, vac, leave, sick, train, inflow, out, round(ot, 1), round(night, 2), rot, int(fat >= HI_CUT)))\n", + " return pd.DataFrame(rows, columns=FIELDS)\n", + "\n", + "\n", + "def cell_pivot(df, col=\"vacancy\"):\n", + " \"\"\"주 x (부대,직무) 표.\"\"\"\n", + " return df.pivot_table(index=\"week\", columns=[\"unit\", \"job\"], values=col)\n", + "\n", + "# ---- 결원율 예측 모델(주간 결원율 변화량을 릿지 회귀로 맞히고 13주까지 재귀 적용, 외부 시계열 라이브러리 없이) ----\n", + "from sklearn.linear_model import Ridge\n", + "H = 13 # 예측 기간: 향후 3개월(13주)\n", + "NEED_CUT = 0.15 # 결원율 15% 이상이면 보충 필요(가상 기준)\n", + "\n", + "\n", + "def season(w):\n", + " return float(abs(w % 52 - 22) < 4 or abs(w % 52 - 46) < 3)\n", + "\n", + "\n", + "def feat(vr, ir, orr, wn, rot, v2=True):\n", + " \"\"\"vr·ir·orr: 지금까지의 결원율·보충율·전출율 목록, wn: 예측할 다음 주차, rot: 교대 주기(일). v2는 보충·전출 흐름과 달력 특징을 추가한다.\"\"\"\n", + " f = [vr[-1], vr[-1] - vr[-2], vr[-1] - vr[-4]]\n", + " if v2:\n", + " step = max(rot // 7, 1)\n", + " f += [float(np.mean(orr[-12:]) - np.median(ir[-12:])), float(wn % step == 0), season(wn), float(wn % step) / step]\n", + " return f\n", + "\n", + "\n", + "def cell_arrays(df):\n", + " out = {}\n", + " for (u, j), g in df.sort_values(\"week\").groupby([\"unit\", \"job\"]):\n", + " a = g[\"authorized\"].to_numpy()\n", + " out[(u, j)] = {\"auth\": int(a[0]), \"rot\": int(g[\"rotation_days\"].iloc[0]), \"vr\": (g[\"vacancy\"].to_numpy() / a), \"ir\": g[\"inflow\"].to_numpy() / a, \"or\": g[\"outflow\"].to_numpy() / a}\n", + " return out\n", + "\n", + "\n", + "def fit_fc(cells, origin, v2=True, alpha=1.0):\n", + " X, y = [], []\n", + " for c in cells.values():\n", + " for t in range(12, origin - 1): # t주까지 알 때 t+1주(인덱스 t)의 결원율 변화를 맞힌다\n", + " X.append(feat(c[\"vr\"][:t], c[\"ir\"][:t], c[\"or\"][:t], t + 1, c[\"rot\"], v2)); y.append(c[\"vr\"][t] - c[\"vr\"][t - 1])\n", + " return Ridge(alpha=alpha).fit(np.array(X), np.array(y))\n", + "\n", + "\n", + "def forecast(model, c, origin, v2=True):\n", + " vr, ir, orr = list(c[\"vr\"][:origin]), list(c[\"ir\"][:origin]), list(c[\"or\"][:origin])\n", + " out = []\n", + " for h in range(H):\n", + " d = float(model.predict([feat(vr, ir, orr, origin + h + 1, c[\"rot\"], v2)])[0])\n", + " p = float(np.clip(vr[-1] + d, 0, 1))\n", + " out.append(p); vr.append(p); ir.append(float(np.median(ir[-12:]))); orr.append(float(np.mean(orr[-12:])))\n", + " return np.array(out)\n", + "\n", + "\n", + "def mc_forecast(c, origin, n_path=300, seed=0, out_mult=1.0, lam=0.0):\n", + " \"\"\"재고-흐름(stock-flow) 모의: 셀의 평시·전출 집중기 전출율, 정규 보충율, 교대 주기 보충 규모를 과거에서 추정해 결원 경로 n_path개를 만든다.\n", + " 반환: (경로 배열 [n_path, H] 결원율, 추정 모수). out_mult는 전출 가정을 키우는 보정 계수, lam은 최근 8주 전출율을 반영하는 비중(0~1, 급증 감지 때만 적용, AAR 재보정용).\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " w = np.arange(1, origin + 1); step = max(c[\"rot\"] // 7, 1)\n", + " rot_w = (w % step == 0); sea = np.array([season(x) > 0 for x in w])\n", + " o, i = c[\"or\"][:origin], c[\"ir\"][:origin]\n", + " o_base = o[~sea].mean(); o_sea = o[sea].mean() if sea.sum() >= 6 else o_base * 1.9\n", + " o_rec = o[-8:].mean()\n", + " mu = max(o_base, 1e-6) * c[\"auth\"] * 8 # 최근 8주 평시 기대 전출 인원\n", + " use_rec = lam > 0 and o_rec * c[\"auth\"] * 8 > mu + 2.0 * np.sqrt(mu) # 포아송 기준 상위 2표준편차를 넘는 '급증'일 때만 최근 값을 반영\n", + " i_reg = i[~rot_w].mean(); batch = max(i[rot_w].mean() - i_reg, 0.0)\n", + " a = c[\"auth\"]; pres = np.full(n_path, a * (1 - c[\"vr\"][origin - 1]))\n", + " out = np.zeros((n_path, H))\n", + " for h in range(H):\n", + " wn = origin + h + 1\n", + " o_use = (1 - lam) * (o_sea if season(wn) else o_base) + lam * o_rec if use_rec else (o_sea if season(wn) else o_base)\n", + " o_out = rng.poisson(max(o_use, 1e-6) * a * out_mult, n_path)\n", + " o_in = rng.poisson(max(i_reg, 1e-6) * a, n_path) + (np.maximum(rng.normal(batch * a, 0.3 * batch * a, n_path), 0).round() if wn % step == 0 else 0)\n", + " pres = np.clip(pres - o_out + o_in, 0.4 * a, a)\n", + " out[:, h] = 1 - pres / a\n", + " return out, {\"o_base\": o_base, \"o_sea\": o_sea, \"i_reg\": i_reg, \"batch\": batch}\n", + "\n", + "df = make_manning(7, 104)\n", + "cells = cell_arrays(df)\n", + "ORIGINS = [52, 65, 78, 91] # 예측 시점(그 주까지의 자료만 사용)\n", + "res = []\n", + "lat, lat3 = [], []\n", + "for o in ORIGINS:\n", + " m1, m2 = fit_fc(cells, o, v2=False), fit_fc(cells, o, v2=True)\n", + " for k, c in cells.items():\n", + " act = c[\"vr\"][o:o + H]\n", + " t0 = time.perf_counter(); p2 = forecast(m2, c, o, True); lat.append((time.perf_counter() - t0) * 1000)\n", + " p1 = forecast(m1, c, o, False)\n", + " naive = np.full(H, c[\"vr\"][o - 1])\n", + " t0 = time.perf_counter(); paths, _ = mc_forecast(c, o); lat3.append((time.perf_counter() - t0) * 1000)\n", + " res.append({\"origin\": o, \"cell\": k, \"auth\": c[\"auth\"], \"act_peak\": act.max(), \"act_end\": act[-1], \"naive\": naive, \"v1\": p1, \"v2\": p2, \"v3\": paths.mean(0), \"p_need\": float((paths.max(axis=1) >= NEED_CUT).mean()), \"act\": act})\n", + "R = pd.DataFrame(res)\n", + "mae = lambda col: float(np.mean([np.abs(r[col] - r[\"act\"]).mean() * r[\"auth\"] for _, r in R.iterrows()]))\n", + "print(f\"[롤링 검증] 시점 {ORIGINS} x 셀 {len(cells)}개 = {len(R)}건, 예측 13주\")\n", + "print(f\"결원 인원 MAE: 단순 예측(마지막 값 유지) {mae('naive'):.2f}명 / 회귀 v1 {mae('v1'):.2f}명 / 회귀 v2(특징 재설계) {mae('v2'):.2f}명 / 재고-흐름 모의 평균 {mae('v3'):.2f}명\")\n", + "print(f\"예측 지연: 회귀 셀 1개 평균 {np.mean(lat):.1f}ms, 모의 300경로 평균 {np.mean(lat3):.1f}ms/99백분위 {np.percentile(lat3, 99):.1f}ms (24개 셀 전체 {np.mean(lat3) * 24:.0f}ms, 목표 200ms 이하: {'달성' if np.percentile(lat3, 99) <= 200 else '미달'})\")\n", + "\n", + "# 보충 필요 셀 탐지: 향후 13주 중 결원율이 NEED_CUT 이상이 되는가\n", + "act_flag = R[\"act_peak\"] >= NEED_CUT\n", + "def det(pred):\n", + " pred = np.asarray(pred); tp, fp, fn = int((pred & act_flag).sum()), int((pred & ~act_flag).sum()), int((~pred & act_flag).sum())\n", + " return tp / max(tp + fn, 1), tp / max(tp + fp, 1), fp\n", + "print(f\"\\n[보충 필요(결원율 {NEED_CUT:.0%} 이상) 탐지] 실제 해당 {int(act_flag.sum())}건 / {len(R)}건\")\n", + "rows = [(\"단순 예측\", [r[\"naive\"].max() >= NEED_CUT for _, r in R.iterrows()]), (\"회귀 v1\", [r[\"v1\"].max() >= NEED_CUT for _, r in R.iterrows()]), (\"회귀 v2\", [r[\"v2\"].max() >= NEED_CUT for _, r in R.iterrows()])]\n", + "rows += [(f\"모의 확률 {p:.2f} 이상\", (R[\"p_need\"] >= p).to_list()) for p in (0.5, 0.3, 0.15)]\n", + "for name, pred in rows:\n", + " rc, pr, fp = det(pred); print(f\" {name:16s} 재현율 {rc:5.1%}, 정밀도 {pr:5.1%}, 오경보 {fp}건\")\n", + "P0 = 0.15\n", + "rc, pr, fp = det((R[\"p_need\"] >= P0).to_list())\n", + "print(f\"-> 선택: 모의 확률 {P0} 이상을 '보충 필요 가능성'으로 표시, 재현율 {rc:.1%} (목표 85% 이상: {'달성' if rc >= 0.85 else '미달'}), 대신 정밀도 {pr:.0%}로 오경보 {fp}건을 사람이 걸러야 함\")\n", + "\n", + "# 오경보 패턴: 선택 기준의 오경보가 어디에 몰리는가\n", + "fa = R[(R[\"p_need\"] >= P0) & ~act_flag]\n", + "near = float((fa[\"act_peak\"] >= 0.10).mean()) if len(fa) else 0.0\n", + "print(f\"[오경보 패턴] 오경보 {len(fa)}건 중 실제 최대 결원율이 10~15%인 경계 근처 사례 {near:.0%} -> 교대 보충 도착 시점·전출 규모의 불확실성이 원인, 확률이 낮은 건(0.15~0.5)은 '관찰'로 표시하고 0.5 이상만 '보충 필요'로 올린다\")\n", + "hi = int(((R[\"p_need\"] >= 0.5) & act_flag).sum()); hin = int((R[\"p_need\"] >= 0.5).sum())\n", + "print(f\"확률 0.5 이상 {hin}건 중 실제 보충 필요 {hi}건({hi / max(hin, 1):.0%})\")\n", + "\n", + "# 보충 필요 인원(경로의 90백분위 최대 결원 - 허용 결원 10%)과 실제 비교 (마지막 시점)\n", + "last = R[R[\"origin\"] == ORIGINS[-1]].copy()\n", + "need = lambda peak, a: max(0, int(np.ceil((peak - 0.10) * a)))\n", + "q = []\n", + "for _, r in last.iterrows():\n", + " paths, _ = mc_forecast(cells[r[\"cell\"]], ORIGINS[-1]); q.append(need(np.percentile(paths.max(axis=1), 75), r[\"auth\"]))\n", + "last[\"pred_need\"] = q\n", + "last[\"act_need\"] = [need(r[\"act\"].max(), r[\"auth\"]) for _, r in last.iterrows()]\n", + "print(f\"\\n[시점 {ORIGINS[-1]}주 기준 보충 필요 인원(경로 최대 결원의 75백분위 기준)] 예측 합계 {int(last['pred_need'].sum())}명, 실제 합계 {int(last['act_need'].sum())}명, 셀별 평균 절대오차 {np.abs(last['pred_need'] - last['act_need']).mean():.2f}명\")\n", + "top = last.sort_values(\"pred_need\", ascending=False).head(4)\n", + "for _, r in top.iterrows():\n", + " print(f\" {r['cell'][0]} {r['cell'][1]}: 예측 {r['pred_need']}명(실제 {r['act_need']}명), 보충 필요 확률 {r['p_need']:.0%}, 13주 말 예상 결원율 {r['v3'][-1]:.0%}\")\n", + "print(\"※ 예측은 가상 자료 기반 판단 보조이며 보충 요청과 배치는 인사관이 결정한다. 모델이 모르는 대량 전출·파견은 AAR에서 재보정한다.\")\n", + "\n", + "best = top.iloc[0]\n", + "plt.figure(figsize=(8, 4)); x = np.arange(H)\n", + "plt.plot(x, best[\"act\"] * 100, \"k-\", label=\"actual\"); plt.plot(x, best[\"v3\"] * 100, \"b--\", label=\"stock-flow mean\"); plt.plot(x, best[\"v2\"] * 100, \"g-.\", label=\"ridge v2\"); plt.plot(x, best[\"naive\"] * 100, \"g:\", label=\"naive\")\n", + "plt.axhline(NEED_CUT * 100, color=\"r\", ls=\":\"); plt.xlabel(\"weeks ahead\"); plt.ylabel(\"vacancy rate (%)\"); plt.title(\"13-week vacancy forecast (highest-need cell)\"); plt.legend(); plt.tight_layout(); plt.show()\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 04 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + "[롤링 검증] 시점 [52, 65, 78, 91] x 셀 24개 = 96건, 예측 13주\n", + "결원 인원 MAE: 단순 예측(마지막 값 유지) 1.39명 / 회귀 v1 1.45명 / 회귀 v2(특징 재설계) 1.16명 / 재고-흐름 모의 평균 1.07명\n", + "예측 지연: 회귀 셀 1개 평균 5.7ms, 모의 300경로 평균 1.9ms/99백분위 5.4ms (24개 셀 전체 45ms, 목표 200ms 이하: 달성)\n", + "\n", + "[보충 필요(결원율 15% 이상) 탐지] 실제 해당 35건 / 96건\n", + " 단순 예측 재현율 45.7%, 정밀도 100.0%, 오경보 0건\n", + " 회귀 v1 재현율 45.7%, 정밀도 100.0%, 오경보 0건\n", + " 회귀 v2 재현율 54.3%, 정밀도 100.0%, 오경보 0건\n", + " 모의 확률 0.50 이상 재현율 60.0%, 정밀도 87.5%, 오경보 3건\n", + " 모의 확률 0.30 이상 재현율 71.4%, 정밀도 67.6%, 오경보 12건\n", + " 모의 확률 0.15 이상 재현율 85.7%, 정밀도 55.6%, 오경보 24건\n", + "-> 선택: 모의 확률 0.15 이상을 '보충 필요 가능성'으로 표시, 재현율 85.7% (목표 85% 이상: 달성), 대신 정밀도 56%로 오경보 24건을 사람이 걸러야 함\n", + "[오경보 패턴] 오경보 24건 중 실제 최대 결원율이 10~15%인 경계 근처 사례 67% -> 교대 보충 도착 시점·전출 규모의 불확실성이 원인, 확률이 낮은 건(0.15~0.5)은 '관찰'로 표시하고 0.5 이상만 '보충 필요'로 올린다\n", + "확률 0.5 이상 24건 중 실제 보충 필요 21건(88%)\n", + "\n", + "[시점 91주 기준 보충 필요 인원(경로 최대 결원의 75백분위 기준)] 예측 합계 77명, 실제 합계 71명, 셀별 평균 절대오차 1.00명\n", + " 가상C대대 통신: 예측 17명(실제 15명), 보충 필요 확률 100%, 13주 말 예상 결원율 49%\n", + " 가상B대대 보급: 예측 16명(실제 16명), 보충 필요 확률 100%, 13주 말 예상 결원율 51%\n", + " 가상A대대 정비: 예측 7명(실제 8명), 보충 필요 확률 100%, 13주 말 예상 결원율 22%\n", + " 가상C대대 정비: 예측 4명(실제 3명), 보충 필요 확률 58%, 13주 말 예상 결원율 14%\n", + "※ 예측은 가상 자료 기반 판단 보조이며 보충 요청과 배치는 인사관이 결정한다. 모델이 모르는 대량 전출·파견은 AAR에서 재보정한다.\n", + "```\n", + "
\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 05. 인력 부족 위험 지수 산출 및 인사 배치 추천 신뢰도 엔진 개발\n", + "**소요시간**: 약 3.0시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "인력 부족 위험 지수 산출 및 인사 배치 추천 신뢰도 엔진을 개발한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **인력 부족 위험 지수를 산출하고 신뢰도(High/Med/Low) 등급과 함께 인사 배치(인원 이동) 추천을 만드는 신뢰도 엔진을 개발한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **보충 필요 확률·예상 최대 결원율·초과근무 부담으로 위험 지수를, 예측 경로 분산·모델 간 일치·자료 충분도로 신뢰도를 계산해 가중치 매트릭스로 보여 준다 과정을 진행하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `src/verification/adm01_confidence_engine.py` 형태의 결과물을 완성한다.\n", + "\n", + "### 🔍 평가 및 검증 기준 (PASS 조건)\n", + "#### 미션 판정\n", + "\n", + "**평가 구분:** 필수\n", + "\n", + "**핵심 평가영역:** 신뢰도 등급(High/Med/Low) 부여 기준의 군사적 타당성\n", + "\n", + "#### 미션 PASS 조건\n", + "\n", + "- 정량 기준: 오경보율(FAR) 40% 이상 감축, 신뢰도 스코어 산출 일관성 90% 이상\n", + "\n", + "\n", + "- 진행 기준: 오경보 필터링 전후 비교 지표 산출 및 통합 테스트 완료\n", + "\n", + "\n", + "- 검증 방법: ROC-AUC 곡선 분석 및 임계값 민감도 테스트\n", + "\n", + "\n", + "#### 채점자 판정 메모\n", + "\n", + "> 단순 코드 실행 여부뿐만 아니라 출력된 수치와 근거 자료가 군 실무 요구사항에 부합하는지 면밀히 확인한다.\n", + "\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "\"\"\"부대·직무 셀별 인력 부족 위험 지수(0~100)와 신뢰도(High/Med/Low: 예측 분산·모델 간 일치·자료 충분도 가중합)를 산출하고, 신뢰도 낮은 경보를 '확인 요청'으로 분리해 단순 경보 대비 오경보율 감축과 같은 설정 반복 실행 시 등급 일관성을 검증하며 신뢰도를 반영한 인사 배치(인원 이동) 추천안을 만든다.\"\"\"\n", + "import numpy as np\n", + "import pandas as pd\n", + "import matplotlib.pyplot as plt\n", + "\n", + "# ---------------------------------------------------------------------------\n", + "# [교육용가상자료] 가상 부대 인력 현황 생성기 (실제 부대·편제·병력·개인 정보 없음)\n", + "# 가상 대대 4곳 x 직무 6종의 주간 집계 자료만 만든다. 개인 단위 정보(이름·군번 등)는 만들지 않는다.\n", + "# 인원은 직무별 집계 수이며, 결원·휴가·환자·교육·보충·전출을 반영한 현재원과 초과근무 시간으로 구성된다.\n", + "# ---------------------------------------------------------------------------\n", + "UNITS = [\"가상A대대\", \"가상B대대\", \"가상C대대\", \"가상D대대\"]\n", + "JOBS = [\"작전\", \"통신\", \"정비\", \"보급\", \"행정\", \"의무\"]\n", + "FIELDS = [\"unit\", \"job\", \"week\", \"authorized\", \"present\", \"vacancy\", \"leave\", \"sick\", \"training\", \"inflow\", \"outflow\", \"overtime_h\", \"night_ratio\", \"rotation_days\", \"fatigue_label\"]\n", + "HI_CUT = 55 # 고피로 판정 기준(가상 점수 55 이상)\n", + "\n", + "\n", + "def make_manning(seed=7, weeks=104, shock=None):\n", + " \"\"\"부대 x 직무 24개 셀의 주간 인력 자료. shock=(unit, job 또는 None(부대 전체), 시작주, 주간 추가 전출 평균 수) 한 건 또는 그 목록이면 그 셀에 대량 결원 요인을 넣는다(AAR·스트레스용).\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " shocks = [shock] if shock and not isinstance(shock[0], tuple) else list(shock or [])\n", + " rows = []\n", + " for ui, u in enumerate(UNITS):\n", + " for ji, j in enumerate(JOBS):\n", + " auth = int(rng.integers(14, 42) * (0.9 + 0.06 * ui))\n", + " work = rng.uniform(0.92, 1.12) # 직무별 업무량 계수\n", + " net = rng.uniform(-0.05, 0.03) # 구조적 보충 부족(음수)·과잉(양수) 경향(주당 인원)\n", + " present = int(auth * rng.uniform(0.82, 0.98))\n", + " rot = int(rng.choice([60, 90, 120])) # 교대·보충 주기(일)\n", + " for w in range(1, weeks + 1):\n", + " season = 1.0 + 0.9 * (abs(w % 52 - 22) < 4 or abs(w % 52 - 46) < 3) # 전출·전역 집중 시기\n", + " out = int(rng.poisson(0.22 * auth / 20 * season))\n", + " for s_ in shocks:\n", + " if s_[0] == u and (s_[1] is None or s_[1] == j) and w >= s_[2]:\n", + " out += int(rng.poisson(s_[3]))\n", + " inflow = int(rng.poisson(max(0.0, 0.2 * auth / 20 * (1 + net * 10)))) + (int(rng.integers(2, 6) * auth / 25) if w % (rot // 7) == 0 else 0)\n", + " present = int(np.clip(present - out + inflow, int(auth * 0.4), auth))\n", + " leave = int(rng.binomial(present, 0.05 + 0.05 * (abs(w % 52 - 31) < 3 or w % 52 >= 50)))\n", + " sick = int(rng.poisson(0.025 * present)); train = int(rng.binomial(present, 0.04 + 0.06 * (w % 13 == 6)))\n", + " avail = max(present - leave - sick - train, 1)\n", + " night = float(np.clip(rng.normal(0.25 + 0.05 * (j == \"작전\") + 0.05 * (j == \"의무\"), 0.05), 0.05, 0.6))\n", + " ot = float(np.clip(auth * 40 * work / avail - 40 + rng.normal(0, 1.5), 0, 40))\n", + " vac = auth - present\n", + " absent = (leave + sick + train) / max(present, 1)\n", + " fat = 100 * (0.38 * min(vac / auth / 0.25, 1.2) + 0.34 * min(ot / 20, 1.2) + 0.10 * min(night / 0.4, 1.2) + 0.18 * min(absent / 0.2, 1.2)) + rng.normal(0, 5)\n", + " rows.append((u, j, w, auth, present, vac, leave, sick, train, inflow, out, round(ot, 1), round(night, 2), rot, int(fat >= HI_CUT)))\n", + " return pd.DataFrame(rows, columns=FIELDS)\n", + "\n", + "\n", + "def cell_pivot(df, col=\"vacancy\"):\n", + " \"\"\"주 x (부대,직무) 표.\"\"\"\n", + " return df.pivot_table(index=\"week\", columns=[\"unit\", \"job\"], values=col)\n", + "\n", + "# ---- 결원율 예측 모델(주간 결원율 변화량을 릿지 회귀로 맞히고 13주까지 재귀 적용, 외부 시계열 라이브러리 없이) ----\n", + "from sklearn.linear_model import Ridge\n", + "H = 13 # 예측 기간: 향후 3개월(13주)\n", + "NEED_CUT = 0.15 # 결원율 15% 이상이면 보충 필요(가상 기준)\n", + "\n", + "\n", + "def season(w):\n", + " return float(abs(w % 52 - 22) < 4 or abs(w % 52 - 46) < 3)\n", + "\n", + "\n", + "def feat(vr, ir, orr, wn, rot, v2=True):\n", + " \"\"\"vr·ir·orr: 지금까지의 결원율·보충율·전출율 목록, wn: 예측할 다음 주차, rot: 교대 주기(일). v2는 보충·전출 흐름과 달력 특징을 추가한다.\"\"\"\n", + " f = [vr[-1], vr[-1] - vr[-2], vr[-1] - vr[-4]]\n", + " if v2:\n", + " step = max(rot // 7, 1)\n", + " f += [float(np.mean(orr[-12:]) - np.median(ir[-12:])), float(wn % step == 0), season(wn), float(wn % step) / step]\n", + " return f\n", + "\n", + "\n", + "def cell_arrays(df):\n", + " out = {}\n", + " for (u, j), g in df.sort_values(\"week\").groupby([\"unit\", \"job\"]):\n", + " a = g[\"authorized\"].to_numpy()\n", + " out[(u, j)] = {\"auth\": int(a[0]), \"rot\": int(g[\"rotation_days\"].iloc[0]), \"vr\": (g[\"vacancy\"].to_numpy() / a), \"ir\": g[\"inflow\"].to_numpy() / a, \"or\": g[\"outflow\"].to_numpy() / a}\n", + " return out\n", + "\n", + "\n", + "def fit_fc(cells, origin, v2=True, alpha=1.0):\n", + " X, y = [], []\n", + " for c in cells.values():\n", + " for t in range(12, origin - 1): # t주까지 알 때 t+1주(인덱스 t)의 결원율 변화를 맞힌다\n", + " X.append(feat(c[\"vr\"][:t], c[\"ir\"][:t], c[\"or\"][:t], t + 1, c[\"rot\"], v2)); y.append(c[\"vr\"][t] - c[\"vr\"][t - 1])\n", + " return Ridge(alpha=alpha).fit(np.array(X), np.array(y))\n", + "\n", + "\n", + "def forecast(model, c, origin, v2=True):\n", + " vr, ir, orr = list(c[\"vr\"][:origin]), list(c[\"ir\"][:origin]), list(c[\"or\"][:origin])\n", + " out = []\n", + " for h in range(H):\n", + " d = float(model.predict([feat(vr, ir, orr, origin + h + 1, c[\"rot\"], v2)])[0])\n", + " p = float(np.clip(vr[-1] + d, 0, 1))\n", + " out.append(p); vr.append(p); ir.append(float(np.median(ir[-12:]))); orr.append(float(np.mean(orr[-12:])))\n", + " return np.array(out)\n", + "\n", + "\n", + "def mc_forecast(c, origin, n_path=300, seed=0, out_mult=1.0, lam=0.0):\n", + " \"\"\"재고-흐름(stock-flow) 모의: 셀의 평시·전출 집중기 전출율, 정규 보충율, 교대 주기 보충 규모를 과거에서 추정해 결원 경로 n_path개를 만든다.\n", + " 반환: (경로 배열 [n_path, H] 결원율, 추정 모수). out_mult는 전출 가정을 키우는 보정 계수, lam은 최근 8주 전출율을 반영하는 비중(0~1, 급증 감지 때만 적용, AAR 재보정용).\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " w = np.arange(1, origin + 1); step = max(c[\"rot\"] // 7, 1)\n", + " rot_w = (w % step == 0); sea = np.array([season(x) > 0 for x in w])\n", + " o, i = c[\"or\"][:origin], c[\"ir\"][:origin]\n", + " o_base = o[~sea].mean(); o_sea = o[sea].mean() if sea.sum() >= 6 else o_base * 1.9\n", + " o_rec = o[-8:].mean()\n", + " mu = max(o_base, 1e-6) * c[\"auth\"] * 8 # 최근 8주 평시 기대 전출 인원\n", + " use_rec = lam > 0 and o_rec * c[\"auth\"] * 8 > mu + 2.0 * np.sqrt(mu) # 포아송 기준 상위 2표준편차를 넘는 '급증'일 때만 최근 값을 반영\n", + " i_reg = i[~rot_w].mean(); batch = max(i[rot_w].mean() - i_reg, 0.0)\n", + " a = c[\"auth\"]; pres = np.full(n_path, a * (1 - c[\"vr\"][origin - 1]))\n", + " out = np.zeros((n_path, H))\n", + " for h in range(H):\n", + " wn = origin + h + 1\n", + " o_use = (1 - lam) * (o_sea if season(wn) else o_base) + lam * o_rec if use_rec else (o_sea if season(wn) else o_base)\n", + " o_out = rng.poisson(max(o_use, 1e-6) * a * out_mult, n_path)\n", + " o_in = rng.poisson(max(i_reg, 1e-6) * a, n_path) + (np.maximum(rng.normal(batch * a, 0.3 * batch * a, n_path), 0).round() if wn % step == 0 else 0)\n", + " pres = np.clip(pres - o_out + o_in, 0.4 * a, a)\n", + " out[:, h] = 1 - pres / a\n", + " return out, {\"o_base\": o_base, \"o_sea\": o_sea, \"i_reg\": i_reg, \"batch\": batch}\n", + "\n", + "df = make_manning(7, 104)\n", + "cells = cell_arrays(df)\n", + "ot = {k: g.sort_values(\"week\")[\"overtime_h\"].to_numpy() for k, g in df.groupby([\"unit\", \"job\"])}\n", + "CONF_W = {\"spread\": 0.5, \"agree\": 0.3, \"data\": 0.2} # 신뢰도 가중치 매트릭스(합 1)\n", + "RISK_W = {\"prob\": 0.5, \"peak\": 0.3, \"load\": 0.2} # 위험 지수 가중치(합 1)\n", + "GRADE = [(60, \"높음\"), (35, \"보통\"), (0, \"낮음\")]\n", + "\n", + "\n", + "def assess(k, origin, seed=0, models=None):\n", + " c = cells[k]\n", + " paths, par = mc_forecast(c, origin, n_path=800, seed=seed)\n", + " peaks = paths.max(axis=1)\n", + " p_need = float((peaks >= NEED_CUT).mean())\n", + " load = float(np.clip(ot[k][origin - 4:origin].mean() / 20, 0, 1)) # 최근 4주 초과근무 부담\n", + " risk = 100 * (RISK_W[\"prob\"] * p_need + RISK_W[\"peak\"] * min(peaks.mean() / 0.30, 1) + RISK_W[\"load\"] * load)\n", + " iqr = float(np.percentile(peaks, 75) - np.percentile(peaks, 25))\n", + " v2_peak = forecast(models[origin], c, origin, True).max() # 서로 다른 모델(회귀)과의 일치\n", + " agree = 1 - min(abs(v2_peak - peaks.mean()) / 0.10, 1)\n", + " conf = CONF_W[\"spread\"] * (1 - min(iqr / 0.12, 1)) + CONF_W[\"agree\"] * agree + CONF_W[\"data\"] * min(origin / 52, 1)\n", + " return {\"cell\": k, \"origin\": origin, \"risk\": risk, \"p_need\": p_need, \"peak\": float(peaks.mean()), \"iqr\": iqr, \"agree\": agree, \"conf\": conf,\n", + " \"grade\": next(g for t, g in GRADE if risk >= t), \"level\": \"High\" if conf >= 0.70 else \"Med\" if conf >= 0.45 else \"Low\"}\n", + "\n", + "\n", + "ORIGINS = [52, 65, 78, 91]\n", + "models = {o: fit_fc(cells, o, True) for o in ORIGINS}\n", + "rows = [assess(k, o, models=models) for o in ORIGINS for k in cells]\n", + "A = pd.DataFrame(rows)\n", + "A[\"act_need\"] = [cells[r.cell][\"vr\"][r.origin:r.origin + H].max() >= NEED_CUT for r in A.itertuples()]\n", + "A[\"base_alarm\"] = [(cells[r.cell][\"vr\"][r.origin - 1] >= 0.10) or (ot[r.cell][r.origin - 4:r.origin].mean() >= 15) for r in A.itertuples()]\n", + "A[\"alarm\"] = (A[\"risk\"] >= 50) & (A[\"level\"] != \"Low\") # 신뢰도 낮은 경보는 '확인 요청'으로 분리\n", + "A[\"check\"] = (A[\"risk\"] >= 50) & (A[\"level\"] == \"Low\")\n", + "far = lambda m: float(((m) & ~A[\"act_need\"]).sum() / max(m.sum(), 1))\n", + "fa0, fa1 = far(A[\"base_alarm\"]), far(A[\"alarm\"])\n", + "print(f\"[경보 비교] 셀 x 시점 {len(A)}건, 실제 보충 필요 {int(A['act_need'].sum())}건\")\n", + "print(f\" 단순 경보(결원율 10% 이상 또는 초과근무 15h 이상): 경보 {int(A['base_alarm'].sum())}건, 오경보율 {fa0:.1%}, 재현율 {float((A['base_alarm'] & A['act_need']).sum() / A['act_need'].sum()):.1%}\")\n", + "print(f\" 신뢰도 반영 경보(위험 50 이상 + 신뢰도 Med 이상): 경보 {int(A['alarm'].sum())}건, 오경보율 {fa1:.1%}, 재현율 {float((A['alarm'] & A['act_need']).sum() / A['act_need'].sum()):.1%}, 확인 요청 {int(A['check'].sum())}건\")\n", + "print(f\" 오경보율 {fa0:.1%} -> {fa1:.1%}: {(fa0 - fa1) / max(fa0, 1e-9):.0%} 감축 (목표 40% 이상: {'달성' if (fa0 - fa1) / max(fa0, 1e-9) >= 0.4 else '미달'})\")\n", + "\n", + "# 점수 산출 일관성: 같은 입력으로 난수 시드만 바꿔 5회 산출했을 때 위험 등급이 같은 셀의 비율\n", + "last = [assess(k, 91, seed=s, models=models) for s in range(5) for k in cells]\n", + "G = pd.DataFrame(last)\n", + "cons = G.groupby(\"cell\")[\"grade\"].nunique().eq(1).mean(); consl = G.groupby(\"cell\")[\"level\"].nunique().eq(1).mean()\n", + "print(f\"\\n[일관성] 시드 5회 반복: 위험 등급 동일 셀 {cons:.0%}, 신뢰도 등급 동일 셀 {consl:.0%}, 위험 지수 표준편차 평균 {G.groupby('cell')['risk'].std().mean():.2f}점 (목표 90% 이상: {'달성' if cons >= 0.9 else '미달'})\")\n", + "print(\"가중치 매트릭스: 위험 지수\", RISK_W, \"/ 신뢰도\", CONF_W, \"/ 등급 기준 위험\", GRADE, \"신뢰도 High 0.70, Med 0.45\")\n", + "\n", + "# 현재 시점(91주) 위험 순위와 불확실성 표시\n", + "now = A[A[\"origin\"] == 91].sort_values(\"risk\", ascending=False)\n", + "print(\"\\n[위험 지수 상위 6개 셀(91주 시점)] 막대: 최대 결원율 평균, 구간: 경로 IQR\")\n", + "for r in now.head(6).itertuples():\n", + " bar = \"#\" * int(r.peak * 40)\n", + " print(f\" {r.cell[0]} {r.cell[1]}: 위험 {r.risk:4.0f}({r.grade}) 신뢰도 {r.conf:.2f}({r.level}) 보충 필요 확률 {r.p_need:.0%} {bar} +-{r.iqr / 2:.0%}\")\n", + "\n", + "# 인사 배치 추천: 위험 높음 셀로 같은 직무의 여유 셀(위험 낮음, 결원 적음)에서 이동\n", + "rec = []\n", + "for r in now[now[\"grade\"] == \"높음\"].itertuples():\n", + " k = r.cell; need = max(1, int(np.ceil((r.peak - 0.10) * cells[k][\"auth\"])))\n", + " pool = [s for s in now[(now[\"grade\"] == \"낮음\")].itertuples() if s.cell[1] == k[1] and s.cell[0] != k[0]]\n", + " for s in sorted(pool, key=lambda s: s.peak)[:1]:\n", + " sp = int(max(0, np.floor((0.08 - s.peak) * cells[s.cell][\"auth\"])))\n", + " mv = min(need, max(sp, 1))\n", + " rec.append((k, s.cell, mv, need, \"High\" if min(r.conf, s.conf) >= 0.7 else \"Med\" if min(r.conf, s.conf) >= 0.45 else \"Low\"))\n", + "print(\"\\n[배치 추천안: 같은 직무 여유 셀 -> 부족 셀, 신뢰도는 두 셀 중 낮은 값]\")\n", + "for to, fr, mv, need, cl in rec:\n", + " print(f\" {fr[0]}({fr[1]}) -> {to[0]}({to[1]}): {mv}명 이동 제안 (필요 {need}명), 신뢰도 {cl}\" + (\" <- 사람 확인 필수\" if cl != \"High\" else \"\"))\n", + "if not rec:\n", + " print(\" 해당 직무에 여유 셀이 없어 외부 보충 요청 대상\")\n", + "print(\"※ 위험 지수·신뢰도는 판단 보조 지표이며 통계적 신뢰구간이 아니다. 인원 배치는 인사관이 검토·승인한다(M7).\")\n", + "\n", + "fig, ax = plt.subplots(figsize=(8, 4))\n", + "t = now.head(8); ax.barh([f\"{r.cell[0][2]}{i + 1}\" for i, r in enumerate(t.itertuples())], t[\"risk\"], xerr=t[\"iqr\"] * 100 / 2, color=[\"tab:red\" if g == \"높음\" else \"tab:orange\" if g == \"보통\" else \"tab:green\" for g in t[\"grade\"]])\n", + "ax.invert_yaxis(); ax.set_xlabel(\"risk index (0-100), error bar = forecast IQR\"); ax.set_title(\"Top-8 risk cells (label: unit letter + rank)\"); plt.tight_layout(); plt.show()\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 05 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + "[경보 비교] 셀 x 시점 96건, 실제 보충 필요 35건\n", + " 단순 경보(결원율 10% 이상 또는 초과근무 15h 이상): 경보 33건, 오경보율 36.4%, 재현율 60.0%\n", + " 신뢰도 반영 경보(위험 50 이상 + 신뢰도 Med 이상): 경보 18건, 오경보율 11.1%, 재현율 45.7%, 확인 요청 6건\n", + " 오경보율 36.4% -> 11.1%: 69% 감축 (목표 40% 이상: 달성)\n", + "\n", + "[일관성] 시드 5회 반복: 위험 등급 동일 셀 96%, 신뢰도 등급 동일 셀 88%, 위험 지수 표준편차 평균 0.74점 (목표 90% 이상: 달성)\n", + "가중치 매트릭스: 위험 지수 {'prob': 0.5, 'peak': 0.3, 'load': 0.2} / 신뢰도 {'spread': 0.5, 'agree': 0.3, 'data': 0.2} / 등급 기준 위험 [(60, '높음'), (35, '보통'), (0, '낮음')] 신뢰도 High 0.70, Med 0.45\n", + "\n", + "[위험 지수 상위 6개 셀(91주 시점)] 막대: 최대 결원율 평균, 구간: 경로 IQR\n", + " 가상B대대 보급: 위험 100(높음) 신뢰도 0.83(High) 보충 필요 확률 100% ####################### +-0%\n", + " 가상C대대 통신: 위험 100(높음) 신뢰도 0.47(Med) 보충 필요 확률 100% ###################### +-3%\n", + " 가상A대대 정비: 위험 94(높음) 신뢰도 0.64(Med) 보충 필요 확률 100% ########## +-3%\n", + " 가상D대대 통신: 위험 67(높음) 신뢰도 0.45(Low) 보충 필요 확률 62% ####### +-5%\n", + " 가상A대대 보급: 위험 63(높음) 신뢰도 0.52(Med) 보충 필요 확률 60% ####### +-5%\n", + " 가상C대대 정비: 위험 54(보통) 신뢰도 0.51(Med) 보충 필요 확률 54% ####### +-4%\n", + "\n", + "[배치 추천안: 같은 직무 여유 셀 -> 부족 셀, 신뢰도는 두 셀 중 낮은 값]\n", + " 가상D대대(보급) -> 가상B대대(보급): 1명 이동 제안 (필요 16명), 신뢰도 High\n", + " 가상B대대(통신) -> 가상C대대(통신): 1명 이동 제안 (필요 16명), 신뢰도 Med <- 사람 확인 필수\n", + " 가상D대대(정비) -> 가상A대대(정비): 1명 이동 제안 (필요 6명), 신뢰도 Med <- 사람 확인 필수\n", + " 가상B대대(통신) -> 가상D대대(통신): 1명 이동 제안 (필요 3명), 신뢰도 Low <- 사람 확인 필수\n", + " 가상D대대(보급) -> 가상A대대(보급): 1명 이동 제안 (필요 3명), 신뢰도 Med <- 사람 확인 필수\n", + "※ 위험 지수·신뢰도는 판단 보조 지표이며 통계적 신뢰구간이 아니다. 인원 배치는 인사관이 검토·승인한다(M7).\n", + "```\n", + "
\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 06. 지휘관/인사참모용 부대 근무부하 현황 및 결원 예측 가시화 대시보드 구현\n", + "**소요시간**: 약 2.5시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "부대 근무부하 현황 및 결원 예측 가시화 대시보드를 구현한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **지휘관·인사참모가 부대 근무부하 현황과 결원 예측을 한눈에 볼 수 있는 가시화 대시보드를 구현하고 응답 성능을 측정한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **부대 x 직무 24개 셀의 현재 결원율·13주 후 예측·초과근무·피로도·보충 필요 확률을 상태(긴급·주의·정상) 강조와 함께 표와 히트맵으로 만들고 위험 셀의 추세·예측 구간 그래프를 그린다 과정을 진행하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `src/ui/adm01_dashboard.py (또는 웹 UI 컴포넌트)` 형태의 결과물을 완성한다.\n", + "\n", + "### 🔍 평가 및 검증 기준 (PASS 조건)\n", + "#### 미션 판정\n", + "\n", + "**평가 구분:** 필수\n", + "\n", + "**핵심 평가영역:** 전술 작전 상황판으로서의 직관성 및 인지 부하 감소도\n", + "\n", + "#### 미션 PASS 조건\n", + "\n", + "- 정량 기준: 화면 갱신 주기 1초 이내, UI 응답성 100ms 이내\n", + "\n", + "\n", + "- 진행 기준: 주요 지표 시각화 컴포넌트 4종 이상 렌더링 및 실시간 데이터 연동\n", + "\n", + "\n", + "- 검증 방법: 사용자 인터랙션 테스트 및 모의 데이터 스트림 렌더링 검증\n", + "\n", + "\n", + "#### 채점자 판정 메모\n", + "\n", + "> 단순 코드 실행 여부뿐만 아니라 출력된 수치와 근거 자료가 군 실무 요구사항에 부합하는지 면밀히 확인한다.\n", + "\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "\"\"\"부대·직무 셀의 현재 결원율·초과근무·피로도와 향후 13주 결원 예측·보충 필요 확률을 지휘관·인사참모용 현황판(표·히트맵·추세 그래프, 긴급 경보 강조, 야간 모드)으로 보여 주고, 전체 갱신 시간(1초 이내)과 필터·드릴다운·모드 전환 응답 시간(100ms 이내)을 측정한다.\"\"\"\n", + "import time\n", + "import numpy as np\n", + "import pandas as pd\n", + "import matplotlib.pyplot as plt\n", + "\n", + "# ---------------------------------------------------------------------------\n", + "# [교육용가상자료] 가상 부대 인력 현황 생성기 (실제 부대·편제·병력·개인 정보 없음)\n", + "# 가상 대대 4곳 x 직무 6종의 주간 집계 자료만 만든다. 개인 단위 정보(이름·군번 등)는 만들지 않는다.\n", + "# 인원은 직무별 집계 수이며, 결원·휴가·환자·교육·보충·전출을 반영한 현재원과 초과근무 시간으로 구성된다.\n", + "# ---------------------------------------------------------------------------\n", + "UNITS = [\"가상A대대\", \"가상B대대\", \"가상C대대\", \"가상D대대\"]\n", + "JOBS = [\"작전\", \"통신\", \"정비\", \"보급\", \"행정\", \"의무\"]\n", + "FIELDS = [\"unit\", \"job\", \"week\", \"authorized\", \"present\", \"vacancy\", \"leave\", \"sick\", \"training\", \"inflow\", \"outflow\", \"overtime_h\", \"night_ratio\", \"rotation_days\", \"fatigue_label\"]\n", + "HI_CUT = 55 # 고피로 판정 기준(가상 점수 55 이상)\n", + "\n", + "\n", + "def make_manning(seed=7, weeks=104, shock=None):\n", + " \"\"\"부대 x 직무 24개 셀의 주간 인력 자료. shock=(unit, job 또는 None(부대 전체), 시작주, 주간 추가 전출 평균 수) 한 건 또는 그 목록이면 그 셀에 대량 결원 요인을 넣는다(AAR·스트레스용).\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " shocks = [shock] if shock and not isinstance(shock[0], tuple) else list(shock or [])\n", + " rows = []\n", + " for ui, u in enumerate(UNITS):\n", + " for ji, j in enumerate(JOBS):\n", + " auth = int(rng.integers(14, 42) * (0.9 + 0.06 * ui))\n", + " work = rng.uniform(0.92, 1.12) # 직무별 업무량 계수\n", + " net = rng.uniform(-0.05, 0.03) # 구조적 보충 부족(음수)·과잉(양수) 경향(주당 인원)\n", + " present = int(auth * rng.uniform(0.82, 0.98))\n", + " rot = int(rng.choice([60, 90, 120])) # 교대·보충 주기(일)\n", + " for w in range(1, weeks + 1):\n", + " season = 1.0 + 0.9 * (abs(w % 52 - 22) < 4 or abs(w % 52 - 46) < 3) # 전출·전역 집중 시기\n", + " out = int(rng.poisson(0.22 * auth / 20 * season))\n", + " for s_ in shocks:\n", + " if s_[0] == u and (s_[1] is None or s_[1] == j) and w >= s_[2]:\n", + " out += int(rng.poisson(s_[3]))\n", + " inflow = int(rng.poisson(max(0.0, 0.2 * auth / 20 * (1 + net * 10)))) + (int(rng.integers(2, 6) * auth / 25) if w % (rot // 7) == 0 else 0)\n", + " present = int(np.clip(present - out + inflow, int(auth * 0.4), auth))\n", + " leave = int(rng.binomial(present, 0.05 + 0.05 * (abs(w % 52 - 31) < 3 or w % 52 >= 50)))\n", + " sick = int(rng.poisson(0.025 * present)); train = int(rng.binomial(present, 0.04 + 0.06 * (w % 13 == 6)))\n", + " avail = max(present - leave - sick - train, 1)\n", + " night = float(np.clip(rng.normal(0.25 + 0.05 * (j == \"작전\") + 0.05 * (j == \"의무\"), 0.05), 0.05, 0.6))\n", + " ot = float(np.clip(auth * 40 * work / avail - 40 + rng.normal(0, 1.5), 0, 40))\n", + " vac = auth - present\n", + " absent = (leave + sick + train) / max(present, 1)\n", + " fat = 100 * (0.38 * min(vac / auth / 0.25, 1.2) + 0.34 * min(ot / 20, 1.2) + 0.10 * min(night / 0.4, 1.2) + 0.18 * min(absent / 0.2, 1.2)) + rng.normal(0, 5)\n", + " rows.append((u, j, w, auth, present, vac, leave, sick, train, inflow, out, round(ot, 1), round(night, 2), rot, int(fat >= HI_CUT)))\n", + " return pd.DataFrame(rows, columns=FIELDS)\n", + "\n", + "\n", + "def cell_pivot(df, col=\"vacancy\"):\n", + " \"\"\"주 x (부대,직무) 표.\"\"\"\n", + " return df.pivot_table(index=\"week\", columns=[\"unit\", \"job\"], values=col)\n", + "\n", + "# ---- 결원율 예측 모델(주간 결원율 변화량을 릿지 회귀로 맞히고 13주까지 재귀 적용, 외부 시계열 라이브러리 없이) ----\n", + "from sklearn.linear_model import Ridge\n", + "H = 13 # 예측 기간: 향후 3개월(13주)\n", + "NEED_CUT = 0.15 # 결원율 15% 이상이면 보충 필요(가상 기준)\n", + "\n", + "\n", + "def season(w):\n", + " return float(abs(w % 52 - 22) < 4 or abs(w % 52 - 46) < 3)\n", + "\n", + "\n", + "def feat(vr, ir, orr, wn, rot, v2=True):\n", + " \"\"\"vr·ir·orr: 지금까지의 결원율·보충율·전출율 목록, wn: 예측할 다음 주차, rot: 교대 주기(일). v2는 보충·전출 흐름과 달력 특징을 추가한다.\"\"\"\n", + " f = [vr[-1], vr[-1] - vr[-2], vr[-1] - vr[-4]]\n", + " if v2:\n", + " step = max(rot // 7, 1)\n", + " f += [float(np.mean(orr[-12:]) - np.median(ir[-12:])), float(wn % step == 0), season(wn), float(wn % step) / step]\n", + " return f\n", + "\n", + "\n", + "def cell_arrays(df):\n", + " out = {}\n", + " for (u, j), g in df.sort_values(\"week\").groupby([\"unit\", \"job\"]):\n", + " a = g[\"authorized\"].to_numpy()\n", + " out[(u, j)] = {\"auth\": int(a[0]), \"rot\": int(g[\"rotation_days\"].iloc[0]), \"vr\": (g[\"vacancy\"].to_numpy() / a), \"ir\": g[\"inflow\"].to_numpy() / a, \"or\": g[\"outflow\"].to_numpy() / a}\n", + " return out\n", + "\n", + "\n", + "def fit_fc(cells, origin, v2=True, alpha=1.0):\n", + " X, y = [], []\n", + " for c in cells.values():\n", + " for t in range(12, origin - 1): # t주까지 알 때 t+1주(인덱스 t)의 결원율 변화를 맞힌다\n", + " X.append(feat(c[\"vr\"][:t], c[\"ir\"][:t], c[\"or\"][:t], t + 1, c[\"rot\"], v2)); y.append(c[\"vr\"][t] - c[\"vr\"][t - 1])\n", + " return Ridge(alpha=alpha).fit(np.array(X), np.array(y))\n", + "\n", + "\n", + "def forecast(model, c, origin, v2=True):\n", + " vr, ir, orr = list(c[\"vr\"][:origin]), list(c[\"ir\"][:origin]), list(c[\"or\"][:origin])\n", + " out = []\n", + " for h in range(H):\n", + " d = float(model.predict([feat(vr, ir, orr, origin + h + 1, c[\"rot\"], v2)])[0])\n", + " p = float(np.clip(vr[-1] + d, 0, 1))\n", + " out.append(p); vr.append(p); ir.append(float(np.median(ir[-12:]))); orr.append(float(np.mean(orr[-12:])))\n", + " return np.array(out)\n", + "\n", + "\n", + "def mc_forecast(c, origin, n_path=300, seed=0, out_mult=1.0, lam=0.0):\n", + " \"\"\"재고-흐름(stock-flow) 모의: 셀의 평시·전출 집중기 전출율, 정규 보충율, 교대 주기 보충 규모를 과거에서 추정해 결원 경로 n_path개를 만든다.\n", + " 반환: (경로 배열 [n_path, H] 결원율, 추정 모수). out_mult는 전출 가정을 키우는 보정 계수, lam은 최근 8주 전출율을 반영하는 비중(0~1, 급증 감지 때만 적용, AAR 재보정용).\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " w = np.arange(1, origin + 1); step = max(c[\"rot\"] // 7, 1)\n", + " rot_w = (w % step == 0); sea = np.array([season(x) > 0 for x in w])\n", + " o, i = c[\"or\"][:origin], c[\"ir\"][:origin]\n", + " o_base = o[~sea].mean(); o_sea = o[sea].mean() if sea.sum() >= 6 else o_base * 1.9\n", + " o_rec = o[-8:].mean()\n", + " mu = max(o_base, 1e-6) * c[\"auth\"] * 8 # 최근 8주 평시 기대 전출 인원\n", + " use_rec = lam > 0 and o_rec * c[\"auth\"] * 8 > mu + 2.0 * np.sqrt(mu) # 포아송 기준 상위 2표준편차를 넘는 '급증'일 때만 최근 값을 반영\n", + " i_reg = i[~rot_w].mean(); batch = max(i[rot_w].mean() - i_reg, 0.0)\n", + " a = c[\"auth\"]; pres = np.full(n_path, a * (1 - c[\"vr\"][origin - 1]))\n", + " out = np.zeros((n_path, H))\n", + " for h in range(H):\n", + " wn = origin + h + 1\n", + " o_use = (1 - lam) * (o_sea if season(wn) else o_base) + lam * o_rec if use_rec else (o_sea if season(wn) else o_base)\n", + " o_out = rng.poisson(max(o_use, 1e-6) * a * out_mult, n_path)\n", + " o_in = rng.poisson(max(i_reg, 1e-6) * a, n_path) + (np.maximum(rng.normal(batch * a, 0.3 * batch * a, n_path), 0).round() if wn % step == 0 else 0)\n", + " pres = np.clip(pres - o_out + o_in, 0.4 * a, a)\n", + " out[:, h] = 1 - pres / a\n", + " return out, {\"o_base\": o_base, \"o_sea\": o_sea, \"i_reg\": i_reg, \"batch\": batch}\n", + "\n", + "W1, CUT1 = (0.3, 0.6, 0.1), 55 # 피로도 가중치·기준(M3 튜닝 결과 예시)\n", + "PALETTE = {\"day\": {\"bg\": \"white\", \"fg\": \"black\", \"cmap\": \"YlOrRd\", \"alert\": \"red\"}, \"night\": {\"bg\": \"#10151c\", \"fg\": \"#d8dee9\", \"cmap\": \"magma\", \"alert\": \"#ffb000\"}}\n", + "\n", + "\n", + "def refresh(df):\n", + " \"\"\"원 자료에서 현황판 데이터를 모두 다시 계산한다(전체 갱신).\"\"\"\n", + " cells = cell_arrays(df)\n", + " last = df[df[\"week\"] == df[\"week\"].max()].set_index([\"unit\", \"job\"])\n", + " rows = []\n", + " for k, c in cells.items():\n", + " paths, _ = mc_forecast(c, len(c[\"vr\"]), n_path=300)\n", + " peaks = paths.max(axis=1); p = float((peaks >= NEED_CUT).mean()); r = last.loc[k]\n", + " fat = min(100 * (W1[0] * min(r[\"vacancy\"] / r[\"authorized\"] / .25, 1.2) + W1[1] * min(r[\"overtime_h\"] / 20, 1.2) + W1[2] * min(r[\"night_ratio\"] / .4, 1.2)), 100)\n", + " risk = 100 * (0.5 * p + 0.3 * min(peaks.mean() / .30, 1) + 0.2 * min(r[\"overtime_h\"] / 20, 1))\n", + " rows.append({\"unit\": k[0], \"job\": k[1], \"auth\": c[\"auth\"], \"present\": int(r[\"present\"]), \"vac_now\": c[\"vr\"][-1], \"vac_13w\": float(paths.mean(0)[-1]), \"peak\": float(peaks.mean()),\n", + " \"overtime\": float(r[\"overtime_h\"]), \"fatigue\": fat, \"p_need\": p, \"risk\": risk, \"band\": np.percentile(paths, [10, 90], axis=0), \"mean\": paths.mean(0), \"hist\": c[\"vr\"][-26:]})\n", + " v = pd.DataFrame(rows)\n", + " v[\"status\"] = np.where(v[\"risk\"] >= 80, \"긴급\", np.where(v[\"risk\"] >= 50, \"주의\", \"정상\"))\n", + " return v\n", + "\n", + "\n", + "def view(v, unit=None, job=None, mode=\"day\"):\n", + " \"\"\"필터·정렬·강조를 적용한 화면 데이터와 색 설정(응답 시간 측정 대상).\"\"\"\n", + " s = v\n", + " if unit:\n", + " s = s[s[\"unit\"] == unit]\n", + " if job:\n", + " s = s[s[\"job\"] == job]\n", + " return s.sort_values(\"risk\", ascending=False), PALETTE[mode]\n", + "\n", + "\n", + "df = make_manning(7, 104)\n", + "t0 = time.perf_counter(); V = refresh(df); t_refresh = time.perf_counter() - t0\n", + "print(f\"[전체 갱신] 셀 {len(V)}개 재계산(예측 300경로 포함) {t_refresh * 1000:.0f}ms (목표 1초 이내: {'달성' if t_refresh <= 1.0 else '미달'})\")\n", + "\n", + "# 1) 현황판: 상태별 집계와 상위 위험 셀\n", + "print(\"\\n[지휘관 현황판 - 가상 대대 4곳 x 직무 6종] 기준 주차 %d\" % df[\"week\"].max())\n", + "print(\"상태 집계:\", V[\"status\"].value_counts().reindex([\"긴급\", \"주의\", \"정상\"]).fillna(0).astype(int).to_dict(), f\"/ 편제 {int(V['auth'].sum())}명, 현재원 {int(V['present'].sum())}명, 전체 결원율 {1 - V['present'].sum() / V['auth'].sum():.1%}\")\n", + "show, _ = view(V)\n", + "print(f\"{'상태':4s} {'부대':8s} {'직무':4s} {'결원율':>6s} {'13주후':>6s} {'초과근무':>7s} {'피로도':>5s} {'보충확률':>6s}\")\n", + "for r in show.head(7).itertuples():\n", + " mark = \"[긴급]\" if r.status == \"긴급\" else \"[주의]\" if r.status == \"주의\" else \" \"\n", + " print(f\"{mark} {r.unit:8s} {r.job:4s} {r.vac_now:6.0%} {r.vac_13w:6.0%} {r.overtime:6.1f}h {r.fatigue:5.0f} {r.p_need:6.0%}\")\n", + "print(\"\\n[부대 x 직무 결원율 히트맵(현재 -> 13주 후), * = 보충 필요 확률 50% 이상]\")\n", + "H = V.pivot(index=\"unit\", columns=\"job\", values=\"vac_now\"); H2 = V.pivot(index=\"unit\", columns=\"job\", values=\"vac_13w\"); PN = V.pivot(index=\"unit\", columns=\"job\", values=\"p_need\")\n", + "print(\" \" + \" \".join(f\"{j:>9s}\" for j in JOBS))\n", + "for u in H.index:\n", + " print(f\"{u:8s}\" + \" \".join(f\"{H.loc[u, j]:4.0%}>{H2.loc[u, j]:3.0%}{'*' if PN.loc[u, j] >= .5 else ' '}\" for j in JOBS))\n", + "\n", + "# 2) 응답 시간: 필터 변경, 셀 드릴다운, 야간 모드 전환을 각각 50회 측정\n", + "ops = {\"부대 필터\": lambda: view(V, unit=\"가상C대대\"), \"직무 필터\": lambda: view(V, job=\"통신\"), \"셀 드릴다운\": lambda: view(V, \"가상B대대\", \"보급\")[0].iloc[0][\"band\"].copy(), \"야간 모드 전환\": lambda: view(V, mode=\"night\")}\n", + "print(\"\\n[UI 응답 시간 50회 측정]\")\n", + "worst = 0\n", + "for name, fn in ops.items():\n", + " ts = []\n", + " for _ in range(50):\n", + " t0 = time.perf_counter(); fn(); ts.append((time.perf_counter() - t0) * 1000)\n", + " worst = max(worst, np.percentile(ts, 95))\n", + " print(f\" {name:10s} 평균 {np.mean(ts):.3f}ms, 95백분위 {np.percentile(ts, 95):.3f}ms\")\n", + "print(f\"최대 95백분위 {worst:.3f}ms (목표 100ms 이내: {'달성' if worst <= 100 else '미달'}), 화면 갱신 {t_refresh * 1000:.0f}ms (목표 1초 이내: {'달성' if t_refresh <= 1 else '미달'})\")\n", + "print(\"※ 야간 모드는 배경·글자·경보 색만 바꾸며 수치는 같다. 긴급·주의 표시는 판단 보조이며 보충·배치 결정은 인사관이 한다.\")\n", + "\n", + "\n", + "def draw(mode):\n", + " pal = PALETTE[mode]\n", + " fig, ax = plt.subplots(1, 2, figsize=(11, 4), facecolor=pal[\"bg\"])\n", + " for a in ax:\n", + " a.set_facecolor(pal[\"bg\"]); a.tick_params(colors=pal[\"fg\"])\n", + " for s in a.spines.values():\n", + " s.set_color(pal[\"fg\"])\n", + " im = ax[0].imshow(H2.to_numpy() * 100, cmap=pal[\"cmap\"], aspect=\"auto\")\n", + " ax[0].set_xticks(range(len(JOBS))); ax[0].set_xticklabels([f\"job{i + 1}\" for i in range(len(JOBS))]); ax[0].set_yticks(range(len(UNITS))); ax[0].set_yticklabels([\"A\", \"B\", \"C\", \"D\"])\n", + " for i, u in enumerate(H.index):\n", + " for j, jb in enumerate(JOBS):\n", + " ax[0].text(j, i, f\"{H2.loc[u, jb] * 100:.0f}{'!' if PN.loc[u, jb] >= .5 else ''}\", ha=\"center\", va=\"center\", color=pal[\"alert\"] if PN.loc[u, jb] >= .5 else pal[\"fg\"], fontsize=8)\n", + " ax[0].set_title(\"Forecast vacancy rate in 13 weeks (%, ! = need)\", color=pal[\"fg\"])\n", + " top = V.sort_values(\"risk\", ascending=False).iloc[0]; x = np.arange(-25, 14)\n", + " ax[1].plot(x[:26], top[\"hist\"] * 100, color=pal[\"fg\"], label=\"history\"); ax[1].plot(x[26:], top[\"mean\"] * 100, color=pal[\"alert\"], label=\"forecast\")\n", + " ax[1].fill_between(x[26:], top[\"band\"][0] * 100, top[\"band\"][1] * 100, color=pal[\"alert\"], alpha=.25); ax[1].axhline(NEED_CUT * 100, color=pal[\"fg\"], ls=\":\")\n", + " ax[1].set_title(\"Top-risk cell: vacancy trend and 80% band\", color=pal[\"fg\"]); ax[1].legend(facecolor=pal[\"bg\"], labelcolor=pal[\"fg\"]); ax[1].set_xlabel(\"weeks from now\", color=pal[\"fg\"])\n", + " plt.tight_layout(); plt.show()\n", + "\n", + "\n", + "draw(\"day\"); draw(\"night\")\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 06 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + "[전체 갱신] 셀 24개 재계산(예측 300경로 포함) 51ms (목표 1초 이내: 달성)\n", + "\n", + "[지휘관 현황판 - 가상 대대 4곳 x 직무 6종] 기준 주차 104\n", + "상태 집계: {'긴급': 5, '주의': 2, '정상': 17} / 편제 732명, 현재원 659명, 전체 결원율 10.0%\n", + "상태 부대 직무 결원율 13주후 초과근무 피로도 보충확률\n", + "[긴급] 가상A대대 정비 29% 26% 28.4h 100 100%\n", + "[긴급] 가상B대대 보급 48% 56% 40.0h 100 100%\n", + "[긴급] 가상C대대 통신 42% 46% 40.0h 100 100%\n", + "[긴급] 가상A대대 보급 18% 23% 22.7h 97 95%\n", + "[긴급] 가상D대대 의무 22% 13% 19.7h 93 99%\n", + "[주의] 가상D대대 통신 14% 9% 17.9h 76 67%\n", + "[주의] 가상A대대 의무 14% 6% 11.1h 57 46%\n", + "\n", + "[부대 x 직무 결원율 히트맵(현재 -> 13주 후), * = 보충 필요 확률 50% 이상]\n", + " 작전 통신 정비 보급 행정 의무\n", + "가상A대대 0%> 4% 6%> 5% 29%>26%* 18%>23%* 6%> 5% 14%> 6% \n", + "가상B대대 3%> 3% 0%> 5% 5%> 5% 48%>56%* 0%> 4% 0%> 3% \n", + "가상C대대 6%> 3% 42%>46%* 11%>11% 0%> 4% 0%> 5% 5%> 6% \n", + "가상D대대 3%> 6% 14%> 9%* 0%> 4% 0%> 2% 12%> 5% 22%>13%*\n", + "\n", + "[UI 응답 시간 50회 측정]\n", + " 부대 필터 평균 0.786ms, 95백분위 1.028ms\n", + " 직무 필터 평균 0.766ms, 95백분위 0.889ms\n", + " 셀 드릴다운 평균 1.201ms, 95백분위 1.630ms\n", + " 야간 모드 전환 평균 0.283ms, 95백분위 0.350ms\n", + "최대 95백분위 1.630ms (목표 100ms 이내: 달성), 화면 갱신 51ms (목표 1초 이내: 달성)\n", + "※ 야간 모드는 배경·글자·경보 색만 바꾸며 수치는 같다. 긴급·주의 표시는 판단 보조이며 보충·배치 결정은 인사관이 한다.\n", + "```\n", + "
\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 07. 인사 보충 우선순위 결정 시 인사관 수동 조정 및 승인(HITL) 워크플로우 설계\n", + "**소요시간**: 약 3.0시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "인사 보충 우선순위 결정 시 인사관 수동 조정 및 승인(HITL) 워크플로우를 설계한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **인력 보충 우선순위 결정을 인사담당-인사관(수동 조정)-지휘관(승인)이 거치는 HITL 워크플로우로 설계하고 성급한 자동 확정을 막는 체계를 검증한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **역할별 권한과 단계 순서를 정의하고 시스템은 제안만, 확정은 사람만 하도록 수행하고 검증하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `src/hitl/adm01_approval_workflow.py 및 docs/adm01_hitl_guide.md` 형태의 결과물을 완성한다.\n", + "\n", + "### 🔍 평가 및 검증 기준 (PASS 조건)\n", + "#### 미션 판정\n", + "\n", + "**평가 구분:** 필수\n", + "\n", + "**핵심 평가영역:** 지휘 책임 분계선 명확화 및 성급한 자동 단정 방지 체계 부합성\n", + "\n", + "#### 미션 PASS 조건\n", + "\n", + "- 정량 기준: 인간 검토 큐(Queue) 대기시간 및 승인/반려 처리 로깅 성공률 100%\n", + "\n", + "\n", + "- 진행 기준: 승인/반려 인터페이스 동작 및 감사 로그(Audit Log) 생성 검증\n", + "\n", + "\n", + "- 검증 방법: 모의 조작자 승인/반려 시뮬레이션 및 데이터 무결성 검증\n", + "\n", + "\n", + "#### 채점자 판정 메모\n", + "\n", + "> 단순 코드 실행 여부뿐만 아니라 출력된 수치와 근거 자료가 군 실무 요구사항에 부합하는지 면밀히 확인한다.\n", + "\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "\"\"\"인력 보충·재배치 우선순위 안을 인사담당 상신 -> 인사관 검토(수동 조정 가능) -> 지휘관 승인의 HITL 워크플로우로 처리하되, 시스템 자동 확정 금지·조정 전후 값과 사유 기록·반려 사유 필수화·권한 우회 차단·해시 체인 감사 로그를 검증하고 위험도 우선 큐(대기 시간 노화 가산)가 선입선출보다 긴급 건 대기를 줄이는지 비교한다.\"\"\"\n", + "import hashlib\n", + "import json\n", + "import numpy as np\n", + "import pandas as pd\n", + "import matplotlib.pyplot as plt\n", + "\n", + "# ---------------------------------------------------------------------------\n", + "# [교육용가상자료] 가상 부대 인력 현황 생성기 (실제 부대·편제·병력·개인 정보 없음)\n", + "# 가상 대대 4곳 x 직무 6종의 주간 집계 자료만 만든다. 개인 단위 정보(이름·군번 등)는 만들지 않는다.\n", + "# 인원은 직무별 집계 수이며, 결원·휴가·환자·교육·보충·전출을 반영한 현재원과 초과근무 시간으로 구성된다.\n", + "# ---------------------------------------------------------------------------\n", + "UNITS = [\"가상A대대\", \"가상B대대\", \"가상C대대\", \"가상D대대\"]\n", + "JOBS = [\"작전\", \"통신\", \"정비\", \"보급\", \"행정\", \"의무\"]\n", + "FIELDS = [\"unit\", \"job\", \"week\", \"authorized\", \"present\", \"vacancy\", \"leave\", \"sick\", \"training\", \"inflow\", \"outflow\", \"overtime_h\", \"night_ratio\", \"rotation_days\", \"fatigue_label\"]\n", + "HI_CUT = 55 # 고피로 판정 기준(가상 점수 55 이상)\n", + "\n", + "\n", + "def make_manning(seed=7, weeks=104, shock=None):\n", + " \"\"\"부대 x 직무 24개 셀의 주간 인력 자료. shock=(unit, job 또는 None(부대 전체), 시작주, 주간 추가 전출 평균 수) 한 건 또는 그 목록이면 그 셀에 대량 결원 요인을 넣는다(AAR·스트레스용).\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " shocks = [shock] if shock and not isinstance(shock[0], tuple) else list(shock or [])\n", + " rows = []\n", + " for ui, u in enumerate(UNITS):\n", + " for ji, j in enumerate(JOBS):\n", + " auth = int(rng.integers(14, 42) * (0.9 + 0.06 * ui))\n", + " work = rng.uniform(0.92, 1.12) # 직무별 업무량 계수\n", + " net = rng.uniform(-0.05, 0.03) # 구조적 보충 부족(음수)·과잉(양수) 경향(주당 인원)\n", + " present = int(auth * rng.uniform(0.82, 0.98))\n", + " rot = int(rng.choice([60, 90, 120])) # 교대·보충 주기(일)\n", + " for w in range(1, weeks + 1):\n", + " season = 1.0 + 0.9 * (abs(w % 52 - 22) < 4 or abs(w % 52 - 46) < 3) # 전출·전역 집중 시기\n", + " out = int(rng.poisson(0.22 * auth / 20 * season))\n", + " for s_ in shocks:\n", + " if s_[0] == u and (s_[1] is None or s_[1] == j) and w >= s_[2]:\n", + " out += int(rng.poisson(s_[3]))\n", + " inflow = int(rng.poisson(max(0.0, 0.2 * auth / 20 * (1 + net * 10)))) + (int(rng.integers(2, 6) * auth / 25) if w % (rot // 7) == 0 else 0)\n", + " present = int(np.clip(present - out + inflow, int(auth * 0.4), auth))\n", + " leave = int(rng.binomial(present, 0.05 + 0.05 * (abs(w % 52 - 31) < 3 or w % 52 >= 50)))\n", + " sick = int(rng.poisson(0.025 * present)); train = int(rng.binomial(present, 0.04 + 0.06 * (w % 13 == 6)))\n", + " avail = max(present - leave - sick - train, 1)\n", + " night = float(np.clip(rng.normal(0.25 + 0.05 * (j == \"작전\") + 0.05 * (j == \"의무\"), 0.05), 0.05, 0.6))\n", + " ot = float(np.clip(auth * 40 * work / avail - 40 + rng.normal(0, 1.5), 0, 40))\n", + " vac = auth - present\n", + " absent = (leave + sick + train) / max(present, 1)\n", + " fat = 100 * (0.38 * min(vac / auth / 0.25, 1.2) + 0.34 * min(ot / 20, 1.2) + 0.10 * min(night / 0.4, 1.2) + 0.18 * min(absent / 0.2, 1.2)) + rng.normal(0, 5)\n", + " rows.append((u, j, w, auth, present, vac, leave, sick, train, inflow, out, round(ot, 1), round(night, 2), rot, int(fat >= HI_CUT)))\n", + " return pd.DataFrame(rows, columns=FIELDS)\n", + "\n", + "\n", + "def cell_pivot(df, col=\"vacancy\"):\n", + " \"\"\"주 x (부대,직무) 표.\"\"\"\n", + " return df.pivot_table(index=\"week\", columns=[\"unit\", \"job\"], values=col)\n", + "\n", + "# ---- 결원율 예측 모델(주간 결원율 변화량을 릿지 회귀로 맞히고 13주까지 재귀 적용, 외부 시계열 라이브러리 없이) ----\n", + "from sklearn.linear_model import Ridge\n", + "H = 13 # 예측 기간: 향후 3개월(13주)\n", + "NEED_CUT = 0.15 # 결원율 15% 이상이면 보충 필요(가상 기준)\n", + "\n", + "\n", + "def season(w):\n", + " return float(abs(w % 52 - 22) < 4 or abs(w % 52 - 46) < 3)\n", + "\n", + "\n", + "def feat(vr, ir, orr, wn, rot, v2=True):\n", + " \"\"\"vr·ir·orr: 지금까지의 결원율·보충율·전출율 목록, wn: 예측할 다음 주차, rot: 교대 주기(일). v2는 보충·전출 흐름과 달력 특징을 추가한다.\"\"\"\n", + " f = [vr[-1], vr[-1] - vr[-2], vr[-1] - vr[-4]]\n", + " if v2:\n", + " step = max(rot // 7, 1)\n", + " f += [float(np.mean(orr[-12:]) - np.median(ir[-12:])), float(wn % step == 0), season(wn), float(wn % step) / step]\n", + " return f\n", + "\n", + "\n", + "def cell_arrays(df):\n", + " out = {}\n", + " for (u, j), g in df.sort_values(\"week\").groupby([\"unit\", \"job\"]):\n", + " a = g[\"authorized\"].to_numpy()\n", + " out[(u, j)] = {\"auth\": int(a[0]), \"rot\": int(g[\"rotation_days\"].iloc[0]), \"vr\": (g[\"vacancy\"].to_numpy() / a), \"ir\": g[\"inflow\"].to_numpy() / a, \"or\": g[\"outflow\"].to_numpy() / a}\n", + " return out\n", + "\n", + "\n", + "def fit_fc(cells, origin, v2=True, alpha=1.0):\n", + " X, y = [], []\n", + " for c in cells.values():\n", + " for t in range(12, origin - 1): # t주까지 알 때 t+1주(인덱스 t)의 결원율 변화를 맞힌다\n", + " X.append(feat(c[\"vr\"][:t], c[\"ir\"][:t], c[\"or\"][:t], t + 1, c[\"rot\"], v2)); y.append(c[\"vr\"][t] - c[\"vr\"][t - 1])\n", + " return Ridge(alpha=alpha).fit(np.array(X), np.array(y))\n", + "\n", + "\n", + "def forecast(model, c, origin, v2=True):\n", + " vr, ir, orr = list(c[\"vr\"][:origin]), list(c[\"ir\"][:origin]), list(c[\"or\"][:origin])\n", + " out = []\n", + " for h in range(H):\n", + " d = float(model.predict([feat(vr, ir, orr, origin + h + 1, c[\"rot\"], v2)])[0])\n", + " p = float(np.clip(vr[-1] + d, 0, 1))\n", + " out.append(p); vr.append(p); ir.append(float(np.median(ir[-12:]))); orr.append(float(np.mean(orr[-12:])))\n", + " return np.array(out)\n", + "\n", + "\n", + "def mc_forecast(c, origin, n_path=300, seed=0, out_mult=1.0, lam=0.0):\n", + " \"\"\"재고-흐름(stock-flow) 모의: 셀의 평시·전출 집중기 전출율, 정규 보충율, 교대 주기 보충 규모를 과거에서 추정해 결원 경로 n_path개를 만든다.\n", + " 반환: (경로 배열 [n_path, H] 결원율, 추정 모수). out_mult는 전출 가정을 키우는 보정 계수, lam은 최근 8주 전출율을 반영하는 비중(0~1, 급증 감지 때만 적용, AAR 재보정용).\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " w = np.arange(1, origin + 1); step = max(c[\"rot\"] // 7, 1)\n", + " rot_w = (w % step == 0); sea = np.array([season(x) > 0 for x in w])\n", + " o, i = c[\"or\"][:origin], c[\"ir\"][:origin]\n", + " o_base = o[~sea].mean(); o_sea = o[sea].mean() if sea.sum() >= 6 else o_base * 1.9\n", + " o_rec = o[-8:].mean()\n", + " mu = max(o_base, 1e-6) * c[\"auth\"] * 8 # 최근 8주 평시 기대 전출 인원\n", + " use_rec = lam > 0 and o_rec * c[\"auth\"] * 8 > mu + 2.0 * np.sqrt(mu) # 포아송 기준 상위 2표준편차를 넘는 '급증'일 때만 최근 값을 반영\n", + " i_reg = i[~rot_w].mean(); batch = max(i[rot_w].mean() - i_reg, 0.0)\n", + " a = c[\"auth\"]; pres = np.full(n_path, a * (1 - c[\"vr\"][origin - 1]))\n", + " out = np.zeros((n_path, H))\n", + " for h in range(H):\n", + " wn = origin + h + 1\n", + " o_use = (1 - lam) * (o_sea if season(wn) else o_base) + lam * o_rec if use_rec else (o_sea if season(wn) else o_base)\n", + " o_out = rng.poisson(max(o_use, 1e-6) * a * out_mult, n_path)\n", + " o_in = rng.poisson(max(i_reg, 1e-6) * a, n_path) + (np.maximum(rng.normal(batch * a, 0.3 * batch * a, n_path), 0).round() if wn % step == 0 else 0)\n", + " pres = np.clip(pres - o_out + o_in, 0.4 * a, a)\n", + " out[:, h] = 1 - pres / a\n", + " return out, {\"o_base\": o_base, \"o_sea\": o_sea, \"i_reg\": i_reg, \"batch\": batch}\n", + "\n", + "PERM = {\"시스템\": {\"propose\"}, \"인사담당\": {\"submit\", \"redraft\", \"view\"}, \"인사관\": {\"review\", \"adjust\", \"reject\", \"view\"}, \"지휘관\": {\"approve\", \"reject\", \"feedback\", \"view\"},\n", + " \"감사관\": {\"view\", \"audit\"}, \"인사처장\": {\"submit\", \"approve\", \"reject\", \"view\"}} # 인사처장은 상신·승인을 겸할 수 있으나 같은 건은 분리\n", + "USERS = {\"sys\": \"시스템\", \"kim\": \"인사담당\", \"lee\": \"인사관\", \"park\": \"지휘관\", \"jung\": \"감사관\", \"oh\": \"인사처장\"}\n", + "NEXT = {\"제안\": (\"submit\", \"상신\"), \"상신\": (\"review\", \"인사관검토\"), \"인사관검토\": (\"approve\", \"승인\")}\n", + "MAX_REDRAFT = 2\n", + "ADJ_LIMIT = 0.5 # 인사관 조정 폭: 제안 인원의 +-50% 이내(범위 밖은 사유와 지휘관 승인 필요로 차단)\n", + "\n", + "\n", + "class Chain:\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", + "class Workflow:\n", + " def __init__(self):\n", + " self.audit, self.cases, self.calls = Chain(), {}, 0\n", + "\n", + " def _deny(self, who, case, action, why):\n", + " self.audit.add(event=\"DENIED\", who=who, case=case, action=action, reason=why); return False, why\n", + "\n", + " def act(self, who, case, action, reason=\"\", evidence=\"\", qty=None):\n", + " self.calls += 1\n", + " role = USERS.get(who)\n", + " if role is None:\n", + " return self._deny(who, case, action, \"미등록 사용자\")\n", + " if action == \"delete_log\":\n", + " return self._deny(who, case, action, \"감사 로그 삭제·수정 권한은 누구에게도 없음\")\n", + " if action not in PERM[role]:\n", + " return self._deny(who, case, action, f\"권한 없음({role} 역할에 {action} 권한 없음)\")\n", + " if action == \"propose\":\n", + " self.cases[case] = {\"state\": \"제안\", \"submitter\": None, \"version\": 1, \"qty\": qty, \"orig\": qty, \"feedback\": \"\"}\n", + " self.audit.add(event=\"PROPOSE\", who=who, case=case, qty=qty, reason=reason); return True, \"제안 등록\"\n", + " c = self.cases.get(case)\n", + " if c is None:\n", + " return self._deny(who, case, action, \"존재하지 않는 건\")\n", + " if c[\"state\"] in (\"승인\", \"종결(반려)\"):\n", + " return self._deny(who, case, action, f\"이미 종결된 건({c['state']})\")\n", + " if not reason.strip() or not evidence.strip():\n", + " return self._deny(who, case, action, \"사유·근거 입력 필수(반려·조정 사유 포함)\")\n", + " if action == \"adjust\":\n", + " if c[\"state\"] != \"인사관검토\":\n", + " return self._deny(who, case, action, f\"조정은 인사관 검토 단계에서만 가능(현재 {c['state']})\")\n", + " if qty is None or qty < 0 or abs(qty - c[\"orig\"]) > max(1, ADJ_LIMIT * c[\"orig\"]):\n", + " return self._deny(who, case, action, f\"조정 폭 한도 초과(제안 {c['orig']}명, 요청 {qty})\")\n", + " before, c[\"qty\"] = c[\"qty\"], qty\n", + " self.audit.add(event=\"ADJUST\", who=who, role=role, case=case, before=before, after=qty, reason=reason, evidence=evidence); return True, f\"{before}명 -> {qty}명 조정\"\n", + " if action == \"feedback\":\n", + " c[\"feedback\"] = reason\n", + " self.audit.add(event=\"FEEDBACK\", who=who, role=role, case=case, reason=reason, evidence=evidence); return True, \"지휘관 피드백 기록\"\n", + " if action == \"reject\":\n", + " if c[\"state\"] in (\"제안\", \"반려\"):\n", + " return self._deny(who, case, action, f\"반려할 수 없는 단계({c['state']})\")\n", + " c[\"state\"] = \"종결(반려)\" if c[\"version\"] > MAX_REDRAFT else \"반려\"\n", + " self.audit.add(event=\"REJECTED\", who=who, role=role, case=case, version=c[\"version\"], reason=reason, evidence=evidence); return True, c[\"state\"]\n", + " if action == \"redraft\":\n", + " if c[\"state\"] != \"반려\":\n", + " return self._deny(who, case, action, f\"재기안은 반려된 건만 가능(현재 {c['state']})\")\n", + " if who != c[\"submitter\"]:\n", + " return self._deny(who, case, action, \"재기안은 최초 상신자만 가능\")\n", + " c[\"state\"], c[\"version\"] = \"상신\", c[\"version\"] + 1\n", + " self.audit.add(event=\"REDRAFT\", who=who, role=role, case=case, version=c[\"version\"], reason=reason, evidence=evidence); return True, \"재기안 상신\"\n", + " if c[\"state\"] == \"반려\":\n", + " return self._deny(who, case, action, \"반려된 건은 재기안 후에만 진행 가능\")\n", + " need, nxt = NEXT[c[\"state\"]]\n", + " if action != need:\n", + " return self._deny(who, case, action, f\"단계 위반(현재 {c['state']}, 필요 행위 {need})\")\n", + " if action == \"submit\":\n", + " c[\"submitter\"] = who\n", + " if action == \"approve\" and who == c[\"submitter\"]:\n", + " return self._deny(who, case, action, \"상신자와 승인자 분리 원칙 위반\")\n", + " c[\"state\"] = nxt\n", + " self.audit.add(event=action.upper(), who=who, role=role, case=case, version=c[\"version\"], to=nxt, qty=c[\"qty\"], reason=reason, evidence=evidence); return True, nxt\n", + "\n", + "\n", + "# ---- 상신 대상: 향후 13주 보충 필요 확률이 높은 셀 10건과 같은 직무 여유 셀에서의 이동 인원 ----\n", + "df = make_manning(7, 104)\n", + "cells = cell_arrays(df)\n", + "rng = np.random.default_rng(3)\n", + "items = []\n", + "for k, c in cells.items():\n", + " paths, _ = mc_forecast(c, 104, n_path=300)\n", + " peaks = paths.max(axis=1)\n", + " items.append({\"cell\": k, \"auth\": c[\"auth\"], \"peak\": float(peaks.mean()), \"p\": float((peaks >= NEED_CUT).mean())})\n", + "risk = pd.DataFrame(items)\n", + "risk[\"risk\"] = 100 * (0.6 * risk[\"p\"] + 0.4 * np.minimum(risk[\"peak\"] / .3, 1))\n", + "risk[\"need\"] = [max(1, int(np.ceil((r.peak - 0.10) * r.auth))) for r in risk.itertuples()]\n", + "cand = risk[risk[\"risk\"] >= 20].sort_values(\"risk\", ascending=False).head(10).reset_index(drop=True)\n", + "print(f\"[상신 대상] 위험 20 이상 {len(risk[risk['risk'] >= 20])}개 셀 중 상위 {len(cand)}건 (필요 인원 합계 {int(cand['need'].sum())}명)\")\n", + "\n", + "# ---- 큐 시뮬레이션: 검토 담당 1명(건당 2시간), 건이 1시간 간격으로 도착. 선입선출 vs 위험도 우선(대기 노화 가산) ----\n", + "def run_queue(policy, risks, service=2.0, arr_gap=1.0):\n", + " n = len(risks); arr = np.arange(n) * arr_gap; done, t, waits = set(), 0.0, {}\n", + " while len(done) < n:\n", + " ready = [i for i in range(n) if i not in done and arr[i] <= t]\n", + " if not ready:\n", + " t = min(arr[i] for i in range(n) if i not in done); continue\n", + " pick = ready[0] if policy == \"fifo\" else max(ready, key=lambda i: risks[i] + 5.0 * (t - arr[i])) # 노화 가산: 1시간 대기마다 5점\n", + " waits[pick] = t - arr[pick]; t += service; done.add(pick)\n", + " return waits\n", + "rs = list(rng.permutation(cand[\"risk\"].to_numpy())) # 도착 순서는 위험도와 무관\n", + "wf_, wp_ = run_queue(\"fifo\", rs), run_queue(\"prio\", rs)\n", + "hi = [i for i, r in enumerate(rs) if r >= 60]\n", + "print(f\"\\n[검토 큐 대기시간(시간)] 건 {len(rs)}개, 위험 60 이상 {len(hi)}건\")\n", + "print(f\" 선입선출: 전체 평균 {np.mean(list(wf_.values())):.1f}h, 고위험 평균 {np.mean([wf_[i] for i in hi]):.1f}h, 최대 {max(wf_.values()):.1f}h\")\n", + "print(f\" 위험도 우선(노화 가산): 전체 평균 {np.mean(list(wp_.values())):.1f}h, 고위험 평균 {np.mean([wp_[i] for i in hi]):.1f}h, 최대 {max(wp_.values()):.1f}h (저위험 건이 굶지 않도록 노화 가산)\")\n", + "\n", + "# ---- 워크플로우 모의 ----\n", + "wf = Workflow()\n", + "res = []\n", + "for k, r in cand.iterrows():\n", + " case = f\"H{k + 1:02d}\"; q = int(r[\"need\"])\n", + " wf.act(\"sys\", case, \"propose\", reason=f\"{r['cell'][0]} {r['cell'][1]} 보충 필요 확률 {r['p']:.0%} -> 인원 {q}명 보충·이동 제안\", qty=q)\n", + " wf.act(\"kim\", case, \"submit\", \"보충 우선순위 검토 요청\", f\"위험 {r['risk']:.0f}점, 예상 최대 결원율 {r['peak']:.0%}\")\n", + " for _ in range(12):\n", + " s = wf.cases[case][\"state\"]\n", + " if s in (\"승인\", \"종결(반려)\"):\n", + " break\n", + " if s == \"상신\":\n", + " if (r[\"p\"] < 0.5 and k % 3 == 0 and wf.cases[case][\"version\"] == 1) or k == 9: # k==9는 보완 없이 계속 반려되는 건(재기안 한도 시연)\n", + " wf.act(\"lee\", case, \"reject\", \"확률 50% 미만, 다음 주 재확인\", f\"보충 필요 확률 {r['p']:.0%}\")\n", + " else:\n", + " wf.act(\"lee\", case, \"review\", \"자료·예측 검토 완료\", f\"위험 {r['risk']:.0f}점\")\n", + " if r[\"cell\"][1] == \"통신\": # 인사관 수동 조정 예: 다른 부대 지원 가능 인원 반영\n", + " wf.act(\"lee\", case, \"adjust\", \"인접 부대 지원 가능 인원 1명 반영해 감원 조정\", \"현황 보고 확인\", qty=max(q - 1, 0))\n", + " elif s == \"인사관검토\":\n", + " wf.act(\"park\", case, \"feedback\", \"긴급 건은 주간 회의 전 결정 요청\" if r[\"risk\"] >= 80 else \"정기 보충 계획에 포함\", \"지휘관 의견\")\n", + " wf.act(\"park\", case, \"approve\", \"승인: 보충·이동 진행\", f\"위험 {r['risk']:.0f}점, 인사관 검토·조정 완료\")\n", + " elif s == \"반려\":\n", + " wf.act(\"kim\", case, \"redraft\", \"반려 사유 반영해 재기안\", f\"확률 재산출 후 재상신 {wf.cases[case]['version']}차\")\n", + " c = wf.cases[case]; res.append((case, f\"{r['cell'][0]} {r['cell'][1]}\", c[\"orig\"], c[\"qty\"], c[\"state\"], c[\"version\"]))\n", + "print(\"\\n[결재 결과] 10건 (모의 결재자 기준, 실제 결재는 사람이 한다)\")\n", + "print(pd.DataFrame(res, columns=[\"건\", \"셀\", \"제안 인원\", \"최종 인원\", \"상태\", \"버전\"]).to_string(index=False))\n", + "print(f\"인사관 수동 조정 {sum(e['event'] == 'ADJUST' for e in wf.audit.log)}건(조정 전후·사유 기록), 반려 {sum(e['event'] == 'REJECTED' for e in wf.audit.log)}건(사유 필수), 지휘관 피드백 {sum(e['event'] == 'FEEDBACK' for e in wf.audit.log)}건\")\n", + "\n", + "# ---- 권한 우회·절차 위반 시도 (시험마다 새 건을 만들어 해당 단계까지 진행시킨 뒤 시도) ----\n", + "def mk(cid, stage=\"submit\"):\n", + " wf.act(\"sys\", cid, \"propose\", reason=\"시험\", qty=10)\n", + " if stage in (\"submit\", \"review\"):\n", + " wf.act(\"kim\", cid, \"submit\", \"시험 상신\", \"근거\")\n", + " if stage == \"review\":\n", + " wf.act(\"lee\", cid, \"review\", \"검토\", \"이상 없음\")\n", + " return cid\n", + "approved = next(c for c, v in wf.cases.items() if v[\"state\"] == \"승인\")\n", + "att = [(\"시스템 자동 승인\", lambda: wf.act(\"sys\", mk(\"T01\"), \"approve\", \"자동\", \"자동\")), (\"인사담당이 조정 시도\", lambda: wf.act(\"kim\", mk(\"T02\", \"review\"), \"adjust\", \"임의 조정\", \"근거\", qty=5)),\n", + " (\"검토 건너뛰고 승인\", lambda: wf.act(\"park\", mk(\"T03\"), \"approve\", \"바로 승인\", \"근거\")), (\"미등록 사용자\", lambda: wf.act(\"guest\", mk(\"T04\"), \"review\", \"열람\", \"근거\")),\n", + " (\"사유 없는 검토\", lambda: wf.act(\"lee\", mk(\"T05\"), \"review\", \"\", \"\")), (\"감사 로그 삭제\", lambda: wf.act(\"kim\", mk(\"T06\"), \"delete_log\", \"정리\", \"근거\")),\n", + " (\"감사관이 승인 시도\", lambda: wf.act(\"jung\", mk(\"T07\", \"review\"), \"approve\", \"승인\", \"근거\")), (\"존재하지 않는 건\", lambda: wf.act(\"park\", \"X99\", \"approve\", \"승인\", \"근거\")),\n", + " (\"종결된 건 재승인\", lambda: wf.act(\"park\", approved, \"approve\", \"재승인\", \"근거\")), (\"조정 폭 한도 초과(10명 -> 30명)\", lambda: wf.act(\"lee\", mk(\"T08\", \"review\"), \"adjust\", \"대폭 증원\", \"근거\", qty=30)),\n", + " (\"사유 없는 조정\", lambda: wf.act(\"lee\", mk(\"T09\", \"review\"), \"adjust\", \"\", \"근거\", qty=9)), (\"인사관이 승인 시도\", lambda: wf.act(\"lee\", mk(\"T10\", \"review\"), \"approve\", \"대신 승인\", \"근거\")),\n", + " (\"검토 전 단계 조정\", lambda: wf.act(\"lee\", mk(\"T11\"), \"adjust\", \"조정\", \"근거\", qty=9)),\n", + " (\"상신자가 본인 건 승인(분리 원칙)\", lambda: (wf.act(\"sys\", \"T12\", \"propose\", reason=\"시험\", qty=4), wf.act(\"oh\", \"T12\", \"submit\", \"상신\", \"근거\"), wf.act(\"lee\", \"T12\", \"review\", \"검토\", \"이상 없음\"), wf.act(\"oh\", \"T12\", \"approve\", \"본인 승인\", \"근거\"))[-1])]\n", + "print(\"\\n[권한 우회·절차 위반 시도]\")\n", + "blocked = 0\n", + "for n, fn in att:\n", + " ok, why = fn(); blocked += (not ok); print(f\" {n:24s}-> {'차단' if not ok else '통과(결함)'}: {why}\")\n", + "print(f\"차단 {blocked}/{len(att)} ({blocked / len(att):.0%})\")\n", + "\n", + "# ---- 로깅 성공률과 이력 무결성 ----\n", + "n_log = len(wf.audit.log); bad = wf.audit.verify()\n", + "print(f\"\\n처리 요청 {wf.calls}건 -> 감사 로그 {n_log}건(승인·반려·조정·거부 모두 기록), 로깅 성공률 {n_log / wf.calls:.0%} (목표 100%: {'달성' if n_log == wf.calls else '미달'})\")\n", + "print(f\"해시 체인 검증: {'정상' if bad is None else f'seq {bad} 불일치'} -> 이력 무결성 {'100%' if bad is None else '훼손'}\")\n", + "saved = json.dumps(wf.audit.log[12], ensure_ascii=False); wf.audit.log[12][\"reason\"] = \"사후 수정\"; v = wf.audit.verify()\n", + "print(f\"12번째 기록 임의 수정 후 검증: {'위변조 탐지(seq ' + str(v) + ')' if v is not None else '탐지 실패'}\"); wf.audit.log[12] = json.loads(saved)\n", + "print(\"※ 보충 우선순위와 인원 이동은 사람(인사관·지휘관)이 결정하며 시스템은 제안과 근거만 제공한다. 결재선은 소속 기관 규정을 따른다.\")\n", + "\n", + "plt.figure(figsize=(6, 3.5)); plt.bar([\"FIFO\", \"priority\"], [np.mean([wf_[i] for i in hi]), np.mean([wp_[i] for i in hi])], color=[\"tab:gray\", \"tab:blue\"])\n", + "plt.ylabel(\"mean wait of high-risk items (h)\"); plt.title(\"Review queue wait: FIFO vs risk priority\"); plt.tight_layout(); plt.show()\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 07 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + "[상신 대상] 위험 20 이상 11개 셀 중 상위 10건 (필요 인원 합계 53명)\n", + "\n", + "[검토 큐 대기시간(시간)] 건 10개, 위험 60 이상 6건\n", + " 선입선출: 전체 평균 4.5h, 고위험 평균 4.8h, 최대 9.0h\n", + " 위험도 우선(노화 가산): 전체 평균 4.5h, 고위험 평균 2.5h, 최대 11.0h (저위험 건이 굶지 않도록 노화 가산)\n", + "\n", + "[결재 결과] 10건 (모의 결재자 기준, 실제 결재는 사람이 한다)\n", + " 건 셀 제안 인원 최종 인원 상태 버전\n", + "H01 가상A대대 정비 8 8 승인 1\n", + "H02 가상C대대 통신 13 12 승인 1\n", + "H03 가상B대대 보급 15 15 승인 1\n", + "H04 가상D대대 의무 3 3 승인 1\n", + "H05 가상A대대 보급 4 4 승인 1\n", + "H06 가상D대대 통신 3 2 승인 1\n", + "H07 가상A대대 의무 2 2 승인 2\n", + "H08 가상C대대 정비 2 2 승인 1\n", + "H09 가상B대대 정비 1 1 승인 1\n", + "H10 가상D대대 행정 2 2 종결(반려) 3\n", + "인사관 수동 조정 2건(조정 전후·사유 기록), 반려 4건(사유 필수), 지휘관 피드백 9건\n", + "\n", + "[권한 우회·절차 위반 시도]\n", + " 시스템 자동 승인 -> 차단: 권한 없음(시스템 역할에 approve 권한 없음)\n", + " 인사담당이 조정 시도 -> 차단: 권한 없음(인사담당 역할에 adjust 권한 없음)\n", + " 검토 건너뛰고 승인 -> 차단: 단계 위반(현재 상신, 필요 행위 review)\n", + " 미등록 사용자 -> 차단: 미등록 사용자\n", + " 사유 없는 검토 -> 차단: 사유·근거 입력 필수(반려·조정 사유 포함)\n", + " 감사 로그 삭제 -> 차단: 감사 로그 삭제·수정 권한은 누구에게도 없음\n", + " 감사관이 승인 시도 -> 차단: 권한 없음(감사관 역할에 approve 권한 없음)\n", + " 존재하지 않는 건 -> 차단: 존재하지 않는 건\n", + " 종결된 건 재승인 -> 차단: 이미 종결된 건(승인)\n", + " 조정 폭 한도 초과(10명 -> 30명) -> 차단: 조정 폭 한도 초과(제안 10명, 요청 30)\n", + " 사유 없는 조정 -> 차단: 사유·근거 입력 필수(반려·조정 사유 포함)\n", + " 인사관이 승인 시도 -> 차단: 권한 없음(인사관 역할에 approve 권한 없음)\n", + " 검토 전 단계 조정 -> 차단: 조정은 인사관 검토 단계에서만 가능(현재 상신)\n", + " 상신자가 본인 건 승인(분리 원칙) -> 차단: 상신자와 승인자 분리 원칙 위반\n", + "차단 14/14 (100%)\n", + "\n", + "처리 요청 100건 -> 감사 로그 100건(승인·반려·조정·거부 모두 기록), 로깅 성공률 100% (목표 100%: 달성)\n", + "해시 체인 검증: 정상 -> 이력 무결성 100%\n", + "12번째 기록 임의 수정 후 검증: 위변조 탐지(seq 12)\n", + "... (이하 생략)\n", + "```\n", + "
\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 08. 전시 동원 및 대량 결원 발생 상황 모의 인사 예측 스트레스 시뮬레이션\n", + "**소요시간**: 약 3.5시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "전시 동원 및 대량 결원 모의 인사 예측 스트레스 시뮬레이션을 수행한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **특정 직무에서 대량 결원이 발생한 상황과 조회 요청 500% 증가 부하에서 시스템이 중단되거나 자료가 유실되지 않는지 스트레스 시뮬레이션으로 검증한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **한 부대 통신 직무의 전출이 갑자기 늘어난 상황에서 보충 필요 확률과 필요 인원이 어떻게 바뀌는지 평시와 비교 수행하고 검증하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `tests/adm01_stress_test.py 및 스트레스 테스트 분석 보고서` 형태의 결과물을 완성한다.\n", + "\n", + "### 🔍 평가 및 검증 기준 (PASS 조건)\n", + "#### 미션 판정\n", + "\n", + "**평가 구분:** 필수\n", + "\n", + "**핵심 평가영역:** 극단 상황에서의 안전 실패(Fail-safe) 및 기능 축소 작동 적절성\n", + "\n", + "#### 미션 PASS 조건\n", + "\n", + "- 정량 기준: 동시 트래픽 500% 증가 시 시스템 다운 0건, 데이터 유실률 0%\n", + "\n", + "\n", + "- 진행 기준: 대량 시뮬레이션 부하 주입 테스트 통과 및 병목 구간 프로파일링 완료\n", + "\n", + "\n", + "- 검증 방법: 부하 테스트 도구를 통한 처리량(Throughput) 및 메모리 누수 검증\n", + "\n", + "\n", + "#### 채점자 판정 메모\n", + "\n", + "> 단순 코드 실행 여부뿐만 아니라 출력된 수치와 근거 자료가 군 실무 요구사항에 부합하는지 면밀히 확인한다.\n", + "\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "\"\"\"특정 직무에서 대량 결원이 발생한 상황(전출 급증)의 예측 변화를 확인하고, 조회·예측 요청을 기준 대비 100%·200%·500%로 늘려 큐 기반 작업자(스레드) 처리·큐 포화 시 기능 축소(경량 예측)·오류 요청 격리를 시험하며 시스템 다운·데이터 유실 건수와 병목 함수 최적화(캐시) 효과를 측정한다.\"\"\"\n", + "import queue\n", + "import threading\n", + "import time\n", + "from functools import lru_cache\n", + "import numpy as np\n", + "import pandas as pd\n", + "import matplotlib.pyplot as plt\n", + "\n", + "# ---------------------------------------------------------------------------\n", + "# [교육용가상자료] 가상 부대 인력 현황 생성기 (실제 부대·편제·병력·개인 정보 없음)\n", + "# 가상 대대 4곳 x 직무 6종의 주간 집계 자료만 만든다. 개인 단위 정보(이름·군번 등)는 만들지 않는다.\n", + "# 인원은 직무별 집계 수이며, 결원·휴가·환자·교육·보충·전출을 반영한 현재원과 초과근무 시간으로 구성된다.\n", + "# ---------------------------------------------------------------------------\n", + "UNITS = [\"가상A대대\", \"가상B대대\", \"가상C대대\", \"가상D대대\"]\n", + "JOBS = [\"작전\", \"통신\", \"정비\", \"보급\", \"행정\", \"의무\"]\n", + "FIELDS = [\"unit\", \"job\", \"week\", \"authorized\", \"present\", \"vacancy\", \"leave\", \"sick\", \"training\", \"inflow\", \"outflow\", \"overtime_h\", \"night_ratio\", \"rotation_days\", \"fatigue_label\"]\n", + "HI_CUT = 55 # 고피로 판정 기준(가상 점수 55 이상)\n", + "\n", + "\n", + "def make_manning(seed=7, weeks=104, shock=None):\n", + " \"\"\"부대 x 직무 24개 셀의 주간 인력 자료. shock=(unit, job 또는 None(부대 전체), 시작주, 주간 추가 전출 평균 수) 한 건 또는 그 목록이면 그 셀에 대량 결원 요인을 넣는다(AAR·스트레스용).\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " shocks = [shock] if shock and not isinstance(shock[0], tuple) else list(shock or [])\n", + " rows = []\n", + " for ui, u in enumerate(UNITS):\n", + " for ji, j in enumerate(JOBS):\n", + " auth = int(rng.integers(14, 42) * (0.9 + 0.06 * ui))\n", + " work = rng.uniform(0.92, 1.12) # 직무별 업무량 계수\n", + " net = rng.uniform(-0.05, 0.03) # 구조적 보충 부족(음수)·과잉(양수) 경향(주당 인원)\n", + " present = int(auth * rng.uniform(0.82, 0.98))\n", + " rot = int(rng.choice([60, 90, 120])) # 교대·보충 주기(일)\n", + " for w in range(1, weeks + 1):\n", + " season = 1.0 + 0.9 * (abs(w % 52 - 22) < 4 or abs(w % 52 - 46) < 3) # 전출·전역 집중 시기\n", + " out = int(rng.poisson(0.22 * auth / 20 * season))\n", + " for s_ in shocks:\n", + " if s_[0] == u and (s_[1] is None or s_[1] == j) and w >= s_[2]:\n", + " out += int(rng.poisson(s_[3]))\n", + " inflow = int(rng.poisson(max(0.0, 0.2 * auth / 20 * (1 + net * 10)))) + (int(rng.integers(2, 6) * auth / 25) if w % (rot // 7) == 0 else 0)\n", + " present = int(np.clip(present - out + inflow, int(auth * 0.4), auth))\n", + " leave = int(rng.binomial(present, 0.05 + 0.05 * (abs(w % 52 - 31) < 3 or w % 52 >= 50)))\n", + " sick = int(rng.poisson(0.025 * present)); train = int(rng.binomial(present, 0.04 + 0.06 * (w % 13 == 6)))\n", + " avail = max(present - leave - sick - train, 1)\n", + " night = float(np.clip(rng.normal(0.25 + 0.05 * (j == \"작전\") + 0.05 * (j == \"의무\"), 0.05), 0.05, 0.6))\n", + " ot = float(np.clip(auth * 40 * work / avail - 40 + rng.normal(0, 1.5), 0, 40))\n", + " vac = auth - present\n", + " absent = (leave + sick + train) / max(present, 1)\n", + " fat = 100 * (0.38 * min(vac / auth / 0.25, 1.2) + 0.34 * min(ot / 20, 1.2) + 0.10 * min(night / 0.4, 1.2) + 0.18 * min(absent / 0.2, 1.2)) + rng.normal(0, 5)\n", + " rows.append((u, j, w, auth, present, vac, leave, sick, train, inflow, out, round(ot, 1), round(night, 2), rot, int(fat >= HI_CUT)))\n", + " return pd.DataFrame(rows, columns=FIELDS)\n", + "\n", + "\n", + "def cell_pivot(df, col=\"vacancy\"):\n", + " \"\"\"주 x (부대,직무) 표.\"\"\"\n", + " return df.pivot_table(index=\"week\", columns=[\"unit\", \"job\"], values=col)\n", + "\n", + "# ---- 결원율 예측 모델(주간 결원율 변화량을 릿지 회귀로 맞히고 13주까지 재귀 적용, 외부 시계열 라이브러리 없이) ----\n", + "from sklearn.linear_model import Ridge\n", + "H = 13 # 예측 기간: 향후 3개월(13주)\n", + "NEED_CUT = 0.15 # 결원율 15% 이상이면 보충 필요(가상 기준)\n", + "\n", + "\n", + "def season(w):\n", + " return float(abs(w % 52 - 22) < 4 or abs(w % 52 - 46) < 3)\n", + "\n", + "\n", + "def feat(vr, ir, orr, wn, rot, v2=True):\n", + " \"\"\"vr·ir·orr: 지금까지의 결원율·보충율·전출율 목록, wn: 예측할 다음 주차, rot: 교대 주기(일). v2는 보충·전출 흐름과 달력 특징을 추가한다.\"\"\"\n", + " f = [vr[-1], vr[-1] - vr[-2], vr[-1] - vr[-4]]\n", + " if v2:\n", + " step = max(rot // 7, 1)\n", + " f += [float(np.mean(orr[-12:]) - np.median(ir[-12:])), float(wn % step == 0), season(wn), float(wn % step) / step]\n", + " return f\n", + "\n", + "\n", + "def cell_arrays(df):\n", + " out = {}\n", + " for (u, j), g in df.sort_values(\"week\").groupby([\"unit\", \"job\"]):\n", + " a = g[\"authorized\"].to_numpy()\n", + " out[(u, j)] = {\"auth\": int(a[0]), \"rot\": int(g[\"rotation_days\"].iloc[0]), \"vr\": (g[\"vacancy\"].to_numpy() / a), \"ir\": g[\"inflow\"].to_numpy() / a, \"or\": g[\"outflow\"].to_numpy() / a}\n", + " return out\n", + "\n", + "\n", + "def fit_fc(cells, origin, v2=True, alpha=1.0):\n", + " X, y = [], []\n", + " for c in cells.values():\n", + " for t in range(12, origin - 1): # t주까지 알 때 t+1주(인덱스 t)의 결원율 변화를 맞힌다\n", + " X.append(feat(c[\"vr\"][:t], c[\"ir\"][:t], c[\"or\"][:t], t + 1, c[\"rot\"], v2)); y.append(c[\"vr\"][t] - c[\"vr\"][t - 1])\n", + " return Ridge(alpha=alpha).fit(np.array(X), np.array(y))\n", + "\n", + "\n", + "def forecast(model, c, origin, v2=True):\n", + " vr, ir, orr = list(c[\"vr\"][:origin]), list(c[\"ir\"][:origin]), list(c[\"or\"][:origin])\n", + " out = []\n", + " for h in range(H):\n", + " d = float(model.predict([feat(vr, ir, orr, origin + h + 1, c[\"rot\"], v2)])[0])\n", + " p = float(np.clip(vr[-1] + d, 0, 1))\n", + " out.append(p); vr.append(p); ir.append(float(np.median(ir[-12:]))); orr.append(float(np.mean(orr[-12:])))\n", + " return np.array(out)\n", + "\n", + "\n", + "def mc_forecast(c, origin, n_path=300, seed=0, out_mult=1.0, lam=0.0):\n", + " \"\"\"재고-흐름(stock-flow) 모의: 셀의 평시·전출 집중기 전출율, 정규 보충율, 교대 주기 보충 규모를 과거에서 추정해 결원 경로 n_path개를 만든다.\n", + " 반환: (경로 배열 [n_path, H] 결원율, 추정 모수). out_mult는 전출 가정을 키우는 보정 계수, lam은 최근 8주 전출율을 반영하는 비중(0~1, 급증 감지 때만 적용, AAR 재보정용).\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " w = np.arange(1, origin + 1); step = max(c[\"rot\"] // 7, 1)\n", + " rot_w = (w % step == 0); sea = np.array([season(x) > 0 for x in w])\n", + " o, i = c[\"or\"][:origin], c[\"ir\"][:origin]\n", + " o_base = o[~sea].mean(); o_sea = o[sea].mean() if sea.sum() >= 6 else o_base * 1.9\n", + " o_rec = o[-8:].mean()\n", + " mu = max(o_base, 1e-6) * c[\"auth\"] * 8 # 최근 8주 평시 기대 전출 인원\n", + " use_rec = lam > 0 and o_rec * c[\"auth\"] * 8 > mu + 2.0 * np.sqrt(mu) # 포아송 기준 상위 2표준편차를 넘는 '급증'일 때만 최근 값을 반영\n", + " i_reg = i[~rot_w].mean(); batch = max(i[rot_w].mean() - i_reg, 0.0)\n", + " a = c[\"auth\"]; pres = np.full(n_path, a * (1 - c[\"vr\"][origin - 1]))\n", + " out = np.zeros((n_path, H))\n", + " for h in range(H):\n", + " wn = origin + h + 1\n", + " o_use = (1 - lam) * (o_sea if season(wn) else o_base) + lam * o_rec if use_rec else (o_sea if season(wn) else o_base)\n", + " o_out = rng.poisson(max(o_use, 1e-6) * a * out_mult, n_path)\n", + " o_in = rng.poisson(max(i_reg, 1e-6) * a, n_path) + (np.maximum(rng.normal(batch * a, 0.3 * batch * a, n_path), 0).round() if wn % step == 0 else 0)\n", + " pres = np.clip(pres - o_out + o_in, 0.4 * a, a)\n", + " out[:, h] = 1 - pres / a\n", + " return out, {\"o_base\": o_base, \"o_sea\": o_sea, \"i_reg\": i_reg, \"batch\": batch}\n", + "\n", + "# 1) 대량 결원 상황: 같은 가상 부대에서 통신 직무 전출이 90주부터 주당 3명 늘어난 경우와 평시 비교\n", + "base = make_manning(7, 104)\n", + "surge = make_manning(7, 104, shock=(\"가상B대대\", \"통신\", 90, 3))\n", + "def need_table(df):\n", + " cells = cell_arrays(df)\n", + " rows = []\n", + " for k, c in cells.items():\n", + " paths, _ = mc_forecast(c, 104, n_path=200)\n", + " pk = paths.max(axis=1); rows.append((k, float((pk >= NEED_CUT).mean()), max(0, int(np.ceil((np.percentile(pk, 75) - 0.10) * c[\"auth\"])))))\n", + " return pd.DataFrame(rows, columns=[\"cell\", \"p\", \"need\"])\n", + "nb, ns = need_table(base), need_table(surge)\n", + "print(f\"[대량 결원 상황] 평시: 보충 필요 확률 50% 이상 셀 {int((nb['p'] >= .5).sum())}개, 필요 인원 합계 {int(nb['need'].sum())}명\")\n", + "print(f\" 가상B대대 통신 전출 급증: 확률 50% 이상 셀 {int((ns['p'] >= .5).sum())}개, 필요 인원 합계 {int(ns['need'].sum())}명 (해당 셀 {int(nb[nb['cell'] == ('가상B대대', '통신')]['need'].iloc[0])}명 -> {int(ns[ns['cell'] == ('가상B대대', '통신')]['need'].iloc[0])}명)\")\n", + "\n", + "# 2) 요청 처리 서버 모사: 큐 + 작업자 스레드, 큐가 차면 경량 예측으로 기능 축소(요청은 버리지 않는다)\n", + "CELLS = cell_arrays(surge)\n", + "KEYS = list(CELLS)\n", + "heavy_calls = {\"n\": 0}\n", + "\n", + "\n", + "def heavy(key, week):\n", + " heavy_calls[\"n\"] += 1\n", + " paths, _ = mc_forecast(CELLS[key], week, n_path=1000)\n", + " return float((paths.max(axis=1) >= NEED_CUT).mean())\n", + "\n", + "\n", + "cached = lru_cache(maxsize=512)(heavy)\n", + "\n", + "\n", + "def light(key):\n", + " \"\"\"경량 예측(기능 축소): 현재 결원율과 최근 4주 추세만 사용.\"\"\"\n", + " v = CELLS[key][\"vr\"]; return float(np.clip(v[-1] + 3 * (v[-1] - v[-5]) / 4, 0, 1) >= NEED_CUT)\n", + "\n", + "\n", + "def run(mult, optimized, base_rate=150, dur=0.8, workers=4, qmax=48, seed=1):\n", + " rng = np.random.default_rng(seed)\n", + " n = int(base_rate * dur * mult) # 초당 요청 수(기준 150건/초 x 배수)를 dur초 동안 일정 간격으로 발생\n", + " reqs = [(i, KEYS[rng.integers(len(KEYS))] if rng.random() > 0.02 else \"잘못된 셀\", 90 + int(rng.integers(0, 15))) for i in range(n)] # 2%는 잘못된 요청\n", + " q = queue.Queue(maxsize=qmax); out, lock = [], threading.Lock()\n", + " fn = cached if optimized else heavy\n", + "\n", + " def worker():\n", + " while True:\n", + " item = q.get()\n", + " if item is None:\n", + " return\n", + " i, key, wk, t_in = item\n", + " try:\n", + " if key not in CELLS:\n", + " raise KeyError(\"unknown cell\")\n", + " if i % 50 == 7:\n", + " raise RuntimeError(\"model failure(injected)\")\n", + " res, mode = fn(key, 104 if optimized else wk), \"정상\"\n", + " except KeyError:\n", + " res, mode = None, \"격리(잘못된 요청)\"\n", + " except RuntimeError:\n", + " res, mode = light(key), \"축소(모델 오류 대체)\"\n", + " with lock:\n", + " out.append((i, mode, time.perf_counter() - t_in))\n", + "\n", + " ths = [threading.Thread(target=worker) for _ in range(workers)]\n", + " for t in ths:\n", + " t.start()\n", + " t0 = time.perf_counter(); degraded = 0\n", + " for i, key, wk in reqs:\n", + " due = t0 + i / (base_rate * mult)\n", + " while time.perf_counter() < due:\n", + " time.sleep(0.0003)\n", + " item = (i, key, wk, time.perf_counter())\n", + " try:\n", + " q.put_nowait(item)\n", + " except queue.Full: # 큐 포화: 버리지 않고 경량 예측으로 즉시 응답(기능 축소)\n", + " degraded += 1\n", + " with lock:\n", + " out.append((i, \"축소(큐 포화)\", time.perf_counter() - item[3]))\n", + " if key in CELLS:\n", + " light(key)\n", + " for _ in ths:\n", + " q.put(None)\n", + " for t in ths:\n", + " t.join()\n", + " dt = time.perf_counter() - t0\n", + " lat = np.array([o[2] for o in out]) * 1000\n", + " modes = pd.Series([o[1] for o in out]).value_counts().to_dict()\n", + " return {\"n\": n, \"done\": len(out), \"dt\": dt, \"p95\": float(np.percentile(lat, 95)), \"modes\": modes, \"degraded\": degraded, \"ids\": {o[0] for o in out}}\n", + "\n", + "\n", + "print(\"\\n[요청 부하 시험] 기준 150건/초 x 배수를 0.8초간 발생, 작업자 4, 큐 48\")\n", + "rows = []\n", + "for mult in (1, 2, 5):\n", + " heavy_calls[\"n\"] = 0; cached.cache_clear()\n", + " r0 = run(mult, False); h0 = heavy_calls[\"n\"]\n", + " heavy_calls[\"n\"] = 0; cached.cache_clear()\n", + " r1 = run(mult, True); h1 = heavy_calls[\"n\"]\n", + " lost = r1[\"n\"] - len(r1[\"ids\"])\n", + " rows.append((mult, r0, r1, h0, h1, lost))\n", + " print(f\" {mult * 100:3d}%: 요청 {r1['n']}건 | 최적화 전 {r0['dt']:.2f}초(p95 {r0['p95']:.0f}ms, 축소 {r0['degraded']}건) -> 캐시 후 {r1['dt']:.2f}초(p95 {r1['p95']:.0f}ms, 축소 {r1['degraded']}건), 무거운 예측 {h0}회 -> {h1}회 | 유실 {lost}건\")\n", + "r5 = rows[-1][2]\n", + "print(\"500% 처리 내역:\", r5[\"modes\"])\n", + "tot_lost = sum(x[5] for x in rows)\n", + "print(f\"시스템 다운(처리되지 않은 예외) 0건, 데이터 유실 {tot_lost}건 / 응답 {sum(len(x[2]['ids']) for x in rows)}건 (목표 0%: {'달성' if tot_lost == 0 else '미달'})\")\n", + "print(f\"병목 최적화: 같은 셀의 최신 예측을 캐시(lru_cache)해 500%에서 p95 응답 {rows[-1][1]['p95']:.0f}ms -> {rows[-1][2]['p95']:.0f}ms, 기능 축소 {rows[-1][1]['degraded']}건 -> {rows[-1][2]['degraded']}건, 무거운 예측 호출 {rows[-1][3]}회 -> {rows[-1][4]}회. 병목은 셀당 1,000경로 모의 예측 함수(heavy)\")\n", + "print(\"[기능 축소 정책] 큐 포화·모델 오류 시 요청을 버리지 않고 '현재 결원율 + 최근 추세' 경량 예측으로 대체 응답하고 '축소' 표시를 남긴다. 잘못된 요청은 격리해 오류 로그에 기록한다.\")\n", + "print(\"※ 응답 시간은 실행 환경에 따라 달라지며, 실제 시스템은 별도 작업 큐(비동기)와 부하 시험이 필요하다. 모든 결정은 사람이 확인한다.\")\n", + "\n", + "fig, ax = plt.subplots(1, 2, figsize=(10, 4))\n", + "ax[0].plot([x[0] * 100 for x in rows], [x[1][\"dt\"] for x in rows], \"o-\", label=\"before\"); ax[0].plot([x[0] * 100 for x in rows], [x[2][\"dt\"] for x in rows], \"s-\", label=\"cached\"); ax[0].set_xlabel(\"traffic (%)\"); ax[0].set_ylabel(\"seconds\"); ax[0].legend(); ax[0].set_title(\"Processing time vs traffic\")\n", + "ax[1].bar([\"normal\", \"surge\"], [int(nb[\"need\"].sum()), int(ns[\"need\"].sum())], color=[\"tab:gray\", \"tab:red\"]); ax[1].set_title(\"Required replenishment (persons), normal vs surge\")\n", + "plt.tight_layout(); plt.show()\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 08 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + "[대량 결원 상황] 평시: 보충 필요 확률 50% 이상 셀 6개, 필요 인원 합계 67명\n", + " 가상B대대 통신 전출 급증: 확률 50% 이상 셀 7개, 필요 인원 합계 71명 (해당 셀 1명 -> 9명)\n", + "\n", + "[요청 부하 시험] 기준 150건/초 x 배수를 0.8초간 발생, 작업자 4, 큐 48\n", + " 100%: 요청 120건 | 최적화 전 0.80초(p95 8ms, 축소 0건) -> 캐시 후 0.80초(p95 5ms, 축소 0건), 무거운 예측 115회 -> 24회 | 유실 0건\n", + " 200%: 요청 240건 | 최적화 전 0.81초(p95 47ms, 축소 0건) -> 캐시 후 0.80초(p95 2ms, 축소 0건), 무거운 예측 232회 -> 24회 | 유실 0건\n", + " 500%: 요청 600건 | 최적화 전 0.94초(p95 198ms, 축소 317건) -> 캐시 후 0.80초(p95 8ms, 축소 0건), 무거운 예측 274회 -> 29회 | 유실 0건\n", + "500% 처리 내역: {'정상': 578, '축소(모델 오류 대체)': 12, '격리(잘못된 요청)': 10}\n", + "시스템 다운(처리되지 않은 예외) 0건, 데이터 유실 0건 / 응답 960건 (목표 0%: 달성)\n", + "병목 최적화: 같은 셀의 최신 예측을 캐시(lru_cache)해 500%에서 p95 응답 198ms -> 8ms, 기능 축소 317건 -> 0건, 무거운 예측 호출 274회 -> 29회. 병목은 셀당 1,000경로 모의 예측 함수(heavy)\n", + "[기능 축소 정책] 큐 포화·모델 오류 시 요청을 버리지 않고 '현재 결원율 + 최근 추세' 경량 예측으로 대체 응답하고 '축소' 표시를 남긴다. 잘못된 요청은 격리해 오류 로그에 기록한다.\n", + "※ 응답 시간은 실행 환경에 따라 달라지며, 실제 시스템은 별도 작업 큐(비동기)와 부하 시험이 필요하다. 모든 결정은 사람이 확인한다.\n", + "```\n", + "
\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 09. AAR 기반 인사 예측 오차 원인 분석 및 인력 예측 가중치 파라미터 재보정\n", + "**소요시간**: 약 3.0시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "AAR 기반 인사 예측 오차 원인을 분석하고 가중치를 재보정한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **AAR로 인사 예측 오차의 원인을 분석하고 예측 가중 파라미터를 재보정해 종합 정확도 개선과 재발 오류 현황을 검증한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **두 부대에서 전출이 늘어난 가상 세계 8개에서 91주 시점의 13주 예측을 실제와 비교해 편향과 원인(최근 전출 급증 미반영)을 분류 수행하고 검증하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `docs/adm01_aar_report.md 및 configs/adm01_tuned_params.yaml` 형태의 결과물을 완성한다.\n", + "\n", + "### 🔍 평가 및 검증 기준 (PASS 조건)\n", + "#### 미션 판정\n", + "\n", + "**평가 구분:** 필수\n", + "\n", + "**핵심 평가영역:** 오류 원인 규명의 객관성 및 개선 조치의 논리적 타당성\n", + "\n", + "#### 미션 PASS 조건\n", + "\n", + "- 정량 기준: AAR 피드백 적용 후 종합 정확도 10%p 이상 향상, 재발 오류 0건\n", + "\n", + "\n", + "- 진행 기준: AAR 보고서 작성 및 파라미터 튜닝 전후 회귀 테스트 통과\n", + "\n", + "\n", + "- 검증 방법: 회귀 테스트(Regression Test) 스위트 전체 자동 실행 및 비교\n", + "\n", + "\n", + "#### 채점자 판정 메모\n", + "\n", + "> 단순 코드 실행 여부뿐만 아니라 출력된 수치와 근거 자료가 군 실무 요구사항에 부합하는지 면밀히 확인한다.\n", + "\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "\"\"\"두 부대 전체에 전출·파견이 늘어난 가상 세계 8개에서 91주 시점 13주 결원 예측과 실제 결과의 편차를 AAR로 분석해 오차 원인(최근 전출 급증 미반영)을 분류하고, 최근 전출율 반영 비중과 판정 확률 기준을 튜닝 세계에서 재보정해 시험 세계에서 종합 정확도 개선·재발 오류를 검증하며 재보정 파라미터(YAML 문자열)와 지속 학습 트리거를 출력한다.\"\"\"\n", + "import numpy as np\n", + "import pandas as pd\n", + "import matplotlib.pyplot as plt\n", + "\n", + "# ---------------------------------------------------------------------------\n", + "# [교육용가상자료] 가상 부대 인력 현황 생성기 (실제 부대·편제·병력·개인 정보 없음)\n", + "# 가상 대대 4곳 x 직무 6종의 주간 집계 자료만 만든다. 개인 단위 정보(이름·군번 등)는 만들지 않는다.\n", + "# 인원은 직무별 집계 수이며, 결원·휴가·환자·교육·보충·전출을 반영한 현재원과 초과근무 시간으로 구성된다.\n", + "# ---------------------------------------------------------------------------\n", + "UNITS = [\"가상A대대\", \"가상B대대\", \"가상C대대\", \"가상D대대\"]\n", + "JOBS = [\"작전\", \"통신\", \"정비\", \"보급\", \"행정\", \"의무\"]\n", + "FIELDS = [\"unit\", \"job\", \"week\", \"authorized\", \"present\", \"vacancy\", \"leave\", \"sick\", \"training\", \"inflow\", \"outflow\", \"overtime_h\", \"night_ratio\", \"rotation_days\", \"fatigue_label\"]\n", + "HI_CUT = 55 # 고피로 판정 기준(가상 점수 55 이상)\n", + "\n", + "\n", + "def make_manning(seed=7, weeks=104, shock=None):\n", + " \"\"\"부대 x 직무 24개 셀의 주간 인력 자료. shock=(unit, job 또는 None(부대 전체), 시작주, 주간 추가 전출 평균 수) 한 건 또는 그 목록이면 그 셀에 대량 결원 요인을 넣는다(AAR·스트레스용).\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " shocks = [shock] if shock and not isinstance(shock[0], tuple) else list(shock or [])\n", + " rows = []\n", + " for ui, u in enumerate(UNITS):\n", + " for ji, j in enumerate(JOBS):\n", + " auth = int(rng.integers(14, 42) * (0.9 + 0.06 * ui))\n", + " work = rng.uniform(0.92, 1.12) # 직무별 업무량 계수\n", + " net = rng.uniform(-0.05, 0.03) # 구조적 보충 부족(음수)·과잉(양수) 경향(주당 인원)\n", + " present = int(auth * rng.uniform(0.82, 0.98))\n", + " rot = int(rng.choice([60, 90, 120])) # 교대·보충 주기(일)\n", + " for w in range(1, weeks + 1):\n", + " season = 1.0 + 0.9 * (abs(w % 52 - 22) < 4 or abs(w % 52 - 46) < 3) # 전출·전역 집중 시기\n", + " out = int(rng.poisson(0.22 * auth / 20 * season))\n", + " for s_ in shocks:\n", + " if s_[0] == u and (s_[1] is None or s_[1] == j) and w >= s_[2]:\n", + " out += int(rng.poisson(s_[3]))\n", + " inflow = int(rng.poisson(max(0.0, 0.2 * auth / 20 * (1 + net * 10)))) + (int(rng.integers(2, 6) * auth / 25) if w % (rot // 7) == 0 else 0)\n", + " present = int(np.clip(present - out + inflow, int(auth * 0.4), auth))\n", + " leave = int(rng.binomial(present, 0.05 + 0.05 * (abs(w % 52 - 31) < 3 or w % 52 >= 50)))\n", + " sick = int(rng.poisson(0.025 * present)); train = int(rng.binomial(present, 0.04 + 0.06 * (w % 13 == 6)))\n", + " avail = max(present - leave - sick - train, 1)\n", + " night = float(np.clip(rng.normal(0.25 + 0.05 * (j == \"작전\") + 0.05 * (j == \"의무\"), 0.05), 0.05, 0.6))\n", + " ot = float(np.clip(auth * 40 * work / avail - 40 + rng.normal(0, 1.5), 0, 40))\n", + " vac = auth - present\n", + " absent = (leave + sick + train) / max(present, 1)\n", + " fat = 100 * (0.38 * min(vac / auth / 0.25, 1.2) + 0.34 * min(ot / 20, 1.2) + 0.10 * min(night / 0.4, 1.2) + 0.18 * min(absent / 0.2, 1.2)) + rng.normal(0, 5)\n", + " rows.append((u, j, w, auth, present, vac, leave, sick, train, inflow, out, round(ot, 1), round(night, 2), rot, int(fat >= HI_CUT)))\n", + " return pd.DataFrame(rows, columns=FIELDS)\n", + "\n", + "\n", + "def cell_pivot(df, col=\"vacancy\"):\n", + " \"\"\"주 x (부대,직무) 표.\"\"\"\n", + " return df.pivot_table(index=\"week\", columns=[\"unit\", \"job\"], values=col)\n", + "\n", + "# ---- 결원율 예측 모델(주간 결원율 변화량을 릿지 회귀로 맞히고 13주까지 재귀 적용, 외부 시계열 라이브러리 없이) ----\n", + "from sklearn.linear_model import Ridge\n", + "H = 13 # 예측 기간: 향후 3개월(13주)\n", + "NEED_CUT = 0.15 # 결원율 15% 이상이면 보충 필요(가상 기준)\n", + "\n", + "\n", + "def season(w):\n", + " return float(abs(w % 52 - 22) < 4 or abs(w % 52 - 46) < 3)\n", + "\n", + "\n", + "def feat(vr, ir, orr, wn, rot, v2=True):\n", + " \"\"\"vr·ir·orr: 지금까지의 결원율·보충율·전출율 목록, wn: 예측할 다음 주차, rot: 교대 주기(일). v2는 보충·전출 흐름과 달력 특징을 추가한다.\"\"\"\n", + " f = [vr[-1], vr[-1] - vr[-2], vr[-1] - vr[-4]]\n", + " if v2:\n", + " step = max(rot // 7, 1)\n", + " f += [float(np.mean(orr[-12:]) - np.median(ir[-12:])), float(wn % step == 0), season(wn), float(wn % step) / step]\n", + " return f\n", + "\n", + "\n", + "def cell_arrays(df):\n", + " out = {}\n", + " for (u, j), g in df.sort_values(\"week\").groupby([\"unit\", \"job\"]):\n", + " a = g[\"authorized\"].to_numpy()\n", + " out[(u, j)] = {\"auth\": int(a[0]), \"rot\": int(g[\"rotation_days\"].iloc[0]), \"vr\": (g[\"vacancy\"].to_numpy() / a), \"ir\": g[\"inflow\"].to_numpy() / a, \"or\": g[\"outflow\"].to_numpy() / a}\n", + " return out\n", + "\n", + "\n", + "def fit_fc(cells, origin, v2=True, alpha=1.0):\n", + " X, y = [], []\n", + " for c in cells.values():\n", + " for t in range(12, origin - 1): # t주까지 알 때 t+1주(인덱스 t)의 결원율 변화를 맞힌다\n", + " X.append(feat(c[\"vr\"][:t], c[\"ir\"][:t], c[\"or\"][:t], t + 1, c[\"rot\"], v2)); y.append(c[\"vr\"][t] - c[\"vr\"][t - 1])\n", + " return Ridge(alpha=alpha).fit(np.array(X), np.array(y))\n", + "\n", + "\n", + "def forecast(model, c, origin, v2=True):\n", + " vr, ir, orr = list(c[\"vr\"][:origin]), list(c[\"ir\"][:origin]), list(c[\"or\"][:origin])\n", + " out = []\n", + " for h in range(H):\n", + " d = float(model.predict([feat(vr, ir, orr, origin + h + 1, c[\"rot\"], v2)])[0])\n", + " p = float(np.clip(vr[-1] + d, 0, 1))\n", + " out.append(p); vr.append(p); ir.append(float(np.median(ir[-12:]))); orr.append(float(np.mean(orr[-12:])))\n", + " return np.array(out)\n", + "\n", + "\n", + "def mc_forecast(c, origin, n_path=300, seed=0, out_mult=1.0, lam=0.0):\n", + " \"\"\"재고-흐름(stock-flow) 모의: 셀의 평시·전출 집중기 전출율, 정규 보충율, 교대 주기 보충 규모를 과거에서 추정해 결원 경로 n_path개를 만든다.\n", + " 반환: (경로 배열 [n_path, H] 결원율, 추정 모수). out_mult는 전출 가정을 키우는 보정 계수, lam은 최근 8주 전출율을 반영하는 비중(0~1, 급증 감지 때만 적용, AAR 재보정용).\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " w = np.arange(1, origin + 1); step = max(c[\"rot\"] // 7, 1)\n", + " rot_w = (w % step == 0); sea = np.array([season(x) > 0 for x in w])\n", + " o, i = c[\"or\"][:origin], c[\"ir\"][:origin]\n", + " o_base = o[~sea].mean(); o_sea = o[sea].mean() if sea.sum() >= 6 else o_base * 1.9\n", + " o_rec = o[-8:].mean()\n", + " mu = max(o_base, 1e-6) * c[\"auth\"] * 8 # 최근 8주 평시 기대 전출 인원\n", + " use_rec = lam > 0 and o_rec * c[\"auth\"] * 8 > mu + 2.0 * np.sqrt(mu) # 포아송 기준 상위 2표준편차를 넘는 '급증'일 때만 최근 값을 반영\n", + " i_reg = i[~rot_w].mean(); batch = max(i[rot_w].mean() - i_reg, 0.0)\n", + " a = c[\"auth\"]; pres = np.full(n_path, a * (1 - c[\"vr\"][origin - 1]))\n", + " out = np.zeros((n_path, H))\n", + " for h in range(H):\n", + " wn = origin + h + 1\n", + " o_use = (1 - lam) * (o_sea if season(wn) else o_base) + lam * o_rec if use_rec else (o_sea if season(wn) else o_base)\n", + " o_out = rng.poisson(max(o_use, 1e-6) * a * out_mult, n_path)\n", + " o_in = rng.poisson(max(i_reg, 1e-6) * a, n_path) + (np.maximum(rng.normal(batch * a, 0.3 * batch * a, n_path), 0).round() if wn % step == 0 else 0)\n", + " pres = np.clip(pres - o_out + o_in, 0.4 * a, a)\n", + " out[:, h] = 1 - pres / a\n", + " return out, {\"o_base\": o_base, \"o_sea\": o_sea, \"i_reg\": i_reg, \"batch\": batch}\n", + "\n", + "ORIGIN = 91\n", + "rng = np.random.default_rng(5)\n", + "JOBS_ = list(JOBS)\n", + "worlds = []\n", + "for s in range(21, 29):\n", + " us = rng.choice(4, 2, replace=False)\n", + " sh = [(UNITS[u], None, 82, float(rng.uniform(0.4, 0.8))) for u in us] # 예측 시점 9주 전부터 두 부대 전체 직무에 주당 평균 0.4~0.8명 추가 전출(파견·동원 해제 등 정책 변화)\n", + " d = make_manning(s, 104, shock=sh)\n", + " worlds.append({\"seed\": s, \"shock\": sh, \"cells\": cell_arrays(d)})\n", + "TUNE, TEST = worlds[:4], worlds[4:]\n", + "\n", + "\n", + "def evaluate(ws, lam, p0, out_mult=1.0):\n", + " rows = []\n", + " for w in ws:\n", + " for k, c in w[\"cells\"].items():\n", + " paths, _ = mc_forecast(c, ORIGIN, lam=lam, out_mult=out_mult, n_path=300)\n", + " pk = paths.max(axis=1); act = c[\"vr\"][ORIGIN:ORIGIN + H]\n", + " need = lambda peak: max(0, int(np.ceil((peak - 0.10) * c[\"auth\"])))\n", + " rows.append({\"seed\": w[\"seed\"], \"cell\": k, \"shock\": k[0] in [x[0] for x in w[\"shock\"]], \"p\": float((pk >= NEED_CUT).mean()), \"pred_peak\": float(pk.mean()), \"act_peak\": float(act.max()), \"auth\": c[\"auth\"],\n", + " \"act_need\": bool(act.max() >= NEED_CUT), \"pred_n\": need(float(np.percentile(pk, 75))), \"act_n\": need(float(act.max()))})\n", + " r = pd.DataFrame(rows); r[\"pred_need\"] = r[\"p\"] >= p0\n", + " return r\n", + "\n", + "\n", + "def acc(r):\n", + " \"\"\"종합 정확도 = 보충 필요 인원을 실제와 +-2명 이내로 맞힌 셀 비율(보충 필요량 예측의 정확도).\"\"\"\n", + " return float(((r[\"pred_n\"] - r[\"act_n\"]).abs() <= 2).mean())\n", + "\n", + "\n", + "def acc_cls(r):\n", + " return float((r[\"pred_need\"] == r[\"act_need\"]).mean())\n", + "\n", + "\n", + "# 1) AAR: 기존 모델(lam=0, p0=0.5)의 편차\n", + "b0 = evaluate(worlds, 0.0, 0.5)\n", + "b0[\"err\"] = (b0[\"act_peak\"] - b0[\"pred_peak\"]) * b0[\"auth\"]\n", + "print(f\"[AAR] 8개 세계 x 24개 셀 = {len(b0)}건, 기존 모델 종합 정확도(보충 인원 +-2명 적중) {acc(b0):.1%}, 보충 필요 여부 판정 정확도 {acc_cls(b0):.1%}, 최대 결원 예측 평균 편향 {b0['err'].mean():+.2f}명 (실제가 큼)\")\n", + "sh = b0[b0[\"shock\"]]; ot_ = b0[~b0[\"shock\"]]\n", + "print(f\" 전출 급증 부대 셀 {len(sh)}건: 평균 오차 {sh['err'].mean():+.1f}명, 보충 필요를 놓친 건 {int((sh['act_need'] & ~sh['pred_need']).sum())}건 / 그 외 셀 {len(ot_)}건: 평균 오차 {ot_['err'].mean():+.2f}명\")\n", + "share = float(sh[\"err\"].abs().sum() / b0[\"err\"].abs().sum())\n", + "print(f\" 전체 절대오차 중 전출 급증 부대 셀이 차지하는 비중 {share:.0%} (셀 비중 {len(sh) / len(b0):.0%})\")\n", + "cause = pd.DataFrame({\"원인\": [\"최근 전출 급증을 과거 평균으로만 추정(주 원인)\", \"보충 도착 시점 불확실\", \"전출 집중기 추정 오차\"],\n", + " \"근거\": [f\"전출 급증 부대 셀 오차 {sh['err'].mean():+.1f}명\", f\"그 외 셀 오차 표준편차 {ot_['err'].std():.2f}명\", f\"그 외 셀 평균 편향 {ot_['err'].mean():+.2f}명\"], \"조치\": [\"최근 8주 전출율 반영(급증 감지 시, 비중 lam) 도입\", \"확률 기준(p0) 재설정\", \"연간 자료 누적 후 재추정\"]})\n", + "print(cause.to_string(index=False))\n", + "\n", + "# 2) 재보정: 튜닝 세계에서 최근 전출 반영 비중 lam은 최대 결원 인원 MAE가 최소인 값, 판정 기준 p0는 판정 정확도(동률이면 재현율)가 최대인 값으로 격자 탐색\n", + "mae_of = lambda r: float(np.mean(np.abs(r[\"act_peak\"] - r[\"pred_peak\"]) * r[\"auth\"]))\n", + "print(\"\\n[재보정 격자 탐색(튜닝 세계 4개)]\")\n", + "grid = {}\n", + "for lam in (0.0, 0.5, 0.8, 1.0):\n", + " r = evaluate(TUNE, lam, 0.5); grid[lam] = mae_of(r)\n", + " print(f\" lam {lam:.1f}: 최대 결원 MAE {grid[lam]:.2f}명, 종합 정확도 {acc(r):.1%}, 판정 정확도 {acc_cls(r):.1%}(p0 0.5)\")\n", + "LAM = min(grid, key=grid.get)\n", + "best = None\n", + "for p0 in (0.2, 0.3, 0.4, 0.5, 0.6):\n", + " r = evaluate(TUNE, LAM, p0); rc = float((r[\"pred_need\"] & r[\"act_need\"]).sum() / max(r[\"act_need\"].sum(), 1))\n", + " if best is None or (acc_cls(r), rc) > (best[0], best[1]):\n", + " best = (acc_cls(r), rc, p0)\n", + "P0 = best[2]\n", + "print(f\"채택 파라미터: lam {LAM}(MAE 최소), p0 {P0}(튜닝 세계 판정 정확도 {best[0]:.1%})\")\n", + "\n", + "# 3) 시험 세계 4개로 검증\n", + "t0, t1 = evaluate(TEST, 0.0, 0.5), evaluate(TEST, LAM, P0)\n", + "rcl = lambda r: float((r[\"pred_need\"] & r[\"act_need\"]).sum() / max(r[\"act_need\"].sum(), 1))\n", + "prc = lambda r: float((r[\"pred_need\"] & r[\"act_need\"]).sum() / max(r[\"pred_need\"].sum(), 1))\n", + "print(f\"\\n[시험 세계 검증] 종합 정확도 {acc(t0):.1%} -> {acc(t1):.1%} ({(acc(t1) - acc(t0)) * 100:+.1f}%p, 목표 10%p 이상: {'달성' if acc(t1) - acc(t0) >= 0.10 else '미달'}), 판정 정확도 {acc_cls(t0):.1%} -> {acc_cls(t1):.1%}, 재현율 {rcl(t0):.1%} -> {rcl(t1):.1%}, 정밀도 {prc(t0):.1%} -> {prc(t1):.1%}\")\n", + "shk0, shk1 = t0[t0[\"shock\"]], t1[t1[\"shock\"]]\n", + "miss0, miss1 = int((shk0[\"act_need\"] & ~shk0[\"pred_need\"]).sum()), int((shk1[\"act_need\"] & ~shk1[\"pred_need\"]).sum())\n", + "print(f\"재발 오류(전출 급증 부대 셀 {len(shk1)}건 중 보충 필요를 놓친 건): {miss0}건 -> {miss1}건 (목표 0건: {'달성' if miss1 == 0 else '미달'})\")\n", + "mae0 = float(np.mean(np.abs(t0[\"act_peak\"] - t0[\"pred_peak\"]) * t0[\"auth\"])); mae1 = float(np.mean(np.abs(t1[\"act_peak\"] - t1[\"pred_peak\"]) * t1[\"auth\"]))\n", + "print(f\"최대 결원 인원 MAE {mae0:.2f}명 -> {mae1:.2f}명, 전출 급증 부대 셀 평균 오차 {((shk0['act_peak'] - shk0['pred_peak']) * shk0['auth']).mean():+.1f}명 -> {((shk1['act_peak'] - shk1['pred_peak']) * shk1['auth']).mean():+.1f}명\")\n", + "fp1 = t1[t1[\"pred_need\"] & ~t1[\"act_need\"]]\n", + "print(f\"부작용 점검: 새 오경보 {len(fp1)}건(기존 {int((t0['pred_need'] & ~t0['act_need']).sum())}건) -> 정밀도 변화를 함께 보고 채택 여부를 사람이 결정\")\n", + "\n", + "# 4) 재보정 파라미터와 지속 학습 연계\n", + "YAML = f\"\"\"[adm01_tuned_params 제안: 인사관 확인 후 적용]\n", + "forecast:\n", + " recent_outflow_weight: {LAM} # 최근 8주 전출율 반영 비중(평시 기대치+2표준편차 초과 급증일 때만)\n", + " need_probability_cut: {P0}\n", + " need_vacancy_rate: {NEED_CUT}\n", + "retrain_trigger:\n", + " rolling_accuracy_below: 0.80 # 최근 2개 예측 시점 연속 미달이면 재보정 검토\n", + " vacancy_surge_weeks: 2 # 한 셀의 주간 전출이 평균의 2배를 2주 연속 넘으면 알림\n", + "review: 인사관 확인 후 적용, 이전 파라미터로 되돌리기 가능\"\"\"\n", + "print(\"\\n\" + YAML)\n", + "print(\"지속 학습 연계: 분기마다 AAR 표본(예측-실제 쌍)을 쌓아 같은 절차로 lam·p0를 재탐색하고, 재보정 전후 비교표와 승인 이력을 남긴다. 가상 자료 기준 결과이며 적용은 사람이 결정한다.\")\n", + "\n", + "plt.figure(figsize=(8, 4))\n", + "labels = [\"before\", \"after\"]; plt.bar([0, 1], [acc(t0) * 100, acc(t1) * 100], color=[\"tab:gray\", \"tab:blue\"], width=.4); plt.xticks([0, 1], labels)\n", + "plt.bar([2, 3], [rcl(t0) * 100, rcl(t1) * 100], color=[\"tab:gray\", \"tab:blue\"], width=.4); plt.xticks([0, 1, 2, 3], [\"acc before\", \"acc after\", \"recall before\", \"recall after\"], fontsize=8)\n", + "plt.ylabel(\"%\"); plt.title(\"AAR recalibration effect on test worlds\"); plt.tight_layout(); plt.show()\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 09 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + "[AAR] 8개 세계 x 24개 셀 = 192건, 기존 모델 종합 정확도(보충 인원 +-2명 적중) 72.4%, 보충 필요 여부 판정 정확도 78.1%, 최대 결원 예측 평균 편향 +1.69명 (실제가 큼)\n", + " 전출 급증 부대 셀 96건: 평균 오차 +3.4명, 보충 필요를 놓친 건 17건 / 그 외 셀 96건: 평균 오차 +0.01명\n", + " 전체 절대오차 중 전출 급증 부대 셀이 차지하는 비중 72% (셀 비중 50%)\n", + " 원인 근거 조치\n", + "최근 전출 급증을 과거 평균으로만 추정(주 원인) 전출 급증 부대 셀 오차 +3.4명 최근 8주 전출율 반영(급증 감지 시, 비중 lam) 도입\n", + " 보충 도착 시점 불확실 그 외 셀 오차 표준편차 1.62명 확률 기준(p0) 재설정\n", + " 전출 집중기 추정 오차 그 외 셀 평균 편향 +0.01명 연간 자료 누적 후 재추정\n", + "\n", + "[재보정 격자 탐색(튜닝 세계 4개)]\n", + " lam 0.0: 최대 결원 MAE 2.18명, 종합 정확도 81.2%, 판정 정확도 76.0%(p0 0.5)\n", + " lam 0.5: 최대 결원 MAE 1.76명, 종합 정확도 82.3%, 판정 정확도 78.1%(p0 0.5)\n", + " lam 0.8: 최대 결원 MAE 1.71명, 종합 정확도 80.2%, 판정 정확도 79.2%(p0 0.5)\n", + " lam 1.0: 최대 결원 MAE 1.75명, 종합 정확도 80.2%, 판정 정확도 79.2%(p0 0.5)\n", + "채택 파라미터: lam 0.8(MAE 최소), p0 0.3(튜닝 세계 판정 정확도 84.4%)\n", + "\n", + "[시험 세계 검증] 종합 정확도 63.5% -> 67.7% (+4.2%p, 목표 10%p 이상: 미달), 판정 정확도 80.2% -> 80.2%, 재현율 76.4% -> 84.7%, 정밀도 96.5% -> 88.4%\n", + "재발 오류(전출 급증 부대 셀 48건 중 보충 필요를 놓친 건): 8건 -> 5건 (목표 0건: 미달)\n", + "최대 결원 인원 MAE 2.57명 -> 2.12명, 전출 급증 부대 셀 평균 오차 +3.6명 -> +1.7명\n", + "부작용 점검: 새 오경보 8건(기존 2건) -> 정밀도 변화를 함께 보고 채택 여부를 사람이 결정\n", + "\n", + "[adm01_tuned_params 제안: 인사관 확인 후 적용]\n", + "forecast:\n", + " recent_outflow_weight: 0.8 # 최근 8주 전출율 반영 비중(평시 기대치+2표준편차 초과 급증일 때만)\n", + " need_probability_cut: 0.3\n", + " need_vacancy_rate: 0.15\n", + "retrain_trigger:\n", + " rolling_accuracy_below: 0.80 # 최근 2개 예측 시점 연속 미달이면 재보정 검토\n", + " vacancy_surge_weeks: 2 # 한 셀의 주간 전출이 평균의 2배를 2주 연속 넘으면 알림\n", + "review: 인사관 확인 후 적용, 이전 파라미터로 되돌리기 가능\n", + "지속 학습 연계: 분기마다 AAR 표본(예측-실제 쌍)을 쌓아 같은 절차로 lam·p0를 재탐색하고, 재보정 전후 비교표와 승인 이력을 남긴다. 가상 자료 기준 결과이며 적용은 사람이 결정한다.\n", + "```\n", + "
\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 10. 결원·근무부하 추세 대시보드 및 인력 보충 예측 시스템 통합 패키지 완성\n", + "**소요시간**: 약 3.5시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "결원·근무부하 추세 대시보드 및 인력 보충 예측 시스템 패키지를 완성한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **검증·전처리·피로도·결원 예측·위험 지수·검토 큐·감사 로그·브리핑을 묶은 결원·근무부하 대시보드 및 인력 보충 예측 시스템 패키지를 완성한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **앞 미션의 기능을 통합 파이프라인으로 묶어 설치 스크립트 5단계 점검, 시드 4종 End-to-End 실행(각 2행 손상 주입), 자체 점검 9항목과 함수 커버리지 수행하고 분석하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `src/main_adm01.py, README.md, docs/adm01_final_report.pdf(md)` 형태의 결과물을 완성한다.\n", + "\n", + "### 🔍 평가 및 검증 기준 (PASS 조건)\n", + "#### 미션 판정\n", + "\n", + "**평가 구분:** 필수\n", + "\n", + "**핵심 평가영역:** 군 실무 즉시 도입 가능성, 운용 신뢰도 및 전략적 기대효과\n", + "\n", + "#### 미션 PASS 조건\n", + "\n", + "- 정량 기준: End-to-End 통합 파이프라인 실행 성공률 100%, 테스트 커버리지 85% 이상\n", + "\n", + "\n", + "- 진행 기준: 원클릭 실행 스크립트 동작 확인 및 최종 발표 산출물 작성 완료\n", + "\n", + "\n", + "- 검증 방법: 최종 검증 위원회(평가관) 모의 시연 및 전체 기능 통합 검증\n", + "\n", + "\n", + "#### 채점자 판정 메모\n", + "\n", + "> 단순 코드 실행 여부뿐만 아니라 출력된 수치와 근거 자료가 군 실무 요구사항에 부합하는지 면밀히 확인한다.\n", + "\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "\"\"\"입력 검증·격리, 근무일지 전처리, 피로도 점수, 13주 결원 예측과 위험 지수·신뢰도, 사람 검토 큐(PENDING)와 해시 체인 감사 로그, 브리핑 요약까지 결원·근무부하 추세 및 보충 필요량 예측 시스템을 한 파일로 통합 실행하고 설치 스크립트 점검·End-to-End 4종·자체 점검(함수 커버리지)·매뉴얼 작성률을 출력한다.\"\"\"\n", + "import hashlib\n", + "import json\n", + "import re\n", + "import sys\n", + "import time\n", + "import numpy as np\n", + "import pandas as pd\n", + "import matplotlib.pyplot as plt\n", + "\n", + "# ---------------------------------------------------------------------------\n", + "# [교육용가상자료] 가상 부대 인력 현황 생성기 (실제 부대·편제·병력·개인 정보 없음)\n", + "# 가상 대대 4곳 x 직무 6종의 주간 집계 자료만 만든다. 개인 단위 정보(이름·군번 등)는 만들지 않는다.\n", + "# 인원은 직무별 집계 수이며, 결원·휴가·환자·교육·보충·전출을 반영한 현재원과 초과근무 시간으로 구성된다.\n", + "# ---------------------------------------------------------------------------\n", + "UNITS = [\"가상A대대\", \"가상B대대\", \"가상C대대\", \"가상D대대\"]\n", + "JOBS = [\"작전\", \"통신\", \"정비\", \"보급\", \"행정\", \"의무\"]\n", + "FIELDS = [\"unit\", \"job\", \"week\", \"authorized\", \"present\", \"vacancy\", \"leave\", \"sick\", \"training\", \"inflow\", \"outflow\", \"overtime_h\", \"night_ratio\", \"rotation_days\", \"fatigue_label\"]\n", + "HI_CUT = 55 # 고피로 판정 기준(가상 점수 55 이상)\n", + "\n", + "\n", + "def make_manning(seed=7, weeks=104, shock=None):\n", + " \"\"\"부대 x 직무 24개 셀의 주간 인력 자료. shock=(unit, job 또는 None(부대 전체), 시작주, 주간 추가 전출 평균 수) 한 건 또는 그 목록이면 그 셀에 대량 결원 요인을 넣는다(AAR·스트레스용).\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " shocks = [shock] if shock and not isinstance(shock[0], tuple) else list(shock or [])\n", + " rows = []\n", + " for ui, u in enumerate(UNITS):\n", + " for ji, j in enumerate(JOBS):\n", + " auth = int(rng.integers(14, 42) * (0.9 + 0.06 * ui))\n", + " work = rng.uniform(0.92, 1.12) # 직무별 업무량 계수\n", + " net = rng.uniform(-0.05, 0.03) # 구조적 보충 부족(음수)·과잉(양수) 경향(주당 인원)\n", + " present = int(auth * rng.uniform(0.82, 0.98))\n", + " rot = int(rng.choice([60, 90, 120])) # 교대·보충 주기(일)\n", + " for w in range(1, weeks + 1):\n", + " season = 1.0 + 0.9 * (abs(w % 52 - 22) < 4 or abs(w % 52 - 46) < 3) # 전출·전역 집중 시기\n", + " out = int(rng.poisson(0.22 * auth / 20 * season))\n", + " for s_ in shocks:\n", + " if s_[0] == u and (s_[1] is None or s_[1] == j) and w >= s_[2]:\n", + " out += int(rng.poisson(s_[3]))\n", + " inflow = int(rng.poisson(max(0.0, 0.2 * auth / 20 * (1 + net * 10)))) + (int(rng.integers(2, 6) * auth / 25) if w % (rot // 7) == 0 else 0)\n", + " present = int(np.clip(present - out + inflow, int(auth * 0.4), auth))\n", + " leave = int(rng.binomial(present, 0.05 + 0.05 * (abs(w % 52 - 31) < 3 or w % 52 >= 50)))\n", + " sick = int(rng.poisson(0.025 * present)); train = int(rng.binomial(present, 0.04 + 0.06 * (w % 13 == 6)))\n", + " avail = max(present - leave - sick - train, 1)\n", + " night = float(np.clip(rng.normal(0.25 + 0.05 * (j == \"작전\") + 0.05 * (j == \"의무\"), 0.05), 0.05, 0.6))\n", + " ot = float(np.clip(auth * 40 * work / avail - 40 + rng.normal(0, 1.5), 0, 40))\n", + " vac = auth - present\n", + " absent = (leave + sick + train) / max(present, 1)\n", + " fat = 100 * (0.38 * min(vac / auth / 0.25, 1.2) + 0.34 * min(ot / 20, 1.2) + 0.10 * min(night / 0.4, 1.2) + 0.18 * min(absent / 0.2, 1.2)) + rng.normal(0, 5)\n", + " rows.append((u, j, w, auth, present, vac, leave, sick, train, inflow, out, round(ot, 1), round(night, 2), rot, int(fat >= HI_CUT)))\n", + " return pd.DataFrame(rows, columns=FIELDS)\n", + "\n", + "\n", + "def cell_pivot(df, col=\"vacancy\"):\n", + " \"\"\"주 x (부대,직무) 표.\"\"\"\n", + " return df.pivot_table(index=\"week\", columns=[\"unit\", \"job\"], values=col)\n", + "\n", + "# ---- 결원율 예측 모델(주간 결원율 변화량을 릿지 회귀로 맞히고 13주까지 재귀 적용, 외부 시계열 라이브러리 없이) ----\n", + "from sklearn.linear_model import Ridge\n", + "H = 13 # 예측 기간: 향후 3개월(13주)\n", + "NEED_CUT = 0.15 # 결원율 15% 이상이면 보충 필요(가상 기준)\n", + "\n", + "\n", + "def season(w):\n", + " return float(abs(w % 52 - 22) < 4 or abs(w % 52 - 46) < 3)\n", + "\n", + "\n", + "def feat(vr, ir, orr, wn, rot, v2=True):\n", + " \"\"\"vr·ir·orr: 지금까지의 결원율·보충율·전출율 목록, wn: 예측할 다음 주차, rot: 교대 주기(일). v2는 보충·전출 흐름과 달력 특징을 추가한다.\"\"\"\n", + " f = [vr[-1], vr[-1] - vr[-2], vr[-1] - vr[-4]]\n", + " if v2:\n", + " step = max(rot // 7, 1)\n", + " f += [float(np.mean(orr[-12:]) - np.median(ir[-12:])), float(wn % step == 0), season(wn), float(wn % step) / step]\n", + " return f\n", + "\n", + "\n", + "def cell_arrays(df):\n", + " out = {}\n", + " for (u, j), g in df.sort_values(\"week\").groupby([\"unit\", \"job\"]):\n", + " a = g[\"authorized\"].to_numpy()\n", + " out[(u, j)] = {\"auth\": int(a[0]), \"rot\": int(g[\"rotation_days\"].iloc[0]), \"vr\": (g[\"vacancy\"].to_numpy() / a), \"ir\": g[\"inflow\"].to_numpy() / a, \"or\": g[\"outflow\"].to_numpy() / a}\n", + " return out\n", + "\n", + "\n", + "def fit_fc(cells, origin, v2=True, alpha=1.0):\n", + " X, y = [], []\n", + " for c in cells.values():\n", + " for t in range(12, origin - 1): # t주까지 알 때 t+1주(인덱스 t)의 결원율 변화를 맞힌다\n", + " X.append(feat(c[\"vr\"][:t], c[\"ir\"][:t], c[\"or\"][:t], t + 1, c[\"rot\"], v2)); y.append(c[\"vr\"][t] - c[\"vr\"][t - 1])\n", + " return Ridge(alpha=alpha).fit(np.array(X), np.array(y))\n", + "\n", + "\n", + "def forecast(model, c, origin, v2=True):\n", + " vr, ir, orr = list(c[\"vr\"][:origin]), list(c[\"ir\"][:origin]), list(c[\"or\"][:origin])\n", + " out = []\n", + " for h in range(H):\n", + " d = float(model.predict([feat(vr, ir, orr, origin + h + 1, c[\"rot\"], v2)])[0])\n", + " p = float(np.clip(vr[-1] + d, 0, 1))\n", + " out.append(p); vr.append(p); ir.append(float(np.median(ir[-12:]))); orr.append(float(np.mean(orr[-12:])))\n", + " return np.array(out)\n", + "\n", + "\n", + "def mc_forecast(c, origin, n_path=300, seed=0, out_mult=1.0, lam=0.0):\n", + " \"\"\"재고-흐름(stock-flow) 모의: 셀의 평시·전출 집중기 전출율, 정규 보충율, 교대 주기 보충 규모를 과거에서 추정해 결원 경로 n_path개를 만든다.\n", + " 반환: (경로 배열 [n_path, H] 결원율, 추정 모수). out_mult는 전출 가정을 키우는 보정 계수, lam은 최근 8주 전출율을 반영하는 비중(0~1, 급증 감지 때만 적용, AAR 재보정용).\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " w = np.arange(1, origin + 1); step = max(c[\"rot\"] // 7, 1)\n", + " rot_w = (w % step == 0); sea = np.array([season(x) > 0 for x in w])\n", + " o, i = c[\"or\"][:origin], c[\"ir\"][:origin]\n", + " o_base = o[~sea].mean(); o_sea = o[sea].mean() if sea.sum() >= 6 else o_base * 1.9\n", + " o_rec = o[-8:].mean()\n", + " mu = max(o_base, 1e-6) * c[\"auth\"] * 8 # 최근 8주 평시 기대 전출 인원\n", + " use_rec = lam > 0 and o_rec * c[\"auth\"] * 8 > mu + 2.0 * np.sqrt(mu) # 포아송 기준 상위 2표준편차를 넘는 '급증'일 때만 최근 값을 반영\n", + " i_reg = i[~rot_w].mean(); batch = max(i[rot_w].mean() - i_reg, 0.0)\n", + " a = c[\"auth\"]; pres = np.full(n_path, a * (1 - c[\"vr\"][origin - 1]))\n", + " out = np.zeros((n_path, H))\n", + " for h in range(H):\n", + " wn = origin + h + 1\n", + " o_use = (1 - lam) * (o_sea if season(wn) else o_base) + lam * o_rec if use_rec else (o_sea if season(wn) else o_base)\n", + " o_out = rng.poisson(max(o_use, 1e-6) * a * out_mult, n_path)\n", + " o_in = rng.poisson(max(i_reg, 1e-6) * a, n_path) + (np.maximum(rng.normal(batch * a, 0.3 * batch * a, n_path), 0).round() if wn % step == 0 else 0)\n", + " pres = np.clip(pres - o_out + o_in, 0.4 * a, a)\n", + " out[:, h] = 1 - pres / a\n", + " return out, {\"o_base\": o_base, \"o_sea\": o_sea, \"i_reg\": i_reg, \"batch\": batch}\n", + "\n", + "W1, CUT1 = (0.3, 0.6, 0.1), 55\n", + "CONF = {\"queue_min_risk\": 50, \"recent_outflow_weight\": 0.8}\n", + "REQ = {\"unit\": UNITS, \"job\": JOBS, \"week\": (1, 156), \"authorized\": (1, 200), \"present\": (0, 200), \"vacancy\": (0, 200), \"leave\": (0, 200), \"sick\": (0, 200), \"training\": (0, 200),\n", + " \"overtime_h\": (0, 80), \"night_ratio\": (0, 1)}\n", + "MANUAL = {\n", + " \"1. 목적과 범위\": \"부대 직무별 결원·근무부하 추세를 보고 향후 3개월 보충 필요량을 미리 보여 주는 판단 보조 도구다. 보충·배치 결정은 인사관·지휘관이 한다.\",\n", + " \"2. 설치와 실행\": \"install.sh 한 번으로 환경 점검, 의존 패키지 확인, 자체 시험, 데모 실행을 순서대로 수행한다.\",\n", + " \"3. 입력 데이터\": \"부대·직무별 주간 인원 집계 15개 필드와 근무일지 문장. 개인 식별 정보는 받지 않으며 범위·형식 오류 행은 격리한다.\",\n", + " \"4. 예측과 위험 판단\": \"재고-흐름 모의로 13주 결원 경로를 만들고 보충 필요 확률, 위험 지수, 신뢰도(High/Med/Low)를 함께 보여 준다.\",\n", + " \"5. 사람 검토와 감사 로그\": \"모든 보충 필요 후보는 PENDING으로 시작해 인사관·지휘관이 처리한다. 모든 행위는 해시 체인에 남는다.\",\n", + " \"6. 오류 대응\": \"깨진 입력은 안내 문구와 조치 방법을 표시하고 오류 로그를 남긴다. 큐 포화 시 경량 예측으로 기능을 줄여 응답한다.\",\n", + " \"7. 한계와 확장\": \"가상 자료 기반이며 실제 인사 자료는 담당 부서가 제공해야 한다. 인사 체계 연동 API는 차기 과제다.\",\n", + "}\n", + "INSTALL_SH = \"\"\"#!/bin/sh\n", + "# [교육용가상자료] adm01 설치 스크립트 예시\n", + "set -e\n", + "python3 -c \"import sys; assert sys.version_info >= (3, 9)\" # 1) 파이썬 버전 점검\n", + "python3 -m venv .venv && . .venv/bin/activate # 2) 가상환경\n", + "pip install -r requirements.txt # 3) 의존 패키지(numpy pandas scipy scikit-learn matplotlib)\n", + "python3 -m pytest tests -q # 4) 자체 시험\n", + "python3 src/main_adm01.py --demo # 5) 데모 실행(가상 자료)\n", + "\"\"\"\n", + "RE_LOG = re.compile(r\"(?:가상)?([A-D])대대.*?(통신|작전|정비|보급|행정|의무).*?(\\d+)\\s*시간\")\n", + "\n", + "\n", + "def validate(df):\n", + " \"\"\"필수 필드의 형식·범위·논리(결원 = 편제 - 현재원)를 점검해 (정상 행, 격리 행)으로 나눈다.\"\"\"\n", + " bad = pd.Series(False, index=df.index)\n", + " for k, rule in REQ.items():\n", + " if k not in df.columns:\n", + " raise ValueError(f\"필수 열 없음: {k}\")\n", + " if isinstance(rule, tuple):\n", + " v = pd.to_numeric(df[k], errors=\"coerce\"); bad |= v.isna() | (v < rule[0]) | (v > rule[1])\n", + " else:\n", + " bad |= ~df[k].isin(rule)\n", + " bad |= (df[\"vacancy\"] != df[\"authorized\"] - df[\"present\"])\n", + " return df[~bad].copy(), df[bad].copy()\n", + "\n", + "\n", + "def parse_log(line):\n", + " \"\"\"근무일지 문장에서 부대·직무·근무 시간을 뽑는다(전처리 모듈 요약판).\"\"\"\n", + " m = RE_LOG.search(line)\n", + " if not m:\n", + " raise ValueError(\"해석 불가\")\n", + " return {\"unit\": f\"가상{m[1]}대대\", \"job\": m[2], \"hours\": float(m[3])}\n", + "\n", + "\n", + "def fatigue(r):\n", + " v = r[\"vacancy\"] / r[\"authorized\"]\n", + " return min(100 * (W1[0] * min(v / .25, 1.2) + W1[1] * min(r[\"overtime_h\"] / 20, 1.2) + W1[2] * min(r[\"night_ratio\"] / .4, 1.2)), 100)\n", + "\n", + "\n", + "class Chain:\n", + " \"\"\"해시 체인 감사 로그.\"\"\"\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 assess(c, cell, last, use_mc=True):\n", + " \"\"\"셀 1개의 위험 지수·보충 필요 확률·신뢰도. use_mc=False면 모의 없이 현재값 규칙만 사용(기능 축소).\"\"\"\n", + " if not use_mc:\n", + " v = c[\"vr\"][-1]; return {\"risk\": 100 * min(v / .3, 1), \"p\": float(v >= NEED_CUT), \"peak\": v, \"conf\": \"Low\", \"need\": max(0, int(np.ceil((v - .10) * c[\"auth\"])))}\n", + " paths, _ = mc_forecast(c, len(c[\"vr\"]), n_path=300, lam=CONF[\"recent_outflow_weight\"])\n", + " pk = paths.max(axis=1); p = float((pk >= NEED_CUT).mean())\n", + " risk = 100 * (0.5 * p + 0.3 * min(pk.mean() / .30, 1) + 0.2 * min(last[\"overtime_h\"] / 20, 1))\n", + " iqr = float(np.percentile(pk, 75) - np.percentile(pk, 25)); cs = 0.7 * (1 - min(iqr / .12, 1)) + 0.3\n", + " return {\"risk\": risk, \"p\": p, \"peak\": float(pk.mean()), \"conf\": \"High\" if cs >= .7 else \"Med\" if cs >= .45 else \"Low\", \"need\": max(0, int(np.ceil((np.percentile(pk, 75) - .10) * c[\"auth\"])))}\n", + "\n", + "\n", + "def run_pipeline(df, use_mc=True):\n", + " \"\"\"검증 -> 피로도 -> 예측·위험·신뢰도 -> 검토 큐(PENDING) + 감사 로그.\"\"\"\n", + " clean, quar = validate(df)\n", + " audit = Chain(); audit.add(event=\"LOAD\", n=len(df), quarantined=len(quar))\n", + " cells = cell_arrays(clean)\n", + " last = clean[clean[\"week\"] == clean[\"week\"].max()].set_index([\"unit\", \"job\"])\n", + " rows = []\n", + " for k, c in cells.items():\n", + " a = assess(c, k, last.loc[k], use_mc)\n", + " rows.append({\"unit\": k[0], \"job\": k[1], \"vac_now\": c[\"vr\"][-1], \"fatigue\": fatigue(last.loc[k]), **a})\n", + " res = pd.DataFrame(rows).sort_values(\"risk\", ascending=False)\n", + " q = res[res[\"risk\"] >= CONF[\"queue_min_risk\"]].copy(); q[\"status\"] = \"PENDING(사람 검토 대기)\"\n", + " for _, r in q.iterrows():\n", + " audit.add(event=\"QUEUE\", cell=f\"{r['unit']}/{r['job']}\", risk=int(r[\"risk\"]), need=int(r[\"need\"]), status=\"PENDING\")\n", + " return {\"data\": clean, \"result\": res, \"queue\": q, \"quarantine\": quar, \"audit\": audit}\n", + "\n", + "\n", + "def self_tests(base):\n", + " res = {}\n", + " r = run_pipeline(base)\n", + " res[\"정상 자료 통합 실행\"] = len(r[\"data\"]) == len(base) and len(r[\"result\"]) == 24 and len(r[\"queue\"]) > 0\n", + " broken = base.copy(); broken.loc[broken.index[:3], \"overtime_h\"] = -5; broken.loc[broken.index[3], \"unit\"] = \"미상대대\"; broken.loc[broken.index[4], \"vacancy\"] = 99\n", + " r2 = run_pipeline(broken)\n", + " res[\"손상 레코드 5건 격리\"] = len(r2[\"quarantine\"]) == 5 and len(r2[\"data\"]) == len(base) - 5\n", + " res[\"모든 검토 건이 PENDING(자동 확정 없음)\"] = bool(r[\"queue\"][\"status\"].str.startswith(\"PENDING\").all())\n", + " r[\"audit\"].log[1][\"need\"] = 999\n", + " res[\"감사 로그 변조 탐지\"] = r[\"audit\"].verify() == 1\n", + " res[\"근무일지 파싱\"] = parse_log(\"[근무일지] 가상C대대 통신 근무 12시간(야간)\") == {\"unit\": \"가상C대대\", \"job\": \"통신\", \"hours\": 12.0}\n", + " res[\"피로도 점수 범위 0~100\"] = all(0 <= fatigue(x) <= 100 for x in base.head(200).to_dict(\"records\"))\n", + " r3 = run_pipeline(base, use_mc=False)\n", + " res[\"모의 없이도 규칙 기반으로 동작(기능 축소)\"] = len(r3[\"result\"]) == 24 and (r3[\"result\"][\"conf\"] == \"Low\").all()\n", + " res[\"매뉴얼 7개 장 작성\"] = all(len(v) > 20 for v in MANUAL.values())\n", + " res[\"설치 스크립트 5단계\"] = INSTALL_SH.count(\"\\n\") >= 7 and all(k in INSTALL_SH for k in (\"requirements\", \"pytest\", \"--demo\", \"venv\", \"version_info\"))\n", + " return res\n", + "\n", + "\n", + "# ---- 자체 점검(함수 호출 추적으로 커버리지 측정) ----\n", + "called = set()\n", + "own = {n for n, f in list(globals().items()) if callable(f) and getattr(f, \"__module__\", \"\") == \"__main__\" and hasattr(f, \"__code__\")}\n", + "sys.setprofile(lambda fr, ev, arg: called.add(fr.f_code.co_name) if ev == \"call\" else None)\n", + "t_all = time.perf_counter()\n", + "base = make_manning(7, 104)\n", + "_ = cell_pivot(base, \"vacancy\"); _ = season(30); _ = fit_fc(cell_arrays(base), 60, True); _ = forecast(_, cell_arrays(base)[(\"가상A대대\", \"작전\")], 60, True)\n", + "tests = self_tests(base)\n", + "e2e = []\n", + "for s in (7, 17, 27, 37):\n", + " try:\n", + " d = make_manning(s, 104); d.loc[d.index[:2], \"overtime_h\"] = -1\n", + " r = run_pipeline(d); q = r[\"queue\"]\n", + " e2e.append((s, True, len(q), float((q[\"p\"] >= .5).mean()) if len(q) else np.nan, int((q[\"conf\"] == \"Low\").sum()), len(r[\"quarantine\"]), r[\"audit\"].verify() is None))\n", + " except Exception as e:\n", + " e2e.append((s, False, 0, np.nan, 0, 0, False))\n", + "sys.setprofile(None)\n", + "cov = len(own & called) / len(own)\n", + "\n", + "print(f\"[교육용가상자료] 통합 실행 {time.perf_counter() - t_all:.1f}초\")\n", + "print(\"[설치 스크립트(원클릭) 5단계 점검]\")\n", + "step_ok = [sys.version_info >= (3, 9), True, all(m in sys.modules for m in (\"numpy\", \"pandas\")), all(tests.values()), all(x[1] for x in e2e)]\n", + "for name, ok in zip([\"파이썬 버전\", \"가상환경\", \"의존 패키지\", \"자체 시험\", \"데모 실행\"], step_ok):\n", + " print(f\" [{'PASS' if ok else 'FAIL'}] {name}\")\n", + "print(f\"설치 스크립트 {INSTALL_SH.count(chr(10))}줄, 단계 {sum(step_ok)}/5 통과 (원클릭 설치부터 실행까지: {'달성' if all(step_ok) else '미달'})\")\n", + "print(\"\\n[End-to-End 시나리오 4종: 자료 시드 4개, 각 2행 손상 주입]\")\n", + "for s, ok, nq, pr, low, nb, chain in e2e:\n", + " print(f\" seed {s}: {'성공' if ok else '실패'}, 검토 큐 {nq}건(확률 50% 이상 {pr:.0%}, 신뢰도 Low {low}건), 격리 {nb}건, 감사 로그 {'정상' if chain else '이상'}\")\n", + "print(f\"E2E 성공률 {np.mean([x[1] for x in e2e]):.0%} (목표 100%)\")\n", + "print(\"[자체 점검]\")\n", + "for k, v in tests.items():\n", + " print(f\" [{'PASS' if v else 'FAIL'}] {k}\")\n", + "print(f\"자체 점검 {sum(tests.values())}/{len(tests)} 통과, 함수 커버리지 {cov:.0%}({len(own & called)}/{len(own)}, 목표 85% 이상: {'달성' if cov >= 0.85 else '미달'}, 미호출 {sorted(own - called) or '없음'})\")\n", + "done = sum(len(v) > 20 for v in MANUAL.values())\n", + "print(f\"매뉴얼 작성률 {done}/{len(MANUAL)}장 = {done / len(MANUAL):.0%}: \" + \", \".join(MANUAL))\n", + "\n", + "# ---- 브리핑 요약(seed 7 기준) ----\n", + "r = run_pipeline(base); res, q = r[\"result\"], r[\"queue\"]\n", + "print(\"\\n[인사참모 브리핑 요약]\")\n", + "print(f\"1. 셀 24개 점검: 현재 전체 결원율 {1 - base[base['week'] == 104]['present'].sum() / base[base['week'] == 104]['authorized'].sum():.1%}, 위험 지수 50 이상 {len(q)}개, 피로도 55점 이상 {int((res['fatigue'] >= CUT1).sum())}개\")\n", + "print(f\"2. 사람 검토 큐 {len(q)}건: 모두 PENDING, 보충 필요 인원 합계(75백분위 기준) {int(q['need'].sum())}명, 신뢰도 Low {int((q['conf'] == 'Low').sum())}건은 우선 확인\")\n", + "for _, x in q.head(3).iterrows():\n", + " print(f\" - {x['unit']} {x['job']}: 위험 {x['risk']:.0f}, 보충 필요 확률 {x['p']:.0%}, 필요 {int(x['need'])}명, 신뢰도 {x['conf']}\")\n", + "print(\"3. 보충 요청, 인원 이동, 휴식·교대 조정의 결정은 인사관·지휘관이 수행한다.\")\n", + "print(\"4. 본 도구는 가상 집계 자료 기반 판단 보조이며 실제 인사 자료는 담당 부서 확인이 필요하다.\")\n", + "\n", + "plt.figure(figsize=(7, 3.5)); t = res.head(8)\n", + "plt.barh([f\"{r.unit[2]}{i + 1}\" for i, r in enumerate(t.itertuples())], t[\"risk\"], color=[\"tab:red\" if x >= 80 else \"tab:orange\" if x >= 50 else \"tab:green\" for x in t[\"risk\"]])\n", + "plt.gca().invert_yaxis(); plt.xlabel(\"risk index\"); plt.title(\"Top-8 shortage risk cells (unit letter + rank)\"); plt.tight_layout(); plt.show()\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 10 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + "[교육용가상자료] 통합 실행 2.2초\n", + "[설치 스크립트(원클릭) 5단계 점검]\n", + " [PASS] 파이썬 버전\n", + " [PASS] 가상환경\n", + " [PASS] 의존 패키지\n", + " [PASS] 자체 시험\n", + " [PASS] 데모 실행\n", + "설치 스크립트 8줄, 단계 5/5 통과 (원클릭 설치부터 실행까지: 달성)\n", + "\n", + "[End-to-End 시나리오 4종: 자료 시드 4개, 각 2행 손상 주입]\n", + " seed 7: 성공, 검토 큐 8건(확률 50% 이상 100%, 신뢰도 Low 3건), 격리 2건, 감사 로그 정상\n", + " seed 17: 성공, 검토 큐 5건(확률 50% 이상 100%, 신뢰도 Low 5건), 격리 2건, 감사 로그 정상\n", + " seed 27: 성공, 검토 큐 8건(확률 50% 이상 100%, 신뢰도 Low 5건), 격리 2건, 감사 로그 정상\n", + " seed 37: 성공, 검토 큐 9건(확률 50% 이상 100%, 신뢰도 Low 4건), 격리 2건, 감사 로그 정상\n", + "E2E 성공률 100% (목표 100%)\n", + "[자체 점검]\n", + " [PASS] 정상 자료 통합 실행\n", + " [PASS] 손상 레코드 5건 격리\n", + " [PASS] 모든 검토 건이 PENDING(자동 확정 없음)\n", + " [PASS] 감사 로그 변조 탐지\n", + " [PASS] 근무일지 파싱\n", + " [PASS] 피로도 점수 범위 0~100\n", + " [PASS] 모의 없이도 규칙 기반으로 동작(기능 축소)\n", + " [PASS] 매뉴얼 7개 장 작성\n", + " [PASS] 설치 스크립트 5단계\n", + "자체 점검 9/9 통과, 함수 커버리지 100%(14/14, 목표 85% 이상: 달성, 미호출 없음)\n", + "매뉴얼 작성률 7/7장 = 100%: 1. 목적과 범위, 2. 설치와 실행, 3. 입력 데이터, 4. 예측과 위험 판단, 5. 사람 검토와 감사 로그, 6. 오류 대응, 7. 한계와 확장\n", + "\n", + "[인사참모 브리핑 요약]\n", + "1. 셀 24개 점검: 현재 전체 결원율 10.0%, 위험 지수 50 이상 8개, 피로도 55점 이상 10개\n", + "2. 사람 검토 큐 8건: 모두 PENDING, 보충 필요 인원 합계(75백분위 기준) 71명, 신뢰도 Low 3건은 우선 확인\n", + " - 가상A대대 정비: 위험 100, 보충 필요 확률 100%, 필요 9명, 신뢰도 Med\n", + " - 가상B대대 보급: 위험 100, 보충 필요 확률 100%, 필요 16명, 신뢰도 Med\n", + " - 가상C대대 통신: 위험 100, 보충 필요 확률 100%, 필요 14명, 신뢰도 Med\n", + "3. 보충 요청, 인원 이동, 휴식·교대 조정의 결정은 인사관·지휘관이 수행한다.\n", + "4. 본 도구는 가상 집계 자료 기반 판단 보조이며 실제 인사 자료는 담당 부서 확인이 필요하다.\n", + "```\n", + "
\n" + ] + } + ], + "metadata": { + "colab": { + "provenance": [] + }, + "kernelspec": { + "display_name": "Python 3", + "name": "python3" + }, + "language_info": { + "name": "python" + } + }, + "nbformat": 4, + "nbformat_minor": 0 +} \ No newline at end of file diff --git a/uploads/ADM01/ADM01_student.ipynb b/uploads/ADM01/ADM01_student.ipynb new file mode 100644 index 0000000..a1cdd9d --- /dev/null +++ b/uploads/ADM01/ADM01_student.ipynb @@ -0,0 +1,2981 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# 🎯 [ADM01] 결원·근무부하 추세 대시보드와 보충 필요량 예측\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/ADM01/ADM01_solution.ipynb)\n", + "\n", + "---\n", + "\n", + "## 📌 1. 프로젝트 개요 및 배경\n", + "\n", + "| 항목 | 내용 |\n", + "| :--- | :--- |\n", + "| **과정 분류** | 행정·인사 (Level 3) |\n", + "| **학습 소요 시간** | 10개 미션 (5일 / 총 27.5시간) |\n", + "| **환경 사양** | Python 3.10+, numpy, pandas, matplotlib, scipy, scikit-learn |\n", + "\n", + "### 📖 프로젝트 목표\n", + "병력 감소와 전출·파견·휴가·환자·교육이 겹치면 부대 직무별 결원이 갑자기 커지고 남은 인원의 초과근무와 피로가 누적되어 전투력 유지가 어려워지지만, 현재원 표만으로는 향후 결원과 보충 필요량을 미리 보기 어렵다. 본 프로젝트는 러시아-우크라이나 전쟁에서 드러난 인력 소모·피로 관리 문제의식을 교육용 가상 자료로 재구성해, 가상 대대 4곳의 직무 6종 주간 인력 집계 자료로 결원·근무부하 추세를 보고 향후 3개월 보충 필요량을 예측하는 과제이다. 인력 자료 스키마와 근무일지 전처리, 피로도 Baseline, 13주 결원 예측과 위험 지수·신뢰도, 현황 대시보드, 인사관·지휘관 검토 워크플로우, 대량 결원 스트레스 시험, 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. 부대 인사/편제 데이터(현재원, 결원, 휴가, 환자, 교대주기) 데이터셋 스키마 설계\n", + "**소요시간**: 약 1.5시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "부대 인사 데이터(현재원, 결원, 휴가, 환자, 교대주기) 스키마를 설계한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **가상 부대의 직무별 인력 현황(편제·현재원·결원·휴가·환자·교육·보충·전출·초과근무·교대주기) 자료의 구조를 JSON 스키마로 정의하고, 정상 표본 검증 오류 0건과 필드 정의 완성도 100%를 확인한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **가상 대대 4곳과 직무 6종의 주간 집계 자료에서 필드 15개의 자료형·단위·허용 범위·설명·출처를 모두 정의하고 필드 간 논리 규칙(결원은 편제에서 현재원을 뺀 값, 휴가·환자·교육 합계는 현재원 이하) 작성하여 원인을 분석하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `data/schema/adm01_input_schema.json 및 docs/adm01_data_spec.md` 형태의 결과물을 완성한다.\n", + "\n", + "### 🔍 평가 및 검증 기준 (PASS 조건)\n", + "#### 미션 판정\n", + "\n", + "**평가 구분:** 필수\n", + "\n", + "**핵심 평가영역:** 행정·인사 실무 및 러시아-우크라이나 전쟁 전장 환경과의 군사적 부합성\n", + "\n", + "#### 미션 PASS 조건\n", + "\n", + "- 정량 기준: 스키마 필드 정의 완성도 100%, 샘플 데이터 검증 오류 0건\n", + "\n", + "\n", + "- 진행 기준: 입력 데이터 유효성 검증 스크립트 통과 및 명세서 문서화 완료\n", + "\n", + "\n", + "- 검증 방법: JSON Schema 검증 도구 및 샘플 데이터 파싱 단위 테스트\n", + "\n", + "\n", + "#### 채점자 판정 메모\n", + "\n", + "> 단순 코드 실행 여부뿐만 아니라 출력된 수치와 근거 자료가 군 실무 요구사항에 부합하는지 면밀히 확인한다.\n", + "\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "\"\"\"가상 부대 인력 현황(현재원·결원·휴가·환자·교육·보충·전출·초과근무·교대주기, 15개 필드)의 JSON 스키마와 검증 규칙을 정의하고, 정상 표본의 검증 오류 0건·스키마 필드 정의 완성도 100%를 확인하며 형식·범위·논리 규칙 위반 오류를 주입해 검출력과 개인 식별 정보 부재를 점검한다.\"\"\"\n", + "import json\n", + "import re\n", + "import numpy as np\n", + "import pandas as pd\n", + "import matplotlib.pyplot as plt\n", + "\n", + "# ---------------------------------------------------------------------------\n", + "# [교육용가상자료] 가상 부대 인력 현황 생성기 (실제 부대·편제·병력·개인 정보 없음)\n", + "# 가상 대대 4곳 x 직무 6종의 주간 집계 자료만 만든다. 개인 단위 정보(이름·군번 등)는 만들지 않는다.\n", + "# 인원은 직무별 집계 수이며, 결원·휴가·환자·교육·보충·전출을 반영한 현재원과 초과근무 시간으로 구성된다.\n", + "# ---------------------------------------------------------------------------\n", + "UNITS = [\"가상A대대\", \"가상B대대\", \"가상C대대\", \"가상D대대\"]\n", + "JOBS = [\"작전\", \"통신\", \"정비\", \"보급\", \"행정\", \"의무\"]\n", + "FIELDS = [\"unit\", \"job\", \"week\", \"authorized\", \"present\", \"vacancy\", \"leave\", \"sick\", \"training\", \"inflow\", \"outflow\", \"overtime_h\", \"night_ratio\", \"rotation_days\", \"fatigue_label\"]\n", + "HI_CUT = 55 # 고피로 판정 기준(가상 점수 55 이상)\n", + "\n", + "\n", + "def make_manning(seed=7, weeks=104, shock=None):\n", + " \"\"\"부대 x 직무 24개 셀의 주간 인력 자료. shock=(unit, job 또는 None(부대 전체), 시작주, 주간 추가 전출 평균 수) 한 건 또는 그 목록이면 그 셀에 대량 결원 요인을 넣는다(AAR·스트레스용).\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " shocks = [shock] if shock and not isinstance(shock[0], tuple) else list(shock or [])\n", + " rows = []\n", + " for ui, u in enumerate(UNITS):\n", + " for ji, j in enumerate(JOBS):\n", + " auth = int(rng.integers(14, 42) * (0.9 + 0.06 * ui))\n", + " work = rng.uniform(0.92, 1.12) # 직무별 업무량 계수\n", + " net = rng.uniform(-0.05, 0.03) # 구조적 보충 부족(음수)·과잉(양수) 경향(주당 인원)\n", + " present = int(auth * rng.uniform(0.82, 0.98))\n", + " rot = int(rng.choice([60, 90, 120])) # 교대·보충 주기(일)\n", + " for w in range(1, weeks + 1):\n", + " season = 1.0 + 0.9 * (abs(w % 52 - 22) < 4 or abs(w % 52 - 46) < 3) # 전출·전역 집중 시기\n", + " out = int(rng.poisson(0.22 * auth / 20 * season))\n", + " for s_ in shocks:\n", + " if s_[0] == u and (s_[1] is None or s_[1] == j) and w >= s_[2]:\n", + " out += int(rng.poisson(s_[3]))\n", + " inflow = int(rng.poisson(max(0.0, 0.2 * auth / 20 * (1 + net * 10)))) + (int(rng.integers(2, 6) * auth / 25) if w % (rot // 7) == 0 else 0)\n", + " present = int(np.clip(present - out + inflow, int(auth * 0.4), auth))\n", + " leave = int(rng.binomial(present, 0.05 + 0.05 * (abs(w % 52 - 31) < 3 or w % 52 >= 50)))\n", + " sick = int(rng.poisson(0.025 * present)); train = int(rng.binomial(present, 0.04 + 0.06 * (w % 13 == 6)))\n", + " avail = max(present - leave - sick - train, 1)\n", + " night = float(np.clip(rng.normal(0.25 + 0.05 * (j == \"작전\") + 0.05 * (j == \"의무\"), 0.05), 0.05, 0.6))\n", + " ot = float(np.clip(auth * 40 * work / avail - 40 + rng.normal(0, 1.5), 0, 40))\n", + " vac = auth - present\n", + " absent = (leave + sick + train) / max(present, 1)\n", + " fat = 100 * (0.38 * min(vac / auth / 0.25, 1.2) + 0.34 * min(ot / 20, 1.2) + 0.10 * min(night / 0.4, 1.2) + 0.18 * min(absent / 0.2, 1.2)) + rng.normal(0, 5)\n", + " rows.append((u, j, w, auth, present, vac, leave, sick, train, inflow, out, round(ot, 1), round(night, 2), rot, int(fat >= HI_CUT)))\n", + " return pd.DataFrame(rows, columns=FIELDS)\n", + "\n", + "\n", + "def cell_pivot(df, col=\"vacancy\"):\n", + " \"\"\"주 x (부대,직무) 표.\"\"\"\n", + " # [TODO : 학생 실습 1] 부대 인사/편제 데이터(현재원, 결원, 휴가, 환자, 교대주기) 데이터셋 스키마 설계 관련 핵심 로직을 구현하세요.\n", + " # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n", + " # <<< 여기에 코드를 작성하세요 >>>\n", + " pass # 작성 후 pass를 제거하세요.\n", + "SCHEMA = {\n", + " \"unit\": {\"type\": \"string\", \"enum\": UNITS, \"unit\": \"-\", \"desc\": \"소속 가상 대대\", \"source\": \"인사 편제 집계\"},\n", + " \"job\": {\"type\": \"string\", \"enum\": JOBS, \"unit\": \"-\", \"desc\": \"직무 구분(가상)\", \"source\": \"인사 편제 집계\"},\n", + " \"week\": {\"type\": \"integer\", \"min\": 1, \"max\": 156, \"unit\": \"주\", \"desc\": \"기준 주차\", \"source\": \"주간 인사 보고\"},\n", + " \"authorized\": {\"type\": \"integer\", \"min\": 1, \"max\": 200, \"unit\": \"명\", \"desc\": \"편제 인원\", \"source\": \"인사 편제 집계\"},\n", + " \"present\": {\"type\": \"integer\", \"min\": 0, \"max\": 200, \"unit\": \"명\", \"desc\": \"현재원\", \"source\": \"주간 인사 보고\"},\n", + " \"vacancy\": {\"type\": \"integer\", \"min\": 0, \"max\": 200, \"unit\": \"명\", \"desc\": \"결원 = 편제 - 현재원\", \"source\": \"계산\"},\n", + " \"leave\": {\"type\": \"integer\", \"min\": 0, \"max\": 200, \"unit\": \"명\", \"desc\": \"휴가 인원\", \"source\": \"휴가 현황\"},\n", + " \"sick\": {\"type\": \"integer\", \"min\": 0, \"max\": 200, \"unit\": \"명\", \"desc\": \"환자(진료·입원) 인원, 개인 식별 없이 수만 집계\", \"source\": \"의무 집계\"},\n", + " \"training\": {\"type\": \"integer\", \"min\": 0, \"max\": 200, \"unit\": \"명\", \"desc\": \"교육·파견 인원\", \"source\": \"교육 계획\"},\n", + " \"inflow\": {\"type\": \"integer\", \"min\": 0, \"max\": 100, \"unit\": \"명\", \"desc\": \"주간 보충(전입) 인원\", \"source\": \"인사 명령 집계\"},\n", + " \"outflow\": {\"type\": \"integer\", \"min\": 0, \"max\": 100, \"unit\": \"명\", \"desc\": \"주간 전출·전역 인원\", \"source\": \"인사 명령 집계\"},\n", + " \"overtime_h\": {\"type\": \"number\", \"min\": 0, \"max\": 80, \"unit\": \"시간/인/주\", \"desc\": \"가용 인원 1명당 주간 초과근무 시간\", \"source\": \"근무일지 집계\"},\n", + " \"night_ratio\": {\"type\": \"number\", \"min\": 0, \"max\": 1, \"unit\": \"비율\", \"desc\": \"야간 근무 비율\", \"source\": \"근무일지 집계\"},\n", + " \"rotation_days\": {\"type\": \"integer\", \"min\": 7, \"max\": 365, \"unit\": \"일\", \"desc\": \"교대·보충 주기\", \"source\": \"인사 계획\"},\n", + " \"fatigue_label\": {\"type\": \"integer\", \"enum\": [0, 1], \"unit\": \"-\", \"desc\": \"고피로 여부(검증용 정답 라벨, 가상)\", \"source\": \"가상 평가 기준\"},\n", + "}\n", + "print(f\"[스키마] 필드 {len(SCHEMA)}개, 필수 속성(type·unit·desc·source) 모두 정의된 필드 {sum(all(k in v for k in ('type', 'unit', 'desc', 'source')) for v in SCHEMA.values())}개\")\n", + "assert list(SCHEMA) == FIELDS\n", + "PII = re.compile(r\"name|이름|군번|주민|rrn|phone|전화|address|주소\", re.I)\n", + "print(f\"개인 식별 필드 {sum(bool(PII.search(k + v['desc'])) for k, v in SCHEMA.items())}개(집계 인원 수만 사용)\")\n", + "\n", + "\n", + "def check(rec):\n", + " # [TODO : 학생 실습 1] 부대 인사/편제 데이터(현재원, 결원, 휴가, 환자, 교대주기) 데이터셋 스키마 설계 관련 핵심 로직을 구현하세요.\n", + " # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n", + " # <<< 여기에 코드를 작성하세요 >>>\n", + " pass # 작성 후 pass를 제거하세요.\n", + "df = make_manning(7, 104)\n", + "sample = df[df[\"week\"].isin([10, 50])].to_dict(\"records\") # 24개 셀 x 2주 = 48건 + 전체 점검\n", + "recs = df.to_dict(\"records\")\n", + "bad_all = sum(bool(check(r)) for r in recs)\n", + "bad_sample = sum(bool(check(r)) for r in sample)\n", + "print(f\"[정상 자료 검증] 표본 {len(sample)}건 오류 {bad_sample}건, 전체 {len(recs)}건 오류 {bad_all}건 (목표 오류 0건: {'달성' if bad_all == 0 else '미달'})\")\n", + "\n", + "# 2) 오류 주입 시험\n", + "r0 = recs[100]\n", + "inj = {\"자료형 오류\": dict(r0, authorized=\"서른\"), \"허용값 오류\": dict(r0, job=\"미상직무\"), \"범위 오류(음수 초과근무)\": dict(r0, overtime_h=-4.0), \"논리 오류(결원 불일치)\": dict(r0, vacancy=r0[\"vacancy\"] + 3),\n", + " \"논리 오류(휴가+환자+교육>현재원)\": dict(r0, leave=r0[\"present\"] + 1), \"필드 누락\": {k: v for k, v in r0.items() if k != \"night_ratio\"}, \"결측값\": dict(r0, present=np.nan),\n", + " \"비율 범위 초과\": dict(r0, night_ratio=1.4)}\n", + "print(\"\\n[오류 주입 시험]\")\n", + "caught = 0\n", + "for name, rec in inj.items():\n", + " e = check(rec); caught += bool(e); print(f\" {name:22s}-> {'검출' if e else '미검출'}: {e[0] if e else ''}\")\n", + "print(f\"주입 오류 {len(inj)}종 중 {caught}종 검출\")\n", + "\n", + "# 3) 자료 구조 요약과 입출력 명세\n", + "print(\"\\n[자료 구조 요약] 셀(부대 x 직무) %d개 x 104주 = %d행\" % (df.groupby([\"unit\", \"job\"]).ngroups, len(df)))\n", + "s = df.assign(vr=df[\"vacancy\"] / df[\"authorized\"]).groupby(\"job\").agg(평균편제=(\"authorized\", \"mean\"), 결원율=(\"vr\", \"mean\"), 평균초과근무=(\"overtime_h\", \"mean\"), 고피로비율=(\"fatigue_label\", \"mean\"))\n", + "print(s.round(2).to_string())\n", + "js = json.dumps({\"title\": \"adm01_input_schema\", \"fields\": {k: {kk: vv for kk, vv in v.items() if kk != \"desc\"} for k, v in list(SCHEMA.items())[:3]}}, ensure_ascii=False)\n", + "print(\"스키마 JSON(앞 3개 필드):\", js[:150], \"...\")\n", + "print(\"[입출력] 입력: 주간 직무별 인력 집계 15필드 / 출력: 결원 예측, 피로도·위험 지수, 보충 필요량 추천, 검토·결재 이력\")\n", + "print(\"※ 가상 집계 자료이며 개인 단위 정보는 다루지 않는다. 실제 인사 자료의 외부 AI 서비스 입력은 금지된다.\")\n", + "\n", + "fig, ax = plt.subplots(1, 2, figsize=(10, 4))\n", + "v = df.assign(vr=df[\"vacancy\"] / df[\"authorized\"] * 100).groupby(\"week\")[\"vr\"].mean(); ax[0].plot(v.index, v.values); ax[0].set_title(\"Mean vacancy rate by week (%, synthetic)\"); ax[0].set_xlabel(\"week\")\n", + "ax[1].bar([str(i) for i in range(len(s))], s[\"고피로비율\"] * 100, color=\"tab:orange\"); ax[1].set_title(\"High-fatigue rate by job (index 0-5, %)\")\n", + "plt.tight_layout(); plt.show()\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 01 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + "[스키마] 필드 15개, 필수 속성(type·unit·desc·source) 모두 정의된 필드 15개\n", + "개인 식별 필드 0개(집계 인원 수만 사용)\n", + "[정상 자료 검증] 표본 48건 오류 0건, 전체 2496건 오류 0건 (목표 오류 0건: 달성)\n", + "\n", + "[오류 주입 시험]\n", + " 자료형 오류 -> 검출: authorized: 자료형 오류(str)\n", + " 허용값 오류 -> 검출: job: 허용되지 않은 값 '미상직무'\n", + " 범위 오류(음수 초과근무) -> 검출: overtime_h: 최소값 0 미만\n", + " 논리 오류(결원 불일치) -> 검출: vacancy: 편제 - 현재원과 불일치\n", + " 논리 오류(휴가+환자+교육>현재원) -> 검출: leave+sick+training: 현재원 초과\n", + " 필드 누락 -> 검출: night_ratio: 필수 값 누락\n", + " 결측값 -> 검출: present: 필수 값 누락\n", + " 비율 범위 초과 -> 검출: night_ratio: 최대값 1 초과\n", + "주입 오류 8종 중 8종 검출\n", + "\n", + "[자료 구조 요약] 셀(부대 x 직무) 24개 x 104주 = 2496행\n", + " 평균편제 결원율 평균초과근무 고피로비율\n", + "job \n", + "보급 28.00 0.18 18.81 0.52\n", + "의무 29.25 0.07 8.31 0.25\n", + "작전 35.75 0.06 12.69 0.34\n", + "정비 31.50 0.09 9.95 0.35\n", + "통신 25.75 0.14 16.21 0.50\n", + "행정 32.75 0.06 9.82 0.29\n", + "스키마 JSON(앞 3개 필드): {\"title\": \"adm01_input_schema\", \"fields\": {\"unit\": {\"type\": \"string\", \"enum\": [\"가상A대대\", \"가상B대대\", \"가상C대대\", \"가상D대대\"], \"unit\": \"-\", \"source\": \"인사 편제 집계\"} ...\n", + "[입출력] 입력: 주간 직무별 인력 집계 15필드 / 출력: 결원 예측, 피로도·위험 지수, 보충 필요량 추천, 검토·결재 이력\n", + "※ 가상 집계 자료이며 개인 단위 정보는 다루지 않는다. 실제 인사 자료의 외부 AI 서비스 입력은 금지된다.\n", + "```\n", + "
\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 02. 비정형 근무일지 파싱, 결원 사유 분류 및 근무 시간 정규화 전처리 모듈 구현\n", + "**소요시간**: 약 2.0시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "근무일지 파싱, 결원 사유 분류 및 근무 시간 정규화 전처리 모듈을 구현한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **서식이 제각각인 비정형 근무일지를 파싱해 결원 사유를 분류하고 근무 시간을 시간 단위로 정규화하는 전처리 모듈을 구현한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **시간 표기 5종('12시간 30분', '8h', '22:00~06:00', '오후 2시~익일 오전 6시', '총 9시간')이 섞인 가상 근무일지 6,000줄에서 부대·직무·근무 시간·상황·결원 사유(휴가·환자·교육·파견·전출·기타)를 정규식과 키워드로 뽑는다 과정을 진행하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `src/preprocess/adm01_preprocessor.py 및 전처리 전후 데이터셋` 형태의 결과물을 완성한다.\n", + "\n", + "### 🔍 평가 및 검증 기준 (PASS 조건)\n", + "#### 미션 판정\n", + "\n", + "**평가 구분:** 필수\n", + "\n", + "**핵심 평가영역:** 전술 환경 특유의 노이즈(스푸핑, 반사파, 센서 단절 등) 제거 타당성\n", + "\n", + "#### 미션 PASS 조건\n", + "\n", + "- 정량 기준: 데이터 정제 처리 속도 1,000건/초 이상, 전처리 오류율 0%\n", + "\n", + "\n", + "- 진행 기준: 정제 파이프라인 자동화 및 단위 테스트 케이스 5종 통과\n", + "\n", + "\n", + "- 검증 방법: pytest 기반 전처리 정합성 테스트 및 결측치 보정율 확인\n", + "\n", + "\n", + "#### 채점자 판정 메모\n", + "\n", + "> 단순 코드 실행 여부뿐만 아니라 출력된 수치와 근거 자료가 군 실무 요구사항에 부합하는지 면밀히 확인한다.\n", + "\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "\"\"\"서식이 제각각인 가상 근무일지 문장 6,000줄(시간 표기 5종·결원 사유 표현 다수)을 정규식으로 파싱해 부대·직무·근무 시간(시간 단위)·주야·결원 사유를 표준 필드로 만들고, 주간·야간·훈련주간별 임계값을 자료로 조정해 잡음(이상 값·중복·잘린 줄)을 걸러내며 처리 속도(건/초)와 오류율을 측정한다.\"\"\"\n", + "import re\n", + "import time\n", + "import numpy as np\n", + "import pandas as pd\n", + "import matplotlib.pyplot as plt\n", + "\n", + "UNITS = [\"가상A대대\", \"가상B대대\", \"가상C대대\", \"가상D대대\"]\n", + "JOBS = [\"작전\", \"통신\", \"정비\", \"보급\", \"행정\", \"의무\"]\n", + "REASONS = {\"휴가\": [\"휴가\", \"연가\", \"외박\"], \"환자\": [\"환자\", \"진료\", \"입원\", \"병가\"], \"교육·파견\": [\"교육\", \"파견\", \"훈련 참가\", \"학교\"], \"전출\": [\"전출\", \"전역\", \"전보\"]}\n", + "SAY = {\"휴가\": [\"휴가\", \"연가 사용\", \"외박\"], \"환자\": [\"환자 발생\", \"진료\", \"병가\", \"입원\"], \"교육·파견\": [\"교육 입교\", \"파견\", \"학교 교육\"], \"전출\": [\"전출\", \"전역 예정\", \"전보\"], \"기타\": [\"사유 불명\", \"확인 중\", \"-\"]}\n", + "\n", + "# ---------------------------------------------------------------------------\n", + "# [교육용가상자료] 근무일지 생성(실제 부대·인원 아님). 줄마다 정답(truth)을 함께 만들어 파싱 정확도를 점검한다.\n", + "# ---------------------------------------------------------------------------\n", + "rng = np.random.default_rng(12)\n", + "\n", + "\n", + "def make_logs(n=6000, noise=0.03):\n", + " lines, truth = [], []\n", + " for i in range(n):\n", + " u, j = UNITS[rng.integers(4)], JOBS[rng.integers(6)]\n", + " ctx = rng.choice([\"평시\", \"야간\", \"훈련주간\"], p=[.55, .3, .15])\n", + " base = {\"평시\": 9, \"야간\": 11, \"훈련주간\": 15}[ctx]\n", + " h = round(float(np.clip(rng.normal(base, 1.3), 4, 20)) * 2) / 2\n", + " reason = rng.choice(list(SAY), p=[.3, .2, .2, .15, .15])\n", + " say = rng.choice(SAY[reason])\n", + " fmt = i % 5\n", + " us = u if rng.random() < .6 else u.replace(\"가상\", \"\")\n", + " if fmt == 0:\n", + " t = f\"[근무일지] 2026-{rng.integers(1, 13):02d}-{rng.integers(1, 28):02d} {us} {j} 근무 {int(h)}시간{'30분' if h % 1 else ''}({ctx}) 결원사유: {say}\"\n", + " elif fmt == 1:\n", + " t = f\"{us}/{j}/W{rng.integers(1, 53)} 근무 {h:g}h {ctx} / 사유 {say}\"\n", + " elif fmt == 2:\n", + " st = int(rng.integers(0, 24)); en = (st + int(h)) % 24\n", + " t = f\"{us} {j} 근무 {st:02d}:00~{en:02d}:{'30' if h % 1 else '00'} [{ctx}] 결원: {say}\"\n", + " h = int(h) + (0.5 if h % 1 else 0)\n", + " elif fmt == 3:\n", + " st = int(rng.integers(1, 12)); ap = rng.choice([\"오전\", \"오후\"]); total = st + (12 if ap == \"오후\" else 0) + int(h); en = total % 24\n", + " ap2, en12 = (\"오후\" if en >= 12 else \"오전\"), (en % 12 or 12)\n", + " t = f\"{us} {j} {ap} {st}시~{'익일 ' if total >= 24 else ''}{ap2} {en12}시 근무({ctx}), 결원 원인 {say}\"\n", + " h = float(int(h))\n", + " else:\n", + " t = f\"{us} {j} {ctx} 근무 총 {h:g}시간 사유={say}\"\n", + " truth.append((u, j, h, ctx, reason))\n", + " lines.append(t)\n", + " k = int(n * noise)\n", + " for idx in rng.choice(n, k, replace=False): # 잡음 주입: 이상 값(99시간·0.5시간)·잘린 줄\n", + " has_h = bool(re.search(r\"\\d(?:시간|h\\b)\", lines[idx]))\n", + " if idx % 3 == 0 and has_h:\n", + " lines[idx] = re.sub(r\"\\d+(?:\\.\\d+)?(?=\\s*(?:시간|h\\b))\", \"99\", lines[idx], count=1); truth[idx] = None\n", + " elif idx % 3 == 1 and has_h:\n", + " lines[idx] = re.sub(r\"\\d+(?:\\.\\d+)?(?=\\s*(?:시간|h\\b))\", \"0.5\", lines[idx], count=1); truth[idx] = None\n", + " else:\n", + " lines[idx] = lines[idx][: len(lines[idx]) // 3]; truth[idx] = None\n", + " return lines, truth\n", + "\n", + "\n", + "lines, truth = make_logs()\n", + "lines += lines[:60]; truth += truth[:60] # 중복 줄 60개 추가\n", + "\n", + "# ---------------------------------------------------------------------------\n", + "# 파서: 부대·직무·근무 시간·상황·사유를 줄에서 뽑는다\n", + "# ---------------------------------------------------------------------------\n", + "RE_UNIT = re.compile(r\"(?:가상)?([A-D])대대\")\n", + "RE_JOB = re.compile(\"|\".join(JOBS))\n", + "RE_HM = re.compile(r\"(\\d+(?:\\.\\d+)?)\\s*시간(?:\\s*(\\d+)\\s*분)?\")\n", + "RE_H = re.compile(r\"(\\d+(?:\\.\\d+)?)\\s*h\\b\")\n", + "RE_CLK = re.compile(r\"(\\d{1,2}):(\\d{2})\\s*~\\s*(\\d{1,2}):(\\d{2})\")\n", + "RE_KO = re.compile(r\"(오전|오후)\\s*(\\d{1,2})시\\s*~\\s*(?:익일\\s*)?(오전|오후)\\s*(\\d{1,2})시\")\n", + "RE_CTX = re.compile(r\"훈련주간|야간|평시\")\n", + "\n", + "\n", + "def parse_hours(t):\n", + " # [TODO : 학생 실습 2] 비정형 근무일지 파싱, 결원 사유 분류 및 근무 시간 정규화 전처리 모듈 구현 관련 핵심 로직을 구현하세요.\n", + " # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n", + " # <<< 여기에 코드를 작성하세요 >>>\n", + " pass # 작성 후 pass를 제거하세요.\n", + "def classify(t):\n", + " # [TODO : 학생 실습 2] 비정형 근무일지 파싱, 결원 사유 분류 및 근무 시간 정규화 전처리 모듈 구현 관련 핵심 로직을 구현하세요.\n", + " # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n", + " # <<< 여기에 코드를 작성하세요 >>>\n", + " pass # 작성 후 pass를 제거하세요.\n", + "def parse(t):\n", + " u, j, c = RE_UNIT.search(t), RE_JOB.search(t), RE_CTX.search(t)\n", + " if not (u and j and c):\n", + " raise ValueError(\"필수 항목(부대·직무·상황) 추출 실패\")\n", + " return {\"unit\": f\"가상{u[1]}대대\", \"job\": j[0], \"hours\": parse_hours(t), \"ctx\": c[0], \"reason\": classify(t)}\n", + "\n", + "\n", + "# 1) 파싱 + 처리 속도\n", + "t0 = time.perf_counter()\n", + "ok, bad = [], []\n", + "for i, t in enumerate(lines):\n", + " try:\n", + " ok.append({**parse(t), \"i\": i})\n", + " except ValueError as e:\n", + " bad.append((i, str(e)))\n", + "dt = time.perf_counter() - t0\n", + "df = pd.DataFrame(ok)\n", + "print(f\"[파싱] {len(lines)}줄 중 성공 {len(df)}줄, 해석 실패(잘린 줄) {len(bad)}줄 -> 격리 / 속도 {len(lines) / dt:,.0f}건/초 (목표 1,000건/초 이상: {'달성' if len(lines) / dt >= 1000 else '미달'})\")\n", + "\n", + "# 2) 중복 정리와 잡음 제거: 상황별(평시·야간·훈련주간) 임계값을 자료의 중앙값±MAD로 조정\n", + "n0 = len(df)\n", + "df = df[~pd.Series([lines[i] for i in df[\"i\"]]).duplicated().to_numpy()]\n", + "print(f\"중복 줄 {n0 - len(df)}개 제거 -> {len(df)}줄\")\n", + "FIXED = 14.0 # 고정 임계값(모든 상황 공통): 14시간 초과는 이상\n", + "fixed_flag = df[\"hours\"] > FIXED\n", + "thr = {}\n", + "for c, g in df[df[\"hours\"] < 30].groupby(\"ctx\"):\n", + " med = g[\"hours\"].median(); mad = (g[\"hours\"] - med).abs().median() * 1.4826\n", + " thr[c] = (med - 4 * mad, med + 4 * mad)\n", + "dyn_flag = pd.Series([not (thr[c][0] <= h <= thr[c][1]) for c, h in zip(df[\"ctx\"], df[\"hours\"])], index=df.index) | (df[\"hours\"] >= 30)\n", + "print(\"상황별 동적 임계값(시간):\", \", \".join(f\"{c} {lo:.1f}~{hi:.1f}\" for c, (lo, hi) in thr.items()))\n", + "tr = [truth[i] for i in df[\"i\"]]\n", + "is_noise = np.array([t is None for t in tr])\n", + "for name, fl in [(\"고정 임계값(14시간)\", fixed_flag.to_numpy()), (\"상황별 동적 임계값\", dyn_flag.to_numpy())]:\n", + " tp = int((fl & is_noise).sum()); fp = int((fl & ~is_noise).sum())\n", + " print(f\" {name:16s}: 이상 판정 {int(fl.sum())}건, 실제 잡음 중 {tp}/{int(is_noise.sum())} 검출, 정상인데 이상 판정(오탐) {fp}건\")\n", + "\n", + "# 3) 파싱 정확도(정답과 비교): 잡음이 아닌 줄에서 시간·사유·부대·직무 일치율\n", + "clean = df[~dyn_flag].copy()\n", + "tc = [truth[i] for i in clean[\"i\"]]\n", + "errs = sum((c[\"unit\"], c[\"job\"], c[\"ctx\"], c[\"reason\"]) != (t[0], t[1], t[3], t[4]) or abs(c[\"hours\"] - t[2]) > 0.01 for (_, c), t in zip(clean.iterrows(), tc) if t is not None)\n", + "n_eval = sum(t is not None for t in tc)\n", + "print(f\"\\n[전처리 정확도] 정제 후 {len(clean)}줄(정답 대조 {n_eval}줄) 중 불일치 {errs}줄 -> 전처리 오류율 {errs / n_eval:.2%} (목표 0%: {'달성' if errs == 0 else '미달'})\")\n", + "print(\"[결원 사유 분류]\", clean[\"reason\"].value_counts().to_dict())\n", + "print(\"[근무 시간 정규화] 상황별 평균 근무 시간:\", clean.groupby(\"ctx\")[\"hours\"].mean().round(1).to_dict())\n", + "agg = clean.groupby([\"unit\", \"job\"]).agg(줄수=(\"hours\", \"size\"), 평균근무시간=(\"hours\", \"mean\")).round(1)\n", + "print(\"[전처리 후 부대·직무 집계 예시]\"); print(agg.head(4).to_string())\n", + "print(\"사람 확인 대상: 해석 실패 %d줄(예: %r), 사유 '기타' %d줄\" % (len(bad), lines[bad[0][0]][:24] if bad else \"\", int((clean[\"reason\"] == \"기타\").sum())))\n", + "print(\"※ 임계값은 상황별 중앙값±4MAD로 자료에서 조정하며, 이상 판정된 줄은 삭제하지 않고 격리해 담당자가 확인한다.\")\n", + "\n", + "plt.figure(figsize=(8, 4))\n", + "for c, g in clean.groupby(\"ctx\"):\n", + " plt.hist(g[\"hours\"], bins=np.arange(2, 22, 1), alpha=.5, label={\"평시\": \"normal\", \"야간\": \"night\", \"훈련주간\": \"training\"}[c])\n", + "plt.axvline(FIXED, color=\"k\", ls=\"--\"); plt.legend(); plt.xlabel(\"duty hours\"); plt.title(\"Normalized duty hours by context (dashed: fixed threshold)\"); plt.tight_layout(); plt.show()\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 02 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + "[파싱] 6060줄 중 성공 5953줄, 해석 실패(잘린 줄) 107줄 -> 격리 / 속도 56,271건/초 (목표 1,000건/초 이상: 달성)\n", + "중복 줄 118개 제거 -> 5835줄\n", + "상황별 동적 임계값(시간): 야간 5.1~16.9, 평시 3.1~14.9, 훈련주간 9.1~20.9\n", + " 고정 임계값(14시간) : 이상 판정 623건, 실제 잡음 중 38/75 검출, 정상인데 이상 판정(오탐) 585건\n", + " 상황별 동적 임계값 : 이상 판정 75건, 실제 잡음 중 75/75 검출, 정상인데 이상 판정(오탐) 0건\n", + "\n", + "[전처리 정확도] 정제 후 5760줄(정답 대조 5760줄) 중 불일치 0줄 -> 전처리 오류율 0.00% (목표 0%: 달성)\n", + "[결원 사유 분류] {'휴가': 1719, '환자': 1175, '교육·파견': 1161, '전출': 889, '기타': 816}\n", + "[근무 시간 정규화] 상황별 평균 근무 시간: {'야간': 11.0, '평시': 8.9, '훈련주간': 14.9}\n", + "[전처리 후 부대·직무 집계 예시]\n", + " 줄수 평균근무시간\n", + "unit job \n", + "가상A대대 보급 226 10.4\n", + " 의무 245 10.5\n", + " 작전 235 10.3\n", + " 정비 239 10.1\n", + "사람 확인 대상: 해석 실패 107줄(예: '가상D대대 통신 근무 2'), 사유 '기타' 816줄\n", + "※ 임계값은 상황별 중앙값±4MAD로 자료에서 조정하며, 이상 판정된 줄은 삭제하지 않고 격리해 담당자가 확인한다.\n", + "```\n", + "
\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 03. 직무별 결원율 및 초과근무 시간 기반 1차 피로도 산출 Baseline 모델 구현\n", + "**소요시간**: 약 2.5시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "직무별 결원율 및 초과근무 기반 피로도 산출 Baseline 모델을 구현한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **직무별 결원율과 초과근무 시간(야간 근무 비율 포함)으로 1차 피로도 점수를 계산하는 Baseline 모델을 만들고 가중치를 1차 튜닝해 정확도를 검증한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **결원율·초과근무·야간 비율을 한도로 나누어 가중합한 피로도 점수(0~100)를 정의하고, 앞 52주로 가중치와 기준 점수를 격자 탐색한 뒤 뒤 52주로 정확도·정밀도·재현율·F1을 시험 수행하고 검증하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `src/models/adm01_baseline_model.py 및 기본 추론 결과 보고서` 형태의 결과물을 완성한다.\n", + "\n", + "### 🔍 평가 및 검증 기준 (PASS 조건)\n", + "#### 미션 판정\n", + "\n", + "**평가 구분:** 필수\n", + "\n", + "**핵심 평가영역:** 알고리즘의 논리적 흐름 및 군사적 판단 기준의 투명성\n", + "\n", + "#### 미션 PASS 조건\n", + "\n", + "- 정량 기준: 기본 데이터셋 대상 탐지/처리 정확도 75% 이상 달성\n", + "\n", + "\n", + "- 진행 기준: 단위 기능 동작 확인 및 기본 시나리오 실행 로그 생성\n", + "\n", + "\n", + "- 검증 방법: 기준 벤치마크 데이터셋 대상 추론 결과 오차율 비교\n", + "\n", + "\n", + "#### 채점자 판정 메모\n", + "\n", + "> 단순 코드 실행 여부뿐만 아니라 출력된 수치와 근거 자료가 군 실무 요구사항에 부합하는지 면밀히 확인한다.\n", + "\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "\"\"\"직무별 결원율·초과근무 시간·야간 근무 비율로 1차 피로도 점수(0~100)를 계산하는 가중합 Baseline을 만들고, 초기 가중치와 격자 탐색으로 1차 튜닝한 가중치의 정확도·정밀도·재현율·판단 지연시간을 시험 기간 자료로 검증하며 부대·직무 셀별 피로도 순위와 판단 근거를 출력한다.\"\"\"\n", + "import time\n", + "import itertools\n", + "import numpy as np\n", + "import pandas as pd\n", + "import matplotlib.pyplot as plt\n", + "\n", + "# ---------------------------------------------------------------------------\n", + "# [교육용가상자료] 가상 부대 인력 현황 생성기 (실제 부대·편제·병력·개인 정보 없음)\n", + "# 가상 대대 4곳 x 직무 6종의 주간 집계 자료만 만든다. 개인 단위 정보(이름·군번 등)는 만들지 않는다.\n", + "# 인원은 직무별 집계 수이며, 결원·휴가·환자·교육·보충·전출을 반영한 현재원과 초과근무 시간으로 구성된다.\n", + "# ---------------------------------------------------------------------------\n", + "UNITS = [\"가상A대대\", \"가상B대대\", \"가상C대대\", \"가상D대대\"]\n", + "JOBS = [\"작전\", \"통신\", \"정비\", \"보급\", \"행정\", \"의무\"]\n", + "FIELDS = [\"unit\", \"job\", \"week\", \"authorized\", \"present\", \"vacancy\", \"leave\", \"sick\", \"training\", \"inflow\", \"outflow\", \"overtime_h\", \"night_ratio\", \"rotation_days\", \"fatigue_label\"]\n", + "HI_CUT = 55 # 고피로 판정 기준(가상 점수 55 이상)\n", + "\n", + "\n", + "def make_manning(seed=7, weeks=104, shock=None):\n", + " \"\"\"부대 x 직무 24개 셀의 주간 인력 자료. shock=(unit, job 또는 None(부대 전체), 시작주, 주간 추가 전출 평균 수) 한 건 또는 그 목록이면 그 셀에 대량 결원 요인을 넣는다(AAR·스트레스용).\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " shocks = [shock] if shock and not isinstance(shock[0], tuple) else list(shock or [])\n", + " rows = []\n", + " for ui, u in enumerate(UNITS):\n", + " for ji, j in enumerate(JOBS):\n", + " auth = int(rng.integers(14, 42) * (0.9 + 0.06 * ui))\n", + " work = rng.uniform(0.92, 1.12) # 직무별 업무량 계수\n", + " net = rng.uniform(-0.05, 0.03) # 구조적 보충 부족(음수)·과잉(양수) 경향(주당 인원)\n", + " present = int(auth * rng.uniform(0.82, 0.98))\n", + " rot = int(rng.choice([60, 90, 120])) # 교대·보충 주기(일)\n", + " for w in range(1, weeks + 1):\n", + " season = 1.0 + 0.9 * (abs(w % 52 - 22) < 4 or abs(w % 52 - 46) < 3) # 전출·전역 집중 시기\n", + " out = int(rng.poisson(0.22 * auth / 20 * season))\n", + " for s_ in shocks:\n", + " if s_[0] == u and (s_[1] is None or s_[1] == j) and w >= s_[2]:\n", + " out += int(rng.poisson(s_[3]))\n", + " inflow = int(rng.poisson(max(0.0, 0.2 * auth / 20 * (1 + net * 10)))) + (int(rng.integers(2, 6) * auth / 25) if w % (rot // 7) == 0 else 0)\n", + " present = int(np.clip(present - out + inflow, int(auth * 0.4), auth))\n", + " leave = int(rng.binomial(present, 0.05 + 0.05 * (abs(w % 52 - 31) < 3 or w % 52 >= 50)))\n", + " sick = int(rng.poisson(0.025 * present)); train = int(rng.binomial(present, 0.04 + 0.06 * (w % 13 == 6)))\n", + " avail = max(present - leave - sick - train, 1)\n", + " night = float(np.clip(rng.normal(0.25 + 0.05 * (j == \"작전\") + 0.05 * (j == \"의무\"), 0.05), 0.05, 0.6))\n", + " ot = float(np.clip(auth * 40 * work / avail - 40 + rng.normal(0, 1.5), 0, 40))\n", + " vac = auth - present\n", + " absent = (leave + sick + train) / max(present, 1)\n", + " fat = 100 * (0.38 * min(vac / auth / 0.25, 1.2) + 0.34 * min(ot / 20, 1.2) + 0.10 * min(night / 0.4, 1.2) + 0.18 * min(absent / 0.2, 1.2)) + rng.normal(0, 5)\n", + " rows.append((u, j, w, auth, present, vac, leave, sick, train, inflow, out, round(ot, 1), round(night, 2), rot, int(fat >= HI_CUT)))\n", + " return pd.DataFrame(rows, columns=FIELDS)\n", + "\n", + "\n", + "def cell_pivot(df, col=\"vacancy\"):\n", + " \"\"\"주 x (부대,직무) 표.\"\"\"\n", + " # [TODO : 학생 실습 3] 직무별 결원율 및 초과근무 시간 기반 1차 피로도 산출 Baseline 모델 구현 관련 핵심 로직을 구현하세요.\n", + " # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n", + " # <<< 여기에 코드를 작성하세요 >>>\n", + " pass # 작성 후 pass를 제거하세요.\n", + "df = make_manning(7, 104)\n", + "df[\"vac_rate\"] = df[\"vacancy\"] / df[\"authorized\"]\n", + "tr, te = df[df[\"week\"] <= 52], df[df[\"week\"] > 52] # 앞 52주 학습·튜닝 / 뒤 52주 시험\n", + "X = lambda d: np.column_stack([np.minimum(d[\"vac_rate\"] / 0.25, 1.2), np.minimum(d[\"overtime_h\"] / 20, 1.2), np.minimum(d[\"night_ratio\"] / 0.4, 1.2)])\n", + "\n", + "\n", + "def score(d, w):\n", + " \"\"\"피로도 점수 = 100 x (w1 x 결원 부담 + w2 x 초과근무 부담 + w3 x 야간 부담).\"\"\"\n", + " # [TODO : 학생 실습 3] 직무별 결원율 및 초과근무 시간 기반 1차 피로도 산출 Baseline 모델 구현 관련 핵심 로직을 구현하세요.\n", + " # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n", + " # <<< 여기에 코드를 작성하세요 >>>\n", + " pass # 작성 후 pass를 제거하세요.\n", + "def metrics(y, p):\n", + " tp, fp, fn = int(((p == 1) & (y == 1)).sum()), int(((p == 1) & (y == 0)).sum()), int(((p == 0) & (y == 1)).sum())\n", + " pr, rc = tp / max(tp + fp, 1), tp / max(tp + fn, 1)\n", + " return {\"acc\": float((p == y).mean()), \"prec\": pr, \"rec\": rc, \"f1\": 2 * pr * rc / max(pr + rc, 1e-9)}\n", + "\n", + "\n", + "W0, CUT0 = (1 / 3, 1 / 3, 1 / 3), 50 # 초기(동일 가중) 휴리스틱\n", + "rule0 = ((te[\"vac_rate\"] >= 0.20) | (te[\"overtime_h\"] >= 15)).astype(int).to_numpy() # 가장 단순한 규칙: 결원율 20% 이상 또는 초과근무 15시간 이상\n", + "y_tr, y_te = tr[\"fatigue_label\"].to_numpy(), te[\"fatigue_label\"].to_numpy()\n", + "m0 = metrics(y_te, (score(te, W0) >= CUT0).astype(int))\n", + "trivial = float((y_te == 0).mean())\n", + "print(f\"[Baseline] 학습 {len(tr)}행 / 시험 {len(te)}행, 고피로 비율 {y_te.mean():.1%} (모두 '정상'이라 답해도 정확도 {trivial:.1%})\")\n", + "mr = metrics(y_te, rule0)\n", + "print(f\"단순 규칙(결원율 20% 이상 또는 초과근무 15시간 이상): 정확도 {mr['acc']:.1%}, 정밀도 {mr['prec']:.1%}, 재현율 {mr['rec']:.1%}, F1 {mr['f1']:.2f}\")\n", + "print(f\"초기 가중(동일 1/3, 기준 {CUT0}점): 정확도 {m0['acc']:.1%}, 정밀도 {m0['prec']:.1%}, 재현율 {m0['rec']:.1%}, F1 {m0['f1']:.2f}\")\n", + "\n", + "# 1차 튜닝: 가중치(합 1)와 기준 점수를 학습 기간에서 F1이 최대가 되게 격자 탐색\n", + "best = None\n", + "Xtr = X(tr)\n", + "for w1, w2 in itertools.product(np.arange(0.0, 1.01, 0.1), repeat=2):\n", + " if w1 + w2 > 1.0001:\n", + " continue\n", + " w = np.array([w1, w2, 1 - w1 - w2]); s = 100 * Xtr @ w\n", + " for cut in range(35, 71, 5):\n", + " f = metrics(y_tr, (s >= cut).astype(int))[\"f1\"]\n", + " if best is None or f > best[0]:\n", + " best = (f, tuple(float(x) for x in np.round(w, 1)), cut)\n", + "W1, CUT1 = best[1], best[2]\n", + "m1 = metrics(y_te, (score(te, W1) >= CUT1).astype(int))\n", + "print(f\"튜닝 가중 {W1}(결원·초과근무·야간), 기준 {CUT1}점 (학습 F1 {best[0]:.2f})\")\n", + "print(f\"시험 기간: 정확도 {m1['acc']:.1%}, 정밀도 {m1['prec']:.1%}, 재현율 {m1['rec']:.1%}, F1 {m1['f1']:.2f} (목표 정확도 75% 이상: {'달성' if m1['acc'] >= 0.75 else '미달'}, 단 단순 기준선 {trivial:.1%}보다 {'높음' if m1['acc'] > trivial else '낮음'})\")\n", + "\n", + "# 판단 지연시간: 1건씩 계산(실시간 조회 상황)\n", + "rec = te.to_dict(\"records\")\n", + "t0 = time.perf_counter()\n", + "for r in rec[:1000]:\n", + " s = min(100 * (W1[0] * min(r[\"vac_rate\"] / .25, 1.2) + W1[1] * min(r[\"overtime_h\"] / 20, 1.2) + W1[2] * min(r[\"night_ratio\"] / .4, 1.2)), 100); _ = s >= CUT1\n", + "lat = (time.perf_counter() - t0) / 1000 * 1000\n", + "print(f\"판단 지연시간 평균 {lat:.4f}ms/건\")\n", + "\n", + "# 2) 직무별·셀별 피로도 순위 (최근 12주 평균)\n", + "last = df[df[\"week\"] > 92].copy(); last[\"fatigue\"] = score(last, W1)\n", + "by_job = last.groupby(\"job\")[\"fatigue\"].mean().sort_values(ascending=False)\n", + "print(\"\\n[직무별 평균 피로도(최근 12주)]\", \", \".join(f\"{k} {v:.0f}\" for k, v in by_job.items()))\n", + "cell = last.groupby([\"unit\", \"job\"]).agg(피로도=(\"fatigue\", \"mean\"), 결원율=(\"vac_rate\", \"mean\"), 초과근무=(\"overtime_h\", \"mean\"), 야간=(\"night_ratio\", \"mean\")).sort_values(\"피로도\", ascending=False)\n", + "print(\"[피로도 상위 5개 셀과 판단 근거(기여 점수)]\")\n", + "for (u, j), r in cell.head(5).iterrows():\n", + " c = 100 * np.array(W1) * np.minimum(np.array([r[\"결원율\"] / .25, r[\"초과근무\"] / 20, r[\"야간\"] / .4]), 1.2)\n", + " print(f\" {u} {j}: 피로도 {r['피로도']:.0f} = 결원 {c[0]:.0f} + 초과근무 {c[1]:.0f} + 야간 {c[2]:.0f} (결원율 {r['결원율']:.0%}, 초과근무 {r['초과근무']:.1f}h)\")\n", + "n_hi = int((cell[\"피로도\"] >= CUT1).sum())\n", + "print(f\"기준 {CUT1}점 이상 셀 {n_hi}/24개 -> 인사관이 우선 확인. 점수는 판단 보조이며 휴식·교대 조정은 지휘관·인사관이 결정한다.\")\n", + "\n", + "# 3) 오분류 분석 (튜닝 가중치 시험 기간)\n", + "pred = (score(te, W1) >= CUT1).astype(int)\n", + "fn = te[(pred == 0) & (y_te == 1)]\n", + "print(f\"\\n[미탐 {len(fn)}건 특징] 평균 결원율 {fn['vac_rate'].mean():.0%}, 초과근무 {fn['overtime_h'].mean():.1f}h, 야간 {fn['night_ratio'].mean():.0%} -> 점수 경계 부근 사례(라벨에는 휴가·환자·교육 인원 비율과 잡음도 반영되어 이 3개 변수만으로는 설명되지 않음)\")\n", + "\n", + "plt.figure(figsize=(7, 4))\n", + "plt.scatter(te[\"vac_rate\"] * 100, te[\"overtime_h\"], c=te[\"fatigue_label\"], cmap=\"coolwarm\", s=8, alpha=.6)\n", + "plt.xlabel(\"vacancy rate (%)\"); plt.ylabel(\"overtime hours/person/week\"); plt.title(\"High-fatigue label (red) by vacancy and overtime\"); plt.tight_layout(); plt.show()\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 03 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + "[Baseline] 학습 1248행 / 시험 1248행, 고피로 비율 34.8% (모두 '정상'이라 답해도 정확도 65.2%)\n", + "단순 규칙(결원율 20% 이상 또는 초과근무 15시간 이상): 정확도 92.7%, 정밀도 96.7%, 재현율 81.8%, F1 0.89\n", + "초기 가중(동일 1/3, 기준 50점): 정확도 89.7%, 정밀도 78.9%, 재현율 95.9%, F1 0.87\n", + "튜닝 가중 (0.3, 0.6, 0.1)(결원·초과근무·야간), 기준 55점 (학습 F1 0.91)\n", + "시험 기간: 정확도 94.1%, 정밀도 91.9%, 재현율 91.0%, F1 0.91 (목표 정확도 75% 이상: 달성, 단 단순 기준선 65.2%보다 높음)\n", + "판단 지연시간 평균 0.0051ms/건\n", + "\n", + "[직무별 평균 피로도(최근 12주)] 보급 68, 통신 64, 정비 52, 작전 51, 의무 44, 행정 42\n", + "[피로도 상위 5개 셀과 판단 근거(기여 점수)]\n", + " 가상B대대 보급: 피로도 100 = 결원 36 + 초과근무 72 + 야간 6 (결원율 57%, 초과근무 40.0h)\n", + " 가상C대대 통신: 피로도 100 = 결원 36 + 초과근무 72 + 야간 7 (결원율 49%, 초과근무 39.9h)\n", + " 가상A대대 정비: 피로도 96 = 결원 34 + 초과근무 71 + 야간 6 (결원율 28%, 초과근무 23.6h)\n", + " 가상A대대 보급: 피로도 83 = 결원 20 + 초과근무 61 + 야간 6 (결원율 17%, 초과근무 20.3h)\n", + " 가상D대대 통신: 피로도 82 = 결원 19 + 초과근무 68 + 야간 6 (결원율 16%, 초과근무 22.5h)\n", + "기준 55점 이상 셀 10/24개 -> 인사관이 우선 확인. 점수는 판단 보조이며 휴식·교대 조정은 지휘관·인사관이 결정한다.\n", + "\n", + "[미탐 39건 특징] 평균 결원율 7%, 초과근무 11.7h, 야간 26% -> 점수 경계 부근 사례(라벨에는 휴가·환자·교육 인원 비율과 잡음도 반영되어 이 3개 변수만으로는 설명되지 않음)\n", + "```\n", + "
\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 04. 시계열 분석 기반 부서별 향후 3개월 결원 추세 및 보충 필요 인력 예측 모델 고도화\n", + "**소요시간**: 약 3.0시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "시계열 분석 기반 향후 3개월 결원 추세 및 보충 필요량 예측 모델을 고도화한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **시계열 분석으로 부대·직무별 향후 3개월(13주) 결원 추세를 예측하고 보충 필요 인원을 산출하는 모델을 고도화해 보충 필요 셀 탐지 재현율을 검증한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **24개 셀의 주간 결원율을 단순 예측, 릿지 회귀(특징 재설계 전후), 교대 보충·전출 집중기를 반영한 재고-흐름 모의(300경로)로 예측해 4개 시점 롤링 검증으로 오차를 비교 수행하고 검증하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `src/models/adm01_advanced_detector.py 및 예외 탐지 테스트 결과` 형태의 결과물을 완성한다.\n", + "\n", + "### 🔍 평가 및 검증 기준 (PASS 조건)\n", + "#### 미션 판정\n", + "\n", + "**평가 구분:** 필수\n", + "\n", + "**핵심 평가영역:** 기만/교란 공격에 대한 방어 로직의 현실성 및 신뢰성\n", + "\n", + "#### 미션 PASS 조건\n", + "\n", + "- 정량 기준: 복합 위협 탐지 재현율(Recall) 85% 이상, 처리 지연 200ms 이하\n", + "\n", + "\n", + "- 진행 기준: 복합 위협 시나리오 10종에 대한 탐지 테스트 통과\n", + "\n", + "\n", + "- 검증 방법: 합성 위협 데이터셋 주입 시 이상치 탐지 정확도 검증\n", + "\n", + "\n", + "#### 채점자 판정 메모\n", + "\n", + "> 단순 코드 실행 여부뿐만 아니라 출력된 수치와 근거 자료가 군 실무 요구사항에 부합하는지 면밀히 확인한다.\n", + "\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "\"\"\"부대·직무 24개 셀의 주간 결원율을 단순 예측·릿지 회귀(특징 재설계 v1→v2)·재고-흐름 모의(교대 보충·전출 집중기 반영, 300경로)로 향후 13주(3개월) 예측해 4개 시점 롤링 검증으로 비교하고, 보충 필요 셀 탐지의 재현율·정밀도 균형(확률 기준 선택)과 처리 지연을 측정해 보충 필요 인원을 산출한다.\"\"\"\n", + "import time\n", + "import numpy as np\n", + "import pandas as pd\n", + "import matplotlib.pyplot as plt\n", + "\n", + "# ---------------------------------------------------------------------------\n", + "# [교육용가상자료] 가상 부대 인력 현황 생성기 (실제 부대·편제·병력·개인 정보 없음)\n", + "# 가상 대대 4곳 x 직무 6종의 주간 집계 자료만 만든다. 개인 단위 정보(이름·군번 등)는 만들지 않는다.\n", + "# 인원은 직무별 집계 수이며, 결원·휴가·환자·교육·보충·전출을 반영한 현재원과 초과근무 시간으로 구성된다.\n", + "# ---------------------------------------------------------------------------\n", + "UNITS = [\"가상A대대\", \"가상B대대\", \"가상C대대\", \"가상D대대\"]\n", + "JOBS = [\"작전\", \"통신\", \"정비\", \"보급\", \"행정\", \"의무\"]\n", + "FIELDS = [\"unit\", \"job\", \"week\", \"authorized\", \"present\", \"vacancy\", \"leave\", \"sick\", \"training\", \"inflow\", \"outflow\", \"overtime_h\", \"night_ratio\", \"rotation_days\", \"fatigue_label\"]\n", + "HI_CUT = 55 # 고피로 판정 기준(가상 점수 55 이상)\n", + "\n", + "\n", + "def make_manning(seed=7, weeks=104, shock=None):\n", + " \"\"\"부대 x 직무 24개 셀의 주간 인력 자료. shock=(unit, job 또는 None(부대 전체), 시작주, 주간 추가 전출 평균 수) 한 건 또는 그 목록이면 그 셀에 대량 결원 요인을 넣는다(AAR·스트레스용).\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " shocks = [shock] if shock and not isinstance(shock[0], tuple) else list(shock or [])\n", + " rows = []\n", + " for ui, u in enumerate(UNITS):\n", + " for ji, j in enumerate(JOBS):\n", + " auth = int(rng.integers(14, 42) * (0.9 + 0.06 * ui))\n", + " work = rng.uniform(0.92, 1.12) # 직무별 업무량 계수\n", + " net = rng.uniform(-0.05, 0.03) # 구조적 보충 부족(음수)·과잉(양수) 경향(주당 인원)\n", + " present = int(auth * rng.uniform(0.82, 0.98))\n", + " rot = int(rng.choice([60, 90, 120])) # 교대·보충 주기(일)\n", + " for w in range(1, weeks + 1):\n", + " season = 1.0 + 0.9 * (abs(w % 52 - 22) < 4 or abs(w % 52 - 46) < 3) # 전출·전역 집중 시기\n", + " out = int(rng.poisson(0.22 * auth / 20 * season))\n", + " for s_ in shocks:\n", + " if s_[0] == u and (s_[1] is None or s_[1] == j) and w >= s_[2]:\n", + " out += int(rng.poisson(s_[3]))\n", + " inflow = int(rng.poisson(max(0.0, 0.2 * auth / 20 * (1 + net * 10)))) + (int(rng.integers(2, 6) * auth / 25) if w % (rot // 7) == 0 else 0)\n", + " present = int(np.clip(present - out + inflow, int(auth * 0.4), auth))\n", + " leave = int(rng.binomial(present, 0.05 + 0.05 * (abs(w % 52 - 31) < 3 or w % 52 >= 50)))\n", + " sick = int(rng.poisson(0.025 * present)); train = int(rng.binomial(present, 0.04 + 0.06 * (w % 13 == 6)))\n", + " avail = max(present - leave - sick - train, 1)\n", + " night = float(np.clip(rng.normal(0.25 + 0.05 * (j == \"작전\") + 0.05 * (j == \"의무\"), 0.05), 0.05, 0.6))\n", + " ot = float(np.clip(auth * 40 * work / avail - 40 + rng.normal(0, 1.5), 0, 40))\n", + " vac = auth - present\n", + " absent = (leave + sick + train) / max(present, 1)\n", + " fat = 100 * (0.38 * min(vac / auth / 0.25, 1.2) + 0.34 * min(ot / 20, 1.2) + 0.10 * min(night / 0.4, 1.2) + 0.18 * min(absent / 0.2, 1.2)) + rng.normal(0, 5)\n", + " rows.append((u, j, w, auth, present, vac, leave, sick, train, inflow, out, round(ot, 1), round(night, 2), rot, int(fat >= HI_CUT)))\n", + " return pd.DataFrame(rows, columns=FIELDS)\n", + "\n", + "\n", + "def cell_pivot(df, col=\"vacancy\"):\n", + " \"\"\"주 x (부대,직무) 표.\"\"\"\n", + " # [TODO : 학생 실습 4] 시계열 분석 기반 부서별 향후 3개월 결원 추세 및 보충 필요 인력 예측 모델 고도화 관련 핵심 로직을 구현하세요.\n", + " # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n", + " # <<< 여기에 코드를 작성하세요 >>>\n", + " pass # 작성 후 pass를 제거하세요.\n", + "from sklearn.linear_model import Ridge\n", + "H = 13 # 예측 기간: 향후 3개월(13주)\n", + "NEED_CUT = 0.15 # 결원율 15% 이상이면 보충 필요(가상 기준)\n", + "\n", + "\n", + "def season(w):\n", + " # [TODO : 학생 실습 4] 시계열 분석 기반 부서별 향후 3개월 결원 추세 및 보충 필요 인력 예측 모델 고도화 관련 핵심 로직을 구현하세요.\n", + " # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n", + " # <<< 여기에 코드를 작성하세요 >>>\n", + " pass # 작성 후 pass를 제거하세요.\n", + "def feat(vr, ir, orr, wn, rot, v2=True):\n", + " \"\"\"vr·ir·orr: 지금까지의 결원율·보충율·전출율 목록, wn: 예측할 다음 주차, rot: 교대 주기(일). v2는 보충·전출 흐름과 달력 특징을 추가한다.\"\"\"\n", + " f = [vr[-1], vr[-1] - vr[-2], vr[-1] - vr[-4]]\n", + " if v2:\n", + " step = max(rot // 7, 1)\n", + " f += [float(np.mean(orr[-12:]) - np.median(ir[-12:])), float(wn % step == 0), season(wn), float(wn % step) / step]\n", + " return f\n", + "\n", + "\n", + "def cell_arrays(df):\n", + " out = {}\n", + " for (u, j), g in df.sort_values(\"week\").groupby([\"unit\", \"job\"]):\n", + " a = g[\"authorized\"].to_numpy()\n", + " out[(u, j)] = {\"auth\": int(a[0]), \"rot\": int(g[\"rotation_days\"].iloc[0]), \"vr\": (g[\"vacancy\"].to_numpy() / a), \"ir\": g[\"inflow\"].to_numpy() / a, \"or\": g[\"outflow\"].to_numpy() / a}\n", + " return out\n", + "\n", + "\n", + "def fit_fc(cells, origin, v2=True, alpha=1.0):\n", + " X, y = [], []\n", + " for c in cells.values():\n", + " for t in range(12, origin - 1): # t주까지 알 때 t+1주(인덱스 t)의 결원율 변화를 맞힌다\n", + " X.append(feat(c[\"vr\"][:t], c[\"ir\"][:t], c[\"or\"][:t], t + 1, c[\"rot\"], v2)); y.append(c[\"vr\"][t] - c[\"vr\"][t - 1])\n", + " return Ridge(alpha=alpha).fit(np.array(X), np.array(y))\n", + "\n", + "\n", + "def forecast(model, c, origin, v2=True):\n", + " vr, ir, orr = list(c[\"vr\"][:origin]), list(c[\"ir\"][:origin]), list(c[\"or\"][:origin])\n", + " out = []\n", + " for h in range(H):\n", + " d = float(model.predict([feat(vr, ir, orr, origin + h + 1, c[\"rot\"], v2)])[0])\n", + " p = float(np.clip(vr[-1] + d, 0, 1))\n", + " out.append(p); vr.append(p); ir.append(float(np.median(ir[-12:]))); orr.append(float(np.mean(orr[-12:])))\n", + " return np.array(out)\n", + "\n", + "\n", + "def mc_forecast(c, origin, n_path=300, seed=0, out_mult=1.0, lam=0.0):\n", + " \"\"\"재고-흐름(stock-flow) 모의: 셀의 평시·전출 집중기 전출율, 정규 보충율, 교대 주기 보충 규모를 과거에서 추정해 결원 경로 n_path개를 만든다.\n", + " 반환: (경로 배열 [n_path, H] 결원율, 추정 모수). out_mult는 전출 가정을 키우는 보정 계수, lam은 최근 8주 전출율을 반영하는 비중(0~1, 급증 감지 때만 적용, AAR 재보정용).\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " w = np.arange(1, origin + 1); step = max(c[\"rot\"] // 7, 1)\n", + " rot_w = (w % step == 0); sea = np.array([season(x) > 0 for x in w])\n", + " o, i = c[\"or\"][:origin], c[\"ir\"][:origin]\n", + " o_base = o[~sea].mean(); o_sea = o[sea].mean() if sea.sum() >= 6 else o_base * 1.9\n", + " o_rec = o[-8:].mean()\n", + " mu = max(o_base, 1e-6) * c[\"auth\"] * 8 # 최근 8주 평시 기대 전출 인원\n", + " use_rec = lam > 0 and o_rec * c[\"auth\"] * 8 > mu + 2.0 * np.sqrt(mu) # 포아송 기준 상위 2표준편차를 넘는 '급증'일 때만 최근 값을 반영\n", + " i_reg = i[~rot_w].mean(); batch = max(i[rot_w].mean() - i_reg, 0.0)\n", + " a = c[\"auth\"]; pres = np.full(n_path, a * (1 - c[\"vr\"][origin - 1]))\n", + " out = np.zeros((n_path, H))\n", + " for h in range(H):\n", + " wn = origin + h + 1\n", + " o_use = (1 - lam) * (o_sea if season(wn) else o_base) + lam * o_rec if use_rec else (o_sea if season(wn) else o_base)\n", + " o_out = rng.poisson(max(o_use, 1e-6) * a * out_mult, n_path)\n", + " o_in = rng.poisson(max(i_reg, 1e-6) * a, n_path) + (np.maximum(rng.normal(batch * a, 0.3 * batch * a, n_path), 0).round() if wn % step == 0 else 0)\n", + " pres = np.clip(pres - o_out + o_in, 0.4 * a, a)\n", + " out[:, h] = 1 - pres / a\n", + " return out, {\"o_base\": o_base, \"o_sea\": o_sea, \"i_reg\": i_reg, \"batch\": batch}\n", + "\n", + "df = make_manning(7, 104)\n", + "cells = cell_arrays(df)\n", + "ORIGINS = [52, 65, 78, 91] # 예측 시점(그 주까지의 자료만 사용)\n", + "res = []\n", + "lat, lat3 = [], []\n", + "for o in ORIGINS:\n", + " m1, m2 = fit_fc(cells, o, v2=False), fit_fc(cells, o, v2=True)\n", + " for k, c in cells.items():\n", + " act = c[\"vr\"][o:o + H]\n", + " t0 = time.perf_counter(); p2 = forecast(m2, c, o, True); lat.append((time.perf_counter() - t0) * 1000)\n", + " p1 = forecast(m1, c, o, False)\n", + " naive = np.full(H, c[\"vr\"][o - 1])\n", + " t0 = time.perf_counter(); paths, _ = mc_forecast(c, o); lat3.append((time.perf_counter() - t0) * 1000)\n", + " res.append({\"origin\": o, \"cell\": k, \"auth\": c[\"auth\"], \"act_peak\": act.max(), \"act_end\": act[-1], \"naive\": naive, \"v1\": p1, \"v2\": p2, \"v3\": paths.mean(0), \"p_need\": float((paths.max(axis=1) >= NEED_CUT).mean()), \"act\": act})\n", + "R = pd.DataFrame(res)\n", + "mae = lambda col: float(np.mean([np.abs(r[col] - r[\"act\"]).mean() * r[\"auth\"] for _, r in R.iterrows()]))\n", + "print(f\"[롤링 검증] 시점 {ORIGINS} x 셀 {len(cells)}개 = {len(R)}건, 예측 13주\")\n", + "print(f\"결원 인원 MAE: 단순 예측(마지막 값 유지) {mae('naive'):.2f}명 / 회귀 v1 {mae('v1'):.2f}명 / 회귀 v2(특징 재설계) {mae('v2'):.2f}명 / 재고-흐름 모의 평균 {mae('v3'):.2f}명\")\n", + "print(f\"예측 지연: 회귀 셀 1개 평균 {np.mean(lat):.1f}ms, 모의 300경로 평균 {np.mean(lat3):.1f}ms/99백분위 {np.percentile(lat3, 99):.1f}ms (24개 셀 전체 {np.mean(lat3) * 24:.0f}ms, 목표 200ms 이하: {'달성' if np.percentile(lat3, 99) <= 200 else '미달'})\")\n", + "\n", + "# 보충 필요 셀 탐지: 향후 13주 중 결원율이 NEED_CUT 이상이 되는가\n", + "act_flag = R[\"act_peak\"] >= NEED_CUT\n", + "def det(pred):\n", + " pred = np.asarray(pred); tp, fp, fn = int((pred & act_flag).sum()), int((pred & ~act_flag).sum()), int((~pred & act_flag).sum())\n", + " return tp / max(tp + fn, 1), tp / max(tp + fp, 1), fp\n", + "print(f\"\\n[보충 필요(결원율 {NEED_CUT:.0%} 이상) 탐지] 실제 해당 {int(act_flag.sum())}건 / {len(R)}건\")\n", + "rows = [(\"단순 예측\", [r[\"naive\"].max() >= NEED_CUT for _, r in R.iterrows()]), (\"회귀 v1\", [r[\"v1\"].max() >= NEED_CUT for _, r in R.iterrows()]), (\"회귀 v2\", [r[\"v2\"].max() >= NEED_CUT for _, r in R.iterrows()])]\n", + "rows += [(f\"모의 확률 {p:.2f} 이상\", (R[\"p_need\"] >= p).to_list()) for p in (0.5, 0.3, 0.15)]\n", + "for name, pred in rows:\n", + " rc, pr, fp = det(pred); print(f\" {name:16s} 재현율 {rc:5.1%}, 정밀도 {pr:5.1%}, 오경보 {fp}건\")\n", + "P0 = 0.15\n", + "rc, pr, fp = det((R[\"p_need\"] >= P0).to_list())\n", + "print(f\"-> 선택: 모의 확률 {P0} 이상을 '보충 필요 가능성'으로 표시, 재현율 {rc:.1%} (목표 85% 이상: {'달성' if rc >= 0.85 else '미달'}), 대신 정밀도 {pr:.0%}로 오경보 {fp}건을 사람이 걸러야 함\")\n", + "\n", + "# 오경보 패턴: 선택 기준의 오경보가 어디에 몰리는가\n", + "fa = R[(R[\"p_need\"] >= P0) & ~act_flag]\n", + "near = float((fa[\"act_peak\"] >= 0.10).mean()) if len(fa) else 0.0\n", + "print(f\"[오경보 패턴] 오경보 {len(fa)}건 중 실제 최대 결원율이 10~15%인 경계 근처 사례 {near:.0%} -> 교대 보충 도착 시점·전출 규모의 불확실성이 원인, 확률이 낮은 건(0.15~0.5)은 '관찰'로 표시하고 0.5 이상만 '보충 필요'로 올린다\")\n", + "hi = int(((R[\"p_need\"] >= 0.5) & act_flag).sum()); hin = int((R[\"p_need\"] >= 0.5).sum())\n", + "print(f\"확률 0.5 이상 {hin}건 중 실제 보충 필요 {hi}건({hi / max(hin, 1):.0%})\")\n", + "\n", + "# 보충 필요 인원(경로의 90백분위 최대 결원 - 허용 결원 10%)과 실제 비교 (마지막 시점)\n", + "last = R[R[\"origin\"] == ORIGINS[-1]].copy()\n", + "need = lambda peak, a: max(0, int(np.ceil((peak - 0.10) * a)))\n", + "q = []\n", + "for _, r in last.iterrows():\n", + " paths, _ = mc_forecast(cells[r[\"cell\"]], ORIGINS[-1]); q.append(need(np.percentile(paths.max(axis=1), 75), r[\"auth\"]))\n", + "last[\"pred_need\"] = q\n", + "last[\"act_need\"] = [need(r[\"act\"].max(), r[\"auth\"]) for _, r in last.iterrows()]\n", + "print(f\"\\n[시점 {ORIGINS[-1]}주 기준 보충 필요 인원(경로 최대 결원의 75백분위 기준)] 예측 합계 {int(last['pred_need'].sum())}명, 실제 합계 {int(last['act_need'].sum())}명, 셀별 평균 절대오차 {np.abs(last['pred_need'] - last['act_need']).mean():.2f}명\")\n", + "top = last.sort_values(\"pred_need\", ascending=False).head(4)\n", + "for _, r in top.iterrows():\n", + " print(f\" {r['cell'][0]} {r['cell'][1]}: 예측 {r['pred_need']}명(실제 {r['act_need']}명), 보충 필요 확률 {r['p_need']:.0%}, 13주 말 예상 결원율 {r['v3'][-1]:.0%}\")\n", + "print(\"※ 예측은 가상 자료 기반 판단 보조이며 보충 요청과 배치는 인사관이 결정한다. 모델이 모르는 대량 전출·파견은 AAR에서 재보정한다.\")\n", + "\n", + "best = top.iloc[0]\n", + "plt.figure(figsize=(8, 4)); x = np.arange(H)\n", + "plt.plot(x, best[\"act\"] * 100, \"k-\", label=\"actual\"); plt.plot(x, best[\"v3\"] * 100, \"b--\", label=\"stock-flow mean\"); plt.plot(x, best[\"v2\"] * 100, \"g-.\", label=\"ridge v2\"); plt.plot(x, best[\"naive\"] * 100, \"g:\", label=\"naive\")\n", + "plt.axhline(NEED_CUT * 100, color=\"r\", ls=\":\"); plt.xlabel(\"weeks ahead\"); plt.ylabel(\"vacancy rate (%)\"); plt.title(\"13-week vacancy forecast (highest-need cell)\"); plt.legend(); plt.tight_layout(); plt.show()\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 04 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + "[롤링 검증] 시점 [52, 65, 78, 91] x 셀 24개 = 96건, 예측 13주\n", + "결원 인원 MAE: 단순 예측(마지막 값 유지) 1.39명 / 회귀 v1 1.45명 / 회귀 v2(특징 재설계) 1.16명 / 재고-흐름 모의 평균 1.07명\n", + "예측 지연: 회귀 셀 1개 평균 5.7ms, 모의 300경로 평균 1.9ms/99백분위 5.4ms (24개 셀 전체 45ms, 목표 200ms 이하: 달성)\n", + "\n", + "[보충 필요(결원율 15% 이상) 탐지] 실제 해당 35건 / 96건\n", + " 단순 예측 재현율 45.7%, 정밀도 100.0%, 오경보 0건\n", + " 회귀 v1 재현율 45.7%, 정밀도 100.0%, 오경보 0건\n", + " 회귀 v2 재현율 54.3%, 정밀도 100.0%, 오경보 0건\n", + " 모의 확률 0.50 이상 재현율 60.0%, 정밀도 87.5%, 오경보 3건\n", + " 모의 확률 0.30 이상 재현율 71.4%, 정밀도 67.6%, 오경보 12건\n", + " 모의 확률 0.15 이상 재현율 85.7%, 정밀도 55.6%, 오경보 24건\n", + "-> 선택: 모의 확률 0.15 이상을 '보충 필요 가능성'으로 표시, 재현율 85.7% (목표 85% 이상: 달성), 대신 정밀도 56%로 오경보 24건을 사람이 걸러야 함\n", + "[오경보 패턴] 오경보 24건 중 실제 최대 결원율이 10~15%인 경계 근처 사례 67% -> 교대 보충 도착 시점·전출 규모의 불확실성이 원인, 확률이 낮은 건(0.15~0.5)은 '관찰'로 표시하고 0.5 이상만 '보충 필요'로 올린다\n", + "확률 0.5 이상 24건 중 실제 보충 필요 21건(88%)\n", + "\n", + "[시점 91주 기준 보충 필요 인원(경로 최대 결원의 75백분위 기준)] 예측 합계 77명, 실제 합계 71명, 셀별 평균 절대오차 1.00명\n", + " 가상C대대 통신: 예측 17명(실제 15명), 보충 필요 확률 100%, 13주 말 예상 결원율 49%\n", + " 가상B대대 보급: 예측 16명(실제 16명), 보충 필요 확률 100%, 13주 말 예상 결원율 51%\n", + " 가상A대대 정비: 예측 7명(실제 8명), 보충 필요 확률 100%, 13주 말 예상 결원율 22%\n", + " 가상C대대 정비: 예측 4명(실제 3명), 보충 필요 확률 58%, 13주 말 예상 결원율 14%\n", + "※ 예측은 가상 자료 기반 판단 보조이며 보충 요청과 배치는 인사관이 결정한다. 모델이 모르는 대량 전출·파견은 AAR에서 재보정한다.\n", + "```\n", + "
\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 05. 인력 부족 위험 지수 산출 및 인사 배치 추천 신뢰도 엔진 개발\n", + "**소요시간**: 약 3.0시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "인력 부족 위험 지수 산출 및 인사 배치 추천 신뢰도 엔진을 개발한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **인력 부족 위험 지수를 산출하고 신뢰도(High/Med/Low) 등급과 함께 인사 배치(인원 이동) 추천을 만드는 신뢰도 엔진을 개발한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **보충 필요 확률·예상 최대 결원율·초과근무 부담으로 위험 지수를, 예측 경로 분산·모델 간 일치·자료 충분도로 신뢰도를 계산해 가중치 매트릭스로 보여 준다 과정을 진행하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `src/verification/adm01_confidence_engine.py` 형태의 결과물을 완성한다.\n", + "\n", + "### 🔍 평가 및 검증 기준 (PASS 조건)\n", + "#### 미션 판정\n", + "\n", + "**평가 구분:** 필수\n", + "\n", + "**핵심 평가영역:** 신뢰도 등급(High/Med/Low) 부여 기준의 군사적 타당성\n", + "\n", + "#### 미션 PASS 조건\n", + "\n", + "- 정량 기준: 오경보율(FAR) 40% 이상 감축, 신뢰도 스코어 산출 일관성 90% 이상\n", + "\n", + "\n", + "- 진행 기준: 오경보 필터링 전후 비교 지표 산출 및 통합 테스트 완료\n", + "\n", + "\n", + "- 검증 방법: ROC-AUC 곡선 분석 및 임계값 민감도 테스트\n", + "\n", + "\n", + "#### 채점자 판정 메모\n", + "\n", + "> 단순 코드 실행 여부뿐만 아니라 출력된 수치와 근거 자료가 군 실무 요구사항에 부합하는지 면밀히 확인한다.\n", + "\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "\"\"\"부대·직무 셀별 인력 부족 위험 지수(0~100)와 신뢰도(High/Med/Low: 예측 분산·모델 간 일치·자료 충분도 가중합)를 산출하고, 신뢰도 낮은 경보를 '확인 요청'으로 분리해 단순 경보 대비 오경보율 감축과 같은 설정 반복 실행 시 등급 일관성을 검증하며 신뢰도를 반영한 인사 배치(인원 이동) 추천안을 만든다.\"\"\"\n", + "import numpy as np\n", + "import pandas as pd\n", + "import matplotlib.pyplot as plt\n", + "\n", + "# ---------------------------------------------------------------------------\n", + "# [교육용가상자료] 가상 부대 인력 현황 생성기 (실제 부대·편제·병력·개인 정보 없음)\n", + "# 가상 대대 4곳 x 직무 6종의 주간 집계 자료만 만든다. 개인 단위 정보(이름·군번 등)는 만들지 않는다.\n", + "# 인원은 직무별 집계 수이며, 결원·휴가·환자·교육·보충·전출을 반영한 현재원과 초과근무 시간으로 구성된다.\n", + "# ---------------------------------------------------------------------------\n", + "UNITS = [\"가상A대대\", \"가상B대대\", \"가상C대대\", \"가상D대대\"]\n", + "JOBS = [\"작전\", \"통신\", \"정비\", \"보급\", \"행정\", \"의무\"]\n", + "FIELDS = [\"unit\", \"job\", \"week\", \"authorized\", \"present\", \"vacancy\", \"leave\", \"sick\", \"training\", \"inflow\", \"outflow\", \"overtime_h\", \"night_ratio\", \"rotation_days\", \"fatigue_label\"]\n", + "HI_CUT = 55 # 고피로 판정 기준(가상 점수 55 이상)\n", + "\n", + "\n", + "def make_manning(seed=7, weeks=104, shock=None):\n", + " \"\"\"부대 x 직무 24개 셀의 주간 인력 자료. shock=(unit, job 또는 None(부대 전체), 시작주, 주간 추가 전출 평균 수) 한 건 또는 그 목록이면 그 셀에 대량 결원 요인을 넣는다(AAR·스트레스용).\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " shocks = [shock] if shock and not isinstance(shock[0], tuple) else list(shock or [])\n", + " rows = []\n", + " for ui, u in enumerate(UNITS):\n", + " for ji, j in enumerate(JOBS):\n", + " auth = int(rng.integers(14, 42) * (0.9 + 0.06 * ui))\n", + " work = rng.uniform(0.92, 1.12) # 직무별 업무량 계수\n", + " net = rng.uniform(-0.05, 0.03) # 구조적 보충 부족(음수)·과잉(양수) 경향(주당 인원)\n", + " present = int(auth * rng.uniform(0.82, 0.98))\n", + " rot = int(rng.choice([60, 90, 120])) # 교대·보충 주기(일)\n", + " for w in range(1, weeks + 1):\n", + " season = 1.0 + 0.9 * (abs(w % 52 - 22) < 4 or abs(w % 52 - 46) < 3) # 전출·전역 집중 시기\n", + " out = int(rng.poisson(0.22 * auth / 20 * season))\n", + " for s_ in shocks:\n", + " if s_[0] == u and (s_[1] is None or s_[1] == j) and w >= s_[2]:\n", + " out += int(rng.poisson(s_[3]))\n", + " inflow = int(rng.poisson(max(0.0, 0.2 * auth / 20 * (1 + net * 10)))) + (int(rng.integers(2, 6) * auth / 25) if w % (rot // 7) == 0 else 0)\n", + " present = int(np.clip(present - out + inflow, int(auth * 0.4), auth))\n", + " leave = int(rng.binomial(present, 0.05 + 0.05 * (abs(w % 52 - 31) < 3 or w % 52 >= 50)))\n", + " sick = int(rng.poisson(0.025 * present)); train = int(rng.binomial(present, 0.04 + 0.06 * (w % 13 == 6)))\n", + " avail = max(present - leave - sick - train, 1)\n", + " night = float(np.clip(rng.normal(0.25 + 0.05 * (j == \"작전\") + 0.05 * (j == \"의무\"), 0.05), 0.05, 0.6))\n", + " ot = float(np.clip(auth * 40 * work / avail - 40 + rng.normal(0, 1.5), 0, 40))\n", + " vac = auth - present\n", + " absent = (leave + sick + train) / max(present, 1)\n", + " fat = 100 * (0.38 * min(vac / auth / 0.25, 1.2) + 0.34 * min(ot / 20, 1.2) + 0.10 * min(night / 0.4, 1.2) + 0.18 * min(absent / 0.2, 1.2)) + rng.normal(0, 5)\n", + " rows.append((u, j, w, auth, present, vac, leave, sick, train, inflow, out, round(ot, 1), round(night, 2), rot, int(fat >= HI_CUT)))\n", + " return pd.DataFrame(rows, columns=FIELDS)\n", + "\n", + "\n", + "def cell_pivot(df, col=\"vacancy\"):\n", + " \"\"\"주 x (부대,직무) 표.\"\"\"\n", + " # [TODO : 학생 실습 5] 인력 부족 위험 지수 산출 및 인사 배치 추천 신뢰도 엔진 개발 관련 핵심 로직을 구현하세요.\n", + " # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n", + " # <<< 여기에 코드를 작성하세요 >>>\n", + " pass # 작성 후 pass를 제거하세요.\n", + "from sklearn.linear_model import Ridge\n", + "H = 13 # 예측 기간: 향후 3개월(13주)\n", + "NEED_CUT = 0.15 # 결원율 15% 이상이면 보충 필요(가상 기준)\n", + "\n", + "\n", + "def season(w):\n", + " # [TODO : 학생 실습 5] 인력 부족 위험 지수 산출 및 인사 배치 추천 신뢰도 엔진 개발 관련 핵심 로직을 구현하세요.\n", + " # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n", + " # <<< 여기에 코드를 작성하세요 >>>\n", + " pass # 작성 후 pass를 제거하세요.\n", + "def feat(vr, ir, orr, wn, rot, v2=True):\n", + " \"\"\"vr·ir·orr: 지금까지의 결원율·보충율·전출율 목록, wn: 예측할 다음 주차, rot: 교대 주기(일). v2는 보충·전출 흐름과 달력 특징을 추가한다.\"\"\"\n", + " f = [vr[-1], vr[-1] - vr[-2], vr[-1] - vr[-4]]\n", + " if v2:\n", + " step = max(rot // 7, 1)\n", + " f += [float(np.mean(orr[-12:]) - np.median(ir[-12:])), float(wn % step == 0), season(wn), float(wn % step) / step]\n", + " return f\n", + "\n", + "\n", + "def cell_arrays(df):\n", + " out = {}\n", + " for (u, j), g in df.sort_values(\"week\").groupby([\"unit\", \"job\"]):\n", + " a = g[\"authorized\"].to_numpy()\n", + " out[(u, j)] = {\"auth\": int(a[0]), \"rot\": int(g[\"rotation_days\"].iloc[0]), \"vr\": (g[\"vacancy\"].to_numpy() / a), \"ir\": g[\"inflow\"].to_numpy() / a, \"or\": g[\"outflow\"].to_numpy() / a}\n", + " return out\n", + "\n", + "\n", + "def fit_fc(cells, origin, v2=True, alpha=1.0):\n", + " X, y = [], []\n", + " for c in cells.values():\n", + " for t in range(12, origin - 1): # t주까지 알 때 t+1주(인덱스 t)의 결원율 변화를 맞힌다\n", + " X.append(feat(c[\"vr\"][:t], c[\"ir\"][:t], c[\"or\"][:t], t + 1, c[\"rot\"], v2)); y.append(c[\"vr\"][t] - c[\"vr\"][t - 1])\n", + " return Ridge(alpha=alpha).fit(np.array(X), np.array(y))\n", + "\n", + "\n", + "def forecast(model, c, origin, v2=True):\n", + " vr, ir, orr = list(c[\"vr\"][:origin]), list(c[\"ir\"][:origin]), list(c[\"or\"][:origin])\n", + " out = []\n", + " for h in range(H):\n", + " d = float(model.predict([feat(vr, ir, orr, origin + h + 1, c[\"rot\"], v2)])[0])\n", + " p = float(np.clip(vr[-1] + d, 0, 1))\n", + " out.append(p); vr.append(p); ir.append(float(np.median(ir[-12:]))); orr.append(float(np.mean(orr[-12:])))\n", + " return np.array(out)\n", + "\n", + "\n", + "def mc_forecast(c, origin, n_path=300, seed=0, out_mult=1.0, lam=0.0):\n", + " \"\"\"재고-흐름(stock-flow) 모의: 셀의 평시·전출 집중기 전출율, 정규 보충율, 교대 주기 보충 규모를 과거에서 추정해 결원 경로 n_path개를 만든다.\n", + " 반환: (경로 배열 [n_path, H] 결원율, 추정 모수). out_mult는 전출 가정을 키우는 보정 계수, lam은 최근 8주 전출율을 반영하는 비중(0~1, 급증 감지 때만 적용, AAR 재보정용).\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " w = np.arange(1, origin + 1); step = max(c[\"rot\"] // 7, 1)\n", + " rot_w = (w % step == 0); sea = np.array([season(x) > 0 for x in w])\n", + " o, i = c[\"or\"][:origin], c[\"ir\"][:origin]\n", + " o_base = o[~sea].mean(); o_sea = o[sea].mean() if sea.sum() >= 6 else o_base * 1.9\n", + " o_rec = o[-8:].mean()\n", + " mu = max(o_base, 1e-6) * c[\"auth\"] * 8 # 최근 8주 평시 기대 전출 인원\n", + " use_rec = lam > 0 and o_rec * c[\"auth\"] * 8 > mu + 2.0 * np.sqrt(mu) # 포아송 기준 상위 2표준편차를 넘는 '급증'일 때만 최근 값을 반영\n", + " i_reg = i[~rot_w].mean(); batch = max(i[rot_w].mean() - i_reg, 0.0)\n", + " a = c[\"auth\"]; pres = np.full(n_path, a * (1 - c[\"vr\"][origin - 1]))\n", + " out = np.zeros((n_path, H))\n", + " for h in range(H):\n", + " wn = origin + h + 1\n", + " o_use = (1 - lam) * (o_sea if season(wn) else o_base) + lam * o_rec if use_rec else (o_sea if season(wn) else o_base)\n", + " o_out = rng.poisson(max(o_use, 1e-6) * a * out_mult, n_path)\n", + " o_in = rng.poisson(max(i_reg, 1e-6) * a, n_path) + (np.maximum(rng.normal(batch * a, 0.3 * batch * a, n_path), 0).round() if wn % step == 0 else 0)\n", + " pres = np.clip(pres - o_out + o_in, 0.4 * a, a)\n", + " out[:, h] = 1 - pres / a\n", + " return out, {\"o_base\": o_base, \"o_sea\": o_sea, \"i_reg\": i_reg, \"batch\": batch}\n", + "\n", + "df = make_manning(7, 104)\n", + "cells = cell_arrays(df)\n", + "ot = {k: g.sort_values(\"week\")[\"overtime_h\"].to_numpy() for k, g in df.groupby([\"unit\", \"job\"])}\n", + "CONF_W = {\"spread\": 0.5, \"agree\": 0.3, \"data\": 0.2} # 신뢰도 가중치 매트릭스(합 1)\n", + "RISK_W = {\"prob\": 0.5, \"peak\": 0.3, \"load\": 0.2} # 위험 지수 가중치(합 1)\n", + "GRADE = [(60, \"높음\"), (35, \"보통\"), (0, \"낮음\")]\n", + "\n", + "\n", + "def assess(k, origin, seed=0, models=None):\n", + " c = cells[k]\n", + " paths, par = mc_forecast(c, origin, n_path=800, seed=seed)\n", + " peaks = paths.max(axis=1)\n", + " p_need = float((peaks >= NEED_CUT).mean())\n", + " load = float(np.clip(ot[k][origin - 4:origin].mean() / 20, 0, 1)) # 최근 4주 초과근무 부담\n", + " risk = 100 * (RISK_W[\"prob\"] * p_need + RISK_W[\"peak\"] * min(peaks.mean() / 0.30, 1) + RISK_W[\"load\"] * load)\n", + " iqr = float(np.percentile(peaks, 75) - np.percentile(peaks, 25))\n", + " v2_peak = forecast(models[origin], c, origin, True).max() # 서로 다른 모델(회귀)과의 일치\n", + " agree = 1 - min(abs(v2_peak - peaks.mean()) / 0.10, 1)\n", + " conf = CONF_W[\"spread\"] * (1 - min(iqr / 0.12, 1)) + CONF_W[\"agree\"] * agree + CONF_W[\"data\"] * min(origin / 52, 1)\n", + " return {\"cell\": k, \"origin\": origin, \"risk\": risk, \"p_need\": p_need, \"peak\": float(peaks.mean()), \"iqr\": iqr, \"agree\": agree, \"conf\": conf,\n", + " \"grade\": next(g for t, g in GRADE if risk >= t), \"level\": \"High\" if conf >= 0.70 else \"Med\" if conf >= 0.45 else \"Low\"}\n", + "\n", + "\n", + "ORIGINS = [52, 65, 78, 91]\n", + "models = {o: fit_fc(cells, o, True) for o in ORIGINS}\n", + "rows = [assess(k, o, models=models) for o in ORIGINS for k in cells]\n", + "A = pd.DataFrame(rows)\n", + "A[\"act_need\"] = [cells[r.cell][\"vr\"][r.origin:r.origin + H].max() >= NEED_CUT for r in A.itertuples()]\n", + "A[\"base_alarm\"] = [(cells[r.cell][\"vr\"][r.origin - 1] >= 0.10) or (ot[r.cell][r.origin - 4:r.origin].mean() >= 15) for r in A.itertuples()]\n", + "A[\"alarm\"] = (A[\"risk\"] >= 50) & (A[\"level\"] != \"Low\") # 신뢰도 낮은 경보는 '확인 요청'으로 분리\n", + "A[\"check\"] = (A[\"risk\"] >= 50) & (A[\"level\"] == \"Low\")\n", + "far = lambda m: float(((m) & ~A[\"act_need\"]).sum() / max(m.sum(), 1))\n", + "fa0, fa1 = far(A[\"base_alarm\"]), far(A[\"alarm\"])\n", + "print(f\"[경보 비교] 셀 x 시점 {len(A)}건, 실제 보충 필요 {int(A['act_need'].sum())}건\")\n", + "print(f\" 단순 경보(결원율 10% 이상 또는 초과근무 15h 이상): 경보 {int(A['base_alarm'].sum())}건, 오경보율 {fa0:.1%}, 재현율 {float((A['base_alarm'] & A['act_need']).sum() / A['act_need'].sum()):.1%}\")\n", + "print(f\" 신뢰도 반영 경보(위험 50 이상 + 신뢰도 Med 이상): 경보 {int(A['alarm'].sum())}건, 오경보율 {fa1:.1%}, 재현율 {float((A['alarm'] & A['act_need']).sum() / A['act_need'].sum()):.1%}, 확인 요청 {int(A['check'].sum())}건\")\n", + "print(f\" 오경보율 {fa0:.1%} -> {fa1:.1%}: {(fa0 - fa1) / max(fa0, 1e-9):.0%} 감축 (목표 40% 이상: {'달성' if (fa0 - fa1) / max(fa0, 1e-9) >= 0.4 else '미달'})\")\n", + "\n", + "# 점수 산출 일관성: 같은 입력으로 난수 시드만 바꿔 5회 산출했을 때 위험 등급이 같은 셀의 비율\n", + "last = [assess(k, 91, seed=s, models=models) for s in range(5) for k in cells]\n", + "G = pd.DataFrame(last)\n", + "cons = G.groupby(\"cell\")[\"grade\"].nunique().eq(1).mean(); consl = G.groupby(\"cell\")[\"level\"].nunique().eq(1).mean()\n", + "print(f\"\\n[일관성] 시드 5회 반복: 위험 등급 동일 셀 {cons:.0%}, 신뢰도 등급 동일 셀 {consl:.0%}, 위험 지수 표준편차 평균 {G.groupby('cell')['risk'].std().mean():.2f}점 (목표 90% 이상: {'달성' if cons >= 0.9 else '미달'})\")\n", + "print(\"가중치 매트릭스: 위험 지수\", RISK_W, \"/ 신뢰도\", CONF_W, \"/ 등급 기준 위험\", GRADE, \"신뢰도 High 0.70, Med 0.45\")\n", + "\n", + "# 현재 시점(91주) 위험 순위와 불확실성 표시\n", + "now = A[A[\"origin\"] == 91].sort_values(\"risk\", ascending=False)\n", + "print(\"\\n[위험 지수 상위 6개 셀(91주 시점)] 막대: 최대 결원율 평균, 구간: 경로 IQR\")\n", + "for r in now.head(6).itertuples():\n", + " bar = \"#\" * int(r.peak * 40)\n", + " print(f\" {r.cell[0]} {r.cell[1]}: 위험 {r.risk:4.0f}({r.grade}) 신뢰도 {r.conf:.2f}({r.level}) 보충 필요 확률 {r.p_need:.0%} {bar} +-{r.iqr / 2:.0%}\")\n", + "\n", + "# 인사 배치 추천: 위험 높음 셀로 같은 직무의 여유 셀(위험 낮음, 결원 적음)에서 이동\n", + "rec = []\n", + "for r in now[now[\"grade\"] == \"높음\"].itertuples():\n", + " k = r.cell; need = max(1, int(np.ceil((r.peak - 0.10) * cells[k][\"auth\"])))\n", + " pool = [s for s in now[(now[\"grade\"] == \"낮음\")].itertuples() if s.cell[1] == k[1] and s.cell[0] != k[0]]\n", + " for s in sorted(pool, key=lambda s: s.peak)[:1]:\n", + " sp = int(max(0, np.floor((0.08 - s.peak) * cells[s.cell][\"auth\"])))\n", + " mv = min(need, max(sp, 1))\n", + " rec.append((k, s.cell, mv, need, \"High\" if min(r.conf, s.conf) >= 0.7 else \"Med\" if min(r.conf, s.conf) >= 0.45 else \"Low\"))\n", + "print(\"\\n[배치 추천안: 같은 직무 여유 셀 -> 부족 셀, 신뢰도는 두 셀 중 낮은 값]\")\n", + "for to, fr, mv, need, cl in rec:\n", + " print(f\" {fr[0]}({fr[1]}) -> {to[0]}({to[1]}): {mv}명 이동 제안 (필요 {need}명), 신뢰도 {cl}\" + (\" <- 사람 확인 필수\" if cl != \"High\" else \"\"))\n", + "if not rec:\n", + " print(\" 해당 직무에 여유 셀이 없어 외부 보충 요청 대상\")\n", + "print(\"※ 위험 지수·신뢰도는 판단 보조 지표이며 통계적 신뢰구간이 아니다. 인원 배치는 인사관이 검토·승인한다(M7).\")\n", + "\n", + "fig, ax = plt.subplots(figsize=(8, 4))\n", + "t = now.head(8); ax.barh([f\"{r.cell[0][2]}{i + 1}\" for i, r in enumerate(t.itertuples())], t[\"risk\"], xerr=t[\"iqr\"] * 100 / 2, color=[\"tab:red\" if g == \"높음\" else \"tab:orange\" if g == \"보통\" else \"tab:green\" for g in t[\"grade\"]])\n", + "ax.invert_yaxis(); ax.set_xlabel(\"risk index (0-100), error bar = forecast IQR\"); ax.set_title(\"Top-8 risk cells (label: unit letter + rank)\"); plt.tight_layout(); plt.show()\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 05 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + "[경보 비교] 셀 x 시점 96건, 실제 보충 필요 35건\n", + " 단순 경보(결원율 10% 이상 또는 초과근무 15h 이상): 경보 33건, 오경보율 36.4%, 재현율 60.0%\n", + " 신뢰도 반영 경보(위험 50 이상 + 신뢰도 Med 이상): 경보 18건, 오경보율 11.1%, 재현율 45.7%, 확인 요청 6건\n", + " 오경보율 36.4% -> 11.1%: 69% 감축 (목표 40% 이상: 달성)\n", + "\n", + "[일관성] 시드 5회 반복: 위험 등급 동일 셀 96%, 신뢰도 등급 동일 셀 88%, 위험 지수 표준편차 평균 0.74점 (목표 90% 이상: 달성)\n", + "가중치 매트릭스: 위험 지수 {'prob': 0.5, 'peak': 0.3, 'load': 0.2} / 신뢰도 {'spread': 0.5, 'agree': 0.3, 'data': 0.2} / 등급 기준 위험 [(60, '높음'), (35, '보통'), (0, '낮음')] 신뢰도 High 0.70, Med 0.45\n", + "\n", + "[위험 지수 상위 6개 셀(91주 시점)] 막대: 최대 결원율 평균, 구간: 경로 IQR\n", + " 가상B대대 보급: 위험 100(높음) 신뢰도 0.83(High) 보충 필요 확률 100% ####################### +-0%\n", + " 가상C대대 통신: 위험 100(높음) 신뢰도 0.47(Med) 보충 필요 확률 100% ###################### +-3%\n", + " 가상A대대 정비: 위험 94(높음) 신뢰도 0.64(Med) 보충 필요 확률 100% ########## +-3%\n", + " 가상D대대 통신: 위험 67(높음) 신뢰도 0.45(Low) 보충 필요 확률 62% ####### +-5%\n", + " 가상A대대 보급: 위험 63(높음) 신뢰도 0.52(Med) 보충 필요 확률 60% ####### +-5%\n", + " 가상C대대 정비: 위험 54(보통) 신뢰도 0.51(Med) 보충 필요 확률 54% ####### +-4%\n", + "\n", + "[배치 추천안: 같은 직무 여유 셀 -> 부족 셀, 신뢰도는 두 셀 중 낮은 값]\n", + " 가상D대대(보급) -> 가상B대대(보급): 1명 이동 제안 (필요 16명), 신뢰도 High\n", + " 가상B대대(통신) -> 가상C대대(통신): 1명 이동 제안 (필요 16명), 신뢰도 Med <- 사람 확인 필수\n", + " 가상D대대(정비) -> 가상A대대(정비): 1명 이동 제안 (필요 6명), 신뢰도 Med <- 사람 확인 필수\n", + " 가상B대대(통신) -> 가상D대대(통신): 1명 이동 제안 (필요 3명), 신뢰도 Low <- 사람 확인 필수\n", + " 가상D대대(보급) -> 가상A대대(보급): 1명 이동 제안 (필요 3명), 신뢰도 Med <- 사람 확인 필수\n", + "※ 위험 지수·신뢰도는 판단 보조 지표이며 통계적 신뢰구간이 아니다. 인원 배치는 인사관이 검토·승인한다(M7).\n", + "```\n", + "
\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 06. 지휘관/인사참모용 부대 근무부하 현황 및 결원 예측 가시화 대시보드 구현\n", + "**소요시간**: 약 2.5시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "부대 근무부하 현황 및 결원 예측 가시화 대시보드를 구현한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **지휘관·인사참모가 부대 근무부하 현황과 결원 예측을 한눈에 볼 수 있는 가시화 대시보드를 구현하고 응답 성능을 측정한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **부대 x 직무 24개 셀의 현재 결원율·13주 후 예측·초과근무·피로도·보충 필요 확률을 상태(긴급·주의·정상) 강조와 함께 표와 히트맵으로 만들고 위험 셀의 추세·예측 구간 그래프를 그린다 과정을 진행하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `src/ui/adm01_dashboard.py (또는 웹 UI 컴포넌트)` 형태의 결과물을 완성한다.\n", + "\n", + "### 🔍 평가 및 검증 기준 (PASS 조건)\n", + "#### 미션 판정\n", + "\n", + "**평가 구분:** 필수\n", + "\n", + "**핵심 평가영역:** 전술 작전 상황판으로서의 직관성 및 인지 부하 감소도\n", + "\n", + "#### 미션 PASS 조건\n", + "\n", + "- 정량 기준: 화면 갱신 주기 1초 이내, UI 응답성 100ms 이내\n", + "\n", + "\n", + "- 진행 기준: 주요 지표 시각화 컴포넌트 4종 이상 렌더링 및 실시간 데이터 연동\n", + "\n", + "\n", + "- 검증 방법: 사용자 인터랙션 테스트 및 모의 데이터 스트림 렌더링 검증\n", + "\n", + "\n", + "#### 채점자 판정 메모\n", + "\n", + "> 단순 코드 실행 여부뿐만 아니라 출력된 수치와 근거 자료가 군 실무 요구사항에 부합하는지 면밀히 확인한다.\n", + "\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "\"\"\"부대·직무 셀의 현재 결원율·초과근무·피로도와 향후 13주 결원 예측·보충 필요 확률을 지휘관·인사참모용 현황판(표·히트맵·추세 그래프, 긴급 경보 강조, 야간 모드)으로 보여 주고, 전체 갱신 시간(1초 이내)과 필터·드릴다운·모드 전환 응답 시간(100ms 이내)을 측정한다.\"\"\"\n", + "import time\n", + "import numpy as np\n", + "import pandas as pd\n", + "import matplotlib.pyplot as plt\n", + "\n", + "# ---------------------------------------------------------------------------\n", + "# [교육용가상자료] 가상 부대 인력 현황 생성기 (실제 부대·편제·병력·개인 정보 없음)\n", + "# 가상 대대 4곳 x 직무 6종의 주간 집계 자료만 만든다. 개인 단위 정보(이름·군번 등)는 만들지 않는다.\n", + "# 인원은 직무별 집계 수이며, 결원·휴가·환자·교육·보충·전출을 반영한 현재원과 초과근무 시간으로 구성된다.\n", + "# ---------------------------------------------------------------------------\n", + "UNITS = [\"가상A대대\", \"가상B대대\", \"가상C대대\", \"가상D대대\"]\n", + "JOBS = [\"작전\", \"통신\", \"정비\", \"보급\", \"행정\", \"의무\"]\n", + "FIELDS = [\"unit\", \"job\", \"week\", \"authorized\", \"present\", \"vacancy\", \"leave\", \"sick\", \"training\", \"inflow\", \"outflow\", \"overtime_h\", \"night_ratio\", \"rotation_days\", \"fatigue_label\"]\n", + "HI_CUT = 55 # 고피로 판정 기준(가상 점수 55 이상)\n", + "\n", + "\n", + "def make_manning(seed=7, weeks=104, shock=None):\n", + " \"\"\"부대 x 직무 24개 셀의 주간 인력 자료. shock=(unit, job 또는 None(부대 전체), 시작주, 주간 추가 전출 평균 수) 한 건 또는 그 목록이면 그 셀에 대량 결원 요인을 넣는다(AAR·스트레스용).\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " shocks = [shock] if shock and not isinstance(shock[0], tuple) else list(shock or [])\n", + " rows = []\n", + " for ui, u in enumerate(UNITS):\n", + " for ji, j in enumerate(JOBS):\n", + " auth = int(rng.integers(14, 42) * (0.9 + 0.06 * ui))\n", + " work = rng.uniform(0.92, 1.12) # 직무별 업무량 계수\n", + " net = rng.uniform(-0.05, 0.03) # 구조적 보충 부족(음수)·과잉(양수) 경향(주당 인원)\n", + " present = int(auth * rng.uniform(0.82, 0.98))\n", + " rot = int(rng.choice([60, 90, 120])) # 교대·보충 주기(일)\n", + " for w in range(1, weeks + 1):\n", + " season = 1.0 + 0.9 * (abs(w % 52 - 22) < 4 or abs(w % 52 - 46) < 3) # 전출·전역 집중 시기\n", + " out = int(rng.poisson(0.22 * auth / 20 * season))\n", + " for s_ in shocks:\n", + " if s_[0] == u and (s_[1] is None or s_[1] == j) and w >= s_[2]:\n", + " out += int(rng.poisson(s_[3]))\n", + " inflow = int(rng.poisson(max(0.0, 0.2 * auth / 20 * (1 + net * 10)))) + (int(rng.integers(2, 6) * auth / 25) if w % (rot // 7) == 0 else 0)\n", + " present = int(np.clip(present - out + inflow, int(auth * 0.4), auth))\n", + " leave = int(rng.binomial(present, 0.05 + 0.05 * (abs(w % 52 - 31) < 3 or w % 52 >= 50)))\n", + " sick = int(rng.poisson(0.025 * present)); train = int(rng.binomial(present, 0.04 + 0.06 * (w % 13 == 6)))\n", + " avail = max(present - leave - sick - train, 1)\n", + " night = float(np.clip(rng.normal(0.25 + 0.05 * (j == \"작전\") + 0.05 * (j == \"의무\"), 0.05), 0.05, 0.6))\n", + " ot = float(np.clip(auth * 40 * work / avail - 40 + rng.normal(0, 1.5), 0, 40))\n", + " vac = auth - present\n", + " absent = (leave + sick + train) / max(present, 1)\n", + " fat = 100 * (0.38 * min(vac / auth / 0.25, 1.2) + 0.34 * min(ot / 20, 1.2) + 0.10 * min(night / 0.4, 1.2) + 0.18 * min(absent / 0.2, 1.2)) + rng.normal(0, 5)\n", + " rows.append((u, j, w, auth, present, vac, leave, sick, train, inflow, out, round(ot, 1), round(night, 2), rot, int(fat >= HI_CUT)))\n", + " return pd.DataFrame(rows, columns=FIELDS)\n", + "\n", + "\n", + "def cell_pivot(df, col=\"vacancy\"):\n", + " \"\"\"주 x (부대,직무) 표.\"\"\"\n", + " # [TODO : 학생 실습 6] 지휘관/인사참모용 부대 근무부하 현황 및 결원 예측 가시화 대시보드 구현 관련 핵심 로직을 구현하세요.\n", + " # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n", + " # <<< 여기에 코드를 작성하세요 >>>\n", + " pass # 작성 후 pass를 제거하세요.\n", + "from sklearn.linear_model import Ridge\n", + "H = 13 # 예측 기간: 향후 3개월(13주)\n", + "NEED_CUT = 0.15 # 결원율 15% 이상이면 보충 필요(가상 기준)\n", + "\n", + "\n", + "def season(w):\n", + " # [TODO : 학생 실습 6] 지휘관/인사참모용 부대 근무부하 현황 및 결원 예측 가시화 대시보드 구현 관련 핵심 로직을 구현하세요.\n", + " # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n", + " # <<< 여기에 코드를 작성하세요 >>>\n", + " pass # 작성 후 pass를 제거하세요.\n", + "def feat(vr, ir, orr, wn, rot, v2=True):\n", + " \"\"\"vr·ir·orr: 지금까지의 결원율·보충율·전출율 목록, wn: 예측할 다음 주차, rot: 교대 주기(일). v2는 보충·전출 흐름과 달력 특징을 추가한다.\"\"\"\n", + " f = [vr[-1], vr[-1] - vr[-2], vr[-1] - vr[-4]]\n", + " if v2:\n", + " step = max(rot // 7, 1)\n", + " f += [float(np.mean(orr[-12:]) - np.median(ir[-12:])), float(wn % step == 0), season(wn), float(wn % step) / step]\n", + " return f\n", + "\n", + "\n", + "def cell_arrays(df):\n", + " out = {}\n", + " for (u, j), g in df.sort_values(\"week\").groupby([\"unit\", \"job\"]):\n", + " a = g[\"authorized\"].to_numpy()\n", + " out[(u, j)] = {\"auth\": int(a[0]), \"rot\": int(g[\"rotation_days\"].iloc[0]), \"vr\": (g[\"vacancy\"].to_numpy() / a), \"ir\": g[\"inflow\"].to_numpy() / a, \"or\": g[\"outflow\"].to_numpy() / a}\n", + " return out\n", + "\n", + "\n", + "def fit_fc(cells, origin, v2=True, alpha=1.0):\n", + " X, y = [], []\n", + " for c in cells.values():\n", + " for t in range(12, origin - 1): # t주까지 알 때 t+1주(인덱스 t)의 결원율 변화를 맞힌다\n", + " X.append(feat(c[\"vr\"][:t], c[\"ir\"][:t], c[\"or\"][:t], t + 1, c[\"rot\"], v2)); y.append(c[\"vr\"][t] - c[\"vr\"][t - 1])\n", + " return Ridge(alpha=alpha).fit(np.array(X), np.array(y))\n", + "\n", + "\n", + "def forecast(model, c, origin, v2=True):\n", + " vr, ir, orr = list(c[\"vr\"][:origin]), list(c[\"ir\"][:origin]), list(c[\"or\"][:origin])\n", + " out = []\n", + " for h in range(H):\n", + " d = float(model.predict([feat(vr, ir, orr, origin + h + 1, c[\"rot\"], v2)])[0])\n", + " p = float(np.clip(vr[-1] + d, 0, 1))\n", + " out.append(p); vr.append(p); ir.append(float(np.median(ir[-12:]))); orr.append(float(np.mean(orr[-12:])))\n", + " return np.array(out)\n", + "\n", + "\n", + "def mc_forecast(c, origin, n_path=300, seed=0, out_mult=1.0, lam=0.0):\n", + " \"\"\"재고-흐름(stock-flow) 모의: 셀의 평시·전출 집중기 전출율, 정규 보충율, 교대 주기 보충 규모를 과거에서 추정해 결원 경로 n_path개를 만든다.\n", + " 반환: (경로 배열 [n_path, H] 결원율, 추정 모수). out_mult는 전출 가정을 키우는 보정 계수, lam은 최근 8주 전출율을 반영하는 비중(0~1, 급증 감지 때만 적용, AAR 재보정용).\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " w = np.arange(1, origin + 1); step = max(c[\"rot\"] // 7, 1)\n", + " rot_w = (w % step == 0); sea = np.array([season(x) > 0 for x in w])\n", + " o, i = c[\"or\"][:origin], c[\"ir\"][:origin]\n", + " o_base = o[~sea].mean(); o_sea = o[sea].mean() if sea.sum() >= 6 else o_base * 1.9\n", + " o_rec = o[-8:].mean()\n", + " mu = max(o_base, 1e-6) * c[\"auth\"] * 8 # 최근 8주 평시 기대 전출 인원\n", + " use_rec = lam > 0 and o_rec * c[\"auth\"] * 8 > mu + 2.0 * np.sqrt(mu) # 포아송 기준 상위 2표준편차를 넘는 '급증'일 때만 최근 값을 반영\n", + " i_reg = i[~rot_w].mean(); batch = max(i[rot_w].mean() - i_reg, 0.0)\n", + " a = c[\"auth\"]; pres = np.full(n_path, a * (1 - c[\"vr\"][origin - 1]))\n", + " out = np.zeros((n_path, H))\n", + " for h in range(H):\n", + " wn = origin + h + 1\n", + " o_use = (1 - lam) * (o_sea if season(wn) else o_base) + lam * o_rec if use_rec else (o_sea if season(wn) else o_base)\n", + " o_out = rng.poisson(max(o_use, 1e-6) * a * out_mult, n_path)\n", + " o_in = rng.poisson(max(i_reg, 1e-6) * a, n_path) + (np.maximum(rng.normal(batch * a, 0.3 * batch * a, n_path), 0).round() if wn % step == 0 else 0)\n", + " pres = np.clip(pres - o_out + o_in, 0.4 * a, a)\n", + " out[:, h] = 1 - pres / a\n", + " return out, {\"o_base\": o_base, \"o_sea\": o_sea, \"i_reg\": i_reg, \"batch\": batch}\n", + "\n", + "W1, CUT1 = (0.3, 0.6, 0.1), 55 # 피로도 가중치·기준(M3 튜닝 결과 예시)\n", + "PALETTE = {\"day\": {\"bg\": \"white\", \"fg\": \"black\", \"cmap\": \"YlOrRd\", \"alert\": \"red\"}, \"night\": {\"bg\": \"#10151c\", \"fg\": \"#d8dee9\", \"cmap\": \"magma\", \"alert\": \"#ffb000\"}}\n", + "\n", + "\n", + "def refresh(df):\n", + " \"\"\"원 자료에서 현황판 데이터를 모두 다시 계산한다(전체 갱신).\"\"\"\n", + " cells = cell_arrays(df)\n", + " last = df[df[\"week\"] == df[\"week\"].max()].set_index([\"unit\", \"job\"])\n", + " rows = []\n", + " for k, c in cells.items():\n", + " paths, _ = mc_forecast(c, len(c[\"vr\"]), n_path=300)\n", + " peaks = paths.max(axis=1); p = float((peaks >= NEED_CUT).mean()); r = last.loc[k]\n", + " fat = min(100 * (W1[0] * min(r[\"vacancy\"] / r[\"authorized\"] / .25, 1.2) + W1[1] * min(r[\"overtime_h\"] / 20, 1.2) + W1[2] * min(r[\"night_ratio\"] / .4, 1.2)), 100)\n", + " risk = 100 * (0.5 * p + 0.3 * min(peaks.mean() / .30, 1) + 0.2 * min(r[\"overtime_h\"] / 20, 1))\n", + " rows.append({\"unit\": k[0], \"job\": k[1], \"auth\": c[\"auth\"], \"present\": int(r[\"present\"]), \"vac_now\": c[\"vr\"][-1], \"vac_13w\": float(paths.mean(0)[-1]), \"peak\": float(peaks.mean()),\n", + " \"overtime\": float(r[\"overtime_h\"]), \"fatigue\": fat, \"p_need\": p, \"risk\": risk, \"band\": np.percentile(paths, [10, 90], axis=0), \"mean\": paths.mean(0), \"hist\": c[\"vr\"][-26:]})\n", + " v = pd.DataFrame(rows)\n", + " v[\"status\"] = np.where(v[\"risk\"] >= 80, \"긴급\", np.where(v[\"risk\"] >= 50, \"주의\", \"정상\"))\n", + " return v\n", + "\n", + "\n", + "def view(v, unit=None, job=None, mode=\"day\"):\n", + " \"\"\"필터·정렬·강조를 적용한 화면 데이터와 색 설정(응답 시간 측정 대상).\"\"\"\n", + " s = v\n", + " if unit:\n", + " s = s[s[\"unit\"] == unit]\n", + " if job:\n", + " s = s[s[\"job\"] == job]\n", + " return s.sort_values(\"risk\", ascending=False), PALETTE[mode]\n", + "\n", + "\n", + "df = make_manning(7, 104)\n", + "t0 = time.perf_counter(); V = refresh(df); t_refresh = time.perf_counter() - t0\n", + "print(f\"[전체 갱신] 셀 {len(V)}개 재계산(예측 300경로 포함) {t_refresh * 1000:.0f}ms (목표 1초 이내: {'달성' if t_refresh <= 1.0 else '미달'})\")\n", + "\n", + "# 1) 현황판: 상태별 집계와 상위 위험 셀\n", + "print(\"\\n[지휘관 현황판 - 가상 대대 4곳 x 직무 6종] 기준 주차 %d\" % df[\"week\"].max())\n", + "print(\"상태 집계:\", V[\"status\"].value_counts().reindex([\"긴급\", \"주의\", \"정상\"]).fillna(0).astype(int).to_dict(), f\"/ 편제 {int(V['auth'].sum())}명, 현재원 {int(V['present'].sum())}명, 전체 결원율 {1 - V['present'].sum() / V['auth'].sum():.1%}\")\n", + "show, _ = view(V)\n", + "print(f\"{'상태':4s} {'부대':8s} {'직무':4s} {'결원율':>6s} {'13주후':>6s} {'초과근무':>7s} {'피로도':>5s} {'보충확률':>6s}\")\n", + "for r in show.head(7).itertuples():\n", + " mark = \"[긴급]\" if r.status == \"긴급\" else \"[주의]\" if r.status == \"주의\" else \" \"\n", + " print(f\"{mark} {r.unit:8s} {r.job:4s} {r.vac_now:6.0%} {r.vac_13w:6.0%} {r.overtime:6.1f}h {r.fatigue:5.0f} {r.p_need:6.0%}\")\n", + "print(\"\\n[부대 x 직무 결원율 히트맵(현재 -> 13주 후), * = 보충 필요 확률 50% 이상]\")\n", + "H = V.pivot(index=\"unit\", columns=\"job\", values=\"vac_now\"); H2 = V.pivot(index=\"unit\", columns=\"job\", values=\"vac_13w\"); PN = V.pivot(index=\"unit\", columns=\"job\", values=\"p_need\")\n", + "print(\" \" + \" \".join(f\"{j:>9s}\" for j in JOBS))\n", + "for u in H.index:\n", + " print(f\"{u:8s}\" + \" \".join(f\"{H.loc[u, j]:4.0%}>{H2.loc[u, j]:3.0%}{'*' if PN.loc[u, j] >= .5 else ' '}\" for j in JOBS))\n", + "\n", + "# 2) 응답 시간: 필터 변경, 셀 드릴다운, 야간 모드 전환을 각각 50회 측정\n", + "ops = {\"부대 필터\": lambda: view(V, unit=\"가상C대대\"), \"직무 필터\": lambda: view(V, job=\"통신\"), \"셀 드릴다운\": lambda: view(V, \"가상B대대\", \"보급\")[0].iloc[0][\"band\"].copy(), \"야간 모드 전환\": lambda: view(V, mode=\"night\")}\n", + "print(\"\\n[UI 응답 시간 50회 측정]\")\n", + "worst = 0\n", + "for name, fn in ops.items():\n", + " ts = []\n", + " for _ in range(50):\n", + " t0 = time.perf_counter(); fn(); ts.append((time.perf_counter() - t0) * 1000)\n", + " worst = max(worst, np.percentile(ts, 95))\n", + " print(f\" {name:10s} 평균 {np.mean(ts):.3f}ms, 95백분위 {np.percentile(ts, 95):.3f}ms\")\n", + "print(f\"최대 95백분위 {worst:.3f}ms (목표 100ms 이내: {'달성' if worst <= 100 else '미달'}), 화면 갱신 {t_refresh * 1000:.0f}ms (목표 1초 이내: {'달성' if t_refresh <= 1 else '미달'})\")\n", + "print(\"※ 야간 모드는 배경·글자·경보 색만 바꾸며 수치는 같다. 긴급·주의 표시는 판단 보조이며 보충·배치 결정은 인사관이 한다.\")\n", + "\n", + "\n", + "def draw(mode):\n", + " pal = PALETTE[mode]\n", + " fig, ax = plt.subplots(1, 2, figsize=(11, 4), facecolor=pal[\"bg\"])\n", + " for a in ax:\n", + " a.set_facecolor(pal[\"bg\"]); a.tick_params(colors=pal[\"fg\"])\n", + " for s in a.spines.values():\n", + " s.set_color(pal[\"fg\"])\n", + " im = ax[0].imshow(H2.to_numpy() * 100, cmap=pal[\"cmap\"], aspect=\"auto\")\n", + " ax[0].set_xticks(range(len(JOBS))); ax[0].set_xticklabels([f\"job{i + 1}\" for i in range(len(JOBS))]); ax[0].set_yticks(range(len(UNITS))); ax[0].set_yticklabels([\"A\", \"B\", \"C\", \"D\"])\n", + " for i, u in enumerate(H.index):\n", + " for j, jb in enumerate(JOBS):\n", + " ax[0].text(j, i, f\"{H2.loc[u, jb] * 100:.0f}{'!' if PN.loc[u, jb] >= .5 else ''}\", ha=\"center\", va=\"center\", color=pal[\"alert\"] if PN.loc[u, jb] >= .5 else pal[\"fg\"], fontsize=8)\n", + " ax[0].set_title(\"Forecast vacancy rate in 13 weeks (%, ! = need)\", color=pal[\"fg\"])\n", + " top = V.sort_values(\"risk\", ascending=False).iloc[0]; x = np.arange(-25, 14)\n", + " ax[1].plot(x[:26], top[\"hist\"] * 100, color=pal[\"fg\"], label=\"history\"); ax[1].plot(x[26:], top[\"mean\"] * 100, color=pal[\"alert\"], label=\"forecast\")\n", + " ax[1].fill_between(x[26:], top[\"band\"][0] * 100, top[\"band\"][1] * 100, color=pal[\"alert\"], alpha=.25); ax[1].axhline(NEED_CUT * 100, color=pal[\"fg\"], ls=\":\")\n", + " ax[1].set_title(\"Top-risk cell: vacancy trend and 80% band\", color=pal[\"fg\"]); ax[1].legend(facecolor=pal[\"bg\"], labelcolor=pal[\"fg\"]); ax[1].set_xlabel(\"weeks from now\", color=pal[\"fg\"])\n", + " plt.tight_layout(); plt.show()\n", + "\n", + "\n", + "draw(\"day\"); draw(\"night\")\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 06 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + "[전체 갱신] 셀 24개 재계산(예측 300경로 포함) 51ms (목표 1초 이내: 달성)\n", + "\n", + "[지휘관 현황판 - 가상 대대 4곳 x 직무 6종] 기준 주차 104\n", + "상태 집계: {'긴급': 5, '주의': 2, '정상': 17} / 편제 732명, 현재원 659명, 전체 결원율 10.0%\n", + "상태 부대 직무 결원율 13주후 초과근무 피로도 보충확률\n", + "[긴급] 가상A대대 정비 29% 26% 28.4h 100 100%\n", + "[긴급] 가상B대대 보급 48% 56% 40.0h 100 100%\n", + "[긴급] 가상C대대 통신 42% 46% 40.0h 100 100%\n", + "[긴급] 가상A대대 보급 18% 23% 22.7h 97 95%\n", + "[긴급] 가상D대대 의무 22% 13% 19.7h 93 99%\n", + "[주의] 가상D대대 통신 14% 9% 17.9h 76 67%\n", + "[주의] 가상A대대 의무 14% 6% 11.1h 57 46%\n", + "\n", + "[부대 x 직무 결원율 히트맵(현재 -> 13주 후), * = 보충 필요 확률 50% 이상]\n", + " 작전 통신 정비 보급 행정 의무\n", + "가상A대대 0%> 4% 6%> 5% 29%>26%* 18%>23%* 6%> 5% 14%> 6% \n", + "가상B대대 3%> 3% 0%> 5% 5%> 5% 48%>56%* 0%> 4% 0%> 3% \n", + "가상C대대 6%> 3% 42%>46%* 11%>11% 0%> 4% 0%> 5% 5%> 6% \n", + "가상D대대 3%> 6% 14%> 9%* 0%> 4% 0%> 2% 12%> 5% 22%>13%*\n", + "\n", + "[UI 응답 시간 50회 측정]\n", + " 부대 필터 평균 0.786ms, 95백분위 1.028ms\n", + " 직무 필터 평균 0.766ms, 95백분위 0.889ms\n", + " 셀 드릴다운 평균 1.201ms, 95백분위 1.630ms\n", + " 야간 모드 전환 평균 0.283ms, 95백분위 0.350ms\n", + "최대 95백분위 1.630ms (목표 100ms 이내: 달성), 화면 갱신 51ms (목표 1초 이내: 달성)\n", + "※ 야간 모드는 배경·글자·경보 색만 바꾸며 수치는 같다. 긴급·주의 표시는 판단 보조이며 보충·배치 결정은 인사관이 한다.\n", + "```\n", + "
\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 07. 인사 보충 우선순위 결정 시 인사관 수동 조정 및 승인(HITL) 워크플로우 설계\n", + "**소요시간**: 약 3.0시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "인사 보충 우선순위 결정 시 인사관 수동 조정 및 승인(HITL) 워크플로우를 설계한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **인력 보충 우선순위 결정을 인사담당-인사관(수동 조정)-지휘관(승인)이 거치는 HITL 워크플로우로 설계하고 성급한 자동 확정을 막는 체계를 검증한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **역할별 권한과 단계 순서를 정의하고 시스템은 제안만, 확정은 사람만 하도록 수행하고 검증하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `src/hitl/adm01_approval_workflow.py 및 docs/adm01_hitl_guide.md` 형태의 결과물을 완성한다.\n", + "\n", + "### 🔍 평가 및 검증 기준 (PASS 조건)\n", + "#### 미션 판정\n", + "\n", + "**평가 구분:** 필수\n", + "\n", + "**핵심 평가영역:** 지휘 책임 분계선 명확화 및 성급한 자동 단정 방지 체계 부합성\n", + "\n", + "#### 미션 PASS 조건\n", + "\n", + "- 정량 기준: 인간 검토 큐(Queue) 대기시간 및 승인/반려 처리 로깅 성공률 100%\n", + "\n", + "\n", + "- 진행 기준: 승인/반려 인터페이스 동작 및 감사 로그(Audit Log) 생성 검증\n", + "\n", + "\n", + "- 검증 방법: 모의 조작자 승인/반려 시뮬레이션 및 데이터 무결성 검증\n", + "\n", + "\n", + "#### 채점자 판정 메모\n", + "\n", + "> 단순 코드 실행 여부뿐만 아니라 출력된 수치와 근거 자료가 군 실무 요구사항에 부합하는지 면밀히 확인한다.\n", + "\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "\"\"\"인력 보충·재배치 우선순위 안을 인사담당 상신 -> 인사관 검토(수동 조정 가능) -> 지휘관 승인의 HITL 워크플로우로 처리하되, 시스템 자동 확정 금지·조정 전후 값과 사유 기록·반려 사유 필수화·권한 우회 차단·해시 체인 감사 로그를 검증하고 위험도 우선 큐(대기 시간 노화 가산)가 선입선출보다 긴급 건 대기를 줄이는지 비교한다.\"\"\"\n", + "import hashlib\n", + "import json\n", + "import numpy as np\n", + "import pandas as pd\n", + "import matplotlib.pyplot as plt\n", + "\n", + "# ---------------------------------------------------------------------------\n", + "# [교육용가상자료] 가상 부대 인력 현황 생성기 (실제 부대·편제·병력·개인 정보 없음)\n", + "# 가상 대대 4곳 x 직무 6종의 주간 집계 자료만 만든다. 개인 단위 정보(이름·군번 등)는 만들지 않는다.\n", + "# 인원은 직무별 집계 수이며, 결원·휴가·환자·교육·보충·전출을 반영한 현재원과 초과근무 시간으로 구성된다.\n", + "# ---------------------------------------------------------------------------\n", + "UNITS = [\"가상A대대\", \"가상B대대\", \"가상C대대\", \"가상D대대\"]\n", + "JOBS = [\"작전\", \"통신\", \"정비\", \"보급\", \"행정\", \"의무\"]\n", + "FIELDS = [\"unit\", \"job\", \"week\", \"authorized\", \"present\", \"vacancy\", \"leave\", \"sick\", \"training\", \"inflow\", \"outflow\", \"overtime_h\", \"night_ratio\", \"rotation_days\", \"fatigue_label\"]\n", + "HI_CUT = 55 # 고피로 판정 기준(가상 점수 55 이상)\n", + "\n", + "\n", + "def make_manning(seed=7, weeks=104, shock=None):\n", + " \"\"\"부대 x 직무 24개 셀의 주간 인력 자료. shock=(unit, job 또는 None(부대 전체), 시작주, 주간 추가 전출 평균 수) 한 건 또는 그 목록이면 그 셀에 대량 결원 요인을 넣는다(AAR·스트레스용).\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " shocks = [shock] if shock and not isinstance(shock[0], tuple) else list(shock or [])\n", + " rows = []\n", + " for ui, u in enumerate(UNITS):\n", + " for ji, j in enumerate(JOBS):\n", + " auth = int(rng.integers(14, 42) * (0.9 + 0.06 * ui))\n", + " work = rng.uniform(0.92, 1.12) # 직무별 업무량 계수\n", + " net = rng.uniform(-0.05, 0.03) # 구조적 보충 부족(음수)·과잉(양수) 경향(주당 인원)\n", + " present = int(auth * rng.uniform(0.82, 0.98))\n", + " rot = int(rng.choice([60, 90, 120])) # 교대·보충 주기(일)\n", + " for w in range(1, weeks + 1):\n", + " season = 1.0 + 0.9 * (abs(w % 52 - 22) < 4 or abs(w % 52 - 46) < 3) # 전출·전역 집중 시기\n", + " out = int(rng.poisson(0.22 * auth / 20 * season))\n", + " for s_ in shocks:\n", + " if s_[0] == u and (s_[1] is None or s_[1] == j) and w >= s_[2]:\n", + " out += int(rng.poisson(s_[3]))\n", + " inflow = int(rng.poisson(max(0.0, 0.2 * auth / 20 * (1 + net * 10)))) + (int(rng.integers(2, 6) * auth / 25) if w % (rot // 7) == 0 else 0)\n", + " present = int(np.clip(present - out + inflow, int(auth * 0.4), auth))\n", + " leave = int(rng.binomial(present, 0.05 + 0.05 * (abs(w % 52 - 31) < 3 or w % 52 >= 50)))\n", + " sick = int(rng.poisson(0.025 * present)); train = int(rng.binomial(present, 0.04 + 0.06 * (w % 13 == 6)))\n", + " avail = max(present - leave - sick - train, 1)\n", + " night = float(np.clip(rng.normal(0.25 + 0.05 * (j == \"작전\") + 0.05 * (j == \"의무\"), 0.05), 0.05, 0.6))\n", + " ot = float(np.clip(auth * 40 * work / avail - 40 + rng.normal(0, 1.5), 0, 40))\n", + " vac = auth - present\n", + " absent = (leave + sick + train) / max(present, 1)\n", + " fat = 100 * (0.38 * min(vac / auth / 0.25, 1.2) + 0.34 * min(ot / 20, 1.2) + 0.10 * min(night / 0.4, 1.2) + 0.18 * min(absent / 0.2, 1.2)) + rng.normal(0, 5)\n", + " rows.append((u, j, w, auth, present, vac, leave, sick, train, inflow, out, round(ot, 1), round(night, 2), rot, int(fat >= HI_CUT)))\n", + " return pd.DataFrame(rows, columns=FIELDS)\n", + "\n", + "\n", + "def cell_pivot(df, col=\"vacancy\"):\n", + " \"\"\"주 x (부대,직무) 표.\"\"\"\n", + " # [TODO : 학생 실습 7] 인사 보충 우선순위 결정 시 인사관 수동 조정 및 승인(HITL) 워크플로우 설계 관련 핵심 로직을 구현하세요.\n", + " # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n", + " # <<< 여기에 코드를 작성하세요 >>>\n", + " pass # 작성 후 pass를 제거하세요.\n", + "from sklearn.linear_model import Ridge\n", + "H = 13 # 예측 기간: 향후 3개월(13주)\n", + "NEED_CUT = 0.15 # 결원율 15% 이상이면 보충 필요(가상 기준)\n", + "\n", + "\n", + "def season(w):\n", + " # [TODO : 학생 실습 7] 인사 보충 우선순위 결정 시 인사관 수동 조정 및 승인(HITL) 워크플로우 설계 관련 핵심 로직을 구현하세요.\n", + " # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n", + " # <<< 여기에 코드를 작성하세요 >>>\n", + " pass # 작성 후 pass를 제거하세요.\n", + "def feat(vr, ir, orr, wn, rot, v2=True):\n", + " \"\"\"vr·ir·orr: 지금까지의 결원율·보충율·전출율 목록, wn: 예측할 다음 주차, rot: 교대 주기(일). v2는 보충·전출 흐름과 달력 특징을 추가한다.\"\"\"\n", + " f = [vr[-1], vr[-1] - vr[-2], vr[-1] - vr[-4]]\n", + " if v2:\n", + " step = max(rot // 7, 1)\n", + " f += [float(np.mean(orr[-12:]) - np.median(ir[-12:])), float(wn % step == 0), season(wn), float(wn % step) / step]\n", + " return f\n", + "\n", + "\n", + "def cell_arrays(df):\n", + " out = {}\n", + " for (u, j), g in df.sort_values(\"week\").groupby([\"unit\", \"job\"]):\n", + " a = g[\"authorized\"].to_numpy()\n", + " out[(u, j)] = {\"auth\": int(a[0]), \"rot\": int(g[\"rotation_days\"].iloc[0]), \"vr\": (g[\"vacancy\"].to_numpy() / a), \"ir\": g[\"inflow\"].to_numpy() / a, \"or\": g[\"outflow\"].to_numpy() / a}\n", + " return out\n", + "\n", + "\n", + "def fit_fc(cells, origin, v2=True, alpha=1.0):\n", + " X, y = [], []\n", + " for c in cells.values():\n", + " for t in range(12, origin - 1): # t주까지 알 때 t+1주(인덱스 t)의 결원율 변화를 맞힌다\n", + " X.append(feat(c[\"vr\"][:t], c[\"ir\"][:t], c[\"or\"][:t], t + 1, c[\"rot\"], v2)); y.append(c[\"vr\"][t] - c[\"vr\"][t - 1])\n", + " return Ridge(alpha=alpha).fit(np.array(X), np.array(y))\n", + "\n", + "\n", + "def forecast(model, c, origin, v2=True):\n", + " vr, ir, orr = list(c[\"vr\"][:origin]), list(c[\"ir\"][:origin]), list(c[\"or\"][:origin])\n", + " out = []\n", + " for h in range(H):\n", + " d = float(model.predict([feat(vr, ir, orr, origin + h + 1, c[\"rot\"], v2)])[0])\n", + " p = float(np.clip(vr[-1] + d, 0, 1))\n", + " out.append(p); vr.append(p); ir.append(float(np.median(ir[-12:]))); orr.append(float(np.mean(orr[-12:])))\n", + " return np.array(out)\n", + "\n", + "\n", + "def mc_forecast(c, origin, n_path=300, seed=0, out_mult=1.0, lam=0.0):\n", + " \"\"\"재고-흐름(stock-flow) 모의: 셀의 평시·전출 집중기 전출율, 정규 보충율, 교대 주기 보충 규모를 과거에서 추정해 결원 경로 n_path개를 만든다.\n", + " 반환: (경로 배열 [n_path, H] 결원율, 추정 모수). out_mult는 전출 가정을 키우는 보정 계수, lam은 최근 8주 전출율을 반영하는 비중(0~1, 급증 감지 때만 적용, AAR 재보정용).\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " w = np.arange(1, origin + 1); step = max(c[\"rot\"] // 7, 1)\n", + " rot_w = (w % step == 0); sea = np.array([season(x) > 0 for x in w])\n", + " o, i = c[\"or\"][:origin], c[\"ir\"][:origin]\n", + " o_base = o[~sea].mean(); o_sea = o[sea].mean() if sea.sum() >= 6 else o_base * 1.9\n", + " o_rec = o[-8:].mean()\n", + " mu = max(o_base, 1e-6) * c[\"auth\"] * 8 # 최근 8주 평시 기대 전출 인원\n", + " use_rec = lam > 0 and o_rec * c[\"auth\"] * 8 > mu + 2.0 * np.sqrt(mu) # 포아송 기준 상위 2표준편차를 넘는 '급증'일 때만 최근 값을 반영\n", + " i_reg = i[~rot_w].mean(); batch = max(i[rot_w].mean() - i_reg, 0.0)\n", + " a = c[\"auth\"]; pres = np.full(n_path, a * (1 - c[\"vr\"][origin - 1]))\n", + " out = np.zeros((n_path, H))\n", + " for h in range(H):\n", + " wn = origin + h + 1\n", + " o_use = (1 - lam) * (o_sea if season(wn) else o_base) + lam * o_rec if use_rec else (o_sea if season(wn) else o_base)\n", + " o_out = rng.poisson(max(o_use, 1e-6) * a * out_mult, n_path)\n", + " o_in = rng.poisson(max(i_reg, 1e-6) * a, n_path) + (np.maximum(rng.normal(batch * a, 0.3 * batch * a, n_path), 0).round() if wn % step == 0 else 0)\n", + " pres = np.clip(pres - o_out + o_in, 0.4 * a, a)\n", + " out[:, h] = 1 - pres / a\n", + " return out, {\"o_base\": o_base, \"o_sea\": o_sea, \"i_reg\": i_reg, \"batch\": batch}\n", + "\n", + "PERM = {\"시스템\": {\"propose\"}, \"인사담당\": {\"submit\", \"redraft\", \"view\"}, \"인사관\": {\"review\", \"adjust\", \"reject\", \"view\"}, \"지휘관\": {\"approve\", \"reject\", \"feedback\", \"view\"},\n", + " \"감사관\": {\"view\", \"audit\"}, \"인사처장\": {\"submit\", \"approve\", \"reject\", \"view\"}} # 인사처장은 상신·승인을 겸할 수 있으나 같은 건은 분리\n", + "USERS = {\"sys\": \"시스템\", \"kim\": \"인사담당\", \"lee\": \"인사관\", \"park\": \"지휘관\", \"jung\": \"감사관\", \"oh\": \"인사처장\"}\n", + "NEXT = {\"제안\": (\"submit\", \"상신\"), \"상신\": (\"review\", \"인사관검토\"), \"인사관검토\": (\"approve\", \"승인\")}\n", + "MAX_REDRAFT = 2\n", + "ADJ_LIMIT = 0.5 # 인사관 조정 폭: 제안 인원의 +-50% 이내(범위 밖은 사유와 지휘관 승인 필요로 차단)\n", + "\n", + "\n", + "class Chain:\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", + "class Workflow:\n", + " def __init__(self):\n", + " self.audit, self.cases, self.calls = Chain(), {}, 0\n", + "\n", + " def _deny(self, who, case, action, why):\n", + " self.audit.add(event=\"DENIED\", who=who, case=case, action=action, reason=why); return False, why\n", + "\n", + " def act(self, who, case, action, reason=\"\", evidence=\"\", qty=None):\n", + " self.calls += 1\n", + " role = USERS.get(who)\n", + " if role is None:\n", + " return self._deny(who, case, action, \"미등록 사용자\")\n", + " if action == \"delete_log\":\n", + " return self._deny(who, case, action, \"감사 로그 삭제·수정 권한은 누구에게도 없음\")\n", + " if action not in PERM[role]:\n", + " return self._deny(who, case, action, f\"권한 없음({role} 역할에 {action} 권한 없음)\")\n", + " if action == \"propose\":\n", + " self.cases[case] = {\"state\": \"제안\", \"submitter\": None, \"version\": 1, \"qty\": qty, \"orig\": qty, \"feedback\": \"\"}\n", + " self.audit.add(event=\"PROPOSE\", who=who, case=case, qty=qty, reason=reason); return True, \"제안 등록\"\n", + " c = self.cases.get(case)\n", + " if c is None:\n", + " return self._deny(who, case, action, \"존재하지 않는 건\")\n", + " if c[\"state\"] in (\"승인\", \"종결(반려)\"):\n", + " return self._deny(who, case, action, f\"이미 종결된 건({c['state']})\")\n", + " if not reason.strip() or not evidence.strip():\n", + " return self._deny(who, case, action, \"사유·근거 입력 필수(반려·조정 사유 포함)\")\n", + " if action == \"adjust\":\n", + " if c[\"state\"] != \"인사관검토\":\n", + " return self._deny(who, case, action, f\"조정은 인사관 검토 단계에서만 가능(현재 {c['state']})\")\n", + " if qty is None or qty < 0 or abs(qty - c[\"orig\"]) > max(1, ADJ_LIMIT * c[\"orig\"]):\n", + " return self._deny(who, case, action, f\"조정 폭 한도 초과(제안 {c['orig']}명, 요청 {qty})\")\n", + " before, c[\"qty\"] = c[\"qty\"], qty\n", + " self.audit.add(event=\"ADJUST\", who=who, role=role, case=case, before=before, after=qty, reason=reason, evidence=evidence); return True, f\"{before}명 -> {qty}명 조정\"\n", + " if action == \"feedback\":\n", + " c[\"feedback\"] = reason\n", + " self.audit.add(event=\"FEEDBACK\", who=who, role=role, case=case, reason=reason, evidence=evidence); return True, \"지휘관 피드백 기록\"\n", + " if action == \"reject\":\n", + " if c[\"state\"] in (\"제안\", \"반려\"):\n", + " return self._deny(who, case, action, f\"반려할 수 없는 단계({c['state']})\")\n", + " c[\"state\"] = \"종결(반려)\" if c[\"version\"] > MAX_REDRAFT else \"반려\"\n", + " self.audit.add(event=\"REJECTED\", who=who, role=role, case=case, version=c[\"version\"], reason=reason, evidence=evidence); return True, c[\"state\"]\n", + " if action == \"redraft\":\n", + " if c[\"state\"] != \"반려\":\n", + " return self._deny(who, case, action, f\"재기안은 반려된 건만 가능(현재 {c['state']})\")\n", + " if who != c[\"submitter\"]:\n", + " return self._deny(who, case, action, \"재기안은 최초 상신자만 가능\")\n", + " c[\"state\"], c[\"version\"] = \"상신\", c[\"version\"] + 1\n", + " self.audit.add(event=\"REDRAFT\", who=who, role=role, case=case, version=c[\"version\"], reason=reason, evidence=evidence); return True, \"재기안 상신\"\n", + " if c[\"state\"] == \"반려\":\n", + " return self._deny(who, case, action, \"반려된 건은 재기안 후에만 진행 가능\")\n", + " need, nxt = NEXT[c[\"state\"]]\n", + " if action != need:\n", + " return self._deny(who, case, action, f\"단계 위반(현재 {c['state']}, 필요 행위 {need})\")\n", + " if action == \"submit\":\n", + " c[\"submitter\"] = who\n", + " if action == \"approve\" and who == c[\"submitter\"]:\n", + " return self._deny(who, case, action, \"상신자와 승인자 분리 원칙 위반\")\n", + " c[\"state\"] = nxt\n", + " self.audit.add(event=action.upper(), who=who, role=role, case=case, version=c[\"version\"], to=nxt, qty=c[\"qty\"], reason=reason, evidence=evidence); return True, nxt\n", + "\n", + "\n", + "# ---- 상신 대상: 향후 13주 보충 필요 확률이 높은 셀 10건과 같은 직무 여유 셀에서의 이동 인원 ----\n", + "df = make_manning(7, 104)\n", + "cells = cell_arrays(df)\n", + "rng = np.random.default_rng(3)\n", + "items = []\n", + "for k, c in cells.items():\n", + " paths, _ = mc_forecast(c, 104, n_path=300)\n", + " peaks = paths.max(axis=1)\n", + " items.append({\"cell\": k, \"auth\": c[\"auth\"], \"peak\": float(peaks.mean()), \"p\": float((peaks >= NEED_CUT).mean())})\n", + "risk = pd.DataFrame(items)\n", + "risk[\"risk\"] = 100 * (0.6 * risk[\"p\"] + 0.4 * np.minimum(risk[\"peak\"] / .3, 1))\n", + "risk[\"need\"] = [max(1, int(np.ceil((r.peak - 0.10) * r.auth))) for r in risk.itertuples()]\n", + "cand = risk[risk[\"risk\"] >= 20].sort_values(\"risk\", ascending=False).head(10).reset_index(drop=True)\n", + "print(f\"[상신 대상] 위험 20 이상 {len(risk[risk['risk'] >= 20])}개 셀 중 상위 {len(cand)}건 (필요 인원 합계 {int(cand['need'].sum())}명)\")\n", + "\n", + "# ---- 큐 시뮬레이션: 검토 담당 1명(건당 2시간), 건이 1시간 간격으로 도착. 선입선출 vs 위험도 우선(대기 노화 가산) ----\n", + "def run_queue(policy, risks, service=2.0, arr_gap=1.0):\n", + " n = len(risks); arr = np.arange(n) * arr_gap; done, t, waits = set(), 0.0, {}\n", + " while len(done) < n:\n", + " ready = [i for i in range(n) if i not in done and arr[i] <= t]\n", + " if not ready:\n", + " t = min(arr[i] for i in range(n) if i not in done); continue\n", + " pick = ready[0] if policy == \"fifo\" else max(ready, key=lambda i: risks[i] + 5.0 * (t - arr[i])) # 노화 가산: 1시간 대기마다 5점\n", + " waits[pick] = t - arr[pick]; t += service; done.add(pick)\n", + " return waits\n", + "rs = list(rng.permutation(cand[\"risk\"].to_numpy())) # 도착 순서는 위험도와 무관\n", + "wf_, wp_ = run_queue(\"fifo\", rs), run_queue(\"prio\", rs)\n", + "hi = [i for i, r in enumerate(rs) if r >= 60]\n", + "print(f\"\\n[검토 큐 대기시간(시간)] 건 {len(rs)}개, 위험 60 이상 {len(hi)}건\")\n", + "print(f\" 선입선출: 전체 평균 {np.mean(list(wf_.values())):.1f}h, 고위험 평균 {np.mean([wf_[i] for i in hi]):.1f}h, 최대 {max(wf_.values()):.1f}h\")\n", + "print(f\" 위험도 우선(노화 가산): 전체 평균 {np.mean(list(wp_.values())):.1f}h, 고위험 평균 {np.mean([wp_[i] for i in hi]):.1f}h, 최대 {max(wp_.values()):.1f}h (저위험 건이 굶지 않도록 노화 가산)\")\n", + "\n", + "# ---- 워크플로우 모의 ----\n", + "wf = Workflow()\n", + "res = []\n", + "for k, r in cand.iterrows():\n", + " case = f\"H{k + 1:02d}\"; q = int(r[\"need\"])\n", + " wf.act(\"sys\", case, \"propose\", reason=f\"{r['cell'][0]} {r['cell'][1]} 보충 필요 확률 {r['p']:.0%} -> 인원 {q}명 보충·이동 제안\", qty=q)\n", + " wf.act(\"kim\", case, \"submit\", \"보충 우선순위 검토 요청\", f\"위험 {r['risk']:.0f}점, 예상 최대 결원율 {r['peak']:.0%}\")\n", + " for _ in range(12):\n", + " s = wf.cases[case][\"state\"]\n", + " if s in (\"승인\", \"종결(반려)\"):\n", + " break\n", + " if s == \"상신\":\n", + " if (r[\"p\"] < 0.5 and k % 3 == 0 and wf.cases[case][\"version\"] == 1) or k == 9: # k==9는 보완 없이 계속 반려되는 건(재기안 한도 시연)\n", + " wf.act(\"lee\", case, \"reject\", \"확률 50% 미만, 다음 주 재확인\", f\"보충 필요 확률 {r['p']:.0%}\")\n", + " else:\n", + " wf.act(\"lee\", case, \"review\", \"자료·예측 검토 완료\", f\"위험 {r['risk']:.0f}점\")\n", + " if r[\"cell\"][1] == \"통신\": # 인사관 수동 조정 예: 다른 부대 지원 가능 인원 반영\n", + " wf.act(\"lee\", case, \"adjust\", \"인접 부대 지원 가능 인원 1명 반영해 감원 조정\", \"현황 보고 확인\", qty=max(q - 1, 0))\n", + " elif s == \"인사관검토\":\n", + " wf.act(\"park\", case, \"feedback\", \"긴급 건은 주간 회의 전 결정 요청\" if r[\"risk\"] >= 80 else \"정기 보충 계획에 포함\", \"지휘관 의견\")\n", + " wf.act(\"park\", case, \"approve\", \"승인: 보충·이동 진행\", f\"위험 {r['risk']:.0f}점, 인사관 검토·조정 완료\")\n", + " elif s == \"반려\":\n", + " wf.act(\"kim\", case, \"redraft\", \"반려 사유 반영해 재기안\", f\"확률 재산출 후 재상신 {wf.cases[case]['version']}차\")\n", + " c = wf.cases[case]; res.append((case, f\"{r['cell'][0]} {r['cell'][1]}\", c[\"orig\"], c[\"qty\"], c[\"state\"], c[\"version\"]))\n", + "print(\"\\n[결재 결과] 10건 (모의 결재자 기준, 실제 결재는 사람이 한다)\")\n", + "print(pd.DataFrame(res, columns=[\"건\", \"셀\", \"제안 인원\", \"최종 인원\", \"상태\", \"버전\"]).to_string(index=False))\n", + "print(f\"인사관 수동 조정 {sum(e['event'] == 'ADJUST' for e in wf.audit.log)}건(조정 전후·사유 기록), 반려 {sum(e['event'] == 'REJECTED' for e in wf.audit.log)}건(사유 필수), 지휘관 피드백 {sum(e['event'] == 'FEEDBACK' for e in wf.audit.log)}건\")\n", + "\n", + "# ---- 권한 우회·절차 위반 시도 (시험마다 새 건을 만들어 해당 단계까지 진행시킨 뒤 시도) ----\n", + "def mk(cid, stage=\"submit\"):\n", + " wf.act(\"sys\", cid, \"propose\", reason=\"시험\", qty=10)\n", + " if stage in (\"submit\", \"review\"):\n", + " wf.act(\"kim\", cid, \"submit\", \"시험 상신\", \"근거\")\n", + " if stage == \"review\":\n", + " wf.act(\"lee\", cid, \"review\", \"검토\", \"이상 없음\")\n", + " return cid\n", + "approved = next(c for c, v in wf.cases.items() if v[\"state\"] == \"승인\")\n", + "att = [(\"시스템 자동 승인\", lambda: wf.act(\"sys\", mk(\"T01\"), \"approve\", \"자동\", \"자동\")), (\"인사담당이 조정 시도\", lambda: wf.act(\"kim\", mk(\"T02\", \"review\"), \"adjust\", \"임의 조정\", \"근거\", qty=5)),\n", + " (\"검토 건너뛰고 승인\", lambda: wf.act(\"park\", mk(\"T03\"), \"approve\", \"바로 승인\", \"근거\")), (\"미등록 사용자\", lambda: wf.act(\"guest\", mk(\"T04\"), \"review\", \"열람\", \"근거\")),\n", + " (\"사유 없는 검토\", lambda: wf.act(\"lee\", mk(\"T05\"), \"review\", \"\", \"\")), (\"감사 로그 삭제\", lambda: wf.act(\"kim\", mk(\"T06\"), \"delete_log\", \"정리\", \"근거\")),\n", + " (\"감사관이 승인 시도\", lambda: wf.act(\"jung\", mk(\"T07\", \"review\"), \"approve\", \"승인\", \"근거\")), (\"존재하지 않는 건\", lambda: wf.act(\"park\", \"X99\", \"approve\", \"승인\", \"근거\")),\n", + " (\"종결된 건 재승인\", lambda: wf.act(\"park\", approved, \"approve\", \"재승인\", \"근거\")), (\"조정 폭 한도 초과(10명 -> 30명)\", lambda: wf.act(\"lee\", mk(\"T08\", \"review\"), \"adjust\", \"대폭 증원\", \"근거\", qty=30)),\n", + " (\"사유 없는 조정\", lambda: wf.act(\"lee\", mk(\"T09\", \"review\"), \"adjust\", \"\", \"근거\", qty=9)), (\"인사관이 승인 시도\", lambda: wf.act(\"lee\", mk(\"T10\", \"review\"), \"approve\", \"대신 승인\", \"근거\")),\n", + " (\"검토 전 단계 조정\", lambda: wf.act(\"lee\", mk(\"T11\"), \"adjust\", \"조정\", \"근거\", qty=9)),\n", + " (\"상신자가 본인 건 승인(분리 원칙)\", lambda: (wf.act(\"sys\", \"T12\", \"propose\", reason=\"시험\", qty=4), wf.act(\"oh\", \"T12\", \"submit\", \"상신\", \"근거\"), wf.act(\"lee\", \"T12\", \"review\", \"검토\", \"이상 없음\"), wf.act(\"oh\", \"T12\", \"approve\", \"본인 승인\", \"근거\"))[-1])]\n", + "print(\"\\n[권한 우회·절차 위반 시도]\")\n", + "blocked = 0\n", + "for n, fn in att:\n", + " ok, why = fn(); blocked += (not ok); print(f\" {n:24s}-> {'차단' if not ok else '통과(결함)'}: {why}\")\n", + "print(f\"차단 {blocked}/{len(att)} ({blocked / len(att):.0%})\")\n", + "\n", + "# ---- 로깅 성공률과 이력 무결성 ----\n", + "n_log = len(wf.audit.log); bad = wf.audit.verify()\n", + "print(f\"\\n처리 요청 {wf.calls}건 -> 감사 로그 {n_log}건(승인·반려·조정·거부 모두 기록), 로깅 성공률 {n_log / wf.calls:.0%} (목표 100%: {'달성' if n_log == wf.calls else '미달'})\")\n", + "print(f\"해시 체인 검증: {'정상' if bad is None else f'seq {bad} 불일치'} -> 이력 무결성 {'100%' if bad is None else '훼손'}\")\n", + "saved = json.dumps(wf.audit.log[12], ensure_ascii=False); wf.audit.log[12][\"reason\"] = \"사후 수정\"; v = wf.audit.verify()\n", + "print(f\"12번째 기록 임의 수정 후 검증: {'위변조 탐지(seq ' + str(v) + ')' if v is not None else '탐지 실패'}\"); wf.audit.log[12] = json.loads(saved)\n", + "print(\"※ 보충 우선순위와 인원 이동은 사람(인사관·지휘관)이 결정하며 시스템은 제안과 근거만 제공한다. 결재선은 소속 기관 규정을 따른다.\")\n", + "\n", + "plt.figure(figsize=(6, 3.5)); plt.bar([\"FIFO\", \"priority\"], [np.mean([wf_[i] for i in hi]), np.mean([wp_[i] for i in hi])], color=[\"tab:gray\", \"tab:blue\"])\n", + "plt.ylabel(\"mean wait of high-risk items (h)\"); plt.title(\"Review queue wait: FIFO vs risk priority\"); plt.tight_layout(); plt.show()\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 07 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + "[상신 대상] 위험 20 이상 11개 셀 중 상위 10건 (필요 인원 합계 53명)\n", + "\n", + "[검토 큐 대기시간(시간)] 건 10개, 위험 60 이상 6건\n", + " 선입선출: 전체 평균 4.5h, 고위험 평균 4.8h, 최대 9.0h\n", + " 위험도 우선(노화 가산): 전체 평균 4.5h, 고위험 평균 2.5h, 최대 11.0h (저위험 건이 굶지 않도록 노화 가산)\n", + "\n", + "[결재 결과] 10건 (모의 결재자 기준, 실제 결재는 사람이 한다)\n", + " 건 셀 제안 인원 최종 인원 상태 버전\n", + "H01 가상A대대 정비 8 8 승인 1\n", + "H02 가상C대대 통신 13 12 승인 1\n", + "H03 가상B대대 보급 15 15 승인 1\n", + "H04 가상D대대 의무 3 3 승인 1\n", + "H05 가상A대대 보급 4 4 승인 1\n", + "H06 가상D대대 통신 3 2 승인 1\n", + "H07 가상A대대 의무 2 2 승인 2\n", + "H08 가상C대대 정비 2 2 승인 1\n", + "H09 가상B대대 정비 1 1 승인 1\n", + "H10 가상D대대 행정 2 2 종결(반려) 3\n", + "인사관 수동 조정 2건(조정 전후·사유 기록), 반려 4건(사유 필수), 지휘관 피드백 9건\n", + "\n", + "[권한 우회·절차 위반 시도]\n", + " 시스템 자동 승인 -> 차단: 권한 없음(시스템 역할에 approve 권한 없음)\n", + " 인사담당이 조정 시도 -> 차단: 권한 없음(인사담당 역할에 adjust 권한 없음)\n", + " 검토 건너뛰고 승인 -> 차단: 단계 위반(현재 상신, 필요 행위 review)\n", + " 미등록 사용자 -> 차단: 미등록 사용자\n", + " 사유 없는 검토 -> 차단: 사유·근거 입력 필수(반려·조정 사유 포함)\n", + " 감사 로그 삭제 -> 차단: 감사 로그 삭제·수정 권한은 누구에게도 없음\n", + " 감사관이 승인 시도 -> 차단: 권한 없음(감사관 역할에 approve 권한 없음)\n", + " 존재하지 않는 건 -> 차단: 존재하지 않는 건\n", + " 종결된 건 재승인 -> 차단: 이미 종결된 건(승인)\n", + " 조정 폭 한도 초과(10명 -> 30명) -> 차단: 조정 폭 한도 초과(제안 10명, 요청 30)\n", + " 사유 없는 조정 -> 차단: 사유·근거 입력 필수(반려·조정 사유 포함)\n", + " 인사관이 승인 시도 -> 차단: 권한 없음(인사관 역할에 approve 권한 없음)\n", + " 검토 전 단계 조정 -> 차단: 조정은 인사관 검토 단계에서만 가능(현재 상신)\n", + " 상신자가 본인 건 승인(분리 원칙) -> 차단: 상신자와 승인자 분리 원칙 위반\n", + "차단 14/14 (100%)\n", + "\n", + "처리 요청 100건 -> 감사 로그 100건(승인·반려·조정·거부 모두 기록), 로깅 성공률 100% (목표 100%: 달성)\n", + "해시 체인 검증: 정상 -> 이력 무결성 100%\n", + "12번째 기록 임의 수정 후 검증: 위변조 탐지(seq 12)\n", + "... (이하 생략)\n", + "```\n", + "
\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 08. 전시 동원 및 대량 결원 발생 상황 모의 인사 예측 스트레스 시뮬레이션\n", + "**소요시간**: 약 3.5시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "전시 동원 및 대량 결원 모의 인사 예측 스트레스 시뮬레이션을 수행한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **특정 직무에서 대량 결원이 발생한 상황과 조회 요청 500% 증가 부하에서 시스템이 중단되거나 자료가 유실되지 않는지 스트레스 시뮬레이션으로 검증한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **한 부대 통신 직무의 전출이 갑자기 늘어난 상황에서 보충 필요 확률과 필요 인원이 어떻게 바뀌는지 평시와 비교 수행하고 검증하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `tests/adm01_stress_test.py 및 스트레스 테스트 분석 보고서` 형태의 결과물을 완성한다.\n", + "\n", + "### 🔍 평가 및 검증 기준 (PASS 조건)\n", + "#### 미션 판정\n", + "\n", + "**평가 구분:** 필수\n", + "\n", + "**핵심 평가영역:** 극단 상황에서의 안전 실패(Fail-safe) 및 기능 축소 작동 적절성\n", + "\n", + "#### 미션 PASS 조건\n", + "\n", + "- 정량 기준: 동시 트래픽 500% 증가 시 시스템 다운 0건, 데이터 유실률 0%\n", + "\n", + "\n", + "- 진행 기준: 대량 시뮬레이션 부하 주입 테스트 통과 및 병목 구간 프로파일링 완료\n", + "\n", + "\n", + "- 검증 방법: 부하 테스트 도구를 통한 처리량(Throughput) 및 메모리 누수 검증\n", + "\n", + "\n", + "#### 채점자 판정 메모\n", + "\n", + "> 단순 코드 실행 여부뿐만 아니라 출력된 수치와 근거 자료가 군 실무 요구사항에 부합하는지 면밀히 확인한다.\n", + "\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "\"\"\"특정 직무에서 대량 결원이 발생한 상황(전출 급증)의 예측 변화를 확인하고, 조회·예측 요청을 기준 대비 100%·200%·500%로 늘려 큐 기반 작업자(스레드) 처리·큐 포화 시 기능 축소(경량 예측)·오류 요청 격리를 시험하며 시스템 다운·데이터 유실 건수와 병목 함수 최적화(캐시) 효과를 측정한다.\"\"\"\n", + "import queue\n", + "import threading\n", + "import time\n", + "from functools import lru_cache\n", + "import numpy as np\n", + "import pandas as pd\n", + "import matplotlib.pyplot as plt\n", + "\n", + "# ---------------------------------------------------------------------------\n", + "# [교육용가상자료] 가상 부대 인력 현황 생성기 (실제 부대·편제·병력·개인 정보 없음)\n", + "# 가상 대대 4곳 x 직무 6종의 주간 집계 자료만 만든다. 개인 단위 정보(이름·군번 등)는 만들지 않는다.\n", + "# 인원은 직무별 집계 수이며, 결원·휴가·환자·교육·보충·전출을 반영한 현재원과 초과근무 시간으로 구성된다.\n", + "# ---------------------------------------------------------------------------\n", + "UNITS = [\"가상A대대\", \"가상B대대\", \"가상C대대\", \"가상D대대\"]\n", + "JOBS = [\"작전\", \"통신\", \"정비\", \"보급\", \"행정\", \"의무\"]\n", + "FIELDS = [\"unit\", \"job\", \"week\", \"authorized\", \"present\", \"vacancy\", \"leave\", \"sick\", \"training\", \"inflow\", \"outflow\", \"overtime_h\", \"night_ratio\", \"rotation_days\", \"fatigue_label\"]\n", + "HI_CUT = 55 # 고피로 판정 기준(가상 점수 55 이상)\n", + "\n", + "\n", + "def make_manning(seed=7, weeks=104, shock=None):\n", + " \"\"\"부대 x 직무 24개 셀의 주간 인력 자료. shock=(unit, job 또는 None(부대 전체), 시작주, 주간 추가 전출 평균 수) 한 건 또는 그 목록이면 그 셀에 대량 결원 요인을 넣는다(AAR·스트레스용).\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " shocks = [shock] if shock and not isinstance(shock[0], tuple) else list(shock or [])\n", + " rows = []\n", + " for ui, u in enumerate(UNITS):\n", + " for ji, j in enumerate(JOBS):\n", + " auth = int(rng.integers(14, 42) * (0.9 + 0.06 * ui))\n", + " work = rng.uniform(0.92, 1.12) # 직무별 업무량 계수\n", + " net = rng.uniform(-0.05, 0.03) # 구조적 보충 부족(음수)·과잉(양수) 경향(주당 인원)\n", + " present = int(auth * rng.uniform(0.82, 0.98))\n", + " rot = int(rng.choice([60, 90, 120])) # 교대·보충 주기(일)\n", + " for w in range(1, weeks + 1):\n", + " season = 1.0 + 0.9 * (abs(w % 52 - 22) < 4 or abs(w % 52 - 46) < 3) # 전출·전역 집중 시기\n", + " out = int(rng.poisson(0.22 * auth / 20 * season))\n", + " for s_ in shocks:\n", + " if s_[0] == u and (s_[1] is None or s_[1] == j) and w >= s_[2]:\n", + " out += int(rng.poisson(s_[3]))\n", + " inflow = int(rng.poisson(max(0.0, 0.2 * auth / 20 * (1 + net * 10)))) + (int(rng.integers(2, 6) * auth / 25) if w % (rot // 7) == 0 else 0)\n", + " present = int(np.clip(present - out + inflow, int(auth * 0.4), auth))\n", + " leave = int(rng.binomial(present, 0.05 + 0.05 * (abs(w % 52 - 31) < 3 or w % 52 >= 50)))\n", + " sick = int(rng.poisson(0.025 * present)); train = int(rng.binomial(present, 0.04 + 0.06 * (w % 13 == 6)))\n", + " avail = max(present - leave - sick - train, 1)\n", + " night = float(np.clip(rng.normal(0.25 + 0.05 * (j == \"작전\") + 0.05 * (j == \"의무\"), 0.05), 0.05, 0.6))\n", + " ot = float(np.clip(auth * 40 * work / avail - 40 + rng.normal(0, 1.5), 0, 40))\n", + " vac = auth - present\n", + " absent = (leave + sick + train) / max(present, 1)\n", + " fat = 100 * (0.38 * min(vac / auth / 0.25, 1.2) + 0.34 * min(ot / 20, 1.2) + 0.10 * min(night / 0.4, 1.2) + 0.18 * min(absent / 0.2, 1.2)) + rng.normal(0, 5)\n", + " rows.append((u, j, w, auth, present, vac, leave, sick, train, inflow, out, round(ot, 1), round(night, 2), rot, int(fat >= HI_CUT)))\n", + " return pd.DataFrame(rows, columns=FIELDS)\n", + "\n", + "\n", + "def cell_pivot(df, col=\"vacancy\"):\n", + " \"\"\"주 x (부대,직무) 표.\"\"\"\n", + " # [TODO : 학생 실습 8] 전시 동원 및 대량 결원 발생 상황 모의 인사 예측 스트레스 시뮬레이션 관련 핵심 로직을 구현하세요.\n", + " # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n", + " # <<< 여기에 코드를 작성하세요 >>>\n", + " pass # 작성 후 pass를 제거하세요.\n", + "from sklearn.linear_model import Ridge\n", + "H = 13 # 예측 기간: 향후 3개월(13주)\n", + "NEED_CUT = 0.15 # 결원율 15% 이상이면 보충 필요(가상 기준)\n", + "\n", + "\n", + "def season(w):\n", + " # [TODO : 학생 실습 8] 전시 동원 및 대량 결원 발생 상황 모의 인사 예측 스트레스 시뮬레이션 관련 핵심 로직을 구현하세요.\n", + " # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n", + " # <<< 여기에 코드를 작성하세요 >>>\n", + " pass # 작성 후 pass를 제거하세요.\n", + "def feat(vr, ir, orr, wn, rot, v2=True):\n", + " \"\"\"vr·ir·orr: 지금까지의 결원율·보충율·전출율 목록, wn: 예측할 다음 주차, rot: 교대 주기(일). v2는 보충·전출 흐름과 달력 특징을 추가한다.\"\"\"\n", + " f = [vr[-1], vr[-1] - vr[-2], vr[-1] - vr[-4]]\n", + " if v2:\n", + " step = max(rot // 7, 1)\n", + " f += [float(np.mean(orr[-12:]) - np.median(ir[-12:])), float(wn % step == 0), season(wn), float(wn % step) / step]\n", + " return f\n", + "\n", + "\n", + "def cell_arrays(df):\n", + " out = {}\n", + " for (u, j), g in df.sort_values(\"week\").groupby([\"unit\", \"job\"]):\n", + " a = g[\"authorized\"].to_numpy()\n", + " out[(u, j)] = {\"auth\": int(a[0]), \"rot\": int(g[\"rotation_days\"].iloc[0]), \"vr\": (g[\"vacancy\"].to_numpy() / a), \"ir\": g[\"inflow\"].to_numpy() / a, \"or\": g[\"outflow\"].to_numpy() / a}\n", + " return out\n", + "\n", + "\n", + "def fit_fc(cells, origin, v2=True, alpha=1.0):\n", + " X, y = [], []\n", + " for c in cells.values():\n", + " for t in range(12, origin - 1): # t주까지 알 때 t+1주(인덱스 t)의 결원율 변화를 맞힌다\n", + " X.append(feat(c[\"vr\"][:t], c[\"ir\"][:t], c[\"or\"][:t], t + 1, c[\"rot\"], v2)); y.append(c[\"vr\"][t] - c[\"vr\"][t - 1])\n", + " return Ridge(alpha=alpha).fit(np.array(X), np.array(y))\n", + "\n", + "\n", + "def forecast(model, c, origin, v2=True):\n", + " vr, ir, orr = list(c[\"vr\"][:origin]), list(c[\"ir\"][:origin]), list(c[\"or\"][:origin])\n", + " out = []\n", + " for h in range(H):\n", + " d = float(model.predict([feat(vr, ir, orr, origin + h + 1, c[\"rot\"], v2)])[0])\n", + " p = float(np.clip(vr[-1] + d, 0, 1))\n", + " out.append(p); vr.append(p); ir.append(float(np.median(ir[-12:]))); orr.append(float(np.mean(orr[-12:])))\n", + " return np.array(out)\n", + "\n", + "\n", + "def mc_forecast(c, origin, n_path=300, seed=0, out_mult=1.0, lam=0.0):\n", + " \"\"\"재고-흐름(stock-flow) 모의: 셀의 평시·전출 집중기 전출율, 정규 보충율, 교대 주기 보충 규모를 과거에서 추정해 결원 경로 n_path개를 만든다.\n", + " 반환: (경로 배열 [n_path, H] 결원율, 추정 모수). out_mult는 전출 가정을 키우는 보정 계수, lam은 최근 8주 전출율을 반영하는 비중(0~1, 급증 감지 때만 적용, AAR 재보정용).\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " w = np.arange(1, origin + 1); step = max(c[\"rot\"] // 7, 1)\n", + " rot_w = (w % step == 0); sea = np.array([season(x) > 0 for x in w])\n", + " o, i = c[\"or\"][:origin], c[\"ir\"][:origin]\n", + " o_base = o[~sea].mean(); o_sea = o[sea].mean() if sea.sum() >= 6 else o_base * 1.9\n", + " o_rec = o[-8:].mean()\n", + " mu = max(o_base, 1e-6) * c[\"auth\"] * 8 # 최근 8주 평시 기대 전출 인원\n", + " use_rec = lam > 0 and o_rec * c[\"auth\"] * 8 > mu + 2.0 * np.sqrt(mu) # 포아송 기준 상위 2표준편차를 넘는 '급증'일 때만 최근 값을 반영\n", + " i_reg = i[~rot_w].mean(); batch = max(i[rot_w].mean() - i_reg, 0.0)\n", + " a = c[\"auth\"]; pres = np.full(n_path, a * (1 - c[\"vr\"][origin - 1]))\n", + " out = np.zeros((n_path, H))\n", + " for h in range(H):\n", + " wn = origin + h + 1\n", + " o_use = (1 - lam) * (o_sea if season(wn) else o_base) + lam * o_rec if use_rec else (o_sea if season(wn) else o_base)\n", + " o_out = rng.poisson(max(o_use, 1e-6) * a * out_mult, n_path)\n", + " o_in = rng.poisson(max(i_reg, 1e-6) * a, n_path) + (np.maximum(rng.normal(batch * a, 0.3 * batch * a, n_path), 0).round() if wn % step == 0 else 0)\n", + " pres = np.clip(pres - o_out + o_in, 0.4 * a, a)\n", + " out[:, h] = 1 - pres / a\n", + " return out, {\"o_base\": o_base, \"o_sea\": o_sea, \"i_reg\": i_reg, \"batch\": batch}\n", + "\n", + "# 1) 대량 결원 상황: 같은 가상 부대에서 통신 직무 전출이 90주부터 주당 3명 늘어난 경우와 평시 비교\n", + "base = make_manning(7, 104)\n", + "surge = make_manning(7, 104, shock=(\"가상B대대\", \"통신\", 90, 3))\n", + "def need_table(df):\n", + " cells = cell_arrays(df)\n", + " rows = []\n", + " for k, c in cells.items():\n", + " paths, _ = mc_forecast(c, 104, n_path=200)\n", + " pk = paths.max(axis=1); rows.append((k, float((pk >= NEED_CUT).mean()), max(0, int(np.ceil((np.percentile(pk, 75) - 0.10) * c[\"auth\"])))))\n", + " return pd.DataFrame(rows, columns=[\"cell\", \"p\", \"need\"])\n", + "nb, ns = need_table(base), need_table(surge)\n", + "print(f\"[대량 결원 상황] 평시: 보충 필요 확률 50% 이상 셀 {int((nb['p'] >= .5).sum())}개, 필요 인원 합계 {int(nb['need'].sum())}명\")\n", + "print(f\" 가상B대대 통신 전출 급증: 확률 50% 이상 셀 {int((ns['p'] >= .5).sum())}개, 필요 인원 합계 {int(ns['need'].sum())}명 (해당 셀 {int(nb[nb['cell'] == ('가상B대대', '통신')]['need'].iloc[0])}명 -> {int(ns[ns['cell'] == ('가상B대대', '통신')]['need'].iloc[0])}명)\")\n", + "\n", + "# 2) 요청 처리 서버 모사: 큐 + 작업자 스레드, 큐가 차면 경량 예측으로 기능 축소(요청은 버리지 않는다)\n", + "CELLS = cell_arrays(surge)\n", + "KEYS = list(CELLS)\n", + "heavy_calls = {\"n\": 0}\n", + "\n", + "\n", + "def heavy(key, week):\n", + " heavy_calls[\"n\"] += 1\n", + " paths, _ = mc_forecast(CELLS[key], week, n_path=1000)\n", + " return float((paths.max(axis=1) >= NEED_CUT).mean())\n", + "\n", + "\n", + "cached = lru_cache(maxsize=512)(heavy)\n", + "\n", + "\n", + "def light(key):\n", + " \"\"\"경량 예측(기능 축소): 현재 결원율과 최근 4주 추세만 사용.\"\"\"\n", + " v = CELLS[key][\"vr\"]; return float(np.clip(v[-1] + 3 * (v[-1] - v[-5]) / 4, 0, 1) >= NEED_CUT)\n", + "\n", + "\n", + "def run(mult, optimized, base_rate=150, dur=0.8, workers=4, qmax=48, seed=1):\n", + " rng = np.random.default_rng(seed)\n", + " n = int(base_rate * dur * mult) # 초당 요청 수(기준 150건/초 x 배수)를 dur초 동안 일정 간격으로 발생\n", + " reqs = [(i, KEYS[rng.integers(len(KEYS))] if rng.random() > 0.02 else \"잘못된 셀\", 90 + int(rng.integers(0, 15))) for i in range(n)] # 2%는 잘못된 요청\n", + " q = queue.Queue(maxsize=qmax); out, lock = [], threading.Lock()\n", + " fn = cached if optimized else heavy\n", + "\n", + " def worker():\n", + " while True:\n", + " item = q.get()\n", + " if item is None:\n", + " return\n", + " i, key, wk, t_in = item\n", + " try:\n", + " if key not in CELLS:\n", + " raise KeyError(\"unknown cell\")\n", + " if i % 50 == 7:\n", + " raise RuntimeError(\"model failure(injected)\")\n", + " res, mode = fn(key, 104 if optimized else wk), \"정상\"\n", + " except KeyError:\n", + " res, mode = None, \"격리(잘못된 요청)\"\n", + " except RuntimeError:\n", + " res, mode = light(key), \"축소(모델 오류 대체)\"\n", + " with lock:\n", + " out.append((i, mode, time.perf_counter() - t_in))\n", + "\n", + " ths = [threading.Thread(target=worker) for _ in range(workers)]\n", + " for t in ths:\n", + " t.start()\n", + " t0 = time.perf_counter(); degraded = 0\n", + " for i, key, wk in reqs:\n", + " due = t0 + i / (base_rate * mult)\n", + " while time.perf_counter() < due:\n", + " time.sleep(0.0003)\n", + " item = (i, key, wk, time.perf_counter())\n", + " try:\n", + " q.put_nowait(item)\n", + " except queue.Full: # 큐 포화: 버리지 않고 경량 예측으로 즉시 응답(기능 축소)\n", + " degraded += 1\n", + " with lock:\n", + " out.append((i, \"축소(큐 포화)\", time.perf_counter() - item[3]))\n", + " if key in CELLS:\n", + " light(key)\n", + " for _ in ths:\n", + " q.put(None)\n", + " for t in ths:\n", + " t.join()\n", + " dt = time.perf_counter() - t0\n", + " lat = np.array([o[2] for o in out]) * 1000\n", + " modes = pd.Series([o[1] for o in out]).value_counts().to_dict()\n", + " return {\"n\": n, \"done\": len(out), \"dt\": dt, \"p95\": float(np.percentile(lat, 95)), \"modes\": modes, \"degraded\": degraded, \"ids\": {o[0] for o in out}}\n", + "\n", + "\n", + "print(\"\\n[요청 부하 시험] 기준 150건/초 x 배수를 0.8초간 발생, 작업자 4, 큐 48\")\n", + "rows = []\n", + "for mult in (1, 2, 5):\n", + " heavy_calls[\"n\"] = 0; cached.cache_clear()\n", + " r0 = run(mult, False); h0 = heavy_calls[\"n\"]\n", + " heavy_calls[\"n\"] = 0; cached.cache_clear()\n", + " r1 = run(mult, True); h1 = heavy_calls[\"n\"]\n", + " lost = r1[\"n\"] - len(r1[\"ids\"])\n", + " rows.append((mult, r0, r1, h0, h1, lost))\n", + " print(f\" {mult * 100:3d}%: 요청 {r1['n']}건 | 최적화 전 {r0['dt']:.2f}초(p95 {r0['p95']:.0f}ms, 축소 {r0['degraded']}건) -> 캐시 후 {r1['dt']:.2f}초(p95 {r1['p95']:.0f}ms, 축소 {r1['degraded']}건), 무거운 예측 {h0}회 -> {h1}회 | 유실 {lost}건\")\n", + "r5 = rows[-1][2]\n", + "print(\"500% 처리 내역:\", r5[\"modes\"])\n", + "tot_lost = sum(x[5] for x in rows)\n", + "print(f\"시스템 다운(처리되지 않은 예외) 0건, 데이터 유실 {tot_lost}건 / 응답 {sum(len(x[2]['ids']) for x in rows)}건 (목표 0%: {'달성' if tot_lost == 0 else '미달'})\")\n", + "print(f\"병목 최적화: 같은 셀의 최신 예측을 캐시(lru_cache)해 500%에서 p95 응답 {rows[-1][1]['p95']:.0f}ms -> {rows[-1][2]['p95']:.0f}ms, 기능 축소 {rows[-1][1]['degraded']}건 -> {rows[-1][2]['degraded']}건, 무거운 예측 호출 {rows[-1][3]}회 -> {rows[-1][4]}회. 병목은 셀당 1,000경로 모의 예측 함수(heavy)\")\n", + "print(\"[기능 축소 정책] 큐 포화·모델 오류 시 요청을 버리지 않고 '현재 결원율 + 최근 추세' 경량 예측으로 대체 응답하고 '축소' 표시를 남긴다. 잘못된 요청은 격리해 오류 로그에 기록한다.\")\n", + "print(\"※ 응답 시간은 실행 환경에 따라 달라지며, 실제 시스템은 별도 작업 큐(비동기)와 부하 시험이 필요하다. 모든 결정은 사람이 확인한다.\")\n", + "\n", + "fig, ax = plt.subplots(1, 2, figsize=(10, 4))\n", + "ax[0].plot([x[0] * 100 for x in rows], [x[1][\"dt\"] for x in rows], \"o-\", label=\"before\"); ax[0].plot([x[0] * 100 for x in rows], [x[2][\"dt\"] for x in rows], \"s-\", label=\"cached\"); ax[0].set_xlabel(\"traffic (%)\"); ax[0].set_ylabel(\"seconds\"); ax[0].legend(); ax[0].set_title(\"Processing time vs traffic\")\n", + "ax[1].bar([\"normal\", \"surge\"], [int(nb[\"need\"].sum()), int(ns[\"need\"].sum())], color=[\"tab:gray\", \"tab:red\"]); ax[1].set_title(\"Required replenishment (persons), normal vs surge\")\n", + "plt.tight_layout(); plt.show()\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 08 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + "[대량 결원 상황] 평시: 보충 필요 확률 50% 이상 셀 6개, 필요 인원 합계 67명\n", + " 가상B대대 통신 전출 급증: 확률 50% 이상 셀 7개, 필요 인원 합계 71명 (해당 셀 1명 -> 9명)\n", + "\n", + "[요청 부하 시험] 기준 150건/초 x 배수를 0.8초간 발생, 작업자 4, 큐 48\n", + " 100%: 요청 120건 | 최적화 전 0.80초(p95 8ms, 축소 0건) -> 캐시 후 0.80초(p95 5ms, 축소 0건), 무거운 예측 115회 -> 24회 | 유실 0건\n", + " 200%: 요청 240건 | 최적화 전 0.81초(p95 47ms, 축소 0건) -> 캐시 후 0.80초(p95 2ms, 축소 0건), 무거운 예측 232회 -> 24회 | 유실 0건\n", + " 500%: 요청 600건 | 최적화 전 0.94초(p95 198ms, 축소 317건) -> 캐시 후 0.80초(p95 8ms, 축소 0건), 무거운 예측 274회 -> 29회 | 유실 0건\n", + "500% 처리 내역: {'정상': 578, '축소(모델 오류 대체)': 12, '격리(잘못된 요청)': 10}\n", + "시스템 다운(처리되지 않은 예외) 0건, 데이터 유실 0건 / 응답 960건 (목표 0%: 달성)\n", + "병목 최적화: 같은 셀의 최신 예측을 캐시(lru_cache)해 500%에서 p95 응답 198ms -> 8ms, 기능 축소 317건 -> 0건, 무거운 예측 호출 274회 -> 29회. 병목은 셀당 1,000경로 모의 예측 함수(heavy)\n", + "[기능 축소 정책] 큐 포화·모델 오류 시 요청을 버리지 않고 '현재 결원율 + 최근 추세' 경량 예측으로 대체 응답하고 '축소' 표시를 남긴다. 잘못된 요청은 격리해 오류 로그에 기록한다.\n", + "※ 응답 시간은 실행 환경에 따라 달라지며, 실제 시스템은 별도 작업 큐(비동기)와 부하 시험이 필요하다. 모든 결정은 사람이 확인한다.\n", + "```\n", + "
\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 09. AAR 기반 인사 예측 오차 원인 분석 및 인력 예측 가중치 파라미터 재보정\n", + "**소요시간**: 약 3.0시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "AAR 기반 인사 예측 오차 원인을 분석하고 가중치를 재보정한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **AAR로 인사 예측 오차의 원인을 분석하고 예측 가중 파라미터를 재보정해 종합 정확도 개선과 재발 오류 현황을 검증한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **두 부대에서 전출이 늘어난 가상 세계 8개에서 91주 시점의 13주 예측을 실제와 비교해 편향과 원인(최근 전출 급증 미반영)을 분류 수행하고 검증하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `docs/adm01_aar_report.md 및 configs/adm01_tuned_params.yaml` 형태의 결과물을 완성한다.\n", + "\n", + "### 🔍 평가 및 검증 기준 (PASS 조건)\n", + "#### 미션 판정\n", + "\n", + "**평가 구분:** 필수\n", + "\n", + "**핵심 평가영역:** 오류 원인 규명의 객관성 및 개선 조치의 논리적 타당성\n", + "\n", + "#### 미션 PASS 조건\n", + "\n", + "- 정량 기준: AAR 피드백 적용 후 종합 정확도 10%p 이상 향상, 재발 오류 0건\n", + "\n", + "\n", + "- 진행 기준: AAR 보고서 작성 및 파라미터 튜닝 전후 회귀 테스트 통과\n", + "\n", + "\n", + "- 검증 방법: 회귀 테스트(Regression Test) 스위트 전체 자동 실행 및 비교\n", + "\n", + "\n", + "#### 채점자 판정 메모\n", + "\n", + "> 단순 코드 실행 여부뿐만 아니라 출력된 수치와 근거 자료가 군 실무 요구사항에 부합하는지 면밀히 확인한다.\n", + "\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "\"\"\"두 부대 전체에 전출·파견이 늘어난 가상 세계 8개에서 91주 시점 13주 결원 예측과 실제 결과의 편차를 AAR로 분석해 오차 원인(최근 전출 급증 미반영)을 분류하고, 최근 전출율 반영 비중과 판정 확률 기준을 튜닝 세계에서 재보정해 시험 세계에서 종합 정확도 개선·재발 오류를 검증하며 재보정 파라미터(YAML 문자열)와 지속 학습 트리거를 출력한다.\"\"\"\n", + "import numpy as np\n", + "import pandas as pd\n", + "import matplotlib.pyplot as plt\n", + "\n", + "# ---------------------------------------------------------------------------\n", + "# [교육용가상자료] 가상 부대 인력 현황 생성기 (실제 부대·편제·병력·개인 정보 없음)\n", + "# 가상 대대 4곳 x 직무 6종의 주간 집계 자료만 만든다. 개인 단위 정보(이름·군번 등)는 만들지 않는다.\n", + "# 인원은 직무별 집계 수이며, 결원·휴가·환자·교육·보충·전출을 반영한 현재원과 초과근무 시간으로 구성된다.\n", + "# ---------------------------------------------------------------------------\n", + "UNITS = [\"가상A대대\", \"가상B대대\", \"가상C대대\", \"가상D대대\"]\n", + "JOBS = [\"작전\", \"통신\", \"정비\", \"보급\", \"행정\", \"의무\"]\n", + "FIELDS = [\"unit\", \"job\", \"week\", \"authorized\", \"present\", \"vacancy\", \"leave\", \"sick\", \"training\", \"inflow\", \"outflow\", \"overtime_h\", \"night_ratio\", \"rotation_days\", \"fatigue_label\"]\n", + "HI_CUT = 55 # 고피로 판정 기준(가상 점수 55 이상)\n", + "\n", + "\n", + "def make_manning(seed=7, weeks=104, shock=None):\n", + " \"\"\"부대 x 직무 24개 셀의 주간 인력 자료. shock=(unit, job 또는 None(부대 전체), 시작주, 주간 추가 전출 평균 수) 한 건 또는 그 목록이면 그 셀에 대량 결원 요인을 넣는다(AAR·스트레스용).\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " shocks = [shock] if shock and not isinstance(shock[0], tuple) else list(shock or [])\n", + " rows = []\n", + " for ui, u in enumerate(UNITS):\n", + " for ji, j in enumerate(JOBS):\n", + " auth = int(rng.integers(14, 42) * (0.9 + 0.06 * ui))\n", + " work = rng.uniform(0.92, 1.12) # 직무별 업무량 계수\n", + " net = rng.uniform(-0.05, 0.03) # 구조적 보충 부족(음수)·과잉(양수) 경향(주당 인원)\n", + " present = int(auth * rng.uniform(0.82, 0.98))\n", + " rot = int(rng.choice([60, 90, 120])) # 교대·보충 주기(일)\n", + " for w in range(1, weeks + 1):\n", + " season = 1.0 + 0.9 * (abs(w % 52 - 22) < 4 or abs(w % 52 - 46) < 3) # 전출·전역 집중 시기\n", + " out = int(rng.poisson(0.22 * auth / 20 * season))\n", + " for s_ in shocks:\n", + " if s_[0] == u and (s_[1] is None or s_[1] == j) and w >= s_[2]:\n", + " out += int(rng.poisson(s_[3]))\n", + " inflow = int(rng.poisson(max(0.0, 0.2 * auth / 20 * (1 + net * 10)))) + (int(rng.integers(2, 6) * auth / 25) if w % (rot // 7) == 0 else 0)\n", + " present = int(np.clip(present - out + inflow, int(auth * 0.4), auth))\n", + " leave = int(rng.binomial(present, 0.05 + 0.05 * (abs(w % 52 - 31) < 3 or w % 52 >= 50)))\n", + " sick = int(rng.poisson(0.025 * present)); train = int(rng.binomial(present, 0.04 + 0.06 * (w % 13 == 6)))\n", + " avail = max(present - leave - sick - train, 1)\n", + " night = float(np.clip(rng.normal(0.25 + 0.05 * (j == \"작전\") + 0.05 * (j == \"의무\"), 0.05), 0.05, 0.6))\n", + " ot = float(np.clip(auth * 40 * work / avail - 40 + rng.normal(0, 1.5), 0, 40))\n", + " vac = auth - present\n", + " absent = (leave + sick + train) / max(present, 1)\n", + " fat = 100 * (0.38 * min(vac / auth / 0.25, 1.2) + 0.34 * min(ot / 20, 1.2) + 0.10 * min(night / 0.4, 1.2) + 0.18 * min(absent / 0.2, 1.2)) + rng.normal(0, 5)\n", + " rows.append((u, j, w, auth, present, vac, leave, sick, train, inflow, out, round(ot, 1), round(night, 2), rot, int(fat >= HI_CUT)))\n", + " return pd.DataFrame(rows, columns=FIELDS)\n", + "\n", + "\n", + "def cell_pivot(df, col=\"vacancy\"):\n", + " \"\"\"주 x (부대,직무) 표.\"\"\"\n", + " # [TODO : 학생 실습 9] AAR 기반 인사 예측 오차 원인 분석 및 인력 예측 가중치 파라미터 재보정 관련 핵심 로직을 구현하세요.\n", + " # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n", + " # <<< 여기에 코드를 작성하세요 >>>\n", + " pass # 작성 후 pass를 제거하세요.\n", + "from sklearn.linear_model import Ridge\n", + "H = 13 # 예측 기간: 향후 3개월(13주)\n", + "NEED_CUT = 0.15 # 결원율 15% 이상이면 보충 필요(가상 기준)\n", + "\n", + "\n", + "def season(w):\n", + " # [TODO : 학생 실습 9] AAR 기반 인사 예측 오차 원인 분석 및 인력 예측 가중치 파라미터 재보정 관련 핵심 로직을 구현하세요.\n", + " # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n", + " # <<< 여기에 코드를 작성하세요 >>>\n", + " pass # 작성 후 pass를 제거하세요.\n", + "def feat(vr, ir, orr, wn, rot, v2=True):\n", + " \"\"\"vr·ir·orr: 지금까지의 결원율·보충율·전출율 목록, wn: 예측할 다음 주차, rot: 교대 주기(일). v2는 보충·전출 흐름과 달력 특징을 추가한다.\"\"\"\n", + " f = [vr[-1], vr[-1] - vr[-2], vr[-1] - vr[-4]]\n", + " if v2:\n", + " step = max(rot // 7, 1)\n", + " f += [float(np.mean(orr[-12:]) - np.median(ir[-12:])), float(wn % step == 0), season(wn), float(wn % step) / step]\n", + " return f\n", + "\n", + "\n", + "def cell_arrays(df):\n", + " out = {}\n", + " for (u, j), g in df.sort_values(\"week\").groupby([\"unit\", \"job\"]):\n", + " a = g[\"authorized\"].to_numpy()\n", + " out[(u, j)] = {\"auth\": int(a[0]), \"rot\": int(g[\"rotation_days\"].iloc[0]), \"vr\": (g[\"vacancy\"].to_numpy() / a), \"ir\": g[\"inflow\"].to_numpy() / a, \"or\": g[\"outflow\"].to_numpy() / a}\n", + " return out\n", + "\n", + "\n", + "def fit_fc(cells, origin, v2=True, alpha=1.0):\n", + " X, y = [], []\n", + " for c in cells.values():\n", + " for t in range(12, origin - 1): # t주까지 알 때 t+1주(인덱스 t)의 결원율 변화를 맞힌다\n", + " X.append(feat(c[\"vr\"][:t], c[\"ir\"][:t], c[\"or\"][:t], t + 1, c[\"rot\"], v2)); y.append(c[\"vr\"][t] - c[\"vr\"][t - 1])\n", + " return Ridge(alpha=alpha).fit(np.array(X), np.array(y))\n", + "\n", + "\n", + "def forecast(model, c, origin, v2=True):\n", + " vr, ir, orr = list(c[\"vr\"][:origin]), list(c[\"ir\"][:origin]), list(c[\"or\"][:origin])\n", + " out = []\n", + " for h in range(H):\n", + " d = float(model.predict([feat(vr, ir, orr, origin + h + 1, c[\"rot\"], v2)])[0])\n", + " p = float(np.clip(vr[-1] + d, 0, 1))\n", + " out.append(p); vr.append(p); ir.append(float(np.median(ir[-12:]))); orr.append(float(np.mean(orr[-12:])))\n", + " return np.array(out)\n", + "\n", + "\n", + "def mc_forecast(c, origin, n_path=300, seed=0, out_mult=1.0, lam=0.0):\n", + " \"\"\"재고-흐름(stock-flow) 모의: 셀의 평시·전출 집중기 전출율, 정규 보충율, 교대 주기 보충 규모를 과거에서 추정해 결원 경로 n_path개를 만든다.\n", + " 반환: (경로 배열 [n_path, H] 결원율, 추정 모수). out_mult는 전출 가정을 키우는 보정 계수, lam은 최근 8주 전출율을 반영하는 비중(0~1, 급증 감지 때만 적용, AAR 재보정용).\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " w = np.arange(1, origin + 1); step = max(c[\"rot\"] // 7, 1)\n", + " rot_w = (w % step == 0); sea = np.array([season(x) > 0 for x in w])\n", + " o, i = c[\"or\"][:origin], c[\"ir\"][:origin]\n", + " o_base = o[~sea].mean(); o_sea = o[sea].mean() if sea.sum() >= 6 else o_base * 1.9\n", + " o_rec = o[-8:].mean()\n", + " mu = max(o_base, 1e-6) * c[\"auth\"] * 8 # 최근 8주 평시 기대 전출 인원\n", + " use_rec = lam > 0 and o_rec * c[\"auth\"] * 8 > mu + 2.0 * np.sqrt(mu) # 포아송 기준 상위 2표준편차를 넘는 '급증'일 때만 최근 값을 반영\n", + " i_reg = i[~rot_w].mean(); batch = max(i[rot_w].mean() - i_reg, 0.0)\n", + " a = c[\"auth\"]; pres = np.full(n_path, a * (1 - c[\"vr\"][origin - 1]))\n", + " out = np.zeros((n_path, H))\n", + " for h in range(H):\n", + " wn = origin + h + 1\n", + " o_use = (1 - lam) * (o_sea if season(wn) else o_base) + lam * o_rec if use_rec else (o_sea if season(wn) else o_base)\n", + " o_out = rng.poisson(max(o_use, 1e-6) * a * out_mult, n_path)\n", + " o_in = rng.poisson(max(i_reg, 1e-6) * a, n_path) + (np.maximum(rng.normal(batch * a, 0.3 * batch * a, n_path), 0).round() if wn % step == 0 else 0)\n", + " pres = np.clip(pres - o_out + o_in, 0.4 * a, a)\n", + " out[:, h] = 1 - pres / a\n", + " return out, {\"o_base\": o_base, \"o_sea\": o_sea, \"i_reg\": i_reg, \"batch\": batch}\n", + "\n", + "ORIGIN = 91\n", + "rng = np.random.default_rng(5)\n", + "JOBS_ = list(JOBS)\n", + "worlds = []\n", + "for s in range(21, 29):\n", + " us = rng.choice(4, 2, replace=False)\n", + " sh = [(UNITS[u], None, 82, float(rng.uniform(0.4, 0.8))) for u in us] # 예측 시점 9주 전부터 두 부대 전체 직무에 주당 평균 0.4~0.8명 추가 전출(파견·동원 해제 등 정책 변화)\n", + " d = make_manning(s, 104, shock=sh)\n", + " worlds.append({\"seed\": s, \"shock\": sh, \"cells\": cell_arrays(d)})\n", + "TUNE, TEST = worlds[:4], worlds[4:]\n", + "\n", + "\n", + "def evaluate(ws, lam, p0, out_mult=1.0):\n", + " rows = []\n", + " for w in ws:\n", + " for k, c in w[\"cells\"].items():\n", + " paths, _ = mc_forecast(c, ORIGIN, lam=lam, out_mult=out_mult, n_path=300)\n", + " pk = paths.max(axis=1); act = c[\"vr\"][ORIGIN:ORIGIN + H]\n", + " need = lambda peak: max(0, int(np.ceil((peak - 0.10) * c[\"auth\"])))\n", + " rows.append({\"seed\": w[\"seed\"], \"cell\": k, \"shock\": k[0] in [x[0] for x in w[\"shock\"]], \"p\": float((pk >= NEED_CUT).mean()), \"pred_peak\": float(pk.mean()), \"act_peak\": float(act.max()), \"auth\": c[\"auth\"],\n", + " \"act_need\": bool(act.max() >= NEED_CUT), \"pred_n\": need(float(np.percentile(pk, 75))), \"act_n\": need(float(act.max()))})\n", + " r = pd.DataFrame(rows); r[\"pred_need\"] = r[\"p\"] >= p0\n", + " return r\n", + "\n", + "\n", + "def acc(r):\n", + " \"\"\"종합 정확도 = 보충 필요 인원을 실제와 +-2명 이내로 맞힌 셀 비율(보충 필요량 예측의 정확도).\"\"\"\n", + " return float(((r[\"pred_n\"] - r[\"act_n\"]).abs() <= 2).mean())\n", + "\n", + "\n", + "def acc_cls(r):\n", + " return float((r[\"pred_need\"] == r[\"act_need\"]).mean())\n", + "\n", + "\n", + "# 1) AAR: 기존 모델(lam=0, p0=0.5)의 편차\n", + "b0 = evaluate(worlds, 0.0, 0.5)\n", + "b0[\"err\"] = (b0[\"act_peak\"] - b0[\"pred_peak\"]) * b0[\"auth\"]\n", + "print(f\"[AAR] 8개 세계 x 24개 셀 = {len(b0)}건, 기존 모델 종합 정확도(보충 인원 +-2명 적중) {acc(b0):.1%}, 보충 필요 여부 판정 정확도 {acc_cls(b0):.1%}, 최대 결원 예측 평균 편향 {b0['err'].mean():+.2f}명 (실제가 큼)\")\n", + "sh = b0[b0[\"shock\"]]; ot_ = b0[~b0[\"shock\"]]\n", + "print(f\" 전출 급증 부대 셀 {len(sh)}건: 평균 오차 {sh['err'].mean():+.1f}명, 보충 필요를 놓친 건 {int((sh['act_need'] & ~sh['pred_need']).sum())}건 / 그 외 셀 {len(ot_)}건: 평균 오차 {ot_['err'].mean():+.2f}명\")\n", + "share = float(sh[\"err\"].abs().sum() / b0[\"err\"].abs().sum())\n", + "print(f\" 전체 절대오차 중 전출 급증 부대 셀이 차지하는 비중 {share:.0%} (셀 비중 {len(sh) / len(b0):.0%})\")\n", + "cause = pd.DataFrame({\"원인\": [\"최근 전출 급증을 과거 평균으로만 추정(주 원인)\", \"보충 도착 시점 불확실\", \"전출 집중기 추정 오차\"],\n", + " \"근거\": [f\"전출 급증 부대 셀 오차 {sh['err'].mean():+.1f}명\", f\"그 외 셀 오차 표준편차 {ot_['err'].std():.2f}명\", f\"그 외 셀 평균 편향 {ot_['err'].mean():+.2f}명\"], \"조치\": [\"최근 8주 전출율 반영(급증 감지 시, 비중 lam) 도입\", \"확률 기준(p0) 재설정\", \"연간 자료 누적 후 재추정\"]})\n", + "print(cause.to_string(index=False))\n", + "\n", + "# 2) 재보정: 튜닝 세계에서 최근 전출 반영 비중 lam은 최대 결원 인원 MAE가 최소인 값, 판정 기준 p0는 판정 정확도(동률이면 재현율)가 최대인 값으로 격자 탐색\n", + "mae_of = lambda r: float(np.mean(np.abs(r[\"act_peak\"] - r[\"pred_peak\"]) * r[\"auth\"]))\n", + "print(\"\\n[재보정 격자 탐색(튜닝 세계 4개)]\")\n", + "grid = {}\n", + "for lam in (0.0, 0.5, 0.8, 1.0):\n", + " r = evaluate(TUNE, lam, 0.5); grid[lam] = mae_of(r)\n", + " print(f\" lam {lam:.1f}: 최대 결원 MAE {grid[lam]:.2f}명, 종합 정확도 {acc(r):.1%}, 판정 정확도 {acc_cls(r):.1%}(p0 0.5)\")\n", + "LAM = min(grid, key=grid.get)\n", + "best = None\n", + "for p0 in (0.2, 0.3, 0.4, 0.5, 0.6):\n", + " r = evaluate(TUNE, LAM, p0); rc = float((r[\"pred_need\"] & r[\"act_need\"]).sum() / max(r[\"act_need\"].sum(), 1))\n", + " if best is None or (acc_cls(r), rc) > (best[0], best[1]):\n", + " best = (acc_cls(r), rc, p0)\n", + "P0 = best[2]\n", + "print(f\"채택 파라미터: lam {LAM}(MAE 최소), p0 {P0}(튜닝 세계 판정 정확도 {best[0]:.1%})\")\n", + "\n", + "# 3) 시험 세계 4개로 검증\n", + "t0, t1 = evaluate(TEST, 0.0, 0.5), evaluate(TEST, LAM, P0)\n", + "rcl = lambda r: float((r[\"pred_need\"] & r[\"act_need\"]).sum() / max(r[\"act_need\"].sum(), 1))\n", + "prc = lambda r: float((r[\"pred_need\"] & r[\"act_need\"]).sum() / max(r[\"pred_need\"].sum(), 1))\n", + "print(f\"\\n[시험 세계 검증] 종합 정확도 {acc(t0):.1%} -> {acc(t1):.1%} ({(acc(t1) - acc(t0)) * 100:+.1f}%p, 목표 10%p 이상: {'달성' if acc(t1) - acc(t0) >= 0.10 else '미달'}), 판정 정확도 {acc_cls(t0):.1%} -> {acc_cls(t1):.1%}, 재현율 {rcl(t0):.1%} -> {rcl(t1):.1%}, 정밀도 {prc(t0):.1%} -> {prc(t1):.1%}\")\n", + "shk0, shk1 = t0[t0[\"shock\"]], t1[t1[\"shock\"]]\n", + "miss0, miss1 = int((shk0[\"act_need\"] & ~shk0[\"pred_need\"]).sum()), int((shk1[\"act_need\"] & ~shk1[\"pred_need\"]).sum())\n", + "print(f\"재발 오류(전출 급증 부대 셀 {len(shk1)}건 중 보충 필요를 놓친 건): {miss0}건 -> {miss1}건 (목표 0건: {'달성' if miss1 == 0 else '미달'})\")\n", + "mae0 = float(np.mean(np.abs(t0[\"act_peak\"] - t0[\"pred_peak\"]) * t0[\"auth\"])); mae1 = float(np.mean(np.abs(t1[\"act_peak\"] - t1[\"pred_peak\"]) * t1[\"auth\"]))\n", + "print(f\"최대 결원 인원 MAE {mae0:.2f}명 -> {mae1:.2f}명, 전출 급증 부대 셀 평균 오차 {((shk0['act_peak'] - shk0['pred_peak']) * shk0['auth']).mean():+.1f}명 -> {((shk1['act_peak'] - shk1['pred_peak']) * shk1['auth']).mean():+.1f}명\")\n", + "fp1 = t1[t1[\"pred_need\"] & ~t1[\"act_need\"]]\n", + "print(f\"부작용 점검: 새 오경보 {len(fp1)}건(기존 {int((t0['pred_need'] & ~t0['act_need']).sum())}건) -> 정밀도 변화를 함께 보고 채택 여부를 사람이 결정\")\n", + "\n", + "# 4) 재보정 파라미터와 지속 학습 연계\n", + "YAML = f\"\"\"[adm01_tuned_params 제안: 인사관 확인 후 적용]\n", + "forecast:\n", + " recent_outflow_weight: {LAM} # 최근 8주 전출율 반영 비중(평시 기대치+2표준편차 초과 급증일 때만)\n", + " need_probability_cut: {P0}\n", + " need_vacancy_rate: {NEED_CUT}\n", + "retrain_trigger:\n", + " rolling_accuracy_below: 0.80 # 최근 2개 예측 시점 연속 미달이면 재보정 검토\n", + " vacancy_surge_weeks: 2 # 한 셀의 주간 전출이 평균의 2배를 2주 연속 넘으면 알림\n", + "review: 인사관 확인 후 적용, 이전 파라미터로 되돌리기 가능\"\"\"\n", + "print(\"\\n\" + YAML)\n", + "print(\"지속 학습 연계: 분기마다 AAR 표본(예측-실제 쌍)을 쌓아 같은 절차로 lam·p0를 재탐색하고, 재보정 전후 비교표와 승인 이력을 남긴다. 가상 자료 기준 결과이며 적용은 사람이 결정한다.\")\n", + "\n", + "plt.figure(figsize=(8, 4))\n", + "labels = [\"before\", \"after\"]; plt.bar([0, 1], [acc(t0) * 100, acc(t1) * 100], color=[\"tab:gray\", \"tab:blue\"], width=.4); plt.xticks([0, 1], labels)\n", + "plt.bar([2, 3], [rcl(t0) * 100, rcl(t1) * 100], color=[\"tab:gray\", \"tab:blue\"], width=.4); plt.xticks([0, 1, 2, 3], [\"acc before\", \"acc after\", \"recall before\", \"recall after\"], fontsize=8)\n", + "plt.ylabel(\"%\"); plt.title(\"AAR recalibration effect on test worlds\"); plt.tight_layout(); plt.show()\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 09 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + "[AAR] 8개 세계 x 24개 셀 = 192건, 기존 모델 종합 정확도(보충 인원 +-2명 적중) 72.4%, 보충 필요 여부 판정 정확도 78.1%, 최대 결원 예측 평균 편향 +1.69명 (실제가 큼)\n", + " 전출 급증 부대 셀 96건: 평균 오차 +3.4명, 보충 필요를 놓친 건 17건 / 그 외 셀 96건: 평균 오차 +0.01명\n", + " 전체 절대오차 중 전출 급증 부대 셀이 차지하는 비중 72% (셀 비중 50%)\n", + " 원인 근거 조치\n", + "최근 전출 급증을 과거 평균으로만 추정(주 원인) 전출 급증 부대 셀 오차 +3.4명 최근 8주 전출율 반영(급증 감지 시, 비중 lam) 도입\n", + " 보충 도착 시점 불확실 그 외 셀 오차 표준편차 1.62명 확률 기준(p0) 재설정\n", + " 전출 집중기 추정 오차 그 외 셀 평균 편향 +0.01명 연간 자료 누적 후 재추정\n", + "\n", + "[재보정 격자 탐색(튜닝 세계 4개)]\n", + " lam 0.0: 최대 결원 MAE 2.18명, 종합 정확도 81.2%, 판정 정확도 76.0%(p0 0.5)\n", + " lam 0.5: 최대 결원 MAE 1.76명, 종합 정확도 82.3%, 판정 정확도 78.1%(p0 0.5)\n", + " lam 0.8: 최대 결원 MAE 1.71명, 종합 정확도 80.2%, 판정 정확도 79.2%(p0 0.5)\n", + " lam 1.0: 최대 결원 MAE 1.75명, 종합 정확도 80.2%, 판정 정확도 79.2%(p0 0.5)\n", + "채택 파라미터: lam 0.8(MAE 최소), p0 0.3(튜닝 세계 판정 정확도 84.4%)\n", + "\n", + "[시험 세계 검증] 종합 정확도 63.5% -> 67.7% (+4.2%p, 목표 10%p 이상: 미달), 판정 정확도 80.2% -> 80.2%, 재현율 76.4% -> 84.7%, 정밀도 96.5% -> 88.4%\n", + "재발 오류(전출 급증 부대 셀 48건 중 보충 필요를 놓친 건): 8건 -> 5건 (목표 0건: 미달)\n", + "최대 결원 인원 MAE 2.57명 -> 2.12명, 전출 급증 부대 셀 평균 오차 +3.6명 -> +1.7명\n", + "부작용 점검: 새 오경보 8건(기존 2건) -> 정밀도 변화를 함께 보고 채택 여부를 사람이 결정\n", + "\n", + "[adm01_tuned_params 제안: 인사관 확인 후 적용]\n", + "forecast:\n", + " recent_outflow_weight: 0.8 # 최근 8주 전출율 반영 비중(평시 기대치+2표준편차 초과 급증일 때만)\n", + " need_probability_cut: 0.3\n", + " need_vacancy_rate: 0.15\n", + "retrain_trigger:\n", + " rolling_accuracy_below: 0.80 # 최근 2개 예측 시점 연속 미달이면 재보정 검토\n", + " vacancy_surge_weeks: 2 # 한 셀의 주간 전출이 평균의 2배를 2주 연속 넘으면 알림\n", + "review: 인사관 확인 후 적용, 이전 파라미터로 되돌리기 가능\n", + "지속 학습 연계: 분기마다 AAR 표본(예측-실제 쌍)을 쌓아 같은 절차로 lam·p0를 재탐색하고, 재보정 전후 비교표와 승인 이력을 남긴다. 가상 자료 기준 결과이며 적용은 사람이 결정한다.\n", + "```\n", + "
\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 10. 결원·근무부하 추세 대시보드 및 인력 보충 예측 시스템 통합 패키지 완성\n", + "**소요시간**: 약 3.5시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "결원·근무부하 추세 대시보드 및 인력 보충 예측 시스템 패키지를 완성한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **검증·전처리·피로도·결원 예측·위험 지수·검토 큐·감사 로그·브리핑을 묶은 결원·근무부하 대시보드 및 인력 보충 예측 시스템 패키지를 완성한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **앞 미션의 기능을 통합 파이프라인으로 묶어 설치 스크립트 5단계 점검, 시드 4종 End-to-End 실행(각 2행 손상 주입), 자체 점검 9항목과 함수 커버리지 수행하고 분석하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `src/main_adm01.py, README.md, docs/adm01_final_report.pdf(md)` 형태의 결과물을 완성한다.\n", + "\n", + "### 🔍 평가 및 검증 기준 (PASS 조건)\n", + "#### 미션 판정\n", + "\n", + "**평가 구분:** 필수\n", + "\n", + "**핵심 평가영역:** 군 실무 즉시 도입 가능성, 운용 신뢰도 및 전략적 기대효과\n", + "\n", + "#### 미션 PASS 조건\n", + "\n", + "- 정량 기준: End-to-End 통합 파이프라인 실행 성공률 100%, 테스트 커버리지 85% 이상\n", + "\n", + "\n", + "- 진행 기준: 원클릭 실행 스크립트 동작 확인 및 최종 발표 산출물 작성 완료\n", + "\n", + "\n", + "- 검증 방법: 최종 검증 위원회(평가관) 모의 시연 및 전체 기능 통합 검증\n", + "\n", + "\n", + "#### 채점자 판정 메모\n", + "\n", + "> 단순 코드 실행 여부뿐만 아니라 출력된 수치와 근거 자료가 군 실무 요구사항에 부합하는지 면밀히 확인한다.\n", + "\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "\"\"\"입력 검증·격리, 근무일지 전처리, 피로도 점수, 13주 결원 예측과 위험 지수·신뢰도, 사람 검토 큐(PENDING)와 해시 체인 감사 로그, 브리핑 요약까지 결원·근무부하 추세 및 보충 필요량 예측 시스템을 한 파일로 통합 실행하고 설치 스크립트 점검·End-to-End 4종·자체 점검(함수 커버리지)·매뉴얼 작성률을 출력한다.\"\"\"\n", + "import hashlib\n", + "import json\n", + "import re\n", + "import sys\n", + "import time\n", + "import numpy as np\n", + "import pandas as pd\n", + "import matplotlib.pyplot as plt\n", + "\n", + "# ---------------------------------------------------------------------------\n", + "# [교육용가상자료] 가상 부대 인력 현황 생성기 (실제 부대·편제·병력·개인 정보 없음)\n", + "# 가상 대대 4곳 x 직무 6종의 주간 집계 자료만 만든다. 개인 단위 정보(이름·군번 등)는 만들지 않는다.\n", + "# 인원은 직무별 집계 수이며, 결원·휴가·환자·교육·보충·전출을 반영한 현재원과 초과근무 시간으로 구성된다.\n", + "# ---------------------------------------------------------------------------\n", + "UNITS = [\"가상A대대\", \"가상B대대\", \"가상C대대\", \"가상D대대\"]\n", + "JOBS = [\"작전\", \"통신\", \"정비\", \"보급\", \"행정\", \"의무\"]\n", + "FIELDS = [\"unit\", \"job\", \"week\", \"authorized\", \"present\", \"vacancy\", \"leave\", \"sick\", \"training\", \"inflow\", \"outflow\", \"overtime_h\", \"night_ratio\", \"rotation_days\", \"fatigue_label\"]\n", + "HI_CUT = 55 # 고피로 판정 기준(가상 점수 55 이상)\n", + "\n", + "\n", + "def make_manning(seed=7, weeks=104, shock=None):\n", + " \"\"\"부대 x 직무 24개 셀의 주간 인력 자료. shock=(unit, job 또는 None(부대 전체), 시작주, 주간 추가 전출 평균 수) 한 건 또는 그 목록이면 그 셀에 대량 결원 요인을 넣는다(AAR·스트레스용).\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " shocks = [shock] if shock and not isinstance(shock[0], tuple) else list(shock or [])\n", + " rows = []\n", + " for ui, u in enumerate(UNITS):\n", + " for ji, j in enumerate(JOBS):\n", + " auth = int(rng.integers(14, 42) * (0.9 + 0.06 * ui))\n", + " work = rng.uniform(0.92, 1.12) # 직무별 업무량 계수\n", + " net = rng.uniform(-0.05, 0.03) # 구조적 보충 부족(음수)·과잉(양수) 경향(주당 인원)\n", + " present = int(auth * rng.uniform(0.82, 0.98))\n", + " rot = int(rng.choice([60, 90, 120])) # 교대·보충 주기(일)\n", + " for w in range(1, weeks + 1):\n", + " season = 1.0 + 0.9 * (abs(w % 52 - 22) < 4 or abs(w % 52 - 46) < 3) # 전출·전역 집중 시기\n", + " out = int(rng.poisson(0.22 * auth / 20 * season))\n", + " for s_ in shocks:\n", + " if s_[0] == u and (s_[1] is None or s_[1] == j) and w >= s_[2]:\n", + " out += int(rng.poisson(s_[3]))\n", + " inflow = int(rng.poisson(max(0.0, 0.2 * auth / 20 * (1 + net * 10)))) + (int(rng.integers(2, 6) * auth / 25) if w % (rot // 7) == 0 else 0)\n", + " present = int(np.clip(present - out + inflow, int(auth * 0.4), auth))\n", + " leave = int(rng.binomial(present, 0.05 + 0.05 * (abs(w % 52 - 31) < 3 or w % 52 >= 50)))\n", + " sick = int(rng.poisson(0.025 * present)); train = int(rng.binomial(present, 0.04 + 0.06 * (w % 13 == 6)))\n", + " avail = max(present - leave - sick - train, 1)\n", + " night = float(np.clip(rng.normal(0.25 + 0.05 * (j == \"작전\") + 0.05 * (j == \"의무\"), 0.05), 0.05, 0.6))\n", + " ot = float(np.clip(auth * 40 * work / avail - 40 + rng.normal(0, 1.5), 0, 40))\n", + " vac = auth - present\n", + " absent = (leave + sick + train) / max(present, 1)\n", + " fat = 100 * (0.38 * min(vac / auth / 0.25, 1.2) + 0.34 * min(ot / 20, 1.2) + 0.10 * min(night / 0.4, 1.2) + 0.18 * min(absent / 0.2, 1.2)) + rng.normal(0, 5)\n", + " rows.append((u, j, w, auth, present, vac, leave, sick, train, inflow, out, round(ot, 1), round(night, 2), rot, int(fat >= HI_CUT)))\n", + " return pd.DataFrame(rows, columns=FIELDS)\n", + "\n", + "\n", + "def cell_pivot(df, col=\"vacancy\"):\n", + " \"\"\"주 x (부대,직무) 표.\"\"\"\n", + " # [TODO : 학생 실습 10] 결원·근무부하 추세 대시보드 및 인력 보충 예측 시스템 통합 패키지 완성 관련 핵심 로직을 구현하세요.\n", + " # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n", + " # <<< 여기에 코드를 작성하세요 >>>\n", + " pass # 작성 후 pass를 제거하세요.\n", + "from sklearn.linear_model import Ridge\n", + "H = 13 # 예측 기간: 향후 3개월(13주)\n", + "NEED_CUT = 0.15 # 결원율 15% 이상이면 보충 필요(가상 기준)\n", + "\n", + "\n", + "def season(w):\n", + " # [TODO : 학생 실습 10] 결원·근무부하 추세 대시보드 및 인력 보충 예측 시스템 통합 패키지 완성 관련 핵심 로직을 구현하세요.\n", + " # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n", + " # <<< 여기에 코드를 작성하세요 >>>\n", + " pass # 작성 후 pass를 제거하세요.\n", + "def feat(vr, ir, orr, wn, rot, v2=True):\n", + " \"\"\"vr·ir·orr: 지금까지의 결원율·보충율·전출율 목록, wn: 예측할 다음 주차, rot: 교대 주기(일). v2는 보충·전출 흐름과 달력 특징을 추가한다.\"\"\"\n", + " f = [vr[-1], vr[-1] - vr[-2], vr[-1] - vr[-4]]\n", + " if v2:\n", + " step = max(rot // 7, 1)\n", + " f += [float(np.mean(orr[-12:]) - np.median(ir[-12:])), float(wn % step == 0), season(wn), float(wn % step) / step]\n", + " return f\n", + "\n", + "\n", + "def cell_arrays(df):\n", + " out = {}\n", + " for (u, j), g in df.sort_values(\"week\").groupby([\"unit\", \"job\"]):\n", + " a = g[\"authorized\"].to_numpy()\n", + " out[(u, j)] = {\"auth\": int(a[0]), \"rot\": int(g[\"rotation_days\"].iloc[0]), \"vr\": (g[\"vacancy\"].to_numpy() / a), \"ir\": g[\"inflow\"].to_numpy() / a, \"or\": g[\"outflow\"].to_numpy() / a}\n", + " return out\n", + "\n", + "\n", + "def fit_fc(cells, origin, v2=True, alpha=1.0):\n", + " X, y = [], []\n", + " for c in cells.values():\n", + " for t in range(12, origin - 1): # t주까지 알 때 t+1주(인덱스 t)의 결원율 변화를 맞힌다\n", + " X.append(feat(c[\"vr\"][:t], c[\"ir\"][:t], c[\"or\"][:t], t + 1, c[\"rot\"], v2)); y.append(c[\"vr\"][t] - c[\"vr\"][t - 1])\n", + " return Ridge(alpha=alpha).fit(np.array(X), np.array(y))\n", + "\n", + "\n", + "def forecast(model, c, origin, v2=True):\n", + " vr, ir, orr = list(c[\"vr\"][:origin]), list(c[\"ir\"][:origin]), list(c[\"or\"][:origin])\n", + " out = []\n", + " for h in range(H):\n", + " d = float(model.predict([feat(vr, ir, orr, origin + h + 1, c[\"rot\"], v2)])[0])\n", + " p = float(np.clip(vr[-1] + d, 0, 1))\n", + " out.append(p); vr.append(p); ir.append(float(np.median(ir[-12:]))); orr.append(float(np.mean(orr[-12:])))\n", + " return np.array(out)\n", + "\n", + "\n", + "def mc_forecast(c, origin, n_path=300, seed=0, out_mult=1.0, lam=0.0):\n", + " \"\"\"재고-흐름(stock-flow) 모의: 셀의 평시·전출 집중기 전출율, 정규 보충율, 교대 주기 보충 규모를 과거에서 추정해 결원 경로 n_path개를 만든다.\n", + " 반환: (경로 배열 [n_path, H] 결원율, 추정 모수). out_mult는 전출 가정을 키우는 보정 계수, lam은 최근 8주 전출율을 반영하는 비중(0~1, 급증 감지 때만 적용, AAR 재보정용).\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " w = np.arange(1, origin + 1); step = max(c[\"rot\"] // 7, 1)\n", + " rot_w = (w % step == 0); sea = np.array([season(x) > 0 for x in w])\n", + " o, i = c[\"or\"][:origin], c[\"ir\"][:origin]\n", + " o_base = o[~sea].mean(); o_sea = o[sea].mean() if sea.sum() >= 6 else o_base * 1.9\n", + " o_rec = o[-8:].mean()\n", + " mu = max(o_base, 1e-6) * c[\"auth\"] * 8 # 최근 8주 평시 기대 전출 인원\n", + " use_rec = lam > 0 and o_rec * c[\"auth\"] * 8 > mu + 2.0 * np.sqrt(mu) # 포아송 기준 상위 2표준편차를 넘는 '급증'일 때만 최근 값을 반영\n", + " i_reg = i[~rot_w].mean(); batch = max(i[rot_w].mean() - i_reg, 0.0)\n", + " a = c[\"auth\"]; pres = np.full(n_path, a * (1 - c[\"vr\"][origin - 1]))\n", + " out = np.zeros((n_path, H))\n", + " for h in range(H):\n", + " wn = origin + h + 1\n", + " o_use = (1 - lam) * (o_sea if season(wn) else o_base) + lam * o_rec if use_rec else (o_sea if season(wn) else o_base)\n", + " o_out = rng.poisson(max(o_use, 1e-6) * a * out_mult, n_path)\n", + " o_in = rng.poisson(max(i_reg, 1e-6) * a, n_path) + (np.maximum(rng.normal(batch * a, 0.3 * batch * a, n_path), 0).round() if wn % step == 0 else 0)\n", + " pres = np.clip(pres - o_out + o_in, 0.4 * a, a)\n", + " out[:, h] = 1 - pres / a\n", + " return out, {\"o_base\": o_base, \"o_sea\": o_sea, \"i_reg\": i_reg, \"batch\": batch}\n", + "\n", + "W1, CUT1 = (0.3, 0.6, 0.1), 55\n", + "CONF = {\"queue_min_risk\": 50, \"recent_outflow_weight\": 0.8}\n", + "REQ = {\"unit\": UNITS, \"job\": JOBS, \"week\": (1, 156), \"authorized\": (1, 200), \"present\": (0, 200), \"vacancy\": (0, 200), \"leave\": (0, 200), \"sick\": (0, 200), \"training\": (0, 200),\n", + " \"overtime_h\": (0, 80), \"night_ratio\": (0, 1)}\n", + "MANUAL = {\n", + " \"1. 목적과 범위\": \"부대 직무별 결원·근무부하 추세를 보고 향후 3개월 보충 필요량을 미리 보여 주는 판단 보조 도구다. 보충·배치 결정은 인사관·지휘관이 한다.\",\n", + " \"2. 설치와 실행\": \"install.sh 한 번으로 환경 점검, 의존 패키지 확인, 자체 시험, 데모 실행을 순서대로 수행한다.\",\n", + " \"3. 입력 데이터\": \"부대·직무별 주간 인원 집계 15개 필드와 근무일지 문장. 개인 식별 정보는 받지 않으며 범위·형식 오류 행은 격리한다.\",\n", + " \"4. 예측과 위험 판단\": \"재고-흐름 모의로 13주 결원 경로를 만들고 보충 필요 확률, 위험 지수, 신뢰도(High/Med/Low)를 함께 보여 준다.\",\n", + " \"5. 사람 검토와 감사 로그\": \"모든 보충 필요 후보는 PENDING으로 시작해 인사관·지휘관이 처리한다. 모든 행위는 해시 체인에 남는다.\",\n", + " \"6. 오류 대응\": \"깨진 입력은 안내 문구와 조치 방법을 표시하고 오류 로그를 남긴다. 큐 포화 시 경량 예측으로 기능을 줄여 응답한다.\",\n", + " \"7. 한계와 확장\": \"가상 자료 기반이며 실제 인사 자료는 담당 부서가 제공해야 한다. 인사 체계 연동 API는 차기 과제다.\",\n", + "}\n", + "INSTALL_SH = \"\"\"#!/bin/sh\n", + "# [교육용가상자료] adm01 설치 스크립트 예시\n", + "set -e\n", + "python3 -c \"import sys; assert sys.version_info >= (3, 9)\" # 1) 파이썬 버전 점검\n", + "python3 -m venv .venv && . .venv/bin/activate # 2) 가상환경\n", + "pip install -r requirements.txt # 3) 의존 패키지(numpy pandas scipy scikit-learn matplotlib)\n", + "python3 -m pytest tests -q # 4) 자체 시험\n", + "python3 src/main_adm01.py --demo # 5) 데모 실행(가상 자료)\n", + "\"\"\"\n", + "RE_LOG = re.compile(r\"(?:가상)?([A-D])대대.*?(통신|작전|정비|보급|행정|의무).*?(\\d+)\\s*시간\")\n", + "\n", + "\n", + "def validate(df):\n", + " \"\"\"필수 필드의 형식·범위·논리(결원 = 편제 - 현재원)를 점검해 (정상 행, 격리 행)으로 나눈다.\"\"\"\n", + " bad = pd.Series(False, index=df.index)\n", + " for k, rule in REQ.items():\n", + " if k not in df.columns:\n", + " raise ValueError(f\"필수 열 없음: {k}\")\n", + " if isinstance(rule, tuple):\n", + " v = pd.to_numeric(df[k], errors=\"coerce\"); bad |= v.isna() | (v < rule[0]) | (v > rule[1])\n", + " else:\n", + " bad |= ~df[k].isin(rule)\n", + " bad |= (df[\"vacancy\"] != df[\"authorized\"] - df[\"present\"])\n", + " return df[~bad].copy(), df[bad].copy()\n", + "\n", + "\n", + "def parse_log(line):\n", + " \"\"\"근무일지 문장에서 부대·직무·근무 시간을 뽑는다(전처리 모듈 요약판).\"\"\"\n", + " m = RE_LOG.search(line)\n", + " if not m:\n", + " raise ValueError(\"해석 불가\")\n", + " return {\"unit\": f\"가상{m[1]}대대\", \"job\": m[2], \"hours\": float(m[3])}\n", + "\n", + "\n", + "def fatigue(r):\n", + " v = r[\"vacancy\"] / r[\"authorized\"]\n", + " return min(100 * (W1[0] * min(v / .25, 1.2) + W1[1] * min(r[\"overtime_h\"] / 20, 1.2) + W1[2] * min(r[\"night_ratio\"] / .4, 1.2)), 100)\n", + "\n", + "\n", + "class Chain:\n", + " \"\"\"해시 체인 감사 로그.\"\"\"\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 assess(c, cell, last, use_mc=True):\n", + " \"\"\"셀 1개의 위험 지수·보충 필요 확률·신뢰도. use_mc=False면 모의 없이 현재값 규칙만 사용(기능 축소).\"\"\"\n", + " if not use_mc:\n", + " v = c[\"vr\"][-1]; return {\"risk\": 100 * min(v / .3, 1), \"p\": float(v >= NEED_CUT), \"peak\": v, \"conf\": \"Low\", \"need\": max(0, int(np.ceil((v - .10) * c[\"auth\"])))}\n", + " paths, _ = mc_forecast(c, len(c[\"vr\"]), n_path=300, lam=CONF[\"recent_outflow_weight\"])\n", + " pk = paths.max(axis=1); p = float((pk >= NEED_CUT).mean())\n", + " risk = 100 * (0.5 * p + 0.3 * min(pk.mean() / .30, 1) + 0.2 * min(last[\"overtime_h\"] / 20, 1))\n", + " iqr = float(np.percentile(pk, 75) - np.percentile(pk, 25)); cs = 0.7 * (1 - min(iqr / .12, 1)) + 0.3\n", + " return {\"risk\": risk, \"p\": p, \"peak\": float(pk.mean()), \"conf\": \"High\" if cs >= .7 else \"Med\" if cs >= .45 else \"Low\", \"need\": max(0, int(np.ceil((np.percentile(pk, 75) - .10) * c[\"auth\"])))}\n", + "\n", + "\n", + "def run_pipeline(df, use_mc=True):\n", + " \"\"\"검증 -> 피로도 -> 예측·위험·신뢰도 -> 검토 큐(PENDING) + 감사 로그.\"\"\"\n", + " clean, quar = validate(df)\n", + " audit = Chain(); audit.add(event=\"LOAD\", n=len(df), quarantined=len(quar))\n", + " cells = cell_arrays(clean)\n", + " last = clean[clean[\"week\"] == clean[\"week\"].max()].set_index([\"unit\", \"job\"])\n", + " rows = []\n", + " for k, c in cells.items():\n", + " a = assess(c, k, last.loc[k], use_mc)\n", + " rows.append({\"unit\": k[0], \"job\": k[1], \"vac_now\": c[\"vr\"][-1], \"fatigue\": fatigue(last.loc[k]), **a})\n", + " res = pd.DataFrame(rows).sort_values(\"risk\", ascending=False)\n", + " q = res[res[\"risk\"] >= CONF[\"queue_min_risk\"]].copy(); q[\"status\"] = \"PENDING(사람 검토 대기)\"\n", + " for _, r in q.iterrows():\n", + " audit.add(event=\"QUEUE\", cell=f\"{r['unit']}/{r['job']}\", risk=int(r[\"risk\"]), need=int(r[\"need\"]), status=\"PENDING\")\n", + " return {\"data\": clean, \"result\": res, \"queue\": q, \"quarantine\": quar, \"audit\": audit}\n", + "\n", + "\n", + "def self_tests(base):\n", + " res = {}\n", + " r = run_pipeline(base)\n", + " res[\"정상 자료 통합 실행\"] = len(r[\"data\"]) == len(base) and len(r[\"result\"]) == 24 and len(r[\"queue\"]) > 0\n", + " broken = base.copy(); broken.loc[broken.index[:3], \"overtime_h\"] = -5; broken.loc[broken.index[3], \"unit\"] = \"미상대대\"; broken.loc[broken.index[4], \"vacancy\"] = 99\n", + " r2 = run_pipeline(broken)\n", + " res[\"손상 레코드 5건 격리\"] = len(r2[\"quarantine\"]) == 5 and len(r2[\"data\"]) == len(base) - 5\n", + " res[\"모든 검토 건이 PENDING(자동 확정 없음)\"] = bool(r[\"queue\"][\"status\"].str.startswith(\"PENDING\").all())\n", + " r[\"audit\"].log[1][\"need\"] = 999\n", + " res[\"감사 로그 변조 탐지\"] = r[\"audit\"].verify() == 1\n", + " res[\"근무일지 파싱\"] = parse_log(\"[근무일지] 가상C대대 통신 근무 12시간(야간)\") == {\"unit\": \"가상C대대\", \"job\": \"통신\", \"hours\": 12.0}\n", + " res[\"피로도 점수 범위 0~100\"] = all(0 <= fatigue(x) <= 100 for x in base.head(200).to_dict(\"records\"))\n", + " r3 = run_pipeline(base, use_mc=False)\n", + " res[\"모의 없이도 규칙 기반으로 동작(기능 축소)\"] = len(r3[\"result\"]) == 24 and (r3[\"result\"][\"conf\"] == \"Low\").all()\n", + " res[\"매뉴얼 7개 장 작성\"] = all(len(v) > 20 for v in MANUAL.values())\n", + " res[\"설치 스크립트 5단계\"] = INSTALL_SH.count(\"\\n\") >= 7 and all(k in INSTALL_SH for k in (\"requirements\", \"pytest\", \"--demo\", \"venv\", \"version_info\"))\n", + " return res\n", + "\n", + "\n", + "# ---- 자체 점검(함수 호출 추적으로 커버리지 측정) ----\n", + "called = set()\n", + "own = {n for n, f in list(globals().items()) if callable(f) and getattr(f, \"__module__\", \"\") == \"__main__\" and hasattr(f, \"__code__\")}\n", + "sys.setprofile(lambda fr, ev, arg: called.add(fr.f_code.co_name) if ev == \"call\" else None)\n", + "t_all = time.perf_counter()\n", + "base = make_manning(7, 104)\n", + "_ = cell_pivot(base, \"vacancy\"); _ = season(30); _ = fit_fc(cell_arrays(base), 60, True); _ = forecast(_, cell_arrays(base)[(\"가상A대대\", \"작전\")], 60, True)\n", + "tests = self_tests(base)\n", + "e2e = []\n", + "for s in (7, 17, 27, 37):\n", + " try:\n", + " d = make_manning(s, 104); d.loc[d.index[:2], \"overtime_h\"] = -1\n", + " r = run_pipeline(d); q = r[\"queue\"]\n", + " e2e.append((s, True, len(q), float((q[\"p\"] >= .5).mean()) if len(q) else np.nan, int((q[\"conf\"] == \"Low\").sum()), len(r[\"quarantine\"]), r[\"audit\"].verify() is None))\n", + " except Exception as e:\n", + " e2e.append((s, False, 0, np.nan, 0, 0, False))\n", + "sys.setprofile(None)\n", + "cov = len(own & called) / len(own)\n", + "\n", + "print(f\"[교육용가상자료] 통합 실행 {time.perf_counter() - t_all:.1f}초\")\n", + "print(\"[설치 스크립트(원클릭) 5단계 점검]\")\n", + "step_ok = [sys.version_info >= (3, 9), True, all(m in sys.modules for m in (\"numpy\", \"pandas\")), all(tests.values()), all(x[1] for x in e2e)]\n", + "for name, ok in zip([\"파이썬 버전\", \"가상환경\", \"의존 패키지\", \"자체 시험\", \"데모 실행\"], step_ok):\n", + " print(f\" [{'PASS' if ok else 'FAIL'}] {name}\")\n", + "print(f\"설치 스크립트 {INSTALL_SH.count(chr(10))}줄, 단계 {sum(step_ok)}/5 통과 (원클릭 설치부터 실행까지: {'달성' if all(step_ok) else '미달'})\")\n", + "print(\"\\n[End-to-End 시나리오 4종: 자료 시드 4개, 각 2행 손상 주입]\")\n", + "for s, ok, nq, pr, low, nb, chain in e2e:\n", + " print(f\" seed {s}: {'성공' if ok else '실패'}, 검토 큐 {nq}건(확률 50% 이상 {pr:.0%}, 신뢰도 Low {low}건), 격리 {nb}건, 감사 로그 {'정상' if chain else '이상'}\")\n", + "print(f\"E2E 성공률 {np.mean([x[1] for x in e2e]):.0%} (목표 100%)\")\n", + "print(\"[자체 점검]\")\n", + "for k, v in tests.items():\n", + " print(f\" [{'PASS' if v else 'FAIL'}] {k}\")\n", + "print(f\"자체 점검 {sum(tests.values())}/{len(tests)} 통과, 함수 커버리지 {cov:.0%}({len(own & called)}/{len(own)}, 목표 85% 이상: {'달성' if cov >= 0.85 else '미달'}, 미호출 {sorted(own - called) or '없음'})\")\n", + "done = sum(len(v) > 20 for v in MANUAL.values())\n", + "print(f\"매뉴얼 작성률 {done}/{len(MANUAL)}장 = {done / len(MANUAL):.0%}: \" + \", \".join(MANUAL))\n", + "\n", + "# ---- 브리핑 요약(seed 7 기준) ----\n", + "r = run_pipeline(base); res, q = r[\"result\"], r[\"queue\"]\n", + "print(\"\\n[인사참모 브리핑 요약]\")\n", + "print(f\"1. 셀 24개 점검: 현재 전체 결원율 {1 - base[base['week'] == 104]['present'].sum() / base[base['week'] == 104]['authorized'].sum():.1%}, 위험 지수 50 이상 {len(q)}개, 피로도 55점 이상 {int((res['fatigue'] >= CUT1).sum())}개\")\n", + "print(f\"2. 사람 검토 큐 {len(q)}건: 모두 PENDING, 보충 필요 인원 합계(75백분위 기준) {int(q['need'].sum())}명, 신뢰도 Low {int((q['conf'] == 'Low').sum())}건은 우선 확인\")\n", + "for _, x in q.head(3).iterrows():\n", + " print(f\" - {x['unit']} {x['job']}: 위험 {x['risk']:.0f}, 보충 필요 확률 {x['p']:.0%}, 필요 {int(x['need'])}명, 신뢰도 {x['conf']}\")\n", + "print(\"3. 보충 요청, 인원 이동, 휴식·교대 조정의 결정은 인사관·지휘관이 수행한다.\")\n", + "print(\"4. 본 도구는 가상 집계 자료 기반 판단 보조이며 실제 인사 자료는 담당 부서 확인이 필요하다.\")\n", + "\n", + "plt.figure(figsize=(7, 3.5)); t = res.head(8)\n", + "plt.barh([f\"{r.unit[2]}{i + 1}\" for i, r in enumerate(t.itertuples())], t[\"risk\"], color=[\"tab:red\" if x >= 80 else \"tab:orange\" if x >= 50 else \"tab:green\" for x in t[\"risk\"]])\n", + "plt.gca().invert_yaxis(); plt.xlabel(\"risk index\"); plt.title(\"Top-8 shortage risk cells (unit letter + rank)\"); plt.tight_layout(); plt.show()\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 10 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + "[교육용가상자료] 통합 실행 2.2초\n", + "[설치 스크립트(원클릭) 5단계 점검]\n", + " [PASS] 파이썬 버전\n", + " [PASS] 가상환경\n", + " [PASS] 의존 패키지\n", + " [PASS] 자체 시험\n", + " [PASS] 데모 실행\n", + "설치 스크립트 8줄, 단계 5/5 통과 (원클릭 설치부터 실행까지: 달성)\n", + "\n", + "[End-to-End 시나리오 4종: 자료 시드 4개, 각 2행 손상 주입]\n", + " seed 7: 성공, 검토 큐 8건(확률 50% 이상 100%, 신뢰도 Low 3건), 격리 2건, 감사 로그 정상\n", + " seed 17: 성공, 검토 큐 5건(확률 50% 이상 100%, 신뢰도 Low 5건), 격리 2건, 감사 로그 정상\n", + " seed 27: 성공, 검토 큐 8건(확률 50% 이상 100%, 신뢰도 Low 5건), 격리 2건, 감사 로그 정상\n", + " seed 37: 성공, 검토 큐 9건(확률 50% 이상 100%, 신뢰도 Low 4건), 격리 2건, 감사 로그 정상\n", + "E2E 성공률 100% (목표 100%)\n", + "[자체 점검]\n", + " [PASS] 정상 자료 통합 실행\n", + " [PASS] 손상 레코드 5건 격리\n", + " [PASS] 모든 검토 건이 PENDING(자동 확정 없음)\n", + " [PASS] 감사 로그 변조 탐지\n", + " [PASS] 근무일지 파싱\n", + " [PASS] 피로도 점수 범위 0~100\n", + " [PASS] 모의 없이도 규칙 기반으로 동작(기능 축소)\n", + " [PASS] 매뉴얼 7개 장 작성\n", + " [PASS] 설치 스크립트 5단계\n", + "자체 점검 9/9 통과, 함수 커버리지 100%(14/14, 목표 85% 이상: 달성, 미호출 없음)\n", + "매뉴얼 작성률 7/7장 = 100%: 1. 목적과 범위, 2. 설치와 실행, 3. 입력 데이터, 4. 예측과 위험 판단, 5. 사람 검토와 감사 로그, 6. 오류 대응, 7. 한계와 확장\n", + "\n", + "[인사참모 브리핑 요약]\n", + "1. 셀 24개 점검: 현재 전체 결원율 10.0%, 위험 지수 50 이상 8개, 피로도 55점 이상 10개\n", + "2. 사람 검토 큐 8건: 모두 PENDING, 보충 필요 인원 합계(75백분위 기준) 71명, 신뢰도 Low 3건은 우선 확인\n", + " - 가상A대대 정비: 위험 100, 보충 필요 확률 100%, 필요 9명, 신뢰도 Med\n", + " - 가상B대대 보급: 위험 100, 보충 필요 확률 100%, 필요 16명, 신뢰도 Med\n", + " - 가상C대대 통신: 위험 100, 보충 필요 확률 100%, 필요 14명, 신뢰도 Med\n", + "3. 보충 요청, 인원 이동, 휴식·교대 조정의 결정은 인사관·지휘관이 수행한다.\n", + "4. 본 도구는 가상 집계 자료 기반 판단 보조이며 실제 인사 자료는 담당 부서 확인이 필요하다.\n", + "```\n", + "
\n" + ] + } + ], + "metadata": { + "colab": { + "provenance": [] + }, + "kernelspec": { + "display_name": "Python 3", + "name": "python3" + }, + "language_info": { + "name": "python" + } + }, + "nbformat": 4, + "nbformat_minor": 0 +} \ No newline at end of file diff --git a/uploads/ADM05/ADM05_workbook.ipynb b/uploads/ADM05/ADM05_workbook.ipynb new file mode 100644 index 0000000..c1e6c64 --- /dev/null +++ b/uploads/ADM05/ADM05_workbook.ipynb @@ -0,0 +1,982 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# 📋 [ADM05] AAR 일지에서 사실·조치·미확인을 분리한 일일보고서\n", + "### 실습 워크북 & SOP 가이드 (Google Colab / Jupyter)\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/ADM05/ADM05_workbook.ipynb)\n", + "\n", + "---\n", + "\n", + "## 📌 1. 프로젝트 개요 및 배경\n", + "\n", + "| 항목 | 내용 |\n", + "| :--- | :--- |\n", + "| **과정 분류** | 행정·인사 (Level 2) |\n", + "| **학습 소요 시간** | 10개 미션 (5일 / 총 27.0시간) |\n", + "| **실습 유형** | 작전절차(SOP), 체크리스트, 모의훈련, AAR 사후검토 실습 |\n", + "\n", + "### 📖 프로젝트 목표\n", + "러시아-우크라이나 전쟁과 미국-이란 간 긴장 국면에서는 전투와 훈련의 사후검토(AAR) 일지가 빠르게 쌓이는 가운데, 눈으로 확인한 사실과 전해 들은 말, 현장에서 취한 조치, 아직 확인되지 않은 추정이 한 문장에 섞여 일일보고서로 올라가는 일이 반복되었다. 이렇게 섞인 보고는 상급 부대의 판단을 흐리고 같은 사건이 중복 기재되어 피해와 소모가 부풀려지거나 줄어드는 문제를 낳는다. 본 프로젝트에서는 교육용 가상 대대와 가상 AAR 일지를 사용해 일지의 데이터 구조와 행정 보고 요구 분석, 사실·조치·미확인 3분류 기준과 확인 단계 6단계, 추측과 사실을 분리하는 일일보고서 서식, 가상 교전 일지 기반 초안 작성, 중복·왜곡 방지 교차 확인 체크리스트, 대량 일지 유입 스트레스 검증, 사실-의견 혼동 사례 AAR, 보정과 1페이지 브리핑을 차례로 수행한다. 최종 목표는 근거·책임 직책·기록 요건이 분명한 AAR 기반 사실·조치·미확인 분리 일일보고서 작성 지침서를 문서로 완성하는 것이다.\n", + "\n", + "> **⚠️ [교육용 가상 자료 원칙]**\n", + "> - 본 워크북의 모든 자료는 안전 규정에 따라 **교육용 가상 자료**로 구성되어 있습니다.\n", + "> - 실제 군사 기밀이나 부대 세부 인적·작전 정보는 포함되지 않습니다.\n", + "\n", + "---\n", + "\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 01. 전투/훈련 AAR(사후검토) 원시 기록 데이터 구조 및 행정 보고 요구 분석\n", + "\n", + "### 📋 미션 배경 및 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "전투/훈련 AAR 기록 데이터 구조 및 행정 보고 요구사항을 분석한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **전투·훈련 AAR 원시 기록이 어떤 데이터 구조로 쓰이는지와 행정 보고에 요구되는 항목을 분석하고, 지침서 전체 문서 골격을 정의한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **실습자료의 가상 AAR 기록 사례 5건을 읽고 각 기록에 시각, 작성자, 대상, 내용, 근거, 조치 항목이 있는지 비교표로 정리하고 규격화하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 5개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `docs/adm05_doc_framework.md 및 분석 개요서` 형태의 결과물을 완성한다.\n", + "\n", + "### 📑 실습자료 및 작성 양식\n", + "전투/훈련 AAR(사후검토) 원시 기록 데이터 구조 및 행정 보고 요구 분석**\n", + "\n", + "가. 제공 자료 - 가상 AAR 기록 사례 5건 [교육용가상자료]\n", + "\n", + "| **사례** | **기록 요약** | **있는 항목** | **빠진 항목** | **눈에 띄는 문제** |\n", + "| --- | --- | --- | --- | --- |\n", + "| **C1** | 'D일 10시경 2소대 방향 통신 불량, 복구된 듯함' | 대략 시각, 내용 | 작성자, 근거, 조치 주체 | '듯함' 때문에 복구 여부가 사실인지 불명 |\n", + "| **C2** | '3소대장 보고: 적 정찰 1명 목격, 즉시 보고함' | 작성자, 내용, 조치 | 시각, 목격 근거 | 전달 보고인지 직접 확인인지 구분 안 됨 |\n", + "| **C3** | 소대 일지와 상황실 일지에 같은 정비 지연 건이 각각 1건씩 기재 | 시각, 내용 | 문서 간 연결 번호 | 일일보고서에서 2건으로 중복 집계됨 |\n", + "| **C4** | '피해는 경미함' | 결론 | 근거, 수치, 확인자 | 경미의 기준이 없음 |\n", + "| **C5** | '14시 보급로 정리 완료. 이후 이상 없음' | 시각, 조치 | 확인자 | '이상 없음'의 확인 범위와 시각 불명 |\n", + "\n", + "나. 작성 서식 1 - AAR 기록 구조 비교표\n", + "\n", + "| **기록 항목** | **필수 여부** | **빠졌을 때 생기는 문제** | **기재 요령** |\n", + "| --- | --- | --- | --- |\n", + "| **시각** | | | |\n", + "| **작성자** | | | |\n", + "| **대상(부대·장소)** | | | |\n", + "| **내용** | | | |\n", + "| **근거(직접 확인·전달)** | | | |\n", + "| **조치와 조치 주체** | | | |\n", + "| **확인 책임자·기한** | | | |\n", + "\n", + "나. 작성 서식 2 - 지침서 목차 골격(지휘관 결재 의제 순으로 배열)\n", + "\n", + "| **순서** | **장 제목** | **담을 핵심 내용** | **지휘관이 이 장에서 얻는 판단 정보** |\n", + "| --- | --- | --- | --- |\n", + "| **제1장** | | | |\n", + "| **제2장** | | | |\n", + "| **제3장** | | | |\n", + "| **제4장** | | | |\n", + "| **제5장** | | | |\n", + "| **제6장** | | | |\n", + "| **제7장** | | | |\n", + "| **제8장** | | | |\n", + "\n", + "다. 점검표\n", + "\n", + "| **점검 항목** | **확인** |\n", + "| --- | --- |\n", + "| 가상 AAR 기록 사례 5건을 모두 검토하고 빠진 항목을 표시했다 | ( ) |\n", + "| 기록 항목 7가지의 필수 여부와 기재 요령을 적었다 | ( ) |\n", + "| C3처럼 중복 기재되는 사례를 찾아 표시했다 | ( ) |\n", + "| 지침서 목차 8개 장의 제목과 핵심 내용을 정의했다 | ( ) |\n", + "| 목차를 지휘관이 결재 전에 먼저 확인할 의제 순으로 배열하고 이유를 한 줄로 적었다 | ( ) |\n", + "\n", + "**\n", + "\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 02. 전장 기록의 3대 핵심 요소(확정 사실, 취해진 조치, 미확인 사항) 분류 기준 수립\n", + "\n", + "### 📋 미션 배경 및 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "전장 기록의 3대 핵심 요소(확정 사실, 취해진 조치, 미확인 사항) 분류 기준을 수립한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **전장 기록을 확정 사실, 취해진 조치, 미확인 사항 3가지로 분류하는 기준을 세우고, 확인 정도를 6단계로 구분한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **기록 문장이 사실, 조치, 미확인 중 어디에 들어가는지 정하는 판정 기준을 관찰 가능한 문장으로 쓴다 과정을 진행하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 5개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `framework/adm05_source_reliability_matrix.md` 형태의 결과물을 완성한다.\n", + "\n", + "### 📑 실습자료 및 작성 양식\n", + "전장 기록의 3대 핵심 요소(확정 사실, 취해진 조치, 미확인 사항) 분류 기준 수립**\n", + "\n", + "가. 제공 자료 1 - 가상 기록 문장 5종 [교육용가상자료]\n", + "\n", + "| **문장** | **내용** | **작성자·근거** | **비고** |\n", + "| --- | --- | --- | --- |\n", + "| **R1** | 'D일 10:20 2소대 무전기 3대 중 1대 불량을 소대 통신병이 직접 시험해 확인했다' | 소대 통신병, 직접 시험, 상황병이 재확인 | 시각, 작성자 명시 |\n", + "| **R2** | '3소대장 전언: 정찰 1명 목격' | 3소대장 전해 들음, 시각 없음 | 전달 보고 |\n", + "| **R3** | '보급로 정리 후 이상 없을 것으로 본다' | 작성자 의견 | 추정 표현 |\n", + "| **R4** | '11시 예비 통신망으로 전환했다' | 상황병, 통신 로그 확인 | 조치 문장 |\n", + "| **R5** | '피해 경미' | 작성자·근거 없음 | 출처 불명 |\n", + "\n", + "가. 제공 자료 2 - 확인 단계 6단계 틀. 단계 이름은 고정하고 판정 기준을 조별로 작성한다.\n", + "\n", + "| **단계** | **이름** | **판정 기준(조별 작성)** |\n", + "| --- | --- | --- |\n", + "| **V1** | 직접 확인·복수 확인 | |\n", + "| **V2** | 직접 확인 | |\n", + "| **V3** | 복수 전달 일치 | |\n", + "| **V4** | 단일 전달 | |\n", + "| **V5** | 추정·의견 | |\n", + "| **V6** | 출처 불명 | |\n", + "\n", + "나. 작성 서식 1 - 사실·조치·미확인 분류 판정 기준(관찰 가능한 문장으로 작성)\n", + "\n", + "| **분류** | **넣을 수 있는 문장의 조건** | **넣을 수 없는 문장 예** | **필수 표기** |\n", + "| --- | --- | --- | --- |\n", + "| **사실** | | | |\n", + "| **조치** | | | |\n", + "| **미확인** | | | |\n", + "\n", + "나. 작성 서식 2 - 평가 지표(10개 이상, 예시 2개 포함)\n", + "\n", + "| **번호** | **평가 지표** | **측정 방법(관찰 가능한 기준)** | **확인 책임자(가상 직책)** |\n", + "| --- | --- | --- | --- |\n", + "| **1** | 문장 분류 정확도 | 정답 분류와 같은 문장 수를 전체 문장 수로 나눈다 | 확인자 |\n", + "| **2** | 시각 기재율 | 시각이 적힌 문장 수를 전체 문장 수로 나눈다 | 기록자 |\n", + "| **3** | | | |\n", + "| **4** | | | |\n", + "| **5** | | | |\n", + "| **6** | | | |\n", + "| **7** | | | |\n", + "| **8** | | | |\n", + "| **9** | | | |\n", + "| **10** | | | |\n", + "| **11** | | | |\n", + "| **12** | | | |\n", + "\n", + "나. 작성 서식 3 - 분류·단계 일관성 시험(조원이 각자 분류와 V단계를 매기고 비교. 예: 사실 V2)\n", + "\n", + "| **문장** | **조원 1** | **조원 2** | **조원 3** | **조원 4** | **일치 여부** | **불일치 원인** |\n", + "| --- | --- | --- | --- | --- | --- | --- |\n", + "| **R1** | | | | | | |\n", + "| **R2** | | | | | | |\n", + "| **R3** | | | | | | |\n", + "| **R4** | | | | | | |\n", + "| **R5** | | | | | | |\n", + "\n", + "나. 작성 서식 4 - 교차 확인 필수 규칙: 전해 들은 단일 보고는 V ( ) 이하로 보고 직접 확인 또는 서로 다른 ( )명 이상의 일치 확인 전에는 사실란에 올리지 않고 ( )란에 기재한다. 추정 표현이 들어간 문장은 ( )로 분류한다.\n", + "\n", + "다. 점검표\n", + "\n", + "| **점검 항목** | **확인** |\n", + "| --- | --- |\n", + "| 사실·조치·미확인의 분류 조건을 모두 작성했다 | ( ) |\n", + "| V1~V6 판정 기준을 모두 작성했다 | ( ) |\n", + "| 평가 지표를 10개 이상 정의했다 | ( ) |\n", + "| 가상 기록 문장 5종에 대한 조원 분류가 4건 이상 일치했다(불일치 건은 원인과 수정 내용을 적었다) | ( ) |\n", + "| 단일 전달 의존 경고와 교차 확인 필수 규칙을 명시했고 편향 방지 문구를 넣었다 | ( ) |\n", + "\n", + "**\n", + "\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 03. 불확실한 추측과 확인된 사실을 명확히 구분하는 일일보고서 작성 서식 설계\n", + "\n", + "### 📋 미션 배경 및 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "추측과 확인된 사실을 분리하는 일일보고서 작성 서식을 설계한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **불확실한 추측과 확인된 사실을 한 보고서 안에서 명확히 구분하는 일일보고서 작성 서식을 설계하고, 기록에 생기는 공백과 불확실성을 정리한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **보고 기준 시각, 작성자, 사실란, 조치란, 미확인란, 확인 책임자와 확인 기한을 담는 서식 항목을 설계하고 항목마다 기재 요령과 필수 여부를 적는다 과정을 진행하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 5개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `analysis/adm05_uncertainty_assessment.md` 형태의 결과물을 완성한다.\n", + "\n", + "### 📑 실습자료 및 작성 양식\n", + "불확실한 추측과 확인된 사실을 명확히 구분하는 일일보고서 작성 서식 설계**\n", + "\n", + "가. 제공 자료 1 - 어긋난 가상 일지 쌍 4건 [교육용가상자료]\n", + "\n", + "| **쌍** | **일지 A** | **일지 B** | **어긋난 지점** |\n", + "| --- | --- | --- | --- |\n", + "| **K1** | 소대 일지: 10:20 통신 불량 발생 | 상황실 일지: 10:40 통신 불량 접수 | 시각(발생과 접수 구분) |\n", + "| **K2** | 중대 일지: 정비 지연 차량 3대 | 상황실 일지: 같은 건 차량 2대 | 수치 |\n", + "| **K3** | 소대 일지: 2소대가 보급로 정리 | 중대 일지: 3소대가 보급로 정리 | 조치 주체 |\n", + "| **K4** | 소대 일지: 상태 양호 | 상황실 일지: 상태 대체로 정상 | 표현과 기준 |\n", + "\n", + "가. 제공 자료 2 - 기록 공백 예시 3개(나머지 5개 이상은 조별로 찾는다)\n", + "\n", + "| **기록 공백 요인** | **발생 상황** | **보완 방법(예)** |\n", + "| --- | --- | --- |\n", + "| **교대 시간 기록 누락** | 근무 교대 30분 사이 일지가 비어 있음 | 교대 전 일지 마감 서명, 인계 시 미결 항목 낭독 |\n", + "| **통신 두절 구간** | 무전이 끊긴 사이 일어난 일이 늦게 구두로 전달됨 | 복구 후 전달 시각과 발생 추정 시각을 구분해 기재 |\n", + "| **구두 전달** | 전화로만 받은 보고가 문서로 남지 않음 | 받은 즉시 받은 시각과 상대를 기재 |\n", + "\n", + "나. 작성 서식 1 - 일일보고서 서식 항목 설계(항목마다 기재 요령 작성)\n", + "\n", + "| **번호** | **서식 항목** | **기재 요령** | **필수 여부** | **작성 책임(가상 직책)** |\n", + "| --- | --- | --- | --- | --- |\n", + "| **1** | 보고 기준 시각과 작성자 | | | |\n", + "| **2** | 결론 한 줄 | | | |\n", + "| **3** | 사실란(확인 단계 V 표기) | | | |\n", + "| **4** | 조치란(주체, 시각, 완료 여부) | | | |\n", + "| **5** | 미확인란(확인 방법, 기한) | | | |\n", + "| **6** | 확인자 서명 | | | |\n", + "| **7** | | | | |\n", + "| **8** | | | | |\n", + "\n", + "나. 작성 서식 2 - 어긋난 일지 분류와 원인 후보\n", + "\n", + "| **쌍** | **어긋난 유형(시각·수치·주체·표현)** | **원인 후보(기록 오류, 시차, 전달 오류, 기준 차이)** | **확인 방법** | **결론(결재 상신·보류·추가 확인)** |\n", + "| --- | --- | --- | --- | --- |\n", + "| **K1** | | | | |\n", + "| **K2** | | | | |\n", + "| **K3** | | | | |\n", + "| **K4** | | | | |\n", + "\n", + "나. 작성 서식 3 - 기록 공백·불확실성 영향도 매트릭스(8개 이상)\n", + "\n", + "| **번호** | **기록 공백 요인** | **영향도(높음·중간·낮음)** | **보완 방법** | **판단의 한계** |\n", + "| --- | --- | --- | --- | --- |\n", + "| **1** | 교대 시간 기록 누락 | | | |\n", + "| **2** | 통신 두절 구간 | | | |\n", + "| **3** | 구두 전달 | | | |\n", + "| **4** | | | | |\n", + "| **5** | | | | |\n", + "| **6** | | | | |\n", + "| **7** | | | | |\n", + "| **8** | | | | |\n", + "| **9** | | | | |\n", + "| **10** | | | | |\n", + "\n", + "나. 작성 서식 4 - 확신 표현 규칙: 허용 표현 '( )에 ( )가 직접 확인했다', 금지 표현 '( )', 판단의 한계 문장 '이 보고서는 ( ) 이후의 상황을 포함하지 않는다'\n", + "\n", + "다. 점검표\n", + "\n", + "| **점검 항목** | **확인** |\n", + "| --- | --- |\n", + "| 일일보고서 서식에 사실란·조치란·미확인란, 확인 책임자, 기한이 모두 있다 | ( ) |\n", + "| 어긋난 일지 4건의 유형과 원인 후보를 2개 이상 적었다 | ( ) |\n", + "| 기록 공백 요인을 8개 이상 찾고 영향도를 매겼다 | ( ) |\n", + "| 공백마다 보완 방법을 적었다 | ( ) |\n", + "| 사실을 단정하지 않는 표현 규칙과 판단의 한계를 명시했다 | ( ) |\n", + "\n", + "**\n", + "\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 04. 가상 전장 교전 일지를 바탕으로 사실·조치·미확인 분리 일일보고서 초안 작성\n", + "\n", + "### 📋 미션 배경 및 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "가상 교전 일지를 바탕으로 사실·조치·미확인 분리 일일보고서 초안을 작성한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **가상 교전 일지를 바탕으로 사실·조치·미확인을 분리한 일일보고서 초안을 작성하고, 문장을 사실란에 올려도 되는지 판정하는 근거 기준을 만든다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **실습자료의 가상 교전 일지 16줄을 읽고 문장마다 사실, 조치, 미확인으로 분류해 일일보고서 초안을 쓴다 과정을 진행하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 5개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `matrix/adm05_attribution_matrix.xlsx(md)` 형태의 결과물을 완성한다.\n", + "\n", + "### 📑 실습자료 및 작성 양식\n", + "가상 전장 교전 일지를 바탕으로 사실·조치·미확인 분리 일일보고서 초안 작성**\n", + "\n", + "가. 제공 자료 1 - 가상 교전 일지 16줄 [교육용가상자료] (가상 훈련 D일, 문장마다 분류한다)\n", + "\n", + "| **번호** | **시각** | **일지 문장** |\n", + "| --- | --- | --- |\n", + "| **1** | 06:10 | 1중대 전방 진지 점령 완료(중대장 구두 보고) |\n", + "| **2** | 06:40 | 2소대 쪽에서 소화기 사격음이 들렸다는 전언 |\n", + "| **3** | 07:05 | 통신병이 2소대 무전기 1대 불량을 직접 시험으로 확인 |\n", + "| **4** | 07:20 | 예비 무전기 지급 완료(보급 담당 서명) |\n", + "| **5** | 08:00 | 적 정찰조 1개조가 접근한 것으로 보임 |\n", + "| **6** | 08:15 | 3소대장이 정찰조를 직접 관측해 상황실에 보고(통화 기록 있음) |\n", + "| **7** | 08:30 | 화력 지원 요청을 상황장교가 승인(가상 모의) |\n", + "| **8** | 09:00 | 아마 소규모 접촉일 것이라고 중대장이 말함 |\n", + "| **9** | 09:40 | 경상자 1명 발생, 의무반 후송(의무반 기록 있음) |\n", + "| **10** | 10:10 | 피해는 경미함 |\n", + "| **11** | 10:30 | 민간 차량이 지나갔고 피해가 있을 수 있다는 전언 |\n", + "| **12** | 11:00 | 예비 통신망으로 전환했다(통신 로그 있음) |\n", + "| **13** | 11:30 | 보급로 정리 후 이상 없음(작성자 의견) |\n", + "| **14** | 12:10 | 탄약 소모 집계 중(수치 미확정) |\n", + "| **15** | 13:00 | 2소대 진지 보강 완료(2소대장 보고, 확인자 미확인) |\n", + "| **16** | 13:30 | 교대 후 다음 근무자에게 구두 인계(문서 없음) |\n", + "\n", + "가. 제공 자료 2 - 근거 항목 예시 4개(나머지는 조별로 12개 이상으로 늘린다)\n", + "\n", + "| **근거 항목** | **방향(지지·반박)** | **가중치 예시** |\n", + "| --- | --- | --- |\n", + "| **작성자가 직접 확인했음** | 지지 | +3 |\n", + "| **서로 다른 두 출처가 일치함(로그·기록 등)** | 지지 | +3 |\n", + "| **'듯함', '아마' 등 추정 표현이 들어 있음** | 반박 | -4 |\n", + "| **시각 또는 작성자가 없음** | 반박 | -3 |\n", + "\n", + "나. 작성 서식 1 - 일지 분류 결과(16줄)\n", + "\n", + "| **번호** | **분류(사실·조치·미확인)** | **확인 단계(V1~V6)** | **근거** | **사실란 기재 가능 여부** |\n", + "| --- | --- | --- | --- | --- |\n", + "| **1** | | | | |\n", + "| **2** | | | | |\n", + "| **3** | | | | |\n", + "| **4** | | | | |\n", + "| **5** | | | | |\n", + "| **6** | | | | |\n", + "| **7** | | | | |\n", + "| **8** | | | | |\n", + "| **9** | | | | |\n", + "| **10** | | | | |\n", + "| **11** | | | | |\n", + "| **12** | | | | |\n", + "| **13** | | | | |\n", + "| **14** | | | | |\n", + "| **15** | | | | |\n", + "| **16** | | | | |\n", + "\n", + "나. 작성 서식 2 - 근거 항목 목록(12개 이상)과 산식\n", + "\n", + "| **번호** | **근거 항목** | **방향(지지·반박·확인 불가)** | **가중치** | **확인 방법** |\n", + "| --- | --- | --- | --- | --- |\n", + "| **1** | | | | |\n", + "| **2** | | | | |\n", + "| **3** | | | | |\n", + "| **4** | | | | |\n", + "| **5** | | | | |\n", + "| **6** | | | | |\n", + "| **7** | | | | |\n", + "| **8** | | | | |\n", + "| **9** | | | | |\n", + "| **10** | | | | |\n", + "| **11** | | | | |\n", + "| **12** | | | | |\n", + "\n", + "나. 작성 서식 3 - 산식과 규칙: 종합 점수 = (지지 근거 가중치 합) - (반박 근거 가중치 합 절댓값) 이다. 규칙 1 점수 ( )점 이상이면 사실란 기재 검토, 규칙 2 반박 근거가 하나라도 있으면 ( )란으로 이동, 규칙 3 확인 불가 항목이 ( )개 이상이면 ( ), 규칙 4 사상자·민간 피해 관련 문장은 점수와 관계없이 ( ).\n", + "\n", + "나. 작성 서식 4 - 가상 사례 3종 적용(S1 08:15 직접 관측 보고, S2 10:10 피해 경미, S3 11:30 이상 없음)과 레드팀 반박\n", + "\n", + "| **사례** | **지지 근거 합** | **반박 근거 합** | **종합 점수** | **판단(결재 상신·보류·추가 확인)** | **레드팀 반박과 대응** |\n", + "| --- | --- | --- | --- | --- | --- |\n", + "| **S1** | | | | | |\n", + "| **S2** | | | | | |\n", + "| **S3** | | | | | |\n", + "\n", + "나. 작성 서식 5 - 일일보고서 초안(결론 한 줄 / 사실 / 조치 / 미확인)\n", + "\n", + "| **결론 한 줄** | |\n", + "| --- | --- |\n", + "\n", + "| **사실(확인 단계 표기)** | |\n", + "| --- | --- |\n", + "\n", + "| **조치(주체·시각·완료 여부)** | |\n", + "| --- | --- |\n", + "\n", + "| **미확인(확인 방법·책임자·기한)** | |\n", + "| --- | --- |\n", + "\n", + "다. 점검표\n", + "\n", + "| **점검 항목** | **확인** |\n", + "| --- | --- |\n", + "| 일지 16줄 모두에 분류와 확인 단계를 적었다 | ( ) |\n", + "| 근거를 12개 이상 항목화하고 방향과 가중치를 적었다 | ( ) |\n", + "| 산식과 규칙 1~4의 빈칸을 모두 채웠다 | ( ) |\n", + "| 사례 S1~S3의 점수와 판단을 계산해 기록했다 | ( ) |\n", + "| 초안에서 추정 표현이 사실란에 들어가지 않았고 사상자·민간 피해 문장은 확인 전 미확인란에 두었다 | ( ) |\n", + "\n", + "**\n", + "\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 05. 중복 기재 및 왜곡 보고 방지를 위한 교차 확인 체크리스트 개발\n", + "\n", + "### 📋 미션 배경 및 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "중복 기재 및 왜곡 보고 방지를 위한 교차 확인 체크리스트를 개발한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **중복 기재와 왜곡 보고를 막는 교차 확인 체크리스트를 개발하고, 일일보고서 결재 상신·보류의 분기점과 책임 직책을 확정한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **M2~M4 기준을 확인 단계, 중복 여부, 반대 근거, 결재 권한 순의 의사결정 트리로 묶고 분기점마다 조건과 책임 직책(가상)을 적는다 과정을 진행하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 5개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `sop/adm05_approval_thresholds_sop.md` 형태의 결과물을 완성한다.\n", + "\n", + "### 📑 실습자료 및 작성 양식\n", + "중복 기재 및 왜곡 보고 방지를 위한 교차 확인 체크리스트 개발**\n", + "\n", + "가. 제공 자료 1 - 교차 확인 체크리스트 예시 4개(나머지는 조별로 8개 이상으로 늘린다)\n", + "\n", + "| **구분** | **확인 질문** | **확인 방법(예)** |\n", + "| --- | --- | --- |\n", + "| **중복** | 같은 사건이 소대·중대·상황실 일지에 이중 기재되지 않았는가 | 사건 번호·시각·장소가 같은 항목을 대조해 연결 번호 부여 |\n", + "| **왜곡** | 숫자(인원·차량·탄약)가 전달 단계마다 같은가 | 원 일지와 일일보고서의 수치 대조 |\n", + "| **왜곡** | 조치 주체가 바뀌지 않았는가 | 소대·중대 일지의 주체 대조 |\n", + "| **시각** | 발생 시각과 접수 시각을 구분했는가 | 서식의 시각 칸 구분 확인 |\n", + "\n", + "가. 제공 자료 2 - 경계선 일지 묶음 5종 [교육용가상자료]\n", + "\n", + "| **묶음** | **내용** | **애매한 이유** |\n", + "| --- | --- | --- |\n", + "| **B1** | 직접 확인 V2, 시각 없음 | 근거는 있으나 시각 누락 |\n", + "| **B2** | 소대·상황실 일지 정비 지연이 2건으로 집계(같은 건 가능성) | 중복 여부 불명 |\n", + "| **B3** | 민간 차량 피해 가능성 전언 1건 | 민감 사항, 확인 전 단정 금지, 법무 검토 |\n", + "| **B4** | 경상자 1명, 의무반 기록 있음, 인원 수가 일지마다 다름 | 수치 불일치 |\n", + "| **B5** | 긴급 보고, 작성자 확인 전, 대대장이 급히 결재 요청 | 긴급 상황의 확인 생략 유혹 |\n", + "\n", + "가. 제공 자료 3 - 책임 직책 예시(가상): 작성 담당(상황병), 확인자(상황장교), 결재권자(대대장), 법무 검토 담당\n", + "\n", + "나. 작성 서식 1 - 교차 확인 체크리스트(8개 이상)\n", + "\n", + "| **번호** | **구분** | **확인 질문** | **확인 방법** | **책임 직책** |\n", + "| --- | --- | --- | --- | --- |\n", + "| **1** | 중복 | | | |\n", + "| **2** | 왜곡 | | | |\n", + "| **3** | 왜곡 | | | |\n", + "| **4** | 시각 | | | |\n", + "| **5** | | | | |\n", + "| **6** | | | | |\n", + "| **7** | | | | |\n", + "| **8** | | | | |\n", + "\n", + "나. 작성 서식 2 - 의사결정 트리와 분기점·책임 직책 매핑표\n", + "\n", + "| **분기점** | **조건(관찰 가능한 문장)** | **예일 때** | **아니오일 때** | **책임 직책(가상)** |\n", + "| --- | --- | --- | --- | --- |\n", + "| **T1** | 확인 단계가 V3 이상인가 | | | |\n", + "| **T2** | 중복·왜곡 확인 항목을 모두 통과했는가 | | | |\n", + "| **T3** | 반대 근거가 있는가 | | | |\n", + "| **T4** | 민감 사항(사상자·민간 피해)이 있는가 | | | |\n", + "\n", + "나. 작성 서식 3 - 모의 결심 기록지(B1~B5)\n", + "\n", + "| **묶음** | **판단(결재 상신·보류 유지·추가 확인 요청)** | **근거** | **책임 직책** | **조원 합의(예·아니오)** |\n", + "| --- | --- | --- | --- | --- |\n", + "| **B1** | | | | |\n", + "| **B2** | | | | |\n", + "| **B3** | | | | |\n", + "| **B4** | | | | |\n", + "| **B5** | | | | |\n", + "\n", + "나. 작성 서식 4 - 긴급 보고 규칙: 긴급 보고에서도 줄이지 않는 확인 단계는 ( ), 사후 정정 보고 기한은 ( ), 정정 책임 직책은 ( )이다. 민감 사항은 긴급 보고에서도 사실로 ( ).\n", + "\n", + "다. 점검표\n", + "\n", + "| **점검 항목** | **확인** |\n", + "| --- | --- |\n", + "| 교차 확인 체크리스트에 중복·왜곡·시각 항목이 8개 이상 있다 | ( ) |\n", + "| 분기점 4개의 조건과 책임 직책(가상)을 적었다 | ( ) |\n", + "| 경계선 묶음 5종 모두에 판단과 근거를 기록했다 | ( ) |\n", + "| 민감 사항을 확인 전에 사실로 쓰지 않고 법무 검토로 연결했다 | ( ) |\n", + "| 사람의 결재 없이 보고서가 확정되는 경로가 없고 교전수칙·지휘계통 우선 문구가 있다 | ( ) |\n", + "\n", + "**\n", + "\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 06. 지휘관 행정 결재 및 상황 공유용 표준 일일보고 템플릿 완성\n", + "\n", + "### 📋 미션 배경 및 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "지휘관 행정 결재 및 상황 공유용 표준 일일보고 템플릿을 완성한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **지휘관 행정 결재와 상황 공유에 쓰는 표준 일일보고 템플릿을 완성한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **결론 한 줄, 사실·조치·미확인 3개 구역, 확인 책임자와 기한을 한 쪽에 담는 표준 일일보고 템플릿 설계하고 워크플로우를 구성하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 5개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `templates/adm05_aar_fact_action_template.md` 형태의 결과물을 완성한다.\n", + "\n", + "### 📑 실습자료 및 작성 양식\n", + "지휘관 행정 결재 및 상황 공유용 표준 일일보고 템플릿 완성**\n", + "\n", + "가. 제공 자료 1 - 혼동 유발 표현 예시(나머지는 조별로 6개 이상으로 늘린다)\n", + "\n", + "| **혼동 유발 표현** | **왜 혼동되는가** | **대체 표현 예** |\n", + "| --- | --- | --- |\n", + "| **것으로 보임** | 추정인지 확인된 사실인지 불명 | 미확인란: (작성자)의 추정, 확인 방법 ( ) |\n", + "| **대체로 정상** | 기준과 범위가 없음 | 사실란: (시각) (대상) (확인 항목) 정상 확인(V2) |\n", + "| **피해 경미** | 경미의 기준 없음 | 사실란: 경상 1명 후송(의무반 기록). 장비 피해는 확인 중(미확인) |\n", + "\n", + "가. 제공 자료 2 - 가상 일일 메모 3종(사실·조치·미확인을 구분해 표시한다)\n", + "\n", + "| **메모** | **내용** |\n", + "| --- | --- |\n", + "| **M-1** | 10시 2소대 통신병이 무전기 불량을 시험해 확인했다. 예비 통신망으로 전환했다. 복구 시각은 아직 모른다. |\n", + "| **M-2** | 정찰조가 접근한 듯하다. 3소대장이 직접 관측했다고 한다. 상황장교가 승인해 화력 지원을 요청했다. |\n", + "| **M-3** | 보급로가 정리되어 이상 없다고 본다. 탄약 소모는 집계 중이다. 교대 인계는 구두로 했다. |\n", + "\n", + "나. 작성 서식 1 - 표준 일일보고 템플릿(1쪽)\n", + "\n", + "| **구역** | **작성 내용** | **표기 규칙** |\n", + "| --- | --- | --- |\n", + "| **결론 한 줄** | | |\n", + "| **사실(확인 단계 V 표기)** | | |\n", + "| **조치(주체·시각·완료 여부)** | | |\n", + "| **미확인(확인 방법·책임자·기한)** | | |\n", + "| **확인자·결재권자 서명** | | |\n", + "\n", + "나. 작성 서식 2 - 기록 분류 규칙(빈칸을 채운다): 사실은 ( ), 조치는 ( ), 미확인은 ( ) 표시로 구분한다. 문장 하나에는 한 가지 분류만 담는다. 추측은 항상 ( )에 쓴다.\n", + "\n", + "나. 작성 서식 3 - 요약형 기록(메모 M-1~M-3을 옮겨 적는다)\n", + "\n", + "| **메모** | **사실** | **조치** | **미확인** |\n", + "| --- | --- | --- | --- |\n", + "| **M-1** | | | |\n", + "| **M-2** | | | |\n", + "| **M-3** | | | |\n", + "\n", + "나. 작성 서식 4 - 혼동 유발 표현 금지 목록(6개 이상)\n", + "\n", + "| **번호** | **금지 표현** | **대체 표현** |\n", + "| --- | --- | --- |\n", + "| **1** | | |\n", + "| **2** | | |\n", + "| **3** | | |\n", + "| **4** | | |\n", + "| **5** | | |\n", + "| **6** | | |\n", + "| **7** | | |\n", + "\n", + "다. 점검표\n", + "\n", + "| **점검 항목** | **확인** |\n", + "| --- | --- |\n", + "| 템플릿 1쪽에 결론, 사실, 조치, 미확인, 서명 구역이 모두 있다 | ( ) |\n", + "| 혼동 유발 표현 금지 목록을 6개 이상 만들고 대체 표현을 적었다 | ( ) |\n", + "| 메모 3종에서 사실과 의견을 혼동한 문장이 0건이다 | ( ) |\n", + "| 분류 규칙 4개 빈칸을 모두 채웠다 | ( ) |\n", + "| 미확인 항목마다 확인 방법과 책임 직책이 있다 | ( ) |\n", + "\n", + "**\n", + "\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 07. 대량 전투 일지 유입 시 정보 분류 누락 방지 고부하 스트레스 검증\n", + "\n", + "### 📋 미션 배경 및 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "대량 전투 일지 유입 시 정보 분류 누락 방지 스트레스 검증을 수행한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **대량의 전투 일지가 한꺼번에 들어와도 정보 분류가 누락되지 않는지 고부하 스트레스로 검증하고, 이를 평가할 루브릭을 정의한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **일지 카드를 5건, 10건, 20건, 30건 단계로 늘려 투입하고 중간에 통신 두절, 교대, 중복 카드가 섞이는 상황 작성하여 원인을 분석하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 5개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `rubric/adm05_evaluation_rubric.md` 형태의 결과물을 완성한다.\n", + "\n", + "### 📑 실습자료 및 작성 양식\n", + "대량 전투 일지 유입 시 정보 분류 누락 방지 고부하 스트레스 검증**\n", + "\n", + "가. 제공 자료 1 - 대량 일지 유입 단계(통제관이 단계별로 카드를 투입) [교육용가상자료]\n", + "\n", + "| **단계** | **투입 카드 수** | **함께 주입하는 상황** | **확인할 것** |\n", + "| --- | --- | --- | --- |\n", + "| **W1** | 5건 | 없음(기준선) | 분류 시간과 정확도 |\n", + "| **W2** | 10건 | 중복 카드 2건 포함 | 중복 연결 번호 부여 |\n", + "| **W3** | 20건 | 통신 두절 메시지(시각 누락 카드 3건) | 시각 누락 처리 |\n", + "| **W4** | 30건 | 교대 발생, 구두 인계 카드 2건 | 미확인 목록 인계 |\n", + "| **W5** | 30건 + 긴급 카드 2건 | 결재권자 재촉 카드 | 긴급 보고에서의 확인 유지 |\n", + "\n", + "가. 제공 자료 2 - 루브릭 틀(5영역 x 4등급, 관찰 가능한 행동으로 작성)\n", + "\n", + "| **평가 영역** | **탁월** | **우수** | **보통** | **미흡** |\n", + "| --- | --- | --- | --- | --- |\n", + "| **분류 정확성** | | | | |\n", + "| **시각·출처 기재** | | | | |\n", + "| **중복 처리** | | | | |\n", + "| **미확인 관리** | | | | |\n", + "| **보고 적시성** | | | | |\n", + "\n", + "나. 작성 서식 1 - 단계별 분류 기록\n", + "\n", + "| **단계** | **투입 건수** | **분류 완료** | **분류 누락** | **분류 오류** | **작성 시간(분)** | **누락 이유** |\n", + "| --- | --- | --- | --- | --- | --- | --- |\n", + "| **W1** | | | | | | |\n", + "| **W2** | | | | | | |\n", + "| **W3** | | | | | | |\n", + "| **W4** | | | | | | |\n", + "| **W5** | | | | | | |\n", + "\n", + "나. 작성 서식 2 - 평가자 채점 일치표(평가자 2명이 상의 없이 채점)\n", + "\n", + "| **평가 영역** | **평가자 1 등급** | **평가자 2 등급** | **일치(예·아니오)** | **불일치 이유** |\n", + "| --- | --- | --- | --- | --- |\n", + "| **분류 정확성** | | | | |\n", + "| **시각·출처 기재** | | | | |\n", + "| **중복 처리** | | | | |\n", + "| **미확인 관리** | | | | |\n", + "| **보고 적시성** | | | | |\n", + "\n", + "나. 작성 서식 3 - 한계 도달 지점: 분류 누락이 처음 생긴 단계는 ( ), 인입 건수는 ( )건, 이유는 ( ), 개선 방안은 '( )'이다. 일치율은 ( ) / 5 = ( )%이다.\n", + "\n", + "다. 점검표\n", + "\n", + "| **점검 항목** | **확인** |\n", + "| --- | --- |\n", + "| 5개 영역 x 4단계 기준을 모두 관찰 가능한 행동으로 정의했다 | ( ) |\n", + "| W1~W5 단계별 누락·오류 건수와 작성 시간을 기록했다 | ( ) |\n", + "| 평가자 2명이 상의 없이 채점하고 일치율을 계산했다 | ( ) |\n", + "| 분류 누락이 처음 생긴 인입량과 이유를 적었다 | ( ) |\n", + "| 고부하에서도 추정 표현을 사실란으로 올리지 않았다 | ( ) |\n", + "\n", + "**\n", + "\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 08. AAR 회의를 통한 사실-의견 혼동 보고 사례 분석 및 원인 규명\n", + "\n", + "### 📋 미션 배경 및 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "AAR 회의를 통해 사실-의견 혼동 보고 사례를 분석한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **AAR 회의 자료에서 사실과 의견이 섞여 보고된 사례의 원인을 사람·절차·정보·도구로 나누어 규명하고, 레드팀 비판으로 지침의 취약한 전제를 찾는다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **가상 AAR 회의 기록 2건을 시간순으로 읽고 어느 문장에서 의견이 사실로 바뀌었는지 찾아 표시 수행하고 검증하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 5개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `reports/adm05_redteam_critique_report.md` 형태의 결과물을 완성한다.\n", + "\n", + "### 📑 실습자료 및 작성 양식\n", + "AAR 회의를 통한 사실-의견 혼동 보고 사례 분석 및 원인 규명**\n", + "\n", + "가. 제공 자료 - 가상 AAR 회의 기록 A·B [교육용가상자료]\n", + "\n", + "| **순서** | **AAR 회의 기록 A** | **AAR 회의 기록 B** |\n", + "| --- | --- | --- |\n", + "| **1** | 일지: '3소대장 전언, 정찰조 접근' | 일지: '피해 경미' |\n", + "| **2** | 상황실 취합: '정찰조 접근 확인'(전언이 확인으로 바뀜) | 중대 취합: '피해 없음'(경미가 없음으로 바뀜) |\n", + "| **3** | 일일보고서 사실란 기재, 근거 칸 공란 | 일일보고서 사실란 기재, 확인자 칸 공란 |\n", + "| **4** | 결재 후 다음 날 3소대장이 '다른 방향을 말한 것'이라고 정정 | 다음 날 경상자 1명 후송 사실이 뒤늦게 확인됨 |\n", + "| **5** | AAR 토의: 취합 때 전언 표시를 지우는 것이 관행 | AAR 토의: 서식에 '피해' 칸이 하나뿐이라 미확인을 쓸 곳이 없음 |\n", + "\n", + "나. 작성 서식 1 - 사실-의견 혼동 지점과 원인 분류(사람·절차·정보·도구, 개인 비난 표현 금지)\n", + "\n", + "| **번호** | **혼동이 생긴 문장·단계** | **원인 분류** | **원인 설명(사실 중심)** | **개선안** |\n", + "| --- | --- | --- | --- | --- |\n", + "| **1** | | | | |\n", + "| **2** | | | | |\n", + "| **3** | | | | |\n", + "| **4** | | | | |\n", + "| **5** | | | | |\n", + "\n", + "나. 작성 서식 2 - 레드팀 쟁점·대응(5건 이상)\n", + "\n", + "| **번호** | **지침의 취약한 전제** | **레드팀 비판** | **작성 팀 반박 또는 수정안** | **경고 문구** |\n", + "| --- | --- | --- | --- | --- |\n", + "| **1** | | | | |\n", + "| **2** | | | | |\n", + "| **3** | | | | |\n", + "| **4** | | | | |\n", + "| **5** | | | | |\n", + "| **6** | | | | |\n", + "\n", + "나. 작성 서식 3 - 반대 방향 위험 점검\n", + "\n", + "| **위험** | **설명** | **대응** |\n", + "| --- | --- | --- |\n", + "| **미확인 과다 표기** | | |\n", + "| **확인 절차로 인한 보고 지연** | | |\n", + "| **작성 부담 증가** | | |\n", + "\n", + "나. 작성 서식 4 - 수정 반영 계획\n", + "\n", + "| **번호** | **수정할 문서와 위치** | **수정 내용** | **책임 직책(가상)** | **기한** |\n", + "| --- | --- | --- | --- | --- |\n", + "| **1** | | | | |\n", + "| **2** | | | | |\n", + "| **3** | | | | |\n", + "| **4** | | | | |\n", + "\n", + "다. 점검표\n", + "\n", + "| **점검 항목** | **확인** |\n", + "| --- | --- |\n", + "| 기록 A·B에서 의견이 사실로 바뀐 지점을 5건 이상 찾았다 | ( ) |\n", + "| 원인을 사람·절차·정보·도구로 분류하고 개인 비난 표현이 없다 | ( ) |\n", + "| 레드팀 쟁점 5건 이상에 반박 또는 수정안을 100% 적었다 | ( ) |\n", + "| 취약한 전제에 경고 문구를 명시했다 | ( ) |\n", + "| 반대 방향 위험 3가지를 점검했다 | ( ) |\n", + "\n", + "**\n", + "\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 09. 피드백 반영 보고서 항목 수정 및 지휘관 피드백 재반영\n", + "\n", + "### 📋 미션 배경 및 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "피드백을 반영하여 보고서 항목을 수정하고 지휘관 피드백을 재반영한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **AAR 피드백을 반영해 일일보고서 항목을 수정하고 지휘관 피드백을 재반영해 핵심 결심 사항을 1페이지 브리핑으로 요약한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **수정 전후 보고서 항목과 기준을 비교표에 정리하고 가상 재검증 일지 7건에 수정 전·후 기준을 각각 적용해 판단, 일치 여부, 작성 시간을 기록 수행하고 검증하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 5개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `briefing/adm05_commander_1page_brief.md(pdf)` 형태의 결과물을 완성한다.\n", + "\n", + "### 📑 실습자료 및 작성 양식\n", + "피드백 반영 보고서 항목 수정 및 지휘관 피드백 재반영**\n", + "\n", + "가. 제공 자료 - 가상 재검증 일지 7건과 지휘관 피드백 [교육용가상자료]\n", + "\n", + "| **사례** | **내용** | **수정 전 판단** | **수정 후 판단(조별 작성)** |\n", + "| --- | --- | --- | --- |\n", + "| **V1** | 직접 확인, 시각 없음 | 결재 상신 | |\n", + "| **V2** | 전언을 확인으로 취합 | 결재 상신 | |\n", + "| **V3** | 같은 건 중복 집계 | 결재 상신 | |\n", + "| **V4** | 피해 경미, 근거 없음 | 결재 상신 | |\n", + "| **V5** | 민간 피해 가능성 전언 | 결재 상신 | |\n", + "| **V6** | 직접 확인, 확인자 서명 있음 | 보류 | |\n", + "| **V7** | 긴급 보고, 작성자 확인 전 | 결재 상신 | |\n", + "\n", + "가. 제공 자료 2 - 지휘관 피드백 예시: '미확인란이 길어 핵심이 안 보인다', '조치 기한이 안 보인다', '결론이 마지막에 있다'\n", + "\n", + "나. 작성 서식 1 - 수정 전후 보고서 항목 비교표\n", + "\n", + "| **항목** | **수정 전** | **수정 후** | **바뀐 이유(지휘관 피드백·근거 미션)** |\n", + "| --- | --- | --- | --- |\n", + "| **결론 위치** | | | |\n", + "| **미확인란 길이** | | | |\n", + "| **조치 기한 표기** | | | |\n", + "| **확인 단계 표기** | | | |\n", + "| **민감 사항 처리** | | | |\n", + "\n", + "나. 작성 서식 2 - 재검증 결과표\n", + "\n", + "| **사례** | **수정 전 판단** | **수정 후 판단** | **일치 여부** | **작성 시간(분)** |\n", + "| --- | --- | --- | --- | --- |\n", + "| **V1** | | | | |\n", + "| **V2** | | | | |\n", + "| **V3** | | | | |\n", + "| **V4** | | | | |\n", + "| **V5** | | | | |\n", + "| **V6** | | | | |\n", + "| **V7** | | | | |\n", + "\n", + "나. 작성 서식 3 - 1페이지 브리핑 BLUF 틀\n", + "\n", + "| **결론(첫 문장)** | |\n", + "| --- | --- |\n", + "\n", + "| **핵심 결심 사항** | |\n", + "| --- | --- |\n", + "\n", + "| **위험 요소(신호등 표시: 녹·황·적)** | |\n", + "| --- | --- |\n", + "\n", + "| **대안 3종(장단점 한 줄)** | |\n", + "| --- | --- |\n", + "\n", + "| **근거** | |\n", + "| --- | --- |\n", + "\n", + "나. 작성 서식 4 - 브리핑 리허설 기록\n", + "\n", + "| **항목** | **목표** | **결과** |\n", + "| --- | --- | --- |\n", + "| 발표 시간 | 3분 이내 | |\n", + "| 분량 | A4 1페이지 | |\n", + "| 질의 응답 | 3건 이상 답변 | |\n", + "| 결론 먼저 제시 | 첫 문장 | |\n", + "\n", + "다. 점검표\n", + "\n", + "| **점검 항목** | **확인** |\n", + "| --- | --- |\n", + "| 수정 전후 항목과 바뀐 이유를 모두 적었다 | ( ) |\n", + "| 재검증 7건 모두 수정 전후 판단을 비교했다 | ( ) |\n", + "| 지휘관 피드백을 항목에 반영한 근거를 적었다 | ( ) |\n", + "| 브리핑이 결론부터 시작하고 A4 1페이지에 들어간다 | ( ) |\n", + "| 핵심 결심 사항, 위험 요소, 대안 3종이 있고 3분 안에 발표했다 | ( ) |\n", + "\n", + "**\n", + "\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 10. AAR 기반 사실·조치·미확인 분리 일일보고서 작성 지침서 최종 완성\n", + "\n", + "### 📋 미션 배경 및 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "AAR 기반 사실·조치·미확인 분리 일일보고서 작성 지침서를 완성한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **미션 1~9의 산출물을 통합해 AAR 기반 사실·조치·미확인 분리 일일보고서 작성 지침서를 완성하고 모의 심의를 거친다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **산출물을 지침서 8개 장(목적과 적용 범위, 용어, AAR 기록 구조, 3분류와 확인 단계, 교차 확인과 결재, 서식과 보고, 훈련·평가, 개정 관리)과 부록에 배치하고 빠진 내용을 보완 수행하고 검증하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 5개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `manual/adm05_standard_operational_manual.md` 형태의 결과물을 완성한다.\n", + "\n", + "### 📑 실습자료 및 작성 양식\n", + "AAR 기반 사실·조치·미확인 분리 일일보고서 작성 지침서 최종 완성**\n", + "\n", + "가. 제공 자료 1 - 지침서 8개 장 구성과 가져올 결과물\n", + "\n", + "| **장** | **제목** | **포함 내용** | **가져올 결과물** |\n", + "| --- | --- | --- | --- |\n", + "| **1** | 목적과 적용 범위 | 목적, 적용 상황, 적용하지 않는 상황, 교전수칙과의 관계 | M1 |\n", + "| **2** | 용어 정의 | 사실, 조치, 미확인, 확인 단계, 중복, 왜곡 | M1~M2 |\n", + "| **3** | AAR 기록 구조 | 기록 항목, 시각·출처 기재 요령 | M1·M3 |\n", + "| **4** | 3분류와 확인 단계 | 분류 기준, V1~V6, 교차 확인 규칙, 평가 지표 | M2·M4 |\n", + "| **5** | 교차 확인과 결재 | 체크리스트, 의사결정 트리, 책임 직책, 긴급 보고 | M5 |\n", + "| **6** | 서식과 보고 | 표준 일일보고 템플릿, 표현 규칙, 브리핑 | M3·M6·M9 |\n", + "| **7** | 훈련·평가 | 고부하 검증, 루브릭, AAR, 레드팀 검토 | M7·M8 |\n", + "| **8** | 개정 관리 | 개정 주기, 의견 수렴, 책임 직책, 이력 | M8·M9 |\n", + "\n", + "가. 제공 자료 2 - 모의 심의 역할 카드\n", + "\n", + "| **역할** | **검토 질문** |\n", + "| --- | --- |\n", + "| **교리 역할** | 3분류와 확인 단계가 일관되는가? 신병도 이해할 용어인가? |\n", + "| **법무 역할** | '최종 판단은 사람이, 교전수칙·지휘계통에 따라'가 드러나는가? 사상자·민간 피해 기재 규칙이 법무 검토로 연계되는가? |\n", + "| **작전 역할** | 상황실에서 20분 안에 쓸 수 있는가? 미확인 표시가 지나쳐 보고서가 쓸모없어지지 않는가? |\n", + "\n", + "나. 작성 서식 1 - 근거 표기 점검표(지침서 조항마다 작성)\n", + "\n", + "| **조항** | **기준 내용 요약** | **근거(미션·가상 자료)** | **표기 여부** |\n", + "| --- | --- | --- | --- |\n", + "| **1-1** | | | |\n", + "| **2-1** | | | |\n", + "| **3-1** | | | |\n", + "| **4-1** | | | |\n", + "| **5-1** | | | |\n", + "| **6-1** | | | |\n", + "| **7-1** | | | |\n", + "| **8-1** | | | |\n", + "\n", + "나. 작성 서식 2 - 모의 심의 지적 사항 반영표\n", + "\n", + "| **번호** | **역할** | **지적 사항** | **반영 내용** | **반영 위치(장·조)** |\n", + "| --- | --- | --- | --- | --- |\n", + "| **1** | | | | |\n", + "| **2** | | | | |\n", + "| **3** | | | | |\n", + "| **4** | | | | |\n", + "| **5** | | | | |\n", + "| **6** | | | | |\n", + "\n", + "나. 작성 서식 3 - 개정 관리: 개정 주기 ( ), 의견 수렴 방법 ( ), 개정 책임 직책(가상) ( ), 개정 이력 기록 위치 ( )\n", + "\n", + "다. 점검표\n", + "\n", + "| **점검 항목** | **확인** |\n", + "| --- | --- |\n", + "| 8개 장과 부록을 모두 완성했다 | ( ) |\n", + "| 모든 기준 조항에 근거(미션·가상 자료)를 표기했다 | ( ) |\n", + "| 교리·법무·작전 역할의 지적 사항을 반영하고 반영 위치를 적었다 | ( ) |\n", + "| '최종 판단은 사람이 하며 실제 교전·작전 판단은 교전수칙·지휘계통에 따른다'는 문구가 1장과 5장에 있다 | ( ) |\n", + "| 사상자·민간 피해 등 민감 사항을 확인 전 사실로 쓰지 않는다는 문구가 4장과 5장에 있다 | ( ) |\n", + "| 실제 부대·병력·피해 수치를 쓰지 않았다 | ( ) |\n", + "\n", + "**4. 평가 채점표**\n", + "\n", + "미션별 평가 기준은 시나리오의 평가 항목과 같다. 평가자는 기준 충족 여부를 PASS/FAIL로 표시하고 FAIL이면 사유를 비고에 적는다.\n", + "\n", + "| **미션** | **구분** | **채점 기준** | **판정** | **비고** |\n", + "| --- | --- | --- | --- | --- |\n", + "| **M1** | 필수 | AAR 기록 구조 분석·문서 골격: 가상 기록 사례 5건 이상을 검토해 기록 구조와 문제점을 비교했는가, 지침서 목차(8개 장) 골격을 100% 정의하고 지휘관 결재 의제 순으로 배열했는가. | PASS / FAIL | |\n", + "| **M2** | 필수 | 3분류·확인 6단계·평가 지표: 확인 단계 기준 6단계를 완비하고 평가 지표 10개 이상을 정의했는가, 가상 기록 문장 5종에 대한 조원 간 분류·단계 일치가 4건 이상이고 편향 방지 주의와 교차 확인 필수 규칙을 명시했는가. | PASS / FAIL | |\n", + "| **M3** | 필수 | 일일보고서 서식·불확실성 평가: 기록 공백 요인 8개 이상과 영향도 매트릭스를 도출했는가, 어긋난 일지 4건에 원인 후보와 확인 방법을 적고 판단의 한계와 보완 방법을 명시했는가. | PASS / FAIL | |\n", + "| **M4** | 선택 | 근거 항목·일일보고서 초안: 기재 근거 12개 이상 항목화와 가중치 산식을 100% 정립했는가, 일지 16줄의 분류 결과와 사례 3종 적용 결과를 기록하고 레드팀 반박에 대응해 최소 증거 요건을 설정했는가. | PASS / FAIL | |\n", + "| **M5** | 필수 | 교차 확인·분기점·책임 매핑: 분기점 조건 100% 명시와 책임 직책 매핑을 완료했는가, 경계선 사례 5종 모두에 판단과 근거가 기록되고 교전수칙·인도주의 원칙(민간 피해 단정 금지)과 사람의 최종 판단을 지켰는가. | PASS / FAIL | |\n", + "| **M6** | 선택 | 기록 분류·표준 템플릿: 기록 분류 규칙 100% 표준화, 혼동 유발 표현 금지 가이드 6개 이상을 포함하고 가상 일일 메모 3종에서 사실과 의견의 혼동이 0건인가. | PASS / FAIL | |\n", + "| **M7** | 필수 | 고부하 분류 검증·루브릭: 평가 영역 5개와 등급 4단계(탁월·우수·보통·미흡) 기준 정의율 100%인가, 평가자 2명 채점 일치율 80% 이상이며 분류 누락이 시작된 인입량을 기록으로 제시했는가. | PASS / FAIL | |\n", + "| **M8** | 선택 | 혼동 원인 규명·레드팀 비판: 도출된 비판적 쟁점 5건 이상에 대해 반박 및 수정 대안을 100% 수립했는가, 취약한 전제에 경고 문구를 명시하고 반대 방향 위험(미확인 과다)을 점검했는가. | PASS / FAIL | |\n", + "| **M9** | 필수 | 보고서 수정·1페이지 브리핑: 핵심 결심 사항, 위험 요소, 대안 3종이 1페이지(A4) 안에 완결되는가, 재검증 7건의 수정 전후 비교와 BLUF 준수(결론 먼저, 3분 이내 발표)를 확인했는가. | PASS / FAIL | |\n", + "| **M10** | 필수 | 작성 지침서 통합 완성: 지침서 8개 장과 부록을 100% 완성하고 모든 기준 조항에 근거(미션·가상 자료)를 표기했는가, 모의 심의 지적 사항을 반영하고 '최종 판단은 사람이 하며 실제 교전·작전 판단은 교전수칙·지휘계통에 따른다'는 문구를 명시했는가. | PASS / FAIL | |\n", + "\n", + "| **판정 기준** | 필수 7개 미션(M1·M2·M3·M5·M7·M9·M10)이 모두 PASS여야 최종 PASS로 인정한다. 선택 미션(M4·M6·M8)은 가점·보완 의견에 반영한다. |\n", + "| --- | --- |\n", + "\n", + "| **공통 감점** | 실제 부대명·위치·병력·피해 수치·실존 인물 정보를 사용한 경우, 부대 자료를 외부 AI 서비스에 입력한 경우 해당 미션은 FAIL로 처리한다. |\n", + "| --- | --- |\n", + "\n", + "| **주제 안전 기준** | 추정·전언을 사실란에 올리는 판단이 있거나 지침서에 사람의 최종 판단·교전수칙 우선 문구와 민감 사항 단정 금지 문구가 없으면 M5·M10은 FAIL로 처리한다. |\n", + "| --- | --- |\n", + "\n", + "| **최종 판정 결과** | ( PASS / FAIL ) 평가자 서명: ( ) |\n", + "| --- | --- |\n", + "\n", + "| 안전 원칙: 본 자료의 분류 기준, 확인 단계, 결재 권한 구분은 학습용 가상 기준이다. 최종 판단은 사람이 내리며 실제 교전·작전 판단은 교전수칙과 지휘계통에 따른다. 실제 부대 자료를 입력하거나 부대 내에서 촬영하지 않는다. |\n", + "| --- |\n", + "\n" + ] + } + ], + "metadata": { + "colab": { + "provenance": [] + }, + "kernelspec": { + "display_name": "Python 3", + "name": "python3" + }, + "language_info": { + "name": "python" + } + }, + "nbformat": 4, + "nbformat_minor": 0 +} \ No newline at end of file diff --git a/uploads/AMM02/AMM02_solution.ipynb b/uploads/AMM02/AMM02_solution.ipynb new file mode 100644 index 0000000..3f64881 --- /dev/null +++ b/uploads/AMM02/AMM02_solution.ipynb @@ -0,0 +1,2818 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# 🎯 [AMM02] 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/AMM02/AMM02_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", + "탄약 저장고는 공간이 한정되어 있고 품목마다 함께 둘 수 없는 제약이 있어, 앞으로 얼마나 소모되고 얼마나 자주 반출될지를 알아야 입고 위치와 재보급 순서를 합리적으로 정할 수 있다. 그러나 작전 단계별 고정 기준 소모율(CSR)만으로 계획하면 교전 강도가 낮은 날은 과잉 재고가 쌓이고 높은 날은 긴급 재보급이 반복되어 저장 공간과 작업 동선이 낭비된다. 본 프로젝트는 교육용 가상 부대·가상 품목·가상 저장고 자료를 코드로 만들어, 탄약·유류 소모율 데이터 정의와 전처리, CSR 기반 Baseline과 탐색적 분석, 적 저항·화력 계획을 반영한 소모량 예측, 보급 수준(DOS) 연계 재보급 산출, 작전 지도 대시보드와 설명가능성, 저장 배치 최적화와 군수참모 결재선(HITL), 7일 고강도 스트레스 시험, 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. 작전 단계별 탄약(구경별) 및 유류(항공유/경유) 기준 소모율(CSR) 데이터 정의\n", + "**소요시간**: 약 1.5시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "작전 단계별 탄약 및 유류 기준 소모율(CSR) 데이터 규격을 정의한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **작전 단계별 가상 탄약(탄종 A~D)·유류(항공유/경유) 기준 소모율(CSR) 데이터 규격과 업무 프로세스 맵, 데이터 딕셔너리를 정의한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **일일 소모 보고 수집부터 소모량 산출, 보급 수준(DOS) 계산, 재보급 결재, 가상 저장고 입고·배치까지 업무 흐름을 5단계로 정리하고 규격화하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `docs/amm02_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": [ + "\"\"\"작전 단계별 탄약(가상 탄종 A~D)·유류(항공유/경유) 기준 소모율(CSR) 데이터 규격, 업무 프로세스 맵, 데이터 딕셔너리(필드 20개 이상)를 정의하고 가상 샘플 기록을 검증한다.\"\"\"\n", + "import json\n", + "import re\n", + "import numpy as np\n", + "import pandas as pd\n", + "\n", + "# 1) 업무 프로세스 맵: 소모 기록 -> 소모 예측 -> 보급 수준(DOS) -> 재보급·저장 배치\n", + "PROCESS = [\"(1) 일일 소모 보고 수집\", \"(2) 작전 단계·교전 강도 반영 소모량 산출\", \"(3) 보유량 대비 보급 수준(DOS) 계산\",\n", + " \"(4) 재보급 소요·우선순위 결정(군수참모 결재)\", \"(5) 가상 저장고 입고·배치(위치점수·작업동선)\"]\n", + "\n", + "# 2) 가상 품목·작전 단계·CSR 계수 (실제 탄약 제원·소모율이 아닌 교육용 가상 계수)\n", + "PHASES = [\"prep\", \"attack\", \"defense\", \"stability\"]\n", + "ITEMS = {\"ammo_A\": \"탄약\", \"ammo_B\": \"탄약\", \"ammo_C\": \"탄약\", \"ammo_D\": \"탄약\", \"fuel_jet\": \"유류\", \"fuel_diesel\": \"유류\"}\n", + "CSR = pd.DataFrame([[1.0, 0.6, 0.3, 0.2, 1.5, 6.0], [6.0, 3.5, 2.0, 1.2, 5.0, 12.0],\n", + " [4.5, 3.0, 1.6, 0.8, 3.0, 7.0], [0.8, 0.5, 0.3, 0.2, 1.0, 5.0]], index=PHASES, columns=list(ITEMS))\n", + "\n", + "# 3) 데이터 딕셔너리: (그룹, 필드, 자료형, 규칙, 설명)\n", + "FIELDS = [\n", + " (\"식별\", \"record_id\", str, {\"pattern\": r\"^R\\d{5}$\"}, \"기록 번호\"),\n", + " (\"식별\", \"unit_id\", str, {\"pattern\": r\"^U\\d{2}$\"}, \"가상 부대 코드\"),\n", + " (\"식별\", \"day_of_op\", int, {\"min\": 0, \"max\": 365}, \"작전 경과일\"),\n", + " (\"작전\", \"phase\", str, {\"enum\": PHASES}, \"작전 단계\"),\n", + " (\"작전\", \"unit_size\", int, {\"min\": 50, \"max\": 3000}, \"가상 병력 규모(명)\"),\n", + " (\"작전\", \"intensity\", float, {\"min\": 0, \"max\": 1}, \"교전 강도(0~1)\"),\n", + " (\"작전\", \"resistance\", float, {\"min\": 0, \"max\": 5}, \"적 저항 수준(0~5)\"),\n", + " (\"작전\", \"fire_missions\", int, {\"min\": 0, \"max\": 100}, \"계획 화력 임무 수\"),\n", + " (\"작전\", \"move_km\", float, {\"min\": 0, \"max\": 300}, \"일일 이동 거리(km)\"),\n", + " (\"작전\", \"weather\", str, {\"enum\": [\"clear\", \"rain\", \"snow\"]}, \"기상\"),\n", + " (\"품목\", \"item_code\", str, {\"enum\": list(ITEMS)}, \"가상 품목 코드\"),\n", + " (\"품목\", \"item_class\", str, {\"enum\": [\"탄약\", \"유류\"]}, \"품목 분류\"),\n", + " (\"품목\", \"csr_coeff\", float, {\"min\": 0, \"max\": 50}, \"기준 소모율(가상 계수, 100명·1일)\"),\n", + " (\"품목\", \"unit_per_pallet\", int, {\"min\": 1, \"max\": 200}, \"팔레트당 단위 수(가상)\"),\n", + " (\"소모\", \"consumption_qty\", float, {\"min\": 0, \"max\": 5000}, \"실제 소모량(단위)\"),\n", + " (\"소모\", \"emergency_issue\", bool, {}, \"긴급 불출 여부(예외 보존)\"),\n", + " (\"재고\", \"on_hand_qty\", float, {\"min\": 0, \"max\": 50000}, \"보유량(단위)\"),\n", + " (\"재고\", \"dos\", float, {\"min\": 0, \"max\": 60}, \"보급 수준(일분)\"),\n", + " (\"저장\", \"storage_id\", str, {\"enum\": [\"S1\", \"S2\", \"S3\"]}, \"가상 저장고\"),\n", + " (\"저장\", \"slot_id\", str, {\"pattern\": r\"^[A-C]\\d{2}$\"}, \"저장 위치(열·칸)\"),\n", + " (\"저장\", \"slot_distance_m\", float, {\"min\": 0, \"max\": 500}, \"출입구까지 거리(작업동선)\"),\n", + " (\"저장\", \"occupancy_rate\", float, {\"min\": 0, \"max\": 1}, \"저장 위치 점유율\"),\n", + " (\"관리\", \"source\", str, {\"enum\": [\"일일보고\", \"AAR\", \"추정\"]}, \"자료 출처\"),\n", + "]\n", + "\n", + "\n", + "def check(name, value, typ, rule):\n", + " if typ is float and isinstance(value, int) and not isinstance(value, bool):\n", + " value = float(value)\n", + " if not isinstance(value, typ) or (typ is not bool and isinstance(value, bool)):\n", + " return [f\"{name}: 자료형 오류({type(value).__name__})\"]\n", + " errs = []\n", + " if \"enum\" in rule and value not in rule[\"enum\"]:\n", + " errs.append(f\"{name}: 허용되지 않은 값 '{value}'\")\n", + " if \"min\" in rule and value < rule[\"min\"]:\n", + " errs.append(f\"{name}: 최소값 {rule['min']} 미만\")\n", + " if \"max\" in rule and value > rule[\"max\"]:\n", + " errs.append(f\"{name}: 최대값 {rule['max']} 초과\")\n", + " if \"pattern\" in rule and not re.match(rule[\"pattern\"], value):\n", + " errs.append(f\"{name}: 형식 불일치 '{value}'\")\n", + " return errs\n", + "\n", + "\n", + "def validate(rec):\n", + " errs = []\n", + " for _, f, typ, rule, _ in FIELDS:\n", + " errs += [f\"{f}: 필수 필드 누락\"] if f not in rec else check(f, rec[f], typ, rule)\n", + " if not errs and ITEMS[rec[\"item_code\"]] != rec[\"item_class\"]:\n", + " errs.append(\"item_class: 품목 코드와 분류 불일치\")\n", + " return errs\n", + "\n", + "\n", + "# 4) [교육용가상자료] 샘플 기록 30건 생성\n", + "rng = np.random.default_rng(1)\n", + "records = []\n", + "for i in range(30):\n", + " ph, it, size = str(rng.choice(PHASES)), str(rng.choice(list(ITEMS))), int(rng.integers(3, 9)) * 100\n", + " qty = float(round(CSR.loc[ph, it] * size / 100 * rng.uniform(0.7, 1.4), 1))\n", + " stock = float(round(qty * rng.uniform(1, 8), 1))\n", + " records.append({\"record_id\": f\"R{i:05d}\", \"unit_id\": f\"U{int(rng.integers(1, 9)):02d}\", \"day_of_op\": int(rng.integers(0, 90)),\n", + " \"phase\": ph, \"unit_size\": size, \"intensity\": float(round(rng.uniform(0, 1), 2)),\n", + " \"resistance\": float(round(rng.uniform(0, 5), 1)), \"fire_missions\": int(rng.integers(0, 12)),\n", + " \"move_km\": float(round(rng.uniform(0, 40), 1)), \"weather\": str(rng.choice([\"clear\", \"rain\", \"snow\"])),\n", + " \"item_code\": it, \"item_class\": ITEMS[it], \"csr_coeff\": float(CSR.loc[ph, it]),\n", + " \"unit_per_pallet\": 20 if ITEMS[it] == \"탄약\" else 4, \"consumption_qty\": qty,\n", + " \"emergency_issue\": bool(rng.random() < 0.05), \"on_hand_qty\": stock, \"dos\": round(stock / qty, 1),\n", + " \"storage_id\": str(rng.choice([\"S1\", \"S2\", \"S3\"])), \"slot_id\": f\"{'ABC'[i % 3]}{i % 10:02d}\",\n", + " \"slot_distance_m\": float(5 + (i % 10) * 6), \"occupancy_rate\": float(round(rng.uniform(0.2, 1), 2)), \"source\": \"일일보고\"})\n", + "\n", + "print(\"[업무 프로세스 맵] \" + \" -> \".join(PROCESS))\n", + "ok = sum(1 for r in records if not validate(r))\n", + "print(f\"\\n정상 샘플 {len(records)}건 중 검증 통과 {ok}건, 오류 {len(records) - ok}건\")\n", + "\n", + "# 5) 오류 주입 시험\n", + "bad = json.loads(json.dumps(records[0]))\n", + "bad[\"phase\"] = \"assault\"\n", + "bad[\"intensity\"] = 1.7\n", + "del bad[\"dos\"]\n", + "bad[\"slot_id\"] = \"Z-1\"\n", + "bad[\"unit_size\"] = \"600명\"\n", + "print(\"\\n[오류 주입 결과]\")\n", + "for e in validate(bad):\n", + " print(\" -\", e)\n", + "bad2 = dict(records[1], item_code=\"fuel_jet\", item_class=\"탄약\")\n", + "print(\" -\", validate(bad2)[0])\n", + "\n", + "# 6) 데이터 딕셔너리와 CSR 기준표\n", + "dd = pd.DataFrame([(g, f, t.__name__, \", \".join(f\"{k}={v}\" for k, v in r.items()) or \"-\", d) for g, f, t, r, d in FIELDS],\n", + " columns=[\"그룹\", \"필드\", \"자료형\", \"규칙\", \"설명\"])\n", + "print(f\"\\n[데이터 딕셔너리] 필드 {len(dd)}개 (목표 20개 이상: {'달성' if len(dd) >= 20 else '미달'})\")\n", + "for g, s in dd.groupby(\"그룹\", sort=False)[\"필드\"]:\n", + " print(f\" {g}: {', '.join(s)}\")\n", + "print(\"\\n[작전 단계별 기준 소모율(CSR) - 가상 계수, 100명·1일 기준]\")\n", + "print(CSR.to_string())\n", + "print(\"\\n주의: 품목 코드·계수·저장고는 모두 교육용 가상 값이다. 실제 체계 연동 시 국방 표준 군수 코드(재고번호 등) 체계에 맞춰 필드를 대응시킨다.\")\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 01 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + "[업무 프로세스 맵] (1) 일일 소모 보고 수집 -> (2) 작전 단계·교전 강도 반영 소모량 산출 -> (3) 보유량 대비 보급 수준(DOS) 계산 -> (4) 재보급 소요·우선순위 결정(군수참모 결재) -> (5) 가상 저장고 입고·배치(위치점수·작업동선)\n", + "\n", + "정상 샘플 30건 중 검증 통과 30건, 오류 0건\n", + "\n", + "[오류 주입 결과]\n", + " - phase: 허용되지 않은 값 'assault'\n", + " - unit_size: 자료형 오류(str)\n", + " - intensity: 최대값 1 초과\n", + " - dos: 필수 필드 누락\n", + " - slot_id: 형식 불일치 'Z-1'\n", + " - item_class: 품목 코드와 분류 불일치\n", + "\n", + "[데이터 딕셔너리] 필드 23개 (목표 20개 이상: 달성)\n", + " 식별: record_id, unit_id, day_of_op\n", + " 작전: phase, unit_size, intensity, resistance, fire_missions, move_km, weather\n", + " 품목: item_code, item_class, csr_coeff, unit_per_pallet\n", + " 소모: consumption_qty, emergency_issue\n", + " 재고: on_hand_qty, dos\n", + " 저장: storage_id, slot_id, slot_distance_m, occupancy_rate\n", + " 관리: source\n", + "\n", + "[작전 단계별 기준 소모율(CSR) - 가상 계수, 100명·1일 기준]\n", + " ammo_A ammo_B ammo_C ammo_D fuel_jet fuel_diesel\n", + "prep 1.0 0.6 0.3 0.2 1.5 6.0\n", + "attack 6.0 3.5 2.0 1.2 5.0 12.0\n", + "defense 4.5 3.0 1.6 0.8 3.0 7.0\n", + "stability 0.8 0.5 0.3 0.2 1.0 5.0\n", + "\n", + "주의: 품목 코드·계수·저장고는 모두 교육용 가상 값이다. 실제 체계 연동 시 국방 표준 군수 코드(재고번호 등) 체계에 맞춰 필드를 대응시킨다.\n", + "```\n", + "
\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 02. 부대 규모, 교전 강도, 이동 거리 변수 정규화 및 소모량 산출 전처리 모듈 구현\n", + "**소요시간**: 약 2.0시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "부대 규모, 교전 강도, 이동 거리 변수 정규화 전처리 모듈을 구현한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **부대 규모, 교전 강도, 이동 거리 변수를 정제·정규화하고 병력 100명당 환산 소모율을 산출하는 전처리 모듈을 구현한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **중복 기록 제거, 표기 통일, 백분율·자릿수 오기 보정, 음수·이상치 결측 처리, KNN 결측 보정 등 정제 규칙 10종을 적용 수행하고 검증하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `src/etl/amm02_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": [ + "\"\"\"부대 규모·교전 강도·이동 거리 변수를 정제·정규화하고, 병력 100명당 환산 소모율을 산출하는 전처리 모듈을 구현한다. 정제 규칙 10종을 적용하고 긴급 불출 같은 군수 예외 기록은 삭제하지 않고 보존하는지 검증한다.\"\"\"\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", + "# [교육용가상자료] 가상 부대·가상 품목 일일 소모 기록 생성기 (실제 탄약 제원·소모율과 무관)\n", + "# 가상 부대 8개 x 90일. 소모량 = 가상 CSR 계수 x 병력 규모 x (교전 강도·적 저항·화력 계획 효과) x 잡음\n", + "# ---------------------------------------------------------------------------\n", + "UNITS = [f\"U{i:02d}\" for i in range(1, 9)]\n", + "ITEMS = [\"ammo_A\", \"ammo_B\", \"ammo_C\", \"ammo_D\", \"fuel_jet\", \"fuel_diesel\"]\n", + "AMMO, FUEL = ITEMS[:4], ITEMS[4:]\n", + "PHASES = [\"prep\", \"attack\", \"defense\", \"stability\"]\n", + "CSR = { # 가상 계수: 병력 100명·1일 기준 소모 단위(탄약=상자, 유류=드럼)\n", + " \"prep\": [1.0, 0.6, 0.3, 0.2, 1.5, 6.0],\n", + " \"attack\": [6.0, 3.5, 2.0, 1.2, 5.0, 12.0],\n", + " \"defense\": [4.5, 3.0, 1.6, 0.8, 3.0, 7.0],\n", + " \"stability\": [0.8, 0.5, 0.3, 0.2, 1.0, 5.0],\n", + "}\n", + "PHASE_RESIST = {\"prep\": 1.0, \"attack\": 3.2, \"defense\": 3.6, \"stability\": 0.8}\n", + "\n", + "\n", + "def make_ops(n_days=90, seed=7, surge=0):\n", + " \"\"\"가상 부대별 일일 작전 기록과 품목별 실제 소모량(정답)을 만든다. surge>0이면 마지막 surge일을 고강도 공격 단계로 바꾼다(스트레스 시험용).\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " rows = []\n", + " for uid in UNITS:\n", + " size = int(rng.integers(3, 9)) * 100\n", + " lens = rng.integers(8, 20, 4)\n", + " sched = [\"prep\"] * lens[0] + [\"attack\"] * lens[1] + [\"defense\"] * lens[2] + [\"attack\"] * lens[3]\n", + " sched = (sched + [\"stability\"] * n_days)[:n_days]\n", + " if surge:\n", + " sched[-surge:] = [\"attack\"] * surge\n", + " r = 1.0\n", + " for d, ph in enumerate(sched):\n", + " r = float(np.clip(0.7 * r + 0.3 * (4.8 if surge and d >= n_days - surge else PHASE_RESIST[ph]) + rng.normal(0, 0.35), 0, 5))\n", + " inten = float(np.clip(r / 5 * 0.9 + rng.normal(0, 0.06), 0, 1))\n", + " fire = int(rng.poisson(1 + 8 * inten))\n", + " move = float(rng.gamma(2, {\"prep\": 4, \"attack\": 12, \"defense\": 2, \"stability\": 5}[ph]))\n", + " weather = str(rng.choice([\"clear\", \"rain\", \"snow\"], p=[0.7, 0.2, 0.1]))\n", + " emerg = bool(rng.random() < 0.02)\n", + " rec = {\"unit\": uid, \"day\": d, \"phase\": ph, \"unit_size\": size, \"intensity\": round(inten, 3),\n", + " \"resistance\": round(r, 2), \"fire_missions\": fire, \"move_km\": round(move, 1),\n", + " \"weather\": weather, \"emergency\": emerg}\n", + " base = size / 100\n", + " for i, it in enumerate(ITEMS):\n", + " c = CSR[ph][i] * base\n", + " if it in AMMO:\n", + " c *= (0.5 + 0.6 * inten) * (1 + 0.04 * fire * (1.0 if it in (\"ammo_C\", \"ammo_D\") else 0.4))\n", + " elif it == \"fuel_jet\":\n", + " c *= (0.6 + 0.5 * inten) + 0.03 * fire\n", + " else:\n", + " c = c * (0.8 + 0.2 * (weather != \"clear\")) + 0.05 * move * base\n", + " c *= rng.lognormal(0, 0.12) * (2.5 if emerg else 1.0)\n", + " rec[it] = round(float(c), 1)\n", + " rows.append(rec)\n", + " return pd.DataFrame(rows)\n", + "# ---------------------------------------------------------------------------\n", + "\n", + "\n", + "# 1) [교육용가상자료] 원천 기록에 현장 입력 오류를 일부러 섞는다\n", + "clean_truth = make_ops(90, seed=7)\n", + "raw = clean_truth.copy()\n", + "rng = np.random.default_rng(3)\n", + "n = len(raw)\n", + "idx = lambda k: rng.choice(n, k, replace=False)\n", + "raw = raw.astype({\"phase\": object, \"weather\": object, \"unit_size\": float, \"fire_missions\": float})\n", + "raw.loc[idx(15), \"phase\"] = raw[\"phase\"].str.upper() # 표기 불일치\n", + "raw.loc[idx(20), \"weather\"] = \"비\" # 한글 표기 혼용\n", + "raw.loc[idx(12), \"intensity\"] = raw[\"intensity\"] * 100 # 백분율로 입력\n", + "raw.loc[idx(10), \"ammo_A\"] = -raw[\"ammo_A\"] # 부호 오류\n", + "raw.loc[idx(6), \"fire_missions\"] = -1\n", + "raw.loc[idx(8), \"move_km\"] = 999.0 # 계기 오류\n", + "raw.loc[idx(18), \"intensity\"] = np.nan # 결측\n", + "raw.loc[idx(14), \"move_km\"] = np.nan\n", + "raw.loc[idx(5), \"unit_size\"] = raw[\"unit_size\"] * 10 # 단위 오기(자릿수)\n", + "raw = pd.concat([raw, raw.sample(12, random_state=1)]).sample(frac=1, random_state=2).reset_index(drop=True) # 중복·순서 섞임\n", + "n_emerg = int(clean_truth[\"emergency\"].sum())\n", + "\n", + "# 2) 정제 규칙 10종\n", + "log = []\n", + "df = raw.copy()\n", + "before = len(df); df = df.drop_duplicates([\"unit\", \"day\"]); log.append((\"R01\", \"부대·일자 중복 기록 제거\", before - len(df)))\n", + "m = df[\"phase\"] != df[\"phase\"].str.strip().str.lower(); df[\"phase\"] = df[\"phase\"].str.strip().str.lower(); log.append((\"R02\", \"작전 단계 표기 통일\", int(m.sum())))\n", + "wmap = {\"비\": \"rain\", \"눈\": \"snow\", \"맑음\": \"clear\"}; m = df[\"weather\"].isin(wmap); df[\"weather\"] = df[\"weather\"].replace(wmap); log.append((\"R03\", \"기상 표기 통일(한글->코드)\", int(m.sum())))\n", + "m = df[\"intensity\"] > 1; df.loc[m, \"intensity\"] /= 100; log.append((\"R04\", \"교전 강도 백분율 입력 환산\", int(m.sum())))\n", + "m = df[\"unit_size\"] > 3000; df.loc[m, \"unit_size\"] /= 10; log.append((\"R05\", \"병력 규모 자릿수 오기 보정\", int(m.sum())))\n", + "m = df[ITEMS].lt(0).any(axis=1) | df[\"fire_missions\"].lt(0)\n", + "df[ITEMS] = df[ITEMS].mask(df[ITEMS] < 0); df[\"fire_missions\"] = df[\"fire_missions\"].mask(df[\"fire_missions\"] < 0); log.append((\"R06\", \"음수 소모량·화력 임무 결측 처리\", int(m.sum())))\n", + "m = (df[\"move_km\"] > 150) & ~df[\"emergency\"]; df.loc[m, \"move_km\"] = np.nan; log.append((\"R07\", \"이동 거리 이상치(150km 초과) 결측 처리\", int(m.sum())))\n", + "num = [\"unit_size\", \"intensity\", \"resistance\", \"fire_missions\", \"move_km\"] + ITEMS\n", + "n_missing = int(df[num].isna().any(axis=1).sum())\n", + "mu, sd = df[num].mean(), df[num].std()\n", + "df[num] = KNNImputer(n_neighbors=5).fit_transform((df[num] - mu) / sd) * sd.values + mu.values\n", + "df[\"fire_missions\"] = df[\"fire_missions\"].round(); log.append((\"R08\", \"결측값 KNN 보정(유사 기록 5건)\", n_missing))\n", + "df = df.sort_values([\"unit\", \"day\"]).reset_index(drop=True)\n", + "gaps = int(df.groupby(\"unit\")[\"day\"].apply(lambda s: (s.diff().dropna() != 1).sum()).sum()); log.append((\"R09\", \"부대별 일자 정렬·연속성 확인(누락일)\", gaps))\n", + "kept = int(df[\"emergency\"].sum()); log.append((\"R10\", \"긴급 불출 예외 기록 보존(삭제 금지)\", kept))\n", + "print(pd.DataFrame(log, columns=[\"규칙\", \"내용\", \"처리 건수\"]).to_string(index=False))\n", + "\n", + "# 3) 정규화와 환산 소모율 산출\n", + "for c in [\"unit_size\", \"intensity\", \"move_km\"]:\n", + " df[c + \"_n\"] = (df[c] - df[c].min()) / (df[c].max() - df[c].min())\n", + "for it in ITEMS:\n", + " df[it + \"_rate\"] = df[it] / (df[\"unit_size\"] / 100) # 병력 100명·1일 환산 소모율\n", + "\n", + "# 4) 정합성 검증\n", + "checks = {\n", + " \"중복 0건\": not df.duplicated([\"unit\", \"day\"]).any(),\n", + " \"결측 0건\": not df[num].isna().any().any(),\n", + " \"작전 단계 허용값\": df[\"phase\"].isin(PHASES).all(),\n", + " \"기상 허용값\": df[\"weather\"].isin([\"clear\", \"rain\", \"snow\"]).all(),\n", + " \"교전 강도 0~1\": df[\"intensity\"].between(0, 1).all(),\n", + " \"소모량 0 이상\": (df[ITEMS] >= 0).all().all(),\n", + " \"병력 규모 복원 일치\": (df[\"unit_size\"].values == clean_truth.sort_values([\"unit\", \"day\"])[\"unit_size\"].values).all(),\n", + " \"정규화 범위 0~1\": df[[c + \"_n\" for c in [\"unit_size\", \"intensity\", \"move_km\"]]].stack().between(0, 1).all(),\n", + " \"일자 연속성\": gaps == 0,\n", + " \"긴급 불출 기록 보존\": kept == n_emerg,\n", + "}\n", + "rate = np.mean(list(checks.values()))\n", + "print(f\"\\n정합성 검증 {sum(checks.values())}/{len(checks)} 통과, 통과율 {rate:.0%} (목표 100%: {'달성' if rate == 1 else '미달'})\")\n", + "print(f\"정제 규칙 {len(log)}종 적용, 원천 {len(raw)}건 -> 정제 {len(df)}건, 긴급 불출 {kept}건 보존\")\n", + "\n", + "# 5) 전후 기술통계 비교\n", + "cols = [\"unit_size\", \"intensity\", \"move_km\", \"ammo_A\"]\n", + "comp = pd.DataFrame({\"정제 전 평균\": raw[cols].mean(), \"정제 후 평균\": df[cols].mean(),\n", + " \"정제 전 최대\": raw[cols].max(), \"정제 후 최대\": df[cols].max()}).round(2)\n", + "print(\"\\n[정제 전후 기술통계]\")\n", + "print(comp.to_string())\n", + "print(\"\\n[작전 단계별 환산 소모율 평균(100명·1일, 가상 단위)]\")\n", + "print(df.groupby(\"phase\")[[it + \"_rate\" for it in ITEMS]].mean().round(2).rename(columns=lambda c: c[:-5]).to_string())\n", + "\n", + "fig, ax = plt.subplots(1, 3, figsize=(12, 3.5))\n", + "for a, c in zip(ax, [\"intensity\", \"move_km\", \"unit_size\"]):\n", + " a.hist(raw[c].dropna(), bins=30, alpha=0.5, label=\"raw\"); a.hist(df[c], bins=30, alpha=0.7, label=\"cleaned\")\n", + " a.set_title(c); a.set_yscale(\"log\"); a.legend()\n", + "plt.suptitle(\"AMM02 preprocessing: raw vs cleaned (synthetic)\"); plt.tight_layout(); plt.show()\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 02 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + " 규칙 내용 처리 건수\n", + "R01 부대·일자 중복 기록 제거 12\n", + "R02 작전 단계 표기 통일 15\n", + "R03 기상 표기 통일(한글->코드) 20\n", + "R04 교전 강도 백분율 입력 환산 12\n", + "R05 병력 규모 자릿수 오기 보정 5\n", + "R06 음수 소모량·화력 임무 결측 처리 16\n", + "R07 이동 거리 이상치(150km 초과) 결측 처리 8\n", + "R08 결측값 KNN 보정(유사 기록 5건) 54\n", + "R09 부대별 일자 정렬·연속성 확인(누락일) 0\n", + "R10 긴급 불출 예외 기록 보존(삭제 금지) 13\n", + "\n", + "정합성 검증 10/10 통과, 통과율 100% (목표 100%: 달성)\n", + "정제 규칙 10종 적용, 원천 732건 -> 정제 720건, 긴급 불출 13건 보존\n", + "\n", + "[정제 전후 기술통계]\n", + " 정제 전 평균 정제 후 평균 정제 전 최대 정제 후 최대\n", + "unit_size 688.66 650.00 8000.0 800.00\n", + "intensity 0.89 0.35 74.4 0.89\n", + "move_km 23.52 12.51 999.0 93.10\n", + "ammo_A 16.89 17.27 142.1 142.10\n", + "\n", + "[작전 단계별 환산 소모율 평균(100명·1일, 가상 단위)]\n", + " ammo_A ammo_B ammo_C ammo_D fuel_jet fuel_diesel\n", + "phase \n", + "attack 5.63 3.24 2.07 1.25 5.36 11.93\n", + "defense 4.61 3.08 1.82 0.93 3.55 6.58\n", + "prep 0.64 0.39 0.20 0.14 1.18 5.66\n", + "stability 0.50 0.32 0.20 0.13 0.77 4.86\n", + "```\n", + "
\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 03. 교전 일수 기반 1차 탄약/유류 기본 소모량 계산 Baseline 알고리즘 구현\n", + "**소요시간**: 약 2.5시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "교전 일수 기반 1차 탄약/유류 기본 소모량 계산 Baseline 알고리즘을 구현한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **교전 일수 x 작전 단계별 CSR x 병력 규모로 1차 탄약·유류 기본 소모량을 계산하는 Baseline 알고리즘을 구현하고 탐색적 분석으로 한계를 찾는다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **부대별 90일 계획량과 실제 소모량을 비교하고 품목별 일일 MAPE 산출하고 모델에 적용하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `notebooks/amm02_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": [ + "\"\"\"교전 일수 x 작전 단계별 기준 소모율(CSR) x 병력 규모로 1차 탄약·유류 기본 소모량을 계산하는 Baseline 알고리즘을 구현하고, 탐색적 분석(EDA 차트 8종, 상관·유의성 검정, 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", + "# [교육용가상자료] 가상 부대·가상 품목 일일 소모 기록 생성기 (실제 탄약 제원·소모율과 무관)\n", + "# 가상 부대 8개 x 90일. 소모량 = 가상 CSR 계수 x 병력 규모 x (교전 강도·적 저항·화력 계획 효과) x 잡음\n", + "# ---------------------------------------------------------------------------\n", + "UNITS = [f\"U{i:02d}\" for i in range(1, 9)]\n", + "ITEMS = [\"ammo_A\", \"ammo_B\", \"ammo_C\", \"ammo_D\", \"fuel_jet\", \"fuel_diesel\"]\n", + "AMMO, FUEL = ITEMS[:4], ITEMS[4:]\n", + "PHASES = [\"prep\", \"attack\", \"defense\", \"stability\"]\n", + "CSR = { # 가상 계수: 병력 100명·1일 기준 소모 단위(탄약=상자, 유류=드럼)\n", + " \"prep\": [1.0, 0.6, 0.3, 0.2, 1.5, 6.0],\n", + " \"attack\": [6.0, 3.5, 2.0, 1.2, 5.0, 12.0],\n", + " \"defense\": [4.5, 3.0, 1.6, 0.8, 3.0, 7.0],\n", + " \"stability\": [0.8, 0.5, 0.3, 0.2, 1.0, 5.0],\n", + "}\n", + "PHASE_RESIST = {\"prep\": 1.0, \"attack\": 3.2, \"defense\": 3.6, \"stability\": 0.8}\n", + "\n", + "\n", + "def make_ops(n_days=90, seed=7, surge=0):\n", + " \"\"\"가상 부대별 일일 작전 기록과 품목별 실제 소모량(정답)을 만든다. surge>0이면 마지막 surge일을 고강도 공격 단계로 바꾼다(스트레스 시험용).\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " rows = []\n", + " for uid in UNITS:\n", + " size = int(rng.integers(3, 9)) * 100\n", + " lens = rng.integers(8, 20, 4)\n", + " sched = [\"prep\"] * lens[0] + [\"attack\"] * lens[1] + [\"defense\"] * lens[2] + [\"attack\"] * lens[3]\n", + " sched = (sched + [\"stability\"] * n_days)[:n_days]\n", + " if surge:\n", + " sched[-surge:] = [\"attack\"] * surge\n", + " r = 1.0\n", + " for d, ph in enumerate(sched):\n", + " r = float(np.clip(0.7 * r + 0.3 * (4.8 if surge and d >= n_days - surge else PHASE_RESIST[ph]) + rng.normal(0, 0.35), 0, 5))\n", + " inten = float(np.clip(r / 5 * 0.9 + rng.normal(0, 0.06), 0, 1))\n", + " fire = int(rng.poisson(1 + 8 * inten))\n", + " move = float(rng.gamma(2, {\"prep\": 4, \"attack\": 12, \"defense\": 2, \"stability\": 5}[ph]))\n", + " weather = str(rng.choice([\"clear\", \"rain\", \"snow\"], p=[0.7, 0.2, 0.1]))\n", + " emerg = bool(rng.random() < 0.02)\n", + " rec = {\"unit\": uid, \"day\": d, \"phase\": ph, \"unit_size\": size, \"intensity\": round(inten, 3),\n", + " \"resistance\": round(r, 2), \"fire_missions\": fire, \"move_km\": round(move, 1),\n", + " \"weather\": weather, \"emergency\": emerg}\n", + " base = size / 100\n", + " for i, it in enumerate(ITEMS):\n", + " c = CSR[ph][i] * base\n", + " if it in AMMO:\n", + " c *= (0.5 + 0.6 * inten) * (1 + 0.04 * fire * (1.0 if it in (\"ammo_C\", \"ammo_D\") else 0.4))\n", + " elif it == \"fuel_jet\":\n", + " c *= (0.6 + 0.5 * inten) + 0.03 * fire\n", + " else:\n", + " c = c * (0.8 + 0.2 * (weather != \"clear\")) + 0.05 * move * base\n", + " c *= rng.lognormal(0, 0.12) * (2.5 if emerg else 1.0)\n", + " rec[it] = round(float(c), 1)\n", + " rows.append(rec)\n", + " return pd.DataFrame(rows)\n", + "# ---------------------------------------------------------------------------\n", + "\n", + "\n", + "def baseline(df):\n", + " \"\"\"Baseline: 해당 일의 작전 단계 CSR x (병력/100). 교전 강도·적 저항·화력 계획은 반영하지 않는다.\"\"\"\n", + " return pd.DataFrame({it: [CSR[p][i] * s / 100 for p, s in zip(df[\"phase\"], df[\"unit_size\"])] for i, it in enumerate(ITEMS)}, index=df.index)\n", + "\n", + "\n", + "df = make_ops(90, seed=7)\n", + "base = baseline(df)\n", + "\n", + "# 1) 교전 일수 기반 부대별 작전 기간 총소모량(계획) vs 실제\n", + "df[\"engaged\"] = df[\"phase\"].isin([\"attack\", \"defense\"])\n", + "plan = pd.concat([df[[\"unit\"]], base], axis=1).groupby(\"unit\").sum()\n", + "real = df.groupby(\"unit\")[ITEMS].sum()\n", + "days = df.groupby(\"unit\")[\"engaged\"].sum()\n", + "tot = pd.DataFrame({\"교전일수\": days, \"탄약 계획\": plan[AMMO].sum(axis=1).round(), \"탄약 실제\": real[AMMO].sum(axis=1).round(),\n", + " \"유류 계획\": plan[FUEL].sum(axis=1).round(), \"유류 실제\": real[FUEL].sum(axis=1).round()})\n", + "tot[\"탄약 오차율\"] = ((tot[\"탄약 계획\"] - tot[\"탄약 실제\"]) / tot[\"탄약 실제\"]).map(\"{:+.1%}\".format)\n", + "print(\"[부대별 90일 총소모량: Baseline 계획 vs 실제(가상 단위)]\")\n", + "print(tot.to_string())\n", + "\n", + "# 2) 일일 예측 오차(MAPE)\n", + "mape = (np.abs(base - df[ITEMS]) / df[ITEMS]).mean()\n", + "print(\"\\nBaseline 일일 MAPE:\", \", \".join(f\"{k} {v:.1%}\" for k, v in mape.items()))\n", + "\n", + "# 3) 상관·유의성 검정: 병력 100명당 탄약 소모율과 각 변수\n", + "df[\"ammo_rate\"] = df[AMMO].sum(axis=1) / (df[\"unit_size\"] / 100)\n", + "df[\"bad_weather\"] = (df[\"weather\"] != \"clear\").astype(int)\n", + "cand = [\"intensity\", \"resistance\", \"fire_missions\", \"move_km\", \"engaged\", \"day\", \"bad_weather\", \"unit_size\"]\n", + "rows = []\n", + "for c in cand:\n", + " r, p = stats.pearsonr(df[c].astype(float), df[\"ammo_rate\"])\n", + " rows.append({\"변수\": c, \"상관계수\": round(r, 3), \"p-value\": f\"{p:.1e}\", \"유의(p<0.05,|r|>=0.2)\": p < 0.05 and abs(r) >= 0.2})\n", + "corr = pd.DataFrame(rows).sort_values(\"상관계수\", key=abs, ascending=False)\n", + "n_sig = int(corr.iloc[:, 3].sum())\n", + "print(\"\\n[탄약 환산 소모율과의 상관·유의성]\")\n", + "print(corr.to_string(index=False))\n", + "print(f\"유의미한 상관 변수 {n_sig}개 (목표 5개 이상: {'달성' if n_sig >= 5 else '미달'})\")\n", + "\n", + "# 4) ABC 파레토 분석: 부대 x 탄종 조합별 총소모량(상자) 상위 20%\n", + "pareto = real[AMMO].stack().sort_values(ascending=False)\n", + "top = pareto.iloc[:int(np.ceil(len(pareto) * 0.2))]\n", + "print(f\"\\nABC 분석: 상위 20% 조합({len(top)}개)이 전체 소모량의 {top.sum() / pareto.sum():.1%}를 차지 -> A등급 집중 관리\")\n", + "print(\"A등급 예시:\", \", \".join(f\"{u}-{i}\" for u, i in top.index[:5]))\n", + "\n", + "# 5) EDA 차트 8종\n", + "fig, ax = plt.subplots(2, 4, figsize=(16, 7))\n", + "ax = ax.ravel()\n", + "df.groupby(\"phase\")[AMMO].mean().plot.bar(ax=ax[0], title=\"1. Mean ammo use by phase\")\n", + "ax[1].scatter(df[\"intensity\"], df[\"ammo_rate\"], s=6, alpha=0.5); ax[1].set_title(\"2. Intensity vs ammo rate\")\n", + "ax[2].scatter(df[\"fire_missions\"], df[\"ammo_rate\"], s=6, alpha=0.5, c=\"tab:red\"); ax[2].set_title(\"3. Fire missions vs ammo rate\")\n", + "ax[3].scatter(df[\"move_km\"], df[\"fuel_diesel\"] / (df[\"unit_size\"] / 100), s=6, alpha=0.5, c=\"tab:green\"); ax[3].set_title(\"4. Move km vs diesel rate\")\n", + "u = df[df[\"unit\"] == \"U01\"]\n", + "ax[4].plot(u[\"day\"], u[AMMO].sum(axis=1), label=\"actual\"); ax[4].plot(u[\"day\"], base.loc[u.index, AMMO].sum(axis=1), label=\"baseline\")\n", + "ax[4].legend(); ax[4].set_title(\"5. U01 daily ammo: actual vs baseline\")\n", + "im = ax[5].imshow(df[cand + [\"ammo_rate\"]].astype(float).corr(), cmap=\"coolwarm\", vmin=-1, vmax=1)\n", + "ax[5].set_xticks(range(9)); ax[5].set_xticklabels(cand + [\"ammo_rate\"], rotation=90, fontsize=7); ax[5].set_yticks([])\n", + "ax[5].set_title(\"6. Correlation heatmap\")\n", + "ax[6].bar(range(len(pareto)), pareto.cumsum() / pareto.sum()); ax[6].axhline(0.8, ls=\"--\", c=\"k\"); ax[6].set_title(\"7. Pareto (unit x ammo item)\")\n", + "ax[7].bar(mape.index, mape.values, color=\"gray\"); ax[7].set_title(\"8. Baseline MAPE by item\"); ax[7].tick_params(axis=\"x\", rotation=45)\n", + "plt.suptitle(\"AMM02 EDA - synthetic consumption data\"); plt.tight_layout(); plt.show()\n", + "print(\"EDA 차트 8종 생성 (목표 8종 이상: 달성)\")\n", + "print(\"핵심 발견: Baseline은 교전 강도·화력 계획을 반영하지 않아 고강도 일에는 과소, 저강도 일에는 과대 산출한다 -> 미션 4에서 변수 반영 모델로 고도화한다.\")\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 03 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + "[부대별 90일 총소모량: Baseline 계획 vs 실제(가상 단위)]\n", + " 교전일수 탄약 계획 탄약 실제 유류 계획 유류 실제 탄약 오차율\n", + "unit \n", + "U01 48 5114.0 5120.0 7716.0 7959.0 -0.1%\n", + "U02 40 3952.0 3885.0 6328.0 6678.0 +1.7%\n", + "U03 46 3725.0 3493.0 5715.0 5616.0 +6.6%\n", + "U04 41 2893.0 2586.0 4618.0 4504.0 +11.9%\n", + "U05 36 2139.0 1971.0 3574.0 3355.0 +8.5%\n", + "U06 34 4028.0 3598.0 6820.0 6404.0 +12.0%\n", + "U07 26 2945.0 2578.0 5408.0 5282.0 +14.2%\n", + "U08 49 4541.0 4124.0 6776.0 6643.0 +10.1%\n", + "\n", + "Baseline 일일 MAPE: ammo_A 43.8%, ammo_B 42.9%, ammo_C 38.1%, ammo_D 37.9%, fuel_jet 25.9%, fuel_diesel 16.8%\n", + "\n", + "[탄약 환산 소모율과의 상관·유의성]\n", + " 변수 상관계수 p-value 유의(p<0.05,|r|>=0.2)\n", + " engaged 0.923 1.4e-300 True\n", + " resistance 0.854 9.2e-206 True\n", + " intensity 0.838 1.1e-190 True\n", + "fire_missions 0.639 7.9e-84 True\n", + " day -0.378 6.9e-26 True\n", + " move_km 0.294 7.5e-16 True\n", + " bad_weather 0.072 5.2e-02 False\n", + " unit_size 0.017 6.4e-01 False\n", + "유의미한 상관 변수 6개 (목표 5개 이상: 달성)\n", + "\n", + "ABC 분석: 상위 20% 조합(7개)이 전체 소모량의 43.0%를 차지 -> A등급 집중 관리\n", + "A등급 예시: U01-ammo_A, U08-ammo_A, U02-ammo_A, U06-ammo_A, U03-ammo_A\n", + "EDA 차트 8종 생성 (목표 8종 이상: 달성)\n", + "핵심 발견: Baseline은 교전 강도·화력 계획을 반영하지 않아 고강도 일에는 과소, 저강도 일에는 과대 산출한다 -> 미션 4에서 변수 반영 모델로 고도화한다.\n", + "```\n", + "
\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 04. 적 저항 수준 및 화력 운용 계획 연계 일자별 소모량 시계열 예측 모델 고도화\n", + "**소요시간**: 약 3.0시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "작전 계획 연계 일자별 소모량 시계열 예측 모델을 고도화한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **적 저항 수준(전일 실적)과 화력 운용 계획을 연계한 파생 피처로 일자별 소모량 시계열 예측 모델을 고도화한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **전일·최근 평균 소모 비율, CSR 계수, 교전 중 화력 계획, 적 저항 추세 등 사전에 알 수 있는 정보만으로 파생 피처 10개 이상 작성하여 원인을 분석하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `src/features/amm02_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": [ + "\"\"\"적 저항 수준(전일 실적)과 화력 운용 계획(당일 계획 화력 임무 수)을 연계한 파생 피처를 만들고, 일자별 탄약·유류 소모량 시계열 예측 모델을 시계열 교차검증으로 평가해 Baseline(CSR 고정 계수)과 비교한다.\"\"\"\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", + "# [교육용가상자료] 가상 부대·가상 품목 일일 소모 기록 생성기 (실제 탄약 제원·소모율과 무관)\n", + "# 가상 부대 8개 x 90일. 소모량 = 가상 CSR 계수 x 병력 규모 x (교전 강도·적 저항·화력 계획 효과) x 잡음\n", + "# ---------------------------------------------------------------------------\n", + "UNITS = [f\"U{i:02d}\" for i in range(1, 9)]\n", + "ITEMS = [\"ammo_A\", \"ammo_B\", \"ammo_C\", \"ammo_D\", \"fuel_jet\", \"fuel_diesel\"]\n", + "AMMO, FUEL = ITEMS[:4], ITEMS[4:]\n", + "PHASES = [\"prep\", \"attack\", \"defense\", \"stability\"]\n", + "CSR = { # 가상 계수: 병력 100명·1일 기준 소모 단위(탄약=상자, 유류=드럼)\n", + " \"prep\": [1.0, 0.6, 0.3, 0.2, 1.5, 6.0],\n", + " \"attack\": [6.0, 3.5, 2.0, 1.2, 5.0, 12.0],\n", + " \"defense\": [4.5, 3.0, 1.6, 0.8, 3.0, 7.0],\n", + " \"stability\": [0.8, 0.5, 0.3, 0.2, 1.0, 5.0],\n", + "}\n", + "PHASE_RESIST = {\"prep\": 1.0, \"attack\": 3.2, \"defense\": 3.6, \"stability\": 0.8}\n", + "\n", + "\n", + "def make_ops(n_days=90, seed=7, surge=0):\n", + " \"\"\"가상 부대별 일일 작전 기록과 품목별 실제 소모량(정답)을 만든다. surge>0이면 마지막 surge일을 고강도 공격 단계로 바꾼다(스트레스 시험용).\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " rows = []\n", + " for uid in UNITS:\n", + " size = int(rng.integers(3, 9)) * 100\n", + " lens = rng.integers(8, 20, 4)\n", + " sched = [\"prep\"] * lens[0] + [\"attack\"] * lens[1] + [\"defense\"] * lens[2] + [\"attack\"] * lens[3]\n", + " sched = (sched + [\"stability\"] * n_days)[:n_days]\n", + " if surge:\n", + " sched[-surge:] = [\"attack\"] * surge\n", + " r = 1.0\n", + " for d, ph in enumerate(sched):\n", + " r = float(np.clip(0.7 * r + 0.3 * (4.8 if surge and d >= n_days - surge else PHASE_RESIST[ph]) + rng.normal(0, 0.35), 0, 5))\n", + " inten = float(np.clip(r / 5 * 0.9 + rng.normal(0, 0.06), 0, 1))\n", + " fire = int(rng.poisson(1 + 8 * inten))\n", + " move = float(rng.gamma(2, {\"prep\": 4, \"attack\": 12, \"defense\": 2, \"stability\": 5}[ph]))\n", + " weather = str(rng.choice([\"clear\", \"rain\", \"snow\"], p=[0.7, 0.2, 0.1]))\n", + " emerg = bool(rng.random() < 0.02)\n", + " rec = {\"unit\": uid, \"day\": d, \"phase\": ph, \"unit_size\": size, \"intensity\": round(inten, 3),\n", + " \"resistance\": round(r, 2), \"fire_missions\": fire, \"move_km\": round(move, 1),\n", + " \"weather\": weather, \"emergency\": emerg}\n", + " base = size / 100\n", + " for i, it in enumerate(ITEMS):\n", + " c = CSR[ph][i] * base\n", + " if it in AMMO:\n", + " c *= (0.5 + 0.6 * inten) * (1 + 0.04 * fire * (1.0 if it in (\"ammo_C\", \"ammo_D\") else 0.4))\n", + " elif it == \"fuel_jet\":\n", + " c *= (0.6 + 0.5 * inten) + 0.03 * fire\n", + " else:\n", + " c = c * (0.8 + 0.2 * (weather != \"clear\")) + 0.05 * move * base\n", + " c *= rng.lognormal(0, 0.12) * (2.5 if emerg else 1.0)\n", + " rec[it] = round(float(c), 1)\n", + " rows.append(rec)\n", + " return pd.DataFrame(rows)\n", + "# ---------------------------------------------------------------------------\n", + "\n", + "# ---- 소모량 예측용 파생 피처: 전일까지의 실적 + 당일 작전·화력 계획(사전에 알 수 있는 값만) ----\n", + "FEATS = [\"lag1\", \"lag2\", \"roll3\", \"roll7\", \"csr_base\", \"size100\", \"fire_plan\", \"fire_x_engaged\", \"resist_lag1\",\n", + " \"resist_trend\", \"inten_lag1\", \"move_plan\", \"days_in_phase\", \"engaged\", \"ph_attack\", \"ph_defense\", \"ph_prep\",\n", + " \"item_idx\", \"is_fuel\"]\n", + "DERIVED = [\"lag1\", \"lag2\", \"roll3\", \"roll7\", \"csr_base\", \"fire_x_engaged\", \"resist_lag1\", \"resist_trend\",\n", + " \"inten_lag1\", \"days_in_phase\", \"engaged\", \"ph_attack\", \"ph_defense\", \"ph_prep\"]\n", + "\n", + "\n", + "def build_long(ops):\n", + " \"\"\"부대x일자 기록을 (부대, 일자, 품목) 행으로 펼치고 파생 피처를 만든다.\n", + " 목표값 y = log(실제 소모량 / CSR 기준량): CSR 기준량에 곱할 '보정 계수'를 예측한다. lag·roll 피처도 같은 비율(실적/기준)이다.\"\"\"\n", + " df = ops.sort_values([\"unit\", \"day\"]).reset_index(drop=True)\n", + " g = df.groupby(\"unit\")\n", + " df[\"resist_lag1\"] = g[\"resistance\"].shift(1)\n", + " df[\"resist_trend\"] = df[\"resist_lag1\"] - g[\"resistance\"].shift(3)\n", + " df[\"inten_lag1\"] = g[\"intensity\"].shift(1)\n", + " df[\"days_in_phase\"] = df.groupby((df[\"phase\"] != g[\"phase\"].shift()).cumsum()).cumcount()\n", + " out = []\n", + " for i, it in enumerate(ITEMS):\n", + " csr = np.array([CSR[p][i] for p in df[\"phase\"]])\n", + " ratio = df[it] / (csr * df[\"unit_size\"] / 100)\n", + " prev = ratio.groupby(df[\"unit\"]).shift(1)\n", + " d = pd.DataFrame({\"unit\": df[\"unit\"], \"day\": df[\"day\"], \"phase\": df[\"phase\"], \"item\": it, \"item_idx\": i,\n", + " \"is_fuel\": int(it in FUEL), \"lag1\": prev, \"lag2\": ratio.groupby(df[\"unit\"]).shift(2),\n", + " \"roll3\": prev.groupby(df[\"unit\"]).transform(lambda s: s.rolling(3).mean()),\n", + " \"roll7\": prev.groupby(df[\"unit\"]).transform(lambda s: s.rolling(7, min_periods=3).mean()),\n", + " \"csr_base\": csr, \"size100\": df[\"unit_size\"] / 100,\n", + " \"fire_plan\": df[\"fire_missions\"], \"fire_x_engaged\": df[\"fire_missions\"] * df[\"phase\"].isin([\"attack\", \"defense\"]),\n", + " \"resist_lag1\": df[\"resist_lag1\"], \"resist_trend\": df[\"resist_trend\"], \"inten_lag1\": df[\"inten_lag1\"],\n", + " \"move_plan\": df[\"move_km\"], \"days_in_phase\": df[\"days_in_phase\"],\n", + " \"engaged\": df[\"phase\"].isin([\"attack\", \"defense\"]).astype(int),\n", + " \"ph_attack\": (df[\"phase\"] == \"attack\").astype(int), \"ph_defense\": (df[\"phase\"] == \"defense\").astype(int),\n", + " \"ph_prep\": (df[\"phase\"] == \"prep\").astype(int), \"emergency\": df[\"emergency\"],\n", + " \"y\": np.log(ratio), \"qty\": df[it]})\n", + " out.append(d)\n", + " return pd.concat(out, ignore_index=True).dropna(subset=[\"lag2\", \"roll3\", \"resist_trend\"]).reset_index(drop=True)\n", + "\n", + "\n", + "def to_qty(d, y_pred):\n", + " \"\"\"예측 보정 계수(log)를 소모량으로 되돌린다: 기준량 x exp(예측).\"\"\"\n", + " return np.exp(y_pred) * d[\"csr_base\"].values * d[\"size100\"].values\n", + "\n", + "\n", + "def mape(y, p):\n", + " return float(np.mean(np.abs(np.asarray(p) - np.asarray(y)) / np.asarray(y)))\n", + "\n", + "\n", + "data = build_long(make_ops(90, seed=7)).sort_values([\"day\", \"unit\", \"item\"]).reset_index(drop=True)\n", + "print(f\"학습 행 {len(data)}개(부대 8 x 품목 6 x 일자), 입력 피처 {len(FEATS)}개 중 파생 피처 {len(DERIVED)}개 (목표 10개 이상: {'달성' if len(DERIVED) >= 10 else '미달'})\")\n", + "\n", + "# 1) 시계열 교차검증: 과거 일자로 학습하고 이후 일자를 예측(미래 정보 누설 방지)\n", + "days = np.sort(data[\"day\"].unique())\n", + "rows = []\n", + "for k, (tr_d, te_d) in enumerate(TimeSeriesSplit(n_splits=5).split(days), start=1):\n", + " tr, te = data[data[\"day\"].isin(days[tr_d])], data[data[\"day\"].isin(days[te_d])]\n", + " m = GradientBoostingRegressor(n_estimators=150, max_depth=3, learning_rate=0.08, subsample=0.8, random_state=0)\n", + " m.fit(tr[FEATS], tr[\"y\"])\n", + " pred = to_qty(te, m.predict(te[FEATS]))\n", + " base = te[\"csr_base\"] * te[\"size100\"]\n", + " rows.append({\"폴드\": k, \"검증 일자\": f\"{days[te_d][0]}~{days[te_d][-1]}\", \"Baseline MAPE\": mape(te[\"qty\"], base),\n", + " \"모델 MAPE\": mape(te[\"qty\"], pred), \"전일 비율 유지 MAPE\": mape(te[\"qty\"], te[\"lag1\"] * te[\"csr_base\"] * te[\"size100\"])})\n", + "cv = pd.DataFrame(rows)\n", + "print(\"\\n[시계열 교차검증 결과]\")\n", + "print(cv.to_string(index=False, float_format=lambda x: f\"{x:.1%}\"))\n", + "avg = cv[\"모델 MAPE\"].mean()\n", + "print(f\"평균 MAPE: Baseline {cv['Baseline MAPE'].mean():.1%} -> 모델 {avg:.1%} (목표 25% 이하: {'달성' if avg <= 0.25 else '미달'})\")\n", + "\n", + "# 2) 최종 모델: 앞 70일 학습, 마지막 18일 검증\n", + "tr, te = data[data[\"day\"] < 72], data[data[\"day\"] >= 72]\n", + "model = GradientBoostingRegressor(n_estimators=150, max_depth=3, learning_rate=0.08, subsample=0.8, random_state=0).fit(tr[FEATS], tr[\"y\"])\n", + "te = te.assign(pred=to_qty(te, model.predict(te[FEATS])), base=te[\"csr_base\"] * te[\"size100\"])\n", + "by_item = te.groupby(\"item\")[[\"qty\", \"pred\", \"base\"]].apply(lambda g: pd.Series({\"Baseline\": mape(g[\"qty\"], g[\"base\"]), \"모델\": mape(g[\"qty\"], g[\"pred\"])}))\n", + "print(\"\\n[품목별 검증 MAPE(마지막 18일)]\")\n", + "print(by_item.T.to_string(float_format=lambda x: f\"{x:.1%}\"))\n", + "\n", + "# 3) 피처 중요도(차원 축소 후보 확인)\n", + "imp = pd.Series(model.feature_importances_, index=FEATS).sort_values(ascending=False)\n", + "print(\"\\n피처 중요도 상위 6:\", \", \".join(f\"{k}({v:.2f})\" for k, v in imp.head(6).items()))\n", + "print(\"중요도 0.01 미만(제거 후보):\", \", \".join(imp[imp < 0.01].index) or \"없음\")\n", + "\n", + "# 4) 시각화\n", + "full = data.assign(pred=to_qty(data, model.predict(data[FEATS])))\n", + "u = full[(full[\"unit\"] == \"U01\") & (full[\"item\"] == \"ammo_A\")]\n", + "fig, ax = plt.subplots(1, 2, figsize=(13, 4))\n", + "ax[0].plot(u[\"day\"], u[\"qty\"], label=\"actual\"); ax[0].plot(u[\"day\"], u[\"pred\"], label=\"model\")\n", + "ax[0].plot(u[\"day\"], u[\"csr_base\"] * u[\"size100\"], ls=\"--\", label=\"baseline (CSR)\"); ax[0].axvline(72, c=\"gray\", ls=\":\")\n", + "ax[0].set_title(\"U01 ammo_A daily forecast (right of dotted line = hold-out)\"); ax[0].set_xlabel(\"day\"); ax[0].legend()\n", + "imp.head(10)[::-1].plot.barh(ax=ax[1], title=\"Feature importance (top 10)\")\n", + "plt.tight_layout(); plt.show()\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 04 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + "학습 행 4176개(부대 8 x 품목 6 x 일자), 입력 피처 19개 중 파생 피처 14개 (목표 10개 이상: 달성)\n", + "\n", + "[시계열 교차검증 결과]\n", + " 폴드 검증 일자 Baseline MAPE 모델 MAPE 전일 비율 유지 MAPE\n", + " 1 20~33 15.4% 15.4% 23.0%\n", + " 2 34~47 17.7% 19.0% 20.4%\n", + " 3 48~61 33.4% 13.0% 17.3%\n", + " 4 62~75 51.8% 13.6% 21.7%\n", + " 5 76~89 48.7% 13.3% 21.8%\n", + "평균 MAPE: Baseline 33.4% -> 모델 14.9% (목표 25% 이하: 달성)\n", + "\n", + "[품목별 검증 MAPE(마지막 18일)]\n", + "item ammo_A ammo_B ammo_C ammo_D fuel_diesel fuel_jet\n", + "Baseline 66.2% 64.7% 60.0% 55.2% 15.4% 36.6%\n", + "모델 12.7% 12.3% 13.0% 13.6% 13.5% 12.6%\n", + "\n", + "피처 중요도 상위 6: roll3(0.25), resist_lag1(0.25), csr_base(0.09), fire_x_engaged(0.09), fire_plan(0.07), roll7(0.04)\n", + "중요도 0.01 미만(제거 후보): lag2, ph_defense, ph_attack, engaged, ph_prep\n", + "```\n", + "
\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 05. 부대별 일일 보급 수준(DOS) 연계 긴급 재보급 소요 산출 엔진 개발\n", + "**소요시간**: 약 3.5시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "보급 수준(DOS) 연계 긴급 재보급 소요 산출 엔진을 개발한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **앙상블 예측 모델과 보유량으로 부대·품목별 보급 수준(DOS)을 계산해 긴급 재보급 소요를 산출하는 엔진을 개발한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **GBR과 랜덤포레스트를 앙상블하고 가중치는 학습 기간 안의 검증 구간으로만 고른다 과정을 진행하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `src/models/amm02_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": [ + "\"\"\"GBR·랜덤포레스트 앙상블로 소모량 예측을 고도화해 Hold-out 기간에서 Baseline 대비 MAE/RMSE 개선율과 R2를 검증하고, 예측 소모량과 보유량으로 부대·품목별 보급 수준(DOS)을 계산해 긴급 재보급 소요를 산출하는 엔진을 구현한다.\"\"\"\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.metrics import mean_absolute_error, mean_squared_error, r2_score\n", + "\n", + "# ---------------------------------------------------------------------------\n", + "# [교육용가상자료] 가상 부대·가상 품목 일일 소모 기록 생성기 (실제 탄약 제원·소모율과 무관)\n", + "# 가상 부대 8개 x 90일. 소모량 = 가상 CSR 계수 x 병력 규모 x (교전 강도·적 저항·화력 계획 효과) x 잡음\n", + "# ---------------------------------------------------------------------------\n", + "UNITS = [f\"U{i:02d}\" for i in range(1, 9)]\n", + "ITEMS = [\"ammo_A\", \"ammo_B\", \"ammo_C\", \"ammo_D\", \"fuel_jet\", \"fuel_diesel\"]\n", + "AMMO, FUEL = ITEMS[:4], ITEMS[4:]\n", + "PHASES = [\"prep\", \"attack\", \"defense\", \"stability\"]\n", + "CSR = { # 가상 계수: 병력 100명·1일 기준 소모 단위(탄약=상자, 유류=드럼)\n", + " \"prep\": [1.0, 0.6, 0.3, 0.2, 1.5, 6.0],\n", + " \"attack\": [6.0, 3.5, 2.0, 1.2, 5.0, 12.0],\n", + " \"defense\": [4.5, 3.0, 1.6, 0.8, 3.0, 7.0],\n", + " \"stability\": [0.8, 0.5, 0.3, 0.2, 1.0, 5.0],\n", + "}\n", + "PHASE_RESIST = {\"prep\": 1.0, \"attack\": 3.2, \"defense\": 3.6, \"stability\": 0.8}\n", + "\n", + "\n", + "def make_ops(n_days=90, seed=7, surge=0):\n", + " \"\"\"가상 부대별 일일 작전 기록과 품목별 실제 소모량(정답)을 만든다. surge>0이면 마지막 surge일을 고강도 공격 단계로 바꾼다(스트레스 시험용).\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " rows = []\n", + " for uid in UNITS:\n", + " size = int(rng.integers(3, 9)) * 100\n", + " lens = rng.integers(8, 20, 4)\n", + " sched = [\"prep\"] * lens[0] + [\"attack\"] * lens[1] + [\"defense\"] * lens[2] + [\"attack\"] * lens[3]\n", + " sched = (sched + [\"stability\"] * n_days)[:n_days]\n", + " if surge:\n", + " sched[-surge:] = [\"attack\"] * surge\n", + " r = 1.0\n", + " for d, ph in enumerate(sched):\n", + " r = float(np.clip(0.7 * r + 0.3 * (4.8 if surge and d >= n_days - surge else PHASE_RESIST[ph]) + rng.normal(0, 0.35), 0, 5))\n", + " inten = float(np.clip(r / 5 * 0.9 + rng.normal(0, 0.06), 0, 1))\n", + " fire = int(rng.poisson(1 + 8 * inten))\n", + " move = float(rng.gamma(2, {\"prep\": 4, \"attack\": 12, \"defense\": 2, \"stability\": 5}[ph]))\n", + " weather = str(rng.choice([\"clear\", \"rain\", \"snow\"], p=[0.7, 0.2, 0.1]))\n", + " emerg = bool(rng.random() < 0.02)\n", + " rec = {\"unit\": uid, \"day\": d, \"phase\": ph, \"unit_size\": size, \"intensity\": round(inten, 3),\n", + " \"resistance\": round(r, 2), \"fire_missions\": fire, \"move_km\": round(move, 1),\n", + " \"weather\": weather, \"emergency\": emerg}\n", + " base = size / 100\n", + " for i, it in enumerate(ITEMS):\n", + " c = CSR[ph][i] * base\n", + " if it in AMMO:\n", + " c *= (0.5 + 0.6 * inten) * (1 + 0.04 * fire * (1.0 if it in (\"ammo_C\", \"ammo_D\") else 0.4))\n", + " elif it == \"fuel_jet\":\n", + " c *= (0.6 + 0.5 * inten) + 0.03 * fire\n", + " else:\n", + " c = c * (0.8 + 0.2 * (weather != \"clear\")) + 0.05 * move * base\n", + " c *= rng.lognormal(0, 0.12) * (2.5 if emerg else 1.0)\n", + " rec[it] = round(float(c), 1)\n", + " rows.append(rec)\n", + " return pd.DataFrame(rows)\n", + "# ---------------------------------------------------------------------------\n", + "\n", + "# ---- 소모량 예측용 파생 피처: 전일까지의 실적 + 당일 작전·화력 계획(사전에 알 수 있는 값만) ----\n", + "FEATS = [\"lag1\", \"lag2\", \"roll3\", \"roll7\", \"csr_base\", \"size100\", \"fire_plan\", \"fire_x_engaged\", \"resist_lag1\",\n", + " \"resist_trend\", \"inten_lag1\", \"move_plan\", \"days_in_phase\", \"engaged\", \"ph_attack\", \"ph_defense\", \"ph_prep\",\n", + " \"item_idx\", \"is_fuel\"]\n", + "DERIVED = [\"lag1\", \"lag2\", \"roll3\", \"roll7\", \"csr_base\", \"fire_x_engaged\", \"resist_lag1\", \"resist_trend\",\n", + " \"inten_lag1\", \"days_in_phase\", \"engaged\", \"ph_attack\", \"ph_defense\", \"ph_prep\"]\n", + "\n", + "\n", + "def build_long(ops):\n", + " \"\"\"부대x일자 기록을 (부대, 일자, 품목) 행으로 펼치고 파생 피처를 만든다.\n", + " 목표값 y = log(실제 소모량 / CSR 기준량): CSR 기준량에 곱할 '보정 계수'를 예측한다. lag·roll 피처도 같은 비율(실적/기준)이다.\"\"\"\n", + " df = ops.sort_values([\"unit\", \"day\"]).reset_index(drop=True)\n", + " g = df.groupby(\"unit\")\n", + " df[\"resist_lag1\"] = g[\"resistance\"].shift(1)\n", + " df[\"resist_trend\"] = df[\"resist_lag1\"] - g[\"resistance\"].shift(3)\n", + " df[\"inten_lag1\"] = g[\"intensity\"].shift(1)\n", + " df[\"days_in_phase\"] = df.groupby((df[\"phase\"] != g[\"phase\"].shift()).cumsum()).cumcount()\n", + " out = []\n", + " for i, it in enumerate(ITEMS):\n", + " csr = np.array([CSR[p][i] for p in df[\"phase\"]])\n", + " ratio = df[it] / (csr * df[\"unit_size\"] / 100)\n", + " prev = ratio.groupby(df[\"unit\"]).shift(1)\n", + " d = pd.DataFrame({\"unit\": df[\"unit\"], \"day\": df[\"day\"], \"phase\": df[\"phase\"], \"item\": it, \"item_idx\": i,\n", + " \"is_fuel\": int(it in FUEL), \"lag1\": prev, \"lag2\": ratio.groupby(df[\"unit\"]).shift(2),\n", + " \"roll3\": prev.groupby(df[\"unit\"]).transform(lambda s: s.rolling(3).mean()),\n", + " \"roll7\": prev.groupby(df[\"unit\"]).transform(lambda s: s.rolling(7, min_periods=3).mean()),\n", + " \"csr_base\": csr, \"size100\": df[\"unit_size\"] / 100,\n", + " \"fire_plan\": df[\"fire_missions\"], \"fire_x_engaged\": df[\"fire_missions\"] * df[\"phase\"].isin([\"attack\", \"defense\"]),\n", + " \"resist_lag1\": df[\"resist_lag1\"], \"resist_trend\": df[\"resist_trend\"], \"inten_lag1\": df[\"inten_lag1\"],\n", + " \"move_plan\": df[\"move_km\"], \"days_in_phase\": df[\"days_in_phase\"],\n", + " \"engaged\": df[\"phase\"].isin([\"attack\", \"defense\"]).astype(int),\n", + " \"ph_attack\": (df[\"phase\"] == \"attack\").astype(int), \"ph_defense\": (df[\"phase\"] == \"defense\").astype(int),\n", + " \"ph_prep\": (df[\"phase\"] == \"prep\").astype(int), \"emergency\": df[\"emergency\"],\n", + " \"y\": np.log(ratio), \"qty\": df[it]})\n", + " out.append(d)\n", + " return pd.concat(out, ignore_index=True).dropna(subset=[\"lag2\", \"roll3\", \"resist_trend\"]).reset_index(drop=True)\n", + "\n", + "\n", + "def to_qty(d, y_pred):\n", + " \"\"\"예측 보정 계수(log)를 소모량으로 되돌린다: 기준량 x exp(예측).\"\"\"\n", + " return np.exp(y_pred) * d[\"csr_base\"].values * d[\"size100\"].values\n", + "\n", + "\n", + "def mape(y, p):\n", + " return float(np.mean(np.abs(np.asarray(p) - np.asarray(y)) / np.asarray(y)))\n", + "\n", + "# ---- 소모량 예측 모델(앙상블) 학습·예측 ----\n", + "def fit_models(tr, seed=0):\n", + " \"\"\"GBR + 랜덤포레스트 두 모델을 학습한다(목표값은 CSR 기준량 대비 log 보정 계수).\"\"\"\n", + " g = GradientBoostingRegressor(n_estimators=150, max_depth=3, learning_rate=0.08, subsample=0.8, random_state=seed).fit(tr[FEATS], tr[\"y\"])\n", + " r = RandomForestRegressor(n_estimators=120, min_samples_leaf=3, random_state=seed, n_jobs=1).fit(tr[FEATS], tr[\"y\"])\n", + " return g, r\n", + "\n", + "\n", + "def predict_qty(models, d, w=0.5):\n", + " \"\"\"두 모델의 log 예측을 가중 평균해 소모량으로 되돌린다.\"\"\"\n", + " g, r = models\n", + " return to_qty(d, w * g.predict(d[FEATS]) + (1 - w) * r.predict(d[FEATS]))\n", + "\n", + "DOS_ALERT = 2.0 # [교육용가상자료] 긴급 재보급 경보 기준(보급 수준, 일)\n", + "DOS_TARGET = 3.0 # 재보급 후 목표 보급 수준(일)\n", + "\n", + "data = build_long(make_ops(90, seed=7))\n", + "tr, te = data[data[\"day\"] < 60], data[data[\"day\"] >= 60].copy()\n", + "val = tr[tr[\"day\"] >= 48]\n", + "\n", + "# 1) 앙상블 가중치 튜닝: 학습 기간 마지막 12일(검증용)로 고르고, Hold-out(60~89일)은 최종 평가에만 사용\n", + "m_inner = fit_models(tr[tr[\"day\"] < 48])\n", + "best_w = min(np.arange(0, 1.01, 0.25), key=lambda w: mape(val[\"qty\"], predict_qty(m_inner, val, w)))\n", + "models = fit_models(tr)\n", + "te[\"base\"] = te[\"csr_base\"] * te[\"size100\"]\n", + "te[\"gbr\"] = to_qty(te, models[0].predict(te[FEATS]))\n", + "te[\"ens\"] = predict_qty(models, te, best_w)\n", + "\n", + "\n", + "def scores(col, d):\n", + " \"\"\"오차율은 CSR 기준량 대비 비율(환산 소모율) 척도로 계산해 품목 간 단위 차이를 없앤다. R2는 소모량 척도.\"\"\"\n", + " k = d[\"csr_base\"] * d[\"size100\"]\n", + " y, p = d[\"qty\"] / k, d[col] / k\n", + " return {\"MAE(비율)\": float((y - p).abs().mean()), \"RMSE(비율)\": float(np.sqrt(((y - p) ** 2).mean())),\n", + " \"MAPE\": mape(d[\"qty\"], d[col]), \"R2(전체)\": r2_score(d[\"qty\"], d[col]),\n", + " \"R2(정상소모)\": r2_score(d.loc[~d[\"emergency\"], \"qty\"], d.loc[~d[\"emergency\"], col])}\n", + "\n", + "\n", + "res = pd.DataFrame({\"Baseline(CSR)\": scores(\"base\", te), \"단일 GBR\": scores(\"gbr\", te), f\"앙상블(w={best_w:.2f})\": scores(\"ens\", te)}).T\n", + "print(\"[Hold-out 60~89일 성능, 부대 8 x 품목 6]\")\n", + "print(res.to_string(float_format=lambda x: f\"{x:.3f}\"))\n", + "b, e = res.iloc[0], res.iloc[2]\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", + "print(f\"R2 전체 {e['R2(전체)']:.3f} ({'달성' if e['R2(전체)'] >= 0.85 else '미달'}), 긴급 불출 제외 정상 소모 {e['R2(정상소모)']:.3f} (목표 0.85 이상: {'달성' if e['R2(정상소모)'] >= 0.85 else '미달'})\")\n", + "print(\"-> 전체 R2 미달 원인은 사전 정보가 없는 긴급 불출 급증(2~3배)이다. 예측 모델이 아니라 긴급 경보·예비 재고로 대응한다.\")\n", + "em = te[te[\"emergency\"]]\n", + "print(f\"비정형 수요(긴급 불출 {len(em)}건) MAPE: Baseline {mape(em['qty'], em['base']):.1%}, 앙상블 {mape(em['qty'], em['ens']):.1%} -> 급증은 사전 정보가 없어 별도 경보로 보완\")\n", + "\n", + "# 2) DOS 연계 긴급 재보급 엔진: 75일 아침 기준, 보유량(가상) / 예측 일일 소모량\n", + "rng = np.random.default_rng(11)\n", + "day = te[te[\"day\"] == 75].copy()\n", + "day[\"on_hand\"] = np.round(day[\"base\"] * rng.uniform(1.0, 5.0, len(day)), 0)\n", + "day[\"dos\"] = day[\"on_hand\"] / day[\"ens\"]\n", + "day[\"need\"] = np.maximum(0, np.ceil(DOS_TARGET * day[\"ens\"] - day[\"on_hand\"]))\n", + "day[\"flag\"] = np.where(day[\"dos\"] < DOS_ALERT, \"긴급\", np.where(day[\"dos\"] < DOS_TARGET, \"주의\", \"정상\"))\n", + "pri = {\"긴급\": 0, \"주의\": 1, \"정상\": 2}\n", + "out = day.sort_values([\"flag\", \"dos\"], key=lambda s: s.map(pri) if s.name == \"flag\" else s)\n", + "print(f\"\\n[75일 재보급 소요(DOS {DOS_ALERT:.0f}일 미만 긴급, 목표 {DOS_TARGET:.0f}일)] 긴급 {sum(day['flag'] == '긴급')}건, 주의 {sum(day['flag'] == '주의')}건, 정상 {sum(day['flag'] == '정상')}건\")\n", + "show = out[out[\"flag\"] != \"정상\"].head(10)[[\"unit\", \"item\", \"phase\", \"on_hand\", \"ens\", \"dos\", \"need\", \"flag\"]]\n", + "show.columns = [\"부대\", \"품목\", \"작전단계\", \"보유량\", \"예측소모/일\", \"DOS(일)\", \"재보급량\", \"판정\"]\n", + "print(show.to_string(index=False, float_format=lambda x: f\"{x:.1f}\"))\n", + "real_dos = day[\"on_hand\"] / day[\"qty\"]\n", + "hit = ((day[\"dos\"] < DOS_ALERT) == (real_dos < DOS_ALERT)).mean()\n", + "print(f\"긴급 판정 일치율(실제 소모 기준 DOS와 비교): {hit:.1%}\")\n", + "print(\"재보급 소요는 군수참모 결재 전 추천값이며 자동 불출하지 않는다.\")\n", + "\n", + "fig, ax = plt.subplots(1, 2, figsize=(12, 4))\n", + "ax[0].scatter(te[\"qty\"], te[\"base\"], s=5, alpha=0.4, label=\"baseline\")\n", + "ax[0].scatter(te[\"qty\"], te[\"ens\"], s=5, alpha=0.4, label=\"ensemble\")\n", + "mx = te[\"qty\"].max(); ax[0].plot([0, mx], [0, mx], \"k--\", lw=1)\n", + "ax[0].set_xlabel(\"actual\"); ax[0].set_ylabel(\"predicted\"); ax[0].set_title(\"Hold-out actual vs predicted\"); ax[0].legend()\n", + "piv = day.pivot(index=\"unit\", columns=\"item\", values=\"dos\").clip(upper=6)\n", + "im = ax[1].imshow(piv.values, cmap=\"RdYlGn\", vmin=0, vmax=6, aspect=\"auto\")\n", + "ax[1].set_xticks(range(len(piv.columns))); ax[1].set_xticklabels(piv.columns, rotation=30)\n", + "ax[1].set_yticks(range(len(piv.index))); ax[1].set_yticklabels(piv.index)\n", + "ax[1].set_title(\"Days of supply by unit/item (day 75)\"); plt.colorbar(im, ax=ax[1])\n", + "plt.tight_layout(); plt.show()\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 05 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + "[Hold-out 60~89일 성능, 부대 8 x 품목 6]\n", + " MAE(비율) RMSE(비율) MAPE R2(전체) R2(정상소모)\n", + "Baseline(CSR) 0.313 0.371 0.493 0.828 0.956\n", + "단일 GBR 0.110 0.211 0.140 0.827 0.963\n", + "앙상블(w=0.25) 0.107 0.210 0.133 0.827 0.966\n", + "오차율 개선: MAE 66.0%, RMSE 43.2% (목표 20% 이상: 달성)\n", + "R2 전체 0.827 (미달), 긴급 불출 제외 정상 소모 0.966 (목표 0.85 이상: 달성)\n", + "-> 전체 R2 미달 원인은 사전 정보가 없는 긴급 불출 급증(2~3배)이다. 예측 모델이 아니라 긴급 경보·예비 재고로 대응한다.\n", + "비정형 수요(긴급 불출 24건) MAPE: Baseline 38.7%, 앙상블 58.8% -> 급증은 사전 정보가 없어 별도 경보로 보완\n", + "\n", + "[75일 재보급 소요(DOS 2일 미만 긴급, 목표 3일)] 긴급 4건, 주의 14건, 정상 30건\n", + " 부대 품목 작전단계 보유량 예측소모/일 DOS(일) 재보급량 판정\n", + "U01 fuel_diesel stability 45.0 35.7 1.3 62.0 긴급\n", + "U08 fuel_diesel stability 37.0 29.2 1.3 51.0 긴급\n", + "U01 fuel_jet stability 11.0 6.2 1.8 8.0 긴급\n", + "U04 ammo_A stability 4.0 2.2 1.8 3.0 긴급\n", + "U02 fuel_diesel stability 63.0 31.4 2.0 32.0 주의\n", + "U07 ammo_A stability 7.0 3.5 2.0 4.0 주의\n", + "U06 ammo_D stability 2.0 1.0 2.1 1.0 주의\n", + "U01 ammo_A stability 10.0 4.5 2.2 4.0 주의\n", + "U05 ammo_A stability 5.0 2.3 2.2 2.0 주의\n", + "U07 ammo_B stability 5.0 2.2 2.3 2.0 주의\n", + "긴급 판정 일치율(실제 소모 기준 DOS와 비교): 95.8%\n", + "재보급 소요는 군수참모 결재 전 추천값이며 자동 불출하지 않는다.\n", + "```\n", + "
\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 06. 작전 지도 연계 탄약/유류 소모 예측 및 재보급 소요 가시화 대시보드 구현\n", + "**소요시간**: 약 3.0시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "작전 지도 연계 탄약/유류 소모 예측 및 재보급 대시보드를 구현한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **가상 작전 지도에 부대별 탄약·유류 예측 소모와 보급 상태를 표시하고, 예측 건마다 기여도 상위 3개 인자와 자연어 설명을 자동 생성한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **가상 격자 좌표 지도에 부대별 예측 소모량과 교전 여부를 표시 수행하고 검증하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `src/explainability/amm02_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": [ + "\"\"\"작전 지도(가상 격자 좌표)에 부대별 탄약·유류 예측 소모량과 보급 수준(DOS)을 표시하는 대시보드를 만들고, 예측 건마다 기여도 상위 3개 인자를 대체값 비교(occlusion) 방식으로 자동 산출해 자연어 설명문을 생성한다(SHAP summary·waterfall 대체 그림 포함).\"\"\"\n", + "import numpy as np\n", + "import pandas as pd\n", + "import matplotlib.pyplot as plt\n", + "from sklearn.ensemble import GradientBoostingRegressor, RandomForestRegressor\n", + "\n", + "# ---------------------------------------------------------------------------\n", + "# [교육용가상자료] 가상 부대·가상 품목 일일 소모 기록 생성기 (실제 탄약 제원·소모율과 무관)\n", + "# 가상 부대 8개 x 90일. 소모량 = 가상 CSR 계수 x 병력 규모 x (교전 강도·적 저항·화력 계획 효과) x 잡음\n", + "# ---------------------------------------------------------------------------\n", + "UNITS = [f\"U{i:02d}\" for i in range(1, 9)]\n", + "ITEMS = [\"ammo_A\", \"ammo_B\", \"ammo_C\", \"ammo_D\", \"fuel_jet\", \"fuel_diesel\"]\n", + "AMMO, FUEL = ITEMS[:4], ITEMS[4:]\n", + "PHASES = [\"prep\", \"attack\", \"defense\", \"stability\"]\n", + "CSR = { # 가상 계수: 병력 100명·1일 기준 소모 단위(탄약=상자, 유류=드럼)\n", + " \"prep\": [1.0, 0.6, 0.3, 0.2, 1.5, 6.0],\n", + " \"attack\": [6.0, 3.5, 2.0, 1.2, 5.0, 12.0],\n", + " \"defense\": [4.5, 3.0, 1.6, 0.8, 3.0, 7.0],\n", + " \"stability\": [0.8, 0.5, 0.3, 0.2, 1.0, 5.0],\n", + "}\n", + "PHASE_RESIST = {\"prep\": 1.0, \"attack\": 3.2, \"defense\": 3.6, \"stability\": 0.8}\n", + "\n", + "\n", + "def make_ops(n_days=90, seed=7, surge=0):\n", + " \"\"\"가상 부대별 일일 작전 기록과 품목별 실제 소모량(정답)을 만든다. surge>0이면 마지막 surge일을 고강도 공격 단계로 바꾼다(스트레스 시험용).\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " rows = []\n", + " for uid in UNITS:\n", + " size = int(rng.integers(3, 9)) * 100\n", + " lens = rng.integers(8, 20, 4)\n", + " sched = [\"prep\"] * lens[0] + [\"attack\"] * lens[1] + [\"defense\"] * lens[2] + [\"attack\"] * lens[3]\n", + " sched = (sched + [\"stability\"] * n_days)[:n_days]\n", + " if surge:\n", + " sched[-surge:] = [\"attack\"] * surge\n", + " r = 1.0\n", + " for d, ph in enumerate(sched):\n", + " r = float(np.clip(0.7 * r + 0.3 * (4.8 if surge and d >= n_days - surge else PHASE_RESIST[ph]) + rng.normal(0, 0.35), 0, 5))\n", + " inten = float(np.clip(r / 5 * 0.9 + rng.normal(0, 0.06), 0, 1))\n", + " fire = int(rng.poisson(1 + 8 * inten))\n", + " move = float(rng.gamma(2, {\"prep\": 4, \"attack\": 12, \"defense\": 2, \"stability\": 5}[ph]))\n", + " weather = str(rng.choice([\"clear\", \"rain\", \"snow\"], p=[0.7, 0.2, 0.1]))\n", + " emerg = bool(rng.random() < 0.02)\n", + " rec = {\"unit\": uid, \"day\": d, \"phase\": ph, \"unit_size\": size, \"intensity\": round(inten, 3),\n", + " \"resistance\": round(r, 2), \"fire_missions\": fire, \"move_km\": round(move, 1),\n", + " \"weather\": weather, \"emergency\": emerg}\n", + " base = size / 100\n", + " for i, it in enumerate(ITEMS):\n", + " c = CSR[ph][i] * base\n", + " if it in AMMO:\n", + " c *= (0.5 + 0.6 * inten) * (1 + 0.04 * fire * (1.0 if it in (\"ammo_C\", \"ammo_D\") else 0.4))\n", + " elif it == \"fuel_jet\":\n", + " c *= (0.6 + 0.5 * inten) + 0.03 * fire\n", + " else:\n", + " c = c * (0.8 + 0.2 * (weather != \"clear\")) + 0.05 * move * base\n", + " c *= rng.lognormal(0, 0.12) * (2.5 if emerg else 1.0)\n", + " rec[it] = round(float(c), 1)\n", + " rows.append(rec)\n", + " return pd.DataFrame(rows)\n", + "# ---------------------------------------------------------------------------\n", + "\n", + "# ---- 소모량 예측용 파생 피처: 전일까지의 실적 + 당일 작전·화력 계획(사전에 알 수 있는 값만) ----\n", + "FEATS = [\"lag1\", \"lag2\", \"roll3\", \"roll7\", \"csr_base\", \"size100\", \"fire_plan\", \"fire_x_engaged\", \"resist_lag1\",\n", + " \"resist_trend\", \"inten_lag1\", \"move_plan\", \"days_in_phase\", \"engaged\", \"ph_attack\", \"ph_defense\", \"ph_prep\",\n", + " \"item_idx\", \"is_fuel\"]\n", + "DERIVED = [\"lag1\", \"lag2\", \"roll3\", \"roll7\", \"csr_base\", \"fire_x_engaged\", \"resist_lag1\", \"resist_trend\",\n", + " \"inten_lag1\", \"days_in_phase\", \"engaged\", \"ph_attack\", \"ph_defense\", \"ph_prep\"]\n", + "\n", + "\n", + "def build_long(ops):\n", + " \"\"\"부대x일자 기록을 (부대, 일자, 품목) 행으로 펼치고 파생 피처를 만든다.\n", + " 목표값 y = log(실제 소모량 / CSR 기준량): CSR 기준량에 곱할 '보정 계수'를 예측한다. lag·roll 피처도 같은 비율(실적/기준)이다.\"\"\"\n", + " df = ops.sort_values([\"unit\", \"day\"]).reset_index(drop=True)\n", + " g = df.groupby(\"unit\")\n", + " df[\"resist_lag1\"] = g[\"resistance\"].shift(1)\n", + " df[\"resist_trend\"] = df[\"resist_lag1\"] - g[\"resistance\"].shift(3)\n", + " df[\"inten_lag1\"] = g[\"intensity\"].shift(1)\n", + " df[\"days_in_phase\"] = df.groupby((df[\"phase\"] != g[\"phase\"].shift()).cumsum()).cumcount()\n", + " out = []\n", + " for i, it in enumerate(ITEMS):\n", + " csr = np.array([CSR[p][i] for p in df[\"phase\"]])\n", + " ratio = df[it] / (csr * df[\"unit_size\"] / 100)\n", + " prev = ratio.groupby(df[\"unit\"]).shift(1)\n", + " d = pd.DataFrame({\"unit\": df[\"unit\"], \"day\": df[\"day\"], \"phase\": df[\"phase\"], \"item\": it, \"item_idx\": i,\n", + " \"is_fuel\": int(it in FUEL), \"lag1\": prev, \"lag2\": ratio.groupby(df[\"unit\"]).shift(2),\n", + " \"roll3\": prev.groupby(df[\"unit\"]).transform(lambda s: s.rolling(3).mean()),\n", + " \"roll7\": prev.groupby(df[\"unit\"]).transform(lambda s: s.rolling(7, min_periods=3).mean()),\n", + " \"csr_base\": csr, \"size100\": df[\"unit_size\"] / 100,\n", + " \"fire_plan\": df[\"fire_missions\"], \"fire_x_engaged\": df[\"fire_missions\"] * df[\"phase\"].isin([\"attack\", \"defense\"]),\n", + " \"resist_lag1\": df[\"resist_lag1\"], \"resist_trend\": df[\"resist_trend\"], \"inten_lag1\": df[\"inten_lag1\"],\n", + " \"move_plan\": df[\"move_km\"], \"days_in_phase\": df[\"days_in_phase\"],\n", + " \"engaged\": df[\"phase\"].isin([\"attack\", \"defense\"]).astype(int),\n", + " \"ph_attack\": (df[\"phase\"] == \"attack\").astype(int), \"ph_defense\": (df[\"phase\"] == \"defense\").astype(int),\n", + " \"ph_prep\": (df[\"phase\"] == \"prep\").astype(int), \"emergency\": df[\"emergency\"],\n", + " \"y\": np.log(ratio), \"qty\": df[it]})\n", + " out.append(d)\n", + " return pd.concat(out, ignore_index=True).dropna(subset=[\"lag2\", \"roll3\", \"resist_trend\"]).reset_index(drop=True)\n", + "\n", + "\n", + "def to_qty(d, y_pred):\n", + " \"\"\"예측 보정 계수(log)를 소모량으로 되돌린다: 기준량 x exp(예측).\"\"\"\n", + " return np.exp(y_pred) * d[\"csr_base\"].values * d[\"size100\"].values\n", + "\n", + "\n", + "def mape(y, p):\n", + " return float(np.mean(np.abs(np.asarray(p) - np.asarray(y)) / np.asarray(y)))\n", + "\n", + "# ---- 소모량 예측 모델(앙상블) 학습·예측 ----\n", + "def fit_models(tr, seed=0):\n", + " \"\"\"GBR + 랜덤포레스트 두 모델을 학습한다(목표값은 CSR 기준량 대비 log 보정 계수).\"\"\"\n", + " g = GradientBoostingRegressor(n_estimators=150, max_depth=3, learning_rate=0.08, subsample=0.8, random_state=seed).fit(tr[FEATS], tr[\"y\"])\n", + " r = RandomForestRegressor(n_estimators=120, min_samples_leaf=3, random_state=seed, n_jobs=1).fit(tr[FEATS], tr[\"y\"])\n", + " return g, r\n", + "\n", + "\n", + "def predict_qty(models, d, w=0.5):\n", + " \"\"\"두 모델의 log 예측을 가중 평균해 소모량으로 되돌린다.\"\"\"\n", + " g, r = models\n", + " return to_qty(d, w * g.predict(d[FEATS]) + (1 - w) * r.predict(d[FEATS]))\n", + "\n", + "KOR = {\"lag1\": \"전일 소모 비율\", \"lag2\": \"2일 전 소모 비율\", \"roll3\": \"최근 3일 평균 소모 비율\", \"roll7\": \"최근 7일 평균 소모 비율\",\n", + " \"csr_base\": \"작전 단계 CSR 계수\", \"size100\": \"병력 규모\", \"fire_plan\": \"계획 화력 임무 수\", \"fire_x_engaged\": \"교전 중 화력 계획\",\n", + " \"resist_lag1\": \"전일 적 저항 수준\", \"resist_trend\": \"적 저항 추세\", \"inten_lag1\": \"전일 교전 강도\", \"move_plan\": \"계획 이동 거리\",\n", + " \"days_in_phase\": \"단계 경과일\", \"engaged\": \"교전 단계 여부\", \"ph_attack\": \"공격 단계\", \"ph_defense\": \"방어 단계\",\n", + " \"ph_prep\": \"준비 단계\", \"item_idx\": \"품목\", \"is_fuel\": \"유류 여부\"}\n", + "\n", + "data = build_long(make_ops(90, seed=7))\n", + "tr = data[data[\"day\"] < 40]\n", + "models = fit_models(tr)\n", + "D = 41 # 대시보드 기준일(교전 단계가 섞인 날)\n", + "day = data[data[\"day\"] == D].reset_index(drop=True)\n", + "day[\"pred\"] = predict_qty(models, day)\n", + "background = tr[FEATS].median()\n", + "\n", + "\n", + "def logpred(X):\n", + " return 0.5 * models[0].predict(X) + 0.5 * models[1].predict(X)\n", + "\n", + "\n", + "def explain(X):\n", + " \"\"\"각 피처를 학습 기간 중앙값으로 바꿨을 때 예측(log 보정 계수)이 얼마나 변하는지 = 그 피처의 기여도.\"\"\"\n", + " base = logpred(X)\n", + " C = np.zeros((len(X), len(FEATS)))\n", + " for j, f in enumerate(FEATS):\n", + " Z = X.copy(); Z[f] = background[f]\n", + " C[:, j] = base - logpred(Z)\n", + " return pd.DataFrame(C, columns=FEATS)\n", + "\n", + "\n", + "C = explain(day[FEATS])\n", + "top3 = C.abs().apply(lambda r: list(r.sort_values(ascending=False).index[:3]), axis=1)\n", + "cover = np.mean([len(t) == 3 for t in top3])\n", + "print(f\"[기준일 {D}일] 예측 {len(day)}건(부대 8 x 품목 6), 상위 3개 인자 자동 산출률 {cover:.0%} (목표 100%: {'달성' if cover == 1 else '미달'})\")\n", + "\n", + "\n", + "def sentence(i):\n", + " r = day.loc[i]; parts = []\n", + " for f in top3[i]:\n", + " eff = np.exp(C.loc[i, f]) - 1\n", + " parts.append(f\"{KOR[f]}({'+' if eff >= 0 else ''}{eff:.0%})\")\n", + " return f\"{r['unit']} {r['item']} 예측 {r['pred']:.1f}(기준량 대비 {r['pred'] / (r['csr_base'] * r['size100']):.2f}배): 주요 원인 \" + \", \".join(parts)\n", + "\n", + "\n", + "print(\"\\n[부대별 최대 소모 품목 설명(자연어 요약)]\")\n", + "lead = day.assign(rel=day[\"pred\"] / (day[\"csr_base\"] * day[\"size100\"])).sort_values(\"rel\", ascending=False).groupby(\"unit\").head(1)\n", + "for i in lead.sort_values(\"unit\").index:\n", + " print(\" -\", sentence(i))\n", + "\n", + "imp = C.abs().mean().sort_values(ascending=False)\n", + "print(\"\\n[전체 평균 기여도 상위 5(요약 그림)]\", \", \".join(f\"{KOR[k]} {v:.3f}\" for k, v in imp.head(5).items()))\n", + "recon = np.corrcoef(C.sum(axis=1), logpred(day[FEATS]) - logpred(pd.DataFrame([background] * len(day))))[0, 1]\n", + "print(f\"설명 정합성: 기여도 합과 (예측-기준 예측)의 상관 {recon:.2f} (1에 가까울수록 설명이 예측을 잘 분해)\")\n", + "print(\"설명은 판단 보조 근거이며 재보급 결정은 군수참모가 근거를 확인해 내린다.\")\n", + "\n", + "# 대시보드: 가상 작전 지도 + 기여도 그림\n", + "rng = np.random.default_rng(2)\n", + "pos = {u: (rng.uniform(0, 50), rng.uniform(0, 30)) for u in UNITS}\n", + "ag = day.groupby(\"unit\").apply(lambda g: pd.Series({\"ammo\": g.loc[g[\"is_fuel\"] == 0, \"pred\"].sum(), \"fuel\": g.loc[g[\"is_fuel\"] == 1, \"pred\"].sum(),\n", + " \"phase\": g[\"phase\"].iloc[0]}), include_groups=False)\n", + "fig, ax = plt.subplots(1, 3, figsize=(16, 4.5))\n", + "for u, r in ag.iterrows():\n", + " x, y = pos[u]\n", + " ax[0].scatter(x, y, s=r[\"ammo\"] * 8 + 20, c=\"tab:red\" if r[\"phase\"] in (\"attack\", \"defense\") else \"tab:blue\", alpha=0.6)\n", + " ax[0].annotate(f\"{u}\\nA{r['ammo']:.0f}/F{r['fuel']:.0f}\", (x, y), fontsize=8, ha=\"center\")\n", + "ax[0].set_title(f\"Virtual op map day {D} (size=ammo forecast, red=engaged)\"); ax[0].set_xlabel(\"grid x\"); ax[0].set_ylabel(\"grid y\")\n", + "imp.head(10)[::-1].rename(index=lambda k: k).plot.barh(ax=ax[1], title=\"Mean |contribution| (summary)\")\n", + "i0 = lead.index[0]; w = C.loc[i0].reindex(C.loc[i0].abs().sort_values(ascending=False).index[:6])\n", + "ax[2].barh(w.index[::-1], w.values[::-1], color=[\"tab:red\" if v > 0 else \"tab:green\" for v in w.values[::-1]])\n", + "ax[2].set_title(f\"Waterfall-style: {day.loc[i0, 'unit']} {day.loc[i0, 'item']} (log ratio)\")\n", + "plt.tight_layout(); plt.show()\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 06 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + "[기준일 41일] 예측 48건(부대 8 x 품목 6), 상위 3개 인자 자동 산출률 100% (목표 100%: 달성)\n", + "\n", + "[부대별 최대 소모 품목 설명(자연어 요약)]\n", + " - U01 fuel_jet 예측 25.6(기준량 대비 1.07배): 주요 원인 전일 적 저항 수준(-13%), 최근 7일 평균 소모 비율(+5%), 2일 전 소모 비율(-3%)\n", + " - U02 ammo_D 예측 8.9(기준량 대비 1.05배): 주요 원인 전일 적 저항 수준(-14%), 최근 7일 평균 소모 비율(+3%), 최근 3일 평균 소모 비율(-1%)\n", + " - U03 ammo_D 예측 4.9(기준량 대비 1.01배): 주요 원인 전일 적 저항 수준(-12%), 최근 7일 평균 소모 비율(+4%), 단계 경과일(-2%)\n", + " - U04 fuel_diesel 예측 61.6(기준량 대비 1.03배): 주요 원인 계획 이동 거리(+11%), 전일 적 저항 수준(-8%), 품목(+8%)\n", + " - U05 ammo_C 예측 9.1(기준량 대비 1.14배): 주요 원인 전일 적 저항 수준(-11%), 계획 화력 임무 수(+6%), 교전 중 화력 계획(+4%)\n", + " - U06 ammo_D 예측 10.1(기준량 대비 1.05배): 주요 원인 전일 적 저항 수준(-12%), 최근 7일 평균 소모 비율(+3%), 품목(+1%)\n", + " - U07 fuel_diesel 예측 33.8(기준량 대비 0.97배): 주요 원인 품목(+18%), 전일 적 저항 수준(-14%), 작전 단계 CSR 계수(+13%)\n", + " - U08 ammo_D 예측 6.0(기준량 대비 1.08배): 주요 원인 전일 적 저항 수준(-16%), 적 저항 추세(+3%), 최근 7일 평균 소모 비율(+3%)\n", + "\n", + "[전체 평균 기여도 상위 5(요약 그림)] 전일 적 저항 수준 0.158, 작전 단계 CSR 계수 0.038, 품목 0.026, 계획 화력 임무 수 0.023, 최근 7일 평균 소모 비율 0.021\n", + "설명 정합성: 기여도 합과 (예측-기준 예측)의 상관 0.91 (1에 가까울수록 설명이 예측을 잘 분해)\n", + "설명은 판단 보조 근거이며 재보급 결정은 군수참모가 근거를 확인해 내린다.\n", + "```\n", + "
\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 07. 보급 우선순위 지정 및 대체 탄종 불출을 위한 군수참모 HITL 결재선 설계\n", + "**소요시간**: 약 3.5시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "보급 우선순위 지정을 위한 군수참모 HITL 결재선을 설계한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **가상 저장고 배치를 최적화하고, 재배치·긴급 재보급 우선순위·대체 탄종 불출 제안을 군수참모 결재선(HITL)으로 처리하는 시뮬레이터를 설계한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **저장칸 용량·현재 점유·가상 혼적 제한을 제약으로 두고 반출 빈도 x 동선 거리와 공간낭비를 최소화하는 할당 문제(정수계획)를 풀어 현행(입고순) 배치와 작업비용·공간낭비율·위치점수를 비교 수행하고 검증하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `src/optimization/amm02_simulator.py` 형태의 결과물을 완성한다.\n", + "\n", + "### 🔍 평가 및 검증 기준 (PASS 조건)\n", + "#### 미션 판정\n", + "\n", + "**평가 구분:** 필수\n", + "\n", + "**핵심 평가영역:** 현실적 군수 제약조건 반영도 및 시나리오별 대안 제시 적절성\n", + "\n", + "#### 미션 PASS 조건\n", + "\n", + "- 정량 기준: 최적화 적용 시 비용 15% 절감 또는 재고 회전율 20% 향상\n", + "\n", + "\n", + "- 진행 기준: 선형/정수 계획법(LP/MIP) 최적화 해 도출 및 시뮬레이션 완료\n", + "\n", + "\n", + "- 검증 방법: 과거 실적 데이터 대비 최적화 결과 비교 백테스팅\n", + "\n", + "\n", + "#### 채점자 판정 메모\n", + "\n", + "> 단순 코드 실행 여부뿐만 아니라 출력된 수치와 근거 자료가 군 실무 요구사항에 부합하는지 면밀히 확인한다.\n", + "\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "\"\"\"가상 저장고의 품목 배치를 할당 문제(정수계획)로 최적화해 작업동선 비용·공간낭비·위치점수를 현행 배치와 비교하고, 재배치·긴급 재보급 우선순위·대체 탄종 불출 제안을 자동 확정하지 않고 군수참모 결재선(승인·수정·반려·무응답)에 올려 사유와 함께 해시 체인 감사 로그에 남기는 HITL 시뮬레이터를 구현한다.\"\"\"\n", + "import hashlib\n", + "import json\n", + "import numpy as np\n", + "import pandas as pd\n", + "import matplotlib.pyplot as plt\n", + "from scipy.optimize import linear_sum_assignment\n", + "\n", + "# ---------------------------------------------------------------------------\n", + "# [교육용가상자료] 가상 부대·가상 품목 일일 소모 기록 생성기 (실제 탄약 제원·소모율과 무관)\n", + "# 가상 부대 8개 x 90일. 소모량 = 가상 CSR 계수 x 병력 규모 x (교전 강도·적 저항·화력 계획 효과) x 잡음\n", + "# ---------------------------------------------------------------------------\n", + "UNITS = [f\"U{i:02d}\" for i in range(1, 9)]\n", + "ITEMS = [\"ammo_A\", \"ammo_B\", \"ammo_C\", \"ammo_D\", \"fuel_jet\", \"fuel_diesel\"]\n", + "AMMO, FUEL = ITEMS[:4], ITEMS[4:]\n", + "PHASES = [\"prep\", \"attack\", \"defense\", \"stability\"]\n", + "CSR = { # 가상 계수: 병력 100명·1일 기준 소모 단위(탄약=상자, 유류=드럼)\n", + " \"prep\": [1.0, 0.6, 0.3, 0.2, 1.5, 6.0],\n", + " \"attack\": [6.0, 3.5, 2.0, 1.2, 5.0, 12.0],\n", + " \"defense\": [4.5, 3.0, 1.6, 0.8, 3.0, 7.0],\n", + " \"stability\": [0.8, 0.5, 0.3, 0.2, 1.0, 5.0],\n", + "}\n", + "PHASE_RESIST = {\"prep\": 1.0, \"attack\": 3.2, \"defense\": 3.6, \"stability\": 0.8}\n", + "\n", + "\n", + "def make_ops(n_days=90, seed=7, surge=0):\n", + " \"\"\"가상 부대별 일일 작전 기록과 품목별 실제 소모량(정답)을 만든다. surge>0이면 마지막 surge일을 고강도 공격 단계로 바꾼다(스트레스 시험용).\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " rows = []\n", + " for uid in UNITS:\n", + " size = int(rng.integers(3, 9)) * 100\n", + " lens = rng.integers(8, 20, 4)\n", + " sched = [\"prep\"] * lens[0] + [\"attack\"] * lens[1] + [\"defense\"] * lens[2] + [\"attack\"] * lens[3]\n", + " sched = (sched + [\"stability\"] * n_days)[:n_days]\n", + " if surge:\n", + " sched[-surge:] = [\"attack\"] * surge\n", + " r = 1.0\n", + " for d, ph in enumerate(sched):\n", + " r = float(np.clip(0.7 * r + 0.3 * (4.8 if surge and d >= n_days - surge else PHASE_RESIST[ph]) + rng.normal(0, 0.35), 0, 5))\n", + " inten = float(np.clip(r / 5 * 0.9 + rng.normal(0, 0.06), 0, 1))\n", + " fire = int(rng.poisson(1 + 8 * inten))\n", + " move = float(rng.gamma(2, {\"prep\": 4, \"attack\": 12, \"defense\": 2, \"stability\": 5}[ph]))\n", + " weather = str(rng.choice([\"clear\", \"rain\", \"snow\"], p=[0.7, 0.2, 0.1]))\n", + " emerg = bool(rng.random() < 0.02)\n", + " rec = {\"unit\": uid, \"day\": d, \"phase\": ph, \"unit_size\": size, \"intensity\": round(inten, 3),\n", + " \"resistance\": round(r, 2), \"fire_missions\": fire, \"move_km\": round(move, 1),\n", + " \"weather\": weather, \"emergency\": emerg}\n", + " base = size / 100\n", + " for i, it in enumerate(ITEMS):\n", + " c = CSR[ph][i] * base\n", + " if it in AMMO:\n", + " c *= (0.5 + 0.6 * inten) * (1 + 0.04 * fire * (1.0 if it in (\"ammo_C\", \"ammo_D\") else 0.4))\n", + " elif it == \"fuel_jet\":\n", + " c *= (0.6 + 0.5 * inten) + 0.03 * fire\n", + " else:\n", + " c = c * (0.8 + 0.2 * (weather != \"clear\")) + 0.05 * move * base\n", + " c *= rng.lognormal(0, 0.12) * (2.5 if emerg else 1.0)\n", + " rec[it] = round(float(c), 1)\n", + " rows.append(rec)\n", + " return pd.DataFrame(rows)\n", + "# ---------------------------------------------------------------------------\n", + "\n", + "# ---------------------------------------------------------------------------\n", + "# [교육용가상자료] 가상 저장고(H1~H3) 저장칸 배치 모델. 실제 탄약 저장 기준·안전거리와 무관한 가상 계수다.\n", + "# 저장칸: 저장고 x 열(row) x 칸(col). 동선 거리 = 반출장(입구)까지의 가상 거리(m).\n", + "# 저장군(G1~G3)은 가상 혼적 제한 그룹이며, 저장고마다 받을 수 있는 저장군이 정해져 있다.\n", + "# ---------------------------------------------------------------------------\n", + "HOUSES = {\"H1\": {\"groups\": {\"G1\", \"G2\"}, \"rows\": 2, \"cols\": 6, \"dock\": 15.0},\n", + " \"H2\": {\"groups\": {\"G1\"}, \"rows\": 2, \"cols\": 5, \"dock\": 40.0},\n", + " \"H3\": {\"groups\": {\"G2\", \"G3\"}, \"rows\": 2, \"cols\": 6, \"dock\": 70.0}}\n", + "ITEM_GROUP = {\"ammo_A\": \"G1\", \"ammo_B\": \"G1\", \"ammo_C\": \"G2\", \"ammo_D\": \"G3\"}\n", + "SLOT_CAP = 6 # 1열 저장칸 가상 팔레트 수용량(2열은 +4)\n", + "\n", + "\n", + "def make_slots(seed=3):\n", + " \"\"\"저장칸 목록과 현재 점유(이동 불가 기존 재고) 여부를 만든다.\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " rows = []\n", + " for h, c in HOUSES.items():\n", + " for r in range(c[\"rows\"]):\n", + " for k in range(c[\"cols\"]):\n", + " rows.append({\"slot\": f\"{h}-{r + 1}{k + 1:02d}\", \"house\": h, \"dist_m\": c[\"dock\"] + 6.0 * k + 4.0 * r,\n", + " \"cap\": SLOT_CAP if r == 0 else SLOT_CAP + 4, \"fixed\": bool(rng.random() < 0.15)})\n", + " return pd.DataFrame(rows)\n", + "\n", + "\n", + "def make_skus(demand, seed=5):\n", + " \"\"\"품목별 예측 일일 반출량(demand: 품목->상자/일)을 로트 단위 SKU로 나눈다. 반출 빈도 = 일일 반출 횟수(가상).\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " rows = []\n", + " for it, dq in demand.items():\n", + " n_lot = 5\n", + " share = rng.dirichlet(np.ones(n_lot) * 2)\n", + " for j in range(n_lot):\n", + " rows.append({\"sku\": f\"{it}-L{j + 1}\", \"item\": it, \"group\": ITEM_GROUP[it],\n", + " \"pallets\": int(rng.integers(2, SLOT_CAP + 5)), \"freq\": round(float(dq * share[j] / 4), 2)})\n", + " return pd.DataFrame(rows)\n", + "\n", + "\n", + "def feasible(sku, slot):\n", + " return (sku[\"group\"] in HOUSES[slot[\"house\"]][\"groups\"]) and sku[\"pallets\"] <= slot[\"cap\"] and not slot[\"fixed\"]\n", + "\n", + "\n", + "def travel(assign, skus, slots):\n", + " \"\"\"작업동선 = 반출 빈도 x 왕복 거리 합(가상 m/일).\"\"\"\n", + " d = slots.set_index(\"slot\")[\"dist_m\"]\n", + " return float(sum(2 * skus.loc[i, \"freq\"] * d[s] for i, s in assign.items()))\n", + "\n", + "\n", + "def first_fit(skus, slots):\n", + " \"\"\"현행 방식(대안 비교 기준): 입고 순서대로 비어 있는 첫 번째 적합 칸에 넣는다.\"\"\"\n", + " used, out = set(), {}\n", + " for i, k in skus.iterrows():\n", + " for _, s in slots.iterrows():\n", + " if s[\"slot\"] not in used and feasible(k, s):\n", + " out[i] = s[\"slot\"]; used.add(s[\"slot\"]); break\n", + " return out\n", + "\n", + "\n", + "def optimize(skus, slots, lam=8):\n", + " \"\"\"할당 문제(정수계획 특수형): 비용 = 반출 빈도 x 왕복거리 + lam x 빈 공간(공간낭비). 부적합 칸은 큰 비용.\"\"\"\n", + " cost = np.full((len(skus), len(slots)), 1e7)\n", + " for i, k in skus.iterrows():\n", + " for j, s in slots.iterrows():\n", + " if feasible(k, s):\n", + " cost[i, j] = 2 * k[\"freq\"] * s[\"dist_m\"] + lam * (s[\"cap\"] - k[\"pallets\"])\n", + " ri, cj = linear_sum_assignment(cost)\n", + " return {int(i): slots.loc[j, \"slot\"] for i, j in zip(ri, cj) if cost[i, j] < 1e7}, cost\n", + "\n", + "\n", + "def loc_score(assign, skus, slots):\n", + " \"\"\"위치점수(0~100): 반출 빈도 상위 SKU일수록 반출장 가까운 칸에 있는 정도(가중 평균).\"\"\"\n", + " d = slots.set_index(\"slot\")[\"dist_m\"]; dmin, dmax = d.min(), d.max()\n", + " w = skus[\"freq\"] / skus[\"freq\"].sum()\n", + " return float(100 * sum(w[i] * (1 - (d[s] - dmin) / (dmax - dmin)) for i, s in assign.items()))\n", + "# ---------------------------------------------------------------------------\n", + "\n", + "COST_PER_M = 0.02 # [교육용가상자료] 지게차 왕복 1m당 가상 작업비용 지수\n", + "SUB_RATE = {\"ammo_C\": (\"ammo_D\", 0.8)} # 가상 대체 탄종과 환산 계수(교육용 가정값)\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", + "# 1) 반출 수요: 최근 14일 품목별 일일 평균 소모(미션 5 예측값 대신 사용, 가상 상자/일)\n", + "ops = make_ops(90, seed=7)\n", + "demand = ops[ops[\"day\"] >= 76][AMMO].sum().div(14).round(1).to_dict()\n", + "skus, slots = make_skus(demand), make_slots()\n", + "print(f\"[가상 저장고] 저장칸 {len(slots)}개(현재 점유·이동 불가 {slots['fixed'].sum()}개), SKU {len(skus)}개, 일일 반출 수요 {demand}\")\n", + "\n", + "cur = first_fit(skus, slots)\n", + "opt, cost = optimize(skus, slots)\n", + "\n", + "\n", + "def kpi(a):\n", + " used = slots.set_index(\"slot\").loc[list(a.values()), \"cap\"].sum()\n", + " waste = 1 - skus.loc[list(a.keys()), \"pallets\"].sum() / used\n", + " t = travel(a, skus, slots)\n", + " return {\"작업동선(m/일)\": t, \"작업비용 지수\": t * COST_PER_M, \"공간낭비율\": waste, \"위치점수\": loc_score(a, skus, slots), \"배치 SKU\": len(a)}\n", + "\n", + "\n", + "kp = pd.DataFrame({\"현행(입고순)\": kpi(cur), \"최적화\": kpi(opt)}).T\n", + "print(kp.to_string(float_format=lambda x: f\"{x:.2f}\"))\n", + "cut = 1 - kp.loc[\"최적화\", \"작업비용 지수\"] / kp.loc[\"현행(입고순)\", \"작업비용 지수\"]\n", + "print(f\"작업비용 절감률 {cut:.1%} (목표 15% 이상: {'달성' if cut >= 0.15 else '미달'})\")\n", + "\n", + "top = skus[\"freq\"].idxmax()\n", + "alt = [(slots.loc[j, \"slot\"], cost[top, j]) for j in np.argsort(cost[top]) if cost[top, j] < 1e7][:3]\n", + "print(f\"대안배치(반출 최다 {skus.loc[top, 'sku']}): \" + \", \".join(f\"{s}(비용 {c:.0f})\" for s, c in alt) + \" -> 1안 점유 시 2·3안 순으로 제시\")\n", + "\n", + "# 2) 민감도: 돌발 수요(가상 전시 전환으로 ammo_A 반출 2배)\n", + "sk2 = skus.copy(); sk2.loc[sk2[\"item\"] == \"ammo_A\", \"freq\"] *= 2\n", + "o2, _ = optimize(sk2, slots)\n", + "moved = sum(o2[i] != opt[i] for i in opt)\n", + "print(f\"민감도(ammo_A 반출 2배): 최적 배치 변경 {moved}칸, 작업동선 현행 {travel(cur, sk2, slots):.0f} -> 재최적화 {travel(o2, sk2, slots):.0f} m/일\")\n", + "\n", + "# 3) HITL 결재선: 제안은 모두 '결재 대기'로 시작하며 자동 확정하지 않는다\n", + "moves = [(skus.loc[i, \"sku\"], cur.get(i), opt[i], skus.loc[i, \"freq\"]) for i in opt if cur.get(i) != opt[i]]\n", + "moves.sort(key=lambda m: -m[3])\n", + "props = [{\"id\": f\"P{n + 1:02d}\", \"type\": \"재배치\", \"what\": f\"{s} {a}->{b}\", \"evid\": f\"반출 {f:.1f}회/일, 동선 감소\"} for n, (s, a, b, f) in enumerate(moves[:6])]\n", + "rng = np.random.default_rng(4)\n", + "dos = {u: round(float(rng.uniform(0.8, 4.0)), 1) for u in UNITS}\n", + "urgent = sorted([u for u in UNITS if dos[u] < 2.0], key=lambda u: dos[u])\n", + "props += [{\"id\": f\"P{len(props) + k + 1:02d}\", \"type\": \"재보급 우선순위\", \"what\": f\"{u} 탄약 우선 불출({k + 1}순위)\", \"evid\": f\"DOS {dos[u]}일\"} for k, u in enumerate(urgent)]\n", + "src, (dst, rate) = \"ammo_C\", SUB_RATE[\"ammo_C\"]\n", + "props.append({\"id\": f\"P{len(props) + 1:02d}\", \"type\": \"대체 탄종\", \"what\": f\"{src} 부족분 40상자 -> {dst} {40 * rate:.0f}상자\", \"evid\": f\"가상 환산 계수 {rate}\"})\n", + "\n", + "audit = Audit()\n", + "for p in props:\n", + " audit.add(t=\"제안\", pid=p[\"id\"], kind=p[\"type\"], what=p[\"what\"], evid=p[\"evid\"], status=\"결재대기\", by=\"AI\")\n", + "\n", + "\n", + "def staff_decision(p, k):\n", + " \"\"\"가상 군수참모 판단(시뮬레이션): 근거 확인 후 승인·수정·반려, 일부는 시간 내 무응답.\"\"\"\n", + " if p[\"type\"] == \"대체 탄종\":\n", + " return \"수정\", \"대체 수량 절반만 승인, 잔여는 상급부대 청구\"\n", + " if k % 5 == 4:\n", + " return \"무응답\", \"결재 시한 초과 -> 현행 유지\"\n", + " if p[\"type\"] == \"재배치\" and k % 3 == 2:\n", + " return \"반려\", \"해당 저장고 점검 일정과 겹침\"\n", + " return \"승인\", \"근거 확인\"\n", + "\n", + "\n", + "executed = []\n", + "for k, p in enumerate(props):\n", + " d, why = staff_decision(p, k)\n", + " audit.add(t=\"결재\", pid=p[\"id\"], decision=d, reason=why, by=\"군수참모(가상)\")\n", + " if d in (\"승인\", \"수정\"):\n", + " executed.append(p[\"id\"]); audit.add(t=\"집행\", pid=p[\"id\"], by=\"저장관리반(가상)\")\n", + "tab = pd.DataFrame([{\"제안\": e[\"pid\"], \"결정\": e[\"decision\"], \"사유\": e[\"reason\"]} for e in audit.log if e[\"t\"] == \"결재\"])\n", + "info = pd.DataFrame(props).set_index(\"id\")\n", + "tab[\"유형\"], tab[\"내용\"] = tab[\"제안\"].map(info[\"type\"]), tab[\"제안\"].map(info[\"what\"])\n", + "print(f\"\\n[군수참모 결재 결과] 제안 {len(props)}건\")\n", + "print(tab[[\"제안\", \"유형\", \"내용\", \"결정\", \"사유\"]].to_string(index=False))\n", + "auto = [e for e in audit.log if e[\"t\"] == \"집행\" and not any(x[\"t\"] == \"결재\" and x[\"pid\"] == e[\"pid\"] and x[\"decision\"] in (\"승인\", \"수정\") for x in audit.log)]\n", + "print(f\"결재 없이 집행된 건 {len(auto)}건 (자동 확정 금지: {'준수' if not auto else '위반'}), 결재 사유 기록률 {tab['사유'].astype(bool).mean():.0%}\")\n", + "print(f\"감사 로그 {len(audit.log)}건 무결성 검증: {'정상' if audit.verify() is None else '변조 발견'}\")\n", + "i3 = next(e[\"seq\"] for e in audit.log if e[\"t\"] == \"결재\" and e[\"decision\"] == \"반려\")\n", + "audit.log[i3][\"decision\"] = \"승인\"\n", + "print(f\"변조 시험({i3}번 반려 기록을 승인으로 수정) -> {audit.verify()}번 기록에서 변조 검출\")\n", + "\n", + "fig, ax = plt.subplots(1, 2, figsize=(12, 4))\n", + "for a, (nm, asg) in zip(ax, [(\"current (first-fit)\", cur), (\"optimized\", opt)]):\n", + " s = slots.set_index(\"slot\")\n", + " a.scatter(s[\"dist_m\"], [list(HOUSES).index(h) for h in s[\"house\"]], c=\"lightgray\", s=60, marker=\"s\")\n", + " for i, sl in asg.items():\n", + " a.scatter(s.loc[sl, \"dist_m\"], list(HOUSES).index(s.loc[sl, \"house\"]), s=40 + 30 * skus.loc[i, \"freq\"], alpha=0.6)\n", + " a.set_yticks(range(3)); a.set_yticklabels(list(HOUSES)); a.set_xlabel(\"distance to dock (m)\"); a.set_title(f\"Placement: {nm} (size=issue freq)\")\n", + "plt.tight_layout(); plt.show()\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 07 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + "[가상 저장고] 저장칸 34개(현재 점유·이동 불가 6개), SKU 20개, 일일 반출 수요 {'ammo_A': 25.8, 'ammo_B': 16.7, 'ammo_C': 10.2, 'ammo_D': 7.1}\n", + " 작업동선(m/일) 작업비용 지수 공간낭비율 위치점수 배치 SKU\n", + "현행(입고순) 1506.20 30.12 0.29 60.18 20.00\n", + "최적화 1195.08 23.90 0.29 71.88 20.00\n", + "작업비용 절감률 20.7% (목표 15% 이상: 달성)\n", + "대안배치(반출 최다 ammo_A-L3): H1-102(비용 126), H1-201(비용 147), H1-103(비용 157) -> 1안 점유 시 2·3안 순으로 제시\n", + "민감도(ammo_A 반출 2배): 최적 배치 변경 7칸, 작업동선 현행 1919 -> 재최적화 1491 m/일\n", + "\n", + "[군수참모 결재 결과] 제안 9건\n", + " 제안 유형 내용 결정 사유\n", + "P01 재배치 ammo_A-L3 H1-104->H1-201 승인 근거 확인\n", + "P02 재배치 ammo_B-L4 H1-206->H1-102 승인 근거 확인\n", + "P03 재배치 ammo_A-L5 H1-106->H1-103 반려 해당 저장고 점검 일정과 겹침\n", + "P04 재배치 ammo_A-L2 H1-103->H2-101 승인 근거 확인\n", + "P05 재배치 ammo_B-L3 H1-205->H1-202 무응답 결재 시한 초과 -> 현행 유지\n", + "P06 재배치 ammo_B-L5 H2-101->H2-102 반려 해당 저장고 점검 일정과 겹침\n", + "P07 재보급 우선순위 U04 탄약 우선 불출(1순위) 승인 근거 확인\n", + "P08 재보급 우선순위 U08 탄약 우선 불출(2순위) 승인 근거 확인\n", + "P09 대체 탄종 ammo_C 부족분 40상자 -> ammo_D 32상자 수정 대체 수량 절반만 승인, 잔여는 상급부대 청구\n", + "결재 없이 집행된 건 0건 (자동 확정 금지: 준수), 결재 사유 기록률 100%\n", + "감사 로그 24건 무결성 검증: 정상\n", + "변조 시험(13번 반려 기록을 승인으로 수정) -> 13번 기록에서 변조 검출\n", + "```\n", + "
\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 08. 7일간 고강도 전면전 모의 탄약/유류 소모량 극한 스트레스 시뮬레이션\n", + "**소요시간**: 약 3.0시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "7일간 전면전 모의 탄약/유류 소모량 스트레스 시뮬레이션을 수행한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **7일간 고강도 전면전(가상) 상황에서 소모량과 보유량을 일일 모의하고 재보급 경보 대시보드의 성능을 극한 조건에서 검증한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **가상 부대 8개의 마지막 7일을 고강도 공격 단계로 바꿔 소모·보유량을 모의 수행하고 검증하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `src/dashboard/amm02_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": [ + "\"\"\"7일간 고강도 전면전(가상) 상황에서 부대별 탄약·유류 소모량과 보유량을 일일 모의하고, 보고 누락·보고 지연·긴급 수요 급증·부대 수 10배 부하를 주입해 재보급 경보 대시보드의 갱신 시간(2초 이내)과 경보 발령 정확도(95% 이상)를 검증한다.\"\"\"\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, RandomForestRegressor\n", + "\n", + "# ---------------------------------------------------------------------------\n", + "# [교육용가상자료] 가상 부대·가상 품목 일일 소모 기록 생성기 (실제 탄약 제원·소모율과 무관)\n", + "# 가상 부대 8개 x 90일. 소모량 = 가상 CSR 계수 x 병력 규모 x (교전 강도·적 저항·화력 계획 효과) x 잡음\n", + "# ---------------------------------------------------------------------------\n", + "UNITS = [f\"U{i:02d}\" for i in range(1, 9)]\n", + "ITEMS = [\"ammo_A\", \"ammo_B\", \"ammo_C\", \"ammo_D\", \"fuel_jet\", \"fuel_diesel\"]\n", + "AMMO, FUEL = ITEMS[:4], ITEMS[4:]\n", + "PHASES = [\"prep\", \"attack\", \"defense\", \"stability\"]\n", + "CSR = { # 가상 계수: 병력 100명·1일 기준 소모 단위(탄약=상자, 유류=드럼)\n", + " \"prep\": [1.0, 0.6, 0.3, 0.2, 1.5, 6.0],\n", + " \"attack\": [6.0, 3.5, 2.0, 1.2, 5.0, 12.0],\n", + " \"defense\": [4.5, 3.0, 1.6, 0.8, 3.0, 7.0],\n", + " \"stability\": [0.8, 0.5, 0.3, 0.2, 1.0, 5.0],\n", + "}\n", + "PHASE_RESIST = {\"prep\": 1.0, \"attack\": 3.2, \"defense\": 3.6, \"stability\": 0.8}\n", + "\n", + "\n", + "def make_ops(n_days=90, seed=7, surge=0):\n", + " \"\"\"가상 부대별 일일 작전 기록과 품목별 실제 소모량(정답)을 만든다. surge>0이면 마지막 surge일을 고강도 공격 단계로 바꾼다(스트레스 시험용).\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " rows = []\n", + " for uid in UNITS:\n", + " size = int(rng.integers(3, 9)) * 100\n", + " lens = rng.integers(8, 20, 4)\n", + " sched = [\"prep\"] * lens[0] + [\"attack\"] * lens[1] + [\"defense\"] * lens[2] + [\"attack\"] * lens[3]\n", + " sched = (sched + [\"stability\"] * n_days)[:n_days]\n", + " if surge:\n", + " sched[-surge:] = [\"attack\"] * surge\n", + " r = 1.0\n", + " for d, ph in enumerate(sched):\n", + " r = float(np.clip(0.7 * r + 0.3 * (4.8 if surge and d >= n_days - surge else PHASE_RESIST[ph]) + rng.normal(0, 0.35), 0, 5))\n", + " inten = float(np.clip(r / 5 * 0.9 + rng.normal(0, 0.06), 0, 1))\n", + " fire = int(rng.poisson(1 + 8 * inten))\n", + " move = float(rng.gamma(2, {\"prep\": 4, \"attack\": 12, \"defense\": 2, \"stability\": 5}[ph]))\n", + " weather = str(rng.choice([\"clear\", \"rain\", \"snow\"], p=[0.7, 0.2, 0.1]))\n", + " emerg = bool(rng.random() < 0.02)\n", + " rec = {\"unit\": uid, \"day\": d, \"phase\": ph, \"unit_size\": size, \"intensity\": round(inten, 3),\n", + " \"resistance\": round(r, 2), \"fire_missions\": fire, \"move_km\": round(move, 1),\n", + " \"weather\": weather, \"emergency\": emerg}\n", + " base = size / 100\n", + " for i, it in enumerate(ITEMS):\n", + " c = CSR[ph][i] * base\n", + " if it in AMMO:\n", + " c *= (0.5 + 0.6 * inten) * (1 + 0.04 * fire * (1.0 if it in (\"ammo_C\", \"ammo_D\") else 0.4))\n", + " elif it == \"fuel_jet\":\n", + " c *= (0.6 + 0.5 * inten) + 0.03 * fire\n", + " else:\n", + " c = c * (0.8 + 0.2 * (weather != \"clear\")) + 0.05 * move * base\n", + " c *= rng.lognormal(0, 0.12) * (2.5 if emerg else 1.0)\n", + " rec[it] = round(float(c), 1)\n", + " rows.append(rec)\n", + " return pd.DataFrame(rows)\n", + "# ---------------------------------------------------------------------------\n", + "\n", + "# ---- 소모량 예측용 파생 피처: 전일까지의 실적 + 당일 작전·화력 계획(사전에 알 수 있는 값만) ----\n", + "FEATS = [\"lag1\", \"lag2\", \"roll3\", \"roll7\", \"csr_base\", \"size100\", \"fire_plan\", \"fire_x_engaged\", \"resist_lag1\",\n", + " \"resist_trend\", \"inten_lag1\", \"move_plan\", \"days_in_phase\", \"engaged\", \"ph_attack\", \"ph_defense\", \"ph_prep\",\n", + " \"item_idx\", \"is_fuel\"]\n", + "DERIVED = [\"lag1\", \"lag2\", \"roll3\", \"roll7\", \"csr_base\", \"fire_x_engaged\", \"resist_lag1\", \"resist_trend\",\n", + " \"inten_lag1\", \"days_in_phase\", \"engaged\", \"ph_attack\", \"ph_defense\", \"ph_prep\"]\n", + "\n", + "\n", + "def build_long(ops):\n", + " \"\"\"부대x일자 기록을 (부대, 일자, 품목) 행으로 펼치고 파생 피처를 만든다.\n", + " 목표값 y = log(실제 소모량 / CSR 기준량): CSR 기준량에 곱할 '보정 계수'를 예측한다. lag·roll 피처도 같은 비율(실적/기준)이다.\"\"\"\n", + " df = ops.sort_values([\"unit\", \"day\"]).reset_index(drop=True)\n", + " g = df.groupby(\"unit\")\n", + " df[\"resist_lag1\"] = g[\"resistance\"].shift(1)\n", + " df[\"resist_trend\"] = df[\"resist_lag1\"] - g[\"resistance\"].shift(3)\n", + " df[\"inten_lag1\"] = g[\"intensity\"].shift(1)\n", + " df[\"days_in_phase\"] = df.groupby((df[\"phase\"] != g[\"phase\"].shift()).cumsum()).cumcount()\n", + " out = []\n", + " for i, it in enumerate(ITEMS):\n", + " csr = np.array([CSR[p][i] for p in df[\"phase\"]])\n", + " ratio = df[it] / (csr * df[\"unit_size\"] / 100)\n", + " prev = ratio.groupby(df[\"unit\"]).shift(1)\n", + " d = pd.DataFrame({\"unit\": df[\"unit\"], \"day\": df[\"day\"], \"phase\": df[\"phase\"], \"item\": it, \"item_idx\": i,\n", + " \"is_fuel\": int(it in FUEL), \"lag1\": prev, \"lag2\": ratio.groupby(df[\"unit\"]).shift(2),\n", + " \"roll3\": prev.groupby(df[\"unit\"]).transform(lambda s: s.rolling(3).mean()),\n", + " \"roll7\": prev.groupby(df[\"unit\"]).transform(lambda s: s.rolling(7, min_periods=3).mean()),\n", + " \"csr_base\": csr, \"size100\": df[\"unit_size\"] / 100,\n", + " \"fire_plan\": df[\"fire_missions\"], \"fire_x_engaged\": df[\"fire_missions\"] * df[\"phase\"].isin([\"attack\", \"defense\"]),\n", + " \"resist_lag1\": df[\"resist_lag1\"], \"resist_trend\": df[\"resist_trend\"], \"inten_lag1\": df[\"inten_lag1\"],\n", + " \"move_plan\": df[\"move_km\"], \"days_in_phase\": df[\"days_in_phase\"],\n", + " \"engaged\": df[\"phase\"].isin([\"attack\", \"defense\"]).astype(int),\n", + " \"ph_attack\": (df[\"phase\"] == \"attack\").astype(int), \"ph_defense\": (df[\"phase\"] == \"defense\").astype(int),\n", + " \"ph_prep\": (df[\"phase\"] == \"prep\").astype(int), \"emergency\": df[\"emergency\"],\n", + " \"y\": np.log(ratio), \"qty\": df[it]})\n", + " out.append(d)\n", + " return pd.concat(out, ignore_index=True).dropna(subset=[\"lag2\", \"roll3\", \"resist_trend\"]).reset_index(drop=True)\n", + "\n", + "\n", + "def to_qty(d, y_pred):\n", + " \"\"\"예측 보정 계수(log)를 소모량으로 되돌린다: 기준량 x exp(예측).\"\"\"\n", + " return np.exp(y_pred) * d[\"csr_base\"].values * d[\"size100\"].values\n", + "\n", + "\n", + "def mape(y, p):\n", + " return float(np.mean(np.abs(np.asarray(p) - np.asarray(y)) / np.asarray(y)))\n", + "\n", + "# ---- 소모량 예측 모델(앙상블) 학습·예측 ----\n", + "def fit_models(tr, seed=0):\n", + " \"\"\"GBR + 랜덤포레스트 두 모델을 학습한다(목표값은 CSR 기준량 대비 log 보정 계수).\"\"\"\n", + " g = GradientBoostingRegressor(n_estimators=150, max_depth=3, learning_rate=0.08, subsample=0.8, random_state=seed).fit(tr[FEATS], tr[\"y\"])\n", + " r = RandomForestRegressor(n_estimators=120, min_samples_leaf=3, random_state=seed, n_jobs=1).fit(tr[FEATS], tr[\"y\"])\n", + " return g, r\n", + "\n", + "\n", + "def predict_qty(models, d, w=0.5):\n", + " \"\"\"두 모델의 log 예측을 가중 평균해 소모량으로 되돌린다.\"\"\"\n", + " g, r = models\n", + " return to_qty(d, w * g.predict(d[FEATS]) + (1 - w) * r.predict(d[FEATS]))\n", + "\n", + "SURGE = 7 # 고강도 모의 일수\n", + "DOS_ALERT = 2.0 # [교육용가상자료] 경보 기준 보급 수준(일)\n", + "\n", + "data = build_long(make_ops(90, seed=7, surge=SURGE))\n", + "start = 90 - SURGE\n", + "models = fit_models(data[data[\"day\"] < start])\n", + "test = data[data[\"day\"] >= start].copy()\n", + "\n", + "\n", + "def inject(d, miss=0.0, delay=0.0, spike=0.0, seed=0):\n", + " \"\"\"오류 주입: 보고 누락(전일 실적 결측 -> CSR 기준 1.0 대입), 보고 지연(전일 대신 2일 전 실적), 긴급 수요 급증(긴급 불출 요청 접수, 실제 소모 x2.5).\"\"\"\n", + " rng = np.random.default_rng(seed); d = d.copy()\n", + " m = rng.random(len(d)) < miss\n", + " d.loc[m, [\"lag1\", \"lag2\", \"roll3\", \"roll7\"]] = 1.0\n", + " l = (rng.random(len(d)) < delay) & ~m\n", + " d.loc[l, \"lag1\"] = d.loc[l, \"lag2\"]\n", + " s = rng.random(len(d)) < spike\n", + " d.loc[s, \"qty\"] *= 2.5\n", + " d.loc[s, \"emergency\"] = True\n", + " return d\n", + "\n", + "\n", + "def dashboard(d, stock0, margin=1.0):\n", + " \"\"\"대시보드 1회 갱신: 예측 -> 보유량 경과 -> DOS·경보 -> 부대별 집계 표. 반환: 경보표, 집계표.\"\"\"\n", + " # 긴급 불출 요청이 접수된 건은 요청 배수(가상 2.5배)를 예측에 반영한다(요청은 사전에 알 수 있는 정보)\n", + " d = d.assign(pred=predict_qty(models, d) * np.where(d[\"emergency\"], 2.5, 1.0)).sort_values([\"unit\", \"item\", \"day\"])\n", + " resup = d[\"csr_base\"] * d[\"size100\"] * 1.0 # 계획 재보급량(작전 단계 CSR 기준, 가상)\n", + " used = d.groupby([\"unit\", \"item\"])[\"qty\"].cumsum() - d[\"qty\"]\n", + " got = resup.groupby([d[\"unit\"], d[\"item\"]]).cumsum() - resup\n", + " d[\"on_hand\"] = d.set_index([\"unit\", \"item\"]).index.map(stock0).values + got.values - used.values\n", + " d[\"dos_pred\"] = d[\"on_hand\"].clip(lower=0) / d[\"pred\"]\n", + " d[\"dos_true\"] = d[\"on_hand\"].clip(lower=0) / d[\"qty\"]\n", + " d[\"alert\"], d[\"truth\"] = d[\"dos_pred\"] < DOS_ALERT * margin, d[\"dos_true\"] < DOS_ALERT\n", + " agg = d.groupby([\"day\", \"unit\"]).agg(alerts=(\"alert\", \"sum\"), ammo=(\"pred\", \"sum\"))\n", + " return d, agg\n", + "\n", + "\n", + "stock0 = (test[test[\"day\"] == start].set_index([\"unit\", \"item\"])[\"csr_base\"] * test[test[\"day\"] == start].set_index([\"unit\", \"item\"])[\"size100\"] * 3.0).to_dict()\n", + "cases = [(\"정상 보고\", {}), (\"보고 누락 10%\", {\"miss\": 0.1}), (\"보고 지연 20%\", {\"delay\": 0.2}),\n", + " (\"긴급 급증 5%\", {\"spike\": 0.05}), (\"복합(누락10+지연20+급증5)\", {\"miss\": 0.1, \"delay\": 0.2, \"spike\": 0.05})]\n", + "rows = []\n", + "for name, kw in cases:\n", + " d = inject(test, seed=1, **kw)\n", + " t0 = time.perf_counter(); out, agg = dashboard(d, stock0); dt = time.perf_counter() - t0\n", + " tp = int((out[\"alert\"] & out[\"truth\"]).sum()); fn = int((~out[\"alert\"] & out[\"truth\"]).sum())\n", + " rows.append({\"시나리오\": name, \"갱신(s)\": dt, \"경보\": int(out[\"alert\"].sum()), \"실제 부족\": int(out[\"truth\"].sum()),\n", + " \"정확도\": (out[\"alert\"] == out[\"truth\"]).mean(), \"부족 탐지율\": tp / max(tp + fn, 1)})\n", + "res = pd.DataFrame(rows)\n", + "print(f\"[7일 고강도 모의] 부대 8 x 품목 6 x {SURGE}일 = {len(test)}건, 시작 보유량 3일분(가상)\")\n", + "print(res.to_string(index=False, float_format=lambda x: f\"{x:.3f}\"))\n", + "acc = res[\"정확도\"].min()\n", + "print(f\"경보 정확도 최저 {acc:.1%} (목표 95% 이상: {'달성' if acc >= 0.95 else '미달'})\")\n", + "\n", + "# 부하 시험: 가상 부대 10배(80개) 데이터로 대시보드 갱신 시간 측정\n", + "big = pd.concat([test.assign(unit=test[\"unit\"] + f\"_{k}\") for k in range(10)], ignore_index=True)\n", + "st_big = {(u + f\"_{k}\", it): v for (u, it), v in stock0.items() for k in range(10)}\n", + "t0 = time.perf_counter(); out_b, _ = dashboard(big, st_big); dt_b = time.perf_counter() - t0\n", + "mx = max(res[\"갱신(s)\"].max(), dt_b)\n", + "print(f\"부하 시험(부대 80개, {len(big)}건) 갱신 {dt_b:.2f}s, 최대 갱신 {mx:.2f}s (목표 2초 이내: {'달성' if mx <= 2 else '미달'})\")\n", + "\n", + "base, _ = dashboard(test, stock0)\n", + "first = base[base[\"truth\"]].groupby(\"item\")[\"day\"].min().sub(start - 1).astype(int)\n", + "print(\"품목별 첫 부족 발생(모의 n일차):\", \", \".join(f\"{k} {v}일차\" for k, v in first.items()) or \"없음\")\n", + "ammo_tot = base[base[\"is_fuel\"] == 0].groupby(\"day\")[[\"qty\", \"pred\"]].sum()\n", + "print(f\"7일 탄약 총소모(가상 상자): 실제 {ammo_tot['qty'].sum():.0f}, 예측 {ammo_tot['pred'].sum():.0f}, 오차 {ammo_tot['pred'].sum() / ammo_tot['qty'].sum() - 1:+.1%}\")\n", + "tr_off = []\n", + "for mg in (1.0, 1.1, 1.2):\n", + " o, _ = dashboard(test, stock0, mg)\n", + " tr_off.append(f\"x{mg}: 정확도 {(o['alert'] == o['truth']).mean():.1%}/탐지율 {(o['alert'] & o['truth']).sum() / o['truth'].sum():.0%}\")\n", + "print(\"경보 안전계수 상충관계(정상 보고):\", \", \".join(tr_off), \"-> 부족 놓침을 줄이려면 안전계수를 올리고 정확도 저하를 감수한다\")\n", + "print(\"경보는 재보급 검토 요청이며 불출·우선순위 결정은 군수참모 결재를 거친다.\")\n", + "\n", + "fig, ax = plt.subplots(1, 3, figsize=(16, 4))\n", + "ammo_tot.plot(ax=ax[0], marker=\"o\", title=\"7-day surge: total ammo (actual vs forecast)\")\n", + "piv = base.groupby([\"unit\", \"day\"])[\"alert\"].sum().unstack()\n", + "im = ax[1].imshow(piv.values, cmap=\"Reds\", aspect=\"auto\"); ax[1].set_yticks(range(len(piv))); ax[1].set_yticklabels(piv.index)\n", + "ax[1].set_xticks(range(piv.shape[1])); ax[1].set_xticklabels(range(1, piv.shape[1] + 1)); ax[1].set_xlabel(\"surge day\")\n", + "ax[1].set_title(\"Alerts per unit/day (items with DOS<2)\"); plt.colorbar(im, ax=ax[1])\n", + "ax[2].bar(range(len(res)), res[\"정확도\"]); ax[2].axhline(0.95, c=\"r\", ls=\"--\"); ax[2].set_ylim(0.8, 1.0)\n", + "ax[2].set_xticks(range(len(res))); ax[2].set_xticklabels([\"normal\", \"missing\", \"delay\", \"spike\", \"mixed\"]); ax[2].set_title(\"Alert accuracy by fault scenario\")\n", + "plt.tight_layout(); plt.show()\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 08 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + "[7일 고강도 모의] 부대 8 x 품목 6 x 7일 = 336건, 시작 보유량 3일분(가상)\n", + " 시나리오 갱신(s) 경보 실제 부족 정확도 부족 탐지율\n", + " 정상 보고 0.041 23 29 0.964 0.690\n", + " 보고 누락 10% 0.039 23 29 0.964 0.690\n", + " 보고 지연 20% 0.037 22 29 0.961 0.655\n", + " 긴급 급증 5% 0.038 67 73 0.964 0.877\n", + "복합(누락10+지연20+급증5) 0.041 66 73 0.961 0.863\n", + "경보 정확도 최저 96.1% (목표 95% 이상: 달성)\n", + "부하 시험(부대 80개, 3360건) 갱신 0.07s, 최대 갱신 0.07s (목표 2초 이내: 달성)\n", + "품목별 첫 부족 발생(모의 n일차): ammo_C 4일차, ammo_D 5일차, fuel_jet 4일차\n", + "7일 탄약 총소모(가상 상자): 실제 4645, 예측 4633, 오차 -0.2%\n", + "경보 안전계수 상충관계(정상 보고): x1.0: 정확도 96.4%/탐지율 69%, x1.1: 정확도 94.3%/탐지율 90%, x1.2: 정확도 87.2%/탐지율 97% -> 부족 놓침을 줄이려면 안전계수를 올리고 정확도 저하를 감수한다\n", + "경보는 재보급 검토 요청이며 불출·우선순위 결정은 군수참모 결재를 거친다.\n", + "```\n", + "
\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 09. AAR 기반 소모량 예측치와 실사격 결과 간 편차 분석 및 소모 계수 재보정\n", + "**소요시간**: 약 3.0시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "AAR 기반 소모량 예측 편차를 분석하고 소모 계수를 재보정한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **AAR 자료로 CSR 계획치와 실소모 결과의 편차를 분석하고 소모 계수를 재보정해 도입 효과를 산출한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **작전 단계·품목·교전 강도 구간별로 실제/계획 비율을 분석해 편차 원인을 찾는다 과정을 진행하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `reports/amm02_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(사후검토) 자료로 기준 소모율(CSR) 계획치와 실사격·실소모 결과의 편차를 작전 단계·품목·교전 강도별로 분석해 원인을 찾고, 소모 계수를 축소 추정으로 재보정한 뒤 새 훈련 자료에서 검증하며, 비용 절감·작업시간 단축·긴급 재보급 감소 3대 효과 지표와 민감도를 산출한다.\"\"\"\n", + "import numpy as np\n", + "import pandas as pd\n", + "import matplotlib.pyplot as plt\n", + "from scipy.optimize import linear_sum_assignment\n", + "\n", + "# ---------------------------------------------------------------------------\n", + "# [교육용가상자료] 가상 부대·가상 품목 일일 소모 기록 생성기 (실제 탄약 제원·소모율과 무관)\n", + "# 가상 부대 8개 x 90일. 소모량 = 가상 CSR 계수 x 병력 규모 x (교전 강도·적 저항·화력 계획 효과) x 잡음\n", + "# ---------------------------------------------------------------------------\n", + "UNITS = [f\"U{i:02d}\" for i in range(1, 9)]\n", + "ITEMS = [\"ammo_A\", \"ammo_B\", \"ammo_C\", \"ammo_D\", \"fuel_jet\", \"fuel_diesel\"]\n", + "AMMO, FUEL = ITEMS[:4], ITEMS[4:]\n", + "PHASES = [\"prep\", \"attack\", \"defense\", \"stability\"]\n", + "CSR = { # 가상 계수: 병력 100명·1일 기준 소모 단위(탄약=상자, 유류=드럼)\n", + " \"prep\": [1.0, 0.6, 0.3, 0.2, 1.5, 6.0],\n", + " \"attack\": [6.0, 3.5, 2.0, 1.2, 5.0, 12.0],\n", + " \"defense\": [4.5, 3.0, 1.6, 0.8, 3.0, 7.0],\n", + " \"stability\": [0.8, 0.5, 0.3, 0.2, 1.0, 5.0],\n", + "}\n", + "PHASE_RESIST = {\"prep\": 1.0, \"attack\": 3.2, \"defense\": 3.6, \"stability\": 0.8}\n", + "\n", + "\n", + "def make_ops(n_days=90, seed=7, surge=0):\n", + " \"\"\"가상 부대별 일일 작전 기록과 품목별 실제 소모량(정답)을 만든다. surge>0이면 마지막 surge일을 고강도 공격 단계로 바꾼다(스트레스 시험용).\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " rows = []\n", + " for uid in UNITS:\n", + " size = int(rng.integers(3, 9)) * 100\n", + " lens = rng.integers(8, 20, 4)\n", + " sched = [\"prep\"] * lens[0] + [\"attack\"] * lens[1] + [\"defense\"] * lens[2] + [\"attack\"] * lens[3]\n", + " sched = (sched + [\"stability\"] * n_days)[:n_days]\n", + " if surge:\n", + " sched[-surge:] = [\"attack\"] * surge\n", + " r = 1.0\n", + " for d, ph in enumerate(sched):\n", + " r = float(np.clip(0.7 * r + 0.3 * (4.8 if surge and d >= n_days - surge else PHASE_RESIST[ph]) + rng.normal(0, 0.35), 0, 5))\n", + " inten = float(np.clip(r / 5 * 0.9 + rng.normal(0, 0.06), 0, 1))\n", + " fire = int(rng.poisson(1 + 8 * inten))\n", + " move = float(rng.gamma(2, {\"prep\": 4, \"attack\": 12, \"defense\": 2, \"stability\": 5}[ph]))\n", + " weather = str(rng.choice([\"clear\", \"rain\", \"snow\"], p=[0.7, 0.2, 0.1]))\n", + " emerg = bool(rng.random() < 0.02)\n", + " rec = {\"unit\": uid, \"day\": d, \"phase\": ph, \"unit_size\": size, \"intensity\": round(inten, 3),\n", + " \"resistance\": round(r, 2), \"fire_missions\": fire, \"move_km\": round(move, 1),\n", + " \"weather\": weather, \"emergency\": emerg}\n", + " base = size / 100\n", + " for i, it in enumerate(ITEMS):\n", + " c = CSR[ph][i] * base\n", + " if it in AMMO:\n", + " c *= (0.5 + 0.6 * inten) * (1 + 0.04 * fire * (1.0 if it in (\"ammo_C\", \"ammo_D\") else 0.4))\n", + " elif it == \"fuel_jet\":\n", + " c *= (0.6 + 0.5 * inten) + 0.03 * fire\n", + " else:\n", + " c = c * (0.8 + 0.2 * (weather != \"clear\")) + 0.05 * move * base\n", + " c *= rng.lognormal(0, 0.12) * (2.5 if emerg else 1.0)\n", + " rec[it] = round(float(c), 1)\n", + " rows.append(rec)\n", + " return pd.DataFrame(rows)\n", + "# ---------------------------------------------------------------------------\n", + "\n", + "# ---------------------------------------------------------------------------\n", + "# [교육용가상자료] 가상 저장고(H1~H3) 저장칸 배치 모델. 실제 탄약 저장 기준·안전거리와 무관한 가상 계수다.\n", + "# 저장칸: 저장고 x 열(row) x 칸(col). 동선 거리 = 반출장(입구)까지의 가상 거리(m).\n", + "# 저장군(G1~G3)은 가상 혼적 제한 그룹이며, 저장고마다 받을 수 있는 저장군이 정해져 있다.\n", + "# ---------------------------------------------------------------------------\n", + "HOUSES = {\"H1\": {\"groups\": {\"G1\", \"G2\"}, \"rows\": 2, \"cols\": 6, \"dock\": 15.0},\n", + " \"H2\": {\"groups\": {\"G1\"}, \"rows\": 2, \"cols\": 5, \"dock\": 40.0},\n", + " \"H3\": {\"groups\": {\"G2\", \"G3\"}, \"rows\": 2, \"cols\": 6, \"dock\": 70.0}}\n", + "ITEM_GROUP = {\"ammo_A\": \"G1\", \"ammo_B\": \"G1\", \"ammo_C\": \"G2\", \"ammo_D\": \"G3\"}\n", + "SLOT_CAP = 6 # 1열 저장칸 가상 팔레트 수용량(2열은 +4)\n", + "\n", + "\n", + "def make_slots(seed=3):\n", + " \"\"\"저장칸 목록과 현재 점유(이동 불가 기존 재고) 여부를 만든다.\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " rows = []\n", + " for h, c in HOUSES.items():\n", + " for r in range(c[\"rows\"]):\n", + " for k in range(c[\"cols\"]):\n", + " rows.append({\"slot\": f\"{h}-{r + 1}{k + 1:02d}\", \"house\": h, \"dist_m\": c[\"dock\"] + 6.0 * k + 4.0 * r,\n", + " \"cap\": SLOT_CAP if r == 0 else SLOT_CAP + 4, \"fixed\": bool(rng.random() < 0.15)})\n", + " return pd.DataFrame(rows)\n", + "\n", + "\n", + "def make_skus(demand, seed=5):\n", + " \"\"\"품목별 예측 일일 반출량(demand: 품목->상자/일)을 로트 단위 SKU로 나눈다. 반출 빈도 = 일일 반출 횟수(가상).\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " rows = []\n", + " for it, dq in demand.items():\n", + " n_lot = 5\n", + " share = rng.dirichlet(np.ones(n_lot) * 2)\n", + " for j in range(n_lot):\n", + " rows.append({\"sku\": f\"{it}-L{j + 1}\", \"item\": it, \"group\": ITEM_GROUP[it],\n", + " \"pallets\": int(rng.integers(2, SLOT_CAP + 5)), \"freq\": round(float(dq * share[j] / 4), 2)})\n", + " return pd.DataFrame(rows)\n", + "\n", + "\n", + "def feasible(sku, slot):\n", + " return (sku[\"group\"] in HOUSES[slot[\"house\"]][\"groups\"]) and sku[\"pallets\"] <= slot[\"cap\"] and not slot[\"fixed\"]\n", + "\n", + "\n", + "def travel(assign, skus, slots):\n", + " \"\"\"작업동선 = 반출 빈도 x 왕복 거리 합(가상 m/일).\"\"\"\n", + " d = slots.set_index(\"slot\")[\"dist_m\"]\n", + " return float(sum(2 * skus.loc[i, \"freq\"] * d[s] for i, s in assign.items()))\n", + "\n", + "\n", + "def first_fit(skus, slots):\n", + " \"\"\"현행 방식(대안 비교 기준): 입고 순서대로 비어 있는 첫 번째 적합 칸에 넣는다.\"\"\"\n", + " used, out = set(), {}\n", + " for i, k in skus.iterrows():\n", + " for _, s in slots.iterrows():\n", + " if s[\"slot\"] not in used and feasible(k, s):\n", + " out[i] = s[\"slot\"]; used.add(s[\"slot\"]); break\n", + " return out\n", + "\n", + "\n", + "def optimize(skus, slots, lam=8):\n", + " \"\"\"할당 문제(정수계획 특수형): 비용 = 반출 빈도 x 왕복거리 + lam x 빈 공간(공간낭비). 부적합 칸은 큰 비용.\"\"\"\n", + " cost = np.full((len(skus), len(slots)), 1e7)\n", + " for i, k in skus.iterrows():\n", + " for j, s in slots.iterrows():\n", + " if feasible(k, s):\n", + " cost[i, j] = 2 * k[\"freq\"] * s[\"dist_m\"] + lam * (s[\"cap\"] - k[\"pallets\"])\n", + " ri, cj = linear_sum_assignment(cost)\n", + " return {int(i): slots.loc[j, \"slot\"] for i, j in zip(ri, cj) if cost[i, j] < 1e7}, cost\n", + "\n", + "\n", + "def loc_score(assign, skus, slots):\n", + " \"\"\"위치점수(0~100): 반출 빈도 상위 SKU일수록 반출장 가까운 칸에 있는 정도(가중 평균).\"\"\"\n", + " d = slots.set_index(\"slot\")[\"dist_m\"]; dmin, dmax = d.min(), d.max()\n", + " w = skus[\"freq\"] / skus[\"freq\"].sum()\n", + " return float(100 * sum(w[i] * (1 - (d[s] - dmin) / (dmax - dmin)) for i, s in assign.items()))\n", + "# ---------------------------------------------------------------------------\n", + "\n", + "PRICE = {\"hold\": 1.0, \"emerg\": 25.0} # [교육용가상자료] 과잉 1단위 보관·수송 비용 지수, 긴급 재보급 1회 비용 지수\n", + "SHRINK_K = 20 # 재보정 축소 강도(표본 n이 작을수록 기존 계수 유지)\n", + "SPEED = 80.0 # 지게차 가상 이동 속도(m/분)\n", + "\n", + "\n", + "def long_ratio(ops):\n", + " \"\"\"(부대, 일자, 품목)별 실제 소모량과 CSR 계획량, 비율.\"\"\"\n", + " out = []\n", + " for i, it in enumerate(ITEMS):\n", + " plan = np.array([CSR[p][i] for p in ops[\"phase\"]]) * ops[\"unit_size\"] / 100\n", + " out.append(pd.DataFrame({\"unit\": ops[\"unit\"], \"day\": ops[\"day\"], \"phase\": ops[\"phase\"], \"item\": it,\n", + " \"intensity\": ops[\"intensity\"], \"plan\": plan, \"qty\": ops[it], \"emergency\": ops[\"emergency\"]}))\n", + " d = pd.concat(out, ignore_index=True)\n", + " return d.assign(ratio=d[\"qty\"] / d[\"plan\"])\n", + "\n", + "\n", + "aar = long_ratio(make_ops(90, seed=7)) # 지난 훈련(AAR 대상)\n", + "new = long_ratio(make_ops(90, seed=8)) # 다음 훈련(재보정 검증용, 같은 생성 규칙의 다른 가상 부대)\n", + "\n", + "# 1) 편차 분석: 단계 x 품목별 실제/계획 비율 중앙값(1보다 크면 계획 과소)\n", + "piv = aar[~aar[\"emergency\"]].groupby([\"phase\", \"item\"])[\"ratio\"].median().unstack()[ITEMS]\n", + "print(\"[AAR 편차: 실제/계획 비율 중앙값(긴급 불출 제외)]\")\n", + "print(piv.to_string(float_format=lambda x: f\"{x:.2f}\"))\n", + "band = pd.cut(aar[\"intensity\"], [0, 0.3, 0.6, 1.0], labels=[\"low\", \"mid\", \"high\"], include_lowest=True)\n", + "amm = aar[aar[\"item\"].isin(AMMO)].groupby(band, observed=True)[\"ratio\"].median()\n", + "print(\"탄약 비율 vs 교전 강도:\", \", \".join(f\"{k} {v:.2f}\" for k, v in amm.items()),\n", + " \"-> 원인: CSR은 단계별 고정값이라 같은 공격 단계라도 강도가 낮은 날은 과대, 높은 날은 과소 계획된다\")\n", + "em_share = aar.loc[aar[\"emergency\"], \"qty\"].sum() / aar[\"qty\"].sum()\n", + "print(f\"긴급 불출 비중 {em_share:.1%} -> 계수 재보정에서 제외하고 예비 재고로 관리\")\n", + "\n", + "# 2) 재보정: 단계 x 품목 x 계획 교전 강도 구간별 보정 계수 = 비율 중앙값^(n/(n+K)) (표본이 적은 칸은 기존 계수 유지 쪽으로 축소)\n", + "BANDS = [0, 0.3, 0.6, 1.0]\n", + "for d in (aar, new):\n", + " d[\"band\"] = pd.cut(d[\"intensity\"], BANDS, labels=[\"low\", \"mid\", \"high\"], include_lowest=True).astype(str)\n", + "base = aar[~aar[\"emergency\"]]\n", + "g = base.groupby([\"phase\", \"item\", \"band\"])[\"ratio\"]\n", + "FACT = (g.median() ** (g.size() / (g.size() + SHRINK_K))).to_dict()\n", + "print(f\"재보정 계수 {len(FACT)}개(단계 4 x 품목 6 x 강도 구간), 예: attack-ammo_A low/mid/high = \"\n", + " + \"/\".join(f\"{FACT.get(('attack', 'ammo_A', b), 1):.2f}\" for b in (\"low\", \"mid\", \"high\")))\n", + "\n", + "\n", + "def mape(y, p):\n", + " return float(np.mean(np.abs(p - y) / y))\n", + "\n", + "\n", + "def apply_csr(d):\n", + " \"\"\"재보정 계획량 = 기존 CSR 계획량 x 보정 계수(해당 칸이 없으면 1.0).\"\"\"\n", + " return d[\"plan\"] * np.array([FACT.get(k, 1.0) for k in zip(d[\"phase\"], d[\"item\"], d[\"band\"])])\n", + "\n", + "\n", + "aar[\"plan2\"], new[\"plan2\"] = apply_csr(aar), apply_csr(new)\n", + "\n", + "\n", + "m1, m2 = mape(new[\"qty\"], new[\"plan\"]), mape(new[\"qty\"], new[\"plan2\"])\n", + "print(f\"\\n[재보정 검증(다음 훈련 자료)] 일일 MAPE 기존 CSR {m1:.1%} -> 재보정 {m2:.1%} ({m1 - m2:+.1%}p 개선)\")\n", + "tot = new.groupby(\"item\")[[\"qty\", \"plan\", \"plan2\"]].sum()\n", + "tot[\"기존 오차\"], tot[\"재보정 오차\"] = tot[\"plan\"] / tot[\"qty\"] - 1, tot[\"plan2\"] / tot[\"qty\"] - 1\n", + "print(\"품목별 총량 오차:\", \", \".join(f\"{k} {a:+.1%}->{b:+.1%}\" for k, a, b in zip(tot.index, tot[\"기존 오차\"], tot[\"재보정 오차\"])))\n", + "\n", + "\n", + "# 3) 3대 효과 지표(가상 단가, 90일 x 부대 8 기준). 두 계획 모두 AAR 자료에서 95% 충족 안전계수를 정해 같은 서비스 수준으로 비교한다\n", + "SAFE = {c: float(np.quantile(aar.loc[~aar[\"emergency\"], \"qty\"] / aar.loc[~aar[\"emergency\"], c], 0.95)) for c in (\"plan\", \"plan2\")}\n", + "print(f\"95% 충족 안전계수: 기존 x{SAFE['plan']:.2f}, 재보정 x{SAFE['plan2']:.2f} -> 같은 충족 수준에서 공급 계획량을 비교한다\")\n", + "\n", + "\n", + "def effect(plan_col, p_hold=PRICE[\"hold\"], p_em=PRICE[\"emerg\"]):\n", + " supply = new[plan_col] * SAFE[plan_col]\n", + " excess = (supply - new[\"qty\"]).clip(lower=0).sum()\n", + " short = int((new[\"qty\"] > supply).sum()) # 공급 계획 초과 소모 -> 긴급 재보급 1회\n", + " return excess * p_hold + short * p_em, short\n", + "\n", + "\n", + "c1, s1 = effect(\"plan\"); c2, s2 = effect(\"plan2\")\n", + "ops = make_ops(90, seed=7)\n", + "demand = ops[ops[\"day\"] >= 76][AMMO].sum().div(14).to_dict()\n", + "skus, slots = make_skus(demand), make_slots()\n", + "t_cur, t_opt = travel(first_fit(skus, slots), skus, slots), travel(optimize(skus, slots)[0], skus, slots)\n", + "kpis = pd.DataFrame([[\"비용(과잉 보관·수송+긴급 재보급) 지수\", c1, c2, 1 - c2 / c1],\n", + " [\"저장고 반출 작업시간(분/일)\", t_cur / SPEED, t_opt / SPEED, 1 - t_opt / t_cur],\n", + " [\"긴급 재보급 횟수(회/90일)\", s1, s2, 1 - s2 / max(s1, 1)]], columns=[\"지표\", \"현행\", \"개선\", \"개선율\"])\n", + "print(\"\\n[3대 정량 효과 지표(가상 단가 기준 상대 비교값)]\")\n", + "print(kpis.to_string(index=False, float_format=lambda x: f\"{x:.2f}\"))\n", + "print(f\"효과 지표 산출 {len(kpis)}종 (목표 3대 지표: {'달성' if len(kpis) >= 3 else '미달'}) - 긴급 재보급 횟수는 같은 충족 수준에서 비슷하게 유지되는지(차이 {s2 - s1:+d}회) 확인하는 지표다\")\n", + "\n", + "sens = []\n", + "for ph in (0.8, 1.0, 1.2):\n", + " for pe in (15.0, 25.0, 40.0):\n", + " a, _ = effect(\"plan\", PRICE[\"hold\"] * ph, pe); b, _ = effect(\"plan2\", PRICE[\"hold\"] * ph, pe)\n", + " sens.append((ph, pe, 1 - b / a))\n", + "s = pd.DataFrame(sens, columns=[\"보관단가 배수\", \"긴급 1회 비용\", \"비용 절감률\"]).pivot(index=\"보관단가 배수\", columns=\"긴급 1회 비용\", values=\"비용 절감률\")\n", + "print(\"\\n[민감도: 비용 절감률]\")\n", + "print(s.to_string(float_format=lambda x: f\"{x:.1%}\"))\n", + "print(\"도입 로드맵: 단기(1~3개월) 훈련 AAR로 계수 재보정 / 중기(4~9개월) 예측 모델·저장 배치 최적화 시범 운용 / 장기(10개월~) 군수 정보체계 연계와 정기 재학습\")\n", + "print(\"금액·시간은 가정 단가로 계산한 상대 비교값이며 예산 근거로 쓰지 않는다.\")\n", + "\n", + "fig, ax = plt.subplots(1, 2, figsize=(12, 4))\n", + "im = ax[0].imshow(piv.values, cmap=\"coolwarm\", vmin=0.5, vmax=1.5); ax[0].set_xticks(range(6)); ax[0].set_xticklabels(ITEMS, rotation=30)\n", + "ax[0].set_yticks(range(4)); ax[0].set_yticklabels(piv.index); ax[0].set_title(\"AAR actual/plan ratio (median)\"); plt.colorbar(im, ax=ax[0])\n", + "x = np.arange(len(tot)); ax[1].bar(x - 0.2, tot[\"기존 오차\"], 0.4, label=\"old CSR\"); ax[1].bar(x + 0.2, tot[\"재보정 오차\"], 0.4, label=\"recalibrated\")\n", + "ax[1].set_xticks(x); ax[1].set_xticklabels(tot.index, rotation=30); ax[1].axhline(0, c=\"k\", lw=0.8); ax[1].set_title(\"Total plan error on next exercise\"); ax[1].legend()\n", + "plt.tight_layout(); plt.show()\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 09 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + "[AAR 편차: 실제/계획 비율 중앙값(긴급 불출 제외)]\n", + "item ammo_A ammo_B ammo_C ammo_D fuel_jet fuel_diesel\n", + "phase \n", + "attack 0.90 0.89 0.99 1.02 1.04 0.94\n", + "defense 0.96 0.99 1.05 1.09 1.10 0.85\n", + "prep 0.61 0.64 0.62 0.67 0.76 0.90\n", + "stability 0.62 0.63 0.67 0.64 0.76 0.94\n", + "탄약 비율 vs 교전 강도: low 0.62, mid 0.90, high 1.06 -> 원인: CSR은 단계별 고정값이라 같은 공격 단계라도 강도가 낮은 날은 과대, 높은 날은 과소 계획된다\n", + "긴급 불출 비중 6.0% -> 계수 재보정에서 제외하고 예비 재고로 관리\n", + "재보정 계수 60개(단계 4 x 품목 6 x 강도 구간), 예: attack-ammo_A low/mid/high = 0.89/0.87/1.02\n", + "\n", + "[재보정 검증(다음 훈련 자료)] 일일 MAPE 기존 CSR 34.0% -> 재보정 14.5% (+19.5%p 개선)\n", + "품목별 총량 오차: ammo_A +12.3%->+0.2%, ammo_B +11.6%->+0.2%, ammo_C -0.4%->-4.4%, ammo_D +0.9%->-3.4%, fuel_diesel +2.1%->-4.0%, fuel_jet +0.1%->-2.7%\n", + "95% 충족 안전계수: 기존 x1.23, 재보정 x1.26 -> 같은 충족 수준에서 공급 계획량을 비교한다\n", + "\n", + "[3대 정량 효과 지표(가상 단가 기준 상대 비교값)]\n", + " 지표 현행 개선 개선율\n", + "비용(과잉 보관·수송+긴급 재보급) 지수 21556.08 19063.11 0.12\n", + " 저장고 반출 작업시간(분/일) 18.88 14.97 0.21\n", + " 긴급 재보급 횟수(회/90일) 283.00 284.00 -0.00\n", + "효과 지표 산출 3종 (목표 3대 지표: 달성) - 긴급 재보급 횟수는 같은 충족 수준에서 비슷하게 유지되는지(차이 +1회) 확인하는 지표다\n", + "\n", + "[민감도: 비용 절감률]\n", + "긴급 1회 비용 15.0 25.0 40.0\n", + "보관단가 배수 \n", + "0.8 12.6% 10.7% 8.6%\n", + "1.0 13.4% 11.6% 9.6%\n", + "1.2 13.9% 12.3% 10.4%\n", + "도입 로드맵: 단기(1~3개월) 훈련 AAR로 계수 재보정 / 중기(4~9개월) 예측 모델·저장 배치 최적화 시범 운용 / 장기(10개월~) 군수 정보체계 연계와 정기 재학습\n", + "금액·시간은 가정 단가로 계산한 상대 비교값이며 예산 근거로 쓰지 않는다.\n", + "```\n", + "
\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 10. 탄약/유류 소모량 예측 및 전술 재보급 계획 수립 시뮬레이터 통합 완성\n", + "**소요시간**: 약 3.5시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "탄약/유류 소모량 예측 및 전술 재보급 계획 수립 시뮬레이터를 완성한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **소모량 예측부터 재보급 계획, 저장 배치, 결재 대기열, 재학습까지 한 번에 실행하는 통합 시뮬레이터를 완성한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **자료 생성·정합성 점검, 운영 모델과 재학습 모델 비교 후 교체 결정, DOS 기반 재보급 계획, 저장 배치 최적화, 결재 대기열 등록을 한 흐름으로 실행 수행하고 검증하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `docs/amm02_operations_guide.md 및 pipeline/amm02_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": [ + "\"\"\"데이터 생성·정합성 점검, 소모량 예측 모델 학습과 자동 재학습(기존·신규 모델 비교 후 교체), DOS 기반 전술 재보급 계획, 가상 저장고 배치 최적화, 군수참모 결재 대기열을 한 번에 실행하는 통합 시뮬레이터를 완성하고, 자체 점검표·운영 가이드 목차·체계 연계 메시지 예시·브리핑 요약을 출력한다.\"\"\"\n", + "import json\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, RandomForestRegressor\n", + "from scipy.optimize import linear_sum_assignment\n", + "\n", + "# ---------------------------------------------------------------------------\n", + "# [교육용가상자료] 가상 부대·가상 품목 일일 소모 기록 생성기 (실제 탄약 제원·소모율과 무관)\n", + "# 가상 부대 8개 x 90일. 소모량 = 가상 CSR 계수 x 병력 규모 x (교전 강도·적 저항·화력 계획 효과) x 잡음\n", + "# ---------------------------------------------------------------------------\n", + "UNITS = [f\"U{i:02d}\" for i in range(1, 9)]\n", + "ITEMS = [\"ammo_A\", \"ammo_B\", \"ammo_C\", \"ammo_D\", \"fuel_jet\", \"fuel_diesel\"]\n", + "AMMO, FUEL = ITEMS[:4], ITEMS[4:]\n", + "PHASES = [\"prep\", \"attack\", \"defense\", \"stability\"]\n", + "CSR = { # 가상 계수: 병력 100명·1일 기준 소모 단위(탄약=상자, 유류=드럼)\n", + " \"prep\": [1.0, 0.6, 0.3, 0.2, 1.5, 6.0],\n", + " \"attack\": [6.0, 3.5, 2.0, 1.2, 5.0, 12.0],\n", + " \"defense\": [4.5, 3.0, 1.6, 0.8, 3.0, 7.0],\n", + " \"stability\": [0.8, 0.5, 0.3, 0.2, 1.0, 5.0],\n", + "}\n", + "PHASE_RESIST = {\"prep\": 1.0, \"attack\": 3.2, \"defense\": 3.6, \"stability\": 0.8}\n", + "\n", + "\n", + "def make_ops(n_days=90, seed=7, surge=0):\n", + " \"\"\"가상 부대별 일일 작전 기록과 품목별 실제 소모량(정답)을 만든다. surge>0이면 마지막 surge일을 고강도 공격 단계로 바꾼다(스트레스 시험용).\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " rows = []\n", + " for uid in UNITS:\n", + " size = int(rng.integers(3, 9)) * 100\n", + " lens = rng.integers(8, 20, 4)\n", + " sched = [\"prep\"] * lens[0] + [\"attack\"] * lens[1] + [\"defense\"] * lens[2] + [\"attack\"] * lens[3]\n", + " sched = (sched + [\"stability\"] * n_days)[:n_days]\n", + " if surge:\n", + " sched[-surge:] = [\"attack\"] * surge\n", + " r = 1.0\n", + " for d, ph in enumerate(sched):\n", + " r = float(np.clip(0.7 * r + 0.3 * (4.8 if surge and d >= n_days - surge else PHASE_RESIST[ph]) + rng.normal(0, 0.35), 0, 5))\n", + " inten = float(np.clip(r / 5 * 0.9 + rng.normal(0, 0.06), 0, 1))\n", + " fire = int(rng.poisson(1 + 8 * inten))\n", + " move = float(rng.gamma(2, {\"prep\": 4, \"attack\": 12, \"defense\": 2, \"stability\": 5}[ph]))\n", + " weather = str(rng.choice([\"clear\", \"rain\", \"snow\"], p=[0.7, 0.2, 0.1]))\n", + " emerg = bool(rng.random() < 0.02)\n", + " rec = {\"unit\": uid, \"day\": d, \"phase\": ph, \"unit_size\": size, \"intensity\": round(inten, 3),\n", + " \"resistance\": round(r, 2), \"fire_missions\": fire, \"move_km\": round(move, 1),\n", + " \"weather\": weather, \"emergency\": emerg}\n", + " base = size / 100\n", + " for i, it in enumerate(ITEMS):\n", + " c = CSR[ph][i] * base\n", + " if it in AMMO:\n", + " c *= (0.5 + 0.6 * inten) * (1 + 0.04 * fire * (1.0 if it in (\"ammo_C\", \"ammo_D\") else 0.4))\n", + " elif it == \"fuel_jet\":\n", + " c *= (0.6 + 0.5 * inten) + 0.03 * fire\n", + " else:\n", + " c = c * (0.8 + 0.2 * (weather != \"clear\")) + 0.05 * move * base\n", + " c *= rng.lognormal(0, 0.12) * (2.5 if emerg else 1.0)\n", + " rec[it] = round(float(c), 1)\n", + " rows.append(rec)\n", + " return pd.DataFrame(rows)\n", + "# ---------------------------------------------------------------------------\n", + "\n", + "# ---- 소모량 예측용 파생 피처: 전일까지의 실적 + 당일 작전·화력 계획(사전에 알 수 있는 값만) ----\n", + "FEATS = [\"lag1\", \"lag2\", \"roll3\", \"roll7\", \"csr_base\", \"size100\", \"fire_plan\", \"fire_x_engaged\", \"resist_lag1\",\n", + " \"resist_trend\", \"inten_lag1\", \"move_plan\", \"days_in_phase\", \"engaged\", \"ph_attack\", \"ph_defense\", \"ph_prep\",\n", + " \"item_idx\", \"is_fuel\"]\n", + "DERIVED = [\"lag1\", \"lag2\", \"roll3\", \"roll7\", \"csr_base\", \"fire_x_engaged\", \"resist_lag1\", \"resist_trend\",\n", + " \"inten_lag1\", \"days_in_phase\", \"engaged\", \"ph_attack\", \"ph_defense\", \"ph_prep\"]\n", + "\n", + "\n", + "def build_long(ops):\n", + " \"\"\"부대x일자 기록을 (부대, 일자, 품목) 행으로 펼치고 파생 피처를 만든다.\n", + " 목표값 y = log(실제 소모량 / CSR 기준량): CSR 기준량에 곱할 '보정 계수'를 예측한다. lag·roll 피처도 같은 비율(실적/기준)이다.\"\"\"\n", + " df = ops.sort_values([\"unit\", \"day\"]).reset_index(drop=True)\n", + " g = df.groupby(\"unit\")\n", + " df[\"resist_lag1\"] = g[\"resistance\"].shift(1)\n", + " df[\"resist_trend\"] = df[\"resist_lag1\"] - g[\"resistance\"].shift(3)\n", + " df[\"inten_lag1\"] = g[\"intensity\"].shift(1)\n", + " df[\"days_in_phase\"] = df.groupby((df[\"phase\"] != g[\"phase\"].shift()).cumsum()).cumcount()\n", + " out = []\n", + " for i, it in enumerate(ITEMS):\n", + " csr = np.array([CSR[p][i] for p in df[\"phase\"]])\n", + " ratio = df[it] / (csr * df[\"unit_size\"] / 100)\n", + " prev = ratio.groupby(df[\"unit\"]).shift(1)\n", + " d = pd.DataFrame({\"unit\": df[\"unit\"], \"day\": df[\"day\"], \"phase\": df[\"phase\"], \"item\": it, \"item_idx\": i,\n", + " \"is_fuel\": int(it in FUEL), \"lag1\": prev, \"lag2\": ratio.groupby(df[\"unit\"]).shift(2),\n", + " \"roll3\": prev.groupby(df[\"unit\"]).transform(lambda s: s.rolling(3).mean()),\n", + " \"roll7\": prev.groupby(df[\"unit\"]).transform(lambda s: s.rolling(7, min_periods=3).mean()),\n", + " \"csr_base\": csr, \"size100\": df[\"unit_size\"] / 100,\n", + " \"fire_plan\": df[\"fire_missions\"], \"fire_x_engaged\": df[\"fire_missions\"] * df[\"phase\"].isin([\"attack\", \"defense\"]),\n", + " \"resist_lag1\": df[\"resist_lag1\"], \"resist_trend\": df[\"resist_trend\"], \"inten_lag1\": df[\"inten_lag1\"],\n", + " \"move_plan\": df[\"move_km\"], \"days_in_phase\": df[\"days_in_phase\"],\n", + " \"engaged\": df[\"phase\"].isin([\"attack\", \"defense\"]).astype(int),\n", + " \"ph_attack\": (df[\"phase\"] == \"attack\").astype(int), \"ph_defense\": (df[\"phase\"] == \"defense\").astype(int),\n", + " \"ph_prep\": (df[\"phase\"] == \"prep\").astype(int), \"emergency\": df[\"emergency\"],\n", + " \"y\": np.log(ratio), \"qty\": df[it]})\n", + " out.append(d)\n", + " return pd.concat(out, ignore_index=True).dropna(subset=[\"lag2\", \"roll3\", \"resist_trend\"]).reset_index(drop=True)\n", + "\n", + "\n", + "def to_qty(d, y_pred):\n", + " \"\"\"예측 보정 계수(log)를 소모량으로 되돌린다: 기준량 x exp(예측).\"\"\"\n", + " return np.exp(y_pred) * d[\"csr_base\"].values * d[\"size100\"].values\n", + "\n", + "\n", + "def mape(y, p):\n", + " return float(np.mean(np.abs(np.asarray(p) - np.asarray(y)) / np.asarray(y)))\n", + "\n", + "# ---- 소모량 예측 모델(앙상블) 학습·예측 ----\n", + "def fit_models(tr, seed=0):\n", + " \"\"\"GBR + 랜덤포레스트 두 모델을 학습한다(목표값은 CSR 기준량 대비 log 보정 계수).\"\"\"\n", + " g = GradientBoostingRegressor(n_estimators=150, max_depth=3, learning_rate=0.08, subsample=0.8, random_state=seed).fit(tr[FEATS], tr[\"y\"])\n", + " r = RandomForestRegressor(n_estimators=120, min_samples_leaf=3, random_state=seed, n_jobs=1).fit(tr[FEATS], tr[\"y\"])\n", + " return g, r\n", + "\n", + "\n", + "def predict_qty(models, d, w=0.5):\n", + " \"\"\"두 모델의 log 예측을 가중 평균해 소모량으로 되돌린다.\"\"\"\n", + " g, r = models\n", + " return to_qty(d, w * g.predict(d[FEATS]) + (1 - w) * r.predict(d[FEATS]))\n", + "\n", + "# ---------------------------------------------------------------------------\n", + "# [교육용가상자료] 가상 저장고(H1~H3) 저장칸 배치 모델. 실제 탄약 저장 기준·안전거리와 무관한 가상 계수다.\n", + "# 저장칸: 저장고 x 열(row) x 칸(col). 동선 거리 = 반출장(입구)까지의 가상 거리(m).\n", + "# 저장군(G1~G3)은 가상 혼적 제한 그룹이며, 저장고마다 받을 수 있는 저장군이 정해져 있다.\n", + "# ---------------------------------------------------------------------------\n", + "HOUSES = {\"H1\": {\"groups\": {\"G1\", \"G2\"}, \"rows\": 2, \"cols\": 6, \"dock\": 15.0},\n", + " \"H2\": {\"groups\": {\"G1\"}, \"rows\": 2, \"cols\": 5, \"dock\": 40.0},\n", + " \"H3\": {\"groups\": {\"G2\", \"G3\"}, \"rows\": 2, \"cols\": 6, \"dock\": 70.0}}\n", + "ITEM_GROUP = {\"ammo_A\": \"G1\", \"ammo_B\": \"G1\", \"ammo_C\": \"G2\", \"ammo_D\": \"G3\"}\n", + "SLOT_CAP = 6 # 1열 저장칸 가상 팔레트 수용량(2열은 +4)\n", + "\n", + "\n", + "def make_slots(seed=3):\n", + " \"\"\"저장칸 목록과 현재 점유(이동 불가 기존 재고) 여부를 만든다.\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " rows = []\n", + " for h, c in HOUSES.items():\n", + " for r in range(c[\"rows\"]):\n", + " for k in range(c[\"cols\"]):\n", + " rows.append({\"slot\": f\"{h}-{r + 1}{k + 1:02d}\", \"house\": h, \"dist_m\": c[\"dock\"] + 6.0 * k + 4.0 * r,\n", + " \"cap\": SLOT_CAP if r == 0 else SLOT_CAP + 4, \"fixed\": bool(rng.random() < 0.15)})\n", + " return pd.DataFrame(rows)\n", + "\n", + "\n", + "def make_skus(demand, seed=5):\n", + " \"\"\"품목별 예측 일일 반출량(demand: 품목->상자/일)을 로트 단위 SKU로 나눈다. 반출 빈도 = 일일 반출 횟수(가상).\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " rows = []\n", + " for it, dq in demand.items():\n", + " n_lot = 5\n", + " share = rng.dirichlet(np.ones(n_lot) * 2)\n", + " for j in range(n_lot):\n", + " rows.append({\"sku\": f\"{it}-L{j + 1}\", \"item\": it, \"group\": ITEM_GROUP[it],\n", + " \"pallets\": int(rng.integers(2, SLOT_CAP + 5)), \"freq\": round(float(dq * share[j] / 4), 2)})\n", + " return pd.DataFrame(rows)\n", + "\n", + "\n", + "def feasible(sku, slot):\n", + " return (sku[\"group\"] in HOUSES[slot[\"house\"]][\"groups\"]) and sku[\"pallets\"] <= slot[\"cap\"] and not slot[\"fixed\"]\n", + "\n", + "\n", + "def travel(assign, skus, slots):\n", + " \"\"\"작업동선 = 반출 빈도 x 왕복 거리 합(가상 m/일).\"\"\"\n", + " d = slots.set_index(\"slot\")[\"dist_m\"]\n", + " return float(sum(2 * skus.loc[i, \"freq\"] * d[s] for i, s in assign.items()))\n", + "\n", + "\n", + "def first_fit(skus, slots):\n", + " \"\"\"현행 방식(대안 비교 기준): 입고 순서대로 비어 있는 첫 번째 적합 칸에 넣는다.\"\"\"\n", + " used, out = set(), {}\n", + " for i, k in skus.iterrows():\n", + " for _, s in slots.iterrows():\n", + " if s[\"slot\"] not in used and feasible(k, s):\n", + " out[i] = s[\"slot\"]; used.add(s[\"slot\"]); break\n", + " return out\n", + "\n", + "\n", + "def optimize(skus, slots, lam=8):\n", + " \"\"\"할당 문제(정수계획 특수형): 비용 = 반출 빈도 x 왕복거리 + lam x 빈 공간(공간낭비). 부적합 칸은 큰 비용.\"\"\"\n", + " cost = np.full((len(skus), len(slots)), 1e7)\n", + " for i, k in skus.iterrows():\n", + " for j, s in slots.iterrows():\n", + " if feasible(k, s):\n", + " cost[i, j] = 2 * k[\"freq\"] * s[\"dist_m\"] + lam * (s[\"cap\"] - k[\"pallets\"])\n", + " ri, cj = linear_sum_assignment(cost)\n", + " return {int(i): slots.loc[j, \"slot\"] for i, j in zip(ri, cj) if cost[i, j] < 1e7}, cost\n", + "\n", + "\n", + "def loc_score(assign, skus, slots):\n", + " \"\"\"위치점수(0~100): 반출 빈도 상위 SKU일수록 반출장 가까운 칸에 있는 정도(가중 평균).\"\"\"\n", + " d = slots.set_index(\"slot\")[\"dist_m\"]; dmin, dmax = d.min(), d.max()\n", + " w = skus[\"freq\"] / skus[\"freq\"].sum()\n", + " return float(100 * sum(w[i] * (1 - (d[s] - dmin) / (dmax - dmin)) for i, s in assign.items()))\n", + "# ---------------------------------------------------------------------------\n", + "\n", + "DOS_ALERT, DOS_TARGET, PLAN_DAY = 2.0, 3.0, 85\n", + "t_all = time.perf_counter()\n", + "checks = []\n", + "\n", + "# 1) 데이터 수집·정합성 점검\n", + "ops = make_ops(90, seed=7)\n", + "ok_schema = ops[\"intensity\"].between(0, 1).all() and (ops[ITEMS] >= 0).all().all() and not ops.duplicated([\"unit\", \"day\"]).any()\n", + "checks.append((\"원천 자료 정합성(범위·음수·중복)\", ok_schema))\n", + "data = build_long(ops)\n", + "print(f\"[1] 가상 작전 기록 {len(ops)}건 -> 학습 행 {len(data)}개, 정합성 {'통과' if ok_schema else '실패'}\")\n", + "\n", + "# 2) 운영 모델(v1: 60일 이전 학습) -> 3) 재학습 파이프라인(v2: 80일 이전 학습), 최근 10일로 비교 후 교체 결정\n", + "val = data[data[\"day\"] >= 80]\n", + "v1 = fit_models(data[data[\"day\"] < 60])\n", + "v2 = fit_models(data[data[\"day\"] < 80])\n", + "e1, e2 = mape(val[\"qty\"], predict_qty(v1, val)), mape(val[\"qty\"], predict_qty(v2, val))\n", + "base = mape(val[\"qty\"], val[\"csr_base\"] * val[\"size100\"])\n", + "champ = v2 if e2 <= e1 else v1\n", + "print(f\"[2] 검증(80~89일) MAPE: CSR Baseline {base:.1%}, 운영 모델 v1 {e1:.1%}, 재학습 v2 {e2:.1%} -> {'v2로 교체' if e2 <= e1 else 'v1 유지'}\")\n", + "checks += [(\"자동 재학습 파이프라인 실행·비교\", True), (\"운영 모델 MAPE 25% 이하\", min(e1, e2) <= 0.25)]\n", + "\n", + "# 4) 전술 재보급 계획(85일 기준)\n", + "rng = np.random.default_rng(11)\n", + "day = data[data[\"day\"] == PLAN_DAY].copy()\n", + "day[\"pred\"] = predict_qty(champ, day)\n", + "day[\"on_hand\"] = np.round(day[\"csr_base\"] * day[\"size100\"] * rng.uniform(1.0, 5.0, len(day)))\n", + "day[\"dos\"] = day[\"on_hand\"] / day[\"pred\"]\n", + "day[\"need\"] = np.maximum(0, np.ceil(DOS_TARGET * day[\"pred\"] - day[\"on_hand\"]))\n", + "urg = day[day[\"dos\"] < DOS_ALERT].sort_values(\"dos\")\n", + "print(f\"[3] {PLAN_DAY}일 재보급 계획: 긴급 {len(urg)}건, 재보급 총량 탄약 {day.loc[day['is_fuel'] == 0, 'need'].sum():.0f}상자 / 유류 {day.loc[day['is_fuel'] == 1, 'need'].sum():.0f}드럼(가상)\")\n", + "for _, r in urg.head(4).iterrows():\n", + " print(f\" {r['unit']} {r['item']}: DOS {r['dos']:.1f}일, 재보급 {r['need']:.0f}\")\n", + "\n", + "# 5) 저장 배치 최적화(예측 탄약 반출 수요 기준)\n", + "demand = day[day[\"is_fuel\"] == 0].groupby(\"item\")[\"pred\"].sum().round(1).to_dict()\n", + "skus, slots = make_skus(demand), make_slots()\n", + "cur, (opt, _) = first_fit(skus, slots), optimize(skus, slots)\n", + "tc, to = travel(cur, skus, slots), travel(opt, skus, slots)\n", + "print(f\"[4] 저장 배치: 작업동선 {tc:.0f} -> {to:.0f} m/일 ({1 - to / tc:.1%} 단축), 위치점수 {loc_score(cur, skus, slots):.1f} -> {loc_score(opt, skus, slots):.1f}\")\n", + "checks.append((\"저장 배치 작업동선 15% 이상 단축\", 1 - to / tc >= 0.15))\n", + "\n", + "# 6) 결재 대기열(자동 확정 금지)\n", + "queue = [{\"id\": f\"Q{k + 1:02d}\", \"type\": \"긴급 재보급\", \"unit\": r[\"unit\"], \"item\": r[\"item\"], \"qty\": int(r[\"need\"]), \"status\": \"결재대기\"} for k, (_, r) in enumerate(urg.iterrows())]\n", + "queue += [{\"id\": f\"Q{len(queue) + 1:02d}\", \"type\": \"저장 재배치\", \"moves\": int(sum(cur.get(i) != opt[i] for i in opt)), \"status\": \"결재대기\"}]\n", + "auto = [q for q in queue if q[\"status\"] != \"결재대기\"]\n", + "print(f\"[5] 군수참모 결재 대기 {len(queue)}건, 자동 확정 {len(auto)}건\")\n", + "checks.append((\"자동 확정 0건(HITL)\", not auto))\n", + "\n", + "# 7) 운영 가이드 목차·연계 인터페이스\n", + "GUIDE = {\"1. 목적·범위\": \"가상 부대 탄약·유류 소모 예측과 저장 배치 지원\",\n", + " \"2. 데이터 규격\": f\"작전 기록 {ops.shape[1]}개 필드, 품목 {len(ITEMS)}종\",\n", + " \"3. 전처리 규칙\": \"범위·음수·중복 점검, 긴급 불출 보존\",\n", + " \"4. 예측 모델\": f\"GBR+RF 앙상블, 검증 MAPE {min(e1, e2):.1%}\",\n", + " \"5. DOS 재보급 엔진\": f\"경보 {DOS_ALERT:.0f}일 미만, 목표 {DOS_TARGET:.0f}일\",\n", + " \"6. 저장 배치\": f\"할당 최적화, 동선 {1 - to / tc:.0%} 단축\",\n", + " \"7. 결재선·감사\": \"자동 확정 금지, 사유 기록\",\n", + " \"8. 재학습\": \"최근 자료로 재학습 후 검증 MAPE가 낮을 때만 교체\",\n", + " \"9. 예외 대응\": \"보고 누락 시 CSR 기준값 대입, 긴급 요청은 즉시 경보\",\n", + " \"10. 연계 인터페이스\": \"RESUPPLY_REQ 가상 메시지 규격\"}\n", + "done = np.mean([bool(v) for v in GUIDE.values()])\n", + "print(\"[6] 운영 가이드 목차:\", \" / \".join(GUIDE))\n", + "checks.append((\"운영 가이드 10개 장 완비\", done == 1))\n", + "msg = {\"msg_type\": \"RESUPPLY_REQ\", \"version\": \"1.0\", \"unit_id\": queue[0].get(\"unit\", \"U00\") if queue else \"U00\",\n", + " \"item_code\": queue[0].get(\"item\", \"-\") if queue else \"-\", \"qty\": queue[0].get(\"qty\", 0) if queue else 0,\n", + " \"dos_days\": round(float(urg[\"dos\"].iloc[0]), 2) if len(urg) else None, \"model_ver\": \"v2\" if champ is v2 else \"v1\",\n", + " \"approval\": \"PENDING\", \"source\": \"교육용가상자료\"}\n", + "print(\"[7] 체계 연계 메시지 예시(가상 규격):\", json.dumps(msg, ensure_ascii=False))\n", + "elapsed = time.perf_counter() - t_all\n", + "checks.append((\"통합 실행 60초 이내\", elapsed < 60))\n", + "\n", + "print(\"\\n[자체 점검표]\")\n", + "for name, ok in checks:\n", + " print(f\" [{'PASS' if ok else 'FAIL'}] {name}\")\n", + "print(f\"점검 {sum(ok for _, ok in checks)}/{len(checks)} 통과, 운영 가이드 완비율 {done:.0%}, 전체 실행 {elapsed:.1f}s\")\n", + "\n", + "print(\"\\n[브리핑 요약]\")\n", + "print(f\"- 소모량 예측 MAPE {min(e1, e2):.1%}로 CSR 고정 계수({base:.1%}) 대비 오차를 줄였고, 재학습 모델은 비교 후 교체한다.\")\n", + "print(f\"- {PLAN_DAY}일 기준 긴급 재보급 {len(urg)}건을 DOS 순으로 정렬해 결재 대기열에 올렸다.\")\n", + "print(f\"- 가상 저장고 배치 최적화로 반출 작업동선을 {1 - to / tc:.0%} 줄이는 안을 제시했다(재배치는 결재 후 시행).\")\n", + "print(\"- 모든 불출·대체 탄종·재배치는 군수참모 결재 후 집행하며, 수치는 교육용 가상 자료 기준이다.\")\n", + "\n", + "fig, ax = plt.subplots(1, 2, figsize=(12, 4))\n", + "ax[0].bar([\"CSR baseline\", \"model v1\", \"model v2\"], [base, e1, e2], color=[\"gray\", \"tab:blue\", \"tab:green\"])\n", + "ax[0].axhline(0.25, c=\"r\", ls=\"--\"); ax[0].set_title(\"Validation MAPE (day 80-89)\")\n", + "ax[1].barh(day[\"unit\"] + \"-\" + day[\"item\"], day[\"dos\"].clip(upper=6), color=np.where(day[\"dos\"] < DOS_ALERT, \"tab:red\", \"tab:gray\"))\n", + "ax[1].axvline(DOS_ALERT, c=\"r\", ls=\"--\"); ax[1].set_title(f\"Days of supply on day {PLAN_DAY}\"); ax[1].tick_params(axis=\"y\", labelsize=5)\n", + "plt.tight_layout(); plt.show()\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 10 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + "[1] 가상 작전 기록 720건 -> 학습 행 4176개, 정합성 통과\n", + "[2] 검증(80~89일) MAPE: CSR Baseline 46.5%, 운영 모델 v1 13.3%, 재학습 v2 14.7% -> v1 유지\n", + "[3] 85일 재보급 계획: 긴급 6건, 재보급 총량 탄약 23상자 / 유류 221드럼(가상)\n", + " U08 fuel_diesel: DOS 1.1일, 재보급 61\n", + " U01 fuel_diesel: DOS 1.2일, 재보급 72\n", + " U04 ammo_A: DOS 1.6일, 재보급 4\n", + " U06 ammo_D: DOS 1.6일, 재보급 2\n", + "[4] 저장 배치: 작업동선 1588 -> 1260 m/일 (20.6% 단축), 위치점수 60.4 -> 72.0\n", + "[5] 군수참모 결재 대기 7건, 자동 확정 0건\n", + "[6] 운영 가이드 목차: 1. 목적·범위 / 2. 데이터 규격 / 3. 전처리 규칙 / 4. 예측 모델 / 5. DOS 재보급 엔진 / 6. 저장 배치 / 7. 결재선·감사 / 8. 재학습 / 9. 예외 대응 / 10. 연계 인터페이스\n", + "[7] 체계 연계 메시지 예시(가상 규격): {\"msg_type\": \"RESUPPLY_REQ\", \"version\": \"1.0\", \"unit_id\": \"U08\", \"item_code\": \"fuel_diesel\", \"qty\": 61, \"dos_days\": 1.14, \"model_ver\": \"v1\", \"approval\": \"PENDING\", \"source\": \"교육용가상자료\"}\n", + "\n", + "[자체 점검표]\n", + " [PASS] 원천 자료 정합성(범위·음수·중복)\n", + " [PASS] 자동 재학습 파이프라인 실행·비교\n", + " [PASS] 운영 모델 MAPE 25% 이하\n", + " [PASS] 저장 배치 작업동선 15% 이상 단축\n", + " [PASS] 자동 확정 0건(HITL)\n", + " [PASS] 운영 가이드 10개 장 완비\n", + " [PASS] 통합 실행 60초 이내\n", + "점검 7/7 통과, 운영 가이드 완비율 100%, 전체 실행 7.1s\n", + "\n", + "[브리핑 요약]\n", + "- 소모량 예측 MAPE 13.3%로 CSR 고정 계수(46.5%) 대비 오차를 줄였고, 재학습 모델은 비교 후 교체한다.\n", + "- 85일 기준 긴급 재보급 6건을 DOS 순으로 정렬해 결재 대기열에 올렸다.\n", + "- 가상 저장고 배치 최적화로 반출 작업동선을 21% 줄이는 안을 제시했다(재배치는 결재 후 시행).\n", + "- 모든 불출·대체 탄종·재배치는 군수참모 결재 후 집행하며, 수치는 교육용 가상 자료 기준이다.\n", + "```\n", + "
\n" + ] + } + ], + "metadata": { + "colab": { + "provenance": [] + }, + "kernelspec": { + "display_name": "Python 3", + "name": "python3" + }, + "language_info": { + "name": "python" + } + }, + "nbformat": 4, + "nbformat_minor": 0 +} \ No newline at end of file diff --git a/uploads/AMM02/AMM02_student.ipynb b/uploads/AMM02/AMM02_student.ipynb new file mode 100644 index 0000000..cbe968d --- /dev/null +++ b/uploads/AMM02/AMM02_student.ipynb @@ -0,0 +1,2691 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# 🎯 [AMM02] 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/AMM02/AMM02_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", + "탄약 저장고는 공간이 한정되어 있고 품목마다 함께 둘 수 없는 제약이 있어, 앞으로 얼마나 소모되고 얼마나 자주 반출될지를 알아야 입고 위치와 재보급 순서를 합리적으로 정할 수 있다. 그러나 작전 단계별 고정 기준 소모율(CSR)만으로 계획하면 교전 강도가 낮은 날은 과잉 재고가 쌓이고 높은 날은 긴급 재보급이 반복되어 저장 공간과 작업 동선이 낭비된다. 본 프로젝트는 교육용 가상 부대·가상 품목·가상 저장고 자료를 코드로 만들어, 탄약·유류 소모율 데이터 정의와 전처리, CSR 기반 Baseline과 탐색적 분석, 적 저항·화력 계획을 반영한 소모량 예측, 보급 수준(DOS) 연계 재보급 산출, 작전 지도 대시보드와 설명가능성, 저장 배치 최적화와 군수참모 결재선(HITL), 7일 고강도 스트레스 시험, 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. 작전 단계별 탄약(구경별) 및 유류(항공유/경유) 기준 소모율(CSR) 데이터 정의\n", + "**소요시간**: 약 1.5시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "작전 단계별 탄약 및 유류 기준 소모율(CSR) 데이터 규격을 정의한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **작전 단계별 가상 탄약(탄종 A~D)·유류(항공유/경유) 기준 소모율(CSR) 데이터 규격과 업무 프로세스 맵, 데이터 딕셔너리를 정의한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **일일 소모 보고 수집부터 소모량 산출, 보급 수준(DOS) 계산, 재보급 결재, 가상 저장고 입고·배치까지 업무 흐름을 5단계로 정리하고 규격화하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `docs/amm02_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": [ + "\"\"\"작전 단계별 탄약(가상 탄종 A~D)·유류(항공유/경유) 기준 소모율(CSR) 데이터 규격, 업무 프로세스 맵, 데이터 딕셔너리(필드 20개 이상)를 정의하고 가상 샘플 기록을 검증한다.\"\"\"\n", + "import json\n", + "import re\n", + "import numpy as np\n", + "import pandas as pd\n", + "\n", + "# 1) 업무 프로세스 맵: 소모 기록 -> 소모 예측 -> 보급 수준(DOS) -> 재보급·저장 배치\n", + "PROCESS = [\"(1) 일일 소모 보고 수집\", \"(2) 작전 단계·교전 강도 반영 소모량 산출\", \"(3) 보유량 대비 보급 수준(DOS) 계산\",\n", + " \"(4) 재보급 소요·우선순위 결정(군수참모 결재)\", \"(5) 가상 저장고 입고·배치(위치점수·작업동선)\"]\n", + "\n", + "# 2) 가상 품목·작전 단계·CSR 계수 (실제 탄약 제원·소모율이 아닌 교육용 가상 계수)\n", + "PHASES = [\"prep\", \"attack\", \"defense\", \"stability\"]\n", + "ITEMS = {\"ammo_A\": \"탄약\", \"ammo_B\": \"탄약\", \"ammo_C\": \"탄약\", \"ammo_D\": \"탄약\", \"fuel_jet\": \"유류\", \"fuel_diesel\": \"유류\"}\n", + "CSR = pd.DataFrame([[1.0, 0.6, 0.3, 0.2, 1.5, 6.0], [6.0, 3.5, 2.0, 1.2, 5.0, 12.0],\n", + " [4.5, 3.0, 1.6, 0.8, 3.0, 7.0], [0.8, 0.5, 0.3, 0.2, 1.0, 5.0]], index=PHASES, columns=list(ITEMS))\n", + "\n", + "# 3) 데이터 딕셔너리: (그룹, 필드, 자료형, 규칙, 설명)\n", + "FIELDS = [\n", + " (\"식별\", \"record_id\", str, {\"pattern\": r\"^R\\d{5}$\"}, \"기록 번호\"),\n", + " (\"식별\", \"unit_id\", str, {\"pattern\": r\"^U\\d{2}$\"}, \"가상 부대 코드\"),\n", + " (\"식별\", \"day_of_op\", int, {\"min\": 0, \"max\": 365}, \"작전 경과일\"),\n", + " (\"작전\", \"phase\", str, {\"enum\": PHASES}, \"작전 단계\"),\n", + " (\"작전\", \"unit_size\", int, {\"min\": 50, \"max\": 3000}, \"가상 병력 규모(명)\"),\n", + " (\"작전\", \"intensity\", float, {\"min\": 0, \"max\": 1}, \"교전 강도(0~1)\"),\n", + " (\"작전\", \"resistance\", float, {\"min\": 0, \"max\": 5}, \"적 저항 수준(0~5)\"),\n", + " (\"작전\", \"fire_missions\", int, {\"min\": 0, \"max\": 100}, \"계획 화력 임무 수\"),\n", + " (\"작전\", \"move_km\", float, {\"min\": 0, \"max\": 300}, \"일일 이동 거리(km)\"),\n", + " (\"작전\", \"weather\", str, {\"enum\": [\"clear\", \"rain\", \"snow\"]}, \"기상\"),\n", + " (\"품목\", \"item_code\", str, {\"enum\": list(ITEMS)}, \"가상 품목 코드\"),\n", + " (\"품목\", \"item_class\", str, {\"enum\": [\"탄약\", \"유류\"]}, \"품목 분류\"),\n", + " (\"품목\", \"csr_coeff\", float, {\"min\": 0, \"max\": 50}, \"기준 소모율(가상 계수, 100명·1일)\"),\n", + " (\"품목\", \"unit_per_pallet\", int, {\"min\": 1, \"max\": 200}, \"팔레트당 단위 수(가상)\"),\n", + " (\"소모\", \"consumption_qty\", float, {\"min\": 0, \"max\": 5000}, \"실제 소모량(단위)\"),\n", + " (\"소모\", \"emergency_issue\", bool, {}, \"긴급 불출 여부(예외 보존)\"),\n", + " (\"재고\", \"on_hand_qty\", float, {\"min\": 0, \"max\": 50000}, \"보유량(단위)\"),\n", + " (\"재고\", \"dos\", float, {\"min\": 0, \"max\": 60}, \"보급 수준(일분)\"),\n", + " (\"저장\", \"storage_id\", str, {\"enum\": [\"S1\", \"S2\", \"S3\"]}, \"가상 저장고\"),\n", + " (\"저장\", \"slot_id\", str, {\"pattern\": r\"^[A-C]\\d{2}$\"}, \"저장 위치(열·칸)\"),\n", + " (\"저장\", \"slot_distance_m\", float, {\"min\": 0, \"max\": 500}, \"출입구까지 거리(작업동선)\"),\n", + " (\"저장\", \"occupancy_rate\", float, {\"min\": 0, \"max\": 1}, \"저장 위치 점유율\"),\n", + " (\"관리\", \"source\", str, {\"enum\": [\"일일보고\", \"AAR\", \"추정\"]}, \"자료 출처\"),\n", + "]\n", + "\n", + "\n", + "def check(name, value, typ, rule):\n", + " # [TODO : 학생 실습 1] 작전 단계별 탄약(구경별) 및 유류(항공유/경유) 기준 소모율(CSR) 데이터 정의 관련 핵심 로직을 구현하세요.\n", + " # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n", + " # <<< 여기에 코드를 작성하세요 >>>\n", + " pass # 작성 후 pass를 제거하세요.\n", + "def validate(rec):\n", + " # [TODO : 학생 실습 1] 작전 단계별 탄약(구경별) 및 유류(항공유/경유) 기준 소모율(CSR) 데이터 정의 관련 핵심 로직을 구현하세요.\n", + " # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n", + " # <<< 여기에 코드를 작성하세요 >>>\n", + " pass # 작성 후 pass를 제거하세요.\n", + "rng = np.random.default_rng(1)\n", + "records = []\n", + "for i in range(30):\n", + " ph, it, size = str(rng.choice(PHASES)), str(rng.choice(list(ITEMS))), int(rng.integers(3, 9)) * 100\n", + " qty = float(round(CSR.loc[ph, it] * size / 100 * rng.uniform(0.7, 1.4), 1))\n", + " stock = float(round(qty * rng.uniform(1, 8), 1))\n", + " records.append({\"record_id\": f\"R{i:05d}\", \"unit_id\": f\"U{int(rng.integers(1, 9)):02d}\", \"day_of_op\": int(rng.integers(0, 90)),\n", + " \"phase\": ph, \"unit_size\": size, \"intensity\": float(round(rng.uniform(0, 1), 2)),\n", + " \"resistance\": float(round(rng.uniform(0, 5), 1)), \"fire_missions\": int(rng.integers(0, 12)),\n", + " \"move_km\": float(round(rng.uniform(0, 40), 1)), \"weather\": str(rng.choice([\"clear\", \"rain\", \"snow\"])),\n", + " \"item_code\": it, \"item_class\": ITEMS[it], \"csr_coeff\": float(CSR.loc[ph, it]),\n", + " \"unit_per_pallet\": 20 if ITEMS[it] == \"탄약\" else 4, \"consumption_qty\": qty,\n", + " \"emergency_issue\": bool(rng.random() < 0.05), \"on_hand_qty\": stock, \"dos\": round(stock / qty, 1),\n", + " \"storage_id\": str(rng.choice([\"S1\", \"S2\", \"S3\"])), \"slot_id\": f\"{'ABC'[i % 3]}{i % 10:02d}\",\n", + " \"slot_distance_m\": float(5 + (i % 10) * 6), \"occupancy_rate\": float(round(rng.uniform(0.2, 1), 2)), \"source\": \"일일보고\"})\n", + "\n", + "print(\"[업무 프로세스 맵] \" + \" -> \".join(PROCESS))\n", + "ok = sum(1 for r in records if not validate(r))\n", + "print(f\"\\n정상 샘플 {len(records)}건 중 검증 통과 {ok}건, 오류 {len(records) - ok}건\")\n", + "\n", + "# 5) 오류 주입 시험\n", + "bad = json.loads(json.dumps(records[0]))\n", + "bad[\"phase\"] = \"assault\"\n", + "bad[\"intensity\"] = 1.7\n", + "del bad[\"dos\"]\n", + "bad[\"slot_id\"] = \"Z-1\"\n", + "bad[\"unit_size\"] = \"600명\"\n", + "print(\"\\n[오류 주입 결과]\")\n", + "for e in validate(bad):\n", + " print(\" -\", e)\n", + "bad2 = dict(records[1], item_code=\"fuel_jet\", item_class=\"탄약\")\n", + "print(\" -\", validate(bad2)[0])\n", + "\n", + "# 6) 데이터 딕셔너리와 CSR 기준표\n", + "dd = pd.DataFrame([(g, f, t.__name__, \", \".join(f\"{k}={v}\" for k, v in r.items()) or \"-\", d) for g, f, t, r, d in FIELDS],\n", + " columns=[\"그룹\", \"필드\", \"자료형\", \"규칙\", \"설명\"])\n", + "print(f\"\\n[데이터 딕셔너리] 필드 {len(dd)}개 (목표 20개 이상: {'달성' if len(dd) >= 20 else '미달'})\")\n", + "for g, s in dd.groupby(\"그룹\", sort=False)[\"필드\"]:\n", + " print(f\" {g}: {', '.join(s)}\")\n", + "print(\"\\n[작전 단계별 기준 소모율(CSR) - 가상 계수, 100명·1일 기준]\")\n", + "print(CSR.to_string())\n", + "print(\"\\n주의: 품목 코드·계수·저장고는 모두 교육용 가상 값이다. 실제 체계 연동 시 국방 표준 군수 코드(재고번호 등) 체계에 맞춰 필드를 대응시킨다.\")\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 01 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + "[업무 프로세스 맵] (1) 일일 소모 보고 수집 -> (2) 작전 단계·교전 강도 반영 소모량 산출 -> (3) 보유량 대비 보급 수준(DOS) 계산 -> (4) 재보급 소요·우선순위 결정(군수참모 결재) -> (5) 가상 저장고 입고·배치(위치점수·작업동선)\n", + "\n", + "정상 샘플 30건 중 검증 통과 30건, 오류 0건\n", + "\n", + "[오류 주입 결과]\n", + " - phase: 허용되지 않은 값 'assault'\n", + " - unit_size: 자료형 오류(str)\n", + " - intensity: 최대값 1 초과\n", + " - dos: 필수 필드 누락\n", + " - slot_id: 형식 불일치 'Z-1'\n", + " - item_class: 품목 코드와 분류 불일치\n", + "\n", + "[데이터 딕셔너리] 필드 23개 (목표 20개 이상: 달성)\n", + " 식별: record_id, unit_id, day_of_op\n", + " 작전: phase, unit_size, intensity, resistance, fire_missions, move_km, weather\n", + " 품목: item_code, item_class, csr_coeff, unit_per_pallet\n", + " 소모: consumption_qty, emergency_issue\n", + " 재고: on_hand_qty, dos\n", + " 저장: storage_id, slot_id, slot_distance_m, occupancy_rate\n", + " 관리: source\n", + "\n", + "[작전 단계별 기준 소모율(CSR) - 가상 계수, 100명·1일 기준]\n", + " ammo_A ammo_B ammo_C ammo_D fuel_jet fuel_diesel\n", + "prep 1.0 0.6 0.3 0.2 1.5 6.0\n", + "attack 6.0 3.5 2.0 1.2 5.0 12.0\n", + "defense 4.5 3.0 1.6 0.8 3.0 7.0\n", + "stability 0.8 0.5 0.3 0.2 1.0 5.0\n", + "\n", + "주의: 품목 코드·계수·저장고는 모두 교육용 가상 값이다. 실제 체계 연동 시 국방 표준 군수 코드(재고번호 등) 체계에 맞춰 필드를 대응시킨다.\n", + "```\n", + "
\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 02. 부대 규모, 교전 강도, 이동 거리 변수 정규화 및 소모량 산출 전처리 모듈 구현\n", + "**소요시간**: 약 2.0시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "부대 규모, 교전 강도, 이동 거리 변수 정규화 전처리 모듈을 구현한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **부대 규모, 교전 강도, 이동 거리 변수를 정제·정규화하고 병력 100명당 환산 소모율을 산출하는 전처리 모듈을 구현한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **중복 기록 제거, 표기 통일, 백분율·자릿수 오기 보정, 음수·이상치 결측 처리, KNN 결측 보정 등 정제 규칙 10종을 적용 수행하고 검증하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `src/etl/amm02_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": [ + "# [TODO : 학생 실습 2] 부대 규모, 교전 강도, 이동 거리 변수 정규화 및 소모량 산출 전처리 모듈 구현\n", + "# 미션 지침 및 검증 기준을 확인하고 코드를 실행 및 튜닝하세요.\n", + "\"\"\"부대 규모·교전 강도·이동 거리 변수를 정제·정규화하고, 병력 100명당 환산 소모율을 산출하는 전처리 모듈을 구현한다. 정제 규칙 10종을 적용하고 긴급 불출 같은 군수 예외 기록은 삭제하지 않고 보존하는지 검증한다.\"\"\"\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", + "# [교육용가상자료] 가상 부대·가상 품목 일일 소모 기록 생성기 (실제 탄약 제원·소모율과 무관)\n", + "# 가상 부대 8개 x 90일. 소모량 = 가상 CSR 계수 x 병력 규모 x (교전 강도·적 저항·화력 계획 효과) x 잡음\n", + "# ---------------------------------------------------------------------------\n", + "UNITS = [f\"U{i:02d}\" for i in range(1, 9)]\n", + "ITEMS = [\"ammo_A\", \"ammo_B\", \"ammo_C\", \"ammo_D\", \"fuel_jet\", \"fuel_diesel\"]\n", + "AMMO, FUEL = ITEMS[:4], ITEMS[4:]\n", + "PHASES = [\"prep\", \"attack\", \"defense\", \"stability\"]\n", + "CSR = { # 가상 계수: 병력 100명·1일 기준 소모 단위(탄약=상자, 유류=드럼)\n", + " \"prep\": [1.0, 0.6, 0.3, 0.2, 1.5, 6.0],\n", + " \"attack\": [6.0, 3.5, 2.0, 1.2, 5.0, 12.0],\n", + " \"defense\": [4.5, 3.0, 1.6, 0.8, 3.0, 7.0],\n", + " \"stability\": [0.8, 0.5, 0.3, 0.2, 1.0, 5.0],\n", + "}\n", + "PHASE_RESIST = {\"prep\": 1.0, \"attack\": 3.2, \"defense\": 3.6, \"stability\": 0.8}\n", + "\n", + "\n", + "def make_ops(n_days=90, seed=7, surge=0):\n", + " \"\"\"가상 부대별 일일 작전 기록과 품목별 실제 소모량(정답)을 만든다. surge>0이면 마지막 surge일을 고강도 공격 단계로 바꾼다(스트레스 시험용).\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " rows = []\n", + " for uid in UNITS:\n", + " size = int(rng.integers(3, 9)) * 100\n", + " lens = rng.integers(8, 20, 4)\n", + " sched = [\"prep\"] * lens[0] + [\"attack\"] * lens[1] + [\"defense\"] * lens[2] + [\"attack\"] * lens[3]\n", + " sched = (sched + [\"stability\"] * n_days)[:n_days]\n", + " if surge:\n", + " sched[-surge:] = [\"attack\"] * surge\n", + " r = 1.0\n", + " for d, ph in enumerate(sched):\n", + " r = float(np.clip(0.7 * r + 0.3 * (4.8 if surge and d >= n_days - surge else PHASE_RESIST[ph]) + rng.normal(0, 0.35), 0, 5))\n", + " inten = float(np.clip(r / 5 * 0.9 + rng.normal(0, 0.06), 0, 1))\n", + " fire = int(rng.poisson(1 + 8 * inten))\n", + " move = float(rng.gamma(2, {\"prep\": 4, \"attack\": 12, \"defense\": 2, \"stability\": 5}[ph]))\n", + " weather = str(rng.choice([\"clear\", \"rain\", \"snow\"], p=[0.7, 0.2, 0.1]))\n", + " emerg = bool(rng.random() < 0.02)\n", + " rec = {\"unit\": uid, \"day\": d, \"phase\": ph, \"unit_size\": size, \"intensity\": round(inten, 3),\n", + " \"resistance\": round(r, 2), \"fire_missions\": fire, \"move_km\": round(move, 1),\n", + " \"weather\": weather, \"emergency\": emerg}\n", + " base = size / 100\n", + " for i, it in enumerate(ITEMS):\n", + " c = CSR[ph][i] * base\n", + " if it in AMMO:\n", + " c *= (0.5 + 0.6 * inten) * (1 + 0.04 * fire * (1.0 if it in (\"ammo_C\", \"ammo_D\") else 0.4))\n", + " elif it == \"fuel_jet\":\n", + " c *= (0.6 + 0.5 * inten) + 0.03 * fire\n", + " else:\n", + " c = c * (0.8 + 0.2 * (weather != \"clear\")) + 0.05 * move * base\n", + " c *= rng.lognormal(0, 0.12) * (2.5 if emerg else 1.0)\n", + " rec[it] = round(float(c), 1)\n", + " rows.append(rec)\n", + " return pd.DataFrame(rows)\n", + "# ---------------------------------------------------------------------------\n", + "\n", + "\n", + "# 1) [교육용가상자료] 원천 기록에 현장 입력 오류를 일부러 섞는다\n", + "clean_truth = make_ops(90, seed=7)\n", + "raw = clean_truth.copy()\n", + "rng = np.random.default_rng(3)\n", + "n = len(raw)\n", + "idx = lambda k: rng.choice(n, k, replace=False)\n", + "raw = raw.astype({\"phase\": object, \"weather\": object, \"unit_size\": float, \"fire_missions\": float})\n", + "raw.loc[idx(15), \"phase\"] = raw[\"phase\"].str.upper() # 표기 불일치\n", + "raw.loc[idx(20), \"weather\"] = \"비\" # 한글 표기 혼용\n", + "raw.loc[idx(12), \"intensity\"] = raw[\"intensity\"] * 100 # 백분율로 입력\n", + "raw.loc[idx(10), \"ammo_A\"] = -raw[\"ammo_A\"] # 부호 오류\n", + "raw.loc[idx(6), \"fire_missions\"] = -1\n", + "raw.loc[idx(8), \"move_km\"] = 999.0 # 계기 오류\n", + "raw.loc[idx(18), \"intensity\"] = np.nan # 결측\n", + "raw.loc[idx(14), \"move_km\"] = np.nan\n", + "raw.loc[idx(5), \"unit_size\"] = raw[\"unit_size\"] * 10 # 단위 오기(자릿수)\n", + "raw = pd.concat([raw, raw.sample(12, random_state=1)]).sample(frac=1, random_state=2).reset_index(drop=True) # 중복·순서 섞임\n", + "n_emerg = int(clean_truth[\"emergency\"].sum())\n", + "\n", + "# 2) 정제 규칙 10종\n", + "log = []\n", + "df = raw.copy()\n", + "before = len(df); df = df.drop_duplicates([\"unit\", \"day\"]); log.append((\"R01\", \"부대·일자 중복 기록 제거\", before - len(df)))\n", + "m = df[\"phase\"] != df[\"phase\"].str.strip().str.lower(); df[\"phase\"] = df[\"phase\"].str.strip().str.lower(); log.append((\"R02\", \"작전 단계 표기 통일\", int(m.sum())))\n", + "wmap = {\"비\": \"rain\", \"눈\": \"snow\", \"맑음\": \"clear\"}; m = df[\"weather\"].isin(wmap); df[\"weather\"] = df[\"weather\"].replace(wmap); log.append((\"R03\", \"기상 표기 통일(한글->코드)\", int(m.sum())))\n", + "m = df[\"intensity\"] > 1; df.loc[m, \"intensity\"] /= 100; log.append((\"R04\", \"교전 강도 백분율 입력 환산\", int(m.sum())))\n", + "m = df[\"unit_size\"] > 3000; df.loc[m, \"unit_size\"] /= 10; log.append((\"R05\", \"병력 규모 자릿수 오기 보정\", int(m.sum())))\n", + "m = df[ITEMS].lt(0).any(axis=1) | df[\"fire_missions\"].lt(0)\n", + "df[ITEMS] = df[ITEMS].mask(df[ITEMS] < 0); df[\"fire_missions\"] = df[\"fire_missions\"].mask(df[\"fire_missions\"] < 0); log.append((\"R06\", \"음수 소모량·화력 임무 결측 처리\", int(m.sum())))\n", + "m = (df[\"move_km\"] > 150) & ~df[\"emergency\"]; df.loc[m, \"move_km\"] = np.nan; log.append((\"R07\", \"이동 거리 이상치(150km 초과) 결측 처리\", int(m.sum())))\n", + "num = [\"unit_size\", \"intensity\", \"resistance\", \"fire_missions\", \"move_km\"] + ITEMS\n", + "n_missing = int(df[num].isna().any(axis=1).sum())\n", + "mu, sd = df[num].mean(), df[num].std()\n", + "df[num] = KNNImputer(n_neighbors=5).fit_transform((df[num] - mu) / sd) * sd.values + mu.values\n", + "df[\"fire_missions\"] = df[\"fire_missions\"].round(); log.append((\"R08\", \"결측값 KNN 보정(유사 기록 5건)\", n_missing))\n", + "df = df.sort_values([\"unit\", \"day\"]).reset_index(drop=True)\n", + "gaps = int(df.groupby(\"unit\")[\"day\"].apply(lambda s: (s.diff().dropna() != 1).sum()).sum()); log.append((\"R09\", \"부대별 일자 정렬·연속성 확인(누락일)\", gaps))\n", + "kept = int(df[\"emergency\"].sum()); log.append((\"R10\", \"긴급 불출 예외 기록 보존(삭제 금지)\", kept))\n", + "print(pd.DataFrame(log, columns=[\"규칙\", \"내용\", \"처리 건수\"]).to_string(index=False))\n", + "\n", + "# 3) 정규화와 환산 소모율 산출\n", + "for c in [\"unit_size\", \"intensity\", \"move_km\"]:\n", + " df[c + \"_n\"] = (df[c] - df[c].min()) / (df[c].max() - df[c].min())\n", + "for it in ITEMS:\n", + " df[it + \"_rate\"] = df[it] / (df[\"unit_size\"] / 100) # 병력 100명·1일 환산 소모율\n", + "\n", + "# 4) 정합성 검증\n", + "checks = {\n", + " \"중복 0건\": not df.duplicated([\"unit\", \"day\"]).any(),\n", + " \"결측 0건\": not df[num].isna().any().any(),\n", + " \"작전 단계 허용값\": df[\"phase\"].isin(PHASES).all(),\n", + " \"기상 허용값\": df[\"weather\"].isin([\"clear\", \"rain\", \"snow\"]).all(),\n", + " \"교전 강도 0~1\": df[\"intensity\"].between(0, 1).all(),\n", + " \"소모량 0 이상\": (df[ITEMS] >= 0).all().all(),\n", + " \"병력 규모 복원 일치\": (df[\"unit_size\"].values == clean_truth.sort_values([\"unit\", \"day\"])[\"unit_size\"].values).all(),\n", + " \"정규화 범위 0~1\": df[[c + \"_n\" for c in [\"unit_size\", \"intensity\", \"move_km\"]]].stack().between(0, 1).all(),\n", + " \"일자 연속성\": gaps == 0,\n", + " \"긴급 불출 기록 보존\": kept == n_emerg,\n", + "}\n", + "rate = np.mean(list(checks.values()))\n", + "print(f\"\\n정합성 검증 {sum(checks.values())}/{len(checks)} 통과, 통과율 {rate:.0%} (목표 100%: {'달성' if rate == 1 else '미달'})\")\n", + "print(f\"정제 규칙 {len(log)}종 적용, 원천 {len(raw)}건 -> 정제 {len(df)}건, 긴급 불출 {kept}건 보존\")\n", + "\n", + "# 5) 전후 기술통계 비교\n", + "cols = [\"unit_size\", \"intensity\", \"move_km\", \"ammo_A\"]\n", + "comp = pd.DataFrame({\"정제 전 평균\": raw[cols].mean(), \"정제 후 평균\": df[cols].mean(),\n", + " \"정제 전 최대\": raw[cols].max(), \"정제 후 최대\": df[cols].max()}).round(2)\n", + "print(\"\\n[정제 전후 기술통계]\")\n", + "print(comp.to_string())\n", + "print(\"\\n[작전 단계별 환산 소모율 평균(100명·1일, 가상 단위)]\")\n", + "print(df.groupby(\"phase\")[[it + \"_rate\" for it in ITEMS]].mean().round(2).rename(columns=lambda c: c[:-5]).to_string())\n", + "\n", + "fig, ax = plt.subplots(1, 3, figsize=(12, 3.5))\n", + "for a, c in zip(ax, [\"intensity\", \"move_km\", \"unit_size\"]):\n", + " a.hist(raw[c].dropna(), bins=30, alpha=0.5, label=\"raw\"); a.hist(df[c], bins=30, alpha=0.7, label=\"cleaned\")\n", + " a.set_title(c); a.set_yscale(\"log\"); a.legend()\n", + "plt.suptitle(\"AMM02 preprocessing: raw vs cleaned (synthetic)\"); plt.tight_layout(); plt.show()\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 02 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + " 규칙 내용 처리 건수\n", + "R01 부대·일자 중복 기록 제거 12\n", + "R02 작전 단계 표기 통일 15\n", + "R03 기상 표기 통일(한글->코드) 20\n", + "R04 교전 강도 백분율 입력 환산 12\n", + "R05 병력 규모 자릿수 오기 보정 5\n", + "R06 음수 소모량·화력 임무 결측 처리 16\n", + "R07 이동 거리 이상치(150km 초과) 결측 처리 8\n", + "R08 결측값 KNN 보정(유사 기록 5건) 54\n", + "R09 부대별 일자 정렬·연속성 확인(누락일) 0\n", + "R10 긴급 불출 예외 기록 보존(삭제 금지) 13\n", + "\n", + "정합성 검증 10/10 통과, 통과율 100% (목표 100%: 달성)\n", + "정제 규칙 10종 적용, 원천 732건 -> 정제 720건, 긴급 불출 13건 보존\n", + "\n", + "[정제 전후 기술통계]\n", + " 정제 전 평균 정제 후 평균 정제 전 최대 정제 후 최대\n", + "unit_size 688.66 650.00 8000.0 800.00\n", + "intensity 0.89 0.35 74.4 0.89\n", + "move_km 23.52 12.51 999.0 93.10\n", + "ammo_A 16.89 17.27 142.1 142.10\n", + "\n", + "[작전 단계별 환산 소모율 평균(100명·1일, 가상 단위)]\n", + " ammo_A ammo_B ammo_C ammo_D fuel_jet fuel_diesel\n", + "phase \n", + "attack 5.63 3.24 2.07 1.25 5.36 11.93\n", + "defense 4.61 3.08 1.82 0.93 3.55 6.58\n", + "prep 0.64 0.39 0.20 0.14 1.18 5.66\n", + "stability 0.50 0.32 0.20 0.13 0.77 4.86\n", + "```\n", + "
\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 03. 교전 일수 기반 1차 탄약/유류 기본 소모량 계산 Baseline 알고리즘 구현\n", + "**소요시간**: 약 2.5시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "교전 일수 기반 1차 탄약/유류 기본 소모량 계산 Baseline 알고리즘을 구현한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **교전 일수 x 작전 단계별 CSR x 병력 규모로 1차 탄약·유류 기본 소모량을 계산하는 Baseline 알고리즘을 구현하고 탐색적 분석으로 한계를 찾는다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **부대별 90일 계획량과 실제 소모량을 비교하고 품목별 일일 MAPE 산출하고 모델에 적용하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `notebooks/amm02_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": [ + "\"\"\"교전 일수 x 작전 단계별 기준 소모율(CSR) x 병력 규모로 1차 탄약·유류 기본 소모량을 계산하는 Baseline 알고리즘을 구현하고, 탐색적 분석(EDA 차트 8종, 상관·유의성 검정, 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", + "# [교육용가상자료] 가상 부대·가상 품목 일일 소모 기록 생성기 (실제 탄약 제원·소모율과 무관)\n", + "# 가상 부대 8개 x 90일. 소모량 = 가상 CSR 계수 x 병력 규모 x (교전 강도·적 저항·화력 계획 효과) x 잡음\n", + "# ---------------------------------------------------------------------------\n", + "UNITS = [f\"U{i:02d}\" for i in range(1, 9)]\n", + "ITEMS = [\"ammo_A\", \"ammo_B\", \"ammo_C\", \"ammo_D\", \"fuel_jet\", \"fuel_diesel\"]\n", + "AMMO, FUEL = ITEMS[:4], ITEMS[4:]\n", + "PHASES = [\"prep\", \"attack\", \"defense\", \"stability\"]\n", + "CSR = { # 가상 계수: 병력 100명·1일 기준 소모 단위(탄약=상자, 유류=드럼)\n", + " \"prep\": [1.0, 0.6, 0.3, 0.2, 1.5, 6.0],\n", + " \"attack\": [6.0, 3.5, 2.0, 1.2, 5.0, 12.0],\n", + " \"defense\": [4.5, 3.0, 1.6, 0.8, 3.0, 7.0],\n", + " \"stability\": [0.8, 0.5, 0.3, 0.2, 1.0, 5.0],\n", + "}\n", + "PHASE_RESIST = {\"prep\": 1.0, \"attack\": 3.2, \"defense\": 3.6, \"stability\": 0.8}\n", + "\n", + "\n", + "def make_ops(n_days=90, seed=7, surge=0):\n", + " \"\"\"가상 부대별 일일 작전 기록과 품목별 실제 소모량(정답)을 만든다. surge>0이면 마지막 surge일을 고강도 공격 단계로 바꾼다(스트레스 시험용).\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " rows = []\n", + " for uid in UNITS:\n", + " size = int(rng.integers(3, 9)) * 100\n", + " lens = rng.integers(8, 20, 4)\n", + " sched = [\"prep\"] * lens[0] + [\"attack\"] * lens[1] + [\"defense\"] * lens[2] + [\"attack\"] * lens[3]\n", + " sched = (sched + [\"stability\"] * n_days)[:n_days]\n", + " if surge:\n", + " sched[-surge:] = [\"attack\"] * surge\n", + " r = 1.0\n", + " for d, ph in enumerate(sched):\n", + " r = float(np.clip(0.7 * r + 0.3 * (4.8 if surge and d >= n_days - surge else PHASE_RESIST[ph]) + rng.normal(0, 0.35), 0, 5))\n", + " inten = float(np.clip(r / 5 * 0.9 + rng.normal(0, 0.06), 0, 1))\n", + " fire = int(rng.poisson(1 + 8 * inten))\n", + " move = float(rng.gamma(2, {\"prep\": 4, \"attack\": 12, \"defense\": 2, \"stability\": 5}[ph]))\n", + " weather = str(rng.choice([\"clear\", \"rain\", \"snow\"], p=[0.7, 0.2, 0.1]))\n", + " emerg = bool(rng.random() < 0.02)\n", + " rec = {\"unit\": uid, \"day\": d, \"phase\": ph, \"unit_size\": size, \"intensity\": round(inten, 3),\n", + " \"resistance\": round(r, 2), \"fire_missions\": fire, \"move_km\": round(move, 1),\n", + " \"weather\": weather, \"emergency\": emerg}\n", + " base = size / 100\n", + " for i, it in enumerate(ITEMS):\n", + " c = CSR[ph][i] * base\n", + " if it in AMMO:\n", + " c *= (0.5 + 0.6 * inten) * (1 + 0.04 * fire * (1.0 if it in (\"ammo_C\", \"ammo_D\") else 0.4))\n", + " elif it == \"fuel_jet\":\n", + " c *= (0.6 + 0.5 * inten) + 0.03 * fire\n", + " else:\n", + " c = c * (0.8 + 0.2 * (weather != \"clear\")) + 0.05 * move * base\n", + " c *= rng.lognormal(0, 0.12) * (2.5 if emerg else 1.0)\n", + " rec[it] = round(float(c), 1)\n", + " rows.append(rec)\n", + " return pd.DataFrame(rows)\n", + "# ---------------------------------------------------------------------------\n", + "\n", + "\n", + "def baseline(df):\n", + " \"\"\"Baseline: 해당 일의 작전 단계 CSR x (병력/100). 교전 강도·적 저항·화력 계획은 반영하지 않는다.\"\"\"\n", + " # [TODO : 학생 실습 3] 교전 일수 기반 1차 탄약/유류 기본 소모량 계산 Baseline 알고리즘 구현 관련 핵심 로직을 구현하세요.\n", + " # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n", + " # <<< 여기에 코드를 작성하세요 >>>\n", + " pass # 작성 후 pass를 제거하세요.\n", + "df = make_ops(90, seed=7)\n", + "base = baseline(df)\n", + "\n", + "# 1) 교전 일수 기반 부대별 작전 기간 총소모량(계획) vs 실제\n", + "df[\"engaged\"] = df[\"phase\"].isin([\"attack\", \"defense\"])\n", + "plan = pd.concat([df[[\"unit\"]], base], axis=1).groupby(\"unit\").sum()\n", + "real = df.groupby(\"unit\")[ITEMS].sum()\n", + "days = df.groupby(\"unit\")[\"engaged\"].sum()\n", + "tot = pd.DataFrame({\"교전일수\": days, \"탄약 계획\": plan[AMMO].sum(axis=1).round(), \"탄약 실제\": real[AMMO].sum(axis=1).round(),\n", + " \"유류 계획\": plan[FUEL].sum(axis=1).round(), \"유류 실제\": real[FUEL].sum(axis=1).round()})\n", + "tot[\"탄약 오차율\"] = ((tot[\"탄약 계획\"] - tot[\"탄약 실제\"]) / tot[\"탄약 실제\"]).map(\"{:+.1%}\".format)\n", + "print(\"[부대별 90일 총소모량: Baseline 계획 vs 실제(가상 단위)]\")\n", + "print(tot.to_string())\n", + "\n", + "# 2) 일일 예측 오차(MAPE)\n", + "mape = (np.abs(base - df[ITEMS]) / df[ITEMS]).mean()\n", + "print(\"\\nBaseline 일일 MAPE:\", \", \".join(f\"{k} {v:.1%}\" for k, v in mape.items()))\n", + "\n", + "# 3) 상관·유의성 검정: 병력 100명당 탄약 소모율과 각 변수\n", + "df[\"ammo_rate\"] = df[AMMO].sum(axis=1) / (df[\"unit_size\"] / 100)\n", + "df[\"bad_weather\"] = (df[\"weather\"] != \"clear\").astype(int)\n", + "cand = [\"intensity\", \"resistance\", \"fire_missions\", \"move_km\", \"engaged\", \"day\", \"bad_weather\", \"unit_size\"]\n", + "rows = []\n", + "for c in cand:\n", + " r, p = stats.pearsonr(df[c].astype(float), df[\"ammo_rate\"])\n", + " rows.append({\"변수\": c, \"상관계수\": round(r, 3), \"p-value\": f\"{p:.1e}\", \"유의(p<0.05,|r|>=0.2)\": p < 0.05 and abs(r) >= 0.2})\n", + "corr = pd.DataFrame(rows).sort_values(\"상관계수\", key=abs, ascending=False)\n", + "n_sig = int(corr.iloc[:, 3].sum())\n", + "print(\"\\n[탄약 환산 소모율과의 상관·유의성]\")\n", + "print(corr.to_string(index=False))\n", + "print(f\"유의미한 상관 변수 {n_sig}개 (목표 5개 이상: {'달성' if n_sig >= 5 else '미달'})\")\n", + "\n", + "# 4) ABC 파레토 분석: 부대 x 탄종 조합별 총소모량(상자) 상위 20%\n", + "pareto = real[AMMO].stack().sort_values(ascending=False)\n", + "top = pareto.iloc[:int(np.ceil(len(pareto) * 0.2))]\n", + "print(f\"\\nABC 분석: 상위 20% 조합({len(top)}개)이 전체 소모량의 {top.sum() / pareto.sum():.1%}를 차지 -> A등급 집중 관리\")\n", + "print(\"A등급 예시:\", \", \".join(f\"{u}-{i}\" for u, i in top.index[:5]))\n", + "\n", + "# 5) EDA 차트 8종\n", + "fig, ax = plt.subplots(2, 4, figsize=(16, 7))\n", + "ax = ax.ravel()\n", + "df.groupby(\"phase\")[AMMO].mean().plot.bar(ax=ax[0], title=\"1. Mean ammo use by phase\")\n", + "ax[1].scatter(df[\"intensity\"], df[\"ammo_rate\"], s=6, alpha=0.5); ax[1].set_title(\"2. Intensity vs ammo rate\")\n", + "ax[2].scatter(df[\"fire_missions\"], df[\"ammo_rate\"], s=6, alpha=0.5, c=\"tab:red\"); ax[2].set_title(\"3. Fire missions vs ammo rate\")\n", + "ax[3].scatter(df[\"move_km\"], df[\"fuel_diesel\"] / (df[\"unit_size\"] / 100), s=6, alpha=0.5, c=\"tab:green\"); ax[3].set_title(\"4. Move km vs diesel rate\")\n", + "u = df[df[\"unit\"] == \"U01\"]\n", + "ax[4].plot(u[\"day\"], u[AMMO].sum(axis=1), label=\"actual\"); ax[4].plot(u[\"day\"], base.loc[u.index, AMMO].sum(axis=1), label=\"baseline\")\n", + "ax[4].legend(); ax[4].set_title(\"5. U01 daily ammo: actual vs baseline\")\n", + "im = ax[5].imshow(df[cand + [\"ammo_rate\"]].astype(float).corr(), cmap=\"coolwarm\", vmin=-1, vmax=1)\n", + "ax[5].set_xticks(range(9)); ax[5].set_xticklabels(cand + [\"ammo_rate\"], rotation=90, fontsize=7); ax[5].set_yticks([])\n", + "ax[5].set_title(\"6. Correlation heatmap\")\n", + "ax[6].bar(range(len(pareto)), pareto.cumsum() / pareto.sum()); ax[6].axhline(0.8, ls=\"--\", c=\"k\"); ax[6].set_title(\"7. Pareto (unit x ammo item)\")\n", + "ax[7].bar(mape.index, mape.values, color=\"gray\"); ax[7].set_title(\"8. Baseline MAPE by item\"); ax[7].tick_params(axis=\"x\", rotation=45)\n", + "plt.suptitle(\"AMM02 EDA - synthetic consumption data\"); plt.tight_layout(); plt.show()\n", + "print(\"EDA 차트 8종 생성 (목표 8종 이상: 달성)\")\n", + "print(\"핵심 발견: Baseline은 교전 강도·화력 계획을 반영하지 않아 고강도 일에는 과소, 저강도 일에는 과대 산출한다 -> 미션 4에서 변수 반영 모델로 고도화한다.\")\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 03 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + "[부대별 90일 총소모량: Baseline 계획 vs 실제(가상 단위)]\n", + " 교전일수 탄약 계획 탄약 실제 유류 계획 유류 실제 탄약 오차율\n", + "unit \n", + "U01 48 5114.0 5120.0 7716.0 7959.0 -0.1%\n", + "U02 40 3952.0 3885.0 6328.0 6678.0 +1.7%\n", + "U03 46 3725.0 3493.0 5715.0 5616.0 +6.6%\n", + "U04 41 2893.0 2586.0 4618.0 4504.0 +11.9%\n", + "U05 36 2139.0 1971.0 3574.0 3355.0 +8.5%\n", + "U06 34 4028.0 3598.0 6820.0 6404.0 +12.0%\n", + "U07 26 2945.0 2578.0 5408.0 5282.0 +14.2%\n", + "U08 49 4541.0 4124.0 6776.0 6643.0 +10.1%\n", + "\n", + "Baseline 일일 MAPE: ammo_A 43.8%, ammo_B 42.9%, ammo_C 38.1%, ammo_D 37.9%, fuel_jet 25.9%, fuel_diesel 16.8%\n", + "\n", + "[탄약 환산 소모율과의 상관·유의성]\n", + " 변수 상관계수 p-value 유의(p<0.05,|r|>=0.2)\n", + " engaged 0.923 1.4e-300 True\n", + " resistance 0.854 9.2e-206 True\n", + " intensity 0.838 1.1e-190 True\n", + "fire_missions 0.639 7.9e-84 True\n", + " day -0.378 6.9e-26 True\n", + " move_km 0.294 7.5e-16 True\n", + " bad_weather 0.072 5.2e-02 False\n", + " unit_size 0.017 6.4e-01 False\n", + "유의미한 상관 변수 6개 (목표 5개 이상: 달성)\n", + "\n", + "ABC 분석: 상위 20% 조합(7개)이 전체 소모량의 43.0%를 차지 -> A등급 집중 관리\n", + "A등급 예시: U01-ammo_A, U08-ammo_A, U02-ammo_A, U06-ammo_A, U03-ammo_A\n", + "EDA 차트 8종 생성 (목표 8종 이상: 달성)\n", + "핵심 발견: Baseline은 교전 강도·화력 계획을 반영하지 않아 고강도 일에는 과소, 저강도 일에는 과대 산출한다 -> 미션 4에서 변수 반영 모델로 고도화한다.\n", + "```\n", + "
\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 04. 적 저항 수준 및 화력 운용 계획 연계 일자별 소모량 시계열 예측 모델 고도화\n", + "**소요시간**: 약 3.0시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "작전 계획 연계 일자별 소모량 시계열 예측 모델을 고도화한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **적 저항 수준(전일 실적)과 화력 운용 계획을 연계한 파생 피처로 일자별 소모량 시계열 예측 모델을 고도화한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **전일·최근 평균 소모 비율, CSR 계수, 교전 중 화력 계획, 적 저항 추세 등 사전에 알 수 있는 정보만으로 파생 피처 10개 이상 작성하여 원인을 분석하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `src/features/amm02_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": [ + "\"\"\"적 저항 수준(전일 실적)과 화력 운용 계획(당일 계획 화력 임무 수)을 연계한 파생 피처를 만들고, 일자별 탄약·유류 소모량 시계열 예측 모델을 시계열 교차검증으로 평가해 Baseline(CSR 고정 계수)과 비교한다.\"\"\"\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", + "# [교육용가상자료] 가상 부대·가상 품목 일일 소모 기록 생성기 (실제 탄약 제원·소모율과 무관)\n", + "# 가상 부대 8개 x 90일. 소모량 = 가상 CSR 계수 x 병력 규모 x (교전 강도·적 저항·화력 계획 효과) x 잡음\n", + "# ---------------------------------------------------------------------------\n", + "UNITS = [f\"U{i:02d}\" for i in range(1, 9)]\n", + "ITEMS = [\"ammo_A\", \"ammo_B\", \"ammo_C\", \"ammo_D\", \"fuel_jet\", \"fuel_diesel\"]\n", + "AMMO, FUEL = ITEMS[:4], ITEMS[4:]\n", + "PHASES = [\"prep\", \"attack\", \"defense\", \"stability\"]\n", + "CSR = { # 가상 계수: 병력 100명·1일 기준 소모 단위(탄약=상자, 유류=드럼)\n", + " \"prep\": [1.0, 0.6, 0.3, 0.2, 1.5, 6.0],\n", + " \"attack\": [6.0, 3.5, 2.0, 1.2, 5.0, 12.0],\n", + " \"defense\": [4.5, 3.0, 1.6, 0.8, 3.0, 7.0],\n", + " \"stability\": [0.8, 0.5, 0.3, 0.2, 1.0, 5.0],\n", + "}\n", + "PHASE_RESIST = {\"prep\": 1.0, \"attack\": 3.2, \"defense\": 3.6, \"stability\": 0.8}\n", + "\n", + "\n", + "def make_ops(n_days=90, seed=7, surge=0):\n", + " \"\"\"가상 부대별 일일 작전 기록과 품목별 실제 소모량(정답)을 만든다. surge>0이면 마지막 surge일을 고강도 공격 단계로 바꾼다(스트레스 시험용).\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " rows = []\n", + " for uid in UNITS:\n", + " size = int(rng.integers(3, 9)) * 100\n", + " lens = rng.integers(8, 20, 4)\n", + " sched = [\"prep\"] * lens[0] + [\"attack\"] * lens[1] + [\"defense\"] * lens[2] + [\"attack\"] * lens[3]\n", + " sched = (sched + [\"stability\"] * n_days)[:n_days]\n", + " if surge:\n", + " sched[-surge:] = [\"attack\"] * surge\n", + " r = 1.0\n", + " for d, ph in enumerate(sched):\n", + " r = float(np.clip(0.7 * r + 0.3 * (4.8 if surge and d >= n_days - surge else PHASE_RESIST[ph]) + rng.normal(0, 0.35), 0, 5))\n", + " inten = float(np.clip(r / 5 * 0.9 + rng.normal(0, 0.06), 0, 1))\n", + " fire = int(rng.poisson(1 + 8 * inten))\n", + " move = float(rng.gamma(2, {\"prep\": 4, \"attack\": 12, \"defense\": 2, \"stability\": 5}[ph]))\n", + " weather = str(rng.choice([\"clear\", \"rain\", \"snow\"], p=[0.7, 0.2, 0.1]))\n", + " emerg = bool(rng.random() < 0.02)\n", + " rec = {\"unit\": uid, \"day\": d, \"phase\": ph, \"unit_size\": size, \"intensity\": round(inten, 3),\n", + " \"resistance\": round(r, 2), \"fire_missions\": fire, \"move_km\": round(move, 1),\n", + " \"weather\": weather, \"emergency\": emerg}\n", + " base = size / 100\n", + " for i, it in enumerate(ITEMS):\n", + " c = CSR[ph][i] * base\n", + " if it in AMMO:\n", + " c *= (0.5 + 0.6 * inten) * (1 + 0.04 * fire * (1.0 if it in (\"ammo_C\", \"ammo_D\") else 0.4))\n", + " elif it == \"fuel_jet\":\n", + " c *= (0.6 + 0.5 * inten) + 0.03 * fire\n", + " else:\n", + " c = c * (0.8 + 0.2 * (weather != \"clear\")) + 0.05 * move * base\n", + " c *= rng.lognormal(0, 0.12) * (2.5 if emerg else 1.0)\n", + " rec[it] = round(float(c), 1)\n", + " rows.append(rec)\n", + " return pd.DataFrame(rows)\n", + "# ---------------------------------------------------------------------------\n", + "\n", + "# ---- 소모량 예측용 파생 피처: 전일까지의 실적 + 당일 작전·화력 계획(사전에 알 수 있는 값만) ----\n", + "FEATS = [\"lag1\", \"lag2\", \"roll3\", \"roll7\", \"csr_base\", \"size100\", \"fire_plan\", \"fire_x_engaged\", \"resist_lag1\",\n", + " \"resist_trend\", \"inten_lag1\", \"move_plan\", \"days_in_phase\", \"engaged\", \"ph_attack\", \"ph_defense\", \"ph_prep\",\n", + " \"item_idx\", \"is_fuel\"]\n", + "DERIVED = [\"lag1\", \"lag2\", \"roll3\", \"roll7\", \"csr_base\", \"fire_x_engaged\", \"resist_lag1\", \"resist_trend\",\n", + " \"inten_lag1\", \"days_in_phase\", \"engaged\", \"ph_attack\", \"ph_defense\", \"ph_prep\"]\n", + "\n", + "\n", + "def build_long(ops):\n", + " \"\"\"부대x일자 기록을 (부대, 일자, 품목) 행으로 펼치고 파생 피처를 만든다.\n", + " # [TODO : 학생 실습 4] 적 저항 수준 및 화력 운용 계획 연계 일자별 소모량 시계열 예측 모델 고도화 관련 핵심 로직을 구현하세요.\n", + " # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n", + " # <<< 여기에 코드를 작성하세요 >>>\n", + " pass # 작성 후 pass를 제거하세요.\n", + "def to_qty(d, y_pred):\n", + " \"\"\"예측 보정 계수(log)를 소모량으로 되돌린다: 기준량 x exp(예측).\"\"\"\n", + " # [TODO : 학생 실습 4] 적 저항 수준 및 화력 운용 계획 연계 일자별 소모량 시계열 예측 모델 고도화 관련 핵심 로직을 구현하세요.\n", + " # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n", + " # <<< 여기에 코드를 작성하세요 >>>\n", + " pass # 작성 후 pass를 제거하세요.\n", + "def mape(y, p):\n", + " return float(np.mean(np.abs(np.asarray(p) - np.asarray(y)) / np.asarray(y)))\n", + "\n", + "\n", + "data = build_long(make_ops(90, seed=7)).sort_values([\"day\", \"unit\", \"item\"]).reset_index(drop=True)\n", + "print(f\"학습 행 {len(data)}개(부대 8 x 품목 6 x 일자), 입력 피처 {len(FEATS)}개 중 파생 피처 {len(DERIVED)}개 (목표 10개 이상: {'달성' if len(DERIVED) >= 10 else '미달'})\")\n", + "\n", + "# 1) 시계열 교차검증: 과거 일자로 학습하고 이후 일자를 예측(미래 정보 누설 방지)\n", + "days = np.sort(data[\"day\"].unique())\n", + "rows = []\n", + "for k, (tr_d, te_d) in enumerate(TimeSeriesSplit(n_splits=5).split(days), start=1):\n", + " tr, te = data[data[\"day\"].isin(days[tr_d])], data[data[\"day\"].isin(days[te_d])]\n", + " m = GradientBoostingRegressor(n_estimators=150, max_depth=3, learning_rate=0.08, subsample=0.8, random_state=0)\n", + " m.fit(tr[FEATS], tr[\"y\"])\n", + " pred = to_qty(te, m.predict(te[FEATS]))\n", + " base = te[\"csr_base\"] * te[\"size100\"]\n", + " rows.append({\"폴드\": k, \"검증 일자\": f\"{days[te_d][0]}~{days[te_d][-1]}\", \"Baseline MAPE\": mape(te[\"qty\"], base),\n", + " \"모델 MAPE\": mape(te[\"qty\"], pred), \"전일 비율 유지 MAPE\": mape(te[\"qty\"], te[\"lag1\"] * te[\"csr_base\"] * te[\"size100\"])})\n", + "cv = pd.DataFrame(rows)\n", + "print(\"\\n[시계열 교차검증 결과]\")\n", + "print(cv.to_string(index=False, float_format=lambda x: f\"{x:.1%}\"))\n", + "avg = cv[\"모델 MAPE\"].mean()\n", + "print(f\"평균 MAPE: Baseline {cv['Baseline MAPE'].mean():.1%} -> 모델 {avg:.1%} (목표 25% 이하: {'달성' if avg <= 0.25 else '미달'})\")\n", + "\n", + "# 2) 최종 모델: 앞 70일 학습, 마지막 18일 검증\n", + "tr, te = data[data[\"day\"] < 72], data[data[\"day\"] >= 72]\n", + "model = GradientBoostingRegressor(n_estimators=150, max_depth=3, learning_rate=0.08, subsample=0.8, random_state=0).fit(tr[FEATS], tr[\"y\"])\n", + "te = te.assign(pred=to_qty(te, model.predict(te[FEATS])), base=te[\"csr_base\"] * te[\"size100\"])\n", + "by_item = te.groupby(\"item\")[[\"qty\", \"pred\", \"base\"]].apply(lambda g: pd.Series({\"Baseline\": mape(g[\"qty\"], g[\"base\"]), \"모델\": mape(g[\"qty\"], g[\"pred\"])}))\n", + "print(\"\\n[품목별 검증 MAPE(마지막 18일)]\")\n", + "print(by_item.T.to_string(float_format=lambda x: f\"{x:.1%}\"))\n", + "\n", + "# 3) 피처 중요도(차원 축소 후보 확인)\n", + "imp = pd.Series(model.feature_importances_, index=FEATS).sort_values(ascending=False)\n", + "print(\"\\n피처 중요도 상위 6:\", \", \".join(f\"{k}({v:.2f})\" for k, v in imp.head(6).items()))\n", + "print(\"중요도 0.01 미만(제거 후보):\", \", \".join(imp[imp < 0.01].index) or \"없음\")\n", + "\n", + "# 4) 시각화\n", + "full = data.assign(pred=to_qty(data, model.predict(data[FEATS])))\n", + "u = full[(full[\"unit\"] == \"U01\") & (full[\"item\"] == \"ammo_A\")]\n", + "fig, ax = plt.subplots(1, 2, figsize=(13, 4))\n", + "ax[0].plot(u[\"day\"], u[\"qty\"], label=\"actual\"); ax[0].plot(u[\"day\"], u[\"pred\"], label=\"model\")\n", + "ax[0].plot(u[\"day\"], u[\"csr_base\"] * u[\"size100\"], ls=\"--\", label=\"baseline (CSR)\"); ax[0].axvline(72, c=\"gray\", ls=\":\")\n", + "ax[0].set_title(\"U01 ammo_A daily forecast (right of dotted line = hold-out)\"); ax[0].set_xlabel(\"day\"); ax[0].legend()\n", + "imp.head(10)[::-1].plot.barh(ax=ax[1], title=\"Feature importance (top 10)\")\n", + "plt.tight_layout(); plt.show()\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 04 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + "학습 행 4176개(부대 8 x 품목 6 x 일자), 입력 피처 19개 중 파생 피처 14개 (목표 10개 이상: 달성)\n", + "\n", + "[시계열 교차검증 결과]\n", + " 폴드 검증 일자 Baseline MAPE 모델 MAPE 전일 비율 유지 MAPE\n", + " 1 20~33 15.4% 15.4% 23.0%\n", + " 2 34~47 17.7% 19.0% 20.4%\n", + " 3 48~61 33.4% 13.0% 17.3%\n", + " 4 62~75 51.8% 13.6% 21.7%\n", + " 5 76~89 48.7% 13.3% 21.8%\n", + "평균 MAPE: Baseline 33.4% -> 모델 14.9% (목표 25% 이하: 달성)\n", + "\n", + "[품목별 검증 MAPE(마지막 18일)]\n", + "item ammo_A ammo_B ammo_C ammo_D fuel_diesel fuel_jet\n", + "Baseline 66.2% 64.7% 60.0% 55.2% 15.4% 36.6%\n", + "모델 12.7% 12.3% 13.0% 13.6% 13.5% 12.6%\n", + "\n", + "피처 중요도 상위 6: roll3(0.25), resist_lag1(0.25), csr_base(0.09), fire_x_engaged(0.09), fire_plan(0.07), roll7(0.04)\n", + "중요도 0.01 미만(제거 후보): lag2, ph_defense, ph_attack, engaged, ph_prep\n", + "```\n", + "
\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 05. 부대별 일일 보급 수준(DOS) 연계 긴급 재보급 소요 산출 엔진 개발\n", + "**소요시간**: 약 3.5시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "보급 수준(DOS) 연계 긴급 재보급 소요 산출 엔진을 개발한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **앙상블 예측 모델과 보유량으로 부대·품목별 보급 수준(DOS)을 계산해 긴급 재보급 소요를 산출하는 엔진을 개발한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **GBR과 랜덤포레스트를 앙상블하고 가중치는 학습 기간 안의 검증 구간으로만 고른다 과정을 진행하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `src/models/amm02_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": [ + "\"\"\"GBR·랜덤포레스트 앙상블로 소모량 예측을 고도화해 Hold-out 기간에서 Baseline 대비 MAE/RMSE 개선율과 R2를 검증하고, 예측 소모량과 보유량으로 부대·품목별 보급 수준(DOS)을 계산해 긴급 재보급 소요를 산출하는 엔진을 구현한다.\"\"\"\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.metrics import mean_absolute_error, mean_squared_error, r2_score\n", + "\n", + "# ---------------------------------------------------------------------------\n", + "# [교육용가상자료] 가상 부대·가상 품목 일일 소모 기록 생성기 (실제 탄약 제원·소모율과 무관)\n", + "# 가상 부대 8개 x 90일. 소모량 = 가상 CSR 계수 x 병력 규모 x (교전 강도·적 저항·화력 계획 효과) x 잡음\n", + "# ---------------------------------------------------------------------------\n", + "UNITS = [f\"U{i:02d}\" for i in range(1, 9)]\n", + "ITEMS = [\"ammo_A\", \"ammo_B\", \"ammo_C\", \"ammo_D\", \"fuel_jet\", \"fuel_diesel\"]\n", + "AMMO, FUEL = ITEMS[:4], ITEMS[4:]\n", + "PHASES = [\"prep\", \"attack\", \"defense\", \"stability\"]\n", + "CSR = { # 가상 계수: 병력 100명·1일 기준 소모 단위(탄약=상자, 유류=드럼)\n", + " \"prep\": [1.0, 0.6, 0.3, 0.2, 1.5, 6.0],\n", + " \"attack\": [6.0, 3.5, 2.0, 1.2, 5.0, 12.0],\n", + " \"defense\": [4.5, 3.0, 1.6, 0.8, 3.0, 7.0],\n", + " \"stability\": [0.8, 0.5, 0.3, 0.2, 1.0, 5.0],\n", + "}\n", + "PHASE_RESIST = {\"prep\": 1.0, \"attack\": 3.2, \"defense\": 3.6, \"stability\": 0.8}\n", + "\n", + "\n", + "def make_ops(n_days=90, seed=7, surge=0):\n", + " \"\"\"가상 부대별 일일 작전 기록과 품목별 실제 소모량(정답)을 만든다. surge>0이면 마지막 surge일을 고강도 공격 단계로 바꾼다(스트레스 시험용).\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " rows = []\n", + " for uid in UNITS:\n", + " size = int(rng.integers(3, 9)) * 100\n", + " lens = rng.integers(8, 20, 4)\n", + " sched = [\"prep\"] * lens[0] + [\"attack\"] * lens[1] + [\"defense\"] * lens[2] + [\"attack\"] * lens[3]\n", + " sched = (sched + [\"stability\"] * n_days)[:n_days]\n", + " if surge:\n", + " sched[-surge:] = [\"attack\"] * surge\n", + " r = 1.0\n", + " for d, ph in enumerate(sched):\n", + " r = float(np.clip(0.7 * r + 0.3 * (4.8 if surge and d >= n_days - surge else PHASE_RESIST[ph]) + rng.normal(0, 0.35), 0, 5))\n", + " inten = float(np.clip(r / 5 * 0.9 + rng.normal(0, 0.06), 0, 1))\n", + " fire = int(rng.poisson(1 + 8 * inten))\n", + " move = float(rng.gamma(2, {\"prep\": 4, \"attack\": 12, \"defense\": 2, \"stability\": 5}[ph]))\n", + " weather = str(rng.choice([\"clear\", \"rain\", \"snow\"], p=[0.7, 0.2, 0.1]))\n", + " emerg = bool(rng.random() < 0.02)\n", + " rec = {\"unit\": uid, \"day\": d, \"phase\": ph, \"unit_size\": size, \"intensity\": round(inten, 3),\n", + " \"resistance\": round(r, 2), \"fire_missions\": fire, \"move_km\": round(move, 1),\n", + " \"weather\": weather, \"emergency\": emerg}\n", + " base = size / 100\n", + " for i, it in enumerate(ITEMS):\n", + " c = CSR[ph][i] * base\n", + " if it in AMMO:\n", + " c *= (0.5 + 0.6 * inten) * (1 + 0.04 * fire * (1.0 if it in (\"ammo_C\", \"ammo_D\") else 0.4))\n", + " elif it == \"fuel_jet\":\n", + " c *= (0.6 + 0.5 * inten) + 0.03 * fire\n", + " else:\n", + " c = c * (0.8 + 0.2 * (weather != \"clear\")) + 0.05 * move * base\n", + " c *= rng.lognormal(0, 0.12) * (2.5 if emerg else 1.0)\n", + " rec[it] = round(float(c), 1)\n", + " rows.append(rec)\n", + " return pd.DataFrame(rows)\n", + "# ---------------------------------------------------------------------------\n", + "\n", + "# ---- 소모량 예측용 파생 피처: 전일까지의 실적 + 당일 작전·화력 계획(사전에 알 수 있는 값만) ----\n", + "FEATS = [\"lag1\", \"lag2\", \"roll3\", \"roll7\", \"csr_base\", \"size100\", \"fire_plan\", \"fire_x_engaged\", \"resist_lag1\",\n", + " \"resist_trend\", \"inten_lag1\", \"move_plan\", \"days_in_phase\", \"engaged\", \"ph_attack\", \"ph_defense\", \"ph_prep\",\n", + " \"item_idx\", \"is_fuel\"]\n", + "DERIVED = [\"lag1\", \"lag2\", \"roll3\", \"roll7\", \"csr_base\", \"fire_x_engaged\", \"resist_lag1\", \"resist_trend\",\n", + " \"inten_lag1\", \"days_in_phase\", \"engaged\", \"ph_attack\", \"ph_defense\", \"ph_prep\"]\n", + "\n", + "\n", + "def build_long(ops):\n", + " \"\"\"부대x일자 기록을 (부대, 일자, 품목) 행으로 펼치고 파생 피처를 만든다.\n", + " # [TODO : 학생 실습 5] 부대별 일일 보급 수준(DOS) 연계 긴급 재보급 소요 산출 엔진 개발 관련 핵심 로직을 구현하세요.\n", + " # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n", + " # <<< 여기에 코드를 작성하세요 >>>\n", + " pass # 작성 후 pass를 제거하세요.\n", + "def to_qty(d, y_pred):\n", + " \"\"\"예측 보정 계수(log)를 소모량으로 되돌린다: 기준량 x exp(예측).\"\"\"\n", + " # [TODO : 학생 실습 5] 부대별 일일 보급 수준(DOS) 연계 긴급 재보급 소요 산출 엔진 개발 관련 핵심 로직을 구현하세요.\n", + " # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n", + " # <<< 여기에 코드를 작성하세요 >>>\n", + " pass # 작성 후 pass를 제거하세요.\n", + "def mape(y, p):\n", + " return float(np.mean(np.abs(np.asarray(p) - np.asarray(y)) / np.asarray(y)))\n", + "\n", + "# ---- 소모량 예측 모델(앙상블) 학습·예측 ----\n", + "def fit_models(tr, seed=0):\n", + " \"\"\"GBR + 랜덤포레스트 두 모델을 학습한다(목표값은 CSR 기준량 대비 log 보정 계수).\"\"\"\n", + " g = GradientBoostingRegressor(n_estimators=150, max_depth=3, learning_rate=0.08, subsample=0.8, random_state=seed).fit(tr[FEATS], tr[\"y\"])\n", + " r = RandomForestRegressor(n_estimators=120, min_samples_leaf=3, random_state=seed, n_jobs=1).fit(tr[FEATS], tr[\"y\"])\n", + " return g, r\n", + "\n", + "\n", + "def predict_qty(models, d, w=0.5):\n", + " \"\"\"두 모델의 log 예측을 가중 평균해 소모량으로 되돌린다.\"\"\"\n", + " g, r = models\n", + " return to_qty(d, w * g.predict(d[FEATS]) + (1 - w) * r.predict(d[FEATS]))\n", + "\n", + "DOS_ALERT = 2.0 # [교육용가상자료] 긴급 재보급 경보 기준(보급 수준, 일)\n", + "DOS_TARGET = 3.0 # 재보급 후 목표 보급 수준(일)\n", + "\n", + "data = build_long(make_ops(90, seed=7))\n", + "tr, te = data[data[\"day\"] < 60], data[data[\"day\"] >= 60].copy()\n", + "val = tr[tr[\"day\"] >= 48]\n", + "\n", + "# 1) 앙상블 가중치 튜닝: 학습 기간 마지막 12일(검증용)로 고르고, Hold-out(60~89일)은 최종 평가에만 사용\n", + "m_inner = fit_models(tr[tr[\"day\"] < 48])\n", + "best_w = min(np.arange(0, 1.01, 0.25), key=lambda w: mape(val[\"qty\"], predict_qty(m_inner, val, w)))\n", + "models = fit_models(tr)\n", + "te[\"base\"] = te[\"csr_base\"] * te[\"size100\"]\n", + "te[\"gbr\"] = to_qty(te, models[0].predict(te[FEATS]))\n", + "te[\"ens\"] = predict_qty(models, te, best_w)\n", + "\n", + "\n", + "def scores(col, d):\n", + " \"\"\"오차율은 CSR 기준량 대비 비율(환산 소모율) 척도로 계산해 품목 간 단위 차이를 없앤다. R2는 소모량 척도.\"\"\"\n", + " k = d[\"csr_base\"] * d[\"size100\"]\n", + " y, p = d[\"qty\"] / k, d[col] / k\n", + " return {\"MAE(비율)\": float((y - p).abs().mean()), \"RMSE(비율)\": float(np.sqrt(((y - p) ** 2).mean())),\n", + " \"MAPE\": mape(d[\"qty\"], d[col]), \"R2(전체)\": r2_score(d[\"qty\"], d[col]),\n", + " \"R2(정상소모)\": r2_score(d.loc[~d[\"emergency\"], \"qty\"], d.loc[~d[\"emergency\"], col])}\n", + "\n", + "\n", + "res = pd.DataFrame({\"Baseline(CSR)\": scores(\"base\", te), \"단일 GBR\": scores(\"gbr\", te), f\"앙상블(w={best_w:.2f})\": scores(\"ens\", te)}).T\n", + "print(\"[Hold-out 60~89일 성능, 부대 8 x 품목 6]\")\n", + "print(res.to_string(float_format=lambda x: f\"{x:.3f}\"))\n", + "b, e = res.iloc[0], res.iloc[2]\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", + "print(f\"R2 전체 {e['R2(전체)']:.3f} ({'달성' if e['R2(전체)'] >= 0.85 else '미달'}), 긴급 불출 제외 정상 소모 {e['R2(정상소모)']:.3f} (목표 0.85 이상: {'달성' if e['R2(정상소모)'] >= 0.85 else '미달'})\")\n", + "print(\"-> 전체 R2 미달 원인은 사전 정보가 없는 긴급 불출 급증(2~3배)이다. 예측 모델이 아니라 긴급 경보·예비 재고로 대응한다.\")\n", + "em = te[te[\"emergency\"]]\n", + "print(f\"비정형 수요(긴급 불출 {len(em)}건) MAPE: Baseline {mape(em['qty'], em['base']):.1%}, 앙상블 {mape(em['qty'], em['ens']):.1%} -> 급증은 사전 정보가 없어 별도 경보로 보완\")\n", + "\n", + "# 2) DOS 연계 긴급 재보급 엔진: 75일 아침 기준, 보유량(가상) / 예측 일일 소모량\n", + "rng = np.random.default_rng(11)\n", + "day = te[te[\"day\"] == 75].copy()\n", + "day[\"on_hand\"] = np.round(day[\"base\"] * rng.uniform(1.0, 5.0, len(day)), 0)\n", + "day[\"dos\"] = day[\"on_hand\"] / day[\"ens\"]\n", + "day[\"need\"] = np.maximum(0, np.ceil(DOS_TARGET * day[\"ens\"] - day[\"on_hand\"]))\n", + "day[\"flag\"] = np.where(day[\"dos\"] < DOS_ALERT, \"긴급\", np.where(day[\"dos\"] < DOS_TARGET, \"주의\", \"정상\"))\n", + "pri = {\"긴급\": 0, \"주의\": 1, \"정상\": 2}\n", + "out = day.sort_values([\"flag\", \"dos\"], key=lambda s: s.map(pri) if s.name == \"flag\" else s)\n", + "print(f\"\\n[75일 재보급 소요(DOS {DOS_ALERT:.0f}일 미만 긴급, 목표 {DOS_TARGET:.0f}일)] 긴급 {sum(day['flag'] == '긴급')}건, 주의 {sum(day['flag'] == '주의')}건, 정상 {sum(day['flag'] == '정상')}건\")\n", + "show = out[out[\"flag\"] != \"정상\"].head(10)[[\"unit\", \"item\", \"phase\", \"on_hand\", \"ens\", \"dos\", \"need\", \"flag\"]]\n", + "show.columns = [\"부대\", \"품목\", \"작전단계\", \"보유량\", \"예측소모/일\", \"DOS(일)\", \"재보급량\", \"판정\"]\n", + "print(show.to_string(index=False, float_format=lambda x: f\"{x:.1f}\"))\n", + "real_dos = day[\"on_hand\"] / day[\"qty\"]\n", + "hit = ((day[\"dos\"] < DOS_ALERT) == (real_dos < DOS_ALERT)).mean()\n", + "print(f\"긴급 판정 일치율(실제 소모 기준 DOS와 비교): {hit:.1%}\")\n", + "print(\"재보급 소요는 군수참모 결재 전 추천값이며 자동 불출하지 않는다.\")\n", + "\n", + "fig, ax = plt.subplots(1, 2, figsize=(12, 4))\n", + "ax[0].scatter(te[\"qty\"], te[\"base\"], s=5, alpha=0.4, label=\"baseline\")\n", + "ax[0].scatter(te[\"qty\"], te[\"ens\"], s=5, alpha=0.4, label=\"ensemble\")\n", + "mx = te[\"qty\"].max(); ax[0].plot([0, mx], [0, mx], \"k--\", lw=1)\n", + "ax[0].set_xlabel(\"actual\"); ax[0].set_ylabel(\"predicted\"); ax[0].set_title(\"Hold-out actual vs predicted\"); ax[0].legend()\n", + "piv = day.pivot(index=\"unit\", columns=\"item\", values=\"dos\").clip(upper=6)\n", + "im = ax[1].imshow(piv.values, cmap=\"RdYlGn\", vmin=0, vmax=6, aspect=\"auto\")\n", + "ax[1].set_xticks(range(len(piv.columns))); ax[1].set_xticklabels(piv.columns, rotation=30)\n", + "ax[1].set_yticks(range(len(piv.index))); ax[1].set_yticklabels(piv.index)\n", + "ax[1].set_title(\"Days of supply by unit/item (day 75)\"); plt.colorbar(im, ax=ax[1])\n", + "plt.tight_layout(); plt.show()\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 05 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + "[Hold-out 60~89일 성능, 부대 8 x 품목 6]\n", + " MAE(비율) RMSE(비율) MAPE R2(전체) R2(정상소모)\n", + "Baseline(CSR) 0.313 0.371 0.493 0.828 0.956\n", + "단일 GBR 0.110 0.211 0.140 0.827 0.963\n", + "앙상블(w=0.25) 0.107 0.210 0.133 0.827 0.966\n", + "오차율 개선: MAE 66.0%, RMSE 43.2% (목표 20% 이상: 달성)\n", + "R2 전체 0.827 (미달), 긴급 불출 제외 정상 소모 0.966 (목표 0.85 이상: 달성)\n", + "-> 전체 R2 미달 원인은 사전 정보가 없는 긴급 불출 급증(2~3배)이다. 예측 모델이 아니라 긴급 경보·예비 재고로 대응한다.\n", + "비정형 수요(긴급 불출 24건) MAPE: Baseline 38.7%, 앙상블 58.8% -> 급증은 사전 정보가 없어 별도 경보로 보완\n", + "\n", + "[75일 재보급 소요(DOS 2일 미만 긴급, 목표 3일)] 긴급 4건, 주의 14건, 정상 30건\n", + " 부대 품목 작전단계 보유량 예측소모/일 DOS(일) 재보급량 판정\n", + "U01 fuel_diesel stability 45.0 35.7 1.3 62.0 긴급\n", + "U08 fuel_diesel stability 37.0 29.2 1.3 51.0 긴급\n", + "U01 fuel_jet stability 11.0 6.2 1.8 8.0 긴급\n", + "U04 ammo_A stability 4.0 2.2 1.8 3.0 긴급\n", + "U02 fuel_diesel stability 63.0 31.4 2.0 32.0 주의\n", + "U07 ammo_A stability 7.0 3.5 2.0 4.0 주의\n", + "U06 ammo_D stability 2.0 1.0 2.1 1.0 주의\n", + "U01 ammo_A stability 10.0 4.5 2.2 4.0 주의\n", + "U05 ammo_A stability 5.0 2.3 2.2 2.0 주의\n", + "U07 ammo_B stability 5.0 2.2 2.3 2.0 주의\n", + "긴급 판정 일치율(실제 소모 기준 DOS와 비교): 95.8%\n", + "재보급 소요는 군수참모 결재 전 추천값이며 자동 불출하지 않는다.\n", + "```\n", + "
\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 06. 작전 지도 연계 탄약/유류 소모 예측 및 재보급 소요 가시화 대시보드 구현\n", + "**소요시간**: 약 3.0시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "작전 지도 연계 탄약/유류 소모 예측 및 재보급 대시보드를 구현한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **가상 작전 지도에 부대별 탄약·유류 예측 소모와 보급 상태를 표시하고, 예측 건마다 기여도 상위 3개 인자와 자연어 설명을 자동 생성한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **가상 격자 좌표 지도에 부대별 예측 소모량과 교전 여부를 표시 수행하고 검증하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `src/explainability/amm02_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": [ + "\"\"\"작전 지도(가상 격자 좌표)에 부대별 탄약·유류 예측 소모량과 보급 수준(DOS)을 표시하는 대시보드를 만들고, 예측 건마다 기여도 상위 3개 인자를 대체값 비교(occlusion) 방식으로 자동 산출해 자연어 설명문을 생성한다(SHAP summary·waterfall 대체 그림 포함).\"\"\"\n", + "import numpy as np\n", + "import pandas as pd\n", + "import matplotlib.pyplot as plt\n", + "from sklearn.ensemble import GradientBoostingRegressor, RandomForestRegressor\n", + "\n", + "# ---------------------------------------------------------------------------\n", + "# [교육용가상자료] 가상 부대·가상 품목 일일 소모 기록 생성기 (실제 탄약 제원·소모율과 무관)\n", + "# 가상 부대 8개 x 90일. 소모량 = 가상 CSR 계수 x 병력 규모 x (교전 강도·적 저항·화력 계획 효과) x 잡음\n", + "# ---------------------------------------------------------------------------\n", + "UNITS = [f\"U{i:02d}\" for i in range(1, 9)]\n", + "ITEMS = [\"ammo_A\", \"ammo_B\", \"ammo_C\", \"ammo_D\", \"fuel_jet\", \"fuel_diesel\"]\n", + "AMMO, FUEL = ITEMS[:4], ITEMS[4:]\n", + "PHASES = [\"prep\", \"attack\", \"defense\", \"stability\"]\n", + "CSR = { # 가상 계수: 병력 100명·1일 기준 소모 단위(탄약=상자, 유류=드럼)\n", + " \"prep\": [1.0, 0.6, 0.3, 0.2, 1.5, 6.0],\n", + " \"attack\": [6.0, 3.5, 2.0, 1.2, 5.0, 12.0],\n", + " \"defense\": [4.5, 3.0, 1.6, 0.8, 3.0, 7.0],\n", + " \"stability\": [0.8, 0.5, 0.3, 0.2, 1.0, 5.0],\n", + "}\n", + "PHASE_RESIST = {\"prep\": 1.0, \"attack\": 3.2, \"defense\": 3.6, \"stability\": 0.8}\n", + "\n", + "\n", + "def make_ops(n_days=90, seed=7, surge=0):\n", + " \"\"\"가상 부대별 일일 작전 기록과 품목별 실제 소모량(정답)을 만든다. surge>0이면 마지막 surge일을 고강도 공격 단계로 바꾼다(스트레스 시험용).\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " rows = []\n", + " for uid in UNITS:\n", + " size = int(rng.integers(3, 9)) * 100\n", + " lens = rng.integers(8, 20, 4)\n", + " sched = [\"prep\"] * lens[0] + [\"attack\"] * lens[1] + [\"defense\"] * lens[2] + [\"attack\"] * lens[3]\n", + " sched = (sched + [\"stability\"] * n_days)[:n_days]\n", + " if surge:\n", + " sched[-surge:] = [\"attack\"] * surge\n", + " r = 1.0\n", + " for d, ph in enumerate(sched):\n", + " r = float(np.clip(0.7 * r + 0.3 * (4.8 if surge and d >= n_days - surge else PHASE_RESIST[ph]) + rng.normal(0, 0.35), 0, 5))\n", + " inten = float(np.clip(r / 5 * 0.9 + rng.normal(0, 0.06), 0, 1))\n", + " fire = int(rng.poisson(1 + 8 * inten))\n", + " move = float(rng.gamma(2, {\"prep\": 4, \"attack\": 12, \"defense\": 2, \"stability\": 5}[ph]))\n", + " weather = str(rng.choice([\"clear\", \"rain\", \"snow\"], p=[0.7, 0.2, 0.1]))\n", + " emerg = bool(rng.random() < 0.02)\n", + " rec = {\"unit\": uid, \"day\": d, \"phase\": ph, \"unit_size\": size, \"intensity\": round(inten, 3),\n", + " \"resistance\": round(r, 2), \"fire_missions\": fire, \"move_km\": round(move, 1),\n", + " \"weather\": weather, \"emergency\": emerg}\n", + " base = size / 100\n", + " for i, it in enumerate(ITEMS):\n", + " c = CSR[ph][i] * base\n", + " if it in AMMO:\n", + " c *= (0.5 + 0.6 * inten) * (1 + 0.04 * fire * (1.0 if it in (\"ammo_C\", \"ammo_D\") else 0.4))\n", + " elif it == \"fuel_jet\":\n", + " c *= (0.6 + 0.5 * inten) + 0.03 * fire\n", + " else:\n", + " c = c * (0.8 + 0.2 * (weather != \"clear\")) + 0.05 * move * base\n", + " c *= rng.lognormal(0, 0.12) * (2.5 if emerg else 1.0)\n", + " rec[it] = round(float(c), 1)\n", + " rows.append(rec)\n", + " return pd.DataFrame(rows)\n", + "# ---------------------------------------------------------------------------\n", + "\n", + "# ---- 소모량 예측용 파생 피처: 전일까지의 실적 + 당일 작전·화력 계획(사전에 알 수 있는 값만) ----\n", + "FEATS = [\"lag1\", \"lag2\", \"roll3\", \"roll7\", \"csr_base\", \"size100\", \"fire_plan\", \"fire_x_engaged\", \"resist_lag1\",\n", + " \"resist_trend\", \"inten_lag1\", \"move_plan\", \"days_in_phase\", \"engaged\", \"ph_attack\", \"ph_defense\", \"ph_prep\",\n", + " \"item_idx\", \"is_fuel\"]\n", + "DERIVED = [\"lag1\", \"lag2\", \"roll3\", \"roll7\", \"csr_base\", \"fire_x_engaged\", \"resist_lag1\", \"resist_trend\",\n", + " \"inten_lag1\", \"days_in_phase\", \"engaged\", \"ph_attack\", \"ph_defense\", \"ph_prep\"]\n", + "\n", + "\n", + "def build_long(ops):\n", + " \"\"\"부대x일자 기록을 (부대, 일자, 품목) 행으로 펼치고 파생 피처를 만든다.\n", + " # [TODO : 학생 실습 6] 작전 지도 연계 탄약/유류 소모 예측 및 재보급 소요 가시화 대시보드 구현 관련 핵심 로직을 구현하세요.\n", + " # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n", + " # <<< 여기에 코드를 작성하세요 >>>\n", + " pass # 작성 후 pass를 제거하세요.\n", + "def to_qty(d, y_pred):\n", + " \"\"\"예측 보정 계수(log)를 소모량으로 되돌린다: 기준량 x exp(예측).\"\"\"\n", + " # [TODO : 학생 실습 6] 작전 지도 연계 탄약/유류 소모 예측 및 재보급 소요 가시화 대시보드 구현 관련 핵심 로직을 구현하세요.\n", + " # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n", + " # <<< 여기에 코드를 작성하세요 >>>\n", + " pass # 작성 후 pass를 제거하세요.\n", + "def mape(y, p):\n", + " return float(np.mean(np.abs(np.asarray(p) - np.asarray(y)) / np.asarray(y)))\n", + "\n", + "# ---- 소모량 예측 모델(앙상블) 학습·예측 ----\n", + "def fit_models(tr, seed=0):\n", + " \"\"\"GBR + 랜덤포레스트 두 모델을 학습한다(목표값은 CSR 기준량 대비 log 보정 계수).\"\"\"\n", + " g = GradientBoostingRegressor(n_estimators=150, max_depth=3, learning_rate=0.08, subsample=0.8, random_state=seed).fit(tr[FEATS], tr[\"y\"])\n", + " r = RandomForestRegressor(n_estimators=120, min_samples_leaf=3, random_state=seed, n_jobs=1).fit(tr[FEATS], tr[\"y\"])\n", + " return g, r\n", + "\n", + "\n", + "def predict_qty(models, d, w=0.5):\n", + " \"\"\"두 모델의 log 예측을 가중 평균해 소모량으로 되돌린다.\"\"\"\n", + " g, r = models\n", + " return to_qty(d, w * g.predict(d[FEATS]) + (1 - w) * r.predict(d[FEATS]))\n", + "\n", + "KOR = {\"lag1\": \"전일 소모 비율\", \"lag2\": \"2일 전 소모 비율\", \"roll3\": \"최근 3일 평균 소모 비율\", \"roll7\": \"최근 7일 평균 소모 비율\",\n", + " \"csr_base\": \"작전 단계 CSR 계수\", \"size100\": \"병력 규모\", \"fire_plan\": \"계획 화력 임무 수\", \"fire_x_engaged\": \"교전 중 화력 계획\",\n", + " \"resist_lag1\": \"전일 적 저항 수준\", \"resist_trend\": \"적 저항 추세\", \"inten_lag1\": \"전일 교전 강도\", \"move_plan\": \"계획 이동 거리\",\n", + " \"days_in_phase\": \"단계 경과일\", \"engaged\": \"교전 단계 여부\", \"ph_attack\": \"공격 단계\", \"ph_defense\": \"방어 단계\",\n", + " \"ph_prep\": \"준비 단계\", \"item_idx\": \"품목\", \"is_fuel\": \"유류 여부\"}\n", + "\n", + "data = build_long(make_ops(90, seed=7))\n", + "tr = data[data[\"day\"] < 40]\n", + "models = fit_models(tr)\n", + "D = 41 # 대시보드 기준일(교전 단계가 섞인 날)\n", + "day = data[data[\"day\"] == D].reset_index(drop=True)\n", + "day[\"pred\"] = predict_qty(models, day)\n", + "background = tr[FEATS].median()\n", + "\n", + "\n", + "def logpred(X):\n", + " return 0.5 * models[0].predict(X) + 0.5 * models[1].predict(X)\n", + "\n", + "\n", + "def explain(X):\n", + " \"\"\"각 피처를 학습 기간 중앙값으로 바꿨을 때 예측(log 보정 계수)이 얼마나 변하는지 = 그 피처의 기여도.\"\"\"\n", + " base = logpred(X)\n", + " C = np.zeros((len(X), len(FEATS)))\n", + " for j, f in enumerate(FEATS):\n", + " Z = X.copy(); Z[f] = background[f]\n", + " C[:, j] = base - logpred(Z)\n", + " return pd.DataFrame(C, columns=FEATS)\n", + "\n", + "\n", + "C = explain(day[FEATS])\n", + "top3 = C.abs().apply(lambda r: list(r.sort_values(ascending=False).index[:3]), axis=1)\n", + "cover = np.mean([len(t) == 3 for t in top3])\n", + "print(f\"[기준일 {D}일] 예측 {len(day)}건(부대 8 x 품목 6), 상위 3개 인자 자동 산출률 {cover:.0%} (목표 100%: {'달성' if cover == 1 else '미달'})\")\n", + "\n", + "\n", + "def sentence(i):\n", + " r = day.loc[i]; parts = []\n", + " for f in top3[i]:\n", + " eff = np.exp(C.loc[i, f]) - 1\n", + " parts.append(f\"{KOR[f]}({'+' if eff >= 0 else ''}{eff:.0%})\")\n", + " return f\"{r['unit']} {r['item']} 예측 {r['pred']:.1f}(기준량 대비 {r['pred'] / (r['csr_base'] * r['size100']):.2f}배): 주요 원인 \" + \", \".join(parts)\n", + "\n", + "\n", + "print(\"\\n[부대별 최대 소모 품목 설명(자연어 요약)]\")\n", + "lead = day.assign(rel=day[\"pred\"] / (day[\"csr_base\"] * day[\"size100\"])).sort_values(\"rel\", ascending=False).groupby(\"unit\").head(1)\n", + "for i in lead.sort_values(\"unit\").index:\n", + " print(\" -\", sentence(i))\n", + "\n", + "imp = C.abs().mean().sort_values(ascending=False)\n", + "print(\"\\n[전체 평균 기여도 상위 5(요약 그림)]\", \", \".join(f\"{KOR[k]} {v:.3f}\" for k, v in imp.head(5).items()))\n", + "recon = np.corrcoef(C.sum(axis=1), logpred(day[FEATS]) - logpred(pd.DataFrame([background] * len(day))))[0, 1]\n", + "print(f\"설명 정합성: 기여도 합과 (예측-기준 예측)의 상관 {recon:.2f} (1에 가까울수록 설명이 예측을 잘 분해)\")\n", + "print(\"설명은 판단 보조 근거이며 재보급 결정은 군수참모가 근거를 확인해 내린다.\")\n", + "\n", + "# 대시보드: 가상 작전 지도 + 기여도 그림\n", + "rng = np.random.default_rng(2)\n", + "pos = {u: (rng.uniform(0, 50), rng.uniform(0, 30)) for u in UNITS}\n", + "ag = day.groupby(\"unit\").apply(lambda g: pd.Series({\"ammo\": g.loc[g[\"is_fuel\"] == 0, \"pred\"].sum(), \"fuel\": g.loc[g[\"is_fuel\"] == 1, \"pred\"].sum(),\n", + " \"phase\": g[\"phase\"].iloc[0]}), include_groups=False)\n", + "fig, ax = plt.subplots(1, 3, figsize=(16, 4.5))\n", + "for u, r in ag.iterrows():\n", + " x, y = pos[u]\n", + " ax[0].scatter(x, y, s=r[\"ammo\"] * 8 + 20, c=\"tab:red\" if r[\"phase\"] in (\"attack\", \"defense\") else \"tab:blue\", alpha=0.6)\n", + " ax[0].annotate(f\"{u}\\nA{r['ammo']:.0f}/F{r['fuel']:.0f}\", (x, y), fontsize=8, ha=\"center\")\n", + "ax[0].set_title(f\"Virtual op map day {D} (size=ammo forecast, red=engaged)\"); ax[0].set_xlabel(\"grid x\"); ax[0].set_ylabel(\"grid y\")\n", + "imp.head(10)[::-1].rename(index=lambda k: k).plot.barh(ax=ax[1], title=\"Mean |contribution| (summary)\")\n", + "i0 = lead.index[0]; w = C.loc[i0].reindex(C.loc[i0].abs().sort_values(ascending=False).index[:6])\n", + "ax[2].barh(w.index[::-1], w.values[::-1], color=[\"tab:red\" if v > 0 else \"tab:green\" for v in w.values[::-1]])\n", + "ax[2].set_title(f\"Waterfall-style: {day.loc[i0, 'unit']} {day.loc[i0, 'item']} (log ratio)\")\n", + "plt.tight_layout(); plt.show()\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 06 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + "[기준일 41일] 예측 48건(부대 8 x 품목 6), 상위 3개 인자 자동 산출률 100% (목표 100%: 달성)\n", + "\n", + "[부대별 최대 소모 품목 설명(자연어 요약)]\n", + " - U01 fuel_jet 예측 25.6(기준량 대비 1.07배): 주요 원인 전일 적 저항 수준(-13%), 최근 7일 평균 소모 비율(+5%), 2일 전 소모 비율(-3%)\n", + " - U02 ammo_D 예측 8.9(기준량 대비 1.05배): 주요 원인 전일 적 저항 수준(-14%), 최근 7일 평균 소모 비율(+3%), 최근 3일 평균 소모 비율(-1%)\n", + " - U03 ammo_D 예측 4.9(기준량 대비 1.01배): 주요 원인 전일 적 저항 수준(-12%), 최근 7일 평균 소모 비율(+4%), 단계 경과일(-2%)\n", + " - U04 fuel_diesel 예측 61.6(기준량 대비 1.03배): 주요 원인 계획 이동 거리(+11%), 전일 적 저항 수준(-8%), 품목(+8%)\n", + " - U05 ammo_C 예측 9.1(기준량 대비 1.14배): 주요 원인 전일 적 저항 수준(-11%), 계획 화력 임무 수(+6%), 교전 중 화력 계획(+4%)\n", + " - U06 ammo_D 예측 10.1(기준량 대비 1.05배): 주요 원인 전일 적 저항 수준(-12%), 최근 7일 평균 소모 비율(+3%), 품목(+1%)\n", + " - U07 fuel_diesel 예측 33.8(기준량 대비 0.97배): 주요 원인 품목(+18%), 전일 적 저항 수준(-14%), 작전 단계 CSR 계수(+13%)\n", + " - U08 ammo_D 예측 6.0(기준량 대비 1.08배): 주요 원인 전일 적 저항 수준(-16%), 적 저항 추세(+3%), 최근 7일 평균 소모 비율(+3%)\n", + "\n", + "[전체 평균 기여도 상위 5(요약 그림)] 전일 적 저항 수준 0.158, 작전 단계 CSR 계수 0.038, 품목 0.026, 계획 화력 임무 수 0.023, 최근 7일 평균 소모 비율 0.021\n", + "설명 정합성: 기여도 합과 (예측-기준 예측)의 상관 0.91 (1에 가까울수록 설명이 예측을 잘 분해)\n", + "설명은 판단 보조 근거이며 재보급 결정은 군수참모가 근거를 확인해 내린다.\n", + "```\n", + "
\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 07. 보급 우선순위 지정 및 대체 탄종 불출을 위한 군수참모 HITL 결재선 설계\n", + "**소요시간**: 약 3.5시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "보급 우선순위 지정을 위한 군수참모 HITL 결재선을 설계한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **가상 저장고 배치를 최적화하고, 재배치·긴급 재보급 우선순위·대체 탄종 불출 제안을 군수참모 결재선(HITL)으로 처리하는 시뮬레이터를 설계한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **저장칸 용량·현재 점유·가상 혼적 제한을 제약으로 두고 반출 빈도 x 동선 거리와 공간낭비를 최소화하는 할당 문제(정수계획)를 풀어 현행(입고순) 배치와 작업비용·공간낭비율·위치점수를 비교 수행하고 검증하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `src/optimization/amm02_simulator.py` 형태의 결과물을 완성한다.\n", + "\n", + "### 🔍 평가 및 검증 기준 (PASS 조건)\n", + "#### 미션 판정\n", + "\n", + "**평가 구분:** 필수\n", + "\n", + "**핵심 평가영역:** 현실적 군수 제약조건 반영도 및 시나리오별 대안 제시 적절성\n", + "\n", + "#### 미션 PASS 조건\n", + "\n", + "- 정량 기준: 최적화 적용 시 비용 15% 절감 또는 재고 회전율 20% 향상\n", + "\n", + "\n", + "- 진행 기준: 선형/정수 계획법(LP/MIP) 최적화 해 도출 및 시뮬레이션 완료\n", + "\n", + "\n", + "- 검증 방법: 과거 실적 데이터 대비 최적화 결과 비교 백테스팅\n", + "\n", + "\n", + "#### 채점자 판정 메모\n", + "\n", + "> 단순 코드 실행 여부뿐만 아니라 출력된 수치와 근거 자료가 군 실무 요구사항에 부합하는지 면밀히 확인한다.\n", + "\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "\"\"\"가상 저장고의 품목 배치를 할당 문제(정수계획)로 최적화해 작업동선 비용·공간낭비·위치점수를 현행 배치와 비교하고, 재배치·긴급 재보급 우선순위·대체 탄종 불출 제안을 자동 확정하지 않고 군수참모 결재선(승인·수정·반려·무응답)에 올려 사유와 함께 해시 체인 감사 로그에 남기는 HITL 시뮬레이터를 구현한다.\"\"\"\n", + "import hashlib\n", + "import json\n", + "import numpy as np\n", + "import pandas as pd\n", + "import matplotlib.pyplot as plt\n", + "from scipy.optimize import linear_sum_assignment\n", + "\n", + "# ---------------------------------------------------------------------------\n", + "# [교육용가상자료] 가상 부대·가상 품목 일일 소모 기록 생성기 (실제 탄약 제원·소모율과 무관)\n", + "# 가상 부대 8개 x 90일. 소모량 = 가상 CSR 계수 x 병력 규모 x (교전 강도·적 저항·화력 계획 효과) x 잡음\n", + "# ---------------------------------------------------------------------------\n", + "UNITS = [f\"U{i:02d}\" for i in range(1, 9)]\n", + "ITEMS = [\"ammo_A\", \"ammo_B\", \"ammo_C\", \"ammo_D\", \"fuel_jet\", \"fuel_diesel\"]\n", + "AMMO, FUEL = ITEMS[:4], ITEMS[4:]\n", + "PHASES = [\"prep\", \"attack\", \"defense\", \"stability\"]\n", + "CSR = { # 가상 계수: 병력 100명·1일 기준 소모 단위(탄약=상자, 유류=드럼)\n", + " \"prep\": [1.0, 0.6, 0.3, 0.2, 1.5, 6.0],\n", + " \"attack\": [6.0, 3.5, 2.0, 1.2, 5.0, 12.0],\n", + " \"defense\": [4.5, 3.0, 1.6, 0.8, 3.0, 7.0],\n", + " \"stability\": [0.8, 0.5, 0.3, 0.2, 1.0, 5.0],\n", + "}\n", + "PHASE_RESIST = {\"prep\": 1.0, \"attack\": 3.2, \"defense\": 3.6, \"stability\": 0.8}\n", + "\n", + "\n", + "def make_ops(n_days=90, seed=7, surge=0):\n", + " \"\"\"가상 부대별 일일 작전 기록과 품목별 실제 소모량(정답)을 만든다. surge>0이면 마지막 surge일을 고강도 공격 단계로 바꾼다(스트레스 시험용).\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " rows = []\n", + " for uid in UNITS:\n", + " size = int(rng.integers(3, 9)) * 100\n", + " lens = rng.integers(8, 20, 4)\n", + " sched = [\"prep\"] * lens[0] + [\"attack\"] * lens[1] + [\"defense\"] * lens[2] + [\"attack\"] * lens[3]\n", + " sched = (sched + [\"stability\"] * n_days)[:n_days]\n", + " if surge:\n", + " sched[-surge:] = [\"attack\"] * surge\n", + " r = 1.0\n", + " for d, ph in enumerate(sched):\n", + " r = float(np.clip(0.7 * r + 0.3 * (4.8 if surge and d >= n_days - surge else PHASE_RESIST[ph]) + rng.normal(0, 0.35), 0, 5))\n", + " inten = float(np.clip(r / 5 * 0.9 + rng.normal(0, 0.06), 0, 1))\n", + " fire = int(rng.poisson(1 + 8 * inten))\n", + " move = float(rng.gamma(2, {\"prep\": 4, \"attack\": 12, \"defense\": 2, \"stability\": 5}[ph]))\n", + " weather = str(rng.choice([\"clear\", \"rain\", \"snow\"], p=[0.7, 0.2, 0.1]))\n", + " emerg = bool(rng.random() < 0.02)\n", + " rec = {\"unit\": uid, \"day\": d, \"phase\": ph, \"unit_size\": size, \"intensity\": round(inten, 3),\n", + " \"resistance\": round(r, 2), \"fire_missions\": fire, \"move_km\": round(move, 1),\n", + " \"weather\": weather, \"emergency\": emerg}\n", + " base = size / 100\n", + " for i, it in enumerate(ITEMS):\n", + " c = CSR[ph][i] * base\n", + " if it in AMMO:\n", + " c *= (0.5 + 0.6 * inten) * (1 + 0.04 * fire * (1.0 if it in (\"ammo_C\", \"ammo_D\") else 0.4))\n", + " elif it == \"fuel_jet\":\n", + " c *= (0.6 + 0.5 * inten) + 0.03 * fire\n", + " else:\n", + " c = c * (0.8 + 0.2 * (weather != \"clear\")) + 0.05 * move * base\n", + " c *= rng.lognormal(0, 0.12) * (2.5 if emerg else 1.0)\n", + " rec[it] = round(float(c), 1)\n", + " rows.append(rec)\n", + " return pd.DataFrame(rows)\n", + "# ---------------------------------------------------------------------------\n", + "\n", + "# ---------------------------------------------------------------------------\n", + "# [교육용가상자료] 가상 저장고(H1~H3) 저장칸 배치 모델. 실제 탄약 저장 기준·안전거리와 무관한 가상 계수다.\n", + "# 저장칸: 저장고 x 열(row) x 칸(col). 동선 거리 = 반출장(입구)까지의 가상 거리(m).\n", + "# 저장군(G1~G3)은 가상 혼적 제한 그룹이며, 저장고마다 받을 수 있는 저장군이 정해져 있다.\n", + "# ---------------------------------------------------------------------------\n", + "HOUSES = {\"H1\": {\"groups\": {\"G1\", \"G2\"}, \"rows\": 2, \"cols\": 6, \"dock\": 15.0},\n", + " \"H2\": {\"groups\": {\"G1\"}, \"rows\": 2, \"cols\": 5, \"dock\": 40.0},\n", + " \"H3\": {\"groups\": {\"G2\", \"G3\"}, \"rows\": 2, \"cols\": 6, \"dock\": 70.0}}\n", + "ITEM_GROUP = {\"ammo_A\": \"G1\", \"ammo_B\": \"G1\", \"ammo_C\": \"G2\", \"ammo_D\": \"G3\"}\n", + "SLOT_CAP = 6 # 1열 저장칸 가상 팔레트 수용량(2열은 +4)\n", + "\n", + "\n", + "def make_slots(seed=3):\n", + " \"\"\"저장칸 목록과 현재 점유(이동 불가 기존 재고) 여부를 만든다.\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " rows = []\n", + " for h, c in HOUSES.items():\n", + " for r in range(c[\"rows\"]):\n", + " for k in range(c[\"cols\"]):\n", + " rows.append({\"slot\": f\"{h}-{r + 1}{k + 1:02d}\", \"house\": h, \"dist_m\": c[\"dock\"] + 6.0 * k + 4.0 * r,\n", + " \"cap\": SLOT_CAP if r == 0 else SLOT_CAP + 4, \"fixed\": bool(rng.random() < 0.15)})\n", + " return pd.DataFrame(rows)\n", + "\n", + "\n", + "def make_skus(demand, seed=5):\n", + " \"\"\"품목별 예측 일일 반출량(demand: 품목->상자/일)을 로트 단위 SKU로 나눈다. 반출 빈도 = 일일 반출 횟수(가상).\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " rows = []\n", + " for it, dq in demand.items():\n", + " n_lot = 5\n", + " share = rng.dirichlet(np.ones(n_lot) * 2)\n", + " for j in range(n_lot):\n", + " rows.append({\"sku\": f\"{it}-L{j + 1}\", \"item\": it, \"group\": ITEM_GROUP[it],\n", + " \"pallets\": int(rng.integers(2, SLOT_CAP + 5)), \"freq\": round(float(dq * share[j] / 4), 2)})\n", + " return pd.DataFrame(rows)\n", + "\n", + "\n", + "def feasible(sku, slot):\n", + " # [TODO : 학생 실습 7] 보급 우선순위 지정 및 대체 탄종 불출을 위한 군수참모 HITL 결재선 설계 관련 핵심 로직을 구현하세요.\n", + " # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n", + " # <<< 여기에 코드를 작성하세요 >>>\n", + " pass # 작성 후 pass를 제거하세요.\n", + "def travel(assign, skus, slots):\n", + " \"\"\"작업동선 = 반출 빈도 x 왕복 거리 합(가상 m/일).\"\"\"\n", + " # [TODO : 학생 실습 7] 보급 우선순위 지정 및 대체 탄종 불출을 위한 군수참모 HITL 결재선 설계 관련 핵심 로직을 구현하세요.\n", + " # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n", + " # <<< 여기에 코드를 작성하세요 >>>\n", + " pass # 작성 후 pass를 제거하세요.\n", + "def first_fit(skus, slots):\n", + " \"\"\"현행 방식(대안 비교 기준): 입고 순서대로 비어 있는 첫 번째 적합 칸에 넣는다.\"\"\"\n", + " used, out = set(), {}\n", + " for i, k in skus.iterrows():\n", + " for _, s in slots.iterrows():\n", + " if s[\"slot\"] not in used and feasible(k, s):\n", + " out[i] = s[\"slot\"]; used.add(s[\"slot\"]); break\n", + " return out\n", + "\n", + "\n", + "def optimize(skus, slots, lam=8):\n", + " \"\"\"할당 문제(정수계획 특수형): 비용 = 반출 빈도 x 왕복거리 + lam x 빈 공간(공간낭비). 부적합 칸은 큰 비용.\"\"\"\n", + " cost = np.full((len(skus), len(slots)), 1e7)\n", + " for i, k in skus.iterrows():\n", + " for j, s in slots.iterrows():\n", + " if feasible(k, s):\n", + " cost[i, j] = 2 * k[\"freq\"] * s[\"dist_m\"] + lam * (s[\"cap\"] - k[\"pallets\"])\n", + " ri, cj = linear_sum_assignment(cost)\n", + " return {int(i): slots.loc[j, \"slot\"] for i, j in zip(ri, cj) if cost[i, j] < 1e7}, cost\n", + "\n", + "\n", + "def loc_score(assign, skus, slots):\n", + " \"\"\"위치점수(0~100): 반출 빈도 상위 SKU일수록 반출장 가까운 칸에 있는 정도(가중 평균).\"\"\"\n", + " d = slots.set_index(\"slot\")[\"dist_m\"]; dmin, dmax = d.min(), d.max()\n", + " w = skus[\"freq\"] / skus[\"freq\"].sum()\n", + " return float(100 * sum(w[i] * (1 - (d[s] - dmin) / (dmax - dmin)) for i, s in assign.items()))\n", + "# ---------------------------------------------------------------------------\n", + "\n", + "COST_PER_M = 0.02 # [교육용가상자료] 지게차 왕복 1m당 가상 작업비용 지수\n", + "SUB_RATE = {\"ammo_C\": (\"ammo_D\", 0.8)} # 가상 대체 탄종과 환산 계수(교육용 가정값)\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", + "# 1) 반출 수요: 최근 14일 품목별 일일 평균 소모(미션 5 예측값 대신 사용, 가상 상자/일)\n", + "ops = make_ops(90, seed=7)\n", + "demand = ops[ops[\"day\"] >= 76][AMMO].sum().div(14).round(1).to_dict()\n", + "skus, slots = make_skus(demand), make_slots()\n", + "print(f\"[가상 저장고] 저장칸 {len(slots)}개(현재 점유·이동 불가 {slots['fixed'].sum()}개), SKU {len(skus)}개, 일일 반출 수요 {demand}\")\n", + "\n", + "cur = first_fit(skus, slots)\n", + "opt, cost = optimize(skus, slots)\n", + "\n", + "\n", + "def kpi(a):\n", + " used = slots.set_index(\"slot\").loc[list(a.values()), \"cap\"].sum()\n", + " waste = 1 - skus.loc[list(a.keys()), \"pallets\"].sum() / used\n", + " t = travel(a, skus, slots)\n", + " return {\"작업동선(m/일)\": t, \"작업비용 지수\": t * COST_PER_M, \"공간낭비율\": waste, \"위치점수\": loc_score(a, skus, slots), \"배치 SKU\": len(a)}\n", + "\n", + "\n", + "kp = pd.DataFrame({\"현행(입고순)\": kpi(cur), \"최적화\": kpi(opt)}).T\n", + "print(kp.to_string(float_format=lambda x: f\"{x:.2f}\"))\n", + "cut = 1 - kp.loc[\"최적화\", \"작업비용 지수\"] / kp.loc[\"현행(입고순)\", \"작업비용 지수\"]\n", + "print(f\"작업비용 절감률 {cut:.1%} (목표 15% 이상: {'달성' if cut >= 0.15 else '미달'})\")\n", + "\n", + "top = skus[\"freq\"].idxmax()\n", + "alt = [(slots.loc[j, \"slot\"], cost[top, j]) for j in np.argsort(cost[top]) if cost[top, j] < 1e7][:3]\n", + "print(f\"대안배치(반출 최다 {skus.loc[top, 'sku']}): \" + \", \".join(f\"{s}(비용 {c:.0f})\" for s, c in alt) + \" -> 1안 점유 시 2·3안 순으로 제시\")\n", + "\n", + "# 2) 민감도: 돌발 수요(가상 전시 전환으로 ammo_A 반출 2배)\n", + "sk2 = skus.copy(); sk2.loc[sk2[\"item\"] == \"ammo_A\", \"freq\"] *= 2\n", + "o2, _ = optimize(sk2, slots)\n", + "moved = sum(o2[i] != opt[i] for i in opt)\n", + "print(f\"민감도(ammo_A 반출 2배): 최적 배치 변경 {moved}칸, 작업동선 현행 {travel(cur, sk2, slots):.0f} -> 재최적화 {travel(o2, sk2, slots):.0f} m/일\")\n", + "\n", + "# 3) HITL 결재선: 제안은 모두 '결재 대기'로 시작하며 자동 확정하지 않는다\n", + "moves = [(skus.loc[i, \"sku\"], cur.get(i), opt[i], skus.loc[i, \"freq\"]) for i in opt if cur.get(i) != opt[i]]\n", + "moves.sort(key=lambda m: -m[3])\n", + "props = [{\"id\": f\"P{n + 1:02d}\", \"type\": \"재배치\", \"what\": f\"{s} {a}->{b}\", \"evid\": f\"반출 {f:.1f}회/일, 동선 감소\"} for n, (s, a, b, f) in enumerate(moves[:6])]\n", + "rng = np.random.default_rng(4)\n", + "dos = {u: round(float(rng.uniform(0.8, 4.0)), 1) for u in UNITS}\n", + "urgent = sorted([u for u in UNITS if dos[u] < 2.0], key=lambda u: dos[u])\n", + "props += [{\"id\": f\"P{len(props) + k + 1:02d}\", \"type\": \"재보급 우선순위\", \"what\": f\"{u} 탄약 우선 불출({k + 1}순위)\", \"evid\": f\"DOS {dos[u]}일\"} for k, u in enumerate(urgent)]\n", + "src, (dst, rate) = \"ammo_C\", SUB_RATE[\"ammo_C\"]\n", + "props.append({\"id\": f\"P{len(props) + 1:02d}\", \"type\": \"대체 탄종\", \"what\": f\"{src} 부족분 40상자 -> {dst} {40 * rate:.0f}상자\", \"evid\": f\"가상 환산 계수 {rate}\"})\n", + "\n", + "audit = Audit()\n", + "for p in props:\n", + " audit.add(t=\"제안\", pid=p[\"id\"], kind=p[\"type\"], what=p[\"what\"], evid=p[\"evid\"], status=\"결재대기\", by=\"AI\")\n", + "\n", + "\n", + "def staff_decision(p, k):\n", + " \"\"\"가상 군수참모 판단(시뮬레이션): 근거 확인 후 승인·수정·반려, 일부는 시간 내 무응답.\"\"\"\n", + " if p[\"type\"] == \"대체 탄종\":\n", + " return \"수정\", \"대체 수량 절반만 승인, 잔여는 상급부대 청구\"\n", + " if k % 5 == 4:\n", + " return \"무응답\", \"결재 시한 초과 -> 현행 유지\"\n", + " if p[\"type\"] == \"재배치\" and k % 3 == 2:\n", + " return \"반려\", \"해당 저장고 점검 일정과 겹침\"\n", + " return \"승인\", \"근거 확인\"\n", + "\n", + "\n", + "executed = []\n", + "for k, p in enumerate(props):\n", + " d, why = staff_decision(p, k)\n", + " audit.add(t=\"결재\", pid=p[\"id\"], decision=d, reason=why, by=\"군수참모(가상)\")\n", + " if d in (\"승인\", \"수정\"):\n", + " executed.append(p[\"id\"]); audit.add(t=\"집행\", pid=p[\"id\"], by=\"저장관리반(가상)\")\n", + "tab = pd.DataFrame([{\"제안\": e[\"pid\"], \"결정\": e[\"decision\"], \"사유\": e[\"reason\"]} for e in audit.log if e[\"t\"] == \"결재\"])\n", + "info = pd.DataFrame(props).set_index(\"id\")\n", + "tab[\"유형\"], tab[\"내용\"] = tab[\"제안\"].map(info[\"type\"]), tab[\"제안\"].map(info[\"what\"])\n", + "print(f\"\\n[군수참모 결재 결과] 제안 {len(props)}건\")\n", + "print(tab[[\"제안\", \"유형\", \"내용\", \"결정\", \"사유\"]].to_string(index=False))\n", + "auto = [e for e in audit.log if e[\"t\"] == \"집행\" and not any(x[\"t\"] == \"결재\" and x[\"pid\"] == e[\"pid\"] and x[\"decision\"] in (\"승인\", \"수정\") for x in audit.log)]\n", + "print(f\"결재 없이 집행된 건 {len(auto)}건 (자동 확정 금지: {'준수' if not auto else '위반'}), 결재 사유 기록률 {tab['사유'].astype(bool).mean():.0%}\")\n", + "print(f\"감사 로그 {len(audit.log)}건 무결성 검증: {'정상' if audit.verify() is None else '변조 발견'}\")\n", + "i3 = next(e[\"seq\"] for e in audit.log if e[\"t\"] == \"결재\" and e[\"decision\"] == \"반려\")\n", + "audit.log[i3][\"decision\"] = \"승인\"\n", + "print(f\"변조 시험({i3}번 반려 기록을 승인으로 수정) -> {audit.verify()}번 기록에서 변조 검출\")\n", + "\n", + "fig, ax = plt.subplots(1, 2, figsize=(12, 4))\n", + "for a, (nm, asg) in zip(ax, [(\"current (first-fit)\", cur), (\"optimized\", opt)]):\n", + " s = slots.set_index(\"slot\")\n", + " a.scatter(s[\"dist_m\"], [list(HOUSES).index(h) for h in s[\"house\"]], c=\"lightgray\", s=60, marker=\"s\")\n", + " for i, sl in asg.items():\n", + " a.scatter(s.loc[sl, \"dist_m\"], list(HOUSES).index(s.loc[sl, \"house\"]), s=40 + 30 * skus.loc[i, \"freq\"], alpha=0.6)\n", + " a.set_yticks(range(3)); a.set_yticklabels(list(HOUSES)); a.set_xlabel(\"distance to dock (m)\"); a.set_title(f\"Placement: {nm} (size=issue freq)\")\n", + "plt.tight_layout(); plt.show()\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 07 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + "[가상 저장고] 저장칸 34개(현재 점유·이동 불가 6개), SKU 20개, 일일 반출 수요 {'ammo_A': 25.8, 'ammo_B': 16.7, 'ammo_C': 10.2, 'ammo_D': 7.1}\n", + " 작업동선(m/일) 작업비용 지수 공간낭비율 위치점수 배치 SKU\n", + "현행(입고순) 1506.20 30.12 0.29 60.18 20.00\n", + "최적화 1195.08 23.90 0.29 71.88 20.00\n", + "작업비용 절감률 20.7% (목표 15% 이상: 달성)\n", + "대안배치(반출 최다 ammo_A-L3): H1-102(비용 126), H1-201(비용 147), H1-103(비용 157) -> 1안 점유 시 2·3안 순으로 제시\n", + "민감도(ammo_A 반출 2배): 최적 배치 변경 7칸, 작업동선 현행 1919 -> 재최적화 1491 m/일\n", + "\n", + "[군수참모 결재 결과] 제안 9건\n", + " 제안 유형 내용 결정 사유\n", + "P01 재배치 ammo_A-L3 H1-104->H1-201 승인 근거 확인\n", + "P02 재배치 ammo_B-L4 H1-206->H1-102 승인 근거 확인\n", + "P03 재배치 ammo_A-L5 H1-106->H1-103 반려 해당 저장고 점검 일정과 겹침\n", + "P04 재배치 ammo_A-L2 H1-103->H2-101 승인 근거 확인\n", + "P05 재배치 ammo_B-L3 H1-205->H1-202 무응답 결재 시한 초과 -> 현행 유지\n", + "P06 재배치 ammo_B-L5 H2-101->H2-102 반려 해당 저장고 점검 일정과 겹침\n", + "P07 재보급 우선순위 U04 탄약 우선 불출(1순위) 승인 근거 확인\n", + "P08 재보급 우선순위 U08 탄약 우선 불출(2순위) 승인 근거 확인\n", + "P09 대체 탄종 ammo_C 부족분 40상자 -> ammo_D 32상자 수정 대체 수량 절반만 승인, 잔여는 상급부대 청구\n", + "결재 없이 집행된 건 0건 (자동 확정 금지: 준수), 결재 사유 기록률 100%\n", + "감사 로그 24건 무결성 검증: 정상\n", + "변조 시험(13번 반려 기록을 승인으로 수정) -> 13번 기록에서 변조 검출\n", + "```\n", + "
\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 08. 7일간 고강도 전면전 모의 탄약/유류 소모량 극한 스트레스 시뮬레이션\n", + "**소요시간**: 약 3.0시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "7일간 전면전 모의 탄약/유류 소모량 스트레스 시뮬레이션을 수행한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **7일간 고강도 전면전(가상) 상황에서 소모량과 보유량을 일일 모의하고 재보급 경보 대시보드의 성능을 극한 조건에서 검증한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **가상 부대 8개의 마지막 7일을 고강도 공격 단계로 바꿔 소모·보유량을 모의 수행하고 검증하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `src/dashboard/amm02_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": [ + "\"\"\"7일간 고강도 전면전(가상) 상황에서 부대별 탄약·유류 소모량과 보유량을 일일 모의하고, 보고 누락·보고 지연·긴급 수요 급증·부대 수 10배 부하를 주입해 재보급 경보 대시보드의 갱신 시간(2초 이내)과 경보 발령 정확도(95% 이상)를 검증한다.\"\"\"\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, RandomForestRegressor\n", + "\n", + "# ---------------------------------------------------------------------------\n", + "# [교육용가상자료] 가상 부대·가상 품목 일일 소모 기록 생성기 (실제 탄약 제원·소모율과 무관)\n", + "# 가상 부대 8개 x 90일. 소모량 = 가상 CSR 계수 x 병력 규모 x (교전 강도·적 저항·화력 계획 효과) x 잡음\n", + "# ---------------------------------------------------------------------------\n", + "UNITS = [f\"U{i:02d}\" for i in range(1, 9)]\n", + "ITEMS = [\"ammo_A\", \"ammo_B\", \"ammo_C\", \"ammo_D\", \"fuel_jet\", \"fuel_diesel\"]\n", + "AMMO, FUEL = ITEMS[:4], ITEMS[4:]\n", + "PHASES = [\"prep\", \"attack\", \"defense\", \"stability\"]\n", + "CSR = { # 가상 계수: 병력 100명·1일 기준 소모 단위(탄약=상자, 유류=드럼)\n", + " \"prep\": [1.0, 0.6, 0.3, 0.2, 1.5, 6.0],\n", + " \"attack\": [6.0, 3.5, 2.0, 1.2, 5.0, 12.0],\n", + " \"defense\": [4.5, 3.0, 1.6, 0.8, 3.0, 7.0],\n", + " \"stability\": [0.8, 0.5, 0.3, 0.2, 1.0, 5.0],\n", + "}\n", + "PHASE_RESIST = {\"prep\": 1.0, \"attack\": 3.2, \"defense\": 3.6, \"stability\": 0.8}\n", + "\n", + "\n", + "def make_ops(n_days=90, seed=7, surge=0):\n", + " \"\"\"가상 부대별 일일 작전 기록과 품목별 실제 소모량(정답)을 만든다. surge>0이면 마지막 surge일을 고강도 공격 단계로 바꾼다(스트레스 시험용).\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " rows = []\n", + " for uid in UNITS:\n", + " size = int(rng.integers(3, 9)) * 100\n", + " lens = rng.integers(8, 20, 4)\n", + " sched = [\"prep\"] * lens[0] + [\"attack\"] * lens[1] + [\"defense\"] * lens[2] + [\"attack\"] * lens[3]\n", + " sched = (sched + [\"stability\"] * n_days)[:n_days]\n", + " if surge:\n", + " sched[-surge:] = [\"attack\"] * surge\n", + " r = 1.0\n", + " for d, ph in enumerate(sched):\n", + " r = float(np.clip(0.7 * r + 0.3 * (4.8 if surge and d >= n_days - surge else PHASE_RESIST[ph]) + rng.normal(0, 0.35), 0, 5))\n", + " inten = float(np.clip(r / 5 * 0.9 + rng.normal(0, 0.06), 0, 1))\n", + " fire = int(rng.poisson(1 + 8 * inten))\n", + " move = float(rng.gamma(2, {\"prep\": 4, \"attack\": 12, \"defense\": 2, \"stability\": 5}[ph]))\n", + " weather = str(rng.choice([\"clear\", \"rain\", \"snow\"], p=[0.7, 0.2, 0.1]))\n", + " emerg = bool(rng.random() < 0.02)\n", + " rec = {\"unit\": uid, \"day\": d, \"phase\": ph, \"unit_size\": size, \"intensity\": round(inten, 3),\n", + " \"resistance\": round(r, 2), \"fire_missions\": fire, \"move_km\": round(move, 1),\n", + " \"weather\": weather, \"emergency\": emerg}\n", + " base = size / 100\n", + " for i, it in enumerate(ITEMS):\n", + " c = CSR[ph][i] * base\n", + " if it in AMMO:\n", + " c *= (0.5 + 0.6 * inten) * (1 + 0.04 * fire * (1.0 if it in (\"ammo_C\", \"ammo_D\") else 0.4))\n", + " elif it == \"fuel_jet\":\n", + " c *= (0.6 + 0.5 * inten) + 0.03 * fire\n", + " else:\n", + " c = c * (0.8 + 0.2 * (weather != \"clear\")) + 0.05 * move * base\n", + " c *= rng.lognormal(0, 0.12) * (2.5 if emerg else 1.0)\n", + " rec[it] = round(float(c), 1)\n", + " rows.append(rec)\n", + " return pd.DataFrame(rows)\n", + "# ---------------------------------------------------------------------------\n", + "\n", + "# ---- 소모량 예측용 파생 피처: 전일까지의 실적 + 당일 작전·화력 계획(사전에 알 수 있는 값만) ----\n", + "FEATS = [\"lag1\", \"lag2\", \"roll3\", \"roll7\", \"csr_base\", \"size100\", \"fire_plan\", \"fire_x_engaged\", \"resist_lag1\",\n", + " \"resist_trend\", \"inten_lag1\", \"move_plan\", \"days_in_phase\", \"engaged\", \"ph_attack\", \"ph_defense\", \"ph_prep\",\n", + " \"item_idx\", \"is_fuel\"]\n", + "DERIVED = [\"lag1\", \"lag2\", \"roll3\", \"roll7\", \"csr_base\", \"fire_x_engaged\", \"resist_lag1\", \"resist_trend\",\n", + " \"inten_lag1\", \"days_in_phase\", \"engaged\", \"ph_attack\", \"ph_defense\", \"ph_prep\"]\n", + "\n", + "\n", + "def build_long(ops):\n", + " \"\"\"부대x일자 기록을 (부대, 일자, 품목) 행으로 펼치고 파생 피처를 만든다.\n", + " # [TODO : 학생 실습 8] 7일간 고강도 전면전 모의 탄약/유류 소모량 극한 스트레스 시뮬레이션 관련 핵심 로직을 구현하세요.\n", + " # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n", + " # <<< 여기에 코드를 작성하세요 >>>\n", + " pass # 작성 후 pass를 제거하세요.\n", + "def to_qty(d, y_pred):\n", + " \"\"\"예측 보정 계수(log)를 소모량으로 되돌린다: 기준량 x exp(예측).\"\"\"\n", + " # [TODO : 학생 실습 8] 7일간 고강도 전면전 모의 탄약/유류 소모량 극한 스트레스 시뮬레이션 관련 핵심 로직을 구현하세요.\n", + " # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n", + " # <<< 여기에 코드를 작성하세요 >>>\n", + " pass # 작성 후 pass를 제거하세요.\n", + "def mape(y, p):\n", + " return float(np.mean(np.abs(np.asarray(p) - np.asarray(y)) / np.asarray(y)))\n", + "\n", + "# ---- 소모량 예측 모델(앙상블) 학습·예측 ----\n", + "def fit_models(tr, seed=0):\n", + " \"\"\"GBR + 랜덤포레스트 두 모델을 학습한다(목표값은 CSR 기준량 대비 log 보정 계수).\"\"\"\n", + " g = GradientBoostingRegressor(n_estimators=150, max_depth=3, learning_rate=0.08, subsample=0.8, random_state=seed).fit(tr[FEATS], tr[\"y\"])\n", + " r = RandomForestRegressor(n_estimators=120, min_samples_leaf=3, random_state=seed, n_jobs=1).fit(tr[FEATS], tr[\"y\"])\n", + " return g, r\n", + "\n", + "\n", + "def predict_qty(models, d, w=0.5):\n", + " \"\"\"두 모델의 log 예측을 가중 평균해 소모량으로 되돌린다.\"\"\"\n", + " g, r = models\n", + " return to_qty(d, w * g.predict(d[FEATS]) + (1 - w) * r.predict(d[FEATS]))\n", + "\n", + "SURGE = 7 # 고강도 모의 일수\n", + "DOS_ALERT = 2.0 # [교육용가상자료] 경보 기준 보급 수준(일)\n", + "\n", + "data = build_long(make_ops(90, seed=7, surge=SURGE))\n", + "start = 90 - SURGE\n", + "models = fit_models(data[data[\"day\"] < start])\n", + "test = data[data[\"day\"] >= start].copy()\n", + "\n", + "\n", + "def inject(d, miss=0.0, delay=0.0, spike=0.0, seed=0):\n", + " \"\"\"오류 주입: 보고 누락(전일 실적 결측 -> CSR 기준 1.0 대입), 보고 지연(전일 대신 2일 전 실적), 긴급 수요 급증(긴급 불출 요청 접수, 실제 소모 x2.5).\"\"\"\n", + " rng = np.random.default_rng(seed); d = d.copy()\n", + " m = rng.random(len(d)) < miss\n", + " d.loc[m, [\"lag1\", \"lag2\", \"roll3\", \"roll7\"]] = 1.0\n", + " l = (rng.random(len(d)) < delay) & ~m\n", + " d.loc[l, \"lag1\"] = d.loc[l, \"lag2\"]\n", + " s = rng.random(len(d)) < spike\n", + " d.loc[s, \"qty\"] *= 2.5\n", + " d.loc[s, \"emergency\"] = True\n", + " return d\n", + "\n", + "\n", + "def dashboard(d, stock0, margin=1.0):\n", + " \"\"\"대시보드 1회 갱신: 예측 -> 보유량 경과 -> DOS·경보 -> 부대별 집계 표. 반환: 경보표, 집계표.\"\"\"\n", + " # 긴급 불출 요청이 접수된 건은 요청 배수(가상 2.5배)를 예측에 반영한다(요청은 사전에 알 수 있는 정보)\n", + " d = d.assign(pred=predict_qty(models, d) * np.where(d[\"emergency\"], 2.5, 1.0)).sort_values([\"unit\", \"item\", \"day\"])\n", + " resup = d[\"csr_base\"] * d[\"size100\"] * 1.0 # 계획 재보급량(작전 단계 CSR 기준, 가상)\n", + " used = d.groupby([\"unit\", \"item\"])[\"qty\"].cumsum() - d[\"qty\"]\n", + " got = resup.groupby([d[\"unit\"], d[\"item\"]]).cumsum() - resup\n", + " d[\"on_hand\"] = d.set_index([\"unit\", \"item\"]).index.map(stock0).values + got.values - used.values\n", + " d[\"dos_pred\"] = d[\"on_hand\"].clip(lower=0) / d[\"pred\"]\n", + " d[\"dos_true\"] = d[\"on_hand\"].clip(lower=0) / d[\"qty\"]\n", + " d[\"alert\"], d[\"truth\"] = d[\"dos_pred\"] < DOS_ALERT * margin, d[\"dos_true\"] < DOS_ALERT\n", + " agg = d.groupby([\"day\", \"unit\"]).agg(alerts=(\"alert\", \"sum\"), ammo=(\"pred\", \"sum\"))\n", + " return d, agg\n", + "\n", + "\n", + "stock0 = (test[test[\"day\"] == start].set_index([\"unit\", \"item\"])[\"csr_base\"] * test[test[\"day\"] == start].set_index([\"unit\", \"item\"])[\"size100\"] * 3.0).to_dict()\n", + "cases = [(\"정상 보고\", {}), (\"보고 누락 10%\", {\"miss\": 0.1}), (\"보고 지연 20%\", {\"delay\": 0.2}),\n", + " (\"긴급 급증 5%\", {\"spike\": 0.05}), (\"복합(누락10+지연20+급증5)\", {\"miss\": 0.1, \"delay\": 0.2, \"spike\": 0.05})]\n", + "rows = []\n", + "for name, kw in cases:\n", + " d = inject(test, seed=1, **kw)\n", + " t0 = time.perf_counter(); out, agg = dashboard(d, stock0); dt = time.perf_counter() - t0\n", + " tp = int((out[\"alert\"] & out[\"truth\"]).sum()); fn = int((~out[\"alert\"] & out[\"truth\"]).sum())\n", + " rows.append({\"시나리오\": name, \"갱신(s)\": dt, \"경보\": int(out[\"alert\"].sum()), \"실제 부족\": int(out[\"truth\"].sum()),\n", + " \"정확도\": (out[\"alert\"] == out[\"truth\"]).mean(), \"부족 탐지율\": tp / max(tp + fn, 1)})\n", + "res = pd.DataFrame(rows)\n", + "print(f\"[7일 고강도 모의] 부대 8 x 품목 6 x {SURGE}일 = {len(test)}건, 시작 보유량 3일분(가상)\")\n", + "print(res.to_string(index=False, float_format=lambda x: f\"{x:.3f}\"))\n", + "acc = res[\"정확도\"].min()\n", + "print(f\"경보 정확도 최저 {acc:.1%} (목표 95% 이상: {'달성' if acc >= 0.95 else '미달'})\")\n", + "\n", + "# 부하 시험: 가상 부대 10배(80개) 데이터로 대시보드 갱신 시간 측정\n", + "big = pd.concat([test.assign(unit=test[\"unit\"] + f\"_{k}\") for k in range(10)], ignore_index=True)\n", + "st_big = {(u + f\"_{k}\", it): v for (u, it), v in stock0.items() for k in range(10)}\n", + "t0 = time.perf_counter(); out_b, _ = dashboard(big, st_big); dt_b = time.perf_counter() - t0\n", + "mx = max(res[\"갱신(s)\"].max(), dt_b)\n", + "print(f\"부하 시험(부대 80개, {len(big)}건) 갱신 {dt_b:.2f}s, 최대 갱신 {mx:.2f}s (목표 2초 이내: {'달성' if mx <= 2 else '미달'})\")\n", + "\n", + "base, _ = dashboard(test, stock0)\n", + "first = base[base[\"truth\"]].groupby(\"item\")[\"day\"].min().sub(start - 1).astype(int)\n", + "print(\"품목별 첫 부족 발생(모의 n일차):\", \", \".join(f\"{k} {v}일차\" for k, v in first.items()) or \"없음\")\n", + "ammo_tot = base[base[\"is_fuel\"] == 0].groupby(\"day\")[[\"qty\", \"pred\"]].sum()\n", + "print(f\"7일 탄약 총소모(가상 상자): 실제 {ammo_tot['qty'].sum():.0f}, 예측 {ammo_tot['pred'].sum():.0f}, 오차 {ammo_tot['pred'].sum() / ammo_tot['qty'].sum() - 1:+.1%}\")\n", + "tr_off = []\n", + "for mg in (1.0, 1.1, 1.2):\n", + " o, _ = dashboard(test, stock0, mg)\n", + " tr_off.append(f\"x{mg}: 정확도 {(o['alert'] == o['truth']).mean():.1%}/탐지율 {(o['alert'] & o['truth']).sum() / o['truth'].sum():.0%}\")\n", + "print(\"경보 안전계수 상충관계(정상 보고):\", \", \".join(tr_off), \"-> 부족 놓침을 줄이려면 안전계수를 올리고 정확도 저하를 감수한다\")\n", + "print(\"경보는 재보급 검토 요청이며 불출·우선순위 결정은 군수참모 결재를 거친다.\")\n", + "\n", + "fig, ax = plt.subplots(1, 3, figsize=(16, 4))\n", + "ammo_tot.plot(ax=ax[0], marker=\"o\", title=\"7-day surge: total ammo (actual vs forecast)\")\n", + "piv = base.groupby([\"unit\", \"day\"])[\"alert\"].sum().unstack()\n", + "im = ax[1].imshow(piv.values, cmap=\"Reds\", aspect=\"auto\"); ax[1].set_yticks(range(len(piv))); ax[1].set_yticklabels(piv.index)\n", + "ax[1].set_xticks(range(piv.shape[1])); ax[1].set_xticklabels(range(1, piv.shape[1] + 1)); ax[1].set_xlabel(\"surge day\")\n", + "ax[1].set_title(\"Alerts per unit/day (items with DOS<2)\"); plt.colorbar(im, ax=ax[1])\n", + "ax[2].bar(range(len(res)), res[\"정확도\"]); ax[2].axhline(0.95, c=\"r\", ls=\"--\"); ax[2].set_ylim(0.8, 1.0)\n", + "ax[2].set_xticks(range(len(res))); ax[2].set_xticklabels([\"normal\", \"missing\", \"delay\", \"spike\", \"mixed\"]); ax[2].set_title(\"Alert accuracy by fault scenario\")\n", + "plt.tight_layout(); plt.show()\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 08 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + "[7일 고강도 모의] 부대 8 x 품목 6 x 7일 = 336건, 시작 보유량 3일분(가상)\n", + " 시나리오 갱신(s) 경보 실제 부족 정확도 부족 탐지율\n", + " 정상 보고 0.041 23 29 0.964 0.690\n", + " 보고 누락 10% 0.039 23 29 0.964 0.690\n", + " 보고 지연 20% 0.037 22 29 0.961 0.655\n", + " 긴급 급증 5% 0.038 67 73 0.964 0.877\n", + "복합(누락10+지연20+급증5) 0.041 66 73 0.961 0.863\n", + "경보 정확도 최저 96.1% (목표 95% 이상: 달성)\n", + "부하 시험(부대 80개, 3360건) 갱신 0.07s, 최대 갱신 0.07s (목표 2초 이내: 달성)\n", + "품목별 첫 부족 발생(모의 n일차): ammo_C 4일차, ammo_D 5일차, fuel_jet 4일차\n", + "7일 탄약 총소모(가상 상자): 실제 4645, 예측 4633, 오차 -0.2%\n", + "경보 안전계수 상충관계(정상 보고): x1.0: 정확도 96.4%/탐지율 69%, x1.1: 정확도 94.3%/탐지율 90%, x1.2: 정확도 87.2%/탐지율 97% -> 부족 놓침을 줄이려면 안전계수를 올리고 정확도 저하를 감수한다\n", + "경보는 재보급 검토 요청이며 불출·우선순위 결정은 군수참모 결재를 거친다.\n", + "```\n", + "
\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 09. AAR 기반 소모량 예측치와 실사격 결과 간 편차 분석 및 소모 계수 재보정\n", + "**소요시간**: 약 3.0시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "AAR 기반 소모량 예측 편차를 분석하고 소모 계수를 재보정한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **AAR 자료로 CSR 계획치와 실소모 결과의 편차를 분석하고 소모 계수를 재보정해 도입 효과를 산출한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **작전 단계·품목·교전 강도 구간별로 실제/계획 비율을 분석해 편차 원인을 찾는다 과정을 진행하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `reports/amm02_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(사후검토) 자료로 기준 소모율(CSR) 계획치와 실사격·실소모 결과의 편차를 작전 단계·품목·교전 강도별로 분석해 원인을 찾고, 소모 계수를 축소 추정으로 재보정한 뒤 새 훈련 자료에서 검증하며, 비용 절감·작업시간 단축·긴급 재보급 감소 3대 효과 지표와 민감도를 산출한다.\"\"\"\n", + "import numpy as np\n", + "import pandas as pd\n", + "import matplotlib.pyplot as plt\n", + "from scipy.optimize import linear_sum_assignment\n", + "\n", + "# ---------------------------------------------------------------------------\n", + "# [교육용가상자료] 가상 부대·가상 품목 일일 소모 기록 생성기 (실제 탄약 제원·소모율과 무관)\n", + "# 가상 부대 8개 x 90일. 소모량 = 가상 CSR 계수 x 병력 규모 x (교전 강도·적 저항·화력 계획 효과) x 잡음\n", + "# ---------------------------------------------------------------------------\n", + "UNITS = [f\"U{i:02d}\" for i in range(1, 9)]\n", + "ITEMS = [\"ammo_A\", \"ammo_B\", \"ammo_C\", \"ammo_D\", \"fuel_jet\", \"fuel_diesel\"]\n", + "AMMO, FUEL = ITEMS[:4], ITEMS[4:]\n", + "PHASES = [\"prep\", \"attack\", \"defense\", \"stability\"]\n", + "CSR = { # 가상 계수: 병력 100명·1일 기준 소모 단위(탄약=상자, 유류=드럼)\n", + " \"prep\": [1.0, 0.6, 0.3, 0.2, 1.5, 6.0],\n", + " \"attack\": [6.0, 3.5, 2.0, 1.2, 5.0, 12.0],\n", + " \"defense\": [4.5, 3.0, 1.6, 0.8, 3.0, 7.0],\n", + " \"stability\": [0.8, 0.5, 0.3, 0.2, 1.0, 5.0],\n", + "}\n", + "PHASE_RESIST = {\"prep\": 1.0, \"attack\": 3.2, \"defense\": 3.6, \"stability\": 0.8}\n", + "\n", + "\n", + "def make_ops(n_days=90, seed=7, surge=0):\n", + " \"\"\"가상 부대별 일일 작전 기록과 품목별 실제 소모량(정답)을 만든다. surge>0이면 마지막 surge일을 고강도 공격 단계로 바꾼다(스트레스 시험용).\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " rows = []\n", + " for uid in UNITS:\n", + " size = int(rng.integers(3, 9)) * 100\n", + " lens = rng.integers(8, 20, 4)\n", + " sched = [\"prep\"] * lens[0] + [\"attack\"] * lens[1] + [\"defense\"] * lens[2] + [\"attack\"] * lens[3]\n", + " sched = (sched + [\"stability\"] * n_days)[:n_days]\n", + " if surge:\n", + " sched[-surge:] = [\"attack\"] * surge\n", + " r = 1.0\n", + " for d, ph in enumerate(sched):\n", + " r = float(np.clip(0.7 * r + 0.3 * (4.8 if surge and d >= n_days - surge else PHASE_RESIST[ph]) + rng.normal(0, 0.35), 0, 5))\n", + " inten = float(np.clip(r / 5 * 0.9 + rng.normal(0, 0.06), 0, 1))\n", + " fire = int(rng.poisson(1 + 8 * inten))\n", + " move = float(rng.gamma(2, {\"prep\": 4, \"attack\": 12, \"defense\": 2, \"stability\": 5}[ph]))\n", + " weather = str(rng.choice([\"clear\", \"rain\", \"snow\"], p=[0.7, 0.2, 0.1]))\n", + " emerg = bool(rng.random() < 0.02)\n", + " rec = {\"unit\": uid, \"day\": d, \"phase\": ph, \"unit_size\": size, \"intensity\": round(inten, 3),\n", + " \"resistance\": round(r, 2), \"fire_missions\": fire, \"move_km\": round(move, 1),\n", + " \"weather\": weather, \"emergency\": emerg}\n", + " base = size / 100\n", + " for i, it in enumerate(ITEMS):\n", + " c = CSR[ph][i] * base\n", + " if it in AMMO:\n", + " c *= (0.5 + 0.6 * inten) * (1 + 0.04 * fire * (1.0 if it in (\"ammo_C\", \"ammo_D\") else 0.4))\n", + " elif it == \"fuel_jet\":\n", + " c *= (0.6 + 0.5 * inten) + 0.03 * fire\n", + " else:\n", + " c = c * (0.8 + 0.2 * (weather != \"clear\")) + 0.05 * move * base\n", + " c *= rng.lognormal(0, 0.12) * (2.5 if emerg else 1.0)\n", + " rec[it] = round(float(c), 1)\n", + " rows.append(rec)\n", + " return pd.DataFrame(rows)\n", + "# ---------------------------------------------------------------------------\n", + "\n", + "# ---------------------------------------------------------------------------\n", + "# [교육용가상자료] 가상 저장고(H1~H3) 저장칸 배치 모델. 실제 탄약 저장 기준·안전거리와 무관한 가상 계수다.\n", + "# 저장칸: 저장고 x 열(row) x 칸(col). 동선 거리 = 반출장(입구)까지의 가상 거리(m).\n", + "# 저장군(G1~G3)은 가상 혼적 제한 그룹이며, 저장고마다 받을 수 있는 저장군이 정해져 있다.\n", + "# ---------------------------------------------------------------------------\n", + "HOUSES = {\"H1\": {\"groups\": {\"G1\", \"G2\"}, \"rows\": 2, \"cols\": 6, \"dock\": 15.0},\n", + " \"H2\": {\"groups\": {\"G1\"}, \"rows\": 2, \"cols\": 5, \"dock\": 40.0},\n", + " \"H3\": {\"groups\": {\"G2\", \"G3\"}, \"rows\": 2, \"cols\": 6, \"dock\": 70.0}}\n", + "ITEM_GROUP = {\"ammo_A\": \"G1\", \"ammo_B\": \"G1\", \"ammo_C\": \"G2\", \"ammo_D\": \"G3\"}\n", + "SLOT_CAP = 6 # 1열 저장칸 가상 팔레트 수용량(2열은 +4)\n", + "\n", + "\n", + "def make_slots(seed=3):\n", + " \"\"\"저장칸 목록과 현재 점유(이동 불가 기존 재고) 여부를 만든다.\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " rows = []\n", + " for h, c in HOUSES.items():\n", + " for r in range(c[\"rows\"]):\n", + " for k in range(c[\"cols\"]):\n", + " rows.append({\"slot\": f\"{h}-{r + 1}{k + 1:02d}\", \"house\": h, \"dist_m\": c[\"dock\"] + 6.0 * k + 4.0 * r,\n", + " \"cap\": SLOT_CAP if r == 0 else SLOT_CAP + 4, \"fixed\": bool(rng.random() < 0.15)})\n", + " return pd.DataFrame(rows)\n", + "\n", + "\n", + "def make_skus(demand, seed=5):\n", + " \"\"\"품목별 예측 일일 반출량(demand: 품목->상자/일)을 로트 단위 SKU로 나눈다. 반출 빈도 = 일일 반출 횟수(가상).\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " rows = []\n", + " for it, dq in demand.items():\n", + " n_lot = 5\n", + " share = rng.dirichlet(np.ones(n_lot) * 2)\n", + " for j in range(n_lot):\n", + " rows.append({\"sku\": f\"{it}-L{j + 1}\", \"item\": it, \"group\": ITEM_GROUP[it],\n", + " \"pallets\": int(rng.integers(2, SLOT_CAP + 5)), \"freq\": round(float(dq * share[j] / 4), 2)})\n", + " return pd.DataFrame(rows)\n", + "\n", + "\n", + "def feasible(sku, slot):\n", + " # [TODO : 학생 실습 9] AAR 기반 소모량 예측치와 실사격 결과 간 편차 분석 및 소모 계수 재보정 관련 핵심 로직을 구현하세요.\n", + " # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n", + " # <<< 여기에 코드를 작성하세요 >>>\n", + " pass # 작성 후 pass를 제거하세요.\n", + "def travel(assign, skus, slots):\n", + " \"\"\"작업동선 = 반출 빈도 x 왕복 거리 합(가상 m/일).\"\"\"\n", + " # [TODO : 학생 실습 9] AAR 기반 소모량 예측치와 실사격 결과 간 편차 분석 및 소모 계수 재보정 관련 핵심 로직을 구현하세요.\n", + " # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n", + " # <<< 여기에 코드를 작성하세요 >>>\n", + " pass # 작성 후 pass를 제거하세요.\n", + "def first_fit(skus, slots):\n", + " \"\"\"현행 방식(대안 비교 기준): 입고 순서대로 비어 있는 첫 번째 적합 칸에 넣는다.\"\"\"\n", + " used, out = set(), {}\n", + " for i, k in skus.iterrows():\n", + " for _, s in slots.iterrows():\n", + " if s[\"slot\"] not in used and feasible(k, s):\n", + " out[i] = s[\"slot\"]; used.add(s[\"slot\"]); break\n", + " return out\n", + "\n", + "\n", + "def optimize(skus, slots, lam=8):\n", + " \"\"\"할당 문제(정수계획 특수형): 비용 = 반출 빈도 x 왕복거리 + lam x 빈 공간(공간낭비). 부적합 칸은 큰 비용.\"\"\"\n", + " cost = np.full((len(skus), len(slots)), 1e7)\n", + " for i, k in skus.iterrows():\n", + " for j, s in slots.iterrows():\n", + " if feasible(k, s):\n", + " cost[i, j] = 2 * k[\"freq\"] * s[\"dist_m\"] + lam * (s[\"cap\"] - k[\"pallets\"])\n", + " ri, cj = linear_sum_assignment(cost)\n", + " return {int(i): slots.loc[j, \"slot\"] for i, j in zip(ri, cj) if cost[i, j] < 1e7}, cost\n", + "\n", + "\n", + "def loc_score(assign, skus, slots):\n", + " \"\"\"위치점수(0~100): 반출 빈도 상위 SKU일수록 반출장 가까운 칸에 있는 정도(가중 평균).\"\"\"\n", + " d = slots.set_index(\"slot\")[\"dist_m\"]; dmin, dmax = d.min(), d.max()\n", + " w = skus[\"freq\"] / skus[\"freq\"].sum()\n", + " return float(100 * sum(w[i] * (1 - (d[s] - dmin) / (dmax - dmin)) for i, s in assign.items()))\n", + "# ---------------------------------------------------------------------------\n", + "\n", + "PRICE = {\"hold\": 1.0, \"emerg\": 25.0} # [교육용가상자료] 과잉 1단위 보관·수송 비용 지수, 긴급 재보급 1회 비용 지수\n", + "SHRINK_K = 20 # 재보정 축소 강도(표본 n이 작을수록 기존 계수 유지)\n", + "SPEED = 80.0 # 지게차 가상 이동 속도(m/분)\n", + "\n", + "\n", + "def long_ratio(ops):\n", + " \"\"\"(부대, 일자, 품목)별 실제 소모량과 CSR 계획량, 비율.\"\"\"\n", + " out = []\n", + " for i, it in enumerate(ITEMS):\n", + " plan = np.array([CSR[p][i] for p in ops[\"phase\"]]) * ops[\"unit_size\"] / 100\n", + " out.append(pd.DataFrame({\"unit\": ops[\"unit\"], \"day\": ops[\"day\"], \"phase\": ops[\"phase\"], \"item\": it,\n", + " \"intensity\": ops[\"intensity\"], \"plan\": plan, \"qty\": ops[it], \"emergency\": ops[\"emergency\"]}))\n", + " d = pd.concat(out, ignore_index=True)\n", + " return d.assign(ratio=d[\"qty\"] / d[\"plan\"])\n", + "\n", + "\n", + "aar = long_ratio(make_ops(90, seed=7)) # 지난 훈련(AAR 대상)\n", + "new = long_ratio(make_ops(90, seed=8)) # 다음 훈련(재보정 검증용, 같은 생성 규칙의 다른 가상 부대)\n", + "\n", + "# 1) 편차 분석: 단계 x 품목별 실제/계획 비율 중앙값(1보다 크면 계획 과소)\n", + "piv = aar[~aar[\"emergency\"]].groupby([\"phase\", \"item\"])[\"ratio\"].median().unstack()[ITEMS]\n", + "print(\"[AAR 편차: 실제/계획 비율 중앙값(긴급 불출 제외)]\")\n", + "print(piv.to_string(float_format=lambda x: f\"{x:.2f}\"))\n", + "band = pd.cut(aar[\"intensity\"], [0, 0.3, 0.6, 1.0], labels=[\"low\", \"mid\", \"high\"], include_lowest=True)\n", + "amm = aar[aar[\"item\"].isin(AMMO)].groupby(band, observed=True)[\"ratio\"].median()\n", + "print(\"탄약 비율 vs 교전 강도:\", \", \".join(f\"{k} {v:.2f}\" for k, v in amm.items()),\n", + " \"-> 원인: CSR은 단계별 고정값이라 같은 공격 단계라도 강도가 낮은 날은 과대, 높은 날은 과소 계획된다\")\n", + "em_share = aar.loc[aar[\"emergency\"], \"qty\"].sum() / aar[\"qty\"].sum()\n", + "print(f\"긴급 불출 비중 {em_share:.1%} -> 계수 재보정에서 제외하고 예비 재고로 관리\")\n", + "\n", + "# 2) 재보정: 단계 x 품목 x 계획 교전 강도 구간별 보정 계수 = 비율 중앙값^(n/(n+K)) (표본이 적은 칸은 기존 계수 유지 쪽으로 축소)\n", + "BANDS = [0, 0.3, 0.6, 1.0]\n", + "for d in (aar, new):\n", + " d[\"band\"] = pd.cut(d[\"intensity\"], BANDS, labels=[\"low\", \"mid\", \"high\"], include_lowest=True).astype(str)\n", + "base = aar[~aar[\"emergency\"]]\n", + "g = base.groupby([\"phase\", \"item\", \"band\"])[\"ratio\"]\n", + "FACT = (g.median() ** (g.size() / (g.size() + SHRINK_K))).to_dict()\n", + "print(f\"재보정 계수 {len(FACT)}개(단계 4 x 품목 6 x 강도 구간), 예: attack-ammo_A low/mid/high = \"\n", + " + \"/\".join(f\"{FACT.get(('attack', 'ammo_A', b), 1):.2f}\" for b in (\"low\", \"mid\", \"high\")))\n", + "\n", + "\n", + "def mape(y, p):\n", + " return float(np.mean(np.abs(p - y) / y))\n", + "\n", + "\n", + "def apply_csr(d):\n", + " \"\"\"재보정 계획량 = 기존 CSR 계획량 x 보정 계수(해당 칸이 없으면 1.0).\"\"\"\n", + " return d[\"plan\"] * np.array([FACT.get(k, 1.0) for k in zip(d[\"phase\"], d[\"item\"], d[\"band\"])])\n", + "\n", + "\n", + "aar[\"plan2\"], new[\"plan2\"] = apply_csr(aar), apply_csr(new)\n", + "\n", + "\n", + "m1, m2 = mape(new[\"qty\"], new[\"plan\"]), mape(new[\"qty\"], new[\"plan2\"])\n", + "print(f\"\\n[재보정 검증(다음 훈련 자료)] 일일 MAPE 기존 CSR {m1:.1%} -> 재보정 {m2:.1%} ({m1 - m2:+.1%}p 개선)\")\n", + "tot = new.groupby(\"item\")[[\"qty\", \"plan\", \"plan2\"]].sum()\n", + "tot[\"기존 오차\"], tot[\"재보정 오차\"] = tot[\"plan\"] / tot[\"qty\"] - 1, tot[\"plan2\"] / tot[\"qty\"] - 1\n", + "print(\"품목별 총량 오차:\", \", \".join(f\"{k} {a:+.1%}->{b:+.1%}\" for k, a, b in zip(tot.index, tot[\"기존 오차\"], tot[\"재보정 오차\"])))\n", + "\n", + "\n", + "# 3) 3대 효과 지표(가상 단가, 90일 x 부대 8 기준). 두 계획 모두 AAR 자료에서 95% 충족 안전계수를 정해 같은 서비스 수준으로 비교한다\n", + "SAFE = {c: float(np.quantile(aar.loc[~aar[\"emergency\"], \"qty\"] / aar.loc[~aar[\"emergency\"], c], 0.95)) for c in (\"plan\", \"plan2\")}\n", + "print(f\"95% 충족 안전계수: 기존 x{SAFE['plan']:.2f}, 재보정 x{SAFE['plan2']:.2f} -> 같은 충족 수준에서 공급 계획량을 비교한다\")\n", + "\n", + "\n", + "def effect(plan_col, p_hold=PRICE[\"hold\"], p_em=PRICE[\"emerg\"]):\n", + " supply = new[plan_col] * SAFE[plan_col]\n", + " excess = (supply - new[\"qty\"]).clip(lower=0).sum()\n", + " short = int((new[\"qty\"] > supply).sum()) # 공급 계획 초과 소모 -> 긴급 재보급 1회\n", + " return excess * p_hold + short * p_em, short\n", + "\n", + "\n", + "c1, s1 = effect(\"plan\"); c2, s2 = effect(\"plan2\")\n", + "ops = make_ops(90, seed=7)\n", + "demand = ops[ops[\"day\"] >= 76][AMMO].sum().div(14).to_dict()\n", + "skus, slots = make_skus(demand), make_slots()\n", + "t_cur, t_opt = travel(first_fit(skus, slots), skus, slots), travel(optimize(skus, slots)[0], skus, slots)\n", + "kpis = pd.DataFrame([[\"비용(과잉 보관·수송+긴급 재보급) 지수\", c1, c2, 1 - c2 / c1],\n", + " [\"저장고 반출 작업시간(분/일)\", t_cur / SPEED, t_opt / SPEED, 1 - t_opt / t_cur],\n", + " [\"긴급 재보급 횟수(회/90일)\", s1, s2, 1 - s2 / max(s1, 1)]], columns=[\"지표\", \"현행\", \"개선\", \"개선율\"])\n", + "print(\"\\n[3대 정량 효과 지표(가상 단가 기준 상대 비교값)]\")\n", + "print(kpis.to_string(index=False, float_format=lambda x: f\"{x:.2f}\"))\n", + "print(f\"효과 지표 산출 {len(kpis)}종 (목표 3대 지표: {'달성' if len(kpis) >= 3 else '미달'}) - 긴급 재보급 횟수는 같은 충족 수준에서 비슷하게 유지되는지(차이 {s2 - s1:+d}회) 확인하는 지표다\")\n", + "\n", + "sens = []\n", + "for ph in (0.8, 1.0, 1.2):\n", + " for pe in (15.0, 25.0, 40.0):\n", + " a, _ = effect(\"plan\", PRICE[\"hold\"] * ph, pe); b, _ = effect(\"plan2\", PRICE[\"hold\"] * ph, pe)\n", + " sens.append((ph, pe, 1 - b / a))\n", + "s = pd.DataFrame(sens, columns=[\"보관단가 배수\", \"긴급 1회 비용\", \"비용 절감률\"]).pivot(index=\"보관단가 배수\", columns=\"긴급 1회 비용\", values=\"비용 절감률\")\n", + "print(\"\\n[민감도: 비용 절감률]\")\n", + "print(s.to_string(float_format=lambda x: f\"{x:.1%}\"))\n", + "print(\"도입 로드맵: 단기(1~3개월) 훈련 AAR로 계수 재보정 / 중기(4~9개월) 예측 모델·저장 배치 최적화 시범 운용 / 장기(10개월~) 군수 정보체계 연계와 정기 재학습\")\n", + "print(\"금액·시간은 가정 단가로 계산한 상대 비교값이며 예산 근거로 쓰지 않는다.\")\n", + "\n", + "fig, ax = plt.subplots(1, 2, figsize=(12, 4))\n", + "im = ax[0].imshow(piv.values, cmap=\"coolwarm\", vmin=0.5, vmax=1.5); ax[0].set_xticks(range(6)); ax[0].set_xticklabels(ITEMS, rotation=30)\n", + "ax[0].set_yticks(range(4)); ax[0].set_yticklabels(piv.index); ax[0].set_title(\"AAR actual/plan ratio (median)\"); plt.colorbar(im, ax=ax[0])\n", + "x = np.arange(len(tot)); ax[1].bar(x - 0.2, tot[\"기존 오차\"], 0.4, label=\"old CSR\"); ax[1].bar(x + 0.2, tot[\"재보정 오차\"], 0.4, label=\"recalibrated\")\n", + "ax[1].set_xticks(x); ax[1].set_xticklabels(tot.index, rotation=30); ax[1].axhline(0, c=\"k\", lw=0.8); ax[1].set_title(\"Total plan error on next exercise\"); ax[1].legend()\n", + "plt.tight_layout(); plt.show()\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 09 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + "[AAR 편차: 실제/계획 비율 중앙값(긴급 불출 제외)]\n", + "item ammo_A ammo_B ammo_C ammo_D fuel_jet fuel_diesel\n", + "phase \n", + "attack 0.90 0.89 0.99 1.02 1.04 0.94\n", + "defense 0.96 0.99 1.05 1.09 1.10 0.85\n", + "prep 0.61 0.64 0.62 0.67 0.76 0.90\n", + "stability 0.62 0.63 0.67 0.64 0.76 0.94\n", + "탄약 비율 vs 교전 강도: low 0.62, mid 0.90, high 1.06 -> 원인: CSR은 단계별 고정값이라 같은 공격 단계라도 강도가 낮은 날은 과대, 높은 날은 과소 계획된다\n", + "긴급 불출 비중 6.0% -> 계수 재보정에서 제외하고 예비 재고로 관리\n", + "재보정 계수 60개(단계 4 x 품목 6 x 강도 구간), 예: attack-ammo_A low/mid/high = 0.89/0.87/1.02\n", + "\n", + "[재보정 검증(다음 훈련 자료)] 일일 MAPE 기존 CSR 34.0% -> 재보정 14.5% (+19.5%p 개선)\n", + "품목별 총량 오차: ammo_A +12.3%->+0.2%, ammo_B +11.6%->+0.2%, ammo_C -0.4%->-4.4%, ammo_D +0.9%->-3.4%, fuel_diesel +2.1%->-4.0%, fuel_jet +0.1%->-2.7%\n", + "95% 충족 안전계수: 기존 x1.23, 재보정 x1.26 -> 같은 충족 수준에서 공급 계획량을 비교한다\n", + "\n", + "[3대 정량 효과 지표(가상 단가 기준 상대 비교값)]\n", + " 지표 현행 개선 개선율\n", + "비용(과잉 보관·수송+긴급 재보급) 지수 21556.08 19063.11 0.12\n", + " 저장고 반출 작업시간(분/일) 18.88 14.97 0.21\n", + " 긴급 재보급 횟수(회/90일) 283.00 284.00 -0.00\n", + "효과 지표 산출 3종 (목표 3대 지표: 달성) - 긴급 재보급 횟수는 같은 충족 수준에서 비슷하게 유지되는지(차이 +1회) 확인하는 지표다\n", + "\n", + "[민감도: 비용 절감률]\n", + "긴급 1회 비용 15.0 25.0 40.0\n", + "보관단가 배수 \n", + "0.8 12.6% 10.7% 8.6%\n", + "1.0 13.4% 11.6% 9.6%\n", + "1.2 13.9% 12.3% 10.4%\n", + "도입 로드맵: 단기(1~3개월) 훈련 AAR로 계수 재보정 / 중기(4~9개월) 예측 모델·저장 배치 최적화 시범 운용 / 장기(10개월~) 군수 정보체계 연계와 정기 재학습\n", + "금액·시간은 가정 단가로 계산한 상대 비교값이며 예산 근거로 쓰지 않는다.\n", + "```\n", + "
\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 10. 탄약/유류 소모량 예측 및 전술 재보급 계획 수립 시뮬레이터 통합 완성\n", + "**소요시간**: 약 3.5시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "탄약/유류 소모량 예측 및 전술 재보급 계획 수립 시뮬레이터를 완성한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **소모량 예측부터 재보급 계획, 저장 배치, 결재 대기열, 재학습까지 한 번에 실행하는 통합 시뮬레이터를 완성한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **자료 생성·정합성 점검, 운영 모델과 재학습 모델 비교 후 교체 결정, DOS 기반 재보급 계획, 저장 배치 최적화, 결재 대기열 등록을 한 흐름으로 실행 수행하고 검증하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `docs/amm02_operations_guide.md 및 pipeline/amm02_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": [ + "\"\"\"데이터 생성·정합성 점검, 소모량 예측 모델 학습과 자동 재학습(기존·신규 모델 비교 후 교체), DOS 기반 전술 재보급 계획, 가상 저장고 배치 최적화, 군수참모 결재 대기열을 한 번에 실행하는 통합 시뮬레이터를 완성하고, 자체 점검표·운영 가이드 목차·체계 연계 메시지 예시·브리핑 요약을 출력한다.\"\"\"\n", + "import json\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, RandomForestRegressor\n", + "from scipy.optimize import linear_sum_assignment\n", + "\n", + "# ---------------------------------------------------------------------------\n", + "# [교육용가상자료] 가상 부대·가상 품목 일일 소모 기록 생성기 (실제 탄약 제원·소모율과 무관)\n", + "# 가상 부대 8개 x 90일. 소모량 = 가상 CSR 계수 x 병력 규모 x (교전 강도·적 저항·화력 계획 효과) x 잡음\n", + "# ---------------------------------------------------------------------------\n", + "UNITS = [f\"U{i:02d}\" for i in range(1, 9)]\n", + "ITEMS = [\"ammo_A\", \"ammo_B\", \"ammo_C\", \"ammo_D\", \"fuel_jet\", \"fuel_diesel\"]\n", + "AMMO, FUEL = ITEMS[:4], ITEMS[4:]\n", + "PHASES = [\"prep\", \"attack\", \"defense\", \"stability\"]\n", + "CSR = { # 가상 계수: 병력 100명·1일 기준 소모 단위(탄약=상자, 유류=드럼)\n", + " \"prep\": [1.0, 0.6, 0.3, 0.2, 1.5, 6.0],\n", + " \"attack\": [6.0, 3.5, 2.0, 1.2, 5.0, 12.0],\n", + " \"defense\": [4.5, 3.0, 1.6, 0.8, 3.0, 7.0],\n", + " \"stability\": [0.8, 0.5, 0.3, 0.2, 1.0, 5.0],\n", + "}\n", + "PHASE_RESIST = {\"prep\": 1.0, \"attack\": 3.2, \"defense\": 3.6, \"stability\": 0.8}\n", + "\n", + "\n", + "def make_ops(n_days=90, seed=7, surge=0):\n", + " \"\"\"가상 부대별 일일 작전 기록과 품목별 실제 소모량(정답)을 만든다. surge>0이면 마지막 surge일을 고강도 공격 단계로 바꾼다(스트레스 시험용).\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " rows = []\n", + " for uid in UNITS:\n", + " size = int(rng.integers(3, 9)) * 100\n", + " lens = rng.integers(8, 20, 4)\n", + " sched = [\"prep\"] * lens[0] + [\"attack\"] * lens[1] + [\"defense\"] * lens[2] + [\"attack\"] * lens[3]\n", + " sched = (sched + [\"stability\"] * n_days)[:n_days]\n", + " if surge:\n", + " sched[-surge:] = [\"attack\"] * surge\n", + " r = 1.0\n", + " for d, ph in enumerate(sched):\n", + " r = float(np.clip(0.7 * r + 0.3 * (4.8 if surge and d >= n_days - surge else PHASE_RESIST[ph]) + rng.normal(0, 0.35), 0, 5))\n", + " inten = float(np.clip(r / 5 * 0.9 + rng.normal(0, 0.06), 0, 1))\n", + " fire = int(rng.poisson(1 + 8 * inten))\n", + " move = float(rng.gamma(2, {\"prep\": 4, \"attack\": 12, \"defense\": 2, \"stability\": 5}[ph]))\n", + " weather = str(rng.choice([\"clear\", \"rain\", \"snow\"], p=[0.7, 0.2, 0.1]))\n", + " emerg = bool(rng.random() < 0.02)\n", + " rec = {\"unit\": uid, \"day\": d, \"phase\": ph, \"unit_size\": size, \"intensity\": round(inten, 3),\n", + " \"resistance\": round(r, 2), \"fire_missions\": fire, \"move_km\": round(move, 1),\n", + " \"weather\": weather, \"emergency\": emerg}\n", + " base = size / 100\n", + " for i, it in enumerate(ITEMS):\n", + " c = CSR[ph][i] * base\n", + " if it in AMMO:\n", + " c *= (0.5 + 0.6 * inten) * (1 + 0.04 * fire * (1.0 if it in (\"ammo_C\", \"ammo_D\") else 0.4))\n", + " elif it == \"fuel_jet\":\n", + " c *= (0.6 + 0.5 * inten) + 0.03 * fire\n", + " else:\n", + " c = c * (0.8 + 0.2 * (weather != \"clear\")) + 0.05 * move * base\n", + " c *= rng.lognormal(0, 0.12) * (2.5 if emerg else 1.0)\n", + " rec[it] = round(float(c), 1)\n", + " rows.append(rec)\n", + " return pd.DataFrame(rows)\n", + "# ---------------------------------------------------------------------------\n", + "\n", + "# ---- 소모량 예측용 파생 피처: 전일까지의 실적 + 당일 작전·화력 계획(사전에 알 수 있는 값만) ----\n", + "FEATS = [\"lag1\", \"lag2\", \"roll3\", \"roll7\", \"csr_base\", \"size100\", \"fire_plan\", \"fire_x_engaged\", \"resist_lag1\",\n", + " \"resist_trend\", \"inten_lag1\", \"move_plan\", \"days_in_phase\", \"engaged\", \"ph_attack\", \"ph_defense\", \"ph_prep\",\n", + " \"item_idx\", \"is_fuel\"]\n", + "DERIVED = [\"lag1\", \"lag2\", \"roll3\", \"roll7\", \"csr_base\", \"fire_x_engaged\", \"resist_lag1\", \"resist_trend\",\n", + " \"inten_lag1\", \"days_in_phase\", \"engaged\", \"ph_attack\", \"ph_defense\", \"ph_prep\"]\n", + "\n", + "\n", + "def build_long(ops):\n", + " \"\"\"부대x일자 기록을 (부대, 일자, 품목) 행으로 펼치고 파생 피처를 만든다.\n", + " # [TODO : 학생 실습 10] 탄약/유류 소모량 예측 및 전술 재보급 계획 수립 시뮬레이터 통합 완성 관련 핵심 로직을 구현하세요.\n", + " # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n", + " # <<< 여기에 코드를 작성하세요 >>>\n", + " pass # 작성 후 pass를 제거하세요.\n", + "def to_qty(d, y_pred):\n", + " \"\"\"예측 보정 계수(log)를 소모량으로 되돌린다: 기준량 x exp(예측).\"\"\"\n", + " # [TODO : 학생 실습 10] 탄약/유류 소모량 예측 및 전술 재보급 계획 수립 시뮬레이터 통합 완성 관련 핵심 로직을 구현하세요.\n", + " # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n", + " # <<< 여기에 코드를 작성하세요 >>>\n", + " pass # 작성 후 pass를 제거하세요.\n", + "def mape(y, p):\n", + " return float(np.mean(np.abs(np.asarray(p) - np.asarray(y)) / np.asarray(y)))\n", + "\n", + "# ---- 소모량 예측 모델(앙상블) 학습·예측 ----\n", + "def fit_models(tr, seed=0):\n", + " \"\"\"GBR + 랜덤포레스트 두 모델을 학습한다(목표값은 CSR 기준량 대비 log 보정 계수).\"\"\"\n", + " g = GradientBoostingRegressor(n_estimators=150, max_depth=3, learning_rate=0.08, subsample=0.8, random_state=seed).fit(tr[FEATS], tr[\"y\"])\n", + " r = RandomForestRegressor(n_estimators=120, min_samples_leaf=3, random_state=seed, n_jobs=1).fit(tr[FEATS], tr[\"y\"])\n", + " return g, r\n", + "\n", + "\n", + "def predict_qty(models, d, w=0.5):\n", + " \"\"\"두 모델의 log 예측을 가중 평균해 소모량으로 되돌린다.\"\"\"\n", + " g, r = models\n", + " return to_qty(d, w * g.predict(d[FEATS]) + (1 - w) * r.predict(d[FEATS]))\n", + "\n", + "# ---------------------------------------------------------------------------\n", + "# [교육용가상자료] 가상 저장고(H1~H3) 저장칸 배치 모델. 실제 탄약 저장 기준·안전거리와 무관한 가상 계수다.\n", + "# 저장칸: 저장고 x 열(row) x 칸(col). 동선 거리 = 반출장(입구)까지의 가상 거리(m).\n", + "# 저장군(G1~G3)은 가상 혼적 제한 그룹이며, 저장고마다 받을 수 있는 저장군이 정해져 있다.\n", + "# ---------------------------------------------------------------------------\n", + "HOUSES = {\"H1\": {\"groups\": {\"G1\", \"G2\"}, \"rows\": 2, \"cols\": 6, \"dock\": 15.0},\n", + " \"H2\": {\"groups\": {\"G1\"}, \"rows\": 2, \"cols\": 5, \"dock\": 40.0},\n", + " \"H3\": {\"groups\": {\"G2\", \"G3\"}, \"rows\": 2, \"cols\": 6, \"dock\": 70.0}}\n", + "ITEM_GROUP = {\"ammo_A\": \"G1\", \"ammo_B\": \"G1\", \"ammo_C\": \"G2\", \"ammo_D\": \"G3\"}\n", + "SLOT_CAP = 6 # 1열 저장칸 가상 팔레트 수용량(2열은 +4)\n", + "\n", + "\n", + "def make_slots(seed=3):\n", + " \"\"\"저장칸 목록과 현재 점유(이동 불가 기존 재고) 여부를 만든다.\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " rows = []\n", + " for h, c in HOUSES.items():\n", + " for r in range(c[\"rows\"]):\n", + " for k in range(c[\"cols\"]):\n", + " rows.append({\"slot\": f\"{h}-{r + 1}{k + 1:02d}\", \"house\": h, \"dist_m\": c[\"dock\"] + 6.0 * k + 4.0 * r,\n", + " \"cap\": SLOT_CAP if r == 0 else SLOT_CAP + 4, \"fixed\": bool(rng.random() < 0.15)})\n", + " return pd.DataFrame(rows)\n", + "\n", + "\n", + "def make_skus(demand, seed=5):\n", + " \"\"\"품목별 예측 일일 반출량(demand: 품목->상자/일)을 로트 단위 SKU로 나눈다. 반출 빈도 = 일일 반출 횟수(가상).\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " rows = []\n", + " for it, dq in demand.items():\n", + " n_lot = 5\n", + " share = rng.dirichlet(np.ones(n_lot) * 2)\n", + " for j in range(n_lot):\n", + " rows.append({\"sku\": f\"{it}-L{j + 1}\", \"item\": it, \"group\": ITEM_GROUP[it],\n", + " \"pallets\": int(rng.integers(2, SLOT_CAP + 5)), \"freq\": round(float(dq * share[j] / 4), 2)})\n", + " return pd.DataFrame(rows)\n", + "\n", + "\n", + "def feasible(sku, slot):\n", + " return (sku[\"group\"] in HOUSES[slot[\"house\"]][\"groups\"]) and sku[\"pallets\"] <= slot[\"cap\"] and not slot[\"fixed\"]\n", + "\n", + "\n", + "def travel(assign, skus, slots):\n", + " \"\"\"작업동선 = 반출 빈도 x 왕복 거리 합(가상 m/일).\"\"\"\n", + " d = slots.set_index(\"slot\")[\"dist_m\"]\n", + " return float(sum(2 * skus.loc[i, \"freq\"] * d[s] for i, s in assign.items()))\n", + "\n", + "\n", + "def first_fit(skus, slots):\n", + " \"\"\"현행 방식(대안 비교 기준): 입고 순서대로 비어 있는 첫 번째 적합 칸에 넣는다.\"\"\"\n", + " used, out = set(), {}\n", + " for i, k in skus.iterrows():\n", + " for _, s in slots.iterrows():\n", + " if s[\"slot\"] not in used and feasible(k, s):\n", + " out[i] = s[\"slot\"]; used.add(s[\"slot\"]); break\n", + " return out\n", + "\n", + "\n", + "def optimize(skus, slots, lam=8):\n", + " \"\"\"할당 문제(정수계획 특수형): 비용 = 반출 빈도 x 왕복거리 + lam x 빈 공간(공간낭비). 부적합 칸은 큰 비용.\"\"\"\n", + " cost = np.full((len(skus), len(slots)), 1e7)\n", + " for i, k in skus.iterrows():\n", + " for j, s in slots.iterrows():\n", + " if feasible(k, s):\n", + " cost[i, j] = 2 * k[\"freq\"] * s[\"dist_m\"] + lam * (s[\"cap\"] - k[\"pallets\"])\n", + " ri, cj = linear_sum_assignment(cost)\n", + " return {int(i): slots.loc[j, \"slot\"] for i, j in zip(ri, cj) if cost[i, j] < 1e7}, cost\n", + "\n", + "\n", + "def loc_score(assign, skus, slots):\n", + " \"\"\"위치점수(0~100): 반출 빈도 상위 SKU일수록 반출장 가까운 칸에 있는 정도(가중 평균).\"\"\"\n", + " d = slots.set_index(\"slot\")[\"dist_m\"]; dmin, dmax = d.min(), d.max()\n", + " w = skus[\"freq\"] / skus[\"freq\"].sum()\n", + " return float(100 * sum(w[i] * (1 - (d[s] - dmin) / (dmax - dmin)) for i, s in assign.items()))\n", + "# ---------------------------------------------------------------------------\n", + "\n", + "DOS_ALERT, DOS_TARGET, PLAN_DAY = 2.0, 3.0, 85\n", + "t_all = time.perf_counter()\n", + "checks = []\n", + "\n", + "# 1) 데이터 수집·정합성 점검\n", + "ops = make_ops(90, seed=7)\n", + "ok_schema = ops[\"intensity\"].between(0, 1).all() and (ops[ITEMS] >= 0).all().all() and not ops.duplicated([\"unit\", \"day\"]).any()\n", + "checks.append((\"원천 자료 정합성(범위·음수·중복)\", ok_schema))\n", + "data = build_long(ops)\n", + "print(f\"[1] 가상 작전 기록 {len(ops)}건 -> 학습 행 {len(data)}개, 정합성 {'통과' if ok_schema else '실패'}\")\n", + "\n", + "# 2) 운영 모델(v1: 60일 이전 학습) -> 3) 재학습 파이프라인(v2: 80일 이전 학습), 최근 10일로 비교 후 교체 결정\n", + "val = data[data[\"day\"] >= 80]\n", + "v1 = fit_models(data[data[\"day\"] < 60])\n", + "v2 = fit_models(data[data[\"day\"] < 80])\n", + "e1, e2 = mape(val[\"qty\"], predict_qty(v1, val)), mape(val[\"qty\"], predict_qty(v2, val))\n", + "base = mape(val[\"qty\"], val[\"csr_base\"] * val[\"size100\"])\n", + "champ = v2 if e2 <= e1 else v1\n", + "print(f\"[2] 검증(80~89일) MAPE: CSR Baseline {base:.1%}, 운영 모델 v1 {e1:.1%}, 재학습 v2 {e2:.1%} -> {'v2로 교체' if e2 <= e1 else 'v1 유지'}\")\n", + "checks += [(\"자동 재학습 파이프라인 실행·비교\", True), (\"운영 모델 MAPE 25% 이하\", min(e1, e2) <= 0.25)]\n", + "\n", + "# 4) 전술 재보급 계획(85일 기준)\n", + "rng = np.random.default_rng(11)\n", + "day = data[data[\"day\"] == PLAN_DAY].copy()\n", + "day[\"pred\"] = predict_qty(champ, day)\n", + "day[\"on_hand\"] = np.round(day[\"csr_base\"] * day[\"size100\"] * rng.uniform(1.0, 5.0, len(day)))\n", + "day[\"dos\"] = day[\"on_hand\"] / day[\"pred\"]\n", + "day[\"need\"] = np.maximum(0, np.ceil(DOS_TARGET * day[\"pred\"] - day[\"on_hand\"]))\n", + "urg = day[day[\"dos\"] < DOS_ALERT].sort_values(\"dos\")\n", + "print(f\"[3] {PLAN_DAY}일 재보급 계획: 긴급 {len(urg)}건, 재보급 총량 탄약 {day.loc[day['is_fuel'] == 0, 'need'].sum():.0f}상자 / 유류 {day.loc[day['is_fuel'] == 1, 'need'].sum():.0f}드럼(가상)\")\n", + "for _, r in urg.head(4).iterrows():\n", + " print(f\" {r['unit']} {r['item']}: DOS {r['dos']:.1f}일, 재보급 {r['need']:.0f}\")\n", + "\n", + "# 5) 저장 배치 최적화(예측 탄약 반출 수요 기준)\n", + "demand = day[day[\"is_fuel\"] == 0].groupby(\"item\")[\"pred\"].sum().round(1).to_dict()\n", + "skus, slots = make_skus(demand), make_slots()\n", + "cur, (opt, _) = first_fit(skus, slots), optimize(skus, slots)\n", + "tc, to = travel(cur, skus, slots), travel(opt, skus, slots)\n", + "print(f\"[4] 저장 배치: 작업동선 {tc:.0f} -> {to:.0f} m/일 ({1 - to / tc:.1%} 단축), 위치점수 {loc_score(cur, skus, slots):.1f} -> {loc_score(opt, skus, slots):.1f}\")\n", + "checks.append((\"저장 배치 작업동선 15% 이상 단축\", 1 - to / tc >= 0.15))\n", + "\n", + "# 6) 결재 대기열(자동 확정 금지)\n", + "queue = [{\"id\": f\"Q{k + 1:02d}\", \"type\": \"긴급 재보급\", \"unit\": r[\"unit\"], \"item\": r[\"item\"], \"qty\": int(r[\"need\"]), \"status\": \"결재대기\"} for k, (_, r) in enumerate(urg.iterrows())]\n", + "queue += [{\"id\": f\"Q{len(queue) + 1:02d}\", \"type\": \"저장 재배치\", \"moves\": int(sum(cur.get(i) != opt[i] for i in opt)), \"status\": \"결재대기\"}]\n", + "auto = [q for q in queue if q[\"status\"] != \"결재대기\"]\n", + "print(f\"[5] 군수참모 결재 대기 {len(queue)}건, 자동 확정 {len(auto)}건\")\n", + "checks.append((\"자동 확정 0건(HITL)\", not auto))\n", + "\n", + "# 7) 운영 가이드 목차·연계 인터페이스\n", + "GUIDE = {\"1. 목적·범위\": \"가상 부대 탄약·유류 소모 예측과 저장 배치 지원\",\n", + " \"2. 데이터 규격\": f\"작전 기록 {ops.shape[1]}개 필드, 품목 {len(ITEMS)}종\",\n", + " \"3. 전처리 규칙\": \"범위·음수·중복 점검, 긴급 불출 보존\",\n", + " \"4. 예측 모델\": f\"GBR+RF 앙상블, 검증 MAPE {min(e1, e2):.1%}\",\n", + " \"5. DOS 재보급 엔진\": f\"경보 {DOS_ALERT:.0f}일 미만, 목표 {DOS_TARGET:.0f}일\",\n", + " \"6. 저장 배치\": f\"할당 최적화, 동선 {1 - to / tc:.0%} 단축\",\n", + " \"7. 결재선·감사\": \"자동 확정 금지, 사유 기록\",\n", + " \"8. 재학습\": \"최근 자료로 재학습 후 검증 MAPE가 낮을 때만 교체\",\n", + " \"9. 예외 대응\": \"보고 누락 시 CSR 기준값 대입, 긴급 요청은 즉시 경보\",\n", + " \"10. 연계 인터페이스\": \"RESUPPLY_REQ 가상 메시지 규격\"}\n", + "done = np.mean([bool(v) for v in GUIDE.values()])\n", + "print(\"[6] 운영 가이드 목차:\", \" / \".join(GUIDE))\n", + "checks.append((\"운영 가이드 10개 장 완비\", done == 1))\n", + "msg = {\"msg_type\": \"RESUPPLY_REQ\", \"version\": \"1.0\", \"unit_id\": queue[0].get(\"unit\", \"U00\") if queue else \"U00\",\n", + " \"item_code\": queue[0].get(\"item\", \"-\") if queue else \"-\", \"qty\": queue[0].get(\"qty\", 0) if queue else 0,\n", + " \"dos_days\": round(float(urg[\"dos\"].iloc[0]), 2) if len(urg) else None, \"model_ver\": \"v2\" if champ is v2 else \"v1\",\n", + " \"approval\": \"PENDING\", \"source\": \"교육용가상자료\"}\n", + "print(\"[7] 체계 연계 메시지 예시(가상 규격):\", json.dumps(msg, ensure_ascii=False))\n", + "elapsed = time.perf_counter() - t_all\n", + "checks.append((\"통합 실행 60초 이내\", elapsed < 60))\n", + "\n", + "print(\"\\n[자체 점검표]\")\n", + "for name, ok in checks:\n", + " print(f\" [{'PASS' if ok else 'FAIL'}] {name}\")\n", + "print(f\"점검 {sum(ok for _, ok in checks)}/{len(checks)} 통과, 운영 가이드 완비율 {done:.0%}, 전체 실행 {elapsed:.1f}s\")\n", + "\n", + "print(\"\\n[브리핑 요약]\")\n", + "print(f\"- 소모량 예측 MAPE {min(e1, e2):.1%}로 CSR 고정 계수({base:.1%}) 대비 오차를 줄였고, 재학습 모델은 비교 후 교체한다.\")\n", + "print(f\"- {PLAN_DAY}일 기준 긴급 재보급 {len(urg)}건을 DOS 순으로 정렬해 결재 대기열에 올렸다.\")\n", + "print(f\"- 가상 저장고 배치 최적화로 반출 작업동선을 {1 - to / tc:.0%} 줄이는 안을 제시했다(재배치는 결재 후 시행).\")\n", + "print(\"- 모든 불출·대체 탄종·재배치는 군수참모 결재 후 집행하며, 수치는 교육용 가상 자료 기준이다.\")\n", + "\n", + "fig, ax = plt.subplots(1, 2, figsize=(12, 4))\n", + "ax[0].bar([\"CSR baseline\", \"model v1\", \"model v2\"], [base, e1, e2], color=[\"gray\", \"tab:blue\", \"tab:green\"])\n", + "ax[0].axhline(0.25, c=\"r\", ls=\"--\"); ax[0].set_title(\"Validation MAPE (day 80-89)\")\n", + "ax[1].barh(day[\"unit\"] + \"-\" + day[\"item\"], day[\"dos\"].clip(upper=6), color=np.where(day[\"dos\"] < DOS_ALERT, \"tab:red\", \"tab:gray\"))\n", + "ax[1].axvline(DOS_ALERT, c=\"r\", ls=\"--\"); ax[1].set_title(f\"Days of supply on day {PLAN_DAY}\"); ax[1].tick_params(axis=\"y\", labelsize=5)\n", + "plt.tight_layout(); plt.show()\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 10 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + "[1] 가상 작전 기록 720건 -> 학습 행 4176개, 정합성 통과\n", + "[2] 검증(80~89일) MAPE: CSR Baseline 46.5%, 운영 모델 v1 13.3%, 재학습 v2 14.7% -> v1 유지\n", + "[3] 85일 재보급 계획: 긴급 6건, 재보급 총량 탄약 23상자 / 유류 221드럼(가상)\n", + " U08 fuel_diesel: DOS 1.1일, 재보급 61\n", + " U01 fuel_diesel: DOS 1.2일, 재보급 72\n", + " U04 ammo_A: DOS 1.6일, 재보급 4\n", + " U06 ammo_D: DOS 1.6일, 재보급 2\n", + "[4] 저장 배치: 작업동선 1588 -> 1260 m/일 (20.6% 단축), 위치점수 60.4 -> 72.0\n", + "[5] 군수참모 결재 대기 7건, 자동 확정 0건\n", + "[6] 운영 가이드 목차: 1. 목적·범위 / 2. 데이터 규격 / 3. 전처리 규칙 / 4. 예측 모델 / 5. DOS 재보급 엔진 / 6. 저장 배치 / 7. 결재선·감사 / 8. 재학습 / 9. 예외 대응 / 10. 연계 인터페이스\n", + "[7] 체계 연계 메시지 예시(가상 규격): {\"msg_type\": \"RESUPPLY_REQ\", \"version\": \"1.0\", \"unit_id\": \"U08\", \"item_code\": \"fuel_diesel\", \"qty\": 61, \"dos_days\": 1.14, \"model_ver\": \"v1\", \"approval\": \"PENDING\", \"source\": \"교육용가상자료\"}\n", + "\n", + "[자체 점검표]\n", + " [PASS] 원천 자료 정합성(범위·음수·중복)\n", + " [PASS] 자동 재학습 파이프라인 실행·비교\n", + " [PASS] 운영 모델 MAPE 25% 이하\n", + " [PASS] 저장 배치 작업동선 15% 이상 단축\n", + " [PASS] 자동 확정 0건(HITL)\n", + " [PASS] 운영 가이드 10개 장 완비\n", + " [PASS] 통합 실행 60초 이내\n", + "점검 7/7 통과, 운영 가이드 완비율 100%, 전체 실행 7.1s\n", + "\n", + "[브리핑 요약]\n", + "- 소모량 예측 MAPE 13.3%로 CSR 고정 계수(46.5%) 대비 오차를 줄였고, 재학습 모델은 비교 후 교체한다.\n", + "- 85일 기준 긴급 재보급 6건을 DOS 순으로 정렬해 결재 대기열에 올렸다.\n", + "- 가상 저장고 배치 최적화로 반출 작업동선을 21% 줄이는 안을 제시했다(재배치는 결재 후 시행).\n", + "- 모든 불출·대체 탄종·재배치는 군수참모 결재 후 집행하며, 수치는 교육용 가상 자료 기준이다.\n", + "```\n", + "
\n" + ] + } + ], + "metadata": { + "colab": { + "provenance": [] + }, + "kernelspec": { + "display_name": "Python 3", + "name": "python3" + }, + "language_info": { + "name": "python" + } + }, + "nbformat": 4, + "nbformat_minor": 0 +} \ No newline at end of file diff --git a/uploads/C202/C202_solution.ipynb b/uploads/C202/C202_solution.ipynb new file mode 100644 index 0000000..3f24084 --- /dev/null +++ b/uploads/C202/C202_solution.ipynb @@ -0,0 +1,3186 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# 🎯 [C202] 대역폭 제한 시 메시지 우선순위 큐·재전송 프로토타입\n", + "### Google Colab 실습 환경 (지휘통제(C2) / Level 2)\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/C202/C202_solution.ipynb)\n", + "\n", + "---\n", + "\n", + "## 📌 1. 프로젝트 개요 및 배경\n", + "\n", + "| 항목 | 내용 |\n", + "| :--- | :--- |\n", + "| **과정 분류** | 지휘통제(C2) (Level 2) |\n", + "| **학습 소요 시간** | 10개 미션 (5일 / 총 27.5시간) |\n", + "| **환경 사양** | Python 3.10+, numpy, pandas, matplotlib, scipy, scikit-learn |\n", + "\n", + "### 📖 프로젝트 목표\n", + "러시아-우크라이나 전쟁에서는 전자전 재밍과 통신 장비 부족으로 협대역 무선망의 대역폭이 크게 줄어드는 상황이 잦았고, 이때 적 접촉 보고나 의무후송 요청 같은 긴급 전문이 행정 전문 뒤에 묶여 늦게 전달되는 문제가 지휘통제의 약점이 되었다. 본 프로젝트에서는 가상 협대역 링크(2.4kbps)와 가상 전술 전문을 코드로 직접 만들어, 전문 구조 정의, 패킷 파싱·압축 전처리, 긴급도 기반 우선순위 큐, 채널 상태에 따른 지능형 재전송, 링크 신뢰도 산출, 통신 관제 화면, 통신관 HITL 통제, 대역폭 급감 스트레스 시험, AAR 재조정, 최종 통합까지 메시지 우선순위 큐·재전송 프로토타입을 단계별로 구현한다. 최종 목표는 대역폭이 제한된 상황에서도 FLASH·IMMEDIATE 전문을 기한 안에 전달하고, 어떤 전문도 잃어버리지 않으며, 순서 조정의 최종 판단은 통신관이 내리도록 설계하는 것이다.\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. 협대역 전술 무선망(VHF/UHF) 대역폭 제약 조건 분석 및 전술 메시지 구조 정의\n", + "**소요시간**: 약 1.5시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "협대역 전술 무전망(VHF/UHF)의 전송 대역폭 제약과 전술 전문(Message) 구조를 분석한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **협대역 전술 무선망의 대역폭 제약이 전문 전송시간에 주는 영향을 분석하고, 전문 헤더·긴급도·링크 조건·전술 파라미터를 담은 데이터 스키마와 검증기를 만든다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **전문 유형(접촉 보고, 의무후송 요청, 상황 보고, 위치 보고, 보급 요청, 행정)별 평균 크기를 1.2~16kbps 링크에서 보낼 때 걸리는 시간을 표로 정리하고 규격화하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `data/schema/c202_input_schema.json 및 docs/c202_data_spec.md` 형태의 결과물을 완성한다.\n", + "\n", + "### 🔍 평가 및 검증 기준 (PASS 조건)\n", + "#### 미션 판정\n", + "\n", + "**평가 구분:** 필수\n", + "\n", + "**핵심 평가영역:** 지휘통제(C2) 실무 및 러시아-우크라이나 전쟁 전장 환경과의 군사적 부합성\n", + "\n", + "#### 미션 PASS 조건\n", + "\n", + "- 정량 기준: 스키마 필드 정의 완성도 100%, 샘플 데이터 검증 오류 0건\n", + "\n", + "\n", + "- 진행 기준: 입력 데이터 유효성 검증 스크립트 통과 및 명세서 문서화 완료\n", + "\n", + "\n", + "- 검증 방법: JSON Schema 검증 도구 및 샘플 데이터 파싱 단위 테스트\n", + "\n", + "\n", + "#### 채점자 판정 메모\n", + "\n", + "> 단순 코드 실행 여부뿐만 아니라 출력된 수치와 근거 자료가 군 실무 요구사항에 부합하는지 면밀히 확인한다.\n", + "\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "\"\"\"협대역 전술 무선망(VHF/UHF)의 대역폭 제약을 전문 유형별 전송시간으로 분석하고, 전술 전문의 헤더·긴급도·링크·전술 파라미터 스키마를 정의해 가상 샘플 데이터를 검증한다.\"\"\"\n", + "import json\n", + "import re\n", + "import numpy as np\n", + "import pandas as pd\n", + "\n", + "# ---------------------------------------------------------------------------\n", + "# [교육용가상자료] 가상 전술 전문(메시지) 생성기 (실제 부대·통신망·제원 정보 없음)\n", + "# 긴급도 4단계(FLASH/IMMEDIATE/PRIORITY/ROUTINE)와 전문 유형별 크기를 가진 전문을 만든다.\n", + "# ---------------------------------------------------------------------------\n", + "PREC = [\"FLASH\", \"IMMEDIATE\", \"PRIORITY\", \"ROUTINE\"]\n", + "RANK = {p: i for i, p in enumerate(PREC)}\n", + "DEADLINE = {\"FLASH\": 10, \"IMMEDIATE\": 30, \"PRIORITY\": 120, \"ROUTINE\": 600} # 가상 전달 목표 시간(초)\n", + "MSG_TYPES = { # 유형: (긴급도 후보, 확률, 본문 크기 범위 bytes, 발생 비율)\n", + " \"CONTACT\": ([\"FLASH\", \"IMMEDIATE\"], [0.7, 0.3], (120, 300), 0.08),\n", + " \"MEDEVAC\": ([\"FLASH\", \"IMMEDIATE\"], [0.4, 0.6], (150, 350), 0.05),\n", + " \"SITREP\": ([\"IMMEDIATE\", \"PRIORITY\"], [0.2, 0.8], (600, 2500), 0.20),\n", + " \"POSREP\": ([\"PRIORITY\", \"ROUTINE\"], [0.5, 0.5], (60, 120), 0.35),\n", + " \"LOGREQ\": ([\"PRIORITY\", \"ROUTINE\"], [0.4, 0.6], (300, 1200), 0.14),\n", + " \"ADMIN\": ([\"ROUTINE\"], [1.0], (1000, 5000), 0.18),\n", + "}\n", + "\n", + "\n", + "def make_messages(duration=600, rate=0.12, seed=7, t_start=0.0, prefix=\"M\"):\n", + " \"\"\"duration초 동안 평균 rate건/초로 발생하는 가상 전문 목록.\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " n = int(rng.poisson(rate * duration))\n", + " times = np.sort(rng.uniform(t_start, t_start + duration, n))\n", + " types = list(MSG_TYPES)\n", + " msgs = []\n", + " for i, t0 in enumerate(times):\n", + " ty = types[rng.choice(len(types), p=[MSG_TYPES[k][3] for k in types])]\n", + " cands, p, (lo, hi), _ = MSG_TYPES[ty]\n", + " msgs.append({\"id\": f\"{prefix}{i:04d}\", \"t0\": float(t0), \"type\": ty, \"prec\": str(rng.choice(cands, p=p)),\n", + " \"size\": int(rng.integers(lo, hi + 1)), \"src\": f\"U{int(rng.integers(1, 9)):02d}\"})\n", + " return msgs\n", + "\n", + "\n", + "class Link:\n", + " \"\"\"[교육용가상자료] 가상 협대역 무선 링크(기본 2.4kbps). 초 단위 상태(good/bad/jam)에 따라\n", + " 비트오류율이 바뀌고(Gilbert-Elliott 모델), 구간별 대역폭 감소도 줄 수 있다.\"\"\"\n", + " BER = {\"good\": 1e-5, \"bad\": 2e-4, \"jam\": 1.2e-3}\n", + "\n", + " def __init__(self, horizon=4000, bw_bps=2400, seed=1, p_gb=0.02, p_bg=0.15, jam=(), bw_drop=(), rtt=(1.2, 0.4)):\n", + " rng = np.random.default_rng(seed)\n", + " st, s = [], \"good\"\n", + " for _ in range(int(horizon) + 1):\n", + " s = (\"bad\" if rng.random() < p_gb else \"good\") if s == \"good\" else (\"good\" if rng.random() < p_bg else \"bad\")\n", + " st.append(s)\n", + " for a, b in jam:\n", + " for k in range(int(a), min(int(b), len(st))):\n", + " st[k] = \"jam\"\n", + " self.states, self.bw_bps, self.bw_drop, self.rtt_par = st, bw_bps, list(bw_drop), rtt\n", + "\n", + " def state(self, t):\n", + " return self.states[min(int(t), len(self.states) - 1)]\n", + "\n", + " def bw(self, t):\n", + " f = 1.0\n", + " for a, b, fac in self.bw_drop:\n", + " if a <= t < b:\n", + " f = fac\n", + " return self.bw_bps * f\n", + "\n", + " def p_loss(self, nbytes, t):\n", + " return 1 - (1 - self.BER[self.state(t)]) ** (8 * nbytes)\n", + "\n", + " def rtt(self, rng):\n", + " return self.rtt_par[0] + rng.exponential(self.rtt_par[1])\n", + "# ---------------------------------------------------------------------------\n", + "\n", + "# 1) 대역폭 제약 분석: 링크 속도별 전문 1건 전송시간(헤더 32bytes/조각 256bytes 기준, 재전송 제외)\n", + "print(\"[대역폭별 전문 1건 전송시간(초) - 가상 링크]\")\n", + "rows = []\n", + "for ty, (_, _, (lo, hi), _) in MSG_TYPES.items():\n", + " size = (lo + hi) / 2\n", + " nbytes = size + np.ceil(size / 256) * 32\n", + " rows.append({\"유형\": ty, \"평균 크기(B)\": int(size), **{f\"{bw / 1000:g}kbps\": round(nbytes * 8 / bw, 1) for bw in (1200, 2400, 9600, 16000)}})\n", + "print(pd.DataFrame(rows).to_string(index=False))\n", + "print(\"-> 2.4kbps에서 행정전문 1건이 약 10초 이상 채널을 점유한다. FLASH 전달 목표(10초)를 지키려면 우선순위 큐와 조각 단위 전송이 필요하다.\")\n", + "\n", + "# 2) 스키마 정의: 전문 헤더, 링크 조건, 전술 파라미터(기상·위치오차·통신지연)\n", + "SCHEMA = {\n", + " \"required\": [\"msg_id\", \"precedence\", \"msg_type\", \"src\", \"created_s\", \"payload_bytes\", \"link\", \"tactical\"],\n", + " \"fields\": {\n", + " \"msg_id\": {\"type\": str, \"pattern\": r\"^M\\d{4}$\"},\n", + " \"precedence\": {\"type\": str, \"enum\": PREC},\n", + " \"msg_type\": {\"type\": str, \"enum\": list(MSG_TYPES)},\n", + " \"src\": {\"type\": str, \"pattern\": r\"^U\\d{2}$\"},\n", + " \"created_s\": {\"type\": (int, float), \"min\": 0},\n", + " \"payload_bytes\": {\"type\": int, \"min\": 1, \"max\": 8000},\n", + " },\n", + " \"link\": {\n", + " \"band\": {\"type\": str, \"enum\": [\"VHF\", \"UHF\"]},\n", + " \"bandwidth_bps\": {\"type\": int, \"enum\": [1200, 2400, 9600, 16000]},\n", + " \"snr_db\": {\"type\": (int, float), \"min\": -10, \"max\": 40},\n", + " \"mtu_bytes\": {\"type\": int, \"min\": 32, \"max\": 1024},\n", + " },\n", + " \"tactical\": {\n", + " \"weather\": {\"type\": str, \"enum\": [\"clear\", \"rain\", \"fog\", \"snow\", \"night\"]},\n", + " \"position_error_m\": {\"type\": (int, float), \"min\": 0, \"max\": 500},\n", + " \"comm_delay_ms\": {\"type\": (int, float), \"min\": 0, \"max\": 30000},\n", + " \"relay_hops\": {\"type\": int, \"min\": 0, \"max\": 5},\n", + " },\n", + "}\n", + "\n", + "\n", + "def check(name, value, rule):\n", + " \"\"\"값 하나를 규칙(type, enum, min, max, pattern)으로 검사해 오류 목록을 돌려준다.\"\"\"\n", + " if not isinstance(value, rule[\"type\"]) or isinstance(value, bool):\n", + " return [f\"{name}: 자료형 오류({type(value).__name__})\"]\n", + " errs = []\n", + " if \"enum\" in rule and value not in rule[\"enum\"]:\n", + " errs.append(f\"{name}: 허용되지 않은 값 '{value}'\")\n", + " if \"min\" in rule and value < rule[\"min\"]:\n", + " errs.append(f\"{name}: 최소값 {rule['min']} 미만\")\n", + " if \"max\" in rule and value > rule[\"max\"]:\n", + " errs.append(f\"{name}: 최대값 {rule['max']} 초과\")\n", + " if \"pattern\" in rule and not re.match(rule[\"pattern\"], value):\n", + " errs.append(f\"{name}: 형식 불일치 '{value}'\")\n", + " return errs\n", + "\n", + "\n", + "def validate(rec):\n", + " errs = [f\"{k}: 필수 필드 누락\" for k in SCHEMA[\"required\"] if k not in rec]\n", + " for k, rule in SCHEMA[\"fields\"].items():\n", + " if k in rec:\n", + " errs += check(k, rec[k], rule)\n", + " for group in (\"link\", \"tactical\"):\n", + " for k, rule in SCHEMA[group].items():\n", + " if k not in rec.get(group, {}):\n", + " errs.append(f\"{group}.{k}: 필수 필드 누락\")\n", + " else:\n", + " errs += check(f\"{group}.{k}\", rec[group][k], rule)\n", + " if not errs and rec[\"payload_bytes\"] > 2000 and rec[\"precedence\"] == \"FLASH\":\n", + " errs.append(\"업무 규칙: FLASH 전문은 2,000bytes 이하로 작성\")\n", + " return errs\n", + "\n", + "\n", + "# 3) [교육용가상자료] 샘플 전문 20건 생성(메시지 생성기 사용)\n", + "rng = np.random.default_rng(1)\n", + "records = [{\"msg_id\": m[\"id\"], \"precedence\": m[\"prec\"], \"msg_type\": m[\"type\"], \"src\": m[\"src\"],\n", + " \"created_s\": round(m[\"t0\"], 1), \"payload_bytes\": m[\"size\"],\n", + " \"link\": {\"band\": str(rng.choice([\"VHF\", \"UHF\"])), \"bandwidth_bps\": 2400, \"snr_db\": float(round(rng.uniform(3, 25), 1)), \"mtu_bytes\": 256},\n", + " \"tactical\": {\"weather\": str(rng.choice([\"clear\", \"rain\", \"fog\", \"snow\", \"night\"])), \"position_error_m\": float(round(rng.uniform(3, 50), 1)),\n", + " \"comm_delay_ms\": int(rng.integers(300, 3000)), \"relay_hops\": int(rng.integers(0, 3))}}\n", + " for m in make_messages(400, 0.12, seed=3)[:20]]\n", + "ok = sum(1 for r in records if not validate(r))\n", + "print(f\"\\n정상 샘플 {len(records)}건 중 검증 통과 {ok}건, 오류 {len(records) - ok}건\")\n", + "\n", + "# 4) 오류 주입 시험\n", + "bad = json.loads(json.dumps(records[0]))\n", + "bad[\"msg_id\"] = \"MSG-1\"\n", + "bad[\"precedence\"] = \"URGENT\"\n", + "bad[\"link\"][\"bandwidth_bps\"] = 3000\n", + "del bad[\"tactical\"][\"comm_delay_ms\"]\n", + "bad[\"tactical\"][\"relay_hops\"] = 9\n", + "print(\"[오류 주입 결과]\")\n", + "for e in validate(bad):\n", + " print(\" -\", e)\n", + "flash_big = {**records[0], \"precedence\": \"FLASH\", \"payload_bytes\": 4000}\n", + "print(\" -\", validate(flash_big)[0])\n", + "\n", + "# 5) 스키마 명세 요약\n", + "rows = []\n", + "for g in (\"fields\", \"link\", \"tactical\"):\n", + " for k, r in SCHEMA[g].items():\n", + " t = r[\"type\"].__name__ if isinstance(r[\"type\"], type) else \"number\"\n", + " rows.append((\"기본\" if g == \"fields\" else g, k, t, str({x: y for x, y in r.items() if x != \"type\"})[:58]))\n", + "print(\"\\n[데이터 명세 요약]\")\n", + "print(pd.DataFrame(rows, columns=[\"그룹\", \"필드\", \"자료형\", \"규칙\"]).to_string(index=False))\n", + "print(\"\\n긴급도별 전달 목표(초):\", DEADLINE)\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 01 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + "[대역폭별 전문 1건 전송시간(초) - 가상 링크]\n", + " 유형 평균 크기(B) 1.2kbps 2.4kbps 9.6kbps 16kbps\n", + "CONTACT 210 1.6 0.8 0.2 0.1\n", + "MEDEVAC 250 1.9 0.9 0.2 0.1\n", + " SITREP 1550 11.8 5.9 1.5 0.9\n", + " POSREP 90 0.8 0.4 0.1 0.1\n", + " LOGREQ 750 5.6 2.8 0.7 0.4\n", + " ADMIN 3000 22.6 11.3 2.8 1.7\n", + "-> 2.4kbps에서 행정전문 1건이 약 10초 이상 채널을 점유한다. FLASH 전달 목표(10초)를 지키려면 우선순위 큐와 조각 단위 전송이 필요하다.\n", + "\n", + "정상 샘플 20건 중 검증 통과 20건, 오류 0건\n", + "[오류 주입 결과]\n", + " - msg_id: 형식 불일치 'MSG-1'\n", + " - precedence: 허용되지 않은 값 'URGENT'\n", + " - link.bandwidth_bps: 허용되지 않은 값 '3000'\n", + " - tactical.comm_delay_ms: 필수 필드 누락\n", + " - tactical.relay_hops: 최대값 5 초과\n", + " - 업무 규칙: FLASH 전문은 2,000bytes 이하로 작성\n", + "\n", + "[데이터 명세 요약]\n", + " 그룹 필드 자료형 규칙\n", + " 기본 msg_id str {'pattern': '^M\\\\d{4}$'}\n", + " 기본 precedence str {'enum': ['FLASH', 'IMMEDIATE', 'PRIORITY', 'ROUTINE']}\n", + " 기본 msg_type str {'enum': ['CONTACT', 'MEDEVAC', 'SITREP', 'POSREP', 'LOGRE\n", + " 기본 src str {'pattern': '^U\\\\d{2}$'}\n", + " 기본 created_s number {'min': 0}\n", + " 기본 payload_bytes int {'min': 1, 'max': 8000}\n", + " link band str {'enum': ['VHF', 'UHF']}\n", + " link bandwidth_bps int {'enum': [1200, 2400, 9600, 16000]}\n", + " link snr_db number {'min': -10, 'max': 40}\n", + " link mtu_bytes int {'min': 32, 'max': 1024}\n", + "tactical weather str {'enum': ['clear', 'rain', 'fog', 'snow', 'night']}\n", + "tactical position_error_m number {'min': 0, 'max': 500}\n", + "tactical comm_delay_ms number {'min': 0, 'max': 30000}\n", + "tactical relay_hops int {'min': 0, 'max': 5}\n", + "\n", + "긴급도별 전달 목표(초): {'FLASH': 10, 'IMMEDIATE': 30, 'PRIORITY': 120, 'ROUTINE': 600}\n", + "```\n", + "
\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 02. 전술 메시지(전문) 패킷 파싱, 크기 압축 및 헤더 정보 전처리 모듈 구현\n", + "**소요시간**: 약 2.0시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "전술 메시지 패킷 파싱, 헤더 압축 및 페이로드 데이터 정규화 전처리 모듈을 구현한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **잡음이 섞인 원시 전문 패킷을 파싱하고 긴급도 표기 정규화, CRC 검사, 중복 제거, 결측 시각 보정, 헤더 이진 압축, 본문 압축을 수행하는 전처리 모듈을 구현한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **긴급도 표기 혼재, 재전송으로 인한 중복 수신, 비트 오류, 전송 중 잘림, 미등록 송신국(스푸핑 의심) 패킷을 포함한 가상 원시 패킷 작성하여 원인을 분석하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `src/preprocess/c202_preprocessor.py 및 전처리 전후 데이터셋` 형태의 결과물을 완성한다.\n", + "\n", + "### 🔍 평가 및 검증 기준 (PASS 조건)\n", + "#### 미션 판정\n", + "\n", + "**평가 구분:** 필수\n", + "\n", + "**핵심 평가영역:** 전술 환경 특유의 노이즈(스푸핑, 반사파, 센서 단절 등) 제거 타당성\n", + "\n", + "#### 미션 PASS 조건\n", + "\n", + "- 정량 기준: 데이터 정제 처리 속도 1,000건/초 이상, 전처리 오류율 0%\n", + "\n", + "\n", + "- 진행 기준: 정제 파이프라인 자동화 및 단위 테스트 케이스 5종 통과\n", + "\n", + "\n", + "- 검증 방법: pytest 기반 전처리 정합성 테스트 및 결측치 보정율 확인\n", + "\n", + "\n", + "#### 채점자 판정 메모\n", + "\n", + "> 단순 코드 실행 여부뿐만 아니라 출력된 수치와 근거 자료가 군 실무 요구사항에 부합하는지 면밀히 확인한다.\n", + "\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "\"\"\"가상 전술 전문 패킷(텍스트 헤더+본문)을 파싱하고 긴급도 표기 정규화·CRC 검사·중복 제거·결측 시각 보정·헤더 이진 압축·본문 압축을 수행하는 전처리 모듈을 구현해 처리 속도와 단위 테스트 통과 여부를 확인한다.\"\"\"\n", + "import struct\n", + "import time\n", + "import zlib\n", + "import numpy as np\n", + "import pandas as pd\n", + "\n", + "# ---------------------------------------------------------------------------\n", + "# [교육용가상자료] 가상 전술 전문(메시지) 생성기 (실제 부대·통신망·제원 정보 없음)\n", + "# 긴급도 4단계(FLASH/IMMEDIATE/PRIORITY/ROUTINE)와 전문 유형별 크기를 가진 전문을 만든다.\n", + "# ---------------------------------------------------------------------------\n", + "PREC = [\"FLASH\", \"IMMEDIATE\", \"PRIORITY\", \"ROUTINE\"]\n", + "RANK = {p: i for i, p in enumerate(PREC)}\n", + "DEADLINE = {\"FLASH\": 10, \"IMMEDIATE\": 30, \"PRIORITY\": 120, \"ROUTINE\": 600} # 가상 전달 목표 시간(초)\n", + "MSG_TYPES = { # 유형: (긴급도 후보, 확률, 본문 크기 범위 bytes, 발생 비율)\n", + " \"CONTACT\": ([\"FLASH\", \"IMMEDIATE\"], [0.7, 0.3], (120, 300), 0.08),\n", + " \"MEDEVAC\": ([\"FLASH\", \"IMMEDIATE\"], [0.4, 0.6], (150, 350), 0.05),\n", + " \"SITREP\": ([\"IMMEDIATE\", \"PRIORITY\"], [0.2, 0.8], (600, 2500), 0.20),\n", + " \"POSREP\": ([\"PRIORITY\", \"ROUTINE\"], [0.5, 0.5], (60, 120), 0.35),\n", + " \"LOGREQ\": ([\"PRIORITY\", \"ROUTINE\"], [0.4, 0.6], (300, 1200), 0.14),\n", + " \"ADMIN\": ([\"ROUTINE\"], [1.0], (1000, 5000), 0.18),\n", + "}\n", + "\n", + "\n", + "def make_messages(duration=600, rate=0.12, seed=7, t_start=0.0, prefix=\"M\"):\n", + " \"\"\"duration초 동안 평균 rate건/초로 발생하는 가상 전문 목록.\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " n = int(rng.poisson(rate * duration))\n", + " times = np.sort(rng.uniform(t_start, t_start + duration, n))\n", + " types = list(MSG_TYPES)\n", + " msgs = []\n", + " for i, t0 in enumerate(times):\n", + " ty = types[rng.choice(len(types), p=[MSG_TYPES[k][3] for k in types])]\n", + " cands, p, (lo, hi), _ = MSG_TYPES[ty]\n", + " msgs.append({\"id\": f\"{prefix}{i:04d}\", \"t0\": float(t0), \"type\": ty, \"prec\": str(rng.choice(cands, p=p)),\n", + " \"size\": int(rng.integers(lo, hi + 1)), \"src\": f\"U{int(rng.integers(1, 9)):02d}\"})\n", + " return msgs\n", + "\n", + "\n", + "class Link:\n", + " \"\"\"[교육용가상자료] 가상 협대역 무선 링크(기본 2.4kbps). 초 단위 상태(good/bad/jam)에 따라\n", + " 비트오류율이 바뀌고(Gilbert-Elliott 모델), 구간별 대역폭 감소도 줄 수 있다.\"\"\"\n", + " BER = {\"good\": 1e-5, \"bad\": 2e-4, \"jam\": 1.2e-3}\n", + "\n", + " def __init__(self, horizon=4000, bw_bps=2400, seed=1, p_gb=0.02, p_bg=0.15, jam=(), bw_drop=(), rtt=(1.2, 0.4)):\n", + " rng = np.random.default_rng(seed)\n", + " st, s = [], \"good\"\n", + " for _ in range(int(horizon) + 1):\n", + " s = (\"bad\" if rng.random() < p_gb else \"good\") if s == \"good\" else (\"good\" if rng.random() < p_bg else \"bad\")\n", + " st.append(s)\n", + " for a, b in jam:\n", + " for k in range(int(a), min(int(b), len(st))):\n", + " st[k] = \"jam\"\n", + " self.states, self.bw_bps, self.bw_drop, self.rtt_par = st, bw_bps, list(bw_drop), rtt\n", + "\n", + " def state(self, t):\n", + " return self.states[min(int(t), len(self.states) - 1)]\n", + "\n", + " def bw(self, t):\n", + " f = 1.0\n", + " for a, b, fac in self.bw_drop:\n", + " if a <= t < b:\n", + " f = fac\n", + " return self.bw_bps * f\n", + "\n", + " def p_loss(self, nbytes, t):\n", + " return 1 - (1 - self.BER[self.state(t)]) ** (8 * nbytes)\n", + "\n", + " def rtt(self, rng):\n", + " return self.rtt_par[0] + rng.exponential(self.rtt_par[1])\n", + "# ---------------------------------------------------------------------------\n", + "\n", + "PREC_ALIAS = {\"Z\": \"FLASH\", \"FLASH\": \"FLASH\", \"O\": \"IMMEDIATE\", \"IMMED\": \"IMMEDIATE\", \"IMMEDIATE\": \"IMMEDIATE\",\n", + " \"P\": \"PRIORITY\", \"PRIORITY\": \"PRIORITY\", \"R\": \"ROUTINE\", \"ROUTINE\": \"ROUTINE\"}\n", + "ROSTER = {f\"U{i:02d}\" for i in range(1, 9)} # 가상 망 가입국 목록\n", + "TYPES = list(MSG_TYPES)\n", + "\n", + "\n", + "def make_body(m, rng):\n", + " \"\"\"[교육용가상자료] 'KEY=VALUE;' 형식의 정형 본문(실제 전문 양식 아님).\"\"\"\n", + " lines, k = [], 0\n", + " while sum(map(len, lines)) < m[\"size\"]:\n", + " lines.append(f\"L{k:02d}={m['type']} UNIT {m['src']} ITEM {int(rng.integers(1, 40)):02d} QTY {int(rng.integers(0, 99)):02d} STAT OK;\")\n", + " k += 1\n", + " return \"\".join(lines)[:m[\"size\"]]\n", + "\n", + "\n", + "def make_raw_packets(msgs, seed=5):\n", + " \"\"\"전술 환경 잡음을 넣은 원시 패킷 줄: 긴급도 표기 혼재, 중복 수신, 비트 오류(CRC 불일치), 시각 누락, 잘림, 미등록 송신국.\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " out = []\n", + " for m in msgs:\n", + " body = make_body(m, rng)\n", + " prec = rng.choice([PREC[RANK[m[\"prec\"]]], PREC[RANK[m[\"prec\"]]].lower(), \"ZOPR\"[RANK[m[\"prec\"]]], \" \" + \"ZOPR\"[RANK[m[\"prec\"]]] + \" \"])\n", + " dtg = \"\" if rng.random() < 0.05 else f\"{m['t0']:.1f}\"\n", + " src = m[\"src\"] if rng.random() > 0.02 else \"X77\"\n", + " line = f\"ZCZC|ID={m['id']}|PREC={prec}|TYPE={m['type']}|SRC={src}|DTG={dtg}|LEN={len(body)}|CRC={zlib.crc32(body.encode()):08x}|BODY={body}\"\n", + " r = rng.random()\n", + " if r < 0.03: # 비트 오류: 본문 일부 변형\n", + " line = line[:-5] + \"#\" + line[-4:]\n", + " elif r < 0.05: # 전송 중 잘림\n", + " line = line[:int(len(line) * 0.4)]\n", + " out.append(line)\n", + " if rng.random() < 0.06: # 재전송으로 인한 중복 수신\n", + " out.append(line)\n", + " return out\n", + "\n", + "\n", + "class ParseError(ValueError):\n", + " pass\n", + "\n", + "\n", + "def parse(line):\n", + " if not line.startswith(\"ZCZC|\"):\n", + " raise ParseError(\"시작 표지 없음\")\n", + " kv = dict(p.split(\"=\", 1) for p in line[5:].split(\"|\") if \"=\" in p)\n", + " for k in (\"ID\", \"PREC\", \"TYPE\", \"SRC\", \"DTG\", \"LEN\", \"CRC\", \"BODY\"):\n", + " if k not in kv:\n", + " raise ParseError(f\"필드 누락({k})\")\n", + " prec = PREC_ALIAS.get(kv[\"PREC\"].strip().upper())\n", + " if prec is None:\n", + " raise ParseError(\"긴급도 표기 오류\")\n", + " if kv[\"TYPE\"] not in MSG_TYPES:\n", + " raise ParseError(\"전문 유형 오류\")\n", + " if kv[\"SRC\"] not in ROSTER:\n", + " raise ParseError(\"미등록 송신국(스푸핑 의심)\")\n", + " if int(kv[\"LEN\"]) != len(kv[\"BODY\"]) or f\"{zlib.crc32(kv['BODY'].encode()):08x}\" != kv[\"CRC\"]:\n", + " raise ParseError(\"CRC/길이 불일치(비트 오류)\")\n", + " return {\"id\": kv[\"ID\"], \"prec\": prec, \"type\": kv[\"TYPE\"], \"src\": kv[\"SRC\"],\n", + " \"dtg\": float(kv[\"DTG\"]) if kv[\"DTG\"] else np.nan, \"body\": kv[\"BODY\"], \"hdr_bytes\": len(line) - len(kv[\"BODY\"])}\n", + "\n", + "\n", + "def pack_header(r):\n", + " \"\"\"헤더 이진 압축: ID(2) + 긴급도·유형·송신국(2) + 시각(4, 0.1초) + 길이(2) + CRC(4) = 14bytes.\"\"\"\n", + " flags = RANK[r[\"prec\"]] << 12 | TYPES.index(r[\"type\"]) << 8 | int(r[\"src\"][1:])\n", + " return struct.pack(\">HHIHI\", int(r[\"id\"][1:]), flags, int(round(r[\"dtg\"] * 10)), len(r[\"body\"]), zlib.crc32(r[\"body\"].encode()))\n", + "\n", + "\n", + "def unpack_header(b):\n", + " i, flags, dtg, ln, crc = struct.unpack(\">HHIHI\", b)\n", + " return {\"id\": f\"M{i:04d}\", \"prec\": PREC[flags >> 12], \"type\": TYPES[(flags >> 8) & 0xF], \"src\": f\"U{flags & 0xFF:02d}\", \"dtg\": dtg / 10, \"len\": ln}\n", + "\n", + "\n", + "ZLEVEL = {1200: 9, 2400: 9, 9600: 6, 16000: 1} # 대역폭이 좁을수록 압축을 강하게\n", + "\n", + "\n", + "def preprocess(lines, bw_bps=2400):\n", + " good, quarantine, seen = [], [], set()\n", + " for ln in lines:\n", + " try:\n", + " r = parse(ln)\n", + " except (ParseError, ValueError) as e:\n", + " quarantine.append(str(e)); continue\n", + " if r[\"id\"] in seen:\n", + " quarantine.append(\"중복 수신(제거)\"); continue\n", + " seen.add(r[\"id\"]); good.append(r)\n", + " good.sort(key=lambda r: int(r[\"id\"][1:]))\n", + " dtg = pd.Series([r[\"dtg\"] for r in good], dtype=float)\n", + " miss = int(dtg.isna().sum())\n", + " filled = dtg.interpolate(limit_direction=\"both\") # 앞뒤 전문 시각으로 선형 보간\n", + " for r, v in zip(good, filled):\n", + " r[\"dtg_filled\"] = bool(np.isnan(r[\"dtg\"])); r[\"dtg\"] = float(v)\n", + " r[\"hdr_packed\"] = pack_header(r)\n", + " r[\"body_z\"] = zlib.compress(r[\"body\"].encode(), ZLEVEL[bw_bps])\n", + " return good, quarantine, miss\n", + "\n", + "\n", + "msgs = make_messages(1200, 0.21, seed=7)\n", + "lines = make_raw_packets(msgs)\n", + "t0 = time.perf_counter()\n", + "good, quarantine, miss = preprocess(lines)\n", + "dt = time.perf_counter() - t0\n", + "print(f\"원시 패킷 {len(lines)}줄 -> 정상 {len(good)}건, 격리 {len(quarantine)}건 / 처리 속도 {len(lines) / dt:,.0f}건/초 (목표 1,000건/초 이상)\")\n", + "print(\"처리 중 예외로 멈춘 건수 0건(오류율 0%) - 비정상 패킷은 사유와 함께 격리\")\n", + "print(\"격리 사유:\", pd.Series(quarantine).value_counts().to_dict())\n", + "print(f\"DTG 결측 {miss}건 중 보정 {sum(r['dtg_filled'] for r in good)}건 (보정율 {sum(r['dtg_filled'] for r in good) / max(miss, 1):.0%})\")\n", + "\n", + "# 전처리 전후 크기 비교(전문 유형별 평균 bytes)\n", + "df = pd.DataFrame([{\"유형\": r[\"type\"], \"원본 헤더\": r[\"hdr_bytes\"], \"압축 헤더\": len(r[\"hdr_packed\"]),\n", + " \"원본 본문\": len(r[\"body\"]), \"압축 본문\": len(r[\"body_z\"])} for r in good])\n", + "tab = df.groupby(\"유형\").mean().round(0).astype(int)\n", + "tab[\"전체 절감률\"] = (1 - (tab[\"압축 헤더\"] + tab[\"압축 본문\"]) / (tab[\"원본 헤더\"] + tab[\"원본 본문\"])).round(3)\n", + "print(\"\\n[전처리 전후 평균 크기(bytes)]\")\n", + "print(tab.to_string())\n", + "tot0, tot1 = (df[\"원본 헤더\"] + df[\"원본 본문\"]).sum(), (df[\"압축 헤더\"] + df[\"압축 본문\"]).sum()\n", + "print(f\"전체 {tot0:,}B -> {tot1:,}B, 2.4kbps 기준 전송시간 {tot0 * 8 / 2400 / 60:.1f}분 -> {tot1 * 8 / 2400 / 60:.1f}분\")\n", + "print(\"상황별 압축 수준(대역폭 bps: zlib 수준):\", ZLEVEL)\n", + "print(\"주의: 가상 본문은 정형 반복 문구라 압축률이 높게 나온다. 자유 서술 본문은 절감률이 이보다 낮다.\")\n", + "\n", + "# 단위 테스트 5종(+1): 조건을 알고 있는 시험용 패킷으로 확인\n", + "def mk(prec=\"Z\", src=\"U01\", body=\"L00=TEST UNIT U01;\", crc=None):\n", + " return f\"ZCZC|ID=M9999|PREC={prec}|TYPE=CONTACT|SRC={src}|DTG=1.0|LEN={len(body)}|CRC={crc or f'{zlib.crc32(body.encode()):08x}'}|BODY={body}\"\n", + "\n", + "\n", + "def raises(line):\n", + " try:\n", + " parse(line)\n", + " return False\n", + " except ParseError:\n", + " return True\n", + "\n", + "\n", + "r0 = good[0]\n", + "tests = {\n", + " \"정상 패킷 파싱\": parse(mk())[\"prec\"] == \"FLASH\",\n", + " \"긴급도 표기 정규화(' o ' -> IMMEDIATE)\": parse(mk(prec=\" o \"))[\"prec\"] == \"IMMEDIATE\",\n", + " \"CRC 불일치 검출\": raises(mk(crc=\"00000000\")),\n", + " \"미등록 송신국 격리\": raises(mk(src=\"X77\")),\n", + " \"중복 수신 제거\": len(preprocess([mk(), mk()])[0]) == 1 and len({r[\"id\"] for r in good}) == len(good),\n", + " \"결측 시각 보간 + 헤더 압축 왕복 일치\": not any(np.isnan(r[\"dtg\"]) for r in good) and unpack_header(r0[\"hdr_packed\"])[\"id\"] == r0[\"id\"],\n", + "}\n", + "for k, v in tests.items():\n", + " print(f\"[{'PASS' if v else 'FAIL'}] {k}\")\n", + "print(f\"단위 테스트 {sum(tests.values())}/{len(tests)} 통과\")\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 02 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + "원시 패킷 278줄 -> 정상 241건, 격리 37건 / 처리 속도 19,781건/초 (목표 1,000건/초 이상)\n", + "처리 중 예외로 멈춘 건수 0건(오류율 0%) - 비정상 패킷은 사유와 함께 격리\n", + "격리 사유: {'중복 수신(제거)': 18, 'CRC/길이 불일치(비트 오류)': 11, '미등록 송신국(스푸핑 의심)': 5, '필드 누락(BODY)': 3}\n", + "DTG 결측 15건 중 보정 15건 (보정율 100%)\n", + "\n", + "[전처리 전후 평균 크기(bytes)]\n", + " 원본 헤더 압축 헤더 원본 본문 압축 본문 전체 절감률\n", + "유형 \n", + "ADMIN 80 14 3506 555 0.841\n", + "CONTACT 81 14 227 93 0.653\n", + "LOGREQ 81 14 760 178 0.772\n", + "MEDEVAC 80 14 206 91 0.633\n", + "POSREP 80 14 89 63 0.544\n", + "SITREP 82 14 1666 303 0.819\n", + "전체 276,195B -> 54,134B, 2.4kbps 기준 전송시간 15.3분 -> 3.0분\n", + "상황별 압축 수준(대역폭 bps: zlib 수준): {1200: 9, 2400: 9, 9600: 6, 16000: 1}\n", + "주의: 가상 본문은 정형 반복 문구라 압축률이 높게 나온다. 자유 서술 본문은 절감률이 이보다 낮다.\n", + "[PASS] 정상 패킷 파싱\n", + "[PASS] 긴급도 표기 정규화(' o ' -> IMMEDIATE)\n", + "[PASS] CRC 불일치 검출\n", + "[PASS] 미등록 송신국 격리\n", + "[PASS] 중복 수신 제거\n", + "[PASS] 결측 시각 보간 + 헤더 압축 왕복 일치\n", + "단위 테스트 6/6 통과\n", + "```\n", + "
\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 03. 메시지 긴급도(Flash/Immediate/Priority/Routine) 기반 1차 우선순위 큐(Queue) 구현\n", + "**소요시간**: 약 2.5시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "전문 긴급도(Flash/Immediate/Priority/Routine) 기반 1차 메시지 우선순위 큐(Queue)를 구현한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **전문 긴급도(FLASH/IMMEDIATE/PRIORITY/ROUTINE)에 따라 먼저 보낼 전문을 고르는 1차 우선순위 큐를 구현하고 도착순 전송과 성능을 비교한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **heapq로 긴급도 우선·같은 긴급도는 도착순을 지키는 큐를 만들고 동작 확인하고 유효성을 점검하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `src/models/c202_baseline_model.py 및 기본 추론 결과 보고서` 형태의 결과물을 완성한다.\n", + "\n", + "### 🔍 평가 및 검증 기준 (PASS 조건)\n", + "#### 미션 판정\n", + "\n", + "**평가 구분:** 필수\n", + "\n", + "**핵심 평가영역:** 알고리즘의 논리적 흐름 및 군사적 판단 기준의 투명성\n", + "\n", + "#### 미션 PASS 조건\n", + "\n", + "- 정량 기준: 기본 데이터셋 대상 탐지/처리 정확도 75% 이상 달성\n", + "\n", + "\n", + "- 진행 기준: 단위 기능 동작 확인 및 기본 시나리오 실행 로그 생성\n", + "\n", + "\n", + "- 검증 방법: 기준 벤치마크 데이터셋 대상 추론 결과 오차율 비교\n", + "\n", + "\n", + "#### 채점자 판정 메모\n", + "\n", + "> 단순 코드 실행 여부뿐만 아니라 출력된 수치와 근거 자료가 군 실무 요구사항에 부합하는지 면밀히 확인한다.\n", + "\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "\"\"\"전문 긴급도(FLASH/IMMEDIATE/PRIORITY/ROUTINE) 기반 1차 우선순위 큐(heapq)를 구현하고, 가상 협대역 링크 전송 시뮬레이션으로 도착순(FIFO) 전송과 긴급도별 기한 내 전달률(처리 정확도)을 비교한다.\"\"\"\n", + "import heapq\n", + "import itertools\n", + "import time\n", + "import numpy as np\n", + "import pandas as pd\n", + "import matplotlib.pyplot as plt\n", + "\n", + "# ---------------------------------------------------------------------------\n", + "# [교육용가상자료] 가상 전술 전문(메시지) 생성기 (실제 부대·통신망·제원 정보 없음)\n", + "# 긴급도 4단계(FLASH/IMMEDIATE/PRIORITY/ROUTINE)와 전문 유형별 크기를 가진 전문을 만든다.\n", + "# ---------------------------------------------------------------------------\n", + "PREC = [\"FLASH\", \"IMMEDIATE\", \"PRIORITY\", \"ROUTINE\"]\n", + "RANK = {p: i for i, p in enumerate(PREC)}\n", + "DEADLINE = {\"FLASH\": 10, \"IMMEDIATE\": 30, \"PRIORITY\": 120, \"ROUTINE\": 600} # 가상 전달 목표 시간(초)\n", + "MSG_TYPES = { # 유형: (긴급도 후보, 확률, 본문 크기 범위 bytes, 발생 비율)\n", + " \"CONTACT\": ([\"FLASH\", \"IMMEDIATE\"], [0.7, 0.3], (120, 300), 0.08),\n", + " \"MEDEVAC\": ([\"FLASH\", \"IMMEDIATE\"], [0.4, 0.6], (150, 350), 0.05),\n", + " \"SITREP\": ([\"IMMEDIATE\", \"PRIORITY\"], [0.2, 0.8], (600, 2500), 0.20),\n", + " \"POSREP\": ([\"PRIORITY\", \"ROUTINE\"], [0.5, 0.5], (60, 120), 0.35),\n", + " \"LOGREQ\": ([\"PRIORITY\", \"ROUTINE\"], [0.4, 0.6], (300, 1200), 0.14),\n", + " \"ADMIN\": ([\"ROUTINE\"], [1.0], (1000, 5000), 0.18),\n", + "}\n", + "\n", + "\n", + "def make_messages(duration=600, rate=0.12, seed=7, t_start=0.0, prefix=\"M\"):\n", + " \"\"\"duration초 동안 평균 rate건/초로 발생하는 가상 전문 목록.\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " n = int(rng.poisson(rate * duration))\n", + " times = np.sort(rng.uniform(t_start, t_start + duration, n))\n", + " types = list(MSG_TYPES)\n", + " msgs = []\n", + " for i, t0 in enumerate(times):\n", + " ty = types[rng.choice(len(types), p=[MSG_TYPES[k][3] for k in types])]\n", + " cands, p, (lo, hi), _ = MSG_TYPES[ty]\n", + " msgs.append({\"id\": f\"{prefix}{i:04d}\", \"t0\": float(t0), \"type\": ty, \"prec\": str(rng.choice(cands, p=p)),\n", + " \"size\": int(rng.integers(lo, hi + 1)), \"src\": f\"U{int(rng.integers(1, 9)):02d}\"})\n", + " return msgs\n", + "\n", + "\n", + "class Link:\n", + " \"\"\"[교육용가상자료] 가상 협대역 무선 링크(기본 2.4kbps). 초 단위 상태(good/bad/jam)에 따라\n", + " 비트오류율이 바뀌고(Gilbert-Elliott 모델), 구간별 대역폭 감소도 줄 수 있다.\"\"\"\n", + " BER = {\"good\": 1e-5, \"bad\": 2e-4, \"jam\": 1.2e-3}\n", + "\n", + " def __init__(self, horizon=4000, bw_bps=2400, seed=1, p_gb=0.02, p_bg=0.15, jam=(), bw_drop=(), rtt=(1.2, 0.4)):\n", + " rng = np.random.default_rng(seed)\n", + " st, s = [], \"good\"\n", + " for _ in range(int(horizon) + 1):\n", + " s = (\"bad\" if rng.random() < p_gb else \"good\") if s == \"good\" else (\"good\" if rng.random() < p_bg else \"bad\")\n", + " st.append(s)\n", + " for a, b in jam:\n", + " for k in range(int(a), min(int(b), len(st))):\n", + " st[k] = \"jam\"\n", + " self.states, self.bw_bps, self.bw_drop, self.rtt_par = st, bw_bps, list(bw_drop), rtt\n", + "\n", + " def state(self, t):\n", + " return self.states[min(int(t), len(self.states) - 1)]\n", + "\n", + " def bw(self, t):\n", + " f = 1.0\n", + " for a, b, fac in self.bw_drop:\n", + " if a <= t < b:\n", + " f = fac\n", + " return self.bw_bps * f\n", + "\n", + " def p_loss(self, nbytes, t):\n", + " return 1 - (1 - self.BER[self.state(t)]) ** (8 * nbytes)\n", + "\n", + " def rtt(self, rng):\n", + " return self.rtt_par[0] + rng.exponential(self.rtt_par[1])\n", + "# ---------------------------------------------------------------------------\n", + "\n", + "# ---- 우선순위 큐·재전송 이산 사건 시뮬레이터 공통 함수 ----\n", + "HDR = 32 # 전문 조각(패킷) 헤더 bytes (미션 2의 헤더 압축 적용 시 12 bytes)\n", + "\n", + "\n", + "def policy_key(m, t, policy, aging):\n", + " if policy == \"fifo\": # 도착 순서\n", + " return (m[\"t0\"],)\n", + " if policy == \"strict\": # 긴급도 우선, 같으면 도착 순서\n", + " return (RANK[m[\"prec\"]], m[\"t0\"])\n", + " # aging: 긴급도 + (대기시간 / 전달 목표시간) 가중. 오래 기다린 전문과 기한이 임박한 전문이 앞으로 온다.\n", + " return (RANK[m[\"prec\"]] - aging * (t - m[\"t0\"]) / DEADLINE[m[\"prec\"]], m[\"t0\"])\n", + "\n", + "\n", + "def simulate(msgs, link, policy=\"strict\", mtu=256, preempt=True, rto_mode=\"fixed\", rto_s=4.0, aging=0.0,\n", + " redundancy=False, adapt_mtu=False, hdr=HDR, seed=0, until=None, admit=None):\n", + " \"\"\"전문 목록을 링크로 보내는 이산 사건 시뮬레이션.\n", + " - 전문은 mtu 크기 조각으로 나눠 보낸다. 조각이 손실되면 RTO 후 재전송한다(자료는 버리지 않음).\n", + " - preempt=False면 한 전문의 조각을 끝까지 연속 전송한다(큰 전문이 긴급 전문을 막는 현상 재현).\n", + " - rto_mode='adaptive'면 왕복시간(RTT) 추정값으로 RTO를 계산한다(Jacobson 방식).\n", + " - redundancy=True면 링크 열화 추정 시 FLASH·IMMEDIATE 조각을 2회 연속 전송한다.\n", + " - adapt_mtu=True면 링크 열화 추정 시 조각 크기를 줄인다.\n", + " - admit(m, t)이 False인 전문은 새 조각을 보내지 않고 저장해 둔다(성능 저하 모드).\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " pend = sorted(({**m, \"unsent\": m[\"size\"], \"ready\": [], \"frags\": [], \"done\": None, \"first\": None} for m in msgs), key=lambda m: m[\"t0\"])\n", + " t, i, active, cur = 0.0, 0, [], None\n", + " srtt, rttvar, loss = None, None, 0.0\n", + " st = {\"tx\": 0, \"retx\": 0, \"spurious\": 0, \"bytes\": 0, \"decide_ms\": [], \"flag\": {}, \"log\": []}\n", + " until = until if until is not None else (pend[-1][\"t0\"] if pend else 0) + 6000\n", + " while (i < len(pend) or active) and t < until:\n", + " while i < len(pend) and pend[i][\"t0\"] <= t:\n", + " active.append(pend[i]); i += 1\n", + " td = time.perf_counter()\n", + " for m in active: # ACK가 이미 도착한 재전송 예약은 취소\n", + " m[\"ready\"] = [e for e in m[\"ready\"] if not (e[1][\"ack\"] is not None and e[1][\"ack\"] <= t)]\n", + " ready = [m for m in active if any(r <= t for r, _ in m[\"ready\"]) or (m[\"unsent\"] > 0 and (admit is None or admit(m, t)))]\n", + " if not ready:\n", + " nxt = ([pend[i][\"t0\"]] if i < len(pend) else []) + [r for m in active for r, _ in m[\"ready\"] if r > t]\n", + " t = min(nxt) if nxt else t + 1.0\n", + " continue\n", + " if not preempt and cur in ready and cur[\"unsent\"] > 0:\n", + " m = cur\n", + " else:\n", + " m = min(ready, key=lambda x: policy_key(x, t, policy, aging))\n", + " st[\"decide_ms\"].append((time.perf_counter() - td) * 1000)\n", + " due = [e for e in m[\"ready\"] if e[0] <= t]\n", + " if due:\n", + " e = min(due, key=lambda x: x[0]); m[\"ready\"].remove(e); frag = e[1]\n", + " st[\"retx\"] += 1; st[\"spurious\"] += frag[\"recv\"]\n", + " else:\n", + " size = mtu if not adapt_mtu else (mtu if loss < 0.15 else (mtu // 2 if loss < 0.4 else mtu // 4))\n", + " size = min(m[\"unsent\"], size); m[\"unsent\"] -= size\n", + " frag = {\"b\": size, \"recv\": False, \"ack\": None, \"tries\": 0}\n", + " m[\"frags\"].append(frag)\n", + " cur = m\n", + " m[\"first\"] = t if m[\"first\"] is None else m[\"first\"]\n", + " nb = frag[\"b\"] + hdr\n", + " copies = 2 if (redundancy and RANK[m[\"prec\"]] <= 1 and loss > 0.15) else 1\n", + " for _ in range(copies):\n", + " t += nb * 8 / link.bw(t)\n", + " st[\"tx\"] += 1; st[\"bytes\"] += nb\n", + " ok = rng.random() >= link.p_loss(nb, t)\n", + " st[\"log\"].append((t, nb, ok, m[\"prec\"], m[\"id\"])) # 전송 기록(시각, bytes, 성공, 긴급도, 전문)\n", + " loss = 0.85 * loss + 0.15 * (0.0 if ok else 1.0) # 최근 손실률 추정(EWMA)\n", + " st[\"flag\"][int(t)] = st[\"flag\"].get(int(t), False) or loss > 0.12 # 링크 열화 판정(초 단위)\n", + " if ok and not frag[\"recv\"]:\n", + " frag[\"recv\"], frag[\"rt\"] = True, t\n", + " r = link.rtt(rng); frag[\"ack\"] = t + r\n", + " if srtt is None:\n", + " srtt, rttvar = r, r / 2\n", + " else:\n", + " rttvar = 0.75 * rttvar + 0.25 * abs(srtt - r); srtt = 0.875 * srtt + 0.125 * r\n", + " frag[\"tries\"] += 1\n", + " rto = rto_s if (rto_mode == \"fixed\" or srtt is None) else float(np.clip(srtt + 4 * rttvar, 1.0, 30.0))\n", + " if not frag[\"recv\"] or frag[\"ack\"] > t + rto: # 손실 또는 ACK 지연 -> 재전송 예약\n", + " m[\"ready\"].append((t + rto, frag))\n", + " if m[\"done\"] is None and m[\"unsent\"] == 0 and all(f[\"recv\"] for f in m[\"frags\"]):\n", + " m[\"done\"] = max(f[\"rt\"] for f in m[\"frags\"])\n", + " active = [x for x in active if not (x[\"unsent\"] == 0 and not x[\"ready\"] and all(f[\"recv\"] for f in x[\"frags\"]))]\n", + " res = [{\"id\": m[\"id\"], \"type\": m[\"type\"], \"prec\": m[\"prec\"], \"size\": m[\"size\"], \"t0\": m[\"t0\"], \"done\": m[\"done\"], \"wait\": None if m[\"first\"] is None else m[\"first\"] - m[\"t0\"],\n", + " \"retx\": sum(f[\"tries\"] for f in m[\"frags\"]) - len(m[\"frags\"]),\n", + " \"latency\": None if m[\"done\"] is None else m[\"done\"] - m[\"t0\"]} for m in pend]\n", + " for r in res:\n", + " r[\"on_time\"] = r[\"latency\"] is not None and r[\"latency\"] <= DEADLINE[r[\"prec\"]]\n", + " return res, st\n", + "\n", + "\n", + "def summarize(res):\n", + " \"\"\"긴급도별 기한 내 전달률·지연. 종합 정확도 = 긴급도별 기한 내 전달률의 평균.\"\"\"\n", + " df = pd.DataFrame(res)\n", + " rows = []\n", + " for p in PREC:\n", + " d = df[df[\"prec\"] == p]\n", + " lat = d[\"latency\"].dropna()\n", + " rows.append({\"긴급도\": p, \"건수\": len(d), \"기한내 전달률\": round(d[\"on_time\"].mean(), 3) if len(d) else np.nan,\n", + " \"평균 지연(s)\": round(lat.mean(), 1) if len(lat) else np.nan,\n", + " \"95% 지연(s)\": round(lat.quantile(0.95), 1) if len(lat) else np.nan, \"미전달\": int(d[\"done\"].isna().sum())})\n", + " tab = pd.DataFrame(rows)\n", + " return tab, round(float(tab[\"기한내 전달률\"].mean()), 3)\n", + "\n", + "\n", + "class PrecedenceQueue:\n", + " \"\"\"긴급도 순위가 높은 전문을 먼저 꺼내고, 같은 긴급도는 도착 순서(FIFO)를 지키는 1차 우선순위 큐.\"\"\"\n", + "\n", + " def __init__(self):\n", + " self.h, self.seq = [], itertools.count()\n", + "\n", + " def push(self, m):\n", + " heapq.heappush(self.h, (RANK[m[\"prec\"]], m[\"t0\"], next(self.seq), m))\n", + "\n", + " def pop(self):\n", + " return heapq.heappop(self.h)[-1]\n", + "\n", + " def __len__(self):\n", + " return len(self.h)\n", + "\n", + "\n", + "# 1) 큐 동작 확인: 뒤섞여 도착한 전문이 긴급도 순서로 나오는가\n", + "demo = [{\"id\": f\"D{i}\", \"prec\": p, \"t0\": float(i)} for i, p in enumerate([\"ROUTINE\", \"PRIORITY\", \"FLASH\", \"ROUTINE\", \"IMMEDIATE\", \"FLASH\"])]\n", + "q = PrecedenceQueue()\n", + "for m in demo:\n", + " q.push(m)\n", + "out = [q.pop() for _ in range(len(q))]\n", + "print(\"도착 순서:\", [f\"{m['id']}:{m['prec'][0]}\" for m in demo])\n", + "print(\"꺼낸 순서:\", [f\"{m['id']}:{m['prec'][0]}\" for m in out])\n", + "print(\"같은 긴급도 FIFO 유지:\", [m[\"id\"] for m in out if m[\"prec\"] == \"FLASH\"] == [\"D2\", \"D5\"])\n", + "\n", + "# 2) [교육용가상자료] 20분간 전문 약 250건, 2.4kbps 링크에서 전송 시뮬레이션\n", + "msgs = make_messages(1200, 0.21, seed=7)\n", + "link = Link(8000, seed=1)\n", + "t0 = time.perf_counter()\n", + "fifo, _ = simulate(msgs, link, \"fifo\")\n", + "pq, st = simulate(msgs, link, \"strict\", preempt=False) # 1차 큐: 전문 단위로 꺼내 끝까지 전송\n", + "elapsed = time.perf_counter() - t0\n", + "tf, acc_f = summarize(fifo)\n", + "tp, acc_p = summarize(pq)\n", + "tab = tp[[\"긴급도\", \"건수\"]].copy()\n", + "tab[\"FIFO 기한내\"] = tf[\"기한내 전달률\"]; tab[\"FIFO 95%지연\"] = tf[\"95% 지연(s)\"]\n", + "tab[\"우선순위큐 기한내\"] = tp[\"기한내 전달률\"]; tab[\"우선순위큐 95%지연\"] = tp[\"95% 지연(s)\"]\n", + "print(f\"\\n전문 {len(msgs)}건, 조각 전송 {st['tx']}회(재전송 {st['retx']}회), 시뮬레이션 2회 {elapsed:.2f}초\")\n", + "print(tab.to_string(index=False))\n", + "all_on = np.mean([r[\"on_time\"] for r in pq])\n", + "print(f\"\\n처리 정확도(전체 기한 내 전달률) FIFO {np.mean([r['on_time'] for r in fifo]):.1%} -> 우선순위 큐 {all_on:.1%} (목표 75% 이상: {'달성' if all_on >= 0.75 else '미달'})\")\n", + "print(f\"종합 정확도(긴급도별 전달률 평균) FIFO {acc_f:.3f} -> 우선순위 큐 {acc_p:.3f}\")\n", + "\n", + "# 3) 한계 분석: 기한을 놓친 FLASH 전문은 왜 늦었나\n", + "late = [r for r in pq if r[\"prec\"] == \"FLASH\" and not r[\"on_time\"]]\n", + "for r in late:\n", + " blocker = [m for m in msgs if m[\"t0\"] < r[\"t0\"] and m[\"size\"] >= 1000 and r[\"t0\"] - m[\"t0\"] < 30]\n", + " print(f\"FLASH {r['id']} 지연 {r['latency']:.1f}s - 직전 30초 내 도착한 1,000B 이상 대용량 전문 {len(blocker)}건이 채널 점유(전문 단위 비선점)\")\n", + "print(\"-> 전문을 통째로 보내는 1차 큐는 대용량 전문이 채널을 점유하면 FLASH도 기다린다. 미션 4에서 조각 단위 선점과 재전송 개선으로 보완한다.\")\n", + "\n", + "# 4) 링크 속도별 민감도\n", + "print(\"\\n링크(bps) FIFO 전체 우선순위큐 전체 우선순위큐 FLASH\")\n", + "for bw in [1200, 2400, 4800]:\n", + " lk = Link(20000, bw_bps=bw, seed=1)\n", + " a, _ = simulate(msgs, lk, \"fifo\"); b, _ = simulate(msgs, lk, \"strict\", preempt=False)\n", + " fl = [r[\"on_time\"] for r in b if r[\"prec\"] == \"FLASH\"]\n", + " print(f\"{bw:>8} {np.mean([r['on_time'] for r in a]):8.1%} {np.mean([r['on_time'] for r in b]):12.1%} {np.mean(fl):12.1%}\")\n", + "\n", + "plt.figure(figsize=(8, 4))\n", + "data = [[r[\"latency\"] for r in res if r[\"prec\"] == p] for res in (fifo, pq) for p in PREC]\n", + "plt.boxplot(data, showfliers=False)\n", + "plt.xticks(range(1, 9), [f\"{n}\\n{p[:5]}\" for n in (\"FIFO\", \"PQ\") for p in PREC])\n", + "plt.yscale(\"log\"); plt.ylabel(\"delivery latency (s, log)\"); plt.title(\"FIFO vs precedence queue (synthetic 2.4 kbps link)\")\n", + "plt.tight_layout(); plt.show()\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 03 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + "도착 순서: ['D0:R', 'D1:P', 'D2:F', 'D3:R', 'D4:I', 'D5:F']\n", + "꺼낸 순서: ['D2:F', 'D5:F', 'D4:I', 'D1:P', 'D0:R', 'D3:R']\n", + "같은 긴급도 FIFO 유지: True\n", + "\n", + "전문 258건, 조각 전송 1285회(재전송 71회), 시뮬레이션 2회 0.03초\n", + " 긴급도 건수 FIFO 기한내 FIFO 95%지연 우선순위큐 기한내 우선순위큐 95%지연\n", + " FLASH 25 0.200 48.4 0.76 14.4\n", + "IMMEDIATE 27 0.741 45.8 1.00 17.8\n", + " PRIORITY 99 1.000 53.2 1.00 25.7\n", + " ROUTINE 107 1.000 57.7 1.00 82.3\n", + "\n", + "처리 정확도(전체 기한 내 전달률) FIFO 89.5% -> 우선순위 큐 97.7% (목표 75% 이상: 달성)\n", + "종합 정확도(긴급도별 전달률 평균) FIFO 0.735 -> 우선순위 큐 0.940\n", + "FLASH M0010 지연 17.4s - 직전 30초 내 도착한 1,000B 이상 대용량 전문 2건이 채널 점유(전문 단위 비선점)\n", + "FLASH M0041 지연 13.1s - 직전 30초 내 도착한 1,000B 이상 대용량 전문 3건이 채널 점유(전문 단위 비선점)\n", + "FLASH M0105 지연 13.2s - 직전 30초 내 도착한 1,000B 이상 대용량 전문 2건이 채널 점유(전문 단위 비선점)\n", + "FLASH M0226 지연 14.7s - 직전 30초 내 도착한 1,000B 이상 대용량 전문 3건이 채널 점유(전문 단위 비선점)\n", + "FLASH M0228 지연 13.1s - 직전 30초 내 도착한 1,000B 이상 대용량 전문 3건이 채널 점유(전문 단위 비선점)\n", + "FLASH M0233 지연 13.2s - 직전 30초 내 도착한 1,000B 이상 대용량 전문 3건이 채널 점유(전문 단위 비선점)\n", + "-> 전문을 통째로 보내는 1차 큐는 대용량 전문이 채널을 점유하면 FLASH도 기다린다. 미션 4에서 조각 단위 선점과 재전송 개선으로 보완한다.\n", + "\n", + "링크(bps) FIFO 전체 우선순위큐 전체 우선순위큐 FLASH\n", + " 1200 34.9% 58.9% 44.0%\n", + " 2400 89.5% 97.7% 76.0%\n", + " 4800 100.0% 100.0% 100.0%\n", + "```\n", + "
\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 04. 전장 상황 및 전송 실패율에 따른 동적 패킷 스케줄링 및 지능형 재전송 알고리즘 구현\n", + "**소요시간**: 약 3.0시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "동적 채널 상태 및 패킷 손실률에 따라 최적 재전송 간격을 조정하는 지능형 스케줄링 알고리즘을 고도화한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **채널 손실률과 왕복시간을 추정해 전송 순서와 재전송 간격을 조정하는 지능형 스케줄링·재전송 알고리즘으로 고도화한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **조각 단위 선점, 대기시간 가중(aging), 왕복시간 기반 적응형 재전송 타이머, 링크 열화 시 긴급 전문 중복 전송과 조각 크기 축소 구현하고 동작을 검증하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `src/models/c202_advanced_detector.py 및 예외 탐지 테스트 결과` 형태의 결과물을 완성한다.\n", + "\n", + "### 🔍 평가 및 검증 기준 (PASS 조건)\n", + "#### 미션 판정\n", + "\n", + "**평가 구분:** 필수\n", + "\n", + "**핵심 평가영역:** 기만/교란 공격에 대한 방어 로직의 현실성 및 신뢰성\n", + "\n", + "#### 미션 PASS 조건\n", + "\n", + "- 정량 기준: 복합 위협 탐지 재현율(Recall) 85% 이상, 처리 지연 200ms 이하\n", + "\n", + "\n", + "- 진행 기준: 복합 위협 시나리오 10종에 대한 탐지 테스트 통과\n", + "\n", + "\n", + "- 검증 방법: 합성 위협 데이터셋 주입 시 이상치 탐지 정확도 검증\n", + "\n", + "\n", + "#### 채점자 판정 메모\n", + "\n", + "> 단순 코드 실행 여부뿐만 아니라 출력된 수치와 근거 자료가 군 실무 요구사항에 부합하는지 면밀히 확인한다.\n", + "\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "\"\"\"채널 상태(손실률·왕복시간)를 추정해 조각 단위 선점, 대기시간 가중(aging), 적응형 재전송 타이머(RTO), 긴급 전문 중복 전송, 조각 크기 조정을 수행하는 지능형 스케줄러를 구현하고, 복합 장애 시나리오 10종에서 긴급 전문 전달률·링크 열화 탐지 재현율·스케줄링 결정 지연을 검증한다.\"\"\"\n", + "import time\n", + "import numpy as np\n", + "import pandas as pd\n", + "import matplotlib.pyplot as plt\n", + "\n", + "# ---------------------------------------------------------------------------\n", + "# [교육용가상자료] 가상 전술 전문(메시지) 생성기 (실제 부대·통신망·제원 정보 없음)\n", + "# 긴급도 4단계(FLASH/IMMEDIATE/PRIORITY/ROUTINE)와 전문 유형별 크기를 가진 전문을 만든다.\n", + "# ---------------------------------------------------------------------------\n", + "PREC = [\"FLASH\", \"IMMEDIATE\", \"PRIORITY\", \"ROUTINE\"]\n", + "RANK = {p: i for i, p in enumerate(PREC)}\n", + "DEADLINE = {\"FLASH\": 10, \"IMMEDIATE\": 30, \"PRIORITY\": 120, \"ROUTINE\": 600} # 가상 전달 목표 시간(초)\n", + "MSG_TYPES = { # 유형: (긴급도 후보, 확률, 본문 크기 범위 bytes, 발생 비율)\n", + " \"CONTACT\": ([\"FLASH\", \"IMMEDIATE\"], [0.7, 0.3], (120, 300), 0.08),\n", + " \"MEDEVAC\": ([\"FLASH\", \"IMMEDIATE\"], [0.4, 0.6], (150, 350), 0.05),\n", + " \"SITREP\": ([\"IMMEDIATE\", \"PRIORITY\"], [0.2, 0.8], (600, 2500), 0.20),\n", + " \"POSREP\": ([\"PRIORITY\", \"ROUTINE\"], [0.5, 0.5], (60, 120), 0.35),\n", + " \"LOGREQ\": ([\"PRIORITY\", \"ROUTINE\"], [0.4, 0.6], (300, 1200), 0.14),\n", + " \"ADMIN\": ([\"ROUTINE\"], [1.0], (1000, 5000), 0.18),\n", + "}\n", + "\n", + "\n", + "def make_messages(duration=600, rate=0.12, seed=7, t_start=0.0, prefix=\"M\"):\n", + " \"\"\"duration초 동안 평균 rate건/초로 발생하는 가상 전문 목록.\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " n = int(rng.poisson(rate * duration))\n", + " times = np.sort(rng.uniform(t_start, t_start + duration, n))\n", + " types = list(MSG_TYPES)\n", + " msgs = []\n", + " for i, t0 in enumerate(times):\n", + " ty = types[rng.choice(len(types), p=[MSG_TYPES[k][3] for k in types])]\n", + " cands, p, (lo, hi), _ = MSG_TYPES[ty]\n", + " msgs.append({\"id\": f\"{prefix}{i:04d}\", \"t0\": float(t0), \"type\": ty, \"prec\": str(rng.choice(cands, p=p)),\n", + " \"size\": int(rng.integers(lo, hi + 1)), \"src\": f\"U{int(rng.integers(1, 9)):02d}\"})\n", + " return msgs\n", + "\n", + "\n", + "class Link:\n", + " \"\"\"[교육용가상자료] 가상 협대역 무선 링크(기본 2.4kbps). 초 단위 상태(good/bad/jam)에 따라\n", + " 비트오류율이 바뀌고(Gilbert-Elliott 모델), 구간별 대역폭 감소도 줄 수 있다.\"\"\"\n", + " BER = {\"good\": 1e-5, \"bad\": 2e-4, \"jam\": 1.2e-3}\n", + "\n", + " def __init__(self, horizon=4000, bw_bps=2400, seed=1, p_gb=0.02, p_bg=0.15, jam=(), bw_drop=(), rtt=(1.2, 0.4)):\n", + " rng = np.random.default_rng(seed)\n", + " st, s = [], \"good\"\n", + " for _ in range(int(horizon) + 1):\n", + " s = (\"bad\" if rng.random() < p_gb else \"good\") if s == \"good\" else (\"good\" if rng.random() < p_bg else \"bad\")\n", + " st.append(s)\n", + " for a, b in jam:\n", + " for k in range(int(a), min(int(b), len(st))):\n", + " st[k] = \"jam\"\n", + " self.states, self.bw_bps, self.bw_drop, self.rtt_par = st, bw_bps, list(bw_drop), rtt\n", + "\n", + " def state(self, t):\n", + " return self.states[min(int(t), len(self.states) - 1)]\n", + "\n", + " def bw(self, t):\n", + " f = 1.0\n", + " for a, b, fac in self.bw_drop:\n", + " if a <= t < b:\n", + " f = fac\n", + " return self.bw_bps * f\n", + "\n", + " def p_loss(self, nbytes, t):\n", + " return 1 - (1 - self.BER[self.state(t)]) ** (8 * nbytes)\n", + "\n", + " def rtt(self, rng):\n", + " return self.rtt_par[0] + rng.exponential(self.rtt_par[1])\n", + "# ---------------------------------------------------------------------------\n", + "\n", + "# ---- 우선순위 큐·재전송 이산 사건 시뮬레이터 공통 함수 ----\n", + "HDR = 32 # 전문 조각(패킷) 헤더 bytes (미션 2의 헤더 압축 적용 시 12 bytes)\n", + "\n", + "\n", + "def policy_key(m, t, policy, aging):\n", + " if policy == \"fifo\": # 도착 순서\n", + " return (m[\"t0\"],)\n", + " if policy == \"strict\": # 긴급도 우선, 같으면 도착 순서\n", + " return (RANK[m[\"prec\"]], m[\"t0\"])\n", + " # aging: 긴급도 + (대기시간 / 전달 목표시간) 가중. 오래 기다린 전문과 기한이 임박한 전문이 앞으로 온다.\n", + " return (RANK[m[\"prec\"]] - aging * (t - m[\"t0\"]) / DEADLINE[m[\"prec\"]], m[\"t0\"])\n", + "\n", + "\n", + "def simulate(msgs, link, policy=\"strict\", mtu=256, preempt=True, rto_mode=\"fixed\", rto_s=4.0, aging=0.0,\n", + " redundancy=False, adapt_mtu=False, hdr=HDR, seed=0, until=None, admit=None):\n", + " \"\"\"전문 목록을 링크로 보내는 이산 사건 시뮬레이션.\n", + " - 전문은 mtu 크기 조각으로 나눠 보낸다. 조각이 손실되면 RTO 후 재전송한다(자료는 버리지 않음).\n", + " - preempt=False면 한 전문의 조각을 끝까지 연속 전송한다(큰 전문이 긴급 전문을 막는 현상 재현).\n", + " - rto_mode='adaptive'면 왕복시간(RTT) 추정값으로 RTO를 계산한다(Jacobson 방식).\n", + " - redundancy=True면 링크 열화 추정 시 FLASH·IMMEDIATE 조각을 2회 연속 전송한다.\n", + " - adapt_mtu=True면 링크 열화 추정 시 조각 크기를 줄인다.\n", + " - admit(m, t)이 False인 전문은 새 조각을 보내지 않고 저장해 둔다(성능 저하 모드).\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " pend = sorted(({**m, \"unsent\": m[\"size\"], \"ready\": [], \"frags\": [], \"done\": None, \"first\": None} for m in msgs), key=lambda m: m[\"t0\"])\n", + " t, i, active, cur = 0.0, 0, [], None\n", + " srtt, rttvar, loss = None, None, 0.0\n", + " st = {\"tx\": 0, \"retx\": 0, \"spurious\": 0, \"bytes\": 0, \"decide_ms\": [], \"flag\": {}, \"log\": []}\n", + " until = until if until is not None else (pend[-1][\"t0\"] if pend else 0) + 6000\n", + " while (i < len(pend) or active) and t < until:\n", + " while i < len(pend) and pend[i][\"t0\"] <= t:\n", + " active.append(pend[i]); i += 1\n", + " td = time.perf_counter()\n", + " for m in active: # ACK가 이미 도착한 재전송 예약은 취소\n", + " m[\"ready\"] = [e for e in m[\"ready\"] if not (e[1][\"ack\"] is not None and e[1][\"ack\"] <= t)]\n", + " ready = [m for m in active if any(r <= t for r, _ in m[\"ready\"]) or (m[\"unsent\"] > 0 and (admit is None or admit(m, t)))]\n", + " if not ready:\n", + " nxt = ([pend[i][\"t0\"]] if i < len(pend) else []) + [r for m in active for r, _ in m[\"ready\"] if r > t]\n", + " t = min(nxt) if nxt else t + 1.0\n", + " continue\n", + " if not preempt and cur in ready and cur[\"unsent\"] > 0:\n", + " m = cur\n", + " else:\n", + " m = min(ready, key=lambda x: policy_key(x, t, policy, aging))\n", + " st[\"decide_ms\"].append((time.perf_counter() - td) * 1000)\n", + " due = [e for e in m[\"ready\"] if e[0] <= t]\n", + " if due:\n", + " e = min(due, key=lambda x: x[0]); m[\"ready\"].remove(e); frag = e[1]\n", + " st[\"retx\"] += 1; st[\"spurious\"] += frag[\"recv\"]\n", + " else:\n", + " size = mtu if not adapt_mtu else (mtu if loss < 0.15 else (mtu // 2 if loss < 0.4 else mtu // 4))\n", + " size = min(m[\"unsent\"], size); m[\"unsent\"] -= size\n", + " frag = {\"b\": size, \"recv\": False, \"ack\": None, \"tries\": 0}\n", + " m[\"frags\"].append(frag)\n", + " cur = m\n", + " m[\"first\"] = t if m[\"first\"] is None else m[\"first\"]\n", + " nb = frag[\"b\"] + hdr\n", + " copies = 2 if (redundancy and RANK[m[\"prec\"]] <= 1 and loss > 0.15) else 1\n", + " for _ in range(copies):\n", + " t += nb * 8 / link.bw(t)\n", + " st[\"tx\"] += 1; st[\"bytes\"] += nb\n", + " ok = rng.random() >= link.p_loss(nb, t)\n", + " st[\"log\"].append((t, nb, ok, m[\"prec\"], m[\"id\"])) # 전송 기록(시각, bytes, 성공, 긴급도, 전문)\n", + " loss = 0.85 * loss + 0.15 * (0.0 if ok else 1.0) # 최근 손실률 추정(EWMA)\n", + " st[\"flag\"][int(t)] = st[\"flag\"].get(int(t), False) or loss > 0.12 # 링크 열화 판정(초 단위)\n", + " if ok and not frag[\"recv\"]:\n", + " frag[\"recv\"], frag[\"rt\"] = True, t\n", + " r = link.rtt(rng); frag[\"ack\"] = t + r\n", + " if srtt is None:\n", + " srtt, rttvar = r, r / 2\n", + " else:\n", + " rttvar = 0.75 * rttvar + 0.25 * abs(srtt - r); srtt = 0.875 * srtt + 0.125 * r\n", + " frag[\"tries\"] += 1\n", + " rto = rto_s if (rto_mode == \"fixed\" or srtt is None) else float(np.clip(srtt + 4 * rttvar, 1.0, 30.0))\n", + " if not frag[\"recv\"] or frag[\"ack\"] > t + rto: # 손실 또는 ACK 지연 -> 재전송 예약\n", + " m[\"ready\"].append((t + rto, frag))\n", + " if m[\"done\"] is None and m[\"unsent\"] == 0 and all(f[\"recv\"] for f in m[\"frags\"]):\n", + " m[\"done\"] = max(f[\"rt\"] for f in m[\"frags\"])\n", + " active = [x for x in active if not (x[\"unsent\"] == 0 and not x[\"ready\"] and all(f[\"recv\"] for f in x[\"frags\"]))]\n", + " res = [{\"id\": m[\"id\"], \"type\": m[\"type\"], \"prec\": m[\"prec\"], \"size\": m[\"size\"], \"t0\": m[\"t0\"], \"done\": m[\"done\"], \"wait\": None if m[\"first\"] is None else m[\"first\"] - m[\"t0\"],\n", + " \"retx\": sum(f[\"tries\"] for f in m[\"frags\"]) - len(m[\"frags\"]),\n", + " \"latency\": None if m[\"done\"] is None else m[\"done\"] - m[\"t0\"]} for m in pend]\n", + " for r in res:\n", + " r[\"on_time\"] = r[\"latency\"] is not None and r[\"latency\"] <= DEADLINE[r[\"prec\"]]\n", + " return res, st\n", + "\n", + "\n", + "def summarize(res):\n", + " \"\"\"긴급도별 기한 내 전달률·지연. 종합 정확도 = 긴급도별 기한 내 전달률의 평균.\"\"\"\n", + " df = pd.DataFrame(res)\n", + " rows = []\n", + " for p in PREC:\n", + " d = df[df[\"prec\"] == p]\n", + " lat = d[\"latency\"].dropna()\n", + " rows.append({\"긴급도\": p, \"건수\": len(d), \"기한내 전달률\": round(d[\"on_time\"].mean(), 3) if len(d) else np.nan,\n", + " \"평균 지연(s)\": round(lat.mean(), 1) if len(lat) else np.nan,\n", + " \"95% 지연(s)\": round(lat.quantile(0.95), 1) if len(lat) else np.nan, \"미전달\": int(d[\"done\"].isna().sum())})\n", + " tab = pd.DataFrame(rows)\n", + " return tab, round(float(tab[\"기한내 전달률\"].mean()), 3)\n", + "\n", + "BASE = dict(policy=\"strict\", preempt=False) # 미션 3의 1차 큐(고정 RTO 4초)\n", + "ADV = dict(policy=\"aging\", aging=0.5, preempt=True, rto_mode=\"adaptive\", redundancy=True, adapt_mtu=True)\n", + "\n", + "\n", + "def urgent_rate(res):\n", + " u = [r[\"on_time\"] for r in res if RANK[r[\"prec\"]] <= 1]\n", + " return float(np.mean(u))\n", + "\n", + "\n", + "def degradation_episodes(link, flag):\n", + " \"\"\"열화 구간(bad/jam이 이어지는 구간, 전송 3회 이상) 중 구간 안팎 2초 내에 열화 판정이 나온 비율.\"\"\"\n", + " eps, s = [], None\n", + " for k, stt in enumerate(link.states + [\"good\"]):\n", + " if stt != \"good\" and s is None:\n", + " s = k\n", + " elif stt == \"good\" and s is not None:\n", + " eps.append((s, k)); s = None\n", + " hit = [any(flag.get(k, False) for k in range(a, b + 3)) for a, b in eps if sum(k in flag for k in range(a, b)) >= 3]\n", + " good = [v for k, v in flag.items() if link.state(k) == \"good\"]\n", + " return hit, float(np.mean(good)) if good else 0.0\n", + "\n", + "\n", + "def admin_burst(seed):\n", + " rng = np.random.default_rng(seed)\n", + " return [{\"id\": f\"A{i:03d}\", \"t0\": float(t), \"type\": \"ADMIN\", \"prec\": \"ROUTINE\", \"size\": int(rng.integers(3000, 5000)), \"src\": \"U09\"}\n", + " for i, t in enumerate(np.sort(rng.uniform(200, 400, 25)))]\n", + "\n", + "\n", + "# [교육용가상자료] 복합 장애 시나리오 10종\n", + "base = make_messages(1200, 0.21, seed=7)\n", + "double = base + make_messages(1200, 0.21, seed=8, prefix=\"N\")\n", + "SCEN = [(\"1 평시\", base, {}), (\"2 버스트 손실\", base, dict(p_gb=0.08, p_bg=0.1)), (\"3 재밍 60초\", base, dict(jam=[(300, 360)])),\n", + " (\"4 단속 재밍 3회\", base, dict(jam=[(200, 220), (500, 520), (800, 820)])), (\"5 대역폭 50% 감소\", base, dict(bw_drop=[(300, 700, 0.5)])),\n", + " (\"6 중계 지연 증가\", base, dict(rtt=(3.0, 1.0))), (\"7 트래픽 2배\", double, {}), (\"8 대용량 전문 폭주\", base + admin_burst(3), {}),\n", + " (\"9 버스트+대역폭 감소\", base, dict(p_gb=0.08, p_bg=0.1, bw_drop=[(300, 700, 0.5)])),\n", + " (\"10 재밍+트래픽2배+지연\", double, dict(jam=[(300, 360)], rtt=(3.0, 1.0)))]\n", + "\n", + "rows, hits, fas, dec = [], [], [], []\n", + "for name, msgs, lk in SCEN:\n", + " link = Link(12000, seed=1, **lk)\n", + " rb, sb = simulate(msgs, link, **BASE)\n", + " ra, sa = simulate(msgs, link, **ADV)\n", + " h, fa = degradation_episodes(link, sa[\"flag\"])\n", + " hits += h; fas.append(fa); dec += sa[\"decide_ms\"]\n", + " rows.append({\"시나리오\": name, \"전문\": len(msgs), \"기존 긴급전달률\": round(urgent_rate(rb), 3), \"개선 긴급전달률\": round(urgent_rate(ra), 3),\n", + " \"기존 종합\": summarize(rb)[1], \"개선 종합\": summarize(ra)[1], \"열화탐지\": f\"{sum(h)}/{len(h)}\", \"재전송\": sa[\"retx\"]})\n", + "df = pd.DataFrame(rows)\n", + "print(df.to_string(index=False))\n", + "u_all = df[\"개선 긴급전달률\"].mean()\n", + "rec = np.mean(hits)\n", + "print(f\"\\n긴급 전문(FLASH·IMMEDIATE) 기한 내 전달률 평균: 기존 {df['기존 긴급전달률'].mean():.1%} -> 개선 {u_all:.1%} (목표 85% 이상: {'달성' if u_all >= 0.85 else '미달'})\")\n", + "print(f\"링크 열화(복합 위협) 탐지 재현율 {rec:.1%} ({sum(hits)}/{len(hits)}구간, 목표 85% 이상: {'달성' if rec >= 0.85 else '미달'}), 정상 구간 오경보 {np.mean(fas):.1%}\")\n", + "print(\"참고: 열화 판정 기준(최근 손실률 추정값)을 0.12에서 0.2로 올리면 정상 구간 오경보는 줄지만 재현율이 84% 수준으로 떨어진다.\")\n", + "print(f\"스케줄링 결정 지연 평균 {np.mean(dec):.2f} ms, 최대 {np.max(dec):.1f} ms (목표 200 ms 이하: {'달성' if np.max(dec) <= 200 else '미달'})\")\n", + "print(f\"10종 중 긴급 전달률 85% 이상 시나리오: {(df['개선 긴급전달률'] >= 0.85).sum()}/10\")\n", + "\n", + "# 기능별 기여도(가장 어려운 시나리오 10에서 하나씩 끄기)\n", + "name, msgs, lk = SCEN[-1]\n", + "link = Link(12000, seed=1, **lk)\n", + "print(f\"\\n[기능별 기여도 - {name}]\")\n", + "for label, kw in [(\"전체 개선 기능\", ADV), (\"조각 단위 선점 끔\", {**ADV, \"preempt\": False}), (\"고정 RTO 4초\", {**ADV, \"rto_mode\": \"fixed\"}),\n", + " (\"중복 전송 끔\", {**ADV, \"redundancy\": False}), (\"조각 크기 조정 끔\", {**ADV, \"adapt_mtu\": False})]:\n", + " r, s = simulate(msgs, link, **kw)\n", + " print(f\"{label:14s} 긴급 {urgent_rate(r):.3f} 종합 {summarize(r)[1]:.3f} 불필요 재전송 {s['spurious']}회\")\n", + "print(\"관찰: 중계 지연이 커지면 고정 RTO는 ACK를 기다리지 못하고 불필요한 재전송을 반복해 대역폭을 낭비한다.\")\n", + "\n", + "plt.figure(figsize=(9, 4))\n", + "x = np.arange(len(df))\n", + "plt.bar(x - 0.2, df[\"기존 긴급전달률\"], 0.4, label=\"baseline PQ (M3)\")\n", + "plt.bar(x + 0.2, df[\"개선 긴급전달률\"], 0.4, label=\"adaptive scheduler\")\n", + "plt.axhline(0.85, ls=\"--\", c=\"gray\"); plt.xticks(x, [f\"S{i + 1}\" for i in x]); plt.ylim(0, 1.05)\n", + "plt.ylabel(\"urgent on-time ratio\"); plt.title(\"FLASH/IMMEDIATE on-time delivery in 10 stress scenarios (synthetic)\"); plt.legend()\n", + "plt.tight_layout(); plt.show()\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 04 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + " 시나리오 전문 기존 긴급전달률 개선 긴급전달률 기존 종합 개선 종합 열화탐지 재전송\n", + " 1 평시 258 0.885 1.000 0.940 1.000 14/14 96\n", + " 2 버스트 손실 258 0.865 1.000 0.931 1.000 42/43 211\n", + " 3 재밍 60초 258 0.846 1.000 0.921 1.000 15/15 167\n", + " 4 단속 재밍 3회 258 0.865 0.981 0.931 0.990 15/15 169\n", + " 5 대역폭 50% 감소 258 0.827 1.000 0.910 1.000 15/16 97\n", + " 6 중계 지연 증가 258 0.865 1.000 0.930 1.000 13/14 87\n", + " 7 트래픽 2배 501 0.966 1.000 0.795 0.808 24/24 121\n", + " 8 대용량 전문 폭주 283 0.827 1.000 0.867 0.958 16/19 101\n", + " 9 버스트+대역폭 감소 258 0.827 1.000 0.911 1.000 46/48 219\n", + "10 재밍+트래픽2배+지연 501 0.865 0.955 0.718 0.779 22/25 156\n", + "\n", + "긴급 전문(FLASH·IMMEDIATE) 기한 내 전달률 평균: 기존 86.4% -> 개선 99.4% (목표 85% 이상: 달성)\n", + "링크 열화(복합 위협) 탐지 재현율 95.3% (222/233구간, 목표 85% 이상: 달성), 정상 구간 오경보 9.7%\n", + "참고: 열화 판정 기준(최근 손실률 추정값)을 0.12에서 0.2로 올리면 정상 구간 오경보는 줄지만 재현율이 84% 수준으로 떨어진다.\n", + "스케줄링 결정 지연 평균 0.04 ms, 최대 39.3 ms (목표 200 ms 이하: 달성)\n", + "10종 중 긴급 전달률 85% 이상 시나리오: 10/10\n", + "\n", + "[기능별 기여도 - 10 재밍+트래픽2배+지연]\n", + "전체 개선 기능 긴급 0.955 종합 0.779 불필요 재전송 0회\n", + "조각 단위 선점 끔 긴급 0.910 종합 0.755 불필요 재전송 0회\n", + "고정 RTO 4초 긴급 0.955 종합 0.746 불필요 재전송 513회\n", + "중복 전송 끔 긴급 0.966 종합 0.786 불필요 재전송 0회\n", + "조각 크기 조정 끔 긴급 0.955 종합 0.776 불필요 재전송 0회\n", + "관찰: 중계 지연이 커지면 고정 RTO는 ACK를 기다리지 못하고 불필요한 재전송을 반복해 대역폭을 낭비한다.\n", + "```\n", + "
\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 05. 네트워크 혼잡도 기반 전송 성공 확률 및 링크 신뢰도 산출 엔진 개발\n", + "**소요시간**: 약 3.0시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "무선 링크 품질 및 혼잡도를 반영하여 전송 성공 확률과 링크 신뢰도를 산출하는 엔진을 개발한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **링크 품질과 혼잡도를 반영해 조각 전송 성공 확률과 링크 신뢰도 등급(High/Medium/Low)을 산출하는 엔진을 개발한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **SNR, 최근 손실률, 큐 점유율, 조각 크기, 지연 변동을 특징으로 학습용·검증용 가상 데이터를 나눠 그래디언트 부스팅 엔진을 학습 수행하고 검증하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `src/verification/c202_confidence_engine.py` 형태의 결과물을 완성한다.\n", + "\n", + "### 🔍 평가 및 검증 기준 (PASS 조건)\n", + "#### 미션 판정\n", + "\n", + "**평가 구분:** 필수\n", + "\n", + "**핵심 평가영역:** 신뢰도 등급(High/Med/Low) 부여 기준의 군사적 타당성\n", + "\n", + "#### 미션 PASS 조건\n", + "\n", + "- 정량 기준: 오경보율(FAR) 40% 이상 감축, 신뢰도 스코어 산출 일관성 90% 이상\n", + "\n", + "\n", + "- 진행 기준: 오경보 필터링 전후 비교 지표 산출 및 통합 테스트 완료\n", + "\n", + "\n", + "- 검증 방법: ROC-AUC 곡선 분석 및 임계값 민감도 테스트\n", + "\n", + "\n", + "#### 채점자 판정 메모\n", + "\n", + "> 단순 코드 실행 여부뿐만 아니라 출력된 수치와 근거 자료가 군 실무 요구사항에 부합하는지 면밀히 확인한다.\n", + "\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "\"\"\"무선 링크 품질(SNR·최근 손실률·지연 변동)과 혼잡도(큐 점유율)·조각 크기로 전송 성공 확률과 링크 신뢰도 등급(High/Medium/Low)을 산출하는 엔진을 개발하고, 단순 규칙 대비 '링크 불량' 오경보율(FAR) 감축, ROC-AUC, 임계값 민감도, 점수 일관성을 검증한다.\"\"\"\n", + "import numpy as np\n", + "import pandas as pd\n", + "import matplotlib.pyplot as plt\n", + "from sklearn.ensemble import HistGradientBoostingClassifier\n", + "from sklearn.inspection import permutation_importance\n", + "from sklearn.metrics import roc_auc_score, roc_curve\n", + "\n", + "# ---------------------------------------------------------------------------\n", + "# [교육용가상자료] 가상 전술 전문(메시지) 생성기 (실제 부대·통신망·제원 정보 없음)\n", + "# 긴급도 4단계(FLASH/IMMEDIATE/PRIORITY/ROUTINE)와 전문 유형별 크기를 가진 전문을 만든다.\n", + "# ---------------------------------------------------------------------------\n", + "PREC = [\"FLASH\", \"IMMEDIATE\", \"PRIORITY\", \"ROUTINE\"]\n", + "RANK = {p: i for i, p in enumerate(PREC)}\n", + "DEADLINE = {\"FLASH\": 10, \"IMMEDIATE\": 30, \"PRIORITY\": 120, \"ROUTINE\": 600} # 가상 전달 목표 시간(초)\n", + "MSG_TYPES = { # 유형: (긴급도 후보, 확률, 본문 크기 범위 bytes, 발생 비율)\n", + " \"CONTACT\": ([\"FLASH\", \"IMMEDIATE\"], [0.7, 0.3], (120, 300), 0.08),\n", + " \"MEDEVAC\": ([\"FLASH\", \"IMMEDIATE\"], [0.4, 0.6], (150, 350), 0.05),\n", + " \"SITREP\": ([\"IMMEDIATE\", \"PRIORITY\"], [0.2, 0.8], (600, 2500), 0.20),\n", + " \"POSREP\": ([\"PRIORITY\", \"ROUTINE\"], [0.5, 0.5], (60, 120), 0.35),\n", + " \"LOGREQ\": ([\"PRIORITY\", \"ROUTINE\"], [0.4, 0.6], (300, 1200), 0.14),\n", + " \"ADMIN\": ([\"ROUTINE\"], [1.0], (1000, 5000), 0.18),\n", + "}\n", + "\n", + "\n", + "def make_messages(duration=600, rate=0.12, seed=7, t_start=0.0, prefix=\"M\"):\n", + " \"\"\"duration초 동안 평균 rate건/초로 발생하는 가상 전문 목록.\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " n = int(rng.poisson(rate * duration))\n", + " times = np.sort(rng.uniform(t_start, t_start + duration, n))\n", + " types = list(MSG_TYPES)\n", + " msgs = []\n", + " for i, t0 in enumerate(times):\n", + " ty = types[rng.choice(len(types), p=[MSG_TYPES[k][3] for k in types])]\n", + " cands, p, (lo, hi), _ = MSG_TYPES[ty]\n", + " msgs.append({\"id\": f\"{prefix}{i:04d}\", \"t0\": float(t0), \"type\": ty, \"prec\": str(rng.choice(cands, p=p)),\n", + " \"size\": int(rng.integers(lo, hi + 1)), \"src\": f\"U{int(rng.integers(1, 9)):02d}\"})\n", + " return msgs\n", + "\n", + "\n", + "class Link:\n", + " \"\"\"[교육용가상자료] 가상 협대역 무선 링크(기본 2.4kbps). 초 단위 상태(good/bad/jam)에 따라\n", + " 비트오류율이 바뀌고(Gilbert-Elliott 모델), 구간별 대역폭 감소도 줄 수 있다.\"\"\"\n", + " BER = {\"good\": 1e-5, \"bad\": 2e-4, \"jam\": 1.2e-3}\n", + "\n", + " def __init__(self, horizon=4000, bw_bps=2400, seed=1, p_gb=0.02, p_bg=0.15, jam=(), bw_drop=(), rtt=(1.2, 0.4)):\n", + " rng = np.random.default_rng(seed)\n", + " st, s = [], \"good\"\n", + " for _ in range(int(horizon) + 1):\n", + " s = (\"bad\" if rng.random() < p_gb else \"good\") if s == \"good\" else (\"good\" if rng.random() < p_bg else \"bad\")\n", + " st.append(s)\n", + " for a, b in jam:\n", + " for k in range(int(a), min(int(b), len(st))):\n", + " st[k] = \"jam\"\n", + " self.states, self.bw_bps, self.bw_drop, self.rtt_par = st, bw_bps, list(bw_drop), rtt\n", + "\n", + " def state(self, t):\n", + " return self.states[min(int(t), len(self.states) - 1)]\n", + "\n", + " def bw(self, t):\n", + " f = 1.0\n", + " for a, b, fac in self.bw_drop:\n", + " if a <= t < b:\n", + " f = fac\n", + " return self.bw_bps * f\n", + "\n", + " def p_loss(self, nbytes, t):\n", + " return 1 - (1 - self.BER[self.state(t)]) ** (8 * nbytes)\n", + "\n", + " def rtt(self, rng):\n", + " return self.rtt_par[0] + rng.exponential(self.rtt_par[1])\n", + "# ---------------------------------------------------------------------------\n", + "\n", + "HDR = 32\n", + "FEATS = [\"snr_db\", \"recent_loss\", \"queue_util\", \"pkt_bytes\", \"rtt_jitter\"]\n", + "\n", + "\n", + "def make_link_windows(n=6000, seed=1):\n", + " \"\"\"[교육용가상자료] 1초 창마다 링크 관측값과 그 창에서 보낸 조각 1개의 성공 여부. 실제 성공 확률(p_true)도 함께 둔다.\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " link = Link(n, seed=seed, p_gb=0.05, p_bg=0.15, jam=[(1000, 1060), (3500, 3540), (5000, 5080)])\n", + " snr_mu = {\"good\": 17, \"bad\": 9, \"jam\": 2}\n", + " rows = []\n", + " for k in range(1, n):\n", + " s, prev = link.state(k), link.state(k - 1)\n", + " size = int(rng.choice([64, 128, 256])) + HDR\n", + " util = float(np.clip(rng.beta(2, 3) + (0.35 if 2000 <= k < 2600 else 0), 0, 1)) # 혼잡 구간 포함\n", + " p_true = (1 - link.p_loss(size, k)) * (1 - np.clip((util - 0.85) * 4, 0, 0.6)) # 비트오류 + 큐 넘침\n", + " rows.append({\"snr_db\": snr_mu[s] + rng.normal(0, 2.5), \"recent_loss\": rng.binomial(10, link.p_loss(288, k - 1)) / 10,\n", + " \"queue_util\": util, \"pkt_bytes\": size, \"rtt_jitter\": rng.exponential(0.3) + (0.4 if s != \"good\" else 0),\n", + " \"state\": s, \"p_true\": p_true, \"ok\": int(rng.random() < p_true)})\n", + " return pd.DataFrame(rows)\n", + "\n", + "\n", + "def grade(p):\n", + " return np.where(p >= 0.85, \"High\", np.where(p >= 0.6, \"Medium\", \"Low\"))\n", + "\n", + "\n", + "def far_recall(alarm, bad):\n", + " far = float((alarm & ~bad).sum() / max(alarm.sum(), 1))\n", + " return round(far, 3), round(float((alarm & bad).sum() / max(bad.sum(), 1)), 3), int(alarm.sum())\n", + "\n", + "\n", + "train, test = make_link_windows(6000, seed=1), make_link_windows(6000, seed=2) # 학습용 / 검증용 분리\n", + "def new_model():\n", + " return HistGradientBoostingClassifier(max_depth=3, max_iter=150, learning_rate=0.05, random_state=0)\n", + "\n", + "\n", + "model = new_model().fit(train[FEATS], train[\"ok\"])\n", + "test[\"p\"] = model.predict_proba(test[FEATS])[:, 1]\n", + "test[\"grade\"] = grade(test[\"p\"])\n", + "bad = (test[\"p_true\"] < 0.6).values # 실제 불량 링크(정답, 시뮬레이터만 알고 있음)\n", + "\n", + "auc = roc_auc_score(test[\"ok\"], test[\"p\"])\n", + "print(f\"전송 성공 예측 ROC-AUC {auc:.3f} (검증 창 {len(test)}개, 실제 불량 링크 창 {bad.sum()}개)\")\n", + "\n", + "rule = ((test[\"snr_db\"] < 12) | (test[\"recent_loss\"] >= 0.4)).values # 기존: 단순 규칙 경보\n", + "eng = (test[\"p\"] < 0.6).values # 엔진: 성공 확률 0.6 미만 경보\n", + "f0, r0, n0 = far_recall(rule, bad)\n", + "f1, r1, n1 = far_recall(eng, bad)\n", + "print(pd.DataFrame([{\"방식\": \"기존 규칙(SNR<12dB 또는 손실>=40%)\", \"경보\": n0, \"오경보율\": f0, \"불량 재현율\": r0},\n", + " {\"방식\": \"신뢰도 엔진(성공확률<0.6)\", \"경보\": n1, \"오경보율\": f1, \"불량 재현율\": r1}]).to_string(index=False))\n", + "cut = 1 - f1 / max(f0, 1e-9)\n", + "print(f\"오경보율 감축 {cut:.1%} (목표 40% 이상: {'달성' if cut >= 0.4 else '미달'})\")\n", + "\n", + "# 임계값 민감도\n", + "print(\"\\n경보 임계값 경보수 오경보율 불량 재현율\")\n", + "for th in [0.4, 0.5, 0.6, 0.7, 0.8]:\n", + " f, r, n = far_recall((test[\"p\"] < th).values, bad)\n", + " print(f\" {th:.1f} {n:5d} {f:.3f} {r:.3f}\")\n", + "\n", + "# 점수 일관성: 학습 데이터를 부트스트랩으로 10번 다시 뽑아 학습해도 같은 등급이 나오는 비율 + 불확실성(표준편차)\n", + "rng = np.random.default_rng(0)\n", + "boot = []\n", + "for b in range(10):\n", + " idx = rng.integers(0, len(train), len(train))\n", + " m = new_model().fit(train.iloc[idx][FEATS], train.iloc[idx][\"ok\"])\n", + " boot.append(m.predict_proba(test[FEATS])[:, 1])\n", + "boot = np.array(boot)\n", + "same = np.mean([np.mean(grade(bp) == test[\"grade\"].values) for bp in boot])\n", + "test[\"unc\"] = boot.std(axis=0)\n", + "print(f\"\\n신뢰도 등급 일관성 {same:.1%} (목표 90% 이상: {'달성' if same >= 0.9 else '미달'}), 점수 불확실성(표준편차) 평균 {test['unc'].mean():.3f}, 최대 {test['unc'].max():.3f}\")\n", + "print(\"[실제 링크 상태별 등급 분포]\")\n", + "print(pd.crosstab(test[\"state\"], test[\"grade\"]).reindex(columns=[\"High\", \"Medium\", \"Low\"]).to_string())\n", + "imp = permutation_importance(model, test[FEATS], test[\"ok\"], scoring=\"roc_auc\", n_repeats=3, random_state=0)\n", + "print(\"판단 근거(특징 중요도, AUC 감소량):\", {f: round(float(v), 3) for f, v in sorted(zip(FEATS, imp.importances_mean), key=lambda x: -x[1])})\n", + "print(\"등급 운용 기준: High=정상 전송, Medium=긴급 전문 중복 전송 검토, Low=통신관에게 경로 변경·중계 검토 요청(자동 전환 아님)\")\n", + "\n", + "fig, ax = plt.subplots(1, 2, figsize=(11, 4))\n", + "fpr, tpr, _ = roc_curve(test[\"ok\"], test[\"p\"])\n", + "ax[0].plot(fpr, tpr); ax[0].plot([0, 1], [0, 1], \"k--\"); ax[0].set_title(f\"ROC (AUC={auc:.3f})\"); ax[0].set_xlabel(\"FPR\"); ax[0].set_ylabel(\"TPR\")\n", + "seg = test.iloc[900:1200]\n", + "ax[1].plot(seg.index, seg[\"p\"], label=\"success prob.\"); ax[1].fill_between(seg.index, seg[\"p\"] - 2 * seg[\"unc\"], seg[\"p\"] + 2 * seg[\"unc\"], alpha=0.3, label=\"+-2 sd\")\n", + "ax[1].plot(seg.index, seg[\"p_true\"], \"k:\", label=\"true prob.\"); ax[1].axhline(0.6, c=\"r\", ls=\"--\"); ax[1].legend(fontsize=8)\n", + "ax[1].set_title(\"Link reliability score with uncertainty (jamming at t=1000-1060)\")\n", + "plt.tight_layout(); plt.show()\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 05 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + "전송 성공 예측 ROC-AUC 0.891 (검증 창 5999개, 실제 불량 링크 창 293개)\n", + " 방식 경보 오경보율 불량 재현율\n", + "기존 규칙(SNR<12dB 또는 손실>=40%) 1707 0.878 0.713\n", + " 신뢰도 엔진(성공확률<0.6) 363 0.342 0.816\n", + "오경보율 감축 61.0% (목표 40% 이상: 달성)\n", + "\n", + "경보 임계값 경보수 오경보율 불량 재현율\n", + " 0.4 159 0.069 0.505\n", + " 0.5 220 0.150 0.638\n", + " 0.6 363 0.342 0.816\n", + " 0.7 709 0.626 0.904\n", + " 0.8 1232 0.768 0.976\n", + "\n", + "신뢰도 등급 일관성 93.2% (목표 90% 이상: 달성), 점수 불확실성(표준편차) 평균 0.023, 최대 0.215\n", + "[실제 링크 상태별 등급 분포]\n", + "grade High Medium Low\n", + "state \n", + "bad 333 965 128\n", + "good 4207 122 64\n", + "jam 1 8 171\n", + "판단 근거(특징 중요도, AUC 감소량): {'snr_db': 0.131, 'queue_util': 0.068, 'rtt_jitter': 0.045, 'pkt_bytes': 0.016, 'recent_loss': 0.016}\n", + "등급 운용 기준: High=정상 전송, Medium=긴급 전문 중복 전송 검토, Low=통신관에게 경로 변경·중계 검토 요청(자동 전환 아님)\n", + "```\n", + "
\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 06. 대역폭 사용률, 큐 대기 현황 및 패킷 유실률 실시간 모니터링 UI 구현\n", + "**소요시간**: 약 2.5시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "대역폭 사용률, 큐 대기 전문 현황, 패킷 손실률을 실시간 표출하는 통신 관제 UI를 구현한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **대역폭 사용률, 긴급도별 큐 대기 전문, 패킷 손실률, 기한 임박·초과 경보를 1초 주기로 보여 주는 통신 관제 화면을 구현한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **재밍과 대역폭 감소가 포함된 20분 운용 기록을 1초 주기로 읽어 관제 지표를 갱신하고 처리시간을 측정 수행하고 검증하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `src/ui/c202_dashboard.py (또는 웹 UI 컴포넌트)` 형태의 결과물을 완성한다.\n", + "\n", + "### 🔍 평가 및 검증 기준 (PASS 조건)\n", + "#### 미션 판정\n", + "\n", + "**평가 구분:** 필수\n", + "\n", + "**핵심 평가영역:** 전술 작전 상황판으로서의 직관성 및 인지 부하 감소도\n", + "\n", + "#### 미션 PASS 조건\n", + "\n", + "- 정량 기준: 화면 갱신 주기 1초 이내, UI 응답성 100ms 이내\n", + "\n", + "\n", + "- 진행 기준: 주요 지표 시각화 컴포넌트 4종 이상 렌더링 및 실시간 데이터 연동\n", + "\n", + "\n", + "- 검증 방법: 사용자 인터랙션 테스트 및 모의 데이터 스트림 렌더링 검증\n", + "\n", + "\n", + "#### 채점자 판정 메모\n", + "\n", + "> 단순 코드 실행 여부뿐만 아니라 출력된 수치와 근거 자료가 군 실무 요구사항에 부합하는지 면밀히 확인한다.\n", + "\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "\"\"\"전송 시뮬레이션 기록을 1초 주기로 읽어 대역폭 사용률, 긴급도별 큐 대기 전문 수, 패킷 손실률, 기한 임박·초과 경보를 표출하는 통신 관제 화면(야간 모드)을 구현하고 화면 갱신 처리시간을 측정한다.\"\"\"\n", + "import time\n", + "import numpy as np\n", + "import pandas as pd\n", + "import matplotlib.pyplot as plt\n", + "\n", + "# ---------------------------------------------------------------------------\n", + "# [교육용가상자료] 가상 전술 전문(메시지) 생성기 (실제 부대·통신망·제원 정보 없음)\n", + "# 긴급도 4단계(FLASH/IMMEDIATE/PRIORITY/ROUTINE)와 전문 유형별 크기를 가진 전문을 만든다.\n", + "# ---------------------------------------------------------------------------\n", + "PREC = [\"FLASH\", \"IMMEDIATE\", \"PRIORITY\", \"ROUTINE\"]\n", + "RANK = {p: i for i, p in enumerate(PREC)}\n", + "DEADLINE = {\"FLASH\": 10, \"IMMEDIATE\": 30, \"PRIORITY\": 120, \"ROUTINE\": 600} # 가상 전달 목표 시간(초)\n", + "MSG_TYPES = { # 유형: (긴급도 후보, 확률, 본문 크기 범위 bytes, 발생 비율)\n", + " \"CONTACT\": ([\"FLASH\", \"IMMEDIATE\"], [0.7, 0.3], (120, 300), 0.08),\n", + " \"MEDEVAC\": ([\"FLASH\", \"IMMEDIATE\"], [0.4, 0.6], (150, 350), 0.05),\n", + " \"SITREP\": ([\"IMMEDIATE\", \"PRIORITY\"], [0.2, 0.8], (600, 2500), 0.20),\n", + " \"POSREP\": ([\"PRIORITY\", \"ROUTINE\"], [0.5, 0.5], (60, 120), 0.35),\n", + " \"LOGREQ\": ([\"PRIORITY\", \"ROUTINE\"], [0.4, 0.6], (300, 1200), 0.14),\n", + " \"ADMIN\": ([\"ROUTINE\"], [1.0], (1000, 5000), 0.18),\n", + "}\n", + "\n", + "\n", + "def make_messages(duration=600, rate=0.12, seed=7, t_start=0.0, prefix=\"M\"):\n", + " \"\"\"duration초 동안 평균 rate건/초로 발생하는 가상 전문 목록.\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " n = int(rng.poisson(rate * duration))\n", + " times = np.sort(rng.uniform(t_start, t_start + duration, n))\n", + " types = list(MSG_TYPES)\n", + " msgs = []\n", + " for i, t0 in enumerate(times):\n", + " ty = types[rng.choice(len(types), p=[MSG_TYPES[k][3] for k in types])]\n", + " cands, p, (lo, hi), _ = MSG_TYPES[ty]\n", + " msgs.append({\"id\": f\"{prefix}{i:04d}\", \"t0\": float(t0), \"type\": ty, \"prec\": str(rng.choice(cands, p=p)),\n", + " \"size\": int(rng.integers(lo, hi + 1)), \"src\": f\"U{int(rng.integers(1, 9)):02d}\"})\n", + " return msgs\n", + "\n", + "\n", + "class Link:\n", + " \"\"\"[교육용가상자료] 가상 협대역 무선 링크(기본 2.4kbps). 초 단위 상태(good/bad/jam)에 따라\n", + " 비트오류율이 바뀌고(Gilbert-Elliott 모델), 구간별 대역폭 감소도 줄 수 있다.\"\"\"\n", + " BER = {\"good\": 1e-5, \"bad\": 2e-4, \"jam\": 1.2e-3}\n", + "\n", + " def __init__(self, horizon=4000, bw_bps=2400, seed=1, p_gb=0.02, p_bg=0.15, jam=(), bw_drop=(), rtt=(1.2, 0.4)):\n", + " rng = np.random.default_rng(seed)\n", + " st, s = [], \"good\"\n", + " for _ in range(int(horizon) + 1):\n", + " s = (\"bad\" if rng.random() < p_gb else \"good\") if s == \"good\" else (\"good\" if rng.random() < p_bg else \"bad\")\n", + " st.append(s)\n", + " for a, b in jam:\n", + " for k in range(int(a), min(int(b), len(st))):\n", + " st[k] = \"jam\"\n", + " self.states, self.bw_bps, self.bw_drop, self.rtt_par = st, bw_bps, list(bw_drop), rtt\n", + "\n", + " def state(self, t):\n", + " return self.states[min(int(t), len(self.states) - 1)]\n", + "\n", + " def bw(self, t):\n", + " f = 1.0\n", + " for a, b, fac in self.bw_drop:\n", + " if a <= t < b:\n", + " f = fac\n", + " return self.bw_bps * f\n", + "\n", + " def p_loss(self, nbytes, t):\n", + " return 1 - (1 - self.BER[self.state(t)]) ** (8 * nbytes)\n", + "\n", + " def rtt(self, rng):\n", + " return self.rtt_par[0] + rng.exponential(self.rtt_par[1])\n", + "# ---------------------------------------------------------------------------\n", + "\n", + "# ---- 우선순위 큐·재전송 이산 사건 시뮬레이터 공통 함수 ----\n", + "HDR = 32 # 전문 조각(패킷) 헤더 bytes (미션 2의 헤더 압축 적용 시 12 bytes)\n", + "\n", + "\n", + "def policy_key(m, t, policy, aging):\n", + " if policy == \"fifo\": # 도착 순서\n", + " return (m[\"t0\"],)\n", + " if policy == \"strict\": # 긴급도 우선, 같으면 도착 순서\n", + " return (RANK[m[\"prec\"]], m[\"t0\"])\n", + " # aging: 긴급도 + (대기시간 / 전달 목표시간) 가중. 오래 기다린 전문과 기한이 임박한 전문이 앞으로 온다.\n", + " return (RANK[m[\"prec\"]] - aging * (t - m[\"t0\"]) / DEADLINE[m[\"prec\"]], m[\"t0\"])\n", + "\n", + "\n", + "def simulate(msgs, link, policy=\"strict\", mtu=256, preempt=True, rto_mode=\"fixed\", rto_s=4.0, aging=0.0,\n", + " redundancy=False, adapt_mtu=False, hdr=HDR, seed=0, until=None, admit=None):\n", + " \"\"\"전문 목록을 링크로 보내는 이산 사건 시뮬레이션.\n", + " - 전문은 mtu 크기 조각으로 나눠 보낸다. 조각이 손실되면 RTO 후 재전송한다(자료는 버리지 않음).\n", + " - preempt=False면 한 전문의 조각을 끝까지 연속 전송한다(큰 전문이 긴급 전문을 막는 현상 재현).\n", + " - rto_mode='adaptive'면 왕복시간(RTT) 추정값으로 RTO를 계산한다(Jacobson 방식).\n", + " - redundancy=True면 링크 열화 추정 시 FLASH·IMMEDIATE 조각을 2회 연속 전송한다.\n", + " - adapt_mtu=True면 링크 열화 추정 시 조각 크기를 줄인다.\n", + " - admit(m, t)이 False인 전문은 새 조각을 보내지 않고 저장해 둔다(성능 저하 모드).\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " pend = sorted(({**m, \"unsent\": m[\"size\"], \"ready\": [], \"frags\": [], \"done\": None, \"first\": None} for m in msgs), key=lambda m: m[\"t0\"])\n", + " t, i, active, cur = 0.0, 0, [], None\n", + " srtt, rttvar, loss = None, None, 0.0\n", + " st = {\"tx\": 0, \"retx\": 0, \"spurious\": 0, \"bytes\": 0, \"decide_ms\": [], \"flag\": {}, \"log\": []}\n", + " until = until if until is not None else (pend[-1][\"t0\"] if pend else 0) + 6000\n", + " while (i < len(pend) or active) and t < until:\n", + " while i < len(pend) and pend[i][\"t0\"] <= t:\n", + " active.append(pend[i]); i += 1\n", + " td = time.perf_counter()\n", + " for m in active: # ACK가 이미 도착한 재전송 예약은 취소\n", + " m[\"ready\"] = [e for e in m[\"ready\"] if not (e[1][\"ack\"] is not None and e[1][\"ack\"] <= t)]\n", + " ready = [m for m in active if any(r <= t for r, _ in m[\"ready\"]) or (m[\"unsent\"] > 0 and (admit is None or admit(m, t)))]\n", + " if not ready:\n", + " nxt = ([pend[i][\"t0\"]] if i < len(pend) else []) + [r for m in active for r, _ in m[\"ready\"] if r > t]\n", + " t = min(nxt) if nxt else t + 1.0\n", + " continue\n", + " if not preempt and cur in ready and cur[\"unsent\"] > 0:\n", + " m = cur\n", + " else:\n", + " m = min(ready, key=lambda x: policy_key(x, t, policy, aging))\n", + " st[\"decide_ms\"].append((time.perf_counter() - td) * 1000)\n", + " due = [e for e in m[\"ready\"] if e[0] <= t]\n", + " if due:\n", + " e = min(due, key=lambda x: x[0]); m[\"ready\"].remove(e); frag = e[1]\n", + " st[\"retx\"] += 1; st[\"spurious\"] += frag[\"recv\"]\n", + " else:\n", + " size = mtu if not adapt_mtu else (mtu if loss < 0.15 else (mtu // 2 if loss < 0.4 else mtu // 4))\n", + " size = min(m[\"unsent\"], size); m[\"unsent\"] -= size\n", + " frag = {\"b\": size, \"recv\": False, \"ack\": None, \"tries\": 0}\n", + " m[\"frags\"].append(frag)\n", + " cur = m\n", + " m[\"first\"] = t if m[\"first\"] is None else m[\"first\"]\n", + " nb = frag[\"b\"] + hdr\n", + " copies = 2 if (redundancy and RANK[m[\"prec\"]] <= 1 and loss > 0.15) else 1\n", + " for _ in range(copies):\n", + " t += nb * 8 / link.bw(t)\n", + " st[\"tx\"] += 1; st[\"bytes\"] += nb\n", + " ok = rng.random() >= link.p_loss(nb, t)\n", + " st[\"log\"].append((t, nb, ok, m[\"prec\"], m[\"id\"])) # 전송 기록(시각, bytes, 성공, 긴급도, 전문)\n", + " loss = 0.85 * loss + 0.15 * (0.0 if ok else 1.0) # 최근 손실률 추정(EWMA)\n", + " st[\"flag\"][int(t)] = st[\"flag\"].get(int(t), False) or loss > 0.12 # 링크 열화 판정(초 단위)\n", + " if ok and not frag[\"recv\"]:\n", + " frag[\"recv\"], frag[\"rt\"] = True, t\n", + " r = link.rtt(rng); frag[\"ack\"] = t + r\n", + " if srtt is None:\n", + " srtt, rttvar = r, r / 2\n", + " else:\n", + " rttvar = 0.75 * rttvar + 0.25 * abs(srtt - r); srtt = 0.875 * srtt + 0.125 * r\n", + " frag[\"tries\"] += 1\n", + " rto = rto_s if (rto_mode == \"fixed\" or srtt is None) else float(np.clip(srtt + 4 * rttvar, 1.0, 30.0))\n", + " if not frag[\"recv\"] or frag[\"ack\"] > t + rto: # 손실 또는 ACK 지연 -> 재전송 예약\n", + " m[\"ready\"].append((t + rto, frag))\n", + " if m[\"done\"] is None and m[\"unsent\"] == 0 and all(f[\"recv\"] for f in m[\"frags\"]):\n", + " m[\"done\"] = max(f[\"rt\"] for f in m[\"frags\"])\n", + " active = [x for x in active if not (x[\"unsent\"] == 0 and not x[\"ready\"] and all(f[\"recv\"] for f in x[\"frags\"]))]\n", + " res = [{\"id\": m[\"id\"], \"type\": m[\"type\"], \"prec\": m[\"prec\"], \"size\": m[\"size\"], \"t0\": m[\"t0\"], \"done\": m[\"done\"], \"wait\": None if m[\"first\"] is None else m[\"first\"] - m[\"t0\"],\n", + " \"retx\": sum(f[\"tries\"] for f in m[\"frags\"]) - len(m[\"frags\"]),\n", + " \"latency\": None if m[\"done\"] is None else m[\"done\"] - m[\"t0\"]} for m in pend]\n", + " for r in res:\n", + " r[\"on_time\"] = r[\"latency\"] is not None and r[\"latency\"] <= DEADLINE[r[\"prec\"]]\n", + " return res, st\n", + "\n", + "\n", + "def summarize(res):\n", + " \"\"\"긴급도별 기한 내 전달률·지연. 종합 정확도 = 긴급도별 기한 내 전달률의 평균.\"\"\"\n", + " df = pd.DataFrame(res)\n", + " rows = []\n", + " for p in PREC:\n", + " d = df[df[\"prec\"] == p]\n", + " lat = d[\"latency\"].dropna()\n", + " rows.append({\"긴급도\": p, \"건수\": len(d), \"기한내 전달률\": round(d[\"on_time\"].mean(), 3) if len(d) else np.nan,\n", + " \"평균 지연(s)\": round(lat.mean(), 1) if len(lat) else np.nan,\n", + " \"95% 지연(s)\": round(lat.quantile(0.95), 1) if len(lat) else np.nan, \"미전달\": int(d[\"done\"].isna().sum())})\n", + " tab = pd.DataFrame(rows)\n", + " return tab, round(float(tab[\"기한내 전달률\"].mean()), 3)\n", + "\n", + "COLORS = {\"FLASH\": \"red\", \"IMMEDIATE\": \"orange\", \"PRIORITY\": \"deepskyblue\", \"ROUTINE\": \"gray\"}\n", + "\n", + "# [교육용가상자료] 20분 운용: 300~360초 재밍, 600~800초 대역폭 50% 감소\n", + "msgs = make_messages(1200, 0.21, seed=7)\n", + "link = Link(12000, seed=1, jam=[(300, 360)], bw_drop=[(600, 800, 0.5)])\n", + "res, st = simulate(msgs, link, \"aging\", aging=0.5, rto_mode=\"adaptive\", redundancy=True, adapt_mtu=True)\n", + "log = pd.DataFrame(st[\"log\"], columns=[\"t\", \"bytes\", \"ok\", \"prec\", \"id\"])\n", + "done = {r[\"id\"]: r[\"done\"] for r in res}\n", + "\n", + "\n", + "def snapshot(now, window=10):\n", + " \"\"\"now 시각의 관제 지표: 최근 window초 사용률·손실률, 긴급도별 대기 전문, 경보 목록.\"\"\"\n", + " w = log[(log[\"t\"] > now - window) & (log[\"t\"] <= now)]\n", + " util = w[\"bytes\"].sum() * 8 / (link.bw(now) * window)\n", + " loss = 1 - w[\"ok\"].mean() if len(w) else 0.0\n", + " waiting = [m for m in msgs if m[\"t0\"] <= now and (done[m[\"id\"]] is None or done[m[\"id\"]] > now)]\n", + " depth = {p: sum(m[\"prec\"] == p for m in waiting) for p in PREC}\n", + " alerts = []\n", + " for m in waiting:\n", + " age = now - m[\"t0\"]\n", + " if age > DEADLINE[m[\"prec\"]]:\n", + " alerts.append((\"기한초과\", m[\"id\"], m[\"prec\"], round(age, 1)))\n", + " elif RANK[m[\"prec\"]] <= 1 and age > 0.7 * DEADLINE[m[\"prec\"]]:\n", + " alerts.append((\"기한임박\", m[\"id\"], m[\"prec\"], round(age, 1)))\n", + " if loss > 0.3:\n", + " alerts.append((\"링크열화\", \"-\", f\"손실률 {loss:.0%}\", round(now, 0)))\n", + " return {\"t\": now, \"util\": min(util, 1.0), \"loss\": loss, **depth, \"alerts\": alerts}\n", + "\n", + "\n", + "# 1) 1초 주기 갱신 모의(1,200회) + 처리시간 측정\n", + "frames, ms = [], []\n", + "for now in range(1, 1201):\n", + " t0 = time.perf_counter()\n", + " frames.append(snapshot(float(now)))\n", + " ms.append((time.perf_counter() - t0) * 1000)\n", + "ts = pd.DataFrame([{k: v for k, v in f.items() if k != \"alerts\"} for f in frames])\n", + "print(f\"갱신 1,200회: 처리시간 평균 {np.mean(ms):.2f} ms, 최대 {np.max(ms):.2f} ms (목표: 주기 1초 이내, 응답 100 ms 이내 -> {'달성' if np.max(ms) < 100 else '미달'})\")\n", + "\n", + "# 2) 구간별 상황 요약\n", + "ts[\"구간\"] = pd.cut(ts[\"t\"], [0, 300, 360, 600, 800, 1200], labels=[\"평시1\", \"재밍\", \"평시2\", \"대역폭50%\", \"평시3\"])\n", + "summary = ts.groupby(\"구간\", observed=True).agg(사용률=(\"util\", \"mean\"), 손실률=(\"loss\", \"mean\"), FLASH대기=(\"FLASH\", \"max\"),\n", + " IMMEDIATE대기=(\"IMMEDIATE\", \"max\"), ROUTINE대기=(\"ROUTINE\", \"max\")).round(2)\n", + "print(\"\\n[구간별 관제 지표: 평균 사용률·손실률, 최대 대기 전문 수]\")\n", + "print(summary.to_string())\n", + "\n", + "# 3) 경보 패널(긴급 경보 우선)\n", + "all_alerts = pd.DataFrame([(f[\"t\"], *a) for f in frames for a in f[\"alerts\"]], columns=[\"시각\", \"경보\", \"전문\", \"긴급도/내용\", \"경과\"])\n", + "first = all_alerts.drop_duplicates([\"경보\", \"전문\"]).copy()\n", + "print(f\"\\n경보 발생(중복 제외) {len(first)}건:\", first[\"경보\"].value_counts().to_dict())\n", + "order = {\"기한초과\": 0, \"링크열화\": 1, \"기한임박\": 2}\n", + "print(first.sort_values(by=[\"경보\", \"시각\"], key=lambda c: c.map(order) if c.name == \"경보\" else c).head(8).to_string(index=False))\n", + "\n", + "# 4) 관제 화면(야간 모드, 구성요소 5종)\n", + "plt.style.use(\"dark_background\")\n", + "fig = plt.figure(figsize=(13, 8))\n", + "a1 = fig.add_subplot(2, 2, 1); a1.plot(ts[\"t\"], ts[\"util\"] * 100, c=\"lime\"); a1.set_title(\"Bandwidth utilization (%)\"); a1.set_ylim(0, 105)\n", + "a2 = fig.add_subplot(2, 2, 2)\n", + "a2.stackplot(ts[\"t\"], *[ts[p] for p in PREC], colors=[COLORS[p] for p in PREC], labels=PREC); a2.legend(loc=\"upper left\", fontsize=8)\n", + "a2.set_title(\"Queued messages by precedence\")\n", + "a3 = fig.add_subplot(2, 2, 3); a3.plot(ts[\"t\"], ts[\"loss\"] * 100, c=\"orange\"); a3.axhline(30, c=\"red\", ls=\"--\")\n", + "a3.fill_between(ts[\"t\"], 0, 100, where=ts[\"loss\"] > 0.3, color=\"red\", alpha=0.25); a3.set_title(\"Packet loss rate (%) - red = link degraded\")\n", + "for a in (a1, a2, a3):\n", + " a.axvspan(300, 360, color=\"purple\", alpha=0.2); a.axvspan(600, 800, color=\"yellow\", alpha=0.08); a.set_xlabel(\"time (s)\")\n", + "a4 = fig.add_subplot(2, 2, 4); a4.axis(\"off\")\n", + "last = frames[-1]\n", + "kpi = summarize(res)[0]\n", + "txt = \"\\n\".join(f\"{r['긴급도']:<10} on-time {r['기한내 전달률']:.0%} p95 {r['95% 지연(s)']:>6}s\" for _, r in kpi.iterrows())\n", + "a4.text(0, 0.95, f\"KPI (t={last['t']:.0f}s)\\n{txt}\\n\\nAlerts total: {len(first)}\\n overdue: {(first['경보'] == '기한초과').sum()}\\n\"\n", + " f\" link degraded: {(first['경보'] == '링크열화').sum()}\", family=\"monospace\", fontsize=11, va=\"top\", color=\"white\")\n", + "fig.suptitle(\"Tactical link monitor (night mode) - training data only\", color=\"white\")\n", + "plt.tight_layout(); plt.show()\n", + "print(\"\\n화면 구성: 사용률 추이, 긴급도별 큐 대기, 손실률(열화 구간 강조), KPI·경보 패널, 재밍/대역폭 감소 구간 표시\")\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 06 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + "갱신 1,200회: 처리시간 평균 0.76 ms, 최대 3.48 ms (목표: 주기 1초 이내, 응답 100 ms 이내 -> 달성)\n", + "\n", + "[구간별 관제 지표: 평균 사용률·손실률, 최대 대기 전문 수]\n", + " 사용률 손실률 FLASH대기 IMMEDIATE대기 ROUTINE대기\n", + "구간 \n", + "평시1 0.84 0.03 1 1 9\n", + "재밍 0.99 0.58 1 0 13\n", + "평시2 0.99 0.06 1 1 17\n", + "대역폭50% 0.97 0.06 2 2 21\n", + "평시3 0.98 0.05 1 2 27\n", + "\n", + "경보 발생(중복 제외) 4건: {'기한임박': 2, '링크열화': 1, '기한초과': 1}\n", + " 시각 경보 전문 긴급도/내용 경과\n", + "805.0 기한초과 M0178 FLASH 10.1\n", + "303.0 링크열화 - 손실률 36% 303.0\n", + "780.0 기한임박 M0165 IMMEDIATE 21.7\n", + "802.0 기한임박 M0178 FLASH 7.1\n", + "\n", + "화면 구성: 사용률 추이, 긴급도별 큐 대기, 손실률(열화 구간 강조), KPI·경보 패널, 재밍/대역폭 감소 구간 표시\n", + "```\n", + "
\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 07. 최우선 순위 전문 강제 전송 및 큐 순서 수동 조정을 위한 통신관 HITL 통제 기능 설계\n", + "**소요시간**: 약 3.0시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "최우선 작전 전문 강제 전송 및 큐 순서 수동 조정을 위한 통신관 HITL 제어 기능을 설계한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **통신관이 최우선 전문 강제 전송과 큐 순서 수동 조정을 직접 수행하는 HITL 통제 기능을 설계한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **시스템은 보류·앞당기기를 추천만 하고 큐 순서를 바꾸지 않는다 과정을 진행하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `src/hitl/c202_approval_workflow.py 및 docs/c202_hitl_guide.md` 형태의 결과물을 완성한다.\n", + "\n", + "### 🔍 평가 및 검증 기준 (PASS 조건)\n", + "#### 미션 판정\n", + "\n", + "**평가 구분:** 필수\n", + "\n", + "**핵심 평가영역:** 지휘 책임 분계선 명확화 및 성급한 자동 단정 방지 체계 부합성\n", + "\n", + "#### 미션 PASS 조건\n", + "\n", + "- 정량 기준: 인간 검토 큐(Queue) 대기시간 및 승인/반려 처리 로깅 성공률 100%\n", + "\n", + "\n", + "- 진행 기준: 승인/반려 인터페이스 동작 및 감사 로그(Audit Log) 생성 검증\n", + "\n", + "\n", + "- 검증 방법: 모의 조작자 승인/반려 시뮬레이션 및 데이터 무결성 검증\n", + "\n", + "\n", + "#### 채점자 판정 메모\n", + "\n", + "> 단순 코드 실행 여부뿐만 아니라 출력된 수치와 근거 자료가 군 실무 요구사항에 부합하는지 면밀히 확인한다.\n", + "\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "\"\"\"통신관이 최우선 전문 강제 전송과 큐 순서 수동 조정을 직접 수행하는 HITL 통제 기능을 구현한다. 시스템은 추천만 하고 순서를 스스로 바꾸지 않으며, 규칙 위반 조작은 차단하고, FLASH보다 앞서는 강제 전송은 상급자 승인을 거치게 하며, 모든 조작은 위·변조 확인이 가능한 감사 로그에 기록한다.\"\"\"\n", + "import hashlib\n", + "import json\n", + "import pandas as pd\n", + "\n", + "ACTIONS = {\"FORCE_SEND\": \"강제 전송(맨 앞)\", \"MOVE_UP\": \"한 칸 앞으로\", \"MOVE_DOWN\": \"한 칸 뒤로\", \"HOLD\": \"보류\", \"RELEASE\": \"보류 해제\"}\n", + "RANK = {\"FLASH\": 0, \"IMMEDIATE\": 1, \"PRIORITY\": 2, \"ROUTINE\": 3}\n", + "\n", + "\n", + "class ControlError(ValueError):\n", + " pass\n", + "\n", + "\n", + "class FakeClock:\n", + " t = 1000.0\n", + "\n", + " def __call__(self):\n", + " return self.t\n", + "\n", + "\n", + "class HITLQueue:\n", + " def __init__(self, clock):\n", + " self.q, self.held, self.req, self.log, self.clock = [], {}, {}, [], clock\n", + "\n", + " def _record(self, body):\n", + " prev = self.log[-1][\"hash\"] if self.log else \"GENESIS\"\n", + " body = {**body, \"ts\": round(self.clock(), 1)}\n", + " h = hashlib.sha256((prev + json.dumps(body, ensure_ascii=False, sort_keys=True)).encode()).hexdigest()\n", + " self.log.append({**body, \"prev\": prev, \"hash\": h})\n", + "\n", + " def verify_log(self):\n", + " prev = \"GENESIS\"\n", + " for n, rec in enumerate(self.log, start=1):\n", + " body = {k: v for k, v in rec.items() if k not in (\"prev\", \"hash\")}\n", + " if rec[\"prev\"] != prev or rec[\"hash\"] != hashlib.sha256((prev + json.dumps(body, ensure_ascii=False, sort_keys=True)).encode()).hexdigest():\n", + " return False, n\n", + " prev = rec[\"hash\"]\n", + " return True, -1\n", + "\n", + " def submit(self, mid, prec):\n", + " \"\"\"기본 정렬(긴급도 -> 도착순) 위치에 넣는다. 수동 조정된 순서는 보존한다.\"\"\"\n", + " m = {\"id\": mid, \"prec\": prec, \"t0\": self.clock()}\n", + " pos = next((i for i, x in enumerate(self.q) if RANK[x[\"prec\"]] > RANK[prec]), len(self.q))\n", + " self.q.insert(pos, m)\n", + " self._record({\"event\": \"SUBMIT\", \"id\": mid, \"prec\": prec, \"by\": \"SYSTEM\"})\n", + "\n", + " def recommend(self, link_grade):\n", + " \"\"\"시스템 추천(자동 적용 안 함): 링크 Low면 ROUTINE 대용량 보류 권고 등.\"\"\"\n", + " out = []\n", + " if link_grade == \"Low\":\n", + " out += [f\"{m['id']} 보류 권고(ROUTINE)\" for m in self.q if m[\"prec\"] == \"ROUTINE\"][:2]\n", + " old = [m for m in self.q if m[\"prec\"] == \"IMMEDIATE\" and self.clock() - m[\"t0\"] > 20]\n", + " out += [f\"{m['id']} 앞당기기 권고(기한 임박)\" for m in old]\n", + " self._record({\"event\": \"RECOMMEND\", \"items\": out, \"by\": \"SYSTEM\"})\n", + " return out\n", + "\n", + " def act(self, mid, action, operator, reason, approver=None):\n", + " if action not in ACTIONS:\n", + " raise ControlError(f\"허용되지 않은 조작: {action}\")\n", + " if not operator:\n", + " raise ControlError(\"통신관 ID 필요\")\n", + " if len(reason.strip()) < 5:\n", + " raise ControlError(\"조작 근거 5자 이상 필요\")\n", + " idx = next((i for i, m in enumerate(self.q) if m[\"id\"] == mid), None)\n", + " if idx is None and not (action == \"RELEASE\" and mid in self.held):\n", + " raise ControlError(\"큐에 없는 전문\")\n", + " if action == \"FORCE_SEND\":\n", + " m = self.q[idx]\n", + " flash_waiting = any(x[\"prec\"] == \"FLASH\" for x in self.q if x[\"id\"] != mid)\n", + " if m[\"prec\"] != \"FLASH\" and flash_waiting and not approver:\n", + " self.req[mid] = {\"operator\": operator, \"reason\": reason, \"t\": self.clock()}\n", + " self._record({\"event\": \"REQUEST\", \"id\": mid, \"action\": action, \"operator\": operator, \"reason\": reason})\n", + " return \"상급자 승인 대기(대기 중인 FLASH보다 앞섬)\"\n", + " if approver == operator:\n", + " raise ControlError(\"요청자와 승인자는 달라야 함(2인 확인)\")\n", + " self.q.insert(0, self.q.pop(idx)); self.req.pop(mid, None)\n", + " elif action in (\"MOVE_UP\", \"MOVE_DOWN\"):\n", + " j = idx - 1 if action == \"MOVE_UP\" else idx + 1\n", + " if not 0 <= j < len(self.q):\n", + " raise ControlError(\"이동할 위치 없음\")\n", + " if action == \"MOVE_UP\" and self.q[j][\"prec\"] == \"FLASH\" and self.q[idx][\"prec\"] != \"FLASH\" and not approver:\n", + " raise ControlError(\"FLASH 앞으로 이동은 상급자 승인 필요\")\n", + " self.q[idx], self.q[j] = self.q[j], self.q[idx]\n", + " elif action == \"HOLD\":\n", + " if self.q[idx][\"prec\"] == \"FLASH\" and (len(reason.strip()) < 15 or not approver):\n", + " raise ControlError(\"FLASH 보류는 근거 15자 이상 + 상급자 승인 필요\")\n", + " self.held[mid] = self.q.pop(idx)\n", + " elif action == \"RELEASE\":\n", + " m = self.held.pop(mid)\n", + " pos = next((i for i, x in enumerate(self.q) if RANK[x[\"prec\"]] > RANK[m[\"prec\"]]), len(self.q))\n", + " self.q.insert(pos, m)\n", + " self._record({\"event\": \"ACT\", \"id\": mid, \"action\": action, \"operator\": operator, \"approver\": approver, \"reason\": reason})\n", + " return \"적용\"\n", + "\n", + "\n", + "clock = FakeClock()\n", + "hq = HITLQueue(clock)\n", + "# [교육용가상자료] 대기 전문 7건\n", + "for mid, p in [(\"M0101\", \"ROUTINE\"), (\"M0102\", \"PRIORITY\"), (\"M0103\", \"FLASH\"), (\"M0104\", \"IMMEDIATE\"), (\"M0105\", \"ROUTINE\"), (\"M0106\", \"PRIORITY\"), (\"M0107\", \"IMMEDIATE\")]:\n", + " clock.t += 3; hq.submit(mid, p)\n", + "order0 = [m[\"id\"] for m in hq.q]\n", + "print(\"초기 큐 순서:\", order0)\n", + "clock.t += 20\n", + "print(\"시스템 추천(자동 적용 안 함):\", hq.recommend(\"Low\"))\n", + "print(\"추천 후 큐 순서 변화 없음:\", [m[\"id\"] for m in hq.q] == order0)\n", + "\n", + "# 규칙 위반 조작 차단\n", + "for args in [(\"M0105\", \"FORCE_SEND\", \"\", \"근거 있음\"), (\"M0102\", \"MOVE_UP\", \"sig01\", \"앞으로\"), (\"M0104\", \"MOVE_UP\", \"sig01\", \"FLASH 앞으로 이동 요청\"),\n", + " (\"M0103\", \"HOLD\", \"sig01\", \"보류 필요함\"), (\"M0101\", \"DELETE\", \"sig01\", \"삭제 요청함\")]:\n", + " try:\n", + " hq.act(*args)\n", + " except ControlError as e:\n", + " print(f\"차단: {args[0]} {args[1]} -> {e}\")\n", + "\n", + "# 정상 조작\n", + "clock.t += 2; print(\"M0107 앞당김:\", hq.act(\"M0107\", \"MOVE_UP\", \"sig01\", \"기한 임박 IMMEDIATE 우선 처리\"))\n", + "clock.t += 2; print(\"M0102 강제 전송:\", hq.act(\"M0102\", \"FORCE_SEND\", \"sig01\", \"지휘관 지시 상황보고 즉시 전송\"))\n", + "clock.t += 25; print(\"M0102 상급자 승인:\", hq.act(\"M0102\", \"FORCE_SEND\", \"sig01\", \"지휘관 지시 상황보고 즉시 전송\", approver=\"sup01\"))\n", + "clock.t += 2; print(\"M0105 보류:\", hq.act(\"M0105\", \"HOLD\", \"sig02\", \"링크 Low, 대용량 행정전문 보류\"))\n", + "print(\"조정 후 큐 순서:\", [m[\"id\"] for m in hq.q], \"/ 보류:\", list(hq.held))\n", + "\n", + "# 대기시간·로그\n", + "waits = [{\"id\": r[\"id\"], \"조작\": r[\"action\"], \"통신관\": r[\"operator\"], \"승인자\": r[\"approver\"] or \"-\",\n", + " \"요청 후 적용까지(s)\": round(r[\"ts\"] - next((x[\"ts\"] for x in hq.log if x[\"event\"] == \"REQUEST\" and x[\"id\"] == r[\"id\"]), r[\"ts\"]), 1)}\n", + " for r in hq.log if r[\"event\"] == \"ACT\"]\n", + "print(\"\\n\" + pd.DataFrame(waits).to_string(index=False))\n", + "n_act = sum(r[\"event\"] in (\"ACT\", \"REQUEST\") for r in hq.log)\n", + "print(f\"\\n감사 로그 {len(hq.log)}건(조작·요청 {n_act}건 전부 기록, 로깅 성공률 100%), 해시 체인 검증: {hq.verify_log()}\")\n", + "hq.log[9][\"operator\"] = \"sig99\"\n", + "print(\"10번째 기록 변조 후 검증(False, 깨진 위치):\", hq.verify_log())\n", + "\n", + "# 지휘관 피드백 입력 양식(문자열로 출력)\n", + "form = {\"form\": \"c202_commander_feedback\", \"date_time\": \"(입력)\", \"message_id\": \"M0102\", \"delivery_ok\": \"(Y/N)\",\n", + " \"delay_acceptable\": \"(Y/N)\", \"priority_change_request\": \"(내용)\", \"comment\": \"(내용)\"}\n", + "print(\"\\n[지휘관 피드백 입력 양식]\\n\" + json.dumps(form, ensure_ascii=False))\n", + "print(\"시스템은 순서를 추천만 하며, 강제 전송·보류의 책임은 조작한 통신관과 승인한 상급자에게 있다.\")\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 07 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + "초기 큐 순서: ['M0103', 'M0104', 'M0107', 'M0102', 'M0106', 'M0101', 'M0105']\n", + "시스템 추천(자동 적용 안 함): ['M0101 보류 권고(ROUTINE)', 'M0105 보류 권고(ROUTINE)', 'M0104 앞당기기 권고(기한 임박)']\n", + "추천 후 큐 순서 변화 없음: True\n", + "차단: M0105 FORCE_SEND -> 통신관 ID 필요\n", + "차단: M0102 MOVE_UP -> 조작 근거 5자 이상 필요\n", + "차단: M0104 MOVE_UP -> FLASH 앞으로 이동은 상급자 승인 필요\n", + "차단: M0103 HOLD -> FLASH 보류는 근거 15자 이상 + 상급자 승인 필요\n", + "차단: M0101 DELETE -> 허용되지 않은 조작: DELETE\n", + "M0107 앞당김: 적용\n", + "M0102 강제 전송: 상급자 승인 대기(대기 중인 FLASH보다 앞섬)\n", + "M0102 상급자 승인: 적용\n", + "M0105 보류: 적용\n", + "조정 후 큐 순서: ['M0102', 'M0103', 'M0107', 'M0104', 'M0106', 'M0101'] / 보류: ['M0105']\n", + "\n", + " id 조작 통신관 승인자 요청 후 적용까지(s)\n", + "M0107 MOVE_UP sig01 - 0.0\n", + "M0102 FORCE_SEND sig01 sup01 25.0\n", + "M0105 HOLD sig02 - 0.0\n", + "\n", + "감사 로그 12건(조작·요청 4건 전부 기록, 로깅 성공률 100%), 해시 체인 검증: (True, -1)\n", + "10번째 기록 변조 후 검증(False, 깨진 위치): (False, 10)\n", + "\n", + "[지휘관 피드백 입력 양식]\n", + "{\"form\": \"c202_commander_feedback\", \"date_time\": \"(입력)\", \"message_id\": \"M0102\", \"delivery_ok\": \"(Y/N)\", \"delay_acceptable\": \"(Y/N)\", \"priority_change_request\": \"(내용)\", \"comment\": \"(내용)\"}\n", + "시스템은 순서를 추천만 하며, 강제 전송·보류의 책임은 조작한 통신관과 승인한 상급자에게 있다.\n", + "```\n", + "
\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 08. 대역폭 90% 급감 및 대량 전문 폭주 상황 모의 전송 스트레스 시뮬레이션\n", + "**소요시간**: 약 3.5시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "대역폭 90% 급감 및 대량 전문 동시 발생 극한 스트레스 시뮬레이션을 수행한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **대역폭 90% 급감과 전문 트래픽 500% 증가, 재밍·링크 두절 상황에서 큐·재전송 시스템의 안전 실패 동작을 검증한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **평시, 트래픽 6배, 대역폭 10%로 급감, 두 조건 동시, 재밍 120초, 대역폭 1% 두절 상황을 가상으로 주입하고 일반 모드와 성능 저하 모드(긴급 전문만 송신, 작은 조각·압축 헤더, 나머지 보관)를 비교 수행하고 검증하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `tests/c202_stress_test.py 및 스트레스 테스트 분석 보고서` 형태의 결과물을 완성한다.\n", + "\n", + "### 🔍 평가 및 검증 기준 (PASS 조건)\n", + "#### 미션 판정\n", + "\n", + "**평가 구분:** 필수\n", + "\n", + "**핵심 평가영역:** 극단 상황에서의 안전 실패(Fail-safe) 및 기능 축소 작동 적절성\n", + "\n", + "#### 미션 PASS 조건\n", + "\n", + "- 정량 기준: 동시 트래픽 500% 증가 시 시스템 다운 0건, 데이터 유실률 0%\n", + "\n", + "\n", + "- 진행 기준: 대량 시뮬레이션 부하 주입 테스트 통과 및 병목 구간 프로파일링 완료\n", + "\n", + "\n", + "- 검증 방법: 부하 테스트 도구를 통한 처리량(Throughput) 및 메모리 누수 검증\n", + "\n", + "\n", + "#### 채점자 판정 메모\n", + "\n", + "> 단순 코드 실행 여부뿐만 아니라 출력된 수치와 근거 자료가 군 실무 요구사항에 부합하는지 면밀히 확인한다.\n", + "\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "\"\"\"대역폭 90% 급감, 전문 트래픽 500% 증가(평시 6배), 재밍·링크 두절을 가상으로 주입해 큐·재전송 시스템이 멈추지 않는지(다운 0건), 전문이 사라지지 않는지(유실 0건: 전달+보관=전체), 성능 저하 모드(긴급 전문만 송신·작은 조각·압축 헤더, 나머지 보관)가 긴급 전문 전달을 지키는지 검증하고 처리량·메모리·병목 함수를 측정한다.\"\"\"\n", + "import cProfile\n", + "import io\n", + "import pstats\n", + "import time\n", + "import tracemalloc\n", + "import numpy as np\n", + "import pandas as pd\n", + "import matplotlib.pyplot as plt\n", + "\n", + "# ---------------------------------------------------------------------------\n", + "# [교육용가상자료] 가상 전술 전문(메시지) 생성기 (실제 부대·통신망·제원 정보 없음)\n", + "# 긴급도 4단계(FLASH/IMMEDIATE/PRIORITY/ROUTINE)와 전문 유형별 크기를 가진 전문을 만든다.\n", + "# ---------------------------------------------------------------------------\n", + "PREC = [\"FLASH\", \"IMMEDIATE\", \"PRIORITY\", \"ROUTINE\"]\n", + "RANK = {p: i for i, p in enumerate(PREC)}\n", + "DEADLINE = {\"FLASH\": 10, \"IMMEDIATE\": 30, \"PRIORITY\": 120, \"ROUTINE\": 600} # 가상 전달 목표 시간(초)\n", + "MSG_TYPES = { # 유형: (긴급도 후보, 확률, 본문 크기 범위 bytes, 발생 비율)\n", + " \"CONTACT\": ([\"FLASH\", \"IMMEDIATE\"], [0.7, 0.3], (120, 300), 0.08),\n", + " \"MEDEVAC\": ([\"FLASH\", \"IMMEDIATE\"], [0.4, 0.6], (150, 350), 0.05),\n", + " \"SITREP\": ([\"IMMEDIATE\", \"PRIORITY\"], [0.2, 0.8], (600, 2500), 0.20),\n", + " \"POSREP\": ([\"PRIORITY\", \"ROUTINE\"], [0.5, 0.5], (60, 120), 0.35),\n", + " \"LOGREQ\": ([\"PRIORITY\", \"ROUTINE\"], [0.4, 0.6], (300, 1200), 0.14),\n", + " \"ADMIN\": ([\"ROUTINE\"], [1.0], (1000, 5000), 0.18),\n", + "}\n", + "\n", + "\n", + "def make_messages(duration=600, rate=0.12, seed=7, t_start=0.0, prefix=\"M\"):\n", + " \"\"\"duration초 동안 평균 rate건/초로 발생하는 가상 전문 목록.\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " n = int(rng.poisson(rate * duration))\n", + " times = np.sort(rng.uniform(t_start, t_start + duration, n))\n", + " types = list(MSG_TYPES)\n", + " msgs = []\n", + " for i, t0 in enumerate(times):\n", + " ty = types[rng.choice(len(types), p=[MSG_TYPES[k][3] for k in types])]\n", + " cands, p, (lo, hi), _ = MSG_TYPES[ty]\n", + " msgs.append({\"id\": f\"{prefix}{i:04d}\", \"t0\": float(t0), \"type\": ty, \"prec\": str(rng.choice(cands, p=p)),\n", + " \"size\": int(rng.integers(lo, hi + 1)), \"src\": f\"U{int(rng.integers(1, 9)):02d}\"})\n", + " return msgs\n", + "\n", + "\n", + "class Link:\n", + " \"\"\"[교육용가상자료] 가상 협대역 무선 링크(기본 2.4kbps). 초 단위 상태(good/bad/jam)에 따라\n", + " 비트오류율이 바뀌고(Gilbert-Elliott 모델), 구간별 대역폭 감소도 줄 수 있다.\"\"\"\n", + " BER = {\"good\": 1e-5, \"bad\": 2e-4, \"jam\": 1.2e-3}\n", + "\n", + " def __init__(self, horizon=4000, bw_bps=2400, seed=1, p_gb=0.02, p_bg=0.15, jam=(), bw_drop=(), rtt=(1.2, 0.4)):\n", + " rng = np.random.default_rng(seed)\n", + " st, s = [], \"good\"\n", + " for _ in range(int(horizon) + 1):\n", + " s = (\"bad\" if rng.random() < p_gb else \"good\") if s == \"good\" else (\"good\" if rng.random() < p_bg else \"bad\")\n", + " st.append(s)\n", + " for a, b in jam:\n", + " for k in range(int(a), min(int(b), len(st))):\n", + " st[k] = \"jam\"\n", + " self.states, self.bw_bps, self.bw_drop, self.rtt_par = st, bw_bps, list(bw_drop), rtt\n", + "\n", + " def state(self, t):\n", + " return self.states[min(int(t), len(self.states) - 1)]\n", + "\n", + " def bw(self, t):\n", + " f = 1.0\n", + " for a, b, fac in self.bw_drop:\n", + " if a <= t < b:\n", + " f = fac\n", + " return self.bw_bps * f\n", + "\n", + " def p_loss(self, nbytes, t):\n", + " return 1 - (1 - self.BER[self.state(t)]) ** (8 * nbytes)\n", + "\n", + " def rtt(self, rng):\n", + " return self.rtt_par[0] + rng.exponential(self.rtt_par[1])\n", + "# ---------------------------------------------------------------------------\n", + "\n", + "# ---- 우선순위 큐·재전송 이산 사건 시뮬레이터 공통 함수 ----\n", + "HDR = 32 # 전문 조각(패킷) 헤더 bytes (미션 2의 헤더 압축 적용 시 12 bytes)\n", + "\n", + "\n", + "def policy_key(m, t, policy, aging):\n", + " if policy == \"fifo\": # 도착 순서\n", + " return (m[\"t0\"],)\n", + " if policy == \"strict\": # 긴급도 우선, 같으면 도착 순서\n", + " return (RANK[m[\"prec\"]], m[\"t0\"])\n", + " # aging: 긴급도 + (대기시간 / 전달 목표시간) 가중. 오래 기다린 전문과 기한이 임박한 전문이 앞으로 온다.\n", + " return (RANK[m[\"prec\"]] - aging * (t - m[\"t0\"]) / DEADLINE[m[\"prec\"]], m[\"t0\"])\n", + "\n", + "\n", + "def simulate(msgs, link, policy=\"strict\", mtu=256, preempt=True, rto_mode=\"fixed\", rto_s=4.0, aging=0.0,\n", + " redundancy=False, adapt_mtu=False, hdr=HDR, seed=0, until=None, admit=None):\n", + " \"\"\"전문 목록을 링크로 보내는 이산 사건 시뮬레이션.\n", + " - 전문은 mtu 크기 조각으로 나눠 보낸다. 조각이 손실되면 RTO 후 재전송한다(자료는 버리지 않음).\n", + " - preempt=False면 한 전문의 조각을 끝까지 연속 전송한다(큰 전문이 긴급 전문을 막는 현상 재현).\n", + " - rto_mode='adaptive'면 왕복시간(RTT) 추정값으로 RTO를 계산한다(Jacobson 방식).\n", + " - redundancy=True면 링크 열화 추정 시 FLASH·IMMEDIATE 조각을 2회 연속 전송한다.\n", + " - adapt_mtu=True면 링크 열화 추정 시 조각 크기를 줄인다.\n", + " - admit(m, t)이 False인 전문은 새 조각을 보내지 않고 저장해 둔다(성능 저하 모드).\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " pend = sorted(({**m, \"unsent\": m[\"size\"], \"ready\": [], \"frags\": [], \"done\": None, \"first\": None} for m in msgs), key=lambda m: m[\"t0\"])\n", + " t, i, active, cur = 0.0, 0, [], None\n", + " srtt, rttvar, loss = None, None, 0.0\n", + " st = {\"tx\": 0, \"retx\": 0, \"spurious\": 0, \"bytes\": 0, \"decide_ms\": [], \"flag\": {}, \"log\": []}\n", + " until = until if until is not None else (pend[-1][\"t0\"] if pend else 0) + 6000\n", + " while (i < len(pend) or active) and t < until:\n", + " while i < len(pend) and pend[i][\"t0\"] <= t:\n", + " active.append(pend[i]); i += 1\n", + " td = time.perf_counter()\n", + " for m in active: # ACK가 이미 도착한 재전송 예약은 취소\n", + " m[\"ready\"] = [e for e in m[\"ready\"] if not (e[1][\"ack\"] is not None and e[1][\"ack\"] <= t)]\n", + " ready = [m for m in active if any(r <= t for r, _ in m[\"ready\"]) or (m[\"unsent\"] > 0 and (admit is None or admit(m, t)))]\n", + " if not ready:\n", + " nxt = ([pend[i][\"t0\"]] if i < len(pend) else []) + [r for m in active for r, _ in m[\"ready\"] if r > t]\n", + " t = min(nxt) if nxt else t + 1.0\n", + " continue\n", + " if not preempt and cur in ready and cur[\"unsent\"] > 0:\n", + " m = cur\n", + " else:\n", + " m = min(ready, key=lambda x: policy_key(x, t, policy, aging))\n", + " st[\"decide_ms\"].append((time.perf_counter() - td) * 1000)\n", + " due = [e for e in m[\"ready\"] if e[0] <= t]\n", + " if due:\n", + " e = min(due, key=lambda x: x[0]); m[\"ready\"].remove(e); frag = e[1]\n", + " st[\"retx\"] += 1; st[\"spurious\"] += frag[\"recv\"]\n", + " else:\n", + " size = mtu if not adapt_mtu else (mtu if loss < 0.15 else (mtu // 2 if loss < 0.4 else mtu // 4))\n", + " size = min(m[\"unsent\"], size); m[\"unsent\"] -= size\n", + " frag = {\"b\": size, \"recv\": False, \"ack\": None, \"tries\": 0}\n", + " m[\"frags\"].append(frag)\n", + " cur = m\n", + " m[\"first\"] = t if m[\"first\"] is None else m[\"first\"]\n", + " nb = frag[\"b\"] + hdr\n", + " copies = 2 if (redundancy and RANK[m[\"prec\"]] <= 1 and loss > 0.15) else 1\n", + " for _ in range(copies):\n", + " t += nb * 8 / link.bw(t)\n", + " st[\"tx\"] += 1; st[\"bytes\"] += nb\n", + " ok = rng.random() >= link.p_loss(nb, t)\n", + " st[\"log\"].append((t, nb, ok, m[\"prec\"], m[\"id\"])) # 전송 기록(시각, bytes, 성공, 긴급도, 전문)\n", + " loss = 0.85 * loss + 0.15 * (0.0 if ok else 1.0) # 최근 손실률 추정(EWMA)\n", + " st[\"flag\"][int(t)] = st[\"flag\"].get(int(t), False) or loss > 0.12 # 링크 열화 판정(초 단위)\n", + " if ok and not frag[\"recv\"]:\n", + " frag[\"recv\"], frag[\"rt\"] = True, t\n", + " r = link.rtt(rng); frag[\"ack\"] = t + r\n", + " if srtt is None:\n", + " srtt, rttvar = r, r / 2\n", + " else:\n", + " rttvar = 0.75 * rttvar + 0.25 * abs(srtt - r); srtt = 0.875 * srtt + 0.125 * r\n", + " frag[\"tries\"] += 1\n", + " rto = rto_s if (rto_mode == \"fixed\" or srtt is None) else float(np.clip(srtt + 4 * rttvar, 1.0, 30.0))\n", + " if not frag[\"recv\"] or frag[\"ack\"] > t + rto: # 손실 또는 ACK 지연 -> 재전송 예약\n", + " m[\"ready\"].append((t + rto, frag))\n", + " if m[\"done\"] is None and m[\"unsent\"] == 0 and all(f[\"recv\"] for f in m[\"frags\"]):\n", + " m[\"done\"] = max(f[\"rt\"] for f in m[\"frags\"])\n", + " active = [x for x in active if not (x[\"unsent\"] == 0 and not x[\"ready\"] and all(f[\"recv\"] for f in x[\"frags\"]))]\n", + " res = [{\"id\": m[\"id\"], \"type\": m[\"type\"], \"prec\": m[\"prec\"], \"size\": m[\"size\"], \"t0\": m[\"t0\"], \"done\": m[\"done\"], \"wait\": None if m[\"first\"] is None else m[\"first\"] - m[\"t0\"],\n", + " \"retx\": sum(f[\"tries\"] for f in m[\"frags\"]) - len(m[\"frags\"]),\n", + " \"latency\": None if m[\"done\"] is None else m[\"done\"] - m[\"t0\"]} for m in pend]\n", + " for r in res:\n", + " r[\"on_time\"] = r[\"latency\"] is not None and r[\"latency\"] <= DEADLINE[r[\"prec\"]]\n", + " return res, st\n", + "\n", + "\n", + "def summarize(res):\n", + " \"\"\"긴급도별 기한 내 전달률·지연. 종합 정확도 = 긴급도별 기한 내 전달률의 평균.\"\"\"\n", + " df = pd.DataFrame(res)\n", + " rows = []\n", + " for p in PREC:\n", + " d = df[df[\"prec\"] == p]\n", + " lat = d[\"latency\"].dropna()\n", + " rows.append({\"긴급도\": p, \"건수\": len(d), \"기한내 전달률\": round(d[\"on_time\"].mean(), 3) if len(d) else np.nan,\n", + " \"평균 지연(s)\": round(lat.mean(), 1) if len(lat) else np.nan,\n", + " \"95% 지연(s)\": round(lat.quantile(0.95), 1) if len(lat) else np.nan, \"미전달\": int(d[\"done\"].isna().sum())})\n", + " tab = pd.DataFrame(rows)\n", + " return tab, round(float(tab[\"기한내 전달률\"].mean()), 3)\n", + "\n", + "ADV = dict(policy=\"aging\", aging=0.5, rto_mode=\"adaptive\", redundancy=True, adapt_mtu=True)\n", + "NOMINAL_BPS = 2400\n", + "\n", + "\n", + "def degraded_admit(link):\n", + " \"\"\"성능 저하 모드: 대역폭이 평시의 50% 미만이거나 링크가 재밍 상태면 FLASH·IMMEDIATE만 새로 송신하고 나머지는 보관(삭제 없음).\"\"\"\n", + " return lambda m, t: RANK[m[\"prec\"]] <= 1 or (link.bw(t) >= 0.5 * NOMINAL_BPS and link.state(t) != \"jam\")\n", + "\n", + "\n", + "def run_case(msgs, link, fail_safe, seed=0):\n", + " t0 = time.perf_counter()\n", + " try:\n", + " kw = dict(mtu=96, hdr=14, admit=degraded_admit(link)) if fail_safe else {} # 저하모드: 작은 조각 + 압축 헤더(미션 2) + 긴급 전문만 송신\n", + " res, st = simulate(msgs, link, **ADV, seed=seed, until=2000, **kw)\n", + " down = 0\n", + " except Exception as e: # 다운 집계\n", + " print(\"다운:\", e); res, st, down = [], {\"tx\": 0}, 1\n", + " dt = time.perf_counter() - t0\n", + " delivered = sum(r[\"done\"] is not None for r in res)\n", + " kept = len(res) - delivered # 2,000초 시점까지 미전달 = 저장·보관 중(삭제 없음)\n", + " urg = [r[\"on_time\"] for r in res if RANK[r[\"prec\"]] <= 1]\n", + " return {\"전문\": len(msgs), \"전달\": delivered, \"보관\": kept, \"유실\": len(msgs) - delivered - kept, \"다운\": down,\n", + " \"긴급 기한내\": round(float(np.mean(urg)), 3) if urg else np.nan,\n", + " \"FLASH 95%지연(s)\": round(float(np.quantile([r[\"latency\"] for r in res if r[\"prec\"] == \"FLASH\" and r[\"latency\"] is not None], 0.95)), 1),\n", + " \"처리량(건/s)\": round(len(msgs) / dt), \"모드\": \"저하모드\" if fail_safe else \"일반\"}\n", + "\n", + "\n", + "base = make_messages(600, 0.21, seed=7)\n", + "surge = base + [m for k in range(5) for m in make_messages(600, 0.21, seed=20 + k, prefix=f\"S{k}\")] # 평시 + 500%\n", + "CASES = [(\"평시\", base, {}), (\"트래픽 500% 증가\", surge, {}), (\"대역폭 90% 급감(200~500s)\", base, dict(bw_drop=[(200, 500, 0.1)])),\n", + " (\"트래픽 500% + 대역폭 90% 급감\", surge, dict(bw_drop=[(200, 500, 0.1)])),\n", + " (\"트래픽 500% + 재밍 120초\", surge, dict(jam=[(200, 320)])), (\"링크 두절 300초(대역폭 1%)\", base, dict(bw_drop=[(200, 500, 0.01)]))]\n", + "\n", + "rows = []\n", + "for i, (name, msgs, lk) in enumerate(CASES):\n", + " link = Link(4000, seed=1, **lk)\n", + " for fs in (False, True):\n", + " rows.append({\"상황\": name, **run_case(msgs, link, fs, seed=i)})\n", + "df = pd.DataFrame(rows)\n", + "print(df.drop(columns=[\"처리량(건/s)\"]).to_string(index=False))\n", + "print(f\"\\n시스템 다운 {df['다운'].sum()}건, 유실 {df['유실'].sum()}건(전달+보관=전체), 최소 처리량 {df['처리량(건/s)'].min():,}건/s\")\n", + "hard = df[df[\"상황\"] == \"트래픽 500% + 대역폭 90% 급감\"]\n", + "print(\"링크 두절(대역폭 1%) 구간에는 어떤 방식으로도 기한을 지킬 수 없다 -> 통신관에게 대체 수단(중계·전령 등) 검토를 요청하는 것이 안전 실패 동작이다.\")\n", + "print(f\"최악 상황 긴급 전문 기한 내 전달률: 일반 {hard.iloc[0]['긴급 기한내']:.1%} -> 성능 저하 모드 {hard.iloc[1]['긴급 기한내']:.1%}\")\n", + "need = sum(m[\"size\"] + 14 for m in surge if RANK[m[\"prec\"]] <= 1 and 200 <= m[\"t0\"] < 500) / 300\n", + "print(f\"원인: 급감 구간 긴급 전문만 초당 {need:.0f}B가 필요한데 링크는 초당 {NOMINAL_BPS * 0.1 / 8:.0f}B -> 스케줄링으로 해결 불가, 전문 간소화·발송 통제를 지휘계통에 건의해야 한다.\")\n", + "\n", + "# 메모리 누수 점검: 같은 시나리오를 3회 반복해도 메모리가 계속 늘지 않는가\n", + "tracemalloc.start()\n", + "mem = []\n", + "peak = 0\n", + "for k in range(3):\n", + " simulate(base, Link(4000, seed=1), **ADV, until=2000)\n", + " mem.append(tracemalloc.get_traced_memory()[0] / 1e6); peak = max(peak, tracemalloc.get_traced_memory()[1] / 1e6)\n", + "tracemalloc.stop()\n", + "print(f\"평시 시나리오 실행 중 최대 메모리 {peak:.1f} MB\")\n", + "print(\"반복 실행 후 잔류 메모리(MB):\", [round(m, 2) for m in mem], \"-> 누수 의심\" if mem[-1] > mem[0] * 1.5 + 1 else \"-> 누수 징후 없음\")\n", + "\n", + "# 병목 함수 프로파일링\n", + "pr = cProfile.Profile(); pr.enable()\n", + "simulate(surge, Link(4000, seed=1, bw_drop=[(200, 500, 0.1)]), **ADV, until=2000)\n", + "pr.disable()\n", + "s = io.StringIO(); pstats.Stats(pr, stream=s).sort_stats(\"tottime\").print_stats(4)\n", + "top = [ln.split() for ln in s.getvalue().splitlines() if ln.strip()[:1].isdigit() and \"(\" in ln and \"function calls\" not in ln][:3]\n", + "print(\"[병목 상위 3 함수: 호출 수 / 자체 소요(s) / 함수]\")\n", + "for f in top:\n", + " print(f\" {f[0]:>9} / {f[1]} / {' '.join(f[5:]).split('/')[-1][-45:]}\")\n", + "print(\"개선 방향: 매 결정마다 전체 대기 전문을 훑는 부분을 긴급도별 힙(heapq)과 재전송 타이머 힙으로 바꾸면 대기 전문 수에 덜 민감해진다.\")\n", + "\n", + "plt.figure(figsize=(9, 4))\n", + "x = np.arange(len(CASES))\n", + "plt.bar(x - 0.2, df[df[\"모드\"] == \"일반\"][\"긴급 기한내\"], 0.4, label=\"normal\")\n", + "plt.bar(x + 0.2, df[df[\"모드\"] == \"저하모드\"][\"긴급 기한내\"], 0.4, label=\"degraded mode (urgent only)\")\n", + "plt.xticks(x, [f\"case{i + 1}\" for i in x]); plt.ylabel(\"urgent on-time ratio\"); plt.ylim(0, 1.05); plt.legend()\n", + "plt.title(\"Stress test: urgent message delivery (synthetic)\"); plt.tight_layout(); plt.show()\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 08 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + " 상황 전문 전달 보관 유실 다운 긴급 기한내 FLASH 95%지연(s) 모드\n", + " 평시 130 130 0 0 0 1.000 2.0 일반\n", + " 평시 130 130 0 0 0 1.000 1.7 저하모드\n", + " 트래픽 500% 증가 758 567 191 0 0 0.991 2.0 일반\n", + " 트래픽 500% 증가 758 579 179 0 0 0.991 2.2 저하모드\n", + " 대역폭 90% 급감(200~500s) 130 130 0 0 0 0.500 23.9 일반\n", + " 대역폭 90% 급감(200~500s) 130 130 0 0 0 0.667 18.7 저하모드\n", + "트래픽 500% + 대역폭 90% 급감 758 519 239 0 0 0.389 93.4 일반\n", + "트래픽 500% + 대역폭 90% 급감 758 528 230 0 0 0.398 97.8 저하모드\n", + " 트래픽 500% + 재밍 120초 758 547 211 0 0 0.770 14.8 일반\n", + " 트래픽 500% + 재밍 120초 758 563 195 0 0 0.858 6.7 저하모드\n", + " 링크 두절 300초(대역폭 1%) 130 130 0 0 0 0.389 259.8 일반\n", + " 링크 두절 300초(대역폭 1%) 130 130 0 0 0 0.500 175.0 저하모드\n", + "\n", + "시스템 다운 0건, 유실 0건(전달+보관=전체), 최소 처리량 207건/s\n", + "링크 두절(대역폭 1%) 구간에는 어떤 방식으로도 기한을 지킬 수 없다 -> 통신관에게 대체 수단(중계·전령 등) 검토를 요청하는 것이 안전 실패 동작이다.\n", + "최악 상황 긴급 전문 기한 내 전달률: 일반 38.9% -> 성능 저하 모드 39.8%\n", + "원인: 급감 구간 긴급 전문만 초당 91B가 필요한데 링크는 초당 30B -> 스케줄링으로 해결 불가, 전문 간소화·발송 통제를 지휘계통에 건의해야 한다.\n", + "평시 시나리오 실행 중 최대 메모리 0.4 MB\n", + "반복 실행 후 잔류 메모리(MB): [0.01, 0.01, 0.01] -> 누수 징후 없음\n", + "[병목 상위 3 함수: 호출 수 / 자체 소요(s) / 함수]\n", + " 807785 / 0.476 / m08.py:80(policy_key)\n", + " 1 / 0.430 / m08.py:89(simulate)\n", + " 2132 / 0.408 / m08.py:110()\n", + "개선 방향: 매 결정마다 전체 대기 전문을 훑는 부분을 긴급도별 힙(heapq)과 재전송 타이머 힙으로 바꾸면 대기 전문 수에 덜 민감해진다.\n", + "```\n", + "
\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 09. AAR 기반 중요 전문 전송 지연 사례 분석 및 큐 우선순위 가중치 재조정\n", + "**소요시간**: 약 3.0시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "AAR을 통해 중요 전문 전송 지연 사례를 분석하고 큐 스케줄링 가중치를 재조정한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **AAR로 중요 전문의 전송 지연 사례를 원인별로 분석하고 큐 스케줄링 가중치와 재전송 파라미터를 재조정한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **초기 운용 설정(전문 단위 전송, 고정 재전송 타이머 6초, 조각 512bytes)으로 재밍·대역폭 감소가 있는 훈련을 모의하고 지연된 중요 전문을 대기열 적체·재전송 대기·링크 열화로 분류한 AAR 일지 작성하여 원인을 분석하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `docs/c202_aar_report.md 및 configs/c202_tuned_params.yaml` 형태의 결과물을 완성한다.\n", + "\n", + "### 🔍 평가 및 검증 기준 (PASS 조건)\n", + "#### 미션 판정\n", + "\n", + "**평가 구분:** 필수\n", + "\n", + "**핵심 평가영역:** 오류 원인 규명의 객관성 및 개선 조치의 논리적 타당성\n", + "\n", + "#### 미션 PASS 조건\n", + "\n", + "- 정량 기준: AAR 피드백 적용 후 종합 정확도 10%p 이상 향상, 재발 오류 0건\n", + "\n", + "\n", + "- 진행 기준: AAR 보고서 작성 및 파라미터 튜닝 전후 회귀 테스트 통과\n", + "\n", + "\n", + "- 검증 방법: 회귀 테스트(Regression Test) 스위트 전체 자동 실행 및 비교\n", + "\n", + "\n", + "#### 채점자 판정 메모\n", + "\n", + "> 단순 코드 실행 여부뿐만 아니라 출력된 수치와 근거 자료가 군 실무 요구사항에 부합하는지 면밀히 확인한다.\n", + "\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "\"\"\"AAR(사후검토) 일지 형식으로 중요 전문(FLASH·IMMEDIATE·PRIORITY)의 전송 지연 사례를 원인별로 분류하고, 큐 스케줄링 가중치(선점·aging)·조각 크기·재전송 타이머를 격자 탐색으로 재조정한 뒤 다른 운용 조건에서 회귀 테스트로 개선 전후를 비교한다.\"\"\"\n", + "import time\n", + "import numpy as np\n", + "import pandas as pd\n", + "import matplotlib.pyplot as plt\n", + "\n", + "# ---------------------------------------------------------------------------\n", + "# [교육용가상자료] 가상 전술 전문(메시지) 생성기 (실제 부대·통신망·제원 정보 없음)\n", + "# 긴급도 4단계(FLASH/IMMEDIATE/PRIORITY/ROUTINE)와 전문 유형별 크기를 가진 전문을 만든다.\n", + "# ---------------------------------------------------------------------------\n", + "PREC = [\"FLASH\", \"IMMEDIATE\", \"PRIORITY\", \"ROUTINE\"]\n", + "RANK = {p: i for i, p in enumerate(PREC)}\n", + "DEADLINE = {\"FLASH\": 10, \"IMMEDIATE\": 30, \"PRIORITY\": 120, \"ROUTINE\": 600} # 가상 전달 목표 시간(초)\n", + "MSG_TYPES = { # 유형: (긴급도 후보, 확률, 본문 크기 범위 bytes, 발생 비율)\n", + " \"CONTACT\": ([\"FLASH\", \"IMMEDIATE\"], [0.7, 0.3], (120, 300), 0.08),\n", + " \"MEDEVAC\": ([\"FLASH\", \"IMMEDIATE\"], [0.4, 0.6], (150, 350), 0.05),\n", + " \"SITREP\": ([\"IMMEDIATE\", \"PRIORITY\"], [0.2, 0.8], (600, 2500), 0.20),\n", + " \"POSREP\": ([\"PRIORITY\", \"ROUTINE\"], [0.5, 0.5], (60, 120), 0.35),\n", + " \"LOGREQ\": ([\"PRIORITY\", \"ROUTINE\"], [0.4, 0.6], (300, 1200), 0.14),\n", + " \"ADMIN\": ([\"ROUTINE\"], [1.0], (1000, 5000), 0.18),\n", + "}\n", + "\n", + "\n", + "def make_messages(duration=600, rate=0.12, seed=7, t_start=0.0, prefix=\"M\"):\n", + " \"\"\"duration초 동안 평균 rate건/초로 발생하는 가상 전문 목록.\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " n = int(rng.poisson(rate * duration))\n", + " times = np.sort(rng.uniform(t_start, t_start + duration, n))\n", + " types = list(MSG_TYPES)\n", + " msgs = []\n", + " for i, t0 in enumerate(times):\n", + " ty = types[rng.choice(len(types), p=[MSG_TYPES[k][3] for k in types])]\n", + " cands, p, (lo, hi), _ = MSG_TYPES[ty]\n", + " msgs.append({\"id\": f\"{prefix}{i:04d}\", \"t0\": float(t0), \"type\": ty, \"prec\": str(rng.choice(cands, p=p)),\n", + " \"size\": int(rng.integers(lo, hi + 1)), \"src\": f\"U{int(rng.integers(1, 9)):02d}\"})\n", + " return msgs\n", + "\n", + "\n", + "class Link:\n", + " \"\"\"[교육용가상자료] 가상 협대역 무선 링크(기본 2.4kbps). 초 단위 상태(good/bad/jam)에 따라\n", + " 비트오류율이 바뀌고(Gilbert-Elliott 모델), 구간별 대역폭 감소도 줄 수 있다.\"\"\"\n", + " BER = {\"good\": 1e-5, \"bad\": 2e-4, \"jam\": 1.2e-3}\n", + "\n", + " def __init__(self, horizon=4000, bw_bps=2400, seed=1, p_gb=0.02, p_bg=0.15, jam=(), bw_drop=(), rtt=(1.2, 0.4)):\n", + " rng = np.random.default_rng(seed)\n", + " st, s = [], \"good\"\n", + " for _ in range(int(horizon) + 1):\n", + " s = (\"bad\" if rng.random() < p_gb else \"good\") if s == \"good\" else (\"good\" if rng.random() < p_bg else \"bad\")\n", + " st.append(s)\n", + " for a, b in jam:\n", + " for k in range(int(a), min(int(b), len(st))):\n", + " st[k] = \"jam\"\n", + " self.states, self.bw_bps, self.bw_drop, self.rtt_par = st, bw_bps, list(bw_drop), rtt\n", + "\n", + " def state(self, t):\n", + " return self.states[min(int(t), len(self.states) - 1)]\n", + "\n", + " def bw(self, t):\n", + " f = 1.0\n", + " for a, b, fac in self.bw_drop:\n", + " if a <= t < b:\n", + " f = fac\n", + " return self.bw_bps * f\n", + "\n", + " def p_loss(self, nbytes, t):\n", + " return 1 - (1 - self.BER[self.state(t)]) ** (8 * nbytes)\n", + "\n", + " def rtt(self, rng):\n", + " return self.rtt_par[0] + rng.exponential(self.rtt_par[1])\n", + "# ---------------------------------------------------------------------------\n", + "\n", + "# ---- 우선순위 큐·재전송 이산 사건 시뮬레이터 공통 함수 ----\n", + "HDR = 32 # 전문 조각(패킷) 헤더 bytes (미션 2의 헤더 압축 적용 시 12 bytes)\n", + "\n", + "\n", + "def policy_key(m, t, policy, aging):\n", + " if policy == \"fifo\": # 도착 순서\n", + " return (m[\"t0\"],)\n", + " if policy == \"strict\": # 긴급도 우선, 같으면 도착 순서\n", + " return (RANK[m[\"prec\"]], m[\"t0\"])\n", + " # aging: 긴급도 + (대기시간 / 전달 목표시간) 가중. 오래 기다린 전문과 기한이 임박한 전문이 앞으로 온다.\n", + " return (RANK[m[\"prec\"]] - aging * (t - m[\"t0\"]) / DEADLINE[m[\"prec\"]], m[\"t0\"])\n", + "\n", + "\n", + "def simulate(msgs, link, policy=\"strict\", mtu=256, preempt=True, rto_mode=\"fixed\", rto_s=4.0, aging=0.0,\n", + " redundancy=False, adapt_mtu=False, hdr=HDR, seed=0, until=None, admit=None):\n", + " \"\"\"전문 목록을 링크로 보내는 이산 사건 시뮬레이션.\n", + " - 전문은 mtu 크기 조각으로 나눠 보낸다. 조각이 손실되면 RTO 후 재전송한다(자료는 버리지 않음).\n", + " - preempt=False면 한 전문의 조각을 끝까지 연속 전송한다(큰 전문이 긴급 전문을 막는 현상 재현).\n", + " - rto_mode='adaptive'면 왕복시간(RTT) 추정값으로 RTO를 계산한다(Jacobson 방식).\n", + " - redundancy=True면 링크 열화 추정 시 FLASH·IMMEDIATE 조각을 2회 연속 전송한다.\n", + " - adapt_mtu=True면 링크 열화 추정 시 조각 크기를 줄인다.\n", + " - admit(m, t)이 False인 전문은 새 조각을 보내지 않고 저장해 둔다(성능 저하 모드).\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " pend = sorted(({**m, \"unsent\": m[\"size\"], \"ready\": [], \"frags\": [], \"done\": None, \"first\": None} for m in msgs), key=lambda m: m[\"t0\"])\n", + " t, i, active, cur = 0.0, 0, [], None\n", + " srtt, rttvar, loss = None, None, 0.0\n", + " st = {\"tx\": 0, \"retx\": 0, \"spurious\": 0, \"bytes\": 0, \"decide_ms\": [], \"flag\": {}, \"log\": []}\n", + " until = until if until is not None else (pend[-1][\"t0\"] if pend else 0) + 6000\n", + " while (i < len(pend) or active) and t < until:\n", + " while i < len(pend) and pend[i][\"t0\"] <= t:\n", + " active.append(pend[i]); i += 1\n", + " td = time.perf_counter()\n", + " for m in active: # ACK가 이미 도착한 재전송 예약은 취소\n", + " m[\"ready\"] = [e for e in m[\"ready\"] if not (e[1][\"ack\"] is not None and e[1][\"ack\"] <= t)]\n", + " ready = [m for m in active if any(r <= t for r, _ in m[\"ready\"]) or (m[\"unsent\"] > 0 and (admit is None or admit(m, t)))]\n", + " if not ready:\n", + " nxt = ([pend[i][\"t0\"]] if i < len(pend) else []) + [r for m in active for r, _ in m[\"ready\"] if r > t]\n", + " t = min(nxt) if nxt else t + 1.0\n", + " continue\n", + " if not preempt and cur in ready and cur[\"unsent\"] > 0:\n", + " m = cur\n", + " else:\n", + " m = min(ready, key=lambda x: policy_key(x, t, policy, aging))\n", + " st[\"decide_ms\"].append((time.perf_counter() - td) * 1000)\n", + " due = [e for e in m[\"ready\"] if e[0] <= t]\n", + " if due:\n", + " e = min(due, key=lambda x: x[0]); m[\"ready\"].remove(e); frag = e[1]\n", + " st[\"retx\"] += 1; st[\"spurious\"] += frag[\"recv\"]\n", + " else:\n", + " size = mtu if not adapt_mtu else (mtu if loss < 0.15 else (mtu // 2 if loss < 0.4 else mtu // 4))\n", + " size = min(m[\"unsent\"], size); m[\"unsent\"] -= size\n", + " frag = {\"b\": size, \"recv\": False, \"ack\": None, \"tries\": 0}\n", + " m[\"frags\"].append(frag)\n", + " cur = m\n", + " m[\"first\"] = t if m[\"first\"] is None else m[\"first\"]\n", + " nb = frag[\"b\"] + hdr\n", + " copies = 2 if (redundancy and RANK[m[\"prec\"]] <= 1 and loss > 0.15) else 1\n", + " for _ in range(copies):\n", + " t += nb * 8 / link.bw(t)\n", + " st[\"tx\"] += 1; st[\"bytes\"] += nb\n", + " ok = rng.random() >= link.p_loss(nb, t)\n", + " st[\"log\"].append((t, nb, ok, m[\"prec\"], m[\"id\"])) # 전송 기록(시각, bytes, 성공, 긴급도, 전문)\n", + " loss = 0.85 * loss + 0.15 * (0.0 if ok else 1.0) # 최근 손실률 추정(EWMA)\n", + " st[\"flag\"][int(t)] = st[\"flag\"].get(int(t), False) or loss > 0.12 # 링크 열화 판정(초 단위)\n", + " if ok and not frag[\"recv\"]:\n", + " frag[\"recv\"], frag[\"rt\"] = True, t\n", + " r = link.rtt(rng); frag[\"ack\"] = t + r\n", + " if srtt is None:\n", + " srtt, rttvar = r, r / 2\n", + " else:\n", + " rttvar = 0.75 * rttvar + 0.25 * abs(srtt - r); srtt = 0.875 * srtt + 0.125 * r\n", + " frag[\"tries\"] += 1\n", + " rto = rto_s if (rto_mode == \"fixed\" or srtt is None) else float(np.clip(srtt + 4 * rttvar, 1.0, 30.0))\n", + " if not frag[\"recv\"] or frag[\"ack\"] > t + rto: # 손실 또는 ACK 지연 -> 재전송 예약\n", + " m[\"ready\"].append((t + rto, frag))\n", + " if m[\"done\"] is None and m[\"unsent\"] == 0 and all(f[\"recv\"] for f in m[\"frags\"]):\n", + " m[\"done\"] = max(f[\"rt\"] for f in m[\"frags\"])\n", + " active = [x for x in active if not (x[\"unsent\"] == 0 and not x[\"ready\"] and all(f[\"recv\"] for f in x[\"frags\"]))]\n", + " res = [{\"id\": m[\"id\"], \"type\": m[\"type\"], \"prec\": m[\"prec\"], \"size\": m[\"size\"], \"t0\": m[\"t0\"], \"done\": m[\"done\"], \"wait\": None if m[\"first\"] is None else m[\"first\"] - m[\"t0\"],\n", + " \"retx\": sum(f[\"tries\"] for f in m[\"frags\"]) - len(m[\"frags\"]),\n", + " \"latency\": None if m[\"done\"] is None else m[\"done\"] - m[\"t0\"]} for m in pend]\n", + " for r in res:\n", + " r[\"on_time\"] = r[\"latency\"] is not None and r[\"latency\"] <= DEADLINE[r[\"prec\"]]\n", + " return res, st\n", + "\n", + "\n", + "def summarize(res):\n", + " \"\"\"긴급도별 기한 내 전달률·지연. 종합 정확도 = 긴급도별 기한 내 전달률의 평균.\"\"\"\n", + " df = pd.DataFrame(res)\n", + " rows = []\n", + " for p in PREC:\n", + " d = df[df[\"prec\"] == p]\n", + " lat = d[\"latency\"].dropna()\n", + " rows.append({\"긴급도\": p, \"건수\": len(d), \"기한내 전달률\": round(d[\"on_time\"].mean(), 3) if len(d) else np.nan,\n", + " \"평균 지연(s)\": round(lat.mean(), 1) if len(lat) else np.nan,\n", + " \"95% 지연(s)\": round(lat.quantile(0.95), 1) if len(lat) else np.nan, \"미전달\": int(d[\"done\"].isna().sum())})\n", + " tab = pd.DataFrame(rows)\n", + " return tab, round(float(tab[\"기한내 전달률\"].mean()), 3)\n", + "\n", + "INITIAL = dict(policy=\"strict\", preempt=False, rto_mode=\"fixed\", rto_s=6.0, mtu=512, aging=0.0) # 초기 운용 설정\n", + "\n", + "\n", + "def aar_scenario(seed):\n", + " \"\"\"[교육용가상자료] 훈련 1회분: 재밍 60초 + 대역폭 50% 감소 200초 + 중계 지연이 큰 망.\"\"\"\n", + " msgs = make_messages(1200, 0.21, seed=seed)\n", + " link = Link(12000, seed=seed + 100, jam=[(300, 360)], bw_drop=[(600, 800, 0.5)], rtt=(2.0, 0.8))\n", + " return msgs, link\n", + "\n", + "\n", + "def aar_log(res, link):\n", + " rows = []\n", + " for r in res:\n", + " if RANK[r[\"prec\"]] <= 2 and not r[\"on_time\"]:\n", + " bad = any(link.state(k) != \"good\" or link.bw(k) < link.bw_bps for k in range(int(r[\"t0\"]), int(r[\"t0\"] + (r[\"latency\"] or 60)) + 1))\n", + " if r[\"retx\"] > 0 and bad:\n", + " cause = \"링크 열화 중 재전송\"\n", + " elif r[\"retx\"] > 0:\n", + " cause = \"재전송 타이머(RTO) 대기\"\n", + " elif r[\"wait\"] is not None and r[\"wait\"] > DEADLINE[r[\"prec\"]] / 2:\n", + " cause = \"대기열 적체·하위 전문 점유\"\n", + " else:\n", + " cause = \"대용량 전문 분할 전송 지연\"\n", + " rows.append({\"전문\": r[\"id\"], \"긴급도\": r[\"prec\"], \"크기\": r[\"size\"], \"지연(s)\": round(r[\"latency\"] or -1, 1),\n", + " \"첫 송신 대기(s)\": round(r[\"wait\"] or -1, 1), \"재전송\": r[\"retx\"], \"원인\": cause})\n", + " return pd.DataFrame(rows, columns=[\"전문\", \"긴급도\", \"크기\", \"지연(s)\", \"첫 송신 대기(s)\", \"재전송\", \"원인\"])\n", + "\n", + "\n", + "# 1) 무엇이 일어났나: 초기 설정으로 훈련 운용\n", + "msgs, link = aar_scenario(7)\n", + "before_res, _ = simulate(msgs, link, **INITIAL)\n", + "tab0, acc0 = summarize(before_res)\n", + "log0 = aar_log(before_res, link)\n", + "print(\"[1. 무엇이 일어났나] 초기 설정 종합 정확도(긴급도별 기한 내 전달률 평균):\", acc0)\n", + "print(tab0[[\"긴급도\", \"건수\", \"기한내 전달률\", \"95% 지연(s)\"]].to_string(index=False))\n", + "print(\"\\n[2. 왜 일어났나] 중요 전문 지연 원인 분류\")\n", + "print(log0.groupby([\"긴급도\", \"원인\"]).size().rename(\"건수\").to_string())\n", + "\n", + "# 2) 무엇을 바꾸나: 격자 탐색(제약: FLASH 기한 내 전달률 95% 이상)\n", + "grid = []\n", + "for preempt in (False, True):\n", + " for rto in (\"fixed\", \"adaptive\"):\n", + " for mtu in (128, 256, 512):\n", + " for aging in (0.0, 0.5, 1.0):\n", + " cfg = dict(policy=\"aging\" if aging else \"strict\", preempt=preempt, rto_mode=rto, rto_s=6.0, mtu=mtu, aging=aging)\n", + " tab, acc = summarize(simulate(msgs, link, **cfg)[0])\n", + " grid.append({**cfg, \"FLASH\": tab.loc[0, \"기한내 전달률\"], \"종합정확도\": acc})\n", + "g = pd.DataFrame(grid)\n", + "ok = g[g[\"FLASH\"] >= 0.95].sort_values([\"종합정확도\", \"FLASH\"], ascending=False)\n", + "best = ok.iloc[0]\n", + "TUNED = dict(policy=best[\"policy\"], preempt=bool(best[\"preempt\"]), rto_mode=best[\"rto_mode\"], rto_s=6.0, mtu=int(best[\"mtu\"]), aging=float(best[\"aging\"]))\n", + "print(f\"\\n[3. 무엇을 바꾸나] 격자 {len(g)}개 중 제약 충족 {len(ok)}개, 상위 3개\")\n", + "print(ok.head(3)[[\"preempt\", \"rto_mode\", \"mtu\", \"aging\", \"FLASH\", \"종합정확도\"]].to_string(index=False))\n", + "\n", + "after_res, _ = simulate(msgs, link, **TUNED)\n", + "tab1, acc1 = summarize(after_res)\n", + "log1 = aar_log(after_res, link)\n", + "print(f\"\\n종합 정확도 {acc0:.3f} -> {acc1:.3f} ({(acc1 - acc0) * 100:+.1f}%p, 목표 +10%p: {'달성' if acc1 - acc0 >= 0.10 else '미달'})\")\n", + "print(f\"FLASH 기한 내 전달률 {tab0.loc[0, '기한내 전달률']:.1%} -> {tab1.loc[0, '기한내 전달률']:.1%}, 중요 전문 지연 {len(log0)}건 -> {len(log1)}건\")\n", + "repeat = sorted(set(log0[\"전문\"]) & set(log1[\"전문\"]))\n", + "print(f\"재보정 후에도 반복된 지연 전문: {repeat if repeat else '없음(재발 0건)'}\")\n", + "for _, r in log1[log1[\"전문\"].isin(repeat)].iterrows():\n", + " print(f\" {r['전문']} {r['긴급도']} 지연 {r['지연(s)']}s, 재전송 {r['재전송']}회 -> 원인: {r['원인']} (스케줄링으로 줄이기 어려운 유형, 링크 열화 시 중복 전송 검토)\")\n", + "\n", + "# 3) 회귀 테스트: 다른 훈련 3회(다른 전문·링크)에서도 개선이 유지되는가\n", + "print(\"\\n[회귀 테스트] 훈련 초기 재보정 FLASH(재보정)\")\n", + "reg = []\n", + "for sd in (11, 12, 13):\n", + " m2, l2 = aar_scenario(sd)\n", + " a0 = summarize(simulate(m2, l2, **INITIAL)[0])[1]\n", + " t1, a1 = summarize(simulate(m2, l2, **TUNED)[0])\n", + " reg.append(a1 >= a0)\n", + " print(f\" #{sd} {a0:.3f} {a1:.3f} {t1.loc[0, '기한내 전달률']:.3f}\")\n", + "print(f\"회귀 테스트 {sum(reg)}/{len(reg)} 통과(재보정 후 종합 정확도가 떨어지지 않음)\")\n", + "\n", + "yaml = (f\"scheduler:\\n policy: {TUNED['policy']}\\n preempt_fragment: {str(TUNED['preempt']).lower()}\\n aging: {TUNED['aging']}\\n\"\n", + " f\"fragment_mtu_bytes: {TUNED['mtu']}\\nretransmission:\\n rto_mode: {TUNED['rto_mode']}\\n fixed_rto_s: 6.0\\nconstraint:\\n min_flash_on_time: 0.95\")\n", + "print(\"\\n[재보정 파라미터(c202_tuned_params.yaml 내용)]\\n\" + yaml)\n", + "print(\"지속 개선: 훈련마다 AAR 일지를 누적해 같은 격자 탐색을 다시 수행하고, 변경 전 회귀 테스트 통과를 배포 조건으로 둔다.\")\n", + "\n", + "plt.figure(figsize=(7, 4))\n", + "for pre, c in ((False, \"tab:gray\"), (True, \"tab:blue\")):\n", + " s = g[g[\"preempt\"] == pre]\n", + " plt.scatter(s[\"FLASH\"], s[\"종합정확도\"], c=c, label=f\"fragment preempt={pre}\")\n", + "plt.scatter([best[\"FLASH\"]], [best[\"종합정확도\"]], s=200, facecolors=\"none\", edgecolors=\"red\", label=\"selected\")\n", + "plt.axvline(0.95, ls=\"--\", c=\"gray\"); plt.xlabel(\"FLASH on-time ratio\"); plt.ylabel(\"overall accuracy (macro on-time)\")\n", + "plt.legend(); plt.title(\"AAR re-tuning grid (synthetic)\"); plt.tight_layout(); plt.show()\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 09 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + "[1. 무엇이 일어났나] 초기 설정 종합 정확도(긴급도별 기한 내 전달률 평균): 0.921\n", + " 긴급도 건수 기한내 전달률 95% 지연(s)\n", + " FLASH 25 0.720 19.4\n", + "IMMEDIATE 27 0.963 25.6\n", + " PRIORITY 99 1.000 52.0\n", + " ROUTINE 107 1.000 232.4\n", + "\n", + "[2. 왜 일어났나] 중요 전문 지연 원인 분류\n", + "긴급도 원인 \n", + "FLASH 대기열 적체·하위 전문 점유 4\n", + " 링크 열화 중 재전송 3\n", + "IMMEDIATE 링크 열화 중 재전송 1\n", + "\n", + "[3. 무엇을 바꾸나] 격자 36개 중 제약 충족 18개, 상위 3개\n", + " preempt rto_mode mtu aging FLASH 종합정확도\n", + " True fixed 128 0.0 0.96 0.99\n", + " True fixed 128 0.5 0.96 0.99\n", + " True fixed 128 1.0 0.96 0.99\n", + "\n", + "종합 정확도 0.921 -> 0.990 (+6.9%p, 목표 +10%p: 미달)\n", + "FLASH 기한 내 전달률 72.0% -> 96.0%, 중요 전문 지연 8건 -> 1건\n", + "재보정 후에도 반복된 지연 전문: ['M0070']\n", + " M0070 FLASH 지연 41.1s, 재전송 10회 -> 원인: 링크 열화 중 재전송 (스케줄링으로 줄이기 어려운 유형, 링크 열화 시 중복 전송 검토)\n", + "\n", + "[회귀 테스트] 훈련 초기 재보정 FLASH(재보정)\n", + " #11 0.936 1.000 1.000\n", + " #12 0.953 0.988 0.952\n", + " #13 0.943 0.951 0.870\n", + "회귀 테스트 3/3 통과(재보정 후 종합 정확도가 떨어지지 않음)\n", + "\n", + "[재보정 파라미터(c202_tuned_params.yaml 내용)]\n", + "scheduler:\n", + " policy: strict\n", + " preempt_fragment: true\n", + " aging: 0.0\n", + "fragment_mtu_bytes: 128\n", + "retransmission:\n", + " rto_mode: fixed\n", + " fixed_rto_s: 6.0\n", + "constraint:\n", + "... (이하 생략)\n", + "```\n", + "
\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 10. 대역폭 제한 환경 메시지 큐·재전송 시스템 통합 및 전술통신 브리핑 패키지 완성\n", + "**소요시간**: 약 3.5시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "대역폭 제한 시 메시지 우선순위 큐·재전송 시스템 통합 및 배포 가이드를 완성한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **패킷 수신부터 전처리, 큐·재전송, 링크 등급, 통신관 요청 대기열, 관제 화면까지 통합하고 전술통신 브리핑 패키지를 완성한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **재조정 파라미터로 전체 과정을 한 번에 실행하는 통합 코드를 완성하고 손상·미등록 패킷이 격리되는지 확인하고 유효성을 점검하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 2개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `src/main_c202.py, README.md, docs/c202_final_report.pdf(md)` 형태의 결과물을 완성한다.\n", + "\n", + "### 🔍 평가 및 검증 기준 (PASS 조건)\n", + "#### 미션 판정\n", + "\n", + "**평가 구분:** 필수\n", + "\n", + "**핵심 평가영역:** 군 실무 즉시 도입 가능성, 운용 신뢰도 및 전략적 기대효과\n", + "\n", + "#### 미션 PASS 조건\n", + "\n", + "- 정량 기준: End-to-End 통합 파이프라인 실행 성공률 100%, 테스트 커버리지 85% 이상\n", + "\n", + "\n", + "- 진행 기준: 원클릭 실행 스크립트 동작 확인 및 최종 발표 산출물 작성 완료\n", + "\n", + "\n", + "- 검증 방법: 최종 검증 위원회(평가관) 모의 시연 및 전체 기능 통합 검증\n", + "\n", + "\n", + "#### 채점자 판정 메모\n", + "\n", + "> 단순 코드 실행 여부뿐만 아니라 출력된 수치와 근거 자료가 군 실무 요구사항에 부합하는지 면밀히 확인한다.\n", + "\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "\"\"\"원시 전문 패킷 파싱·검증·압축부터 우선순위 큐, 지능형 재전송, 링크 신뢰도 등급, 통신관 HITL 요청 대기열, 관제 화면, AAR 재보정 파라미터 적용까지 전체 파이프라인을 한 파일로 통합 실행하고, 함수 커버리지를 포함한 자체 점검 결과와 전술통신 브리핑 요약을 출력한다.\"\"\"\n", + "import hashlib\n", + "import inspect\n", + "import json\n", + "import sys\n", + "import time\n", + "import zlib\n", + "import numpy as np\n", + "import pandas as pd\n", + "import matplotlib.pyplot as plt\n", + "\n", + "# ---------------------------------------------------------------------------\n", + "# [교육용가상자료] 가상 전술 전문(메시지) 생성기 (실제 부대·통신망·제원 정보 없음)\n", + "# 긴급도 4단계(FLASH/IMMEDIATE/PRIORITY/ROUTINE)와 전문 유형별 크기를 가진 전문을 만든다.\n", + "# ---------------------------------------------------------------------------\n", + "PREC = [\"FLASH\", \"IMMEDIATE\", \"PRIORITY\", \"ROUTINE\"]\n", + "RANK = {p: i for i, p in enumerate(PREC)}\n", + "DEADLINE = {\"FLASH\": 10, \"IMMEDIATE\": 30, \"PRIORITY\": 120, \"ROUTINE\": 600} # 가상 전달 목표 시간(초)\n", + "MSG_TYPES = { # 유형: (긴급도 후보, 확률, 본문 크기 범위 bytes, 발생 비율)\n", + " \"CONTACT\": ([\"FLASH\", \"IMMEDIATE\"], [0.7, 0.3], (120, 300), 0.08),\n", + " \"MEDEVAC\": ([\"FLASH\", \"IMMEDIATE\"], [0.4, 0.6], (150, 350), 0.05),\n", + " \"SITREP\": ([\"IMMEDIATE\", \"PRIORITY\"], [0.2, 0.8], (600, 2500), 0.20),\n", + " \"POSREP\": ([\"PRIORITY\", \"ROUTINE\"], [0.5, 0.5], (60, 120), 0.35),\n", + " \"LOGREQ\": ([\"PRIORITY\", \"ROUTINE\"], [0.4, 0.6], (300, 1200), 0.14),\n", + " \"ADMIN\": ([\"ROUTINE\"], [1.0], (1000, 5000), 0.18),\n", + "}\n", + "\n", + "\n", + "def make_messages(duration=600, rate=0.12, seed=7, t_start=0.0, prefix=\"M\"):\n", + " \"\"\"duration초 동안 평균 rate건/초로 발생하는 가상 전문 목록.\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " n = int(rng.poisson(rate * duration))\n", + " times = np.sort(rng.uniform(t_start, t_start + duration, n))\n", + " types = list(MSG_TYPES)\n", + " msgs = []\n", + " for i, t0 in enumerate(times):\n", + " ty = types[rng.choice(len(types), p=[MSG_TYPES[k][3] for k in types])]\n", + " cands, p, (lo, hi), _ = MSG_TYPES[ty]\n", + " msgs.append({\"id\": f\"{prefix}{i:04d}\", \"t0\": float(t0), \"type\": ty, \"prec\": str(rng.choice(cands, p=p)),\n", + " \"size\": int(rng.integers(lo, hi + 1)), \"src\": f\"U{int(rng.integers(1, 9)):02d}\"})\n", + " return msgs\n", + "\n", + "\n", + "class Link:\n", + " \"\"\"[교육용가상자료] 가상 협대역 무선 링크(기본 2.4kbps). 초 단위 상태(good/bad/jam)에 따라\n", + " 비트오류율이 바뀌고(Gilbert-Elliott 모델), 구간별 대역폭 감소도 줄 수 있다.\"\"\"\n", + " BER = {\"good\": 1e-5, \"bad\": 2e-4, \"jam\": 1.2e-3}\n", + "\n", + " def __init__(self, horizon=4000, bw_bps=2400, seed=1, p_gb=0.02, p_bg=0.15, jam=(), bw_drop=(), rtt=(1.2, 0.4)):\n", + " rng = np.random.default_rng(seed)\n", + " st, s = [], \"good\"\n", + " for _ in range(int(horizon) + 1):\n", + " s = (\"bad\" if rng.random() < p_gb else \"good\") if s == \"good\" else (\"good\" if rng.random() < p_bg else \"bad\")\n", + " st.append(s)\n", + " for a, b in jam:\n", + " for k in range(int(a), min(int(b), len(st))):\n", + " st[k] = \"jam\"\n", + " self.states, self.bw_bps, self.bw_drop, self.rtt_par = st, bw_bps, list(bw_drop), rtt\n", + "\n", + " def state(self, t):\n", + " return self.states[min(int(t), len(self.states) - 1)]\n", + "\n", + " def bw(self, t):\n", + " f = 1.0\n", + " for a, b, fac in self.bw_drop:\n", + " if a <= t < b:\n", + " f = fac\n", + " return self.bw_bps * f\n", + "\n", + " def p_loss(self, nbytes, t):\n", + " return 1 - (1 - self.BER[self.state(t)]) ** (8 * nbytes)\n", + "\n", + " def rtt(self, rng):\n", + " return self.rtt_par[0] + rng.exponential(self.rtt_par[1])\n", + "# ---------------------------------------------------------------------------\n", + "\n", + "# ---- 우선순위 큐·재전송 이산 사건 시뮬레이터 공통 함수 ----\n", + "HDR = 32 # 전문 조각(패킷) 헤더 bytes (미션 2의 헤더 압축 적용 시 12 bytes)\n", + "\n", + "\n", + "def policy_key(m, t, policy, aging):\n", + " if policy == \"fifo\": # 도착 순서\n", + " return (m[\"t0\"],)\n", + " if policy == \"strict\": # 긴급도 우선, 같으면 도착 순서\n", + " return (RANK[m[\"prec\"]], m[\"t0\"])\n", + " # aging: 긴급도 + (대기시간 / 전달 목표시간) 가중. 오래 기다린 전문과 기한이 임박한 전문이 앞으로 온다.\n", + " return (RANK[m[\"prec\"]] - aging * (t - m[\"t0\"]) / DEADLINE[m[\"prec\"]], m[\"t0\"])\n", + "\n", + "\n", + "def simulate(msgs, link, policy=\"strict\", mtu=256, preempt=True, rto_mode=\"fixed\", rto_s=4.0, aging=0.0,\n", + " redundancy=False, adapt_mtu=False, hdr=HDR, seed=0, until=None, admit=None):\n", + " \"\"\"전문 목록을 링크로 보내는 이산 사건 시뮬레이션.\n", + " - 전문은 mtu 크기 조각으로 나눠 보낸다. 조각이 손실되면 RTO 후 재전송한다(자료는 버리지 않음).\n", + " - preempt=False면 한 전문의 조각을 끝까지 연속 전송한다(큰 전문이 긴급 전문을 막는 현상 재현).\n", + " - rto_mode='adaptive'면 왕복시간(RTT) 추정값으로 RTO를 계산한다(Jacobson 방식).\n", + " - redundancy=True면 링크 열화 추정 시 FLASH·IMMEDIATE 조각을 2회 연속 전송한다.\n", + " - adapt_mtu=True면 링크 열화 추정 시 조각 크기를 줄인다.\n", + " - admit(m, t)이 False인 전문은 새 조각을 보내지 않고 저장해 둔다(성능 저하 모드).\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " pend = sorted(({**m, \"unsent\": m[\"size\"], \"ready\": [], \"frags\": [], \"done\": None, \"first\": None} for m in msgs), key=lambda m: m[\"t0\"])\n", + " t, i, active, cur = 0.0, 0, [], None\n", + " srtt, rttvar, loss = None, None, 0.0\n", + " st = {\"tx\": 0, \"retx\": 0, \"spurious\": 0, \"bytes\": 0, \"decide_ms\": [], \"flag\": {}, \"log\": []}\n", + " until = until if until is not None else (pend[-1][\"t0\"] if pend else 0) + 6000\n", + " while (i < len(pend) or active) and t < until:\n", + " while i < len(pend) and pend[i][\"t0\"] <= t:\n", + " active.append(pend[i]); i += 1\n", + " td = time.perf_counter()\n", + " for m in active: # ACK가 이미 도착한 재전송 예약은 취소\n", + " m[\"ready\"] = [e for e in m[\"ready\"] if not (e[1][\"ack\"] is not None and e[1][\"ack\"] <= t)]\n", + " ready = [m for m in active if any(r <= t for r, _ in m[\"ready\"]) or (m[\"unsent\"] > 0 and (admit is None or admit(m, t)))]\n", + " if not ready:\n", + " nxt = ([pend[i][\"t0\"]] if i < len(pend) else []) + [r for m in active for r, _ in m[\"ready\"] if r > t]\n", + " t = min(nxt) if nxt else t + 1.0\n", + " continue\n", + " if not preempt and cur in ready and cur[\"unsent\"] > 0:\n", + " m = cur\n", + " else:\n", + " m = min(ready, key=lambda x: policy_key(x, t, policy, aging))\n", + " st[\"decide_ms\"].append((time.perf_counter() - td) * 1000)\n", + " due = [e for e in m[\"ready\"] if e[0] <= t]\n", + " if due:\n", + " e = min(due, key=lambda x: x[0]); m[\"ready\"].remove(e); frag = e[1]\n", + " st[\"retx\"] += 1; st[\"spurious\"] += frag[\"recv\"]\n", + " else:\n", + " size = mtu if not adapt_mtu else (mtu if loss < 0.15 else (mtu // 2 if loss < 0.4 else mtu // 4))\n", + " size = min(m[\"unsent\"], size); m[\"unsent\"] -= size\n", + " frag = {\"b\": size, \"recv\": False, \"ack\": None, \"tries\": 0}\n", + " m[\"frags\"].append(frag)\n", + " cur = m\n", + " m[\"first\"] = t if m[\"first\"] is None else m[\"first\"]\n", + " nb = frag[\"b\"] + hdr\n", + " copies = 2 if (redundancy and RANK[m[\"prec\"]] <= 1 and loss > 0.15) else 1\n", + " for _ in range(copies):\n", + " t += nb * 8 / link.bw(t)\n", + " st[\"tx\"] += 1; st[\"bytes\"] += nb\n", + " ok = rng.random() >= link.p_loss(nb, t)\n", + " st[\"log\"].append((t, nb, ok, m[\"prec\"], m[\"id\"])) # 전송 기록(시각, bytes, 성공, 긴급도, 전문)\n", + " loss = 0.85 * loss + 0.15 * (0.0 if ok else 1.0) # 최근 손실률 추정(EWMA)\n", + " st[\"flag\"][int(t)] = st[\"flag\"].get(int(t), False) or loss > 0.12 # 링크 열화 판정(초 단위)\n", + " if ok and not frag[\"recv\"]:\n", + " frag[\"recv\"], frag[\"rt\"] = True, t\n", + " r = link.rtt(rng); frag[\"ack\"] = t + r\n", + " if srtt is None:\n", + " srtt, rttvar = r, r / 2\n", + " else:\n", + " rttvar = 0.75 * rttvar + 0.25 * abs(srtt - r); srtt = 0.875 * srtt + 0.125 * r\n", + " frag[\"tries\"] += 1\n", + " rto = rto_s if (rto_mode == \"fixed\" or srtt is None) else float(np.clip(srtt + 4 * rttvar, 1.0, 30.0))\n", + " if not frag[\"recv\"] or frag[\"ack\"] > t + rto: # 손실 또는 ACK 지연 -> 재전송 예약\n", + " m[\"ready\"].append((t + rto, frag))\n", + " if m[\"done\"] is None and m[\"unsent\"] == 0 and all(f[\"recv\"] for f in m[\"frags\"]):\n", + " m[\"done\"] = max(f[\"rt\"] for f in m[\"frags\"])\n", + " active = [x for x in active if not (x[\"unsent\"] == 0 and not x[\"ready\"] and all(f[\"recv\"] for f in x[\"frags\"]))]\n", + " res = [{\"id\": m[\"id\"], \"type\": m[\"type\"], \"prec\": m[\"prec\"], \"size\": m[\"size\"], \"t0\": m[\"t0\"], \"done\": m[\"done\"], \"wait\": None if m[\"first\"] is None else m[\"first\"] - m[\"t0\"],\n", + " \"retx\": sum(f[\"tries\"] for f in m[\"frags\"]) - len(m[\"frags\"]),\n", + " \"latency\": None if m[\"done\"] is None else m[\"done\"] - m[\"t0\"]} for m in pend]\n", + " for r in res:\n", + " r[\"on_time\"] = r[\"latency\"] is not None and r[\"latency\"] <= DEADLINE[r[\"prec\"]]\n", + " return res, st\n", + "\n", + "\n", + "def summarize(res):\n", + " \"\"\"긴급도별 기한 내 전달률·지연. 종합 정확도 = 긴급도별 기한 내 전달률의 평균.\"\"\"\n", + " df = pd.DataFrame(res)\n", + " rows = []\n", + " for p in PREC:\n", + " d = df[df[\"prec\"] == p]\n", + " lat = d[\"latency\"].dropna()\n", + " rows.append({\"긴급도\": p, \"건수\": len(d), \"기한내 전달률\": round(d[\"on_time\"].mean(), 3) if len(d) else np.nan,\n", + " \"평균 지연(s)\": round(lat.mean(), 1) if len(lat) else np.nan,\n", + " \"95% 지연(s)\": round(lat.quantile(0.95), 1) if len(lat) else np.nan, \"미전달\": int(d[\"done\"].isna().sum())})\n", + " tab = pd.DataFrame(rows)\n", + " return tab, round(float(tab[\"기한내 전달률\"].mean()), 3)\n", + "\n", + "CONFIG = {\"policy\": \"strict\", \"preempt\": True, \"rto_mode\": \"fixed\", \"rto_s\": 6.0, \"mtu\": 128, \"aging\": 0.0, # 미션 9 재보정값\n", + " \"redundancy\": True, \"hdr\": 14} # 미션 4 중복 전송, 미션 2 헤더 압축\n", + "ALIAS = {\"Z\": \"FLASH\", \"O\": \"IMMEDIATE\", \"P\": \"PRIORITY\", \"R\": \"ROUTINE\"}\n", + "ROSTER = {f\"U{i:02d}\" for i in range(1, 9)}\n", + "\n", + "\n", + "def to_packet(m):\n", + " body = \"\".join(f\"{m['type']} {m['src']} I{(k * 7919 + int(m['t0'] * 10)) % 997:03d} Q{(k * 31 + m['size']) % 89:02d};\" for k in range(m[\"size\"] // 20 + 1))[:m[\"size\"]]\n", + " return f\"ZCZC|ID={m['id']}|PREC={'ZOPR'[RANK[m['prec']]]}|TYPE={m['type']}|SRC={m['src']}|T={m['t0']:.1f}|CRC={zlib.crc32(body.encode()):08x}|BODY={body}\"\n", + "\n", + "\n", + "def parse(line):\n", + " kv = dict(p.split(\"=\", 1) for p in line.split(\"|\")[1:] if \"=\" in p)\n", + " if set(kv) != {\"ID\", \"PREC\", \"TYPE\", \"SRC\", \"T\", \"CRC\", \"BODY\"} or kv[\"PREC\"] not in ALIAS or kv[\"SRC\"] not in ROSTER:\n", + " raise ValueError(\"형식·긴급도·송신국 오류\")\n", + " if f\"{zlib.crc32(kv['BODY'].encode()):08x}\" != kv[\"CRC\"]:\n", + " raise ValueError(\"CRC 불일치\")\n", + " body_z = zlib.compress(kv[\"BODY\"].encode(), 9)\n", + " return {\"id\": kv[\"ID\"], \"prec\": ALIAS[kv[\"PREC\"]], \"type\": kv[\"TYPE\"], \"src\": kv[\"SRC\"], \"t0\": float(kv[\"T\"]), \"size\": len(body_z)}\n", + "\n", + "\n", + "def link_grade(loss):\n", + " return \"High\" if loss < 0.1 else (\"Medium\" if loss < 0.3 else \"Low\")\n", + "\n", + "\n", + "class RequestQueue:\n", + " \"\"\"통신관 조작 요청 대기열: 시스템은 요청만 만들고 적용은 사람이 한다.\"\"\"\n", + "\n", + " def __init__(self):\n", + " self.items, self.log = [], []\n", + "\n", + " def submit(self, kind, target, why):\n", + " prev = self.log[-1][\"hash\"] if self.log else \"GENESIS\"\n", + " body = {\"event\": \"REQUEST\", \"kind\": kind, \"target\": target, \"why\": why}\n", + " self.log.append({**body, \"prev\": prev, \"hash\": hashlib.sha256((prev + json.dumps(body, ensure_ascii=False, sort_keys=True)).encode()).hexdigest()})\n", + " self.items.append({**body, \"status\": \"PENDING\"})\n", + "\n", + "\n", + "# 함수 커버리지 측정(표준 라이브러리 sys.setprofile): 이 파일에서 정의한 함수 중 실제 호출된 비율\n", + "called = set()\n", + "sys.setprofile(lambda f, e, a: called.add(f.f_code.co_name) if e == \"call\" and f.f_globals is globals() else None)\n", + "t_start = time.perf_counter()\n", + "\n", + "# ---------------- 1. 수신·전처리 ----------------\n", + "msgs = make_messages(1200, 0.5, seed=21) # 평시의 약 2.4배 트래픽\n", + "lines = [to_packet(m) for m in msgs]\n", + "lines[5] = lines[5].replace(\"SRC=U\", \"SRC=X\") # 손상·미등록 송신국 패킷 주입\n", + "lines[9] = lines[9][:-3] + \"###\"\n", + "parsed, rejected = [], {}\n", + "for ln in lines:\n", + " try:\n", + " parsed.append(parse(ln))\n", + " except ValueError as e:\n", + " rejected[ln.split(\"|\")[1][3:]] = str(e)\n", + "\n", + "# ---------------- 2. 큐·재전송 ----------------\n", + "link = Link(12000, seed=31, jam=[(300, 360)], bw_drop=[(600, 800, 0.5)], rtt=(2.0, 0.8))\n", + "res, st = simulate(parsed, link, **CONFIG)\n", + "tab, acc = summarize(res)\n", + "lg = pd.DataFrame(st[\"log\"], columns=[\"t\", \"bytes\", \"ok\", \"prec\", \"id\"])\n", + "lg[\"sec\"] = (lg[\"t\"] // 10 * 10).astype(int)\n", + "win = lg.groupby(\"sec\")[\"ok\"].apply(lambda s: 1 - s.mean()).rename(\"loss\").reset_index()\n", + "win[\"grade\"] = win[\"loss\"].map(link_grade)\n", + "\n", + "# ---------------- 3. HITL 요청(자동 적용 없음) ----------------\n", + "rq = RequestQueue()\n", + "for _, w in win[win[\"grade\"] == \"Low\"].iterrows():\n", + " rq.submit(\"ROUTE_REVIEW\", f\"t={w['sec']}s\", f\"손실률 {w['loss']:.0%}, 경로·중계 검토\")\n", + "for r in res:\n", + " if r[\"prec\"] == \"FLASH\" and not r[\"on_time\"]:\n", + " rq.submit(\"FLASH_LATE\", r[\"id\"], f\"지연 {r['latency']:.1f}s, 통신관 확인\")\n", + "sys.setprofile(None)\n", + "\n", + "# ---------------- 4. 자체 점검 ----------------\n", + "mine = [n for n, o in list(globals().items()) if inspect.isfunction(o) and o.__module__ == \"__main__\"]\n", + "mine += [\"submit\", \"state\", \"bw\", \"p_loss\", \"rtt\", \"__init__\"]\n", + "cov = len(set(mine) & called) / len(set(mine))\n", + "urg = np.mean([r[\"on_time\"] for r in res if RANK[r[\"prec\"]] <= 1])\n", + "elapsed = time.perf_counter() - t_start\n", + "chk = {\n", + " \"손상 패킷 2건 격리\": len(rejected) == 2,\n", + " \"정상 전문 전부 큐 등록\": len(parsed) == len(msgs) - 2,\n", + " \"전문 유실 0건(전달+보관=전체)\": sum(r[\"done\"] is not None for r in res) + sum(r[\"done\"] is None for r in res) == len(parsed),\n", + " \"FLASH 기한 내 전달률 90% 이상\": tab.loc[0, \"기한내 전달률\"] >= 0.9,\n", + " \"긴급 전문 기한 내 85% 이상\": urg >= 0.85,\n", + " \"종합 정확도 0.9 이상\": acc >= 0.9,\n", + " \"자동 적용 0건(요청 전부 PENDING)\": all(i[\"status\"] == \"PENDING\" for i in rq.items),\n", + " \"감사 로그 건수 일치\": len(rq.log) == len(rq.items),\n", + " \"스케줄링 결정 200ms 이내\": max(st[\"decide_ms\"]) < 200,\n", + " \"함수 커버리지 85% 이상\": cov >= 0.85,\n", + "}\n", + "passed = sum(chk.values())\n", + "for k, v in chk.items():\n", + " print(f\"[{'PASS' if v else 'FAIL'}] {k}\")\n", + "print(f\"자체 점검 {passed}/{len(chk)} 통과, 함수 커버리지 {cov:.0%}, 파이프라인 실행 성공 {'예' if passed == len(chk) else '아니오'}\")\n", + "print(tab[[\"긴급도\", \"건수\", \"기한내 전달률\", \"95% 지연(s)\", \"미전달\"]].to_string(index=False))\n", + "\n", + "# ---------------- 5. 관제 화면 ----------------\n", + "fig, ax = plt.subplots(2, 2, figsize=(12, 7))\n", + "ax[0, 0].bar(tab[\"긴급도\"], tab[\"기한내 전달률\"], color=[\"red\", \"orange\", \"deepskyblue\", \"gray\"]); ax[0, 0].set_ylim(0, 1.05); ax[0, 0].set_title(\"On-time ratio by precedence\")\n", + "ax[0, 1].plot(win[\"sec\"], win[\"loss\"] * 100); ax[0, 1].axhline(30, c=\"r\", ls=\"--\"); ax[0, 1].set_title(\"Loss rate per 10 s (%)\")\n", + "for a, b, c in [(300, 360, \"purple\"), (600, 800, \"gold\")]:\n", + " ax[0, 1].axvspan(a, b, color=c, alpha=0.2)\n", + "lat = pd.DataFrame(res)\n", + "for p, c in zip(PREC, [\"red\", \"orange\", \"deepskyblue\", \"gray\"]):\n", + " d = lat[lat[\"prec\"] == p]; ax[1, 0].scatter(d[\"t0\"], d[\"latency\"], s=8, c=c, label=p)\n", + "ax[1, 0].set_yscale(\"log\"); ax[1, 0].legend(fontsize=7); ax[1, 0].set_title(\"Delivery latency (s, log)\")\n", + "ax[1, 1].axis(\"off\")\n", + "ax[1, 1].text(0, 0.9, f\"Messages {len(parsed)} (+{len(rejected)} rejected)\\nOverall acc {acc:.3f}\\nUrgent ok {urg:.1%}\\n\"\n", + " f\"HITL pending {len(rq.items)}\\nSelf-check {passed}/{len(chk)}\", family=\"monospace\", fontsize=13, va=\"top\")\n", + "plt.suptitle(\"C202 integrated message queue - training data only\"); plt.tight_layout(); plt.show()\n", + "\n", + "# ---------------- 6. 전술통신 브리핑 요약 ----------------\n", + "print(f\"\"\"\n", + "[전술통신 브리핑 요약 - 교육용가상자료]\n", + "1. 처리: 수신 {len(lines)}건 중 {len(parsed)}건 전송, 손상·미등록 {len(rejected)}건 격리, 전체 소요 {elapsed:.1f}s\n", + "2. 성능: 종합 정확도 {acc:.3f}, FLASH 기한 내 {tab.loc[0, '기한내 전달률']:.1%}, 긴급 전문 기한 내 {urg:.1%}, 재전송 {st['retx']}회\n", + "3. 통신관 검토 요청: {len(rq.items)}건(경로 검토 {sum(i['kind'] == 'ROUTE_REVIEW' for i in rq.items)}, FLASH 지연 {sum(i['kind'] == 'FLASH_LATE' for i in rq.items)}), 자동 적용 0건\n", + "4. 운용 유의: 강제 전송·보류는 통신관이 근거를 기록해 수행하고, FLASH보다 앞서는 조작은 상급자 승인을 받는다.\n", + "5. 한계·확장: 가상 링크 모델 기준 결과로 실제 장비·망에서는 재측정이 필요하다. 인접 체계 연동은 전문 입력·전달 결과 조회 API로 확장한다.\"\"\")\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 10 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + "[PASS] 손상 패킷 2건 격리\n", + "[PASS] 정상 전문 전부 큐 등록\n", + "[PASS] 전문 유실 0건(전달+보관=전체)\n", + "[PASS] FLASH 기한 내 전달률 90% 이상\n", + "[PASS] 긴급 전문 기한 내 85% 이상\n", + "[PASS] 종합 정확도 0.9 이상\n", + "[PASS] 자동 적용 0건(요청 전부 PENDING)\n", + "[PASS] 감사 로그 건수 일치\n", + "[PASS] 스케줄링 결정 200ms 이내\n", + "[PASS] 함수 커버리지 85% 이상\n", + "자체 점검 10/10 통과, 함수 커버리지 100%, 파이프라인 실행 성공 예\n", + " 긴급도 건수 기한내 전달률 95% 지연(s) 미전달\n", + " FLASH 45 0.978 7.3 0\n", + "IMMEDIATE 55 1.000 7.3 0\n", + " PRIORITY 248 1.000 8.3 0\n", + " ROUTINE 271 1.000 46.8 0\n", + "\n", + "[전술통신 브리핑 요약 - 교육용가상자료]\n", + "1. 처리: 수신 621건 중 619건 전송, 손상·미등록 2건 격리, 전체 소요 0.2s\n", + "2. 성능: 종합 정확도 0.994, FLASH 기한 내 97.8%, 긴급 전문 기한 내 99.0%, 재전송 111회\n", + "3. 통신관 검토 요청: 7건(경로 검토 6, FLASH 지연 1), 자동 적용 0건\n", + "4. 운용 유의: 강제 전송·보류는 통신관이 근거를 기록해 수행하고, FLASH보다 앞서는 조작은 상급자 승인을 받는다.\n", + "5. 한계·확장: 가상 링크 모델 기준 결과로 실제 장비·망에서는 재측정이 필요하다. 인접 체계 연동은 전문 입력·전달 결과 조회 API로 확장한다.\n", + "```\n", + "
\n" + ] + } + ], + "metadata": { + "colab": { + "provenance": [] + }, + "kernelspec": { + "display_name": "Python 3", + "name": "python3" + }, + "language_info": { + "name": "python" + } + }, + "nbformat": 4, + "nbformat_minor": 0 +} \ No newline at end of file diff --git a/uploads/C202/C202_student.ipynb b/uploads/C202/C202_student.ipynb new file mode 100644 index 0000000..753851d --- /dev/null +++ b/uploads/C202/C202_student.ipynb @@ -0,0 +1,3085 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# 🎯 [C202] 대역폭 제한 시 메시지 우선순위 큐·재전송 프로토타입\n", + "### Google Colab 실습 환경 (지휘통제(C2) / Level 2)\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/C202/C202_solution.ipynb)\n", + "\n", + "---\n", + "\n", + "## 📌 1. 프로젝트 개요 및 배경\n", + "\n", + "| 항목 | 내용 |\n", + "| :--- | :--- |\n", + "| **과정 분류** | 지휘통제(C2) (Level 2) |\n", + "| **학습 소요 시간** | 10개 미션 (5일 / 총 27.5시간) |\n", + "| **환경 사양** | Python 3.10+, numpy, pandas, matplotlib, scipy, scikit-learn |\n", + "\n", + "### 📖 프로젝트 목표\n", + "러시아-우크라이나 전쟁에서는 전자전 재밍과 통신 장비 부족으로 협대역 무선망의 대역폭이 크게 줄어드는 상황이 잦았고, 이때 적 접촉 보고나 의무후송 요청 같은 긴급 전문이 행정 전문 뒤에 묶여 늦게 전달되는 문제가 지휘통제의 약점이 되었다. 본 프로젝트에서는 가상 협대역 링크(2.4kbps)와 가상 전술 전문을 코드로 직접 만들어, 전문 구조 정의, 패킷 파싱·압축 전처리, 긴급도 기반 우선순위 큐, 채널 상태에 따른 지능형 재전송, 링크 신뢰도 산출, 통신 관제 화면, 통신관 HITL 통제, 대역폭 급감 스트레스 시험, AAR 재조정, 최종 통합까지 메시지 우선순위 큐·재전송 프로토타입을 단계별로 구현한다. 최종 목표는 대역폭이 제한된 상황에서도 FLASH·IMMEDIATE 전문을 기한 안에 전달하고, 어떤 전문도 잃어버리지 않으며, 순서 조정의 최종 판단은 통신관이 내리도록 설계하는 것이다.\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. 협대역 전술 무선망(VHF/UHF) 대역폭 제약 조건 분석 및 전술 메시지 구조 정의\n", + "**소요시간**: 약 1.5시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "협대역 전술 무전망(VHF/UHF)의 전송 대역폭 제약과 전술 전문(Message) 구조를 분석한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **협대역 전술 무선망의 대역폭 제약이 전문 전송시간에 주는 영향을 분석하고, 전문 헤더·긴급도·링크 조건·전술 파라미터를 담은 데이터 스키마와 검증기를 만든다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **전문 유형(접촉 보고, 의무후송 요청, 상황 보고, 위치 보고, 보급 요청, 행정)별 평균 크기를 1.2~16kbps 링크에서 보낼 때 걸리는 시간을 표로 정리하고 규격화하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `data/schema/c202_input_schema.json 및 docs/c202_data_spec.md` 형태의 결과물을 완성한다.\n", + "\n", + "### 🔍 평가 및 검증 기준 (PASS 조건)\n", + "#### 미션 판정\n", + "\n", + "**평가 구분:** 필수\n", + "\n", + "**핵심 평가영역:** 지휘통제(C2) 실무 및 러시아-우크라이나 전쟁 전장 환경과의 군사적 부합성\n", + "\n", + "#### 미션 PASS 조건\n", + "\n", + "- 정량 기준: 스키마 필드 정의 완성도 100%, 샘플 데이터 검증 오류 0건\n", + "\n", + "\n", + "- 진행 기준: 입력 데이터 유효성 검증 스크립트 통과 및 명세서 문서화 완료\n", + "\n", + "\n", + "- 검증 방법: JSON Schema 검증 도구 및 샘플 데이터 파싱 단위 테스트\n", + "\n", + "\n", + "#### 채점자 판정 메모\n", + "\n", + "> 단순 코드 실행 여부뿐만 아니라 출력된 수치와 근거 자료가 군 실무 요구사항에 부합하는지 면밀히 확인한다.\n", + "\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "\"\"\"협대역 전술 무선망(VHF/UHF)의 대역폭 제약을 전문 유형별 전송시간으로 분석하고, 전술 전문의 헤더·긴급도·링크·전술 파라미터 스키마를 정의해 가상 샘플 데이터를 검증한다.\"\"\"\n", + "import json\n", + "import re\n", + "import numpy as np\n", + "import pandas as pd\n", + "\n", + "# ---------------------------------------------------------------------------\n", + "# [교육용가상자료] 가상 전술 전문(메시지) 생성기 (실제 부대·통신망·제원 정보 없음)\n", + "# 긴급도 4단계(FLASH/IMMEDIATE/PRIORITY/ROUTINE)와 전문 유형별 크기를 가진 전문을 만든다.\n", + "# ---------------------------------------------------------------------------\n", + "PREC = [\"FLASH\", \"IMMEDIATE\", \"PRIORITY\", \"ROUTINE\"]\n", + "RANK = {p: i for i, p in enumerate(PREC)}\n", + "DEADLINE = {\"FLASH\": 10, \"IMMEDIATE\": 30, \"PRIORITY\": 120, \"ROUTINE\": 600} # 가상 전달 목표 시간(초)\n", + "MSG_TYPES = { # 유형: (긴급도 후보, 확률, 본문 크기 범위 bytes, 발생 비율)\n", + " \"CONTACT\": ([\"FLASH\", \"IMMEDIATE\"], [0.7, 0.3], (120, 300), 0.08),\n", + " \"MEDEVAC\": ([\"FLASH\", \"IMMEDIATE\"], [0.4, 0.6], (150, 350), 0.05),\n", + " \"SITREP\": ([\"IMMEDIATE\", \"PRIORITY\"], [0.2, 0.8], (600, 2500), 0.20),\n", + " \"POSREP\": ([\"PRIORITY\", \"ROUTINE\"], [0.5, 0.5], (60, 120), 0.35),\n", + " \"LOGREQ\": ([\"PRIORITY\", \"ROUTINE\"], [0.4, 0.6], (300, 1200), 0.14),\n", + " \"ADMIN\": ([\"ROUTINE\"], [1.0], (1000, 5000), 0.18),\n", + "}\n", + "\n", + "\n", + "def make_messages(duration=600, rate=0.12, seed=7, t_start=0.0, prefix=\"M\"):\n", + " \"\"\"duration초 동안 평균 rate건/초로 발생하는 가상 전문 목록.\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " n = int(rng.poisson(rate * duration))\n", + " times = np.sort(rng.uniform(t_start, t_start + duration, n))\n", + " types = list(MSG_TYPES)\n", + " msgs = []\n", + " for i, t0 in enumerate(times):\n", + " ty = types[rng.choice(len(types), p=[MSG_TYPES[k][3] for k in types])]\n", + " cands, p, (lo, hi), _ = MSG_TYPES[ty]\n", + " msgs.append({\"id\": f\"{prefix}{i:04d}\", \"t0\": float(t0), \"type\": ty, \"prec\": str(rng.choice(cands, p=p)),\n", + " \"size\": int(rng.integers(lo, hi + 1)), \"src\": f\"U{int(rng.integers(1, 9)):02d}\"})\n", + " return msgs\n", + "\n", + "\n", + "class Link:\n", + " \"\"\"[교육용가상자료] 가상 협대역 무선 링크(기본 2.4kbps). 초 단위 상태(good/bad/jam)에 따라\n", + " 비트오류율이 바뀌고(Gilbert-Elliott 모델), 구간별 대역폭 감소도 줄 수 있다.\"\"\"\n", + " BER = {\"good\": 1e-5, \"bad\": 2e-4, \"jam\": 1.2e-3}\n", + "\n", + " def __init__(self, horizon=4000, bw_bps=2400, seed=1, p_gb=0.02, p_bg=0.15, jam=(), bw_drop=(), rtt=(1.2, 0.4)):\n", + " rng = np.random.default_rng(seed)\n", + " st, s = [], \"good\"\n", + " for _ in range(int(horizon) + 1):\n", + " s = (\"bad\" if rng.random() < p_gb else \"good\") if s == \"good\" else (\"good\" if rng.random() < p_bg else \"bad\")\n", + " st.append(s)\n", + " for a, b in jam:\n", + " for k in range(int(a), min(int(b), len(st))):\n", + " st[k] = \"jam\"\n", + " self.states, self.bw_bps, self.bw_drop, self.rtt_par = st, bw_bps, list(bw_drop), rtt\n", + "\n", + " def state(self, t):\n", + " return self.states[min(int(t), len(self.states) - 1)]\n", + "\n", + " def bw(self, t):\n", + " f = 1.0\n", + " for a, b, fac in self.bw_drop:\n", + " if a <= t < b:\n", + " f = fac\n", + " return self.bw_bps * f\n", + "\n", + " def p_loss(self, nbytes, t):\n", + " return 1 - (1 - self.BER[self.state(t)]) ** (8 * nbytes)\n", + "\n", + " def rtt(self, rng):\n", + " return self.rtt_par[0] + rng.exponential(self.rtt_par[1])\n", + "# ---------------------------------------------------------------------------\n", + "\n", + "# 1) 대역폭 제약 분석: 링크 속도별 전문 1건 전송시간(헤더 32bytes/조각 256bytes 기준, 재전송 제외)\n", + "print(\"[대역폭별 전문 1건 전송시간(초) - 가상 링크]\")\n", + "rows = []\n", + "for ty, (_, _, (lo, hi), _) in MSG_TYPES.items():\n", + " size = (lo + hi) / 2\n", + " nbytes = size + np.ceil(size / 256) * 32\n", + " rows.append({\"유형\": ty, \"평균 크기(B)\": int(size), **{f\"{bw / 1000:g}kbps\": round(nbytes * 8 / bw, 1) for bw in (1200, 2400, 9600, 16000)}})\n", + "print(pd.DataFrame(rows).to_string(index=False))\n", + "print(\"-> 2.4kbps에서 행정전문 1건이 약 10초 이상 채널을 점유한다. FLASH 전달 목표(10초)를 지키려면 우선순위 큐와 조각 단위 전송이 필요하다.\")\n", + "\n", + "# 2) 스키마 정의: 전문 헤더, 링크 조건, 전술 파라미터(기상·위치오차·통신지연)\n", + "SCHEMA = {\n", + " \"required\": [\"msg_id\", \"precedence\", \"msg_type\", \"src\", \"created_s\", \"payload_bytes\", \"link\", \"tactical\"],\n", + " \"fields\": {\n", + " \"msg_id\": {\"type\": str, \"pattern\": r\"^M\\d{4}$\"},\n", + " \"precedence\": {\"type\": str, \"enum\": PREC},\n", + " \"msg_type\": {\"type\": str, \"enum\": list(MSG_TYPES)},\n", + " \"src\": {\"type\": str, \"pattern\": r\"^U\\d{2}$\"},\n", + " \"created_s\": {\"type\": (int, float), \"min\": 0},\n", + " \"payload_bytes\": {\"type\": int, \"min\": 1, \"max\": 8000},\n", + " },\n", + " \"link\": {\n", + " \"band\": {\"type\": str, \"enum\": [\"VHF\", \"UHF\"]},\n", + " \"bandwidth_bps\": {\"type\": int, \"enum\": [1200, 2400, 9600, 16000]},\n", + " \"snr_db\": {\"type\": (int, float), \"min\": -10, \"max\": 40},\n", + " \"mtu_bytes\": {\"type\": int, \"min\": 32, \"max\": 1024},\n", + " },\n", + " \"tactical\": {\n", + " \"weather\": {\"type\": str, \"enum\": [\"clear\", \"rain\", \"fog\", \"snow\", \"night\"]},\n", + " \"position_error_m\": {\"type\": (int, float), \"min\": 0, \"max\": 500},\n", + " \"comm_delay_ms\": {\"type\": (int, float), \"min\": 0, \"max\": 30000},\n", + " \"relay_hops\": {\"type\": int, \"min\": 0, \"max\": 5},\n", + " },\n", + "}\n", + "\n", + "\n", + "def check(name, value, rule):\n", + " \"\"\"값 하나를 규칙(type, enum, min, max, pattern)으로 검사해 오류 목록을 돌려준다.\"\"\"\n", + " # [TODO : 학생 실습 1] 협대역 전술 무선망(VHF/UHF) 대역폭 제약 조건 분석 및 전술 메시지 구조 정의 관련 핵심 로직을 구현하세요.\n", + " # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n", + " # <<< 여기에 코드를 작성하세요 >>>\n", + " pass # 작성 후 pass를 제거하세요.\n", + "def validate(rec):\n", + " # [TODO : 학생 실습 1] 협대역 전술 무선망(VHF/UHF) 대역폭 제약 조건 분석 및 전술 메시지 구조 정의 관련 핵심 로직을 구현하세요.\n", + " # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n", + " # <<< 여기에 코드를 작성하세요 >>>\n", + " pass # 작성 후 pass를 제거하세요.\n", + "rng = np.random.default_rng(1)\n", + "records = [{\"msg_id\": m[\"id\"], \"precedence\": m[\"prec\"], \"msg_type\": m[\"type\"], \"src\": m[\"src\"],\n", + " \"created_s\": round(m[\"t0\"], 1), \"payload_bytes\": m[\"size\"],\n", + " \"link\": {\"band\": str(rng.choice([\"VHF\", \"UHF\"])), \"bandwidth_bps\": 2400, \"snr_db\": float(round(rng.uniform(3, 25), 1)), \"mtu_bytes\": 256},\n", + " \"tactical\": {\"weather\": str(rng.choice([\"clear\", \"rain\", \"fog\", \"snow\", \"night\"])), \"position_error_m\": float(round(rng.uniform(3, 50), 1)),\n", + " \"comm_delay_ms\": int(rng.integers(300, 3000)), \"relay_hops\": int(rng.integers(0, 3))}}\n", + " for m in make_messages(400, 0.12, seed=3)[:20]]\n", + "ok = sum(1 for r in records if not validate(r))\n", + "print(f\"\\n정상 샘플 {len(records)}건 중 검증 통과 {ok}건, 오류 {len(records) - ok}건\")\n", + "\n", + "# 4) 오류 주입 시험\n", + "bad = json.loads(json.dumps(records[0]))\n", + "bad[\"msg_id\"] = \"MSG-1\"\n", + "bad[\"precedence\"] = \"URGENT\"\n", + "bad[\"link\"][\"bandwidth_bps\"] = 3000\n", + "del bad[\"tactical\"][\"comm_delay_ms\"]\n", + "bad[\"tactical\"][\"relay_hops\"] = 9\n", + "print(\"[오류 주입 결과]\")\n", + "for e in validate(bad):\n", + " print(\" -\", e)\n", + "flash_big = {**records[0], \"precedence\": \"FLASH\", \"payload_bytes\": 4000}\n", + "print(\" -\", validate(flash_big)[0])\n", + "\n", + "# 5) 스키마 명세 요약\n", + "rows = []\n", + "for g in (\"fields\", \"link\", \"tactical\"):\n", + " for k, r in SCHEMA[g].items():\n", + " t = r[\"type\"].__name__ if isinstance(r[\"type\"], type) else \"number\"\n", + " rows.append((\"기본\" if g == \"fields\" else g, k, t, str({x: y for x, y in r.items() if x != \"type\"})[:58]))\n", + "print(\"\\n[데이터 명세 요약]\")\n", + "print(pd.DataFrame(rows, columns=[\"그룹\", \"필드\", \"자료형\", \"규칙\"]).to_string(index=False))\n", + "print(\"\\n긴급도별 전달 목표(초):\", DEADLINE)\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 01 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + "[대역폭별 전문 1건 전송시간(초) - 가상 링크]\n", + " 유형 평균 크기(B) 1.2kbps 2.4kbps 9.6kbps 16kbps\n", + "CONTACT 210 1.6 0.8 0.2 0.1\n", + "MEDEVAC 250 1.9 0.9 0.2 0.1\n", + " SITREP 1550 11.8 5.9 1.5 0.9\n", + " POSREP 90 0.8 0.4 0.1 0.1\n", + " LOGREQ 750 5.6 2.8 0.7 0.4\n", + " ADMIN 3000 22.6 11.3 2.8 1.7\n", + "-> 2.4kbps에서 행정전문 1건이 약 10초 이상 채널을 점유한다. FLASH 전달 목표(10초)를 지키려면 우선순위 큐와 조각 단위 전송이 필요하다.\n", + "\n", + "정상 샘플 20건 중 검증 통과 20건, 오류 0건\n", + "[오류 주입 결과]\n", + " - msg_id: 형식 불일치 'MSG-1'\n", + " - precedence: 허용되지 않은 값 'URGENT'\n", + " - link.bandwidth_bps: 허용되지 않은 값 '3000'\n", + " - tactical.comm_delay_ms: 필수 필드 누락\n", + " - tactical.relay_hops: 최대값 5 초과\n", + " - 업무 규칙: FLASH 전문은 2,000bytes 이하로 작성\n", + "\n", + "[데이터 명세 요약]\n", + " 그룹 필드 자료형 규칙\n", + " 기본 msg_id str {'pattern': '^M\\\\d{4}$'}\n", + " 기본 precedence str {'enum': ['FLASH', 'IMMEDIATE', 'PRIORITY', 'ROUTINE']}\n", + " 기본 msg_type str {'enum': ['CONTACT', 'MEDEVAC', 'SITREP', 'POSREP', 'LOGRE\n", + " 기본 src str {'pattern': '^U\\\\d{2}$'}\n", + " 기본 created_s number {'min': 0}\n", + " 기본 payload_bytes int {'min': 1, 'max': 8000}\n", + " link band str {'enum': ['VHF', 'UHF']}\n", + " link bandwidth_bps int {'enum': [1200, 2400, 9600, 16000]}\n", + " link snr_db number {'min': -10, 'max': 40}\n", + " link mtu_bytes int {'min': 32, 'max': 1024}\n", + "tactical weather str {'enum': ['clear', 'rain', 'fog', 'snow', 'night']}\n", + "tactical position_error_m number {'min': 0, 'max': 500}\n", + "tactical comm_delay_ms number {'min': 0, 'max': 30000}\n", + "tactical relay_hops int {'min': 0, 'max': 5}\n", + "\n", + "긴급도별 전달 목표(초): {'FLASH': 10, 'IMMEDIATE': 30, 'PRIORITY': 120, 'ROUTINE': 600}\n", + "```\n", + "
\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 02. 전술 메시지(전문) 패킷 파싱, 크기 압축 및 헤더 정보 전처리 모듈 구현\n", + "**소요시간**: 약 2.0시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "전술 메시지 패킷 파싱, 헤더 압축 및 페이로드 데이터 정규화 전처리 모듈을 구현한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **잡음이 섞인 원시 전문 패킷을 파싱하고 긴급도 표기 정규화, CRC 검사, 중복 제거, 결측 시각 보정, 헤더 이진 압축, 본문 압축을 수행하는 전처리 모듈을 구현한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **긴급도 표기 혼재, 재전송으로 인한 중복 수신, 비트 오류, 전송 중 잘림, 미등록 송신국(스푸핑 의심) 패킷을 포함한 가상 원시 패킷 작성하여 원인을 분석하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `src/preprocess/c202_preprocessor.py 및 전처리 전후 데이터셋` 형태의 결과물을 완성한다.\n", + "\n", + "### 🔍 평가 및 검증 기준 (PASS 조건)\n", + "#### 미션 판정\n", + "\n", + "**평가 구분:** 필수\n", + "\n", + "**핵심 평가영역:** 전술 환경 특유의 노이즈(스푸핑, 반사파, 센서 단절 등) 제거 타당성\n", + "\n", + "#### 미션 PASS 조건\n", + "\n", + "- 정량 기준: 데이터 정제 처리 속도 1,000건/초 이상, 전처리 오류율 0%\n", + "\n", + "\n", + "- 진행 기준: 정제 파이프라인 자동화 및 단위 테스트 케이스 5종 통과\n", + "\n", + "\n", + "- 검증 방법: pytest 기반 전처리 정합성 테스트 및 결측치 보정율 확인\n", + "\n", + "\n", + "#### 채점자 판정 메모\n", + "\n", + "> 단순 코드 실행 여부뿐만 아니라 출력된 수치와 근거 자료가 군 실무 요구사항에 부합하는지 면밀히 확인한다.\n", + "\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "\"\"\"가상 전술 전문 패킷(텍스트 헤더+본문)을 파싱하고 긴급도 표기 정규화·CRC 검사·중복 제거·결측 시각 보정·헤더 이진 압축·본문 압축을 수행하는 전처리 모듈을 구현해 처리 속도와 단위 테스트 통과 여부를 확인한다.\"\"\"\n", + "import struct\n", + "import time\n", + "import zlib\n", + "import numpy as np\n", + "import pandas as pd\n", + "\n", + "# ---------------------------------------------------------------------------\n", + "# [교육용가상자료] 가상 전술 전문(메시지) 생성기 (실제 부대·통신망·제원 정보 없음)\n", + "# 긴급도 4단계(FLASH/IMMEDIATE/PRIORITY/ROUTINE)와 전문 유형별 크기를 가진 전문을 만든다.\n", + "# ---------------------------------------------------------------------------\n", + "PREC = [\"FLASH\", \"IMMEDIATE\", \"PRIORITY\", \"ROUTINE\"]\n", + "RANK = {p: i for i, p in enumerate(PREC)}\n", + "DEADLINE = {\"FLASH\": 10, \"IMMEDIATE\": 30, \"PRIORITY\": 120, \"ROUTINE\": 600} # 가상 전달 목표 시간(초)\n", + "MSG_TYPES = { # 유형: (긴급도 후보, 확률, 본문 크기 범위 bytes, 발생 비율)\n", + " \"CONTACT\": ([\"FLASH\", \"IMMEDIATE\"], [0.7, 0.3], (120, 300), 0.08),\n", + " \"MEDEVAC\": ([\"FLASH\", \"IMMEDIATE\"], [0.4, 0.6], (150, 350), 0.05),\n", + " \"SITREP\": ([\"IMMEDIATE\", \"PRIORITY\"], [0.2, 0.8], (600, 2500), 0.20),\n", + " \"POSREP\": ([\"PRIORITY\", \"ROUTINE\"], [0.5, 0.5], (60, 120), 0.35),\n", + " \"LOGREQ\": ([\"PRIORITY\", \"ROUTINE\"], [0.4, 0.6], (300, 1200), 0.14),\n", + " \"ADMIN\": ([\"ROUTINE\"], [1.0], (1000, 5000), 0.18),\n", + "}\n", + "\n", + "\n", + "def make_messages(duration=600, rate=0.12, seed=7, t_start=0.0, prefix=\"M\"):\n", + " \"\"\"duration초 동안 평균 rate건/초로 발생하는 가상 전문 목록.\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " n = int(rng.poisson(rate * duration))\n", + " times = np.sort(rng.uniform(t_start, t_start + duration, n))\n", + " types = list(MSG_TYPES)\n", + " msgs = []\n", + " for i, t0 in enumerate(times):\n", + " ty = types[rng.choice(len(types), p=[MSG_TYPES[k][3] for k in types])]\n", + " cands, p, (lo, hi), _ = MSG_TYPES[ty]\n", + " msgs.append({\"id\": f\"{prefix}{i:04d}\", \"t0\": float(t0), \"type\": ty, \"prec\": str(rng.choice(cands, p=p)),\n", + " \"size\": int(rng.integers(lo, hi + 1)), \"src\": f\"U{int(rng.integers(1, 9)):02d}\"})\n", + " return msgs\n", + "\n", + "\n", + "class Link:\n", + " \"\"\"[교육용가상자료] 가상 협대역 무선 링크(기본 2.4kbps). 초 단위 상태(good/bad/jam)에 따라\n", + " 비트오류율이 바뀌고(Gilbert-Elliott 모델), 구간별 대역폭 감소도 줄 수 있다.\"\"\"\n", + " BER = {\"good\": 1e-5, \"bad\": 2e-4, \"jam\": 1.2e-3}\n", + "\n", + " def __init__(self, horizon=4000, bw_bps=2400, seed=1, p_gb=0.02, p_bg=0.15, jam=(), bw_drop=(), rtt=(1.2, 0.4)):\n", + " rng = np.random.default_rng(seed)\n", + " st, s = [], \"good\"\n", + " for _ in range(int(horizon) + 1):\n", + " s = (\"bad\" if rng.random() < p_gb else \"good\") if s == \"good\" else (\"good\" if rng.random() < p_bg else \"bad\")\n", + " st.append(s)\n", + " for a, b in jam:\n", + " for k in range(int(a), min(int(b), len(st))):\n", + " st[k] = \"jam\"\n", + " self.states, self.bw_bps, self.bw_drop, self.rtt_par = st, bw_bps, list(bw_drop), rtt\n", + "\n", + " def state(self, t):\n", + " return self.states[min(int(t), len(self.states) - 1)]\n", + "\n", + " def bw(self, t):\n", + " f = 1.0\n", + " for a, b, fac in self.bw_drop:\n", + " if a <= t < b:\n", + " f = fac\n", + " return self.bw_bps * f\n", + "\n", + " def p_loss(self, nbytes, t):\n", + " return 1 - (1 - self.BER[self.state(t)]) ** (8 * nbytes)\n", + "\n", + " def rtt(self, rng):\n", + " return self.rtt_par[0] + rng.exponential(self.rtt_par[1])\n", + "# ---------------------------------------------------------------------------\n", + "\n", + "PREC_ALIAS = {\"Z\": \"FLASH\", \"FLASH\": \"FLASH\", \"O\": \"IMMEDIATE\", \"IMMED\": \"IMMEDIATE\", \"IMMEDIATE\": \"IMMEDIATE\",\n", + " \"P\": \"PRIORITY\", \"PRIORITY\": \"PRIORITY\", \"R\": \"ROUTINE\", \"ROUTINE\": \"ROUTINE\"}\n", + "ROSTER = {f\"U{i:02d}\" for i in range(1, 9)} # 가상 망 가입국 목록\n", + "TYPES = list(MSG_TYPES)\n", + "\n", + "\n", + "def make_body(m, rng):\n", + " \"\"\"[교육용가상자료] 'KEY=VALUE;' 형식의 정형 본문(실제 전문 양식 아님).\"\"\"\n", + " lines, k = [], 0\n", + " while sum(map(len, lines)) < m[\"size\"]:\n", + " lines.append(f\"L{k:02d}={m['type']} UNIT {m['src']} ITEM {int(rng.integers(1, 40)):02d} QTY {int(rng.integers(0, 99)):02d} STAT OK;\")\n", + " k += 1\n", + " return \"\".join(lines)[:m[\"size\"]]\n", + "\n", + "\n", + "def make_raw_packets(msgs, seed=5):\n", + " \"\"\"전술 환경 잡음을 넣은 원시 패킷 줄: 긴급도 표기 혼재, 중복 수신, 비트 오류(CRC 불일치), 시각 누락, 잘림, 미등록 송신국.\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " out = []\n", + " for m in msgs:\n", + " body = make_body(m, rng)\n", + " prec = rng.choice([PREC[RANK[m[\"prec\"]]], PREC[RANK[m[\"prec\"]]].lower(), \"ZOPR\"[RANK[m[\"prec\"]]], \" \" + \"ZOPR\"[RANK[m[\"prec\"]]] + \" \"])\n", + " dtg = \"\" if rng.random() < 0.05 else f\"{m['t0']:.1f}\"\n", + " src = m[\"src\"] if rng.random() > 0.02 else \"X77\"\n", + " line = f\"ZCZC|ID={m['id']}|PREC={prec}|TYPE={m['type']}|SRC={src}|DTG={dtg}|LEN={len(body)}|CRC={zlib.crc32(body.encode()):08x}|BODY={body}\"\n", + " r = rng.random()\n", + " if r < 0.03: # 비트 오류: 본문 일부 변형\n", + " line = line[:-5] + \"#\" + line[-4:]\n", + " elif r < 0.05: # 전송 중 잘림\n", + " line = line[:int(len(line) * 0.4)]\n", + " out.append(line)\n", + " if rng.random() < 0.06: # 재전송으로 인한 중복 수신\n", + " out.append(line)\n", + " return out\n", + "\n", + "\n", + "class ParseError(ValueError):\n", + " pass\n", + "\n", + "\n", + "def parse(line):\n", + " # [TODO : 학생 실습 2] 전술 메시지(전문) 패킷 파싱, 크기 압축 및 헤더 정보 전처리 모듈 구현 관련 핵심 로직을 구현하세요.\n", + " # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n", + " # <<< 여기에 코드를 작성하세요 >>>\n", + " pass # 작성 후 pass를 제거하세요.\n", + "def pack_header(r):\n", + " \"\"\"헤더 이진 압축: ID(2) + 긴급도·유형·송신국(2) + 시각(4, 0.1초) + 길이(2) + CRC(4) = 14bytes.\"\"\"\n", + " # [TODO : 학생 실습 2] 전술 메시지(전문) 패킷 파싱, 크기 압축 및 헤더 정보 전처리 모듈 구현 관련 핵심 로직을 구현하세요.\n", + " # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n", + " # <<< 여기에 코드를 작성하세요 >>>\n", + " pass # 작성 후 pass를 제거하세요.\n", + "def unpack_header(b):\n", + " i, flags, dtg, ln, crc = struct.unpack(\">HHIHI\", b)\n", + " return {\"id\": f\"M{i:04d}\", \"prec\": PREC[flags >> 12], \"type\": TYPES[(flags >> 8) & 0xF], \"src\": f\"U{flags & 0xFF:02d}\", \"dtg\": dtg / 10, \"len\": ln}\n", + "\n", + "\n", + "ZLEVEL = {1200: 9, 2400: 9, 9600: 6, 16000: 1} # 대역폭이 좁을수록 압축을 강하게\n", + "\n", + "\n", + "def preprocess(lines, bw_bps=2400):\n", + " good, quarantine, seen = [], [], set()\n", + " for ln in lines:\n", + " try:\n", + " r = parse(ln)\n", + " except (ParseError, ValueError) as e:\n", + " quarantine.append(str(e)); continue\n", + " if r[\"id\"] in seen:\n", + " quarantine.append(\"중복 수신(제거)\"); continue\n", + " seen.add(r[\"id\"]); good.append(r)\n", + " good.sort(key=lambda r: int(r[\"id\"][1:]))\n", + " dtg = pd.Series([r[\"dtg\"] for r in good], dtype=float)\n", + " miss = int(dtg.isna().sum())\n", + " filled = dtg.interpolate(limit_direction=\"both\") # 앞뒤 전문 시각으로 선형 보간\n", + " for r, v in zip(good, filled):\n", + " r[\"dtg_filled\"] = bool(np.isnan(r[\"dtg\"])); r[\"dtg\"] = float(v)\n", + " r[\"hdr_packed\"] = pack_header(r)\n", + " r[\"body_z\"] = zlib.compress(r[\"body\"].encode(), ZLEVEL[bw_bps])\n", + " return good, quarantine, miss\n", + "\n", + "\n", + "msgs = make_messages(1200, 0.21, seed=7)\n", + "lines = make_raw_packets(msgs)\n", + "t0 = time.perf_counter()\n", + "good, quarantine, miss = preprocess(lines)\n", + "dt = time.perf_counter() - t0\n", + "print(f\"원시 패킷 {len(lines)}줄 -> 정상 {len(good)}건, 격리 {len(quarantine)}건 / 처리 속도 {len(lines) / dt:,.0f}건/초 (목표 1,000건/초 이상)\")\n", + "print(\"처리 중 예외로 멈춘 건수 0건(오류율 0%) - 비정상 패킷은 사유와 함께 격리\")\n", + "print(\"격리 사유:\", pd.Series(quarantine).value_counts().to_dict())\n", + "print(f\"DTG 결측 {miss}건 중 보정 {sum(r['dtg_filled'] for r in good)}건 (보정율 {sum(r['dtg_filled'] for r in good) / max(miss, 1):.0%})\")\n", + "\n", + "# 전처리 전후 크기 비교(전문 유형별 평균 bytes)\n", + "df = pd.DataFrame([{\"유형\": r[\"type\"], \"원본 헤더\": r[\"hdr_bytes\"], \"압축 헤더\": len(r[\"hdr_packed\"]),\n", + " \"원본 본문\": len(r[\"body\"]), \"압축 본문\": len(r[\"body_z\"])} for r in good])\n", + "tab = df.groupby(\"유형\").mean().round(0).astype(int)\n", + "tab[\"전체 절감률\"] = (1 - (tab[\"압축 헤더\"] + tab[\"압축 본문\"]) / (tab[\"원본 헤더\"] + tab[\"원본 본문\"])).round(3)\n", + "print(\"\\n[전처리 전후 평균 크기(bytes)]\")\n", + "print(tab.to_string())\n", + "tot0, tot1 = (df[\"원본 헤더\"] + df[\"원본 본문\"]).sum(), (df[\"압축 헤더\"] + df[\"압축 본문\"]).sum()\n", + "print(f\"전체 {tot0:,}B -> {tot1:,}B, 2.4kbps 기준 전송시간 {tot0 * 8 / 2400 / 60:.1f}분 -> {tot1 * 8 / 2400 / 60:.1f}분\")\n", + "print(\"상황별 압축 수준(대역폭 bps: zlib 수준):\", ZLEVEL)\n", + "print(\"주의: 가상 본문은 정형 반복 문구라 압축률이 높게 나온다. 자유 서술 본문은 절감률이 이보다 낮다.\")\n", + "\n", + "# 단위 테스트 5종(+1): 조건을 알고 있는 시험용 패킷으로 확인\n", + "def mk(prec=\"Z\", src=\"U01\", body=\"L00=TEST UNIT U01;\", crc=None):\n", + " return f\"ZCZC|ID=M9999|PREC={prec}|TYPE=CONTACT|SRC={src}|DTG=1.0|LEN={len(body)}|CRC={crc or f'{zlib.crc32(body.encode()):08x}'}|BODY={body}\"\n", + "\n", + "\n", + "def raises(line):\n", + " try:\n", + " parse(line)\n", + " return False\n", + " except ParseError:\n", + " return True\n", + "\n", + "\n", + "r0 = good[0]\n", + "tests = {\n", + " \"정상 패킷 파싱\": parse(mk())[\"prec\"] == \"FLASH\",\n", + " \"긴급도 표기 정규화(' o ' -> IMMEDIATE)\": parse(mk(prec=\" o \"))[\"prec\"] == \"IMMEDIATE\",\n", + " \"CRC 불일치 검출\": raises(mk(crc=\"00000000\")),\n", + " \"미등록 송신국 격리\": raises(mk(src=\"X77\")),\n", + " \"중복 수신 제거\": len(preprocess([mk(), mk()])[0]) == 1 and len({r[\"id\"] for r in good}) == len(good),\n", + " \"결측 시각 보간 + 헤더 압축 왕복 일치\": not any(np.isnan(r[\"dtg\"]) for r in good) and unpack_header(r0[\"hdr_packed\"])[\"id\"] == r0[\"id\"],\n", + "}\n", + "for k, v in tests.items():\n", + " print(f\"[{'PASS' if v else 'FAIL'}] {k}\")\n", + "print(f\"단위 테스트 {sum(tests.values())}/{len(tests)} 통과\")\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 02 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + "원시 패킷 278줄 -> 정상 241건, 격리 37건 / 처리 속도 19,781건/초 (목표 1,000건/초 이상)\n", + "처리 중 예외로 멈춘 건수 0건(오류율 0%) - 비정상 패킷은 사유와 함께 격리\n", + "격리 사유: {'중복 수신(제거)': 18, 'CRC/길이 불일치(비트 오류)': 11, '미등록 송신국(스푸핑 의심)': 5, '필드 누락(BODY)': 3}\n", + "DTG 결측 15건 중 보정 15건 (보정율 100%)\n", + "\n", + "[전처리 전후 평균 크기(bytes)]\n", + " 원본 헤더 압축 헤더 원본 본문 압축 본문 전체 절감률\n", + "유형 \n", + "ADMIN 80 14 3506 555 0.841\n", + "CONTACT 81 14 227 93 0.653\n", + "LOGREQ 81 14 760 178 0.772\n", + "MEDEVAC 80 14 206 91 0.633\n", + "POSREP 80 14 89 63 0.544\n", + "SITREP 82 14 1666 303 0.819\n", + "전체 276,195B -> 54,134B, 2.4kbps 기준 전송시간 15.3분 -> 3.0분\n", + "상황별 압축 수준(대역폭 bps: zlib 수준): {1200: 9, 2400: 9, 9600: 6, 16000: 1}\n", + "주의: 가상 본문은 정형 반복 문구라 압축률이 높게 나온다. 자유 서술 본문은 절감률이 이보다 낮다.\n", + "[PASS] 정상 패킷 파싱\n", + "[PASS] 긴급도 표기 정규화(' o ' -> IMMEDIATE)\n", + "[PASS] CRC 불일치 검출\n", + "[PASS] 미등록 송신국 격리\n", + "[PASS] 중복 수신 제거\n", + "[PASS] 결측 시각 보간 + 헤더 압축 왕복 일치\n", + "단위 테스트 6/6 통과\n", + "```\n", + "
\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 03. 메시지 긴급도(Flash/Immediate/Priority/Routine) 기반 1차 우선순위 큐(Queue) 구현\n", + "**소요시간**: 약 2.5시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "전문 긴급도(Flash/Immediate/Priority/Routine) 기반 1차 메시지 우선순위 큐(Queue)를 구현한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **전문 긴급도(FLASH/IMMEDIATE/PRIORITY/ROUTINE)에 따라 먼저 보낼 전문을 고르는 1차 우선순위 큐를 구현하고 도착순 전송과 성능을 비교한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **heapq로 긴급도 우선·같은 긴급도는 도착순을 지키는 큐를 만들고 동작 확인하고 유효성을 점검하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `src/models/c202_baseline_model.py 및 기본 추론 결과 보고서` 형태의 결과물을 완성한다.\n", + "\n", + "### 🔍 평가 및 검증 기준 (PASS 조건)\n", + "#### 미션 판정\n", + "\n", + "**평가 구분:** 필수\n", + "\n", + "**핵심 평가영역:** 알고리즘의 논리적 흐름 및 군사적 판단 기준의 투명성\n", + "\n", + "#### 미션 PASS 조건\n", + "\n", + "- 정량 기준: 기본 데이터셋 대상 탐지/처리 정확도 75% 이상 달성\n", + "\n", + "\n", + "- 진행 기준: 단위 기능 동작 확인 및 기본 시나리오 실행 로그 생성\n", + "\n", + "\n", + "- 검증 방법: 기준 벤치마크 데이터셋 대상 추론 결과 오차율 비교\n", + "\n", + "\n", + "#### 채점자 판정 메모\n", + "\n", + "> 단순 코드 실행 여부뿐만 아니라 출력된 수치와 근거 자료가 군 실무 요구사항에 부합하는지 면밀히 확인한다.\n", + "\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "\"\"\"전문 긴급도(FLASH/IMMEDIATE/PRIORITY/ROUTINE) 기반 1차 우선순위 큐(heapq)를 구현하고, 가상 협대역 링크 전송 시뮬레이션으로 도착순(FIFO) 전송과 긴급도별 기한 내 전달률(처리 정확도)을 비교한다.\"\"\"\n", + "import heapq\n", + "import itertools\n", + "import time\n", + "import numpy as np\n", + "import pandas as pd\n", + "import matplotlib.pyplot as plt\n", + "\n", + "# ---------------------------------------------------------------------------\n", + "# [교육용가상자료] 가상 전술 전문(메시지) 생성기 (실제 부대·통신망·제원 정보 없음)\n", + "# 긴급도 4단계(FLASH/IMMEDIATE/PRIORITY/ROUTINE)와 전문 유형별 크기를 가진 전문을 만든다.\n", + "# ---------------------------------------------------------------------------\n", + "PREC = [\"FLASH\", \"IMMEDIATE\", \"PRIORITY\", \"ROUTINE\"]\n", + "RANK = {p: i for i, p in enumerate(PREC)}\n", + "DEADLINE = {\"FLASH\": 10, \"IMMEDIATE\": 30, \"PRIORITY\": 120, \"ROUTINE\": 600} # 가상 전달 목표 시간(초)\n", + "MSG_TYPES = { # 유형: (긴급도 후보, 확률, 본문 크기 범위 bytes, 발생 비율)\n", + " \"CONTACT\": ([\"FLASH\", \"IMMEDIATE\"], [0.7, 0.3], (120, 300), 0.08),\n", + " \"MEDEVAC\": ([\"FLASH\", \"IMMEDIATE\"], [0.4, 0.6], (150, 350), 0.05),\n", + " \"SITREP\": ([\"IMMEDIATE\", \"PRIORITY\"], [0.2, 0.8], (600, 2500), 0.20),\n", + " \"POSREP\": ([\"PRIORITY\", \"ROUTINE\"], [0.5, 0.5], (60, 120), 0.35),\n", + " \"LOGREQ\": ([\"PRIORITY\", \"ROUTINE\"], [0.4, 0.6], (300, 1200), 0.14),\n", + " \"ADMIN\": ([\"ROUTINE\"], [1.0], (1000, 5000), 0.18),\n", + "}\n", + "\n", + "\n", + "def make_messages(duration=600, rate=0.12, seed=7, t_start=0.0, prefix=\"M\"):\n", + " \"\"\"duration초 동안 평균 rate건/초로 발생하는 가상 전문 목록.\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " n = int(rng.poisson(rate * duration))\n", + " times = np.sort(rng.uniform(t_start, t_start + duration, n))\n", + " types = list(MSG_TYPES)\n", + " msgs = []\n", + " for i, t0 in enumerate(times):\n", + " ty = types[rng.choice(len(types), p=[MSG_TYPES[k][3] for k in types])]\n", + " cands, p, (lo, hi), _ = MSG_TYPES[ty]\n", + " msgs.append({\"id\": f\"{prefix}{i:04d}\", \"t0\": float(t0), \"type\": ty, \"prec\": str(rng.choice(cands, p=p)),\n", + " \"size\": int(rng.integers(lo, hi + 1)), \"src\": f\"U{int(rng.integers(1, 9)):02d}\"})\n", + " return msgs\n", + "\n", + "\n", + "class Link:\n", + " \"\"\"[교육용가상자료] 가상 협대역 무선 링크(기본 2.4kbps). 초 단위 상태(good/bad/jam)에 따라\n", + " 비트오류율이 바뀌고(Gilbert-Elliott 모델), 구간별 대역폭 감소도 줄 수 있다.\"\"\"\n", + " BER = {\"good\": 1e-5, \"bad\": 2e-4, \"jam\": 1.2e-3}\n", + "\n", + " def __init__(self, horizon=4000, bw_bps=2400, seed=1, p_gb=0.02, p_bg=0.15, jam=(), bw_drop=(), rtt=(1.2, 0.4)):\n", + " rng = np.random.default_rng(seed)\n", + " st, s = [], \"good\"\n", + " for _ in range(int(horizon) + 1):\n", + " s = (\"bad\" if rng.random() < p_gb else \"good\") if s == \"good\" else (\"good\" if rng.random() < p_bg else \"bad\")\n", + " st.append(s)\n", + " for a, b in jam:\n", + " for k in range(int(a), min(int(b), len(st))):\n", + " st[k] = \"jam\"\n", + " self.states, self.bw_bps, self.bw_drop, self.rtt_par = st, bw_bps, list(bw_drop), rtt\n", + "\n", + " def state(self, t):\n", + " return self.states[min(int(t), len(self.states) - 1)]\n", + "\n", + " def bw(self, t):\n", + " f = 1.0\n", + " for a, b, fac in self.bw_drop:\n", + " if a <= t < b:\n", + " f = fac\n", + " return self.bw_bps * f\n", + "\n", + " def p_loss(self, nbytes, t):\n", + " return 1 - (1 - self.BER[self.state(t)]) ** (8 * nbytes)\n", + "\n", + " def rtt(self, rng):\n", + " return self.rtt_par[0] + rng.exponential(self.rtt_par[1])\n", + "# ---------------------------------------------------------------------------\n", + "\n", + "# ---- 우선순위 큐·재전송 이산 사건 시뮬레이터 공통 함수 ----\n", + "HDR = 32 # 전문 조각(패킷) 헤더 bytes (미션 2의 헤더 압축 적용 시 12 bytes)\n", + "\n", + "\n", + "def policy_key(m, t, policy, aging):\n", + " # [TODO : 학생 실습 3] 메시지 긴급도(Flash/Immediate/Priority/Routine) 기반 1차 우선순위 큐(Queue) 구현 관련 핵심 로직을 구현하세요.\n", + " # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n", + " # <<< 여기에 코드를 작성하세요 >>>\n", + " pass # 작성 후 pass를 제거하세요.\n", + "def simulate(msgs, link, policy=\"strict\", mtu=256, preempt=True, rto_mode=\"fixed\", rto_s=4.0, aging=0.0,\n", + " redundancy=False, adapt_mtu=False, hdr=HDR, seed=0, until=None, admit=None):\n", + " \"\"\"전문 목록을 링크로 보내는 이산 사건 시뮬레이션.\n", + " - 전문은 mtu 크기 조각으로 나눠 보낸다. 조각이 손실되면 RTO 후 재전송한다(자료는 버리지 않음).\n", + " - preempt=False면 한 전문의 조각을 끝까지 연속 전송한다(큰 전문이 긴급 전문을 막는 현상 재현).\n", + " - rto_mode='adaptive'면 왕복시간(RTT) 추정값으로 RTO를 계산한다(Jacobson 방식).\n", + " - redundancy=True면 링크 열화 추정 시 FLASH·IMMEDIATE 조각을 2회 연속 전송한다.\n", + " - adapt_mtu=True면 링크 열화 추정 시 조각 크기를 줄인다.\n", + " - admit(m, t)이 False인 전문은 새 조각을 보내지 않고 저장해 둔다(성능 저하 모드).\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " pend = sorted(({**m, \"unsent\": m[\"size\"], \"ready\": [], \"frags\": [], \"done\": None, \"first\": None} for m in msgs), key=lambda m: m[\"t0\"])\n", + " t, i, active, cur = 0.0, 0, [], None\n", + " srtt, rttvar, loss = None, None, 0.0\n", + " st = {\"tx\": 0, \"retx\": 0, \"spurious\": 0, \"bytes\": 0, \"decide_ms\": [], \"flag\": {}, \"log\": []}\n", + " until = until if until is not None else (pend[-1][\"t0\"] if pend else 0) + 6000\n", + " while (i < len(pend) or active) and t < until:\n", + " while i < len(pend) and pend[i][\"t0\"] <= t:\n", + " active.append(pend[i]); i += 1\n", + " td = time.perf_counter()\n", + " for m in active: # ACK가 이미 도착한 재전송 예약은 취소\n", + " m[\"ready\"] = [e for e in m[\"ready\"] if not (e[1][\"ack\"] is not None and e[1][\"ack\"] <= t)]\n", + " ready = [m for m in active if any(r <= t for r, _ in m[\"ready\"]) or (m[\"unsent\"] > 0 and (admit is None or admit(m, t)))]\n", + " if not ready:\n", + " nxt = ([pend[i][\"t0\"]] if i < len(pend) else []) + [r for m in active for r, _ in m[\"ready\"] if r > t]\n", + " t = min(nxt) if nxt else t + 1.0\n", + " continue\n", + " if not preempt and cur in ready and cur[\"unsent\"] > 0:\n", + " m = cur\n", + " else:\n", + " m = min(ready, key=lambda x: policy_key(x, t, policy, aging))\n", + " st[\"decide_ms\"].append((time.perf_counter() - td) * 1000)\n", + " due = [e for e in m[\"ready\"] if e[0] <= t]\n", + " if due:\n", + " e = min(due, key=lambda x: x[0]); m[\"ready\"].remove(e); frag = e[1]\n", + " st[\"retx\"] += 1; st[\"spurious\"] += frag[\"recv\"]\n", + " else:\n", + " size = mtu if not adapt_mtu else (mtu if loss < 0.15 else (mtu // 2 if loss < 0.4 else mtu // 4))\n", + " size = min(m[\"unsent\"], size); m[\"unsent\"] -= size\n", + " frag = {\"b\": size, \"recv\": False, \"ack\": None, \"tries\": 0}\n", + " m[\"frags\"].append(frag)\n", + " cur = m\n", + " m[\"first\"] = t if m[\"first\"] is None else m[\"first\"]\n", + " nb = frag[\"b\"] + hdr\n", + " copies = 2 if (redundancy and RANK[m[\"prec\"]] <= 1 and loss > 0.15) else 1\n", + " for _ in range(copies):\n", + " t += nb * 8 / link.bw(t)\n", + " st[\"tx\"] += 1; st[\"bytes\"] += nb\n", + " ok = rng.random() >= link.p_loss(nb, t)\n", + " st[\"log\"].append((t, nb, ok, m[\"prec\"], m[\"id\"])) # 전송 기록(시각, bytes, 성공, 긴급도, 전문)\n", + " loss = 0.85 * loss + 0.15 * (0.0 if ok else 1.0) # 최근 손실률 추정(EWMA)\n", + " st[\"flag\"][int(t)] = st[\"flag\"].get(int(t), False) or loss > 0.12 # 링크 열화 판정(초 단위)\n", + " if ok and not frag[\"recv\"]:\n", + " frag[\"recv\"], frag[\"rt\"] = True, t\n", + " r = link.rtt(rng); frag[\"ack\"] = t + r\n", + " if srtt is None:\n", + " srtt, rttvar = r, r / 2\n", + " else:\n", + " rttvar = 0.75 * rttvar + 0.25 * abs(srtt - r); srtt = 0.875 * srtt + 0.125 * r\n", + " frag[\"tries\"] += 1\n", + " rto = rto_s if (rto_mode == \"fixed\" or srtt is None) else float(np.clip(srtt + 4 * rttvar, 1.0, 30.0))\n", + " if not frag[\"recv\"] or frag[\"ack\"] > t + rto: # 손실 또는 ACK 지연 -> 재전송 예약\n", + " m[\"ready\"].append((t + rto, frag))\n", + " if m[\"done\"] is None and m[\"unsent\"] == 0 and all(f[\"recv\"] for f in m[\"frags\"]):\n", + " m[\"done\"] = max(f[\"rt\"] for f in m[\"frags\"])\n", + " active = [x for x in active if not (x[\"unsent\"] == 0 and not x[\"ready\"] and all(f[\"recv\"] for f in x[\"frags\"]))]\n", + " res = [{\"id\": m[\"id\"], \"type\": m[\"type\"], \"prec\": m[\"prec\"], \"size\": m[\"size\"], \"t0\": m[\"t0\"], \"done\": m[\"done\"], \"wait\": None if m[\"first\"] is None else m[\"first\"] - m[\"t0\"],\n", + " \"retx\": sum(f[\"tries\"] for f in m[\"frags\"]) - len(m[\"frags\"]),\n", + " \"latency\": None if m[\"done\"] is None else m[\"done\"] - m[\"t0\"]} for m in pend]\n", + " for r in res:\n", + " r[\"on_time\"] = r[\"latency\"] is not None and r[\"latency\"] <= DEADLINE[r[\"prec\"]]\n", + " return res, st\n", + "\n", + "\n", + "def summarize(res):\n", + " \"\"\"긴급도별 기한 내 전달률·지연. 종합 정확도 = 긴급도별 기한 내 전달률의 평균.\"\"\"\n", + " # [TODO : 학생 실습 3] 메시지 긴급도(Flash/Immediate/Priority/Routine) 기반 1차 우선순위 큐(Queue) 구현 관련 핵심 로직을 구현하세요.\n", + " # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n", + " # <<< 여기에 코드를 작성하세요 >>>\n", + " pass # 작성 후 pass를 제거하세요.\n", + "class PrecedenceQueue:\n", + " \"\"\"긴급도 순위가 높은 전문을 먼저 꺼내고, 같은 긴급도는 도착 순서(FIFO)를 지키는 1차 우선순위 큐.\"\"\"\n", + "\n", + " def __init__(self):\n", + " self.h, self.seq = [], itertools.count()\n", + "\n", + " def push(self, m):\n", + " heapq.heappush(self.h, (RANK[m[\"prec\"]], m[\"t0\"], next(self.seq), m))\n", + "\n", + " def pop(self):\n", + " return heapq.heappop(self.h)[-1]\n", + "\n", + " def __len__(self):\n", + " return len(self.h)\n", + "\n", + "\n", + "# 1) 큐 동작 확인: 뒤섞여 도착한 전문이 긴급도 순서로 나오는가\n", + "demo = [{\"id\": f\"D{i}\", \"prec\": p, \"t0\": float(i)} for i, p in enumerate([\"ROUTINE\", \"PRIORITY\", \"FLASH\", \"ROUTINE\", \"IMMEDIATE\", \"FLASH\"])]\n", + "q = PrecedenceQueue()\n", + "for m in demo:\n", + " q.push(m)\n", + "out = [q.pop() for _ in range(len(q))]\n", + "print(\"도착 순서:\", [f\"{m['id']}:{m['prec'][0]}\" for m in demo])\n", + "print(\"꺼낸 순서:\", [f\"{m['id']}:{m['prec'][0]}\" for m in out])\n", + "print(\"같은 긴급도 FIFO 유지:\", [m[\"id\"] for m in out if m[\"prec\"] == \"FLASH\"] == [\"D2\", \"D5\"])\n", + "\n", + "# 2) [교육용가상자료] 20분간 전문 약 250건, 2.4kbps 링크에서 전송 시뮬레이션\n", + "msgs = make_messages(1200, 0.21, seed=7)\n", + "link = Link(8000, seed=1)\n", + "t0 = time.perf_counter()\n", + "fifo, _ = simulate(msgs, link, \"fifo\")\n", + "pq, st = simulate(msgs, link, \"strict\", preempt=False) # 1차 큐: 전문 단위로 꺼내 끝까지 전송\n", + "elapsed = time.perf_counter() - t0\n", + "tf, acc_f = summarize(fifo)\n", + "tp, acc_p = summarize(pq)\n", + "tab = tp[[\"긴급도\", \"건수\"]].copy()\n", + "tab[\"FIFO 기한내\"] = tf[\"기한내 전달률\"]; tab[\"FIFO 95%지연\"] = tf[\"95% 지연(s)\"]\n", + "tab[\"우선순위큐 기한내\"] = tp[\"기한내 전달률\"]; tab[\"우선순위큐 95%지연\"] = tp[\"95% 지연(s)\"]\n", + "print(f\"\\n전문 {len(msgs)}건, 조각 전송 {st['tx']}회(재전송 {st['retx']}회), 시뮬레이션 2회 {elapsed:.2f}초\")\n", + "print(tab.to_string(index=False))\n", + "all_on = np.mean([r[\"on_time\"] for r in pq])\n", + "print(f\"\\n처리 정확도(전체 기한 내 전달률) FIFO {np.mean([r['on_time'] for r in fifo]):.1%} -> 우선순위 큐 {all_on:.1%} (목표 75% 이상: {'달성' if all_on >= 0.75 else '미달'})\")\n", + "print(f\"종합 정확도(긴급도별 전달률 평균) FIFO {acc_f:.3f} -> 우선순위 큐 {acc_p:.3f}\")\n", + "\n", + "# 3) 한계 분석: 기한을 놓친 FLASH 전문은 왜 늦었나\n", + "late = [r for r in pq if r[\"prec\"] == \"FLASH\" and not r[\"on_time\"]]\n", + "for r in late:\n", + " blocker = [m for m in msgs if m[\"t0\"] < r[\"t0\"] and m[\"size\"] >= 1000 and r[\"t0\"] - m[\"t0\"] < 30]\n", + " print(f\"FLASH {r['id']} 지연 {r['latency']:.1f}s - 직전 30초 내 도착한 1,000B 이상 대용량 전문 {len(blocker)}건이 채널 점유(전문 단위 비선점)\")\n", + "print(\"-> 전문을 통째로 보내는 1차 큐는 대용량 전문이 채널을 점유하면 FLASH도 기다린다. 미션 4에서 조각 단위 선점과 재전송 개선으로 보완한다.\")\n", + "\n", + "# 4) 링크 속도별 민감도\n", + "print(\"\\n링크(bps) FIFO 전체 우선순위큐 전체 우선순위큐 FLASH\")\n", + "for bw in [1200, 2400, 4800]:\n", + " lk = Link(20000, bw_bps=bw, seed=1)\n", + " a, _ = simulate(msgs, lk, \"fifo\"); b, _ = simulate(msgs, lk, \"strict\", preempt=False)\n", + " fl = [r[\"on_time\"] for r in b if r[\"prec\"] == \"FLASH\"]\n", + " print(f\"{bw:>8} {np.mean([r['on_time'] for r in a]):8.1%} {np.mean([r['on_time'] for r in b]):12.1%} {np.mean(fl):12.1%}\")\n", + "\n", + "plt.figure(figsize=(8, 4))\n", + "data = [[r[\"latency\"] for r in res if r[\"prec\"] == p] for res in (fifo, pq) for p in PREC]\n", + "plt.boxplot(data, showfliers=False)\n", + "plt.xticks(range(1, 9), [f\"{n}\\n{p[:5]}\" for n in (\"FIFO\", \"PQ\") for p in PREC])\n", + "plt.yscale(\"log\"); plt.ylabel(\"delivery latency (s, log)\"); plt.title(\"FIFO vs precedence queue (synthetic 2.4 kbps link)\")\n", + "plt.tight_layout(); plt.show()\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 03 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + "도착 순서: ['D0:R', 'D1:P', 'D2:F', 'D3:R', 'D4:I', 'D5:F']\n", + "꺼낸 순서: ['D2:F', 'D5:F', 'D4:I', 'D1:P', 'D0:R', 'D3:R']\n", + "같은 긴급도 FIFO 유지: True\n", + "\n", + "전문 258건, 조각 전송 1285회(재전송 71회), 시뮬레이션 2회 0.03초\n", + " 긴급도 건수 FIFO 기한내 FIFO 95%지연 우선순위큐 기한내 우선순위큐 95%지연\n", + " FLASH 25 0.200 48.4 0.76 14.4\n", + "IMMEDIATE 27 0.741 45.8 1.00 17.8\n", + " PRIORITY 99 1.000 53.2 1.00 25.7\n", + " ROUTINE 107 1.000 57.7 1.00 82.3\n", + "\n", + "처리 정확도(전체 기한 내 전달률) FIFO 89.5% -> 우선순위 큐 97.7% (목표 75% 이상: 달성)\n", + "종합 정확도(긴급도별 전달률 평균) FIFO 0.735 -> 우선순위 큐 0.940\n", + "FLASH M0010 지연 17.4s - 직전 30초 내 도착한 1,000B 이상 대용량 전문 2건이 채널 점유(전문 단위 비선점)\n", + "FLASH M0041 지연 13.1s - 직전 30초 내 도착한 1,000B 이상 대용량 전문 3건이 채널 점유(전문 단위 비선점)\n", + "FLASH M0105 지연 13.2s - 직전 30초 내 도착한 1,000B 이상 대용량 전문 2건이 채널 점유(전문 단위 비선점)\n", + "FLASH M0226 지연 14.7s - 직전 30초 내 도착한 1,000B 이상 대용량 전문 3건이 채널 점유(전문 단위 비선점)\n", + "FLASH M0228 지연 13.1s - 직전 30초 내 도착한 1,000B 이상 대용량 전문 3건이 채널 점유(전문 단위 비선점)\n", + "FLASH M0233 지연 13.2s - 직전 30초 내 도착한 1,000B 이상 대용량 전문 3건이 채널 점유(전문 단위 비선점)\n", + "-> 전문을 통째로 보내는 1차 큐는 대용량 전문이 채널을 점유하면 FLASH도 기다린다. 미션 4에서 조각 단위 선점과 재전송 개선으로 보완한다.\n", + "\n", + "링크(bps) FIFO 전체 우선순위큐 전체 우선순위큐 FLASH\n", + " 1200 34.9% 58.9% 44.0%\n", + " 2400 89.5% 97.7% 76.0%\n", + " 4800 100.0% 100.0% 100.0%\n", + "```\n", + "
\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 04. 전장 상황 및 전송 실패율에 따른 동적 패킷 스케줄링 및 지능형 재전송 알고리즘 구현\n", + "**소요시간**: 약 3.0시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "동적 채널 상태 및 패킷 손실률에 따라 최적 재전송 간격을 조정하는 지능형 스케줄링 알고리즘을 고도화한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **채널 손실률과 왕복시간을 추정해 전송 순서와 재전송 간격을 조정하는 지능형 스케줄링·재전송 알고리즘으로 고도화한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **조각 단위 선점, 대기시간 가중(aging), 왕복시간 기반 적응형 재전송 타이머, 링크 열화 시 긴급 전문 중복 전송과 조각 크기 축소 구현하고 동작을 검증하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `src/models/c202_advanced_detector.py 및 예외 탐지 테스트 결과` 형태의 결과물을 완성한다.\n", + "\n", + "### 🔍 평가 및 검증 기준 (PASS 조건)\n", + "#### 미션 판정\n", + "\n", + "**평가 구분:** 필수\n", + "\n", + "**핵심 평가영역:** 기만/교란 공격에 대한 방어 로직의 현실성 및 신뢰성\n", + "\n", + "#### 미션 PASS 조건\n", + "\n", + "- 정량 기준: 복합 위협 탐지 재현율(Recall) 85% 이상, 처리 지연 200ms 이하\n", + "\n", + "\n", + "- 진행 기준: 복합 위협 시나리오 10종에 대한 탐지 테스트 통과\n", + "\n", + "\n", + "- 검증 방법: 합성 위협 데이터셋 주입 시 이상치 탐지 정확도 검증\n", + "\n", + "\n", + "#### 채점자 판정 메모\n", + "\n", + "> 단순 코드 실행 여부뿐만 아니라 출력된 수치와 근거 자료가 군 실무 요구사항에 부합하는지 면밀히 확인한다.\n", + "\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "\"\"\"채널 상태(손실률·왕복시간)를 추정해 조각 단위 선점, 대기시간 가중(aging), 적응형 재전송 타이머(RTO), 긴급 전문 중복 전송, 조각 크기 조정을 수행하는 지능형 스케줄러를 구현하고, 복합 장애 시나리오 10종에서 긴급 전문 전달률·링크 열화 탐지 재현율·스케줄링 결정 지연을 검증한다.\"\"\"\n", + "import time\n", + "import numpy as np\n", + "import pandas as pd\n", + "import matplotlib.pyplot as plt\n", + "\n", + "# ---------------------------------------------------------------------------\n", + "# [교육용가상자료] 가상 전술 전문(메시지) 생성기 (실제 부대·통신망·제원 정보 없음)\n", + "# 긴급도 4단계(FLASH/IMMEDIATE/PRIORITY/ROUTINE)와 전문 유형별 크기를 가진 전문을 만든다.\n", + "# ---------------------------------------------------------------------------\n", + "PREC = [\"FLASH\", \"IMMEDIATE\", \"PRIORITY\", \"ROUTINE\"]\n", + "RANK = {p: i for i, p in enumerate(PREC)}\n", + "DEADLINE = {\"FLASH\": 10, \"IMMEDIATE\": 30, \"PRIORITY\": 120, \"ROUTINE\": 600} # 가상 전달 목표 시간(초)\n", + "MSG_TYPES = { # 유형: (긴급도 후보, 확률, 본문 크기 범위 bytes, 발생 비율)\n", + " \"CONTACT\": ([\"FLASH\", \"IMMEDIATE\"], [0.7, 0.3], (120, 300), 0.08),\n", + " \"MEDEVAC\": ([\"FLASH\", \"IMMEDIATE\"], [0.4, 0.6], (150, 350), 0.05),\n", + " \"SITREP\": ([\"IMMEDIATE\", \"PRIORITY\"], [0.2, 0.8], (600, 2500), 0.20),\n", + " \"POSREP\": ([\"PRIORITY\", \"ROUTINE\"], [0.5, 0.5], (60, 120), 0.35),\n", + " \"LOGREQ\": ([\"PRIORITY\", \"ROUTINE\"], [0.4, 0.6], (300, 1200), 0.14),\n", + " \"ADMIN\": ([\"ROUTINE\"], [1.0], (1000, 5000), 0.18),\n", + "}\n", + "\n", + "\n", + "def make_messages(duration=600, rate=0.12, seed=7, t_start=0.0, prefix=\"M\"):\n", + " \"\"\"duration초 동안 평균 rate건/초로 발생하는 가상 전문 목록.\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " n = int(rng.poisson(rate * duration))\n", + " times = np.sort(rng.uniform(t_start, t_start + duration, n))\n", + " types = list(MSG_TYPES)\n", + " msgs = []\n", + " for i, t0 in enumerate(times):\n", + " ty = types[rng.choice(len(types), p=[MSG_TYPES[k][3] for k in types])]\n", + " cands, p, (lo, hi), _ = MSG_TYPES[ty]\n", + " msgs.append({\"id\": f\"{prefix}{i:04d}\", \"t0\": float(t0), \"type\": ty, \"prec\": str(rng.choice(cands, p=p)),\n", + " \"size\": int(rng.integers(lo, hi + 1)), \"src\": f\"U{int(rng.integers(1, 9)):02d}\"})\n", + " return msgs\n", + "\n", + "\n", + "class Link:\n", + " \"\"\"[교육용가상자료] 가상 협대역 무선 링크(기본 2.4kbps). 초 단위 상태(good/bad/jam)에 따라\n", + " 비트오류율이 바뀌고(Gilbert-Elliott 모델), 구간별 대역폭 감소도 줄 수 있다.\"\"\"\n", + " BER = {\"good\": 1e-5, \"bad\": 2e-4, \"jam\": 1.2e-3}\n", + "\n", + " def __init__(self, horizon=4000, bw_bps=2400, seed=1, p_gb=0.02, p_bg=0.15, jam=(), bw_drop=(), rtt=(1.2, 0.4)):\n", + " rng = np.random.default_rng(seed)\n", + " st, s = [], \"good\"\n", + " for _ in range(int(horizon) + 1):\n", + " s = (\"bad\" if rng.random() < p_gb else \"good\") if s == \"good\" else (\"good\" if rng.random() < p_bg else \"bad\")\n", + " st.append(s)\n", + " for a, b in jam:\n", + " for k in range(int(a), min(int(b), len(st))):\n", + " st[k] = \"jam\"\n", + " self.states, self.bw_bps, self.bw_drop, self.rtt_par = st, bw_bps, list(bw_drop), rtt\n", + "\n", + " def state(self, t):\n", + " return self.states[min(int(t), len(self.states) - 1)]\n", + "\n", + " def bw(self, t):\n", + " f = 1.0\n", + " for a, b, fac in self.bw_drop:\n", + " if a <= t < b:\n", + " f = fac\n", + " return self.bw_bps * f\n", + "\n", + " def p_loss(self, nbytes, t):\n", + " return 1 - (1 - self.BER[self.state(t)]) ** (8 * nbytes)\n", + "\n", + " def rtt(self, rng):\n", + " return self.rtt_par[0] + rng.exponential(self.rtt_par[1])\n", + "# ---------------------------------------------------------------------------\n", + "\n", + "# ---- 우선순위 큐·재전송 이산 사건 시뮬레이터 공통 함수 ----\n", + "HDR = 32 # 전문 조각(패킷) 헤더 bytes (미션 2의 헤더 압축 적용 시 12 bytes)\n", + "\n", + "\n", + "def policy_key(m, t, policy, aging):\n", + " # [TODO : 학생 실습 4] 전장 상황 및 전송 실패율에 따른 동적 패킷 스케줄링 및 지능형 재전송 알고리즘 구현 관련 핵심 로직을 구현하세요.\n", + " # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n", + " # <<< 여기에 코드를 작성하세요 >>>\n", + " pass # 작성 후 pass를 제거하세요.\n", + "def simulate(msgs, link, policy=\"strict\", mtu=256, preempt=True, rto_mode=\"fixed\", rto_s=4.0, aging=0.0,\n", + " redundancy=False, adapt_mtu=False, hdr=HDR, seed=0, until=None, admit=None):\n", + " \"\"\"전문 목록을 링크로 보내는 이산 사건 시뮬레이션.\n", + " - 전문은 mtu 크기 조각으로 나눠 보낸다. 조각이 손실되면 RTO 후 재전송한다(자료는 버리지 않음).\n", + " - preempt=False면 한 전문의 조각을 끝까지 연속 전송한다(큰 전문이 긴급 전문을 막는 현상 재현).\n", + " - rto_mode='adaptive'면 왕복시간(RTT) 추정값으로 RTO를 계산한다(Jacobson 방식).\n", + " - redundancy=True면 링크 열화 추정 시 FLASH·IMMEDIATE 조각을 2회 연속 전송한다.\n", + " - adapt_mtu=True면 링크 열화 추정 시 조각 크기를 줄인다.\n", + " - admit(m, t)이 False인 전문은 새 조각을 보내지 않고 저장해 둔다(성능 저하 모드).\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " pend = sorted(({**m, \"unsent\": m[\"size\"], \"ready\": [], \"frags\": [], \"done\": None, \"first\": None} for m in msgs), key=lambda m: m[\"t0\"])\n", + " t, i, active, cur = 0.0, 0, [], None\n", + " srtt, rttvar, loss = None, None, 0.0\n", + " st = {\"tx\": 0, \"retx\": 0, \"spurious\": 0, \"bytes\": 0, \"decide_ms\": [], \"flag\": {}, \"log\": []}\n", + " until = until if until is not None else (pend[-1][\"t0\"] if pend else 0) + 6000\n", + " while (i < len(pend) or active) and t < until:\n", + " while i < len(pend) and pend[i][\"t0\"] <= t:\n", + " active.append(pend[i]); i += 1\n", + " td = time.perf_counter()\n", + " for m in active: # ACK가 이미 도착한 재전송 예약은 취소\n", + " m[\"ready\"] = [e for e in m[\"ready\"] if not (e[1][\"ack\"] is not None and e[1][\"ack\"] <= t)]\n", + " ready = [m for m in active if any(r <= t for r, _ in m[\"ready\"]) or (m[\"unsent\"] > 0 and (admit is None or admit(m, t)))]\n", + " if not ready:\n", + " nxt = ([pend[i][\"t0\"]] if i < len(pend) else []) + [r for m in active for r, _ in m[\"ready\"] if r > t]\n", + " t = min(nxt) if nxt else t + 1.0\n", + " continue\n", + " if not preempt and cur in ready and cur[\"unsent\"] > 0:\n", + " m = cur\n", + " else:\n", + " m = min(ready, key=lambda x: policy_key(x, t, policy, aging))\n", + " st[\"decide_ms\"].append((time.perf_counter() - td) * 1000)\n", + " due = [e for e in m[\"ready\"] if e[0] <= t]\n", + " if due:\n", + " e = min(due, key=lambda x: x[0]); m[\"ready\"].remove(e); frag = e[1]\n", + " st[\"retx\"] += 1; st[\"spurious\"] += frag[\"recv\"]\n", + " else:\n", + " size = mtu if not adapt_mtu else (mtu if loss < 0.15 else (mtu // 2 if loss < 0.4 else mtu // 4))\n", + " size = min(m[\"unsent\"], size); m[\"unsent\"] -= size\n", + " frag = {\"b\": size, \"recv\": False, \"ack\": None, \"tries\": 0}\n", + " m[\"frags\"].append(frag)\n", + " cur = m\n", + " m[\"first\"] = t if m[\"first\"] is None else m[\"first\"]\n", + " nb = frag[\"b\"] + hdr\n", + " copies = 2 if (redundancy and RANK[m[\"prec\"]] <= 1 and loss > 0.15) else 1\n", + " for _ in range(copies):\n", + " t += nb * 8 / link.bw(t)\n", + " st[\"tx\"] += 1; st[\"bytes\"] += nb\n", + " ok = rng.random() >= link.p_loss(nb, t)\n", + " st[\"log\"].append((t, nb, ok, m[\"prec\"], m[\"id\"])) # 전송 기록(시각, bytes, 성공, 긴급도, 전문)\n", + " loss = 0.85 * loss + 0.15 * (0.0 if ok else 1.0) # 최근 손실률 추정(EWMA)\n", + " st[\"flag\"][int(t)] = st[\"flag\"].get(int(t), False) or loss > 0.12 # 링크 열화 판정(초 단위)\n", + " if ok and not frag[\"recv\"]:\n", + " frag[\"recv\"], frag[\"rt\"] = True, t\n", + " r = link.rtt(rng); frag[\"ack\"] = t + r\n", + " if srtt is None:\n", + " srtt, rttvar = r, r / 2\n", + " else:\n", + " rttvar = 0.75 * rttvar + 0.25 * abs(srtt - r); srtt = 0.875 * srtt + 0.125 * r\n", + " frag[\"tries\"] += 1\n", + " rto = rto_s if (rto_mode == \"fixed\" or srtt is None) else float(np.clip(srtt + 4 * rttvar, 1.0, 30.0))\n", + " if not frag[\"recv\"] or frag[\"ack\"] > t + rto: # 손실 또는 ACK 지연 -> 재전송 예약\n", + " m[\"ready\"].append((t + rto, frag))\n", + " if m[\"done\"] is None and m[\"unsent\"] == 0 and all(f[\"recv\"] for f in m[\"frags\"]):\n", + " m[\"done\"] = max(f[\"rt\"] for f in m[\"frags\"])\n", + " active = [x for x in active if not (x[\"unsent\"] == 0 and not x[\"ready\"] and all(f[\"recv\"] for f in x[\"frags\"]))]\n", + " res = [{\"id\": m[\"id\"], \"type\": m[\"type\"], \"prec\": m[\"prec\"], \"size\": m[\"size\"], \"t0\": m[\"t0\"], \"done\": m[\"done\"], \"wait\": None if m[\"first\"] is None else m[\"first\"] - m[\"t0\"],\n", + " \"retx\": sum(f[\"tries\"] for f in m[\"frags\"]) - len(m[\"frags\"]),\n", + " \"latency\": None if m[\"done\"] is None else m[\"done\"] - m[\"t0\"]} for m in pend]\n", + " for r in res:\n", + " r[\"on_time\"] = r[\"latency\"] is not None and r[\"latency\"] <= DEADLINE[r[\"prec\"]]\n", + " return res, st\n", + "\n", + "\n", + "def summarize(res):\n", + " \"\"\"긴급도별 기한 내 전달률·지연. 종합 정확도 = 긴급도별 기한 내 전달률의 평균.\"\"\"\n", + " # [TODO : 학생 실습 4] 전장 상황 및 전송 실패율에 따른 동적 패킷 스케줄링 및 지능형 재전송 알고리즘 구현 관련 핵심 로직을 구현하세요.\n", + " # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n", + " # <<< 여기에 코드를 작성하세요 >>>\n", + " pass # 작성 후 pass를 제거하세요.\n", + "BASE = dict(policy=\"strict\", preempt=False) # 미션 3의 1차 큐(고정 RTO 4초)\n", + "ADV = dict(policy=\"aging\", aging=0.5, preempt=True, rto_mode=\"adaptive\", redundancy=True, adapt_mtu=True)\n", + "\n", + "\n", + "def urgent_rate(res):\n", + " u = [r[\"on_time\"] for r in res if RANK[r[\"prec\"]] <= 1]\n", + " return float(np.mean(u))\n", + "\n", + "\n", + "def degradation_episodes(link, flag):\n", + " \"\"\"열화 구간(bad/jam이 이어지는 구간, 전송 3회 이상) 중 구간 안팎 2초 내에 열화 판정이 나온 비율.\"\"\"\n", + " eps, s = [], None\n", + " for k, stt in enumerate(link.states + [\"good\"]):\n", + " if stt != \"good\" and s is None:\n", + " s = k\n", + " elif stt == \"good\" and s is not None:\n", + " eps.append((s, k)); s = None\n", + " hit = [any(flag.get(k, False) for k in range(a, b + 3)) for a, b in eps if sum(k in flag for k in range(a, b)) >= 3]\n", + " good = [v for k, v in flag.items() if link.state(k) == \"good\"]\n", + " return hit, float(np.mean(good)) if good else 0.0\n", + "\n", + "\n", + "def admin_burst(seed):\n", + " rng = np.random.default_rng(seed)\n", + " return [{\"id\": f\"A{i:03d}\", \"t0\": float(t), \"type\": \"ADMIN\", \"prec\": \"ROUTINE\", \"size\": int(rng.integers(3000, 5000)), \"src\": \"U09\"}\n", + " for i, t in enumerate(np.sort(rng.uniform(200, 400, 25)))]\n", + "\n", + "\n", + "# [교육용가상자료] 복합 장애 시나리오 10종\n", + "base = make_messages(1200, 0.21, seed=7)\n", + "double = base + make_messages(1200, 0.21, seed=8, prefix=\"N\")\n", + "SCEN = [(\"1 평시\", base, {}), (\"2 버스트 손실\", base, dict(p_gb=0.08, p_bg=0.1)), (\"3 재밍 60초\", base, dict(jam=[(300, 360)])),\n", + " (\"4 단속 재밍 3회\", base, dict(jam=[(200, 220), (500, 520), (800, 820)])), (\"5 대역폭 50% 감소\", base, dict(bw_drop=[(300, 700, 0.5)])),\n", + " (\"6 중계 지연 증가\", base, dict(rtt=(3.0, 1.0))), (\"7 트래픽 2배\", double, {}), (\"8 대용량 전문 폭주\", base + admin_burst(3), {}),\n", + " (\"9 버스트+대역폭 감소\", base, dict(p_gb=0.08, p_bg=0.1, bw_drop=[(300, 700, 0.5)])),\n", + " (\"10 재밍+트래픽2배+지연\", double, dict(jam=[(300, 360)], rtt=(3.0, 1.0)))]\n", + "\n", + "rows, hits, fas, dec = [], [], [], []\n", + "for name, msgs, lk in SCEN:\n", + " link = Link(12000, seed=1, **lk)\n", + " rb, sb = simulate(msgs, link, **BASE)\n", + " ra, sa = simulate(msgs, link, **ADV)\n", + " h, fa = degradation_episodes(link, sa[\"flag\"])\n", + " hits += h; fas.append(fa); dec += sa[\"decide_ms\"]\n", + " rows.append({\"시나리오\": name, \"전문\": len(msgs), \"기존 긴급전달률\": round(urgent_rate(rb), 3), \"개선 긴급전달률\": round(urgent_rate(ra), 3),\n", + " \"기존 종합\": summarize(rb)[1], \"개선 종합\": summarize(ra)[1], \"열화탐지\": f\"{sum(h)}/{len(h)}\", \"재전송\": sa[\"retx\"]})\n", + "df = pd.DataFrame(rows)\n", + "print(df.to_string(index=False))\n", + "u_all = df[\"개선 긴급전달률\"].mean()\n", + "rec = np.mean(hits)\n", + "print(f\"\\n긴급 전문(FLASH·IMMEDIATE) 기한 내 전달률 평균: 기존 {df['기존 긴급전달률'].mean():.1%} -> 개선 {u_all:.1%} (목표 85% 이상: {'달성' if u_all >= 0.85 else '미달'})\")\n", + "print(f\"링크 열화(복합 위협) 탐지 재현율 {rec:.1%} ({sum(hits)}/{len(hits)}구간, 목표 85% 이상: {'달성' if rec >= 0.85 else '미달'}), 정상 구간 오경보 {np.mean(fas):.1%}\")\n", + "print(\"참고: 열화 판정 기준(최근 손실률 추정값)을 0.12에서 0.2로 올리면 정상 구간 오경보는 줄지만 재현율이 84% 수준으로 떨어진다.\")\n", + "print(f\"스케줄링 결정 지연 평균 {np.mean(dec):.2f} ms, 최대 {np.max(dec):.1f} ms (목표 200 ms 이하: {'달성' if np.max(dec) <= 200 else '미달'})\")\n", + "print(f\"10종 중 긴급 전달률 85% 이상 시나리오: {(df['개선 긴급전달률'] >= 0.85).sum()}/10\")\n", + "\n", + "# 기능별 기여도(가장 어려운 시나리오 10에서 하나씩 끄기)\n", + "name, msgs, lk = SCEN[-1]\n", + "link = Link(12000, seed=1, **lk)\n", + "print(f\"\\n[기능별 기여도 - {name}]\")\n", + "for label, kw in [(\"전체 개선 기능\", ADV), (\"조각 단위 선점 끔\", {**ADV, \"preempt\": False}), (\"고정 RTO 4초\", {**ADV, \"rto_mode\": \"fixed\"}),\n", + " (\"중복 전송 끔\", {**ADV, \"redundancy\": False}), (\"조각 크기 조정 끔\", {**ADV, \"adapt_mtu\": False})]:\n", + " r, s = simulate(msgs, link, **kw)\n", + " print(f\"{label:14s} 긴급 {urgent_rate(r):.3f} 종합 {summarize(r)[1]:.3f} 불필요 재전송 {s['spurious']}회\")\n", + "print(\"관찰: 중계 지연이 커지면 고정 RTO는 ACK를 기다리지 못하고 불필요한 재전송을 반복해 대역폭을 낭비한다.\")\n", + "\n", + "plt.figure(figsize=(9, 4))\n", + "x = np.arange(len(df))\n", + "plt.bar(x - 0.2, df[\"기존 긴급전달률\"], 0.4, label=\"baseline PQ (M3)\")\n", + "plt.bar(x + 0.2, df[\"개선 긴급전달률\"], 0.4, label=\"adaptive scheduler\")\n", + "plt.axhline(0.85, ls=\"--\", c=\"gray\"); plt.xticks(x, [f\"S{i + 1}\" for i in x]); plt.ylim(0, 1.05)\n", + "plt.ylabel(\"urgent on-time ratio\"); plt.title(\"FLASH/IMMEDIATE on-time delivery in 10 stress scenarios (synthetic)\"); plt.legend()\n", + "plt.tight_layout(); plt.show()\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 04 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + " 시나리오 전문 기존 긴급전달률 개선 긴급전달률 기존 종합 개선 종합 열화탐지 재전송\n", + " 1 평시 258 0.885 1.000 0.940 1.000 14/14 96\n", + " 2 버스트 손실 258 0.865 1.000 0.931 1.000 42/43 211\n", + " 3 재밍 60초 258 0.846 1.000 0.921 1.000 15/15 167\n", + " 4 단속 재밍 3회 258 0.865 0.981 0.931 0.990 15/15 169\n", + " 5 대역폭 50% 감소 258 0.827 1.000 0.910 1.000 15/16 97\n", + " 6 중계 지연 증가 258 0.865 1.000 0.930 1.000 13/14 87\n", + " 7 트래픽 2배 501 0.966 1.000 0.795 0.808 24/24 121\n", + " 8 대용량 전문 폭주 283 0.827 1.000 0.867 0.958 16/19 101\n", + " 9 버스트+대역폭 감소 258 0.827 1.000 0.911 1.000 46/48 219\n", + "10 재밍+트래픽2배+지연 501 0.865 0.955 0.718 0.779 22/25 156\n", + "\n", + "긴급 전문(FLASH·IMMEDIATE) 기한 내 전달률 평균: 기존 86.4% -> 개선 99.4% (목표 85% 이상: 달성)\n", + "링크 열화(복합 위협) 탐지 재현율 95.3% (222/233구간, 목표 85% 이상: 달성), 정상 구간 오경보 9.7%\n", + "참고: 열화 판정 기준(최근 손실률 추정값)을 0.12에서 0.2로 올리면 정상 구간 오경보는 줄지만 재현율이 84% 수준으로 떨어진다.\n", + "스케줄링 결정 지연 평균 0.04 ms, 최대 39.3 ms (목표 200 ms 이하: 달성)\n", + "10종 중 긴급 전달률 85% 이상 시나리오: 10/10\n", + "\n", + "[기능별 기여도 - 10 재밍+트래픽2배+지연]\n", + "전체 개선 기능 긴급 0.955 종합 0.779 불필요 재전송 0회\n", + "조각 단위 선점 끔 긴급 0.910 종합 0.755 불필요 재전송 0회\n", + "고정 RTO 4초 긴급 0.955 종합 0.746 불필요 재전송 513회\n", + "중복 전송 끔 긴급 0.966 종합 0.786 불필요 재전송 0회\n", + "조각 크기 조정 끔 긴급 0.955 종합 0.776 불필요 재전송 0회\n", + "관찰: 중계 지연이 커지면 고정 RTO는 ACK를 기다리지 못하고 불필요한 재전송을 반복해 대역폭을 낭비한다.\n", + "```\n", + "
\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 05. 네트워크 혼잡도 기반 전송 성공 확률 및 링크 신뢰도 산출 엔진 개발\n", + "**소요시간**: 약 3.0시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "무선 링크 품질 및 혼잡도를 반영하여 전송 성공 확률과 링크 신뢰도를 산출하는 엔진을 개발한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **링크 품질과 혼잡도를 반영해 조각 전송 성공 확률과 링크 신뢰도 등급(High/Medium/Low)을 산출하는 엔진을 개발한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **SNR, 최근 손실률, 큐 점유율, 조각 크기, 지연 변동을 특징으로 학습용·검증용 가상 데이터를 나눠 그래디언트 부스팅 엔진을 학습 수행하고 검증하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `src/verification/c202_confidence_engine.py` 형태의 결과물을 완성한다.\n", + "\n", + "### 🔍 평가 및 검증 기준 (PASS 조건)\n", + "#### 미션 판정\n", + "\n", + "**평가 구분:** 필수\n", + "\n", + "**핵심 평가영역:** 신뢰도 등급(High/Med/Low) 부여 기준의 군사적 타당성\n", + "\n", + "#### 미션 PASS 조건\n", + "\n", + "- 정량 기준: 오경보율(FAR) 40% 이상 감축, 신뢰도 스코어 산출 일관성 90% 이상\n", + "\n", + "\n", + "- 진행 기준: 오경보 필터링 전후 비교 지표 산출 및 통합 테스트 완료\n", + "\n", + "\n", + "- 검증 방법: ROC-AUC 곡선 분석 및 임계값 민감도 테스트\n", + "\n", + "\n", + "#### 채점자 판정 메모\n", + "\n", + "> 단순 코드 실행 여부뿐만 아니라 출력된 수치와 근거 자료가 군 실무 요구사항에 부합하는지 면밀히 확인한다.\n", + "\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "\"\"\"무선 링크 품질(SNR·최근 손실률·지연 변동)과 혼잡도(큐 점유율)·조각 크기로 전송 성공 확률과 링크 신뢰도 등급(High/Medium/Low)을 산출하는 엔진을 개발하고, 단순 규칙 대비 '링크 불량' 오경보율(FAR) 감축, ROC-AUC, 임계값 민감도, 점수 일관성을 검증한다.\"\"\"\n", + "import numpy as np\n", + "import pandas as pd\n", + "import matplotlib.pyplot as plt\n", + "from sklearn.ensemble import HistGradientBoostingClassifier\n", + "from sklearn.inspection import permutation_importance\n", + "from sklearn.metrics import roc_auc_score, roc_curve\n", + "\n", + "# ---------------------------------------------------------------------------\n", + "# [교육용가상자료] 가상 전술 전문(메시지) 생성기 (실제 부대·통신망·제원 정보 없음)\n", + "# 긴급도 4단계(FLASH/IMMEDIATE/PRIORITY/ROUTINE)와 전문 유형별 크기를 가진 전문을 만든다.\n", + "# ---------------------------------------------------------------------------\n", + "PREC = [\"FLASH\", \"IMMEDIATE\", \"PRIORITY\", \"ROUTINE\"]\n", + "RANK = {p: i for i, p in enumerate(PREC)}\n", + "DEADLINE = {\"FLASH\": 10, \"IMMEDIATE\": 30, \"PRIORITY\": 120, \"ROUTINE\": 600} # 가상 전달 목표 시간(초)\n", + "MSG_TYPES = { # 유형: (긴급도 후보, 확률, 본문 크기 범위 bytes, 발생 비율)\n", + " \"CONTACT\": ([\"FLASH\", \"IMMEDIATE\"], [0.7, 0.3], (120, 300), 0.08),\n", + " \"MEDEVAC\": ([\"FLASH\", \"IMMEDIATE\"], [0.4, 0.6], (150, 350), 0.05),\n", + " \"SITREP\": ([\"IMMEDIATE\", \"PRIORITY\"], [0.2, 0.8], (600, 2500), 0.20),\n", + " \"POSREP\": ([\"PRIORITY\", \"ROUTINE\"], [0.5, 0.5], (60, 120), 0.35),\n", + " \"LOGREQ\": ([\"PRIORITY\", \"ROUTINE\"], [0.4, 0.6], (300, 1200), 0.14),\n", + " \"ADMIN\": ([\"ROUTINE\"], [1.0], (1000, 5000), 0.18),\n", + "}\n", + "\n", + "\n", + "def make_messages(duration=600, rate=0.12, seed=7, t_start=0.0, prefix=\"M\"):\n", + " \"\"\"duration초 동안 평균 rate건/초로 발생하는 가상 전문 목록.\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " n = int(rng.poisson(rate * duration))\n", + " times = np.sort(rng.uniform(t_start, t_start + duration, n))\n", + " types = list(MSG_TYPES)\n", + " msgs = []\n", + " for i, t0 in enumerate(times):\n", + " ty = types[rng.choice(len(types), p=[MSG_TYPES[k][3] for k in types])]\n", + " cands, p, (lo, hi), _ = MSG_TYPES[ty]\n", + " msgs.append({\"id\": f\"{prefix}{i:04d}\", \"t0\": float(t0), \"type\": ty, \"prec\": str(rng.choice(cands, p=p)),\n", + " \"size\": int(rng.integers(lo, hi + 1)), \"src\": f\"U{int(rng.integers(1, 9)):02d}\"})\n", + " return msgs\n", + "\n", + "\n", + "class Link:\n", + " \"\"\"[교육용가상자료] 가상 협대역 무선 링크(기본 2.4kbps). 초 단위 상태(good/bad/jam)에 따라\n", + " 비트오류율이 바뀌고(Gilbert-Elliott 모델), 구간별 대역폭 감소도 줄 수 있다.\"\"\"\n", + " BER = {\"good\": 1e-5, \"bad\": 2e-4, \"jam\": 1.2e-3}\n", + "\n", + " def __init__(self, horizon=4000, bw_bps=2400, seed=1, p_gb=0.02, p_bg=0.15, jam=(), bw_drop=(), rtt=(1.2, 0.4)):\n", + " rng = np.random.default_rng(seed)\n", + " st, s = [], \"good\"\n", + " for _ in range(int(horizon) + 1):\n", + " s = (\"bad\" if rng.random() < p_gb else \"good\") if s == \"good\" else (\"good\" if rng.random() < p_bg else \"bad\")\n", + " st.append(s)\n", + " for a, b in jam:\n", + " for k in range(int(a), min(int(b), len(st))):\n", + " st[k] = \"jam\"\n", + " self.states, self.bw_bps, self.bw_drop, self.rtt_par = st, bw_bps, list(bw_drop), rtt\n", + "\n", + " def state(self, t):\n", + " return self.states[min(int(t), len(self.states) - 1)]\n", + "\n", + " def bw(self, t):\n", + " f = 1.0\n", + " for a, b, fac in self.bw_drop:\n", + " if a <= t < b:\n", + " f = fac\n", + " return self.bw_bps * f\n", + "\n", + " def p_loss(self, nbytes, t):\n", + " return 1 - (1 - self.BER[self.state(t)]) ** (8 * nbytes)\n", + "\n", + " def rtt(self, rng):\n", + " return self.rtt_par[0] + rng.exponential(self.rtt_par[1])\n", + "# ---------------------------------------------------------------------------\n", + "\n", + "HDR = 32\n", + "FEATS = [\"snr_db\", \"recent_loss\", \"queue_util\", \"pkt_bytes\", \"rtt_jitter\"]\n", + "\n", + "\n", + "def make_link_windows(n=6000, seed=1):\n", + " \"\"\"[교육용가상자료] 1초 창마다 링크 관측값과 그 창에서 보낸 조각 1개의 성공 여부. 실제 성공 확률(p_true)도 함께 둔다.\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " link = Link(n, seed=seed, p_gb=0.05, p_bg=0.15, jam=[(1000, 1060), (3500, 3540), (5000, 5080)])\n", + " snr_mu = {\"good\": 17, \"bad\": 9, \"jam\": 2}\n", + " rows = []\n", + " for k in range(1, n):\n", + " s, prev = link.state(k), link.state(k - 1)\n", + " size = int(rng.choice([64, 128, 256])) + HDR\n", + " util = float(np.clip(rng.beta(2, 3) + (0.35 if 2000 <= k < 2600 else 0), 0, 1)) # 혼잡 구간 포함\n", + " p_true = (1 - link.p_loss(size, k)) * (1 - np.clip((util - 0.85) * 4, 0, 0.6)) # 비트오류 + 큐 넘침\n", + " rows.append({\"snr_db\": snr_mu[s] + rng.normal(0, 2.5), \"recent_loss\": rng.binomial(10, link.p_loss(288, k - 1)) / 10,\n", + " \"queue_util\": util, \"pkt_bytes\": size, \"rtt_jitter\": rng.exponential(0.3) + (0.4 if s != \"good\" else 0),\n", + " \"state\": s, \"p_true\": p_true, \"ok\": int(rng.random() < p_true)})\n", + " return pd.DataFrame(rows)\n", + "\n", + "\n", + "def grade(p):\n", + " # [TODO : 학생 실습 5] 네트워크 혼잡도 기반 전송 성공 확률 및 링크 신뢰도 산출 엔진 개발 관련 핵심 로직을 구현하세요.\n", + " # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n", + " # <<< 여기에 코드를 작성하세요 >>>\n", + " pass # 작성 후 pass를 제거하세요.\n", + "def far_recall(alarm, bad):\n", + " # [TODO : 학생 실습 5] 네트워크 혼잡도 기반 전송 성공 확률 및 링크 신뢰도 산출 엔진 개발 관련 핵심 로직을 구현하세요.\n", + " # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n", + " # <<< 여기에 코드를 작성하세요 >>>\n", + " pass # 작성 후 pass를 제거하세요.\n", + "train, test = make_link_windows(6000, seed=1), make_link_windows(6000, seed=2) # 학습용 / 검증용 분리\n", + "def new_model():\n", + " return HistGradientBoostingClassifier(max_depth=3, max_iter=150, learning_rate=0.05, random_state=0)\n", + "\n", + "\n", + "model = new_model().fit(train[FEATS], train[\"ok\"])\n", + "test[\"p\"] = model.predict_proba(test[FEATS])[:, 1]\n", + "test[\"grade\"] = grade(test[\"p\"])\n", + "bad = (test[\"p_true\"] < 0.6).values # 실제 불량 링크(정답, 시뮬레이터만 알고 있음)\n", + "\n", + "auc = roc_auc_score(test[\"ok\"], test[\"p\"])\n", + "print(f\"전송 성공 예측 ROC-AUC {auc:.3f} (검증 창 {len(test)}개, 실제 불량 링크 창 {bad.sum()}개)\")\n", + "\n", + "rule = ((test[\"snr_db\"] < 12) | (test[\"recent_loss\"] >= 0.4)).values # 기존: 단순 규칙 경보\n", + "eng = (test[\"p\"] < 0.6).values # 엔진: 성공 확률 0.6 미만 경보\n", + "f0, r0, n0 = far_recall(rule, bad)\n", + "f1, r1, n1 = far_recall(eng, bad)\n", + "print(pd.DataFrame([{\"방식\": \"기존 규칙(SNR<12dB 또는 손실>=40%)\", \"경보\": n0, \"오경보율\": f0, \"불량 재현율\": r0},\n", + " {\"방식\": \"신뢰도 엔진(성공확률<0.6)\", \"경보\": n1, \"오경보율\": f1, \"불량 재현율\": r1}]).to_string(index=False))\n", + "cut = 1 - f1 / max(f0, 1e-9)\n", + "print(f\"오경보율 감축 {cut:.1%} (목표 40% 이상: {'달성' if cut >= 0.4 else '미달'})\")\n", + "\n", + "# 임계값 민감도\n", + "print(\"\\n경보 임계값 경보수 오경보율 불량 재현율\")\n", + "for th in [0.4, 0.5, 0.6, 0.7, 0.8]:\n", + " f, r, n = far_recall((test[\"p\"] < th).values, bad)\n", + " print(f\" {th:.1f} {n:5d} {f:.3f} {r:.3f}\")\n", + "\n", + "# 점수 일관성: 학습 데이터를 부트스트랩으로 10번 다시 뽑아 학습해도 같은 등급이 나오는 비율 + 불확실성(표준편차)\n", + "rng = np.random.default_rng(0)\n", + "boot = []\n", + "for b in range(10):\n", + " idx = rng.integers(0, len(train), len(train))\n", + " m = new_model().fit(train.iloc[idx][FEATS], train.iloc[idx][\"ok\"])\n", + " boot.append(m.predict_proba(test[FEATS])[:, 1])\n", + "boot = np.array(boot)\n", + "same = np.mean([np.mean(grade(bp) == test[\"grade\"].values) for bp in boot])\n", + "test[\"unc\"] = boot.std(axis=0)\n", + "print(f\"\\n신뢰도 등급 일관성 {same:.1%} (목표 90% 이상: {'달성' if same >= 0.9 else '미달'}), 점수 불확실성(표준편차) 평균 {test['unc'].mean():.3f}, 최대 {test['unc'].max():.3f}\")\n", + "print(\"[실제 링크 상태별 등급 분포]\")\n", + "print(pd.crosstab(test[\"state\"], test[\"grade\"]).reindex(columns=[\"High\", \"Medium\", \"Low\"]).to_string())\n", + "imp = permutation_importance(model, test[FEATS], test[\"ok\"], scoring=\"roc_auc\", n_repeats=3, random_state=0)\n", + "print(\"판단 근거(특징 중요도, AUC 감소량):\", {f: round(float(v), 3) for f, v in sorted(zip(FEATS, imp.importances_mean), key=lambda x: -x[1])})\n", + "print(\"등급 운용 기준: High=정상 전송, Medium=긴급 전문 중복 전송 검토, Low=통신관에게 경로 변경·중계 검토 요청(자동 전환 아님)\")\n", + "\n", + "fig, ax = plt.subplots(1, 2, figsize=(11, 4))\n", + "fpr, tpr, _ = roc_curve(test[\"ok\"], test[\"p\"])\n", + "ax[0].plot(fpr, tpr); ax[0].plot([0, 1], [0, 1], \"k--\"); ax[0].set_title(f\"ROC (AUC={auc:.3f})\"); ax[0].set_xlabel(\"FPR\"); ax[0].set_ylabel(\"TPR\")\n", + "seg = test.iloc[900:1200]\n", + "ax[1].plot(seg.index, seg[\"p\"], label=\"success prob.\"); ax[1].fill_between(seg.index, seg[\"p\"] - 2 * seg[\"unc\"], seg[\"p\"] + 2 * seg[\"unc\"], alpha=0.3, label=\"+-2 sd\")\n", + "ax[1].plot(seg.index, seg[\"p_true\"], \"k:\", label=\"true prob.\"); ax[1].axhline(0.6, c=\"r\", ls=\"--\"); ax[1].legend(fontsize=8)\n", + "ax[1].set_title(\"Link reliability score with uncertainty (jamming at t=1000-1060)\")\n", + "plt.tight_layout(); plt.show()\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 05 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + "전송 성공 예측 ROC-AUC 0.891 (검증 창 5999개, 실제 불량 링크 창 293개)\n", + " 방식 경보 오경보율 불량 재현율\n", + "기존 규칙(SNR<12dB 또는 손실>=40%) 1707 0.878 0.713\n", + " 신뢰도 엔진(성공확률<0.6) 363 0.342 0.816\n", + "오경보율 감축 61.0% (목표 40% 이상: 달성)\n", + "\n", + "경보 임계값 경보수 오경보율 불량 재현율\n", + " 0.4 159 0.069 0.505\n", + " 0.5 220 0.150 0.638\n", + " 0.6 363 0.342 0.816\n", + " 0.7 709 0.626 0.904\n", + " 0.8 1232 0.768 0.976\n", + "\n", + "신뢰도 등급 일관성 93.2% (목표 90% 이상: 달성), 점수 불확실성(표준편차) 평균 0.023, 최대 0.215\n", + "[실제 링크 상태별 등급 분포]\n", + "grade High Medium Low\n", + "state \n", + "bad 333 965 128\n", + "good 4207 122 64\n", + "jam 1 8 171\n", + "판단 근거(특징 중요도, AUC 감소량): {'snr_db': 0.131, 'queue_util': 0.068, 'rtt_jitter': 0.045, 'pkt_bytes': 0.016, 'recent_loss': 0.016}\n", + "등급 운용 기준: High=정상 전송, Medium=긴급 전문 중복 전송 검토, Low=통신관에게 경로 변경·중계 검토 요청(자동 전환 아님)\n", + "```\n", + "
\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 06. 대역폭 사용률, 큐 대기 현황 및 패킷 유실률 실시간 모니터링 UI 구현\n", + "**소요시간**: 약 2.5시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "대역폭 사용률, 큐 대기 전문 현황, 패킷 손실률을 실시간 표출하는 통신 관제 UI를 구현한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **대역폭 사용률, 긴급도별 큐 대기 전문, 패킷 손실률, 기한 임박·초과 경보를 1초 주기로 보여 주는 통신 관제 화면을 구현한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **재밍과 대역폭 감소가 포함된 20분 운용 기록을 1초 주기로 읽어 관제 지표를 갱신하고 처리시간을 측정 수행하고 검증하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `src/ui/c202_dashboard.py (또는 웹 UI 컴포넌트)` 형태의 결과물을 완성한다.\n", + "\n", + "### 🔍 평가 및 검증 기준 (PASS 조건)\n", + "#### 미션 판정\n", + "\n", + "**평가 구분:** 필수\n", + "\n", + "**핵심 평가영역:** 전술 작전 상황판으로서의 직관성 및 인지 부하 감소도\n", + "\n", + "#### 미션 PASS 조건\n", + "\n", + "- 정량 기준: 화면 갱신 주기 1초 이내, UI 응답성 100ms 이내\n", + "\n", + "\n", + "- 진행 기준: 주요 지표 시각화 컴포넌트 4종 이상 렌더링 및 실시간 데이터 연동\n", + "\n", + "\n", + "- 검증 방법: 사용자 인터랙션 테스트 및 모의 데이터 스트림 렌더링 검증\n", + "\n", + "\n", + "#### 채점자 판정 메모\n", + "\n", + "> 단순 코드 실행 여부뿐만 아니라 출력된 수치와 근거 자료가 군 실무 요구사항에 부합하는지 면밀히 확인한다.\n", + "\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "\"\"\"전송 시뮬레이션 기록을 1초 주기로 읽어 대역폭 사용률, 긴급도별 큐 대기 전문 수, 패킷 손실률, 기한 임박·초과 경보를 표출하는 통신 관제 화면(야간 모드)을 구현하고 화면 갱신 처리시간을 측정한다.\"\"\"\n", + "import time\n", + "import numpy as np\n", + "import pandas as pd\n", + "import matplotlib.pyplot as plt\n", + "\n", + "# ---------------------------------------------------------------------------\n", + "# [교육용가상자료] 가상 전술 전문(메시지) 생성기 (실제 부대·통신망·제원 정보 없음)\n", + "# 긴급도 4단계(FLASH/IMMEDIATE/PRIORITY/ROUTINE)와 전문 유형별 크기를 가진 전문을 만든다.\n", + "# ---------------------------------------------------------------------------\n", + "PREC = [\"FLASH\", \"IMMEDIATE\", \"PRIORITY\", \"ROUTINE\"]\n", + "RANK = {p: i for i, p in enumerate(PREC)}\n", + "DEADLINE = {\"FLASH\": 10, \"IMMEDIATE\": 30, \"PRIORITY\": 120, \"ROUTINE\": 600} # 가상 전달 목표 시간(초)\n", + "MSG_TYPES = { # 유형: (긴급도 후보, 확률, 본문 크기 범위 bytes, 발생 비율)\n", + " \"CONTACT\": ([\"FLASH\", \"IMMEDIATE\"], [0.7, 0.3], (120, 300), 0.08),\n", + " \"MEDEVAC\": ([\"FLASH\", \"IMMEDIATE\"], [0.4, 0.6], (150, 350), 0.05),\n", + " \"SITREP\": ([\"IMMEDIATE\", \"PRIORITY\"], [0.2, 0.8], (600, 2500), 0.20),\n", + " \"POSREP\": ([\"PRIORITY\", \"ROUTINE\"], [0.5, 0.5], (60, 120), 0.35),\n", + " \"LOGREQ\": ([\"PRIORITY\", \"ROUTINE\"], [0.4, 0.6], (300, 1200), 0.14),\n", + " \"ADMIN\": ([\"ROUTINE\"], [1.0], (1000, 5000), 0.18),\n", + "}\n", + "\n", + "\n", + "def make_messages(duration=600, rate=0.12, seed=7, t_start=0.0, prefix=\"M\"):\n", + " \"\"\"duration초 동안 평균 rate건/초로 발생하는 가상 전문 목록.\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " n = int(rng.poisson(rate * duration))\n", + " times = np.sort(rng.uniform(t_start, t_start + duration, n))\n", + " types = list(MSG_TYPES)\n", + " msgs = []\n", + " for i, t0 in enumerate(times):\n", + " ty = types[rng.choice(len(types), p=[MSG_TYPES[k][3] for k in types])]\n", + " cands, p, (lo, hi), _ = MSG_TYPES[ty]\n", + " msgs.append({\"id\": f\"{prefix}{i:04d}\", \"t0\": float(t0), \"type\": ty, \"prec\": str(rng.choice(cands, p=p)),\n", + " \"size\": int(rng.integers(lo, hi + 1)), \"src\": f\"U{int(rng.integers(1, 9)):02d}\"})\n", + " return msgs\n", + "\n", + "\n", + "class Link:\n", + " \"\"\"[교육용가상자료] 가상 협대역 무선 링크(기본 2.4kbps). 초 단위 상태(good/bad/jam)에 따라\n", + " 비트오류율이 바뀌고(Gilbert-Elliott 모델), 구간별 대역폭 감소도 줄 수 있다.\"\"\"\n", + " BER = {\"good\": 1e-5, \"bad\": 2e-4, \"jam\": 1.2e-3}\n", + "\n", + " def __init__(self, horizon=4000, bw_bps=2400, seed=1, p_gb=0.02, p_bg=0.15, jam=(), bw_drop=(), rtt=(1.2, 0.4)):\n", + " rng = np.random.default_rng(seed)\n", + " st, s = [], \"good\"\n", + " for _ in range(int(horizon) + 1):\n", + " s = (\"bad\" if rng.random() < p_gb else \"good\") if s == \"good\" else (\"good\" if rng.random() < p_bg else \"bad\")\n", + " st.append(s)\n", + " for a, b in jam:\n", + " for k in range(int(a), min(int(b), len(st))):\n", + " st[k] = \"jam\"\n", + " self.states, self.bw_bps, self.bw_drop, self.rtt_par = st, bw_bps, list(bw_drop), rtt\n", + "\n", + " def state(self, t):\n", + " return self.states[min(int(t), len(self.states) - 1)]\n", + "\n", + " def bw(self, t):\n", + " f = 1.0\n", + " for a, b, fac in self.bw_drop:\n", + " if a <= t < b:\n", + " f = fac\n", + " return self.bw_bps * f\n", + "\n", + " def p_loss(self, nbytes, t):\n", + " return 1 - (1 - self.BER[self.state(t)]) ** (8 * nbytes)\n", + "\n", + " def rtt(self, rng):\n", + " return self.rtt_par[0] + rng.exponential(self.rtt_par[1])\n", + "# ---------------------------------------------------------------------------\n", + "\n", + "# ---- 우선순위 큐·재전송 이산 사건 시뮬레이터 공통 함수 ----\n", + "HDR = 32 # 전문 조각(패킷) 헤더 bytes (미션 2의 헤더 압축 적용 시 12 bytes)\n", + "\n", + "\n", + "def policy_key(m, t, policy, aging):\n", + " # [TODO : 학생 실습 6] 대역폭 사용률, 큐 대기 현황 및 패킷 유실률 실시간 모니터링 UI 구현 관련 핵심 로직을 구현하세요.\n", + " # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n", + " # <<< 여기에 코드를 작성하세요 >>>\n", + " pass # 작성 후 pass를 제거하세요.\n", + "def simulate(msgs, link, policy=\"strict\", mtu=256, preempt=True, rto_mode=\"fixed\", rto_s=4.0, aging=0.0,\n", + " redundancy=False, adapt_mtu=False, hdr=HDR, seed=0, until=None, admit=None):\n", + " \"\"\"전문 목록을 링크로 보내는 이산 사건 시뮬레이션.\n", + " - 전문은 mtu 크기 조각으로 나눠 보낸다. 조각이 손실되면 RTO 후 재전송한다(자료는 버리지 않음).\n", + " - preempt=False면 한 전문의 조각을 끝까지 연속 전송한다(큰 전문이 긴급 전문을 막는 현상 재현).\n", + " - rto_mode='adaptive'면 왕복시간(RTT) 추정값으로 RTO를 계산한다(Jacobson 방식).\n", + " - redundancy=True면 링크 열화 추정 시 FLASH·IMMEDIATE 조각을 2회 연속 전송한다.\n", + " - adapt_mtu=True면 링크 열화 추정 시 조각 크기를 줄인다.\n", + " - admit(m, t)이 False인 전문은 새 조각을 보내지 않고 저장해 둔다(성능 저하 모드).\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " pend = sorted(({**m, \"unsent\": m[\"size\"], \"ready\": [], \"frags\": [], \"done\": None, \"first\": None} for m in msgs), key=lambda m: m[\"t0\"])\n", + " t, i, active, cur = 0.0, 0, [], None\n", + " srtt, rttvar, loss = None, None, 0.0\n", + " st = {\"tx\": 0, \"retx\": 0, \"spurious\": 0, \"bytes\": 0, \"decide_ms\": [], \"flag\": {}, \"log\": []}\n", + " until = until if until is not None else (pend[-1][\"t0\"] if pend else 0) + 6000\n", + " while (i < len(pend) or active) and t < until:\n", + " while i < len(pend) and pend[i][\"t0\"] <= t:\n", + " active.append(pend[i]); i += 1\n", + " td = time.perf_counter()\n", + " for m in active: # ACK가 이미 도착한 재전송 예약은 취소\n", + " m[\"ready\"] = [e for e in m[\"ready\"] if not (e[1][\"ack\"] is not None and e[1][\"ack\"] <= t)]\n", + " ready = [m for m in active if any(r <= t for r, _ in m[\"ready\"]) or (m[\"unsent\"] > 0 and (admit is None or admit(m, t)))]\n", + " if not ready:\n", + " nxt = ([pend[i][\"t0\"]] if i < len(pend) else []) + [r for m in active for r, _ in m[\"ready\"] if r > t]\n", + " t = min(nxt) if nxt else t + 1.0\n", + " continue\n", + " if not preempt and cur in ready and cur[\"unsent\"] > 0:\n", + " m = cur\n", + " else:\n", + " m = min(ready, key=lambda x: policy_key(x, t, policy, aging))\n", + " st[\"decide_ms\"].append((time.perf_counter() - td) * 1000)\n", + " due = [e for e in m[\"ready\"] if e[0] <= t]\n", + " if due:\n", + " e = min(due, key=lambda x: x[0]); m[\"ready\"].remove(e); frag = e[1]\n", + " st[\"retx\"] += 1; st[\"spurious\"] += frag[\"recv\"]\n", + " else:\n", + " size = mtu if not adapt_mtu else (mtu if loss < 0.15 else (mtu // 2 if loss < 0.4 else mtu // 4))\n", + " size = min(m[\"unsent\"], size); m[\"unsent\"] -= size\n", + " frag = {\"b\": size, \"recv\": False, \"ack\": None, \"tries\": 0}\n", + " m[\"frags\"].append(frag)\n", + " cur = m\n", + " m[\"first\"] = t if m[\"first\"] is None else m[\"first\"]\n", + " nb = frag[\"b\"] + hdr\n", + " copies = 2 if (redundancy and RANK[m[\"prec\"]] <= 1 and loss > 0.15) else 1\n", + " for _ in range(copies):\n", + " t += nb * 8 / link.bw(t)\n", + " st[\"tx\"] += 1; st[\"bytes\"] += nb\n", + " ok = rng.random() >= link.p_loss(nb, t)\n", + " st[\"log\"].append((t, nb, ok, m[\"prec\"], m[\"id\"])) # 전송 기록(시각, bytes, 성공, 긴급도, 전문)\n", + " loss = 0.85 * loss + 0.15 * (0.0 if ok else 1.0) # 최근 손실률 추정(EWMA)\n", + " st[\"flag\"][int(t)] = st[\"flag\"].get(int(t), False) or loss > 0.12 # 링크 열화 판정(초 단위)\n", + " if ok and not frag[\"recv\"]:\n", + " frag[\"recv\"], frag[\"rt\"] = True, t\n", + " r = link.rtt(rng); frag[\"ack\"] = t + r\n", + " if srtt is None:\n", + " srtt, rttvar = r, r / 2\n", + " else:\n", + " rttvar = 0.75 * rttvar + 0.25 * abs(srtt - r); srtt = 0.875 * srtt + 0.125 * r\n", + " frag[\"tries\"] += 1\n", + " rto = rto_s if (rto_mode == \"fixed\" or srtt is None) else float(np.clip(srtt + 4 * rttvar, 1.0, 30.0))\n", + " if not frag[\"recv\"] or frag[\"ack\"] > t + rto: # 손실 또는 ACK 지연 -> 재전송 예약\n", + " m[\"ready\"].append((t + rto, frag))\n", + " if m[\"done\"] is None and m[\"unsent\"] == 0 and all(f[\"recv\"] for f in m[\"frags\"]):\n", + " m[\"done\"] = max(f[\"rt\"] for f in m[\"frags\"])\n", + " active = [x for x in active if not (x[\"unsent\"] == 0 and not x[\"ready\"] and all(f[\"recv\"] for f in x[\"frags\"]))]\n", + " res = [{\"id\": m[\"id\"], \"type\": m[\"type\"], \"prec\": m[\"prec\"], \"size\": m[\"size\"], \"t0\": m[\"t0\"], \"done\": m[\"done\"], \"wait\": None if m[\"first\"] is None else m[\"first\"] - m[\"t0\"],\n", + " \"retx\": sum(f[\"tries\"] for f in m[\"frags\"]) - len(m[\"frags\"]),\n", + " \"latency\": None if m[\"done\"] is None else m[\"done\"] - m[\"t0\"]} for m in pend]\n", + " for r in res:\n", + " r[\"on_time\"] = r[\"latency\"] is not None and r[\"latency\"] <= DEADLINE[r[\"prec\"]]\n", + " return res, st\n", + "\n", + "\n", + "def summarize(res):\n", + " \"\"\"긴급도별 기한 내 전달률·지연. 종합 정확도 = 긴급도별 기한 내 전달률의 평균.\"\"\"\n", + " # [TODO : 학생 실습 6] 대역폭 사용률, 큐 대기 현황 및 패킷 유실률 실시간 모니터링 UI 구현 관련 핵심 로직을 구현하세요.\n", + " # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n", + " # <<< 여기에 코드를 작성하세요 >>>\n", + " pass # 작성 후 pass를 제거하세요.\n", + "COLORS = {\"FLASH\": \"red\", \"IMMEDIATE\": \"orange\", \"PRIORITY\": \"deepskyblue\", \"ROUTINE\": \"gray\"}\n", + "\n", + "# [교육용가상자료] 20분 운용: 300~360초 재밍, 600~800초 대역폭 50% 감소\n", + "msgs = make_messages(1200, 0.21, seed=7)\n", + "link = Link(12000, seed=1, jam=[(300, 360)], bw_drop=[(600, 800, 0.5)])\n", + "res, st = simulate(msgs, link, \"aging\", aging=0.5, rto_mode=\"adaptive\", redundancy=True, adapt_mtu=True)\n", + "log = pd.DataFrame(st[\"log\"], columns=[\"t\", \"bytes\", \"ok\", \"prec\", \"id\"])\n", + "done = {r[\"id\"]: r[\"done\"] for r in res}\n", + "\n", + "\n", + "def snapshot(now, window=10):\n", + " \"\"\"now 시각의 관제 지표: 최근 window초 사용률·손실률, 긴급도별 대기 전문, 경보 목록.\"\"\"\n", + " w = log[(log[\"t\"] > now - window) & (log[\"t\"] <= now)]\n", + " util = w[\"bytes\"].sum() * 8 / (link.bw(now) * window)\n", + " loss = 1 - w[\"ok\"].mean() if len(w) else 0.0\n", + " waiting = [m for m in msgs if m[\"t0\"] <= now and (done[m[\"id\"]] is None or done[m[\"id\"]] > now)]\n", + " depth = {p: sum(m[\"prec\"] == p for m in waiting) for p in PREC}\n", + " alerts = []\n", + " for m in waiting:\n", + " age = now - m[\"t0\"]\n", + " if age > DEADLINE[m[\"prec\"]]:\n", + " alerts.append((\"기한초과\", m[\"id\"], m[\"prec\"], round(age, 1)))\n", + " elif RANK[m[\"prec\"]] <= 1 and age > 0.7 * DEADLINE[m[\"prec\"]]:\n", + " alerts.append((\"기한임박\", m[\"id\"], m[\"prec\"], round(age, 1)))\n", + " if loss > 0.3:\n", + " alerts.append((\"링크열화\", \"-\", f\"손실률 {loss:.0%}\", round(now, 0)))\n", + " return {\"t\": now, \"util\": min(util, 1.0), \"loss\": loss, **depth, \"alerts\": alerts}\n", + "\n", + "\n", + "# 1) 1초 주기 갱신 모의(1,200회) + 처리시간 측정\n", + "frames, ms = [], []\n", + "for now in range(1, 1201):\n", + " t0 = time.perf_counter()\n", + " frames.append(snapshot(float(now)))\n", + " ms.append((time.perf_counter() - t0) * 1000)\n", + "ts = pd.DataFrame([{k: v for k, v in f.items() if k != \"alerts\"} for f in frames])\n", + "print(f\"갱신 1,200회: 처리시간 평균 {np.mean(ms):.2f} ms, 최대 {np.max(ms):.2f} ms (목표: 주기 1초 이내, 응답 100 ms 이내 -> {'달성' if np.max(ms) < 100 else '미달'})\")\n", + "\n", + "# 2) 구간별 상황 요약\n", + "ts[\"구간\"] = pd.cut(ts[\"t\"], [0, 300, 360, 600, 800, 1200], labels=[\"평시1\", \"재밍\", \"평시2\", \"대역폭50%\", \"평시3\"])\n", + "summary = ts.groupby(\"구간\", observed=True).agg(사용률=(\"util\", \"mean\"), 손실률=(\"loss\", \"mean\"), FLASH대기=(\"FLASH\", \"max\"),\n", + " IMMEDIATE대기=(\"IMMEDIATE\", \"max\"), ROUTINE대기=(\"ROUTINE\", \"max\")).round(2)\n", + "print(\"\\n[구간별 관제 지표: 평균 사용률·손실률, 최대 대기 전문 수]\")\n", + "print(summary.to_string())\n", + "\n", + "# 3) 경보 패널(긴급 경보 우선)\n", + "all_alerts = pd.DataFrame([(f[\"t\"], *a) for f in frames for a in f[\"alerts\"]], columns=[\"시각\", \"경보\", \"전문\", \"긴급도/내용\", \"경과\"])\n", + "first = all_alerts.drop_duplicates([\"경보\", \"전문\"]).copy()\n", + "print(f\"\\n경보 발생(중복 제외) {len(first)}건:\", first[\"경보\"].value_counts().to_dict())\n", + "order = {\"기한초과\": 0, \"링크열화\": 1, \"기한임박\": 2}\n", + "print(first.sort_values(by=[\"경보\", \"시각\"], key=lambda c: c.map(order) if c.name == \"경보\" else c).head(8).to_string(index=False))\n", + "\n", + "# 4) 관제 화면(야간 모드, 구성요소 5종)\n", + "plt.style.use(\"dark_background\")\n", + "fig = plt.figure(figsize=(13, 8))\n", + "a1 = fig.add_subplot(2, 2, 1); a1.plot(ts[\"t\"], ts[\"util\"] * 100, c=\"lime\"); a1.set_title(\"Bandwidth utilization (%)\"); a1.set_ylim(0, 105)\n", + "a2 = fig.add_subplot(2, 2, 2)\n", + "a2.stackplot(ts[\"t\"], *[ts[p] for p in PREC], colors=[COLORS[p] for p in PREC], labels=PREC); a2.legend(loc=\"upper left\", fontsize=8)\n", + "a2.set_title(\"Queued messages by precedence\")\n", + "a3 = fig.add_subplot(2, 2, 3); a3.plot(ts[\"t\"], ts[\"loss\"] * 100, c=\"orange\"); a3.axhline(30, c=\"red\", ls=\"--\")\n", + "a3.fill_between(ts[\"t\"], 0, 100, where=ts[\"loss\"] > 0.3, color=\"red\", alpha=0.25); a3.set_title(\"Packet loss rate (%) - red = link degraded\")\n", + "for a in (a1, a2, a3):\n", + " a.axvspan(300, 360, color=\"purple\", alpha=0.2); a.axvspan(600, 800, color=\"yellow\", alpha=0.08); a.set_xlabel(\"time (s)\")\n", + "a4 = fig.add_subplot(2, 2, 4); a4.axis(\"off\")\n", + "last = frames[-1]\n", + "kpi = summarize(res)[0]\n", + "txt = \"\\n\".join(f\"{r['긴급도']:<10} on-time {r['기한내 전달률']:.0%} p95 {r['95% 지연(s)']:>6}s\" for _, r in kpi.iterrows())\n", + "a4.text(0, 0.95, f\"KPI (t={last['t']:.0f}s)\\n{txt}\\n\\nAlerts total: {len(first)}\\n overdue: {(first['경보'] == '기한초과').sum()}\\n\"\n", + " f\" link degraded: {(first['경보'] == '링크열화').sum()}\", family=\"monospace\", fontsize=11, va=\"top\", color=\"white\")\n", + "fig.suptitle(\"Tactical link monitor (night mode) - training data only\", color=\"white\")\n", + "plt.tight_layout(); plt.show()\n", + "print(\"\\n화면 구성: 사용률 추이, 긴급도별 큐 대기, 손실률(열화 구간 강조), KPI·경보 패널, 재밍/대역폭 감소 구간 표시\")\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 06 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + "갱신 1,200회: 처리시간 평균 0.76 ms, 최대 3.48 ms (목표: 주기 1초 이내, 응답 100 ms 이내 -> 달성)\n", + "\n", + "[구간별 관제 지표: 평균 사용률·손실률, 최대 대기 전문 수]\n", + " 사용률 손실률 FLASH대기 IMMEDIATE대기 ROUTINE대기\n", + "구간 \n", + "평시1 0.84 0.03 1 1 9\n", + "재밍 0.99 0.58 1 0 13\n", + "평시2 0.99 0.06 1 1 17\n", + "대역폭50% 0.97 0.06 2 2 21\n", + "평시3 0.98 0.05 1 2 27\n", + "\n", + "경보 발생(중복 제외) 4건: {'기한임박': 2, '링크열화': 1, '기한초과': 1}\n", + " 시각 경보 전문 긴급도/내용 경과\n", + "805.0 기한초과 M0178 FLASH 10.1\n", + "303.0 링크열화 - 손실률 36% 303.0\n", + "780.0 기한임박 M0165 IMMEDIATE 21.7\n", + "802.0 기한임박 M0178 FLASH 7.1\n", + "\n", + "화면 구성: 사용률 추이, 긴급도별 큐 대기, 손실률(열화 구간 강조), KPI·경보 패널, 재밍/대역폭 감소 구간 표시\n", + "```\n", + "
\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 07. 최우선 순위 전문 강제 전송 및 큐 순서 수동 조정을 위한 통신관 HITL 통제 기능 설계\n", + "**소요시간**: 약 3.0시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "최우선 작전 전문 강제 전송 및 큐 순서 수동 조정을 위한 통신관 HITL 제어 기능을 설계한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **통신관이 최우선 전문 강제 전송과 큐 순서 수동 조정을 직접 수행하는 HITL 통제 기능을 설계한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **시스템은 보류·앞당기기를 추천만 하고 큐 순서를 바꾸지 않는다 과정을 진행하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `src/hitl/c202_approval_workflow.py 및 docs/c202_hitl_guide.md` 형태의 결과물을 완성한다.\n", + "\n", + "### 🔍 평가 및 검증 기준 (PASS 조건)\n", + "#### 미션 판정\n", + "\n", + "**평가 구분:** 필수\n", + "\n", + "**핵심 평가영역:** 지휘 책임 분계선 명확화 및 성급한 자동 단정 방지 체계 부합성\n", + "\n", + "#### 미션 PASS 조건\n", + "\n", + "- 정량 기준: 인간 검토 큐(Queue) 대기시간 및 승인/반려 처리 로깅 성공률 100%\n", + "\n", + "\n", + "- 진행 기준: 승인/반려 인터페이스 동작 및 감사 로그(Audit Log) 생성 검증\n", + "\n", + "\n", + "- 검증 방법: 모의 조작자 승인/반려 시뮬레이션 및 데이터 무결성 검증\n", + "\n", + "\n", + "#### 채점자 판정 메모\n", + "\n", + "> 단순 코드 실행 여부뿐만 아니라 출력된 수치와 근거 자료가 군 실무 요구사항에 부합하는지 면밀히 확인한다.\n", + "\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# [TODO : 학생 실습 7] 최우선 순위 전문 강제 전송 및 큐 순서 수동 조정을 위한 통신관 HITL 통제 기능 설계\n", + "# 미션 지침 및 검증 기준을 확인하고 코드를 실행 및 튜닝하세요.\n", + "\"\"\"통신관이 최우선 전문 강제 전송과 큐 순서 수동 조정을 직접 수행하는 HITL 통제 기능을 구현한다. 시스템은 추천만 하고 순서를 스스로 바꾸지 않으며, 규칙 위반 조작은 차단하고, FLASH보다 앞서는 강제 전송은 상급자 승인을 거치게 하며, 모든 조작은 위·변조 확인이 가능한 감사 로그에 기록한다.\"\"\"\n", + "import hashlib\n", + "import json\n", + "import pandas as pd\n", + "\n", + "ACTIONS = {\"FORCE_SEND\": \"강제 전송(맨 앞)\", \"MOVE_UP\": \"한 칸 앞으로\", \"MOVE_DOWN\": \"한 칸 뒤로\", \"HOLD\": \"보류\", \"RELEASE\": \"보류 해제\"}\n", + "RANK = {\"FLASH\": 0, \"IMMEDIATE\": 1, \"PRIORITY\": 2, \"ROUTINE\": 3}\n", + "\n", + "\n", + "class ControlError(ValueError):\n", + " pass\n", + "\n", + "\n", + "class FakeClock:\n", + " t = 1000.0\n", + "\n", + " def __call__(self):\n", + " return self.t\n", + "\n", + "\n", + "class HITLQueue:\n", + " def __init__(self, clock):\n", + " self.q, self.held, self.req, self.log, self.clock = [], {}, {}, [], clock\n", + "\n", + " def _record(self, body):\n", + " prev = self.log[-1][\"hash\"] if self.log else \"GENESIS\"\n", + " body = {**body, \"ts\": round(self.clock(), 1)}\n", + " h = hashlib.sha256((prev + json.dumps(body, ensure_ascii=False, sort_keys=True)).encode()).hexdigest()\n", + " self.log.append({**body, \"prev\": prev, \"hash\": h})\n", + "\n", + " def verify_log(self):\n", + " prev = \"GENESIS\"\n", + " for n, rec in enumerate(self.log, start=1):\n", + " body = {k: v for k, v in rec.items() if k not in (\"prev\", \"hash\")}\n", + " if rec[\"prev\"] != prev or rec[\"hash\"] != hashlib.sha256((prev + json.dumps(body, ensure_ascii=False, sort_keys=True)).encode()).hexdigest():\n", + " return False, n\n", + " prev = rec[\"hash\"]\n", + " return True, -1\n", + "\n", + " def submit(self, mid, prec):\n", + " \"\"\"기본 정렬(긴급도 -> 도착순) 위치에 넣는다. 수동 조정된 순서는 보존한다.\"\"\"\n", + " m = {\"id\": mid, \"prec\": prec, \"t0\": self.clock()}\n", + " pos = next((i for i, x in enumerate(self.q) if RANK[x[\"prec\"]] > RANK[prec]), len(self.q))\n", + " self.q.insert(pos, m)\n", + " self._record({\"event\": \"SUBMIT\", \"id\": mid, \"prec\": prec, \"by\": \"SYSTEM\"})\n", + "\n", + " def recommend(self, link_grade):\n", + " \"\"\"시스템 추천(자동 적용 안 함): 링크 Low면 ROUTINE 대용량 보류 권고 등.\"\"\"\n", + " out = []\n", + " if link_grade == \"Low\":\n", + " out += [f\"{m['id']} 보류 권고(ROUTINE)\" for m in self.q if m[\"prec\"] == \"ROUTINE\"][:2]\n", + " old = [m for m in self.q if m[\"prec\"] == \"IMMEDIATE\" and self.clock() - m[\"t0\"] > 20]\n", + " out += [f\"{m['id']} 앞당기기 권고(기한 임박)\" for m in old]\n", + " self._record({\"event\": \"RECOMMEND\", \"items\": out, \"by\": \"SYSTEM\"})\n", + " return out\n", + "\n", + " def act(self, mid, action, operator, reason, approver=None):\n", + " if action not in ACTIONS:\n", + " raise ControlError(f\"허용되지 않은 조작: {action}\")\n", + " if not operator:\n", + " raise ControlError(\"통신관 ID 필요\")\n", + " if len(reason.strip()) < 5:\n", + " raise ControlError(\"조작 근거 5자 이상 필요\")\n", + " idx = next((i for i, m in enumerate(self.q) if m[\"id\"] == mid), None)\n", + " if idx is None and not (action == \"RELEASE\" and mid in self.held):\n", + " raise ControlError(\"큐에 없는 전문\")\n", + " if action == \"FORCE_SEND\":\n", + " m = self.q[idx]\n", + " flash_waiting = any(x[\"prec\"] == \"FLASH\" for x in self.q if x[\"id\"] != mid)\n", + " if m[\"prec\"] != \"FLASH\" and flash_waiting and not approver:\n", + " self.req[mid] = {\"operator\": operator, \"reason\": reason, \"t\": self.clock()}\n", + " self._record({\"event\": \"REQUEST\", \"id\": mid, \"action\": action, \"operator\": operator, \"reason\": reason})\n", + " return \"상급자 승인 대기(대기 중인 FLASH보다 앞섬)\"\n", + " if approver == operator:\n", + " raise ControlError(\"요청자와 승인자는 달라야 함(2인 확인)\")\n", + " self.q.insert(0, self.q.pop(idx)); self.req.pop(mid, None)\n", + " elif action in (\"MOVE_UP\", \"MOVE_DOWN\"):\n", + " j = idx - 1 if action == \"MOVE_UP\" else idx + 1\n", + " if not 0 <= j < len(self.q):\n", + " raise ControlError(\"이동할 위치 없음\")\n", + " if action == \"MOVE_UP\" and self.q[j][\"prec\"] == \"FLASH\" and self.q[idx][\"prec\"] != \"FLASH\" and not approver:\n", + " raise ControlError(\"FLASH 앞으로 이동은 상급자 승인 필요\")\n", + " self.q[idx], self.q[j] = self.q[j], self.q[idx]\n", + " elif action == \"HOLD\":\n", + " if self.q[idx][\"prec\"] == \"FLASH\" and (len(reason.strip()) < 15 or not approver):\n", + " raise ControlError(\"FLASH 보류는 근거 15자 이상 + 상급자 승인 필요\")\n", + " self.held[mid] = self.q.pop(idx)\n", + " elif action == \"RELEASE\":\n", + " m = self.held.pop(mid)\n", + " pos = next((i for i, x in enumerate(self.q) if RANK[x[\"prec\"]] > RANK[m[\"prec\"]]), len(self.q))\n", + " self.q.insert(pos, m)\n", + " self._record({\"event\": \"ACT\", \"id\": mid, \"action\": action, \"operator\": operator, \"approver\": approver, \"reason\": reason})\n", + " return \"적용\"\n", + "\n", + "\n", + "clock = FakeClock()\n", + "hq = HITLQueue(clock)\n", + "# [교육용가상자료] 대기 전문 7건\n", + "for mid, p in [(\"M0101\", \"ROUTINE\"), (\"M0102\", \"PRIORITY\"), (\"M0103\", \"FLASH\"), (\"M0104\", \"IMMEDIATE\"), (\"M0105\", \"ROUTINE\"), (\"M0106\", \"PRIORITY\"), (\"M0107\", \"IMMEDIATE\")]:\n", + " clock.t += 3; hq.submit(mid, p)\n", + "order0 = [m[\"id\"] for m in hq.q]\n", + "print(\"초기 큐 순서:\", order0)\n", + "clock.t += 20\n", + "print(\"시스템 추천(자동 적용 안 함):\", hq.recommend(\"Low\"))\n", + "print(\"추천 후 큐 순서 변화 없음:\", [m[\"id\"] for m in hq.q] == order0)\n", + "\n", + "# 규칙 위반 조작 차단\n", + "for args in [(\"M0105\", \"FORCE_SEND\", \"\", \"근거 있음\"), (\"M0102\", \"MOVE_UP\", \"sig01\", \"앞으로\"), (\"M0104\", \"MOVE_UP\", \"sig01\", \"FLASH 앞으로 이동 요청\"),\n", + " (\"M0103\", \"HOLD\", \"sig01\", \"보류 필요함\"), (\"M0101\", \"DELETE\", \"sig01\", \"삭제 요청함\")]:\n", + " try:\n", + " hq.act(*args)\n", + " except ControlError as e:\n", + " print(f\"차단: {args[0]} {args[1]} -> {e}\")\n", + "\n", + "# 정상 조작\n", + "clock.t += 2; print(\"M0107 앞당김:\", hq.act(\"M0107\", \"MOVE_UP\", \"sig01\", \"기한 임박 IMMEDIATE 우선 처리\"))\n", + "clock.t += 2; print(\"M0102 강제 전송:\", hq.act(\"M0102\", \"FORCE_SEND\", \"sig01\", \"지휘관 지시 상황보고 즉시 전송\"))\n", + "clock.t += 25; print(\"M0102 상급자 승인:\", hq.act(\"M0102\", \"FORCE_SEND\", \"sig01\", \"지휘관 지시 상황보고 즉시 전송\", approver=\"sup01\"))\n", + "clock.t += 2; print(\"M0105 보류:\", hq.act(\"M0105\", \"HOLD\", \"sig02\", \"링크 Low, 대용량 행정전문 보류\"))\n", + "print(\"조정 후 큐 순서:\", [m[\"id\"] for m in hq.q], \"/ 보류:\", list(hq.held))\n", + "\n", + "# 대기시간·로그\n", + "waits = [{\"id\": r[\"id\"], \"조작\": r[\"action\"], \"통신관\": r[\"operator\"], \"승인자\": r[\"approver\"] or \"-\",\n", + " \"요청 후 적용까지(s)\": round(r[\"ts\"] - next((x[\"ts\"] for x in hq.log if x[\"event\"] == \"REQUEST\" and x[\"id\"] == r[\"id\"]), r[\"ts\"]), 1)}\n", + " for r in hq.log if r[\"event\"] == \"ACT\"]\n", + "print(\"\\n\" + pd.DataFrame(waits).to_string(index=False))\n", + "n_act = sum(r[\"event\"] in (\"ACT\", \"REQUEST\") for r in hq.log)\n", + "print(f\"\\n감사 로그 {len(hq.log)}건(조작·요청 {n_act}건 전부 기록, 로깅 성공률 100%), 해시 체인 검증: {hq.verify_log()}\")\n", + "hq.log[9][\"operator\"] = \"sig99\"\n", + "print(\"10번째 기록 변조 후 검증(False, 깨진 위치):\", hq.verify_log())\n", + "\n", + "# 지휘관 피드백 입력 양식(문자열로 출력)\n", + "form = {\"form\": \"c202_commander_feedback\", \"date_time\": \"(입력)\", \"message_id\": \"M0102\", \"delivery_ok\": \"(Y/N)\",\n", + " \"delay_acceptable\": \"(Y/N)\", \"priority_change_request\": \"(내용)\", \"comment\": \"(내용)\"}\n", + "print(\"\\n[지휘관 피드백 입력 양식]\\n\" + json.dumps(form, ensure_ascii=False))\n", + "print(\"시스템은 순서를 추천만 하며, 강제 전송·보류의 책임은 조작한 통신관과 승인한 상급자에게 있다.\")\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 07 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + "초기 큐 순서: ['M0103', 'M0104', 'M0107', 'M0102', 'M0106', 'M0101', 'M0105']\n", + "시스템 추천(자동 적용 안 함): ['M0101 보류 권고(ROUTINE)', 'M0105 보류 권고(ROUTINE)', 'M0104 앞당기기 권고(기한 임박)']\n", + "추천 후 큐 순서 변화 없음: True\n", + "차단: M0105 FORCE_SEND -> 통신관 ID 필요\n", + "차단: M0102 MOVE_UP -> 조작 근거 5자 이상 필요\n", + "차단: M0104 MOVE_UP -> FLASH 앞으로 이동은 상급자 승인 필요\n", + "차단: M0103 HOLD -> FLASH 보류는 근거 15자 이상 + 상급자 승인 필요\n", + "차단: M0101 DELETE -> 허용되지 않은 조작: DELETE\n", + "M0107 앞당김: 적용\n", + "M0102 강제 전송: 상급자 승인 대기(대기 중인 FLASH보다 앞섬)\n", + "M0102 상급자 승인: 적용\n", + "M0105 보류: 적용\n", + "조정 후 큐 순서: ['M0102', 'M0103', 'M0107', 'M0104', 'M0106', 'M0101'] / 보류: ['M0105']\n", + "\n", + " id 조작 통신관 승인자 요청 후 적용까지(s)\n", + "M0107 MOVE_UP sig01 - 0.0\n", + "M0102 FORCE_SEND sig01 sup01 25.0\n", + "M0105 HOLD sig02 - 0.0\n", + "\n", + "감사 로그 12건(조작·요청 4건 전부 기록, 로깅 성공률 100%), 해시 체인 검증: (True, -1)\n", + "10번째 기록 변조 후 검증(False, 깨진 위치): (False, 10)\n", + "\n", + "[지휘관 피드백 입력 양식]\n", + "{\"form\": \"c202_commander_feedback\", \"date_time\": \"(입력)\", \"message_id\": \"M0102\", \"delivery_ok\": \"(Y/N)\", \"delay_acceptable\": \"(Y/N)\", \"priority_change_request\": \"(내용)\", \"comment\": \"(내용)\"}\n", + "시스템은 순서를 추천만 하며, 강제 전송·보류의 책임은 조작한 통신관과 승인한 상급자에게 있다.\n", + "```\n", + "
\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 08. 대역폭 90% 급감 및 대량 전문 폭주 상황 모의 전송 스트레스 시뮬레이션\n", + "**소요시간**: 약 3.5시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "대역폭 90% 급감 및 대량 전문 동시 발생 극한 스트레스 시뮬레이션을 수행한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **대역폭 90% 급감과 전문 트래픽 500% 증가, 재밍·링크 두절 상황에서 큐·재전송 시스템의 안전 실패 동작을 검증한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **평시, 트래픽 6배, 대역폭 10%로 급감, 두 조건 동시, 재밍 120초, 대역폭 1% 두절 상황을 가상으로 주입하고 일반 모드와 성능 저하 모드(긴급 전문만 송신, 작은 조각·압축 헤더, 나머지 보관)를 비교 수행하고 검증하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `tests/c202_stress_test.py 및 스트레스 테스트 분석 보고서` 형태의 결과물을 완성한다.\n", + "\n", + "### 🔍 평가 및 검증 기준 (PASS 조건)\n", + "#### 미션 판정\n", + "\n", + "**평가 구분:** 필수\n", + "\n", + "**핵심 평가영역:** 극단 상황에서의 안전 실패(Fail-safe) 및 기능 축소 작동 적절성\n", + "\n", + "#### 미션 PASS 조건\n", + "\n", + "- 정량 기준: 동시 트래픽 500% 증가 시 시스템 다운 0건, 데이터 유실률 0%\n", + "\n", + "\n", + "- 진행 기준: 대량 시뮬레이션 부하 주입 테스트 통과 및 병목 구간 프로파일링 완료\n", + "\n", + "\n", + "- 검증 방법: 부하 테스트 도구를 통한 처리량(Throughput) 및 메모리 누수 검증\n", + "\n", + "\n", + "#### 채점자 판정 메모\n", + "\n", + "> 단순 코드 실행 여부뿐만 아니라 출력된 수치와 근거 자료가 군 실무 요구사항에 부합하는지 면밀히 확인한다.\n", + "\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "\"\"\"대역폭 90% 급감, 전문 트래픽 500% 증가(평시 6배), 재밍·링크 두절을 가상으로 주입해 큐·재전송 시스템이 멈추지 않는지(다운 0건), 전문이 사라지지 않는지(유실 0건: 전달+보관=전체), 성능 저하 모드(긴급 전문만 송신·작은 조각·압축 헤더, 나머지 보관)가 긴급 전문 전달을 지키는지 검증하고 처리량·메모리·병목 함수를 측정한다.\"\"\"\n", + "import cProfile\n", + "import io\n", + "import pstats\n", + "import time\n", + "import tracemalloc\n", + "import numpy as np\n", + "import pandas as pd\n", + "import matplotlib.pyplot as plt\n", + "\n", + "# ---------------------------------------------------------------------------\n", + "# [교육용가상자료] 가상 전술 전문(메시지) 생성기 (실제 부대·통신망·제원 정보 없음)\n", + "# 긴급도 4단계(FLASH/IMMEDIATE/PRIORITY/ROUTINE)와 전문 유형별 크기를 가진 전문을 만든다.\n", + "# ---------------------------------------------------------------------------\n", + "PREC = [\"FLASH\", \"IMMEDIATE\", \"PRIORITY\", \"ROUTINE\"]\n", + "RANK = {p: i for i, p in enumerate(PREC)}\n", + "DEADLINE = {\"FLASH\": 10, \"IMMEDIATE\": 30, \"PRIORITY\": 120, \"ROUTINE\": 600} # 가상 전달 목표 시간(초)\n", + "MSG_TYPES = { # 유형: (긴급도 후보, 확률, 본문 크기 범위 bytes, 발생 비율)\n", + " \"CONTACT\": ([\"FLASH\", \"IMMEDIATE\"], [0.7, 0.3], (120, 300), 0.08),\n", + " \"MEDEVAC\": ([\"FLASH\", \"IMMEDIATE\"], [0.4, 0.6], (150, 350), 0.05),\n", + " \"SITREP\": ([\"IMMEDIATE\", \"PRIORITY\"], [0.2, 0.8], (600, 2500), 0.20),\n", + " \"POSREP\": ([\"PRIORITY\", \"ROUTINE\"], [0.5, 0.5], (60, 120), 0.35),\n", + " \"LOGREQ\": ([\"PRIORITY\", \"ROUTINE\"], [0.4, 0.6], (300, 1200), 0.14),\n", + " \"ADMIN\": ([\"ROUTINE\"], [1.0], (1000, 5000), 0.18),\n", + "}\n", + "\n", + "\n", + "def make_messages(duration=600, rate=0.12, seed=7, t_start=0.0, prefix=\"M\"):\n", + " \"\"\"duration초 동안 평균 rate건/초로 발생하는 가상 전문 목록.\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " n = int(rng.poisson(rate * duration))\n", + " times = np.sort(rng.uniform(t_start, t_start + duration, n))\n", + " types = list(MSG_TYPES)\n", + " msgs = []\n", + " for i, t0 in enumerate(times):\n", + " ty = types[rng.choice(len(types), p=[MSG_TYPES[k][3] for k in types])]\n", + " cands, p, (lo, hi), _ = MSG_TYPES[ty]\n", + " msgs.append({\"id\": f\"{prefix}{i:04d}\", \"t0\": float(t0), \"type\": ty, \"prec\": str(rng.choice(cands, p=p)),\n", + " \"size\": int(rng.integers(lo, hi + 1)), \"src\": f\"U{int(rng.integers(1, 9)):02d}\"})\n", + " return msgs\n", + "\n", + "\n", + "class Link:\n", + " \"\"\"[교육용가상자료] 가상 협대역 무선 링크(기본 2.4kbps). 초 단위 상태(good/bad/jam)에 따라\n", + " 비트오류율이 바뀌고(Gilbert-Elliott 모델), 구간별 대역폭 감소도 줄 수 있다.\"\"\"\n", + " BER = {\"good\": 1e-5, \"bad\": 2e-4, \"jam\": 1.2e-3}\n", + "\n", + " def __init__(self, horizon=4000, bw_bps=2400, seed=1, p_gb=0.02, p_bg=0.15, jam=(), bw_drop=(), rtt=(1.2, 0.4)):\n", + " rng = np.random.default_rng(seed)\n", + " st, s = [], \"good\"\n", + " for _ in range(int(horizon) + 1):\n", + " s = (\"bad\" if rng.random() < p_gb else \"good\") if s == \"good\" else (\"good\" if rng.random() < p_bg else \"bad\")\n", + " st.append(s)\n", + " for a, b in jam:\n", + " for k in range(int(a), min(int(b), len(st))):\n", + " st[k] = \"jam\"\n", + " self.states, self.bw_bps, self.bw_drop, self.rtt_par = st, bw_bps, list(bw_drop), rtt\n", + "\n", + " def state(self, t):\n", + " return self.states[min(int(t), len(self.states) - 1)]\n", + "\n", + " def bw(self, t):\n", + " f = 1.0\n", + " for a, b, fac in self.bw_drop:\n", + " if a <= t < b:\n", + " f = fac\n", + " return self.bw_bps * f\n", + "\n", + " def p_loss(self, nbytes, t):\n", + " return 1 - (1 - self.BER[self.state(t)]) ** (8 * nbytes)\n", + "\n", + " def rtt(self, rng):\n", + " return self.rtt_par[0] + rng.exponential(self.rtt_par[1])\n", + "# ---------------------------------------------------------------------------\n", + "\n", + "# ---- 우선순위 큐·재전송 이산 사건 시뮬레이터 공통 함수 ----\n", + "HDR = 32 # 전문 조각(패킷) 헤더 bytes (미션 2의 헤더 압축 적용 시 12 bytes)\n", + "\n", + "\n", + "def policy_key(m, t, policy, aging):\n", + " # [TODO : 학생 실습 8] 대역폭 90% 급감 및 대량 전문 폭주 상황 모의 전송 스트레스 시뮬레이션 관련 핵심 로직을 구현하세요.\n", + " # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n", + " # <<< 여기에 코드를 작성하세요 >>>\n", + " pass # 작성 후 pass를 제거하세요.\n", + "def simulate(msgs, link, policy=\"strict\", mtu=256, preempt=True, rto_mode=\"fixed\", rto_s=4.0, aging=0.0,\n", + " redundancy=False, adapt_mtu=False, hdr=HDR, seed=0, until=None, admit=None):\n", + " \"\"\"전문 목록을 링크로 보내는 이산 사건 시뮬레이션.\n", + " - 전문은 mtu 크기 조각으로 나눠 보낸다. 조각이 손실되면 RTO 후 재전송한다(자료는 버리지 않음).\n", + " - preempt=False면 한 전문의 조각을 끝까지 연속 전송한다(큰 전문이 긴급 전문을 막는 현상 재현).\n", + " - rto_mode='adaptive'면 왕복시간(RTT) 추정값으로 RTO를 계산한다(Jacobson 방식).\n", + " - redundancy=True면 링크 열화 추정 시 FLASH·IMMEDIATE 조각을 2회 연속 전송한다.\n", + " - adapt_mtu=True면 링크 열화 추정 시 조각 크기를 줄인다.\n", + " - admit(m, t)이 False인 전문은 새 조각을 보내지 않고 저장해 둔다(성능 저하 모드).\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " pend = sorted(({**m, \"unsent\": m[\"size\"], \"ready\": [], \"frags\": [], \"done\": None, \"first\": None} for m in msgs), key=lambda m: m[\"t0\"])\n", + " t, i, active, cur = 0.0, 0, [], None\n", + " srtt, rttvar, loss = None, None, 0.0\n", + " st = {\"tx\": 0, \"retx\": 0, \"spurious\": 0, \"bytes\": 0, \"decide_ms\": [], \"flag\": {}, \"log\": []}\n", + " until = until if until is not None else (pend[-1][\"t0\"] if pend else 0) + 6000\n", + " while (i < len(pend) or active) and t < until:\n", + " while i < len(pend) and pend[i][\"t0\"] <= t:\n", + " active.append(pend[i]); i += 1\n", + " td = time.perf_counter()\n", + " for m in active: # ACK가 이미 도착한 재전송 예약은 취소\n", + " m[\"ready\"] = [e for e in m[\"ready\"] if not (e[1][\"ack\"] is not None and e[1][\"ack\"] <= t)]\n", + " ready = [m for m in active if any(r <= t for r, _ in m[\"ready\"]) or (m[\"unsent\"] > 0 and (admit is None or admit(m, t)))]\n", + " if not ready:\n", + " nxt = ([pend[i][\"t0\"]] if i < len(pend) else []) + [r for m in active for r, _ in m[\"ready\"] if r > t]\n", + " t = min(nxt) if nxt else t + 1.0\n", + " continue\n", + " if not preempt and cur in ready and cur[\"unsent\"] > 0:\n", + " m = cur\n", + " else:\n", + " m = min(ready, key=lambda x: policy_key(x, t, policy, aging))\n", + " st[\"decide_ms\"].append((time.perf_counter() - td) * 1000)\n", + " due = [e for e in m[\"ready\"] if e[0] <= t]\n", + " if due:\n", + " e = min(due, key=lambda x: x[0]); m[\"ready\"].remove(e); frag = e[1]\n", + " st[\"retx\"] += 1; st[\"spurious\"] += frag[\"recv\"]\n", + " else:\n", + " size = mtu if not adapt_mtu else (mtu if loss < 0.15 else (mtu // 2 if loss < 0.4 else mtu // 4))\n", + " size = min(m[\"unsent\"], size); m[\"unsent\"] -= size\n", + " frag = {\"b\": size, \"recv\": False, \"ack\": None, \"tries\": 0}\n", + " m[\"frags\"].append(frag)\n", + " cur = m\n", + " m[\"first\"] = t if m[\"first\"] is None else m[\"first\"]\n", + " nb = frag[\"b\"] + hdr\n", + " copies = 2 if (redundancy and RANK[m[\"prec\"]] <= 1 and loss > 0.15) else 1\n", + " for _ in range(copies):\n", + " t += nb * 8 / link.bw(t)\n", + " st[\"tx\"] += 1; st[\"bytes\"] += nb\n", + " ok = rng.random() >= link.p_loss(nb, t)\n", + " st[\"log\"].append((t, nb, ok, m[\"prec\"], m[\"id\"])) # 전송 기록(시각, bytes, 성공, 긴급도, 전문)\n", + " loss = 0.85 * loss + 0.15 * (0.0 if ok else 1.0) # 최근 손실률 추정(EWMA)\n", + " st[\"flag\"][int(t)] = st[\"flag\"].get(int(t), False) or loss > 0.12 # 링크 열화 판정(초 단위)\n", + " if ok and not frag[\"recv\"]:\n", + " frag[\"recv\"], frag[\"rt\"] = True, t\n", + " r = link.rtt(rng); frag[\"ack\"] = t + r\n", + " if srtt is None:\n", + " srtt, rttvar = r, r / 2\n", + " else:\n", + " rttvar = 0.75 * rttvar + 0.25 * abs(srtt - r); srtt = 0.875 * srtt + 0.125 * r\n", + " frag[\"tries\"] += 1\n", + " rto = rto_s if (rto_mode == \"fixed\" or srtt is None) else float(np.clip(srtt + 4 * rttvar, 1.0, 30.0))\n", + " if not frag[\"recv\"] or frag[\"ack\"] > t + rto: # 손실 또는 ACK 지연 -> 재전송 예약\n", + " m[\"ready\"].append((t + rto, frag))\n", + " if m[\"done\"] is None and m[\"unsent\"] == 0 and all(f[\"recv\"] for f in m[\"frags\"]):\n", + " m[\"done\"] = max(f[\"rt\"] for f in m[\"frags\"])\n", + " active = [x for x in active if not (x[\"unsent\"] == 0 and not x[\"ready\"] and all(f[\"recv\"] for f in x[\"frags\"]))]\n", + " res = [{\"id\": m[\"id\"], \"type\": m[\"type\"], \"prec\": m[\"prec\"], \"size\": m[\"size\"], \"t0\": m[\"t0\"], \"done\": m[\"done\"], \"wait\": None if m[\"first\"] is None else m[\"first\"] - m[\"t0\"],\n", + " \"retx\": sum(f[\"tries\"] for f in m[\"frags\"]) - len(m[\"frags\"]),\n", + " \"latency\": None if m[\"done\"] is None else m[\"done\"] - m[\"t0\"]} for m in pend]\n", + " for r in res:\n", + " r[\"on_time\"] = r[\"latency\"] is not None and r[\"latency\"] <= DEADLINE[r[\"prec\"]]\n", + " return res, st\n", + "\n", + "\n", + "def summarize(res):\n", + " \"\"\"긴급도별 기한 내 전달률·지연. 종합 정확도 = 긴급도별 기한 내 전달률의 평균.\"\"\"\n", + " # [TODO : 학생 실습 8] 대역폭 90% 급감 및 대량 전문 폭주 상황 모의 전송 스트레스 시뮬레이션 관련 핵심 로직을 구현하세요.\n", + " # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n", + " # <<< 여기에 코드를 작성하세요 >>>\n", + " pass # 작성 후 pass를 제거하세요.\n", + "ADV = dict(policy=\"aging\", aging=0.5, rto_mode=\"adaptive\", redundancy=True, adapt_mtu=True)\n", + "NOMINAL_BPS = 2400\n", + "\n", + "\n", + "def degraded_admit(link):\n", + " \"\"\"성능 저하 모드: 대역폭이 평시의 50% 미만이거나 링크가 재밍 상태면 FLASH·IMMEDIATE만 새로 송신하고 나머지는 보관(삭제 없음).\"\"\"\n", + " return lambda m, t: RANK[m[\"prec\"]] <= 1 or (link.bw(t) >= 0.5 * NOMINAL_BPS and link.state(t) != \"jam\")\n", + "\n", + "\n", + "def run_case(msgs, link, fail_safe, seed=0):\n", + " t0 = time.perf_counter()\n", + " try:\n", + " kw = dict(mtu=96, hdr=14, admit=degraded_admit(link)) if fail_safe else {} # 저하모드: 작은 조각 + 압축 헤더(미션 2) + 긴급 전문만 송신\n", + " res, st = simulate(msgs, link, **ADV, seed=seed, until=2000, **kw)\n", + " down = 0\n", + " except Exception as e: # 다운 집계\n", + " print(\"다운:\", e); res, st, down = [], {\"tx\": 0}, 1\n", + " dt = time.perf_counter() - t0\n", + " delivered = sum(r[\"done\"] is not None for r in res)\n", + " kept = len(res) - delivered # 2,000초 시점까지 미전달 = 저장·보관 중(삭제 없음)\n", + " urg = [r[\"on_time\"] for r in res if RANK[r[\"prec\"]] <= 1]\n", + " return {\"전문\": len(msgs), \"전달\": delivered, \"보관\": kept, \"유실\": len(msgs) - delivered - kept, \"다운\": down,\n", + " \"긴급 기한내\": round(float(np.mean(urg)), 3) if urg else np.nan,\n", + " \"FLASH 95%지연(s)\": round(float(np.quantile([r[\"latency\"] for r in res if r[\"prec\"] == \"FLASH\" and r[\"latency\"] is not None], 0.95)), 1),\n", + " \"처리량(건/s)\": round(len(msgs) / dt), \"모드\": \"저하모드\" if fail_safe else \"일반\"}\n", + "\n", + "\n", + "base = make_messages(600, 0.21, seed=7)\n", + "surge = base + [m for k in range(5) for m in make_messages(600, 0.21, seed=20 + k, prefix=f\"S{k}\")] # 평시 + 500%\n", + "CASES = [(\"평시\", base, {}), (\"트래픽 500% 증가\", surge, {}), (\"대역폭 90% 급감(200~500s)\", base, dict(bw_drop=[(200, 500, 0.1)])),\n", + " (\"트래픽 500% + 대역폭 90% 급감\", surge, dict(bw_drop=[(200, 500, 0.1)])),\n", + " (\"트래픽 500% + 재밍 120초\", surge, dict(jam=[(200, 320)])), (\"링크 두절 300초(대역폭 1%)\", base, dict(bw_drop=[(200, 500, 0.01)]))]\n", + "\n", + "rows = []\n", + "for i, (name, msgs, lk) in enumerate(CASES):\n", + " link = Link(4000, seed=1, **lk)\n", + " for fs in (False, True):\n", + " rows.append({\"상황\": name, **run_case(msgs, link, fs, seed=i)})\n", + "df = pd.DataFrame(rows)\n", + "print(df.drop(columns=[\"처리량(건/s)\"]).to_string(index=False))\n", + "print(f\"\\n시스템 다운 {df['다운'].sum()}건, 유실 {df['유실'].sum()}건(전달+보관=전체), 최소 처리량 {df['처리량(건/s)'].min():,}건/s\")\n", + "hard = df[df[\"상황\"] == \"트래픽 500% + 대역폭 90% 급감\"]\n", + "print(\"링크 두절(대역폭 1%) 구간에는 어떤 방식으로도 기한을 지킬 수 없다 -> 통신관에게 대체 수단(중계·전령 등) 검토를 요청하는 것이 안전 실패 동작이다.\")\n", + "print(f\"최악 상황 긴급 전문 기한 내 전달률: 일반 {hard.iloc[0]['긴급 기한내']:.1%} -> 성능 저하 모드 {hard.iloc[1]['긴급 기한내']:.1%}\")\n", + "need = sum(m[\"size\"] + 14 for m in surge if RANK[m[\"prec\"]] <= 1 and 200 <= m[\"t0\"] < 500) / 300\n", + "print(f\"원인: 급감 구간 긴급 전문만 초당 {need:.0f}B가 필요한데 링크는 초당 {NOMINAL_BPS * 0.1 / 8:.0f}B -> 스케줄링으로 해결 불가, 전문 간소화·발송 통제를 지휘계통에 건의해야 한다.\")\n", + "\n", + "# 메모리 누수 점검: 같은 시나리오를 3회 반복해도 메모리가 계속 늘지 않는가\n", + "tracemalloc.start()\n", + "mem = []\n", + "peak = 0\n", + "for k in range(3):\n", + " simulate(base, Link(4000, seed=1), **ADV, until=2000)\n", + " mem.append(tracemalloc.get_traced_memory()[0] / 1e6); peak = max(peak, tracemalloc.get_traced_memory()[1] / 1e6)\n", + "tracemalloc.stop()\n", + "print(f\"평시 시나리오 실행 중 최대 메모리 {peak:.1f} MB\")\n", + "print(\"반복 실행 후 잔류 메모리(MB):\", [round(m, 2) for m in mem], \"-> 누수 의심\" if mem[-1] > mem[0] * 1.5 + 1 else \"-> 누수 징후 없음\")\n", + "\n", + "# 병목 함수 프로파일링\n", + "pr = cProfile.Profile(); pr.enable()\n", + "simulate(surge, Link(4000, seed=1, bw_drop=[(200, 500, 0.1)]), **ADV, until=2000)\n", + "pr.disable()\n", + "s = io.StringIO(); pstats.Stats(pr, stream=s).sort_stats(\"tottime\").print_stats(4)\n", + "top = [ln.split() for ln in s.getvalue().splitlines() if ln.strip()[:1].isdigit() and \"(\" in ln and \"function calls\" not in ln][:3]\n", + "print(\"[병목 상위 3 함수: 호출 수 / 자체 소요(s) / 함수]\")\n", + "for f in top:\n", + " print(f\" {f[0]:>9} / {f[1]} / {' '.join(f[5:]).split('/')[-1][-45:]}\")\n", + "print(\"개선 방향: 매 결정마다 전체 대기 전문을 훑는 부분을 긴급도별 힙(heapq)과 재전송 타이머 힙으로 바꾸면 대기 전문 수에 덜 민감해진다.\")\n", + "\n", + "plt.figure(figsize=(9, 4))\n", + "x = np.arange(len(CASES))\n", + "plt.bar(x - 0.2, df[df[\"모드\"] == \"일반\"][\"긴급 기한내\"], 0.4, label=\"normal\")\n", + "plt.bar(x + 0.2, df[df[\"모드\"] == \"저하모드\"][\"긴급 기한내\"], 0.4, label=\"degraded mode (urgent only)\")\n", + "plt.xticks(x, [f\"case{i + 1}\" for i in x]); plt.ylabel(\"urgent on-time ratio\"); plt.ylim(0, 1.05); plt.legend()\n", + "plt.title(\"Stress test: urgent message delivery (synthetic)\"); plt.tight_layout(); plt.show()\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 08 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + " 상황 전문 전달 보관 유실 다운 긴급 기한내 FLASH 95%지연(s) 모드\n", + " 평시 130 130 0 0 0 1.000 2.0 일반\n", + " 평시 130 130 0 0 0 1.000 1.7 저하모드\n", + " 트래픽 500% 증가 758 567 191 0 0 0.991 2.0 일반\n", + " 트래픽 500% 증가 758 579 179 0 0 0.991 2.2 저하모드\n", + " 대역폭 90% 급감(200~500s) 130 130 0 0 0 0.500 23.9 일반\n", + " 대역폭 90% 급감(200~500s) 130 130 0 0 0 0.667 18.7 저하모드\n", + "트래픽 500% + 대역폭 90% 급감 758 519 239 0 0 0.389 93.4 일반\n", + "트래픽 500% + 대역폭 90% 급감 758 528 230 0 0 0.398 97.8 저하모드\n", + " 트래픽 500% + 재밍 120초 758 547 211 0 0 0.770 14.8 일반\n", + " 트래픽 500% + 재밍 120초 758 563 195 0 0 0.858 6.7 저하모드\n", + " 링크 두절 300초(대역폭 1%) 130 130 0 0 0 0.389 259.8 일반\n", + " 링크 두절 300초(대역폭 1%) 130 130 0 0 0 0.500 175.0 저하모드\n", + "\n", + "시스템 다운 0건, 유실 0건(전달+보관=전체), 최소 처리량 207건/s\n", + "링크 두절(대역폭 1%) 구간에는 어떤 방식으로도 기한을 지킬 수 없다 -> 통신관에게 대체 수단(중계·전령 등) 검토를 요청하는 것이 안전 실패 동작이다.\n", + "최악 상황 긴급 전문 기한 내 전달률: 일반 38.9% -> 성능 저하 모드 39.8%\n", + "원인: 급감 구간 긴급 전문만 초당 91B가 필요한데 링크는 초당 30B -> 스케줄링으로 해결 불가, 전문 간소화·발송 통제를 지휘계통에 건의해야 한다.\n", + "평시 시나리오 실행 중 최대 메모리 0.4 MB\n", + "반복 실행 후 잔류 메모리(MB): [0.01, 0.01, 0.01] -> 누수 징후 없음\n", + "[병목 상위 3 함수: 호출 수 / 자체 소요(s) / 함수]\n", + " 807785 / 0.476 / m08.py:80(policy_key)\n", + " 1 / 0.430 / m08.py:89(simulate)\n", + " 2132 / 0.408 / m08.py:110()\n", + "개선 방향: 매 결정마다 전체 대기 전문을 훑는 부분을 긴급도별 힙(heapq)과 재전송 타이머 힙으로 바꾸면 대기 전문 수에 덜 민감해진다.\n", + "```\n", + "
\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 09. AAR 기반 중요 전문 전송 지연 사례 분석 및 큐 우선순위 가중치 재조정\n", + "**소요시간**: 약 3.0시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "AAR을 통해 중요 전문 전송 지연 사례를 분석하고 큐 스케줄링 가중치를 재조정한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **AAR로 중요 전문의 전송 지연 사례를 원인별로 분석하고 큐 스케줄링 가중치와 재전송 파라미터를 재조정한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **초기 운용 설정(전문 단위 전송, 고정 재전송 타이머 6초, 조각 512bytes)으로 재밍·대역폭 감소가 있는 훈련을 모의하고 지연된 중요 전문을 대기열 적체·재전송 대기·링크 열화로 분류한 AAR 일지 작성하여 원인을 분석하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `docs/c202_aar_report.md 및 configs/c202_tuned_params.yaml` 형태의 결과물을 완성한다.\n", + "\n", + "### 🔍 평가 및 검증 기준 (PASS 조건)\n", + "#### 미션 판정\n", + "\n", + "**평가 구분:** 필수\n", + "\n", + "**핵심 평가영역:** 오류 원인 규명의 객관성 및 개선 조치의 논리적 타당성\n", + "\n", + "#### 미션 PASS 조건\n", + "\n", + "- 정량 기준: AAR 피드백 적용 후 종합 정확도 10%p 이상 향상, 재발 오류 0건\n", + "\n", + "\n", + "- 진행 기준: AAR 보고서 작성 및 파라미터 튜닝 전후 회귀 테스트 통과\n", + "\n", + "\n", + "- 검증 방법: 회귀 테스트(Regression Test) 스위트 전체 자동 실행 및 비교\n", + "\n", + "\n", + "#### 채점자 판정 메모\n", + "\n", + "> 단순 코드 실행 여부뿐만 아니라 출력된 수치와 근거 자료가 군 실무 요구사항에 부합하는지 면밀히 확인한다.\n", + "\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "\"\"\"AAR(사후검토) 일지 형식으로 중요 전문(FLASH·IMMEDIATE·PRIORITY)의 전송 지연 사례를 원인별로 분류하고, 큐 스케줄링 가중치(선점·aging)·조각 크기·재전송 타이머를 격자 탐색으로 재조정한 뒤 다른 운용 조건에서 회귀 테스트로 개선 전후를 비교한다.\"\"\"\n", + "import time\n", + "import numpy as np\n", + "import pandas as pd\n", + "import matplotlib.pyplot as plt\n", + "\n", + "# ---------------------------------------------------------------------------\n", + "# [교육용가상자료] 가상 전술 전문(메시지) 생성기 (실제 부대·통신망·제원 정보 없음)\n", + "# 긴급도 4단계(FLASH/IMMEDIATE/PRIORITY/ROUTINE)와 전문 유형별 크기를 가진 전문을 만든다.\n", + "# ---------------------------------------------------------------------------\n", + "PREC = [\"FLASH\", \"IMMEDIATE\", \"PRIORITY\", \"ROUTINE\"]\n", + "RANK = {p: i for i, p in enumerate(PREC)}\n", + "DEADLINE = {\"FLASH\": 10, \"IMMEDIATE\": 30, \"PRIORITY\": 120, \"ROUTINE\": 600} # 가상 전달 목표 시간(초)\n", + "MSG_TYPES = { # 유형: (긴급도 후보, 확률, 본문 크기 범위 bytes, 발생 비율)\n", + " \"CONTACT\": ([\"FLASH\", \"IMMEDIATE\"], [0.7, 0.3], (120, 300), 0.08),\n", + " \"MEDEVAC\": ([\"FLASH\", \"IMMEDIATE\"], [0.4, 0.6], (150, 350), 0.05),\n", + " \"SITREP\": ([\"IMMEDIATE\", \"PRIORITY\"], [0.2, 0.8], (600, 2500), 0.20),\n", + " \"POSREP\": ([\"PRIORITY\", \"ROUTINE\"], [0.5, 0.5], (60, 120), 0.35),\n", + " \"LOGREQ\": ([\"PRIORITY\", \"ROUTINE\"], [0.4, 0.6], (300, 1200), 0.14),\n", + " \"ADMIN\": ([\"ROUTINE\"], [1.0], (1000, 5000), 0.18),\n", + "}\n", + "\n", + "\n", + "def make_messages(duration=600, rate=0.12, seed=7, t_start=0.0, prefix=\"M\"):\n", + " \"\"\"duration초 동안 평균 rate건/초로 발생하는 가상 전문 목록.\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " n = int(rng.poisson(rate * duration))\n", + " times = np.sort(rng.uniform(t_start, t_start + duration, n))\n", + " types = list(MSG_TYPES)\n", + " msgs = []\n", + " for i, t0 in enumerate(times):\n", + " ty = types[rng.choice(len(types), p=[MSG_TYPES[k][3] for k in types])]\n", + " cands, p, (lo, hi), _ = MSG_TYPES[ty]\n", + " msgs.append({\"id\": f\"{prefix}{i:04d}\", \"t0\": float(t0), \"type\": ty, \"prec\": str(rng.choice(cands, p=p)),\n", + " \"size\": int(rng.integers(lo, hi + 1)), \"src\": f\"U{int(rng.integers(1, 9)):02d}\"})\n", + " return msgs\n", + "\n", + "\n", + "class Link:\n", + " \"\"\"[교육용가상자료] 가상 협대역 무선 링크(기본 2.4kbps). 초 단위 상태(good/bad/jam)에 따라\n", + " 비트오류율이 바뀌고(Gilbert-Elliott 모델), 구간별 대역폭 감소도 줄 수 있다.\"\"\"\n", + " BER = {\"good\": 1e-5, \"bad\": 2e-4, \"jam\": 1.2e-3}\n", + "\n", + " def __init__(self, horizon=4000, bw_bps=2400, seed=1, p_gb=0.02, p_bg=0.15, jam=(), bw_drop=(), rtt=(1.2, 0.4)):\n", + " rng = np.random.default_rng(seed)\n", + " st, s = [], \"good\"\n", + " for _ in range(int(horizon) + 1):\n", + " s = (\"bad\" if rng.random() < p_gb else \"good\") if s == \"good\" else (\"good\" if rng.random() < p_bg else \"bad\")\n", + " st.append(s)\n", + " for a, b in jam:\n", + " for k in range(int(a), min(int(b), len(st))):\n", + " st[k] = \"jam\"\n", + " self.states, self.bw_bps, self.bw_drop, self.rtt_par = st, bw_bps, list(bw_drop), rtt\n", + "\n", + " def state(self, t):\n", + " return self.states[min(int(t), len(self.states) - 1)]\n", + "\n", + " def bw(self, t):\n", + " f = 1.0\n", + " for a, b, fac in self.bw_drop:\n", + " if a <= t < b:\n", + " f = fac\n", + " return self.bw_bps * f\n", + "\n", + " def p_loss(self, nbytes, t):\n", + " return 1 - (1 - self.BER[self.state(t)]) ** (8 * nbytes)\n", + "\n", + " def rtt(self, rng):\n", + " return self.rtt_par[0] + rng.exponential(self.rtt_par[1])\n", + "# ---------------------------------------------------------------------------\n", + "\n", + "# ---- 우선순위 큐·재전송 이산 사건 시뮬레이터 공통 함수 ----\n", + "HDR = 32 # 전문 조각(패킷) 헤더 bytes (미션 2의 헤더 압축 적용 시 12 bytes)\n", + "\n", + "\n", + "def policy_key(m, t, policy, aging):\n", + " # [TODO : 학생 실습 9] AAR 기반 중요 전문 전송 지연 사례 분석 및 큐 우선순위 가중치 재조정 관련 핵심 로직을 구현하세요.\n", + " # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n", + " # <<< 여기에 코드를 작성하세요 >>>\n", + " pass # 작성 후 pass를 제거하세요.\n", + "def simulate(msgs, link, policy=\"strict\", mtu=256, preempt=True, rto_mode=\"fixed\", rto_s=4.0, aging=0.0,\n", + " redundancy=False, adapt_mtu=False, hdr=HDR, seed=0, until=None, admit=None):\n", + " \"\"\"전문 목록을 링크로 보내는 이산 사건 시뮬레이션.\n", + " - 전문은 mtu 크기 조각으로 나눠 보낸다. 조각이 손실되면 RTO 후 재전송한다(자료는 버리지 않음).\n", + " - preempt=False면 한 전문의 조각을 끝까지 연속 전송한다(큰 전문이 긴급 전문을 막는 현상 재현).\n", + " - rto_mode='adaptive'면 왕복시간(RTT) 추정값으로 RTO를 계산한다(Jacobson 방식).\n", + " - redundancy=True면 링크 열화 추정 시 FLASH·IMMEDIATE 조각을 2회 연속 전송한다.\n", + " - adapt_mtu=True면 링크 열화 추정 시 조각 크기를 줄인다.\n", + " - admit(m, t)이 False인 전문은 새 조각을 보내지 않고 저장해 둔다(성능 저하 모드).\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " pend = sorted(({**m, \"unsent\": m[\"size\"], \"ready\": [], \"frags\": [], \"done\": None, \"first\": None} for m in msgs), key=lambda m: m[\"t0\"])\n", + " t, i, active, cur = 0.0, 0, [], None\n", + " srtt, rttvar, loss = None, None, 0.0\n", + " st = {\"tx\": 0, \"retx\": 0, \"spurious\": 0, \"bytes\": 0, \"decide_ms\": [], \"flag\": {}, \"log\": []}\n", + " until = until if until is not None else (pend[-1][\"t0\"] if pend else 0) + 6000\n", + " while (i < len(pend) or active) and t < until:\n", + " while i < len(pend) and pend[i][\"t0\"] <= t:\n", + " active.append(pend[i]); i += 1\n", + " td = time.perf_counter()\n", + " for m in active: # ACK가 이미 도착한 재전송 예약은 취소\n", + " m[\"ready\"] = [e for e in m[\"ready\"] if not (e[1][\"ack\"] is not None and e[1][\"ack\"] <= t)]\n", + " ready = [m for m in active if any(r <= t for r, _ in m[\"ready\"]) or (m[\"unsent\"] > 0 and (admit is None or admit(m, t)))]\n", + " if not ready:\n", + " nxt = ([pend[i][\"t0\"]] if i < len(pend) else []) + [r for m in active for r, _ in m[\"ready\"] if r > t]\n", + " t = min(nxt) if nxt else t + 1.0\n", + " continue\n", + " if not preempt and cur in ready and cur[\"unsent\"] > 0:\n", + " m = cur\n", + " else:\n", + " m = min(ready, key=lambda x: policy_key(x, t, policy, aging))\n", + " st[\"decide_ms\"].append((time.perf_counter() - td) * 1000)\n", + " due = [e for e in m[\"ready\"] if e[0] <= t]\n", + " if due:\n", + " e = min(due, key=lambda x: x[0]); m[\"ready\"].remove(e); frag = e[1]\n", + " st[\"retx\"] += 1; st[\"spurious\"] += frag[\"recv\"]\n", + " else:\n", + " size = mtu if not adapt_mtu else (mtu if loss < 0.15 else (mtu // 2 if loss < 0.4 else mtu // 4))\n", + " size = min(m[\"unsent\"], size); m[\"unsent\"] -= size\n", + " frag = {\"b\": size, \"recv\": False, \"ack\": None, \"tries\": 0}\n", + " m[\"frags\"].append(frag)\n", + " cur = m\n", + " m[\"first\"] = t if m[\"first\"] is None else m[\"first\"]\n", + " nb = frag[\"b\"] + hdr\n", + " copies = 2 if (redundancy and RANK[m[\"prec\"]] <= 1 and loss > 0.15) else 1\n", + " for _ in range(copies):\n", + " t += nb * 8 / link.bw(t)\n", + " st[\"tx\"] += 1; st[\"bytes\"] += nb\n", + " ok = rng.random() >= link.p_loss(nb, t)\n", + " st[\"log\"].append((t, nb, ok, m[\"prec\"], m[\"id\"])) # 전송 기록(시각, bytes, 성공, 긴급도, 전문)\n", + " loss = 0.85 * loss + 0.15 * (0.0 if ok else 1.0) # 최근 손실률 추정(EWMA)\n", + " st[\"flag\"][int(t)] = st[\"flag\"].get(int(t), False) or loss > 0.12 # 링크 열화 판정(초 단위)\n", + " if ok and not frag[\"recv\"]:\n", + " frag[\"recv\"], frag[\"rt\"] = True, t\n", + " r = link.rtt(rng); frag[\"ack\"] = t + r\n", + " if srtt is None:\n", + " srtt, rttvar = r, r / 2\n", + " else:\n", + " rttvar = 0.75 * rttvar + 0.25 * abs(srtt - r); srtt = 0.875 * srtt + 0.125 * r\n", + " frag[\"tries\"] += 1\n", + " rto = rto_s if (rto_mode == \"fixed\" or srtt is None) else float(np.clip(srtt + 4 * rttvar, 1.0, 30.0))\n", + " if not frag[\"recv\"] or frag[\"ack\"] > t + rto: # 손실 또는 ACK 지연 -> 재전송 예약\n", + " m[\"ready\"].append((t + rto, frag))\n", + " if m[\"done\"] is None and m[\"unsent\"] == 0 and all(f[\"recv\"] for f in m[\"frags\"]):\n", + " m[\"done\"] = max(f[\"rt\"] for f in m[\"frags\"])\n", + " active = [x for x in active if not (x[\"unsent\"] == 0 and not x[\"ready\"] and all(f[\"recv\"] for f in x[\"frags\"]))]\n", + " res = [{\"id\": m[\"id\"], \"type\": m[\"type\"], \"prec\": m[\"prec\"], \"size\": m[\"size\"], \"t0\": m[\"t0\"], \"done\": m[\"done\"], \"wait\": None if m[\"first\"] is None else m[\"first\"] - m[\"t0\"],\n", + " \"retx\": sum(f[\"tries\"] for f in m[\"frags\"]) - len(m[\"frags\"]),\n", + " \"latency\": None if m[\"done\"] is None else m[\"done\"] - m[\"t0\"]} for m in pend]\n", + " for r in res:\n", + " r[\"on_time\"] = r[\"latency\"] is not None and r[\"latency\"] <= DEADLINE[r[\"prec\"]]\n", + " return res, st\n", + "\n", + "\n", + "def summarize(res):\n", + " \"\"\"긴급도별 기한 내 전달률·지연. 종합 정확도 = 긴급도별 기한 내 전달률의 평균.\"\"\"\n", + " # [TODO : 학생 실습 9] AAR 기반 중요 전문 전송 지연 사례 분석 및 큐 우선순위 가중치 재조정 관련 핵심 로직을 구현하세요.\n", + " # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n", + " # <<< 여기에 코드를 작성하세요 >>>\n", + " pass # 작성 후 pass를 제거하세요.\n", + "INITIAL = dict(policy=\"strict\", preempt=False, rto_mode=\"fixed\", rto_s=6.0, mtu=512, aging=0.0) # 초기 운용 설정\n", + "\n", + "\n", + "def aar_scenario(seed):\n", + " \"\"\"[교육용가상자료] 훈련 1회분: 재밍 60초 + 대역폭 50% 감소 200초 + 중계 지연이 큰 망.\"\"\"\n", + " msgs = make_messages(1200, 0.21, seed=seed)\n", + " link = Link(12000, seed=seed + 100, jam=[(300, 360)], bw_drop=[(600, 800, 0.5)], rtt=(2.0, 0.8))\n", + " return msgs, link\n", + "\n", + "\n", + "def aar_log(res, link):\n", + " rows = []\n", + " for r in res:\n", + " if RANK[r[\"prec\"]] <= 2 and not r[\"on_time\"]:\n", + " bad = any(link.state(k) != \"good\" or link.bw(k) < link.bw_bps for k in range(int(r[\"t0\"]), int(r[\"t0\"] + (r[\"latency\"] or 60)) + 1))\n", + " if r[\"retx\"] > 0 and bad:\n", + " cause = \"링크 열화 중 재전송\"\n", + " elif r[\"retx\"] > 0:\n", + " cause = \"재전송 타이머(RTO) 대기\"\n", + " elif r[\"wait\"] is not None and r[\"wait\"] > DEADLINE[r[\"prec\"]] / 2:\n", + " cause = \"대기열 적체·하위 전문 점유\"\n", + " else:\n", + " cause = \"대용량 전문 분할 전송 지연\"\n", + " rows.append({\"전문\": r[\"id\"], \"긴급도\": r[\"prec\"], \"크기\": r[\"size\"], \"지연(s)\": round(r[\"latency\"] or -1, 1),\n", + " \"첫 송신 대기(s)\": round(r[\"wait\"] or -1, 1), \"재전송\": r[\"retx\"], \"원인\": cause})\n", + " return pd.DataFrame(rows, columns=[\"전문\", \"긴급도\", \"크기\", \"지연(s)\", \"첫 송신 대기(s)\", \"재전송\", \"원인\"])\n", + "\n", + "\n", + "# 1) 무엇이 일어났나: 초기 설정으로 훈련 운용\n", + "msgs, link = aar_scenario(7)\n", + "before_res, _ = simulate(msgs, link, **INITIAL)\n", + "tab0, acc0 = summarize(before_res)\n", + "log0 = aar_log(before_res, link)\n", + "print(\"[1. 무엇이 일어났나] 초기 설정 종합 정확도(긴급도별 기한 내 전달률 평균):\", acc0)\n", + "print(tab0[[\"긴급도\", \"건수\", \"기한내 전달률\", \"95% 지연(s)\"]].to_string(index=False))\n", + "print(\"\\n[2. 왜 일어났나] 중요 전문 지연 원인 분류\")\n", + "print(log0.groupby([\"긴급도\", \"원인\"]).size().rename(\"건수\").to_string())\n", + "\n", + "# 2) 무엇을 바꾸나: 격자 탐색(제약: FLASH 기한 내 전달률 95% 이상)\n", + "grid = []\n", + "for preempt in (False, True):\n", + " for rto in (\"fixed\", \"adaptive\"):\n", + " for mtu in (128, 256, 512):\n", + " for aging in (0.0, 0.5, 1.0):\n", + " cfg = dict(policy=\"aging\" if aging else \"strict\", preempt=preempt, rto_mode=rto, rto_s=6.0, mtu=mtu, aging=aging)\n", + " tab, acc = summarize(simulate(msgs, link, **cfg)[0])\n", + " grid.append({**cfg, \"FLASH\": tab.loc[0, \"기한내 전달률\"], \"종합정확도\": acc})\n", + "g = pd.DataFrame(grid)\n", + "ok = g[g[\"FLASH\"] >= 0.95].sort_values([\"종합정확도\", \"FLASH\"], ascending=False)\n", + "best = ok.iloc[0]\n", + "TUNED = dict(policy=best[\"policy\"], preempt=bool(best[\"preempt\"]), rto_mode=best[\"rto_mode\"], rto_s=6.0, mtu=int(best[\"mtu\"]), aging=float(best[\"aging\"]))\n", + "print(f\"\\n[3. 무엇을 바꾸나] 격자 {len(g)}개 중 제약 충족 {len(ok)}개, 상위 3개\")\n", + "print(ok.head(3)[[\"preempt\", \"rto_mode\", \"mtu\", \"aging\", \"FLASH\", \"종합정확도\"]].to_string(index=False))\n", + "\n", + "after_res, _ = simulate(msgs, link, **TUNED)\n", + "tab1, acc1 = summarize(after_res)\n", + "log1 = aar_log(after_res, link)\n", + "print(f\"\\n종합 정확도 {acc0:.3f} -> {acc1:.3f} ({(acc1 - acc0) * 100:+.1f}%p, 목표 +10%p: {'달성' if acc1 - acc0 >= 0.10 else '미달'})\")\n", + "print(f\"FLASH 기한 내 전달률 {tab0.loc[0, '기한내 전달률']:.1%} -> {tab1.loc[0, '기한내 전달률']:.1%}, 중요 전문 지연 {len(log0)}건 -> {len(log1)}건\")\n", + "repeat = sorted(set(log0[\"전문\"]) & set(log1[\"전문\"]))\n", + "print(f\"재보정 후에도 반복된 지연 전문: {repeat if repeat else '없음(재발 0건)'}\")\n", + "for _, r in log1[log1[\"전문\"].isin(repeat)].iterrows():\n", + " print(f\" {r['전문']} {r['긴급도']} 지연 {r['지연(s)']}s, 재전송 {r['재전송']}회 -> 원인: {r['원인']} (스케줄링으로 줄이기 어려운 유형, 링크 열화 시 중복 전송 검토)\")\n", + "\n", + "# 3) 회귀 테스트: 다른 훈련 3회(다른 전문·링크)에서도 개선이 유지되는가\n", + "print(\"\\n[회귀 테스트] 훈련 초기 재보정 FLASH(재보정)\")\n", + "reg = []\n", + "for sd in (11, 12, 13):\n", + " m2, l2 = aar_scenario(sd)\n", + " a0 = summarize(simulate(m2, l2, **INITIAL)[0])[1]\n", + " t1, a1 = summarize(simulate(m2, l2, **TUNED)[0])\n", + " reg.append(a1 >= a0)\n", + " print(f\" #{sd} {a0:.3f} {a1:.3f} {t1.loc[0, '기한내 전달률']:.3f}\")\n", + "print(f\"회귀 테스트 {sum(reg)}/{len(reg)} 통과(재보정 후 종합 정확도가 떨어지지 않음)\")\n", + "\n", + "yaml = (f\"scheduler:\\n policy: {TUNED['policy']}\\n preempt_fragment: {str(TUNED['preempt']).lower()}\\n aging: {TUNED['aging']}\\n\"\n", + " f\"fragment_mtu_bytes: {TUNED['mtu']}\\nretransmission:\\n rto_mode: {TUNED['rto_mode']}\\n fixed_rto_s: 6.0\\nconstraint:\\n min_flash_on_time: 0.95\")\n", + "print(\"\\n[재보정 파라미터(c202_tuned_params.yaml 내용)]\\n\" + yaml)\n", + "print(\"지속 개선: 훈련마다 AAR 일지를 누적해 같은 격자 탐색을 다시 수행하고, 변경 전 회귀 테스트 통과를 배포 조건으로 둔다.\")\n", + "\n", + "plt.figure(figsize=(7, 4))\n", + "for pre, c in ((False, \"tab:gray\"), (True, \"tab:blue\")):\n", + " s = g[g[\"preempt\"] == pre]\n", + " plt.scatter(s[\"FLASH\"], s[\"종합정확도\"], c=c, label=f\"fragment preempt={pre}\")\n", + "plt.scatter([best[\"FLASH\"]], [best[\"종합정확도\"]], s=200, facecolors=\"none\", edgecolors=\"red\", label=\"selected\")\n", + "plt.axvline(0.95, ls=\"--\", c=\"gray\"); plt.xlabel(\"FLASH on-time ratio\"); plt.ylabel(\"overall accuracy (macro on-time)\")\n", + "plt.legend(); plt.title(\"AAR re-tuning grid (synthetic)\"); plt.tight_layout(); plt.show()\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 09 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + "[1. 무엇이 일어났나] 초기 설정 종합 정확도(긴급도별 기한 내 전달률 평균): 0.921\n", + " 긴급도 건수 기한내 전달률 95% 지연(s)\n", + " FLASH 25 0.720 19.4\n", + "IMMEDIATE 27 0.963 25.6\n", + " PRIORITY 99 1.000 52.0\n", + " ROUTINE 107 1.000 232.4\n", + "\n", + "[2. 왜 일어났나] 중요 전문 지연 원인 분류\n", + "긴급도 원인 \n", + "FLASH 대기열 적체·하위 전문 점유 4\n", + " 링크 열화 중 재전송 3\n", + "IMMEDIATE 링크 열화 중 재전송 1\n", + "\n", + "[3. 무엇을 바꾸나] 격자 36개 중 제약 충족 18개, 상위 3개\n", + " preempt rto_mode mtu aging FLASH 종합정확도\n", + " True fixed 128 0.0 0.96 0.99\n", + " True fixed 128 0.5 0.96 0.99\n", + " True fixed 128 1.0 0.96 0.99\n", + "\n", + "종합 정확도 0.921 -> 0.990 (+6.9%p, 목표 +10%p: 미달)\n", + "FLASH 기한 내 전달률 72.0% -> 96.0%, 중요 전문 지연 8건 -> 1건\n", + "재보정 후에도 반복된 지연 전문: ['M0070']\n", + " M0070 FLASH 지연 41.1s, 재전송 10회 -> 원인: 링크 열화 중 재전송 (스케줄링으로 줄이기 어려운 유형, 링크 열화 시 중복 전송 검토)\n", + "\n", + "[회귀 테스트] 훈련 초기 재보정 FLASH(재보정)\n", + " #11 0.936 1.000 1.000\n", + " #12 0.953 0.988 0.952\n", + " #13 0.943 0.951 0.870\n", + "회귀 테스트 3/3 통과(재보정 후 종합 정확도가 떨어지지 않음)\n", + "\n", + "[재보정 파라미터(c202_tuned_params.yaml 내용)]\n", + "scheduler:\n", + " policy: strict\n", + " preempt_fragment: true\n", + " aging: 0.0\n", + "fragment_mtu_bytes: 128\n", + "retransmission:\n", + " rto_mode: fixed\n", + " fixed_rto_s: 6.0\n", + "constraint:\n", + "... (이하 생략)\n", + "```\n", + "
\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 10. 대역폭 제한 환경 메시지 큐·재전송 시스템 통합 및 전술통신 브리핑 패키지 완성\n", + "**소요시간**: 약 3.5시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "대역폭 제한 시 메시지 우선순위 큐·재전송 시스템 통합 및 배포 가이드를 완성한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **패킷 수신부터 전처리, 큐·재전송, 링크 등급, 통신관 요청 대기열, 관제 화면까지 통합하고 전술통신 브리핑 패키지를 완성한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **재조정 파라미터로 전체 과정을 한 번에 실행하는 통합 코드를 완성하고 손상·미등록 패킷이 격리되는지 확인하고 유효성을 점검하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 2개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `src/main_c202.py, README.md, docs/c202_final_report.pdf(md)` 형태의 결과물을 완성한다.\n", + "\n", + "### 🔍 평가 및 검증 기준 (PASS 조건)\n", + "#### 미션 판정\n", + "\n", + "**평가 구분:** 필수\n", + "\n", + "**핵심 평가영역:** 군 실무 즉시 도입 가능성, 운용 신뢰도 및 전략적 기대효과\n", + "\n", + "#### 미션 PASS 조건\n", + "\n", + "- 정량 기준: End-to-End 통합 파이프라인 실행 성공률 100%, 테스트 커버리지 85% 이상\n", + "\n", + "\n", + "- 진행 기준: 원클릭 실행 스크립트 동작 확인 및 최종 발표 산출물 작성 완료\n", + "\n", + "\n", + "- 검증 방법: 최종 검증 위원회(평가관) 모의 시연 및 전체 기능 통합 검증\n", + "\n", + "\n", + "#### 채점자 판정 메모\n", + "\n", + "> 단순 코드 실행 여부뿐만 아니라 출력된 수치와 근거 자료가 군 실무 요구사항에 부합하는지 면밀히 확인한다.\n", + "\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "\"\"\"원시 전문 패킷 파싱·검증·압축부터 우선순위 큐, 지능형 재전송, 링크 신뢰도 등급, 통신관 HITL 요청 대기열, 관제 화면, AAR 재보정 파라미터 적용까지 전체 파이프라인을 한 파일로 통합 실행하고, 함수 커버리지를 포함한 자체 점검 결과와 전술통신 브리핑 요약을 출력한다.\"\"\"\n", + "import hashlib\n", + "import inspect\n", + "import json\n", + "import sys\n", + "import time\n", + "import zlib\n", + "import numpy as np\n", + "import pandas as pd\n", + "import matplotlib.pyplot as plt\n", + "\n", + "# ---------------------------------------------------------------------------\n", + "# [교육용가상자료] 가상 전술 전문(메시지) 생성기 (실제 부대·통신망·제원 정보 없음)\n", + "# 긴급도 4단계(FLASH/IMMEDIATE/PRIORITY/ROUTINE)와 전문 유형별 크기를 가진 전문을 만든다.\n", + "# ---------------------------------------------------------------------------\n", + "PREC = [\"FLASH\", \"IMMEDIATE\", \"PRIORITY\", \"ROUTINE\"]\n", + "RANK = {p: i for i, p in enumerate(PREC)}\n", + "DEADLINE = {\"FLASH\": 10, \"IMMEDIATE\": 30, \"PRIORITY\": 120, \"ROUTINE\": 600} # 가상 전달 목표 시간(초)\n", + "MSG_TYPES = { # 유형: (긴급도 후보, 확률, 본문 크기 범위 bytes, 발생 비율)\n", + " \"CONTACT\": ([\"FLASH\", \"IMMEDIATE\"], [0.7, 0.3], (120, 300), 0.08),\n", + " \"MEDEVAC\": ([\"FLASH\", \"IMMEDIATE\"], [0.4, 0.6], (150, 350), 0.05),\n", + " \"SITREP\": ([\"IMMEDIATE\", \"PRIORITY\"], [0.2, 0.8], (600, 2500), 0.20),\n", + " \"POSREP\": ([\"PRIORITY\", \"ROUTINE\"], [0.5, 0.5], (60, 120), 0.35),\n", + " \"LOGREQ\": ([\"PRIORITY\", \"ROUTINE\"], [0.4, 0.6], (300, 1200), 0.14),\n", + " \"ADMIN\": ([\"ROUTINE\"], [1.0], (1000, 5000), 0.18),\n", + "}\n", + "\n", + "\n", + "def make_messages(duration=600, rate=0.12, seed=7, t_start=0.0, prefix=\"M\"):\n", + " \"\"\"duration초 동안 평균 rate건/초로 발생하는 가상 전문 목록.\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " n = int(rng.poisson(rate * duration))\n", + " times = np.sort(rng.uniform(t_start, t_start + duration, n))\n", + " types = list(MSG_TYPES)\n", + " msgs = []\n", + " for i, t0 in enumerate(times):\n", + " ty = types[rng.choice(len(types), p=[MSG_TYPES[k][3] for k in types])]\n", + " cands, p, (lo, hi), _ = MSG_TYPES[ty]\n", + " msgs.append({\"id\": f\"{prefix}{i:04d}\", \"t0\": float(t0), \"type\": ty, \"prec\": str(rng.choice(cands, p=p)),\n", + " \"size\": int(rng.integers(lo, hi + 1)), \"src\": f\"U{int(rng.integers(1, 9)):02d}\"})\n", + " return msgs\n", + "\n", + "\n", + "class Link:\n", + " \"\"\"[교육용가상자료] 가상 협대역 무선 링크(기본 2.4kbps). 초 단위 상태(good/bad/jam)에 따라\n", + " 비트오류율이 바뀌고(Gilbert-Elliott 모델), 구간별 대역폭 감소도 줄 수 있다.\"\"\"\n", + " BER = {\"good\": 1e-5, \"bad\": 2e-4, \"jam\": 1.2e-3}\n", + "\n", + " def __init__(self, horizon=4000, bw_bps=2400, seed=1, p_gb=0.02, p_bg=0.15, jam=(), bw_drop=(), rtt=(1.2, 0.4)):\n", + " rng = np.random.default_rng(seed)\n", + " st, s = [], \"good\"\n", + " for _ in range(int(horizon) + 1):\n", + " s = (\"bad\" if rng.random() < p_gb else \"good\") if s == \"good\" else (\"good\" if rng.random() < p_bg else \"bad\")\n", + " st.append(s)\n", + " for a, b in jam:\n", + " for k in range(int(a), min(int(b), len(st))):\n", + " st[k] = \"jam\"\n", + " self.states, self.bw_bps, self.bw_drop, self.rtt_par = st, bw_bps, list(bw_drop), rtt\n", + "\n", + " def state(self, t):\n", + " return self.states[min(int(t), len(self.states) - 1)]\n", + "\n", + " def bw(self, t):\n", + " f = 1.0\n", + " for a, b, fac in self.bw_drop:\n", + " if a <= t < b:\n", + " f = fac\n", + " return self.bw_bps * f\n", + "\n", + " def p_loss(self, nbytes, t):\n", + " return 1 - (1 - self.BER[self.state(t)]) ** (8 * nbytes)\n", + "\n", + " def rtt(self, rng):\n", + " return self.rtt_par[0] + rng.exponential(self.rtt_par[1])\n", + "# ---------------------------------------------------------------------------\n", + "\n", + "# ---- 우선순위 큐·재전송 이산 사건 시뮬레이터 공통 함수 ----\n", + "HDR = 32 # 전문 조각(패킷) 헤더 bytes (미션 2의 헤더 압축 적용 시 12 bytes)\n", + "\n", + "\n", + "def policy_key(m, t, policy, aging):\n", + " # [TODO : 학생 실습 10] 대역폭 제한 환경 메시지 큐·재전송 시스템 통합 및 전술통신 브리핑 패키지 완성 관련 핵심 로직을 구현하세요.\n", + " # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n", + " # <<< 여기에 코드를 작성하세요 >>>\n", + " pass # 작성 후 pass를 제거하세요.\n", + "def simulate(msgs, link, policy=\"strict\", mtu=256, preempt=True, rto_mode=\"fixed\", rto_s=4.0, aging=0.0,\n", + " redundancy=False, adapt_mtu=False, hdr=HDR, seed=0, until=None, admit=None):\n", + " \"\"\"전문 목록을 링크로 보내는 이산 사건 시뮬레이션.\n", + " - 전문은 mtu 크기 조각으로 나눠 보낸다. 조각이 손실되면 RTO 후 재전송한다(자료는 버리지 않음).\n", + " - preempt=False면 한 전문의 조각을 끝까지 연속 전송한다(큰 전문이 긴급 전문을 막는 현상 재현).\n", + " - rto_mode='adaptive'면 왕복시간(RTT) 추정값으로 RTO를 계산한다(Jacobson 방식).\n", + " - redundancy=True면 링크 열화 추정 시 FLASH·IMMEDIATE 조각을 2회 연속 전송한다.\n", + " - adapt_mtu=True면 링크 열화 추정 시 조각 크기를 줄인다.\n", + " - admit(m, t)이 False인 전문은 새 조각을 보내지 않고 저장해 둔다(성능 저하 모드).\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " pend = sorted(({**m, \"unsent\": m[\"size\"], \"ready\": [], \"frags\": [], \"done\": None, \"first\": None} for m in msgs), key=lambda m: m[\"t0\"])\n", + " t, i, active, cur = 0.0, 0, [], None\n", + " srtt, rttvar, loss = None, None, 0.0\n", + " st = {\"tx\": 0, \"retx\": 0, \"spurious\": 0, \"bytes\": 0, \"decide_ms\": [], \"flag\": {}, \"log\": []}\n", + " until = until if until is not None else (pend[-1][\"t0\"] if pend else 0) + 6000\n", + " while (i < len(pend) or active) and t < until:\n", + " while i < len(pend) and pend[i][\"t0\"] <= t:\n", + " active.append(pend[i]); i += 1\n", + " td = time.perf_counter()\n", + " for m in active: # ACK가 이미 도착한 재전송 예약은 취소\n", + " m[\"ready\"] = [e for e in m[\"ready\"] if not (e[1][\"ack\"] is not None and e[1][\"ack\"] <= t)]\n", + " ready = [m for m in active if any(r <= t for r, _ in m[\"ready\"]) or (m[\"unsent\"] > 0 and (admit is None or admit(m, t)))]\n", + " if not ready:\n", + " nxt = ([pend[i][\"t0\"]] if i < len(pend) else []) + [r for m in active for r, _ in m[\"ready\"] if r > t]\n", + " t = min(nxt) if nxt else t + 1.0\n", + " continue\n", + " if not preempt and cur in ready and cur[\"unsent\"] > 0:\n", + " m = cur\n", + " else:\n", + " m = min(ready, key=lambda x: policy_key(x, t, policy, aging))\n", + " st[\"decide_ms\"].append((time.perf_counter() - td) * 1000)\n", + " due = [e for e in m[\"ready\"] if e[0] <= t]\n", + " if due:\n", + " e = min(due, key=lambda x: x[0]); m[\"ready\"].remove(e); frag = e[1]\n", + " st[\"retx\"] += 1; st[\"spurious\"] += frag[\"recv\"]\n", + " else:\n", + " size = mtu if not adapt_mtu else (mtu if loss < 0.15 else (mtu // 2 if loss < 0.4 else mtu // 4))\n", + " size = min(m[\"unsent\"], size); m[\"unsent\"] -= size\n", + " frag = {\"b\": size, \"recv\": False, \"ack\": None, \"tries\": 0}\n", + " m[\"frags\"].append(frag)\n", + " cur = m\n", + " m[\"first\"] = t if m[\"first\"] is None else m[\"first\"]\n", + " nb = frag[\"b\"] + hdr\n", + " copies = 2 if (redundancy and RANK[m[\"prec\"]] <= 1 and loss > 0.15) else 1\n", + " for _ in range(copies):\n", + " t += nb * 8 / link.bw(t)\n", + " st[\"tx\"] += 1; st[\"bytes\"] += nb\n", + " ok = rng.random() >= link.p_loss(nb, t)\n", + " st[\"log\"].append((t, nb, ok, m[\"prec\"], m[\"id\"])) # 전송 기록(시각, bytes, 성공, 긴급도, 전문)\n", + " loss = 0.85 * loss + 0.15 * (0.0 if ok else 1.0) # 최근 손실률 추정(EWMA)\n", + " st[\"flag\"][int(t)] = st[\"flag\"].get(int(t), False) or loss > 0.12 # 링크 열화 판정(초 단위)\n", + " if ok and not frag[\"recv\"]:\n", + " frag[\"recv\"], frag[\"rt\"] = True, t\n", + " r = link.rtt(rng); frag[\"ack\"] = t + r\n", + " if srtt is None:\n", + " srtt, rttvar = r, r / 2\n", + " else:\n", + " rttvar = 0.75 * rttvar + 0.25 * abs(srtt - r); srtt = 0.875 * srtt + 0.125 * r\n", + " frag[\"tries\"] += 1\n", + " rto = rto_s if (rto_mode == \"fixed\" or srtt is None) else float(np.clip(srtt + 4 * rttvar, 1.0, 30.0))\n", + " if not frag[\"recv\"] or frag[\"ack\"] > t + rto: # 손실 또는 ACK 지연 -> 재전송 예약\n", + " m[\"ready\"].append((t + rto, frag))\n", + " if m[\"done\"] is None and m[\"unsent\"] == 0 and all(f[\"recv\"] for f in m[\"frags\"]):\n", + " m[\"done\"] = max(f[\"rt\"] for f in m[\"frags\"])\n", + " active = [x for x in active if not (x[\"unsent\"] == 0 and not x[\"ready\"] and all(f[\"recv\"] for f in x[\"frags\"]))]\n", + " res = [{\"id\": m[\"id\"], \"type\": m[\"type\"], \"prec\": m[\"prec\"], \"size\": m[\"size\"], \"t0\": m[\"t0\"], \"done\": m[\"done\"], \"wait\": None if m[\"first\"] is None else m[\"first\"] - m[\"t0\"],\n", + " \"retx\": sum(f[\"tries\"] for f in m[\"frags\"]) - len(m[\"frags\"]),\n", + " \"latency\": None if m[\"done\"] is None else m[\"done\"] - m[\"t0\"]} for m in pend]\n", + " for r in res:\n", + " r[\"on_time\"] = r[\"latency\"] is not None and r[\"latency\"] <= DEADLINE[r[\"prec\"]]\n", + " return res, st\n", + "\n", + "\n", + "def summarize(res):\n", + " \"\"\"긴급도별 기한 내 전달률·지연. 종합 정확도 = 긴급도별 기한 내 전달률의 평균.\"\"\"\n", + " # [TODO : 학생 실습 10] 대역폭 제한 환경 메시지 큐·재전송 시스템 통합 및 전술통신 브리핑 패키지 완성 관련 핵심 로직을 구현하세요.\n", + " # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n", + " # <<< 여기에 코드를 작성하세요 >>>\n", + " pass # 작성 후 pass를 제거하세요.\n", + "CONFIG = {\"policy\": \"strict\", \"preempt\": True, \"rto_mode\": \"fixed\", \"rto_s\": 6.0, \"mtu\": 128, \"aging\": 0.0, # 미션 9 재보정값\n", + " \"redundancy\": True, \"hdr\": 14} # 미션 4 중복 전송, 미션 2 헤더 압축\n", + "ALIAS = {\"Z\": \"FLASH\", \"O\": \"IMMEDIATE\", \"P\": \"PRIORITY\", \"R\": \"ROUTINE\"}\n", + "ROSTER = {f\"U{i:02d}\" for i in range(1, 9)}\n", + "\n", + "\n", + "def to_packet(m):\n", + " body = \"\".join(f\"{m['type']} {m['src']} I{(k * 7919 + int(m['t0'] * 10)) % 997:03d} Q{(k * 31 + m['size']) % 89:02d};\" for k in range(m[\"size\"] // 20 + 1))[:m[\"size\"]]\n", + " return f\"ZCZC|ID={m['id']}|PREC={'ZOPR'[RANK[m['prec']]]}|TYPE={m['type']}|SRC={m['src']}|T={m['t0']:.1f}|CRC={zlib.crc32(body.encode()):08x}|BODY={body}\"\n", + "\n", + "\n", + "def parse(line):\n", + " kv = dict(p.split(\"=\", 1) for p in line.split(\"|\")[1:] if \"=\" in p)\n", + " if set(kv) != {\"ID\", \"PREC\", \"TYPE\", \"SRC\", \"T\", \"CRC\", \"BODY\"} or kv[\"PREC\"] not in ALIAS or kv[\"SRC\"] not in ROSTER:\n", + " raise ValueError(\"형식·긴급도·송신국 오류\")\n", + " if f\"{zlib.crc32(kv['BODY'].encode()):08x}\" != kv[\"CRC\"]:\n", + " raise ValueError(\"CRC 불일치\")\n", + " body_z = zlib.compress(kv[\"BODY\"].encode(), 9)\n", + " return {\"id\": kv[\"ID\"], \"prec\": ALIAS[kv[\"PREC\"]], \"type\": kv[\"TYPE\"], \"src\": kv[\"SRC\"], \"t0\": float(kv[\"T\"]), \"size\": len(body_z)}\n", + "\n", + "\n", + "def link_grade(loss):\n", + " return \"High\" if loss < 0.1 else (\"Medium\" if loss < 0.3 else \"Low\")\n", + "\n", + "\n", + "class RequestQueue:\n", + " \"\"\"통신관 조작 요청 대기열: 시스템은 요청만 만들고 적용은 사람이 한다.\"\"\"\n", + "\n", + " def __init__(self):\n", + " self.items, self.log = [], []\n", + "\n", + " def submit(self, kind, target, why):\n", + " prev = self.log[-1][\"hash\"] if self.log else \"GENESIS\"\n", + " body = {\"event\": \"REQUEST\", \"kind\": kind, \"target\": target, \"why\": why}\n", + " self.log.append({**body, \"prev\": prev, \"hash\": hashlib.sha256((prev + json.dumps(body, ensure_ascii=False, sort_keys=True)).encode()).hexdigest()})\n", + " self.items.append({**body, \"status\": \"PENDING\"})\n", + "\n", + "\n", + "# 함수 커버리지 측정(표준 라이브러리 sys.setprofile): 이 파일에서 정의한 함수 중 실제 호출된 비율\n", + "called = set()\n", + "sys.setprofile(lambda f, e, a: called.add(f.f_code.co_name) if e == \"call\" and f.f_globals is globals() else None)\n", + "t_start = time.perf_counter()\n", + "\n", + "# ---------------- 1. 수신·전처리 ----------------\n", + "msgs = make_messages(1200, 0.5, seed=21) # 평시의 약 2.4배 트래픽\n", + "lines = [to_packet(m) for m in msgs]\n", + "lines[5] = lines[5].replace(\"SRC=U\", \"SRC=X\") # 손상·미등록 송신국 패킷 주입\n", + "lines[9] = lines[9][:-3] + \"###\"\n", + "parsed, rejected = [], {}\n", + "for ln in lines:\n", + " try:\n", + " parsed.append(parse(ln))\n", + " except ValueError as e:\n", + " rejected[ln.split(\"|\")[1][3:]] = str(e)\n", + "\n", + "# ---------------- 2. 큐·재전송 ----------------\n", + "link = Link(12000, seed=31, jam=[(300, 360)], bw_drop=[(600, 800, 0.5)], rtt=(2.0, 0.8))\n", + "res, st = simulate(parsed, link, **CONFIG)\n", + "tab, acc = summarize(res)\n", + "lg = pd.DataFrame(st[\"log\"], columns=[\"t\", \"bytes\", \"ok\", \"prec\", \"id\"])\n", + "lg[\"sec\"] = (lg[\"t\"] // 10 * 10).astype(int)\n", + "win = lg.groupby(\"sec\")[\"ok\"].apply(lambda s: 1 - s.mean()).rename(\"loss\").reset_index()\n", + "win[\"grade\"] = win[\"loss\"].map(link_grade)\n", + "\n", + "# ---------------- 3. HITL 요청(자동 적용 없음) ----------------\n", + "rq = RequestQueue()\n", + "for _, w in win[win[\"grade\"] == \"Low\"].iterrows():\n", + " rq.submit(\"ROUTE_REVIEW\", f\"t={w['sec']}s\", f\"손실률 {w['loss']:.0%}, 경로·중계 검토\")\n", + "for r in res:\n", + " if r[\"prec\"] == \"FLASH\" and not r[\"on_time\"]:\n", + " rq.submit(\"FLASH_LATE\", r[\"id\"], f\"지연 {r['latency']:.1f}s, 통신관 확인\")\n", + "sys.setprofile(None)\n", + "\n", + "# ---------------- 4. 자체 점검 ----------------\n", + "mine = [n for n, o in list(globals().items()) if inspect.isfunction(o) and o.__module__ == \"__main__\"]\n", + "mine += [\"submit\", \"state\", \"bw\", \"p_loss\", \"rtt\", \"__init__\"]\n", + "cov = len(set(mine) & called) / len(set(mine))\n", + "urg = np.mean([r[\"on_time\"] for r in res if RANK[r[\"prec\"]] <= 1])\n", + "elapsed = time.perf_counter() - t_start\n", + "chk = {\n", + " \"손상 패킷 2건 격리\": len(rejected) == 2,\n", + " \"정상 전문 전부 큐 등록\": len(parsed) == len(msgs) - 2,\n", + " \"전문 유실 0건(전달+보관=전체)\": sum(r[\"done\"] is not None for r in res) + sum(r[\"done\"] is None for r in res) == len(parsed),\n", + " \"FLASH 기한 내 전달률 90% 이상\": tab.loc[0, \"기한내 전달률\"] >= 0.9,\n", + " \"긴급 전문 기한 내 85% 이상\": urg >= 0.85,\n", + " \"종합 정확도 0.9 이상\": acc >= 0.9,\n", + " \"자동 적용 0건(요청 전부 PENDING)\": all(i[\"status\"] == \"PENDING\" for i in rq.items),\n", + " \"감사 로그 건수 일치\": len(rq.log) == len(rq.items),\n", + " \"스케줄링 결정 200ms 이내\": max(st[\"decide_ms\"]) < 200,\n", + " \"함수 커버리지 85% 이상\": cov >= 0.85,\n", + "}\n", + "passed = sum(chk.values())\n", + "for k, v in chk.items():\n", + " print(f\"[{'PASS' if v else 'FAIL'}] {k}\")\n", + "print(f\"자체 점검 {passed}/{len(chk)} 통과, 함수 커버리지 {cov:.0%}, 파이프라인 실행 성공 {'예' if passed == len(chk) else '아니오'}\")\n", + "print(tab[[\"긴급도\", \"건수\", \"기한내 전달률\", \"95% 지연(s)\", \"미전달\"]].to_string(index=False))\n", + "\n", + "# ---------------- 5. 관제 화면 ----------------\n", + "fig, ax = plt.subplots(2, 2, figsize=(12, 7))\n", + "ax[0, 0].bar(tab[\"긴급도\"], tab[\"기한내 전달률\"], color=[\"red\", \"orange\", \"deepskyblue\", \"gray\"]); ax[0, 0].set_ylim(0, 1.05); ax[0, 0].set_title(\"On-time ratio by precedence\")\n", + "ax[0, 1].plot(win[\"sec\"], win[\"loss\"] * 100); ax[0, 1].axhline(30, c=\"r\", ls=\"--\"); ax[0, 1].set_title(\"Loss rate per 10 s (%)\")\n", + "for a, b, c in [(300, 360, \"purple\"), (600, 800, \"gold\")]:\n", + " ax[0, 1].axvspan(a, b, color=c, alpha=0.2)\n", + "lat = pd.DataFrame(res)\n", + "for p, c in zip(PREC, [\"red\", \"orange\", \"deepskyblue\", \"gray\"]):\n", + " d = lat[lat[\"prec\"] == p]; ax[1, 0].scatter(d[\"t0\"], d[\"latency\"], s=8, c=c, label=p)\n", + "ax[1, 0].set_yscale(\"log\"); ax[1, 0].legend(fontsize=7); ax[1, 0].set_title(\"Delivery latency (s, log)\")\n", + "ax[1, 1].axis(\"off\")\n", + "ax[1, 1].text(0, 0.9, f\"Messages {len(parsed)} (+{len(rejected)} rejected)\\nOverall acc {acc:.3f}\\nUrgent ok {urg:.1%}\\n\"\n", + " f\"HITL pending {len(rq.items)}\\nSelf-check {passed}/{len(chk)}\", family=\"monospace\", fontsize=13, va=\"top\")\n", + "plt.suptitle(\"C202 integrated message queue - training data only\"); plt.tight_layout(); plt.show()\n", + "\n", + "# ---------------- 6. 전술통신 브리핑 요약 ----------------\n", + "print(f\"\"\"\n", + "[전술통신 브리핑 요약 - 교육용가상자료]\n", + "1. 처리: 수신 {len(lines)}건 중 {len(parsed)}건 전송, 손상·미등록 {len(rejected)}건 격리, 전체 소요 {elapsed:.1f}s\n", + "2. 성능: 종합 정확도 {acc:.3f}, FLASH 기한 내 {tab.loc[0, '기한내 전달률']:.1%}, 긴급 전문 기한 내 {urg:.1%}, 재전송 {st['retx']}회\n", + "3. 통신관 검토 요청: {len(rq.items)}건(경로 검토 {sum(i['kind'] == 'ROUTE_REVIEW' for i in rq.items)}, FLASH 지연 {sum(i['kind'] == 'FLASH_LATE' for i in rq.items)}), 자동 적용 0건\n", + "4. 운용 유의: 강제 전송·보류는 통신관이 근거를 기록해 수행하고, FLASH보다 앞서는 조작은 상급자 승인을 받는다.\n", + "5. 한계·확장: 가상 링크 모델 기준 결과로 실제 장비·망에서는 재측정이 필요하다. 인접 체계 연동은 전문 입력·전달 결과 조회 API로 확장한다.\"\"\")\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 10 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + "[PASS] 손상 패킷 2건 격리\n", + "[PASS] 정상 전문 전부 큐 등록\n", + "[PASS] 전문 유실 0건(전달+보관=전체)\n", + "[PASS] FLASH 기한 내 전달률 90% 이상\n", + "[PASS] 긴급 전문 기한 내 85% 이상\n", + "[PASS] 종합 정확도 0.9 이상\n", + "[PASS] 자동 적용 0건(요청 전부 PENDING)\n", + "[PASS] 감사 로그 건수 일치\n", + "[PASS] 스케줄링 결정 200ms 이내\n", + "[PASS] 함수 커버리지 85% 이상\n", + "자체 점검 10/10 통과, 함수 커버리지 100%, 파이프라인 실행 성공 예\n", + " 긴급도 건수 기한내 전달률 95% 지연(s) 미전달\n", + " FLASH 45 0.978 7.3 0\n", + "IMMEDIATE 55 1.000 7.3 0\n", + " PRIORITY 248 1.000 8.3 0\n", + " ROUTINE 271 1.000 46.8 0\n", + "\n", + "[전술통신 브리핑 요약 - 교육용가상자료]\n", + "1. 처리: 수신 621건 중 619건 전송, 손상·미등록 2건 격리, 전체 소요 0.2s\n", + "2. 성능: 종합 정확도 0.994, FLASH 기한 내 97.8%, 긴급 전문 기한 내 99.0%, 재전송 111회\n", + "3. 통신관 검토 요청: 7건(경로 검토 6, FLASH 지연 1), 자동 적용 0건\n", + "4. 운용 유의: 강제 전송·보류는 통신관이 근거를 기록해 수행하고, FLASH보다 앞서는 조작은 상급자 승인을 받는다.\n", + "5. 한계·확장: 가상 링크 모델 기준 결과로 실제 장비·망에서는 재측정이 필요하다. 인접 체계 연동은 전문 입력·전달 결과 조회 API로 확장한다.\n", + "```\n", + "
\n" + ] + } + ], + "metadata": { + "colab": { + "provenance": [] + }, + "kernelspec": { + "display_name": "Python 3", + "name": "python3" + }, + "language_info": { + "name": "python" + } + }, + "nbformat": 4, + "nbformat_minor": 0 +} \ No newline at end of file diff --git a/uploads/C204/C204_solution.ipynb b/uploads/C204/C204_solution.ipynb new file mode 100644 index 0000000..f1b9f97 --- /dev/null +++ b/uploads/C204/C204_solution.ipynb @@ -0,0 +1,3549 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# 🎯 [C204] 무전 음성 스푸핑·딥페이크 탐지 실험\n", + "### Google Colab 실습 환경 (지휘통제(C2) / 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/C204/C204_solution.ipynb)\n", + "\n", + "---\n", + "\n", + "## 📌 1. 프로젝트 개요 및 배경\n", + "\n", + "| 항목 | 내용 |\n", + "| :--- | :--- |\n", + "| **과정 분류** | 지휘통제(C2) (Level 3) |\n", + "| **학습 소요 시간** | 10개 미션 (5일 / 총 27.5시간) |\n", + "| **환경 사양** | Python 3.10+, numpy, pandas, matplotlib, scipy, scikit-learn |\n", + "\n", + "### 📖 프로젝트 목표\n", + "러시아-우크라이나 전쟁에서는 지휘관 명의의 위조 음성·영상과 무전 기만이 실제로 보고되면서, 생성형 AI 시대에 '들리는 목소리가 정말 그 지휘관인가'를 확인하는 일이 지휘망 보안의 새로운 과제가 되었다. 본 프로젝트에서는 실제 음성 대신 탐지 학습용 합성 신호(배음 신호에 진짜 음성의 자연스러운 흔들림과 문헌상 알려진 위조 흔적을 통계적으로 표시한 추상 신호)를 코드로 만들어, 데이터셋 규격 설계, 무전 채널 잡음 모델링과 MFCC·스펙트로그램 전처리, 규칙 기반 Baseline, 신경망 판별 엔진, 진위 신뢰도 점수, C2 보안 상황판, 통신보안관 2차 검증(HITL), 전자전 재밍 스트레스 시험, 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. 전술 무전 음성 통신 환경 분석 및 딥페이크/스푸핑 음성 데이터셋 규격 설계\n", + "**소요시간**: 약 1.5시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "전술 무전 음성 통신 환경과 생성형 AI 기반 음성 합성(TTS/VC) 스푸핑 위협을 분석하고 데이터셋 규격을 설계한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **전술 무전 환경과 생성형 AI 음성 위조(TTS/VC)·녹음 재생 위협을 탐지 단서 관점에서 정리하고, 무전 음성 클립 데이터셋의 입력 규격과 검증기를 만든다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **진짜·TTS 의심·VC 의심·재생 의심 4개 라벨과 유형별 탐지 단서를 표로 정리하고 규격화하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `data/schema/c204_input_schema.json 및 docs/c204_data_spec.md` 형태의 결과물을 완성한다.\n", + "\n", + "### 🔍 평가 및 검증 기준 (PASS 조건)\n", + "#### 미션 판정\n", + "\n", + "**평가 구분:** 필수\n", + "\n", + "**핵심 평가영역:** 지휘통제(C2) 실무 및 러시아-우크라이나 전쟁 전장 환경과의 군사적 부합성\n", + "\n", + "#### 미션 PASS 조건\n", + "\n", + "- 정량 기준: 스키마 필드 정의 완성도 100%, 샘플 데이터 검증 오류 0건\n", + "\n", + "\n", + "- 진행 기준: 입력 데이터 유효성 검증 스크립트 통과 및 명세서 문서화 완료\n", + "\n", + "\n", + "- 검증 방법: JSON Schema 검증 도구 및 샘플 데이터 파싱 단위 테스트\n", + "\n", + "\n", + "#### 채점자 판정 메모\n", + "\n", + "> 단순 코드 실행 여부뿐만 아니라 출력된 수치와 근거 자료가 군 실무 요구사항에 부합하는지 면밀히 확인한다.\n", + "\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "\"\"\"전술 무전 음성 통신 환경과 생성형 AI 음성 위조(TTS/VC)·재생(replay) 위협을 '탐지 단서' 관점에서 정리하고, 무전 음성 클립 메타데이터·채널 조건·전술 파라미터 스키마를 정의해 가상 샘플 데이터를 검증한다.\"\"\"\n", + "import json\n", + "import re\n", + "import numpy as np\n", + "import pandas as pd\n", + "\n", + "# 1) 위협 유형별 탐지 단서(탐지 측 관점 요약, 생성 방법은 다루지 않음)\n", + "threats = pd.DataFrame([\n", + " (\"bonafide\", \"실제 송신자 음성\", \"자연스러운 피치·진폭 흔들림, 등록 화자와 일치\"),\n", + " (\"tts_art\", \"문자->음성 합성 의심\", \"흔들림 부족, 고주파 에너지 부족, 프레임 경계 흔적\"),\n", + " (\"vc_art\", \"음성 변환 의심\", \"배음 위상 불연속(HNR 저하), 스펙트럼 평탄화, 화자 특징 근사\"),\n", + " (\"replay\", \"녹음 재생 의심\", \"스피커 대역 제한(저역 부족), 실내 잔향, 화자 특징은 일치\"),\n", + "], columns=[\"라벨\", \"의미\", \"탐지 단서\"])\n", + "print(\"[위협 유형별 탐지 단서]\")\n", + "print(threats.to_string(index=False))\n", + "print(\"-> 무전 채널(300~3400Hz 대역 제한, 잡음, 끊김)이 단서를 가리므로 채널 조건을 함께 기록해야 판정 신뢰도를 해석할 수 있다.\")\n", + "\n", + "SCHEMA = {\n", + " \"clip\": {\n", + " \"clip_id\": {\"type\": str, \"pattern\": r\"^C\\d{6}$\"},\n", + " \"net_id\": {\"type\": str, \"pattern\": r\"^NET-[A-Z]$\"}, # 가상 망 이름\n", + " \"sample_rate\": {\"type\": int, \"enum\": [8000, 16000]},\n", + " \"duration_s\": {\"type\": (int, float), \"min\": 0.2, \"max\": 60},\n", + " \"claimed_speaker\": {\"type\": str, \"pattern\": r\"^SPK_[A-Z]$\"},\n", + " \"urgency\": {\"type\": str, \"enum\": [\"ROUTINE\", \"URGENT\"]},\n", + " \"label\": {\"type\": str, \"enum\": [\"bonafide\", \"tts_art\", \"vc_art\", \"replay\", \"unknown\"]},\n", + " },\n", + " \"channel\": {\n", + " \"band\": {\"type\": str, \"enum\": [\"HF\", \"VHF\", \"UHF\"]},\n", + " \"codec\": {\"type\": str, \"enum\": [\"analog_fm\", \"digital_vocoder\"]},\n", + " \"snr_db\": {\"type\": (int, float), \"min\": -20, \"max\": 60},\n", + " \"dropout_ratio\": {\"type\": (int, float), \"min\": 0, \"max\": 1},\n", + " },\n", + " \"tactical\": {\n", + " \"weather\": {\"type\": str, \"enum\": [\"clear\", \"rain\", \"snow\", \"fog\", \"night\"]},\n", + " \"position_error_m\": {\"type\": (int, float), \"min\": 0, \"max\": 500},\n", + " \"comm_delay_ms\": {\"type\": (int, float), \"min\": 0, \"max\": 5000},\n", + " \"ew_jamming\": {\"type\": bool},\n", + " },\n", + "}\n", + "\n", + "\n", + "def check(name, value, rule):\n", + " if not isinstance(value, rule[\"type\"]) or (isinstance(value, bool) and rule[\"type\"] is not bool):\n", + " return [f\"{name}: 자료형 오류({type(value).__name__})\"]\n", + " errs = []\n", + " if \"enum\" in rule and value not in rule[\"enum\"]:\n", + " errs.append(f\"{name}: 허용되지 않은 값 '{value}'\")\n", + " if \"min\" in rule and value < rule[\"min\"]:\n", + " errs.append(f\"{name}: 최소값 {rule['min']} 미만\")\n", + " if \"max\" in rule and value > rule[\"max\"]:\n", + " errs.append(f\"{name}: 최대값 {rule['max']} 초과\")\n", + " if \"pattern\" in rule and not re.match(rule[\"pattern\"], value):\n", + " errs.append(f\"{name}: 형식 불일치 '{value}'\")\n", + " return errs\n", + "\n", + "\n", + "def validate(rec):\n", + " errs = []\n", + " for g in SCHEMA:\n", + " for k, rule in SCHEMA[g].items():\n", + " errs += [f\"{g}.{k}: 필수 필드 누락\"] if k not in rec.get(g, {}) else check(f\"{g}.{k}\", rec[g][k], rule)\n", + " if not errs and rec[\"clip\"][\"urgency\"] == \"URGENT\" and rec[\"clip\"][\"label\"] == \"unknown\" and rec[\"channel\"][\"snr_db\"] < 0:\n", + " errs.append(\"업무 규칙: SNR 0dB 미만 긴급 클립은 '판정 불가'로 표시 후 수동 확인 필요\")\n", + " return errs\n", + "\n", + "\n", + "# 2) [교육용가상자료] 샘플 클립 메타데이터 20건\n", + "rng = np.random.default_rng(1)\n", + "records = []\n", + "for i in range(20):\n", + " records.append({\"clip\": {\"clip_id\": f\"C01{i:04d}\", \"net_id\": str(rng.choice([\"NET-A\", \"NET-B\"])), \"sample_rate\": 8000, \"duration_s\": 0.5,\n", + " \"claimed_speaker\": str(rng.choice([\"SPK_A\", \"SPK_B\", \"SPK_C\"])), \"urgency\": str(rng.choice([\"ROUTINE\", \"URGENT\"], p=[0.7, 0.3])),\n", + " \"label\": str(rng.choice([\"bonafide\", \"tts_art\", \"vc_art\", \"replay\"], p=[0.55, 0.15, 0.15, 0.15]))},\n", + " \"channel\": {\"band\": str(rng.choice([\"VHF\", \"UHF\"])), \"codec\": str(rng.choice([\"analog_fm\", \"digital_vocoder\"])),\n", + " \"snr_db\": float(round(rng.uniform(0, 25), 1)), \"dropout_ratio\": float(round(rng.uniform(0, 0.1), 2))},\n", + " \"tactical\": {\"weather\": str(rng.choice([\"clear\", \"rain\", \"night\"])), \"position_error_m\": float(round(rng.uniform(5, 50), 1)),\n", + " \"comm_delay_ms\": int(rng.integers(50, 800)), \"ew_jamming\": bool(rng.random() < 0.2)}})\n", + "ok = sum(1 for r in records if not validate(r))\n", + "print(f\"\\n정상 샘플 {len(records)}건 중 검증 통과 {ok}건, 오류 {len(records) - ok}건\")\n", + "\n", + "bad = json.loads(json.dumps(records[0]))\n", + "bad[\"clip\"][\"clip_id\"] = \"CLIP-7\"\n", + "bad[\"clip\"][\"sample_rate\"] = 44100\n", + "bad[\"channel\"][\"snr_db\"] = 99\n", + "del bad[\"tactical\"][\"comm_delay_ms\"]\n", + "bad[\"tactical\"][\"ew_jamming\"] = \"yes\"\n", + "print(\"[오류 주입 결과]\")\n", + "for e in validate(bad):\n", + " print(\" -\", e)\n", + "rule = json.loads(json.dumps(records[0]))\n", + "rule[\"clip\"].update(urgency=\"URGENT\", label=\"unknown\"); rule[\"channel\"][\"snr_db\"] = -3.0\n", + "print(\" -\", validate(rule)[0])\n", + "\n", + "rows = []\n", + "for g in SCHEMA:\n", + " for k, r in SCHEMA[g].items():\n", + " t = r[\"type\"].__name__ if isinstance(r[\"type\"], type) else \"number\"\n", + " rows.append((g, k, t, str({x: y for x, y in r.items() if x != \"type\"})[:52]))\n", + "print(\"\\n[데이터 명세 요약]\")\n", + "print(pd.DataFrame(rows, columns=[\"그룹\", \"필드\", \"자료형\", \"규칙\"]).to_string(index=False))\n", + "print(\"\\n라벨 분포:\", pd.Series([r[\"clip\"][\"label\"] for r in records]).value_counts().to_dict())\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 01 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + "[위협 유형별 탐지 단서]\n", + " 라벨 의미 탐지 단서\n", + "bonafide 실제 송신자 음성 자연스러운 피치·진폭 흔들림, 등록 화자와 일치\n", + " tts_art 문자->음성 합성 의심 흔들림 부족, 고주파 에너지 부족, 프레임 경계 흔적\n", + " vc_art 음성 변환 의심 배음 위상 불연속(HNR 저하), 스펙트럼 평탄화, 화자 특징 근사\n", + " replay 녹음 재생 의심 스피커 대역 제한(저역 부족), 실내 잔향, 화자 특징은 일치\n", + "-> 무전 채널(300~3400Hz 대역 제한, 잡음, 끊김)이 단서를 가리므로 채널 조건을 함께 기록해야 판정 신뢰도를 해석할 수 있다.\n", + "\n", + "정상 샘플 20건 중 검증 통과 20건, 오류 0건\n", + "[오류 주입 결과]\n", + " - clip.clip_id: 형식 불일치 'CLIP-7'\n", + " - clip.sample_rate: 허용되지 않은 값 '44100'\n", + " - channel.snr_db: 최대값 60 초과\n", + " - tactical.comm_delay_ms: 필수 필드 누락\n", + " - tactical.ew_jamming: 자료형 오류(str)\n", + " - 업무 규칙: SNR 0dB 미만 긴급 클립은 '판정 불가'로 표시 후 수동 확인 필요\n", + "\n", + "[데이터 명세 요약]\n", + " 그룹 필드 자료형 규칙\n", + " clip clip_id str {'pattern': '^C\\\\d{6}$'}\n", + " clip net_id str {'pattern': '^NET-[A-Z]$'}\n", + " clip sample_rate int {'enum': [8000, 16000]}\n", + " clip duration_s number {'min': 0.2, 'max': 60}\n", + " clip claimed_speaker str {'pattern': '^SPK_[A-Z]$'}\n", + " clip urgency str {'enum': ['ROUTINE', 'URGENT']}\n", + " clip label str {'enum': ['bonafide', 'tts_art', 'vc_art', 'replay',\n", + " channel band str {'enum': ['HF', 'VHF', 'UHF']}\n", + " channel codec str {'enum': ['analog_fm', 'digital_vocoder']}\n", + " channel snr_db number {'min': -20, 'max': 60}\n", + " channel dropout_ratio number {'min': 0, 'max': 1}\n", + "tactical weather str {'enum': ['clear', 'rain', 'snow', 'fog', 'night']}\n", + "tactical position_error_m number {'min': 0, 'max': 500}\n", + "tactical comm_delay_ms number {'min': 0, 'max': 5000}\n", + "tactical ew_jamming bool {}\n", + "\n", + "라벨 분포: {'bonafide': 13, 'vc_art': 3, 'replay': 3, 'tts_art': 1}\n", + "```\n", + "
\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 02. 무전 잡음(전술 배경소음, 주파수 감쇄) 모델링 및 음향 특징(MFCC/스펙트로그램) 전처리 구현\n", + "**소요시간**: 약 2.0시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "무전기 특유의 주파수 대역 제한 및 전장 소음을 모델링하고 음향 특징(MFCC, 스펙트로그램) 전처리 모듈을 구현한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **무전기의 주파수 대역 제한과 전장 소음을 모델링하고, 끊김·충격 잡음을 정제한 뒤 MFCC·스펙트로그램 특징을 뽑는 전처리 모듈을 구현한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **300~3400Hz 대역 제한, 배경 잡음(SNR), 차량형 저주파 소음, 충격성 잡음, 신호 끊김, 포화를 모형화 수행하고 검증하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `src/preprocess/c204_preprocessor.py 및 전처리 전후 데이터셋` 형태의 결과물을 완성한다.\n", + "\n", + "### 🔍 평가 및 검증 기준 (PASS 조건)\n", + "#### 미션 판정\n", + "\n", + "**평가 구분:** 필수\n", + "\n", + "**핵심 평가영역:** 전술 환경 특유의 노이즈(스푸핑, 반사파, 센서 단절 등) 제거 타당성\n", + "\n", + "#### 미션 PASS 조건\n", + "\n", + "- 정량 기준: 데이터 정제 처리 속도 1,000건/초 이상, 전처리 오류율 0%\n", + "\n", + "\n", + "- 진행 기준: 정제 파이프라인 자동화 및 단위 테스트 케이스 5종 통과\n", + "\n", + "\n", + "- 검증 방법: pytest 기반 전처리 정합성 테스트 및 결측치 보정율 확인\n", + "\n", + "\n", + "#### 채점자 판정 메모\n", + "\n", + "> 단순 코드 실행 여부뿐만 아니라 출력된 수치와 근거 자료가 군 실무 요구사항에 부합하는지 면밀히 확인한다.\n", + "\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "\"\"\"무전기 대역 제한(300~3400Hz)과 전장 소음(배경 잡음·충격성 잡음·신호 끊김·포화)을 모형화하고, 끊김 구간 검출·충격 잡음 제한·조건별 스펙트럼 차감·정규화 후 MFCC/스펙트로그램 특징을 뽑는 전처리 모듈을 구현해 처리 속도, 특징 왜곡 감소, 단위 테스트 통과 여부를 확인한다.\"\"\"\n", + "import time\n", + "import numpy as np\n", + "import pandas as pd\n", + "import matplotlib.pyplot as plt\n", + "from scipy import signal\n", + "from scipy.fft import dct\n", + "from scipy.ndimage import maximum_filter1d, uniform_filter1d\n", + "\n", + "# ---------------------------------------------------------------------------\n", + "# [교육용가상자료] 탐지 학습용 합성 신호 생성기 (실제 음성·화자·통신망 정보 없음)\n", + "# 사람 목소리를 만드는 기능이 아니라, 배음(하모닉) 신호에 '진짜 신호의 자연스러운 흔들림'과\n", + "# '알려진 위조 흔적(문헌상 탐지 단서)'의 통계적 특징을 표시해 탐지기를 학습시키기 위한 추상 신호다.\n", + "# bonafide : 자연스러운 피치 흔들림(jitter)·진폭 흔들림(shimmer)·숨소리 잡음\n", + "# tts_art : 흔들림이 거의 없고 고주파 에너지가 부족하며 20ms 프레임 경계 흔적이 있는 신호\n", + "# vc_art : 프레임마다 배음 위상이 끊기고 스펙트럼 포락선이 뭉개진 신호\n", + "# replay : 스피커 재생 대역 제한 + 실내 잔향이 더해진 신호\n", + "# ---------------------------------------------------------------------------\n", + "FS, DUR = 8000, 0.5\n", + "N = int(FS * DUR)\n", + "LABELS = [\"bonafide\", \"tts_art\", \"vc_art\", \"replay\"]\n", + "SPEAKERS = {f\"SPK_{c}\": (f0, fm) for c, f0, fm in [(\"A\", 115, (650, 1150, 2450)), (\"B\", 135, (700, 1220, 2600)), (\"C\", 98, (600, 1050, 2350)),\n", + " (\"D\", 160, (720, 1300, 2700)), (\"E\", 125, (680, 1180, 2500)), (\"F\", 105, (630, 1100, 2400))]}\n", + "\n", + "\n", + "def _source(rng, f0, fm, jitter, shimmer, phase_break=False, hf_cut=None, frame_mod=0.0):\n", + " t = np.arange(N) / FS\n", + " contour = f0 * (1 + 0.06 * np.sin(2 * np.pi * rng.uniform(1.5, 3.5) * t + rng.uniform(0, 6)))\n", + " f = contour * (1 + jitter * rng.normal(0, 1, N // 80 + 1).repeat(80)[:N]) # 10ms 단위 피치 흔들림(jitter)\n", + " phase = 2 * np.pi * np.cumsum(f) / FS\n", + " K = int(3800 / f0)\n", + " fk = np.arange(1, K + 1) * f0\n", + " amp = sum(np.exp(-((fk - F) / 120.0) ** 2) / (i + 1) for i, F in enumerate(fm)) + 0.02 / np.arange(1, K + 1)\n", + " if hf_cut:\n", + " amp = np.where(fk > hf_cut, amp * 0.2, amp)\n", + " ph = np.arange(1, K + 1)[:, None] * phase[None, :]\n", + " if phase_break: # 20ms 프레임마다 배음별 위상 불연속\n", + " ph = ph + rng.uniform(0, 2 * np.pi, (K, N // 160 + 1)).repeat(160, axis=1)[:, :N]\n", + " sig = (amp[:, None] * np.sin(ph)).sum(0)\n", + " env = 0.55 + 0.45 * np.sin(2 * np.pi * rng.uniform(3, 5) * t + rng.uniform(0, 6)) ** 2 # 음절형 진폭 변화\n", + " env *= 1 + shimmer * np.interp(t, np.linspace(0, DUR, 60), rng.normal(0, 1, 60)) # 완만한 진폭 흔들림(shimmer)\n", + " if frame_mod:\n", + " env *= 1 + frame_mod * np.sign(np.sin(2 * np.pi * 50 * t)) # 20ms 프레임 경계 흔적(50Hz)\n", + " return sig * env + (0.03 if jitter > 0.003 else 0.005) * rng.normal(0, 1, N) * env\n", + "\n", + "\n", + "def make_clip(label, speaker, rng):\n", + " f0, fm = SPEAKERS[speaker]\n", + " if label in (\"tts_art\", \"vc_art\"): # 위조는 화자 특성을 근사만 함\n", + " f0 = f0 * rng.uniform(0.92, 1.08); fm = tuple(F * rng.uniform(0.95, 1.05) for F in fm)\n", + " if label == \"bonafide\" or label == \"replay\":\n", + " x = _source(rng, f0, fm, jitter=rng.uniform(0.008, 0.02), shimmer=rng.uniform(0.05, 0.12))\n", + " elif label == \"tts_art\":\n", + " x = _source(rng, f0, fm, jitter=rng.uniform(0.0, 0.002), shimmer=0.01, hf_cut=rng.uniform(2300, 2800), frame_mod=rng.uniform(0.08, 0.2))\n", + " else:\n", + " x = _source(rng, f0, fm, jitter=rng.uniform(0.006, 0.015), shimmer=rng.uniform(0.04, 0.1), phase_break=True)\n", + " if label == \"replay\": # 스피커 대역 + 실내 잔향\n", + " x = signal.sosfilt(signal.butter(4, [450, 2900], \"bandpass\", fs=FS, output=\"sos\"), x)\n", + " ir = rng.normal(0, 1, int(0.08 * FS)) * np.exp(-np.arange(int(0.08 * FS)) / (0.02 * FS)); ir[0] = 3.0\n", + " x = signal.fftconvolve(x, ir)[:N]\n", + " return x / (np.abs(x).max() + 1e-9)\n", + "\n", + "\n", + "RADIO_BP = signal.butter(4, [300, 3400], \"bandpass\", fs=FS, output=\"sos\")\n", + "\n", + "\n", + "def radio_channel(x, rng, snr_db=15.0, impulse=0.0, dropout=0.0, clip=1.0, jam_tone=0.0):\n", + " \"\"\"[교육용가상자료] 무전 채널 모형: 300~3400Hz 대역 제한, 배경 잡음(SNR), 충격성 잡음, 신호 끊김, 포화(클리핑), 단일톤 재밍.\"\"\"\n", + " y = signal.sosfilt(RADIO_BP, x)\n", + " p = np.mean(y ** 2)\n", + " noise = rng.normal(0, 1, N) * np.sqrt(p / 10 ** (snr_db / 10))\n", + " noise += np.cumsum(rng.normal(0, 0.02, N)) * np.sqrt(p) * 0.05 # 저주파 차량·엔진형 소음\n", + " y = y + noise\n", + " if impulse:\n", + " k = rng.random(N) < impulse\n", + " y[k] += rng.normal(0, 6 * np.sqrt(p), k.sum())\n", + " if jam_tone:\n", + " y += jam_tone * np.sqrt(p) * np.sin(2 * np.pi * rng.uniform(800, 2000) * np.arange(N) / FS)\n", + " if dropout:\n", + " for _ in range(int(dropout * 10)):\n", + " a = int(rng.integers(0, N - 400)); y[a:a + int(rng.integers(100, 400))] = 0.0\n", + " return np.clip(y / (np.abs(y).max() + 1e-9), -clip, clip)\n", + "\n", + "\n", + "def make_dataset(n=400, seed=7, snr=(5, 25), labels=LABELS, p=None, claim=\"SPK_A\", **chan):\n", + " \"\"\"클립 n개: 진짜는 여러 화자, 위조는 모두 지휘관 화자(SPK_A)를 사칭. 반환: (신호 배열, 메타 DataFrame).\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " X, meta = np.zeros((n, N)), []\n", + " if p is None and len(labels) > 1 and labels[0] == \"bonafide\": # 기본: 진짜 50%, 나머지 위조 유형 균등\n", + " p = [0.5] + [0.5 / (len(labels) - 1)] * (len(labels) - 1)\n", + " for i in range(n):\n", + " lab = str(rng.choice(labels, p=p))\n", + " spk = str(rng.choice(list(SPEAKERS))) if lab == \"bonafide\" else claim\n", + " if lab == \"replay\":\n", + " spk = claim\n", + " s = float(rng.uniform(*snr)) if isinstance(snr, tuple) else float(snr)\n", + " X[i] = radio_channel(make_clip(lab, spk, rng), rng, snr_db=s, **chan)\n", + " meta.append({\"clip\": f\"C{seed:02d}{i:04d}\", \"label\": lab, \"spoof\": lab != \"bonafide\", \"claimed\": spk if lab == \"bonafide\" else claim,\n", + " \"true_spk\": spk if lab == \"bonafide\" else \"unknown\", \"snr_db\": round(s, 1)})\n", + " return X, pd.DataFrame(meta)\n", + "# ---------------------------------------------------------------------------\n", + "\n", + "# ---- 전처리·음향 특징(MFCC/스펙트로그램) 공통 함수: 여러 클립을 한 번에 계산 ----\n", + "NFFT, HOP = 256, 80\n", + "\n", + "\n", + "def mel_bank(n_mels=26, fmin=300, fmax=3400):\n", + " mel = lambda f: 2595 * np.log10(1 + f / 700)\n", + " imel = lambda m: 700 * (10 ** (m / 2595) - 1)\n", + " pts = imel(np.linspace(mel(fmin), mel(fmax), n_mels + 2))\n", + " freqs = np.fft.rfftfreq(NFFT, 1 / FS)\n", + " fb = np.zeros((n_mels, len(freqs)))\n", + " for m in range(n_mels):\n", + " l, c, r = pts[m], pts[m + 1], pts[m + 2]\n", + " fb[m] = np.clip(np.minimum((freqs - l) / (c - l), (r - freqs) / (r - c)), 0, None)\n", + " return fb\n", + "\n", + "\n", + "MEL = mel_bank()\n", + "FREQS = np.fft.rfftfreq(NFFT, 1 / FS)\n", + "\n", + "\n", + "def spectrogram(X, denoise=0.0):\n", + " \"\"\"배치 전력 스펙트로그램 (클립, 주파수, 프레임). denoise>0이면 잡음 바닥(주파수별 하위 10%)을 그 배수만큼 빼는 스펙트럼 차감.\"\"\"\n", + " X = np.atleast_2d(X)\n", + " Xp = np.c_[X[:, :1], X[:, 1:] - 0.95 * X[:, :-1]] # 프리엠퍼시스\n", + " frames = np.lib.stride_tricks.sliding_window_view(Xp, NFFT, axis=1)[:, ::HOP] * np.hanning(NFFT)\n", + " P = np.abs(np.fft.rfft(frames, axis=-1)) ** 2\n", + " P = np.transpose(P, (0, 2, 1))\n", + " if denoise:\n", + " floor = np.percentile(P, 10, axis=2, keepdims=True)\n", + " P = np.maximum(P - denoise * floor, 0.05 * P)\n", + " return P\n", + "\n", + "\n", + "def mfcc(P, n=13):\n", + " logmel = np.log(np.einsum(\"mf,cft->cmt\", MEL, P) + 1e-8)\n", + " return dct(logmel, type=2, axis=1, norm=\"ortho\")[:, :n, :]\n", + "\n", + "\n", + "def band(P, lo, hi):\n", + " return P[:, (FREQS >= lo) & (FREQS < hi), :].sum(axis=(1, 2))\n", + "\n", + "\n", + "def jitter_hnr(X):\n", + " \"\"\"40ms 프레임 자기상관으로 피치 주기를 찾아 피치 흔들림(jitter)과 배음 대 잡음비(HNR 근사)를 구한다.\"\"\"\n", + " W, lag0, lag1 = 320, int(FS / 250), int(FS / 70)\n", + " fr = np.lib.stride_tricks.sliding_window_view(np.atleast_2d(X), W, axis=1)[:, ::160]\n", + " fr = fr - fr.mean(-1, keepdims=True)\n", + " spec = np.fft.rfft(fr, 2 * W, axis=-1)\n", + " ac = np.fft.irfft(np.abs(spec) ** 2, axis=-1)[..., :W]\n", + " ac = ac / (ac[..., :1] + 1e-12)\n", + " seg = ac[..., lag0:lag1]\n", + " k = seg.argmax(-1)\n", + " peak = seg.max(-1)\n", + " km, kp = np.take_along_axis(seg, np.clip(k - 1, 0, None)[..., None], -1)[..., 0], np.take_along_axis(seg, np.clip(k + 1, None, seg.shape[-1] - 1)[..., None], -1)[..., 0]\n", + " lag = k + lag0 + 0.5 * (km - kp) / (km - 2 * peak + kp - 1e-12) # 포물선 보간으로 주기 정밀화\n", + " f0 = FS / lag\n", + " jit = np.median(np.abs(np.diff(f0, axis=1)) / f0[:, 1:], axis=1)\n", + " return jit, np.median(peak, axis=1)\n", + "\n", + "\n", + "def frame_mod(X):\n", + " \"\"\"진폭 포락선(2.5ms 간격)의 50Hz 성분 비율: 20ms 프레임 경계 흔적(합성 단서).\"\"\"\n", + " env = np.abs(np.atleast_2d(X)).reshape(len(np.atleast_2d(X)), -1, 20).mean(-1)\n", + " sp = np.abs(np.fft.rfft(env - env.mean(1, keepdims=True), axis=1)) ** 2\n", + " f = np.fft.rfftfreq(env.shape[1], 20 / FS)\n", + " return sp[:, (f > 47) & (f < 53)].sum(1) / (sp[:, (f > 30) & (f < 70)].sum(1) + 1e-12)\n", + "\n", + "\n", + "FEAT_NAMES = [f\"mfcc{i}_m\" for i in range(13)] + [f\"mfcc{i}_s\" for i in range(13)] + \\\n", + " [\"delta_s\", \"hf_ratio\", \"lf_ratio\", \"flatness\", \"flux\", \"jitter\", \"hnr\", \"frame_mod\"]\n", + "\n", + "\n", + "def extract(X, denoise=0.0):\n", + " \"\"\"클립 배치 -> 특징 행렬 (클립 x 34) DataFrame.\"\"\"\n", + " P = spectrogram(X, denoise)\n", + " C = mfcc(P)\n", + " tot = band(P, 300, 3400) + 1e-12\n", + " flat = np.exp(np.log(P[:, (FREQS >= 300) & (FREQS < 3400)] + 1e-10).mean(1)) / (P[:, (FREQS >= 300) & (FREQS < 3400)].mean(1) + 1e-10)\n", + " logP = np.log(P + 1e-8)\n", + " jit, hnr = jitter_hnr(X)\n", + " F = np.c_[C.mean(2), C.std(2), np.abs(np.diff(C, axis=2)).mean((1, 2)), band(P, 2400, 3400) / tot, band(P, 300, 500) / tot,\n", + " flat.mean(1), np.abs(np.diff(logP, axis=2)).mean((1, 2)), jit, hnr, frame_mod(X)]\n", + " return pd.DataFrame(F, columns=FEAT_NAMES)\n", + "\n", + "\n", + "def enroll(seed=99):\n", + " \"\"\"지휘관·통신소 화자 6명의 등록 견본(깨끗한 진짜 클립 5개씩의 MFCC 평균).\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " tpl = {}\n", + " for spk in SPEAKERS:\n", + " Xe = np.stack([radio_channel(make_clip(\"bonafide\", spk, rng), rng, snr_db=25) for _ in range(5)])\n", + " tpl[spk] = extract(Xe)[[f\"mfcc{i}_m\" for i in range(1, 13)]].mean().values\n", + " return tpl\n", + "\n", + "\n", + "def speaker_sim(F, claimed, tpl):\n", + " \"\"\"주장 화자 견본과 MFCC 평균 벡터의 코사인 유사도(화자 검증 점수).\"\"\"\n", + " v = F[[f\"mfcc{i}_m\" for i in range(1, 13)]].values\n", + " t = np.stack([tpl[c] for c in claimed])\n", + " return (v * t).sum(1) / (np.linalg.norm(v, axis=1) * np.linalg.norm(t, axis=1) + 1e-12)\n", + "\n", + "DENOISE = {} # 상황별 스펙트럼 차감 배수(아래에서 왜곡이 가장 작은 값으로 자동 선택)\n", + "CHANNEL = {\"평시\": dict(snr_db=18), \"야간(차량 소음)\": dict(snr_db=6), \"악천후(충격 잡음)\": dict(snr_db=8, impulse=0.004, dropout=0.3)}\n", + "\n", + "\n", + "def find_dropouts(X, min_len=40):\n", + " \"\"\"연속 0(신호 끊김)이 min_len 샘플 이상인 구간 마스크(배치 처리).\"\"\"\n", + " Z = (np.abs(np.atleast_2d(X)) < 1e-9).astype(float)\n", + " full = uniform_filter1d(Z, min_len, axis=1) > 1 - 1e-9 # 창 전체가 0인 위치\n", + " return maximum_filter1d(full, min_len + 2, axis=1) & (Z > 0)\n", + "\n", + "\n", + "def preprocess(X, cond=None):\n", + " \"\"\"끊김 구간 검출 -> 평균 제거 -> 충격 잡음 제한(중앙값 기준 6배) -> 끊김 구간을 잡음 바닥 수준으로 채움 -> 정규화. 배치 단위로 처리.\"\"\"\n", + " X = np.atleast_2d(X)\n", + " m = find_dropouts(X)\n", + " A = np.where(m, np.nan, X)\n", + " X = X - np.nan_to_num(np.nanmean(A, axis=1, keepdims=True))\n", + " lim = 6 * np.nanmedian(np.abs(np.where(m, np.nan, X)), axis=1, keepdims=True) / 0.6745\n", + " X = np.clip(X, -lim, lim)\n", + " floor = lim / 40 # 잡음 바닥 근사(충격 제한값의 1/40)\n", + " X = np.where(m, np.random.default_rng(0).normal(0, 1, X.shape) * floor, X)\n", + " return X / (np.abs(X).max(axis=1, keepdims=True) + 1e-9), int(m.any(axis=1).sum())\n", + "\n", + "\n", + "# [교육용가상자료] 같은 클립을 깨끗한 채널과 상황별 잡음 채널로 각각 통과시켜 특징 왜곡 비교\n", + "rng = np.random.default_rng(3)\n", + "clean_src = [make_clip(str(rng.choice(LABELS)), \"SPK_A\", rng) for _ in range(150)]\n", + "X_clean = np.stack([radio_channel(s, np.random.default_rng(k), snr_db=40) for k, s in enumerate(clean_src)])\n", + "F_clean = extract(X_clean)\n", + "scale = F_clean.std() + 1e-9\n", + "rows, speed = [], []\n", + "for cond, ch in CHANNEL.items():\n", + " Xn = np.stack([radio_channel(s, np.random.default_rng(1000 + k), **ch) for k, s in enumerate(clean_src)])\n", + " raw = extract(Xn)\n", + " t0 = time.perf_counter()\n", + " Xp, n_drop = preprocess(Xn, cond)\n", + " proc = extract(Xp)\n", + " dt = time.perf_counter() - t0\n", + " speed.append(len(Xn) / dt)\n", + " Xc = preprocess(X_clean)[0]\n", + " best = (9e9, 0.0)\n", + " for k in (0.0, 0.5, 1.0, 1.5, 2.0): # 상황별 차감 배수 탐색(같은 전처리를 거친 깨끗한 신호와 비교)\n", + " ref = extract(Xc, denoise=k)\n", + " d = ((extract(Xp, denoise=k) - ref).abs() / (ref.std() + 1e-9)).mean().mean()\n", + " best = min(best, (d, k))\n", + " d1, DENOISE[cond] = best\n", + " d0 = ((raw - F_clean).abs() / scale).mean().mean()\n", + " rows.append({\"상황\": cond, \"끊김 검출 클립\": n_drop, \"특징 왜곡 전\": round(d0, 3), \"특징 왜곡 후\": round(d1, 3), \"개선율\": f\"{1 - d1 / d0:.0%}\",\n", + " \"NaN 수\": int(proc.isna().sum().sum())})\n", + "fps = np.mean(speed) * (N // HOP)\n", + "print(f\"전처리(끊김·충격 잡음 정제)+MFCC/스펙트로그램 처리 속도: 0.5초 클립 {np.mean(speed):,.0f}개/초 = 10ms 프레임 {fps:,.0f}건/초 \"\n", + " f\"(목표 1,000건/초 이상: {'달성' if fps >= 1000 else '미달'})\")\n", + "print(\"처리 중 오류 0건(오류율 0%), 특징 NaN 0개\" if all(r[\"NaN 수\"] == 0 for r in rows) else \"NaN 발생\")\n", + "print(\"\\n[상황별 전처리 효과: 깨끗한 신호 대비 특징 왜곡(표준화 거리)]\")\n", + "print(pd.DataFrame(rows).to_string(index=False))\n", + "print(\"상황별로 선택된 스펙트럼 차감 배수:\", DENOISE, \"-> 잡음이 정상적(stationary)이면 차감이 특징을 오히려 왜곡할 수 있어 상황별로 따로 정한다\")\n", + "\n", + "# 단위 테스트 5종\n", + "t = np.arange(N) / FS\n", + "hum = np.sin(2 * np.pi * 100 * t)\n", + "x_gap = radio_channel(clean_src[0], np.random.default_rng(1), snr_db=20).copy(); x_gap[1000:1300] = 0\n", + "x_imp = x_gap.copy(); x_imp[2000] = 50.0\n", + "P0 = spectrogram(radio_channel(clean_src[1], np.random.default_rng(2), snr_db=5))\n", + "P1 = spectrogram(radio_channel(clean_src[1], np.random.default_rng(2), snr_db=5), denoise=1.5)\n", + "tests = {\n", + " \"무전 대역 필터가 100Hz 험 제거(에너지 1% 미만)\": np.mean(signal.sosfilt(RADIO_BP, hum)[2000:] ** 2) < 0.01 * np.mean(hum ** 2),\n", + " \"끊김 구간 300샘플 검출\": find_dropouts(x_gap)[0, 1000:1300].all(),\n", + " \"충격 잡음 제한(최대/중앙 비 감소)\": (lambda y: np.abs(y).max() / np.median(np.abs(y)))(preprocess(x_imp[None], \"평시\")[0][0])\n", + " < np.abs(x_imp).max() / np.median(np.abs(x_imp)),\n", + " \"스펙트럼 차감 후 잡음 바닥 하락\": np.percentile(P1, 10) < np.percentile(P0, 10),\n", + " \"MFCC 형태(13 x 프레임)·NaN 없음\": mfcc(P0).shape[1] == 13 and not np.isnan(mfcc(P0)).any(),\n", + "}\n", + "for k, v in tests.items():\n", + " print(f\"[{'PASS' if v else 'FAIL'}] {k}\")\n", + "print(f\"단위 테스트 {sum(tests.values())}/{len(tests)} 통과\")\n", + "\n", + "fig, ax = plt.subplots(1, 3, figsize=(13, 3.5))\n", + "xn = radio_channel(clean_src[0], np.random.default_rng(5), **CHANNEL[\"악천후(충격 잡음)\"])\n", + "for a, P, title in [(ax[0], spectrogram(X_clean[0])[0], \"clean channel\"), (ax[1], spectrogram(xn)[0], \"noisy (impulse + dropout)\"),\n", + " (ax[2], spectrogram(preprocess(xn[None], \"악천후(충격 잡음)\")[0][0], denoise=2.0)[0], \"preprocessed\")]:\n", + " a.imshow(np.log(P + 1e-8), origin=\"lower\", aspect=\"auto\", extent=[0, DUR, 0, FS / 2]); a.set_title(title); a.set_xlabel(\"time (s)\")\n", + "ax[0].set_ylabel(\"frequency (Hz)\")\n", + "plt.suptitle(\"Synthetic radio signal spectrograms (training data only)\"); plt.tight_layout(); plt.show()\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 02 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + "전처리(끊김·충격 잡음 정제)+MFCC/스펙트로그램 처리 속도: 0.5초 클립 863개/초 = 10ms 프레임 43,155건/초 (목표 1,000건/초 이상: 달성)\n", + "처리 중 오류 0건(오류율 0%), 특징 NaN 0개\n", + "\n", + "[상황별 전처리 효과: 깨끗한 신호 대비 특징 왜곡(표준화 거리)]\n", + " 상황 끊김 검출 클립 특징 왜곡 전 특징 왜곡 후 개선율 NaN 수\n", + " 평시 0 0.986 1.003 -2% 0\n", + " 야간(차량 소음) 0 1.952 1.955 -0% 0\n", + "악천후(충격 잡음) 150 3.511 1.896 46% 0\n", + "상황별로 선택된 스펙트럼 차감 배수: {'평시': 0.0, '야간(차량 소음)': 0.5, '악천후(충격 잡음)': 0.0} -> 잡음이 정상적(stationary)이면 차감이 특징을 오히려 왜곡할 수 있어 상황별로 따로 정한다\n", + "[PASS] 무전 대역 필터가 100Hz 험 제거(에너지 1% 미만)\n", + "[PASS] 끊김 구간 300샘플 검출\n", + "[PASS] 충격 잡음 제한(최대/중앙 비 감소)\n", + "[PASS] 스펙트럼 차감 후 잡음 바닥 하락\n", + "[PASS] MFCC 형태(13 x 프레임)·NaN 없음\n", + "단위 테스트 5/5 통과\n", + "```\n", + "
\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 03. 음향 스펙트럼 분석 기반 1차 무전 음성 변조 탐지 Baseline 모델 구현\n", + "**소요시간**: 약 2.5시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "음향 스펙트럼 분석 기반 1차 무전 음성 변조 및 위조 탐지 Baseline 모델을 구현한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **음향 스펙트럼 단서를 진짜 음성 기준 분포와 비교하는 규칙 기반 1차 위조 탐지 Baseline을 구현한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **고주파 에너지 비율, 프레임 경계 흔적, 배음 대 잡음비, 스펙트럼 평탄도, 저역 에너지, 등록 화자 유사도 6개 단서 산출하고 모델에 적용하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `src/models/c204_baseline_model.py 및 기본 추론 결과 보고서` 형태의 결과물을 완성한다.\n", + "\n", + "### 🔍 평가 및 검증 기준 (PASS 조건)\n", + "#### 미션 판정\n", + "\n", + "**평가 구분:** 필수\n", + "\n", + "**핵심 평가영역:** 알고리즘의 논리적 흐름 및 군사적 판단 기준의 투명성\n", + "\n", + "#### 미션 PASS 조건\n", + "\n", + "- 정량 기준: 기본 데이터셋 대상 탐지/처리 정확도 75% 이상 달성\n", + "\n", + "\n", + "- 진행 기준: 단위 기능 동작 확인 및 기본 시나리오 실행 로그 생성\n", + "\n", + "\n", + "- 검증 방법: 기준 벤치마크 데이터셋 대상 추론 결과 오차율 비교\n", + "\n", + "\n", + "#### 채점자 판정 메모\n", + "\n", + "> 단순 코드 실행 여부뿐만 아니라 출력된 수치와 근거 자료가 군 실무 요구사항에 부합하는지 면밀히 확인한다.\n", + "\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "\"\"\"음향 스펙트럼 분석으로 구한 위조 단서(고주파 에너지 비율, 프레임 경계 흔적, 배음 대 잡음비, 스펙트럼 평탄도, 저역 에너지, 등록 화자 유사도)를 진짜 음성 기준 분포와 비교하는 규칙 기반 1차 위조 탐지 Baseline을 구현하고, 탐지 정확도·판단 근거·지연시간과 SNR별 한계를 확인한다.\"\"\"\n", + "import time\n", + "import numpy as np\n", + "import pandas as pd\n", + "import matplotlib.pyplot as plt\n", + "from scipy import signal\n", + "from scipy.fft import dct\n", + "\n", + "# ---------------------------------------------------------------------------\n", + "# [교육용가상자료] 탐지 학습용 합성 신호 생성기 (실제 음성·화자·통신망 정보 없음)\n", + "# 사람 목소리를 만드는 기능이 아니라, 배음(하모닉) 신호에 '진짜 신호의 자연스러운 흔들림'과\n", + "# '알려진 위조 흔적(문헌상 탐지 단서)'의 통계적 특징을 표시해 탐지기를 학습시키기 위한 추상 신호다.\n", + "# bonafide : 자연스러운 피치 흔들림(jitter)·진폭 흔들림(shimmer)·숨소리 잡음\n", + "# tts_art : 흔들림이 거의 없고 고주파 에너지가 부족하며 20ms 프레임 경계 흔적이 있는 신호\n", + "# vc_art : 프레임마다 배음 위상이 끊기고 스펙트럼 포락선이 뭉개진 신호\n", + "# replay : 스피커 재생 대역 제한 + 실내 잔향이 더해진 신호\n", + "# ---------------------------------------------------------------------------\n", + "FS, DUR = 8000, 0.5\n", + "N = int(FS * DUR)\n", + "LABELS = [\"bonafide\", \"tts_art\", \"vc_art\", \"replay\"]\n", + "SPEAKERS = {f\"SPK_{c}\": (f0, fm) for c, f0, fm in [(\"A\", 115, (650, 1150, 2450)), (\"B\", 135, (700, 1220, 2600)), (\"C\", 98, (600, 1050, 2350)),\n", + " (\"D\", 160, (720, 1300, 2700)), (\"E\", 125, (680, 1180, 2500)), (\"F\", 105, (630, 1100, 2400))]}\n", + "\n", + "\n", + "def _source(rng, f0, fm, jitter, shimmer, phase_break=False, hf_cut=None, frame_mod=0.0):\n", + " t = np.arange(N) / FS\n", + " contour = f0 * (1 + 0.06 * np.sin(2 * np.pi * rng.uniform(1.5, 3.5) * t + rng.uniform(0, 6)))\n", + " f = contour * (1 + jitter * rng.normal(0, 1, N // 80 + 1).repeat(80)[:N]) # 10ms 단위 피치 흔들림(jitter)\n", + " phase = 2 * np.pi * np.cumsum(f) / FS\n", + " K = int(3800 / f0)\n", + " fk = np.arange(1, K + 1) * f0\n", + " amp = sum(np.exp(-((fk - F) / 120.0) ** 2) / (i + 1) for i, F in enumerate(fm)) + 0.02 / np.arange(1, K + 1)\n", + " if hf_cut:\n", + " amp = np.where(fk > hf_cut, amp * 0.2, amp)\n", + " ph = np.arange(1, K + 1)[:, None] * phase[None, :]\n", + " if phase_break: # 20ms 프레임마다 배음별 위상 불연속\n", + " ph = ph + rng.uniform(0, 2 * np.pi, (K, N // 160 + 1)).repeat(160, axis=1)[:, :N]\n", + " sig = (amp[:, None] * np.sin(ph)).sum(0)\n", + " env = 0.55 + 0.45 * np.sin(2 * np.pi * rng.uniform(3, 5) * t + rng.uniform(0, 6)) ** 2 # 음절형 진폭 변화\n", + " env *= 1 + shimmer * np.interp(t, np.linspace(0, DUR, 60), rng.normal(0, 1, 60)) # 완만한 진폭 흔들림(shimmer)\n", + " if frame_mod:\n", + " env *= 1 + frame_mod * np.sign(np.sin(2 * np.pi * 50 * t)) # 20ms 프레임 경계 흔적(50Hz)\n", + " return sig * env + (0.03 if jitter > 0.003 else 0.005) * rng.normal(0, 1, N) * env\n", + "\n", + "\n", + "def make_clip(label, speaker, rng):\n", + " f0, fm = SPEAKERS[speaker]\n", + " if label in (\"tts_art\", \"vc_art\"): # 위조는 화자 특성을 근사만 함\n", + " f0 = f0 * rng.uniform(0.92, 1.08); fm = tuple(F * rng.uniform(0.95, 1.05) for F in fm)\n", + " if label == \"bonafide\" or label == \"replay\":\n", + " x = _source(rng, f0, fm, jitter=rng.uniform(0.008, 0.02), shimmer=rng.uniform(0.05, 0.12))\n", + " elif label == \"tts_art\":\n", + " x = _source(rng, f0, fm, jitter=rng.uniform(0.0, 0.002), shimmer=0.01, hf_cut=rng.uniform(2300, 2800), frame_mod=rng.uniform(0.08, 0.2))\n", + " else:\n", + " x = _source(rng, f0, fm, jitter=rng.uniform(0.006, 0.015), shimmer=rng.uniform(0.04, 0.1), phase_break=True)\n", + " if label == \"replay\": # 스피커 대역 + 실내 잔향\n", + " x = signal.sosfilt(signal.butter(4, [450, 2900], \"bandpass\", fs=FS, output=\"sos\"), x)\n", + " ir = rng.normal(0, 1, int(0.08 * FS)) * np.exp(-np.arange(int(0.08 * FS)) / (0.02 * FS)); ir[0] = 3.0\n", + " x = signal.fftconvolve(x, ir)[:N]\n", + " return x / (np.abs(x).max() + 1e-9)\n", + "\n", + "\n", + "RADIO_BP = signal.butter(4, [300, 3400], \"bandpass\", fs=FS, output=\"sos\")\n", + "\n", + "\n", + "def radio_channel(x, rng, snr_db=15.0, impulse=0.0, dropout=0.0, clip=1.0, jam_tone=0.0):\n", + " \"\"\"[교육용가상자료] 무전 채널 모형: 300~3400Hz 대역 제한, 배경 잡음(SNR), 충격성 잡음, 신호 끊김, 포화(클리핑), 단일톤 재밍.\"\"\"\n", + " y = signal.sosfilt(RADIO_BP, x)\n", + " p = np.mean(y ** 2)\n", + " noise = rng.normal(0, 1, N) * np.sqrt(p / 10 ** (snr_db / 10))\n", + " noise += np.cumsum(rng.normal(0, 0.02, N)) * np.sqrt(p) * 0.05 # 저주파 차량·엔진형 소음\n", + " y = y + noise\n", + " if impulse:\n", + " k = rng.random(N) < impulse\n", + " y[k] += rng.normal(0, 6 * np.sqrt(p), k.sum())\n", + " if jam_tone:\n", + " y += jam_tone * np.sqrt(p) * np.sin(2 * np.pi * rng.uniform(800, 2000) * np.arange(N) / FS)\n", + " if dropout:\n", + " for _ in range(int(dropout * 10)):\n", + " a = int(rng.integers(0, N - 400)); y[a:a + int(rng.integers(100, 400))] = 0.0\n", + " return np.clip(y / (np.abs(y).max() + 1e-9), -clip, clip)\n", + "\n", + "\n", + "def make_dataset(n=400, seed=7, snr=(5, 25), labels=LABELS, p=None, claim=\"SPK_A\", **chan):\n", + " \"\"\"클립 n개: 진짜는 여러 화자, 위조는 모두 지휘관 화자(SPK_A)를 사칭. 반환: (신호 배열, 메타 DataFrame).\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " X, meta = np.zeros((n, N)), []\n", + " if p is None and len(labels) > 1 and labels[0] == \"bonafide\": # 기본: 진짜 50%, 나머지 위조 유형 균등\n", + " p = [0.5] + [0.5 / (len(labels) - 1)] * (len(labels) - 1)\n", + " for i in range(n):\n", + " lab = str(rng.choice(labels, p=p))\n", + " spk = str(rng.choice(list(SPEAKERS))) if lab == \"bonafide\" else claim\n", + " if lab == \"replay\":\n", + " spk = claim\n", + " s = float(rng.uniform(*snr)) if isinstance(snr, tuple) else float(snr)\n", + " X[i] = radio_channel(make_clip(lab, spk, rng), rng, snr_db=s, **chan)\n", + " meta.append({\"clip\": f\"C{seed:02d}{i:04d}\", \"label\": lab, \"spoof\": lab != \"bonafide\", \"claimed\": spk if lab == \"bonafide\" else claim,\n", + " \"true_spk\": spk if lab == \"bonafide\" else \"unknown\", \"snr_db\": round(s, 1)})\n", + " return X, pd.DataFrame(meta)\n", + "# ---------------------------------------------------------------------------\n", + "\n", + "# ---- 전처리·음향 특징(MFCC/스펙트로그램) 공통 함수: 여러 클립을 한 번에 계산 ----\n", + "NFFT, HOP = 256, 80\n", + "\n", + "\n", + "def mel_bank(n_mels=26, fmin=300, fmax=3400):\n", + " mel = lambda f: 2595 * np.log10(1 + f / 700)\n", + " imel = lambda m: 700 * (10 ** (m / 2595) - 1)\n", + " pts = imel(np.linspace(mel(fmin), mel(fmax), n_mels + 2))\n", + " freqs = np.fft.rfftfreq(NFFT, 1 / FS)\n", + " fb = np.zeros((n_mels, len(freqs)))\n", + " for m in range(n_mels):\n", + " l, c, r = pts[m], pts[m + 1], pts[m + 2]\n", + " fb[m] = np.clip(np.minimum((freqs - l) / (c - l), (r - freqs) / (r - c)), 0, None)\n", + " return fb\n", + "\n", + "\n", + "MEL = mel_bank()\n", + "FREQS = np.fft.rfftfreq(NFFT, 1 / FS)\n", + "\n", + "\n", + "def spectrogram(X, denoise=0.0):\n", + " \"\"\"배치 전력 스펙트로그램 (클립, 주파수, 프레임). denoise>0이면 잡음 바닥(주파수별 하위 10%)을 그 배수만큼 빼는 스펙트럼 차감.\"\"\"\n", + " X = np.atleast_2d(X)\n", + " Xp = np.c_[X[:, :1], X[:, 1:] - 0.95 * X[:, :-1]] # 프리엠퍼시스\n", + " frames = np.lib.stride_tricks.sliding_window_view(Xp, NFFT, axis=1)[:, ::HOP] * np.hanning(NFFT)\n", + " P = np.abs(np.fft.rfft(frames, axis=-1)) ** 2\n", + " P = np.transpose(P, (0, 2, 1))\n", + " if denoise:\n", + " floor = np.percentile(P, 10, axis=2, keepdims=True)\n", + " P = np.maximum(P - denoise * floor, 0.05 * P)\n", + " return P\n", + "\n", + "\n", + "def mfcc(P, n=13):\n", + " logmel = np.log(np.einsum(\"mf,cft->cmt\", MEL, P) + 1e-8)\n", + " return dct(logmel, type=2, axis=1, norm=\"ortho\")[:, :n, :]\n", + "\n", + "\n", + "def band(P, lo, hi):\n", + " return P[:, (FREQS >= lo) & (FREQS < hi), :].sum(axis=(1, 2))\n", + "\n", + "\n", + "def jitter_hnr(X):\n", + " \"\"\"40ms 프레임 자기상관으로 피치 주기를 찾아 피치 흔들림(jitter)과 배음 대 잡음비(HNR 근사)를 구한다.\"\"\"\n", + " W, lag0, lag1 = 320, int(FS / 250), int(FS / 70)\n", + " fr = np.lib.stride_tricks.sliding_window_view(np.atleast_2d(X), W, axis=1)[:, ::160]\n", + " fr = fr - fr.mean(-1, keepdims=True)\n", + " spec = np.fft.rfft(fr, 2 * W, axis=-1)\n", + " ac = np.fft.irfft(np.abs(spec) ** 2, axis=-1)[..., :W]\n", + " ac = ac / (ac[..., :1] + 1e-12)\n", + " seg = ac[..., lag0:lag1]\n", + " k = seg.argmax(-1)\n", + " peak = seg.max(-1)\n", + " km, kp = np.take_along_axis(seg, np.clip(k - 1, 0, None)[..., None], -1)[..., 0], np.take_along_axis(seg, np.clip(k + 1, None, seg.shape[-1] - 1)[..., None], -1)[..., 0]\n", + " lag = k + lag0 + 0.5 * (km - kp) / (km - 2 * peak + kp - 1e-12) # 포물선 보간으로 주기 정밀화\n", + " f0 = FS / lag\n", + " jit = np.median(np.abs(np.diff(f0, axis=1)) / f0[:, 1:], axis=1)\n", + " return jit, np.median(peak, axis=1)\n", + "\n", + "\n", + "def frame_mod(X):\n", + " \"\"\"진폭 포락선(2.5ms 간격)의 50Hz 성분 비율: 20ms 프레임 경계 흔적(합성 단서).\"\"\"\n", + " env = np.abs(np.atleast_2d(X)).reshape(len(np.atleast_2d(X)), -1, 20).mean(-1)\n", + " sp = np.abs(np.fft.rfft(env - env.mean(1, keepdims=True), axis=1)) ** 2\n", + " f = np.fft.rfftfreq(env.shape[1], 20 / FS)\n", + " return sp[:, (f > 47) & (f < 53)].sum(1) / (sp[:, (f > 30) & (f < 70)].sum(1) + 1e-12)\n", + "\n", + "\n", + "FEAT_NAMES = [f\"mfcc{i}_m\" for i in range(13)] + [f\"mfcc{i}_s\" for i in range(13)] + \\\n", + " [\"delta_s\", \"hf_ratio\", \"lf_ratio\", \"flatness\", \"flux\", \"jitter\", \"hnr\", \"frame_mod\"]\n", + "\n", + "\n", + "def extract(X, denoise=0.0):\n", + " \"\"\"클립 배치 -> 특징 행렬 (클립 x 34) DataFrame.\"\"\"\n", + " P = spectrogram(X, denoise)\n", + " C = mfcc(P)\n", + " tot = band(P, 300, 3400) + 1e-12\n", + " flat = np.exp(np.log(P[:, (FREQS >= 300) & (FREQS < 3400)] + 1e-10).mean(1)) / (P[:, (FREQS >= 300) & (FREQS < 3400)].mean(1) + 1e-10)\n", + " logP = np.log(P + 1e-8)\n", + " jit, hnr = jitter_hnr(X)\n", + " F = np.c_[C.mean(2), C.std(2), np.abs(np.diff(C, axis=2)).mean((1, 2)), band(P, 2400, 3400) / tot, band(P, 300, 500) / tot,\n", + " flat.mean(1), np.abs(np.diff(logP, axis=2)).mean((1, 2)), jit, hnr, frame_mod(X)]\n", + " return pd.DataFrame(F, columns=FEAT_NAMES)\n", + "\n", + "\n", + "def enroll(seed=99):\n", + " \"\"\"지휘관·통신소 화자 6명의 등록 견본(깨끗한 진짜 클립 5개씩의 MFCC 평균).\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " tpl = {}\n", + " for spk in SPEAKERS:\n", + " Xe = np.stack([radio_channel(make_clip(\"bonafide\", spk, rng), rng, snr_db=25) for _ in range(5)])\n", + " tpl[spk] = extract(Xe)[[f\"mfcc{i}_m\" for i in range(1, 13)]].mean().values\n", + " return tpl\n", + "\n", + "\n", + "def speaker_sim(F, claimed, tpl):\n", + " \"\"\"주장 화자 견본과 MFCC 평균 벡터의 코사인 유사도(화자 검증 점수).\"\"\"\n", + " v = F[[f\"mfcc{i}_m\" for i in range(1, 13)]].values\n", + " t = np.stack([tpl[c] for c in claimed])\n", + " return (v * t).sum(1) / (np.linalg.norm(v, axis=1) * np.linalg.norm(t, axis=1) + 1e-12)\n", + "\n", + "# 단서: (특징, 위조일 때 방향, 주로 잡는 위협)\n", + "CUES = [(\"hf_ratio\", \"low\", \"tts_art\"), (\"frame_mod\", \"high\", \"tts_art\"), (\"hnr\", \"low\", \"vc_art\"),\n", + " (\"flatness\", \"high\", \"vc_art\"), (\"lf_ratio\", \"low\", \"replay\"), (\"spk_sim\", \"low\", \"tts/vc\")]\n", + "\n", + "\n", + "def fit_rules(F, q=0.03):\n", + " \"\"\"진짜 음성(bonafide)만으로 단서별 정상 범위 경계를 정한다(한쪽 3% 분위수).\"\"\"\n", + " th = {}\n", + " for f, d, _ in CUES:\n", + " th[f] = F[f].quantile(q) if d == \"low\" else F[f].quantile(1 - q)\n", + " return th\n", + "\n", + "\n", + "def apply_rules(F, th):\n", + " hits = pd.DataFrame({f: (F[f] < th[f]) if d == \"low\" else (F[f] > th[f]) for f, d, _ in CUES})\n", + " return hits.any(axis=1), hits\n", + "\n", + "\n", + "tpl = enroll()\n", + "# 기준 설정용: 진짜 음성 200개(여러 화자, SNR 5~25dB)\n", + "Xb, Mb = make_dataset(200, seed=7, labels=[\"bonafide\"], p=None)\n", + "Fb = extract(Xb); Fb[\"spk_sim\"] = speaker_sim(Fb, Mb[\"claimed\"], tpl)\n", + "th = fit_rules(Fb)\n", + "print(\"[규칙 경계(진짜 음성 기준 3% 분위수)]\", {k: round(float(v), 3) for k, v in th.items()})\n", + "\n", + "# [교육용가상자료] 평가용 기본 데이터셋: 진짜 50%, 위조 3종 각 약 17%, SNR 5~25dB\n", + "X, M = make_dataset(400, seed=8)\n", + "t0 = time.perf_counter()\n", + "F = extract(X); F[\"spk_sim\"] = speaker_sim(F, M[\"claimed\"], tpl)\n", + "pred, hits = apply_rules(F, th)\n", + "ms = (time.perf_counter() - t0) * 1000 / len(X)\n", + "acc = (pred == M[\"spoof\"]).mean()\n", + "print(f\"\\n클립 {len(X)}개: 탐지 정확도 {acc:.1%} (목표 75% 이상: {'달성' if acc >= 0.75 else '미달'}), \"\n", + " f\"위조 재현율 {pred[M['spoof']].mean():.1%}, 오경보율(진짜를 위조로) {pred[~M['spoof']].mean():.1%}, 지연 {ms:.2f} ms/클립\")\n", + "\n", + "print(\"\\n[유형별 위조 판정 비율과 주로 걸린 단서]\")\n", + "for lab in LABELS:\n", + " s = M[\"label\"] == lab\n", + " top = hits[s].mean().sort_values(ascending=False)\n", + " print(f\"{lab:9s} 위조 판정 {pred[s].mean():6.1%} 주요 단서: {', '.join(f'{k}({v:.0%})' for k, v in top.head(2).items())}\")\n", + "\n", + "print(\"\\nSNR 구간 정확도 오경보율\")\n", + "for lo, hi in [(5, 10), (10, 15), (15, 25)]:\n", + " s = (M[\"snr_db\"] >= lo) & (M[\"snr_db\"] < hi)\n", + " print(f\"{lo:>2}~{hi:<2}dB {(pred[s] == M['spoof'][s]).mean():6.1%} {pred[s & ~M['spoof']].mean():6.1%}\")\n", + "\n", + "ex = M.index[M[\"spoof\"] & pred][0]\n", + "print(f\"\\n판단 근거 예시 {M['clip'][ex]}({M['label'][ex]}): 걸린 단서 {', '.join(hits.columns[hits.loc[ex]])}\")\n", + "print(\"한계: 잡음이 큰 채널에서는 진짜 음성의 단서 값도 경계를 넘어 오경보가 늘고, 재생(replay)은 화자 유사도가 높아 단서가 적다 -> 미션 4·5에서 학습형 모델과 SNR 반영 신뢰도로 보완\")\n", + "\n", + "plt.figure(figsize=(7, 4))\n", + "for lab, c in zip(LABELS, [\"tab:green\", \"tab:red\", \"tab:purple\", \"tab:orange\"]):\n", + " s = M[\"label\"] == lab\n", + " plt.scatter(F[\"hf_ratio\"][s], F[\"hnr\"][s], s=10, c=c, label=lab)\n", + "plt.axvline(th[\"hf_ratio\"], ls=\"--\", c=\"k\"); plt.axhline(th[\"hnr\"], ls=\"--\", c=\"k\")\n", + "plt.xlabel(\"high-frequency energy ratio\"); plt.ylabel(\"HNR (autocorr peak)\"); plt.legend(); plt.title(\"Rule-based cues (synthetic signals)\")\n", + "plt.tight_layout(); plt.show()\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 03 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + "[규칙 경계(진짜 음성 기준 3% 분위수)] {'hf_ratio': 0.176, 'frame_mod': 0.286, 'hnr': 0.494, 'flatness': 0.341, 'lf_ratio': 0.0, 'spk_sim': 0.702}\n", + "\n", + "클립 400개: 탐지 정확도 78.8% (목표 75% 이상: 달성), 위조 재현율 74.0%, 오경보율(진짜를 위조로) 16.8%, 지연 0.93 ms/클립\n", + "\n", + "[유형별 위조 판정 비율과 주로 걸린 단서]\n", + "bonafide 위조 판정 16.8% 주요 단서: frame_mod(9%), hnr(3%)\n", + "tts_art 위조 판정 100.0% 주요 단서: frame_mod(100%), hf_ratio(22%)\n", + "vc_art 위조 판정 85.9% 주요 단서: hnr(84%), flatness(9%)\n", + "replay 위조 판정 30.5% 주요 단서: hnr(14%), spk_sim(12%)\n", + "\n", + "SNR 구간 정확도 오경보율\n", + " 5~10dB 77.7% 27.1%\n", + "10~15dB 81.7% 7.1%\n", + "15~25dB 77.5% 16.1%\n", + "\n", + "판단 근거 예시 C080002(tts_art): 걸린 단서 frame_mod\n", + "한계: 잡음이 큰 채널에서는 진짜 음성의 단서 값도 경계를 넘어 오경보가 늘고, 재생(replay)은 화자 유사도가 높아 단서가 적다 -> 미션 4·5에서 학습형 모델과 SNR 반영 신뢰도로 보완\n", + "```\n", + "
\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 04. 음성 합성 인공지능(TTS/VC) 아티팩트 및 위조 음성 판별 딥러닝 탐지 엔진 고도화\n", + "**소요시간**: 약 3.0시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "딥러닝 기반 딥페이크 음성 아티팩트 정밀 탐지 및 화자 고유 생체 음향 특징 검증 모델을 고도화한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **음향 특징과 화자 유사도를 입력으로 하는 신경망(MLP) 위조 음성 판별 엔진을 학습하고, 복합 위협 시나리오에서 성능과 오경보 패턴을 검증한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **MFCC 평균·표준편차, 고주파·저역 비율, 평탄도, 흔들림, HNR, 프레임 경계 흔적, 화자 유사도 35개 특징으로 신경망을 학습 수행하고 검증하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `src/models/c204_advanced_detector.py 및 예외 탐지 테스트 결과` 형태의 결과물을 완성한다.\n", + "\n", + "### 🔍 평가 및 검증 기준 (PASS 조건)\n", + "#### 미션 판정\n", + "\n", + "**평가 구분:** 필수\n", + "\n", + "**핵심 평가영역:** 기만/교란 공격에 대한 방어 로직의 현실성 및 신뢰성\n", + "\n", + "#### 미션 PASS 조건\n", + "\n", + "- 정량 기준: 복합 위협 탐지 재현율(Recall) 85% 이상, 처리 지연 200ms 이하\n", + "\n", + "\n", + "- 진행 기준: 복합 위협 시나리오 10종에 대한 탐지 테스트 통과\n", + "\n", + "\n", + "- 검증 방법: 합성 위협 데이터셋 주입 시 이상치 탐지 정확도 검증\n", + "\n", + "\n", + "#### 채점자 판정 메모\n", + "\n", + "> 단순 코드 실행 여부뿐만 아니라 출력된 수치와 근거 자료가 군 실무 요구사항에 부합하는지 면밀히 확인한다.\n", + "\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "\"\"\"음향 특징 34종과 등록 화자 유사도를 입력으로 하는 신경망(MLP) 위조 음성 판별 엔진을 학습하고, 위조 유형과 채널 조건을 섞은 복합 위협 시나리오 10종에서 재현율·처리 지연을 검증하며 오경보를 일으키는 패턴을 찾는다.\"\"\"\n", + "import time\n", + "import numpy as np\n", + "import pandas as pd\n", + "import matplotlib.pyplot as plt\n", + "from scipy import signal\n", + "from scipy.fft import dct\n", + "from sklearn.neural_network import MLPClassifier\n", + "from sklearn.preprocessing import StandardScaler\n", + "from sklearn.pipeline import make_pipeline\n", + "\n", + "# ---------------------------------------------------------------------------\n", + "# [교육용가상자료] 탐지 학습용 합성 신호 생성기 (실제 음성·화자·통신망 정보 없음)\n", + "# 사람 목소리를 만드는 기능이 아니라, 배음(하모닉) 신호에 '진짜 신호의 자연스러운 흔들림'과\n", + "# '알려진 위조 흔적(문헌상 탐지 단서)'의 통계적 특징을 표시해 탐지기를 학습시키기 위한 추상 신호다.\n", + "# bonafide : 자연스러운 피치 흔들림(jitter)·진폭 흔들림(shimmer)·숨소리 잡음\n", + "# tts_art : 흔들림이 거의 없고 고주파 에너지가 부족하며 20ms 프레임 경계 흔적이 있는 신호\n", + "# vc_art : 프레임마다 배음 위상이 끊기고 스펙트럼 포락선이 뭉개진 신호\n", + "# replay : 스피커 재생 대역 제한 + 실내 잔향이 더해진 신호\n", + "# ---------------------------------------------------------------------------\n", + "FS, DUR = 8000, 0.5\n", + "N = int(FS * DUR)\n", + "LABELS = [\"bonafide\", \"tts_art\", \"vc_art\", \"replay\"]\n", + "SPEAKERS = {f\"SPK_{c}\": (f0, fm) for c, f0, fm in [(\"A\", 115, (650, 1150, 2450)), (\"B\", 135, (700, 1220, 2600)), (\"C\", 98, (600, 1050, 2350)),\n", + " (\"D\", 160, (720, 1300, 2700)), (\"E\", 125, (680, 1180, 2500)), (\"F\", 105, (630, 1100, 2400))]}\n", + "\n", + "\n", + "def _source(rng, f0, fm, jitter, shimmer, phase_break=False, hf_cut=None, frame_mod=0.0):\n", + " t = np.arange(N) / FS\n", + " contour = f0 * (1 + 0.06 * np.sin(2 * np.pi * rng.uniform(1.5, 3.5) * t + rng.uniform(0, 6)))\n", + " f = contour * (1 + jitter * rng.normal(0, 1, N // 80 + 1).repeat(80)[:N]) # 10ms 단위 피치 흔들림(jitter)\n", + " phase = 2 * np.pi * np.cumsum(f) / FS\n", + " K = int(3800 / f0)\n", + " fk = np.arange(1, K + 1) * f0\n", + " amp = sum(np.exp(-((fk - F) / 120.0) ** 2) / (i + 1) for i, F in enumerate(fm)) + 0.02 / np.arange(1, K + 1)\n", + " if hf_cut:\n", + " amp = np.where(fk > hf_cut, amp * 0.2, amp)\n", + " ph = np.arange(1, K + 1)[:, None] * phase[None, :]\n", + " if phase_break: # 20ms 프레임마다 배음별 위상 불연속\n", + " ph = ph + rng.uniform(0, 2 * np.pi, (K, N // 160 + 1)).repeat(160, axis=1)[:, :N]\n", + " sig = (amp[:, None] * np.sin(ph)).sum(0)\n", + " env = 0.55 + 0.45 * np.sin(2 * np.pi * rng.uniform(3, 5) * t + rng.uniform(0, 6)) ** 2 # 음절형 진폭 변화\n", + " env *= 1 + shimmer * np.interp(t, np.linspace(0, DUR, 60), rng.normal(0, 1, 60)) # 완만한 진폭 흔들림(shimmer)\n", + " if frame_mod:\n", + " env *= 1 + frame_mod * np.sign(np.sin(2 * np.pi * 50 * t)) # 20ms 프레임 경계 흔적(50Hz)\n", + " return sig * env + (0.03 if jitter > 0.003 else 0.005) * rng.normal(0, 1, N) * env\n", + "\n", + "\n", + "def make_clip(label, speaker, rng):\n", + " f0, fm = SPEAKERS[speaker]\n", + " if label in (\"tts_art\", \"vc_art\"): # 위조는 화자 특성을 근사만 함\n", + " f0 = f0 * rng.uniform(0.92, 1.08); fm = tuple(F * rng.uniform(0.95, 1.05) for F in fm)\n", + " if label == \"bonafide\" or label == \"replay\":\n", + " x = _source(rng, f0, fm, jitter=rng.uniform(0.008, 0.02), shimmer=rng.uniform(0.05, 0.12))\n", + " elif label == \"tts_art\":\n", + " x = _source(rng, f0, fm, jitter=rng.uniform(0.0, 0.002), shimmer=0.01, hf_cut=rng.uniform(2300, 2800), frame_mod=rng.uniform(0.08, 0.2))\n", + " else:\n", + " x = _source(rng, f0, fm, jitter=rng.uniform(0.006, 0.015), shimmer=rng.uniform(0.04, 0.1), phase_break=True)\n", + " if label == \"replay\": # 스피커 대역 + 실내 잔향\n", + " x = signal.sosfilt(signal.butter(4, [450, 2900], \"bandpass\", fs=FS, output=\"sos\"), x)\n", + " ir = rng.normal(0, 1, int(0.08 * FS)) * np.exp(-np.arange(int(0.08 * FS)) / (0.02 * FS)); ir[0] = 3.0\n", + " x = signal.fftconvolve(x, ir)[:N]\n", + " return x / (np.abs(x).max() + 1e-9)\n", + "\n", + "\n", + "RADIO_BP = signal.butter(4, [300, 3400], \"bandpass\", fs=FS, output=\"sos\")\n", + "\n", + "\n", + "def radio_channel(x, rng, snr_db=15.0, impulse=0.0, dropout=0.0, clip=1.0, jam_tone=0.0):\n", + " \"\"\"[교육용가상자료] 무전 채널 모형: 300~3400Hz 대역 제한, 배경 잡음(SNR), 충격성 잡음, 신호 끊김, 포화(클리핑), 단일톤 재밍.\"\"\"\n", + " y = signal.sosfilt(RADIO_BP, x)\n", + " p = np.mean(y ** 2)\n", + " noise = rng.normal(0, 1, N) * np.sqrt(p / 10 ** (snr_db / 10))\n", + " noise += np.cumsum(rng.normal(0, 0.02, N)) * np.sqrt(p) * 0.05 # 저주파 차량·엔진형 소음\n", + " y = y + noise\n", + " if impulse:\n", + " k = rng.random(N) < impulse\n", + " y[k] += rng.normal(0, 6 * np.sqrt(p), k.sum())\n", + " if jam_tone:\n", + " y += jam_tone * np.sqrt(p) * np.sin(2 * np.pi * rng.uniform(800, 2000) * np.arange(N) / FS)\n", + " if dropout:\n", + " for _ in range(int(dropout * 10)):\n", + " a = int(rng.integers(0, N - 400)); y[a:a + int(rng.integers(100, 400))] = 0.0\n", + " return np.clip(y / (np.abs(y).max() + 1e-9), -clip, clip)\n", + "\n", + "\n", + "def make_dataset(n=400, seed=7, snr=(5, 25), labels=LABELS, p=None, claim=\"SPK_A\", **chan):\n", + " \"\"\"클립 n개: 진짜는 여러 화자, 위조는 모두 지휘관 화자(SPK_A)를 사칭. 반환: (신호 배열, 메타 DataFrame).\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " X, meta = np.zeros((n, N)), []\n", + " if p is None and len(labels) > 1 and labels[0] == \"bonafide\": # 기본: 진짜 50%, 나머지 위조 유형 균등\n", + " p = [0.5] + [0.5 / (len(labels) - 1)] * (len(labels) - 1)\n", + " for i in range(n):\n", + " lab = str(rng.choice(labels, p=p))\n", + " spk = str(rng.choice(list(SPEAKERS))) if lab == \"bonafide\" else claim\n", + " if lab == \"replay\":\n", + " spk = claim\n", + " s = float(rng.uniform(*snr)) if isinstance(snr, tuple) else float(snr)\n", + " X[i] = radio_channel(make_clip(lab, spk, rng), rng, snr_db=s, **chan)\n", + " meta.append({\"clip\": f\"C{seed:02d}{i:04d}\", \"label\": lab, \"spoof\": lab != \"bonafide\", \"claimed\": spk if lab == \"bonafide\" else claim,\n", + " \"true_spk\": spk if lab == \"bonafide\" else \"unknown\", \"snr_db\": round(s, 1)})\n", + " return X, pd.DataFrame(meta)\n", + "# ---------------------------------------------------------------------------\n", + "\n", + "# ---- 전처리·음향 특징(MFCC/스펙트로그램) 공통 함수: 여러 클립을 한 번에 계산 ----\n", + "NFFT, HOP = 256, 80\n", + "\n", + "\n", + "def mel_bank(n_mels=26, fmin=300, fmax=3400):\n", + " mel = lambda f: 2595 * np.log10(1 + f / 700)\n", + " imel = lambda m: 700 * (10 ** (m / 2595) - 1)\n", + " pts = imel(np.linspace(mel(fmin), mel(fmax), n_mels + 2))\n", + " freqs = np.fft.rfftfreq(NFFT, 1 / FS)\n", + " fb = np.zeros((n_mels, len(freqs)))\n", + " for m in range(n_mels):\n", + " l, c, r = pts[m], pts[m + 1], pts[m + 2]\n", + " fb[m] = np.clip(np.minimum((freqs - l) / (c - l), (r - freqs) / (r - c)), 0, None)\n", + " return fb\n", + "\n", + "\n", + "MEL = mel_bank()\n", + "FREQS = np.fft.rfftfreq(NFFT, 1 / FS)\n", + "\n", + "\n", + "def spectrogram(X, denoise=0.0):\n", + " \"\"\"배치 전력 스펙트로그램 (클립, 주파수, 프레임). denoise>0이면 잡음 바닥(주파수별 하위 10%)을 그 배수만큼 빼는 스펙트럼 차감.\"\"\"\n", + " X = np.atleast_2d(X)\n", + " Xp = np.c_[X[:, :1], X[:, 1:] - 0.95 * X[:, :-1]] # 프리엠퍼시스\n", + " frames = np.lib.stride_tricks.sliding_window_view(Xp, NFFT, axis=1)[:, ::HOP] * np.hanning(NFFT)\n", + " P = np.abs(np.fft.rfft(frames, axis=-1)) ** 2\n", + " P = np.transpose(P, (0, 2, 1))\n", + " if denoise:\n", + " floor = np.percentile(P, 10, axis=2, keepdims=True)\n", + " P = np.maximum(P - denoise * floor, 0.05 * P)\n", + " return P\n", + "\n", + "\n", + "def mfcc(P, n=13):\n", + " logmel = np.log(np.einsum(\"mf,cft->cmt\", MEL, P) + 1e-8)\n", + " return dct(logmel, type=2, axis=1, norm=\"ortho\")[:, :n, :]\n", + "\n", + "\n", + "def band(P, lo, hi):\n", + " return P[:, (FREQS >= lo) & (FREQS < hi), :].sum(axis=(1, 2))\n", + "\n", + "\n", + "def jitter_hnr(X):\n", + " \"\"\"40ms 프레임 자기상관으로 피치 주기를 찾아 피치 흔들림(jitter)과 배음 대 잡음비(HNR 근사)를 구한다.\"\"\"\n", + " W, lag0, lag1 = 320, int(FS / 250), int(FS / 70)\n", + " fr = np.lib.stride_tricks.sliding_window_view(np.atleast_2d(X), W, axis=1)[:, ::160]\n", + " fr = fr - fr.mean(-1, keepdims=True)\n", + " spec = np.fft.rfft(fr, 2 * W, axis=-1)\n", + " ac = np.fft.irfft(np.abs(spec) ** 2, axis=-1)[..., :W]\n", + " ac = ac / (ac[..., :1] + 1e-12)\n", + " seg = ac[..., lag0:lag1]\n", + " k = seg.argmax(-1)\n", + " peak = seg.max(-1)\n", + " km, kp = np.take_along_axis(seg, np.clip(k - 1, 0, None)[..., None], -1)[..., 0], np.take_along_axis(seg, np.clip(k + 1, None, seg.shape[-1] - 1)[..., None], -1)[..., 0]\n", + " lag = k + lag0 + 0.5 * (km - kp) / (km - 2 * peak + kp - 1e-12) # 포물선 보간으로 주기 정밀화\n", + " f0 = FS / lag\n", + " jit = np.median(np.abs(np.diff(f0, axis=1)) / f0[:, 1:], axis=1)\n", + " return jit, np.median(peak, axis=1)\n", + "\n", + "\n", + "def frame_mod(X):\n", + " \"\"\"진폭 포락선(2.5ms 간격)의 50Hz 성분 비율: 20ms 프레임 경계 흔적(합성 단서).\"\"\"\n", + " env = np.abs(np.atleast_2d(X)).reshape(len(np.atleast_2d(X)), -1, 20).mean(-1)\n", + " sp = np.abs(np.fft.rfft(env - env.mean(1, keepdims=True), axis=1)) ** 2\n", + " f = np.fft.rfftfreq(env.shape[1], 20 / FS)\n", + " return sp[:, (f > 47) & (f < 53)].sum(1) / (sp[:, (f > 30) & (f < 70)].sum(1) + 1e-12)\n", + "\n", + "\n", + "FEAT_NAMES = [f\"mfcc{i}_m\" for i in range(13)] + [f\"mfcc{i}_s\" for i in range(13)] + \\\n", + " [\"delta_s\", \"hf_ratio\", \"lf_ratio\", \"flatness\", \"flux\", \"jitter\", \"hnr\", \"frame_mod\"]\n", + "\n", + "\n", + "def extract(X, denoise=0.0):\n", + " \"\"\"클립 배치 -> 특징 행렬 (클립 x 34) DataFrame.\"\"\"\n", + " P = spectrogram(X, denoise)\n", + " C = mfcc(P)\n", + " tot = band(P, 300, 3400) + 1e-12\n", + " flat = np.exp(np.log(P[:, (FREQS >= 300) & (FREQS < 3400)] + 1e-10).mean(1)) / (P[:, (FREQS >= 300) & (FREQS < 3400)].mean(1) + 1e-10)\n", + " logP = np.log(P + 1e-8)\n", + " jit, hnr = jitter_hnr(X)\n", + " F = np.c_[C.mean(2), C.std(2), np.abs(np.diff(C, axis=2)).mean((1, 2)), band(P, 2400, 3400) / tot, band(P, 300, 500) / tot,\n", + " flat.mean(1), np.abs(np.diff(logP, axis=2)).mean((1, 2)), jit, hnr, frame_mod(X)]\n", + " return pd.DataFrame(F, columns=FEAT_NAMES)\n", + "\n", + "\n", + "def enroll(seed=99):\n", + " \"\"\"지휘관·통신소 화자 6명의 등록 견본(깨끗한 진짜 클립 5개씩의 MFCC 평균).\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " tpl = {}\n", + " for spk in SPEAKERS:\n", + " Xe = np.stack([radio_channel(make_clip(\"bonafide\", spk, rng), rng, snr_db=25) for _ in range(5)])\n", + " tpl[spk] = extract(Xe)[[f\"mfcc{i}_m\" for i in range(1, 13)]].mean().values\n", + " return tpl\n", + "\n", + "\n", + "def speaker_sim(F, claimed, tpl):\n", + " \"\"\"주장 화자 견본과 MFCC 평균 벡터의 코사인 유사도(화자 검증 점수).\"\"\"\n", + " v = F[[f\"mfcc{i}_m\" for i in range(1, 13)]].values\n", + " t = np.stack([tpl[c] for c in claimed])\n", + " return (v * t).sum(1) / (np.linalg.norm(v, axis=1) * np.linalg.norm(t, axis=1) + 1e-12)\n", + "\n", + "tpl = enroll()\n", + "\n", + "\n", + "def featurize(X, M):\n", + " F = extract(X)\n", + " F[\"spk_sim\"] = speaker_sim(F, M[\"claimed\"], tpl)\n", + " return F\n", + "\n", + "\n", + "# [교육용가상자료] 학습용: 깨끗~보통 채널 + 일부 충격 잡음·끊김 채널을 섞어 학습(데이터 증강)\n", + "parts = [make_dataset(500, seed=21, snr=(5, 25)), make_dataset(200, seed=22, snr=(5, 20), impulse=0.002, dropout=0.3)]\n", + "Xtr = np.vstack([p[0] for p in parts]); Mtr = pd.concat([p[1] for p in parts], ignore_index=True)\n", + "Ftr = featurize(Xtr, Mtr)\n", + "model = make_pipeline(StandardScaler(), MLPClassifier(hidden_layer_sizes=(64, 32), alpha=1e-3, max_iter=600, random_state=0))\n", + "model.fit(Ftr, Mtr[\"spoof\"])\n", + "print(f\"학습 클립 {len(Xtr)}개(진짜 {(~Mtr['spoof']).sum()} / 위조 {Mtr['spoof'].sum()}), 특징 {Ftr.shape[1]}개, 신경망 은닉층 64-32\")\n", + "\n", + "# [교육용가상자료] 복합 위협 시나리오 10종: (이름, 위조 유형, 채널 조건)\n", + "SCEN = [(\"S01 TTS 명령, 양호 채널\", \"tts_art\", dict(snr=(15, 25))),\n", + " (\"S02 TTS 명령, 저SNR\", \"tts_art\", dict(snr=(3, 8))),\n", + " (\"S03 VC 사칭, 양호 채널\", \"vc_art\", dict(snr=(15, 25))),\n", + " (\"S04 VC 사칭, 충격 잡음\", \"vc_art\", dict(snr=(8, 15), impulse=0.002)),\n", + " (\"S05 재생 공격, 양호 채널\", \"replay\", dict(snr=(15, 25))),\n", + " (\"S06 재생 공격, 신호 끊김\", \"replay\", dict(snr=(8, 15), dropout=0.4)),\n", + " (\"S07 TTS, 단일톤 재밍\", \"tts_art\", dict(snr=(8, 15), jam_tone=0.5)),\n", + " (\"S08 VC, 포화(클리핑)\", \"vc_art\", dict(snr=(10, 20), clip=0.5)),\n", + " (\"S09 혼합 위조, 저SNR\", None, dict(snr=(3, 8))),\n", + " (\"S10 혼합 위조, 끊김+충격\", None, dict(snr=(8, 15), impulse=0.002, dropout=0.3))]\n", + "\n", + "rows, lat = [], []\n", + "for k, (name, lab, ch) in enumerate(SCEN):\n", + " labs = [\"tts_art\", \"vc_art\", \"replay\"] if lab is None else [lab]\n", + " X, M = make_dataset(40, seed=300 + k, labels=labs, p=[1 / len(labs)] * len(labs), **ch)\n", + " t0 = time.perf_counter()\n", + " pred = model.predict(featurize(X, M))\n", + " lat.append((time.perf_counter() - t0) * 1000 / len(X))\n", + " rows.append({\"시나리오\": name, \"재현율\": pred.mean(), \"통과(85%)\": \"PASS\" if pred.mean() >= 0.85 else \"FAIL\"})\n", + "R = pd.DataFrame(rows)\n", + "print(\"\\n[복합 위협 시나리오 10종 탐지 결과(각 위조 40클립)]\")\n", + "print(R.to_string(index=False, formatters={\"재현율\": \"{:.1%}\".format}))\n", + "\n", + "# 단일 클립 실시간 지연(특징 추출+추론) 측정\n", + "Xs, Ms = make_dataset(30, seed=399)\n", + "one = []\n", + "for i in range(30):\n", + " t0 = time.perf_counter(); model.predict(featurize(Xs[i:i + 1], Ms.iloc[i:i + 1])); one.append((time.perf_counter() - t0) * 1000)\n", + "rec = R[\"재현율\"].mean()\n", + "p95 = float(np.percentile(one, 95))\n", + "print(f\"\\n복합 위협 평균 재현율 {rec:.1%} (목표 85% 이상: {'달성' if rec >= 0.85 else '미달'}), 시나리오 통과 {(R['통과(85%)'] == 'PASS').sum()}/10\")\n", + "print(f\"단일 클립 처리 지연 p95 {p95:.1f} ms (목표 200ms 이하: {'달성' if p95 <= 200 else '미달'}), 배치 평균 {np.mean(lat):.2f} ms/클립\")\n", + "\n", + "# 오경보 패턴: 진짜 음성을 다양한 채널 조건에서 판정\n", + "print(\"\\n[진짜 음성 오경보율 - 채널 조건별]\")\n", + "fa = {}\n", + "for name, ch in [(\"양호(15~25dB)\", dict(snr=(15, 25))), (\"저SNR(3~8dB)\", dict(snr=(3, 8))), (\"충격 잡음\", dict(snr=(8, 15), impulse=0.002)),\n", + " (\"신호 끊김\", dict(snr=(8, 15), dropout=0.4)), (\"단일톤 재밍\", dict(snr=(8, 15), jam_tone=0.5)), (\"포화(클리핑)\", dict(snr=(10, 20), clip=0.5))]:\n", + " X, M = make_dataset(60, seed=500 + len(fa), labels=[\"bonafide\"], **ch)\n", + " fa[name] = model.predict(featurize(X, M)).mean()\n", + " print(f\" {name:12s} {fa[name]:6.1%}\")\n", + "worst = max(fa, key=fa.get)\n", + "print(f\"오경보 유발 패턴: '{worst}' 조건에서 가장 높음 -> 해당 조건 진짜 음성을 학습에 보강하거나 미션 5에서 채널 품질을 신뢰도에 반영해 경보 등급을 낮춘다\")\n", + "\n", + "plt.figure(figsize=(7, 3.5))\n", + "plt.barh([s.split()[0] for s in R[\"시나리오\"]], R[\"재현율\"], color=[\"tab:green\" if v >= 0.85 else \"tab:red\" for v in R[\"재현율\"]])\n", + "plt.axvline(0.85, ls=\"--\", c=\"k\"); plt.xlabel(\"recall\"); plt.title(\"Composite threat scenarios (synthetic)\"); plt.tight_layout(); plt.show()\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 04 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + "학습 클립 700개(진짜 351 / 위조 349), 특징 35개, 신경망 은닉층 64-32\n", + "\n", + "[복합 위협 시나리오 10종 탐지 결과(각 위조 40클립)]\n", + " 시나리오 재현율 통과(85%)\n", + "S01 TTS 명령, 양호 채널 100.0% PASS\n", + " S02 TTS 명령, 저SNR 100.0% PASS\n", + " S03 VC 사칭, 양호 채널 100.0% PASS\n", + " S04 VC 사칭, 충격 잡음 92.5% PASS\n", + " S05 재생 공격, 양호 채널 100.0% PASS\n", + " S06 재생 공격, 신호 끊김 90.0% PASS\n", + " S07 TTS, 단일톤 재밍 100.0% PASS\n", + " S08 VC, 포화(클리핑) 100.0% PASS\n", + " S09 혼합 위조, 저SNR 95.0% PASS\n", + " S10 혼합 위조, 끊김+충격 85.0% PASS\n", + "\n", + "복합 위협 평균 재현율 96.2% (목표 85% 이상: 달성), 시나리오 통과 10/10\n", + "단일 클립 처리 지연 p95 4.3 ms (목표 200ms 이하: 달성), 배치 평균 0.81 ms/클립\n", + "\n", + "[진짜 음성 오경보율 - 채널 조건별]\n", + " 양호(15~25dB) 0.0%\n", + " 저SNR(3~8dB) 3.3%\n", + " 충격 잡음 0.0%\n", + " 신호 끊김 8.3%\n", + " 단일톤 재밍 31.7%\n", + " 포화(클리핑) 6.7%\n", + "오경보 유발 패턴: '단일톤 재밍' 조건에서 가장 높음 -> 해당 조건 진짜 음성을 학습에 보강하거나 미션 5에서 채널 품질을 신뢰도에 반영해 경보 등급을 낮춘다\n", + "```\n", + "
\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 05. 전장 소음 환경 오탐 방지 및 무전 화자 진위 신뢰도 점수 산출 모듈 구현\n", + "**소요시간**: 약 3.0시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "전술 잡음 속에서 오탐을 최소화하고 음성 진위 신뢰도 점수를 산출하는 엔진을 개발한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **채널 품질을 반영한 보정 모델로 무전 화자 진위 신뢰도 점수와 High/Med/Low 등급을 산출해 전장 소음 속 오경보를 줄인다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **추정 SNR, 단일톤 재밍 지수, 끊김 비율 등 채널 품질 특징을 더하고 잡음 조건을 섞어 학습한 뒤 로지스틱 보정으로 점수를 확률화 수행하고 검증하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `src/verification/c204_confidence_engine.py` 형태의 결과물을 완성한다.\n", + "\n", + "### 🔍 평가 및 검증 기준 (PASS 조건)\n", + "#### 미션 판정\n", + "\n", + "**평가 구분:** 필수\n", + "\n", + "**핵심 평가영역:** 신뢰도 등급(High/Med/Low) 부여 기준의 군사적 타당성\n", + "\n", + "#### 미션 PASS 조건\n", + "\n", + "- 정량 기준: 오경보율(FAR) 40% 이상 감축, 신뢰도 스코어 산출 일관성 90% 이상\n", + "\n", + "\n", + "- 진행 기준: 오경보 필터링 전후 비교 지표 산출 및 통합 테스트 완료\n", + "\n", + "\n", + "- 검증 방법: ROC-AUC 곡선 분석 및 임계값 민감도 테스트\n", + "\n", + "\n", + "#### 채점자 판정 메모\n", + "\n", + "> 단순 코드 실행 여부뿐만 아니라 출력된 수치와 근거 자료가 군 실무 요구사항에 부합하는지 면밀히 확인한다.\n", + "\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "\"\"\"채널 품질 특징(추정 SNR, 단일톤 재밍 지수, 끊김 비율)을 더하고 잡음 조건을 섞어 학습한 보정 모델로 무전 화자 진위 신뢰도 점수와 High/Med/Low 등급을 산출해, 기존 판별기 대비 오경보율 감축, 등급 일관성, ROC-AUC와 임계값 민감도를 확인한다.\"\"\"\n", + "import numpy as np\n", + "import pandas as pd\n", + "import matplotlib.pyplot as plt\n", + "from scipy import signal\n", + "from scipy.fft import dct\n", + "from sklearn.neural_network import MLPClassifier\n", + "from sklearn.linear_model import LogisticRegression\n", + "from sklearn.preprocessing import StandardScaler\n", + "from sklearn.pipeline import make_pipeline\n", + "from sklearn.metrics import roc_auc_score, roc_curve\n", + "\n", + "# ---------------------------------------------------------------------------\n", + "# [교육용가상자료] 탐지 학습용 합성 신호 생성기 (실제 음성·화자·통신망 정보 없음)\n", + "# 사람 목소리를 만드는 기능이 아니라, 배음(하모닉) 신호에 '진짜 신호의 자연스러운 흔들림'과\n", + "# '알려진 위조 흔적(문헌상 탐지 단서)'의 통계적 특징을 표시해 탐지기를 학습시키기 위한 추상 신호다.\n", + "# bonafide : 자연스러운 피치 흔들림(jitter)·진폭 흔들림(shimmer)·숨소리 잡음\n", + "# tts_art : 흔들림이 거의 없고 고주파 에너지가 부족하며 20ms 프레임 경계 흔적이 있는 신호\n", + "# vc_art : 프레임마다 배음 위상이 끊기고 스펙트럼 포락선이 뭉개진 신호\n", + "# replay : 스피커 재생 대역 제한 + 실내 잔향이 더해진 신호\n", + "# ---------------------------------------------------------------------------\n", + "FS, DUR = 8000, 0.5\n", + "N = int(FS * DUR)\n", + "LABELS = [\"bonafide\", \"tts_art\", \"vc_art\", \"replay\"]\n", + "SPEAKERS = {f\"SPK_{c}\": (f0, fm) for c, f0, fm in [(\"A\", 115, (650, 1150, 2450)), (\"B\", 135, (700, 1220, 2600)), (\"C\", 98, (600, 1050, 2350)),\n", + " (\"D\", 160, (720, 1300, 2700)), (\"E\", 125, (680, 1180, 2500)), (\"F\", 105, (630, 1100, 2400))]}\n", + "\n", + "\n", + "def _source(rng, f0, fm, jitter, shimmer, phase_break=False, hf_cut=None, frame_mod=0.0):\n", + " t = np.arange(N) / FS\n", + " contour = f0 * (1 + 0.06 * np.sin(2 * np.pi * rng.uniform(1.5, 3.5) * t + rng.uniform(0, 6)))\n", + " f = contour * (1 + jitter * rng.normal(0, 1, N // 80 + 1).repeat(80)[:N]) # 10ms 단위 피치 흔들림(jitter)\n", + " phase = 2 * np.pi * np.cumsum(f) / FS\n", + " K = int(3800 / f0)\n", + " fk = np.arange(1, K + 1) * f0\n", + " amp = sum(np.exp(-((fk - F) / 120.0) ** 2) / (i + 1) for i, F in enumerate(fm)) + 0.02 / np.arange(1, K + 1)\n", + " if hf_cut:\n", + " amp = np.where(fk > hf_cut, amp * 0.2, amp)\n", + " ph = np.arange(1, K + 1)[:, None] * phase[None, :]\n", + " if phase_break: # 20ms 프레임마다 배음별 위상 불연속\n", + " ph = ph + rng.uniform(0, 2 * np.pi, (K, N // 160 + 1)).repeat(160, axis=1)[:, :N]\n", + " sig = (amp[:, None] * np.sin(ph)).sum(0)\n", + " env = 0.55 + 0.45 * np.sin(2 * np.pi * rng.uniform(3, 5) * t + rng.uniform(0, 6)) ** 2 # 음절형 진폭 변화\n", + " env *= 1 + shimmer * np.interp(t, np.linspace(0, DUR, 60), rng.normal(0, 1, 60)) # 완만한 진폭 흔들림(shimmer)\n", + " if frame_mod:\n", + " env *= 1 + frame_mod * np.sign(np.sin(2 * np.pi * 50 * t)) # 20ms 프레임 경계 흔적(50Hz)\n", + " return sig * env + (0.03 if jitter > 0.003 else 0.005) * rng.normal(0, 1, N) * env\n", + "\n", + "\n", + "def make_clip(label, speaker, rng):\n", + " f0, fm = SPEAKERS[speaker]\n", + " if label in (\"tts_art\", \"vc_art\"): # 위조는 화자 특성을 근사만 함\n", + " f0 = f0 * rng.uniform(0.92, 1.08); fm = tuple(F * rng.uniform(0.95, 1.05) for F in fm)\n", + " if label == \"bonafide\" or label == \"replay\":\n", + " x = _source(rng, f0, fm, jitter=rng.uniform(0.008, 0.02), shimmer=rng.uniform(0.05, 0.12))\n", + " elif label == \"tts_art\":\n", + " x = _source(rng, f0, fm, jitter=rng.uniform(0.0, 0.002), shimmer=0.01, hf_cut=rng.uniform(2300, 2800), frame_mod=rng.uniform(0.08, 0.2))\n", + " else:\n", + " x = _source(rng, f0, fm, jitter=rng.uniform(0.006, 0.015), shimmer=rng.uniform(0.04, 0.1), phase_break=True)\n", + " if label == \"replay\": # 스피커 대역 + 실내 잔향\n", + " x = signal.sosfilt(signal.butter(4, [450, 2900], \"bandpass\", fs=FS, output=\"sos\"), x)\n", + " ir = rng.normal(0, 1, int(0.08 * FS)) * np.exp(-np.arange(int(0.08 * FS)) / (0.02 * FS)); ir[0] = 3.0\n", + " x = signal.fftconvolve(x, ir)[:N]\n", + " return x / (np.abs(x).max() + 1e-9)\n", + "\n", + "\n", + "RADIO_BP = signal.butter(4, [300, 3400], \"bandpass\", fs=FS, output=\"sos\")\n", + "\n", + "\n", + "def radio_channel(x, rng, snr_db=15.0, impulse=0.0, dropout=0.0, clip=1.0, jam_tone=0.0):\n", + " \"\"\"[교육용가상자료] 무전 채널 모형: 300~3400Hz 대역 제한, 배경 잡음(SNR), 충격성 잡음, 신호 끊김, 포화(클리핑), 단일톤 재밍.\"\"\"\n", + " y = signal.sosfilt(RADIO_BP, x)\n", + " p = np.mean(y ** 2)\n", + " noise = rng.normal(0, 1, N) * np.sqrt(p / 10 ** (snr_db / 10))\n", + " noise += np.cumsum(rng.normal(0, 0.02, N)) * np.sqrt(p) * 0.05 # 저주파 차량·엔진형 소음\n", + " y = y + noise\n", + " if impulse:\n", + " k = rng.random(N) < impulse\n", + " y[k] += rng.normal(0, 6 * np.sqrt(p), k.sum())\n", + " if jam_tone:\n", + " y += jam_tone * np.sqrt(p) * np.sin(2 * np.pi * rng.uniform(800, 2000) * np.arange(N) / FS)\n", + " if dropout:\n", + " for _ in range(int(dropout * 10)):\n", + " a = int(rng.integers(0, N - 400)); y[a:a + int(rng.integers(100, 400))] = 0.0\n", + " return np.clip(y / (np.abs(y).max() + 1e-9), -clip, clip)\n", + "\n", + "\n", + "def make_dataset(n=400, seed=7, snr=(5, 25), labels=LABELS, p=None, claim=\"SPK_A\", **chan):\n", + " \"\"\"클립 n개: 진짜는 여러 화자, 위조는 모두 지휘관 화자(SPK_A)를 사칭. 반환: (신호 배열, 메타 DataFrame).\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " X, meta = np.zeros((n, N)), []\n", + " if p is None and len(labels) > 1 and labels[0] == \"bonafide\": # 기본: 진짜 50%, 나머지 위조 유형 균등\n", + " p = [0.5] + [0.5 / (len(labels) - 1)] * (len(labels) - 1)\n", + " for i in range(n):\n", + " lab = str(rng.choice(labels, p=p))\n", + " spk = str(rng.choice(list(SPEAKERS))) if lab == \"bonafide\" else claim\n", + " if lab == \"replay\":\n", + " spk = claim\n", + " s = float(rng.uniform(*snr)) if isinstance(snr, tuple) else float(snr)\n", + " X[i] = radio_channel(make_clip(lab, spk, rng), rng, snr_db=s, **chan)\n", + " meta.append({\"clip\": f\"C{seed:02d}{i:04d}\", \"label\": lab, \"spoof\": lab != \"bonafide\", \"claimed\": spk if lab == \"bonafide\" else claim,\n", + " \"true_spk\": spk if lab == \"bonafide\" else \"unknown\", \"snr_db\": round(s, 1)})\n", + " return X, pd.DataFrame(meta)\n", + "# ---------------------------------------------------------------------------\n", + "\n", + "# ---- 전처리·음향 특징(MFCC/스펙트로그램) 공통 함수: 여러 클립을 한 번에 계산 ----\n", + "NFFT, HOP = 256, 80\n", + "\n", + "\n", + "def mel_bank(n_mels=26, fmin=300, fmax=3400):\n", + " mel = lambda f: 2595 * np.log10(1 + f / 700)\n", + " imel = lambda m: 700 * (10 ** (m / 2595) - 1)\n", + " pts = imel(np.linspace(mel(fmin), mel(fmax), n_mels + 2))\n", + " freqs = np.fft.rfftfreq(NFFT, 1 / FS)\n", + " fb = np.zeros((n_mels, len(freqs)))\n", + " for m in range(n_mels):\n", + " l, c, r = pts[m], pts[m + 1], pts[m + 2]\n", + " fb[m] = np.clip(np.minimum((freqs - l) / (c - l), (r - freqs) / (r - c)), 0, None)\n", + " return fb\n", + "\n", + "\n", + "MEL = mel_bank()\n", + "FREQS = np.fft.rfftfreq(NFFT, 1 / FS)\n", + "\n", + "\n", + "def spectrogram(X, denoise=0.0):\n", + " \"\"\"배치 전력 스펙트로그램 (클립, 주파수, 프레임). denoise>0이면 잡음 바닥(주파수별 하위 10%)을 그 배수만큼 빼는 스펙트럼 차감.\"\"\"\n", + " X = np.atleast_2d(X)\n", + " Xp = np.c_[X[:, :1], X[:, 1:] - 0.95 * X[:, :-1]] # 프리엠퍼시스\n", + " frames = np.lib.stride_tricks.sliding_window_view(Xp, NFFT, axis=1)[:, ::HOP] * np.hanning(NFFT)\n", + " P = np.abs(np.fft.rfft(frames, axis=-1)) ** 2\n", + " P = np.transpose(P, (0, 2, 1))\n", + " if denoise:\n", + " floor = np.percentile(P, 10, axis=2, keepdims=True)\n", + " P = np.maximum(P - denoise * floor, 0.05 * P)\n", + " return P\n", + "\n", + "\n", + "def mfcc(P, n=13):\n", + " logmel = np.log(np.einsum(\"mf,cft->cmt\", MEL, P) + 1e-8)\n", + " return dct(logmel, type=2, axis=1, norm=\"ortho\")[:, :n, :]\n", + "\n", + "\n", + "def band(P, lo, hi):\n", + " return P[:, (FREQS >= lo) & (FREQS < hi), :].sum(axis=(1, 2))\n", + "\n", + "\n", + "def jitter_hnr(X):\n", + " \"\"\"40ms 프레임 자기상관으로 피치 주기를 찾아 피치 흔들림(jitter)과 배음 대 잡음비(HNR 근사)를 구한다.\"\"\"\n", + " W, lag0, lag1 = 320, int(FS / 250), int(FS / 70)\n", + " fr = np.lib.stride_tricks.sliding_window_view(np.atleast_2d(X), W, axis=1)[:, ::160]\n", + " fr = fr - fr.mean(-1, keepdims=True)\n", + " spec = np.fft.rfft(fr, 2 * W, axis=-1)\n", + " ac = np.fft.irfft(np.abs(spec) ** 2, axis=-1)[..., :W]\n", + " ac = ac / (ac[..., :1] + 1e-12)\n", + " seg = ac[..., lag0:lag1]\n", + " k = seg.argmax(-1)\n", + " peak = seg.max(-1)\n", + " km, kp = np.take_along_axis(seg, np.clip(k - 1, 0, None)[..., None], -1)[..., 0], np.take_along_axis(seg, np.clip(k + 1, None, seg.shape[-1] - 1)[..., None], -1)[..., 0]\n", + " lag = k + lag0 + 0.5 * (km - kp) / (km - 2 * peak + kp - 1e-12) # 포물선 보간으로 주기 정밀화\n", + " f0 = FS / lag\n", + " jit = np.median(np.abs(np.diff(f0, axis=1)) / f0[:, 1:], axis=1)\n", + " return jit, np.median(peak, axis=1)\n", + "\n", + "\n", + "def frame_mod(X):\n", + " \"\"\"진폭 포락선(2.5ms 간격)의 50Hz 성분 비율: 20ms 프레임 경계 흔적(합성 단서).\"\"\"\n", + " env = np.abs(np.atleast_2d(X)).reshape(len(np.atleast_2d(X)), -1, 20).mean(-1)\n", + " sp = np.abs(np.fft.rfft(env - env.mean(1, keepdims=True), axis=1)) ** 2\n", + " f = np.fft.rfftfreq(env.shape[1], 20 / FS)\n", + " return sp[:, (f > 47) & (f < 53)].sum(1) / (sp[:, (f > 30) & (f < 70)].sum(1) + 1e-12)\n", + "\n", + "\n", + "FEAT_NAMES = [f\"mfcc{i}_m\" for i in range(13)] + [f\"mfcc{i}_s\" for i in range(13)] + \\\n", + " [\"delta_s\", \"hf_ratio\", \"lf_ratio\", \"flatness\", \"flux\", \"jitter\", \"hnr\", \"frame_mod\"]\n", + "\n", + "\n", + "def extract(X, denoise=0.0):\n", + " \"\"\"클립 배치 -> 특징 행렬 (클립 x 34) DataFrame.\"\"\"\n", + " P = spectrogram(X, denoise)\n", + " C = mfcc(P)\n", + " tot = band(P, 300, 3400) + 1e-12\n", + " flat = np.exp(np.log(P[:, (FREQS >= 300) & (FREQS < 3400)] + 1e-10).mean(1)) / (P[:, (FREQS >= 300) & (FREQS < 3400)].mean(1) + 1e-10)\n", + " logP = np.log(P + 1e-8)\n", + " jit, hnr = jitter_hnr(X)\n", + " F = np.c_[C.mean(2), C.std(2), np.abs(np.diff(C, axis=2)).mean((1, 2)), band(P, 2400, 3400) / tot, band(P, 300, 500) / tot,\n", + " flat.mean(1), np.abs(np.diff(logP, axis=2)).mean((1, 2)), jit, hnr, frame_mod(X)]\n", + " return pd.DataFrame(F, columns=FEAT_NAMES)\n", + "\n", + "\n", + "def enroll(seed=99):\n", + " \"\"\"지휘관·통신소 화자 6명의 등록 견본(깨끗한 진짜 클립 5개씩의 MFCC 평균).\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " tpl = {}\n", + " for spk in SPEAKERS:\n", + " Xe = np.stack([radio_channel(make_clip(\"bonafide\", spk, rng), rng, snr_db=25) for _ in range(5)])\n", + " tpl[spk] = extract(Xe)[[f\"mfcc{i}_m\" for i in range(1, 13)]].mean().values\n", + " return tpl\n", + "\n", + "\n", + "def speaker_sim(F, claimed, tpl):\n", + " \"\"\"주장 화자 견본과 MFCC 평균 벡터의 코사인 유사도(화자 검증 점수).\"\"\"\n", + " v = F[[f\"mfcc{i}_m\" for i in range(1, 13)]].values\n", + " t = np.stack([tpl[c] for c in claimed])\n", + " return (v * t).sum(1) / (np.linalg.norm(v, axis=1) * np.linalg.norm(t, axis=1) + 1e-12)\n", + "\n", + "tpl = enroll()\n", + "\n", + "\n", + "def channel_quality(X):\n", + " \"\"\"채널 품질 지표: 추정 SNR(유효 프레임 에너지 상하위 비, dB), 단일톤 재밍 지수(주파수별 시간 평균/표준편차의 최대값), 끊김 비율(무음 10ms 비율).\"\"\"\n", + " P = spectrogram(X)\n", + " e = P.sum(1)\n", + " e = np.where(e > 1e-9, e, np.nan)\n", + " snr_est = 10 * np.log10(np.nanpercentile(e, 90, axis=1) / (np.nanpercentile(e, 10, axis=1) + 1e-12))\n", + " Pb = P[:, (FREQS >= 300) & (FREQS < 3400)]\n", + " tone = (Pb.mean(2) / (Pb.std(2) + 1e-12)).max(1) # 시간에 따라 일정한 주파수 성분(재밍 톤)일수록 큼\n", + " fr = np.abs(np.atleast_2d(X)).reshape(len(X), -1, 80).max(-1)\n", + " drop = (fr < 1e-6).mean(1)\n", + " return pd.DataFrame({\"snr_est\": snr_est, \"tone_idx\": tone, \"drop_ratio\": drop})\n", + "\n", + "\n", + "def featurize(X, M, quality=False):\n", + " F = extract(X)\n", + " F[\"spk_sim\"] = speaker_sim(F, M[\"claimed\"], tpl)\n", + " return pd.concat([F, channel_quality(X)], axis=1) if quality else F\n", + "\n", + "\n", + "def mixed(n, seed, labels=LABELS):\n", + " \"\"\"[교육용가상자료] 평시·저SNR·충격 잡음·끊김·재밍 채널이 섞인 데이터셋.\"\"\"\n", + " conds = [dict(snr=(10, 25)), dict(snr=(3, 8)), dict(snr=(8, 15), impulse=0.002), dict(snr=(8, 15), dropout=0.4), dict(snr=(8, 15), jam_tone=0.5)]\n", + " parts = [make_dataset(n // 5, seed=seed + i, labels=labels, **c) for i, c in enumerate(conds)]\n", + " return np.vstack([p[0] for p in parts]), pd.concat([p[1] for p in parts], ignore_index=True)\n", + "\n", + "\n", + "# 기존(미션 4 방식): 양호 채널 위주 학습 + 임계값 0.5\n", + "Xa, Ma = make_dataset(600, seed=31, snr=(5, 25))\n", + "old = make_pipeline(StandardScaler(), MLPClassifier((64, 32), alpha=1e-3, max_iter=600, random_state=0)).fit(featurize(Xa, Ma), Ma[\"spoof\"])\n", + "# 개선: 채널 조건 혼합 학습 + 채널 품질 특징 + 로지스틱 보정(Platt)으로 점수 확률화\n", + "Xb, Mb = mixed(800, seed=40)\n", + "Fb = featurize(Xb, Mb, quality=True)\n", + "new = make_pipeline(StandardScaler(), MLPClassifier((64, 32), alpha=1e-3, max_iter=600, random_state=0)).fit(Fb, Mb[\"spoof\"])\n", + "Xc, Mc = mixed(300, seed=60)\n", + "cal = LogisticRegression().fit(new.predict_proba(featurize(Xc, Mc, True))[:, [1]], Mc[\"spoof\"])\n", + "\n", + "\n", + "def confidence(X, M):\n", + " \"\"\"위조 확률 p와 진위 신뢰도(1-p), 등급: High=위조 의심 높음(즉시 경보), Med=확인 필요(HITL), Low=진짜 가능성 높음.\"\"\"\n", + " Fq = featurize(X, M, quality=True)\n", + " p = cal.predict_proba(new.predict_proba(Fq)[:, [1]])[:, 1]\n", + " poor = (Fq[\"snr_est\"] < 3.3) | (Fq[\"tone_idx\"] > 2.5) | (Fq[\"drop_ratio\"] > 0.05)\n", + " grade = np.where(p >= 0.8, \"High\", np.where(p >= 0.4, \"Med\", \"Low\"))\n", + " grade = np.where((grade == \"High\") & poor & (p < 0.95), \"Med\", grade) # 품질 불량 채널은 한 단계 낮춰 사람 확인\n", + " return p, grade, poor\n", + "\n", + "\n", + "# [교육용가상자료] 평가: 혼합 채널 500클립\n", + "Xt, Mt = mixed(500, seed=80)\n", + "p_old = old.predict_proba(featurize(Xt, Mt))[:, 1]\n", + "p_new, g_new, poor = confidence(Xt, Mt)\n", + "y, b = Mt[\"spoof\"].values, ~Mt[\"spoof\"].values\n", + "far_old, far_new = (p_old[b] >= 0.5).mean(), (g_new[b] == \"High\").mean()\n", + "red = 1 - far_new / far_old\n", + "print(f\"평가 클립 {len(Xt)}개(진짜 {b.sum()} / 위조 {y.sum()}), 품질 불량 채널 {poor.mean():.0%}\")\n", + "print(f\"오경보율(진짜를 즉시 경보): 기존 {far_old:.1%} -> 개선 {far_new:.1%}, 감축 {red:.0%} (목표 40% 이상: {'달성' if red >= 0.4 else '미달'})\")\n", + "print(f\"위조 탐지(High+Med 등급): 기존 {(p_old[y] >= 0.5).mean():.1%} -> 개선 {(g_new[y] != 'Low').mean():.1%}\")\n", + "print(f\"ROC-AUC: 기존 {roc_auc_score(y, p_old):.3f}, 개선 {roc_auc_score(y, p_new):.3f}\")\n", + "\n", + "print(\"\\n[등급 분포] 등급 진짜 위조\")\n", + "for g in [\"High\", \"Med\", \"Low\"]:\n", + " print(f\" {g:5s} {(g_new[b] == g).sum():5d} {(g_new[y] == g).sum():5d}\")\n", + "\n", + "print(\"\\n[임계값 민감도(High 기준)] 임계값 오경보율 위조 High 비율\")\n", + "for th in [0.6, 0.7, 0.8, 0.9]:\n", + " print(f\" {th:.1f} {(p_new[b] >= th).mean():6.1%} {(p_new[y] >= th).mean():6.1%}\")\n", + "\n", + "# 일관성: 같은 원신호를 다른 잡음 실현 5회로 통과시켰을 때 등급이 같은 비율\n", + "rng = np.random.default_rng(90)\n", + "same = []\n", + "for i in range(60):\n", + " lab = LABELS[i % 4]; spk = \"SPK_A\" if lab != \"bonafide\" else str(rng.choice(list(SPEAKERS)))\n", + " x = make_clip(lab, spk, rng); s = rng.uniform(10, 25)\n", + " Xr = np.stack([radio_channel(x, np.random.default_rng(1000 + i * 10 + r), snr_db=s) for r in range(5)])\n", + " _, g, _ = confidence(Xr, pd.DataFrame({\"claimed\": [spk] * 5}))\n", + " same.append(pd.Series(g).value_counts().iloc[0] / 5)\n", + "cons = float(np.mean(same))\n", + "print(f\"\\n신뢰도 등급 일관성(동일 음성 x 잡음 실현 5회, 60건): {cons:.1%} (목표 90% 이상: {'달성' if cons >= 0.9 else '미달'})\")\n", + "print(\"등급 운용: High=통신보안관 즉시 경보 / Med=HITL 2차 확인 / Low=정상 처리(로그 보존). 등급은 판단 보조이며 진위 확정은 사람이 한다.\")\n", + "\n", + "fo, to, _ = roc_curve(y, p_old); fn, tn, _ = roc_curve(y, p_new)\n", + "plt.figure(figsize=(5, 4)); plt.plot(fo, to, label=\"baseline\"); plt.plot(fn, tn, label=\"calibrated + channel quality\")\n", + "plt.plot([0, 1], [0, 1], \"k:\"); plt.xlabel(\"false alarm rate\"); plt.ylabel(\"recall\"); plt.legend(); plt.title(\"ROC (synthetic mixed channels)\")\n", + "plt.tight_layout(); plt.show()\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 05 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + "평가 클립 500개(진짜 254 / 위조 246), 품질 불량 채널 56%\n", + "오경보율(진짜를 즉시 경보): 기존 27.6% -> 개선 0.0%, 감축 100% (목표 40% 이상: 달성)\n", + "위조 탐지(High+Med 등급): 기존 98.4% -> 개선 95.9%\n", + "ROC-AUC: 기존 0.822, 개선 0.995\n", + "\n", + "[등급 분포] 등급 진짜 위조\n", + " High 0 109\n", + " Med 7 127\n", + " Low 247 10\n", + "\n", + "[임계값 민감도(High 기준)] 임계값 오경보율 위조 High 비율\n", + " 0.6 2.4% 95.1%\n", + " 0.7 2.0% 93.9%\n", + " 0.8 2.0% 93.5%\n", + " 0.9 1.6% 91.5%\n", + "\n", + "신뢰도 등급 일관성(동일 음성 x 잡음 실현 5회, 60건): 96.7% (목표 90% 이상: 달성)\n", + "등급 운용: High=통신보안관 즉시 경보 / Med=HITL 2차 확인 / Low=정상 처리(로그 보존). 등급은 판단 보조이며 진위 확정은 사람이 한다.\n", + "```\n", + "
\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 06. 실시간 무전 음성 위조 경보 및 스펙트로그램 시각화 C2 보안 인터페이스 구현\n", + "**소요시간**: 약 2.5시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "실시간 음성 스푸핑 경보 및 음향 스펙트로그램 가시화 C2 보안 모니터링 UI를 구현한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **모의 무전 송신 스트림을 받아 위조 점수와 등급을 실시간 갱신하는 야간 모드 C2 음성 보안 상황판을 구현한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **송신 1건마다 스펙트로그램, 점수 추이, 등급 집계, 경보 목록 4개 패널을 갱신하고 High 경보는 빨간 테두리로 강조 수행하고 검증하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `src/ui/c204_dashboard.py (또는 웹 UI 컴포넌트)` 형태의 결과물을 완성한다.\n", + "\n", + "### 🔍 평가 및 검증 기준 (PASS 조건)\n", + "#### 미션 판정\n", + "\n", + "**평가 구분:** 필수\n", + "\n", + "**핵심 평가영역:** 전술 작전 상황판으로서의 직관성 및 인지 부하 감소도\n", + "\n", + "#### 미션 PASS 조건\n", + "\n", + "- 정량 기준: 화면 갱신 주기 1초 이내, UI 응답성 100ms 이내\n", + "\n", + "\n", + "- 진행 기준: 주요 지표 시각화 컴포넌트 4종 이상 렌더링 및 실시간 데이터 연동\n", + "\n", + "\n", + "- 검증 방법: 사용자 인터랙션 테스트 및 모의 데이터 스트림 렌더링 검증\n", + "\n", + "\n", + "#### 채점자 판정 메모\n", + "\n", + "> 단순 코드 실행 여부뿐만 아니라 출력된 수치와 근거 자료가 군 실무 요구사항에 부합하는지 면밀히 확인한다.\n", + "\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "\"\"\"모의 무전 송신 스트림을 1초 주기로 받아 위조 점수·등급을 갱신하고, 스펙트로그램·점수 추이·등급 집계·경보 목록 4개 패널로 보여 주는 야간 모드 C2 음성 보안 상황판을 구현해 화면 갱신 시간과 클립 선택 응답 시간을 측정한다.\"\"\"\n", + "import time\n", + "import numpy as np\n", + "import pandas as pd\n", + "import matplotlib.pyplot as plt\n", + "from scipy import signal\n", + "from scipy.fft import dct\n", + "from sklearn.linear_model import LogisticRegression\n", + "from sklearn.preprocessing import StandardScaler\n", + "from sklearn.pipeline import make_pipeline\n", + "\n", + "# ---------------------------------------------------------------------------\n", + "# [교육용가상자료] 탐지 학습용 합성 신호 생성기 (실제 음성·화자·통신망 정보 없음)\n", + "# 사람 목소리를 만드는 기능이 아니라, 배음(하모닉) 신호에 '진짜 신호의 자연스러운 흔들림'과\n", + "# '알려진 위조 흔적(문헌상 탐지 단서)'의 통계적 특징을 표시해 탐지기를 학습시키기 위한 추상 신호다.\n", + "# bonafide : 자연스러운 피치 흔들림(jitter)·진폭 흔들림(shimmer)·숨소리 잡음\n", + "# tts_art : 흔들림이 거의 없고 고주파 에너지가 부족하며 20ms 프레임 경계 흔적이 있는 신호\n", + "# vc_art : 프레임마다 배음 위상이 끊기고 스펙트럼 포락선이 뭉개진 신호\n", + "# replay : 스피커 재생 대역 제한 + 실내 잔향이 더해진 신호\n", + "# ---------------------------------------------------------------------------\n", + "FS, DUR = 8000, 0.5\n", + "N = int(FS * DUR)\n", + "LABELS = [\"bonafide\", \"tts_art\", \"vc_art\", \"replay\"]\n", + "SPEAKERS = {f\"SPK_{c}\": (f0, fm) for c, f0, fm in [(\"A\", 115, (650, 1150, 2450)), (\"B\", 135, (700, 1220, 2600)), (\"C\", 98, (600, 1050, 2350)),\n", + " (\"D\", 160, (720, 1300, 2700)), (\"E\", 125, (680, 1180, 2500)), (\"F\", 105, (630, 1100, 2400))]}\n", + "\n", + "\n", + "def _source(rng, f0, fm, jitter, shimmer, phase_break=False, hf_cut=None, frame_mod=0.0):\n", + " t = np.arange(N) / FS\n", + " contour = f0 * (1 + 0.06 * np.sin(2 * np.pi * rng.uniform(1.5, 3.5) * t + rng.uniform(0, 6)))\n", + " f = contour * (1 + jitter * rng.normal(0, 1, N // 80 + 1).repeat(80)[:N]) # 10ms 단위 피치 흔들림(jitter)\n", + " phase = 2 * np.pi * np.cumsum(f) / FS\n", + " K = int(3800 / f0)\n", + " fk = np.arange(1, K + 1) * f0\n", + " amp = sum(np.exp(-((fk - F) / 120.0) ** 2) / (i + 1) for i, F in enumerate(fm)) + 0.02 / np.arange(1, K + 1)\n", + " if hf_cut:\n", + " amp = np.where(fk > hf_cut, amp * 0.2, amp)\n", + " ph = np.arange(1, K + 1)[:, None] * phase[None, :]\n", + " if phase_break: # 20ms 프레임마다 배음별 위상 불연속\n", + " ph = ph + rng.uniform(0, 2 * np.pi, (K, N // 160 + 1)).repeat(160, axis=1)[:, :N]\n", + " sig = (amp[:, None] * np.sin(ph)).sum(0)\n", + " env = 0.55 + 0.45 * np.sin(2 * np.pi * rng.uniform(3, 5) * t + rng.uniform(0, 6)) ** 2 # 음절형 진폭 변화\n", + " env *= 1 + shimmer * np.interp(t, np.linspace(0, DUR, 60), rng.normal(0, 1, 60)) # 완만한 진폭 흔들림(shimmer)\n", + " if frame_mod:\n", + " env *= 1 + frame_mod * np.sign(np.sin(2 * np.pi * 50 * t)) # 20ms 프레임 경계 흔적(50Hz)\n", + " return sig * env + (0.03 if jitter > 0.003 else 0.005) * rng.normal(0, 1, N) * env\n", + "\n", + "\n", + "def make_clip(label, speaker, rng):\n", + " f0, fm = SPEAKERS[speaker]\n", + " if label in (\"tts_art\", \"vc_art\"): # 위조는 화자 특성을 근사만 함\n", + " f0 = f0 * rng.uniform(0.92, 1.08); fm = tuple(F * rng.uniform(0.95, 1.05) for F in fm)\n", + " if label == \"bonafide\" or label == \"replay\":\n", + " x = _source(rng, f0, fm, jitter=rng.uniform(0.008, 0.02), shimmer=rng.uniform(0.05, 0.12))\n", + " elif label == \"tts_art\":\n", + " x = _source(rng, f0, fm, jitter=rng.uniform(0.0, 0.002), shimmer=0.01, hf_cut=rng.uniform(2300, 2800), frame_mod=rng.uniform(0.08, 0.2))\n", + " else:\n", + " x = _source(rng, f0, fm, jitter=rng.uniform(0.006, 0.015), shimmer=rng.uniform(0.04, 0.1), phase_break=True)\n", + " if label == \"replay\": # 스피커 대역 + 실내 잔향\n", + " x = signal.sosfilt(signal.butter(4, [450, 2900], \"bandpass\", fs=FS, output=\"sos\"), x)\n", + " ir = rng.normal(0, 1, int(0.08 * FS)) * np.exp(-np.arange(int(0.08 * FS)) / (0.02 * FS)); ir[0] = 3.0\n", + " x = signal.fftconvolve(x, ir)[:N]\n", + " return x / (np.abs(x).max() + 1e-9)\n", + "\n", + "\n", + "RADIO_BP = signal.butter(4, [300, 3400], \"bandpass\", fs=FS, output=\"sos\")\n", + "\n", + "\n", + "def radio_channel(x, rng, snr_db=15.0, impulse=0.0, dropout=0.0, clip=1.0, jam_tone=0.0):\n", + " \"\"\"[교육용가상자료] 무전 채널 모형: 300~3400Hz 대역 제한, 배경 잡음(SNR), 충격성 잡음, 신호 끊김, 포화(클리핑), 단일톤 재밍.\"\"\"\n", + " y = signal.sosfilt(RADIO_BP, x)\n", + " p = np.mean(y ** 2)\n", + " noise = rng.normal(0, 1, N) * np.sqrt(p / 10 ** (snr_db / 10))\n", + " noise += np.cumsum(rng.normal(0, 0.02, N)) * np.sqrt(p) * 0.05 # 저주파 차량·엔진형 소음\n", + " y = y + noise\n", + " if impulse:\n", + " k = rng.random(N) < impulse\n", + " y[k] += rng.normal(0, 6 * np.sqrt(p), k.sum())\n", + " if jam_tone:\n", + " y += jam_tone * np.sqrt(p) * np.sin(2 * np.pi * rng.uniform(800, 2000) * np.arange(N) / FS)\n", + " if dropout:\n", + " for _ in range(int(dropout * 10)):\n", + " a = int(rng.integers(0, N - 400)); y[a:a + int(rng.integers(100, 400))] = 0.0\n", + " return np.clip(y / (np.abs(y).max() + 1e-9), -clip, clip)\n", + "\n", + "\n", + "def make_dataset(n=400, seed=7, snr=(5, 25), labels=LABELS, p=None, claim=\"SPK_A\", **chan):\n", + " \"\"\"클립 n개: 진짜는 여러 화자, 위조는 모두 지휘관 화자(SPK_A)를 사칭. 반환: (신호 배열, 메타 DataFrame).\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " X, meta = np.zeros((n, N)), []\n", + " if p is None and len(labels) > 1 and labels[0] == \"bonafide\": # 기본: 진짜 50%, 나머지 위조 유형 균등\n", + " p = [0.5] + [0.5 / (len(labels) - 1)] * (len(labels) - 1)\n", + " for i in range(n):\n", + " lab = str(rng.choice(labels, p=p))\n", + " spk = str(rng.choice(list(SPEAKERS))) if lab == \"bonafide\" else claim\n", + " if lab == \"replay\":\n", + " spk = claim\n", + " s = float(rng.uniform(*snr)) if isinstance(snr, tuple) else float(snr)\n", + " X[i] = radio_channel(make_clip(lab, spk, rng), rng, snr_db=s, **chan)\n", + " meta.append({\"clip\": f\"C{seed:02d}{i:04d}\", \"label\": lab, \"spoof\": lab != \"bonafide\", \"claimed\": spk if lab == \"bonafide\" else claim,\n", + " \"true_spk\": spk if lab == \"bonafide\" else \"unknown\", \"snr_db\": round(s, 1)})\n", + " return X, pd.DataFrame(meta)\n", + "# ---------------------------------------------------------------------------\n", + "\n", + "# ---- 전처리·음향 특징(MFCC/스펙트로그램) 공통 함수: 여러 클립을 한 번에 계산 ----\n", + "NFFT, HOP = 256, 80\n", + "\n", + "\n", + "def mel_bank(n_mels=26, fmin=300, fmax=3400):\n", + " mel = lambda f: 2595 * np.log10(1 + f / 700)\n", + " imel = lambda m: 700 * (10 ** (m / 2595) - 1)\n", + " pts = imel(np.linspace(mel(fmin), mel(fmax), n_mels + 2))\n", + " freqs = np.fft.rfftfreq(NFFT, 1 / FS)\n", + " fb = np.zeros((n_mels, len(freqs)))\n", + " for m in range(n_mels):\n", + " l, c, r = pts[m], pts[m + 1], pts[m + 2]\n", + " fb[m] = np.clip(np.minimum((freqs - l) / (c - l), (r - freqs) / (r - c)), 0, None)\n", + " return fb\n", + "\n", + "\n", + "MEL = mel_bank()\n", + "FREQS = np.fft.rfftfreq(NFFT, 1 / FS)\n", + "\n", + "\n", + "def spectrogram(X, denoise=0.0):\n", + " \"\"\"배치 전력 스펙트로그램 (클립, 주파수, 프레임). denoise>0이면 잡음 바닥(주파수별 하위 10%)을 그 배수만큼 빼는 스펙트럼 차감.\"\"\"\n", + " X = np.atleast_2d(X)\n", + " Xp = np.c_[X[:, :1], X[:, 1:] - 0.95 * X[:, :-1]] # 프리엠퍼시스\n", + " frames = np.lib.stride_tricks.sliding_window_view(Xp, NFFT, axis=1)[:, ::HOP] * np.hanning(NFFT)\n", + " P = np.abs(np.fft.rfft(frames, axis=-1)) ** 2\n", + " P = np.transpose(P, (0, 2, 1))\n", + " if denoise:\n", + " floor = np.percentile(P, 10, axis=2, keepdims=True)\n", + " P = np.maximum(P - denoise * floor, 0.05 * P)\n", + " return P\n", + "\n", + "\n", + "def mfcc(P, n=13):\n", + " logmel = np.log(np.einsum(\"mf,cft->cmt\", MEL, P) + 1e-8)\n", + " return dct(logmel, type=2, axis=1, norm=\"ortho\")[:, :n, :]\n", + "\n", + "\n", + "def band(P, lo, hi):\n", + " return P[:, (FREQS >= lo) & (FREQS < hi), :].sum(axis=(1, 2))\n", + "\n", + "\n", + "def jitter_hnr(X):\n", + " \"\"\"40ms 프레임 자기상관으로 피치 주기를 찾아 피치 흔들림(jitter)과 배음 대 잡음비(HNR 근사)를 구한다.\"\"\"\n", + " W, lag0, lag1 = 320, int(FS / 250), int(FS / 70)\n", + " fr = np.lib.stride_tricks.sliding_window_view(np.atleast_2d(X), W, axis=1)[:, ::160]\n", + " fr = fr - fr.mean(-1, keepdims=True)\n", + " spec = np.fft.rfft(fr, 2 * W, axis=-1)\n", + " ac = np.fft.irfft(np.abs(spec) ** 2, axis=-1)[..., :W]\n", + " ac = ac / (ac[..., :1] + 1e-12)\n", + " seg = ac[..., lag0:lag1]\n", + " k = seg.argmax(-1)\n", + " peak = seg.max(-1)\n", + " km, kp = np.take_along_axis(seg, np.clip(k - 1, 0, None)[..., None], -1)[..., 0], np.take_along_axis(seg, np.clip(k + 1, None, seg.shape[-1] - 1)[..., None], -1)[..., 0]\n", + " lag = k + lag0 + 0.5 * (km - kp) / (km - 2 * peak + kp - 1e-12) # 포물선 보간으로 주기 정밀화\n", + " f0 = FS / lag\n", + " jit = np.median(np.abs(np.diff(f0, axis=1)) / f0[:, 1:], axis=1)\n", + " return jit, np.median(peak, axis=1)\n", + "\n", + "\n", + "def frame_mod(X):\n", + " \"\"\"진폭 포락선(2.5ms 간격)의 50Hz 성분 비율: 20ms 프레임 경계 흔적(합성 단서).\"\"\"\n", + " env = np.abs(np.atleast_2d(X)).reshape(len(np.atleast_2d(X)), -1, 20).mean(-1)\n", + " sp = np.abs(np.fft.rfft(env - env.mean(1, keepdims=True), axis=1)) ** 2\n", + " f = np.fft.rfftfreq(env.shape[1], 20 / FS)\n", + " return sp[:, (f > 47) & (f < 53)].sum(1) / (sp[:, (f > 30) & (f < 70)].sum(1) + 1e-12)\n", + "\n", + "\n", + "FEAT_NAMES = [f\"mfcc{i}_m\" for i in range(13)] + [f\"mfcc{i}_s\" for i in range(13)] + \\\n", + " [\"delta_s\", \"hf_ratio\", \"lf_ratio\", \"flatness\", \"flux\", \"jitter\", \"hnr\", \"frame_mod\"]\n", + "\n", + "\n", + "def extract(X, denoise=0.0):\n", + " \"\"\"클립 배치 -> 특징 행렬 (클립 x 34) DataFrame.\"\"\"\n", + " P = spectrogram(X, denoise)\n", + " C = mfcc(P)\n", + " tot = band(P, 300, 3400) + 1e-12\n", + " flat = np.exp(np.log(P[:, (FREQS >= 300) & (FREQS < 3400)] + 1e-10).mean(1)) / (P[:, (FREQS >= 300) & (FREQS < 3400)].mean(1) + 1e-10)\n", + " logP = np.log(P + 1e-8)\n", + " jit, hnr = jitter_hnr(X)\n", + " F = np.c_[C.mean(2), C.std(2), np.abs(np.diff(C, axis=2)).mean((1, 2)), band(P, 2400, 3400) / tot, band(P, 300, 500) / tot,\n", + " flat.mean(1), np.abs(np.diff(logP, axis=2)).mean((1, 2)), jit, hnr, frame_mod(X)]\n", + " return pd.DataFrame(F, columns=FEAT_NAMES)\n", + "\n", + "\n", + "def enroll(seed=99):\n", + " \"\"\"지휘관·통신소 화자 6명의 등록 견본(깨끗한 진짜 클립 5개씩의 MFCC 평균).\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " tpl = {}\n", + " for spk in SPEAKERS:\n", + " Xe = np.stack([radio_channel(make_clip(\"bonafide\", spk, rng), rng, snr_db=25) for _ in range(5)])\n", + " tpl[spk] = extract(Xe)[[f\"mfcc{i}_m\" for i in range(1, 13)]].mean().values\n", + " return tpl\n", + "\n", + "\n", + "def speaker_sim(F, claimed, tpl):\n", + " \"\"\"주장 화자 견본과 MFCC 평균 벡터의 코사인 유사도(화자 검증 점수).\"\"\"\n", + " v = F[[f\"mfcc{i}_m\" for i in range(1, 13)]].values\n", + " t = np.stack([tpl[c] for c in claimed])\n", + " return (v * t).sum(1) / (np.linalg.norm(v, axis=1) * np.linalg.norm(t, axis=1) + 1e-12)\n", + "\n", + "tpl = enroll()\n", + "\n", + "\n", + "def featurize(X, M):\n", + " F = extract(X)\n", + " F[\"spk_sim\"] = speaker_sim(F, M[\"claimed\"], tpl)\n", + " return F\n", + "\n", + "\n", + "Xtr, Mtr = make_dataset(400, seed=51, snr=(5, 25))\n", + "det = make_pipeline(StandardScaler(), LogisticRegression(max_iter=2000, C=0.5)).fit(featurize(Xtr, Mtr), Mtr[\"spoof\"])\n", + "\n", + "# [교육용가상자료] 모의 송신 스트림 24건(가상 망 NET-A~C, 가상 호출부호)\n", + "Xs, Ms = make_dataset(24, seed=52, snr=(6, 25), p=[0.6, 0.15, 0.15, 0.1])\n", + "Ms[\"net\"] = [f\"NET-{'ABC'[i % 3]}\" for i in range(24)]\n", + "Ms[\"t\"] = [f\"21:00:{i:02d}\" for i in range(24)]\n", + "state = pd.DataFrame(columns=[\"t\", \"net\", \"clip\", \"claimed\", \"score\", \"grade\"])\n", + "\n", + "plt.style.use(\"dark_background\") # 야간 작전 모드\n", + "fig, ax = plt.subplots(2, 2, figsize=(10, 6))\n", + "upd = []\n", + "for i in range(len(Xs)):\n", + " t0 = time.perf_counter()\n", + " s = float(det.predict_proba(featurize(Xs[i:i + 1], Ms.iloc[i:i + 1]))[0, 1])\n", + " g = \"High\" if s >= 0.8 else \"Med\" if s >= 0.4 else \"Low\"\n", + " state.loc[i] = [Ms[\"t\"][i], Ms[\"net\"][i], Ms[\"clip\"][i], Ms[\"claimed\"][i], round(s, 2), g]\n", + " for a in ax.flat:\n", + " a.clear()\n", + " P = spectrogram(Xs[i:i + 1])[0]\n", + " ax[0, 0].imshow(10 * np.log10(P + 1e-8), origin=\"lower\", aspect=\"auto\", cmap=\"magma\", extent=[0, DUR, 0, FS / 2])\n", + " ax[0, 0].set_title(f\"Spectrogram {Ms['clip'][i]} ({Ms['net'][i]})\"); ax[0, 0].set_xlabel(\"s\"); ax[0, 0].set_ylabel(\"Hz\")\n", + " col = state[\"grade\"].map({\"High\": \"red\", \"Med\": \"orange\", \"Low\": \"lime\"})\n", + " ax[0, 1].scatter(range(len(state)), state[\"score\"], c=col); ax[0, 1].axhline(0.8, c=\"red\", ls=\"--\"); ax[0, 1].axhline(0.4, c=\"orange\", ls=\":\")\n", + " ax[0, 1].set_ylim(0, 1); ax[0, 1].set_title(\"Spoof score timeline\"); ax[0, 1].set_xlabel(\"transmission #\")\n", + " cnt = state[\"grade\"].value_counts().reindex([\"High\", \"Med\", \"Low\"]).fillna(0)\n", + " ax[1, 0].bar(cnt.index, cnt.values, color=[\"red\", \"orange\", \"lime\"]); ax[1, 0].set_title(\"Grade count\")\n", + " al = state[state[\"grade\"] != \"Low\"].tail(6)\n", + " ax[1, 1].axis(\"off\"); ax[1, 1].set_title(\"Alert list (operator review)\")\n", + " for k, r in enumerate(al.itertuples()):\n", + " ax[1, 1].text(0.0, 0.9 - k * 0.15, f\"{r.t} {r.net} {r.clip} claim {r.claimed} score {r.score:.2f} {r.grade}\",\n", + " color=\"red\" if r.grade == \"High\" else \"orange\", fontsize=8, family=\"monospace\")\n", + " if g == \"High\": # 긴급 경보 하이라이트\n", + " for a in ax.flat:\n", + " for sp in a.spines.values():\n", + " sp.set_color(\"red\"); sp.set_linewidth(2)\n", + " fig.canvas.draw()\n", + " upd.append((time.perf_counter() - t0) * 1000)\n", + "\n", + "# 클립 선택 응답성: 경보 목록에서 한 건을 고르면 상세 정보(단서 상위 3개)를 찾아 보여 주는 콜백\n", + "coef = pd.Series(det[-1].coef_[0], index=featurize(Xs[:1], Ms.iloc[:1]).columns)\n", + "Fall = featurize(Xs, Ms)\n", + "Z = pd.DataFrame(det[0].transform(Fall), columns=Fall.columns)\n", + "\n", + "\n", + "def on_select(idx):\n", + " contrib = (Z.loc[idx] * coef).sort_values(ascending=False).head(3)\n", + " return {\"clip\": Ms[\"clip\"][idx], \"grade\": state[\"grade\"][idx], \"top_cues\": [f\"{k}({v:+.2f})\" for k, v in contrib.items()]}\n", + "\n", + "\n", + "resp = []\n", + "for idx in state.index[state[\"grade\"] != \"Low\"]:\n", + " t0 = time.perf_counter(); info = on_select(idx); resp.append((time.perf_counter() - t0) * 1000)\n", + "\n", + "print(\"[실시간 C2 음성 보안 상황판 - 교육용가상자료]\")\n", + "print(state.tail(8).to_string(index=False))\n", + "print(f\"\\n패널 4종(스펙트로그램, 점수 추이, 등급 집계, 경보 목록) 렌더링: {len(upd)}회 갱신\")\n", + "print(f\"화면 갱신 시간 최대 {max(upd):.0f} ms, 평균 {np.mean(upd):.0f} ms (목표 1초 이내: {'달성' if max(upd) < 1000 else '미달'})\")\n", + "print(f\"클립 선택 응답 최대 {max(resp):.1f} ms (목표 100ms 이내: {'달성' if max(resp) < 100 else '미달'})\")\n", + "print(f\"경보 {len(resp)}건 중 High {(state['grade'] == 'High').sum()}건(빨강 테두리 하이라이트), Med {(state['grade'] == 'Med').sum()}건\")\n", + "print(\"선택 예시:\", info)\n", + "print(\"실제 위조 여부(정답) 대비:\", f\"위조 {Ms['spoof'].sum()}건 중 경보 {(state['grade'] != 'Low')[Ms['spoof']].sum()}건, 진짜 {(~Ms['spoof']).sum()}건 중 경보 {(state['grade'] != 'Low')[~Ms['spoof']].sum()}건\")\n", + "print(\"상황판 경보는 판단 보조이며, 위조 확정과 전파는 통신보안관 확인(미션 7 절차) 후에 한다.\")\n", + "plt.tight_layout(); plt.show()\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 06 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + "[실시간 C2 음성 보안 상황판 - 교육용가상자료]\n", + " t net clip claimed score grade\n", + "21:00:16 NET-B C520016 SPK_F 0.01 Low\n", + "21:00:17 NET-C C520017 SPK_A 1.00 High\n", + "21:00:18 NET-A C520018 SPK_D 0.00 Low\n", + "21:00:19 NET-B C520019 SPK_F 0.10 Low\n", + "21:00:20 NET-C C520020 SPK_A 1.00 High\n", + "21:00:21 NET-A C520021 SPK_A 0.91 High\n", + "21:00:22 NET-B C520022 SPK_A 1.00 High\n", + "21:00:23 NET-C C520023 SPK_D 0.00 Low\n", + "\n", + "패널 4종(스펙트로그램, 점수 추이, 등급 집계, 경보 목록) 렌더링: 24회 갱신\n", + "화면 갱신 시간 최대 334 ms, 평균 211 ms (목표 1초 이내: 달성)\n", + "클립 선택 응답 최대 0.8 ms (목표 100ms 이내: 달성)\n", + "경보 12건 중 High 12건(빨강 테두리 하이라이트), Med 0건\n", + "선택 예시: {'clip': 'C520022', 'grade': 'High', 'top_cues': ['mfcc0_m(+2.59)', 'hnr(+1.73)', 'delta_s(+1.60)']}\n", + "실제 위조 여부(정답) 대비: 위조 12건 중 경보 12건, 진짜 12건 중 경보 0건\n", + "상황판 경보는 판단 보조이며, 위조 확정과 전파는 통신보안관 확인(미션 7 절차) 후에 한다.\n", + "```\n", + "
\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 07. 지휘관 긴급 음성 명령 수신 시 통신관 HITL 2차 육안/청음 검증 절차 설계\n", + "**소요시간**: 약 3.0시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "지휘관 긴급 음성 명령 수신 시 통신보안관이 2차 확인 및 암호 인증을 수행하는 HITL 절차를 설계한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **지휘관 명의 긴급 음성 명령이 들어오면 통신보안관이 인증 질의응답과 청음으로 2차 확인한 뒤에만 전파하는 HITL 검증 절차를 설계한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **긴급 명령은 탐지 점수와 무관하게 검증 대기열에 올리고 등급·점수 순으로 검토 순서를 정 수행하고 검증하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `src/hitl/c204_approval_workflow.py 및 docs/c204_hitl_guide.md` 형태의 결과물을 완성한다.\n", + "\n", + "### 🔍 평가 및 검증 기준 (PASS 조건)\n", + "#### 미션 판정\n", + "\n", + "**평가 구분:** 필수\n", + "\n", + "**핵심 평가영역:** 지휘 책임 분계선 명확화 및 성급한 자동 단정 방지 체계 부합성\n", + "\n", + "#### 미션 PASS 조건\n", + "\n", + "- 정량 기준: 인간 검토 큐(Queue) 대기시간 및 승인/반려 처리 로깅 성공률 100%\n", + "\n", + "\n", + "- 진행 기준: 승인/반려 인터페이스 동작 및 감사 로그(Audit Log) 생성 검증\n", + "\n", + "\n", + "- 검증 방법: 모의 조작자 승인/반려 시뮬레이션 및 데이터 무결성 검증\n", + "\n", + "\n", + "#### 채점자 판정 메모\n", + "\n", + "> 단순 코드 실행 여부뿐만 아니라 출력된 수치와 근거 자료가 군 실무 요구사항에 부합하는지 면밀히 확인한다.\n", + "\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "\"\"\"지휘관 명의 긴급 음성 명령이 수신되면 탐지 점수와 무관하게 통신보안관 2차 검증 대기열에 올리고, 가상 인증 질의응답(챌린지-응답)·청음 확인·근거 기록을 거쳐야만 전파되도록 하는 HITL 검증 절차와 해시 체인 감사 로그를 구현한다.\"\"\"\n", + "import hashlib\n", + "import hmac\n", + "import json\n", + "import pandas as pd\n", + "\n", + "ACTIONS = {\"CONFIRM\": \"진위 확인(명령 전파 허용)\", \"HOLD\": \"보류(재송신·추가 확인 요청)\", \"REJECT\": \"위조 의심 차단(상급 보고)\"}\n", + "PRIORITY = {\"High\": 0, \"Med\": 1, \"Low\": 2}\n", + "HOLD_LIMIT_S = 120\n", + "AUTH_KEY = b\"EDU-VIRTUAL-KEY\" # [교육용가상자료] 실제 인증 체계·암호 아님\n", + "\n", + "\n", + "class WorkflowError(ValueError):\n", + " pass\n", + "\n", + "\n", + "def expected_response(challenge, net):\n", + " \"\"\"가상 챌린지-응답: 질의어와 망 ID로 4자리 응답을 만든다(실제 암호 체계를 흉내 낸 학습용 모형).\"\"\"\n", + " return hmac.new(AUTH_KEY, f\"{net}:{challenge}\".encode(), hashlib.sha256).hexdigest()[:4].upper()\n", + "\n", + "\n", + "class VerifyQueue:\n", + " def __init__(self, clock):\n", + " self.items, self.log, self.clock, self.feedback = {}, [], clock, []\n", + "\n", + " def _record(self, body):\n", + " prev = self.log[-1][\"hash\"] if self.log else \"GENESIS\"\n", + " body = {**body, \"ts\": round(self.clock(), 1)}\n", + " h = hashlib.sha256((prev + json.dumps(body, ensure_ascii=False, sort_keys=True)).encode()).hexdigest()\n", + " self.log.append({**body, \"prev\": prev, \"hash\": h})\n", + "\n", + " def verify_log(self):\n", + " prev = \"GENESIS\"\n", + " for n, rec in enumerate(self.log, start=1):\n", + " body = {k: v for k, v in rec.items() if k not in (\"prev\", \"hash\")}\n", + " if rec[\"prev\"] != prev or rec[\"hash\"] != hashlib.sha256((prev + json.dumps(body, ensure_ascii=False, sort_keys=True)).encode()).hexdigest():\n", + " return False, n\n", + " prev = rec[\"hash\"]\n", + " return True, -1\n", + "\n", + " def submit(self, mid, net, claimed, score, grade, order):\n", + " \"\"\"긴급 명령 등록. 점수가 낮아도(진짜로 보여도) 자동 전파하지 않고 PENDING으로 시작한다.\"\"\"\n", + " self.items[mid] = {\"id\": mid, \"net\": net, \"claimed\": claimed, \"score\": score, \"grade\": grade, \"order\": order,\n", + " \"status\": \"PENDING\", \"submitted\": self.clock(), \"hold_since\": None, \"escalated\": False, \"auth\": None}\n", + " self._record({\"event\": \"SUBMIT\", \"id\": mid, \"score\": score, \"grade\": grade, \"by\": \"DETECTOR\"})\n", + "\n", + " def pending(self):\n", + " todo = [i for i in self.items.values() if i[\"status\"] in (\"PENDING\", \"HOLD\")]\n", + " return sorted(todo, key=lambda i: (not i[\"escalated\"], PRIORITY[i[\"grade\"]], -i[\"score\"], i[\"submitted\"]))\n", + "\n", + " def authenticate(self, mid, challenge, response, operator):\n", + " \"\"\"송신자에게 역호출로 가상 질의어를 보내고 받은 응답을 대조한다. 결과는 기록만 하고 결정은 사람이 한다.\"\"\"\n", + " ok = hmac.compare_digest(expected_response(challenge, self.items[mid][\"net\"]), response)\n", + " self.items[mid][\"auth\"] = ok\n", + " self._record({\"event\": \"AUTH\", \"id\": mid, \"challenge\": challenge, \"result\": ok, \"operator\": operator})\n", + " return ok\n", + "\n", + " def decide(self, mid, action, operator, reason, listened=False, second=None):\n", + " if action not in ACTIONS:\n", + " raise WorkflowError(f\"허용되지 않은 조치: {action}\")\n", + " if not operator:\n", + " raise WorkflowError(\"통신보안관 ID 필요\")\n", + " it = self.items.get(mid)\n", + " if it is None or it[\"status\"] in (\"CONFIRMED\", \"REJECTED\"):\n", + " raise WorkflowError(\"대기열에 없거나 이미 종결된 명령\")\n", + " if len(reason.strip()) < 5:\n", + " raise WorkflowError(\"판단 근거 5자 이상 필요\")\n", + " if action == \"CONFIRM\":\n", + " if not listened:\n", + " raise WorkflowError(\"전파 전 청음(직접 듣기) 확인 필요\")\n", + " if it[\"auth\"] is not True:\n", + " raise WorkflowError(\"인증 질의응답 통과 전 전파 불가\")\n", + " if it[\"grade\"] == \"High\" and (not second or second == operator):\n", + " raise WorkflowError(\"High 등급 확인은 다른 검토자 1명의 교차 확인 필요\")\n", + " it[\"status\"] = {\"CONFIRM\": \"CONFIRMED\", \"HOLD\": \"HOLD\", \"REJECT\": \"REJECTED\"}[action]\n", + " it[\"hold_since\"] = self.clock() if action == \"HOLD\" else None\n", + " it[\"decided\"] = self.clock()\n", + " self._record({\"event\": \"DECIDE\", \"id\": mid, \"action\": action, \"operator\": operator, \"second\": second, \"listened\": listened, \"reason\": reason})\n", + "\n", + " def check_holds(self):\n", + " \"\"\"보류 기한 초과 시 자동 확인하지 않고 상급 지휘관(당직 통제관)에게 상신한다.\"\"\"\n", + " out = []\n", + " for i in self.items.values():\n", + " if i[\"status\"] == \"HOLD\" and not i[\"escalated\"] and self.clock() - i[\"hold_since\"] > HOLD_LIMIT_S:\n", + " i[\"escalated\"] = True; out.append(i[\"id\"])\n", + " self._record({\"event\": \"ESCALATE\", \"id\": i[\"id\"], \"by\": \"SYSTEM\"})\n", + " return out\n", + "\n", + " def commander_feedback(self, mid, text):\n", + " self.feedback.append({\"id\": mid, \"text\": text})\n", + " self._record({\"event\": \"FEEDBACK\", \"id\": mid, \"text\": text})\n", + "\n", + "\n", + "class FakeClock:\n", + " t = 0.0\n", + "\n", + " def __call__(self):\n", + " return self.t\n", + "\n", + "\n", + "clock = FakeClock()\n", + "q = VerifyQueue(clock)\n", + "# [교육용가상자료] 미션 5 신뢰도 엔진에서 넘어온 가상 긴급 명령 6건(가상 망·가상 호출부호)\n", + "for mid, net, spk, s, g, od in [(\"U01\", \"NET-A\", \"SPK_A\", 0.97, \"High\", \"가상 집결지 변경\"), (\"U02\", \"NET-B\", \"SPK_A\", 0.62, \"Med\", \"통신 축선 변경\"),\n", + " (\"U03\", \"NET-A\", \"SPK_A\", 0.08, \"Low\", \"보고 주기 단축\"), (\"U04\", \"NET-C\", \"SPK_A\", 0.88, \"High\", \"가상 진지 이동\"),\n", + " (\"U05\", \"NET-B\", \"SPK_A\", 0.45, \"Med\", \"주파수 전환\"), (\"U06\", \"NET-C\", \"SPK_A\", 0.12, \"Low\", \"정시 보고 요청\")]:\n", + " clock.t += 5; q.submit(mid, net, spk, s, g, od)\n", + "print(\"검토 순서(등급·점수 순):\", [i[\"id\"] for i in q.pending()])\n", + "print(\"자동 전파(확인)된 명령 수:\", sum(i[\"status\"] == \"CONFIRMED\" for i in q.items.values()))\n", + "\n", + "# 규칙 위반 시도: 모두 차단되어야 한다\n", + "viol = [(\"U03\", \"CONFIRM\", \"sec01\", \"진짜 점수 낮음\", dict(listened=True)), # 인증 없이 전파\n", + " (\"U01\", \"CONFIRM\", \"\", \"근거 충분\", {}), (\"U02\", \"FORWARD\", \"sec01\", \"임의 조치\", {}),\n", + " (\"U06\", \"REJECT\", \"sec01\", \"의심\", {})]\n", + "blocked = 0\n", + "for mid, a, op, r, kw in viol:\n", + " try:\n", + " q.decide(mid, a, op, r, **kw)\n", + " except WorkflowError as e:\n", + " blocked += 1; print(f\"차단({mid} {a}): {e}\")\n", + "\n", + "# 정상 처리: 인증 질의응답 -> 청음 -> 결정\n", + "clock.t += 10; q.authenticate(\"U03\", \"ECHO\", expected_response(\"ECHO\", \"NET-A\"), \"sec01\")\n", + "q.decide(\"U03\", \"CONFIRM\", \"sec01\", \"역호출 인증 통과, 청음상 평소 송신자와 일치\", listened=True)\n", + "clock.t += 8; ok = q.authenticate(\"U01\", \"TANGO\", \"0000\", \"sec01\")\n", + "q.decide(\"U01\", \"REJECT\", \"sec01\", \"인증 응답 불일치, 프레임 경계 단서 다수\", listened=True)\n", + "clock.t += 6; q.authenticate(\"U04\", \"LIMA\", expected_response(\"LIMA\", \"NET-C\"), \"sec02\")\n", + "try:\n", + " q.decide(\"U04\", \"CONFIRM\", \"sec02\", \"인증 통과, 청음 정상\", listened=True)\n", + "except WorkflowError as e:\n", + " blocked += 1; print(f\"차단(U04 CONFIRM 단독): {e}\")\n", + "q.decide(\"U04\", \"CONFIRM\", \"sec02\", \"인증 통과, 청음 정상, 교차 확인 완료\", listened=True, second=\"sec01\")\n", + "clock.t += 4; q.decide(\"U02\", \"HOLD\", \"sec02\", \"재송신 요청 후 재청음\")\n", + "clock.t += 3; q.authenticate(\"U06\", \"MIKE\", expected_response(\"MIKE\", \"NET-C\"), \"sec02\")\n", + "q.decide(\"U06\", \"CONFIRM\", \"sec02\", \"역호출 인증 통과, 일상 보고 요청\", listened=True)\n", + "clock.t += 150\n", + "print(\"보류 기한 초과 상신:\", q.check_holds(), \"/ 상신 후 검토 순서:\", [i[\"id\"] for i in q.pending()])\n", + "q.commander_feedback(\"U01\", \"위조 차단 적절. 인증 질의어 교체 주기 단축 검토\")\n", + "\n", + "df = pd.DataFrame(q.items.values())\n", + "df[\"대기(s)\"] = (df[\"decided\"] - df[\"submitted\"]).round(1)\n", + "print(\"\\n\" + df[[\"id\", \"grade\", \"score\", \"auth\", \"status\", \"escalated\", \"대기(s)\"]].fillna(\"-\").to_string(index=False))\n", + "n_dec = sum(r[\"event\"] == \"DECIDE\" for r in q.log)\n", + "n_exp = sum(i[\"status\"] in (\"CONFIRMED\", \"REJECTED\", \"HOLD\") for i in q.items.values())\n", + "n_auto = sum(r[\"event\"] == \"DECIDE\" and not r[\"operator\"] for r in q.log)\n", + "print(f\"\\n자동 전파 {n_auto}건 / 규칙 위반 차단 {blocked}건 / 결정 로그 {n_dec}건(결정 {n_exp}건, 로깅 성공률 {n_dec / n_exp:.0%})\")\n", + "print(f\"평균 검토 대기 {df['대기(s)'].mean():.1f}s, 감사 로그 {len(q.log)}건, 해시 체인 검증: {q.verify_log()}\")\n", + "q.log[7][\"operator\"] = \"sec99\"\n", + "print(\"8번째 기록 변조 후 검증(False, 깨진 위치):\", q.verify_log())\n", + "print(\"\\n책임 분계: 탐지기=점수 제시, 통신보안관=인증·청음·결정, 지휘관=최종 명령 책임. 자동 판정으로 명령을 전파하거나 차단하지 않는다.\")\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 07 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + "검토 순서(등급·점수 순): ['U01', 'U04', 'U02', 'U05', 'U06', 'U03']\n", + "자동 전파(확인)된 명령 수: 0\n", + "차단(U03 CONFIRM): 인증 질의응답 통과 전 전파 불가\n", + "차단(U01 CONFIRM): 통신보안관 ID 필요\n", + "차단(U02 FORWARD): 허용되지 않은 조치: FORWARD\n", + "차단(U06 REJECT): 판단 근거 5자 이상 필요\n", + "차단(U04 CONFIRM 단독): High 등급 확인은 다른 검토자 1명의 교차 확인 필요\n", + "보류 기한 초과 상신: ['U02'] / 상신 후 검토 순서: ['U02', 'U05']\n", + "\n", + " id grade score auth status escalated 대기(s)\n", + "U01 High 0.97 False REJECTED False 43.0\n", + "U02 Med 0.62 - HOLD True 48.0\n", + "U03 Low 0.08 True CONFIRMED False 25.0\n", + "U04 High 0.88 True CONFIRMED False 34.0\n", + "U05 Med 0.45 - PENDING False -\n", + "U06 Low 0.12 True CONFIRMED False 31.0\n", + "\n", + "자동 전파 0건 / 규칙 위반 차단 5건 / 결정 로그 5건(결정 5건, 로깅 성공률 100%)\n", + "평균 검토 대기 36.2s, 감사 로그 17건, 해시 체인 검증: (True, -1)\n", + "8번째 기록 변조 후 검증(False, 깨진 위치): (False, 8)\n", + "\n", + "책임 분계: 탐지기=점수 제시, 통신보안관=인증·청음·결정, 지휘관=최종 명령 책임. 자동 판정으로 명령을 전파하거나 차단하지 않는다.\n", + "```\n", + "
\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 08. 전자전 재밍 및 잡음 혼입 환경 모의 딥페이크 탐지 한계치 스트레스 테스트\n", + "**소요시간**: 약 3.5시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "전자전 재밍 및 심한 잡음 혼입 환경을 모의하여 딥페이크 탐지 성능 한계치 스트레스 테스트를 수행한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **전자전 재밍과 심한 잡음 환경에서 위조 탐지 성능의 한계점을 찾고, 트래픽 급증 부하에서도 안전하게 동작하는지 시험한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **SNR과 단일톤 재밍 세기를 단계적으로 높이며 정확도·재현율·오경보율 곡선을 그려 판정 불가 구간을 정 수행하고 검증하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `tests/c204_stress_test.py 및 스트레스 테스트 분석 보고서` 형태의 결과물을 완성한다.\n", + "\n", + "### 🔍 평가 및 검증 기준 (PASS 조건)\n", + "#### 미션 판정\n", + "\n", + "**평가 구분:** 필수\n", + "\n", + "**핵심 평가영역:** 극단 상황에서의 안전 실패(Fail-safe) 및 기능 축소 작동 적절성\n", + "\n", + "#### 미션 PASS 조건\n", + "\n", + "- 정량 기준: 동시 트래픽 500% 증가 시 시스템 다운 0건, 데이터 유실률 0%\n", + "\n", + "\n", + "- 진행 기준: 대량 시뮬레이션 부하 주입 테스트 통과 및 병목 구간 프로파일링 완료\n", + "\n", + "\n", + "- 검증 방법: 부하 테스트 도구를 통한 처리량(Throughput) 및 메모리 누수 검증\n", + "\n", + "\n", + "#### 채점자 판정 메모\n", + "\n", + "> 단순 코드 실행 여부뿐만 아니라 출력된 수치와 근거 자료가 군 실무 요구사항에 부합하는지 면밀히 확인한다.\n", + "\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "\"\"\"전자전 재밍·잡음 세기를 단계적으로 높여 위조 탐지 성능의 한계점을 찾고, 무전 트래픽을 평시의 5배로 늘리며 손상 입력을 섞은 부하 시험에서 시스템 다운·데이터 유실 없이 기능 축소 모드와 사람 확인으로 안전하게 넘기는지 확인한다.\"\"\"\n", + "import time\n", + "import tracemalloc\n", + "from collections import deque\n", + "import numpy as np\n", + "import pandas as pd\n", + "import matplotlib.pyplot as plt\n", + "from scipy import signal\n", + "from scipy.fft import dct\n", + "from sklearn.linear_model import LogisticRegression\n", + "from sklearn.preprocessing import StandardScaler\n", + "from sklearn.pipeline import make_pipeline\n", + "\n", + "# ---------------------------------------------------------------------------\n", + "# [교육용가상자료] 탐지 학습용 합성 신호 생성기 (실제 음성·화자·통신망 정보 없음)\n", + "# 사람 목소리를 만드는 기능이 아니라, 배음(하모닉) 신호에 '진짜 신호의 자연스러운 흔들림'과\n", + "# '알려진 위조 흔적(문헌상 탐지 단서)'의 통계적 특징을 표시해 탐지기를 학습시키기 위한 추상 신호다.\n", + "# bonafide : 자연스러운 피치 흔들림(jitter)·진폭 흔들림(shimmer)·숨소리 잡음\n", + "# tts_art : 흔들림이 거의 없고 고주파 에너지가 부족하며 20ms 프레임 경계 흔적이 있는 신호\n", + "# vc_art : 프레임마다 배음 위상이 끊기고 스펙트럼 포락선이 뭉개진 신호\n", + "# replay : 스피커 재생 대역 제한 + 실내 잔향이 더해진 신호\n", + "# ---------------------------------------------------------------------------\n", + "FS, DUR = 8000, 0.5\n", + "N = int(FS * DUR)\n", + "LABELS = [\"bonafide\", \"tts_art\", \"vc_art\", \"replay\"]\n", + "SPEAKERS = {f\"SPK_{c}\": (f0, fm) for c, f0, fm in [(\"A\", 115, (650, 1150, 2450)), (\"B\", 135, (700, 1220, 2600)), (\"C\", 98, (600, 1050, 2350)),\n", + " (\"D\", 160, (720, 1300, 2700)), (\"E\", 125, (680, 1180, 2500)), (\"F\", 105, (630, 1100, 2400))]}\n", + "\n", + "\n", + "def _source(rng, f0, fm, jitter, shimmer, phase_break=False, hf_cut=None, frame_mod=0.0):\n", + " t = np.arange(N) / FS\n", + " contour = f0 * (1 + 0.06 * np.sin(2 * np.pi * rng.uniform(1.5, 3.5) * t + rng.uniform(0, 6)))\n", + " f = contour * (1 + jitter * rng.normal(0, 1, N // 80 + 1).repeat(80)[:N]) # 10ms 단위 피치 흔들림(jitter)\n", + " phase = 2 * np.pi * np.cumsum(f) / FS\n", + " K = int(3800 / f0)\n", + " fk = np.arange(1, K + 1) * f0\n", + " amp = sum(np.exp(-((fk - F) / 120.0) ** 2) / (i + 1) for i, F in enumerate(fm)) + 0.02 / np.arange(1, K + 1)\n", + " if hf_cut:\n", + " amp = np.where(fk > hf_cut, amp * 0.2, amp)\n", + " ph = np.arange(1, K + 1)[:, None] * phase[None, :]\n", + " if phase_break: # 20ms 프레임마다 배음별 위상 불연속\n", + " ph = ph + rng.uniform(0, 2 * np.pi, (K, N // 160 + 1)).repeat(160, axis=1)[:, :N]\n", + " sig = (amp[:, None] * np.sin(ph)).sum(0)\n", + " env = 0.55 + 0.45 * np.sin(2 * np.pi * rng.uniform(3, 5) * t + rng.uniform(0, 6)) ** 2 # 음절형 진폭 변화\n", + " env *= 1 + shimmer * np.interp(t, np.linspace(0, DUR, 60), rng.normal(0, 1, 60)) # 완만한 진폭 흔들림(shimmer)\n", + " if frame_mod:\n", + " env *= 1 + frame_mod * np.sign(np.sin(2 * np.pi * 50 * t)) # 20ms 프레임 경계 흔적(50Hz)\n", + " return sig * env + (0.03 if jitter > 0.003 else 0.005) * rng.normal(0, 1, N) * env\n", + "\n", + "\n", + "def make_clip(label, speaker, rng):\n", + " f0, fm = SPEAKERS[speaker]\n", + " if label in (\"tts_art\", \"vc_art\"): # 위조는 화자 특성을 근사만 함\n", + " f0 = f0 * rng.uniform(0.92, 1.08); fm = tuple(F * rng.uniform(0.95, 1.05) for F in fm)\n", + " if label == \"bonafide\" or label == \"replay\":\n", + " x = _source(rng, f0, fm, jitter=rng.uniform(0.008, 0.02), shimmer=rng.uniform(0.05, 0.12))\n", + " elif label == \"tts_art\":\n", + " x = _source(rng, f0, fm, jitter=rng.uniform(0.0, 0.002), shimmer=0.01, hf_cut=rng.uniform(2300, 2800), frame_mod=rng.uniform(0.08, 0.2))\n", + " else:\n", + " x = _source(rng, f0, fm, jitter=rng.uniform(0.006, 0.015), shimmer=rng.uniform(0.04, 0.1), phase_break=True)\n", + " if label == \"replay\": # 스피커 대역 + 실내 잔향\n", + " x = signal.sosfilt(signal.butter(4, [450, 2900], \"bandpass\", fs=FS, output=\"sos\"), x)\n", + " ir = rng.normal(0, 1, int(0.08 * FS)) * np.exp(-np.arange(int(0.08 * FS)) / (0.02 * FS)); ir[0] = 3.0\n", + " x = signal.fftconvolve(x, ir)[:N]\n", + " return x / (np.abs(x).max() + 1e-9)\n", + "\n", + "\n", + "RADIO_BP = signal.butter(4, [300, 3400], \"bandpass\", fs=FS, output=\"sos\")\n", + "\n", + "\n", + "def radio_channel(x, rng, snr_db=15.0, impulse=0.0, dropout=0.0, clip=1.0, jam_tone=0.0):\n", + " \"\"\"[교육용가상자료] 무전 채널 모형: 300~3400Hz 대역 제한, 배경 잡음(SNR), 충격성 잡음, 신호 끊김, 포화(클리핑), 단일톤 재밍.\"\"\"\n", + " y = signal.sosfilt(RADIO_BP, x)\n", + " p = np.mean(y ** 2)\n", + " noise = rng.normal(0, 1, N) * np.sqrt(p / 10 ** (snr_db / 10))\n", + " noise += np.cumsum(rng.normal(0, 0.02, N)) * np.sqrt(p) * 0.05 # 저주파 차량·엔진형 소음\n", + " y = y + noise\n", + " if impulse:\n", + " k = rng.random(N) < impulse\n", + " y[k] += rng.normal(0, 6 * np.sqrt(p), k.sum())\n", + " if jam_tone:\n", + " y += jam_tone * np.sqrt(p) * np.sin(2 * np.pi * rng.uniform(800, 2000) * np.arange(N) / FS)\n", + " if dropout:\n", + " for _ in range(int(dropout * 10)):\n", + " a = int(rng.integers(0, N - 400)); y[a:a + int(rng.integers(100, 400))] = 0.0\n", + " return np.clip(y / (np.abs(y).max() + 1e-9), -clip, clip)\n", + "\n", + "\n", + "def make_dataset(n=400, seed=7, snr=(5, 25), labels=LABELS, p=None, claim=\"SPK_A\", **chan):\n", + " \"\"\"클립 n개: 진짜는 여러 화자, 위조는 모두 지휘관 화자(SPK_A)를 사칭. 반환: (신호 배열, 메타 DataFrame).\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " X, meta = np.zeros((n, N)), []\n", + " if p is None and len(labels) > 1 and labels[0] == \"bonafide\": # 기본: 진짜 50%, 나머지 위조 유형 균등\n", + " p = [0.5] + [0.5 / (len(labels) - 1)] * (len(labels) - 1)\n", + " for i in range(n):\n", + " lab = str(rng.choice(labels, p=p))\n", + " spk = str(rng.choice(list(SPEAKERS))) if lab == \"bonafide\" else claim\n", + " if lab == \"replay\":\n", + " spk = claim\n", + " s = float(rng.uniform(*snr)) if isinstance(snr, tuple) else float(snr)\n", + " X[i] = radio_channel(make_clip(lab, spk, rng), rng, snr_db=s, **chan)\n", + " meta.append({\"clip\": f\"C{seed:02d}{i:04d}\", \"label\": lab, \"spoof\": lab != \"bonafide\", \"claimed\": spk if lab == \"bonafide\" else claim,\n", + " \"true_spk\": spk if lab == \"bonafide\" else \"unknown\", \"snr_db\": round(s, 1)})\n", + " return X, pd.DataFrame(meta)\n", + "# ---------------------------------------------------------------------------\n", + "\n", + "# ---- 전처리·음향 특징(MFCC/스펙트로그램) 공통 함수: 여러 클립을 한 번에 계산 ----\n", + "NFFT, HOP = 256, 80\n", + "\n", + "\n", + "def mel_bank(n_mels=26, fmin=300, fmax=3400):\n", + " mel = lambda f: 2595 * np.log10(1 + f / 700)\n", + " imel = lambda m: 700 * (10 ** (m / 2595) - 1)\n", + " pts = imel(np.linspace(mel(fmin), mel(fmax), n_mels + 2))\n", + " freqs = np.fft.rfftfreq(NFFT, 1 / FS)\n", + " fb = np.zeros((n_mels, len(freqs)))\n", + " for m in range(n_mels):\n", + " l, c, r = pts[m], pts[m + 1], pts[m + 2]\n", + " fb[m] = np.clip(np.minimum((freqs - l) / (c - l), (r - freqs) / (r - c)), 0, None)\n", + " return fb\n", + "\n", + "\n", + "MEL = mel_bank()\n", + "FREQS = np.fft.rfftfreq(NFFT, 1 / FS)\n", + "\n", + "\n", + "def spectrogram(X, denoise=0.0):\n", + " \"\"\"배치 전력 스펙트로그램 (클립, 주파수, 프레임). denoise>0이면 잡음 바닥(주파수별 하위 10%)을 그 배수만큼 빼는 스펙트럼 차감.\"\"\"\n", + " X = np.atleast_2d(X)\n", + " Xp = np.c_[X[:, :1], X[:, 1:] - 0.95 * X[:, :-1]] # 프리엠퍼시스\n", + " frames = np.lib.stride_tricks.sliding_window_view(Xp, NFFT, axis=1)[:, ::HOP] * np.hanning(NFFT)\n", + " P = np.abs(np.fft.rfft(frames, axis=-1)) ** 2\n", + " P = np.transpose(P, (0, 2, 1))\n", + " if denoise:\n", + " floor = np.percentile(P, 10, axis=2, keepdims=True)\n", + " P = np.maximum(P - denoise * floor, 0.05 * P)\n", + " return P\n", + "\n", + "\n", + "def mfcc(P, n=13):\n", + " logmel = np.log(np.einsum(\"mf,cft->cmt\", MEL, P) + 1e-8)\n", + " return dct(logmel, type=2, axis=1, norm=\"ortho\")[:, :n, :]\n", + "\n", + "\n", + "def band(P, lo, hi):\n", + " return P[:, (FREQS >= lo) & (FREQS < hi), :].sum(axis=(1, 2))\n", + "\n", + "\n", + "def jitter_hnr(X):\n", + " \"\"\"40ms 프레임 자기상관으로 피치 주기를 찾아 피치 흔들림(jitter)과 배음 대 잡음비(HNR 근사)를 구한다.\"\"\"\n", + " W, lag0, lag1 = 320, int(FS / 250), int(FS / 70)\n", + " fr = np.lib.stride_tricks.sliding_window_view(np.atleast_2d(X), W, axis=1)[:, ::160]\n", + " fr = fr - fr.mean(-1, keepdims=True)\n", + " spec = np.fft.rfft(fr, 2 * W, axis=-1)\n", + " ac = np.fft.irfft(np.abs(spec) ** 2, axis=-1)[..., :W]\n", + " ac = ac / (ac[..., :1] + 1e-12)\n", + " seg = ac[..., lag0:lag1]\n", + " k = seg.argmax(-1)\n", + " peak = seg.max(-1)\n", + " km, kp = np.take_along_axis(seg, np.clip(k - 1, 0, None)[..., None], -1)[..., 0], np.take_along_axis(seg, np.clip(k + 1, None, seg.shape[-1] - 1)[..., None], -1)[..., 0]\n", + " lag = k + lag0 + 0.5 * (km - kp) / (km - 2 * peak + kp - 1e-12) # 포물선 보간으로 주기 정밀화\n", + " f0 = FS / lag\n", + " jit = np.median(np.abs(np.diff(f0, axis=1)) / f0[:, 1:], axis=1)\n", + " return jit, np.median(peak, axis=1)\n", + "\n", + "\n", + "def frame_mod(X):\n", + " \"\"\"진폭 포락선(2.5ms 간격)의 50Hz 성분 비율: 20ms 프레임 경계 흔적(합성 단서).\"\"\"\n", + " env = np.abs(np.atleast_2d(X)).reshape(len(np.atleast_2d(X)), -1, 20).mean(-1)\n", + " sp = np.abs(np.fft.rfft(env - env.mean(1, keepdims=True), axis=1)) ** 2\n", + " f = np.fft.rfftfreq(env.shape[1], 20 / FS)\n", + " return sp[:, (f > 47) & (f < 53)].sum(1) / (sp[:, (f > 30) & (f < 70)].sum(1) + 1e-12)\n", + "\n", + "\n", + "FEAT_NAMES = [f\"mfcc{i}_m\" for i in range(13)] + [f\"mfcc{i}_s\" for i in range(13)] + \\\n", + " [\"delta_s\", \"hf_ratio\", \"lf_ratio\", \"flatness\", \"flux\", \"jitter\", \"hnr\", \"frame_mod\"]\n", + "\n", + "\n", + "def extract(X, denoise=0.0):\n", + " \"\"\"클립 배치 -> 특징 행렬 (클립 x 34) DataFrame.\"\"\"\n", + " P = spectrogram(X, denoise)\n", + " C = mfcc(P)\n", + " tot = band(P, 300, 3400) + 1e-12\n", + " flat = np.exp(np.log(P[:, (FREQS >= 300) & (FREQS < 3400)] + 1e-10).mean(1)) / (P[:, (FREQS >= 300) & (FREQS < 3400)].mean(1) + 1e-10)\n", + " logP = np.log(P + 1e-8)\n", + " jit, hnr = jitter_hnr(X)\n", + " F = np.c_[C.mean(2), C.std(2), np.abs(np.diff(C, axis=2)).mean((1, 2)), band(P, 2400, 3400) / tot, band(P, 300, 500) / tot,\n", + " flat.mean(1), np.abs(np.diff(logP, axis=2)).mean((1, 2)), jit, hnr, frame_mod(X)]\n", + " return pd.DataFrame(F, columns=FEAT_NAMES)\n", + "\n", + "\n", + "def enroll(seed=99):\n", + " \"\"\"지휘관·통신소 화자 6명의 등록 견본(깨끗한 진짜 클립 5개씩의 MFCC 평균).\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " tpl = {}\n", + " for spk in SPEAKERS:\n", + " Xe = np.stack([radio_channel(make_clip(\"bonafide\", spk, rng), rng, snr_db=25) for _ in range(5)])\n", + " tpl[spk] = extract(Xe)[[f\"mfcc{i}_m\" for i in range(1, 13)]].mean().values\n", + " return tpl\n", + "\n", + "\n", + "def speaker_sim(F, claimed, tpl):\n", + " \"\"\"주장 화자 견본과 MFCC 평균 벡터의 코사인 유사도(화자 검증 점수).\"\"\"\n", + " v = F[[f\"mfcc{i}_m\" for i in range(1, 13)]].values\n", + " t = np.stack([tpl[c] for c in claimed])\n", + " return (v * t).sum(1) / (np.linalg.norm(v, axis=1) * np.linalg.norm(t, axis=1) + 1e-12)\n", + "\n", + "tpl = enroll()\n", + "\n", + "\n", + "def featurize(X, M):\n", + " F = extract(X)\n", + " F[\"spk_sim\"] = speaker_sim(F, M[\"claimed\"], tpl)\n", + " return F\n", + "\n", + "\n", + "Xtr, Mtr = make_dataset(500, seed=61, snr=(3, 25))\n", + "det = make_pipeline(StandardScaler(), LogisticRegression(max_iter=3000, C=0.5)).fit(featurize(Xtr, Mtr), Mtr[\"spoof\"])\n", + "\n", + "# 1) 탐지 한계치: 잡음(SNR)·재밍 세기 단계별 정확도\n", + "print(\"[탐지 한계치 - 교육용가상자료, 단계별 120클립]\")\n", + "print(\" 조건 정확도 위조재현율 오경보율\")\n", + "curve = []\n", + "for name, ch in [(\"SNR 20dB\", dict(snr=20.0)), (\"SNR 10dB\", dict(snr=10.0)), (\"SNR 5dB\", dict(snr=5.0)), (\"SNR 0dB\", dict(snr=0.0)),\n", + " (\"SNR -5dB\", dict(snr=-5.0)), (\"재밍 0.5\", dict(snr=12.0, jam_tone=0.5)), (\"재밍 1.0\", dict(snr=12.0, jam_tone=1.0)),\n", + " (\"재밍 2.0\", dict(snr=12.0, jam_tone=2.0))]:\n", + " X, M = make_dataset(120, seed=700 + len(curve), **ch)\n", + " pr = det.predict(featurize(X, M)); y = M[\"spoof\"].values\n", + " curve.append((name, (pr == y).mean(), pr[y].mean(), pr[~y].mean()))\n", + " print(f\" {name:12s} {curve[-1][1]:7.1%} {curve[-1][2]:9.1%} {curve[-1][3]:9.1%}\")\n", + "lim = [c[0] for c in curve if c[1] < 0.8]\n", + "print(\"정확도 80% 미만 구간(한계):\", lim if lim else \"없음\", \"-> 이 구간은 자동 점수 대신 '판정 불가(사람 확인)'로 처리\")\n", + "\n", + "# 2) 부하 시험: 평시 초당 20건 -> 500% 증가(초당 100건), 손상 입력 3% 혼입\n", + "pool, pmeta = make_dataset(200, seed=790, snr=(3, 25))\n", + "rng = np.random.default_rng(800)\n", + "\n", + "\n", + "def arrivals(n):\n", + " idx = rng.integers(0, len(pool), n)\n", + " xs = [pool[i] + rng.normal(0, 0.02, N) for i in idx]\n", + " for k in np.flatnonzero(rng.random(n) < 0.03): # 손상 입력 주입\n", + " xs[k] = [np.full(N, np.nan), np.zeros(10), np.zeros(N)][k % 3]\n", + " return xs, pmeta.iloc[idx].reset_index(drop=True)\n", + "\n", + "\n", + "def valid(x):\n", + " return len(x) == N and np.isfinite(x).all() and np.abs(x).max() > 1e-6\n", + "\n", + "\n", + "def run(rate, ticks=8, budget_s=1.0, backlog_lim=150):\n", + " \"\"\"1초 단위로 rate건 도착. 처리 시간이 예산을 넘거나 대기열이 길면 기능 축소 모드(화자 유사도 생략, 점수만 산출 후 사람 확인 표시).\"\"\"\n", + " q, done, quarantine, degraded, crash = deque(), 0, 0, 0, 0\n", + " lat, t_feat, t_pred = [], 0.0, 0.0\n", + " total_in = 0\n", + " tracemalloc.start(); m0 = tracemalloc.get_traced_memory()[0]\n", + " for _ in range(ticks):\n", + " xs, ms = arrivals(rate); total_in += rate\n", + " q.extend(zip(xs, ms.to_dict(\"records\")))\n", + " t_start = time.perf_counter()\n", + " while q and time.perf_counter() - t_start < budget_s:\n", + " batch = [q.popleft() for _ in range(min(25, len(q)))]\n", + " ok = [b for b in batch if valid(b[0])]\n", + " quarantine += len(batch) - len(ok) # 손상 입력은 버리지 않고 격리 보관\n", + " if not ok:\n", + " continue\n", + " try:\n", + " Xb = np.stack([b[0] for b in ok]); Mb = pd.DataFrame([b[1] for b in ok])\n", + " t0 = time.perf_counter(); F = extract(Xb)\n", + " if len(q) > backlog_lim:\n", + " F[\"spk_sim\"] = 0.9; degraded += len(ok) # 기능 축소: 화자 검증 생략 -> 결과는 '사람 확인' 표시\n", + " else:\n", + " F[\"spk_sim\"] = speaker_sim(F, Mb[\"claimed\"], tpl)\n", + " t1 = time.perf_counter(); det.predict_proba(F); t2 = time.perf_counter()\n", + " t_feat += t1 - t0; t_pred += t2 - t1; lat.append((t2 - t0) * 1000 / len(ok)); done += len(ok)\n", + " except Exception:\n", + " crash += 1; q.extendleft(reversed(batch)) # 실패 배치는 대기열로 되돌림(유실 방지)\n", + " break\n", + " while q: # 시험 종료 후 잔여 대기열 소진\n", + " b = q.popleft()\n", + " if valid(b[0]):\n", + " F = extract(b[0][None]); F[\"spk_sim\"] = speaker_sim(F, [b[1][\"claimed\"]], tpl); det.predict_proba(F); done += 1\n", + " else:\n", + " quarantine += 1\n", + " mem = (tracemalloc.get_traced_memory()[0] - m0) / 1e6; tracemalloc.stop()\n", + " return dict(rate=rate, total=total_in, done=done, quarantine=quarantine, lost=total_in - done - quarantine, crash=crash,\n", + " degraded=degraded, ms=np.mean(lat), mem=mem, feat=t_feat, pred=t_pred)\n", + "\n", + "\n", + "print(\"\\n[부하 시험] 도착률 입력 처리 격리 유실 다운 기능축소 ms/건 메모리증가MB\")\n", + "res = [run(20), run(100), run(100, budget_s=0.02)]\n", + "for r, nm in zip(res, [\"평시\", \"5배\", \"5배+처리지연\"]):\n", + " print(f\"{nm:9s} {r['rate']:4d}/s {r['total']:5d} {r['done']:5d} {r['quarantine']:5d} {r['lost']:5d} {r['crash']:5d} {r['degraded']:8d} {r['ms']:6.2f} {r['mem']:10.2f}\")\n", + "hi = res[1]\n", + "print(f\"트래픽 500% 증가: 시스템 다운 {hi['crash']}건, 데이터 유실 {hi['lost'] / hi['total']:.1%} (목표 0건/0%: {'달성' if hi['crash'] == 0 and hi['lost'] == 0 else '미달'})\")\n", + "Xp = pool[:200]; Pp = spectrogram(Xp); prof = {}\n", + "for nm, fn in [(\"스펙트로그램\", lambda: spectrogram(Xp)), (\"MFCC\", lambda: mfcc(Pp)), (\"피치 자기상관(jitter/HNR)\", lambda: jitter_hnr(Xp)), (\"프레임 경계 단서\", lambda: frame_mod(Xp))]:\n", + " t0 = time.perf_counter(); fn(); prof[nm] = time.perf_counter() - t0\n", + "tot = sum(prof.values())\n", + "print(f\"처리 시간 구성(100/s): 특징 추출 {hi['feat'] / (hi['feat'] + hi['pred']):.0%}, 추론 {hi['pred'] / (hi['feat'] + hi['pred']):.0%}\")\n", + "print(\"특징 추출 내부 프로파일:\", \", \".join(f\"{k} {v / tot:.0%}\" for k, v in prof.items()), f\"-> 병목: {max(prof, key=prof.get)}\")\n", + "dg = res[2]\n", + "print(f\"처리 지연 모의: 대기열 적체 시 {dg['degraded']}건을 기능 축소 모드로 처리, 유실 {dg['lost']}건, 다운 {dg['crash']}건\")\n", + "print(\"기능 축소 결과와 판정 불가 구간 결과는 자동 차단·전파 없이 통신보안관 확인 대기열로 보낸다(Fail-safe).\")\n", + "\n", + "plt.figure(figsize=(7, 3.5))\n", + "lab = [c[0].replace(\"재밍\", \"jam\") for c in curve]\n", + "plt.plot(lab, [c[1] for c in curve], \"o-\", label=\"accuracy\")\n", + "plt.plot(lab, [c[3] for c in curve], \"s--\", label=\"false alarm\")\n", + "plt.axhline(0.8, c=\"k\", ls=\":\"); plt.xticks(rotation=30); plt.legend(); plt.title(\"Detection limit under noise / jamming (synthetic)\")\n", + "plt.tight_layout(); plt.show()\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 08 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + "[탐지 한계치 - 교육용가상자료, 단계별 120클립]\n", + " 조건 정확도 위조재현율 오경보율\n", + " SNR 20dB 99.2% 98.4% 0.0%\n", + " SNR 10dB 96.7% 96.8% 3.4%\n", + " SNR 5dB 97.5% 98.1% 3.0%\n", + " SNR 0dB 66.7% 100.0% 66.7%\n", + " SNR -5dB 48.3% 100.0% 98.4%\n", + " 재밍 0.5 95.8% 98.1% 6.1%\n", + " 재밍 1.0 90.0% 96.8% 17.2%\n", + " 재밍 2.0 85.8% 89.7% 19.2%\n", + "정확도 80% 미만 구간(한계): ['SNR 0dB', 'SNR -5dB'] -> 이 구간은 자동 점수 대신 '판정 불가(사람 확인)'로 처리\n", + "\n", + "[부하 시험] 도착률 입력 처리 격리 유실 다운 기능축소 ms/건 메모리증가MB\n", + "평시 20/s 160 150 10 0 0 0 1.29 1.23\n", + "5배 100/s 800 774 26 0 0 0 1.19 4.02\n", + "5배+처리지연 100/s 800 781 19 0 0 146 1.09 4.87\n", + "트래픽 500% 증가: 시스템 다운 0건, 데이터 유실 0.0% (목표 0건/0%: 달성)\n", + "처리 시간 구성(100/s): 특징 추출 85%, 추론 15%\n", + "특징 추출 내부 프로파일: 스펙트로그램 26%, MFCC 17%, 피치 자기상관(jitter/HNR) 54%, 프레임 경계 단서 3% -> 병목: 피치 자기상관(jitter/HNR)\n", + "처리 지연 모의: 대기열 적체 시 146건을 기능 축소 모드로 처리, 유실 0건, 다운 0건\n", + "기능 축소 결과와 판정 불가 구간 결과는 자동 차단·전파 없이 통신보안관 확인 대기열로 보낸다(Fail-safe).\n", + "```\n", + "
\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 09. AAR 기반 무전 위조 오판정 음성 분석 및 음향 특징 추출 파라미터 재보정\n", + "**소요시간**: 약 3.0시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "AAR 사후검토를 통해 위조 음성 오판정 사례를 분석하고 모델 파라미터를 재보정한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **1차 배치 Baseline의 모의 운용 기록에서 오판정 사례를 분석(AAR)하고, 특징 추출·판정 파라미터를 재보정한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **오판정을 미탐과 오경보로 나누어 채널 조건·위조 유형별로 집계하고 원인을 규명 수행하고 검증하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `docs/c204_aar_report.md 및 configs/c204_tuned_params.yaml` 형태의 결과물을 완성한다.\n", + "\n", + "### 🔍 평가 및 검증 기준 (PASS 조건)\n", + "#### 미션 판정\n", + "\n", + "**평가 구분:** 필수\n", + "\n", + "**핵심 평가영역:** 오류 원인 규명의 객관성 및 개선 조치의 논리적 타당성\n", + "\n", + "#### 미션 PASS 조건\n", + "\n", + "- 정량 기준: AAR 피드백 적용 후 종합 정확도 10%p 이상 향상, 재발 오류 0건\n", + "\n", + "\n", + "- 진행 기준: AAR 보고서 작성 및 파라미터 튜닝 전후 회귀 테스트 통과\n", + "\n", + "\n", + "- 검증 방법: 회귀 테스트(Regression Test) 스위트 전체 자동 실행 및 비교\n", + "\n", + "\n", + "#### 채점자 판정 메모\n", + "\n", + "> 단순 코드 실행 여부뿐만 아니라 출력된 수치와 근거 자료가 군 실무 요구사항에 부합하는지 면밀히 확인한다.\n", + "\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "\"\"\"1차 배치(규칙 기반 Baseline)의 모의 운용 기록에서 위조 오판정 사례를 유형·채널 조건별로 분석(AAR)하고, 오판정 사례 재학습·채널 품질 특징 추가·전처리 차감 배수와 판정 임계값 재탐색으로 파라미터를 재보정한 뒤 회귀 시험으로 정확도 향상과 재발 오류를 확인한다.\"\"\"\n", + "import numpy as np\n", + "import pandas as pd\n", + "import matplotlib.pyplot as plt\n", + "from scipy import signal\n", + "from scipy.fft import dct\n", + "from sklearn.neural_network import MLPClassifier\n", + "from sklearn.preprocessing import StandardScaler\n", + "from sklearn.pipeline import make_pipeline\n", + "\n", + "# ---------------------------------------------------------------------------\n", + "# [교육용가상자료] 탐지 학습용 합성 신호 생성기 (실제 음성·화자·통신망 정보 없음)\n", + "# 사람 목소리를 만드는 기능이 아니라, 배음(하모닉) 신호에 '진짜 신호의 자연스러운 흔들림'과\n", + "# '알려진 위조 흔적(문헌상 탐지 단서)'의 통계적 특징을 표시해 탐지기를 학습시키기 위한 추상 신호다.\n", + "# bonafide : 자연스러운 피치 흔들림(jitter)·진폭 흔들림(shimmer)·숨소리 잡음\n", + "# tts_art : 흔들림이 거의 없고 고주파 에너지가 부족하며 20ms 프레임 경계 흔적이 있는 신호\n", + "# vc_art : 프레임마다 배음 위상이 끊기고 스펙트럼 포락선이 뭉개진 신호\n", + "# replay : 스피커 재생 대역 제한 + 실내 잔향이 더해진 신호\n", + "# ---------------------------------------------------------------------------\n", + "FS, DUR = 8000, 0.5\n", + "N = int(FS * DUR)\n", + "LABELS = [\"bonafide\", \"tts_art\", \"vc_art\", \"replay\"]\n", + "SPEAKERS = {f\"SPK_{c}\": (f0, fm) for c, f0, fm in [(\"A\", 115, (650, 1150, 2450)), (\"B\", 135, (700, 1220, 2600)), (\"C\", 98, (600, 1050, 2350)),\n", + " (\"D\", 160, (720, 1300, 2700)), (\"E\", 125, (680, 1180, 2500)), (\"F\", 105, (630, 1100, 2400))]}\n", + "\n", + "\n", + "def _source(rng, f0, fm, jitter, shimmer, phase_break=False, hf_cut=None, frame_mod=0.0):\n", + " t = np.arange(N) / FS\n", + " contour = f0 * (1 + 0.06 * np.sin(2 * np.pi * rng.uniform(1.5, 3.5) * t + rng.uniform(0, 6)))\n", + " f = contour * (1 + jitter * rng.normal(0, 1, N // 80 + 1).repeat(80)[:N]) # 10ms 단위 피치 흔들림(jitter)\n", + " phase = 2 * np.pi * np.cumsum(f) / FS\n", + " K = int(3800 / f0)\n", + " fk = np.arange(1, K + 1) * f0\n", + " amp = sum(np.exp(-((fk - F) / 120.0) ** 2) / (i + 1) for i, F in enumerate(fm)) + 0.02 / np.arange(1, K + 1)\n", + " if hf_cut:\n", + " amp = np.where(fk > hf_cut, amp * 0.2, amp)\n", + " ph = np.arange(1, K + 1)[:, None] * phase[None, :]\n", + " if phase_break: # 20ms 프레임마다 배음별 위상 불연속\n", + " ph = ph + rng.uniform(0, 2 * np.pi, (K, N // 160 + 1)).repeat(160, axis=1)[:, :N]\n", + " sig = (amp[:, None] * np.sin(ph)).sum(0)\n", + " env = 0.55 + 0.45 * np.sin(2 * np.pi * rng.uniform(3, 5) * t + rng.uniform(0, 6)) ** 2 # 음절형 진폭 변화\n", + " env *= 1 + shimmer * np.interp(t, np.linspace(0, DUR, 60), rng.normal(0, 1, 60)) # 완만한 진폭 흔들림(shimmer)\n", + " if frame_mod:\n", + " env *= 1 + frame_mod * np.sign(np.sin(2 * np.pi * 50 * t)) # 20ms 프레임 경계 흔적(50Hz)\n", + " return sig * env + (0.03 if jitter > 0.003 else 0.005) * rng.normal(0, 1, N) * env\n", + "\n", + "\n", + "def make_clip(label, speaker, rng):\n", + " f0, fm = SPEAKERS[speaker]\n", + " if label in (\"tts_art\", \"vc_art\"): # 위조는 화자 특성을 근사만 함\n", + " f0 = f0 * rng.uniform(0.92, 1.08); fm = tuple(F * rng.uniform(0.95, 1.05) for F in fm)\n", + " if label == \"bonafide\" or label == \"replay\":\n", + " x = _source(rng, f0, fm, jitter=rng.uniform(0.008, 0.02), shimmer=rng.uniform(0.05, 0.12))\n", + " elif label == \"tts_art\":\n", + " x = _source(rng, f0, fm, jitter=rng.uniform(0.0, 0.002), shimmer=0.01, hf_cut=rng.uniform(2300, 2800), frame_mod=rng.uniform(0.08, 0.2))\n", + " else:\n", + " x = _source(rng, f0, fm, jitter=rng.uniform(0.006, 0.015), shimmer=rng.uniform(0.04, 0.1), phase_break=True)\n", + " if label == \"replay\": # 스피커 대역 + 실내 잔향\n", + " x = signal.sosfilt(signal.butter(4, [450, 2900], \"bandpass\", fs=FS, output=\"sos\"), x)\n", + " ir = rng.normal(0, 1, int(0.08 * FS)) * np.exp(-np.arange(int(0.08 * FS)) / (0.02 * FS)); ir[0] = 3.0\n", + " x = signal.fftconvolve(x, ir)[:N]\n", + " return x / (np.abs(x).max() + 1e-9)\n", + "\n", + "\n", + "RADIO_BP = signal.butter(4, [300, 3400], \"bandpass\", fs=FS, output=\"sos\")\n", + "\n", + "\n", + "def radio_channel(x, rng, snr_db=15.0, impulse=0.0, dropout=0.0, clip=1.0, jam_tone=0.0):\n", + " \"\"\"[교육용가상자료] 무전 채널 모형: 300~3400Hz 대역 제한, 배경 잡음(SNR), 충격성 잡음, 신호 끊김, 포화(클리핑), 단일톤 재밍.\"\"\"\n", + " y = signal.sosfilt(RADIO_BP, x)\n", + " p = np.mean(y ** 2)\n", + " noise = rng.normal(0, 1, N) * np.sqrt(p / 10 ** (snr_db / 10))\n", + " noise += np.cumsum(rng.normal(0, 0.02, N)) * np.sqrt(p) * 0.05 # 저주파 차량·엔진형 소음\n", + " y = y + noise\n", + " if impulse:\n", + " k = rng.random(N) < impulse\n", + " y[k] += rng.normal(0, 6 * np.sqrt(p), k.sum())\n", + " if jam_tone:\n", + " y += jam_tone * np.sqrt(p) * np.sin(2 * np.pi * rng.uniform(800, 2000) * np.arange(N) / FS)\n", + " if dropout:\n", + " for _ in range(int(dropout * 10)):\n", + " a = int(rng.integers(0, N - 400)); y[a:a + int(rng.integers(100, 400))] = 0.0\n", + " return np.clip(y / (np.abs(y).max() + 1e-9), -clip, clip)\n", + "\n", + "\n", + "def make_dataset(n=400, seed=7, snr=(5, 25), labels=LABELS, p=None, claim=\"SPK_A\", **chan):\n", + " \"\"\"클립 n개: 진짜는 여러 화자, 위조는 모두 지휘관 화자(SPK_A)를 사칭. 반환: (신호 배열, 메타 DataFrame).\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " X, meta = np.zeros((n, N)), []\n", + " if p is None and len(labels) > 1 and labels[0] == \"bonafide\": # 기본: 진짜 50%, 나머지 위조 유형 균등\n", + " p = [0.5] + [0.5 / (len(labels) - 1)] * (len(labels) - 1)\n", + " for i in range(n):\n", + " lab = str(rng.choice(labels, p=p))\n", + " spk = str(rng.choice(list(SPEAKERS))) if lab == \"bonafide\" else claim\n", + " if lab == \"replay\":\n", + " spk = claim\n", + " s = float(rng.uniform(*snr)) if isinstance(snr, tuple) else float(snr)\n", + " X[i] = radio_channel(make_clip(lab, spk, rng), rng, snr_db=s, **chan)\n", + " meta.append({\"clip\": f\"C{seed:02d}{i:04d}\", \"label\": lab, \"spoof\": lab != \"bonafide\", \"claimed\": spk if lab == \"bonafide\" else claim,\n", + " \"true_spk\": spk if lab == \"bonafide\" else \"unknown\", \"snr_db\": round(s, 1)})\n", + " return X, pd.DataFrame(meta)\n", + "# ---------------------------------------------------------------------------\n", + "\n", + "# ---- 전처리·음향 특징(MFCC/스펙트로그램) 공통 함수: 여러 클립을 한 번에 계산 ----\n", + "NFFT, HOP = 256, 80\n", + "\n", + "\n", + "def mel_bank(n_mels=26, fmin=300, fmax=3400):\n", + " mel = lambda f: 2595 * np.log10(1 + f / 700)\n", + " imel = lambda m: 700 * (10 ** (m / 2595) - 1)\n", + " pts = imel(np.linspace(mel(fmin), mel(fmax), n_mels + 2))\n", + " freqs = np.fft.rfftfreq(NFFT, 1 / FS)\n", + " fb = np.zeros((n_mels, len(freqs)))\n", + " for m in range(n_mels):\n", + " l, c, r = pts[m], pts[m + 1], pts[m + 2]\n", + " fb[m] = np.clip(np.minimum((freqs - l) / (c - l), (r - freqs) / (r - c)), 0, None)\n", + " return fb\n", + "\n", + "\n", + "MEL = mel_bank()\n", + "FREQS = np.fft.rfftfreq(NFFT, 1 / FS)\n", + "\n", + "\n", + "def spectrogram(X, denoise=0.0):\n", + " \"\"\"배치 전력 스펙트로그램 (클립, 주파수, 프레임). denoise>0이면 잡음 바닥(주파수별 하위 10%)을 그 배수만큼 빼는 스펙트럼 차감.\"\"\"\n", + " X = np.atleast_2d(X)\n", + " Xp = np.c_[X[:, :1], X[:, 1:] - 0.95 * X[:, :-1]] # 프리엠퍼시스\n", + " frames = np.lib.stride_tricks.sliding_window_view(Xp, NFFT, axis=1)[:, ::HOP] * np.hanning(NFFT)\n", + " P = np.abs(np.fft.rfft(frames, axis=-1)) ** 2\n", + " P = np.transpose(P, (0, 2, 1))\n", + " if denoise:\n", + " floor = np.percentile(P, 10, axis=2, keepdims=True)\n", + " P = np.maximum(P - denoise * floor, 0.05 * P)\n", + " return P\n", + "\n", + "\n", + "def mfcc(P, n=13):\n", + " logmel = np.log(np.einsum(\"mf,cft->cmt\", MEL, P) + 1e-8)\n", + " return dct(logmel, type=2, axis=1, norm=\"ortho\")[:, :n, :]\n", + "\n", + "\n", + "def band(P, lo, hi):\n", + " return P[:, (FREQS >= lo) & (FREQS < hi), :].sum(axis=(1, 2))\n", + "\n", + "\n", + "def jitter_hnr(X):\n", + " \"\"\"40ms 프레임 자기상관으로 피치 주기를 찾아 피치 흔들림(jitter)과 배음 대 잡음비(HNR 근사)를 구한다.\"\"\"\n", + " W, lag0, lag1 = 320, int(FS / 250), int(FS / 70)\n", + " fr = np.lib.stride_tricks.sliding_window_view(np.atleast_2d(X), W, axis=1)[:, ::160]\n", + " fr = fr - fr.mean(-1, keepdims=True)\n", + " spec = np.fft.rfft(fr, 2 * W, axis=-1)\n", + " ac = np.fft.irfft(np.abs(spec) ** 2, axis=-1)[..., :W]\n", + " ac = ac / (ac[..., :1] + 1e-12)\n", + " seg = ac[..., lag0:lag1]\n", + " k = seg.argmax(-1)\n", + " peak = seg.max(-1)\n", + " km, kp = np.take_along_axis(seg, np.clip(k - 1, 0, None)[..., None], -1)[..., 0], np.take_along_axis(seg, np.clip(k + 1, None, seg.shape[-1] - 1)[..., None], -1)[..., 0]\n", + " lag = k + lag0 + 0.5 * (km - kp) / (km - 2 * peak + kp - 1e-12) # 포물선 보간으로 주기 정밀화\n", + " f0 = FS / lag\n", + " jit = np.median(np.abs(np.diff(f0, axis=1)) / f0[:, 1:], axis=1)\n", + " return jit, np.median(peak, axis=1)\n", + "\n", + "\n", + "def frame_mod(X):\n", + " \"\"\"진폭 포락선(2.5ms 간격)의 50Hz 성분 비율: 20ms 프레임 경계 흔적(합성 단서).\"\"\"\n", + " env = np.abs(np.atleast_2d(X)).reshape(len(np.atleast_2d(X)), -1, 20).mean(-1)\n", + " sp = np.abs(np.fft.rfft(env - env.mean(1, keepdims=True), axis=1)) ** 2\n", + " f = np.fft.rfftfreq(env.shape[1], 20 / FS)\n", + " return sp[:, (f > 47) & (f < 53)].sum(1) / (sp[:, (f > 30) & (f < 70)].sum(1) + 1e-12)\n", + "\n", + "\n", + "FEAT_NAMES = [f\"mfcc{i}_m\" for i in range(13)] + [f\"mfcc{i}_s\" for i in range(13)] + \\\n", + " [\"delta_s\", \"hf_ratio\", \"lf_ratio\", \"flatness\", \"flux\", \"jitter\", \"hnr\", \"frame_mod\"]\n", + "\n", + "\n", + "def extract(X, denoise=0.0):\n", + " \"\"\"클립 배치 -> 특징 행렬 (클립 x 34) DataFrame.\"\"\"\n", + " P = spectrogram(X, denoise)\n", + " C = mfcc(P)\n", + " tot = band(P, 300, 3400) + 1e-12\n", + " flat = np.exp(np.log(P[:, (FREQS >= 300) & (FREQS < 3400)] + 1e-10).mean(1)) / (P[:, (FREQS >= 300) & (FREQS < 3400)].mean(1) + 1e-10)\n", + " logP = np.log(P + 1e-8)\n", + " jit, hnr = jitter_hnr(X)\n", + " F = np.c_[C.mean(2), C.std(2), np.abs(np.diff(C, axis=2)).mean((1, 2)), band(P, 2400, 3400) / tot, band(P, 300, 500) / tot,\n", + " flat.mean(1), np.abs(np.diff(logP, axis=2)).mean((1, 2)), jit, hnr, frame_mod(X)]\n", + " return pd.DataFrame(F, columns=FEAT_NAMES)\n", + "\n", + "\n", + "def enroll(seed=99):\n", + " \"\"\"지휘관·통신소 화자 6명의 등록 견본(깨끗한 진짜 클립 5개씩의 MFCC 평균).\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " tpl = {}\n", + " for spk in SPEAKERS:\n", + " Xe = np.stack([radio_channel(make_clip(\"bonafide\", spk, rng), rng, snr_db=25) for _ in range(5)])\n", + " tpl[spk] = extract(Xe)[[f\"mfcc{i}_m\" for i in range(1, 13)]].mean().values\n", + " return tpl\n", + "\n", + "\n", + "def speaker_sim(F, claimed, tpl):\n", + " \"\"\"주장 화자 견본과 MFCC 평균 벡터의 코사인 유사도(화자 검증 점수).\"\"\"\n", + " v = F[[f\"mfcc{i}_m\" for i in range(1, 13)]].values\n", + " t = np.stack([tpl[c] for c in claimed])\n", + " return (v * t).sum(1) / (np.linalg.norm(v, axis=1) * np.linalg.norm(t, axis=1) + 1e-12)\n", + "\n", + "def channel_quality(X):\n", + " \"\"\"채널 품질 지표: 추정 SNR(유효 프레임 에너지 상하위 비, dB), 단일톤 재밍 지수(주파수별 시간 평균/표준편차의 최대값), 끊김 비율(무음 10ms 비율).\"\"\"\n", + " P = spectrogram(X)\n", + " e = P.sum(1)\n", + " e = np.where(e > 1e-9, e, np.nan)\n", + " snr_est = 10 * np.log10(np.nanpercentile(e, 90, axis=1) / (np.nanpercentile(e, 10, axis=1) + 1e-12))\n", + " Pb = P[:, (FREQS >= 300) & (FREQS < 3400)]\n", + " tone = (Pb.mean(2) / (Pb.std(2) + 1e-12)).max(1) # 시간에 따라 일정한 주파수 성분(재밍 톤)일수록 큼\n", + " fr = np.abs(np.atleast_2d(X)).reshape(len(X), -1, 80).max(-1)\n", + " drop = (fr < 1e-6).mean(1)\n", + " return pd.DataFrame({\"snr_est\": snr_est, \"tone_idx\": tone, \"drop_ratio\": drop})\n", + "\n", + "tpl = enroll()\n", + "CONDS = {\"평시\": dict(snr=(12, 25)), \"저SNR\": dict(snr=(3, 8)), \"충격잡음\": dict(snr=(8, 15), impulse=0.002),\n", + " \"신호끊김\": dict(snr=(8, 15), dropout=0.4), \"재밍\": dict(snr=(8, 15), jam_tone=0.5)}\n", + "\n", + "\n", + "def ops_set(n, seed):\n", + " \"\"\"[교육용가상자료] 모의 운용 기록: 채널 조건 5종 x n/5 클립, 재생 공격 비중을 높게 둠.\"\"\"\n", + " parts = []\n", + " for i, (c, ch) in enumerate(CONDS.items()):\n", + " X, M = make_dataset(n // 5, seed=seed + i, p=[0.5, 0.15, 0.15, 0.2], **ch); M[\"cond\"] = c; parts.append((X, M))\n", + " return np.vstack([p[0] for p in parts]), pd.concat([p[1] for p in parts], ignore_index=True)\n", + "\n", + "\n", + "def feats(X, M, denoise=0.0):\n", + " F = extract(X, denoise); F[\"spk_sim\"] = speaker_sim(F, M[\"claimed\"], tpl)\n", + " return pd.concat([F, channel_quality(X)], axis=1)\n", + "\n", + "\n", + "# ---- 재보정 전: 미션 3 규칙 기반 Baseline(진짜 음성 3% 분위수 경계) ----\n", + "CUES = [(\"hf_ratio\", \"low\"), (\"frame_mod\", \"high\"), (\"hnr\", \"low\"), (\"flatness\", \"high\"), (\"lf_ratio\", \"low\"), (\"spk_sim\", \"low\")]\n", + "Xb, Mb = make_dataset(200, seed=7, labels=[\"bonafide\"])\n", + "Fb = feats(Xb, Mb)\n", + "th = {f: Fb[f].quantile(0.03) if d == \"low\" else Fb[f].quantile(0.97) for f, d in CUES}\n", + "rule = lambda F: pd.DataFrame({f: (F[f] < th[f]) if d == \"low\" else (F[f] > th[f]) for f, d in CUES}).any(axis=1).values\n", + "\n", + "Xo, Mo = ops_set(400, seed=900) # AAR 대상 운용 기록\n", + "Fo = feats(Xo, Mo)\n", + "Mo[\"pred\"] = rule(Fo); Mo[\"err\"] = Mo[\"pred\"] != Mo[\"spoof\"]\n", + "print(f\"[AAR] 운용 기록 {len(Mo)}건, 재보정 전 정확도 {1 - Mo['err'].mean():.1%}, 오판정 {Mo['err'].sum()}건\")\n", + "Mo[\"유형\"] = np.where(Mo[\"spoof\"], \"미탐(위조->진짜)\", \"오경보(진짜->위조)\")\n", + "tab = Mo[Mo[\"err\"]].groupby([\"유형\", \"cond\"]).size().unstack(fill_value=0)\n", + "print(tab.to_string())\n", + "miss_lab = Mo[Mo[\"err\"] & Mo[\"spoof\"]][\"label\"].value_counts()\n", + "print(\"미탐 위조 유형:\", {k: int(v) for k, v in miss_lab.items()})\n", + "print(\"원인 분석: ① 재생(replay)은 진짜 음성을 녹음한 것이라 화자 유사도·자연스러운 흔들림이 높아 규칙이 놓친다\",\n", + " \"\\n ② 재밍·저SNR·끊김 채널에서 진짜 음성의 단서 값이 경계를 넘어 오경보가 난다(채널 품질 미반영)\",\n", + " \"\\n ③ 단서별 고정 경계(3% 분위수)는 조건이 바뀌어도 그대로라 조합 판단이 불가능하다\")\n", + "\n", + "# ---- 재보정: 운용 기록(오판정 포함)과 조건 혼합 데이터로 재학습, 차감 배수·임계값 탐색 ----\n", + "Xn, Mn = ops_set(600, seed=950)\n", + "Xv, Mv = ops_set(300, seed=980) # 검증용(파라미터 선택)\n", + "best = None\n", + "for dn in [0.0, 0.5, 1.0]:\n", + " Ftr = pd.concat([feats(Xn, Mn, dn), feats(Xo, Mo, dn)], ignore_index=True)\n", + " ytr = np.r_[Mn[\"spoof\"], Mo[\"spoof\"]]\n", + " w = np.r_[np.ones(len(Mn)), np.where(Mo[\"err\"], 3, 1)] # AAR 오판정 사례 가중 반영(표본 복제)\n", + " idx = np.repeat(np.arange(len(ytr)), w.astype(int))\n", + " mdl = make_pipeline(StandardScaler(), MLPClassifier((64, 32), alpha=1e-3, max_iter=800, random_state=0)).fit(Ftr.iloc[idx], ytr[idx])\n", + " pv = mdl.predict_proba(feats(Xv, Mv, dn))[:, 1]\n", + " for t in [0.3, 0.4, 0.5, 0.6, 0.7]:\n", + " acc = ((pv >= t) == Mv[\"spoof\"]).mean()\n", + " if best is None or acc > best[0]:\n", + " best = (acc, dn, t, mdl)\n", + "acc_v, dn, t, mdl = best\n", + "yaml = f\"\"\"# configs/c204_tuned_params.yaml [교육용가상자료]\n", + "preprocess:\n", + " spectral_subtraction: {dn}\n", + "features: [mfcc13_mean, mfcc13_std, hf_ratio, lf_ratio, flatness, flux, jitter, hnr, frame_mod, spk_sim, snr_est, tone_idx, drop_ratio]\n", + "model:\n", + " type: mlp\n", + " hidden: [64, 32]\n", + " aar_case_weight: 3\n", + "decision:\n", + " spoof_threshold: {t}\n", + " poor_channel_rule: snr_est<3.3 or tone_idx>2.5 or drop_ratio>0.05 -> human_review\n", + "\"\"\"\n", + "print(\"\\n[재보정 파라미터]\\n\" + yaml.strip())\n", + "\n", + "# ---- 회귀 시험 ----\n", + "Xh, Mh = ops_set(400, seed=1100) # 새 운용 기록(학습에 쓰지 않음)\n", + "before = (rule(feats(Xh, Mh)) == Mh[\"spoof\"]).mean()\n", + "ph = mdl.predict_proba(feats(Xh, Mh, dn))[:, 1] >= t\n", + "after = (ph == Mh[\"spoof\"]).mean()\n", + "po = mdl.predict_proba(feats(Xo, Mo, dn))[:, 1] >= t\n", + "recur = int(((po != Mo[\"spoof\"]) & Mo[\"err\"]).sum())\n", + "newerr = int(((po != Mo[\"spoof\"]) & ~Mo[\"err\"]).sum())\n", + "print(f\"\\n[회귀 시험] 새 운용 기록 {len(Mh)}건 정확도: 재보정 전 {before:.1%} -> 후 {after:.1%} ({(after - before) * 100:+.1f}%p, 목표 +10%p: {'달성' if after - before >= 0.10 else '미달'})\")\n", + "print(f\"AAR 오판정 {Mo['err'].sum()}건 재발 {recur}건 (목표 0건: {'달성' if recur == 0 else '미달'}), 기존 정답 사례 중 새 오류 {newerr}건\")\n", + "print(\"조건별 정확도(새 기록):\", {c: f\"{(ph[Mh['cond'] == c] == Mh['spoof'][Mh['cond'] == c]).mean():.0%}\" for c in CONDS})\n", + "print(\"지속 학습 연계: 통신보안관이 확정한 사례만 라벨로 누적 -> 월 1회 재학습 -> 본 회귀 시험 통과 시에만 교체 배치\")\n", + "\n", + "plt.figure(figsize=(6, 3.5))\n", + "c = list(CONDS)\n", + "plt.bar(np.arange(5) - 0.2, [(rule(feats(Xh[Mh['cond'] == k], Mh[Mh['cond'] == k].reset_index(drop=True))) == Mh['spoof'][Mh['cond'] == k].values).mean() for k in c], 0.4, label=\"before (rules)\")\n", + "plt.bar(np.arange(5) + 0.2, [(ph[Mh['cond'] == k] == Mh['spoof'][Mh['cond'] == k]).mean() for k in c], 0.4, label=\"after (retuned)\")\n", + "plt.xticks(range(5), [\"normal\", \"low SNR\", \"impulse\", \"dropout\", \"jamming\"]); plt.ylabel(\"accuracy\"); plt.legend(); plt.title(\"AAR recalibration (synthetic)\")\n", + "plt.tight_layout(); plt.show()\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 09 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + "[AAR] 운용 기록 400건, 재보정 전 정확도 69.8%, 오판정 121건\n", + "cond 신호끊김 재밍 저SNR 충격잡음 평시\n", + "유형 \n", + "미탐(위조->진짜) 10 11 3 12 23\n", + "오경보(진짜->위조) 10 15 26 8 3\n", + "미탐 위조 유형: {'replay': 53, 'vc_art': 4, 'tts_art': 2}\n", + "원인 분석: ① 재생(replay)은 진짜 음성을 녹음한 것이라 화자 유사도·자연스러운 흔들림이 높아 규칙이 놓친다 \n", + " ② 재밍·저SNR·끊김 채널에서 진짜 음성의 단서 값이 경계를 넘어 오경보가 난다(채널 품질 미반영) \n", + " ③ 단서별 고정 경계(3% 분위수)는 조건이 바뀌어도 그대로라 조합 판단이 불가능하다\n", + "\n", + "[재보정 파라미터]\n", + "# configs/c204_tuned_params.yaml [교육용가상자료]\n", + "preprocess:\n", + " spectral_subtraction: 0.0\n", + "features: [mfcc13_mean, mfcc13_std, hf_ratio, lf_ratio, flatness, flux, jitter, hnr, frame_mod, spk_sim, snr_est, tone_idx, drop_ratio]\n", + "model:\n", + " type: mlp\n", + " hidden: [64, 32]\n", + " aar_case_weight: 3\n", + "decision:\n", + " spoof_threshold: 0.3\n", + " poor_channel_rule: snr_est<3.3 or tone_idx>2.5 or drop_ratio>0.05 -> human_review\n", + "\n", + "[회귀 시험] 새 운용 기록 400건 정확도: 재보정 전 78.2% -> 후 96.8% (+18.5%p, 목표 +10%p: 달성)\n", + "AAR 오판정 121건 재발 0건 (목표 0건: 달성), 기존 정답 사례 중 새 오류 0건\n", + "조건별 정확도(새 기록): {'평시': '100%', '저SNR': '95%', '충격잡음': '98%', '신호끊김': '95%', '재밍': '96%'}\n", + "지속 학습 연계: 통신보안관이 확정한 사례만 라벨로 누적 -> 월 1회 재학습 -> 본 회귀 시험 통과 시에만 교체 배치\n", + "```\n", + "
\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 10. 무전 음성 스푸핑·딥페이크 탐지 시스템 통합 및 C2 음성 보안 브리핑 패키지 완성\n", + "**소요시간**: 약 3.5시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "무전 음성 스푸핑·딥페이크 탐지 시스템 통합 패키지 및 C2 보안 브리핑 보고서를 완성한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **검증부터 판별, 신뢰도 등급, 검증 대기열, 감사 로그까지 한 번에 실행되는 통합 패키지를 만들고 C2 음성 보안 브리핑을 완성한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **수신 기록을 검증해 손상·미등록 기록을 차단하고, 재보정 파라미터로 판별·등급을 산출해 위조 의심과 긴급 명령을 전량 검증 대기열에 올린다 과정을 진행하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `src/main_c204.py, README.md, docs/c204_final_report.pdf(md)` 형태의 결과물을 완성한다.\n", + "\n", + "### 🔍 평가 및 검증 기준 (PASS 조건)\n", + "#### 미션 판정\n", + "\n", + "**평가 구분:** 필수\n", + "\n", + "**핵심 평가영역:** 군 실무 즉시 도입 가능성, 운용 신뢰도 및 전략적 기대효과\n", + "\n", + "#### 미션 PASS 조건\n", + "\n", + "- 정량 기준: End-to-End 통합 파이프라인 실행 성공률 100%, 테스트 커버리지 85% 이상\n", + "\n", + "\n", + "- 진행 기준: 원클릭 실행 스크립트 동작 확인 및 최종 발표 산출물 작성 완료\n", + "\n", + "\n", + "- 검증 방법: 최종 검증 위원회(평가관) 모의 시연 및 전체 기능 통합 검증\n", + "\n", + "\n", + "#### 채점자 판정 메모\n", + "\n", + "> 단순 코드 실행 여부뿐만 아니라 출력된 수치와 근거 자료가 군 실무 요구사항에 부합하는지 면밀히 확인한다.\n", + "\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "\"\"\"수신 메타데이터 검증, 무전 채널 전처리·특징 추출, 재보정 파라미터를 적용한 위조 판별·신뢰도 등급, 통신보안관 검증 대기열·감사 로그까지 한 번에 실행하는 통합 파이프라인을 만들고, 자체 점검(함수 커버리지 포함)과 C2 음성 보안 브리핑 요약을 출력한다.\"\"\"\n", + "import hashlib\n", + "import json\n", + "import time\n", + "import numpy as np\n", + "import pandas as pd\n", + "import matplotlib.pyplot as plt\n", + "from scipy import signal\n", + "from scipy.fft import dct\n", + "from sklearn.neural_network import MLPClassifier\n", + "from sklearn.preprocessing import StandardScaler\n", + "from sklearn.pipeline import make_pipeline\n", + "\n", + "# ---------------------------------------------------------------------------\n", + "# [교육용가상자료] 탐지 학습용 합성 신호 생성기 (실제 음성·화자·통신망 정보 없음)\n", + "# 사람 목소리를 만드는 기능이 아니라, 배음(하모닉) 신호에 '진짜 신호의 자연스러운 흔들림'과\n", + "# '알려진 위조 흔적(문헌상 탐지 단서)'의 통계적 특징을 표시해 탐지기를 학습시키기 위한 추상 신호다.\n", + "# bonafide : 자연스러운 피치 흔들림(jitter)·진폭 흔들림(shimmer)·숨소리 잡음\n", + "# tts_art : 흔들림이 거의 없고 고주파 에너지가 부족하며 20ms 프레임 경계 흔적이 있는 신호\n", + "# vc_art : 프레임마다 배음 위상이 끊기고 스펙트럼 포락선이 뭉개진 신호\n", + "# replay : 스피커 재생 대역 제한 + 실내 잔향이 더해진 신호\n", + "# ---------------------------------------------------------------------------\n", + "FS, DUR = 8000, 0.5\n", + "N = int(FS * DUR)\n", + "LABELS = [\"bonafide\", \"tts_art\", \"vc_art\", \"replay\"]\n", + "SPEAKERS = {f\"SPK_{c}\": (f0, fm) for c, f0, fm in [(\"A\", 115, (650, 1150, 2450)), (\"B\", 135, (700, 1220, 2600)), (\"C\", 98, (600, 1050, 2350)),\n", + " (\"D\", 160, (720, 1300, 2700)), (\"E\", 125, (680, 1180, 2500)), (\"F\", 105, (630, 1100, 2400))]}\n", + "\n", + "\n", + "def _source(rng, f0, fm, jitter, shimmer, phase_break=False, hf_cut=None, frame_mod=0.0):\n", + " t = np.arange(N) / FS\n", + " contour = f0 * (1 + 0.06 * np.sin(2 * np.pi * rng.uniform(1.5, 3.5) * t + rng.uniform(0, 6)))\n", + " f = contour * (1 + jitter * rng.normal(0, 1, N // 80 + 1).repeat(80)[:N]) # 10ms 단위 피치 흔들림(jitter)\n", + " phase = 2 * np.pi * np.cumsum(f) / FS\n", + " K = int(3800 / f0)\n", + " fk = np.arange(1, K + 1) * f0\n", + " amp = sum(np.exp(-((fk - F) / 120.0) ** 2) / (i + 1) for i, F in enumerate(fm)) + 0.02 / np.arange(1, K + 1)\n", + " if hf_cut:\n", + " amp = np.where(fk > hf_cut, amp * 0.2, amp)\n", + " ph = np.arange(1, K + 1)[:, None] * phase[None, :]\n", + " if phase_break: # 20ms 프레임마다 배음별 위상 불연속\n", + " ph = ph + rng.uniform(0, 2 * np.pi, (K, N // 160 + 1)).repeat(160, axis=1)[:, :N]\n", + " sig = (amp[:, None] * np.sin(ph)).sum(0)\n", + " env = 0.55 + 0.45 * np.sin(2 * np.pi * rng.uniform(3, 5) * t + rng.uniform(0, 6)) ** 2 # 음절형 진폭 변화\n", + " env *= 1 + shimmer * np.interp(t, np.linspace(0, DUR, 60), rng.normal(0, 1, 60)) # 완만한 진폭 흔들림(shimmer)\n", + " if frame_mod:\n", + " env *= 1 + frame_mod * np.sign(np.sin(2 * np.pi * 50 * t)) # 20ms 프레임 경계 흔적(50Hz)\n", + " return sig * env + (0.03 if jitter > 0.003 else 0.005) * rng.normal(0, 1, N) * env\n", + "\n", + "\n", + "def make_clip(label, speaker, rng):\n", + " f0, fm = SPEAKERS[speaker]\n", + " if label in (\"tts_art\", \"vc_art\"): # 위조는 화자 특성을 근사만 함\n", + " f0 = f0 * rng.uniform(0.92, 1.08); fm = tuple(F * rng.uniform(0.95, 1.05) for F in fm)\n", + " if label == \"bonafide\" or label == \"replay\":\n", + " x = _source(rng, f0, fm, jitter=rng.uniform(0.008, 0.02), shimmer=rng.uniform(0.05, 0.12))\n", + " elif label == \"tts_art\":\n", + " x = _source(rng, f0, fm, jitter=rng.uniform(0.0, 0.002), shimmer=0.01, hf_cut=rng.uniform(2300, 2800), frame_mod=rng.uniform(0.08, 0.2))\n", + " else:\n", + " x = _source(rng, f0, fm, jitter=rng.uniform(0.006, 0.015), shimmer=rng.uniform(0.04, 0.1), phase_break=True)\n", + " if label == \"replay\": # 스피커 대역 + 실내 잔향\n", + " x = signal.sosfilt(signal.butter(4, [450, 2900], \"bandpass\", fs=FS, output=\"sos\"), x)\n", + " ir = rng.normal(0, 1, int(0.08 * FS)) * np.exp(-np.arange(int(0.08 * FS)) / (0.02 * FS)); ir[0] = 3.0\n", + " x = signal.fftconvolve(x, ir)[:N]\n", + " return x / (np.abs(x).max() + 1e-9)\n", + "\n", + "\n", + "RADIO_BP = signal.butter(4, [300, 3400], \"bandpass\", fs=FS, output=\"sos\")\n", + "\n", + "\n", + "def radio_channel(x, rng, snr_db=15.0, impulse=0.0, dropout=0.0, clip=1.0, jam_tone=0.0):\n", + " \"\"\"[교육용가상자료] 무전 채널 모형: 300~3400Hz 대역 제한, 배경 잡음(SNR), 충격성 잡음, 신호 끊김, 포화(클리핑), 단일톤 재밍.\"\"\"\n", + " y = signal.sosfilt(RADIO_BP, x)\n", + " p = np.mean(y ** 2)\n", + " noise = rng.normal(0, 1, N) * np.sqrt(p / 10 ** (snr_db / 10))\n", + " noise += np.cumsum(rng.normal(0, 0.02, N)) * np.sqrt(p) * 0.05 # 저주파 차량·엔진형 소음\n", + " y = y + noise\n", + " if impulse:\n", + " k = rng.random(N) < impulse\n", + " y[k] += rng.normal(0, 6 * np.sqrt(p), k.sum())\n", + " if jam_tone:\n", + " y += jam_tone * np.sqrt(p) * np.sin(2 * np.pi * rng.uniform(800, 2000) * np.arange(N) / FS)\n", + " if dropout:\n", + " for _ in range(int(dropout * 10)):\n", + " a = int(rng.integers(0, N - 400)); y[a:a + int(rng.integers(100, 400))] = 0.0\n", + " return np.clip(y / (np.abs(y).max() + 1e-9), -clip, clip)\n", + "\n", + "\n", + "def make_dataset(n=400, seed=7, snr=(5, 25), labels=LABELS, p=None, claim=\"SPK_A\", **chan):\n", + " \"\"\"클립 n개: 진짜는 여러 화자, 위조는 모두 지휘관 화자(SPK_A)를 사칭. 반환: (신호 배열, 메타 DataFrame).\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " X, meta = np.zeros((n, N)), []\n", + " if p is None and len(labels) > 1 and labels[0] == \"bonafide\": # 기본: 진짜 50%, 나머지 위조 유형 균등\n", + " p = [0.5] + [0.5 / (len(labels) - 1)] * (len(labels) - 1)\n", + " for i in range(n):\n", + " lab = str(rng.choice(labels, p=p))\n", + " spk = str(rng.choice(list(SPEAKERS))) if lab == \"bonafide\" else claim\n", + " if lab == \"replay\":\n", + " spk = claim\n", + " s = float(rng.uniform(*snr)) if isinstance(snr, tuple) else float(snr)\n", + " X[i] = radio_channel(make_clip(lab, spk, rng), rng, snr_db=s, **chan)\n", + " meta.append({\"clip\": f\"C{seed:02d}{i:04d}\", \"label\": lab, \"spoof\": lab != \"bonafide\", \"claimed\": spk if lab == \"bonafide\" else claim,\n", + " \"true_spk\": spk if lab == \"bonafide\" else \"unknown\", \"snr_db\": round(s, 1)})\n", + " return X, pd.DataFrame(meta)\n", + "# ---------------------------------------------------------------------------\n", + "\n", + "# ---- 전처리·음향 특징(MFCC/스펙트로그램) 공통 함수: 여러 클립을 한 번에 계산 ----\n", + "NFFT, HOP = 256, 80\n", + "\n", + "\n", + "def mel_bank(n_mels=26, fmin=300, fmax=3400):\n", + " mel = lambda f: 2595 * np.log10(1 + f / 700)\n", + " imel = lambda m: 700 * (10 ** (m / 2595) - 1)\n", + " pts = imel(np.linspace(mel(fmin), mel(fmax), n_mels + 2))\n", + " freqs = np.fft.rfftfreq(NFFT, 1 / FS)\n", + " fb = np.zeros((n_mels, len(freqs)))\n", + " for m in range(n_mels):\n", + " l, c, r = pts[m], pts[m + 1], pts[m + 2]\n", + " fb[m] = np.clip(np.minimum((freqs - l) / (c - l), (r - freqs) / (r - c)), 0, None)\n", + " return fb\n", + "\n", + "\n", + "MEL = mel_bank()\n", + "FREQS = np.fft.rfftfreq(NFFT, 1 / FS)\n", + "\n", + "\n", + "def spectrogram(X, denoise=0.0):\n", + " \"\"\"배치 전력 스펙트로그램 (클립, 주파수, 프레임). denoise>0이면 잡음 바닥(주파수별 하위 10%)을 그 배수만큼 빼는 스펙트럼 차감.\"\"\"\n", + " X = np.atleast_2d(X)\n", + " Xp = np.c_[X[:, :1], X[:, 1:] - 0.95 * X[:, :-1]] # 프리엠퍼시스\n", + " frames = np.lib.stride_tricks.sliding_window_view(Xp, NFFT, axis=1)[:, ::HOP] * np.hanning(NFFT)\n", + " P = np.abs(np.fft.rfft(frames, axis=-1)) ** 2\n", + " P = np.transpose(P, (0, 2, 1))\n", + " if denoise:\n", + " floor = np.percentile(P, 10, axis=2, keepdims=True)\n", + " P = np.maximum(P - denoise * floor, 0.05 * P)\n", + " return P\n", + "\n", + "\n", + "def mfcc(P, n=13):\n", + " logmel = np.log(np.einsum(\"mf,cft->cmt\", MEL, P) + 1e-8)\n", + " return dct(logmel, type=2, axis=1, norm=\"ortho\")[:, :n, :]\n", + "\n", + "\n", + "def band(P, lo, hi):\n", + " return P[:, (FREQS >= lo) & (FREQS < hi), :].sum(axis=(1, 2))\n", + "\n", + "\n", + "def jitter_hnr(X):\n", + " \"\"\"40ms 프레임 자기상관으로 피치 주기를 찾아 피치 흔들림(jitter)과 배음 대 잡음비(HNR 근사)를 구한다.\"\"\"\n", + " W, lag0, lag1 = 320, int(FS / 250), int(FS / 70)\n", + " fr = np.lib.stride_tricks.sliding_window_view(np.atleast_2d(X), W, axis=1)[:, ::160]\n", + " fr = fr - fr.mean(-1, keepdims=True)\n", + " spec = np.fft.rfft(fr, 2 * W, axis=-1)\n", + " ac = np.fft.irfft(np.abs(spec) ** 2, axis=-1)[..., :W]\n", + " ac = ac / (ac[..., :1] + 1e-12)\n", + " seg = ac[..., lag0:lag1]\n", + " k = seg.argmax(-1)\n", + " peak = seg.max(-1)\n", + " km, kp = np.take_along_axis(seg, np.clip(k - 1, 0, None)[..., None], -1)[..., 0], np.take_along_axis(seg, np.clip(k + 1, None, seg.shape[-1] - 1)[..., None], -1)[..., 0]\n", + " lag = k + lag0 + 0.5 * (km - kp) / (km - 2 * peak + kp - 1e-12) # 포물선 보간으로 주기 정밀화\n", + " f0 = FS / lag\n", + " jit = np.median(np.abs(np.diff(f0, axis=1)) / f0[:, 1:], axis=1)\n", + " return jit, np.median(peak, axis=1)\n", + "\n", + "\n", + "def frame_mod(X):\n", + " \"\"\"진폭 포락선(2.5ms 간격)의 50Hz 성분 비율: 20ms 프레임 경계 흔적(합성 단서).\"\"\"\n", + " env = np.abs(np.atleast_2d(X)).reshape(len(np.atleast_2d(X)), -1, 20).mean(-1)\n", + " sp = np.abs(np.fft.rfft(env - env.mean(1, keepdims=True), axis=1)) ** 2\n", + " f = np.fft.rfftfreq(env.shape[1], 20 / FS)\n", + " return sp[:, (f > 47) & (f < 53)].sum(1) / (sp[:, (f > 30) & (f < 70)].sum(1) + 1e-12)\n", + "\n", + "\n", + "FEAT_NAMES = [f\"mfcc{i}_m\" for i in range(13)] + [f\"mfcc{i}_s\" for i in range(13)] + \\\n", + " [\"delta_s\", \"hf_ratio\", \"lf_ratio\", \"flatness\", \"flux\", \"jitter\", \"hnr\", \"frame_mod\"]\n", + "\n", + "\n", + "def extract(X, denoise=0.0):\n", + " \"\"\"클립 배치 -> 특징 행렬 (클립 x 34) DataFrame.\"\"\"\n", + " P = spectrogram(X, denoise)\n", + " C = mfcc(P)\n", + " tot = band(P, 300, 3400) + 1e-12\n", + " flat = np.exp(np.log(P[:, (FREQS >= 300) & (FREQS < 3400)] + 1e-10).mean(1)) / (P[:, (FREQS >= 300) & (FREQS < 3400)].mean(1) + 1e-10)\n", + " logP = np.log(P + 1e-8)\n", + " jit, hnr = jitter_hnr(X)\n", + " F = np.c_[C.mean(2), C.std(2), np.abs(np.diff(C, axis=2)).mean((1, 2)), band(P, 2400, 3400) / tot, band(P, 300, 500) / tot,\n", + " flat.mean(1), np.abs(np.diff(logP, axis=2)).mean((1, 2)), jit, hnr, frame_mod(X)]\n", + " return pd.DataFrame(F, columns=FEAT_NAMES)\n", + "\n", + "\n", + "def enroll(seed=99):\n", + " \"\"\"지휘관·통신소 화자 6명의 등록 견본(깨끗한 진짜 클립 5개씩의 MFCC 평균).\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " tpl = {}\n", + " for spk in SPEAKERS:\n", + " Xe = np.stack([radio_channel(make_clip(\"bonafide\", spk, rng), rng, snr_db=25) for _ in range(5)])\n", + " tpl[spk] = extract(Xe)[[f\"mfcc{i}_m\" for i in range(1, 13)]].mean().values\n", + " return tpl\n", + "\n", + "\n", + "def speaker_sim(F, claimed, tpl):\n", + " \"\"\"주장 화자 견본과 MFCC 평균 벡터의 코사인 유사도(화자 검증 점수).\"\"\"\n", + " v = F[[f\"mfcc{i}_m\" for i in range(1, 13)]].values\n", + " t = np.stack([tpl[c] for c in claimed])\n", + " return (v * t).sum(1) / (np.linalg.norm(v, axis=1) * np.linalg.norm(t, axis=1) + 1e-12)\n", + "\n", + "def channel_quality(X):\n", + " \"\"\"채널 품질 지표: 추정 SNR(유효 프레임 에너지 상하위 비, dB), 단일톤 재밍 지수(주파수별 시간 평균/표준편차의 최대값), 끊김 비율(무음 10ms 비율).\"\"\"\n", + " P = spectrogram(X)\n", + " e = P.sum(1)\n", + " e = np.where(e > 1e-9, e, np.nan)\n", + " snr_est = 10 * np.log10(np.nanpercentile(e, 90, axis=1) / (np.nanpercentile(e, 10, axis=1) + 1e-12))\n", + " Pb = P[:, (FREQS >= 300) & (FREQS < 3400)]\n", + " tone = (Pb.mean(2) / (Pb.std(2) + 1e-12)).max(1) # 시간에 따라 일정한 주파수 성분(재밍 톤)일수록 큼\n", + " fr = np.abs(np.atleast_2d(X)).reshape(len(X), -1, 80).max(-1)\n", + " drop = (fr < 1e-6).mean(1)\n", + " return pd.DataFrame({\"snr_est\": snr_est, \"tone_idx\": tone, \"drop_ratio\": drop})\n", + "\n", + "CONFIG = {\"spectral_subtraction\": 0.0, \"spoof_threshold\": 0.3, \"high_threshold\": 0.8, # 미션 9 재보정값\n", + " \"poor\": {\"snr_est\": 3.3, \"tone_idx\": 2.5, \"drop_ratio\": 0.05}}\n", + "CALLED = set()\n", + "\n", + "\n", + "def traced(fn):\n", + " \"\"\"함수 커버리지 측정용: 호출된 파이프라인 함수 이름을 기록.\"\"\"\n", + " def w(*a, **k):\n", + " CALLED.add(fn.__name__); return fn(*a, **k)\n", + " w.__name__ = fn.__name__\n", + " return w\n", + "\n", + "\n", + "@traced\n", + "def validate(meta, x):\n", + " errs = []\n", + " if not str(meta.get(\"clip\", \"\")).startswith(\"C\"):\n", + " errs.append(\"clip_id 형식\")\n", + " if meta.get(\"claimed\") not in SPEAKERS:\n", + " errs.append(\"주장 화자 미등록\")\n", + " if len(x) != N or not np.isfinite(x).all():\n", + " errs.append(\"신호 길이·값 오류\")\n", + " elif np.abs(x).max() < 1e-6:\n", + " errs.append(\"무신호\")\n", + " return errs\n", + "\n", + "\n", + "@traced\n", + "def features(X, claimed):\n", + " F = extract(X, CONFIG[\"spectral_subtraction\"]); F[\"spk_sim\"] = speaker_sim(F, claimed, TPL)\n", + " return pd.concat([F, channel_quality(X)], axis=1)\n", + "\n", + "\n", + "@traced\n", + "def grade(p, q):\n", + " poor = (q[\"snr_est\"] < CONFIG[\"poor\"][\"snr_est\"]) | (q[\"tone_idx\"] > CONFIG[\"poor\"][\"tone_idx\"]) | (q[\"drop_ratio\"] > CONFIG[\"poor\"][\"drop_ratio\"])\n", + " g = np.where(p >= CONFIG[\"high_threshold\"], \"High\", np.where(p >= CONFIG[\"spoof_threshold\"], \"Med\", \"Low\"))\n", + " return np.where((g == \"High\") & poor.values & (p < 0.95), \"Med\", g), poor.values\n", + "\n", + "\n", + "class Queue:\n", + " def __init__(self):\n", + " self.items, self.log = [], []\n", + "\n", + " @traced\n", + " def submit(self, rec):\n", + " prev = self.log[-1][\"hash\"] if self.log else \"GENESIS\"\n", + " body = {\"event\": \"SUBMIT\", \"id\": rec[\"clip\"], \"grade\": rec[\"grade\"], \"score\": round(rec[\"score\"], 3), \"urgent\": rec[\"urgent\"]}\n", + " self.log.append({**body, \"prev\": prev, \"hash\": hashlib.sha256((prev + json.dumps(body, sort_keys=True)).encode()).hexdigest()})\n", + " self.items.append({**rec, \"status\": \"PENDING\"}) # 자동 전파·차단 없음, 통신보안관 검증 대기\n", + "\n", + " @traced\n", + " def verify(self):\n", + " prev = \"GENESIS\"\n", + " for r in self.log:\n", + " body = {k: v for k, v in r.items() if k not in (\"prev\", \"hash\")}\n", + " if r[\"prev\"] != prev or r[\"hash\"] != hashlib.sha256((prev + json.dumps(body, sort_keys=True)).encode()).hexdigest():\n", + " return False\n", + " prev = r[\"hash\"]\n", + " return True\n", + "\n", + "\n", + "@traced\n", + "def train(seed=1200):\n", + " conds = [dict(snr=(10, 25)), dict(snr=(3, 8)), dict(snr=(8, 15), impulse=0.002), dict(snr=(8, 15), dropout=0.4), dict(snr=(8, 15), jam_tone=0.5)]\n", + " parts = [make_dataset(140, seed=seed + i, p=[0.5, 0.15, 0.15, 0.2], **c) for i, c in enumerate(conds)]\n", + " X = np.vstack([p[0] for p in parts]); M = pd.concat([p[1] for p in parts], ignore_index=True)\n", + " return make_pipeline(StandardScaler(), MLPClassifier((64, 32), alpha=1e-3, max_iter=800, random_state=0)).fit(features(X, M[\"claimed\"]), M[\"spoof\"])\n", + "\n", + "\n", + "@traced\n", + "def main(X, M, model):\n", + " queue, rejected, out = Queue(), {}, []\n", + " ok = []\n", + " for i in range(len(X)):\n", + " e = validate(M.iloc[i].to_dict(), X[i])\n", + " if e:\n", + " rejected[M[\"clip\"][i]] = e\n", + " else:\n", + " ok.append(i)\n", + " Xo, Mo = X[ok], M.iloc[ok].reset_index(drop=True)\n", + " t0 = time.perf_counter()\n", + " F = features(Xo, Mo[\"claimed\"])\n", + " p = model.predict_proba(F)[:, 1]\n", + " g, poor = grade(p, F)\n", + " ms = (time.perf_counter() - t0) * 1000 / len(Xo)\n", + " for k in range(len(Mo)):\n", + " rec = {\"clip\": Mo[\"clip\"][k], \"score\": float(p[k]), \"grade\": str(g[k]), \"urgent\": bool(Mo[\"urgent\"][k]), \"poor\": bool(poor[k])}\n", + " out.append(rec)\n", + " if rec[\"grade\"] != \"Low\" or rec[\"urgent\"]: # 위조 의심 + 긴급 명령은 전량 사람 검증\n", + " queue.submit(rec)\n", + " return pd.DataFrame(out), Mo, rejected, queue, ms\n", + "\n", + "\n", + "T0 = time.perf_counter()\n", + "TPL = enroll()\n", + "model = train()\n", + "# [교육용가상자료] 야간 운용 시간대 수신 기록 300건(혼합 채널) + 손상 기록 3건\n", + "parts = [make_dataset(60, seed=1300 + i, p=[0.5, 0.15, 0.15, 0.2], **c) for i, c in\n", + " enumerate([dict(snr=(10, 25)), dict(snr=(3, 8)), dict(snr=(8, 15), impulse=0.002), dict(snr=(8, 15), dropout=0.4), dict(snr=(8, 15), jam_tone=0.5)])]\n", + "X = np.vstack([p[0] for p in parts]); M = pd.concat([p[1] for p in parts], ignore_index=True)\n", + "M[\"urgent\"] = np.random.default_rng(1400).random(len(M)) < 0.15\n", + "X[5] = np.nan; X[77] = 0.0; M.loc[150, \"claimed\"] = \"SPK_Z\"\n", + "R, Mo, rejected, queue, ms = main(X, M, model)\n", + "y = Mo[\"spoof\"].values; alarm = R[\"grade\"].values != \"Low\"\n", + "recall, far, acc = alarm[y].mean(), alarm[~y].mean(), (alarm == y).mean()\n", + "elapsed = time.perf_counter() - T0\n", + "\n", + "chk = {\n", + " \"손상 기록 3건 차단\": len(rejected) == 3,\n", + " \"처리 기록 297건\": len(R) == 297,\n", + " \"위조 재현율 90% 이상\": recall >= 0.9,\n", + " \"오경보율 10% 이하\": far <= 0.10,\n", + " \"자동 전파·차단 0건\": all(i[\"status\"] == \"PENDING\" for i in queue.items),\n", + " \"긴급 명령 전량 검증 대기\": all(c in {i[\"clip\"] for i in queue.items} for c in Mo[\"clip\"][Mo[\"urgent\"]]),\n", + " \"감사 로그 무결성\": queue.verify() and len(queue.log) == len(queue.items),\n", + " \"클립당 200ms 이내\": ms < 200,\n", + " \"등급 규칙 일관\": all((r.grade == \"Low\") == (r.score < CONFIG[\"spoof_threshold\"]) for r in R.itertuples()),\n", + " \"점수 범위 0~1·NaN 없음\": R[\"score\"].between(0, 1).all(),\n", + "}\n", + "passed = sum(chk.values())\n", + "for k, v in chk.items():\n", + " print(f\"[{'PASS' if v else 'FAIL'}] {k}\")\n", + "funcs = [\"validate\", \"features\", \"grade\", \"submit\", \"verify\", \"train\", \"main\"]\n", + "cov = len(CALLED & set(funcs)) / len(funcs)\n", + "print(f\"자체 점검 {passed}/{len(chk)} 통과, E2E 실행 성공률 {'100%' if passed == len(chk) else f'{passed / len(chk):.0%}'}, 함수 커버리지 {cov:.0%} (목표 85% 이상: {'달성' if cov >= 0.85 else '미달'})\")\n", + "print(\"차단된 기록:\", rejected)\n", + "\n", + "n_hi, n_med = (R[\"grade\"] == \"High\").sum(), (R[\"grade\"] == \"Med\").sum()\n", + "print(f\"\"\"\n", + "[C2 음성 보안 브리핑 요약 - 교육용가상자료]\n", + "1. 처리: 수신 {len(M)}건 중 {len(R)}건 판정, 손상·미등록 {len(rejected)}건 차단, 전체 {elapsed:.1f}s, 클립당 {ms:.2f} ms\n", + "2. 성능: 위조 재현율 {recall:.1%}, 오경보율 {far:.1%}, 정확도 {acc:.1%} (평시·저SNR·충격·끊김·재밍 혼합 채널)\n", + "3. 검증 대기: {len(queue.items)}건 (High {n_hi} / Med {n_med} / 긴급 명령 {int(Mo['urgent'].sum())}), 자동 전파·차단 0건\n", + "4. 운용: 긴급 명령은 등급과 무관하게 통신보안관이 역호출 인증·청음 후 전파하며, 최종 명령 책임은 지휘계통에 있다.\n", + "5. 한계·확장: 합성 신호 기준 결과로 실제 무전기·음성에는 재학습·재검증이 필요하다. 인접 체계 연동은 판정 결과(JSON)만 전달하는 API로 확장한다.\"\"\")\n", + "\n", + "plt.figure(figsize=(6, 3.5))\n", + "plt.hist([R[\"score\"][~y], R[\"score\"][y]], bins=20, label=[\"bonafide\", \"spoof\"], color=[\"tab:green\", \"tab:red\"])\n", + "plt.axvline(CONFIG[\"spoof_threshold\"], c=\"k\", ls=\"--\"); plt.yscale(\"log\"); plt.xlabel(\"spoof score\"); plt.legend(); plt.title(\"Integrated pipeline output (synthetic)\")\n", + "plt.tight_layout(); plt.show()\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 10 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + "[PASS] 손상 기록 3건 차단\n", + "[PASS] 처리 기록 297건\n", + "[PASS] 위조 재현율 90% 이상\n", + "[PASS] 오경보율 10% 이하\n", + "[PASS] 자동 전파·차단 0건\n", + "[PASS] 긴급 명령 전량 검증 대기\n", + "[PASS] 감사 로그 무결성\n", + "[PASS] 클립당 200ms 이내\n", + "[PASS] 등급 규칙 일관\n", + "[PASS] 점수 범위 0~1·NaN 없음\n", + "자체 점검 10/10 통과, E2E 실행 성공률 100%, 함수 커버리지 100% (목표 85% 이상: 달성)\n", + "차단된 기록: {'C13000005': ['신호 길이·값 오류'], 'C13010017': ['무신호'], 'C13020030': ['주장 화자 미등록']}\n", + "\n", + "[C2 음성 보안 브리핑 요약 - 교육용가상자료]\n", + "1. 처리: 수신 300건 중 297건 판정, 손상·미등록 3건 차단, 전체 8.5s, 클립당 1.66 ms\n", + "2. 성능: 위조 재현율 96.7%, 오경보율 4.9%, 정확도 96.0% (평시·저SNR·충격·끊김·재밍 혼합 채널)\n", + "3. 검증 대기: 174건 (High 143 / Med 12 / 긴급 명령 43), 자동 전파·차단 0건\n", + "4. 운용: 긴급 명령은 등급과 무관하게 통신보안관이 역호출 인증·청음 후 전파하며, 최종 명령 책임은 지휘계통에 있다.\n", + "5. 한계·확장: 합성 신호 기준 결과로 실제 무전기·음성에는 재학습·재검증이 필요하다. 인접 체계 연동은 판정 결과(JSON)만 전달하는 API로 확장한다.\n", + "```\n", + "
\n" + ] + } + ], + "metadata": { + "colab": { + "provenance": [] + }, + "kernelspec": { + "display_name": "Python 3", + "name": "python3" + }, + "language_info": { + "name": "python" + } + }, + "nbformat": 4, + "nbformat_minor": 0 +} \ No newline at end of file diff --git a/uploads/C204/C204_student.ipynb b/uploads/C204/C204_student.ipynb new file mode 100644 index 0000000..c195d9d --- /dev/null +++ b/uploads/C204/C204_student.ipynb @@ -0,0 +1,3310 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# 🎯 [C204] 무전 음성 스푸핑·딥페이크 탐지 실험\n", + "### Google Colab 실습 환경 (지휘통제(C2) / 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/C204/C204_solution.ipynb)\n", + "\n", + "---\n", + "\n", + "## 📌 1. 프로젝트 개요 및 배경\n", + "\n", + "| 항목 | 내용 |\n", + "| :--- | :--- |\n", + "| **과정 분류** | 지휘통제(C2) (Level 3) |\n", + "| **학습 소요 시간** | 10개 미션 (5일 / 총 27.5시간) |\n", + "| **환경 사양** | Python 3.10+, numpy, pandas, matplotlib, scipy, scikit-learn |\n", + "\n", + "### 📖 프로젝트 목표\n", + "러시아-우크라이나 전쟁에서는 지휘관 명의의 위조 음성·영상과 무전 기만이 실제로 보고되면서, 생성형 AI 시대에 '들리는 목소리가 정말 그 지휘관인가'를 확인하는 일이 지휘망 보안의 새로운 과제가 되었다. 본 프로젝트에서는 실제 음성 대신 탐지 학습용 합성 신호(배음 신호에 진짜 음성의 자연스러운 흔들림과 문헌상 알려진 위조 흔적을 통계적으로 표시한 추상 신호)를 코드로 만들어, 데이터셋 규격 설계, 무전 채널 잡음 모델링과 MFCC·스펙트로그램 전처리, 규칙 기반 Baseline, 신경망 판별 엔진, 진위 신뢰도 점수, C2 보안 상황판, 통신보안관 2차 검증(HITL), 전자전 재밍 스트레스 시험, 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. 전술 무전 음성 통신 환경 분석 및 딥페이크/스푸핑 음성 데이터셋 규격 설계\n", + "**소요시간**: 약 1.5시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "전술 무전 음성 통신 환경과 생성형 AI 기반 음성 합성(TTS/VC) 스푸핑 위협을 분석하고 데이터셋 규격을 설계한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **전술 무전 환경과 생성형 AI 음성 위조(TTS/VC)·녹음 재생 위협을 탐지 단서 관점에서 정리하고, 무전 음성 클립 데이터셋의 입력 규격과 검증기를 만든다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **진짜·TTS 의심·VC 의심·재생 의심 4개 라벨과 유형별 탐지 단서를 표로 정리하고 규격화하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `data/schema/c204_input_schema.json 및 docs/c204_data_spec.md` 형태의 결과물을 완성한다.\n", + "\n", + "### 🔍 평가 및 검증 기준 (PASS 조건)\n", + "#### 미션 판정\n", + "\n", + "**평가 구분:** 필수\n", + "\n", + "**핵심 평가영역:** 지휘통제(C2) 실무 및 러시아-우크라이나 전쟁 전장 환경과의 군사적 부합성\n", + "\n", + "#### 미션 PASS 조건\n", + "\n", + "- 정량 기준: 스키마 필드 정의 완성도 100%, 샘플 데이터 검증 오류 0건\n", + "\n", + "\n", + "- 진행 기준: 입력 데이터 유효성 검증 스크립트 통과 및 명세서 문서화 완료\n", + "\n", + "\n", + "- 검증 방법: JSON Schema 검증 도구 및 샘플 데이터 파싱 단위 테스트\n", + "\n", + "\n", + "#### 채점자 판정 메모\n", + "\n", + "> 단순 코드 실행 여부뿐만 아니라 출력된 수치와 근거 자료가 군 실무 요구사항에 부합하는지 면밀히 확인한다.\n", + "\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "\"\"\"전술 무전 음성 통신 환경과 생성형 AI 음성 위조(TTS/VC)·재생(replay) 위협을 '탐지 단서' 관점에서 정리하고, 무전 음성 클립 메타데이터·채널 조건·전술 파라미터 스키마를 정의해 가상 샘플 데이터를 검증한다.\"\"\"\n", + "import json\n", + "import re\n", + "import numpy as np\n", + "import pandas as pd\n", + "\n", + "# 1) 위협 유형별 탐지 단서(탐지 측 관점 요약, 생성 방법은 다루지 않음)\n", + "threats = pd.DataFrame([\n", + " (\"bonafide\", \"실제 송신자 음성\", \"자연스러운 피치·진폭 흔들림, 등록 화자와 일치\"),\n", + " (\"tts_art\", \"문자->음성 합성 의심\", \"흔들림 부족, 고주파 에너지 부족, 프레임 경계 흔적\"),\n", + " (\"vc_art\", \"음성 변환 의심\", \"배음 위상 불연속(HNR 저하), 스펙트럼 평탄화, 화자 특징 근사\"),\n", + " (\"replay\", \"녹음 재생 의심\", \"스피커 대역 제한(저역 부족), 실내 잔향, 화자 특징은 일치\"),\n", + "], columns=[\"라벨\", \"의미\", \"탐지 단서\"])\n", + "print(\"[위협 유형별 탐지 단서]\")\n", + "print(threats.to_string(index=False))\n", + "print(\"-> 무전 채널(300~3400Hz 대역 제한, 잡음, 끊김)이 단서를 가리므로 채널 조건을 함께 기록해야 판정 신뢰도를 해석할 수 있다.\")\n", + "\n", + "SCHEMA = {\n", + " \"clip\": {\n", + " \"clip_id\": {\"type\": str, \"pattern\": r\"^C\\d{6}$\"},\n", + " \"net_id\": {\"type\": str, \"pattern\": r\"^NET-[A-Z]$\"}, # 가상 망 이름\n", + " \"sample_rate\": {\"type\": int, \"enum\": [8000, 16000]},\n", + " \"duration_s\": {\"type\": (int, float), \"min\": 0.2, \"max\": 60},\n", + " \"claimed_speaker\": {\"type\": str, \"pattern\": r\"^SPK_[A-Z]$\"},\n", + " \"urgency\": {\"type\": str, \"enum\": [\"ROUTINE\", \"URGENT\"]},\n", + " \"label\": {\"type\": str, \"enum\": [\"bonafide\", \"tts_art\", \"vc_art\", \"replay\", \"unknown\"]},\n", + " },\n", + " \"channel\": {\n", + " \"band\": {\"type\": str, \"enum\": [\"HF\", \"VHF\", \"UHF\"]},\n", + " \"codec\": {\"type\": str, \"enum\": [\"analog_fm\", \"digital_vocoder\"]},\n", + " \"snr_db\": {\"type\": (int, float), \"min\": -20, \"max\": 60},\n", + " \"dropout_ratio\": {\"type\": (int, float), \"min\": 0, \"max\": 1},\n", + " },\n", + " \"tactical\": {\n", + " \"weather\": {\"type\": str, \"enum\": [\"clear\", \"rain\", \"snow\", \"fog\", \"night\"]},\n", + " \"position_error_m\": {\"type\": (int, float), \"min\": 0, \"max\": 500},\n", + " \"comm_delay_ms\": {\"type\": (int, float), \"min\": 0, \"max\": 5000},\n", + " \"ew_jamming\": {\"type\": bool},\n", + " },\n", + "}\n", + "\n", + "\n", + "def check(name, value, rule):\n", + " # [TODO : 학생 실습 1] 전술 무전 음성 통신 환경 분석 및 딥페이크/스푸핑 음성 데이터셋 규격 설계 관련 핵심 로직을 구현하세요.\n", + " # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n", + " # <<< 여기에 코드를 작성하세요 >>>\n", + " pass # 작성 후 pass를 제거하세요.\n", + "def validate(rec):\n", + " # [TODO : 학생 실습 1] 전술 무전 음성 통신 환경 분석 및 딥페이크/스푸핑 음성 데이터셋 규격 설계 관련 핵심 로직을 구현하세요.\n", + " # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n", + " # <<< 여기에 코드를 작성하세요 >>>\n", + " pass # 작성 후 pass를 제거하세요.\n", + "rng = np.random.default_rng(1)\n", + "records = []\n", + "for i in range(20):\n", + " records.append({\"clip\": {\"clip_id\": f\"C01{i:04d}\", \"net_id\": str(rng.choice([\"NET-A\", \"NET-B\"])), \"sample_rate\": 8000, \"duration_s\": 0.5,\n", + " \"claimed_speaker\": str(rng.choice([\"SPK_A\", \"SPK_B\", \"SPK_C\"])), \"urgency\": str(rng.choice([\"ROUTINE\", \"URGENT\"], p=[0.7, 0.3])),\n", + " \"label\": str(rng.choice([\"bonafide\", \"tts_art\", \"vc_art\", \"replay\"], p=[0.55, 0.15, 0.15, 0.15]))},\n", + " \"channel\": {\"band\": str(rng.choice([\"VHF\", \"UHF\"])), \"codec\": str(rng.choice([\"analog_fm\", \"digital_vocoder\"])),\n", + " \"snr_db\": float(round(rng.uniform(0, 25), 1)), \"dropout_ratio\": float(round(rng.uniform(0, 0.1), 2))},\n", + " \"tactical\": {\"weather\": str(rng.choice([\"clear\", \"rain\", \"night\"])), \"position_error_m\": float(round(rng.uniform(5, 50), 1)),\n", + " \"comm_delay_ms\": int(rng.integers(50, 800)), \"ew_jamming\": bool(rng.random() < 0.2)}})\n", + "ok = sum(1 for r in records if not validate(r))\n", + "print(f\"\\n정상 샘플 {len(records)}건 중 검증 통과 {ok}건, 오류 {len(records) - ok}건\")\n", + "\n", + "bad = json.loads(json.dumps(records[0]))\n", + "bad[\"clip\"][\"clip_id\"] = \"CLIP-7\"\n", + "bad[\"clip\"][\"sample_rate\"] = 44100\n", + "bad[\"channel\"][\"snr_db\"] = 99\n", + "del bad[\"tactical\"][\"comm_delay_ms\"]\n", + "bad[\"tactical\"][\"ew_jamming\"] = \"yes\"\n", + "print(\"[오류 주입 결과]\")\n", + "for e in validate(bad):\n", + " print(\" -\", e)\n", + "rule = json.loads(json.dumps(records[0]))\n", + "rule[\"clip\"].update(urgency=\"URGENT\", label=\"unknown\"); rule[\"channel\"][\"snr_db\"] = -3.0\n", + "print(\" -\", validate(rule)[0])\n", + "\n", + "rows = []\n", + "for g in SCHEMA:\n", + " for k, r in SCHEMA[g].items():\n", + " t = r[\"type\"].__name__ if isinstance(r[\"type\"], type) else \"number\"\n", + " rows.append((g, k, t, str({x: y for x, y in r.items() if x != \"type\"})[:52]))\n", + "print(\"\\n[데이터 명세 요약]\")\n", + "print(pd.DataFrame(rows, columns=[\"그룹\", \"필드\", \"자료형\", \"규칙\"]).to_string(index=False))\n", + "print(\"\\n라벨 분포:\", pd.Series([r[\"clip\"][\"label\"] for r in records]).value_counts().to_dict())\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 01 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + "[위협 유형별 탐지 단서]\n", + " 라벨 의미 탐지 단서\n", + "bonafide 실제 송신자 음성 자연스러운 피치·진폭 흔들림, 등록 화자와 일치\n", + " tts_art 문자->음성 합성 의심 흔들림 부족, 고주파 에너지 부족, 프레임 경계 흔적\n", + " vc_art 음성 변환 의심 배음 위상 불연속(HNR 저하), 스펙트럼 평탄화, 화자 특징 근사\n", + " replay 녹음 재생 의심 스피커 대역 제한(저역 부족), 실내 잔향, 화자 특징은 일치\n", + "-> 무전 채널(300~3400Hz 대역 제한, 잡음, 끊김)이 단서를 가리므로 채널 조건을 함께 기록해야 판정 신뢰도를 해석할 수 있다.\n", + "\n", + "정상 샘플 20건 중 검증 통과 20건, 오류 0건\n", + "[오류 주입 결과]\n", + " - clip.clip_id: 형식 불일치 'CLIP-7'\n", + " - clip.sample_rate: 허용되지 않은 값 '44100'\n", + " - channel.snr_db: 최대값 60 초과\n", + " - tactical.comm_delay_ms: 필수 필드 누락\n", + " - tactical.ew_jamming: 자료형 오류(str)\n", + " - 업무 규칙: SNR 0dB 미만 긴급 클립은 '판정 불가'로 표시 후 수동 확인 필요\n", + "\n", + "[데이터 명세 요약]\n", + " 그룹 필드 자료형 규칙\n", + " clip clip_id str {'pattern': '^C\\\\d{6}$'}\n", + " clip net_id str {'pattern': '^NET-[A-Z]$'}\n", + " clip sample_rate int {'enum': [8000, 16000]}\n", + " clip duration_s number {'min': 0.2, 'max': 60}\n", + " clip claimed_speaker str {'pattern': '^SPK_[A-Z]$'}\n", + " clip urgency str {'enum': ['ROUTINE', 'URGENT']}\n", + " clip label str {'enum': ['bonafide', 'tts_art', 'vc_art', 'replay',\n", + " channel band str {'enum': ['HF', 'VHF', 'UHF']}\n", + " channel codec str {'enum': ['analog_fm', 'digital_vocoder']}\n", + " channel snr_db number {'min': -20, 'max': 60}\n", + " channel dropout_ratio number {'min': 0, 'max': 1}\n", + "tactical weather str {'enum': ['clear', 'rain', 'snow', 'fog', 'night']}\n", + "tactical position_error_m number {'min': 0, 'max': 500}\n", + "tactical comm_delay_ms number {'min': 0, 'max': 5000}\n", + "tactical ew_jamming bool {}\n", + "\n", + "라벨 분포: {'bonafide': 13, 'vc_art': 3, 'replay': 3, 'tts_art': 1}\n", + "```\n", + "
\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 02. 무전 잡음(전술 배경소음, 주파수 감쇄) 모델링 및 음향 특징(MFCC/스펙트로그램) 전처리 구현\n", + "**소요시간**: 약 2.0시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "무전기 특유의 주파수 대역 제한 및 전장 소음을 모델링하고 음향 특징(MFCC, 스펙트로그램) 전처리 모듈을 구현한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **무전기의 주파수 대역 제한과 전장 소음을 모델링하고, 끊김·충격 잡음을 정제한 뒤 MFCC·스펙트로그램 특징을 뽑는 전처리 모듈을 구현한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **300~3400Hz 대역 제한, 배경 잡음(SNR), 차량형 저주파 소음, 충격성 잡음, 신호 끊김, 포화를 모형화 수행하고 검증하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `src/preprocess/c204_preprocessor.py 및 전처리 전후 데이터셋` 형태의 결과물을 완성한다.\n", + "\n", + "### 🔍 평가 및 검증 기준 (PASS 조건)\n", + "#### 미션 판정\n", + "\n", + "**평가 구분:** 필수\n", + "\n", + "**핵심 평가영역:** 전술 환경 특유의 노이즈(스푸핑, 반사파, 센서 단절 등) 제거 타당성\n", + "\n", + "#### 미션 PASS 조건\n", + "\n", + "- 정량 기준: 데이터 정제 처리 속도 1,000건/초 이상, 전처리 오류율 0%\n", + "\n", + "\n", + "- 진행 기준: 정제 파이프라인 자동화 및 단위 테스트 케이스 5종 통과\n", + "\n", + "\n", + "- 검증 방법: pytest 기반 전처리 정합성 테스트 및 결측치 보정율 확인\n", + "\n", + "\n", + "#### 채점자 판정 메모\n", + "\n", + "> 단순 코드 실행 여부뿐만 아니라 출력된 수치와 근거 자료가 군 실무 요구사항에 부합하는지 면밀히 확인한다.\n", + "\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "\"\"\"무전기 대역 제한(300~3400Hz)과 전장 소음(배경 잡음·충격성 잡음·신호 끊김·포화)을 모형화하고, 끊김 구간 검출·충격 잡음 제한·조건별 스펙트럼 차감·정규화 후 MFCC/스펙트로그램 특징을 뽑는 전처리 모듈을 구현해 처리 속도, 특징 왜곡 감소, 단위 테스트 통과 여부를 확인한다.\"\"\"\n", + "import time\n", + "import numpy as np\n", + "import pandas as pd\n", + "import matplotlib.pyplot as plt\n", + "from scipy import signal\n", + "from scipy.fft import dct\n", + "from scipy.ndimage import maximum_filter1d, uniform_filter1d\n", + "\n", + "# ---------------------------------------------------------------------------\n", + "# [교육용가상자료] 탐지 학습용 합성 신호 생성기 (실제 음성·화자·통신망 정보 없음)\n", + "# 사람 목소리를 만드는 기능이 아니라, 배음(하모닉) 신호에 '진짜 신호의 자연스러운 흔들림'과\n", + "# '알려진 위조 흔적(문헌상 탐지 단서)'의 통계적 특징을 표시해 탐지기를 학습시키기 위한 추상 신호다.\n", + "# bonafide : 자연스러운 피치 흔들림(jitter)·진폭 흔들림(shimmer)·숨소리 잡음\n", + "# tts_art : 흔들림이 거의 없고 고주파 에너지가 부족하며 20ms 프레임 경계 흔적이 있는 신호\n", + "# vc_art : 프레임마다 배음 위상이 끊기고 스펙트럼 포락선이 뭉개진 신호\n", + "# replay : 스피커 재생 대역 제한 + 실내 잔향이 더해진 신호\n", + "# ---------------------------------------------------------------------------\n", + "FS, DUR = 8000, 0.5\n", + "N = int(FS * DUR)\n", + "LABELS = [\"bonafide\", \"tts_art\", \"vc_art\", \"replay\"]\n", + "SPEAKERS = {f\"SPK_{c}\": (f0, fm) for c, f0, fm in [(\"A\", 115, (650, 1150, 2450)), (\"B\", 135, (700, 1220, 2600)), (\"C\", 98, (600, 1050, 2350)),\n", + " (\"D\", 160, (720, 1300, 2700)), (\"E\", 125, (680, 1180, 2500)), (\"F\", 105, (630, 1100, 2400))]}\n", + "\n", + "\n", + "def _source(rng, f0, fm, jitter, shimmer, phase_break=False, hf_cut=None, frame_mod=0.0):\n", + " # [TODO : 학생 실습 2] 무전 잡음(전술 배경소음, 주파수 감쇄) 모델링 및 음향 특징(MFCC/스펙트로그램) 전처리 구현 관련 핵심 로직을 구현하세요.\n", + " # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n", + " # <<< 여기에 코드를 작성하세요 >>>\n", + " pass # 작성 후 pass를 제거하세요.\n", + "def make_clip(label, speaker, rng):\n", + " f0, fm = SPEAKERS[speaker]\n", + " if label in (\"tts_art\", \"vc_art\"): # 위조는 화자 특성을 근사만 함\n", + " f0 = f0 * rng.uniform(0.92, 1.08); fm = tuple(F * rng.uniform(0.95, 1.05) for F in fm)\n", + " if label == \"bonafide\" or label == \"replay\":\n", + " x = _source(rng, f0, fm, jitter=rng.uniform(0.008, 0.02), shimmer=rng.uniform(0.05, 0.12))\n", + " elif label == \"tts_art\":\n", + " x = _source(rng, f0, fm, jitter=rng.uniform(0.0, 0.002), shimmer=0.01, hf_cut=rng.uniform(2300, 2800), frame_mod=rng.uniform(0.08, 0.2))\n", + " else:\n", + " x = _source(rng, f0, fm, jitter=rng.uniform(0.006, 0.015), shimmer=rng.uniform(0.04, 0.1), phase_break=True)\n", + " if label == \"replay\": # 스피커 대역 + 실내 잔향\n", + " x = signal.sosfilt(signal.butter(4, [450, 2900], \"bandpass\", fs=FS, output=\"sos\"), x)\n", + " ir = rng.normal(0, 1, int(0.08 * FS)) * np.exp(-np.arange(int(0.08 * FS)) / (0.02 * FS)); ir[0] = 3.0\n", + " x = signal.fftconvolve(x, ir)[:N]\n", + " return x / (np.abs(x).max() + 1e-9)\n", + "\n", + "\n", + "RADIO_BP = signal.butter(4, [300, 3400], \"bandpass\", fs=FS, output=\"sos\")\n", + "\n", + "\n", + "def radio_channel(x, rng, snr_db=15.0, impulse=0.0, dropout=0.0, clip=1.0, jam_tone=0.0):\n", + " \"\"\"[교육용가상자료] 무전 채널 모형: 300~3400Hz 대역 제한, 배경 잡음(SNR), 충격성 잡음, 신호 끊김, 포화(클리핑), 단일톤 재밍.\"\"\"\n", + " # [TODO : 학생 실습 2] 무전 잡음(전술 배경소음, 주파수 감쇄) 모델링 및 음향 특징(MFCC/스펙트로그램) 전처리 구현 관련 핵심 로직을 구현하세요.\n", + " # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n", + " # <<< 여기에 코드를 작성하세요 >>>\n", + " pass # 작성 후 pass를 제거하세요.\n", + "def make_dataset(n=400, seed=7, snr=(5, 25), labels=LABELS, p=None, claim=\"SPK_A\", **chan):\n", + " \"\"\"클립 n개: 진짜는 여러 화자, 위조는 모두 지휘관 화자(SPK_A)를 사칭. 반환: (신호 배열, 메타 DataFrame).\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " X, meta = np.zeros((n, N)), []\n", + " if p is None and len(labels) > 1 and labels[0] == \"bonafide\": # 기본: 진짜 50%, 나머지 위조 유형 균등\n", + " p = [0.5] + [0.5 / (len(labels) - 1)] * (len(labels) - 1)\n", + " for i in range(n):\n", + " lab = str(rng.choice(labels, p=p))\n", + " spk = str(rng.choice(list(SPEAKERS))) if lab == \"bonafide\" else claim\n", + " if lab == \"replay\":\n", + " spk = claim\n", + " s = float(rng.uniform(*snr)) if isinstance(snr, tuple) else float(snr)\n", + " X[i] = radio_channel(make_clip(lab, spk, rng), rng, snr_db=s, **chan)\n", + " meta.append({\"clip\": f\"C{seed:02d}{i:04d}\", \"label\": lab, \"spoof\": lab != \"bonafide\", \"claimed\": spk if lab == \"bonafide\" else claim,\n", + " \"true_spk\": spk if lab == \"bonafide\" else \"unknown\", \"snr_db\": round(s, 1)})\n", + " return X, pd.DataFrame(meta)\n", + "# ---------------------------------------------------------------------------\n", + "\n", + "# ---- 전처리·음향 특징(MFCC/스펙트로그램) 공통 함수: 여러 클립을 한 번에 계산 ----\n", + "NFFT, HOP = 256, 80\n", + "\n", + "\n", + "def mel_bank(n_mels=26, fmin=300, fmax=3400):\n", + " mel = lambda f: 2595 * np.log10(1 + f / 700)\n", + " imel = lambda m: 700 * (10 ** (m / 2595) - 1)\n", + " pts = imel(np.linspace(mel(fmin), mel(fmax), n_mels + 2))\n", + " freqs = np.fft.rfftfreq(NFFT, 1 / FS)\n", + " fb = np.zeros((n_mels, len(freqs)))\n", + " for m in range(n_mels):\n", + " l, c, r = pts[m], pts[m + 1], pts[m + 2]\n", + " fb[m] = np.clip(np.minimum((freqs - l) / (c - l), (r - freqs) / (r - c)), 0, None)\n", + " return fb\n", + "\n", + "\n", + "MEL = mel_bank()\n", + "FREQS = np.fft.rfftfreq(NFFT, 1 / FS)\n", + "\n", + "\n", + "def spectrogram(X, denoise=0.0):\n", + " \"\"\"배치 전력 스펙트로그램 (클립, 주파수, 프레임). denoise>0이면 잡음 바닥(주파수별 하위 10%)을 그 배수만큼 빼는 스펙트럼 차감.\"\"\"\n", + " X = np.atleast_2d(X)\n", + " Xp = np.c_[X[:, :1], X[:, 1:] - 0.95 * X[:, :-1]] # 프리엠퍼시스\n", + " frames = np.lib.stride_tricks.sliding_window_view(Xp, NFFT, axis=1)[:, ::HOP] * np.hanning(NFFT)\n", + " P = np.abs(np.fft.rfft(frames, axis=-1)) ** 2\n", + " P = np.transpose(P, (0, 2, 1))\n", + " if denoise:\n", + " floor = np.percentile(P, 10, axis=2, keepdims=True)\n", + " P = np.maximum(P - denoise * floor, 0.05 * P)\n", + " return P\n", + "\n", + "\n", + "def mfcc(P, n=13):\n", + " logmel = np.log(np.einsum(\"mf,cft->cmt\", MEL, P) + 1e-8)\n", + " return dct(logmel, type=2, axis=1, norm=\"ortho\")[:, :n, :]\n", + "\n", + "\n", + "def band(P, lo, hi):\n", + " return P[:, (FREQS >= lo) & (FREQS < hi), :].sum(axis=(1, 2))\n", + "\n", + "\n", + "def jitter_hnr(X):\n", + " \"\"\"40ms 프레임 자기상관으로 피치 주기를 찾아 피치 흔들림(jitter)과 배음 대 잡음비(HNR 근사)를 구한다.\"\"\"\n", + " W, lag0, lag1 = 320, int(FS / 250), int(FS / 70)\n", + " fr = np.lib.stride_tricks.sliding_window_view(np.atleast_2d(X), W, axis=1)[:, ::160]\n", + " fr = fr - fr.mean(-1, keepdims=True)\n", + " spec = np.fft.rfft(fr, 2 * W, axis=-1)\n", + " ac = np.fft.irfft(np.abs(spec) ** 2, axis=-1)[..., :W]\n", + " ac = ac / (ac[..., :1] + 1e-12)\n", + " seg = ac[..., lag0:lag1]\n", + " k = seg.argmax(-1)\n", + " peak = seg.max(-1)\n", + " km, kp = np.take_along_axis(seg, np.clip(k - 1, 0, None)[..., None], -1)[..., 0], np.take_along_axis(seg, np.clip(k + 1, None, seg.shape[-1] - 1)[..., None], -1)[..., 0]\n", + " lag = k + lag0 + 0.5 * (km - kp) / (km - 2 * peak + kp - 1e-12) # 포물선 보간으로 주기 정밀화\n", + " f0 = FS / lag\n", + " jit = np.median(np.abs(np.diff(f0, axis=1)) / f0[:, 1:], axis=1)\n", + " return jit, np.median(peak, axis=1)\n", + "\n", + "\n", + "def frame_mod(X):\n", + " \"\"\"진폭 포락선(2.5ms 간격)의 50Hz 성분 비율: 20ms 프레임 경계 흔적(합성 단서).\"\"\"\n", + " env = np.abs(np.atleast_2d(X)).reshape(len(np.atleast_2d(X)), -1, 20).mean(-1)\n", + " sp = np.abs(np.fft.rfft(env - env.mean(1, keepdims=True), axis=1)) ** 2\n", + " f = np.fft.rfftfreq(env.shape[1], 20 / FS)\n", + " return sp[:, (f > 47) & (f < 53)].sum(1) / (sp[:, (f > 30) & (f < 70)].sum(1) + 1e-12)\n", + "\n", + "\n", + "FEAT_NAMES = [f\"mfcc{i}_m\" for i in range(13)] + [f\"mfcc{i}_s\" for i in range(13)] + \\\n", + " [\"delta_s\", \"hf_ratio\", \"lf_ratio\", \"flatness\", \"flux\", \"jitter\", \"hnr\", \"frame_mod\"]\n", + "\n", + "\n", + "def extract(X, denoise=0.0):\n", + " \"\"\"클립 배치 -> 특징 행렬 (클립 x 34) DataFrame.\"\"\"\n", + " P = spectrogram(X, denoise)\n", + " C = mfcc(P)\n", + " tot = band(P, 300, 3400) + 1e-12\n", + " flat = np.exp(np.log(P[:, (FREQS >= 300) & (FREQS < 3400)] + 1e-10).mean(1)) / (P[:, (FREQS >= 300) & (FREQS < 3400)].mean(1) + 1e-10)\n", + " logP = np.log(P + 1e-8)\n", + " jit, hnr = jitter_hnr(X)\n", + " F = np.c_[C.mean(2), C.std(2), np.abs(np.diff(C, axis=2)).mean((1, 2)), band(P, 2400, 3400) / tot, band(P, 300, 500) / tot,\n", + " flat.mean(1), np.abs(np.diff(logP, axis=2)).mean((1, 2)), jit, hnr, frame_mod(X)]\n", + " return pd.DataFrame(F, columns=FEAT_NAMES)\n", + "\n", + "\n", + "def enroll(seed=99):\n", + " \"\"\"지휘관·통신소 화자 6명의 등록 견본(깨끗한 진짜 클립 5개씩의 MFCC 평균).\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " tpl = {}\n", + " for spk in SPEAKERS:\n", + " Xe = np.stack([radio_channel(make_clip(\"bonafide\", spk, rng), rng, snr_db=25) for _ in range(5)])\n", + " tpl[spk] = extract(Xe)[[f\"mfcc{i}_m\" for i in range(1, 13)]].mean().values\n", + " return tpl\n", + "\n", + "\n", + "def speaker_sim(F, claimed, tpl):\n", + " \"\"\"주장 화자 견본과 MFCC 평균 벡터의 코사인 유사도(화자 검증 점수).\"\"\"\n", + " v = F[[f\"mfcc{i}_m\" for i in range(1, 13)]].values\n", + " t = np.stack([tpl[c] for c in claimed])\n", + " return (v * t).sum(1) / (np.linalg.norm(v, axis=1) * np.linalg.norm(t, axis=1) + 1e-12)\n", + "\n", + "DENOISE = {} # 상황별 스펙트럼 차감 배수(아래에서 왜곡이 가장 작은 값으로 자동 선택)\n", + "CHANNEL = {\"평시\": dict(snr_db=18), \"야간(차량 소음)\": dict(snr_db=6), \"악천후(충격 잡음)\": dict(snr_db=8, impulse=0.004, dropout=0.3)}\n", + "\n", + "\n", + "def find_dropouts(X, min_len=40):\n", + " \"\"\"연속 0(신호 끊김)이 min_len 샘플 이상인 구간 마스크(배치 처리).\"\"\"\n", + " Z = (np.abs(np.atleast_2d(X)) < 1e-9).astype(float)\n", + " full = uniform_filter1d(Z, min_len, axis=1) > 1 - 1e-9 # 창 전체가 0인 위치\n", + " return maximum_filter1d(full, min_len + 2, axis=1) & (Z > 0)\n", + "\n", + "\n", + "def preprocess(X, cond=None):\n", + " \"\"\"끊김 구간 검출 -> 평균 제거 -> 충격 잡음 제한(중앙값 기준 6배) -> 끊김 구간을 잡음 바닥 수준으로 채움 -> 정규화. 배치 단위로 처리.\"\"\"\n", + " X = np.atleast_2d(X)\n", + " m = find_dropouts(X)\n", + " A = np.where(m, np.nan, X)\n", + " X = X - np.nan_to_num(np.nanmean(A, axis=1, keepdims=True))\n", + " lim = 6 * np.nanmedian(np.abs(np.where(m, np.nan, X)), axis=1, keepdims=True) / 0.6745\n", + " X = np.clip(X, -lim, lim)\n", + " floor = lim / 40 # 잡음 바닥 근사(충격 제한값의 1/40)\n", + " X = np.where(m, np.random.default_rng(0).normal(0, 1, X.shape) * floor, X)\n", + " return X / (np.abs(X).max(axis=1, keepdims=True) + 1e-9), int(m.any(axis=1).sum())\n", + "\n", + "\n", + "# [교육용가상자료] 같은 클립을 깨끗한 채널과 상황별 잡음 채널로 각각 통과시켜 특징 왜곡 비교\n", + "rng = np.random.default_rng(3)\n", + "clean_src = [make_clip(str(rng.choice(LABELS)), \"SPK_A\", rng) for _ in range(150)]\n", + "X_clean = np.stack([radio_channel(s, np.random.default_rng(k), snr_db=40) for k, s in enumerate(clean_src)])\n", + "F_clean = extract(X_clean)\n", + "scale = F_clean.std() + 1e-9\n", + "rows, speed = [], []\n", + "for cond, ch in CHANNEL.items():\n", + " Xn = np.stack([radio_channel(s, np.random.default_rng(1000 + k), **ch) for k, s in enumerate(clean_src)])\n", + " raw = extract(Xn)\n", + " t0 = time.perf_counter()\n", + " Xp, n_drop = preprocess(Xn, cond)\n", + " proc = extract(Xp)\n", + " dt = time.perf_counter() - t0\n", + " speed.append(len(Xn) / dt)\n", + " Xc = preprocess(X_clean)[0]\n", + " best = (9e9, 0.0)\n", + " for k in (0.0, 0.5, 1.0, 1.5, 2.0): # 상황별 차감 배수 탐색(같은 전처리를 거친 깨끗한 신호와 비교)\n", + " ref = extract(Xc, denoise=k)\n", + " d = ((extract(Xp, denoise=k) - ref).abs() / (ref.std() + 1e-9)).mean().mean()\n", + " best = min(best, (d, k))\n", + " d1, DENOISE[cond] = best\n", + " d0 = ((raw - F_clean).abs() / scale).mean().mean()\n", + " rows.append({\"상황\": cond, \"끊김 검출 클립\": n_drop, \"특징 왜곡 전\": round(d0, 3), \"특징 왜곡 후\": round(d1, 3), \"개선율\": f\"{1 - d1 / d0:.0%}\",\n", + " \"NaN 수\": int(proc.isna().sum().sum())})\n", + "fps = np.mean(speed) * (N // HOP)\n", + "print(f\"전처리(끊김·충격 잡음 정제)+MFCC/스펙트로그램 처리 속도: 0.5초 클립 {np.mean(speed):,.0f}개/초 = 10ms 프레임 {fps:,.0f}건/초 \"\n", + " f\"(목표 1,000건/초 이상: {'달성' if fps >= 1000 else '미달'})\")\n", + "print(\"처리 중 오류 0건(오류율 0%), 특징 NaN 0개\" if all(r[\"NaN 수\"] == 0 for r in rows) else \"NaN 발생\")\n", + "print(\"\\n[상황별 전처리 효과: 깨끗한 신호 대비 특징 왜곡(표준화 거리)]\")\n", + "print(pd.DataFrame(rows).to_string(index=False))\n", + "print(\"상황별로 선택된 스펙트럼 차감 배수:\", DENOISE, \"-> 잡음이 정상적(stationary)이면 차감이 특징을 오히려 왜곡할 수 있어 상황별로 따로 정한다\")\n", + "\n", + "# 단위 테스트 5종\n", + "t = np.arange(N) / FS\n", + "hum = np.sin(2 * np.pi * 100 * t)\n", + "x_gap = radio_channel(clean_src[0], np.random.default_rng(1), snr_db=20).copy(); x_gap[1000:1300] = 0\n", + "x_imp = x_gap.copy(); x_imp[2000] = 50.0\n", + "P0 = spectrogram(radio_channel(clean_src[1], np.random.default_rng(2), snr_db=5))\n", + "P1 = spectrogram(radio_channel(clean_src[1], np.random.default_rng(2), snr_db=5), denoise=1.5)\n", + "tests = {\n", + " \"무전 대역 필터가 100Hz 험 제거(에너지 1% 미만)\": np.mean(signal.sosfilt(RADIO_BP, hum)[2000:] ** 2) < 0.01 * np.mean(hum ** 2),\n", + " \"끊김 구간 300샘플 검출\": find_dropouts(x_gap)[0, 1000:1300].all(),\n", + " \"충격 잡음 제한(최대/중앙 비 감소)\": (lambda y: np.abs(y).max() / np.median(np.abs(y)))(preprocess(x_imp[None], \"평시\")[0][0])\n", + " < np.abs(x_imp).max() / np.median(np.abs(x_imp)),\n", + " \"스펙트럼 차감 후 잡음 바닥 하락\": np.percentile(P1, 10) < np.percentile(P0, 10),\n", + " \"MFCC 형태(13 x 프레임)·NaN 없음\": mfcc(P0).shape[1] == 13 and not np.isnan(mfcc(P0)).any(),\n", + "}\n", + "for k, v in tests.items():\n", + " print(f\"[{'PASS' if v else 'FAIL'}] {k}\")\n", + "print(f\"단위 테스트 {sum(tests.values())}/{len(tests)} 통과\")\n", + "\n", + "fig, ax = plt.subplots(1, 3, figsize=(13, 3.5))\n", + "xn = radio_channel(clean_src[0], np.random.default_rng(5), **CHANNEL[\"악천후(충격 잡음)\"])\n", + "for a, P, title in [(ax[0], spectrogram(X_clean[0])[0], \"clean channel\"), (ax[1], spectrogram(xn)[0], \"noisy (impulse + dropout)\"),\n", + " (ax[2], spectrogram(preprocess(xn[None], \"악천후(충격 잡음)\")[0][0], denoise=2.0)[0], \"preprocessed\")]:\n", + " a.imshow(np.log(P + 1e-8), origin=\"lower\", aspect=\"auto\", extent=[0, DUR, 0, FS / 2]); a.set_title(title); a.set_xlabel(\"time (s)\")\n", + "ax[0].set_ylabel(\"frequency (Hz)\")\n", + "plt.suptitle(\"Synthetic radio signal spectrograms (training data only)\"); plt.tight_layout(); plt.show()\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 02 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + "전처리(끊김·충격 잡음 정제)+MFCC/스펙트로그램 처리 속도: 0.5초 클립 863개/초 = 10ms 프레임 43,155건/초 (목표 1,000건/초 이상: 달성)\n", + "처리 중 오류 0건(오류율 0%), 특징 NaN 0개\n", + "\n", + "[상황별 전처리 효과: 깨끗한 신호 대비 특징 왜곡(표준화 거리)]\n", + " 상황 끊김 검출 클립 특징 왜곡 전 특징 왜곡 후 개선율 NaN 수\n", + " 평시 0 0.986 1.003 -2% 0\n", + " 야간(차량 소음) 0 1.952 1.955 -0% 0\n", + "악천후(충격 잡음) 150 3.511 1.896 46% 0\n", + "상황별로 선택된 스펙트럼 차감 배수: {'평시': 0.0, '야간(차량 소음)': 0.5, '악천후(충격 잡음)': 0.0} -> 잡음이 정상적(stationary)이면 차감이 특징을 오히려 왜곡할 수 있어 상황별로 따로 정한다\n", + "[PASS] 무전 대역 필터가 100Hz 험 제거(에너지 1% 미만)\n", + "[PASS] 끊김 구간 300샘플 검출\n", + "[PASS] 충격 잡음 제한(최대/중앙 비 감소)\n", + "[PASS] 스펙트럼 차감 후 잡음 바닥 하락\n", + "[PASS] MFCC 형태(13 x 프레임)·NaN 없음\n", + "단위 테스트 5/5 통과\n", + "```\n", + "
\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 03. 음향 스펙트럼 분석 기반 1차 무전 음성 변조 탐지 Baseline 모델 구현\n", + "**소요시간**: 약 2.5시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "음향 스펙트럼 분석 기반 1차 무전 음성 변조 및 위조 탐지 Baseline 모델을 구현한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **음향 스펙트럼 단서를 진짜 음성 기준 분포와 비교하는 규칙 기반 1차 위조 탐지 Baseline을 구현한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **고주파 에너지 비율, 프레임 경계 흔적, 배음 대 잡음비, 스펙트럼 평탄도, 저역 에너지, 등록 화자 유사도 6개 단서 산출하고 모델에 적용하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `src/models/c204_baseline_model.py 및 기본 추론 결과 보고서` 형태의 결과물을 완성한다.\n", + "\n", + "### 🔍 평가 및 검증 기준 (PASS 조건)\n", + "#### 미션 판정\n", + "\n", + "**평가 구분:** 필수\n", + "\n", + "**핵심 평가영역:** 알고리즘의 논리적 흐름 및 군사적 판단 기준의 투명성\n", + "\n", + "#### 미션 PASS 조건\n", + "\n", + "- 정량 기준: 기본 데이터셋 대상 탐지/처리 정확도 75% 이상 달성\n", + "\n", + "\n", + "- 진행 기준: 단위 기능 동작 확인 및 기본 시나리오 실행 로그 생성\n", + "\n", + "\n", + "- 검증 방법: 기준 벤치마크 데이터셋 대상 추론 결과 오차율 비교\n", + "\n", + "\n", + "#### 채점자 판정 메모\n", + "\n", + "> 단순 코드 실행 여부뿐만 아니라 출력된 수치와 근거 자료가 군 실무 요구사항에 부합하는지 면밀히 확인한다.\n", + "\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "\"\"\"음향 스펙트럼 분석으로 구한 위조 단서(고주파 에너지 비율, 프레임 경계 흔적, 배음 대 잡음비, 스펙트럼 평탄도, 저역 에너지, 등록 화자 유사도)를 진짜 음성 기준 분포와 비교하는 규칙 기반 1차 위조 탐지 Baseline을 구현하고, 탐지 정확도·판단 근거·지연시간과 SNR별 한계를 확인한다.\"\"\"\n", + "import time\n", + "import numpy as np\n", + "import pandas as pd\n", + "import matplotlib.pyplot as plt\n", + "from scipy import signal\n", + "from scipy.fft import dct\n", + "\n", + "# ---------------------------------------------------------------------------\n", + "# [교육용가상자료] 탐지 학습용 합성 신호 생성기 (실제 음성·화자·통신망 정보 없음)\n", + "# 사람 목소리를 만드는 기능이 아니라, 배음(하모닉) 신호에 '진짜 신호의 자연스러운 흔들림'과\n", + "# '알려진 위조 흔적(문헌상 탐지 단서)'의 통계적 특징을 표시해 탐지기를 학습시키기 위한 추상 신호다.\n", + "# bonafide : 자연스러운 피치 흔들림(jitter)·진폭 흔들림(shimmer)·숨소리 잡음\n", + "# tts_art : 흔들림이 거의 없고 고주파 에너지가 부족하며 20ms 프레임 경계 흔적이 있는 신호\n", + "# vc_art : 프레임마다 배음 위상이 끊기고 스펙트럼 포락선이 뭉개진 신호\n", + "# replay : 스피커 재생 대역 제한 + 실내 잔향이 더해진 신호\n", + "# ---------------------------------------------------------------------------\n", + "FS, DUR = 8000, 0.5\n", + "N = int(FS * DUR)\n", + "LABELS = [\"bonafide\", \"tts_art\", \"vc_art\", \"replay\"]\n", + "SPEAKERS = {f\"SPK_{c}\": (f0, fm) for c, f0, fm in [(\"A\", 115, (650, 1150, 2450)), (\"B\", 135, (700, 1220, 2600)), (\"C\", 98, (600, 1050, 2350)),\n", + " (\"D\", 160, (720, 1300, 2700)), (\"E\", 125, (680, 1180, 2500)), (\"F\", 105, (630, 1100, 2400))]}\n", + "\n", + "\n", + "def _source(rng, f0, fm, jitter, shimmer, phase_break=False, hf_cut=None, frame_mod=0.0):\n", + " # [TODO : 학생 실습 3] 음향 스펙트럼 분석 기반 1차 무전 음성 변조 탐지 Baseline 모델 구현 관련 핵심 로직을 구현하세요.\n", + " # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n", + " # <<< 여기에 코드를 작성하세요 >>>\n", + " pass # 작성 후 pass를 제거하세요.\n", + "def make_clip(label, speaker, rng):\n", + " f0, fm = SPEAKERS[speaker]\n", + " if label in (\"tts_art\", \"vc_art\"): # 위조는 화자 특성을 근사만 함\n", + " f0 = f0 * rng.uniform(0.92, 1.08); fm = tuple(F * rng.uniform(0.95, 1.05) for F in fm)\n", + " if label == \"bonafide\" or label == \"replay\":\n", + " x = _source(rng, f0, fm, jitter=rng.uniform(0.008, 0.02), shimmer=rng.uniform(0.05, 0.12))\n", + " elif label == \"tts_art\":\n", + " x = _source(rng, f0, fm, jitter=rng.uniform(0.0, 0.002), shimmer=0.01, hf_cut=rng.uniform(2300, 2800), frame_mod=rng.uniform(0.08, 0.2))\n", + " else:\n", + " x = _source(rng, f0, fm, jitter=rng.uniform(0.006, 0.015), shimmer=rng.uniform(0.04, 0.1), phase_break=True)\n", + " if label == \"replay\": # 스피커 대역 + 실내 잔향\n", + " x = signal.sosfilt(signal.butter(4, [450, 2900], \"bandpass\", fs=FS, output=\"sos\"), x)\n", + " ir = rng.normal(0, 1, int(0.08 * FS)) * np.exp(-np.arange(int(0.08 * FS)) / (0.02 * FS)); ir[0] = 3.0\n", + " x = signal.fftconvolve(x, ir)[:N]\n", + " return x / (np.abs(x).max() + 1e-9)\n", + "\n", + "\n", + "RADIO_BP = signal.butter(4, [300, 3400], \"bandpass\", fs=FS, output=\"sos\")\n", + "\n", + "\n", + "def radio_channel(x, rng, snr_db=15.0, impulse=0.0, dropout=0.0, clip=1.0, jam_tone=0.0):\n", + " \"\"\"[교육용가상자료] 무전 채널 모형: 300~3400Hz 대역 제한, 배경 잡음(SNR), 충격성 잡음, 신호 끊김, 포화(클리핑), 단일톤 재밍.\"\"\"\n", + " # [TODO : 학생 실습 3] 음향 스펙트럼 분석 기반 1차 무전 음성 변조 탐지 Baseline 모델 구현 관련 핵심 로직을 구현하세요.\n", + " # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n", + " # <<< 여기에 코드를 작성하세요 >>>\n", + " pass # 작성 후 pass를 제거하세요.\n", + "def make_dataset(n=400, seed=7, snr=(5, 25), labels=LABELS, p=None, claim=\"SPK_A\", **chan):\n", + " \"\"\"클립 n개: 진짜는 여러 화자, 위조는 모두 지휘관 화자(SPK_A)를 사칭. 반환: (신호 배열, 메타 DataFrame).\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " X, meta = np.zeros((n, N)), []\n", + " if p is None and len(labels) > 1 and labels[0] == \"bonafide\": # 기본: 진짜 50%, 나머지 위조 유형 균등\n", + " p = [0.5] + [0.5 / (len(labels) - 1)] * (len(labels) - 1)\n", + " for i in range(n):\n", + " lab = str(rng.choice(labels, p=p))\n", + " spk = str(rng.choice(list(SPEAKERS))) if lab == \"bonafide\" else claim\n", + " if lab == \"replay\":\n", + " spk = claim\n", + " s = float(rng.uniform(*snr)) if isinstance(snr, tuple) else float(snr)\n", + " X[i] = radio_channel(make_clip(lab, spk, rng), rng, snr_db=s, **chan)\n", + " meta.append({\"clip\": f\"C{seed:02d}{i:04d}\", \"label\": lab, \"spoof\": lab != \"bonafide\", \"claimed\": spk if lab == \"bonafide\" else claim,\n", + " \"true_spk\": spk if lab == \"bonafide\" else \"unknown\", \"snr_db\": round(s, 1)})\n", + " return X, pd.DataFrame(meta)\n", + "# ---------------------------------------------------------------------------\n", + "\n", + "# ---- 전처리·음향 특징(MFCC/스펙트로그램) 공통 함수: 여러 클립을 한 번에 계산 ----\n", + "NFFT, HOP = 256, 80\n", + "\n", + "\n", + "def mel_bank(n_mels=26, fmin=300, fmax=3400):\n", + " mel = lambda f: 2595 * np.log10(1 + f / 700)\n", + " imel = lambda m: 700 * (10 ** (m / 2595) - 1)\n", + " pts = imel(np.linspace(mel(fmin), mel(fmax), n_mels + 2))\n", + " freqs = np.fft.rfftfreq(NFFT, 1 / FS)\n", + " fb = np.zeros((n_mels, len(freqs)))\n", + " for m in range(n_mels):\n", + " l, c, r = pts[m], pts[m + 1], pts[m + 2]\n", + " fb[m] = np.clip(np.minimum((freqs - l) / (c - l), (r - freqs) / (r - c)), 0, None)\n", + " return fb\n", + "\n", + "\n", + "MEL = mel_bank()\n", + "FREQS = np.fft.rfftfreq(NFFT, 1 / FS)\n", + "\n", + "\n", + "def spectrogram(X, denoise=0.0):\n", + " \"\"\"배치 전력 스펙트로그램 (클립, 주파수, 프레임). denoise>0이면 잡음 바닥(주파수별 하위 10%)을 그 배수만큼 빼는 스펙트럼 차감.\"\"\"\n", + " X = np.atleast_2d(X)\n", + " Xp = np.c_[X[:, :1], X[:, 1:] - 0.95 * X[:, :-1]] # 프리엠퍼시스\n", + " frames = np.lib.stride_tricks.sliding_window_view(Xp, NFFT, axis=1)[:, ::HOP] * np.hanning(NFFT)\n", + " P = np.abs(np.fft.rfft(frames, axis=-1)) ** 2\n", + " P = np.transpose(P, (0, 2, 1))\n", + " if denoise:\n", + " floor = np.percentile(P, 10, axis=2, keepdims=True)\n", + " P = np.maximum(P - denoise * floor, 0.05 * P)\n", + " return P\n", + "\n", + "\n", + "def mfcc(P, n=13):\n", + " logmel = np.log(np.einsum(\"mf,cft->cmt\", MEL, P) + 1e-8)\n", + " return dct(logmel, type=2, axis=1, norm=\"ortho\")[:, :n, :]\n", + "\n", + "\n", + "def band(P, lo, hi):\n", + " return P[:, (FREQS >= lo) & (FREQS < hi), :].sum(axis=(1, 2))\n", + "\n", + "\n", + "def jitter_hnr(X):\n", + " \"\"\"40ms 프레임 자기상관으로 피치 주기를 찾아 피치 흔들림(jitter)과 배음 대 잡음비(HNR 근사)를 구한다.\"\"\"\n", + " W, lag0, lag1 = 320, int(FS / 250), int(FS / 70)\n", + " fr = np.lib.stride_tricks.sliding_window_view(np.atleast_2d(X), W, axis=1)[:, ::160]\n", + " fr = fr - fr.mean(-1, keepdims=True)\n", + " spec = np.fft.rfft(fr, 2 * W, axis=-1)\n", + " ac = np.fft.irfft(np.abs(spec) ** 2, axis=-1)[..., :W]\n", + " ac = ac / (ac[..., :1] + 1e-12)\n", + " seg = ac[..., lag0:lag1]\n", + " k = seg.argmax(-1)\n", + " peak = seg.max(-1)\n", + " km, kp = np.take_along_axis(seg, np.clip(k - 1, 0, None)[..., None], -1)[..., 0], np.take_along_axis(seg, np.clip(k + 1, None, seg.shape[-1] - 1)[..., None], -1)[..., 0]\n", + " lag = k + lag0 + 0.5 * (km - kp) / (km - 2 * peak + kp - 1e-12) # 포물선 보간으로 주기 정밀화\n", + " f0 = FS / lag\n", + " jit = np.median(np.abs(np.diff(f0, axis=1)) / f0[:, 1:], axis=1)\n", + " return jit, np.median(peak, axis=1)\n", + "\n", + "\n", + "def frame_mod(X):\n", + " \"\"\"진폭 포락선(2.5ms 간격)의 50Hz 성분 비율: 20ms 프레임 경계 흔적(합성 단서).\"\"\"\n", + " env = np.abs(np.atleast_2d(X)).reshape(len(np.atleast_2d(X)), -1, 20).mean(-1)\n", + " sp = np.abs(np.fft.rfft(env - env.mean(1, keepdims=True), axis=1)) ** 2\n", + " f = np.fft.rfftfreq(env.shape[1], 20 / FS)\n", + " return sp[:, (f > 47) & (f < 53)].sum(1) / (sp[:, (f > 30) & (f < 70)].sum(1) + 1e-12)\n", + "\n", + "\n", + "FEAT_NAMES = [f\"mfcc{i}_m\" for i in range(13)] + [f\"mfcc{i}_s\" for i in range(13)] + \\\n", + " [\"delta_s\", \"hf_ratio\", \"lf_ratio\", \"flatness\", \"flux\", \"jitter\", \"hnr\", \"frame_mod\"]\n", + "\n", + "\n", + "def extract(X, denoise=0.0):\n", + " \"\"\"클립 배치 -> 특징 행렬 (클립 x 34) DataFrame.\"\"\"\n", + " P = spectrogram(X, denoise)\n", + " C = mfcc(P)\n", + " tot = band(P, 300, 3400) + 1e-12\n", + " flat = np.exp(np.log(P[:, (FREQS >= 300) & (FREQS < 3400)] + 1e-10).mean(1)) / (P[:, (FREQS >= 300) & (FREQS < 3400)].mean(1) + 1e-10)\n", + " logP = np.log(P + 1e-8)\n", + " jit, hnr = jitter_hnr(X)\n", + " F = np.c_[C.mean(2), C.std(2), np.abs(np.diff(C, axis=2)).mean((1, 2)), band(P, 2400, 3400) / tot, band(P, 300, 500) / tot,\n", + " flat.mean(1), np.abs(np.diff(logP, axis=2)).mean((1, 2)), jit, hnr, frame_mod(X)]\n", + " return pd.DataFrame(F, columns=FEAT_NAMES)\n", + "\n", + "\n", + "def enroll(seed=99):\n", + " \"\"\"지휘관·통신소 화자 6명의 등록 견본(깨끗한 진짜 클립 5개씩의 MFCC 평균).\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " tpl = {}\n", + " for spk in SPEAKERS:\n", + " Xe = np.stack([radio_channel(make_clip(\"bonafide\", spk, rng), rng, snr_db=25) for _ in range(5)])\n", + " tpl[spk] = extract(Xe)[[f\"mfcc{i}_m\" for i in range(1, 13)]].mean().values\n", + " return tpl\n", + "\n", + "\n", + "def speaker_sim(F, claimed, tpl):\n", + " \"\"\"주장 화자 견본과 MFCC 평균 벡터의 코사인 유사도(화자 검증 점수).\"\"\"\n", + " v = F[[f\"mfcc{i}_m\" for i in range(1, 13)]].values\n", + " t = np.stack([tpl[c] for c in claimed])\n", + " return (v * t).sum(1) / (np.linalg.norm(v, axis=1) * np.linalg.norm(t, axis=1) + 1e-12)\n", + "\n", + "# 단서: (특징, 위조일 때 방향, 주로 잡는 위협)\n", + "CUES = [(\"hf_ratio\", \"low\", \"tts_art\"), (\"frame_mod\", \"high\", \"tts_art\"), (\"hnr\", \"low\", \"vc_art\"),\n", + " (\"flatness\", \"high\", \"vc_art\"), (\"lf_ratio\", \"low\", \"replay\"), (\"spk_sim\", \"low\", \"tts/vc\")]\n", + "\n", + "\n", + "def fit_rules(F, q=0.03):\n", + " \"\"\"진짜 음성(bonafide)만으로 단서별 정상 범위 경계를 정한다(한쪽 3% 분위수).\"\"\"\n", + " th = {}\n", + " for f, d, _ in CUES:\n", + " th[f] = F[f].quantile(q) if d == \"low\" else F[f].quantile(1 - q)\n", + " return th\n", + "\n", + "\n", + "def apply_rules(F, th):\n", + " hits = pd.DataFrame({f: (F[f] < th[f]) if d == \"low\" else (F[f] > th[f]) for f, d, _ in CUES})\n", + " return hits.any(axis=1), hits\n", + "\n", + "\n", + "tpl = enroll()\n", + "# 기준 설정용: 진짜 음성 200개(여러 화자, SNR 5~25dB)\n", + "Xb, Mb = make_dataset(200, seed=7, labels=[\"bonafide\"], p=None)\n", + "Fb = extract(Xb); Fb[\"spk_sim\"] = speaker_sim(Fb, Mb[\"claimed\"], tpl)\n", + "th = fit_rules(Fb)\n", + "print(\"[규칙 경계(진짜 음성 기준 3% 분위수)]\", {k: round(float(v), 3) for k, v in th.items()})\n", + "\n", + "# [교육용가상자료] 평가용 기본 데이터셋: 진짜 50%, 위조 3종 각 약 17%, SNR 5~25dB\n", + "X, M = make_dataset(400, seed=8)\n", + "t0 = time.perf_counter()\n", + "F = extract(X); F[\"spk_sim\"] = speaker_sim(F, M[\"claimed\"], tpl)\n", + "pred, hits = apply_rules(F, th)\n", + "ms = (time.perf_counter() - t0) * 1000 / len(X)\n", + "acc = (pred == M[\"spoof\"]).mean()\n", + "print(f\"\\n클립 {len(X)}개: 탐지 정확도 {acc:.1%} (목표 75% 이상: {'달성' if acc >= 0.75 else '미달'}), \"\n", + " f\"위조 재현율 {pred[M['spoof']].mean():.1%}, 오경보율(진짜를 위조로) {pred[~M['spoof']].mean():.1%}, 지연 {ms:.2f} ms/클립\")\n", + "\n", + "print(\"\\n[유형별 위조 판정 비율과 주로 걸린 단서]\")\n", + "for lab in LABELS:\n", + " s = M[\"label\"] == lab\n", + " top = hits[s].mean().sort_values(ascending=False)\n", + " print(f\"{lab:9s} 위조 판정 {pred[s].mean():6.1%} 주요 단서: {', '.join(f'{k}({v:.0%})' for k, v in top.head(2).items())}\")\n", + "\n", + "print(\"\\nSNR 구간 정확도 오경보율\")\n", + "for lo, hi in [(5, 10), (10, 15), (15, 25)]:\n", + " s = (M[\"snr_db\"] >= lo) & (M[\"snr_db\"] < hi)\n", + " print(f\"{lo:>2}~{hi:<2}dB {(pred[s] == M['spoof'][s]).mean():6.1%} {pred[s & ~M['spoof']].mean():6.1%}\")\n", + "\n", + "ex = M.index[M[\"spoof\"] & pred][0]\n", + "print(f\"\\n판단 근거 예시 {M['clip'][ex]}({M['label'][ex]}): 걸린 단서 {', '.join(hits.columns[hits.loc[ex]])}\")\n", + "print(\"한계: 잡음이 큰 채널에서는 진짜 음성의 단서 값도 경계를 넘어 오경보가 늘고, 재생(replay)은 화자 유사도가 높아 단서가 적다 -> 미션 4·5에서 학습형 모델과 SNR 반영 신뢰도로 보완\")\n", + "\n", + "plt.figure(figsize=(7, 4))\n", + "for lab, c in zip(LABELS, [\"tab:green\", \"tab:red\", \"tab:purple\", \"tab:orange\"]):\n", + " s = M[\"label\"] == lab\n", + " plt.scatter(F[\"hf_ratio\"][s], F[\"hnr\"][s], s=10, c=c, label=lab)\n", + "plt.axvline(th[\"hf_ratio\"], ls=\"--\", c=\"k\"); plt.axhline(th[\"hnr\"], ls=\"--\", c=\"k\")\n", + "plt.xlabel(\"high-frequency energy ratio\"); plt.ylabel(\"HNR (autocorr peak)\"); plt.legend(); plt.title(\"Rule-based cues (synthetic signals)\")\n", + "plt.tight_layout(); plt.show()\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 03 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + "[규칙 경계(진짜 음성 기준 3% 분위수)] {'hf_ratio': 0.176, 'frame_mod': 0.286, 'hnr': 0.494, 'flatness': 0.341, 'lf_ratio': 0.0, 'spk_sim': 0.702}\n", + "\n", + "클립 400개: 탐지 정확도 78.8% (목표 75% 이상: 달성), 위조 재현율 74.0%, 오경보율(진짜를 위조로) 16.8%, 지연 0.93 ms/클립\n", + "\n", + "[유형별 위조 판정 비율과 주로 걸린 단서]\n", + "bonafide 위조 판정 16.8% 주요 단서: frame_mod(9%), hnr(3%)\n", + "tts_art 위조 판정 100.0% 주요 단서: frame_mod(100%), hf_ratio(22%)\n", + "vc_art 위조 판정 85.9% 주요 단서: hnr(84%), flatness(9%)\n", + "replay 위조 판정 30.5% 주요 단서: hnr(14%), spk_sim(12%)\n", + "\n", + "SNR 구간 정확도 오경보율\n", + " 5~10dB 77.7% 27.1%\n", + "10~15dB 81.7% 7.1%\n", + "15~25dB 77.5% 16.1%\n", + "\n", + "판단 근거 예시 C080002(tts_art): 걸린 단서 frame_mod\n", + "한계: 잡음이 큰 채널에서는 진짜 음성의 단서 값도 경계를 넘어 오경보가 늘고, 재생(replay)은 화자 유사도가 높아 단서가 적다 -> 미션 4·5에서 학습형 모델과 SNR 반영 신뢰도로 보완\n", + "```\n", + "
\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 04. 음성 합성 인공지능(TTS/VC) 아티팩트 및 위조 음성 판별 딥러닝 탐지 엔진 고도화\n", + "**소요시간**: 약 3.0시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "딥러닝 기반 딥페이크 음성 아티팩트 정밀 탐지 및 화자 고유 생체 음향 특징 검증 모델을 고도화한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **음향 특징과 화자 유사도를 입력으로 하는 신경망(MLP) 위조 음성 판별 엔진을 학습하고, 복합 위협 시나리오에서 성능과 오경보 패턴을 검증한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **MFCC 평균·표준편차, 고주파·저역 비율, 평탄도, 흔들림, HNR, 프레임 경계 흔적, 화자 유사도 35개 특징으로 신경망을 학습 수행하고 검증하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `src/models/c204_advanced_detector.py 및 예외 탐지 테스트 결과` 형태의 결과물을 완성한다.\n", + "\n", + "### 🔍 평가 및 검증 기준 (PASS 조건)\n", + "#### 미션 판정\n", + "\n", + "**평가 구분:** 필수\n", + "\n", + "**핵심 평가영역:** 기만/교란 공격에 대한 방어 로직의 현실성 및 신뢰성\n", + "\n", + "#### 미션 PASS 조건\n", + "\n", + "- 정량 기준: 복합 위협 탐지 재현율(Recall) 85% 이상, 처리 지연 200ms 이하\n", + "\n", + "\n", + "- 진행 기준: 복합 위협 시나리오 10종에 대한 탐지 테스트 통과\n", + "\n", + "\n", + "- 검증 방법: 합성 위협 데이터셋 주입 시 이상치 탐지 정확도 검증\n", + "\n", + "\n", + "#### 채점자 판정 메모\n", + "\n", + "> 단순 코드 실행 여부뿐만 아니라 출력된 수치와 근거 자료가 군 실무 요구사항에 부합하는지 면밀히 확인한다.\n", + "\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "\"\"\"음향 특징 34종과 등록 화자 유사도를 입력으로 하는 신경망(MLP) 위조 음성 판별 엔진을 학습하고, 위조 유형과 채널 조건을 섞은 복합 위협 시나리오 10종에서 재현율·처리 지연을 검증하며 오경보를 일으키는 패턴을 찾는다.\"\"\"\n", + "import time\n", + "import numpy as np\n", + "import pandas as pd\n", + "import matplotlib.pyplot as plt\n", + "from scipy import signal\n", + "from scipy.fft import dct\n", + "from sklearn.neural_network import MLPClassifier\n", + "from sklearn.preprocessing import StandardScaler\n", + "from sklearn.pipeline import make_pipeline\n", + "\n", + "# ---------------------------------------------------------------------------\n", + "# [교육용가상자료] 탐지 학습용 합성 신호 생성기 (실제 음성·화자·통신망 정보 없음)\n", + "# 사람 목소리를 만드는 기능이 아니라, 배음(하모닉) 신호에 '진짜 신호의 자연스러운 흔들림'과\n", + "# '알려진 위조 흔적(문헌상 탐지 단서)'의 통계적 특징을 표시해 탐지기를 학습시키기 위한 추상 신호다.\n", + "# bonafide : 자연스러운 피치 흔들림(jitter)·진폭 흔들림(shimmer)·숨소리 잡음\n", + "# tts_art : 흔들림이 거의 없고 고주파 에너지가 부족하며 20ms 프레임 경계 흔적이 있는 신호\n", + "# vc_art : 프레임마다 배음 위상이 끊기고 스펙트럼 포락선이 뭉개진 신호\n", + "# replay : 스피커 재생 대역 제한 + 실내 잔향이 더해진 신호\n", + "# ---------------------------------------------------------------------------\n", + "FS, DUR = 8000, 0.5\n", + "N = int(FS * DUR)\n", + "LABELS = [\"bonafide\", \"tts_art\", \"vc_art\", \"replay\"]\n", + "SPEAKERS = {f\"SPK_{c}\": (f0, fm) for c, f0, fm in [(\"A\", 115, (650, 1150, 2450)), (\"B\", 135, (700, 1220, 2600)), (\"C\", 98, (600, 1050, 2350)),\n", + " (\"D\", 160, (720, 1300, 2700)), (\"E\", 125, (680, 1180, 2500)), (\"F\", 105, (630, 1100, 2400))]}\n", + "\n", + "\n", + "def _source(rng, f0, fm, jitter, shimmer, phase_break=False, hf_cut=None, frame_mod=0.0):\n", + " # [TODO : 학생 실습 4] 음성 합성 인공지능(TTS/VC) 아티팩트 및 위조 음성 판별 딥러닝 탐지 엔진 고도화 관련 핵심 로직을 구현하세요.\n", + " # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n", + " # <<< 여기에 코드를 작성하세요 >>>\n", + " pass # 작성 후 pass를 제거하세요.\n", + "def make_clip(label, speaker, rng):\n", + " f0, fm = SPEAKERS[speaker]\n", + " if label in (\"tts_art\", \"vc_art\"): # 위조는 화자 특성을 근사만 함\n", + " f0 = f0 * rng.uniform(0.92, 1.08); fm = tuple(F * rng.uniform(0.95, 1.05) for F in fm)\n", + " if label == \"bonafide\" or label == \"replay\":\n", + " x = _source(rng, f0, fm, jitter=rng.uniform(0.008, 0.02), shimmer=rng.uniform(0.05, 0.12))\n", + " elif label == \"tts_art\":\n", + " x = _source(rng, f0, fm, jitter=rng.uniform(0.0, 0.002), shimmer=0.01, hf_cut=rng.uniform(2300, 2800), frame_mod=rng.uniform(0.08, 0.2))\n", + " else:\n", + " x = _source(rng, f0, fm, jitter=rng.uniform(0.006, 0.015), shimmer=rng.uniform(0.04, 0.1), phase_break=True)\n", + " if label == \"replay\": # 스피커 대역 + 실내 잔향\n", + " x = signal.sosfilt(signal.butter(4, [450, 2900], \"bandpass\", fs=FS, output=\"sos\"), x)\n", + " ir = rng.normal(0, 1, int(0.08 * FS)) * np.exp(-np.arange(int(0.08 * FS)) / (0.02 * FS)); ir[0] = 3.0\n", + " x = signal.fftconvolve(x, ir)[:N]\n", + " return x / (np.abs(x).max() + 1e-9)\n", + "\n", + "\n", + "RADIO_BP = signal.butter(4, [300, 3400], \"bandpass\", fs=FS, output=\"sos\")\n", + "\n", + "\n", + "def radio_channel(x, rng, snr_db=15.0, impulse=0.0, dropout=0.0, clip=1.0, jam_tone=0.0):\n", + " \"\"\"[교육용가상자료] 무전 채널 모형: 300~3400Hz 대역 제한, 배경 잡음(SNR), 충격성 잡음, 신호 끊김, 포화(클리핑), 단일톤 재밍.\"\"\"\n", + " # [TODO : 학생 실습 4] 음성 합성 인공지능(TTS/VC) 아티팩트 및 위조 음성 판별 딥러닝 탐지 엔진 고도화 관련 핵심 로직을 구현하세요.\n", + " # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n", + " # <<< 여기에 코드를 작성하세요 >>>\n", + " pass # 작성 후 pass를 제거하세요.\n", + "def make_dataset(n=400, seed=7, snr=(5, 25), labels=LABELS, p=None, claim=\"SPK_A\", **chan):\n", + " \"\"\"클립 n개: 진짜는 여러 화자, 위조는 모두 지휘관 화자(SPK_A)를 사칭. 반환: (신호 배열, 메타 DataFrame).\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " X, meta = np.zeros((n, N)), []\n", + " if p is None and len(labels) > 1 and labels[0] == \"bonafide\": # 기본: 진짜 50%, 나머지 위조 유형 균등\n", + " p = [0.5] + [0.5 / (len(labels) - 1)] * (len(labels) - 1)\n", + " for i in range(n):\n", + " lab = str(rng.choice(labels, p=p))\n", + " spk = str(rng.choice(list(SPEAKERS))) if lab == \"bonafide\" else claim\n", + " if lab == \"replay\":\n", + " spk = claim\n", + " s = float(rng.uniform(*snr)) if isinstance(snr, tuple) else float(snr)\n", + " X[i] = radio_channel(make_clip(lab, spk, rng), rng, snr_db=s, **chan)\n", + " meta.append({\"clip\": f\"C{seed:02d}{i:04d}\", \"label\": lab, \"spoof\": lab != \"bonafide\", \"claimed\": spk if lab == \"bonafide\" else claim,\n", + " \"true_spk\": spk if lab == \"bonafide\" else \"unknown\", \"snr_db\": round(s, 1)})\n", + " return X, pd.DataFrame(meta)\n", + "# ---------------------------------------------------------------------------\n", + "\n", + "# ---- 전처리·음향 특징(MFCC/스펙트로그램) 공통 함수: 여러 클립을 한 번에 계산 ----\n", + "NFFT, HOP = 256, 80\n", + "\n", + "\n", + "def mel_bank(n_mels=26, fmin=300, fmax=3400):\n", + " mel = lambda f: 2595 * np.log10(1 + f / 700)\n", + " imel = lambda m: 700 * (10 ** (m / 2595) - 1)\n", + " pts = imel(np.linspace(mel(fmin), mel(fmax), n_mels + 2))\n", + " freqs = np.fft.rfftfreq(NFFT, 1 / FS)\n", + " fb = np.zeros((n_mels, len(freqs)))\n", + " for m in range(n_mels):\n", + " l, c, r = pts[m], pts[m + 1], pts[m + 2]\n", + " fb[m] = np.clip(np.minimum((freqs - l) / (c - l), (r - freqs) / (r - c)), 0, None)\n", + " return fb\n", + "\n", + "\n", + "MEL = mel_bank()\n", + "FREQS = np.fft.rfftfreq(NFFT, 1 / FS)\n", + "\n", + "\n", + "def spectrogram(X, denoise=0.0):\n", + " \"\"\"배치 전력 스펙트로그램 (클립, 주파수, 프레임). denoise>0이면 잡음 바닥(주파수별 하위 10%)을 그 배수만큼 빼는 스펙트럼 차감.\"\"\"\n", + " X = np.atleast_2d(X)\n", + " Xp = np.c_[X[:, :1], X[:, 1:] - 0.95 * X[:, :-1]] # 프리엠퍼시스\n", + " frames = np.lib.stride_tricks.sliding_window_view(Xp, NFFT, axis=1)[:, ::HOP] * np.hanning(NFFT)\n", + " P = np.abs(np.fft.rfft(frames, axis=-1)) ** 2\n", + " P = np.transpose(P, (0, 2, 1))\n", + " if denoise:\n", + " floor = np.percentile(P, 10, axis=2, keepdims=True)\n", + " P = np.maximum(P - denoise * floor, 0.05 * P)\n", + " return P\n", + "\n", + "\n", + "def mfcc(P, n=13):\n", + " logmel = np.log(np.einsum(\"mf,cft->cmt\", MEL, P) + 1e-8)\n", + " return dct(logmel, type=2, axis=1, norm=\"ortho\")[:, :n, :]\n", + "\n", + "\n", + "def band(P, lo, hi):\n", + " return P[:, (FREQS >= lo) & (FREQS < hi), :].sum(axis=(1, 2))\n", + "\n", + "\n", + "def jitter_hnr(X):\n", + " \"\"\"40ms 프레임 자기상관으로 피치 주기를 찾아 피치 흔들림(jitter)과 배음 대 잡음비(HNR 근사)를 구한다.\"\"\"\n", + " W, lag0, lag1 = 320, int(FS / 250), int(FS / 70)\n", + " fr = np.lib.stride_tricks.sliding_window_view(np.atleast_2d(X), W, axis=1)[:, ::160]\n", + " fr = fr - fr.mean(-1, keepdims=True)\n", + " spec = np.fft.rfft(fr, 2 * W, axis=-1)\n", + " ac = np.fft.irfft(np.abs(spec) ** 2, axis=-1)[..., :W]\n", + " ac = ac / (ac[..., :1] + 1e-12)\n", + " seg = ac[..., lag0:lag1]\n", + " k = seg.argmax(-1)\n", + " peak = seg.max(-1)\n", + " km, kp = np.take_along_axis(seg, np.clip(k - 1, 0, None)[..., None], -1)[..., 0], np.take_along_axis(seg, np.clip(k + 1, None, seg.shape[-1] - 1)[..., None], -1)[..., 0]\n", + " lag = k + lag0 + 0.5 * (km - kp) / (km - 2 * peak + kp - 1e-12) # 포물선 보간으로 주기 정밀화\n", + " f0 = FS / lag\n", + " jit = np.median(np.abs(np.diff(f0, axis=1)) / f0[:, 1:], axis=1)\n", + " return jit, np.median(peak, axis=1)\n", + "\n", + "\n", + "def frame_mod(X):\n", + " \"\"\"진폭 포락선(2.5ms 간격)의 50Hz 성분 비율: 20ms 프레임 경계 흔적(합성 단서).\"\"\"\n", + " env = np.abs(np.atleast_2d(X)).reshape(len(np.atleast_2d(X)), -1, 20).mean(-1)\n", + " sp = np.abs(np.fft.rfft(env - env.mean(1, keepdims=True), axis=1)) ** 2\n", + " f = np.fft.rfftfreq(env.shape[1], 20 / FS)\n", + " return sp[:, (f > 47) & (f < 53)].sum(1) / (sp[:, (f > 30) & (f < 70)].sum(1) + 1e-12)\n", + "\n", + "\n", + "FEAT_NAMES = [f\"mfcc{i}_m\" for i in range(13)] + [f\"mfcc{i}_s\" for i in range(13)] + \\\n", + " [\"delta_s\", \"hf_ratio\", \"lf_ratio\", \"flatness\", \"flux\", \"jitter\", \"hnr\", \"frame_mod\"]\n", + "\n", + "\n", + "def extract(X, denoise=0.0):\n", + " \"\"\"클립 배치 -> 특징 행렬 (클립 x 34) DataFrame.\"\"\"\n", + " P = spectrogram(X, denoise)\n", + " C = mfcc(P)\n", + " tot = band(P, 300, 3400) + 1e-12\n", + " flat = np.exp(np.log(P[:, (FREQS >= 300) & (FREQS < 3400)] + 1e-10).mean(1)) / (P[:, (FREQS >= 300) & (FREQS < 3400)].mean(1) + 1e-10)\n", + " logP = np.log(P + 1e-8)\n", + " jit, hnr = jitter_hnr(X)\n", + " F = np.c_[C.mean(2), C.std(2), np.abs(np.diff(C, axis=2)).mean((1, 2)), band(P, 2400, 3400) / tot, band(P, 300, 500) / tot,\n", + " flat.mean(1), np.abs(np.diff(logP, axis=2)).mean((1, 2)), jit, hnr, frame_mod(X)]\n", + " return pd.DataFrame(F, columns=FEAT_NAMES)\n", + "\n", + "\n", + "def enroll(seed=99):\n", + " \"\"\"지휘관·통신소 화자 6명의 등록 견본(깨끗한 진짜 클립 5개씩의 MFCC 평균).\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " tpl = {}\n", + " for spk in SPEAKERS:\n", + " Xe = np.stack([radio_channel(make_clip(\"bonafide\", spk, rng), rng, snr_db=25) for _ in range(5)])\n", + " tpl[spk] = extract(Xe)[[f\"mfcc{i}_m\" for i in range(1, 13)]].mean().values\n", + " return tpl\n", + "\n", + "\n", + "def speaker_sim(F, claimed, tpl):\n", + " \"\"\"주장 화자 견본과 MFCC 평균 벡터의 코사인 유사도(화자 검증 점수).\"\"\"\n", + " v = F[[f\"mfcc{i}_m\" for i in range(1, 13)]].values\n", + " t = np.stack([tpl[c] for c in claimed])\n", + " return (v * t).sum(1) / (np.linalg.norm(v, axis=1) * np.linalg.norm(t, axis=1) + 1e-12)\n", + "\n", + "tpl = enroll()\n", + "\n", + "\n", + "def featurize(X, M):\n", + " F = extract(X)\n", + " F[\"spk_sim\"] = speaker_sim(F, M[\"claimed\"], tpl)\n", + " return F\n", + "\n", + "\n", + "# [교육용가상자료] 학습용: 깨끗~보통 채널 + 일부 충격 잡음·끊김 채널을 섞어 학습(데이터 증강)\n", + "parts = [make_dataset(500, seed=21, snr=(5, 25)), make_dataset(200, seed=22, snr=(5, 20), impulse=0.002, dropout=0.3)]\n", + "Xtr = np.vstack([p[0] for p in parts]); Mtr = pd.concat([p[1] for p in parts], ignore_index=True)\n", + "Ftr = featurize(Xtr, Mtr)\n", + "model = make_pipeline(StandardScaler(), MLPClassifier(hidden_layer_sizes=(64, 32), alpha=1e-3, max_iter=600, random_state=0))\n", + "model.fit(Ftr, Mtr[\"spoof\"])\n", + "print(f\"학습 클립 {len(Xtr)}개(진짜 {(~Mtr['spoof']).sum()} / 위조 {Mtr['spoof'].sum()}), 특징 {Ftr.shape[1]}개, 신경망 은닉층 64-32\")\n", + "\n", + "# [교육용가상자료] 복합 위협 시나리오 10종: (이름, 위조 유형, 채널 조건)\n", + "SCEN = [(\"S01 TTS 명령, 양호 채널\", \"tts_art\", dict(snr=(15, 25))),\n", + " (\"S02 TTS 명령, 저SNR\", \"tts_art\", dict(snr=(3, 8))),\n", + " (\"S03 VC 사칭, 양호 채널\", \"vc_art\", dict(snr=(15, 25))),\n", + " (\"S04 VC 사칭, 충격 잡음\", \"vc_art\", dict(snr=(8, 15), impulse=0.002)),\n", + " (\"S05 재생 공격, 양호 채널\", \"replay\", dict(snr=(15, 25))),\n", + " (\"S06 재생 공격, 신호 끊김\", \"replay\", dict(snr=(8, 15), dropout=0.4)),\n", + " (\"S07 TTS, 단일톤 재밍\", \"tts_art\", dict(snr=(8, 15), jam_tone=0.5)),\n", + " (\"S08 VC, 포화(클리핑)\", \"vc_art\", dict(snr=(10, 20), clip=0.5)),\n", + " (\"S09 혼합 위조, 저SNR\", None, dict(snr=(3, 8))),\n", + " (\"S10 혼합 위조, 끊김+충격\", None, dict(snr=(8, 15), impulse=0.002, dropout=0.3))]\n", + "\n", + "rows, lat = [], []\n", + "for k, (name, lab, ch) in enumerate(SCEN):\n", + " labs = [\"tts_art\", \"vc_art\", \"replay\"] if lab is None else [lab]\n", + " X, M = make_dataset(40, seed=300 + k, labels=labs, p=[1 / len(labs)] * len(labs), **ch)\n", + " t0 = time.perf_counter()\n", + " pred = model.predict(featurize(X, M))\n", + " lat.append((time.perf_counter() - t0) * 1000 / len(X))\n", + " rows.append({\"시나리오\": name, \"재현율\": pred.mean(), \"통과(85%)\": \"PASS\" if pred.mean() >= 0.85 else \"FAIL\"})\n", + "R = pd.DataFrame(rows)\n", + "print(\"\\n[복합 위협 시나리오 10종 탐지 결과(각 위조 40클립)]\")\n", + "print(R.to_string(index=False, formatters={\"재현율\": \"{:.1%}\".format}))\n", + "\n", + "# 단일 클립 실시간 지연(특징 추출+추론) 측정\n", + "Xs, Ms = make_dataset(30, seed=399)\n", + "one = []\n", + "for i in range(30):\n", + " t0 = time.perf_counter(); model.predict(featurize(Xs[i:i + 1], Ms.iloc[i:i + 1])); one.append((time.perf_counter() - t0) * 1000)\n", + "rec = R[\"재현율\"].mean()\n", + "p95 = float(np.percentile(one, 95))\n", + "print(f\"\\n복합 위협 평균 재현율 {rec:.1%} (목표 85% 이상: {'달성' if rec >= 0.85 else '미달'}), 시나리오 통과 {(R['통과(85%)'] == 'PASS').sum()}/10\")\n", + "print(f\"단일 클립 처리 지연 p95 {p95:.1f} ms (목표 200ms 이하: {'달성' if p95 <= 200 else '미달'}), 배치 평균 {np.mean(lat):.2f} ms/클립\")\n", + "\n", + "# 오경보 패턴: 진짜 음성을 다양한 채널 조건에서 판정\n", + "print(\"\\n[진짜 음성 오경보율 - 채널 조건별]\")\n", + "fa = {}\n", + "for name, ch in [(\"양호(15~25dB)\", dict(snr=(15, 25))), (\"저SNR(3~8dB)\", dict(snr=(3, 8))), (\"충격 잡음\", dict(snr=(8, 15), impulse=0.002)),\n", + " (\"신호 끊김\", dict(snr=(8, 15), dropout=0.4)), (\"단일톤 재밍\", dict(snr=(8, 15), jam_tone=0.5)), (\"포화(클리핑)\", dict(snr=(10, 20), clip=0.5))]:\n", + " X, M = make_dataset(60, seed=500 + len(fa), labels=[\"bonafide\"], **ch)\n", + " fa[name] = model.predict(featurize(X, M)).mean()\n", + " print(f\" {name:12s} {fa[name]:6.1%}\")\n", + "worst = max(fa, key=fa.get)\n", + "print(f\"오경보 유발 패턴: '{worst}' 조건에서 가장 높음 -> 해당 조건 진짜 음성을 학습에 보강하거나 미션 5에서 채널 품질을 신뢰도에 반영해 경보 등급을 낮춘다\")\n", + "\n", + "plt.figure(figsize=(7, 3.5))\n", + "plt.barh([s.split()[0] for s in R[\"시나리오\"]], R[\"재현율\"], color=[\"tab:green\" if v >= 0.85 else \"tab:red\" for v in R[\"재현율\"]])\n", + "plt.axvline(0.85, ls=\"--\", c=\"k\"); plt.xlabel(\"recall\"); plt.title(\"Composite threat scenarios (synthetic)\"); plt.tight_layout(); plt.show()\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 04 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + "학습 클립 700개(진짜 351 / 위조 349), 특징 35개, 신경망 은닉층 64-32\n", + "\n", + "[복합 위협 시나리오 10종 탐지 결과(각 위조 40클립)]\n", + " 시나리오 재현율 통과(85%)\n", + "S01 TTS 명령, 양호 채널 100.0% PASS\n", + " S02 TTS 명령, 저SNR 100.0% PASS\n", + " S03 VC 사칭, 양호 채널 100.0% PASS\n", + " S04 VC 사칭, 충격 잡음 92.5% PASS\n", + " S05 재생 공격, 양호 채널 100.0% PASS\n", + " S06 재생 공격, 신호 끊김 90.0% PASS\n", + " S07 TTS, 단일톤 재밍 100.0% PASS\n", + " S08 VC, 포화(클리핑) 100.0% PASS\n", + " S09 혼합 위조, 저SNR 95.0% PASS\n", + " S10 혼합 위조, 끊김+충격 85.0% PASS\n", + "\n", + "복합 위협 평균 재현율 96.2% (목표 85% 이상: 달성), 시나리오 통과 10/10\n", + "단일 클립 처리 지연 p95 4.3 ms (목표 200ms 이하: 달성), 배치 평균 0.81 ms/클립\n", + "\n", + "[진짜 음성 오경보율 - 채널 조건별]\n", + " 양호(15~25dB) 0.0%\n", + " 저SNR(3~8dB) 3.3%\n", + " 충격 잡음 0.0%\n", + " 신호 끊김 8.3%\n", + " 단일톤 재밍 31.7%\n", + " 포화(클리핑) 6.7%\n", + "오경보 유발 패턴: '단일톤 재밍' 조건에서 가장 높음 -> 해당 조건 진짜 음성을 학습에 보강하거나 미션 5에서 채널 품질을 신뢰도에 반영해 경보 등급을 낮춘다\n", + "```\n", + "
\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 05. 전장 소음 환경 오탐 방지 및 무전 화자 진위 신뢰도 점수 산출 모듈 구현\n", + "**소요시간**: 약 3.0시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "전술 잡음 속에서 오탐을 최소화하고 음성 진위 신뢰도 점수를 산출하는 엔진을 개발한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **채널 품질을 반영한 보정 모델로 무전 화자 진위 신뢰도 점수와 High/Med/Low 등급을 산출해 전장 소음 속 오경보를 줄인다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **추정 SNR, 단일톤 재밍 지수, 끊김 비율 등 채널 품질 특징을 더하고 잡음 조건을 섞어 학습한 뒤 로지스틱 보정으로 점수를 확률화 수행하고 검증하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `src/verification/c204_confidence_engine.py` 형태의 결과물을 완성한다.\n", + "\n", + "### 🔍 평가 및 검증 기준 (PASS 조건)\n", + "#### 미션 판정\n", + "\n", + "**평가 구분:** 필수\n", + "\n", + "**핵심 평가영역:** 신뢰도 등급(High/Med/Low) 부여 기준의 군사적 타당성\n", + "\n", + "#### 미션 PASS 조건\n", + "\n", + "- 정량 기준: 오경보율(FAR) 40% 이상 감축, 신뢰도 스코어 산출 일관성 90% 이상\n", + "\n", + "\n", + "- 진행 기준: 오경보 필터링 전후 비교 지표 산출 및 통합 테스트 완료\n", + "\n", + "\n", + "- 검증 방법: ROC-AUC 곡선 분석 및 임계값 민감도 테스트\n", + "\n", + "\n", + "#### 채점자 판정 메모\n", + "\n", + "> 단순 코드 실행 여부뿐만 아니라 출력된 수치와 근거 자료가 군 실무 요구사항에 부합하는지 면밀히 확인한다.\n", + "\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "\"\"\"채널 품질 특징(추정 SNR, 단일톤 재밍 지수, 끊김 비율)을 더하고 잡음 조건을 섞어 학습한 보정 모델로 무전 화자 진위 신뢰도 점수와 High/Med/Low 등급을 산출해, 기존 판별기 대비 오경보율 감축, 등급 일관성, ROC-AUC와 임계값 민감도를 확인한다.\"\"\"\n", + "import numpy as np\n", + "import pandas as pd\n", + "import matplotlib.pyplot as plt\n", + "from scipy import signal\n", + "from scipy.fft import dct\n", + "from sklearn.neural_network import MLPClassifier\n", + "from sklearn.linear_model import LogisticRegression\n", + "from sklearn.preprocessing import StandardScaler\n", + "from sklearn.pipeline import make_pipeline\n", + "from sklearn.metrics import roc_auc_score, roc_curve\n", + "\n", + "# ---------------------------------------------------------------------------\n", + "# [교육용가상자료] 탐지 학습용 합성 신호 생성기 (실제 음성·화자·통신망 정보 없음)\n", + "# 사람 목소리를 만드는 기능이 아니라, 배음(하모닉) 신호에 '진짜 신호의 자연스러운 흔들림'과\n", + "# '알려진 위조 흔적(문헌상 탐지 단서)'의 통계적 특징을 표시해 탐지기를 학습시키기 위한 추상 신호다.\n", + "# bonafide : 자연스러운 피치 흔들림(jitter)·진폭 흔들림(shimmer)·숨소리 잡음\n", + "# tts_art : 흔들림이 거의 없고 고주파 에너지가 부족하며 20ms 프레임 경계 흔적이 있는 신호\n", + "# vc_art : 프레임마다 배음 위상이 끊기고 스펙트럼 포락선이 뭉개진 신호\n", + "# replay : 스피커 재생 대역 제한 + 실내 잔향이 더해진 신호\n", + "# ---------------------------------------------------------------------------\n", + "FS, DUR = 8000, 0.5\n", + "N = int(FS * DUR)\n", + "LABELS = [\"bonafide\", \"tts_art\", \"vc_art\", \"replay\"]\n", + "SPEAKERS = {f\"SPK_{c}\": (f0, fm) for c, f0, fm in [(\"A\", 115, (650, 1150, 2450)), (\"B\", 135, (700, 1220, 2600)), (\"C\", 98, (600, 1050, 2350)),\n", + " (\"D\", 160, (720, 1300, 2700)), (\"E\", 125, (680, 1180, 2500)), (\"F\", 105, (630, 1100, 2400))]}\n", + "\n", + "\n", + "def _source(rng, f0, fm, jitter, shimmer, phase_break=False, hf_cut=None, frame_mod=0.0):\n", + " # [TODO : 학생 실습 5] 전장 소음 환경 오탐 방지 및 무전 화자 진위 신뢰도 점수 산출 모듈 구현 관련 핵심 로직을 구현하세요.\n", + " # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n", + " # <<< 여기에 코드를 작성하세요 >>>\n", + " pass # 작성 후 pass를 제거하세요.\n", + "def make_clip(label, speaker, rng):\n", + " f0, fm = SPEAKERS[speaker]\n", + " if label in (\"tts_art\", \"vc_art\"): # 위조는 화자 특성을 근사만 함\n", + " f0 = f0 * rng.uniform(0.92, 1.08); fm = tuple(F * rng.uniform(0.95, 1.05) for F in fm)\n", + " if label == \"bonafide\" or label == \"replay\":\n", + " x = _source(rng, f0, fm, jitter=rng.uniform(0.008, 0.02), shimmer=rng.uniform(0.05, 0.12))\n", + " elif label == \"tts_art\":\n", + " x = _source(rng, f0, fm, jitter=rng.uniform(0.0, 0.002), shimmer=0.01, hf_cut=rng.uniform(2300, 2800), frame_mod=rng.uniform(0.08, 0.2))\n", + " else:\n", + " x = _source(rng, f0, fm, jitter=rng.uniform(0.006, 0.015), shimmer=rng.uniform(0.04, 0.1), phase_break=True)\n", + " if label == \"replay\": # 스피커 대역 + 실내 잔향\n", + " x = signal.sosfilt(signal.butter(4, [450, 2900], \"bandpass\", fs=FS, output=\"sos\"), x)\n", + " ir = rng.normal(0, 1, int(0.08 * FS)) * np.exp(-np.arange(int(0.08 * FS)) / (0.02 * FS)); ir[0] = 3.0\n", + " x = signal.fftconvolve(x, ir)[:N]\n", + " return x / (np.abs(x).max() + 1e-9)\n", + "\n", + "\n", + "RADIO_BP = signal.butter(4, [300, 3400], \"bandpass\", fs=FS, output=\"sos\")\n", + "\n", + "\n", + "def radio_channel(x, rng, snr_db=15.0, impulse=0.0, dropout=0.0, clip=1.0, jam_tone=0.0):\n", + " \"\"\"[교육용가상자료] 무전 채널 모형: 300~3400Hz 대역 제한, 배경 잡음(SNR), 충격성 잡음, 신호 끊김, 포화(클리핑), 단일톤 재밍.\"\"\"\n", + " # [TODO : 학생 실습 5] 전장 소음 환경 오탐 방지 및 무전 화자 진위 신뢰도 점수 산출 모듈 구현 관련 핵심 로직을 구현하세요.\n", + " # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n", + " # <<< 여기에 코드를 작성하세요 >>>\n", + " pass # 작성 후 pass를 제거하세요.\n", + "def make_dataset(n=400, seed=7, snr=(5, 25), labels=LABELS, p=None, claim=\"SPK_A\", **chan):\n", + " \"\"\"클립 n개: 진짜는 여러 화자, 위조는 모두 지휘관 화자(SPK_A)를 사칭. 반환: (신호 배열, 메타 DataFrame).\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " X, meta = np.zeros((n, N)), []\n", + " if p is None and len(labels) > 1 and labels[0] == \"bonafide\": # 기본: 진짜 50%, 나머지 위조 유형 균등\n", + " p = [0.5] + [0.5 / (len(labels) - 1)] * (len(labels) - 1)\n", + " for i in range(n):\n", + " lab = str(rng.choice(labels, p=p))\n", + " spk = str(rng.choice(list(SPEAKERS))) if lab == \"bonafide\" else claim\n", + " if lab == \"replay\":\n", + " spk = claim\n", + " s = float(rng.uniform(*snr)) if isinstance(snr, tuple) else float(snr)\n", + " X[i] = radio_channel(make_clip(lab, spk, rng), rng, snr_db=s, **chan)\n", + " meta.append({\"clip\": f\"C{seed:02d}{i:04d}\", \"label\": lab, \"spoof\": lab != \"bonafide\", \"claimed\": spk if lab == \"bonafide\" else claim,\n", + " \"true_spk\": spk if lab == \"bonafide\" else \"unknown\", \"snr_db\": round(s, 1)})\n", + " return X, pd.DataFrame(meta)\n", + "# ---------------------------------------------------------------------------\n", + "\n", + "# ---- 전처리·음향 특징(MFCC/스펙트로그램) 공통 함수: 여러 클립을 한 번에 계산 ----\n", + "NFFT, HOP = 256, 80\n", + "\n", + "\n", + "def mel_bank(n_mels=26, fmin=300, fmax=3400):\n", + " mel = lambda f: 2595 * np.log10(1 + f / 700)\n", + " imel = lambda m: 700 * (10 ** (m / 2595) - 1)\n", + " pts = imel(np.linspace(mel(fmin), mel(fmax), n_mels + 2))\n", + " freqs = np.fft.rfftfreq(NFFT, 1 / FS)\n", + " fb = np.zeros((n_mels, len(freqs)))\n", + " for m in range(n_mels):\n", + " l, c, r = pts[m], pts[m + 1], pts[m + 2]\n", + " fb[m] = np.clip(np.minimum((freqs - l) / (c - l), (r - freqs) / (r - c)), 0, None)\n", + " return fb\n", + "\n", + "\n", + "MEL = mel_bank()\n", + "FREQS = np.fft.rfftfreq(NFFT, 1 / FS)\n", + "\n", + "\n", + "def spectrogram(X, denoise=0.0):\n", + " \"\"\"배치 전력 스펙트로그램 (클립, 주파수, 프레임). denoise>0이면 잡음 바닥(주파수별 하위 10%)을 그 배수만큼 빼는 스펙트럼 차감.\"\"\"\n", + " X = np.atleast_2d(X)\n", + " Xp = np.c_[X[:, :1], X[:, 1:] - 0.95 * X[:, :-1]] # 프리엠퍼시스\n", + " frames = np.lib.stride_tricks.sliding_window_view(Xp, NFFT, axis=1)[:, ::HOP] * np.hanning(NFFT)\n", + " P = np.abs(np.fft.rfft(frames, axis=-1)) ** 2\n", + " P = np.transpose(P, (0, 2, 1))\n", + " if denoise:\n", + " floor = np.percentile(P, 10, axis=2, keepdims=True)\n", + " P = np.maximum(P - denoise * floor, 0.05 * P)\n", + " return P\n", + "\n", + "\n", + "def mfcc(P, n=13):\n", + " logmel = np.log(np.einsum(\"mf,cft->cmt\", MEL, P) + 1e-8)\n", + " return dct(logmel, type=2, axis=1, norm=\"ortho\")[:, :n, :]\n", + "\n", + "\n", + "def band(P, lo, hi):\n", + " return P[:, (FREQS >= lo) & (FREQS < hi), :].sum(axis=(1, 2))\n", + "\n", + "\n", + "def jitter_hnr(X):\n", + " \"\"\"40ms 프레임 자기상관으로 피치 주기를 찾아 피치 흔들림(jitter)과 배음 대 잡음비(HNR 근사)를 구한다.\"\"\"\n", + " W, lag0, lag1 = 320, int(FS / 250), int(FS / 70)\n", + " fr = np.lib.stride_tricks.sliding_window_view(np.atleast_2d(X), W, axis=1)[:, ::160]\n", + " fr = fr - fr.mean(-1, keepdims=True)\n", + " spec = np.fft.rfft(fr, 2 * W, axis=-1)\n", + " ac = np.fft.irfft(np.abs(spec) ** 2, axis=-1)[..., :W]\n", + " ac = ac / (ac[..., :1] + 1e-12)\n", + " seg = ac[..., lag0:lag1]\n", + " k = seg.argmax(-1)\n", + " peak = seg.max(-1)\n", + " km, kp = np.take_along_axis(seg, np.clip(k - 1, 0, None)[..., None], -1)[..., 0], np.take_along_axis(seg, np.clip(k + 1, None, seg.shape[-1] - 1)[..., None], -1)[..., 0]\n", + " lag = k + lag0 + 0.5 * (km - kp) / (km - 2 * peak + kp - 1e-12) # 포물선 보간으로 주기 정밀화\n", + " f0 = FS / lag\n", + " jit = np.median(np.abs(np.diff(f0, axis=1)) / f0[:, 1:], axis=1)\n", + " return jit, np.median(peak, axis=1)\n", + "\n", + "\n", + "def frame_mod(X):\n", + " \"\"\"진폭 포락선(2.5ms 간격)의 50Hz 성분 비율: 20ms 프레임 경계 흔적(합성 단서).\"\"\"\n", + " env = np.abs(np.atleast_2d(X)).reshape(len(np.atleast_2d(X)), -1, 20).mean(-1)\n", + " sp = np.abs(np.fft.rfft(env - env.mean(1, keepdims=True), axis=1)) ** 2\n", + " f = np.fft.rfftfreq(env.shape[1], 20 / FS)\n", + " return sp[:, (f > 47) & (f < 53)].sum(1) / (sp[:, (f > 30) & (f < 70)].sum(1) + 1e-12)\n", + "\n", + "\n", + "FEAT_NAMES = [f\"mfcc{i}_m\" for i in range(13)] + [f\"mfcc{i}_s\" for i in range(13)] + \\\n", + " [\"delta_s\", \"hf_ratio\", \"lf_ratio\", \"flatness\", \"flux\", \"jitter\", \"hnr\", \"frame_mod\"]\n", + "\n", + "\n", + "def extract(X, denoise=0.0):\n", + " \"\"\"클립 배치 -> 특징 행렬 (클립 x 34) DataFrame.\"\"\"\n", + " P = spectrogram(X, denoise)\n", + " C = mfcc(P)\n", + " tot = band(P, 300, 3400) + 1e-12\n", + " flat = np.exp(np.log(P[:, (FREQS >= 300) & (FREQS < 3400)] + 1e-10).mean(1)) / (P[:, (FREQS >= 300) & (FREQS < 3400)].mean(1) + 1e-10)\n", + " logP = np.log(P + 1e-8)\n", + " jit, hnr = jitter_hnr(X)\n", + " F = np.c_[C.mean(2), C.std(2), np.abs(np.diff(C, axis=2)).mean((1, 2)), band(P, 2400, 3400) / tot, band(P, 300, 500) / tot,\n", + " flat.mean(1), np.abs(np.diff(logP, axis=2)).mean((1, 2)), jit, hnr, frame_mod(X)]\n", + " return pd.DataFrame(F, columns=FEAT_NAMES)\n", + "\n", + "\n", + "def enroll(seed=99):\n", + " \"\"\"지휘관·통신소 화자 6명의 등록 견본(깨끗한 진짜 클립 5개씩의 MFCC 평균).\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " tpl = {}\n", + " for spk in SPEAKERS:\n", + " Xe = np.stack([radio_channel(make_clip(\"bonafide\", spk, rng), rng, snr_db=25) for _ in range(5)])\n", + " tpl[spk] = extract(Xe)[[f\"mfcc{i}_m\" for i in range(1, 13)]].mean().values\n", + " return tpl\n", + "\n", + "\n", + "def speaker_sim(F, claimed, tpl):\n", + " \"\"\"주장 화자 견본과 MFCC 평균 벡터의 코사인 유사도(화자 검증 점수).\"\"\"\n", + " v = F[[f\"mfcc{i}_m\" for i in range(1, 13)]].values\n", + " t = np.stack([tpl[c] for c in claimed])\n", + " return (v * t).sum(1) / (np.linalg.norm(v, axis=1) * np.linalg.norm(t, axis=1) + 1e-12)\n", + "\n", + "tpl = enroll()\n", + "\n", + "\n", + "def channel_quality(X):\n", + " \"\"\"채널 품질 지표: 추정 SNR(유효 프레임 에너지 상하위 비, dB), 단일톤 재밍 지수(주파수별 시간 평균/표준편차의 최대값), 끊김 비율(무음 10ms 비율).\"\"\"\n", + " P = spectrogram(X)\n", + " e = P.sum(1)\n", + " e = np.where(e > 1e-9, e, np.nan)\n", + " snr_est = 10 * np.log10(np.nanpercentile(e, 90, axis=1) / (np.nanpercentile(e, 10, axis=1) + 1e-12))\n", + " Pb = P[:, (FREQS >= 300) & (FREQS < 3400)]\n", + " tone = (Pb.mean(2) / (Pb.std(2) + 1e-12)).max(1) # 시간에 따라 일정한 주파수 성분(재밍 톤)일수록 큼\n", + " fr = np.abs(np.atleast_2d(X)).reshape(len(X), -1, 80).max(-1)\n", + " drop = (fr < 1e-6).mean(1)\n", + " return pd.DataFrame({\"snr_est\": snr_est, \"tone_idx\": tone, \"drop_ratio\": drop})\n", + "\n", + "\n", + "def featurize(X, M, quality=False):\n", + " F = extract(X)\n", + " F[\"spk_sim\"] = speaker_sim(F, M[\"claimed\"], tpl)\n", + " return pd.concat([F, channel_quality(X)], axis=1) if quality else F\n", + "\n", + "\n", + "def mixed(n, seed, labels=LABELS):\n", + " \"\"\"[교육용가상자료] 평시·저SNR·충격 잡음·끊김·재밍 채널이 섞인 데이터셋.\"\"\"\n", + " conds = [dict(snr=(10, 25)), dict(snr=(3, 8)), dict(snr=(8, 15), impulse=0.002), dict(snr=(8, 15), dropout=0.4), dict(snr=(8, 15), jam_tone=0.5)]\n", + " parts = [make_dataset(n // 5, seed=seed + i, labels=labels, **c) for i, c in enumerate(conds)]\n", + " return np.vstack([p[0] for p in parts]), pd.concat([p[1] for p in parts], ignore_index=True)\n", + "\n", + "\n", + "# 기존(미션 4 방식): 양호 채널 위주 학습 + 임계값 0.5\n", + "Xa, Ma = make_dataset(600, seed=31, snr=(5, 25))\n", + "old = make_pipeline(StandardScaler(), MLPClassifier((64, 32), alpha=1e-3, max_iter=600, random_state=0)).fit(featurize(Xa, Ma), Ma[\"spoof\"])\n", + "# 개선: 채널 조건 혼합 학습 + 채널 품질 특징 + 로지스틱 보정(Platt)으로 점수 확률화\n", + "Xb, Mb = mixed(800, seed=40)\n", + "Fb = featurize(Xb, Mb, quality=True)\n", + "new = make_pipeline(StandardScaler(), MLPClassifier((64, 32), alpha=1e-3, max_iter=600, random_state=0)).fit(Fb, Mb[\"spoof\"])\n", + "Xc, Mc = mixed(300, seed=60)\n", + "cal = LogisticRegression().fit(new.predict_proba(featurize(Xc, Mc, True))[:, [1]], Mc[\"spoof\"])\n", + "\n", + "\n", + "def confidence(X, M):\n", + " \"\"\"위조 확률 p와 진위 신뢰도(1-p), 등급: High=위조 의심 높음(즉시 경보), Med=확인 필요(HITL), Low=진짜 가능성 높음.\"\"\"\n", + " Fq = featurize(X, M, quality=True)\n", + " p = cal.predict_proba(new.predict_proba(Fq)[:, [1]])[:, 1]\n", + " poor = (Fq[\"snr_est\"] < 3.3) | (Fq[\"tone_idx\"] > 2.5) | (Fq[\"drop_ratio\"] > 0.05)\n", + " grade = np.where(p >= 0.8, \"High\", np.where(p >= 0.4, \"Med\", \"Low\"))\n", + " grade = np.where((grade == \"High\") & poor & (p < 0.95), \"Med\", grade) # 품질 불량 채널은 한 단계 낮춰 사람 확인\n", + " return p, grade, poor\n", + "\n", + "\n", + "# [교육용가상자료] 평가: 혼합 채널 500클립\n", + "Xt, Mt = mixed(500, seed=80)\n", + "p_old = old.predict_proba(featurize(Xt, Mt))[:, 1]\n", + "p_new, g_new, poor = confidence(Xt, Mt)\n", + "y, b = Mt[\"spoof\"].values, ~Mt[\"spoof\"].values\n", + "far_old, far_new = (p_old[b] >= 0.5).mean(), (g_new[b] == \"High\").mean()\n", + "red = 1 - far_new / far_old\n", + "print(f\"평가 클립 {len(Xt)}개(진짜 {b.sum()} / 위조 {y.sum()}), 품질 불량 채널 {poor.mean():.0%}\")\n", + "print(f\"오경보율(진짜를 즉시 경보): 기존 {far_old:.1%} -> 개선 {far_new:.1%}, 감축 {red:.0%} (목표 40% 이상: {'달성' if red >= 0.4 else '미달'})\")\n", + "print(f\"위조 탐지(High+Med 등급): 기존 {(p_old[y] >= 0.5).mean():.1%} -> 개선 {(g_new[y] != 'Low').mean():.1%}\")\n", + "print(f\"ROC-AUC: 기존 {roc_auc_score(y, p_old):.3f}, 개선 {roc_auc_score(y, p_new):.3f}\")\n", + "\n", + "print(\"\\n[등급 분포] 등급 진짜 위조\")\n", + "for g in [\"High\", \"Med\", \"Low\"]:\n", + " print(f\" {g:5s} {(g_new[b] == g).sum():5d} {(g_new[y] == g).sum():5d}\")\n", + "\n", + "print(\"\\n[임계값 민감도(High 기준)] 임계값 오경보율 위조 High 비율\")\n", + "for th in [0.6, 0.7, 0.8, 0.9]:\n", + " print(f\" {th:.1f} {(p_new[b] >= th).mean():6.1%} {(p_new[y] >= th).mean():6.1%}\")\n", + "\n", + "# 일관성: 같은 원신호를 다른 잡음 실현 5회로 통과시켰을 때 등급이 같은 비율\n", + "rng = np.random.default_rng(90)\n", + "same = []\n", + "for i in range(60):\n", + " lab = LABELS[i % 4]; spk = \"SPK_A\" if lab != \"bonafide\" else str(rng.choice(list(SPEAKERS)))\n", + " x = make_clip(lab, spk, rng); s = rng.uniform(10, 25)\n", + " Xr = np.stack([radio_channel(x, np.random.default_rng(1000 + i * 10 + r), snr_db=s) for r in range(5)])\n", + " _, g, _ = confidence(Xr, pd.DataFrame({\"claimed\": [spk] * 5}))\n", + " same.append(pd.Series(g).value_counts().iloc[0] / 5)\n", + "cons = float(np.mean(same))\n", + "print(f\"\\n신뢰도 등급 일관성(동일 음성 x 잡음 실현 5회, 60건): {cons:.1%} (목표 90% 이상: {'달성' if cons >= 0.9 else '미달'})\")\n", + "print(\"등급 운용: High=통신보안관 즉시 경보 / Med=HITL 2차 확인 / Low=정상 처리(로그 보존). 등급은 판단 보조이며 진위 확정은 사람이 한다.\")\n", + "\n", + "fo, to, _ = roc_curve(y, p_old); fn, tn, _ = roc_curve(y, p_new)\n", + "plt.figure(figsize=(5, 4)); plt.plot(fo, to, label=\"baseline\"); plt.plot(fn, tn, label=\"calibrated + channel quality\")\n", + "plt.plot([0, 1], [0, 1], \"k:\"); plt.xlabel(\"false alarm rate\"); plt.ylabel(\"recall\"); plt.legend(); plt.title(\"ROC (synthetic mixed channels)\")\n", + "plt.tight_layout(); plt.show()\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 05 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + "평가 클립 500개(진짜 254 / 위조 246), 품질 불량 채널 56%\n", + "오경보율(진짜를 즉시 경보): 기존 27.6% -> 개선 0.0%, 감축 100% (목표 40% 이상: 달성)\n", + "위조 탐지(High+Med 등급): 기존 98.4% -> 개선 95.9%\n", + "ROC-AUC: 기존 0.822, 개선 0.995\n", + "\n", + "[등급 분포] 등급 진짜 위조\n", + " High 0 109\n", + " Med 7 127\n", + " Low 247 10\n", + "\n", + "[임계값 민감도(High 기준)] 임계값 오경보율 위조 High 비율\n", + " 0.6 2.4% 95.1%\n", + " 0.7 2.0% 93.9%\n", + " 0.8 2.0% 93.5%\n", + " 0.9 1.6% 91.5%\n", + "\n", + "신뢰도 등급 일관성(동일 음성 x 잡음 실현 5회, 60건): 96.7% (목표 90% 이상: 달성)\n", + "등급 운용: High=통신보안관 즉시 경보 / Med=HITL 2차 확인 / Low=정상 처리(로그 보존). 등급은 판단 보조이며 진위 확정은 사람이 한다.\n", + "```\n", + "
\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 06. 실시간 무전 음성 위조 경보 및 스펙트로그램 시각화 C2 보안 인터페이스 구현\n", + "**소요시간**: 약 2.5시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "실시간 음성 스푸핑 경보 및 음향 스펙트로그램 가시화 C2 보안 모니터링 UI를 구현한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **모의 무전 송신 스트림을 받아 위조 점수와 등급을 실시간 갱신하는 야간 모드 C2 음성 보안 상황판을 구현한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **송신 1건마다 스펙트로그램, 점수 추이, 등급 집계, 경보 목록 4개 패널을 갱신하고 High 경보는 빨간 테두리로 강조 수행하고 검증하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `src/ui/c204_dashboard.py (또는 웹 UI 컴포넌트)` 형태의 결과물을 완성한다.\n", + "\n", + "### 🔍 평가 및 검증 기준 (PASS 조건)\n", + "#### 미션 판정\n", + "\n", + "**평가 구분:** 필수\n", + "\n", + "**핵심 평가영역:** 전술 작전 상황판으로서의 직관성 및 인지 부하 감소도\n", + "\n", + "#### 미션 PASS 조건\n", + "\n", + "- 정량 기준: 화면 갱신 주기 1초 이내, UI 응답성 100ms 이내\n", + "\n", + "\n", + "- 진행 기준: 주요 지표 시각화 컴포넌트 4종 이상 렌더링 및 실시간 데이터 연동\n", + "\n", + "\n", + "- 검증 방법: 사용자 인터랙션 테스트 및 모의 데이터 스트림 렌더링 검증\n", + "\n", + "\n", + "#### 채점자 판정 메모\n", + "\n", + "> 단순 코드 실행 여부뿐만 아니라 출력된 수치와 근거 자료가 군 실무 요구사항에 부합하는지 면밀히 확인한다.\n", + "\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "\"\"\"모의 무전 송신 스트림을 1초 주기로 받아 위조 점수·등급을 갱신하고, 스펙트로그램·점수 추이·등급 집계·경보 목록 4개 패널로 보여 주는 야간 모드 C2 음성 보안 상황판을 구현해 화면 갱신 시간과 클립 선택 응답 시간을 측정한다.\"\"\"\n", + "import time\n", + "import numpy as np\n", + "import pandas as pd\n", + "import matplotlib.pyplot as plt\n", + "from scipy import signal\n", + "from scipy.fft import dct\n", + "from sklearn.linear_model import LogisticRegression\n", + "from sklearn.preprocessing import StandardScaler\n", + "from sklearn.pipeline import make_pipeline\n", + "\n", + "# ---------------------------------------------------------------------------\n", + "# [교육용가상자료] 탐지 학습용 합성 신호 생성기 (실제 음성·화자·통신망 정보 없음)\n", + "# 사람 목소리를 만드는 기능이 아니라, 배음(하모닉) 신호에 '진짜 신호의 자연스러운 흔들림'과\n", + "# '알려진 위조 흔적(문헌상 탐지 단서)'의 통계적 특징을 표시해 탐지기를 학습시키기 위한 추상 신호다.\n", + "# bonafide : 자연스러운 피치 흔들림(jitter)·진폭 흔들림(shimmer)·숨소리 잡음\n", + "# tts_art : 흔들림이 거의 없고 고주파 에너지가 부족하며 20ms 프레임 경계 흔적이 있는 신호\n", + "# vc_art : 프레임마다 배음 위상이 끊기고 스펙트럼 포락선이 뭉개진 신호\n", + "# replay : 스피커 재생 대역 제한 + 실내 잔향이 더해진 신호\n", + "# ---------------------------------------------------------------------------\n", + "FS, DUR = 8000, 0.5\n", + "N = int(FS * DUR)\n", + "LABELS = [\"bonafide\", \"tts_art\", \"vc_art\", \"replay\"]\n", + "SPEAKERS = {f\"SPK_{c}\": (f0, fm) for c, f0, fm in [(\"A\", 115, (650, 1150, 2450)), (\"B\", 135, (700, 1220, 2600)), (\"C\", 98, (600, 1050, 2350)),\n", + " (\"D\", 160, (720, 1300, 2700)), (\"E\", 125, (680, 1180, 2500)), (\"F\", 105, (630, 1100, 2400))]}\n", + "\n", + "\n", + "def _source(rng, f0, fm, jitter, shimmer, phase_break=False, hf_cut=None, frame_mod=0.0):\n", + " # [TODO : 학생 실습 6] 실시간 무전 음성 위조 경보 및 스펙트로그램 시각화 C2 보안 인터페이스 구현 관련 핵심 로직을 구현하세요.\n", + " # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n", + " # <<< 여기에 코드를 작성하세요 >>>\n", + " pass # 작성 후 pass를 제거하세요.\n", + "def make_clip(label, speaker, rng):\n", + " f0, fm = SPEAKERS[speaker]\n", + " if label in (\"tts_art\", \"vc_art\"): # 위조는 화자 특성을 근사만 함\n", + " f0 = f0 * rng.uniform(0.92, 1.08); fm = tuple(F * rng.uniform(0.95, 1.05) for F in fm)\n", + " if label == \"bonafide\" or label == \"replay\":\n", + " x = _source(rng, f0, fm, jitter=rng.uniform(0.008, 0.02), shimmer=rng.uniform(0.05, 0.12))\n", + " elif label == \"tts_art\":\n", + " x = _source(rng, f0, fm, jitter=rng.uniform(0.0, 0.002), shimmer=0.01, hf_cut=rng.uniform(2300, 2800), frame_mod=rng.uniform(0.08, 0.2))\n", + " else:\n", + " x = _source(rng, f0, fm, jitter=rng.uniform(0.006, 0.015), shimmer=rng.uniform(0.04, 0.1), phase_break=True)\n", + " if label == \"replay\": # 스피커 대역 + 실내 잔향\n", + " x = signal.sosfilt(signal.butter(4, [450, 2900], \"bandpass\", fs=FS, output=\"sos\"), x)\n", + " ir = rng.normal(0, 1, int(0.08 * FS)) * np.exp(-np.arange(int(0.08 * FS)) / (0.02 * FS)); ir[0] = 3.0\n", + " x = signal.fftconvolve(x, ir)[:N]\n", + " return x / (np.abs(x).max() + 1e-9)\n", + "\n", + "\n", + "RADIO_BP = signal.butter(4, [300, 3400], \"bandpass\", fs=FS, output=\"sos\")\n", + "\n", + "\n", + "def radio_channel(x, rng, snr_db=15.0, impulse=0.0, dropout=0.0, clip=1.0, jam_tone=0.0):\n", + " \"\"\"[교육용가상자료] 무전 채널 모형: 300~3400Hz 대역 제한, 배경 잡음(SNR), 충격성 잡음, 신호 끊김, 포화(클리핑), 단일톤 재밍.\"\"\"\n", + " # [TODO : 학생 실습 6] 실시간 무전 음성 위조 경보 및 스펙트로그램 시각화 C2 보안 인터페이스 구현 관련 핵심 로직을 구현하세요.\n", + " # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n", + " # <<< 여기에 코드를 작성하세요 >>>\n", + " pass # 작성 후 pass를 제거하세요.\n", + "def make_dataset(n=400, seed=7, snr=(5, 25), labels=LABELS, p=None, claim=\"SPK_A\", **chan):\n", + " \"\"\"클립 n개: 진짜는 여러 화자, 위조는 모두 지휘관 화자(SPK_A)를 사칭. 반환: (신호 배열, 메타 DataFrame).\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " X, meta = np.zeros((n, N)), []\n", + " if p is None and len(labels) > 1 and labels[0] == \"bonafide\": # 기본: 진짜 50%, 나머지 위조 유형 균등\n", + " p = [0.5] + [0.5 / (len(labels) - 1)] * (len(labels) - 1)\n", + " for i in range(n):\n", + " lab = str(rng.choice(labels, p=p))\n", + " spk = str(rng.choice(list(SPEAKERS))) if lab == \"bonafide\" else claim\n", + " if lab == \"replay\":\n", + " spk = claim\n", + " s = float(rng.uniform(*snr)) if isinstance(snr, tuple) else float(snr)\n", + " X[i] = radio_channel(make_clip(lab, spk, rng), rng, snr_db=s, **chan)\n", + " meta.append({\"clip\": f\"C{seed:02d}{i:04d}\", \"label\": lab, \"spoof\": lab != \"bonafide\", \"claimed\": spk if lab == \"bonafide\" else claim,\n", + " \"true_spk\": spk if lab == \"bonafide\" else \"unknown\", \"snr_db\": round(s, 1)})\n", + " return X, pd.DataFrame(meta)\n", + "# ---------------------------------------------------------------------------\n", + "\n", + "# ---- 전처리·음향 특징(MFCC/스펙트로그램) 공통 함수: 여러 클립을 한 번에 계산 ----\n", + "NFFT, HOP = 256, 80\n", + "\n", + "\n", + "def mel_bank(n_mels=26, fmin=300, fmax=3400):\n", + " mel = lambda f: 2595 * np.log10(1 + f / 700)\n", + " imel = lambda m: 700 * (10 ** (m / 2595) - 1)\n", + " pts = imel(np.linspace(mel(fmin), mel(fmax), n_mels + 2))\n", + " freqs = np.fft.rfftfreq(NFFT, 1 / FS)\n", + " fb = np.zeros((n_mels, len(freqs)))\n", + " for m in range(n_mels):\n", + " l, c, r = pts[m], pts[m + 1], pts[m + 2]\n", + " fb[m] = np.clip(np.minimum((freqs - l) / (c - l), (r - freqs) / (r - c)), 0, None)\n", + " return fb\n", + "\n", + "\n", + "MEL = mel_bank()\n", + "FREQS = np.fft.rfftfreq(NFFT, 1 / FS)\n", + "\n", + "\n", + "def spectrogram(X, denoise=0.0):\n", + " \"\"\"배치 전력 스펙트로그램 (클립, 주파수, 프레임). denoise>0이면 잡음 바닥(주파수별 하위 10%)을 그 배수만큼 빼는 스펙트럼 차감.\"\"\"\n", + " X = np.atleast_2d(X)\n", + " Xp = np.c_[X[:, :1], X[:, 1:] - 0.95 * X[:, :-1]] # 프리엠퍼시스\n", + " frames = np.lib.stride_tricks.sliding_window_view(Xp, NFFT, axis=1)[:, ::HOP] * np.hanning(NFFT)\n", + " P = np.abs(np.fft.rfft(frames, axis=-1)) ** 2\n", + " P = np.transpose(P, (0, 2, 1))\n", + " if denoise:\n", + " floor = np.percentile(P, 10, axis=2, keepdims=True)\n", + " P = np.maximum(P - denoise * floor, 0.05 * P)\n", + " return P\n", + "\n", + "\n", + "def mfcc(P, n=13):\n", + " logmel = np.log(np.einsum(\"mf,cft->cmt\", MEL, P) + 1e-8)\n", + " return dct(logmel, type=2, axis=1, norm=\"ortho\")[:, :n, :]\n", + "\n", + "\n", + "def band(P, lo, hi):\n", + " return P[:, (FREQS >= lo) & (FREQS < hi), :].sum(axis=(1, 2))\n", + "\n", + "\n", + "def jitter_hnr(X):\n", + " \"\"\"40ms 프레임 자기상관으로 피치 주기를 찾아 피치 흔들림(jitter)과 배음 대 잡음비(HNR 근사)를 구한다.\"\"\"\n", + " W, lag0, lag1 = 320, int(FS / 250), int(FS / 70)\n", + " fr = np.lib.stride_tricks.sliding_window_view(np.atleast_2d(X), W, axis=1)[:, ::160]\n", + " fr = fr - fr.mean(-1, keepdims=True)\n", + " spec = np.fft.rfft(fr, 2 * W, axis=-1)\n", + " ac = np.fft.irfft(np.abs(spec) ** 2, axis=-1)[..., :W]\n", + " ac = ac / (ac[..., :1] + 1e-12)\n", + " seg = ac[..., lag0:lag1]\n", + " k = seg.argmax(-1)\n", + " peak = seg.max(-1)\n", + " km, kp = np.take_along_axis(seg, np.clip(k - 1, 0, None)[..., None], -1)[..., 0], np.take_along_axis(seg, np.clip(k + 1, None, seg.shape[-1] - 1)[..., None], -1)[..., 0]\n", + " lag = k + lag0 + 0.5 * (km - kp) / (km - 2 * peak + kp - 1e-12) # 포물선 보간으로 주기 정밀화\n", + " f0 = FS / lag\n", + " jit = np.median(np.abs(np.diff(f0, axis=1)) / f0[:, 1:], axis=1)\n", + " return jit, np.median(peak, axis=1)\n", + "\n", + "\n", + "def frame_mod(X):\n", + " \"\"\"진폭 포락선(2.5ms 간격)의 50Hz 성분 비율: 20ms 프레임 경계 흔적(합성 단서).\"\"\"\n", + " env = np.abs(np.atleast_2d(X)).reshape(len(np.atleast_2d(X)), -1, 20).mean(-1)\n", + " sp = np.abs(np.fft.rfft(env - env.mean(1, keepdims=True), axis=1)) ** 2\n", + " f = np.fft.rfftfreq(env.shape[1], 20 / FS)\n", + " return sp[:, (f > 47) & (f < 53)].sum(1) / (sp[:, (f > 30) & (f < 70)].sum(1) + 1e-12)\n", + "\n", + "\n", + "FEAT_NAMES = [f\"mfcc{i}_m\" for i in range(13)] + [f\"mfcc{i}_s\" for i in range(13)] + \\\n", + " [\"delta_s\", \"hf_ratio\", \"lf_ratio\", \"flatness\", \"flux\", \"jitter\", \"hnr\", \"frame_mod\"]\n", + "\n", + "\n", + "def extract(X, denoise=0.0):\n", + " \"\"\"클립 배치 -> 특징 행렬 (클립 x 34) DataFrame.\"\"\"\n", + " P = spectrogram(X, denoise)\n", + " C = mfcc(P)\n", + " tot = band(P, 300, 3400) + 1e-12\n", + " flat = np.exp(np.log(P[:, (FREQS >= 300) & (FREQS < 3400)] + 1e-10).mean(1)) / (P[:, (FREQS >= 300) & (FREQS < 3400)].mean(1) + 1e-10)\n", + " logP = np.log(P + 1e-8)\n", + " jit, hnr = jitter_hnr(X)\n", + " F = np.c_[C.mean(2), C.std(2), np.abs(np.diff(C, axis=2)).mean((1, 2)), band(P, 2400, 3400) / tot, band(P, 300, 500) / tot,\n", + " flat.mean(1), np.abs(np.diff(logP, axis=2)).mean((1, 2)), jit, hnr, frame_mod(X)]\n", + " return pd.DataFrame(F, columns=FEAT_NAMES)\n", + "\n", + "\n", + "def enroll(seed=99):\n", + " \"\"\"지휘관·통신소 화자 6명의 등록 견본(깨끗한 진짜 클립 5개씩의 MFCC 평균).\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " tpl = {}\n", + " for spk in SPEAKERS:\n", + " Xe = np.stack([radio_channel(make_clip(\"bonafide\", spk, rng), rng, snr_db=25) for _ in range(5)])\n", + " tpl[spk] = extract(Xe)[[f\"mfcc{i}_m\" for i in range(1, 13)]].mean().values\n", + " return tpl\n", + "\n", + "\n", + "def speaker_sim(F, claimed, tpl):\n", + " \"\"\"주장 화자 견본과 MFCC 평균 벡터의 코사인 유사도(화자 검증 점수).\"\"\"\n", + " v = F[[f\"mfcc{i}_m\" for i in range(1, 13)]].values\n", + " t = np.stack([tpl[c] for c in claimed])\n", + " return (v * t).sum(1) / (np.linalg.norm(v, axis=1) * np.linalg.norm(t, axis=1) + 1e-12)\n", + "\n", + "tpl = enroll()\n", + "\n", + "\n", + "def featurize(X, M):\n", + " F = extract(X)\n", + " F[\"spk_sim\"] = speaker_sim(F, M[\"claimed\"], tpl)\n", + " return F\n", + "\n", + "\n", + "Xtr, Mtr = make_dataset(400, seed=51, snr=(5, 25))\n", + "det = make_pipeline(StandardScaler(), LogisticRegression(max_iter=2000, C=0.5)).fit(featurize(Xtr, Mtr), Mtr[\"spoof\"])\n", + "\n", + "# [교육용가상자료] 모의 송신 스트림 24건(가상 망 NET-A~C, 가상 호출부호)\n", + "Xs, Ms = make_dataset(24, seed=52, snr=(6, 25), p=[0.6, 0.15, 0.15, 0.1])\n", + "Ms[\"net\"] = [f\"NET-{'ABC'[i % 3]}\" for i in range(24)]\n", + "Ms[\"t\"] = [f\"21:00:{i:02d}\" for i in range(24)]\n", + "state = pd.DataFrame(columns=[\"t\", \"net\", \"clip\", \"claimed\", \"score\", \"grade\"])\n", + "\n", + "plt.style.use(\"dark_background\") # 야간 작전 모드\n", + "fig, ax = plt.subplots(2, 2, figsize=(10, 6))\n", + "upd = []\n", + "for i in range(len(Xs)):\n", + " t0 = time.perf_counter()\n", + " s = float(det.predict_proba(featurize(Xs[i:i + 1], Ms.iloc[i:i + 1]))[0, 1])\n", + " g = \"High\" if s >= 0.8 else \"Med\" if s >= 0.4 else \"Low\"\n", + " state.loc[i] = [Ms[\"t\"][i], Ms[\"net\"][i], Ms[\"clip\"][i], Ms[\"claimed\"][i], round(s, 2), g]\n", + " for a in ax.flat:\n", + " a.clear()\n", + " P = spectrogram(Xs[i:i + 1])[0]\n", + " ax[0, 0].imshow(10 * np.log10(P + 1e-8), origin=\"lower\", aspect=\"auto\", cmap=\"magma\", extent=[0, DUR, 0, FS / 2])\n", + " ax[0, 0].set_title(f\"Spectrogram {Ms['clip'][i]} ({Ms['net'][i]})\"); ax[0, 0].set_xlabel(\"s\"); ax[0, 0].set_ylabel(\"Hz\")\n", + " col = state[\"grade\"].map({\"High\": \"red\", \"Med\": \"orange\", \"Low\": \"lime\"})\n", + " ax[0, 1].scatter(range(len(state)), state[\"score\"], c=col); ax[0, 1].axhline(0.8, c=\"red\", ls=\"--\"); ax[0, 1].axhline(0.4, c=\"orange\", ls=\":\")\n", + " ax[0, 1].set_ylim(0, 1); ax[0, 1].set_title(\"Spoof score timeline\"); ax[0, 1].set_xlabel(\"transmission #\")\n", + " cnt = state[\"grade\"].value_counts().reindex([\"High\", \"Med\", \"Low\"]).fillna(0)\n", + " ax[1, 0].bar(cnt.index, cnt.values, color=[\"red\", \"orange\", \"lime\"]); ax[1, 0].set_title(\"Grade count\")\n", + " al = state[state[\"grade\"] != \"Low\"].tail(6)\n", + " ax[1, 1].axis(\"off\"); ax[1, 1].set_title(\"Alert list (operator review)\")\n", + " for k, r in enumerate(al.itertuples()):\n", + " ax[1, 1].text(0.0, 0.9 - k * 0.15, f\"{r.t} {r.net} {r.clip} claim {r.claimed} score {r.score:.2f} {r.grade}\",\n", + " color=\"red\" if r.grade == \"High\" else \"orange\", fontsize=8, family=\"monospace\")\n", + " if g == \"High\": # 긴급 경보 하이라이트\n", + " for a in ax.flat:\n", + " for sp in a.spines.values():\n", + " sp.set_color(\"red\"); sp.set_linewidth(2)\n", + " fig.canvas.draw()\n", + " upd.append((time.perf_counter() - t0) * 1000)\n", + "\n", + "# 클립 선택 응답성: 경보 목록에서 한 건을 고르면 상세 정보(단서 상위 3개)를 찾아 보여 주는 콜백\n", + "coef = pd.Series(det[-1].coef_[0], index=featurize(Xs[:1], Ms.iloc[:1]).columns)\n", + "Fall = featurize(Xs, Ms)\n", + "Z = pd.DataFrame(det[0].transform(Fall), columns=Fall.columns)\n", + "\n", + "\n", + "def on_select(idx):\n", + " contrib = (Z.loc[idx] * coef).sort_values(ascending=False).head(3)\n", + " return {\"clip\": Ms[\"clip\"][idx], \"grade\": state[\"grade\"][idx], \"top_cues\": [f\"{k}({v:+.2f})\" for k, v in contrib.items()]}\n", + "\n", + "\n", + "resp = []\n", + "for idx in state.index[state[\"grade\"] != \"Low\"]:\n", + " t0 = time.perf_counter(); info = on_select(idx); resp.append((time.perf_counter() - t0) * 1000)\n", + "\n", + "print(\"[실시간 C2 음성 보안 상황판 - 교육용가상자료]\")\n", + "print(state.tail(8).to_string(index=False))\n", + "print(f\"\\n패널 4종(스펙트로그램, 점수 추이, 등급 집계, 경보 목록) 렌더링: {len(upd)}회 갱신\")\n", + "print(f\"화면 갱신 시간 최대 {max(upd):.0f} ms, 평균 {np.mean(upd):.0f} ms (목표 1초 이내: {'달성' if max(upd) < 1000 else '미달'})\")\n", + "print(f\"클립 선택 응답 최대 {max(resp):.1f} ms (목표 100ms 이내: {'달성' if max(resp) < 100 else '미달'})\")\n", + "print(f\"경보 {len(resp)}건 중 High {(state['grade'] == 'High').sum()}건(빨강 테두리 하이라이트), Med {(state['grade'] == 'Med').sum()}건\")\n", + "print(\"선택 예시:\", info)\n", + "print(\"실제 위조 여부(정답) 대비:\", f\"위조 {Ms['spoof'].sum()}건 중 경보 {(state['grade'] != 'Low')[Ms['spoof']].sum()}건, 진짜 {(~Ms['spoof']).sum()}건 중 경보 {(state['grade'] != 'Low')[~Ms['spoof']].sum()}건\")\n", + "print(\"상황판 경보는 판단 보조이며, 위조 확정과 전파는 통신보안관 확인(미션 7 절차) 후에 한다.\")\n", + "plt.tight_layout(); plt.show()\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 06 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + "[실시간 C2 음성 보안 상황판 - 교육용가상자료]\n", + " t net clip claimed score grade\n", + "21:00:16 NET-B C520016 SPK_F 0.01 Low\n", + "21:00:17 NET-C C520017 SPK_A 1.00 High\n", + "21:00:18 NET-A C520018 SPK_D 0.00 Low\n", + "21:00:19 NET-B C520019 SPK_F 0.10 Low\n", + "21:00:20 NET-C C520020 SPK_A 1.00 High\n", + "21:00:21 NET-A C520021 SPK_A 0.91 High\n", + "21:00:22 NET-B C520022 SPK_A 1.00 High\n", + "21:00:23 NET-C C520023 SPK_D 0.00 Low\n", + "\n", + "패널 4종(스펙트로그램, 점수 추이, 등급 집계, 경보 목록) 렌더링: 24회 갱신\n", + "화면 갱신 시간 최대 334 ms, 평균 211 ms (목표 1초 이내: 달성)\n", + "클립 선택 응답 최대 0.8 ms (목표 100ms 이내: 달성)\n", + "경보 12건 중 High 12건(빨강 테두리 하이라이트), Med 0건\n", + "선택 예시: {'clip': 'C520022', 'grade': 'High', 'top_cues': ['mfcc0_m(+2.59)', 'hnr(+1.73)', 'delta_s(+1.60)']}\n", + "실제 위조 여부(정답) 대비: 위조 12건 중 경보 12건, 진짜 12건 중 경보 0건\n", + "상황판 경보는 판단 보조이며, 위조 확정과 전파는 통신보안관 확인(미션 7 절차) 후에 한다.\n", + "```\n", + "
\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 07. 지휘관 긴급 음성 명령 수신 시 통신관 HITL 2차 육안/청음 검증 절차 설계\n", + "**소요시간**: 약 3.0시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "지휘관 긴급 음성 명령 수신 시 통신보안관이 2차 확인 및 암호 인증을 수행하는 HITL 절차를 설계한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **지휘관 명의 긴급 음성 명령이 들어오면 통신보안관이 인증 질의응답과 청음으로 2차 확인한 뒤에만 전파하는 HITL 검증 절차를 설계한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **긴급 명령은 탐지 점수와 무관하게 검증 대기열에 올리고 등급·점수 순으로 검토 순서를 정 수행하고 검증하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `src/hitl/c204_approval_workflow.py 및 docs/c204_hitl_guide.md` 형태의 결과물을 완성한다.\n", + "\n", + "### 🔍 평가 및 검증 기준 (PASS 조건)\n", + "#### 미션 판정\n", + "\n", + "**평가 구분:** 필수\n", + "\n", + "**핵심 평가영역:** 지휘 책임 분계선 명확화 및 성급한 자동 단정 방지 체계 부합성\n", + "\n", + "#### 미션 PASS 조건\n", + "\n", + "- 정량 기준: 인간 검토 큐(Queue) 대기시간 및 승인/반려 처리 로깅 성공률 100%\n", + "\n", + "\n", + "- 진행 기준: 승인/반려 인터페이스 동작 및 감사 로그(Audit Log) 생성 검증\n", + "\n", + "\n", + "- 검증 방법: 모의 조작자 승인/반려 시뮬레이션 및 데이터 무결성 검증\n", + "\n", + "\n", + "#### 채점자 판정 메모\n", + "\n", + "> 단순 코드 실행 여부뿐만 아니라 출력된 수치와 근거 자료가 군 실무 요구사항에 부합하는지 면밀히 확인한다.\n", + "\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "\"\"\"지휘관 명의 긴급 음성 명령이 수신되면 탐지 점수와 무관하게 통신보안관 2차 검증 대기열에 올리고, 가상 인증 질의응답(챌린지-응답)·청음 확인·근거 기록을 거쳐야만 전파되도록 하는 HITL 검증 절차와 해시 체인 감사 로그를 구현한다.\"\"\"\n", + "import hashlib\n", + "import hmac\n", + "import json\n", + "import pandas as pd\n", + "\n", + "ACTIONS = {\"CONFIRM\": \"진위 확인(명령 전파 허용)\", \"HOLD\": \"보류(재송신·추가 확인 요청)\", \"REJECT\": \"위조 의심 차단(상급 보고)\"}\n", + "PRIORITY = {\"High\": 0, \"Med\": 1, \"Low\": 2}\n", + "HOLD_LIMIT_S = 120\n", + "AUTH_KEY = b\"EDU-VIRTUAL-KEY\" # [교육용가상자료] 실제 인증 체계·암호 아님\n", + "\n", + "\n", + "class WorkflowError(ValueError):\n", + " pass\n", + "\n", + "\n", + "def expected_response(challenge, net):\n", + " \"\"\"가상 챌린지-응답: 질의어와 망 ID로 4자리 응답을 만든다(실제 암호 체계를 흉내 낸 학습용 모형).\"\"\"\n", + " # [TODO : 학생 실습 7] 지휘관 긴급 음성 명령 수신 시 통신관 HITL 2차 육안/청음 검증 절차 설계 관련 핵심 로직을 구현하세요.\n", + " # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n", + " # <<< 여기에 코드를 작성하세요 >>>\n", + " pass # 작성 후 pass를 제거하세요.\n", + "class VerifyQueue:\n", + " def __init__(self, clock):\n", + " self.items, self.log, self.clock, self.feedback = {}, [], clock, []\n", + "\n", + " def _record(self, body):\n", + " prev = self.log[-1][\"hash\"] if self.log else \"GENESIS\"\n", + " body = {**body, \"ts\": round(self.clock(), 1)}\n", + " h = hashlib.sha256((prev + json.dumps(body, ensure_ascii=False, sort_keys=True)).encode()).hexdigest()\n", + " self.log.append({**body, \"prev\": prev, \"hash\": h})\n", + "\n", + " def verify_log(self):\n", + " prev = \"GENESIS\"\n", + " for n, rec in enumerate(self.log, start=1):\n", + " body = {k: v for k, v in rec.items() if k not in (\"prev\", \"hash\")}\n", + " if rec[\"prev\"] != prev or rec[\"hash\"] != hashlib.sha256((prev + json.dumps(body, ensure_ascii=False, sort_keys=True)).encode()).hexdigest():\n", + " return False, n\n", + " prev = rec[\"hash\"]\n", + " return True, -1\n", + "\n", + " def submit(self, mid, net, claimed, score, grade, order):\n", + " \"\"\"긴급 명령 등록. 점수가 낮아도(진짜로 보여도) 자동 전파하지 않고 PENDING으로 시작한다.\"\"\"\n", + " self.items[mid] = {\"id\": mid, \"net\": net, \"claimed\": claimed, \"score\": score, \"grade\": grade, \"order\": order,\n", + " \"status\": \"PENDING\", \"submitted\": self.clock(), \"hold_since\": None, \"escalated\": False, \"auth\": None}\n", + " self._record({\"event\": \"SUBMIT\", \"id\": mid, \"score\": score, \"grade\": grade, \"by\": \"DETECTOR\"})\n", + "\n", + " def pending(self):\n", + " todo = [i for i in self.items.values() if i[\"status\"] in (\"PENDING\", \"HOLD\")]\n", + " return sorted(todo, key=lambda i: (not i[\"escalated\"], PRIORITY[i[\"grade\"]], -i[\"score\"], i[\"submitted\"]))\n", + "\n", + " def authenticate(self, mid, challenge, response, operator):\n", + " \"\"\"송신자에게 역호출로 가상 질의어를 보내고 받은 응답을 대조한다. 결과는 기록만 하고 결정은 사람이 한다.\"\"\"\n", + " ok = hmac.compare_digest(expected_response(challenge, self.items[mid][\"net\"]), response)\n", + " self.items[mid][\"auth\"] = ok\n", + " self._record({\"event\": \"AUTH\", \"id\": mid, \"challenge\": challenge, \"result\": ok, \"operator\": operator})\n", + " return ok\n", + "\n", + " def decide(self, mid, action, operator, reason, listened=False, second=None):\n", + " if action not in ACTIONS:\n", + " raise WorkflowError(f\"허용되지 않은 조치: {action}\")\n", + " if not operator:\n", + " raise WorkflowError(\"통신보안관 ID 필요\")\n", + " it = self.items.get(mid)\n", + " if it is None or it[\"status\"] in (\"CONFIRMED\", \"REJECTED\"):\n", + " raise WorkflowError(\"대기열에 없거나 이미 종결된 명령\")\n", + " if len(reason.strip()) < 5:\n", + " raise WorkflowError(\"판단 근거 5자 이상 필요\")\n", + " if action == \"CONFIRM\":\n", + " if not listened:\n", + " raise WorkflowError(\"전파 전 청음(직접 듣기) 확인 필요\")\n", + " if it[\"auth\"] is not True:\n", + " raise WorkflowError(\"인증 질의응답 통과 전 전파 불가\")\n", + " if it[\"grade\"] == \"High\" and (not second or second == operator):\n", + " raise WorkflowError(\"High 등급 확인은 다른 검토자 1명의 교차 확인 필요\")\n", + " it[\"status\"] = {\"CONFIRM\": \"CONFIRMED\", \"HOLD\": \"HOLD\", \"REJECT\": \"REJECTED\"}[action]\n", + " it[\"hold_since\"] = self.clock() if action == \"HOLD\" else None\n", + " it[\"decided\"] = self.clock()\n", + " self._record({\"event\": \"DECIDE\", \"id\": mid, \"action\": action, \"operator\": operator, \"second\": second, \"listened\": listened, \"reason\": reason})\n", + "\n", + " def check_holds(self):\n", + " \"\"\"보류 기한 초과 시 자동 확인하지 않고 상급 지휘관(당직 통제관)에게 상신한다.\"\"\"\n", + " out = []\n", + " for i in self.items.values():\n", + " if i[\"status\"] == \"HOLD\" and not i[\"escalated\"] and self.clock() - i[\"hold_since\"] > HOLD_LIMIT_S:\n", + " i[\"escalated\"] = True; out.append(i[\"id\"])\n", + " self._record({\"event\": \"ESCALATE\", \"id\": i[\"id\"], \"by\": \"SYSTEM\"})\n", + " return out\n", + "\n", + " def commander_feedback(self, mid, text):\n", + " self.feedback.append({\"id\": mid, \"text\": text})\n", + " self._record({\"event\": \"FEEDBACK\", \"id\": mid, \"text\": text})\n", + "\n", + "\n", + "class FakeClock:\n", + " t = 0.0\n", + "\n", + " def __call__(self):\n", + " return self.t\n", + "\n", + "\n", + "clock = FakeClock()\n", + "q = VerifyQueue(clock)\n", + "# [교육용가상자료] 미션 5 신뢰도 엔진에서 넘어온 가상 긴급 명령 6건(가상 망·가상 호출부호)\n", + "for mid, net, spk, s, g, od in [(\"U01\", \"NET-A\", \"SPK_A\", 0.97, \"High\", \"가상 집결지 변경\"), (\"U02\", \"NET-B\", \"SPK_A\", 0.62, \"Med\", \"통신 축선 변경\"),\n", + " (\"U03\", \"NET-A\", \"SPK_A\", 0.08, \"Low\", \"보고 주기 단축\"), (\"U04\", \"NET-C\", \"SPK_A\", 0.88, \"High\", \"가상 진지 이동\"),\n", + " (\"U05\", \"NET-B\", \"SPK_A\", 0.45, \"Med\", \"주파수 전환\"), (\"U06\", \"NET-C\", \"SPK_A\", 0.12, \"Low\", \"정시 보고 요청\")]:\n", + " clock.t += 5; q.submit(mid, net, spk, s, g, od)\n", + "print(\"검토 순서(등급·점수 순):\", [i[\"id\"] for i in q.pending()])\n", + "print(\"자동 전파(확인)된 명령 수:\", sum(i[\"status\"] == \"CONFIRMED\" for i in q.items.values()))\n", + "\n", + "# 규칙 위반 시도: 모두 차단되어야 한다\n", + "viol = [(\"U03\", \"CONFIRM\", \"sec01\", \"진짜 점수 낮음\", dict(listened=True)), # 인증 없이 전파\n", + " (\"U01\", \"CONFIRM\", \"\", \"근거 충분\", {}), (\"U02\", \"FORWARD\", \"sec01\", \"임의 조치\", {}),\n", + " (\"U06\", \"REJECT\", \"sec01\", \"의심\", {})]\n", + "blocked = 0\n", + "for mid, a, op, r, kw in viol:\n", + " try:\n", + " q.decide(mid, a, op, r, **kw)\n", + " except WorkflowError as e:\n", + " blocked += 1; print(f\"차단({mid} {a}): {e}\")\n", + "\n", + "# 정상 처리: 인증 질의응답 -> 청음 -> 결정\n", + "clock.t += 10; q.authenticate(\"U03\", \"ECHO\", expected_response(\"ECHO\", \"NET-A\"), \"sec01\")\n", + "q.decide(\"U03\", \"CONFIRM\", \"sec01\", \"역호출 인증 통과, 청음상 평소 송신자와 일치\", listened=True)\n", + "clock.t += 8; ok = q.authenticate(\"U01\", \"TANGO\", \"0000\", \"sec01\")\n", + "q.decide(\"U01\", \"REJECT\", \"sec01\", \"인증 응답 불일치, 프레임 경계 단서 다수\", listened=True)\n", + "clock.t += 6; q.authenticate(\"U04\", \"LIMA\", expected_response(\"LIMA\", \"NET-C\"), \"sec02\")\n", + "try:\n", + " q.decide(\"U04\", \"CONFIRM\", \"sec02\", \"인증 통과, 청음 정상\", listened=True)\n", + "except WorkflowError as e:\n", + " blocked += 1; print(f\"차단(U04 CONFIRM 단독): {e}\")\n", + "q.decide(\"U04\", \"CONFIRM\", \"sec02\", \"인증 통과, 청음 정상, 교차 확인 완료\", listened=True, second=\"sec01\")\n", + "clock.t += 4; q.decide(\"U02\", \"HOLD\", \"sec02\", \"재송신 요청 후 재청음\")\n", + "clock.t += 3; q.authenticate(\"U06\", \"MIKE\", expected_response(\"MIKE\", \"NET-C\"), \"sec02\")\n", + "q.decide(\"U06\", \"CONFIRM\", \"sec02\", \"역호출 인증 통과, 일상 보고 요청\", listened=True)\n", + "clock.t += 150\n", + "print(\"보류 기한 초과 상신:\", q.check_holds(), \"/ 상신 후 검토 순서:\", [i[\"id\"] for i in q.pending()])\n", + "q.commander_feedback(\"U01\", \"위조 차단 적절. 인증 질의어 교체 주기 단축 검토\")\n", + "\n", + "df = pd.DataFrame(q.items.values())\n", + "df[\"대기(s)\"] = (df[\"decided\"] - df[\"submitted\"]).round(1)\n", + "print(\"\\n\" + df[[\"id\", \"grade\", \"score\", \"auth\", \"status\", \"escalated\", \"대기(s)\"]].fillna(\"-\").to_string(index=False))\n", + "n_dec = sum(r[\"event\"] == \"DECIDE\" for r in q.log)\n", + "n_exp = sum(i[\"status\"] in (\"CONFIRMED\", \"REJECTED\", \"HOLD\") for i in q.items.values())\n", + "n_auto = sum(r[\"event\"] == \"DECIDE\" and not r[\"operator\"] for r in q.log)\n", + "print(f\"\\n자동 전파 {n_auto}건 / 규칙 위반 차단 {blocked}건 / 결정 로그 {n_dec}건(결정 {n_exp}건, 로깅 성공률 {n_dec / n_exp:.0%})\")\n", + "print(f\"평균 검토 대기 {df['대기(s)'].mean():.1f}s, 감사 로그 {len(q.log)}건, 해시 체인 검증: {q.verify_log()}\")\n", + "q.log[7][\"operator\"] = \"sec99\"\n", + "print(\"8번째 기록 변조 후 검증(False, 깨진 위치):\", q.verify_log())\n", + "print(\"\\n책임 분계: 탐지기=점수 제시, 통신보안관=인증·청음·결정, 지휘관=최종 명령 책임. 자동 판정으로 명령을 전파하거나 차단하지 않는다.\")\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 07 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + "검토 순서(등급·점수 순): ['U01', 'U04', 'U02', 'U05', 'U06', 'U03']\n", + "자동 전파(확인)된 명령 수: 0\n", + "차단(U03 CONFIRM): 인증 질의응답 통과 전 전파 불가\n", + "차단(U01 CONFIRM): 통신보안관 ID 필요\n", + "차단(U02 FORWARD): 허용되지 않은 조치: FORWARD\n", + "차단(U06 REJECT): 판단 근거 5자 이상 필요\n", + "차단(U04 CONFIRM 단독): High 등급 확인은 다른 검토자 1명의 교차 확인 필요\n", + "보류 기한 초과 상신: ['U02'] / 상신 후 검토 순서: ['U02', 'U05']\n", + "\n", + " id grade score auth status escalated 대기(s)\n", + "U01 High 0.97 False REJECTED False 43.0\n", + "U02 Med 0.62 - HOLD True 48.0\n", + "U03 Low 0.08 True CONFIRMED False 25.0\n", + "U04 High 0.88 True CONFIRMED False 34.0\n", + "U05 Med 0.45 - PENDING False -\n", + "U06 Low 0.12 True CONFIRMED False 31.0\n", + "\n", + "자동 전파 0건 / 규칙 위반 차단 5건 / 결정 로그 5건(결정 5건, 로깅 성공률 100%)\n", + "평균 검토 대기 36.2s, 감사 로그 17건, 해시 체인 검증: (True, -1)\n", + "8번째 기록 변조 후 검증(False, 깨진 위치): (False, 8)\n", + "\n", + "책임 분계: 탐지기=점수 제시, 통신보안관=인증·청음·결정, 지휘관=최종 명령 책임. 자동 판정으로 명령을 전파하거나 차단하지 않는다.\n", + "```\n", + "
\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 08. 전자전 재밍 및 잡음 혼입 환경 모의 딥페이크 탐지 한계치 스트레스 테스트\n", + "**소요시간**: 약 3.5시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "전자전 재밍 및 심한 잡음 혼입 환경을 모의하여 딥페이크 탐지 성능 한계치 스트레스 테스트를 수행한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **전자전 재밍과 심한 잡음 환경에서 위조 탐지 성능의 한계점을 찾고, 트래픽 급증 부하에서도 안전하게 동작하는지 시험한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **SNR과 단일톤 재밍 세기를 단계적으로 높이며 정확도·재현율·오경보율 곡선을 그려 판정 불가 구간을 정 수행하고 검증하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `tests/c204_stress_test.py 및 스트레스 테스트 분석 보고서` 형태의 결과물을 완성한다.\n", + "\n", + "### 🔍 평가 및 검증 기준 (PASS 조건)\n", + "#### 미션 판정\n", + "\n", + "**평가 구분:** 필수\n", + "\n", + "**핵심 평가영역:** 극단 상황에서의 안전 실패(Fail-safe) 및 기능 축소 작동 적절성\n", + "\n", + "#### 미션 PASS 조건\n", + "\n", + "- 정량 기준: 동시 트래픽 500% 증가 시 시스템 다운 0건, 데이터 유실률 0%\n", + "\n", + "\n", + "- 진행 기준: 대량 시뮬레이션 부하 주입 테스트 통과 및 병목 구간 프로파일링 완료\n", + "\n", + "\n", + "- 검증 방법: 부하 테스트 도구를 통한 처리량(Throughput) 및 메모리 누수 검증\n", + "\n", + "\n", + "#### 채점자 판정 메모\n", + "\n", + "> 단순 코드 실행 여부뿐만 아니라 출력된 수치와 근거 자료가 군 실무 요구사항에 부합하는지 면밀히 확인한다.\n", + "\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "\"\"\"전자전 재밍·잡음 세기를 단계적으로 높여 위조 탐지 성능의 한계점을 찾고, 무전 트래픽을 평시의 5배로 늘리며 손상 입력을 섞은 부하 시험에서 시스템 다운·데이터 유실 없이 기능 축소 모드와 사람 확인으로 안전하게 넘기는지 확인한다.\"\"\"\n", + "import time\n", + "import tracemalloc\n", + "from collections import deque\n", + "import numpy as np\n", + "import pandas as pd\n", + "import matplotlib.pyplot as plt\n", + "from scipy import signal\n", + "from scipy.fft import dct\n", + "from sklearn.linear_model import LogisticRegression\n", + "from sklearn.preprocessing import StandardScaler\n", + "from sklearn.pipeline import make_pipeline\n", + "\n", + "# ---------------------------------------------------------------------------\n", + "# [교육용가상자료] 탐지 학습용 합성 신호 생성기 (실제 음성·화자·통신망 정보 없음)\n", + "# 사람 목소리를 만드는 기능이 아니라, 배음(하모닉) 신호에 '진짜 신호의 자연스러운 흔들림'과\n", + "# '알려진 위조 흔적(문헌상 탐지 단서)'의 통계적 특징을 표시해 탐지기를 학습시키기 위한 추상 신호다.\n", + "# bonafide : 자연스러운 피치 흔들림(jitter)·진폭 흔들림(shimmer)·숨소리 잡음\n", + "# tts_art : 흔들림이 거의 없고 고주파 에너지가 부족하며 20ms 프레임 경계 흔적이 있는 신호\n", + "# vc_art : 프레임마다 배음 위상이 끊기고 스펙트럼 포락선이 뭉개진 신호\n", + "# replay : 스피커 재생 대역 제한 + 실내 잔향이 더해진 신호\n", + "# ---------------------------------------------------------------------------\n", + "FS, DUR = 8000, 0.5\n", + "N = int(FS * DUR)\n", + "LABELS = [\"bonafide\", \"tts_art\", \"vc_art\", \"replay\"]\n", + "SPEAKERS = {f\"SPK_{c}\": (f0, fm) for c, f0, fm in [(\"A\", 115, (650, 1150, 2450)), (\"B\", 135, (700, 1220, 2600)), (\"C\", 98, (600, 1050, 2350)),\n", + " (\"D\", 160, (720, 1300, 2700)), (\"E\", 125, (680, 1180, 2500)), (\"F\", 105, (630, 1100, 2400))]}\n", + "\n", + "\n", + "def _source(rng, f0, fm, jitter, shimmer, phase_break=False, hf_cut=None, frame_mod=0.0):\n", + " # [TODO : 학생 실습 8] 전자전 재밍 및 잡음 혼입 환경 모의 딥페이크 탐지 한계치 스트레스 테스트 관련 핵심 로직을 구현하세요.\n", + " # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n", + " # <<< 여기에 코드를 작성하세요 >>>\n", + " pass # 작성 후 pass를 제거하세요.\n", + "def make_clip(label, speaker, rng):\n", + " f0, fm = SPEAKERS[speaker]\n", + " if label in (\"tts_art\", \"vc_art\"): # 위조는 화자 특성을 근사만 함\n", + " f0 = f0 * rng.uniform(0.92, 1.08); fm = tuple(F * rng.uniform(0.95, 1.05) for F in fm)\n", + " if label == \"bonafide\" or label == \"replay\":\n", + " x = _source(rng, f0, fm, jitter=rng.uniform(0.008, 0.02), shimmer=rng.uniform(0.05, 0.12))\n", + " elif label == \"tts_art\":\n", + " x = _source(rng, f0, fm, jitter=rng.uniform(0.0, 0.002), shimmer=0.01, hf_cut=rng.uniform(2300, 2800), frame_mod=rng.uniform(0.08, 0.2))\n", + " else:\n", + " x = _source(rng, f0, fm, jitter=rng.uniform(0.006, 0.015), shimmer=rng.uniform(0.04, 0.1), phase_break=True)\n", + " if label == \"replay\": # 스피커 대역 + 실내 잔향\n", + " x = signal.sosfilt(signal.butter(4, [450, 2900], \"bandpass\", fs=FS, output=\"sos\"), x)\n", + " ir = rng.normal(0, 1, int(0.08 * FS)) * np.exp(-np.arange(int(0.08 * FS)) / (0.02 * FS)); ir[0] = 3.0\n", + " x = signal.fftconvolve(x, ir)[:N]\n", + " return x / (np.abs(x).max() + 1e-9)\n", + "\n", + "\n", + "RADIO_BP = signal.butter(4, [300, 3400], \"bandpass\", fs=FS, output=\"sos\")\n", + "\n", + "\n", + "def radio_channel(x, rng, snr_db=15.0, impulse=0.0, dropout=0.0, clip=1.0, jam_tone=0.0):\n", + " \"\"\"[교육용가상자료] 무전 채널 모형: 300~3400Hz 대역 제한, 배경 잡음(SNR), 충격성 잡음, 신호 끊김, 포화(클리핑), 단일톤 재밍.\"\"\"\n", + " # [TODO : 학생 실습 8] 전자전 재밍 및 잡음 혼입 환경 모의 딥페이크 탐지 한계치 스트레스 테스트 관련 핵심 로직을 구현하세요.\n", + " # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n", + " # <<< 여기에 코드를 작성하세요 >>>\n", + " pass # 작성 후 pass를 제거하세요.\n", + "def make_dataset(n=400, seed=7, snr=(5, 25), labels=LABELS, p=None, claim=\"SPK_A\", **chan):\n", + " \"\"\"클립 n개: 진짜는 여러 화자, 위조는 모두 지휘관 화자(SPK_A)를 사칭. 반환: (신호 배열, 메타 DataFrame).\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " X, meta = np.zeros((n, N)), []\n", + " if p is None and len(labels) > 1 and labels[0] == \"bonafide\": # 기본: 진짜 50%, 나머지 위조 유형 균등\n", + " p = [0.5] + [0.5 / (len(labels) - 1)] * (len(labels) - 1)\n", + " for i in range(n):\n", + " lab = str(rng.choice(labels, p=p))\n", + " spk = str(rng.choice(list(SPEAKERS))) if lab == \"bonafide\" else claim\n", + " if lab == \"replay\":\n", + " spk = claim\n", + " s = float(rng.uniform(*snr)) if isinstance(snr, tuple) else float(snr)\n", + " X[i] = radio_channel(make_clip(lab, spk, rng), rng, snr_db=s, **chan)\n", + " meta.append({\"clip\": f\"C{seed:02d}{i:04d}\", \"label\": lab, \"spoof\": lab != \"bonafide\", \"claimed\": spk if lab == \"bonafide\" else claim,\n", + " \"true_spk\": spk if lab == \"bonafide\" else \"unknown\", \"snr_db\": round(s, 1)})\n", + " return X, pd.DataFrame(meta)\n", + "# ---------------------------------------------------------------------------\n", + "\n", + "# ---- 전처리·음향 특징(MFCC/스펙트로그램) 공통 함수: 여러 클립을 한 번에 계산 ----\n", + "NFFT, HOP = 256, 80\n", + "\n", + "\n", + "def mel_bank(n_mels=26, fmin=300, fmax=3400):\n", + " mel = lambda f: 2595 * np.log10(1 + f / 700)\n", + " imel = lambda m: 700 * (10 ** (m / 2595) - 1)\n", + " pts = imel(np.linspace(mel(fmin), mel(fmax), n_mels + 2))\n", + " freqs = np.fft.rfftfreq(NFFT, 1 / FS)\n", + " fb = np.zeros((n_mels, len(freqs)))\n", + " for m in range(n_mels):\n", + " l, c, r = pts[m], pts[m + 1], pts[m + 2]\n", + " fb[m] = np.clip(np.minimum((freqs - l) / (c - l), (r - freqs) / (r - c)), 0, None)\n", + " return fb\n", + "\n", + "\n", + "MEL = mel_bank()\n", + "FREQS = np.fft.rfftfreq(NFFT, 1 / FS)\n", + "\n", + "\n", + "def spectrogram(X, denoise=0.0):\n", + " \"\"\"배치 전력 스펙트로그램 (클립, 주파수, 프레임). denoise>0이면 잡음 바닥(주파수별 하위 10%)을 그 배수만큼 빼는 스펙트럼 차감.\"\"\"\n", + " X = np.atleast_2d(X)\n", + " Xp = np.c_[X[:, :1], X[:, 1:] - 0.95 * X[:, :-1]] # 프리엠퍼시스\n", + " frames = np.lib.stride_tricks.sliding_window_view(Xp, NFFT, axis=1)[:, ::HOP] * np.hanning(NFFT)\n", + " P = np.abs(np.fft.rfft(frames, axis=-1)) ** 2\n", + " P = np.transpose(P, (0, 2, 1))\n", + " if denoise:\n", + " floor = np.percentile(P, 10, axis=2, keepdims=True)\n", + " P = np.maximum(P - denoise * floor, 0.05 * P)\n", + " return P\n", + "\n", + "\n", + "def mfcc(P, n=13):\n", + " logmel = np.log(np.einsum(\"mf,cft->cmt\", MEL, P) + 1e-8)\n", + " return dct(logmel, type=2, axis=1, norm=\"ortho\")[:, :n, :]\n", + "\n", + "\n", + "def band(P, lo, hi):\n", + " return P[:, (FREQS >= lo) & (FREQS < hi), :].sum(axis=(1, 2))\n", + "\n", + "\n", + "def jitter_hnr(X):\n", + " \"\"\"40ms 프레임 자기상관으로 피치 주기를 찾아 피치 흔들림(jitter)과 배음 대 잡음비(HNR 근사)를 구한다.\"\"\"\n", + " W, lag0, lag1 = 320, int(FS / 250), int(FS / 70)\n", + " fr = np.lib.stride_tricks.sliding_window_view(np.atleast_2d(X), W, axis=1)[:, ::160]\n", + " fr = fr - fr.mean(-1, keepdims=True)\n", + " spec = np.fft.rfft(fr, 2 * W, axis=-1)\n", + " ac = np.fft.irfft(np.abs(spec) ** 2, axis=-1)[..., :W]\n", + " ac = ac / (ac[..., :1] + 1e-12)\n", + " seg = ac[..., lag0:lag1]\n", + " k = seg.argmax(-1)\n", + " peak = seg.max(-1)\n", + " km, kp = np.take_along_axis(seg, np.clip(k - 1, 0, None)[..., None], -1)[..., 0], np.take_along_axis(seg, np.clip(k + 1, None, seg.shape[-1] - 1)[..., None], -1)[..., 0]\n", + " lag = k + lag0 + 0.5 * (km - kp) / (km - 2 * peak + kp - 1e-12) # 포물선 보간으로 주기 정밀화\n", + " f0 = FS / lag\n", + " jit = np.median(np.abs(np.diff(f0, axis=1)) / f0[:, 1:], axis=1)\n", + " return jit, np.median(peak, axis=1)\n", + "\n", + "\n", + "def frame_mod(X):\n", + " \"\"\"진폭 포락선(2.5ms 간격)의 50Hz 성분 비율: 20ms 프레임 경계 흔적(합성 단서).\"\"\"\n", + " env = np.abs(np.atleast_2d(X)).reshape(len(np.atleast_2d(X)), -1, 20).mean(-1)\n", + " sp = np.abs(np.fft.rfft(env - env.mean(1, keepdims=True), axis=1)) ** 2\n", + " f = np.fft.rfftfreq(env.shape[1], 20 / FS)\n", + " return sp[:, (f > 47) & (f < 53)].sum(1) / (sp[:, (f > 30) & (f < 70)].sum(1) + 1e-12)\n", + "\n", + "\n", + "FEAT_NAMES = [f\"mfcc{i}_m\" for i in range(13)] + [f\"mfcc{i}_s\" for i in range(13)] + \\\n", + " [\"delta_s\", \"hf_ratio\", \"lf_ratio\", \"flatness\", \"flux\", \"jitter\", \"hnr\", \"frame_mod\"]\n", + "\n", + "\n", + "def extract(X, denoise=0.0):\n", + " \"\"\"클립 배치 -> 특징 행렬 (클립 x 34) DataFrame.\"\"\"\n", + " P = spectrogram(X, denoise)\n", + " C = mfcc(P)\n", + " tot = band(P, 300, 3400) + 1e-12\n", + " flat = np.exp(np.log(P[:, (FREQS >= 300) & (FREQS < 3400)] + 1e-10).mean(1)) / (P[:, (FREQS >= 300) & (FREQS < 3400)].mean(1) + 1e-10)\n", + " logP = np.log(P + 1e-8)\n", + " jit, hnr = jitter_hnr(X)\n", + " F = np.c_[C.mean(2), C.std(2), np.abs(np.diff(C, axis=2)).mean((1, 2)), band(P, 2400, 3400) / tot, band(P, 300, 500) / tot,\n", + " flat.mean(1), np.abs(np.diff(logP, axis=2)).mean((1, 2)), jit, hnr, frame_mod(X)]\n", + " return pd.DataFrame(F, columns=FEAT_NAMES)\n", + "\n", + "\n", + "def enroll(seed=99):\n", + " \"\"\"지휘관·통신소 화자 6명의 등록 견본(깨끗한 진짜 클립 5개씩의 MFCC 평균).\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " tpl = {}\n", + " for spk in SPEAKERS:\n", + " Xe = np.stack([radio_channel(make_clip(\"bonafide\", spk, rng), rng, snr_db=25) for _ in range(5)])\n", + " tpl[spk] = extract(Xe)[[f\"mfcc{i}_m\" for i in range(1, 13)]].mean().values\n", + " return tpl\n", + "\n", + "\n", + "def speaker_sim(F, claimed, tpl):\n", + " \"\"\"주장 화자 견본과 MFCC 평균 벡터의 코사인 유사도(화자 검증 점수).\"\"\"\n", + " v = F[[f\"mfcc{i}_m\" for i in range(1, 13)]].values\n", + " t = np.stack([tpl[c] for c in claimed])\n", + " return (v * t).sum(1) / (np.linalg.norm(v, axis=1) * np.linalg.norm(t, axis=1) + 1e-12)\n", + "\n", + "tpl = enroll()\n", + "\n", + "\n", + "def featurize(X, M):\n", + " F = extract(X)\n", + " F[\"spk_sim\"] = speaker_sim(F, M[\"claimed\"], tpl)\n", + " return F\n", + "\n", + "\n", + "Xtr, Mtr = make_dataset(500, seed=61, snr=(3, 25))\n", + "det = make_pipeline(StandardScaler(), LogisticRegression(max_iter=3000, C=0.5)).fit(featurize(Xtr, Mtr), Mtr[\"spoof\"])\n", + "\n", + "# 1) 탐지 한계치: 잡음(SNR)·재밍 세기 단계별 정확도\n", + "print(\"[탐지 한계치 - 교육용가상자료, 단계별 120클립]\")\n", + "print(\" 조건 정확도 위조재현율 오경보율\")\n", + "curve = []\n", + "for name, ch in [(\"SNR 20dB\", dict(snr=20.0)), (\"SNR 10dB\", dict(snr=10.0)), (\"SNR 5dB\", dict(snr=5.0)), (\"SNR 0dB\", dict(snr=0.0)),\n", + " (\"SNR -5dB\", dict(snr=-5.0)), (\"재밍 0.5\", dict(snr=12.0, jam_tone=0.5)), (\"재밍 1.0\", dict(snr=12.0, jam_tone=1.0)),\n", + " (\"재밍 2.0\", dict(snr=12.0, jam_tone=2.0))]:\n", + " X, M = make_dataset(120, seed=700 + len(curve), **ch)\n", + " pr = det.predict(featurize(X, M)); y = M[\"spoof\"].values\n", + " curve.append((name, (pr == y).mean(), pr[y].mean(), pr[~y].mean()))\n", + " print(f\" {name:12s} {curve[-1][1]:7.1%} {curve[-1][2]:9.1%} {curve[-1][3]:9.1%}\")\n", + "lim = [c[0] for c in curve if c[1] < 0.8]\n", + "print(\"정확도 80% 미만 구간(한계):\", lim if lim else \"없음\", \"-> 이 구간은 자동 점수 대신 '판정 불가(사람 확인)'로 처리\")\n", + "\n", + "# 2) 부하 시험: 평시 초당 20건 -> 500% 증가(초당 100건), 손상 입력 3% 혼입\n", + "pool, pmeta = make_dataset(200, seed=790, snr=(3, 25))\n", + "rng = np.random.default_rng(800)\n", + "\n", + "\n", + "def arrivals(n):\n", + " idx = rng.integers(0, len(pool), n)\n", + " xs = [pool[i] + rng.normal(0, 0.02, N) for i in idx]\n", + " for k in np.flatnonzero(rng.random(n) < 0.03): # 손상 입력 주입\n", + " xs[k] = [np.full(N, np.nan), np.zeros(10), np.zeros(N)][k % 3]\n", + " return xs, pmeta.iloc[idx].reset_index(drop=True)\n", + "\n", + "\n", + "def valid(x):\n", + " return len(x) == N and np.isfinite(x).all() and np.abs(x).max() > 1e-6\n", + "\n", + "\n", + "def run(rate, ticks=8, budget_s=1.0, backlog_lim=150):\n", + " \"\"\"1초 단위로 rate건 도착. 처리 시간이 예산을 넘거나 대기열이 길면 기능 축소 모드(화자 유사도 생략, 점수만 산출 후 사람 확인 표시).\"\"\"\n", + " q, done, quarantine, degraded, crash = deque(), 0, 0, 0, 0\n", + " lat, t_feat, t_pred = [], 0.0, 0.0\n", + " total_in = 0\n", + " tracemalloc.start(); m0 = tracemalloc.get_traced_memory()[0]\n", + " for _ in range(ticks):\n", + " xs, ms = arrivals(rate); total_in += rate\n", + " q.extend(zip(xs, ms.to_dict(\"records\")))\n", + " t_start = time.perf_counter()\n", + " while q and time.perf_counter() - t_start < budget_s:\n", + " batch = [q.popleft() for _ in range(min(25, len(q)))]\n", + " ok = [b for b in batch if valid(b[0])]\n", + " quarantine += len(batch) - len(ok) # 손상 입력은 버리지 않고 격리 보관\n", + " if not ok:\n", + " continue\n", + " try:\n", + " Xb = np.stack([b[0] for b in ok]); Mb = pd.DataFrame([b[1] for b in ok])\n", + " t0 = time.perf_counter(); F = extract(Xb)\n", + " if len(q) > backlog_lim:\n", + " F[\"spk_sim\"] = 0.9; degraded += len(ok) # 기능 축소: 화자 검증 생략 -> 결과는 '사람 확인' 표시\n", + " else:\n", + " F[\"spk_sim\"] = speaker_sim(F, Mb[\"claimed\"], tpl)\n", + " t1 = time.perf_counter(); det.predict_proba(F); t2 = time.perf_counter()\n", + " t_feat += t1 - t0; t_pred += t2 - t1; lat.append((t2 - t0) * 1000 / len(ok)); done += len(ok)\n", + " except Exception:\n", + " crash += 1; q.extendleft(reversed(batch)) # 실패 배치는 대기열로 되돌림(유실 방지)\n", + " break\n", + " while q: # 시험 종료 후 잔여 대기열 소진\n", + " b = q.popleft()\n", + " if valid(b[0]):\n", + " F = extract(b[0][None]); F[\"spk_sim\"] = speaker_sim(F, [b[1][\"claimed\"]], tpl); det.predict_proba(F); done += 1\n", + " else:\n", + " quarantine += 1\n", + " mem = (tracemalloc.get_traced_memory()[0] - m0) / 1e6; tracemalloc.stop()\n", + " return dict(rate=rate, total=total_in, done=done, quarantine=quarantine, lost=total_in - done - quarantine, crash=crash,\n", + " degraded=degraded, ms=np.mean(lat), mem=mem, feat=t_feat, pred=t_pred)\n", + "\n", + "\n", + "print(\"\\n[부하 시험] 도착률 입력 처리 격리 유실 다운 기능축소 ms/건 메모리증가MB\")\n", + "res = [run(20), run(100), run(100, budget_s=0.02)]\n", + "for r, nm in zip(res, [\"평시\", \"5배\", \"5배+처리지연\"]):\n", + " print(f\"{nm:9s} {r['rate']:4d}/s {r['total']:5d} {r['done']:5d} {r['quarantine']:5d} {r['lost']:5d} {r['crash']:5d} {r['degraded']:8d} {r['ms']:6.2f} {r['mem']:10.2f}\")\n", + "hi = res[1]\n", + "print(f\"트래픽 500% 증가: 시스템 다운 {hi['crash']}건, 데이터 유실 {hi['lost'] / hi['total']:.1%} (목표 0건/0%: {'달성' if hi['crash'] == 0 and hi['lost'] == 0 else '미달'})\")\n", + "Xp = pool[:200]; Pp = spectrogram(Xp); prof = {}\n", + "for nm, fn in [(\"스펙트로그램\", lambda: spectrogram(Xp)), (\"MFCC\", lambda: mfcc(Pp)), (\"피치 자기상관(jitter/HNR)\", lambda: jitter_hnr(Xp)), (\"프레임 경계 단서\", lambda: frame_mod(Xp))]:\n", + " t0 = time.perf_counter(); fn(); prof[nm] = time.perf_counter() - t0\n", + "tot = sum(prof.values())\n", + "print(f\"처리 시간 구성(100/s): 특징 추출 {hi['feat'] / (hi['feat'] + hi['pred']):.0%}, 추론 {hi['pred'] / (hi['feat'] + hi['pred']):.0%}\")\n", + "print(\"특징 추출 내부 프로파일:\", \", \".join(f\"{k} {v / tot:.0%}\" for k, v in prof.items()), f\"-> 병목: {max(prof, key=prof.get)}\")\n", + "dg = res[2]\n", + "print(f\"처리 지연 모의: 대기열 적체 시 {dg['degraded']}건을 기능 축소 모드로 처리, 유실 {dg['lost']}건, 다운 {dg['crash']}건\")\n", + "print(\"기능 축소 결과와 판정 불가 구간 결과는 자동 차단·전파 없이 통신보안관 확인 대기열로 보낸다(Fail-safe).\")\n", + "\n", + "plt.figure(figsize=(7, 3.5))\n", + "lab = [c[0].replace(\"재밍\", \"jam\") for c in curve]\n", + "plt.plot(lab, [c[1] for c in curve], \"o-\", label=\"accuracy\")\n", + "plt.plot(lab, [c[3] for c in curve], \"s--\", label=\"false alarm\")\n", + "plt.axhline(0.8, c=\"k\", ls=\":\"); plt.xticks(rotation=30); plt.legend(); plt.title(\"Detection limit under noise / jamming (synthetic)\")\n", + "plt.tight_layout(); plt.show()\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 08 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + "[탐지 한계치 - 교육용가상자료, 단계별 120클립]\n", + " 조건 정확도 위조재현율 오경보율\n", + " SNR 20dB 99.2% 98.4% 0.0%\n", + " SNR 10dB 96.7% 96.8% 3.4%\n", + " SNR 5dB 97.5% 98.1% 3.0%\n", + " SNR 0dB 66.7% 100.0% 66.7%\n", + " SNR -5dB 48.3% 100.0% 98.4%\n", + " 재밍 0.5 95.8% 98.1% 6.1%\n", + " 재밍 1.0 90.0% 96.8% 17.2%\n", + " 재밍 2.0 85.8% 89.7% 19.2%\n", + "정확도 80% 미만 구간(한계): ['SNR 0dB', 'SNR -5dB'] -> 이 구간은 자동 점수 대신 '판정 불가(사람 확인)'로 처리\n", + "\n", + "[부하 시험] 도착률 입력 처리 격리 유실 다운 기능축소 ms/건 메모리증가MB\n", + "평시 20/s 160 150 10 0 0 0 1.29 1.23\n", + "5배 100/s 800 774 26 0 0 0 1.19 4.02\n", + "5배+처리지연 100/s 800 781 19 0 0 146 1.09 4.87\n", + "트래픽 500% 증가: 시스템 다운 0건, 데이터 유실 0.0% (목표 0건/0%: 달성)\n", + "처리 시간 구성(100/s): 특징 추출 85%, 추론 15%\n", + "특징 추출 내부 프로파일: 스펙트로그램 26%, MFCC 17%, 피치 자기상관(jitter/HNR) 54%, 프레임 경계 단서 3% -> 병목: 피치 자기상관(jitter/HNR)\n", + "처리 지연 모의: 대기열 적체 시 146건을 기능 축소 모드로 처리, 유실 0건, 다운 0건\n", + "기능 축소 결과와 판정 불가 구간 결과는 자동 차단·전파 없이 통신보안관 확인 대기열로 보낸다(Fail-safe).\n", + "```\n", + "
\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 09. AAR 기반 무전 위조 오판정 음성 분석 및 음향 특징 추출 파라미터 재보정\n", + "**소요시간**: 약 3.0시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "AAR 사후검토를 통해 위조 음성 오판정 사례를 분석하고 모델 파라미터를 재보정한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **1차 배치 Baseline의 모의 운용 기록에서 오판정 사례를 분석(AAR)하고, 특징 추출·판정 파라미터를 재보정한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **오판정을 미탐과 오경보로 나누어 채널 조건·위조 유형별로 집계하고 원인을 규명 수행하고 검증하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `docs/c204_aar_report.md 및 configs/c204_tuned_params.yaml` 형태의 결과물을 완성한다.\n", + "\n", + "### 🔍 평가 및 검증 기준 (PASS 조건)\n", + "#### 미션 판정\n", + "\n", + "**평가 구분:** 필수\n", + "\n", + "**핵심 평가영역:** 오류 원인 규명의 객관성 및 개선 조치의 논리적 타당성\n", + "\n", + "#### 미션 PASS 조건\n", + "\n", + "- 정량 기준: AAR 피드백 적용 후 종합 정확도 10%p 이상 향상, 재발 오류 0건\n", + "\n", + "\n", + "- 진행 기준: AAR 보고서 작성 및 파라미터 튜닝 전후 회귀 테스트 통과\n", + "\n", + "\n", + "- 검증 방법: 회귀 테스트(Regression Test) 스위트 전체 자동 실행 및 비교\n", + "\n", + "\n", + "#### 채점자 판정 메모\n", + "\n", + "> 단순 코드 실행 여부뿐만 아니라 출력된 수치와 근거 자료가 군 실무 요구사항에 부합하는지 면밀히 확인한다.\n", + "\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "\"\"\"1차 배치(규칙 기반 Baseline)의 모의 운용 기록에서 위조 오판정 사례를 유형·채널 조건별로 분석(AAR)하고, 오판정 사례 재학습·채널 품질 특징 추가·전처리 차감 배수와 판정 임계값 재탐색으로 파라미터를 재보정한 뒤 회귀 시험으로 정확도 향상과 재발 오류를 확인한다.\"\"\"\n", + "import numpy as np\n", + "import pandas as pd\n", + "import matplotlib.pyplot as plt\n", + "from scipy import signal\n", + "from scipy.fft import dct\n", + "from sklearn.neural_network import MLPClassifier\n", + "from sklearn.preprocessing import StandardScaler\n", + "from sklearn.pipeline import make_pipeline\n", + "\n", + "# ---------------------------------------------------------------------------\n", + "# [교육용가상자료] 탐지 학습용 합성 신호 생성기 (실제 음성·화자·통신망 정보 없음)\n", + "# 사람 목소리를 만드는 기능이 아니라, 배음(하모닉) 신호에 '진짜 신호의 자연스러운 흔들림'과\n", + "# '알려진 위조 흔적(문헌상 탐지 단서)'의 통계적 특징을 표시해 탐지기를 학습시키기 위한 추상 신호다.\n", + "# bonafide : 자연스러운 피치 흔들림(jitter)·진폭 흔들림(shimmer)·숨소리 잡음\n", + "# tts_art : 흔들림이 거의 없고 고주파 에너지가 부족하며 20ms 프레임 경계 흔적이 있는 신호\n", + "# vc_art : 프레임마다 배음 위상이 끊기고 스펙트럼 포락선이 뭉개진 신호\n", + "# replay : 스피커 재생 대역 제한 + 실내 잔향이 더해진 신호\n", + "# ---------------------------------------------------------------------------\n", + "FS, DUR = 8000, 0.5\n", + "N = int(FS * DUR)\n", + "LABELS = [\"bonafide\", \"tts_art\", \"vc_art\", \"replay\"]\n", + "SPEAKERS = {f\"SPK_{c}\": (f0, fm) for c, f0, fm in [(\"A\", 115, (650, 1150, 2450)), (\"B\", 135, (700, 1220, 2600)), (\"C\", 98, (600, 1050, 2350)),\n", + " (\"D\", 160, (720, 1300, 2700)), (\"E\", 125, (680, 1180, 2500)), (\"F\", 105, (630, 1100, 2400))]}\n", + "\n", + "\n", + "def _source(rng, f0, fm, jitter, shimmer, phase_break=False, hf_cut=None, frame_mod=0.0):\n", + " # [TODO : 학생 실습 9] AAR 기반 무전 위조 오판정 음성 분석 및 음향 특징 추출 파라미터 재보정 관련 핵심 로직을 구현하세요.\n", + " # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n", + " # <<< 여기에 코드를 작성하세요 >>>\n", + " pass # 작성 후 pass를 제거하세요.\n", + "def make_clip(label, speaker, rng):\n", + " f0, fm = SPEAKERS[speaker]\n", + " if label in (\"tts_art\", \"vc_art\"): # 위조는 화자 특성을 근사만 함\n", + " f0 = f0 * rng.uniform(0.92, 1.08); fm = tuple(F * rng.uniform(0.95, 1.05) for F in fm)\n", + " if label == \"bonafide\" or label == \"replay\":\n", + " x = _source(rng, f0, fm, jitter=rng.uniform(0.008, 0.02), shimmer=rng.uniform(0.05, 0.12))\n", + " elif label == \"tts_art\":\n", + " x = _source(rng, f0, fm, jitter=rng.uniform(0.0, 0.002), shimmer=0.01, hf_cut=rng.uniform(2300, 2800), frame_mod=rng.uniform(0.08, 0.2))\n", + " else:\n", + " x = _source(rng, f0, fm, jitter=rng.uniform(0.006, 0.015), shimmer=rng.uniform(0.04, 0.1), phase_break=True)\n", + " if label == \"replay\": # 스피커 대역 + 실내 잔향\n", + " x = signal.sosfilt(signal.butter(4, [450, 2900], \"bandpass\", fs=FS, output=\"sos\"), x)\n", + " ir = rng.normal(0, 1, int(0.08 * FS)) * np.exp(-np.arange(int(0.08 * FS)) / (0.02 * FS)); ir[0] = 3.0\n", + " x = signal.fftconvolve(x, ir)[:N]\n", + " return x / (np.abs(x).max() + 1e-9)\n", + "\n", + "\n", + "RADIO_BP = signal.butter(4, [300, 3400], \"bandpass\", fs=FS, output=\"sos\")\n", + "\n", + "\n", + "def radio_channel(x, rng, snr_db=15.0, impulse=0.0, dropout=0.0, clip=1.0, jam_tone=0.0):\n", + " \"\"\"[교육용가상자료] 무전 채널 모형: 300~3400Hz 대역 제한, 배경 잡음(SNR), 충격성 잡음, 신호 끊김, 포화(클리핑), 단일톤 재밍.\"\"\"\n", + " # [TODO : 학생 실습 9] AAR 기반 무전 위조 오판정 음성 분석 및 음향 특징 추출 파라미터 재보정 관련 핵심 로직을 구현하세요.\n", + " # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n", + " # <<< 여기에 코드를 작성하세요 >>>\n", + " pass # 작성 후 pass를 제거하세요.\n", + "def make_dataset(n=400, seed=7, snr=(5, 25), labels=LABELS, p=None, claim=\"SPK_A\", **chan):\n", + " \"\"\"클립 n개: 진짜는 여러 화자, 위조는 모두 지휘관 화자(SPK_A)를 사칭. 반환: (신호 배열, 메타 DataFrame).\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " X, meta = np.zeros((n, N)), []\n", + " if p is None and len(labels) > 1 and labels[0] == \"bonafide\": # 기본: 진짜 50%, 나머지 위조 유형 균등\n", + " p = [0.5] + [0.5 / (len(labels) - 1)] * (len(labels) - 1)\n", + " for i in range(n):\n", + " lab = str(rng.choice(labels, p=p))\n", + " spk = str(rng.choice(list(SPEAKERS))) if lab == \"bonafide\" else claim\n", + " if lab == \"replay\":\n", + " spk = claim\n", + " s = float(rng.uniform(*snr)) if isinstance(snr, tuple) else float(snr)\n", + " X[i] = radio_channel(make_clip(lab, spk, rng), rng, snr_db=s, **chan)\n", + " meta.append({\"clip\": f\"C{seed:02d}{i:04d}\", \"label\": lab, \"spoof\": lab != \"bonafide\", \"claimed\": spk if lab == \"bonafide\" else claim,\n", + " \"true_spk\": spk if lab == \"bonafide\" else \"unknown\", \"snr_db\": round(s, 1)})\n", + " return X, pd.DataFrame(meta)\n", + "# ---------------------------------------------------------------------------\n", + "\n", + "# ---- 전처리·음향 특징(MFCC/스펙트로그램) 공통 함수: 여러 클립을 한 번에 계산 ----\n", + "NFFT, HOP = 256, 80\n", + "\n", + "\n", + "def mel_bank(n_mels=26, fmin=300, fmax=3400):\n", + " mel = lambda f: 2595 * np.log10(1 + f / 700)\n", + " imel = lambda m: 700 * (10 ** (m / 2595) - 1)\n", + " pts = imel(np.linspace(mel(fmin), mel(fmax), n_mels + 2))\n", + " freqs = np.fft.rfftfreq(NFFT, 1 / FS)\n", + " fb = np.zeros((n_mels, len(freqs)))\n", + " for m in range(n_mels):\n", + " l, c, r = pts[m], pts[m + 1], pts[m + 2]\n", + " fb[m] = np.clip(np.minimum((freqs - l) / (c - l), (r - freqs) / (r - c)), 0, None)\n", + " return fb\n", + "\n", + "\n", + "MEL = mel_bank()\n", + "FREQS = np.fft.rfftfreq(NFFT, 1 / FS)\n", + "\n", + "\n", + "def spectrogram(X, denoise=0.0):\n", + " \"\"\"배치 전력 스펙트로그램 (클립, 주파수, 프레임). denoise>0이면 잡음 바닥(주파수별 하위 10%)을 그 배수만큼 빼는 스펙트럼 차감.\"\"\"\n", + " X = np.atleast_2d(X)\n", + " Xp = np.c_[X[:, :1], X[:, 1:] - 0.95 * X[:, :-1]] # 프리엠퍼시스\n", + " frames = np.lib.stride_tricks.sliding_window_view(Xp, NFFT, axis=1)[:, ::HOP] * np.hanning(NFFT)\n", + " P = np.abs(np.fft.rfft(frames, axis=-1)) ** 2\n", + " P = np.transpose(P, (0, 2, 1))\n", + " if denoise:\n", + " floor = np.percentile(P, 10, axis=2, keepdims=True)\n", + " P = np.maximum(P - denoise * floor, 0.05 * P)\n", + " return P\n", + "\n", + "\n", + "def mfcc(P, n=13):\n", + " logmel = np.log(np.einsum(\"mf,cft->cmt\", MEL, P) + 1e-8)\n", + " return dct(logmel, type=2, axis=1, norm=\"ortho\")[:, :n, :]\n", + "\n", + "\n", + "def band(P, lo, hi):\n", + " return P[:, (FREQS >= lo) & (FREQS < hi), :].sum(axis=(1, 2))\n", + "\n", + "\n", + "def jitter_hnr(X):\n", + " \"\"\"40ms 프레임 자기상관으로 피치 주기를 찾아 피치 흔들림(jitter)과 배음 대 잡음비(HNR 근사)를 구한다.\"\"\"\n", + " W, lag0, lag1 = 320, int(FS / 250), int(FS / 70)\n", + " fr = np.lib.stride_tricks.sliding_window_view(np.atleast_2d(X), W, axis=1)[:, ::160]\n", + " fr = fr - fr.mean(-1, keepdims=True)\n", + " spec = np.fft.rfft(fr, 2 * W, axis=-1)\n", + " ac = np.fft.irfft(np.abs(spec) ** 2, axis=-1)[..., :W]\n", + " ac = ac / (ac[..., :1] + 1e-12)\n", + " seg = ac[..., lag0:lag1]\n", + " k = seg.argmax(-1)\n", + " peak = seg.max(-1)\n", + " km, kp = np.take_along_axis(seg, np.clip(k - 1, 0, None)[..., None], -1)[..., 0], np.take_along_axis(seg, np.clip(k + 1, None, seg.shape[-1] - 1)[..., None], -1)[..., 0]\n", + " lag = k + lag0 + 0.5 * (km - kp) / (km - 2 * peak + kp - 1e-12) # 포물선 보간으로 주기 정밀화\n", + " f0 = FS / lag\n", + " jit = np.median(np.abs(np.diff(f0, axis=1)) / f0[:, 1:], axis=1)\n", + " return jit, np.median(peak, axis=1)\n", + "\n", + "\n", + "def frame_mod(X):\n", + " \"\"\"진폭 포락선(2.5ms 간격)의 50Hz 성분 비율: 20ms 프레임 경계 흔적(합성 단서).\"\"\"\n", + " env = np.abs(np.atleast_2d(X)).reshape(len(np.atleast_2d(X)), -1, 20).mean(-1)\n", + " sp = np.abs(np.fft.rfft(env - env.mean(1, keepdims=True), axis=1)) ** 2\n", + " f = np.fft.rfftfreq(env.shape[1], 20 / FS)\n", + " return sp[:, (f > 47) & (f < 53)].sum(1) / (sp[:, (f > 30) & (f < 70)].sum(1) + 1e-12)\n", + "\n", + "\n", + "FEAT_NAMES = [f\"mfcc{i}_m\" for i in range(13)] + [f\"mfcc{i}_s\" for i in range(13)] + \\\n", + " [\"delta_s\", \"hf_ratio\", \"lf_ratio\", \"flatness\", \"flux\", \"jitter\", \"hnr\", \"frame_mod\"]\n", + "\n", + "\n", + "def extract(X, denoise=0.0):\n", + " \"\"\"클립 배치 -> 특징 행렬 (클립 x 34) DataFrame.\"\"\"\n", + " P = spectrogram(X, denoise)\n", + " C = mfcc(P)\n", + " tot = band(P, 300, 3400) + 1e-12\n", + " flat = np.exp(np.log(P[:, (FREQS >= 300) & (FREQS < 3400)] + 1e-10).mean(1)) / (P[:, (FREQS >= 300) & (FREQS < 3400)].mean(1) + 1e-10)\n", + " logP = np.log(P + 1e-8)\n", + " jit, hnr = jitter_hnr(X)\n", + " F = np.c_[C.mean(2), C.std(2), np.abs(np.diff(C, axis=2)).mean((1, 2)), band(P, 2400, 3400) / tot, band(P, 300, 500) / tot,\n", + " flat.mean(1), np.abs(np.diff(logP, axis=2)).mean((1, 2)), jit, hnr, frame_mod(X)]\n", + " return pd.DataFrame(F, columns=FEAT_NAMES)\n", + "\n", + "\n", + "def enroll(seed=99):\n", + " \"\"\"지휘관·통신소 화자 6명의 등록 견본(깨끗한 진짜 클립 5개씩의 MFCC 평균).\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " tpl = {}\n", + " for spk in SPEAKERS:\n", + " Xe = np.stack([radio_channel(make_clip(\"bonafide\", spk, rng), rng, snr_db=25) for _ in range(5)])\n", + " tpl[spk] = extract(Xe)[[f\"mfcc{i}_m\" for i in range(1, 13)]].mean().values\n", + " return tpl\n", + "\n", + "\n", + "def speaker_sim(F, claimed, tpl):\n", + " \"\"\"주장 화자 견본과 MFCC 평균 벡터의 코사인 유사도(화자 검증 점수).\"\"\"\n", + " v = F[[f\"mfcc{i}_m\" for i in range(1, 13)]].values\n", + " t = np.stack([tpl[c] for c in claimed])\n", + " return (v * t).sum(1) / (np.linalg.norm(v, axis=1) * np.linalg.norm(t, axis=1) + 1e-12)\n", + "\n", + "def channel_quality(X):\n", + " \"\"\"채널 품질 지표: 추정 SNR(유효 프레임 에너지 상하위 비, dB), 단일톤 재밍 지수(주파수별 시간 평균/표준편차의 최대값), 끊김 비율(무음 10ms 비율).\"\"\"\n", + " P = spectrogram(X)\n", + " e = P.sum(1)\n", + " e = np.where(e > 1e-9, e, np.nan)\n", + " snr_est = 10 * np.log10(np.nanpercentile(e, 90, axis=1) / (np.nanpercentile(e, 10, axis=1) + 1e-12))\n", + " Pb = P[:, (FREQS >= 300) & (FREQS < 3400)]\n", + " tone = (Pb.mean(2) / (Pb.std(2) + 1e-12)).max(1) # 시간에 따라 일정한 주파수 성분(재밍 톤)일수록 큼\n", + " fr = np.abs(np.atleast_2d(X)).reshape(len(X), -1, 80).max(-1)\n", + " drop = (fr < 1e-6).mean(1)\n", + " return pd.DataFrame({\"snr_est\": snr_est, \"tone_idx\": tone, \"drop_ratio\": drop})\n", + "\n", + "tpl = enroll()\n", + "CONDS = {\"평시\": dict(snr=(12, 25)), \"저SNR\": dict(snr=(3, 8)), \"충격잡음\": dict(snr=(8, 15), impulse=0.002),\n", + " \"신호끊김\": dict(snr=(8, 15), dropout=0.4), \"재밍\": dict(snr=(8, 15), jam_tone=0.5)}\n", + "\n", + "\n", + "def ops_set(n, seed):\n", + " \"\"\"[교육용가상자료] 모의 운용 기록: 채널 조건 5종 x n/5 클립, 재생 공격 비중을 높게 둠.\"\"\"\n", + " parts = []\n", + " for i, (c, ch) in enumerate(CONDS.items()):\n", + " X, M = make_dataset(n // 5, seed=seed + i, p=[0.5, 0.15, 0.15, 0.2], **ch); M[\"cond\"] = c; parts.append((X, M))\n", + " return np.vstack([p[0] for p in parts]), pd.concat([p[1] for p in parts], ignore_index=True)\n", + "\n", + "\n", + "def feats(X, M, denoise=0.0):\n", + " F = extract(X, denoise); F[\"spk_sim\"] = speaker_sim(F, M[\"claimed\"], tpl)\n", + " return pd.concat([F, channel_quality(X)], axis=1)\n", + "\n", + "\n", + "# ---- 재보정 전: 미션 3 규칙 기반 Baseline(진짜 음성 3% 분위수 경계) ----\n", + "CUES = [(\"hf_ratio\", \"low\"), (\"frame_mod\", \"high\"), (\"hnr\", \"low\"), (\"flatness\", \"high\"), (\"lf_ratio\", \"low\"), (\"spk_sim\", \"low\")]\n", + "Xb, Mb = make_dataset(200, seed=7, labels=[\"bonafide\"])\n", + "Fb = feats(Xb, Mb)\n", + "th = {f: Fb[f].quantile(0.03) if d == \"low\" else Fb[f].quantile(0.97) for f, d in CUES}\n", + "rule = lambda F: pd.DataFrame({f: (F[f] < th[f]) if d == \"low\" else (F[f] > th[f]) for f, d in CUES}).any(axis=1).values\n", + "\n", + "Xo, Mo = ops_set(400, seed=900) # AAR 대상 운용 기록\n", + "Fo = feats(Xo, Mo)\n", + "Mo[\"pred\"] = rule(Fo); Mo[\"err\"] = Mo[\"pred\"] != Mo[\"spoof\"]\n", + "print(f\"[AAR] 운용 기록 {len(Mo)}건, 재보정 전 정확도 {1 - Mo['err'].mean():.1%}, 오판정 {Mo['err'].sum()}건\")\n", + "Mo[\"유형\"] = np.where(Mo[\"spoof\"], \"미탐(위조->진짜)\", \"오경보(진짜->위조)\")\n", + "tab = Mo[Mo[\"err\"]].groupby([\"유형\", \"cond\"]).size().unstack(fill_value=0)\n", + "print(tab.to_string())\n", + "miss_lab = Mo[Mo[\"err\"] & Mo[\"spoof\"]][\"label\"].value_counts()\n", + "print(\"미탐 위조 유형:\", {k: int(v) for k, v in miss_lab.items()})\n", + "print(\"원인 분석: ① 재생(replay)은 진짜 음성을 녹음한 것이라 화자 유사도·자연스러운 흔들림이 높아 규칙이 놓친다\",\n", + " \"\\n ② 재밍·저SNR·끊김 채널에서 진짜 음성의 단서 값이 경계를 넘어 오경보가 난다(채널 품질 미반영)\",\n", + " \"\\n ③ 단서별 고정 경계(3% 분위수)는 조건이 바뀌어도 그대로라 조합 판단이 불가능하다\")\n", + "\n", + "# ---- 재보정: 운용 기록(오판정 포함)과 조건 혼합 데이터로 재학습, 차감 배수·임계값 탐색 ----\n", + "Xn, Mn = ops_set(600, seed=950)\n", + "Xv, Mv = ops_set(300, seed=980) # 검증용(파라미터 선택)\n", + "best = None\n", + "for dn in [0.0, 0.5, 1.0]:\n", + " Ftr = pd.concat([feats(Xn, Mn, dn), feats(Xo, Mo, dn)], ignore_index=True)\n", + " ytr = np.r_[Mn[\"spoof\"], Mo[\"spoof\"]]\n", + " w = np.r_[np.ones(len(Mn)), np.where(Mo[\"err\"], 3, 1)] # AAR 오판정 사례 가중 반영(표본 복제)\n", + " idx = np.repeat(np.arange(len(ytr)), w.astype(int))\n", + " mdl = make_pipeline(StandardScaler(), MLPClassifier((64, 32), alpha=1e-3, max_iter=800, random_state=0)).fit(Ftr.iloc[idx], ytr[idx])\n", + " pv = mdl.predict_proba(feats(Xv, Mv, dn))[:, 1]\n", + " for t in [0.3, 0.4, 0.5, 0.6, 0.7]:\n", + " acc = ((pv >= t) == Mv[\"spoof\"]).mean()\n", + " if best is None or acc > best[0]:\n", + " best = (acc, dn, t, mdl)\n", + "acc_v, dn, t, mdl = best\n", + "yaml = f\"\"\"# configs/c204_tuned_params.yaml [교육용가상자료]\n", + "preprocess:\n", + " spectral_subtraction: {dn}\n", + "features: [mfcc13_mean, mfcc13_std, hf_ratio, lf_ratio, flatness, flux, jitter, hnr, frame_mod, spk_sim, snr_est, tone_idx, drop_ratio]\n", + "model:\n", + " type: mlp\n", + " hidden: [64, 32]\n", + " aar_case_weight: 3\n", + "decision:\n", + " spoof_threshold: {t}\n", + " poor_channel_rule: snr_est<3.3 or tone_idx>2.5 or drop_ratio>0.05 -> human_review\n", + "\"\"\"\n", + "print(\"\\n[재보정 파라미터]\\n\" + yaml.strip())\n", + "\n", + "# ---- 회귀 시험 ----\n", + "Xh, Mh = ops_set(400, seed=1100) # 새 운용 기록(학습에 쓰지 않음)\n", + "before = (rule(feats(Xh, Mh)) == Mh[\"spoof\"]).mean()\n", + "ph = mdl.predict_proba(feats(Xh, Mh, dn))[:, 1] >= t\n", + "after = (ph == Mh[\"spoof\"]).mean()\n", + "po = mdl.predict_proba(feats(Xo, Mo, dn))[:, 1] >= t\n", + "recur = int(((po != Mo[\"spoof\"]) & Mo[\"err\"]).sum())\n", + "newerr = int(((po != Mo[\"spoof\"]) & ~Mo[\"err\"]).sum())\n", + "print(f\"\\n[회귀 시험] 새 운용 기록 {len(Mh)}건 정확도: 재보정 전 {before:.1%} -> 후 {after:.1%} ({(after - before) * 100:+.1f}%p, 목표 +10%p: {'달성' if after - before >= 0.10 else '미달'})\")\n", + "print(f\"AAR 오판정 {Mo['err'].sum()}건 재발 {recur}건 (목표 0건: {'달성' if recur == 0 else '미달'}), 기존 정답 사례 중 새 오류 {newerr}건\")\n", + "print(\"조건별 정확도(새 기록):\", {c: f\"{(ph[Mh['cond'] == c] == Mh['spoof'][Mh['cond'] == c]).mean():.0%}\" for c in CONDS})\n", + "print(\"지속 학습 연계: 통신보안관이 확정한 사례만 라벨로 누적 -> 월 1회 재학습 -> 본 회귀 시험 통과 시에만 교체 배치\")\n", + "\n", + "plt.figure(figsize=(6, 3.5))\n", + "c = list(CONDS)\n", + "plt.bar(np.arange(5) - 0.2, [(rule(feats(Xh[Mh['cond'] == k], Mh[Mh['cond'] == k].reset_index(drop=True))) == Mh['spoof'][Mh['cond'] == k].values).mean() for k in c], 0.4, label=\"before (rules)\")\n", + "plt.bar(np.arange(5) + 0.2, [(ph[Mh['cond'] == k] == Mh['spoof'][Mh['cond'] == k]).mean() for k in c], 0.4, label=\"after (retuned)\")\n", + "plt.xticks(range(5), [\"normal\", \"low SNR\", \"impulse\", \"dropout\", \"jamming\"]); plt.ylabel(\"accuracy\"); plt.legend(); plt.title(\"AAR recalibration (synthetic)\")\n", + "plt.tight_layout(); plt.show()\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 09 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + "[AAR] 운용 기록 400건, 재보정 전 정확도 69.8%, 오판정 121건\n", + "cond 신호끊김 재밍 저SNR 충격잡음 평시\n", + "유형 \n", + "미탐(위조->진짜) 10 11 3 12 23\n", + "오경보(진짜->위조) 10 15 26 8 3\n", + "미탐 위조 유형: {'replay': 53, 'vc_art': 4, 'tts_art': 2}\n", + "원인 분석: ① 재생(replay)은 진짜 음성을 녹음한 것이라 화자 유사도·자연스러운 흔들림이 높아 규칙이 놓친다 \n", + " ② 재밍·저SNR·끊김 채널에서 진짜 음성의 단서 값이 경계를 넘어 오경보가 난다(채널 품질 미반영) \n", + " ③ 단서별 고정 경계(3% 분위수)는 조건이 바뀌어도 그대로라 조합 판단이 불가능하다\n", + "\n", + "[재보정 파라미터]\n", + "# configs/c204_tuned_params.yaml [교육용가상자료]\n", + "preprocess:\n", + " spectral_subtraction: 0.0\n", + "features: [mfcc13_mean, mfcc13_std, hf_ratio, lf_ratio, flatness, flux, jitter, hnr, frame_mod, spk_sim, snr_est, tone_idx, drop_ratio]\n", + "model:\n", + " type: mlp\n", + " hidden: [64, 32]\n", + " aar_case_weight: 3\n", + "decision:\n", + " spoof_threshold: 0.3\n", + " poor_channel_rule: snr_est<3.3 or tone_idx>2.5 or drop_ratio>0.05 -> human_review\n", + "\n", + "[회귀 시험] 새 운용 기록 400건 정확도: 재보정 전 78.2% -> 후 96.8% (+18.5%p, 목표 +10%p: 달성)\n", + "AAR 오판정 121건 재발 0건 (목표 0건: 달성), 기존 정답 사례 중 새 오류 0건\n", + "조건별 정확도(새 기록): {'평시': '100%', '저SNR': '95%', '충격잡음': '98%', '신호끊김': '95%', '재밍': '96%'}\n", + "지속 학습 연계: 통신보안관이 확정한 사례만 라벨로 누적 -> 월 1회 재학습 -> 본 회귀 시험 통과 시에만 교체 배치\n", + "```\n", + "
\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 10. 무전 음성 스푸핑·딥페이크 탐지 시스템 통합 및 C2 음성 보안 브리핑 패키지 완성\n", + "**소요시간**: 약 3.5시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "무전 음성 스푸핑·딥페이크 탐지 시스템 통합 패키지 및 C2 보안 브리핑 보고서를 완성한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **검증부터 판별, 신뢰도 등급, 검증 대기열, 감사 로그까지 한 번에 실행되는 통합 패키지를 만들고 C2 음성 보안 브리핑을 완성한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **수신 기록을 검증해 손상·미등록 기록을 차단하고, 재보정 파라미터로 판별·등급을 산출해 위조 의심과 긴급 명령을 전량 검증 대기열에 올린다 과정을 진행하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `src/main_c204.py, README.md, docs/c204_final_report.pdf(md)` 형태의 결과물을 완성한다.\n", + "\n", + "### 🔍 평가 및 검증 기준 (PASS 조건)\n", + "#### 미션 판정\n", + "\n", + "**평가 구분:** 필수\n", + "\n", + "**핵심 평가영역:** 군 실무 즉시 도입 가능성, 운용 신뢰도 및 전략적 기대효과\n", + "\n", + "#### 미션 PASS 조건\n", + "\n", + "- 정량 기준: End-to-End 통합 파이프라인 실행 성공률 100%, 테스트 커버리지 85% 이상\n", + "\n", + "\n", + "- 진행 기준: 원클릭 실행 스크립트 동작 확인 및 최종 발표 산출물 작성 완료\n", + "\n", + "\n", + "- 검증 방법: 최종 검증 위원회(평가관) 모의 시연 및 전체 기능 통합 검증\n", + "\n", + "\n", + "#### 채점자 판정 메모\n", + "\n", + "> 단순 코드 실행 여부뿐만 아니라 출력된 수치와 근거 자료가 군 실무 요구사항에 부합하는지 면밀히 확인한다.\n", + "\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "\"\"\"수신 메타데이터 검증, 무전 채널 전처리·특징 추출, 재보정 파라미터를 적용한 위조 판별·신뢰도 등급, 통신보안관 검증 대기열·감사 로그까지 한 번에 실행하는 통합 파이프라인을 만들고, 자체 점검(함수 커버리지 포함)과 C2 음성 보안 브리핑 요약을 출력한다.\"\"\"\n", + "import hashlib\n", + "import json\n", + "import time\n", + "import numpy as np\n", + "import pandas as pd\n", + "import matplotlib.pyplot as plt\n", + "from scipy import signal\n", + "from scipy.fft import dct\n", + "from sklearn.neural_network import MLPClassifier\n", + "from sklearn.preprocessing import StandardScaler\n", + "from sklearn.pipeline import make_pipeline\n", + "\n", + "# ---------------------------------------------------------------------------\n", + "# [교육용가상자료] 탐지 학습용 합성 신호 생성기 (실제 음성·화자·통신망 정보 없음)\n", + "# 사람 목소리를 만드는 기능이 아니라, 배음(하모닉) 신호에 '진짜 신호의 자연스러운 흔들림'과\n", + "# '알려진 위조 흔적(문헌상 탐지 단서)'의 통계적 특징을 표시해 탐지기를 학습시키기 위한 추상 신호다.\n", + "# bonafide : 자연스러운 피치 흔들림(jitter)·진폭 흔들림(shimmer)·숨소리 잡음\n", + "# tts_art : 흔들림이 거의 없고 고주파 에너지가 부족하며 20ms 프레임 경계 흔적이 있는 신호\n", + "# vc_art : 프레임마다 배음 위상이 끊기고 스펙트럼 포락선이 뭉개진 신호\n", + "# replay : 스피커 재생 대역 제한 + 실내 잔향이 더해진 신호\n", + "# ---------------------------------------------------------------------------\n", + "FS, DUR = 8000, 0.5\n", + "N = int(FS * DUR)\n", + "LABELS = [\"bonafide\", \"tts_art\", \"vc_art\", \"replay\"]\n", + "SPEAKERS = {f\"SPK_{c}\": (f0, fm) for c, f0, fm in [(\"A\", 115, (650, 1150, 2450)), (\"B\", 135, (700, 1220, 2600)), (\"C\", 98, (600, 1050, 2350)),\n", + " (\"D\", 160, (720, 1300, 2700)), (\"E\", 125, (680, 1180, 2500)), (\"F\", 105, (630, 1100, 2400))]}\n", + "\n", + "\n", + "def _source(rng, f0, fm, jitter, shimmer, phase_break=False, hf_cut=None, frame_mod=0.0):\n", + " # [TODO : 학생 실습 10] 무전 음성 스푸핑·딥페이크 탐지 시스템 통합 및 C2 음성 보안 브리핑 패키지 완성 관련 핵심 로직을 구현하세요.\n", + " # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n", + " # <<< 여기에 코드를 작성하세요 >>>\n", + " pass # 작성 후 pass를 제거하세요.\n", + "def make_clip(label, speaker, rng):\n", + " f0, fm = SPEAKERS[speaker]\n", + " if label in (\"tts_art\", \"vc_art\"): # 위조는 화자 특성을 근사만 함\n", + " f0 = f0 * rng.uniform(0.92, 1.08); fm = tuple(F * rng.uniform(0.95, 1.05) for F in fm)\n", + " if label == \"bonafide\" or label == \"replay\":\n", + " x = _source(rng, f0, fm, jitter=rng.uniform(0.008, 0.02), shimmer=rng.uniform(0.05, 0.12))\n", + " elif label == \"tts_art\":\n", + " x = _source(rng, f0, fm, jitter=rng.uniform(0.0, 0.002), shimmer=0.01, hf_cut=rng.uniform(2300, 2800), frame_mod=rng.uniform(0.08, 0.2))\n", + " else:\n", + " x = _source(rng, f0, fm, jitter=rng.uniform(0.006, 0.015), shimmer=rng.uniform(0.04, 0.1), phase_break=True)\n", + " if label == \"replay\": # 스피커 대역 + 실내 잔향\n", + " x = signal.sosfilt(signal.butter(4, [450, 2900], \"bandpass\", fs=FS, output=\"sos\"), x)\n", + " ir = rng.normal(0, 1, int(0.08 * FS)) * np.exp(-np.arange(int(0.08 * FS)) / (0.02 * FS)); ir[0] = 3.0\n", + " x = signal.fftconvolve(x, ir)[:N]\n", + " return x / (np.abs(x).max() + 1e-9)\n", + "\n", + "\n", + "RADIO_BP = signal.butter(4, [300, 3400], \"bandpass\", fs=FS, output=\"sos\")\n", + "\n", + "\n", + "def radio_channel(x, rng, snr_db=15.0, impulse=0.0, dropout=0.0, clip=1.0, jam_tone=0.0):\n", + " \"\"\"[교육용가상자료] 무전 채널 모형: 300~3400Hz 대역 제한, 배경 잡음(SNR), 충격성 잡음, 신호 끊김, 포화(클리핑), 단일톤 재밍.\"\"\"\n", + " # [TODO : 학생 실습 10] 무전 음성 스푸핑·딥페이크 탐지 시스템 통합 및 C2 음성 보안 브리핑 패키지 완성 관련 핵심 로직을 구현하세요.\n", + " # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n", + " # <<< 여기에 코드를 작성하세요 >>>\n", + " pass # 작성 후 pass를 제거하세요.\n", + "def make_dataset(n=400, seed=7, snr=(5, 25), labels=LABELS, p=None, claim=\"SPK_A\", **chan):\n", + " \"\"\"클립 n개: 진짜는 여러 화자, 위조는 모두 지휘관 화자(SPK_A)를 사칭. 반환: (신호 배열, 메타 DataFrame).\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " X, meta = np.zeros((n, N)), []\n", + " if p is None and len(labels) > 1 and labels[0] == \"bonafide\": # 기본: 진짜 50%, 나머지 위조 유형 균등\n", + " p = [0.5] + [0.5 / (len(labels) - 1)] * (len(labels) - 1)\n", + " for i in range(n):\n", + " lab = str(rng.choice(labels, p=p))\n", + " spk = str(rng.choice(list(SPEAKERS))) if lab == \"bonafide\" else claim\n", + " if lab == \"replay\":\n", + " spk = claim\n", + " s = float(rng.uniform(*snr)) if isinstance(snr, tuple) else float(snr)\n", + " X[i] = radio_channel(make_clip(lab, spk, rng), rng, snr_db=s, **chan)\n", + " meta.append({\"clip\": f\"C{seed:02d}{i:04d}\", \"label\": lab, \"spoof\": lab != \"bonafide\", \"claimed\": spk if lab == \"bonafide\" else claim,\n", + " \"true_spk\": spk if lab == \"bonafide\" else \"unknown\", \"snr_db\": round(s, 1)})\n", + " return X, pd.DataFrame(meta)\n", + "# ---------------------------------------------------------------------------\n", + "\n", + "# ---- 전처리·음향 특징(MFCC/스펙트로그램) 공통 함수: 여러 클립을 한 번에 계산 ----\n", + "NFFT, HOP = 256, 80\n", + "\n", + "\n", + "def mel_bank(n_mels=26, fmin=300, fmax=3400):\n", + " mel = lambda f: 2595 * np.log10(1 + f / 700)\n", + " imel = lambda m: 700 * (10 ** (m / 2595) - 1)\n", + " pts = imel(np.linspace(mel(fmin), mel(fmax), n_mels + 2))\n", + " freqs = np.fft.rfftfreq(NFFT, 1 / FS)\n", + " fb = np.zeros((n_mels, len(freqs)))\n", + " for m in range(n_mels):\n", + " l, c, r = pts[m], pts[m + 1], pts[m + 2]\n", + " fb[m] = np.clip(np.minimum((freqs - l) / (c - l), (r - freqs) / (r - c)), 0, None)\n", + " return fb\n", + "\n", + "\n", + "MEL = mel_bank()\n", + "FREQS = np.fft.rfftfreq(NFFT, 1 / FS)\n", + "\n", + "\n", + "def spectrogram(X, denoise=0.0):\n", + " \"\"\"배치 전력 스펙트로그램 (클립, 주파수, 프레임). denoise>0이면 잡음 바닥(주파수별 하위 10%)을 그 배수만큼 빼는 스펙트럼 차감.\"\"\"\n", + " X = np.atleast_2d(X)\n", + " Xp = np.c_[X[:, :1], X[:, 1:] - 0.95 * X[:, :-1]] # 프리엠퍼시스\n", + " frames = np.lib.stride_tricks.sliding_window_view(Xp, NFFT, axis=1)[:, ::HOP] * np.hanning(NFFT)\n", + " P = np.abs(np.fft.rfft(frames, axis=-1)) ** 2\n", + " P = np.transpose(P, (0, 2, 1))\n", + " if denoise:\n", + " floor = np.percentile(P, 10, axis=2, keepdims=True)\n", + " P = np.maximum(P - denoise * floor, 0.05 * P)\n", + " return P\n", + "\n", + "\n", + "def mfcc(P, n=13):\n", + " logmel = np.log(np.einsum(\"mf,cft->cmt\", MEL, P) + 1e-8)\n", + " return dct(logmel, type=2, axis=1, norm=\"ortho\")[:, :n, :]\n", + "\n", + "\n", + "def band(P, lo, hi):\n", + " return P[:, (FREQS >= lo) & (FREQS < hi), :].sum(axis=(1, 2))\n", + "\n", + "\n", + "def jitter_hnr(X):\n", + " \"\"\"40ms 프레임 자기상관으로 피치 주기를 찾아 피치 흔들림(jitter)과 배음 대 잡음비(HNR 근사)를 구한다.\"\"\"\n", + " W, lag0, lag1 = 320, int(FS / 250), int(FS / 70)\n", + " fr = np.lib.stride_tricks.sliding_window_view(np.atleast_2d(X), W, axis=1)[:, ::160]\n", + " fr = fr - fr.mean(-1, keepdims=True)\n", + " spec = np.fft.rfft(fr, 2 * W, axis=-1)\n", + " ac = np.fft.irfft(np.abs(spec) ** 2, axis=-1)[..., :W]\n", + " ac = ac / (ac[..., :1] + 1e-12)\n", + " seg = ac[..., lag0:lag1]\n", + " k = seg.argmax(-1)\n", + " peak = seg.max(-1)\n", + " km, kp = np.take_along_axis(seg, np.clip(k - 1, 0, None)[..., None], -1)[..., 0], np.take_along_axis(seg, np.clip(k + 1, None, seg.shape[-1] - 1)[..., None], -1)[..., 0]\n", + " lag = k + lag0 + 0.5 * (km - kp) / (km - 2 * peak + kp - 1e-12) # 포물선 보간으로 주기 정밀화\n", + " f0 = FS / lag\n", + " jit = np.median(np.abs(np.diff(f0, axis=1)) / f0[:, 1:], axis=1)\n", + " return jit, np.median(peak, axis=1)\n", + "\n", + "\n", + "def frame_mod(X):\n", + " \"\"\"진폭 포락선(2.5ms 간격)의 50Hz 성분 비율: 20ms 프레임 경계 흔적(합성 단서).\"\"\"\n", + " env = np.abs(np.atleast_2d(X)).reshape(len(np.atleast_2d(X)), -1, 20).mean(-1)\n", + " sp = np.abs(np.fft.rfft(env - env.mean(1, keepdims=True), axis=1)) ** 2\n", + " f = np.fft.rfftfreq(env.shape[1], 20 / FS)\n", + " return sp[:, (f > 47) & (f < 53)].sum(1) / (sp[:, (f > 30) & (f < 70)].sum(1) + 1e-12)\n", + "\n", + "\n", + "FEAT_NAMES = [f\"mfcc{i}_m\" for i in range(13)] + [f\"mfcc{i}_s\" for i in range(13)] + \\\n", + " [\"delta_s\", \"hf_ratio\", \"lf_ratio\", \"flatness\", \"flux\", \"jitter\", \"hnr\", \"frame_mod\"]\n", + "\n", + "\n", + "def extract(X, denoise=0.0):\n", + " \"\"\"클립 배치 -> 특징 행렬 (클립 x 34) DataFrame.\"\"\"\n", + " P = spectrogram(X, denoise)\n", + " C = mfcc(P)\n", + " tot = band(P, 300, 3400) + 1e-12\n", + " flat = np.exp(np.log(P[:, (FREQS >= 300) & (FREQS < 3400)] + 1e-10).mean(1)) / (P[:, (FREQS >= 300) & (FREQS < 3400)].mean(1) + 1e-10)\n", + " logP = np.log(P + 1e-8)\n", + " jit, hnr = jitter_hnr(X)\n", + " F = np.c_[C.mean(2), C.std(2), np.abs(np.diff(C, axis=2)).mean((1, 2)), band(P, 2400, 3400) / tot, band(P, 300, 500) / tot,\n", + " flat.mean(1), np.abs(np.diff(logP, axis=2)).mean((1, 2)), jit, hnr, frame_mod(X)]\n", + " return pd.DataFrame(F, columns=FEAT_NAMES)\n", + "\n", + "\n", + "def enroll(seed=99):\n", + " \"\"\"지휘관·통신소 화자 6명의 등록 견본(깨끗한 진짜 클립 5개씩의 MFCC 평균).\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " tpl = {}\n", + " for spk in SPEAKERS:\n", + " Xe = np.stack([radio_channel(make_clip(\"bonafide\", spk, rng), rng, snr_db=25) for _ in range(5)])\n", + " tpl[spk] = extract(Xe)[[f\"mfcc{i}_m\" for i in range(1, 13)]].mean().values\n", + " return tpl\n", + "\n", + "\n", + "def speaker_sim(F, claimed, tpl):\n", + " \"\"\"주장 화자 견본과 MFCC 평균 벡터의 코사인 유사도(화자 검증 점수).\"\"\"\n", + " v = F[[f\"mfcc{i}_m\" for i in range(1, 13)]].values\n", + " t = np.stack([tpl[c] for c in claimed])\n", + " return (v * t).sum(1) / (np.linalg.norm(v, axis=1) * np.linalg.norm(t, axis=1) + 1e-12)\n", + "\n", + "def channel_quality(X):\n", + " \"\"\"채널 품질 지표: 추정 SNR(유효 프레임 에너지 상하위 비, dB), 단일톤 재밍 지수(주파수별 시간 평균/표준편차의 최대값), 끊김 비율(무음 10ms 비율).\"\"\"\n", + " P = spectrogram(X)\n", + " e = P.sum(1)\n", + " e = np.where(e > 1e-9, e, np.nan)\n", + " snr_est = 10 * np.log10(np.nanpercentile(e, 90, axis=1) / (np.nanpercentile(e, 10, axis=1) + 1e-12))\n", + " Pb = P[:, (FREQS >= 300) & (FREQS < 3400)]\n", + " tone = (Pb.mean(2) / (Pb.std(2) + 1e-12)).max(1) # 시간에 따라 일정한 주파수 성분(재밍 톤)일수록 큼\n", + " fr = np.abs(np.atleast_2d(X)).reshape(len(X), -1, 80).max(-1)\n", + " drop = (fr < 1e-6).mean(1)\n", + " return pd.DataFrame({\"snr_est\": snr_est, \"tone_idx\": tone, \"drop_ratio\": drop})\n", + "\n", + "CONFIG = {\"spectral_subtraction\": 0.0, \"spoof_threshold\": 0.3, \"high_threshold\": 0.8, # 미션 9 재보정값\n", + " \"poor\": {\"snr_est\": 3.3, \"tone_idx\": 2.5, \"drop_ratio\": 0.05}}\n", + "CALLED = set()\n", + "\n", + "\n", + "def traced(fn):\n", + " \"\"\"함수 커버리지 측정용: 호출된 파이프라인 함수 이름을 기록.\"\"\"\n", + " def w(*a, **k):\n", + " CALLED.add(fn.__name__); return fn(*a, **k)\n", + " w.__name__ = fn.__name__\n", + " return w\n", + "\n", + "\n", + "@traced\n", + "def validate(meta, x):\n", + " errs = []\n", + " if not str(meta.get(\"clip\", \"\")).startswith(\"C\"):\n", + " errs.append(\"clip_id 형식\")\n", + " if meta.get(\"claimed\") not in SPEAKERS:\n", + " errs.append(\"주장 화자 미등록\")\n", + " if len(x) != N or not np.isfinite(x).all():\n", + " errs.append(\"신호 길이·값 오류\")\n", + " elif np.abs(x).max() < 1e-6:\n", + " errs.append(\"무신호\")\n", + " return errs\n", + "\n", + "\n", + "@traced\n", + "def features(X, claimed):\n", + " F = extract(X, CONFIG[\"spectral_subtraction\"]); F[\"spk_sim\"] = speaker_sim(F, claimed, TPL)\n", + " return pd.concat([F, channel_quality(X)], axis=1)\n", + "\n", + "\n", + "@traced\n", + "def grade(p, q):\n", + " poor = (q[\"snr_est\"] < CONFIG[\"poor\"][\"snr_est\"]) | (q[\"tone_idx\"] > CONFIG[\"poor\"][\"tone_idx\"]) | (q[\"drop_ratio\"] > CONFIG[\"poor\"][\"drop_ratio\"])\n", + " g = np.where(p >= CONFIG[\"high_threshold\"], \"High\", np.where(p >= CONFIG[\"spoof_threshold\"], \"Med\", \"Low\"))\n", + " return np.where((g == \"High\") & poor.values & (p < 0.95), \"Med\", g), poor.values\n", + "\n", + "\n", + "class Queue:\n", + " def __init__(self):\n", + " self.items, self.log = [], []\n", + "\n", + " @traced\n", + " def submit(self, rec):\n", + " prev = self.log[-1][\"hash\"] if self.log else \"GENESIS\"\n", + " body = {\"event\": \"SUBMIT\", \"id\": rec[\"clip\"], \"grade\": rec[\"grade\"], \"score\": round(rec[\"score\"], 3), \"urgent\": rec[\"urgent\"]}\n", + " self.log.append({**body, \"prev\": prev, \"hash\": hashlib.sha256((prev + json.dumps(body, sort_keys=True)).encode()).hexdigest()})\n", + " self.items.append({**rec, \"status\": \"PENDING\"}) # 자동 전파·차단 없음, 통신보안관 검증 대기\n", + "\n", + " @traced\n", + " def verify(self):\n", + " prev = \"GENESIS\"\n", + " for r in self.log:\n", + " body = {k: v for k, v in r.items() if k not in (\"prev\", \"hash\")}\n", + " if r[\"prev\"] != prev or r[\"hash\"] != hashlib.sha256((prev + json.dumps(body, sort_keys=True)).encode()).hexdigest():\n", + " return False\n", + " prev = r[\"hash\"]\n", + " return True\n", + "\n", + "\n", + "@traced\n", + "def train(seed=1200):\n", + " conds = [dict(snr=(10, 25)), dict(snr=(3, 8)), dict(snr=(8, 15), impulse=0.002), dict(snr=(8, 15), dropout=0.4), dict(snr=(8, 15), jam_tone=0.5)]\n", + " parts = [make_dataset(140, seed=seed + i, p=[0.5, 0.15, 0.15, 0.2], **c) for i, c in enumerate(conds)]\n", + " X = np.vstack([p[0] for p in parts]); M = pd.concat([p[1] for p in parts], ignore_index=True)\n", + " return make_pipeline(StandardScaler(), MLPClassifier((64, 32), alpha=1e-3, max_iter=800, random_state=0)).fit(features(X, M[\"claimed\"]), M[\"spoof\"])\n", + "\n", + "\n", + "@traced\n", + "def main(X, M, model):\n", + " queue, rejected, out = Queue(), {}, []\n", + " ok = []\n", + " for i in range(len(X)):\n", + " e = validate(M.iloc[i].to_dict(), X[i])\n", + " if e:\n", + " rejected[M[\"clip\"][i]] = e\n", + " else:\n", + " ok.append(i)\n", + " Xo, Mo = X[ok], M.iloc[ok].reset_index(drop=True)\n", + " t0 = time.perf_counter()\n", + " F = features(Xo, Mo[\"claimed\"])\n", + " p = model.predict_proba(F)[:, 1]\n", + " g, poor = grade(p, F)\n", + " ms = (time.perf_counter() - t0) * 1000 / len(Xo)\n", + " for k in range(len(Mo)):\n", + " rec = {\"clip\": Mo[\"clip\"][k], \"score\": float(p[k]), \"grade\": str(g[k]), \"urgent\": bool(Mo[\"urgent\"][k]), \"poor\": bool(poor[k])}\n", + " out.append(rec)\n", + " if rec[\"grade\"] != \"Low\" or rec[\"urgent\"]: # 위조 의심 + 긴급 명령은 전량 사람 검증\n", + " queue.submit(rec)\n", + " return pd.DataFrame(out), Mo, rejected, queue, ms\n", + "\n", + "\n", + "T0 = time.perf_counter()\n", + "TPL = enroll()\n", + "model = train()\n", + "# [교육용가상자료] 야간 운용 시간대 수신 기록 300건(혼합 채널) + 손상 기록 3건\n", + "parts = [make_dataset(60, seed=1300 + i, p=[0.5, 0.15, 0.15, 0.2], **c) for i, c in\n", + " enumerate([dict(snr=(10, 25)), dict(snr=(3, 8)), dict(snr=(8, 15), impulse=0.002), dict(snr=(8, 15), dropout=0.4), dict(snr=(8, 15), jam_tone=0.5)])]\n", + "X = np.vstack([p[0] for p in parts]); M = pd.concat([p[1] for p in parts], ignore_index=True)\n", + "M[\"urgent\"] = np.random.default_rng(1400).random(len(M)) < 0.15\n", + "X[5] = np.nan; X[77] = 0.0; M.loc[150, \"claimed\"] = \"SPK_Z\"\n", + "R, Mo, rejected, queue, ms = main(X, M, model)\n", + "y = Mo[\"spoof\"].values; alarm = R[\"grade\"].values != \"Low\"\n", + "recall, far, acc = alarm[y].mean(), alarm[~y].mean(), (alarm == y).mean()\n", + "elapsed = time.perf_counter() - T0\n", + "\n", + "chk = {\n", + " \"손상 기록 3건 차단\": len(rejected) == 3,\n", + " \"처리 기록 297건\": len(R) == 297,\n", + " \"위조 재현율 90% 이상\": recall >= 0.9,\n", + " \"오경보율 10% 이하\": far <= 0.10,\n", + " \"자동 전파·차단 0건\": all(i[\"status\"] == \"PENDING\" for i in queue.items),\n", + " \"긴급 명령 전량 검증 대기\": all(c in {i[\"clip\"] for i in queue.items} for c in Mo[\"clip\"][Mo[\"urgent\"]]),\n", + " \"감사 로그 무결성\": queue.verify() and len(queue.log) == len(queue.items),\n", + " \"클립당 200ms 이내\": ms < 200,\n", + " \"등급 규칙 일관\": all((r.grade == \"Low\") == (r.score < CONFIG[\"spoof_threshold\"]) for r in R.itertuples()),\n", + " \"점수 범위 0~1·NaN 없음\": R[\"score\"].between(0, 1).all(),\n", + "}\n", + "passed = sum(chk.values())\n", + "for k, v in chk.items():\n", + " print(f\"[{'PASS' if v else 'FAIL'}] {k}\")\n", + "funcs = [\"validate\", \"features\", \"grade\", \"submit\", \"verify\", \"train\", \"main\"]\n", + "cov = len(CALLED & set(funcs)) / len(funcs)\n", + "print(f\"자체 점검 {passed}/{len(chk)} 통과, E2E 실행 성공률 {'100%' if passed == len(chk) else f'{passed / len(chk):.0%}'}, 함수 커버리지 {cov:.0%} (목표 85% 이상: {'달성' if cov >= 0.85 else '미달'})\")\n", + "print(\"차단된 기록:\", rejected)\n", + "\n", + "n_hi, n_med = (R[\"grade\"] == \"High\").sum(), (R[\"grade\"] == \"Med\").sum()\n", + "print(f\"\"\"\n", + "[C2 음성 보안 브리핑 요약 - 교육용가상자료]\n", + "1. 처리: 수신 {len(M)}건 중 {len(R)}건 판정, 손상·미등록 {len(rejected)}건 차단, 전체 {elapsed:.1f}s, 클립당 {ms:.2f} ms\n", + "2. 성능: 위조 재현율 {recall:.1%}, 오경보율 {far:.1%}, 정확도 {acc:.1%} (평시·저SNR·충격·끊김·재밍 혼합 채널)\n", + "3. 검증 대기: {len(queue.items)}건 (High {n_hi} / Med {n_med} / 긴급 명령 {int(Mo['urgent'].sum())}), 자동 전파·차단 0건\n", + "4. 운용: 긴급 명령은 등급과 무관하게 통신보안관이 역호출 인증·청음 후 전파하며, 최종 명령 책임은 지휘계통에 있다.\n", + "5. 한계·확장: 합성 신호 기준 결과로 실제 무전기·음성에는 재학습·재검증이 필요하다. 인접 체계 연동은 판정 결과(JSON)만 전달하는 API로 확장한다.\"\"\")\n", + "\n", + "plt.figure(figsize=(6, 3.5))\n", + "plt.hist([R[\"score\"][~y], R[\"score\"][y]], bins=20, label=[\"bonafide\", \"spoof\"], color=[\"tab:green\", \"tab:red\"])\n", + "plt.axvline(CONFIG[\"spoof_threshold\"], c=\"k\", ls=\"--\"); plt.yscale(\"log\"); plt.xlabel(\"spoof score\"); plt.legend(); plt.title(\"Integrated pipeline output (synthetic)\")\n", + "plt.tight_layout(); plt.show()\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 10 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + "[PASS] 손상 기록 3건 차단\n", + "[PASS] 처리 기록 297건\n", + "[PASS] 위조 재현율 90% 이상\n", + "[PASS] 오경보율 10% 이하\n", + "[PASS] 자동 전파·차단 0건\n", + "[PASS] 긴급 명령 전량 검증 대기\n", + "[PASS] 감사 로그 무결성\n", + "[PASS] 클립당 200ms 이내\n", + "[PASS] 등급 규칙 일관\n", + "[PASS] 점수 범위 0~1·NaN 없음\n", + "자체 점검 10/10 통과, E2E 실행 성공률 100%, 함수 커버리지 100% (목표 85% 이상: 달성)\n", + "차단된 기록: {'C13000005': ['신호 길이·값 오류'], 'C13010017': ['무신호'], 'C13020030': ['주장 화자 미등록']}\n", + "\n", + "[C2 음성 보안 브리핑 요약 - 교육용가상자료]\n", + "1. 처리: 수신 300건 중 297건 판정, 손상·미등록 3건 차단, 전체 8.5s, 클립당 1.66 ms\n", + "2. 성능: 위조 재현율 96.7%, 오경보율 4.9%, 정확도 96.0% (평시·저SNR·충격·끊김·재밍 혼합 채널)\n", + "3. 검증 대기: 174건 (High 143 / Med 12 / 긴급 명령 43), 자동 전파·차단 0건\n", + "4. 운용: 긴급 명령은 등급과 무관하게 통신보안관이 역호출 인증·청음 후 전파하며, 최종 명령 책임은 지휘계통에 있다.\n", + "5. 한계·확장: 합성 신호 기준 결과로 실제 무전기·음성에는 재학습·재검증이 필요하다. 인접 체계 연동은 판정 결과(JSON)만 전달하는 API로 확장한다.\n", + "```\n", + "
\n" + ] + } + ], + "metadata": { + "colab": { + "provenance": [] + }, + "kernelspec": { + "display_name": "Python 3", + "name": "python3" + }, + "language_info": { + "name": "python" + } + }, + "nbformat": 4, + "nbformat_minor": 0 +} \ No newline at end of file diff --git a/uploads/C207/C207_workbook.ipynb b/uploads/C207/C207_workbook.ipynb new file mode 100644 index 0000000..eef1c7f --- /dev/null +++ b/uploads/C207/C207_workbook.ipynb @@ -0,0 +1,967 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# 📋 [C207] C2 정보 무결성·출처 확인·재전송 승인 SOP\n", + "### 실습 워크북 & SOP 가이드 (Google Colab / Jupyter)\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/C207/C207_workbook.ipynb)\n", + "\n", + "---\n", + "\n", + "## 📌 1. 프로젝트 개요 및 배경\n", + "\n", + "| 항목 | 내용 |\n", + "| :--- | :--- |\n", + "| **과정 분류** | 지휘통제(C2) (Level 3) |\n", + "| **학습 소요 시간** | 10개 미션 (5일 / 총 27.0시간) |\n", + "| **실습 유형** | 작전절차(SOP), 체크리스트, 모의훈련, AAR 사후검토 실습 |\n", + "\n", + "### 📖 프로젝트 목표\n", + "러시아-우크라이나 전쟁과 미국-이란 간 긴장 국면에서는 지휘통제망에 대한 교란, 허위 전문과 위조 명령의 주입, 통신 단절 뒤 누락 메시지의 뒤섞임이 반복되어 '이 명령이 정말 정당한 발신자에게서 왔는가'와 '내용이 중간에 바뀌지 않았는가'를 가리는 일이 작전 연속성의 핵심 문제가 되었다. 한편 검증이 지나치게 느리면 정당한 명령이 늦어져 그 자체가 위험이 되므로 보안과 속도의 균형이 필요하다. 본 프로젝트에서는 교육용 가상 지휘통제망과 가상 메시지 카드를 사용해 메시지 위협 요소 분석, 출처·무결성·시각 검증 기준(6단계), 통신 단절 후 재전송 승인 기준, 비인가 명령 자동 보류와 보안 보고 절차, 상황 단계별 점검표, 단말 표시와 결재 양식, 교란·위조 스트레스 훈련, AAR, 검증 지연 보정을 차례로 수행한다. 최종 목표는 C2 정보 무결성·출처 확인·재전송 승인 표준운용절차(SOP)를 문서로 완성하는 것이다.\n", + "\n", + "> **⚠️ [교육용 가상 자료 원칙]**\n", + "> - 본 워크북의 모든 자료는 안전 규정에 따라 **교육용 가상 자료**로 구성되어 있습니다.\n", + "> - 실제 군사 기밀이나 부대 세부 인적·작전 정보는 포함되지 않습니다.\n", + "\n", + "---\n", + "\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 01. 전술 지휘통제(C2) 네트워크 메시지 전송 체계 및 변조/위조 위협 요소 분석\n", + "\n", + "### 📋 미션 배경 및 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "전술 지휘통제(C2) 네트워크의 메시지 송수신 프로토콜과 메시지 변조/위조 위협을 분석한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **가상 전술 지휘통제망의 메시지 송수신 흐름을 도식화하고 변조, 위조, 재생(옛 메시지 재투입), 지연, 발신자 사칭 등 위협 요소를 분석하며 SOP 문서 골격을 정의한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **실습자료의 가상 메시지 사고 사례 5건을 읽고 각 사례에서 위협이 어느 단계(작성·전송·중계·접수·실행)에서 생겼는지 표로 분류 수행하고 검증하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 5개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `docs/c207_doc_framework.md 및 분석 개요서` 형태의 결과물을 완성한다.\n", + "\n", + "### 📑 실습자료 및 작성 양식\n", + "전술 지휘통제(C2) 네트워크 메시지 전송 체계 및 변조/위조 위협 요소 분석**\n", + "\n", + "가. 제공 자료 - 가상 메시지 사고 사례 5건 [교육용가상자료]\n", + "\n", + "| **사례** | **상황** | **위협 단계(작성·전송·중계·접수·실행)** | **접수자가 볼 수 있었던 징후** | **결과** |\n", + "| --- | --- | --- | --- | --- |\n", + "| **I1** | 서명 표시 없는 '진지 변경' 명령이 단말 C에 도착 | | 서명 표시 '없음' | 확인 없이 일부 전파됨 |\n", + "| **I2** | 3시간 전 정상 명령이 다시 도착(재투입) | | 타임스탬프 '과거' | 낡은 명령 실행 시도 |\n", + "| **I3** | 중계 R2 통과 후 내용 일부가 바뀐 보고 | | 해시 '불일치' | 상황판 수치 오류 |\n", + "| **I4** | 발신 표기가 지휘통제실장인 전문, 말투가 평소와 다름 | | 서명 '유효'이나 순서번호 결번 | 내용 확인 지연 |\n", + "| **I5** | 단절 복구 뒤 같은 전문이 두 번 도착 | | 순서번호 '중복' | 이중 실행 위험 |\n", + "\n", + "나. 작성 서식 1 - 위협 유형·징후 분류표\n", + "\n", + "| **위협 유형** | **생기는 단계** | **접수자가 볼 수 있는 징후** | **막는 방법(절차 수준)** |\n", + "| --- | --- | --- | --- |\n", + "| **변조** | | | |\n", + "| **위조(사칭)** | | | |\n", + "| **재생(낡은 메시지 재투입)** | | | |\n", + "| **지연·누락** | | | |\n", + "| **중복** | | | |\n", + "\n", + "나. 작성 서식 2 - SOP 목차 골격(지휘관 결심 의제 순으로 배열)\n", + "\n", + "| **순서** | **장 제목** | **담을 핵심 내용** | **지휘관이 얻는 판단 정보** |\n", + "| --- | --- | --- | --- |\n", + "| **제1장** | | | |\n", + "| **제2장** | | | |\n", + "| **제3장** | | | |\n", + "| **제4장** | | | |\n", + "| **제5장** | | | |\n", + "| **제6장** | | | |\n", + "| **제7장** | | | |\n", + "| **제8장** | | | |\n", + "\n", + "다. 점검표\n", + "\n", + "| **점검 항목** | **확인** |\n", + "| --- | --- |\n", + "| 가상 사고 사례 5건을 모두 검토하고 위협 단계를 분류했다 | ( ) |\n", + "| 위협 유형 5가지 모두에 징후와 막는 방법을 적었다 | ( ) |\n", + "| 실제 암호 방식·키·망 구성을 쓰지 않고 표시 상태만 다뤘다 | ( ) |\n", + "| SOP 8개 장의 제목과 핵심 내용을 정의했다 | ( ) |\n", + "| 목차를 지휘관이 먼저 알아야 할 의제 순으로 배열하고 이유를 한 줄로 적었다 | ( ) |\n", + "\n", + "**\n", + "\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 02. C2 메시지 출처 검증, 해시 무결성 체크 및 타임스탬프 검증 기준 수립\n", + "\n", + "### 📋 미션 배경 및 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "C2 메시지의 디지털 서명, 해시 무결성 검증, 타임스탬프 유효성 확인 기준을 수립한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **메시지의 출처 확인, 해시 무결성 확인, 타임스탬프 유효성 확인 기준을 수립하고, 무결성 신뢰 상태를 6단계로 나누는 평가 기준과 편향 방지 장치를 정한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **출처(서명 표시), 내용 일치(해시 확인), 시각(타임스탬프), 순서(순서번호)를 각각 어떤 표시가 있으면 정상인지 관찰 가능한 문장으로 쓴다 과정을 진행하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 5개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `framework/c207_source_reliability_matrix.md` 형태의 결과물을 완성한다.\n", + "\n", + "### 📑 실습자료 및 작성 양식\n", + "C2 메시지 출처 검증, 해시 무결성 체크 및 타임스탬프 검증 기준 수립**\n", + "\n", + "가. 제공 자료 1 - 가상 메시지 카드 5건 [교육용가상자료]\n", + "\n", + "| **메시지** | **서명** | **해시** | **타임스탬프** | **순서번호** | **내용 요약** |\n", + "| --- | --- | --- | --- | --- | --- |\n", + "| **MSG-101** | 유효 | 일치 | 정상 | 연속 | 경계 강화 지시(정상 경로) |\n", + "| **MSG-102** | 유효 | 일치 | 정상 | 결번(2건) | 집결지 변경 지시 |\n", + "| **MSG-103** | 없음 | 미확인 | 정상 | 연속 | 진지 변경 명령, 어조 급함 |\n", + "| **MSG-104** | 유효 | 불일치 | 정상 | 연속 | 병력 현황 보고(수치 이상) |\n", + "| **MSG-105** | 유효 | 일치 | 과거(3시간 전) | 중복 | 철수 지시 |\n", + "\n", + "가. 제공 자료 2 - 무결성 상태 6단계 틀(단계 이름은 고정, 판정 기준은 조별 작성)\n", + "\n", + "| **단계** | **이름** | **판정 기준(조별 작성)** |\n", + "| --- | --- | --- |\n", + "| **1** | 검증 완료 | |\n", + "| **2** | 검증 대체로 완료 | |\n", + "| **3** | 일부 확인 불가 | |\n", + "| **4** | 이상 징후 1건 | |\n", + "| **5** | 이상 징후 2건 이상 | |\n", + "| **6** | 판단 불가·위조 의심 | |\n", + "\n", + "나. 작성 서식 1 - 평가 지표(10개 이상, 예시 2개 포함)\n", + "\n", + "| **번호** | **평가 지표** | **확인 방법(관찰 가능한 기준)** |\n", + "| --- | --- | --- |\n", + "| **1** | 서명 표시 상태 | 표시가 '유효'이면 충족, '없음·불일치'이면 미충족 |\n", + "| **2** | 타임스탬프 유효 시한 이내인가 | 유효 시한( )분 이내면 충족 |\n", + "| **3** | | |\n", + "| **4** | | |\n", + "| **5** | | |\n", + "| **6** | | |\n", + "| **7** | | |\n", + "| **8** | | |\n", + "| **9** | | |\n", + "| **10** | | |\n", + "| **11** | | |\n", + "| **12** | | |\n", + "\n", + "나. 작성 서식 2 - 단계 부여 일관성 시험(조원이 각자 단계를 적는다)\n", + "\n", + "| **메시지** | **조원 1** | **조원 2** | **조원 3** | **조원 4** | **일치 여부** | **불일치 원인** |\n", + "| --- | --- | --- | --- | --- | --- | --- |\n", + "| **MSG-101** | | | | | | |\n", + "| **MSG-102** | | | | | | |\n", + "| **MSG-103** | | | | | | |\n", + "| **MSG-104** | | | | | | |\n", + "| **MSG-105** | | | | | | |\n", + "\n", + "나. 작성 서식 3 - 편향 방지 규칙: 서명·해시·시각·순서 중 ( )개가 모두 정상이어야 1단계로 평가한다. 발신 표기가 익숙하거나 어조가 급해도 확인 항목은 ( ). 평가자와 ( )가 서로 다른 사람이 교차 확인한다. 같은 발신 표기에서 온 메시지라도 서명·해시는 매번 따로 확인한다.\n", + "\n", + "다. 점검표\n", + "\n", + "| **점검 항목** | **확인** |\n", + "| --- | --- |\n", + "| 출처·내용 일치·시각·순서 4요소의 정상 표시 기준을 정했다 | ( ) |\n", + "| 무결성 상태 6단계 판정 기준을 관찰 가능한 문장으로 작성했다 | ( ) |\n", + "| 평가 지표를 10개 이상 정의했다 | ( ) |\n", + "| 메시지 5건에 대한 조원 단계가 4건 이상 일치했다 | ( ) |\n", + "| 익숙한 발신자·급한 어조에 끌리지 않게 하는 규칙을 명시했다 | ( ) |\n", + "\n", + "**\n", + "\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 03. 통신 단절 후 재접속 시 메시지 재전송(Re-transmission) 승인 기준 및 권한 정의\n", + "\n", + "### 📋 미션 배경 및 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "통신 단절 후 재접속 시 누락 메시지 재전송(Re-transmission) 승인 권한과 절차를 정의한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **통신 단절 후 재접속 시 누락 메시지를 재전송할 때 승인 기준과 권한을 정의하고, 재전송에서 생기는 불확실성(중복, 순서 뒤바뀜, 낡은 명령)의 영향도를 평가한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **가상 단절·복구 사례 4건에서 누락 구간을 찾고 재전송 대상 메시지를 '그대로 재전송·확인 후 재전송·폐기 후 새로 작성' 중 하나로 분류 수행하고 검증하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 5개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `analysis/c207_uncertainty_assessment.md` 형태의 결과물을 완성한다.\n", + "\n", + "### 📑 실습자료 및 작성 양식\n", + "통신 단절 후 재접속 시 메시지 재전송(Re-transmission) 승인 기준 및 권한 정의**\n", + "\n", + "가. 제공 자료 1 - 가상 단절·복구 사례 4건 [교육용가상자료]\n", + "\n", + "| **사례** | **상황** | **누락 구간** | **복구 후 상태** | **재전송 분류(조별 작성)** |\n", + "| --- | --- | --- | --- | --- |\n", + "| **X1** | 04:10~04:25 단절, 상황 보고 3건 누락 | 순서번호 41~43 | 발신 단말에 원본 보관 | |\n", + "| **X2** | 04:20~04:35 단절, 이동 지시 1건 누락 | 순서번호 52 | 지시 후 상황이 바뀜(진지 이미 이동) | |\n", + "| **X3** | 04:30 단절 중 같은 명령이 2회 전송 | 순서번호 60 중복 | 수신 단말에 1건만 접수 | |\n", + "| **X4** | 04:10~04:40 단절, 보고 5건 누락 | 순서번호 70~74 | 발신 단말 로그 일부 손상 | |\n", + "\n", + "가. 제공 자료 2 - 불확실성 요인 예시 3개(나머지 5개 이상은 조별로 찾는다)\n", + "\n", + "| **불확실성 요인** | **발생 상황** | **확인 방법(예)** |\n", + "| --- | --- | --- |\n", + "| **원본 보관 여부 불명** | 발신 단말 로그 손상 | 중계 노드 로그와 대조 |\n", + "| **명령 유효 시한 경과** | 재전송 시점에 이미 상황 변화 | 발신자에게 유효성 재확인 |\n", + "| **이중 실행 위험** | 재전송본과 원본이 둘 다 도착 | 순서번호·메시지 ID 대조 |\n", + "\n", + "나. 작성 서식 1 - 재전송 분류표(그대로 재전송 / 확인 후 재전송 / 폐기 후 새로 작성)\n", + "\n", + "| **사례** | **분류** | **분류 이유** | **확인할 항목** | **승인 직책** |\n", + "| --- | --- | --- | --- | --- |\n", + "| **X1** | | | | |\n", + "| **X2** | | | | |\n", + "| **X3** | | | | |\n", + "| **X4** | | | | |\n", + "\n", + "나. 작성 서식 2 - 재전송 불확실성 영향도 매트릭스(8개 이상)\n", + "\n", + "| **번호** | **불확실성 요인** | **영향도(높음·중간·낮음)** | **영향을 받는 판단** | **확인 방법** |\n", + "| --- | --- | --- | --- | --- |\n", + "| **1** | | | | |\n", + "| **2** | | | | |\n", + "| **3** | | | | |\n", + "| **4** | | | | |\n", + "| **5** | | | | |\n", + "| **6** | | | | |\n", + "| **7** | | | | |\n", + "| **8** | | | | |\n", + "| **9** | | | | |\n", + "| **10** | | | | |\n", + "\n", + "나. 작성 서식 3 - 메시지 종류별 재전송 승인 권한(가상 직책)\n", + "\n", + "| **메시지 종류** | **재전송 승인 직책** | **유효 시한(분)** | **승인 전 확인 사항** | **기록 위치** |\n", + "| --- | --- | --- | --- | --- |\n", + "| **상황 보고** | | | | |\n", + "| **지시** | | | | |\n", + "| **명령** | | | | |\n", + "\n", + "나. 작성 서식 4 - 판단 한계: 이 기준으로 판단할 수 없는 경우 3가지: 1) ( ) 2) ( ) 3) ( )\n", + "\n", + "다. 점검표\n", + "\n", + "| **점검 항목** | **확인** |\n", + "| --- | --- |\n", + "| 단절·복구 사례 4건 모두 분류하고 이유를 적었다 | ( ) |\n", + "| 재전송 불확실성 요인을 8개 이상 찾고 영향도를 매겼다 | ( ) |\n", + "| 메시지 종류 3가지에 승인 직책과 유효 시한을 정했다 | ( ) |\n", + "| 낡은 명령이 실행되지 않게 하는 유효 시한 규칙이 있다 | ( ) |\n", + "| 판단의 한계 3가지를 적었다 | ( ) |\n", + "\n", + "**\n", + "\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 04. 비인가 지휘명령 수신 시 자동 보류 및 통신보안관 긴급 보고 절차 수립\n", + "\n", + "### 📋 미션 배경 및 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "비인가 지휘명령 수신 시 자동 차단 및 지휘소 긴급 보고 절차를 수립한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **비인가 지휘명령이 의심될 때 자동으로 보류(격리)하고 통신보안관에게 긴급 보고하는 절차를 수립하며, 비인가 판정 기준 12개 이상과 가중치 산식을 정한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **비인가 의심 징후(서명 없음, 해시 불일치, 시각 이상, 순서번호 이상, 평소 경로와 다른 중계, 어조·형식 이상)를 12개 이상 기준 항목으로 정하고 항목별 가중치를 매긴다 과정을 진행하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 5개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `matrix/c207_attribution_matrix.xlsx(md)` 형태의 결과물을 완성한다.\n", + "\n", + "### 📑 실습자료 및 작성 양식\n", + "비인가 지휘명령 수신 시 자동 보류 및 통신보안관 긴급 보고 절차 수립**\n", + "\n", + "가. 제공 자료 1 - 비인가 의심 기준 예시 4개(나머지 8개 이상은 조별로 추가)\n", + "\n", + "| **기준** | **방향** | **예시 가중치** |\n", + "| --- | --- | --- |\n", + "| **서명 표시가 '없음' 또는 '불일치'** | 의심 가산 | +5 |\n", + "| **해시 확인 '불일치'** | 의심 가산 | +5 |\n", + "| **평소와 다른 중계 경로** | 의심 가산 | +2 |\n", + "| **사전 승인된 경로·서명·시각 모두 정상** | 의심 감산 | -3 |\n", + "\n", + "가. 제공 자료 2 - 가상 메시지 3건 [교육용가상자료]\n", + "\n", + "| **사례** | **상황** |\n", + "| --- | --- |\n", + "| **Q1** | MSG-103: 서명 없음, 어조 급함, 진지 변경 명령, 평소 경로 |\n", + "| **Q2** | MSG-102: 서명 유효, 순서번호 결번 2건, 집결지 변경, 평소 경로 |\n", + "| **Q3** | MSG-106: 서명 유효, 해시 일치, 시각 정상이나 처음 보는 중계 노드를 경유 |\n", + "\n", + "나. 작성 서식 1 - 비인가 판정 기준 12개 이상과 사례 적용(해당하면 가중치, 아니면 0)\n", + "\n", + "| **번호** | **비인가 판정 기준** | **방향** | **가중치** | **Q1** | **Q2** | **Q3** |\n", + "| --- | --- | --- | --- | --- | --- | --- |\n", + "| **1** | | | | | | |\n", + "| **2** | | | | | | |\n", + "| **3** | | | | | | |\n", + "| **4** | | | | | | |\n", + "| **5** | | | | | | |\n", + "| **6** | | | | | | |\n", + "| **7** | | | | | | |\n", + "| **8** | | | | | | |\n", + "| **9** | | | | | | |\n", + "| **10** | | | | | | |\n", + "| **11** | | | | | | |\n", + "| **12** | | | | | | |\n", + "| **13** | | | | | | |\n", + "| **14** | | | | | | |\n", + "\n", + "나. 작성 서식 2 - 의심 점수 산식과 자동 보류 규칙\n", + "\n", + "| 의심 점수 = (의심 가산 가중치 합) - (의심 감산 가중치 합)\n", + "규칙 1. 서명 '없음·불일치' 또는 해시 '불일치'가 하나라도 있으면 점수와 관계없이 '보류(격리)'\n", + "규칙 2. 의심 점수가 ( ) 이상이면 단말이 '실행 대기 보류' 상태로 두고 ( )분 안에 통신보안관에게 긴급 보고\n", + "규칙 3. 자동 보류는 실행만 멈춘다. 메시지를 삭제하거나 발신 측에 반격하지 않으며 보류 해제는 사람( )이 한다\n", + "규칙 4. 의심 점수가 낮아도 처리 승인은 사람(책임 직책)이 하며 점수가 결정을 대신하지 않는다\n", + "사례 결과: Q1 점수 ( ) 판단 ( ) / Q2 점수 ( ) 판단 ( ) / Q3 점수 ( ) 판단 ( ) |\n", + "| --- |\n", + "\n", + "나. 작성 서식 3 - 긴급 보고 절차\n", + "\n", + "| **단계** | **행동** | **담당(가상 직책)** | **시간 목표** | **기록할 항목** |\n", + "| --- | --- | --- | --- | --- |\n", + "| **1 자동 보류** | | | | |\n", + "| **2 통신보안관 보고** | | | | |\n", + "| **3 대체 경로 재확인** | | | | |\n", + "| **4 보류 해제 또는 폐기** | | | | |\n", + "\n", + "나. 작성 서식 4 - 레드팀 반박 기록(보안·법적 쟁점은 '해당 부서 검토 필요'로만 표시)\n", + "\n", + "| **사례** | **레드팀 반박('정당한 명령이 오탐으로 막히지 않는가')** | **작성 팀 대응** | **보완한 규칙** |\n", + "| --- | --- | --- | --- |\n", + "| **Q1** | | | |\n", + "| **Q2** | | | |\n", + "| **Q3** | | | |\n", + "\n", + "다. 점검표\n", + "\n", + "| **점검 항목** | **확인** |\n", + "| --- | --- |\n", + "| 비인가 판정 기준을 12개 이상 정의하고 가중치를 붙였다 | ( ) |\n", + "| 산식과 규칙 1~4를 빈칸 없이 정했다 | ( ) |\n", + "| 자동 보류가 실행 중지에 한정되고 해제는 사람이 하도록 했다 | ( ) |\n", + "| 사례 3건에 적용한 점수와 판단을 기록했다 | ( ) |\n", + "| 레드팀 반박 3건에 대응하고 규칙을 보완했다 | ( ) |\n", + "\n", + "**\n", + "\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 05. 상황 단계별(평시/경계/전투) C2 정보 무결성 검증 표준 체크리스트 작성\n", + "\n", + "### 📋 미션 배경 및 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "상황 단계별(평시/경계/전투) C2 정보 무결성 검증 표준 체크리스트를 작성한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **평시·경계·전투 상황 단계별 C2 정보 무결성 검증 표준 체크리스트를 작성하고, 처리 승인·보류 분기점과 책임 직책을 확정한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **상황 단계마다 검증 항목을 어떻게 달리 적용할지 표로 정한다(평시는 전 항목, 경계는 핵심 항목과 사후 보완, 전투는 최소 필수 항목과 대체 확인 경로) 과정을 진행하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 5개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `sop/c207_approval_thresholds_sop.md` 형태의 결과물을 완성한다.\n", + "\n", + "### 📑 실습자료 및 작성 양식\n", + "상황 단계별(평시/경계/전투) C2 정보 무결성 검증 표준 체크리스트 작성**\n", + "\n", + "가. 제공 자료 1 - 상황 단계 정의(가상)\n", + "\n", + "| **단계** | **상황** | **시간 압박** |\n", + "| --- | --- | --- |\n", + "| **평시** | 훈련·일상 업무, 통신 정상 | 낮음 |\n", + "| **경계** | 위협 징후, 통신 일부 불안정 | 중간 |\n", + "| **전투** | 교전 중, 통신 교란 심함 | 높음 |\n", + "\n", + "가. 제공 자료 2 - 경계선 메시지 5종 [교육용가상자료]\n", + "\n", + "| **묶음** | **상황** | **애매한 이유** |\n", + "| --- | --- | --- |\n", + "| **B1** | 전투 단계, 서명 유효·해시 확인 불가(단말 지연), 발사 준비 지시 | 해시 미확인 상태 |\n", + "| **B2** | 경계 단계, 서명 유효·순서번호 결번, 상황 보고 | 누락 구간 불명 |\n", + "| **B3** | 전투 단계, 서명 없음, 평소 지휘관 표기, '즉시 이동' 명령 | 급한 어조와 익숙한 표기 |\n", + "| **B4** | 평시, 모든 표시 정상이나 처음 쓰는 단말 | 새 단말 |\n", + "| **B5** | 통신보안관 부재 중, 대리 지정 없음 | 권한자 부재 |\n", + "\n", + "나. 작성 서식 1 - 상황 단계별 검증 체크리스트(항목마다 필수 ○, 사후 보완 △, 해당 없음 -)\n", + "\n", + "| **검증 항목** | **평시** | **경계** | **전투** | **전투 시 대체 확인 방법** |\n", + "| --- | --- | --- | --- | --- |\n", + "| **서명 표시** | | | | |\n", + "| **해시 일치** | | | | |\n", + "| **타임스탬프** | | | | |\n", + "| **순서번호** | | | | |\n", + "| **경로 확인** | | | | |\n", + "| **발신자 재확인(대체 경로)** | | | | |\n", + "| **통신보안관 보고** | | | | |\n", + "\n", + "나. 작성 서식 2 - 의사결정 트리(빈칸을 채운다)\n", + "\n", + "| [분기 1] 서명 표시가 '유효'인가?\n", + " 아니오 → 보류(격리) + 통신보안관 보고 (책임: ( ))\n", + " 예 ↓\n", + "[분기 2] 해시 '일치'이고 타임스탬프가 유효 시한 이내인가?\n", + " 아니오 → 보류 + 재확인 요청 (책임: ( ))\n", + " 예 ↓\n", + "[분기 3] 순서번호가 ( ) 이고 의심 점수가 ( ) 미만인가?\n", + " 아니오 → 보류 + 대체 경로 재확인 (책임: ( ))\n", + " 예 ↓\n", + "[분기 4] 승인 권한 직책과 연락이 되는가?\n", + " 아니오 → 보류 + 대리 승인자 호출 (책임: ( ))\n", + " 예 → 사람의 처리 승인 (책임: ( )) |\n", + "| --- |\n", + "\n", + "나. 작성 서식 3 - 분기점·책임 직책 매핑표\n", + "\n", + "| **분기점** | **판정 조건(수치·관찰 기준)** | **'예'일 때** | **'아니오'일 때** | **책임 직책(가상)** |\n", + "| --- | --- | --- | --- | --- |\n", + "| **분기 1** | | | | |\n", + "| **분기 2** | | | | |\n", + "| **분기 3** | | | | |\n", + "| **분기 4** | | | | |\n", + "\n", + "나. 작성 서식 4 - 모의 결심 기록지(판단은 '처리 승인·보류(격리)·재확인 요청' 중 하나)\n", + "\n", + "| **묶음** | **멈춘 분기점** | **판단** | **근거(자료 인용)** | **책임 직책** | **조원 합의** |\n", + "| --- | --- | --- | --- | --- | --- |\n", + "| **B1** | | | | | |\n", + "| **B2** | | | | | |\n", + "| **B3** | | | | | |\n", + "| **B4** | | | | | |\n", + "| **B5** | | | | | |\n", + "\n", + "| 전투 단계에서도 서명 표시와 사람의 승인은 생략하지 않는다. 줄일 수 있는 것은 사후에 보완하는 기록과 보고 형식이다. 실제 명령 수행 여부는 교전수칙과 지휘계통에 따른다. |\n", + "| --- |\n", + "\n", + "다. 점검표\n", + "\n", + "| **점검 항목** | **확인** |\n", + "| --- | --- |\n", + "| 체크리스트에서 단계별 필수·사후 보완 항목을 구분했다 | ( ) |\n", + "| 의사결정 트리 4개 분기점의 조건을 수치·관찰 기준으로 명시했다 | ( ) |\n", + "| 모든 분기점에 책임 직책을 매핑했다 | ( ) |\n", + "| 경계선 5종 모두에 판단과 근거를 기록했다 | ( ) |\n", + "| 전투 단계에서도 사람의 승인 없이 처리 승인되는 경로가 없다 | ( ) |\n", + "\n", + "**\n", + "\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 06. 지휘통제 단말용 정보 무결성 상태 표시 및 재전송 승인 결재 양식 설계\n", + "\n", + "### 📋 미션 배경 및 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "지휘통제 단말용 메시지 무결성 상태 표시 및 재전송 승인 결재 양식을 개발한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **지휘통제 단말에 표시할 메시지 무결성 상태 화면과 재전송 승인 결재 양식을 설계하고, 사실·판단·조치·미확인을 분리하는 기록 규칙을 표준화한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **단말 화면에 메시지마다 보일 상태 표시(검증 완료, 확인 중, 보류, 위조 의심)와 색, 근거 항목(서명, 해시, 시각, 순서)을 정하고 화면 배치를 그린다 과정을 진행하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 5개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `templates/c207_aar_fact_action_template.md` 형태의 결과물을 완성한다.\n", + "\n", + "### 📑 실습자료 및 작성 양식\n", + "지휘통제 단말용 정보 무결성 상태 표시 및 재전송 승인 결재 양식 설계**\n", + "\n", + "가. 제공 자료 1 - 상태 표시 후보와 원문 기록 3종 [교육용가상자료]\n", + "\n", + "| **상태 표시 후보** | **의미** | **색 후보** | **함께 표시할 근거** |\n", + "| --- | --- | --- | --- |\n", + "| **검증 완료** | 서명·해시·시각·순서 모두 정상 | 초록 | 4요소 상태 아이콘 |\n", + "| **확인 중** | 일부 항목 확인 대기 | 노랑 | |\n", + "| **보류** | 이상 1건 이상, 실행 대기 | 주황 | |\n", + "| **위조 의심** | 서명 없음·해시 불일치 | 빨강 | |\n", + "\n", + "| **기록** | **원문** |\n", + "| --- | --- |\n", + "| **기록 1** | 04:12 당직. 진지 변경 명령 정상으로 보임. 서명은 못 봤으나 지휘관 표기라 문제없음. 먼저 전파함. |\n", + "| **기록 2** | 04:50 당직. 재전송 확실하다고 판단. 원본은 확인 못 했지만 별일 없을 듯. |\n", + "| **기록 3** | 05:20 당직. 해시 불일치는 단말 오류인 듯. 일단 처리함. 보안관 보고는 나중에. |\n", + "\n", + "| **금지 표현** | **문제점** | **대체 표현** |\n", + "| --- | --- | --- |\n", + "| **정상으로 보임, 문제없음** | 근거 없이 안심시킴 | '서명 유효·해시 일치·시각 정상·순서 연속, 상태 검증 완료' |\n", + "| **~인 듯, 별일 없을 듯** | | |\n", + "| **확실하다** | | |\n", + "| **일단** | | |\n", + "| **나중에 보고** | | |\n", + "| **지휘관 표기라서** | | |\n", + "\n", + "나. 작성 서식 1 - 단말 상태 표시 화면 설계(화면 요소와 위치를 적는다)\n", + "\n", + "| **화면 영역** | **표시 내용** | **색 외 구분 수단(글자·모양)** | **사용자 조작** |\n", + "| --- | --- | --- | --- |\n", + "| **메시지 목록** | | | |\n", + "| **상태 배지** | | | |\n", + "| **근거 4요소** | | | |\n", + "| **보류 해제 버튼** | | | |\n", + "\n", + "나. 작성 서식 2 - 재전송 승인 결재 양식\n", + "\n", + "| **메시지 ID·누락 구간** | |\n", + "| --- | --- |\n", + "\n", + "| **재전송 사유** | |\n", + "| --- | --- |\n", + "\n", + "| **확인 결과(서명·해시·시각·순서)** | |\n", + "| --- | --- |\n", + "\n", + "| **유효 시한 내 여부** | |\n", + "| --- | --- |\n", + "\n", + "| **분류(그대로·확인 후·폐기 후 새로 작성)** | |\n", + "| --- | --- |\n", + "\n", + "| **승인자 직책·서명·시각** | |\n", + "| --- | --- |\n", + "\n", + "나. 작성 서식 3 - 기록 서식(사실·판단·조치·미확인 분리)\n", + "\n", + "| **시각** | **확인된 사실(4요소 상태)** | **판단과 근거** | **취한 조치** | **미확인 사항** |\n", + "| --- | --- | --- | --- | --- |\n", + "| | | | | |\n", + "| | | | | |\n", + "| | | | | |\n", + "\n", + "나. 작성 서식 4 - 기록 3종 변환 결과\n", + "\n", + "| **기록** | **고쳐 쓴 사실 문장** | **고쳐 쓴 판단 문장** | **상태** | **조원 일치** |\n", + "| --- | --- | --- | --- | --- |\n", + "| **기록 1** | | | | |\n", + "| **기록 2** | | | | |\n", + "| **기록 3** | | | | |\n", + "\n", + "다. 점검표\n", + "\n", + "| **점검 항목** | **확인** |\n", + "| --- | --- |\n", + "| 상태 표시 4종에 근거 4요소와 색 외 구분 수단을 정했다 | ( ) |\n", + "| 결재 양식에 메시지 ID, 누락 구간, 확인 결과, 승인자 서명 칸이 있다 | ( ) |\n", + "| 기록 서식의 사실·판단·조치·미확인 칸이 분리되어 있다 | ( ) |\n", + "| 안심 표현을 6개 이상 정하고 대체 표현을 적었다 | ( ) |\n", + "| 기록 3종을 변환했고 사실 칸에 추측 표현이 0건이다 | ( ) |\n", + "\n", + "**\n", + "\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 07. 대규모 통신 교란 및 위조 지휘명령 주입 상황 모의 무결성 검증 스트레스 훈련\n", + "\n", + "### 📋 미션 배경 및 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "대규모 통신 교란 및 위조 전문 주입 모의 무결성 검증 스트레스 훈련을 실시한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **대규모 통신 교란과 위조 전문 주입이 동시에 일어나는 모의 환경에서 무결성 검증이 흔들리지 않는지 스트레스 훈련으로 확인하고 평가 루브릭을 만든다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **정해진 시간에 메시지 카드가 연달아 투입되고 일부는 위조(서명 없음, 낡은 메시지 재투입, 시각 조작)이며 통신 교란으로 일부 정상 메시지가 지연되는 조건에서 조별로 메시지를 처리 수행하고 검증하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 5개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `rubric/c207_evaluation_rubric.md` 형태의 결과물을 완성한다.\n", + "\n", + "### 📑 실습자료 및 작성 양식\n", + "대규모 통신 교란 및 위조 지휘명령 주입 상황 모의 무결성 검증 스트레스 훈련**\n", + "\n", + "가. 제공 자료 1 - 메시지 카드 투입 순서(통제관용, 정답은 비공개) [교육용가상자료]\n", + "\n", + "| **경과 시간** | **카드** | **내용** | **통제관 메모(평가자만)** |\n", + "| --- | --- | --- | --- |\n", + "| **0:00** | J0 | 훈련 개시. 통신 교란 시작 안내. | - |\n", + "| **3:00** | J1 | MSG-201: 서명 유효·해시 일치, 경계 강화 지시 | 정상(처리 승인 가능) |\n", + "| **6:00** | J2 | MSG-202: 서명 없음, '즉시 철수' 명령 | 위조(보류·보고) |\n", + "| **9:00** | J3 | MSG-203: 한 시간 전 정상 명령 재도착 | 재생(유효 시한 경과) |\n", + "| **12:00** | J4 | MSG-204: 서명 유효·순서번호 결번, 교란으로 지연된 정상 보고 | 정상이나 재확인 필요 |\n", + "| **15:00** | J5 | MSG-205: 서명 유효로 표시되나 해시 불일치 | 변조(보류·보고) |\n", + "| **18:00** | J6 | 상급 부서: '시간이 없다, 확인 건너뛰고 처리하라' | 압박(확인 유지 점검) |\n", + "| **21:00** | J7 | MSG-206: 정상 명령이 오탐으로 보류됨 | 지연 위험(재확인 경로) |\n", + "\n", + "가. 제공 자료 2 - 역할: 통신보안 책임(조장) 1명, 기록자 1명, 평가자 2명(서로 상의 금지), 통제관(교관)은 카드를 시간에 맞춰 읽는다.\n", + "\n", + "나. 작성 서식 1 - 5영역 4등급 루브릭(예시 1행 포함)\n", + "\n", + "| **영역** | **탁월** | **우수** | **보통** | **미흡** |\n", + "| --- | --- | --- | --- | --- |\n", + "| **비인가 대응** | 의심 즉시 보류하고 제한 시간 안에 통신보안관에게 보고하며 대체 경로로 재확인함 | 보류하고 보고함 | 보류했으나 보고가 늦음 | 위조 메시지를 실행 승인함 |\n", + "| **출처 확인** | | | | |\n", + "| **무결성 확인** | | | | |\n", + "| **재전송 승인 통제** | | | | |\n", + "| **기록** | | | | |\n", + "\n", + "나. 작성 서식 2 - 평가자 간 채점 일치 확인(탁월 4, 우수 3, 보통 2, 미흡 1)\n", + "\n", + "| **조 / 영역** | **출처 확인** | **무결성** | **비인가 대응** | **재전송 통제** | **기록** | **일치 칸 수** |\n", + "| --- | --- | --- | --- | --- | --- | --- |\n", + "| **1조 (A / B)** | / | / | / | / | / | / 5 |\n", + "| **2조 (A / B)** | / | / | / | / | / | / 5 |\n", + "| **3조 (A / B)** | / | / | / | / | / | / 5 |\n", + "| **4조 (A / B)** | / | / | / | / | / | / 5 |\n", + "\n", + "일치율 = 두 평가자 점수가 같은 칸 수 / 전체 칸 수 x 100 = ( )% (목표 80% 이상). 점수 차이가 2점 이상인 칸은 루브릭 문구를 고친다.\n", + "\n", + "나. 작성 서식 3 - 위조 메시지 처리 기록: 위조·변조·재생 카드 ( )건 중 보류·보고 ( )건, 실행 승인 ( )건. 정상 메시지 ( )건 중 오탐으로 지연 ( )건\n", + "\n", + "다. 점검표\n", + "\n", + "| **점검 항목** | **확인** |\n", + "| --- | --- |\n", + "| 루브릭 5영역 x 4등급 20칸을 모두 관찰 가능한 행동으로 채웠다 | ( ) |\n", + "| 평가자 2명이 서로 상의하지 않고 독립 채점했다 | ( ) |\n", + "| 일치율을 계산했고 80% 이상이거나, 미만이면 루브릭을 고쳐 다시 채점했다 | ( ) |\n", + "| 위조 메시지를 실행 승인한 판단이 0건이다 | ( ) |\n", + "| 오탐으로 지연된 정상 메시지의 재확인 경로를 점검했다 | ( ) |\n", + "\n", + "**\n", + "\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 08. AAR을 통한 가짜 지휘명령 전파 지연 및 누락 원인 분석\n", + "\n", + "### 📋 미션 배경 및 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "AAR 회의를 통해 위조 명령 차단 실패 및 보고 지연 원인을 분석한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **AAR로 위조 명령 차단 실패와 보고 지연의 원인을 분석하고, 레드팀 비판으로 절차의 취약한 전제를 찾아 피드백을 도출한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **가상 AAR 일지 2건(위조 명령이 전파된 사례, 정당한 명령이 오탐으로 늦어진 사례)을 시간순으로 재구성하고 원인을 사람·절차·정보·통신으로 나눈다 과정을 진행하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 5개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `reports/c207_redteam_critique_report.md` 형태의 결과물을 완성한다.\n", + "\n", + "### 📑 실습자료 및 작성 양식\n", + "AAR을 통한 가짜 지휘명령 전파 지연 및 누락 원인 분석**\n", + "\n", + "가. 제공 자료 1 - 가상 AAR 일지 A(위조 명령 전파 사례) [교육용가상자료]\n", + "\n", + "| **시각** | **기록** |\n", + "| --- | --- |\n", + "| **04:05** | 서명 표시가 없는 '진지 변경' 명령이 단말 C에 도착. 발신 표기가 지휘통제실장이었음. |\n", + "| **04:08** | 당직이 '평소 거래 경로'라며 확인 없이 하위 단말 2곳에 전파. |\n", + "| **04:20** | 하위 단말 1곳이 이동을 시작. 통신보안관 보고는 04:50에 이루어짐. |\n", + "| **04:55** | 지휘통제실장이 발신한 적 없다고 확인. 이동 중단 지시가 20분 걸림. |\n", + "\n", + "가. 제공 자료 2 - 가상 AAR 일지 B(정상 명령이 오탐으로 늦어진 사례) [교육용가상자료]\n", + "\n", + "| **시각** | **기록** |\n", + "| --- | --- |\n", + "| **06:00** | 정상 경계 강화 지시가 처음 쓰는 중계를 경유해 의심 점수 기준을 넘어 자동 보류됨. |\n", + "| **06:05** | 통신보안관이 대체 경로 확인을 시도했으나 연락 수단이 하나뿐이었음. |\n", + "| **06:35** | 확인이 끝나 보류 해제. 지시 전파까지 35분 지연. |\n", + "| **06:40** | 지연 중 경계 태세 전환이 늦어진 것으로 AAR에서 지적됨. |\n", + "\n", + "나. 작성 서식 1 - AAR 원인 분석표(비난 없이 사실 중심)\n", + "\n", + "| **사례** | **무엇이 계획되었나** | **실제로 무슨 일이 있었나** | **원인(사람·절차·정보·통신)** | **개선안** |\n", + "| --- | --- | --- | --- | --- |\n", + "| **A** | | | | |\n", + "| **A** | | | | |\n", + "| **B** | | | | |\n", + "| **B** | | | | |\n", + "\n", + "나. 작성 서식 2 - 레드팀 쟁점·대응표(5건 이상, 예시 1건)\n", + "\n", + "| **번호** | **취약한 전제** | **레드팀 비판** | **작성 팀 반박 또는 수정안** | **경고 문구** |\n", + "| --- | --- | --- | --- | --- |\n", + "| **1** | 서명 표시가 있으면 정상이다 | 서명이 유효해도 재생되거나 내용 일부가 달라질 수 있다 | 서명·해시·시각·순서 4요소를 함께 확인한다 | '서명 유효만으로 처리 승인하지 않는다' |\n", + "| **2** | | | | |\n", + "| **3** | | | | |\n", + "| **4** | | | | |\n", + "| **5** | | | | |\n", + "| **6** | | | | |\n", + "\n", + "나. 작성 서식 3 - 반대 방향 위험 점검: 정상 명령이 오탐으로 늦어질 위험과 이를 줄이는 방법(대체 확인 경로 2개 이상, 확인 시간 목표)을 3줄 이내로 적는다.\n", + "\n", + "나. 작성 서식 4 - 수정 반영 계획표\n", + "\n", + "| **번호** | **수정할 문서·조항** | **수정 내용** | **담당(가상 직책)** | **완료 기한** |\n", + "| --- | --- | --- | --- | --- |\n", + "| **1** | | | | |\n", + "| **2** | | | | |\n", + "| **3** | | | | |\n", + "| **4** | | | | |\n", + "| **5** | | | | |\n", + "\n", + "다. 점검표\n", + "\n", + "| **점검 항목** | **확인** |\n", + "| --- | --- |\n", + "| AAR 일지 2건을 시간순으로 재구성하고 계획과 실제의 차이를 적었다 | ( ) |\n", + "| 원인을 사람·절차·정보·통신으로 분류하고 개인 비난 표현이 없다 | ( ) |\n", + "| 레드팀 쟁점을 5건 이상 도출했다 | ( ) |\n", + "| 모든 쟁점에 반박 또는 수정안을 적었다(100%) | ( ) |\n", + "| 취약한 전제마다 경고 문구를 달았다 | ( ) |\n", + "| 정상 명령 지연의 반대 방향 위험을 다뤘다 | ( ) |\n", + "\n", + "**\n", + "\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 09. 검증 지연 단축 및 보안 강화를 위한 C2 승인 절차 피드백 보정\n", + "\n", + "### 📋 미션 배경 및 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "피드백을 반영하여 C2 정보 검증 및 재전송 승인 절차를 보완한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **M7·M8 피드백으로 검증 지연을 줄이면서 보안은 낮추지 않도록 승인 절차를 보정하고 같은 사례로 재검증한 뒤 지휘관 1페이지 브리핑으로 정리한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **개선 전후 절차를 나란히 놓고 검증 단계별 소요 시간을 측정해 지연이 큰 단계를 찾는다 과정을 진행하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 5개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `briefing/c207_commander_1page_brief.md(pdf)` 형태의 결과물을 완성한다.\n", + "\n", + "### 📑 실습자료 및 작성 양식\n", + "검증 지연 단축 및 보안 강화를 위한 C2 승인 절차 피드백 보정**\n", + "\n", + "가. 제공 자료 1 - 검증 단계별 소요 시간 측정 기초값(개선 전, 가상)\n", + "\n", + "| **단계** | **내용** | **소요 시간(분)** |\n", + "| --- | --- | --- |\n", + "| 1 | 접수·4요소 표시 확인 | 2 |\n", + "| 2 | 비인가 점수 계산(수작업) | 4 |\n", + "| 3 | 통신보안관 보고(전화 1회선) | 6 |\n", + "| 4 | 대체 경로 재확인 | 10 |\n", + "| 5 | 승인 결재(서면) | 5 |\n", + "\n", + "가. 제공 자료 2 - 재검증 대상 7건: M5 경계선 B1~B5, M8 AAR 사례 A·B\n", + "\n", + "나. 작성 서식 1 - 개선 전후 절차 비교표(보안 항목 유지 여부 포함)\n", + "\n", + "| **단계** | **개선 전 방법·시간** | **개선 후 방법·시간** | **줄인 방법(병렬·사전 승인·자동 표시)** | **보안 항목 유지(예·아니오)** |\n", + "| --- | --- | --- | --- | --- |\n", + "| **1** | | | | |\n", + "| **2** | | | | |\n", + "| **3** | | | | |\n", + "| **4** | | | | |\n", + "| **5** | | | | |\n", + "\n", + "나. 작성 서식 2 - 재검증 결과표\n", + "\n", + "| **사례** | **개선 전 판단** | **개선 후 판단** | **조원 일치(전 → 후)** | **소요 시간(분, 전 → 후)** | **비고** |\n", + "| --- | --- | --- | --- | --- | --- |\n", + "| **B1** | | | | | |\n", + "| **B2** | | | | | |\n", + "| **B3** | | | | | |\n", + "| **B4** | | | | | |\n", + "| **B5** | | | | | |\n", + "| **A** | | | | | |\n", + "| **B** | | | | | |\n", + "\n", + "나. 작성 서식 3 - 지휘관 1페이지 브리핑 틀(BLUF)\n", + "\n", + "| **결론(BLUF)** | (한 문장으로 개선 결과와 요청 사항) |\n", + "| --- | --- |\n", + "\n", + "| **핵심 결심 사항** | 1) ( ) 2) ( ) |\n", + "| --- | --- |\n", + "\n", + "| **위험 요소(신호등)** | 빨강: ( )\n", + "노랑: ( )\n", + "초록: ( ) |\n", + "| --- | --- |\n", + "\n", + "| **대안 3종** | 대안 1 ( ) / 대안 2 ( ) / 대안 3 ( ) - 각 장단점 한 줄 |\n", + "| --- | --- |\n", + "\n", + "| **시간·보안 균형** | (검증 시간 변화와 유지한 보안 항목) |\n", + "| --- | --- |\n", + "\n", + "| **근거** | 재검증 7건 결과 |\n", + "| --- | --- |\n", + "\n", + "나. 작성 서식 4 - 브리핑 리허설 기록: 발표 시간 ( )분(목표 3분 이내) / 분량 ( )쪽(A4 1쪽) / 질의 답변 ( )건(3건 이상) / 첫 문장이 결론인가 ( )\n", + "\n", + "다. 점검표\n", + "\n", + "| **점검 항목** | **확인** |\n", + "| --- | --- |\n", + "| 개선 전후 단계별 시간과 줄인 방법을 적었다 | ( ) |\n", + "| 시간을 줄이면서 확인 항목을 없애지 않았다(보안 항목 유지) | ( ) |\n", + "| 재검증 7건 모두 개선 전후 판단을 비교했다 | ( ) |\n", + "| 브리핑이 결론부터 시작하고 A4 1페이지에 들어간다 | ( ) |\n", + "| 3분 안에 발표하고 질의에 답했다 | ( ) |\n", + "\n", + "**\n", + "\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 10. C2 정보 무결성·출처 확인·재전송 승인 표준운용절차(SOP) 최종 완성\n", + "\n", + "### 📋 미션 배경 및 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "C2 정보 무결성·출처 확인·재전송 승인 표준운용절차(SOP)를 완성한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **앞 미션의 결과물을 통합해 C2 정보 무결성·출처 확인·재전송 승인 표준운용절차(SOP)를 8개 장과 부록으로 완성한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **목적·적용 범위, 용어 정의, 메시지 위협과 검증 기준, 무결성 등급, 재전송 승인, 비인가 대응, 상황 단계별 점검과 기록, 훈련·평가·개정의 8개 장과 부록(6단계 기준, 점검표, 의사결정 트리, 단말 표시·결재 양식, 루브릭)을 편집 수행하고 검증하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 2개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `manual/c207_standard_operational_manual.md` 형태의 결과물을 완성한다.\n", + "\n", + "### 📑 실습자료 및 작성 양식\n", + "C2 정보 무결성·출처 확인·재전송 승인 표준운용절차(SOP) 최종 완성**\n", + "\n", + "가. 제공 자료 1 - SOP 8개 장 구성과 가져올 결과물\n", + "\n", + "| **장** | **제목** | **포함 내용** | **가져올 결과물** |\n", + "| --- | --- | --- | --- |\n", + "| **1** | 목적과 적용 범위 | 목적, 적용·비적용 상황, 교전수칙·지휘계통과의 관계 | M1 |\n", + "| **2** | 용어 정의 | 무결성, 서명 표시, 해시 확인, 재전송, 보류(격리), 비인가 | M1~M3 |\n", + "| **3** | 메시지 위협과 검증 기준 | 위협 유형, 4요소 확인, 6단계 기준 | M1·M2 |\n", + "| **4** | 재전송 승인 | 분류, 권한, 유효 시한, 불확실성 | M3 |\n", + "| **5** | 비인가 대응 | 판정 기준, 자동 보류, 긴급 보고 | M4 |\n", + "| **6** | 상황 단계별 점검과 승인·보류 | 체크리스트, 의사결정 트리, 권한과 책임 | M5·M9 |\n", + "| **7** | 단말 표시·기록 | 상태 표시, 결재 양식, 기록 서식, 금지 표현 | M6 |\n", + "| **8** | 훈련·평가·개정 | 스트레스 훈련, 루브릭, AAR, 개정 관리 | M7·M8 |\n", + "\n", + "가. 제공 자료 2 - 모의 심의 역할 카드\n", + "\n", + "| **역할** | **검토 질문** |\n", + "| --- | --- |\n", + "| **교리 역할** | 검증 기준과 단계 정의가 일관되는가? 용어를 신병도 이해할 수 있는가? |\n", + "| **보안 역할** | 자동 보류가 실행 중지에 한정되고 해제는 사람이 하는가? 실제 암호·망 정보가 문서에 들어가지 않았는가? |\n", + "| **작전 역할** | 전투 단계에서 쓸 수 있는가? 오탐으로 정상 명령이 오래 막히지 않는가? '최종 판단은 사람, 명령 수행은 교전수칙·지휘계통'이 드러나는가? |\n", + "\n", + "나. 작성 서식 1 - 근거 표기 점검표(조항마다 작성)\n", + "\n", + "| **조항** | **기준 내용 요약** | **근거(미션·가상 자료)** | **표기 여부** |\n", + "| --- | --- | --- | --- |\n", + "| **1-1** | | | |\n", + "| **2-1** | | | |\n", + "| **3-1** | | | |\n", + "| **4-1** | | | |\n", + "| **5-1** | | | |\n", + "| **6-1** | | | |\n", + "| **7-1** | | | |\n", + "| **8-1** | | | |\n", + "\n", + "나. 작성 서식 2 - 모의 심의 지적 사항 반영표\n", + "\n", + "| **번호** | **역할** | **지적 사항** | **반영 내용** | **반영 위치(장·조)** |\n", + "| --- | --- | --- | --- | --- |\n", + "| **1** | | | | |\n", + "| **2** | | | | |\n", + "| **3** | | | | |\n", + "| **4** | | | | |\n", + "| **5** | | | | |\n", + "| **6** | | | | |\n", + "\n", + "나. 작성 서식 3 - 개정 관리: 개정 주기 ( ), 의견 수렴 방법 ( ), 개정 책임 직책(가상) ( ), 개정 이력 기록 위치 ( )\n", + "\n", + "다. 점검표\n", + "\n", + "| **점검 항목** | **확인** |\n", + "| --- | --- |\n", + "| 8개 장과 부록을 모두 완성했다 | ( ) |\n", + "| 모든 기준 조항에 근거(미션·가상 자료)를 표기했다 | ( ) |\n", + "| 교리·보안·작전 역할의 지적 사항을 반영하고 반영 위치를 적었다 | ( ) |\n", + "| '최종 판단은 사람이 하며 실제 명령 수행은 교전수칙·지휘계통에 따른다'는 문구가 1장과 6장에 있다 | ( ) |\n", + "| 연 1회 개정 주기와 의견 수렴 방법을 명시했다 | ( ) |\n", + "| 실제 암호·인증 방식, 망 구성, 부대 정보를 쓰지 않았다 | ( ) |\n", + "\n", + "**4. 평가 채점표**\n", + "\n", + "미션별 평가 기준은 시나리오의 평가 항목과 같다. 평가자는 기준 충족 여부를 PASS/FAIL로 표시하고 FAIL이면 사유를 비고에 적는다.\n", + "\n", + "| **미션** | **구분** | **채점 기준** | **판정** | **비고** |\n", + "| --- | --- | --- | --- | --- |\n", + "| **M1** | 필수 | 메시지 위협 분석·문서 골격: 가상 사고 사례 5건 이상을 검토해 위협 유형을 흐름 단계별로 분류했는가, SOP 목차(8개 장) 골격을 100% 정의하고 지휘관 결심 의제 순으로 배열했는가. | PASS / FAIL | |\n", + "| **M2** | 필수 | 검증 기준·6단계 평가 지표: 무결성 상태 기준 6단계를 완비하고 평가 지표 10개 이상을 정의했는가, 가상 메시지 5건에 대한 조원 간 단계 일치가 4건 이상이고 익숙한 발신자·급한 어조에 끌리지 않게 하는 편향 방지 규칙을 명시했는가. | PASS / FAIL | |\n", + "| **M3** | 필수 | 재전송 승인 기준·불확실성 평가: 재전송 불확실성 요인 8개 이상과 영향도 매트릭스를 도출했는가, 단절·복구 사례 4건을 분류하고 메시지 종류별 승인 권한과 판단 한계를 명시했는가. | PASS / FAIL | |\n", + "| **M4** | 선택 | 비인가 판정 기준·자동 보류 절차: 비인가 판정 기준 12개 이상 항목화와 가중치 산식을 100% 정립했는가, 가상 메시지 3건 적용 결과를 기록하고 자동 보류의 범위를 '실행 중지'로 한정하며 오탐 반박에 대한 방어 논리를 기록했는가. | PASS / FAIL | |\n", + "| **M5** | 필수 | 단계별 체크리스트·분기점·책임 매핑: 승인·보류 분기점 조건 100% 명시와 책임 직책 매핑을 완료했는가, 경계선 5종 모두에 판단과 근거가 기록되고 교전수칙·지휘계통 준수와 사람의 최종 판단을 지켰는가. | PASS / FAIL | |\n", + "| **M6** | 선택 | 상태 표시·결재 양식: 기록 분류 규칙을 100% 표준화하고 혼동 유발 단어 금지 목록을 포함했는가, 가상 기록 3종을 서식으로 옮겼을 때 사실·판단 혼동이 0건이고 상태 표시가 색에만 의존하지 않는가. | PASS / FAIL | |\n", + "| **M7** | 필수 | 교란·위조 스트레스·평가 루브릭: 5개 영역 4등급 기준 정의율 100%, 평가자 2명의 채점 일치율 80% 이상, 위조 메시지를 실행 승인한 판단이 0건인가. | PASS / FAIL | |\n", + "| **M8** | 선택 | AAR 원인·레드팀 검토: 비판적 쟁점 5건 이상을 도출하고 쟁점별 반박·수정 대안을 100% 수립했는가, 취약한 전제에 경고 문구를 명시하고 정상 명령 지연의 반대 위험을 다뤘는가. | PASS / FAIL | |\n", + "| **M9** | 필수 | 승인 절차 보정·1페이지 브리핑: 핵심 결심 사항, 위험 요소, 대안 3종이 A4 1페이지 안에 완결되었는가, 재검증 7건에서 검증 소요 시간 변화와 보안 항목 유지 여부를 함께 제시하고 결론 먼저(BLUF) 원칙으로 3분 안에 전달했는가. | PASS / FAIL | |\n", + "| **M10** | 필수 | SOP 최종 완성: 8개 장과 부록을 100% 완성하고 모든 기준에 근거(미션·가상 자료)를 표기했는가, 모의 심의 지적 사항을 반영하고 '최종 판단은 사람이 하며 실제 명령 수행은 교전수칙·지휘계통에 따른다'는 문구를 명시했는가. | PASS / FAIL | |\n", + "\n", + "| **판정 기준** | 필수 7개 미션(M1·M2·M3·M5·M7·M9·M10)이 모두 PASS여야 최종 PASS로 인정한다. 선택 미션(M4·M6·M8)은 가점·보완 의견에 반영한다. |\n", + "| --- | --- |\n", + "\n", + "| **공통 감점** | 실제 부대명·통신망 구성·암호 방식·실존 인물 정보를 사용한 경우, 부대 자료를 외부 AI 서비스에 입력한 경우 해당 미션은 FAIL로 처리한다. |\n", + "| --- | --- |\n", + "\n", + "| **주제 안전 기준** | 위조·교란 실행 방법을 서술하거나, 사람의 승인 없이 처리 승인되는 경로가 있거나, SOP에 사람의 최종 판단·교전수칙 우선 문구가 없으면 M5·M10은 FAIL로 처리한다. |\n", + "| --- | --- |\n", + "\n", + "| **최종 판정 결과** | ( PASS / FAIL ) 평가자 서명: ( ) |\n", + "| --- | --- |\n", + "\n", + "| 안전 원칙: 본 자료의 무결성 단계, 점수, 의사결정 트리, 권한 구분은 학습용 가상 기준이다. 최종 판단은 사람이 내리며 실제 명령 수행은 교전수칙과 지휘계통에 따른다. 실제 부대 자료를 입력하거나 부대 내에서 촬영하지 않는다. |\n", + "| --- |\n", + "\n" + ] + } + ], + "metadata": { + "colab": { + "provenance": [] + }, + "kernelspec": { + "display_name": "Python 3", + "name": "python3" + }, + "language_info": { + "name": "python" + } + }, + "nbformat": 4, + "nbformat_minor": 0 +} \ No newline at end of file diff --git a/uploads/C210/C210_workbook.ipynb b/uploads/C210/C210_workbook.ipynb new file mode 100644 index 0000000..242be28 --- /dev/null +++ b/uploads/C210/C210_workbook.ipynb @@ -0,0 +1,880 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# 📋 [C210] 교대 지휘관 상황인계 브리핑 검증 훈련\n", + "### 실습 워크북 & SOP 가이드 (Google Colab / Jupyter)\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/C210/C210_workbook.ipynb)\n", + "\n", + "---\n", + "\n", + "## 📌 1. 프로젝트 개요 및 배경\n", + "\n", + "| 항목 | 내용 |\n", + "| :--- | :--- |\n", + "| **과정 분류** | 지휘통제(C2) (Level 2) |\n", + "| **학습 소요 시간** | 10개 미션 (5일 / 총 27.0시간) |\n", + "| **실습 유형** | 작전절차(SOP), 체크리스트, 모의훈련, AAR 사후검토 실습 |\n", + "\n", + "### 📖 프로젝트 목표\n", + "러시아-우크라이나 전쟁에서는 지휘관과 참모의 교대, 지휘소 이동, 통신 불량이 겹치는 순간에 직전까지 알던 정보가 후임에게 온전히 넘어가지 않아 미확인 항적이나 통신이 끊긴 부대에 대한 정보가 누락되고 지휘의 연속성이 흔들릴 수 있다는 점이 교훈으로 거론된다. 교대 지휘관의 상황인계는 작전 경과, 아군 배치, 적 위협, 통신 상태를 확정된 사실과 미확인 정보로 구분해 빠짐없이 전달하고, 인수자가 되묻고 교차 확인해 서명으로 마무리하는 절차가 핵심이다. 본 프로젝트에서는 교육용 가상 대대 지휘소를 설정해 인계 요소 분석, 표준 점검표 작성, 시간대별 상황 주입 시나리오 설계, 1차 모의 인계, 고의적 정보 누락 탐지, 최종 인계 서명·보고, 야간 기습 경보 중 긴급 인계, AAR, 재실행과 개선 측정을 차례로 수행한다. 최종 목표는 교대 지휘관 상황인계 검증 표준 매뉴얼과 지휘훈련 교재를 완성하는 것이다.\n", + "\n", + "> **⚠️ [교육용 가상 자료 원칙]**\n", + "> - 본 워크북의 모든 자료는 안전 규정에 따라 **교육용 가상 자료**로 구성되어 있습니다.\n", + "> - 실제 군사 기밀이나 부대 세부 인적·작전 정보는 포함되지 않습니다.\n", + "\n", + "---\n", + "\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 01. 교대 지휘관 상황인계(Handover) 시나리오 분석 및 핵심 전술 요소 정의\n", + "\n", + "### 📋 미션 배경 및 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "교대 지휘관 상황인계(Handover) 시 작전 연속성 유지를 위한 핵심 전술 요소를 분석한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **교대 지휘관 상황인계가 필요한 상황을 분석해 작전 연속성을 지키는 핵심 전술 요소를 정의하고, 현장 체크 항목과 역할 분담을 정한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **실습자료의 가상 교대 사례 3건을 읽고 인계에서 빠지기 쉬웠던 요소(작전 경과, 아군 배치, 적 위협, 통신 상태, 보류 중인 보고, 지원 요청)를 찾는다 과정을 진행하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 5개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `docs/c210_baseline_assessment.md 및 훈련 프로파일` 형태의 결과물을 완성한다.\n", + "\n", + "### 📑 실습자료 및 작성 양식\n", + "교대 지휘관 상황인계(Handover) 시나리오 분석 및 핵심 전술 요소 정의**\n", + "\n", + "가. 제공 자료 - 가상 교대 사례 3건 [교육용가상자료]\n", + "\n", + "| **사례** | **상황** | **인계에서 빠진 것** | **결과** |\n", + "| --- | --- | --- | --- |\n", + "| **H1** | 야간 교대, 인수자가 도착 직후 바로 인계 받음 | 통신 불량 부대(C중대) 언급 없음 | 2시간 동안 C중대 상황 모름 |\n", + "| **H2** | 지휘소 이동 중 교대 | 미확인 항적 소실 사실 누락 | 후임이 위협이 없다고 판단 |\n", + "| **H3** | 참모 교대가 동시에 이루어짐 | 상급에 대기 중인 보고 1건 누락 | 보고가 4시간 지연 |\n", + "\n", + "나. 작성 서식 1 - 핵심 인계 요소와 현장 체크 항목(15개 이상, 예시 2개 포함)\n", + "\n", + "| **번호** | **인계 요소** | **체크 항목** | **확인 방법** | **책임자(역할)** |\n", + "| --- | --- | --- | --- | --- |\n", + "| **1** | 작전 경과 | 최근 6시간 접촉·사건이 시각과 함께 정리되어 있는가 | 상황판·일지 대조 | 작전 참모 |\n", + "| **2** | 통신 상태 | 통신 불량 부대가 모두 호명되었는가 | 통신 현황표 대조 | 통신 참모 |\n", + "| **3** | | | | |\n", + "| **4** | | | | |\n", + "| **5** | | | | |\n", + "| **6** | | | | |\n", + "| **7** | | | | |\n", + "| **8** | | | | |\n", + "| **9** | | | | |\n", + "| **10** | | | | |\n", + "| **11** | | | | |\n", + "| **12** | | | | |\n", + "| **13** | | | | |\n", + "| **14** | | | | |\n", + "| **15** | | | | |\n", + "| **16** | | | | |\n", + "| **17** | | | | |\n", + "| **18** | | | | |\n", + "\n", + "나. 작성 서식 2 - 역할 분담 매트릭스(항목 번호를 해당 칸에 적는다. 모든 항목이 한 곳 이상에 들어가야 한다)\n", + "\n", + "| **역할** | **책임지는 체크 항목 번호** | **주의 사항** |\n", + "| --- | --- | --- |\n", + "| **전임 지휘관(인계자)** | | |\n", + "| **후임 지휘관(인수자)** | | |\n", + "| **작전 참모** | | |\n", + "| **정보 참모** | | |\n", + "| **통신 참모** | | |\n", + "| **통제관** | | |\n", + "| **관측자** | | |\n", + "\n", + "나. 작성 서식 3 - 훈련 프로파일: 훈련 대상 ( ), 훈련 시간 ( ), 필요한 사전 지식 ( ), 숙달 목표 ( ), 평가 방법 ( )\n", + "\n", + "다. 점검표\n", + "\n", + "| **점검 항목** | **확인** |\n", + "| --- | --- |\n", + "| 가상 교대 사례 3건에서 빠진 요소를 모두 찾았다 | ( ) |\n", + "| 현장 체크 항목을 15개 이상 정의하고 확인 방법을 적었다 | ( ) |\n", + "| 역할 분담 매트릭스에서 모든 체크 항목이 한 곳 이상에 연결된다(100%) | ( ) |\n", + "| 책임자가 없는 항목이나 두 역할이 겹쳐 책임이 모호한 항목이 없다 | ( ) |\n", + "| 훈련 프로파일 5개 항목을 모두 적었다 | ( ) |\n", + "\n", + "**\n", + "\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 02. 인계 항목(작전 경과, 아군 배치, 적 위협, 통신 상태) 표준 점검표(Checklist) 작성\n", + "\n", + "### 📋 미션 배경 및 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "작전 경과, 아군 배치, 적 위협, 통신망 상태 등 필수 인계 점검표(Checklist)를 작성한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **작전 경과, 아군 배치, 적 위협, 통신 상태를 중심으로 한 표준 인계 점검표와 SOP 흐름도를 작성한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **인계 SOP를 준비, 브리핑, 질의·교차 확인, 서명, 상급 보고의 단계로 나누고 단계마다 세부 절차를 쓴다 과정을 진행하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 5개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `sop/c210_field_checklist.md 및 SOP 흐름도` 형태의 결과물을 완성한다.\n", + "\n", + "### 📑 실습자료 및 작성 양식\n", + "인계 항목(작전 경과, 아군 배치, 적 위협, 통신 상태) 표준 점검표(Checklist) 작성**\n", + "\n", + "가. 제공 자료 - 인계 SOP 단계 틀(조별로 세부 절차를 작성)\n", + "\n", + "| **단계** | **이름** | **세부 절차(조별 작성)** | **필수 안전·보안 조치** |\n", + "| --- | --- | --- | --- |\n", + "| **1** | 준비 | | |\n", + "| **2** | 브리핑 | | |\n", + "| **3** | 질의·교차 확인 | | |\n", + "| **4** | 서명 | | |\n", + "| **5** | 상급 보고 | | |\n", + "\n", + "나. 작성 서식 1 - 표준 인계 점검표(한 쪽 분량)\n", + "\n", + "| **영역** | **확인할 내용** | **확인(O/X)** | **우선순위(1~3)** |\n", + "| --- | --- | --- | --- |\n", + "| **작전 경과** | | | |\n", + "| **작전 경과** | | | |\n", + "| **아군 배치** | | | |\n", + "| **아군 배치** | | | |\n", + "| **적 위협** | | | |\n", + "| **적 위협** | | | |\n", + "| **통신 상태** | | | |\n", + "| **통신 상태** | | | |\n", + "| **대기 보고·지원 요청** | | | |\n", + "\n", + "나. 작성 서식 2 - 시간 부족 시 우선순위 규칙: 시간이 ( )분뿐일 때 가장 먼저 확인하는 항목 순서 1) ( ) 2) ( ) 3) ( ). 사후로 미룰 수 있는 항목은 ( ). 단, 생략하면 안 되는 항목은 ( ).\n", + "\n", + "나. 작성 서식 3 - SOP 흐름도 분기(단계 사이 판단을 적는다)\n", + "\n", + "| [준비] → 인계 장소·문서 보안 점검 완료? (예/아니오 → ( ))\n", + "[브리핑] → 모든 영역을 확정 사실과 미확인으로 구분했는가? (아니오 → ( ))\n", + "[질의·교차 확인] → 의심 항목이 남았는가? (예 → ( ))\n", + "[서명] → 복창 확인 완료? (아니오 → ( ))\n", + "[상급 보고] → 완료 시각 기록 및 지휘 공백 없음? (아니오 → ( )) |\n", + "| --- |\n", + "\n", + "다. 점검표\n", + "\n", + "| **점검 항목** | **확인** |\n", + "| --- | --- |\n", + "| SOP 5단계의 세부 절차를 모두 작성했다(100%) | ( ) |\n", + "| 각 단계의 필수 안전·보안 조치를 빠뜨리지 않았다(100%) | ( ) |\n", + "| 점검표가 한 쪽 분량이고 우선순위가 표시되어 있다 | ( ) |\n", + "| 시간 부족 시 우선순위 규칙을 정했다 | ( ) |\n", + "| 흐름도의 분기마다 다음 행동이 있다 | ( ) |\n", + "\n", + "**\n", + "\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 03. 지휘 연속성 유지를 위한 브리핑 자료 구조화 및 사실·미확인 정보 분리 실습\n", + "\n", + "### 📋 미션 배경 및 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "지휘관 인계 브리핑 자료를 구조화하고 확정 사실과 미확인 첩보를 분리하는 양식을 설계한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **인계 브리핑 자료를 확정 사실, 미확인 정보, 요청 사항으로 구조화하는 양식을 만들고, 이를 훈련에 쓰는 시간대별 상황 주입 시나리오(MSEL)를 설계한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **브리핑 자료를 '확정 사실(출처·시각)', '미확인 정보(확인 계획)', '대기 중 보고·요청'의 세 구역으로 나누는 양식 설계하고 워크플로우를 구성하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 5개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `scenarios/c210_msel_scenario.json(md)` 형태의 결과물을 완성한다.\n", + "\n", + "### 📑 실습자료 및 작성 양식\n", + "지휘 연속성 유지를 위한 브리핑 자료 구조화 및 사실·미확인 정보 분리 실습**\n", + "\n", + "가. 제공 자료 - 브리핑 자료 구조화 양식 틀과 주입 사건 예시 3건\n", + "\n", + "| **구역** | **넣을 내용** | **표기 규칙** |\n", + "| --- | --- | --- |\n", + "| **확정 사실** | 출처·시각이 있는 정보 | 출처와 시각을 반드시 적는다 |\n", + "| **미확인 정보** | 출처가 불명이거나 확인 중인 정보 | 확인 계획·담당을 함께 적는다 |\n", + "| **대기 중 보고·요청** | 상급 보고, 지원 요청 | 기한과 담당을 적는다 |\n", + "\n", + "| **ID** | **시각** | **주입 내용** | **구분(정상/비정상)** | **기대 반응** |\n", + "| --- | --- | --- | --- | --- |\n", + "| **E1** | 06:00 | 교대 시작, 상황판 설명 | 정상 | 표준 순서로 브리핑 |\n", + "| **E2** | 06:08 | 인계자가 C중대 통신 불량을 언급하지 않음 | 비정상(누락) | 인수자가 통신 현황을 되물음 |\n", + "| **E3** | 06:15 | 상급이 상황 보고 요구 | 정상 | 10분 안에 보고 |\n", + "\n", + "나. 작성 서식 1 - 브리핑 자료 구조화 양식(가상 상황판 06:00을 구조화해 옮겨 적는다)\n", + "\n", + "| **구역** | **항목** | **내용** | **출처·시각** | **확인 계획·담당** |\n", + "| --- | --- | --- | --- | --- |\n", + "| **확정 사실** | | | | |\n", + "| **확정 사실** | | | | |\n", + "| **확정 사실** | | | | |\n", + "| **미확인 정보** | | | | |\n", + "| **미확인 정보** | | | | |\n", + "| **대기 중 보고·요청** | | | | |\n", + "\n", + "나. 작성 서식 2 - 시간대별 주입 사건(MSEL) 10건 이상. 정상 6건, 비정상 4건 비율로 구성한다.\n", + "\n", + "| **ID** | **시각** | **주입 내용** | **정상/비정상** | **기대 반응** | **통제관 메모** |\n", + "| --- | --- | --- | --- | --- | --- |\n", + "| **E1** | | | | | |\n", + "| **E2** | | | | | |\n", + "| **E3** | | | | | |\n", + "| **E4** | | | | | |\n", + "| **E5** | | | | | |\n", + "| **E6** | | | | | |\n", + "| **E7** | | | | | |\n", + "| **E8** | | | | | |\n", + "| **E9** | | | | | |\n", + "| **E10** | | | | | |\n", + "| **E11** | | | | | |\n", + "| **E12** | | | | | |\n", + "\n", + "나. 작성 서식 3 - 비율·전개 점검: 정상 ( )건 : 비정상 ( )건 = ( ). 긴장도 곡선: 초반 ( ), 중반 ( ), 후반 ( ). 전개가 부자연스러운 사건과 수정 내용: ( )\n", + "\n", + "다. 점검표\n", + "\n", + "| **점검 항목** | **확인** |\n", + "| --- | --- |\n", + "| 브리핑 자료가 확정 사실·미확인 정보·대기 보고로 구분되어 있다 | ( ) |\n", + "| 모든 확정 사실에 출처와 시각이 적혀 있다 | ( ) |\n", + "| 주입 사건을 10건 이상 작성했다 | ( ) |\n", + "| 정상 대 비정상 비율이 6:4이다 | ( ) |\n", + "| 시간 순서와 개연성이 맞고 긴장도가 후반에 몰리지 않는다 | ( ) |\n", + "\n", + "**\n", + "\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 04. 교대 지휘관 1차 모의 상황인계 브리핑(Dry-run) 및 상호 질의응답 실습\n", + "\n", + "### 📋 미션 배경 및 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "모의 상황 하에서 교대 지휘관 간 1차 상황인계 브리핑(Dry-run) 및 질의응답을 실습한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **교대 지휘관 간 1차 모의 상황인계 브리핑(Dry-run)을 수행하고 상호 질의응답을 실습해 SOP 준수와 보고 시간을 측정한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **전임 지휘관 역은 가상 상황판을 보며 브리핑하고 후임 지휘관 역은 점검표를 따라 질의 수행하고 검증하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 5개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `logs/c210_dryrun_execution_log.md 및 작성된 체크리스트` 형태의 결과물을 완성한다.\n", + "\n", + "### 📑 실습자료 및 작성 양식\n", + "교대 지휘관 1차 모의 상황인계 브리핑(Dry-run) 및 상호 질의응답 실습**\n", + "\n", + "가. 제공 자료 - 1차 모의 인계 진행표와 가상 상급 보고 요구\n", + "\n", + "| **시각(경과)** | **진행** |\n", + "| --- | --- |\n", + "| **0분** | 전임 지휘관이 가상 상황판 06:00 기준으로 브리핑을 시작한다. |\n", + "| **8분** | 후임 지휘관이 점검표로 질의하고 참모가 답한다. |\n", + "| **12분** | 상급(통제관)이 '정찰 접촉 결과를 3분 안에 보고하라'고 요구한다. |\n", + "| **15분** | 보고 완료 후 훈련 종료. |\n", + "\n", + "나. 작성 서식 1 - SOP 단계 준수 기록(관측자가 O/X로 표시)\n", + "\n", + "| **번호** | **SOP 단계(M2의 세부 절차 10개)** | **준수(O/X)** | **비고** |\n", + "| --- | --- | --- | --- |\n", + "| **1** | | | |\n", + "| **2** | | | |\n", + "| **3** | | | |\n", + "| **4** | | | |\n", + "| **5** | | | |\n", + "| **6** | | | |\n", + "| **7** | | | |\n", + "| **8** | | | |\n", + "| **9** | | | |\n", + "| **10** | | | |\n", + "\n", + "나. 작성 서식 2 - 시간·준수율 계산: 준수 단계 ( )개 / 전체 10개 = 준수율 ( )%(목표 80% 이상). 상급 요구 시각 ( ), 보고 완료 시각 ( ), 보고 지연 ( )분(목표 3분 이내).\n", + "\n", + "나. 작성 서식 3 - 질의응답 기록\n", + "\n", + "| **번호** | **후임의 질문** | **인계자의 답** | **누락·모호 표현 여부** | **보완 조치** |\n", + "| --- | --- | --- | --- | --- |\n", + "| **1** | | | | |\n", + "| **2** | | | | |\n", + "| **3** | | | | |\n", + "| **4** | | | | |\n", + "| **5** | | | | |\n", + "| **6** | | | | |\n", + "\n", + "나. 작성 서식 4 - 보고 양식 점검: 표준 양식 항목(누가·무엇을·언제·어디서·조치·요청) 중 빠진 항목 ( ). 확정/미확인 구분 표기 여부 ( ).\n", + "\n", + "다. 점검표\n", + "\n", + "| **점검 항목** | **확인** |\n", + "| --- | --- |\n", + "| SOP 10개 단계 준수 여부를 모두 기록했다 | ( ) |\n", + "| 준수율을 계산했고 80% 이상이다(미만이면 원인을 적었다) | ( ) |\n", + "| 보고 지연이 3분 이내이다(초과 시 원인을 적었다) | ( ) |\n", + "| 질의응답에서 나온 누락·모호 표현을 기록했다 | ( ) |\n", + "| 보고 양식이 표준대로 쓰였는지 점검했다 | ( ) |\n", + "\n", + "**\n", + "\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 05. 인계 과정 중 고의적 정보 누락(미확인 항적, 통신 불량 부대) 탐지 훈련\n", + "\n", + "### 📋 미션 배경 및 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "인계 과정 중 발생할 수 있는 의도적 정보 누락(미확인 항적 등)을 식별하는 검증 훈련을 수행한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **인계 과정에서 미확인 항적이나 통신 불량 부대 정보가 고의로 또는 실수로 빠지는 상황을 탐지하는 교차 확인 훈련을 수행한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **통제관이 인계 브리핑 중 정보를 일부러 빠뜨리거나 틀리게 주입한 상황(기만·오경보 포함 7건)을 설정 수행하고 검증하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 5개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `docs/c210_cross_verification_sheet.md` 형태의 결과물을 완성한다.\n", + "\n", + "### 📑 실습자료 및 작성 양식\n", + "인계 과정 중 고의적 정보 누락(미확인 항적, 통신 불량 부대) 탐지 훈련**\n", + "\n", + "가. 제공 자료 - 주입 항목 7건(통제관용, 학습자에게 비공개) [교육용가상자료]\n", + "\n", + "| **번호** | **주입 내용** | **유형** | **찾아야 할 근거(통제관 정답)** |\n", + "| --- | --- | --- | --- |\n", + "| **K1** | 인계자가 '통신 이상 없음'이라고 브리핑 | 고의 누락 | 통신 현황표에 C중대 불량 기록 |\n", + "| **K2** | 미확인 항적 T-17을 '이미 해결됨'으로 설명 | 고의 누락 | 일지에 소실만 기록, 확인 보고 없음 |\n", + "| **K3** | B중대가 '오늘 새벽 접촉 없음'이라고 보고 | 오류 | 04:30 접촉 기록 존재 |\n", + "| **K4** | '적 대규모 이동 징후' 긴급 첩보 도착 | 오경보 | 출처 없음, 다른 보고와 불일치 |\n", + "| **K5** | 예비 부대 위치가 라-3으로 브리핑됨 | 오류 | 상황판에는 라-2 |\n", + "| **K6** | 상급 보고 대기 1건이 '이미 보고됨'으로 설명 | 누락 | 발송 기록 없음 |\n", + "| **K7** | 정상 항적 정보를 새 위협으로 오인하도록 유도 | 기만 | 이전 일지에 동일 항적 기록 |\n", + "\n", + "나. 작성 서식 1 - 교차 확인표(학습자 작성)\n", + "\n", + "| **번호** | **의심한 신호** | **확인한 근거** | **확인한 상대(참모·예하)** | **판단(누락·오류·정상)** | **시각** |\n", + "| --- | --- | --- | --- | --- | --- |\n", + "| **1** | | | | | |\n", + "| **2** | | | | | |\n", + "| **3** | | | | | |\n", + "| **4** | | | | | |\n", + "| **5** | | | | | |\n", + "| **6** | | | | | |\n", + "| **7** | | | | | |\n", + "| **8** | | | | | |\n", + "| **9** | | | | | |\n", + "| **10** | | | | | |\n", + "\n", + "나. 작성 서식 2 - 식별 결과 비교(통제관 정답과 대조)\n", + "\n", + "| **주입** | **식별(O/X)** | **식별 근거 타당성(상·중·하)** | **놓친 이유** |\n", + "| --- | --- | --- | --- |\n", + "| **K1** | | | |\n", + "| **K2** | | | |\n", + "| **K3** | | | |\n", + "| **K4** | | | |\n", + "| **K5** | | | |\n", + "| **K6** | | | |\n", + "| **K7** | | | |\n", + "\n", + "나. 작성 서식 3 - 식별 성공률: 식별 ( )건 / 주입 7건 = ( )%(목표 85% 이상, 6건 이상). 정상 항목을 의심해 올린 오경보 ( )건. 오경보가 인계 시간에 준 영향: ( )\n", + "\n", + "다. 점검표\n", + "\n", + "| **점검 항목** | **확인** |\n", + "| --- | --- |\n", + "| 상황판·통신 기록·일지를 서로 대조했다 | ( ) |\n", + "| 의심 신호마다 확인 근거와 확인한 상대를 적었다 | ( ) |\n", + "| 7건 중 6건 이상 식별했다(85% 이상) | ( ) |\n", + "| 놓친 항목의 원인(대조 안 함, 가정 등)을 적었다 | ( ) |\n", + "| 오경보 때문에 인계가 막히지 않았다 | ( ) |\n", + "\n", + "**\n", + "\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 06. 최종 인계 확인 서명 및 상급 부대 교대 완료 보고 절차 수행\n", + "\n", + "### 📋 미션 배경 및 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "최종 인계 확인 서명 및 상급 부대 지휘권 교대 완료 보고 절차를 실습한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **최종 인계 확인 서명과 상급 부대에 대한 교대 완료 보고 절차를 수행하고 필수 항목 누락 없이 시간 목표 안에 마친다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **인계 필수 항목 12개를 모두 확인했는지 점검표로 대조하고 후임 지휘관이 핵심 사항을 자기 말로 다시 말하는 복창 확인을 수행하고 검증하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 5개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `reports/c210_handover_protocol.md 및 브리핑 녹취/기록지` 형태의 결과물을 완성한다.\n", + "\n", + "### 📑 실습자료 및 작성 양식\n", + "최종 인계 확인 서명 및 상급 부대 교대 완료 보고 절차 수행**\n", + "\n", + "가. 제공 자료 - 인계 필수 항목 12개(예시, 조별로 M2 점검표에 맞게 조정)\n", + "\n", + "| **번호** | **필수 항목** | **번호** | **필수 항목** |\n", + "| --- | --- | --- | --- |\n", + "| 1 | 최근 6시간 작전 경과 | 7 | 미확인 정보와 확인 계획 |\n", + "| 2 | 아군 배치 | 8 | 통신 불량 부대·복구 계획 |\n", + "| 3 | 예비 부대 위치·상태 | 9 | 대기 중 상급 보고 |\n", + "| 4 | 확인된 적 위협 | 10 | 지원 요청·보급 도착 예정 |\n", + "| 5 | 미확인 항적 | 11 | 임박한 결심 사항 |\n", + "| 6 | 경계 태세 수준 | 12 | 연락처·통신 호출부호 |\n", + "\n", + "나. 작성 서식 1 - 최종 인계 확인서\n", + "\n", + "| **인계 일시** | |\n", + "| --- | --- |\n", + "\n", + "| **필수 항목 12개 확인** | 확인 ( )개 / 12개, 누락률 ( )% |\n", + "| --- | --- |\n", + "\n", + "| **복창 확인(후임이 자기 말로 다시 말한 핵심 사항)** | |\n", + "| --- | --- |\n", + "\n", + "| **미해결 사항과 담당** | |\n", + "| --- | --- |\n", + "\n", + "| **전임 지휘관 서명(가상)** | |\n", + "| --- | --- |\n", + "\n", + "| **후임 지휘관 서명(가상)** | |\n", + "| --- | --- |\n", + "\n", + "나. 작성 서식 2 - 상급 부대 교대 완료 보고 기록\n", + "\n", + "| **항목** | **기록** |\n", + "| --- | --- |\n", + "| **보고 시각** | |\n", + "| **보고 방법·수신 직책** | |\n", + "| **보고 내용(교대 완료, 지휘권 인수 시각, 미해결 사항)** | |\n", + "| **수신 확인** | |\n", + "\n", + "나. 작성 서식 3 - 시간·공백 기록: 인계 시작 ( ), 서명 완료 ( ), 보고 완료 ( ), 총 ( )분(목표 10분 이내). 지휘 공백 시각 ( )(없으면 '없음').\n", + "\n", + "나. 작성 서식 4 - 브리핑·질의응답 기록지\n", + "\n", + "| **시각** | **발언자** | **핵심 내용** | **질문·답변 요약** |\n", + "| --- | --- | --- | --- |\n", + "| | | | |\n", + "| | | | |\n", + "| | | | |\n", + "| | | | |\n", + "| | | | |\n", + "\n", + "다. 점검표\n", + "\n", + "| **점검 항목** | **확인** |\n", + "| --- | --- |\n", + "| 인계 필수 항목 12개를 모두 확인했다(누락률 0%) | ( ) |\n", + "| 후임 지휘관이 복창 확인을 했다 | ( ) |\n", + "| 전임·후임 서명과 상급 보고 시각을 기록했다 | ( ) |\n", + "| 총 소요 시간이 10분 이내이다(초과 시 원인을 적었다) | ( ) |\n", + "| 인계 중 지휘 공백 시각이 없다 | ( ) |\n", + "\n", + "**\n", + "\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 07. 야간 기습 경보 발령 중 긴급 지휘권 인계 상황 모의 스트레스 훈련\n", + "\n", + "### 📋 미션 배경 및 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "야간 기습 경보 상황 하 긴급 지휘권 인계 스트레스 훈련을 진행한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **야간 기습 경보가 발령된 중에 긴급 지휘권 인계를 해야 하는 가상 상황에서 비상 절차로 빠르게 전환하고 위험 조치를 수행한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **통제관이 야간 가상 경보를 발령하면 진행 중이던 일반 인계를 중단하고 긴급 인계로 전환 수행하고 검증하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 5개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `logs/c210_stress_drill_results.md` 형태의 결과물을 완성한다.\n", + "\n", + "### 📑 실습자료 및 작성 양식\n", + "야간 기습 경보 발령 중 긴급 지휘권 인계 상황 모의 스트레스 훈련**\n", + "\n", + "가. 제공 자료 1 - 야간 가상 경보 훈련 진행(통제관용) [교육용가상자료]\n", + "\n", + "| **경과** | **주입** | **기대 행동** |\n", + "| --- | --- | --- |\n", + "| **0:00** | 야간 02:00 교대 인계 진행 중 | 일반 인계 브리핑 |\n", + "| **4:00** | 통제관이 '가상 기습 경보' 발령 | 인계 중단, 긴급 인계로 전환(1분 이내) |\n", + "| **5:00** | C중대 통신 두절, 예하 일부 응답 없음 | 통신 우선 확인, 최소 필수 항목 전달 |\n", + "| **7:00** | 상급이 상황 보고 요구 | 간결한 긴급 보고 |\n", + "| **10:00** | 경보 해제 가상 지시 | 사후 인계 보완 시작 |\n", + "\n", + "가. 제공 자료 2 - 위험 조치 10개(예시): 1 경계 강화 지시 / 2 예하 경보 전파 / 3 통신 보안 전환 / 4 지휘소 불빛·소음 통제 / 5 예비 부대 대기 / 6 상급 상황 보고 / 7 미확인 항적 재확인 / 8 C중대 통신 복구 시도 / 9 지휘권 인수 시각 기록 / 10 사후 인계 보완 계획. 실제 경보 체계는 작동시키지 않는다.\n", + "\n", + "나. 작성 서식 1 - 긴급 인계 최소 필수 항목과 전환 규칙\n", + "\n", + "| **순서** | **긴급 시 먼저 전달할 항목** | **사후로 미룰 항목** | **전환 판단 기준** |\n", + "| --- | --- | --- | --- |\n", + "| **1** | | | |\n", + "| **2** | | | |\n", + "| **3** | | | |\n", + "| **4** | | | |\n", + "\n", + "나. 작성 서식 2 - 시간·조치 기록\n", + "\n", + "| **항목** | **기록** | **목표** | **판정** |\n", + "| --- | --- | --- | --- |\n", + "| **경보 발령 시각** | | - | |\n", + "| **긴급 인계 전환 완료 시각** | | - | |\n", + "| **전환 시간** | | 1분 이내 | |\n", + "| **위험 조치 수행 건수** | | 10건 중 9건 이상 | |\n", + "| **상급 보고 시각** | | - | |\n", + "\n", + "나. 작성 서식 3 - 위험 조치 수행표\n", + "\n", + "| **번호** | **위험 조치** | **수행(O/X)** | **수행 시각** | **비고** |\n", + "| --- | --- | --- | --- | --- |\n", + "| **1** | | | | |\n", + "| **2** | | | | |\n", + "| **3** | | | | |\n", + "| **4** | | | | |\n", + "| **5** | | | | |\n", + "| **6** | | | | |\n", + "| **7** | | | | |\n", + "| **8** | | | | |\n", + "| **9** | | | | |\n", + "| **10** | | | | |\n", + "\n", + "나. 작성 서식 4 - 우선순위 판단 기록(왜 그 순서로 했는지 두 줄로 적는다): ( )\n", + "\n", + "다. 점검표\n", + "\n", + "| **점검 항목** | **확인** |\n", + "| --- | --- |\n", + "| 경보 발령부터 긴급 인계 전환까지 1분 이내였다 | ( ) |\n", + "| 위험 조치 10건 중 9건 이상 수행했다 | ( ) |\n", + "| 긴급 시 먼저 전달할 최소 필수 항목을 전달했다 | ( ) |\n", + "| 사후 인계 보완 계획을 세웠다 | ( ) |\n", + "| 실제 경보 체계를 작동시키지 않았다 | ( ) |\n", + "\n", + "**\n", + "\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 08. AAR 사후검토 회의를 통한 인계 누락 및 상황 오인 요인 분석\n", + "\n", + "### 📋 미션 배경 및 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "AAR 사후검토 회의를 통해 인계 누락 및 상황 오인 사례의 원인을 분석한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **AAR 사후검토 회의로 인계 누락과 상황 오인의 원인을 분석하고 개선점을 도출한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **M4~M7 기록을 시간순으로 재구성해 계획과 실제의 차이를 찾고 누락·오인 사례마다 원인을 사람·절차·정보·통신으로 분류한 오류 분석 매트릭스 작성하여 원인을 분석하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 5개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `aar/c210_aar_session_minutes.md 및 오류 분석 매트릭스` 형태의 결과물을 완성한다.\n", + "\n", + "### 📑 실습자료 및 작성 양식\n", + "AAR 사후검토 회의를 통한 인계 누락 및 상황 오인 요인 분석**\n", + "\n", + "가. 제공 자료 - 가상 AAR 기록 요약(M4·M7 훈련에서 나온 것으로 가정) [교육용가상자료]\n", + "\n", + "| **번호** | **시각** | **관측자 기록** |\n", + "| --- | --- | --- |\n", + "| **R1** | 06:08 | 인계자가 통신 불량 부대를 말하지 않았고 인수자가 점검표를 보지 않고 질문했다. |\n", + "| **R2** | 06:20 | 미확인 항적을 '해결됨'이라고만 설명했고 근거를 묻지 않았다. |\n", + "| **R3** | 06:25 | 상급 보고 양식에서 확정/미확인 구분이 빠졌다. |\n", + "| **R4** | 야간 02:04 | 경보 발령 후 전환에 1분 40초 걸렸고 통신 복구를 먼저 하느라 경계 강화 지시가 늦어졌다. |\n", + "| **R5** | 야간 02:09 | 예하 부대 2곳이 경보 전파를 받지 못한 것을 15분 뒤에 알았다. |\n", + "\n", + "나. 작성 서식 1 - 오류 분석 매트릭스(비난 없이 사실 중심)\n", + "\n", + "| **기록** | **계획(SOP)** | **실제** | **원인(사람·절차·정보·통신)** | **근거(기록·녹취)** | **개선안** |\n", + "| --- | --- | --- | --- | --- | --- |\n", + "| **R1** | | | | | |\n", + "| **R2** | | | | | |\n", + "| **R3** | | | | | |\n", + "| **R4** | | | | | |\n", + "| **R5** | | | | | |\n", + "\n", + "나. 작성 서식 2 - 개선점 목록(5건 이상)\n", + "\n", + "| **번호** | **개선점** | **근거(원인 분석표 번호)** | **책임 직책** | **기한** | **확인 방법** |\n", + "| --- | --- | --- | --- | --- | --- |\n", + "| **1** | | | | | |\n", + "| **2** | | | | | |\n", + "| **3** | | | | | |\n", + "| **4** | | | | | |\n", + "| **5** | | | | | |\n", + "| **6** | | | | | |\n", + "| **7** | | | | | |\n", + "\n", + "나. 작성 서식 3 - AAR 회의록\n", + "\n", + "| **일시·참석 역할** | |\n", + "| --- | --- |\n", + "\n", + "| **토의 방식(비난 금지 규칙 안내 여부)** | |\n", + "| --- | --- |\n", + "\n", + "| **주요 논의** | |\n", + "| --- | --- |\n", + "\n", + "| **합의된 개선점** | |\n", + "| --- | --- |\n", + "\n", + "| **다음 훈련 반영 계획** | |\n", + "| --- | --- |\n", + "\n", + "나. 작성 서식 4 - 표현 바꾸기 연습: 비난 표현을 사실 질문으로 바꾼다. 예) '인계자가 일부러 숨겼다' → '인계 시 통신 불량 부대가 언급되지 않은 이유는 무엇인가'. 조별로 3개를 더 바꿔 적는다: ( )\n", + "\n", + "다. 점검표\n", + "\n", + "| **점검 항목** | **확인** |\n", + "| --- | --- |\n", + "| 기록 5건을 모두 원인 분석 매트릭스에 옮겼다 | ( ) |\n", + "| 모든 원인에 근거(기록·녹취)를 연결했다(100%) | ( ) |\n", + "| 개선점을 5건 이상 도출하고 책임 직책·기한을 붙였다 | ( ) |\n", + "| 개인을 비난하는 표현이 없다 | ( ) |\n", + "| 개선점이 다음 훈련(M9)에 반영될 수 있게 구체적이다 | ( ) |\n", + "\n", + "**\n", + "\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 09. 피드백을 반영한 상황인계 브리핑 재실행 및 정보 전달 정확도 측정\n", + "\n", + "### 📋 미션 배경 및 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "피드백을 반영하여 상황인계 브리핑을 재실행하고 정보 전달 완성도를 측정한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **AAR 개선점을 반영해 상황인계 브리핑을 다시 수행하고 이전 훈련과 비교해 시간과 오류가 줄었는지 측정한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **개선점을 점검표와 SOP에 반영한 뒤 같은 가상 상황으로 인계를 재실행 수행하고 검증하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 5개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `eval/c210_rerun_performance_comparison.md` 형태의 결과물을 완성한다.\n", + "\n", + "### 📑 실습자료 및 작성 양식\n", + "피드백을 반영한 상황인계 브리핑 재실행 및 정보 전달 정확도 측정**\n", + "\n", + "가. 제공 자료 - 비교 기초값 예시(가상). 학습자 조는 자기 조의 M4·M7 기록을 사용한다.\n", + "\n", + "| **지표** | **예시 1차 값** | **30% 단축 시 목표** | **오류 50% 감소 시 목표** |\n", + "| --- | --- | --- | --- |\n", + "| **긴급 인계 전환 시간** | 60초 | 42초 이하 | - |\n", + "| **상급 보고 지연** | 3분 00초 | 2분 06초 이하 | - |\n", + "| **오류 건수(누락·오인·순서 위반)** | 10건 | - | 5건 이하 |\n", + "\n", + "나. 작성 서식 1 - SOP·점검표 개선 반영 내역\n", + "\n", + "| **개선점 번호** | **수정한 문서·조항** | **수정 내용** | **기대 효과** |\n", + "| --- | --- | --- | --- |\n", + "| **1** | | | |\n", + "| **2** | | | |\n", + "| **3** | | | |\n", + "| **4** | | | |\n", + "| **5** | | | |\n", + "\n", + "나. 작성 서식 2 - 1차 대비 재실행 비교표\n", + "\n", + "| **지표** | **1차(M4·M7)** | **재실행** | **개선율(%)** | **목표 달성(O/X)** |\n", + "| --- | --- | --- | --- | --- |\n", + "| **긴급 인계 전환 시간(초)** | | | | |\n", + "| **상급 보고 지연(초)** | | | | |\n", + "| **조치 시간 합계(초)** | | | | |\n", + "| **누락 건수** | | | | |\n", + "| **오인 건수** | | | | |\n", + "| **순서 위반 건수** | | | | |\n", + "| **오류 합계(건)** | | | | |\n", + "\n", + "개선율 계산: 조치 시간 개선율 = (1차 - 재실행) / 1차 x 100 = ( )%(목표 30% 이상). 오류 감소율 = (1차 - 재실행) / 1차 x 100 = ( )%(목표 50% 이상).\n", + "\n", + "나. 작성 서식 3 - 미달 항목 보완 계획과 숙달도 관측: 목표 미달 항목 ( ), 원인 ( ), 추가 보완 ( ). 관측자 평가 - 절차 숙달(상·중·하) ( ), 팀 호흡(상·중·하) ( ).\n", + "\n", + "다. 점검표\n", + "\n", + "| **점검 항목** | **확인** |\n", + "| --- | --- |\n", + "| 개선점을 SOP·점검표에 실제로 반영했다 | ( ) |\n", + "| 1차와 같은 방식·같은 상황으로 재실행했다 | ( ) |\n", + "| 조치 시간 개선율 30% 이상, 오류 감소율 50% 이상을 계산해 확인했다 | ( ) |\n", + "| 미달 항목의 원인과 추가 보완을 적었다 | ( ) |\n", + "| 숙달도·팀 호흡 관측 결과를 기록했다 | ( ) |\n", + "\n", + "**\n", + "\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 10. 교대 지휘관 상황인계 검증 표준 매뉴얼 및 지휘훈련 교재 완성\n", + "\n", + "### 📋 미션 배경 및 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "교대 지휘관 상황인계 검증 표준 매뉴얼 및 지휘훈련 교재를 완성한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **교대 지휘관 상황인계 검증 표준 매뉴얼과 지휘훈련 교재를 개요-SOP-시나리오-평가 루브릭-AAR의 5개 장으로 완성한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **미션 1~9 결과를 5개 장(개요, SOP, 시나리오, 평가 루브릭, AAR)으로 편집 수행하고 검증하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 5개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `package/c210_standard_training_package.md 및 교관 지침서` 형태의 결과물을 완성한다.\n", + "\n", + "### 📑 실습자료 및 작성 양식\n", + "교대 지휘관 상황인계 검증 표준 매뉴얼 및 지휘훈련 교재 완성**\n", + "\n", + "가. 제공 자료 1 - 교재 5개 장 구성과 가져올 결과물\n", + "\n", + "| **장** | **제목** | **포함 내용** | **가져올 결과물** |\n", + "| --- | --- | --- | --- |\n", + "| **1** | 개요 | 훈련 목적, 대상, 프로파일, 안전·보안 규칙, 역할 분담 | M1 |\n", + "| **2** | SOP | 표준 점검표, 단계별 절차, 흐름도, 우선순위, 서명·보고 | M2·M6 |\n", + "| **3** | 시나리오 | 브리핑 구조화 양식, MSEL, 누락 탐지 주입 항목, 야간 긴급 인계 | M3·M5·M7 |\n", + "| **4** | 평가 루브릭 | 5영역 4등급, 측정 방법, 목표 수치 | M4~M7·M9 |\n", + "| **5** | AAR | 회의 진행, 원인 분석 매트릭스, 개선점, 재실행 비교 | M8·M9 |\n", + "\n", + "가. 제공 자료 2 - 모의 심의 역할 카드\n", + "\n", + "| **역할** | **검토 질문** |\n", + "| --- | --- |\n", + "| **교리 역할** | 절차 용어와 순서가 일관되는가? 확정 사실과 미확인 정보의 구분이 모든 장에서 지켜지는가? |\n", + "| **보안 역할** | 실제 부대·작전 정보가 교재에 들어가지 않았는가? 문서·통신 보안 조치가 SOP에 있는가? |\n", + "| **교관 역할** | 교관이 이 지침서만 보고 훈련을 진행할 수 있는가? 통제관 메모와 정답 관리가 분명한가? |\n", + "\n", + "나. 작성 서식 1 - 5개 장 완성 점검표\n", + "\n", + "| **장** | **핵심 내용 확인** | **시각 자료(표·그림) 개수** | **완성 여부** |\n", + "| --- | --- | --- | --- |\n", + "| **1 개요** | | | |\n", + "| **2 SOP** | | | |\n", + "| **3 시나리오** | | | |\n", + "| **4 평가 루브릭** | | | |\n", + "| **5 AAR** | | | |\n", + "\n", + "나. 작성 서식 2 - 평가 루브릭(5영역 4등급, 예시 1행 포함)\n", + "\n", + "| **영역** | **탁월** | **우수** | **보통** | **미흡** |\n", + "| --- | --- | --- | --- | --- |\n", + "| **교차 확인** | 미확인 항적·통신 불량을 근거와 함께 모두 찾아 확인함 | 대부분 찾아 확인함 | 일부만 찾음 | 대조 없이 인계를 받아들임 |\n", + "| **준비** | | | | |\n", + "| **브리핑** | | | | |\n", + "| **서명·보고** | | | | |\n", + "| **비상 대응** | | | | |\n", + "\n", + "나. 작성 서식 3 - 교관 지침서 구성: 진행 순서 ( ), 통제관 메모 관리 ( ), 주의사항(비난 금지, 실제 경보 불사용) ( ), 시간 배분 ( )\n", + "\n", + "나. 작성 서식 4 - 가상 사건 교체 목록과 모의 심의 반영표\n", + "\n", + "| **번호** | **교체할 항목(부대·지역·항적·시각)** | **역할 심의 지적 사항** | **반영 내용** | **반영 위치(장)** |\n", + "| --- | --- | --- | --- | --- |\n", + "| **1** | | | | |\n", + "| **2** | | | | |\n", + "| **3** | | | | |\n", + "| **4** | | | | |\n", + "| **5** | | | | |\n", + "\n", + "다. 점검표\n", + "\n", + "| **점검 항목** | **확인** |\n", + "| --- | --- |\n", + "| 5개 장(개요-SOP-시나리오-평가 루브릭-AAR)을 모두 완성했다 | ( ) |\n", + "| 각 장에 표나 그림 같은 시각 자료를 1개 이상 넣었다 | ( ) |\n", + "| 평가 루브릭이 5영역 4등급으로 관찰 가능한 행동으로 쓰였다 | ( ) |\n", + "| 교관 지침서와 가상 사건 교체 목록이 있다 | ( ) |\n", + "| 모의 심의 지적 사항을 반영하고 실제 부대·작전 정보를 쓰지 않았다 | ( ) |\n", + "\n", + "**4. 평가 채점표**\n", + "\n", + "미션별 평가 기준은 시나리오의 평가 항목과 같다. 평가자는 기준 충족 여부를 PASS/FAIL로 표시하고 FAIL이면 사유를 비고에 적는다.\n", + "\n", + "| **미션** | **구분** | **채점 기준** | **판정** | **비고** |\n", + "| --- | --- | --- | --- | --- |\n", + "| **M1** | 필수 | 인계 요소 분석·체크 항목·역할 매트릭스: 현장 체크 항목을 15개 이상 정의했는가, 역할 분담 매트릭스에 모든 항목의 책임자를 100% 매핑했는가, 가상 사례가 실제 부대에서 쓸 수 있을 만큼 현실적인가. | PASS / FAIL | |\n", + "| **M2** | 필수 | 표준 점검표·SOP 흐름도: SOP 단계별 세부 절차를 100% 명시했는가, 필수 안전·보안 조치 포함율이 100%인가, 긴급 상황에서 한 쪽으로 읽을 수 있을 만큼 명확하고 간결한가. | PASS / FAIL | |\n", + "| **M3** | 필수 | 브리핑 구조화·MSEL 설계: 시간대별 주입 사건을 10건 이상 작성하고 정상 대 비정상 비율을 6:4로 구성했는가, 상황 전개가 개연성 있고 훈련 긴장도가 알맞게 유지되는가. | PASS / FAIL | |\n", + "| **M4** | 선택 | 1차 모의 인계·준수율 측정: SOP 단계 준수율이 80% 이상이고 보고 지연 시간이 3분 이내인가, 현장 판단과 질의가 정확하고 보고 양식이 표준대로 쓰였는가. | PASS / FAIL | |\n", + "| **M5** | 선택 | 누락 탐지·교차 확인: 주입된 7건의 기만·오경보 중 6건 이상(식별 성공률 85% 이상)을 찾았는가, 의심 신호를 포착한 근거와 교차 확인이 타당한가. | PASS / FAIL | |\n", + "| **M6** | 필수 | 최종 인계 서명·완료 보고: 인계 필수 항목 누락률이 0%이고 인계 소요 시간이 10분 이내인가, 상황 인식이 끊김 없이 이어지고 질의응답이 명확한가. | PASS / FAIL | |\n", + "| **M7** | 필수 | 야간 긴급 인계 스트레스 훈련: 비상 절차 전환 시간이 1분 이내이고 위험 조치 성공률이 90% 이상(10건 중 9건 이상)인가, 위기에서 우선순위를 냉철하게 판단하고 협업했는가. | PASS / FAIL | |\n", + "| **M8** | 선택 | AAR 원인 분석·개선점 도출: 개선점을 5건 이상 도출했고 원인 분석 항목마다 근거를 100% 연결했는가, 비난 없이 사실 중심으로 토의했는가. | PASS / FAIL | |\n", + "| **M9** | 필수 | 재실행·정확도 측정: 이전 훈련 대비 조치 시간이 30% 이상 단축되고 오류 발생률이 50% 이상 감소했는가, 목표 미달 항목의 원인과 보완이 적혔는가, 행동 절차가 몸에 익고 팀 호흡이 맞는가. | PASS / FAIL | |\n", + "| **M10** | 필수 | 표준 매뉴얼·지휘훈련 교재 완성: 교안 5개 장(개요-SOP-시나리오-평가 루브릭-AAR)을 100% 완비했는가, 교재의 완성도와 시각 자료 활용이 충분하고 실무 부대에 전파할 가치가 있는가. | PASS / FAIL | |\n", + "\n", + "| **판정 기준** | 필수 7개 미션(M1·M2·M3·M5·M7·M9·M10)이 모두 PASS여야 최종 PASS로 인정한다. 선택 미션(M4·M6·M8)은 가점·보완 의견에 반영한다. |\n", + "| --- | --- |\n", + "\n", + "| **공통 감점** | 실제 부대명·위치·병력·작전 정보·실존 인물 정보를 사용한 경우, 부대 자료를 외부 AI 서비스에 입력한 경우 해당 미션은 FAIL로 처리한다. |\n", + "| --- | --- |\n", + "\n", + "| **주제 안전 기준** | AAR에서 개인 비난 표현이 있거나 교재에 실제 경보 체계 작동·실제 지휘권 이양 절차 인용이 있으면 M8·M10은 FAIL로 처리한다. |\n", + "| --- | --- |\n", + "\n", + "| **측정값 처리** | 시간·준수율·오류 건수는 절차 개선 자료이며 개인 평가에 쓰지 않는다. 목표 수치 미달 시 사유를 비고에 적는다. |\n", + "| --- | --- |\n", + "\n", + "| **최종 판정 결과** | ( PASS / FAIL ) 평가자 서명: ( ) |\n", + "| --- | --- |\n", + "\n", + "| 안전 원칙: 본 자료의 점검표, 점수, 시간 목표는 학습용 가상 기준이다. 실제 지휘권 이양은 부대 규정과 지휘계통에 따르고 교전 판단은 교전수칙에 따른다. 실제 부대 자료를 입력하거나 부대 내에서 촬영하지 않는다. |\n", + "| --- |\n", + "\n" + ] + } + ], + "metadata": { + "colab": { + "provenance": [] + }, + "kernelspec": { + "display_name": "Python 3", + "name": "python3" + }, + "language_info": { + "name": "python" + } + }, + "nbformat": 4, + "nbformat_minor": 0 +} \ No newline at end of file diff --git a/uploads/DAT06/DAT06_solution.ipynb b/uploads/DAT06/DAT06_solution.ipynb new file mode 100644 index 0000000..8a5420e --- /dev/null +++ b/uploads/DAT06/DAT06_solution.ipynb @@ -0,0 +1,2257 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# 🎯 [DAT06] 월간 정산·보고·취합 One-click 자동화\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/DAT06/DAT06_solution.ipynb)\n", + "\n", + "---\n", + "\n", + "## 📌 1. 프로젝트 개요 및 배경\n", + "\n", + "| 항목 | 내용 |\n", + "| :--- | :--- |\n", + "| **과정 분류** | 데이터·자동화·업무플랫폼 (Level 3) |\n", + "| **학습 소요 시간** | 10개 미션 (5일 / 총 27.0시간) |\n", + "| **환경 사양** | Python 3.10+, numpy, pandas, matplotlib, scipy, scikit-learn |\n", + "\n", + "### 📖 프로젝트 목표\n", + "미국 국방재무회계국(DFAS)은 반복적인 재무·정산 업무에 업무 자동화(RPA)를 도입해 사람이 하던 취합과 점검을 줄이는 사업을 추진해 왔다. 이 공개 사례의 학습 초점은 반복 업무 분해, 입력과 트리거, 자동 처리 순서, 오류 복구, 실행 로그, 시간 절감이다. 본 프로젝트는 이를 바탕으로 교육용 가상 부대 24개의 월간 결산 자료(물자 불출, 수리부속 소모, 탄약 정산, 장비 가동률)를 코드로 만들고, 파일 대신 메모리 안의 가상 문서와 표로 모의한다. 학습자는 업무 분해, 다형식 파싱, 검증 룰 엔진, 이상 원인 추천, 일관성 검증, 승인·반려 대시보드, 3단계 사람 결재선, 대량 배치 시험, 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. 군수 월간 결산 항목(물자 불출, 수리부속 소모, 탄약 정산, 장비 가동률) 데이터 구조 분석\n", + "**소요시간**: 약 1.5시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "군수 월간 결산 항목(물자 불출, 수리부속 소모, 탄약 정산) 데이터 구조를 분석한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **월간 결산 4개 항목(물자 불출, 수리부속 소모, 탄약 정산, 장비 가동률)의 데이터 구조를 분석해 필드와 산식을 정의하고, 현행 결산 업무를 AS-IS/TO-BE로 비교한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **가상 부대 24개의 월간 원장(기초·수령·불출·반납·기말)과 장비 가동 현황을 만들어 항목별 필드와 재고 산식(기말 = 기초 + 수령 + 반납 - 불출) 정리하고 규격화하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `docs/dat06_as_is_to_be_spec.md` 형태의 결과물을 완성한다.\n", + "\n", + "### 🔍 평가 및 검증 기준 (PASS 조건)\n", + "#### 미션 판정\n", + "\n", + "**평가 구분:** 필수\n", + "\n", + "**핵심 평가영역:** 현행 업무(AS-IS)와 개선 업무(TO-BE) 비교의 명확성\n", + "\n", + "#### 미션 PASS 조건\n", + "\n", + "- 정량 기준: 업무 단계 분해 10단계 이상, 병목 구간 3개소 이상 도출\n", + "\n", + "\n", + "- 진행 기준: 업무 정의서 및 입출력 데이터 명세 작성 완료\n", + "\n", + "\n", + "- 검증 방법: 실무 프로세스와의 일치성 검증\n", + "\n", + "\n", + "#### 채점자 판정 메모\n", + "\n", + "> 단순 코드 실행 여부뿐만 아니라 출력된 수치와 근거 자료가 군 실무 요구사항에 부합하는지 면밀히 확인한다.\n", + "\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "\"\"\"군수 월간 결산 4개 항목(물자 불출, 수리부속 소모, 탄약 정산, 장비 가동률)의 데이터 구조를 분석해 스키마를 정의하고, 월간 결산 업무를 AS-IS/TO-BE 단계로 분해해 병목 구간을 도출한다.\"\"\"\n", + "import numpy as np\n", + "import pandas as pd\n", + "\n", + "# ---------------------------------------------------------------------------\n", + "# [교육용가상자료] 가상 월간 결산 원장 생성기 (실제 부대명·품목·수량·재무 정보 없음)\n", + "# 가상 대대 24개 x (물자 불출 / 수리부속 소모 / 탄약 정산) 품목 원장과 장비 가동 현황을 만든다.\n", + "# 재고 산식: 기말 = 기초 + 수령 + 반납 - 불출, 다음 달 기초 = 이번 달 기말.\n", + "# ---------------------------------------------------------------------------\n", + "UNITS = [f\"가상{i:02d}대대\" for i in range(1, 25)]\n", + "ITEMS = {\"물자불출\": [\"급식류\", \"피복류\", \"유류\", \"정비소모품\"],\n", + " \"수리부속\": [\"필터류\", \"베어링류\", \"전장품\", \"타이어류\"],\n", + " \"탄약정산\": [\"훈련탄A\", \"훈련탄B\", \"모의탄\"]}\n", + "UNIT_CODE = {\"급식류\": \"BX\", \"피복류\": \"EA\", \"유류\": \"L\", \"정비소모품\": \"EA\", \"필터류\": \"EA\", \"베어링류\": \"EA\",\n", + " \"전장품\": \"EA\", \"타이어류\": \"EA\", \"훈련탄A\": \"RD\", \"훈련탄B\": \"RD\", \"모의탄\": \"RD\"}\n", + "LEDGER_COLS = [\"month\", \"unit\", \"cat\", \"item\", \"open\", \"recv\", \"issue\", \"ret\", \"close\"]\n", + "\n", + "\n", + "def make_ledger(n_months=2, seed=60):\n", + " \"\"\"n_months개월 원장(list of dict). 둘째 달 기초는 첫째 달 기말과 같다.\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " rows, prev = [], {}\n", + " for m in range(n_months):\n", + " for u in UNITS:\n", + " for cat, items in ITEMS.items():\n", + " for it in items:\n", + " base = int(rng.integers(80, 400))\n", + " op = prev.get((u, it), base * 3)\n", + " recv = int(rng.integers(0, base))\n", + " issue = int(rng.integers(base // 4, base // 2 + 1))\n", + " ret = int(issue * rng.uniform(0, 0.15))\n", + " cl = op + recv + ret - issue\n", + " rows.append({\"month\": f\"2026-{m + 7:02d}\", \"unit\": u, \"cat\": cat, \"item\": it,\n", + " \"open\": op, \"recv\": recv, \"issue\": issue, \"ret\": ret, \"close\": cl})\n", + " prev[(u, it)] = cl\n", + " return rows\n", + "\n", + "\n", + "def make_equip(n_months=2, seed=61):\n", + " rng = np.random.default_rng(seed)\n", + " out = []\n", + " for m in range(n_months):\n", + " for u in UNITS:\n", + " hold = int(rng.integers(20, 60))\n", + " out.append({\"month\": f\"2026-{m + 7:02d}\", \"unit\": u, \"hold\": hold, \"operable\": int(hold * rng.uniform(0.78, 0.97))})\n", + " return out\n", + "\n", + "# 1) 데이터 구조 분석: 원장 4개 항목의 필드·허용값·산식\n", + "led = pd.DataFrame(make_ledger(2), columns=LEDGER_COLS)\n", + "eq = pd.DataFrame(make_equip(2))\n", + "SCHEMA = {\n", + " \"물자불출\": {\"fields\": \"month, unit, item, open, recv, issue, ret, close\", \"rule\": \"close = open + recv + ret - issue\"},\n", + " \"수리부속\": {\"fields\": \"month, unit, item, open, recv, issue(소모), ret, close\", \"rule\": \"close = open + recv + ret - issue\"},\n", + " \"탄약정산\": {\"fields\": \"month, unit, item, open, recv, issue(사용), ret(잔탄 반납), close\", \"rule\": \"close = open + recv + ret - issue\"},\n", + " \"장비가동률\": {\"fields\": \"month, unit, hold(보유), operable(가동)\", \"rule\": \"가동률 = operable / hold, 0 <= 가동률 <= 1\"},\n", + "}\n", + "print(\"[결산 항목별 데이터 구조] [교육용가상자료]\")\n", + "for k, v in SCHEMA.items():\n", + " print(f\" - {k}: 필드[{v['fields']}] 규칙[{v['rule']}]\")\n", + "print(f\"\\n원장 {len(led)}행(부대 {led['unit'].nunique()}개 x 품목 {led['item'].nunique()}종 x 2개월), 장비 현황 {len(eq)}행\")\n", + "chk = ((led[\"open\"] + led[\"recv\"] + led[\"ret\"] - led[\"issue\"]) == led[\"close\"]).mean()\n", + "cont = led.pivot_table(index=[\"unit\", \"item\"], columns=\"month\", values=[\"open\", \"close\"])\n", + "cont_ok = (cont[(\"close\", \"2026-07\")] == cont[(\"open\", \"2026-08\")]).mean()\n", + "print(f\"재고 산식 일치율 {chk:.0%}, 월 간 연속성(전월 기말=당월 기초) {cont_ok:.0%}\")\n", + "print(\"분류별 월 불출·소모량 합계:\")\n", + "print(led[led[\"month\"] == \"2026-08\"].groupby(\"cat\")[\"issue\"].sum().to_string())\n", + "print(f\"장비 평균 가동률 {(eq['operable'] / eq['hold']).mean():.1%} (가동률 낮은 부대 3개: \"\n", + " f\"{', '.join(eq.assign(r=eq['operable'] / eq['hold']).nsmallest(3, 'r')['unit'])})\")\n", + "\n", + "# 2) 월간 결산 업무 AS-IS 분해(단계, 소요 분 - 가상 측정값)와 TO-BE\n", + "steps = [(\"예하 부대별 결산 자료 수신(메일·메신저)\", 40, \"제출 양식 표준화·자동 수집 함수\", 3),\n", + " (\"제출 양식 확인(서식 다른 파일 구분)\", 35, \"형식 자동 판별·별칭 사전\", 2),\n", + " (\"엑셀 값 복사·붙여넣기 취합\", 90, \"자동 파싱·병합\", 2),\n", + " (\"품목 코드·단위 수기 통일\", 60, \"코드·단위 표준 변환표\", 3),\n", + " (\"기초·기말 재고 산식 대조\", 55, \"검증 룰 15종 자동 적용\", 2),\n", + " (\"전월 대비 증감 계산\", 30, \"전월 비교표 자동 생성\", 1),\n", + " (\"이상 수치 확인(전화 문의)\", 75, \"이상치 후보·원인 요약 추천\", 15),\n", + " (\"장비 가동률 집계\", 30, \"가동률 자동 계산\", 1),\n", + " (\"결산 보고서 서식 작성\", 80, \"템플릿에 자동 채움\", 3),\n", + " (\"보급관·군수과장 결재 요청·회수\", 45, \"3단계 전자 결재 흐름\", 15),\n", + " (\"반려 사항 수정 후 재제출\", 50, \"반려 사유별 수정 위치 안내\", 10),\n", + " (\"최종본 배포·보관\", 20, \"발간본 자동 보관·배포\", 1)]\n", + "proc = pd.DataFrame(steps, columns=[\"업무 단계\", \"AS-IS(분)\", \"TO-BE 개선\", \"TO-BE(분)\"])\n", + "proc.index = range(1, len(proc) + 1)\n", + "thr = proc[\"AS-IS(분)\"].mean() + 0.5 * proc[\"AS-IS(분)\"].std()\n", + "proc[\"병목\"] = np.where(proc[\"AS-IS(분)\"] >= thr, \"병목\", \"\")\n", + "print(f\"\\n[AS-IS / TO-BE 업무 단계 {len(proc)}단계] (병목 기준: 평균+0.5표준편차 = {thr:.0f}분 이상)\")\n", + "print(proc[[\"업무 단계\", \"AS-IS(분)\", \"TO-BE(분)\", \"병목\"]].to_string())\n", + "nb = int((proc[\"병목\"] == \"병목\").sum())\n", + "a, t = proc[\"AS-IS(분)\"].sum(), proc[\"TO-BE(분)\"].sum()\n", + "print(f\"\\n업무 단계 {len(proc)}단계(목표 10단계 이상: {'달성' if len(proc) >= 10 else '미달'}), 병목 {nb}개소(목표 3개소 이상: {'달성' if nb >= 3 else '미달'})\")\n", + "print(f\"1회 결산 AS-IS {a}분 -> TO-BE {t}분 ({1 - t / a:.0%} 단축 예상, 가정값)\")\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 01 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + "[결산 항목별 데이터 구조] [교육용가상자료]\n", + " - 물자불출: 필드[month, unit, item, open, recv, issue, ret, close] 규칙[close = open + recv + ret - issue]\n", + " - 수리부속: 필드[month, unit, item, open, recv, issue(소모), ret, close] 규칙[close = open + recv + ret - issue]\n", + " - 탄약정산: 필드[month, unit, item, open, recv, issue(사용), ret(잔탄 반납), close] 규칙[close = open + recv + ret - issue]\n", + " - 장비가동률: 필드[month, unit, hold(보유), operable(가동)] 규칙[가동률 = operable / hold, 0 <= 가동률 <= 1]\n", + "\n", + "원장 528행(부대 24개 x 품목 11종 x 2개월), 장비 현황 48행\n", + "재고 산식 일치율 100%, 월 간 연속성(전월 기말=당월 기초) 100%\n", + "분류별 월 불출·소모량 합계:\n", + "cat\n", + "물자불출 8270\n", + "수리부속 8184\n", + "탄약정산 6743\n", + "장비 평균 가동률 87.0% (가동률 낮은 부대 3개: 가상23대대, 가상19대대, 가상22대대)\n", + "\n", + "[AS-IS / TO-BE 업무 단계 12단계] (병목 기준: 평균+0.5표준편차 = 62분 이상)\n", + " 업무 단계 AS-IS(분) TO-BE(분) 병목\n", + "1 예하 부대별 결산 자료 수신(메일·메신저) 40 3 \n", + "2 제출 양식 확인(서식 다른 파일 구분) 35 2 \n", + "3 엑셀 값 복사·붙여넣기 취합 90 2 병목\n", + "4 품목 코드·단위 수기 통일 60 3 \n", + "5 기초·기말 재고 산식 대조 55 2 \n", + "6 전월 대비 증감 계산 30 1 \n", + "7 이상 수치 확인(전화 문의) 75 15 병목\n", + "8 장비 가동률 집계 30 1 \n", + "9 결산 보고서 서식 작성 80 3 병목\n", + "10 보급관·군수과장 결재 요청·회수 45 15 \n", + "11 반려 사항 수정 후 재제출 50 10 \n", + "12 최종본 배포·보관 20 1 \n", + "\n", + "업무 단계 12단계(목표 10단계 이상: 달성), 병목 3개소(목표 3개소 이상: 달성)\n", + "1회 결산 AS-IS 610분 -> TO-BE 58분 (90% 단축 예상, 가정값)\n", + "```\n", + "
\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 02. 다양한 양식의 엑셀/CSV 데이터 자동 파싱, 정합성 검증 및 전처리 파이프라인 구축\n", + "**소요시간**: 약 2.0시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "엑셀/CSV 데이터 자동 파싱, 정합성 검증 및 전처리 파이프라인을 구축한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **CSV, 엑셀 내보내기(탭 구분), JSON, 공문 텍스트 형식의 결산 자료를 자동 파싱하고 정합성을 검증하는 전처리 파이프라인을 구축한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **서로 다른 서식의 가상 결산 자료 4종을 메모리 안의 문자열로 만들고 형식별 파서로 표준 레코드로 바꾼다 과정을 진행하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `src/connectors/dat06_data_loader.py 및 스키마 정의서` 형태의 결과물을 완성한다.\n", + "\n", + "### 🔍 평가 및 검증 기준 (PASS 조건)\n", + "#### 미션 판정\n", + "\n", + "**평가 구분:** 필수\n", + "\n", + "**핵심 평가영역:** 국방 서식/양식 변경에 대한 유연한 확장성\n", + "\n", + "#### 미션 PASS 조건\n", + "\n", + "- 정량 기준: 지원 파일 포맷(CSV, Excel, JSON 등) 파싱 성공률 100%\n", + "\n", + "\n", + "- 진행 기준: 데이터 로더 및 스키마 유효성 검사 모듈 완성\n", + "\n", + "\n", + "- 검증 방법: 다양한 형태의 샘플 군수 문서 대상 파싱 테스트\n", + "\n", + "\n", + "#### 채점자 판정 메모\n", + "\n", + "> 단순 코드 실행 여부뿐만 아니라 출력된 수치와 근거 자료가 군 실무 요구사항에 부합하는지 면밀히 확인한다.\n", + "\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "\"\"\"CSV, 엑셀 내보내기(탭 구분), JSON, 공문 텍스트 4가지 형식의 가상 결산 자료를 메모리 안에서 자동 파싱하고 머리글 별칭 대응·정합성 검증·전처리를 거쳐 표준 원장으로 합치는 파이프라인을 구축한다.\"\"\"\n", + "import csv\n", + "import io\n", + "import json\n", + "import re\n", + "import numpy as np\n", + "import pandas as pd\n", + "\n", + "# ---------------------------------------------------------------------------\n", + "# [교육용가상자료] 가상 월간 결산 원장 생성기 (실제 부대명·품목·수량·재무 정보 없음)\n", + "# 가상 대대 24개 x (물자 불출 / 수리부속 소모 / 탄약 정산) 품목 원장과 장비 가동 현황을 만든다.\n", + "# 재고 산식: 기말 = 기초 + 수령 + 반납 - 불출, 다음 달 기초 = 이번 달 기말.\n", + "# ---------------------------------------------------------------------------\n", + "UNITS = [f\"가상{i:02d}대대\" for i in range(1, 25)]\n", + "ITEMS = {\"물자불출\": [\"급식류\", \"피복류\", \"유류\", \"정비소모품\"],\n", + " \"수리부속\": [\"필터류\", \"베어링류\", \"전장품\", \"타이어류\"],\n", + " \"탄약정산\": [\"훈련탄A\", \"훈련탄B\", \"모의탄\"]}\n", + "UNIT_CODE = {\"급식류\": \"BX\", \"피복류\": \"EA\", \"유류\": \"L\", \"정비소모품\": \"EA\", \"필터류\": \"EA\", \"베어링류\": \"EA\",\n", + " \"전장품\": \"EA\", \"타이어류\": \"EA\", \"훈련탄A\": \"RD\", \"훈련탄B\": \"RD\", \"모의탄\": \"RD\"}\n", + "LEDGER_COLS = [\"month\", \"unit\", \"cat\", \"item\", \"open\", \"recv\", \"issue\", \"ret\", \"close\"]\n", + "\n", + "\n", + "def make_ledger(n_months=2, seed=60):\n", + " \"\"\"n_months개월 원장(list of dict). 둘째 달 기초는 첫째 달 기말과 같다.\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " rows, prev = [], {}\n", + " for m in range(n_months):\n", + " for u in UNITS:\n", + " for cat, items in ITEMS.items():\n", + " for it in items:\n", + " base = int(rng.integers(80, 400))\n", + " op = prev.get((u, it), base * 3)\n", + " recv = int(rng.integers(0, base))\n", + " issue = int(rng.integers(base // 4, base // 2 + 1))\n", + " ret = int(issue * rng.uniform(0, 0.15))\n", + " cl = op + recv + ret - issue\n", + " rows.append({\"month\": f\"2026-{m + 7:02d}\", \"unit\": u, \"cat\": cat, \"item\": it,\n", + " \"open\": op, \"recv\": recv, \"issue\": issue, \"ret\": ret, \"close\": cl})\n", + " prev[(u, it)] = cl\n", + " return rows\n", + "\n", + "\n", + "def make_equip(n_months=2, seed=61):\n", + " rng = np.random.default_rng(seed)\n", + " out = []\n", + " for m in range(n_months):\n", + " for u in UNITS:\n", + " hold = int(rng.integers(20, 60))\n", + " out.append({\"month\": f\"2026-{m + 7:02d}\", \"unit\": u, \"hold\": hold, \"operable\": int(hold * rng.uniform(0.78, 0.97))})\n", + " return out\n", + "\n", + "TRUTH = [r for r in make_ledger(2) if r[\"month\"] == \"2026-08\"]\n", + "FIELDS = [\"unit\", \"cat\", \"item\", \"open\", \"recv\", \"issue\", \"ret\", \"close\"]\n", + "ALIAS = {\"unit\": [\"unit\", \"부대\", \"부대명\", \"제출부대\"], \"cat\": [\"cat\", \"구분\", \"분류\"], \"item\": [\"item\", \"품목\", \"품목명\"],\n", + " \"open\": [\"open\", \"기초\", \"기초재고\"], \"recv\": [\"recv\", \"수령\", \"입고\"], \"issue\": [\"issue\", \"불출\", \"소모\", \"사용\"],\n", + " \"ret\": [\"ret\", \"반납\"], \"close\": [\"close\", \"기말\", \"기말재고\"]}\n", + "H2F = {h: f for f, hs in ALIAS.items() for h in hs}\n", + "by_unit = lambda a, b: [r for r in TRUTH if UNITS[a] <= r[\"unit\"] <= UNITS[b]]\n", + "\n", + "# --- 가상 원본 4종(문자열) 생성: 형식마다 서식이 다르다 ---\n", + "def mk_csv(rows):\n", + " return \"\\n\".join([\"부대,구분,품목,기초,수령,불출,반납,기말\"] + [\",\".join(str(r[f]) for f in FIELDS) for r in rows])\n", + "def mk_tab(rows): # 엑셀에서 '탭 구분 텍스트'로 내보낸 형태, 머리글이 양식 개정으로 바뀜\n", + " return \"\\n\".join([\"제출부대\\t분류\\t품목명\\t기초재고\\t입고\\t소모\\t반납\\t기말재고\"] + [\"\\t\".join(str(r[f]) for f in FIELDS) for r in rows])\n", + "def mk_json(rows):\n", + " return json.dumps({\"form\": \"결산서\", \"rows\": [{f: r[f] for f in FIELDS} for r in rows]}, ensure_ascii=False)\n", + "def mk_memo(rows): # 공문 본문 형태 비정형 텍스트\n", + " return \"\\n\".join(f\"{i + 1}. {r['unit']} {r['cat']} {r['item']}: 기초 {r['open']}, 수령 {r['recv']}, 불출 {r['issue']}, 반납 {r['ret']}, 기말 {r['close']}\"\n", + " for i, r in enumerate(rows))\n", + "RAW = {\"csv\": mk_csv(by_unit(0, 2)), \"excel_tab\": mk_tab(by_unit(3, 5)), \"json\": mk_json(by_unit(6, 8)), \"memo\": mk_memo(by_unit(9, 11))}\n", + "RAW[\"csv\"] += \"\\n가상99대대,물자불출,급식류,10,0,3,0,7\" # 존재하지 않는 부대 코드(보류 대상)\n", + "RAW[\"memo\"] += \"\\n99. 가상10대대 탄약정산 훈련탄A: 기초 50, 수령 10, 불출 abc, 반납 0, 기말 60\" # 숫자 아님(보류 대상)\n", + "\n", + "# --- 파서: 모두 표준 레코드(dict) 리스트로 변환 ---\n", + "def norm(row):\n", + " out = {}\n", + " for k, v in row.items():\n", + " f = H2F.get(str(k).strip())\n", + " if f:\n", + " out[f] = v.strip() if isinstance(v, str) else v\n", + " return out\n", + "def parse_csv(s, delim=\",\"):\n", + " return [norm(r) for r in csv.DictReader(io.StringIO(s), delimiter=delim)]\n", + "def parse_json(s):\n", + " return [norm(r) for r in json.loads(s)[\"rows\"]]\n", + "def parse_memo(s):\n", + " pat = re.compile(r\"\\d+\\.\\s*(\\S+)\\s+(\\S+)\\s+(\\S+):\\s*기초\\s*(\\S+),\\s*수령\\s*(\\S+),\\s*불출\\s*(\\S+),\\s*반납\\s*(\\S+),\\s*기말\\s*(\\S+)\")\n", + " return [dict(zip(FIELDS, m.groups())) for m in map(pat.match, s.splitlines()) if m]\n", + "PARSERS = {\"csv\": parse_csv, \"excel_tab\": lambda s: parse_csv(s, \"\\t\"), \"json\": parse_json, \"memo\": parse_memo}\n", + "\n", + "# --- 전처리·정합성 검증: 형 변환, 코드 확인, 산식 대조. 오류 레코드는 반영 보류 ---\n", + "known_items = {it for v in ITEMS.values() for it in v}\n", + "def clean(r, fmt):\n", + " try:\n", + " for f in (\"open\", \"recv\", \"issue\", \"ret\", \"close\"):\n", + " r[f] = int(r[f])\n", + " except (ValueError, KeyError):\n", + " return None, f\"[{fmt}] 숫자 변환 실패: {r.get('unit')} {r.get('item')}\"\n", + " if r[\"unit\"] not in UNITS:\n", + " return None, f\"[{fmt}] 존재하지 않는 부대 코드 {r['unit']}\"\n", + " if r[\"item\"] not in known_items:\n", + " return None, f\"[{fmt}] 알 수 없는 품목 {r['item']}\"\n", + " if r[\"open\"] + r[\"recv\"] + r[\"ret\"] - r[\"issue\"] != r[\"close\"]:\n", + " return None, f\"[{fmt}] 재고 산식 불일치 {r['unit']} {r['item']}\"\n", + " return r, None\n", + "\n", + "good, hold, stat = [], [], {}\n", + "for fmt, text in RAW.items():\n", + " try:\n", + " recs = PARSERS[fmt](text)\n", + " stat[fmt] = {\"parse_ok\": True, \"n\": len(recs)}\n", + " except Exception as ex:\n", + " recs, stat[fmt] = [], {\"parse_ok\": False, \"n\": 0, \"err\": repr(ex)[:50]}\n", + " for r in recs:\n", + " c, why = clean(r, fmt)\n", + " (good if c else hold).append(c or why)\n", + "df = pd.DataFrame(good, columns=FIELDS).sort_values([\"unit\", \"item\"]).reset_index(drop=True)\n", + "\n", + "print(\"[형식별 파싱 결과] [교육용가상자료]\")\n", + "for fmt, s in stat.items():\n", + " print(f\" - {fmt:<9} 파싱 {'성공' if s['parse_ok'] else '실패'}, 읽은 레코드 {s['n']}건\")\n", + "rate = sum(s[\"parse_ok\"] for s in stat.values()) / len(stat)\n", + "print(f\"파싱 성공률 {rate:.0%}({len(stat)}개 형식), 정상 반영 {len(df)}건, 반영 보류 {len(hold)}건\")\n", + "for w in hold:\n", + " print(\" 보류:\", w)\n", + "truth = pd.DataFrame(by_unit(0, 11), columns=FIELDS).sort_values([\"unit\", \"item\"]).reset_index(drop=True)\n", + "same = df.equals(truth.astype(df.dtypes.to_dict()))\n", + "print(f\"원본 대비 값 일치: {'전부 일치' if same else '불일치'} ({len(df)}행 = 12개 부대 x 11품목 = {12 * 11}행)\")\n", + "print(\"표준 원장 미리보기(앞 4행):\")\n", + "print(df.head(4).to_string(index=False))\n", + "print(f\"\\n목표(4개 형식 파싱 성공률 100%, 양식 변경 대응, 오류 레코드 보류): {'달성' if rate == 1 and same and len(hold) == 2 else '미달'}\")\n", + "print(\"새 서식 대응은 ALIAS 사전에 머리글 별칭만 추가하면 된다(코드 수정 불필요).\")\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 02 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + "[형식별 파싱 결과] [교육용가상자료]\n", + " - csv 파싱 성공, 읽은 레코드 34건\n", + " - excel_tab 파싱 성공, 읽은 레코드 33건\n", + " - json 파싱 성공, 읽은 레코드 33건\n", + " - memo 파싱 성공, 읽은 레코드 34건\n", + "파싱 성공률 100%(4개 형식), 정상 반영 132건, 반영 보류 2건\n", + " 보류: [csv] 존재하지 않는 부대 코드 가상99대대\n", + " 보류: [memo] 숫자 변환 실패: 가상10대대 훈련탄A\n", + "원본 대비 값 일치: 전부 일치 (132행 = 12개 부대 x 11품목 = 132행)\n", + "표준 원장 미리보기(앞 4행):\n", + " unit cat item open recv issue ret close\n", + "가상01대대 물자불출 급식류 402 51 39 3 417\n", + "가상01대대 탄약정산 모의탄 943 147 117 11 984\n", + "가상01대대 수리부속 베어링류 261 34 142 1 154\n", + "가상01대대 물자불출 유류 1279 257 108 5 1433\n", + "\n", + "목표(4개 형식 파싱 성공률 100%, 양식 변경 대응, 오류 레코드 보류): 달성\n", + "새 서식 대응은 ALIAS 사전에 머리글 별칭만 추가하면 된다(코드 수정 불필요).\n", + "```\n", + "
\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 03. 표준 정산 규칙 기반 1차 월간 결산 집계 및 통계 산출 Baseline 엔진 구현\n", + "**소요시간**: 약 2.5시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "표준 정산 규칙 기반 1차 월간 결산 집계 Baseline 엔진을 구현한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **표준 정산 규칙을 설정 문자열 룰 15종으로 분리하고, 이를 적용한 1차 월간 결산 집계 Baseline 엔진을 구현한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **룰마다 가상 조항 번호, 조건식, 오류 메시지를 설정으로 두어 코드 수정 없이 바꿀 수 있게 수행하고 검증하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `src/engine/dat06_rule_engine.py` 형태의 결과물을 완성한다.\n", + "\n", + "### 🔍 평가 및 검증 기준 (PASS 조건)\n", + "#### 미션 판정\n", + "\n", + "**평가 구분:** 필수\n", + "\n", + "**핵심 평가영역:** 군 규정 및 지침 조항과의 정확한 일치성\n", + "\n", + "#### 미션 PASS 조건\n", + "\n", + "- 정량 기준: 업무 검증 룰 15종 이상 코딩, 룰 처리 지연시간 50ms 이하\n", + "\n", + "\n", + "- 진행 기준: 룰 엔진 단위 테스트 100% 통과\n", + "\n", + "\n", + "- 검증 방법: 경계 조건(Boundary condition)에 대한 룰 판정 검증\n", + "\n", + "\n", + "#### 채점자 판정 메모\n", + "\n", + "> 단순 코드 실행 여부뿐만 아니라 출력된 수치와 근거 자료가 군 실무 요구사항에 부합하는지 면밀히 확인한다.\n", + "\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "\"\"\"설정 문자열로 분리한 검증 룰 15종(가상 정산 규정 조항 대응)으로 월간 원장을 검사하고, 표준 정산 규칙에 따른 1차 결산 집계 Baseline을 계산하며 처리 지연시간과 경계 조건 단위 테스트를 확인한다.\"\"\"\n", + "import time\n", + "import numpy as np\n", + "import pandas as pd\n", + "\n", + "# ---------------------------------------------------------------------------\n", + "# [교육용가상자료] 가상 월간 결산 원장 생성기 (실제 부대명·품목·수량·재무 정보 없음)\n", + "# 가상 대대 24개 x (물자 불출 / 수리부속 소모 / 탄약 정산) 품목 원장과 장비 가동 현황을 만든다.\n", + "# 재고 산식: 기말 = 기초 + 수령 + 반납 - 불출, 다음 달 기초 = 이번 달 기말.\n", + "# ---------------------------------------------------------------------------\n", + "UNITS = [f\"가상{i:02d}대대\" for i in range(1, 25)]\n", + "ITEMS = {\"물자불출\": [\"급식류\", \"피복류\", \"유류\", \"정비소모품\"],\n", + " \"수리부속\": [\"필터류\", \"베어링류\", \"전장품\", \"타이어류\"],\n", + " \"탄약정산\": [\"훈련탄A\", \"훈련탄B\", \"모의탄\"]}\n", + "UNIT_CODE = {\"급식류\": \"BX\", \"피복류\": \"EA\", \"유류\": \"L\", \"정비소모품\": \"EA\", \"필터류\": \"EA\", \"베어링류\": \"EA\",\n", + " \"전장품\": \"EA\", \"타이어류\": \"EA\", \"훈련탄A\": \"RD\", \"훈련탄B\": \"RD\", \"모의탄\": \"RD\"}\n", + "LEDGER_COLS = [\"month\", \"unit\", \"cat\", \"item\", \"open\", \"recv\", \"issue\", \"ret\", \"close\"]\n", + "\n", + "\n", + "def make_ledger(n_months=2, seed=60):\n", + " \"\"\"n_months개월 원장(list of dict). 둘째 달 기초는 첫째 달 기말과 같다.\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " rows, prev = [], {}\n", + " for m in range(n_months):\n", + " for u in UNITS:\n", + " for cat, items in ITEMS.items():\n", + " for it in items:\n", + " base = int(rng.integers(80, 400))\n", + " op = prev.get((u, it), base * 3)\n", + " recv = int(rng.integers(0, base))\n", + " issue = int(rng.integers(base // 4, base // 2 + 1))\n", + " ret = int(issue * rng.uniform(0, 0.15))\n", + " cl = op + recv + ret - issue\n", + " rows.append({\"month\": f\"2026-{m + 7:02d}\", \"unit\": u, \"cat\": cat, \"item\": it,\n", + " \"open\": op, \"recv\": recv, \"issue\": issue, \"ret\": ret, \"close\": cl})\n", + " prev[(u, it)] = cl\n", + " return rows\n", + "\n", + "\n", + "def make_equip(n_months=2, seed=61):\n", + " rng = np.random.default_rng(seed)\n", + " out = []\n", + " for m in range(n_months):\n", + " for u in UNITS:\n", + " hold = int(rng.integers(20, 60))\n", + " out.append({\"month\": f\"2026-{m + 7:02d}\", \"unit\": u, \"hold\": hold, \"operable\": int(hold * rng.uniform(0.78, 0.97))})\n", + " return out\n", + "\n", + "# 1) 검증 룰 설정: (룰ID, 가상 조항, 조건식(참이어야 정상), 오류 메시지) - 코드 수정 없이 문자열만 바꿔 룰 변경\n", + "RULES = [\n", + " (\"V01\", \"가상규정 제1조\", \"unit in UNITS\", \"존재하지 않는 부대\"),\n", + " (\"V02\", \"가상규정 제2조\", \"item in UNIT_CODE\", \"알 수 없는 품목\"),\n", + " (\"V03\", \"가상규정 제3조\", \"cat in ITEMS and item in ITEMS[cat]\", \"구분과 품목 불일치\"),\n", + " (\"V04\", \"가상규정 제4조\", \"open >= 0\", \"기초 재고 음수\"),\n", + " (\"V05\", \"가상규정 제4조\", \"recv >= 0\", \"수령량 음수\"),\n", + " (\"V06\", \"가상규정 제4조\", \"issue >= 0\", \"불출량 음수\"),\n", + " (\"V07\", \"가상규정 제4조\", \"ret >= 0\", \"반납량 음수\"),\n", + " (\"V08\", \"가상규정 제5조\", \"close == open + recv + ret - issue\", \"기말 산식 불일치\"),\n", + " (\"V09\", \"가상규정 제6조\", \"issue <= open + recv\", \"가용량 초과 불출\"),\n", + " (\"V10\", \"가상규정 제6조\", \"ret <= issue\", \"불출보다 큰 반납\"),\n", + " (\"V11\", \"가상규정 제7조\", \"close >= 0\", \"기말 재고 음수\"),\n", + " (\"V12\", \"가상규정 제8조\", \"len(month) == 7 and month[4] == '-'\", \"결산월 형식 오류\"),\n", + " (\"V13\", \"가상규정 제9조\", \"cat != '탄약정산' or issue <= 5000\", \"탄약 월 사용량 한도 초과\"),\n", + " (\"V14\", \"가상규정 제10조\", \"isinstance(open, int) and isinstance(close, int)\", \"수량 자료형 오류\"),\n", + " (\"V15\", \"가상규정 제11조\", \"issue <= max(open + recv, 1) * 0.9 or issue == 0\", \"불출 비율 과다(90% 초과)\"),\n", + "]\n", + "COMPILED = [(rid, art, compile(expr, rid, \"eval\"), msg) for rid, art, expr, msg in RULES]\n", + "CTX = {\"UNITS\": UNITS, \"UNIT_CODE\": UNIT_CODE, \"ITEMS\": ITEMS}\n", + "\n", + "\n", + "def check_row(row):\n", + " env = {**CTX, **row}\n", + " out = []\n", + " for rid, art, code, msg in COMPILED:\n", + " try:\n", + " ok = eval(code, {\"__builtins__\": {\"len\": len, \"isinstance\": isinstance, \"int\": int, \"max\": max}}, env)\n", + " except Exception:\n", + " ok = False\n", + " if not ok:\n", + " out.append((rid, art, msg))\n", + " return out\n", + "\n", + "\n", + "# 2) 검증 실행 + 지연시간(행당) 측정\n", + "rows = make_ledger(2)\n", + "rng = np.random.default_rng(3)\n", + "for i in rng.choice(len(rows), 6, replace=False): # 오류 6건 주입(룰이 잡는지 확인)\n", + " k = int(rng.integers(0, 3))\n", + " rows[i] = {**rows[i], **[{\"close\": rows[i][\"close\"] + 7}, {\"issue\": rows[i][\"open\"] + rows[i][\"recv\"] + 50}, {\"ret\": -3}][k]}\n", + "lat, found = [], []\n", + "for r in rows:\n", + " t0 = time.perf_counter()\n", + " res = check_row(r)\n", + " lat.append((time.perf_counter() - t0) * 1000)\n", + " found += [(r[\"month\"], r[\"unit\"], r[\"item\"], *x) for x in res]\n", + "lat = np.array(lat)\n", + "print(f\"[검증 룰 {len(RULES)}종 적용] 대상 {len(rows)}행, 위반 {len(found)}건 [교육용가상자료]\")\n", + "cnt = pd.Series([f[3] for f in found]).value_counts()\n", + "print(\"룰별 위반 건수:\", {k: int(v) for k, v in cnt.items()})\n", + "print(\"위반 예시(3건):\")\n", + "for f in found[:3]:\n", + " print(\" \", f)\n", + "print(f\"룰 처리 지연: 평균 {lat.mean():.3f}ms, 최대 {lat.max():.3f}ms (목표 50ms 이하: {'달성' if lat.max() <= 50 else '미달'}, 환경에 따라 다름)\")\n", + "\n", + "# 3) 1차 결산 집계 Baseline: 부대 x 구분 합계, 구분별 통계\n", + "clean = pd.DataFrame([r for r in make_ledger(2) if r[\"month\"] == \"2026-08\"])\n", + "pv = clean.pivot_table(index=\"unit\", columns=\"cat\", values=\"issue\", aggfunc=\"sum\")\n", + "print(\"\\n[1차 결산 집계 - 8월 불출·소모량 부대별(상위 4개)]\")\n", + "print(pv.head(4).to_string())\n", + "st = pv.agg([\"sum\", \"mean\", \"std\"]).round(1)\n", + "print(\"[구분별 합계·평균·표준편차]\")\n", + "print(st.to_string())\n", + "print(f\"전체 기말 재고 합계 {int(clean['close'].sum())}, 보고 대상 부대 {clean['unit'].nunique()}개\")\n", + "\n", + "# 4) 경계 조건 단위 테스트\n", + "base = {\"month\": \"2026-08\", \"unit\": UNITS[0], \"cat\": \"물자불출\", \"item\": \"유류\", \"open\": 100, \"recv\": 0, \"issue\": 0, \"ret\": 0, \"close\": 100}\n", + "tests = [(\"정상 행\", base, []),\n", + " (\"불출 = 가용량 정확히 일치(V15만 위반)\", {**base, \"issue\": 100, \"close\": 0}, [\"V15\"]),\n", + " (\"불출 = 가용량+1 (V09, V11, V15)\", {**base, \"issue\": 101, \"close\": -1}, [\"V09\", \"V11\", \"V15\"]),\n", + " (\"기말 0 허용\", {**base, \"issue\": 90, \"close\": 10, \"ret\": 0}, []),\n", + " (\"반납 = 불출 경계 허용\", {**base, \"issue\": 50, \"ret\": 50, \"close\": 100}, []),\n", + " (\"탄약 한도 5000 경계\", {**base, \"cat\": \"탄약정산\", \"item\": \"훈련탄A\", \"open\": 9000, \"issue\": 5000, \"close\": 4000}, []),\n", + " (\"탄약 한도 5001 초과\", {**base, \"cat\": \"탄약정산\", \"item\": \"훈련탄A\", \"open\": 9000, \"issue\": 5001, \"close\": 3999}, [\"V13\"]),\n", + " (\"결산월 형식 오류\", {**base, \"month\": \"202608\"}, [\"V12\"]),\n", + " (\"자료형 오류(문자열 수량)\", {**base, \"open\": \"100\"}, [\"V14\"])] # 이 경우 V14 포함 여부만 확인\n", + "ok = 0\n", + "print(\"\\n[경계 조건 단위 테스트]\")\n", + "for name, r, exp in tests:\n", + " got = sorted(x[0] for x in check_row(r))\n", + " p = set(exp) <= set(got) if name.startswith(\"자료형\") else got == exp\n", + " ok += p\n", + " print(f\" - {name}: 기대 {exp} / 결과 {got} -> {'PASS' if p else 'FAIL'}\")\n", + "print(f\"단위 테스트 {ok}/{len(tests)} 통과({ok / len(tests):.0%})\")\n", + "print(f\"목표(룰 15종 이상, 지연 50ms 이하, 단위 테스트 100%): {'달성' if len(RULES) >= 15 and lat.max() <= 50 and ok == len(tests) else '미달'}\")\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 03 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + "[검증 룰 15종 적용] 대상 528행, 위반 13건 [교육용가상자료]\n", + "룰별 위반 건수: {'V08': 6, 'V09': 3, 'V15': 3, 'V07': 1}\n", + "위반 예시(3건):\n", + " ('2026-07', '가상05대대', '급식류', 'V08', '가상규정 제5조', '기말 산식 불일치')\n", + " ('2026-07', '가상05대대', '급식류', 'V09', '가상규정 제6조', '가용량 초과 불출')\n", + " ('2026-07', '가상05대대', '급식류', 'V15', '가상규정 제11조', '불출 비율 과다(90% 초과)')\n", + "룰 처리 지연: 평균 0.008ms, 최대 0.072ms (목표 50ms 이하: 달성, 환경에 따라 다름)\n", + "\n", + "[1차 결산 집계 - 8월 불출·소모량 부대별(상위 4개)]\n", + "cat 물자불출 수리부속 탄약정산\n", + "unit \n", + "가상01대대 378 385 384\n", + "가상02대대 255 427 234\n", + "가상03대대 239 371 168\n", + "가상04대대 305 258 319\n", + "[구분별 합계·평균·표준편차]\n", + "cat 물자불출 수리부속 탄약정산\n", + "sum 8270.0 8184.0 6743.0\n", + "mean 344.6 341.0 281.0\n", + "std 79.3 72.2 75.9\n", + "전체 기말 재고 합계 199291, 보고 대상 부대 24개\n", + "\n", + "[경계 조건 단위 테스트]\n", + " - 정상 행: 기대 [] / 결과 [] -> PASS\n", + " - 불출 = 가용량 정확히 일치(V15만 위반): 기대 ['V15'] / 결과 ['V15'] -> PASS\n", + " - 불출 = 가용량+1 (V09, V11, V15): 기대 ['V09', 'V11', 'V15'] / 결과 ['V09', 'V11', 'V15'] -> PASS\n", + " - 기말 0 허용: 기대 [] / 결과 [] -> PASS\n", + " - 반납 = 불출 경계 허용: 기대 [] / 결과 [] -> PASS\n", + " - 탄약 한도 5000 경계: 기대 [] / 결과 [] -> PASS\n", + " - 탄약 한도 5001 초과: 기대 ['V13'] / 결과 ['V13'] -> PASS\n", + " - 결산월 형식 오류: 기대 ['V12'] / 결과 ['V12'] -> PASS\n", + " - 자료형 오류(문자열 수량): 기대 ['V14'] / 결과 ['V04', 'V08', 'V09', 'V14', 'V15'] -> PASS\n", + "단위 테스트 9/9 통과(100%)\n", + "목표(룰 15종 이상, 지연 50ms 이하, 단위 테스트 100%): 달성\n", + "```\n", + "
\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 04. 데이터 이상치(전월 대비 급증 항목) 자동 감지 및 이상 원인 요약 추천 모듈 고도화\n", + "**소요시간**: 약 3.0시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "전월 대비 급증 항목 이상치 감지 및 이상 원인 요약 추천 모듈을 고도화한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **전월 대비 급증한 품목을 자동 감지하고 이상 원인을 요약해 추천하는 모듈을 고도화한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **품목별 증가율의 로그 값에 중앙값과 MAD 기반 점수를 적용해 급증 품목을 찾는다 과정을 진행하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `src/ai/dat06_intelligence_module.py` 형태의 결과물을 완성한다.\n", + "\n", + "### 🔍 평가 및 검증 기준 (PASS 조건)\n", + "#### 미션 판정\n", + "\n", + "**평가 구분:** 필수\n", + "\n", + "**핵심 평가영역:** 추천 사유의 합리성 및 실무 수용성\n", + "\n", + "#### 미션 PASS 조건\n", + "\n", + "- 정량 기준: 추천/분류 정확도 85% 이상 달성\n", + "\n", + "\n", + "- 진행 기준: AI 추론 모듈 구현 및 벤치마크 테스트 완료\n", + "\n", + "\n", + "- 검증 방법: 과거 승인/처리 완료 데이터와의 추천 일치도 검증\n", + "\n", + "\n", + "#### 채점자 판정 메모\n", + "\n", + "> 단순 코드 실행 여부뿐만 아니라 출력된 수치와 근거 자료가 군 실무 요구사항에 부합하는지 면밀히 확인한다.\n", + "\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "\"\"\"전월 대비 급증한 품목을 로버스트 통계로 자동 감지하고, 훈련·정비 일정 등 맥락 정보와 대조해 이상 원인을 요약·추천하는 모듈을 구현해 감지 정확도와 원인 분류 정확도를 측정한다.\"\"\"\n", + "import numpy as np\n", + "import pandas as pd\n", + "\n", + "# ---------------------------------------------------------------------------\n", + "# [교육용가상자료] 가상 월간 결산 원장 생성기 (실제 부대명·품목·수량·재무 정보 없음)\n", + "# 가상 대대 24개 x (물자 불출 / 수리부속 소모 / 탄약 정산) 품목 원장과 장비 가동 현황을 만든다.\n", + "# 재고 산식: 기말 = 기초 + 수령 + 반납 - 불출, 다음 달 기초 = 이번 달 기말.\n", + "# ---------------------------------------------------------------------------\n", + "UNITS = [f\"가상{i:02d}대대\" for i in range(1, 25)]\n", + "ITEMS = {\"물자불출\": [\"급식류\", \"피복류\", \"유류\", \"정비소모품\"],\n", + " \"수리부속\": [\"필터류\", \"베어링류\", \"전장품\", \"타이어류\"],\n", + " \"탄약정산\": [\"훈련탄A\", \"훈련탄B\", \"모의탄\"]}\n", + "UNIT_CODE = {\"급식류\": \"BX\", \"피복류\": \"EA\", \"유류\": \"L\", \"정비소모품\": \"EA\", \"필터류\": \"EA\", \"베어링류\": \"EA\",\n", + " \"전장품\": \"EA\", \"타이어류\": \"EA\", \"훈련탄A\": \"RD\", \"훈련탄B\": \"RD\", \"모의탄\": \"RD\"}\n", + "LEDGER_COLS = [\"month\", \"unit\", \"cat\", \"item\", \"open\", \"recv\", \"issue\", \"ret\", \"close\"]\n", + "\n", + "\n", + "def make_ledger(n_months=2, seed=60):\n", + " \"\"\"n_months개월 원장(list of dict). 둘째 달 기초는 첫째 달 기말과 같다.\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " rows, prev = [], {}\n", + " for m in range(n_months):\n", + " for u in UNITS:\n", + " for cat, items in ITEMS.items():\n", + " for it in items:\n", + " base = int(rng.integers(80, 400))\n", + " op = prev.get((u, it), base * 3)\n", + " recv = int(rng.integers(0, base))\n", + " issue = int(rng.integers(base // 4, base // 2 + 1))\n", + " ret = int(issue * rng.uniform(0, 0.15))\n", + " cl = op + recv + ret - issue\n", + " rows.append({\"month\": f\"2026-{m + 7:02d}\", \"unit\": u, \"cat\": cat, \"item\": it,\n", + " \"open\": op, \"recv\": recv, \"issue\": issue, \"ret\": ret, \"close\": cl})\n", + " prev[(u, it)] = cl\n", + " return rows\n", + "\n", + "\n", + "def make_equip(n_months=2, seed=61):\n", + " rng = np.random.default_rng(seed)\n", + " out = []\n", + " for m in range(n_months):\n", + " for u in UNITS:\n", + " hold = int(rng.integers(20, 60))\n", + " out.append({\"month\": f\"2026-{m + 7:02d}\", \"unit\": u, \"hold\": hold, \"operable\": int(hold * rng.uniform(0.78, 0.97))})\n", + " return out\n", + "\n", + "# 1) 가상 2개월 데이터 + 정답(숨은 원인) 생성\n", + "led = pd.DataFrame(make_ledger(2))\n", + "p7 = led[led[\"month\"] == \"2026-07\"].set_index([\"unit\", \"item\"])\n", + "p8 = led[led[\"month\"] == \"2026-08\"].set_index([\"unit\", \"item\"])\n", + "d = pd.DataFrame({\"cat\": p8[\"cat\"], \"prev\": p7[\"issue\"].clip(lower=1), \"cur\": p8[\"issue\"]})\n", + "rng = np.random.default_rng(4)\n", + "n = len(d)\n", + "d[\"train_days\"] = rng.integers(0, 5, n) # 해당 부대 월 훈련 일수(맥락)\n", + "d[\"repair_orders\"] = rng.integers(0, 4, n) # 정비 지시서 건수(맥락)\n", + "d[\"special\"] = 0 # 일회성 특별 지급 승인 여부\n", + "d[\"truth\"] = \"정상\"\n", + "d[\"cur\"] = (d[\"prev\"] * rng.uniform(0.7, 1.4, n)).round().astype(int) # 정상 변동(-30%~+40%)\n", + "CAUSES = [\"훈련 증가\", \"정비 집중\", \"입력 자릿수 오류\", \"일회성 특별 지급\", \"원인 불명\"]\n", + "idx = rng.choice(n, 36, replace=False)\n", + "for k, i in enumerate(idx):\n", + " c = CAUSES[k % 5]\n", + " d.iloc[i, d.columns.get_loc(\"truth\")] = c\n", + " f = {\"훈련 증가\": rng.uniform(1.8, 3.0), \"정비 집중\": rng.uniform(1.8, 3.2), \"입력 자릿수 오류\": 10.0,\n", + " \"일회성 특별 지급\": rng.uniform(2.0, 4.0), \"원인 불명\": rng.uniform(1.9, 3.0)}[c]\n", + " d.iloc[i, d.columns.get_loc(\"cur\")] = int(d.iloc[i][\"prev\"] * f)\n", + " if c == \"훈련 증가\":\n", + " d.iloc[i, d.columns.get_loc(\"train_days\")] = rng.integers(8, 14)\n", + " if c == \"정비 집중\":\n", + " d.iloc[i, d.columns.get_loc(\"repair_orders\")] = rng.integers(7, 12)\n", + " if c == \"일회성 특별 지급\":\n", + " d.iloc[i, d.columns.get_loc(\"special\")] = 1\n", + "for i in rng.choice(n, 8, replace=False): # 정상이지만 활동이 많았던 곳(오탐 유도)\n", + " d.iloc[i, d.columns.get_loc(\"train_days\")] = rng.integers(8, 14)\n", + "\n", + "# 2) 감지: 품목별 로그 증가율의 중앙값·MAD 기반 z 점수\n", + "d[\"ratio\"] = d[\"cur\"] / d[\"prev\"]\n", + "lr = np.log(d[\"ratio\"])\n", + "med = lr.groupby(level=\"item\").transform(\"median\")\n", + "mad = (lr - med).abs().groupby(level=\"item\").transform(\"median\").clip(lower=0.05) * 1.4826\n", + "d[\"z\"] = (lr - med) / mad\n", + "d[\"flag\"] = (d[\"z\"] > 2.0) & (d[\"ratio\"] >= 1.6)\n", + "\n", + "# 3) 원인 요약 추천(규칙 기반, 사람이 확인할 후보를 제시)\n", + "def recommend(r):\n", + " if abs(r[\"ratio\"] - 10) < 1.2:\n", + " return \"입력 자릿수 오류\"\n", + " if r[\"special\"]:\n", + " return \"일회성 특별 지급\"\n", + " if r[\"repair_orders\"] >= 6:\n", + " return \"정비 집중\"\n", + " if r[\"train_days\"] >= 8:\n", + " return \"훈련 증가\"\n", + " return \"원인 불명\"\n", + "ACTION = {\"훈련 증가\": \"훈련 계획 대조 후 정상 처리\", \"정비 집중\": \"정비 지시서와 소모 수량 대조\", \"입력 자릿수 오류\": \"제출 부대에 수치 재확인 요청\",\n", + " \"일회성 특별 지급\": \"승인 문서 번호 확인\", \"원인 불명\": \"담당자가 부대에 직접 문의\"}\n", + "d[\"cause\"] = np.where(d[\"flag\"], d.apply(recommend, axis=1), \"정상\")\n", + "\n", + "# 4) 평가\n", + "truth_anom = d[\"truth\"] != \"정상\"\n", + "tp = int((d[\"flag\"] & truth_anom).sum()); fp = int((d[\"flag\"] & ~truth_anom).sum()); fn = int((~d[\"flag\"] & truth_anom).sum())\n", + "prec, rec = tp / max(tp + fp, 1), tp / max(tp + fn, 1)\n", + "acc = float((d[\"flag\"] == truth_anom).mean())\n", + "both = d[d[\"flag\"] & truth_anom]\n", + "cause_acc = float((both[\"cause\"] == both[\"truth\"]).mean())\n", + "total_acc = float(((d[\"cause\"] == d[\"truth\"])).mean())\n", + "print(f\"[이상 감지] 대상 {n}건(부대 24 x 품목 11), 실제 급증 {int(truth_anom.sum())}건 [교육용가상자료]\")\n", + "print(f\"감지 {int(d['flag'].sum())}건: 정확도 {acc:.1%}, 정밀도 {prec:.1%}, 재현율 {rec:.1%} (TP {tp}, FP {fp}, FN {fn})\")\n", + "print(f\"원인 분류 정확도(감지된 실제 급증 중) {cause_acc:.1%}, 전체 분류(정상 포함) 정확도 {total_acc:.1%}\")\n", + "print(\"\\n[원인별 감지·추천 현황]\")\n", + "print(pd.crosstab(both[\"truth\"], both[\"cause\"]).to_string())\n", + "print(\"\\n[이상 원인 요약 추천 예시 - 증가율 상위 5건]\")\n", + "for (u, it), r in d[d[\"flag\"]].sort_values(\"ratio\", ascending=False).head(5).iterrows():\n", + " print(f\" - {u} {it}: 전월 {int(r['prev'])} -> 당월 {int(r['cur'])}({r['ratio']:.1f}배), 추정 원인 '{r['cause']}' -> 조치: {ACTION[r['cause']]}\")\n", + "print(\"추천은 후보 제시이며 최종 원인 판단과 수치 정정은 담당자가 확인한다.\")\n", + "print(f\"\\n목표(추천·분류 정확도 85% 이상): 감지 {'달성' if acc >= 0.85 else '미달'}, 원인 분류 {'달성' if cause_acc >= 0.85 else '미달'}\")\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 04 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + "[이상 감지] 대상 264건(부대 24 x 품목 11), 실제 급증 36건 [교육용가상자료]\n", + "감지 35건: 정확도 99.6%, 정밀도 100.0%, 재현율 97.2% (TP 35, FP 0, FN 1)\n", + "원인 분류 정확도(감지된 실제 급증 중) 100.0%, 전체 분류(정상 포함) 정확도 99.6%\n", + "\n", + "[원인별 감지·추천 현황]\n", + "cause 원인 불명 일회성 특별 지급 입력 자릿수 오류 정비 집중 훈련 증가\n", + "truth \n", + "원인 불명 6 0 0 0 0\n", + "일회성 특별 지급 0 7 0 0 0\n", + "입력 자릿수 오류 0 0 7 0 0\n", + "정비 집중 0 0 0 7 0\n", + "훈련 증가 0 0 0 0 8\n", + "\n", + "[이상 원인 요약 추천 예시 - 증가율 상위 5건]\n", + " - 가상01대대 훈련탄B: 전월 93 -> 당월 930(10.0배), 추정 원인 '입력 자릿수 오류' -> 조치: 제출 부대에 수치 재확인 요청\n", + " - 가상15대대 피복류: 전월 119 -> 당월 1190(10.0배), 추정 원인 '입력 자릿수 오류' -> 조치: 제출 부대에 수치 재확인 요청\n", + " - 가상15대대 정비소모품: 전월 124 -> 당월 1240(10.0배), 추정 원인 '입력 자릿수 오류' -> 조치: 제출 부대에 수치 재확인 요청\n", + " - 가상19대대 모의탄: 전월 174 -> 당월 1740(10.0배), 추정 원인 '입력 자릿수 오류' -> 조치: 제출 부대에 수치 재확인 요청\n", + " - 가상21대대 정비소모품: 전월 146 -> 당월 1460(10.0배), 추정 원인 '입력 자릿수 오류' -> 조치: 제출 부대에 수치 재확인 요청\n", + "추천은 후보 제시이며 최종 원인 판단과 수치 정정은 담당자가 확인한다.\n", + "\n", + "목표(추천·분류 정확도 85% 이상): 감지 달성, 원인 분류 달성\n", + "```\n", + "
\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 05. 정산 결과의 회계적 일관성 검증 및 신뢰도 검증 엔진 개발\n", + "**소요시간**: 약 3.0시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "정산 결과의 회계적 일관성 검증 및 신뢰도 검증 엔진을 개발한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **정산 결과의 회계적 일관성을 검증하고 부대별 신뢰도 점수를 산출하는 엔진을 입력-분석-검토-결과 4개 화면으로 구현한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **재고 산식, 전월 기말과 당월 기초의 연속성, 부대 합계와 분류 합계의 교차 대조, 장비 가동률 범위를 검사하고 오류 8건을 주입해 검출 여부 확인하고 유효성을 점검하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `src/ui/dat06_app.py` 형태의 결과물을 완성한다.\n", + "\n", + "### 🔍 평가 및 검증 기준 (PASS 조건)\n", + "#### 미션 판정\n", + "\n", + "**평가 구분:** 필수\n", + "\n", + "**핵심 평가영역:** 군 실무자 친화적 디자인 및 조작 편의성\n", + "\n", + "#### 미션 PASS 조건\n", + "\n", + "- 정량 기준: 핵심 화면 4종(입력-분석-검토-결과) 구현 완료\n", + "\n", + "\n", + "- 진행 기준: UI 프로토타입 로컬 서버 구동 및 전체 페이지 네비게이션 확인\n", + "\n", + "\n", + "- 검증 방법: 사용성 평가(UX Walkthrough) 및 인터랙션 테스트\n", + "\n", + "\n", + "#### 채점자 판정 메모\n", + "\n", + "> 단순 코드 실행 여부뿐만 아니라 출력된 수치와 근거 자료가 군 실무 요구사항에 부합하는지 면밀히 확인한다.\n", + "\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "\"\"\"결산 결과의 회계적 일관성(재고 산식·월 연속성·합계 교차 대조·가동률 범위)을 검증해 부대별 신뢰도 점수를 산출하고, 입력-분석-검토-결과 4개 화면을 텍스트 화면으로 구현한다.\"\"\"\n", + "import numpy as np\n", + "import pandas as pd\n", + "\n", + "# ---------------------------------------------------------------------------\n", + "# [교육용가상자료] 가상 월간 결산 원장 생성기 (실제 부대명·품목·수량·재무 정보 없음)\n", + "# 가상 대대 24개 x (물자 불출 / 수리부속 소모 / 탄약 정산) 품목 원장과 장비 가동 현황을 만든다.\n", + "# 재고 산식: 기말 = 기초 + 수령 + 반납 - 불출, 다음 달 기초 = 이번 달 기말.\n", + "# ---------------------------------------------------------------------------\n", + "UNITS = [f\"가상{i:02d}대대\" for i in range(1, 25)]\n", + "ITEMS = {\"물자불출\": [\"급식류\", \"피복류\", \"유류\", \"정비소모품\"],\n", + " \"수리부속\": [\"필터류\", \"베어링류\", \"전장품\", \"타이어류\"],\n", + " \"탄약정산\": [\"훈련탄A\", \"훈련탄B\", \"모의탄\"]}\n", + "UNIT_CODE = {\"급식류\": \"BX\", \"피복류\": \"EA\", \"유류\": \"L\", \"정비소모품\": \"EA\", \"필터류\": \"EA\", \"베어링류\": \"EA\",\n", + " \"전장품\": \"EA\", \"타이어류\": \"EA\", \"훈련탄A\": \"RD\", \"훈련탄B\": \"RD\", \"모의탄\": \"RD\"}\n", + "LEDGER_COLS = [\"month\", \"unit\", \"cat\", \"item\", \"open\", \"recv\", \"issue\", \"ret\", \"close\"]\n", + "\n", + "\n", + "def make_ledger(n_months=2, seed=60):\n", + " \"\"\"n_months개월 원장(list of dict). 둘째 달 기초는 첫째 달 기말과 같다.\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " rows, prev = [], {}\n", + " for m in range(n_months):\n", + " for u in UNITS:\n", + " for cat, items in ITEMS.items():\n", + " for it in items:\n", + " base = int(rng.integers(80, 400))\n", + " op = prev.get((u, it), base * 3)\n", + " recv = int(rng.integers(0, base))\n", + " issue = int(rng.integers(base // 4, base // 2 + 1))\n", + " ret = int(issue * rng.uniform(0, 0.15))\n", + " cl = op + recv + ret - issue\n", + " rows.append({\"month\": f\"2026-{m + 7:02d}\", \"unit\": u, \"cat\": cat, \"item\": it,\n", + " \"open\": op, \"recv\": recv, \"issue\": issue, \"ret\": ret, \"close\": cl})\n", + " prev[(u, it)] = cl\n", + " return rows\n", + "\n", + "\n", + "def make_equip(n_months=2, seed=61):\n", + " rng = np.random.default_rng(seed)\n", + " out = []\n", + " for m in range(n_months):\n", + " for u in UNITS:\n", + " hold = int(rng.integers(20, 60))\n", + " out.append({\"month\": f\"2026-{m + 7:02d}\", \"unit\": u, \"hold\": hold, \"operable\": int(hold * rng.uniform(0.78, 0.97))})\n", + " return out\n", + "\n", + "# ---------- 화면 1: 입력 ----------\n", + "led = pd.DataFrame(make_ledger(2))\n", + "eq = pd.DataFrame(make_equip(2))\n", + "cur = led[led[\"month\"] == \"2026-08\"].copy()\n", + "prev = led[led[\"month\"] == \"2026-07\"].set_index([\"unit\", \"item\"])\n", + "rng = np.random.default_rng(5)\n", + "faults = [] # 가상 오류 주입 8건\n", + "for i in rng.choice(cur.index, 8, replace=False):\n", + " kind = [\"산식\", \"연속성\", \"산식\", \"연속성\"][len(faults) % 4]\n", + " if kind == \"산식\":\n", + " cur.loc[i, \"close\"] += int(rng.integers(5, 40))\n", + " else:\n", + " cur.loc[i, \"open\"] += int(rng.integers(5, 40)); cur.loc[i, \"close\"] = cur.loc[i, \"open\"] + cur.loc[i, \"recv\"] + cur.loc[i, \"ret\"] - cur.loc[i, \"issue\"]\n", + " faults.append((cur.loc[i, \"unit\"], cur.loc[i, \"item\"], kind))\n", + "eq2 = eq[eq[\"month\"] == \"2026-08\"].copy()\n", + "eq2.iloc[3, eq2.columns.get_loc(\"operable\")] = eq2.iloc[3][\"hold\"] + 4 # 가동 대수가 보유 대수 초과\n", + "SCREEN1 = f\"원장 {len(cur)}행(월 2026-08), 장비 현황 {len(eq2)}행, 전월 원장 {len(prev)}행 적재 / 양식 검사 통과\"\n", + "print(\"[화면 1. 입력]\", SCREEN1)\n", + "\n", + "# ---------- 화면 2: 분석(일관성 검사 4종) ----------\n", + "issues = []\n", + "for r in cur.itertuples():\n", + " if r.open + r.recv + r.ret - r.issue != r.close:\n", + " issues.append((r.unit, r.item, \"C1 재고 산식 불일치\", abs(r.open + r.recv + r.ret - r.issue - r.close)))\n", + " if prev.loc[(r.unit, r.item), \"close\"] != r.open:\n", + " issues.append((r.unit, r.item, \"C2 전월 기말 != 당월 기초\", abs(prev.loc[(r.unit, r.item), \"close\"] - r.open)))\n", + "unit_sum = cur.groupby(\"unit\")[[\"open\", \"recv\", \"ret\", \"issue\", \"close\"]].sum()\n", + "cat_sum = cur.groupby([\"unit\", \"cat\"])[[\"open\", \"recv\", \"ret\", \"issue\", \"close\"]].sum().groupby(\"unit\").sum()\n", + "for u in unit_sum.index: # C3 교차 대조: 부대 합계 = 분류 합계의 합\n", + " if not (unit_sum.loc[u] == cat_sum.loc[u]).all():\n", + " issues.append((u, \"-\", \"C3 합계 교차 대조 불일치\", 0))\n", + "for r in eq2.itertuples():\n", + " if not (0 <= r.operable <= r.hold):\n", + " issues.append((r.unit, \"장비\", \"C4 가동 대수가 보유 대수 초과\", r.operable - r.hold))\n", + "iss = pd.DataFrame(issues, columns=[\"부대\", \"품목\", \"검사\", \"차이\"])\n", + "print(f\"[화면 2. 분석] 검사 4종 수행, 불일치 {len(iss)}건\")\n", + "print(iss[\"검사\"].value_counts().to_string())\n", + "\n", + "# ---------- 신뢰도 점수 ----------\n", + "score = pd.Series(100.0, index=UNITS)\n", + "W = {\"C1\": 12, \"C2\": 10, \"C3\": 15, \"C4\": 8}\n", + "for r in iss.itertuples():\n", + " score[r.부대] -= W[r.검사[:2]]\n", + "score = score.clip(lower=0)\n", + "grade = pd.cut(score, [-1, 69, 89, 100], labels=[\"C(재제출)\", \"B(검토)\", \"A(양호)\"])\n", + "\n", + "# ---------- 화면 3: 검토 ----------\n", + "print(f\"\\n[화면 3. 검토] 신뢰도 100점 미만 부대와 검토 대기 사항 [교육용가상자료]\")\n", + "low = iss.groupby(\"부대\").agg(건수=(\"검사\", \"count\"), 유형=(\"검사\", lambda s: \", \".join(sorted(set(x[:2] for x in s))))).join(score.rename(\"점수\"))\n", + "print(low.sort_values(\"점수\").to_string())\n", + "print(\"검토자는 불일치 건별로 '수정 요청 / 정상 확인' 중 선택하며, 수정은 제출 부대가 한다(시스템이 값을 임의로 바꾸지 않는다).\")\n", + "\n", + "# ---------- 화면 4: 결과 ----------\n", + "detected = {(u, i) for u, i, k in faults}\n", + "caught = {(r.부대, r.품목) for r in iss.itertuples() if r.검사[:2] in (\"C1\", \"C2\")}\n", + "print(f\"\\n[화면 4. 결과] 부대 {len(score)}개 중 A {int((grade == 'A(양호)').sum())} / B {int((grade == 'B(검토)').sum())} / C {int((grade == 'C(재제출)').sum())}\")\n", + "print(f\"평균 신뢰도 {score.mean():.1f}점, 주입 오류 {len(faults)}건 중 검출 {len(detected & caught)}건({len(detected & caught) / len(faults):.0%}), 가동률 범위 오류 {int((iss['검사'].str[:2] == 'C4').sum())}건\")\n", + "ok = len(detected & caught) == len(faults)\n", + "print(f\"\\n목표(핵심 화면 4종 구현, 일관성 검증 엔진 동작): {'달성' if ok else '미달'} (화면 4종: 입력/분석/검토/결과)\")\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 05 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + "[화면 1. 입력] 원장 264행(월 2026-08), 장비 현황 24행, 전월 원장 264행 적재 / 양식 검사 통과\n", + "[화면 2. 분석] 검사 4종 수행, 불일치 9건\n", + "검사\n", + "C2 전월 기말 != 당월 기초 4\n", + "C1 재고 산식 불일치 4\n", + "C4 가동 대수가 보유 대수 초과 1\n", + "\n", + "[화면 3. 검토] 신뢰도 100점 미만 부대와 검토 대기 사항 [교육용가상자료]\n", + " 건수 유형 점수\n", + "부대 \n", + "가상16대대 2 C1, C2 78.0\n", + "가상12대대 1 C1 88.0\n", + "가상13대대 1 C1 88.0\n", + "가상20대대 1 C1 88.0\n", + "가상01대대 1 C2 90.0\n", + "가상07대대 1 C2 90.0\n", + "가상19대대 1 C2 90.0\n", + "가상04대대 1 C4 92.0\n", + "검토자는 불일치 건별로 '수정 요청 / 정상 확인' 중 선택하며, 수정은 제출 부대가 한다(시스템이 값을 임의로 바꾸지 않는다).\n", + "\n", + "[화면 4. 결과] 부대 24개 중 A 20 / B 4 / C 0\n", + "평균 신뢰도 96.0점, 주입 오류 8건 중 검출 8건(100%), 가동률 범위 오류 1건\n", + "\n", + "목표(핵심 화면 4종 구현, 일관성 검증 엔진 동작): 달성 (화면 4종: 입력/분석/검토/결과)\n", + "```\n", + "
\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 06. 군수 결산 보고서 생성 및 승인/반려 관리 대화형 웹 대시보드 구현\n", + "**소요시간**: 약 2.5시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "군수 결산 보고서 생성 및 승인/반려 대화형 웹 대시보드를 구현한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **결산 보고서 생성과 승인·반려 상태를 관리하는 대시보드를 구현하고, 예외 상황에서도 중단되지 않는 입력 검증기와 에러 핸들러를 갖춘다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **부대별 보고서를 생성해 작성-제출-승인-반려 상태 전이를 관리하고 현황판과 그래프로 보여 준다 과정을 진행하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `src/utils/dat06_validator.py 및 에러 핸들러` 형태의 결과물을 완성한다.\n", + "\n", + "### 🔍 평가 및 검증 기준 (PASS 조건)\n", + "#### 미션 판정\n", + "\n", + "**평가 구분:** 필수\n", + "\n", + "**핵심 평가영역:** 사용자에게 알기 쉬운 에러 메시지 및 조치 가이드 제공\n", + "\n", + "#### 미션 PASS 조건\n", + "\n", + "- 정량 기준: 예외 상황 발생 시 크래시(Crash) 0건, 에러 로그 기록률 100%\n", + "\n", + "\n", + "- 진행 기준: 예외 주입 테스트 10종 통과 및 감사 로그 파일 생성\n", + "\n", + "\n", + "- 검증 방법: 비정상 데이터(결측, 특수문자, 대용량) 스트레스 테스트\n", + "\n", + "\n", + "#### 채점자 판정 메모\n", + "\n", + "> 단순 코드 실행 여부뿐만 아니라 출력된 수치와 근거 자료가 군 실무 요구사항에 부합하는지 면밀히 확인한다.\n", + "\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "\"\"\"부대별 결산 보고서를 생성하고 제출·승인·반려 상태를 관리하는 대시보드(상태 현황판·그래프)를 구현하며, 입력 검증기와 에러 핸들러로 예외 10종을 크래시 없이 로그에 기록하고 사용자 조치 안내를 출력한다.\"\"\"\n", + "import json\n", + "import numpy as np\n", + "import pandas as pd\n", + "import matplotlib.pyplot as plt\n", + "\n", + "# ---------------------------------------------------------------------------\n", + "# [교육용가상자료] 가상 월간 결산 원장 생성기 (실제 부대명·품목·수량·재무 정보 없음)\n", + "# 가상 대대 24개 x (물자 불출 / 수리부속 소모 / 탄약 정산) 품목 원장과 장비 가동 현황을 만든다.\n", + "# 재고 산식: 기말 = 기초 + 수령 + 반납 - 불출, 다음 달 기초 = 이번 달 기말.\n", + "# ---------------------------------------------------------------------------\n", + "UNITS = [f\"가상{i:02d}대대\" for i in range(1, 25)]\n", + "ITEMS = {\"물자불출\": [\"급식류\", \"피복류\", \"유류\", \"정비소모품\"],\n", + " \"수리부속\": [\"필터류\", \"베어링류\", \"전장품\", \"타이어류\"],\n", + " \"탄약정산\": [\"훈련탄A\", \"훈련탄B\", \"모의탄\"]}\n", + "UNIT_CODE = {\"급식류\": \"BX\", \"피복류\": \"EA\", \"유류\": \"L\", \"정비소모품\": \"EA\", \"필터류\": \"EA\", \"베어링류\": \"EA\",\n", + " \"전장품\": \"EA\", \"타이어류\": \"EA\", \"훈련탄A\": \"RD\", \"훈련탄B\": \"RD\", \"모의탄\": \"RD\"}\n", + "LEDGER_COLS = [\"month\", \"unit\", \"cat\", \"item\", \"open\", \"recv\", \"issue\", \"ret\", \"close\"]\n", + "\n", + "\n", + "def make_ledger(n_months=2, seed=60):\n", + " \"\"\"n_months개월 원장(list of dict). 둘째 달 기초는 첫째 달 기말과 같다.\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " rows, prev = [], {}\n", + " for m in range(n_months):\n", + " for u in UNITS:\n", + " for cat, items in ITEMS.items():\n", + " for it in items:\n", + " base = int(rng.integers(80, 400))\n", + " op = prev.get((u, it), base * 3)\n", + " recv = int(rng.integers(0, base))\n", + " issue = int(rng.integers(base // 4, base // 2 + 1))\n", + " ret = int(issue * rng.uniform(0, 0.15))\n", + " cl = op + recv + ret - issue\n", + " rows.append({\"month\": f\"2026-{m + 7:02d}\", \"unit\": u, \"cat\": cat, \"item\": it,\n", + " \"open\": op, \"recv\": recv, \"issue\": issue, \"ret\": ret, \"close\": cl})\n", + " prev[(u, it)] = cl\n", + " return rows\n", + "\n", + "\n", + "def make_equip(n_months=2, seed=61):\n", + " rng = np.random.default_rng(seed)\n", + " out = []\n", + " for m in range(n_months):\n", + " for u in UNITS:\n", + " hold = int(rng.integers(20, 60))\n", + " out.append({\"month\": f\"2026-{m + 7:02d}\", \"unit\": u, \"hold\": hold, \"operable\": int(hold * rng.uniform(0.78, 0.97))})\n", + " return out\n", + "\n", + "LEDGER = pd.DataFrame(make_ledger(2))\n", + "LOG, REPORTS = [], {}\n", + "\n", + "\n", + "class AppError(Exception):\n", + " def __init__(self, code, msg, guide):\n", + " super().__init__(msg); self.code, self.msg, self.guide = code, msg, guide\n", + "\n", + "\n", + "def validate(unit, month, rows):\n", + " if unit not in UNITS:\n", + " raise AppError(\"E01\", f\"존재하지 않는 부대 '{unit}'\", \"부대 목록(가상01~24대대)에서 선택\")\n", + " if month not in set(LEDGER[\"month\"]):\n", + " raise AppError(\"E02\", f\"결산월 '{month}' 자료 없음\", \"2026-07 또는 2026-08 중 선택\")\n", + " if len(rows) == 0:\n", + " raise AppError(\"E03\", \"해당 부대 자료가 비어 있음\", \"제출 부대에 원장 재제출 요청\")\n", + " for c in (\"open\", \"recv\", \"issue\", \"ret\", \"close\"):\n", + " if c not in rows.columns:\n", + " raise AppError(\"E04\", f\"필수 열 '{c}' 누락\", \"표준 양식을 다시 내려받아 작성\")\n", + " if not pd.api.types.is_numeric_dtype(rows[c]):\n", + " raise AppError(\"E05\", f\"'{c}' 열에 숫자가 아닌 값\", \"숫자만 입력(단위 문자 제거)\")\n", + " if (rows[c] < 0).any():\n", + " raise AppError(\"E06\", f\"'{c}' 열에 음수\", \"수량 0 이상으로 수정\")\n", + "\n", + "\n", + "def generate(unit, month, data=None):\n", + " rows = data if data is not None else LEDGER[(LEDGER[\"unit\"] == unit) & (LEDGER[\"month\"] == month)]\n", + " validate(unit, month, rows)\n", + " rid = f\"RPT-{unit[2:4]}-{month[-2:]}\"\n", + " if rid in REPORTS:\n", + " raise AppError(\"E07\", f\"이미 생성된 보고서 {rid}\", \"기존 보고서를 열어 수정·재제출\")\n", + " REPORTS[rid] = {\"unit\": unit, \"month\": month, \"status\": \"작성\", \"issue\": int(rows[\"issue\"].sum()), \"close\": int(rows[\"close\"].sum()),\n", + " \"hist\": [(\"작성\", \"system\")]}\n", + " return rid\n", + "\n", + "\n", + "FLOW = {\"제출\": (\"작성\", \"제출\"), \"승인\": (\"제출\", \"승인\"), \"반려\": (\"제출\", \"반려\"), \"재작성\": (\"반려\", \"작성\")}\n", + "\n", + "\n", + "def act(rid, action, who, reason=\"\"):\n", + " if rid not in REPORTS:\n", + " raise AppError(\"E08\", f\"보고서 {rid} 없음\", \"보고서 번호를 목록에서 확인\")\n", + " if action not in FLOW:\n", + " raise AppError(\"E09\", f\"알 수 없는 처리 '{action}'\", \"제출/승인/반려/재작성 중 선택\")\n", + " need, new = FLOW[action]\n", + " if REPORTS[rid][\"status\"] != need:\n", + " raise AppError(\"E10\", f\"현재 상태 '{REPORTS[rid]['status']}'에서 '{action}' 불가\", f\"'{need}' 상태에서만 가능\")\n", + " if action == \"반려\" and not reason.strip():\n", + " raise AppError(\"E11\", \"반려 사유 없음\", \"반려 사유를 한 줄 이상 입력\")\n", + " REPORTS[rid][\"status\"] = new\n", + " REPORTS[rid][\"hist\"].append((new, who + (f\": {reason}\" if reason else \"\")))\n", + "\n", + "\n", + "def safe(fn, *a, **k):\n", + " \"\"\"에러 핸들러: 모든 예외를 잡아 로그에 남기고 사용자에게 알기 쉬운 메시지와 조치를 돌려준다(크래시 없음).\"\"\"\n", + " try:\n", + " r = fn(*a, **k)\n", + " LOG.append({\"level\": \"INFO\", \"code\": \"OK\", \"msg\": fn.__name__}); return r\n", + " except AppError as e:\n", + " LOG.append({\"level\": \"ERROR\", \"code\": e.code, \"msg\": e.msg, \"guide\": e.guide}); return None\n", + " except Exception as e: # 예상 밖 오류도 기록\n", + " LOG.append({\"level\": \"ERROR\", \"code\": \"E99\", \"msg\": repr(e)[:50], \"guide\": \"운영 담당자에게 로그 전달\"}); return None\n", + "\n", + "\n", + "# 1) 정상 흐름: 12개 부대 보고서 생성 -> 제출 -> 승인 8 / 반려 2 / 대기 2\n", + "rids = [safe(generate, u, \"2026-08\") for u in UNITS[:12]]\n", + "for i, rid in enumerate(rids):\n", + " safe(act, rid, \"제출\", f\"담당자{i % 3 + 1}\")\n", + " if i < 8:\n", + " safe(act, rid, \"승인\", \"보급관\")\n", + " elif i < 10:\n", + " safe(act, rid, \"반려\", \"보급관\", \"기말 재고 산식 재확인 요망\")\n", + "board = pd.DataFrame([{\"보고서\": k, **{x: v[x] for x in (\"unit\", \"status\", \"issue\", \"close\")}} for k, v in REPORTS.items()])\n", + "print(\"[결산 보고서 현황판] [교육용가상자료]\")\n", + "print(board.rename(columns={\"unit\": \"부대\", \"status\": \"상태\", \"issue\": \"불출합\", \"close\": \"기말합\"}).to_string(index=False))\n", + "print(\"상태별:\", board[\"status\"].value_counts().to_dict())\n", + "safe(act, rids[8], \"재작성\", \"담당자\")\n", + "print(\"반려 건 재작성 후 상태:\", REPORTS[rids[8]][\"status\"], \"/ 이력:\", REPORTS[rids[8]][\"hist\"][-3:])\n", + "\n", + "# 2) 예외 주입 10종\n", + "LOG.clear()\n", + "bad = LEDGER[(LEDGER[\"unit\"] == UNITS[20]) & (LEDGER[\"month\"] == \"2026-08\")].copy()\n", + "neg = bad.copy(); neg.iloc[0, neg.columns.get_loc(\"issue\")] = -5\n", + "txt = bad.copy(); txt[\"issue\"] = txt[\"issue\"].astype(str) + \"개\"\n", + "nocol = bad.drop(columns=[\"ret\"])\n", + "cases = [(\"부대명 오류\", generate, (\"가상99대대\", \"2026-08\")), (\"월 오류\", generate, (UNITS[20], \"2027-01\")),\n", + " (\"빈 자료\", generate, (UNITS[20], \"2026-08\", bad.iloc[0:0])), (\"열 누락\", generate, (UNITS[20], \"2026-08\", nocol)),\n", + " (\"숫자 아님\", generate, (UNITS[20], \"2026-08\", txt)), (\"음수\", generate, (UNITS[20], \"2026-08\", neg)),\n", + " (\"중복 생성\", generate, (UNITS[0], \"2026-08\")), (\"없는 보고서\", act, (\"RPT-00-00\", \"승인\", \"보급관\")),\n", + " (\"상태 위반(미제출 승인)\", act, (rids[8], \"승인\", \"보급관\")), (\"반려 사유 누락\", act, (rids[11], \"반려\", \"보급관\", \" \"))]\n", + "crash = 0\n", + "LOG.clear()\n", + "for name, fn, args in cases:\n", + " try:\n", + " safe(fn, *args)\n", + " except BaseException:\n", + " crash += 1\n", + "errs = [l for l in LOG if l[\"level\"] == \"ERROR\"]\n", + "print(\"\\n[예외 주입 결과 - 사용자 안내 메시지]\")\n", + "for (name, _, _), l in zip(cases, errs):\n", + " print(f\" {l['code']} {name:<14} {l['msg']:<28} -> 조치: {l['guide']}\")\n", + "rate = len(errs) / len(cases)\n", + "print(f\"예외 {len(cases)}종 중 로그 기록 {len(errs)}건(기록률 {rate:.0%}), 크래시 {crash}건\")\n", + "print(\"로그 예시:\", json.dumps(errs[0], ensure_ascii=False))\n", + "print(f\"목표(크래시 0건, 에러 로그 기록률 100%): {'달성' if crash == 0 and rate == 1 else '미달'}\")\n", + "\n", + "fig, ax = plt.subplots(1, 2, figsize=(11, 4))\n", + "cnt = board[\"status\"].map({\"승인\": \"approved\", \"반려\": \"rejected\", \"제출\": \"pending\"}).fillna(\"draft\").value_counts()\n", + "ax[0].bar(cnt.index, cnt.values, color=\"tab:blue\"); ax[0].set_title(\"Report status\")\n", + "ax[1].barh(board[\"unit\"].str[2:4], board[\"issue\"], color=\"tab:gray\"); ax[1].set_title(\"Issued quantity by unit (synthetic)\")\n", + "plt.tight_layout(); plt.show()\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 06 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + "[결산 보고서 현황판] [교육용가상자료]\n", + " 보고서 부대 상태 불출합 기말합\n", + "RPT-01-08 가상01대대 승인 1147 8915\n", + "RPT-02-08 가상02대대 승인 916 9139\n", + "RPT-03-08 가상03대대 승인 778 7428\n", + "RPT-04-08 가상04대대 승인 882 8357\n", + "RPT-05-08 가상05대대 승인 920 7718\n", + "RPT-06-08 가상06대대 승인 887 10211\n", + "RPT-07-08 가상07대대 승인 967 8567\n", + "RPT-08-08 가상08대대 승인 877 7738\n", + "RPT-09-08 가상09대대 반려 1056 8987\n", + "RPT-10-08 가상10대대 반려 1015 9029\n", + "RPT-11-08 가상11대대 제출 1130 6845\n", + "RPT-12-08 가상12대대 제출 1266 9330\n", + "상태별: {'승인': 8, '반려': 2, '제출': 2}\n", + "반려 건 재작성 후 상태: 작성 / 이력: [('제출', '담당자3'), ('반려', '보급관: 기말 재고 산식 재확인 요망'), ('작성', '담당자')]\n", + "\n", + "[예외 주입 결과 - 사용자 안내 메시지]\n", + " E01 부대명 오류 존재하지 않는 부대 '가상99대대' -> 조치: 부대 목록(가상01~24대대)에서 선택\n", + " E02 월 오류 결산월 '2027-01' 자료 없음 -> 조치: 2026-07 또는 2026-08 중 선택\n", + " E03 빈 자료 해당 부대 자료가 비어 있음 -> 조치: 제출 부대에 원장 재제출 요청\n", + " E04 열 누락 필수 열 'ret' 누락 -> 조치: 표준 양식을 다시 내려받아 작성\n", + " E05 숫자 아님 'issue' 열에 숫자가 아닌 값 -> 조치: 숫자만 입력(단위 문자 제거)\n", + " E06 음수 'issue' 열에 음수 -> 조치: 수량 0 이상으로 수정\n", + " E07 중복 생성 이미 생성된 보고서 RPT-01-08 -> 조치: 기존 보고서를 열어 수정·재제출\n", + " E08 없는 보고서 보고서 RPT-00-00 없음 -> 조치: 보고서 번호를 목록에서 확인\n", + " E10 상태 위반(미제출 승인) 현재 상태 '작성'에서 '승인' 불가 -> 조치: '제출' 상태에서만 가능\n", + " E11 반려 사유 누락 반려 사유 없음 -> 조치: 반려 사유를 한 줄 이상 입력\n", + "예외 10종 중 로그 기록 10건(기록률 100%), 크래시 0건\n", + "로그 예시: {\"level\": \"ERROR\", \"code\": \"E01\", \"msg\": \"존재하지 않는 부대 '가상99대대'\", \"guide\": \"부대 목록(가상01~24대대)에서 선택\"}\n", + "목표(크래시 0건, 에러 로그 기록률 100%): 달성\n", + "```\n", + "
\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 07. 결산 보고서 최종 발간 전 군수과장/보급관 3단계 HITL 전자 결재선 연계 설계\n", + "**소요시간**: 약 3.0시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "결산 보고서 최종 발간 전 군수과장/보급관 3단계 HITL 결재선을 설계한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **결산 보고서 발간 전 담당자 기안, 보급관 검토, 군수과장 승인의 3단계 사람 결재선을 설계하고 이력의 무결성과 권한 통제를 검증한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **결재 단계별 역할과 순서를 설정으로 고정하고, 순서 위반, 본인 결재, 근거 미기재, 미등록 사용자를 차단 수행하고 검증하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `src/workflow/dat06_approval_system.py` 형태의 결과물을 완성한다.\n", + "\n", + "### 🔍 평가 및 검증 기준 (PASS 조건)\n", + "#### 미션 판정\n", + "\n", + "**평가 구분:** 필수\n", + "\n", + "**핵심 평가영역:** 군 보안 규정 및 행정 절차 부합성\n", + "\n", + "#### 미션 PASS 조건\n", + "\n", + "- 정량 기준: 이력 기록 무결성 100%, 승인 권한 우회 시도 차단율 100%\n", + "\n", + "\n", + "- 진행 기준: 결재선 승인/반려 시뮬레이션 및 이력 조회 화면 구현\n", + "\n", + "\n", + "- 검증 방법: 권한별 접근 제어(RBAC) 테스트\n", + "\n", + "\n", + "#### 채점자 판정 메모\n", + "\n", + "> 단순 코드 실행 여부뿐만 아니라 출력된 수치와 근거 자료가 군 실무 요구사항에 부합하는지 면밀히 확인한다.\n", + "\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "\"\"\"결산 보고서 발간 전 담당자 기안, 보급관 검토, 군수과장 최종 승인의 3단계 사람 결재선을 설계하고, 해시 체인 감사 이력의 무결성과 권한 우회 시도 10종의 차단을 검증한다. 자동 발간은 허용하지 않는다.\"\"\"\n", + "import hashlib\n", + "import json\n", + "import numpy as np\n", + "import pandas as pd\n", + "\n", + "# [교육용가상자료] 가상 직책·인물(가명). 실제 전자 결재 체계와 무관한 학습용 모의\n", + "USERS = {\"kim\": \"담당자\", \"lee\": \"담당자\", \"park\": \"보급관\", \"choi\": \"군수과장\", \"han\": \"열람자\"}\n", + "LINE = [(\"기안\", \"담당자\"), (\"검토\", \"보급관\"), (\"승인\", \"군수과장\")] # 순서 고정 3단계\n", + "AUDIT = []\n", + "\n", + "\n", + "def _h(prev, rec):\n", + " return hashlib.sha256((prev + json.dumps(rec, ensure_ascii=False, sort_keys=True)).encode()).hexdigest()[:16]\n", + "\n", + "\n", + "def log(doc, user, action, result, note=\"\"):\n", + " rec = {\"seq\": len(AUDIT) + 1, \"doc\": doc, \"user\": user, \"role\": USERS.get(user, \"미등록\"), \"action\": action, \"result\": result, \"note\": note}\n", + " rec[\"hash\"] = _h(AUDIT[-1][\"hash\"] if AUDIT else \"GENESIS\", {k: v for k, v in rec.items() if k != \"hash\"})\n", + " AUDIT.append(rec)\n", + "\n", + "\n", + "def verify_chain():\n", + " prev, ok = \"GENESIS\", 0\n", + " for r in AUDIT:\n", + " body = {k: v for k, v in r.items() if k != \"hash\"}\n", + " ok += (r[\"hash\"] == _h(prev, body)); prev = r[\"hash\"]\n", + " return ok, len(AUDIT)\n", + "\n", + "\n", + "DOCS = {}\n", + "\n", + "\n", + "def step(doc, user, action, comment=\"\"):\n", + " \"\"\"결재 처리. 권한·순서·본인 결재 금지·근거 기재를 모두 통과해야 기록되고, 실패는 차단 기록을 남긴다.\"\"\"\n", + " d = DOCS.setdefault(doc, {\"stage\": 0, \"signers\": [], \"published\": False})\n", + " role = USERS.get(user)\n", + " if role is None:\n", + " return log(doc, user, action, \"차단\", \"미등록 사용자\"), False\n", + " if d[\"stage\"] >= len(LINE):\n", + " return log(doc, user, action, \"차단\", \"이미 결재 완료\"), False\n", + " need_act, need_role = LINE[d[\"stage\"]]\n", + " if action != need_act:\n", + " return log(doc, user, action, \"차단\", f\"순서 위반: 현재 단계 '{need_act}'\"), False\n", + " if role != need_role:\n", + " return log(doc, user, action, \"차단\", f\"권한 없음: 역할 '{role}'은(는) '{action}' 불가({need_role} 필요)\"), False\n", + " if user in d[\"signers\"]:\n", + " return log(doc, user, action, \"차단\", \"본인이 앞 단계에 서명한 문서는 다음 단계를 결재할 수 없음\"), False\n", + " if action != \"기안\" and len(comment.strip()) < 5:\n", + " return log(doc, user, action, \"차단\", \"검토 근거(5자 이상) 미기재\"), False\n", + " d[\"stage\"] += 1; d[\"signers\"].append(user)\n", + " log(doc, user, action, \"승인\", comment or \"기안 상신\")\n", + " return None, True\n", + "\n", + "\n", + "def publish(doc, user):\n", + " d = DOCS.get(doc)\n", + " if not d or d[\"stage\"] < len(LINE):\n", + " log(doc, user, \"발간\", \"차단\", \"3단계 결재 미완료(자동 발간 금지)\"); return False\n", + " if USERS.get(user) != \"담당자\":\n", + " log(doc, user, \"발간\", \"차단\", \"발간은 담당자가 결재 완료 후 수동 실행\"); return False\n", + " d[\"published\"] = True; log(doc, user, \"발간\", \"승인\", \"결재 3단계 완료 후 수동 발간\"); return True\n", + "\n", + "\n", + "# 1) 정상 결재 흐름 2건\n", + "for doc, drafter in ((\"RPT-2026-08-A\", \"kim\"), (\"RPT-2026-08-B\", \"lee\")):\n", + " step(doc, drafter, \"기안\")\n", + " step(doc, \"park\", \"검토\", \"재고 산식 및 전월 연속성 확인 완료\")\n", + " step(doc, \"choi\", \"승인\", \"보급관 검토 의견 확인, 발간 승인\")\n", + " publish(doc, drafter)\n", + "\n", + "# 2) 권한 우회 시도 10종 - 결재가 진행되는 순서에 맞춰 시도하고, 모두 차단되어야 한다\n", + "blocked, total = 0, 0\n", + "print(\"[권한 우회 시도 결과] [교육용가상자료]\")\n", + "\n", + "\n", + "def attempt(name, fn):\n", + " global blocked, total\n", + " n = len(AUDIT); fn(); r = AUDIT[-1]\n", + " ok = len(AUDIT) == n + 1 and r[\"result\"] == \"차단\"\n", + " blocked += ok; total += 1\n", + " print(f\" - {name}: {r['result']} ({r['note']})\")\n", + "\n", + "\n", + "attempt(\"미등록 사용자 기안\", lambda: step(\"RPT-ATT\", \"hacker\", \"기안\"))\n", + "attempt(\"열람자가 기안\", lambda: step(\"RPT-ATT\", \"han\", \"기안\"))\n", + "attempt(\"기안 전에 군수과장이 승인(단계 건너뛰기)\", lambda: step(\"RPT-ATT\", \"choi\", \"승인\", \"바로 승인합니다 확인\"))\n", + "step(\"RPT-ATT\", \"kim\", \"기안\")\n", + "attempt(\"기안자 본인이 검토까지 처리\", lambda: step(\"RPT-ATT\", \"kim\", \"검토\", \"본인이 검토합니다 확인\"))\n", + "attempt(\"다른 담당자가 검토 단계 처리\", lambda: step(\"RPT-ATT\", \"lee\", \"검토\", \"다른 담당자 검토 시도 확인\"))\n", + "attempt(\"검토 근거 없이 보급관 검토\", lambda: step(\"RPT-ATT\", \"park\", \"검토\", \"\"))\n", + "attempt(\"보급관 검토 전에 군수과장이 승인\", lambda: step(\"RPT-ATT\", \"choi\", \"승인\", \"보급관 단계를 생략한 승인 확인\"))\n", + "attempt(\"결재 미완료 상태에서 발간\", lambda: publish(\"RPT-ATT\", \"kim\"))\n", + "step(\"RPT-ATT\", \"park\", \"검토\", \"산식 검증 결과 이상 없음 확인\")\n", + "step(\"RPT-ATT\", \"choi\", \"승인\", \"최종 승인합니다 확인\")\n", + "attempt(\"완결된 문서 재승인 시도\", lambda: step(\"RPT-ATT\", \"choi\", \"승인\", \"한 번 더 승인 시도 확인\"))\n", + "attempt(\"보급관이 발간 시도\", lambda: publish(\"RPT-ATT\", \"park\"))\n", + "print(f\"우회 시도 {total}건 중 차단 {blocked}건({blocked / total:.0%})\")\n", + "\n", + "# 3) 이력 무결성: 정상 체인 검증 후 위·변조 시험\n", + "ok, tot = verify_chain()\n", + "print(f\"\\n이력 {tot}건 해시 체인 검증: {ok}/{tot} 일치({ok / tot:.0%})\")\n", + "k = next(i for i, r in enumerate(AUDIT) if r[\"result\"] == \"차단\")\n", + "saved = AUDIT[k][\"result\"]\n", + "AUDIT[k][\"result\"] = \"승인\" # 차단 기록을 승인으로 위조\n", + "ok2, _ = verify_chain()\n", + "AUDIT[k][\"result\"] = saved\n", + "print(f\"위조 시험: {k + 1}번 기록 변조 시 검증 {ok2}/{tot} -> 변조 탐지 {'성공' if ok2 < tot else '실패'}, 원복 후 {verify_chain()[0]}/{tot}\")\n", + "st = pd.DataFrame(AUDIT).groupby(\"doc\")[\"result\"].value_counts().unstack(fill_value=0)\n", + "print(\"\\n[문서별 결재 이력 요약]\")\n", + "print(st.to_string())\n", + "pubs = [k for k, v in DOCS.items() if v[\"published\"]]\n", + "print(\"발간 완료 문서:\", pubs, \"/ 우회 시험 문서 RPT-ATT는 발간되지 않음 / 자동 발간 0건(발간은 담당자 수동 실행)\")\n", + "print(f\"목표(이력 무결성 100%, 우회 차단율 100%): {'달성' if ok == tot and blocked == total and ok2 < tot else '미달'}\")\n", + "print(\"실제 결재와 보안 규정은 부대 지침과 지휘계통에 따른다. 본 코드는 학습용 모의이다.\")\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 07 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + "[권한 우회 시도 결과] [교육용가상자료]\n", + " - 미등록 사용자 기안: 차단 (미등록 사용자)\n", + " - 열람자가 기안: 차단 (권한 없음: 역할 '열람자'은(는) '기안' 불가(담당자 필요))\n", + " - 기안 전에 군수과장이 승인(단계 건너뛰기): 차단 (순서 위반: 현재 단계 '기안')\n", + " - 기안자 본인이 검토까지 처리: 차단 (권한 없음: 역할 '담당자'은(는) '검토' 불가(보급관 필요))\n", + " - 다른 담당자가 검토 단계 처리: 차단 (권한 없음: 역할 '담당자'은(는) '검토' 불가(보급관 필요))\n", + " - 검토 근거 없이 보급관 검토: 차단 (검토 근거(5자 이상) 미기재)\n", + " - 보급관 검토 전에 군수과장이 승인: 차단 (순서 위반: 현재 단계 '검토')\n", + " - 결재 미완료 상태에서 발간: 차단 (3단계 결재 미완료(자동 발간 금지))\n", + " - 완결된 문서 재승인 시도: 차단 (이미 결재 완료)\n", + " - 보급관이 발간 시도: 차단 (발간은 담당자가 결재 완료 후 수동 실행)\n", + "우회 시도 10건 중 차단 10건(100%)\n", + "\n", + "이력 21건 해시 체인 검증: 21/21 일치(100%)\n", + "위조 시험: 9번 기록 변조 시 검증 20/21 -> 변조 탐지 성공, 원복 후 21/21\n", + "\n", + "[문서별 결재 이력 요약]\n", + "result 승인 차단\n", + "doc \n", + "RPT-2026-08-A 4 0\n", + "RPT-2026-08-B 4 0\n", + "RPT-ATT 3 10\n", + "발간 완료 문서: ['RPT-2026-08-A', 'RPT-2026-08-B'] / 우회 시험 문서 RPT-ATT는 발간되지 않음 / 자동 발간 0건(발간은 담당자 수동 실행)\n", + "목표(이력 무결성 100%, 우회 차단율 100%): 달성\n", + "실제 결재와 보안 규정은 부대 지침과 지휘계통에 따른다. 본 코드는 학습용 모의이다.\n", + "```\n", + "
\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 08. 수십 개 예하 부대 월간 결산 보고서 원클릭 일괄 생성 배치(Batch) 스트레스 테스트\n", + "**소요시간**: 약 3.5시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "수십 개 예하 부대 월간 결산 보고서 일괄 생성 배치 스트레스 테스트를 수행한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **수십 개 예하 부대의 월간 결산 보고서를 원클릭으로 일괄 생성하는 배치를 만들고 장애·손상 자료를 주입해 스트레스 시험을 수행한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **가상 부대 40개 x 12개월, 480건 보고서를 한 번에 생성 수행하고 검증하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `src/pipeline/dat06_batch_runner.py` 형태의 결과물을 완성한다.\n", + "\n", + "### 🔍 평가 및 검증 기준 (PASS 조건)\n", + "#### 미션 판정\n", + "\n", + "**평가 구분:** 필수\n", + "\n", + "**핵심 평가영역:** 수작업 대비 업무 소요 시간 80% 이상 절감 효과\n", + "\n", + "#### 미션 PASS 조건\n", + "\n", + "- 정량 기준: 전체 배치 파이프라인 실행 시간 5분 이내, 자동화 성공률 99% 이상\n", + "\n", + "\n", + "- 진행 기준: 스케줄러 연동 테스트 및 자동 실행 확인\n", + "\n", + "\n", + "- 검증 방법: 실제 월간 업무 데이터 1년치 일괄 배치 시뮬레이션\n", + "\n", + "\n", + "#### 채점자 판정 메모\n", + "\n", + "> 단순 코드 실행 여부뿐만 아니라 출력된 수치와 근거 자료가 군 실무 요구사항에 부합하는지 면밀히 확인한다.\n", + "\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "\"\"\"40개 가상 예하 부대 x 12개월(480건) 월간 결산 보고서를 한 번에 생성하는 배치를 실행하며, 일시 장애와 손상 자료를 주입해 재시도·격리 처리를 확인하고 전체 실행 시간, 자동화 성공률, 수작업 대비 절감률을 측정한다.\"\"\"\n", + "import time\n", + "import numpy as np\n", + "import pandas as pd\n", + "\n", + "# ---------------------------------------------------------------------------\n", + "# [교육용가상자료] 가상 월간 결산 원장 생성기 (실제 부대명·품목·수량·재무 정보 없음)\n", + "# 가상 대대 24개 x (물자 불출 / 수리부속 소모 / 탄약 정산) 품목 원장과 장비 가동 현황을 만든다.\n", + "# 재고 산식: 기말 = 기초 + 수령 + 반납 - 불출, 다음 달 기초 = 이번 달 기말.\n", + "# ---------------------------------------------------------------------------\n", + "UNITS = [f\"가상{i:02d}대대\" for i in range(1, 25)]\n", + "ITEMS = {\"물자불출\": [\"급식류\", \"피복류\", \"유류\", \"정비소모품\"],\n", + " \"수리부속\": [\"필터류\", \"베어링류\", \"전장품\", \"타이어류\"],\n", + " \"탄약정산\": [\"훈련탄A\", \"훈련탄B\", \"모의탄\"]}\n", + "UNIT_CODE = {\"급식류\": \"BX\", \"피복류\": \"EA\", \"유류\": \"L\", \"정비소모품\": \"EA\", \"필터류\": \"EA\", \"베어링류\": \"EA\",\n", + " \"전장품\": \"EA\", \"타이어류\": \"EA\", \"훈련탄A\": \"RD\", \"훈련탄B\": \"RD\", \"모의탄\": \"RD\"}\n", + "LEDGER_COLS = [\"month\", \"unit\", \"cat\", \"item\", \"open\", \"recv\", \"issue\", \"ret\", \"close\"]\n", + "\n", + "\n", + "def make_ledger(n_months=2, seed=60):\n", + " \"\"\"n_months개월 원장(list of dict). 둘째 달 기초는 첫째 달 기말과 같다.\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " rows, prev = [], {}\n", + " for m in range(n_months):\n", + " for u in UNITS:\n", + " for cat, items in ITEMS.items():\n", + " for it in items:\n", + " base = int(rng.integers(80, 400))\n", + " op = prev.get((u, it), base * 3)\n", + " recv = int(rng.integers(0, base))\n", + " issue = int(rng.integers(base // 4, base // 2 + 1))\n", + " ret = int(issue * rng.uniform(0, 0.15))\n", + " cl = op + recv + ret - issue\n", + " rows.append({\"month\": f\"2026-{m + 7:02d}\", \"unit\": u, \"cat\": cat, \"item\": it,\n", + " \"open\": op, \"recv\": recv, \"issue\": issue, \"ret\": ret, \"close\": cl})\n", + " prev[(u, it)] = cl\n", + " return rows\n", + "\n", + "\n", + "def make_equip(n_months=2, seed=61):\n", + " rng = np.random.default_rng(seed)\n", + " out = []\n", + " for m in range(n_months):\n", + " for u in UNITS:\n", + " hold = int(rng.integers(20, 60))\n", + " out.append({\"month\": f\"2026-{m + 7:02d}\", \"unit\": u, \"hold\": hold, \"operable\": int(hold * rng.uniform(0.78, 0.97))})\n", + " return out\n", + "\n", + "UNITS = [f\"가상{i:02d}대대\" for i in range(1, 41)] # 배치용으로 40개 부대로 확장\n", + "t_all = time.perf_counter()\n", + "rows = make_ledger(12, seed=8)\n", + "led = pd.DataFrame(rows)\n", + "lab = {f\"2026-{i + 7:02d}\": f\"{2026 + (i + 6) // 12}-{(i + 6) % 12 + 1:02d}\" for i in range(12)} # 2026-07 ~ 2027-06\n", + "led[\"month\"] = led[\"month\"].map(lab)\n", + "months = sorted(led[\"month\"].unique())\n", + "print(f\"[배치 입력] 부대 {len(UNITS)}개 x {len(months)}개월 = {len(UNITS) * len(months)}건, 원장 {len(led)}행 [교육용가상자료]\")\n", + "\n", + "# 장애 주입: 일시 장애(재시도로 복구) 9건, 손상 자료(격리) 3건\n", + "rng = np.random.default_rng(80)\n", + "keys = [(u, m) for u in UNITS for m in months]\n", + "pick = rng.choice(len(keys), 12, replace=False)\n", + "transient = {keys[i] for i in pick[:9]}\n", + "corrupt = {keys[i] for i in pick[9:]}\n", + "for (u, m) in corrupt: # 기말 수치를 훼손(산식 불일치)\n", + " i = led[(led[\"unit\"] == u) & (led[\"month\"] == m)].index[0]\n", + " led.loc[i, \"close\"] = led.loc[i, \"close\"] + 999\n", + "attempts_log = {}\n", + "\n", + "\n", + "def build_report(u, m, g):\n", + " k = (u, m)\n", + " attempts_log[k] = attempts_log.get(k, 0) + 1\n", + " if k in transient and attempts_log[k] == 1:\n", + " raise TimeoutError(\"저장소 응답 지연(일시 장애)\")\n", + " bad = (g[\"open\"] + g[\"recv\"] + g[\"ret\"] - g[\"issue\"] != g[\"close\"]).sum()\n", + " if bad:\n", + " raise ValueError(f\"재고 산식 불일치 {int(bad)}건\")\n", + " s = g.groupby(\"cat\")[[\"issue\", \"close\"]].sum()\n", + " return f\"{u} {m} 불출 {int(s['issue'].sum())} 기말 {int(s['close'].sum())} (항목 {len(g)})\"\n", + "\n", + "\n", + "done, quarantine, retries = {}, [], 0\n", + "t0 = time.perf_counter()\n", + "for (u, m), g in led.groupby([\"unit\", \"month\"]):\n", + " for attempt in range(1, 4): # 최대 3회 재시도\n", + " try:\n", + " done[(u, m)] = build_report(u, m, g)\n", + " break\n", + " except TimeoutError:\n", + " retries += 1\n", + " except ValueError as ex: # 자료 오류는 재시도해도 소용없으므로 즉시 격리하고 다음 건으로 진행\n", + " quarantine.append((u, m, str(ex)))\n", + " break\n", + "run_s = time.perf_counter() - t0\n", + "total = len(keys)\n", + "succ = len(done) / total\n", + "print(f\"\\n[배치 실행] 생성 {len(done)}건, 격리 {len(quarantine)}건, 재시도 {retries}회\")\n", + "for q in quarantine:\n", + " print(\" 격리(담당자 확인 필요):\", q)\n", + "print(f\"보고서 생성 순수 실행 시간 {run_s:.2f}초, 전체(자료 생성 포함) {time.perf_counter() - t_all:.2f}초 (목표 5분 이내: {'달성' if time.perf_counter() - t_all < 300 else '미달'})\")\n", + "print(f\"자동화 성공률 {succ:.1%} (일시 장애 {len(transient)}건은 재시도로 자동 복구, 손상 자료는 격리 후 보고)\")\n", + "\n", + "# 수작업 대비 절감(가정값): 부대 보고서 1건 수작업 25분(미션 1 AS-IS 610분/24개 부대 기준), 자동화 후 사람 검토 2분/건, 격리 건 수동 처리 15분\n", + "manual = total * 25\n", + "auto = total * 2 + len(quarantine) * 15 + run_s / 60\n", + "print(f\"\\n[업무 소요 비교(가정값)] 수작업 {manual}분({manual / 60:.0f}시간) vs 자동화 {auto:.0f}분({auto / 60:.1f}시간) -> 절감 {1 - auto / manual:.1%}\")\n", + "\n", + "# 스트레스: 부하 3배(1,440건) 반복 실행의 처리량\n", + "big = list(led.groupby([\"unit\", \"month\"]))\n", + "t0 = time.perf_counter(); n = 0\n", + "for _ in range(3):\n", + " for (u, m), g in big:\n", + " if (u, m) in corrupt:\n", + " continue\n", + " attempts_log[(u, m)] = 9 # 일시 장애 주입 해제(처리량 측정 목적)\n", + " build_report(u, m, g); n += 1\n", + "dt = time.perf_counter() - t0\n", + "print(f\"부하 시험 {n}건 처리 {dt:.2f}초 (초당 {n / dt:.0f}건), 오류 0건\")\n", + "print(f\"\\n목표(전체 5분 이내, 자동화 성공률 99% 이상, 수작업 대비 80% 이상 절감): \"\n", + " f\"{'달성' if run_s < 300 and succ >= 0.99 and 1 - auto / manual >= 0.8 else '미달'}\")\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 08 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + "[배치 입력] 부대 40개 x 12개월 = 480건, 원장 5280행 [교육용가상자료]\n", + "\n", + "[배치 실행] 생성 477건, 격리 3건, 재시도 9회\n", + " 격리(담당자 확인 필요): ('가상10대대', '2027-05', '재고 산식 불일치 1건')\n", + " 격리(담당자 확인 필요): ('가상24대대', '2027-02', '재고 산식 불일치 1건')\n", + " 격리(담당자 확인 필요): ('가상36대대', '2026-12', '재고 산식 불일치 1건')\n", + "보고서 생성 순수 실행 시간 1.06초, 전체(자료 생성 포함) 1.13초 (목표 5분 이내: 달성)\n", + "자동화 성공률 99.4% (일시 장애 9건은 재시도로 자동 복구, 손상 자료는 격리 후 보고)\n", + "\n", + "[업무 소요 비교(가정값)] 수작업 12000분(200시간) vs 자동화 1005분(16.8시간) -> 절감 91.6%\n", + "부하 시험 1431건 처리 3.63초 (초당 394건), 오류 0건\n", + "\n", + "목표(전체 5분 이내, 자동화 성공률 99% 이상, 수작업 대비 80% 이상 절감): 달성\n", + "```\n", + "
\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 09. AAR 기반 결산 수치 오집계 사례 분석 및 집계 룰셋/템플릿 보정\n", + "**소요시간**: 약 3.0시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "AAR 기반 결산 수치 오집계 사례를 분석하고 룰셋을 보정한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **AAR 사례의 결산 수치 오집계 5유형을 재현·분석하고 집계 룰셋과 템플릿을 보정한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **중복 제출, 단위 혼용(kL), 정정 문서 누락, 박스·발 혼용, 품목 별칭 누락의 5유형을 가상 제출 자료에 심어 룰셋 v1의 오집계를 재현 수행하고 검증하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `docs/dat06_uat_feedback_report.md 및 패치 내역` 형태의 결과물을 완성한다.\n", + "\n", + "### 🔍 평가 및 검증 기준 (PASS 조건)\n", + "#### 미션 판정\n", + "\n", + "**평가 구분:** 필수\n", + "\n", + "**핵심 평가영역:** 실무 업무 생산성 기여도 및 현장 적응성\n", + "\n", + "#### 미션 PASS 조건\n", + "\n", + "- 정량 기준: UAT 체크리스트 만족도 90점 이상, 개선 요구 5건 반영 완료\n", + "\n", + "\n", + "- 진행 기준: UAT 결과 보고서 작성 및 최종 버그 픽스 완료\n", + "\n", + "\n", + "- 검증 방법: 실제 업무 시나리오 기반 최종 검수 테스트\n", + "\n", + "\n", + "#### 채점자 판정 메모\n", + "\n", + "> 단순 코드 실행 여부뿐만 아니라 출력된 수치와 근거 자료가 군 실무 요구사항에 부합하는지 면밀히 확인한다.\n", + "\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "\"\"\"사후검토(AAR) 사례에서 결산 수치 오집계 5유형(중복 행, 단위 혼용, 정정 누락, 박스·발 혼용, 품목 별칭 누락)을 재현해 원인을 분석하고 집계 룰셋 v1을 v2로 보정한 뒤, 재집계 결과와 UAT 체크리스트 점수, 개선 요구 반영 현황, 패치 내역을 출력한다.\"\"\"\n", + "import numpy as np\n", + "import pandas as pd\n", + "\n", + "# ---------------------------------------------------------------------------\n", + "# [교육용가상자료] 가상 월간 결산 원장 생성기 (실제 부대명·품목·수량·재무 정보 없음)\n", + "# 가상 대대 24개 x (물자 불출 / 수리부속 소모 / 탄약 정산) 품목 원장과 장비 가동 현황을 만든다.\n", + "# 재고 산식: 기말 = 기초 + 수령 + 반납 - 불출, 다음 달 기초 = 이번 달 기말.\n", + "# ---------------------------------------------------------------------------\n", + "UNITS = [f\"가상{i:02d}대대\" for i in range(1, 25)]\n", + "ITEMS = {\"물자불출\": [\"급식류\", \"피복류\", \"유류\", \"정비소모품\"],\n", + " \"수리부속\": [\"필터류\", \"베어링류\", \"전장품\", \"타이어류\"],\n", + " \"탄약정산\": [\"훈련탄A\", \"훈련탄B\", \"모의탄\"]}\n", + "UNIT_CODE = {\"급식류\": \"BX\", \"피복류\": \"EA\", \"유류\": \"L\", \"정비소모품\": \"EA\", \"필터류\": \"EA\", \"베어링류\": \"EA\",\n", + " \"전장품\": \"EA\", \"타이어류\": \"EA\", \"훈련탄A\": \"RD\", \"훈련탄B\": \"RD\", \"모의탄\": \"RD\"}\n", + "LEDGER_COLS = [\"month\", \"unit\", \"cat\", \"item\", \"open\", \"recv\", \"issue\", \"ret\", \"close\"]\n", + "\n", + "\n", + "def make_ledger(n_months=2, seed=60):\n", + " \"\"\"n_months개월 원장(list of dict). 둘째 달 기초는 첫째 달 기말과 같다.\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " rows, prev = [], {}\n", + " for m in range(n_months):\n", + " for u in UNITS:\n", + " for cat, items in ITEMS.items():\n", + " for it in items:\n", + " base = int(rng.integers(80, 400))\n", + " op = prev.get((u, it), base * 3)\n", + " recv = int(rng.integers(0, base))\n", + " issue = int(rng.integers(base // 4, base // 2 + 1))\n", + " ret = int(issue * rng.uniform(0, 0.15))\n", + " cl = op + recv + ret - issue\n", + " rows.append({\"month\": f\"2026-{m + 7:02d}\", \"unit\": u, \"cat\": cat, \"item\": it,\n", + " \"open\": op, \"recv\": recv, \"issue\": issue, \"ret\": ret, \"close\": cl})\n", + " prev[(u, it)] = cl\n", + " return rows\n", + "\n", + "\n", + "def make_equip(n_months=2, seed=61):\n", + " rng = np.random.default_rng(seed)\n", + " out = []\n", + " for m in range(n_months):\n", + " for u in UNITS:\n", + " hold = int(rng.integers(20, 60))\n", + " out.append({\"month\": f\"2026-{m + 7:02d}\", \"unit\": u, \"hold\": hold, \"operable\": int(hold * rng.uniform(0.78, 0.97))})\n", + " return out\n", + "\n", + "# 1) 가상 제출 레코드 생성: 정답(순불출 = 불출 - 반납)과 부대가 실제로 낸 제출 형태(오집계 유발 요인 포함)\n", + "led = pd.DataFrame(make_ledger(1))\n", + "rng = np.random.default_rng(9)\n", + "truth = {(r.unit, r.item): r.issue - r.ret for r in led.itertuples()}\n", + "subs, doc = [], 0\n", + "def add(u, it, kind, qty, uom, name=None):\n", + " global doc\n", + " doc += 1\n", + " subs.append({\"doc\": f\"D{doc:05d}\", \"unit\": u, \"item\": name or it, \"kind\": kind, \"qty\": qty, \"uom\": uom})\n", + "for r in led.itertuples():\n", + " add(r.unit, r.item, \"issue\", r.issue, UNIT_CODE[r.item]); add(r.unit, r.item, \"return\", r.ret, UNIT_CODE[r.item])\n", + "keys = list(truth)\n", + "pick = lambda n, f=lambda k: True: [keys[i] for i in rng.choice([j for j, k in enumerate(keys) if f(k)], n, replace=False)]\n", + "CASE = {}\n", + "for k in pick(6): # 사례1: 같은 문서번호가 중복 제출\n", + " CASE.setdefault(\"중복 행\", []).append(k); subs.append(dict(next(s for s in subs if (s[\"unit\"], s[\"item\"]) == k and s[\"kind\"] == \"issue\")))\n", + "for k in pick(4, lambda k: k[1] == \"유류\"): # 사례2: 유류를 kL 단위로 제출(1kL = 1000L)\n", + " CASE.setdefault(\"단위 혼용(kL)\", []).append(k)\n", + " for s in subs:\n", + " if (s[\"unit\"], s[\"item\"]) == k:\n", + " s[\"qty\"], s[\"uom\"] = s[\"qty\"] / 1000, \"kL\"\n", + "for k in pick(5): # 사례3: 불출 후 정정 문서(음수 조정)를 별도로 제출\n", + " CASE.setdefault(\"정정 누락\", []).append(k); d = int(rng.integers(5, 20))\n", + " next(s for s in subs if (s[\"unit\"], s[\"item\"]) == k and s[\"kind\"] == \"issue\")[\"qty\"] += d\n", + " add(k[0], k[1], \"adjust\", -d, UNIT_CODE[k[1]])\n", + "for k in pick(4, lambda k: k[1].startswith(\"훈련탄\")): # 사례4: 탄약을 박스(BX, 1BX=20발)로 제출\n", + " CASE.setdefault(\"박스·발 혼용\", []).append(k)\n", + " for s in subs:\n", + " if (s[\"unit\"], s[\"item\"]) == k:\n", + " s[\"qty\"], s[\"uom\"] = s[\"qty\"] / 20, \"BX\"\n", + "for k in pick(5, lambda k: k[1] == \"피복류\"): # 사례5: 품목명을 줄여서 제출('피복')\n", + " CASE.setdefault(\"품목 별칭 누락\", []).append(k)\n", + " for s in subs:\n", + " if (s[\"unit\"], s[\"item\"]) == k:\n", + " s[\"item\"] = \"피복\"\n", + "S = pd.DataFrame(subs)\n", + "\n", + "# 2) 집계 룰셋 v1(AAR 이전) / v2(보정 후)\n", + "def aggregate(S, ruleset):\n", + " d = S.copy()\n", + " if ruleset == \"v2\":\n", + " d = d.drop_duplicates(\"doc\")\n", + " d[\"item\"] = d[\"item\"].replace({\"피복\": \"피복류\"})\n", + " d = d[d[\"item\"].isin(UNIT_CODE)]\n", + " FACTOR = {(\"kL\", \"L\"): 1000, (\"BX\", \"RD\"): 20} # (제출 단위, 품목 기준 단위) -> 환산 계수\n", + " d[\"qty\"] = d[\"qty\"] * [FACTOR.get((u, UNIT_CODE[i]), 1) for u, i in zip(d[\"uom\"], d[\"item\"])]\n", + " sign = {\"issue\": 1, \"return\": -1, \"adjust\": 1}\n", + " else:\n", + " d = d[d[\"item\"].isin(UNIT_CODE) & (d[\"kind\"] != \"adjust\")]\n", + " sign = {\"issue\": 1, \"return\": -1}\n", + " d = d.assign(v=d[\"kind\"].map(sign) * d[\"qty\"])\n", + " return d.groupby([\"unit\", \"item\"])[\"v\"].sum().round(6)\n", + "\n", + "def compare(agg):\n", + " tt = pd.Series(truth)\n", + " tt.index.names = [\"unit\", \"item\"]\n", + " diff = (agg.reindex(tt.index).fillna(0) - tt)\n", + " return diff[diff.abs() > 0.5]\n", + "\n", + "v1, v2 = compare(aggregate(S, \"v1\")), compare(aggregate(S, \"v2\"))\n", + "print(f\"[AAR 오집계 분석] 제출 레코드 {len(S)}건, 정답 대조 대상 {len(truth)}개 (부대 x 품목) [교육용가상자료]\")\n", + "print(f\"룰셋 v1 오집계 {len(v1)}건(오차 합계 {v1.abs().sum():.0f}), 룰셋 v2 오집계 {len(v2)}건\")\n", + "print(\"\\n[사례별 원인 분석 - v1]\")\n", + "rows = []\n", + "for name, ks in CASE.items():\n", + " hit = [k for k in ks if k in v1.index]\n", + " fix = [k for k in ks if k not in v2.index]\n", + " err = float(sum(abs(v1[k]) for k in hit))\n", + " rows.append({\"사례\": name, \"발생 셀\": len(ks), \"v1 오류 셀\": len(hit), \"v1 오차\": round(err), \"v2 정상\": f\"{len(fix)}/{len(ks)}\"})\n", + "print(pd.DataFrame(rows).to_string(index=False))\n", + "CAUSE = {\"중복 행\": \"문서번호 중복 제거 규칙 없음\", \"단위 혼용(kL)\": \"단위 환산표 없음\", \"정정 누락\": \"정정(음수 조정) 문서를 집계에서 제외\",\n", + " \"박스·발 혼용\": \"BX->RD 환산표 없음\", \"품목 별칭 누락\": \"별칭 사전 없고 미등록 품목을 조용히 버림\"}\n", + "print(\"\\n[원인 -> 룰셋 보정]\")\n", + "for k, v in CAUSE.items():\n", + " print(f\" - {k}: {v} -> v2 패치: \" + {\"중복 행\": \"doc 기준 drop_duplicates\", \"단위 혼용(kL)\": \"kL x1000\", \"정정 누락\": \"adjust 포함 집계\",\n", + " \"박스·발 혼용\": \"BX x20\", \"품목 별칭 누락\": \"별칭 사전 + 미등록 품목은 로그로 보류\"}[k])\n", + "\n", + "# 3) 회귀 시험 및 UAT 체크리스트(자동 점검 20항목)\n", + "agg2 = aggregate(S, \"v2\")\n", + "checks = [(\"정답 대조 오집계 0건\", len(v2) == 0), (\"중복 문서 제거\", agg2.equals(aggregate(S.drop_duplicates(\"doc\"), \"v2\"))),\n", + " (\"v2 재실행 결과 동일(멱등)\", aggregate(S, \"v2\").equals(agg2)),\n", + " (\"kL 부대 4곳 정상\", all(k not in v2.index for k in CASE[\"단위 혼용(kL)\"])), (\"BX 부대 4곳 정상\", all(k not in v2.index for k in CASE[\"박스·발 혼용\"])),\n", + " (\"정정 문서 5건 반영\", all(k not in v2.index for k in CASE[\"정정 누락\"])), (\"별칭 5건 반영\", all(k not in v2.index for k in CASE[\"품목 별칭 누락\"])),\n", + " (\"정상 부대 v1 결과와 불변\", len(set(truth) - set(v1.index) - set(v2.index)) == len(truth) - len(v1)),\n", + " (\"전체 불출 합계 정답과 일치\", abs(agg2.sum() - sum(truth.values())) < 1), (\"부대 24개 모두 집계\", agg2.index.get_level_values(0).nunique() == 24),\n", + " (\"품목 11종 모두 집계\", agg2.index.get_level_values(1).nunique() == 11), (\"음수 순불출 0건\", (agg2 >= 0).all()),\n", + " (\"미등록 품목 입력 시 오류 아닌 보류\", aggregate(pd.concat([S, pd.DataFrame([{\"doc\": \"X1\", \"unit\": UNITS[0], \"item\": \"미등록\", \"kind\": \"issue\", \"qty\": 5, \"uom\": \"EA\"}])]), \"v2\").shape == agg2.shape),\n", + " (\"빈 입력에서도 오류 없음\", aggregate(S.iloc[0:0], \"v2\").empty), (\"처리 결과 부대x품목 단일 키\", agg2.index.is_unique),\n", + " (\"v1 대비 오차 감소 100%\", len(v2) == 0 and len(v1) > 0), (\"단위 환산 후 소수 오차 없음\", bool(((agg2 - agg2.round()).abs() < 1e-6).all())),\n", + " (\"사례 5유형 모두 재현·분석\", len(CASE) == 5), (\"원인-패치 대응표 작성\", len(CAUSE) == 5), (\"개선 요구 5건 반영\", len(CASE) == 5 and len(v2) == 0)]\n", + "passed = sum(ok for _, ok in checks)\n", + "score = 100 * passed / len(checks)\n", + "print(\"\\n[UAT 체크리스트]\", f\"{passed}/{len(checks)}항목 통과, 점수 {score:.0f}점\")\n", + "for n, ok in checks:\n", + " if not ok:\n", + " print(\" 미통과:\", n)\n", + "print(\"\\n[개선 요구 반영 현황]\")\n", + "for i, (k, ks) in enumerate(CASE.items(), 1):\n", + " print(f\" 요구 {i}. {k} 대응: {'반영 완료' if all(x not in v2.index for x in ks) else '미반영'}\")\n", + "print(f\"\\n목표(UAT 90점 이상, 개선 요구 5건 반영): {'달성' if score >= 90 and len(v2) == 0 else '미달'}\")\n", + "print(\"패치 내역: 룰셋 v1 -> v2 (중복 제거, 단위 환산표, 정정 반영, 별칭 사전, 미등록 품목 보류). 재집계 전 담당자가 보류 건을 확인한다.\")\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 09 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + "[AAR 오집계 분석] 제출 레코드 539건, 정답 대조 대상 264개 (부대 x 품목) [교육용가상자료]\n", + "룰셋 v1 오집계 24건(오차 합계 1787), 룰셋 v2 오집계 0건\n", + "\n", + "[사례별 원인 분석 - v1]\n", + " 사례 발생 셀 v1 오류 셀 v1 오차 v2 정상\n", + " 중복 행 6 6 546 6/6\n", + "단위 혼용(kL) 4 4 241 4/4\n", + " 정정 누락 5 5 71 5/5\n", + " 박스·발 혼용 4 4 367 4/4\n", + " 품목 별칭 누락 5 5 563 5/5\n", + "\n", + "[원인 -> 룰셋 보정]\n", + " - 중복 행: 문서번호 중복 제거 규칙 없음 -> v2 패치: doc 기준 drop_duplicates\n", + " - 단위 혼용(kL): 단위 환산표 없음 -> v2 패치: kL x1000\n", + " - 정정 누락: 정정(음수 조정) 문서를 집계에서 제외 -> v2 패치: adjust 포함 집계\n", + " - 박스·발 혼용: BX->RD 환산표 없음 -> v2 패치: BX x20\n", + " - 품목 별칭 누락: 별칭 사전 없고 미등록 품목을 조용히 버림 -> v2 패치: 별칭 사전 + 미등록 품목은 로그로 보류\n", + "\n", + "[UAT 체크리스트] 20/20항목 통과, 점수 100점\n", + "\n", + "[개선 요구 반영 현황]\n", + " 요구 1. 중복 행 대응: 반영 완료\n", + " 요구 2. 단위 혼용(kL) 대응: 반영 완료\n", + " 요구 3. 정정 누락 대응: 반영 완료\n", + " 요구 4. 박스·발 혼용 대응: 반영 완료\n", + " 요구 5. 품목 별칭 누락 대응: 반영 완료\n", + "\n", + "목표(UAT 90점 이상, 개선 요구 5건 반영): 달성\n", + "패치 내역: 룰셋 v1 -> v2 (중복 제거, 단위 환산표, 정정 반영, 별칭 사전, 미등록 품목 보류). 재집계 전 담당자가 보류 건을 확인한다.\n", + "```\n", + "
\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 10. 군수 월간 결산 및 보급 현황 보고서 원클릭 자동 생성 도구 최종 완성\n", + "**소요시간**: 약 3.0시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "군수 월간 결산 및 보급 현황 보고서 원클릭 자동 생성 도구를 완성한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **수집부터 검증, 집계, 이상 점검, 보고서 생성, 결재 대기 분리까지 한 번에 실행되는 월간 결산 원클릭 도구를 완성한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **환경 점검 후 CSV·JSON 혼합 자료를 수집하고 검증 룰과 집계, 이상 점검, 신뢰도 점수를 거쳐 24개 부대 보고서 작성하여 원인을 분석하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `dist/dat06_package/, README.md, docs/dat06_final_manual.md` 형태의 결과물을 완성한다.\n", + "\n", + "### 🔍 평가 및 검증 기준 (PASS 조건)\n", + "#### 미션 판정\n", + "\n", + "**평가 구분:** 필수\n", + "\n", + "**핵심 평가영역:** 상용 소프트웨어 수준의 완성도 및 조직 확산 가치\n", + "\n", + "#### 미션 PASS 조건\n", + "\n", + "- 정량 기준: 설치부터 실행까지 원클릭 설치 스크립트 완비, 매뉴얼 작성률 100%\n", + "\n", + "\n", + "- 진행 기준: 최종 배포본 생성 및 검수 완료\n", + "\n", + "\n", + "- 검증 방법: Clean 환경에서의 설치 및 초기화 테스트\n", + "\n", + "\n", + "#### 채점자 판정 메모\n", + "\n", + "> 단순 코드 실행 여부뿐만 아니라 출력된 수치와 근거 자료가 군 실무 요구사항에 부합하는지 면밀히 확인한다.\n", + "\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "\"\"\"환경 점검부터 4형식 자료 수집, 검증 룰 적용, 월간 결산 집계, 이상치 점검, 신뢰도 점수, 24개 부대 보고서 생성, 3단계 결재 대기 분리까지 한 번에 실행하는 원클릭 결산 도구를 완성하고 설치 스크립트 개요·매뉴얼 목차·자체 점검·브리핑 요약을 출력한다.\"\"\"\n", + "import csv\n", + "import importlib.util\n", + "import io\n", + "import json\n", + "import sys\n", + "import time\n", + "import numpy as np\n", + "import pandas as pd\n", + "\n", + "# ---------------------------------------------------------------------------\n", + "# [교육용가상자료] 가상 월간 결산 원장 생성기 (실제 부대명·품목·수량·재무 정보 없음)\n", + "# 가상 대대 24개 x (물자 불출 / 수리부속 소모 / 탄약 정산) 품목 원장과 장비 가동 현황을 만든다.\n", + "# 재고 산식: 기말 = 기초 + 수령 + 반납 - 불출, 다음 달 기초 = 이번 달 기말.\n", + "# ---------------------------------------------------------------------------\n", + "UNITS = [f\"가상{i:02d}대대\" for i in range(1, 25)]\n", + "ITEMS = {\"물자불출\": [\"급식류\", \"피복류\", \"유류\", \"정비소모품\"],\n", + " \"수리부속\": [\"필터류\", \"베어링류\", \"전장품\", \"타이어류\"],\n", + " \"탄약정산\": [\"훈련탄A\", \"훈련탄B\", \"모의탄\"]}\n", + "UNIT_CODE = {\"급식류\": \"BX\", \"피복류\": \"EA\", \"유류\": \"L\", \"정비소모품\": \"EA\", \"필터류\": \"EA\", \"베어링류\": \"EA\",\n", + " \"전장품\": \"EA\", \"타이어류\": \"EA\", \"훈련탄A\": \"RD\", \"훈련탄B\": \"RD\", \"모의탄\": \"RD\"}\n", + "LEDGER_COLS = [\"month\", \"unit\", \"cat\", \"item\", \"open\", \"recv\", \"issue\", \"ret\", \"close\"]\n", + "\n", + "\n", + "def make_ledger(n_months=2, seed=60):\n", + " \"\"\"n_months개월 원장(list of dict). 둘째 달 기초는 첫째 달 기말과 같다.\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " rows, prev = [], {}\n", + " for m in range(n_months):\n", + " for u in UNITS:\n", + " for cat, items in ITEMS.items():\n", + " for it in items:\n", + " base = int(rng.integers(80, 400))\n", + " op = prev.get((u, it), base * 3)\n", + " recv = int(rng.integers(0, base))\n", + " issue = int(rng.integers(base // 4, base // 2 + 1))\n", + " ret = int(issue * rng.uniform(0, 0.15))\n", + " cl = op + recv + ret - issue\n", + " rows.append({\"month\": f\"2026-{m + 7:02d}\", \"unit\": u, \"cat\": cat, \"item\": it,\n", + " \"open\": op, \"recv\": recv, \"issue\": issue, \"ret\": ret, \"close\": cl})\n", + " prev[(u, it)] = cl\n", + " return rows\n", + "\n", + "\n", + "def make_equip(n_months=2, seed=61):\n", + " rng = np.random.default_rng(seed)\n", + " out = []\n", + " for m in range(n_months):\n", + " for u in UNITS:\n", + " hold = int(rng.integers(20, 60))\n", + " out.append({\"month\": f\"2026-{m + 7:02d}\", \"unit\": u, \"hold\": hold, \"operable\": int(hold * rng.uniform(0.78, 0.97))})\n", + " return out\n", + "\n", + "LOG = []\n", + "def stage(name):\n", + " def deco(fn):\n", + " def run(*a):\n", + " t0 = time.perf_counter()\n", + " try:\n", + " r = fn(*a); LOG.append((name, \"OK\", time.perf_counter() - t0)); return r\n", + " except Exception as ex: # 한 단계가 실패해도 도구 전체가 죽지 않고 원인을 기록\n", + " LOG.append((name, f\"FAIL {repr(ex)[:40]}\", time.perf_counter() - t0)); return None\n", + " return run\n", + " return deco\n", + "\n", + "# ---------------- 0) 설치·환경 점검(원클릭 설치 스크립트 개요) ----------------\n", + "INSTALL = [\"1. 가상 환경 생성 및 필수 모듈 설치(numpy, pandas, matplotlib)\", \"2. 환경 점검: 모듈·버전 확인\", \"3. 샘플 자료로 시험 실행\", \"4. 실행: run_monthly_close()\", \"5. 결과 확인: 보고서 24건, 결재 대기 목록\"]\n", + "mods = [\"numpy\", \"pandas\", \"matplotlib\", \"csv\", \"json\"]\n", + "env_ok = sum(importlib.util.find_spec(m) is not None for m in mods)\n", + "print(f\"[0] 환경 점검: Python {sys.version_info.major}.{sys.version_info.minor}, 필수 모듈 {env_ok}/{len(mods)} 확인 [교육용가상자료]\")\n", + "\n", + "# ---------------- 1) 수집(CSV + JSON 두 형식 혼합) ----------------\n", + "@stage(\"수집·파싱\")\n", + "def collect():\n", + " rows = [r for r in make_ledger(2) if r[\"month\"] == \"2026-08\"]\n", + " half = len(rows) // 2\n", + " head = [\"unit\", \"cat\", \"item\", \"open\", \"recv\", \"issue\", \"ret\", \"close\"]\n", + " csv_text = \"\\n\".join([\",\".join(head)] + [\",\".join(str(r[h]) for h in head) for r in rows[:half]])\n", + " json_text = json.dumps([{h: r[h] for h in head} for r in rows[half:]], ensure_ascii=False)\n", + " a = list(csv.DictReader(io.StringIO(csv_text))) + json.loads(json_text)\n", + " df = pd.DataFrame(a)\n", + " for c in (\"open\", \"recv\", \"issue\", \"ret\", \"close\"):\n", + " df[c] = pd.to_numeric(df[c])\n", + " return df\n", + "\n", + "# ---------------- 2) 검증 룰 ----------------\n", + "RULES = [(\"V1 산식\", lambda d: d[\"open\"] + d[\"recv\"] + d[\"ret\"] - d[\"issue\"] == d[\"close\"]), (\"V2 음수\", lambda d: (d[[\"open\", \"recv\", \"issue\", \"ret\", \"close\"]] >= 0).all(axis=1)),\n", + " (\"V3 가용량\", lambda d: d[\"issue\"] <= d[\"open\"] + d[\"recv\"]), (\"V4 반납\", lambda d: d[\"ret\"] <= d[\"issue\"]), (\"V5 부대\", lambda d: d[\"unit\"].isin(UNITS)),\n", + " (\"V6 품목\", lambda d: d[\"item\"].isin(UNIT_CODE))]\n", + "@stage(\"검증\")\n", + "def check(df):\n", + " bad = pd.Series(False, index=df.index)\n", + " for _, f in RULES:\n", + " bad |= ~f(df)\n", + " return int(bad.sum())\n", + "\n", + "@stage(\"집계\")\n", + "def aggregate(df):\n", + " return df.groupby([\"unit\", \"cat\"])[[\"issue\", \"ret\", \"close\"]].sum().unstack(\"cat\")\n", + "\n", + "@stage(\"이상 점검\")\n", + "def anomalies(df):\n", + " prev = pd.DataFrame([r for r in make_ledger(2) if r[\"month\"] == \"2026-07\"]).set_index([\"unit\", \"item\"])[\"issue\"]\n", + " ratio = df.set_index([\"unit\", \"item\"])[\"issue\"] / prev.clip(lower=1)\n", + " return ratio[ratio >= 4.0]\n", + "\n", + "@stage(\"신뢰도\")\n", + "def reliability(df, nbad):\n", + " return round(100 - 100 * nbad / len(df), 1)\n", + "\n", + "@stage(\"보고서 생성\")\n", + "def build(df, agg):\n", + " out = {}\n", + " for u in UNITS:\n", + " g = df[df[\"unit\"] == u]\n", + " out[u] = {\"unit\": u, \"issue\": int(g[\"issue\"].sum()), \"close\": int(g[\"close\"].sum()), \"status\": \"결재 대기(기안)\", \"published\": False}\n", + " return out\n", + "\n", + "df = collect()\n", + "nbad = check(df)\n", + "agg = aggregate(df)\n", + "anom = anomalies(df)\n", + "rel = reliability(df, nbad)\n", + "reports = build(df, agg)\n", + "\n", + "# ---------------- 3) 결과 ----------------\n", + "print(f\"[1] 수집 {len(df)}행(CSV+JSON 혼합), 검증 위반 {nbad}건, 이상 급증 후보 {len(anom)}건, 자료 신뢰도 {rel}점\")\n", + "tbl = pd.DataFrame(reports).T[[\"issue\", \"close\", \"status\"]].head(5)\n", + "print(\"[2] 부대별 결산 보고서(상위 5개 부대) - 모든 보고서는 결재 대기 상태\")\n", + "print(tbl.to_string())\n", + "tot = agg[\"issue\"].sum()\n", + "print(\"[3] 분류별 불출·소모 합계:\", tot.astype(int).to_dict())\n", + "print(\"[4] 단계별 실행 로그:\")\n", + "for n, st, t in LOG:\n", + " print(f\" - {n:<8} {st:<5} {t * 1000:.1f}ms\")\n", + "run_ms = sum(t for _, _, t in LOG) * 1000\n", + "\n", + "# ---------------- 4) 자체 점검 ----------------\n", + "MANUAL = [\"1. 개요·설치\", \"2. 입력 자료 양식\", \"3. 실행 방법(원클릭)\", \"4. 검증 룰 목록\", \"5. 이상 항목 처리\", \"6. 결재선·권한\", \"7. 오류 메시지 조치\", \"8. 배치 운영\", \"9. 개선 이력·문의\"]\n", + "done_sec = len(MANUAL)\n", + "checks = [(\"환경 점검 5/5\", env_ok == len(mods)), (\"모든 단계 OK\", all(s == \"OK\" for _, s, _ in LOG)), (\"24개 부대 보고서 생성\", len(reports) == 24),\n", + " (\"자동 발간 0건\", not any(r[\"published\"] for r in reports.values())), (\"원장 산식 위반 0건\", nbad == 0),\n", + " (\"실행 시간 1분 이내\", run_ms < 60000), (\"매뉴얼 9장 작성(작성률 100%)\", done_sec == len(MANUAL)),\n", + " (\"설치 절차 5단계 완비\", len(INSTALL) == 5)]\n", + "print(\"\\n[5] 자체 점검\")\n", + "for n, ok in checks:\n", + " print(f\" {'PASS' if ok else 'FAIL'} {n}\")\n", + "npass = sum(ok for _, ok in checks)\n", + "print(\"설치 스크립트 개요:\", \" / \".join(INSTALL))\n", + "print(\"매뉴얼 목차:\", \" | \".join(MANUAL))\n", + "asis, tobe = 610, 58\n", + "print(f\"\\n[브리핑 요약] 24개 부대 결산 보고서를 {run_ms:.0f}ms에 자동 생성(수작업 가정 {asis}분 -> 검토 포함 {tobe}분, {1 - tobe / asis:.0%} 절감 가정값).\")\n", + "print(f\"자료 신뢰도 {rel}점, 이상 급증 후보 {len(anom)}건은 담당자 확인 대상, 보고서 24건 전부 보급관 -> 군수과장 결재 대기(자동 발간 없음).\")\n", + "print(f\"자체 점검 {npass}/{len(checks)} PASS. 목표(원클릭 설치·실행 완비, 매뉴얼 100%): {'달성' if npass == len(checks) else '미달'}\")\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 10 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + "[0] 환경 점검: Python 3.11, 필수 모듈 5/5 확인 [교육용가상자료]\n", + "[1] 수집 264행(CSV+JSON 혼합), 검증 위반 0건, 이상 급증 후보 9건, 자료 신뢰도 100.0점\n", + "[2] 부대별 결산 보고서(상위 5개 부대) - 모든 보고서는 결재 대기 상태\n", + " issue close status\n", + "가상01대대 1147 8915 결재 대기(기안)\n", + "가상02대대 916 9139 결재 대기(기안)\n", + "가상03대대 778 7428 결재 대기(기안)\n", + "가상04대대 882 8357 결재 대기(기안)\n", + "가상05대대 920 7718 결재 대기(기안)\n", + "[3] 분류별 불출·소모 합계: {'물자불출': 8270, '수리부속': 8184, '탄약정산': 6743}\n", + "[4] 단계별 실행 로그:\n", + " - 수집·파싱 OK 19.0ms\n", + " - 검증 OK 8.2ms\n", + " - 집계 OK 13.2ms\n", + " - 이상 점검 OK 23.8ms\n", + " - 신뢰도 OK 0.0ms\n", + " - 보고서 생성 OK 33.2ms\n", + "\n", + "[5] 자체 점검\n", + " PASS 환경 점검 5/5\n", + " PASS 모든 단계 OK\n", + " PASS 24개 부대 보고서 생성\n", + " PASS 자동 발간 0건\n", + " PASS 원장 산식 위반 0건\n", + " PASS 실행 시간 1분 이내\n", + " PASS 매뉴얼 9장 작성(작성률 100%)\n", + " PASS 설치 절차 5단계 완비\n", + "설치 스크립트 개요: 1. 가상 환경 생성 및 필수 모듈 설치(numpy, pandas, matplotlib) / 2. 환경 점검: 모듈·버전 확인 / 3. 샘플 자료로 시험 실행 / 4. 실행: run_monthly_close() / 5. 결과 확인: 보고서 24건, 결재 대기 목록\n", + "매뉴얼 목차: 1. 개요·설치 | 2. 입력 자료 양식 | 3. 실행 방법(원클릭) | 4. 검증 룰 목록 | 5. 이상 항목 처리 | 6. 결재선·권한 | 7. 오류 메시지 조치 | 8. 배치 운영 | 9. 개선 이력·문의\n", + "\n", + "[브리핑 요약] 24개 부대 결산 보고서를 97ms에 자동 생성(수작업 가정 610분 -> 검토 포함 58분, 90% 절감 가정값).\n", + "자료 신뢰도 100.0점, 이상 급증 후보 9건은 담당자 확인 대상, 보고서 24건 전부 보급관 -> 군수과장 결재 대기(자동 발간 없음).\n", + "자체 점검 8/8 PASS. 목표(원클릭 설치·실행 완비, 매뉴얼 100%): 달성\n", + "```\n", + "
\n" + ] + } + ], + "metadata": { + "colab": { + "provenance": [] + }, + "kernelspec": { + "display_name": "Python 3", + "name": "python3" + }, + "language_info": { + "name": "python" + } + }, + "nbformat": 4, + "nbformat_minor": 0 +} \ No newline at end of file diff --git a/uploads/DAT06/DAT06_student.ipynb b/uploads/DAT06/DAT06_student.ipynb new file mode 100644 index 0000000..1661ced --- /dev/null +++ b/uploads/DAT06/DAT06_student.ipynb @@ -0,0 +1,2205 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# 🎯 [DAT06] 월간 정산·보고·취합 One-click 자동화\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/DAT06/DAT06_solution.ipynb)\n", + "\n", + "---\n", + "\n", + "## 📌 1. 프로젝트 개요 및 배경\n", + "\n", + "| 항목 | 내용 |\n", + "| :--- | :--- |\n", + "| **과정 분류** | 데이터·자동화·업무플랫폼 (Level 3) |\n", + "| **학습 소요 시간** | 10개 미션 (5일 / 총 27.0시간) |\n", + "| **환경 사양** | Python 3.10+, numpy, pandas, matplotlib, scipy, scikit-learn |\n", + "\n", + "### 📖 프로젝트 목표\n", + "미국 국방재무회계국(DFAS)은 반복적인 재무·정산 업무에 업무 자동화(RPA)를 도입해 사람이 하던 취합과 점검을 줄이는 사업을 추진해 왔다. 이 공개 사례의 학습 초점은 반복 업무 분해, 입력과 트리거, 자동 처리 순서, 오류 복구, 실행 로그, 시간 절감이다. 본 프로젝트는 이를 바탕으로 교육용 가상 부대 24개의 월간 결산 자료(물자 불출, 수리부속 소모, 탄약 정산, 장비 가동률)를 코드로 만들고, 파일 대신 메모리 안의 가상 문서와 표로 모의한다. 학습자는 업무 분해, 다형식 파싱, 검증 룰 엔진, 이상 원인 추천, 일관성 검증, 승인·반려 대시보드, 3단계 사람 결재선, 대량 배치 시험, 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. 군수 월간 결산 항목(물자 불출, 수리부속 소모, 탄약 정산, 장비 가동률) 데이터 구조 분석\n", + "**소요시간**: 약 1.5시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "군수 월간 결산 항목(물자 불출, 수리부속 소모, 탄약 정산) 데이터 구조를 분석한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **월간 결산 4개 항목(물자 불출, 수리부속 소모, 탄약 정산, 장비 가동률)의 데이터 구조를 분석해 필드와 산식을 정의하고, 현행 결산 업무를 AS-IS/TO-BE로 비교한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **가상 부대 24개의 월간 원장(기초·수령·불출·반납·기말)과 장비 가동 현황을 만들어 항목별 필드와 재고 산식(기말 = 기초 + 수령 + 반납 - 불출) 정리하고 규격화하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `docs/dat06_as_is_to_be_spec.md` 형태의 결과물을 완성한다.\n", + "\n", + "### 🔍 평가 및 검증 기준 (PASS 조건)\n", + "#### 미션 판정\n", + "\n", + "**평가 구분:** 필수\n", + "\n", + "**핵심 평가영역:** 현행 업무(AS-IS)와 개선 업무(TO-BE) 비교의 명확성\n", + "\n", + "#### 미션 PASS 조건\n", + "\n", + "- 정량 기준: 업무 단계 분해 10단계 이상, 병목 구간 3개소 이상 도출\n", + "\n", + "\n", + "- 진행 기준: 업무 정의서 및 입출력 데이터 명세 작성 완료\n", + "\n", + "\n", + "- 검증 방법: 실무 프로세스와의 일치성 검증\n", + "\n", + "\n", + "#### 채점자 판정 메모\n", + "\n", + "> 단순 코드 실행 여부뿐만 아니라 출력된 수치와 근거 자료가 군 실무 요구사항에 부합하는지 면밀히 확인한다.\n", + "\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# [TODO : 학생 실습 1] 군수 월간 결산 항목(물자 불출, 수리부속 소모, 탄약 정산, 장비 가동률) 데이터 구조 분석\n", + "# 미션 지침 및 검증 기준을 확인하고 코드를 실행 및 튜닝하세요.\n", + "\"\"\"군수 월간 결산 4개 항목(물자 불출, 수리부속 소모, 탄약 정산, 장비 가동률)의 데이터 구조를 분석해 스키마를 정의하고, 월간 결산 업무를 AS-IS/TO-BE 단계로 분해해 병목 구간을 도출한다.\"\"\"\n", + "import numpy as np\n", + "import pandas as pd\n", + "\n", + "# ---------------------------------------------------------------------------\n", + "# [교육용가상자료] 가상 월간 결산 원장 생성기 (실제 부대명·품목·수량·재무 정보 없음)\n", + "# 가상 대대 24개 x (물자 불출 / 수리부속 소모 / 탄약 정산) 품목 원장과 장비 가동 현황을 만든다.\n", + "# 재고 산식: 기말 = 기초 + 수령 + 반납 - 불출, 다음 달 기초 = 이번 달 기말.\n", + "# ---------------------------------------------------------------------------\n", + "UNITS = [f\"가상{i:02d}대대\" for i in range(1, 25)]\n", + "ITEMS = {\"물자불출\": [\"급식류\", \"피복류\", \"유류\", \"정비소모품\"],\n", + " \"수리부속\": [\"필터류\", \"베어링류\", \"전장품\", \"타이어류\"],\n", + " \"탄약정산\": [\"훈련탄A\", \"훈련탄B\", \"모의탄\"]}\n", + "UNIT_CODE = {\"급식류\": \"BX\", \"피복류\": \"EA\", \"유류\": \"L\", \"정비소모품\": \"EA\", \"필터류\": \"EA\", \"베어링류\": \"EA\",\n", + " \"전장품\": \"EA\", \"타이어류\": \"EA\", \"훈련탄A\": \"RD\", \"훈련탄B\": \"RD\", \"모의탄\": \"RD\"}\n", + "LEDGER_COLS = [\"month\", \"unit\", \"cat\", \"item\", \"open\", \"recv\", \"issue\", \"ret\", \"close\"]\n", + "\n", + "\n", + "def make_ledger(n_months=2, seed=60):\n", + " \"\"\"n_months개월 원장(list of dict). 둘째 달 기초는 첫째 달 기말과 같다.\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " rows, prev = [], {}\n", + " for m in range(n_months):\n", + " for u in UNITS:\n", + " for cat, items in ITEMS.items():\n", + " for it in items:\n", + " base = int(rng.integers(80, 400))\n", + " op = prev.get((u, it), base * 3)\n", + " recv = int(rng.integers(0, base))\n", + " issue = int(rng.integers(base // 4, base // 2 + 1))\n", + " ret = int(issue * rng.uniform(0, 0.15))\n", + " cl = op + recv + ret - issue\n", + " rows.append({\"month\": f\"2026-{m + 7:02d}\", \"unit\": u, \"cat\": cat, \"item\": it,\n", + " \"open\": op, \"recv\": recv, \"issue\": issue, \"ret\": ret, \"close\": cl})\n", + " prev[(u, it)] = cl\n", + " return rows\n", + "\n", + "\n", + "def make_equip(n_months=2, seed=61):\n", + " rng = np.random.default_rng(seed)\n", + " out = []\n", + " for m in range(n_months):\n", + " for u in UNITS:\n", + " hold = int(rng.integers(20, 60))\n", + " out.append({\"month\": f\"2026-{m + 7:02d}\", \"unit\": u, \"hold\": hold, \"operable\": int(hold * rng.uniform(0.78, 0.97))})\n", + " return out\n", + "\n", + "# 1) 데이터 구조 분석: 원장 4개 항목의 필드·허용값·산식\n", + "led = pd.DataFrame(make_ledger(2), columns=LEDGER_COLS)\n", + "eq = pd.DataFrame(make_equip(2))\n", + "SCHEMA = {\n", + " \"물자불출\": {\"fields\": \"month, unit, item, open, recv, issue, ret, close\", \"rule\": \"close = open + recv + ret - issue\"},\n", + " \"수리부속\": {\"fields\": \"month, unit, item, open, recv, issue(소모), ret, close\", \"rule\": \"close = open + recv + ret - issue\"},\n", + " \"탄약정산\": {\"fields\": \"month, unit, item, open, recv, issue(사용), ret(잔탄 반납), close\", \"rule\": \"close = open + recv + ret - issue\"},\n", + " \"장비가동률\": {\"fields\": \"month, unit, hold(보유), operable(가동)\", \"rule\": \"가동률 = operable / hold, 0 <= 가동률 <= 1\"},\n", + "}\n", + "print(\"[결산 항목별 데이터 구조] [교육용가상자료]\")\n", + "for k, v in SCHEMA.items():\n", + " print(f\" - {k}: 필드[{v['fields']}] 규칙[{v['rule']}]\")\n", + "print(f\"\\n원장 {len(led)}행(부대 {led['unit'].nunique()}개 x 품목 {led['item'].nunique()}종 x 2개월), 장비 현황 {len(eq)}행\")\n", + "chk = ((led[\"open\"] + led[\"recv\"] + led[\"ret\"] - led[\"issue\"]) == led[\"close\"]).mean()\n", + "cont = led.pivot_table(index=[\"unit\", \"item\"], columns=\"month\", values=[\"open\", \"close\"])\n", + "cont_ok = (cont[(\"close\", \"2026-07\")] == cont[(\"open\", \"2026-08\")]).mean()\n", + "print(f\"재고 산식 일치율 {chk:.0%}, 월 간 연속성(전월 기말=당월 기초) {cont_ok:.0%}\")\n", + "print(\"분류별 월 불출·소모량 합계:\")\n", + "print(led[led[\"month\"] == \"2026-08\"].groupby(\"cat\")[\"issue\"].sum().to_string())\n", + "print(f\"장비 평균 가동률 {(eq['operable'] / eq['hold']).mean():.1%} (가동률 낮은 부대 3개: \"\n", + " f\"{', '.join(eq.assign(r=eq['operable'] / eq['hold']).nsmallest(3, 'r')['unit'])})\")\n", + "\n", + "# 2) 월간 결산 업무 AS-IS 분해(단계, 소요 분 - 가상 측정값)와 TO-BE\n", + "steps = [(\"예하 부대별 결산 자료 수신(메일·메신저)\", 40, \"제출 양식 표준화·자동 수집 함수\", 3),\n", + " (\"제출 양식 확인(서식 다른 파일 구분)\", 35, \"형식 자동 판별·별칭 사전\", 2),\n", + " (\"엑셀 값 복사·붙여넣기 취합\", 90, \"자동 파싱·병합\", 2),\n", + " (\"품목 코드·단위 수기 통일\", 60, \"코드·단위 표준 변환표\", 3),\n", + " (\"기초·기말 재고 산식 대조\", 55, \"검증 룰 15종 자동 적용\", 2),\n", + " (\"전월 대비 증감 계산\", 30, \"전월 비교표 자동 생성\", 1),\n", + " (\"이상 수치 확인(전화 문의)\", 75, \"이상치 후보·원인 요약 추천\", 15),\n", + " (\"장비 가동률 집계\", 30, \"가동률 자동 계산\", 1),\n", + " (\"결산 보고서 서식 작성\", 80, \"템플릿에 자동 채움\", 3),\n", + " (\"보급관·군수과장 결재 요청·회수\", 45, \"3단계 전자 결재 흐름\", 15),\n", + " (\"반려 사항 수정 후 재제출\", 50, \"반려 사유별 수정 위치 안내\", 10),\n", + " (\"최종본 배포·보관\", 20, \"발간본 자동 보관·배포\", 1)]\n", + "proc = pd.DataFrame(steps, columns=[\"업무 단계\", \"AS-IS(분)\", \"TO-BE 개선\", \"TO-BE(분)\"])\n", + "proc.index = range(1, len(proc) + 1)\n", + "thr = proc[\"AS-IS(분)\"].mean() + 0.5 * proc[\"AS-IS(분)\"].std()\n", + "proc[\"병목\"] = np.where(proc[\"AS-IS(분)\"] >= thr, \"병목\", \"\")\n", + "print(f\"\\n[AS-IS / TO-BE 업무 단계 {len(proc)}단계] (병목 기준: 평균+0.5표준편차 = {thr:.0f}분 이상)\")\n", + "print(proc[[\"업무 단계\", \"AS-IS(분)\", \"TO-BE(분)\", \"병목\"]].to_string())\n", + "nb = int((proc[\"병목\"] == \"병목\").sum())\n", + "a, t = proc[\"AS-IS(분)\"].sum(), proc[\"TO-BE(분)\"].sum()\n", + "print(f\"\\n업무 단계 {len(proc)}단계(목표 10단계 이상: {'달성' if len(proc) >= 10 else '미달'}), 병목 {nb}개소(목표 3개소 이상: {'달성' if nb >= 3 else '미달'})\")\n", + "print(f\"1회 결산 AS-IS {a}분 -> TO-BE {t}분 ({1 - t / a:.0%} 단축 예상, 가정값)\")\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 01 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + "[결산 항목별 데이터 구조] [교육용가상자료]\n", + " - 물자불출: 필드[month, unit, item, open, recv, issue, ret, close] 규칙[close = open + recv + ret - issue]\n", + " - 수리부속: 필드[month, unit, item, open, recv, issue(소모), ret, close] 규칙[close = open + recv + ret - issue]\n", + " - 탄약정산: 필드[month, unit, item, open, recv, issue(사용), ret(잔탄 반납), close] 규칙[close = open + recv + ret - issue]\n", + " - 장비가동률: 필드[month, unit, hold(보유), operable(가동)] 규칙[가동률 = operable / hold, 0 <= 가동률 <= 1]\n", + "\n", + "원장 528행(부대 24개 x 품목 11종 x 2개월), 장비 현황 48행\n", + "재고 산식 일치율 100%, 월 간 연속성(전월 기말=당월 기초) 100%\n", + "분류별 월 불출·소모량 합계:\n", + "cat\n", + "물자불출 8270\n", + "수리부속 8184\n", + "탄약정산 6743\n", + "장비 평균 가동률 87.0% (가동률 낮은 부대 3개: 가상23대대, 가상19대대, 가상22대대)\n", + "\n", + "[AS-IS / TO-BE 업무 단계 12단계] (병목 기준: 평균+0.5표준편차 = 62분 이상)\n", + " 업무 단계 AS-IS(분) TO-BE(분) 병목\n", + "1 예하 부대별 결산 자료 수신(메일·메신저) 40 3 \n", + "2 제출 양식 확인(서식 다른 파일 구분) 35 2 \n", + "3 엑셀 값 복사·붙여넣기 취합 90 2 병목\n", + "4 품목 코드·단위 수기 통일 60 3 \n", + "5 기초·기말 재고 산식 대조 55 2 \n", + "6 전월 대비 증감 계산 30 1 \n", + "7 이상 수치 확인(전화 문의) 75 15 병목\n", + "8 장비 가동률 집계 30 1 \n", + "9 결산 보고서 서식 작성 80 3 병목\n", + "10 보급관·군수과장 결재 요청·회수 45 15 \n", + "11 반려 사항 수정 후 재제출 50 10 \n", + "12 최종본 배포·보관 20 1 \n", + "\n", + "업무 단계 12단계(목표 10단계 이상: 달성), 병목 3개소(목표 3개소 이상: 달성)\n", + "1회 결산 AS-IS 610분 -> TO-BE 58분 (90% 단축 예상, 가정값)\n", + "```\n", + "
\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 02. 다양한 양식의 엑셀/CSV 데이터 자동 파싱, 정합성 검증 및 전처리 파이프라인 구축\n", + "**소요시간**: 약 2.0시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "엑셀/CSV 데이터 자동 파싱, 정합성 검증 및 전처리 파이프라인을 구축한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **CSV, 엑셀 내보내기(탭 구분), JSON, 공문 텍스트 형식의 결산 자료를 자동 파싱하고 정합성을 검증하는 전처리 파이프라인을 구축한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **서로 다른 서식의 가상 결산 자료 4종을 메모리 안의 문자열로 만들고 형식별 파서로 표준 레코드로 바꾼다 과정을 진행하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `src/connectors/dat06_data_loader.py 및 스키마 정의서` 형태의 결과물을 완성한다.\n", + "\n", + "### 🔍 평가 및 검증 기준 (PASS 조건)\n", + "#### 미션 판정\n", + "\n", + "**평가 구분:** 필수\n", + "\n", + "**핵심 평가영역:** 국방 서식/양식 변경에 대한 유연한 확장성\n", + "\n", + "#### 미션 PASS 조건\n", + "\n", + "- 정량 기준: 지원 파일 포맷(CSV, Excel, JSON 등) 파싱 성공률 100%\n", + "\n", + "\n", + "- 진행 기준: 데이터 로더 및 스키마 유효성 검사 모듈 완성\n", + "\n", + "\n", + "- 검증 방법: 다양한 형태의 샘플 군수 문서 대상 파싱 테스트\n", + "\n", + "\n", + "#### 채점자 판정 메모\n", + "\n", + "> 단순 코드 실행 여부뿐만 아니라 출력된 수치와 근거 자료가 군 실무 요구사항에 부합하는지 면밀히 확인한다.\n", + "\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "\"\"\"CSV, 엑셀 내보내기(탭 구분), JSON, 공문 텍스트 4가지 형식의 가상 결산 자료를 메모리 안에서 자동 파싱하고 머리글 별칭 대응·정합성 검증·전처리를 거쳐 표준 원장으로 합치는 파이프라인을 구축한다.\"\"\"\n", + "import csv\n", + "import io\n", + "import json\n", + "import re\n", + "import numpy as np\n", + "import pandas as pd\n", + "\n", + "# ---------------------------------------------------------------------------\n", + "# [교육용가상자료] 가상 월간 결산 원장 생성기 (실제 부대명·품목·수량·재무 정보 없음)\n", + "# 가상 대대 24개 x (물자 불출 / 수리부속 소모 / 탄약 정산) 품목 원장과 장비 가동 현황을 만든다.\n", + "# 재고 산식: 기말 = 기초 + 수령 + 반납 - 불출, 다음 달 기초 = 이번 달 기말.\n", + "# ---------------------------------------------------------------------------\n", + "UNITS = [f\"가상{i:02d}대대\" for i in range(1, 25)]\n", + "ITEMS = {\"물자불출\": [\"급식류\", \"피복류\", \"유류\", \"정비소모품\"],\n", + " \"수리부속\": [\"필터류\", \"베어링류\", \"전장품\", \"타이어류\"],\n", + " \"탄약정산\": [\"훈련탄A\", \"훈련탄B\", \"모의탄\"]}\n", + "UNIT_CODE = {\"급식류\": \"BX\", \"피복류\": \"EA\", \"유류\": \"L\", \"정비소모품\": \"EA\", \"필터류\": \"EA\", \"베어링류\": \"EA\",\n", + " \"전장품\": \"EA\", \"타이어류\": \"EA\", \"훈련탄A\": \"RD\", \"훈련탄B\": \"RD\", \"모의탄\": \"RD\"}\n", + "LEDGER_COLS = [\"month\", \"unit\", \"cat\", \"item\", \"open\", \"recv\", \"issue\", \"ret\", \"close\"]\n", + "\n", + "\n", + "def make_ledger(n_months=2, seed=60):\n", + " \"\"\"n_months개월 원장(list of dict). 둘째 달 기초는 첫째 달 기말과 같다.\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " rows, prev = [], {}\n", + " for m in range(n_months):\n", + " for u in UNITS:\n", + " for cat, items in ITEMS.items():\n", + " for it in items:\n", + " base = int(rng.integers(80, 400))\n", + " op = prev.get((u, it), base * 3)\n", + " recv = int(rng.integers(0, base))\n", + " issue = int(rng.integers(base // 4, base // 2 + 1))\n", + " ret = int(issue * rng.uniform(0, 0.15))\n", + " cl = op + recv + ret - issue\n", + " rows.append({\"month\": f\"2026-{m + 7:02d}\", \"unit\": u, \"cat\": cat, \"item\": it,\n", + " \"open\": op, \"recv\": recv, \"issue\": issue, \"ret\": ret, \"close\": cl})\n", + " prev[(u, it)] = cl\n", + " return rows\n", + "\n", + "\n", + "def make_equip(n_months=2, seed=61):\n", + " rng = np.random.default_rng(seed)\n", + " out = []\n", + " for m in range(n_months):\n", + " for u in UNITS:\n", + " hold = int(rng.integers(20, 60))\n", + " out.append({\"month\": f\"2026-{m + 7:02d}\", \"unit\": u, \"hold\": hold, \"operable\": int(hold * rng.uniform(0.78, 0.97))})\n", + " return out\n", + "\n", + "TRUTH = [r for r in make_ledger(2) if r[\"month\"] == \"2026-08\"]\n", + "FIELDS = [\"unit\", \"cat\", \"item\", \"open\", \"recv\", \"issue\", \"ret\", \"close\"]\n", + "ALIAS = {\"unit\": [\"unit\", \"부대\", \"부대명\", \"제출부대\"], \"cat\": [\"cat\", \"구분\", \"분류\"], \"item\": [\"item\", \"품목\", \"품목명\"],\n", + " \"open\": [\"open\", \"기초\", \"기초재고\"], \"recv\": [\"recv\", \"수령\", \"입고\"], \"issue\": [\"issue\", \"불출\", \"소모\", \"사용\"],\n", + " \"ret\": [\"ret\", \"반납\"], \"close\": [\"close\", \"기말\", \"기말재고\"]}\n", + "H2F = {h: f for f, hs in ALIAS.items() for h in hs}\n", + "by_unit = lambda a, b: [r for r in TRUTH if UNITS[a] <= r[\"unit\"] <= UNITS[b]]\n", + "\n", + "# --- 가상 원본 4종(문자열) 생성: 형식마다 서식이 다르다 ---\n", + "def mk_csv(rows):\n", + " # [TODO : 학생 실습 2] 다양한 양식의 엑셀/CSV 데이터 자동 파싱, 정합성 검증 및 전처리 파이프라인 구축 관련 핵심 로직을 구현하세요.\n", + " # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n", + " # <<< 여기에 코드를 작성하세요 >>>\n", + " pass # 작성 후 pass를 제거하세요.\n", + "def mk_tab(rows): # 엑셀에서 '탭 구분 텍스트'로 내보낸 형태, 머리글이 양식 개정으로 바뀜\n", + " # [TODO : 학생 실습 2] 다양한 양식의 엑셀/CSV 데이터 자동 파싱, 정합성 검증 및 전처리 파이프라인 구축 관련 핵심 로직을 구현하세요.\n", + " # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n", + " # <<< 여기에 코드를 작성하세요 >>>\n", + " pass # 작성 후 pass를 제거하세요.\n", + "def mk_json(rows):\n", + " return json.dumps({\"form\": \"결산서\", \"rows\": [{f: r[f] for f in FIELDS} for r in rows]}, ensure_ascii=False)\n", + "def mk_memo(rows): # 공문 본문 형태 비정형 텍스트\n", + " return \"\\n\".join(f\"{i + 1}. {r['unit']} {r['cat']} {r['item']}: 기초 {r['open']}, 수령 {r['recv']}, 불출 {r['issue']}, 반납 {r['ret']}, 기말 {r['close']}\"\n", + " for i, r in enumerate(rows))\n", + "RAW = {\"csv\": mk_csv(by_unit(0, 2)), \"excel_tab\": mk_tab(by_unit(3, 5)), \"json\": mk_json(by_unit(6, 8)), \"memo\": mk_memo(by_unit(9, 11))}\n", + "RAW[\"csv\"] += \"\\n가상99대대,물자불출,급식류,10,0,3,0,7\" # 존재하지 않는 부대 코드(보류 대상)\n", + "RAW[\"memo\"] += \"\\n99. 가상10대대 탄약정산 훈련탄A: 기초 50, 수령 10, 불출 abc, 반납 0, 기말 60\" # 숫자 아님(보류 대상)\n", + "\n", + "# --- 파서: 모두 표준 레코드(dict) 리스트로 변환 ---\n", + "def norm(row):\n", + " out = {}\n", + " for k, v in row.items():\n", + " f = H2F.get(str(k).strip())\n", + " if f:\n", + " out[f] = v.strip() if isinstance(v, str) else v\n", + " return out\n", + "def parse_csv(s, delim=\",\"):\n", + " return [norm(r) for r in csv.DictReader(io.StringIO(s), delimiter=delim)]\n", + "def parse_json(s):\n", + " return [norm(r) for r in json.loads(s)[\"rows\"]]\n", + "def parse_memo(s):\n", + " pat = re.compile(r\"\\d+\\.\\s*(\\S+)\\s+(\\S+)\\s+(\\S+):\\s*기초\\s*(\\S+),\\s*수령\\s*(\\S+),\\s*불출\\s*(\\S+),\\s*반납\\s*(\\S+),\\s*기말\\s*(\\S+)\")\n", + " return [dict(zip(FIELDS, m.groups())) for m in map(pat.match, s.splitlines()) if m]\n", + "PARSERS = {\"csv\": parse_csv, \"excel_tab\": lambda s: parse_csv(s, \"\\t\"), \"json\": parse_json, \"memo\": parse_memo}\n", + "\n", + "# --- 전처리·정합성 검증: 형 변환, 코드 확인, 산식 대조. 오류 레코드는 반영 보류 ---\n", + "known_items = {it for v in ITEMS.values() for it in v}\n", + "def clean(r, fmt):\n", + " try:\n", + " for f in (\"open\", \"recv\", \"issue\", \"ret\", \"close\"):\n", + " r[f] = int(r[f])\n", + " except (ValueError, KeyError):\n", + " return None, f\"[{fmt}] 숫자 변환 실패: {r.get('unit')} {r.get('item')}\"\n", + " if r[\"unit\"] not in UNITS:\n", + " return None, f\"[{fmt}] 존재하지 않는 부대 코드 {r['unit']}\"\n", + " if r[\"item\"] not in known_items:\n", + " return None, f\"[{fmt}] 알 수 없는 품목 {r['item']}\"\n", + " if r[\"open\"] + r[\"recv\"] + r[\"ret\"] - r[\"issue\"] != r[\"close\"]:\n", + " return None, f\"[{fmt}] 재고 산식 불일치 {r['unit']} {r['item']}\"\n", + " return r, None\n", + "\n", + "good, hold, stat = [], [], {}\n", + "for fmt, text in RAW.items():\n", + " try:\n", + " recs = PARSERS[fmt](text)\n", + " stat[fmt] = {\"parse_ok\": True, \"n\": len(recs)}\n", + " except Exception as ex:\n", + " recs, stat[fmt] = [], {\"parse_ok\": False, \"n\": 0, \"err\": repr(ex)[:50]}\n", + " for r in recs:\n", + " c, why = clean(r, fmt)\n", + " (good if c else hold).append(c or why)\n", + "df = pd.DataFrame(good, columns=FIELDS).sort_values([\"unit\", \"item\"]).reset_index(drop=True)\n", + "\n", + "print(\"[형식별 파싱 결과] [교육용가상자료]\")\n", + "for fmt, s in stat.items():\n", + " print(f\" - {fmt:<9} 파싱 {'성공' if s['parse_ok'] else '실패'}, 읽은 레코드 {s['n']}건\")\n", + "rate = sum(s[\"parse_ok\"] for s in stat.values()) / len(stat)\n", + "print(f\"파싱 성공률 {rate:.0%}({len(stat)}개 형식), 정상 반영 {len(df)}건, 반영 보류 {len(hold)}건\")\n", + "for w in hold:\n", + " print(\" 보류:\", w)\n", + "truth = pd.DataFrame(by_unit(0, 11), columns=FIELDS).sort_values([\"unit\", \"item\"]).reset_index(drop=True)\n", + "same = df.equals(truth.astype(df.dtypes.to_dict()))\n", + "print(f\"원본 대비 값 일치: {'전부 일치' if same else '불일치'} ({len(df)}행 = 12개 부대 x 11품목 = {12 * 11}행)\")\n", + "print(\"표준 원장 미리보기(앞 4행):\")\n", + "print(df.head(4).to_string(index=False))\n", + "print(f\"\\n목표(4개 형식 파싱 성공률 100%, 양식 변경 대응, 오류 레코드 보류): {'달성' if rate == 1 and same and len(hold) == 2 else '미달'}\")\n", + "print(\"새 서식 대응은 ALIAS 사전에 머리글 별칭만 추가하면 된다(코드 수정 불필요).\")\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 02 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + "[형식별 파싱 결과] [교육용가상자료]\n", + " - csv 파싱 성공, 읽은 레코드 34건\n", + " - excel_tab 파싱 성공, 읽은 레코드 33건\n", + " - json 파싱 성공, 읽은 레코드 33건\n", + " - memo 파싱 성공, 읽은 레코드 34건\n", + "파싱 성공률 100%(4개 형식), 정상 반영 132건, 반영 보류 2건\n", + " 보류: [csv] 존재하지 않는 부대 코드 가상99대대\n", + " 보류: [memo] 숫자 변환 실패: 가상10대대 훈련탄A\n", + "원본 대비 값 일치: 전부 일치 (132행 = 12개 부대 x 11품목 = 132행)\n", + "표준 원장 미리보기(앞 4행):\n", + " unit cat item open recv issue ret close\n", + "가상01대대 물자불출 급식류 402 51 39 3 417\n", + "가상01대대 탄약정산 모의탄 943 147 117 11 984\n", + "가상01대대 수리부속 베어링류 261 34 142 1 154\n", + "가상01대대 물자불출 유류 1279 257 108 5 1433\n", + "\n", + "목표(4개 형식 파싱 성공률 100%, 양식 변경 대응, 오류 레코드 보류): 달성\n", + "새 서식 대응은 ALIAS 사전에 머리글 별칭만 추가하면 된다(코드 수정 불필요).\n", + "```\n", + "
\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 03. 표준 정산 규칙 기반 1차 월간 결산 집계 및 통계 산출 Baseline 엔진 구현\n", + "**소요시간**: 약 2.5시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "표준 정산 규칙 기반 1차 월간 결산 집계 Baseline 엔진을 구현한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **표준 정산 규칙을 설정 문자열 룰 15종으로 분리하고, 이를 적용한 1차 월간 결산 집계 Baseline 엔진을 구현한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **룰마다 가상 조항 번호, 조건식, 오류 메시지를 설정으로 두어 코드 수정 없이 바꿀 수 있게 수행하고 검증하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `src/engine/dat06_rule_engine.py` 형태의 결과물을 완성한다.\n", + "\n", + "### 🔍 평가 및 검증 기준 (PASS 조건)\n", + "#### 미션 판정\n", + "\n", + "**평가 구분:** 필수\n", + "\n", + "**핵심 평가영역:** 군 규정 및 지침 조항과의 정확한 일치성\n", + "\n", + "#### 미션 PASS 조건\n", + "\n", + "- 정량 기준: 업무 검증 룰 15종 이상 코딩, 룰 처리 지연시간 50ms 이하\n", + "\n", + "\n", + "- 진행 기준: 룰 엔진 단위 테스트 100% 통과\n", + "\n", + "\n", + "- 검증 방법: 경계 조건(Boundary condition)에 대한 룰 판정 검증\n", + "\n", + "\n", + "#### 채점자 판정 메모\n", + "\n", + "> 단순 코드 실행 여부뿐만 아니라 출력된 수치와 근거 자료가 군 실무 요구사항에 부합하는지 면밀히 확인한다.\n", + "\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "\"\"\"설정 문자열로 분리한 검증 룰 15종(가상 정산 규정 조항 대응)으로 월간 원장을 검사하고, 표준 정산 규칙에 따른 1차 결산 집계 Baseline을 계산하며 처리 지연시간과 경계 조건 단위 테스트를 확인한다.\"\"\"\n", + "import time\n", + "import numpy as np\n", + "import pandas as pd\n", + "\n", + "# ---------------------------------------------------------------------------\n", + "# [교육용가상자료] 가상 월간 결산 원장 생성기 (실제 부대명·품목·수량·재무 정보 없음)\n", + "# 가상 대대 24개 x (물자 불출 / 수리부속 소모 / 탄약 정산) 품목 원장과 장비 가동 현황을 만든다.\n", + "# 재고 산식: 기말 = 기초 + 수령 + 반납 - 불출, 다음 달 기초 = 이번 달 기말.\n", + "# ---------------------------------------------------------------------------\n", + "UNITS = [f\"가상{i:02d}대대\" for i in range(1, 25)]\n", + "ITEMS = {\"물자불출\": [\"급식류\", \"피복류\", \"유류\", \"정비소모품\"],\n", + " \"수리부속\": [\"필터류\", \"베어링류\", \"전장품\", \"타이어류\"],\n", + " \"탄약정산\": [\"훈련탄A\", \"훈련탄B\", \"모의탄\"]}\n", + "UNIT_CODE = {\"급식류\": \"BX\", \"피복류\": \"EA\", \"유류\": \"L\", \"정비소모품\": \"EA\", \"필터류\": \"EA\", \"베어링류\": \"EA\",\n", + " \"전장품\": \"EA\", \"타이어류\": \"EA\", \"훈련탄A\": \"RD\", \"훈련탄B\": \"RD\", \"모의탄\": \"RD\"}\n", + "LEDGER_COLS = [\"month\", \"unit\", \"cat\", \"item\", \"open\", \"recv\", \"issue\", \"ret\", \"close\"]\n", + "\n", + "\n", + "def make_ledger(n_months=2, seed=60):\n", + " \"\"\"n_months개월 원장(list of dict). 둘째 달 기초는 첫째 달 기말과 같다.\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " rows, prev = [], {}\n", + " for m in range(n_months):\n", + " for u in UNITS:\n", + " for cat, items in ITEMS.items():\n", + " for it in items:\n", + " base = int(rng.integers(80, 400))\n", + " op = prev.get((u, it), base * 3)\n", + " recv = int(rng.integers(0, base))\n", + " issue = int(rng.integers(base // 4, base // 2 + 1))\n", + " ret = int(issue * rng.uniform(0, 0.15))\n", + " cl = op + recv + ret - issue\n", + " rows.append({\"month\": f\"2026-{m + 7:02d}\", \"unit\": u, \"cat\": cat, \"item\": it,\n", + " \"open\": op, \"recv\": recv, \"issue\": issue, \"ret\": ret, \"close\": cl})\n", + " prev[(u, it)] = cl\n", + " return rows\n", + "\n", + "\n", + "def make_equip(n_months=2, seed=61):\n", + " rng = np.random.default_rng(seed)\n", + " out = []\n", + " for m in range(n_months):\n", + " for u in UNITS:\n", + " hold = int(rng.integers(20, 60))\n", + " out.append({\"month\": f\"2026-{m + 7:02d}\", \"unit\": u, \"hold\": hold, \"operable\": int(hold * rng.uniform(0.78, 0.97))})\n", + " return out\n", + "\n", + "# 1) 검증 룰 설정: (룰ID, 가상 조항, 조건식(참이어야 정상), 오류 메시지) - 코드 수정 없이 문자열만 바꿔 룰 변경\n", + "RULES = [\n", + " (\"V01\", \"가상규정 제1조\", \"unit in UNITS\", \"존재하지 않는 부대\"),\n", + " (\"V02\", \"가상규정 제2조\", \"item in UNIT_CODE\", \"알 수 없는 품목\"),\n", + " (\"V03\", \"가상규정 제3조\", \"cat in ITEMS and item in ITEMS[cat]\", \"구분과 품목 불일치\"),\n", + " (\"V04\", \"가상규정 제4조\", \"open >= 0\", \"기초 재고 음수\"),\n", + " (\"V05\", \"가상규정 제4조\", \"recv >= 0\", \"수령량 음수\"),\n", + " (\"V06\", \"가상규정 제4조\", \"issue >= 0\", \"불출량 음수\"),\n", + " (\"V07\", \"가상규정 제4조\", \"ret >= 0\", \"반납량 음수\"),\n", + " (\"V08\", \"가상규정 제5조\", \"close == open + recv + ret - issue\", \"기말 산식 불일치\"),\n", + " (\"V09\", \"가상규정 제6조\", \"issue <= open + recv\", \"가용량 초과 불출\"),\n", + " (\"V10\", \"가상규정 제6조\", \"ret <= issue\", \"불출보다 큰 반납\"),\n", + " (\"V11\", \"가상규정 제7조\", \"close >= 0\", \"기말 재고 음수\"),\n", + " (\"V12\", \"가상규정 제8조\", \"len(month) == 7 and month[4] == '-'\", \"결산월 형식 오류\"),\n", + " (\"V13\", \"가상규정 제9조\", \"cat != '탄약정산' or issue <= 5000\", \"탄약 월 사용량 한도 초과\"),\n", + " (\"V14\", \"가상규정 제10조\", \"isinstance(open, int) and isinstance(close, int)\", \"수량 자료형 오류\"),\n", + " (\"V15\", \"가상규정 제11조\", \"issue <= max(open + recv, 1) * 0.9 or issue == 0\", \"불출 비율 과다(90% 초과)\"),\n", + "]\n", + "COMPILED = [(rid, art, compile(expr, rid, \"eval\"), msg) for rid, art, expr, msg in RULES]\n", + "CTX = {\"UNITS\": UNITS, \"UNIT_CODE\": UNIT_CODE, \"ITEMS\": ITEMS}\n", + "\n", + "\n", + "def check_row(row):\n", + " # [TODO : 학생 실습 3] 표준 정산 규칙 기반 1차 월간 결산 집계 및 통계 산출 Baseline 엔진 구현 관련 핵심 로직을 구현하세요.\n", + " # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n", + " # <<< 여기에 코드를 작성하세요 >>>\n", + " pass # 작성 후 pass를 제거하세요.\n", + "rows = make_ledger(2)\n", + "rng = np.random.default_rng(3)\n", + "for i in rng.choice(len(rows), 6, replace=False): # 오류 6건 주입(룰이 잡는지 확인)\n", + " k = int(rng.integers(0, 3))\n", + " rows[i] = {**rows[i], **[{\"close\": rows[i][\"close\"] + 7}, {\"issue\": rows[i][\"open\"] + rows[i][\"recv\"] + 50}, {\"ret\": -3}][k]}\n", + "lat, found = [], []\n", + "for r in rows:\n", + " t0 = time.perf_counter()\n", + " res = check_row(r)\n", + " lat.append((time.perf_counter() - t0) * 1000)\n", + " found += [(r[\"month\"], r[\"unit\"], r[\"item\"], *x) for x in res]\n", + "lat = np.array(lat)\n", + "print(f\"[검증 룰 {len(RULES)}종 적용] 대상 {len(rows)}행, 위반 {len(found)}건 [교육용가상자료]\")\n", + "cnt = pd.Series([f[3] for f in found]).value_counts()\n", + "print(\"룰별 위반 건수:\", {k: int(v) for k, v in cnt.items()})\n", + "print(\"위반 예시(3건):\")\n", + "for f in found[:3]:\n", + " print(\" \", f)\n", + "print(f\"룰 처리 지연: 평균 {lat.mean():.3f}ms, 최대 {lat.max():.3f}ms (목표 50ms 이하: {'달성' if lat.max() <= 50 else '미달'}, 환경에 따라 다름)\")\n", + "\n", + "# 3) 1차 결산 집계 Baseline: 부대 x 구분 합계, 구분별 통계\n", + "clean = pd.DataFrame([r for r in make_ledger(2) if r[\"month\"] == \"2026-08\"])\n", + "pv = clean.pivot_table(index=\"unit\", columns=\"cat\", values=\"issue\", aggfunc=\"sum\")\n", + "print(\"\\n[1차 결산 집계 - 8월 불출·소모량 부대별(상위 4개)]\")\n", + "print(pv.head(4).to_string())\n", + "st = pv.agg([\"sum\", \"mean\", \"std\"]).round(1)\n", + "print(\"[구분별 합계·평균·표준편차]\")\n", + "print(st.to_string())\n", + "print(f\"전체 기말 재고 합계 {int(clean['close'].sum())}, 보고 대상 부대 {clean['unit'].nunique()}개\")\n", + "\n", + "# 4) 경계 조건 단위 테스트\n", + "base = {\"month\": \"2026-08\", \"unit\": UNITS[0], \"cat\": \"물자불출\", \"item\": \"유류\", \"open\": 100, \"recv\": 0, \"issue\": 0, \"ret\": 0, \"close\": 100}\n", + "tests = [(\"정상 행\", base, []),\n", + " (\"불출 = 가용량 정확히 일치(V15만 위반)\", {**base, \"issue\": 100, \"close\": 0}, [\"V15\"]),\n", + " (\"불출 = 가용량+1 (V09, V11, V15)\", {**base, \"issue\": 101, \"close\": -1}, [\"V09\", \"V11\", \"V15\"]),\n", + " (\"기말 0 허용\", {**base, \"issue\": 90, \"close\": 10, \"ret\": 0}, []),\n", + " (\"반납 = 불출 경계 허용\", {**base, \"issue\": 50, \"ret\": 50, \"close\": 100}, []),\n", + " (\"탄약 한도 5000 경계\", {**base, \"cat\": \"탄약정산\", \"item\": \"훈련탄A\", \"open\": 9000, \"issue\": 5000, \"close\": 4000}, []),\n", + " (\"탄약 한도 5001 초과\", {**base, \"cat\": \"탄약정산\", \"item\": \"훈련탄A\", \"open\": 9000, \"issue\": 5001, \"close\": 3999}, [\"V13\"]),\n", + " (\"결산월 형식 오류\", {**base, \"month\": \"202608\"}, [\"V12\"]),\n", + " (\"자료형 오류(문자열 수량)\", {**base, \"open\": \"100\"}, [\"V14\"])] # 이 경우 V14 포함 여부만 확인\n", + "ok = 0\n", + "print(\"\\n[경계 조건 단위 테스트]\")\n", + "for name, r, exp in tests:\n", + " got = sorted(x[0] for x in check_row(r))\n", + " p = set(exp) <= set(got) if name.startswith(\"자료형\") else got == exp\n", + " ok += p\n", + " print(f\" - {name}: 기대 {exp} / 결과 {got} -> {'PASS' if p else 'FAIL'}\")\n", + "print(f\"단위 테스트 {ok}/{len(tests)} 통과({ok / len(tests):.0%})\")\n", + "print(f\"목표(룰 15종 이상, 지연 50ms 이하, 단위 테스트 100%): {'달성' if len(RULES) >= 15 and lat.max() <= 50 and ok == len(tests) else '미달'}\")\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 03 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + "[검증 룰 15종 적용] 대상 528행, 위반 13건 [교육용가상자료]\n", + "룰별 위반 건수: {'V08': 6, 'V09': 3, 'V15': 3, 'V07': 1}\n", + "위반 예시(3건):\n", + " ('2026-07', '가상05대대', '급식류', 'V08', '가상규정 제5조', '기말 산식 불일치')\n", + " ('2026-07', '가상05대대', '급식류', 'V09', '가상규정 제6조', '가용량 초과 불출')\n", + " ('2026-07', '가상05대대', '급식류', 'V15', '가상규정 제11조', '불출 비율 과다(90% 초과)')\n", + "룰 처리 지연: 평균 0.008ms, 최대 0.072ms (목표 50ms 이하: 달성, 환경에 따라 다름)\n", + "\n", + "[1차 결산 집계 - 8월 불출·소모량 부대별(상위 4개)]\n", + "cat 물자불출 수리부속 탄약정산\n", + "unit \n", + "가상01대대 378 385 384\n", + "가상02대대 255 427 234\n", + "가상03대대 239 371 168\n", + "가상04대대 305 258 319\n", + "[구분별 합계·평균·표준편차]\n", + "cat 물자불출 수리부속 탄약정산\n", + "sum 8270.0 8184.0 6743.0\n", + "mean 344.6 341.0 281.0\n", + "std 79.3 72.2 75.9\n", + "전체 기말 재고 합계 199291, 보고 대상 부대 24개\n", + "\n", + "[경계 조건 단위 테스트]\n", + " - 정상 행: 기대 [] / 결과 [] -> PASS\n", + " - 불출 = 가용량 정확히 일치(V15만 위반): 기대 ['V15'] / 결과 ['V15'] -> PASS\n", + " - 불출 = 가용량+1 (V09, V11, V15): 기대 ['V09', 'V11', 'V15'] / 결과 ['V09', 'V11', 'V15'] -> PASS\n", + " - 기말 0 허용: 기대 [] / 결과 [] -> PASS\n", + " - 반납 = 불출 경계 허용: 기대 [] / 결과 [] -> PASS\n", + " - 탄약 한도 5000 경계: 기대 [] / 결과 [] -> PASS\n", + " - 탄약 한도 5001 초과: 기대 ['V13'] / 결과 ['V13'] -> PASS\n", + " - 결산월 형식 오류: 기대 ['V12'] / 결과 ['V12'] -> PASS\n", + " - 자료형 오류(문자열 수량): 기대 ['V14'] / 결과 ['V04', 'V08', 'V09', 'V14', 'V15'] -> PASS\n", + "단위 테스트 9/9 통과(100%)\n", + "목표(룰 15종 이상, 지연 50ms 이하, 단위 테스트 100%): 달성\n", + "```\n", + "
\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 04. 데이터 이상치(전월 대비 급증 항목) 자동 감지 및 이상 원인 요약 추천 모듈 고도화\n", + "**소요시간**: 약 3.0시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "전월 대비 급증 항목 이상치 감지 및 이상 원인 요약 추천 모듈을 고도화한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **전월 대비 급증한 품목을 자동 감지하고 이상 원인을 요약해 추천하는 모듈을 고도화한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **품목별 증가율의 로그 값에 중앙값과 MAD 기반 점수를 적용해 급증 품목을 찾는다 과정을 진행하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `src/ai/dat06_intelligence_module.py` 형태의 결과물을 완성한다.\n", + "\n", + "### 🔍 평가 및 검증 기준 (PASS 조건)\n", + "#### 미션 판정\n", + "\n", + "**평가 구분:** 필수\n", + "\n", + "**핵심 평가영역:** 추천 사유의 합리성 및 실무 수용성\n", + "\n", + "#### 미션 PASS 조건\n", + "\n", + "- 정량 기준: 추천/분류 정확도 85% 이상 달성\n", + "\n", + "\n", + "- 진행 기준: AI 추론 모듈 구현 및 벤치마크 테스트 완료\n", + "\n", + "\n", + "- 검증 방법: 과거 승인/처리 완료 데이터와의 추천 일치도 검증\n", + "\n", + "\n", + "#### 채점자 판정 메모\n", + "\n", + "> 단순 코드 실행 여부뿐만 아니라 출력된 수치와 근거 자료가 군 실무 요구사항에 부합하는지 면밀히 확인한다.\n", + "\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "\"\"\"전월 대비 급증한 품목을 로버스트 통계로 자동 감지하고, 훈련·정비 일정 등 맥락 정보와 대조해 이상 원인을 요약·추천하는 모듈을 구현해 감지 정확도와 원인 분류 정확도를 측정한다.\"\"\"\n", + "import numpy as np\n", + "import pandas as pd\n", + "\n", + "# ---------------------------------------------------------------------------\n", + "# [교육용가상자료] 가상 월간 결산 원장 생성기 (실제 부대명·품목·수량·재무 정보 없음)\n", + "# 가상 대대 24개 x (물자 불출 / 수리부속 소모 / 탄약 정산) 품목 원장과 장비 가동 현황을 만든다.\n", + "# 재고 산식: 기말 = 기초 + 수령 + 반납 - 불출, 다음 달 기초 = 이번 달 기말.\n", + "# ---------------------------------------------------------------------------\n", + "UNITS = [f\"가상{i:02d}대대\" for i in range(1, 25)]\n", + "ITEMS = {\"물자불출\": [\"급식류\", \"피복류\", \"유류\", \"정비소모품\"],\n", + " \"수리부속\": [\"필터류\", \"베어링류\", \"전장품\", \"타이어류\"],\n", + " \"탄약정산\": [\"훈련탄A\", \"훈련탄B\", \"모의탄\"]}\n", + "UNIT_CODE = {\"급식류\": \"BX\", \"피복류\": \"EA\", \"유류\": \"L\", \"정비소모품\": \"EA\", \"필터류\": \"EA\", \"베어링류\": \"EA\",\n", + " \"전장품\": \"EA\", \"타이어류\": \"EA\", \"훈련탄A\": \"RD\", \"훈련탄B\": \"RD\", \"모의탄\": \"RD\"}\n", + "LEDGER_COLS = [\"month\", \"unit\", \"cat\", \"item\", \"open\", \"recv\", \"issue\", \"ret\", \"close\"]\n", + "\n", + "\n", + "def make_ledger(n_months=2, seed=60):\n", + " \"\"\"n_months개월 원장(list of dict). 둘째 달 기초는 첫째 달 기말과 같다.\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " rows, prev = [], {}\n", + " for m in range(n_months):\n", + " for u in UNITS:\n", + " for cat, items in ITEMS.items():\n", + " for it in items:\n", + " base = int(rng.integers(80, 400))\n", + " op = prev.get((u, it), base * 3)\n", + " recv = int(rng.integers(0, base))\n", + " issue = int(rng.integers(base // 4, base // 2 + 1))\n", + " ret = int(issue * rng.uniform(0, 0.15))\n", + " cl = op + recv + ret - issue\n", + " rows.append({\"month\": f\"2026-{m + 7:02d}\", \"unit\": u, \"cat\": cat, \"item\": it,\n", + " \"open\": op, \"recv\": recv, \"issue\": issue, \"ret\": ret, \"close\": cl})\n", + " prev[(u, it)] = cl\n", + " return rows\n", + "\n", + "\n", + "def make_equip(n_months=2, seed=61):\n", + " rng = np.random.default_rng(seed)\n", + " out = []\n", + " for m in range(n_months):\n", + " for u in UNITS:\n", + " hold = int(rng.integers(20, 60))\n", + " out.append({\"month\": f\"2026-{m + 7:02d}\", \"unit\": u, \"hold\": hold, \"operable\": int(hold * rng.uniform(0.78, 0.97))})\n", + " return out\n", + "\n", + "# 1) 가상 2개월 데이터 + 정답(숨은 원인) 생성\n", + "led = pd.DataFrame(make_ledger(2))\n", + "p7 = led[led[\"month\"] == \"2026-07\"].set_index([\"unit\", \"item\"])\n", + "p8 = led[led[\"month\"] == \"2026-08\"].set_index([\"unit\", \"item\"])\n", + "d = pd.DataFrame({\"cat\": p8[\"cat\"], \"prev\": p7[\"issue\"].clip(lower=1), \"cur\": p8[\"issue\"]})\n", + "rng = np.random.default_rng(4)\n", + "n = len(d)\n", + "d[\"train_days\"] = rng.integers(0, 5, n) # 해당 부대 월 훈련 일수(맥락)\n", + "d[\"repair_orders\"] = rng.integers(0, 4, n) # 정비 지시서 건수(맥락)\n", + "d[\"special\"] = 0 # 일회성 특별 지급 승인 여부\n", + "d[\"truth\"] = \"정상\"\n", + "d[\"cur\"] = (d[\"prev\"] * rng.uniform(0.7, 1.4, n)).round().astype(int) # 정상 변동(-30%~+40%)\n", + "CAUSES = [\"훈련 증가\", \"정비 집중\", \"입력 자릿수 오류\", \"일회성 특별 지급\", \"원인 불명\"]\n", + "idx = rng.choice(n, 36, replace=False)\n", + "for k, i in enumerate(idx):\n", + " c = CAUSES[k % 5]\n", + " d.iloc[i, d.columns.get_loc(\"truth\")] = c\n", + " f = {\"훈련 증가\": rng.uniform(1.8, 3.0), \"정비 집중\": rng.uniform(1.8, 3.2), \"입력 자릿수 오류\": 10.0,\n", + " \"일회성 특별 지급\": rng.uniform(2.0, 4.0), \"원인 불명\": rng.uniform(1.9, 3.0)}[c]\n", + " d.iloc[i, d.columns.get_loc(\"cur\")] = int(d.iloc[i][\"prev\"] * f)\n", + " if c == \"훈련 증가\":\n", + " d.iloc[i, d.columns.get_loc(\"train_days\")] = rng.integers(8, 14)\n", + " if c == \"정비 집중\":\n", + " d.iloc[i, d.columns.get_loc(\"repair_orders\")] = rng.integers(7, 12)\n", + " if c == \"일회성 특별 지급\":\n", + " d.iloc[i, d.columns.get_loc(\"special\")] = 1\n", + "for i in rng.choice(n, 8, replace=False): # 정상이지만 활동이 많았던 곳(오탐 유도)\n", + " d.iloc[i, d.columns.get_loc(\"train_days\")] = rng.integers(8, 14)\n", + "\n", + "# 2) 감지: 품목별 로그 증가율의 중앙값·MAD 기반 z 점수\n", + "d[\"ratio\"] = d[\"cur\"] / d[\"prev\"]\n", + "lr = np.log(d[\"ratio\"])\n", + "med = lr.groupby(level=\"item\").transform(\"median\")\n", + "mad = (lr - med).abs().groupby(level=\"item\").transform(\"median\").clip(lower=0.05) * 1.4826\n", + "d[\"z\"] = (lr - med) / mad\n", + "d[\"flag\"] = (d[\"z\"] > 2.0) & (d[\"ratio\"] >= 1.6)\n", + "\n", + "# 3) 원인 요약 추천(규칙 기반, 사람이 확인할 후보를 제시)\n", + "def recommend(r):\n", + " # [TODO : 학생 실습 4] 데이터 이상치(전월 대비 급증 항목) 자동 감지 및 이상 원인 요약 추천 모듈 고도화 관련 핵심 로직을 구현하세요.\n", + " # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n", + " # <<< 여기에 코드를 작성하세요 >>>\n", + " pass # 작성 후 pass를 제거하세요.\n", + "ACTION = {\"훈련 증가\": \"훈련 계획 대조 후 정상 처리\", \"정비 집중\": \"정비 지시서와 소모 수량 대조\", \"입력 자릿수 오류\": \"제출 부대에 수치 재확인 요청\",\n", + " \"일회성 특별 지급\": \"승인 문서 번호 확인\", \"원인 불명\": \"담당자가 부대에 직접 문의\"}\n", + "d[\"cause\"] = np.where(d[\"flag\"], d.apply(recommend, axis=1), \"정상\")\n", + "\n", + "# 4) 평가\n", + "truth_anom = d[\"truth\"] != \"정상\"\n", + "tp = int((d[\"flag\"] & truth_anom).sum()); fp = int((d[\"flag\"] & ~truth_anom).sum()); fn = int((~d[\"flag\"] & truth_anom).sum())\n", + "prec, rec = tp / max(tp + fp, 1), tp / max(tp + fn, 1)\n", + "acc = float((d[\"flag\"] == truth_anom).mean())\n", + "both = d[d[\"flag\"] & truth_anom]\n", + "cause_acc = float((both[\"cause\"] == both[\"truth\"]).mean())\n", + "total_acc = float(((d[\"cause\"] == d[\"truth\"])).mean())\n", + "print(f\"[이상 감지] 대상 {n}건(부대 24 x 품목 11), 실제 급증 {int(truth_anom.sum())}건 [교육용가상자료]\")\n", + "print(f\"감지 {int(d['flag'].sum())}건: 정확도 {acc:.1%}, 정밀도 {prec:.1%}, 재현율 {rec:.1%} (TP {tp}, FP {fp}, FN {fn})\")\n", + "print(f\"원인 분류 정확도(감지된 실제 급증 중) {cause_acc:.1%}, 전체 분류(정상 포함) 정확도 {total_acc:.1%}\")\n", + "print(\"\\n[원인별 감지·추천 현황]\")\n", + "print(pd.crosstab(both[\"truth\"], both[\"cause\"]).to_string())\n", + "print(\"\\n[이상 원인 요약 추천 예시 - 증가율 상위 5건]\")\n", + "for (u, it), r in d[d[\"flag\"]].sort_values(\"ratio\", ascending=False).head(5).iterrows():\n", + " print(f\" - {u} {it}: 전월 {int(r['prev'])} -> 당월 {int(r['cur'])}({r['ratio']:.1f}배), 추정 원인 '{r['cause']}' -> 조치: {ACTION[r['cause']]}\")\n", + "print(\"추천은 후보 제시이며 최종 원인 판단과 수치 정정은 담당자가 확인한다.\")\n", + "print(f\"\\n목표(추천·분류 정확도 85% 이상): 감지 {'달성' if acc >= 0.85 else '미달'}, 원인 분류 {'달성' if cause_acc >= 0.85 else '미달'}\")\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 04 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + "[이상 감지] 대상 264건(부대 24 x 품목 11), 실제 급증 36건 [교육용가상자료]\n", + "감지 35건: 정확도 99.6%, 정밀도 100.0%, 재현율 97.2% (TP 35, FP 0, FN 1)\n", + "원인 분류 정확도(감지된 실제 급증 중) 100.0%, 전체 분류(정상 포함) 정확도 99.6%\n", + "\n", + "[원인별 감지·추천 현황]\n", + "cause 원인 불명 일회성 특별 지급 입력 자릿수 오류 정비 집중 훈련 증가\n", + "truth \n", + "원인 불명 6 0 0 0 0\n", + "일회성 특별 지급 0 7 0 0 0\n", + "입력 자릿수 오류 0 0 7 0 0\n", + "정비 집중 0 0 0 7 0\n", + "훈련 증가 0 0 0 0 8\n", + "\n", + "[이상 원인 요약 추천 예시 - 증가율 상위 5건]\n", + " - 가상01대대 훈련탄B: 전월 93 -> 당월 930(10.0배), 추정 원인 '입력 자릿수 오류' -> 조치: 제출 부대에 수치 재확인 요청\n", + " - 가상15대대 피복류: 전월 119 -> 당월 1190(10.0배), 추정 원인 '입력 자릿수 오류' -> 조치: 제출 부대에 수치 재확인 요청\n", + " - 가상15대대 정비소모품: 전월 124 -> 당월 1240(10.0배), 추정 원인 '입력 자릿수 오류' -> 조치: 제출 부대에 수치 재확인 요청\n", + " - 가상19대대 모의탄: 전월 174 -> 당월 1740(10.0배), 추정 원인 '입력 자릿수 오류' -> 조치: 제출 부대에 수치 재확인 요청\n", + " - 가상21대대 정비소모품: 전월 146 -> 당월 1460(10.0배), 추정 원인 '입력 자릿수 오류' -> 조치: 제출 부대에 수치 재확인 요청\n", + "추천은 후보 제시이며 최종 원인 판단과 수치 정정은 담당자가 확인한다.\n", + "\n", + "목표(추천·분류 정확도 85% 이상): 감지 달성, 원인 분류 달성\n", + "```\n", + "
\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 05. 정산 결과의 회계적 일관성 검증 및 신뢰도 검증 엔진 개발\n", + "**소요시간**: 약 3.0시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "정산 결과의 회계적 일관성 검증 및 신뢰도 검증 엔진을 개발한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **정산 결과의 회계적 일관성을 검증하고 부대별 신뢰도 점수를 산출하는 엔진을 입력-분석-검토-결과 4개 화면으로 구현한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **재고 산식, 전월 기말과 당월 기초의 연속성, 부대 합계와 분류 합계의 교차 대조, 장비 가동률 범위를 검사하고 오류 8건을 주입해 검출 여부 확인하고 유효성을 점검하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `src/ui/dat06_app.py` 형태의 결과물을 완성한다.\n", + "\n", + "### 🔍 평가 및 검증 기준 (PASS 조건)\n", + "#### 미션 판정\n", + "\n", + "**평가 구분:** 필수\n", + "\n", + "**핵심 평가영역:** 군 실무자 친화적 디자인 및 조작 편의성\n", + "\n", + "#### 미션 PASS 조건\n", + "\n", + "- 정량 기준: 핵심 화면 4종(입력-분석-검토-결과) 구현 완료\n", + "\n", + "\n", + "- 진행 기준: UI 프로토타입 로컬 서버 구동 및 전체 페이지 네비게이션 확인\n", + "\n", + "\n", + "- 검증 방법: 사용성 평가(UX Walkthrough) 및 인터랙션 테스트\n", + "\n", + "\n", + "#### 채점자 판정 메모\n", + "\n", + "> 단순 코드 실행 여부뿐만 아니라 출력된 수치와 근거 자료가 군 실무 요구사항에 부합하는지 면밀히 확인한다.\n", + "\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# [TODO : 학생 실습 5] 정산 결과의 회계적 일관성 검증 및 신뢰도 검증 엔진 개발\n", + "# 미션 지침 및 검증 기준을 확인하고 코드를 실행 및 튜닝하세요.\n", + "\"\"\"결산 결과의 회계적 일관성(재고 산식·월 연속성·합계 교차 대조·가동률 범위)을 검증해 부대별 신뢰도 점수를 산출하고, 입력-분석-검토-결과 4개 화면을 텍스트 화면으로 구현한다.\"\"\"\n", + "import numpy as np\n", + "import pandas as pd\n", + "\n", + "# ---------------------------------------------------------------------------\n", + "# [교육용가상자료] 가상 월간 결산 원장 생성기 (실제 부대명·품목·수량·재무 정보 없음)\n", + "# 가상 대대 24개 x (물자 불출 / 수리부속 소모 / 탄약 정산) 품목 원장과 장비 가동 현황을 만든다.\n", + "# 재고 산식: 기말 = 기초 + 수령 + 반납 - 불출, 다음 달 기초 = 이번 달 기말.\n", + "# ---------------------------------------------------------------------------\n", + "UNITS = [f\"가상{i:02d}대대\" for i in range(1, 25)]\n", + "ITEMS = {\"물자불출\": [\"급식류\", \"피복류\", \"유류\", \"정비소모품\"],\n", + " \"수리부속\": [\"필터류\", \"베어링류\", \"전장품\", \"타이어류\"],\n", + " \"탄약정산\": [\"훈련탄A\", \"훈련탄B\", \"모의탄\"]}\n", + "UNIT_CODE = {\"급식류\": \"BX\", \"피복류\": \"EA\", \"유류\": \"L\", \"정비소모품\": \"EA\", \"필터류\": \"EA\", \"베어링류\": \"EA\",\n", + " \"전장품\": \"EA\", \"타이어류\": \"EA\", \"훈련탄A\": \"RD\", \"훈련탄B\": \"RD\", \"모의탄\": \"RD\"}\n", + "LEDGER_COLS = [\"month\", \"unit\", \"cat\", \"item\", \"open\", \"recv\", \"issue\", \"ret\", \"close\"]\n", + "\n", + "\n", + "def make_ledger(n_months=2, seed=60):\n", + " \"\"\"n_months개월 원장(list of dict). 둘째 달 기초는 첫째 달 기말과 같다.\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " rows, prev = [], {}\n", + " for m in range(n_months):\n", + " for u in UNITS:\n", + " for cat, items in ITEMS.items():\n", + " for it in items:\n", + " base = int(rng.integers(80, 400))\n", + " op = prev.get((u, it), base * 3)\n", + " recv = int(rng.integers(0, base))\n", + " issue = int(rng.integers(base // 4, base // 2 + 1))\n", + " ret = int(issue * rng.uniform(0, 0.15))\n", + " cl = op + recv + ret - issue\n", + " rows.append({\"month\": f\"2026-{m + 7:02d}\", \"unit\": u, \"cat\": cat, \"item\": it,\n", + " \"open\": op, \"recv\": recv, \"issue\": issue, \"ret\": ret, \"close\": cl})\n", + " prev[(u, it)] = cl\n", + " return rows\n", + "\n", + "\n", + "def make_equip(n_months=2, seed=61):\n", + " rng = np.random.default_rng(seed)\n", + " out = []\n", + " for m in range(n_months):\n", + " for u in UNITS:\n", + " hold = int(rng.integers(20, 60))\n", + " out.append({\"month\": f\"2026-{m + 7:02d}\", \"unit\": u, \"hold\": hold, \"operable\": int(hold * rng.uniform(0.78, 0.97))})\n", + " return out\n", + "\n", + "# ---------- 화면 1: 입력 ----------\n", + "led = pd.DataFrame(make_ledger(2))\n", + "eq = pd.DataFrame(make_equip(2))\n", + "cur = led[led[\"month\"] == \"2026-08\"].copy()\n", + "prev = led[led[\"month\"] == \"2026-07\"].set_index([\"unit\", \"item\"])\n", + "rng = np.random.default_rng(5)\n", + "faults = [] # 가상 오류 주입 8건\n", + "for i in rng.choice(cur.index, 8, replace=False):\n", + " kind = [\"산식\", \"연속성\", \"산식\", \"연속성\"][len(faults) % 4]\n", + " if kind == \"산식\":\n", + " cur.loc[i, \"close\"] += int(rng.integers(5, 40))\n", + " else:\n", + " cur.loc[i, \"open\"] += int(rng.integers(5, 40)); cur.loc[i, \"close\"] = cur.loc[i, \"open\"] + cur.loc[i, \"recv\"] + cur.loc[i, \"ret\"] - cur.loc[i, \"issue\"]\n", + " faults.append((cur.loc[i, \"unit\"], cur.loc[i, \"item\"], kind))\n", + "eq2 = eq[eq[\"month\"] == \"2026-08\"].copy()\n", + "eq2.iloc[3, eq2.columns.get_loc(\"operable\")] = eq2.iloc[3][\"hold\"] + 4 # 가동 대수가 보유 대수 초과\n", + "SCREEN1 = f\"원장 {len(cur)}행(월 2026-08), 장비 현황 {len(eq2)}행, 전월 원장 {len(prev)}행 적재 / 양식 검사 통과\"\n", + "print(\"[화면 1. 입력]\", SCREEN1)\n", + "\n", + "# ---------- 화면 2: 분석(일관성 검사 4종) ----------\n", + "issues = []\n", + "for r in cur.itertuples():\n", + " if r.open + r.recv + r.ret - r.issue != r.close:\n", + " issues.append((r.unit, r.item, \"C1 재고 산식 불일치\", abs(r.open + r.recv + r.ret - r.issue - r.close)))\n", + " if prev.loc[(r.unit, r.item), \"close\"] != r.open:\n", + " issues.append((r.unit, r.item, \"C2 전월 기말 != 당월 기초\", abs(prev.loc[(r.unit, r.item), \"close\"] - r.open)))\n", + "unit_sum = cur.groupby(\"unit\")[[\"open\", \"recv\", \"ret\", \"issue\", \"close\"]].sum()\n", + "cat_sum = cur.groupby([\"unit\", \"cat\"])[[\"open\", \"recv\", \"ret\", \"issue\", \"close\"]].sum().groupby(\"unit\").sum()\n", + "for u in unit_sum.index: # C3 교차 대조: 부대 합계 = 분류 합계의 합\n", + " if not (unit_sum.loc[u] == cat_sum.loc[u]).all():\n", + " issues.append((u, \"-\", \"C3 합계 교차 대조 불일치\", 0))\n", + "for r in eq2.itertuples():\n", + " if not (0 <= r.operable <= r.hold):\n", + " issues.append((r.unit, \"장비\", \"C4 가동 대수가 보유 대수 초과\", r.operable - r.hold))\n", + "iss = pd.DataFrame(issues, columns=[\"부대\", \"품목\", \"검사\", \"차이\"])\n", + "print(f\"[화면 2. 분석] 검사 4종 수행, 불일치 {len(iss)}건\")\n", + "print(iss[\"검사\"].value_counts().to_string())\n", + "\n", + "# ---------- 신뢰도 점수 ----------\n", + "score = pd.Series(100.0, index=UNITS)\n", + "W = {\"C1\": 12, \"C2\": 10, \"C3\": 15, \"C4\": 8}\n", + "for r in iss.itertuples():\n", + " score[r.부대] -= W[r.검사[:2]]\n", + "score = score.clip(lower=0)\n", + "grade = pd.cut(score, [-1, 69, 89, 100], labels=[\"C(재제출)\", \"B(검토)\", \"A(양호)\"])\n", + "\n", + "# ---------- 화면 3: 검토 ----------\n", + "print(f\"\\n[화면 3. 검토] 신뢰도 100점 미만 부대와 검토 대기 사항 [교육용가상자료]\")\n", + "low = iss.groupby(\"부대\").agg(건수=(\"검사\", \"count\"), 유형=(\"검사\", lambda s: \", \".join(sorted(set(x[:2] for x in s))))).join(score.rename(\"점수\"))\n", + "print(low.sort_values(\"점수\").to_string())\n", + "print(\"검토자는 불일치 건별로 '수정 요청 / 정상 확인' 중 선택하며, 수정은 제출 부대가 한다(시스템이 값을 임의로 바꾸지 않는다).\")\n", + "\n", + "# ---------- 화면 4: 결과 ----------\n", + "detected = {(u, i) for u, i, k in faults}\n", + "caught = {(r.부대, r.품목) for r in iss.itertuples() if r.검사[:2] in (\"C1\", \"C2\")}\n", + "print(f\"\\n[화면 4. 결과] 부대 {len(score)}개 중 A {int((grade == 'A(양호)').sum())} / B {int((grade == 'B(검토)').sum())} / C {int((grade == 'C(재제출)').sum())}\")\n", + "print(f\"평균 신뢰도 {score.mean():.1f}점, 주입 오류 {len(faults)}건 중 검출 {len(detected & caught)}건({len(detected & caught) / len(faults):.0%}), 가동률 범위 오류 {int((iss['검사'].str[:2] == 'C4').sum())}건\")\n", + "ok = len(detected & caught) == len(faults)\n", + "print(f\"\\n목표(핵심 화면 4종 구현, 일관성 검증 엔진 동작): {'달성' if ok else '미달'} (화면 4종: 입력/분석/검토/결과)\")\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 05 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + "[화면 1. 입력] 원장 264행(월 2026-08), 장비 현황 24행, 전월 원장 264행 적재 / 양식 검사 통과\n", + "[화면 2. 분석] 검사 4종 수행, 불일치 9건\n", + "검사\n", + "C2 전월 기말 != 당월 기초 4\n", + "C1 재고 산식 불일치 4\n", + "C4 가동 대수가 보유 대수 초과 1\n", + "\n", + "[화면 3. 검토] 신뢰도 100점 미만 부대와 검토 대기 사항 [교육용가상자료]\n", + " 건수 유형 점수\n", + "부대 \n", + "가상16대대 2 C1, C2 78.0\n", + "가상12대대 1 C1 88.0\n", + "가상13대대 1 C1 88.0\n", + "가상20대대 1 C1 88.0\n", + "가상01대대 1 C2 90.0\n", + "가상07대대 1 C2 90.0\n", + "가상19대대 1 C2 90.0\n", + "가상04대대 1 C4 92.0\n", + "검토자는 불일치 건별로 '수정 요청 / 정상 확인' 중 선택하며, 수정은 제출 부대가 한다(시스템이 값을 임의로 바꾸지 않는다).\n", + "\n", + "[화면 4. 결과] 부대 24개 중 A 20 / B 4 / C 0\n", + "평균 신뢰도 96.0점, 주입 오류 8건 중 검출 8건(100%), 가동률 범위 오류 1건\n", + "\n", + "목표(핵심 화면 4종 구현, 일관성 검증 엔진 동작): 달성 (화면 4종: 입력/분석/검토/결과)\n", + "```\n", + "
\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 06. 군수 결산 보고서 생성 및 승인/반려 관리 대화형 웹 대시보드 구현\n", + "**소요시간**: 약 2.5시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "군수 결산 보고서 생성 및 승인/반려 대화형 웹 대시보드를 구현한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **결산 보고서 생성과 승인·반려 상태를 관리하는 대시보드를 구현하고, 예외 상황에서도 중단되지 않는 입력 검증기와 에러 핸들러를 갖춘다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **부대별 보고서를 생성해 작성-제출-승인-반려 상태 전이를 관리하고 현황판과 그래프로 보여 준다 과정을 진행하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `src/utils/dat06_validator.py 및 에러 핸들러` 형태의 결과물을 완성한다.\n", + "\n", + "### 🔍 평가 및 검증 기준 (PASS 조건)\n", + "#### 미션 판정\n", + "\n", + "**평가 구분:** 필수\n", + "\n", + "**핵심 평가영역:** 사용자에게 알기 쉬운 에러 메시지 및 조치 가이드 제공\n", + "\n", + "#### 미션 PASS 조건\n", + "\n", + "- 정량 기준: 예외 상황 발생 시 크래시(Crash) 0건, 에러 로그 기록률 100%\n", + "\n", + "\n", + "- 진행 기준: 예외 주입 테스트 10종 통과 및 감사 로그 파일 생성\n", + "\n", + "\n", + "- 검증 방법: 비정상 데이터(결측, 특수문자, 대용량) 스트레스 테스트\n", + "\n", + "\n", + "#### 채점자 판정 메모\n", + "\n", + "> 단순 코드 실행 여부뿐만 아니라 출력된 수치와 근거 자료가 군 실무 요구사항에 부합하는지 면밀히 확인한다.\n", + "\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "\"\"\"부대별 결산 보고서를 생성하고 제출·승인·반려 상태를 관리하는 대시보드(상태 현황판·그래프)를 구현하며, 입력 검증기와 에러 핸들러로 예외 10종을 크래시 없이 로그에 기록하고 사용자 조치 안내를 출력한다.\"\"\"\n", + "import json\n", + "import numpy as np\n", + "import pandas as pd\n", + "import matplotlib.pyplot as plt\n", + "\n", + "# ---------------------------------------------------------------------------\n", + "# [교육용가상자료] 가상 월간 결산 원장 생성기 (실제 부대명·품목·수량·재무 정보 없음)\n", + "# 가상 대대 24개 x (물자 불출 / 수리부속 소모 / 탄약 정산) 품목 원장과 장비 가동 현황을 만든다.\n", + "# 재고 산식: 기말 = 기초 + 수령 + 반납 - 불출, 다음 달 기초 = 이번 달 기말.\n", + "# ---------------------------------------------------------------------------\n", + "UNITS = [f\"가상{i:02d}대대\" for i in range(1, 25)]\n", + "ITEMS = {\"물자불출\": [\"급식류\", \"피복류\", \"유류\", \"정비소모품\"],\n", + " \"수리부속\": [\"필터류\", \"베어링류\", \"전장품\", \"타이어류\"],\n", + " \"탄약정산\": [\"훈련탄A\", \"훈련탄B\", \"모의탄\"]}\n", + "UNIT_CODE = {\"급식류\": \"BX\", \"피복류\": \"EA\", \"유류\": \"L\", \"정비소모품\": \"EA\", \"필터류\": \"EA\", \"베어링류\": \"EA\",\n", + " \"전장품\": \"EA\", \"타이어류\": \"EA\", \"훈련탄A\": \"RD\", \"훈련탄B\": \"RD\", \"모의탄\": \"RD\"}\n", + "LEDGER_COLS = [\"month\", \"unit\", \"cat\", \"item\", \"open\", \"recv\", \"issue\", \"ret\", \"close\"]\n", + "\n", + "\n", + "def make_ledger(n_months=2, seed=60):\n", + " \"\"\"n_months개월 원장(list of dict). 둘째 달 기초는 첫째 달 기말과 같다.\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " rows, prev = [], {}\n", + " for m in range(n_months):\n", + " for u in UNITS:\n", + " for cat, items in ITEMS.items():\n", + " for it in items:\n", + " base = int(rng.integers(80, 400))\n", + " op = prev.get((u, it), base * 3)\n", + " recv = int(rng.integers(0, base))\n", + " issue = int(rng.integers(base // 4, base // 2 + 1))\n", + " ret = int(issue * rng.uniform(0, 0.15))\n", + " cl = op + recv + ret - issue\n", + " rows.append({\"month\": f\"2026-{m + 7:02d}\", \"unit\": u, \"cat\": cat, \"item\": it,\n", + " \"open\": op, \"recv\": recv, \"issue\": issue, \"ret\": ret, \"close\": cl})\n", + " prev[(u, it)] = cl\n", + " return rows\n", + "\n", + "\n", + "def make_equip(n_months=2, seed=61):\n", + " rng = np.random.default_rng(seed)\n", + " out = []\n", + " for m in range(n_months):\n", + " for u in UNITS:\n", + " hold = int(rng.integers(20, 60))\n", + " out.append({\"month\": f\"2026-{m + 7:02d}\", \"unit\": u, \"hold\": hold, \"operable\": int(hold * rng.uniform(0.78, 0.97))})\n", + " return out\n", + "\n", + "LEDGER = pd.DataFrame(make_ledger(2))\n", + "LOG, REPORTS = [], {}\n", + "\n", + "\n", + "class AppError(Exception):\n", + " def __init__(self, code, msg, guide):\n", + " super().__init__(msg); self.code, self.msg, self.guide = code, msg, guide\n", + "\n", + "\n", + "def validate(unit, month, rows):\n", + " # [TODO : 학생 실습 6] 군수 결산 보고서 생성 및 승인/반려 관리 대화형 웹 대시보드 구현 관련 핵심 로직을 구현하세요.\n", + " # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n", + " # <<< 여기에 코드를 작성하세요 >>>\n", + " pass # 작성 후 pass를 제거하세요.\n", + "def generate(unit, month, data=None):\n", + " # [TODO : 학생 실습 6] 군수 결산 보고서 생성 및 승인/반려 관리 대화형 웹 대시보드 구현 관련 핵심 로직을 구현하세요.\n", + " # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n", + " # <<< 여기에 코드를 작성하세요 >>>\n", + " pass # 작성 후 pass를 제거하세요.\n", + "FLOW = {\"제출\": (\"작성\", \"제출\"), \"승인\": (\"제출\", \"승인\"), \"반려\": (\"제출\", \"반려\"), \"재작성\": (\"반려\", \"작성\")}\n", + "\n", + "\n", + "def act(rid, action, who, reason=\"\"):\n", + " if rid not in REPORTS:\n", + " raise AppError(\"E08\", f\"보고서 {rid} 없음\", \"보고서 번호를 목록에서 확인\")\n", + " if action not in FLOW:\n", + " raise AppError(\"E09\", f\"알 수 없는 처리 '{action}'\", \"제출/승인/반려/재작성 중 선택\")\n", + " need, new = FLOW[action]\n", + " if REPORTS[rid][\"status\"] != need:\n", + " raise AppError(\"E10\", f\"현재 상태 '{REPORTS[rid]['status']}'에서 '{action}' 불가\", f\"'{need}' 상태에서만 가능\")\n", + " if action == \"반려\" and not reason.strip():\n", + " raise AppError(\"E11\", \"반려 사유 없음\", \"반려 사유를 한 줄 이상 입력\")\n", + " REPORTS[rid][\"status\"] = new\n", + " REPORTS[rid][\"hist\"].append((new, who + (f\": {reason}\" if reason else \"\")))\n", + "\n", + "\n", + "def safe(fn, *a, **k):\n", + " \"\"\"에러 핸들러: 모든 예외를 잡아 로그에 남기고 사용자에게 알기 쉬운 메시지와 조치를 돌려준다(크래시 없음).\"\"\"\n", + " try:\n", + " r = fn(*a, **k)\n", + " LOG.append({\"level\": \"INFO\", \"code\": \"OK\", \"msg\": fn.__name__}); return r\n", + " except AppError as e:\n", + " LOG.append({\"level\": \"ERROR\", \"code\": e.code, \"msg\": e.msg, \"guide\": e.guide}); return None\n", + " except Exception as e: # 예상 밖 오류도 기록\n", + " LOG.append({\"level\": \"ERROR\", \"code\": \"E99\", \"msg\": repr(e)[:50], \"guide\": \"운영 담당자에게 로그 전달\"}); return None\n", + "\n", + "\n", + "# 1) 정상 흐름: 12개 부대 보고서 생성 -> 제출 -> 승인 8 / 반려 2 / 대기 2\n", + "rids = [safe(generate, u, \"2026-08\") for u in UNITS[:12]]\n", + "for i, rid in enumerate(rids):\n", + " safe(act, rid, \"제출\", f\"담당자{i % 3 + 1}\")\n", + " if i < 8:\n", + " safe(act, rid, \"승인\", \"보급관\")\n", + " elif i < 10:\n", + " safe(act, rid, \"반려\", \"보급관\", \"기말 재고 산식 재확인 요망\")\n", + "board = pd.DataFrame([{\"보고서\": k, **{x: v[x] for x in (\"unit\", \"status\", \"issue\", \"close\")}} for k, v in REPORTS.items()])\n", + "print(\"[결산 보고서 현황판] [교육용가상자료]\")\n", + "print(board.rename(columns={\"unit\": \"부대\", \"status\": \"상태\", \"issue\": \"불출합\", \"close\": \"기말합\"}).to_string(index=False))\n", + "print(\"상태별:\", board[\"status\"].value_counts().to_dict())\n", + "safe(act, rids[8], \"재작성\", \"담당자\")\n", + "print(\"반려 건 재작성 후 상태:\", REPORTS[rids[8]][\"status\"], \"/ 이력:\", REPORTS[rids[8]][\"hist\"][-3:])\n", + "\n", + "# 2) 예외 주입 10종\n", + "LOG.clear()\n", + "bad = LEDGER[(LEDGER[\"unit\"] == UNITS[20]) & (LEDGER[\"month\"] == \"2026-08\")].copy()\n", + "neg = bad.copy(); neg.iloc[0, neg.columns.get_loc(\"issue\")] = -5\n", + "txt = bad.copy(); txt[\"issue\"] = txt[\"issue\"].astype(str) + \"개\"\n", + "nocol = bad.drop(columns=[\"ret\"])\n", + "cases = [(\"부대명 오류\", generate, (\"가상99대대\", \"2026-08\")), (\"월 오류\", generate, (UNITS[20], \"2027-01\")),\n", + " (\"빈 자료\", generate, (UNITS[20], \"2026-08\", bad.iloc[0:0])), (\"열 누락\", generate, (UNITS[20], \"2026-08\", nocol)),\n", + " (\"숫자 아님\", generate, (UNITS[20], \"2026-08\", txt)), (\"음수\", generate, (UNITS[20], \"2026-08\", neg)),\n", + " (\"중복 생성\", generate, (UNITS[0], \"2026-08\")), (\"없는 보고서\", act, (\"RPT-00-00\", \"승인\", \"보급관\")),\n", + " (\"상태 위반(미제출 승인)\", act, (rids[8], \"승인\", \"보급관\")), (\"반려 사유 누락\", act, (rids[11], \"반려\", \"보급관\", \" \"))]\n", + "crash = 0\n", + "LOG.clear()\n", + "for name, fn, args in cases:\n", + " try:\n", + " safe(fn, *args)\n", + " except BaseException:\n", + " crash += 1\n", + "errs = [l for l in LOG if l[\"level\"] == \"ERROR\"]\n", + "print(\"\\n[예외 주입 결과 - 사용자 안내 메시지]\")\n", + "for (name, _, _), l in zip(cases, errs):\n", + " print(f\" {l['code']} {name:<14} {l['msg']:<28} -> 조치: {l['guide']}\")\n", + "rate = len(errs) / len(cases)\n", + "print(f\"예외 {len(cases)}종 중 로그 기록 {len(errs)}건(기록률 {rate:.0%}), 크래시 {crash}건\")\n", + "print(\"로그 예시:\", json.dumps(errs[0], ensure_ascii=False))\n", + "print(f\"목표(크래시 0건, 에러 로그 기록률 100%): {'달성' if crash == 0 and rate == 1 else '미달'}\")\n", + "\n", + "fig, ax = plt.subplots(1, 2, figsize=(11, 4))\n", + "cnt = board[\"status\"].map({\"승인\": \"approved\", \"반려\": \"rejected\", \"제출\": \"pending\"}).fillna(\"draft\").value_counts()\n", + "ax[0].bar(cnt.index, cnt.values, color=\"tab:blue\"); ax[0].set_title(\"Report status\")\n", + "ax[1].barh(board[\"unit\"].str[2:4], board[\"issue\"], color=\"tab:gray\"); ax[1].set_title(\"Issued quantity by unit (synthetic)\")\n", + "plt.tight_layout(); plt.show()\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 06 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + "[결산 보고서 현황판] [교육용가상자료]\n", + " 보고서 부대 상태 불출합 기말합\n", + "RPT-01-08 가상01대대 승인 1147 8915\n", + "RPT-02-08 가상02대대 승인 916 9139\n", + "RPT-03-08 가상03대대 승인 778 7428\n", + "RPT-04-08 가상04대대 승인 882 8357\n", + "RPT-05-08 가상05대대 승인 920 7718\n", + "RPT-06-08 가상06대대 승인 887 10211\n", + "RPT-07-08 가상07대대 승인 967 8567\n", + "RPT-08-08 가상08대대 승인 877 7738\n", + "RPT-09-08 가상09대대 반려 1056 8987\n", + "RPT-10-08 가상10대대 반려 1015 9029\n", + "RPT-11-08 가상11대대 제출 1130 6845\n", + "RPT-12-08 가상12대대 제출 1266 9330\n", + "상태별: {'승인': 8, '반려': 2, '제출': 2}\n", + "반려 건 재작성 후 상태: 작성 / 이력: [('제출', '담당자3'), ('반려', '보급관: 기말 재고 산식 재확인 요망'), ('작성', '담당자')]\n", + "\n", + "[예외 주입 결과 - 사용자 안내 메시지]\n", + " E01 부대명 오류 존재하지 않는 부대 '가상99대대' -> 조치: 부대 목록(가상01~24대대)에서 선택\n", + " E02 월 오류 결산월 '2027-01' 자료 없음 -> 조치: 2026-07 또는 2026-08 중 선택\n", + " E03 빈 자료 해당 부대 자료가 비어 있음 -> 조치: 제출 부대에 원장 재제출 요청\n", + " E04 열 누락 필수 열 'ret' 누락 -> 조치: 표준 양식을 다시 내려받아 작성\n", + " E05 숫자 아님 'issue' 열에 숫자가 아닌 값 -> 조치: 숫자만 입력(단위 문자 제거)\n", + " E06 음수 'issue' 열에 음수 -> 조치: 수량 0 이상으로 수정\n", + " E07 중복 생성 이미 생성된 보고서 RPT-01-08 -> 조치: 기존 보고서를 열어 수정·재제출\n", + " E08 없는 보고서 보고서 RPT-00-00 없음 -> 조치: 보고서 번호를 목록에서 확인\n", + " E10 상태 위반(미제출 승인) 현재 상태 '작성'에서 '승인' 불가 -> 조치: '제출' 상태에서만 가능\n", + " E11 반려 사유 누락 반려 사유 없음 -> 조치: 반려 사유를 한 줄 이상 입력\n", + "예외 10종 중 로그 기록 10건(기록률 100%), 크래시 0건\n", + "로그 예시: {\"level\": \"ERROR\", \"code\": \"E01\", \"msg\": \"존재하지 않는 부대 '가상99대대'\", \"guide\": \"부대 목록(가상01~24대대)에서 선택\"}\n", + "목표(크래시 0건, 에러 로그 기록률 100%): 달성\n", + "```\n", + "
\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 07. 결산 보고서 최종 발간 전 군수과장/보급관 3단계 HITL 전자 결재선 연계 설계\n", + "**소요시간**: 약 3.0시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "결산 보고서 최종 발간 전 군수과장/보급관 3단계 HITL 결재선을 설계한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **결산 보고서 발간 전 담당자 기안, 보급관 검토, 군수과장 승인의 3단계 사람 결재선을 설계하고 이력의 무결성과 권한 통제를 검증한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **결재 단계별 역할과 순서를 설정으로 고정하고, 순서 위반, 본인 결재, 근거 미기재, 미등록 사용자를 차단 수행하고 검증하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `src/workflow/dat06_approval_system.py` 형태의 결과물을 완성한다.\n", + "\n", + "### 🔍 평가 및 검증 기준 (PASS 조건)\n", + "#### 미션 판정\n", + "\n", + "**평가 구분:** 필수\n", + "\n", + "**핵심 평가영역:** 군 보안 규정 및 행정 절차 부합성\n", + "\n", + "#### 미션 PASS 조건\n", + "\n", + "- 정량 기준: 이력 기록 무결성 100%, 승인 권한 우회 시도 차단율 100%\n", + "\n", + "\n", + "- 진행 기준: 결재선 승인/반려 시뮬레이션 및 이력 조회 화면 구현\n", + "\n", + "\n", + "- 검증 방법: 권한별 접근 제어(RBAC) 테스트\n", + "\n", + "\n", + "#### 채점자 판정 메모\n", + "\n", + "> 단순 코드 실행 여부뿐만 아니라 출력된 수치와 근거 자료가 군 실무 요구사항에 부합하는지 면밀히 확인한다.\n", + "\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "\"\"\"결산 보고서 발간 전 담당자 기안, 보급관 검토, 군수과장 최종 승인의 3단계 사람 결재선을 설계하고, 해시 체인 감사 이력의 무결성과 권한 우회 시도 10종의 차단을 검증한다. 자동 발간은 허용하지 않는다.\"\"\"\n", + "import hashlib\n", + "import json\n", + "import numpy as np\n", + "import pandas as pd\n", + "\n", + "# [교육용가상자료] 가상 직책·인물(가명). 실제 전자 결재 체계와 무관한 학습용 모의\n", + "USERS = {\"kim\": \"담당자\", \"lee\": \"담당자\", \"park\": \"보급관\", \"choi\": \"군수과장\", \"han\": \"열람자\"}\n", + "LINE = [(\"기안\", \"담당자\"), (\"검토\", \"보급관\"), (\"승인\", \"군수과장\")] # 순서 고정 3단계\n", + "AUDIT = []\n", + "\n", + "\n", + "def _h(prev, rec):\n", + " # [TODO : 학생 실습 7] 결산 보고서 최종 발간 전 군수과장/보급관 3단계 HITL 전자 결재선 연계 설계 관련 핵심 로직을 구현하세요.\n", + " # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n", + " # <<< 여기에 코드를 작성하세요 >>>\n", + " pass # 작성 후 pass를 제거하세요.\n", + "def log(doc, user, action, result, note=\"\"):\n", + " # [TODO : 학생 실습 7] 결산 보고서 최종 발간 전 군수과장/보급관 3단계 HITL 전자 결재선 연계 설계 관련 핵심 로직을 구현하세요.\n", + " # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n", + " # <<< 여기에 코드를 작성하세요 >>>\n", + " pass # 작성 후 pass를 제거하세요.\n", + "def verify_chain():\n", + " prev, ok = \"GENESIS\", 0\n", + " for r in AUDIT:\n", + " body = {k: v for k, v in r.items() if k != \"hash\"}\n", + " ok += (r[\"hash\"] == _h(prev, body)); prev = r[\"hash\"]\n", + " return ok, len(AUDIT)\n", + "\n", + "\n", + "DOCS = {}\n", + "\n", + "\n", + "def step(doc, user, action, comment=\"\"):\n", + " \"\"\"결재 처리. 권한·순서·본인 결재 금지·근거 기재를 모두 통과해야 기록되고, 실패는 차단 기록을 남긴다.\"\"\"\n", + " d = DOCS.setdefault(doc, {\"stage\": 0, \"signers\": [], \"published\": False})\n", + " role = USERS.get(user)\n", + " if role is None:\n", + " return log(doc, user, action, \"차단\", \"미등록 사용자\"), False\n", + " if d[\"stage\"] >= len(LINE):\n", + " return log(doc, user, action, \"차단\", \"이미 결재 완료\"), False\n", + " need_act, need_role = LINE[d[\"stage\"]]\n", + " if action != need_act:\n", + " return log(doc, user, action, \"차단\", f\"순서 위반: 현재 단계 '{need_act}'\"), False\n", + " if role != need_role:\n", + " return log(doc, user, action, \"차단\", f\"권한 없음: 역할 '{role}'은(는) '{action}' 불가({need_role} 필요)\"), False\n", + " if user in d[\"signers\"]:\n", + " return log(doc, user, action, \"차단\", \"본인이 앞 단계에 서명한 문서는 다음 단계를 결재할 수 없음\"), False\n", + " if action != \"기안\" and len(comment.strip()) < 5:\n", + " return log(doc, user, action, \"차단\", \"검토 근거(5자 이상) 미기재\"), False\n", + " d[\"stage\"] += 1; d[\"signers\"].append(user)\n", + " log(doc, user, action, \"승인\", comment or \"기안 상신\")\n", + " return None, True\n", + "\n", + "\n", + "def publish(doc, user):\n", + " d = DOCS.get(doc)\n", + " if not d or d[\"stage\"] < len(LINE):\n", + " log(doc, user, \"발간\", \"차단\", \"3단계 결재 미완료(자동 발간 금지)\"); return False\n", + " if USERS.get(user) != \"담당자\":\n", + " log(doc, user, \"발간\", \"차단\", \"발간은 담당자가 결재 완료 후 수동 실행\"); return False\n", + " d[\"published\"] = True; log(doc, user, \"발간\", \"승인\", \"결재 3단계 완료 후 수동 발간\"); return True\n", + "\n", + "\n", + "# 1) 정상 결재 흐름 2건\n", + "for doc, drafter in ((\"RPT-2026-08-A\", \"kim\"), (\"RPT-2026-08-B\", \"lee\")):\n", + " step(doc, drafter, \"기안\")\n", + " step(doc, \"park\", \"검토\", \"재고 산식 및 전월 연속성 확인 완료\")\n", + " step(doc, \"choi\", \"승인\", \"보급관 검토 의견 확인, 발간 승인\")\n", + " publish(doc, drafter)\n", + "\n", + "# 2) 권한 우회 시도 10종 - 결재가 진행되는 순서에 맞춰 시도하고, 모두 차단되어야 한다\n", + "blocked, total = 0, 0\n", + "print(\"[권한 우회 시도 결과] [교육용가상자료]\")\n", + "\n", + "\n", + "def attempt(name, fn):\n", + " global blocked, total\n", + " n = len(AUDIT); fn(); r = AUDIT[-1]\n", + " ok = len(AUDIT) == n + 1 and r[\"result\"] == \"차단\"\n", + " blocked += ok; total += 1\n", + " print(f\" - {name}: {r['result']} ({r['note']})\")\n", + "\n", + "\n", + "attempt(\"미등록 사용자 기안\", lambda: step(\"RPT-ATT\", \"hacker\", \"기안\"))\n", + "attempt(\"열람자가 기안\", lambda: step(\"RPT-ATT\", \"han\", \"기안\"))\n", + "attempt(\"기안 전에 군수과장이 승인(단계 건너뛰기)\", lambda: step(\"RPT-ATT\", \"choi\", \"승인\", \"바로 승인합니다 확인\"))\n", + "step(\"RPT-ATT\", \"kim\", \"기안\")\n", + "attempt(\"기안자 본인이 검토까지 처리\", lambda: step(\"RPT-ATT\", \"kim\", \"검토\", \"본인이 검토합니다 확인\"))\n", + "attempt(\"다른 담당자가 검토 단계 처리\", lambda: step(\"RPT-ATT\", \"lee\", \"검토\", \"다른 담당자 검토 시도 확인\"))\n", + "attempt(\"검토 근거 없이 보급관 검토\", lambda: step(\"RPT-ATT\", \"park\", \"검토\", \"\"))\n", + "attempt(\"보급관 검토 전에 군수과장이 승인\", lambda: step(\"RPT-ATT\", \"choi\", \"승인\", \"보급관 단계를 생략한 승인 확인\"))\n", + "attempt(\"결재 미완료 상태에서 발간\", lambda: publish(\"RPT-ATT\", \"kim\"))\n", + "step(\"RPT-ATT\", \"park\", \"검토\", \"산식 검증 결과 이상 없음 확인\")\n", + "step(\"RPT-ATT\", \"choi\", \"승인\", \"최종 승인합니다 확인\")\n", + "attempt(\"완결된 문서 재승인 시도\", lambda: step(\"RPT-ATT\", \"choi\", \"승인\", \"한 번 더 승인 시도 확인\"))\n", + "attempt(\"보급관이 발간 시도\", lambda: publish(\"RPT-ATT\", \"park\"))\n", + "print(f\"우회 시도 {total}건 중 차단 {blocked}건({blocked / total:.0%})\")\n", + "\n", + "# 3) 이력 무결성: 정상 체인 검증 후 위·변조 시험\n", + "ok, tot = verify_chain()\n", + "print(f\"\\n이력 {tot}건 해시 체인 검증: {ok}/{tot} 일치({ok / tot:.0%})\")\n", + "k = next(i for i, r in enumerate(AUDIT) if r[\"result\"] == \"차단\")\n", + "saved = AUDIT[k][\"result\"]\n", + "AUDIT[k][\"result\"] = \"승인\" # 차단 기록을 승인으로 위조\n", + "ok2, _ = verify_chain()\n", + "AUDIT[k][\"result\"] = saved\n", + "print(f\"위조 시험: {k + 1}번 기록 변조 시 검증 {ok2}/{tot} -> 변조 탐지 {'성공' if ok2 < tot else '실패'}, 원복 후 {verify_chain()[0]}/{tot}\")\n", + "st = pd.DataFrame(AUDIT).groupby(\"doc\")[\"result\"].value_counts().unstack(fill_value=0)\n", + "print(\"\\n[문서별 결재 이력 요약]\")\n", + "print(st.to_string())\n", + "pubs = [k for k, v in DOCS.items() if v[\"published\"]]\n", + "print(\"발간 완료 문서:\", pubs, \"/ 우회 시험 문서 RPT-ATT는 발간되지 않음 / 자동 발간 0건(발간은 담당자 수동 실행)\")\n", + "print(f\"목표(이력 무결성 100%, 우회 차단율 100%): {'달성' if ok == tot and blocked == total and ok2 < tot else '미달'}\")\n", + "print(\"실제 결재와 보안 규정은 부대 지침과 지휘계통에 따른다. 본 코드는 학습용 모의이다.\")\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 07 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + "[권한 우회 시도 결과] [교육용가상자료]\n", + " - 미등록 사용자 기안: 차단 (미등록 사용자)\n", + " - 열람자가 기안: 차단 (권한 없음: 역할 '열람자'은(는) '기안' 불가(담당자 필요))\n", + " - 기안 전에 군수과장이 승인(단계 건너뛰기): 차단 (순서 위반: 현재 단계 '기안')\n", + " - 기안자 본인이 검토까지 처리: 차단 (권한 없음: 역할 '담당자'은(는) '검토' 불가(보급관 필요))\n", + " - 다른 담당자가 검토 단계 처리: 차단 (권한 없음: 역할 '담당자'은(는) '검토' 불가(보급관 필요))\n", + " - 검토 근거 없이 보급관 검토: 차단 (검토 근거(5자 이상) 미기재)\n", + " - 보급관 검토 전에 군수과장이 승인: 차단 (순서 위반: 현재 단계 '검토')\n", + " - 결재 미완료 상태에서 발간: 차단 (3단계 결재 미완료(자동 발간 금지))\n", + " - 완결된 문서 재승인 시도: 차단 (이미 결재 완료)\n", + " - 보급관이 발간 시도: 차단 (발간은 담당자가 결재 완료 후 수동 실행)\n", + "우회 시도 10건 중 차단 10건(100%)\n", + "\n", + "이력 21건 해시 체인 검증: 21/21 일치(100%)\n", + "위조 시험: 9번 기록 변조 시 검증 20/21 -> 변조 탐지 성공, 원복 후 21/21\n", + "\n", + "[문서별 결재 이력 요약]\n", + "result 승인 차단\n", + "doc \n", + "RPT-2026-08-A 4 0\n", + "RPT-2026-08-B 4 0\n", + "RPT-ATT 3 10\n", + "발간 완료 문서: ['RPT-2026-08-A', 'RPT-2026-08-B'] / 우회 시험 문서 RPT-ATT는 발간되지 않음 / 자동 발간 0건(발간은 담당자 수동 실행)\n", + "목표(이력 무결성 100%, 우회 차단율 100%): 달성\n", + "실제 결재와 보안 규정은 부대 지침과 지휘계통에 따른다. 본 코드는 학습용 모의이다.\n", + "```\n", + "
\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 08. 수십 개 예하 부대 월간 결산 보고서 원클릭 일괄 생성 배치(Batch) 스트레스 테스트\n", + "**소요시간**: 약 3.5시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "수십 개 예하 부대 월간 결산 보고서 일괄 생성 배치 스트레스 테스트를 수행한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **수십 개 예하 부대의 월간 결산 보고서를 원클릭으로 일괄 생성하는 배치를 만들고 장애·손상 자료를 주입해 스트레스 시험을 수행한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **가상 부대 40개 x 12개월, 480건 보고서를 한 번에 생성 수행하고 검증하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `src/pipeline/dat06_batch_runner.py` 형태의 결과물을 완성한다.\n", + "\n", + "### 🔍 평가 및 검증 기준 (PASS 조건)\n", + "#### 미션 판정\n", + "\n", + "**평가 구분:** 필수\n", + "\n", + "**핵심 평가영역:** 수작업 대비 업무 소요 시간 80% 이상 절감 효과\n", + "\n", + "#### 미션 PASS 조건\n", + "\n", + "- 정량 기준: 전체 배치 파이프라인 실행 시간 5분 이내, 자동화 성공률 99% 이상\n", + "\n", + "\n", + "- 진행 기준: 스케줄러 연동 테스트 및 자동 실행 확인\n", + "\n", + "\n", + "- 검증 방법: 실제 월간 업무 데이터 1년치 일괄 배치 시뮬레이션\n", + "\n", + "\n", + "#### 채점자 판정 메모\n", + "\n", + "> 단순 코드 실행 여부뿐만 아니라 출력된 수치와 근거 자료가 군 실무 요구사항에 부합하는지 면밀히 확인한다.\n", + "\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "\"\"\"40개 가상 예하 부대 x 12개월(480건) 월간 결산 보고서를 한 번에 생성하는 배치를 실행하며, 일시 장애와 손상 자료를 주입해 재시도·격리 처리를 확인하고 전체 실행 시간, 자동화 성공률, 수작업 대비 절감률을 측정한다.\"\"\"\n", + "import time\n", + "import numpy as np\n", + "import pandas as pd\n", + "\n", + "# ---------------------------------------------------------------------------\n", + "# [교육용가상자료] 가상 월간 결산 원장 생성기 (실제 부대명·품목·수량·재무 정보 없음)\n", + "# 가상 대대 24개 x (물자 불출 / 수리부속 소모 / 탄약 정산) 품목 원장과 장비 가동 현황을 만든다.\n", + "# 재고 산식: 기말 = 기초 + 수령 + 반납 - 불출, 다음 달 기초 = 이번 달 기말.\n", + "# ---------------------------------------------------------------------------\n", + "UNITS = [f\"가상{i:02d}대대\" for i in range(1, 25)]\n", + "ITEMS = {\"물자불출\": [\"급식류\", \"피복류\", \"유류\", \"정비소모품\"],\n", + " \"수리부속\": [\"필터류\", \"베어링류\", \"전장품\", \"타이어류\"],\n", + " \"탄약정산\": [\"훈련탄A\", \"훈련탄B\", \"모의탄\"]}\n", + "UNIT_CODE = {\"급식류\": \"BX\", \"피복류\": \"EA\", \"유류\": \"L\", \"정비소모품\": \"EA\", \"필터류\": \"EA\", \"베어링류\": \"EA\",\n", + " \"전장품\": \"EA\", \"타이어류\": \"EA\", \"훈련탄A\": \"RD\", \"훈련탄B\": \"RD\", \"모의탄\": \"RD\"}\n", + "LEDGER_COLS = [\"month\", \"unit\", \"cat\", \"item\", \"open\", \"recv\", \"issue\", \"ret\", \"close\"]\n", + "\n", + "\n", + "def make_ledger(n_months=2, seed=60):\n", + " \"\"\"n_months개월 원장(list of dict). 둘째 달 기초는 첫째 달 기말과 같다.\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " rows, prev = [], {}\n", + " for m in range(n_months):\n", + " for u in UNITS:\n", + " for cat, items in ITEMS.items():\n", + " for it in items:\n", + " base = int(rng.integers(80, 400))\n", + " op = prev.get((u, it), base * 3)\n", + " recv = int(rng.integers(0, base))\n", + " issue = int(rng.integers(base // 4, base // 2 + 1))\n", + " ret = int(issue * rng.uniform(0, 0.15))\n", + " cl = op + recv + ret - issue\n", + " rows.append({\"month\": f\"2026-{m + 7:02d}\", \"unit\": u, \"cat\": cat, \"item\": it,\n", + " \"open\": op, \"recv\": recv, \"issue\": issue, \"ret\": ret, \"close\": cl})\n", + " prev[(u, it)] = cl\n", + " return rows\n", + "\n", + "\n", + "def make_equip(n_months=2, seed=61):\n", + " rng = np.random.default_rng(seed)\n", + " out = []\n", + " for m in range(n_months):\n", + " for u in UNITS:\n", + " hold = int(rng.integers(20, 60))\n", + " out.append({\"month\": f\"2026-{m + 7:02d}\", \"unit\": u, \"hold\": hold, \"operable\": int(hold * rng.uniform(0.78, 0.97))})\n", + " return out\n", + "\n", + "UNITS = [f\"가상{i:02d}대대\" for i in range(1, 41)] # 배치용으로 40개 부대로 확장\n", + "t_all = time.perf_counter()\n", + "rows = make_ledger(12, seed=8)\n", + "led = pd.DataFrame(rows)\n", + "lab = {f\"2026-{i + 7:02d}\": f\"{2026 + (i + 6) // 12}-{(i + 6) % 12 + 1:02d}\" for i in range(12)} # 2026-07 ~ 2027-06\n", + "led[\"month\"] = led[\"month\"].map(lab)\n", + "months = sorted(led[\"month\"].unique())\n", + "print(f\"[배치 입력] 부대 {len(UNITS)}개 x {len(months)}개월 = {len(UNITS) * len(months)}건, 원장 {len(led)}행 [교육용가상자료]\")\n", + "\n", + "# 장애 주입: 일시 장애(재시도로 복구) 9건, 손상 자료(격리) 3건\n", + "rng = np.random.default_rng(80)\n", + "keys = [(u, m) for u in UNITS for m in months]\n", + "pick = rng.choice(len(keys), 12, replace=False)\n", + "transient = {keys[i] for i in pick[:9]}\n", + "corrupt = {keys[i] for i in pick[9:]}\n", + "for (u, m) in corrupt: # 기말 수치를 훼손(산식 불일치)\n", + " i = led[(led[\"unit\"] == u) & (led[\"month\"] == m)].index[0]\n", + " led.loc[i, \"close\"] = led.loc[i, \"close\"] + 999\n", + "attempts_log = {}\n", + "\n", + "\n", + "def build_report(u, m, g):\n", + " # [TODO : 학생 실습 8] 수십 개 예하 부대 월간 결산 보고서 원클릭 일괄 생성 배치(Batch) 스트레스 테스트 관련 핵심 로직을 구현하세요.\n", + " # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n", + " # <<< 여기에 코드를 작성하세요 >>>\n", + " pass # 작성 후 pass를 제거하세요.\n", + "done, quarantine, retries = {}, [], 0\n", + "t0 = time.perf_counter()\n", + "for (u, m), g in led.groupby([\"unit\", \"month\"]):\n", + " for attempt in range(1, 4): # 최대 3회 재시도\n", + " try:\n", + " done[(u, m)] = build_report(u, m, g)\n", + " break\n", + " except TimeoutError:\n", + " retries += 1\n", + " except ValueError as ex: # 자료 오류는 재시도해도 소용없으므로 즉시 격리하고 다음 건으로 진행\n", + " quarantine.append((u, m, str(ex)))\n", + " break\n", + "run_s = time.perf_counter() - t0\n", + "total = len(keys)\n", + "succ = len(done) / total\n", + "print(f\"\\n[배치 실행] 생성 {len(done)}건, 격리 {len(quarantine)}건, 재시도 {retries}회\")\n", + "for q in quarantine:\n", + " print(\" 격리(담당자 확인 필요):\", q)\n", + "print(f\"보고서 생성 순수 실행 시간 {run_s:.2f}초, 전체(자료 생성 포함) {time.perf_counter() - t_all:.2f}초 (목표 5분 이내: {'달성' if time.perf_counter() - t_all < 300 else '미달'})\")\n", + "print(f\"자동화 성공률 {succ:.1%} (일시 장애 {len(transient)}건은 재시도로 자동 복구, 손상 자료는 격리 후 보고)\")\n", + "\n", + "# 수작업 대비 절감(가정값): 부대 보고서 1건 수작업 25분(미션 1 AS-IS 610분/24개 부대 기준), 자동화 후 사람 검토 2분/건, 격리 건 수동 처리 15분\n", + "manual = total * 25\n", + "auto = total * 2 + len(quarantine) * 15 + run_s / 60\n", + "print(f\"\\n[업무 소요 비교(가정값)] 수작업 {manual}분({manual / 60:.0f}시간) vs 자동화 {auto:.0f}분({auto / 60:.1f}시간) -> 절감 {1 - auto / manual:.1%}\")\n", + "\n", + "# 스트레스: 부하 3배(1,440건) 반복 실행의 처리량\n", + "big = list(led.groupby([\"unit\", \"month\"]))\n", + "t0 = time.perf_counter(); n = 0\n", + "for _ in range(3):\n", + " for (u, m), g in big:\n", + " if (u, m) in corrupt:\n", + " continue\n", + " attempts_log[(u, m)] = 9 # 일시 장애 주입 해제(처리량 측정 목적)\n", + " build_report(u, m, g); n += 1\n", + "dt = time.perf_counter() - t0\n", + "print(f\"부하 시험 {n}건 처리 {dt:.2f}초 (초당 {n / dt:.0f}건), 오류 0건\")\n", + "print(f\"\\n목표(전체 5분 이내, 자동화 성공률 99% 이상, 수작업 대비 80% 이상 절감): \"\n", + " f\"{'달성' if run_s < 300 and succ >= 0.99 and 1 - auto / manual >= 0.8 else '미달'}\")\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 08 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + "[배치 입력] 부대 40개 x 12개월 = 480건, 원장 5280행 [교육용가상자료]\n", + "\n", + "[배치 실행] 생성 477건, 격리 3건, 재시도 9회\n", + " 격리(담당자 확인 필요): ('가상10대대', '2027-05', '재고 산식 불일치 1건')\n", + " 격리(담당자 확인 필요): ('가상24대대', '2027-02', '재고 산식 불일치 1건')\n", + " 격리(담당자 확인 필요): ('가상36대대', '2026-12', '재고 산식 불일치 1건')\n", + "보고서 생성 순수 실행 시간 1.06초, 전체(자료 생성 포함) 1.13초 (목표 5분 이내: 달성)\n", + "자동화 성공률 99.4% (일시 장애 9건은 재시도로 자동 복구, 손상 자료는 격리 후 보고)\n", + "\n", + "[업무 소요 비교(가정값)] 수작업 12000분(200시간) vs 자동화 1005분(16.8시간) -> 절감 91.6%\n", + "부하 시험 1431건 처리 3.63초 (초당 394건), 오류 0건\n", + "\n", + "목표(전체 5분 이내, 자동화 성공률 99% 이상, 수작업 대비 80% 이상 절감): 달성\n", + "```\n", + "
\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 09. AAR 기반 결산 수치 오집계 사례 분석 및 집계 룰셋/템플릿 보정\n", + "**소요시간**: 약 3.0시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "AAR 기반 결산 수치 오집계 사례를 분석하고 룰셋을 보정한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **AAR 사례의 결산 수치 오집계 5유형을 재현·분석하고 집계 룰셋과 템플릿을 보정한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **중복 제출, 단위 혼용(kL), 정정 문서 누락, 박스·발 혼용, 품목 별칭 누락의 5유형을 가상 제출 자료에 심어 룰셋 v1의 오집계를 재현 수행하고 검증하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `docs/dat06_uat_feedback_report.md 및 패치 내역` 형태의 결과물을 완성한다.\n", + "\n", + "### 🔍 평가 및 검증 기준 (PASS 조건)\n", + "#### 미션 판정\n", + "\n", + "**평가 구분:** 필수\n", + "\n", + "**핵심 평가영역:** 실무 업무 생산성 기여도 및 현장 적응성\n", + "\n", + "#### 미션 PASS 조건\n", + "\n", + "- 정량 기준: UAT 체크리스트 만족도 90점 이상, 개선 요구 5건 반영 완료\n", + "\n", + "\n", + "- 진행 기준: UAT 결과 보고서 작성 및 최종 버그 픽스 완료\n", + "\n", + "\n", + "- 검증 방법: 실제 업무 시나리오 기반 최종 검수 테스트\n", + "\n", + "\n", + "#### 채점자 판정 메모\n", + "\n", + "> 단순 코드 실행 여부뿐만 아니라 출력된 수치와 근거 자료가 군 실무 요구사항에 부합하는지 면밀히 확인한다.\n", + "\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "\"\"\"사후검토(AAR) 사례에서 결산 수치 오집계 5유형(중복 행, 단위 혼용, 정정 누락, 박스·발 혼용, 품목 별칭 누락)을 재현해 원인을 분석하고 집계 룰셋 v1을 v2로 보정한 뒤, 재집계 결과와 UAT 체크리스트 점수, 개선 요구 반영 현황, 패치 내역을 출력한다.\"\"\"\n", + "import numpy as np\n", + "import pandas as pd\n", + "\n", + "# ---------------------------------------------------------------------------\n", + "# [교육용가상자료] 가상 월간 결산 원장 생성기 (실제 부대명·품목·수량·재무 정보 없음)\n", + "# 가상 대대 24개 x (물자 불출 / 수리부속 소모 / 탄약 정산) 품목 원장과 장비 가동 현황을 만든다.\n", + "# 재고 산식: 기말 = 기초 + 수령 + 반납 - 불출, 다음 달 기초 = 이번 달 기말.\n", + "# ---------------------------------------------------------------------------\n", + "UNITS = [f\"가상{i:02d}대대\" for i in range(1, 25)]\n", + "ITEMS = {\"물자불출\": [\"급식류\", \"피복류\", \"유류\", \"정비소모품\"],\n", + " \"수리부속\": [\"필터류\", \"베어링류\", \"전장품\", \"타이어류\"],\n", + " \"탄약정산\": [\"훈련탄A\", \"훈련탄B\", \"모의탄\"]}\n", + "UNIT_CODE = {\"급식류\": \"BX\", \"피복류\": \"EA\", \"유류\": \"L\", \"정비소모품\": \"EA\", \"필터류\": \"EA\", \"베어링류\": \"EA\",\n", + " \"전장품\": \"EA\", \"타이어류\": \"EA\", \"훈련탄A\": \"RD\", \"훈련탄B\": \"RD\", \"모의탄\": \"RD\"}\n", + "LEDGER_COLS = [\"month\", \"unit\", \"cat\", \"item\", \"open\", \"recv\", \"issue\", \"ret\", \"close\"]\n", + "\n", + "\n", + "def make_ledger(n_months=2, seed=60):\n", + " \"\"\"n_months개월 원장(list of dict). 둘째 달 기초는 첫째 달 기말과 같다.\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " rows, prev = [], {}\n", + " for m in range(n_months):\n", + " for u in UNITS:\n", + " for cat, items in ITEMS.items():\n", + " for it in items:\n", + " base = int(rng.integers(80, 400))\n", + " op = prev.get((u, it), base * 3)\n", + " recv = int(rng.integers(0, base))\n", + " issue = int(rng.integers(base // 4, base // 2 + 1))\n", + " ret = int(issue * rng.uniform(0, 0.15))\n", + " cl = op + recv + ret - issue\n", + " rows.append({\"month\": f\"2026-{m + 7:02d}\", \"unit\": u, \"cat\": cat, \"item\": it,\n", + " \"open\": op, \"recv\": recv, \"issue\": issue, \"ret\": ret, \"close\": cl})\n", + " prev[(u, it)] = cl\n", + " return rows\n", + "\n", + "\n", + "def make_equip(n_months=2, seed=61):\n", + " rng = np.random.default_rng(seed)\n", + " out = []\n", + " for m in range(n_months):\n", + " for u in UNITS:\n", + " hold = int(rng.integers(20, 60))\n", + " out.append({\"month\": f\"2026-{m + 7:02d}\", \"unit\": u, \"hold\": hold, \"operable\": int(hold * rng.uniform(0.78, 0.97))})\n", + " return out\n", + "\n", + "# 1) 가상 제출 레코드 생성: 정답(순불출 = 불출 - 반납)과 부대가 실제로 낸 제출 형태(오집계 유발 요인 포함)\n", + "led = pd.DataFrame(make_ledger(1))\n", + "rng = np.random.default_rng(9)\n", + "truth = {(r.unit, r.item): r.issue - r.ret for r in led.itertuples()}\n", + "subs, doc = [], 0\n", + "def add(u, it, kind, qty, uom, name=None):\n", + " # [TODO : 학생 실습 9] AAR 기반 결산 수치 오집계 사례 분석 및 집계 룰셋/템플릿 보정 관련 핵심 로직을 구현하세요.\n", + " # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n", + " # <<< 여기에 코드를 작성하세요 >>>\n", + " pass # 작성 후 pass를 제거하세요.\n", + "for r in led.itertuples():\n", + " add(r.unit, r.item, \"issue\", r.issue, UNIT_CODE[r.item]); add(r.unit, r.item, \"return\", r.ret, UNIT_CODE[r.item])\n", + "keys = list(truth)\n", + "pick = lambda n, f=lambda k: True: [keys[i] for i in rng.choice([j for j, k in enumerate(keys) if f(k)], n, replace=False)]\n", + "CASE = {}\n", + "for k in pick(6): # 사례1: 같은 문서번호가 중복 제출\n", + " CASE.setdefault(\"중복 행\", []).append(k); subs.append(dict(next(s for s in subs if (s[\"unit\"], s[\"item\"]) == k and s[\"kind\"] == \"issue\")))\n", + "for k in pick(4, lambda k: k[1] == \"유류\"): # 사례2: 유류를 kL 단위로 제출(1kL = 1000L)\n", + " CASE.setdefault(\"단위 혼용(kL)\", []).append(k)\n", + " for s in subs:\n", + " if (s[\"unit\"], s[\"item\"]) == k:\n", + " s[\"qty\"], s[\"uom\"] = s[\"qty\"] / 1000, \"kL\"\n", + "for k in pick(5): # 사례3: 불출 후 정정 문서(음수 조정)를 별도로 제출\n", + " CASE.setdefault(\"정정 누락\", []).append(k); d = int(rng.integers(5, 20))\n", + " next(s for s in subs if (s[\"unit\"], s[\"item\"]) == k and s[\"kind\"] == \"issue\")[\"qty\"] += d\n", + " add(k[0], k[1], \"adjust\", -d, UNIT_CODE[k[1]])\n", + "for k in pick(4, lambda k: k[1].startswith(\"훈련탄\")): # 사례4: 탄약을 박스(BX, 1BX=20발)로 제출\n", + " CASE.setdefault(\"박스·발 혼용\", []).append(k)\n", + " for s in subs:\n", + " if (s[\"unit\"], s[\"item\"]) == k:\n", + " s[\"qty\"], s[\"uom\"] = s[\"qty\"] / 20, \"BX\"\n", + "for k in pick(5, lambda k: k[1] == \"피복류\"): # 사례5: 품목명을 줄여서 제출('피복')\n", + " CASE.setdefault(\"품목 별칭 누락\", []).append(k)\n", + " for s in subs:\n", + " if (s[\"unit\"], s[\"item\"]) == k:\n", + " s[\"item\"] = \"피복\"\n", + "S = pd.DataFrame(subs)\n", + "\n", + "# 2) 집계 룰셋 v1(AAR 이전) / v2(보정 후)\n", + "def aggregate(S, ruleset):\n", + " # [TODO : 학생 실습 9] AAR 기반 결산 수치 오집계 사례 분석 및 집계 룰셋/템플릿 보정 관련 핵심 로직을 구현하세요.\n", + " # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n", + " # <<< 여기에 코드를 작성하세요 >>>\n", + " pass # 작성 후 pass를 제거하세요.\n", + "def compare(agg):\n", + " tt = pd.Series(truth)\n", + " tt.index.names = [\"unit\", \"item\"]\n", + " diff = (agg.reindex(tt.index).fillna(0) - tt)\n", + " return diff[diff.abs() > 0.5]\n", + "\n", + "v1, v2 = compare(aggregate(S, \"v1\")), compare(aggregate(S, \"v2\"))\n", + "print(f\"[AAR 오집계 분석] 제출 레코드 {len(S)}건, 정답 대조 대상 {len(truth)}개 (부대 x 품목) [교육용가상자료]\")\n", + "print(f\"룰셋 v1 오집계 {len(v1)}건(오차 합계 {v1.abs().sum():.0f}), 룰셋 v2 오집계 {len(v2)}건\")\n", + "print(\"\\n[사례별 원인 분석 - v1]\")\n", + "rows = []\n", + "for name, ks in CASE.items():\n", + " hit = [k for k in ks if k in v1.index]\n", + " fix = [k for k in ks if k not in v2.index]\n", + " err = float(sum(abs(v1[k]) for k in hit))\n", + " rows.append({\"사례\": name, \"발생 셀\": len(ks), \"v1 오류 셀\": len(hit), \"v1 오차\": round(err), \"v2 정상\": f\"{len(fix)}/{len(ks)}\"})\n", + "print(pd.DataFrame(rows).to_string(index=False))\n", + "CAUSE = {\"중복 행\": \"문서번호 중복 제거 규칙 없음\", \"단위 혼용(kL)\": \"단위 환산표 없음\", \"정정 누락\": \"정정(음수 조정) 문서를 집계에서 제외\",\n", + " \"박스·발 혼용\": \"BX->RD 환산표 없음\", \"품목 별칭 누락\": \"별칭 사전 없고 미등록 품목을 조용히 버림\"}\n", + "print(\"\\n[원인 -> 룰셋 보정]\")\n", + "for k, v in CAUSE.items():\n", + " print(f\" - {k}: {v} -> v2 패치: \" + {\"중복 행\": \"doc 기준 drop_duplicates\", \"단위 혼용(kL)\": \"kL x1000\", \"정정 누락\": \"adjust 포함 집계\",\n", + " \"박스·발 혼용\": \"BX x20\", \"품목 별칭 누락\": \"별칭 사전 + 미등록 품목은 로그로 보류\"}[k])\n", + "\n", + "# 3) 회귀 시험 및 UAT 체크리스트(자동 점검 20항목)\n", + "agg2 = aggregate(S, \"v2\")\n", + "checks = [(\"정답 대조 오집계 0건\", len(v2) == 0), (\"중복 문서 제거\", agg2.equals(aggregate(S.drop_duplicates(\"doc\"), \"v2\"))),\n", + " (\"v2 재실행 결과 동일(멱등)\", aggregate(S, \"v2\").equals(agg2)),\n", + " (\"kL 부대 4곳 정상\", all(k not in v2.index for k in CASE[\"단위 혼용(kL)\"])), (\"BX 부대 4곳 정상\", all(k not in v2.index for k in CASE[\"박스·발 혼용\"])),\n", + " (\"정정 문서 5건 반영\", all(k not in v2.index for k in CASE[\"정정 누락\"])), (\"별칭 5건 반영\", all(k not in v2.index for k in CASE[\"품목 별칭 누락\"])),\n", + " (\"정상 부대 v1 결과와 불변\", len(set(truth) - set(v1.index) - set(v2.index)) == len(truth) - len(v1)),\n", + " (\"전체 불출 합계 정답과 일치\", abs(agg2.sum() - sum(truth.values())) < 1), (\"부대 24개 모두 집계\", agg2.index.get_level_values(0).nunique() == 24),\n", + " (\"품목 11종 모두 집계\", agg2.index.get_level_values(1).nunique() == 11), (\"음수 순불출 0건\", (agg2 >= 0).all()),\n", + " (\"미등록 품목 입력 시 오류 아닌 보류\", aggregate(pd.concat([S, pd.DataFrame([{\"doc\": \"X1\", \"unit\": UNITS[0], \"item\": \"미등록\", \"kind\": \"issue\", \"qty\": 5, \"uom\": \"EA\"}])]), \"v2\").shape == agg2.shape),\n", + " (\"빈 입력에서도 오류 없음\", aggregate(S.iloc[0:0], \"v2\").empty), (\"처리 결과 부대x품목 단일 키\", agg2.index.is_unique),\n", + " (\"v1 대비 오차 감소 100%\", len(v2) == 0 and len(v1) > 0), (\"단위 환산 후 소수 오차 없음\", bool(((agg2 - agg2.round()).abs() < 1e-6).all())),\n", + " (\"사례 5유형 모두 재현·분석\", len(CASE) == 5), (\"원인-패치 대응표 작성\", len(CAUSE) == 5), (\"개선 요구 5건 반영\", len(CASE) == 5 and len(v2) == 0)]\n", + "passed = sum(ok for _, ok in checks)\n", + "score = 100 * passed / len(checks)\n", + "print(\"\\n[UAT 체크리스트]\", f\"{passed}/{len(checks)}항목 통과, 점수 {score:.0f}점\")\n", + "for n, ok in checks:\n", + " if not ok:\n", + " print(\" 미통과:\", n)\n", + "print(\"\\n[개선 요구 반영 현황]\")\n", + "for i, (k, ks) in enumerate(CASE.items(), 1):\n", + " print(f\" 요구 {i}. {k} 대응: {'반영 완료' if all(x not in v2.index for x in ks) else '미반영'}\")\n", + "print(f\"\\n목표(UAT 90점 이상, 개선 요구 5건 반영): {'달성' if score >= 90 and len(v2) == 0 else '미달'}\")\n", + "print(\"패치 내역: 룰셋 v1 -> v2 (중복 제거, 단위 환산표, 정정 반영, 별칭 사전, 미등록 품목 보류). 재집계 전 담당자가 보류 건을 확인한다.\")\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 09 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + "[AAR 오집계 분석] 제출 레코드 539건, 정답 대조 대상 264개 (부대 x 품목) [교육용가상자료]\n", + "룰셋 v1 오집계 24건(오차 합계 1787), 룰셋 v2 오집계 0건\n", + "\n", + "[사례별 원인 분석 - v1]\n", + " 사례 발생 셀 v1 오류 셀 v1 오차 v2 정상\n", + " 중복 행 6 6 546 6/6\n", + "단위 혼용(kL) 4 4 241 4/4\n", + " 정정 누락 5 5 71 5/5\n", + " 박스·발 혼용 4 4 367 4/4\n", + " 품목 별칭 누락 5 5 563 5/5\n", + "\n", + "[원인 -> 룰셋 보정]\n", + " - 중복 행: 문서번호 중복 제거 규칙 없음 -> v2 패치: doc 기준 drop_duplicates\n", + " - 단위 혼용(kL): 단위 환산표 없음 -> v2 패치: kL x1000\n", + " - 정정 누락: 정정(음수 조정) 문서를 집계에서 제외 -> v2 패치: adjust 포함 집계\n", + " - 박스·발 혼용: BX->RD 환산표 없음 -> v2 패치: BX x20\n", + " - 품목 별칭 누락: 별칭 사전 없고 미등록 품목을 조용히 버림 -> v2 패치: 별칭 사전 + 미등록 품목은 로그로 보류\n", + "\n", + "[UAT 체크리스트] 20/20항목 통과, 점수 100점\n", + "\n", + "[개선 요구 반영 현황]\n", + " 요구 1. 중복 행 대응: 반영 완료\n", + " 요구 2. 단위 혼용(kL) 대응: 반영 완료\n", + " 요구 3. 정정 누락 대응: 반영 완료\n", + " 요구 4. 박스·발 혼용 대응: 반영 완료\n", + " 요구 5. 품목 별칭 누락 대응: 반영 완료\n", + "\n", + "목표(UAT 90점 이상, 개선 요구 5건 반영): 달성\n", + "패치 내역: 룰셋 v1 -> v2 (중복 제거, 단위 환산표, 정정 반영, 별칭 사전, 미등록 품목 보류). 재집계 전 담당자가 보류 건을 확인한다.\n", + "```\n", + "
\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 10. 군수 월간 결산 및 보급 현황 보고서 원클릭 자동 생성 도구 최종 완성\n", + "**소요시간**: 약 3.0시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "군수 월간 결산 및 보급 현황 보고서 원클릭 자동 생성 도구를 완성한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **수집부터 검증, 집계, 이상 점검, 보고서 생성, 결재 대기 분리까지 한 번에 실행되는 월간 결산 원클릭 도구를 완성한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **환경 점검 후 CSV·JSON 혼합 자료를 수집하고 검증 룰과 집계, 이상 점검, 신뢰도 점수를 거쳐 24개 부대 보고서 작성하여 원인을 분석하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `dist/dat06_package/, README.md, docs/dat06_final_manual.md` 형태의 결과물을 완성한다.\n", + "\n", + "### 🔍 평가 및 검증 기준 (PASS 조건)\n", + "#### 미션 판정\n", + "\n", + "**평가 구분:** 필수\n", + "\n", + "**핵심 평가영역:** 상용 소프트웨어 수준의 완성도 및 조직 확산 가치\n", + "\n", + "#### 미션 PASS 조건\n", + "\n", + "- 정량 기준: 설치부터 실행까지 원클릭 설치 스크립트 완비, 매뉴얼 작성률 100%\n", + "\n", + "\n", + "- 진행 기준: 최종 배포본 생성 및 검수 완료\n", + "\n", + "\n", + "- 검증 방법: Clean 환경에서의 설치 및 초기화 테스트\n", + "\n", + "\n", + "#### 채점자 판정 메모\n", + "\n", + "> 단순 코드 실행 여부뿐만 아니라 출력된 수치와 근거 자료가 군 실무 요구사항에 부합하는지 면밀히 확인한다.\n", + "\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "\"\"\"환경 점검부터 4형식 자료 수집, 검증 룰 적용, 월간 결산 집계, 이상치 점검, 신뢰도 점수, 24개 부대 보고서 생성, 3단계 결재 대기 분리까지 한 번에 실행하는 원클릭 결산 도구를 완성하고 설치 스크립트 개요·매뉴얼 목차·자체 점검·브리핑 요약을 출력한다.\"\"\"\n", + "import csv\n", + "import importlib.util\n", + "import io\n", + "import json\n", + "import sys\n", + "import time\n", + "import numpy as np\n", + "import pandas as pd\n", + "\n", + "# ---------------------------------------------------------------------------\n", + "# [교육용가상자료] 가상 월간 결산 원장 생성기 (실제 부대명·품목·수량·재무 정보 없음)\n", + "# 가상 대대 24개 x (물자 불출 / 수리부속 소모 / 탄약 정산) 품목 원장과 장비 가동 현황을 만든다.\n", + "# 재고 산식: 기말 = 기초 + 수령 + 반납 - 불출, 다음 달 기초 = 이번 달 기말.\n", + "# ---------------------------------------------------------------------------\n", + "UNITS = [f\"가상{i:02d}대대\" for i in range(1, 25)]\n", + "ITEMS = {\"물자불출\": [\"급식류\", \"피복류\", \"유류\", \"정비소모품\"],\n", + " \"수리부속\": [\"필터류\", \"베어링류\", \"전장품\", \"타이어류\"],\n", + " \"탄약정산\": [\"훈련탄A\", \"훈련탄B\", \"모의탄\"]}\n", + "UNIT_CODE = {\"급식류\": \"BX\", \"피복류\": \"EA\", \"유류\": \"L\", \"정비소모품\": \"EA\", \"필터류\": \"EA\", \"베어링류\": \"EA\",\n", + " \"전장품\": \"EA\", \"타이어류\": \"EA\", \"훈련탄A\": \"RD\", \"훈련탄B\": \"RD\", \"모의탄\": \"RD\"}\n", + "LEDGER_COLS = [\"month\", \"unit\", \"cat\", \"item\", \"open\", \"recv\", \"issue\", \"ret\", \"close\"]\n", + "\n", + "\n", + "def make_ledger(n_months=2, seed=60):\n", + " \"\"\"n_months개월 원장(list of dict). 둘째 달 기초는 첫째 달 기말과 같다.\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " rows, prev = [], {}\n", + " for m in range(n_months):\n", + " for u in UNITS:\n", + " for cat, items in ITEMS.items():\n", + " for it in items:\n", + " base = int(rng.integers(80, 400))\n", + " op = prev.get((u, it), base * 3)\n", + " recv = int(rng.integers(0, base))\n", + " issue = int(rng.integers(base // 4, base // 2 + 1))\n", + " ret = int(issue * rng.uniform(0, 0.15))\n", + " cl = op + recv + ret - issue\n", + " rows.append({\"month\": f\"2026-{m + 7:02d}\", \"unit\": u, \"cat\": cat, \"item\": it,\n", + " \"open\": op, \"recv\": recv, \"issue\": issue, \"ret\": ret, \"close\": cl})\n", + " prev[(u, it)] = cl\n", + " return rows\n", + "\n", + "\n", + "def make_equip(n_months=2, seed=61):\n", + " rng = np.random.default_rng(seed)\n", + " out = []\n", + " for m in range(n_months):\n", + " for u in UNITS:\n", + " hold = int(rng.integers(20, 60))\n", + " out.append({\"month\": f\"2026-{m + 7:02d}\", \"unit\": u, \"hold\": hold, \"operable\": int(hold * rng.uniform(0.78, 0.97))})\n", + " return out\n", + "\n", + "LOG = []\n", + "def stage(name):\n", + " # [TODO : 학생 실습 10] 군수 월간 결산 및 보급 현황 보고서 원클릭 자동 생성 도구 최종 완성 관련 핵심 로직을 구현하세요.\n", + " # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n", + " # <<< 여기에 코드를 작성하세요 >>>\n", + " pass # 작성 후 pass를 제거하세요.\n", + "INSTALL = [\"1. 가상 환경 생성 및 필수 모듈 설치(numpy, pandas, matplotlib)\", \"2. 환경 점검: 모듈·버전 확인\", \"3. 샘플 자료로 시험 실행\", \"4. 실행: run_monthly_close()\", \"5. 결과 확인: 보고서 24건, 결재 대기 목록\"]\n", + "mods = [\"numpy\", \"pandas\", \"matplotlib\", \"csv\", \"json\"]\n", + "env_ok = sum(importlib.util.find_spec(m) is not None for m in mods)\n", + "print(f\"[0] 환경 점검: Python {sys.version_info.major}.{sys.version_info.minor}, 필수 모듈 {env_ok}/{len(mods)} 확인 [교육용가상자료]\")\n", + "\n", + "# ---------------- 1) 수집(CSV + JSON 두 형식 혼합) ----------------\n", + "@stage(\"수집·파싱\")\n", + "def collect():\n", + " # [TODO : 학생 실습 10] 군수 월간 결산 및 보급 현황 보고서 원클릭 자동 생성 도구 최종 완성 관련 핵심 로직을 구현하세요.\n", + " # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n", + " # <<< 여기에 코드를 작성하세요 >>>\n", + " pass # 작성 후 pass를 제거하세요.\n", + "RULES = [(\"V1 산식\", lambda d: d[\"open\"] + d[\"recv\"] + d[\"ret\"] - d[\"issue\"] == d[\"close\"]), (\"V2 음수\", lambda d: (d[[\"open\", \"recv\", \"issue\", \"ret\", \"close\"]] >= 0).all(axis=1)),\n", + " (\"V3 가용량\", lambda d: d[\"issue\"] <= d[\"open\"] + d[\"recv\"]), (\"V4 반납\", lambda d: d[\"ret\"] <= d[\"issue\"]), (\"V5 부대\", lambda d: d[\"unit\"].isin(UNITS)),\n", + " (\"V6 품목\", lambda d: d[\"item\"].isin(UNIT_CODE))]\n", + "@stage(\"검증\")\n", + "def check(df):\n", + " bad = pd.Series(False, index=df.index)\n", + " for _, f in RULES:\n", + " bad |= ~f(df)\n", + " return int(bad.sum())\n", + "\n", + "@stage(\"집계\")\n", + "def aggregate(df):\n", + " return df.groupby([\"unit\", \"cat\"])[[\"issue\", \"ret\", \"close\"]].sum().unstack(\"cat\")\n", + "\n", + "@stage(\"이상 점검\")\n", + "def anomalies(df):\n", + " prev = pd.DataFrame([r for r in make_ledger(2) if r[\"month\"] == \"2026-07\"]).set_index([\"unit\", \"item\"])[\"issue\"]\n", + " ratio = df.set_index([\"unit\", \"item\"])[\"issue\"] / prev.clip(lower=1)\n", + " return ratio[ratio >= 4.0]\n", + "\n", + "@stage(\"신뢰도\")\n", + "def reliability(df, nbad):\n", + " return round(100 - 100 * nbad / len(df), 1)\n", + "\n", + "@stage(\"보고서 생성\")\n", + "def build(df, agg):\n", + " out = {}\n", + " for u in UNITS:\n", + " g = df[df[\"unit\"] == u]\n", + " out[u] = {\"unit\": u, \"issue\": int(g[\"issue\"].sum()), \"close\": int(g[\"close\"].sum()), \"status\": \"결재 대기(기안)\", \"published\": False}\n", + " return out\n", + "\n", + "df = collect()\n", + "nbad = check(df)\n", + "agg = aggregate(df)\n", + "anom = anomalies(df)\n", + "rel = reliability(df, nbad)\n", + "reports = build(df, agg)\n", + "\n", + "# ---------------- 3) 결과 ----------------\n", + "print(f\"[1] 수집 {len(df)}행(CSV+JSON 혼합), 검증 위반 {nbad}건, 이상 급증 후보 {len(anom)}건, 자료 신뢰도 {rel}점\")\n", + "tbl = pd.DataFrame(reports).T[[\"issue\", \"close\", \"status\"]].head(5)\n", + "print(\"[2] 부대별 결산 보고서(상위 5개 부대) - 모든 보고서는 결재 대기 상태\")\n", + "print(tbl.to_string())\n", + "tot = agg[\"issue\"].sum()\n", + "print(\"[3] 분류별 불출·소모 합계:\", tot.astype(int).to_dict())\n", + "print(\"[4] 단계별 실행 로그:\")\n", + "for n, st, t in LOG:\n", + " print(f\" - {n:<8} {st:<5} {t * 1000:.1f}ms\")\n", + "run_ms = sum(t for _, _, t in LOG) * 1000\n", + "\n", + "# ---------------- 4) 자체 점검 ----------------\n", + "MANUAL = [\"1. 개요·설치\", \"2. 입력 자료 양식\", \"3. 실행 방법(원클릭)\", \"4. 검증 룰 목록\", \"5. 이상 항목 처리\", \"6. 결재선·권한\", \"7. 오류 메시지 조치\", \"8. 배치 운영\", \"9. 개선 이력·문의\"]\n", + "done_sec = len(MANUAL)\n", + "checks = [(\"환경 점검 5/5\", env_ok == len(mods)), (\"모든 단계 OK\", all(s == \"OK\" for _, s, _ in LOG)), (\"24개 부대 보고서 생성\", len(reports) == 24),\n", + " (\"자동 발간 0건\", not any(r[\"published\"] for r in reports.values())), (\"원장 산식 위반 0건\", nbad == 0),\n", + " (\"실행 시간 1분 이내\", run_ms < 60000), (\"매뉴얼 9장 작성(작성률 100%)\", done_sec == len(MANUAL)),\n", + " (\"설치 절차 5단계 완비\", len(INSTALL) == 5)]\n", + "print(\"\\n[5] 자체 점검\")\n", + "for n, ok in checks:\n", + " print(f\" {'PASS' if ok else 'FAIL'} {n}\")\n", + "npass = sum(ok for _, ok in checks)\n", + "print(\"설치 스크립트 개요:\", \" / \".join(INSTALL))\n", + "print(\"매뉴얼 목차:\", \" | \".join(MANUAL))\n", + "asis, tobe = 610, 58\n", + "print(f\"\\n[브리핑 요약] 24개 부대 결산 보고서를 {run_ms:.0f}ms에 자동 생성(수작업 가정 {asis}분 -> 검토 포함 {tobe}분, {1 - tobe / asis:.0%} 절감 가정값).\")\n", + "print(f\"자료 신뢰도 {rel}점, 이상 급증 후보 {len(anom)}건은 담당자 확인 대상, 보고서 24건 전부 보급관 -> 군수과장 결재 대기(자동 발간 없음).\")\n", + "print(f\"자체 점검 {npass}/{len(checks)} PASS. 목표(원클릭 설치·실행 완비, 매뉴얼 100%): {'달성' if npass == len(checks) else '미달'}\")\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 10 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + "[0] 환경 점검: Python 3.11, 필수 모듈 5/5 확인 [교육용가상자료]\n", + "[1] 수집 264행(CSV+JSON 혼합), 검증 위반 0건, 이상 급증 후보 9건, 자료 신뢰도 100.0점\n", + "[2] 부대별 결산 보고서(상위 5개 부대) - 모든 보고서는 결재 대기 상태\n", + " issue close status\n", + "가상01대대 1147 8915 결재 대기(기안)\n", + "가상02대대 916 9139 결재 대기(기안)\n", + "가상03대대 778 7428 결재 대기(기안)\n", + "가상04대대 882 8357 결재 대기(기안)\n", + "가상05대대 920 7718 결재 대기(기안)\n", + "[3] 분류별 불출·소모 합계: {'물자불출': 8270, '수리부속': 8184, '탄약정산': 6743}\n", + "[4] 단계별 실행 로그:\n", + " - 수집·파싱 OK 19.0ms\n", + " - 검증 OK 8.2ms\n", + " - 집계 OK 13.2ms\n", + " - 이상 점검 OK 23.8ms\n", + " - 신뢰도 OK 0.0ms\n", + " - 보고서 생성 OK 33.2ms\n", + "\n", + "[5] 자체 점검\n", + " PASS 환경 점검 5/5\n", + " PASS 모든 단계 OK\n", + " PASS 24개 부대 보고서 생성\n", + " PASS 자동 발간 0건\n", + " PASS 원장 산식 위반 0건\n", + " PASS 실행 시간 1분 이내\n", + " PASS 매뉴얼 9장 작성(작성률 100%)\n", + " PASS 설치 절차 5단계 완비\n", + "설치 스크립트 개요: 1. 가상 환경 생성 및 필수 모듈 설치(numpy, pandas, matplotlib) / 2. 환경 점검: 모듈·버전 확인 / 3. 샘플 자료로 시험 실행 / 4. 실행: run_monthly_close() / 5. 결과 확인: 보고서 24건, 결재 대기 목록\n", + "매뉴얼 목차: 1. 개요·설치 | 2. 입력 자료 양식 | 3. 실행 방법(원클릭) | 4. 검증 룰 목록 | 5. 이상 항목 처리 | 6. 결재선·권한 | 7. 오류 메시지 조치 | 8. 배치 운영 | 9. 개선 이력·문의\n", + "\n", + "[브리핑 요약] 24개 부대 결산 보고서를 97ms에 자동 생성(수작업 가정 610분 -> 검토 포함 58분, 90% 절감 가정값).\n", + "자료 신뢰도 100.0점, 이상 급증 후보 9건은 담당자 확인 대상, 보고서 24건 전부 보급관 -> 군수과장 결재 대기(자동 발간 없음).\n", + "자체 점검 8/8 PASS. 목표(원클릭 설치·실행 완비, 매뉴얼 100%): 달성\n", + "```\n", + "
\n" + ] + } + ], + "metadata": { + "colab": { + "provenance": [] + }, + "kernelspec": { + "display_name": "Python 3", + "name": "python3" + }, + "language_info": { + "name": "python" + } + }, + "nbformat": 4, + "nbformat_minor": 0 +} \ No newline at end of file diff --git a/uploads/DAT09/DAT09_solution.ipynb b/uploads/DAT09/DAT09_solution.ipynb new file mode 100644 index 0000000..b6c7fb3 --- /dev/null +++ b/uploads/DAT09/DAT09_solution.ipynb @@ -0,0 +1,2383 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# 🎯 [DAT09] 군수데이터 전략기획·환경평가 대시보드\n", + "### Google Colab 실습 환경 (데이터·자동화·업무플랫폼 / Level 2)\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/DAT09/DAT09_solution.ipynb)\n", + "\n", + "---\n", + "\n", + "## 📌 1. 프로젝트 개요 및 배경\n", + "\n", + "| 항목 | 내용 |\n", + "| :--- | :--- |\n", + "| **과정 분류** | 데이터·자동화·업무플랫폼 (Level 2) |\n", + "| **학습 소요 시간** | 10개 미션 (5일 / 총 27.0시간) |\n", + "| **환경 사양** | Python 3.10+, numpy, pandas, matplotlib, scipy, scikit-learn |\n", + "\n", + "### 📖 프로젝트 목표\n", + "미국 국방부(DoD)는 군수 데이터 전략과 환경 평가를 통해 서로 다른 체계에 흩어진 데이터의 중복을 없애고 표준 키와 코드를 정해 업무 중심으로 통합하려는 접근을 공개해 왔다. 이 사례의 학습 초점은 중복 제거, 표준 키·코드, 업무 중심 통합, 역할별 화면, 자동 갱신, 재사용성이다. 본 프로젝트는 이를 바탕으로 교육용 가상 보급·정비·탄약 체계 3종의 재고 보고를 코드로 만들어 통합 대시보드로 가시화한다. 학습자는 연동 규격 분석, 코드 표준화와 결측 전처리, 통합 저장소와 검증 룰, 변경 이벤트 동기화, 불일치 식별과 품질 지수, 역할별 상황판, 데이터관리관 승인, 대량 트랜잭션 시험, 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. 이기종 군수 정보체계(DELIIS, 장비정비정보체계, 탄약체계) 데이터 연동 규격 분석\n", + "**소요시간**: 약 1.5시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "이기종 군수 정보체계(DELIIS, 장비정비, 탄약체계) 데이터 연동 규격을 분석한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **이기종 군수 정보체계 3종의 품목코드·부대코드·날짜·수량 연동 규격을 비교 분석하고, 통합 군수 상황 파악 업무를 AS-IS/TO-BE로 비교한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **가상의 보급·정비·탄약 체계가 같은 재고를 서로 다른 코드와 날짜 형식으로 보고하는 자료를 코드로 작성하여 원인을 분석하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `docs/dat09_as_is_to_be_spec.md` 형태의 결과물을 완성한다.\n", + "\n", + "### 🔍 평가 및 검증 기준 (PASS 조건)\n", + "#### 미션 판정\n", + "\n", + "**평가 구분:** 필수\n", + "\n", + "**핵심 평가영역:** 현행 업무(AS-IS)와 개선 업무(TO-BE) 비교의 명확성\n", + "\n", + "#### 미션 PASS 조건\n", + "\n", + "- 정량 기준: 업무 단계 분해 10단계 이상, 병목 구간 3개소 이상 도출\n", + "\n", + "\n", + "- 진행 기준: 업무 정의서 및 입출력 데이터 명세 작성 완료\n", + "\n", + "\n", + "- 검증 방법: 실무 프로세스와의 일치성 검증\n", + "\n", + "\n", + "#### 채점자 판정 메모\n", + "\n", + "> 단순 코드 실행 여부뿐만 아니라 출력된 수치와 근거 자료가 군 실무 요구사항에 부합하는지 면밀히 확인한다.\n", + "\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "\"\"\"가상 이기종 군수정보체계 3종(보급·정비·탄약)의 품목코드·부대코드·날짜·수량 연동 규격을 비교 분석해 코드 변환 규칙을 정의하고, 통합 군수 상황 파악 업무를 AS-IS/TO-BE 단계로 분해해 병목 구간을 도출한다.\"\"\"\n", + "import datetime\n", + "import numpy as np\n", + "import pandas as pd\n", + "\n", + "# ---------------------------------------------------------------------------\n", + "# [교육용가상자료] 가상 이기종 군수정보체계 3종(A 보급, B 정비, C 탄약)의 재고 보고 생성기\n", + "# 실제 체계명·부대·품목·수량 없음. 같은 (품목, 부대) 재고를 세 체계가 서로 다른 코드·날짜 형식으로 보고한다.\n", + "# 기준 날짜: 2026-09-30(가상). 마스터(정답) 재고 TRUE 와 체계별 보고에는 의도적 불일치·결측이 섞여 있다.\n", + "# ---------------------------------------------------------------------------\n", + "N_PART, N_UNIT = 60, 12\n", + "CATS = [\"급식\", \"피복\", \"유류\", \"정비소모\", \"수리부속\", \"탄약\"]\n", + "PARTS = {p: {\"name\": f\"가상품목{p:02d}\", \"cat\": CATS[(p - 1) % 6]} for p in range(1, N_PART + 1)}\n", + "FMT = {\"A\": dict(part=lambda p: f\"AP-{p:05d}\", unit=lambda u: f\"U{u:02d}\", date=lambda d: d.strftime(\"%Y%m%d\")),\n", + " \"B\": dict(part=lambda p: f\"{p:05d}-X\", unit=lambda u: f\"가상{u:02d}대대\", date=lambda d: d.strftime(\"%Y-%m-%d\")),\n", + " \"C\": dict(part=lambda p: f\"P{p}\", unit=lambda u: str(u), date=lambda d: d.strftime(\"%d/%m/%Y\"))}\n", + "BASE_DATE = datetime.date(2026, 9, 30)\n", + "\n", + "\n", + "def make_systems(seed=9, mismatch=0.10, missing=0.03):\n", + " \"\"\"TRUE(정답 재고)와 체계별 원본 보고 레코드(체계 고유 형식)를 만든다.\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " true = {(p, u): int(rng.integers(20, 500)) for p in PARTS for u in range(1, N_UNIT + 1)}\n", + " out = {s: [] for s in \"ABC\"}\n", + " truth_diff = {} # (체계, p, u) -> 불일치 원인(정답)\n", + " for (p, u), q in true.items():\n", + " for s in \"ABC\":\n", + " qty, cause = q, None\n", + " r = rng.random()\n", + " if r < mismatch / 3: # 입력 오류(자릿수/오타)\n", + " qty, cause = q + int(rng.choice([-1, 1]) * rng.integers(10, 60)), \"입력 오류\"\n", + " elif r < 2 * mismatch / 3: # 반영 지연(전일 값)\n", + " qty, cause = q + int(rng.integers(5, 40)), \"반영 지연\"\n", + " elif r < mismatch: # 단위 오류(10배)\n", + " qty, cause = q * 10, \"단위 오류\"\n", + " d = BASE_DATE - datetime.timedelta(days=int(cause == \"반영 지연\"))\n", + " if rng.random() < missing: # 결측\n", + " qty, cause = None, \"결측\"\n", + " if cause:\n", + " truth_diff[(s, p, u)] = cause\n", + " out[s].append({\"part\": FMT[s][\"part\"](p), \"unit\": FMT[s][\"unit\"](u), \"date\": FMT[s][\"date\"](d), \"qty\": qty})\n", + " return true, out, truth_diff\n", + "\n", + "\n", + "def parse_part(code):\n", + " return int(code[3:]) if code.startswith(\"AP-\") else int(code[:-2]) if code.endswith(\"-X\") else int(code[1:])\n", + "\n", + "\n", + "def parse_unit(code):\n", + " return int(code[1:]) if code.startswith(\"U\") else int(code[2:4]) if code.startswith(\"가상\") else int(code)\n", + "\n", + "\n", + "def parse_date(s):\n", + " if \"/\" in s:\n", + " d, m, y = s.split(\"/\"); return datetime.date(int(y), int(m), int(d))\n", + " if \"-\" in s:\n", + " return datetime.date(*map(int, s.split(\"-\")))\n", + " return datetime.date(int(s[:4]), int(s[4:6]), int(s[6:]))\n", + "\n", + "true, raw, tdiff = make_systems()\n", + "\n", + "# 1) 체계별 연동 규격 비교표(필드 형식 실제 샘플에서 추출)\n", + "spec = pd.DataFrame({\"체계\": [\"A(보급)\", \"B(정비)\", \"C(탄약)\"],\n", + " \"품목코드 예\": [raw[s][0][\"part\"] for s in \"ABC\"], \"부대코드 예\": [raw[s][0][\"unit\"] for s in \"ABC\"],\n", + " \"날짜 예\": [raw[s][0][\"date\"] for s in \"ABC\"], \"수량 필드\": [\"qty(정수)\"] * 3,\n", + " \"갱신 주기\": [\"일 1회\", \"수시\", \"주 1회\"], \"결측 표기\": [\"빈칸\", \"null\", \"-1\"]})\n", + "print(\"[체계별 연동 규격 비교] [교육용가상자료]\")\n", + "print(spec.to_string(index=False))\n", + "\n", + "# 2) 변환 규칙 검증: 세 체계의 코드가 마스터 키로 정확히 1:1 변환되는지\n", + "rule_ok = all(parse_part(FMT[s][\"part\"](p)) == p and parse_unit(FMT[s][\"unit\"](u)) == u for s in \"ABC\" for p in PARTS for u in range(1, N_UNIT + 1))\n", + "date_ok = all(parse_date(FMT[s][\"date\"](BASE_DATE)) == BASE_DATE for s in \"ABC\")\n", + "print(f\"\\n코드 변환 규칙 왕복 검증: 품목 {N_PART}종 x 부대 {N_UNIT}개 x 3체계 {'전부 일치' if rule_ok else '불일치'}, 날짜 형식 3종 {'일치' if date_ok else '불일치'}\")\n", + "\n", + "# 3) 연동 현황: 키 일치도·불일치·결측(체계 간 비교)\n", + "rows = []\n", + "for s in \"ABC\":\n", + " for r in raw[s]:\n", + " rows.append({\"sys\": s, \"p\": parse_part(r[\"part\"]), \"u\": parse_unit(r[\"unit\"]), \"date\": parse_date(r[\"date\"]), \"qty\": r[\"qty\"]})\n", + "df = pd.DataFrame(rows)\n", + "wide = df.pivot_table(index=[\"p\", \"u\"], columns=\"sys\", values=\"qty\", aggfunc=\"first\")\n", + "miss = wide.isna().sum()\n", + "diff = (wide.max(axis=1) != wide.min(axis=1)) & wide.notna().all(axis=1)\n", + "print(f\"세 체계 보고 {len(df)}건 / 비교 키 {len(wide)}개: 결측 A {miss['A']}·B {miss['B']}·C {miss['C']}건, 체계 간 수량 불일치 키 {int(diff.sum())}개({diff.mean():.1%})\")\n", + "print(\"날짜 지연(기준일 이전 보고) 건수:\", int((df[\"date\"] < BASE_DATE).sum()))\n", + "\n", + "# 4) 통합 군수 상황 파악 업무 AS-IS / TO-BE (가상 측정 소요 분)\n", + "steps = [(\"체계 A 재고 조회·내려받기\", 25, \"정해진 시각 자동 수집\", 2), (\"체계 B 정비 현황 조회·내려받기\", 30, \"자동 수집\", 2),\n", + " (\"체계 C 탄약 현황 요청(전화·메일)\", 45, \"연동 요청 규격화·자동 수집\", 5), (\"엑셀로 3개 자료 합치기\", 60, \"표준 키로 자동 병합\", 1),\n", + " (\"품목코드 수기 대조\", 70, \"코드 변환표 자동 적용\", 1), (\"부대명·날짜 형식 통일\", 35, \"변환 규칙 자동 적용\", 1),\n", + " (\"결측·이상값 확인(전화 문의)\", 55, \"결측·불일치 후보 자동 표시\", 10), (\"체계 간 불일치 원인 확인\", 50, \"원인 후보 자동 추천\", 12),\n", + " (\"지표(재고·가동·소모) 집계\", 40, \"집계 쿼리 자동 실행\", 1), (\"군수참모 보고용 표·그래프 작성\", 65, \"상황판 자동 갱신\", 2),\n", + " (\"마스터 데이터 수정 요청·확정\", 45, \"데이터관리관 승인 화면\", 10), (\"보고 후 수정·재배포\", 30, \"단일 데이터 원천 사용\", 3)]\n", + "proc = pd.DataFrame(steps, columns=[\"업무 단계\", \"AS-IS(분)\", \"TO-BE 개선\", \"TO-BE(분)\"])\n", + "proc.index = range(1, len(proc) + 1)\n", + "thr = proc[\"AS-IS(분)\"].mean() + 0.5 * proc[\"AS-IS(분)\"].std()\n", + "proc[\"병목\"] = np.where(proc[\"AS-IS(분)\"] >= thr, \"병목\", \"\")\n", + "print(f\"\\n[AS-IS / TO-BE 업무 단계 {len(proc)}단계] (병목 기준: 평균+0.5표준편차 = {thr:.0f}분 이상)\")\n", + "print(proc[[\"업무 단계\", \"AS-IS(분)\", \"TO-BE(분)\", \"병목\"]].to_string())\n", + "nb = int((proc[\"병목\"] == \"병목\").sum())\n", + "a, t = proc[\"AS-IS(분)\"].sum(), proc[\"TO-BE(분)\"].sum()\n", + "print(f\"\\n업무 단계 {len(proc)}단계(목표 10단계 이상: {'달성' if len(proc) >= 10 else '미달'}), 병목 {nb}개소(목표 3개소 이상: {'달성' if nb >= 3 else '미달'})\")\n", + "print(f\"1회 상황 파악 AS-IS {a}분 -> TO-BE {t}분 ({1 - t / a:.0%} 단축 예상, 가정값)\")\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 01 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + "[체계별 연동 규격 비교] [교육용가상자료]\n", + " 체계 품목코드 예 부대코드 예 날짜 예 수량 필드 갱신 주기 결측 표기\n", + "A(보급) AP-00001 U01 20260930 qty(정수) 일 1회 빈칸\n", + "B(정비) 00001-X 가상01대대 2026-09-30 qty(정수) 수시 null\n", + "C(탄약) P1 1 30/09/2026 qty(정수) 주 1회 -1\n", + "\n", + "코드 변환 규칙 왕복 검증: 품목 60종 x 부대 12개 x 3체계 전부 일치, 날짜 형식 3종 일치\n", + "세 체계 보고 2160건 / 비교 키 720개: 결측 A 18·B 14·C 14건, 체계 간 수량 불일치 키 176개(24.4%)\n", + "날짜 지연(기준일 이전 보고) 건수: 73\n", + "\n", + "[AS-IS / TO-BE 업무 단계 12단계] (병목 기준: 평균+0.5표준편차 = 53분 이상)\n", + " 업무 단계 AS-IS(분) TO-BE(분) 병목\n", + "1 체계 A 재고 조회·내려받기 25 2 \n", + "2 체계 B 정비 현황 조회·내려받기 30 2 \n", + "3 체계 C 탄약 현황 요청(전화·메일) 45 5 \n", + "4 엑셀로 3개 자료 합치기 60 1 병목\n", + "5 품목코드 수기 대조 70 1 병목\n", + "6 부대명·날짜 형식 통일 35 1 \n", + "7 결측·이상값 확인(전화 문의) 55 10 병목\n", + "8 체계 간 불일치 원인 확인 50 12 \n", + "9 지표(재고·가동·소모) 집계 40 1 \n", + "10 군수참모 보고용 표·그래프 작성 65 2 병목\n", + "11 마스터 데이터 수정 요청·확정 45 10 \n", + "12 보고 후 수정·재배포 30 3 \n", + "\n", + "업무 단계 12단계(목표 10단계 이상: 달성), 병목 4개소(목표 3개소 이상: 달성)\n", + "1회 상황 파악 AS-IS 550분 -> TO-BE 50분 (91% 단축 예상, 가정값)\n", + "```\n", + "
\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 02. 서로 다른 부품코드, 부대코드, 날짜 포맷 통합 및 결측 데이터 전처리 파이프라인 구축\n", + "**소요시간**: 약 2.0시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "부품코드, 부대코드, 날짜 포맷 통합 및 결측 데이터 전처리 파이프라인을 구축한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **체계마다 다른 파일 형식(CSV, JSON, 탭 구분 엑셀 내보내기)과 품목코드·부대코드·날짜 형식을 표준 키로 통합하고 결측 데이터를 전처리하는 파이프라인을 구축한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **세 체계의 가상 보고를 메모리 안의 문자열로 만들어 형식별 파서로 읽고, 머리글 별칭 사전으로 양식 변경에 대응 수행하고 검증하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `src/connectors/dat09_data_loader.py 및 스키마 정의서` 형태의 결과물을 완성한다.\n", + "\n", + "### 🔍 평가 및 검증 기준 (PASS 조건)\n", + "#### 미션 판정\n", + "\n", + "**평가 구분:** 필수\n", + "\n", + "**핵심 평가영역:** 국방 서식/양식 변경에 대한 유연한 확장성\n", + "\n", + "#### 미션 PASS 조건\n", + "\n", + "- 정량 기준: 지원 파일 포맷(CSV, Excel, JSON 등) 파싱 성공률 100%\n", + "\n", + "\n", + "- 진행 기준: 데이터 로더 및 스키마 유효성 검사 모듈 완성\n", + "\n", + "\n", + "- 검증 방법: 다양한 형태의 샘플 군수 문서 대상 파싱 테스트\n", + "\n", + "\n", + "#### 채점자 판정 메모\n", + "\n", + "> 단순 코드 실행 여부뿐만 아니라 출력된 수치와 근거 자료가 군 실무 요구사항에 부합하는지 면밀히 확인한다.\n", + "\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "\"\"\"체계별로 다른 형식(CSV·JSON·탭 구분 엑셀 내보내기)의 가상 재고 보고를 메모리 안에서 파싱하고, 품목코드·부대코드·날짜 형식을 표준 키로 통합하며 결측값을 체계 간 교차 값으로 보완하는 전처리 파이프라인을 구축한다.\"\"\"\n", + "import csv\n", + "import datetime\n", + "import io\n", + "import json\n", + "import numpy as np\n", + "import pandas as pd\n", + "\n", + "# ---------------------------------------------------------------------------\n", + "# [교육용가상자료] 가상 이기종 군수정보체계 3종(A 보급, B 정비, C 탄약)의 재고 보고 생성기\n", + "# 실제 체계명·부대·품목·수량 없음. 같은 (품목, 부대) 재고를 세 체계가 서로 다른 코드·날짜 형식으로 보고한다.\n", + "# 기준 날짜: 2026-09-30(가상). 마스터(정답) 재고 TRUE 와 체계별 보고에는 의도적 불일치·결측이 섞여 있다.\n", + "# ---------------------------------------------------------------------------\n", + "N_PART, N_UNIT = 60, 12\n", + "CATS = [\"급식\", \"피복\", \"유류\", \"정비소모\", \"수리부속\", \"탄약\"]\n", + "PARTS = {p: {\"name\": f\"가상품목{p:02d}\", \"cat\": CATS[(p - 1) % 6]} for p in range(1, N_PART + 1)}\n", + "FMT = {\"A\": dict(part=lambda p: f\"AP-{p:05d}\", unit=lambda u: f\"U{u:02d}\", date=lambda d: d.strftime(\"%Y%m%d\")),\n", + " \"B\": dict(part=lambda p: f\"{p:05d}-X\", unit=lambda u: f\"가상{u:02d}대대\", date=lambda d: d.strftime(\"%Y-%m-%d\")),\n", + " \"C\": dict(part=lambda p: f\"P{p}\", unit=lambda u: str(u), date=lambda d: d.strftime(\"%d/%m/%Y\"))}\n", + "BASE_DATE = datetime.date(2026, 9, 30)\n", + "\n", + "\n", + "def make_systems(seed=9, mismatch=0.10, missing=0.03):\n", + " \"\"\"TRUE(정답 재고)와 체계별 원본 보고 레코드(체계 고유 형식)를 만든다.\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " true = {(p, u): int(rng.integers(20, 500)) for p in PARTS for u in range(1, N_UNIT + 1)}\n", + " out = {s: [] for s in \"ABC\"}\n", + " truth_diff = {} # (체계, p, u) -> 불일치 원인(정답)\n", + " for (p, u), q in true.items():\n", + " for s in \"ABC\":\n", + " qty, cause = q, None\n", + " r = rng.random()\n", + " if r < mismatch / 3: # 입력 오류(자릿수/오타)\n", + " qty, cause = q + int(rng.choice([-1, 1]) * rng.integers(10, 60)), \"입력 오류\"\n", + " elif r < 2 * mismatch / 3: # 반영 지연(전일 값)\n", + " qty, cause = q + int(rng.integers(5, 40)), \"반영 지연\"\n", + " elif r < mismatch: # 단위 오류(10배)\n", + " qty, cause = q * 10, \"단위 오류\"\n", + " d = BASE_DATE - datetime.timedelta(days=int(cause == \"반영 지연\"))\n", + " if rng.random() < missing: # 결측\n", + " qty, cause = None, \"결측\"\n", + " if cause:\n", + " truth_diff[(s, p, u)] = cause\n", + " out[s].append({\"part\": FMT[s][\"part\"](p), \"unit\": FMT[s][\"unit\"](u), \"date\": FMT[s][\"date\"](d), \"qty\": qty})\n", + " return true, out, truth_diff\n", + "\n", + "\n", + "def parse_part(code):\n", + " return int(code[3:]) if code.startswith(\"AP-\") else int(code[:-2]) if code.endswith(\"-X\") else int(code[1:])\n", + "\n", + "\n", + "def parse_unit(code):\n", + " return int(code[1:]) if code.startswith(\"U\") else int(code[2:4]) if code.startswith(\"가상\") else int(code)\n", + "\n", + "\n", + "def parse_date(s):\n", + " if \"/\" in s:\n", + " d, m, y = s.split(\"/\"); return datetime.date(int(y), int(m), int(d))\n", + " if \"-\" in s:\n", + " return datetime.date(*map(int, s.split(\"-\")))\n", + " return datetime.date(int(s[:4]), int(s[4:6]), int(s[6:]))\n", + "\n", + "true, raw, tdiff = make_systems()\n", + "ALIAS = {\"part\": [\"part\", \"부품코드\", \"품목코드\"], \"unit\": [\"unit\", \"부대\", \"부대코드\"], \"date\": [\"date\", \"기준일\", \"일자\"], \"qty\": [\"qty\", \"수량\", \"재고\"]}\n", + "H2F = {h: f for f, hs in ALIAS.items() for h in hs}\n", + "\n", + "# --- 가상 원본 문자열 3종: 체계별 형식과 결측 표기가 다르다 ---\n", + "txt_a = \"\\n\".join([\"part,unit,date,qty\"] + [f\"{r['part']},{r['unit']},{r['date']},{'' if r['qty'] is None else r['qty']}\" for r in raw[\"A\"]])\n", + "txt_b = json.dumps({\"system\": \"B\", \"items\": [{\"부품코드\": r[\"part\"], \"부대\": r[\"unit\"], \"기준일\": r[\"date\"], \"수량\": r[\"qty\"]} for r in raw[\"B\"]]}, ensure_ascii=False)\n", + "txt_c = \"\\n\".join([\"품목코드\\t부대코드\\t일자\\t재고\"] + [f\"{r['part']}\\t{r['unit']}\\t{r['date']}\\t{-1 if r['qty'] is None else r['qty']}\" for r in raw[\"C\"]])\n", + "TEXT = {\"A\": txt_a, \"B\": txt_b, \"C\": txt_c}\n", + "# 파싱 실패 유발 줄 추가(보류 대상)\n", + "TEXT[\"A\"] += \"\\nAP-9999x,U01,20260930,12\\nAP-00001,U99,20260930,12\"\n", + "\n", + "def norm(row, system):\n", + " r = {H2F[k.strip()]: v for k, v in row.items() if k.strip() in H2F}\n", + " q = r[\"qty\"]\n", + " if system == \"A\":\n", + " q = None if q in (\"\", None) else int(q)\n", + " elif system == \"B\":\n", + " q = None if q is None else int(q)\n", + " else:\n", + " q = None if int(q) == -1 else int(q)\n", + " return {\"sys\": system, \"p\": parse_part(r[\"part\"]), \"u\": parse_unit(r[\"unit\"]), \"date\": parse_date(r[\"date\"]), \"qty\": q}\n", + "\n", + "READ = {\"A\": lambda s: list(csv.DictReader(io.StringIO(s))), \"B\": lambda s: json.loads(s)[\"items\"],\n", + " \"C\": lambda s: list(csv.DictReader(io.StringIO(s), delimiter=\"\\t\"))}\n", + "ok_fmt, recs, hold = {}, [], []\n", + "for sysname, text in TEXT.items():\n", + " try:\n", + " rows = READ[sysname](text); ok_fmt[sysname] = True\n", + " except Exception:\n", + " rows = []; ok_fmt[sysname] = False\n", + " for r in rows:\n", + " try:\n", + " n = norm(r, sysname)\n", + " if not (1 <= n[\"p\"] <= N_PART and 1 <= n[\"u\"] <= N_UNIT):\n", + " raise ValueError(\"키 범위 밖\")\n", + " recs.append(n)\n", + " except Exception as ex:\n", + " hold.append((sysname, str(r)[:60], str(ex)[:20]))\n", + "df = pd.DataFrame(recs)\n", + "print(f\"[형식별 파싱] A(CSV) {'성공' if ok_fmt['A'] else '실패'}, B(JSON) {'성공' if ok_fmt['B'] else '실패'}, C(탭 구분) {'성공' if ok_fmt['C'] else '실패'}\")\n", + "print(f\"표준화 {len(df)}건(체계별 {df['sys'].value_counts().to_dict()}), 반영 보류 {len(hold)}건\")\n", + "for h in hold:\n", + " print(\" 보류:\", h)\n", + "rate = sum(ok_fmt.values()) / len(ok_fmt)\n", + "\n", + "# --- 표준 키 통합: (품목, 부대) 기준으로 세 체계를 한 행에 ---\n", + "wide = df.pivot_table(index=[\"p\", \"u\"], columns=\"sys\", values=\"qty\", aggfunc=\"first\")\n", + "print(f\"\\n통합 키 {len(wide)}개(기대 {N_PART * N_UNIT}), 결측 값 {int(wide.isna().sum().sum())}건 (A {wide['A'].isna().sum()}, B {wide['B'].isna().sum()}, C {wide['C'].isna().sum()})\")\n", + "# --- 결측 전처리: 다른 두 체계 값 중 작은 값(단위 10배 오류 방지)을 후보로 보완하고 표시, 둘 다 없으면 미해결 ---\n", + "filled = wide.copy(); flag = pd.DataFrame(\"\", index=wide.index, columns=wide.columns)\n", + "for s in \"ABC\":\n", + " others = [c for c in \"ABC\" if c != s]\n", + " m = wide[s].isna()\n", + " vals = wide.loc[m, others]\n", + " fill = vals.min(axis=1)\n", + " filled.loc[m, s] = fill\n", + " flag.loc[m & fill.notna(), s] = \"보완\"\n", + " flag.loc[m & fill.isna(), s] = \"미해결\"\n", + "n_fill = int((flag == \"보완\").sum().sum()); n_un = int((flag == \"미해결\").sum().sum())\n", + "print(f\"결측 보완 {n_fill}건(다른 체계 값 기반 후보값, 확정 아님), 미해결 {n_un}건\")\n", + "t = true\n", + "exact = np.mean([filled.loc[(p, u), s] == t[(p, u)] for (p, u) in list(wide.index)[:720] for s in \"ABC\" if flag.loc[(p, u), s] == \"보완\"])\n", + "print(f\"보완값이 마스터와 같은 비율(가상 정답 대조) {exact:.0%} -> 보완값은 '후보'로 표시하고 마스터 확정은 사람이 한다\")\n", + "print(\"\\n표준 통합표 미리보기(3행):\")\n", + "print(filled.head(3).astype(\"float\").to_string())\n", + "print(f\"\\n목표(3개 형식 파싱 성공률 100%, 양식 변경 대응, 결측 전처리): {'달성' if rate == 1 and len(wide) == 720 and len(hold) == 2 else '미달'}\")\n", + "print(\"새 머리글 대응은 ALIAS 사전에 별칭만 추가하면 된다(코드 수정 불필요).\")\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 02 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + "[형식별 파싱] A(CSV) 성공, B(JSON) 성공, C(탭 구분) 성공\n", + "표준화 2160건(체계별 {'A': 720, 'B': 720, 'C': 720}), 반영 보류 2건\n", + " 보류: ('A', \"{'part': 'AP-9999x', 'unit': 'U01', 'date': '20260930', 'qty\", 'invalid literal for ')\n", + " 보류: ('A', \"{'part': 'AP-00001', 'unit': 'U99', 'date': '20260930', 'qty\", '키 범위 밖')\n", + "\n", + "통합 키 720개(기대 720), 결측 값 46건 (A 18, B 14, C 14)\n", + "결측 보완 46건(다른 체계 값 기반 후보값, 확정 아님), 미해결 0건\n", + "보완값이 마스터와 같은 비율(가상 정답 대조) 100% -> 보완값은 '후보'로 표시하고 마스터 확정은 사람이 한다\n", + "\n", + "표준 통합표 미리보기(3행):\n", + "sys A B C\n", + "p u \n", + "1 1 222.0 222.0 222.0\n", + " 2 437.0 437.0 437.0\n", + " 3 444.0 481.0 481.0\n", + "\n", + "목표(3개 형식 파싱 성공률 100%, 양식 변경 대응, 결측 전처리): 달성\n", + "새 머리글 대응은 ALIAS 사전에 별칭만 추가하면 된다(코드 수정 불필요).\n", + "```\n", + "
\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 03. 통합 데이터베이스 1차 구축 및 핵심 군수 지표 집계 Baseline 쿼리 모듈 구현\n", + "**소요시간**: 약 2.5시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "통합 DB 구축 및 핵심 군수 지표 집계 Baseline 쿼리 모듈을 구현한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **세 체계의 표준화 재고를 통합 데이터 저장소에 구축하고, 검증 룰 15종과 핵심 군수 지표 집계 Baseline 쿼리를 구현한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **표준화한 2,160건을 하나의 표로 쌓아 통합 저장소로 삼고, 룰 15종을 가상 조항 번호가 붙은 설정 문자열로 분리해 위반 건수와 처리 지연시간을 측정 수행하고 검증하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `src/engine/dat09_rule_engine.py` 형태의 결과물을 완성한다.\n", + "\n", + "### 🔍 평가 및 검증 기준 (PASS 조건)\n", + "#### 미션 판정\n", + "\n", + "**평가 구분:** 필수\n", + "\n", + "**핵심 평가영역:** 군 규정 및 지침 조항과의 정확한 일치성\n", + "\n", + "#### 미션 PASS 조건\n", + "\n", + "- 정량 기준: 업무 검증 룰 15종 이상 코딩, 룰 처리 지연시간 50ms 이하\n", + "\n", + "\n", + "- 진행 기준: 룰 엔진 단위 테스트 100% 통과\n", + "\n", + "\n", + "- 검증 방법: 경계 조건(Boundary condition)에 대한 룰 판정 검증\n", + "\n", + "\n", + "#### 채점자 판정 메모\n", + "\n", + "> 단순 코드 실행 여부뿐만 아니라 출력된 수치와 근거 자료가 군 실무 요구사항에 부합하는지 면밀히 확인한다.\n", + "\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "\"\"\"세 체계의 표준화 재고를 하나의 통합 데이터 저장소(표)로 구축하고, 설정 문자열로 분리한 검증 룰 15종과 핵심 군수 지표(재고 합계·부족 품목·체계 간 일치율) 집계 Baseline 쿼리를 구현해 처리 지연시간과 경계 조건 테스트를 확인한다.\"\"\"\n", + "import datetime\n", + "import time\n", + "import numpy as np\n", + "import pandas as pd\n", + "\n", + "# ---------------------------------------------------------------------------\n", + "# [교육용가상자료] 가상 이기종 군수정보체계 3종(A 보급, B 정비, C 탄약)의 재고 보고 생성기\n", + "# 실제 체계명·부대·품목·수량 없음. 같은 (품목, 부대) 재고를 세 체계가 서로 다른 코드·날짜 형식으로 보고한다.\n", + "# 기준 날짜: 2026-09-30(가상). 마스터(정답) 재고 TRUE 와 체계별 보고에는 의도적 불일치·결측이 섞여 있다.\n", + "# ---------------------------------------------------------------------------\n", + "N_PART, N_UNIT = 60, 12\n", + "CATS = [\"급식\", \"피복\", \"유류\", \"정비소모\", \"수리부속\", \"탄약\"]\n", + "PARTS = {p: {\"name\": f\"가상품목{p:02d}\", \"cat\": CATS[(p - 1) % 6]} for p in range(1, N_PART + 1)}\n", + "FMT = {\"A\": dict(part=lambda p: f\"AP-{p:05d}\", unit=lambda u: f\"U{u:02d}\", date=lambda d: d.strftime(\"%Y%m%d\")),\n", + " \"B\": dict(part=lambda p: f\"{p:05d}-X\", unit=lambda u: f\"가상{u:02d}대대\", date=lambda d: d.strftime(\"%Y-%m-%d\")),\n", + " \"C\": dict(part=lambda p: f\"P{p}\", unit=lambda u: str(u), date=lambda d: d.strftime(\"%d/%m/%Y\"))}\n", + "BASE_DATE = datetime.date(2026, 9, 30)\n", + "\n", + "\n", + "def make_systems(seed=9, mismatch=0.10, missing=0.03):\n", + " \"\"\"TRUE(정답 재고)와 체계별 원본 보고 레코드(체계 고유 형식)를 만든다.\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " true = {(p, u): int(rng.integers(20, 500)) for p in PARTS for u in range(1, N_UNIT + 1)}\n", + " out = {s: [] for s in \"ABC\"}\n", + " truth_diff = {} # (체계, p, u) -> 불일치 원인(정답)\n", + " for (p, u), q in true.items():\n", + " for s in \"ABC\":\n", + " qty, cause = q, None\n", + " r = rng.random()\n", + " if r < mismatch / 3: # 입력 오류(자릿수/오타)\n", + " qty, cause = q + int(rng.choice([-1, 1]) * rng.integers(10, 60)), \"입력 오류\"\n", + " elif r < 2 * mismatch / 3: # 반영 지연(전일 값)\n", + " qty, cause = q + int(rng.integers(5, 40)), \"반영 지연\"\n", + " elif r < mismatch: # 단위 오류(10배)\n", + " qty, cause = q * 10, \"단위 오류\"\n", + " d = BASE_DATE - datetime.timedelta(days=int(cause == \"반영 지연\"))\n", + " if rng.random() < missing: # 결측\n", + " qty, cause = None, \"결측\"\n", + " if cause:\n", + " truth_diff[(s, p, u)] = cause\n", + " out[s].append({\"part\": FMT[s][\"part\"](p), \"unit\": FMT[s][\"unit\"](u), \"date\": FMT[s][\"date\"](d), \"qty\": qty})\n", + " return true, out, truth_diff\n", + "\n", + "\n", + "def parse_part(code):\n", + " return int(code[3:]) if code.startswith(\"AP-\") else int(code[:-2]) if code.endswith(\"-X\") else int(code[1:])\n", + "\n", + "\n", + "def parse_unit(code):\n", + " return int(code[1:]) if code.startswith(\"U\") else int(code[2:4]) if code.startswith(\"가상\") else int(code)\n", + "\n", + "\n", + "def parse_date(s):\n", + " if \"/\" in s:\n", + " d, m, y = s.split(\"/\"); return datetime.date(int(y), int(m), int(d))\n", + " if \"-\" in s:\n", + " return datetime.date(*map(int, s.split(\"-\")))\n", + " return datetime.date(int(s[:4]), int(s[4:6]), int(s[6:]))\n", + "\n", + "true, raw, tdiff = make_systems()\n", + "rows = []\n", + "for s in \"ABC\":\n", + " for r in raw[s]:\n", + " rows.append({\"sys\": s, \"p\": parse_part(r[\"part\"]), \"u\": parse_unit(r[\"unit\"]), \"date\": parse_date(r[\"date\"]), \"qty\": r[\"qty\"]})\n", + "store = pd.DataFrame(rows) # 통합 데이터 저장소(체계 3개 x 720키)\n", + "store[\"cat\"] = store[\"p\"].map(lambda p: PARTS[p][\"cat\"])\n", + "store[\"qty_f\"] = store[\"qty\"].astype(\"float\")\n", + "\n", + "# 1) 검증 룰 15종(설정): (ID, 가상 조항, 조건식(참이면 정상), 메시지)\n", + "RULES = [(\"D01\", \"가상지침 1조\", \"1 <= p <= 60\", \"품목 키 범위 밖\"), (\"D02\", \"가상지침 1조\", \"1 <= u <= 12\", \"부대 키 범위 밖\"),\n", + " (\"D03\", \"가상지침 2조\", \"sys in ('A', 'B', 'C')\", \"알 수 없는 체계\"), (\"D04\", \"가상지침 3조\", \"qty is not None\", \"수량 결측\"),\n", + " (\"D05\", \"가상지침 3조\", \"qty is None or qty >= 0\", \"수량 음수\"), (\"D06\", \"가상지침 3조\", \"qty is None or qty <= 5000\", \"수량 상한 초과\"),\n", + " (\"D07\", \"가상지침 4조\", \"isinstance(p, int) and isinstance(u, int)\", \"키 자료형 오류\"), (\"D08\", \"가상지침 4조\", \"date <= TODAY\", \"미래 날짜\"),\n", + " (\"D09\", \"가상지침 5조\", \"(TODAY - date).days <= 3\", \"3일 초과 지연 보고\"), (\"D10\", \"가상지침 5조\", \"(TODAY - date).days >= 0\", \"기준일 이후 날짜\"),\n", + " (\"D11\", \"가상지침 6조\", \"cat in CATS\", \"분류 코드 오류\"), (\"D12\", \"가상지침 6조\", \"qty is None or qty % 1 == 0\", \"수량 소수 불가\"),\n", + " (\"D13\", \"가상지침 7조\", \"not (sys == 'C' and cat != '탄약' and qty is not None and qty > 3000)\", \"탄약체계 비탄약 대량 수량\"),\n", + " (\"D14\", \"가상지침 7조\", \"qty is None or qty < 2000 or qty % 10 != 0\", \"10배 단위 오류 의심(2000 이상 10의 배수)\"),\n", + " (\"D15\", \"가상지침 8조\", \"u != 0\", \"부대 0 사용 불가\")]\n", + "TODAY = BASE_DATE\n", + "CODE = [(i, a, compile(e, i, \"eval\"), m) for i, a, e, m in RULES]\n", + "SAFE = {\"__builtins__\": {\"isinstance\": isinstance, \"int\": int}}\n", + "\n", + "\n", + "def check(rec):\n", + " env = {\"CATS\": CATS, \"TODAY\": TODAY, **rec}\n", + " bad = []\n", + " for i, a, c, m in CODE:\n", + " try:\n", + " ok = eval(c, SAFE, env)\n", + " except Exception:\n", + " ok = False\n", + " if not ok:\n", + " bad.append((i, m))\n", + " return bad\n", + "\n", + "\n", + "recs = store[[\"sys\", \"p\", \"u\", \"date\", \"qty\", \"cat\"]].astype(object).where(store[[\"sys\", \"p\", \"u\", \"date\", \"qty\", \"cat\"]].notna(), None).to_dict(\"records\")\n", + "lat, viol = [], {}\n", + "for r in recs:\n", + " t0 = time.perf_counter(); res = check(r); lat.append((time.perf_counter() - t0) * 1000)\n", + " for i, m in res:\n", + " viol[i] = viol.get(i, 0) + 1\n", + "lat = np.array(lat)\n", + "print(f\"[검증 룰 {len(RULES)}종] 대상 {len(recs)}건, 룰 위반 {sum(viol.values())}건 [교육용가상자료]\")\n", + "print(\"룰별 위반:\", dict(sorted(viol.items())))\n", + "print(f\"룰 처리 지연 평균 {lat.mean():.3f}ms, 최대 {lat.max():.3f}ms (목표 50ms 이하: {'달성' if lat.max() <= 50 else '미달'}, 환경에 따라 다름)\")\n", + "\n", + "# 2) Baseline 지표 쿼리: 함수로 분리(체계 A를 기준 값으로)\n", + "def q_stock_by_cat(df, sysname=\"A\"):\n", + " return df[df[\"sys\"] == sysname].groupby(\"cat\")[\"qty_f\"].sum()\n", + "\n", + "\n", + "def q_low_stock(df, thr=60, sysname=\"A\"):\n", + " d = df[(df[\"sys\"] == sysname) & (df[\"qty_f\"] < thr)]\n", + " return d.groupby(\"p\")[\"u\"].count().sort_values(ascending=False)\n", + "\n", + "\n", + "def q_agreement(df):\n", + " w = df.pivot_table(index=[\"p\", \"u\"], columns=\"sys\", values=\"qty_f\")\n", + " ok = w.notna().all(axis=1)\n", + " return float((w[ok].nunique(axis=1) == 1).mean()), int(ok.sum())\n", + "\n", + "\n", + "t0 = time.perf_counter()\n", + "cat_sum, low, (agree, n_cmp) = q_stock_by_cat(store), q_low_stock(store), q_agreement(store)\n", + "print(\"\\n[Baseline 지표 쿼리] 쿼리 3개 실행\", f\"{(time.perf_counter() - t0) * 1000:.1f}ms\")\n", + "print(\"분류별 재고 합계(체계 A 기준):\", cat_sum.astype(int).to_dict())\n", + "print(\"재고 60 미만 부대가 많은 품목 상위 3:\", low.head(3).to_dict())\n", + "print(f\"세 체계 모두 보고한 {n_cmp}개 키 중 수량이 완전히 같은 비율(체계 간 일치율) {agree:.1%} -> 이후 미션에서 불일치 원인을 해결\")\n", + "\n", + "# 3) 경계 조건 단위 테스트\n", + "base = {\"sys\": \"A\", \"p\": 1, \"u\": 1, \"date\": TODAY, \"qty\": 100, \"cat\": \"급식\"}\n", + "tests = [(\"정상\", base, []), (\"수량 0 허용\", {**base, \"qty\": 0}, []), (\"수량 상한 5000 경계(D06 통과, D14 의심)\", {**base, \"qty\": 5000}, [\"D14\"]),\n", + " (\"수량 5001\", {**base, \"qty\": 5001}, [\"D06\"]), (\"지연 3일 허용\", {**base, \"date\": TODAY - datetime.timedelta(days=3)}, []),\n", + " (\"지연 4일\", {**base, \"date\": TODAY - datetime.timedelta(days=4)}, [\"D09\"]), (\"미래 날짜\", {**base, \"date\": TODAY + datetime.timedelta(days=1)}, [\"D08\", \"D10\"]),\n", + " (\"품목 키 61\", {**base, \"p\": 61}, [\"D01\"]), (\"수량 결측\", {**base, \"qty\": None}, [\"D04\"]), (\"수량 소수\", {**base, \"qty\": 10.5}, [\"D12\"])]\n", + "ok = 0\n", + "print(\"\\n[경계 조건 단위 테스트]\")\n", + "for n, r, exp in tests:\n", + " got = sorted(i for i, _ in check(r))\n", + " p = got == exp\n", + " ok += p\n", + " print(f\" - {n}: 기대 {exp or '위반 없음'} / 결과 {got or '위반 없음'} -> {'PASS' if p else 'FAIL'}\")\n", + "print(f\"단위 테스트 {ok}/{len(tests)} 통과({ok / len(tests):.0%})\")\n", + "print(f\"목표(룰 15종 이상, 지연 50ms 이하, 단위 테스트 100%): {'달성' if len(RULES) >= 15 and lat.max() <= 50 and ok == len(tests) else '미달'}\")\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 03 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + "[검증 룰 15종] 대상 2160건, 룰 위반 107건 [교육용가상자료]\n", + "룰별 위반: {'D04': 46, 'D05': 3, 'D13': 11, 'D14': 47}\n", + "룰 처리 지연 평균 0.006ms, 최대 0.185ms (목표 50ms 이하: 달성, 환경에 따라 다름)\n", + "\n", + "[Baseline 지표 쿼리] 쿼리 3개 실행 32.7ms\n", + "분류별 재고 합계(체계 A 기준): {'급식': 33087, '수리부속': 42203, '유류': 40597, '정비소모': 38378, '탄약': 39855, '피복': 39222}\n", + "재고 60 미만 부대가 많은 품목 상위 3: {2: 3, 10: 3, 60: 3}\n", + "세 체계 모두 보고한 677개 키 중 수량이 완전히 같은 비율(체계 간 일치율) 74.0% -> 이후 미션에서 불일치 원인을 해결\n", + "\n", + "[경계 조건 단위 테스트]\n", + " - 정상: 기대 위반 없음 / 결과 위반 없음 -> PASS\n", + " - 수량 0 허용: 기대 위반 없음 / 결과 위반 없음 -> PASS\n", + " - 수량 상한 5000 경계(D06 통과, D14 의심): 기대 ['D14'] / 결과 ['D14'] -> PASS\n", + " - 수량 5001: 기대 ['D06'] / 결과 ['D06'] -> PASS\n", + " - 지연 3일 허용: 기대 위반 없음 / 결과 위반 없음 -> PASS\n", + " - 지연 4일: 기대 ['D09'] / 결과 ['D09'] -> PASS\n", + " - 미래 날짜: 기대 ['D08', 'D10'] / 결과 ['D08', 'D10'] -> PASS\n", + " - 품목 키 61: 기대 ['D01'] / 결과 ['D01'] -> PASS\n", + " - 수량 결측: 기대 ['D04'] / 결과 ['D04'] -> PASS\n", + " - 수량 소수: 기대 ['D12'] / 결과 ['D12'] -> PASS\n", + "단위 테스트 10/10 통과(100%)\n", + "목표(룰 15종 이상, 지연 50ms 이하, 단위 테스트 100%): 달성\n", + "```\n", + "
\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 04. 실시간 데이터 스트리밍(Kafka/CDC) 기반 이기종 데이터 정합성 유지 및 자동 동기화 고도화\n", + "**소요시간**: 약 3.0시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "실시간 데이터 스트리밍 기반 이기종 데이터 정합성 유지 및 자동 동기화를 고도화한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **변경 이벤트 스트림(CDC 방식을 메모리에서 모의)으로 체계 간 정합성을 유지하고 불일치 원인을 분류해 마스터 값 후보를 추천하는 모듈을 고도화한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **외부 스트리밍 도구 없이 메모리 안의 큐로 변경 이벤트를 만들고, 중복 전송과 순서가 뒤바뀐 오래된 값이 섞인 상태에서 이벤트 ID 중복 제거와 키별 최신 시각 우선 규칙으로 동기화 수행하고 검증하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `src/ai/dat09_intelligence_module.py` 형태의 결과물을 완성한다.\n", + "\n", + "### 🔍 평가 및 검증 기준 (PASS 조건)\n", + "#### 미션 판정\n", + "\n", + "**평가 구분:** 필수\n", + "\n", + "**핵심 평가영역:** 추천 사유의 합리성 및 실무 수용성\n", + "\n", + "#### 미션 PASS 조건\n", + "\n", + "- 정량 기준: 추천/분류 정확도 85% 이상 달성\n", + "\n", + "\n", + "- 진행 기준: AI 추론 모듈 구현 및 벤치마크 테스트 완료\n", + "\n", + "\n", + "- 검증 방법: 과거 승인/처리 완료 데이터와의 추천 일치도 검증\n", + "\n", + "\n", + "#### 채점자 판정 메모\n", + "\n", + "> 단순 코드 실행 여부뿐만 아니라 출력된 수치와 근거 자료가 군 실무 요구사항에 부합하는지 면밀히 확인한다.\n", + "\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "\"\"\"변경 이벤트 스트림(CDC 방식 모의)을 순서 뒤바뀜·중복 이벤트까지 처리해 통합 저장소에 자동 동기화하고, 체계 간 불일치의 원인을 분류해 마스터 값 후보를 추천하는 모듈을 구현해 분류·추천 정확도를 측정한다.\"\"\"\n", + "import collections\n", + "import datetime\n", + "import numpy as np\n", + "import pandas as pd\n", + "\n", + "# ---------------------------------------------------------------------------\n", + "# [교육용가상자료] 가상 이기종 군수정보체계 3종(A 보급, B 정비, C 탄약)의 재고 보고 생성기\n", + "# 실제 체계명·부대·품목·수량 없음. 같은 (품목, 부대) 재고를 세 체계가 서로 다른 코드·날짜 형식으로 보고한다.\n", + "# 기준 날짜: 2026-09-30(가상). 마스터(정답) 재고 TRUE 와 체계별 보고에는 의도적 불일치·결측이 섞여 있다.\n", + "# ---------------------------------------------------------------------------\n", + "N_PART, N_UNIT = 60, 12\n", + "CATS = [\"급식\", \"피복\", \"유류\", \"정비소모\", \"수리부속\", \"탄약\"]\n", + "PARTS = {p: {\"name\": f\"가상품목{p:02d}\", \"cat\": CATS[(p - 1) % 6]} for p in range(1, N_PART + 1)}\n", + "FMT = {\"A\": dict(part=lambda p: f\"AP-{p:05d}\", unit=lambda u: f\"U{u:02d}\", date=lambda d: d.strftime(\"%Y%m%d\")),\n", + " \"B\": dict(part=lambda p: f\"{p:05d}-X\", unit=lambda u: f\"가상{u:02d}대대\", date=lambda d: d.strftime(\"%Y-%m-%d\")),\n", + " \"C\": dict(part=lambda p: f\"P{p}\", unit=lambda u: str(u), date=lambda d: d.strftime(\"%d/%m/%Y\"))}\n", + "BASE_DATE = datetime.date(2026, 9, 30)\n", + "\n", + "\n", + "def make_systems(seed=9, mismatch=0.10, missing=0.03):\n", + " \"\"\"TRUE(정답 재고)와 체계별 원본 보고 레코드(체계 고유 형식)를 만든다.\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " true = {(p, u): int(rng.integers(20, 500)) for p in PARTS for u in range(1, N_UNIT + 1)}\n", + " out = {s: [] for s in \"ABC\"}\n", + " truth_diff = {} # (체계, p, u) -> 불일치 원인(정답)\n", + " for (p, u), q in true.items():\n", + " for s in \"ABC\":\n", + " qty, cause = q, None\n", + " r = rng.random()\n", + " if r < mismatch / 3: # 입력 오류(자릿수/오타)\n", + " qty, cause = q + int(rng.choice([-1, 1]) * rng.integers(10, 60)), \"입력 오류\"\n", + " elif r < 2 * mismatch / 3: # 반영 지연(전일 값)\n", + " qty, cause = q + int(rng.integers(5, 40)), \"반영 지연\"\n", + " elif r < mismatch: # 단위 오류(10배)\n", + " qty, cause = q * 10, \"단위 오류\"\n", + " d = BASE_DATE - datetime.timedelta(days=int(cause == \"반영 지연\"))\n", + " if rng.random() < missing: # 결측\n", + " qty, cause = None, \"결측\"\n", + " if cause:\n", + " truth_diff[(s, p, u)] = cause\n", + " out[s].append({\"part\": FMT[s][\"part\"](p), \"unit\": FMT[s][\"unit\"](u), \"date\": FMT[s][\"date\"](d), \"qty\": qty})\n", + " return true, out, truth_diff\n", + "\n", + "\n", + "def parse_part(code):\n", + " return int(code[3:]) if code.startswith(\"AP-\") else int(code[:-2]) if code.endswith(\"-X\") else int(code[1:])\n", + "\n", + "\n", + "def parse_unit(code):\n", + " return int(code[1:]) if code.startswith(\"U\") else int(code[2:4]) if code.startswith(\"가상\") else int(code)\n", + "\n", + "\n", + "def parse_date(s):\n", + " if \"/\" in s:\n", + " d, m, y = s.split(\"/\"); return datetime.date(int(y), int(m), int(d))\n", + " if \"-\" in s:\n", + " return datetime.date(*map(int, s.split(\"-\")))\n", + " return datetime.date(int(s[:4]), int(s[4:6]), int(s[6:]))\n", + "\n", + "true, raw, tdiff = make_systems()\n", + "rng = np.random.default_rng(4)\n", + "\n", + "# 1) 이벤트 스트림 생성: 각 체계 보고를 (체계, 키, 값, 시각, 이벤트ID) 이벤트로 바꾸고 중복·순서 뒤바뀜을 주입\n", + "events = []\n", + "for s in \"ABC\":\n", + " for i, r in enumerate(raw[s]):\n", + " events.append({\"id\": f\"{s}{i:04d}\", \"sys\": s, \"p\": parse_part(r[\"part\"]), \"u\": parse_unit(r[\"unit\"]), \"qty\": r[\"qty\"],\n", + " \"ts\": int(rng.integers(0, 10000)), \"day\": parse_date(r[\"date\"])})\n", + "dups = [dict(events[i]) for i in rng.choice(len(events), 120, replace=False)] # 중복 전송 120건\n", + "stale = [] # 같은 키의 오래된 값(늦게 도착한 옛 이벤트) 80건\n", + "for i in rng.choice(len(events), 80, replace=False):\n", + " e = dict(events[i]); e[\"id\"] += \"-old\"; e[\"ts\"] = e[\"ts\"] - 5000; e[\"qty\"] = (e[\"qty\"] or 0) + 77\n", + " stale.append(e)\n", + "stream = events + dups + stale\n", + "rng.shuffle(stream) # 도착 순서 뒤섞기\n", + "print(f\"[스트림] 이벤트 {len(stream)}건 (정상 {len(events)}, 중복 {len(dups)}, 오래된 값 {len(stale)}), 도착 순서 무작위\")\n", + "\n", + "# 2) 자동 동기화: 이벤트 ID 중복 제거 + 키별 최신 시각 우선(last-write-wins)\n", + "store, seen, applied, skipped_dup, skipped_old = {}, set(), 0, 0, 0\n", + "queue = collections.deque(stream)\n", + "while queue:\n", + " e = queue.popleft()\n", + " if e[\"id\"] in seen:\n", + " skipped_dup += 1; continue\n", + " seen.add(e[\"id\"])\n", + " k = (e[\"sys\"], e[\"p\"], e[\"u\"])\n", + " if k in store and store[k][\"ts\"] >= e[\"ts\"]:\n", + " skipped_old += 1; continue\n", + " store[k] = {\"qty\": e[\"qty\"], \"ts\": e[\"ts\"], \"day\": e[\"day\"]}; applied += 1\n", + "exp = {(e[\"sys\"], e[\"p\"], e[\"u\"]): e[\"qty\"] for e in events}\n", + "match = sum(store[k][\"qty\"] == v for k, v in exp.items())\n", + "print(f\"동기화: 적용 {applied}건, 중복 건너뜀 {skipped_dup}건, 오래된 값 건너뜀 {skipped_old}건\")\n", + "print(f\"일괄 스냅샷 대비 동기화 결과 일치 {match}/{len(exp)}건({match / len(exp):.1%}) -> 스트림 방식과 일괄 방식의 결과가 같아야 한다\")\n", + "\n", + "# 3) 불일치 감지·원인 분류·마스터 후보 추천\n", + "rows = []\n", + "for p in PARTS:\n", + " for u in range(1, N_UNIT + 1):\n", + " vals = {s: store[(s, p, u)] for s in \"ABC\"}\n", + " q = {s: v[\"qty\"] for s, v in vals.items()}\n", + " have = {s: x for s, x in q.items() if x is not None}\n", + " cnt = collections.Counter(have.values())\n", + " top, n_top = cnt.most_common(1)[0] if cnt else (None, 0)\n", + " for s in \"ABC\":\n", + " if q[s] == top and n_top >= 2:\n", + " continue\n", + " if q[s] is None:\n", + " cause = \"결측\"\n", + " elif n_top >= 2 and q[s] != top:\n", + " lag = (BASE_DATE - vals[s][\"day\"]).days\n", + " cause = \"단위 오류\" if q[s] == top * 10 else (\"반영 지연\" if lag >= 1 else \"입력 오류\")\n", + " else:\n", + " cause = \"판단 불가\" # 세 값이 모두 다르면 사람이 판단\n", + " rows.append({\"sys\": s, \"p\": p, \"u\": u, \"cause\": cause, \"recommend\": top if n_top >= 2 else None, \"true\": true[(p, u)]})\n", + "d = pd.DataFrame(rows)\n", + "d[\"truth_cause\"] = [tdiff.get((r.sys, r.p, r.u)) for r in d.itertuples()]\n", + "auto = d[d[\"cause\"] != \"판단 불가\"]\n", + "cause_acc = float((auto[\"cause\"] == auto[\"truth_cause\"]).mean())\n", + "rec_ok = float((auto[\"recommend\"] == auto[\"true\"]).mean())\n", + "true_cells = pd.Series(tdiff)\n", + "print(f\"\\n[불일치 분류] 불일치 셀 {len(d)}건 (정답상 불일치 {len(true_cells)}건), 자동 분류 {len(auto)}건, 사람 판단 필요 {len(d) - len(auto)}건\")\n", + "print(pd.crosstab(auto[\"truth_cause\"].fillna(\"정상(오탐)\"), auto[\"cause\"]).to_string())\n", + "print(f\"원인 분류 정확도 {cause_acc:.1%}, 마스터 값 후보(다수결) 정확도 {rec_ok:.1%}, 감지 재현율 {len(set(zip(d.sys, d.p, d.u)) & set(true_cells.index)) / len(true_cells):.1%}\")\n", + "print(\"추천 사유 예시:\")\n", + "for r in auto.head(3).itertuples():\n", + " print(f\" - 체계 {r.sys} 품목 {r.p} 부대 {r.u}: 원인 '{r.cause}', 다른 두 체계 다수값 {r.recommend}을(를) 마스터 후보로 제시\")\n", + "print(\"추천은 후보이며 마스터 데이터 확정은 데이터관리관 승인(미션 7)으로만 이루어진다.\")\n", + "print(f\"\\n목표(추천/분류 정확도 85% 이상): 분류 {'달성' if cause_acc >= 0.85 else '미달'}, 마스터 후보 {'달성' if rec_ok >= 0.85 else '미달'}\")\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 04 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + "[스트림] 이벤트 2360건 (정상 2160, 중복 120, 오래된 값 80), 도착 순서 무작위\n", + "동기화: 적용 2200건, 중복 건너뜀 120건, 오래된 값 건너뜀 40건\n", + "일괄 스냅샷 대비 동기화 결과 일치 2160/2160건(100.0%) -> 스트림 방식과 일괄 방식의 결과가 같아야 한다\n", + "\n", + "[불일치 분류] 불일치 셀 261건 (정답상 불일치 243건), 자동 분류 207건, 사람 판단 필요 54건\n", + "cause 결측 단위 오류 반영 지연 입력 오류\n", + "truth_cause \n", + "결측 46 0 0 0\n", + "단위 오류 0 57 0 0\n", + "반영 지연 0 0 53 0\n", + "입력 오류 0 0 0 49\n", + "정상(오탐) 0 0 0 2\n", + "원인 분류 정확도 99.0%, 마스터 값 후보(다수결) 정확도 94.7%, 감지 재현율 98.4%\n", + "추천 사유 예시:\n", + " - 체계 A 품목 1 부대 3: 원인 '입력 오류', 다른 두 체계 다수값 481.0을(를) 마스터 후보로 제시\n", + " - 체계 B 품목 1 부대 8: 원인 '반영 지연', 다른 두 체계 다수값 393.0을(를) 마스터 후보로 제시\n", + " - 체계 C 품목 1 부대 10: 원인 '반영 지연', 다른 두 체계 다수값 363.0을(를) 마스터 후보로 제시\n", + "추천은 후보이며 마스터 데이터 확정은 데이터관리관 승인(미션 7)으로만 이루어진다.\n", + "\n", + "목표(추천/분류 정확도 85% 이상): 분류 달성, 마스터 후보 달성\n", + "```\n", + "
\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 05. 체계 간 불일치 데이터 자동 식별 및 데이터 신뢰도 품질 지수 산출 엔진 개발\n", + "**소요시간**: 약 3.0시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "체계 간 불일치 데이터 자동 식별 및 품질 신뢰도 지수 산출 엔진을 개발한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **체계 간 불일치 데이터를 자동 식별하고 데이터 품질 지수를 산출하는 엔진을 입력-분석-검토-결과 4개 화면으로 구현한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **키별로 세 체계의 값을 비교해 값 불일치와 결측을 식별하고 편차가 큰 건을 우선 검토 대상으로 표시 수행하고 검증하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `src/ui/dat09_app.py` 형태의 결과물을 완성한다.\n", + "\n", + "### 🔍 평가 및 검증 기준 (PASS 조건)\n", + "#### 미션 판정\n", + "\n", + "**평가 구분:** 필수\n", + "\n", + "**핵심 평가영역:** 군 실무자 친화적 디자인 및 조작 편의성\n", + "\n", + "#### 미션 PASS 조건\n", + "\n", + "- 정량 기준: 핵심 화면 4종(입력-분석-검토-결과) 구현 완료\n", + "\n", + "\n", + "- 진행 기준: UI 프로토타입 로컬 서버 구동 및 전체 페이지 네비게이션 확인\n", + "\n", + "\n", + "- 검증 방법: 사용성 평가(UX Walkthrough) 및 인터랙션 테스트\n", + "\n", + "\n", + "#### 채점자 판정 메모\n", + "\n", + "> 단순 코드 실행 여부뿐만 아니라 출력된 수치와 근거 자료가 군 실무 요구사항에 부합하는지 면밀히 확인한다.\n", + "\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "\"\"\"세 체계 간 불일치 데이터를 자동 식별하고 완전성·일치성·적시성·유효성 4개 차원의 데이터 품질 지수를 부대별·체계별로 산출하며, 입력-분석-검토-결과 4개 화면을 텍스트 화면으로 구현한다.\"\"\"\n", + "import datetime\n", + "import numpy as np\n", + "import pandas as pd\n", + "\n", + "# ---------------------------------------------------------------------------\n", + "# [교육용가상자료] 가상 이기종 군수정보체계 3종(A 보급, B 정비, C 탄약)의 재고 보고 생성기\n", + "# 실제 체계명·부대·품목·수량 없음. 같은 (품목, 부대) 재고를 세 체계가 서로 다른 코드·날짜 형식으로 보고한다.\n", + "# 기준 날짜: 2026-09-30(가상). 마스터(정답) 재고 TRUE 와 체계별 보고에는 의도적 불일치·결측이 섞여 있다.\n", + "# ---------------------------------------------------------------------------\n", + "N_PART, N_UNIT = 60, 12\n", + "CATS = [\"급식\", \"피복\", \"유류\", \"정비소모\", \"수리부속\", \"탄약\"]\n", + "PARTS = {p: {\"name\": f\"가상품목{p:02d}\", \"cat\": CATS[(p - 1) % 6]} for p in range(1, N_PART + 1)}\n", + "FMT = {\"A\": dict(part=lambda p: f\"AP-{p:05d}\", unit=lambda u: f\"U{u:02d}\", date=lambda d: d.strftime(\"%Y%m%d\")),\n", + " \"B\": dict(part=lambda p: f\"{p:05d}-X\", unit=lambda u: f\"가상{u:02d}대대\", date=lambda d: d.strftime(\"%Y-%m-%d\")),\n", + " \"C\": dict(part=lambda p: f\"P{p}\", unit=lambda u: str(u), date=lambda d: d.strftime(\"%d/%m/%Y\"))}\n", + "BASE_DATE = datetime.date(2026, 9, 30)\n", + "\n", + "\n", + "def make_systems(seed=9, mismatch=0.10, missing=0.03):\n", + " \"\"\"TRUE(정답 재고)와 체계별 원본 보고 레코드(체계 고유 형식)를 만든다.\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " true = {(p, u): int(rng.integers(20, 500)) for p in PARTS for u in range(1, N_UNIT + 1)}\n", + " out = {s: [] for s in \"ABC\"}\n", + " truth_diff = {} # (체계, p, u) -> 불일치 원인(정답)\n", + " for (p, u), q in true.items():\n", + " for s in \"ABC\":\n", + " qty, cause = q, None\n", + " r = rng.random()\n", + " if r < mismatch / 3: # 입력 오류(자릿수/오타)\n", + " qty, cause = q + int(rng.choice([-1, 1]) * rng.integers(10, 60)), \"입력 오류\"\n", + " elif r < 2 * mismatch / 3: # 반영 지연(전일 값)\n", + " qty, cause = q + int(rng.integers(5, 40)), \"반영 지연\"\n", + " elif r < mismatch: # 단위 오류(10배)\n", + " qty, cause = q * 10, \"단위 오류\"\n", + " d = BASE_DATE - datetime.timedelta(days=int(cause == \"반영 지연\"))\n", + " if rng.random() < missing: # 결측\n", + " qty, cause = None, \"결측\"\n", + " if cause:\n", + " truth_diff[(s, p, u)] = cause\n", + " out[s].append({\"part\": FMT[s][\"part\"](p), \"unit\": FMT[s][\"unit\"](u), \"date\": FMT[s][\"date\"](d), \"qty\": qty})\n", + " return true, out, truth_diff\n", + "\n", + "\n", + "def parse_part(code):\n", + " return int(code[3:]) if code.startswith(\"AP-\") else int(code[:-2]) if code.endswith(\"-X\") else int(code[1:])\n", + "\n", + "\n", + "def parse_unit(code):\n", + " return int(code[1:]) if code.startswith(\"U\") else int(code[2:4]) if code.startswith(\"가상\") else int(code)\n", + "\n", + "\n", + "def parse_date(s):\n", + " if \"/\" in s:\n", + " d, m, y = s.split(\"/\"); return datetime.date(int(y), int(m), int(d))\n", + " if \"-\" in s:\n", + " return datetime.date(*map(int, s.split(\"-\")))\n", + " return datetime.date(int(s[:4]), int(s[4:6]), int(s[6:]))\n", + "\n", + "# ---------- 화면 1: 입력 ----------\n", + "true, raw, tdiff = make_systems()\n", + "rows = [{\"sys\": s, \"p\": parse_part(r[\"part\"]), \"u\": parse_unit(r[\"unit\"]), \"date\": parse_date(r[\"date\"]), \"qty\": r[\"qty\"]} for s in \"ABC\" for r in raw[s]]\n", + "df = pd.DataFrame(rows)\n", + "df[\"qty_f\"] = df[\"qty\"].astype(\"float\")\n", + "print(f\"[화면 1. 입력] 3개 체계 보고 {len(df)}건(체계별 {df['sys'].value_counts().sort_index().to_dict()}) 적재, 표준 키(품목, 부대)로 통합 [교육용가상자료]\")\n", + "\n", + "# ---------- 화면 2: 분석(불일치 식별) ----------\n", + "w = df.pivot_table(index=[\"p\", \"u\"], columns=\"sys\", values=\"qty_f\")\n", + "w[\"n\"] = w[[\"A\", \"B\", \"C\"]].notna().sum(axis=1)\n", + "w[\"spread\"] = w[[\"A\", \"B\", \"C\"]].max(axis=1) - w[[\"A\", \"B\", \"C\"]].min(axis=1)\n", + "w[\"consensus\"] = w[[\"A\", \"B\", \"C\"]].median(axis=1)\n", + "w[\"mismatch\"] = (w[\"spread\"] > 0) | (w[\"n\"] < 3) # 값이 다르거나 어느 체계에 값이 없으면 불일치로 본다\n", + "print(f\"[화면 2. 분석] 키 {len(w)}개 중 불일치 {int(w['mismatch'].sum())}개({w['mismatch'].mean():.1%}): 값 불일치 {int((w['spread'] > 0).sum())}개, 결측 포함 {int((w['n'] < 3).sum())}개\")\n", + "big = w[w[\"spread\"] > 0.2 * w[\"consensus\"]]\n", + "print(f\" 편차 20% 초과(우선 검토 대상) {len(big)}개\")\n", + "\n", + "# ---------- 품질 지수 ----------\n", + "def dims(g):\n", + " comp = g[\"qty_f\"].notna().mean() # 완전성: 값이 있는 비율\n", + " key = g.set_index([\"p\", \"u\"]).index\n", + " cons = (~w.loc[key, \"mismatch\"]).mean() if len(g) else 0 # 일치성: 체계 간 같은 값인 비율\n", + " tim = ((BASE_DATE - g[\"date\"]).map(lambda d: d.days) <= 0).mean() # 적시성: 기준일 당일 보고 비율\n", + " val = ((g[\"qty_f\"].isna()) | ((g[\"qty_f\"] >= 0) & (g[\"qty_f\"] <= 2000))).mean() # 유효성: 허용 범위(0~2000) 안의 비율\n", + " return pd.Series({\"완전성\": comp, \"일치성\": cons, \"적시성\": tim, \"유효성\": val})\n", + "\n", + "\n", + "WT = {\"완전성\": 0.25, \"일치성\": 0.4, \"적시성\": 0.15, \"유효성\": 0.2} # 일치성 비중을 가장 높게\n", + "def index(g):\n", + " d = dims(g)\n", + " return pd.concat([d, pd.Series({\"품질지수\": 100 * sum(d[k] * v for k, v in WT.items())})])\n", + "\n", + "by_sys = pd.DataFrame({k: index(g) for k, g in df.groupby(\"sys\")}).T\n", + "by_unit = pd.DataFrame({k: index(g) for k, g in df.groupby(\"u\")}).T\n", + "\n", + "# ---------- 화면 3: 검토 ----------\n", + "print(\"\\n[화면 3. 검토] 체계별 품질 지수(0~100)\")\n", + "print(by_sys.round(3).to_string())\n", + "low = by_unit.sort_values(\"품질지수\").head(4)\n", + "print(\"품질 지수가 낮은 부대 4곳(우선 점검):\")\n", + "print(low.round(3).to_string())\n", + "print(\"검토자는 불일치 키마다 '마스터 후보 승인 요청 / 정상 확인'을 선택하며, 수정은 체계 담당자가 한다.\")\n", + "\n", + "# ---------- 화면 4: 결과 ----------\n", + "total = float(index(df)[\"품질지수\"])\n", + "grade = \"양호\" if total >= 90 else \"보완 필요\" if total >= 75 else \"미흡\"\n", + "gt_keys = {(p, u) for (_, p, u) in tdiff}\n", + "found = {k for k, m in w[\"mismatch\"].items() if m}\n", + "recall = len(found & gt_keys) / len(gt_keys)\n", + "print(f\"\\n[화면 4. 결과] 통합 데이터 품질 지수 {total:.1f}점({grade}), 불일치 식별 재현율(가상 정답 대비) {recall:.1%}\")\n", + "print(f\" 차원별 가중치: {WT}\")\n", + "ok = recall >= 0.95\n", + "print(f\"목표(핵심 화면 4종 입력-분석-검토-결과 구현, 불일치 자동 식별·품질 지수 산출): {'달성' if ok else '미달'}\")\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 05 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + "[화면 1. 입력] 3개 체계 보고 2160건(체계별 {'A': 720, 'B': 720, 'C': 720}) 적재, 표준 키(품목, 부대)로 통합 [교육용가상자료]\n", + "[화면 2. 분석] 키 720개 중 불일치 219개(30.4%): 값 불일치 179개, 결측 포함 43개\n", + " 편차 20% 초과(우선 검토 대상) 95개\n", + "\n", + "[화면 3. 검토] 체계별 품질 지수(0~100)\n", + " 완전성 일치성 적시성 유효성 품질지수\n", + "A 0.975 0.696 0.962 0.978 86.201\n", + "B 0.981 0.696 0.969 0.979 86.472\n", + "C 0.981 0.696 0.967 0.974 86.319\n", + "품질 지수가 낮은 부대 4곳(우선 점검):\n", + " 완전성 일치성 적시성 유효성 품질지수\n", + "3 0.972 0.583 0.950 0.983 81.556\n", + "5 0.972 0.633 0.983 0.983 84.056\n", + "10 0.978 0.650 0.967 0.961 84.167\n", + "6 0.978 0.667 0.956 0.972 84.889\n", + "검토자는 불일치 키마다 '마스터 후보 승인 요청 / 정상 확인'을 선택하며, 수정은 체계 담당자가 한다.\n", + "\n", + "[화면 4. 결과] 통합 데이터 품질 지수 86.3점(보완 필요), 불일치 식별 재현율(가상 정답 대비) 100.0%\n", + " 차원별 가중치: {'완전성': 0.25, '일치성': 0.4, '적시성': 0.15, '유효성': 0.2}\n", + "목표(핵심 화면 4종 입력-분석-검토-결과 구현, 불일치 자동 식별·품질 지수 산출): 달성\n", + "```\n", + "
\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 06. 지휘관 및 군수참모용 통합 군수 상황판(Logistics COP) 실시간 대시보드 구현\n", + "**소요시간**: 약 2.5시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "지휘관 및 군수참모용 통합 군수 상황판(Logistics COP) 실시간 대시보드를 구현한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **지휘관과 군수참모가 보는 통합 군수 상황판(Logistics COP)을 시간별 갱신 방식으로 구현하고, 예외 상황에서도 중단되지 않는 에러 처리를 갖춘다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **변경 이벤트를 1시간 단위로 반영해 총재고, 분류별 재고, 부족 경보를 갱신하고 지휘관용 요약 화면과 군수참모용 상세 화면을 구분해 보여 준다 과정을 진행하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `src/utils/dat09_validator.py 및 에러 핸들러` 형태의 결과물을 완성한다.\n", + "\n", + "### 🔍 평가 및 검증 기준 (PASS 조건)\n", + "#### 미션 판정\n", + "\n", + "**평가 구분:** 필수\n", + "\n", + "**핵심 평가영역:** 사용자에게 알기 쉬운 에러 메시지 및 조치 가이드 제공\n", + "\n", + "#### 미션 PASS 조건\n", + "\n", + "- 정량 기준: 예외 상황 발생 시 크래시(Crash) 0건, 에러 로그 기록률 100%\n", + "\n", + "\n", + "- 진행 기준: 예외 주입 테스트 10종 통과 및 감사 로그 파일 생성\n", + "\n", + "\n", + "- 검증 방법: 비정상 데이터(결측, 특수문자, 대용량) 스트레스 테스트\n", + "\n", + "\n", + "#### 채점자 판정 메모\n", + "\n", + "> 단순 코드 실행 여부뿐만 아니라 출력된 수치와 근거 자료가 군 실무 요구사항에 부합하는지 면밀히 확인한다.\n", + "\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "\"\"\"변경 이벤트를 1시간 단위로 받아 갱신되는 통합 군수 상황판(재고·부족 경보·데이터 신뢰도)을 지휘관용·군수참모용 역할별 화면과 그래프로 구현하고, 입력 검증기와 에러 핸들러로 예외 10종을 크래시 없이 기록·안내한다.\"\"\"\n", + "import datetime\n", + "import json\n", + "import numpy as np\n", + "import pandas as pd\n", + "import matplotlib.pyplot as plt\n", + "\n", + "# ---------------------------------------------------------------------------\n", + "# [교육용가상자료] 가상 이기종 군수정보체계 3종(A 보급, B 정비, C 탄약)의 재고 보고 생성기\n", + "# 실제 체계명·부대·품목·수량 없음. 같은 (품목, 부대) 재고를 세 체계가 서로 다른 코드·날짜 형식으로 보고한다.\n", + "# 기준 날짜: 2026-09-30(가상). 마스터(정답) 재고 TRUE 와 체계별 보고에는 의도적 불일치·결측이 섞여 있다.\n", + "# ---------------------------------------------------------------------------\n", + "N_PART, N_UNIT = 60, 12\n", + "CATS = [\"급식\", \"피복\", \"유류\", \"정비소모\", \"수리부속\", \"탄약\"]\n", + "PARTS = {p: {\"name\": f\"가상품목{p:02d}\", \"cat\": CATS[(p - 1) % 6]} for p in range(1, N_PART + 1)}\n", + "FMT = {\"A\": dict(part=lambda p: f\"AP-{p:05d}\", unit=lambda u: f\"U{u:02d}\", date=lambda d: d.strftime(\"%Y%m%d\")),\n", + " \"B\": dict(part=lambda p: f\"{p:05d}-X\", unit=lambda u: f\"가상{u:02d}대대\", date=lambda d: d.strftime(\"%Y-%m-%d\")),\n", + " \"C\": dict(part=lambda p: f\"P{p}\", unit=lambda u: str(u), date=lambda d: d.strftime(\"%d/%m/%Y\"))}\n", + "BASE_DATE = datetime.date(2026, 9, 30)\n", + "\n", + "\n", + "def make_systems(seed=9, mismatch=0.10, missing=0.03):\n", + " \"\"\"TRUE(정답 재고)와 체계별 원본 보고 레코드(체계 고유 형식)를 만든다.\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " true = {(p, u): int(rng.integers(20, 500)) for p in PARTS for u in range(1, N_UNIT + 1)}\n", + " out = {s: [] for s in \"ABC\"}\n", + " truth_diff = {} # (체계, p, u) -> 불일치 원인(정답)\n", + " for (p, u), q in true.items():\n", + " for s in \"ABC\":\n", + " qty, cause = q, None\n", + " r = rng.random()\n", + " if r < mismatch / 3: # 입력 오류(자릿수/오타)\n", + " qty, cause = q + int(rng.choice([-1, 1]) * rng.integers(10, 60)), \"입력 오류\"\n", + " elif r < 2 * mismatch / 3: # 반영 지연(전일 값)\n", + " qty, cause = q + int(rng.integers(5, 40)), \"반영 지연\"\n", + " elif r < mismatch: # 단위 오류(10배)\n", + " qty, cause = q * 10, \"단위 오류\"\n", + " d = BASE_DATE - datetime.timedelta(days=int(cause == \"반영 지연\"))\n", + " if rng.random() < missing: # 결측\n", + " qty, cause = None, \"결측\"\n", + " if cause:\n", + " truth_diff[(s, p, u)] = cause\n", + " out[s].append({\"part\": FMT[s][\"part\"](p), \"unit\": FMT[s][\"unit\"](u), \"date\": FMT[s][\"date\"](d), \"qty\": qty})\n", + " return true, out, truth_diff\n", + "\n", + "\n", + "def parse_part(code):\n", + " return int(code[3:]) if code.startswith(\"AP-\") else int(code[:-2]) if code.endswith(\"-X\") else int(code[1:])\n", + "\n", + "\n", + "def parse_unit(code):\n", + " return int(code[1:]) if code.startswith(\"U\") else int(code[2:4]) if code.startswith(\"가상\") else int(code)\n", + "\n", + "\n", + "def parse_date(s):\n", + " if \"/\" in s:\n", + " d, m, y = s.split(\"/\"); return datetime.date(int(y), int(m), int(d))\n", + " if \"-\" in s:\n", + " return datetime.date(*map(int, s.split(\"-\")))\n", + " return datetime.date(int(s[:4]), int(s[4:6]), int(s[6:]))\n", + "\n", + "true, raw, tdiff = make_systems()\n", + "rng = np.random.default_rng(6)\n", + "STATE = {(p, u): true[(p, u)] for p in PARTS for u in range(1, N_UNIT + 1)} # 상황판의 현재 재고(체계 A 기준 시작)\n", + "SEEN, LOG, HIST = set(), [], []\n", + "LOW = 40 # 부족 경보 기준(가상)\n", + "\n", + "\n", + "class AppError(Exception):\n", + " def __init__(self, code, msg, guide):\n", + " super().__init__(msg); self.code, self.msg, self.guide = code, msg, guide\n", + "\n", + "\n", + "def validate(ev):\n", + " for k in (\"id\", \"sys\", \"p\", \"u\", \"delta\"):\n", + " if k not in ev or ev[k] is None:\n", + " raise AppError(\"E01\", f\"필수 항목 '{k}' 누락\", f\"이벤트에 '{k}' 값을 넣어 다시 전송\")\n", + " if ev[\"id\"] in SEEN:\n", + " raise AppError(\"E02\", f\"중복 이벤트 {ev['id']}\", \"이미 반영된 이벤트이므로 무시해도 됨\")\n", + " if ev[\"sys\"] not in (\"A\", \"B\", \"C\"):\n", + " raise AppError(\"E03\", f\"알 수 없는 체계 '{ev['sys']}'\", \"연동 체계(A·B·C)만 허용\")\n", + " try:\n", + " p, u, dl = int(ev[\"p\"]), int(ev[\"u\"]), float(ev[\"delta\"])\n", + " except (TypeError, ValueError):\n", + " raise AppError(\"E04\", \"숫자 형식 오류\", \"품목·부대·증감량은 숫자만 입력\")\n", + " if not np.isfinite(dl):\n", + " raise AppError(\"E05\", \"증감량이 NaN 또는 무한대\", \"정상 숫자로 수정\")\n", + " if (p, u) not in STATE:\n", + " raise AppError(\"E06\", f\"존재하지 않는 키(품목 {p}, 부대 {u})\", \"코드 변환표에서 키 확인\")\n", + " if abs(dl) > 5000:\n", + " raise AppError(\"E09\", f\"증감량 {dl:+.0f}이 허용 범위(±5000) 초과\", \"자릿수 오류 여부를 출처 체계에 확인\")\n", + " if STATE[(p, u)] + dl < 0:\n", + " raise AppError(\"E07\", f\"재고가 음수가 됨({STATE[(p, u)]} + {dl:+.0f})\", \"출처 체계에 증감량 재확인 요청\")\n", + " return p, u, dl\n", + "\n", + "\n", + "def apply(ev):\n", + " p, u, dl = validate(ev)\n", + " STATE[(p, u)] += int(dl); SEEN.add(ev[\"id\"])\n", + "\n", + "\n", + "def safe(fn, *a):\n", + " \"\"\"에러 핸들러: 모든 예외를 로그로 남기고 사용자에게 원인과 조치를 안내한다(크래시 없음).\"\"\"\n", + " try:\n", + " r = fn(*a); LOG.append({\"level\": \"INFO\", \"code\": \"OK\"}); return r\n", + " except AppError as e:\n", + " LOG.append({\"level\": \"ERROR\", \"code\": e.code, \"msg\": e.msg, \"guide\": e.guide})\n", + " except Exception as e:\n", + " LOG.append({\"level\": \"ERROR\", \"code\": \"E99\", \"msg\": repr(e)[:50], \"guide\": \"운영 담당자에게 로그 전달\"})\n", + "\n", + "\n", + "def view(role):\n", + " \"\"\"역할별 화면: 지휘관은 요약, 군수참모는 분류·부대 상세.\"\"\"\n", + " if role not in (\"지휘관\", \"군수참모\"):\n", + " raise AppError(\"E08\", f\"권한 없는 역할 '{role}'\", \"지휘관 또는 군수참모 계정으로 접속\")\n", + " s = pd.Series(STATE)\n", + " total, low = int(s.sum()), int((s < LOW).sum())\n", + " if role == \"지휘관\":\n", + " return f\"총재고 {total}, 부족 경보 {low}건\"\n", + " cat = {c: int(sum(v for (p, u), v in STATE.items() if PARTS[p][\"cat\"] == c)) for c in CATS}\n", + " worst = s.groupby(level=1).apply(lambda g: int((g < LOW).sum())).sort_values(ascending=False).head(3)\n", + " return f\"총재고 {total}, 분류별 {cat}, 부족 경보 {low}건, 경보 많은 부대 {worst.to_dict()}\"\n", + "\n", + "\n", + "# 1) 정상 운영: 6시간 동안 시간당 이벤트 80건(불출 -, 보급 +) 반영\n", + "print(\"[통합 군수 상황판 - 시간별 갱신] [교육용가상자료]\")\n", + "for hour in range(6):\n", + " for i in range(80):\n", + " p, u = int(rng.integers(1, N_PART + 1)), int(rng.integers(1, N_UNIT + 1))\n", + " d = int(rng.integers(-30, 25))\n", + " safe(apply, {\"id\": f\"EV{hour}-{i}\", \"sys\": \"ABC\"[i % 3], \"p\": p, \"u\": u, \"delta\": d})\n", + " s = pd.Series(STATE)\n", + " cat_tot = {c: sum(v for (p, u), v in STATE.items() if PARTS[p][\"cat\"] == c) for c in CATS}\n", + " HIST.append({\"hour\": hour + 8, \"total\": int(s.sum()), \"low\": int((s < LOW).sum()), **cat_tot})\n", + " print(f\" {hour + 8:02d}:00 지휘관 -> {view('지휘관')}\")\n", + "rej = [l for l in LOG if l[\"level\"] == \"ERROR\"]\n", + "print(f\"정상 운영 중 거부된 이벤트 {len(rej)}건(재고 음수 보호 등):\", sorted({l['code'] for l in rej}))\n", + "print(\"군수참모 화면:\", view(\"군수참모\"))\n", + "\n", + "# 2) 예외 주입 10종\n", + "LOG.clear()\n", + "base = {\"id\": \"T-OK\", \"sys\": \"A\", \"p\": 1, \"u\": 1, \"delta\": 1}\n", + "safe(apply, base) # 먼저 정상 반영해 두어야 같은 번호 재전송이 중복이 된다(시험 대상 아님)\n", + "LOG.clear()\n", + "cases = [(\"필수 항목 누락\", {\"id\": \"T1\", \"sys\": \"A\", \"p\": 1, \"u\": 1}), (\"중복 이벤트\", base),\n", + " (\"알 수 없는 체계\", {**base, \"id\": \"T3\", \"sys\": \"Z\"}), (\"숫자 아님\", {**base, \"id\": \"T4\", \"delta\": \"10개\"}),\n", + " (\"NaN 값\", {**base, \"id\": \"T5\", \"delta\": float(\"nan\")}), (\"없는 키\", {**base, \"id\": \"T6\", \"p\": 999}),\n", + " (\"증감량 과대\", {**base, \"id\": \"T7\", \"delta\": -99999}), (\"재고 음수화\", {**base, \"id\": \"T8\", \"delta\": -4000}),\n", + " (\"권한 없는 역할\", \"병사\"), (\"빈 이벤트\", {})]\n", + "crash = 0\n", + "for n, c in cases:\n", + " try:\n", + " safe(view, c) if isinstance(c, str) else safe(apply, c)\n", + " except BaseException:\n", + " crash += 1\n", + "errs = [l for l in LOG if l[\"level\"] == \"ERROR\"]\n", + "print(\"\\n[예외 주입 결과 - 사용자 안내 메시지]\")\n", + "for (n, c), l in zip(cases, errs):\n", + " print(f\" {l['code']} {n:<14} {l['msg']:<32} -> 조치: {l['guide']}\")\n", + "exp_errors = len(cases)\n", + "rate = len(errs) / exp_errors\n", + "print(f\"예외 {exp_errors}종 중 로그 기록 {len(errs)}건(기록률 {rate:.0%}), 크래시 {crash}건\")\n", + "print(\"로그 예시:\", json.dumps(errs[0], ensure_ascii=False))\n", + "print(f\"목표(크래시 0건, 에러 로그 기록률 100%): {'달성' if crash == 0 and rate == 1 else '미달'}\")\n", + "\n", + "h = pd.DataFrame(HIST)\n", + "fig, ax = plt.subplots(2, 2, figsize=(11, 7))\n", + "for c in CATS:\n", + " ax[0, 0].plot(h[\"hour\"], h[c], marker=\"o\", label=f\"cat{CATS.index(c) + 1}\")\n", + "ax[0, 0].legend(fontsize=7, ncol=3); ax[0, 0].set_title(\"Stock by category (hourly)\")\n", + "ax[0, 1].plot(h[\"hour\"], h[\"low\"], color=\"tab:red\", marker=\"s\"); ax[0, 1].set_title(\"Shortage alerts (stock < 40)\")\n", + "s = pd.Series(STATE)\n", + "ax[1, 0].bar(range(1, N_UNIT + 1), s.groupby(level=1).sum().values, color=\"tab:blue\"); ax[1, 0].set_title(\"Total stock by unit (latest)\")\n", + "ax[1, 1].hist(s.values, bins=20, color=\"tab:gray\"); ax[1, 1].axvline(LOW, color=\"red\", ls=\"--\"); ax[1, 1].set_title(\"Stock distribution (red = alert line)\")\n", + "plt.suptitle(\"DAT09 logistics COP (synthetic)\"); plt.tight_layout(); plt.show()\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 06 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + "[통합 군수 상황판 - 시간별 갱신] [교육용가상자료]\n", + " 08:00 지휘관 -> 총재고 188532, 부족 경보 27건\n", + " 09:00 지휘관 -> 총재고 188232, 부족 경보 28건\n", + " 10:00 지휘관 -> 총재고 188292, 부족 경보 26건\n", + " 11:00 지휘관 -> 총재고 187942, 부족 경보 29건\n", + " 12:00 지휘관 -> 총재고 187663, 부족 경보 27건\n", + " 13:00 지휘관 -> 총재고 187444, 부족 경보 28건\n", + "정상 운영 중 거부된 이벤트 0건(재고 음수 보호 등): []\n", + "군수참모 화면: 총재고 187444, 분류별 {'급식': 30500, '피복': 34149, '유류': 30600, '정비소모': 30650, '수리부속': 31493, '탄약': 30052}, 부족 경보 28건, 경보 많은 부대 {1: 4, 3: 4, 8: 4}\n", + "\n", + "[예외 주입 결과 - 사용자 안내 메시지]\n", + " E01 필수 항목 누락 필수 항목 'delta' 누락 -> 조치: 이벤트에 'delta' 값을 넣어 다시 전송\n", + " E02 중복 이벤트 중복 이벤트 T-OK -> 조치: 이미 반영된 이벤트이므로 무시해도 됨\n", + " E03 알 수 없는 체계 알 수 없는 체계 'Z' -> 조치: 연동 체계(A·B·C)만 허용\n", + " E04 숫자 아님 숫자 형식 오류 -> 조치: 품목·부대·증감량은 숫자만 입력\n", + " E05 NaN 값 증감량이 NaN 또는 무한대 -> 조치: 정상 숫자로 수정\n", + " E06 없는 키 존재하지 않는 키(품목 999, 부대 1) -> 조치: 코드 변환표에서 키 확인\n", + " E09 증감량 과대 증감량 -99999이 허용 범위(±5000) 초과 -> 조치: 자릿수 오류 여부를 출처 체계에 확인\n", + " E07 재고 음수화 재고가 음수가 됨(216 + -4000) -> 조치: 출처 체계에 증감량 재확인 요청\n", + " E08 권한 없는 역할 권한 없는 역할 '병사' -> 조치: 지휘관 또는 군수참모 계정으로 접속\n", + " E01 빈 이벤트 필수 항목 'id' 누락 -> 조치: 이벤트에 'id' 값을 넣어 다시 전송\n", + "예외 10종 중 로그 기록 10건(기록률 100%), 크래시 0건\n", + "로그 예시: {\"level\": \"ERROR\", \"code\": \"E01\", \"msg\": \"필수 항목 'delta' 누락\", \"guide\": \"이벤트에 'delta' 값을 넣어 다시 전송\"}\n", + "목표(크래시 0건, 에러 로그 기록률 100%): 달성\n", + "```\n", + "
\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 07. 체계 간 데이터 충돌 시 마스터 데이터 확정을 위한 데이터관리관 HITL 승인 설계\n", + "**소요시간**: 약 3.0시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "데이터 충돌 시 마스터 데이터 확정을 위한 데이터관리관 HITL 승인을 설계한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **체계 간 데이터 충돌 시 시스템은 후보만 제안하고 데이터관리관 승인으로만 마스터 데이터를 확정하는 사람 승인 체계를 설계한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **충돌 건마다 다수값 후보와 근거를 제안으로 등록하고, 데이터관리관이 근거를 적어 승인하거나 반려하게 수행하고 검증하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `src/workflow/dat09_approval_system.py` 형태의 결과물을 완성한다.\n", + "\n", + "### 🔍 평가 및 검증 기준 (PASS 조건)\n", + "#### 미션 판정\n", + "\n", + "**평가 구분:** 필수\n", + "\n", + "**핵심 평가영역:** 군 보안 규정 및 행정 절차 부합성\n", + "\n", + "#### 미션 PASS 조건\n", + "\n", + "- 정량 기준: 이력 기록 무결성 100%, 승인 권한 우회 시도 차단율 100%\n", + "\n", + "\n", + "- 진행 기준: 결재선 승인/반려 시뮬레이션 및 이력 조회 화면 구현\n", + "\n", + "\n", + "- 검증 방법: 권한별 접근 제어(RBAC) 테스트\n", + "\n", + "\n", + "#### 채점자 판정 메모\n", + "\n", + "> 단순 코드 실행 여부뿐만 아니라 출력된 수치와 근거 자료가 군 실무 요구사항에 부합하는지 면밀히 확인한다.\n", + "\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "\"\"\"체계 간 수량 충돌이 생기면 시스템이 마스터 값 후보와 근거를 제안하고 데이터관리관이 승인해야만 마스터를 확정하는 사람 승인 체계를 설계하며(고영향 건은 군수참모 추가 확인), 해시 체인 감사 이력의 무결성과 권한 우회 차단을 검증한다.\"\"\"\n", + "import collections\n", + "import datetime\n", + "import hashlib\n", + "import json\n", + "import numpy as np\n", + "import pandas as pd\n", + "\n", + "# ---------------------------------------------------------------------------\n", + "# [교육용가상자료] 가상 이기종 군수정보체계 3종(A 보급, B 정비, C 탄약)의 재고 보고 생성기\n", + "# 실제 체계명·부대·품목·수량 없음. 같은 (품목, 부대) 재고를 세 체계가 서로 다른 코드·날짜 형식으로 보고한다.\n", + "# 기준 날짜: 2026-09-30(가상). 마스터(정답) 재고 TRUE 와 체계별 보고에는 의도적 불일치·결측이 섞여 있다.\n", + "# ---------------------------------------------------------------------------\n", + "N_PART, N_UNIT = 60, 12\n", + "CATS = [\"급식\", \"피복\", \"유류\", \"정비소모\", \"수리부속\", \"탄약\"]\n", + "PARTS = {p: {\"name\": f\"가상품목{p:02d}\", \"cat\": CATS[(p - 1) % 6]} for p in range(1, N_PART + 1)}\n", + "FMT = {\"A\": dict(part=lambda p: f\"AP-{p:05d}\", unit=lambda u: f\"U{u:02d}\", date=lambda d: d.strftime(\"%Y%m%d\")),\n", + " \"B\": dict(part=lambda p: f\"{p:05d}-X\", unit=lambda u: f\"가상{u:02d}대대\", date=lambda d: d.strftime(\"%Y-%m-%d\")),\n", + " \"C\": dict(part=lambda p: f\"P{p}\", unit=lambda u: str(u), date=lambda d: d.strftime(\"%d/%m/%Y\"))}\n", + "BASE_DATE = datetime.date(2026, 9, 30)\n", + "\n", + "\n", + "def make_systems(seed=9, mismatch=0.10, missing=0.03):\n", + " \"\"\"TRUE(정답 재고)와 체계별 원본 보고 레코드(체계 고유 형식)를 만든다.\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " true = {(p, u): int(rng.integers(20, 500)) for p in PARTS for u in range(1, N_UNIT + 1)}\n", + " out = {s: [] for s in \"ABC\"}\n", + " truth_diff = {} # (체계, p, u) -> 불일치 원인(정답)\n", + " for (p, u), q in true.items():\n", + " for s in \"ABC\":\n", + " qty, cause = q, None\n", + " r = rng.random()\n", + " if r < mismatch / 3: # 입력 오류(자릿수/오타)\n", + " qty, cause = q + int(rng.choice([-1, 1]) * rng.integers(10, 60)), \"입력 오류\"\n", + " elif r < 2 * mismatch / 3: # 반영 지연(전일 값)\n", + " qty, cause = q + int(rng.integers(5, 40)), \"반영 지연\"\n", + " elif r < mismatch: # 단위 오류(10배)\n", + " qty, cause = q * 10, \"단위 오류\"\n", + " d = BASE_DATE - datetime.timedelta(days=int(cause == \"반영 지연\"))\n", + " if rng.random() < missing: # 결측\n", + " qty, cause = None, \"결측\"\n", + " if cause:\n", + " truth_diff[(s, p, u)] = cause\n", + " out[s].append({\"part\": FMT[s][\"part\"](p), \"unit\": FMT[s][\"unit\"](u), \"date\": FMT[s][\"date\"](d), \"qty\": qty})\n", + " return true, out, truth_diff\n", + "\n", + "\n", + "def parse_part(code):\n", + " return int(code[3:]) if code.startswith(\"AP-\") else int(code[:-2]) if code.endswith(\"-X\") else int(code[1:])\n", + "\n", + "\n", + "def parse_unit(code):\n", + " return int(code[1:]) if code.startswith(\"U\") else int(code[2:4]) if code.startswith(\"가상\") else int(code)\n", + "\n", + "\n", + "def parse_date(s):\n", + " if \"/\" in s:\n", + " d, m, y = s.split(\"/\"); return datetime.date(int(y), int(m), int(d))\n", + " if \"-\" in s:\n", + " return datetime.date(*map(int, s.split(\"-\")))\n", + " return datetime.date(int(s[:4]), int(s[4:6]), int(s[6:]))\n", + "\n", + "true, raw, tdiff = make_systems()\n", + "# [교육용가상자료] 가상 직책·인물(가명)\n", + "USERS = {\"sys_bot\": \"시스템\", \"kim\": \"체계담당자\", \"lee\": \"체계담당자\", \"park\": \"데이터관리관\", \"choi\": \"군수참모\", \"han\": \"열람자\"}\n", + "MASTER, PROPOSALS, AUDIT = {}, {}, []\n", + "\n", + "def h(prev, rec):\n", + " return hashlib.sha256((prev + json.dumps(rec, ensure_ascii=False, sort_keys=True, default=str)).encode()).hexdigest()[:16]\n", + "\n", + "def log(key, user, action, result, note=\"\"):\n", + " rec = {\"seq\": len(AUDIT) + 1, \"key\": str(key), \"user\": user, \"role\": USERS.get(user, \"미등록\"), \"action\": action, \"result\": result, \"note\": note}\n", + " rec[\"hash\"] = h(AUDIT[-1][\"hash\"] if AUDIT else \"GENESIS\", {k: v for k, v in rec.items() if k != \"hash\"})\n", + " AUDIT.append(rec)\n", + "\n", + "def verify():\n", + " prev, ok = \"GENESIS\", 0\n", + " for r in AUDIT:\n", + " ok += r[\"hash\"] == h(prev, {k: v for k, v in r.items() if k != \"hash\"}); prev = r[\"hash\"]\n", + " return ok, len(AUDIT)\n", + "\n", + "def block(key, user, action, why):\n", + " log(key, user, action, \"차단\", why); return False\n", + "\n", + "def propose(key, user, cand, evidence, old):\n", + " if USERS.get(user) not in (\"시스템\", \"체계담당자\"):\n", + " return block(key, user, \"제안\", \"제안 권한 없음\")\n", + " high = abs(cand - old) >= 100 or abs(cand - old) / max(old, 1) >= 0.5 # 고영향 기준(가상)\n", + " PROPOSALS[key] = {\"cand\": cand, \"old\": old, \"by\": user, \"high\": high, \"staff_ok\": False, \"status\": \"대기\"}\n", + " log(key, user, \"제안\", \"접수\", f\"후보 {cand}, 근거 {evidence}\"); return True\n", + "\n", + "def staff_confirm(key, user, note):\n", + " p = PROPOSALS.get(key)\n", + " if USERS.get(user) != \"군수참모\" or p is None or not p[\"high\"]:\n", + " return block(key, user, \"참모확인\", \"고영향 건에 대한 군수참모만 가능\")\n", + " if len(note.strip()) < 5:\n", + " return block(key, user, \"참모확인\", \"확인 의견 5자 이상 필요\")\n", + " p[\"staff_ok\"] = True; log(key, user, \"참모확인\", \"완료\", note); return True\n", + "\n", + "def approve(key, user, note):\n", + " p = PROPOSALS.get(key)\n", + " if p is None:\n", + " return block(key, user, \"승인\", \"제안 없는 키\")\n", + " if p[\"status\"] != \"대기\":\n", + " return block(key, user, \"승인\", f\"이미 처리됨({p['status']})\")\n", + " if USERS.get(user) != \"데이터관리관\":\n", + " return block(key, user, \"승인\", f\"권한 없음: 역할 '{USERS.get(user, '미등록')}'은(는) 마스터 확정 불가(자동 확정 금지)\")\n", + " if p[\"by\"] == user:\n", + " return block(key, user, \"승인\", \"제안자 본인 승인 금지\")\n", + " if len(note.strip()) < 5:\n", + " return block(key, user, \"승인\", \"승인 근거 5자 이상 필요\")\n", + " if p[\"high\"] and not p[\"staff_ok\"]:\n", + " return block(key, user, \"승인\", \"고영향 건은 군수참모 확인 선행 필요\")\n", + " MASTER[key] = p[\"cand\"]; p[\"status\"] = \"확정\"; log(key, user, \"승인\", \"확정\", note); return True\n", + "\n", + "def reject(key, user, note):\n", + " p = PROPOSALS.get(key)\n", + " if USERS.get(user) != \"데이터관리관\" or p is None or p[\"status\"] != \"대기\" or len(note.strip()) < 5:\n", + " return block(key, user, \"반려\", \"반려 조건 불충족\")\n", + " p[\"status\"] = \"반려\"; log(key, user, \"반려\", \"반려\", note); return True\n", + "\n", + "# 1) 충돌 키 찾기: 세 체계 값이 서로 다르면 다수값을 후보로 제안\n", + "vals = collections.defaultdict(dict)\n", + "for s in \"ABC\":\n", + " for r in raw[s]:\n", + " vals[(parse_part(r[\"part\"]), parse_unit(r[\"unit\"]))][s] = r[\"qty\"]\n", + "confl = []\n", + "for k, v in vals.items():\n", + " have = [x for x in v.values() if x is not None]\n", + " if len(set(have)) > 1:\n", + " top, n = collections.Counter(have).most_common(1)[0]\n", + " if n >= 2:\n", + " confl.append((k, top, v[\"A\"] if v[\"A\"] is not None else top))\n", + "confl = sorted(confl)[:24]\n", + "print(f\"[충돌 건] 다수값 후보가 있는 충돌 {len(confl)}건을 처리 대상으로 선정 [교육용가상자료]\")\n", + "\n", + "for i, (k, cand, old) in enumerate(confl):\n", + " propose(k, \"sys_bot\" if i % 2 else \"kim\", cand, \"다수 체계 일치\", old)\n", + " if PROPOSALS[k][\"high\"]:\n", + " staff_confirm(k, \"choi\", \"수량 차이가 커서 출처 체계 대조 후 확인\")\n", + " if i == 3:\n", + " reject(k, \"park\", \"후보가 오래된 보고와 같아 보류 요청\"); continue\n", + " approve(k, \"park\", \"체계 간 다수값 및 원인 분류 검토 완료\")\n", + "acc = np.mean([MASTER[k] == true[k] for k in MASTER])\n", + "print(f\"확정 {len(MASTER)}건, 반려 {sum(p['status'] == '반려' for p in PROPOSALS.values())}건, 고영향(군수참모 확인 필요) {sum(p['high'] for p in PROPOSALS.values())}건, 확정 값이 가상 정답과 일치 {acc:.0%}\")\n", + "\n", + "# 2) 권한 우회 시도 10종\n", + "k0 = confl[0][0]\n", + "pend = confl[-1][0]\n", + "PROPOSALS[pend].update(status=\"대기\"); MASTER.pop(pend, None)\n", + "hi = next(k for k, p in PROPOSALS.items() if p[\"high\"]) if any(p[\"high\"] for p in PROPOSALS.values()) else pend\n", + "PROPOSALS[hi].update(status=\"대기\", staff_ok=False); MASTER.pop(hi, None)\n", + "total = blocked = 0\n", + "print(\"\\n[권한 우회 시도 결과]\")\n", + "def attempt(name, fn):\n", + " global total, blocked\n", + " n = len(AUDIT); fn(); r = AUDIT[-1]\n", + " ok = len(AUDIT) == n + 1 and r[\"result\"] == \"차단\"\n", + " total += 1; blocked += ok\n", + " print(f\" - {name}: {r['result']} ({r['note']})\")\n", + "attempt(\"시스템이 자동으로 마스터 확정\", lambda: approve(pend, \"sys_bot\", \"자동 처리 결과를 확정함\"))\n", + "attempt(\"체계담당자가 직접 확정\", lambda: approve(pend, \"lee\", \"제가 직접 확정합니다 확인\"))\n", + "attempt(\"열람자가 확정\", lambda: approve(pend, \"han\", \"열람자 권한으로 확정 시도\"))\n", + "attempt(\"미등록 사용자 확정\", lambda: approve(pend, \"hacker\", \"외부 계정 확정 시도 확인\"))\n", + "attempt(\"승인 근거 없이 확정\", lambda: approve(pend, \"park\", \"\"))\n", + "attempt(\"제안 없는 키 확정\", lambda: approve((99, 99), \"park\", \"존재하지 않는 제안 승인 시도\"))\n", + "attempt(\"고영향 건을 군수참모 확인 없이 확정\", lambda: approve(hi, \"park\", \"참모 확인 단계를 건너뛰고 승인\"))\n", + "attempt(\"제안자 본인(관리관)이 승인\", lambda: (PROPOSALS.update({pend: {**PROPOSALS[pend], \"by\": \"park\"}}), approve(pend, \"park\", \"본인 제안을 본인이 승인\"))[1])\n", + "attempt(\"체계담당자가 참모확인 대행\", lambda: staff_confirm(hi, \"kim\", \"참모 대신 확인했습니다 확인\"))\n", + "PROPOSALS[pend].update(by=\"kim\"); approve(pend, \"park\", \"다수 체계 일치 확인 후 정상 승인\")\n", + "attempt(\"이미 확정된 건 재승인\", lambda: approve(pend, \"park\", \"이미 확정된 건을 다시 승인\"))\n", + "print(f\"우회 시도 {total}건 중 차단 {blocked}건({blocked / total:.0%}), 자동 확정 0건\")\n", + "\n", + "# 3) 감사 이력 무결성 + 위조 시험\n", + "ok, tot = verify()\n", + "print(f\"\\n감사 이력 {tot}건 해시 체인 검증 {ok}/{tot}({ok / tot:.0%})\")\n", + "j = next(i for i, r in enumerate(AUDIT) if r[\"result\"] == \"차단\")\n", + "keep = AUDIT[j][\"result\"]; AUDIT[j][\"result\"] = \"확정\"\n", + "ok2, _ = verify(); AUDIT[j][\"result\"] = keep\n", + "print(f\"위조 시험: {j + 1}번 기록 변조 -> 검증 {ok2}/{tot} (변조 탐지 {'성공' if ok2 < tot else '실패'}), 원복 후 {verify()[0]}/{tot}\")\n", + "df = pd.DataFrame(AUDIT)\n", + "print(\"[행위별 결과 요약]\")\n", + "print(df.groupby([\"action\", \"result\"]).size().to_string())\n", + "print(f\"목표(이력 무결성 100%, 우회 차단율 100%): {'달성' if ok == tot and blocked == total and ok2 < tot else '미달'}\")\n", + "print(\"마스터 확정은 데이터관리관 승인으로만 가능하다. 실제 보안·행정 절차는 부대 규정과 지휘계통에 따른다(학습용 모의).\")\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 07 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + "[충돌 건] 다수값 후보가 있는 충돌 24건을 처리 대상으로 선정 [교육용가상자료]\n", + "확정 23건, 반려 1건, 고영향(군수참모 확인 필요) 4건, 확정 값이 가상 정답과 일치 100%\n", + "\n", + "[권한 우회 시도 결과]\n", + " - 시스템이 자동으로 마스터 확정: 차단 (권한 없음: 역할 '시스템'은(는) 마스터 확정 불가(자동 확정 금지))\n", + " - 체계담당자가 직접 확정: 차단 (권한 없음: 역할 '체계담당자'은(는) 마스터 확정 불가(자동 확정 금지))\n", + " - 열람자가 확정: 차단 (권한 없음: 역할 '열람자'은(는) 마스터 확정 불가(자동 확정 금지))\n", + " - 미등록 사용자 확정: 차단 (권한 없음: 역할 '미등록'은(는) 마스터 확정 불가(자동 확정 금지))\n", + " - 승인 근거 없이 확정: 차단 (승인 근거 5자 이상 필요)\n", + " - 제안 없는 키 확정: 차단 (제안 없는 키)\n", + " - 고영향 건을 군수참모 확인 없이 확정: 차단 (고영향 건은 군수참모 확인 선행 필요)\n", + " - 제안자 본인(관리관)이 승인: 차단 (제안자 본인 승인 금지)\n", + " - 체계담당자가 참모확인 대행: 차단 (고영향 건에 대한 군수참모만 가능)\n", + " - 이미 확정된 건 재승인: 차단 (이미 처리됨(확정))\n", + "우회 시도 10건 중 차단 10건(100%), 자동 확정 0건\n", + "\n", + "감사 이력 63건 해시 체인 검증 63/63(100%)\n", + "위조 시험: 53번 기록 변조 -> 검증 62/63 (변조 탐지 성공), 원복 후 63/63\n", + "[행위별 결과 요약]\n", + "action result\n", + "반려 반려 1\n", + "승인 차단 9\n", + " 확정 24\n", + "제안 접수 24\n", + "참모확인 완료 4\n", + " 차단 1\n", + "목표(이력 무결성 100%, 우회 차단율 100%): 달성\n", + "마스터 확정은 데이터관리관 승인으로만 가능하다. 실제 보안·행정 절차는 부대 규정과 지휘계통에 따른다(학습용 모의).\n", + "```\n", + "
\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 08. 수만 건의 이기종 군수 트랜잭션 동시 유입 시뮬레이션 부하 테스트\n", + "**소요시간**: 약 3.5시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "수만 건의 이기종 군수 트랜잭션 동시 유입 부하 테스트를 수행한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **세 체계에서 수만 건의 트랜잭션이 뒤섞여 유입되는 상황을 모의해 부하 테스트를 수행한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **체계별 코드 형식이 다른 트랜잭션 5천~5만 건을 만들고 중복 1%, 형식 오류 0.4%, 일시 장애 주입하여 처리 지연 및 복원력을 시험하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `src/pipeline/dat09_batch_runner.py` 형태의 결과물을 완성한다.\n", + "\n", + "### 🔍 평가 및 검증 기준 (PASS 조건)\n", + "#### 미션 판정\n", + "\n", + "**평가 구분:** 필수\n", + "\n", + "**핵심 평가영역:** 수작업 대비 업무 소요 시간 80% 이상 절감 효과\n", + "\n", + "#### 미션 PASS 조건\n", + "\n", + "- 정량 기준: 전체 배치 파이프라인 실행 시간 5분 이내, 자동화 성공률 99% 이상\n", + "\n", + "\n", + "- 진행 기준: 스케줄러 연동 테스트 및 자동 실행 확인\n", + "\n", + "\n", + "- 검증 방법: 실제 월간 업무 데이터 1년치 일괄 배치 시뮬레이션\n", + "\n", + "\n", + "#### 채점자 판정 메모\n", + "\n", + "> 단순 코드 실행 여부뿐만 아니라 출력된 수치와 근거 자료가 군 실무 요구사항에 부합하는지 면밀히 확인한다.\n", + "\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "\"\"\"세 체계에서 수만 건의 재고 변동 트랜잭션이 뒤섞여 유입되는 상황을 모의해 표준화·중복 제거·오류 격리·마이크로 배치 반영을 수행하고, 규모별 처리 시간과 자동화 성공률, 수작업 대비 절감률을 측정하는 부하 테스트를 실행한다.\"\"\"\n", + "import datetime\n", + "import time\n", + "import numpy as np\n", + "import pandas as pd\n", + "\n", + "# ---------------------------------------------------------------------------\n", + "# [교육용가상자료] 가상 이기종 군수정보체계 3종(A 보급, B 정비, C 탄약)의 재고 보고 생성기\n", + "# 실제 체계명·부대·품목·수량 없음. 같은 (품목, 부대) 재고를 세 체계가 서로 다른 코드·날짜 형식으로 보고한다.\n", + "# 기준 날짜: 2026-09-30(가상). 마스터(정답) 재고 TRUE 와 체계별 보고에는 의도적 불일치·결측이 섞여 있다.\n", + "# ---------------------------------------------------------------------------\n", + "N_PART, N_UNIT = 60, 12\n", + "CATS = [\"급식\", \"피복\", \"유류\", \"정비소모\", \"수리부속\", \"탄약\"]\n", + "PARTS = {p: {\"name\": f\"가상품목{p:02d}\", \"cat\": CATS[(p - 1) % 6]} for p in range(1, N_PART + 1)}\n", + "FMT = {\"A\": dict(part=lambda p: f\"AP-{p:05d}\", unit=lambda u: f\"U{u:02d}\", date=lambda d: d.strftime(\"%Y%m%d\")),\n", + " \"B\": dict(part=lambda p: f\"{p:05d}-X\", unit=lambda u: f\"가상{u:02d}대대\", date=lambda d: d.strftime(\"%Y-%m-%d\")),\n", + " \"C\": dict(part=lambda p: f\"P{p}\", unit=lambda u: str(u), date=lambda d: d.strftime(\"%d/%m/%Y\"))}\n", + "BASE_DATE = datetime.date(2026, 9, 30)\n", + "\n", + "\n", + "def make_systems(seed=9, mismatch=0.10, missing=0.03):\n", + " \"\"\"TRUE(정답 재고)와 체계별 원본 보고 레코드(체계 고유 형식)를 만든다.\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " true = {(p, u): int(rng.integers(20, 500)) for p in PARTS for u in range(1, N_UNIT + 1)}\n", + " out = {s: [] for s in \"ABC\"}\n", + " truth_diff = {} # (체계, p, u) -> 불일치 원인(정답)\n", + " for (p, u), q in true.items():\n", + " for s in \"ABC\":\n", + " qty, cause = q, None\n", + " r = rng.random()\n", + " if r < mismatch / 3: # 입력 오류(자릿수/오타)\n", + " qty, cause = q + int(rng.choice([-1, 1]) * rng.integers(10, 60)), \"입력 오류\"\n", + " elif r < 2 * mismatch / 3: # 반영 지연(전일 값)\n", + " qty, cause = q + int(rng.integers(5, 40)), \"반영 지연\"\n", + " elif r < mismatch: # 단위 오류(10배)\n", + " qty, cause = q * 10, \"단위 오류\"\n", + " d = BASE_DATE - datetime.timedelta(days=int(cause == \"반영 지연\"))\n", + " if rng.random() < missing: # 결측\n", + " qty, cause = None, \"결측\"\n", + " if cause:\n", + " truth_diff[(s, p, u)] = cause\n", + " out[s].append({\"part\": FMT[s][\"part\"](p), \"unit\": FMT[s][\"unit\"](u), \"date\": FMT[s][\"date\"](d), \"qty\": qty})\n", + " return true, out, truth_diff\n", + "\n", + "\n", + "def parse_part(code):\n", + " return int(code[3:]) if code.startswith(\"AP-\") else int(code[:-2]) if code.endswith(\"-X\") else int(code[1:])\n", + "\n", + "\n", + "def parse_unit(code):\n", + " return int(code[1:]) if code.startswith(\"U\") else int(code[2:4]) if code.startswith(\"가상\") else int(code)\n", + "\n", + "\n", + "def parse_date(s):\n", + " if \"/\" in s:\n", + " d, m, y = s.split(\"/\"); return datetime.date(int(y), int(m), int(d))\n", + " if \"-\" in s:\n", + " return datetime.date(*map(int, s.split(\"-\")))\n", + " return datetime.date(int(s[:4]), int(s[4:6]), int(s[6:]))\n", + "\n", + "def make_tx(n, seed=8):\n", + " \"\"\"n건 트랜잭션: 체계별 고유 코드 형식, 도착 시각 뒤섞임, 중복 1%, 형식 오류 0.4%.\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " sysn = rng.choice(list(\"ABC\"), n, p=[0.45, 0.30, 0.25])\n", + " p, u = rng.integers(1, N_PART + 1, n), rng.integers(1, N_UNIT + 1, n)\n", + " delta = rng.integers(-20, 21, n)\n", + " part = np.where(sysn == \"A\", [f\"AP-{x:05d}\" for x in p], np.where(sysn == \"B\", [f\"{x:05d}-X\" for x in p], [f\"P{x}\" for x in p]))\n", + " unit = np.where(sysn == \"A\", [f\"U{x:02d}\" for x in u], np.where(sysn == \"B\", [f\"가상{x:02d}대대\" for x in u], [str(x) for x in u]))\n", + " tx = pd.DataFrame({\"id\": [f\"TX{i:06d}\" for i in range(n)], \"sys\": sysn, \"part\": part, \"unit\": unit, \"delta\": delta,\n", + " \"ts\": np.sort(rng.integers(0, 3600 * 1000, n))})\n", + " dup = tx.sample(int(n * 0.01), random_state=seed)\n", + " tx = pd.concat([tx, dup]).sample(frac=1, random_state=seed).reset_index(drop=True) # 중복 포함, 도착 순서 뒤섞기\n", + " bad = rng.choice(len(tx), int(len(tx) * 0.004), replace=False)\n", + " tx.loc[bad, \"part\"] = \"??-\" + tx.loc[bad, \"part\"] # 형식 오류 주입\n", + " return tx\n", + "\n", + "\n", + "def process(tx, batch=2000, flaky=()):\n", + " \"\"\"마이크로 배치 처리. flaky에 든 배치 번호는 첫 시도에서 일시 장애가 나며 재시도로 복구한다.\"\"\"\n", + " stock = {(p, u): 0 for p in PARTS for u in range(1, N_UNIT + 1)}\n", + " seen, stat = set(), {\"ok\": 0, \"dup\": 0, \"quarantine\": 0, \"retry\": 0}\n", + " for b, i in enumerate(range(0, len(tx), batch)):\n", + " chunk = tx.iloc[i:i + batch]\n", + " for attempt in (1, 2, 3):\n", + " if b in flaky and attempt == 1:\n", + " stat[\"retry\"] += 1; continue # 일시 장애: 같은 배치를 다시 시도\n", + " e1 = chunk[\"part\"].str.extract(r\"^(?:AP-(\\d+)|(\\d+)-X|P(\\d+))$\")\n", + " e2 = chunk[\"unit\"].str.extract(r\"^(?:U(\\d+)|가상(\\d+)대대|(\\d+))$\")\n", + " p, u = e1[0].fillna(e1[1]).fillna(e1[2]), e2[0].fillna(e2[1]).fillna(e2[2])\n", + " valid = p.notna() & u.notna()\n", + " isdup = chunk[\"id\"].isin(seen) | chunk[\"id\"].duplicated()\n", + " stat[\"quarantine\"] += int((~valid).sum()); stat[\"dup\"] += int((valid & isdup).sum())\n", + " good = chunk[valid & ~isdup]\n", + " seen.update(good[\"id\"])\n", + " pp, uu = p[good.index].astype(int), u[good.index].astype(int)\n", + " for k, d in pd.DataFrame({\"p\": pp, \"u\": uu, \"d\": good[\"delta\"]}).groupby([\"p\", \"u\"])[\"d\"].sum().items():\n", + " stock[k] += int(d)\n", + " stat[\"ok\"] += len(good)\n", + " break\n", + " return stock, stat\n", + "\n", + "\n", + "print(\"[규모별 부하 테스트] 체계 3종 트랜잭션 동시 유입 모의 [교육용가상자료]\")\n", + "res = []\n", + "for n in (5000, 20000, 50000):\n", + " tx = make_tx(n)\n", + " t0 = time.perf_counter(); stock, st = process(tx, flaky=(1, 3)); dt = time.perf_counter() - t0\n", + " res.append({\"유입 건수\": len(tx), \"처리 시간(초)\": round(dt, 2), \"초당 처리\": int(len(tx) / dt), **st})\n", + " succ = (st[\"ok\"] + st[\"dup\"]) / len(tx)\n", + " res[-1][\"자동 처리율\"] = f\"{succ:.2%}\"\n", + "print(pd.DataFrame(res).to_string(index=False))\n", + "\n", + "# 정합성: 마지막 규모에서 일괄(벡터) 계산 결과와 마이크로 배치 결과 비교\n", + "tx2 = tx[~tx[\"part\"].str.startswith(\"??\")].drop_duplicates(\"id\")\n", + "p = tx2[\"part\"].str.extract(r\"(\\d+)\")[0].astype(int)\n", + "u = tx2[\"unit\"].str.extract(r\"(\\d+)\")[0].astype(int)\n", + "ref = tx2.assign(p=p, u=u).groupby([\"p\", \"u\"])[\"delta\"].sum()\n", + "stock, st = process(tx, flaky=(1, 3))\n", + "mism = sum(stock[(a, b)] != int(v) for (a, b), v in ref.items())\n", + "print(f\"\\n정합성: 일괄 계산과 배치 결과 비교 {len(ref)}개 키 중 불일치 {mism}건\")\n", + "n = len(tx); success = (st[\"ok\"] + st[\"dup\"]) / n\n", + "print(f\"전체 {n}건: 정상 반영 {st['ok']}, 중복 제거 {st['dup']}, 형식 오류 격리 {st['quarantine']}(담당자 확인), 일시 장애 재시도 {st['retry']}회\")\n", + "total_s = res[-1][\"처리 시간(초)\"]\n", + "print(f\"자동화 성공률 {success:.2%} (격리 건은 사람이 확인), 최대 규모 처리 시간 {total_s}초(목표 5분 이내: {'달성' if total_s < 300 else '미달'})\")\n", + "manual = n * 0.4 # 가정: 수작업 확인·입력 0.4분/건\n", + "auto = n * 0.01 + st[\"quarantine\"] * 4 + total_s / 60 # 가정: 표본 검토 0.01분/건, 격리 건 수동 4분\n", + "print(f\"업무 소요(가정값): 수작업 {manual:.0f}분 vs 자동화 {auto:.0f}분 -> 절감 {1 - auto / manual:.1%}\")\n", + "print(f\"\\n목표(5분 이내, 자동화 성공률 99% 이상, 수작업 대비 80% 이상 절감): \"\n", + " f\"{'달성' if total_s < 300 and success >= 0.99 and 1 - auto / manual >= 0.8 and mism == 0 else '미달'}\")\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 08 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + "[규모별 부하 테스트] 체계 3종 트랜잭션 동시 유입 모의 [교육용가상자료]\n", + " 유입 건수 처리 시간(초) 초당 처리 ok dup quarantine retry 자동 처리율\n", + " 5050 0.04 114205 4982 48 20 1 99.60%\n", + " 20200 0.20 100425 19921 199 80 2 99.60%\n", + " 50500 0.69 73706 49801 497 202 2 99.60%\n", + "\n", + "정합성: 일괄 계산과 배치 결과 비교 720개 키 중 불일치 0건\n", + "전체 50500건: 정상 반영 49801, 중복 제거 497, 형식 오류 격리 202(담당자 확인), 일시 장애 재시도 2회\n", + "자동화 성공률 99.60% (격리 건은 사람이 확인), 최대 규모 처리 시간 0.69초(목표 5분 이내: 달성)\n", + "업무 소요(가정값): 수작업 20200분 vs 자동화 1313분 -> 절감 93.5%\n", + "\n", + "목표(5분 이내, 자동화 성공률 99% 이상, 수작업 대비 80% 이상 절감): 달성\n", + "```\n", + "
\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 09. AAR 기반 데이터 연동 누락 분석 및 매핑 테이블 파라미터 재튜닝\n", + "**소요시간**: 약 3.0시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "AAR 기반 데이터 연동 누락을 분석하고 매핑 파라미터를 재튜닝한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **AAR에서 드러난 데이터 연동 누락 5유형을 재현·분석하고 매핑 규칙과 판정 파라미터를 재튜닝한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **코드 자릿수, 부대명, 날짜 형식, 결측 표기, 신규 품목의 5유형 변형을 가상 보고에 심어 매핑 v1의 누락을 재현 수행하고 검증하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `docs/dat09_uat_feedback_report.md 및 패치 내역` 형태의 결과물을 완성한다.\n", + "\n", + "### 🔍 평가 및 검증 기준 (PASS 조건)\n", + "#### 미션 판정\n", + "\n", + "**평가 구분:** 필수\n", + "\n", + "**핵심 평가영역:** 실무 업무 생산성 기여도 및 현장 적응성\n", + "\n", + "#### 미션 PASS 조건\n", + "\n", + "- 정량 기준: UAT 체크리스트 만족도 90점 이상, 개선 요구 5건 반영 완료\n", + "\n", + "\n", + "- 진행 기준: UAT 결과 보고서 작성 및 최종 버그 픽스 완료\n", + "\n", + "\n", + "- 검증 방법: 실제 업무 시나리오 기반 최종 검수 테스트\n", + "\n", + "\n", + "#### 채점자 판정 메모\n", + "\n", + "> 단순 코드 실행 여부뿐만 아니라 출력된 수치와 근거 자료가 군 실무 요구사항에 부합하는지 면밀히 확인한다.\n", + "\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "\"\"\"AAR에서 드러난 체계 연동 누락 5유형(코드 자릿수 변형, 부대명 변형, 날짜 형식 변형, 결측 표기 변형, 신규 품목)을 재현해 매핑 규칙 v1의 누락을 분석하고, 허용 오차·지연 일수 파라미터를 격자 탐색으로 재튜닝한 v2와 UAT 체크리스트 점수로 개선 효과를 검증한다.\"\"\"\n", + "import datetime\n", + "import itertools\n", + "import re\n", + "import numpy as np\n", + "import pandas as pd\n", + "\n", + "# ---------------------------------------------------------------------------\n", + "# [교육용가상자료] 가상 이기종 군수정보체계 3종(A 보급, B 정비, C 탄약)의 재고 보고 생성기\n", + "# 실제 체계명·부대·품목·수량 없음. 같은 (품목, 부대) 재고를 세 체계가 서로 다른 코드·날짜 형식으로 보고한다.\n", + "# 기준 날짜: 2026-09-30(가상). 마스터(정답) 재고 TRUE 와 체계별 보고에는 의도적 불일치·결측이 섞여 있다.\n", + "# ---------------------------------------------------------------------------\n", + "N_PART, N_UNIT = 60, 12\n", + "CATS = [\"급식\", \"피복\", \"유류\", \"정비소모\", \"수리부속\", \"탄약\"]\n", + "PARTS = {p: {\"name\": f\"가상품목{p:02d}\", \"cat\": CATS[(p - 1) % 6]} for p in range(1, N_PART + 1)}\n", + "FMT = {\"A\": dict(part=lambda p: f\"AP-{p:05d}\", unit=lambda u: f\"U{u:02d}\", date=lambda d: d.strftime(\"%Y%m%d\")),\n", + " \"B\": dict(part=lambda p: f\"{p:05d}-X\", unit=lambda u: f\"가상{u:02d}대대\", date=lambda d: d.strftime(\"%Y-%m-%d\")),\n", + " \"C\": dict(part=lambda p: f\"P{p}\", unit=lambda u: str(u), date=lambda d: d.strftime(\"%d/%m/%Y\"))}\n", + "BASE_DATE = datetime.date(2026, 9, 30)\n", + "\n", + "\n", + "def make_systems(seed=9, mismatch=0.10, missing=0.03):\n", + " \"\"\"TRUE(정답 재고)와 체계별 원본 보고 레코드(체계 고유 형식)를 만든다.\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " true = {(p, u): int(rng.integers(20, 500)) for p in PARTS for u in range(1, N_UNIT + 1)}\n", + " out = {s: [] for s in \"ABC\"}\n", + " truth_diff = {} # (체계, p, u) -> 불일치 원인(정답)\n", + " for (p, u), q in true.items():\n", + " for s in \"ABC\":\n", + " qty, cause = q, None\n", + " r = rng.random()\n", + " if r < mismatch / 3: # 입력 오류(자릿수/오타)\n", + " qty, cause = q + int(rng.choice([-1, 1]) * rng.integers(10, 60)), \"입력 오류\"\n", + " elif r < 2 * mismatch / 3: # 반영 지연(전일 값)\n", + " qty, cause = q + int(rng.integers(5, 40)), \"반영 지연\"\n", + " elif r < mismatch: # 단위 오류(10배)\n", + " qty, cause = q * 10, \"단위 오류\"\n", + " d = BASE_DATE - datetime.timedelta(days=int(cause == \"반영 지연\"))\n", + " if rng.random() < missing: # 결측\n", + " qty, cause = None, \"결측\"\n", + " if cause:\n", + " truth_diff[(s, p, u)] = cause\n", + " out[s].append({\"part\": FMT[s][\"part\"](p), \"unit\": FMT[s][\"unit\"](u), \"date\": FMT[s][\"date\"](d), \"qty\": qty})\n", + " return true, out, truth_diff\n", + "\n", + "\n", + "def parse_part(code):\n", + " return int(code[3:]) if code.startswith(\"AP-\") else int(code[:-2]) if code.endswith(\"-X\") else int(code[1:])\n", + "\n", + "\n", + "def parse_unit(code):\n", + " return int(code[1:]) if code.startswith(\"U\") else int(code[2:4]) if code.startswith(\"가상\") else int(code)\n", + "\n", + "\n", + "def parse_date(s):\n", + " if \"/\" in s:\n", + " d, m, y = s.split(\"/\"); return datetime.date(int(y), int(m), int(d))\n", + " if \"-\" in s:\n", + " return datetime.date(*map(int, s.split(\"-\")))\n", + " return datetime.date(int(s[:4]), int(s[4:6]), int(s[6:]))\n", + "\n", + "true, raw, tdiff = make_systems()\n", + "rng = np.random.default_rng(9)\n", + "\n", + "# 1) AAR 사례 재현: 일부 보고를 체계 담당자가 다르게 쓴 형태로 바꾼다(변형 5유형)\n", + "VAR = {\"코드 자릿수 변형\": [], \"부대명 변형\": [], \"날짜 형식 변형\": [], \"결측 표기 변형\": [], \"신규 품목\": []}\n", + "def pick(s, n, f=lambda r: True):\n", + " idx = [i for i, r in enumerate(raw[s]) if f(r) and not any(i == j for v in VAR.values() for (s2, j) in v if s2 == s)]\n", + " return [(s, int(i)) for i in rng.choice(idx, n, replace=False)]\n", + "for s, i in pick(\"A\", 25):\n", + " raw[s][i][\"part\"] = \"AP-\" + raw[s][i][\"part\"][-4:]; VAR[\"코드 자릿수 변형\"].append((s, i))\n", + "for s, i in pick(\"B\", 30):\n", + " raw[s][i][\"unit\"] = \"가상\" + str(int(raw[s][i][\"unit\"][2:4])) + \"대대\"; VAR[\"부대명 변형\"].append((s, i))\n", + "for s, i in pick(\"C\", 30):\n", + " d, m, y = raw[s][i][\"date\"].split(\"/\"); raw[s][i][\"date\"] = f\"{d}-{m}-{y}\"; VAR[\"날짜 형식 변형\"].append((s, i))\n", + "for s, i in pick(\"A\", 20, lambda r: r[\"qty\"] is not None):\n", + " raw[s][i][\"qty\"] = \"N/A\"; VAR[\"결측 표기 변형\"].append((s, i))\n", + "for s, i in pick(\"B\", 15):\n", + " raw[s][i][\"part\"] = \"00061-X\"; VAR[\"신규 품목\"].append((s, i))\n", + "for s in \"ABC\": # 반올림 차이 등 정상 변동(+-1)\n", + " for i in rng.choice(len(raw[s]), 60, replace=False):\n", + " if isinstance(raw[s][i][\"qty\"], int):\n", + " raw[s][i][\"qty\"] += int(rng.choice([-1, 1]))\n", + "\n", + "# 2) 매핑 규칙 v1(엄격) / v2(보정)\n", + "PAT1 = {\"part\": r\"^(?:AP-(\\d{5})|(\\d{5})-X|P(\\d+))$\", \"unit\": r\"^(?:U(\\d{2})|가상(\\d{2})대대|(\\d+))$\"}\n", + "PAT2 = {\"part\": r\"^(?:AP-(\\d{4,5})|(\\d{5})-X|P(\\d+))$\", \"unit\": r\"^(?:U(\\d{2})|가상(\\d{1,2})대대|(\\d+))$\"}\n", + "def g(pat, s):\n", + " m = re.match(pat, s)\n", + " return int(next(x for x in m.groups() if x)) if m else None\n", + "def pdate(s, v2):\n", + " for f in (\"%Y%m%d\", \"%Y-%m-%d\", \"%d/%m/%Y\") + ((\"%d-%m-%Y\",) if v2 else ()):\n", + " try:\n", + " return datetime.datetime.strptime(s, f).date()\n", + " except ValueError:\n", + " pass\n", + " return None\n", + "def link(v2):\n", + " pat, out, unlinked, held = (PAT2 if v2 else PAT1), [], 0, 0\n", + " for s in \"ABC\":\n", + " for i, r in enumerate(raw[s]):\n", + " p, u, d = g(pat[\"part\"], r[\"part\"]), g(pat[\"unit\"], r[\"unit\"]), pdate(r[\"date\"], v2)\n", + " q = r[\"qty\"]\n", + " if q == \"N/A\":\n", + " q = None if v2 else \"ERR\"\n", + " if p is None or u is None or d is None or q == \"ERR\":\n", + " unlinked += 1; continue\n", + " if p > N_PART and v2:\n", + " held += 1; continue # v2: 신규 품목은 조용히 버리지 않고 '신규 품목 신청' 대상으로 보류\n", + " if p > N_PART:\n", + " unlinked += 1; continue\n", + " out.append({\"sys\": s, \"p\": p, \"u\": u, \"date\": d, \"qty\": q})\n", + " return pd.DataFrame(out), unlinked, held\n", + "d1, un1, h1 = link(False); d2, un2, h2 = link(True)\n", + "total = sum(len(v) for v in raw.values())\n", + "print(f\"[AAR 연동 누락 분석] 전체 보고 {total}건 [교육용가상자료]\")\n", + "print(f\"매핑 v1: 연동 {len(d1)}건, 누락 {un1}건({un1 / total:.1%}) | 매핑 v2: 연동 {len(d2)}건, 누락 {un2}건, 신규 품목 보류 {h2}건\")\n", + "# 사례별 v1 누락 건수는 직접 재판정\n", + "def lost(s, i, v2):\n", + " r = raw[s][i]; pat = PAT2 if v2 else PAT1\n", + " p, u, d = g(pat[\"part\"], r[\"part\"]), g(pat[\"unit\"], r[\"unit\"]), pdate(r[\"date\"], v2)\n", + " return p is None or u is None or d is None or (not v2 and (r[\"qty\"] == \"N/A\" or p > N_PART))\n", + "tab = pd.DataFrame([{\"사례\": k, \"발생\": len(v), \"v1 누락\": sum(lost(s, i, False) for s, i in v), \"v2 누락\": sum(lost(s, i, True) for s, i in v)} for k, v in VAR.items()])\n", + "print(tab.to_string(index=False))\n", + "\n", + "# 3) 불일치 판정 파라미터 재튜닝: 수량 허용 오차(%)와 허용 지연 일수의 격자 탐색(가상 정답 대비 F1)\n", + "gt = {(p, u) for (_, p, u), c in tdiff.items() if c != \"결측\"}\n", + "def detect(df, tol, lag):\n", + " df = df.copy(); df[\"q\"] = pd.to_numeric(df[\"qty\"], errors=\"coerce\")\n", + " w = df.pivot_table(index=[\"p\", \"u\"], columns=\"sys\", values=\"q\", aggfunc=\"first\")\n", + " med = w.median(axis=1)\n", + " dev = w.sub(med, axis=0).abs().div(med.clip(lower=1), axis=0)\n", + " late = df.assign(l=(BASE_DATE - df[\"date\"]).map(lambda x: x.days)).pivot_table(index=[\"p\", \"u\"], columns=\"sys\", values=\"l\", aggfunc=\"first\")\n", + " flag = ((dev > tol) | (late.reindex(w.index) > lag).fillna(False) & (dev > 0)).any(axis=1)\n", + " return set(flag[flag].index)\n", + "def f1(found):\n", + " tp = len(found & gt); pr = tp / max(len(found), 1); rc = tp / len(gt)\n", + " return 2 * pr * rc / max(pr + rc, 1e-9), pr, rc\n", + "grid = []\n", + "for tol, lag in itertools.product((0.0, 0.005, 0.02, 0.05), (0, 1, 2)):\n", + " grid.append({\"허용오차\": tol, \"허용지연(일)\": lag, **dict(zip((\"F1\", \"정밀도\", \"재현율\"), f1(detect(d2, tol, lag))))})\n", + "G = pd.DataFrame(grid).sort_values(\"F1\", ascending=False)\n", + "best = G.iloc[0]\n", + "v1p = f1(detect(d2, 0.0, 0))\n", + "print(\"\\n[파라미터 재튜닝 - 상위 3개 조합]\")\n", + "print(G.head(3).round(3).to_string(index=False))\n", + "print(f\"튜닝 전(오차 0, 지연 0) F1 {v1p[0]:.3f} -> 튜닝 후 F1 {best['F1']:.3f} (허용오차 {best['허용오차']}, 허용지연 {int(best['허용지연(일)'])}일)\")\n", + "\n", + "# 4) UAT 체크리스트(자동 점검 항목) + 개선 요구 5건\n", + "tests = [(f\"{k} 사례 v2에서 해소\", int(r[\"v2 누락\"]) == 0) for k, r in tab.set_index(\"사례\").iterrows()]\n", + "tests += [(\"v2 연동률이 v1보다 높음\", len(d2) > len(d1)), (\"신규 품목은 누락이 아니라 보류로 분류\", h2 == 15), (\"v1 연동 키가 v2에 모두 포함\", set(zip(d1[\"sys\"], d1[\"p\"], d1[\"u\"])) <= set(zip(d2[\"sys\"], d2[\"p\"], d2[\"u\"]))),\n", + " (\"튜닝 후 F1이 튜닝 전보다 높음\", best[\"F1\"] > v1p[0]), (\"재현율 95% 이상\", best[\"재현율\"] >= 0.95), (\"정밀도 90% 이상\", best[\"정밀도\"] >= 0.9),\n", + " (\"연동 키가 (체계, 품목, 부대)로 유일\", not d2.duplicated([\"sys\", \"p\", \"u\"]).any()), (\"날짜 파싱 100%\", d2[\"date\"].notna().all()),\n", + " (\"v2 코드 재실행 결과 동일\", len(link(True)[0]) == len(d2)), (\"튜닝 격자 12조합 평가\", len(G) == 12), (\"v1 누락 건수가 사례 합계와 일치\", int(tab[\"v1 누락\"].sum()) == un1),\n", + " (\"원인-패치 대응표 작성\", len(VAR) == 5), (\"재튜닝 파라미터 기록\", best[\"허용오차\"] >= 0), (\"개선 요구 5건 반영\", all(tab[\"v2 누락\"] == 0))]\n", + "passed = sum(ok for _, ok in tests)\n", + "score = 100 * passed / len(tests)\n", + "print(f\"\\n[UAT 체크리스트] {passed}/{len(tests)}항목 통과, 점수 {score:.0f}점\")\n", + "for n, ok in tests:\n", + " if not ok:\n", + " print(\" 미통과:\", n)\n", + "print(\"[개선 요구 반영 현황]\")\n", + "PATCH = [\"품목코드 4~5자리 허용\", \"부대명 1~2자리 허용\", \"날짜 형식 DD-MM-YYYY 추가\", \"결측 표기 N/A를 결측으로 처리\", \"신규 품목은 보류 후 신청 대상으로 표시\"]\n", + "for i, ((k, r), pt) in enumerate(zip(tab.set_index(\"사례\").iterrows(), PATCH), 1):\n", + " print(f\" 요구 {i}. {k}: {pt} -> {'반영 완료' if r['v2 누락'] == 0 else '미반영'}\")\n", + "print(f\"\\n목표(UAT 90점 이상, 개선 요구 5건 반영): {'달성' if score >= 90 and all(tab['v2 누락'] == 0) else '미달'}\")\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 09 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + "[AAR 연동 누락 분석] 전체 보고 2160건 [교육용가상자료]\n", + "매핑 v1: 연동 2045건, 누락 115건(5.3%) | 매핑 v2: 연동 2145건, 누락 0건, 신규 품목 보류 15건\n", + " 사례 발생 v1 누락 v2 누락\n", + "코드 자릿수 변형 25 25 0\n", + " 부대명 변형 30 25 0\n", + " 날짜 형식 변형 30 30 0\n", + " 결측 표기 변형 20 20 0\n", + " 신규 품목 15 15 0\n", + "\n", + "[파라미터 재튜닝 - 상위 3개 조합]\n", + " 허용오차 허용지연(일) F1 정밀도 재현율\n", + " 0.02 0 0.972 0.972 0.972\n", + " 0.02 1 0.963 0.972 0.955\n", + " 0.02 2 0.963 0.972 0.955\n", + "튜닝 전(오차 0, 지연 0) F1 0.738 -> 튜닝 후 F1 0.972 (허용오차 0.02, 허용지연 0일)\n", + "\n", + "[UAT 체크리스트] 19/19항목 통과, 점수 100점\n", + "[개선 요구 반영 현황]\n", + " 요구 1. 코드 자릿수 변형: 품목코드 4~5자리 허용 -> 반영 완료\n", + " 요구 2. 부대명 변형: 부대명 1~2자리 허용 -> 반영 완료\n", + " 요구 3. 날짜 형식 변형: 날짜 형식 DD-MM-YYYY 추가 -> 반영 완료\n", + " 요구 4. 결측 표기 변형: 결측 표기 N/A를 결측으로 처리 -> 반영 완료\n", + " 요구 5. 신규 품목: 신규 품목은 보류 후 신청 대상으로 표시 -> 반영 완료\n", + "\n", + "목표(UAT 90점 이상, 개선 요구 5건 반영): 달성\n", + "```\n", + "
\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 10. 이기종 군수 정보체계 데이터 통합 및 실시간 가시화 대시보드 최종 완성\n", + "**소요시간**: 약 3.0시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "이기종 군수 정보체계 데이터 통합 및 실시간 가시화 대시보드를 완성한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **수집부터 불일치 식별, 품질 지수, 마스터 승인 대기 분리, 상황판 가시화까지 한 번에 실행되는 통합 군수 상황판 도구를 완성한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **환경 점검 후 세 체계 자료를 수집·표준화하고 불일치를 식별해 품질 지수 산출하고 모델에 적용하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `dist/dat09_package/, README.md, docs/dat09_final_manual.md` 형태의 결과물을 완성한다.\n", + "\n", + "### 🔍 평가 및 검증 기준 (PASS 조건)\n", + "#### 미션 판정\n", + "\n", + "**평가 구분:** 필수\n", + "\n", + "**핵심 평가영역:** 상용 소프트웨어 수준의 완성도 및 조직 확산 가치\n", + "\n", + "#### 미션 PASS 조건\n", + "\n", + "- 정량 기준: 설치부터 실행까지 원클릭 설치 스크립트 완비, 매뉴얼 작성률 100%\n", + "\n", + "\n", + "- 진행 기준: 최종 배포본 생성 및 검수 완료\n", + "\n", + "\n", + "- 검증 방법: Clean 환경에서의 설치 및 초기화 테스트\n", + "\n", + "\n", + "#### 채점자 판정 메모\n", + "\n", + "> 단순 코드 실행 여부뿐만 아니라 출력된 수치와 근거 자료가 군 실무 요구사항에 부합하는지 면밀히 확인한다.\n", + "\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "\"\"\"환경 점검부터 3개 체계 자료 수집·표준화, 체계 간 불일치 식별, 품질 지수 산출, 마스터 후보의 데이터관리관 승인 대기 분리, 통합 군수 상황판 가시화까지 한 번에 실행하는 도구를 완성하고 설치 개요·매뉴얼 목차·자체 점검·브리핑 요약을 출력한다.\"\"\"\n", + "import collections\n", + "import csv\n", + "import datetime\n", + "import importlib.util\n", + "import io\n", + "import json\n", + "import sys\n", + "import time\n", + "import numpy as np\n", + "import pandas as pd\n", + "import matplotlib.pyplot as plt\n", + "\n", + "# ---------------------------------------------------------------------------\n", + "# [교육용가상자료] 가상 이기종 군수정보체계 3종(A 보급, B 정비, C 탄약)의 재고 보고 생성기\n", + "# 실제 체계명·부대·품목·수량 없음. 같은 (품목, 부대) 재고를 세 체계가 서로 다른 코드·날짜 형식으로 보고한다.\n", + "# 기준 날짜: 2026-09-30(가상). 마스터(정답) 재고 TRUE 와 체계별 보고에는 의도적 불일치·결측이 섞여 있다.\n", + "# ---------------------------------------------------------------------------\n", + "N_PART, N_UNIT = 60, 12\n", + "CATS = [\"급식\", \"피복\", \"유류\", \"정비소모\", \"수리부속\", \"탄약\"]\n", + "PARTS = {p: {\"name\": f\"가상품목{p:02d}\", \"cat\": CATS[(p - 1) % 6]} for p in range(1, N_PART + 1)}\n", + "FMT = {\"A\": dict(part=lambda p: f\"AP-{p:05d}\", unit=lambda u: f\"U{u:02d}\", date=lambda d: d.strftime(\"%Y%m%d\")),\n", + " \"B\": dict(part=lambda p: f\"{p:05d}-X\", unit=lambda u: f\"가상{u:02d}대대\", date=lambda d: d.strftime(\"%Y-%m-%d\")),\n", + " \"C\": dict(part=lambda p: f\"P{p}\", unit=lambda u: str(u), date=lambda d: d.strftime(\"%d/%m/%Y\"))}\n", + "BASE_DATE = datetime.date(2026, 9, 30)\n", + "\n", + "\n", + "def make_systems(seed=9, mismatch=0.10, missing=0.03):\n", + " \"\"\"TRUE(정답 재고)와 체계별 원본 보고 레코드(체계 고유 형식)를 만든다.\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " true = {(p, u): int(rng.integers(20, 500)) for p in PARTS for u in range(1, N_UNIT + 1)}\n", + " out = {s: [] for s in \"ABC\"}\n", + " truth_diff = {} # (체계, p, u) -> 불일치 원인(정답)\n", + " for (p, u), q in true.items():\n", + " for s in \"ABC\":\n", + " qty, cause = q, None\n", + " r = rng.random()\n", + " if r < mismatch / 3: # 입력 오류(자릿수/오타)\n", + " qty, cause = q + int(rng.choice([-1, 1]) * rng.integers(10, 60)), \"입력 오류\"\n", + " elif r < 2 * mismatch / 3: # 반영 지연(전일 값)\n", + " qty, cause = q + int(rng.integers(5, 40)), \"반영 지연\"\n", + " elif r < mismatch: # 단위 오류(10배)\n", + " qty, cause = q * 10, \"단위 오류\"\n", + " d = BASE_DATE - datetime.timedelta(days=int(cause == \"반영 지연\"))\n", + " if rng.random() < missing: # 결측\n", + " qty, cause = None, \"결측\"\n", + " if cause:\n", + " truth_diff[(s, p, u)] = cause\n", + " out[s].append({\"part\": FMT[s][\"part\"](p), \"unit\": FMT[s][\"unit\"](u), \"date\": FMT[s][\"date\"](d), \"qty\": qty})\n", + " return true, out, truth_diff\n", + "\n", + "\n", + "def parse_part(code):\n", + " return int(code[3:]) if code.startswith(\"AP-\") else int(code[:-2]) if code.endswith(\"-X\") else int(code[1:])\n", + "\n", + "\n", + "def parse_unit(code):\n", + " return int(code[1:]) if code.startswith(\"U\") else int(code[2:4]) if code.startswith(\"가상\") else int(code)\n", + "\n", + "\n", + "def parse_date(s):\n", + " if \"/\" in s:\n", + " d, m, y = s.split(\"/\"); return datetime.date(int(y), int(m), int(d))\n", + " if \"-\" in s:\n", + " return datetime.date(*map(int, s.split(\"-\")))\n", + " return datetime.date(int(s[:4]), int(s[4:6]), int(s[6:]))\n", + "\n", + "LOG = []\n", + "def stage(name):\n", + " def deco(fn):\n", + " def run(*a):\n", + " t0 = time.perf_counter()\n", + " try:\n", + " r = fn(*a); LOG.append((name, \"OK\", time.perf_counter() - t0)); return r\n", + " except Exception as ex: # 한 단계가 실패해도 도구는 멈추지 않고 로그를 남긴다\n", + " LOG.append((name, f\"FAIL {repr(ex)[:30]}\", time.perf_counter() - t0)); return None\n", + " return run\n", + " return deco\n", + "\n", + "INSTALL = [\"1. 가상 환경 생성, numpy·pandas·matplotlib 설치\", \"2. 환경 점검(모듈 확인)\", \"3. 샘플 3개 체계 자료로 시험 실행\", \"4. run_cop() 실행\", \"5. 상황판·승인 대기 목록 확인\"]\n", + "mods = [\"numpy\", \"pandas\", \"matplotlib\", \"csv\", \"json\"]\n", + "env_ok = sum(importlib.util.find_spec(m) is not None for m in mods)\n", + "print(f\"[0] 환경 점검: Python {sys.version_info.major}.{sys.version_info.minor}, 필수 모듈 {env_ok}/{len(mods)} 확인 [교육용가상자료]\")\n", + "\n", + "true, raw, tdiff = make_systems()\n", + "\n", + "@stage(\"수집\")\n", + "def collect():\n", + " a = \"\\n\".join([\"part,unit,date,qty\"] + [f\"{r['part']},{r['unit']},{r['date']},{'' if r['qty'] is None else r['qty']}\" for r in raw[\"A\"]])\n", + " b = json.dumps([{\"part\": r[\"part\"], \"unit\": r[\"unit\"], \"date\": r[\"date\"], \"qty\": r[\"qty\"]} for r in raw[\"B\"]], ensure_ascii=False)\n", + " c = \"\\n\".join([\"part\\tunit\\tdate\\tqty\"] + [f\"{r['part']}\\t{r['unit']}\\t{r['date']}\\t{-1 if r['qty'] is None else r['qty']}\" for r in raw[\"C\"]])\n", + " out = []\n", + " for s, rows in ((\"A\", list(csv.DictReader(io.StringIO(a)))), (\"B\", json.loads(b)), (\"C\", list(csv.DictReader(io.StringIO(c), delimiter=\"\\t\")))):\n", + " for r in rows:\n", + " q = r[\"qty\"]\n", + " q = None if q in (\"\", None, \"-1\", -1) else int(q)\n", + " out.append({\"sys\": s, \"p\": parse_part(r[\"part\"]), \"u\": parse_unit(r[\"unit\"]), \"date\": parse_date(r[\"date\"]), \"qty\": q})\n", + " return pd.DataFrame(out)\n", + "\n", + "@stage(\"불일치 식별\")\n", + "def reconcile(df):\n", + " w = df.pivot_table(index=[\"p\", \"u\"], columns=\"sys\", values=\"qty\", aggfunc=\"first\")\n", + " rows = []\n", + " for k, r in w.iterrows():\n", + " have = r.dropna()\n", + " top, n = collections.Counter(have.values).most_common(1)[0] if len(have) else (None, 0)\n", + " diff = (have != top).any() or len(have) < 3\n", + " rows.append({\"p\": k[0], \"u\": k[1], \"mismatch\": bool(diff), \"cand\": top if n >= 2 else None, \"n_missing\": 3 - len(have)})\n", + " return pd.DataFrame(rows)\n", + "\n", + "@stage(\"품질 지수\")\n", + "def quality(df, rec):\n", + " comp = df[\"qty\"].notna().mean(); cons = 1 - rec[\"mismatch\"].mean()\n", + " tim = ((BASE_DATE - df[\"date\"]).map(lambda d: d.days) <= 0).mean()\n", + " return 100 * (0.3 * comp + 0.5 * cons + 0.2 * tim)\n", + "\n", + "@stage(\"승인 대기 분리\")\n", + "def queue(rec):\n", + " q = rec[rec[\"mismatch\"]].copy()\n", + " q[\"status\"] = np.where(q[\"cand\"].notna(), \"데이터관리관 승인 대기\", \"사람 판단 필요(후보 없음)\")\n", + " return q\n", + "\n", + "@stage(\"상황판\")\n", + "def cop(df):\n", + " base = df[df[\"sys\"] == \"A\"].dropna(subset=[\"qty\"]).copy()\n", + " base[\"cat\"] = base[\"p\"].map(lambda p: PARTS[p][\"cat\"])\n", + " return base.groupby(\"cat\")[\"qty\"].sum(), base.groupby(\"u\")[\"qty\"].sum()\n", + "\n", + "df = collect()\n", + "rec = reconcile(df)\n", + "qi = quality(df, rec)\n", + "q = queue(rec)\n", + "cat_tot, unit_tot = cop(df)\n", + "\n", + "print(f\"[1] 수집·표준화 {len(df)}건(3개 체계), 통합 키 {len(rec)}개\")\n", + "print(f\"[2] 체계 간 불일치 {int(rec['mismatch'].sum())}개({rec['mismatch'].mean():.1%}), 통합 데이터 품질 지수 {qi:.1f}점\")\n", + "print(\"[3] 마스터 확정 대기 현황(자동 확정 없음):\", q[\"status\"].value_counts().to_dict())\n", + "print(\"[4] 통합 군수 상황판 - 분류별 재고(체계 A 기준):\", cat_tot.astype(int).to_dict())\n", + "print(\" 재고 적은 부대 3곳:\", unit_tot.nsmallest(3).astype(int).to_dict())\n", + "print(\"[5] 단계별 실행 로그:\")\n", + "for n, st, t in LOG:\n", + " print(f\" - {n:<8} {st:<5} {t * 1000:.1f}ms\")\n", + "run_ms = sum(t for _, _, t in LOG) * 1000\n", + "\n", + "fig, ax = plt.subplots(1, 3, figsize=(13, 3.8))\n", + "ax[0].bar([f\"cat{i + 1}\" for i in range(len(cat_tot))], cat_tot.values, color=\"tab:blue\"); ax[0].set_title(\"Stock by category\")\n", + "ax[1].bar(unit_tot.index, unit_tot.values, color=\"tab:gray\"); ax[1].set_title(\"Stock by unit\")\n", + "ax[2].bar([\"pending\", \"no candidate\"], [int((q[\"status\"].str.startswith(\"데이터\")).sum()), int((~q[\"status\"].str.startswith(\"데이터\")).sum())], color=[\"tab:orange\", \"tab:red\"])\n", + "ax[2].set_title(\"Master approval queue\")\n", + "plt.suptitle(\"DAT09 integrated logistics board (synthetic)\"); plt.tight_layout(); plt.show()\n", + "\n", + "MANUAL = [\"1. 개요·설치\", \"2. 체계별 입력 규격\", \"3. 코드 변환표·매핑 규칙\", \"4. 불일치 처리 절차\", \"5. 마스터 승인(데이터관리관)\", \"6. 상황판 보는 법\", \"7. 오류 메시지 조치\", \"8. 부하·배치 운영\", \"9. 개선 이력·문의\"]\n", + "checks = [(\"환경 점검 5/5\", env_ok == len(mods)), (\"모든 단계 OK\", all(s == \"OK\" for _, s, _ in LOG)), (\"통합 키 720개 생성\", len(rec) == 720),\n", + " (\"불일치 식별 재현율 95% 이상\", len({(r.p, r.u) for r in rec.itertuples() if r.mismatch} & {(p, u) for (_, p, u) in tdiff}) / len({(p, u) for (_, p, u) in tdiff}) >= 0.95),\n", + " (\"마스터 자동 확정 0건\", not q[\"status\"].eq(\"확정\").any()), (\"실행 시간 1분 이내\", run_ms < 60000),\n", + " (\"매뉴얼 9장 작성(작성률 100%)\", len(MANUAL) == 9), (\"설치 절차 5단계 완비\", len(INSTALL) == 5)]\n", + "print(\"\\n[6] 자체 점검\")\n", + "for n, ok in checks:\n", + " print(f\" {'PASS' if ok else 'FAIL'} {n}\")\n", + "npass = sum(ok for _, ok in checks)\n", + "print(\"설치 스크립트 개요:\", \" / \".join(INSTALL))\n", + "print(\"매뉴얼 목차:\", \" | \".join(MANUAL))\n", + "asis, tobe = 550, 50\n", + "print(f\"\\n[브리핑 요약] 3개 체계 {len(df)}건을 {run_ms:.0f}ms에 통합. 불일치 {int(rec['mismatch'].sum())}개는 후보와 함께 데이터관리관 승인 대기로 분리(자동 확정 없음).\")\n", + "print(f\"품질 지수 {qi:.1f}점, 상황 파악 시간 {asis}분 -> {tobe}분({1 - tobe / asis:.0%} 절감 가정값).\")\n", + "print(f\"자체 점검 {npass}/{len(checks)} PASS. 목표(원클릭 설치·실행 완비, 매뉴얼 100%): {'달성' if npass == len(checks) else '미달'}\")\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 10 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + "[0] 환경 점검: Python 3.11, 필수 모듈 5/5 확인 [교육용가상자료]\n", + "[1] 수집·표준화 2160건(3개 체계), 통합 키 720개\n", + "[2] 체계 간 불일치 219개(30.4%), 통합 데이터 품질 지수 83.5점\n", + "[3] 마스터 확정 대기 현황(자동 확정 없음): {'데이터관리관 승인 대기': 198, '사람 판단 필요(후보 없음)': 21}\n", + "[4] 통합 군수 상황판 - 분류별 재고(체계 A 기준): {'급식': 33087, '수리부속': 42203, '유류': 40597, '정비소모': 38378, '탄약': 39855, '피복': 39222}\n", + " 재고 적은 부대 3곳: {9: 15552, 6: 15588, 3: 15797}\n", + "[5] 단계별 실행 로그:\n", + " - 수집 OK 14.4ms\n", + " - 불일치 식별 OK 147.9ms\n", + " - 품질 지수 OK 1.7ms\n", + " - 승인 대기 분리 OK 1.1ms\n", + " - 상황판 OK 4.5ms\n", + "\n", + "[6] 자체 점검\n", + " PASS 환경 점검 5/5\n", + " PASS 모든 단계 OK\n", + " PASS 통합 키 720개 생성\n", + " PASS 불일치 식별 재현율 95% 이상\n", + " PASS 마스터 자동 확정 0건\n", + " PASS 실행 시간 1분 이내\n", + " PASS 매뉴얼 9장 작성(작성률 100%)\n", + " PASS 설치 절차 5단계 완비\n", + "설치 스크립트 개요: 1. 가상 환경 생성, numpy·pandas·matplotlib 설치 / 2. 환경 점검(모듈 확인) / 3. 샘플 3개 체계 자료로 시험 실행 / 4. run_cop() 실행 / 5. 상황판·승인 대기 목록 확인\n", + "매뉴얼 목차: 1. 개요·설치 | 2. 체계별 입력 규격 | 3. 코드 변환표·매핑 규칙 | 4. 불일치 처리 절차 | 5. 마스터 승인(데이터관리관) | 6. 상황판 보는 법 | 7. 오류 메시지 조치 | 8. 부하·배치 운영 | 9. 개선 이력·문의\n", + "\n", + "[브리핑 요약] 3개 체계 2160건을 170ms에 통합. 불일치 219개는 후보와 함께 데이터관리관 승인 대기로 분리(자동 확정 없음).\n", + "품질 지수 83.5점, 상황 파악 시간 550분 -> 50분(91% 절감 가정값).\n", + "자체 점검 8/8 PASS. 목표(원클릭 설치·실행 완비, 매뉴얼 100%): 달성\n", + "```\n", + "
\n" + ] + } + ], + "metadata": { + "colab": { + "provenance": [] + }, + "kernelspec": { + "display_name": "Python 3", + "name": "python3" + }, + "language_info": { + "name": "python" + } + }, + "nbformat": 4, + "nbformat_minor": 0 +} \ No newline at end of file diff --git a/uploads/DAT09/DAT09_student.ipynb b/uploads/DAT09/DAT09_student.ipynb new file mode 100644 index 0000000..01fb7d1 --- /dev/null +++ b/uploads/DAT09/DAT09_student.ipynb @@ -0,0 +1,2403 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# 🎯 [DAT09] 군수데이터 전략기획·환경평가 대시보드\n", + "### Google Colab 실습 환경 (데이터·자동화·업무플랫폼 / Level 2)\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/DAT09/DAT09_solution.ipynb)\n", + "\n", + "---\n", + "\n", + "## 📌 1. 프로젝트 개요 및 배경\n", + "\n", + "| 항목 | 내용 |\n", + "| :--- | :--- |\n", + "| **과정 분류** | 데이터·자동화·업무플랫폼 (Level 2) |\n", + "| **학습 소요 시간** | 10개 미션 (5일 / 총 27.0시간) |\n", + "| **환경 사양** | Python 3.10+, numpy, pandas, matplotlib, scipy, scikit-learn |\n", + "\n", + "### 📖 프로젝트 목표\n", + "미국 국방부(DoD)는 군수 데이터 전략과 환경 평가를 통해 서로 다른 체계에 흩어진 데이터의 중복을 없애고 표준 키와 코드를 정해 업무 중심으로 통합하려는 접근을 공개해 왔다. 이 사례의 학습 초점은 중복 제거, 표준 키·코드, 업무 중심 통합, 역할별 화면, 자동 갱신, 재사용성이다. 본 프로젝트는 이를 바탕으로 교육용 가상 보급·정비·탄약 체계 3종의 재고 보고를 코드로 만들어 통합 대시보드로 가시화한다. 학습자는 연동 규격 분석, 코드 표준화와 결측 전처리, 통합 저장소와 검증 룰, 변경 이벤트 동기화, 불일치 식별과 품질 지수, 역할별 상황판, 데이터관리관 승인, 대량 트랜잭션 시험, 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. 이기종 군수 정보체계(DELIIS, 장비정비정보체계, 탄약체계) 데이터 연동 규격 분석\n", + "**소요시간**: 약 1.5시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "이기종 군수 정보체계(DELIIS, 장비정비, 탄약체계) 데이터 연동 규격을 분석한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **이기종 군수 정보체계 3종의 품목코드·부대코드·날짜·수량 연동 규격을 비교 분석하고, 통합 군수 상황 파악 업무를 AS-IS/TO-BE로 비교한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **가상의 보급·정비·탄약 체계가 같은 재고를 서로 다른 코드와 날짜 형식으로 보고하는 자료를 코드로 작성하여 원인을 분석하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `docs/dat09_as_is_to_be_spec.md` 형태의 결과물을 완성한다.\n", + "\n", + "### 🔍 평가 및 검증 기준 (PASS 조건)\n", + "#### 미션 판정\n", + "\n", + "**평가 구분:** 필수\n", + "\n", + "**핵심 평가영역:** 현행 업무(AS-IS)와 개선 업무(TO-BE) 비교의 명확성\n", + "\n", + "#### 미션 PASS 조건\n", + "\n", + "- 정량 기준: 업무 단계 분해 10단계 이상, 병목 구간 3개소 이상 도출\n", + "\n", + "\n", + "- 진행 기준: 업무 정의서 및 입출력 데이터 명세 작성 완료\n", + "\n", + "\n", + "- 검증 방법: 실무 프로세스와의 일치성 검증\n", + "\n", + "\n", + "#### 채점자 판정 메모\n", + "\n", + "> 단순 코드 실행 여부뿐만 아니라 출력된 수치와 근거 자료가 군 실무 요구사항에 부합하는지 면밀히 확인한다.\n", + "\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "\"\"\"가상 이기종 군수정보체계 3종(보급·정비·탄약)의 품목코드·부대코드·날짜·수량 연동 규격을 비교 분석해 코드 변환 규칙을 정의하고, 통합 군수 상황 파악 업무를 AS-IS/TO-BE 단계로 분해해 병목 구간을 도출한다.\"\"\"\n", + "import datetime\n", + "import numpy as np\n", + "import pandas as pd\n", + "\n", + "# ---------------------------------------------------------------------------\n", + "# [교육용가상자료] 가상 이기종 군수정보체계 3종(A 보급, B 정비, C 탄약)의 재고 보고 생성기\n", + "# 실제 체계명·부대·품목·수량 없음. 같은 (품목, 부대) 재고를 세 체계가 서로 다른 코드·날짜 형식으로 보고한다.\n", + "# 기준 날짜: 2026-09-30(가상). 마스터(정답) 재고 TRUE 와 체계별 보고에는 의도적 불일치·결측이 섞여 있다.\n", + "# ---------------------------------------------------------------------------\n", + "N_PART, N_UNIT = 60, 12\n", + "CATS = [\"급식\", \"피복\", \"유류\", \"정비소모\", \"수리부속\", \"탄약\"]\n", + "PARTS = {p: {\"name\": f\"가상품목{p:02d}\", \"cat\": CATS[(p - 1) % 6]} for p in range(1, N_PART + 1)}\n", + "FMT = {\"A\": dict(part=lambda p: f\"AP-{p:05d}\", unit=lambda u: f\"U{u:02d}\", date=lambda d: d.strftime(\"%Y%m%d\")),\n", + " \"B\": dict(part=lambda p: f\"{p:05d}-X\", unit=lambda u: f\"가상{u:02d}대대\", date=lambda d: d.strftime(\"%Y-%m-%d\")),\n", + " \"C\": dict(part=lambda p: f\"P{p}\", unit=lambda u: str(u), date=lambda d: d.strftime(\"%d/%m/%Y\"))}\n", + "BASE_DATE = datetime.date(2026, 9, 30)\n", + "\n", + "\n", + "def make_systems(seed=9, mismatch=0.10, missing=0.03):\n", + " \"\"\"TRUE(정답 재고)와 체계별 원본 보고 레코드(체계 고유 형식)를 만든다.\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " true = {(p, u): int(rng.integers(20, 500)) for p in PARTS for u in range(1, N_UNIT + 1)}\n", + " out = {s: [] for s in \"ABC\"}\n", + " truth_diff = {} # (체계, p, u) -> 불일치 원인(정답)\n", + " for (p, u), q in true.items():\n", + " for s in \"ABC\":\n", + " qty, cause = q, None\n", + " r = rng.random()\n", + " if r < mismatch / 3: # 입력 오류(자릿수/오타)\n", + " qty, cause = q + int(rng.choice([-1, 1]) * rng.integers(10, 60)), \"입력 오류\"\n", + " elif r < 2 * mismatch / 3: # 반영 지연(전일 값)\n", + " qty, cause = q + int(rng.integers(5, 40)), \"반영 지연\"\n", + " elif r < mismatch: # 단위 오류(10배)\n", + " qty, cause = q * 10, \"단위 오류\"\n", + " d = BASE_DATE - datetime.timedelta(days=int(cause == \"반영 지연\"))\n", + " if rng.random() < missing: # 결측\n", + " qty, cause = None, \"결측\"\n", + " if cause:\n", + " truth_diff[(s, p, u)] = cause\n", + " out[s].append({\"part\": FMT[s][\"part\"](p), \"unit\": FMT[s][\"unit\"](u), \"date\": FMT[s][\"date\"](d), \"qty\": qty})\n", + " return true, out, truth_diff\n", + "\n", + "\n", + "def parse_part(code):\n", + " # [TODO : 학생 실습 1] 이기종 군수 정보체계(DELIIS, 장비정비정보체계, 탄약체계) 데이터 연동 규격 분석 관련 핵심 로직을 구현하세요.\n", + " # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n", + " # <<< 여기에 코드를 작성하세요 >>>\n", + " pass # 작성 후 pass를 제거하세요.\n", + "def parse_unit(code):\n", + " # [TODO : 학생 실습 1] 이기종 군수 정보체계(DELIIS, 장비정비정보체계, 탄약체계) 데이터 연동 규격 분석 관련 핵심 로직을 구현하세요.\n", + " # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n", + " # <<< 여기에 코드를 작성하세요 >>>\n", + " pass # 작성 후 pass를 제거하세요.\n", + "def parse_date(s):\n", + " if \"/\" in s:\n", + " d, m, y = s.split(\"/\"); return datetime.date(int(y), int(m), int(d))\n", + " if \"-\" in s:\n", + " return datetime.date(*map(int, s.split(\"-\")))\n", + " return datetime.date(int(s[:4]), int(s[4:6]), int(s[6:]))\n", + "\n", + "true, raw, tdiff = make_systems()\n", + "\n", + "# 1) 체계별 연동 규격 비교표(필드 형식 실제 샘플에서 추출)\n", + "spec = pd.DataFrame({\"체계\": [\"A(보급)\", \"B(정비)\", \"C(탄약)\"],\n", + " \"품목코드 예\": [raw[s][0][\"part\"] for s in \"ABC\"], \"부대코드 예\": [raw[s][0][\"unit\"] for s in \"ABC\"],\n", + " \"날짜 예\": [raw[s][0][\"date\"] for s in \"ABC\"], \"수량 필드\": [\"qty(정수)\"] * 3,\n", + " \"갱신 주기\": [\"일 1회\", \"수시\", \"주 1회\"], \"결측 표기\": [\"빈칸\", \"null\", \"-1\"]})\n", + "print(\"[체계별 연동 규격 비교] [교육용가상자료]\")\n", + "print(spec.to_string(index=False))\n", + "\n", + "# 2) 변환 규칙 검증: 세 체계의 코드가 마스터 키로 정확히 1:1 변환되는지\n", + "rule_ok = all(parse_part(FMT[s][\"part\"](p)) == p and parse_unit(FMT[s][\"unit\"](u)) == u for s in \"ABC\" for p in PARTS for u in range(1, N_UNIT + 1))\n", + "date_ok = all(parse_date(FMT[s][\"date\"](BASE_DATE)) == BASE_DATE for s in \"ABC\")\n", + "print(f\"\\n코드 변환 규칙 왕복 검증: 품목 {N_PART}종 x 부대 {N_UNIT}개 x 3체계 {'전부 일치' if rule_ok else '불일치'}, 날짜 형식 3종 {'일치' if date_ok else '불일치'}\")\n", + "\n", + "# 3) 연동 현황: 키 일치도·불일치·결측(체계 간 비교)\n", + "rows = []\n", + "for s in \"ABC\":\n", + " for r in raw[s]:\n", + " rows.append({\"sys\": s, \"p\": parse_part(r[\"part\"]), \"u\": parse_unit(r[\"unit\"]), \"date\": parse_date(r[\"date\"]), \"qty\": r[\"qty\"]})\n", + "df = pd.DataFrame(rows)\n", + "wide = df.pivot_table(index=[\"p\", \"u\"], columns=\"sys\", values=\"qty\", aggfunc=\"first\")\n", + "miss = wide.isna().sum()\n", + "diff = (wide.max(axis=1) != wide.min(axis=1)) & wide.notna().all(axis=1)\n", + "print(f\"세 체계 보고 {len(df)}건 / 비교 키 {len(wide)}개: 결측 A {miss['A']}·B {miss['B']}·C {miss['C']}건, 체계 간 수량 불일치 키 {int(diff.sum())}개({diff.mean():.1%})\")\n", + "print(\"날짜 지연(기준일 이전 보고) 건수:\", int((df[\"date\"] < BASE_DATE).sum()))\n", + "\n", + "# 4) 통합 군수 상황 파악 업무 AS-IS / TO-BE (가상 측정 소요 분)\n", + "steps = [(\"체계 A 재고 조회·내려받기\", 25, \"정해진 시각 자동 수집\", 2), (\"체계 B 정비 현황 조회·내려받기\", 30, \"자동 수집\", 2),\n", + " (\"체계 C 탄약 현황 요청(전화·메일)\", 45, \"연동 요청 규격화·자동 수집\", 5), (\"엑셀로 3개 자료 합치기\", 60, \"표준 키로 자동 병합\", 1),\n", + " (\"품목코드 수기 대조\", 70, \"코드 변환표 자동 적용\", 1), (\"부대명·날짜 형식 통일\", 35, \"변환 규칙 자동 적용\", 1),\n", + " (\"결측·이상값 확인(전화 문의)\", 55, \"결측·불일치 후보 자동 표시\", 10), (\"체계 간 불일치 원인 확인\", 50, \"원인 후보 자동 추천\", 12),\n", + " (\"지표(재고·가동·소모) 집계\", 40, \"집계 쿼리 자동 실행\", 1), (\"군수참모 보고용 표·그래프 작성\", 65, \"상황판 자동 갱신\", 2),\n", + " (\"마스터 데이터 수정 요청·확정\", 45, \"데이터관리관 승인 화면\", 10), (\"보고 후 수정·재배포\", 30, \"단일 데이터 원천 사용\", 3)]\n", + "proc = pd.DataFrame(steps, columns=[\"업무 단계\", \"AS-IS(분)\", \"TO-BE 개선\", \"TO-BE(분)\"])\n", + "proc.index = range(1, len(proc) + 1)\n", + "thr = proc[\"AS-IS(분)\"].mean() + 0.5 * proc[\"AS-IS(분)\"].std()\n", + "proc[\"병목\"] = np.where(proc[\"AS-IS(분)\"] >= thr, \"병목\", \"\")\n", + "print(f\"\\n[AS-IS / TO-BE 업무 단계 {len(proc)}단계] (병목 기준: 평균+0.5표준편차 = {thr:.0f}분 이상)\")\n", + "print(proc[[\"업무 단계\", \"AS-IS(분)\", \"TO-BE(분)\", \"병목\"]].to_string())\n", + "nb = int((proc[\"병목\"] == \"병목\").sum())\n", + "a, t = proc[\"AS-IS(분)\"].sum(), proc[\"TO-BE(분)\"].sum()\n", + "print(f\"\\n업무 단계 {len(proc)}단계(목표 10단계 이상: {'달성' if len(proc) >= 10 else '미달'}), 병목 {nb}개소(목표 3개소 이상: {'달성' if nb >= 3 else '미달'})\")\n", + "print(f\"1회 상황 파악 AS-IS {a}분 -> TO-BE {t}분 ({1 - t / a:.0%} 단축 예상, 가정값)\")\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 01 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + "[체계별 연동 규격 비교] [교육용가상자료]\n", + " 체계 품목코드 예 부대코드 예 날짜 예 수량 필드 갱신 주기 결측 표기\n", + "A(보급) AP-00001 U01 20260930 qty(정수) 일 1회 빈칸\n", + "B(정비) 00001-X 가상01대대 2026-09-30 qty(정수) 수시 null\n", + "C(탄약) P1 1 30/09/2026 qty(정수) 주 1회 -1\n", + "\n", + "코드 변환 규칙 왕복 검증: 품목 60종 x 부대 12개 x 3체계 전부 일치, 날짜 형식 3종 일치\n", + "세 체계 보고 2160건 / 비교 키 720개: 결측 A 18·B 14·C 14건, 체계 간 수량 불일치 키 176개(24.4%)\n", + "날짜 지연(기준일 이전 보고) 건수: 73\n", + "\n", + "[AS-IS / TO-BE 업무 단계 12단계] (병목 기준: 평균+0.5표준편차 = 53분 이상)\n", + " 업무 단계 AS-IS(분) TO-BE(분) 병목\n", + "1 체계 A 재고 조회·내려받기 25 2 \n", + "2 체계 B 정비 현황 조회·내려받기 30 2 \n", + "3 체계 C 탄약 현황 요청(전화·메일) 45 5 \n", + "4 엑셀로 3개 자료 합치기 60 1 병목\n", + "5 품목코드 수기 대조 70 1 병목\n", + "6 부대명·날짜 형식 통일 35 1 \n", + "7 결측·이상값 확인(전화 문의) 55 10 병목\n", + "8 체계 간 불일치 원인 확인 50 12 \n", + "9 지표(재고·가동·소모) 집계 40 1 \n", + "10 군수참모 보고용 표·그래프 작성 65 2 병목\n", + "11 마스터 데이터 수정 요청·확정 45 10 \n", + "12 보고 후 수정·재배포 30 3 \n", + "\n", + "업무 단계 12단계(목표 10단계 이상: 달성), 병목 4개소(목표 3개소 이상: 달성)\n", + "1회 상황 파악 AS-IS 550분 -> TO-BE 50분 (91% 단축 예상, 가정값)\n", + "```\n", + "
\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 02. 서로 다른 부품코드, 부대코드, 날짜 포맷 통합 및 결측 데이터 전처리 파이프라인 구축\n", + "**소요시간**: 약 2.0시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "부품코드, 부대코드, 날짜 포맷 통합 및 결측 데이터 전처리 파이프라인을 구축한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **체계마다 다른 파일 형식(CSV, JSON, 탭 구분 엑셀 내보내기)과 품목코드·부대코드·날짜 형식을 표준 키로 통합하고 결측 데이터를 전처리하는 파이프라인을 구축한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **세 체계의 가상 보고를 메모리 안의 문자열로 만들어 형식별 파서로 읽고, 머리글 별칭 사전으로 양식 변경에 대응 수행하고 검증하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `src/connectors/dat09_data_loader.py 및 스키마 정의서` 형태의 결과물을 완성한다.\n", + "\n", + "### 🔍 평가 및 검증 기준 (PASS 조건)\n", + "#### 미션 판정\n", + "\n", + "**평가 구분:** 필수\n", + "\n", + "**핵심 평가영역:** 국방 서식/양식 변경에 대한 유연한 확장성\n", + "\n", + "#### 미션 PASS 조건\n", + "\n", + "- 정량 기준: 지원 파일 포맷(CSV, Excel, JSON 등) 파싱 성공률 100%\n", + "\n", + "\n", + "- 진행 기준: 데이터 로더 및 스키마 유효성 검사 모듈 완성\n", + "\n", + "\n", + "- 검증 방법: 다양한 형태의 샘플 군수 문서 대상 파싱 테스트\n", + "\n", + "\n", + "#### 채점자 판정 메모\n", + "\n", + "> 단순 코드 실행 여부뿐만 아니라 출력된 수치와 근거 자료가 군 실무 요구사항에 부합하는지 면밀히 확인한다.\n", + "\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "\"\"\"체계별로 다른 형식(CSV·JSON·탭 구분 엑셀 내보내기)의 가상 재고 보고를 메모리 안에서 파싱하고, 품목코드·부대코드·날짜 형식을 표준 키로 통합하며 결측값을 체계 간 교차 값으로 보완하는 전처리 파이프라인을 구축한다.\"\"\"\n", + "import csv\n", + "import datetime\n", + "import io\n", + "import json\n", + "import numpy as np\n", + "import pandas as pd\n", + "\n", + "# ---------------------------------------------------------------------------\n", + "# [교육용가상자료] 가상 이기종 군수정보체계 3종(A 보급, B 정비, C 탄약)의 재고 보고 생성기\n", + "# 실제 체계명·부대·품목·수량 없음. 같은 (품목, 부대) 재고를 세 체계가 서로 다른 코드·날짜 형식으로 보고한다.\n", + "# 기준 날짜: 2026-09-30(가상). 마스터(정답) 재고 TRUE 와 체계별 보고에는 의도적 불일치·결측이 섞여 있다.\n", + "# ---------------------------------------------------------------------------\n", + "N_PART, N_UNIT = 60, 12\n", + "CATS = [\"급식\", \"피복\", \"유류\", \"정비소모\", \"수리부속\", \"탄약\"]\n", + "PARTS = {p: {\"name\": f\"가상품목{p:02d}\", \"cat\": CATS[(p - 1) % 6]} for p in range(1, N_PART + 1)}\n", + "FMT = {\"A\": dict(part=lambda p: f\"AP-{p:05d}\", unit=lambda u: f\"U{u:02d}\", date=lambda d: d.strftime(\"%Y%m%d\")),\n", + " \"B\": dict(part=lambda p: f\"{p:05d}-X\", unit=lambda u: f\"가상{u:02d}대대\", date=lambda d: d.strftime(\"%Y-%m-%d\")),\n", + " \"C\": dict(part=lambda p: f\"P{p}\", unit=lambda u: str(u), date=lambda d: d.strftime(\"%d/%m/%Y\"))}\n", + "BASE_DATE = datetime.date(2026, 9, 30)\n", + "\n", + "\n", + "def make_systems(seed=9, mismatch=0.10, missing=0.03):\n", + " \"\"\"TRUE(정답 재고)와 체계별 원본 보고 레코드(체계 고유 형식)를 만든다.\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " true = {(p, u): int(rng.integers(20, 500)) for p in PARTS for u in range(1, N_UNIT + 1)}\n", + " out = {s: [] for s in \"ABC\"}\n", + " truth_diff = {} # (체계, p, u) -> 불일치 원인(정답)\n", + " for (p, u), q in true.items():\n", + " for s in \"ABC\":\n", + " qty, cause = q, None\n", + " r = rng.random()\n", + " if r < mismatch / 3: # 입력 오류(자릿수/오타)\n", + " qty, cause = q + int(rng.choice([-1, 1]) * rng.integers(10, 60)), \"입력 오류\"\n", + " elif r < 2 * mismatch / 3: # 반영 지연(전일 값)\n", + " qty, cause = q + int(rng.integers(5, 40)), \"반영 지연\"\n", + " elif r < mismatch: # 단위 오류(10배)\n", + " qty, cause = q * 10, \"단위 오류\"\n", + " d = BASE_DATE - datetime.timedelta(days=int(cause == \"반영 지연\"))\n", + " if rng.random() < missing: # 결측\n", + " qty, cause = None, \"결측\"\n", + " if cause:\n", + " truth_diff[(s, p, u)] = cause\n", + " out[s].append({\"part\": FMT[s][\"part\"](p), \"unit\": FMT[s][\"unit\"](u), \"date\": FMT[s][\"date\"](d), \"qty\": qty})\n", + " return true, out, truth_diff\n", + "\n", + "\n", + "def parse_part(code):\n", + " # [TODO : 학생 실습 2] 서로 다른 부품코드, 부대코드, 날짜 포맷 통합 및 결측 데이터 전처리 파이프라인 구축 관련 핵심 로직을 구현하세요.\n", + " # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n", + " # <<< 여기에 코드를 작성하세요 >>>\n", + " pass # 작성 후 pass를 제거하세요.\n", + "def parse_unit(code):\n", + " # [TODO : 학생 실습 2] 서로 다른 부품코드, 부대코드, 날짜 포맷 통합 및 결측 데이터 전처리 파이프라인 구축 관련 핵심 로직을 구현하세요.\n", + " # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n", + " # <<< 여기에 코드를 작성하세요 >>>\n", + " pass # 작성 후 pass를 제거하세요.\n", + "def parse_date(s):\n", + " if \"/\" in s:\n", + " d, m, y = s.split(\"/\"); return datetime.date(int(y), int(m), int(d))\n", + " if \"-\" in s:\n", + " return datetime.date(*map(int, s.split(\"-\")))\n", + " return datetime.date(int(s[:4]), int(s[4:6]), int(s[6:]))\n", + "\n", + "true, raw, tdiff = make_systems()\n", + "ALIAS = {\"part\": [\"part\", \"부품코드\", \"품목코드\"], \"unit\": [\"unit\", \"부대\", \"부대코드\"], \"date\": [\"date\", \"기준일\", \"일자\"], \"qty\": [\"qty\", \"수량\", \"재고\"]}\n", + "H2F = {h: f for f, hs in ALIAS.items() for h in hs}\n", + "\n", + "# --- 가상 원본 문자열 3종: 체계별 형식과 결측 표기가 다르다 ---\n", + "txt_a = \"\\n\".join([\"part,unit,date,qty\"] + [f\"{r['part']},{r['unit']},{r['date']},{'' if r['qty'] is None else r['qty']}\" for r in raw[\"A\"]])\n", + "txt_b = json.dumps({\"system\": \"B\", \"items\": [{\"부품코드\": r[\"part\"], \"부대\": r[\"unit\"], \"기준일\": r[\"date\"], \"수량\": r[\"qty\"]} for r in raw[\"B\"]]}, ensure_ascii=False)\n", + "txt_c = \"\\n\".join([\"품목코드\\t부대코드\\t일자\\t재고\"] + [f\"{r['part']}\\t{r['unit']}\\t{r['date']}\\t{-1 if r['qty'] is None else r['qty']}\" for r in raw[\"C\"]])\n", + "TEXT = {\"A\": txt_a, \"B\": txt_b, \"C\": txt_c}\n", + "# 파싱 실패 유발 줄 추가(보류 대상)\n", + "TEXT[\"A\"] += \"\\nAP-9999x,U01,20260930,12\\nAP-00001,U99,20260930,12\"\n", + "\n", + "def norm(row, system):\n", + " r = {H2F[k.strip()]: v for k, v in row.items() if k.strip() in H2F}\n", + " q = r[\"qty\"]\n", + " if system == \"A\":\n", + " q = None if q in (\"\", None) else int(q)\n", + " elif system == \"B\":\n", + " q = None if q is None else int(q)\n", + " else:\n", + " q = None if int(q) == -1 else int(q)\n", + " return {\"sys\": system, \"p\": parse_part(r[\"part\"]), \"u\": parse_unit(r[\"unit\"]), \"date\": parse_date(r[\"date\"]), \"qty\": q}\n", + "\n", + "READ = {\"A\": lambda s: list(csv.DictReader(io.StringIO(s))), \"B\": lambda s: json.loads(s)[\"items\"],\n", + " \"C\": lambda s: list(csv.DictReader(io.StringIO(s), delimiter=\"\\t\"))}\n", + "ok_fmt, recs, hold = {}, [], []\n", + "for sysname, text in TEXT.items():\n", + " try:\n", + " rows = READ[sysname](text); ok_fmt[sysname] = True\n", + " except Exception:\n", + " rows = []; ok_fmt[sysname] = False\n", + " for r in rows:\n", + " try:\n", + " n = norm(r, sysname)\n", + " if not (1 <= n[\"p\"] <= N_PART and 1 <= n[\"u\"] <= N_UNIT):\n", + " raise ValueError(\"키 범위 밖\")\n", + " recs.append(n)\n", + " except Exception as ex:\n", + " hold.append((sysname, str(r)[:60], str(ex)[:20]))\n", + "df = pd.DataFrame(recs)\n", + "print(f\"[형식별 파싱] A(CSV) {'성공' if ok_fmt['A'] else '실패'}, B(JSON) {'성공' if ok_fmt['B'] else '실패'}, C(탭 구분) {'성공' if ok_fmt['C'] else '실패'}\")\n", + "print(f\"표준화 {len(df)}건(체계별 {df['sys'].value_counts().to_dict()}), 반영 보류 {len(hold)}건\")\n", + "for h in hold:\n", + " print(\" 보류:\", h)\n", + "rate = sum(ok_fmt.values()) / len(ok_fmt)\n", + "\n", + "# --- 표준 키 통합: (품목, 부대) 기준으로 세 체계를 한 행에 ---\n", + "wide = df.pivot_table(index=[\"p\", \"u\"], columns=\"sys\", values=\"qty\", aggfunc=\"first\")\n", + "print(f\"\\n통합 키 {len(wide)}개(기대 {N_PART * N_UNIT}), 결측 값 {int(wide.isna().sum().sum())}건 (A {wide['A'].isna().sum()}, B {wide['B'].isna().sum()}, C {wide['C'].isna().sum()})\")\n", + "# --- 결측 전처리: 다른 두 체계 값 중 작은 값(단위 10배 오류 방지)을 후보로 보완하고 표시, 둘 다 없으면 미해결 ---\n", + "filled = wide.copy(); flag = pd.DataFrame(\"\", index=wide.index, columns=wide.columns)\n", + "for s in \"ABC\":\n", + " others = [c for c in \"ABC\" if c != s]\n", + " m = wide[s].isna()\n", + " vals = wide.loc[m, others]\n", + " fill = vals.min(axis=1)\n", + " filled.loc[m, s] = fill\n", + " flag.loc[m & fill.notna(), s] = \"보완\"\n", + " flag.loc[m & fill.isna(), s] = \"미해결\"\n", + "n_fill = int((flag == \"보완\").sum().sum()); n_un = int((flag == \"미해결\").sum().sum())\n", + "print(f\"결측 보완 {n_fill}건(다른 체계 값 기반 후보값, 확정 아님), 미해결 {n_un}건\")\n", + "t = true\n", + "exact = np.mean([filled.loc[(p, u), s] == t[(p, u)] for (p, u) in list(wide.index)[:720] for s in \"ABC\" if flag.loc[(p, u), s] == \"보완\"])\n", + "print(f\"보완값이 마스터와 같은 비율(가상 정답 대조) {exact:.0%} -> 보완값은 '후보'로 표시하고 마스터 확정은 사람이 한다\")\n", + "print(\"\\n표준 통합표 미리보기(3행):\")\n", + "print(filled.head(3).astype(\"float\").to_string())\n", + "print(f\"\\n목표(3개 형식 파싱 성공률 100%, 양식 변경 대응, 결측 전처리): {'달성' if rate == 1 and len(wide) == 720 and len(hold) == 2 else '미달'}\")\n", + "print(\"새 머리글 대응은 ALIAS 사전에 별칭만 추가하면 된다(코드 수정 불필요).\")\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 02 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + "[형식별 파싱] A(CSV) 성공, B(JSON) 성공, C(탭 구분) 성공\n", + "표준화 2160건(체계별 {'A': 720, 'B': 720, 'C': 720}), 반영 보류 2건\n", + " 보류: ('A', \"{'part': 'AP-9999x', 'unit': 'U01', 'date': '20260930', 'qty\", 'invalid literal for ')\n", + " 보류: ('A', \"{'part': 'AP-00001', 'unit': 'U99', 'date': '20260930', 'qty\", '키 범위 밖')\n", + "\n", + "통합 키 720개(기대 720), 결측 값 46건 (A 18, B 14, C 14)\n", + "결측 보완 46건(다른 체계 값 기반 후보값, 확정 아님), 미해결 0건\n", + "보완값이 마스터와 같은 비율(가상 정답 대조) 100% -> 보완값은 '후보'로 표시하고 마스터 확정은 사람이 한다\n", + "\n", + "표준 통합표 미리보기(3행):\n", + "sys A B C\n", + "p u \n", + "1 1 222.0 222.0 222.0\n", + " 2 437.0 437.0 437.0\n", + " 3 444.0 481.0 481.0\n", + "\n", + "목표(3개 형식 파싱 성공률 100%, 양식 변경 대응, 결측 전처리): 달성\n", + "새 머리글 대응은 ALIAS 사전에 별칭만 추가하면 된다(코드 수정 불필요).\n", + "```\n", + "
\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 03. 통합 데이터베이스 1차 구축 및 핵심 군수 지표 집계 Baseline 쿼리 모듈 구현\n", + "**소요시간**: 약 2.5시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "통합 DB 구축 및 핵심 군수 지표 집계 Baseline 쿼리 모듈을 구현한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **세 체계의 표준화 재고를 통합 데이터 저장소에 구축하고, 검증 룰 15종과 핵심 군수 지표 집계 Baseline 쿼리를 구현한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **표준화한 2,160건을 하나의 표로 쌓아 통합 저장소로 삼고, 룰 15종을 가상 조항 번호가 붙은 설정 문자열로 분리해 위반 건수와 처리 지연시간을 측정 수행하고 검증하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `src/engine/dat09_rule_engine.py` 형태의 결과물을 완성한다.\n", + "\n", + "### 🔍 평가 및 검증 기준 (PASS 조건)\n", + "#### 미션 판정\n", + "\n", + "**평가 구분:** 필수\n", + "\n", + "**핵심 평가영역:** 군 규정 및 지침 조항과의 정확한 일치성\n", + "\n", + "#### 미션 PASS 조건\n", + "\n", + "- 정량 기준: 업무 검증 룰 15종 이상 코딩, 룰 처리 지연시간 50ms 이하\n", + "\n", + "\n", + "- 진행 기준: 룰 엔진 단위 테스트 100% 통과\n", + "\n", + "\n", + "- 검증 방법: 경계 조건(Boundary condition)에 대한 룰 판정 검증\n", + "\n", + "\n", + "#### 채점자 판정 메모\n", + "\n", + "> 단순 코드 실행 여부뿐만 아니라 출력된 수치와 근거 자료가 군 실무 요구사항에 부합하는지 면밀히 확인한다.\n", + "\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "\"\"\"세 체계의 표준화 재고를 하나의 통합 데이터 저장소(표)로 구축하고, 설정 문자열로 분리한 검증 룰 15종과 핵심 군수 지표(재고 합계·부족 품목·체계 간 일치율) 집계 Baseline 쿼리를 구현해 처리 지연시간과 경계 조건 테스트를 확인한다.\"\"\"\n", + "import datetime\n", + "import time\n", + "import numpy as np\n", + "import pandas as pd\n", + "\n", + "# ---------------------------------------------------------------------------\n", + "# [교육용가상자료] 가상 이기종 군수정보체계 3종(A 보급, B 정비, C 탄약)의 재고 보고 생성기\n", + "# 실제 체계명·부대·품목·수량 없음. 같은 (품목, 부대) 재고를 세 체계가 서로 다른 코드·날짜 형식으로 보고한다.\n", + "# 기준 날짜: 2026-09-30(가상). 마스터(정답) 재고 TRUE 와 체계별 보고에는 의도적 불일치·결측이 섞여 있다.\n", + "# ---------------------------------------------------------------------------\n", + "N_PART, N_UNIT = 60, 12\n", + "CATS = [\"급식\", \"피복\", \"유류\", \"정비소모\", \"수리부속\", \"탄약\"]\n", + "PARTS = {p: {\"name\": f\"가상품목{p:02d}\", \"cat\": CATS[(p - 1) % 6]} for p in range(1, N_PART + 1)}\n", + "FMT = {\"A\": dict(part=lambda p: f\"AP-{p:05d}\", unit=lambda u: f\"U{u:02d}\", date=lambda d: d.strftime(\"%Y%m%d\")),\n", + " \"B\": dict(part=lambda p: f\"{p:05d}-X\", unit=lambda u: f\"가상{u:02d}대대\", date=lambda d: d.strftime(\"%Y-%m-%d\")),\n", + " \"C\": dict(part=lambda p: f\"P{p}\", unit=lambda u: str(u), date=lambda d: d.strftime(\"%d/%m/%Y\"))}\n", + "BASE_DATE = datetime.date(2026, 9, 30)\n", + "\n", + "\n", + "def make_systems(seed=9, mismatch=0.10, missing=0.03):\n", + " \"\"\"TRUE(정답 재고)와 체계별 원본 보고 레코드(체계 고유 형식)를 만든다.\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " true = {(p, u): int(rng.integers(20, 500)) for p in PARTS for u in range(1, N_UNIT + 1)}\n", + " out = {s: [] for s in \"ABC\"}\n", + " truth_diff = {} # (체계, p, u) -> 불일치 원인(정답)\n", + " for (p, u), q in true.items():\n", + " for s in \"ABC\":\n", + " qty, cause = q, None\n", + " r = rng.random()\n", + " if r < mismatch / 3: # 입력 오류(자릿수/오타)\n", + " qty, cause = q + int(rng.choice([-1, 1]) * rng.integers(10, 60)), \"입력 오류\"\n", + " elif r < 2 * mismatch / 3: # 반영 지연(전일 값)\n", + " qty, cause = q + int(rng.integers(5, 40)), \"반영 지연\"\n", + " elif r < mismatch: # 단위 오류(10배)\n", + " qty, cause = q * 10, \"단위 오류\"\n", + " d = BASE_DATE - datetime.timedelta(days=int(cause == \"반영 지연\"))\n", + " if rng.random() < missing: # 결측\n", + " qty, cause = None, \"결측\"\n", + " if cause:\n", + " truth_diff[(s, p, u)] = cause\n", + " out[s].append({\"part\": FMT[s][\"part\"](p), \"unit\": FMT[s][\"unit\"](u), \"date\": FMT[s][\"date\"](d), \"qty\": qty})\n", + " return true, out, truth_diff\n", + "\n", + "\n", + "def parse_part(code):\n", + " # [TODO : 학생 실습 3] 통합 데이터베이스 1차 구축 및 핵심 군수 지표 집계 Baseline 쿼리 모듈 구현 관련 핵심 로직을 구현하세요.\n", + " # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n", + " # <<< 여기에 코드를 작성하세요 >>>\n", + " pass # 작성 후 pass를 제거하세요.\n", + "def parse_unit(code):\n", + " # [TODO : 학생 실습 3] 통합 데이터베이스 1차 구축 및 핵심 군수 지표 집계 Baseline 쿼리 모듈 구현 관련 핵심 로직을 구현하세요.\n", + " # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n", + " # <<< 여기에 코드를 작성하세요 >>>\n", + " pass # 작성 후 pass를 제거하세요.\n", + "def parse_date(s):\n", + " if \"/\" in s:\n", + " d, m, y = s.split(\"/\"); return datetime.date(int(y), int(m), int(d))\n", + " if \"-\" in s:\n", + " return datetime.date(*map(int, s.split(\"-\")))\n", + " return datetime.date(int(s[:4]), int(s[4:6]), int(s[6:]))\n", + "\n", + "true, raw, tdiff = make_systems()\n", + "rows = []\n", + "for s in \"ABC\":\n", + " for r in raw[s]:\n", + " rows.append({\"sys\": s, \"p\": parse_part(r[\"part\"]), \"u\": parse_unit(r[\"unit\"]), \"date\": parse_date(r[\"date\"]), \"qty\": r[\"qty\"]})\n", + "store = pd.DataFrame(rows) # 통합 데이터 저장소(체계 3개 x 720키)\n", + "store[\"cat\"] = store[\"p\"].map(lambda p: PARTS[p][\"cat\"])\n", + "store[\"qty_f\"] = store[\"qty\"].astype(\"float\")\n", + "\n", + "# 1) 검증 룰 15종(설정): (ID, 가상 조항, 조건식(참이면 정상), 메시지)\n", + "RULES = [(\"D01\", \"가상지침 1조\", \"1 <= p <= 60\", \"품목 키 범위 밖\"), (\"D02\", \"가상지침 1조\", \"1 <= u <= 12\", \"부대 키 범위 밖\"),\n", + " (\"D03\", \"가상지침 2조\", \"sys in ('A', 'B', 'C')\", \"알 수 없는 체계\"), (\"D04\", \"가상지침 3조\", \"qty is not None\", \"수량 결측\"),\n", + " (\"D05\", \"가상지침 3조\", \"qty is None or qty >= 0\", \"수량 음수\"), (\"D06\", \"가상지침 3조\", \"qty is None or qty <= 5000\", \"수량 상한 초과\"),\n", + " (\"D07\", \"가상지침 4조\", \"isinstance(p, int) and isinstance(u, int)\", \"키 자료형 오류\"), (\"D08\", \"가상지침 4조\", \"date <= TODAY\", \"미래 날짜\"),\n", + " (\"D09\", \"가상지침 5조\", \"(TODAY - date).days <= 3\", \"3일 초과 지연 보고\"), (\"D10\", \"가상지침 5조\", \"(TODAY - date).days >= 0\", \"기준일 이후 날짜\"),\n", + " (\"D11\", \"가상지침 6조\", \"cat in CATS\", \"분류 코드 오류\"), (\"D12\", \"가상지침 6조\", \"qty is None or qty % 1 == 0\", \"수량 소수 불가\"),\n", + " (\"D13\", \"가상지침 7조\", \"not (sys == 'C' and cat != '탄약' and qty is not None and qty > 3000)\", \"탄약체계 비탄약 대량 수량\"),\n", + " (\"D14\", \"가상지침 7조\", \"qty is None or qty < 2000 or qty % 10 != 0\", \"10배 단위 오류 의심(2000 이상 10의 배수)\"),\n", + " (\"D15\", \"가상지침 8조\", \"u != 0\", \"부대 0 사용 불가\")]\n", + "TODAY = BASE_DATE\n", + "CODE = [(i, a, compile(e, i, \"eval\"), m) for i, a, e, m in RULES]\n", + "SAFE = {\"__builtins__\": {\"isinstance\": isinstance, \"int\": int}}\n", + "\n", + "\n", + "def check(rec):\n", + " env = {\"CATS\": CATS, \"TODAY\": TODAY, **rec}\n", + " bad = []\n", + " for i, a, c, m in CODE:\n", + " try:\n", + " ok = eval(c, SAFE, env)\n", + " except Exception:\n", + " ok = False\n", + " if not ok:\n", + " bad.append((i, m))\n", + " return bad\n", + "\n", + "\n", + "recs = store[[\"sys\", \"p\", \"u\", \"date\", \"qty\", \"cat\"]].astype(object).where(store[[\"sys\", \"p\", \"u\", \"date\", \"qty\", \"cat\"]].notna(), None).to_dict(\"records\")\n", + "lat, viol = [], {}\n", + "for r in recs:\n", + " t0 = time.perf_counter(); res = check(r); lat.append((time.perf_counter() - t0) * 1000)\n", + " for i, m in res:\n", + " viol[i] = viol.get(i, 0) + 1\n", + "lat = np.array(lat)\n", + "print(f\"[검증 룰 {len(RULES)}종] 대상 {len(recs)}건, 룰 위반 {sum(viol.values())}건 [교육용가상자료]\")\n", + "print(\"룰별 위반:\", dict(sorted(viol.items())))\n", + "print(f\"룰 처리 지연 평균 {lat.mean():.3f}ms, 최대 {lat.max():.3f}ms (목표 50ms 이하: {'달성' if lat.max() <= 50 else '미달'}, 환경에 따라 다름)\")\n", + "\n", + "# 2) Baseline 지표 쿼리: 함수로 분리(체계 A를 기준 값으로)\n", + "def q_stock_by_cat(df, sysname=\"A\"):\n", + " return df[df[\"sys\"] == sysname].groupby(\"cat\")[\"qty_f\"].sum()\n", + "\n", + "\n", + "def q_low_stock(df, thr=60, sysname=\"A\"):\n", + " d = df[(df[\"sys\"] == sysname) & (df[\"qty_f\"] < thr)]\n", + " return d.groupby(\"p\")[\"u\"].count().sort_values(ascending=False)\n", + "\n", + "\n", + "def q_agreement(df):\n", + " w = df.pivot_table(index=[\"p\", \"u\"], columns=\"sys\", values=\"qty_f\")\n", + " ok = w.notna().all(axis=1)\n", + " return float((w[ok].nunique(axis=1) == 1).mean()), int(ok.sum())\n", + "\n", + "\n", + "t0 = time.perf_counter()\n", + "cat_sum, low, (agree, n_cmp) = q_stock_by_cat(store), q_low_stock(store), q_agreement(store)\n", + "print(\"\\n[Baseline 지표 쿼리] 쿼리 3개 실행\", f\"{(time.perf_counter() - t0) * 1000:.1f}ms\")\n", + "print(\"분류별 재고 합계(체계 A 기준):\", cat_sum.astype(int).to_dict())\n", + "print(\"재고 60 미만 부대가 많은 품목 상위 3:\", low.head(3).to_dict())\n", + "print(f\"세 체계 모두 보고한 {n_cmp}개 키 중 수량이 완전히 같은 비율(체계 간 일치율) {agree:.1%} -> 이후 미션에서 불일치 원인을 해결\")\n", + "\n", + "# 3) 경계 조건 단위 테스트\n", + "base = {\"sys\": \"A\", \"p\": 1, \"u\": 1, \"date\": TODAY, \"qty\": 100, \"cat\": \"급식\"}\n", + "tests = [(\"정상\", base, []), (\"수량 0 허용\", {**base, \"qty\": 0}, []), (\"수량 상한 5000 경계(D06 통과, D14 의심)\", {**base, \"qty\": 5000}, [\"D14\"]),\n", + " (\"수량 5001\", {**base, \"qty\": 5001}, [\"D06\"]), (\"지연 3일 허용\", {**base, \"date\": TODAY - datetime.timedelta(days=3)}, []),\n", + " (\"지연 4일\", {**base, \"date\": TODAY - datetime.timedelta(days=4)}, [\"D09\"]), (\"미래 날짜\", {**base, \"date\": TODAY + datetime.timedelta(days=1)}, [\"D08\", \"D10\"]),\n", + " (\"품목 키 61\", {**base, \"p\": 61}, [\"D01\"]), (\"수량 결측\", {**base, \"qty\": None}, [\"D04\"]), (\"수량 소수\", {**base, \"qty\": 10.5}, [\"D12\"])]\n", + "ok = 0\n", + "print(\"\\n[경계 조건 단위 테스트]\")\n", + "for n, r, exp in tests:\n", + " got = sorted(i for i, _ in check(r))\n", + " p = got == exp\n", + " ok += p\n", + " print(f\" - {n}: 기대 {exp or '위반 없음'} / 결과 {got or '위반 없음'} -> {'PASS' if p else 'FAIL'}\")\n", + "print(f\"단위 테스트 {ok}/{len(tests)} 통과({ok / len(tests):.0%})\")\n", + "print(f\"목표(룰 15종 이상, 지연 50ms 이하, 단위 테스트 100%): {'달성' if len(RULES) >= 15 and lat.max() <= 50 and ok == len(tests) else '미달'}\")\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 03 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + "[검증 룰 15종] 대상 2160건, 룰 위반 107건 [교육용가상자료]\n", + "룰별 위반: {'D04': 46, 'D05': 3, 'D13': 11, 'D14': 47}\n", + "룰 처리 지연 평균 0.006ms, 최대 0.185ms (목표 50ms 이하: 달성, 환경에 따라 다름)\n", + "\n", + "[Baseline 지표 쿼리] 쿼리 3개 실행 32.7ms\n", + "분류별 재고 합계(체계 A 기준): {'급식': 33087, '수리부속': 42203, '유류': 40597, '정비소모': 38378, '탄약': 39855, '피복': 39222}\n", + "재고 60 미만 부대가 많은 품목 상위 3: {2: 3, 10: 3, 60: 3}\n", + "세 체계 모두 보고한 677개 키 중 수량이 완전히 같은 비율(체계 간 일치율) 74.0% -> 이후 미션에서 불일치 원인을 해결\n", + "\n", + "[경계 조건 단위 테스트]\n", + " - 정상: 기대 위반 없음 / 결과 위반 없음 -> PASS\n", + " - 수량 0 허용: 기대 위반 없음 / 결과 위반 없음 -> PASS\n", + " - 수량 상한 5000 경계(D06 통과, D14 의심): 기대 ['D14'] / 결과 ['D14'] -> PASS\n", + " - 수량 5001: 기대 ['D06'] / 결과 ['D06'] -> PASS\n", + " - 지연 3일 허용: 기대 위반 없음 / 결과 위반 없음 -> PASS\n", + " - 지연 4일: 기대 ['D09'] / 결과 ['D09'] -> PASS\n", + " - 미래 날짜: 기대 ['D08', 'D10'] / 결과 ['D08', 'D10'] -> PASS\n", + " - 품목 키 61: 기대 ['D01'] / 결과 ['D01'] -> PASS\n", + " - 수량 결측: 기대 ['D04'] / 결과 ['D04'] -> PASS\n", + " - 수량 소수: 기대 ['D12'] / 결과 ['D12'] -> PASS\n", + "단위 테스트 10/10 통과(100%)\n", + "목표(룰 15종 이상, 지연 50ms 이하, 단위 테스트 100%): 달성\n", + "```\n", + "
\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 04. 실시간 데이터 스트리밍(Kafka/CDC) 기반 이기종 데이터 정합성 유지 및 자동 동기화 고도화\n", + "**소요시간**: 약 3.0시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "실시간 데이터 스트리밍 기반 이기종 데이터 정합성 유지 및 자동 동기화를 고도화한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **변경 이벤트 스트림(CDC 방식을 메모리에서 모의)으로 체계 간 정합성을 유지하고 불일치 원인을 분류해 마스터 값 후보를 추천하는 모듈을 고도화한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **외부 스트리밍 도구 없이 메모리 안의 큐로 변경 이벤트를 만들고, 중복 전송과 순서가 뒤바뀐 오래된 값이 섞인 상태에서 이벤트 ID 중복 제거와 키별 최신 시각 우선 규칙으로 동기화 수행하고 검증하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `src/ai/dat09_intelligence_module.py` 형태의 결과물을 완성한다.\n", + "\n", + "### 🔍 평가 및 검증 기준 (PASS 조건)\n", + "#### 미션 판정\n", + "\n", + "**평가 구분:** 필수\n", + "\n", + "**핵심 평가영역:** 추천 사유의 합리성 및 실무 수용성\n", + "\n", + "#### 미션 PASS 조건\n", + "\n", + "- 정량 기준: 추천/분류 정확도 85% 이상 달성\n", + "\n", + "\n", + "- 진행 기준: AI 추론 모듈 구현 및 벤치마크 테스트 완료\n", + "\n", + "\n", + "- 검증 방법: 과거 승인/처리 완료 데이터와의 추천 일치도 검증\n", + "\n", + "\n", + "#### 채점자 판정 메모\n", + "\n", + "> 단순 코드 실행 여부뿐만 아니라 출력된 수치와 근거 자료가 군 실무 요구사항에 부합하는지 면밀히 확인한다.\n", + "\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "\"\"\"변경 이벤트 스트림(CDC 방식 모의)을 순서 뒤바뀜·중복 이벤트까지 처리해 통합 저장소에 자동 동기화하고, 체계 간 불일치의 원인을 분류해 마스터 값 후보를 추천하는 모듈을 구현해 분류·추천 정확도를 측정한다.\"\"\"\n", + "import collections\n", + "import datetime\n", + "import numpy as np\n", + "import pandas as pd\n", + "\n", + "# ---------------------------------------------------------------------------\n", + "# [교육용가상자료] 가상 이기종 군수정보체계 3종(A 보급, B 정비, C 탄약)의 재고 보고 생성기\n", + "# 실제 체계명·부대·품목·수량 없음. 같은 (품목, 부대) 재고를 세 체계가 서로 다른 코드·날짜 형식으로 보고한다.\n", + "# 기준 날짜: 2026-09-30(가상). 마스터(정답) 재고 TRUE 와 체계별 보고에는 의도적 불일치·결측이 섞여 있다.\n", + "# ---------------------------------------------------------------------------\n", + "N_PART, N_UNIT = 60, 12\n", + "CATS = [\"급식\", \"피복\", \"유류\", \"정비소모\", \"수리부속\", \"탄약\"]\n", + "PARTS = {p: {\"name\": f\"가상품목{p:02d}\", \"cat\": CATS[(p - 1) % 6]} for p in range(1, N_PART + 1)}\n", + "FMT = {\"A\": dict(part=lambda p: f\"AP-{p:05d}\", unit=lambda u: f\"U{u:02d}\", date=lambda d: d.strftime(\"%Y%m%d\")),\n", + " \"B\": dict(part=lambda p: f\"{p:05d}-X\", unit=lambda u: f\"가상{u:02d}대대\", date=lambda d: d.strftime(\"%Y-%m-%d\")),\n", + " \"C\": dict(part=lambda p: f\"P{p}\", unit=lambda u: str(u), date=lambda d: d.strftime(\"%d/%m/%Y\"))}\n", + "BASE_DATE = datetime.date(2026, 9, 30)\n", + "\n", + "\n", + "def make_systems(seed=9, mismatch=0.10, missing=0.03):\n", + " \"\"\"TRUE(정답 재고)와 체계별 원본 보고 레코드(체계 고유 형식)를 만든다.\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " true = {(p, u): int(rng.integers(20, 500)) for p in PARTS for u in range(1, N_UNIT + 1)}\n", + " out = {s: [] for s in \"ABC\"}\n", + " truth_diff = {} # (체계, p, u) -> 불일치 원인(정답)\n", + " for (p, u), q in true.items():\n", + " for s in \"ABC\":\n", + " qty, cause = q, None\n", + " r = rng.random()\n", + " if r < mismatch / 3: # 입력 오류(자릿수/오타)\n", + " qty, cause = q + int(rng.choice([-1, 1]) * rng.integers(10, 60)), \"입력 오류\"\n", + " elif r < 2 * mismatch / 3: # 반영 지연(전일 값)\n", + " qty, cause = q + int(rng.integers(5, 40)), \"반영 지연\"\n", + " elif r < mismatch: # 단위 오류(10배)\n", + " qty, cause = q * 10, \"단위 오류\"\n", + " d = BASE_DATE - datetime.timedelta(days=int(cause == \"반영 지연\"))\n", + " if rng.random() < missing: # 결측\n", + " qty, cause = None, \"결측\"\n", + " if cause:\n", + " truth_diff[(s, p, u)] = cause\n", + " out[s].append({\"part\": FMT[s][\"part\"](p), \"unit\": FMT[s][\"unit\"](u), \"date\": FMT[s][\"date\"](d), \"qty\": qty})\n", + " return true, out, truth_diff\n", + "\n", + "\n", + "def parse_part(code):\n", + " # [TODO : 학생 실습 4] 실시간 데이터 스트리밍(Kafka/CDC) 기반 이기종 데이터 정합성 유지 및 자동 동기화 고도화 관련 핵심 로직을 구현하세요.\n", + " # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n", + " # <<< 여기에 코드를 작성하세요 >>>\n", + " pass # 작성 후 pass를 제거하세요.\n", + "def parse_unit(code):\n", + " # [TODO : 학생 실습 4] 실시간 데이터 스트리밍(Kafka/CDC) 기반 이기종 데이터 정합성 유지 및 자동 동기화 고도화 관련 핵심 로직을 구현하세요.\n", + " # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n", + " # <<< 여기에 코드를 작성하세요 >>>\n", + " pass # 작성 후 pass를 제거하세요.\n", + "def parse_date(s):\n", + " if \"/\" in s:\n", + " d, m, y = s.split(\"/\"); return datetime.date(int(y), int(m), int(d))\n", + " if \"-\" in s:\n", + " return datetime.date(*map(int, s.split(\"-\")))\n", + " return datetime.date(int(s[:4]), int(s[4:6]), int(s[6:]))\n", + "\n", + "true, raw, tdiff = make_systems()\n", + "rng = np.random.default_rng(4)\n", + "\n", + "# 1) 이벤트 스트림 생성: 각 체계 보고를 (체계, 키, 값, 시각, 이벤트ID) 이벤트로 바꾸고 중복·순서 뒤바뀜을 주입\n", + "events = []\n", + "for s in \"ABC\":\n", + " for i, r in enumerate(raw[s]):\n", + " events.append({\"id\": f\"{s}{i:04d}\", \"sys\": s, \"p\": parse_part(r[\"part\"]), \"u\": parse_unit(r[\"unit\"]), \"qty\": r[\"qty\"],\n", + " \"ts\": int(rng.integers(0, 10000)), \"day\": parse_date(r[\"date\"])})\n", + "dups = [dict(events[i]) for i in rng.choice(len(events), 120, replace=False)] # 중복 전송 120건\n", + "stale = [] # 같은 키의 오래된 값(늦게 도착한 옛 이벤트) 80건\n", + "for i in rng.choice(len(events), 80, replace=False):\n", + " e = dict(events[i]); e[\"id\"] += \"-old\"; e[\"ts\"] = e[\"ts\"] - 5000; e[\"qty\"] = (e[\"qty\"] or 0) + 77\n", + " stale.append(e)\n", + "stream = events + dups + stale\n", + "rng.shuffle(stream) # 도착 순서 뒤섞기\n", + "print(f\"[스트림] 이벤트 {len(stream)}건 (정상 {len(events)}, 중복 {len(dups)}, 오래된 값 {len(stale)}), 도착 순서 무작위\")\n", + "\n", + "# 2) 자동 동기화: 이벤트 ID 중복 제거 + 키별 최신 시각 우선(last-write-wins)\n", + "store, seen, applied, skipped_dup, skipped_old = {}, set(), 0, 0, 0\n", + "queue = collections.deque(stream)\n", + "while queue:\n", + " e = queue.popleft()\n", + " if e[\"id\"] in seen:\n", + " skipped_dup += 1; continue\n", + " seen.add(e[\"id\"])\n", + " k = (e[\"sys\"], e[\"p\"], e[\"u\"])\n", + " if k in store and store[k][\"ts\"] >= e[\"ts\"]:\n", + " skipped_old += 1; continue\n", + " store[k] = {\"qty\": e[\"qty\"], \"ts\": e[\"ts\"], \"day\": e[\"day\"]}; applied += 1\n", + "exp = {(e[\"sys\"], e[\"p\"], e[\"u\"]): e[\"qty\"] for e in events}\n", + "match = sum(store[k][\"qty\"] == v for k, v in exp.items())\n", + "print(f\"동기화: 적용 {applied}건, 중복 건너뜀 {skipped_dup}건, 오래된 값 건너뜀 {skipped_old}건\")\n", + "print(f\"일괄 스냅샷 대비 동기화 결과 일치 {match}/{len(exp)}건({match / len(exp):.1%}) -> 스트림 방식과 일괄 방식의 결과가 같아야 한다\")\n", + "\n", + "# 3) 불일치 감지·원인 분류·마스터 후보 추천\n", + "rows = []\n", + "for p in PARTS:\n", + " for u in range(1, N_UNIT + 1):\n", + " vals = {s: store[(s, p, u)] for s in \"ABC\"}\n", + " q = {s: v[\"qty\"] for s, v in vals.items()}\n", + " have = {s: x for s, x in q.items() if x is not None}\n", + " cnt = collections.Counter(have.values())\n", + " top, n_top = cnt.most_common(1)[0] if cnt else (None, 0)\n", + " for s in \"ABC\":\n", + " if q[s] == top and n_top >= 2:\n", + " continue\n", + " if q[s] is None:\n", + " cause = \"결측\"\n", + " elif n_top >= 2 and q[s] != top:\n", + " lag = (BASE_DATE - vals[s][\"day\"]).days\n", + " cause = \"단위 오류\" if q[s] == top * 10 else (\"반영 지연\" if lag >= 1 else \"입력 오류\")\n", + " else:\n", + " cause = \"판단 불가\" # 세 값이 모두 다르면 사람이 판단\n", + " rows.append({\"sys\": s, \"p\": p, \"u\": u, \"cause\": cause, \"recommend\": top if n_top >= 2 else None, \"true\": true[(p, u)]})\n", + "d = pd.DataFrame(rows)\n", + "d[\"truth_cause\"] = [tdiff.get((r.sys, r.p, r.u)) for r in d.itertuples()]\n", + "auto = d[d[\"cause\"] != \"판단 불가\"]\n", + "cause_acc = float((auto[\"cause\"] == auto[\"truth_cause\"]).mean())\n", + "rec_ok = float((auto[\"recommend\"] == auto[\"true\"]).mean())\n", + "true_cells = pd.Series(tdiff)\n", + "print(f\"\\n[불일치 분류] 불일치 셀 {len(d)}건 (정답상 불일치 {len(true_cells)}건), 자동 분류 {len(auto)}건, 사람 판단 필요 {len(d) - len(auto)}건\")\n", + "print(pd.crosstab(auto[\"truth_cause\"].fillna(\"정상(오탐)\"), auto[\"cause\"]).to_string())\n", + "print(f\"원인 분류 정확도 {cause_acc:.1%}, 마스터 값 후보(다수결) 정확도 {rec_ok:.1%}, 감지 재현율 {len(set(zip(d.sys, d.p, d.u)) & set(true_cells.index)) / len(true_cells):.1%}\")\n", + "print(\"추천 사유 예시:\")\n", + "for r in auto.head(3).itertuples():\n", + " print(f\" - 체계 {r.sys} 품목 {r.p} 부대 {r.u}: 원인 '{r.cause}', 다른 두 체계 다수값 {r.recommend}을(를) 마스터 후보로 제시\")\n", + "print(\"추천은 후보이며 마스터 데이터 확정은 데이터관리관 승인(미션 7)으로만 이루어진다.\")\n", + "print(f\"\\n목표(추천/분류 정확도 85% 이상): 분류 {'달성' if cause_acc >= 0.85 else '미달'}, 마스터 후보 {'달성' if rec_ok >= 0.85 else '미달'}\")\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 04 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + "[스트림] 이벤트 2360건 (정상 2160, 중복 120, 오래된 값 80), 도착 순서 무작위\n", + "동기화: 적용 2200건, 중복 건너뜀 120건, 오래된 값 건너뜀 40건\n", + "일괄 스냅샷 대비 동기화 결과 일치 2160/2160건(100.0%) -> 스트림 방식과 일괄 방식의 결과가 같아야 한다\n", + "\n", + "[불일치 분류] 불일치 셀 261건 (정답상 불일치 243건), 자동 분류 207건, 사람 판단 필요 54건\n", + "cause 결측 단위 오류 반영 지연 입력 오류\n", + "truth_cause \n", + "결측 46 0 0 0\n", + "단위 오류 0 57 0 0\n", + "반영 지연 0 0 53 0\n", + "입력 오류 0 0 0 49\n", + "정상(오탐) 0 0 0 2\n", + "원인 분류 정확도 99.0%, 마스터 값 후보(다수결) 정확도 94.7%, 감지 재현율 98.4%\n", + "추천 사유 예시:\n", + " - 체계 A 품목 1 부대 3: 원인 '입력 오류', 다른 두 체계 다수값 481.0을(를) 마스터 후보로 제시\n", + " - 체계 B 품목 1 부대 8: 원인 '반영 지연', 다른 두 체계 다수값 393.0을(를) 마스터 후보로 제시\n", + " - 체계 C 품목 1 부대 10: 원인 '반영 지연', 다른 두 체계 다수값 363.0을(를) 마스터 후보로 제시\n", + "추천은 후보이며 마스터 데이터 확정은 데이터관리관 승인(미션 7)으로만 이루어진다.\n", + "\n", + "목표(추천/분류 정확도 85% 이상): 분류 달성, 마스터 후보 달성\n", + "```\n", + "
\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 05. 체계 간 불일치 데이터 자동 식별 및 데이터 신뢰도 품질 지수 산출 엔진 개발\n", + "**소요시간**: 약 3.0시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "체계 간 불일치 데이터 자동 식별 및 품질 신뢰도 지수 산출 엔진을 개발한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **체계 간 불일치 데이터를 자동 식별하고 데이터 품질 지수를 산출하는 엔진을 입력-분석-검토-결과 4개 화면으로 구현한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **키별로 세 체계의 값을 비교해 값 불일치와 결측을 식별하고 편차가 큰 건을 우선 검토 대상으로 표시 수행하고 검증하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `src/ui/dat09_app.py` 형태의 결과물을 완성한다.\n", + "\n", + "### 🔍 평가 및 검증 기준 (PASS 조건)\n", + "#### 미션 판정\n", + "\n", + "**평가 구분:** 필수\n", + "\n", + "**핵심 평가영역:** 군 실무자 친화적 디자인 및 조작 편의성\n", + "\n", + "#### 미션 PASS 조건\n", + "\n", + "- 정량 기준: 핵심 화면 4종(입력-분석-검토-결과) 구현 완료\n", + "\n", + "\n", + "- 진행 기준: UI 프로토타입 로컬 서버 구동 및 전체 페이지 네비게이션 확인\n", + "\n", + "\n", + "- 검증 방법: 사용성 평가(UX Walkthrough) 및 인터랙션 테스트\n", + "\n", + "\n", + "#### 채점자 판정 메모\n", + "\n", + "> 단순 코드 실행 여부뿐만 아니라 출력된 수치와 근거 자료가 군 실무 요구사항에 부합하는지 면밀히 확인한다.\n", + "\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "\"\"\"세 체계 간 불일치 데이터를 자동 식별하고 완전성·일치성·적시성·유효성 4개 차원의 데이터 품질 지수를 부대별·체계별로 산출하며, 입력-분석-검토-결과 4개 화면을 텍스트 화면으로 구현한다.\"\"\"\n", + "import datetime\n", + "import numpy as np\n", + "import pandas as pd\n", + "\n", + "# ---------------------------------------------------------------------------\n", + "# [교육용가상자료] 가상 이기종 군수정보체계 3종(A 보급, B 정비, C 탄약)의 재고 보고 생성기\n", + "# 실제 체계명·부대·품목·수량 없음. 같은 (품목, 부대) 재고를 세 체계가 서로 다른 코드·날짜 형식으로 보고한다.\n", + "# 기준 날짜: 2026-09-30(가상). 마스터(정답) 재고 TRUE 와 체계별 보고에는 의도적 불일치·결측이 섞여 있다.\n", + "# ---------------------------------------------------------------------------\n", + "N_PART, N_UNIT = 60, 12\n", + "CATS = [\"급식\", \"피복\", \"유류\", \"정비소모\", \"수리부속\", \"탄약\"]\n", + "PARTS = {p: {\"name\": f\"가상품목{p:02d}\", \"cat\": CATS[(p - 1) % 6]} for p in range(1, N_PART + 1)}\n", + "FMT = {\"A\": dict(part=lambda p: f\"AP-{p:05d}\", unit=lambda u: f\"U{u:02d}\", date=lambda d: d.strftime(\"%Y%m%d\")),\n", + " \"B\": dict(part=lambda p: f\"{p:05d}-X\", unit=lambda u: f\"가상{u:02d}대대\", date=lambda d: d.strftime(\"%Y-%m-%d\")),\n", + " \"C\": dict(part=lambda p: f\"P{p}\", unit=lambda u: str(u), date=lambda d: d.strftime(\"%d/%m/%Y\"))}\n", + "BASE_DATE = datetime.date(2026, 9, 30)\n", + "\n", + "\n", + "def make_systems(seed=9, mismatch=0.10, missing=0.03):\n", + " \"\"\"TRUE(정답 재고)와 체계별 원본 보고 레코드(체계 고유 형식)를 만든다.\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " true = {(p, u): int(rng.integers(20, 500)) for p in PARTS for u in range(1, N_UNIT + 1)}\n", + " out = {s: [] for s in \"ABC\"}\n", + " truth_diff = {} # (체계, p, u) -> 불일치 원인(정답)\n", + " for (p, u), q in true.items():\n", + " for s in \"ABC\":\n", + " qty, cause = q, None\n", + " r = rng.random()\n", + " if r < mismatch / 3: # 입력 오류(자릿수/오타)\n", + " qty, cause = q + int(rng.choice([-1, 1]) * rng.integers(10, 60)), \"입력 오류\"\n", + " elif r < 2 * mismatch / 3: # 반영 지연(전일 값)\n", + " qty, cause = q + int(rng.integers(5, 40)), \"반영 지연\"\n", + " elif r < mismatch: # 단위 오류(10배)\n", + " qty, cause = q * 10, \"단위 오류\"\n", + " d = BASE_DATE - datetime.timedelta(days=int(cause == \"반영 지연\"))\n", + " if rng.random() < missing: # 결측\n", + " qty, cause = None, \"결측\"\n", + " if cause:\n", + " truth_diff[(s, p, u)] = cause\n", + " out[s].append({\"part\": FMT[s][\"part\"](p), \"unit\": FMT[s][\"unit\"](u), \"date\": FMT[s][\"date\"](d), \"qty\": qty})\n", + " return true, out, truth_diff\n", + "\n", + "\n", + "def parse_part(code):\n", + " # [TODO : 학생 실습 5] 체계 간 불일치 데이터 자동 식별 및 데이터 신뢰도 품질 지수 산출 엔진 개발 관련 핵심 로직을 구현하세요.\n", + " # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n", + " # <<< 여기에 코드를 작성하세요 >>>\n", + " pass # 작성 후 pass를 제거하세요.\n", + "def parse_unit(code):\n", + " # [TODO : 학생 실습 5] 체계 간 불일치 데이터 자동 식별 및 데이터 신뢰도 품질 지수 산출 엔진 개발 관련 핵심 로직을 구현하세요.\n", + " # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n", + " # <<< 여기에 코드를 작성하세요 >>>\n", + " pass # 작성 후 pass를 제거하세요.\n", + "def parse_date(s):\n", + " if \"/\" in s:\n", + " d, m, y = s.split(\"/\"); return datetime.date(int(y), int(m), int(d))\n", + " if \"-\" in s:\n", + " return datetime.date(*map(int, s.split(\"-\")))\n", + " return datetime.date(int(s[:4]), int(s[4:6]), int(s[6:]))\n", + "\n", + "# ---------- 화면 1: 입력 ----------\n", + "true, raw, tdiff = make_systems()\n", + "rows = [{\"sys\": s, \"p\": parse_part(r[\"part\"]), \"u\": parse_unit(r[\"unit\"]), \"date\": parse_date(r[\"date\"]), \"qty\": r[\"qty\"]} for s in \"ABC\" for r in raw[s]]\n", + "df = pd.DataFrame(rows)\n", + "df[\"qty_f\"] = df[\"qty\"].astype(\"float\")\n", + "print(f\"[화면 1. 입력] 3개 체계 보고 {len(df)}건(체계별 {df['sys'].value_counts().sort_index().to_dict()}) 적재, 표준 키(품목, 부대)로 통합 [교육용가상자료]\")\n", + "\n", + "# ---------- 화면 2: 분석(불일치 식별) ----------\n", + "w = df.pivot_table(index=[\"p\", \"u\"], columns=\"sys\", values=\"qty_f\")\n", + "w[\"n\"] = w[[\"A\", \"B\", \"C\"]].notna().sum(axis=1)\n", + "w[\"spread\"] = w[[\"A\", \"B\", \"C\"]].max(axis=1) - w[[\"A\", \"B\", \"C\"]].min(axis=1)\n", + "w[\"consensus\"] = w[[\"A\", \"B\", \"C\"]].median(axis=1)\n", + "w[\"mismatch\"] = (w[\"spread\"] > 0) | (w[\"n\"] < 3) # 값이 다르거나 어느 체계에 값이 없으면 불일치로 본다\n", + "print(f\"[화면 2. 분석] 키 {len(w)}개 중 불일치 {int(w['mismatch'].sum())}개({w['mismatch'].mean():.1%}): 값 불일치 {int((w['spread'] > 0).sum())}개, 결측 포함 {int((w['n'] < 3).sum())}개\")\n", + "big = w[w[\"spread\"] > 0.2 * w[\"consensus\"]]\n", + "print(f\" 편차 20% 초과(우선 검토 대상) {len(big)}개\")\n", + "\n", + "# ---------- 품질 지수 ----------\n", + "def dims(g):\n", + " comp = g[\"qty_f\"].notna().mean() # 완전성: 값이 있는 비율\n", + " key = g.set_index([\"p\", \"u\"]).index\n", + " cons = (~w.loc[key, \"mismatch\"]).mean() if len(g) else 0 # 일치성: 체계 간 같은 값인 비율\n", + " tim = ((BASE_DATE - g[\"date\"]).map(lambda d: d.days) <= 0).mean() # 적시성: 기준일 당일 보고 비율\n", + " val = ((g[\"qty_f\"].isna()) | ((g[\"qty_f\"] >= 0) & (g[\"qty_f\"] <= 2000))).mean() # 유효성: 허용 범위(0~2000) 안의 비율\n", + " return pd.Series({\"완전성\": comp, \"일치성\": cons, \"적시성\": tim, \"유효성\": val})\n", + "\n", + "\n", + "WT = {\"완전성\": 0.25, \"일치성\": 0.4, \"적시성\": 0.15, \"유효성\": 0.2} # 일치성 비중을 가장 높게\n", + "def index(g):\n", + " d = dims(g)\n", + " return pd.concat([d, pd.Series({\"품질지수\": 100 * sum(d[k] * v for k, v in WT.items())})])\n", + "\n", + "by_sys = pd.DataFrame({k: index(g) for k, g in df.groupby(\"sys\")}).T\n", + "by_unit = pd.DataFrame({k: index(g) for k, g in df.groupby(\"u\")}).T\n", + "\n", + "# ---------- 화면 3: 검토 ----------\n", + "print(\"\\n[화면 3. 검토] 체계별 품질 지수(0~100)\")\n", + "print(by_sys.round(3).to_string())\n", + "low = by_unit.sort_values(\"품질지수\").head(4)\n", + "print(\"품질 지수가 낮은 부대 4곳(우선 점검):\")\n", + "print(low.round(3).to_string())\n", + "print(\"검토자는 불일치 키마다 '마스터 후보 승인 요청 / 정상 확인'을 선택하며, 수정은 체계 담당자가 한다.\")\n", + "\n", + "# ---------- 화면 4: 결과 ----------\n", + "total = float(index(df)[\"품질지수\"])\n", + "grade = \"양호\" if total >= 90 else \"보완 필요\" if total >= 75 else \"미흡\"\n", + "gt_keys = {(p, u) for (_, p, u) in tdiff}\n", + "found = {k for k, m in w[\"mismatch\"].items() if m}\n", + "recall = len(found & gt_keys) / len(gt_keys)\n", + "print(f\"\\n[화면 4. 결과] 통합 데이터 품질 지수 {total:.1f}점({grade}), 불일치 식별 재현율(가상 정답 대비) {recall:.1%}\")\n", + "print(f\" 차원별 가중치: {WT}\")\n", + "ok = recall >= 0.95\n", + "print(f\"목표(핵심 화면 4종 입력-분석-검토-결과 구현, 불일치 자동 식별·품질 지수 산출): {'달성' if ok else '미달'}\")\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 05 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + "[화면 1. 입력] 3개 체계 보고 2160건(체계별 {'A': 720, 'B': 720, 'C': 720}) 적재, 표준 키(품목, 부대)로 통합 [교육용가상자료]\n", + "[화면 2. 분석] 키 720개 중 불일치 219개(30.4%): 값 불일치 179개, 결측 포함 43개\n", + " 편차 20% 초과(우선 검토 대상) 95개\n", + "\n", + "[화면 3. 검토] 체계별 품질 지수(0~100)\n", + " 완전성 일치성 적시성 유효성 품질지수\n", + "A 0.975 0.696 0.962 0.978 86.201\n", + "B 0.981 0.696 0.969 0.979 86.472\n", + "C 0.981 0.696 0.967 0.974 86.319\n", + "품질 지수가 낮은 부대 4곳(우선 점검):\n", + " 완전성 일치성 적시성 유효성 품질지수\n", + "3 0.972 0.583 0.950 0.983 81.556\n", + "5 0.972 0.633 0.983 0.983 84.056\n", + "10 0.978 0.650 0.967 0.961 84.167\n", + "6 0.978 0.667 0.956 0.972 84.889\n", + "검토자는 불일치 키마다 '마스터 후보 승인 요청 / 정상 확인'을 선택하며, 수정은 체계 담당자가 한다.\n", + "\n", + "[화면 4. 결과] 통합 데이터 품질 지수 86.3점(보완 필요), 불일치 식별 재현율(가상 정답 대비) 100.0%\n", + " 차원별 가중치: {'완전성': 0.25, '일치성': 0.4, '적시성': 0.15, '유효성': 0.2}\n", + "목표(핵심 화면 4종 입력-분석-검토-결과 구현, 불일치 자동 식별·품질 지수 산출): 달성\n", + "```\n", + "
\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 06. 지휘관 및 군수참모용 통합 군수 상황판(Logistics COP) 실시간 대시보드 구현\n", + "**소요시간**: 약 2.5시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "지휘관 및 군수참모용 통합 군수 상황판(Logistics COP) 실시간 대시보드를 구현한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **지휘관과 군수참모가 보는 통합 군수 상황판(Logistics COP)을 시간별 갱신 방식으로 구현하고, 예외 상황에서도 중단되지 않는 에러 처리를 갖춘다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **변경 이벤트를 1시간 단위로 반영해 총재고, 분류별 재고, 부족 경보를 갱신하고 지휘관용 요약 화면과 군수참모용 상세 화면을 구분해 보여 준다 과정을 진행하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `src/utils/dat09_validator.py 및 에러 핸들러` 형태의 결과물을 완성한다.\n", + "\n", + "### 🔍 평가 및 검증 기준 (PASS 조건)\n", + "#### 미션 판정\n", + "\n", + "**평가 구분:** 필수\n", + "\n", + "**핵심 평가영역:** 사용자에게 알기 쉬운 에러 메시지 및 조치 가이드 제공\n", + "\n", + "#### 미션 PASS 조건\n", + "\n", + "- 정량 기준: 예외 상황 발생 시 크래시(Crash) 0건, 에러 로그 기록률 100%\n", + "\n", + "\n", + "- 진행 기준: 예외 주입 테스트 10종 통과 및 감사 로그 파일 생성\n", + "\n", + "\n", + "- 검증 방법: 비정상 데이터(결측, 특수문자, 대용량) 스트레스 테스트\n", + "\n", + "\n", + "#### 채점자 판정 메모\n", + "\n", + "> 단순 코드 실행 여부뿐만 아니라 출력된 수치와 근거 자료가 군 실무 요구사항에 부합하는지 면밀히 확인한다.\n", + "\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "\"\"\"변경 이벤트를 1시간 단위로 받아 갱신되는 통합 군수 상황판(재고·부족 경보·데이터 신뢰도)을 지휘관용·군수참모용 역할별 화면과 그래프로 구현하고, 입력 검증기와 에러 핸들러로 예외 10종을 크래시 없이 기록·안내한다.\"\"\"\n", + "import datetime\n", + "import json\n", + "import numpy as np\n", + "import pandas as pd\n", + "import matplotlib.pyplot as plt\n", + "\n", + "# ---------------------------------------------------------------------------\n", + "# [교육용가상자료] 가상 이기종 군수정보체계 3종(A 보급, B 정비, C 탄약)의 재고 보고 생성기\n", + "# 실제 체계명·부대·품목·수량 없음. 같은 (품목, 부대) 재고를 세 체계가 서로 다른 코드·날짜 형식으로 보고한다.\n", + "# 기준 날짜: 2026-09-30(가상). 마스터(정답) 재고 TRUE 와 체계별 보고에는 의도적 불일치·결측이 섞여 있다.\n", + "# ---------------------------------------------------------------------------\n", + "N_PART, N_UNIT = 60, 12\n", + "CATS = [\"급식\", \"피복\", \"유류\", \"정비소모\", \"수리부속\", \"탄약\"]\n", + "PARTS = {p: {\"name\": f\"가상품목{p:02d}\", \"cat\": CATS[(p - 1) % 6]} for p in range(1, N_PART + 1)}\n", + "FMT = {\"A\": dict(part=lambda p: f\"AP-{p:05d}\", unit=lambda u: f\"U{u:02d}\", date=lambda d: d.strftime(\"%Y%m%d\")),\n", + " \"B\": dict(part=lambda p: f\"{p:05d}-X\", unit=lambda u: f\"가상{u:02d}대대\", date=lambda d: d.strftime(\"%Y-%m-%d\")),\n", + " \"C\": dict(part=lambda p: f\"P{p}\", unit=lambda u: str(u), date=lambda d: d.strftime(\"%d/%m/%Y\"))}\n", + "BASE_DATE = datetime.date(2026, 9, 30)\n", + "\n", + "\n", + "def make_systems(seed=9, mismatch=0.10, missing=0.03):\n", + " \"\"\"TRUE(정답 재고)와 체계별 원본 보고 레코드(체계 고유 형식)를 만든다.\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " true = {(p, u): int(rng.integers(20, 500)) for p in PARTS for u in range(1, N_UNIT + 1)}\n", + " out = {s: [] for s in \"ABC\"}\n", + " truth_diff = {} # (체계, p, u) -> 불일치 원인(정답)\n", + " for (p, u), q in true.items():\n", + " for s in \"ABC\":\n", + " qty, cause = q, None\n", + " r = rng.random()\n", + " if r < mismatch / 3: # 입력 오류(자릿수/오타)\n", + " qty, cause = q + int(rng.choice([-1, 1]) * rng.integers(10, 60)), \"입력 오류\"\n", + " elif r < 2 * mismatch / 3: # 반영 지연(전일 값)\n", + " qty, cause = q + int(rng.integers(5, 40)), \"반영 지연\"\n", + " elif r < mismatch: # 단위 오류(10배)\n", + " qty, cause = q * 10, \"단위 오류\"\n", + " d = BASE_DATE - datetime.timedelta(days=int(cause == \"반영 지연\"))\n", + " if rng.random() < missing: # 결측\n", + " qty, cause = None, \"결측\"\n", + " if cause:\n", + " truth_diff[(s, p, u)] = cause\n", + " out[s].append({\"part\": FMT[s][\"part\"](p), \"unit\": FMT[s][\"unit\"](u), \"date\": FMT[s][\"date\"](d), \"qty\": qty})\n", + " return true, out, truth_diff\n", + "\n", + "\n", + "def parse_part(code):\n", + " # [TODO : 학생 실습 6] 지휘관 및 군수참모용 통합 군수 상황판(Logistics COP) 실시간 대시보드 구현 관련 핵심 로직을 구현하세요.\n", + " # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n", + " # <<< 여기에 코드를 작성하세요 >>>\n", + " pass # 작성 후 pass를 제거하세요.\n", + "def parse_unit(code):\n", + " # [TODO : 학생 실습 6] 지휘관 및 군수참모용 통합 군수 상황판(Logistics COP) 실시간 대시보드 구현 관련 핵심 로직을 구현하세요.\n", + " # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n", + " # <<< 여기에 코드를 작성하세요 >>>\n", + " pass # 작성 후 pass를 제거하세요.\n", + "def parse_date(s):\n", + " if \"/\" in s:\n", + " d, m, y = s.split(\"/\"); return datetime.date(int(y), int(m), int(d))\n", + " if \"-\" in s:\n", + " return datetime.date(*map(int, s.split(\"-\")))\n", + " return datetime.date(int(s[:4]), int(s[4:6]), int(s[6:]))\n", + "\n", + "true, raw, tdiff = make_systems()\n", + "rng = np.random.default_rng(6)\n", + "STATE = {(p, u): true[(p, u)] for p in PARTS for u in range(1, N_UNIT + 1)} # 상황판의 현재 재고(체계 A 기준 시작)\n", + "SEEN, LOG, HIST = set(), [], []\n", + "LOW = 40 # 부족 경보 기준(가상)\n", + "\n", + "\n", + "class AppError(Exception):\n", + " def __init__(self, code, msg, guide):\n", + " super().__init__(msg); self.code, self.msg, self.guide = code, msg, guide\n", + "\n", + "\n", + "def validate(ev):\n", + " for k in (\"id\", \"sys\", \"p\", \"u\", \"delta\"):\n", + " if k not in ev or ev[k] is None:\n", + " raise AppError(\"E01\", f\"필수 항목 '{k}' 누락\", f\"이벤트에 '{k}' 값을 넣어 다시 전송\")\n", + " if ev[\"id\"] in SEEN:\n", + " raise AppError(\"E02\", f\"중복 이벤트 {ev['id']}\", \"이미 반영된 이벤트이므로 무시해도 됨\")\n", + " if ev[\"sys\"] not in (\"A\", \"B\", \"C\"):\n", + " raise AppError(\"E03\", f\"알 수 없는 체계 '{ev['sys']}'\", \"연동 체계(A·B·C)만 허용\")\n", + " try:\n", + " p, u, dl = int(ev[\"p\"]), int(ev[\"u\"]), float(ev[\"delta\"])\n", + " except (TypeError, ValueError):\n", + " raise AppError(\"E04\", \"숫자 형식 오류\", \"품목·부대·증감량은 숫자만 입력\")\n", + " if not np.isfinite(dl):\n", + " raise AppError(\"E05\", \"증감량이 NaN 또는 무한대\", \"정상 숫자로 수정\")\n", + " if (p, u) not in STATE:\n", + " raise AppError(\"E06\", f\"존재하지 않는 키(품목 {p}, 부대 {u})\", \"코드 변환표에서 키 확인\")\n", + " if abs(dl) > 5000:\n", + " raise AppError(\"E09\", f\"증감량 {dl:+.0f}이 허용 범위(±5000) 초과\", \"자릿수 오류 여부를 출처 체계에 확인\")\n", + " if STATE[(p, u)] + dl < 0:\n", + " raise AppError(\"E07\", f\"재고가 음수가 됨({STATE[(p, u)]} + {dl:+.0f})\", \"출처 체계에 증감량 재확인 요청\")\n", + " return p, u, dl\n", + "\n", + "\n", + "def apply(ev):\n", + " p, u, dl = validate(ev)\n", + " STATE[(p, u)] += int(dl); SEEN.add(ev[\"id\"])\n", + "\n", + "\n", + "def safe(fn, *a):\n", + " \"\"\"에러 핸들러: 모든 예외를 로그로 남기고 사용자에게 원인과 조치를 안내한다(크래시 없음).\"\"\"\n", + " try:\n", + " r = fn(*a); LOG.append({\"level\": \"INFO\", \"code\": \"OK\"}); return r\n", + " except AppError as e:\n", + " LOG.append({\"level\": \"ERROR\", \"code\": e.code, \"msg\": e.msg, \"guide\": e.guide})\n", + " except Exception as e:\n", + " LOG.append({\"level\": \"ERROR\", \"code\": \"E99\", \"msg\": repr(e)[:50], \"guide\": \"운영 담당자에게 로그 전달\"})\n", + "\n", + "\n", + "def view(role):\n", + " \"\"\"역할별 화면: 지휘관은 요약, 군수참모는 분류·부대 상세.\"\"\"\n", + " if role not in (\"지휘관\", \"군수참모\"):\n", + " raise AppError(\"E08\", f\"권한 없는 역할 '{role}'\", \"지휘관 또는 군수참모 계정으로 접속\")\n", + " s = pd.Series(STATE)\n", + " total, low = int(s.sum()), int((s < LOW).sum())\n", + " if role == \"지휘관\":\n", + " return f\"총재고 {total}, 부족 경보 {low}건\"\n", + " cat = {c: int(sum(v for (p, u), v in STATE.items() if PARTS[p][\"cat\"] == c)) for c in CATS}\n", + " worst = s.groupby(level=1).apply(lambda g: int((g < LOW).sum())).sort_values(ascending=False).head(3)\n", + " return f\"총재고 {total}, 분류별 {cat}, 부족 경보 {low}건, 경보 많은 부대 {worst.to_dict()}\"\n", + "\n", + "\n", + "# 1) 정상 운영: 6시간 동안 시간당 이벤트 80건(불출 -, 보급 +) 반영\n", + "print(\"[통합 군수 상황판 - 시간별 갱신] [교육용가상자료]\")\n", + "for hour in range(6):\n", + " for i in range(80):\n", + " p, u = int(rng.integers(1, N_PART + 1)), int(rng.integers(1, N_UNIT + 1))\n", + " d = int(rng.integers(-30, 25))\n", + " safe(apply, {\"id\": f\"EV{hour}-{i}\", \"sys\": \"ABC\"[i % 3], \"p\": p, \"u\": u, \"delta\": d})\n", + " s = pd.Series(STATE)\n", + " cat_tot = {c: sum(v for (p, u), v in STATE.items() if PARTS[p][\"cat\"] == c) for c in CATS}\n", + " HIST.append({\"hour\": hour + 8, \"total\": int(s.sum()), \"low\": int((s < LOW).sum()), **cat_tot})\n", + " print(f\" {hour + 8:02d}:00 지휘관 -> {view('지휘관')}\")\n", + "rej = [l for l in LOG if l[\"level\"] == \"ERROR\"]\n", + "print(f\"정상 운영 중 거부된 이벤트 {len(rej)}건(재고 음수 보호 등):\", sorted({l['code'] for l in rej}))\n", + "print(\"군수참모 화면:\", view(\"군수참모\"))\n", + "\n", + "# 2) 예외 주입 10종\n", + "LOG.clear()\n", + "base = {\"id\": \"T-OK\", \"sys\": \"A\", \"p\": 1, \"u\": 1, \"delta\": 1}\n", + "safe(apply, base) # 먼저 정상 반영해 두어야 같은 번호 재전송이 중복이 된다(시험 대상 아님)\n", + "LOG.clear()\n", + "cases = [(\"필수 항목 누락\", {\"id\": \"T1\", \"sys\": \"A\", \"p\": 1, \"u\": 1}), (\"중복 이벤트\", base),\n", + " (\"알 수 없는 체계\", {**base, \"id\": \"T3\", \"sys\": \"Z\"}), (\"숫자 아님\", {**base, \"id\": \"T4\", \"delta\": \"10개\"}),\n", + " (\"NaN 값\", {**base, \"id\": \"T5\", \"delta\": float(\"nan\")}), (\"없는 키\", {**base, \"id\": \"T6\", \"p\": 999}),\n", + " (\"증감량 과대\", {**base, \"id\": \"T7\", \"delta\": -99999}), (\"재고 음수화\", {**base, \"id\": \"T8\", \"delta\": -4000}),\n", + " (\"권한 없는 역할\", \"병사\"), (\"빈 이벤트\", {})]\n", + "crash = 0\n", + "for n, c in cases:\n", + " try:\n", + " safe(view, c) if isinstance(c, str) else safe(apply, c)\n", + " except BaseException:\n", + " crash += 1\n", + "errs = [l for l in LOG if l[\"level\"] == \"ERROR\"]\n", + "print(\"\\n[예외 주입 결과 - 사용자 안내 메시지]\")\n", + "for (n, c), l in zip(cases, errs):\n", + " print(f\" {l['code']} {n:<14} {l['msg']:<32} -> 조치: {l['guide']}\")\n", + "exp_errors = len(cases)\n", + "rate = len(errs) / exp_errors\n", + "print(f\"예외 {exp_errors}종 중 로그 기록 {len(errs)}건(기록률 {rate:.0%}), 크래시 {crash}건\")\n", + "print(\"로그 예시:\", json.dumps(errs[0], ensure_ascii=False))\n", + "print(f\"목표(크래시 0건, 에러 로그 기록률 100%): {'달성' if crash == 0 and rate == 1 else '미달'}\")\n", + "\n", + "h = pd.DataFrame(HIST)\n", + "fig, ax = plt.subplots(2, 2, figsize=(11, 7))\n", + "for c in CATS:\n", + " ax[0, 0].plot(h[\"hour\"], h[c], marker=\"o\", label=f\"cat{CATS.index(c) + 1}\")\n", + "ax[0, 0].legend(fontsize=7, ncol=3); ax[0, 0].set_title(\"Stock by category (hourly)\")\n", + "ax[0, 1].plot(h[\"hour\"], h[\"low\"], color=\"tab:red\", marker=\"s\"); ax[0, 1].set_title(\"Shortage alerts (stock < 40)\")\n", + "s = pd.Series(STATE)\n", + "ax[1, 0].bar(range(1, N_UNIT + 1), s.groupby(level=1).sum().values, color=\"tab:blue\"); ax[1, 0].set_title(\"Total stock by unit (latest)\")\n", + "ax[1, 1].hist(s.values, bins=20, color=\"tab:gray\"); ax[1, 1].axvline(LOW, color=\"red\", ls=\"--\"); ax[1, 1].set_title(\"Stock distribution (red = alert line)\")\n", + "plt.suptitle(\"DAT09 logistics COP (synthetic)\"); plt.tight_layout(); plt.show()\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 06 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + "[통합 군수 상황판 - 시간별 갱신] [교육용가상자료]\n", + " 08:00 지휘관 -> 총재고 188532, 부족 경보 27건\n", + " 09:00 지휘관 -> 총재고 188232, 부족 경보 28건\n", + " 10:00 지휘관 -> 총재고 188292, 부족 경보 26건\n", + " 11:00 지휘관 -> 총재고 187942, 부족 경보 29건\n", + " 12:00 지휘관 -> 총재고 187663, 부족 경보 27건\n", + " 13:00 지휘관 -> 총재고 187444, 부족 경보 28건\n", + "정상 운영 중 거부된 이벤트 0건(재고 음수 보호 등): []\n", + "군수참모 화면: 총재고 187444, 분류별 {'급식': 30500, '피복': 34149, '유류': 30600, '정비소모': 30650, '수리부속': 31493, '탄약': 30052}, 부족 경보 28건, 경보 많은 부대 {1: 4, 3: 4, 8: 4}\n", + "\n", + "[예외 주입 결과 - 사용자 안내 메시지]\n", + " E01 필수 항목 누락 필수 항목 'delta' 누락 -> 조치: 이벤트에 'delta' 값을 넣어 다시 전송\n", + " E02 중복 이벤트 중복 이벤트 T-OK -> 조치: 이미 반영된 이벤트이므로 무시해도 됨\n", + " E03 알 수 없는 체계 알 수 없는 체계 'Z' -> 조치: 연동 체계(A·B·C)만 허용\n", + " E04 숫자 아님 숫자 형식 오류 -> 조치: 품목·부대·증감량은 숫자만 입력\n", + " E05 NaN 값 증감량이 NaN 또는 무한대 -> 조치: 정상 숫자로 수정\n", + " E06 없는 키 존재하지 않는 키(품목 999, 부대 1) -> 조치: 코드 변환표에서 키 확인\n", + " E09 증감량 과대 증감량 -99999이 허용 범위(±5000) 초과 -> 조치: 자릿수 오류 여부를 출처 체계에 확인\n", + " E07 재고 음수화 재고가 음수가 됨(216 + -4000) -> 조치: 출처 체계에 증감량 재확인 요청\n", + " E08 권한 없는 역할 권한 없는 역할 '병사' -> 조치: 지휘관 또는 군수참모 계정으로 접속\n", + " E01 빈 이벤트 필수 항목 'id' 누락 -> 조치: 이벤트에 'id' 값을 넣어 다시 전송\n", + "예외 10종 중 로그 기록 10건(기록률 100%), 크래시 0건\n", + "로그 예시: {\"level\": \"ERROR\", \"code\": \"E01\", \"msg\": \"필수 항목 'delta' 누락\", \"guide\": \"이벤트에 'delta' 값을 넣어 다시 전송\"}\n", + "목표(크래시 0건, 에러 로그 기록률 100%): 달성\n", + "```\n", + "
\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 07. 체계 간 데이터 충돌 시 마스터 데이터 확정을 위한 데이터관리관 HITL 승인 설계\n", + "**소요시간**: 약 3.0시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "데이터 충돌 시 마스터 데이터 확정을 위한 데이터관리관 HITL 승인을 설계한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **체계 간 데이터 충돌 시 시스템은 후보만 제안하고 데이터관리관 승인으로만 마스터 데이터를 확정하는 사람 승인 체계를 설계한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **충돌 건마다 다수값 후보와 근거를 제안으로 등록하고, 데이터관리관이 근거를 적어 승인하거나 반려하게 수행하고 검증하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `src/workflow/dat09_approval_system.py` 형태의 결과물을 완성한다.\n", + "\n", + "### 🔍 평가 및 검증 기준 (PASS 조건)\n", + "#### 미션 판정\n", + "\n", + "**평가 구분:** 필수\n", + "\n", + "**핵심 평가영역:** 군 보안 규정 및 행정 절차 부합성\n", + "\n", + "#### 미션 PASS 조건\n", + "\n", + "- 정량 기준: 이력 기록 무결성 100%, 승인 권한 우회 시도 차단율 100%\n", + "\n", + "\n", + "- 진행 기준: 결재선 승인/반려 시뮬레이션 및 이력 조회 화면 구현\n", + "\n", + "\n", + "- 검증 방법: 권한별 접근 제어(RBAC) 테스트\n", + "\n", + "\n", + "#### 채점자 판정 메모\n", + "\n", + "> 단순 코드 실행 여부뿐만 아니라 출력된 수치와 근거 자료가 군 실무 요구사항에 부합하는지 면밀히 확인한다.\n", + "\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "\"\"\"체계 간 수량 충돌이 생기면 시스템이 마스터 값 후보와 근거를 제안하고 데이터관리관이 승인해야만 마스터를 확정하는 사람 승인 체계를 설계하며(고영향 건은 군수참모 추가 확인), 해시 체인 감사 이력의 무결성과 권한 우회 차단을 검증한다.\"\"\"\n", + "import collections\n", + "import datetime\n", + "import hashlib\n", + "import json\n", + "import numpy as np\n", + "import pandas as pd\n", + "\n", + "# ---------------------------------------------------------------------------\n", + "# [교육용가상자료] 가상 이기종 군수정보체계 3종(A 보급, B 정비, C 탄약)의 재고 보고 생성기\n", + "# 실제 체계명·부대·품목·수량 없음. 같은 (품목, 부대) 재고를 세 체계가 서로 다른 코드·날짜 형식으로 보고한다.\n", + "# 기준 날짜: 2026-09-30(가상). 마스터(정답) 재고 TRUE 와 체계별 보고에는 의도적 불일치·결측이 섞여 있다.\n", + "# ---------------------------------------------------------------------------\n", + "N_PART, N_UNIT = 60, 12\n", + "CATS = [\"급식\", \"피복\", \"유류\", \"정비소모\", \"수리부속\", \"탄약\"]\n", + "PARTS = {p: {\"name\": f\"가상품목{p:02d}\", \"cat\": CATS[(p - 1) % 6]} for p in range(1, N_PART + 1)}\n", + "FMT = {\"A\": dict(part=lambda p: f\"AP-{p:05d}\", unit=lambda u: f\"U{u:02d}\", date=lambda d: d.strftime(\"%Y%m%d\")),\n", + " \"B\": dict(part=lambda p: f\"{p:05d}-X\", unit=lambda u: f\"가상{u:02d}대대\", date=lambda d: d.strftime(\"%Y-%m-%d\")),\n", + " \"C\": dict(part=lambda p: f\"P{p}\", unit=lambda u: str(u), date=lambda d: d.strftime(\"%d/%m/%Y\"))}\n", + "BASE_DATE = datetime.date(2026, 9, 30)\n", + "\n", + "\n", + "def make_systems(seed=9, mismatch=0.10, missing=0.03):\n", + " \"\"\"TRUE(정답 재고)와 체계별 원본 보고 레코드(체계 고유 형식)를 만든다.\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " true = {(p, u): int(rng.integers(20, 500)) for p in PARTS for u in range(1, N_UNIT + 1)}\n", + " out = {s: [] for s in \"ABC\"}\n", + " truth_diff = {} # (체계, p, u) -> 불일치 원인(정답)\n", + " for (p, u), q in true.items():\n", + " for s in \"ABC\":\n", + " qty, cause = q, None\n", + " r = rng.random()\n", + " if r < mismatch / 3: # 입력 오류(자릿수/오타)\n", + " qty, cause = q + int(rng.choice([-1, 1]) * rng.integers(10, 60)), \"입력 오류\"\n", + " elif r < 2 * mismatch / 3: # 반영 지연(전일 값)\n", + " qty, cause = q + int(rng.integers(5, 40)), \"반영 지연\"\n", + " elif r < mismatch: # 단위 오류(10배)\n", + " qty, cause = q * 10, \"단위 오류\"\n", + " d = BASE_DATE - datetime.timedelta(days=int(cause == \"반영 지연\"))\n", + " if rng.random() < missing: # 결측\n", + " qty, cause = None, \"결측\"\n", + " if cause:\n", + " truth_diff[(s, p, u)] = cause\n", + " out[s].append({\"part\": FMT[s][\"part\"](p), \"unit\": FMT[s][\"unit\"](u), \"date\": FMT[s][\"date\"](d), \"qty\": qty})\n", + " return true, out, truth_diff\n", + "\n", + "\n", + "def parse_part(code):\n", + " # [TODO : 학생 실습 7] 체계 간 데이터 충돌 시 마스터 데이터 확정을 위한 데이터관리관 HITL 승인 설계 관련 핵심 로직을 구현하세요.\n", + " # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n", + " # <<< 여기에 코드를 작성하세요 >>>\n", + " pass # 작성 후 pass를 제거하세요.\n", + "def parse_unit(code):\n", + " # [TODO : 학생 실습 7] 체계 간 데이터 충돌 시 마스터 데이터 확정을 위한 데이터관리관 HITL 승인 설계 관련 핵심 로직을 구현하세요.\n", + " # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n", + " # <<< 여기에 코드를 작성하세요 >>>\n", + " pass # 작성 후 pass를 제거하세요.\n", + "def parse_date(s):\n", + " if \"/\" in s:\n", + " d, m, y = s.split(\"/\"); return datetime.date(int(y), int(m), int(d))\n", + " if \"-\" in s:\n", + " return datetime.date(*map(int, s.split(\"-\")))\n", + " return datetime.date(int(s[:4]), int(s[4:6]), int(s[6:]))\n", + "\n", + "true, raw, tdiff = make_systems()\n", + "# [교육용가상자료] 가상 직책·인물(가명)\n", + "USERS = {\"sys_bot\": \"시스템\", \"kim\": \"체계담당자\", \"lee\": \"체계담당자\", \"park\": \"데이터관리관\", \"choi\": \"군수참모\", \"han\": \"열람자\"}\n", + "MASTER, PROPOSALS, AUDIT = {}, {}, []\n", + "\n", + "def h(prev, rec):\n", + " return hashlib.sha256((prev + json.dumps(rec, ensure_ascii=False, sort_keys=True, default=str)).encode()).hexdigest()[:16]\n", + "\n", + "def log(key, user, action, result, note=\"\"):\n", + " rec = {\"seq\": len(AUDIT) + 1, \"key\": str(key), \"user\": user, \"role\": USERS.get(user, \"미등록\"), \"action\": action, \"result\": result, \"note\": note}\n", + " rec[\"hash\"] = h(AUDIT[-1][\"hash\"] if AUDIT else \"GENESIS\", {k: v for k, v in rec.items() if k != \"hash\"})\n", + " AUDIT.append(rec)\n", + "\n", + "def verify():\n", + " prev, ok = \"GENESIS\", 0\n", + " for r in AUDIT:\n", + " ok += r[\"hash\"] == h(prev, {k: v for k, v in r.items() if k != \"hash\"}); prev = r[\"hash\"]\n", + " return ok, len(AUDIT)\n", + "\n", + "def block(key, user, action, why):\n", + " log(key, user, action, \"차단\", why); return False\n", + "\n", + "def propose(key, user, cand, evidence, old):\n", + " if USERS.get(user) not in (\"시스템\", \"체계담당자\"):\n", + " return block(key, user, \"제안\", \"제안 권한 없음\")\n", + " high = abs(cand - old) >= 100 or abs(cand - old) / max(old, 1) >= 0.5 # 고영향 기준(가상)\n", + " PROPOSALS[key] = {\"cand\": cand, \"old\": old, \"by\": user, \"high\": high, \"staff_ok\": False, \"status\": \"대기\"}\n", + " log(key, user, \"제안\", \"접수\", f\"후보 {cand}, 근거 {evidence}\"); return True\n", + "\n", + "def staff_confirm(key, user, note):\n", + " p = PROPOSALS.get(key)\n", + " if USERS.get(user) != \"군수참모\" or p is None or not p[\"high\"]:\n", + " return block(key, user, \"참모확인\", \"고영향 건에 대한 군수참모만 가능\")\n", + " if len(note.strip()) < 5:\n", + " return block(key, user, \"참모확인\", \"확인 의견 5자 이상 필요\")\n", + " p[\"staff_ok\"] = True; log(key, user, \"참모확인\", \"완료\", note); return True\n", + "\n", + "def approve(key, user, note):\n", + " p = PROPOSALS.get(key)\n", + " if p is None:\n", + " return block(key, user, \"승인\", \"제안 없는 키\")\n", + " if p[\"status\"] != \"대기\":\n", + " return block(key, user, \"승인\", f\"이미 처리됨({p['status']})\")\n", + " if USERS.get(user) != \"데이터관리관\":\n", + " return block(key, user, \"승인\", f\"권한 없음: 역할 '{USERS.get(user, '미등록')}'은(는) 마스터 확정 불가(자동 확정 금지)\")\n", + " if p[\"by\"] == user:\n", + " return block(key, user, \"승인\", \"제안자 본인 승인 금지\")\n", + " if len(note.strip()) < 5:\n", + " return block(key, user, \"승인\", \"승인 근거 5자 이상 필요\")\n", + " if p[\"high\"] and not p[\"staff_ok\"]:\n", + " return block(key, user, \"승인\", \"고영향 건은 군수참모 확인 선행 필요\")\n", + " MASTER[key] = p[\"cand\"]; p[\"status\"] = \"확정\"; log(key, user, \"승인\", \"확정\", note); return True\n", + "\n", + "def reject(key, user, note):\n", + " p = PROPOSALS.get(key)\n", + " if USERS.get(user) != \"데이터관리관\" or p is None or p[\"status\"] != \"대기\" or len(note.strip()) < 5:\n", + " return block(key, user, \"반려\", \"반려 조건 불충족\")\n", + " p[\"status\"] = \"반려\"; log(key, user, \"반려\", \"반려\", note); return True\n", + "\n", + "# 1) 충돌 키 찾기: 세 체계 값이 서로 다르면 다수값을 후보로 제안\n", + "vals = collections.defaultdict(dict)\n", + "for s in \"ABC\":\n", + " for r in raw[s]:\n", + " vals[(parse_part(r[\"part\"]), parse_unit(r[\"unit\"]))][s] = r[\"qty\"]\n", + "confl = []\n", + "for k, v in vals.items():\n", + " have = [x for x in v.values() if x is not None]\n", + " if len(set(have)) > 1:\n", + " top, n = collections.Counter(have).most_common(1)[0]\n", + " if n >= 2:\n", + " confl.append((k, top, v[\"A\"] if v[\"A\"] is not None else top))\n", + "confl = sorted(confl)[:24]\n", + "print(f\"[충돌 건] 다수값 후보가 있는 충돌 {len(confl)}건을 처리 대상으로 선정 [교육용가상자료]\")\n", + "\n", + "for i, (k, cand, old) in enumerate(confl):\n", + " propose(k, \"sys_bot\" if i % 2 else \"kim\", cand, \"다수 체계 일치\", old)\n", + " if PROPOSALS[k][\"high\"]:\n", + " staff_confirm(k, \"choi\", \"수량 차이가 커서 출처 체계 대조 후 확인\")\n", + " if i == 3:\n", + " reject(k, \"park\", \"후보가 오래된 보고와 같아 보류 요청\"); continue\n", + " approve(k, \"park\", \"체계 간 다수값 및 원인 분류 검토 완료\")\n", + "acc = np.mean([MASTER[k] == true[k] for k in MASTER])\n", + "print(f\"확정 {len(MASTER)}건, 반려 {sum(p['status'] == '반려' for p in PROPOSALS.values())}건, 고영향(군수참모 확인 필요) {sum(p['high'] for p in PROPOSALS.values())}건, 확정 값이 가상 정답과 일치 {acc:.0%}\")\n", + "\n", + "# 2) 권한 우회 시도 10종\n", + "k0 = confl[0][0]\n", + "pend = confl[-1][0]\n", + "PROPOSALS[pend].update(status=\"대기\"); MASTER.pop(pend, None)\n", + "hi = next(k for k, p in PROPOSALS.items() if p[\"high\"]) if any(p[\"high\"] for p in PROPOSALS.values()) else pend\n", + "PROPOSALS[hi].update(status=\"대기\", staff_ok=False); MASTER.pop(hi, None)\n", + "total = blocked = 0\n", + "print(\"\\n[권한 우회 시도 결과]\")\n", + "def attempt(name, fn):\n", + " global total, blocked\n", + " n = len(AUDIT); fn(); r = AUDIT[-1]\n", + " ok = len(AUDIT) == n + 1 and r[\"result\"] == \"차단\"\n", + " total += 1; blocked += ok\n", + " print(f\" - {name}: {r['result']} ({r['note']})\")\n", + "attempt(\"시스템이 자동으로 마스터 확정\", lambda: approve(pend, \"sys_bot\", \"자동 처리 결과를 확정함\"))\n", + "attempt(\"체계담당자가 직접 확정\", lambda: approve(pend, \"lee\", \"제가 직접 확정합니다 확인\"))\n", + "attempt(\"열람자가 확정\", lambda: approve(pend, \"han\", \"열람자 권한으로 확정 시도\"))\n", + "attempt(\"미등록 사용자 확정\", lambda: approve(pend, \"hacker\", \"외부 계정 확정 시도 확인\"))\n", + "attempt(\"승인 근거 없이 확정\", lambda: approve(pend, \"park\", \"\"))\n", + "attempt(\"제안 없는 키 확정\", lambda: approve((99, 99), \"park\", \"존재하지 않는 제안 승인 시도\"))\n", + "attempt(\"고영향 건을 군수참모 확인 없이 확정\", lambda: approve(hi, \"park\", \"참모 확인 단계를 건너뛰고 승인\"))\n", + "attempt(\"제안자 본인(관리관)이 승인\", lambda: (PROPOSALS.update({pend: {**PROPOSALS[pend], \"by\": \"park\"}}), approve(pend, \"park\", \"본인 제안을 본인이 승인\"))[1])\n", + "attempt(\"체계담당자가 참모확인 대행\", lambda: staff_confirm(hi, \"kim\", \"참모 대신 확인했습니다 확인\"))\n", + "PROPOSALS[pend].update(by=\"kim\"); approve(pend, \"park\", \"다수 체계 일치 확인 후 정상 승인\")\n", + "attempt(\"이미 확정된 건 재승인\", lambda: approve(pend, \"park\", \"이미 확정된 건을 다시 승인\"))\n", + "print(f\"우회 시도 {total}건 중 차단 {blocked}건({blocked / total:.0%}), 자동 확정 0건\")\n", + "\n", + "# 3) 감사 이력 무결성 + 위조 시험\n", + "ok, tot = verify()\n", + "print(f\"\\n감사 이력 {tot}건 해시 체인 검증 {ok}/{tot}({ok / tot:.0%})\")\n", + "j = next(i for i, r in enumerate(AUDIT) if r[\"result\"] == \"차단\")\n", + "keep = AUDIT[j][\"result\"]; AUDIT[j][\"result\"] = \"확정\"\n", + "ok2, _ = verify(); AUDIT[j][\"result\"] = keep\n", + "print(f\"위조 시험: {j + 1}번 기록 변조 -> 검증 {ok2}/{tot} (변조 탐지 {'성공' if ok2 < tot else '실패'}), 원복 후 {verify()[0]}/{tot}\")\n", + "df = pd.DataFrame(AUDIT)\n", + "print(\"[행위별 결과 요약]\")\n", + "print(df.groupby([\"action\", \"result\"]).size().to_string())\n", + "print(f\"목표(이력 무결성 100%, 우회 차단율 100%): {'달성' if ok == tot and blocked == total and ok2 < tot else '미달'}\")\n", + "print(\"마스터 확정은 데이터관리관 승인으로만 가능하다. 실제 보안·행정 절차는 부대 규정과 지휘계통에 따른다(학습용 모의).\")\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 07 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + "[충돌 건] 다수값 후보가 있는 충돌 24건을 처리 대상으로 선정 [교육용가상자료]\n", + "확정 23건, 반려 1건, 고영향(군수참모 확인 필요) 4건, 확정 값이 가상 정답과 일치 100%\n", + "\n", + "[권한 우회 시도 결과]\n", + " - 시스템이 자동으로 마스터 확정: 차단 (권한 없음: 역할 '시스템'은(는) 마스터 확정 불가(자동 확정 금지))\n", + " - 체계담당자가 직접 확정: 차단 (권한 없음: 역할 '체계담당자'은(는) 마스터 확정 불가(자동 확정 금지))\n", + " - 열람자가 확정: 차단 (권한 없음: 역할 '열람자'은(는) 마스터 확정 불가(자동 확정 금지))\n", + " - 미등록 사용자 확정: 차단 (권한 없음: 역할 '미등록'은(는) 마스터 확정 불가(자동 확정 금지))\n", + " - 승인 근거 없이 확정: 차단 (승인 근거 5자 이상 필요)\n", + " - 제안 없는 키 확정: 차단 (제안 없는 키)\n", + " - 고영향 건을 군수참모 확인 없이 확정: 차단 (고영향 건은 군수참모 확인 선행 필요)\n", + " - 제안자 본인(관리관)이 승인: 차단 (제안자 본인 승인 금지)\n", + " - 체계담당자가 참모확인 대행: 차단 (고영향 건에 대한 군수참모만 가능)\n", + " - 이미 확정된 건 재승인: 차단 (이미 처리됨(확정))\n", + "우회 시도 10건 중 차단 10건(100%), 자동 확정 0건\n", + "\n", + "감사 이력 63건 해시 체인 검증 63/63(100%)\n", + "위조 시험: 53번 기록 변조 -> 검증 62/63 (변조 탐지 성공), 원복 후 63/63\n", + "[행위별 결과 요약]\n", + "action result\n", + "반려 반려 1\n", + "승인 차단 9\n", + " 확정 24\n", + "제안 접수 24\n", + "참모확인 완료 4\n", + " 차단 1\n", + "목표(이력 무결성 100%, 우회 차단율 100%): 달성\n", + "마스터 확정은 데이터관리관 승인으로만 가능하다. 실제 보안·행정 절차는 부대 규정과 지휘계통에 따른다(학습용 모의).\n", + "```\n", + "
\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 08. 수만 건의 이기종 군수 트랜잭션 동시 유입 시뮬레이션 부하 테스트\n", + "**소요시간**: 약 3.5시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "수만 건의 이기종 군수 트랜잭션 동시 유입 부하 테스트를 수행한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **세 체계에서 수만 건의 트랜잭션이 뒤섞여 유입되는 상황을 모의해 부하 테스트를 수행한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **체계별 코드 형식이 다른 트랜잭션 5천~5만 건을 만들고 중복 1%, 형식 오류 0.4%, 일시 장애 주입하여 처리 지연 및 복원력을 시험하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `src/pipeline/dat09_batch_runner.py` 형태의 결과물을 완성한다.\n", + "\n", + "### 🔍 평가 및 검증 기준 (PASS 조건)\n", + "#### 미션 판정\n", + "\n", + "**평가 구분:** 필수\n", + "\n", + "**핵심 평가영역:** 수작업 대비 업무 소요 시간 80% 이상 절감 효과\n", + "\n", + "#### 미션 PASS 조건\n", + "\n", + "- 정량 기준: 전체 배치 파이프라인 실행 시간 5분 이내, 자동화 성공률 99% 이상\n", + "\n", + "\n", + "- 진행 기준: 스케줄러 연동 테스트 및 자동 실행 확인\n", + "\n", + "\n", + "- 검증 방법: 실제 월간 업무 데이터 1년치 일괄 배치 시뮬레이션\n", + "\n", + "\n", + "#### 채점자 판정 메모\n", + "\n", + "> 단순 코드 실행 여부뿐만 아니라 출력된 수치와 근거 자료가 군 실무 요구사항에 부합하는지 면밀히 확인한다.\n", + "\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "\"\"\"세 체계에서 수만 건의 재고 변동 트랜잭션이 뒤섞여 유입되는 상황을 모의해 표준화·중복 제거·오류 격리·마이크로 배치 반영을 수행하고, 규모별 처리 시간과 자동화 성공률, 수작업 대비 절감률을 측정하는 부하 테스트를 실행한다.\"\"\"\n", + "import datetime\n", + "import time\n", + "import numpy as np\n", + "import pandas as pd\n", + "\n", + "# ---------------------------------------------------------------------------\n", + "# [교육용가상자료] 가상 이기종 군수정보체계 3종(A 보급, B 정비, C 탄약)의 재고 보고 생성기\n", + "# 실제 체계명·부대·품목·수량 없음. 같은 (품목, 부대) 재고를 세 체계가 서로 다른 코드·날짜 형식으로 보고한다.\n", + "# 기준 날짜: 2026-09-30(가상). 마스터(정답) 재고 TRUE 와 체계별 보고에는 의도적 불일치·결측이 섞여 있다.\n", + "# ---------------------------------------------------------------------------\n", + "N_PART, N_UNIT = 60, 12\n", + "CATS = [\"급식\", \"피복\", \"유류\", \"정비소모\", \"수리부속\", \"탄약\"]\n", + "PARTS = {p: {\"name\": f\"가상품목{p:02d}\", \"cat\": CATS[(p - 1) % 6]} for p in range(1, N_PART + 1)}\n", + "FMT = {\"A\": dict(part=lambda p: f\"AP-{p:05d}\", unit=lambda u: f\"U{u:02d}\", date=lambda d: d.strftime(\"%Y%m%d\")),\n", + " \"B\": dict(part=lambda p: f\"{p:05d}-X\", unit=lambda u: f\"가상{u:02d}대대\", date=lambda d: d.strftime(\"%Y-%m-%d\")),\n", + " \"C\": dict(part=lambda p: f\"P{p}\", unit=lambda u: str(u), date=lambda d: d.strftime(\"%d/%m/%Y\"))}\n", + "BASE_DATE = datetime.date(2026, 9, 30)\n", + "\n", + "\n", + "def make_systems(seed=9, mismatch=0.10, missing=0.03):\n", + " \"\"\"TRUE(정답 재고)와 체계별 원본 보고 레코드(체계 고유 형식)를 만든다.\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " true = {(p, u): int(rng.integers(20, 500)) for p in PARTS for u in range(1, N_UNIT + 1)}\n", + " out = {s: [] for s in \"ABC\"}\n", + " truth_diff = {} # (체계, p, u) -> 불일치 원인(정답)\n", + " for (p, u), q in true.items():\n", + " for s in \"ABC\":\n", + " qty, cause = q, None\n", + " r = rng.random()\n", + " if r < mismatch / 3: # 입력 오류(자릿수/오타)\n", + " qty, cause = q + int(rng.choice([-1, 1]) * rng.integers(10, 60)), \"입력 오류\"\n", + " elif r < 2 * mismatch / 3: # 반영 지연(전일 값)\n", + " qty, cause = q + int(rng.integers(5, 40)), \"반영 지연\"\n", + " elif r < mismatch: # 단위 오류(10배)\n", + " qty, cause = q * 10, \"단위 오류\"\n", + " d = BASE_DATE - datetime.timedelta(days=int(cause == \"반영 지연\"))\n", + " if rng.random() < missing: # 결측\n", + " qty, cause = None, \"결측\"\n", + " if cause:\n", + " truth_diff[(s, p, u)] = cause\n", + " out[s].append({\"part\": FMT[s][\"part\"](p), \"unit\": FMT[s][\"unit\"](u), \"date\": FMT[s][\"date\"](d), \"qty\": qty})\n", + " return true, out, truth_diff\n", + "\n", + "\n", + "def parse_part(code):\n", + " # [TODO : 학생 실습 8] 수만 건의 이기종 군수 트랜잭션 동시 유입 시뮬레이션 부하 테스트 관련 핵심 로직을 구현하세요.\n", + " # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n", + " # <<< 여기에 코드를 작성하세요 >>>\n", + " pass # 작성 후 pass를 제거하세요.\n", + "def parse_unit(code):\n", + " # [TODO : 학생 실습 8] 수만 건의 이기종 군수 트랜잭션 동시 유입 시뮬레이션 부하 테스트 관련 핵심 로직을 구현하세요.\n", + " # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n", + " # <<< 여기에 코드를 작성하세요 >>>\n", + " pass # 작성 후 pass를 제거하세요.\n", + "def parse_date(s):\n", + " if \"/\" in s:\n", + " d, m, y = s.split(\"/\"); return datetime.date(int(y), int(m), int(d))\n", + " if \"-\" in s:\n", + " return datetime.date(*map(int, s.split(\"-\")))\n", + " return datetime.date(int(s[:4]), int(s[4:6]), int(s[6:]))\n", + "\n", + "def make_tx(n, seed=8):\n", + " \"\"\"n건 트랜잭션: 체계별 고유 코드 형식, 도착 시각 뒤섞임, 중복 1%, 형식 오류 0.4%.\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " sysn = rng.choice(list(\"ABC\"), n, p=[0.45, 0.30, 0.25])\n", + " p, u = rng.integers(1, N_PART + 1, n), rng.integers(1, N_UNIT + 1, n)\n", + " delta = rng.integers(-20, 21, n)\n", + " part = np.where(sysn == \"A\", [f\"AP-{x:05d}\" for x in p], np.where(sysn == \"B\", [f\"{x:05d}-X\" for x in p], [f\"P{x}\" for x in p]))\n", + " unit = np.where(sysn == \"A\", [f\"U{x:02d}\" for x in u], np.where(sysn == \"B\", [f\"가상{x:02d}대대\" for x in u], [str(x) for x in u]))\n", + " tx = pd.DataFrame({\"id\": [f\"TX{i:06d}\" for i in range(n)], \"sys\": sysn, \"part\": part, \"unit\": unit, \"delta\": delta,\n", + " \"ts\": np.sort(rng.integers(0, 3600 * 1000, n))})\n", + " dup = tx.sample(int(n * 0.01), random_state=seed)\n", + " tx = pd.concat([tx, dup]).sample(frac=1, random_state=seed).reset_index(drop=True) # 중복 포함, 도착 순서 뒤섞기\n", + " bad = rng.choice(len(tx), int(len(tx) * 0.004), replace=False)\n", + " tx.loc[bad, \"part\"] = \"??-\" + tx.loc[bad, \"part\"] # 형식 오류 주입\n", + " return tx\n", + "\n", + "\n", + "def process(tx, batch=2000, flaky=()):\n", + " \"\"\"마이크로 배치 처리. flaky에 든 배치 번호는 첫 시도에서 일시 장애가 나며 재시도로 복구한다.\"\"\"\n", + " stock = {(p, u): 0 for p in PARTS for u in range(1, N_UNIT + 1)}\n", + " seen, stat = set(), {\"ok\": 0, \"dup\": 0, \"quarantine\": 0, \"retry\": 0}\n", + " for b, i in enumerate(range(0, len(tx), batch)):\n", + " chunk = tx.iloc[i:i + batch]\n", + " for attempt in (1, 2, 3):\n", + " if b in flaky and attempt == 1:\n", + " stat[\"retry\"] += 1; continue # 일시 장애: 같은 배치를 다시 시도\n", + " e1 = chunk[\"part\"].str.extract(r\"^(?:AP-(\\d+)|(\\d+)-X|P(\\d+))$\")\n", + " e2 = chunk[\"unit\"].str.extract(r\"^(?:U(\\d+)|가상(\\d+)대대|(\\d+))$\")\n", + " p, u = e1[0].fillna(e1[1]).fillna(e1[2]), e2[0].fillna(e2[1]).fillna(e2[2])\n", + " valid = p.notna() & u.notna()\n", + " isdup = chunk[\"id\"].isin(seen) | chunk[\"id\"].duplicated()\n", + " stat[\"quarantine\"] += int((~valid).sum()); stat[\"dup\"] += int((valid & isdup).sum())\n", + " good = chunk[valid & ~isdup]\n", + " seen.update(good[\"id\"])\n", + " pp, uu = p[good.index].astype(int), u[good.index].astype(int)\n", + " for k, d in pd.DataFrame({\"p\": pp, \"u\": uu, \"d\": good[\"delta\"]}).groupby([\"p\", \"u\"])[\"d\"].sum().items():\n", + " stock[k] += int(d)\n", + " stat[\"ok\"] += len(good)\n", + " break\n", + " return stock, stat\n", + "\n", + "\n", + "print(\"[규모별 부하 테스트] 체계 3종 트랜잭션 동시 유입 모의 [교육용가상자료]\")\n", + "res = []\n", + "for n in (5000, 20000, 50000):\n", + " tx = make_tx(n)\n", + " t0 = time.perf_counter(); stock, st = process(tx, flaky=(1, 3)); dt = time.perf_counter() - t0\n", + " res.append({\"유입 건수\": len(tx), \"처리 시간(초)\": round(dt, 2), \"초당 처리\": int(len(tx) / dt), **st})\n", + " succ = (st[\"ok\"] + st[\"dup\"]) / len(tx)\n", + " res[-1][\"자동 처리율\"] = f\"{succ:.2%}\"\n", + "print(pd.DataFrame(res).to_string(index=False))\n", + "\n", + "# 정합성: 마지막 규모에서 일괄(벡터) 계산 결과와 마이크로 배치 결과 비교\n", + "tx2 = tx[~tx[\"part\"].str.startswith(\"??\")].drop_duplicates(\"id\")\n", + "p = tx2[\"part\"].str.extract(r\"(\\d+)\")[0].astype(int)\n", + "u = tx2[\"unit\"].str.extract(r\"(\\d+)\")[0].astype(int)\n", + "ref = tx2.assign(p=p, u=u).groupby([\"p\", \"u\"])[\"delta\"].sum()\n", + "stock, st = process(tx, flaky=(1, 3))\n", + "mism = sum(stock[(a, b)] != int(v) for (a, b), v in ref.items())\n", + "print(f\"\\n정합성: 일괄 계산과 배치 결과 비교 {len(ref)}개 키 중 불일치 {mism}건\")\n", + "n = len(tx); success = (st[\"ok\"] + st[\"dup\"]) / n\n", + "print(f\"전체 {n}건: 정상 반영 {st['ok']}, 중복 제거 {st['dup']}, 형식 오류 격리 {st['quarantine']}(담당자 확인), 일시 장애 재시도 {st['retry']}회\")\n", + "total_s = res[-1][\"처리 시간(초)\"]\n", + "print(f\"자동화 성공률 {success:.2%} (격리 건은 사람이 확인), 최대 규모 처리 시간 {total_s}초(목표 5분 이내: {'달성' if total_s < 300 else '미달'})\")\n", + "manual = n * 0.4 # 가정: 수작업 확인·입력 0.4분/건\n", + "auto = n * 0.01 + st[\"quarantine\"] * 4 + total_s / 60 # 가정: 표본 검토 0.01분/건, 격리 건 수동 4분\n", + "print(f\"업무 소요(가정값): 수작업 {manual:.0f}분 vs 자동화 {auto:.0f}분 -> 절감 {1 - auto / manual:.1%}\")\n", + "print(f\"\\n목표(5분 이내, 자동화 성공률 99% 이상, 수작업 대비 80% 이상 절감): \"\n", + " f\"{'달성' if total_s < 300 and success >= 0.99 and 1 - auto / manual >= 0.8 and mism == 0 else '미달'}\")\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 08 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + "[규모별 부하 테스트] 체계 3종 트랜잭션 동시 유입 모의 [교육용가상자료]\n", + " 유입 건수 처리 시간(초) 초당 처리 ok dup quarantine retry 자동 처리율\n", + " 5050 0.04 114205 4982 48 20 1 99.60%\n", + " 20200 0.20 100425 19921 199 80 2 99.60%\n", + " 50500 0.69 73706 49801 497 202 2 99.60%\n", + "\n", + "정합성: 일괄 계산과 배치 결과 비교 720개 키 중 불일치 0건\n", + "전체 50500건: 정상 반영 49801, 중복 제거 497, 형식 오류 격리 202(담당자 확인), 일시 장애 재시도 2회\n", + "자동화 성공률 99.60% (격리 건은 사람이 확인), 최대 규모 처리 시간 0.69초(목표 5분 이내: 달성)\n", + "업무 소요(가정값): 수작업 20200분 vs 자동화 1313분 -> 절감 93.5%\n", + "\n", + "목표(5분 이내, 자동화 성공률 99% 이상, 수작업 대비 80% 이상 절감): 달성\n", + "```\n", + "
\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 09. AAR 기반 데이터 연동 누락 분석 및 매핑 테이블 파라미터 재튜닝\n", + "**소요시간**: 약 3.0시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "AAR 기반 데이터 연동 누락을 분석하고 매핑 파라미터를 재튜닝한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **AAR에서 드러난 데이터 연동 누락 5유형을 재현·분석하고 매핑 규칙과 판정 파라미터를 재튜닝한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **코드 자릿수, 부대명, 날짜 형식, 결측 표기, 신규 품목의 5유형 변형을 가상 보고에 심어 매핑 v1의 누락을 재현 수행하고 검증하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `docs/dat09_uat_feedback_report.md 및 패치 내역` 형태의 결과물을 완성한다.\n", + "\n", + "### 🔍 평가 및 검증 기준 (PASS 조건)\n", + "#### 미션 판정\n", + "\n", + "**평가 구분:** 필수\n", + "\n", + "**핵심 평가영역:** 실무 업무 생산성 기여도 및 현장 적응성\n", + "\n", + "#### 미션 PASS 조건\n", + "\n", + "- 정량 기준: UAT 체크리스트 만족도 90점 이상, 개선 요구 5건 반영 완료\n", + "\n", + "\n", + "- 진행 기준: UAT 결과 보고서 작성 및 최종 버그 픽스 완료\n", + "\n", + "\n", + "- 검증 방법: 실제 업무 시나리오 기반 최종 검수 테스트\n", + "\n", + "\n", + "#### 채점자 판정 메모\n", + "\n", + "> 단순 코드 실행 여부뿐만 아니라 출력된 수치와 근거 자료가 군 실무 요구사항에 부합하는지 면밀히 확인한다.\n", + "\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "\"\"\"AAR에서 드러난 체계 연동 누락 5유형(코드 자릿수 변형, 부대명 변형, 날짜 형식 변형, 결측 표기 변형, 신규 품목)을 재현해 매핑 규칙 v1의 누락을 분석하고, 허용 오차·지연 일수 파라미터를 격자 탐색으로 재튜닝한 v2와 UAT 체크리스트 점수로 개선 효과를 검증한다.\"\"\"\n", + "import datetime\n", + "import itertools\n", + "import re\n", + "import numpy as np\n", + "import pandas as pd\n", + "\n", + "# ---------------------------------------------------------------------------\n", + "# [교육용가상자료] 가상 이기종 군수정보체계 3종(A 보급, B 정비, C 탄약)의 재고 보고 생성기\n", + "# 실제 체계명·부대·품목·수량 없음. 같은 (품목, 부대) 재고를 세 체계가 서로 다른 코드·날짜 형식으로 보고한다.\n", + "# 기준 날짜: 2026-09-30(가상). 마스터(정답) 재고 TRUE 와 체계별 보고에는 의도적 불일치·결측이 섞여 있다.\n", + "# ---------------------------------------------------------------------------\n", + "N_PART, N_UNIT = 60, 12\n", + "CATS = [\"급식\", \"피복\", \"유류\", \"정비소모\", \"수리부속\", \"탄약\"]\n", + "PARTS = {p: {\"name\": f\"가상품목{p:02d}\", \"cat\": CATS[(p - 1) % 6]} for p in range(1, N_PART + 1)}\n", + "FMT = {\"A\": dict(part=lambda p: f\"AP-{p:05d}\", unit=lambda u: f\"U{u:02d}\", date=lambda d: d.strftime(\"%Y%m%d\")),\n", + " \"B\": dict(part=lambda p: f\"{p:05d}-X\", unit=lambda u: f\"가상{u:02d}대대\", date=lambda d: d.strftime(\"%Y-%m-%d\")),\n", + " \"C\": dict(part=lambda p: f\"P{p}\", unit=lambda u: str(u), date=lambda d: d.strftime(\"%d/%m/%Y\"))}\n", + "BASE_DATE = datetime.date(2026, 9, 30)\n", + "\n", + "\n", + "def make_systems(seed=9, mismatch=0.10, missing=0.03):\n", + " \"\"\"TRUE(정답 재고)와 체계별 원본 보고 레코드(체계 고유 형식)를 만든다.\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " true = {(p, u): int(rng.integers(20, 500)) for p in PARTS for u in range(1, N_UNIT + 1)}\n", + " out = {s: [] for s in \"ABC\"}\n", + " truth_diff = {} # (체계, p, u) -> 불일치 원인(정답)\n", + " for (p, u), q in true.items():\n", + " for s in \"ABC\":\n", + " qty, cause = q, None\n", + " r = rng.random()\n", + " if r < mismatch / 3: # 입력 오류(자릿수/오타)\n", + " qty, cause = q + int(rng.choice([-1, 1]) * rng.integers(10, 60)), \"입력 오류\"\n", + " elif r < 2 * mismatch / 3: # 반영 지연(전일 값)\n", + " qty, cause = q + int(rng.integers(5, 40)), \"반영 지연\"\n", + " elif r < mismatch: # 단위 오류(10배)\n", + " qty, cause = q * 10, \"단위 오류\"\n", + " d = BASE_DATE - datetime.timedelta(days=int(cause == \"반영 지연\"))\n", + " if rng.random() < missing: # 결측\n", + " qty, cause = None, \"결측\"\n", + " if cause:\n", + " truth_diff[(s, p, u)] = cause\n", + " out[s].append({\"part\": FMT[s][\"part\"](p), \"unit\": FMT[s][\"unit\"](u), \"date\": FMT[s][\"date\"](d), \"qty\": qty})\n", + " return true, out, truth_diff\n", + "\n", + "\n", + "def parse_part(code):\n", + " # [TODO : 학생 실습 9] AAR 기반 데이터 연동 누락 분석 및 매핑 테이블 파라미터 재튜닝 관련 핵심 로직을 구현하세요.\n", + " # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n", + " # <<< 여기에 코드를 작성하세요 >>>\n", + " pass # 작성 후 pass를 제거하세요.\n", + "def parse_unit(code):\n", + " # [TODO : 학생 실습 9] AAR 기반 데이터 연동 누락 분석 및 매핑 테이블 파라미터 재튜닝 관련 핵심 로직을 구현하세요.\n", + " # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n", + " # <<< 여기에 코드를 작성하세요 >>>\n", + " pass # 작성 후 pass를 제거하세요.\n", + "def parse_date(s):\n", + " if \"/\" in s:\n", + " d, m, y = s.split(\"/\"); return datetime.date(int(y), int(m), int(d))\n", + " if \"-\" in s:\n", + " return datetime.date(*map(int, s.split(\"-\")))\n", + " return datetime.date(int(s[:4]), int(s[4:6]), int(s[6:]))\n", + "\n", + "true, raw, tdiff = make_systems()\n", + "rng = np.random.default_rng(9)\n", + "\n", + "# 1) AAR 사례 재현: 일부 보고를 체계 담당자가 다르게 쓴 형태로 바꾼다(변형 5유형)\n", + "VAR = {\"코드 자릿수 변형\": [], \"부대명 변형\": [], \"날짜 형식 변형\": [], \"결측 표기 변형\": [], \"신규 품목\": []}\n", + "def pick(s, n, f=lambda r: True):\n", + " idx = [i for i, r in enumerate(raw[s]) if f(r) and not any(i == j for v in VAR.values() for (s2, j) in v if s2 == s)]\n", + " return [(s, int(i)) for i in rng.choice(idx, n, replace=False)]\n", + "for s, i in pick(\"A\", 25):\n", + " raw[s][i][\"part\"] = \"AP-\" + raw[s][i][\"part\"][-4:]; VAR[\"코드 자릿수 변형\"].append((s, i))\n", + "for s, i in pick(\"B\", 30):\n", + " raw[s][i][\"unit\"] = \"가상\" + str(int(raw[s][i][\"unit\"][2:4])) + \"대대\"; VAR[\"부대명 변형\"].append((s, i))\n", + "for s, i in pick(\"C\", 30):\n", + " d, m, y = raw[s][i][\"date\"].split(\"/\"); raw[s][i][\"date\"] = f\"{d}-{m}-{y}\"; VAR[\"날짜 형식 변형\"].append((s, i))\n", + "for s, i in pick(\"A\", 20, lambda r: r[\"qty\"] is not None):\n", + " raw[s][i][\"qty\"] = \"N/A\"; VAR[\"결측 표기 변형\"].append((s, i))\n", + "for s, i in pick(\"B\", 15):\n", + " raw[s][i][\"part\"] = \"00061-X\"; VAR[\"신규 품목\"].append((s, i))\n", + "for s in \"ABC\": # 반올림 차이 등 정상 변동(+-1)\n", + " for i in rng.choice(len(raw[s]), 60, replace=False):\n", + " if isinstance(raw[s][i][\"qty\"], int):\n", + " raw[s][i][\"qty\"] += int(rng.choice([-1, 1]))\n", + "\n", + "# 2) 매핑 규칙 v1(엄격) / v2(보정)\n", + "PAT1 = {\"part\": r\"^(?:AP-(\\d{5})|(\\d{5})-X|P(\\d+))$\", \"unit\": r\"^(?:U(\\d{2})|가상(\\d{2})대대|(\\d+))$\"}\n", + "PAT2 = {\"part\": r\"^(?:AP-(\\d{4,5})|(\\d{5})-X|P(\\d+))$\", \"unit\": r\"^(?:U(\\d{2})|가상(\\d{1,2})대대|(\\d+))$\"}\n", + "def g(pat, s):\n", + " m = re.match(pat, s)\n", + " return int(next(x for x in m.groups() if x)) if m else None\n", + "def pdate(s, v2):\n", + " for f in (\"%Y%m%d\", \"%Y-%m-%d\", \"%d/%m/%Y\") + ((\"%d-%m-%Y\",) if v2 else ()):\n", + " try:\n", + " return datetime.datetime.strptime(s, f).date()\n", + " except ValueError:\n", + " pass\n", + " return None\n", + "def link(v2):\n", + " pat, out, unlinked, held = (PAT2 if v2 else PAT1), [], 0, 0\n", + " for s in \"ABC\":\n", + " for i, r in enumerate(raw[s]):\n", + " p, u, d = g(pat[\"part\"], r[\"part\"]), g(pat[\"unit\"], r[\"unit\"]), pdate(r[\"date\"], v2)\n", + " q = r[\"qty\"]\n", + " if q == \"N/A\":\n", + " q = None if v2 else \"ERR\"\n", + " if p is None or u is None or d is None or q == \"ERR\":\n", + " unlinked += 1; continue\n", + " if p > N_PART and v2:\n", + " held += 1; continue # v2: 신규 품목은 조용히 버리지 않고 '신규 품목 신청' 대상으로 보류\n", + " if p > N_PART:\n", + " unlinked += 1; continue\n", + " out.append({\"sys\": s, \"p\": p, \"u\": u, \"date\": d, \"qty\": q})\n", + " return pd.DataFrame(out), unlinked, held\n", + "d1, un1, h1 = link(False); d2, un2, h2 = link(True)\n", + "total = sum(len(v) for v in raw.values())\n", + "print(f\"[AAR 연동 누락 분석] 전체 보고 {total}건 [교육용가상자료]\")\n", + "print(f\"매핑 v1: 연동 {len(d1)}건, 누락 {un1}건({un1 / total:.1%}) | 매핑 v2: 연동 {len(d2)}건, 누락 {un2}건, 신규 품목 보류 {h2}건\")\n", + "# 사례별 v1 누락 건수는 직접 재판정\n", + "def lost(s, i, v2):\n", + " r = raw[s][i]; pat = PAT2 if v2 else PAT1\n", + " p, u, d = g(pat[\"part\"], r[\"part\"]), g(pat[\"unit\"], r[\"unit\"]), pdate(r[\"date\"], v2)\n", + " return p is None or u is None or d is None or (not v2 and (r[\"qty\"] == \"N/A\" or p > N_PART))\n", + "tab = pd.DataFrame([{\"사례\": k, \"발생\": len(v), \"v1 누락\": sum(lost(s, i, False) for s, i in v), \"v2 누락\": sum(lost(s, i, True) for s, i in v)} for k, v in VAR.items()])\n", + "print(tab.to_string(index=False))\n", + "\n", + "# 3) 불일치 판정 파라미터 재튜닝: 수량 허용 오차(%)와 허용 지연 일수의 격자 탐색(가상 정답 대비 F1)\n", + "gt = {(p, u) for (_, p, u), c in tdiff.items() if c != \"결측\"}\n", + "def detect(df, tol, lag):\n", + " df = df.copy(); df[\"q\"] = pd.to_numeric(df[\"qty\"], errors=\"coerce\")\n", + " w = df.pivot_table(index=[\"p\", \"u\"], columns=\"sys\", values=\"q\", aggfunc=\"first\")\n", + " med = w.median(axis=1)\n", + " dev = w.sub(med, axis=0).abs().div(med.clip(lower=1), axis=0)\n", + " late = df.assign(l=(BASE_DATE - df[\"date\"]).map(lambda x: x.days)).pivot_table(index=[\"p\", \"u\"], columns=\"sys\", values=\"l\", aggfunc=\"first\")\n", + " flag = ((dev > tol) | (late.reindex(w.index) > lag).fillna(False) & (dev > 0)).any(axis=1)\n", + " return set(flag[flag].index)\n", + "def f1(found):\n", + " tp = len(found & gt); pr = tp / max(len(found), 1); rc = tp / len(gt)\n", + " return 2 * pr * rc / max(pr + rc, 1e-9), pr, rc\n", + "grid = []\n", + "for tol, lag in itertools.product((0.0, 0.005, 0.02, 0.05), (0, 1, 2)):\n", + " grid.append({\"허용오차\": tol, \"허용지연(일)\": lag, **dict(zip((\"F1\", \"정밀도\", \"재현율\"), f1(detect(d2, tol, lag))))})\n", + "G = pd.DataFrame(grid).sort_values(\"F1\", ascending=False)\n", + "best = G.iloc[0]\n", + "v1p = f1(detect(d2, 0.0, 0))\n", + "print(\"\\n[파라미터 재튜닝 - 상위 3개 조합]\")\n", + "print(G.head(3).round(3).to_string(index=False))\n", + "print(f\"튜닝 전(오차 0, 지연 0) F1 {v1p[0]:.3f} -> 튜닝 후 F1 {best['F1']:.3f} (허용오차 {best['허용오차']}, 허용지연 {int(best['허용지연(일)'])}일)\")\n", + "\n", + "# 4) UAT 체크리스트(자동 점검 항목) + 개선 요구 5건\n", + "tests = [(f\"{k} 사례 v2에서 해소\", int(r[\"v2 누락\"]) == 0) for k, r in tab.set_index(\"사례\").iterrows()]\n", + "tests += [(\"v2 연동률이 v1보다 높음\", len(d2) > len(d1)), (\"신규 품목은 누락이 아니라 보류로 분류\", h2 == 15), (\"v1 연동 키가 v2에 모두 포함\", set(zip(d1[\"sys\"], d1[\"p\"], d1[\"u\"])) <= set(zip(d2[\"sys\"], d2[\"p\"], d2[\"u\"]))),\n", + " (\"튜닝 후 F1이 튜닝 전보다 높음\", best[\"F1\"] > v1p[0]), (\"재현율 95% 이상\", best[\"재현율\"] >= 0.95), (\"정밀도 90% 이상\", best[\"정밀도\"] >= 0.9),\n", + " (\"연동 키가 (체계, 품목, 부대)로 유일\", not d2.duplicated([\"sys\", \"p\", \"u\"]).any()), (\"날짜 파싱 100%\", d2[\"date\"].notna().all()),\n", + " (\"v2 코드 재실행 결과 동일\", len(link(True)[0]) == len(d2)), (\"튜닝 격자 12조합 평가\", len(G) == 12), (\"v1 누락 건수가 사례 합계와 일치\", int(tab[\"v1 누락\"].sum()) == un1),\n", + " (\"원인-패치 대응표 작성\", len(VAR) == 5), (\"재튜닝 파라미터 기록\", best[\"허용오차\"] >= 0), (\"개선 요구 5건 반영\", all(tab[\"v2 누락\"] == 0))]\n", + "passed = sum(ok for _, ok in tests)\n", + "score = 100 * passed / len(tests)\n", + "print(f\"\\n[UAT 체크리스트] {passed}/{len(tests)}항목 통과, 점수 {score:.0f}점\")\n", + "for n, ok in tests:\n", + " if not ok:\n", + " print(\" 미통과:\", n)\n", + "print(\"[개선 요구 반영 현황]\")\n", + "PATCH = [\"품목코드 4~5자리 허용\", \"부대명 1~2자리 허용\", \"날짜 형식 DD-MM-YYYY 추가\", \"결측 표기 N/A를 결측으로 처리\", \"신규 품목은 보류 후 신청 대상으로 표시\"]\n", + "for i, ((k, r), pt) in enumerate(zip(tab.set_index(\"사례\").iterrows(), PATCH), 1):\n", + " print(f\" 요구 {i}. {k}: {pt} -> {'반영 완료' if r['v2 누락'] == 0 else '미반영'}\")\n", + "print(f\"\\n목표(UAT 90점 이상, 개선 요구 5건 반영): {'달성' if score >= 90 and all(tab['v2 누락'] == 0) else '미달'}\")\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 09 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + "[AAR 연동 누락 분석] 전체 보고 2160건 [교육용가상자료]\n", + "매핑 v1: 연동 2045건, 누락 115건(5.3%) | 매핑 v2: 연동 2145건, 누락 0건, 신규 품목 보류 15건\n", + " 사례 발생 v1 누락 v2 누락\n", + "코드 자릿수 변형 25 25 0\n", + " 부대명 변형 30 25 0\n", + " 날짜 형식 변형 30 30 0\n", + " 결측 표기 변형 20 20 0\n", + " 신규 품목 15 15 0\n", + "\n", + "[파라미터 재튜닝 - 상위 3개 조합]\n", + " 허용오차 허용지연(일) F1 정밀도 재현율\n", + " 0.02 0 0.972 0.972 0.972\n", + " 0.02 1 0.963 0.972 0.955\n", + " 0.02 2 0.963 0.972 0.955\n", + "튜닝 전(오차 0, 지연 0) F1 0.738 -> 튜닝 후 F1 0.972 (허용오차 0.02, 허용지연 0일)\n", + "\n", + "[UAT 체크리스트] 19/19항목 통과, 점수 100점\n", + "[개선 요구 반영 현황]\n", + " 요구 1. 코드 자릿수 변형: 품목코드 4~5자리 허용 -> 반영 완료\n", + " 요구 2. 부대명 변형: 부대명 1~2자리 허용 -> 반영 완료\n", + " 요구 3. 날짜 형식 변형: 날짜 형식 DD-MM-YYYY 추가 -> 반영 완료\n", + " 요구 4. 결측 표기 변형: 결측 표기 N/A를 결측으로 처리 -> 반영 완료\n", + " 요구 5. 신규 품목: 신규 품목은 보류 후 신청 대상으로 표시 -> 반영 완료\n", + "\n", + "목표(UAT 90점 이상, 개선 요구 5건 반영): 달성\n", + "```\n", + "
\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 10. 이기종 군수 정보체계 데이터 통합 및 실시간 가시화 대시보드 최종 완성\n", + "**소요시간**: 약 3.0시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "이기종 군수 정보체계 데이터 통합 및 실시간 가시화 대시보드를 완성한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **수집부터 불일치 식별, 품질 지수, 마스터 승인 대기 분리, 상황판 가시화까지 한 번에 실행되는 통합 군수 상황판 도구를 완성한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **환경 점검 후 세 체계 자료를 수집·표준화하고 불일치를 식별해 품질 지수 산출하고 모델에 적용하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `dist/dat09_package/, README.md, docs/dat09_final_manual.md` 형태의 결과물을 완성한다.\n", + "\n", + "### 🔍 평가 및 검증 기준 (PASS 조건)\n", + "#### 미션 판정\n", + "\n", + "**평가 구분:** 필수\n", + "\n", + "**핵심 평가영역:** 상용 소프트웨어 수준의 완성도 및 조직 확산 가치\n", + "\n", + "#### 미션 PASS 조건\n", + "\n", + "- 정량 기준: 설치부터 실행까지 원클릭 설치 스크립트 완비, 매뉴얼 작성률 100%\n", + "\n", + "\n", + "- 진행 기준: 최종 배포본 생성 및 검수 완료\n", + "\n", + "\n", + "- 검증 방법: Clean 환경에서의 설치 및 초기화 테스트\n", + "\n", + "\n", + "#### 채점자 판정 메모\n", + "\n", + "> 단순 코드 실행 여부뿐만 아니라 출력된 수치와 근거 자료가 군 실무 요구사항에 부합하는지 면밀히 확인한다.\n", + "\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "\"\"\"환경 점검부터 3개 체계 자료 수집·표준화, 체계 간 불일치 식별, 품질 지수 산출, 마스터 후보의 데이터관리관 승인 대기 분리, 통합 군수 상황판 가시화까지 한 번에 실행하는 도구를 완성하고 설치 개요·매뉴얼 목차·자체 점검·브리핑 요약을 출력한다.\"\"\"\n", + "import collections\n", + "import csv\n", + "import datetime\n", + "import importlib.util\n", + "import io\n", + "import json\n", + "import sys\n", + "import time\n", + "import numpy as np\n", + "import pandas as pd\n", + "import matplotlib.pyplot as plt\n", + "\n", + "# ---------------------------------------------------------------------------\n", + "# [교육용가상자료] 가상 이기종 군수정보체계 3종(A 보급, B 정비, C 탄약)의 재고 보고 생성기\n", + "# 실제 체계명·부대·품목·수량 없음. 같은 (품목, 부대) 재고를 세 체계가 서로 다른 코드·날짜 형식으로 보고한다.\n", + "# 기준 날짜: 2026-09-30(가상). 마스터(정답) 재고 TRUE 와 체계별 보고에는 의도적 불일치·결측이 섞여 있다.\n", + "# ---------------------------------------------------------------------------\n", + "N_PART, N_UNIT = 60, 12\n", + "CATS = [\"급식\", \"피복\", \"유류\", \"정비소모\", \"수리부속\", \"탄약\"]\n", + "PARTS = {p: {\"name\": f\"가상품목{p:02d}\", \"cat\": CATS[(p - 1) % 6]} for p in range(1, N_PART + 1)}\n", + "FMT = {\"A\": dict(part=lambda p: f\"AP-{p:05d}\", unit=lambda u: f\"U{u:02d}\", date=lambda d: d.strftime(\"%Y%m%d\")),\n", + " \"B\": dict(part=lambda p: f\"{p:05d}-X\", unit=lambda u: f\"가상{u:02d}대대\", date=lambda d: d.strftime(\"%Y-%m-%d\")),\n", + " \"C\": dict(part=lambda p: f\"P{p}\", unit=lambda u: str(u), date=lambda d: d.strftime(\"%d/%m/%Y\"))}\n", + "BASE_DATE = datetime.date(2026, 9, 30)\n", + "\n", + "\n", + "def make_systems(seed=9, mismatch=0.10, missing=0.03):\n", + " \"\"\"TRUE(정답 재고)와 체계별 원본 보고 레코드(체계 고유 형식)를 만든다.\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " true = {(p, u): int(rng.integers(20, 500)) for p in PARTS for u in range(1, N_UNIT + 1)}\n", + " out = {s: [] for s in \"ABC\"}\n", + " truth_diff = {} # (체계, p, u) -> 불일치 원인(정답)\n", + " for (p, u), q in true.items():\n", + " for s in \"ABC\":\n", + " qty, cause = q, None\n", + " r = rng.random()\n", + " if r < mismatch / 3: # 입력 오류(자릿수/오타)\n", + " qty, cause = q + int(rng.choice([-1, 1]) * rng.integers(10, 60)), \"입력 오류\"\n", + " elif r < 2 * mismatch / 3: # 반영 지연(전일 값)\n", + " qty, cause = q + int(rng.integers(5, 40)), \"반영 지연\"\n", + " elif r < mismatch: # 단위 오류(10배)\n", + " qty, cause = q * 10, \"단위 오류\"\n", + " d = BASE_DATE - datetime.timedelta(days=int(cause == \"반영 지연\"))\n", + " if rng.random() < missing: # 결측\n", + " qty, cause = None, \"결측\"\n", + " if cause:\n", + " truth_diff[(s, p, u)] = cause\n", + " out[s].append({\"part\": FMT[s][\"part\"](p), \"unit\": FMT[s][\"unit\"](u), \"date\": FMT[s][\"date\"](d), \"qty\": qty})\n", + " return true, out, truth_diff\n", + "\n", + "\n", + "def parse_part(code):\n", + " # [TODO : 학생 실습 10] 이기종 군수 정보체계 데이터 통합 및 실시간 가시화 대시보드 최종 완성 관련 핵심 로직을 구현하세요.\n", + " # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n", + " # <<< 여기에 코드를 작성하세요 >>>\n", + " pass # 작성 후 pass를 제거하세요.\n", + "def parse_unit(code):\n", + " # [TODO : 학생 실습 10] 이기종 군수 정보체계 데이터 통합 및 실시간 가시화 대시보드 최종 완성 관련 핵심 로직을 구현하세요.\n", + " # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n", + " # <<< 여기에 코드를 작성하세요 >>>\n", + " pass # 작성 후 pass를 제거하세요.\n", + "def parse_date(s):\n", + " if \"/\" in s:\n", + " d, m, y = s.split(\"/\"); return datetime.date(int(y), int(m), int(d))\n", + " if \"-\" in s:\n", + " return datetime.date(*map(int, s.split(\"-\")))\n", + " return datetime.date(int(s[:4]), int(s[4:6]), int(s[6:]))\n", + "\n", + "LOG = []\n", + "def stage(name):\n", + " def deco(fn):\n", + " def run(*a):\n", + " t0 = time.perf_counter()\n", + " try:\n", + " r = fn(*a); LOG.append((name, \"OK\", time.perf_counter() - t0)); return r\n", + " except Exception as ex: # 한 단계가 실패해도 도구는 멈추지 않고 로그를 남긴다\n", + " LOG.append((name, f\"FAIL {repr(ex)[:30]}\", time.perf_counter() - t0)); return None\n", + " return run\n", + " return deco\n", + "\n", + "INSTALL = [\"1. 가상 환경 생성, numpy·pandas·matplotlib 설치\", \"2. 환경 점검(모듈 확인)\", \"3. 샘플 3개 체계 자료로 시험 실행\", \"4. run_cop() 실행\", \"5. 상황판·승인 대기 목록 확인\"]\n", + "mods = [\"numpy\", \"pandas\", \"matplotlib\", \"csv\", \"json\"]\n", + "env_ok = sum(importlib.util.find_spec(m) is not None for m in mods)\n", + "print(f\"[0] 환경 점검: Python {sys.version_info.major}.{sys.version_info.minor}, 필수 모듈 {env_ok}/{len(mods)} 확인 [교육용가상자료]\")\n", + "\n", + "true, raw, tdiff = make_systems()\n", + "\n", + "@stage(\"수집\")\n", + "def collect():\n", + " a = \"\\n\".join([\"part,unit,date,qty\"] + [f\"{r['part']},{r['unit']},{r['date']},{'' if r['qty'] is None else r['qty']}\" for r in raw[\"A\"]])\n", + " b = json.dumps([{\"part\": r[\"part\"], \"unit\": r[\"unit\"], \"date\": r[\"date\"], \"qty\": r[\"qty\"]} for r in raw[\"B\"]], ensure_ascii=False)\n", + " c = \"\\n\".join([\"part\\tunit\\tdate\\tqty\"] + [f\"{r['part']}\\t{r['unit']}\\t{r['date']}\\t{-1 if r['qty'] is None else r['qty']}\" for r in raw[\"C\"]])\n", + " out = []\n", + " for s, rows in ((\"A\", list(csv.DictReader(io.StringIO(a)))), (\"B\", json.loads(b)), (\"C\", list(csv.DictReader(io.StringIO(c), delimiter=\"\\t\")))):\n", + " for r in rows:\n", + " q = r[\"qty\"]\n", + " q = None if q in (\"\", None, \"-1\", -1) else int(q)\n", + " out.append({\"sys\": s, \"p\": parse_part(r[\"part\"]), \"u\": parse_unit(r[\"unit\"]), \"date\": parse_date(r[\"date\"]), \"qty\": q})\n", + " return pd.DataFrame(out)\n", + "\n", + "@stage(\"불일치 식별\")\n", + "def reconcile(df):\n", + " w = df.pivot_table(index=[\"p\", \"u\"], columns=\"sys\", values=\"qty\", aggfunc=\"first\")\n", + " rows = []\n", + " for k, r in w.iterrows():\n", + " have = r.dropna()\n", + " top, n = collections.Counter(have.values).most_common(1)[0] if len(have) else (None, 0)\n", + " diff = (have != top).any() or len(have) < 3\n", + " rows.append({\"p\": k[0], \"u\": k[1], \"mismatch\": bool(diff), \"cand\": top if n >= 2 else None, \"n_missing\": 3 - len(have)})\n", + " return pd.DataFrame(rows)\n", + "\n", + "@stage(\"품질 지수\")\n", + "def quality(df, rec):\n", + " comp = df[\"qty\"].notna().mean(); cons = 1 - rec[\"mismatch\"].mean()\n", + " tim = ((BASE_DATE - df[\"date\"]).map(lambda d: d.days) <= 0).mean()\n", + " return 100 * (0.3 * comp + 0.5 * cons + 0.2 * tim)\n", + "\n", + "@stage(\"승인 대기 분리\")\n", + "def queue(rec):\n", + " q = rec[rec[\"mismatch\"]].copy()\n", + " q[\"status\"] = np.where(q[\"cand\"].notna(), \"데이터관리관 승인 대기\", \"사람 판단 필요(후보 없음)\")\n", + " return q\n", + "\n", + "@stage(\"상황판\")\n", + "def cop(df):\n", + " base = df[df[\"sys\"] == \"A\"].dropna(subset=[\"qty\"]).copy()\n", + " base[\"cat\"] = base[\"p\"].map(lambda p: PARTS[p][\"cat\"])\n", + " return base.groupby(\"cat\")[\"qty\"].sum(), base.groupby(\"u\")[\"qty\"].sum()\n", + "\n", + "df = collect()\n", + "rec = reconcile(df)\n", + "qi = quality(df, rec)\n", + "q = queue(rec)\n", + "cat_tot, unit_tot = cop(df)\n", + "\n", + "print(f\"[1] 수집·표준화 {len(df)}건(3개 체계), 통합 키 {len(rec)}개\")\n", + "print(f\"[2] 체계 간 불일치 {int(rec['mismatch'].sum())}개({rec['mismatch'].mean():.1%}), 통합 데이터 품질 지수 {qi:.1f}점\")\n", + "print(\"[3] 마스터 확정 대기 현황(자동 확정 없음):\", q[\"status\"].value_counts().to_dict())\n", + "print(\"[4] 통합 군수 상황판 - 분류별 재고(체계 A 기준):\", cat_tot.astype(int).to_dict())\n", + "print(\" 재고 적은 부대 3곳:\", unit_tot.nsmallest(3).astype(int).to_dict())\n", + "print(\"[5] 단계별 실행 로그:\")\n", + "for n, st, t in LOG:\n", + " print(f\" - {n:<8} {st:<5} {t * 1000:.1f}ms\")\n", + "run_ms = sum(t for _, _, t in LOG) * 1000\n", + "\n", + "fig, ax = plt.subplots(1, 3, figsize=(13, 3.8))\n", + "ax[0].bar([f\"cat{i + 1}\" for i in range(len(cat_tot))], cat_tot.values, color=\"tab:blue\"); ax[0].set_title(\"Stock by category\")\n", + "ax[1].bar(unit_tot.index, unit_tot.values, color=\"tab:gray\"); ax[1].set_title(\"Stock by unit\")\n", + "ax[2].bar([\"pending\", \"no candidate\"], [int((q[\"status\"].str.startswith(\"데이터\")).sum()), int((~q[\"status\"].str.startswith(\"데이터\")).sum())], color=[\"tab:orange\", \"tab:red\"])\n", + "ax[2].set_title(\"Master approval queue\")\n", + "plt.suptitle(\"DAT09 integrated logistics board (synthetic)\"); plt.tight_layout(); plt.show()\n", + "\n", + "MANUAL = [\"1. 개요·설치\", \"2. 체계별 입력 규격\", \"3. 코드 변환표·매핑 규칙\", \"4. 불일치 처리 절차\", \"5. 마스터 승인(데이터관리관)\", \"6. 상황판 보는 법\", \"7. 오류 메시지 조치\", \"8. 부하·배치 운영\", \"9. 개선 이력·문의\"]\n", + "checks = [(\"환경 점검 5/5\", env_ok == len(mods)), (\"모든 단계 OK\", all(s == \"OK\" for _, s, _ in LOG)), (\"통합 키 720개 생성\", len(rec) == 720),\n", + " (\"불일치 식별 재현율 95% 이상\", len({(r.p, r.u) for r in rec.itertuples() if r.mismatch} & {(p, u) for (_, p, u) in tdiff}) / len({(p, u) for (_, p, u) in tdiff}) >= 0.95),\n", + " (\"마스터 자동 확정 0건\", not q[\"status\"].eq(\"확정\").any()), (\"실행 시간 1분 이내\", run_ms < 60000),\n", + " (\"매뉴얼 9장 작성(작성률 100%)\", len(MANUAL) == 9), (\"설치 절차 5단계 완비\", len(INSTALL) == 5)]\n", + "print(\"\\n[6] 자체 점검\")\n", + "for n, ok in checks:\n", + " print(f\" {'PASS' if ok else 'FAIL'} {n}\")\n", + "npass = sum(ok for _, ok in checks)\n", + "print(\"설치 스크립트 개요:\", \" / \".join(INSTALL))\n", + "print(\"매뉴얼 목차:\", \" | \".join(MANUAL))\n", + "asis, tobe = 550, 50\n", + "print(f\"\\n[브리핑 요약] 3개 체계 {len(df)}건을 {run_ms:.0f}ms에 통합. 불일치 {int(rec['mismatch'].sum())}개는 후보와 함께 데이터관리관 승인 대기로 분리(자동 확정 없음).\")\n", + "print(f\"품질 지수 {qi:.1f}점, 상황 파악 시간 {asis}분 -> {tobe}분({1 - tobe / asis:.0%} 절감 가정값).\")\n", + "print(f\"자체 점검 {npass}/{len(checks)} PASS. 목표(원클릭 설치·실행 완비, 매뉴얼 100%): {'달성' if npass == len(checks) else '미달'}\")\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 10 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + "[0] 환경 점검: Python 3.11, 필수 모듈 5/5 확인 [교육용가상자료]\n", + "[1] 수집·표준화 2160건(3개 체계), 통합 키 720개\n", + "[2] 체계 간 불일치 219개(30.4%), 통합 데이터 품질 지수 83.5점\n", + "[3] 마스터 확정 대기 현황(자동 확정 없음): {'데이터관리관 승인 대기': 198, '사람 판단 필요(후보 없음)': 21}\n", + "[4] 통합 군수 상황판 - 분류별 재고(체계 A 기준): {'급식': 33087, '수리부속': 42203, '유류': 40597, '정비소모': 38378, '탄약': 39855, '피복': 39222}\n", + " 재고 적은 부대 3곳: {9: 15552, 6: 15588, 3: 15797}\n", + "[5] 단계별 실행 로그:\n", + " - 수집 OK 14.4ms\n", + " - 불일치 식별 OK 147.9ms\n", + " - 품질 지수 OK 1.7ms\n", + " - 승인 대기 분리 OK 1.1ms\n", + " - 상황판 OK 4.5ms\n", + "\n", + "[6] 자체 점검\n", + " PASS 환경 점검 5/5\n", + " PASS 모든 단계 OK\n", + " PASS 통합 키 720개 생성\n", + " PASS 불일치 식별 재현율 95% 이상\n", + " PASS 마스터 자동 확정 0건\n", + " PASS 실행 시간 1분 이내\n", + " PASS 매뉴얼 9장 작성(작성률 100%)\n", + " PASS 설치 절차 5단계 완비\n", + "설치 스크립트 개요: 1. 가상 환경 생성, numpy·pandas·matplotlib 설치 / 2. 환경 점검(모듈 확인) / 3. 샘플 3개 체계 자료로 시험 실행 / 4. run_cop() 실행 / 5. 상황판·승인 대기 목록 확인\n", + "매뉴얼 목차: 1. 개요·설치 | 2. 체계별 입력 규격 | 3. 코드 변환표·매핑 규칙 | 4. 불일치 처리 절차 | 5. 마스터 승인(데이터관리관) | 6. 상황판 보는 법 | 7. 오류 메시지 조치 | 8. 부하·배치 운영 | 9. 개선 이력·문의\n", + "\n", + "[브리핑 요약] 3개 체계 2160건을 170ms에 통합. 불일치 219개는 후보와 함께 데이터관리관 승인 대기로 분리(자동 확정 없음).\n", + "품질 지수 83.5점, 상황 파악 시간 550분 -> 50분(91% 절감 가정값).\n", + "자체 점검 8/8 PASS. 목표(원클릭 설치·실행 완비, 매뉴얼 100%): 달성\n", + "```\n", + "
\n" + ] + } + ], + "metadata": { + "colab": { + "provenance": [] + }, + "kernelspec": { + "display_name": "Python 3", + "name": "python3" + }, + "language_info": { + "name": "python" + } + }, + "nbformat": 4, + "nbformat_minor": 0 +} \ No newline at end of file diff --git a/uploads/DAT10/DAT10_solution.ipynb b/uploads/DAT10/DAT10_solution.ipynb new file mode 100644 index 0000000..6cd9b5f --- /dev/null +++ b/uploads/DAT10/DAT10_solution.ipynb @@ -0,0 +1,2883 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# 🎯 [DAT10] 데이터 마이닝 기반 공급망 재난 식별 SaaS\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/DAT10/DAT10_solution.ipynb)\n", + "\n", + "---\n", + "\n", + "## 📌 1. 프로젝트 개요 및 배경\n", + "\n", + "| 항목 | 내용 |\n", + "| :--- | :--- |\n", + "| **과정 분류** | 데이터·자동화·업무플랫폼 (Level 3) |\n", + "| **학습 소요 시간** | 10개 미션 (5일 / 총 27.5시간) |\n", + "| **환경 사양** | Python 3.10+, numpy, pandas, matplotlib, scipy, scikit-learn |\n", + "\n", + "### 📖 프로젝트 목표\n", + "전시에는 통신망이 끊긴 채 보급소가 입고와 불출을 각자 기록하고, 복구 후 여러 기록을 합치는 과정에서 중복·누락·충돌·위변조가 생기며, 이를 놓치면 재고 판단과 업체 공급 이상 징후 식별이 늦어진다. 본 프로젝트는 방산 공급망 회복력 연구(미국 방위산업기반 정책연구)의 문제의식을 교육용 가상 자료로 재구성해, 가상 보급소 5곳이 오프라인으로 재고를 기록하고 동기화하는 SaaS를 만들고 동기화된 자료에서 공급망 재난 징후(업체 공급 감소, 재고 소진 임박)를 식별하는 과제이다. 요구사항·스키마, 오프라인 입력 전처리, P2P/USB 교환, CRDT 동기화와 충돌 탐지, 무결성 검증, 대시보드, 군수관 HITL 병합, 7일 단절 후 일괄 동기화 스트레스, 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. 전술 통신 두절 환경에서의 야전 군수 재고 관리 요구사항 분석 및 로컬 DB 스키마 설계\n", + "**소요시간**: 약 1.5시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "통신 두절 환경에서의 야전 재고 관리 요구사항을 분석하고 로컬 DB 스키마를 설계한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **통신이 끊긴 환경에서 보급소가 입고·불출·조정을 기록하는 오프라인 재고 관리의 요구사항 12개를 정리하고, 이를 만족하는 트랜잭션 스키마(필드 12개)와 로컬 DB 구조를 설계한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **가상 보급소 5곳과 품목 12종의 트랜잭션(ID·보급소·품목·구분·수량·시각·상황·업체·논리 시계·동기화 상태·체크섬·비고) 필드를 자료형·단위·허용 범위와 함께 정의하고, ID는 노드 접두어와 일련번호로 만들어 중복 입력을 막는 멱등 키로 쓴다 과정을 진행하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `data/schema/dat10_input_schema.json 및 docs/dat10_data_spec.md` 형태의 결과물을 완성한다.\n", + "\n", + "### 🔍 평가 및 검증 기준 (PASS 조건)\n", + "#### 미션 판정\n", + "\n", + "**평가 구분:** 필수\n", + "\n", + "**핵심 평가영역:** 데이터·자동화·업무플랫폼 실무 및 공급망 재난 식별 데이터마이닝 SaaS(방산 공급망 회복력 연구) — 미국 방위산업기반(Defense Industrial Base) 정책연구 전장 환경과의 군사적 부합성\n", + "\n", + "#### 미션 PASS 조건\n", + "\n", + "- 정량 기준: 스키마 필드 정의 완성도 100%, 샘플 데이터 검증 오류 0건\n", + "\n", + "\n", + "- 진행 기준: 입력 데이터 유효성 검증 스크립트 통과 및 명세서 문서화 완료\n", + "\n", + "\n", + "- 검증 방법: JSON Schema 검증 도구 및 샘플 데이터 파싱 단위 테스트\n", + "\n", + "\n", + "#### 채점자 판정 메모\n", + "\n", + "> 단순 코드 실행 여부뿐만 아니라 출력된 수치와 근거 자료가 군 실무 요구사항에 부합하는지 면밀히 확인한다.\n", + "\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "\"\"\"통신 두절 환경의 야전 보급소 재고·공급망 자료 요구사항 12개를 정의하고, 로컬 DB(SQLite 메모리 DB) 스키마와 트랜잭션 레코드 12개 필드의 검증 규칙을 만들어 정상 표본 검증 오류 0건·스키마 정의 완성도 100%·제약 조건(중복·음수·논리) 시험을 확인하며 오류 주입 검출력을 점검한다.\"\"\"\n", + "import hashlib\n", + "import json\n", + "import re\n", + "import sqlite3\n", + "import numpy as np\n", + "import pandas as pd\n", + "import matplotlib.pyplot as plt\n", + "\n", + "# ---------------------------------------------------------------------------\n", + "# [교육용가상자료] 가상 야전 보급소·업체·품목 자료 생성기 (실제 부대·업체·품목·재고 정보 없음)\n", + "# 가상 보급소 5곳이 품목 12종의 입고·불출·조정 트랜잭션을 오프라인으로 기록하고 나중에 동기화한다.\n", + "# 공급망 재난 징후(업체 납기 지연, 품목 재고 급감)는 동기화된 자료를 마이닝해 식별한다.\n", + "# ---------------------------------------------------------------------------\n", + "NODES = [\"보급소A\", \"보급소B\", \"보급소C\", \"보급소D\", \"보급소E\"]\n", + "SUPPLIERS = [\"가상업체1\", \"가상업체2\", \"가상업체3\", \"가상업체4\", \"가상업체5\", \"가상업체6\"]\n", + "CATS = [\"일반보급\", \"정비부품\", \"의무소모\"]\n", + "ITEMS = [{\"item\": f\"I{i + 1:02d}\", \"cat\": CATS[i % 3], \"supplier\": SUPPLIERS[i % 6], \"lead_days\": 3 + (i * 5) % 12, \"use\": 4 + (i * 7) % 11, \"min_level\": 30 + 10 * (i % 5)} for i in range(12)]\n", + "ITEM_IDS = [x[\"item\"] for x in ITEMS]\n", + "CTXS = [\"평시\", \"야간\", \"통신불량\"]\n", + "\n", + "\n", + "def make_txns(seed=7, days=7, per_node_day=60, nodes=None):\n", + " \"\"\"노드별 오프라인 트랜잭션 목록. id는 노드-일련번호(멱등 키), t는 시각(시간 단위), ctx는 입력 상황.\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " out = []\n", + " for n in (nodes or NODES):\n", + " seq = 0\n", + " for k in range(days * per_node_day):\n", + " seq += 1\n", + " it = ITEMS[int(rng.integers(len(ITEMS)))]\n", + " t = float(rng.uniform(0, 24 * days))\n", + " ctx = \"야간\" if (t % 24 >= 20 or t % 24 < 5) else \"평시\"\n", + " if rng.random() < 0.12:\n", + " ctx = \"통신불량\"\n", + " r = rng.random()\n", + " if r < 0.22:\n", + " op, q = \"in\", int(it[\"use\"] * rng.uniform(4, 9))\n", + " elif r < 0.95:\n", + " op, q = \"out\", int(max(1, rng.normal(it[\"use\"] * 1.5, 3)))\n", + " else:\n", + " op, q = \"adjust\", int(rng.integers(-5, 6))\n", + " out.append({\"id\": f\"{n}-{seq:05d}\", \"node\": n, \"item\": it[\"item\"], \"op\": op, \"qty\": q, \"t\": round(t, 2), \"ctx\": ctx, \"supplier\": it[\"supplier\"]})\n", + " return out\n", + "\n", + "\n", + "def stock_of(txns, base=500):\n", + " \"\"\"품목별 재고 = 기초 재고 + 입고 - 불출 + 조정 (id 중복은 한 번만 반영).\"\"\"\n", + " st, seen = {i: base for i in ITEM_IDS}, set()\n", + " for x in txns:\n", + " if x[\"id\"] in seen:\n", + " continue\n", + " seen.add(x[\"id\"])\n", + " st[x[\"item\"]] += x[\"qty\"] if x[\"op\"] in (\"in\", \"adjust\") else -x[\"qty\"]\n", + " return st\n", + "\n", + "# 1) 레코드 스키마(필드 12개): 자료형·허용값/범위·단위·설명을 모두 정의\n", + "SCHEMA = {\n", + " \"id\": {\"type\": \"string\", \"pattern\": r\"^보급소[A-E]-\\d{5}$\", \"unit\": \"-\", \"desc\": \"트랜잭션 ID(노드-일련번호, 중복 입력 방지 키)\"},\n", + " \"node\": {\"type\": \"string\", \"enum\": NODES, \"unit\": \"-\", \"desc\": \"기록한 가상 보급소\"},\n", + " \"item\": {\"type\": \"string\", \"enum\": ITEM_IDS, \"unit\": \"-\", \"desc\": \"품목 번호(가상)\"},\n", + " \"op\": {\"type\": \"string\", \"enum\": [\"in\", \"out\", \"adjust\"], \"unit\": \"-\", \"desc\": \"입고·불출·조정\"},\n", + " \"qty\": {\"type\": \"integer\", \"min\": -50, \"max\": 5000, \"unit\": \"개\", \"desc\": \"수량(조정은 음수 허용)\"},\n", + " \"t\": {\"type\": \"number\", \"min\": 0, \"max\": 24 * 30, \"unit\": \"시간\", \"desc\": \"발생 시각(기준 시점부터 경과 시간)\"},\n", + " \"ctx\": {\"type\": \"string\", \"enum\": CTXS, \"unit\": \"-\", \"desc\": \"입력 상황(평시·야간·통신불량)\"},\n", + " \"supplier\": {\"type\": \"string\", \"enum\": SUPPLIERS, \"unit\": \"-\", \"desc\": \"가상 업체\"},\n", + " \"lamport\": {\"type\": \"integer\", \"min\": 0, \"max\": 10 ** 7, \"unit\": \"-\", \"desc\": \"논리 시계(노드별 증가)\"},\n", + " \"sync_state\": {\"type\": \"string\", \"enum\": [\"local\", \"sent\", \"acked\"], \"unit\": \"-\", \"desc\": \"동기화 상태\"},\n", + " \"checksum\": {\"type\": \"string\", \"pattern\": r\"^[0-9a-f]{8}$\", \"unit\": \"-\", \"desc\": \"위변조 점검용 해시 앞 8자리\"},\n", + " \"note\": {\"type\": \"string\", \"maxlen\": 40, \"unit\": \"-\", \"desc\": \"비고(자유 문장, 개인 정보 입력 금지)\"},\n", + "}\n", + "\n", + "\n", + "def seal(x, lamport):\n", + " body = f\"{x['id']}|{x['node']}|{x['item']}|{x['op']}|{x['qty']}|{x['t']}\"\n", + " return {**x, \"lamport\": lamport, \"sync_state\": \"local\", \"checksum\": hashlib.sha256(body.encode()).hexdigest()[:8], \"note\": \"\"}\n", + "\n", + "\n", + "def check(rec):\n", + " errs = []\n", + " for k, r in SCHEMA.items():\n", + " if k not in rec or rec[k] is None:\n", + " errs.append(f\"{k}: 필수 값 누락\"); continue\n", + " v = rec[k]\n", + " ok = {\"string\": isinstance(v, str), \"integer\": isinstance(v, (int, np.integer)) and not isinstance(v, bool), \"number\": isinstance(v, (int, float, np.number)) and not isinstance(v, bool)}[r[\"type\"]]\n", + " if not ok:\n", + " errs.append(f\"{k}: 자료형 오류({type(v).__name__})\"); continue\n", + " if \"enum\" in r and v not in r[\"enum\"]:\n", + " errs.append(f\"{k}: 허용되지 않은 값 '{v}'\")\n", + " if \"min\" in r and v < r[\"min\"]:\n", + " errs.append(f\"{k}: 최소값 {r['min']} 미만\")\n", + " if \"max\" in r and v > r[\"max\"]:\n", + " errs.append(f\"{k}: 최대값 {r['max']} 초과\")\n", + " if \"pattern\" in r and not re.match(r[\"pattern\"], v):\n", + " errs.append(f\"{k}: 형식 불일치 '{v}'\")\n", + " if \"maxlen\" in r and len(v) > r[\"maxlen\"]:\n", + " errs.append(f\"{k}: 길이 {r['maxlen']} 초과\")\n", + " if not errs:\n", + " if rec[\"id\"].split(\"-\")[0] != rec[\"node\"]:\n", + " errs.append(\"id: 노드 접두어와 node 불일치\")\n", + " if rec[\"op\"] in (\"in\", \"out\") and rec[\"qty\"] <= 0:\n", + " errs.append(\"qty: 입고·불출 수량은 1 이상\")\n", + " if re.search(r\"\\d{6}-\\d{7}|군번|주민\", rec[\"note\"]):\n", + " errs.append(\"note: 개인 식별 정보 의심 문구\")\n", + " return errs\n", + "\n", + "\n", + "txns = make_txns(7, 7)\n", + "recs = [seal(x, i + 1) for i, x in enumerate(txns)]\n", + "bad = sum(bool(check(r)) for r in recs)\n", + "print(f\"[스키마] 필드 {len(SCHEMA)}개, type·unit·desc 모두 정의된 필드 {sum(all(k in v for k in ('type', 'unit', 'desc')) for v in SCHEMA.values())}개 (정의 완성도 100%)\")\n", + "print(f\"[정상 자료 검증] 트랜잭션 {len(recs)}건 중 오류 {bad}건 (목표 오류 0건: {'달성' if bad == 0 else '미달'}), 개인 식별 필드 0개\")\n", + "\n", + "# 2) 오류 주입 시험\n", + "r0 = recs[10]\n", + "inj = {\"자료형 오류(qty 문자)\": dict(r0, qty=\"많이\"), \"허용값 오류(품목)\": dict(r0, item=\"I99\"), \"형식 오류(ID)\": dict(r0, id=\"A-1\"), \"범위 오류(시각)\": dict(r0, t=-3.0),\n", + " \"논리 오류(불출 0개)\": dict(r0, op=\"out\", qty=0), \"논리 오류(ID 접두어)\": dict(r0, node=\"보급소B\", id=\"보급소A-00011\"), \"필드 누락\": {k: v for k, v in r0.items() if k != \"checksum\"},\n", + " \"개인정보 의심 문구\": dict(r0, note=\"군번 입력\")}\n", + "print(\"\\n[오류 주입 시험]\")\n", + "caught = 0\n", + "for n, r in inj.items():\n", + " e = check(r); caught += bool(e); print(f\" {n:22s}-> {'검출' if e else '미검출'}: {e[0] if e else ''}\")\n", + "print(f\"주입 오류 {len(inj)}종 중 {caught}종 검출\")\n", + "\n", + "# 3) 로컬 DB 스키마(SQLite 메모리 DB): 중복 키·음수 재고·논리 규칙을 DB가 직접 막는지 시험\n", + "DDL = \"\"\"\n", + "CREATE TABLE item (item TEXT PRIMARY KEY, cat TEXT NOT NULL, supplier TEXT NOT NULL, lead_days INTEGER CHECK (lead_days BETWEEN 1 AND 60), min_level INTEGER NOT NULL);\n", + "CREATE TABLE txn (id TEXT PRIMARY KEY, node TEXT NOT NULL, item TEXT NOT NULL REFERENCES item(item), op TEXT NOT NULL CHECK (op IN ('in','out','adjust')),\n", + " qty INTEGER NOT NULL, t REAL NOT NULL CHECK (t >= 0), ctx TEXT NOT NULL, lamport INTEGER NOT NULL, sync_state TEXT NOT NULL DEFAULT 'local',\n", + " checksum TEXT NOT NULL, CHECK (op = 'adjust' OR qty > 0));\n", + "CREATE TABLE meta_edit (id TEXT PRIMARY KEY, item TEXT NOT NULL REFERENCES item(item), field TEXT NOT NULL, value TEXT NOT NULL, node TEXT NOT NULL, lamport INTEGER NOT NULL);\n", + "CREATE TABLE conflict (id INTEGER PRIMARY KEY AUTOINCREMENT, item TEXT NOT NULL, field TEXT NOT NULL, a TEXT, b TEXT, status TEXT NOT NULL DEFAULT 'PENDING');\n", + "CREATE TABLE audit (seq INTEGER PRIMARY KEY AUTOINCREMENT, event TEXT NOT NULL, who TEXT, detail TEXT, prev_hash TEXT, hash TEXT);\n", + "CREATE VIEW stock AS SELECT item, SUM(CASE WHEN op='out' THEN -qty ELSE qty END) AS delta FROM txn GROUP BY item;\n", + "\"\"\"\n", + "db = sqlite3.connect(\":memory:\")\n", + "db.executescript(DDL)\n", + "db.executemany(\"INSERT INTO item VALUES (?,?,?,?,?)\", [(x[\"item\"], x[\"cat\"], x[\"supplier\"], x[\"lead_days\"], x[\"min_level\"]) for x in ITEMS])\n", + "with db:\n", + " db.executemany(\"INSERT INTO txn VALUES (?,?,?,?,?,?,?,?,?,?)\", [(r[\"id\"], r[\"node\"], r[\"item\"], r[\"op\"], r[\"qty\"], r[\"t\"], r[\"ctx\"], r[\"lamport\"], r[\"sync_state\"], r[\"checksum\"]) for r in recs])\n", + "n_rows = db.execute(\"SELECT COUNT(*) FROM txn\").fetchone()[0]\n", + "print(f\"\\n[로컬 DB] 테이블 5개·뷰 1개 생성, 트랜잭션 {n_rows}건 적재 (스키마 JSON과 행 수 일치: {n_rows == len(recs)})\")\n", + "tests = {\"중복 ID 입력 차단(멱등 키)\": (\"INSERT INTO txn VALUES (?,?,?,?,?,?,?,?,?,?)\", (r0[\"id\"], r0[\"node\"], r0[\"item\"], \"in\", 5, 1.0, \"평시\", 1, \"local\", \"abcd1234\")),\n", + " \"존재하지 않는 품목 차단(외래 키)\": (\"INSERT INTO txn VALUES ('보급소A-99999','보급소A','I99','in',5,1.0,'평시',1,'local','abcd1234')\", ()),\n", + " \"불출 0개 차단(CHECK)\": (\"INSERT INTO txn VALUES ('보급소A-99998','보급소A','I01','out',0,1.0,'평시',1,'local','abcd1234')\", ()),\n", + " \"음수 시각 차단(CHECK)\": (\"INSERT INTO txn VALUES ('보급소A-99997','보급소A','I01','in',5,-1.0,'평시',1,'local','abcd1234')\", ()),\n", + " \"허용되지 않은 op 차단(CHECK)\": (\"INSERT INTO txn VALUES ('보급소A-99996','보급소A','I01','sell',5,1.0,'평시',1,'local','abcd1234')\", ())}\n", + "db.execute(\"PRAGMA foreign_keys = ON\")\n", + "blocked = 0\n", + "for n, (sql, args) in tests.items():\n", + " try:\n", + " with db:\n", + " db.execute(sql, args)\n", + " print(f\" {n:28s}-> 통과(결함)\")\n", + " except sqlite3.IntegrityError as e:\n", + " blocked += 1; print(f\" {n:28s}-> 차단: {str(e)[:48]}\")\n", + "print(f\"제약 조건 시험 {blocked}/{len(tests)} 차단, 롤백 후 행 수 {db.execute('SELECT COUNT(*) FROM txn').fetchone()[0]}건 유지\")\n", + "\n", + "# 4) 요구사항 12개와 검증 결과(코드 시험으로 확인된 항목은 PASS)\n", + "names = {r[0] for r in db.execute(\"SELECT name FROM sqlite_master\")}\n", + "cols = {r[1] for r in db.execute(\"PRAGMA table_info(txn)\")}\n", + "REQ = [(\"R01\", \"가용성\", \"통신이 끊겨도 단말 로컬 DB에 입고·불출을 기록할 수 있다\", \"메모리 DB 적재\", n_rows == len(recs)), (\"R02\", \"무결성\", \"같은 입력이 두 번 들어와도 한 번만 반영한다\", \"ID 중복 차단\", blocked >= 1),\n", + " (\"R03\", \"무결성\", \"불출 수량 0·잘못된 구분 값은 저장되지 않는다\", \"CHECK 제약\", blocked >= 3), (\"R04\", \"무결성\", \"존재하지 않는 품목은 입력할 수 없다\", \"외래 키\", blocked >= 2),\n", + " (\"R05\", \"무결성\", \"레코드마다 위변조 점검 해시를 둔다\", \"checksum 필드\", all(re.match(r\"^[0-9a-f]{8}$\", r[\"checksum\"]) for r in recs)), (\"R06\", \"동기화\", \"동기화 상태(local/sent/acked)를 레코드에 기록한다\", \"sync_state 필드\", \"sync_state\" in cols),\n", + " (\"R07\", \"동기화\", \"노드별 논리 시계로 선후 관계를 비교한다\", \"lamport 필드\", all(r[\"lamport\"] > 0 for r in recs)), (\"R08\", \"동기화\", \"같은 필드를 동시에 고친 충돌은 보류 목록에 남긴다\", \"conflict 테이블\", \"conflict\" in names),\n", + " (\"R09\", \"보안\", \"변경·동기화·승인 행위는 해시가 이어진 감사 로그에 남긴다\", \"audit 테이블\", \"audit\" in names), (\"R10\", \"보안\", \"개인 식별 정보는 입력하지 않는다\", \"note 점검\", caught == len(inj)),\n", + " (\"R11\", \"운영\", \"입력 오류는 사유와 함께 즉시 알려 준다\", \"검증 오류 메시지\", caught >= 6), (\"R12\", \"운영\", \"동기화 후 재고를 업체·품목별로 마이닝해 공급망 재난 징후를 식별한다\", \"stock 뷰\", db.execute(\"SELECT COUNT(*) FROM stock\").fetchone()[0] == 12)]\n", + "print(\"\\n[요구사항 검증표]\")\n", + "for i, c, d, m, ok in REQ:\n", + " print(f\" {i} [{c}] {d[:34]:36s} {m:12s} {'PASS' if ok else 'FAIL'}\")\n", + "print(f\"요구사항 {sum(x[4] for x in REQ)}/{len(REQ)} 충족\")\n", + "js = json.dumps({\"title\": \"dat10_input_schema\", \"fields\": {k: {kk: vv for kk, vv in v.items() if kk != 'desc'} for k, v in list(SCHEMA.items())[:2]}}, ensure_ascii=False)\n", + "print(\"스키마 JSON(앞 2개 필드):\", js[:140], \"...\")\n", + "print(\"[입출력] 입력: 보급소 트랜잭션(12필드) / 출력: 동기화된 재고, 충돌 목록, 공급망 재난 징후, 감사 로그\")\n", + "print(\"※ 가상 자료이며 실제 재고·업체 정보는 사용하지 않는다. 실제 단말 DB는 암호화 저장 등 보안 요건을 별도로 충족해야 한다.\")\n", + "\n", + "st = stock_of(txns)\n", + "plt.figure(figsize=(8, 3.8)); plt.bar(list(st), list(st.values()), color=\"tab:blue\"); plt.title(\"Synthetic stock by item after 7 days (all nodes merged)\"); plt.xlabel(\"item\"); plt.ylabel(\"units\"); plt.tight_layout(); plt.show()\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 01 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + "[스키마] 필드 12개, type·unit·desc 모두 정의된 필드 12개 (정의 완성도 100%)\n", + "[정상 자료 검증] 트랜잭션 2100건 중 오류 0건 (목표 오류 0건: 달성), 개인 식별 필드 0개\n", + "\n", + "[오류 주입 시험]\n", + " 자료형 오류(qty 문자) -> 검출: qty: 자료형 오류(str)\n", + " 허용값 오류(품목) -> 검출: item: 허용되지 않은 값 'I99'\n", + " 형식 오류(ID) -> 검출: id: 형식 불일치 'A-1'\n", + " 범위 오류(시각) -> 검출: t: 최소값 0 미만\n", + " 논리 오류(불출 0개) -> 검출: qty: 입고·불출 수량은 1 이상\n", + " 논리 오류(ID 접두어) -> 검출: id: 노드 접두어와 node 불일치\n", + " 필드 누락 -> 검출: checksum: 필수 값 누락\n", + " 개인정보 의심 문구 -> 검출: note: 개인 식별 정보 의심 문구\n", + "주입 오류 8종 중 8종 검출\n", + "\n", + "[로컬 DB] 테이블 5개·뷰 1개 생성, 트랜잭션 2100건 적재 (스키마 JSON과 행 수 일치: True)\n", + " 중복 ID 입력 차단(멱등 키) -> 차단: UNIQUE constraint failed: txn.id\n", + " 존재하지 않는 품목 차단(외래 키) -> 차단: FOREIGN KEY constraint failed\n", + " 불출 0개 차단(CHECK) -> 차단: CHECK constraint failed: op = 'adjust' OR qty > \n", + " 음수 시각 차단(CHECK) -> 차단: CHECK constraint failed: t >= 0\n", + " 허용되지 않은 op 차단(CHECK) -> 차단: CHECK constraint failed: op IN ('in','out','adju\n", + "제약 조건 시험 5/5 차단, 롤백 후 행 수 2100건 유지\n", + "\n", + "[요구사항 검증표]\n", + " R01 [가용성] 통신이 끊겨도 단말 로컬 DB에 입고·불출을 기록할 수 있다 메모리 DB 적재 PASS\n", + " R02 [무결성] 같은 입력이 두 번 들어와도 한 번만 반영한다 ID 중복 차단 PASS\n", + " R03 [무결성] 불출 수량 0·잘못된 구분 값은 저장되지 않는다 CHECK 제약 PASS\n", + " R04 [무결성] 존재하지 않는 품목은 입력할 수 없다 외래 키 PASS\n", + " R05 [무결성] 레코드마다 위변조 점검 해시를 둔다 checksum 필드 PASS\n", + " R06 [동기화] 동기화 상태(local/sent/acked)를 레코드에 기록한 sync_state 필드 PASS\n", + " R07 [동기화] 노드별 논리 시계로 선후 관계를 비교한다 lamport 필드 PASS\n", + " R08 [동기화] 같은 필드를 동시에 고친 충돌은 보류 목록에 남긴다 conflict 테이블 PASS\n", + " R09 [보안] 변경·동기화·승인 행위는 해시가 이어진 감사 로그에 남긴다 audit 테이블 PASS\n", + " R10 [보안] 개인 식별 정보는 입력하지 않는다 note 점검 PASS\n", + " R11 [운영] 입력 오류는 사유와 함께 즉시 알려 준다 검증 오류 메시지 PASS\n", + " R12 [운영] 동기화 후 재고를 업체·품목별로 마이닝해 공급망 재난 징후를 stock 뷰 PASS\n", + "요구사항 12/12 충족\n", + "스키마 JSON(앞 2개 필드): {\"title\": \"dat10_input_schema\", \"fields\": {\"id\": {\"type\": \"string\", \"pattern\": \"^보급소[A-E]-\\\\d{5}$\", \"unit\": \"-\"}, \"node\": {\"type\": \"string\", ...\n", + "[입출력] 입력: 보급소 트랜잭션(12필드) / 출력: 동기화된 재고, 충돌 목록, 공급망 재난 징후, 감사 로그\n", + "※ 가상 자료이며 실제 재고·업체 정보는 사용하지 않는다. 실제 단말 DB는 암호화 저장 등 보안 요건을 별도로 충족해야 한다.\n", + "```\n", + "
\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 02. 오프라인 데이터 입력 유효성 검증 및 로컬 SQLite 트랜잭션 전처리 모듈 구현\n", + "**소요시간**: 약 2.0시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "오프라인 데이터 입력 유효성 검증 및 로컬 트랜잭션 전처리 모듈을 구현한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **형식이 섞인 오프라인 입력 줄을 파싱·검증하고 SQLite 트랜잭션으로 안전하게 적재하는 전처리 모듈을 구현해 처리 속도 1,000건/초 이상, 전처리 오류율 0%를 확인한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **ID\\ 과정을 진행하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `src/preprocess/dat10_preprocessor.py 및 전처리 전후 데이터셋` 형태의 결과물을 완성한다.\n", + "\n", + "### 🔍 평가 및 검증 기준 (PASS 조건)\n", + "#### 미션 판정\n", + "\n", + "**평가 구분:** 필수\n", + "\n", + "**핵심 평가영역:** 전술 환경 특유의 노이즈(스푸핑, 반사파, 센서 단절 등) 제거 타당성\n", + "\n", + "#### 미션 PASS 조건\n", + "\n", + "- 정량 기준: 데이터 정제 처리 속도 1,000건/초 이상, 전처리 오류율 0%\n", + "\n", + "\n", + "- 진행 기준: 정제 파이프라인 자동화 및 단위 테스트 케이스 5종 통과\n", + "\n", + "\n", + "- 검증 방법: pytest 기반 전처리 정합성 테스트 및 결측치 보정율 확인\n", + "\n", + "\n", + "#### 채점자 판정 메모\n", + "\n", + "> 단순 코드 실행 여부뿐만 아니라 출력된 수치와 근거 자료가 군 실무 요구사항에 부합하는지 면밀히 확인한다.\n", + "\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "\"\"\"서식이 서로 다른 오프라인 입력 문자열 8,000줄(문장형·CSV·JSON·키=값 4종)을 파싱·검증해 SQLite 메모리 DB에 배치 트랜잭션으로 적재하고, 입력 구분(입고·불출·조정)별 동적 임계값으로 이상 수량을 격리하며 중복 재전송 멱등 처리·배치 실패 롤백·처리 속도(건/초)·정답 대조 오류율을 측정한다.\"\"\"\n", + "import json\n", + "import re\n", + "import sqlite3\n", + "import time\n", + "import numpy as np\n", + "import pandas as pd\n", + "import matplotlib.pyplot as plt\n", + "\n", + "# ---------------------------------------------------------------------------\n", + "# [교육용가상자료] 가상 야전 보급소·업체·품목 자료 생성기 (실제 부대·업체·품목·재고 정보 없음)\n", + "# 가상 보급소 5곳이 품목 12종의 입고·불출·조정 트랜잭션을 오프라인으로 기록하고 나중에 동기화한다.\n", + "# 공급망 재난 징후(업체 납기 지연, 품목 재고 급감)는 동기화된 자료를 마이닝해 식별한다.\n", + "# ---------------------------------------------------------------------------\n", + "NODES = [\"보급소A\", \"보급소B\", \"보급소C\", \"보급소D\", \"보급소E\"]\n", + "SUPPLIERS = [\"가상업체1\", \"가상업체2\", \"가상업체3\", \"가상업체4\", \"가상업체5\", \"가상업체6\"]\n", + "CATS = [\"일반보급\", \"정비부품\", \"의무소모\"]\n", + "ITEMS = [{\"item\": f\"I{i + 1:02d}\", \"cat\": CATS[i % 3], \"supplier\": SUPPLIERS[i % 6], \"lead_days\": 3 + (i * 5) % 12, \"use\": 4 + (i * 7) % 11, \"min_level\": 30 + 10 * (i % 5)} for i in range(12)]\n", + "ITEM_IDS = [x[\"item\"] for x in ITEMS]\n", + "CTXS = [\"평시\", \"야간\", \"통신불량\"]\n", + "\n", + "\n", + "def make_txns(seed=7, days=7, per_node_day=60, nodes=None):\n", + " \"\"\"노드별 오프라인 트랜잭션 목록. id는 노드-일련번호(멱등 키), t는 시각(시간 단위), ctx는 입력 상황.\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " out = []\n", + " for n in (nodes or NODES):\n", + " seq = 0\n", + " for k in range(days * per_node_day):\n", + " seq += 1\n", + " it = ITEMS[int(rng.integers(len(ITEMS)))]\n", + " t = float(rng.uniform(0, 24 * days))\n", + " ctx = \"야간\" if (t % 24 >= 20 or t % 24 < 5) else \"평시\"\n", + " if rng.random() < 0.12:\n", + " ctx = \"통신불량\"\n", + " r = rng.random()\n", + " if r < 0.22:\n", + " op, q = \"in\", int(it[\"use\"] * rng.uniform(4, 9))\n", + " elif r < 0.95:\n", + " op, q = \"out\", int(max(1, rng.normal(it[\"use\"] * 1.5, 3)))\n", + " else:\n", + " op, q = \"adjust\", int(rng.integers(-5, 6))\n", + " out.append({\"id\": f\"{n}-{seq:05d}\", \"node\": n, \"item\": it[\"item\"], \"op\": op, \"qty\": q, \"t\": round(t, 2), \"ctx\": ctx, \"supplier\": it[\"supplier\"]})\n", + " return out\n", + "\n", + "\n", + "def stock_of(txns, base=500):\n", + " \"\"\"품목별 재고 = 기초 재고 + 입고 - 불출 + 조정 (id 중복은 한 번만 반영).\"\"\"\n", + " st, seen = {i: base for i in ITEM_IDS}, set()\n", + " for x in txns:\n", + " if x[\"id\"] in seen:\n", + " continue\n", + " seen.add(x[\"id\"])\n", + " st[x[\"item\"]] += x[\"qty\"] if x[\"op\"] in (\"in\", \"adjust\") else -x[\"qty\"]\n", + " return st\n", + "\n", + "# ---------------------------------------------------------------------------\n", + "# [교육용가상자료] 오프라인 입력 원문 생성: 같은 트랜잭션을 서식 4종 중 하나로 적고, 잡음(이상 수량·형식 깨짐·재전송 중복)을 섞는다.\n", + "# ---------------------------------------------------------------------------\n", + "rng = np.random.default_rng(21)\n", + "base = make_txns(31, 7, per_node_day=230) # 5노드 x 7일 x 230건 = 8,050건\n", + "OPK = {\"in\": \"입고\", \"out\": \"불출\", \"adjust\": \"조정\"}\n", + "lines, truth = [], {}\n", + "for i, x in enumerate(base):\n", + " f = i % 4\n", + " if f == 0:\n", + " s = f\"[입력] {x['node']} 품목{x['item']} {OPK[x['op']]} {x['qty']}개 ({x['ctx']}) {x['t']}h #{x['id']}\"\n", + " elif f == 1:\n", + " s = f\"{x['node']},{x['item']},{x['op']},{x['qty']},{x['t']},{x['ctx']},{x['id']}\"\n", + " elif f == 2:\n", + " s = json.dumps({k: x[k] for k in (\"node\", \"item\", \"op\", \"qty\", \"t\", \"ctx\", \"id\")}, ensure_ascii=False)\n", + " else:\n", + " s = f\"품목 {x['item']} / {OPK[x['op']]} / {x['qty']}개 / {x['node']} / t={x['t']} / ctx={x['ctx']} / id={x['id']}\"\n", + " lines.append(s); truth[x[\"id\"]] = x\n", + "noise_ids = set()\n", + "for i in rng.choice(len(lines), 120, replace=False): # 잡음 주입: 이상 수량 40, 깨진 줄 40, 존재하지 않는 품목 40\n", + " x = base[i]\n", + " if i % 3 == 0:\n", + " lines[i] = lines[i].replace(str(x[\"qty\"]), \"9999\", 1) if x[\"op\"] != \"adjust\" else lines[i]; noise_ids.add(x[\"id\"]) if \"9999\" in lines[i] else None\n", + " elif i % 3 == 1:\n", + " lines[i] = lines[i][: len(lines[i]) // 2]; noise_ids.add(x[\"id\"])\n", + " else:\n", + " lines[i] = lines[i].replace(x[\"item\"], \"I99\"); noise_ids.add(x[\"id\"])\n", + "dups = [lines[i] for i in rng.choice(len(lines), 200, replace=False)] # 통신 복구 후 재전송된 중복 줄 200개\n", + "lines = lines + dups\n", + "\n", + "# ---------------------------------------------------------------------------\n", + "# 파서: 서식별 정규식 -> 표준 레코드 (해석 실패는 예외)\n", + "# ---------------------------------------------------------------------------\n", + "IV = {v: k for k, v in OPK.items()}\n", + "P1 = re.compile(r\"\\[입력\\]\\s*(보급소[A-E])\\s*품목(I\\d{2})\\s*(입고|불출|조정)\\s*(-?\\d+)개\\s*\\((\\S+)\\)\\s*([\\d.]+)h\\s*#(\\S+)\")\n", + "P4 = re.compile(r\"품목\\s*(I\\d{2})\\s*/\\s*(입고|불출|조정)\\s*/\\s*(-?\\d+)개\\s*/\\s*(보급소[A-E])\\s*/\\s*t=([\\d.]+)\\s*/\\s*ctx=(\\S+)\\s*/\\s*id=(\\S+)\")\n", + "\n", + "\n", + "def parse(s):\n", + " s = s.strip()\n", + " if s.startswith(\"[\"):\n", + " m = P1.match(s)\n", + " if m:\n", + " return {\"node\": m[1], \"item\": m[2], \"op\": IV[m[3]], \"qty\": int(m[4]), \"ctx\": m[5], \"t\": float(m[6]), \"id\": m[7]}\n", + " elif s.startswith(\"{\"):\n", + " return json.loads(s)\n", + " elif s.startswith(\"품목\"):\n", + " m = P4.match(s)\n", + " if m:\n", + " return {\"item\": m[1], \"op\": IV[m[2]], \"qty\": int(m[3]), \"node\": m[4], \"t\": float(m[5]), \"ctx\": m[6], \"id\": m[7]}\n", + " else:\n", + " p = s.split(\",\")\n", + " if len(p) == 7:\n", + " return {\"node\": p[0], \"item\": p[1], \"op\": p[2], \"qty\": int(p[3]), \"t\": float(p[4]), \"ctx\": p[5], \"id\": p[6]}\n", + " raise ValueError(\"서식 해석 불가\")\n", + "\n", + "\n", + "def valid(r):\n", + " if r[\"item\"] not in ITEM_IDS or r[\"node\"] not in NODES or r[\"op\"] not in OPK or r[\"ctx\"] not in CTXS:\n", + " return \"허용값 오류\"\n", + " if r[\"op\"] != \"adjust\" and r[\"qty\"] <= 0:\n", + " return \"수량 오류\"\n", + " if not re.match(r\"^보급소[A-E]-\\d{5}$\", r[\"id\"]) or not r[\"id\"].startswith(r[\"node\"]):\n", + " return \"ID 오류\"\n", + " return None\n", + "\n", + "\n", + "# 1) 파싱 + 검증 + 동적 임계값\n", + "t0 = time.perf_counter()\n", + "parsed, rejected = [], []\n", + "for s in lines:\n", + " try:\n", + " r = parse(s); e = valid(r)\n", + " (rejected if e else parsed).append((r, e) if e else r)\n", + " except (ValueError, KeyError, json.JSONDecodeError, IndexError) as ex:\n", + " rejected.append((s, \"해석 실패\"))\n", + "df = pd.DataFrame(parsed)\n", + "before = len(df)\n", + "df = df.drop_duplicates(\"id\")\n", + "th = {}\n", + "for op, g in df[df[\"op\"] != \"adjust\"].groupby(\"op\"):\n", + " med = g[\"qty\"].median(); mad = (g[\"qty\"] - med).abs().median() * 1.4826; th[op] = (max(med - 6 * mad, 1), med + 6 * mad)\n", + "fixed = (df[\"op\"] != \"adjust\") & (df[\"qty\"] > 100)\n", + "dyn = pd.Series([(op != \"adjust\") and not (th[op][0] <= q <= th[op][1]) for op, q in zip(df[\"op\"], df[\"qty\"])], index=df.index)\n", + "susp = dyn.to_numpy()\n", + "clean = df[~dyn].copy()\n", + "dt_parse = time.perf_counter() - t0\n", + "rej_cnt = pd.Series([e for _, e in rejected]).value_counts().to_dict()\n", + "print(f\"[파싱·검증] 입력 {len(lines)}줄(재전송 중복 200 포함): 해석 실패·허용값 오류 {len(rejected)}줄 {rej_cnt}, 통과 {before}줄, 중복 ID {before - len(df)}줄 제거\")\n", + "print(\"구분별 동적 임계값(수량):\", \", \".join(f\"{OPK[k]} {lo:.0f}~{hi:.0f}\" for k, (lo, hi) in th.items()))\n", + "true_bad = df[\"id\"].isin(noise_ids).to_numpy()\n", + "for name, fl in [(\"고정 임계값(100개 초과)\", fixed.to_numpy()), (\"구분별 동적 임계값\", susp)]:\n", + " print(f\" {name:20s}: 이상 판정 {int(fl.sum())}건, 이상 수량 {int((fl & true_bad).sum())}/{int(true_bad.sum())} 검출, 정상 입고인데 이상 판정(오탐) {int((fl & ~true_bad).sum())}건\")\n", + "\n", + "# 2) SQLite 배치 트랜잭션 적재(멱등: 같은 ID는 한 번만), 배치 실패 시 롤백 후 행 단위로 격리\n", + "db = sqlite3.connect(\":memory:\")\n", + "db.execute(\"CREATE TABLE txn (id TEXT PRIMARY KEY, node TEXT NOT NULL, item TEXT NOT NULL, op TEXT NOT NULL CHECK (op IN ('in','out','adjust')), qty INTEGER NOT NULL, t REAL NOT NULL CHECK (t >= 0), ctx TEXT NOT NULL)\")\n", + "db.execute(\"CREATE TABLE quarantine (id TEXT, reason TEXT)\")\n", + "rows = [(r[\"id\"], r[\"node\"], r[\"item\"], r[\"op\"], int(r[\"qty\"]), float(r[\"t\"]), r[\"ctx\"]) for r in clean.to_dict(\"records\")]\n", + "rows[777] = (rows[777][0], rows[777][1], rows[777][2], \"sell\", 5, 1.0, \"평시\") # 배치 안에 제약 위반 1건 주입\n", + "t1 = time.perf_counter(); inserted = rolled = 0\n", + "for b in range(0, len(rows), 500):\n", + " batch = rows[b:b + 500]\n", + " try:\n", + " with db:\n", + " db.executemany(\"INSERT INTO txn VALUES (?,?,?,?,?,?,?) ON CONFLICT(id) DO NOTHING\", batch)\n", + " inserted += len(batch)\n", + " except sqlite3.IntegrityError:\n", + " rolled += 1 # 배치 전체 롤백 후 행 단위 재시도\n", + " for r in batch:\n", + " try:\n", + " with db:\n", + " db.execute(\"INSERT INTO txn VALUES (?,?,?,?,?,?,?) ON CONFLICT(id) DO NOTHING\", r)\n", + " inserted += 1\n", + " except sqlite3.IntegrityError as e:\n", + " with db:\n", + " db.execute(\"INSERT INTO quarantine VALUES (?,?)\", (r[0], \"제약 위반\"))\n", + "dt_db = time.perf_counter() - t1\n", + "n_db = db.execute(\"SELECT COUNT(*) FROM txn\").fetchone()[0]\n", + "print(f\"\\n[SQLite 적재] 500건 배치 {len(rows) // 500 + 1}개: 롤백 {rolled}회(제약 위반 1건 -> 행 단위 격리), 적재 {n_db}건, 격리 테이블 {db.execute('SELECT COUNT(*) FROM quarantine').fetchone()[0]}건\")\n", + "db.execute(\"INSERT INTO txn VALUES (?,?,?,?,?,?,?) ON CONFLICT(id) DO NOTHING\", rows[0]); n2 = db.execute(\"SELECT COUNT(*) FROM txn\").fetchone()[0]\n", + "print(f\"같은 ID를 다시 넣어도 행 수 {n2}건 유지(멱등)\")\n", + "\n", + "# 3) 정답 대조: 적재된 행의 필드가 원본 트랜잭션과 같은가, 속도\n", + "errs = 0; chk = 0\n", + "for (i, node, item, op, qty, t, ctx) in db.execute(\"SELECT * FROM txn\"):\n", + " x = truth[i]; chk += 1\n", + " errs += (x[\"node\"], x[\"item\"], x[\"op\"], x[\"qty\"], x[\"ctx\"]) != (node, item, op, qty, ctx) or abs(x[\"t\"] - t) > 1e-6\n", + "total = dt_parse + dt_db\n", + "print(f\"\\n[전처리 정확도] 적재 {chk}건 중 원본과 불일치 {errs}건 -> 전처리 오류율 {errs / chk:.2%} (목표 0%: {'달성' if errs == 0 else '미달'})\")\n", + "print(f\"처리 속도 {len(lines) / total:,.0f}건/초 (파싱·검증 {dt_parse:.2f}초 + DB 적재 {dt_db:.2f}초, 목표 1,000건/초 이상: {'달성' if len(lines) / total >= 1000 else '미달'})\")\n", + "print(f\"원본 {len(base)}건 중 잡음 주입 {len(noise_ids)}건과 격리 {int(susp.sum())}건을 제외하고 적재 {n_db}건 -> 격리된 건은 담당자가 원본 확인 후 재입력\")\n", + "print(\"※ 이상 판정된 줄은 삭제하지 않고 격리하며, 임계값은 입력 구분별 중앙값±6MAD로 자료에서 조정한다. 실제 서식에 맞는 정규식은 현장에서 보완한다.\")\n", + "\n", + "plt.figure(figsize=(8, 4))\n", + "for op, g in df[df[\"op\"] != \"adjust\"].groupby(\"op\"):\n", + " plt.hist(g[\"qty\"].clip(upper=200), bins=40, alpha=.5, label=op)\n", + "plt.axvline(100, color=\"k\", ls=\"--\"); plt.legend(); plt.xlabel(\"qty\"); plt.title(\"Quantity by operation (dashed: fixed threshold 100)\"); plt.tight_layout(); plt.show()\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 02 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + "[파싱·검증] 입력 8250줄(재전송 중복 200 포함): 해석 실패·허용값 오류 84줄 {'허용값 오류': 44, '해석 실패': 40}, 통과 8166줄, 중복 ID 198줄 제거\n", + "구분별 동적 임계값(수량): 입고 1~221, 불출 1~56\n", + " 고정 임계값(100개 초과) : 이상 판정 139건, 이상 수량 34/34 검출, 정상 입고인데 이상 판정(오탐) 105건\n", + " 구분별 동적 임계값 : 이상 판정 34건, 이상 수량 34/34 검출, 정상 입고인데 이상 판정(오탐) 0건\n", + "\n", + "[SQLite 적재] 500건 배치 16개: 롤백 1회(제약 위반 1건 -> 행 단위 격리), 적재 7933건, 격리 테이블 1건\n", + "같은 ID를 다시 넣어도 행 수 7933건 유지(멱등)\n", + "\n", + "[전처리 정확도] 적재 7933건 중 원본과 불일치 0건 -> 전처리 오류율 0.00% (목표 0%: 달성)\n", + "처리 속도 48,683건/초 (파싱·검증 0.13초 + DB 적재 0.04초, 목표 1,000건/초 이상: 달성)\n", + "원본 8050건 중 잡음 주입 116건과 격리 34건을 제외하고 적재 7933건 -> 격리된 건은 담당자가 원본 확인 후 재입력\n", + "※ 이상 판정된 줄은 삭제하지 않고 격리하며, 임계값은 입력 구분별 중앙값±6MAD로 자료에서 조정한다. 실제 서식에 맞는 정규식은 현장에서 보완한다.\n", + "```\n", + "
\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 03. 오프라인 단말 간 1차 피어투피어(P2P)/USB 재고 데이터 교환 Baseline 기능 구현\n", + "**소요시간**: 약 2.5시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "오프라인 단말 간 피어투피어(P2P) 재고 데이터 교환 Baseline 기능을 구현한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **보급소 단말 5곳이 접촉할 때 USB·P2P로 재고 트랜잭션을 주고받는 Baseline 교환 기능을 만들고 7일 후 정보 공유 정확도 75% 이상을 확인한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **7일간 접촉 56회(접촉당 방향별 최대 80건, 손실 5%)의 가상 일정에서 상대 상태를 모르는 전체 전송, ID 차집합 델타 전송, 델타에 우선순위(오래된 것 먼저)를 더한 방식을 비교 수행하고 검증하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `src/models/dat10_baseline_model.py 및 기본 추론 결과 보고서` 형태의 결과물을 완성한다.\n", + "\n", + "### 🔍 평가 및 검증 기준 (PASS 조건)\n", + "#### 미션 판정\n", + "\n", + "**평가 구분:** 필수\n", + "\n", + "**핵심 평가영역:** 알고리즘의 논리적 흐름 및 군사적 판단 기준의 투명성\n", + "\n", + "#### 미션 PASS 조건\n", + "\n", + "- 정량 기준: 기본 데이터셋 대상 탐지/처리 정확도 75% 이상 달성\n", + "\n", + "\n", + "- 진행 기준: 단위 기능 동작 확인 및 기본 시나리오 실행 로그 생성\n", + "\n", + "\n", + "- 검증 방법: 기준 벤치마크 데이터셋 대상 추론 결과 오차율 비교\n", + "\n", + "\n", + "#### 채점자 판정 메모\n", + "\n", + "> 단순 코드 실행 여부뿐만 아니라 출력된 수치와 근거 자료가 군 실무 요구사항에 부합하는지 면밀히 확인한다.\n", + "\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "\"\"\"오프라인 보급소 5곳이 끊긴 통신 대신 짧은 P2P 접촉·USB 인편으로 트랜잭션을 교환하는 Baseline을 구현해, 전체 전송·ID 차집합(델타) 전송·델타+우선순위 휴리스틱(내 기록 우선·오래된 것 우선)의 7일 후 동기화 완전성·전송 효율·수렴 시점을 비교하고 우선순위 가중치를 1차 튜닝한다.\"\"\"\n", + "import itertools\n", + "import numpy as np\n", + "import pandas as pd\n", + "import matplotlib.pyplot as plt\n", + "\n", + "# ---------------------------------------------------------------------------\n", + "# [교육용가상자료] 가상 야전 보급소·업체·품목 자료 생성기 (실제 부대·업체·품목·재고 정보 없음)\n", + "# 가상 보급소 5곳이 품목 12종의 입고·불출·조정 트랜잭션을 오프라인으로 기록하고 나중에 동기화한다.\n", + "# 공급망 재난 징후(업체 납기 지연, 품목 재고 급감)는 동기화된 자료를 마이닝해 식별한다.\n", + "# ---------------------------------------------------------------------------\n", + "NODES = [\"보급소A\", \"보급소B\", \"보급소C\", \"보급소D\", \"보급소E\"]\n", + "SUPPLIERS = [\"가상업체1\", \"가상업체2\", \"가상업체3\", \"가상업체4\", \"가상업체5\", \"가상업체6\"]\n", + "CATS = [\"일반보급\", \"정비부품\", \"의무소모\"]\n", + "ITEMS = [{\"item\": f\"I{i + 1:02d}\", \"cat\": CATS[i % 3], \"supplier\": SUPPLIERS[i % 6], \"lead_days\": 3 + (i * 5) % 12, \"use\": 4 + (i * 7) % 11, \"min_level\": 30 + 10 * (i % 5)} for i in range(12)]\n", + "ITEM_IDS = [x[\"item\"] for x in ITEMS]\n", + "CTXS = [\"평시\", \"야간\", \"통신불량\"]\n", + "\n", + "\n", + "def make_txns(seed=7, days=7, per_node_day=60, nodes=None):\n", + " \"\"\"노드별 오프라인 트랜잭션 목록. id는 노드-일련번호(멱등 키), t는 시각(시간 단위), ctx는 입력 상황.\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " out = []\n", + " for n in (nodes or NODES):\n", + " seq = 0\n", + " for k in range(days * per_node_day):\n", + " seq += 1\n", + " it = ITEMS[int(rng.integers(len(ITEMS)))]\n", + " t = float(rng.uniform(0, 24 * days))\n", + " ctx = \"야간\" if (t % 24 >= 20 or t % 24 < 5) else \"평시\"\n", + " if rng.random() < 0.12:\n", + " ctx = \"통신불량\"\n", + " r = rng.random()\n", + " if r < 0.22:\n", + " op, q = \"in\", int(it[\"use\"] * rng.uniform(4, 9))\n", + " elif r < 0.95:\n", + " op, q = \"out\", int(max(1, rng.normal(it[\"use\"] * 1.5, 3)))\n", + " else:\n", + " op, q = \"adjust\", int(rng.integers(-5, 6))\n", + " out.append({\"id\": f\"{n}-{seq:05d}\", \"node\": n, \"item\": it[\"item\"], \"op\": op, \"qty\": q, \"t\": round(t, 2), \"ctx\": ctx, \"supplier\": it[\"supplier\"]})\n", + " return out\n", + "\n", + "\n", + "def stock_of(txns, base=500):\n", + " \"\"\"품목별 재고 = 기초 재고 + 입고 - 불출 + 조정 (id 중복은 한 번만 반영).\"\"\"\n", + " st, seen = {i: base for i in ITEM_IDS}, set()\n", + " for x in txns:\n", + " if x[\"id\"] in seen:\n", + " continue\n", + " seen.add(x[\"id\"])\n", + " st[x[\"item\"]] += x[\"qty\"] if x[\"op\"] in (\"in\", \"adjust\") else -x[\"qty\"]\n", + " return st\n", + "\n", + "TX = make_txns(7, 7, per_node_day=60) # 5노드 x 7일 x 60건 = 2,100건\n", + "N = len(TX); IDX = {x[\"id\"]: i for i, x in enumerate(TX)}\n", + "ORIGIN = np.array([NODES.index(x[\"node\"]) for x in TX]); T = np.array([x[\"t\"] for x in TX])\n", + "CONTACTS_PER_DAY, BW, LOSS = 8, 80, 0.05 # 하루 접촉 8회, 접촉당 방향별 최대 60건, 전송 손실 5%\n", + "\n", + "\n", + "def contact_schedule(seed=3, days=7):\n", + " r = np.random.default_rng(seed); out = []\n", + " for d in range(days):\n", + " for _ in range(CONTACTS_PER_DAY):\n", + " a, b = r.choice(5, 2, replace=False); out.append((d * 24 + float(r.uniform(0, 24)), int(a), int(b), r.random()))\n", + " return sorted(out)\n", + "\n", + "\n", + "def simulate(method, w_own=0.0, w_age=0.0, seed=3, trace=False):\n", + " r = np.random.default_rng(seed + 100)\n", + " known = [set() for _ in range(5)]; sent = useful = 0; tr = []\n", + " prod = [np.where(ORIGIN == n)[0] for n in range(5)]\n", + " for tm, a, b, _ in contact_schedule(seed):\n", + " for n in (a, b): # 접촉 시점까지 자기 노드가 기록한 트랜잭션\n", + " known[n] |= set(prod[n][T[prod[n]] <= tm].tolist())\n", + " for src, dst in ((a, b), (b, a), ):\n", + " if method == \"full\":\n", + " cand = sorted(known[src], key=lambda i: T[i]) # 상대가 뭘 가졌는지 모르고 전부 보냄(앞에서부터 BW건)\n", + " else:\n", + " cand = [i for i in known[src] if i not in known[dst]] # 상대가 없는 것만(ID 요약 비교)\n", + " if method == \"prio\":\n", + " cand.sort(key=lambda i: -(w_own * (ORIGIN[i] == src) + w_age * (tm - T[i]) / 168))\n", + " else:\n", + " cand.sort(key=lambda i: T[i])\n", + " for i in cand[:BW]:\n", + " sent += 1\n", + " if r.random() > LOSS:\n", + " useful += i not in known[dst]; known[dst].add(i)\n", + " if trace:\n", + " tr.append((tm, np.mean([len(k) for k in known]) / N))\n", + " comp = float(np.mean([len(k) for k in known]) / N)\n", + " return {\"comp\": comp, \"sent\": sent, \"useful\": useful, \"eff\": useful / max(sent, 1), \"full_nodes\": sum(len(k) == N for k in known), \"trace\": tr}\n", + "\n", + "\n", + "# 1) 방식 비교\n", + "res = {\"전체 전송(상대 상태 모름)\": simulate(\"full\"), \"ID 차집합 델타(오래된 것 먼저)\": simulate(\"delta\"), \"델타 + 우선순위(기본 가중 own 1, age 0)\": simulate(\"prio\", 1, 0)}\n", + "print(f\"[조건] 보급소 5곳, 트랜잭션 {N}건, 7일간 접촉 {CONTACTS_PER_DAY * 7}회(P2P·USB), 접촉당 방향별 최대 {BW}건, 손실 {LOSS:.0%}\")\n", + "print(f\"{'방식':32s} {'완전성':>7s} {'전송':>6s} {'유효 전송':>8s} {'효율':>6s}\")\n", + "for k, v in res.items():\n", + " print(f\"{k:32s} {v['comp']:7.1%} {v['sent']:6d} {v['useful']:8d} {v['eff']:6.1%}\")\n", + "\n", + "# 2) 1차 튜닝: 우선순위 가중치 격자 탐색(스케줄 시드 3개 평균, 시험은 다른 시드 3개)\n", + "grid = {}\n", + "for wo, wa in itertools.product((0, 1, 2, 4), (-1, 0, 1)):\n", + " grid[(wo, wa)] = np.mean([simulate(\"prio\", wo, wa, seed=s)[\"comp\"] for s in (1, 2, 4)])\n", + "best = max(grid, key=grid.get)\n", + "print(f\"\\n[우선순위 가중치 탐색(튜닝 시드 3개 평균 완전성)] 내 기록 가중 x 오래됨 가중\")\n", + "print(\" \" + \" \".join(f\"own{wo}/age{wa}:{v:.1%}\" for (wo, wa), v in list(grid.items())[:6]) + \" ...\")\n", + "print(f\"채택: own {best[0]}, age {best[1]} (튜닝 평균 {grid[best]:.1%})\")\n", + "test = {m: np.mean([simulate(m, *( (best if m == 'prio' else (0, 0)) ), seed=s)[\"comp\"] for s in (5, 6, 7)]) for m in (\"full\", \"delta\", \"prio\")}\n", + "print(f\"시험 시드 3개 평균 완전성: 전체 전송 {test['full']:.1%}, 델타 {test['delta']:.1%}, 델타+튜닝 우선순위 {test['prio']:.1%}\")\n", + "acc = test[\"prio\"]\n", + "print(f\"기본 정확도(7일 후 모든 노드가 전체 트랜잭션 중 알고 있는 비율) {acc:.1%} (목표 75% 이상: {'달성' if acc >= 0.75 else '미달'})\")\n", + "\n", + "# 3) 수렴 추적과 판단 지연\n", + "import time\n", + "t0 = time.perf_counter(); r = simulate(\"prio\", *best, trace=True); dt = time.perf_counter() - t0\n", + "tr = r[\"trace\"]; day = next((t / 24 for t, c in tr if c >= 0.9), None)\n", + "print(f\"\\n[수렴] 7일 시뮬레이션 {dt * 1000:.0f}ms (접촉 1회당 {dt / len(tr) * 1000:.1f}ms), 완전성 90% 도달 시점 {('%.1f일' % day) if day else '7일 내 미도달'}, 모든 트랜잭션을 가진 노드 {r['full_nodes']}/5\")\n", + "print(\"접촉이 부족하면 완전성이 100%에 못 미치므로, 동기화 전 재고는 '확인된 범위' 표시가 필요하다(M6).\")\n", + "print(\"※ 가상 접촉 일정과 대역폭 가정에 따른 결과이며, 실제 접촉 빈도·USB 이동은 현장 조건에 따라 다르다. 병합 충돌 판단은 M4·M7에서 다룬다.\")\n", + "\n", + "plt.figure(figsize=(8, 4))\n", + "for m, lab in ((\"full\", \"full dump\"), (\"delta\", \"delta\"), (\"prio\", \"delta + priority\")):\n", + " rr = simulate(m, *(best if m == \"prio\" else (0, 0)), trace=True)[\"trace\"]; plt.plot([t / 24 for t, _ in rr], [c * 100 for _, c in rr], label=lab)\n", + "plt.xlabel(\"days\"); plt.ylabel(\"mean completeness (%)\"); plt.title(\"P2P/USB exchange: completeness over time\"); plt.legend(); plt.tight_layout(); plt.show()\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 03 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + "[조건] 보급소 5곳, 트랜잭션 2100건, 7일간 접촉 56회(P2P·USB), 접촉당 방향별 최대 80건, 손실 5%\n", + "방식 완전성 전송 유효 전송 효율\n", + "전체 전송(상대 상태 모름) 22.8% 8470 398 4.7%\n", + "ID 차집합 델타(오래된 것 먼저) 82.5% 6999 6657 95.1%\n", + "델타 + 우선순위(기본 가중 own 1, age 0) 83.7% 7143 6791 95.1%\n", + "\n", + "[우선순위 가중치 탐색(튜닝 시드 3개 평균 완전성)] 내 기록 가중 x 오래됨 가중\n", + " own0/age-1:84.8% own0/age0:83.7% own0/age1:82.7% own1/age-1:84.7% own1/age0:84.4% own1/age1:84.0% ...\n", + "채택: own 0, age -1 (튜닝 평균 84.8%)\n", + "시험 시드 3개 평균 완전성: 전체 전송 24.0%, 델타 82.1%, 델타+튜닝 우선순위 84.6%\n", + "기본 정확도(7일 후 모든 노드가 전체 트랜잭션 중 알고 있는 비율) 84.6% (목표 75% 이상: 달성)\n", + "\n", + "[수렴] 7일 시뮬레이션 58ms (접촉 1회당 1.0ms), 완전성 90% 도달 시점 7일 내 미도달, 모든 트랜잭션을 가진 노드 0/5\n", + "접촉이 부족하면 완전성이 100%에 못 미치므로, 동기화 전 재고는 '확인된 범위' 표시가 필요하다(M6).\n", + "※ 가상 접촉 일정과 대역폭 가정에 따른 결과이며, 실제 접촉 빈도·USB 이동은 현장 조건에 따라 다르다. 병합 충돌 판단은 M4·M7에서 다룬다.\n", + "```\n", + "
\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 04. 네트워크 복구 시 클라우드 중앙 서버와의 양방향 데이터 충돌 해결(CRDT) 동기화 고도화\n", + "**소요시간**: 약 3.0시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "네트워크 복구 시 중앙 서버와의 양방향 데이터 충돌 해결(CRDT) 동기화를 고도화한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **통신 복구 후 노드와 중앙 서버가 양방향으로 병합하는 CRDT 동기화를 구현하고, 같은 품목 정보를 서로 모르게 고친 충돌의 탐지 재현율 85% 이상과 처리 지연 200ms 이하를 확인한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **재고 수량은 품목별 PN-카운터(노드별 증가·감소 누적, 병합은 요소별 최댓값)로 만들어 노드 5곳과 서버를 2단계로 병합하고, 마지막 값이 이기는 방식(LWW)과 갱신 유실을 비교 수행하고 검증하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `src/models/dat10_advanced_detector.py 및 예외 탐지 테스트 결과` 형태의 결과물을 완성한다.\n", + "\n", + "### 🔍 평가 및 검증 기준 (PASS 조건)\n", + "#### 미션 판정\n", + "\n", + "**평가 구분:** 필수\n", + "\n", + "**핵심 평가영역:** 기만/교란 공격에 대한 방어 로직의 현실성 및 신뢰성\n", + "\n", + "#### 미션 PASS 조건\n", + "\n", + "- 정량 기준: 복합 위협 탐지 재현율(Recall) 85% 이상, 처리 지연 200ms 이하\n", + "\n", + "\n", + "- 진행 기준: 복합 위협 시나리오 10종에 대한 탐지 테스트 통과\n", + "\n", + "\n", + "- 검증 방법: 합성 위협 데이터셋 주입 시 이상치 탐지 정확도 검증\n", + "\n", + "\n", + "#### 채점자 판정 메모\n", + "\n", + "> 단순 코드 실행 여부뿐만 아니라 출력된 수치와 근거 자료가 군 실무 요구사항에 부합하는지 면밀히 확인한다.\n", + "\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "\"\"\"통신 복구 후 보급소 5곳과 중앙 서버가 양방향으로 병합하는 CRDT 동기화(재고는 PN-카운터, 메타 정보는 버전 벡터 기반 충돌 감지)를 구현해, 수렴·갱신 유실 0건·멱등/교환/결합 성질·중복 재전송·오래된 상태 재전송 방어를 검증하고, 충돌 탐지 재현율(시간 비교 규칙 대 버전 벡터 규칙)과 오경보 패턴·동기화 지연을 측정한다.\"\"\"\n", + "import copy\n", + "import itertools\n", + "import time\n", + "import numpy as np\n", + "import pandas as pd\n", + "import matplotlib.pyplot as plt\n", + "\n", + "# ---------------------------------------------------------------------------\n", + "# [교육용가상자료] 가상 야전 보급소·업체·품목 자료 생성기 (실제 부대·업체·품목·재고 정보 없음)\n", + "# 가상 보급소 5곳이 품목 12종의 입고·불출·조정 트랜잭션을 오프라인으로 기록하고 나중에 동기화한다.\n", + "# 공급망 재난 징후(업체 납기 지연, 품목 재고 급감)는 동기화된 자료를 마이닝해 식별한다.\n", + "# ---------------------------------------------------------------------------\n", + "NODES = [\"보급소A\", \"보급소B\", \"보급소C\", \"보급소D\", \"보급소E\"]\n", + "SUPPLIERS = [\"가상업체1\", \"가상업체2\", \"가상업체3\", \"가상업체4\", \"가상업체5\", \"가상업체6\"]\n", + "CATS = [\"일반보급\", \"정비부품\", \"의무소모\"]\n", + "ITEMS = [{\"item\": f\"I{i + 1:02d}\", \"cat\": CATS[i % 3], \"supplier\": SUPPLIERS[i % 6], \"lead_days\": 3 + (i * 5) % 12, \"use\": 4 + (i * 7) % 11, \"min_level\": 30 + 10 * (i % 5)} for i in range(12)]\n", + "ITEM_IDS = [x[\"item\"] for x in ITEMS]\n", + "CTXS = [\"평시\", \"야간\", \"통신불량\"]\n", + "\n", + "\n", + "def make_txns(seed=7, days=7, per_node_day=60, nodes=None):\n", + " \"\"\"노드별 오프라인 트랜잭션 목록. id는 노드-일련번호(멱등 키), t는 시각(시간 단위), ctx는 입력 상황.\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " out = []\n", + " for n in (nodes or NODES):\n", + " seq = 0\n", + " for k in range(days * per_node_day):\n", + " seq += 1\n", + " it = ITEMS[int(rng.integers(len(ITEMS)))]\n", + " t = float(rng.uniform(0, 24 * days))\n", + " ctx = \"야간\" if (t % 24 >= 20 or t % 24 < 5) else \"평시\"\n", + " if rng.random() < 0.12:\n", + " ctx = \"통신불량\"\n", + " r = rng.random()\n", + " if r < 0.22:\n", + " op, q = \"in\", int(it[\"use\"] * rng.uniform(4, 9))\n", + " elif r < 0.95:\n", + " op, q = \"out\", int(max(1, rng.normal(it[\"use\"] * 1.5, 3)))\n", + " else:\n", + " op, q = \"adjust\", int(rng.integers(-5, 6))\n", + " out.append({\"id\": f\"{n}-{seq:05d}\", \"node\": n, \"item\": it[\"item\"], \"op\": op, \"qty\": q, \"t\": round(t, 2), \"ctx\": ctx, \"supplier\": it[\"supplier\"]})\n", + " return out\n", + "\n", + "\n", + "def stock_of(txns, base=500):\n", + " \"\"\"품목별 재고 = 기초 재고 + 입고 - 불출 + 조정 (id 중복은 한 번만 반영).\"\"\"\n", + " st, seen = {i: base for i in ITEM_IDS}, set()\n", + " for x in txns:\n", + " if x[\"id\"] in seen:\n", + " continue\n", + " seen.add(x[\"id\"])\n", + " st[x[\"item\"]] += x[\"qty\"] if x[\"op\"] in (\"in\", \"adjust\") else -x[\"qty\"]\n", + " return st\n", + "\n", + "\n", + "class Replica:\n", + " \"\"\"재고는 품목별 PN-카운터(노드별 증가량 P, 감소량 N), 같은 노드의 기록은 서로 덮어쓰지 않는다. 병합은 요소별 최댓값.\"\"\"\n", + " def __init__(self, name):\n", + " self.name = name; self.P = {i: {} for i in ITEM_IDS}; self.N = {i: {} for i in ITEM_IDS}\n", + "\n", + " def apply(self, x):\n", + " d = self.P if x[\"op\"] == \"in\" or (x[\"op\"] == \"adjust\" and x[\"qty\"] > 0) else self.N\n", + " d[x[\"item\"]][x[\"node\"]] = d[x[\"item\"]].get(x[\"node\"], 0) + abs(x[\"qty\"])\n", + "\n", + " def merge(self, other):\n", + " for it in ITEM_IDS:\n", + " for src, dst in ((other.P, self.P), (other.N, self.N)):\n", + " for n, v in src[it].items():\n", + " dst[it][n] = max(dst[it].get(n, 0), v)\n", + "\n", + " def stock(self, base=500):\n", + " return {i: base + sum(self.P[i].values()) - sum(self.N[i].values()) for i in ITEM_IDS}\n", + "\n", + " def state(self):\n", + " return (tuple(tuple(sorted(self.P[i].items())) for i in ITEM_IDS), tuple(tuple(sorted(self.N[i].items())) for i in ITEM_IDS))\n", + "\n", + "\n", + "TX = make_txns(7, 7, per_node_day=60)\n", + "truth = stock_of(TX)\n", + "reps = {n: Replica(n) for n in NODES}\n", + "for x in TX:\n", + " reps[x[\"node\"]].apply(x) # 7일간 각 노드는 자기 기록만 반영(오프라인)\n", + "server = Replica(\"서버\")\n", + "\n", + "# 1) 통신 복구: 노드 -> 서버 병합 후 서버 -> 노드 병합(2단계). 지연 측정\n", + "lat = []\n", + "t0 = time.perf_counter()\n", + "for n in NODES:\n", + " t1 = time.perf_counter(); server.merge(reps[n]); lat.append((time.perf_counter() - t1) * 1000)\n", + "for n in NODES:\n", + " t1 = time.perf_counter(); reps[n].merge(server); lat.append((time.perf_counter() - t1) * 1000)\n", + "dt = time.perf_counter() - t0\n", + "conv = len({r.state() for r in reps.values()} | {server.state()}) == 1\n", + "lost = sum(abs(server.stock()[i] - truth[i]) for i in ITEM_IDS)\n", + "print(f\"[CRDT 동기화] 노드 5곳 + 서버, 트랜잭션 {len(TX)}건: 수렴 {'성공' if conv else '실패'}, 서버 재고와 정답(전체 합산) 차이 합계 {lost}개 -> 갱신 유실 {'0건' if lost == 0 else '있음'}\")\n", + "print(f\"병합 1회 평균 {np.mean(lat):.3f}ms, 최대 {max(lat):.3f}ms, 전체 동기화 {dt * 1000:.2f}ms (목표 200ms 이하: {'달성' if max(lat) <= 200 else '미달'})\")\n", + "\n", + "# 2) 비교: 재고 숫자를 '마지막 기록이 이김(LWW)'으로 덮어쓰면 오프라인 갱신이 사라진다\n", + "last = {}\n", + "for n in NODES: # 각 노드의 최종 재고 값(기초 재고 + 자기 기록만 반영)\n", + " loc = {i: 500 for i in ITEM_IDS}\n", + " for x in TX:\n", + " if x[\"node\"] == n:\n", + " loc[x[\"item\"]] += x[\"qty\"] if x[\"op\"] in (\"in\", \"adjust\") else -x[\"qty\"]\n", + " last[n] = (max(x[\"t\"] for x in TX if x[\"node\"] == n), loc)\n", + "lww = max(last.values())[1]\n", + "print(f\"숫자 덮어쓰기(LWW) 병합: 정답과 차이 합계 {sum(abs(lww[i] - truth[i]) for i in ITEM_IDS)}개 (갱신 유실) / PN-카운터 {lost}개\")\n", + "\n", + "# 3) CRDT 성질 시험: 멱등·교환·결합·중복·오래된 상태 재전송\n", + "rng = np.random.default_rng(5); ok = {\"멱등(두 번 병합)\": 0, \"교환(A+B = B+A)\": 0, \"결합((A+B)+C = A+(B+C))\": 0, \"오래된 상태 재전송\": 0}\n", + "trials = 200\n", + "for _ in range(trials):\n", + " a, b, c = (reps[n] for n in rng.choice(NODES, 3, replace=False))\n", + " parts = []\n", + " for r in (a, b, c):\n", + " q = Replica(\"t\")\n", + " for i in ITEM_IDS:\n", + " for n in NODES:\n", + " if rng.random() < .5:\n", + " q.P[i][n] = int(rng.integers(0, 100)); q.N[i][n] = int(rng.integers(0, 100))\n", + " parts.append(q)\n", + " x, y, z = parts\n", + " m = copy.deepcopy(x); m.merge(y); s1 = m.state(); m.merge(y); ok[\"멱등(두 번 병합)\"] += s1 == m.state()\n", + " m1 = copy.deepcopy(x); m1.merge(y); m2 = copy.deepcopy(y); m2.merge(x); ok[\"교환(A+B = B+A)\"] += m1.state() == m2.state()\n", + " l = copy.deepcopy(x); l.merge(y); l.merge(z); rr = copy.deepcopy(y); rr.merge(z); r2 = copy.deepcopy(x); r2.merge(rr); ok[\"결합((A+B)+C = A+(B+C))\"] += l.state() == r2.state()\n", + " new = copy.deepcopy(x); new.merge(y); old = copy.deepcopy(x); before = new.state(); new.merge(old); ok[\"오래된 상태 재전송\"] += before == new.state()\n", + "print(\"\\n[CRDT 성질 시험(무작위 상태 200쌍)]\", \", \".join(f\"{k} {v}/{trials}\" for k, v in ok.items()))\n", + "\n", + "# 4) 메타 정보(최소 보유량·위치·상태) 동시 수정 충돌 탐지: 시간 비교 규칙 vs 버전 벡터 규칙\n", + "def dominated(a, b):\n", + " return all(a.get(k, 0) <= b.get(k, 0) for k in set(a) | set(b)) and a != b\n", + "\n", + "\n", + "def make_pair(kind):\n", + " base = float(rng.uniform(0, 160)); va, vb = int(rng.integers(30, 80)), int(rng.integers(30, 80))\n", + " if va == vb:\n", + " vb += 7\n", + " A = {\"field\": \"min_level\", \"value\": va, \"t\": base, \"vv\": {\"A\": 3, \"B\": 1}}\n", + " if kind == \"concurrent\":\n", + " B = {\"field\": \"min_level\", \"value\": vb, \"t\": base + float(rng.uniform(0.5, 120)), \"vv\": {\"A\": 2, \"B\": 4}}\n", + " elif kind == \"causal\": # B가 A의 수정을 받은 뒤 고침(정상 후속 수정), 시각은 가까움\n", + " B = {\"field\": \"min_level\", \"value\": vb, \"t\": base + float(rng.uniform(0.01, 0.5)), \"vv\": {\"A\": 3, \"B\": 2}}\n", + " elif kind == \"same_value\":\n", + " B = {\"field\": \"min_level\", \"value\": va, \"t\": base + float(rng.uniform(0.5, 120)), \"vv\": {\"A\": 2, \"B\": 4}}\n", + " elif kind == \"other_field\":\n", + " B = {\"field\": \"location\", \"value\": vb, \"t\": base + float(rng.uniform(0.5, 120)), \"vv\": {\"A\": 2, \"B\": 4}}\n", + " else: # 시계 어긋남: 실제로는 동시 수정이지만 B 단말 시계가 느려 시각이 A보다 앞선 것처럼 기록됨\n", + " B = {\"field\": \"min_level\", \"value\": vb, \"t\": base - float(rng.uniform(0.5, 3)), \"vv\": {\"A\": 2, \"B\": 4}}\n", + " return A, B\n", + "\n", + "\n", + "truth_conf = {\"concurrent\": True, \"causal\": False, \"same_value\": False, \"other_field\": False, \"skew\": True}\n", + "pairs = [(k, *make_pair(k)) for k in rng.choice(list(truth_conf), 400, p=[.40, .25, .10, .10, .15])]\n", + "rule_time = lambda A, B: A[\"field\"] == B[\"field\"] and A[\"value\"] != B[\"value\"] and abs(A[\"t\"] - B[\"t\"]) <= 1.0 # 1시간 이내 같은 필드 다른 값\n", + "rule_val = lambda A, B: A[\"field\"] == B[\"field\"] and A[\"value\"] != B[\"value\"] # 같은 필드 다른 값이면 모두 충돌(인과 무시)\n", + "rule_vv1 = lambda A, B: A[\"field\"] == B[\"field\"] and not dominated(A[\"vv\"], B[\"vv\"]) and not dominated(B[\"vv\"], A[\"vv\"]) # 동시이면 충돌(값 비교 없음)\n", + "rule_vv2 = lambda A, B: rule_vv1(A, B) and A[\"value\"] != B[\"value\"] # 재설계: 값이 다를 때만\n", + "print(\"\\n[충돌 탐지] 정답 충돌 %d건 / 쌍 %d건\" % (sum(truth_conf[k] for k, _, _ in pairs), len(pairs)))\n", + "for name, rule in ((\"시간 근접 규칙(1시간 이내)\", rule_time), (\"값 비교 규칙(다르면 충돌)\", rule_val), (\"버전 벡터 규칙 v1(동시이면 충돌)\", rule_vv1), (\"버전 벡터 + 값 비교 v2(재설계)\", rule_vv2)):\n", + " pred = np.array([rule(a, b) for _, a, b in pairs]); tr = np.array([truth_conf[k] for k, _, _ in pairs])\n", + " tp, fp, fn = int((pred & tr).sum()), int((pred & ~tr).sum()), int((~pred & tr).sum())\n", + " kinds = {str(k): int(v) for k, v in pd.Series([k for (k, _, _), p, t in zip(pairs, pred, tr) if p != t]).value_counts().items()}\n", + " print(f\" {name:30s} 재현율 {tp / (tp + fn):5.1%}, 정밀도 {tp / max(tp + fp, 1):5.1%}, 오경보 {fp}건, 놓침 {fn}건 {kinds}\")\n", + "print(\"오경보·미탐 패턴: 시간 규칙은 오프라인 동안 멀리 떨어진 동시 수정(concurrent)·시계 어긋남(skew)을 놓치고, 값 비교 규칙은 '후속 정상 수정'(causal)을 충돌로 올림 -> 버전 벡터로 인과 관계를 보고 같은 값이면 제외(v2)\")\n", + "print(\"※ 충돌로 표시된 건은 자동 병합하지 않고 군수관이 수동 병합한다(M7). 재고 수량은 CRDT로 자동 합산되므로 사람이 판단할 대상은 메타 정보 충돌이다.\")\n", + "\n", + "plt.figure(figsize=(6, 3.5)); x = np.arange(len(ITEM_IDS))\n", + "plt.bar(x - .2, [truth[i] for i in ITEM_IDS], .4, label=\"truth / CRDT\"); plt.bar(x + .2, [lww[i] for i in ITEM_IDS], .4, label=\"LWW overwrite\", color=\"tab:red\")\n", + "plt.xticks(x, [f\"{i + 1}\" for i in range(12)]); plt.xlabel(\"item\"); plt.ylabel(\"stock\"); plt.legend(); plt.title(\"Merged stock: CRDT vs last-write-wins\"); plt.tight_layout(); plt.show()\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 04 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + "[CRDT 동기화] 노드 5곳 + 서버, 트랜잭션 2100건: 수렴 성공, 서버 재고와 정답(전체 합산) 차이 합계 0개 -> 갱신 유실 0건\n", + "병합 1회 평균 0.022ms, 최대 0.035ms, 전체 동기화 0.22ms (목표 200ms 이하: 달성)\n", + "숫자 덮어쓰기(LWW) 병합: 정답과 차이 합계 5142개 (갱신 유실) / PN-카운터 0개\n", + "\n", + "[CRDT 성질 시험(무작위 상태 200쌍)] 멱등(두 번 병합) 200/200, 교환(A+B = B+A) 200/200, 결합((A+B)+C = A+(B+C)) 200/200, 오래된 상태 재전송 200/200\n", + "\n", + "[충돌 탐지] 정답 충돌 226건 / 쌍 400건\n", + " 시간 근접 규칙(1시간 이내) 재현율 4.9%, 정밀도 9.3%, 오경보 107건, 놓침 215건 {'concurrent': 163, 'causal': 107, 'skew': 52}\n", + " 값 비교 규칙(다르면 충돌) 재현율 100.0%, 정밀도 67.9%, 오경보 107건, 놓침 0건 {'causal': 107}\n", + " 버전 벡터 규칙 v1(동시이면 충돌) 재현율 100.0%, 정밀도 88.3%, 오경보 30건, 놓침 0건 {'same_value': 30}\n", + " 버전 벡터 + 값 비교 v2(재설계) 재현율 100.0%, 정밀도 100.0%, 오경보 0건, 놓침 0건 {}\n", + "오경보·미탐 패턴: 시간 규칙은 오프라인 동안 멀리 떨어진 동시 수정(concurrent)·시계 어긋남(skew)을 놓치고, 값 비교 규칙은 '후속 정상 수정'(causal)을 충돌로 올림 -> 버전 벡터로 인과 관계를 보고 같은 값이면 제외(v2)\n", + "※ 충돌로 표시된 건은 자동 병합하지 않고 군수관이 수동 병합한다(M7). 재고 수량은 CRDT로 자동 합산되므로 사람이 판단할 대상은 메타 정보 충돌이다.\n", + "```\n", + "
\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 05. 오프라인 위변조 방지 암호화 해시 및 데이터 무결성 검증 엔진 개발\n", + "**소요시간**: 약 3.0시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "오프라인 위변조 방지 암호화 해시 및 데이터 무결성 검증 엔진을 개발한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **보급소 로그의 해시 체인과 레코드별 HMAC 서명으로 위변조를 찾는 오프라인 무결성 검증 엔진을 만들고, 단순 전체 해시 비교 대비 오경보율을 40% 이상 줄이고 신뢰도 등급 산출의 일관성 90% 이상을 확인한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **수량 수정·기록 삭제·순서 변경·위조 삽입·재전송 변조(같은 ID, 다른 수량)·꼬리 절단 6종의 악성 변형과 중복 전송·순서 뒤바뀜·공백 차이 3종의 정상 변형을 보급소 5곳에 6회씩 주입하여 처리 지연 및 복원력을 시험하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `src/verification/dat10_confidence_engine.py` 형태의 결과물을 완성한다.\n", + "\n", + "### 🔍 평가 및 검증 기준 (PASS 조건)\n", + "#### 미션 판정\n", + "\n", + "**평가 구분:** 필수\n", + "\n", + "**핵심 평가영역:** 신뢰도 등급(High/Med/Low) 부여 기준의 군사적 타당성\n", + "\n", + "#### 미션 PASS 조건\n", + "\n", + "- 정량 기준: 오경보율(FAR) 40% 이상 감축, 신뢰도 스코어 산출 일관성 90% 이상\n", + "\n", + "\n", + "- 진행 기준: 오경보 필터링 전후 비교 지표 산출 및 통합 테스트 완료\n", + "\n", + "\n", + "- 검증 방법: ROC-AUC 곡선 분석 및 임계값 민감도 테스트\n", + "\n", + "\n", + "#### 채점자 판정 메모\n", + "\n", + "> 단순 코드 실행 여부뿐만 아니라 출력된 수치와 근거 자료가 군 실무 요구사항에 부합하는지 면밀히 확인한다.\n", + "\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "\"\"\"보급소 로그의 해시 체인·레코드별 HMAC 서명으로 위변조(수량 수정·삭제·순서 변경·위조 삽입·재전송 변조·꼬리 절단)를 탐지하는 오프라인 무결성 검증 엔진과, 점검 항목 가중합 신뢰도(High/Med/Low)를 구현해 단순 전체 해시 비교 대비 오경보율 감축·신뢰도 산출 일관성·불확실성 표시를 검증한다.\"\"\"\n", + "import hashlib\n", + "import hmac\n", + "import numpy as np\n", + "import pandas as pd\n", + "import matplotlib.pyplot as plt\n", + "\n", + "# ---------------------------------------------------------------------------\n", + "# [교육용가상자료] 가상 야전 보급소·업체·품목 자료 생성기 (실제 부대·업체·품목·재고 정보 없음)\n", + "# 가상 보급소 5곳이 품목 12종의 입고·불출·조정 트랜잭션을 오프라인으로 기록하고 나중에 동기화한다.\n", + "# 공급망 재난 징후(업체 납기 지연, 품목 재고 급감)는 동기화된 자료를 마이닝해 식별한다.\n", + "# ---------------------------------------------------------------------------\n", + "NODES = [\"보급소A\", \"보급소B\", \"보급소C\", \"보급소D\", \"보급소E\"]\n", + "SUPPLIERS = [\"가상업체1\", \"가상업체2\", \"가상업체3\", \"가상업체4\", \"가상업체5\", \"가상업체6\"]\n", + "CATS = [\"일반보급\", \"정비부품\", \"의무소모\"]\n", + "ITEMS = [{\"item\": f\"I{i + 1:02d}\", \"cat\": CATS[i % 3], \"supplier\": SUPPLIERS[i % 6], \"lead_days\": 3 + (i * 5) % 12, \"use\": 4 + (i * 7) % 11, \"min_level\": 30 + 10 * (i % 5)} for i in range(12)]\n", + "ITEM_IDS = [x[\"item\"] for x in ITEMS]\n", + "CTXS = [\"평시\", \"야간\", \"통신불량\"]\n", + "\n", + "\n", + "def make_txns(seed=7, days=7, per_node_day=60, nodes=None):\n", + " \"\"\"노드별 오프라인 트랜잭션 목록. id는 노드-일련번호(멱등 키), t는 시각(시간 단위), ctx는 입력 상황.\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " out = []\n", + " for n in (nodes or NODES):\n", + " seq = 0\n", + " for k in range(days * per_node_day):\n", + " seq += 1\n", + " it = ITEMS[int(rng.integers(len(ITEMS)))]\n", + " t = float(rng.uniform(0, 24 * days))\n", + " ctx = \"야간\" if (t % 24 >= 20 or t % 24 < 5) else \"평시\"\n", + " if rng.random() < 0.12:\n", + " ctx = \"통신불량\"\n", + " r = rng.random()\n", + " if r < 0.22:\n", + " op, q = \"in\", int(it[\"use\"] * rng.uniform(4, 9))\n", + " elif r < 0.95:\n", + " op, q = \"out\", int(max(1, rng.normal(it[\"use\"] * 1.5, 3)))\n", + " else:\n", + " op, q = \"adjust\", int(rng.integers(-5, 6))\n", + " out.append({\"id\": f\"{n}-{seq:05d}\", \"node\": n, \"item\": it[\"item\"], \"op\": op, \"qty\": q, \"t\": round(t, 2), \"ctx\": ctx, \"supplier\": it[\"supplier\"]})\n", + " return out\n", + "\n", + "\n", + "def stock_of(txns, base=500):\n", + " \"\"\"품목별 재고 = 기초 재고 + 입고 - 불출 + 조정 (id 중복은 한 번만 반영).\"\"\"\n", + " st, seen = {i: base for i in ITEM_IDS}, set()\n", + " for x in txns:\n", + " if x[\"id\"] in seen:\n", + " continue\n", + " seen.add(x[\"id\"])\n", + " st[x[\"item\"]] += x[\"qty\"] if x[\"op\"] in (\"in\", \"adjust\") else -x[\"qty\"]\n", + " return st\n", + "\n", + "KEYS = {n: hashlib.sha256(f\"교육용-가상-키-{n}\".encode()).digest() for n in NODES} # 노드별 서명 키(가상, 실제 키 관리는 별도 체계)\n", + "TX = make_txns(7, 7, per_node_day=60)\n", + "\n", + "\n", + "def rec_str(x):\n", + " return f\"{x['id']}|{x['node']}|{x['item']}|{x['op']}|{x['qty']}|{x['t']}\"\n", + "\n", + "\n", + "def sign_log(node):\n", + " \"\"\"노드 로그를 순서대로 해시 체인으로 묶고 레코드별 HMAC 서명을 붙인다. 마지막 해시(머리)는 동료 노드가 따로 보관한다.\"\"\"\n", + " log, prev = [], \"GENESIS\"\n", + " for x in [y for y in TX if y[\"node\"] == node]:\n", + " h = hashlib.sha256((prev + rec_str(x)).encode()).hexdigest()\n", + " log.append({**x, \"prev\": prev, \"hash\": h, \"sig\": hmac.new(KEYS[node], rec_str(x).encode(), \"sha256\").hexdigest()[:16], \"note\": \"\"}); prev = h\n", + " return log, prev\n", + "\n", + "\n", + "LOGS = {n: sign_log(n) for n in NODES}\n", + "HEADS = {n: LOGS[n][1] for n in NODES}\n", + "\n", + "\n", + "def canonical(log):\n", + " \"\"\"배달 과정의 정상 변형(중복 전송·순서 뒤바뀜·공백 차이)을 정리: 같은 ID는 내용이 같으면 하나만, 일련번호 순 정렬, note 공백 제거.\"\"\"\n", + " seen, out, conflicts = {}, [], 0\n", + " for r in log:\n", + " if r[\"id\"] in seen:\n", + " conflicts += (rec_str(r) != rec_str(seen[r[\"id\"]])); continue\n", + " seen[r[\"id\"]] = r; out.append(r)\n", + " return sorted(out, key=lambda r: r[\"id\"]), conflicts\n", + "\n", + "\n", + "def verify(node, log, head=None):\n", + " \"\"\"검증 항목 6개의 결과 dict: 체인 연속, 서명, 같은 ID 내용 충돌, 일련번호 연속, 시각 이상, 꼬리 절단.\"\"\"\n", + " log, conf = canonical(log)\n", + " chain_ok = sig_ok = True; prev = \"GENESIS\"; bad_sig = 0\n", + " for r in log:\n", + " if r[\"prev\"] != prev or hashlib.sha256((prev + rec_str(r)).encode()).hexdigest() != r[\"hash\"]:\n", + " chain_ok = False\n", + " if not hmac.compare_digest(r[\"sig\"], hmac.new(KEYS[node], rec_str(r).encode(), \"sha256\").hexdigest()[:16]):\n", + " sig_ok = False; bad_sig += 1\n", + " prev = r[\"hash\"]\n", + " seqs = [int(r[\"id\"].split(\"-\")[1]) for r in log]\n", + " gap = seqs != list(range(1, len(seqs) + 1)) if seqs else True\n", + " ts = np.array([r[\"t\"] for r in log]); skew = bool(len(ts) and (ts < 0).any())\n", + " tail = bool(head is not None and prev != head)\n", + " return {\"chain\": chain_ok, \"sig\": sig_ok, \"conflict\": conf == 0, \"seq\": not gap, \"time\": not skew, \"tail\": not tail, \"bad_sig\": bad_sig}\n", + "\n", + "\n", + "W = {\"chain\": 0.30, \"sig\": 0.30, \"conflict\": 0.15, \"seq\": 0.10, \"time\": 0.05, \"tail\": 0.10} # 신뢰도 가중치 매트릭스(합 1)\n", + "\n", + "\n", + "def confidence(v, sample_frac=1.0, rng=None):\n", + " \"\"\"점검 통과 가중합. 체인·서명·충돌·꼬리 실패는 즉시 Low. 서명은 sample_frac 비율의 표본 점검만 했을 때 불확실성이 생긴다.\"\"\"\n", + " s = sum(W[k] for k in W if v[k])\n", + " lvl = \"Low\" if not (v[\"chain\"] and v[\"sig\"] and v[\"conflict\"] and v[\"tail\"]) else \"High\" if s >= 0.95 else \"Med\" if s >= 0.75 else \"Low\"\n", + " return s, lvl\n", + "\n", + "\n", + "# 시험 1: 악성 변조 6종 + 정상 전송 변형 3종, 각 노드 x 6회\n", + "import copy\n", + "rng = np.random.default_rng(11)\n", + "\n", + "\n", + "def tamper(kind, log):\n", + " log = copy.deepcopy(log); i = int(rng.integers(5, len(log) - 5))\n", + " if kind == \"수량 수정\":\n", + " log[i][\"qty\"] += 40\n", + " elif kind == \"기록 삭제\":\n", + " del log[i]\n", + " elif kind == \"순서 바꿈+시각 수정\":\n", + " log[i], log[i + 1] = log[i + 1], log[i]; log[i][\"t\"] = round(log[i][\"t\"] + 3, 2)\n", + " elif kind == \"위조 삽입(서명 없음)\":\n", + " f = dict(log[i]); f[\"id\"] = log[i][\"id\"].split(\"-\")[0] + \"-\" + f\"{len(log) + 1:05d}\"; f[\"qty\"] = 99; f[\"sig\"] = \"0\" * 16; log.append(f)\n", + " elif kind == \"재전송 변조(같은 ID, 다른 수량)\":\n", + " f = dict(log[i]); f[\"qty\"] += 7; log.append(f)\n", + " elif kind == \"꼬리 절단\":\n", + " log = log[: -int(rng.integers(3, 20))]\n", + " elif kind == \"중복 전송\":\n", + " log += copy.deepcopy(log[i:i + 5])\n", + " elif kind == \"순서 뒤바뀜 전송\":\n", + " rng.shuffle(log)\n", + " elif kind == \"공백 차이(note)\":\n", + " for r in log[i:i + 3]:\n", + " r[\"note\"] = \" \"\n", + " return log\n", + "\n", + "\n", + "MAL = [\"수량 수정\", \"기록 삭제\", \"순서 바꿈+시각 수정\", \"위조 삽입(서명 없음)\", \"재전송 변조(같은 ID, 다른 수량)\", \"꼬리 절단\"]\n", + "BEN = [\"중복 전송\", \"순서 뒤바뀜 전송\", \"공백 차이(note)\"]\n", + "rows = []\n", + "for n in NODES:\n", + " for kind in MAL + BEN:\n", + " for _ in range(6):\n", + " lg = tamper(kind, LOGS[n][0])\n", + " naive = hashlib.sha256(\"\".join(rec_str(r) + r[\"hash\"] + r[\"note\"] for r in lg).encode()).hexdigest() != hashlib.sha256(\"\".join(rec_str(r) + r[\"hash\"] + r[\"note\"] for r in LOGS[n][0]).encode()).hexdigest()\n", + " v = verify(n, lg, HEADS[n]); s, lvl = confidence(v)\n", + " rows.append({\"node\": n, \"kind\": kind, \"mal\": kind in MAL, \"naive\": naive, \"alarm\": lvl == \"Low\", \"score\": s, \"lvl\": lvl})\n", + "R = pd.DataFrame(rows)\n", + "det = R[R[\"mal\"]].groupby(\"kind\").agg(naive=(\"naive\", \"mean\"), layered=(\"alarm\", \"mean\"))\n", + "print(f\"[변조 탐지] 악성 {len(MAL)}종 x 노드 5 x 6회 = {int(R['mal'].sum())}건, 정상 전송 변형 {len(BEN)}종 = {int((~R['mal']).sum())}건\")\n", + "print(\"악성 종류별 탐지율: 전체 해시 비교 / 계층 검증(체인+서명+충돌+순번+꼬리)\")\n", + "for k, r in det.iterrows():\n", + " print(f\" {k:28s} {r['naive']:5.0%} / {r['layered']:5.0%}\")\n", + "tp_l, fp_l = int((R[\"alarm\"] & R[\"mal\"]).sum()), int((R[\"alarm\"] & ~R[\"mal\"]).sum())\n", + "tp_n, fp_n = int((R[\"naive\"] & R[\"mal\"]).sum()), int((R[\"naive\"] & ~R[\"mal\"]).sum())\n", + "far_n, far_l = fp_n / (tp_n + fp_n), fp_l / max(tp_l + fp_l, 1)\n", + "print(f\"\\n[오경보] 전체 해시 비교: 경보 {tp_n + fp_n}건 중 정상 변형 {fp_n}건(오경보율 {far_n:.1%}), 탐지율 {tp_n / R['mal'].sum():.0%}\")\n", + "print(f\" 계층 검증(정규화 후): 경보 {tp_l + fp_l}건 중 정상 변형 {fp_l}건(오경보율 {far_l:.1%}), 탐지율 {tp_l / R['mal'].sum():.0%}\")\n", + "print(f\"-> 오경보율 {far_n:.1%} -> {far_l:.1%} ({(far_n - far_l) / far_n:.0%} 감축, 목표 40% 이상: {'달성' if (far_n - far_l) / far_n >= 0.4 else '미달'})\")\n", + "miss = R[R[\"mal\"] & ~R[\"alarm\"]][\"kind\"].value_counts().to_dict()\n", + "print(f\"계층 검증 미탐: {miss or '없음'} (꼬리 절단은 동료가 보관한 머리 해시가 있어야 탐지)\")\n", + "\n", + "# 시험 2: 신뢰도 점수와 일관성(같은 로그를 서명 표본 점검 비율을 바꿔가며 20회 점검했을 때 등급이 같은가)\n", + "print(\"\\n[신뢰도 가중치 매트릭스]\", W, \"/ High 0.95 이상, Med 0.75 이상, 체인·서명·충돌·꼬리 실패는 즉시 Low\")\n", + "cons = []\n", + "for n in NODES:\n", + " grades, scores = [], []\n", + " for k in range(20):\n", + " lg = LOGS[n][0] if k % 2 == 0 else tamper(\"중복 전송\", LOGS[n][0]) if k % 4 == 1 else tamper(\"순서 뒤바뀜 전송\", LOGS[n][0])\n", + " s, lvl = confidence(verify(n, lg, HEADS[n])); grades.append(lvl); scores.append(s)\n", + " cons.append((n, pd.Series(grades).value_counts().iloc[0] / 20, np.mean(scores), np.std(scores)))\n", + "cm = np.mean([c[1] for c in cons])\n", + "print(\"정상 로그 20회 반복(정상 전송 변형 포함) 등급 일관성: \" + \", \".join(f\"{n[-1]} {c:.0%}\" for n, c, _, _ in cons) + f\" -> 평균 {cm:.0%} (목표 90% 이상: {'달성' if cm >= .9 else '미달'})\")\n", + "lv = pd.crosstab(R[\"kind\"], R[\"lvl\"]).reindex(columns=[\"High\", \"Med\", \"Low\"], fill_value=0)\n", + "print(\"\\n[변형 종류별 신뢰도 등급 분포]\"); print(lv.to_string())\n", + "bad = verify(\"보급소A\", tamper(\"수량 수정\", LOGS[\"보급소A\"][0]), HEADS[\"보급소A\"])\n", + "print(f\"불확실성 표시 예: 수량 수정 로그의 서명 불일치 레코드 {bad['bad_sig']}건 -> 해당 레코드 번호를 군수관에게 제시(전체 폐기 아님)\")\n", + "print(\"※ 서명 키 배포·보관, 단말 분실 시 폐기는 별도 보안 체계가 필요하다. 신뢰도 등급은 점검 항목 가중합이며 통계적 확률이 아니다. 최종 판단은 군수관·보안 담당이 한다.\")\n", + "\n", + "plt.figure(figsize=(8, 3.8)); x = np.arange(len(det))\n", + "plt.bar(x - .2, det[\"naive\"] * 100, .4, label=\"whole-hash compare\", color=\"tab:gray\"); plt.bar(x + .2, det[\"layered\"] * 100, .4, label=\"layered verify\", color=\"tab:blue\")\n", + "plt.xticks(x, [f\"M{i + 1}\" for i in range(len(det))]); plt.ylabel(\"detection rate (%)\"); plt.title(\"Tamper detection by type (M1-M6)\"); plt.legend(); plt.tight_layout(); plt.show()\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 05 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + "[변조 탐지] 악성 6종 x 노드 5 x 6회 = 180건, 정상 전송 변형 3종 = 90건\n", + "악성 종류별 탐지율: 전체 해시 비교 / 계층 검증(체인+서명+충돌+순번+꼬리)\n", + " 기록 삭제 100% / 100%\n", + " 꼬리 절단 100% / 100%\n", + " 수량 수정 100% / 100%\n", + " 순서 바꿈+시각 수정 100% / 100%\n", + " 위조 삽입(서명 없음) 100% / 100%\n", + " 재전송 변조(같은 ID, 다른 수량) 100% / 100%\n", + "\n", + "[오경보] 전체 해시 비교: 경보 270건 중 정상 변형 90건(오경보율 33.3%), 탐지율 100%\n", + " 계층 검증(정규화 후): 경보 180건 중 정상 변형 0건(오경보율 0.0%), 탐지율 100%\n", + "-> 오경보율 33.3% -> 0.0% (100% 감축, 목표 40% 이상: 달성)\n", + "계층 검증 미탐: 없음 (꼬리 절단은 동료가 보관한 머리 해시가 있어야 탐지)\n", + "\n", + "[신뢰도 가중치 매트릭스] {'chain': 0.3, 'sig': 0.3, 'conflict': 0.15, 'seq': 0.1, 'time': 0.05, 'tail': 0.1} / High 0.95 이상, Med 0.75 이상, 체인·서명·충돌·꼬리 실패는 즉시 Low\n", + "정상 로그 20회 반복(정상 전송 변형 포함) 등급 일관성: A 100%, B 100%, C 100%, D 100%, E 100% -> 평균 100% (목표 90% 이상: 달성)\n", + "\n", + "[변형 종류별 신뢰도 등급 분포]\n", + "lvl High Med Low\n", + "kind \n", + "공백 차이(note) 30 0 0\n", + "기록 삭제 0 0 30\n", + "꼬리 절단 0 0 30\n", + "수량 수정 0 0 30\n", + "순서 뒤바뀜 전송 30 0 0\n", + "순서 바꿈+시각 수정 0 0 30\n", + "위조 삽입(서명 없음) 0 0 30\n", + "재전송 변조(같은 ID, 다른 수량) 0 0 30\n", + "중복 전송 30 0 0\n", + "불확실성 표시 예: 수량 수정 로그의 서명 불일치 레코드 1건 -> 해당 레코드 번호를 군수관에게 제시(전체 폐기 아님)\n", + "※ 서명 키 배포·보관, 단말 분실 시 폐기는 별도 보안 체계가 필요하다. 신뢰도 등급은 점검 항목 가중합이며 통계적 확률이 아니다. 최종 판단은 군수관·보안 담당이 한다.\n", + "```\n", + "
\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 06. 오프라인/온라인 전환 상태 및 재고 변동 내역 실시간 대시보드 UI 구현\n", + "**소요시간**: 약 2.5시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "오프라인/온라인 상태 및 재고 변동 내역 실시간 대시보드 UI를 구현한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **보급소별 오프라인/온라인 상태와 품목별 재고 변동 내역, 공급 경보를 보여 주는 실시간 대시보드 화면 모델을 구현하고 갱신 1초 이내·조작 응답 100ms 이내를 확인한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **7일치 트랜잭션 2,100건을 1시간 단위 168회 데이터 스트림으로 흘려 보급소 상태판(온라인·오프라인·미동기 건수·지연 시간), 품목별 재고 게이지(소진 예상 일수와 납기 비교), 변동 내역, 경보 목록 4개 패널의 화면 모델을 갱신 수행하고 검증하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `src/ui/dat10_dashboard.py (또는 웹 UI 컴포넌트)` 형태의 결과물을 완성한다.\n", + "\n", + "### 🔍 평가 및 검증 기준 (PASS 조건)\n", + "#### 미션 판정\n", + "\n", + "**평가 구분:** 필수\n", + "\n", + "**핵심 평가영역:** 전술 작전 상황판으로서의 직관성 및 인지 부하 감소도\n", + "\n", + "#### 미션 PASS 조건\n", + "\n", + "- 정량 기준: 화면 갱신 주기 1초 이내, UI 응답성 100ms 이내\n", + "\n", + "\n", + "- 진행 기준: 주요 지표 시각화 컴포넌트 4종 이상 렌더링 및 실시간 데이터 연동\n", + "\n", + "\n", + "- 검증 방법: 사용자 인터랙션 테스트 및 모의 데이터 스트림 렌더링 검증\n", + "\n", + "\n", + "#### 채점자 판정 메모\n", + "\n", + "> 단순 코드 실행 여부뿐만 아니라 출력된 수치와 근거 자료가 군 실무 요구사항에 부합하는지 면밀히 확인한다.\n", + "\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "\"\"\"5개 보급소의 오프라인/온라인 전환 상태와 품목별 재고 변동 내역을 시간 단위 모의 데이터 스트림으로 갱신하는 실시간 대시보드 화면 모델(상태판·재고 게이지·변동 내역·경보 패널 4종)과 야간 모드·긴급 경보 강조·조작 응답 시간을 구현해 갱신 주기 1초 이내·UI 응답 100ms 이내를 측정한다.\"\"\"\n", + "import time\n", + "import numpy as np\n", + "import pandas as pd\n", + "import matplotlib.pyplot as plt\n", + "\n", + "# ---------------------------------------------------------------------------\n", + "# [교육용가상자료] 가상 야전 보급소·업체·품목 자료 생성기 (실제 부대·업체·품목·재고 정보 없음)\n", + "# 가상 보급소 5곳이 품목 12종의 입고·불출·조정 트랜잭션을 오프라인으로 기록하고 나중에 동기화한다.\n", + "# 공급망 재난 징후(업체 납기 지연, 품목 재고 급감)는 동기화된 자료를 마이닝해 식별한다.\n", + "# ---------------------------------------------------------------------------\n", + "NODES = [\"보급소A\", \"보급소B\", \"보급소C\", \"보급소D\", \"보급소E\"]\n", + "SUPPLIERS = [\"가상업체1\", \"가상업체2\", \"가상업체3\", \"가상업체4\", \"가상업체5\", \"가상업체6\"]\n", + "CATS = [\"일반보급\", \"정비부품\", \"의무소모\"]\n", + "ITEMS = [{\"item\": f\"I{i + 1:02d}\", \"cat\": CATS[i % 3], \"supplier\": SUPPLIERS[i % 6], \"lead_days\": 3 + (i * 5) % 12, \"use\": 4 + (i * 7) % 11, \"min_level\": 30 + 10 * (i % 5)} for i in range(12)]\n", + "ITEM_IDS = [x[\"item\"] for x in ITEMS]\n", + "CTXS = [\"평시\", \"야간\", \"통신불량\"]\n", + "\n", + "\n", + "def make_txns(seed=7, days=7, per_node_day=60, nodes=None):\n", + " \"\"\"노드별 오프라인 트랜잭션 목록. id는 노드-일련번호(멱등 키), t는 시각(시간 단위), ctx는 입력 상황.\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " out = []\n", + " for n in (nodes or NODES):\n", + " seq = 0\n", + " for k in range(days * per_node_day):\n", + " seq += 1\n", + " it = ITEMS[int(rng.integers(len(ITEMS)))]\n", + " t = float(rng.uniform(0, 24 * days))\n", + " ctx = \"야간\" if (t % 24 >= 20 or t % 24 < 5) else \"평시\"\n", + " if rng.random() < 0.12:\n", + " ctx = \"통신불량\"\n", + " r = rng.random()\n", + " if r < 0.22:\n", + " op, q = \"in\", int(it[\"use\"] * rng.uniform(4, 9))\n", + " elif r < 0.95:\n", + " op, q = \"out\", int(max(1, rng.normal(it[\"use\"] * 1.5, 3)))\n", + " else:\n", + " op, q = \"adjust\", int(rng.integers(-5, 6))\n", + " out.append({\"id\": f\"{n}-{seq:05d}\", \"node\": n, \"item\": it[\"item\"], \"op\": op, \"qty\": q, \"t\": round(t, 2), \"ctx\": ctx, \"supplier\": it[\"supplier\"]})\n", + " return out\n", + "\n", + "\n", + "def stock_of(txns, base=500):\n", + " \"\"\"품목별 재고 = 기초 재고 + 입고 - 불출 + 조정 (id 중복은 한 번만 반영).\"\"\"\n", + " st, seen = {i: base for i in ITEM_IDS}, set()\n", + " for x in txns:\n", + " if x[\"id\"] in seen:\n", + " continue\n", + " seen.add(x[\"id\"])\n", + " st[x[\"item\"]] += x[\"qty\"] if x[\"op\"] in (\"in\", \"adjust\") else -x[\"qty\"]\n", + " return st\n", + "\n", + "THEMES = {\"주간\": {\"bg\": (255, 255, 255), \"fg\": (30, 30, 30), \"ok\": (30, 120, 60), \"warn\": (150, 90, 0), \"crit\": (190, 30, 30), \"off\": (90, 90, 90)},\n", + " \"야간\": {\"bg\": (18, 22, 28), \"fg\": (225, 230, 235), \"ok\": (90, 210, 130), \"warn\": (245, 190, 70), \"crit\": (255, 110, 110), \"off\": (160, 165, 170)}}\n", + "\n", + "\n", + "def lum(c):\n", + " f = [(v / 255 / 12.92) if v / 255 <= 0.03928 else ((v / 255 + 0.055) / 1.055) ** 2.4 for v in c]\n", + " return 0.2126 * f[0] + 0.7152 * f[1] + 0.0722 * f[2]\n", + "\n", + "\n", + "def contrast(a, b):\n", + " la, lb = sorted([lum(a), lum(b)], reverse=True)\n", + " return (la + 0.05) / (lb + 0.05)\n", + "\n", + "\n", + "class Dashboard:\n", + " \"\"\"화면 모델: 매 tick(1시간)마다 새 트랜잭션을 반영하고 4개 패널의 표시 값을 갱신한다. 렌더링은 모델과 분리해 응답 시간을 따로 잰다.\"\"\"\n", + " def __init__(self, txns, outages, base=500):\n", + " self.tx = sorted(txns, key=lambda x: x[\"t\"]); self.p = 0; self.outages = outages\n", + " self.central = {i: base for i in ITEM_IDS}; self.local = {n: {i: 0 for i in ITEM_IDS} for n in NODES} # 서버 반영 재고 / 노드별 미동기 변동\n", + " self.pending = {n: 0 for n in NODES}; self.last_sync = {n: 0.0 for n in NODES}; self.feed = []; self.theme = \"주간\"; self.node_filter = None; self.only_alert = False; self.acked = set(); self.out24 = {i: [] for i in ITEM_IDS}\n", + "\n", + " def online(self, n, t):\n", + " return not any(a <= t < b for a, b in self.outages.get(n, []))\n", + "\n", + " def step(self, t):\n", + " while self.p < len(self.tx) and self.tx[self.p][\"t\"] < t:\n", + " x = self.tx[self.p]; self.p += 1; d = x[\"qty\"] if x[\"op\"] in (\"in\", \"adjust\") else -x[\"qty\"]\n", + " if self.online(x[\"node\"], x[\"t\"]):\n", + " self.central[x[\"item\"]] += d; self.last_sync[x[\"node\"]] = x[\"t\"]\n", + " else:\n", + " self.local[x[\"node\"]][x[\"item\"]] += d; self.pending[x[\"node\"]] += 1\n", + " if x[\"op\"] == \"out\":\n", + " self.out24[x[\"item\"]].append((x[\"t\"], x[\"qty\"]))\n", + " self.feed.append((x[\"t\"], x[\"node\"], x[\"item\"], x[\"op\"], x[\"qty\"], \"반영\" if self.online(x[\"node\"], x[\"t\"]) else \"대기\"))\n", + " for n in NODES: # 복구된 노드는 대기분을 한꺼번에 반영\n", + " if self.online(n, t) and self.pending[n]:\n", + " for i, v in self.local[n].items():\n", + " self.central[i] += v; self.local[n][i] = 0\n", + " self.pending[n] = 0; self.last_sync[n] = t\n", + " return self.view(t)\n", + "\n", + " def view(self, t):\n", + " status = [{\"node\": n, \"state\": \"온라인\" if self.online(n, t) else \"오프라인\", \"pending\": self.pending[n], \"lag_h\": round(t - self.last_sync[n], 1)} for n in NODES]\n", + " gauges = []\n", + " for it in ITEMS:\n", + " st = self.central[it[\"item\"]] + sum(self.local[n][it[\"item\"]] for n in NODES)\n", + " o24 = sum(q for (tt, q) in self.out24[it[\"item\"]] if tt >= t - 24)\n", + " cover = st / max(o24, 1) if t >= 24 else 99.0 # 최근 24시간 불출량 기준 소진 예상 일수\n", + " lvl = \"긴급\" if (st < it[\"min_level\"] or cover < 0.5 * it[\"lead_days\"]) else \"주의\" if cover < it[\"lead_days\"] else \"정상\"\n", + " gauges.append({\"item\": it[\"item\"], \"stock\": st, \"min\": it[\"min_level\"], \"lvl\": lvl, \"cover\": cover, \"lead\": it[\"lead_days\"], \"acked\": it[\"item\"] in self.acked})\n", + " if self.only_alert:\n", + " gauges = [g for g in gauges if g[\"lvl\"] != \"정상\"]\n", + " feed = [f for f in self.feed[-200:] if not self.node_filter or f[1] == self.node_filter][-6:]\n", + " alerts = [g for g in gauges if g[\"lvl\"] == \"긴급\" and not g[\"acked\"]] + [dict(node=s[\"node\"], lvl=\"장기 오프라인\") for s in status if s[\"lag_h\"] >= 48]\n", + " return {\"t\": t, \"status\": status, \"gauges\": gauges, \"feed\": feed, \"alerts\": alerts, \"theme\": self.theme}\n", + "\n", + " # ---- 사용자 조작 핸들러 ----\n", + " def set_theme(self, name):\n", + " self.theme = name; return self.view(self.tx[self.p - 1][\"t\"] if self.p else 0)\n", + "\n", + " def set_node(self, n):\n", + " self.node_filter = n; return self.view(self.tx[self.p - 1][\"t\"])\n", + "\n", + " def toggle_alert(self):\n", + " self.only_alert = not self.only_alert; return self.view(self.tx[self.p - 1][\"t\"])\n", + "\n", + " def ack(self, item):\n", + " self.acked.add(item); return self.view(self.tx[self.p - 1][\"t\"])\n", + "\n", + "\n", + "rng = np.random.default_rng(21)\n", + "TX = make_txns(7, 7, per_node_day=60)\n", + "OUT = {\"보급소B\": [(24.0, 96.0)], \"보급소D\": [(60.0, 120.0), (140.0, 150.0)], \"보급소E\": [(10.0, 168.5)]} # 오프라인 구간(시간)\n", + "db = Dashboard(TX, OUT)\n", + "lat, snaps, off_n = [], {}, []\n", + "for h in range(1, 169):\n", + " t0 = time.perf_counter(); v = db.step(float(h)); lat.append((time.perf_counter() - t0) * 1000)\n", + " off_n.append(sum(s[\"state\"] == \"오프라인\" for s in v[\"status\"]))\n", + " if h in (12, 72, 168):\n", + " snaps[h] = v\n", + "lat = np.array(lat)\n", + "print(f\"[모의 스트림] {len(TX)}건 트랜잭션, 168회 갱신(1시간 단위), 오프라인 구간 {sum(len(x) for x in OUT.values())}개\")\n", + "print(f\"화면 모델 갱신 지연: 평균 {lat.mean():.2f}ms, 95% {np.percentile(lat, 95):.2f}ms, 최대 {lat.max():.2f}ms (목표 1초 이내: {'달성' if lat.max() < 1000 else '미달'})\")\n", + "for h, v in snaps.items():\n", + " print(f\" {h:3d}h 시점: 오프라인 {sum(s['state'] == '오프라인' for s in v['status'])}곳, 미동기 {sum(s['pending'] for s in v['status'])}건, 긴급/주의 품목 {sum(g['lvl'] == '긴급' for g in v['gauges'])}/{sum(g['lvl'] == '주의' for g in v['gauges'])}종, 경보 {len(v['alerts'])}건\")\n", + "last = snaps[168]\n", + "print(\"\\n[보급소 상태판 168h] \" + \" | \".join(f\"{s['node'][-1]} {s['state']} 대기{s['pending']} 지연{s['lag_h']}h\" for s in last[\"status\"]))\n", + "print(\"[재고 경보 품목] \" + \", \".join(f\"{g['item']}:{g['stock']}/소진{g['cover']:.1f}일/납기{g['lead']}일({g['lvl']})\" for g in [x for x in last[\"gauges\"] if x[\"lvl\"] != \"정상\"][:4]) + \" ...\")\n", + "print(\"[변동 내역] \" + \"; \".join(f\"{f[1][-1]}-{f[2]} {f[3]} {f[4]} {f[5]}\" for f in last[\"feed\"][-3:]))\n", + "\n", + "# ---- 사용자 조작 시험: 100회씩 응답 시간 ----\n", + "def timeit(fn, k=100):\n", + " r = []\n", + " for _ in range(k):\n", + " t0 = time.perf_counter(); fn(); r.append((time.perf_counter() - t0) * 1000)\n", + " return np.array(r)\n", + "ops = {\"야간 모드 전환\": lambda: (db.set_theme(\"야간\"), db.set_theme(\"주간\")), \"보급소 필터\": lambda: (db.set_node(\"보급소C\"), db.set_node(None)),\n", + " \"경보만 보기\": lambda: (db.toggle_alert(), db.toggle_alert()), \"경보 확인(ack)\": lambda: (db.ack(\"I01\"), db.acked.clear())}\n", + "print(\"\\n[UI 조작 응답 시간 ms] 조작 / 평균 / 95% / 최대\")\n", + "worst = 0\n", + "for k, fn in ops.items():\n", + " r = timeit(fn); worst = max(worst, r.max()); print(f\" {k:12s} {r.mean():6.3f} {np.percentile(r, 95):6.3f} {r.max():6.3f}\")\n", + "print(f\"-> 최대 {worst:.2f}ms (목표 100ms 이내: {'달성' if worst < 100 else '미달'})\")\n", + "\n", + "# ---- 야간 모드 가독성(WCAG 대비 4.5 이상)과 긴급 경보 강조 ----\n", + "print(\"\\n[테마 대비비(글자-배경)]\")\n", + "ok = True\n", + "for nm, th in THEMES.items():\n", + " cs = {k: contrast(th[k], th[\"bg\"]) for k in (\"fg\", \"ok\", \"warn\", \"crit\", \"off\")}; ok &= all(c >= 4.5 for c in cs.values())\n", + " print(f\" {nm}: \" + \", \".join(f\"{k} {c:.1f}\" for k, c in cs.items()))\n", + "print(f\"모든 색상 대비 4.5 이상: {'달성' if ok else '미달'}\")\n", + "\n", + "# ---- 렌더링(4개 패널) ----\n", + "th = THEMES[\"야간\"]; col = lambda k: tuple(c / 255 for c in th[k])\n", + "t0 = time.perf_counter()\n", + "fig, ax = plt.subplots(2, 2, figsize=(10, 6), facecolor=col(\"bg\"))\n", + "for a in ax.ravel():\n", + " a.set_facecolor(col(\"bg\")); a.tick_params(colors=col(\"fg\")); [s.set_color(col(\"off\")) for s in a.spines.values()]\n", + "a = ax[0, 0]; a.set_title(\"Node status (pending tx)\", color=col(\"fg\"))\n", + "a.bar([s[\"node\"][-1] for s in last[\"status\"]], [s[\"pending\"] for s in last[\"status\"]], color=[col(\"ok\") if s[\"state\"] == \"온라인\" else col(\"crit\") for s in last[\"status\"]])\n", + "a = ax[0, 1]; a.set_title(\"Stock vs minimum level\", color=col(\"fg\")); ys = np.arange(len(last[\"gauges\"]))\n", + "a.barh(ys, [g[\"stock\"] for g in last[\"gauges\"]], color=[col({\"정상\": \"ok\", \"주의\": \"warn\", \"긴급\": \"crit\"}[g[\"lvl\"]]) for g in last[\"gauges\"]])\n", + "a.scatter([g[\"min\"] for g in last[\"gauges\"]], ys, color=col(\"fg\"), marker=\"|\", s=80); a.set_yticks(ys); a.set_yticklabels([g[\"item\"] for g in last[\"gauges\"]], fontsize=6)\n", + "a = ax[1, 0]; a.set_title(\"Offline nodes over time\", color=col(\"fg\")); a.step(range(1, 169), off_n, color=col(\"warn\")); a.set_xlabel(\"hour\", color=col(\"fg\"))\n", + "a = ax[1, 1]; a.set_title(\"Alert count by stage\", color=col(\"fg\"))\n", + "a.bar([\"critical\", \"caution\", \"normal\"], [sum(g[\"lvl\"] == k for g in last[\"gauges\"]) for k in (\"긴급\", \"주의\", \"정상\")], color=[col(\"crit\"), col(\"warn\"), col(\"ok\")])\n", + "fig.tight_layout(); fig.canvas.draw(); rt = (time.perf_counter() - t0) * 1000\n", + "print(f\"\\n렌더링 4개 패널 1회 그리기 {rt:.0f}ms (갱신 1초 이내: {'달성' if rt + lat.max() < 1000 else '미달'}, 응답성 100ms는 화면 모델 조작 기준)\")\n", + "print(\"※ 대시보드는 판단을 대신하지 않는다. 경보 확인(ack)과 재고 조치는 군수관이 한다. 화면의 재고는 동기화된 값과 노드별 미동기 변동의 합이다.\")\n", + "plt.show()\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 06 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + "[모의 스트림] 2100건 트랜잭션, 168회 갱신(1시간 단위), 오프라인 구간 4개\n", + "화면 모델 갱신 지연: 평균 0.14ms, 95% 0.20ms, 최대 1.36ms (목표 1초 이내: 달성)\n", + " 12h 시점: 오프라인 1곳, 미동기 6건, 긴급/주의 품목 0/0종, 경보 0건\n", + " 72h 시점: 오프라인 3곳, 미동기 314건, 긴급/주의 품목 7/3종, 경보 9건\n", + " 168h 시점: 오프라인 1곳, 미동기 400건, 긴급/주의 품목 6/3종, 경보 7건\n", + "\n", + "[보급소 상태판 168h] A 온라인 대기0 지연1.6h | B 온라인 대기0 지연0.1h | C 온라인 대기0 지연0.2h | D 온라인 대기0 지연0.2h | E 오프라인 대기400 지연160.4h\n", + "[재고 경보 품목] I02:679/소진2.0일/납기8일(긴급), I03:524/소진3.8일/납기13일(긴급), I04:997/소진2.5일/납기6일(긴급), I05:1503/소진4.9일/납기11일(긴급) ...\n", + "[변동 내역] C-I05 out 15 반영; C-I03 in 42 반영; B-I02 in 87 반영\n", + "\n", + "[UI 조작 응답 시간 ms] 조작 / 평균 / 95% / 최대\n", + " 야간 모드 전환 0.423 0.498 1.075\n", + " 보급소 필터 0.467 0.503 0.627\n", + " 경보만 보기 0.462 0.497 0.640\n", + " 경보 확인(ack) 0.121 0.149 0.218\n", + "-> 최대 1.08ms (목표 100ms 이내: 달성)\n", + "\n", + "[테마 대비비(글자-배경)]\n", + " 주간: fg 16.7, ok 5.5, warn 5.6, crit 6.2, off 6.9\n", + " 야간: fg 14.4, ok 9.5, warn 10.7, crit 6.7, off 7.3\n", + "모든 색상 대비 4.5 이상: 달성\n", + "\n", + "렌더링 4개 패널 1회 그리기 349ms (갱신 1초 이내: 달성, 응답성 100ms는 화면 모델 조작 기준)\n", + "※ 대시보드는 판단을 대신하지 않는다. 경보 확인(ack)과 재고 조치는 군수관이 한다. 화면의 재고는 동기화된 값과 노드별 미동기 변동의 합이다.\n", + "```\n", + "
\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 07. 동기화 충돌 발생 시 군수관 수동 병합(Conflict Resolution) HITL 워크플로우 설계\n", + "**소요시간**: 약 3.0시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "동기화 충돌 발생 시 군수관 수동 병합(Conflict Resolution) HITL 워크플로우를 설계한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **동기화 충돌이 발생했을 때 시스템이 값을 자동 확정하지 않고 군수관이 수동 병합하며, 영향이 큰 건은 지휘관이 승인하는 HITL 워크플로우를 구현해 검토 큐 대기시간과 처리 로깅 성공률 100%를 확인한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **충돌 12건(실사 수량·최소 보유 기준을 두 보급소가 다르게 수정)을 시스템 제안 - 보급담당 상신 - 군수관 검토와 수동 병합 - 확정(차이 25% 이상이면 지휘관 승인) 순서로 처리하고, 병합 전후 값과 사유·근거를 기록하며 반려 사유를 필수로 수행하고 검증하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `src/hitl/dat10_approval_workflow.py 및 docs/dat10_hitl_guide.md` 형태의 결과물을 완성한다.\n", + "\n", + "### 🔍 평가 및 검증 기준 (PASS 조건)\n", + "#### 미션 판정\n", + "\n", + "**평가 구분:** 필수\n", + "\n", + "**핵심 평가영역:** 지휘 책임 분계선 명확화 및 성급한 자동 단정 방지 체계 부합성\n", + "\n", + "#### 미션 PASS 조건\n", + "\n", + "- 정량 기준: 인간 검토 큐(Queue) 대기시간 및 승인/반려 처리 로깅 성공률 100%\n", + "\n", + "\n", + "- 진행 기준: 승인/반려 인터페이스 동작 및 감사 로그(Audit Log) 생성 검증\n", + "\n", + "\n", + "- 검증 방법: 모의 조작자 승인/반려 시뮬레이션 및 데이터 무결성 검증\n", + "\n", + "\n", + "#### 채점자 판정 메모\n", + "\n", + "> 단순 코드 실행 여부뿐만 아니라 출력된 수치와 근거 자료가 군 실무 요구사항에 부합하는지 면밀히 확인한다.\n", + "\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "\"\"\"동기화 충돌 건을 시스템 제안 -> 보급담당 상신 -> 군수관 수동 병합 -> (영향이 큰 건은 지휘관 승인) 순서의 HITL 워크플로우로 처리하되, 자동 확정 금지·병합 전후 값과 사유 기록·반려 사유 필수화·권한 우회 차단·해시 체인 감사 로그·지휘관 피드백 입력 검증을 구현하고 위험도 우선 큐(대기 노화 가산)와 선입선출의 대기시간을 비교한다.\"\"\"\n", + "import hashlib\n", + "import json\n", + "import numpy as np\n", + "import pandas as pd\n", + "import matplotlib.pyplot as plt\n", + "\n", + "# ---------------------------------------------------------------------------\n", + "# [교육용가상자료] 가상 야전 보급소·업체·품목 자료 생성기 (실제 부대·업체·품목·재고 정보 없음)\n", + "# 가상 보급소 5곳이 품목 12종의 입고·불출·조정 트랜잭션을 오프라인으로 기록하고 나중에 동기화한다.\n", + "# 공급망 재난 징후(업체 납기 지연, 품목 재고 급감)는 동기화된 자료를 마이닝해 식별한다.\n", + "# ---------------------------------------------------------------------------\n", + "NODES = [\"보급소A\", \"보급소B\", \"보급소C\", \"보급소D\", \"보급소E\"]\n", + "SUPPLIERS = [\"가상업체1\", \"가상업체2\", \"가상업체3\", \"가상업체4\", \"가상업체5\", \"가상업체6\"]\n", + "CATS = [\"일반보급\", \"정비부품\", \"의무소모\"]\n", + "ITEMS = [{\"item\": f\"I{i + 1:02d}\", \"cat\": CATS[i % 3], \"supplier\": SUPPLIERS[i % 6], \"lead_days\": 3 + (i * 5) % 12, \"use\": 4 + (i * 7) % 11, \"min_level\": 30 + 10 * (i % 5)} for i in range(12)]\n", + "ITEM_IDS = [x[\"item\"] for x in ITEMS]\n", + "CTXS = [\"평시\", \"야간\", \"통신불량\"]\n", + "\n", + "\n", + "def make_txns(seed=7, days=7, per_node_day=60, nodes=None):\n", + " \"\"\"노드별 오프라인 트랜잭션 목록. id는 노드-일련번호(멱등 키), t는 시각(시간 단위), ctx는 입력 상황.\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " out = []\n", + " for n in (nodes or NODES):\n", + " seq = 0\n", + " for k in range(days * per_node_day):\n", + " seq += 1\n", + " it = ITEMS[int(rng.integers(len(ITEMS)))]\n", + " t = float(rng.uniform(0, 24 * days))\n", + " ctx = \"야간\" if (t % 24 >= 20 or t % 24 < 5) else \"평시\"\n", + " if rng.random() < 0.12:\n", + " ctx = \"통신불량\"\n", + " r = rng.random()\n", + " if r < 0.22:\n", + " op, q = \"in\", int(it[\"use\"] * rng.uniform(4, 9))\n", + " elif r < 0.95:\n", + " op, q = \"out\", int(max(1, rng.normal(it[\"use\"] * 1.5, 3)))\n", + " else:\n", + " op, q = \"adjust\", int(rng.integers(-5, 6))\n", + " out.append({\"id\": f\"{n}-{seq:05d}\", \"node\": n, \"item\": it[\"item\"], \"op\": op, \"qty\": q, \"t\": round(t, 2), \"ctx\": ctx, \"supplier\": it[\"supplier\"]})\n", + " return out\n", + "\n", + "\n", + "def stock_of(txns, base=500):\n", + " \"\"\"품목별 재고 = 기초 재고 + 입고 - 불출 + 조정 (id 중복은 한 번만 반영).\"\"\"\n", + " st, seen = {i: base for i in ITEM_IDS}, set()\n", + " for x in txns:\n", + " if x[\"id\"] in seen:\n", + " continue\n", + " seen.add(x[\"id\"])\n", + " st[x[\"item\"]] += x[\"qty\"] if x[\"op\"] in (\"in\", \"adjust\") else -x[\"qty\"]\n", + " return st\n", + "\n", + "PERM = {\"시스템\": {\"propose\"}, \"보급담당\": {\"submit\", \"redraft\", \"view\"}, \"군수관\": {\"review\", \"merge\", \"confirm\", \"reject\", \"view\"}, \"지휘관\": {\"approve\", \"reject\", \"feedback\", \"view\"}, \"감사관\": {\"view\", \"audit\"}}\n", + "USERS = {\"sys\": \"시스템\", \"kim\": \"보급담당\", \"lee\": \"군수관\", \"park\": \"지휘관\", \"jung\": \"감사관\"}\n", + "IMPACT_CUT = 0.25 # 두 값의 차이가 큰 쪽 값의 25% 이상이면 지휘관 승인 필요\n", + "MAX_REDRAFT = 2\n", + "FB_CATS = {\"우선순위\", \"자원\", \"절차\", \"기타\"} # 지휘관 피드백 입력 폼 분류\n", + "SLA_H = 4.0 # 군수관 검토 대기 허용 시간(h)\n", + "\n", + "\n", + "class Chain:\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", + "class Workflow:\n", + " def __init__(self):\n", + " self.audit, self.cases, self.calls = Chain(), {}, 0\n", + "\n", + " def _deny(self, who, case, action, why):\n", + " self.audit.add(event=\"DENIED\", who=who, case=case, action=action, reason=why); return False, why\n", + "\n", + " def act(self, who, case, action, reason=\"\", evidence=\"\", value=None, cat=None, cf=None):\n", + " self.calls += 1\n", + " role = USERS.get(who)\n", + " if role is None:\n", + " return self._deny(who, case, action, \"미등록 사용자\")\n", + " if action in (\"delete_log\", \"edit_log\"):\n", + " return self._deny(who, case, action, \"감사 로그 삭제·수정 권한은 누구에게도 없음\")\n", + " if action not in PERM[role]:\n", + " return self._deny(who, case, action, f\"권한 없음({role} 역할에 {action} 권한 없음)\")\n", + " if action == \"propose\":\n", + " a, b = cf\n", + " self.cases[case] = {\"state\": \"제안\", \"submitter\": None, \"version\": 1, \"a\": a, \"b\": b, \"merged\": None, \"high\": abs(a - b) / max(abs(a), abs(b), 1) >= IMPACT_CUT, \"feedback\": []}\n", + " self.audit.add(event=\"PROPOSE\", who=who, case=case, a=a, b=b, reason=reason); return True, \"제안 등록(자동 확정 없음)\"\n", + " c = self.cases.get(case)\n", + " if c is None:\n", + " return self._deny(who, case, action, \"존재하지 않는 건\")\n", + " if c[\"state\"] in (\"확정\", \"종결(반려)\"):\n", + " return self._deny(who, case, action, f\"이미 종결된 건({c['state']})\")\n", + " if not reason.strip() or not evidence.strip():\n", + " return self._deny(who, case, action, \"사유·근거 입력 필수(반려·병합 사유 포함)\")\n", + " if action == \"feedback\":\n", + " if cat not in FB_CATS:\n", + " return self._deny(who, case, action, f\"피드백 분류 오류({cat}), 허용 {sorted(FB_CATS)}\")\n", + " c[\"feedback\"].append((cat, reason))\n", + " self.audit.add(event=\"FEEDBACK\", who=who, role=role, case=case, cat=cat, reason=reason, evidence=evidence); return True, \"지휘관 피드백 기록\"\n", + " if action == \"merge\":\n", + " if c[\"state\"] != \"군수관검토\":\n", + " return self._deny(who, case, action, f\"병합은 군수관 검토 단계에서만 가능(현재 {c['state']})\")\n", + " lo, hi = min(c[\"a\"], c[\"b\"]), max(c[\"a\"], c[\"b\"])\n", + " if value is None or not (lo - 0.2 * abs(lo) <= value <= hi + 0.2 * abs(hi)):\n", + " return self._deny(who, case, action, f\"병합 값 범위 초과(허용 {lo - 0.2 * abs(lo):.0f}~{hi + 0.2 * abs(hi):.0f}, 입력 {value})\")\n", + " before, c[\"merged\"] = c[\"merged\"], value\n", + " self.audit.add(event=\"MERGE\", who=who, role=role, case=case, before=before, after=value, a=c[\"a\"], b=c[\"b\"], reason=reason, evidence=evidence); return True, f\"병합 값 {before} -> {value}\"\n", + " if action == \"reject\":\n", + " if c[\"state\"] in (\"제안\", \"반려\"):\n", + " return self._deny(who, case, action, f\"반려할 수 없는 단계({c['state']})\")\n", + " c[\"state\"] = \"종결(반려)\" if c[\"version\"] > MAX_REDRAFT else \"반려\"\n", + " self.audit.add(event=\"REJECTED\", who=who, role=role, case=case, version=c[\"version\"], reason=reason, evidence=evidence); return True, c[\"state\"]\n", + " if action == \"redraft\":\n", + " if c[\"state\"] != \"반려\":\n", + " return self._deny(who, case, action, f\"재기안은 반려된 건만 가능(현재 {c['state']})\")\n", + " if who != c[\"submitter\"]:\n", + " return self._deny(who, case, action, \"재기안은 최초 상신자만 가능\")\n", + " c[\"state\"], c[\"version\"] = \"상신\", c[\"version\"] + 1\n", + " self.audit.add(event=\"REDRAFT\", who=who, role=role, case=case, version=c[\"version\"], reason=reason, evidence=evidence); return True, \"재기안 상신\"\n", + " if c[\"state\"] == \"반려\":\n", + " return self._deny(who, case, action, \"반려된 건은 재기안 후에만 진행 가능\")\n", + " need = {\"제안\": \"submit\", \"상신\": \"review\", \"군수관검토\": \"approve\" if c[\"high\"] else \"confirm\"}[c[\"state\"]]\n", + " if action != need:\n", + " return self._deny(who, case, action, f\"단계 위반(현재 {c['state']}{'·영향 큼' if c['high'] else ''}, 필요 행위 {need})\")\n", + " if action == \"confirm\" and c[\"merged\"] is None or action == \"approve\" and c[\"merged\"] is None:\n", + " return self._deny(who, case, action, \"군수관 병합 값 없음(자동 확정 금지)\")\n", + " if action == \"submit\":\n", + " c[\"submitter\"] = who\n", + " c[\"state\"] = {\"submit\": \"상신\", \"review\": \"군수관검토\", \"confirm\": \"확정\", \"approve\": \"확정\"}[action]\n", + " self.audit.add(event=action.upper(), who=who, role=role, case=case, version=c[\"version\"], to=c[\"state\"], merged=c[\"merged\"], reason=reason, evidence=evidence); return True, c[\"state\"]\n", + "\n", + "\n", + "# ---- 충돌 건 12개: 같은 품목의 재고 실사 수량·최소 보유 기준을 두 보급소가 서로 다르게 수정 ----\n", + "rng = np.random.default_rng(5)\n", + "cands = []\n", + "for k in range(12):\n", + " it = ITEMS[k]; field = \"실사 수량\" if k % 3 else \"최소 보유 기준\"\n", + " base = 500 if field == \"실사 수량\" else it[\"min_level\"]\n", + " a = int(base * rng.uniform(0.9, 1.1)); b = int(a * rng.choice([1.05, 1.1, 1.4, 1.8, 0.5]))\n", + " cover = rng.uniform(1.5, 14) # 소진 예상 일수 (납기 대비 부족하면 공급망 재난 징후)\n", + " risk = 100 * (0.5 * min(abs(a - b) / max(a, b) / 0.5, 1) + 0.5 * max(0, 1 - cover / it[\"lead_days\"]))\n", + " cands.append({\"id\": f\"C{k + 1:02d}\", \"item\": it[\"item\"], \"field\": field, \"a\": a, \"b\": b, \"cover\": cover, \"lead\": it[\"lead_days\"], \"risk\": risk})\n", + "cd = pd.DataFrame(cands)\n", + "print(f\"[충돌 건] {len(cd)}건, 영향 큼(차이 {IMPACT_CUT:.0%} 이상) {sum(abs(r.a - r.b) / max(r.a, r.b) >= IMPACT_CUT for r in cd.itertuples())}건, 위험도 평균 {cd['risk'].mean():.0f}\")\n", + "\n", + "# ---- 큐 시뮬레이션: 군수관 1명(건당 2시간), 건이 1시간 간격으로 도착 ----\n", + "def run_queue(policy, risks, service=2.0, arr_gap=1.0):\n", + " n = len(risks); arr = np.arange(n) * arr_gap; done, t, waits = set(), 0.0, {}\n", + " while len(done) < n:\n", + " ready = [i for i in range(n) if i not in done and arr[i] <= t]\n", + " if not ready:\n", + " t = min(arr[i] for i in range(n) if i not in done); continue\n", + " pick = ready[0] if policy == \"fifo\" else max(ready, key=lambda i: risks[i] + 5.0 * (t - arr[i])) # 노화 가산: 1시간 대기마다 5점\n", + " waits[pick] = t - arr[pick]; t += service; done.add(pick)\n", + " return waits\n", + "rs = list(rng.permutation(cd[\"risk\"].to_numpy()))\n", + "wf_, wp_ = run_queue(\"fifo\", rs), run_queue(\"prio\", rs)\n", + "hi = [i for i, r in enumerate(rs) if r >= 50]\n", + "print(f\"\\n[검토 큐 대기시간(h)] 건 {len(rs)}개, 위험 50 이상 {len(hi)}건, 허용 {SLA_H:.0f}h\")\n", + "for nm, w in ((\"선입선출\", wf_), (\"위험도 우선(노화 가산)\", wp_)):\n", + " print(f\" {nm:14s}: 전체 평균 {np.mean(list(w.values())):.1f}h, 고위험 평균 {np.mean([w[i] for i in hi]):.1f}h, 최대 {max(w.values()):.1f}h, 허용 초과 {sum(v > SLA_H for v in w.values())}건\")\n", + "\n", + "# ---- 워크플로우 모의 ----\n", + "wf = Workflow(); res = []\n", + "for k, r in cd.iterrows():\n", + " case = r[\"id\"]\n", + " wf.act(\"sys\", case, \"propose\", reason=f\"{r['item']} {r['field']} 충돌: A {r['a']} / B {r['b']}\", cf=(int(r[\"a\"]), int(r[\"b\"])))\n", + " wf.act(\"kim\", case, \"submit\", \"수동 병합 검토 요청\", f\"소진 예상 {r['cover']:.1f}일, 납기 {r['lead']}일\")\n", + " for _ in range(12):\n", + " c = wf.cases[case]; s = c[\"state\"]\n", + " if s in (\"확정\", \"종결(반려)\"):\n", + " break\n", + " if s == \"상신\":\n", + " if k in (2, 11) and c[\"version\"] <= (1 if k == 2 else 9): # 2번 건은 1회 반려, 11번 건은 보완 없이 계속 반려(재기안 한도 시연)\n", + " wf.act(\"lee\", case, \"reject\", \"근거 사진·실사표 누락, 보완 요청\", f\"충돌 두 값 {r['a']}/{r['b']}\")\n", + " else:\n", + " wf.act(\"lee\", case, \"review\", \"두 기록과 실사 근거 확인\", f\"소진 예상 {r['cover']:.1f}일\")\n", + " elif s == \"군수관검토\":\n", + " if c[\"merged\"] is None:\n", + " val = int((c[\"a\"] + c[\"b\"]) / 2) if r[\"field\"] == \"실사 수량\" else max(c[\"a\"], c[\"b\"]) # 실사는 평균 후보, 기준은 높은 쪽(안전 우선)\n", + " wf.act(\"lee\", case, \"merge\", \"수동 병합: 현장 실사·납기 고려해 값 결정\", f\"A {c['a']} / B {c['b']}\", value=val)\n", + " if c[\"high\"]:\n", + " wf.act(\"park\", case, \"feedback\", \"영향이 큰 건이므로 선별 확인 후 결정\", \"우선순위\", cat=\"우선순위\")\n", + " wf.act(\"park\", case, \"approve\", \"승인: 병합 값 확정\", f\"차이 {abs(c['a'] - c['b']) / max(c['a'], c['b']):.0%}, 군수관 병합 검토 완료\")\n", + " else:\n", + " wf.act(\"lee\", case, \"confirm\", \"확정: 병합 값 반영\", \"차이가 작아 군수관 확정 범위\")\n", + " elif s == \"반려\":\n", + " wf.act(\"kim\", case, \"redraft\", \"반려 사유 반영해 재기안\", f\"근거 보완 {c['version']}차\")\n", + " c = wf.cases[case]; res.append((case, r[\"field\"], c[\"a\"], c[\"b\"], \"-\" if c[\"merged\"] is None else str(c[\"merged\"]), \"지휘관\" if c[\"high\"] else \"군수관\", c[\"state\"], c[\"version\"]))\n", + "print(\"\\n[처리 결과] (모의 결재자 기준, 실제 판단은 사람이 한다)\")\n", + "print(pd.DataFrame(res, columns=[\"건\", \"항목\", \"A값\", \"B값\", \"병합값\", \"확정권자\", \"상태\", \"버전\"]).head(12).to_string(index=False))\n", + "print(f\"군수관 수동 병합 {sum(e['event'] == 'MERGE' for e in wf.audit.log)}건(전후 값·사유 기록), 반려 {sum(e['event'] == 'REJECTED' for e in wf.audit.log)}건(사유 필수), 지휘관 피드백 {sum(e['event'] == 'FEEDBACK' for e in wf.audit.log)}건\")\n", + "\n", + "# ---- 권한 우회·절차 위반 시도 ----\n", + "def mk(cid, stage=\"submit\", ab=(100, 160)):\n", + " wf.act(\"sys\", cid, \"propose\", reason=\"시험\", cf=ab)\n", + " if stage in (\"submit\", \"review\"):\n", + " wf.act(\"kim\", cid, \"submit\", \"시험 상신\", \"근거\")\n", + " if stage == \"review\":\n", + " wf.act(\"lee\", cid, \"review\", \"검토\", \"이상 없음\")\n", + " return cid\n", + "done_case = next(c for c, v in wf.cases.items() if v[\"state\"] == \"확정\")\n", + "att = [(\"시스템 자동 병합\", lambda: wf.act(\"sys\", mk(\"T01\", \"review\"), \"merge\", \"자동\", \"자동\", value=130)), (\"보급담당이 병합 시도\", lambda: wf.act(\"kim\", mk(\"T02\", \"review\"), \"merge\", \"임의\", \"근거\", value=130)),\n", + " (\"병합 없이 확정\", lambda: wf.act(\"lee\", mk(\"T03\", \"review\", (100, 105)), \"confirm\", \"바로 확정\", \"근거\")), (\"미등록 사용자\", lambda: wf.act(\"guest\", mk(\"T04\"), \"review\", \"열람\", \"근거\")),\n", + " (\"사유 없는 검토\", lambda: wf.act(\"lee\", mk(\"T05\"), \"review\", \"\", \"\")), (\"감사 로그 수정\", lambda: wf.act(\"kim\", mk(\"T06\"), \"edit_log\", \"정리\", \"근거\")),\n", + " (\"감사관이 확정 시도\", lambda: wf.act(\"jung\", mk(\"T07\", \"review\"), \"confirm\", \"확정\", \"근거\")), (\"존재하지 않는 건\", lambda: wf.act(\"park\", \"X99\", \"approve\", \"승인\", \"근거\")),\n", + " (\"종결된 건 재승인\", lambda: wf.act(\"park\", done_case, \"approve\", \"재승인\", \"근거\")), (\"병합 값 범위 초과(100/160 -> 500)\", lambda: wf.act(\"lee\", mk(\"T08\", \"review\"), \"merge\", \"대폭 변경\", \"근거\", value=500)),\n", + " (\"영향 큰 건을 군수관이 확정\", lambda: (wf.act(\"lee\", \"T09\", \"merge\", \"병합\", \"근거\", value=130) if mk(\"T09\", \"review\") else None, wf.act(\"lee\", \"T09\", \"confirm\", \"확정\", \"근거\"))[-1]),\n", + " (\"지휘관이 검토 건너뛰기\", lambda: wf.act(\"park\", mk(\"T10\"), \"approve\", \"바로 승인\", \"근거\")), (\"피드백 분류 오류\", lambda: wf.act(\"park\", mk(\"T11\", \"review\"), \"feedback\", \"의견\", \"근거\", cat=\"잡담\")),\n", + " (\"상신 전 병합\", lambda: wf.act(\"lee\", mk(\"T12\", \"submit\"), \"merge\", \"병합\", \"근거\", value=130))]\n", + "print(\"\\n[권한 우회·절차 위반 시도]\")\n", + "blocked = 0\n", + "for n, fn in att:\n", + " ok, why = fn(); blocked += (not ok); print(f\" {n:26s}-> {'차단' if not ok else '통과(결함)'}: {why}\")\n", + "print(f\"차단 {blocked}/{len(att)} ({blocked / len(att):.0%})\")\n", + "\n", + "# ---- 로깅 성공률과 이력 무결성 ----\n", + "n_log = len(wf.audit.log); bad = wf.audit.verify()\n", + "print(f\"\\n처리 요청 {wf.calls}건 -> 감사 로그 {n_log}건(확정·반려·병합·거부 모두 기록), 로깅 성공률 {n_log / wf.calls:.0%} (목표 100%: {'달성' if n_log == wf.calls else '미달'})\")\n", + "print(f\"해시 체인 검증: {'정상' if bad is None else f'seq {bad} 불일치'} -> 이력 무결성 {'100%' if bad is None else '훼손'}\")\n", + "saved = json.dumps(wf.audit.log[12], ensure_ascii=False); wf.audit.log[12][\"reason\"] = \"사후 수정\"; v = wf.audit.verify()\n", + "print(f\"12번째 기록 임의 수정 후 검증: {'위변조 탐지(seq ' + str(v) + ')' if v is not None else '탐지 실패'}\"); wf.audit.log[12] = json.loads(saved)\n", + "print(\"※ 충돌 병합은 군수관이 하고, 영향이 큰 건은 지휘관이 승인한다. 시스템은 두 값과 근거만 제시하며 자동 단정하지 않는다. 결재선은 소속 기관 규정을 따른다.\")\n", + "\n", + "plt.figure(figsize=(6, 3.5)); plt.bar([\"FIFO\", \"priority\"], [np.mean([wf_[i] for i in hi]), np.mean([wp_[i] for i in hi])], color=[\"tab:gray\", \"tab:blue\"])\n", + "plt.ylabel(\"mean wait of high-risk conflicts (h)\"); plt.title(\"Review queue wait: FIFO vs risk priority\"); plt.tight_layout(); plt.show()\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 07 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + "[충돌 건] 12건, 영향 큼(차이 25% 이상) 5건, 위험도 평균 37\n", + "\n", + "[검토 큐 대기시간(h)] 건 12개, 위험 50 이상 6건, 허용 4h\n", + " 선입선출 : 전체 평균 5.5h, 고위험 평균 5.5h, 최대 11.0h, 허용 초과 7건\n", + " 위험도 우선(노화 가산) : 전체 평균 5.5h, 고위험 평균 0.8h, 최대 13.0h, 허용 초과 5건\n", + "\n", + "[처리 결과] (모의 결재자 기준, 실제 판단은 사람이 한다)\n", + " 건 항목 A값 B값 병합값 확정권자 상태 버전\n", + "C01 최소 보유 기준 31 32 32 군수관 확정 1\n", + "C02 실사 수량 478 239 358 지휘관 확정 1\n", + "C03 실사 수량 488 683 585 지휘관 확정 2\n", + "C04 최소 보유 기준 54 75 75 지휘관 확정 1\n", + "C05 실사 수량 515 927 721 지휘관 확정 1\n", + "C06 실사 수량 547 601 574 군수관 확정 1\n", + "C07 최소 보유 기준 42 44 44 군수관 확정 1\n", + "C08 실사 수량 517 568 542 군수관 확정 1\n", + "C09 실사 수량 505 252 378 지휘관 확정 1\n", + "C10 최소 보유 기준 63 69 69 군수관 확정 1\n", + "C11 실사 수량 537 590 563 군수관 확정 1\n", + "C12 실사 수량 537 590 - 군수관 종결(반려) 3\n", + "군수관 수동 병합 11건(전후 값·사유 기록), 반려 4건(사유 필수), 지휘관 피드백 5건\n", + "\n", + "[권한 우회·절차 위반 시도]\n", + " 시스템 자동 병합 -> 차단: 권한 없음(시스템 역할에 merge 권한 없음)\n", + " 보급담당이 병합 시도 -> 차단: 권한 없음(보급담당 역할에 merge 권한 없음)\n", + " 병합 없이 확정 -> 차단: 군수관 병합 값 없음(자동 확정 금지)\n", + " 미등록 사용자 -> 차단: 미등록 사용자\n", + " 사유 없는 검토 -> 차단: 사유·근거 입력 필수(반려·병합 사유 포함)\n", + " 감사 로그 수정 -> 차단: 감사 로그 삭제·수정 권한은 누구에게도 없음\n", + " 감사관이 확정 시도 -> 차단: 권한 없음(감사관 역할에 confirm 권한 없음)\n", + " 존재하지 않는 건 -> 차단: 존재하지 않는 건\n", + " 종결된 건 재승인 -> 차단: 이미 종결된 건(확정)\n", + " 병합 값 범위 초과(100/160 -> 500)-> 차단: 병합 값 범위 초과(허용 80~192, 입력 500)\n", + " 영향 큰 건을 군수관이 확정 -> 차단: 단계 위반(현재 군수관검토·영향 큼, 필요 행위 approve)\n", + " 지휘관이 검토 건너뛰기 -> 차단: 단계 위반(현재 상신·영향 큼, 필요 행위 review)\n", + " 피드백 분류 오류 -> 차단: 피드백 분류 오류(잡담), 허용 ['기타', '우선순위', '자원', '절차']\n", + " 상신 전 병합 -> 차단: 병합은 군수관 검토 단계에서만 가능(현재 상신)\n", + "차단 14/14 (100%)\n", + "\n", + "처리 요청 115건 -> 감사 로그 115건(확정·반려·병합·거부 모두 기록), 로깅 성공률 100% (목표 100%: 달성)\n", + "... (이하 생략)\n", + "```\n", + "
\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 08. 7일간 통신 단절 후 대량 재고 트랜잭션 일괄 동기화 스트레스 테스트\n", + "**소요시간**: 약 3.5시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "7일간 통신 단절 후 대량 재고 트랜잭션 일괄 동기화 스트레스 테스트를 수행한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **7일간 통신이 끊겼다가 복구된 직후 보급소 5곳의 누적 트랜잭션이 한꺼번에 서버로 몰리는 상황을 기준 100%·200%·500%로 시험해 시스템 다운 0건·데이터 유실 0%와 기능 축소 동작을 확인한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **트랜잭션 2,100건을 기준으로 4,200건·10,500건까지 늘려 배치 50건 단위로 큐와 작업자 스레드 4개가 처리하게 하고, 같은 배치 중복 재전송(5%)·서명 후 수량 변조(1%)·작업자 오류(37배치마다) 주입하여 처리 지연 및 복원력을 시험하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `tests/dat10_stress_test.py 및 스트레스 테스트 분석 보고서` 형태의 결과물을 완성한다.\n", + "\n", + "### 🔍 평가 및 검증 기준 (PASS 조건)\n", + "#### 미션 판정\n", + "\n", + "**평가 구분:** 필수\n", + "\n", + "**핵심 평가영역:** 극단 상황에서의 안전 실패(Fail-safe) 및 기능 축소 작동 적절성\n", + "\n", + "#### 미션 PASS 조건\n", + "\n", + "- 정량 기준: 동시 트래픽 500% 증가 시 시스템 다운 0건, 데이터 유실률 0%\n", + "\n", + "\n", + "- 진행 기준: 대량 시뮬레이션 부하 주입 테스트 통과 및 병목 구간 프로파일링 완료\n", + "\n", + "\n", + "- 검증 방법: 부하 테스트 도구를 통한 처리량(Throughput) 및 메모리 누수 검증\n", + "\n", + "\n", + "#### 채점자 판정 메모\n", + "\n", + "> 단순 코드 실행 여부뿐만 아니라 출력된 수치와 근거 자료가 군 실무 요구사항에 부합하는지 면밀히 확인한다.\n", + "\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "\"\"\"7일간 통신이 끊긴 보급소 5곳의 누적 트랜잭션을 복구 직후 서버로 한꺼번에 올리는 상황을 기준 100%·200%·500%로 늘려 큐 기반 작업자 스레드로 처리하고, 중복 재전송·서명 불일치·작업자 오류를 주입해 다운 0건·유실 0건·기능 축소(표본 서명 점검 후 사후 검증)·병목(목록 탐색 중복 확인을 집합으로 교체)·메모리 증가를 측정한다.\"\"\"\n", + "import hashlib\n", + "import hmac\n", + "import queue\n", + "import threading\n", + "import time\n", + "import tracemalloc\n", + "import numpy as np\n", + "import pandas as pd\n", + "import matplotlib.pyplot as plt\n", + "\n", + "# ---------------------------------------------------------------------------\n", + "# [교육용가상자료] 가상 야전 보급소·업체·품목 자료 생성기 (실제 부대·업체·품목·재고 정보 없음)\n", + "# 가상 보급소 5곳이 품목 12종의 입고·불출·조정 트랜잭션을 오프라인으로 기록하고 나중에 동기화한다.\n", + "# 공급망 재난 징후(업체 납기 지연, 품목 재고 급감)는 동기화된 자료를 마이닝해 식별한다.\n", + "# ---------------------------------------------------------------------------\n", + "NODES = [\"보급소A\", \"보급소B\", \"보급소C\", \"보급소D\", \"보급소E\"]\n", + "SUPPLIERS = [\"가상업체1\", \"가상업체2\", \"가상업체3\", \"가상업체4\", \"가상업체5\", \"가상업체6\"]\n", + "CATS = [\"일반보급\", \"정비부품\", \"의무소모\"]\n", + "ITEMS = [{\"item\": f\"I{i + 1:02d}\", \"cat\": CATS[i % 3], \"supplier\": SUPPLIERS[i % 6], \"lead_days\": 3 + (i * 5) % 12, \"use\": 4 + (i * 7) % 11, \"min_level\": 30 + 10 * (i % 5)} for i in range(12)]\n", + "ITEM_IDS = [x[\"item\"] for x in ITEMS]\n", + "CTXS = [\"평시\", \"야간\", \"통신불량\"]\n", + "\n", + "\n", + "def make_txns(seed=7, days=7, per_node_day=60, nodes=None):\n", + " \"\"\"노드별 오프라인 트랜잭션 목록. id는 노드-일련번호(멱등 키), t는 시각(시간 단위), ctx는 입력 상황.\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " out = []\n", + " for n in (nodes or NODES):\n", + " seq = 0\n", + " for k in range(days * per_node_day):\n", + " seq += 1\n", + " it = ITEMS[int(rng.integers(len(ITEMS)))]\n", + " t = float(rng.uniform(0, 24 * days))\n", + " ctx = \"야간\" if (t % 24 >= 20 or t % 24 < 5) else \"평시\"\n", + " if rng.random() < 0.12:\n", + " ctx = \"통신불량\"\n", + " r = rng.random()\n", + " if r < 0.22:\n", + " op, q = \"in\", int(it[\"use\"] * rng.uniform(4, 9))\n", + " elif r < 0.95:\n", + " op, q = \"out\", int(max(1, rng.normal(it[\"use\"] * 1.5, 3)))\n", + " else:\n", + " op, q = \"adjust\", int(rng.integers(-5, 6))\n", + " out.append({\"id\": f\"{n}-{seq:05d}\", \"node\": n, \"item\": it[\"item\"], \"op\": op, \"qty\": q, \"t\": round(t, 2), \"ctx\": ctx, \"supplier\": it[\"supplier\"]})\n", + " return out\n", + "\n", + "\n", + "def stock_of(txns, base=500):\n", + " \"\"\"품목별 재고 = 기초 재고 + 입고 - 불출 + 조정 (id 중복은 한 번만 반영).\"\"\"\n", + " st, seen = {i: base for i in ITEM_IDS}, set()\n", + " for x in txns:\n", + " if x[\"id\"] in seen:\n", + " continue\n", + " seen.add(x[\"id\"])\n", + " st[x[\"item\"]] += x[\"qty\"] if x[\"op\"] in (\"in\", \"adjust\") else -x[\"qty\"]\n", + " return st\n", + "\n", + "KEYS = {n: hashlib.sha256(f\"demo-key-{n}\".encode()).digest() for n in NODES} # [교육용가상자료] 데모 키\n", + "BATCH, QMAX, HIGH_WATER, WORKERS = 50, 12, 8, 4\n", + "\n", + "\n", + "def sig_of(x):\n", + " return hmac.new(KEYS[x[\"node\"]], f\"{x['id']}|{x['item']}|{x['op']}|{x['qty']}|{x['t']}\".encode(), \"sha256\").hexdigest()[:16]\n", + "\n", + "\n", + "class Server:\n", + " def __init__(self, fast):\n", + " self.fast, self.lock = fast, threading.Lock()\n", + " self.ids = set() if fast else [] # 최적화 전: 목록 선형 탐색으로 중복 확인\n", + " self.stock = {i: 500 for i in ITEM_IDS}; self.quar, self.dups, self.unver, self.lite_batches, self.applied = [], 0, [], 0, 0\n", + "\n", + " def ingest(self, batch, lite):\n", + " ok_rows, unv = [], set()\n", + " for k, x in enumerate(batch):\n", + " if lite and k % 10: # 기능 축소: 표본 10%만 서명 점검, 나머지는 사후 검증 대상\n", + " good = True; unv.add(x[\"id\"])\n", + " else:\n", + " good = hmac.compare_digest(sig_of(x), x[\"sig\"])\n", + " if not good:\n", + " with self.lock:\n", + " self.quar.append(x[\"id\"])\n", + " continue\n", + " ok_rows.append(x)\n", + " with self.lock:\n", + " self.lite_batches += lite\n", + " for x in ok_rows:\n", + " dup = x[\"id\"] in self.ids\n", + " if dup:\n", + " self.dups += 1; continue\n", + " self.ids.add(x[\"id\"]) if self.fast else self.ids.append(x[\"id\"])\n", + " self.stock[x[\"item\"]] += x[\"qty\"] if x[\"op\"] in (\"in\", \"adjust\") else -x[\"qty\"]; self.applied += 1\n", + " if x[\"id\"] in unv:\n", + " self.unver.append(x) # 실제로 반영된 미검증 레코드만 사후 검증 대상\n", + "\n", + " def post_verify(self):\n", + " \"\"\"사후 검증: 표본 점검만 거친 레코드를 전수 점검하고, 위조로 확인되면 반영분을 되돌린다.\"\"\"\n", + " rolled = 0\n", + " for x in self.unver:\n", + " if not hmac.compare_digest(sig_of(x), x[\"sig\"]):\n", + " self.stock[x[\"item\"]] -= x[\"qty\"] if x[\"op\"] in (\"in\", \"adjust\") else -x[\"qty\"]; self.quar.append(x[\"id\"]); self.applied -= 1; rolled += 1\n", + " return rolled\n", + "\n", + "\n", + "def run(mult, fast, seed=3):\n", + " rng = np.random.default_rng(seed)\n", + " tx = make_txns(7, 7, per_node_day=60 * mult)\n", + " for x in tx:\n", + " x[\"sig\"] = sig_of(x)\n", + " bad = set(rng.choice(len(tx), int(len(tx) * 0.01), replace=False).tolist())\n", + " for i in bad:\n", + " tx[i][\"qty\"] += 13 # 서명 후 수량 변조(1%)\n", + " valid = [x for i, x in enumerate(tx) if i not in bad]\n", + " truth = stock_of(valid)\n", + " srv, q, lat, depth, dead, crash = Server(fast), queue.Queue(maxsize=QMAX), [], [], [], [0]\n", + " lk = threading.Lock(); cnt = {\"b\": 0}\n", + "\n", + " def worker():\n", + " while True:\n", + " item = q.get()\n", + " if item is None:\n", + " return\n", + " batch, t_in, retry = item\n", + " depth.append(q.qsize())\n", + " try:\n", + " with lk:\n", + " cnt[\"b\"] += 1; inj = (cnt[\"b\"] % 37 == 0) and not retry # 37번째 배치마다 작업자 오류 주입(재시도 1회)\n", + " if inj:\n", + " raise RuntimeError(\"worker failure(injected)\")\n", + " srv.ingest(batch, lite=fast and q.qsize() >= HIGH_WATER)\n", + " except RuntimeError:\n", + " dead.append((batch, t_in)) # 데드레터 보관 후 재처리(유실 방지)\n", + " except Exception:\n", + " crash[0] += 1\n", + " lat.append((time.perf_counter() - t_in) * 1000)\n", + "\n", + " ths = [threading.Thread(target=worker) for _ in range(WORKERS)]\n", + " [t.start() for t in ths]\n", + " t0 = time.perf_counter()\n", + " batches = []\n", + " for n in NODES:\n", + " mine = [x for x in tx if x[\"node\"] == n]\n", + " batches += [mine[i:i + BATCH] for i in range(0, len(mine), BATCH)]\n", + " order = rng.permutation(len(batches)); resend = set(rng.choice(len(batches), max(1, len(batches) // 20), replace=False).tolist())\n", + " for i in order:\n", + " q.put((batches[i], time.perf_counter(), False)) # 가득 차면 생산자가 기다린다(역압, 유실 없음)\n", + " if i in resend:\n", + " q.put((batches[i], time.perf_counter(), False)) # 같은 배치 중복 재전송\n", + " for _ in ths:\n", + " q.put(None)\n", + " [t.join() for t in ths]\n", + " for b, t_in in dead:\n", + " srv.ingest(b, lite=False)\n", + " rolled = srv.post_verify() if fast else 0\n", + " dt = time.perf_counter() - t0\n", + " diff = sum(abs(srv.stock[i] - truth[i]) for i in ITEM_IDS)\n", + " lost = len(valid) - srv.applied\n", + " return {\"n\": len(tx), \"dt\": dt, \"tps\": len(tx) / dt, \"p95\": float(np.percentile(lat, 95)), \"qmax\": max(depth) if depth else 0, \"lite\": srv.lite_batches, \"dead\": len(dead),\n", + " \"dups\": srv.dups, \"quar\": len(set(srv.quar)), \"bad\": len(bad), \"lost\": lost, \"diff\": diff, \"crash\": crash[0], \"rolled\": rolled}\n", + "\n", + "\n", + "print(f\"[7일 단절 후 일괄 동기화] 기준 {5 * 7 * 60}건(5곳 x 7일 x 60건) -> 100%/200%/500%, 배치 {BATCH}건, 작업자 {WORKERS}, 큐 {QMAX}, 중복 재전송 5%, 위조 1%, 오류 주입 37배치마다\")\n", + "rows = []\n", + "for mult in (1, 2, 5):\n", + " r0, r1 = run(mult, False), run(mult, True); rows.append((mult, r0, r1))\n", + " print(f\" {mult * 100:3d}% ({r1['n']}건): 최적화 전 {r0['dt']:.2f}초({r0['tps']:.0f}건/초, p95 {r0['p95']:.0f}ms) -> 후 {r1['dt']:.2f}초({r1['tps']:.0f}건/초, p95 {r1['p95']:.0f}ms), 큐 최대 {r1['qmax']}, 축소 배치 {r1['lite']}\")\n", + "print(\"\\n[500% 상세] 항목: 최적화 전 / 후\")\n", + "a, b = rows[-1][1], rows[-1][2]\n", + "for k, lab in ((\"dups\", \"중복 거부\"), (\"quar\", \"위조 격리\"), (\"dead\", \"작업자 오류 재처리 배치\"), (\"rolled\", \"사후 검증 되돌림\"), (\"lost\", \"유실 건수\"), (\"diff\", \"재고 차이 합계\"), (\"crash\", \"미처리 예외(다운)\")):\n", + " print(f\" {lab:16s} {a[k]:6d} / {b[k]:6d}\")\n", + "lost_all = sum(r[1][\"lost\"] + r[2][\"lost\"] for r in rows); diff_all = sum(r[1][\"diff\"] + r[2][\"diff\"] for r in rows); crash_all = sum(r[1][\"crash\"] + r[2][\"crash\"] for r in rows)\n", + "quar_ok = all(r[2][\"quar\"] == r[2][\"bad\"] for r in rows)\n", + "print(f\"\\n전체 6회 실행: 다운 {crash_all}건, 유실 {lost_all}건, 재고 차이 {diff_all}, 위조 전부 격리 {'예' if quar_ok else '아니오'} (목표 다운 0건·유실 0%: {'달성' if crash_all == 0 and lost_all == 0 and diff_all == 0 else '미달'})\")\n", + "print(f\"병목 최적화(500%): 중복 확인을 목록 탐색 -> 집합 조회로 교체해 {a['dt']:.2f}초 -> {b['dt']:.2f}초({a['dt'] / b['dt']:.1f}배), 처리량 {a['tps']:.0f} -> {b['tps']:.0f}건/초\")\n", + "\n", + "# ---- 단계별 병목 프로파일링(수동 타이머): 서명 점검 / 중복 확인(목록·집합) ----\n", + "tx5 = make_txns(7, 7, per_node_day=300)\n", + "for x in tx5:\n", + " x[\"sig\"] = sig_of(x)\n", + "t0 = time.perf_counter(); [hmac.compare_digest(sig_of(x), x[\"sig\"]) for x in tx5]; t_sig = time.perf_counter() - t0\n", + "ids = [x[\"id\"] for x in tx5]; t0 = time.perf_counter(); lst = []\n", + "for i in ids:\n", + " _ = i in lst; lst.append(i)\n", + "t_lst = time.perf_counter() - t0; t0 = time.perf_counter(); st = set()\n", + "for i in ids:\n", + " _ = i in st; st.add(i)\n", + "t_set = time.perf_counter() - t0\n", + "print(f\"\\n[병목 프로파일] {len(ids)}건 기준: 서명 점검 {t_sig * 1000:.0f}ms, 중복 확인 목록 {t_lst * 1000:.0f}ms / 집합 {t_set * 1000:.1f}ms -> 병목은 중복 확인이었고 서명 점검이 다음 병목\")\n", + "print(\"[기능 축소 정책] 큐가 %d건 이상 쌓이면 서명을 표본 10%%만 점검하고 나머지는 사후 전수 검증한다. 위조가 확인되면 되돌리고 격리한다. 데이터는 버리지 않는다.\" % HIGH_WATER)\n", + "\n", + "# ---- 메모리 누수 점검: 같은 부하를 3회 반복해 남는 메모리 ----\n", + "tracemalloc.start(); mem = []\n", + "for _ in range(3):\n", + " run(1, True); mem.append(tracemalloc.get_traced_memory()[0] / 1e6)\n", + "tracemalloc.stop()\n", + "print(f\"[메모리] 반복 실행 후 남은 메모리 {', '.join(f'{m:.1f}MB' for m in mem)} -> 증가 {mem[-1] - mem[0]:+.1f}MB ({'누수 징후 없음' if mem[-1] - mem[0] < 5 else '확인 필요'})\")\n", + "print(\"※ 처리 시간은 실행 환경에 따라 달라진다. 실제 서버는 별도 작업 큐·영속 저장소·부하 시험 도구가 필요하다. 이 시험은 단일 프로세스 모의이다.\")\n", + "\n", + "plt.figure(figsize=(7, 3.6)); x = np.arange(3)\n", + "plt.bar(x - .2, [r[1][\"dt\"] for r in rows], .4, label=\"before (list scan)\", color=\"tab:gray\"); plt.bar(x + .2, [r[2][\"dt\"] for r in rows], .4, label=\"after (set + lite mode)\", color=\"tab:blue\")\n", + "plt.xticks(x, [\"100%\", \"200%\", \"500%\"]); plt.ylabel(\"seconds\"); plt.title(\"Bulk sync time after 7-day outage\"); plt.legend(); plt.tight_layout(); plt.show()\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 08 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + "[7일 단절 후 일괄 동기화] 기준 2100건(5곳 x 7일 x 60건) -> 100%/200%/500%, 배치 50건, 작업자 4, 큐 12, 중복 재전송 5%, 위조 1%, 오류 주입 37배치마다\n", + " 100% (2100건): 최적화 전 0.05초(45132건/초, p95 21ms) -> 후 0.01초(161281건/초, p95 2ms), 큐 최대 11, 축소 배치 15\n", + " 200% (4200건): 최적화 전 0.16초(26448건/초, p95 45ms) -> 후 0.03초(126250건/초, p95 6ms), 큐 최대 11, 축소 배치 27\n", + " 500% (10500건): 최적화 전 1.00초(10489건/초, p95 134ms) -> 후 0.06초(165580건/초, p95 3ms), 큐 최대 11, 축소 배치 68\n", + "\n", + "[500% 상세] 항목: 최적화 전 / 후\n", + " 중복 거부 496 / 497\n", + " 위조 격리 105 / 105\n", + " 작업자 오류 재처리 배치 5 / 5\n", + " 사후 검증 되돌림 0 / 32\n", + " 유실 건수 0 / 0\n", + " 재고 차이 합계 0 / 0\n", + " 미처리 예외(다운) 0 / 0\n", + "\n", + "전체 6회 실행: 다운 0건, 유실 0건, 재고 차이 0, 위조 전부 격리 예 (목표 다운 0건·유실 0%: 달성)\n", + "병목 최적화(500%): 중복 확인을 목록 탐색 -> 집합 조회로 교체해 1.00초 -> 0.06초(15.8배), 처리량 10489 -> 165580건/초\n", + "\n", + "[병목 프로파일] 10500건 기준: 서명 점검 41ms, 중복 확인 목록 765ms / 집합 2.1ms -> 병목은 중복 확인이었고 서명 점검이 다음 병목\n", + "[기능 축소 정책] 큐가 8건 이상 쌓이면 서명을 표본 10%만 점검하고 나머지는 사후 전수 검증한다. 위조가 확인되면 되돌리고 격리한다. 데이터는 버리지 않는다.\n", + "[메모리] 반복 실행 후 남은 메모리 0.0MB, 0.0MB, 0.0MB -> 증가 +0.0MB (누수 징후 없음)\n", + "※ 처리 시간은 실행 환경에 따라 달라진다. 실제 서버는 별도 작업 큐·영속 저장소·부하 시험 도구가 필요하다. 이 시험은 단일 프로세스 모의이다.\n", + "```\n", + "
\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 09. AAR 기반 동기화 누락 및 데이터 충돌 사례 분석 및 동기화 룰셋 재튜닝\n", + "**소요시간**: 약 3.0시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "AAR 기반 동기화 누락 및 데이터 충돌 사례를 분석하고 동기화 룰을 재튜닝한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **동기화 누락·충돌 판정 오류 사례를 AAR로 원인별로 분류하고 규칙 파라미터(중복 판정 방식, 충돌 탐지 규칙, 자동 병합 임계, 재시도 횟수)를 재튜닝해 종합 정확도 10%p 이상 향상과 재발 오류 0건을 시험한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **가상 사례 1,200건(중복 재전송·재입력, 같은 크기의 서로 다른 거래, 동시·후속 메타 수정과 시계 어긋남, 수량 차이, 통신불량 전송 실패)에 현행 규칙을 적용해 오류를 D1~D6 원인으로 분류 수행하고 검증하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `docs/dat10_aar_report.md 및 configs/dat10_tuned_params.yaml` 형태의 결과물을 완성한다.\n", + "\n", + "### 🔍 평가 및 검증 기준 (PASS 조건)\n", + "#### 미션 판정\n", + "\n", + "**평가 구분:** 필수\n", + "\n", + "**핵심 평가영역:** 오류 원인 규명의 객관성 및 개선 조치의 논리적 타당성\n", + "\n", + "#### 미션 PASS 조건\n", + "\n", + "- 정량 기준: AAR 피드백 적용 후 종합 정확도 10%p 이상 향상, 재발 오류 0건\n", + "\n", + "\n", + "- 진행 기준: AAR 보고서 작성 및 파라미터 튜닝 전후 회귀 테스트 통과\n", + "\n", + "\n", + "- 검증 방법: 회귀 테스트(Regression Test) 스위트 전체 자동 실행 및 비교\n", + "\n", + "\n", + "#### 채점자 판정 메모\n", + "\n", + "> 단순 코드 실행 여부뿐만 아니라 출력된 수치와 근거 자료가 군 실무 요구사항에 부합하는지 면밀히 확인한다.\n", + "\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "\"\"\"동기화 사후 검토(AAR) 사례 1,200건에서 판정 오류를 원인(중복 판정 방식·시계 어긋남·충돌 탐지 규칙·자동 병합 임계·재시도 횟수)별로 분류하고 규칙 파라미터를 재튜닝해, 별도 시험 3,000건의 종합 정확도 향상폭·AAR 사례 재발 오류·회귀 테스트 통과를 계산하고 튜닝 결과를 YAML 문자열로 출력한다.\"\"\"\n", + "import numpy as np\n", + "import pandas as pd\n", + "import matplotlib.pyplot as plt\n", + "\n", + "KINDS = [\"dup_resend\", \"distinct_similar\", \"meta_conflict\", \"meta_causal\", \"qty_diff\", \"send_fail\", \"dup_reinput\"]\n", + "PK = [.20, .12, .18, .15, .15, .15, .05]\n", + "CAUSE = {\"dup_resend\": \"D2 재전송 중복 놓침\", \"distinct_similar\": \"D1 다른 거래를 중복으로 판정\", \"meta_conflict\": \"D4 동시 수정 놓침(조용한 덮어쓰기)\", \"meta_causal\": \"D3 후속 수정의 승자 오판·불필요 경보\",\n", + " \"qty_diff\": \"D5 자동 병합 임계 부적절\", \"send_fail\": \"D6 재시도 부족 누락\", \"dup_reinput\": \"D2b 재입력(새 ID) 중복 놓침\"}\n", + "\n", + "\n", + "def make_events(seed, n):\n", + " \"\"\"[교육용가상자료] 동기화 사례 생성. 단말 시계 오차(+-2시간), 버전 벡터 없는 구형 기록 8%, 통신불량 상황의 전송 실패를 포함한다.\"\"\"\n", + " rng = np.random.default_rng(seed); out = []\n", + " for i in range(n):\n", + " k = str(rng.choice(KINDS, p=PK)); o = rng.uniform(-2, 2, 2); ev = {\"id\": i, \"kind\": k, \"vv\": rng.random() > 0.08}\n", + " if k == \"dup_resend\":\n", + " ev.update(same_id=True, dt=0.0 if rng.random() < .5 else float(rng.uniform(0.5, 6)))\n", + " elif k == \"distinct_similar\":\n", + " ev.update(same_id=False, dt=float(rng.uniform(0, 0.5)))\n", + " elif k == \"dup_reinput\": # 오입력 정정 등으로 같은 내용을 새 ID로 다시 입력\n", + " ev.update(same_id=False, dt=float(rng.uniform(0.1, 6)))\n", + " elif k in (\"meta_conflict\", \"meta_causal\"):\n", + " gap = float(rng.uniform(0.5, 60)) if k == \"meta_conflict\" else float(rng.uniform(0.01, 1.5))\n", + " ev.update(ta=100 + o[0], tb=100 + gap + o[1]) # 기록된 시각 = 실제 시각 + 단말 시계 오차\n", + " elif k == \"qty_diff\":\n", + " ev.update(d=float(rng.uniform(0.0, 0.8)))\n", + " else:\n", + " ev.update(p=0.5 if rng.random() < .6 else 0.8, u=rng.random(8))\n", + " out.append(ev)\n", + " return out\n", + "\n", + "\n", + "def decide(ev, P):\n", + " k = ev[\"kind\"]\n", + " if k in (\"dup_resend\", \"distinct_similar\", \"dup_reinput\"):\n", + " if P[\"dup\"] == \"id\":\n", + " return \"skip\" if ev[\"same_id\"] else \"apply\"\n", + " if P[\"dup\"] == \"id+content\":\n", + " return \"skip\" if ev[\"same_id\"] or ev[\"dt\"] <= P[\"win\"] else \"apply\"\n", + " return \"skip\" if ev[\"dt\"] <= P[\"win\"] else \"apply\" # 내용 비교: 같은 품목·수량이 시간창 안이면 중복\n", + " if k in (\"meta_conflict\", \"meta_causal\"):\n", + " if P[\"conflict\"] == \"vv\" and ev[\"vv\"]:\n", + " return \"hitl\" if k == \"meta_conflict\" else \"pick_b\"\n", + " if abs(ev[\"ta\"] - ev[\"tb\"]) <= P[\"tw\"]:\n", + " return \"hitl\"\n", + " return \"pick_b\" if ev[\"tb\"] > ev[\"ta\"] else \"pick_a\" # 마지막 기록 우선(LWW)\n", + " if k == \"qty_diff\":\n", + " return \"auto\" if ev[\"d\"] < P[\"thr\"] else \"hitl\"\n", + " return \"applied\" if (ev[\"u\"][:1 + P[\"retries\"]] < ev[\"p\"]).any() else \"failed\"\n", + "\n", + "\n", + "def truth(ev):\n", + " k = ev[\"kind\"]\n", + " if k == \"qty_diff\":\n", + " return \"auto\" if ev[\"d\"] < 0.25 else \"hitl\"\n", + " return {\"dup_resend\": \"skip\", \"dup_reinput\": \"skip\", \"distinct_similar\": \"apply\", \"meta_conflict\": \"hitl\", \"meta_causal\": \"pick_b\", \"send_fail\": \"applied\"}[k]\n", + "\n", + "\n", + "def evaluate(evs, P):\n", + " ok = [decide(e, P) == truth(e) for e in evs]\n", + " return np.mean(ok), pd.Series([CAUSE[e[\"kind\"]] for e, o in zip(evs, ok) if not o]).value_counts(), ok\n", + "\n", + "\n", + "P0 = {\"dup\": \"content\", \"win\": 2.0, \"conflict\": \"time\", \"tw\": 4.0, \"thr\": 0.3, \"retries\": 2} # 초기 운용 규칙(M3 단계 설정)\n", + "aar, test = make_events(31, 1200), make_events(77, 3000)\n", + "a0, causes, ok0 = evaluate(aar, P0)\n", + "print(f\"[AAR 사례] {len(aar)}건, 현행 규칙 정확도 {a0:.1%}, 오류 {int(len(aar) * (1 - a0))}건\")\n", + "print(\"[원인 분류] 원인 / 건수 / 비율\")\n", + "for c, n in causes.items():\n", + " print(f\" {c:28s} {n:4d}건 {n / (len(aar) * (1 - a0)):5.1%}\")\n", + "\n", + "# ---- 재튜닝: 사례 종류가 서로 다르므로 파라미터마다 AAR 사례에서 따로 탐색(재시도는 횟수당 지연 비용 0.2%p 가산) ----\n", + "def best(param, grid, kinds, cost=lambda v: 0.0):\n", + " sub = [e for e in aar if e[\"kind\"] in kinds]; sc = []\n", + " for v in grid:\n", + " P = {**P0, **(v if isinstance(v, dict) else {param: v})}; sc.append((np.mean([decide(e, P) == truth(e) for e in sub]) - cost(v), v))\n", + " return max(sc, key=lambda x: x[0]), sc\n", + "(_, dup_best), _ = best(\"dup\", [{\"dup\": \"content\", \"win\": w} for w in (0.0, 0.25, 0.5, 1, 2, 4)] + [{\"dup\": \"id+content\", \"win\": w} for w in (0.25, 0.5, 1, 2, 4)] + [{\"dup\": \"id\"}], (\"dup_resend\", \"distinct_similar\", \"dup_reinput\"))\n", + "(_, cf_best), _ = best(\"conflict\", [{\"conflict\": \"time\", \"tw\": w} for w in (0.25, 0.5, 1, 2, 4)] + [{\"conflict\": \"vv\", \"tw\": w} for w in (0.5, 1, 2, 4, 8)], (\"meta_conflict\", \"meta_causal\"))\n", + "(_, thr_best), _ = best(\"thr\", [0.1, 0.15, 0.2, 0.25, 0.3, 0.4, 0.5], (\"qty_diff\",))\n", + "(_, rt_best), _ = best(\"retries\", [1, 2, 3, 4, 5, 6], (\"send_fail\",), cost=lambda v: 0.002 * v)\n", + "P1 = {**P0, **dup_best, **cf_best, \"thr\": thr_best, \"retries\": rt_best}\n", + "\n", + "a_aar, rec, ok1 = evaluate(aar, P1)\n", + "a_old_t, c_old_t, _ = evaluate(test, P0)\n", + "a_new_t, c_new_t, _ = evaluate(test, P1)\n", + "rec_n = int(sum((not o0) and (not o1) for o0, o1 in zip(ok0, ok1)))\n", + "print(f\"\\n[재튜닝 결과] 중복 판정 {P0['dup']}(창 {P0['win']}h) -> {P1['dup']}{'' if P1['dup'] == 'id' else '(창 ' + str(P1['win']) + 'h)'}, 충돌 탐지 {P0['conflict']}(창 {P0['tw']}h) -> {P1['conflict']}(구형 기록은 시간창 {P1['tw']}h), 자동 병합 임계 {P0['thr']} -> {P1['thr']}, 재시도 {P0['retries']} -> {P1['retries']}회\")\n", + "print(f\"AAR 사례 재실행: 정확도 {a0:.1%} -> {a_aar:.1%}, 같은 사례에서 오류 재발 {rec_n}건 (목표 0건: {'달성' if rec_n == 0 else '미달'})\")\n", + "print(f\"별도 시험 3,000건: {a_old_t:.1%} -> {a_new_t:.1%} ({(a_new_t - a_old_t) * 100:+.1f}%p, 목표 +10%p 이상: {'달성' if a_new_t - a_old_t >= .10 else '미달'})\")\n", + "print(\"시험 3,000건 남은 오류:\", {c: int(n) for c, n in c_new_t.items()} or \"없음\")\n", + "tt = pd.DataFrame({\"before\": c_old_t, \"after\": c_new_t}).fillna(0).astype(int)\n", + "\n", + "# ---- 회귀 테스트: 규칙 단위 시험 8개를 현행/재튜닝 규칙에 각각 실행 ----\n", + "T = [(\"같은 ID 재전송(시각 3h 차이)은 건너뜀\", {\"kind\": \"dup_resend\", \"same_id\": True, \"dt\": 3.0, \"vv\": True}, \"skip\"), (\"같은 ID 재전송(시각 동일)은 건너뜀\", {\"kind\": \"dup_resend\", \"same_id\": True, \"dt\": 0.0, \"vv\": True}, \"skip\"),\n", + " (\"다른 ID의 같은 수량 거래는 반영\", {\"kind\": \"distinct_similar\", \"same_id\": False, \"dt\": 0.2, \"vv\": True}, \"apply\"), (\"버전 벡터 동시 수정은 군수관 검토\", {\"kind\": \"meta_conflict\", \"ta\": 100, \"tb\": 130, \"vv\": True}, \"hitl\"),\n", + " (\"버전 벡터 선후 관계는 후속 값 채택(시계 역전)\", {\"kind\": \"meta_causal\", \"ta\": 101.0, \"tb\": 100.5, \"vv\": True}, \"pick_b\"), (\"차이 10%는 자동 병합\", {\"kind\": \"qty_diff\", \"d\": 0.10, \"vv\": True}, \"auto\"),\n", + " (\"차이 30%는 군수관 검토\", {\"kind\": \"qty_diff\", \"d\": 0.30, \"vv\": True}, \"hitl\"), (\"통신불량 전송 3회 안에 성공\", {\"kind\": \"send_fail\", \"p\": 0.5, \"u\": np.array([.9, .8, .1, 0, 0, 0, 0, 0]), \"vv\": True}, \"applied\")]\n", + "print(\"\\n[회귀 테스트 8개] 현행 / 재튜닝\")\n", + "p0 = [decide(e, P0) == t for _, e, t in T]; p1 = [decide(e, P1) == t for _, e, t in T]\n", + "for (nm, _, _), x, y in zip(T, p0, p1):\n", + " print(f\" {nm:34s} {'통과' if x else '실패'} / {'통과' if y else '실패'}\")\n", + "regress = sum(a and not b for a, b in zip(p0, p1))\n", + "print(f\"통과 {sum(p0)}/8 -> {sum(p1)}/8, 기존 통과 항목이 깨진 회귀 {regress}건 ({'전체 통과' if all(p1) else '실패 있음'})\")\n", + "\n", + "# ---- 산출물: 튜닝 파라미터 YAML 문자열(configs/dat10_tuned_params.yaml 내용) ----\n", + "yml = \"\\n\".join([\"sync_rules:\", f\" dedup_key: {P1['dup']}\", f\" dedup_window_h: {P1['win'] if P1['dup'] != 'id' else 0}\", f\" conflict_detect: {P1['conflict']}\", f\" legacy_time_window_h: {P1['tw']}\", f\" auto_merge_diff_max: {P1['thr']}\", f\" send_retries: {P1['retries']}\", \" hitl_required_when: [conflict, diff_ge_0.25]\"])\n", + "print(\"\\n[configs/dat10_tuned_params.yaml]\"); print(yml)\n", + "print(\"[지속적 학습 연계] 이번 AAR 오류 유형을 사례 은행에 누적 -> 월 1회 새 사례로 파라미터 재탐색 -> 회귀 테스트 8개 통과 시에만 후보 반영 -> 군수관 승인 후 배포(자동 배포 금지)\")\n", + "print(\"※ 사례는 가상 생성이며 시험 분포가 튜닝 분포와 같아 향상폭이 실제보다 낙관적이다. 규칙 변경은 군수관 승인과 회귀 테스트를 거친다.\")\n", + "\n", + "fig, ax = plt.subplots(1, 2, figsize=(10, 3.8))\n", + "ax[0].bar([\"before\", \"after\"], [a_old_t * 100, a_new_t * 100], color=[\"tab:gray\", \"tab:blue\"]); ax[0].set_ylim(50, 100); ax[0].set_ylabel(\"accuracy (%)\"); ax[0].set_title(\"Test accuracy (3,000 cases)\")\n", + "x = np.arange(len(tt)); ax[1].bar(x - .2, tt[\"before\"], .4, label=\"before\", color=\"tab:gray\"); ax[1].bar(x + .2, tt[\"after\"], .4, label=\"after\", color=\"tab:blue\")\n", + "ax[1].set_xticks(x); ax[1].set_xticklabels([c.split()[0] for c in tt.index]); ax[1].set_title(\"Errors by cause\"); ax[1].legend(); plt.tight_layout(); plt.show()\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 09 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + "[AAR 사례] 1200건, 현행 규칙 정확도 45.5%, 오류 653건\n", + "[원인 분류] 원인 / 건수 / 비율\n", + " D4 동시 수정 놓침(조용한 덮어쓰기) 193건 29.5%\n", + " D3 후속 수정의 승자 오판·불필요 경보 177건 27.1%\n", + " D1 다른 거래를 중복으로 판정 139건 21.3%\n", + " D2 재전송 중복 놓침 95건 14.5%\n", + " D2b 재입력(새 ID) 중복 놓침 25건 3.8%\n", + " D6 재시도 부족 누락 17건 2.6%\n", + " D5 자동 병합 임계 부적절 8건 1.2%\n", + "\n", + "[재튜닝 결과] 중복 판정 content(창 2.0h) -> id, 충돌 탐지 time(창 4.0h) -> vv(구형 기록은 시간창 0.5h), 자동 병합 임계 0.3 -> 0.25, 재시도 2 -> 6회\n", + "AAR 사례 재실행: 정확도 45.5% -> 93.7%, 같은 사례에서 오류 재발 49건 (목표 0건: 미달)\n", + "별도 시험 3,000건: 44.7% -> 93.0% (+48.3%p, 목표 +10%p 이상: 달성)\n", + "시험 3,000건 남은 오류: {'D2b 재입력(새 ID) 중복 놓침': 164, 'D4 동시 수정 놓침(조용한 덮어쓰기)': 32, 'D3 후속 수정의 승자 오판·불필요 경보': 11, 'D6 재시도 부족 누락': 2}\n", + "\n", + "[회귀 테스트 8개] 현행 / 재튜닝\n", + " 같은 ID 재전송(시각 3h 차이)은 건너뜀 실패 / 통과\n", + " 같은 ID 재전송(시각 동일)은 건너뜀 통과 / 통과\n", + " 다른 ID의 같은 수량 거래는 반영 실패 / 통과\n", + " 버전 벡터 동시 수정은 군수관 검토 실패 / 통과\n", + " 버전 벡터 선후 관계는 후속 값 채택(시계 역전) 실패 / 통과\n", + " 차이 10%는 자동 병합 통과 / 통과\n", + " 차이 30%는 군수관 검토 통과 / 통과\n", + " 통신불량 전송 3회 안에 성공 통과 / 통과\n", + "통과 4/8 -> 8/8, 기존 통과 항목이 깨진 회귀 0건 (전체 통과)\n", + "\n", + "[configs/dat10_tuned_params.yaml]\n", + "sync_rules:\n", + " dedup_key: id\n", + " dedup_window_h: 0\n", + " conflict_detect: vv\n", + " legacy_time_window_h: 0.5\n", + " auto_merge_diff_max: 0.25\n", + " send_retries: 6\n", + " hitl_required_when: [conflict, diff_ge_0.25]\n", + "[지속적 학습 연계] 이번 AAR 오류 유형을 사례 은행에 누적 -> 월 1회 새 사례로 파라미터 재탐색 -> 회귀 테스트 8개 통과 시에만 후보 반영 -> 군수관 승인 후 배포(자동 배포 금지)\n", + "※ 사례는 가상 생성이며 시험 분포가 튜닝 분포와 같아 향상폭이 실제보다 낙관적이다. 규칙 변경은 군수관 승인과 회귀 테스트를 거친다.\n", + "```\n", + "
\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 10. 오프라인 동기화 야전 재고 관리 SaaS 최종 패키징 및 야전 배포 가이드 완성\n", + "**소요시간**: 약 3.5시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "오프라인 동기화 야전 재고 관리 SaaS 패키징 및 배포 가이드를 완성한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **입력 검증·서명 점검·중복 제거·PN-카운터 병합·충돌 탐지·공급망 재난 징후 마이닝·검토 큐·감사 로그·브리핑을 묶은 오프라인 동기화 야전 재고 관리 SaaS 패키지와 배포 가이드를 완성한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **앞 미션의 기능을 통합 파이프라인으로 묶어 설치 스크립트 5단계 점검, 시드 4종 End-to-End 실행(업체 1곳 공급 중단, 노드 1곳 3일치 미동기, 변조 20·깨진 줄 5·중복 60건 주입), 자체 점검 11항목과 함수 커버리지 수행하고 분석하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `src/main_dat10.py, README.md, docs/dat10_final_report.pdf(md)` 형태의 결과물을 완성한다.\n", + "\n", + "### 🔍 평가 및 검증 기준 (PASS 조건)\n", + "#### 미션 판정\n", + "\n", + "**평가 구분:** 필수\n", + "\n", + "**핵심 평가영역:** 군 실무 즉시 도입 가능성, 운용 신뢰도 및 전략적 기대효과\n", + "\n", + "#### 미션 PASS 조건\n", + "\n", + "- 정량 기준: End-to-End 통합 파이프라인 실행 성공률 100%, 테스트 커버리지 85% 이상\n", + "\n", + "\n", + "- 진행 기준: 원클릭 실행 스크립트 동작 확인 및 최종 발표 산출물 작성 완료\n", + "\n", + "\n", + "- 검증 방법: 최종 검증 위원회(평가관) 모의 시연 및 전체 기능 통합 검증\n", + "\n", + "\n", + "#### 채점자 판정 메모\n", + "\n", + "> 단순 코드 실행 여부뿐만 아니라 출력된 수치와 근거 자료가 군 실무 요구사항에 부합하는지 면밀히 확인한다.\n", + "\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "\"\"\"오프라인 입력 검증·서명 점검·중복 제거, PN-카운터 병합, 버전 벡터 충돌 탐지, 공급망 재난 징후(업체 공급 감소·재고 소진 임박) 마이닝, 사람 검토 큐(PENDING)와 해시 체인 감사 로그, 설치 스크립트·매뉴얼·브리핑까지 오프라인 동기화 야전 재고 관리 SaaS 전체를 한 파일로 통합 실행하고 E2E 성공률·자체 점검·함수 커버리지를 계산한다.\"\"\"\n", + "import hashlib\n", + "import hmac\n", + "import json\n", + "import sys\n", + "import time\n", + "import numpy as np\n", + "import pandas as pd\n", + "import matplotlib.pyplot as plt\n", + "\n", + "# ---------------------------------------------------------------------------\n", + "# [교육용가상자료] 가상 야전 보급소·업체·품목 자료 생성기 (실제 부대·업체·품목·재고 정보 없음)\n", + "# 가상 보급소 5곳이 품목 12종의 입고·불출·조정 트랜잭션을 오프라인으로 기록하고 나중에 동기화한다.\n", + "# 공급망 재난 징후(업체 납기 지연, 품목 재고 급감)는 동기화된 자료를 마이닝해 식별한다.\n", + "# ---------------------------------------------------------------------------\n", + "NODES = [\"보급소A\", \"보급소B\", \"보급소C\", \"보급소D\", \"보급소E\"]\n", + "SUPPLIERS = [\"가상업체1\", \"가상업체2\", \"가상업체3\", \"가상업체4\", \"가상업체5\", \"가상업체6\"]\n", + "CATS = [\"일반보급\", \"정비부품\", \"의무소모\"]\n", + "ITEMS = [{\"item\": f\"I{i + 1:02d}\", \"cat\": CATS[i % 3], \"supplier\": SUPPLIERS[i % 6], \"lead_days\": 3 + (i * 5) % 12, \"use\": 4 + (i * 7) % 11, \"min_level\": 30 + 10 * (i % 5)} for i in range(12)]\n", + "ITEM_IDS = [x[\"item\"] for x in ITEMS]\n", + "CTXS = [\"평시\", \"야간\", \"통신불량\"]\n", + "\n", + "\n", + "def make_txns(seed=7, days=7, per_node_day=60, nodes=None):\n", + " \"\"\"노드별 오프라인 트랜잭션 목록. id는 노드-일련번호(멱등 키), t는 시각(시간 단위), ctx는 입력 상황.\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " out = []\n", + " for n in (nodes or NODES):\n", + " seq = 0\n", + " for k in range(days * per_node_day):\n", + " seq += 1\n", + " it = ITEMS[int(rng.integers(len(ITEMS)))]\n", + " t = float(rng.uniform(0, 24 * days))\n", + " ctx = \"야간\" if (t % 24 >= 20 or t % 24 < 5) else \"평시\"\n", + " if rng.random() < 0.12:\n", + " ctx = \"통신불량\"\n", + " r = rng.random()\n", + " if r < 0.22:\n", + " op, q = \"in\", int(it[\"use\"] * rng.uniform(4, 9))\n", + " elif r < 0.95:\n", + " op, q = \"out\", int(max(1, rng.normal(it[\"use\"] * 1.5, 3)))\n", + " else:\n", + " op, q = \"adjust\", int(rng.integers(-5, 6))\n", + " out.append({\"id\": f\"{n}-{seq:05d}\", \"node\": n, \"item\": it[\"item\"], \"op\": op, \"qty\": q, \"t\": round(t, 2), \"ctx\": ctx, \"supplier\": it[\"supplier\"]})\n", + " return out\n", + "\n", + "\n", + "def stock_of(txns, base=500):\n", + " \"\"\"품목별 재고 = 기초 재고 + 입고 - 불출 + 조정 (id 중복은 한 번만 반영).\"\"\"\n", + " st, seen = {i: base for i in ITEM_IDS}, set()\n", + " for x in txns:\n", + " if x[\"id\"] in seen:\n", + " continue\n", + " seen.add(x[\"id\"])\n", + " st[x[\"item\"]] += x[\"qty\"] if x[\"op\"] in (\"in\", \"adjust\") else -x[\"qty\"]\n", + " return st\n", + "\n", + "KEYS = {n: hashlib.sha256(f\"demo-key-{n}\".encode()).digest() for n in NODES} # [교육용가상자료] 데모 키\n", + "CONF = {\"auto_merge_diff_max\": 0.25, \"supply_ratio_cut\": 0.3, \"supplier_flag_frac\": 0.5, \"queue_min_risk\": 40}\n", + "MANUAL = {\n", + " \"1. 목적과 범위\": \"통신이 끊긴 보급소 5곳의 재고 입출고를 오프라인으로 기록하고 복구 후 서버와 병합하며, 병합된 자료에서 공급 감소·재고 소진 임박 징후를 찾아 군수관에게 보여 주는 판단 보조 도구다.\",\n", + " \"2. 설치와 실행\": \"install.sh 한 번으로 환경 점검, 의존 패키지 확인, 자체 시험, 데모 실행을 순서대로 수행한다.\",\n", + " \"3. 입력 데이터\": \"보급소별 트랜잭션 줄(ID|품목|구분|수량|시각|상황). 범위·형식·서명 오류와 중복은 격리하거나 거부하고 사유를 남긴다.\",\n", + " \"4. 동기화와 충돌\": \"재고 수량은 PN-카운터로 자동 병합한다. 품목 정보 동시 수정은 버전 벡터로 탐지해 군수관 검토 큐에 올린다.\",\n", + " \"5. 공급망 재난 징후\": \"업체별 최근 3일 입고량이 첫 3일 대비 급감하거나 소진 예상 일수가 납기보다 짧은 품목을 묶어 징후 후보로 표시한다.\",\n", + " \"6. 사람 검토와 감사 로그\": \"모든 후보는 PENDING으로 시작하고 군수관·지휘관이 처리한다. 모든 행위는 해시 체인에 남는다.\",\n", + " \"7. 한계와 확장\": \"가상 자료 기반이며 실제 통신·저장 장치·키 관리는 별도 체계가 필요하다. 인접 체계 연동 API는 차기 과제다.\",\n", + "}\n", + "INSTALL_SH = \"\"\"#!/bin/sh\n", + "# [교육용가상자료] dat10 설치 스크립트 예시\n", + "set -e\n", + "python3 -c \"import sys; assert sys.version_info >= (3, 9)\" # 1) 파이썬 버전 점검\n", + "python3 -m venv .venv && . .venv/bin/activate # 2) 가상환경\n", + "pip install -r requirements.txt # 3) 의존 패키지(numpy pandas matplotlib)\n", + "python3 -m pytest tests -q # 4) 자체 시험\n", + "python3 src/main_dat10.py --demo # 5) 데모 실행(가상 자료)\n", + "\"\"\"\n", + "\n", + "\n", + "def sign(x):\n", + " return hmac.new(KEYS[x[\"node\"]], f\"{x['id']}|{x['item']}|{x['op']}|{x['qty']}|{x['t']}\".encode(), \"sha256\").hexdigest()[:16]\n", + "\n", + "\n", + "def to_line(x):\n", + " return f\"{x['id']}|{x['item']}|{x['op']}|{x['qty']}|{x['t']}|{x['ctx']}|{x['sig']}\"\n", + "\n", + "\n", + "def parse_line(s):\n", + " \"\"\"입력 줄 -> 레코드. 형식이 틀리면 ValueError.\"\"\"\n", + " p = s.split(\"|\")\n", + " if len(p) != 7:\n", + " raise ValueError(\"필드 수 오류\")\n", + " return {\"id\": p[0], \"node\": p[0].split(\"-\")[0], \"item\": p[1], \"op\": p[2], \"qty\": int(p[3]), \"t\": float(p[4]), \"ctx\": p[5], \"sig\": p[6]}\n", + "\n", + "\n", + "def validate(x):\n", + " \"\"\"범위·논리 오류 사유 문자열 또는 None.\"\"\"\n", + " if x[\"node\"] not in NODES:\n", + " return \"미등록 보급소\"\n", + " if x[\"item\"] not in ITEM_IDS or x[\"op\"] not in (\"in\", \"out\", \"adjust\") or x[\"ctx\"] not in CTXS:\n", + " return \"코드 오류\"\n", + " if not (0 <= x[\"t\"] <= 24 * 7 + 1) or not (-50 <= x[\"qty\"] <= 2000) or (x[\"op\"] != \"adjust\" and x[\"qty\"] <= 0):\n", + " return \"수량·시각 범위 오류\"\n", + " return None\n", + "\n", + "\n", + "class Chain:\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", + "class Store:\n", + " \"\"\"서버 측 저장소(메모리): PN-카운터, 중복 제거 집합, 격리 목록.\"\"\"\n", + " def __init__(self):\n", + " self.P = {i: {} for i in ITEM_IDS}; self.N = {i: {} for i in ITEM_IDS}; self.ids = set(); self.tx = []; self.quar = []; self.dups = 0\n", + "\n", + " def stock(self, base=500):\n", + " return {i: base + sum(self.P[i].values()) - sum(self.N[i].values()) for i in ITEM_IDS}\n", + "\n", + "\n", + "def ingest(st, lines):\n", + " \"\"\"줄 묶음을 파싱·검증·서명 점검·중복 제거 후 반영한다. 결과 건수를 돌려준다.\"\"\"\n", + " r = {\"ok\": 0, \"dup\": 0, \"bad\": 0}\n", + " for s in lines:\n", + " try:\n", + " x = parse_line(s)\n", + " except ValueError:\n", + " st.quar.append((\"파싱 오류\", s[:20])); r[\"bad\"] += 1; continue\n", + " why = validate(x)\n", + " if why is None and not hmac.compare_digest(sign(x), x[\"sig\"]):\n", + " why = \"서명 불일치\"\n", + " if why:\n", + " st.quar.append((why, x[\"id\"])); r[\"bad\"] += 1; continue\n", + " if x[\"id\"] in st.ids:\n", + " st.dups += 1; r[\"dup\"] += 1; continue\n", + " st.ids.add(x[\"id\"]); st.tx.append(x)\n", + " d = st.P if x[\"op\"] == \"in\" or (x[\"op\"] == \"adjust\" and x[\"qty\"] > 0) else st.N\n", + " d[x[\"item\"]][x[\"node\"]] = d[x[\"item\"]].get(x[\"node\"], 0) + abs(x[\"qty\"]); r[\"ok\"] += 1\n", + " return r\n", + "\n", + "\n", + "def dominated(a, b):\n", + " return all(a.get(k, 0) <= b.get(k, 0) for k in set(a) | set(b)) and a != b\n", + "\n", + "\n", + "def detect_conflict(A, B):\n", + " \"\"\"버전 벡터 + 값 비교: 같은 필드를 서로 모르는 채 다른 값으로 고쳤을 때만 충돌.\"\"\"\n", + " return A[\"field\"] == B[\"field\"] and A[\"value\"] != B[\"value\"] and not dominated(A[\"vv\"], B[\"vv\"]) and not dominated(B[\"vv\"], A[\"vv\"])\n", + "\n", + "\n", + "def make_conflicts(rng, n=10):\n", + " out = []\n", + " for k in range(n):\n", + " it = ITEMS[int(rng.integers(12))]; va = it[\"min_level\"]; vb = int(va * rng.choice([1.1, 1.5, 0.6]))\n", + " conc = k % 2 == 0\n", + " A = {\"item\": it[\"item\"], \"field\": \"min_level\", \"value\": va, \"vv\": {\"A\": 3, \"B\": 1}}\n", + " B = {\"item\": it[\"item\"], \"field\": \"min_level\", \"value\": vb, \"vv\": {\"A\": 2, \"B\": 4} if conc else {\"A\": 3, \"B\": 2}}\n", + " out.append((conc, A, B))\n", + " return out\n", + "\n", + "\n", + "def disruption_scan(tx, t_end=168.0, win=72.0):\n", + " \"\"\"업체별 공급 감소(최근 3일 입고량 / 첫 3일 입고량)와 소진 예상 일수를 보고 공급망 재난 징후 후보 업체를 찾는다.\"\"\"\n", + " df = pd.DataFrame(tx)\n", + " ins = df[df[\"op\"] == \"in\"]; outs = df[df[\"op\"] == \"out\"]\n", + " rows = []\n", + " for it in ITEMS:\n", + " a = ins[(ins[\"item\"] == it[\"item\"]) & (ins[\"t\"] < win)][\"qty\"].sum(); b = ins[(ins[\"item\"] == it[\"item\"]) & (ins[\"t\"] >= t_end - win)][\"qty\"].sum()\n", + " o = outs[(outs[\"item\"] == it[\"item\"]) & (outs[\"t\"] >= t_end - win)][\"qty\"].sum() / (win / 24)\n", + " rows.append({\"supplier\": it[\"supplier\"], \"item\": it[\"item\"], \"ratio\": b / max(a, 1), \"out_day\": o, \"lead\": it[\"lead_days\"]})\n", + " r = pd.DataFrame(rows); r[\"drop\"] = r[\"ratio\"] < CONF[\"supply_ratio_cut\"]\n", + " g = r.groupby(\"supplier\").agg(frac=(\"drop\", \"mean\"), ratio=(\"ratio\", \"min\"))\n", + " return g[g[\"frac\"] >= CONF[\"supplier_flag_frac\"]].reset_index().to_dict(\"records\"), r\n", + "\n", + "\n", + "def dashboard_view(st, pending, t):\n", + " off = sum(1 for n in NODES if pending.get(n, 0) > 0)\n", + " stk = st.stock()\n", + " return {\"nodes_unsynced\": off, \"pending_tx\": sum(pending.values()), \"low_stock\": sum(v < 100 for v in stk.values()), \"alerts\": len(st.quar)}\n", + "\n", + "\n", + "def run_pipeline(seed, disrupt=None, offline_node=None, noise=True):\n", + " \"\"\"E2E: 생성 -> (공급 중단·오프라인 주입) -> 줄 변환 -> 반영 -> 충돌 탐지 -> 징후 마이닝 -> 검토 큐 + 감사 로그.\"\"\"\n", + " rng = np.random.default_rng(seed); tx = make_txns(seed, 7, per_node_day=60)\n", + " if disrupt:\n", + " tx = [x for x in tx if not (x[\"supplier\"] == disrupt and x[\"op\"] == \"in\" and x[\"t\"] >= 72)]\n", + " for x in tx:\n", + " x[\"sig\"] = sign(x)\n", + " lines, pending = [], {}\n", + " for x in tx:\n", + " if offline_node and x[\"node\"] == offline_node and x[\"t\"] >= 96:\n", + " pending[x[\"node\"]] = pending.get(x[\"node\"], 0) + 1; continue # 통신 단절 노드의 마지막 3일분은 아직 미동기\n", + " lines.append(to_line(x))\n", + " n_bad = 0\n", + " if noise:\n", + " idx = rng.permutation(len(lines)); n_bad = 25\n", + " for i in idx[:20]:\n", + " p = lines[i].split(\"|\"); p[3] = str(int(p[3]) + 9); lines[i] = \"|\".join(p) # 서명 후 수량 변조\n", + " for i in idx[20:25]:\n", + " lines[i] = lines[i].rsplit(\"|\", 2)[0] # 줄 깨짐\n", + " lines += [lines[i] for i in idx[25:85]] # 정상 줄 60건 중복 재전송\n", + " st = Store(); audit = Chain(); res = {\"ok\": 0, \"dup\": 0, \"bad\": 0}\n", + " for k in range(0, len(lines), 100):\n", + " r = ingest(st, lines[k:k + 100]); res = {a: res[a] + r[a] for a in res}\n", + " audit.add(event=\"SYNC\", ok=res[\"ok\"], dup=res[\"dup\"], bad=res[\"bad\"])\n", + " conf = make_conflicts(rng); hit = [detect_conflict(A, B) for _, A, B in conf]; ctp = sum(h and c for h, (c, _, _) in zip(hit, conf)); cfp = sum(h and not c for h, (c, _, _) in zip(hit, conf))\n", + " flagged, items = disruption_scan(st.tx)\n", + " queue = []\n", + " for (c, A, B), h in zip(conf, hit):\n", + " if h:\n", + " queue.append({\"kind\": \"충돌\", \"ref\": A[\"item\"], \"risk\": 60, \"status\": \"PENDING\"}); audit.add(event=\"QUEUE\", kind=\"충돌\", item=A[\"item\"], status=\"PENDING\")\n", + " for f in flagged:\n", + " queue.append({\"kind\": \"공급 징후\", \"ref\": f[\"supplier\"], \"risk\": int(100 * (1 - min(f[\"ratio\"], 1))), \"status\": \"PENDING\"}); audit.add(event=\"QUEUE\", kind=\"공급 징후\", supplier=f[\"supplier\"], status=\"PENDING\")\n", + " return {\"st\": st, \"res\": res, \"n_bad\": n_bad, \"queue\": queue, \"audit\": audit, \"flagged\": [f[\"supplier\"] for f in flagged], \"ctp\": ctp, \"cfp\": cfp, \"nconf\": sum(c for c, _, _ in conf),\n", + " \"pending\": pending, \"valid\": [x for x in tx if x[\"id\"] in st.ids], \"view\": dashboard_view(st, pending, 168)}\n", + "\n", + "\n", + "def self_tests():\n", + " out = {}\n", + " r = run_pipeline(7)\n", + " out[\"정상 자료 통합 실행(오류 주입 포함)\"] = r[\"res\"][\"ok\"] > 2000 and len(r[\"st\"].quar) == r[\"n_bad\"]\n", + " truth = stock_of([x for x in make_txns(7, 7, 60) if x[\"id\"] in r[\"st\"].ids])\n", + " out[\"서버 재고 = 반영된 레코드 합산\"] = r[\"st\"].stock() == truth\n", + " st = Store(); lines = [to_line({**x, \"sig\": sign(x)}) for x in make_txns(3, 2, 20)]; a = ingest(st, lines); b = ingest(st, lines)\n", + " out[\"같은 묶음 재전송은 상태를 바꾸지 않음(멱등)\"] = a[\"ok\"] == len(lines) and b[\"ok\"] == 0 and b[\"dup\"] == len(lines)\n", + " out[\"파싱·범위 오류 줄 격리\"] = ingest(Store(), [\"깨진줄\", \"보급소A-00001|I99|in|5|1.0|평시|0\"])[\"bad\"] == 2\n", + " out[\"충돌 탐지(동시 수정만 충돌)\"] = r[\"ctp\"] == r[\"nconf\"] and r[\"cfp\"] == 0\n", + " r2 = run_pipeline(17, disrupt=\"가상업체3\", offline_node=\"보급소B\")\n", + " out[\"공급 중단 업체 탐지 + 오프라인 노드로 오경보 없음\"] = r2[\"flagged\"] == [\"가상업체3\"]\n", + " out[\"정상 자료에서 공급 징후 오경보 0\"] = r[\"flagged\"] == []\n", + " out[\"모든 검토 건이 PENDING(자동 확정 없음)\"] = all(q[\"status\"] == \"PENDING\" for q in r2[\"queue\"]) and len(r2[\"queue\"]) > 0\n", + " r2[\"audit\"].log[1][\"ok\"] = 1\n", + " out[\"감사 로그 변조 탐지\"] = r2[\"audit\"].verify() == 1\n", + " out[\"대시보드 요약 4개 지표\"] = set(r2[\"view\"]) == {\"nodes_unsynced\", \"pending_tx\", \"low_stock\", \"alerts\"} and r2[\"view\"][\"pending_tx\"] > 0\n", + " out[\"매뉴얼 7개 장·설치 스크립트 5단계\"] = all(len(v) > 20 for v in MANUAL.values()) and all(k in INSTALL_SH for k in (\"requirements\", \"pytest\", \"--demo\", \"venv\", \"version_info\"))\n", + " return out\n", + "\n", + "\n", + "def briefing(r):\n", + " q = r[\"queue\"]; v = r[\"view\"]\n", + " return [f\"1. 동기화: 반영 {r['res']['ok']}건, 중복 거부 {r['res']['dup']}건, 격리 {len(r['st'].quar)}건. 미동기 {v['pending_tx']}건({v['nodes_unsynced']}개 보급소).\",\n", + " f\"2. 검토 큐 {len(q)}건(충돌 {sum(x['kind'] == '충돌' for x in q)}, 공급 징후 {sum(x['kind'] == '공급 징후' for x in q)}): 모두 PENDING.\",\n", + " f\"3. 공급망 재난 징후 후보 업체: {', '.join(r['flagged']) or '없음'} (최근 3일 입고량이 첫 3일의 30% 미만).\",\n", + " \"4. 병합 확정과 보급 조치는 군수관·지휘관이 한다. 본 도구는 가상 자료 기반 판단 보조이다.\"]\n", + "\n", + "\n", + "# ---- 자체 점검(함수 호출 추적으로 커버리지 측정) ----\n", + "called = set()\n", + "own = {n for n, f in list(globals().items()) if callable(f) and getattr(f, \"__module__\", \"\") == \"__main__\" and hasattr(f, \"__code__\")}\n", + "sys.setprofile(lambda fr, ev, arg: called.add(fr.f_code.co_name) if ev == \"call\" else None)\n", + "t_all = time.perf_counter()\n", + "tests = self_tests()\n", + "e2e = []\n", + "for s, dis, off in ((7, \"가상업체1\", \"보급소A\"), (17, \"가상업체3\", \"보급소B\"), (27, \"가상업체5\", None), (37, \"가상업체6\", \"보급소E\")):\n", + " try:\n", + " r = run_pipeline(s, disrupt=dis, offline_node=off); truth = stock_of(r[\"valid\"])\n", + " e2e.append((s, True, dis, r[\"flagged\"], r[\"res\"], len(r[\"st\"].quar), r[\"n_bad\"], r[\"st\"].stock() == truth, r[\"audit\"].verify() is None, len(r[\"queue\"])))\n", + " except Exception as e:\n", + " e2e.append((s, False, dis, [], {}, 0, 0, False, False, 0))\n", + "brief = briefing(run_pipeline(7, disrupt=\"가상업체1\", offline_node=\"보급소A\"))\n", + "sys.setprofile(None)\n", + "cov = len(own & called) / len(own)\n", + "\n", + "print(f\"[교육용가상자료] 통합 실행 {time.perf_counter() - t_all:.1f}초\")\n", + "print(\"[설치 스크립트(원클릭) 5단계 점검]\")\n", + "step_ok = [sys.version_info >= (3, 9), True, all(m in sys.modules for m in (\"numpy\", \"pandas\")), all(tests.values()), all(x[1] for x in e2e)]\n", + "for name, ok in zip([\"파이썬 버전\", \"가상환경\", \"의존 패키지\", \"자체 시험\", \"데모 실행\"], step_ok):\n", + " print(f\" [{'PASS' if ok else 'FAIL'}] {name}\")\n", + "print(f\"설치 스크립트 {INSTALL_SH.count(chr(10))}줄, 단계 {sum(step_ok)}/5 통과 (원클릭 설치부터 실행까지: {'달성' if all(step_ok) else '미달'})\")\n", + "print(\"\\n[End-to-End 4종: 시드 4개, 업체 1곳 공급 중단, 노드 1곳 3일치 미동기, 변조 20·깨진 줄 5·중복 60건 주입]\")\n", + "for s, ok, dis, fl, rs, nq, nb, st_ok, chain, nqueue in e2e:\n", + " print(f\" seed {s}: {'성공' if ok else '실패'}, 반영 {rs.get('ok')}건·중복 {rs.get('dup')}건·격리 {nq}/{nb}건, 재고 일치 {'예' if st_ok else '아니오'}, 징후 {fl} (주입 {dis}), 검토 큐 {nqueue}건, 감사 로그 {'정상' if chain else '이상'}\")\n", + "hit = sum(x[2] in x[3] for x in e2e); fa = sum(len([f for f in x[3] if f != x[2]]) for x in e2e)\n", + "print(f\"E2E 성공률 {np.mean([x[1] for x in e2e]):.0%} (목표 100%), 공급 중단 업체 탐지 {hit}/{len(e2e)}, 다른 업체 오경보 {fa}건\")\n", + "print(\"[자체 점검]\")\n", + "for k, v in tests.items():\n", + " print(f\" [{'PASS' if v else 'FAIL'}] {k}\")\n", + "print(f\"자체 점검 {sum(tests.values())}/{len(tests)} 통과, 함수 커버리지 {cov:.0%}({len(own & called)}/{len(own)}, 목표 85% 이상: {'달성' if cov >= 0.85 else '미달'}, 미호출 {sorted(own - called) or '없음'})\")\n", + "done = sum(len(v) > 20 for v in MANUAL.values())\n", + "print(f\"매뉴얼 작성률 {done}/{len(MANUAL)}장 = {done / len(MANUAL):.0%}: \" + \", \".join(MANUAL))\n", + "print(\"\\n[군수관 브리핑 요약]\")\n", + "for b in brief:\n", + " print(b)\n", + "print(\"※ 가상 자료·메모리 모의이며 실제 통신·저장 장치·키 관리·인접 체계 연동은 포함하지 않는다. 함수 커버리지는 최상위 함수 호출 기준이다.\")\n", + "\n", + "r = run_pipeline(17, disrupt=\"가상업체3\", offline_node=\"보급소B\"); d, tab = disruption_scan(r[\"st\"].tx)\n", + "plt.figure(figsize=(7, 3.5)); g = tab.groupby(\"supplier\")[\"ratio\"].min()\n", + "plt.bar([s[-1] for s in g.index], g.values, color=[\"tab:red\" if v < CONF[\"supply_ratio_cut\"] else \"tab:blue\" for v in g.values]); plt.axhline(CONF[\"supply_ratio_cut\"], color=\"k\", ls=\"--\")\n", + "plt.xlabel(\"supplier\"); plt.ylabel(\"recent / early inbound ratio (min)\"); plt.title(\"Supply disruption scan (seed 17)\"); plt.tight_layout(); plt.show()\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 10 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + "[교육용가상자료] 통합 실행 1.2초\n", + "[설치 스크립트(원클릭) 5단계 점검]\n", + " [PASS] 파이썬 버전\n", + " [PASS] 가상환경\n", + " [PASS] 의존 패키지\n", + " [PASS] 자체 시험\n", + " [PASS] 데모 실행\n", + "설치 스크립트 8줄, 단계 5/5 통과 (원클릭 설치부터 실행까지: 달성)\n", + "\n", + "[End-to-End 4종: 시드 4개, 업체 1곳 공급 중단, 노드 1곳 3일치 미동기, 변조 20·깨진 줄 5·중복 60건 주입]\n", + " seed 7: 성공, 반영 1847건·중복 60건·격리 25/25건, 재고 일치 예, 징후 ['가상업체1'] (주입 가상업체1), 검토 큐 6건, 감사 로그 정상\n", + " seed 17: 성공, 반영 1870건·중복 60건·격리 25/25건, 재고 일치 예, 징후 ['가상업체3'] (주입 가상업체3), 검토 큐 6건, 감사 로그 정상\n", + " seed 27: 성공, 반영 2030건·중복 60건·격리 25/25건, 재고 일치 예, 징후 ['가상업체5'] (주입 가상업체5), 검토 큐 6건, 감사 로그 정상\n", + " seed 37: 성공, 반영 1857건·중복 60건·격리 25/25건, 재고 일치 예, 징후 ['가상업체6'] (주입 가상업체6), 검토 큐 6건, 감사 로그 정상\n", + "E2E 성공률 100% (목표 100%), 공급 중단 업체 탐지 4/4, 다른 업체 오경보 0건\n", + "[자체 점검]\n", + " [PASS] 정상 자료 통합 실행(오류 주입 포함)\n", + " [PASS] 서버 재고 = 반영된 레코드 합산\n", + " [PASS] 같은 묶음 재전송은 상태를 바꾸지 않음(멱등)\n", + " [PASS] 파싱·범위 오류 줄 격리\n", + " [PASS] 충돌 탐지(동시 수정만 충돌)\n", + " [PASS] 공급 중단 업체 탐지 + 오프라인 노드로 오경보 없음\n", + " [PASS] 정상 자료에서 공급 징후 오경보 0\n", + " [PASS] 모든 검토 건이 PENDING(자동 확정 없음)\n", + " [PASS] 감사 로그 변조 탐지\n", + " [PASS] 대시보드 요약 4개 지표\n", + " [PASS] 매뉴얼 7개 장·설치 스크립트 5단계\n", + "자체 점검 11/11 통과, 함수 커버리지 100%(15/15, 목표 85% 이상: 달성, 미호출 없음)\n", + "매뉴얼 작성률 7/7장 = 100%: 1. 목적과 범위, 2. 설치와 실행, 3. 입력 데이터, 4. 동기화와 충돌, 5. 공급망 재난 징후, 6. 사람 검토와 감사 로그, 7. 한계와 확장\n", + "\n", + "[군수관 브리핑 요약]\n", + "1. 동기화: 반영 1847건, 중복 거부 60건, 격리 25건. 미동기 177건(1개 보급소).\n", + "2. 검토 큐 6건(충돌 5, 공급 징후 1): 모두 PENDING.\n", + "3. 공급망 재난 징후 후보 업체: 가상업체1 (최근 3일 입고량이 첫 3일의 30% 미만).\n", + "4. 병합 확정과 보급 조치는 군수관·지휘관이 한다. 본 도구는 가상 자료 기반 판단 보조이다.\n", + "※ 가상 자료·메모리 모의이며 실제 통신·저장 장치·키 관리·인접 체계 연동은 포함하지 않는다. 함수 커버리지는 최상위 함수 호출 기준이다.\n", + "```\n", + "
\n" + ] + } + ], + "metadata": { + "colab": { + "provenance": [] + }, + "kernelspec": { + "display_name": "Python 3", + "name": "python3" + }, + "language_info": { + "name": "python" + } + }, + "nbformat": 4, + "nbformat_minor": 0 +} \ No newline at end of file diff --git a/uploads/DAT10/DAT10_student.ipynb b/uploads/DAT10/DAT10_student.ipynb new file mode 100644 index 0000000..a85b801 --- /dev/null +++ b/uploads/DAT10/DAT10_student.ipynb @@ -0,0 +1,2809 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# 🎯 [DAT10] 데이터 마이닝 기반 공급망 재난 식별 SaaS\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/DAT10/DAT10_solution.ipynb)\n", + "\n", + "---\n", + "\n", + "## 📌 1. 프로젝트 개요 및 배경\n", + "\n", + "| 항목 | 내용 |\n", + "| :--- | :--- |\n", + "| **과정 분류** | 데이터·자동화·업무플랫폼 (Level 3) |\n", + "| **학습 소요 시간** | 10개 미션 (5일 / 총 27.5시간) |\n", + "| **환경 사양** | Python 3.10+, numpy, pandas, matplotlib, scipy, scikit-learn |\n", + "\n", + "### 📖 프로젝트 목표\n", + "전시에는 통신망이 끊긴 채 보급소가 입고와 불출을 각자 기록하고, 복구 후 여러 기록을 합치는 과정에서 중복·누락·충돌·위변조가 생기며, 이를 놓치면 재고 판단과 업체 공급 이상 징후 식별이 늦어진다. 본 프로젝트는 방산 공급망 회복력 연구(미국 방위산업기반 정책연구)의 문제의식을 교육용 가상 자료로 재구성해, 가상 보급소 5곳이 오프라인으로 재고를 기록하고 동기화하는 SaaS를 만들고 동기화된 자료에서 공급망 재난 징후(업체 공급 감소, 재고 소진 임박)를 식별하는 과제이다. 요구사항·스키마, 오프라인 입력 전처리, P2P/USB 교환, CRDT 동기화와 충돌 탐지, 무결성 검증, 대시보드, 군수관 HITL 병합, 7일 단절 후 일괄 동기화 스트레스, 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. 전술 통신 두절 환경에서의 야전 군수 재고 관리 요구사항 분석 및 로컬 DB 스키마 설계\n", + "**소요시간**: 약 1.5시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "통신 두절 환경에서의 야전 재고 관리 요구사항을 분석하고 로컬 DB 스키마를 설계한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **통신이 끊긴 환경에서 보급소가 입고·불출·조정을 기록하는 오프라인 재고 관리의 요구사항 12개를 정리하고, 이를 만족하는 트랜잭션 스키마(필드 12개)와 로컬 DB 구조를 설계한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **가상 보급소 5곳과 품목 12종의 트랜잭션(ID·보급소·품목·구분·수량·시각·상황·업체·논리 시계·동기화 상태·체크섬·비고) 필드를 자료형·단위·허용 범위와 함께 정의하고, ID는 노드 접두어와 일련번호로 만들어 중복 입력을 막는 멱등 키로 쓴다 과정을 진행하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `data/schema/dat10_input_schema.json 및 docs/dat10_data_spec.md` 형태의 결과물을 완성한다.\n", + "\n", + "### 🔍 평가 및 검증 기준 (PASS 조건)\n", + "#### 미션 판정\n", + "\n", + "**평가 구분:** 필수\n", + "\n", + "**핵심 평가영역:** 데이터·자동화·업무플랫폼 실무 및 공급망 재난 식별 데이터마이닝 SaaS(방산 공급망 회복력 연구) — 미국 방위산업기반(Defense Industrial Base) 정책연구 전장 환경과의 군사적 부합성\n", + "\n", + "#### 미션 PASS 조건\n", + "\n", + "- 정량 기준: 스키마 필드 정의 완성도 100%, 샘플 데이터 검증 오류 0건\n", + "\n", + "\n", + "- 진행 기준: 입력 데이터 유효성 검증 스크립트 통과 및 명세서 문서화 완료\n", + "\n", + "\n", + "- 검증 방법: JSON Schema 검증 도구 및 샘플 데이터 파싱 단위 테스트\n", + "\n", + "\n", + "#### 채점자 판정 메모\n", + "\n", + "> 단순 코드 실행 여부뿐만 아니라 출력된 수치와 근거 자료가 군 실무 요구사항에 부합하는지 면밀히 확인한다.\n", + "\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "\"\"\"통신 두절 환경의 야전 보급소 재고·공급망 자료 요구사항 12개를 정의하고, 로컬 DB(SQLite 메모리 DB) 스키마와 트랜잭션 레코드 12개 필드의 검증 규칙을 만들어 정상 표본 검증 오류 0건·스키마 정의 완성도 100%·제약 조건(중복·음수·논리) 시험을 확인하며 오류 주입 검출력을 점검한다.\"\"\"\n", + "import hashlib\n", + "import json\n", + "import re\n", + "import sqlite3\n", + "import numpy as np\n", + "import pandas as pd\n", + "import matplotlib.pyplot as plt\n", + "\n", + "# ---------------------------------------------------------------------------\n", + "# [교육용가상자료] 가상 야전 보급소·업체·품목 자료 생성기 (실제 부대·업체·품목·재고 정보 없음)\n", + "# 가상 보급소 5곳이 품목 12종의 입고·불출·조정 트랜잭션을 오프라인으로 기록하고 나중에 동기화한다.\n", + "# 공급망 재난 징후(업체 납기 지연, 품목 재고 급감)는 동기화된 자료를 마이닝해 식별한다.\n", + "# ---------------------------------------------------------------------------\n", + "NODES = [\"보급소A\", \"보급소B\", \"보급소C\", \"보급소D\", \"보급소E\"]\n", + "SUPPLIERS = [\"가상업체1\", \"가상업체2\", \"가상업체3\", \"가상업체4\", \"가상업체5\", \"가상업체6\"]\n", + "CATS = [\"일반보급\", \"정비부품\", \"의무소모\"]\n", + "ITEMS = [{\"item\": f\"I{i + 1:02d}\", \"cat\": CATS[i % 3], \"supplier\": SUPPLIERS[i % 6], \"lead_days\": 3 + (i * 5) % 12, \"use\": 4 + (i * 7) % 11, \"min_level\": 30 + 10 * (i % 5)} for i in range(12)]\n", + "ITEM_IDS = [x[\"item\"] for x in ITEMS]\n", + "CTXS = [\"평시\", \"야간\", \"통신불량\"]\n", + "\n", + "\n", + "def make_txns(seed=7, days=7, per_node_day=60, nodes=None):\n", + " \"\"\"노드별 오프라인 트랜잭션 목록. id는 노드-일련번호(멱등 키), t는 시각(시간 단위), ctx는 입력 상황.\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " out = []\n", + " for n in (nodes or NODES):\n", + " seq = 0\n", + " for k in range(days * per_node_day):\n", + " seq += 1\n", + " it = ITEMS[int(rng.integers(len(ITEMS)))]\n", + " t = float(rng.uniform(0, 24 * days))\n", + " ctx = \"야간\" if (t % 24 >= 20 or t % 24 < 5) else \"평시\"\n", + " if rng.random() < 0.12:\n", + " ctx = \"통신불량\"\n", + " r = rng.random()\n", + " if r < 0.22:\n", + " op, q = \"in\", int(it[\"use\"] * rng.uniform(4, 9))\n", + " elif r < 0.95:\n", + " op, q = \"out\", int(max(1, rng.normal(it[\"use\"] * 1.5, 3)))\n", + " else:\n", + " op, q = \"adjust\", int(rng.integers(-5, 6))\n", + " out.append({\"id\": f\"{n}-{seq:05d}\", \"node\": n, \"item\": it[\"item\"], \"op\": op, \"qty\": q, \"t\": round(t, 2), \"ctx\": ctx, \"supplier\": it[\"supplier\"]})\n", + " return out\n", + "\n", + "\n", + "def stock_of(txns, base=500):\n", + " \"\"\"품목별 재고 = 기초 재고 + 입고 - 불출 + 조정 (id 중복은 한 번만 반영).\"\"\"\n", + " # [TODO : 학생 실습 1] 전술 통신 두절 환경에서의 야전 군수 재고 관리 요구사항 분석 및 로컬 DB 스키마 설계 관련 핵심 로직을 구현하세요.\n", + " # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n", + " # <<< 여기에 코드를 작성하세요 >>>\n", + " pass # 작성 후 pass를 제거하세요.\n", + "SCHEMA = {\n", + " \"id\": {\"type\": \"string\", \"pattern\": r\"^보급소[A-E]-\\d{5}$\", \"unit\": \"-\", \"desc\": \"트랜잭션 ID(노드-일련번호, 중복 입력 방지 키)\"},\n", + " \"node\": {\"type\": \"string\", \"enum\": NODES, \"unit\": \"-\", \"desc\": \"기록한 가상 보급소\"},\n", + " \"item\": {\"type\": \"string\", \"enum\": ITEM_IDS, \"unit\": \"-\", \"desc\": \"품목 번호(가상)\"},\n", + " \"op\": {\"type\": \"string\", \"enum\": [\"in\", \"out\", \"adjust\"], \"unit\": \"-\", \"desc\": \"입고·불출·조정\"},\n", + " \"qty\": {\"type\": \"integer\", \"min\": -50, \"max\": 5000, \"unit\": \"개\", \"desc\": \"수량(조정은 음수 허용)\"},\n", + " \"t\": {\"type\": \"number\", \"min\": 0, \"max\": 24 * 30, \"unit\": \"시간\", \"desc\": \"발생 시각(기준 시점부터 경과 시간)\"},\n", + " \"ctx\": {\"type\": \"string\", \"enum\": CTXS, \"unit\": \"-\", \"desc\": \"입력 상황(평시·야간·통신불량)\"},\n", + " \"supplier\": {\"type\": \"string\", \"enum\": SUPPLIERS, \"unit\": \"-\", \"desc\": \"가상 업체\"},\n", + " \"lamport\": {\"type\": \"integer\", \"min\": 0, \"max\": 10 ** 7, \"unit\": \"-\", \"desc\": \"논리 시계(노드별 증가)\"},\n", + " \"sync_state\": {\"type\": \"string\", \"enum\": [\"local\", \"sent\", \"acked\"], \"unit\": \"-\", \"desc\": \"동기화 상태\"},\n", + " \"checksum\": {\"type\": \"string\", \"pattern\": r\"^[0-9a-f]{8}$\", \"unit\": \"-\", \"desc\": \"위변조 점검용 해시 앞 8자리\"},\n", + " \"note\": {\"type\": \"string\", \"maxlen\": 40, \"unit\": \"-\", \"desc\": \"비고(자유 문장, 개인 정보 입력 금지)\"},\n", + "}\n", + "\n", + "\n", + "def seal(x, lamport):\n", + " # [TODO : 학생 실습 1] 전술 통신 두절 환경에서의 야전 군수 재고 관리 요구사항 분석 및 로컬 DB 스키마 설계 관련 핵심 로직을 구현하세요.\n", + " # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n", + " # <<< 여기에 코드를 작성하세요 >>>\n", + " pass # 작성 후 pass를 제거하세요.\n", + "def check(rec):\n", + " errs = []\n", + " for k, r in SCHEMA.items():\n", + " if k not in rec or rec[k] is None:\n", + " errs.append(f\"{k}: 필수 값 누락\"); continue\n", + " v = rec[k]\n", + " ok = {\"string\": isinstance(v, str), \"integer\": isinstance(v, (int, np.integer)) and not isinstance(v, bool), \"number\": isinstance(v, (int, float, np.number)) and not isinstance(v, bool)}[r[\"type\"]]\n", + " if not ok:\n", + " errs.append(f\"{k}: 자료형 오류({type(v).__name__})\"); continue\n", + " if \"enum\" in r and v not in r[\"enum\"]:\n", + " errs.append(f\"{k}: 허용되지 않은 값 '{v}'\")\n", + " if \"min\" in r and v < r[\"min\"]:\n", + " errs.append(f\"{k}: 최소값 {r['min']} 미만\")\n", + " if \"max\" in r and v > r[\"max\"]:\n", + " errs.append(f\"{k}: 최대값 {r['max']} 초과\")\n", + " if \"pattern\" in r and not re.match(r[\"pattern\"], v):\n", + " errs.append(f\"{k}: 형식 불일치 '{v}'\")\n", + " if \"maxlen\" in r and len(v) > r[\"maxlen\"]:\n", + " errs.append(f\"{k}: 길이 {r['maxlen']} 초과\")\n", + " if not errs:\n", + " if rec[\"id\"].split(\"-\")[0] != rec[\"node\"]:\n", + " errs.append(\"id: 노드 접두어와 node 불일치\")\n", + " if rec[\"op\"] in (\"in\", \"out\") and rec[\"qty\"] <= 0:\n", + " errs.append(\"qty: 입고·불출 수량은 1 이상\")\n", + " if re.search(r\"\\d{6}-\\d{7}|군번|주민\", rec[\"note\"]):\n", + " errs.append(\"note: 개인 식별 정보 의심 문구\")\n", + " return errs\n", + "\n", + "\n", + "txns = make_txns(7, 7)\n", + "recs = [seal(x, i + 1) for i, x in enumerate(txns)]\n", + "bad = sum(bool(check(r)) for r in recs)\n", + "print(f\"[스키마] 필드 {len(SCHEMA)}개, type·unit·desc 모두 정의된 필드 {sum(all(k in v for k in ('type', 'unit', 'desc')) for v in SCHEMA.values())}개 (정의 완성도 100%)\")\n", + "print(f\"[정상 자료 검증] 트랜잭션 {len(recs)}건 중 오류 {bad}건 (목표 오류 0건: {'달성' if bad == 0 else '미달'}), 개인 식별 필드 0개\")\n", + "\n", + "# 2) 오류 주입 시험\n", + "r0 = recs[10]\n", + "inj = {\"자료형 오류(qty 문자)\": dict(r0, qty=\"많이\"), \"허용값 오류(품목)\": dict(r0, item=\"I99\"), \"형식 오류(ID)\": dict(r0, id=\"A-1\"), \"범위 오류(시각)\": dict(r0, t=-3.0),\n", + " \"논리 오류(불출 0개)\": dict(r0, op=\"out\", qty=0), \"논리 오류(ID 접두어)\": dict(r0, node=\"보급소B\", id=\"보급소A-00011\"), \"필드 누락\": {k: v for k, v in r0.items() if k != \"checksum\"},\n", + " \"개인정보 의심 문구\": dict(r0, note=\"군번 입력\")}\n", + "print(\"\\n[오류 주입 시험]\")\n", + "caught = 0\n", + "for n, r in inj.items():\n", + " e = check(r); caught += bool(e); print(f\" {n:22s}-> {'검출' if e else '미검출'}: {e[0] if e else ''}\")\n", + "print(f\"주입 오류 {len(inj)}종 중 {caught}종 검출\")\n", + "\n", + "# 3) 로컬 DB 스키마(SQLite 메모리 DB): 중복 키·음수 재고·논리 규칙을 DB가 직접 막는지 시험\n", + "DDL = \"\"\"\n", + "CREATE TABLE item (item TEXT PRIMARY KEY, cat TEXT NOT NULL, supplier TEXT NOT NULL, lead_days INTEGER CHECK (lead_days BETWEEN 1 AND 60), min_level INTEGER NOT NULL);\n", + "CREATE TABLE txn (id TEXT PRIMARY KEY, node TEXT NOT NULL, item TEXT NOT NULL REFERENCES item(item), op TEXT NOT NULL CHECK (op IN ('in','out','adjust')),\n", + " qty INTEGER NOT NULL, t REAL NOT NULL CHECK (t >= 0), ctx TEXT NOT NULL, lamport INTEGER NOT NULL, sync_state TEXT NOT NULL DEFAULT 'local',\n", + " checksum TEXT NOT NULL, CHECK (op = 'adjust' OR qty > 0));\n", + "CREATE TABLE meta_edit (id TEXT PRIMARY KEY, item TEXT NOT NULL REFERENCES item(item), field TEXT NOT NULL, value TEXT NOT NULL, node TEXT NOT NULL, lamport INTEGER NOT NULL);\n", + "CREATE TABLE conflict (id INTEGER PRIMARY KEY AUTOINCREMENT, item TEXT NOT NULL, field TEXT NOT NULL, a TEXT, b TEXT, status TEXT NOT NULL DEFAULT 'PENDING');\n", + "CREATE TABLE audit (seq INTEGER PRIMARY KEY AUTOINCREMENT, event TEXT NOT NULL, who TEXT, detail TEXT, prev_hash TEXT, hash TEXT);\n", + "CREATE VIEW stock AS SELECT item, SUM(CASE WHEN op='out' THEN -qty ELSE qty END) AS delta FROM txn GROUP BY item;\n", + "\"\"\"\n", + "db = sqlite3.connect(\":memory:\")\n", + "db.executescript(DDL)\n", + "db.executemany(\"INSERT INTO item VALUES (?,?,?,?,?)\", [(x[\"item\"], x[\"cat\"], x[\"supplier\"], x[\"lead_days\"], x[\"min_level\"]) for x in ITEMS])\n", + "with db:\n", + " db.executemany(\"INSERT INTO txn VALUES (?,?,?,?,?,?,?,?,?,?)\", [(r[\"id\"], r[\"node\"], r[\"item\"], r[\"op\"], r[\"qty\"], r[\"t\"], r[\"ctx\"], r[\"lamport\"], r[\"sync_state\"], r[\"checksum\"]) for r in recs])\n", + "n_rows = db.execute(\"SELECT COUNT(*) FROM txn\").fetchone()[0]\n", + "print(f\"\\n[로컬 DB] 테이블 5개·뷰 1개 생성, 트랜잭션 {n_rows}건 적재 (스키마 JSON과 행 수 일치: {n_rows == len(recs)})\")\n", + "tests = {\"중복 ID 입력 차단(멱등 키)\": (\"INSERT INTO txn VALUES (?,?,?,?,?,?,?,?,?,?)\", (r0[\"id\"], r0[\"node\"], r0[\"item\"], \"in\", 5, 1.0, \"평시\", 1, \"local\", \"abcd1234\")),\n", + " \"존재하지 않는 품목 차단(외래 키)\": (\"INSERT INTO txn VALUES ('보급소A-99999','보급소A','I99','in',5,1.0,'평시',1,'local','abcd1234')\", ()),\n", + " \"불출 0개 차단(CHECK)\": (\"INSERT INTO txn VALUES ('보급소A-99998','보급소A','I01','out',0,1.0,'평시',1,'local','abcd1234')\", ()),\n", + " \"음수 시각 차단(CHECK)\": (\"INSERT INTO txn VALUES ('보급소A-99997','보급소A','I01','in',5,-1.0,'평시',1,'local','abcd1234')\", ()),\n", + " \"허용되지 않은 op 차단(CHECK)\": (\"INSERT INTO txn VALUES ('보급소A-99996','보급소A','I01','sell',5,1.0,'평시',1,'local','abcd1234')\", ())}\n", + "db.execute(\"PRAGMA foreign_keys = ON\")\n", + "blocked = 0\n", + "for n, (sql, args) in tests.items():\n", + " try:\n", + " with db:\n", + " db.execute(sql, args)\n", + " print(f\" {n:28s}-> 통과(결함)\")\n", + " except sqlite3.IntegrityError as e:\n", + " blocked += 1; print(f\" {n:28s}-> 차단: {str(e)[:48]}\")\n", + "print(f\"제약 조건 시험 {blocked}/{len(tests)} 차단, 롤백 후 행 수 {db.execute('SELECT COUNT(*) FROM txn').fetchone()[0]}건 유지\")\n", + "\n", + "# 4) 요구사항 12개와 검증 결과(코드 시험으로 확인된 항목은 PASS)\n", + "names = {r[0] for r in db.execute(\"SELECT name FROM sqlite_master\")}\n", + "cols = {r[1] for r in db.execute(\"PRAGMA table_info(txn)\")}\n", + "REQ = [(\"R01\", \"가용성\", \"통신이 끊겨도 단말 로컬 DB에 입고·불출을 기록할 수 있다\", \"메모리 DB 적재\", n_rows == len(recs)), (\"R02\", \"무결성\", \"같은 입력이 두 번 들어와도 한 번만 반영한다\", \"ID 중복 차단\", blocked >= 1),\n", + " (\"R03\", \"무결성\", \"불출 수량 0·잘못된 구분 값은 저장되지 않는다\", \"CHECK 제약\", blocked >= 3), (\"R04\", \"무결성\", \"존재하지 않는 품목은 입력할 수 없다\", \"외래 키\", blocked >= 2),\n", + " (\"R05\", \"무결성\", \"레코드마다 위변조 점검 해시를 둔다\", \"checksum 필드\", all(re.match(r\"^[0-9a-f]{8}$\", r[\"checksum\"]) for r in recs)), (\"R06\", \"동기화\", \"동기화 상태(local/sent/acked)를 레코드에 기록한다\", \"sync_state 필드\", \"sync_state\" in cols),\n", + " (\"R07\", \"동기화\", \"노드별 논리 시계로 선후 관계를 비교한다\", \"lamport 필드\", all(r[\"lamport\"] > 0 for r in recs)), (\"R08\", \"동기화\", \"같은 필드를 동시에 고친 충돌은 보류 목록에 남긴다\", \"conflict 테이블\", \"conflict\" in names),\n", + " (\"R09\", \"보안\", \"변경·동기화·승인 행위는 해시가 이어진 감사 로그에 남긴다\", \"audit 테이블\", \"audit\" in names), (\"R10\", \"보안\", \"개인 식별 정보는 입력하지 않는다\", \"note 점검\", caught == len(inj)),\n", + " (\"R11\", \"운영\", \"입력 오류는 사유와 함께 즉시 알려 준다\", \"검증 오류 메시지\", caught >= 6), (\"R12\", \"운영\", \"동기화 후 재고를 업체·품목별로 마이닝해 공급망 재난 징후를 식별한다\", \"stock 뷰\", db.execute(\"SELECT COUNT(*) FROM stock\").fetchone()[0] == 12)]\n", + "print(\"\\n[요구사항 검증표]\")\n", + "for i, c, d, m, ok in REQ:\n", + " print(f\" {i} [{c}] {d[:34]:36s} {m:12s} {'PASS' if ok else 'FAIL'}\")\n", + "print(f\"요구사항 {sum(x[4] for x in REQ)}/{len(REQ)} 충족\")\n", + "js = json.dumps({\"title\": \"dat10_input_schema\", \"fields\": {k: {kk: vv for kk, vv in v.items() if kk != 'desc'} for k, v in list(SCHEMA.items())[:2]}}, ensure_ascii=False)\n", + "print(\"스키마 JSON(앞 2개 필드):\", js[:140], \"...\")\n", + "print(\"[입출력] 입력: 보급소 트랜잭션(12필드) / 출력: 동기화된 재고, 충돌 목록, 공급망 재난 징후, 감사 로그\")\n", + "print(\"※ 가상 자료이며 실제 재고·업체 정보는 사용하지 않는다. 실제 단말 DB는 암호화 저장 등 보안 요건을 별도로 충족해야 한다.\")\n", + "\n", + "st = stock_of(txns)\n", + "plt.figure(figsize=(8, 3.8)); plt.bar(list(st), list(st.values()), color=\"tab:blue\"); plt.title(\"Synthetic stock by item after 7 days (all nodes merged)\"); plt.xlabel(\"item\"); plt.ylabel(\"units\"); plt.tight_layout(); plt.show()\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 01 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + "[스키마] 필드 12개, type·unit·desc 모두 정의된 필드 12개 (정의 완성도 100%)\n", + "[정상 자료 검증] 트랜잭션 2100건 중 오류 0건 (목표 오류 0건: 달성), 개인 식별 필드 0개\n", + "\n", + "[오류 주입 시험]\n", + " 자료형 오류(qty 문자) -> 검출: qty: 자료형 오류(str)\n", + " 허용값 오류(품목) -> 검출: item: 허용되지 않은 값 'I99'\n", + " 형식 오류(ID) -> 검출: id: 형식 불일치 'A-1'\n", + " 범위 오류(시각) -> 검출: t: 최소값 0 미만\n", + " 논리 오류(불출 0개) -> 검출: qty: 입고·불출 수량은 1 이상\n", + " 논리 오류(ID 접두어) -> 검출: id: 노드 접두어와 node 불일치\n", + " 필드 누락 -> 검출: checksum: 필수 값 누락\n", + " 개인정보 의심 문구 -> 검출: note: 개인 식별 정보 의심 문구\n", + "주입 오류 8종 중 8종 검출\n", + "\n", + "[로컬 DB] 테이블 5개·뷰 1개 생성, 트랜잭션 2100건 적재 (스키마 JSON과 행 수 일치: True)\n", + " 중복 ID 입력 차단(멱등 키) -> 차단: UNIQUE constraint failed: txn.id\n", + " 존재하지 않는 품목 차단(외래 키) -> 차단: FOREIGN KEY constraint failed\n", + " 불출 0개 차단(CHECK) -> 차단: CHECK constraint failed: op = 'adjust' OR qty > \n", + " 음수 시각 차단(CHECK) -> 차단: CHECK constraint failed: t >= 0\n", + " 허용되지 않은 op 차단(CHECK) -> 차단: CHECK constraint failed: op IN ('in','out','adju\n", + "제약 조건 시험 5/5 차단, 롤백 후 행 수 2100건 유지\n", + "\n", + "[요구사항 검증표]\n", + " R01 [가용성] 통신이 끊겨도 단말 로컬 DB에 입고·불출을 기록할 수 있다 메모리 DB 적재 PASS\n", + " R02 [무결성] 같은 입력이 두 번 들어와도 한 번만 반영한다 ID 중복 차단 PASS\n", + " R03 [무결성] 불출 수량 0·잘못된 구분 값은 저장되지 않는다 CHECK 제약 PASS\n", + " R04 [무결성] 존재하지 않는 품목은 입력할 수 없다 외래 키 PASS\n", + " R05 [무결성] 레코드마다 위변조 점검 해시를 둔다 checksum 필드 PASS\n", + " R06 [동기화] 동기화 상태(local/sent/acked)를 레코드에 기록한 sync_state 필드 PASS\n", + " R07 [동기화] 노드별 논리 시계로 선후 관계를 비교한다 lamport 필드 PASS\n", + " R08 [동기화] 같은 필드를 동시에 고친 충돌은 보류 목록에 남긴다 conflict 테이블 PASS\n", + " R09 [보안] 변경·동기화·승인 행위는 해시가 이어진 감사 로그에 남긴다 audit 테이블 PASS\n", + " R10 [보안] 개인 식별 정보는 입력하지 않는다 note 점검 PASS\n", + " R11 [운영] 입력 오류는 사유와 함께 즉시 알려 준다 검증 오류 메시지 PASS\n", + " R12 [운영] 동기화 후 재고를 업체·품목별로 마이닝해 공급망 재난 징후를 stock 뷰 PASS\n", + "요구사항 12/12 충족\n", + "스키마 JSON(앞 2개 필드): {\"title\": \"dat10_input_schema\", \"fields\": {\"id\": {\"type\": \"string\", \"pattern\": \"^보급소[A-E]-\\\\d{5}$\", \"unit\": \"-\"}, \"node\": {\"type\": \"string\", ...\n", + "[입출력] 입력: 보급소 트랜잭션(12필드) / 출력: 동기화된 재고, 충돌 목록, 공급망 재난 징후, 감사 로그\n", + "※ 가상 자료이며 실제 재고·업체 정보는 사용하지 않는다. 실제 단말 DB는 암호화 저장 등 보안 요건을 별도로 충족해야 한다.\n", + "```\n", + "
\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 02. 오프라인 데이터 입력 유효성 검증 및 로컬 SQLite 트랜잭션 전처리 모듈 구현\n", + "**소요시간**: 약 2.0시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "오프라인 데이터 입력 유효성 검증 및 로컬 트랜잭션 전처리 모듈을 구현한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **형식이 섞인 오프라인 입력 줄을 파싱·검증하고 SQLite 트랜잭션으로 안전하게 적재하는 전처리 모듈을 구현해 처리 속도 1,000건/초 이상, 전처리 오류율 0%를 확인한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **ID\\ 과정을 진행하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `src/preprocess/dat10_preprocessor.py 및 전처리 전후 데이터셋` 형태의 결과물을 완성한다.\n", + "\n", + "### 🔍 평가 및 검증 기준 (PASS 조건)\n", + "#### 미션 판정\n", + "\n", + "**평가 구분:** 필수\n", + "\n", + "**핵심 평가영역:** 전술 환경 특유의 노이즈(스푸핑, 반사파, 센서 단절 등) 제거 타당성\n", + "\n", + "#### 미션 PASS 조건\n", + "\n", + "- 정량 기준: 데이터 정제 처리 속도 1,000건/초 이상, 전처리 오류율 0%\n", + "\n", + "\n", + "- 진행 기준: 정제 파이프라인 자동화 및 단위 테스트 케이스 5종 통과\n", + "\n", + "\n", + "- 검증 방법: pytest 기반 전처리 정합성 테스트 및 결측치 보정율 확인\n", + "\n", + "\n", + "#### 채점자 판정 메모\n", + "\n", + "> 단순 코드 실행 여부뿐만 아니라 출력된 수치와 근거 자료가 군 실무 요구사항에 부합하는지 면밀히 확인한다.\n", + "\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "\"\"\"서식이 서로 다른 오프라인 입력 문자열 8,000줄(문장형·CSV·JSON·키=값 4종)을 파싱·검증해 SQLite 메모리 DB에 배치 트랜잭션으로 적재하고, 입력 구분(입고·불출·조정)별 동적 임계값으로 이상 수량을 격리하며 중복 재전송 멱등 처리·배치 실패 롤백·처리 속도(건/초)·정답 대조 오류율을 측정한다.\"\"\"\n", + "import json\n", + "import re\n", + "import sqlite3\n", + "import time\n", + "import numpy as np\n", + "import pandas as pd\n", + "import matplotlib.pyplot as plt\n", + "\n", + "# ---------------------------------------------------------------------------\n", + "# [교육용가상자료] 가상 야전 보급소·업체·품목 자료 생성기 (실제 부대·업체·품목·재고 정보 없음)\n", + "# 가상 보급소 5곳이 품목 12종의 입고·불출·조정 트랜잭션을 오프라인으로 기록하고 나중에 동기화한다.\n", + "# 공급망 재난 징후(업체 납기 지연, 품목 재고 급감)는 동기화된 자료를 마이닝해 식별한다.\n", + "# ---------------------------------------------------------------------------\n", + "NODES = [\"보급소A\", \"보급소B\", \"보급소C\", \"보급소D\", \"보급소E\"]\n", + "SUPPLIERS = [\"가상업체1\", \"가상업체2\", \"가상업체3\", \"가상업체4\", \"가상업체5\", \"가상업체6\"]\n", + "CATS = [\"일반보급\", \"정비부품\", \"의무소모\"]\n", + "ITEMS = [{\"item\": f\"I{i + 1:02d}\", \"cat\": CATS[i % 3], \"supplier\": SUPPLIERS[i % 6], \"lead_days\": 3 + (i * 5) % 12, \"use\": 4 + (i * 7) % 11, \"min_level\": 30 + 10 * (i % 5)} for i in range(12)]\n", + "ITEM_IDS = [x[\"item\"] for x in ITEMS]\n", + "CTXS = [\"평시\", \"야간\", \"통신불량\"]\n", + "\n", + "\n", + "def make_txns(seed=7, days=7, per_node_day=60, nodes=None):\n", + " \"\"\"노드별 오프라인 트랜잭션 목록. id는 노드-일련번호(멱등 키), t는 시각(시간 단위), ctx는 입력 상황.\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " out = []\n", + " for n in (nodes or NODES):\n", + " seq = 0\n", + " for k in range(days * per_node_day):\n", + " seq += 1\n", + " it = ITEMS[int(rng.integers(len(ITEMS)))]\n", + " t = float(rng.uniform(0, 24 * days))\n", + " ctx = \"야간\" if (t % 24 >= 20 or t % 24 < 5) else \"평시\"\n", + " if rng.random() < 0.12:\n", + " ctx = \"통신불량\"\n", + " r = rng.random()\n", + " if r < 0.22:\n", + " op, q = \"in\", int(it[\"use\"] * rng.uniform(4, 9))\n", + " elif r < 0.95:\n", + " op, q = \"out\", int(max(1, rng.normal(it[\"use\"] * 1.5, 3)))\n", + " else:\n", + " op, q = \"adjust\", int(rng.integers(-5, 6))\n", + " out.append({\"id\": f\"{n}-{seq:05d}\", \"node\": n, \"item\": it[\"item\"], \"op\": op, \"qty\": q, \"t\": round(t, 2), \"ctx\": ctx, \"supplier\": it[\"supplier\"]})\n", + " return out\n", + "\n", + "\n", + "def stock_of(txns, base=500):\n", + " \"\"\"품목별 재고 = 기초 재고 + 입고 - 불출 + 조정 (id 중복은 한 번만 반영).\"\"\"\n", + " # [TODO : 학생 실습 2] 오프라인 데이터 입력 유효성 검증 및 로컬 SQLite 트랜잭션 전처리 모듈 구현 관련 핵심 로직을 구현하세요.\n", + " # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n", + " # <<< 여기에 코드를 작성하세요 >>>\n", + " pass # 작성 후 pass를 제거하세요.\n", + "rng = np.random.default_rng(21)\n", + "base = make_txns(31, 7, per_node_day=230) # 5노드 x 7일 x 230건 = 8,050건\n", + "OPK = {\"in\": \"입고\", \"out\": \"불출\", \"adjust\": \"조정\"}\n", + "lines, truth = [], {}\n", + "for i, x in enumerate(base):\n", + " f = i % 4\n", + " if f == 0:\n", + " s = f\"[입력] {x['node']} 품목{x['item']} {OPK[x['op']]} {x['qty']}개 ({x['ctx']}) {x['t']}h #{x['id']}\"\n", + " elif f == 1:\n", + " s = f\"{x['node']},{x['item']},{x['op']},{x['qty']},{x['t']},{x['ctx']},{x['id']}\"\n", + " elif f == 2:\n", + " s = json.dumps({k: x[k] for k in (\"node\", \"item\", \"op\", \"qty\", \"t\", \"ctx\", \"id\")}, ensure_ascii=False)\n", + " else:\n", + " s = f\"품목 {x['item']} / {OPK[x['op']]} / {x['qty']}개 / {x['node']} / t={x['t']} / ctx={x['ctx']} / id={x['id']}\"\n", + " lines.append(s); truth[x[\"id\"]] = x\n", + "noise_ids = set()\n", + "for i in rng.choice(len(lines), 120, replace=False): # 잡음 주입: 이상 수량 40, 깨진 줄 40, 존재하지 않는 품목 40\n", + " x = base[i]\n", + " if i % 3 == 0:\n", + " lines[i] = lines[i].replace(str(x[\"qty\"]), \"9999\", 1) if x[\"op\"] != \"adjust\" else lines[i]; noise_ids.add(x[\"id\"]) if \"9999\" in lines[i] else None\n", + " elif i % 3 == 1:\n", + " lines[i] = lines[i][: len(lines[i]) // 2]; noise_ids.add(x[\"id\"])\n", + " else:\n", + " lines[i] = lines[i].replace(x[\"item\"], \"I99\"); noise_ids.add(x[\"id\"])\n", + "dups = [lines[i] for i in rng.choice(len(lines), 200, replace=False)] # 통신 복구 후 재전송된 중복 줄 200개\n", + "lines = lines + dups\n", + "\n", + "# ---------------------------------------------------------------------------\n", + "# 파서: 서식별 정규식 -> 표준 레코드 (해석 실패는 예외)\n", + "# ---------------------------------------------------------------------------\n", + "IV = {v: k for k, v in OPK.items()}\n", + "P1 = re.compile(r\"\\[입력\\]\\s*(보급소[A-E])\\s*품목(I\\d{2})\\s*(입고|불출|조정)\\s*(-?\\d+)개\\s*\\((\\S+)\\)\\s*([\\d.]+)h\\s*#(\\S+)\")\n", + "P4 = re.compile(r\"품목\\s*(I\\d{2})\\s*/\\s*(입고|불출|조정)\\s*/\\s*(-?\\d+)개\\s*/\\s*(보급소[A-E])\\s*/\\s*t=([\\d.]+)\\s*/\\s*ctx=(\\S+)\\s*/\\s*id=(\\S+)\")\n", + "\n", + "\n", + "def parse(s):\n", + " # [TODO : 학생 실습 2] 오프라인 데이터 입력 유효성 검증 및 로컬 SQLite 트랜잭션 전처리 모듈 구현 관련 핵심 로직을 구현하세요.\n", + " # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n", + " # <<< 여기에 코드를 작성하세요 >>>\n", + " pass # 작성 후 pass를 제거하세요.\n", + "def valid(r):\n", + " if r[\"item\"] not in ITEM_IDS or r[\"node\"] not in NODES or r[\"op\"] not in OPK or r[\"ctx\"] not in CTXS:\n", + " return \"허용값 오류\"\n", + " if r[\"op\"] != \"adjust\" and r[\"qty\"] <= 0:\n", + " return \"수량 오류\"\n", + " if not re.match(r\"^보급소[A-E]-\\d{5}$\", r[\"id\"]) or not r[\"id\"].startswith(r[\"node\"]):\n", + " return \"ID 오류\"\n", + " return None\n", + "\n", + "\n", + "# 1) 파싱 + 검증 + 동적 임계값\n", + "t0 = time.perf_counter()\n", + "parsed, rejected = [], []\n", + "for s in lines:\n", + " try:\n", + " r = parse(s); e = valid(r)\n", + " (rejected if e else parsed).append((r, e) if e else r)\n", + " except (ValueError, KeyError, json.JSONDecodeError, IndexError) as ex:\n", + " rejected.append((s, \"해석 실패\"))\n", + "df = pd.DataFrame(parsed)\n", + "before = len(df)\n", + "df = df.drop_duplicates(\"id\")\n", + "th = {}\n", + "for op, g in df[df[\"op\"] != \"adjust\"].groupby(\"op\"):\n", + " med = g[\"qty\"].median(); mad = (g[\"qty\"] - med).abs().median() * 1.4826; th[op] = (max(med - 6 * mad, 1), med + 6 * mad)\n", + "fixed = (df[\"op\"] != \"adjust\") & (df[\"qty\"] > 100)\n", + "dyn = pd.Series([(op != \"adjust\") and not (th[op][0] <= q <= th[op][1]) for op, q in zip(df[\"op\"], df[\"qty\"])], index=df.index)\n", + "susp = dyn.to_numpy()\n", + "clean = df[~dyn].copy()\n", + "dt_parse = time.perf_counter() - t0\n", + "rej_cnt = pd.Series([e for _, e in rejected]).value_counts().to_dict()\n", + "print(f\"[파싱·검증] 입력 {len(lines)}줄(재전송 중복 200 포함): 해석 실패·허용값 오류 {len(rejected)}줄 {rej_cnt}, 통과 {before}줄, 중복 ID {before - len(df)}줄 제거\")\n", + "print(\"구분별 동적 임계값(수량):\", \", \".join(f\"{OPK[k]} {lo:.0f}~{hi:.0f}\" for k, (lo, hi) in th.items()))\n", + "true_bad = df[\"id\"].isin(noise_ids).to_numpy()\n", + "for name, fl in [(\"고정 임계값(100개 초과)\", fixed.to_numpy()), (\"구분별 동적 임계값\", susp)]:\n", + " print(f\" {name:20s}: 이상 판정 {int(fl.sum())}건, 이상 수량 {int((fl & true_bad).sum())}/{int(true_bad.sum())} 검출, 정상 입고인데 이상 판정(오탐) {int((fl & ~true_bad).sum())}건\")\n", + "\n", + "# 2) SQLite 배치 트랜잭션 적재(멱등: 같은 ID는 한 번만), 배치 실패 시 롤백 후 행 단위로 격리\n", + "db = sqlite3.connect(\":memory:\")\n", + "db.execute(\"CREATE TABLE txn (id TEXT PRIMARY KEY, node TEXT NOT NULL, item TEXT NOT NULL, op TEXT NOT NULL CHECK (op IN ('in','out','adjust')), qty INTEGER NOT NULL, t REAL NOT NULL CHECK (t >= 0), ctx TEXT NOT NULL)\")\n", + "db.execute(\"CREATE TABLE quarantine (id TEXT, reason TEXT)\")\n", + "rows = [(r[\"id\"], r[\"node\"], r[\"item\"], r[\"op\"], int(r[\"qty\"]), float(r[\"t\"]), r[\"ctx\"]) for r in clean.to_dict(\"records\")]\n", + "rows[777] = (rows[777][0], rows[777][1], rows[777][2], \"sell\", 5, 1.0, \"평시\") # 배치 안에 제약 위반 1건 주입\n", + "t1 = time.perf_counter(); inserted = rolled = 0\n", + "for b in range(0, len(rows), 500):\n", + " batch = rows[b:b + 500]\n", + " try:\n", + " with db:\n", + " db.executemany(\"INSERT INTO txn VALUES (?,?,?,?,?,?,?) ON CONFLICT(id) DO NOTHING\", batch)\n", + " inserted += len(batch)\n", + " except sqlite3.IntegrityError:\n", + " rolled += 1 # 배치 전체 롤백 후 행 단위 재시도\n", + " for r in batch:\n", + " try:\n", + " with db:\n", + " db.execute(\"INSERT INTO txn VALUES (?,?,?,?,?,?,?) ON CONFLICT(id) DO NOTHING\", r)\n", + " inserted += 1\n", + " except sqlite3.IntegrityError as e:\n", + " with db:\n", + " db.execute(\"INSERT INTO quarantine VALUES (?,?)\", (r[0], \"제약 위반\"))\n", + "dt_db = time.perf_counter() - t1\n", + "n_db = db.execute(\"SELECT COUNT(*) FROM txn\").fetchone()[0]\n", + "print(f\"\\n[SQLite 적재] 500건 배치 {len(rows) // 500 + 1}개: 롤백 {rolled}회(제약 위반 1건 -> 행 단위 격리), 적재 {n_db}건, 격리 테이블 {db.execute('SELECT COUNT(*) FROM quarantine').fetchone()[0]}건\")\n", + "db.execute(\"INSERT INTO txn VALUES (?,?,?,?,?,?,?) ON CONFLICT(id) DO NOTHING\", rows[0]); n2 = db.execute(\"SELECT COUNT(*) FROM txn\").fetchone()[0]\n", + "print(f\"같은 ID를 다시 넣어도 행 수 {n2}건 유지(멱등)\")\n", + "\n", + "# 3) 정답 대조: 적재된 행의 필드가 원본 트랜잭션과 같은가, 속도\n", + "errs = 0; chk = 0\n", + "for (i, node, item, op, qty, t, ctx) in db.execute(\"SELECT * FROM txn\"):\n", + " x = truth[i]; chk += 1\n", + " errs += (x[\"node\"], x[\"item\"], x[\"op\"], x[\"qty\"], x[\"ctx\"]) != (node, item, op, qty, ctx) or abs(x[\"t\"] - t) > 1e-6\n", + "total = dt_parse + dt_db\n", + "print(f\"\\n[전처리 정확도] 적재 {chk}건 중 원본과 불일치 {errs}건 -> 전처리 오류율 {errs / chk:.2%} (목표 0%: {'달성' if errs == 0 else '미달'})\")\n", + "print(f\"처리 속도 {len(lines) / total:,.0f}건/초 (파싱·검증 {dt_parse:.2f}초 + DB 적재 {dt_db:.2f}초, 목표 1,000건/초 이상: {'달성' if len(lines) / total >= 1000 else '미달'})\")\n", + "print(f\"원본 {len(base)}건 중 잡음 주입 {len(noise_ids)}건과 격리 {int(susp.sum())}건을 제외하고 적재 {n_db}건 -> 격리된 건은 담당자가 원본 확인 후 재입력\")\n", + "print(\"※ 이상 판정된 줄은 삭제하지 않고 격리하며, 임계값은 입력 구분별 중앙값±6MAD로 자료에서 조정한다. 실제 서식에 맞는 정규식은 현장에서 보완한다.\")\n", + "\n", + "plt.figure(figsize=(8, 4))\n", + "for op, g in df[df[\"op\"] != \"adjust\"].groupby(\"op\"):\n", + " plt.hist(g[\"qty\"].clip(upper=200), bins=40, alpha=.5, label=op)\n", + "plt.axvline(100, color=\"k\", ls=\"--\"); plt.legend(); plt.xlabel(\"qty\"); plt.title(\"Quantity by operation (dashed: fixed threshold 100)\"); plt.tight_layout(); plt.show()\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 02 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + "[파싱·검증] 입력 8250줄(재전송 중복 200 포함): 해석 실패·허용값 오류 84줄 {'허용값 오류': 44, '해석 실패': 40}, 통과 8166줄, 중복 ID 198줄 제거\n", + "구분별 동적 임계값(수량): 입고 1~221, 불출 1~56\n", + " 고정 임계값(100개 초과) : 이상 판정 139건, 이상 수량 34/34 검출, 정상 입고인데 이상 판정(오탐) 105건\n", + " 구분별 동적 임계값 : 이상 판정 34건, 이상 수량 34/34 검출, 정상 입고인데 이상 판정(오탐) 0건\n", + "\n", + "[SQLite 적재] 500건 배치 16개: 롤백 1회(제약 위반 1건 -> 행 단위 격리), 적재 7933건, 격리 테이블 1건\n", + "같은 ID를 다시 넣어도 행 수 7933건 유지(멱등)\n", + "\n", + "[전처리 정확도] 적재 7933건 중 원본과 불일치 0건 -> 전처리 오류율 0.00% (목표 0%: 달성)\n", + "처리 속도 48,683건/초 (파싱·검증 0.13초 + DB 적재 0.04초, 목표 1,000건/초 이상: 달성)\n", + "원본 8050건 중 잡음 주입 116건과 격리 34건을 제외하고 적재 7933건 -> 격리된 건은 담당자가 원본 확인 후 재입력\n", + "※ 이상 판정된 줄은 삭제하지 않고 격리하며, 임계값은 입력 구분별 중앙값±6MAD로 자료에서 조정한다. 실제 서식에 맞는 정규식은 현장에서 보완한다.\n", + "```\n", + "
\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 03. 오프라인 단말 간 1차 피어투피어(P2P)/USB 재고 데이터 교환 Baseline 기능 구현\n", + "**소요시간**: 약 2.5시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "오프라인 단말 간 피어투피어(P2P) 재고 데이터 교환 Baseline 기능을 구현한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **보급소 단말 5곳이 접촉할 때 USB·P2P로 재고 트랜잭션을 주고받는 Baseline 교환 기능을 만들고 7일 후 정보 공유 정확도 75% 이상을 확인한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **7일간 접촉 56회(접촉당 방향별 최대 80건, 손실 5%)의 가상 일정에서 상대 상태를 모르는 전체 전송, ID 차집합 델타 전송, 델타에 우선순위(오래된 것 먼저)를 더한 방식을 비교 수행하고 검증하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `src/models/dat10_baseline_model.py 및 기본 추론 결과 보고서` 형태의 결과물을 완성한다.\n", + "\n", + "### 🔍 평가 및 검증 기준 (PASS 조건)\n", + "#### 미션 판정\n", + "\n", + "**평가 구분:** 필수\n", + "\n", + "**핵심 평가영역:** 알고리즘의 논리적 흐름 및 군사적 판단 기준의 투명성\n", + "\n", + "#### 미션 PASS 조건\n", + "\n", + "- 정량 기준: 기본 데이터셋 대상 탐지/처리 정확도 75% 이상 달성\n", + "\n", + "\n", + "- 진행 기준: 단위 기능 동작 확인 및 기본 시나리오 실행 로그 생성\n", + "\n", + "\n", + "- 검증 방법: 기준 벤치마크 데이터셋 대상 추론 결과 오차율 비교\n", + "\n", + "\n", + "#### 채점자 판정 메모\n", + "\n", + "> 단순 코드 실행 여부뿐만 아니라 출력된 수치와 근거 자료가 군 실무 요구사항에 부합하는지 면밀히 확인한다.\n", + "\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "\"\"\"오프라인 보급소 5곳이 끊긴 통신 대신 짧은 P2P 접촉·USB 인편으로 트랜잭션을 교환하는 Baseline을 구현해, 전체 전송·ID 차집합(델타) 전송·델타+우선순위 휴리스틱(내 기록 우선·오래된 것 우선)의 7일 후 동기화 완전성·전송 효율·수렴 시점을 비교하고 우선순위 가중치를 1차 튜닝한다.\"\"\"\n", + "import itertools\n", + "import numpy as np\n", + "import pandas as pd\n", + "import matplotlib.pyplot as plt\n", + "\n", + "# ---------------------------------------------------------------------------\n", + "# [교육용가상자료] 가상 야전 보급소·업체·품목 자료 생성기 (실제 부대·업체·품목·재고 정보 없음)\n", + "# 가상 보급소 5곳이 품목 12종의 입고·불출·조정 트랜잭션을 오프라인으로 기록하고 나중에 동기화한다.\n", + "# 공급망 재난 징후(업체 납기 지연, 품목 재고 급감)는 동기화된 자료를 마이닝해 식별한다.\n", + "# ---------------------------------------------------------------------------\n", + "NODES = [\"보급소A\", \"보급소B\", \"보급소C\", \"보급소D\", \"보급소E\"]\n", + "SUPPLIERS = [\"가상업체1\", \"가상업체2\", \"가상업체3\", \"가상업체4\", \"가상업체5\", \"가상업체6\"]\n", + "CATS = [\"일반보급\", \"정비부품\", \"의무소모\"]\n", + "ITEMS = [{\"item\": f\"I{i + 1:02d}\", \"cat\": CATS[i % 3], \"supplier\": SUPPLIERS[i % 6], \"lead_days\": 3 + (i * 5) % 12, \"use\": 4 + (i * 7) % 11, \"min_level\": 30 + 10 * (i % 5)} for i in range(12)]\n", + "ITEM_IDS = [x[\"item\"] for x in ITEMS]\n", + "CTXS = [\"평시\", \"야간\", \"통신불량\"]\n", + "\n", + "\n", + "def make_txns(seed=7, days=7, per_node_day=60, nodes=None):\n", + " \"\"\"노드별 오프라인 트랜잭션 목록. id는 노드-일련번호(멱등 키), t는 시각(시간 단위), ctx는 입력 상황.\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " out = []\n", + " for n in (nodes or NODES):\n", + " seq = 0\n", + " for k in range(days * per_node_day):\n", + " seq += 1\n", + " it = ITEMS[int(rng.integers(len(ITEMS)))]\n", + " t = float(rng.uniform(0, 24 * days))\n", + " ctx = \"야간\" if (t % 24 >= 20 or t % 24 < 5) else \"평시\"\n", + " if rng.random() < 0.12:\n", + " ctx = \"통신불량\"\n", + " r = rng.random()\n", + " if r < 0.22:\n", + " op, q = \"in\", int(it[\"use\"] * rng.uniform(4, 9))\n", + " elif r < 0.95:\n", + " op, q = \"out\", int(max(1, rng.normal(it[\"use\"] * 1.5, 3)))\n", + " else:\n", + " op, q = \"adjust\", int(rng.integers(-5, 6))\n", + " out.append({\"id\": f\"{n}-{seq:05d}\", \"node\": n, \"item\": it[\"item\"], \"op\": op, \"qty\": q, \"t\": round(t, 2), \"ctx\": ctx, \"supplier\": it[\"supplier\"]})\n", + " return out\n", + "\n", + "\n", + "def stock_of(txns, base=500):\n", + " \"\"\"품목별 재고 = 기초 재고 + 입고 - 불출 + 조정 (id 중복은 한 번만 반영).\"\"\"\n", + " # [TODO : 학생 실습 3] 오프라인 단말 간 1차 피어투피어(P2P)/USB 재고 데이터 교환 Baseline 기능 구현 관련 핵심 로직을 구현하세요.\n", + " # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n", + " # <<< 여기에 코드를 작성하세요 >>>\n", + " pass # 작성 후 pass를 제거하세요.\n", + "TX = make_txns(7, 7, per_node_day=60) # 5노드 x 7일 x 60건 = 2,100건\n", + "N = len(TX); IDX = {x[\"id\"]: i for i, x in enumerate(TX)}\n", + "ORIGIN = np.array([NODES.index(x[\"node\"]) for x in TX]); T = np.array([x[\"t\"] for x in TX])\n", + "CONTACTS_PER_DAY, BW, LOSS = 8, 80, 0.05 # 하루 접촉 8회, 접촉당 방향별 최대 60건, 전송 손실 5%\n", + "\n", + "\n", + "def contact_schedule(seed=3, days=7):\n", + " # [TODO : 학생 실습 3] 오프라인 단말 간 1차 피어투피어(P2P)/USB 재고 데이터 교환 Baseline 기능 구현 관련 핵심 로직을 구현하세요.\n", + " # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n", + " # <<< 여기에 코드를 작성하세요 >>>\n", + " pass # 작성 후 pass를 제거하세요.\n", + "def simulate(method, w_own=0.0, w_age=0.0, seed=3, trace=False):\n", + " r = np.random.default_rng(seed + 100)\n", + " known = [set() for _ in range(5)]; sent = useful = 0; tr = []\n", + " prod = [np.where(ORIGIN == n)[0] for n in range(5)]\n", + " for tm, a, b, _ in contact_schedule(seed):\n", + " for n in (a, b): # 접촉 시점까지 자기 노드가 기록한 트랜잭션\n", + " known[n] |= set(prod[n][T[prod[n]] <= tm].tolist())\n", + " for src, dst in ((a, b), (b, a), ):\n", + " if method == \"full\":\n", + " cand = sorted(known[src], key=lambda i: T[i]) # 상대가 뭘 가졌는지 모르고 전부 보냄(앞에서부터 BW건)\n", + " else:\n", + " cand = [i for i in known[src] if i not in known[dst]] # 상대가 없는 것만(ID 요약 비교)\n", + " if method == \"prio\":\n", + " cand.sort(key=lambda i: -(w_own * (ORIGIN[i] == src) + w_age * (tm - T[i]) / 168))\n", + " else:\n", + " cand.sort(key=lambda i: T[i])\n", + " for i in cand[:BW]:\n", + " sent += 1\n", + " if r.random() > LOSS:\n", + " useful += i not in known[dst]; known[dst].add(i)\n", + " if trace:\n", + " tr.append((tm, np.mean([len(k) for k in known]) / N))\n", + " comp = float(np.mean([len(k) for k in known]) / N)\n", + " return {\"comp\": comp, \"sent\": sent, \"useful\": useful, \"eff\": useful / max(sent, 1), \"full_nodes\": sum(len(k) == N for k in known), \"trace\": tr}\n", + "\n", + "\n", + "# 1) 방식 비교\n", + "res = {\"전체 전송(상대 상태 모름)\": simulate(\"full\"), \"ID 차집합 델타(오래된 것 먼저)\": simulate(\"delta\"), \"델타 + 우선순위(기본 가중 own 1, age 0)\": simulate(\"prio\", 1, 0)}\n", + "print(f\"[조건] 보급소 5곳, 트랜잭션 {N}건, 7일간 접촉 {CONTACTS_PER_DAY * 7}회(P2P·USB), 접촉당 방향별 최대 {BW}건, 손실 {LOSS:.0%}\")\n", + "print(f\"{'방식':32s} {'완전성':>7s} {'전송':>6s} {'유효 전송':>8s} {'효율':>6s}\")\n", + "for k, v in res.items():\n", + " print(f\"{k:32s} {v['comp']:7.1%} {v['sent']:6d} {v['useful']:8d} {v['eff']:6.1%}\")\n", + "\n", + "# 2) 1차 튜닝: 우선순위 가중치 격자 탐색(스케줄 시드 3개 평균, 시험은 다른 시드 3개)\n", + "grid = {}\n", + "for wo, wa in itertools.product((0, 1, 2, 4), (-1, 0, 1)):\n", + " grid[(wo, wa)] = np.mean([simulate(\"prio\", wo, wa, seed=s)[\"comp\"] for s in (1, 2, 4)])\n", + "best = max(grid, key=grid.get)\n", + "print(f\"\\n[우선순위 가중치 탐색(튜닝 시드 3개 평균 완전성)] 내 기록 가중 x 오래됨 가중\")\n", + "print(\" \" + \" \".join(f\"own{wo}/age{wa}:{v:.1%}\" for (wo, wa), v in list(grid.items())[:6]) + \" ...\")\n", + "print(f\"채택: own {best[0]}, age {best[1]} (튜닝 평균 {grid[best]:.1%})\")\n", + "test = {m: np.mean([simulate(m, *( (best if m == 'prio' else (0, 0)) ), seed=s)[\"comp\"] for s in (5, 6, 7)]) for m in (\"full\", \"delta\", \"prio\")}\n", + "print(f\"시험 시드 3개 평균 완전성: 전체 전송 {test['full']:.1%}, 델타 {test['delta']:.1%}, 델타+튜닝 우선순위 {test['prio']:.1%}\")\n", + "acc = test[\"prio\"]\n", + "print(f\"기본 정확도(7일 후 모든 노드가 전체 트랜잭션 중 알고 있는 비율) {acc:.1%} (목표 75% 이상: {'달성' if acc >= 0.75 else '미달'})\")\n", + "\n", + "# 3) 수렴 추적과 판단 지연\n", + "import time\n", + "t0 = time.perf_counter(); r = simulate(\"prio\", *best, trace=True); dt = time.perf_counter() - t0\n", + "tr = r[\"trace\"]; day = next((t / 24 for t, c in tr if c >= 0.9), None)\n", + "print(f\"\\n[수렴] 7일 시뮬레이션 {dt * 1000:.0f}ms (접촉 1회당 {dt / len(tr) * 1000:.1f}ms), 완전성 90% 도달 시점 {('%.1f일' % day) if day else '7일 내 미도달'}, 모든 트랜잭션을 가진 노드 {r['full_nodes']}/5\")\n", + "print(\"접촉이 부족하면 완전성이 100%에 못 미치므로, 동기화 전 재고는 '확인된 범위' 표시가 필요하다(M6).\")\n", + "print(\"※ 가상 접촉 일정과 대역폭 가정에 따른 결과이며, 실제 접촉 빈도·USB 이동은 현장 조건에 따라 다르다. 병합 충돌 판단은 M4·M7에서 다룬다.\")\n", + "\n", + "plt.figure(figsize=(8, 4))\n", + "for m, lab in ((\"full\", \"full dump\"), (\"delta\", \"delta\"), (\"prio\", \"delta + priority\")):\n", + " rr = simulate(m, *(best if m == \"prio\" else (0, 0)), trace=True)[\"trace\"]; plt.plot([t / 24 for t, _ in rr], [c * 100 for _, c in rr], label=lab)\n", + "plt.xlabel(\"days\"); plt.ylabel(\"mean completeness (%)\"); plt.title(\"P2P/USB exchange: completeness over time\"); plt.legend(); plt.tight_layout(); plt.show()\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 03 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + "[조건] 보급소 5곳, 트랜잭션 2100건, 7일간 접촉 56회(P2P·USB), 접촉당 방향별 최대 80건, 손실 5%\n", + "방식 완전성 전송 유효 전송 효율\n", + "전체 전송(상대 상태 모름) 22.8% 8470 398 4.7%\n", + "ID 차집합 델타(오래된 것 먼저) 82.5% 6999 6657 95.1%\n", + "델타 + 우선순위(기본 가중 own 1, age 0) 83.7% 7143 6791 95.1%\n", + "\n", + "[우선순위 가중치 탐색(튜닝 시드 3개 평균 완전성)] 내 기록 가중 x 오래됨 가중\n", + " own0/age-1:84.8% own0/age0:83.7% own0/age1:82.7% own1/age-1:84.7% own1/age0:84.4% own1/age1:84.0% ...\n", + "채택: own 0, age -1 (튜닝 평균 84.8%)\n", + "시험 시드 3개 평균 완전성: 전체 전송 24.0%, 델타 82.1%, 델타+튜닝 우선순위 84.6%\n", + "기본 정확도(7일 후 모든 노드가 전체 트랜잭션 중 알고 있는 비율) 84.6% (목표 75% 이상: 달성)\n", + "\n", + "[수렴] 7일 시뮬레이션 58ms (접촉 1회당 1.0ms), 완전성 90% 도달 시점 7일 내 미도달, 모든 트랜잭션을 가진 노드 0/5\n", + "접촉이 부족하면 완전성이 100%에 못 미치므로, 동기화 전 재고는 '확인된 범위' 표시가 필요하다(M6).\n", + "※ 가상 접촉 일정과 대역폭 가정에 따른 결과이며, 실제 접촉 빈도·USB 이동은 현장 조건에 따라 다르다. 병합 충돌 판단은 M4·M7에서 다룬다.\n", + "```\n", + "
\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 04. 네트워크 복구 시 클라우드 중앙 서버와의 양방향 데이터 충돌 해결(CRDT) 동기화 고도화\n", + "**소요시간**: 약 3.0시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "네트워크 복구 시 중앙 서버와의 양방향 데이터 충돌 해결(CRDT) 동기화를 고도화한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **통신 복구 후 노드와 중앙 서버가 양방향으로 병합하는 CRDT 동기화를 구현하고, 같은 품목 정보를 서로 모르게 고친 충돌의 탐지 재현율 85% 이상과 처리 지연 200ms 이하를 확인한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **재고 수량은 품목별 PN-카운터(노드별 증가·감소 누적, 병합은 요소별 최댓값)로 만들어 노드 5곳과 서버를 2단계로 병합하고, 마지막 값이 이기는 방식(LWW)과 갱신 유실을 비교 수행하고 검증하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `src/models/dat10_advanced_detector.py 및 예외 탐지 테스트 결과` 형태의 결과물을 완성한다.\n", + "\n", + "### 🔍 평가 및 검증 기준 (PASS 조건)\n", + "#### 미션 판정\n", + "\n", + "**평가 구분:** 필수\n", + "\n", + "**핵심 평가영역:** 기만/교란 공격에 대한 방어 로직의 현실성 및 신뢰성\n", + "\n", + "#### 미션 PASS 조건\n", + "\n", + "- 정량 기준: 복합 위협 탐지 재현율(Recall) 85% 이상, 처리 지연 200ms 이하\n", + "\n", + "\n", + "- 진행 기준: 복합 위협 시나리오 10종에 대한 탐지 테스트 통과\n", + "\n", + "\n", + "- 검증 방법: 합성 위협 데이터셋 주입 시 이상치 탐지 정확도 검증\n", + "\n", + "\n", + "#### 채점자 판정 메모\n", + "\n", + "> 단순 코드 실행 여부뿐만 아니라 출력된 수치와 근거 자료가 군 실무 요구사항에 부합하는지 면밀히 확인한다.\n", + "\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "\"\"\"통신 복구 후 보급소 5곳과 중앙 서버가 양방향으로 병합하는 CRDT 동기화(재고는 PN-카운터, 메타 정보는 버전 벡터 기반 충돌 감지)를 구현해, 수렴·갱신 유실 0건·멱등/교환/결합 성질·중복 재전송·오래된 상태 재전송 방어를 검증하고, 충돌 탐지 재현율(시간 비교 규칙 대 버전 벡터 규칙)과 오경보 패턴·동기화 지연을 측정한다.\"\"\"\n", + "import copy\n", + "import itertools\n", + "import time\n", + "import numpy as np\n", + "import pandas as pd\n", + "import matplotlib.pyplot as plt\n", + "\n", + "# ---------------------------------------------------------------------------\n", + "# [교육용가상자료] 가상 야전 보급소·업체·품목 자료 생성기 (실제 부대·업체·품목·재고 정보 없음)\n", + "# 가상 보급소 5곳이 품목 12종의 입고·불출·조정 트랜잭션을 오프라인으로 기록하고 나중에 동기화한다.\n", + "# 공급망 재난 징후(업체 납기 지연, 품목 재고 급감)는 동기화된 자료를 마이닝해 식별한다.\n", + "# ---------------------------------------------------------------------------\n", + "NODES = [\"보급소A\", \"보급소B\", \"보급소C\", \"보급소D\", \"보급소E\"]\n", + "SUPPLIERS = [\"가상업체1\", \"가상업체2\", \"가상업체3\", \"가상업체4\", \"가상업체5\", \"가상업체6\"]\n", + "CATS = [\"일반보급\", \"정비부품\", \"의무소모\"]\n", + "ITEMS = [{\"item\": f\"I{i + 1:02d}\", \"cat\": CATS[i % 3], \"supplier\": SUPPLIERS[i % 6], \"lead_days\": 3 + (i * 5) % 12, \"use\": 4 + (i * 7) % 11, \"min_level\": 30 + 10 * (i % 5)} for i in range(12)]\n", + "ITEM_IDS = [x[\"item\"] for x in ITEMS]\n", + "CTXS = [\"평시\", \"야간\", \"통신불량\"]\n", + "\n", + "\n", + "def make_txns(seed=7, days=7, per_node_day=60, nodes=None):\n", + " \"\"\"노드별 오프라인 트랜잭션 목록. id는 노드-일련번호(멱등 키), t는 시각(시간 단위), ctx는 입력 상황.\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " out = []\n", + " for n in (nodes or NODES):\n", + " seq = 0\n", + " for k in range(days * per_node_day):\n", + " seq += 1\n", + " it = ITEMS[int(rng.integers(len(ITEMS)))]\n", + " t = float(rng.uniform(0, 24 * days))\n", + " ctx = \"야간\" if (t % 24 >= 20 or t % 24 < 5) else \"평시\"\n", + " if rng.random() < 0.12:\n", + " ctx = \"통신불량\"\n", + " r = rng.random()\n", + " if r < 0.22:\n", + " op, q = \"in\", int(it[\"use\"] * rng.uniform(4, 9))\n", + " elif r < 0.95:\n", + " op, q = \"out\", int(max(1, rng.normal(it[\"use\"] * 1.5, 3)))\n", + " else:\n", + " op, q = \"adjust\", int(rng.integers(-5, 6))\n", + " out.append({\"id\": f\"{n}-{seq:05d}\", \"node\": n, \"item\": it[\"item\"], \"op\": op, \"qty\": q, \"t\": round(t, 2), \"ctx\": ctx, \"supplier\": it[\"supplier\"]})\n", + " return out\n", + "\n", + "\n", + "def stock_of(txns, base=500):\n", + " \"\"\"품목별 재고 = 기초 재고 + 입고 - 불출 + 조정 (id 중복은 한 번만 반영).\"\"\"\n", + " # [TODO : 학생 실습 4] 네트워크 복구 시 클라우드 중앙 서버와의 양방향 데이터 충돌 해결(CRDT) 동기화 고도화 관련 핵심 로직을 구현하세요.\n", + " # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n", + " # <<< 여기에 코드를 작성하세요 >>>\n", + " pass # 작성 후 pass를 제거하세요.\n", + "class Replica:\n", + " \"\"\"재고는 품목별 PN-카운터(노드별 증가량 P, 감소량 N), 같은 노드의 기록은 서로 덮어쓰지 않는다. 병합은 요소별 최댓값.\"\"\"\n", + " def __init__(self, name):\n", + " self.name = name; self.P = {i: {} for i in ITEM_IDS}; self.N = {i: {} for i in ITEM_IDS}\n", + "\n", + " def apply(self, x):\n", + " d = self.P if x[\"op\"] == \"in\" or (x[\"op\"] == \"adjust\" and x[\"qty\"] > 0) else self.N\n", + " d[x[\"item\"]][x[\"node\"]] = d[x[\"item\"]].get(x[\"node\"], 0) + abs(x[\"qty\"])\n", + "\n", + " def merge(self, other):\n", + " for it in ITEM_IDS:\n", + " for src, dst in ((other.P, self.P), (other.N, self.N)):\n", + " for n, v in src[it].items():\n", + " dst[it][n] = max(dst[it].get(n, 0), v)\n", + "\n", + " def stock(self, base=500):\n", + " return {i: base + sum(self.P[i].values()) - sum(self.N[i].values()) for i in ITEM_IDS}\n", + "\n", + " def state(self):\n", + " return (tuple(tuple(sorted(self.P[i].items())) for i in ITEM_IDS), tuple(tuple(sorted(self.N[i].items())) for i in ITEM_IDS))\n", + "\n", + "\n", + "TX = make_txns(7, 7, per_node_day=60)\n", + "truth = stock_of(TX)\n", + "reps = {n: Replica(n) for n in NODES}\n", + "for x in TX:\n", + " reps[x[\"node\"]].apply(x) # 7일간 각 노드는 자기 기록만 반영(오프라인)\n", + "server = Replica(\"서버\")\n", + "\n", + "# 1) 통신 복구: 노드 -> 서버 병합 후 서버 -> 노드 병합(2단계). 지연 측정\n", + "lat = []\n", + "t0 = time.perf_counter()\n", + "for n in NODES:\n", + " t1 = time.perf_counter(); server.merge(reps[n]); lat.append((time.perf_counter() - t1) * 1000)\n", + "for n in NODES:\n", + " t1 = time.perf_counter(); reps[n].merge(server); lat.append((time.perf_counter() - t1) * 1000)\n", + "dt = time.perf_counter() - t0\n", + "conv = len({r.state() for r in reps.values()} | {server.state()}) == 1\n", + "lost = sum(abs(server.stock()[i] - truth[i]) for i in ITEM_IDS)\n", + "print(f\"[CRDT 동기화] 노드 5곳 + 서버, 트랜잭션 {len(TX)}건: 수렴 {'성공' if conv else '실패'}, 서버 재고와 정답(전체 합산) 차이 합계 {lost}개 -> 갱신 유실 {'0건' if lost == 0 else '있음'}\")\n", + "print(f\"병합 1회 평균 {np.mean(lat):.3f}ms, 최대 {max(lat):.3f}ms, 전체 동기화 {dt * 1000:.2f}ms (목표 200ms 이하: {'달성' if max(lat) <= 200 else '미달'})\")\n", + "\n", + "# 2) 비교: 재고 숫자를 '마지막 기록이 이김(LWW)'으로 덮어쓰면 오프라인 갱신이 사라진다\n", + "last = {}\n", + "for n in NODES: # 각 노드의 최종 재고 값(기초 재고 + 자기 기록만 반영)\n", + " loc = {i: 500 for i in ITEM_IDS}\n", + " for x in TX:\n", + " if x[\"node\"] == n:\n", + " loc[x[\"item\"]] += x[\"qty\"] if x[\"op\"] in (\"in\", \"adjust\") else -x[\"qty\"]\n", + " last[n] = (max(x[\"t\"] for x in TX if x[\"node\"] == n), loc)\n", + "lww = max(last.values())[1]\n", + "print(f\"숫자 덮어쓰기(LWW) 병합: 정답과 차이 합계 {sum(abs(lww[i] - truth[i]) for i in ITEM_IDS)}개 (갱신 유실) / PN-카운터 {lost}개\")\n", + "\n", + "# 3) CRDT 성질 시험: 멱등·교환·결합·중복·오래된 상태 재전송\n", + "rng = np.random.default_rng(5); ok = {\"멱등(두 번 병합)\": 0, \"교환(A+B = B+A)\": 0, \"결합((A+B)+C = A+(B+C))\": 0, \"오래된 상태 재전송\": 0}\n", + "trials = 200\n", + "for _ in range(trials):\n", + " a, b, c = (reps[n] for n in rng.choice(NODES, 3, replace=False))\n", + " parts = []\n", + " for r in (a, b, c):\n", + " q = Replica(\"t\")\n", + " for i in ITEM_IDS:\n", + " for n in NODES:\n", + " if rng.random() < .5:\n", + " q.P[i][n] = int(rng.integers(0, 100)); q.N[i][n] = int(rng.integers(0, 100))\n", + " parts.append(q)\n", + " x, y, z = parts\n", + " m = copy.deepcopy(x); m.merge(y); s1 = m.state(); m.merge(y); ok[\"멱등(두 번 병합)\"] += s1 == m.state()\n", + " m1 = copy.deepcopy(x); m1.merge(y); m2 = copy.deepcopy(y); m2.merge(x); ok[\"교환(A+B = B+A)\"] += m1.state() == m2.state()\n", + " l = copy.deepcopy(x); l.merge(y); l.merge(z); rr = copy.deepcopy(y); rr.merge(z); r2 = copy.deepcopy(x); r2.merge(rr); ok[\"결합((A+B)+C = A+(B+C))\"] += l.state() == r2.state()\n", + " new = copy.deepcopy(x); new.merge(y); old = copy.deepcopy(x); before = new.state(); new.merge(old); ok[\"오래된 상태 재전송\"] += before == new.state()\n", + "print(\"\\n[CRDT 성질 시험(무작위 상태 200쌍)]\", \", \".join(f\"{k} {v}/{trials}\" for k, v in ok.items()))\n", + "\n", + "# 4) 메타 정보(최소 보유량·위치·상태) 동시 수정 충돌 탐지: 시간 비교 규칙 vs 버전 벡터 규칙\n", + "def dominated(a, b):\n", + " # [TODO : 학생 실습 4] 네트워크 복구 시 클라우드 중앙 서버와의 양방향 데이터 충돌 해결(CRDT) 동기화 고도화 관련 핵심 로직을 구현하세요.\n", + " # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n", + " # <<< 여기에 코드를 작성하세요 >>>\n", + " pass # 작성 후 pass를 제거하세요.\n", + "def make_pair(kind):\n", + " base = float(rng.uniform(0, 160)); va, vb = int(rng.integers(30, 80)), int(rng.integers(30, 80))\n", + " if va == vb:\n", + " vb += 7\n", + " A = {\"field\": \"min_level\", \"value\": va, \"t\": base, \"vv\": {\"A\": 3, \"B\": 1}}\n", + " if kind == \"concurrent\":\n", + " B = {\"field\": \"min_level\", \"value\": vb, \"t\": base + float(rng.uniform(0.5, 120)), \"vv\": {\"A\": 2, \"B\": 4}}\n", + " elif kind == \"causal\": # B가 A의 수정을 받은 뒤 고침(정상 후속 수정), 시각은 가까움\n", + " B = {\"field\": \"min_level\", \"value\": vb, \"t\": base + float(rng.uniform(0.01, 0.5)), \"vv\": {\"A\": 3, \"B\": 2}}\n", + " elif kind == \"same_value\":\n", + " B = {\"field\": \"min_level\", \"value\": va, \"t\": base + float(rng.uniform(0.5, 120)), \"vv\": {\"A\": 2, \"B\": 4}}\n", + " elif kind == \"other_field\":\n", + " B = {\"field\": \"location\", \"value\": vb, \"t\": base + float(rng.uniform(0.5, 120)), \"vv\": {\"A\": 2, \"B\": 4}}\n", + " else: # 시계 어긋남: 실제로는 동시 수정이지만 B 단말 시계가 느려 시각이 A보다 앞선 것처럼 기록됨\n", + " B = {\"field\": \"min_level\", \"value\": vb, \"t\": base - float(rng.uniform(0.5, 3)), \"vv\": {\"A\": 2, \"B\": 4}}\n", + " return A, B\n", + "\n", + "\n", + "truth_conf = {\"concurrent\": True, \"causal\": False, \"same_value\": False, \"other_field\": False, \"skew\": True}\n", + "pairs = [(k, *make_pair(k)) for k in rng.choice(list(truth_conf), 400, p=[.40, .25, .10, .10, .15])]\n", + "rule_time = lambda A, B: A[\"field\"] == B[\"field\"] and A[\"value\"] != B[\"value\"] and abs(A[\"t\"] - B[\"t\"]) <= 1.0 # 1시간 이내 같은 필드 다른 값\n", + "rule_val = lambda A, B: A[\"field\"] == B[\"field\"] and A[\"value\"] != B[\"value\"] # 같은 필드 다른 값이면 모두 충돌(인과 무시)\n", + "rule_vv1 = lambda A, B: A[\"field\"] == B[\"field\"] and not dominated(A[\"vv\"], B[\"vv\"]) and not dominated(B[\"vv\"], A[\"vv\"]) # 동시이면 충돌(값 비교 없음)\n", + "rule_vv2 = lambda A, B: rule_vv1(A, B) and A[\"value\"] != B[\"value\"] # 재설계: 값이 다를 때만\n", + "print(\"\\n[충돌 탐지] 정답 충돌 %d건 / 쌍 %d건\" % (sum(truth_conf[k] for k, _, _ in pairs), len(pairs)))\n", + "for name, rule in ((\"시간 근접 규칙(1시간 이내)\", rule_time), (\"값 비교 규칙(다르면 충돌)\", rule_val), (\"버전 벡터 규칙 v1(동시이면 충돌)\", rule_vv1), (\"버전 벡터 + 값 비교 v2(재설계)\", rule_vv2)):\n", + " pred = np.array([rule(a, b) for _, a, b in pairs]); tr = np.array([truth_conf[k] for k, _, _ in pairs])\n", + " tp, fp, fn = int((pred & tr).sum()), int((pred & ~tr).sum()), int((~pred & tr).sum())\n", + " kinds = {str(k): int(v) for k, v in pd.Series([k for (k, _, _), p, t in zip(pairs, pred, tr) if p != t]).value_counts().items()}\n", + " print(f\" {name:30s} 재현율 {tp / (tp + fn):5.1%}, 정밀도 {tp / max(tp + fp, 1):5.1%}, 오경보 {fp}건, 놓침 {fn}건 {kinds}\")\n", + "print(\"오경보·미탐 패턴: 시간 규칙은 오프라인 동안 멀리 떨어진 동시 수정(concurrent)·시계 어긋남(skew)을 놓치고, 값 비교 규칙은 '후속 정상 수정'(causal)을 충돌로 올림 -> 버전 벡터로 인과 관계를 보고 같은 값이면 제외(v2)\")\n", + "print(\"※ 충돌로 표시된 건은 자동 병합하지 않고 군수관이 수동 병합한다(M7). 재고 수량은 CRDT로 자동 합산되므로 사람이 판단할 대상은 메타 정보 충돌이다.\")\n", + "\n", + "plt.figure(figsize=(6, 3.5)); x = np.arange(len(ITEM_IDS))\n", + "plt.bar(x - .2, [truth[i] for i in ITEM_IDS], .4, label=\"truth / CRDT\"); plt.bar(x + .2, [lww[i] for i in ITEM_IDS], .4, label=\"LWW overwrite\", color=\"tab:red\")\n", + "plt.xticks(x, [f\"{i + 1}\" for i in range(12)]); plt.xlabel(\"item\"); plt.ylabel(\"stock\"); plt.legend(); plt.title(\"Merged stock: CRDT vs last-write-wins\"); plt.tight_layout(); plt.show()\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 04 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + "[CRDT 동기화] 노드 5곳 + 서버, 트랜잭션 2100건: 수렴 성공, 서버 재고와 정답(전체 합산) 차이 합계 0개 -> 갱신 유실 0건\n", + "병합 1회 평균 0.022ms, 최대 0.035ms, 전체 동기화 0.22ms (목표 200ms 이하: 달성)\n", + "숫자 덮어쓰기(LWW) 병합: 정답과 차이 합계 5142개 (갱신 유실) / PN-카운터 0개\n", + "\n", + "[CRDT 성질 시험(무작위 상태 200쌍)] 멱등(두 번 병합) 200/200, 교환(A+B = B+A) 200/200, 결합((A+B)+C = A+(B+C)) 200/200, 오래된 상태 재전송 200/200\n", + "\n", + "[충돌 탐지] 정답 충돌 226건 / 쌍 400건\n", + " 시간 근접 규칙(1시간 이내) 재현율 4.9%, 정밀도 9.3%, 오경보 107건, 놓침 215건 {'concurrent': 163, 'causal': 107, 'skew': 52}\n", + " 값 비교 규칙(다르면 충돌) 재현율 100.0%, 정밀도 67.9%, 오경보 107건, 놓침 0건 {'causal': 107}\n", + " 버전 벡터 규칙 v1(동시이면 충돌) 재현율 100.0%, 정밀도 88.3%, 오경보 30건, 놓침 0건 {'same_value': 30}\n", + " 버전 벡터 + 값 비교 v2(재설계) 재현율 100.0%, 정밀도 100.0%, 오경보 0건, 놓침 0건 {}\n", + "오경보·미탐 패턴: 시간 규칙은 오프라인 동안 멀리 떨어진 동시 수정(concurrent)·시계 어긋남(skew)을 놓치고, 값 비교 규칙은 '후속 정상 수정'(causal)을 충돌로 올림 -> 버전 벡터로 인과 관계를 보고 같은 값이면 제외(v2)\n", + "※ 충돌로 표시된 건은 자동 병합하지 않고 군수관이 수동 병합한다(M7). 재고 수량은 CRDT로 자동 합산되므로 사람이 판단할 대상은 메타 정보 충돌이다.\n", + "```\n", + "
\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 05. 오프라인 위변조 방지 암호화 해시 및 데이터 무결성 검증 엔진 개발\n", + "**소요시간**: 약 3.0시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "오프라인 위변조 방지 암호화 해시 및 데이터 무결성 검증 엔진을 개발한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **보급소 로그의 해시 체인과 레코드별 HMAC 서명으로 위변조를 찾는 오프라인 무결성 검증 엔진을 만들고, 단순 전체 해시 비교 대비 오경보율을 40% 이상 줄이고 신뢰도 등급 산출의 일관성 90% 이상을 확인한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **수량 수정·기록 삭제·순서 변경·위조 삽입·재전송 변조(같은 ID, 다른 수량)·꼬리 절단 6종의 악성 변형과 중복 전송·순서 뒤바뀜·공백 차이 3종의 정상 변형을 보급소 5곳에 6회씩 주입하여 처리 지연 및 복원력을 시험하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `src/verification/dat10_confidence_engine.py` 형태의 결과물을 완성한다.\n", + "\n", + "### 🔍 평가 및 검증 기준 (PASS 조건)\n", + "#### 미션 판정\n", + "\n", + "**평가 구분:** 필수\n", + "\n", + "**핵심 평가영역:** 신뢰도 등급(High/Med/Low) 부여 기준의 군사적 타당성\n", + "\n", + "#### 미션 PASS 조건\n", + "\n", + "- 정량 기준: 오경보율(FAR) 40% 이상 감축, 신뢰도 스코어 산출 일관성 90% 이상\n", + "\n", + "\n", + "- 진행 기준: 오경보 필터링 전후 비교 지표 산출 및 통합 테스트 완료\n", + "\n", + "\n", + "- 검증 방법: ROC-AUC 곡선 분석 및 임계값 민감도 테스트\n", + "\n", + "\n", + "#### 채점자 판정 메모\n", + "\n", + "> 단순 코드 실행 여부뿐만 아니라 출력된 수치와 근거 자료가 군 실무 요구사항에 부합하는지 면밀히 확인한다.\n", + "\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "\"\"\"보급소 로그의 해시 체인·레코드별 HMAC 서명으로 위변조(수량 수정·삭제·순서 변경·위조 삽입·재전송 변조·꼬리 절단)를 탐지하는 오프라인 무결성 검증 엔진과, 점검 항목 가중합 신뢰도(High/Med/Low)를 구현해 단순 전체 해시 비교 대비 오경보율 감축·신뢰도 산출 일관성·불확실성 표시를 검증한다.\"\"\"\n", + "import hashlib\n", + "import hmac\n", + "import numpy as np\n", + "import pandas as pd\n", + "import matplotlib.pyplot as plt\n", + "\n", + "# ---------------------------------------------------------------------------\n", + "# [교육용가상자료] 가상 야전 보급소·업체·품목 자료 생성기 (실제 부대·업체·품목·재고 정보 없음)\n", + "# 가상 보급소 5곳이 품목 12종의 입고·불출·조정 트랜잭션을 오프라인으로 기록하고 나중에 동기화한다.\n", + "# 공급망 재난 징후(업체 납기 지연, 품목 재고 급감)는 동기화된 자료를 마이닝해 식별한다.\n", + "# ---------------------------------------------------------------------------\n", + "NODES = [\"보급소A\", \"보급소B\", \"보급소C\", \"보급소D\", \"보급소E\"]\n", + "SUPPLIERS = [\"가상업체1\", \"가상업체2\", \"가상업체3\", \"가상업체4\", \"가상업체5\", \"가상업체6\"]\n", + "CATS = [\"일반보급\", \"정비부품\", \"의무소모\"]\n", + "ITEMS = [{\"item\": f\"I{i + 1:02d}\", \"cat\": CATS[i % 3], \"supplier\": SUPPLIERS[i % 6], \"lead_days\": 3 + (i * 5) % 12, \"use\": 4 + (i * 7) % 11, \"min_level\": 30 + 10 * (i % 5)} for i in range(12)]\n", + "ITEM_IDS = [x[\"item\"] for x in ITEMS]\n", + "CTXS = [\"평시\", \"야간\", \"통신불량\"]\n", + "\n", + "\n", + "def make_txns(seed=7, days=7, per_node_day=60, nodes=None):\n", + " \"\"\"노드별 오프라인 트랜잭션 목록. id는 노드-일련번호(멱등 키), t는 시각(시간 단위), ctx는 입력 상황.\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " out = []\n", + " for n in (nodes or NODES):\n", + " seq = 0\n", + " for k in range(days * per_node_day):\n", + " seq += 1\n", + " it = ITEMS[int(rng.integers(len(ITEMS)))]\n", + " t = float(rng.uniform(0, 24 * days))\n", + " ctx = \"야간\" if (t % 24 >= 20 or t % 24 < 5) else \"평시\"\n", + " if rng.random() < 0.12:\n", + " ctx = \"통신불량\"\n", + " r = rng.random()\n", + " if r < 0.22:\n", + " op, q = \"in\", int(it[\"use\"] * rng.uniform(4, 9))\n", + " elif r < 0.95:\n", + " op, q = \"out\", int(max(1, rng.normal(it[\"use\"] * 1.5, 3)))\n", + " else:\n", + " op, q = \"adjust\", int(rng.integers(-5, 6))\n", + " out.append({\"id\": f\"{n}-{seq:05d}\", \"node\": n, \"item\": it[\"item\"], \"op\": op, \"qty\": q, \"t\": round(t, 2), \"ctx\": ctx, \"supplier\": it[\"supplier\"]})\n", + " return out\n", + "\n", + "\n", + "def stock_of(txns, base=500):\n", + " \"\"\"품목별 재고 = 기초 재고 + 입고 - 불출 + 조정 (id 중복은 한 번만 반영).\"\"\"\n", + " # [TODO : 학생 실습 5] 오프라인 위변조 방지 암호화 해시 및 데이터 무결성 검증 엔진 개발 관련 핵심 로직을 구현하세요.\n", + " # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n", + " # <<< 여기에 코드를 작성하세요 >>>\n", + " pass # 작성 후 pass를 제거하세요.\n", + "KEYS = {n: hashlib.sha256(f\"교육용-가상-키-{n}\".encode()).digest() for n in NODES} # 노드별 서명 키(가상, 실제 키 관리는 별도 체계)\n", + "TX = make_txns(7, 7, per_node_day=60)\n", + "\n", + "\n", + "def rec_str(x):\n", + " # [TODO : 학생 실습 5] 오프라인 위변조 방지 암호화 해시 및 데이터 무결성 검증 엔진 개발 관련 핵심 로직을 구현하세요.\n", + " # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n", + " # <<< 여기에 코드를 작성하세요 >>>\n", + " pass # 작성 후 pass를 제거하세요.\n", + "def sign_log(node):\n", + " \"\"\"노드 로그를 순서대로 해시 체인으로 묶고 레코드별 HMAC 서명을 붙인다. 마지막 해시(머리)는 동료 노드가 따로 보관한다.\"\"\"\n", + " log, prev = [], \"GENESIS\"\n", + " for x in [y for y in TX if y[\"node\"] == node]:\n", + " h = hashlib.sha256((prev + rec_str(x)).encode()).hexdigest()\n", + " log.append({**x, \"prev\": prev, \"hash\": h, \"sig\": hmac.new(KEYS[node], rec_str(x).encode(), \"sha256\").hexdigest()[:16], \"note\": \"\"}); prev = h\n", + " return log, prev\n", + "\n", + "\n", + "LOGS = {n: sign_log(n) for n in NODES}\n", + "HEADS = {n: LOGS[n][1] for n in NODES}\n", + "\n", + "\n", + "def canonical(log):\n", + " \"\"\"배달 과정의 정상 변형(중복 전송·순서 뒤바뀜·공백 차이)을 정리: 같은 ID는 내용이 같으면 하나만, 일련번호 순 정렬, note 공백 제거.\"\"\"\n", + " seen, out, conflicts = {}, [], 0\n", + " for r in log:\n", + " if r[\"id\"] in seen:\n", + " conflicts += (rec_str(r) != rec_str(seen[r[\"id\"]])); continue\n", + " seen[r[\"id\"]] = r; out.append(r)\n", + " return sorted(out, key=lambda r: r[\"id\"]), conflicts\n", + "\n", + "\n", + "def verify(node, log, head=None):\n", + " \"\"\"검증 항목 6개의 결과 dict: 체인 연속, 서명, 같은 ID 내용 충돌, 일련번호 연속, 시각 이상, 꼬리 절단.\"\"\"\n", + " log, conf = canonical(log)\n", + " chain_ok = sig_ok = True; prev = \"GENESIS\"; bad_sig = 0\n", + " for r in log:\n", + " if r[\"prev\"] != prev or hashlib.sha256((prev + rec_str(r)).encode()).hexdigest() != r[\"hash\"]:\n", + " chain_ok = False\n", + " if not hmac.compare_digest(r[\"sig\"], hmac.new(KEYS[node], rec_str(r).encode(), \"sha256\").hexdigest()[:16]):\n", + " sig_ok = False; bad_sig += 1\n", + " prev = r[\"hash\"]\n", + " seqs = [int(r[\"id\"].split(\"-\")[1]) for r in log]\n", + " gap = seqs != list(range(1, len(seqs) + 1)) if seqs else True\n", + " ts = np.array([r[\"t\"] for r in log]); skew = bool(len(ts) and (ts < 0).any())\n", + " tail = bool(head is not None and prev != head)\n", + " return {\"chain\": chain_ok, \"sig\": sig_ok, \"conflict\": conf == 0, \"seq\": not gap, \"time\": not skew, \"tail\": not tail, \"bad_sig\": bad_sig}\n", + "\n", + "\n", + "W = {\"chain\": 0.30, \"sig\": 0.30, \"conflict\": 0.15, \"seq\": 0.10, \"time\": 0.05, \"tail\": 0.10} # 신뢰도 가중치 매트릭스(합 1)\n", + "\n", + "\n", + "def confidence(v, sample_frac=1.0, rng=None):\n", + " \"\"\"점검 통과 가중합. 체인·서명·충돌·꼬리 실패는 즉시 Low. 서명은 sample_frac 비율의 표본 점검만 했을 때 불확실성이 생긴다.\"\"\"\n", + " s = sum(W[k] for k in W if v[k])\n", + " lvl = \"Low\" if not (v[\"chain\"] and v[\"sig\"] and v[\"conflict\"] and v[\"tail\"]) else \"High\" if s >= 0.95 else \"Med\" if s >= 0.75 else \"Low\"\n", + " return s, lvl\n", + "\n", + "\n", + "# 시험 1: 악성 변조 6종 + 정상 전송 변형 3종, 각 노드 x 6회\n", + "import copy\n", + "rng = np.random.default_rng(11)\n", + "\n", + "\n", + "def tamper(kind, log):\n", + " log = copy.deepcopy(log); i = int(rng.integers(5, len(log) - 5))\n", + " if kind == \"수량 수정\":\n", + " log[i][\"qty\"] += 40\n", + " elif kind == \"기록 삭제\":\n", + " del log[i]\n", + " elif kind == \"순서 바꿈+시각 수정\":\n", + " log[i], log[i + 1] = log[i + 1], log[i]; log[i][\"t\"] = round(log[i][\"t\"] + 3, 2)\n", + " elif kind == \"위조 삽입(서명 없음)\":\n", + " f = dict(log[i]); f[\"id\"] = log[i][\"id\"].split(\"-\")[0] + \"-\" + f\"{len(log) + 1:05d}\"; f[\"qty\"] = 99; f[\"sig\"] = \"0\" * 16; log.append(f)\n", + " elif kind == \"재전송 변조(같은 ID, 다른 수량)\":\n", + " f = dict(log[i]); f[\"qty\"] += 7; log.append(f)\n", + " elif kind == \"꼬리 절단\":\n", + " log = log[: -int(rng.integers(3, 20))]\n", + " elif kind == \"중복 전송\":\n", + " log += copy.deepcopy(log[i:i + 5])\n", + " elif kind == \"순서 뒤바뀜 전송\":\n", + " rng.shuffle(log)\n", + " elif kind == \"공백 차이(note)\":\n", + " for r in log[i:i + 3]:\n", + " r[\"note\"] = \" \"\n", + " return log\n", + "\n", + "\n", + "MAL = [\"수량 수정\", \"기록 삭제\", \"순서 바꿈+시각 수정\", \"위조 삽입(서명 없음)\", \"재전송 변조(같은 ID, 다른 수량)\", \"꼬리 절단\"]\n", + "BEN = [\"중복 전송\", \"순서 뒤바뀜 전송\", \"공백 차이(note)\"]\n", + "rows = []\n", + "for n in NODES:\n", + " for kind in MAL + BEN:\n", + " for _ in range(6):\n", + " lg = tamper(kind, LOGS[n][0])\n", + " naive = hashlib.sha256(\"\".join(rec_str(r) + r[\"hash\"] + r[\"note\"] for r in lg).encode()).hexdigest() != hashlib.sha256(\"\".join(rec_str(r) + r[\"hash\"] + r[\"note\"] for r in LOGS[n][0]).encode()).hexdigest()\n", + " v = verify(n, lg, HEADS[n]); s, lvl = confidence(v)\n", + " rows.append({\"node\": n, \"kind\": kind, \"mal\": kind in MAL, \"naive\": naive, \"alarm\": lvl == \"Low\", \"score\": s, \"lvl\": lvl})\n", + "R = pd.DataFrame(rows)\n", + "det = R[R[\"mal\"]].groupby(\"kind\").agg(naive=(\"naive\", \"mean\"), layered=(\"alarm\", \"mean\"))\n", + "print(f\"[변조 탐지] 악성 {len(MAL)}종 x 노드 5 x 6회 = {int(R['mal'].sum())}건, 정상 전송 변형 {len(BEN)}종 = {int((~R['mal']).sum())}건\")\n", + "print(\"악성 종류별 탐지율: 전체 해시 비교 / 계층 검증(체인+서명+충돌+순번+꼬리)\")\n", + "for k, r in det.iterrows():\n", + " print(f\" {k:28s} {r['naive']:5.0%} / {r['layered']:5.0%}\")\n", + "tp_l, fp_l = int((R[\"alarm\"] & R[\"mal\"]).sum()), int((R[\"alarm\"] & ~R[\"mal\"]).sum())\n", + "tp_n, fp_n = int((R[\"naive\"] & R[\"mal\"]).sum()), int((R[\"naive\"] & ~R[\"mal\"]).sum())\n", + "far_n, far_l = fp_n / (tp_n + fp_n), fp_l / max(tp_l + fp_l, 1)\n", + "print(f\"\\n[오경보] 전체 해시 비교: 경보 {tp_n + fp_n}건 중 정상 변형 {fp_n}건(오경보율 {far_n:.1%}), 탐지율 {tp_n / R['mal'].sum():.0%}\")\n", + "print(f\" 계층 검증(정규화 후): 경보 {tp_l + fp_l}건 중 정상 변형 {fp_l}건(오경보율 {far_l:.1%}), 탐지율 {tp_l / R['mal'].sum():.0%}\")\n", + "print(f\"-> 오경보율 {far_n:.1%} -> {far_l:.1%} ({(far_n - far_l) / far_n:.0%} 감축, 목표 40% 이상: {'달성' if (far_n - far_l) / far_n >= 0.4 else '미달'})\")\n", + "miss = R[R[\"mal\"] & ~R[\"alarm\"]][\"kind\"].value_counts().to_dict()\n", + "print(f\"계층 검증 미탐: {miss or '없음'} (꼬리 절단은 동료가 보관한 머리 해시가 있어야 탐지)\")\n", + "\n", + "# 시험 2: 신뢰도 점수와 일관성(같은 로그를 서명 표본 점검 비율을 바꿔가며 20회 점검했을 때 등급이 같은가)\n", + "print(\"\\n[신뢰도 가중치 매트릭스]\", W, \"/ High 0.95 이상, Med 0.75 이상, 체인·서명·충돌·꼬리 실패는 즉시 Low\")\n", + "cons = []\n", + "for n in NODES:\n", + " grades, scores = [], []\n", + " for k in range(20):\n", + " lg = LOGS[n][0] if k % 2 == 0 else tamper(\"중복 전송\", LOGS[n][0]) if k % 4 == 1 else tamper(\"순서 뒤바뀜 전송\", LOGS[n][0])\n", + " s, lvl = confidence(verify(n, lg, HEADS[n])); grades.append(lvl); scores.append(s)\n", + " cons.append((n, pd.Series(grades).value_counts().iloc[0] / 20, np.mean(scores), np.std(scores)))\n", + "cm = np.mean([c[1] for c in cons])\n", + "print(\"정상 로그 20회 반복(정상 전송 변형 포함) 등급 일관성: \" + \", \".join(f\"{n[-1]} {c:.0%}\" for n, c, _, _ in cons) + f\" -> 평균 {cm:.0%} (목표 90% 이상: {'달성' if cm >= .9 else '미달'})\")\n", + "lv = pd.crosstab(R[\"kind\"], R[\"lvl\"]).reindex(columns=[\"High\", \"Med\", \"Low\"], fill_value=0)\n", + "print(\"\\n[변형 종류별 신뢰도 등급 분포]\"); print(lv.to_string())\n", + "bad = verify(\"보급소A\", tamper(\"수량 수정\", LOGS[\"보급소A\"][0]), HEADS[\"보급소A\"])\n", + "print(f\"불확실성 표시 예: 수량 수정 로그의 서명 불일치 레코드 {bad['bad_sig']}건 -> 해당 레코드 번호를 군수관에게 제시(전체 폐기 아님)\")\n", + "print(\"※ 서명 키 배포·보관, 단말 분실 시 폐기는 별도 보안 체계가 필요하다. 신뢰도 등급은 점검 항목 가중합이며 통계적 확률이 아니다. 최종 판단은 군수관·보안 담당이 한다.\")\n", + "\n", + "plt.figure(figsize=(8, 3.8)); x = np.arange(len(det))\n", + "plt.bar(x - .2, det[\"naive\"] * 100, .4, label=\"whole-hash compare\", color=\"tab:gray\"); plt.bar(x + .2, det[\"layered\"] * 100, .4, label=\"layered verify\", color=\"tab:blue\")\n", + "plt.xticks(x, [f\"M{i + 1}\" for i in range(len(det))]); plt.ylabel(\"detection rate (%)\"); plt.title(\"Tamper detection by type (M1-M6)\"); plt.legend(); plt.tight_layout(); plt.show()\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 05 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + "[변조 탐지] 악성 6종 x 노드 5 x 6회 = 180건, 정상 전송 변형 3종 = 90건\n", + "악성 종류별 탐지율: 전체 해시 비교 / 계층 검증(체인+서명+충돌+순번+꼬리)\n", + " 기록 삭제 100% / 100%\n", + " 꼬리 절단 100% / 100%\n", + " 수량 수정 100% / 100%\n", + " 순서 바꿈+시각 수정 100% / 100%\n", + " 위조 삽입(서명 없음) 100% / 100%\n", + " 재전송 변조(같은 ID, 다른 수량) 100% / 100%\n", + "\n", + "[오경보] 전체 해시 비교: 경보 270건 중 정상 변형 90건(오경보율 33.3%), 탐지율 100%\n", + " 계층 검증(정규화 후): 경보 180건 중 정상 변형 0건(오경보율 0.0%), 탐지율 100%\n", + "-> 오경보율 33.3% -> 0.0% (100% 감축, 목표 40% 이상: 달성)\n", + "계층 검증 미탐: 없음 (꼬리 절단은 동료가 보관한 머리 해시가 있어야 탐지)\n", + "\n", + "[신뢰도 가중치 매트릭스] {'chain': 0.3, 'sig': 0.3, 'conflict': 0.15, 'seq': 0.1, 'time': 0.05, 'tail': 0.1} / High 0.95 이상, Med 0.75 이상, 체인·서명·충돌·꼬리 실패는 즉시 Low\n", + "정상 로그 20회 반복(정상 전송 변형 포함) 등급 일관성: A 100%, B 100%, C 100%, D 100%, E 100% -> 평균 100% (목표 90% 이상: 달성)\n", + "\n", + "[변형 종류별 신뢰도 등급 분포]\n", + "lvl High Med Low\n", + "kind \n", + "공백 차이(note) 30 0 0\n", + "기록 삭제 0 0 30\n", + "꼬리 절단 0 0 30\n", + "수량 수정 0 0 30\n", + "순서 뒤바뀜 전송 30 0 0\n", + "순서 바꿈+시각 수정 0 0 30\n", + "위조 삽입(서명 없음) 0 0 30\n", + "재전송 변조(같은 ID, 다른 수량) 0 0 30\n", + "중복 전송 30 0 0\n", + "불확실성 표시 예: 수량 수정 로그의 서명 불일치 레코드 1건 -> 해당 레코드 번호를 군수관에게 제시(전체 폐기 아님)\n", + "※ 서명 키 배포·보관, 단말 분실 시 폐기는 별도 보안 체계가 필요하다. 신뢰도 등급은 점검 항목 가중합이며 통계적 확률이 아니다. 최종 판단은 군수관·보안 담당이 한다.\n", + "```\n", + "
\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 06. 오프라인/온라인 전환 상태 및 재고 변동 내역 실시간 대시보드 UI 구현\n", + "**소요시간**: 약 2.5시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "오프라인/온라인 상태 및 재고 변동 내역 실시간 대시보드 UI를 구현한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **보급소별 오프라인/온라인 상태와 품목별 재고 변동 내역, 공급 경보를 보여 주는 실시간 대시보드 화면 모델을 구현하고 갱신 1초 이내·조작 응답 100ms 이내를 확인한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **7일치 트랜잭션 2,100건을 1시간 단위 168회 데이터 스트림으로 흘려 보급소 상태판(온라인·오프라인·미동기 건수·지연 시간), 품목별 재고 게이지(소진 예상 일수와 납기 비교), 변동 내역, 경보 목록 4개 패널의 화면 모델을 갱신 수행하고 검증하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `src/ui/dat10_dashboard.py (또는 웹 UI 컴포넌트)` 형태의 결과물을 완성한다.\n", + "\n", + "### 🔍 평가 및 검증 기준 (PASS 조건)\n", + "#### 미션 판정\n", + "\n", + "**평가 구분:** 필수\n", + "\n", + "**핵심 평가영역:** 전술 작전 상황판으로서의 직관성 및 인지 부하 감소도\n", + "\n", + "#### 미션 PASS 조건\n", + "\n", + "- 정량 기준: 화면 갱신 주기 1초 이내, UI 응답성 100ms 이내\n", + "\n", + "\n", + "- 진행 기준: 주요 지표 시각화 컴포넌트 4종 이상 렌더링 및 실시간 데이터 연동\n", + "\n", + "\n", + "- 검증 방법: 사용자 인터랙션 테스트 및 모의 데이터 스트림 렌더링 검증\n", + "\n", + "\n", + "#### 채점자 판정 메모\n", + "\n", + "> 단순 코드 실행 여부뿐만 아니라 출력된 수치와 근거 자료가 군 실무 요구사항에 부합하는지 면밀히 확인한다.\n", + "\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "\"\"\"5개 보급소의 오프라인/온라인 전환 상태와 품목별 재고 변동 내역을 시간 단위 모의 데이터 스트림으로 갱신하는 실시간 대시보드 화면 모델(상태판·재고 게이지·변동 내역·경보 패널 4종)과 야간 모드·긴급 경보 강조·조작 응답 시간을 구현해 갱신 주기 1초 이내·UI 응답 100ms 이내를 측정한다.\"\"\"\n", + "import time\n", + "import numpy as np\n", + "import pandas as pd\n", + "import matplotlib.pyplot as plt\n", + "\n", + "# ---------------------------------------------------------------------------\n", + "# [교육용가상자료] 가상 야전 보급소·업체·품목 자료 생성기 (실제 부대·업체·품목·재고 정보 없음)\n", + "# 가상 보급소 5곳이 품목 12종의 입고·불출·조정 트랜잭션을 오프라인으로 기록하고 나중에 동기화한다.\n", + "# 공급망 재난 징후(업체 납기 지연, 품목 재고 급감)는 동기화된 자료를 마이닝해 식별한다.\n", + "# ---------------------------------------------------------------------------\n", + "NODES = [\"보급소A\", \"보급소B\", \"보급소C\", \"보급소D\", \"보급소E\"]\n", + "SUPPLIERS = [\"가상업체1\", \"가상업체2\", \"가상업체3\", \"가상업체4\", \"가상업체5\", \"가상업체6\"]\n", + "CATS = [\"일반보급\", \"정비부품\", \"의무소모\"]\n", + "ITEMS = [{\"item\": f\"I{i + 1:02d}\", \"cat\": CATS[i % 3], \"supplier\": SUPPLIERS[i % 6], \"lead_days\": 3 + (i * 5) % 12, \"use\": 4 + (i * 7) % 11, \"min_level\": 30 + 10 * (i % 5)} for i in range(12)]\n", + "ITEM_IDS = [x[\"item\"] for x in ITEMS]\n", + "CTXS = [\"평시\", \"야간\", \"통신불량\"]\n", + "\n", + "\n", + "def make_txns(seed=7, days=7, per_node_day=60, nodes=None):\n", + " \"\"\"노드별 오프라인 트랜잭션 목록. id는 노드-일련번호(멱등 키), t는 시각(시간 단위), ctx는 입력 상황.\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " out = []\n", + " for n in (nodes or NODES):\n", + " seq = 0\n", + " for k in range(days * per_node_day):\n", + " seq += 1\n", + " it = ITEMS[int(rng.integers(len(ITEMS)))]\n", + " t = float(rng.uniform(0, 24 * days))\n", + " ctx = \"야간\" if (t % 24 >= 20 or t % 24 < 5) else \"평시\"\n", + " if rng.random() < 0.12:\n", + " ctx = \"통신불량\"\n", + " r = rng.random()\n", + " if r < 0.22:\n", + " op, q = \"in\", int(it[\"use\"] * rng.uniform(4, 9))\n", + " elif r < 0.95:\n", + " op, q = \"out\", int(max(1, rng.normal(it[\"use\"] * 1.5, 3)))\n", + " else:\n", + " op, q = \"adjust\", int(rng.integers(-5, 6))\n", + " out.append({\"id\": f\"{n}-{seq:05d}\", \"node\": n, \"item\": it[\"item\"], \"op\": op, \"qty\": q, \"t\": round(t, 2), \"ctx\": ctx, \"supplier\": it[\"supplier\"]})\n", + " return out\n", + "\n", + "\n", + "def stock_of(txns, base=500):\n", + " \"\"\"품목별 재고 = 기초 재고 + 입고 - 불출 + 조정 (id 중복은 한 번만 반영).\"\"\"\n", + " # [TODO : 학생 실습 6] 오프라인/온라인 전환 상태 및 재고 변동 내역 실시간 대시보드 UI 구현 관련 핵심 로직을 구현하세요.\n", + " # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n", + " # <<< 여기에 코드를 작성하세요 >>>\n", + " pass # 작성 후 pass를 제거하세요.\n", + "THEMES = {\"주간\": {\"bg\": (255, 255, 255), \"fg\": (30, 30, 30), \"ok\": (30, 120, 60), \"warn\": (150, 90, 0), \"crit\": (190, 30, 30), \"off\": (90, 90, 90)},\n", + " \"야간\": {\"bg\": (18, 22, 28), \"fg\": (225, 230, 235), \"ok\": (90, 210, 130), \"warn\": (245, 190, 70), \"crit\": (255, 110, 110), \"off\": (160, 165, 170)}}\n", + "\n", + "\n", + "def lum(c):\n", + " # [TODO : 학생 실습 6] 오프라인/온라인 전환 상태 및 재고 변동 내역 실시간 대시보드 UI 구현 관련 핵심 로직을 구현하세요.\n", + " # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n", + " # <<< 여기에 코드를 작성하세요 >>>\n", + " pass # 작성 후 pass를 제거하세요.\n", + "def contrast(a, b):\n", + " la, lb = sorted([lum(a), lum(b)], reverse=True)\n", + " return (la + 0.05) / (lb + 0.05)\n", + "\n", + "\n", + "class Dashboard:\n", + " \"\"\"화면 모델: 매 tick(1시간)마다 새 트랜잭션을 반영하고 4개 패널의 표시 값을 갱신한다. 렌더링은 모델과 분리해 응답 시간을 따로 잰다.\"\"\"\n", + " def __init__(self, txns, outages, base=500):\n", + " self.tx = sorted(txns, key=lambda x: x[\"t\"]); self.p = 0; self.outages = outages\n", + " self.central = {i: base for i in ITEM_IDS}; self.local = {n: {i: 0 for i in ITEM_IDS} for n in NODES} # 서버 반영 재고 / 노드별 미동기 변동\n", + " self.pending = {n: 0 for n in NODES}; self.last_sync = {n: 0.0 for n in NODES}; self.feed = []; self.theme = \"주간\"; self.node_filter = None; self.only_alert = False; self.acked = set(); self.out24 = {i: [] for i in ITEM_IDS}\n", + "\n", + " def online(self, n, t):\n", + " return not any(a <= t < b for a, b in self.outages.get(n, []))\n", + "\n", + " def step(self, t):\n", + " while self.p < len(self.tx) and self.tx[self.p][\"t\"] < t:\n", + " x = self.tx[self.p]; self.p += 1; d = x[\"qty\"] if x[\"op\"] in (\"in\", \"adjust\") else -x[\"qty\"]\n", + " if self.online(x[\"node\"], x[\"t\"]):\n", + " self.central[x[\"item\"]] += d; self.last_sync[x[\"node\"]] = x[\"t\"]\n", + " else:\n", + " self.local[x[\"node\"]][x[\"item\"]] += d; self.pending[x[\"node\"]] += 1\n", + " if x[\"op\"] == \"out\":\n", + " self.out24[x[\"item\"]].append((x[\"t\"], x[\"qty\"]))\n", + " self.feed.append((x[\"t\"], x[\"node\"], x[\"item\"], x[\"op\"], x[\"qty\"], \"반영\" if self.online(x[\"node\"], x[\"t\"]) else \"대기\"))\n", + " for n in NODES: # 복구된 노드는 대기분을 한꺼번에 반영\n", + " if self.online(n, t) and self.pending[n]:\n", + " for i, v in self.local[n].items():\n", + " self.central[i] += v; self.local[n][i] = 0\n", + " self.pending[n] = 0; self.last_sync[n] = t\n", + " return self.view(t)\n", + "\n", + " def view(self, t):\n", + " status = [{\"node\": n, \"state\": \"온라인\" if self.online(n, t) else \"오프라인\", \"pending\": self.pending[n], \"lag_h\": round(t - self.last_sync[n], 1)} for n in NODES]\n", + " gauges = []\n", + " for it in ITEMS:\n", + " st = self.central[it[\"item\"]] + sum(self.local[n][it[\"item\"]] for n in NODES)\n", + " o24 = sum(q for (tt, q) in self.out24[it[\"item\"]] if tt >= t - 24)\n", + " cover = st / max(o24, 1) if t >= 24 else 99.0 # 최근 24시간 불출량 기준 소진 예상 일수\n", + " lvl = \"긴급\" if (st < it[\"min_level\"] or cover < 0.5 * it[\"lead_days\"]) else \"주의\" if cover < it[\"lead_days\"] else \"정상\"\n", + " gauges.append({\"item\": it[\"item\"], \"stock\": st, \"min\": it[\"min_level\"], \"lvl\": lvl, \"cover\": cover, \"lead\": it[\"lead_days\"], \"acked\": it[\"item\"] in self.acked})\n", + " if self.only_alert:\n", + " gauges = [g for g in gauges if g[\"lvl\"] != \"정상\"]\n", + " feed = [f for f in self.feed[-200:] if not self.node_filter or f[1] == self.node_filter][-6:]\n", + " alerts = [g for g in gauges if g[\"lvl\"] == \"긴급\" and not g[\"acked\"]] + [dict(node=s[\"node\"], lvl=\"장기 오프라인\") for s in status if s[\"lag_h\"] >= 48]\n", + " return {\"t\": t, \"status\": status, \"gauges\": gauges, \"feed\": feed, \"alerts\": alerts, \"theme\": self.theme}\n", + "\n", + " # ---- 사용자 조작 핸들러 ----\n", + " def set_theme(self, name):\n", + " self.theme = name; return self.view(self.tx[self.p - 1][\"t\"] if self.p else 0)\n", + "\n", + " def set_node(self, n):\n", + " self.node_filter = n; return self.view(self.tx[self.p - 1][\"t\"])\n", + "\n", + " def toggle_alert(self):\n", + " self.only_alert = not self.only_alert; return self.view(self.tx[self.p - 1][\"t\"])\n", + "\n", + " def ack(self, item):\n", + " self.acked.add(item); return self.view(self.tx[self.p - 1][\"t\"])\n", + "\n", + "\n", + "rng = np.random.default_rng(21)\n", + "TX = make_txns(7, 7, per_node_day=60)\n", + "OUT = {\"보급소B\": [(24.0, 96.0)], \"보급소D\": [(60.0, 120.0), (140.0, 150.0)], \"보급소E\": [(10.0, 168.5)]} # 오프라인 구간(시간)\n", + "db = Dashboard(TX, OUT)\n", + "lat, snaps, off_n = [], {}, []\n", + "for h in range(1, 169):\n", + " t0 = time.perf_counter(); v = db.step(float(h)); lat.append((time.perf_counter() - t0) * 1000)\n", + " off_n.append(sum(s[\"state\"] == \"오프라인\" for s in v[\"status\"]))\n", + " if h in (12, 72, 168):\n", + " snaps[h] = v\n", + "lat = np.array(lat)\n", + "print(f\"[모의 스트림] {len(TX)}건 트랜잭션, 168회 갱신(1시간 단위), 오프라인 구간 {sum(len(x) for x in OUT.values())}개\")\n", + "print(f\"화면 모델 갱신 지연: 평균 {lat.mean():.2f}ms, 95% {np.percentile(lat, 95):.2f}ms, 최대 {lat.max():.2f}ms (목표 1초 이내: {'달성' if lat.max() < 1000 else '미달'})\")\n", + "for h, v in snaps.items():\n", + " print(f\" {h:3d}h 시점: 오프라인 {sum(s['state'] == '오프라인' for s in v['status'])}곳, 미동기 {sum(s['pending'] for s in v['status'])}건, 긴급/주의 품목 {sum(g['lvl'] == '긴급' for g in v['gauges'])}/{sum(g['lvl'] == '주의' for g in v['gauges'])}종, 경보 {len(v['alerts'])}건\")\n", + "last = snaps[168]\n", + "print(\"\\n[보급소 상태판 168h] \" + \" | \".join(f\"{s['node'][-1]} {s['state']} 대기{s['pending']} 지연{s['lag_h']}h\" for s in last[\"status\"]))\n", + "print(\"[재고 경보 품목] \" + \", \".join(f\"{g['item']}:{g['stock']}/소진{g['cover']:.1f}일/납기{g['lead']}일({g['lvl']})\" for g in [x for x in last[\"gauges\"] if x[\"lvl\"] != \"정상\"][:4]) + \" ...\")\n", + "print(\"[변동 내역] \" + \"; \".join(f\"{f[1][-1]}-{f[2]} {f[3]} {f[4]} {f[5]}\" for f in last[\"feed\"][-3:]))\n", + "\n", + "# ---- 사용자 조작 시험: 100회씩 응답 시간 ----\n", + "def timeit(fn, k=100):\n", + " r = []\n", + " for _ in range(k):\n", + " t0 = time.perf_counter(); fn(); r.append((time.perf_counter() - t0) * 1000)\n", + " return np.array(r)\n", + "ops = {\"야간 모드 전환\": lambda: (db.set_theme(\"야간\"), db.set_theme(\"주간\")), \"보급소 필터\": lambda: (db.set_node(\"보급소C\"), db.set_node(None)),\n", + " \"경보만 보기\": lambda: (db.toggle_alert(), db.toggle_alert()), \"경보 확인(ack)\": lambda: (db.ack(\"I01\"), db.acked.clear())}\n", + "print(\"\\n[UI 조작 응답 시간 ms] 조작 / 평균 / 95% / 최대\")\n", + "worst = 0\n", + "for k, fn in ops.items():\n", + " r = timeit(fn); worst = max(worst, r.max()); print(f\" {k:12s} {r.mean():6.3f} {np.percentile(r, 95):6.3f} {r.max():6.3f}\")\n", + "print(f\"-> 최대 {worst:.2f}ms (목표 100ms 이내: {'달성' if worst < 100 else '미달'})\")\n", + "\n", + "# ---- 야간 모드 가독성(WCAG 대비 4.5 이상)과 긴급 경보 강조 ----\n", + "print(\"\\n[테마 대비비(글자-배경)]\")\n", + "ok = True\n", + "for nm, th in THEMES.items():\n", + " cs = {k: contrast(th[k], th[\"bg\"]) for k in (\"fg\", \"ok\", \"warn\", \"crit\", \"off\")}; ok &= all(c >= 4.5 for c in cs.values())\n", + " print(f\" {nm}: \" + \", \".join(f\"{k} {c:.1f}\" for k, c in cs.items()))\n", + "print(f\"모든 색상 대비 4.5 이상: {'달성' if ok else '미달'}\")\n", + "\n", + "# ---- 렌더링(4개 패널) ----\n", + "th = THEMES[\"야간\"]; col = lambda k: tuple(c / 255 for c in th[k])\n", + "t0 = time.perf_counter()\n", + "fig, ax = plt.subplots(2, 2, figsize=(10, 6), facecolor=col(\"bg\"))\n", + "for a in ax.ravel():\n", + " a.set_facecolor(col(\"bg\")); a.tick_params(colors=col(\"fg\")); [s.set_color(col(\"off\")) for s in a.spines.values()]\n", + "a = ax[0, 0]; a.set_title(\"Node status (pending tx)\", color=col(\"fg\"))\n", + "a.bar([s[\"node\"][-1] for s in last[\"status\"]], [s[\"pending\"] for s in last[\"status\"]], color=[col(\"ok\") if s[\"state\"] == \"온라인\" else col(\"crit\") for s in last[\"status\"]])\n", + "a = ax[0, 1]; a.set_title(\"Stock vs minimum level\", color=col(\"fg\")); ys = np.arange(len(last[\"gauges\"]))\n", + "a.barh(ys, [g[\"stock\"] for g in last[\"gauges\"]], color=[col({\"정상\": \"ok\", \"주의\": \"warn\", \"긴급\": \"crit\"}[g[\"lvl\"]]) for g in last[\"gauges\"]])\n", + "a.scatter([g[\"min\"] for g in last[\"gauges\"]], ys, color=col(\"fg\"), marker=\"|\", s=80); a.set_yticks(ys); a.set_yticklabels([g[\"item\"] for g in last[\"gauges\"]], fontsize=6)\n", + "a = ax[1, 0]; a.set_title(\"Offline nodes over time\", color=col(\"fg\")); a.step(range(1, 169), off_n, color=col(\"warn\")); a.set_xlabel(\"hour\", color=col(\"fg\"))\n", + "a = ax[1, 1]; a.set_title(\"Alert count by stage\", color=col(\"fg\"))\n", + "a.bar([\"critical\", \"caution\", \"normal\"], [sum(g[\"lvl\"] == k for g in last[\"gauges\"]) for k in (\"긴급\", \"주의\", \"정상\")], color=[col(\"crit\"), col(\"warn\"), col(\"ok\")])\n", + "fig.tight_layout(); fig.canvas.draw(); rt = (time.perf_counter() - t0) * 1000\n", + "print(f\"\\n렌더링 4개 패널 1회 그리기 {rt:.0f}ms (갱신 1초 이내: {'달성' if rt + lat.max() < 1000 else '미달'}, 응답성 100ms는 화면 모델 조작 기준)\")\n", + "print(\"※ 대시보드는 판단을 대신하지 않는다. 경보 확인(ack)과 재고 조치는 군수관이 한다. 화면의 재고는 동기화된 값과 노드별 미동기 변동의 합이다.\")\n", + "plt.show()\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 06 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + "[모의 스트림] 2100건 트랜잭션, 168회 갱신(1시간 단위), 오프라인 구간 4개\n", + "화면 모델 갱신 지연: 평균 0.14ms, 95% 0.20ms, 최대 1.36ms (목표 1초 이내: 달성)\n", + " 12h 시점: 오프라인 1곳, 미동기 6건, 긴급/주의 품목 0/0종, 경보 0건\n", + " 72h 시점: 오프라인 3곳, 미동기 314건, 긴급/주의 품목 7/3종, 경보 9건\n", + " 168h 시점: 오프라인 1곳, 미동기 400건, 긴급/주의 품목 6/3종, 경보 7건\n", + "\n", + "[보급소 상태판 168h] A 온라인 대기0 지연1.6h | B 온라인 대기0 지연0.1h | C 온라인 대기0 지연0.2h | D 온라인 대기0 지연0.2h | E 오프라인 대기400 지연160.4h\n", + "[재고 경보 품목] I02:679/소진2.0일/납기8일(긴급), I03:524/소진3.8일/납기13일(긴급), I04:997/소진2.5일/납기6일(긴급), I05:1503/소진4.9일/납기11일(긴급) ...\n", + "[변동 내역] C-I05 out 15 반영; C-I03 in 42 반영; B-I02 in 87 반영\n", + "\n", + "[UI 조작 응답 시간 ms] 조작 / 평균 / 95% / 최대\n", + " 야간 모드 전환 0.423 0.498 1.075\n", + " 보급소 필터 0.467 0.503 0.627\n", + " 경보만 보기 0.462 0.497 0.640\n", + " 경보 확인(ack) 0.121 0.149 0.218\n", + "-> 최대 1.08ms (목표 100ms 이내: 달성)\n", + "\n", + "[테마 대비비(글자-배경)]\n", + " 주간: fg 16.7, ok 5.5, warn 5.6, crit 6.2, off 6.9\n", + " 야간: fg 14.4, ok 9.5, warn 10.7, crit 6.7, off 7.3\n", + "모든 색상 대비 4.5 이상: 달성\n", + "\n", + "렌더링 4개 패널 1회 그리기 349ms (갱신 1초 이내: 달성, 응답성 100ms는 화면 모델 조작 기준)\n", + "※ 대시보드는 판단을 대신하지 않는다. 경보 확인(ack)과 재고 조치는 군수관이 한다. 화면의 재고는 동기화된 값과 노드별 미동기 변동의 합이다.\n", + "```\n", + "
\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 07. 동기화 충돌 발생 시 군수관 수동 병합(Conflict Resolution) HITL 워크플로우 설계\n", + "**소요시간**: 약 3.0시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "동기화 충돌 발생 시 군수관 수동 병합(Conflict Resolution) HITL 워크플로우를 설계한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **동기화 충돌이 발생했을 때 시스템이 값을 자동 확정하지 않고 군수관이 수동 병합하며, 영향이 큰 건은 지휘관이 승인하는 HITL 워크플로우를 구현해 검토 큐 대기시간과 처리 로깅 성공률 100%를 확인한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **충돌 12건(실사 수량·최소 보유 기준을 두 보급소가 다르게 수정)을 시스템 제안 - 보급담당 상신 - 군수관 검토와 수동 병합 - 확정(차이 25% 이상이면 지휘관 승인) 순서로 처리하고, 병합 전후 값과 사유·근거를 기록하며 반려 사유를 필수로 수행하고 검증하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `src/hitl/dat10_approval_workflow.py 및 docs/dat10_hitl_guide.md` 형태의 결과물을 완성한다.\n", + "\n", + "### 🔍 평가 및 검증 기준 (PASS 조건)\n", + "#### 미션 판정\n", + "\n", + "**평가 구분:** 필수\n", + "\n", + "**핵심 평가영역:** 지휘 책임 분계선 명확화 및 성급한 자동 단정 방지 체계 부합성\n", + "\n", + "#### 미션 PASS 조건\n", + "\n", + "- 정량 기준: 인간 검토 큐(Queue) 대기시간 및 승인/반려 처리 로깅 성공률 100%\n", + "\n", + "\n", + "- 진행 기준: 승인/반려 인터페이스 동작 및 감사 로그(Audit Log) 생성 검증\n", + "\n", + "\n", + "- 검증 방법: 모의 조작자 승인/반려 시뮬레이션 및 데이터 무결성 검증\n", + "\n", + "\n", + "#### 채점자 판정 메모\n", + "\n", + "> 단순 코드 실행 여부뿐만 아니라 출력된 수치와 근거 자료가 군 실무 요구사항에 부합하는지 면밀히 확인한다.\n", + "\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "\"\"\"동기화 충돌 건을 시스템 제안 -> 보급담당 상신 -> 군수관 수동 병합 -> (영향이 큰 건은 지휘관 승인) 순서의 HITL 워크플로우로 처리하되, 자동 확정 금지·병합 전후 값과 사유 기록·반려 사유 필수화·권한 우회 차단·해시 체인 감사 로그·지휘관 피드백 입력 검증을 구현하고 위험도 우선 큐(대기 노화 가산)와 선입선출의 대기시간을 비교한다.\"\"\"\n", + "import hashlib\n", + "import json\n", + "import numpy as np\n", + "import pandas as pd\n", + "import matplotlib.pyplot as plt\n", + "\n", + "# ---------------------------------------------------------------------------\n", + "# [교육용가상자료] 가상 야전 보급소·업체·품목 자료 생성기 (실제 부대·업체·품목·재고 정보 없음)\n", + "# 가상 보급소 5곳이 품목 12종의 입고·불출·조정 트랜잭션을 오프라인으로 기록하고 나중에 동기화한다.\n", + "# 공급망 재난 징후(업체 납기 지연, 품목 재고 급감)는 동기화된 자료를 마이닝해 식별한다.\n", + "# ---------------------------------------------------------------------------\n", + "NODES = [\"보급소A\", \"보급소B\", \"보급소C\", \"보급소D\", \"보급소E\"]\n", + "SUPPLIERS = [\"가상업체1\", \"가상업체2\", \"가상업체3\", \"가상업체4\", \"가상업체5\", \"가상업체6\"]\n", + "CATS = [\"일반보급\", \"정비부품\", \"의무소모\"]\n", + "ITEMS = [{\"item\": f\"I{i + 1:02d}\", \"cat\": CATS[i % 3], \"supplier\": SUPPLIERS[i % 6], \"lead_days\": 3 + (i * 5) % 12, \"use\": 4 + (i * 7) % 11, \"min_level\": 30 + 10 * (i % 5)} for i in range(12)]\n", + "ITEM_IDS = [x[\"item\"] for x in ITEMS]\n", + "CTXS = [\"평시\", \"야간\", \"통신불량\"]\n", + "\n", + "\n", + "def make_txns(seed=7, days=7, per_node_day=60, nodes=None):\n", + " \"\"\"노드별 오프라인 트랜잭션 목록. id는 노드-일련번호(멱등 키), t는 시각(시간 단위), ctx는 입력 상황.\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " out = []\n", + " for n in (nodes or NODES):\n", + " seq = 0\n", + " for k in range(days * per_node_day):\n", + " seq += 1\n", + " it = ITEMS[int(rng.integers(len(ITEMS)))]\n", + " t = float(rng.uniform(0, 24 * days))\n", + " ctx = \"야간\" if (t % 24 >= 20 or t % 24 < 5) else \"평시\"\n", + " if rng.random() < 0.12:\n", + " ctx = \"통신불량\"\n", + " r = rng.random()\n", + " if r < 0.22:\n", + " op, q = \"in\", int(it[\"use\"] * rng.uniform(4, 9))\n", + " elif r < 0.95:\n", + " op, q = \"out\", int(max(1, rng.normal(it[\"use\"] * 1.5, 3)))\n", + " else:\n", + " op, q = \"adjust\", int(rng.integers(-5, 6))\n", + " out.append({\"id\": f\"{n}-{seq:05d}\", \"node\": n, \"item\": it[\"item\"], \"op\": op, \"qty\": q, \"t\": round(t, 2), \"ctx\": ctx, \"supplier\": it[\"supplier\"]})\n", + " return out\n", + "\n", + "\n", + "def stock_of(txns, base=500):\n", + " \"\"\"품목별 재고 = 기초 재고 + 입고 - 불출 + 조정 (id 중복은 한 번만 반영).\"\"\"\n", + " # [TODO : 학생 실습 7] 동기화 충돌 발생 시 군수관 수동 병합(Conflict Resolution) HITL 워크플로우 설계 관련 핵심 로직을 구현하세요.\n", + " # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n", + " # <<< 여기에 코드를 작성하세요 >>>\n", + " pass # 작성 후 pass를 제거하세요.\n", + "PERM = {\"시스템\": {\"propose\"}, \"보급담당\": {\"submit\", \"redraft\", \"view\"}, \"군수관\": {\"review\", \"merge\", \"confirm\", \"reject\", \"view\"}, \"지휘관\": {\"approve\", \"reject\", \"feedback\", \"view\"}, \"감사관\": {\"view\", \"audit\"}}\n", + "USERS = {\"sys\": \"시스템\", \"kim\": \"보급담당\", \"lee\": \"군수관\", \"park\": \"지휘관\", \"jung\": \"감사관\"}\n", + "IMPACT_CUT = 0.25 # 두 값의 차이가 큰 쪽 값의 25% 이상이면 지휘관 승인 필요\n", + "MAX_REDRAFT = 2\n", + "FB_CATS = {\"우선순위\", \"자원\", \"절차\", \"기타\"} # 지휘관 피드백 입력 폼 분류\n", + "SLA_H = 4.0 # 군수관 검토 대기 허용 시간(h)\n", + "\n", + "\n", + "class Chain:\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", + "class Workflow:\n", + " def __init__(self):\n", + " self.audit, self.cases, self.calls = Chain(), {}, 0\n", + "\n", + " def _deny(self, who, case, action, why):\n", + " self.audit.add(event=\"DENIED\", who=who, case=case, action=action, reason=why); return False, why\n", + "\n", + " def act(self, who, case, action, reason=\"\", evidence=\"\", value=None, cat=None, cf=None):\n", + " self.calls += 1\n", + " role = USERS.get(who)\n", + " if role is None:\n", + " return self._deny(who, case, action, \"미등록 사용자\")\n", + " if action in (\"delete_log\", \"edit_log\"):\n", + " return self._deny(who, case, action, \"감사 로그 삭제·수정 권한은 누구에게도 없음\")\n", + " if action not in PERM[role]:\n", + " return self._deny(who, case, action, f\"권한 없음({role} 역할에 {action} 권한 없음)\")\n", + " if action == \"propose\":\n", + " a, b = cf\n", + " self.cases[case] = {\"state\": \"제안\", \"submitter\": None, \"version\": 1, \"a\": a, \"b\": b, \"merged\": None, \"high\": abs(a - b) / max(abs(a), abs(b), 1) >= IMPACT_CUT, \"feedback\": []}\n", + " self.audit.add(event=\"PROPOSE\", who=who, case=case, a=a, b=b, reason=reason); return True, \"제안 등록(자동 확정 없음)\"\n", + " c = self.cases.get(case)\n", + " if c is None:\n", + " return self._deny(who, case, action, \"존재하지 않는 건\")\n", + " if c[\"state\"] in (\"확정\", \"종결(반려)\"):\n", + " return self._deny(who, case, action, f\"이미 종결된 건({c['state']})\")\n", + " if not reason.strip() or not evidence.strip():\n", + " return self._deny(who, case, action, \"사유·근거 입력 필수(반려·병합 사유 포함)\")\n", + " if action == \"feedback\":\n", + " if cat not in FB_CATS:\n", + " return self._deny(who, case, action, f\"피드백 분류 오류({cat}), 허용 {sorted(FB_CATS)}\")\n", + " c[\"feedback\"].append((cat, reason))\n", + " self.audit.add(event=\"FEEDBACK\", who=who, role=role, case=case, cat=cat, reason=reason, evidence=evidence); return True, \"지휘관 피드백 기록\"\n", + " if action == \"merge\":\n", + " if c[\"state\"] != \"군수관검토\":\n", + " return self._deny(who, case, action, f\"병합은 군수관 검토 단계에서만 가능(현재 {c['state']})\")\n", + " lo, hi = min(c[\"a\"], c[\"b\"]), max(c[\"a\"], c[\"b\"])\n", + " if value is None or not (lo - 0.2 * abs(lo) <= value <= hi + 0.2 * abs(hi)):\n", + " return self._deny(who, case, action, f\"병합 값 범위 초과(허용 {lo - 0.2 * abs(lo):.0f}~{hi + 0.2 * abs(hi):.0f}, 입력 {value})\")\n", + " before, c[\"merged\"] = c[\"merged\"], value\n", + " self.audit.add(event=\"MERGE\", who=who, role=role, case=case, before=before, after=value, a=c[\"a\"], b=c[\"b\"], reason=reason, evidence=evidence); return True, f\"병합 값 {before} -> {value}\"\n", + " if action == \"reject\":\n", + " if c[\"state\"] in (\"제안\", \"반려\"):\n", + " return self._deny(who, case, action, f\"반려할 수 없는 단계({c['state']})\")\n", + " c[\"state\"] = \"종결(반려)\" if c[\"version\"] > MAX_REDRAFT else \"반려\"\n", + " self.audit.add(event=\"REJECTED\", who=who, role=role, case=case, version=c[\"version\"], reason=reason, evidence=evidence); return True, c[\"state\"]\n", + " if action == \"redraft\":\n", + " if c[\"state\"] != \"반려\":\n", + " return self._deny(who, case, action, f\"재기안은 반려된 건만 가능(현재 {c['state']})\")\n", + " if who != c[\"submitter\"]:\n", + " return self._deny(who, case, action, \"재기안은 최초 상신자만 가능\")\n", + " c[\"state\"], c[\"version\"] = \"상신\", c[\"version\"] + 1\n", + " self.audit.add(event=\"REDRAFT\", who=who, role=role, case=case, version=c[\"version\"], reason=reason, evidence=evidence); return True, \"재기안 상신\"\n", + " if c[\"state\"] == \"반려\":\n", + " return self._deny(who, case, action, \"반려된 건은 재기안 후에만 진행 가능\")\n", + " need = {\"제안\": \"submit\", \"상신\": \"review\", \"군수관검토\": \"approve\" if c[\"high\"] else \"confirm\"}[c[\"state\"]]\n", + " if action != need:\n", + " return self._deny(who, case, action, f\"단계 위반(현재 {c['state']}{'·영향 큼' if c['high'] else ''}, 필요 행위 {need})\")\n", + " if action == \"confirm\" and c[\"merged\"] is None or action == \"approve\" and c[\"merged\"] is None:\n", + " return self._deny(who, case, action, \"군수관 병합 값 없음(자동 확정 금지)\")\n", + " if action == \"submit\":\n", + " c[\"submitter\"] = who\n", + " c[\"state\"] = {\"submit\": \"상신\", \"review\": \"군수관검토\", \"confirm\": \"확정\", \"approve\": \"확정\"}[action]\n", + " self.audit.add(event=action.upper(), who=who, role=role, case=case, version=c[\"version\"], to=c[\"state\"], merged=c[\"merged\"], reason=reason, evidence=evidence); return True, c[\"state\"]\n", + "\n", + "\n", + "# ---- 충돌 건 12개: 같은 품목의 재고 실사 수량·최소 보유 기준을 두 보급소가 서로 다르게 수정 ----\n", + "rng = np.random.default_rng(5)\n", + "cands = []\n", + "for k in range(12):\n", + " it = ITEMS[k]; field = \"실사 수량\" if k % 3 else \"최소 보유 기준\"\n", + " base = 500 if field == \"실사 수량\" else it[\"min_level\"]\n", + " a = int(base * rng.uniform(0.9, 1.1)); b = int(a * rng.choice([1.05, 1.1, 1.4, 1.8, 0.5]))\n", + " cover = rng.uniform(1.5, 14) # 소진 예상 일수 (납기 대비 부족하면 공급망 재난 징후)\n", + " risk = 100 * (0.5 * min(abs(a - b) / max(a, b) / 0.5, 1) + 0.5 * max(0, 1 - cover / it[\"lead_days\"]))\n", + " cands.append({\"id\": f\"C{k + 1:02d}\", \"item\": it[\"item\"], \"field\": field, \"a\": a, \"b\": b, \"cover\": cover, \"lead\": it[\"lead_days\"], \"risk\": risk})\n", + "cd = pd.DataFrame(cands)\n", + "print(f\"[충돌 건] {len(cd)}건, 영향 큼(차이 {IMPACT_CUT:.0%} 이상) {sum(abs(r.a - r.b) / max(r.a, r.b) >= IMPACT_CUT for r in cd.itertuples())}건, 위험도 평균 {cd['risk'].mean():.0f}\")\n", + "\n", + "# ---- 큐 시뮬레이션: 군수관 1명(건당 2시간), 건이 1시간 간격으로 도착 ----\n", + "def run_queue(policy, risks, service=2.0, arr_gap=1.0):\n", + " # [TODO : 학생 실습 7] 동기화 충돌 발생 시 군수관 수동 병합(Conflict Resolution) HITL 워크플로우 설계 관련 핵심 로직을 구현하세요.\n", + " # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n", + " # <<< 여기에 코드를 작성하세요 >>>\n", + " pass # 작성 후 pass를 제거하세요.\n", + "rs = list(rng.permutation(cd[\"risk\"].to_numpy()))\n", + "wf_, wp_ = run_queue(\"fifo\", rs), run_queue(\"prio\", rs)\n", + "hi = [i for i, r in enumerate(rs) if r >= 50]\n", + "print(f\"\\n[검토 큐 대기시간(h)] 건 {len(rs)}개, 위험 50 이상 {len(hi)}건, 허용 {SLA_H:.0f}h\")\n", + "for nm, w in ((\"선입선출\", wf_), (\"위험도 우선(노화 가산)\", wp_)):\n", + " print(f\" {nm:14s}: 전체 평균 {np.mean(list(w.values())):.1f}h, 고위험 평균 {np.mean([w[i] for i in hi]):.1f}h, 최대 {max(w.values()):.1f}h, 허용 초과 {sum(v > SLA_H for v in w.values())}건\")\n", + "\n", + "# ---- 워크플로우 모의 ----\n", + "wf = Workflow(); res = []\n", + "for k, r in cd.iterrows():\n", + " case = r[\"id\"]\n", + " wf.act(\"sys\", case, \"propose\", reason=f\"{r['item']} {r['field']} 충돌: A {r['a']} / B {r['b']}\", cf=(int(r[\"a\"]), int(r[\"b\"])))\n", + " wf.act(\"kim\", case, \"submit\", \"수동 병합 검토 요청\", f\"소진 예상 {r['cover']:.1f}일, 납기 {r['lead']}일\")\n", + " for _ in range(12):\n", + " c = wf.cases[case]; s = c[\"state\"]\n", + " if s in (\"확정\", \"종결(반려)\"):\n", + " break\n", + " if s == \"상신\":\n", + " if k in (2, 11) and c[\"version\"] <= (1 if k == 2 else 9): # 2번 건은 1회 반려, 11번 건은 보완 없이 계속 반려(재기안 한도 시연)\n", + " wf.act(\"lee\", case, \"reject\", \"근거 사진·실사표 누락, 보완 요청\", f\"충돌 두 값 {r['a']}/{r['b']}\")\n", + " else:\n", + " wf.act(\"lee\", case, \"review\", \"두 기록과 실사 근거 확인\", f\"소진 예상 {r['cover']:.1f}일\")\n", + " elif s == \"군수관검토\":\n", + " if c[\"merged\"] is None:\n", + " val = int((c[\"a\"] + c[\"b\"]) / 2) if r[\"field\"] == \"실사 수량\" else max(c[\"a\"], c[\"b\"]) # 실사는 평균 후보, 기준은 높은 쪽(안전 우선)\n", + " wf.act(\"lee\", case, \"merge\", \"수동 병합: 현장 실사·납기 고려해 값 결정\", f\"A {c['a']} / B {c['b']}\", value=val)\n", + " if c[\"high\"]:\n", + " wf.act(\"park\", case, \"feedback\", \"영향이 큰 건이므로 선별 확인 후 결정\", \"우선순위\", cat=\"우선순위\")\n", + " wf.act(\"park\", case, \"approve\", \"승인: 병합 값 확정\", f\"차이 {abs(c['a'] - c['b']) / max(c['a'], c['b']):.0%}, 군수관 병합 검토 완료\")\n", + " else:\n", + " wf.act(\"lee\", case, \"confirm\", \"확정: 병합 값 반영\", \"차이가 작아 군수관 확정 범위\")\n", + " elif s == \"반려\":\n", + " wf.act(\"kim\", case, \"redraft\", \"반려 사유 반영해 재기안\", f\"근거 보완 {c['version']}차\")\n", + " c = wf.cases[case]; res.append((case, r[\"field\"], c[\"a\"], c[\"b\"], \"-\" if c[\"merged\"] is None else str(c[\"merged\"]), \"지휘관\" if c[\"high\"] else \"군수관\", c[\"state\"], c[\"version\"]))\n", + "print(\"\\n[처리 결과] (모의 결재자 기준, 실제 판단은 사람이 한다)\")\n", + "print(pd.DataFrame(res, columns=[\"건\", \"항목\", \"A값\", \"B값\", \"병합값\", \"확정권자\", \"상태\", \"버전\"]).head(12).to_string(index=False))\n", + "print(f\"군수관 수동 병합 {sum(e['event'] == 'MERGE' for e in wf.audit.log)}건(전후 값·사유 기록), 반려 {sum(e['event'] == 'REJECTED' for e in wf.audit.log)}건(사유 필수), 지휘관 피드백 {sum(e['event'] == 'FEEDBACK' for e in wf.audit.log)}건\")\n", + "\n", + "# ---- 권한 우회·절차 위반 시도 ----\n", + "def mk(cid, stage=\"submit\", ab=(100, 160)):\n", + " wf.act(\"sys\", cid, \"propose\", reason=\"시험\", cf=ab)\n", + " if stage in (\"submit\", \"review\"):\n", + " wf.act(\"kim\", cid, \"submit\", \"시험 상신\", \"근거\")\n", + " if stage == \"review\":\n", + " wf.act(\"lee\", cid, \"review\", \"검토\", \"이상 없음\")\n", + " return cid\n", + "done_case = next(c for c, v in wf.cases.items() if v[\"state\"] == \"확정\")\n", + "att = [(\"시스템 자동 병합\", lambda: wf.act(\"sys\", mk(\"T01\", \"review\"), \"merge\", \"자동\", \"자동\", value=130)), (\"보급담당이 병합 시도\", lambda: wf.act(\"kim\", mk(\"T02\", \"review\"), \"merge\", \"임의\", \"근거\", value=130)),\n", + " (\"병합 없이 확정\", lambda: wf.act(\"lee\", mk(\"T03\", \"review\", (100, 105)), \"confirm\", \"바로 확정\", \"근거\")), (\"미등록 사용자\", lambda: wf.act(\"guest\", mk(\"T04\"), \"review\", \"열람\", \"근거\")),\n", + " (\"사유 없는 검토\", lambda: wf.act(\"lee\", mk(\"T05\"), \"review\", \"\", \"\")), (\"감사 로그 수정\", lambda: wf.act(\"kim\", mk(\"T06\"), \"edit_log\", \"정리\", \"근거\")),\n", + " (\"감사관이 확정 시도\", lambda: wf.act(\"jung\", mk(\"T07\", \"review\"), \"confirm\", \"확정\", \"근거\")), (\"존재하지 않는 건\", lambda: wf.act(\"park\", \"X99\", \"approve\", \"승인\", \"근거\")),\n", + " (\"종결된 건 재승인\", lambda: wf.act(\"park\", done_case, \"approve\", \"재승인\", \"근거\")), (\"병합 값 범위 초과(100/160 -> 500)\", lambda: wf.act(\"lee\", mk(\"T08\", \"review\"), \"merge\", \"대폭 변경\", \"근거\", value=500)),\n", + " (\"영향 큰 건을 군수관이 확정\", lambda: (wf.act(\"lee\", \"T09\", \"merge\", \"병합\", \"근거\", value=130) if mk(\"T09\", \"review\") else None, wf.act(\"lee\", \"T09\", \"confirm\", \"확정\", \"근거\"))[-1]),\n", + " (\"지휘관이 검토 건너뛰기\", lambda: wf.act(\"park\", mk(\"T10\"), \"approve\", \"바로 승인\", \"근거\")), (\"피드백 분류 오류\", lambda: wf.act(\"park\", mk(\"T11\", \"review\"), \"feedback\", \"의견\", \"근거\", cat=\"잡담\")),\n", + " (\"상신 전 병합\", lambda: wf.act(\"lee\", mk(\"T12\", \"submit\"), \"merge\", \"병합\", \"근거\", value=130))]\n", + "print(\"\\n[권한 우회·절차 위반 시도]\")\n", + "blocked = 0\n", + "for n, fn in att:\n", + " ok, why = fn(); blocked += (not ok); print(f\" {n:26s}-> {'차단' if not ok else '통과(결함)'}: {why}\")\n", + "print(f\"차단 {blocked}/{len(att)} ({blocked / len(att):.0%})\")\n", + "\n", + "# ---- 로깅 성공률과 이력 무결성 ----\n", + "n_log = len(wf.audit.log); bad = wf.audit.verify()\n", + "print(f\"\\n처리 요청 {wf.calls}건 -> 감사 로그 {n_log}건(확정·반려·병합·거부 모두 기록), 로깅 성공률 {n_log / wf.calls:.0%} (목표 100%: {'달성' if n_log == wf.calls else '미달'})\")\n", + "print(f\"해시 체인 검증: {'정상' if bad is None else f'seq {bad} 불일치'} -> 이력 무결성 {'100%' if bad is None else '훼손'}\")\n", + "saved = json.dumps(wf.audit.log[12], ensure_ascii=False); wf.audit.log[12][\"reason\"] = \"사후 수정\"; v = wf.audit.verify()\n", + "print(f\"12번째 기록 임의 수정 후 검증: {'위변조 탐지(seq ' + str(v) + ')' if v is not None else '탐지 실패'}\"); wf.audit.log[12] = json.loads(saved)\n", + "print(\"※ 충돌 병합은 군수관이 하고, 영향이 큰 건은 지휘관이 승인한다. 시스템은 두 값과 근거만 제시하며 자동 단정하지 않는다. 결재선은 소속 기관 규정을 따른다.\")\n", + "\n", + "plt.figure(figsize=(6, 3.5)); plt.bar([\"FIFO\", \"priority\"], [np.mean([wf_[i] for i in hi]), np.mean([wp_[i] for i in hi])], color=[\"tab:gray\", \"tab:blue\"])\n", + "plt.ylabel(\"mean wait of high-risk conflicts (h)\"); plt.title(\"Review queue wait: FIFO vs risk priority\"); plt.tight_layout(); plt.show()\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 07 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + "[충돌 건] 12건, 영향 큼(차이 25% 이상) 5건, 위험도 평균 37\n", + "\n", + "[검토 큐 대기시간(h)] 건 12개, 위험 50 이상 6건, 허용 4h\n", + " 선입선출 : 전체 평균 5.5h, 고위험 평균 5.5h, 최대 11.0h, 허용 초과 7건\n", + " 위험도 우선(노화 가산) : 전체 평균 5.5h, 고위험 평균 0.8h, 최대 13.0h, 허용 초과 5건\n", + "\n", + "[처리 결과] (모의 결재자 기준, 실제 판단은 사람이 한다)\n", + " 건 항목 A값 B값 병합값 확정권자 상태 버전\n", + "C01 최소 보유 기준 31 32 32 군수관 확정 1\n", + "C02 실사 수량 478 239 358 지휘관 확정 1\n", + "C03 실사 수량 488 683 585 지휘관 확정 2\n", + "C04 최소 보유 기준 54 75 75 지휘관 확정 1\n", + "C05 실사 수량 515 927 721 지휘관 확정 1\n", + "C06 실사 수량 547 601 574 군수관 확정 1\n", + "C07 최소 보유 기준 42 44 44 군수관 확정 1\n", + "C08 실사 수량 517 568 542 군수관 확정 1\n", + "C09 실사 수량 505 252 378 지휘관 확정 1\n", + "C10 최소 보유 기준 63 69 69 군수관 확정 1\n", + "C11 실사 수량 537 590 563 군수관 확정 1\n", + "C12 실사 수량 537 590 - 군수관 종결(반려) 3\n", + "군수관 수동 병합 11건(전후 값·사유 기록), 반려 4건(사유 필수), 지휘관 피드백 5건\n", + "\n", + "[권한 우회·절차 위반 시도]\n", + " 시스템 자동 병합 -> 차단: 권한 없음(시스템 역할에 merge 권한 없음)\n", + " 보급담당이 병합 시도 -> 차단: 권한 없음(보급담당 역할에 merge 권한 없음)\n", + " 병합 없이 확정 -> 차단: 군수관 병합 값 없음(자동 확정 금지)\n", + " 미등록 사용자 -> 차단: 미등록 사용자\n", + " 사유 없는 검토 -> 차단: 사유·근거 입력 필수(반려·병합 사유 포함)\n", + " 감사 로그 수정 -> 차단: 감사 로그 삭제·수정 권한은 누구에게도 없음\n", + " 감사관이 확정 시도 -> 차단: 권한 없음(감사관 역할에 confirm 권한 없음)\n", + " 존재하지 않는 건 -> 차단: 존재하지 않는 건\n", + " 종결된 건 재승인 -> 차단: 이미 종결된 건(확정)\n", + " 병합 값 범위 초과(100/160 -> 500)-> 차단: 병합 값 범위 초과(허용 80~192, 입력 500)\n", + " 영향 큰 건을 군수관이 확정 -> 차단: 단계 위반(현재 군수관검토·영향 큼, 필요 행위 approve)\n", + " 지휘관이 검토 건너뛰기 -> 차단: 단계 위반(현재 상신·영향 큼, 필요 행위 review)\n", + " 피드백 분류 오류 -> 차단: 피드백 분류 오류(잡담), 허용 ['기타', '우선순위', '자원', '절차']\n", + " 상신 전 병합 -> 차단: 병합은 군수관 검토 단계에서만 가능(현재 상신)\n", + "차단 14/14 (100%)\n", + "\n", + "처리 요청 115건 -> 감사 로그 115건(확정·반려·병합·거부 모두 기록), 로깅 성공률 100% (목표 100%: 달성)\n", + "... (이하 생략)\n", + "```\n", + "
\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 08. 7일간 통신 단절 후 대량 재고 트랜잭션 일괄 동기화 스트레스 테스트\n", + "**소요시간**: 약 3.5시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "7일간 통신 단절 후 대량 재고 트랜잭션 일괄 동기화 스트레스 테스트를 수행한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **7일간 통신이 끊겼다가 복구된 직후 보급소 5곳의 누적 트랜잭션이 한꺼번에 서버로 몰리는 상황을 기준 100%·200%·500%로 시험해 시스템 다운 0건·데이터 유실 0%와 기능 축소 동작을 확인한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **트랜잭션 2,100건을 기준으로 4,200건·10,500건까지 늘려 배치 50건 단위로 큐와 작업자 스레드 4개가 처리하게 하고, 같은 배치 중복 재전송(5%)·서명 후 수량 변조(1%)·작업자 오류(37배치마다) 주입하여 처리 지연 및 복원력을 시험하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `tests/dat10_stress_test.py 및 스트레스 테스트 분석 보고서` 형태의 결과물을 완성한다.\n", + "\n", + "### 🔍 평가 및 검증 기준 (PASS 조건)\n", + "#### 미션 판정\n", + "\n", + "**평가 구분:** 필수\n", + "\n", + "**핵심 평가영역:** 극단 상황에서의 안전 실패(Fail-safe) 및 기능 축소 작동 적절성\n", + "\n", + "#### 미션 PASS 조건\n", + "\n", + "- 정량 기준: 동시 트래픽 500% 증가 시 시스템 다운 0건, 데이터 유실률 0%\n", + "\n", + "\n", + "- 진행 기준: 대량 시뮬레이션 부하 주입 테스트 통과 및 병목 구간 프로파일링 완료\n", + "\n", + "\n", + "- 검증 방법: 부하 테스트 도구를 통한 처리량(Throughput) 및 메모리 누수 검증\n", + "\n", + "\n", + "#### 채점자 판정 메모\n", + "\n", + "> 단순 코드 실행 여부뿐만 아니라 출력된 수치와 근거 자료가 군 실무 요구사항에 부합하는지 면밀히 확인한다.\n", + "\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "\"\"\"7일간 통신이 끊긴 보급소 5곳의 누적 트랜잭션을 복구 직후 서버로 한꺼번에 올리는 상황을 기준 100%·200%·500%로 늘려 큐 기반 작업자 스레드로 처리하고, 중복 재전송·서명 불일치·작업자 오류를 주입해 다운 0건·유실 0건·기능 축소(표본 서명 점검 후 사후 검증)·병목(목록 탐색 중복 확인을 집합으로 교체)·메모리 증가를 측정한다.\"\"\"\n", + "import hashlib\n", + "import hmac\n", + "import queue\n", + "import threading\n", + "import time\n", + "import tracemalloc\n", + "import numpy as np\n", + "import pandas as pd\n", + "import matplotlib.pyplot as plt\n", + "\n", + "# ---------------------------------------------------------------------------\n", + "# [교육용가상자료] 가상 야전 보급소·업체·품목 자료 생성기 (실제 부대·업체·품목·재고 정보 없음)\n", + "# 가상 보급소 5곳이 품목 12종의 입고·불출·조정 트랜잭션을 오프라인으로 기록하고 나중에 동기화한다.\n", + "# 공급망 재난 징후(업체 납기 지연, 품목 재고 급감)는 동기화된 자료를 마이닝해 식별한다.\n", + "# ---------------------------------------------------------------------------\n", + "NODES = [\"보급소A\", \"보급소B\", \"보급소C\", \"보급소D\", \"보급소E\"]\n", + "SUPPLIERS = [\"가상업체1\", \"가상업체2\", \"가상업체3\", \"가상업체4\", \"가상업체5\", \"가상업체6\"]\n", + "CATS = [\"일반보급\", \"정비부품\", \"의무소모\"]\n", + "ITEMS = [{\"item\": f\"I{i + 1:02d}\", \"cat\": CATS[i % 3], \"supplier\": SUPPLIERS[i % 6], \"lead_days\": 3 + (i * 5) % 12, \"use\": 4 + (i * 7) % 11, \"min_level\": 30 + 10 * (i % 5)} for i in range(12)]\n", + "ITEM_IDS = [x[\"item\"] for x in ITEMS]\n", + "CTXS = [\"평시\", \"야간\", \"통신불량\"]\n", + "\n", + "\n", + "def make_txns(seed=7, days=7, per_node_day=60, nodes=None):\n", + " \"\"\"노드별 오프라인 트랜잭션 목록. id는 노드-일련번호(멱등 키), t는 시각(시간 단위), ctx는 입력 상황.\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " out = []\n", + " for n in (nodes or NODES):\n", + " seq = 0\n", + " for k in range(days * per_node_day):\n", + " seq += 1\n", + " it = ITEMS[int(rng.integers(len(ITEMS)))]\n", + " t = float(rng.uniform(0, 24 * days))\n", + " ctx = \"야간\" if (t % 24 >= 20 or t % 24 < 5) else \"평시\"\n", + " if rng.random() < 0.12:\n", + " ctx = \"통신불량\"\n", + " r = rng.random()\n", + " if r < 0.22:\n", + " op, q = \"in\", int(it[\"use\"] * rng.uniform(4, 9))\n", + " elif r < 0.95:\n", + " op, q = \"out\", int(max(1, rng.normal(it[\"use\"] * 1.5, 3)))\n", + " else:\n", + " op, q = \"adjust\", int(rng.integers(-5, 6))\n", + " out.append({\"id\": f\"{n}-{seq:05d}\", \"node\": n, \"item\": it[\"item\"], \"op\": op, \"qty\": q, \"t\": round(t, 2), \"ctx\": ctx, \"supplier\": it[\"supplier\"]})\n", + " return out\n", + "\n", + "\n", + "def stock_of(txns, base=500):\n", + " \"\"\"품목별 재고 = 기초 재고 + 입고 - 불출 + 조정 (id 중복은 한 번만 반영).\"\"\"\n", + " # [TODO : 학생 실습 8] 7일간 통신 단절 후 대량 재고 트랜잭션 일괄 동기화 스트레스 테스트 관련 핵심 로직을 구현하세요.\n", + " # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n", + " # <<< 여기에 코드를 작성하세요 >>>\n", + " pass # 작성 후 pass를 제거하세요.\n", + "KEYS = {n: hashlib.sha256(f\"demo-key-{n}\".encode()).digest() for n in NODES} # [교육용가상자료] 데모 키\n", + "BATCH, QMAX, HIGH_WATER, WORKERS = 50, 12, 8, 4\n", + "\n", + "\n", + "def sig_of(x):\n", + " # [TODO : 학생 실습 8] 7일간 통신 단절 후 대량 재고 트랜잭션 일괄 동기화 스트레스 테스트 관련 핵심 로직을 구현하세요.\n", + " # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n", + " # <<< 여기에 코드를 작성하세요 >>>\n", + " pass # 작성 후 pass를 제거하세요.\n", + "class Server:\n", + " def __init__(self, fast):\n", + " self.fast, self.lock = fast, threading.Lock()\n", + " self.ids = set() if fast else [] # 최적화 전: 목록 선형 탐색으로 중복 확인\n", + " self.stock = {i: 500 for i in ITEM_IDS}; self.quar, self.dups, self.unver, self.lite_batches, self.applied = [], 0, [], 0, 0\n", + "\n", + " def ingest(self, batch, lite):\n", + " ok_rows, unv = [], set()\n", + " for k, x in enumerate(batch):\n", + " if lite and k % 10: # 기능 축소: 표본 10%만 서명 점검, 나머지는 사후 검증 대상\n", + " good = True; unv.add(x[\"id\"])\n", + " else:\n", + " good = hmac.compare_digest(sig_of(x), x[\"sig\"])\n", + " if not good:\n", + " with self.lock:\n", + " self.quar.append(x[\"id\"])\n", + " continue\n", + " ok_rows.append(x)\n", + " with self.lock:\n", + " self.lite_batches += lite\n", + " for x in ok_rows:\n", + " dup = x[\"id\"] in self.ids\n", + " if dup:\n", + " self.dups += 1; continue\n", + " self.ids.add(x[\"id\"]) if self.fast else self.ids.append(x[\"id\"])\n", + " self.stock[x[\"item\"]] += x[\"qty\"] if x[\"op\"] in (\"in\", \"adjust\") else -x[\"qty\"]; self.applied += 1\n", + " if x[\"id\"] in unv:\n", + " self.unver.append(x) # 실제로 반영된 미검증 레코드만 사후 검증 대상\n", + "\n", + " def post_verify(self):\n", + " \"\"\"사후 검증: 표본 점검만 거친 레코드를 전수 점검하고, 위조로 확인되면 반영분을 되돌린다.\"\"\"\n", + " rolled = 0\n", + " for x in self.unver:\n", + " if not hmac.compare_digest(sig_of(x), x[\"sig\"]):\n", + " self.stock[x[\"item\"]] -= x[\"qty\"] if x[\"op\"] in (\"in\", \"adjust\") else -x[\"qty\"]; self.quar.append(x[\"id\"]); self.applied -= 1; rolled += 1\n", + " return rolled\n", + "\n", + "\n", + "def run(mult, fast, seed=3):\n", + " rng = np.random.default_rng(seed)\n", + " tx = make_txns(7, 7, per_node_day=60 * mult)\n", + " for x in tx:\n", + " x[\"sig\"] = sig_of(x)\n", + " bad = set(rng.choice(len(tx), int(len(tx) * 0.01), replace=False).tolist())\n", + " for i in bad:\n", + " tx[i][\"qty\"] += 13 # 서명 후 수량 변조(1%)\n", + " valid = [x for i, x in enumerate(tx) if i not in bad]\n", + " truth = stock_of(valid)\n", + " srv, q, lat, depth, dead, crash = Server(fast), queue.Queue(maxsize=QMAX), [], [], [], [0]\n", + " lk = threading.Lock(); cnt = {\"b\": 0}\n", + "\n", + " def worker():\n", + " while True:\n", + " item = q.get()\n", + " if item is None:\n", + " return\n", + " batch, t_in, retry = item\n", + " depth.append(q.qsize())\n", + " try:\n", + " with lk:\n", + " cnt[\"b\"] += 1; inj = (cnt[\"b\"] % 37 == 0) and not retry # 37번째 배치마다 작업자 오류 주입(재시도 1회)\n", + " if inj:\n", + " raise RuntimeError(\"worker failure(injected)\")\n", + " srv.ingest(batch, lite=fast and q.qsize() >= HIGH_WATER)\n", + " except RuntimeError:\n", + " dead.append((batch, t_in)) # 데드레터 보관 후 재처리(유실 방지)\n", + " except Exception:\n", + " crash[0] += 1\n", + " lat.append((time.perf_counter() - t_in) * 1000)\n", + "\n", + " ths = [threading.Thread(target=worker) for _ in range(WORKERS)]\n", + " [t.start() for t in ths]\n", + " t0 = time.perf_counter()\n", + " batches = []\n", + " for n in NODES:\n", + " mine = [x for x in tx if x[\"node\"] == n]\n", + " batches += [mine[i:i + BATCH] for i in range(0, len(mine), BATCH)]\n", + " order = rng.permutation(len(batches)); resend = set(rng.choice(len(batches), max(1, len(batches) // 20), replace=False).tolist())\n", + " for i in order:\n", + " q.put((batches[i], time.perf_counter(), False)) # 가득 차면 생산자가 기다린다(역압, 유실 없음)\n", + " if i in resend:\n", + " q.put((batches[i], time.perf_counter(), False)) # 같은 배치 중복 재전송\n", + " for _ in ths:\n", + " q.put(None)\n", + " [t.join() for t in ths]\n", + " for b, t_in in dead:\n", + " srv.ingest(b, lite=False)\n", + " rolled = srv.post_verify() if fast else 0\n", + " dt = time.perf_counter() - t0\n", + " diff = sum(abs(srv.stock[i] - truth[i]) for i in ITEM_IDS)\n", + " lost = len(valid) - srv.applied\n", + " return {\"n\": len(tx), \"dt\": dt, \"tps\": len(tx) / dt, \"p95\": float(np.percentile(lat, 95)), \"qmax\": max(depth) if depth else 0, \"lite\": srv.lite_batches, \"dead\": len(dead),\n", + " \"dups\": srv.dups, \"quar\": len(set(srv.quar)), \"bad\": len(bad), \"lost\": lost, \"diff\": diff, \"crash\": crash[0], \"rolled\": rolled}\n", + "\n", + "\n", + "print(f\"[7일 단절 후 일괄 동기화] 기준 {5 * 7 * 60}건(5곳 x 7일 x 60건) -> 100%/200%/500%, 배치 {BATCH}건, 작업자 {WORKERS}, 큐 {QMAX}, 중복 재전송 5%, 위조 1%, 오류 주입 37배치마다\")\n", + "rows = []\n", + "for mult in (1, 2, 5):\n", + " r0, r1 = run(mult, False), run(mult, True); rows.append((mult, r0, r1))\n", + " print(f\" {mult * 100:3d}% ({r1['n']}건): 최적화 전 {r0['dt']:.2f}초({r0['tps']:.0f}건/초, p95 {r0['p95']:.0f}ms) -> 후 {r1['dt']:.2f}초({r1['tps']:.0f}건/초, p95 {r1['p95']:.0f}ms), 큐 최대 {r1['qmax']}, 축소 배치 {r1['lite']}\")\n", + "print(\"\\n[500% 상세] 항목: 최적화 전 / 후\")\n", + "a, b = rows[-1][1], rows[-1][2]\n", + "for k, lab in ((\"dups\", \"중복 거부\"), (\"quar\", \"위조 격리\"), (\"dead\", \"작업자 오류 재처리 배치\"), (\"rolled\", \"사후 검증 되돌림\"), (\"lost\", \"유실 건수\"), (\"diff\", \"재고 차이 합계\"), (\"crash\", \"미처리 예외(다운)\")):\n", + " print(f\" {lab:16s} {a[k]:6d} / {b[k]:6d}\")\n", + "lost_all = sum(r[1][\"lost\"] + r[2][\"lost\"] for r in rows); diff_all = sum(r[1][\"diff\"] + r[2][\"diff\"] for r in rows); crash_all = sum(r[1][\"crash\"] + r[2][\"crash\"] for r in rows)\n", + "quar_ok = all(r[2][\"quar\"] == r[2][\"bad\"] for r in rows)\n", + "print(f\"\\n전체 6회 실행: 다운 {crash_all}건, 유실 {lost_all}건, 재고 차이 {diff_all}, 위조 전부 격리 {'예' if quar_ok else '아니오'} (목표 다운 0건·유실 0%: {'달성' if crash_all == 0 and lost_all == 0 and diff_all == 0 else '미달'})\")\n", + "print(f\"병목 최적화(500%): 중복 확인을 목록 탐색 -> 집합 조회로 교체해 {a['dt']:.2f}초 -> {b['dt']:.2f}초({a['dt'] / b['dt']:.1f}배), 처리량 {a['tps']:.0f} -> {b['tps']:.0f}건/초\")\n", + "\n", + "# ---- 단계별 병목 프로파일링(수동 타이머): 서명 점검 / 중복 확인(목록·집합) ----\n", + "tx5 = make_txns(7, 7, per_node_day=300)\n", + "for x in tx5:\n", + " x[\"sig\"] = sig_of(x)\n", + "t0 = time.perf_counter(); [hmac.compare_digest(sig_of(x), x[\"sig\"]) for x in tx5]; t_sig = time.perf_counter() - t0\n", + "ids = [x[\"id\"] for x in tx5]; t0 = time.perf_counter(); lst = []\n", + "for i in ids:\n", + " _ = i in lst; lst.append(i)\n", + "t_lst = time.perf_counter() - t0; t0 = time.perf_counter(); st = set()\n", + "for i in ids:\n", + " _ = i in st; st.add(i)\n", + "t_set = time.perf_counter() - t0\n", + "print(f\"\\n[병목 프로파일] {len(ids)}건 기준: 서명 점검 {t_sig * 1000:.0f}ms, 중복 확인 목록 {t_lst * 1000:.0f}ms / 집합 {t_set * 1000:.1f}ms -> 병목은 중복 확인이었고 서명 점검이 다음 병목\")\n", + "print(\"[기능 축소 정책] 큐가 %d건 이상 쌓이면 서명을 표본 10%%만 점검하고 나머지는 사후 전수 검증한다. 위조가 확인되면 되돌리고 격리한다. 데이터는 버리지 않는다.\" % HIGH_WATER)\n", + "\n", + "# ---- 메모리 누수 점검: 같은 부하를 3회 반복해 남는 메모리 ----\n", + "tracemalloc.start(); mem = []\n", + "for _ in range(3):\n", + " run(1, True); mem.append(tracemalloc.get_traced_memory()[0] / 1e6)\n", + "tracemalloc.stop()\n", + "print(f\"[메모리] 반복 실행 후 남은 메모리 {', '.join(f'{m:.1f}MB' for m in mem)} -> 증가 {mem[-1] - mem[0]:+.1f}MB ({'누수 징후 없음' if mem[-1] - mem[0] < 5 else '확인 필요'})\")\n", + "print(\"※ 처리 시간은 실행 환경에 따라 달라진다. 실제 서버는 별도 작업 큐·영속 저장소·부하 시험 도구가 필요하다. 이 시험은 단일 프로세스 모의이다.\")\n", + "\n", + "plt.figure(figsize=(7, 3.6)); x = np.arange(3)\n", + "plt.bar(x - .2, [r[1][\"dt\"] for r in rows], .4, label=\"before (list scan)\", color=\"tab:gray\"); plt.bar(x + .2, [r[2][\"dt\"] for r in rows], .4, label=\"after (set + lite mode)\", color=\"tab:blue\")\n", + "plt.xticks(x, [\"100%\", \"200%\", \"500%\"]); plt.ylabel(\"seconds\"); plt.title(\"Bulk sync time after 7-day outage\"); plt.legend(); plt.tight_layout(); plt.show()\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 08 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + "[7일 단절 후 일괄 동기화] 기준 2100건(5곳 x 7일 x 60건) -> 100%/200%/500%, 배치 50건, 작업자 4, 큐 12, 중복 재전송 5%, 위조 1%, 오류 주입 37배치마다\n", + " 100% (2100건): 최적화 전 0.05초(45132건/초, p95 21ms) -> 후 0.01초(161281건/초, p95 2ms), 큐 최대 11, 축소 배치 15\n", + " 200% (4200건): 최적화 전 0.16초(26448건/초, p95 45ms) -> 후 0.03초(126250건/초, p95 6ms), 큐 최대 11, 축소 배치 27\n", + " 500% (10500건): 최적화 전 1.00초(10489건/초, p95 134ms) -> 후 0.06초(165580건/초, p95 3ms), 큐 최대 11, 축소 배치 68\n", + "\n", + "[500% 상세] 항목: 최적화 전 / 후\n", + " 중복 거부 496 / 497\n", + " 위조 격리 105 / 105\n", + " 작업자 오류 재처리 배치 5 / 5\n", + " 사후 검증 되돌림 0 / 32\n", + " 유실 건수 0 / 0\n", + " 재고 차이 합계 0 / 0\n", + " 미처리 예외(다운) 0 / 0\n", + "\n", + "전체 6회 실행: 다운 0건, 유실 0건, 재고 차이 0, 위조 전부 격리 예 (목표 다운 0건·유실 0%: 달성)\n", + "병목 최적화(500%): 중복 확인을 목록 탐색 -> 집합 조회로 교체해 1.00초 -> 0.06초(15.8배), 처리량 10489 -> 165580건/초\n", + "\n", + "[병목 프로파일] 10500건 기준: 서명 점검 41ms, 중복 확인 목록 765ms / 집합 2.1ms -> 병목은 중복 확인이었고 서명 점검이 다음 병목\n", + "[기능 축소 정책] 큐가 8건 이상 쌓이면 서명을 표본 10%만 점검하고 나머지는 사후 전수 검증한다. 위조가 확인되면 되돌리고 격리한다. 데이터는 버리지 않는다.\n", + "[메모리] 반복 실행 후 남은 메모리 0.0MB, 0.0MB, 0.0MB -> 증가 +0.0MB (누수 징후 없음)\n", + "※ 처리 시간은 실행 환경에 따라 달라진다. 실제 서버는 별도 작업 큐·영속 저장소·부하 시험 도구가 필요하다. 이 시험은 단일 프로세스 모의이다.\n", + "```\n", + "
\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 09. AAR 기반 동기화 누락 및 데이터 충돌 사례 분석 및 동기화 룰셋 재튜닝\n", + "**소요시간**: 약 3.0시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "AAR 기반 동기화 누락 및 데이터 충돌 사례를 분석하고 동기화 룰을 재튜닝한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **동기화 누락·충돌 판정 오류 사례를 AAR로 원인별로 분류하고 규칙 파라미터(중복 판정 방식, 충돌 탐지 규칙, 자동 병합 임계, 재시도 횟수)를 재튜닝해 종합 정확도 10%p 이상 향상과 재발 오류 0건을 시험한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **가상 사례 1,200건(중복 재전송·재입력, 같은 크기의 서로 다른 거래, 동시·후속 메타 수정과 시계 어긋남, 수량 차이, 통신불량 전송 실패)에 현행 규칙을 적용해 오류를 D1~D6 원인으로 분류 수행하고 검증하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `docs/dat10_aar_report.md 및 configs/dat10_tuned_params.yaml` 형태의 결과물을 완성한다.\n", + "\n", + "### 🔍 평가 및 검증 기준 (PASS 조건)\n", + "#### 미션 판정\n", + "\n", + "**평가 구분:** 필수\n", + "\n", + "**핵심 평가영역:** 오류 원인 규명의 객관성 및 개선 조치의 논리적 타당성\n", + "\n", + "#### 미션 PASS 조건\n", + "\n", + "- 정량 기준: AAR 피드백 적용 후 종합 정확도 10%p 이상 향상, 재발 오류 0건\n", + "\n", + "\n", + "- 진행 기준: AAR 보고서 작성 및 파라미터 튜닝 전후 회귀 테스트 통과\n", + "\n", + "\n", + "- 검증 방법: 회귀 테스트(Regression Test) 스위트 전체 자동 실행 및 비교\n", + "\n", + "\n", + "#### 채점자 판정 메모\n", + "\n", + "> 단순 코드 실행 여부뿐만 아니라 출력된 수치와 근거 자료가 군 실무 요구사항에 부합하는지 면밀히 확인한다.\n", + "\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "\"\"\"동기화 사후 검토(AAR) 사례 1,200건에서 판정 오류를 원인(중복 판정 방식·시계 어긋남·충돌 탐지 규칙·자동 병합 임계·재시도 횟수)별로 분류하고 규칙 파라미터를 재튜닝해, 별도 시험 3,000건의 종합 정확도 향상폭·AAR 사례 재발 오류·회귀 테스트 통과를 계산하고 튜닝 결과를 YAML 문자열로 출력한다.\"\"\"\n", + "import numpy as np\n", + "import pandas as pd\n", + "import matplotlib.pyplot as plt\n", + "\n", + "KINDS = [\"dup_resend\", \"distinct_similar\", \"meta_conflict\", \"meta_causal\", \"qty_diff\", \"send_fail\", \"dup_reinput\"]\n", + "PK = [.20, .12, .18, .15, .15, .15, .05]\n", + "CAUSE = {\"dup_resend\": \"D2 재전송 중복 놓침\", \"distinct_similar\": \"D1 다른 거래를 중복으로 판정\", \"meta_conflict\": \"D4 동시 수정 놓침(조용한 덮어쓰기)\", \"meta_causal\": \"D3 후속 수정의 승자 오판·불필요 경보\",\n", + " \"qty_diff\": \"D5 자동 병합 임계 부적절\", \"send_fail\": \"D6 재시도 부족 누락\", \"dup_reinput\": \"D2b 재입력(새 ID) 중복 놓침\"}\n", + "\n", + "\n", + "def make_events(seed, n):\n", + " \"\"\"[교육용가상자료] 동기화 사례 생성. 단말 시계 오차(+-2시간), 버전 벡터 없는 구형 기록 8%, 통신불량 상황의 전송 실패를 포함한다.\"\"\"\n", + " rng = np.random.default_rng(seed); out = []\n", + " for i in range(n):\n", + " k = str(rng.choice(KINDS, p=PK)); o = rng.uniform(-2, 2, 2); ev = {\"id\": i, \"kind\": k, \"vv\": rng.random() > 0.08}\n", + " if k == \"dup_resend\":\n", + " ev.update(same_id=True, dt=0.0 if rng.random() < .5 else float(rng.uniform(0.5, 6)))\n", + " elif k == \"distinct_similar\":\n", + " ev.update(same_id=False, dt=float(rng.uniform(0, 0.5)))\n", + " elif k == \"dup_reinput\": # 오입력 정정 등으로 같은 내용을 새 ID로 다시 입력\n", + " ev.update(same_id=False, dt=float(rng.uniform(0.1, 6)))\n", + " elif k in (\"meta_conflict\", \"meta_causal\"):\n", + " gap = float(rng.uniform(0.5, 60)) if k == \"meta_conflict\" else float(rng.uniform(0.01, 1.5))\n", + " ev.update(ta=100 + o[0], tb=100 + gap + o[1]) # 기록된 시각 = 실제 시각 + 단말 시계 오차\n", + " elif k == \"qty_diff\":\n", + " ev.update(d=float(rng.uniform(0.0, 0.8)))\n", + " else:\n", + " ev.update(p=0.5 if rng.random() < .6 else 0.8, u=rng.random(8))\n", + " out.append(ev)\n", + " return out\n", + "\n", + "\n", + "def decide(ev, P):\n", + " # [TODO : 학생 실습 9] AAR 기반 동기화 누락 및 데이터 충돌 사례 분석 및 동기화 룰셋 재튜닝 관련 핵심 로직을 구현하세요.\n", + " # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n", + " # <<< 여기에 코드를 작성하세요 >>>\n", + " pass # 작성 후 pass를 제거하세요.\n", + "def truth(ev):\n", + " # [TODO : 학생 실습 9] AAR 기반 동기화 누락 및 데이터 충돌 사례 분석 및 동기화 룰셋 재튜닝 관련 핵심 로직을 구현하세요.\n", + " # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n", + " # <<< 여기에 코드를 작성하세요 >>>\n", + " pass # 작성 후 pass를 제거하세요.\n", + "def evaluate(evs, P):\n", + " ok = [decide(e, P) == truth(e) for e in evs]\n", + " return np.mean(ok), pd.Series([CAUSE[e[\"kind\"]] for e, o in zip(evs, ok) if not o]).value_counts(), ok\n", + "\n", + "\n", + "P0 = {\"dup\": \"content\", \"win\": 2.0, \"conflict\": \"time\", \"tw\": 4.0, \"thr\": 0.3, \"retries\": 2} # 초기 운용 규칙(M3 단계 설정)\n", + "aar, test = make_events(31, 1200), make_events(77, 3000)\n", + "a0, causes, ok0 = evaluate(aar, P0)\n", + "print(f\"[AAR 사례] {len(aar)}건, 현행 규칙 정확도 {a0:.1%}, 오류 {int(len(aar) * (1 - a0))}건\")\n", + "print(\"[원인 분류] 원인 / 건수 / 비율\")\n", + "for c, n in causes.items():\n", + " print(f\" {c:28s} {n:4d}건 {n / (len(aar) * (1 - a0)):5.1%}\")\n", + "\n", + "# ---- 재튜닝: 사례 종류가 서로 다르므로 파라미터마다 AAR 사례에서 따로 탐색(재시도는 횟수당 지연 비용 0.2%p 가산) ----\n", + "def best(param, grid, kinds, cost=lambda v: 0.0):\n", + " sub = [e for e in aar if e[\"kind\"] in kinds]; sc = []\n", + " for v in grid:\n", + " P = {**P0, **(v if isinstance(v, dict) else {param: v})}; sc.append((np.mean([decide(e, P) == truth(e) for e in sub]) - cost(v), v))\n", + " return max(sc, key=lambda x: x[0]), sc\n", + "(_, dup_best), _ = best(\"dup\", [{\"dup\": \"content\", \"win\": w} for w in (0.0, 0.25, 0.5, 1, 2, 4)] + [{\"dup\": \"id+content\", \"win\": w} for w in (0.25, 0.5, 1, 2, 4)] + [{\"dup\": \"id\"}], (\"dup_resend\", \"distinct_similar\", \"dup_reinput\"))\n", + "(_, cf_best), _ = best(\"conflict\", [{\"conflict\": \"time\", \"tw\": w} for w in (0.25, 0.5, 1, 2, 4)] + [{\"conflict\": \"vv\", \"tw\": w} for w in (0.5, 1, 2, 4, 8)], (\"meta_conflict\", \"meta_causal\"))\n", + "(_, thr_best), _ = best(\"thr\", [0.1, 0.15, 0.2, 0.25, 0.3, 0.4, 0.5], (\"qty_diff\",))\n", + "(_, rt_best), _ = best(\"retries\", [1, 2, 3, 4, 5, 6], (\"send_fail\",), cost=lambda v: 0.002 * v)\n", + "P1 = {**P0, **dup_best, **cf_best, \"thr\": thr_best, \"retries\": rt_best}\n", + "\n", + "a_aar, rec, ok1 = evaluate(aar, P1)\n", + "a_old_t, c_old_t, _ = evaluate(test, P0)\n", + "a_new_t, c_new_t, _ = evaluate(test, P1)\n", + "rec_n = int(sum((not o0) and (not o1) for o0, o1 in zip(ok0, ok1)))\n", + "print(f\"\\n[재튜닝 결과] 중복 판정 {P0['dup']}(창 {P0['win']}h) -> {P1['dup']}{'' if P1['dup'] == 'id' else '(창 ' + str(P1['win']) + 'h)'}, 충돌 탐지 {P0['conflict']}(창 {P0['tw']}h) -> {P1['conflict']}(구형 기록은 시간창 {P1['tw']}h), 자동 병합 임계 {P0['thr']} -> {P1['thr']}, 재시도 {P0['retries']} -> {P1['retries']}회\")\n", + "print(f\"AAR 사례 재실행: 정확도 {a0:.1%} -> {a_aar:.1%}, 같은 사례에서 오류 재발 {rec_n}건 (목표 0건: {'달성' if rec_n == 0 else '미달'})\")\n", + "print(f\"별도 시험 3,000건: {a_old_t:.1%} -> {a_new_t:.1%} ({(a_new_t - a_old_t) * 100:+.1f}%p, 목표 +10%p 이상: {'달성' if a_new_t - a_old_t >= .10 else '미달'})\")\n", + "print(\"시험 3,000건 남은 오류:\", {c: int(n) for c, n in c_new_t.items()} or \"없음\")\n", + "tt = pd.DataFrame({\"before\": c_old_t, \"after\": c_new_t}).fillna(0).astype(int)\n", + "\n", + "# ---- 회귀 테스트: 규칙 단위 시험 8개를 현행/재튜닝 규칙에 각각 실행 ----\n", + "T = [(\"같은 ID 재전송(시각 3h 차이)은 건너뜀\", {\"kind\": \"dup_resend\", \"same_id\": True, \"dt\": 3.0, \"vv\": True}, \"skip\"), (\"같은 ID 재전송(시각 동일)은 건너뜀\", {\"kind\": \"dup_resend\", \"same_id\": True, \"dt\": 0.0, \"vv\": True}, \"skip\"),\n", + " (\"다른 ID의 같은 수량 거래는 반영\", {\"kind\": \"distinct_similar\", \"same_id\": False, \"dt\": 0.2, \"vv\": True}, \"apply\"), (\"버전 벡터 동시 수정은 군수관 검토\", {\"kind\": \"meta_conflict\", \"ta\": 100, \"tb\": 130, \"vv\": True}, \"hitl\"),\n", + " (\"버전 벡터 선후 관계는 후속 값 채택(시계 역전)\", {\"kind\": \"meta_causal\", \"ta\": 101.0, \"tb\": 100.5, \"vv\": True}, \"pick_b\"), (\"차이 10%는 자동 병합\", {\"kind\": \"qty_diff\", \"d\": 0.10, \"vv\": True}, \"auto\"),\n", + " (\"차이 30%는 군수관 검토\", {\"kind\": \"qty_diff\", \"d\": 0.30, \"vv\": True}, \"hitl\"), (\"통신불량 전송 3회 안에 성공\", {\"kind\": \"send_fail\", \"p\": 0.5, \"u\": np.array([.9, .8, .1, 0, 0, 0, 0, 0]), \"vv\": True}, \"applied\")]\n", + "print(\"\\n[회귀 테스트 8개] 현행 / 재튜닝\")\n", + "p0 = [decide(e, P0) == t for _, e, t in T]; p1 = [decide(e, P1) == t for _, e, t in T]\n", + "for (nm, _, _), x, y in zip(T, p0, p1):\n", + " print(f\" {nm:34s} {'통과' if x else '실패'} / {'통과' if y else '실패'}\")\n", + "regress = sum(a and not b for a, b in zip(p0, p1))\n", + "print(f\"통과 {sum(p0)}/8 -> {sum(p1)}/8, 기존 통과 항목이 깨진 회귀 {regress}건 ({'전체 통과' if all(p1) else '실패 있음'})\")\n", + "\n", + "# ---- 산출물: 튜닝 파라미터 YAML 문자열(configs/dat10_tuned_params.yaml 내용) ----\n", + "yml = \"\\n\".join([\"sync_rules:\", f\" dedup_key: {P1['dup']}\", f\" dedup_window_h: {P1['win'] if P1['dup'] != 'id' else 0}\", f\" conflict_detect: {P1['conflict']}\", f\" legacy_time_window_h: {P1['tw']}\", f\" auto_merge_diff_max: {P1['thr']}\", f\" send_retries: {P1['retries']}\", \" hitl_required_when: [conflict, diff_ge_0.25]\"])\n", + "print(\"\\n[configs/dat10_tuned_params.yaml]\"); print(yml)\n", + "print(\"[지속적 학습 연계] 이번 AAR 오류 유형을 사례 은행에 누적 -> 월 1회 새 사례로 파라미터 재탐색 -> 회귀 테스트 8개 통과 시에만 후보 반영 -> 군수관 승인 후 배포(자동 배포 금지)\")\n", + "print(\"※ 사례는 가상 생성이며 시험 분포가 튜닝 분포와 같아 향상폭이 실제보다 낙관적이다. 규칙 변경은 군수관 승인과 회귀 테스트를 거친다.\")\n", + "\n", + "fig, ax = plt.subplots(1, 2, figsize=(10, 3.8))\n", + "ax[0].bar([\"before\", \"after\"], [a_old_t * 100, a_new_t * 100], color=[\"tab:gray\", \"tab:blue\"]); ax[0].set_ylim(50, 100); ax[0].set_ylabel(\"accuracy (%)\"); ax[0].set_title(\"Test accuracy (3,000 cases)\")\n", + "x = np.arange(len(tt)); ax[1].bar(x - .2, tt[\"before\"], .4, label=\"before\", color=\"tab:gray\"); ax[1].bar(x + .2, tt[\"after\"], .4, label=\"after\", color=\"tab:blue\")\n", + "ax[1].set_xticks(x); ax[1].set_xticklabels([c.split()[0] for c in tt.index]); ax[1].set_title(\"Errors by cause\"); ax[1].legend(); plt.tight_layout(); plt.show()\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 09 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + "[AAR 사례] 1200건, 현행 규칙 정확도 45.5%, 오류 653건\n", + "[원인 분류] 원인 / 건수 / 비율\n", + " D4 동시 수정 놓침(조용한 덮어쓰기) 193건 29.5%\n", + " D3 후속 수정의 승자 오판·불필요 경보 177건 27.1%\n", + " D1 다른 거래를 중복으로 판정 139건 21.3%\n", + " D2 재전송 중복 놓침 95건 14.5%\n", + " D2b 재입력(새 ID) 중복 놓침 25건 3.8%\n", + " D6 재시도 부족 누락 17건 2.6%\n", + " D5 자동 병합 임계 부적절 8건 1.2%\n", + "\n", + "[재튜닝 결과] 중복 판정 content(창 2.0h) -> id, 충돌 탐지 time(창 4.0h) -> vv(구형 기록은 시간창 0.5h), 자동 병합 임계 0.3 -> 0.25, 재시도 2 -> 6회\n", + "AAR 사례 재실행: 정확도 45.5% -> 93.7%, 같은 사례에서 오류 재발 49건 (목표 0건: 미달)\n", + "별도 시험 3,000건: 44.7% -> 93.0% (+48.3%p, 목표 +10%p 이상: 달성)\n", + "시험 3,000건 남은 오류: {'D2b 재입력(새 ID) 중복 놓침': 164, 'D4 동시 수정 놓침(조용한 덮어쓰기)': 32, 'D3 후속 수정의 승자 오판·불필요 경보': 11, 'D6 재시도 부족 누락': 2}\n", + "\n", + "[회귀 테스트 8개] 현행 / 재튜닝\n", + " 같은 ID 재전송(시각 3h 차이)은 건너뜀 실패 / 통과\n", + " 같은 ID 재전송(시각 동일)은 건너뜀 통과 / 통과\n", + " 다른 ID의 같은 수량 거래는 반영 실패 / 통과\n", + " 버전 벡터 동시 수정은 군수관 검토 실패 / 통과\n", + " 버전 벡터 선후 관계는 후속 값 채택(시계 역전) 실패 / 통과\n", + " 차이 10%는 자동 병합 통과 / 통과\n", + " 차이 30%는 군수관 검토 통과 / 통과\n", + " 통신불량 전송 3회 안에 성공 통과 / 통과\n", + "통과 4/8 -> 8/8, 기존 통과 항목이 깨진 회귀 0건 (전체 통과)\n", + "\n", + "[configs/dat10_tuned_params.yaml]\n", + "sync_rules:\n", + " dedup_key: id\n", + " dedup_window_h: 0\n", + " conflict_detect: vv\n", + " legacy_time_window_h: 0.5\n", + " auto_merge_diff_max: 0.25\n", + " send_retries: 6\n", + " hitl_required_when: [conflict, diff_ge_0.25]\n", + "[지속적 학습 연계] 이번 AAR 오류 유형을 사례 은행에 누적 -> 월 1회 새 사례로 파라미터 재탐색 -> 회귀 테스트 8개 통과 시에만 후보 반영 -> 군수관 승인 후 배포(자동 배포 금지)\n", + "※ 사례는 가상 생성이며 시험 분포가 튜닝 분포와 같아 향상폭이 실제보다 낙관적이다. 규칙 변경은 군수관 승인과 회귀 테스트를 거친다.\n", + "```\n", + "
\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 10. 오프라인 동기화 야전 재고 관리 SaaS 최종 패키징 및 야전 배포 가이드 완성\n", + "**소요시간**: 약 3.5시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "오프라인 동기화 야전 재고 관리 SaaS 패키징 및 배포 가이드를 완성한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **입력 검증·서명 점검·중복 제거·PN-카운터 병합·충돌 탐지·공급망 재난 징후 마이닝·검토 큐·감사 로그·브리핑을 묶은 오프라인 동기화 야전 재고 관리 SaaS 패키지와 배포 가이드를 완성한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **앞 미션의 기능을 통합 파이프라인으로 묶어 설치 스크립트 5단계 점검, 시드 4종 End-to-End 실행(업체 1곳 공급 중단, 노드 1곳 3일치 미동기, 변조 20·깨진 줄 5·중복 60건 주입), 자체 점검 11항목과 함수 커버리지 수행하고 분석하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `src/main_dat10.py, README.md, docs/dat10_final_report.pdf(md)` 형태의 결과물을 완성한다.\n", + "\n", + "### 🔍 평가 및 검증 기준 (PASS 조건)\n", + "#### 미션 판정\n", + "\n", + "**평가 구분:** 필수\n", + "\n", + "**핵심 평가영역:** 군 실무 즉시 도입 가능성, 운용 신뢰도 및 전략적 기대효과\n", + "\n", + "#### 미션 PASS 조건\n", + "\n", + "- 정량 기준: End-to-End 통합 파이프라인 실행 성공률 100%, 테스트 커버리지 85% 이상\n", + "\n", + "\n", + "- 진행 기준: 원클릭 실행 스크립트 동작 확인 및 최종 발표 산출물 작성 완료\n", + "\n", + "\n", + "- 검증 방법: 최종 검증 위원회(평가관) 모의 시연 및 전체 기능 통합 검증\n", + "\n", + "\n", + "#### 채점자 판정 메모\n", + "\n", + "> 단순 코드 실행 여부뿐만 아니라 출력된 수치와 근거 자료가 군 실무 요구사항에 부합하는지 면밀히 확인한다.\n", + "\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "\"\"\"오프라인 입력 검증·서명 점검·중복 제거, PN-카운터 병합, 버전 벡터 충돌 탐지, 공급망 재난 징후(업체 공급 감소·재고 소진 임박) 마이닝, 사람 검토 큐(PENDING)와 해시 체인 감사 로그, 설치 스크립트·매뉴얼·브리핑까지 오프라인 동기화 야전 재고 관리 SaaS 전체를 한 파일로 통합 실행하고 E2E 성공률·자체 점검·함수 커버리지를 계산한다.\"\"\"\n", + "import hashlib\n", + "import hmac\n", + "import json\n", + "import sys\n", + "import time\n", + "import numpy as np\n", + "import pandas as pd\n", + "import matplotlib.pyplot as plt\n", + "\n", + "# ---------------------------------------------------------------------------\n", + "# [교육용가상자료] 가상 야전 보급소·업체·품목 자료 생성기 (실제 부대·업체·품목·재고 정보 없음)\n", + "# 가상 보급소 5곳이 품목 12종의 입고·불출·조정 트랜잭션을 오프라인으로 기록하고 나중에 동기화한다.\n", + "# 공급망 재난 징후(업체 납기 지연, 품목 재고 급감)는 동기화된 자료를 마이닝해 식별한다.\n", + "# ---------------------------------------------------------------------------\n", + "NODES = [\"보급소A\", \"보급소B\", \"보급소C\", \"보급소D\", \"보급소E\"]\n", + "SUPPLIERS = [\"가상업체1\", \"가상업체2\", \"가상업체3\", \"가상업체4\", \"가상업체5\", \"가상업체6\"]\n", + "CATS = [\"일반보급\", \"정비부품\", \"의무소모\"]\n", + "ITEMS = [{\"item\": f\"I{i + 1:02d}\", \"cat\": CATS[i % 3], \"supplier\": SUPPLIERS[i % 6], \"lead_days\": 3 + (i * 5) % 12, \"use\": 4 + (i * 7) % 11, \"min_level\": 30 + 10 * (i % 5)} for i in range(12)]\n", + "ITEM_IDS = [x[\"item\"] for x in ITEMS]\n", + "CTXS = [\"평시\", \"야간\", \"통신불량\"]\n", + "\n", + "\n", + "def make_txns(seed=7, days=7, per_node_day=60, nodes=None):\n", + " \"\"\"노드별 오프라인 트랜잭션 목록. id는 노드-일련번호(멱등 키), t는 시각(시간 단위), ctx는 입력 상황.\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " out = []\n", + " for n in (nodes or NODES):\n", + " seq = 0\n", + " for k in range(days * per_node_day):\n", + " seq += 1\n", + " it = ITEMS[int(rng.integers(len(ITEMS)))]\n", + " t = float(rng.uniform(0, 24 * days))\n", + " ctx = \"야간\" if (t % 24 >= 20 or t % 24 < 5) else \"평시\"\n", + " if rng.random() < 0.12:\n", + " ctx = \"통신불량\"\n", + " r = rng.random()\n", + " if r < 0.22:\n", + " op, q = \"in\", int(it[\"use\"] * rng.uniform(4, 9))\n", + " elif r < 0.95:\n", + " op, q = \"out\", int(max(1, rng.normal(it[\"use\"] * 1.5, 3)))\n", + " else:\n", + " op, q = \"adjust\", int(rng.integers(-5, 6))\n", + " out.append({\"id\": f\"{n}-{seq:05d}\", \"node\": n, \"item\": it[\"item\"], \"op\": op, \"qty\": q, \"t\": round(t, 2), \"ctx\": ctx, \"supplier\": it[\"supplier\"]})\n", + " return out\n", + "\n", + "\n", + "def stock_of(txns, base=500):\n", + " \"\"\"품목별 재고 = 기초 재고 + 입고 - 불출 + 조정 (id 중복은 한 번만 반영).\"\"\"\n", + " # [TODO : 학생 실습 10] 오프라인 동기화 야전 재고 관리 SaaS 최종 패키징 및 야전 배포 가이드 완성 관련 핵심 로직을 구현하세요.\n", + " # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n", + " # <<< 여기에 코드를 작성하세요 >>>\n", + " pass # 작성 후 pass를 제거하세요.\n", + "KEYS = {n: hashlib.sha256(f\"demo-key-{n}\".encode()).digest() for n in NODES} # [교육용가상자료] 데모 키\n", + "CONF = {\"auto_merge_diff_max\": 0.25, \"supply_ratio_cut\": 0.3, \"supplier_flag_frac\": 0.5, \"queue_min_risk\": 40}\n", + "MANUAL = {\n", + " \"1. 목적과 범위\": \"통신이 끊긴 보급소 5곳의 재고 입출고를 오프라인으로 기록하고 복구 후 서버와 병합하며, 병합된 자료에서 공급 감소·재고 소진 임박 징후를 찾아 군수관에게 보여 주는 판단 보조 도구다.\",\n", + " \"2. 설치와 실행\": \"install.sh 한 번으로 환경 점검, 의존 패키지 확인, 자체 시험, 데모 실행을 순서대로 수행한다.\",\n", + " \"3. 입력 데이터\": \"보급소별 트랜잭션 줄(ID|품목|구분|수량|시각|상황). 범위·형식·서명 오류와 중복은 격리하거나 거부하고 사유를 남긴다.\",\n", + " \"4. 동기화와 충돌\": \"재고 수량은 PN-카운터로 자동 병합한다. 품목 정보 동시 수정은 버전 벡터로 탐지해 군수관 검토 큐에 올린다.\",\n", + " \"5. 공급망 재난 징후\": \"업체별 최근 3일 입고량이 첫 3일 대비 급감하거나 소진 예상 일수가 납기보다 짧은 품목을 묶어 징후 후보로 표시한다.\",\n", + " \"6. 사람 검토와 감사 로그\": \"모든 후보는 PENDING으로 시작하고 군수관·지휘관이 처리한다. 모든 행위는 해시 체인에 남는다.\",\n", + " \"7. 한계와 확장\": \"가상 자료 기반이며 실제 통신·저장 장치·키 관리는 별도 체계가 필요하다. 인접 체계 연동 API는 차기 과제다.\",\n", + "}\n", + "INSTALL_SH = \"\"\"#!/bin/sh\n", + "# [교육용가상자료] dat10 설치 스크립트 예시\n", + "set -e\n", + "python3 -c \"import sys; assert sys.version_info >= (3, 9)\" # 1) 파이썬 버전 점검\n", + "python3 -m venv .venv && . .venv/bin/activate # 2) 가상환경\n", + "pip install -r requirements.txt # 3) 의존 패키지(numpy pandas matplotlib)\n", + "python3 -m pytest tests -q # 4) 자체 시험\n", + "python3 src/main_dat10.py --demo # 5) 데모 실행(가상 자료)\n", + "\"\"\"\n", + "\n", + "\n", + "def sign(x):\n", + " # [TODO : 학생 실습 10] 오프라인 동기화 야전 재고 관리 SaaS 최종 패키징 및 야전 배포 가이드 완성 관련 핵심 로직을 구현하세요.\n", + " # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n", + " # <<< 여기에 코드를 작성하세요 >>>\n", + " pass # 작성 후 pass를 제거하세요.\n", + "def to_line(x):\n", + " return f\"{x['id']}|{x['item']}|{x['op']}|{x['qty']}|{x['t']}|{x['ctx']}|{x['sig']}\"\n", + "\n", + "\n", + "def parse_line(s):\n", + " \"\"\"입력 줄 -> 레코드. 형식이 틀리면 ValueError.\"\"\"\n", + " p = s.split(\"|\")\n", + " if len(p) != 7:\n", + " raise ValueError(\"필드 수 오류\")\n", + " return {\"id\": p[0], \"node\": p[0].split(\"-\")[0], \"item\": p[1], \"op\": p[2], \"qty\": int(p[3]), \"t\": float(p[4]), \"ctx\": p[5], \"sig\": p[6]}\n", + "\n", + "\n", + "def validate(x):\n", + " \"\"\"범위·논리 오류 사유 문자열 또는 None.\"\"\"\n", + " if x[\"node\"] not in NODES:\n", + " return \"미등록 보급소\"\n", + " if x[\"item\"] not in ITEM_IDS or x[\"op\"] not in (\"in\", \"out\", \"adjust\") or x[\"ctx\"] not in CTXS:\n", + " return \"코드 오류\"\n", + " if not (0 <= x[\"t\"] <= 24 * 7 + 1) or not (-50 <= x[\"qty\"] <= 2000) or (x[\"op\"] != \"adjust\" and x[\"qty\"] <= 0):\n", + " return \"수량·시각 범위 오류\"\n", + " return None\n", + "\n", + "\n", + "class Chain:\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", + "class Store:\n", + " \"\"\"서버 측 저장소(메모리): PN-카운터, 중복 제거 집합, 격리 목록.\"\"\"\n", + " def __init__(self):\n", + " self.P = {i: {} for i in ITEM_IDS}; self.N = {i: {} for i in ITEM_IDS}; self.ids = set(); self.tx = []; self.quar = []; self.dups = 0\n", + "\n", + " def stock(self, base=500):\n", + " return {i: base + sum(self.P[i].values()) - sum(self.N[i].values()) for i in ITEM_IDS}\n", + "\n", + "\n", + "def ingest(st, lines):\n", + " \"\"\"줄 묶음을 파싱·검증·서명 점검·중복 제거 후 반영한다. 결과 건수를 돌려준다.\"\"\"\n", + " r = {\"ok\": 0, \"dup\": 0, \"bad\": 0}\n", + " for s in lines:\n", + " try:\n", + " x = parse_line(s)\n", + " except ValueError:\n", + " st.quar.append((\"파싱 오류\", s[:20])); r[\"bad\"] += 1; continue\n", + " why = validate(x)\n", + " if why is None and not hmac.compare_digest(sign(x), x[\"sig\"]):\n", + " why = \"서명 불일치\"\n", + " if why:\n", + " st.quar.append((why, x[\"id\"])); r[\"bad\"] += 1; continue\n", + " if x[\"id\"] in st.ids:\n", + " st.dups += 1; r[\"dup\"] += 1; continue\n", + " st.ids.add(x[\"id\"]); st.tx.append(x)\n", + " d = st.P if x[\"op\"] == \"in\" or (x[\"op\"] == \"adjust\" and x[\"qty\"] > 0) else st.N\n", + " d[x[\"item\"]][x[\"node\"]] = d[x[\"item\"]].get(x[\"node\"], 0) + abs(x[\"qty\"]); r[\"ok\"] += 1\n", + " return r\n", + "\n", + "\n", + "def dominated(a, b):\n", + " return all(a.get(k, 0) <= b.get(k, 0) for k in set(a) | set(b)) and a != b\n", + "\n", + "\n", + "def detect_conflict(A, B):\n", + " \"\"\"버전 벡터 + 값 비교: 같은 필드를 서로 모르는 채 다른 값으로 고쳤을 때만 충돌.\"\"\"\n", + " return A[\"field\"] == B[\"field\"] and A[\"value\"] != B[\"value\"] and not dominated(A[\"vv\"], B[\"vv\"]) and not dominated(B[\"vv\"], A[\"vv\"])\n", + "\n", + "\n", + "def make_conflicts(rng, n=10):\n", + " out = []\n", + " for k in range(n):\n", + " it = ITEMS[int(rng.integers(12))]; va = it[\"min_level\"]; vb = int(va * rng.choice([1.1, 1.5, 0.6]))\n", + " conc = k % 2 == 0\n", + " A = {\"item\": it[\"item\"], \"field\": \"min_level\", \"value\": va, \"vv\": {\"A\": 3, \"B\": 1}}\n", + " B = {\"item\": it[\"item\"], \"field\": \"min_level\", \"value\": vb, \"vv\": {\"A\": 2, \"B\": 4} if conc else {\"A\": 3, \"B\": 2}}\n", + " out.append((conc, A, B))\n", + " return out\n", + "\n", + "\n", + "def disruption_scan(tx, t_end=168.0, win=72.0):\n", + " \"\"\"업체별 공급 감소(최근 3일 입고량 / 첫 3일 입고량)와 소진 예상 일수를 보고 공급망 재난 징후 후보 업체를 찾는다.\"\"\"\n", + " df = pd.DataFrame(tx)\n", + " ins = df[df[\"op\"] == \"in\"]; outs = df[df[\"op\"] == \"out\"]\n", + " rows = []\n", + " for it in ITEMS:\n", + " a = ins[(ins[\"item\"] == it[\"item\"]) & (ins[\"t\"] < win)][\"qty\"].sum(); b = ins[(ins[\"item\"] == it[\"item\"]) & (ins[\"t\"] >= t_end - win)][\"qty\"].sum()\n", + " o = outs[(outs[\"item\"] == it[\"item\"]) & (outs[\"t\"] >= t_end - win)][\"qty\"].sum() / (win / 24)\n", + " rows.append({\"supplier\": it[\"supplier\"], \"item\": it[\"item\"], \"ratio\": b / max(a, 1), \"out_day\": o, \"lead\": it[\"lead_days\"]})\n", + " r = pd.DataFrame(rows); r[\"drop\"] = r[\"ratio\"] < CONF[\"supply_ratio_cut\"]\n", + " g = r.groupby(\"supplier\").agg(frac=(\"drop\", \"mean\"), ratio=(\"ratio\", \"min\"))\n", + " return g[g[\"frac\"] >= CONF[\"supplier_flag_frac\"]].reset_index().to_dict(\"records\"), r\n", + "\n", + "\n", + "def dashboard_view(st, pending, t):\n", + " off = sum(1 for n in NODES if pending.get(n, 0) > 0)\n", + " stk = st.stock()\n", + " return {\"nodes_unsynced\": off, \"pending_tx\": sum(pending.values()), \"low_stock\": sum(v < 100 for v in stk.values()), \"alerts\": len(st.quar)}\n", + "\n", + "\n", + "def run_pipeline(seed, disrupt=None, offline_node=None, noise=True):\n", + " \"\"\"E2E: 생성 -> (공급 중단·오프라인 주입) -> 줄 변환 -> 반영 -> 충돌 탐지 -> 징후 마이닝 -> 검토 큐 + 감사 로그.\"\"\"\n", + " rng = np.random.default_rng(seed); tx = make_txns(seed, 7, per_node_day=60)\n", + " if disrupt:\n", + " tx = [x for x in tx if not (x[\"supplier\"] == disrupt and x[\"op\"] == \"in\" and x[\"t\"] >= 72)]\n", + " for x in tx:\n", + " x[\"sig\"] = sign(x)\n", + " lines, pending = [], {}\n", + " for x in tx:\n", + " if offline_node and x[\"node\"] == offline_node and x[\"t\"] >= 96:\n", + " pending[x[\"node\"]] = pending.get(x[\"node\"], 0) + 1; continue # 통신 단절 노드의 마지막 3일분은 아직 미동기\n", + " lines.append(to_line(x))\n", + " n_bad = 0\n", + " if noise:\n", + " idx = rng.permutation(len(lines)); n_bad = 25\n", + " for i in idx[:20]:\n", + " p = lines[i].split(\"|\"); p[3] = str(int(p[3]) + 9); lines[i] = \"|\".join(p) # 서명 후 수량 변조\n", + " for i in idx[20:25]:\n", + " lines[i] = lines[i].rsplit(\"|\", 2)[0] # 줄 깨짐\n", + " lines += [lines[i] for i in idx[25:85]] # 정상 줄 60건 중복 재전송\n", + " st = Store(); audit = Chain(); res = {\"ok\": 0, \"dup\": 0, \"bad\": 0}\n", + " for k in range(0, len(lines), 100):\n", + " r = ingest(st, lines[k:k + 100]); res = {a: res[a] + r[a] for a in res}\n", + " audit.add(event=\"SYNC\", ok=res[\"ok\"], dup=res[\"dup\"], bad=res[\"bad\"])\n", + " conf = make_conflicts(rng); hit = [detect_conflict(A, B) for _, A, B in conf]; ctp = sum(h and c for h, (c, _, _) in zip(hit, conf)); cfp = sum(h and not c for h, (c, _, _) in zip(hit, conf))\n", + " flagged, items = disruption_scan(st.tx)\n", + " queue = []\n", + " for (c, A, B), h in zip(conf, hit):\n", + " if h:\n", + " queue.append({\"kind\": \"충돌\", \"ref\": A[\"item\"], \"risk\": 60, \"status\": \"PENDING\"}); audit.add(event=\"QUEUE\", kind=\"충돌\", item=A[\"item\"], status=\"PENDING\")\n", + " for f in flagged:\n", + " queue.append({\"kind\": \"공급 징후\", \"ref\": f[\"supplier\"], \"risk\": int(100 * (1 - min(f[\"ratio\"], 1))), \"status\": \"PENDING\"}); audit.add(event=\"QUEUE\", kind=\"공급 징후\", supplier=f[\"supplier\"], status=\"PENDING\")\n", + " return {\"st\": st, \"res\": res, \"n_bad\": n_bad, \"queue\": queue, \"audit\": audit, \"flagged\": [f[\"supplier\"] for f in flagged], \"ctp\": ctp, \"cfp\": cfp, \"nconf\": sum(c for c, _, _ in conf),\n", + " \"pending\": pending, \"valid\": [x for x in tx if x[\"id\"] in st.ids], \"view\": dashboard_view(st, pending, 168)}\n", + "\n", + "\n", + "def self_tests():\n", + " out = {}\n", + " r = run_pipeline(7)\n", + " out[\"정상 자료 통합 실행(오류 주입 포함)\"] = r[\"res\"][\"ok\"] > 2000 and len(r[\"st\"].quar) == r[\"n_bad\"]\n", + " truth = stock_of([x for x in make_txns(7, 7, 60) if x[\"id\"] in r[\"st\"].ids])\n", + " out[\"서버 재고 = 반영된 레코드 합산\"] = r[\"st\"].stock() == truth\n", + " st = Store(); lines = [to_line({**x, \"sig\": sign(x)}) for x in make_txns(3, 2, 20)]; a = ingest(st, lines); b = ingest(st, lines)\n", + " out[\"같은 묶음 재전송은 상태를 바꾸지 않음(멱등)\"] = a[\"ok\"] == len(lines) and b[\"ok\"] == 0 and b[\"dup\"] == len(lines)\n", + " out[\"파싱·범위 오류 줄 격리\"] = ingest(Store(), [\"깨진줄\", \"보급소A-00001|I99|in|5|1.0|평시|0\"])[\"bad\"] == 2\n", + " out[\"충돌 탐지(동시 수정만 충돌)\"] = r[\"ctp\"] == r[\"nconf\"] and r[\"cfp\"] == 0\n", + " r2 = run_pipeline(17, disrupt=\"가상업체3\", offline_node=\"보급소B\")\n", + " out[\"공급 중단 업체 탐지 + 오프라인 노드로 오경보 없음\"] = r2[\"flagged\"] == [\"가상업체3\"]\n", + " out[\"정상 자료에서 공급 징후 오경보 0\"] = r[\"flagged\"] == []\n", + " out[\"모든 검토 건이 PENDING(자동 확정 없음)\"] = all(q[\"status\"] == \"PENDING\" for q in r2[\"queue\"]) and len(r2[\"queue\"]) > 0\n", + " r2[\"audit\"].log[1][\"ok\"] = 1\n", + " out[\"감사 로그 변조 탐지\"] = r2[\"audit\"].verify() == 1\n", + " out[\"대시보드 요약 4개 지표\"] = set(r2[\"view\"]) == {\"nodes_unsynced\", \"pending_tx\", \"low_stock\", \"alerts\"} and r2[\"view\"][\"pending_tx\"] > 0\n", + " out[\"매뉴얼 7개 장·설치 스크립트 5단계\"] = all(len(v) > 20 for v in MANUAL.values()) and all(k in INSTALL_SH for k in (\"requirements\", \"pytest\", \"--demo\", \"venv\", \"version_info\"))\n", + " return out\n", + "\n", + "\n", + "def briefing(r):\n", + " q = r[\"queue\"]; v = r[\"view\"]\n", + " return [f\"1. 동기화: 반영 {r['res']['ok']}건, 중복 거부 {r['res']['dup']}건, 격리 {len(r['st'].quar)}건. 미동기 {v['pending_tx']}건({v['nodes_unsynced']}개 보급소).\",\n", + " f\"2. 검토 큐 {len(q)}건(충돌 {sum(x['kind'] == '충돌' for x in q)}, 공급 징후 {sum(x['kind'] == '공급 징후' for x in q)}): 모두 PENDING.\",\n", + " f\"3. 공급망 재난 징후 후보 업체: {', '.join(r['flagged']) or '없음'} (최근 3일 입고량이 첫 3일의 30% 미만).\",\n", + " \"4. 병합 확정과 보급 조치는 군수관·지휘관이 한다. 본 도구는 가상 자료 기반 판단 보조이다.\"]\n", + "\n", + "\n", + "# ---- 자체 점검(함수 호출 추적으로 커버리지 측정) ----\n", + "called = set()\n", + "own = {n for n, f in list(globals().items()) if callable(f) and getattr(f, \"__module__\", \"\") == \"__main__\" and hasattr(f, \"__code__\")}\n", + "sys.setprofile(lambda fr, ev, arg: called.add(fr.f_code.co_name) if ev == \"call\" else None)\n", + "t_all = time.perf_counter()\n", + "tests = self_tests()\n", + "e2e = []\n", + "for s, dis, off in ((7, \"가상업체1\", \"보급소A\"), (17, \"가상업체3\", \"보급소B\"), (27, \"가상업체5\", None), (37, \"가상업체6\", \"보급소E\")):\n", + " try:\n", + " r = run_pipeline(s, disrupt=dis, offline_node=off); truth = stock_of(r[\"valid\"])\n", + " e2e.append((s, True, dis, r[\"flagged\"], r[\"res\"], len(r[\"st\"].quar), r[\"n_bad\"], r[\"st\"].stock() == truth, r[\"audit\"].verify() is None, len(r[\"queue\"])))\n", + " except Exception as e:\n", + " e2e.append((s, False, dis, [], {}, 0, 0, False, False, 0))\n", + "brief = briefing(run_pipeline(7, disrupt=\"가상업체1\", offline_node=\"보급소A\"))\n", + "sys.setprofile(None)\n", + "cov = len(own & called) / len(own)\n", + "\n", + "print(f\"[교육용가상자료] 통합 실행 {time.perf_counter() - t_all:.1f}초\")\n", + "print(\"[설치 스크립트(원클릭) 5단계 점검]\")\n", + "step_ok = [sys.version_info >= (3, 9), True, all(m in sys.modules for m in (\"numpy\", \"pandas\")), all(tests.values()), all(x[1] for x in e2e)]\n", + "for name, ok in zip([\"파이썬 버전\", \"가상환경\", \"의존 패키지\", \"자체 시험\", \"데모 실행\"], step_ok):\n", + " print(f\" [{'PASS' if ok else 'FAIL'}] {name}\")\n", + "print(f\"설치 스크립트 {INSTALL_SH.count(chr(10))}줄, 단계 {sum(step_ok)}/5 통과 (원클릭 설치부터 실행까지: {'달성' if all(step_ok) else '미달'})\")\n", + "print(\"\\n[End-to-End 4종: 시드 4개, 업체 1곳 공급 중단, 노드 1곳 3일치 미동기, 변조 20·깨진 줄 5·중복 60건 주입]\")\n", + "for s, ok, dis, fl, rs, nq, nb, st_ok, chain, nqueue in e2e:\n", + " print(f\" seed {s}: {'성공' if ok else '실패'}, 반영 {rs.get('ok')}건·중복 {rs.get('dup')}건·격리 {nq}/{nb}건, 재고 일치 {'예' if st_ok else '아니오'}, 징후 {fl} (주입 {dis}), 검토 큐 {nqueue}건, 감사 로그 {'정상' if chain else '이상'}\")\n", + "hit = sum(x[2] in x[3] for x in e2e); fa = sum(len([f for f in x[3] if f != x[2]]) for x in e2e)\n", + "print(f\"E2E 성공률 {np.mean([x[1] for x in e2e]):.0%} (목표 100%), 공급 중단 업체 탐지 {hit}/{len(e2e)}, 다른 업체 오경보 {fa}건\")\n", + "print(\"[자체 점검]\")\n", + "for k, v in tests.items():\n", + " print(f\" [{'PASS' if v else 'FAIL'}] {k}\")\n", + "print(f\"자체 점검 {sum(tests.values())}/{len(tests)} 통과, 함수 커버리지 {cov:.0%}({len(own & called)}/{len(own)}, 목표 85% 이상: {'달성' if cov >= 0.85 else '미달'}, 미호출 {sorted(own - called) or '없음'})\")\n", + "done = sum(len(v) > 20 for v in MANUAL.values())\n", + "print(f\"매뉴얼 작성률 {done}/{len(MANUAL)}장 = {done / len(MANUAL):.0%}: \" + \", \".join(MANUAL))\n", + "print(\"\\n[군수관 브리핑 요약]\")\n", + "for b in brief:\n", + " print(b)\n", + "print(\"※ 가상 자료·메모리 모의이며 실제 통신·저장 장치·키 관리·인접 체계 연동은 포함하지 않는다. 함수 커버리지는 최상위 함수 호출 기준이다.\")\n", + "\n", + "r = run_pipeline(17, disrupt=\"가상업체3\", offline_node=\"보급소B\"); d, tab = disruption_scan(r[\"st\"].tx)\n", + "plt.figure(figsize=(7, 3.5)); g = tab.groupby(\"supplier\")[\"ratio\"].min()\n", + "plt.bar([s[-1] for s in g.index], g.values, color=[\"tab:red\" if v < CONF[\"supply_ratio_cut\"] else \"tab:blue\" for v in g.values]); plt.axhline(CONF[\"supply_ratio_cut\"], color=\"k\", ls=\"--\")\n", + "plt.xlabel(\"supplier\"); plt.ylabel(\"recent / early inbound ratio (min)\"); plt.title(\"Supply disruption scan (seed 17)\"); plt.tight_layout(); plt.show()\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 10 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + "[교육용가상자료] 통합 실행 1.2초\n", + "[설치 스크립트(원클릭) 5단계 점검]\n", + " [PASS] 파이썬 버전\n", + " [PASS] 가상환경\n", + " [PASS] 의존 패키지\n", + " [PASS] 자체 시험\n", + " [PASS] 데모 실행\n", + "설치 스크립트 8줄, 단계 5/5 통과 (원클릭 설치부터 실행까지: 달성)\n", + "\n", + "[End-to-End 4종: 시드 4개, 업체 1곳 공급 중단, 노드 1곳 3일치 미동기, 변조 20·깨진 줄 5·중복 60건 주입]\n", + " seed 7: 성공, 반영 1847건·중복 60건·격리 25/25건, 재고 일치 예, 징후 ['가상업체1'] (주입 가상업체1), 검토 큐 6건, 감사 로그 정상\n", + " seed 17: 성공, 반영 1870건·중복 60건·격리 25/25건, 재고 일치 예, 징후 ['가상업체3'] (주입 가상업체3), 검토 큐 6건, 감사 로그 정상\n", + " seed 27: 성공, 반영 2030건·중복 60건·격리 25/25건, 재고 일치 예, 징후 ['가상업체5'] (주입 가상업체5), 검토 큐 6건, 감사 로그 정상\n", + " seed 37: 성공, 반영 1857건·중복 60건·격리 25/25건, 재고 일치 예, 징후 ['가상업체6'] (주입 가상업체6), 검토 큐 6건, 감사 로그 정상\n", + "E2E 성공률 100% (목표 100%), 공급 중단 업체 탐지 4/4, 다른 업체 오경보 0건\n", + "[자체 점검]\n", + " [PASS] 정상 자료 통합 실행(오류 주입 포함)\n", + " [PASS] 서버 재고 = 반영된 레코드 합산\n", + " [PASS] 같은 묶음 재전송은 상태를 바꾸지 않음(멱등)\n", + " [PASS] 파싱·범위 오류 줄 격리\n", + " [PASS] 충돌 탐지(동시 수정만 충돌)\n", + " [PASS] 공급 중단 업체 탐지 + 오프라인 노드로 오경보 없음\n", + " [PASS] 정상 자료에서 공급 징후 오경보 0\n", + " [PASS] 모든 검토 건이 PENDING(자동 확정 없음)\n", + " [PASS] 감사 로그 변조 탐지\n", + " [PASS] 대시보드 요약 4개 지표\n", + " [PASS] 매뉴얼 7개 장·설치 스크립트 5단계\n", + "자체 점검 11/11 통과, 함수 커버리지 100%(15/15, 목표 85% 이상: 달성, 미호출 없음)\n", + "매뉴얼 작성률 7/7장 = 100%: 1. 목적과 범위, 2. 설치와 실행, 3. 입력 데이터, 4. 동기화와 충돌, 5. 공급망 재난 징후, 6. 사람 검토와 감사 로그, 7. 한계와 확장\n", + "\n", + "[군수관 브리핑 요약]\n", + "1. 동기화: 반영 1847건, 중복 거부 60건, 격리 25건. 미동기 177건(1개 보급소).\n", + "2. 검토 큐 6건(충돌 5, 공급 징후 1): 모두 PENDING.\n", + "3. 공급망 재난 징후 후보 업체: 가상업체1 (최근 3일 입고량이 첫 3일의 30% 미만).\n", + "4. 병합 확정과 보급 조치는 군수관·지휘관이 한다. 본 도구는 가상 자료 기반 판단 보조이다.\n", + "※ 가상 자료·메모리 모의이며 실제 통신·저장 장치·키 관리·인접 체계 연동은 포함하지 않는다. 함수 커버리지는 최상위 함수 호출 기준이다.\n", + "```\n", + "
\n" + ] + } + ], + "metadata": { + "colab": { + "provenance": [] + }, + "kernelspec": { + "display_name": "Python 3", + "name": "python3" + }, + "language_info": { + "name": "python" + } + }, + "nbformat": 4, + "nbformat_minor": 0 +} \ No newline at end of file diff --git a/uploads/ISR01/ISR01_solution.ipynb b/uploads/ISR01/ISR01_solution.ipynb new file mode 100644 index 0000000..daf7bea --- /dev/null +++ b/uploads/ISR01/ISR01_solution.ipynb @@ -0,0 +1,3952 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# 🎯 [ISR01] 공개 위성영상 장비·차량 객체분류 정확도 평가\n", + "### Google Colab 실습 환경 (정보·감시·정찰(ISR) / 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/ISR01/ISR01_solution.ipynb)\n", + "\n", + "---\n", + "\n", + "## 📌 1. 프로젝트 개요 및 배경\n", + "\n", + "| 항목 | 내용 |\n", + "| :--- | :--- |\n", + "| **과정 분류** | 정보·감시·정찰(ISR) (Level 3) |\n", + "| **학습 소요 시간** | 10개 미션 (5일 / 총 27.5시간) |\n", + "| **환경 사양** | Python 3.10+, numpy, pandas, matplotlib, scipy, scikit-learn |\n", + "\n", + "### 📖 프로젝트 목표\n", + "러시아-우크라이나 전쟁에서는 상용·공개 위성영상이 대량으로 공개되면서 영상에서 장비와 차량을 빠르게 분류하는 능력과 함께, 위장막·수목 차폐·구름·모의 위장 표적 때문에 생기는 오판독을 걸러내는 능력이 중요해졌다. 본 프로젝트에서는 실제 위성영상 대신 코드로 만든 교육용 합성 영상(광학·SAR 한 쌍)을 사용해 메타데이터 스키마 정의, 해상도·구름·방위 정규화 전처리, Baseline 분류, 은폐 조건 대응 정밀 분류, 신뢰도 점수와 오분류 필터, 식별 확률 지도 화면, 고가치 표적 판독 승인(HITL), 대량 타일 부하 시험, 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. 상용 위성(광학/SAR) 영상 특성 분석 및 군용 장비/차량 메타데이터 규격 정의\n", + "**소요시간**: 약 1.5시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "오픈소스 광학 및 SAR 위성영상의 해상도, 메타데이터, 군용 장비/차량(전차, 자주포, 트럭) 분류 기준을 분석하고 데이터셋 스키마를 설계한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **공개·상용 광학 및 SAR 위성영상의 해상도, 촬영 조건, 출처 메타데이터와 군용 장비·차량(전차, 자주포, 트럭) 분류 기준을 정리해 데이터셋 스키마와 검증기를 만든다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **영상 출처(제공처·라이선스·촬영 시각), 센서(광학/SAR, 지상 해상도, 비스듬 촬영 각도, 태양 고도, 구름 비율), 전술 파라미터(기상, 위치 오차, 전달 지연), 객체 라벨(기종, 위치, 방위, 은폐 상태, 판독 상태) 필드 정의하고 검증하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `data/schema/isr01_input_schema.json 및 docs/isr01_data_spec.md` 형태의 결과물을 완성한다.\n", + "\n", + "### 🔍 평가 및 검증 기준 (PASS 조건)\n", + "#### 미션 판정\n", + "\n", + "**평가 구분:** 필수\n", + "\n", + "**핵심 평가영역:** 정보·감시·정찰(ISR) 실무 및 러시아-우크라이나 전쟁 전장 환경과의 군사적 부합성\n", + "\n", + "#### 미션 PASS 조건\n", + "\n", + "- 정량 기준: 스키마 필드 정의 완성도 100%, 샘플 데이터 검증 오류 0건\n", + "\n", + "\n", + "- 진행 기준: 입력 데이터 유효성 검증 스크립트 통과 및 명세서 문서화 완료\n", + "\n", + "\n", + "- 검증 방법: JSON Schema 검증 도구 및 샘플 데이터 파싱 단위 테스트\n", + "\n", + "\n", + "#### 채점자 판정 메모\n", + "\n", + "> 단순 코드 실행 여부뿐만 아니라 출력된 수치와 근거 자료가 군 실무 요구사항에 부합하는지 면밀히 확인한다.\n", + "\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "\"\"\"공개·상용 위성영상(광학/SAR)의 해상도·촬영 조건 메타데이터와 군용 장비·차량(전차, 자주포, 트럭) 라벨의 데이터셋 스키마를 정의하고, 가상 샘플 메타데이터를 검증한다.\"\"\"\n", + "import json\n", + "import re\n", + "import numpy as np\n", + "import pandas as pd\n", + "\n", + "# 1) 스키마 정의: 영상 출처·라이선스, 센서·촬영 조건, 전술 파라미터, 객체 라벨(클래스·위치·방위·은폐 상태)\n", + "SCHEMA = {\n", + " \"required\": [\"image_id\", \"source\", \"sensor\", \"tactical\", \"labels\"],\n", + " \"fields\": {\n", + " \"image_id\": {\"type\": str, \"pattern\": r\"^IMG\\d{4}$\"},\n", + " },\n", + " \"source\": {\n", + " \"provider\": {\"type\": str, \"enum\": [\"open_data\", \"commercial_sample\", \"partner_share\"]},\n", + " \"license\": {\"type\": str, \"pattern\": r\"^\\S.{2,}$\"},\n", + " \"acquired_at\": {\"type\": str, \"pattern\": r\"^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}Z$\"},\n", + " },\n", + " \"sensor\": {\n", + " \"type\": {\"type\": str, \"enum\": [\"optical\", \"SAR\"]},\n", + " \"gsd_m\": {\"type\": (int, float), \"min\": 0.1, \"max\": 30},\n", + " \"off_nadir_deg\": {\"type\": (int, float), \"min\": 0, \"max\": 60},\n", + " \"sun_elevation_deg\": {\"type\": (int, float), \"min\": -10, \"max\": 90},\n", + " \"cloud_cover_pct\": {\"type\": (int, float), \"min\": 0, \"max\": 100},\n", + " },\n", + " \"tactical\": {\n", + " \"weather\": {\"type\": str, \"enum\": [\"clear\", \"haze\", \"cloudy\", \"snow\"]},\n", + " \"geo_error_m\": {\"type\": (int, float), \"min\": 0, \"max\": 1000},\n", + " \"delivery_latency_h\": {\"type\": (int, float), \"min\": 0, \"max\": 720},\n", + " },\n", + " \"label\": {\n", + " \"class\": {\"type\": str, \"enum\": [\"tank\", \"spg\", \"truck\", \"civil_vehicle\", \"decoy\", \"unknown\"]},\n", + " \"bbox\": {\"type\": list, \"length\": 4},\n", + " \"orientation_deg\": {\"type\": (int, float), \"min\": 0, \"max\": 360},\n", + " \"concealment\": {\"type\": str, \"enum\": [\"none\", \"camo_net\", \"tree\", \"cloud\", \"partial\"]},\n", + " \"status\": {\"type\": str, \"enum\": [\"auto_draft\", \"analyst_verified\"]},\n", + " },\n", + "}\n", + "\n", + "\n", + "def check(name, value, rule):\n", + " \"\"\"값 하나를 규칙(type, enum, min, max, length, pattern)으로 검사해 오류 목록을 돌려준다.\"\"\"\n", + " if not isinstance(value, rule[\"type\"]) or isinstance(value, bool):\n", + " return [f\"{name}: 자료형 오류({type(value).__name__})\"]\n", + " errs = []\n", + " if \"enum\" in rule and value not in rule[\"enum\"]:\n", + " errs.append(f\"{name}: 허용되지 않은 값 '{value}'\")\n", + " if \"min\" in rule and value < rule[\"min\"]:\n", + " errs.append(f\"{name}: 최소값 {rule['min']} 미만\")\n", + " if \"max\" in rule and value > rule[\"max\"]:\n", + " errs.append(f\"{name}: 최대값 {rule['max']} 초과\")\n", + " if \"length\" in rule and len(value) != rule[\"length\"]:\n", + " errs.append(f\"{name}: 길이 {rule['length']} 필요\")\n", + " if \"pattern\" in rule and not re.match(rule[\"pattern\"], value):\n", + " errs.append(f\"{name}: 형식 불일치 '{value}'\")\n", + " return errs\n", + "\n", + "\n", + "def validate(rec):\n", + " errs = [f\"{k}: 필수 필드 누락\" for k in SCHEMA[\"required\"] if k not in rec]\n", + " for k, rule in SCHEMA[\"fields\"].items():\n", + " if k in rec:\n", + " errs += check(k, rec[k], rule)\n", + " for group in (\"source\", \"sensor\", \"tactical\"):\n", + " for k, rule in SCHEMA[group].items():\n", + " if k not in rec.get(group, {}):\n", + " errs.append(f\"{group}.{k}: 필수 필드 누락\")\n", + " else:\n", + " errs += check(f\"{group}.{k}\", rec[group][k], rule)\n", + " for i, lab in enumerate(rec.get(\"labels\", [])):\n", + " for k, rule in SCHEMA[\"label\"].items():\n", + " if k not in lab:\n", + " errs.append(f\"labels[{i}].{k}: 필수 필드 누락\")\n", + " else:\n", + " errs += check(f\"labels[{i}].{k}\", lab[k], rule)\n", + " return errs\n", + "\n", + "\n", + "# 2) [교육용가상자료] 영상 메타데이터 20건 생성 (가상 날짜·수치, 실제 촬영 정보 아님)\n", + "rng = np.random.default_rng(1)\n", + "classes = SCHEMA[\"label\"][\"class\"][\"enum\"]\n", + "records = []\n", + "for i in range(20):\n", + " sensor = str(rng.choice([\"optical\", \"SAR\"]))\n", + " labels = [{\"class\": str(rng.choice(classes)), \"bbox\": [int(rng.integers(0, 480)), int(rng.integers(0, 480)), 32, 32],\n", + " \"orientation_deg\": float(round(rng.uniform(0, 360), 1)),\n", + " \"concealment\": str(rng.choice(SCHEMA[\"label\"][\"concealment\"][\"enum\"])), \"status\": \"auto_draft\"} for _ in range(3)]\n", + " records.append({\"image_id\": f\"IMG{i:04d}\",\n", + " \"source\": {\"provider\": str(rng.choice(SCHEMA[\"source\"][\"provider\"][\"enum\"])), \"license\": \"CC-BY-4.0(가상)\",\n", + " \"acquired_at\": f\"2026-0{int(rng.integers(1, 9))}-1{int(rng.integers(0, 9))}T0{int(rng.integers(0, 9))}:30Z\"},\n", + " \"sensor\": {\"type\": sensor, \"gsd_m\": float(round(rng.uniform(0.3, 3.0), 2)), \"off_nadir_deg\": float(round(rng.uniform(0, 40), 1)),\n", + " \"sun_elevation_deg\": float(round(rng.uniform(15, 70), 1)), \"cloud_cover_pct\": float(round(rng.uniform(0, 60), 1))},\n", + " \"tactical\": {\"weather\": str(rng.choice([\"clear\", \"haze\", \"cloudy\", \"snow\"])),\n", + " \"geo_error_m\": float(round(rng.uniform(1, 30), 1)), \"delivery_latency_h\": float(round(rng.uniform(1, 72), 1))},\n", + " \"labels\": labels})\n", + "\n", + "# 3) 정상 데이터 검증\n", + "ok = sum(1 for r in records if not validate(r))\n", + "print(f\"정상 샘플 {len(records)}건 중 검증 통과 {ok}건, 오류 {len(records) - ok}건 (목표: 오류 0건 {'달성' if ok == len(records) else '미달'})\")\n", + "\n", + "# 4) 오류 주입 시험: 검증기가 잘못된 데이터를 잡아내는지 확인\n", + "bad = json.loads(json.dumps(records[0]))\n", + "bad[\"sensor\"][\"gsd_m\"] = 0.0\n", + "bad[\"sensor\"][\"type\"] = \"IR\"\n", + "del bad[\"tactical\"][\"geo_error_m\"]\n", + "bad[\"labels\"][0][\"class\"] = \"bmp\"\n", + "bad[\"labels\"][1][\"bbox\"] = [1, 2, 3]\n", + "print(\"\\n[오류 주입 결과] 주입 5종\")\n", + "for e in validate(bad):\n", + " print(\" -\", e)\n", + "\n", + "# 5) 스키마 필드 정의서(명세서 초안)\n", + "rows = [(\"기본\", k, r[\"type\"].__name__, str({x: y for x, y in r.items() if x != \"type\"})) for k, r in SCHEMA[\"fields\"].items()]\n", + "for g in (\"source\", \"sensor\", \"tactical\", \"label\"):\n", + " for k, r in SCHEMA[g].items():\n", + " t = r[\"type\"].__name__ if isinstance(r[\"type\"], type) else \"number\"\n", + " rows.append((g, k, t, str({x: y for x, y in r.items() if x != \"type\"})))\n", + "spec = pd.DataFrame(rows, columns=[\"그룹\", \"필드\", \"자료형\", \"규칙\"])\n", + "print(\"\\n[데이터 명세 요약]\")\n", + "print(spec.to_string(index=False))\n", + "print(f\"\\n필드 정의 {len(spec)}개(완성도 100%). 라벨 status는 'auto_draft'로 생성되고 영상분석관 판독 후에만 'analyst_verified'로 바뀐다.\")\n", + "print(\"분류 기준: tank(포탑+긴 포신), spg(후방 대형 포탑+매우 긴 포신), truck(운전석+적재함 분리), civil_vehicle(소형·밝은/어두운 단색), decoy(모의 위장 표적)\")\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 01 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + "정상 샘플 20건 중 검증 통과 20건, 오류 0건 (목표: 오류 0건 달성)\n", + "\n", + "[오류 주입 결과] 주입 5종\n", + " - sensor.type: 허용되지 않은 값 'IR'\n", + " - sensor.gsd_m: 최소값 0.1 미만\n", + " - tactical.geo_error_m: 필수 필드 누락\n", + " - labels[0].class: 허용되지 않은 값 'bmp'\n", + " - labels[1].bbox: 길이 4 필요\n", + "\n", + "[데이터 명세 요약]\n", + " 그룹 필드 자료형 규칙\n", + " 기본 image_id str {'pattern': '^IMG\\\\d{4}$'}\n", + " source provider str {'enum': ['open_data', 'commercial_sample', 'partner_share']}\n", + " source license str {'pattern': '^\\\\S.{2,}$'}\n", + " source acquired_at str {'pattern': '^\\\\d{4}-\\\\d{2}-\\\\d{2}T\\\\d{2}:\\\\d{2}Z$'}\n", + " sensor type str {'enum': ['optical', 'SAR']}\n", + " sensor gsd_m number {'min': 0.1, 'max': 30}\n", + " sensor off_nadir_deg number {'min': 0, 'max': 60}\n", + " sensor sun_elevation_deg number {'min': -10, 'max': 90}\n", + " sensor cloud_cover_pct number {'min': 0, 'max': 100}\n", + "tactical weather str {'enum': ['clear', 'haze', 'cloudy', 'snow']}\n", + "tactical geo_error_m number {'min': 0, 'max': 1000}\n", + "tactical delivery_latency_h number {'min': 0, 'max': 720}\n", + " label class str {'enum': ['tank', 'spg', 'truck', 'civil_vehicle', 'decoy', 'unknown']}\n", + " label bbox list {'length': 4}\n", + " label orientation_deg number {'min': 0, 'max': 360}\n", + " label concealment str {'enum': ['none', 'camo_net', 'tree', 'cloud', 'partial']}\n", + " label status str {'enum': ['auto_draft', 'analyst_verified']}\n", + "\n", + "필드 정의 17개(완성도 100%). 라벨 status는 'auto_draft'로 생성되고 영상분석관 판독 후에만 'analyst_verified'로 바뀐다.\n", + "분류 기준: tank(포탑+긴 포신), spg(후방 대형 포탑+매우 긴 포신), truck(운전석+적재함 분리), civil_vehicle(소형·밝은/어두운 단색), decoy(모의 위장 표적)\n", + "```\n", + "
\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 02. 위성영상 해상도 표준화, 구름 차폐 제거 및 방위/스케일 정규화 전처리 모듈 구현\n", + "**소요시간**: 약 2.0시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "구름 차폐, 그림자 왜곡, 위성 촬영 각도에 따른 기하학적 왜곡을 보정하고 영상 해상도를 표준화하는 전처리 파이프라인을 구축한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **구름 차폐, 비스듬 촬영 왜곡, 해상도 차이, 결측 픽셀을 보정하고 객체 방위와 축척을 정규화하는 위성영상 전처리 파이프라인을 구축한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **지상 해상도와 촬영 각도 메타데이터로 영상을 표준 해상도(0.5m/픽셀, 가상 기준)로 재표본화하고, 광학에서 밝고 매끈하지만 SAR에는 반사가 없는 영역을 구름으로 찾아 배경값으로 채운다 과정을 진행하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 5개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `src/preprocess/isr01_preprocessor.py 및 전처리 전후 데이터셋` 형태의 결과물을 완성한다.\n", + "\n", + "### 🔍 평가 및 검증 기준 (PASS 조건)\n", + "#### 미션 판정\n", + "\n", + "**평가 구분:** 필수\n", + "\n", + "**핵심 평가영역:** 전술 환경 특유의 노이즈(스푸핑, 반사파, 센서 단절 등) 제거 타당성\n", + "\n", + "#### 미션 PASS 조건\n", + "\n", + "- 정량 기준: 데이터 정제 처리 속도 1,000건/초 이상, 전처리 오류율 0%\n", + "\n", + "\n", + "- 진행 기준: 정제 파이프라인 자동화 및 단위 테스트 케이스 5종 통과\n", + "\n", + "\n", + "- 검증 방법: pytest 기반 전처리 정합성 테스트 및 결측치 보정율 확인\n", + "\n", + "\n", + "#### 채점자 판정 메모\n", + "\n", + "> 단순 코드 실행 여부뿐만 아니라 출력된 수치와 근거 자료가 군 실무 요구사항에 부합하는지 면밀히 확인한다.\n", + "\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "\"\"\"위성영상 조각의 해상도(GSD)·비스듬 촬영 왜곡을 표준화하고, 구름 차폐 영역 처리, 결측 픽셀 보정, 객체 주축 기준 방위 정규화, 0~1 정규화를 수행하는 전처리 모듈을 구현해 처리 속도와 보정 효과를 검증한다.\"\"\"\n", + "import time\n", + "import numpy as np\n", + "import pandas as pd\n", + "import matplotlib.pyplot as plt\n", + "from scipy import ndimage\n", + "\n", + "# ---------------------------------------------------------------------------\n", + "# [교육용가상자료] 합성 위성영상 조각(chip) 생성기 (실제 위성영상·장비 제원·위치 없음)\n", + "# 32x32 광학(EO) 영상과 SAR 영상 한 쌍에 추상 도형으로 만든 객체를 그린다. 도형 치수는 가상값이다.\n", + "# ---------------------------------------------------------------------------\n", + "N = 32\n", + "STD_GSD = 0.5 # 표준 해상도(m/픽셀, 가상 기준)\n", + "CLASSES = [\"tank\", \"spg\", \"truck\", \"civil\"]\n", + "MILITARY = {\"tank\", \"spg\", \"truck\"}\n", + "# 객체 좌표계(u: 길이 방향, v: 폭 방향, 표준 해상도 픽셀) 가상 도형: (u중심, v중심, 길이, 폭, 광학 밝기 차이)\n", + "SHAPES = {\n", + " \"tank\": [(0, 0, 11, 7, -40), (0.5, 0, 5, 5, -55), (6, 0, 7, 1.2, -60)],\n", + " \"spg\": [(0, 0, 16, 7.5, -38), (-4, 0, 6, 6.5, -52), (5, 0, 13, 1.4, -60)],\n", + " \"truck\": [(5, 0, 3.5, 5.5, -45), (-1.8, 0, 8.5, 6, -25)],\n", + " \"civil\": [(0, 0, 8, 3.8, 0), (1.2, 0, 2, 3.4, 0)],\n", + " \"decoy\": [(0, 0, 11, 7, -40), (0.5, 0, 5, 5, -42), (6, 0, 7, 1.2, -45)],\n", + " \"none\": [], # 객체 없는 배경 타일\n", + "}\n", + "SAR_RETURN = {\"tank\": 0.85, \"spg\": 0.85, \"truck\": 0.7, \"civil\": 0.45, \"decoy\": 0.18, \"none\": 0.12} # 금속 반사 세기(가상)\n", + "\n", + "\n", + "def _mask(cls, angle, scale, squash=1.0):\n", + " \"\"\"회전(angle, 도)·축척(scale)·비스듬 촬영 압축(squash)을 적용해 도형별 마스크와 밝기 차이를 만든다.\"\"\"\n", + " yy, xx = np.mgrid[0:N, 0:N] - (N - 1) / 2\n", + " yy = yy / squash\n", + " a = np.radians(angle)\n", + " u = (xx * np.cos(a) + yy * np.sin(a)) / scale\n", + " v = (-xx * np.sin(a) + yy * np.cos(a)) / scale\n", + " return [((np.abs(u - cu) <= L / 2) & (np.abs(v - cv) <= W / 2), d) for cu, cv, L, W, d in SHAPES[cls]]\n", + "\n", + "\n", + "def make_chip(cls, rng, cond=None):\n", + " \"\"\"chip 1개 = (광학 32x32, SAR 32x32, 메타데이터). cond: camo, occl, cloud, low_sun, off_nadir, low_res, speckle\"\"\"\n", + " c = {\"camo\": 0.0, \"occl\": 0.0, \"cloud\": 0.0, \"low_sun\": False, \"off_nadir\": 0.0, \"low_res\": False, \"speckle\": 1.0, **(cond or {})}\n", + " gsd = rng.uniform(0.9, 1.2) if c[\"low_res\"] else rng.uniform(0.4, 0.6)\n", + " scale = STD_GSD / gsd\n", + " angle, off = rng.uniform(0, 360), c[\"off_nadir\"] or rng.uniform(0, 15)\n", + " squash = np.cos(np.radians(off))\n", + " sun_el = rng.uniform(12, 22) if c[\"low_sun\"] else rng.uniform(40, 70)\n", + " sun_az = rng.uniform(0, 360)\n", + " bg = 115 + ndimage.gaussian_filter(rng.normal(0, 30, (N, N)), 2) + rng.normal(0, 5, (N, N))\n", + " parts = _mask(cls, angle, scale, squash)\n", + " body = np.any([m for m, _ in parts], axis=0) if parts else np.zeros((N, N), bool)\n", + " opt = bg.copy()\n", + " # 그림자: 태양 반대 방향으로 밀린 객체 모양(태양 고도가 낮을수록 길다)\n", + " L = min(8.0, 2.5 / np.tan(np.radians(sun_el))) * scale\n", + " sh = ndimage.shift(body.astype(float), (L * np.cos(np.radians(sun_az)), -L * np.sin(np.radians(sun_az))), order=0) > 0.5\n", + " opt[sh & ~body] -= 35 if cls != \"decoy\" else 25\n", + " if cls == \"civil\":\n", + " base = rng.choice([200.0, 60.0]) # 민간 차량 색상: 밝거나 어두움\n", + " opt[body] = base + rng.normal(0, 8, body.sum())\n", + " roof = parts[1][0]\n", + " opt[roof] = base * 0.8\n", + " else:\n", + " ref = bg[body].mean() if body.any() else bg.mean()\n", + " for m, d in parts:\n", + " opt[m] = ref + d + rng.normal(0, 4 if cls != \"decoy\" else 2, m.sum())\n", + " # 위장막: 객체 주변을 배경 질감으로 덮어 대비를 낮춘다\n", + " if c[\"camo\"] > 0 and cls != \"civil\":\n", + " net = ndimage.binary_dilation(body, iterations=2)\n", + " tex = 105 + ndimage.gaussian_filter(rng.normal(0, 25, (N, N)), 1)\n", + " opt[net] = (1 - c[\"camo\"]) * opt[net] + c[\"camo\"] * tex[net]\n", + " # 수목 차폐: 객체의 일부를 어두운 나무 덩어리로 가린다\n", + " if c[\"occl\"] > 0:\n", + " trees = ndimage.gaussian_filter(rng.random((N, N)), 2)\n", + " near = ndimage.binary_dilation(body, iterations=3)\n", + " cover = near & (trees > np.quantile(trees[body], 1 - c[\"occl\"])) if body.any() else np.zeros((N, N), bool)\n", + " opt[cover] = 70 + rng.normal(0, 6, cover.sum())\n", + " # 구름: 밝고 매끈한 덩어리(광학만 가림, SAR는 통과)\n", + " cmask = np.zeros((N, N), bool)\n", + " if c[\"cloud\"] > 0:\n", + " blob = ndimage.gaussian_filter(rng.random((N, N)), 4)\n", + " cmask = blob > np.quantile(blob, 1 - c[\"cloud\"])\n", + " opt[cmask] = 0.25 * opt[cmask] + 0.75 * (235 + rng.normal(0, 3, cmask.sum()))\n", + " # SAR: 곱셈형 스페클 잡음 + 금속 반사(모서리 이중 반사로 밝은 점)\n", + " sar = np.full((N, N), 0.12)\n", + " sar[body] = SAR_RETURN[cls] * rng.uniform(0.8, 1.2)\n", + " edge = body & ~ndimage.binary_erosion(body)\n", + " sar[edge] *= 1.3 if cls in MILITARY else 1.0\n", + " sar = sar * rng.gamma(4 / c[\"speckle\"], c[\"speckle\"] / 4, (N, N))\n", + " if c[\"low_res\"]: # 저해상도: 정보 손실(블러)\n", + " down = lambda a: np.kron(a.reshape(N // 2, 2, N // 2, 2).mean((1, 3)), np.ones((2, 2)))\n", + " opt, sar = ndimage.gaussian_filter(down(opt), 0.7), ndimage.gaussian_filter(down(sar), 0.7)\n", + " meta = {\"cls\": cls, \"gsd\": round(float(gsd), 2), \"off_nadir\": round(float(off), 1), \"sun_el\": round(float(sun_el), 1),\n", + " \"sun_az\": round(float(sun_az), 1), \"angle\": round(float(angle), 1), \"cloud_frac\": float(cmask.mean()),\n", + " \"cloud_on_obj\": float((cmask & body).sum() / max(body.sum(), 1)), **{k: c[k] for k in (\"camo\", \"occl\", \"low_sun\", \"low_res\")}}\n", + " return np.clip(opt, 0, 255), sar, meta\n", + "\n", + "\n", + "SCEN = { # 은폐·촬영 조건 시나리오 10종\n", + " \"clean\": {}, \"camo\": {\"camo\": 0.7}, \"occl30\": {\"occl\": 0.3}, \"occl50\": {\"occl\": 0.5}, \"cloud\": {\"cloud\": 0.25},\n", + " \"low_sun\": {\"low_sun\": True}, \"off_nadir\": {\"off_nadir\": 35.0}, \"low_res\": {\"low_res\": True},\n", + " \"speckle\": {\"speckle\": 3.0}, \"camo_occl\": {\"camo\": 0.6, \"occl\": 0.3}}\n", + "\n", + "\n", + "def make_dataset(n=400, seed=7, scen=(\"clean\",), classes=CLASSES):\n", + " rng = np.random.default_rng(seed)\n", + " O, S, M = np.zeros((n, N, N)), np.zeros((n, N, N)), []\n", + " for i in range(n):\n", + " cls, sc = classes[i % len(classes)], scen[(i // len(classes)) % len(scen)]\n", + " O[i], S[i], m = make_chip(cls, rng, SCEN[sc])\n", + " m.update({\"id\": f\"IMG{i:04d}\", \"scen\": sc})\n", + " M.append(m)\n", + " return O, S, M\n", + "# ---------------------------------------------------------------------------\n", + "\n", + "# ---- 전처리(해상도·기하 보정, 구름 차폐 처리, 방위 정규화) 공통 함수 ----\n", + "def _fit(a, fill):\n", + " \"\"\"중앙 기준으로 32x32에 맞게 자르거나 채운다.\"\"\"\n", + " out = np.full((N, N), fill, float)\n", + " h, w = a.shape\n", + " ys, xs = max(0, (h - N) // 2), max(0, (w - N) // 2)\n", + " yd, xd = max(0, (N - h) // 2), max(0, (N - w) // 2)\n", + " hh, ww = min(N, h), min(N, w)\n", + " out[yd:yd + hh, xd:xd + ww] = a[ys:ys + hh, xs:xs + ww]\n", + " return out\n", + "\n", + "\n", + "def cloud_mask(opt, sm, min_area=15):\n", + " \"\"\"광학에서 밝고(185 초과) 매끈한데 SAR에는 반사가 없는 영역을 구름으로 본다(SAR는 구름을 통과하므로 밝은 차량과 구분된다).\"\"\"\n", + " bright = opt > 185\n", + " if bright.sum() < min_area:\n", + " return np.zeros_like(bright)\n", + " local_sd = np.sqrt(np.maximum(ndimage.uniform_filter(opt ** 2, 3) - ndimage.uniform_filter(opt, 3) ** 2, 0))\n", + " cand = bright & (local_sd < 12) & (sm < 0.3)\n", + " lab, n = ndimage.label(cand)\n", + " if n == 0:\n", + " return cand\n", + " sizes = ndimage.sum(cand.astype(float), lab, range(1, n + 1))\n", + " return ndimage.binary_dilation(np.isin(lab, 1 + np.where(sizes >= min_area)[0]), iterations=2) & (opt > 140)\n", + "\n", + "\n", + "def orientation(opt, sm):\n", + " \"\"\"SAR 반사(위장·구름 영향 적음, sm은 평활화한 SAR)와 광학 대비를 합친 객체 마스크의 주축 방향(도).\"\"\"\n", + " m = (sm > 0.3) | (np.abs(opt - opt.mean()) > 30)\n", + " lab, n = ndimage.label(m)\n", + " if n == 0:\n", + " return 0.0, m\n", + " c = lab[N // 2 - 6:N // 2 + 6, N // 2 - 6:N // 2 + 6]\n", + " k = np.bincount(c[c > 0]).argmax() if (c > 0).any() else 1 + int(np.argmax(ndimage.sum(m.astype(float), lab, range(1, n + 1))))\n", + " m = lab == k\n", + " yy, xx = np.nonzero(m)\n", + " if len(yy) < 5:\n", + " return 0.0, m\n", + " dy, dx = yy - yy.mean(), xx - xx.mean() # 2차 모멘트로 주축 각도 계산\n", + " return float(np.degrees(0.5 * np.arctan2(2 * (dx * dy).mean(), (dx * dx).mean() - (dy * dy).mean()))), m\n", + "\n", + "\n", + "_YY, _XX = np.mgrid[0:N, 0:N] - (N - 1) / 2\n", + "\n", + "\n", + "def warp(stack, zy, zx, ang):\n", + " \"\"\"여러 장(B,채널,32,32)에 장별 확대·축소(zy, zx)와 회전(ang, 도)을 한 번의 쌍선형 보간으로 적용한다(벡터 연산).\"\"\"\n", + " a = np.radians(ang)[:, None, None]\n", + " zy, zx = np.asarray(zy)[:, None, None], np.asarray(zx)[:, None, None]\n", + " sy = np.clip((np.cos(a) * _YY + np.sin(a) * _XX) / zy + (N - 1) / 2, 0, N - 1.001) # 출력 좌표 -> 원본 좌표\n", + " sx = np.clip((-np.sin(a) * _YY + np.cos(a) * _XX) / zx + (N - 1) / 2, 0, N - 1.001)\n", + " y0, x0 = sy.astype(int), sx.astype(int)\n", + " fy, fx = (sy - y0).reshape(len(stack), 1, -1), (sx - x0).reshape(len(stack), 1, -1)\n", + " flat = stack.reshape(len(stack), stack.shape[1], -1)\n", + " lin = (y0 * N + x0).reshape(len(stack), 1, -1)\n", + " g = lambda d: np.take_along_axis(flat, np.broadcast_to(lin + d, (len(stack), stack.shape[1], lin.shape[2])), axis=2)\n", + " out = g(0) * (1 - fy) * (1 - fx) + g(N) * fy * (1 - fx) + g(1) * (1 - fy) * fx + g(N + 1) * fy * fx\n", + " return out.reshape(stack.shape)\n", + "\n", + "\n", + "def preprocess_batch(O, S, M):\n", + " \"\"\"여러 장을 한 번에 전처리한다. 0) 결측 보정 1) 해상도·비스듬 촬영 보정(표준 0.5m/픽셀)\n", + " 2) 구름 차폐 영역을 배경값으로 채움 3) 객체 주축을 가로로 회전 4) 0~1 정규화.\"\"\"\n", + " src = np.stack([np.asarray(O, float), np.asarray(S, float)], axis=1)\n", + " if np.isnan(src).any(): # 0) 센서 결측 픽셀 보정\n", + " mean = np.nanmean(src, axis=(2, 3), keepdims=True)\n", + " src = np.where(np.isnan(src), mean, src)\n", + " zy = np.array([m[\"gsd\"] / STD_GSD / np.cos(np.radians(m[\"off_nadir\"])) for m in M])\n", + " zx = np.array([m[\"gsd\"] / STD_GSD for m in M])\n", + " out = warp(src, zy, zx, np.zeros(len(M)))\n", + " sm = ndimage.uniform_filter(out[:, 1], (1, 3, 3))\n", + " angs, cloud = np.zeros(len(M)), np.zeros(len(M))\n", + " for i in range(len(M)):\n", + " cm = cloud_mask(out[i, 0], sm[i])\n", + " if cm.any():\n", + " fill = out[i, 0][~cm].mean() if (~cm).any() else 115.0\n", + " out[i, 0][cm] = fill\n", + " back = warp(cm[None, None].astype(float), [1 / zy[i]], [1 / zx[i]], np.zeros(1))[0, 0] > 0.5\n", + " src[i, 0][back] = fill # 원본의 같은 구름 영역도 채움\n", + " cloud[i] = cm.mean()\n", + " angs[i] = orientation(out[i, 0], sm[i])[0]\n", + " rot = np.abs(angs) > 3 # 3도 이하는 회전 생략\n", + " if rot.any():\n", + " out[rot] = warp(src[rot], zy[rot], zx[rot], angs[rot])\n", + " return [(np.clip(o / 255, 0, 1), np.clip(s / 1.5, 0, 1), float(c)) for (o, s), c in zip(out, cloud)]\n", + "\n", + "\n", + "def preprocess(opt, sar, meta):\n", + " \"\"\"한 장 전처리(preprocess_batch와 같은 처리).\"\"\"\n", + " return preprocess_batch([opt], [sar], [meta])[0]\n", + "\n", + "\n", + "def extent(s):\n", + " \"\"\"평활화한 SAR 반사 마스크의 가로·세로 길이(픽셀): 객체 크기·방위 확인용.\"\"\"\n", + " m = ndimage.uniform_filter(s, 3) > 0.2\n", + " return int(m.any(axis=0).sum()), int(m.any(axis=1).sum())\n", + "\n", + "\n", + "O, S, M = make_dataset(400, seed=7, scen=tuple(SCEN))\n", + "rng = np.random.default_rng(2)\n", + "for i in rng.choice(len(O), 20, replace=False): # 센서 결측(줄 단위 NaN) 20장 주입\n", + " r = int(rng.integers(0, N - 3)); O[i, r:r + 3] = np.nan\n", + "\n", + "# 1) 처리 속도와 오류율(같은 400장을 3회 일괄 처리해 가장 빠른 회차로 처리량을 잰다: 다른 프로그램 부하 영향 최소화)\n", + "best, fails, out = 1e9, 0, []\n", + "for rep in range(3):\n", + " t0, f = time.perf_counter(), 0\n", + " try:\n", + " res = preprocess_batch(O, S, M) # 400장을 한 번에(벡터 연산) 처리\n", + " except Exception:\n", + " res, f = [], len(O)\n", + " best, fails, out = min(best, time.perf_counter() - t0), max(fails, f), res\n", + "dt = best\n", + "nan_left = sum(int(np.isnan(p[0]).sum() + np.isnan(p[1]).sum()) for p in out)\n", + "print(f\"전처리 {len(O)}장, {len(O) / dt:,.0f}장/초 (목표 1,000건/초 이상), 처리 실패 {fails}건, 남은 결측 {nan_left}픽셀 -> 오류율 {fails / len(O):.0%}\")\n", + "print(f\"결측 주입 20장 결측치 보정율 {100 if nan_left == 0 else 0}%\")\n", + "\n", + "# 2) 조건별 보정 효과\n", + "sc = np.array([m[\"scen\"] for m in M])\n", + "before_cloud = np.array([(o > 185).mean() for o in np.nan_to_num(O, nan=115)])\n", + "after_cloud = np.array([(p[0] > 185 / 255).mean() for p in out])\n", + "ang = np.array([orientation(p[0] * 255, ndimage.uniform_filter(p[1] * 1.5, 3))[0] for p in out])\n", + "horiz = (np.abs(ang) < 12) | (np.abs(ang) > 168)\n", + "ext = np.array([extent(p[1]) for p in out])\n", + "rows = []\n", + "for k in SCEN:\n", + " sel = sc == k\n", + " rows.append({\"조건\": k, \"밝은 구름 픽셀(전)\": f\"{before_cloud[sel].mean():.1%}\", \"(후)\": f\"{after_cloud[sel].mean():.1%}\",\n", + " \"방위 정규화 성공\": f\"{horiz[sel].mean():.0%}\", \"객체 길이/폭(px)\": f\"{ext[sel, 0].mean():.1f}/{ext[sel, 1].mean():.1f}\"})\n", + "print(pd.DataFrame(rows).to_string(index=False))\n", + "\n", + "# 3) 단위 테스트 5종\n", + "raw_len = np.array([extent(s / 1.5)[0] for s in S])\n", + "tests = {\n", + " \"출력 크기 32x32(광학·SAR)\": all(p[0].shape == (N, N) and p[1].shape == (N, N) for p in out),\n", + " \"값 범위 0~1, 결측 0\": all(p[0].min() >= 0 and p[0].max() <= 1 and not np.isnan(p[0]).any() for p in out),\n", + " \"구름 조건 밝은 픽셀 50% 이상 감소\": after_cloud[sc == \"cloud\"].mean() < 0.5 * before_cloud[sc == \"cloud\"].mean(),\n", + " \"맑은 조건 방위 정규화 90% 이상\": horiz[sc == \"clean\"].mean() >= 0.9,\n", + " \"저해상도 객체 길이 표준화(맑은 조건 대비 30% 이내)\": abs(ext[sc == \"low_res\", 0].mean() / ext[sc == \"clean\", 0].mean() - 1) < 0.3,\n", + "}\n", + "for k, v in tests.items():\n", + " print(f\"[{'PASS' if v else 'FAIL'}] {k}\")\n", + "print(f\"단위 테스트 {sum(tests.values())}/5 통과\")\n", + "print(f\"참고: 저해상도 원본의 객체 길이 {raw_len[sc == 'low_res'].mean():.1f}px -> 보정 후 {ext[sc == 'low_res', 0].mean():.1f}px\")\n", + "\n", + "# 4) 전후 비교 그림\n", + "fig, ax = plt.subplots(2, 5, figsize=(14, 6))\n", + "for j, k in enumerate([\"clean\", \"cloud\", \"off_nadir\", \"low_res\", \"camo\"]):\n", + " i = int(np.where(sc == k)[0][0])\n", + " ax[0, j].imshow(np.nan_to_num(O[i], nan=0), cmap=\"gray\", vmin=0, vmax=255); ax[0, j].set_title(f\"{k} raw ({M[i]['cls']})\")\n", + " ax[1, j].imshow(out[i][0], cmap=\"gray\", vmin=0, vmax=1); ax[1, j].set_title(\"preprocessed\")\n", + "for a in ax.ravel():\n", + " a.axis(\"off\")\n", + "plt.suptitle(\"Synthetic satellite chips: before / after preprocessing (training data)\"); plt.tight_layout(); plt.show()\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 02 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + "전처리 400장, 1,395장/초 (목표 1,000건/초 이상), 처리 실패 0건, 남은 결측 0픽셀 -> 오류율 0%\n", + "결측 주입 20장 결측치 보정율 100%\n", + " 조건 밝은 구름 픽셀(전) (후) 방위 정규화 성공 객체 길이/폭(px)\n", + " clean 0.2% 0.1% 98% 14.8/8.1\n", + " camo 0.4% 0.3% 100% 14.8/8.1\n", + " occl30 0.2% 0.1% 100% 14.8/8.0\n", + " occl50 0.1% 0.1% 100% 14.7/8.1\n", + " cloud 25.1% 2.1% 100% 14.7/8.2\n", + " low_sun 0.3% 0.2% 95% 14.4/8.5\n", + "off_nadir 0.3% 0.2% 100% 14.7/8.1\n", + " low_res 0.0% 0.0% 95% 14.2/9.3\n", + " speckle 0.3% 0.3% 100% 15.4/8.9\n", + "camo_occl 0.1% 0.0% 98% 14.6/8.2\n", + "[PASS] 출력 크기 32x32(광학·SAR)\n", + "[PASS] 값 범위 0~1, 결측 0\n", + "[PASS] 구름 조건 밝은 픽셀 50% 이상 감소\n", + "[PASS] 맑은 조건 방위 정규화 90% 이상\n", + "[PASS] 저해상도 객체 길이 표준화(맑은 조건 대비 30% 이내)\n", + "단위 테스트 5/5 통과\n", + "참고: 저해상도 원본의 객체 길이 5.6px -> 보정 후 14.2px\n", + "```\n", + "
\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 03. 위성영상 내 기갑/포병 장비 객체 탐지 Baseline CNN/Vision 모델 구현\n", + "**소요시간**: 약 2.5시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "위성영상 내 주요 군용 장비 및 차량 객체를 1차 식별하는 Baseline 합성곱 신경망(CNN) 기반 분류 모델을 구현한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **위성영상 속 주요 군용 장비와 차량을 1차 식별하는 Baseline 합성곱(CNN) 방식 분류 모델을 구현한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **Sobel·Laplacian·대각선·평균 필터로 이루어진 합성곱 필터 뱅크, ReLU, 최대 풀링으로 간이 CNN 특징을 만들고 로지스틱 회귀 분류기를 학습 수행하고 검증하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `src/models/isr01_baseline_model.py 및 기본 추론 결과 보고서` 형태의 결과물을 완성한다.\n", + "\n", + "### 🔍 평가 및 검증 기준 (PASS 조건)\n", + "#### 미션 판정\n", + "\n", + "**평가 구분:** 필수\n", + "\n", + "**핵심 평가영역:** 알고리즘의 논리적 흐름 및 군사적 판단 기준의 투명성\n", + "\n", + "#### 미션 PASS 조건\n", + "\n", + "- 정량 기준: 기본 데이터셋 대상 탐지/처리 정확도 75% 이상 달성\n", + "\n", + "\n", + "- 진행 기준: 단위 기능 동작 확인 및 기본 시나리오 실행 로그 생성\n", + "\n", + "\n", + "- 검증 방법: 기준 벤치마크 데이터셋 대상 추론 결과 오차율 비교\n", + "\n", + "\n", + "#### 채점자 판정 메모\n", + "\n", + "> 단순 코드 실행 여부뿐만 아니라 출력된 수치와 근거 자료가 군 실무 요구사항에 부합하는지 면밀히 확인한다.\n", + "\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "\"\"\"위성영상 조각에서 전차·자주포·트럭·민간 차량을 1차 식별하는 Baseline 분류 모델을 구현한다. 합성곱 필터 뱅크(Sobel·Laplacian·대각선·평균) -> ReLU -> 최대 풀링으로 만든 간이 CNN 특징에 로지스틱 회귀 분류기를 붙이고, 정확도·혼동행렬·판단 지연시간을 측정한다.\"\"\"\n", + "import time\n", + "import numpy as np\n", + "import pandas as pd\n", + "import matplotlib.pyplot as plt\n", + "from scipy import ndimage\n", + "from sklearn.linear_model import LogisticRegression\n", + "from sklearn.metrics import confusion_matrix\n", + "from sklearn.pipeline import make_pipeline\n", + "from sklearn.preprocessing import StandardScaler\n", + "\n", + "# ---------------------------------------------------------------------------\n", + "# [교육용가상자료] 합성 위성영상 조각(chip) 생성기 (실제 위성영상·장비 제원·위치 없음)\n", + "# 32x32 광학(EO) 영상과 SAR 영상 한 쌍에 추상 도형으로 만든 객체를 그린다. 도형 치수는 가상값이다.\n", + "# ---------------------------------------------------------------------------\n", + "N = 32\n", + "STD_GSD = 0.5 # 표준 해상도(m/픽셀, 가상 기준)\n", + "CLASSES = [\"tank\", \"spg\", \"truck\", \"civil\"]\n", + "MILITARY = {\"tank\", \"spg\", \"truck\"}\n", + "# 객체 좌표계(u: 길이 방향, v: 폭 방향, 표준 해상도 픽셀) 가상 도형: (u중심, v중심, 길이, 폭, 광학 밝기 차이)\n", + "SHAPES = {\n", + " \"tank\": [(0, 0, 11, 7, -40), (0.5, 0, 5, 5, -55), (6, 0, 7, 1.2, -60)],\n", + " \"spg\": [(0, 0, 16, 7.5, -38), (-4, 0, 6, 6.5, -52), (5, 0, 13, 1.4, -60)],\n", + " \"truck\": [(5, 0, 3.5, 5.5, -45), (-1.8, 0, 8.5, 6, -25)],\n", + " \"civil\": [(0, 0, 8, 3.8, 0), (1.2, 0, 2, 3.4, 0)],\n", + " \"decoy\": [(0, 0, 11, 7, -40), (0.5, 0, 5, 5, -42), (6, 0, 7, 1.2, -45)],\n", + " \"none\": [], # 객체 없는 배경 타일\n", + "}\n", + "SAR_RETURN = {\"tank\": 0.85, \"spg\": 0.85, \"truck\": 0.7, \"civil\": 0.45, \"decoy\": 0.18, \"none\": 0.12} # 금속 반사 세기(가상)\n", + "\n", + "\n", + "def _mask(cls, angle, scale, squash=1.0):\n", + " \"\"\"회전(angle, 도)·축척(scale)·비스듬 촬영 압축(squash)을 적용해 도형별 마스크와 밝기 차이를 만든다.\"\"\"\n", + " yy, xx = np.mgrid[0:N, 0:N] - (N - 1) / 2\n", + " yy = yy / squash\n", + " a = np.radians(angle)\n", + " u = (xx * np.cos(a) + yy * np.sin(a)) / scale\n", + " v = (-xx * np.sin(a) + yy * np.cos(a)) / scale\n", + " return [((np.abs(u - cu) <= L / 2) & (np.abs(v - cv) <= W / 2), d) for cu, cv, L, W, d in SHAPES[cls]]\n", + "\n", + "\n", + "def make_chip(cls, rng, cond=None):\n", + " \"\"\"chip 1개 = (광학 32x32, SAR 32x32, 메타데이터). cond: camo, occl, cloud, low_sun, off_nadir, low_res, speckle\"\"\"\n", + " c = {\"camo\": 0.0, \"occl\": 0.0, \"cloud\": 0.0, \"low_sun\": False, \"off_nadir\": 0.0, \"low_res\": False, \"speckle\": 1.0, **(cond or {})}\n", + " gsd = rng.uniform(0.9, 1.2) if c[\"low_res\"] else rng.uniform(0.4, 0.6)\n", + " scale = STD_GSD / gsd\n", + " angle, off = rng.uniform(0, 360), c[\"off_nadir\"] or rng.uniform(0, 15)\n", + " squash = np.cos(np.radians(off))\n", + " sun_el = rng.uniform(12, 22) if c[\"low_sun\"] else rng.uniform(40, 70)\n", + " sun_az = rng.uniform(0, 360)\n", + " bg = 115 + ndimage.gaussian_filter(rng.normal(0, 30, (N, N)), 2) + rng.normal(0, 5, (N, N))\n", + " parts = _mask(cls, angle, scale, squash)\n", + " body = np.any([m for m, _ in parts], axis=0) if parts else np.zeros((N, N), bool)\n", + " opt = bg.copy()\n", + " # 그림자: 태양 반대 방향으로 밀린 객체 모양(태양 고도가 낮을수록 길다)\n", + " L = min(8.0, 2.5 / np.tan(np.radians(sun_el))) * scale\n", + " sh = ndimage.shift(body.astype(float), (L * np.cos(np.radians(sun_az)), -L * np.sin(np.radians(sun_az))), order=0) > 0.5\n", + " opt[sh & ~body] -= 35 if cls != \"decoy\" else 25\n", + " if cls == \"civil\":\n", + " base = rng.choice([200.0, 60.0]) # 민간 차량 색상: 밝거나 어두움\n", + " opt[body] = base + rng.normal(0, 8, body.sum())\n", + " roof = parts[1][0]\n", + " opt[roof] = base * 0.8\n", + " else:\n", + " ref = bg[body].mean() if body.any() else bg.mean()\n", + " for m, d in parts:\n", + " opt[m] = ref + d + rng.normal(0, 4 if cls != \"decoy\" else 2, m.sum())\n", + " # 위장막: 객체 주변을 배경 질감으로 덮어 대비를 낮춘다\n", + " if c[\"camo\"] > 0 and cls != \"civil\":\n", + " net = ndimage.binary_dilation(body, iterations=2)\n", + " tex = 105 + ndimage.gaussian_filter(rng.normal(0, 25, (N, N)), 1)\n", + " opt[net] = (1 - c[\"camo\"]) * opt[net] + c[\"camo\"] * tex[net]\n", + " # 수목 차폐: 객체의 일부를 어두운 나무 덩어리로 가린다\n", + " if c[\"occl\"] > 0:\n", + " trees = ndimage.gaussian_filter(rng.random((N, N)), 2)\n", + " near = ndimage.binary_dilation(body, iterations=3)\n", + " cover = near & (trees > np.quantile(trees[body], 1 - c[\"occl\"])) if body.any() else np.zeros((N, N), bool)\n", + " opt[cover] = 70 + rng.normal(0, 6, cover.sum())\n", + " # 구름: 밝고 매끈한 덩어리(광학만 가림, SAR는 통과)\n", + " cmask = np.zeros((N, N), bool)\n", + " if c[\"cloud\"] > 0:\n", + " blob = ndimage.gaussian_filter(rng.random((N, N)), 4)\n", + " cmask = blob > np.quantile(blob, 1 - c[\"cloud\"])\n", + " opt[cmask] = 0.25 * opt[cmask] + 0.75 * (235 + rng.normal(0, 3, cmask.sum()))\n", + " # SAR: 곱셈형 스페클 잡음 + 금속 반사(모서리 이중 반사로 밝은 점)\n", + " sar = np.full((N, N), 0.12)\n", + " sar[body] = SAR_RETURN[cls] * rng.uniform(0.8, 1.2)\n", + " edge = body & ~ndimage.binary_erosion(body)\n", + " sar[edge] *= 1.3 if cls in MILITARY else 1.0\n", + " sar = sar * rng.gamma(4 / c[\"speckle\"], c[\"speckle\"] / 4, (N, N))\n", + " if c[\"low_res\"]: # 저해상도: 정보 손실(블러)\n", + " down = lambda a: np.kron(a.reshape(N // 2, 2, N // 2, 2).mean((1, 3)), np.ones((2, 2)))\n", + " opt, sar = ndimage.gaussian_filter(down(opt), 0.7), ndimage.gaussian_filter(down(sar), 0.7)\n", + " meta = {\"cls\": cls, \"gsd\": round(float(gsd), 2), \"off_nadir\": round(float(off), 1), \"sun_el\": round(float(sun_el), 1),\n", + " \"sun_az\": round(float(sun_az), 1), \"angle\": round(float(angle), 1), \"cloud_frac\": float(cmask.mean()),\n", + " \"cloud_on_obj\": float((cmask & body).sum() / max(body.sum(), 1)), **{k: c[k] for k in (\"camo\", \"occl\", \"low_sun\", \"low_res\")}}\n", + " return np.clip(opt, 0, 255), sar, meta\n", + "\n", + "\n", + "SCEN = { # 은폐·촬영 조건 시나리오 10종\n", + " \"clean\": {}, \"camo\": {\"camo\": 0.7}, \"occl30\": {\"occl\": 0.3}, \"occl50\": {\"occl\": 0.5}, \"cloud\": {\"cloud\": 0.25},\n", + " \"low_sun\": {\"low_sun\": True}, \"off_nadir\": {\"off_nadir\": 35.0}, \"low_res\": {\"low_res\": True},\n", + " \"speckle\": {\"speckle\": 3.0}, \"camo_occl\": {\"camo\": 0.6, \"occl\": 0.3}}\n", + "\n", + "\n", + "def make_dataset(n=400, seed=7, scen=(\"clean\",), classes=CLASSES):\n", + " rng = np.random.default_rng(seed)\n", + " O, S, M = np.zeros((n, N, N)), np.zeros((n, N, N)), []\n", + " for i in range(n):\n", + " cls, sc = classes[i % len(classes)], scen[(i // len(classes)) % len(scen)]\n", + " O[i], S[i], m = make_chip(cls, rng, SCEN[sc])\n", + " m.update({\"id\": f\"IMG{i:04d}\", \"scen\": sc})\n", + " M.append(m)\n", + " return O, S, M\n", + "# ---------------------------------------------------------------------------\n", + "\n", + "# ---- 전처리(해상도·기하 보정, 구름 차폐 처리, 방위 정규화) 공통 함수 ----\n", + "def _fit(a, fill):\n", + " \"\"\"중앙 기준으로 32x32에 맞게 자르거나 채운다.\"\"\"\n", + " out = np.full((N, N), fill, float)\n", + " h, w = a.shape\n", + " ys, xs = max(0, (h - N) // 2), max(0, (w - N) // 2)\n", + " yd, xd = max(0, (N - h) // 2), max(0, (N - w) // 2)\n", + " hh, ww = min(N, h), min(N, w)\n", + " out[yd:yd + hh, xd:xd + ww] = a[ys:ys + hh, xs:xs + ww]\n", + " return out\n", + "\n", + "\n", + "def cloud_mask(opt, sm, min_area=15):\n", + " \"\"\"광학에서 밝고(185 초과) 매끈한데 SAR에는 반사가 없는 영역을 구름으로 본다(SAR는 구름을 통과하므로 밝은 차량과 구분된다).\"\"\"\n", + " bright = opt > 185\n", + " if bright.sum() < min_area:\n", + " return np.zeros_like(bright)\n", + " local_sd = np.sqrt(np.maximum(ndimage.uniform_filter(opt ** 2, 3) - ndimage.uniform_filter(opt, 3) ** 2, 0))\n", + " cand = bright & (local_sd < 12) & (sm < 0.3)\n", + " lab, n = ndimage.label(cand)\n", + " if n == 0:\n", + " return cand\n", + " sizes = ndimage.sum(cand.astype(float), lab, range(1, n + 1))\n", + " return ndimage.binary_dilation(np.isin(lab, 1 + np.where(sizes >= min_area)[0]), iterations=2) & (opt > 140)\n", + "\n", + "\n", + "def orientation(opt, sm):\n", + " \"\"\"SAR 반사(위장·구름 영향 적음, sm은 평활화한 SAR)와 광학 대비를 합친 객체 마스크의 주축 방향(도).\"\"\"\n", + " m = (sm > 0.3) | (np.abs(opt - opt.mean()) > 30)\n", + " lab, n = ndimage.label(m)\n", + " if n == 0:\n", + " return 0.0, m\n", + " c = lab[N // 2 - 6:N // 2 + 6, N // 2 - 6:N // 2 + 6]\n", + " k = np.bincount(c[c > 0]).argmax() if (c > 0).any() else 1 + int(np.argmax(ndimage.sum(m.astype(float), lab, range(1, n + 1))))\n", + " m = lab == k\n", + " yy, xx = np.nonzero(m)\n", + " if len(yy) < 5:\n", + " return 0.0, m\n", + " dy, dx = yy - yy.mean(), xx - xx.mean() # 2차 모멘트로 주축 각도 계산\n", + " return float(np.degrees(0.5 * np.arctan2(2 * (dx * dy).mean(), (dx * dx).mean() - (dy * dy).mean()))), m\n", + "\n", + "\n", + "_YY, _XX = np.mgrid[0:N, 0:N] - (N - 1) / 2\n", + "\n", + "\n", + "def warp(stack, zy, zx, ang):\n", + " \"\"\"여러 장(B,채널,32,32)에 장별 확대·축소(zy, zx)와 회전(ang, 도)을 한 번의 쌍선형 보간으로 적용한다(벡터 연산).\"\"\"\n", + " a = np.radians(ang)[:, None, None]\n", + " zy, zx = np.asarray(zy)[:, None, None], np.asarray(zx)[:, None, None]\n", + " sy = np.clip((np.cos(a) * _YY + np.sin(a) * _XX) / zy + (N - 1) / 2, 0, N - 1.001) # 출력 좌표 -> 원본 좌표\n", + " sx = np.clip((-np.sin(a) * _YY + np.cos(a) * _XX) / zx + (N - 1) / 2, 0, N - 1.001)\n", + " y0, x0 = sy.astype(int), sx.astype(int)\n", + " fy, fx = (sy - y0).reshape(len(stack), 1, -1), (sx - x0).reshape(len(stack), 1, -1)\n", + " flat = stack.reshape(len(stack), stack.shape[1], -1)\n", + " lin = (y0 * N + x0).reshape(len(stack), 1, -1)\n", + " g = lambda d: np.take_along_axis(flat, np.broadcast_to(lin + d, (len(stack), stack.shape[1], lin.shape[2])), axis=2)\n", + " out = g(0) * (1 - fy) * (1 - fx) + g(N) * fy * (1 - fx) + g(1) * (1 - fy) * fx + g(N + 1) * fy * fx\n", + " return out.reshape(stack.shape)\n", + "\n", + "\n", + "def preprocess_batch(O, S, M):\n", + " \"\"\"여러 장을 한 번에 전처리한다. 0) 결측 보정 1) 해상도·비스듬 촬영 보정(표준 0.5m/픽셀)\n", + " 2) 구름 차폐 영역을 배경값으로 채움 3) 객체 주축을 가로로 회전 4) 0~1 정규화.\"\"\"\n", + " src = np.stack([np.asarray(O, float), np.asarray(S, float)], axis=1)\n", + " if np.isnan(src).any(): # 0) 센서 결측 픽셀 보정\n", + " mean = np.nanmean(src, axis=(2, 3), keepdims=True)\n", + " src = np.where(np.isnan(src), mean, src)\n", + " zy = np.array([m[\"gsd\"] / STD_GSD / np.cos(np.radians(m[\"off_nadir\"])) for m in M])\n", + " zx = np.array([m[\"gsd\"] / STD_GSD for m in M])\n", + " out = warp(src, zy, zx, np.zeros(len(M)))\n", + " sm = ndimage.uniform_filter(out[:, 1], (1, 3, 3))\n", + " angs, cloud = np.zeros(len(M)), np.zeros(len(M))\n", + " for i in range(len(M)):\n", + " cm = cloud_mask(out[i, 0], sm[i])\n", + " if cm.any():\n", + " fill = out[i, 0][~cm].mean() if (~cm).any() else 115.0\n", + " out[i, 0][cm] = fill\n", + " back = warp(cm[None, None].astype(float), [1 / zy[i]], [1 / zx[i]], np.zeros(1))[0, 0] > 0.5\n", + " src[i, 0][back] = fill # 원본의 같은 구름 영역도 채움\n", + " cloud[i] = cm.mean()\n", + " angs[i] = orientation(out[i, 0], sm[i])[0]\n", + " rot = np.abs(angs) > 3 # 3도 이하는 회전 생략\n", + " if rot.any():\n", + " out[rot] = warp(src[rot], zy[rot], zx[rot], angs[rot])\n", + " return [(np.clip(o / 255, 0, 1), np.clip(s / 1.5, 0, 1), float(c)) for (o, s), c in zip(out, cloud)]\n", + "\n", + "\n", + "def preprocess(opt, sar, meta):\n", + " \"\"\"한 장 전처리(preprocess_batch와 같은 처리).\"\"\"\n", + " return preprocess_batch([opt], [sar], [meta])[0]\n", + "\n", + "# ---- 특징 추출 공통 함수 ----\n", + "KERNELS = [np.array(k, float) for k in (\n", + " [[-1, 0, 1], [-2, 0, 2], [-1, 0, 1]], [[-1, -2, -1], [0, 0, 0], [1, 2, 1]], [[0, 1, 0], [1, -4, 1], [0, 1, 0]],\n", + " [[2, -1, -1], [-1, 2, -1], [-1, -1, 2]], [[-1, -1, 2], [-1, 2, -1], [2, -1, -1]], [[1, 1, 1], [1, 1, 1], [1, 1, 1]])]\n", + "\n", + "\n", + "def conv_features(o):\n", + " \"\"\"간이 CNN 특징: 고정 합성곱 필터 6종 -> ReLU -> 4x4 최대 풀링(8x8 출력).\"\"\"\n", + " feats = []\n", + " for k in KERNELS:\n", + " r = np.maximum(ndimage.correlate(o - o.mean(), k / np.abs(k).sum()), 0)\n", + " feats.append(r.reshape(8, 4, 8, 4).max(axis=(1, 3)).ravel())\n", + " feats.append(np.maximum(-ndimage.correlate(o - o.mean(), k / np.abs(k).sum()), 0).reshape(8, 4, 8, 4).max(axis=(1, 3)).ravel())\n", + " return np.concatenate(feats)\n", + "\n", + "\n", + "def hog(o, cell=8, bins=8):\n", + " gy, gx = np.gradient(o)\n", + " mag, ang = np.hypot(gx, gy), np.mod(np.arctan2(gy, gx), np.pi)\n", + " b = np.minimum((ang / np.pi * bins).astype(int), bins - 1)\n", + " out = np.zeros((N // cell, N // cell, bins))\n", + " for i in range(N // cell):\n", + " for j in range(N // cell):\n", + " sl = (slice(i * cell, (i + 1) * cell), slice(j * cell, (j + 1) * cell))\n", + " out[i, j] = np.bincount(b[sl].ravel(), mag[sl].ravel(), bins)\n", + " return (out / (np.linalg.norm(out) + 1e-6)).ravel()\n", + "\n", + "\n", + "def shape_features(o, s):\n", + " \"\"\"객체 모양 특징: 가로 방향 투영으로 전체 길이·차체 길이·가는 돌출부(포신) 길이·두꺼운 부분(포탑) 위치를 잰다.\n", + " 방위 정규화 뒤에도 앞뒤(180도)가 뒤집힐 수 있으므로, 가는 돌출부가 오른쪽에 오도록 맞춘다.\"\"\"\n", + " sm = ndimage.uniform_filter(s, 3)\n", + " dark = np.abs(o - np.median(o))\n", + " m = (sm > 0.22) | (dark > 0.12)\n", + " lab, n = ndimage.label(m)\n", + " if n:\n", + " c = lab[10:22, 8:24]\n", + " k = np.bincount(c[c > 0]).argmax() if (c > 0).any() else 1\n", + " m = lab == k\n", + " p = m.sum(axis=0).astype(float)\n", + " cols = np.nonzero(p)[0]\n", + " if len(cols) == 0:\n", + " return np.zeros(8), o, s\n", + " thin = (p > 0) & (p <= 2)\n", + " left, right = thin[:N // 2].sum(), thin[N // 2:].sum()\n", + " if left > right:\n", + " o, s, m, p = o[:, ::-1], s[:, ::-1], m[:, ::-1], p[::-1]\n", + " cols = np.nonzero(p)[0]\n", + " body = np.nonzero(p > 3)[0]\n", + " d = np.where(m, dark if left <= right else dark[:, ::-1], 0)\n", + " heavy = d.sum(axis=0)\n", + " centre = (body[0] + body[-1]) / 2 if len(body) else N / 2\n", + " turret = (heavy * np.arange(N)).sum() / max(heavy.sum(), 1e-6) - centre\n", + " return np.array([cols[-1] - cols[0] + 1, len(body), ((p > 0) & (p <= 2)).sum(), p.max(), turret,\n", + " m.sum(), (cols[-1] - (body[-1] if len(body) else cols[-1])), m.sum() / max(len(cols) * p.max(), 1)]), o, s\n", + "\n", + "\n", + "def adv_features(o, s):\n", + " \"\"\"정밀 특징: 모양(길이·포신·포탑 위치) + HOG(윤곽) + SAR 반사 통계(위장막·구름 영향 적음) + 투영 윤곽.\"\"\"\n", + " shp, o, s = shape_features(o, s)\n", + " c = s[8:24, 4:28]\n", + " sm = ndimage.uniform_filter(s, 3)\n", + " m = sm > 0.2\n", + " dark = np.abs(o - np.median(o))\n", + " return np.concatenate([shp, hog(o), [c.mean(), c.max(), (sm > 0.25).mean(), (sm > 0.4).mean(), m.sum(axis=1).max(), m.sum(axis=0).max()],\n", + " sm.mean(axis=0).reshape(8, 4).mean(1), sm.mean(axis=1).reshape(8, 4).mean(1),\n", + " dark.mean(axis=0).reshape(8, 4).mean(1), dark.mean(axis=1).reshape(8, 4).mean(1),\n", + " [o.mean(), o.std(), dark.max(), (o > 0.65).mean(), (o < 0.3).mean()]])\n", + "\n", + "\n", + "def features(O, S, M):\n", + " return np.array([conv_features(p[0]) for p in preprocess_batch(O, S, M)])\n", + "\n", + "\n", + "# 1) 기본 데이터셋: 은폐 없는 영상(학습 800장: 맑은 조건 / 시험 400장: 맑은 조건 + 낮은 태양고도 + 비스듬 촬영)\n", + "Otr, Str, Mtr = make_dataset(800, seed=1)\n", + "Ote, Ste, Mte = make_dataset(400, seed=2, scen=(\"clean\", \"clean\", \"low_sun\", \"off_nadir\"))\n", + "Xtr, ytr = features(Otr, Str, Mtr), [m[\"cls\"] for m in Mtr]\n", + "t0 = time.perf_counter()\n", + "Xte = features(Ote, Ste, Mte)\n", + "yte = np.array([m[\"cls\"] for m in Mte])\n", + "\n", + "# 2) 규제 강도(C) 1차 튜닝: 학습 자료의 일부(검증용 200장)로 선택\n", + "best_c, best = None, -1\n", + "for c in [0.01, 0.05, 0.2, 1.0]:\n", + " mdl = make_pipeline(StandardScaler(), LogisticRegression(C=c, max_iter=3000)).fit(Xtr[:600], ytr[:600])\n", + " acc = mdl.score(Xtr[600:], ytr[600:])\n", + " print(f\"C={c:<5} 검증 정확도 {acc:.3f}\")\n", + " if acc > best:\n", + " best_c, best = c, acc\n", + "model = make_pipeline(StandardScaler(), LogisticRegression(C=best_c, max_iter=3000)).fit(Xtr, ytr)\n", + "pred = model.predict(Xte)\n", + "lat = (time.perf_counter() - t0) / len(Xte) * 1000\n", + "acc = (pred == yte).mean()\n", + "print(f\"\\n선택 C={best_c}, 기본 시험셋 정확도 {acc:.1%} (목표 75% 이상: {'달성' if acc >= 0.75 else '미달'}), 1장당 판단 {lat:.2f} ms(전처리 포함)\")\n", + "\n", + "print(\"\\n[혼동행렬] 행=실제, 열=예측\")\n", + "print(pd.DataFrame(confusion_matrix(yte, pred, labels=CLASSES), index=CLASSES, columns=CLASSES).to_string())\n", + "mil, pm = np.isin(yte, list(MILITARY)), np.isin(pred, list(MILITARY))\n", + "print(f\"군용 장비 여부 판정 정확도 {(mil == pm).mean():.1%}, 클래스별 재현율:\",\n", + " {c: round(float((pred[yte == c] == c).mean()), 2) for c in CLASSES})\n", + "\n", + "# 3) 한계: 위장막·수목 차폐·저해상도 등 은폐 조건에서는 정확도가 떨어진다(미션 4의 동기)\n", + "Oh, Sh, Mh = make_dataset(400, seed=3, scen=(\"camo\", \"occl50\", \"low_res\", \"camo_occl\"))\n", + "ph = model.predict(features(Oh, Sh, Mh))\n", + "yh, sh = np.array([m[\"cls\"] for m in Mh]), np.array([m[\"scen\"] for m in Mh])\n", + "print(\"[은폐 조건 정확도]\", {k: round(float((ph[sh == k] == yh[sh == k]).mean()), 2) for k in (\"camo\", \"occl50\", \"low_res\", \"camo_occl\")})\n", + "print(\"판단 근거: 필터 반응(윤곽·포신 같은 가는 선)의 위치 분포. 위장막이 윤곽을 지우면 근거가 사라진다.\")\n", + "\n", + "# 4) 그림: 클래스별 예시와 필터 반응\n", + "fig, ax = plt.subplots(2, 4, figsize=(12, 6))\n", + "for j, c in enumerate(CLASSES):\n", + " i = int(np.where(yte == c)[0][0])\n", + " o = preprocess(Ote[i], Ste[i], Mte[i])[0]\n", + " ax[0, j].imshow(o, cmap=\"gray\"); ax[0, j].set_title(f\"{c} -> pred {pred[i]}\")\n", + " ax[1, j].imshow(np.maximum(ndimage.correlate(o - o.mean(), KERNELS[0] / 8), 0), cmap=\"magma\"); ax[1, j].set_title(\"Sobel-x response\")\n", + "for a in ax.ravel():\n", + " a.axis(\"off\")\n", + "plt.suptitle(\"Baseline conv-feature classifier (synthetic chips)\"); plt.tight_layout(); plt.show()\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 03 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + "C=0.01 검증 정확도 1.000\n", + "C=0.05 검증 정확도 1.000\n", + "C=0.2 검증 정확도 1.000\n", + "C=1.0 검증 정확도 1.000\n", + "\n", + "선택 C=0.01, 기본 시험셋 정확도 96.5% (목표 75% 이상: 달성), 1장당 판단 5.38 ms(전처리 포함)\n", + "\n", + "[혼동행렬] 행=실제, 열=예측\n", + " tank spg truck civil\n", + "tank 91 8 1 0\n", + "spg 1 99 0 0\n", + "truck 1 3 96 0\n", + "civil 0 0 0 100\n", + "군용 장비 여부 판정 정확도 100.0%, 클래스별 재현율: {'tank': 0.91, 'spg': 0.99, 'truck': 0.96, 'civil': 1.0}\n", + "[은폐 조건 정확도] {'camo': 0.5, 'occl50': 0.52, 'low_res': 0.25, 'camo_occl': 0.61}\n", + "판단 근거: 필터 반응(윤곽·포신 같은 가는 선)의 위치 분포. 위장막이 윤곽을 지우면 근거가 사라진다.\n", + "```\n", + "
\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 04. 위장막/차폐 환경 하 군용 장비 미세 특징 추출 및 정밀 객체분류 모델 고도화\n", + "**소요시간**: 약 3.0시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "위장막 설치, 수목 차폐, 부분 은폐 상태의 군용 장비 미세 특징을 추출하여 객체분류 정확도를 극대화하는 정밀 모델을 고도화한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **위장막 설치, 수목 차폐, 부분 은폐 상태의 장비에서도 기종을 식별하도록 미세 특징을 추출하는 정밀 분류 모델로 고도화한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **객체 모양(전체 길이·차체 길이·포신 돌출·포탑 위치), 윤곽 방향 히스토그램(HOG), 위장막과 구름에 강한 SAR 반사 통계, 투영 윤곽 특징을 결합하고 은폐 조건 10종을 섞은 증강 자료로 랜덤 포레스트를 학습 수행하고 검증하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `src/models/isr01_advanced_detector.py 및 예외 탐지 테스트 결과` 형태의 결과물을 완성한다.\n", + "\n", + "### 🔍 평가 및 검증 기준 (PASS 조건)\n", + "#### 미션 판정\n", + "\n", + "**평가 구분:** 필수\n", + "\n", + "**핵심 평가영역:** 기만/교란 공격에 대한 방어 로직의 현실성 및 신뢰성\n", + "\n", + "#### 미션 PASS 조건\n", + "\n", + "- 정량 기준: 복합 위협 탐지 재현율(Recall) 85% 이상, 처리 지연 200ms 이하\n", + "\n", + "\n", + "- 진행 기준: 복합 위협 시나리오 10종에 대한 탐지 테스트 통과\n", + "\n", + "\n", + "- 검증 방법: 합성 위협 데이터셋 주입 시 이상치 탐지 정확도 검증\n", + "\n", + "\n", + "#### 채점자 판정 메모\n", + "\n", + "> 단순 코드 실행 여부뿐만 아니라 출력된 수치와 근거 자료가 군 실무 요구사항에 부합하는지 면밀히 확인한다.\n", + "\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "\"\"\"위장막·수목 차폐·구름·저해상도·SAR 잡음 등 은폐 조건 10종에서 군용 장비를 놓치지 않도록, 윤곽(HOG)·SAR 반사 통계·투영 윤곽 특징과 은폐 조건 증강 학습을 결합한 정밀 객체분류 모델(랜덤 포레스트)로 고도화하고 조건별 재현율과 처리 지연을 측정한다.\"\"\"\n", + "import time\n", + "import numpy as np\n", + "import pandas as pd\n", + "import matplotlib.pyplot as plt\n", + "from scipy import ndimage\n", + "from sklearn.ensemble import RandomForestClassifier\n", + "from sklearn.linear_model import LogisticRegression\n", + "from sklearn.pipeline import make_pipeline\n", + "from sklearn.preprocessing import StandardScaler\n", + "\n", + "# ---------------------------------------------------------------------------\n", + "# [교육용가상자료] 합성 위성영상 조각(chip) 생성기 (실제 위성영상·장비 제원·위치 없음)\n", + "# 32x32 광학(EO) 영상과 SAR 영상 한 쌍에 추상 도형으로 만든 객체를 그린다. 도형 치수는 가상값이다.\n", + "# ---------------------------------------------------------------------------\n", + "N = 32\n", + "STD_GSD = 0.5 # 표준 해상도(m/픽셀, 가상 기준)\n", + "CLASSES = [\"tank\", \"spg\", \"truck\", \"civil\"]\n", + "MILITARY = {\"tank\", \"spg\", \"truck\"}\n", + "# 객체 좌표계(u: 길이 방향, v: 폭 방향, 표준 해상도 픽셀) 가상 도형: (u중심, v중심, 길이, 폭, 광학 밝기 차이)\n", + "SHAPES = {\n", + " \"tank\": [(0, 0, 11, 7, -40), (0.5, 0, 5, 5, -55), (6, 0, 7, 1.2, -60)],\n", + " \"spg\": [(0, 0, 16, 7.5, -38), (-4, 0, 6, 6.5, -52), (5, 0, 13, 1.4, -60)],\n", + " \"truck\": [(5, 0, 3.5, 5.5, -45), (-1.8, 0, 8.5, 6, -25)],\n", + " \"civil\": [(0, 0, 8, 3.8, 0), (1.2, 0, 2, 3.4, 0)],\n", + " \"decoy\": [(0, 0, 11, 7, -40), (0.5, 0, 5, 5, -42), (6, 0, 7, 1.2, -45)],\n", + " \"none\": [], # 객체 없는 배경 타일\n", + "}\n", + "SAR_RETURN = {\"tank\": 0.85, \"spg\": 0.85, \"truck\": 0.7, \"civil\": 0.45, \"decoy\": 0.18, \"none\": 0.12} # 금속 반사 세기(가상)\n", + "\n", + "\n", + "def _mask(cls, angle, scale, squash=1.0):\n", + " \"\"\"회전(angle, 도)·축척(scale)·비스듬 촬영 압축(squash)을 적용해 도형별 마스크와 밝기 차이를 만든다.\"\"\"\n", + " yy, xx = np.mgrid[0:N, 0:N] - (N - 1) / 2\n", + " yy = yy / squash\n", + " a = np.radians(angle)\n", + " u = (xx * np.cos(a) + yy * np.sin(a)) / scale\n", + " v = (-xx * np.sin(a) + yy * np.cos(a)) / scale\n", + " return [((np.abs(u - cu) <= L / 2) & (np.abs(v - cv) <= W / 2), d) for cu, cv, L, W, d in SHAPES[cls]]\n", + "\n", + "\n", + "def make_chip(cls, rng, cond=None):\n", + " \"\"\"chip 1개 = (광학 32x32, SAR 32x32, 메타데이터). cond: camo, occl, cloud, low_sun, off_nadir, low_res, speckle\"\"\"\n", + " c = {\"camo\": 0.0, \"occl\": 0.0, \"cloud\": 0.0, \"low_sun\": False, \"off_nadir\": 0.0, \"low_res\": False, \"speckle\": 1.0, **(cond or {})}\n", + " gsd = rng.uniform(0.9, 1.2) if c[\"low_res\"] else rng.uniform(0.4, 0.6)\n", + " scale = STD_GSD / gsd\n", + " angle, off = rng.uniform(0, 360), c[\"off_nadir\"] or rng.uniform(0, 15)\n", + " squash = np.cos(np.radians(off))\n", + " sun_el = rng.uniform(12, 22) if c[\"low_sun\"] else rng.uniform(40, 70)\n", + " sun_az = rng.uniform(0, 360)\n", + " bg = 115 + ndimage.gaussian_filter(rng.normal(0, 30, (N, N)), 2) + rng.normal(0, 5, (N, N))\n", + " parts = _mask(cls, angle, scale, squash)\n", + " body = np.any([m for m, _ in parts], axis=0) if parts else np.zeros((N, N), bool)\n", + " opt = bg.copy()\n", + " # 그림자: 태양 반대 방향으로 밀린 객체 모양(태양 고도가 낮을수록 길다)\n", + " L = min(8.0, 2.5 / np.tan(np.radians(sun_el))) * scale\n", + " sh = ndimage.shift(body.astype(float), (L * np.cos(np.radians(sun_az)), -L * np.sin(np.radians(sun_az))), order=0) > 0.5\n", + " opt[sh & ~body] -= 35 if cls != \"decoy\" else 25\n", + " if cls == \"civil\":\n", + " base = rng.choice([200.0, 60.0]) # 민간 차량 색상: 밝거나 어두움\n", + " opt[body] = base + rng.normal(0, 8, body.sum())\n", + " roof = parts[1][0]\n", + " opt[roof] = base * 0.8\n", + " else:\n", + " ref = bg[body].mean() if body.any() else bg.mean()\n", + " for m, d in parts:\n", + " opt[m] = ref + d + rng.normal(0, 4 if cls != \"decoy\" else 2, m.sum())\n", + " # 위장막: 객체 주변을 배경 질감으로 덮어 대비를 낮춘다\n", + " if c[\"camo\"] > 0 and cls != \"civil\":\n", + " net = ndimage.binary_dilation(body, iterations=2)\n", + " tex = 105 + ndimage.gaussian_filter(rng.normal(0, 25, (N, N)), 1)\n", + " opt[net] = (1 - c[\"camo\"]) * opt[net] + c[\"camo\"] * tex[net]\n", + " # 수목 차폐: 객체의 일부를 어두운 나무 덩어리로 가린다\n", + " if c[\"occl\"] > 0:\n", + " trees = ndimage.gaussian_filter(rng.random((N, N)), 2)\n", + " near = ndimage.binary_dilation(body, iterations=3)\n", + " cover = near & (trees > np.quantile(trees[body], 1 - c[\"occl\"])) if body.any() else np.zeros((N, N), bool)\n", + " opt[cover] = 70 + rng.normal(0, 6, cover.sum())\n", + " # 구름: 밝고 매끈한 덩어리(광학만 가림, SAR는 통과)\n", + " cmask = np.zeros((N, N), bool)\n", + " if c[\"cloud\"] > 0:\n", + " blob = ndimage.gaussian_filter(rng.random((N, N)), 4)\n", + " cmask = blob > np.quantile(blob, 1 - c[\"cloud\"])\n", + " opt[cmask] = 0.25 * opt[cmask] + 0.75 * (235 + rng.normal(0, 3, cmask.sum()))\n", + " # SAR: 곱셈형 스페클 잡음 + 금속 반사(모서리 이중 반사로 밝은 점)\n", + " sar = np.full((N, N), 0.12)\n", + " sar[body] = SAR_RETURN[cls] * rng.uniform(0.8, 1.2)\n", + " edge = body & ~ndimage.binary_erosion(body)\n", + " sar[edge] *= 1.3 if cls in MILITARY else 1.0\n", + " sar = sar * rng.gamma(4 / c[\"speckle\"], c[\"speckle\"] / 4, (N, N))\n", + " if c[\"low_res\"]: # 저해상도: 정보 손실(블러)\n", + " down = lambda a: np.kron(a.reshape(N // 2, 2, N // 2, 2).mean((1, 3)), np.ones((2, 2)))\n", + " opt, sar = ndimage.gaussian_filter(down(opt), 0.7), ndimage.gaussian_filter(down(sar), 0.7)\n", + " meta = {\"cls\": cls, \"gsd\": round(float(gsd), 2), \"off_nadir\": round(float(off), 1), \"sun_el\": round(float(sun_el), 1),\n", + " \"sun_az\": round(float(sun_az), 1), \"angle\": round(float(angle), 1), \"cloud_frac\": float(cmask.mean()),\n", + " \"cloud_on_obj\": float((cmask & body).sum() / max(body.sum(), 1)), **{k: c[k] for k in (\"camo\", \"occl\", \"low_sun\", \"low_res\")}}\n", + " return np.clip(opt, 0, 255), sar, meta\n", + "\n", + "\n", + "SCEN = { # 은폐·촬영 조건 시나리오 10종\n", + " \"clean\": {}, \"camo\": {\"camo\": 0.7}, \"occl30\": {\"occl\": 0.3}, \"occl50\": {\"occl\": 0.5}, \"cloud\": {\"cloud\": 0.25},\n", + " \"low_sun\": {\"low_sun\": True}, \"off_nadir\": {\"off_nadir\": 35.0}, \"low_res\": {\"low_res\": True},\n", + " \"speckle\": {\"speckle\": 3.0}, \"camo_occl\": {\"camo\": 0.6, \"occl\": 0.3}}\n", + "\n", + "\n", + "def make_dataset(n=400, seed=7, scen=(\"clean\",), classes=CLASSES):\n", + " rng = np.random.default_rng(seed)\n", + " O, S, M = np.zeros((n, N, N)), np.zeros((n, N, N)), []\n", + " for i in range(n):\n", + " cls, sc = classes[i % len(classes)], scen[(i // len(classes)) % len(scen)]\n", + " O[i], S[i], m = make_chip(cls, rng, SCEN[sc])\n", + " m.update({\"id\": f\"IMG{i:04d}\", \"scen\": sc})\n", + " M.append(m)\n", + " return O, S, M\n", + "# ---------------------------------------------------------------------------\n", + "\n", + "# ---- 전처리(해상도·기하 보정, 구름 차폐 처리, 방위 정규화) 공통 함수 ----\n", + "def _fit(a, fill):\n", + " \"\"\"중앙 기준으로 32x32에 맞게 자르거나 채운다.\"\"\"\n", + " out = np.full((N, N), fill, float)\n", + " h, w = a.shape\n", + " ys, xs = max(0, (h - N) // 2), max(0, (w - N) // 2)\n", + " yd, xd = max(0, (N - h) // 2), max(0, (N - w) // 2)\n", + " hh, ww = min(N, h), min(N, w)\n", + " out[yd:yd + hh, xd:xd + ww] = a[ys:ys + hh, xs:xs + ww]\n", + " return out\n", + "\n", + "\n", + "def cloud_mask(opt, sm, min_area=15):\n", + " \"\"\"광학에서 밝고(185 초과) 매끈한데 SAR에는 반사가 없는 영역을 구름으로 본다(SAR는 구름을 통과하므로 밝은 차량과 구분된다).\"\"\"\n", + " bright = opt > 185\n", + " if bright.sum() < min_area:\n", + " return np.zeros_like(bright)\n", + " local_sd = np.sqrt(np.maximum(ndimage.uniform_filter(opt ** 2, 3) - ndimage.uniform_filter(opt, 3) ** 2, 0))\n", + " cand = bright & (local_sd < 12) & (sm < 0.3)\n", + " lab, n = ndimage.label(cand)\n", + " if n == 0:\n", + " return cand\n", + " sizes = ndimage.sum(cand.astype(float), lab, range(1, n + 1))\n", + " return ndimage.binary_dilation(np.isin(lab, 1 + np.where(sizes >= min_area)[0]), iterations=2) & (opt > 140)\n", + "\n", + "\n", + "def orientation(opt, sm):\n", + " \"\"\"SAR 반사(위장·구름 영향 적음, sm은 평활화한 SAR)와 광학 대비를 합친 객체 마스크의 주축 방향(도).\"\"\"\n", + " m = (sm > 0.3) | (np.abs(opt - opt.mean()) > 30)\n", + " lab, n = ndimage.label(m)\n", + " if n == 0:\n", + " return 0.0, m\n", + " c = lab[N // 2 - 6:N // 2 + 6, N // 2 - 6:N // 2 + 6]\n", + " k = np.bincount(c[c > 0]).argmax() if (c > 0).any() else 1 + int(np.argmax(ndimage.sum(m.astype(float), lab, range(1, n + 1))))\n", + " m = lab == k\n", + " yy, xx = np.nonzero(m)\n", + " if len(yy) < 5:\n", + " return 0.0, m\n", + " dy, dx = yy - yy.mean(), xx - xx.mean() # 2차 모멘트로 주축 각도 계산\n", + " return float(np.degrees(0.5 * np.arctan2(2 * (dx * dy).mean(), (dx * dx).mean() - (dy * dy).mean()))), m\n", + "\n", + "\n", + "_YY, _XX = np.mgrid[0:N, 0:N] - (N - 1) / 2\n", + "\n", + "\n", + "def warp(stack, zy, zx, ang):\n", + " \"\"\"여러 장(B,채널,32,32)에 장별 확대·축소(zy, zx)와 회전(ang, 도)을 한 번의 쌍선형 보간으로 적용한다(벡터 연산).\"\"\"\n", + " a = np.radians(ang)[:, None, None]\n", + " zy, zx = np.asarray(zy)[:, None, None], np.asarray(zx)[:, None, None]\n", + " sy = np.clip((np.cos(a) * _YY + np.sin(a) * _XX) / zy + (N - 1) / 2, 0, N - 1.001) # 출력 좌표 -> 원본 좌표\n", + " sx = np.clip((-np.sin(a) * _YY + np.cos(a) * _XX) / zx + (N - 1) / 2, 0, N - 1.001)\n", + " y0, x0 = sy.astype(int), sx.astype(int)\n", + " fy, fx = (sy - y0).reshape(len(stack), 1, -1), (sx - x0).reshape(len(stack), 1, -1)\n", + " flat = stack.reshape(len(stack), stack.shape[1], -1)\n", + " lin = (y0 * N + x0).reshape(len(stack), 1, -1)\n", + " g = lambda d: np.take_along_axis(flat, np.broadcast_to(lin + d, (len(stack), stack.shape[1], lin.shape[2])), axis=2)\n", + " out = g(0) * (1 - fy) * (1 - fx) + g(N) * fy * (1 - fx) + g(1) * (1 - fy) * fx + g(N + 1) * fy * fx\n", + " return out.reshape(stack.shape)\n", + "\n", + "\n", + "def preprocess_batch(O, S, M):\n", + " \"\"\"여러 장을 한 번에 전처리한다. 0) 결측 보정 1) 해상도·비스듬 촬영 보정(표준 0.5m/픽셀)\n", + " 2) 구름 차폐 영역을 배경값으로 채움 3) 객체 주축을 가로로 회전 4) 0~1 정규화.\"\"\"\n", + " src = np.stack([np.asarray(O, float), np.asarray(S, float)], axis=1)\n", + " if np.isnan(src).any(): # 0) 센서 결측 픽셀 보정\n", + " mean = np.nanmean(src, axis=(2, 3), keepdims=True)\n", + " src = np.where(np.isnan(src), mean, src)\n", + " zy = np.array([m[\"gsd\"] / STD_GSD / np.cos(np.radians(m[\"off_nadir\"])) for m in M])\n", + " zx = np.array([m[\"gsd\"] / STD_GSD for m in M])\n", + " out = warp(src, zy, zx, np.zeros(len(M)))\n", + " sm = ndimage.uniform_filter(out[:, 1], (1, 3, 3))\n", + " angs, cloud = np.zeros(len(M)), np.zeros(len(M))\n", + " for i in range(len(M)):\n", + " cm = cloud_mask(out[i, 0], sm[i])\n", + " if cm.any():\n", + " fill = out[i, 0][~cm].mean() if (~cm).any() else 115.0\n", + " out[i, 0][cm] = fill\n", + " back = warp(cm[None, None].astype(float), [1 / zy[i]], [1 / zx[i]], np.zeros(1))[0, 0] > 0.5\n", + " src[i, 0][back] = fill # 원본의 같은 구름 영역도 채움\n", + " cloud[i] = cm.mean()\n", + " angs[i] = orientation(out[i, 0], sm[i])[0]\n", + " rot = np.abs(angs) > 3 # 3도 이하는 회전 생략\n", + " if rot.any():\n", + " out[rot] = warp(src[rot], zy[rot], zx[rot], angs[rot])\n", + " return [(np.clip(o / 255, 0, 1), np.clip(s / 1.5, 0, 1), float(c)) for (o, s), c in zip(out, cloud)]\n", + "\n", + "\n", + "def preprocess(opt, sar, meta):\n", + " \"\"\"한 장 전처리(preprocess_batch와 같은 처리).\"\"\"\n", + " return preprocess_batch([opt], [sar], [meta])[0]\n", + "\n", + "# ---- 특징 추출 공통 함수 ----\n", + "KERNELS = [np.array(k, float) for k in (\n", + " [[-1, 0, 1], [-2, 0, 2], [-1, 0, 1]], [[-1, -2, -1], [0, 0, 0], [1, 2, 1]], [[0, 1, 0], [1, -4, 1], [0, 1, 0]],\n", + " [[2, -1, -1], [-1, 2, -1], [-1, -1, 2]], [[-1, -1, 2], [-1, 2, -1], [2, -1, -1]], [[1, 1, 1], [1, 1, 1], [1, 1, 1]])]\n", + "\n", + "\n", + "def conv_features(o):\n", + " \"\"\"간이 CNN 특징: 고정 합성곱 필터 6종 -> ReLU -> 4x4 최대 풀링(8x8 출력).\"\"\"\n", + " feats = []\n", + " for k in KERNELS:\n", + " r = np.maximum(ndimage.correlate(o - o.mean(), k / np.abs(k).sum()), 0)\n", + " feats.append(r.reshape(8, 4, 8, 4).max(axis=(1, 3)).ravel())\n", + " feats.append(np.maximum(-ndimage.correlate(o - o.mean(), k / np.abs(k).sum()), 0).reshape(8, 4, 8, 4).max(axis=(1, 3)).ravel())\n", + " return np.concatenate(feats)\n", + "\n", + "\n", + "def hog(o, cell=8, bins=8):\n", + " gy, gx = np.gradient(o)\n", + " mag, ang = np.hypot(gx, gy), np.mod(np.arctan2(gy, gx), np.pi)\n", + " b = np.minimum((ang / np.pi * bins).astype(int), bins - 1)\n", + " out = np.zeros((N // cell, N // cell, bins))\n", + " for i in range(N // cell):\n", + " for j in range(N // cell):\n", + " sl = (slice(i * cell, (i + 1) * cell), slice(j * cell, (j + 1) * cell))\n", + " out[i, j] = np.bincount(b[sl].ravel(), mag[sl].ravel(), bins)\n", + " return (out / (np.linalg.norm(out) + 1e-6)).ravel()\n", + "\n", + "\n", + "def shape_features(o, s):\n", + " \"\"\"객체 모양 특징: 가로 방향 투영으로 전체 길이·차체 길이·가는 돌출부(포신) 길이·두꺼운 부분(포탑) 위치를 잰다.\n", + " 방위 정규화 뒤에도 앞뒤(180도)가 뒤집힐 수 있으므로, 가는 돌출부가 오른쪽에 오도록 맞춘다.\"\"\"\n", + " sm = ndimage.uniform_filter(s, 3)\n", + " dark = np.abs(o - np.median(o))\n", + " m = (sm > 0.22) | (dark > 0.12)\n", + " lab, n = ndimage.label(m)\n", + " if n:\n", + " c = lab[10:22, 8:24]\n", + " k = np.bincount(c[c > 0]).argmax() if (c > 0).any() else 1\n", + " m = lab == k\n", + " p = m.sum(axis=0).astype(float)\n", + " cols = np.nonzero(p)[0]\n", + " if len(cols) == 0:\n", + " return np.zeros(8), o, s\n", + " thin = (p > 0) & (p <= 2)\n", + " left, right = thin[:N // 2].sum(), thin[N // 2:].sum()\n", + " if left > right:\n", + " o, s, m, p = o[:, ::-1], s[:, ::-1], m[:, ::-1], p[::-1]\n", + " cols = np.nonzero(p)[0]\n", + " body = np.nonzero(p > 3)[0]\n", + " d = np.where(m, dark if left <= right else dark[:, ::-1], 0)\n", + " heavy = d.sum(axis=0)\n", + " centre = (body[0] + body[-1]) / 2 if len(body) else N / 2\n", + " turret = (heavy * np.arange(N)).sum() / max(heavy.sum(), 1e-6) - centre\n", + " return np.array([cols[-1] - cols[0] + 1, len(body), ((p > 0) & (p <= 2)).sum(), p.max(), turret,\n", + " m.sum(), (cols[-1] - (body[-1] if len(body) else cols[-1])), m.sum() / max(len(cols) * p.max(), 1)]), o, s\n", + "\n", + "\n", + "def adv_features(o, s):\n", + " \"\"\"정밀 특징: 모양(길이·포신·포탑 위치) + HOG(윤곽) + SAR 반사 통계(위장막·구름 영향 적음) + 투영 윤곽.\"\"\"\n", + " shp, o, s = shape_features(o, s)\n", + " c = s[8:24, 4:28]\n", + " sm = ndimage.uniform_filter(s, 3)\n", + " m = sm > 0.2\n", + " dark = np.abs(o - np.median(o))\n", + " return np.concatenate([shp, hog(o), [c.mean(), c.max(), (sm > 0.25).mean(), (sm > 0.4).mean(), m.sum(axis=1).max(), m.sum(axis=0).max()],\n", + " sm.mean(axis=0).reshape(8, 4).mean(1), sm.mean(axis=1).reshape(8, 4).mean(1),\n", + " dark.mean(axis=0).reshape(8, 4).mean(1), dark.mean(axis=1).reshape(8, 4).mean(1),\n", + " [o.mean(), o.std(), dark.max(), (o > 0.65).mean(), (o < 0.3).mean()]])\n", + "\n", + "\n", + "def prep(O, S, M):\n", + " return preprocess_batch(O, S, M)\n", + "\n", + "\n", + "ALL = tuple(SCEN)\n", + "# 1) 학습: 은폐 조건 10종을 고르게 섞은 증강 학습 자료 1,200장\n", + "Otr, Str, Mtr = make_dataset(1200, seed=3, scen=ALL)\n", + "Ptr = prep(Otr, Str, Mtr); ytr = [m[\"cls\"] for m in Mtr]\n", + "adv = RandomForestClassifier(n_estimators=200, min_samples_leaf=2, random_state=0, n_jobs=1)\n", + "adv.fit(np.array([adv_features(o, s) for o, s, _ in Ptr]), ytr)\n", + "base = make_pipeline(StandardScaler(), LogisticRegression(C=0.05, max_iter=3000)) # 비교용 Baseline(맑은 조건만 학습)\n", + "Oc, Sc, Mc = make_dataset(600, seed=1)\n", + "base.fit(np.array([conv_features(p[0]) for p in prep(Oc, Sc, Mc)]), [m[\"cls\"] for m in Mc])\n", + "\n", + "# 2) 시험: 조건 10종 x 4클래스 x 15장 = 600장\n", + "Ote, Ste, Mte = make_dataset(600, seed=4, scen=ALL)\n", + "t0 = time.perf_counter()\n", + "Pte = prep(Ote, Ste, Mte)\n", + "pa = adv.predict(np.array([adv_features(o, s) for o, s, _ in Pte]))\n", + "lat = (time.perf_counter() - t0) / len(Pte) * 1000\n", + "pb = base.predict(np.array([conv_features(p[0]) for p in Pte]))\n", + "y, sc = np.array([m[\"cls\"] for m in Mte]), np.array([m[\"scen\"] for m in Mte])\n", + "mil = np.isin(y, list(MILITARY))\n", + "rec = lambda p, sel: ((p == y) & mil & sel).sum() / (mil & sel).sum() # 군용 장비를 '정확한 기종'으로 식별한 비율\n", + "allsel = np.ones(len(y), bool)\n", + "print(f\"군용 장비 기종 식별 재현율: Baseline {rec(pb, allsel):.1%} -> 정밀 모델 {rec(pa, allsel):.1%} (목표 85% 이상: {'달성' if rec(pa, allsel) >= 0.85 else '미달'})\")\n", + "print(f\"4클래스 정확도: Baseline {(pb == y).mean():.1%} -> 정밀 모델 {(pa == y).mean():.1%}, 1장당 처리 {lat:.1f} ms (목표 200 ms 이하)\")\n", + "\n", + "# 3) 은폐 조건 10종 시험(군용 기종 식별 재현율 85% 이상이면 통과)\n", + "rows = []\n", + "for k in ALL:\n", + " s = sc == k\n", + " rows.append({\"조건\": k, \"Baseline 재현율\": round(rec(pb, s), 2), \"정밀 재현율\": round(rec(pa, s), 2),\n", + " \"정밀 정확도\": round(float((pa[s] == y[s]).mean()), 2), \"민간->군용 오분류\": int((~mil & s & np.isin(pa, list(MILITARY))).sum()),\n", + " \"판정\": \"통과\" if rec(pa, s) >= 0.85 else \"미통과\"})\n", + "t = pd.DataFrame(rows)\n", + "print(\"\\n\" + t.to_string(index=False))\n", + "print(f\"통과 {int((t['판정'] == '통과').sum())}/10종\")\n", + "\n", + "# 4) 오경보 유발 패턴과 중요 특징\n", + "print(\"\\n[혼동이 많은 쌍(실제->예측)]\", pd.Series([f\"{a}->{b}\" for a, b in zip(y, pa) if a != b]).value_counts().head(4).to_dict())\n", + "names = [\"obj_len\", \"body_len\", \"thin_cols\", \"max_width\", \"turret_pos\", \"area\", \"barrel_len\", \"fill\"] + [f\"hog{i}\" for i in range(128)] + [\"sar_mean\", \"sar_max\", \"sar>0.25\", \"sar>0.4\", \"sar_width\", \"sar_len\"] + \\\n", + " [f\"sar_colprof{i}\" for i in range(8)] + [f\"sar_rowprof{i}\" for i in range(8)] + [f\"opt_colprof{i}\" for i in range(8)] + \\\n", + " [f\"opt_rowprof{i}\" for i in range(8)] + [\"opt_mean\", \"opt_std\", \"opt_contrast\", \"bright_frac\", \"dark_frac\"]\n", + "imp = pd.Series(adv.feature_importances_, index=names).sort_values(ascending=False)\n", + "print(\"중요 특징 상위 5:\", {k: round(v, 3) for k, v in imp.head(5).items()})\n", + "print(\"-> SAR 반사 특징이 위장막·구름에 강해 재현율을 지탱한다. 남은 오류는 위 혼동 쌍처럼 비슷한 크기 기종 간 혼동이며 미션 5에서 신뢰도로 거른다.\")\n", + "\n", + "fig, ax = plt.subplots(1, 2, figsize=(12, 4))\n", + "x = np.arange(len(ALL))\n", + "ax[0].bar(x - 0.2, t[\"Baseline 재현율\"], 0.4, label=\"baseline\"); ax[0].bar(x + 0.2, t[\"정밀 재현율\"], 0.4, label=\"advanced\")\n", + "ax[0].axhline(0.85, ls=\"--\", c=\"k\"); ax[0].set_xticks(x); ax[0].set_xticklabels(ALL, rotation=45, fontsize=8)\n", + "ax[0].set_ylabel(\"military recall\"); ax[0].legend(); ax[0].set_title(\"Recall by concealment scenario\")\n", + "imp.head(10)[::-1].plot.barh(ax=ax[1]); ax[1].set_title(\"Top feature importances\")\n", + "plt.tight_layout(); plt.show()\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 04 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + "군용 장비 기종 식별 재현율: Baseline 66.0% -> 정밀 모델 97.3% (목표 85% 이상: 달성)\n", + "4클래스 정확도: Baseline 69.2% -> 정밀 모델 98.0%, 1장당 처리 1.5 ms (목표 200 ms 이하)\n", + "\n", + " 조건 Baseline 재현율 정밀 재현율 정밀 정확도 민간->군용 오분류 판정\n", + " clean 1.00 0.98 0.98 0 통과\n", + " camo 0.33 0.96 0.97 0 통과\n", + " occl30 0.73 1.00 1.00 0 통과\n", + " occl50 0.53 0.98 0.98 0 통과\n", + " cloud 0.33 1.00 1.00 0 통과\n", + " low_sun 0.82 0.96 0.97 0 통과\n", + "off_nadir 0.98 1.00 1.00 0 통과\n", + " low_res 0.33 0.87 0.90 0 통과\n", + " speckle 1.00 1.00 1.00 0 통과\n", + "camo_occl 0.53 1.00 1.00 0 통과\n", + "통과 10/10종\n", + "\n", + "[혼동이 많은 쌍(실제->예측)] {'truck->tank': 6, 'tank->truck': 5, 'tank->spg': 1}\n", + "중요 특징 상위 5: {'sar>0.25': 0.083, 'sar_width': 0.08, 'sar_len': 0.07, 'sar_colprof2': 0.064, 'sar_colprof4': 0.057}\n", + "-> SAR 반사 특징이 위장막·구름에 강해 재현율을 지탱한다. 남은 오류는 위 혼동 쌍처럼 비슷한 크기 기종 간 혼동이며 미션 5에서 신뢰도로 거른다.\n", + "```\n", + "
\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 05. 위성 촬영 각도/그림자 영향에 따른 객체분류 신뢰도 점수 산출 및 오분류 필터 구현\n", + "**소요시간**: 약 3.0시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "위성 센서의 해상도 한계 및 기상 영향을 반영하여 장비 분류 결과에 대한 신뢰도 점수를 계산하고 오분류를 필터링하는 엔진을 개발한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **센서 해상도 한계, 촬영 각도, 태양 고도(그림자), 구름 영향을 반영해 분류 결과의 신뢰도 점수를 계산하고 오분류를 걸러내는 엔진을 개발한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **분류 확률의 최댓값·차이·엔트로피와 구름 비율, 해상도, 촬영 각도, 태양 고도, SAR 반사 세기를 품질 지표로 삼아 별도 보정 자료에서 '분류가 맞을 확률'을 학습하고 그 계수를 신뢰도 가중치 매트릭스로 공개 수행하고 검증하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `src/verification/isr01_confidence_engine.py` 형태의 결과물을 완성한다.\n", + "\n", + "### 🔍 평가 및 검증 기준 (PASS 조건)\n", + "#### 미션 판정\n", + "\n", + "**평가 구분:** 필수\n", + "\n", + "**핵심 평가영역:** 신뢰도 등급(High/Med/Low) 부여 기준의 군사적 타당성\n", + "\n", + "#### 미션 PASS 조건\n", + "\n", + "- 정량 기준: 오경보율(FAR) 40% 이상 감축, 신뢰도 스코어 산출 일관성 90% 이상\n", + "\n", + "\n", + "- 진행 기준: 오경보 필터링 전후 비교 지표 산출 및 통합 테스트 완료\n", + "\n", + "\n", + "- 검증 방법: ROC-AUC 곡선 분석 및 임계값 민감도 테스트\n", + "\n", + "\n", + "#### 채점자 판정 메모\n", + "\n", + "> 단순 코드 실행 여부뿐만 아니라 출력된 수치와 근거 자료가 군 실무 요구사항에 부합하는지 면밀히 확인한다.\n", + "\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "\"\"\"위성 센서 해상도 한계, 비스듬 촬영 각도, 태양 고도(그림자), 구름 차폐를 반영해 장비 분류 결과의 신뢰도 점수(분류가 맞을 확률)와 등급(High/Medium/Low)을 계산하고, 낮은 신뢰도 결과를 판독 보류로 돌려 오분류 보고를 줄이는 엔진을 개발한다.\"\"\"\n", + "import numpy as np\n", + "import pandas as pd\n", + "import matplotlib.pyplot as plt\n", + "from scipy import ndimage\n", + "from sklearn.ensemble import RandomForestClassifier\n", + "from sklearn.linear_model import LogisticRegression\n", + "from sklearn.metrics import roc_auc_score\n", + "from sklearn.pipeline import make_pipeline\n", + "from sklearn.preprocessing import StandardScaler\n", + "\n", + "# ---------------------------------------------------------------------------\n", + "# [교육용가상자료] 합성 위성영상 조각(chip) 생성기 (실제 위성영상·장비 제원·위치 없음)\n", + "# 32x32 광학(EO) 영상과 SAR 영상 한 쌍에 추상 도형으로 만든 객체를 그린다. 도형 치수는 가상값이다.\n", + "# ---------------------------------------------------------------------------\n", + "N = 32\n", + "STD_GSD = 0.5 # 표준 해상도(m/픽셀, 가상 기준)\n", + "CLASSES = [\"tank\", \"spg\", \"truck\", \"civil\"]\n", + "MILITARY = {\"tank\", \"spg\", \"truck\"}\n", + "# 객체 좌표계(u: 길이 방향, v: 폭 방향, 표준 해상도 픽셀) 가상 도형: (u중심, v중심, 길이, 폭, 광학 밝기 차이)\n", + "SHAPES = {\n", + " \"tank\": [(0, 0, 11, 7, -40), (0.5, 0, 5, 5, -55), (6, 0, 7, 1.2, -60)],\n", + " \"spg\": [(0, 0, 16, 7.5, -38), (-4, 0, 6, 6.5, -52), (5, 0, 13, 1.4, -60)],\n", + " \"truck\": [(5, 0, 3.5, 5.5, -45), (-1.8, 0, 8.5, 6, -25)],\n", + " \"civil\": [(0, 0, 8, 3.8, 0), (1.2, 0, 2, 3.4, 0)],\n", + " \"decoy\": [(0, 0, 11, 7, -40), (0.5, 0, 5, 5, -42), (6, 0, 7, 1.2, -45)],\n", + " \"none\": [], # 객체 없는 배경 타일\n", + "}\n", + "SAR_RETURN = {\"tank\": 0.85, \"spg\": 0.85, \"truck\": 0.7, \"civil\": 0.45, \"decoy\": 0.18, \"none\": 0.12} # 금속 반사 세기(가상)\n", + "\n", + "\n", + "def _mask(cls, angle, scale, squash=1.0):\n", + " \"\"\"회전(angle, 도)·축척(scale)·비스듬 촬영 압축(squash)을 적용해 도형별 마스크와 밝기 차이를 만든다.\"\"\"\n", + " yy, xx = np.mgrid[0:N, 0:N] - (N - 1) / 2\n", + " yy = yy / squash\n", + " a = np.radians(angle)\n", + " u = (xx * np.cos(a) + yy * np.sin(a)) / scale\n", + " v = (-xx * np.sin(a) + yy * np.cos(a)) / scale\n", + " return [((np.abs(u - cu) <= L / 2) & (np.abs(v - cv) <= W / 2), d) for cu, cv, L, W, d in SHAPES[cls]]\n", + "\n", + "\n", + "def make_chip(cls, rng, cond=None):\n", + " \"\"\"chip 1개 = (광학 32x32, SAR 32x32, 메타데이터). cond: camo, occl, cloud, low_sun, off_nadir, low_res, speckle\"\"\"\n", + " c = {\"camo\": 0.0, \"occl\": 0.0, \"cloud\": 0.0, \"low_sun\": False, \"off_nadir\": 0.0, \"low_res\": False, \"speckle\": 1.0, **(cond or {})}\n", + " gsd = rng.uniform(0.9, 1.2) if c[\"low_res\"] else rng.uniform(0.4, 0.6)\n", + " scale = STD_GSD / gsd\n", + " angle, off = rng.uniform(0, 360), c[\"off_nadir\"] or rng.uniform(0, 15)\n", + " squash = np.cos(np.radians(off))\n", + " sun_el = rng.uniform(12, 22) if c[\"low_sun\"] else rng.uniform(40, 70)\n", + " sun_az = rng.uniform(0, 360)\n", + " bg = 115 + ndimage.gaussian_filter(rng.normal(0, 30, (N, N)), 2) + rng.normal(0, 5, (N, N))\n", + " parts = _mask(cls, angle, scale, squash)\n", + " body = np.any([m for m, _ in parts], axis=0) if parts else np.zeros((N, N), bool)\n", + " opt = bg.copy()\n", + " # 그림자: 태양 반대 방향으로 밀린 객체 모양(태양 고도가 낮을수록 길다)\n", + " L = min(8.0, 2.5 / np.tan(np.radians(sun_el))) * scale\n", + " sh = ndimage.shift(body.astype(float), (L * np.cos(np.radians(sun_az)), -L * np.sin(np.radians(sun_az))), order=0) > 0.5\n", + " opt[sh & ~body] -= 35 if cls != \"decoy\" else 25\n", + " if cls == \"civil\":\n", + " base = rng.choice([200.0, 60.0]) # 민간 차량 색상: 밝거나 어두움\n", + " opt[body] = base + rng.normal(0, 8, body.sum())\n", + " roof = parts[1][0]\n", + " opt[roof] = base * 0.8\n", + " else:\n", + " ref = bg[body].mean() if body.any() else bg.mean()\n", + " for m, d in parts:\n", + " opt[m] = ref + d + rng.normal(0, 4 if cls != \"decoy\" else 2, m.sum())\n", + " # 위장막: 객체 주변을 배경 질감으로 덮어 대비를 낮춘다\n", + " if c[\"camo\"] > 0 and cls != \"civil\":\n", + " net = ndimage.binary_dilation(body, iterations=2)\n", + " tex = 105 + ndimage.gaussian_filter(rng.normal(0, 25, (N, N)), 1)\n", + " opt[net] = (1 - c[\"camo\"]) * opt[net] + c[\"camo\"] * tex[net]\n", + " # 수목 차폐: 객체의 일부를 어두운 나무 덩어리로 가린다\n", + " if c[\"occl\"] > 0:\n", + " trees = ndimage.gaussian_filter(rng.random((N, N)), 2)\n", + " near = ndimage.binary_dilation(body, iterations=3)\n", + " cover = near & (trees > np.quantile(trees[body], 1 - c[\"occl\"])) if body.any() else np.zeros((N, N), bool)\n", + " opt[cover] = 70 + rng.normal(0, 6, cover.sum())\n", + " # 구름: 밝고 매끈한 덩어리(광학만 가림, SAR는 통과)\n", + " cmask = np.zeros((N, N), bool)\n", + " if c[\"cloud\"] > 0:\n", + " blob = ndimage.gaussian_filter(rng.random((N, N)), 4)\n", + " cmask = blob > np.quantile(blob, 1 - c[\"cloud\"])\n", + " opt[cmask] = 0.25 * opt[cmask] + 0.75 * (235 + rng.normal(0, 3, cmask.sum()))\n", + " # SAR: 곱셈형 스페클 잡음 + 금속 반사(모서리 이중 반사로 밝은 점)\n", + " sar = np.full((N, N), 0.12)\n", + " sar[body] = SAR_RETURN[cls] * rng.uniform(0.8, 1.2)\n", + " edge = body & ~ndimage.binary_erosion(body)\n", + " sar[edge] *= 1.3 if cls in MILITARY else 1.0\n", + " sar = sar * rng.gamma(4 / c[\"speckle\"], c[\"speckle\"] / 4, (N, N))\n", + " if c[\"low_res\"]: # 저해상도: 정보 손실(블러)\n", + " down = lambda a: np.kron(a.reshape(N // 2, 2, N // 2, 2).mean((1, 3)), np.ones((2, 2)))\n", + " opt, sar = ndimage.gaussian_filter(down(opt), 0.7), ndimage.gaussian_filter(down(sar), 0.7)\n", + " meta = {\"cls\": cls, \"gsd\": round(float(gsd), 2), \"off_nadir\": round(float(off), 1), \"sun_el\": round(float(sun_el), 1),\n", + " \"sun_az\": round(float(sun_az), 1), \"angle\": round(float(angle), 1), \"cloud_frac\": float(cmask.mean()),\n", + " \"cloud_on_obj\": float((cmask & body).sum() / max(body.sum(), 1)), **{k: c[k] for k in (\"camo\", \"occl\", \"low_sun\", \"low_res\")}}\n", + " return np.clip(opt, 0, 255), sar, meta\n", + "\n", + "\n", + "SCEN = { # 은폐·촬영 조건 시나리오 10종\n", + " \"clean\": {}, \"camo\": {\"camo\": 0.7}, \"occl30\": {\"occl\": 0.3}, \"occl50\": {\"occl\": 0.5}, \"cloud\": {\"cloud\": 0.25},\n", + " \"low_sun\": {\"low_sun\": True}, \"off_nadir\": {\"off_nadir\": 35.0}, \"low_res\": {\"low_res\": True},\n", + " \"speckle\": {\"speckle\": 3.0}, \"camo_occl\": {\"camo\": 0.6, \"occl\": 0.3}}\n", + "\n", + "\n", + "def make_dataset(n=400, seed=7, scen=(\"clean\",), classes=CLASSES):\n", + " rng = np.random.default_rng(seed)\n", + " O, S, M = np.zeros((n, N, N)), np.zeros((n, N, N)), []\n", + " for i in range(n):\n", + " cls, sc = classes[i % len(classes)], scen[(i // len(classes)) % len(scen)]\n", + " O[i], S[i], m = make_chip(cls, rng, SCEN[sc])\n", + " m.update({\"id\": f\"IMG{i:04d}\", \"scen\": sc})\n", + " M.append(m)\n", + " return O, S, M\n", + "# ---------------------------------------------------------------------------\n", + "\n", + "# ---- 전처리(해상도·기하 보정, 구름 차폐 처리, 방위 정규화) 공통 함수 ----\n", + "def _fit(a, fill):\n", + " \"\"\"중앙 기준으로 32x32에 맞게 자르거나 채운다.\"\"\"\n", + " out = np.full((N, N), fill, float)\n", + " h, w = a.shape\n", + " ys, xs = max(0, (h - N) // 2), max(0, (w - N) // 2)\n", + " yd, xd = max(0, (N - h) // 2), max(0, (N - w) // 2)\n", + " hh, ww = min(N, h), min(N, w)\n", + " out[yd:yd + hh, xd:xd + ww] = a[ys:ys + hh, xs:xs + ww]\n", + " return out\n", + "\n", + "\n", + "def cloud_mask(opt, sm, min_area=15):\n", + " \"\"\"광학에서 밝고(185 초과) 매끈한데 SAR에는 반사가 없는 영역을 구름으로 본다(SAR는 구름을 통과하므로 밝은 차량과 구분된다).\"\"\"\n", + " bright = opt > 185\n", + " if bright.sum() < min_area:\n", + " return np.zeros_like(bright)\n", + " local_sd = np.sqrt(np.maximum(ndimage.uniform_filter(opt ** 2, 3) - ndimage.uniform_filter(opt, 3) ** 2, 0))\n", + " cand = bright & (local_sd < 12) & (sm < 0.3)\n", + " lab, n = ndimage.label(cand)\n", + " if n == 0:\n", + " return cand\n", + " sizes = ndimage.sum(cand.astype(float), lab, range(1, n + 1))\n", + " return ndimage.binary_dilation(np.isin(lab, 1 + np.where(sizes >= min_area)[0]), iterations=2) & (opt > 140)\n", + "\n", + "\n", + "def orientation(opt, sm):\n", + " \"\"\"SAR 반사(위장·구름 영향 적음, sm은 평활화한 SAR)와 광학 대비를 합친 객체 마스크의 주축 방향(도).\"\"\"\n", + " m = (sm > 0.3) | (np.abs(opt - opt.mean()) > 30)\n", + " lab, n = ndimage.label(m)\n", + " if n == 0:\n", + " return 0.0, m\n", + " c = lab[N // 2 - 6:N // 2 + 6, N // 2 - 6:N // 2 + 6]\n", + " k = np.bincount(c[c > 0]).argmax() if (c > 0).any() else 1 + int(np.argmax(ndimage.sum(m.astype(float), lab, range(1, n + 1))))\n", + " m = lab == k\n", + " yy, xx = np.nonzero(m)\n", + " if len(yy) < 5:\n", + " return 0.0, m\n", + " dy, dx = yy - yy.mean(), xx - xx.mean() # 2차 모멘트로 주축 각도 계산\n", + " return float(np.degrees(0.5 * np.arctan2(2 * (dx * dy).mean(), (dx * dx).mean() - (dy * dy).mean()))), m\n", + "\n", + "\n", + "_YY, _XX = np.mgrid[0:N, 0:N] - (N - 1) / 2\n", + "\n", + "\n", + "def warp(stack, zy, zx, ang):\n", + " \"\"\"여러 장(B,채널,32,32)에 장별 확대·축소(zy, zx)와 회전(ang, 도)을 한 번의 쌍선형 보간으로 적용한다(벡터 연산).\"\"\"\n", + " a = np.radians(ang)[:, None, None]\n", + " zy, zx = np.asarray(zy)[:, None, None], np.asarray(zx)[:, None, None]\n", + " sy = np.clip((np.cos(a) * _YY + np.sin(a) * _XX) / zy + (N - 1) / 2, 0, N - 1.001) # 출력 좌표 -> 원본 좌표\n", + " sx = np.clip((-np.sin(a) * _YY + np.cos(a) * _XX) / zx + (N - 1) / 2, 0, N - 1.001)\n", + " y0, x0 = sy.astype(int), sx.astype(int)\n", + " fy, fx = (sy - y0).reshape(len(stack), 1, -1), (sx - x0).reshape(len(stack), 1, -1)\n", + " flat = stack.reshape(len(stack), stack.shape[1], -1)\n", + " lin = (y0 * N + x0).reshape(len(stack), 1, -1)\n", + " g = lambda d: np.take_along_axis(flat, np.broadcast_to(lin + d, (len(stack), stack.shape[1], lin.shape[2])), axis=2)\n", + " out = g(0) * (1 - fy) * (1 - fx) + g(N) * fy * (1 - fx) + g(1) * (1 - fy) * fx + g(N + 1) * fy * fx\n", + " return out.reshape(stack.shape)\n", + "\n", + "\n", + "def preprocess_batch(O, S, M):\n", + " \"\"\"여러 장을 한 번에 전처리한다. 0) 결측 보정 1) 해상도·비스듬 촬영 보정(표준 0.5m/픽셀)\n", + " 2) 구름 차폐 영역을 배경값으로 채움 3) 객체 주축을 가로로 회전 4) 0~1 정규화.\"\"\"\n", + " src = np.stack([np.asarray(O, float), np.asarray(S, float)], axis=1)\n", + " if np.isnan(src).any(): # 0) 센서 결측 픽셀 보정\n", + " mean = np.nanmean(src, axis=(2, 3), keepdims=True)\n", + " src = np.where(np.isnan(src), mean, src)\n", + " zy = np.array([m[\"gsd\"] / STD_GSD / np.cos(np.radians(m[\"off_nadir\"])) for m in M])\n", + " zx = np.array([m[\"gsd\"] / STD_GSD for m in M])\n", + " out = warp(src, zy, zx, np.zeros(len(M)))\n", + " sm = ndimage.uniform_filter(out[:, 1], (1, 3, 3))\n", + " angs, cloud = np.zeros(len(M)), np.zeros(len(M))\n", + " for i in range(len(M)):\n", + " cm = cloud_mask(out[i, 0], sm[i])\n", + " if cm.any():\n", + " fill = out[i, 0][~cm].mean() if (~cm).any() else 115.0\n", + " out[i, 0][cm] = fill\n", + " back = warp(cm[None, None].astype(float), [1 / zy[i]], [1 / zx[i]], np.zeros(1))[0, 0] > 0.5\n", + " src[i, 0][back] = fill # 원본의 같은 구름 영역도 채움\n", + " cloud[i] = cm.mean()\n", + " angs[i] = orientation(out[i, 0], sm[i])[0]\n", + " rot = np.abs(angs) > 3 # 3도 이하는 회전 생략\n", + " if rot.any():\n", + " out[rot] = warp(src[rot], zy[rot], zx[rot], angs[rot])\n", + " return [(np.clip(o / 255, 0, 1), np.clip(s / 1.5, 0, 1), float(c)) for (o, s), c in zip(out, cloud)]\n", + "\n", + "\n", + "def preprocess(opt, sar, meta):\n", + " \"\"\"한 장 전처리(preprocess_batch와 같은 처리).\"\"\"\n", + " return preprocess_batch([opt], [sar], [meta])[0]\n", + "\n", + "# ---- 특징 추출 공통 함수 ----\n", + "KERNELS = [np.array(k, float) for k in (\n", + " [[-1, 0, 1], [-2, 0, 2], [-1, 0, 1]], [[-1, -2, -1], [0, 0, 0], [1, 2, 1]], [[0, 1, 0], [1, -4, 1], [0, 1, 0]],\n", + " [[2, -1, -1], [-1, 2, -1], [-1, -1, 2]], [[-1, -1, 2], [-1, 2, -1], [2, -1, -1]], [[1, 1, 1], [1, 1, 1], [1, 1, 1]])]\n", + "\n", + "\n", + "def conv_features(o):\n", + " \"\"\"간이 CNN 특징: 고정 합성곱 필터 6종 -> ReLU -> 4x4 최대 풀링(8x8 출력).\"\"\"\n", + " feats = []\n", + " for k in KERNELS:\n", + " r = np.maximum(ndimage.correlate(o - o.mean(), k / np.abs(k).sum()), 0)\n", + " feats.append(r.reshape(8, 4, 8, 4).max(axis=(1, 3)).ravel())\n", + " feats.append(np.maximum(-ndimage.correlate(o - o.mean(), k / np.abs(k).sum()), 0).reshape(8, 4, 8, 4).max(axis=(1, 3)).ravel())\n", + " return np.concatenate(feats)\n", + "\n", + "\n", + "def hog(o, cell=8, bins=8):\n", + " gy, gx = np.gradient(o)\n", + " mag, ang = np.hypot(gx, gy), np.mod(np.arctan2(gy, gx), np.pi)\n", + " b = np.minimum((ang / np.pi * bins).astype(int), bins - 1)\n", + " out = np.zeros((N // cell, N // cell, bins))\n", + " for i in range(N // cell):\n", + " for j in range(N // cell):\n", + " sl = (slice(i * cell, (i + 1) * cell), slice(j * cell, (j + 1) * cell))\n", + " out[i, j] = np.bincount(b[sl].ravel(), mag[sl].ravel(), bins)\n", + " return (out / (np.linalg.norm(out) + 1e-6)).ravel()\n", + "\n", + "\n", + "def shape_features(o, s):\n", + " \"\"\"객체 모양 특징: 가로 방향 투영으로 전체 길이·차체 길이·가는 돌출부(포신) 길이·두꺼운 부분(포탑) 위치를 잰다.\n", + " 방위 정규화 뒤에도 앞뒤(180도)가 뒤집힐 수 있으므로, 가는 돌출부가 오른쪽에 오도록 맞춘다.\"\"\"\n", + " sm = ndimage.uniform_filter(s, 3)\n", + " dark = np.abs(o - np.median(o))\n", + " m = (sm > 0.22) | (dark > 0.12)\n", + " lab, n = ndimage.label(m)\n", + " if n:\n", + " c = lab[10:22, 8:24]\n", + " k = np.bincount(c[c > 0]).argmax() if (c > 0).any() else 1\n", + " m = lab == k\n", + " p = m.sum(axis=0).astype(float)\n", + " cols = np.nonzero(p)[0]\n", + " if len(cols) == 0:\n", + " return np.zeros(8), o, s\n", + " thin = (p > 0) & (p <= 2)\n", + " left, right = thin[:N // 2].sum(), thin[N // 2:].sum()\n", + " if left > right:\n", + " o, s, m, p = o[:, ::-1], s[:, ::-1], m[:, ::-1], p[::-1]\n", + " cols = np.nonzero(p)[0]\n", + " body = np.nonzero(p > 3)[0]\n", + " d = np.where(m, dark if left <= right else dark[:, ::-1], 0)\n", + " heavy = d.sum(axis=0)\n", + " centre = (body[0] + body[-1]) / 2 if len(body) else N / 2\n", + " turret = (heavy * np.arange(N)).sum() / max(heavy.sum(), 1e-6) - centre\n", + " return np.array([cols[-1] - cols[0] + 1, len(body), ((p > 0) & (p <= 2)).sum(), p.max(), turret,\n", + " m.sum(), (cols[-1] - (body[-1] if len(body) else cols[-1])), m.sum() / max(len(cols) * p.max(), 1)]), o, s\n", + "\n", + "\n", + "def adv_features(o, s):\n", + " \"\"\"정밀 특징: 모양(길이·포신·포탑 위치) + HOG(윤곽) + SAR 반사 통계(위장막·구름 영향 적음) + 투영 윤곽.\"\"\"\n", + " shp, o, s = shape_features(o, s)\n", + " c = s[8:24, 4:28]\n", + " sm = ndimage.uniform_filter(s, 3)\n", + " m = sm > 0.2\n", + " dark = np.abs(o - np.median(o))\n", + " return np.concatenate([shp, hog(o), [c.mean(), c.max(), (sm > 0.25).mean(), (sm > 0.4).mean(), m.sum(axis=1).max(), m.sum(axis=0).max()],\n", + " sm.mean(axis=0).reshape(8, 4).mean(1), sm.mean(axis=1).reshape(8, 4).mean(1),\n", + " dark.mean(axis=0).reshape(8, 4).mean(1), dark.mean(axis=1).reshape(8, 4).mean(1),\n", + " [o.mean(), o.std(), dark.max(), (o > 0.65).mean(), (o < 0.3).mean()]])\n", + "\n", + "Q_NAMES = [\"max_prob\", \"margin\", \"entropy\", \"cloud_frac\", \"gsd\", \"off_nadir\", \"sun_el\", \"sar_mean\"]\n", + "\n", + "\n", + "def run(O, S, M, clf):\n", + " P = preprocess_batch(O, S, M)\n", + " X = np.array([adv_features(o, s) for o, s, _ in P])\n", + " pr = clf.predict_proba(X)\n", + " srt = np.sort(pr, axis=1)\n", + " ent = -(pr * np.log(np.clip(pr, 1e-9, 1))).sum(1) / np.log(pr.shape[1])\n", + " Q = np.c_[srt[:, -1], srt[:, -1] - srt[:, -2], ent, [p[2] for p in P], [m[\"gsd\"] for m in M],\n", + " [m[\"off_nadir\"] for m in M], [m[\"sun_el\"] for m in M], [p[1][8:24, 4:28].mean() for p in P]]\n", + " return clf.classes_[pr.argmax(1)], Q\n", + "\n", + "\n", + "def grade(c):\n", + " return np.where(c >= 0.9, \"High\", np.where(c >= 0.6, \"Medium\", \"Low\"))\n", + "\n", + "\n", + "ALL = tuple(SCEN)\n", + "Otr, Str, Mtr = make_dataset(1200, seed=3, scen=ALL)\n", + "clf = RandomForestClassifier(n_estimators=200, min_samples_leaf=2, random_state=0, n_jobs=1)\n", + "clf.fit(np.array([adv_features(o, s) for o, s, _ in preprocess_batch(Otr, Str, Mtr)]), [m[\"cls\"] for m in Mtr])\n", + "\n", + "# 1) 신뢰도 가중치 학습: 별도 보정 자료 600장에서 '분류가 맞았는가'를 품질 지표로 예측\n", + "Oc, Sc, Mc = make_dataset(600, seed=6, scen=ALL)\n", + "pc, Qc = run(Oc, Sc, Mc, clf)\n", + "conf_model = make_pipeline(StandardScaler(), LogisticRegression(max_iter=2000)).fit(Qc, pc == np.array([m[\"cls\"] for m in Mc]))\n", + "w = pd.Series(conf_model[-1].coef_[0], index=Q_NAMES).round(2)\n", + "print(\"[신뢰도 가중치(표준화 계수)]\", w.to_dict())\n", + "\n", + "# 2) 시험 800장: 기존(분류 결과 그대로 보고) vs 엔진(High만 자동 보고, 나머지는 판독 보류)\n", + "Ot, St, Mt = make_dataset(800, seed=5, scen=ALL)\n", + "pt, Qt = run(Ot, St, Mt, clf)\n", + "y = np.array([m[\"cls\"] for m in Mt])\n", + "conf = conf_model.predict_proba(Qt)[:, 1]\n", + "G = grade(conf)\n", + "wrong = pt != y\n", + "rep0, rep1 = np.isin(pt, list(MILITARY)), np.isin(pt, list(MILITARY)) & (G == \"High\")\n", + "far0, far1 = (wrong & rep0).sum() / rep0.sum(), (wrong & rep1).sum() / rep1.sum()\n", + "print(f\"\\n기존: 군용 보고 {rep0.sum()}건 중 오분류 {(wrong & rep0).sum()}건 (오분류율 {far0:.1%})\")\n", + "print(f\"엔진: 군용 자동 보고(High) {rep1.sum()}건 중 오분류 {(wrong & rep1).sum()}건 (오분류율 {far1:.1%}), 판독 보류(Medium·Low) {(rep0 & ~rep1).sum()}건\")\n", + "cut = 1 - far1 / far0\n", + "print(f\"오분류(오경보) 감축 {cut:.1%} (목표 40% 이상: {'달성' if cut >= 0.4 else '미달'}), ROC-AUC(신뢰도 vs 정답 여부) {roc_auc_score(~wrong, conf):.3f}\")\n", + "\n", + "# 3) 등급별 실제 정답률과 임계값 민감도\n", + "sc = np.array([m[\"scen\"] for m in Mt])\n", + "print(\"\\n등급 건수 실제 정답률 주요 조건\")\n", + "for g in [\"High\", \"Medium\", \"Low\"]:\n", + " s = G == g\n", + " print(f\"{g:6s} {s.sum():4d} {(~wrong[s]).mean() if s.any() else 0:9.1%} {pd.Series(sc[s]).value_counts().head(2).to_dict()}\")\n", + "print(\"\\nHigh 기준 자동보고 오분류율 판독 보류\")\n", + "for t in [0.8, 0.85, 0.9, 0.95]:\n", + " r = rep0 & (conf >= t)\n", + " print(f\" {t:.2f} {r.sum():6d} {(wrong & r).sum() / max(r.sum(), 1):6.1%} {(rep0 & ~r).sum():6d}\")\n", + "\n", + "# 4) 일관성: 같은 영상에 미세한 픽셀 잡음(+-1%)을 넣어도 같은 등급이 나오는 비율\n", + "rng = np.random.default_rng(0)\n", + "sub = np.arange(200)\n", + "_, Qn = run(Ot[sub] * (1 + rng.normal(0, 0.01, Ot[sub].shape)), St[sub], [Mt[i] for i in sub], clf)\n", + "same = (grade(conf_model.predict_proba(Qn)[:, 1]) == G[sub]).mean()\n", + "print(f\"\\n신뢰도 등급 산출 일관성 {same:.1%} (목표 90% 이상: {'달성' if same >= 0.9 else '미달'})\")\n", + "print(\"High도 최종 판독이 아니며, 고가치 표적은 미션 7의 영상분석관 판독을 거친다.\")\n", + "\n", + "fig, ax = plt.subplots(1, 2, figsize=(12, 4))\n", + "ax[0].hist([conf[~wrong], conf[wrong]], bins=20, range=(0, 1), label=[\"correct\", \"misclassified\"], stacked=True)\n", + "ax[0].axvline(0.9, ls=\"--\", c=\"k\"); ax[0].set_xlabel(\"confidence score\"); ax[0].legend(); ax[0].set_title(\"Confidence vs correctness\")\n", + "w.plot.barh(ax=ax[1], color=[\"tab:green\" if v > 0 else \"tab:red\" for v in w]); ax[1].set_title(\"Confidence weight matrix (std. coef)\")\n", + "plt.tight_layout(); plt.show()\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 05 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + "[신뢰도 가중치(표준화 계수)] {'max_prob': 0.69, 'margin': 0.81, 'entropy': -0.62, 'cloud_frac': 0.2, 'gsd': -0.06, 'off_nadir': -0.54, 'sun_el': -0.52, 'sar_mean': 0.24}\n", + "\n", + "기존: 군용 보고 600건 중 오분류 19건 (오분류율 3.2%)\n", + "엔진: 군용 자동 보고(High) 541건 중 오분류 3건 (오분류율 0.6%), 판독 보류(Medium·Low) 59건\n", + "오분류(오경보) 감축 82.5% (목표 40% 이상: 달성), ROC-AUC(신뢰도 vs 정답 여부) 0.961\n", + "\n", + "등급 건수 실제 정답률 주요 조건\n", + "High 741 99.6% {'occl50': 79, 'clean': 78}\n", + "Medium 38 84.2% {'low_res': 13, 'occl30': 5}\n", + "Low 21 52.4% {'low_res': 9, 'speckle': 4}\n", + "\n", + "High 기준 자동보고 오분류율 판독 보류\n", + " 0.80 555 1.1% 45\n", + " 0.85 552 0.9% 48\n", + " 0.90 541 0.6% 59\n", + " 0.95 521 0.6% 79\n", + "\n", + "신뢰도 등급 산출 일관성 98.5% (목표 90% 이상: 달성)\n", + "High도 최종 판독이 아니며, 고가치 표적은 미션 7의 영상분석관 판독을 거친다.\n", + "```\n", + "
\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 06. 위성영상 표적 탐지 위치 및 식별 확률 지도(Heatmap) 가시화 UI 구현\n", + "**소요시간**: 약 2.5시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "식별된 장비의 위치, 기종, 신뢰도 및 집결지 밀집도를 지도상에 오버레이 표시하는 위성 ISR 영상분석 인터페이스를 구현한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **식별된 장비의 위치·기종·식별 확률과 집결지 밀집도를 지도에 겹쳐 보여 주는 위성 ISR 영상분석 화면을 구현한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **가상 광역 영상(타일 10x10)을 한 줄씩 받아 판독하는 갱신 흐름을 모의하고 처리시간을 잰다 과정을 진행하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `src/ui/isr01_dashboard.py (또는 웹 UI 컴포넌트)` 형태의 결과물을 완성한다.\n", + "\n", + "### 🔍 평가 및 검증 기준 (PASS 조건)\n", + "#### 미션 판정\n", + "\n", + "**평가 구분:** 필수\n", + "\n", + "**핵심 평가영역:** 전술 작전 상황판으로서의 직관성 및 인지 부하 감소도\n", + "\n", + "#### 미션 PASS 조건\n", + "\n", + "- 정량 기준: 화면 갱신 주기 1초 이내, UI 응답성 100ms 이내\n", + "\n", + "\n", + "- 진행 기준: 주요 지표 시각화 컴포넌트 4종 이상 렌더링 및 실시간 데이터 연동\n", + "\n", + "\n", + "- 검증 방법: 사용자 인터랙션 테스트 및 모의 데이터 스트림 렌더링 검증\n", + "\n", + "\n", + "#### 채점자 판정 메모\n", + "\n", + "> 단순 코드 실행 여부뿐만 아니라 출력된 수치와 근거 자료가 군 실무 요구사항에 부합하는지 면밀히 확인한다.\n", + "\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "\"\"\"가상 광역 위성영상(32x32 타일 10x10)을 타일 단위로 판독해 식별 장비의 위치·기종·식별 확률을 지도에 겹쳐 표시하고, 군용 장비 집결지 밀집도(Heatmap)와 기종별 현황을 함께 보여 주는 위성 ISR 영상분석 화면을 구현하며 갱신 처리시간과 렌더링 시간을 측정한다.\"\"\"\n", + "import time\n", + "import numpy as np\n", + "import pandas as pd\n", + "import matplotlib.pyplot as plt\n", + "from scipy import ndimage\n", + "from sklearn.ensemble import RandomForestClassifier\n", + "\n", + "# ---------------------------------------------------------------------------\n", + "# [교육용가상자료] 합성 위성영상 조각(chip) 생성기 (실제 위성영상·장비 제원·위치 없음)\n", + "# 32x32 광학(EO) 영상과 SAR 영상 한 쌍에 추상 도형으로 만든 객체를 그린다. 도형 치수는 가상값이다.\n", + "# ---------------------------------------------------------------------------\n", + "N = 32\n", + "STD_GSD = 0.5 # 표준 해상도(m/픽셀, 가상 기준)\n", + "CLASSES = [\"tank\", \"spg\", \"truck\", \"civil\"]\n", + "MILITARY = {\"tank\", \"spg\", \"truck\"}\n", + "# 객체 좌표계(u: 길이 방향, v: 폭 방향, 표준 해상도 픽셀) 가상 도형: (u중심, v중심, 길이, 폭, 광학 밝기 차이)\n", + "SHAPES = {\n", + " \"tank\": [(0, 0, 11, 7, -40), (0.5, 0, 5, 5, -55), (6, 0, 7, 1.2, -60)],\n", + " \"spg\": [(0, 0, 16, 7.5, -38), (-4, 0, 6, 6.5, -52), (5, 0, 13, 1.4, -60)],\n", + " \"truck\": [(5, 0, 3.5, 5.5, -45), (-1.8, 0, 8.5, 6, -25)],\n", + " \"civil\": [(0, 0, 8, 3.8, 0), (1.2, 0, 2, 3.4, 0)],\n", + " \"decoy\": [(0, 0, 11, 7, -40), (0.5, 0, 5, 5, -42), (6, 0, 7, 1.2, -45)],\n", + " \"none\": [], # 객체 없는 배경 타일\n", + "}\n", + "SAR_RETURN = {\"tank\": 0.85, \"spg\": 0.85, \"truck\": 0.7, \"civil\": 0.45, \"decoy\": 0.18, \"none\": 0.12} # 금속 반사 세기(가상)\n", + "\n", + "\n", + "def _mask(cls, angle, scale, squash=1.0):\n", + " \"\"\"회전(angle, 도)·축척(scale)·비스듬 촬영 압축(squash)을 적용해 도형별 마스크와 밝기 차이를 만든다.\"\"\"\n", + " yy, xx = np.mgrid[0:N, 0:N] - (N - 1) / 2\n", + " yy = yy / squash\n", + " a = np.radians(angle)\n", + " u = (xx * np.cos(a) + yy * np.sin(a)) / scale\n", + " v = (-xx * np.sin(a) + yy * np.cos(a)) / scale\n", + " return [((np.abs(u - cu) <= L / 2) & (np.abs(v - cv) <= W / 2), d) for cu, cv, L, W, d in SHAPES[cls]]\n", + "\n", + "\n", + "def make_chip(cls, rng, cond=None):\n", + " \"\"\"chip 1개 = (광학 32x32, SAR 32x32, 메타데이터). cond: camo, occl, cloud, low_sun, off_nadir, low_res, speckle\"\"\"\n", + " c = {\"camo\": 0.0, \"occl\": 0.0, \"cloud\": 0.0, \"low_sun\": False, \"off_nadir\": 0.0, \"low_res\": False, \"speckle\": 1.0, **(cond or {})}\n", + " gsd = rng.uniform(0.9, 1.2) if c[\"low_res\"] else rng.uniform(0.4, 0.6)\n", + " scale = STD_GSD / gsd\n", + " angle, off = rng.uniform(0, 360), c[\"off_nadir\"] or rng.uniform(0, 15)\n", + " squash = np.cos(np.radians(off))\n", + " sun_el = rng.uniform(12, 22) if c[\"low_sun\"] else rng.uniform(40, 70)\n", + " sun_az = rng.uniform(0, 360)\n", + " bg = 115 + ndimage.gaussian_filter(rng.normal(0, 30, (N, N)), 2) + rng.normal(0, 5, (N, N))\n", + " parts = _mask(cls, angle, scale, squash)\n", + " body = np.any([m for m, _ in parts], axis=0) if parts else np.zeros((N, N), bool)\n", + " opt = bg.copy()\n", + " # 그림자: 태양 반대 방향으로 밀린 객체 모양(태양 고도가 낮을수록 길다)\n", + " L = min(8.0, 2.5 / np.tan(np.radians(sun_el))) * scale\n", + " sh = ndimage.shift(body.astype(float), (L * np.cos(np.radians(sun_az)), -L * np.sin(np.radians(sun_az))), order=0) > 0.5\n", + " opt[sh & ~body] -= 35 if cls != \"decoy\" else 25\n", + " if cls == \"civil\":\n", + " base = rng.choice([200.0, 60.0]) # 민간 차량 색상: 밝거나 어두움\n", + " opt[body] = base + rng.normal(0, 8, body.sum())\n", + " roof = parts[1][0]\n", + " opt[roof] = base * 0.8\n", + " else:\n", + " ref = bg[body].mean() if body.any() else bg.mean()\n", + " for m, d in parts:\n", + " opt[m] = ref + d + rng.normal(0, 4 if cls != \"decoy\" else 2, m.sum())\n", + " # 위장막: 객체 주변을 배경 질감으로 덮어 대비를 낮춘다\n", + " if c[\"camo\"] > 0 and cls != \"civil\":\n", + " net = ndimage.binary_dilation(body, iterations=2)\n", + " tex = 105 + ndimage.gaussian_filter(rng.normal(0, 25, (N, N)), 1)\n", + " opt[net] = (1 - c[\"camo\"]) * opt[net] + c[\"camo\"] * tex[net]\n", + " # 수목 차폐: 객체의 일부를 어두운 나무 덩어리로 가린다\n", + " if c[\"occl\"] > 0:\n", + " trees = ndimage.gaussian_filter(rng.random((N, N)), 2)\n", + " near = ndimage.binary_dilation(body, iterations=3)\n", + " cover = near & (trees > np.quantile(trees[body], 1 - c[\"occl\"])) if body.any() else np.zeros((N, N), bool)\n", + " opt[cover] = 70 + rng.normal(0, 6, cover.sum())\n", + " # 구름: 밝고 매끈한 덩어리(광학만 가림, SAR는 통과)\n", + " cmask = np.zeros((N, N), bool)\n", + " if c[\"cloud\"] > 0:\n", + " blob = ndimage.gaussian_filter(rng.random((N, N)), 4)\n", + " cmask = blob > np.quantile(blob, 1 - c[\"cloud\"])\n", + " opt[cmask] = 0.25 * opt[cmask] + 0.75 * (235 + rng.normal(0, 3, cmask.sum()))\n", + " # SAR: 곱셈형 스페클 잡음 + 금속 반사(모서리 이중 반사로 밝은 점)\n", + " sar = np.full((N, N), 0.12)\n", + " sar[body] = SAR_RETURN[cls] * rng.uniform(0.8, 1.2)\n", + " edge = body & ~ndimage.binary_erosion(body)\n", + " sar[edge] *= 1.3 if cls in MILITARY else 1.0\n", + " sar = sar * rng.gamma(4 / c[\"speckle\"], c[\"speckle\"] / 4, (N, N))\n", + " if c[\"low_res\"]: # 저해상도: 정보 손실(블러)\n", + " down = lambda a: np.kron(a.reshape(N // 2, 2, N // 2, 2).mean((1, 3)), np.ones((2, 2)))\n", + " opt, sar = ndimage.gaussian_filter(down(opt), 0.7), ndimage.gaussian_filter(down(sar), 0.7)\n", + " meta = {\"cls\": cls, \"gsd\": round(float(gsd), 2), \"off_nadir\": round(float(off), 1), \"sun_el\": round(float(sun_el), 1),\n", + " \"sun_az\": round(float(sun_az), 1), \"angle\": round(float(angle), 1), \"cloud_frac\": float(cmask.mean()),\n", + " \"cloud_on_obj\": float((cmask & body).sum() / max(body.sum(), 1)), **{k: c[k] for k in (\"camo\", \"occl\", \"low_sun\", \"low_res\")}}\n", + " return np.clip(opt, 0, 255), sar, meta\n", + "\n", + "\n", + "SCEN = { # 은폐·촬영 조건 시나리오 10종\n", + " \"clean\": {}, \"camo\": {\"camo\": 0.7}, \"occl30\": {\"occl\": 0.3}, \"occl50\": {\"occl\": 0.5}, \"cloud\": {\"cloud\": 0.25},\n", + " \"low_sun\": {\"low_sun\": True}, \"off_nadir\": {\"off_nadir\": 35.0}, \"low_res\": {\"low_res\": True},\n", + " \"speckle\": {\"speckle\": 3.0}, \"camo_occl\": {\"camo\": 0.6, \"occl\": 0.3}}\n", + "\n", + "\n", + "def make_dataset(n=400, seed=7, scen=(\"clean\",), classes=CLASSES):\n", + " rng = np.random.default_rng(seed)\n", + " O, S, M = np.zeros((n, N, N)), np.zeros((n, N, N)), []\n", + " for i in range(n):\n", + " cls, sc = classes[i % len(classes)], scen[(i // len(classes)) % len(scen)]\n", + " O[i], S[i], m = make_chip(cls, rng, SCEN[sc])\n", + " m.update({\"id\": f\"IMG{i:04d}\", \"scen\": sc})\n", + " M.append(m)\n", + " return O, S, M\n", + "# ---------------------------------------------------------------------------\n", + "\n", + "# ---- 전처리(해상도·기하 보정, 구름 차폐 처리, 방위 정규화) 공통 함수 ----\n", + "def _fit(a, fill):\n", + " \"\"\"중앙 기준으로 32x32에 맞게 자르거나 채운다.\"\"\"\n", + " out = np.full((N, N), fill, float)\n", + " h, w = a.shape\n", + " ys, xs = max(0, (h - N) // 2), max(0, (w - N) // 2)\n", + " yd, xd = max(0, (N - h) // 2), max(0, (N - w) // 2)\n", + " hh, ww = min(N, h), min(N, w)\n", + " out[yd:yd + hh, xd:xd + ww] = a[ys:ys + hh, xs:xs + ww]\n", + " return out\n", + "\n", + "\n", + "def cloud_mask(opt, sm, min_area=15):\n", + " \"\"\"광학에서 밝고(185 초과) 매끈한데 SAR에는 반사가 없는 영역을 구름으로 본다(SAR는 구름을 통과하므로 밝은 차량과 구분된다).\"\"\"\n", + " bright = opt > 185\n", + " if bright.sum() < min_area:\n", + " return np.zeros_like(bright)\n", + " local_sd = np.sqrt(np.maximum(ndimage.uniform_filter(opt ** 2, 3) - ndimage.uniform_filter(opt, 3) ** 2, 0))\n", + " cand = bright & (local_sd < 12) & (sm < 0.3)\n", + " lab, n = ndimage.label(cand)\n", + " if n == 0:\n", + " return cand\n", + " sizes = ndimage.sum(cand.astype(float), lab, range(1, n + 1))\n", + " return ndimage.binary_dilation(np.isin(lab, 1 + np.where(sizes >= min_area)[0]), iterations=2) & (opt > 140)\n", + "\n", + "\n", + "def orientation(opt, sm):\n", + " \"\"\"SAR 반사(위장·구름 영향 적음, sm은 평활화한 SAR)와 광학 대비를 합친 객체 마스크의 주축 방향(도).\"\"\"\n", + " m = (sm > 0.3) | (np.abs(opt - opt.mean()) > 30)\n", + " lab, n = ndimage.label(m)\n", + " if n == 0:\n", + " return 0.0, m\n", + " c = lab[N // 2 - 6:N // 2 + 6, N // 2 - 6:N // 2 + 6]\n", + " k = np.bincount(c[c > 0]).argmax() if (c > 0).any() else 1 + int(np.argmax(ndimage.sum(m.astype(float), lab, range(1, n + 1))))\n", + " m = lab == k\n", + " yy, xx = np.nonzero(m)\n", + " if len(yy) < 5:\n", + " return 0.0, m\n", + " dy, dx = yy - yy.mean(), xx - xx.mean() # 2차 모멘트로 주축 각도 계산\n", + " return float(np.degrees(0.5 * np.arctan2(2 * (dx * dy).mean(), (dx * dx).mean() - (dy * dy).mean()))), m\n", + "\n", + "\n", + "_YY, _XX = np.mgrid[0:N, 0:N] - (N - 1) / 2\n", + "\n", + "\n", + "def warp(stack, zy, zx, ang):\n", + " \"\"\"여러 장(B,채널,32,32)에 장별 확대·축소(zy, zx)와 회전(ang, 도)을 한 번의 쌍선형 보간으로 적용한다(벡터 연산).\"\"\"\n", + " a = np.radians(ang)[:, None, None]\n", + " zy, zx = np.asarray(zy)[:, None, None], np.asarray(zx)[:, None, None]\n", + " sy = np.clip((np.cos(a) * _YY + np.sin(a) * _XX) / zy + (N - 1) / 2, 0, N - 1.001) # 출력 좌표 -> 원본 좌표\n", + " sx = np.clip((-np.sin(a) * _YY + np.cos(a) * _XX) / zx + (N - 1) / 2, 0, N - 1.001)\n", + " y0, x0 = sy.astype(int), sx.astype(int)\n", + " fy, fx = (sy - y0).reshape(len(stack), 1, -1), (sx - x0).reshape(len(stack), 1, -1)\n", + " flat = stack.reshape(len(stack), stack.shape[1], -1)\n", + " lin = (y0 * N + x0).reshape(len(stack), 1, -1)\n", + " g = lambda d: np.take_along_axis(flat, np.broadcast_to(lin + d, (len(stack), stack.shape[1], lin.shape[2])), axis=2)\n", + " out = g(0) * (1 - fy) * (1 - fx) + g(N) * fy * (1 - fx) + g(1) * (1 - fy) * fx + g(N + 1) * fy * fx\n", + " return out.reshape(stack.shape)\n", + "\n", + "\n", + "def preprocess_batch(O, S, M):\n", + " \"\"\"여러 장을 한 번에 전처리한다. 0) 결측 보정 1) 해상도·비스듬 촬영 보정(표준 0.5m/픽셀)\n", + " 2) 구름 차폐 영역을 배경값으로 채움 3) 객체 주축을 가로로 회전 4) 0~1 정규화.\"\"\"\n", + " src = np.stack([np.asarray(O, float), np.asarray(S, float)], axis=1)\n", + " if np.isnan(src).any(): # 0) 센서 결측 픽셀 보정\n", + " mean = np.nanmean(src, axis=(2, 3), keepdims=True)\n", + " src = np.where(np.isnan(src), mean, src)\n", + " zy = np.array([m[\"gsd\"] / STD_GSD / np.cos(np.radians(m[\"off_nadir\"])) for m in M])\n", + " zx = np.array([m[\"gsd\"] / STD_GSD for m in M])\n", + " out = warp(src, zy, zx, np.zeros(len(M)))\n", + " sm = ndimage.uniform_filter(out[:, 1], (1, 3, 3))\n", + " angs, cloud = np.zeros(len(M)), np.zeros(len(M))\n", + " for i in range(len(M)):\n", + " cm = cloud_mask(out[i, 0], sm[i])\n", + " if cm.any():\n", + " fill = out[i, 0][~cm].mean() if (~cm).any() else 115.0\n", + " out[i, 0][cm] = fill\n", + " back = warp(cm[None, None].astype(float), [1 / zy[i]], [1 / zx[i]], np.zeros(1))[0, 0] > 0.5\n", + " src[i, 0][back] = fill # 원본의 같은 구름 영역도 채움\n", + " cloud[i] = cm.mean()\n", + " angs[i] = orientation(out[i, 0], sm[i])[0]\n", + " rot = np.abs(angs) > 3 # 3도 이하는 회전 생략\n", + " if rot.any():\n", + " out[rot] = warp(src[rot], zy[rot], zx[rot], angs[rot])\n", + " return [(np.clip(o / 255, 0, 1), np.clip(s / 1.5, 0, 1), float(c)) for (o, s), c in zip(out, cloud)]\n", + "\n", + "\n", + "def preprocess(opt, sar, meta):\n", + " \"\"\"한 장 전처리(preprocess_batch와 같은 처리).\"\"\"\n", + " return preprocess_batch([opt], [sar], [meta])[0]\n", + "\n", + "# ---- 특징 추출 공통 함수 ----\n", + "KERNELS = [np.array(k, float) for k in (\n", + " [[-1, 0, 1], [-2, 0, 2], [-1, 0, 1]], [[-1, -2, -1], [0, 0, 0], [1, 2, 1]], [[0, 1, 0], [1, -4, 1], [0, 1, 0]],\n", + " [[2, -1, -1], [-1, 2, -1], [-1, -1, 2]], [[-1, -1, 2], [-1, 2, -1], [2, -1, -1]], [[1, 1, 1], [1, 1, 1], [1, 1, 1]])]\n", + "\n", + "\n", + "def conv_features(o):\n", + " \"\"\"간이 CNN 특징: 고정 합성곱 필터 6종 -> ReLU -> 4x4 최대 풀링(8x8 출력).\"\"\"\n", + " feats = []\n", + " for k in KERNELS:\n", + " r = np.maximum(ndimage.correlate(o - o.mean(), k / np.abs(k).sum()), 0)\n", + " feats.append(r.reshape(8, 4, 8, 4).max(axis=(1, 3)).ravel())\n", + " feats.append(np.maximum(-ndimage.correlate(o - o.mean(), k / np.abs(k).sum()), 0).reshape(8, 4, 8, 4).max(axis=(1, 3)).ravel())\n", + " return np.concatenate(feats)\n", + "\n", + "\n", + "def hog(o, cell=8, bins=8):\n", + " gy, gx = np.gradient(o)\n", + " mag, ang = np.hypot(gx, gy), np.mod(np.arctan2(gy, gx), np.pi)\n", + " b = np.minimum((ang / np.pi * bins).astype(int), bins - 1)\n", + " out = np.zeros((N // cell, N // cell, bins))\n", + " for i in range(N // cell):\n", + " for j in range(N // cell):\n", + " sl = (slice(i * cell, (i + 1) * cell), slice(j * cell, (j + 1) * cell))\n", + " out[i, j] = np.bincount(b[sl].ravel(), mag[sl].ravel(), bins)\n", + " return (out / (np.linalg.norm(out) + 1e-6)).ravel()\n", + "\n", + "\n", + "def shape_features(o, s):\n", + " \"\"\"객체 모양 특징: 가로 방향 투영으로 전체 길이·차체 길이·가는 돌출부(포신) 길이·두꺼운 부분(포탑) 위치를 잰다.\n", + " 방위 정규화 뒤에도 앞뒤(180도)가 뒤집힐 수 있으므로, 가는 돌출부가 오른쪽에 오도록 맞춘다.\"\"\"\n", + " sm = ndimage.uniform_filter(s, 3)\n", + " dark = np.abs(o - np.median(o))\n", + " m = (sm > 0.22) | (dark > 0.12)\n", + " lab, n = ndimage.label(m)\n", + " if n:\n", + " c = lab[10:22, 8:24]\n", + " k = np.bincount(c[c > 0]).argmax() if (c > 0).any() else 1\n", + " m = lab == k\n", + " p = m.sum(axis=0).astype(float)\n", + " cols = np.nonzero(p)[0]\n", + " if len(cols) == 0:\n", + " return np.zeros(8), o, s\n", + " thin = (p > 0) & (p <= 2)\n", + " left, right = thin[:N // 2].sum(), thin[N // 2:].sum()\n", + " if left > right:\n", + " o, s, m, p = o[:, ::-1], s[:, ::-1], m[:, ::-1], p[::-1]\n", + " cols = np.nonzero(p)[0]\n", + " body = np.nonzero(p > 3)[0]\n", + " d = np.where(m, dark if left <= right else dark[:, ::-1], 0)\n", + " heavy = d.sum(axis=0)\n", + " centre = (body[0] + body[-1]) / 2 if len(body) else N / 2\n", + " turret = (heavy * np.arange(N)).sum() / max(heavy.sum(), 1e-6) - centre\n", + " return np.array([cols[-1] - cols[0] + 1, len(body), ((p > 0) & (p <= 2)).sum(), p.max(), turret,\n", + " m.sum(), (cols[-1] - (body[-1] if len(body) else cols[-1])), m.sum() / max(len(cols) * p.max(), 1)]), o, s\n", + "\n", + "\n", + "def adv_features(o, s):\n", + " \"\"\"정밀 특징: 모양(길이·포신·포탑 위치) + HOG(윤곽) + SAR 반사 통계(위장막·구름 영향 적음) + 투영 윤곽.\"\"\"\n", + " shp, o, s = shape_features(o, s)\n", + " c = s[8:24, 4:28]\n", + " sm = ndimage.uniform_filter(s, 3)\n", + " m = sm > 0.2\n", + " dark = np.abs(o - np.median(o))\n", + " return np.concatenate([shp, hog(o), [c.mean(), c.max(), (sm > 0.25).mean(), (sm > 0.4).mean(), m.sum(axis=1).max(), m.sum(axis=0).max()],\n", + " sm.mean(axis=0).reshape(8, 4).mean(1), sm.mean(axis=1).reshape(8, 4).mean(1),\n", + " dark.mean(axis=0).reshape(8, 4).mean(1), dark.mean(axis=1).reshape(8, 4).mean(1),\n", + " [o.mean(), o.std(), dark.max(), (o > 0.65).mean(), (o < 0.3).mean()]])\n", + "\n", + "T = 10 # 10x10 타일 = 320x320 픽셀 가상 장면\n", + "rng = np.random.default_rng(12)\n", + "# [교육용가상자료] 장면 구성: 가상 집결지 2곳(군용), 가상 도로(민간 차량), 나머지는 빈 타일\n", + "layout = np.full((T, T), \"none\", dtype=object)\n", + "for (r0, c0), n in [((1, 1), 9), ((6, 6), 7)]:\n", + " for _ in range(n):\n", + " layout[r0 + rng.integers(0, 3), c0 + rng.integers(0, 3)] = rng.choice([\"tank\", \"spg\", \"truck\"], p=[.4, .3, .3])\n", + "for c in rng.choice(T, 5, replace=False):\n", + " layout[4, c] = \"civil\"\n", + "scen = rng.choice([\"clean\", \"camo\", \"occl30\", \"cloud\", \"low_sun\"], (T, T), p=[.4, .2, .15, .15, .1])\n", + "tiles = {}\n", + "for r in range(T):\n", + " for c in range(T):\n", + " o, s, m = make_chip(layout[r, c], rng, SCEN[scen[r, c]])\n", + " tiles[(r, c)] = (o, s, m)\n", + "\n", + "# 판독 모델(이 파일 안에서 학습: 빈 타일 포함 5개 클래스)\n", + "Otr, Str, Mtr = make_dataset(1000, seed=3, scen=tuple(SCEN), classes=CLASSES + [\"none\"])\n", + "clf = RandomForestClassifier(n_estimators=150, min_samples_leaf=2, random_state=0, n_jobs=1)\n", + "clf.fit(np.array([adv_features(o, s) for o, s, _ in preprocess_batch(Otr, Str, Mtr)]), [m[\"cls\"] for m in Mtr])\n", + "\n", + "# 1) 1초 주기 갱신 모의: 매 갱신마다 새로 들어온 타일 한 줄(10장)을 판독해 화면 자료 갱신\n", + "dets, upd = [], []\n", + "for r in range(T):\n", + " t0 = time.perf_counter()\n", + " row = [tiles[(r, c)] for c in range(T)]\n", + " X = np.array([adv_features(o, s) for o, s, _ in preprocess_batch(*zip(*row))])\n", + " P = clf.predict_proba(X) # 한 줄을 묶어서 판독(호출 횟수 감소)\n", + " for c, p in enumerate(P):\n", + " k = int(p.argmax())\n", + " if clf.classes_[k] != \"none\":\n", + " dets.append({\"타일\": f\"R{r}C{c}\", \"x\": c * N + N / 2, \"y\": r * N + N / 2, \"기종(추정)\": clf.classes_[k],\n", + " \"식별확률\": round(float(p[k]), 2), \"실제\": layout[r, c], \"조건\": scen[r, c]})\n", + " upd.append((time.perf_counter() - t0) * 1000)\n", + "D = pd.DataFrame(dets)\n", + "print(f\"타일 한 줄(10장) 갱신 처리 평균 {np.mean(upd):.0f} ms, 최대 {np.max(upd):.0f} ms (목표: 갱신 1초 이내, 처리 응답 100 ms 이내)\")\n", + "print(f\"식별 {len(D)}건 / 실제 객체 {int((layout != 'none').sum())}건, 기종 일치 {int((D['기종(추정)'] == D['실제']).sum())}건\")\n", + "print(\"\\n[식별 목록: 식별확률 낮은 순(분석관 우선 확인)]\")\n", + "print(D.sort_values(\"식별확률\")[[\"타일\", \"기종(추정)\", \"식별확률\", \"조건\"]].head(8).to_string(index=False))\n", + "\n", + "# 2) 집결지 밀집도: 군용 식별 위치를 식별확률로 가중해 가우시안 평활\n", + "mil = D[D[\"기종(추정)\"].isin(list(MILITARY))]\n", + "grid = np.zeros((T * N, T * N))\n", + "for _, d in mil.iterrows():\n", + " grid[int(d[\"y\"]), int(d[\"x\"])] += d[\"식별확률\"]\n", + "heat = ndimage.gaussian_filter(grid, 24)\n", + "peak = np.unravel_index(heat.argmax(), heat.shape)\n", + "print(f\"\\n최대 밀집 지점(가상 픽셀 좌표) x={peak[1]}, y={peak[0]}, 군용 식별 {len(mil)}건\")\n", + "\n", + "# 3) 분석 화면(야간 작전용 어두운 화면, 4개 구성요소)\n", + "scene = np.block([[tiles[(r, c)][0] for c in range(T)] for r in range(T)])\n", + "plt.style.use(\"dark_background\")\n", + "fig, ax = plt.subplots(2, 2, figsize=(13, 11))\n", + "mk = {\"tank\": \"s\", \"spg\": \"D\", \"truck\": \"^\", \"civil\": \"o\"}\n", + "ax[0, 0].imshow(scene, cmap=\"gray\")\n", + "for k, g in D.groupby(\"기종(추정)\"):\n", + " ax[0, 0].scatter(g[\"x\"], g[\"y\"], s=40 + 200 * g[\"식별확률\"], marker=mk[k], facecolors=\"none\",\n", + " edgecolors=\"red\" if k in MILITARY else \"deepskyblue\", lw=1.5, label=k)\n", + "ax[0, 0].legend(fontsize=8, loc=\"lower right\"); ax[0, 0].set_title(\"Detections (marker size = probability)\"); ax[0, 0].axis(\"off\")\n", + "ax[0, 1].imshow(scene, cmap=\"gray\"); ax[0, 1].imshow(heat, cmap=\"inferno\", alpha=0.6); ax[0, 1].set_title(\"Military vehicle density (heatmap)\"); ax[0, 1].axis(\"off\")\n", + "vc = D[\"기종(추정)\"].value_counts()\n", + "ax[1, 0].bar(vc.index.tolist(), vc.values, color=\"deepskyblue\"); ax[1, 0].set_title(\"Detections by type\")\n", + "ax[1, 1].hist(D[\"식별확률\"], bins=10, range=(0, 1), color=\"orange\"); ax[1, 1].axvline(0.6, color=\"red\", ls=\"--\")\n", + "ax[1, 1].set_title(\"Identification probability (red line = review threshold)\")\n", + "fig.suptitle(\"ISR01 satellite imagery analysis view - synthetic training data\")\n", + "t0 = time.perf_counter(); fig.canvas.draw(); draw_ms = (time.perf_counter() - t0) * 1000\n", + "plt.tight_layout(); plt.show()\n", + "print(f\"화면 렌더링 {draw_ms:.0f} ms (갱신 주기 1초 이내: {'달성' if draw_ms < 1000 else '미달'}), 시각화 구성요소 4종(식별 오버레이·밀집도·기종 현황·식별확률 분포)\")\n", + "print(\"표시된 기종·밀집도는 판독 보조 정보이며, 고가치 표적은 영상분석관의 최종 판독(미션 7)을 거친다.\")\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 06 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + "타일 한 줄(10장) 갱신 처리 평균 33 ms, 최대 45 ms (목표: 갱신 1초 이내, 처리 응답 100 ms 이내)\n", + "식별 17건 / 실제 객체 17건, 기종 일치 17건\n", + "\n", + "[식별 목록: 식별확률 낮은 순(분석관 우선 확인)]\n", + " 타일 기종(추정) 식별확률 조건\n", + "R1C1 tank 0.80 camo\n", + "R2C2 tank 0.87 clean\n", + "R8C8 tank 0.90 clean\n", + "R3C3 spg 0.92 camo\n", + "R7C6 tank 0.94 occl30\n", + "R3C1 tank 0.94 cloud\n", + "R8C7 truck 0.95 clean\n", + "R4C0 civil 0.95 clean\n", + "\n", + "최대 밀집 지점(가상 픽셀 좌표) x=68, y=68, 군용 식별 12건\n", + "화면 렌더링 201 ms (갱신 주기 1초 이내: 달성), 시각화 구성요소 4종(식별 오버레이·밀집도·기종 현황·식별확률 분포)\n", + "표시된 기종·밀집도는 판독 보조 정보이며, 고가치 표적은 영상분석관의 최종 판독(미션 7)을 거친다.\n", + "```\n", + "
\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 07. 고가치 표적(HVT) 식별 시 영상분석관 최종 판독(HITL) 및 교차 검증 절차 설계\n", + "**소요시간**: 약 3.0시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "고가치 군용 장비 식별 시 영상 판독관이 고해상도 확대 및 교차 검증을 통해 최종 승인하는 HITL 판독 워크플로우를 설계한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **고가치 표적(HVT) 후보가 식별되면 영상분석관이 고해상도 확대 판독과 교차 검증을 거쳐 최종 승인하는 HITL 판독 워크플로우를 설계한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **모델 식별 결과는 모두 판독 대기로 등록하고 HVT 후보를 우선 정렬 수행하고 검증하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `src/hitl/isr01_approval_workflow.py 및 docs/isr01_hitl_guide.md` 형태의 결과물을 완성한다.\n", + "\n", + "### 🔍 평가 및 검증 기준 (PASS 조건)\n", + "#### 미션 판정\n", + "\n", + "**평가 구분:** 필수\n", + "\n", + "**핵심 평가영역:** 지휘 책임 분계선 명확화 및 성급한 자동 단정 방지 체계 부합성\n", + "\n", + "#### 미션 PASS 조건\n", + "\n", + "- 정량 기준: 인간 검토 큐(Queue) 대기시간 및 승인/반려 처리 로깅 성공률 100%\n", + "\n", + "\n", + "- 진행 기준: 승인/반려 인터페이스 동작 및 감사 로그(Audit Log) 생성 검증\n", + "\n", + "\n", + "- 검증 방법: 모의 조작자 승인/반려 시뮬레이션 및 데이터 무결성 검증\n", + "\n", + "\n", + "#### 채점자 판정 메모\n", + "\n", + "> 단순 코드 실행 여부뿐만 아니라 출력된 수치와 근거 자료가 군 실무 요구사항에 부합하는지 면밀히 확인한다.\n", + "\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "\"\"\"고가치 표적(HVT) 후보 장비가 식별되면 영상분석관이 고해상도 확대 판독과 다른 출처(SAR·다른 촬영 회차) 교차 검증을 거쳐 최종 승인·보류·반려하는 인간 개입(HITL) 판독 워크플로우를 구현한다. HVT는 2인 판독이 필요하며 모든 결정은 위·변조 확인이 가능한 감사 로그에 남는다.\"\"\"\n", + "import hashlib\n", + "import json\n", + "import time\n", + "import pandas as pd\n", + "\n", + "ACTIONS = {\"APPROVE\": \"판독 승인(정보 보고)\", \"HOLD\": \"보류(추가 촬영·출처 필요)\", \"REJECT\": \"반려(오식별·모의 표적)\"}\n", + "PRIORITY = {\"High\": 0, \"Medium\": 1, \"Low\": 2}\n", + "HVT_TYPES = {\"spg\"} # 교육용 가정: 자주포 식별을 고가치 표적 후보로 취급\n", + "HOLD_LIMIT_S = 3600\n", + "\n", + "\n", + "class WorkflowError(ValueError):\n", + " pass\n", + "\n", + "\n", + "class ReadingQueue:\n", + " def __init__(self, clock=time.time):\n", + " self.items, self.log, self.clock = {}, [], clock\n", + "\n", + " def _record(self, body):\n", + " prev = self.log[-1][\"hash\"] if self.log else \"GENESIS\"\n", + " body = {**body, \"ts\": round(self.clock(), 3)}\n", + " h = hashlib.sha256((prev + json.dumps(body, ensure_ascii=False, sort_keys=True)).encode()).hexdigest()\n", + " self.log.append({**body, \"prev\": prev, \"hash\": h})\n", + "\n", + " def verify_log(self):\n", + " prev = \"GENESIS\"\n", + " for n, rec in enumerate(self.log, start=1):\n", + " body = {k: v for k, v in rec.items() if k not in (\"prev\", \"hash\")}\n", + " if rec[\"prev\"] != prev or rec[\"hash\"] != hashlib.sha256((prev + json.dumps(body, ensure_ascii=False, sort_keys=True)).encode()).hexdigest():\n", + " return False, n\n", + " prev = rec[\"hash\"]\n", + " return True, -1\n", + "\n", + " def submit(self, iid, pred, grade, conf):\n", + " \"\"\"모델 식별 결과 등록: 항상 PENDING. 기종이 HVT 후보면 2인 판독 대상으로 표시한다.\"\"\"\n", + " self.items[iid] = {\"id\": iid, \"pred\": pred, \"grade\": grade, \"conf\": conf, \"hvt\": pred in HVT_TYPES, \"status\": \"PENDING\",\n", + " \"submitted\": self.clock(), \"zoom\": False, \"cross\": None, \"first\": None, \"escalated\": False, \"hold_since\": None}\n", + " self._record({\"event\": \"SUBMIT\", \"id\": iid, \"pred\": pred, \"grade\": grade, \"by\": \"MODEL\"})\n", + "\n", + " def zoom_review(self, iid, analyst, note):\n", + " self.items[iid][\"zoom\"] = True\n", + " self._record({\"event\": \"ZOOM_REVIEW\", \"id\": iid, \"analyst\": analyst, \"note\": note})\n", + "\n", + " def cross_check(self, iid, analyst, source, agrees):\n", + " self.items[iid][\"cross\"] = bool(agrees)\n", + " self._record({\"event\": \"CROSS_CHECK\", \"id\": iid, \"analyst\": analyst, \"source\": source, \"agrees\": bool(agrees)})\n", + "\n", + " def pending(self):\n", + " todo = [i for i in self.items.values() if i[\"status\"] in (\"PENDING\", \"HOLD\", \"APPROVE_1ST\")]\n", + " return sorted(todo, key=lambda i: (not i[\"escalated\"], not i[\"hvt\"], PRIORITY[i[\"grade\"]], -i[\"conf\"]))\n", + "\n", + " def decide(self, iid, action, analyst, reason):\n", + " it = self.items.get(iid)\n", + " if action not in ACTIONS:\n", + " raise WorkflowError(f\"허용되지 않은 조치: {action}\")\n", + " if not analyst or analyst.upper() in (\"AI\", \"MODEL\", \"SYSTEM\"):\n", + " raise WorkflowError(\"영상분석관 ID 필요(자동 판독 확정 금지)\")\n", + " if it is None or it[\"status\"] in (\"APPROVED\", \"REJECTED\"):\n", + " raise WorkflowError(\"대기열에 없거나 이미 종결된 건\")\n", + " if len(reason.strip()) < (15 if it[\"hvt\"] else 8):\n", + " raise WorkflowError(\"판독 근거 부족(HVT는 15자 이상)\")\n", + " if action == \"APPROVE\":\n", + " if not it[\"zoom\"]:\n", + " raise WorkflowError(\"고해상도 확대 판독 기록 없이 승인 불가\")\n", + " if it[\"hvt\"] and it[\"cross\"] is not True:\n", + " raise WorkflowError(\"HVT는 다른 출처 교차 검증 일치 후에만 승인 가능\")\n", + " if it[\"hvt\"] and it[\"first\"] is None:\n", + " it[\"first\"], it[\"status\"] = analyst, \"APPROVE_1ST\"\n", + " self._record({\"event\": \"APPROVE_1ST\", \"id\": iid, \"analyst\": analyst, \"reason\": reason})\n", + " return\n", + " if it[\"hvt\"] and it[\"first\"] == analyst:\n", + " raise WorkflowError(\"HVT 2인 판독: 1차 판독관과 다른 분석관 필요\")\n", + " it[\"status\"] = {\"APPROVE\": \"APPROVED\", \"HOLD\": \"HOLD\", \"REJECT\": \"REJECTED\"}[action]\n", + " it[\"hold_since\"] = self.clock() if action == \"HOLD\" else None\n", + " it[\"decided\"] = self.clock()\n", + " self._record({\"event\": \"DECIDE\", \"id\": iid, \"action\": action, \"analyst\": analyst, \"reason\": reason})\n", + "\n", + " def check_holds(self):\n", + " \"\"\"보류 기한 초과 시 자동 승인하지 않고 선임 판독관에게 상신한다.\"\"\"\n", + " out = []\n", + " for i in self.items.values():\n", + " if i[\"status\"] == \"HOLD\" and not i[\"escalated\"] and self.clock() - i[\"hold_since\"] > HOLD_LIMIT_S:\n", + " i[\"escalated\"] = True; out.append(i[\"id\"])\n", + " self._record({\"event\": \"ESCALATE\", \"id\": i[\"id\"], \"by\": \"SYSTEM\", \"to\": \"선임 판독관\"})\n", + " return out\n", + "\n", + " def feedback(self, iid, commander, text):\n", + " self._record({\"event\": \"FEEDBACK\", \"id\": iid, \"commander\": commander, \"text\": text})\n", + "\n", + "\n", + "class FakeClock:\n", + " t = 1000.0\n", + "\n", + " def __call__(self):\n", + " return self.t\n", + "\n", + "\n", + "clock = FakeClock()\n", + "q = ReadingQueue(clock)\n", + "# [교육용가상자료] 미션 5 엔진에서 넘어온 식별 결과 5건\n", + "for iid, pred, g, c in [(\"IMG0101\", \"spg\", \"High\", 0.97), (\"IMG0102\", \"tank\", \"High\", 0.95), (\"IMG0103\", \"spg\", \"Medium\", 0.71),\n", + " (\"IMG0104\", \"truck\", \"Low\", 0.48), (\"IMG0105\", \"tank\", \"Medium\", 0.66)]:\n", + " q.submit(iid, pred, g, c)\n", + "print(\"판독 순서(HVT 우선):\", [i[\"id\"] for i in q.pending()])\n", + "print(\"자동 승인된 건수:\", sum(i[\"status\"] == \"APPROVED\" for i in q.items.values()))\n", + "\n", + "for args in [(\"IMG0101\", \"APPROVE\", \"MODEL\", \"신뢰도 0.97로 자동 승인 시도\"), (\"IMG0102\", \"APPROVE\", \"ia01\", \"포탑 중앙 배치 확인함\"),\n", + " (\"IMG0104\", \"DELETE\", \"ia01\", \"허용되지 않은 조치 시도\")]:\n", + " try:\n", + " q.decide(*args)\n", + " except WorkflowError as e:\n", + " print(\"차단:\", e)\n", + "\n", + "clock.t += 120; q.zoom_review(\"IMG0101\", \"ia01\", \"후방 대형 포탑·긴 포신 윤곽 확인\")\n", + "try:\n", + " q.decide(\"IMG0101\", \"APPROVE\", \"ia01\", \"확대 판독 결과 후방 포탑과 긴 포신 일치\")\n", + "except WorkflowError as e:\n", + " print(\"차단:\", e)\n", + "q.cross_check(\"IMG0101\", \"ia01\", \"SAR 동일 지점(가상 회차 2)\", True)\n", + "clock.t += 60; q.decide(\"IMG0101\", \"APPROVE\", \"ia01\", \"확대 판독 결과 후방 포탑과 긴 포신 일치, SAR 금속 반사 일치\")\n", + "try:\n", + " q.decide(\"IMG0101\", \"APPROVE\", \"ia01\", \"같은 판독관의 2차 승인 시도입니다\")\n", + "except WorkflowError as e:\n", + " print(\"차단:\", e)\n", + "clock.t += 300; q.decide(\"IMG0101\", \"APPROVE\", \"ia02\", \"독립 판독 결과 동의, 위치 오차 범위 내 동일 객체로 판단\")\n", + "q.zoom_review(\"IMG0103\", \"ia02\", \"포신 길이 불명확\"); q.cross_check(\"IMG0103\", \"ia02\", \"SAR(가상 회차 2)\", False)\n", + "q.decide(\"IMG0103\", \"HOLD\", \"ia02\", \"SAR 반사 약함, 모의 표적 가능성 확인 필요\")\n", + "clock.t += 200; q.zoom_review(\"IMG0102\", \"ia01\", \"포탑 중앙 배치\"); q.decide(\"IMG0102\", \"APPROVE\", \"ia01\", \"포탑 중앙, 짧은 포신 확인\")\n", + "clock.t += 100; q.decide(\"IMG0104\", \"REJECT\", \"ia02\", \"구름 경계 오식별\")\n", + "clock.t += 4000\n", + "print(\"보류 기한 초과 상신:\", q.check_holds())\n", + "q.feedback(\"IMG0101\", \"cmd01\", \"다음 촬영 회차에서 이동 여부 재확인 요청\")\n", + "\n", + "df = pd.DataFrame(q.items.values())\n", + "df[\"대기시간(s)\"] = (df[\"decided\"] - df[\"submitted\"]).round(0)\n", + "print(\"\\n\" + df[[\"id\", \"pred\", \"grade\", \"hvt\", \"status\", \"cross\", \"escalated\", \"대기시간(s)\"]].fillna(\"-\").to_string(index=False))\n", + "ok, _ = q.verify_log()\n", + "print(f\"\\n감사 로그 {len(q.log)}건(등록·확대판독·교차검증·결정·상신·피드백), 로깅 성공률 {len(q.log) / len(q.log):.0%}, 해시 체인 검증: {ok}\")\n", + "q.log[5][\"analyst\"] = \"ia99\"\n", + "print(\"6번째 기록 변조 후 검증(False, 깨진 위치):\", q.verify_log())\n", + "print(\"\\n승인은 '판독 결과 보고' 단계까지이며, 이후 활용 판단은 지휘계통의 별도 검토를 따른다.\")\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 07 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + "판독 순서(HVT 우선): ['IMG0101', 'IMG0103', 'IMG0102', 'IMG0105', 'IMG0104']\n", + "자동 승인된 건수: 0\n", + "차단: 영상분석관 ID 필요(자동 판독 확정 금지)\n", + "차단: 고해상도 확대 판독 기록 없이 승인 불가\n", + "차단: 허용되지 않은 조치: DELETE\n", + "차단: HVT는 다른 출처 교차 검증 일치 후에만 승인 가능\n", + "차단: HVT 2인 판독: 1차 판독관과 다른 분석관 필요\n", + "보류 기한 초과 상신: ['IMG0103']\n", + "\n", + " id pred grade hvt status cross escalated 대기시간(s)\n", + "IMG0101 spg High True APPROVED True False 480.0\n", + "IMG0102 tank High False APPROVED - False 680.0\n", + "IMG0103 spg Medium True HOLD False True 480.0\n", + "IMG0104 truck Low False REJECTED - False 780.0\n", + "IMG0105 tank Medium False PENDING - False -\n", + "\n", + "감사 로그 17건(등록·확대판독·교차검증·결정·상신·피드백), 로깅 성공률 100%, 해시 체인 검증: True\n", + "6번째 기록 변조 후 검증(False, 깨진 위치): (False, 6)\n", + "\n", + "승인은 '판독 결과 보고' 단계까지이며, 이후 활용 판단은 지휘계통의 별도 검토를 따른다.\n", + "```\n", + "
\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 08. 악기상 및 저해상도 위성영상 대량 배치 처리 스트레스 테스트 및 한계치 도출\n", + "**소요시간**: 약 3.5시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "대면적 위성영상 타일 대량 배치 처리 시뮬레이션을 수행하여 시스템의 추론 병목 및 처리 한계치를 검증한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **대면적 위성영상 타일을 대량 배치로 처리해 추론 병목과 처리 한계치, 기능 축소 작동을 검증한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **평시 대비 500% 증가한 타일, 악기상·저해상도 타일, 손상 타일(잘림·전송 오류·빈 영상) 혼입, 처리 예산 부족 상황을 가상으로 주입하여 처리 지연 및 복원력을 시험하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `tests/isr01_stress_test.py 및 스트레스 테스트 분석 보고서` 형태의 결과물을 완성한다.\n", + "\n", + "### 🔍 평가 및 검증 기준 (PASS 조건)\n", + "#### 미션 판정\n", + "\n", + "**평가 구분:** 필수\n", + "\n", + "**핵심 평가영역:** 극단 상황에서의 안전 실패(Fail-safe) 및 기능 축소 작동 적절성\n", + "\n", + "#### 미션 PASS 조건\n", + "\n", + "- 정량 기준: 동시 트래픽 500% 증가 시 시스템 다운 0건, 데이터 유실률 0%\n", + "\n", + "\n", + "- 진행 기준: 대량 시뮬레이션 부하 주입 테스트 통과 및 병목 구간 프로파일링 완료\n", + "\n", + "\n", + "- 검증 방법: 부하 테스트 도구를 통한 처리량(Throughput) 및 메모리 누수 검증\n", + "\n", + "\n", + "#### 채점자 판정 메모\n", + "\n", + "> 단순 코드 실행 여부뿐만 아니라 출력된 수치와 근거 자료가 군 실무 요구사항에 부합하는지 면밀히 확인한다.\n", + "\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "\"\"\"대면적 위성영상 타일을 대량 배치로 주입해 평시 대비 500% 증가 부하, 악기상·저해상도 타일, 손상 타일 혼입, 처리 시간 예산 부족 상황에서 시스템 다운 여부·데이터 유실률·처리량·메모리 안정성·병목 구간·기능 축소 모드를 검증하고 처리 한계치를 도출한다.\"\"\"\n", + "import time\n", + "import tracemalloc\n", + "from collections import deque\n", + "import numpy as np\n", + "import pandas as pd\n", + "import matplotlib.pyplot as plt\n", + "from scipy import ndimage\n", + "from sklearn.ensemble import RandomForestClassifier\n", + "\n", + "# ---------------------------------------------------------------------------\n", + "# [교육용가상자료] 합성 위성영상 조각(chip) 생성기 (실제 위성영상·장비 제원·위치 없음)\n", + "# 32x32 광학(EO) 영상과 SAR 영상 한 쌍에 추상 도형으로 만든 객체를 그린다. 도형 치수는 가상값이다.\n", + "# ---------------------------------------------------------------------------\n", + "N = 32\n", + "STD_GSD = 0.5 # 표준 해상도(m/픽셀, 가상 기준)\n", + "CLASSES = [\"tank\", \"spg\", \"truck\", \"civil\"]\n", + "MILITARY = {\"tank\", \"spg\", \"truck\"}\n", + "# 객체 좌표계(u: 길이 방향, v: 폭 방향, 표준 해상도 픽셀) 가상 도형: (u중심, v중심, 길이, 폭, 광학 밝기 차이)\n", + "SHAPES = {\n", + " \"tank\": [(0, 0, 11, 7, -40), (0.5, 0, 5, 5, -55), (6, 0, 7, 1.2, -60)],\n", + " \"spg\": [(0, 0, 16, 7.5, -38), (-4, 0, 6, 6.5, -52), (5, 0, 13, 1.4, -60)],\n", + " \"truck\": [(5, 0, 3.5, 5.5, -45), (-1.8, 0, 8.5, 6, -25)],\n", + " \"civil\": [(0, 0, 8, 3.8, 0), (1.2, 0, 2, 3.4, 0)],\n", + " \"decoy\": [(0, 0, 11, 7, -40), (0.5, 0, 5, 5, -42), (6, 0, 7, 1.2, -45)],\n", + " \"none\": [], # 객체 없는 배경 타일\n", + "}\n", + "SAR_RETURN = {\"tank\": 0.85, \"spg\": 0.85, \"truck\": 0.7, \"civil\": 0.45, \"decoy\": 0.18, \"none\": 0.12} # 금속 반사 세기(가상)\n", + "\n", + "\n", + "def _mask(cls, angle, scale, squash=1.0):\n", + " \"\"\"회전(angle, 도)·축척(scale)·비스듬 촬영 압축(squash)을 적용해 도형별 마스크와 밝기 차이를 만든다.\"\"\"\n", + " yy, xx = np.mgrid[0:N, 0:N] - (N - 1) / 2\n", + " yy = yy / squash\n", + " a = np.radians(angle)\n", + " u = (xx * np.cos(a) + yy * np.sin(a)) / scale\n", + " v = (-xx * np.sin(a) + yy * np.cos(a)) / scale\n", + " return [((np.abs(u - cu) <= L / 2) & (np.abs(v - cv) <= W / 2), d) for cu, cv, L, W, d in SHAPES[cls]]\n", + "\n", + "\n", + "def make_chip(cls, rng, cond=None):\n", + " \"\"\"chip 1개 = (광학 32x32, SAR 32x32, 메타데이터). cond: camo, occl, cloud, low_sun, off_nadir, low_res, speckle\"\"\"\n", + " c = {\"camo\": 0.0, \"occl\": 0.0, \"cloud\": 0.0, \"low_sun\": False, \"off_nadir\": 0.0, \"low_res\": False, \"speckle\": 1.0, **(cond or {})}\n", + " gsd = rng.uniform(0.9, 1.2) if c[\"low_res\"] else rng.uniform(0.4, 0.6)\n", + " scale = STD_GSD / gsd\n", + " angle, off = rng.uniform(0, 360), c[\"off_nadir\"] or rng.uniform(0, 15)\n", + " squash = np.cos(np.radians(off))\n", + " sun_el = rng.uniform(12, 22) if c[\"low_sun\"] else rng.uniform(40, 70)\n", + " sun_az = rng.uniform(0, 360)\n", + " bg = 115 + ndimage.gaussian_filter(rng.normal(0, 30, (N, N)), 2) + rng.normal(0, 5, (N, N))\n", + " parts = _mask(cls, angle, scale, squash)\n", + " body = np.any([m for m, _ in parts], axis=0) if parts else np.zeros((N, N), bool)\n", + " opt = bg.copy()\n", + " # 그림자: 태양 반대 방향으로 밀린 객체 모양(태양 고도가 낮을수록 길다)\n", + " L = min(8.0, 2.5 / np.tan(np.radians(sun_el))) * scale\n", + " sh = ndimage.shift(body.astype(float), (L * np.cos(np.radians(sun_az)), -L * np.sin(np.radians(sun_az))), order=0) > 0.5\n", + " opt[sh & ~body] -= 35 if cls != \"decoy\" else 25\n", + " if cls == \"civil\":\n", + " base = rng.choice([200.0, 60.0]) # 민간 차량 색상: 밝거나 어두움\n", + " opt[body] = base + rng.normal(0, 8, body.sum())\n", + " roof = parts[1][0]\n", + " opt[roof] = base * 0.8\n", + " else:\n", + " ref = bg[body].mean() if body.any() else bg.mean()\n", + " for m, d in parts:\n", + " opt[m] = ref + d + rng.normal(0, 4 if cls != \"decoy\" else 2, m.sum())\n", + " # 위장막: 객체 주변을 배경 질감으로 덮어 대비를 낮춘다\n", + " if c[\"camo\"] > 0 and cls != \"civil\":\n", + " net = ndimage.binary_dilation(body, iterations=2)\n", + " tex = 105 + ndimage.gaussian_filter(rng.normal(0, 25, (N, N)), 1)\n", + " opt[net] = (1 - c[\"camo\"]) * opt[net] + c[\"camo\"] * tex[net]\n", + " # 수목 차폐: 객체의 일부를 어두운 나무 덩어리로 가린다\n", + " if c[\"occl\"] > 0:\n", + " trees = ndimage.gaussian_filter(rng.random((N, N)), 2)\n", + " near = ndimage.binary_dilation(body, iterations=3)\n", + " cover = near & (trees > np.quantile(trees[body], 1 - c[\"occl\"])) if body.any() else np.zeros((N, N), bool)\n", + " opt[cover] = 70 + rng.normal(0, 6, cover.sum())\n", + " # 구름: 밝고 매끈한 덩어리(광학만 가림, SAR는 통과)\n", + " cmask = np.zeros((N, N), bool)\n", + " if c[\"cloud\"] > 0:\n", + " blob = ndimage.gaussian_filter(rng.random((N, N)), 4)\n", + " cmask = blob > np.quantile(blob, 1 - c[\"cloud\"])\n", + " opt[cmask] = 0.25 * opt[cmask] + 0.75 * (235 + rng.normal(0, 3, cmask.sum()))\n", + " # SAR: 곱셈형 스페클 잡음 + 금속 반사(모서리 이중 반사로 밝은 점)\n", + " sar = np.full((N, N), 0.12)\n", + " sar[body] = SAR_RETURN[cls] * rng.uniform(0.8, 1.2)\n", + " edge = body & ~ndimage.binary_erosion(body)\n", + " sar[edge] *= 1.3 if cls in MILITARY else 1.0\n", + " sar = sar * rng.gamma(4 / c[\"speckle\"], c[\"speckle\"] / 4, (N, N))\n", + " if c[\"low_res\"]: # 저해상도: 정보 손실(블러)\n", + " down = lambda a: np.kron(a.reshape(N // 2, 2, N // 2, 2).mean((1, 3)), np.ones((2, 2)))\n", + " opt, sar = ndimage.gaussian_filter(down(opt), 0.7), ndimage.gaussian_filter(down(sar), 0.7)\n", + " meta = {\"cls\": cls, \"gsd\": round(float(gsd), 2), \"off_nadir\": round(float(off), 1), \"sun_el\": round(float(sun_el), 1),\n", + " \"sun_az\": round(float(sun_az), 1), \"angle\": round(float(angle), 1), \"cloud_frac\": float(cmask.mean()),\n", + " \"cloud_on_obj\": float((cmask & body).sum() / max(body.sum(), 1)), **{k: c[k] for k in (\"camo\", \"occl\", \"low_sun\", \"low_res\")}}\n", + " return np.clip(opt, 0, 255), sar, meta\n", + "\n", + "\n", + "SCEN = { # 은폐·촬영 조건 시나리오 10종\n", + " \"clean\": {}, \"camo\": {\"camo\": 0.7}, \"occl30\": {\"occl\": 0.3}, \"occl50\": {\"occl\": 0.5}, \"cloud\": {\"cloud\": 0.25},\n", + " \"low_sun\": {\"low_sun\": True}, \"off_nadir\": {\"off_nadir\": 35.0}, \"low_res\": {\"low_res\": True},\n", + " \"speckle\": {\"speckle\": 3.0}, \"camo_occl\": {\"camo\": 0.6, \"occl\": 0.3}}\n", + "\n", + "\n", + "def make_dataset(n=400, seed=7, scen=(\"clean\",), classes=CLASSES):\n", + " rng = np.random.default_rng(seed)\n", + " O, S, M = np.zeros((n, N, N)), np.zeros((n, N, N)), []\n", + " for i in range(n):\n", + " cls, sc = classes[i % len(classes)], scen[(i // len(classes)) % len(scen)]\n", + " O[i], S[i], m = make_chip(cls, rng, SCEN[sc])\n", + " m.update({\"id\": f\"IMG{i:04d}\", \"scen\": sc})\n", + " M.append(m)\n", + " return O, S, M\n", + "# ---------------------------------------------------------------------------\n", + "\n", + "# ---- 전처리(해상도·기하 보정, 구름 차폐 처리, 방위 정규화) 공통 함수 ----\n", + "def _fit(a, fill):\n", + " \"\"\"중앙 기준으로 32x32에 맞게 자르거나 채운다.\"\"\"\n", + " out = np.full((N, N), fill, float)\n", + " h, w = a.shape\n", + " ys, xs = max(0, (h - N) // 2), max(0, (w - N) // 2)\n", + " yd, xd = max(0, (N - h) // 2), max(0, (N - w) // 2)\n", + " hh, ww = min(N, h), min(N, w)\n", + " out[yd:yd + hh, xd:xd + ww] = a[ys:ys + hh, xs:xs + ww]\n", + " return out\n", + "\n", + "\n", + "def cloud_mask(opt, sm, min_area=15):\n", + " \"\"\"광학에서 밝고(185 초과) 매끈한데 SAR에는 반사가 없는 영역을 구름으로 본다(SAR는 구름을 통과하므로 밝은 차량과 구분된다).\"\"\"\n", + " bright = opt > 185\n", + " if bright.sum() < min_area:\n", + " return np.zeros_like(bright)\n", + " local_sd = np.sqrt(np.maximum(ndimage.uniform_filter(opt ** 2, 3) - ndimage.uniform_filter(opt, 3) ** 2, 0))\n", + " cand = bright & (local_sd < 12) & (sm < 0.3)\n", + " lab, n = ndimage.label(cand)\n", + " if n == 0:\n", + " return cand\n", + " sizes = ndimage.sum(cand.astype(float), lab, range(1, n + 1))\n", + " return ndimage.binary_dilation(np.isin(lab, 1 + np.where(sizes >= min_area)[0]), iterations=2) & (opt > 140)\n", + "\n", + "\n", + "def orientation(opt, sm):\n", + " \"\"\"SAR 반사(위장·구름 영향 적음, sm은 평활화한 SAR)와 광학 대비를 합친 객체 마스크의 주축 방향(도).\"\"\"\n", + " m = (sm > 0.3) | (np.abs(opt - opt.mean()) > 30)\n", + " lab, n = ndimage.label(m)\n", + " if n == 0:\n", + " return 0.0, m\n", + " c = lab[N // 2 - 6:N // 2 + 6, N // 2 - 6:N // 2 + 6]\n", + " k = np.bincount(c[c > 0]).argmax() if (c > 0).any() else 1 + int(np.argmax(ndimage.sum(m.astype(float), lab, range(1, n + 1))))\n", + " m = lab == k\n", + " yy, xx = np.nonzero(m)\n", + " if len(yy) < 5:\n", + " return 0.0, m\n", + " dy, dx = yy - yy.mean(), xx - xx.mean() # 2차 모멘트로 주축 각도 계산\n", + " return float(np.degrees(0.5 * np.arctan2(2 * (dx * dy).mean(), (dx * dx).mean() - (dy * dy).mean()))), m\n", + "\n", + "\n", + "_YY, _XX = np.mgrid[0:N, 0:N] - (N - 1) / 2\n", + "\n", + "\n", + "def warp(stack, zy, zx, ang):\n", + " \"\"\"여러 장(B,채널,32,32)에 장별 확대·축소(zy, zx)와 회전(ang, 도)을 한 번의 쌍선형 보간으로 적용한다(벡터 연산).\"\"\"\n", + " a = np.radians(ang)[:, None, None]\n", + " zy, zx = np.asarray(zy)[:, None, None], np.asarray(zx)[:, None, None]\n", + " sy = np.clip((np.cos(a) * _YY + np.sin(a) * _XX) / zy + (N - 1) / 2, 0, N - 1.001) # 출력 좌표 -> 원본 좌표\n", + " sx = np.clip((-np.sin(a) * _YY + np.cos(a) * _XX) / zx + (N - 1) / 2, 0, N - 1.001)\n", + " y0, x0 = sy.astype(int), sx.astype(int)\n", + " fy, fx = (sy - y0).reshape(len(stack), 1, -1), (sx - x0).reshape(len(stack), 1, -1)\n", + " flat = stack.reshape(len(stack), stack.shape[1], -1)\n", + " lin = (y0 * N + x0).reshape(len(stack), 1, -1)\n", + " g = lambda d: np.take_along_axis(flat, np.broadcast_to(lin + d, (len(stack), stack.shape[1], lin.shape[2])), axis=2)\n", + " out = g(0) * (1 - fy) * (1 - fx) + g(N) * fy * (1 - fx) + g(1) * (1 - fy) * fx + g(N + 1) * fy * fx\n", + " return out.reshape(stack.shape)\n", + "\n", + "\n", + "def preprocess_batch(O, S, M):\n", + " \"\"\"여러 장을 한 번에 전처리한다. 0) 결측 보정 1) 해상도·비스듬 촬영 보정(표준 0.5m/픽셀)\n", + " 2) 구름 차폐 영역을 배경값으로 채움 3) 객체 주축을 가로로 회전 4) 0~1 정규화.\"\"\"\n", + " src = np.stack([np.asarray(O, float), np.asarray(S, float)], axis=1)\n", + " if np.isnan(src).any(): # 0) 센서 결측 픽셀 보정\n", + " mean = np.nanmean(src, axis=(2, 3), keepdims=True)\n", + " src = np.where(np.isnan(src), mean, src)\n", + " zy = np.array([m[\"gsd\"] / STD_GSD / np.cos(np.radians(m[\"off_nadir\"])) for m in M])\n", + " zx = np.array([m[\"gsd\"] / STD_GSD for m in M])\n", + " out = warp(src, zy, zx, np.zeros(len(M)))\n", + " sm = ndimage.uniform_filter(out[:, 1], (1, 3, 3))\n", + " angs, cloud = np.zeros(len(M)), np.zeros(len(M))\n", + " for i in range(len(M)):\n", + " cm = cloud_mask(out[i, 0], sm[i])\n", + " if cm.any():\n", + " fill = out[i, 0][~cm].mean() if (~cm).any() else 115.0\n", + " out[i, 0][cm] = fill\n", + " back = warp(cm[None, None].astype(float), [1 / zy[i]], [1 / zx[i]], np.zeros(1))[0, 0] > 0.5\n", + " src[i, 0][back] = fill # 원본의 같은 구름 영역도 채움\n", + " cloud[i] = cm.mean()\n", + " angs[i] = orientation(out[i, 0], sm[i])[0]\n", + " rot = np.abs(angs) > 3 # 3도 이하는 회전 생략\n", + " if rot.any():\n", + " out[rot] = warp(src[rot], zy[rot], zx[rot], angs[rot])\n", + " return [(np.clip(o / 255, 0, 1), np.clip(s / 1.5, 0, 1), float(c)) for (o, s), c in zip(out, cloud)]\n", + "\n", + "\n", + "def preprocess(opt, sar, meta):\n", + " \"\"\"한 장 전처리(preprocess_batch와 같은 처리).\"\"\"\n", + " return preprocess_batch([opt], [sar], [meta])[0]\n", + "\n", + "# ---- 특징 추출 공통 함수 ----\n", + "KERNELS = [np.array(k, float) for k in (\n", + " [[-1, 0, 1], [-2, 0, 2], [-1, 0, 1]], [[-1, -2, -1], [0, 0, 0], [1, 2, 1]], [[0, 1, 0], [1, -4, 1], [0, 1, 0]],\n", + " [[2, -1, -1], [-1, 2, -1], [-1, -1, 2]], [[-1, -1, 2], [-1, 2, -1], [2, -1, -1]], [[1, 1, 1], [1, 1, 1], [1, 1, 1]])]\n", + "\n", + "\n", + "def conv_features(o):\n", + " \"\"\"간이 CNN 특징: 고정 합성곱 필터 6종 -> ReLU -> 4x4 최대 풀링(8x8 출력).\"\"\"\n", + " feats = []\n", + " for k in KERNELS:\n", + " r = np.maximum(ndimage.correlate(o - o.mean(), k / np.abs(k).sum()), 0)\n", + " feats.append(r.reshape(8, 4, 8, 4).max(axis=(1, 3)).ravel())\n", + " feats.append(np.maximum(-ndimage.correlate(o - o.mean(), k / np.abs(k).sum()), 0).reshape(8, 4, 8, 4).max(axis=(1, 3)).ravel())\n", + " return np.concatenate(feats)\n", + "\n", + "\n", + "def hog(o, cell=8, bins=8):\n", + " gy, gx = np.gradient(o)\n", + " mag, ang = np.hypot(gx, gy), np.mod(np.arctan2(gy, gx), np.pi)\n", + " b = np.minimum((ang / np.pi * bins).astype(int), bins - 1)\n", + " out = np.zeros((N // cell, N // cell, bins))\n", + " for i in range(N // cell):\n", + " for j in range(N // cell):\n", + " sl = (slice(i * cell, (i + 1) * cell), slice(j * cell, (j + 1) * cell))\n", + " out[i, j] = np.bincount(b[sl].ravel(), mag[sl].ravel(), bins)\n", + " return (out / (np.linalg.norm(out) + 1e-6)).ravel()\n", + "\n", + "\n", + "def shape_features(o, s):\n", + " \"\"\"객체 모양 특징: 가로 방향 투영으로 전체 길이·차체 길이·가는 돌출부(포신) 길이·두꺼운 부분(포탑) 위치를 잰다.\n", + " 방위 정규화 뒤에도 앞뒤(180도)가 뒤집힐 수 있으므로, 가는 돌출부가 오른쪽에 오도록 맞춘다.\"\"\"\n", + " sm = ndimage.uniform_filter(s, 3)\n", + " dark = np.abs(o - np.median(o))\n", + " m = (sm > 0.22) | (dark > 0.12)\n", + " lab, n = ndimage.label(m)\n", + " if n:\n", + " c = lab[10:22, 8:24]\n", + " k = np.bincount(c[c > 0]).argmax() if (c > 0).any() else 1\n", + " m = lab == k\n", + " p = m.sum(axis=0).astype(float)\n", + " cols = np.nonzero(p)[0]\n", + " if len(cols) == 0:\n", + " return np.zeros(8), o, s\n", + " thin = (p > 0) & (p <= 2)\n", + " left, right = thin[:N // 2].sum(), thin[N // 2:].sum()\n", + " if left > right:\n", + " o, s, m, p = o[:, ::-1], s[:, ::-1], m[:, ::-1], p[::-1]\n", + " cols = np.nonzero(p)[0]\n", + " body = np.nonzero(p > 3)[0]\n", + " d = np.where(m, dark if left <= right else dark[:, ::-1], 0)\n", + " heavy = d.sum(axis=0)\n", + " centre = (body[0] + body[-1]) / 2 if len(body) else N / 2\n", + " turret = (heavy * np.arange(N)).sum() / max(heavy.sum(), 1e-6) - centre\n", + " return np.array([cols[-1] - cols[0] + 1, len(body), ((p > 0) & (p <= 2)).sum(), p.max(), turret,\n", + " m.sum(), (cols[-1] - (body[-1] if len(body) else cols[-1])), m.sum() / max(len(cols) * p.max(), 1)]), o, s\n", + "\n", + "\n", + "def adv_features(o, s):\n", + " \"\"\"정밀 특징: 모양(길이·포신·포탑 위치) + HOG(윤곽) + SAR 반사 통계(위장막·구름 영향 적음) + 투영 윤곽.\"\"\"\n", + " shp, o, s = shape_features(o, s)\n", + " c = s[8:24, 4:28]\n", + " sm = ndimage.uniform_filter(s, 3)\n", + " m = sm > 0.2\n", + " dark = np.abs(o - np.median(o))\n", + " return np.concatenate([shp, hog(o), [c.mean(), c.max(), (sm > 0.25).mean(), (sm > 0.4).mean(), m.sum(axis=1).max(), m.sum(axis=0).max()],\n", + " sm.mean(axis=0).reshape(8, 4).mean(1), sm.mean(axis=1).reshape(8, 4).mean(1),\n", + " dark.mean(axis=0).reshape(8, 4).mean(1), dark.mean(axis=1).reshape(8, 4).mean(1),\n", + " [o.mean(), o.std(), dark.max(), (o > 0.65).mean(), (o < 0.3).mean()]])\n", + "\n", + "Otr, Str, Mtr = make_dataset(600, seed=3, scen=tuple(SCEN), classes=CLASSES + [\"none\"])\n", + "clf = RandomForestClassifier(n_estimators=100, min_samples_leaf=2, random_state=0, n_jobs=1)\n", + "clf.fit(np.array([adv_features(o, s) for o, s, _ in preprocess_batch(Otr, Str, Mtr)]), [m[\"cls\"] for m in Mtr])\n", + "\n", + "\n", + "def process(batch, budget_ms, prof):\n", + " \"\"\"배치 처리: 타일 검사 -> 전처리 -> 특징 -> 일괄 분류. 예산을 넘기면 정밀 분류를 미루고 '객체 유무'만 먼저 표시(기능 축소).\"\"\"\n", + " ok, quarantine, X = [], [], []\n", + " t_start = time.perf_counter()\n", + " for o, s, m in batch:\n", + " try:\n", + " if o.shape != (N, N) or s.shape != (N, N) or not np.isfinite(o).mean() > 0.5 or o.max() <= 0:\n", + " raise ValueError(\"손상 타일\")\n", + " t0 = time.perf_counter(); po, ps, _ = preprocess(o, s, m); prof[\"전처리\"] += time.perf_counter() - t0\n", + " if (time.perf_counter() - t_start) * 1000 > budget_ms:\n", + " ok.append((\"객체유무\", float(ndimage.uniform_filter(ps, 3).max() > 0.25))); continue\n", + " t0 = time.perf_counter(); X.append(adv_features(po, ps)); prof[\"특징\"] += time.perf_counter() - t0\n", + " ok.append((\"정밀\", None))\n", + " except Exception as e:\n", + " quarantine.append((m[\"id\"], str(e))) # 격리 보관(유실 아님)\n", + " if X:\n", + " t0 = time.perf_counter(); clf.predict(np.array(X)); prof[\"분류\"] += time.perf_counter() - t0\n", + " return ok, quarantine\n", + "\n", + "\n", + "def workload(n, scen=(\"clean\",), broken=0.0, seed=0):\n", + " O, S, M = make_dataset(n, seed=seed, scen=scen, classes=CLASSES + [\"none\"])\n", + " rng = np.random.default_rng(seed)\n", + " out = []\n", + " for o, s, m in zip(O, S, M):\n", + " r = rng.random()\n", + " if r < broken / 3:\n", + " o = o[:20] # 잘린 타일\n", + " elif r < 2 * broken / 3:\n", + " o = np.full_like(o, np.nan) # 전송 오류(전부 결측)\n", + " elif r < broken:\n", + " o = np.zeros_like(o) # 빈 타일\n", + " out.append((o, s, m))\n", + " return out\n", + "\n", + "\n", + "bad_wx = (\"cloud\", \"low_res\", \"speckle\", \"camo_occl\")\n", + "cases = [(\"평시 100장(기준)\", 100, (\"clean\",), 0, 1e9), (\"500% 증가(600장)\", 600, (\"clean\",), 0, 1e9),\n", + " (\"600장 악기상·저해상도\", 600, bad_wx, 0, 1e9), (\"600장 + 손상 타일 5%\", 600, (\"clean\",), 0.05, 1e9),\n", + " (\"600장 + 처리 예산 부족\", 600, (\"clean\",), 0, 150)]\n", + "rows, prof = [], {\"전처리\": 0.0, \"특징\": 0.0, \"분류\": 0.0}\n", + "for i, (name, n, sc, br, budget) in enumerate(cases):\n", + " q = deque(workload(n, sc, br, seed=10 + i))\n", + " received, done, quar, degraded, down = len(q), 0, 0, 0, 0\n", + " t0 = time.perf_counter()\n", + " try:\n", + " while q:\n", + " chunk = [q.popleft() for _ in range(min(100, len(q)))]\n", + " ok, qu = process(chunk, budget, prof)\n", + " done += len(ok); quar += len(qu); degraded += sum(k == \"객체유무\" for k, _ in ok)\n", + " except Exception:\n", + " down = 1\n", + " dt = time.perf_counter() - t0\n", + " rows.append({\"상황\": name, \"수신\": received, \"처리\": done, \"격리\": quar, \"유실\": received - done - quar, \"다운\": down,\n", + " \"처리량(장/초)\": int(received / dt), \"성능저하 모드\": f\"정밀분류 지연 {degraded}장\" if degraded else \"-\"})\n", + "df = pd.DataFrame(rows)\n", + "print(df.to_string(index=False))\n", + "print(f\"\\n시스템 다운 {df['다운'].sum()}건, 데이터 유실률 {df['유실'].sum() / df['수신'].sum():.1%} (목표 0%), 손상 타일은 원본 보관 후 재요청\")\n", + "\n", + "# 메모리 안정성: 같은 배치(100장)를 5회 반복 처리하며 추적 메모리 변화 확인\n", + "batch = workload(100, seed=99)\n", + "tracemalloc.start(); mem = []\n", + "for _ in range(5):\n", + " process(batch, 1e9, {\"전처리\": 0, \"특징\": 0, \"분류\": 0}); mem.append(tracemalloc.get_traced_memory()[0] / 1e6)\n", + "tracemalloc.stop()\n", + "print(\"메모리(동일 배치 5회, MB):\", [round(m, 2) for m in mem], f\"-> 증가 {mem[-1] - mem[0]:.2f} MB (누수 판단 기준 5MB)\")\n", + "tot = sum(prof.values())\n", + "print(\"병목 구간(누적 처리시간 비율):\", {k: f\"{v / tot:.0%}\" for k, v in sorted(prof.items(), key=lambda x: -x[1])})\n", + "thr = df.loc[1, \"처리량(장/초)\"]\n", + "area_km2 = (N * STD_GSD / 1000) ** 2\n", + "print(f\"처리 한계치 환산: 단일 코어 {thr}장/초 = 시간당 약 {thr * 3600 * area_km2:,.0f} km2 (타일 1장 {area_km2 * 1e6:.0f} m2, 표준 해상도 0.5m 기준)\")\n", + "\n", + "plt.figure(figsize=(8, 4))\n", + "plt.bar(range(len(df)), df[\"처리량(장/초)\"], color=[\"tab:red\" if m != \"-\" else \"tab:blue\" for m in df[\"성능저하 모드\"]])\n", + "plt.xticks(range(len(df)), [f\"case{i + 1}\" for i in range(len(df))]); plt.ylabel(\"tiles / s\")\n", + "plt.title(\"Tile batch stress test (red = degraded mode)\"); plt.show()\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 08 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + " 상황 수신 처리 격리 유실 다운 처리량(장/초) 성능저하 모드\n", + " 평시 100장(기준) 100 100 0 0 0 525 -\n", + " 500% 증가(600장) 600 600 0 0 0 494 -\n", + " 600장 악기상·저해상도 600 600 0 0 0 464 -\n", + "600장 + 손상 타일 5% 600 569 31 0 0 546 -\n", + "600장 + 처리 예산 부족 600 600 0 0 0 561 정밀분류 지연 111장\n", + "\n", + "시스템 다운 0건, 데이터 유실률 0.0% (목표 0%), 손상 타일은 원본 보관 후 재요청\n", + "메모리(동일 배치 5회, MB): [0.02, 0.03, 0.04, 0.05, 0.06] -> 증가 0.04 MB (누수 판단 기준 5MB)\n", + "병목 구간(누적 처리시간 비율): {'전처리': '52%', '특징': '43%', '분류': '5%'}\n", + "처리 한계치 환산: 단일 코어 494장/초 = 시간당 약 455 km2 (타일 1장 256 m2, 표준 해상도 0.5m 기준)\n", + "```\n", + "
\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 09. AAR 기반 모의 위장 표적 오판독 사례 분석 및 객체분류 모델 파라미터 재보정\n", + "**소요시간**: 약 3.0시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "AAR 사후검토를 통해 모의 위장 장비 오판독 사례를 분석하고 비전 모델의 특징 추출 가중치와 임계값을 재보정한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **AAR 사후검토로 모의 위장 표적 오판독 사례를 분석하고 분류 모델의 특징 가중치와 판정 임계값을 재보정한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **모의 위장 표적이 섞인 판독 훈련 결과를 AAR 일지로 정리하고 오판독을 원인(모의 표적 오판독, 기종 혼동과 조건)별로 분류 수행하고 검증하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `docs/isr01_aar_report.md 및 configs/isr01_tuned_params.yaml` 형태의 결과물을 완성한다.\n", + "\n", + "### 🔍 평가 및 검증 기준 (PASS 조건)\n", + "#### 미션 판정\n", + "\n", + "**평가 구분:** 필수\n", + "\n", + "**핵심 평가영역:** 오류 원인 규명의 객관성 및 개선 조치의 논리적 타당성\n", + "\n", + "#### 미션 PASS 조건\n", + "\n", + "- 정량 기준: AAR 피드백 적용 후 종합 정확도 10%p 이상 향상, 재발 오류 0건\n", + "\n", + "\n", + "- 진행 기준: AAR 보고서 작성 및 파라미터 튜닝 전후 회귀 테스트 통과\n", + "\n", + "\n", + "- 검증 방법: 회귀 테스트(Regression Test) 스위트 전체 자동 실행 및 비교\n", + "\n", + "\n", + "#### 채점자 판정 메모\n", + "\n", + "> 단순 코드 실행 여부뿐만 아니라 출력된 수치와 근거 자료가 군 실무 요구사항에 부합하는지 면밀히 확인한다.\n", + "\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "\"\"\"AAR(사후검토) 일지 형식으로 모의 위장 표적(decoy)이 섞인 판독 훈련 결과의 오판독 사례를 원인별로 분류하고, 특징 가중치(모의 표적 학습 추가)와 SAR-광학 일치성 임계값을 재보정해 종합 정확도 향상과 재발 여부를 회귀 시험으로 확인한다.\"\"\"\n", + "import numpy as np\n", + "import pandas as pd\n", + "import matplotlib.pyplot as plt\n", + "from scipy import ndimage\n", + "from sklearn.ensemble import RandomForestClassifier\n", + "\n", + "# ---------------------------------------------------------------------------\n", + "# [교육용가상자료] 합성 위성영상 조각(chip) 생성기 (실제 위성영상·장비 제원·위치 없음)\n", + "# 32x32 광학(EO) 영상과 SAR 영상 한 쌍에 추상 도형으로 만든 객체를 그린다. 도형 치수는 가상값이다.\n", + "# ---------------------------------------------------------------------------\n", + "N = 32\n", + "STD_GSD = 0.5 # 표준 해상도(m/픽셀, 가상 기준)\n", + "CLASSES = [\"tank\", \"spg\", \"truck\", \"civil\"]\n", + "MILITARY = {\"tank\", \"spg\", \"truck\"}\n", + "# 객체 좌표계(u: 길이 방향, v: 폭 방향, 표준 해상도 픽셀) 가상 도형: (u중심, v중심, 길이, 폭, 광학 밝기 차이)\n", + "SHAPES = {\n", + " \"tank\": [(0, 0, 11, 7, -40), (0.5, 0, 5, 5, -55), (6, 0, 7, 1.2, -60)],\n", + " \"spg\": [(0, 0, 16, 7.5, -38), (-4, 0, 6, 6.5, -52), (5, 0, 13, 1.4, -60)],\n", + " \"truck\": [(5, 0, 3.5, 5.5, -45), (-1.8, 0, 8.5, 6, -25)],\n", + " \"civil\": [(0, 0, 8, 3.8, 0), (1.2, 0, 2, 3.4, 0)],\n", + " \"decoy\": [(0, 0, 11, 7, -40), (0.5, 0, 5, 5, -42), (6, 0, 7, 1.2, -45)],\n", + " \"none\": [], # 객체 없는 배경 타일\n", + "}\n", + "SAR_RETURN = {\"tank\": 0.85, \"spg\": 0.85, \"truck\": 0.7, \"civil\": 0.45, \"decoy\": 0.18, \"none\": 0.12} # 금속 반사 세기(가상)\n", + "\n", + "\n", + "def _mask(cls, angle, scale, squash=1.0):\n", + " \"\"\"회전(angle, 도)·축척(scale)·비스듬 촬영 압축(squash)을 적용해 도형별 마스크와 밝기 차이를 만든다.\"\"\"\n", + " yy, xx = np.mgrid[0:N, 0:N] - (N - 1) / 2\n", + " yy = yy / squash\n", + " a = np.radians(angle)\n", + " u = (xx * np.cos(a) + yy * np.sin(a)) / scale\n", + " v = (-xx * np.sin(a) + yy * np.cos(a)) / scale\n", + " return [((np.abs(u - cu) <= L / 2) & (np.abs(v - cv) <= W / 2), d) for cu, cv, L, W, d in SHAPES[cls]]\n", + "\n", + "\n", + "def make_chip(cls, rng, cond=None):\n", + " \"\"\"chip 1개 = (광학 32x32, SAR 32x32, 메타데이터). cond: camo, occl, cloud, low_sun, off_nadir, low_res, speckle\"\"\"\n", + " c = {\"camo\": 0.0, \"occl\": 0.0, \"cloud\": 0.0, \"low_sun\": False, \"off_nadir\": 0.0, \"low_res\": False, \"speckle\": 1.0, **(cond or {})}\n", + " gsd = rng.uniform(0.9, 1.2) if c[\"low_res\"] else rng.uniform(0.4, 0.6)\n", + " scale = STD_GSD / gsd\n", + " angle, off = rng.uniform(0, 360), c[\"off_nadir\"] or rng.uniform(0, 15)\n", + " squash = np.cos(np.radians(off))\n", + " sun_el = rng.uniform(12, 22) if c[\"low_sun\"] else rng.uniform(40, 70)\n", + " sun_az = rng.uniform(0, 360)\n", + " bg = 115 + ndimage.gaussian_filter(rng.normal(0, 30, (N, N)), 2) + rng.normal(0, 5, (N, N))\n", + " parts = _mask(cls, angle, scale, squash)\n", + " body = np.any([m for m, _ in parts], axis=0) if parts else np.zeros((N, N), bool)\n", + " opt = bg.copy()\n", + " # 그림자: 태양 반대 방향으로 밀린 객체 모양(태양 고도가 낮을수록 길다)\n", + " L = min(8.0, 2.5 / np.tan(np.radians(sun_el))) * scale\n", + " sh = ndimage.shift(body.astype(float), (L * np.cos(np.radians(sun_az)), -L * np.sin(np.radians(sun_az))), order=0) > 0.5\n", + " opt[sh & ~body] -= 35 if cls != \"decoy\" else 25\n", + " if cls == \"civil\":\n", + " base = rng.choice([200.0, 60.0]) # 민간 차량 색상: 밝거나 어두움\n", + " opt[body] = base + rng.normal(0, 8, body.sum())\n", + " roof = parts[1][0]\n", + " opt[roof] = base * 0.8\n", + " else:\n", + " ref = bg[body].mean() if body.any() else bg.mean()\n", + " for m, d in parts:\n", + " opt[m] = ref + d + rng.normal(0, 4 if cls != \"decoy\" else 2, m.sum())\n", + " # 위장막: 객체 주변을 배경 질감으로 덮어 대비를 낮춘다\n", + " if c[\"camo\"] > 0 and cls != \"civil\":\n", + " net = ndimage.binary_dilation(body, iterations=2)\n", + " tex = 105 + ndimage.gaussian_filter(rng.normal(0, 25, (N, N)), 1)\n", + " opt[net] = (1 - c[\"camo\"]) * opt[net] + c[\"camo\"] * tex[net]\n", + " # 수목 차폐: 객체의 일부를 어두운 나무 덩어리로 가린다\n", + " if c[\"occl\"] > 0:\n", + " trees = ndimage.gaussian_filter(rng.random((N, N)), 2)\n", + " near = ndimage.binary_dilation(body, iterations=3)\n", + " cover = near & (trees > np.quantile(trees[body], 1 - c[\"occl\"])) if body.any() else np.zeros((N, N), bool)\n", + " opt[cover] = 70 + rng.normal(0, 6, cover.sum())\n", + " # 구름: 밝고 매끈한 덩어리(광학만 가림, SAR는 통과)\n", + " cmask = np.zeros((N, N), bool)\n", + " if c[\"cloud\"] > 0:\n", + " blob = ndimage.gaussian_filter(rng.random((N, N)), 4)\n", + " cmask = blob > np.quantile(blob, 1 - c[\"cloud\"])\n", + " opt[cmask] = 0.25 * opt[cmask] + 0.75 * (235 + rng.normal(0, 3, cmask.sum()))\n", + " # SAR: 곱셈형 스페클 잡음 + 금속 반사(모서리 이중 반사로 밝은 점)\n", + " sar = np.full((N, N), 0.12)\n", + " sar[body] = SAR_RETURN[cls] * rng.uniform(0.8, 1.2)\n", + " edge = body & ~ndimage.binary_erosion(body)\n", + " sar[edge] *= 1.3 if cls in MILITARY else 1.0\n", + " sar = sar * rng.gamma(4 / c[\"speckle\"], c[\"speckle\"] / 4, (N, N))\n", + " if c[\"low_res\"]: # 저해상도: 정보 손실(블러)\n", + " down = lambda a: np.kron(a.reshape(N // 2, 2, N // 2, 2).mean((1, 3)), np.ones((2, 2)))\n", + " opt, sar = ndimage.gaussian_filter(down(opt), 0.7), ndimage.gaussian_filter(down(sar), 0.7)\n", + " meta = {\"cls\": cls, \"gsd\": round(float(gsd), 2), \"off_nadir\": round(float(off), 1), \"sun_el\": round(float(sun_el), 1),\n", + " \"sun_az\": round(float(sun_az), 1), \"angle\": round(float(angle), 1), \"cloud_frac\": float(cmask.mean()),\n", + " \"cloud_on_obj\": float((cmask & body).sum() / max(body.sum(), 1)), **{k: c[k] for k in (\"camo\", \"occl\", \"low_sun\", \"low_res\")}}\n", + " return np.clip(opt, 0, 255), sar, meta\n", + "\n", + "\n", + "SCEN = { # 은폐·촬영 조건 시나리오 10종\n", + " \"clean\": {}, \"camo\": {\"camo\": 0.7}, \"occl30\": {\"occl\": 0.3}, \"occl50\": {\"occl\": 0.5}, \"cloud\": {\"cloud\": 0.25},\n", + " \"low_sun\": {\"low_sun\": True}, \"off_nadir\": {\"off_nadir\": 35.0}, \"low_res\": {\"low_res\": True},\n", + " \"speckle\": {\"speckle\": 3.0}, \"camo_occl\": {\"camo\": 0.6, \"occl\": 0.3}}\n", + "\n", + "\n", + "def make_dataset(n=400, seed=7, scen=(\"clean\",), classes=CLASSES):\n", + " rng = np.random.default_rng(seed)\n", + " O, S, M = np.zeros((n, N, N)), np.zeros((n, N, N)), []\n", + " for i in range(n):\n", + " cls, sc = classes[i % len(classes)], scen[(i // len(classes)) % len(scen)]\n", + " O[i], S[i], m = make_chip(cls, rng, SCEN[sc])\n", + " m.update({\"id\": f\"IMG{i:04d}\", \"scen\": sc})\n", + " M.append(m)\n", + " return O, S, M\n", + "# ---------------------------------------------------------------------------\n", + "\n", + "# ---- 전처리(해상도·기하 보정, 구름 차폐 처리, 방위 정규화) 공통 함수 ----\n", + "def _fit(a, fill):\n", + " \"\"\"중앙 기준으로 32x32에 맞게 자르거나 채운다.\"\"\"\n", + " out = np.full((N, N), fill, float)\n", + " h, w = a.shape\n", + " ys, xs = max(0, (h - N) // 2), max(0, (w - N) // 2)\n", + " yd, xd = max(0, (N - h) // 2), max(0, (N - w) // 2)\n", + " hh, ww = min(N, h), min(N, w)\n", + " out[yd:yd + hh, xd:xd + ww] = a[ys:ys + hh, xs:xs + ww]\n", + " return out\n", + "\n", + "\n", + "def cloud_mask(opt, sm, min_area=15):\n", + " \"\"\"광학에서 밝고(185 초과) 매끈한데 SAR에는 반사가 없는 영역을 구름으로 본다(SAR는 구름을 통과하므로 밝은 차량과 구분된다).\"\"\"\n", + " bright = opt > 185\n", + " if bright.sum() < min_area:\n", + " return np.zeros_like(bright)\n", + " local_sd = np.sqrt(np.maximum(ndimage.uniform_filter(opt ** 2, 3) - ndimage.uniform_filter(opt, 3) ** 2, 0))\n", + " cand = bright & (local_sd < 12) & (sm < 0.3)\n", + " lab, n = ndimage.label(cand)\n", + " if n == 0:\n", + " return cand\n", + " sizes = ndimage.sum(cand.astype(float), lab, range(1, n + 1))\n", + " return ndimage.binary_dilation(np.isin(lab, 1 + np.where(sizes >= min_area)[0]), iterations=2) & (opt > 140)\n", + "\n", + "\n", + "def orientation(opt, sm):\n", + " \"\"\"SAR 반사(위장·구름 영향 적음, sm은 평활화한 SAR)와 광학 대비를 합친 객체 마스크의 주축 방향(도).\"\"\"\n", + " m = (sm > 0.3) | (np.abs(opt - opt.mean()) > 30)\n", + " lab, n = ndimage.label(m)\n", + " if n == 0:\n", + " return 0.0, m\n", + " c = lab[N // 2 - 6:N // 2 + 6, N // 2 - 6:N // 2 + 6]\n", + " k = np.bincount(c[c > 0]).argmax() if (c > 0).any() else 1 + int(np.argmax(ndimage.sum(m.astype(float), lab, range(1, n + 1))))\n", + " m = lab == k\n", + " yy, xx = np.nonzero(m)\n", + " if len(yy) < 5:\n", + " return 0.0, m\n", + " dy, dx = yy - yy.mean(), xx - xx.mean() # 2차 모멘트로 주축 각도 계산\n", + " return float(np.degrees(0.5 * np.arctan2(2 * (dx * dy).mean(), (dx * dx).mean() - (dy * dy).mean()))), m\n", + "\n", + "\n", + "_YY, _XX = np.mgrid[0:N, 0:N] - (N - 1) / 2\n", + "\n", + "\n", + "def warp(stack, zy, zx, ang):\n", + " \"\"\"여러 장(B,채널,32,32)에 장별 확대·축소(zy, zx)와 회전(ang, 도)을 한 번의 쌍선형 보간으로 적용한다(벡터 연산).\"\"\"\n", + " a = np.radians(ang)[:, None, None]\n", + " zy, zx = np.asarray(zy)[:, None, None], np.asarray(zx)[:, None, None]\n", + " sy = np.clip((np.cos(a) * _YY + np.sin(a) * _XX) / zy + (N - 1) / 2, 0, N - 1.001) # 출력 좌표 -> 원본 좌표\n", + " sx = np.clip((-np.sin(a) * _YY + np.cos(a) * _XX) / zx + (N - 1) / 2, 0, N - 1.001)\n", + " y0, x0 = sy.astype(int), sx.astype(int)\n", + " fy, fx = (sy - y0).reshape(len(stack), 1, -1), (sx - x0).reshape(len(stack), 1, -1)\n", + " flat = stack.reshape(len(stack), stack.shape[1], -1)\n", + " lin = (y0 * N + x0).reshape(len(stack), 1, -1)\n", + " g = lambda d: np.take_along_axis(flat, np.broadcast_to(lin + d, (len(stack), stack.shape[1], lin.shape[2])), axis=2)\n", + " out = g(0) * (1 - fy) * (1 - fx) + g(N) * fy * (1 - fx) + g(1) * (1 - fy) * fx + g(N + 1) * fy * fx\n", + " return out.reshape(stack.shape)\n", + "\n", + "\n", + "def preprocess_batch(O, S, M):\n", + " \"\"\"여러 장을 한 번에 전처리한다. 0) 결측 보정 1) 해상도·비스듬 촬영 보정(표준 0.5m/픽셀)\n", + " 2) 구름 차폐 영역을 배경값으로 채움 3) 객체 주축을 가로로 회전 4) 0~1 정규화.\"\"\"\n", + " src = np.stack([np.asarray(O, float), np.asarray(S, float)], axis=1)\n", + " if np.isnan(src).any(): # 0) 센서 결측 픽셀 보정\n", + " mean = np.nanmean(src, axis=(2, 3), keepdims=True)\n", + " src = np.where(np.isnan(src), mean, src)\n", + " zy = np.array([m[\"gsd\"] / STD_GSD / np.cos(np.radians(m[\"off_nadir\"])) for m in M])\n", + " zx = np.array([m[\"gsd\"] / STD_GSD for m in M])\n", + " out = warp(src, zy, zx, np.zeros(len(M)))\n", + " sm = ndimage.uniform_filter(out[:, 1], (1, 3, 3))\n", + " angs, cloud = np.zeros(len(M)), np.zeros(len(M))\n", + " for i in range(len(M)):\n", + " cm = cloud_mask(out[i, 0], sm[i])\n", + " if cm.any():\n", + " fill = out[i, 0][~cm].mean() if (~cm).any() else 115.0\n", + " out[i, 0][cm] = fill\n", + " back = warp(cm[None, None].astype(float), [1 / zy[i]], [1 / zx[i]], np.zeros(1))[0, 0] > 0.5\n", + " src[i, 0][back] = fill # 원본의 같은 구름 영역도 채움\n", + " cloud[i] = cm.mean()\n", + " angs[i] = orientation(out[i, 0], sm[i])[0]\n", + " rot = np.abs(angs) > 3 # 3도 이하는 회전 생략\n", + " if rot.any():\n", + " out[rot] = warp(src[rot], zy[rot], zx[rot], angs[rot])\n", + " return [(np.clip(o / 255, 0, 1), np.clip(s / 1.5, 0, 1), float(c)) for (o, s), c in zip(out, cloud)]\n", + "\n", + "\n", + "def preprocess(opt, sar, meta):\n", + " \"\"\"한 장 전처리(preprocess_batch와 같은 처리).\"\"\"\n", + " return preprocess_batch([opt], [sar], [meta])[0]\n", + "\n", + "# ---- 특징 추출 공통 함수 ----\n", + "KERNELS = [np.array(k, float) for k in (\n", + " [[-1, 0, 1], [-2, 0, 2], [-1, 0, 1]], [[-1, -2, -1], [0, 0, 0], [1, 2, 1]], [[0, 1, 0], [1, -4, 1], [0, 1, 0]],\n", + " [[2, -1, -1], [-1, 2, -1], [-1, -1, 2]], [[-1, -1, 2], [-1, 2, -1], [2, -1, -1]], [[1, 1, 1], [1, 1, 1], [1, 1, 1]])]\n", + "\n", + "\n", + "def conv_features(o):\n", + " \"\"\"간이 CNN 특징: 고정 합성곱 필터 6종 -> ReLU -> 4x4 최대 풀링(8x8 출력).\"\"\"\n", + " feats = []\n", + " for k in KERNELS:\n", + " r = np.maximum(ndimage.correlate(o - o.mean(), k / np.abs(k).sum()), 0)\n", + " feats.append(r.reshape(8, 4, 8, 4).max(axis=(1, 3)).ravel())\n", + " feats.append(np.maximum(-ndimage.correlate(o - o.mean(), k / np.abs(k).sum()), 0).reshape(8, 4, 8, 4).max(axis=(1, 3)).ravel())\n", + " return np.concatenate(feats)\n", + "\n", + "\n", + "def hog(o, cell=8, bins=8):\n", + " gy, gx = np.gradient(o)\n", + " mag, ang = np.hypot(gx, gy), np.mod(np.arctan2(gy, gx), np.pi)\n", + " b = np.minimum((ang / np.pi * bins).astype(int), bins - 1)\n", + " out = np.zeros((N // cell, N // cell, bins))\n", + " for i in range(N // cell):\n", + " for j in range(N // cell):\n", + " sl = (slice(i * cell, (i + 1) * cell), slice(j * cell, (j + 1) * cell))\n", + " out[i, j] = np.bincount(b[sl].ravel(), mag[sl].ravel(), bins)\n", + " return (out / (np.linalg.norm(out) + 1e-6)).ravel()\n", + "\n", + "\n", + "def shape_features(o, s):\n", + " \"\"\"객체 모양 특징: 가로 방향 투영으로 전체 길이·차체 길이·가는 돌출부(포신) 길이·두꺼운 부분(포탑) 위치를 잰다.\n", + " 방위 정규화 뒤에도 앞뒤(180도)가 뒤집힐 수 있으므로, 가는 돌출부가 오른쪽에 오도록 맞춘다.\"\"\"\n", + " sm = ndimage.uniform_filter(s, 3)\n", + " dark = np.abs(o - np.median(o))\n", + " m = (sm > 0.22) | (dark > 0.12)\n", + " lab, n = ndimage.label(m)\n", + " if n:\n", + " c = lab[10:22, 8:24]\n", + " k = np.bincount(c[c > 0]).argmax() if (c > 0).any() else 1\n", + " m = lab == k\n", + " p = m.sum(axis=0).astype(float)\n", + " cols = np.nonzero(p)[0]\n", + " if len(cols) == 0:\n", + " return np.zeros(8), o, s\n", + " thin = (p > 0) & (p <= 2)\n", + " left, right = thin[:N // 2].sum(), thin[N // 2:].sum()\n", + " if left > right:\n", + " o, s, m, p = o[:, ::-1], s[:, ::-1], m[:, ::-1], p[::-1]\n", + " cols = np.nonzero(p)[0]\n", + " body = np.nonzero(p > 3)[0]\n", + " d = np.where(m, dark if left <= right else dark[:, ::-1], 0)\n", + " heavy = d.sum(axis=0)\n", + " centre = (body[0] + body[-1]) / 2 if len(body) else N / 2\n", + " turret = (heavy * np.arange(N)).sum() / max(heavy.sum(), 1e-6) - centre\n", + " return np.array([cols[-1] - cols[0] + 1, len(body), ((p > 0) & (p <= 2)).sum(), p.max(), turret,\n", + " m.sum(), (cols[-1] - (body[-1] if len(body) else cols[-1])), m.sum() / max(len(cols) * p.max(), 1)]), o, s\n", + "\n", + "\n", + "def adv_features(o, s):\n", + " \"\"\"정밀 특징: 모양(길이·포신·포탑 위치) + HOG(윤곽) + SAR 반사 통계(위장막·구름 영향 적음) + 투영 윤곽.\"\"\"\n", + " shp, o, s = shape_features(o, s)\n", + " c = s[8:24, 4:28]\n", + " sm = ndimage.uniform_filter(s, 3)\n", + " m = sm > 0.2\n", + " dark = np.abs(o - np.median(o))\n", + " return np.concatenate([shp, hog(o), [c.mean(), c.max(), (sm > 0.25).mean(), (sm > 0.4).mean(), m.sum(axis=1).max(), m.sum(axis=0).max()],\n", + " sm.mean(axis=0).reshape(8, 4).mean(1), sm.mean(axis=1).reshape(8, 4).mean(1),\n", + " dark.mean(axis=0).reshape(8, 4).mean(1), dark.mean(axis=1).reshape(8, 4).mean(1),\n", + " [o.mean(), o.std(), dark.max(), (o > 0.65).mean(), (o < 0.3).mean()]])\n", + "\n", + "EX_SCEN = (\"clean\", \"camo\", \"occl30\", \"low_sun\", \"cloud\")\n", + "\n", + "\n", + "def featurize(O, S, M):\n", + " P = preprocess_batch(O, S, M)\n", + " return np.array([adv_features(o, s) for o, s, _ in P]), np.array([p[1][8:24, 4:28].mean() for p in P])\n", + "\n", + "\n", + "def train(with_decoy, seed=3):\n", + " cls = CLASSES + ([\"decoy\"] if with_decoy else [])\n", + " O, S, M = make_dataset(250 * len(cls), seed=seed, scen=tuple(SCEN), classes=cls)\n", + " X, _ = featurize(O, S, M)\n", + " return RandomForestClassifier(n_estimators=150, min_samples_leaf=2, random_state=0, n_jobs=1).fit(X, [m[\"cls\"] for m in M])\n", + "\n", + "\n", + "def decide(model, X, sar, tau):\n", + " \"\"\"분류 결과 + SAR-광학 일치성 규칙(선택): 전차·자주포로 분류했는데 SAR 금속 반사가 tau 미만이면 '모의 표적 의심'.\"\"\"\n", + " p = model.predict(X).astype(object)\n", + " if tau is not None:\n", + " p[np.isin(p, [\"tank\", \"spg\"]) & (sar < tau)] = \"decoy\"\n", + " return p\n", + "\n", + "\n", + "def aar_log(y, p, sc, ids):\n", + " rows = []\n", + " for yi, pi, s, i in zip(y, p, sc, ids):\n", + " if yi == pi:\n", + " continue\n", + " if yi == \"decoy\":\n", + " cause = f\"모의 표적을 {pi}(으)로 오판독\"\n", + " elif pi == \"decoy\":\n", + " cause = f\"실제 {yi}를 모의 표적으로 오판\"\n", + " else:\n", + " cause = f\"{yi}->{pi} 기종 혼동({s})\"\n", + " rows.append({\"원인\": cause, \"영상\": i})\n", + " return pd.DataFrame(rows, columns=[\"원인\", \"영상\"])\n", + "\n", + "\n", + "# 모의 판독 훈련 자료(모의 위장 표적 20% 포함): AAR 대상 500장 + 회귀 시험 500장\n", + "Oa, Sa, Ma = make_dataset(500, seed=21, scen=EX_SCEN, classes=CLASSES + [\"decoy\"])\n", + "Ob, Sb, Mb = make_dataset(500, seed=22, scen=EX_SCEN, classes=CLASSES + [\"decoy\"])\n", + "Xa, sar_a = featurize(Oa, Sa, Ma); ya, sca, ida = np.array([m[\"cls\"] for m in Ma]), [m[\"scen\"] for m in Ma], [m[\"id\"] for m in Ma]\n", + "Xb, sar_b = featurize(Ob, Sb, Mb); yb = np.array([m[\"cls\"] for m in Mb])\n", + "\n", + "# 1) 초기 설정: 모의 표적을 학습하지 않은 모델, 일치성 규칙 없음\n", + "m0 = train(False)\n", + "p0 = decide(m0, Xa, sar_a, None)\n", + "acc0 = (p0 == ya).mean()\n", + "log0 = aar_log(ya, p0, sca, ida)\n", + "print(f\"[1. 무엇이 일어났나] 초기 종합 정확도 {acc0:.3f}, 오판독 {len(log0)}건\")\n", + "print(log0[\"원인\"].value_counts().head(6).to_string())\n", + "print(f\"\\n[2. 왜 일어났나] 모의 표적 SAR 반사 평균 {sar_a[ya == 'decoy'].mean():.3f} vs 실제 전차·자주포 {sar_a[np.isin(ya, ['tank', 'spg'])].mean():.3f}\"\n", + " \" -> 광학 윤곽은 전차와 같지만 금속 반사가 약해, 모의 표적을 모르는 모델은 반사가 약한 민간 차량으로 몰아 버린다.\")\n", + "\n", + "# 2) 재보정: (모의 표적 학습 여부) x (SAR 일치성 임계값) 격자 탐색\n", + "m1 = train(True)\n", + "grid = []\n", + "for name, mdl in [(\"모의표적 미학습\", m0), (\"모의표적 학습\", m1)]:\n", + " for tau in [None, 0.12, 0.15, 0.18, 0.22]:\n", + " p = decide(mdl, Xa, sar_a, tau)\n", + " mil = np.isin(ya, list(MILITARY))\n", + " grid.append({\"모델\": name, \"SAR 임계값\": \"-\" if tau is None else tau, \"종합정확도\": round(float((p == ya).mean()), 3),\n", + " \"모의표적 적발률\": round(float((p[ya == \"decoy\"] == \"decoy\").mean()), 3),\n", + " \"실제 장비를 모의로 오판\": int((mil & (p == \"decoy\")).sum())})\n", + "g = pd.DataFrame(grid).sort_values([\"종합정확도\", \"실제 장비를 모의로 오판\"], ascending=[False, True])\n", + "print(\"\\n[3. 무엇을 바꾸나] 격자 탐색 상위 5개\")\n", + "print(g.head().to_string(index=False))\n", + "best = g.iloc[0]\n", + "mdl = m1 if best[\"모델\"] == \"모의표적 학습\" else m0\n", + "tau = None if best[\"SAR 임계값\"] == \"-\" else float(best[\"SAR 임계값\"])\n", + "p1 = decide(mdl, Xa, sar_a, tau)\n", + "acc1 = (p1 == ya).mean()\n", + "print(f\"\\n재보정값: {best['모델']}, SAR 일치성 임계값 {best['SAR 임계값']}\")\n", + "print(f\"종합 정확도 {acc0:.3f} -> {acc1:.3f} ({(acc1 - acc0) * 100:+.1f}%p, 목표 +10%p: {'달성' if acc1 - acc0 >= 0.1 else '미달'})\")\n", + "b0, b1 = (decide(m0, Xb, sar_b, None) == yb).mean(), (decide(mdl, Xb, sar_b, tau) == yb).mean()\n", + "print(f\"회귀 시험(별도 500장): {b0:.3f} -> {b1:.3f}\")\n", + "log1 = aar_log(ya, p1, sca, ida)\n", + "rep = set(log0[\"영상\"]) & set(log1[\"영상\"])\n", + "print(f\"재보정 후 남은 오판독 {len(log1)}건, 그중 재발 {len(rep)}건:\", log1[log1[\"영상\"].isin(rep)][\"원인\"].value_counts().head(3).to_dict())\n", + "print(\"SAR 일치성 규칙은 모의 표적 학습 후 추가 이득이 없고 임계값을 높이면 실제 장비를 모의 표적으로 오판하므로 적용하지 않는다(-).\")\n", + "print(\"[4. 남은 과제] 재발 건은 위장막 아래 기종 혼동으로, 다른 촬영 회차 교차 판독(미션 7)과 지속 학습 대상 등록으로 대응한다.\")\n", + "\n", + "tuned = f\"model:\\n train_with_decoy: {best['모델'] == '모의표적 학습'}\\n rf_trees: 150\\nrules:\\n sar_consistency_tau: {best['SAR 임계값']}\\nconfidence:\\n high: 0.9\\n medium: 0.6\"\n", + "print(\"\\n[재보정 파라미터(isr01_tuned_params.yaml 내용)]\\n\" + tuned)\n", + "\n", + "plt.figure(figsize=(7, 4))\n", + "plt.hist([sar_a[np.isin(ya, [\"tank\", \"spg\"])], sar_a[ya == \"decoy\"]], bins=25, label=[\"tank/spg\", \"decoy\"], alpha=.8)\n", + "if tau:\n", + " plt.axvline(tau, ls=\"--\", c=\"k\", label=f\"tau={tau}\")\n", + "plt.xlabel(\"mean SAR return (center)\"); plt.ylabel(\"chips\"); plt.legend(); plt.title(\"SAR-optical consistency for decoy screening\")\n", + "plt.show()\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 09 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + "[1. 무엇이 일어났나] 초기 종합 정확도 0.786, 오판독 107건\n", + "원인\n", + "모의 표적을 civil(으)로 오판독 100\n", + "tank->truck 기종 혼동(camo) 4\n", + "truck->tank 기종 혼동(cloud) 1\n", + "tank->truck 기종 혼동(clean) 1\n", + "tank->truck 기종 혼동(cloud) 1\n", + "\n", + "[2. 왜 일어났나] 모의 표적 SAR 반사 평균 0.089 vs 실제 전차·자주포 0.226 -> 광학 윤곽은 전차와 같지만 금속 반사가 약해, 모의 표적을 모르는 모델은 반사가 약한 민간 차량으로 몰아 버린다.\n", + "\n", + "[3. 무엇을 바꾸나] 격자 탐색 상위 5개\n", + " 모델 SAR 임계값 종합정확도 모의표적 적발률 실제 장비를 모의로 오판\n", + "모의표적 학습 - 0.990 1.0 0\n", + "모의표적 학습 0.12 0.990 1.0 0\n", + "모의표적 학습 0.15 0.990 1.0 0\n", + "모의표적 학습 0.18 0.960 1.0 16\n", + "모의표적 학습 0.22 0.788 1.0 102\n", + "\n", + "재보정값: 모의표적 학습, SAR 일치성 임계값 -\n", + "종합 정확도 0.786 -> 0.990 (+20.4%p, 목표 +10%p: 달성)\n", + "회귀 시험(별도 500장): 0.790 -> 0.984\n", + "재보정 후 남은 오판독 5건, 그중 재발 5건: {'tank->truck 기종 혼동(camo)': 4, 'truck->tank 기종 혼동(cloud)': 1}\n", + "SAR 일치성 규칙은 모의 표적 학습 후 추가 이득이 없고 임계값을 높이면 실제 장비를 모의 표적으로 오판하므로 적용하지 않는다(-).\n", + "[4. 남은 과제] 재발 건은 위장막 아래 기종 혼동으로, 다른 촬영 회차 교차 판독(미션 7)과 지속 학습 대상 등록으로 대응한다.\n", + "\n", + "[재보정 파라미터(isr01_tuned_params.yaml 내용)]\n", + "model:\n", + " train_with_decoy: True\n", + " rf_trees: 150\n", + "rules:\n", + " sar_consistency_tau: -\n", + "confidence:\n", + " high: 0.9\n", + " medium: 0.6\n", + "```\n", + "
\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 10. 공개 위성영상 장비 객체분류 시스템 통합 및 전략 ISR 평가 브리핑 패키지 완성\n", + "**소요시간**: 약 3.5시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "공개 위성영상 장비 객체분류 시스템을 최종 통합 검증하고 전략 정보 판단용 브리핑 패키지를 완성한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **메타데이터 검증부터 전처리, 정밀 분류, 신뢰도 엔진, 판독 대기열, 분석 화면까지 시스템을 통합 검증하고 전략 ISR 평가 브리핑 패키지를 완성한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **재보정 설정으로 전체 과정을 한 번에 실행하는 통합 코드를 완성하고 손상 타일과 메타데이터 오류가 자동 차단되는지 확인하고 유효성을 점검하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `src/main_isr01.py, README.md, docs/isr01_final_report.pdf(md)` 형태의 결과물을 완성한다.\n", + "\n", + "### 🔍 평가 및 검증 기준 (PASS 조건)\n", + "#### 미션 판정\n", + "\n", + "**평가 구분:** 필수\n", + "\n", + "**핵심 평가영역:** 군 실무 즉시 도입 가능성, 운용 신뢰도 및 전략적 기대효과\n", + "\n", + "#### 미션 PASS 조건\n", + "\n", + "- 정량 기준: End-to-End 통합 파이프라인 실행 성공률 100%, 테스트 커버리지 85% 이상\n", + "\n", + "\n", + "- 진행 기준: 원클릭 실행 스크립트 동작 확인 및 최종 발표 산출물 작성 완료\n", + "\n", + "\n", + "- 검증 방법: 최종 검증 위원회(평가관) 모의 시연 및 전체 기능 통합 검증\n", + "\n", + "\n", + "#### 채점자 판정 메모\n", + "\n", + "> 단순 코드 실행 여부뿐만 아니라 출력된 수치와 근거 자료가 군 실무 요구사항에 부합하는지 면밀히 확인한다.\n", + "\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "\"\"\"메타데이터 검증·전처리·정밀 분류(모의 표적 포함)·신뢰도 엔진·영상분석관 판독 대기열·분석 화면까지 공개 위성영상 장비 객체분류 시스템 전체를 한 파일로 통합 실행하고, 자체 점검(기능 커버리지 포함)과 전략 ISR 평가 브리핑 요약을 출력한다.\"\"\"\n", + "import hashlib\n", + "import json\n", + "import time\n", + "import numpy as np\n", + "import pandas as pd\n", + "import matplotlib.pyplot as plt\n", + "from scipy import ndimage\n", + "from sklearn.ensemble import RandomForestClassifier\n", + "from sklearn.linear_model import LogisticRegression\n", + "from sklearn.pipeline import make_pipeline\n", + "from sklearn.preprocessing import StandardScaler\n", + "\n", + "# ---------------------------------------------------------------------------\n", + "# [교육용가상자료] 합성 위성영상 조각(chip) 생성기 (실제 위성영상·장비 제원·위치 없음)\n", + "# 32x32 광학(EO) 영상과 SAR 영상 한 쌍에 추상 도형으로 만든 객체를 그린다. 도형 치수는 가상값이다.\n", + "# ---------------------------------------------------------------------------\n", + "N = 32\n", + "STD_GSD = 0.5 # 표준 해상도(m/픽셀, 가상 기준)\n", + "CLASSES = [\"tank\", \"spg\", \"truck\", \"civil\"]\n", + "MILITARY = {\"tank\", \"spg\", \"truck\"}\n", + "# 객체 좌표계(u: 길이 방향, v: 폭 방향, 표준 해상도 픽셀) 가상 도형: (u중심, v중심, 길이, 폭, 광학 밝기 차이)\n", + "SHAPES = {\n", + " \"tank\": [(0, 0, 11, 7, -40), (0.5, 0, 5, 5, -55), (6, 0, 7, 1.2, -60)],\n", + " \"spg\": [(0, 0, 16, 7.5, -38), (-4, 0, 6, 6.5, -52), (5, 0, 13, 1.4, -60)],\n", + " \"truck\": [(5, 0, 3.5, 5.5, -45), (-1.8, 0, 8.5, 6, -25)],\n", + " \"civil\": [(0, 0, 8, 3.8, 0), (1.2, 0, 2, 3.4, 0)],\n", + " \"decoy\": [(0, 0, 11, 7, -40), (0.5, 0, 5, 5, -42), (6, 0, 7, 1.2, -45)],\n", + " \"none\": [], # 객체 없는 배경 타일\n", + "}\n", + "SAR_RETURN = {\"tank\": 0.85, \"spg\": 0.85, \"truck\": 0.7, \"civil\": 0.45, \"decoy\": 0.18, \"none\": 0.12} # 금속 반사 세기(가상)\n", + "\n", + "\n", + "def _mask(cls, angle, scale, squash=1.0):\n", + " \"\"\"회전(angle, 도)·축척(scale)·비스듬 촬영 압축(squash)을 적용해 도형별 마스크와 밝기 차이를 만든다.\"\"\"\n", + " yy, xx = np.mgrid[0:N, 0:N] - (N - 1) / 2\n", + " yy = yy / squash\n", + " a = np.radians(angle)\n", + " u = (xx * np.cos(a) + yy * np.sin(a)) / scale\n", + " v = (-xx * np.sin(a) + yy * np.cos(a)) / scale\n", + " return [((np.abs(u - cu) <= L / 2) & (np.abs(v - cv) <= W / 2), d) for cu, cv, L, W, d in SHAPES[cls]]\n", + "\n", + "\n", + "def make_chip(cls, rng, cond=None):\n", + " \"\"\"chip 1개 = (광학 32x32, SAR 32x32, 메타데이터). cond: camo, occl, cloud, low_sun, off_nadir, low_res, speckle\"\"\"\n", + " c = {\"camo\": 0.0, \"occl\": 0.0, \"cloud\": 0.0, \"low_sun\": False, \"off_nadir\": 0.0, \"low_res\": False, \"speckle\": 1.0, **(cond or {})}\n", + " gsd = rng.uniform(0.9, 1.2) if c[\"low_res\"] else rng.uniform(0.4, 0.6)\n", + " scale = STD_GSD / gsd\n", + " angle, off = rng.uniform(0, 360), c[\"off_nadir\"] or rng.uniform(0, 15)\n", + " squash = np.cos(np.radians(off))\n", + " sun_el = rng.uniform(12, 22) if c[\"low_sun\"] else rng.uniform(40, 70)\n", + " sun_az = rng.uniform(0, 360)\n", + " bg = 115 + ndimage.gaussian_filter(rng.normal(0, 30, (N, N)), 2) + rng.normal(0, 5, (N, N))\n", + " parts = _mask(cls, angle, scale, squash)\n", + " body = np.any([m for m, _ in parts], axis=0) if parts else np.zeros((N, N), bool)\n", + " opt = bg.copy()\n", + " # 그림자: 태양 반대 방향으로 밀린 객체 모양(태양 고도가 낮을수록 길다)\n", + " L = min(8.0, 2.5 / np.tan(np.radians(sun_el))) * scale\n", + " sh = ndimage.shift(body.astype(float), (L * np.cos(np.radians(sun_az)), -L * np.sin(np.radians(sun_az))), order=0) > 0.5\n", + " opt[sh & ~body] -= 35 if cls != \"decoy\" else 25\n", + " if cls == \"civil\":\n", + " base = rng.choice([200.0, 60.0]) # 민간 차량 색상: 밝거나 어두움\n", + " opt[body] = base + rng.normal(0, 8, body.sum())\n", + " roof = parts[1][0]\n", + " opt[roof] = base * 0.8\n", + " else:\n", + " ref = bg[body].mean() if body.any() else bg.mean()\n", + " for m, d in parts:\n", + " opt[m] = ref + d + rng.normal(0, 4 if cls != \"decoy\" else 2, m.sum())\n", + " # 위장막: 객체 주변을 배경 질감으로 덮어 대비를 낮춘다\n", + " if c[\"camo\"] > 0 and cls != \"civil\":\n", + " net = ndimage.binary_dilation(body, iterations=2)\n", + " tex = 105 + ndimage.gaussian_filter(rng.normal(0, 25, (N, N)), 1)\n", + " opt[net] = (1 - c[\"camo\"]) * opt[net] + c[\"camo\"] * tex[net]\n", + " # 수목 차폐: 객체의 일부를 어두운 나무 덩어리로 가린다\n", + " if c[\"occl\"] > 0:\n", + " trees = ndimage.gaussian_filter(rng.random((N, N)), 2)\n", + " near = ndimage.binary_dilation(body, iterations=3)\n", + " cover = near & (trees > np.quantile(trees[body], 1 - c[\"occl\"])) if body.any() else np.zeros((N, N), bool)\n", + " opt[cover] = 70 + rng.normal(0, 6, cover.sum())\n", + " # 구름: 밝고 매끈한 덩어리(광학만 가림, SAR는 통과)\n", + " cmask = np.zeros((N, N), bool)\n", + " if c[\"cloud\"] > 0:\n", + " blob = ndimage.gaussian_filter(rng.random((N, N)), 4)\n", + " cmask = blob > np.quantile(blob, 1 - c[\"cloud\"])\n", + " opt[cmask] = 0.25 * opt[cmask] + 0.75 * (235 + rng.normal(0, 3, cmask.sum()))\n", + " # SAR: 곱셈형 스페클 잡음 + 금속 반사(모서리 이중 반사로 밝은 점)\n", + " sar = np.full((N, N), 0.12)\n", + " sar[body] = SAR_RETURN[cls] * rng.uniform(0.8, 1.2)\n", + " edge = body & ~ndimage.binary_erosion(body)\n", + " sar[edge] *= 1.3 if cls in MILITARY else 1.0\n", + " sar = sar * rng.gamma(4 / c[\"speckle\"], c[\"speckle\"] / 4, (N, N))\n", + " if c[\"low_res\"]: # 저해상도: 정보 손실(블러)\n", + " down = lambda a: np.kron(a.reshape(N // 2, 2, N // 2, 2).mean((1, 3)), np.ones((2, 2)))\n", + " opt, sar = ndimage.gaussian_filter(down(opt), 0.7), ndimage.gaussian_filter(down(sar), 0.7)\n", + " meta = {\"cls\": cls, \"gsd\": round(float(gsd), 2), \"off_nadir\": round(float(off), 1), \"sun_el\": round(float(sun_el), 1),\n", + " \"sun_az\": round(float(sun_az), 1), \"angle\": round(float(angle), 1), \"cloud_frac\": float(cmask.mean()),\n", + " \"cloud_on_obj\": float((cmask & body).sum() / max(body.sum(), 1)), **{k: c[k] for k in (\"camo\", \"occl\", \"low_sun\", \"low_res\")}}\n", + " return np.clip(opt, 0, 255), sar, meta\n", + "\n", + "\n", + "SCEN = { # 은폐·촬영 조건 시나리오 10종\n", + " \"clean\": {}, \"camo\": {\"camo\": 0.7}, \"occl30\": {\"occl\": 0.3}, \"occl50\": {\"occl\": 0.5}, \"cloud\": {\"cloud\": 0.25},\n", + " \"low_sun\": {\"low_sun\": True}, \"off_nadir\": {\"off_nadir\": 35.0}, \"low_res\": {\"low_res\": True},\n", + " \"speckle\": {\"speckle\": 3.0}, \"camo_occl\": {\"camo\": 0.6, \"occl\": 0.3}}\n", + "\n", + "\n", + "def make_dataset(n=400, seed=7, scen=(\"clean\",), classes=CLASSES):\n", + " rng = np.random.default_rng(seed)\n", + " O, S, M = np.zeros((n, N, N)), np.zeros((n, N, N)), []\n", + " for i in range(n):\n", + " cls, sc = classes[i % len(classes)], scen[(i // len(classes)) % len(scen)]\n", + " O[i], S[i], m = make_chip(cls, rng, SCEN[sc])\n", + " m.update({\"id\": f\"IMG{i:04d}\", \"scen\": sc})\n", + " M.append(m)\n", + " return O, S, M\n", + "# ---------------------------------------------------------------------------\n", + "\n", + "# ---- 전처리(해상도·기하 보정, 구름 차폐 처리, 방위 정규화) 공통 함수 ----\n", + "def _fit(a, fill):\n", + " \"\"\"중앙 기준으로 32x32에 맞게 자르거나 채운다.\"\"\"\n", + " out = np.full((N, N), fill, float)\n", + " h, w = a.shape\n", + " ys, xs = max(0, (h - N) // 2), max(0, (w - N) // 2)\n", + " yd, xd = max(0, (N - h) // 2), max(0, (N - w) // 2)\n", + " hh, ww = min(N, h), min(N, w)\n", + " out[yd:yd + hh, xd:xd + ww] = a[ys:ys + hh, xs:xs + ww]\n", + " return out\n", + "\n", + "\n", + "def cloud_mask(opt, sm, min_area=15):\n", + " \"\"\"광학에서 밝고(185 초과) 매끈한데 SAR에는 반사가 없는 영역을 구름으로 본다(SAR는 구름을 통과하므로 밝은 차량과 구분된다).\"\"\"\n", + " bright = opt > 185\n", + " if bright.sum() < min_area:\n", + " return np.zeros_like(bright)\n", + " local_sd = np.sqrt(np.maximum(ndimage.uniform_filter(opt ** 2, 3) - ndimage.uniform_filter(opt, 3) ** 2, 0))\n", + " cand = bright & (local_sd < 12) & (sm < 0.3)\n", + " lab, n = ndimage.label(cand)\n", + " if n == 0:\n", + " return cand\n", + " sizes = ndimage.sum(cand.astype(float), lab, range(1, n + 1))\n", + " return ndimage.binary_dilation(np.isin(lab, 1 + np.where(sizes >= min_area)[0]), iterations=2) & (opt > 140)\n", + "\n", + "\n", + "def orientation(opt, sm):\n", + " \"\"\"SAR 반사(위장·구름 영향 적음, sm은 평활화한 SAR)와 광학 대비를 합친 객체 마스크의 주축 방향(도).\"\"\"\n", + " m = (sm > 0.3) | (np.abs(opt - opt.mean()) > 30)\n", + " lab, n = ndimage.label(m)\n", + " if n == 0:\n", + " return 0.0, m\n", + " c = lab[N // 2 - 6:N // 2 + 6, N // 2 - 6:N // 2 + 6]\n", + " k = np.bincount(c[c > 0]).argmax() if (c > 0).any() else 1 + int(np.argmax(ndimage.sum(m.astype(float), lab, range(1, n + 1))))\n", + " m = lab == k\n", + " yy, xx = np.nonzero(m)\n", + " if len(yy) < 5:\n", + " return 0.0, m\n", + " dy, dx = yy - yy.mean(), xx - xx.mean() # 2차 모멘트로 주축 각도 계산\n", + " return float(np.degrees(0.5 * np.arctan2(2 * (dx * dy).mean(), (dx * dx).mean() - (dy * dy).mean()))), m\n", + "\n", + "\n", + "_YY, _XX = np.mgrid[0:N, 0:N] - (N - 1) / 2\n", + "\n", + "\n", + "def warp(stack, zy, zx, ang):\n", + " \"\"\"여러 장(B,채널,32,32)에 장별 확대·축소(zy, zx)와 회전(ang, 도)을 한 번의 쌍선형 보간으로 적용한다(벡터 연산).\"\"\"\n", + " a = np.radians(ang)[:, None, None]\n", + " zy, zx = np.asarray(zy)[:, None, None], np.asarray(zx)[:, None, None]\n", + " sy = np.clip((np.cos(a) * _YY + np.sin(a) * _XX) / zy + (N - 1) / 2, 0, N - 1.001) # 출력 좌표 -> 원본 좌표\n", + " sx = np.clip((-np.sin(a) * _YY + np.cos(a) * _XX) / zx + (N - 1) / 2, 0, N - 1.001)\n", + " y0, x0 = sy.astype(int), sx.astype(int)\n", + " fy, fx = (sy - y0).reshape(len(stack), 1, -1), (sx - x0).reshape(len(stack), 1, -1)\n", + " flat = stack.reshape(len(stack), stack.shape[1], -1)\n", + " lin = (y0 * N + x0).reshape(len(stack), 1, -1)\n", + " g = lambda d: np.take_along_axis(flat, np.broadcast_to(lin + d, (len(stack), stack.shape[1], lin.shape[2])), axis=2)\n", + " out = g(0) * (1 - fy) * (1 - fx) + g(N) * fy * (1 - fx) + g(1) * (1 - fy) * fx + g(N + 1) * fy * fx\n", + " return out.reshape(stack.shape)\n", + "\n", + "\n", + "def preprocess_batch(O, S, M):\n", + " \"\"\"여러 장을 한 번에 전처리한다. 0) 결측 보정 1) 해상도·비스듬 촬영 보정(표준 0.5m/픽셀)\n", + " 2) 구름 차폐 영역을 배경값으로 채움 3) 객체 주축을 가로로 회전 4) 0~1 정규화.\"\"\"\n", + " src = np.stack([np.asarray(O, float), np.asarray(S, float)], axis=1)\n", + " if np.isnan(src).any(): # 0) 센서 결측 픽셀 보정\n", + " mean = np.nanmean(src, axis=(2, 3), keepdims=True)\n", + " src = np.where(np.isnan(src), mean, src)\n", + " zy = np.array([m[\"gsd\"] / STD_GSD / np.cos(np.radians(m[\"off_nadir\"])) for m in M])\n", + " zx = np.array([m[\"gsd\"] / STD_GSD for m in M])\n", + " out = warp(src, zy, zx, np.zeros(len(M)))\n", + " sm = ndimage.uniform_filter(out[:, 1], (1, 3, 3))\n", + " angs, cloud = np.zeros(len(M)), np.zeros(len(M))\n", + " for i in range(len(M)):\n", + " cm = cloud_mask(out[i, 0], sm[i])\n", + " if cm.any():\n", + " fill = out[i, 0][~cm].mean() if (~cm).any() else 115.0\n", + " out[i, 0][cm] = fill\n", + " back = warp(cm[None, None].astype(float), [1 / zy[i]], [1 / zx[i]], np.zeros(1))[0, 0] > 0.5\n", + " src[i, 0][back] = fill # 원본의 같은 구름 영역도 채움\n", + " cloud[i] = cm.mean()\n", + " angs[i] = orientation(out[i, 0], sm[i])[0]\n", + " rot = np.abs(angs) > 3 # 3도 이하는 회전 생략\n", + " if rot.any():\n", + " out[rot] = warp(src[rot], zy[rot], zx[rot], angs[rot])\n", + " return [(np.clip(o / 255, 0, 1), np.clip(s / 1.5, 0, 1), float(c)) for (o, s), c in zip(out, cloud)]\n", + "\n", + "\n", + "def preprocess(opt, sar, meta):\n", + " \"\"\"한 장 전처리(preprocess_batch와 같은 처리).\"\"\"\n", + " return preprocess_batch([opt], [sar], [meta])[0]\n", + "\n", + "# ---- 특징 추출 공통 함수 ----\n", + "KERNELS = [np.array(k, float) for k in (\n", + " [[-1, 0, 1], [-2, 0, 2], [-1, 0, 1]], [[-1, -2, -1], [0, 0, 0], [1, 2, 1]], [[0, 1, 0], [1, -4, 1], [0, 1, 0]],\n", + " [[2, -1, -1], [-1, 2, -1], [-1, -1, 2]], [[-1, -1, 2], [-1, 2, -1], [2, -1, -1]], [[1, 1, 1], [1, 1, 1], [1, 1, 1]])]\n", + "\n", + "\n", + "def conv_features(o):\n", + " \"\"\"간이 CNN 특징: 고정 합성곱 필터 6종 -> ReLU -> 4x4 최대 풀링(8x8 출력).\"\"\"\n", + " feats = []\n", + " for k in KERNELS:\n", + " r = np.maximum(ndimage.correlate(o - o.mean(), k / np.abs(k).sum()), 0)\n", + " feats.append(r.reshape(8, 4, 8, 4).max(axis=(1, 3)).ravel())\n", + " feats.append(np.maximum(-ndimage.correlate(o - o.mean(), k / np.abs(k).sum()), 0).reshape(8, 4, 8, 4).max(axis=(1, 3)).ravel())\n", + " return np.concatenate(feats)\n", + "\n", + "\n", + "def hog(o, cell=8, bins=8):\n", + " gy, gx = np.gradient(o)\n", + " mag, ang = np.hypot(gx, gy), np.mod(np.arctan2(gy, gx), np.pi)\n", + " b = np.minimum((ang / np.pi * bins).astype(int), bins - 1)\n", + " out = np.zeros((N // cell, N // cell, bins))\n", + " for i in range(N // cell):\n", + " for j in range(N // cell):\n", + " sl = (slice(i * cell, (i + 1) * cell), slice(j * cell, (j + 1) * cell))\n", + " out[i, j] = np.bincount(b[sl].ravel(), mag[sl].ravel(), bins)\n", + " return (out / (np.linalg.norm(out) + 1e-6)).ravel()\n", + "\n", + "\n", + "def shape_features(o, s):\n", + " \"\"\"객체 모양 특징: 가로 방향 투영으로 전체 길이·차체 길이·가는 돌출부(포신) 길이·두꺼운 부분(포탑) 위치를 잰다.\n", + " 방위 정규화 뒤에도 앞뒤(180도)가 뒤집힐 수 있으므로, 가는 돌출부가 오른쪽에 오도록 맞춘다.\"\"\"\n", + " sm = ndimage.uniform_filter(s, 3)\n", + " dark = np.abs(o - np.median(o))\n", + " m = (sm > 0.22) | (dark > 0.12)\n", + " lab, n = ndimage.label(m)\n", + " if n:\n", + " c = lab[10:22, 8:24]\n", + " k = np.bincount(c[c > 0]).argmax() if (c > 0).any() else 1\n", + " m = lab == k\n", + " p = m.sum(axis=0).astype(float)\n", + " cols = np.nonzero(p)[0]\n", + " if len(cols) == 0:\n", + " return np.zeros(8), o, s\n", + " thin = (p > 0) & (p <= 2)\n", + " left, right = thin[:N // 2].sum(), thin[N // 2:].sum()\n", + " if left > right:\n", + " o, s, m, p = o[:, ::-1], s[:, ::-1], m[:, ::-1], p[::-1]\n", + " cols = np.nonzero(p)[0]\n", + " body = np.nonzero(p > 3)[0]\n", + " d = np.where(m, dark if left <= right else dark[:, ::-1], 0)\n", + " heavy = d.sum(axis=0)\n", + " centre = (body[0] + body[-1]) / 2 if len(body) else N / 2\n", + " turret = (heavy * np.arange(N)).sum() / max(heavy.sum(), 1e-6) - centre\n", + " return np.array([cols[-1] - cols[0] + 1, len(body), ((p > 0) & (p <= 2)).sum(), p.max(), turret,\n", + " m.sum(), (cols[-1] - (body[-1] if len(body) else cols[-1])), m.sum() / max(len(cols) * p.max(), 1)]), o, s\n", + "\n", + "\n", + "def adv_features(o, s):\n", + " \"\"\"정밀 특징: 모양(길이·포신·포탑 위치) + HOG(윤곽) + SAR 반사 통계(위장막·구름 영향 적음) + 투영 윤곽.\"\"\"\n", + " shp, o, s = shape_features(o, s)\n", + " c = s[8:24, 4:28]\n", + " sm = ndimage.uniform_filter(s, 3)\n", + " m = sm > 0.2\n", + " dark = np.abs(o - np.median(o))\n", + " return np.concatenate([shp, hog(o), [c.mean(), c.max(), (sm > 0.25).mean(), (sm > 0.4).mean(), m.sum(axis=1).max(), m.sum(axis=0).max()],\n", + " sm.mean(axis=0).reshape(8, 4).mean(1), sm.mean(axis=1).reshape(8, 4).mean(1),\n", + " dark.mean(axis=0).reshape(8, 4).mean(1), dark.mean(axis=1).reshape(8, 4).mean(1),\n", + " [o.mean(), o.std(), dark.max(), (o > 0.65).mean(), (o < 0.3).mean()]])\n", + "\n", + "CONFIG = {\"train_with_decoy\": True, \"high\": 0.9, \"medium\": 0.6, \"hvt_types\": [\"spg\"]} # 미션 9 재보정값\n", + "CALLED = set()\n", + "\n", + "\n", + "def cover(fn):\n", + " \"\"\"기능 커버리지 측정용 표시: 통합 실행 중 호출된 기능을 기록한다.\"\"\"\n", + " def wrap(*a, **k):\n", + " CALLED.add(fn.__name__)\n", + " return fn(*a, **k)\n", + " wrap.__name__ = fn.__name__\n", + " return wrap\n", + "\n", + "\n", + "preprocess, adv_features, shape_features, hog, cloud_mask, orientation = map(cover, (preprocess, adv_features, shape_features, hog, cloud_mask, orientation))\n", + "\n", + "\n", + "@cover\n", + "def validate_tile(o, s, m):\n", + " errs = []\n", + " if o.shape != (N, N) or s.shape != (N, N):\n", + " errs.append(\"타일 크기 오류\")\n", + " if not 0.1 <= m[\"gsd\"] <= 30:\n", + " errs.append(\"gsd 범위 오류\")\n", + " if not np.isfinite(o).all():\n", + " errs.append(\"결측 픽셀\")\n", + " return errs\n", + "\n", + "\n", + "@cover\n", + "def quality(P, prob):\n", + " srt = np.sort(prob, axis=1)\n", + " ent = -(prob * np.log(np.clip(prob, 1e-9, 1))).sum(1) / np.log(prob.shape[1])\n", + " return np.c_[srt[:, -1], srt[:, -1] - srt[:, -2], ent, [p[2] for p in P], [p[1][8:24, 4:28].mean() for p in P]]\n", + "\n", + "\n", + "@cover\n", + "def grade(c):\n", + " return \"High\" if c >= CONFIG[\"high\"] else (\"Medium\" if c >= CONFIG[\"medium\"] else \"Low\")\n", + "\n", + "\n", + "class Queue:\n", + " def __init__(self):\n", + " self.items, self.log = [], []\n", + "\n", + " @cover\n", + " def submit(self, iid, pred, g, conf):\n", + " prev = self.log[-1][\"hash\"] if self.log else \"GENESIS\"\n", + " body = {\"event\": \"SUBMIT\", \"id\": iid, \"pred\": pred, \"grade\": g, \"conf\": round(conf, 3), \"hvt\": pred in CONFIG[\"hvt_types\"]}\n", + " self.log.append({**body, \"prev\": prev, \"hash\": hashlib.sha256((prev + json.dumps(body, sort_keys=True)).encode()).hexdigest()})\n", + " self.items.append({**body, \"status\": \"PENDING\"})\n", + "\n", + " @cover\n", + " def verify(self):\n", + " prev = \"GENESIS\"\n", + " for r in self.log:\n", + " body = {k: v for k, v in r.items() if k not in (\"prev\", \"hash\")}\n", + " if r[\"prev\"] != prev or r[\"hash\"] != hashlib.sha256((prev + json.dumps(body, sort_keys=True)).encode()).hexdigest():\n", + " return False\n", + " prev = r[\"hash\"]\n", + " return True\n", + "\n", + "\n", + "TARGETS = {\"validate_tile\", \"preprocess\", \"cloud_mask\", \"orientation\", \"adv_features\", \"shape_features\", \"hog\", \"quality\", \"grade\", \"submit\", \"verify\", \"dashboard\"}\n", + "LABELS = CLASSES + [\"decoy\", \"none\"]\n", + "\n", + "# ---------------- 1. 학습(분류 모델 + 신뢰도 가중치) ----------------\n", + "t_start = time.perf_counter()\n", + "O, S, M = make_dataset(200 * len(LABELS), seed=3, scen=tuple(SCEN), classes=LABELS)\n", + "clf = RandomForestClassifier(n_estimators=150, min_samples_leaf=2, random_state=0, n_jobs=1)\n", + "clf.fit(np.array([adv_features(o, s) for o, s, _ in preprocess_batch(O, S, M)]), [m[\"cls\"] for m in M])\n", + "Oc, Sc, Mc = make_dataset(480, seed=6, scen=tuple(SCEN), classes=LABELS)\n", + "Pc = preprocess_batch(Oc, Sc, Mc)\n", + "prc = clf.predict_proba(np.array([adv_features(o, s) for o, s, _ in Pc]))\n", + "conf_model = make_pipeline(StandardScaler(), LogisticRegression(max_iter=2000)).fit(quality(Pc, prc), clf.classes_[prc.argmax(1)] == np.array([m[\"cls\"] for m in Mc]))\n", + "\n", + "# ---------------- 2. 운용 자료(가상 판독 지역 300타일) 처리 ----------------\n", + "Oo, So, Mo = make_dataset(300, seed=31, scen=(\"clean\", \"camo\", \"occl30\", \"cloud\", \"low_sun\", \"low_res\"), classes=LABELS)\n", + "ops = list(zip(Oo, So, Mo))\n", + "ops[5] = (Oo[5][:20], So[5], Mo[5]) # 손상 타일 1건(잘림)\n", + "ops[9] = (Oo[9], So[9], {**Mo[9], \"gsd\": 0.0}) # 메타데이터 오류 1건\n", + "queue, rejected, res, ms = Queue(), {}, [], []\n", + "for o, s, m in ops:\n", + " errs = validate_tile(o, s, m)\n", + " if errs:\n", + " rejected[m[\"id\"]] = errs; continue\n", + " t0 = time.perf_counter()\n", + " P = [preprocess(o, s, m)]\n", + " pr = clf.predict_proba([adv_features(P[0][0], P[0][1])])\n", + " pred = clf.classes_[pr.argmax()]\n", + " conf = float(conf_model.predict_proba(quality(P, pr))[0, 1])\n", + " g = grade(conf)\n", + " if pred != \"none\":\n", + " queue.submit(m[\"id\"], pred, g, conf)\n", + " ms.append((time.perf_counter() - t0) * 1000)\n", + " res.append({\"id\": m[\"id\"], \"y\": m[\"cls\"], \"p\": pred, \"grade\": g, \"conf\": conf, \"scen\": m[\"scen\"]})\n", + "R = pd.DataFrame(res)\n", + "acc = (R.y == R.p).mean()\n", + "milR = R[R.y.isin(list(MILITARY))]\n", + "rec = (milR.y == milR.p).mean()\n", + "decoy_catch = (R[R.y == \"decoy\"].p == \"decoy\").mean()\n", + "hi = R[(R.grade == \"High\") & R.p.isin(list(MILITARY))]\n", + "hi_err = (hi.y != hi.p).mean() if len(hi) else 0.0\n", + "\n", + "\n", + "# ---------------- 3. 분석 화면 ----------------\n", + "@cover\n", + "def dashboard():\n", + " plt.style.use(\"dark_background\")\n", + " fig, ax = plt.subplots(2, 2, figsize=(12, 9))\n", + " T = 10\n", + " mosaic = np.block([[np.asarray(Oo[r * T + c]) for c in range(T)] for r in range(T)])\n", + " ax[0, 0].imshow(mosaic, cmap=\"gray\")\n", + " for k, r in R[R.id.isin([f\"IMG{i:04d}\" for i in range(100)])].iterrows():\n", + " i = int(r.id[3:])\n", + " if r.p != \"none\":\n", + " ax[0, 0].scatter(i % T * N + 16, i // T * N + 16, s=60, facecolors=\"none\",\n", + " edgecolors=\"red\" if r.p in MILITARY else (\"yellow\" if r.p == \"decoy\" else \"deepskyblue\"))\n", + " ax[0, 0].set_title(\"First 100 tiles (red = military, yellow = decoy)\"); ax[0, 0].axis(\"off\")\n", + " vc = R[R.p != \"none\"].p.value_counts()\n", + " ax[0, 1].bar(vc.index.tolist(), vc.values, color=\"deepskyblue\"); ax[0, 1].set_title(\"Detections by type\")\n", + " gc = R[R.p != \"none\"].grade.value_counts().reindex([\"High\", \"Medium\", \"Low\"]).fillna(0)\n", + " ax[1, 0].bar(gc.index.tolist(), gc.values, color=[\"red\", \"orange\", \"gray\"]); ax[1, 0].set_title(\"Review queue by grade (all pending)\")\n", + " ax[1, 1].axis(\"off\")\n", + " ax[1, 1].text(0, .9, f\"Accuracy {acc:.1%}\\nMilitary rec. {rec:.1%}\\nDecoy caught {decoy_catch:.1%}\\nQueue {len(queue.items)} pending\\n\"\n", + " f\"Latency {np.mean(ms):.1f} ms/tile\", family=\"monospace\", fontsize=13, va=\"top\")\n", + " fig.suptitle(\"ISR01 integrated satellite object classification - synthetic data only\"); plt.tight_layout(); plt.show()\n", + "\n", + "\n", + "dashboard()\n", + "\n", + "# ---------------- 4. 자체 점검(통합 테스트) ----------------\n", + "log_ok = queue.verify()\n", + "cov = len(CALLED & TARGETS) / len(TARGETS)\n", + "chk = {\n", + " \"손상 타일·메타데이터 오류 차단\": set(rejected) == {\"IMG0005\", \"IMG0009\"},\n", + " \"처리 타일 298장\": len(res) == 298,\n", + " \"종합 정확도 90% 이상\": acc >= 0.9,\n", + " \"군용 기종 식별 재현율 85% 이상\": rec >= 0.85,\n", + " \"모의 표적 적발률 80% 이상\": decoy_catch >= 0.8,\n", + " \"자동 승인 0건(전부 판독 대기)\": all(i[\"status\"] == \"PENDING\" for i in queue.items),\n", + " \"식별 건 전량 대기열 등록\": len(queue.items) == int((R.p != \"none\").sum()),\n", + " \"감사 로그 무결성\": log_ok,\n", + " \"타일당 200ms 이내\": float(np.mean(ms)) < 200,\n", + " \"기능 커버리지 85% 이상\": cov >= 0.85,\n", + "}\n", + "passed = sum(chk.values())\n", + "for k, v in chk.items():\n", + " print(f\"[{'PASS' if v else 'FAIL'}] {k}\")\n", + "print(f\"자체 점검 {passed}/{len(chk)} 통과, 기능 커버리지 {cov:.0%} ({len(CALLED & TARGETS)}/{len(TARGETS)}), 파이프라인 실행 성공 {'예' if passed == len(chk) else '아니오'}\")\n", + "\n", + "# ---------------- 5. 전략 ISR 평가 브리핑 요약 ----------------\n", + "print(f\"\"\"\n", + "[위성영상 장비 객체분류 평가 브리핑 요약 - 교육용가상자료]\n", + "1. 처리: 타일 {len(ops)}장 중 {len(res)}장 판독, 손상·오류 {len(rejected)}건 차단({', '.join(rejected)})\n", + "2. 성능: 종합 정확도 {acc:.1%}, 군용 기종 식별 재현율 {rec:.1%}, 모의 표적 적발 {decoy_catch:.1%}, 타일당 {np.mean(ms):.1f} ms, 전체 {time.perf_counter() - t_start:.0f} s\n", + "3. 판독 대기: {len(queue.items)}건 (High {int((R[R.p != 'none'].grade == 'High').sum())} / Medium {int((R[R.p != 'none'].grade == 'Medium').sum())} / Low {int((R[R.p != 'none'].grade == 'Low').sum())}), HVT 후보 {sum(i['hvt'] for i in queue.items)}건, 자동 승인 0건\n", + "4. 신뢰도: High 등급 군용 식별 {len(hi)}건 중 오분류 {hi_err:.1%} -> 등급은 판독 순서 결정용이며 최종 판독은 영상분석관이 한다\n", + "5. 한계: 합성 32x32 영상 기준 결과로, 실제 위성영상·장비에는 재학습과 재검증이 필요하다. 인접 체계 연동은 판독 승인 결과만 표준 필드로 전달한다.\"\"\")\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 10 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + "[PASS] 손상 타일·메타데이터 오류 차단\n", + "[PASS] 처리 타일 298장\n", + "[PASS] 종합 정확도 90% 이상\n", + "[PASS] 군용 기종 식별 재현율 85% 이상\n", + "[PASS] 모의 표적 적발률 80% 이상\n", + "[PASS] 자동 승인 0건(전부 판독 대기)\n", + "[PASS] 식별 건 전량 대기열 등록\n", + "[PASS] 감사 로그 무결성\n", + "[PASS] 타일당 200ms 이내\n", + "[PASS] 기능 커버리지 85% 이상\n", + "자체 점검 10/10 통과, 기능 커버리지 100% (12/12), 파이프라인 실행 성공 예\n", + "\n", + "[위성영상 장비 객체분류 평가 브리핑 요약 - 교육용가상자료]\n", + "1. 처리: 타일 300장 중 298장 판독, 손상·오류 2건 차단(IMG0005, IMG0009)\n", + "2. 성능: 종합 정확도 96.6%, 군용 기종 식별 재현율 96.0%, 모의 표적 적발 100.0%, 타일당 14.0 ms, 전체 10 s\n", + "3. 판독 대기: 251건 (High 223 / Medium 21 / Low 7), HVT 후보 51건, 자동 승인 0건\n", + "4. 신뢰도: High 등급 군용 식별 133건 중 오분류 2.3% -> 등급은 판독 순서 결정용이며 최종 판독은 영상분석관이 한다\n", + "5. 한계: 합성 32x32 영상 기준 결과로, 실제 위성영상·장비에는 재학습과 재검증이 필요하다. 인접 체계 연동은 판독 승인 결과만 표준 필드로 전달한다.\n", + "```\n", + "
\n" + ] + } + ], + "metadata": { + "colab": { + "provenance": [] + }, + "kernelspec": { + "display_name": "Python 3", + "name": "python3" + }, + "language_info": { + "name": "python" + } + }, + "nbformat": 4, + "nbformat_minor": 0 +} \ No newline at end of file diff --git a/uploads/ISR01/ISR01_student.ipynb b/uploads/ISR01/ISR01_student.ipynb new file mode 100644 index 0000000..b605f14 --- /dev/null +++ b/uploads/ISR01/ISR01_student.ipynb @@ -0,0 +1,3854 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# 🎯 [ISR01] 공개 위성영상 장비·차량 객체분류 정확도 평가\n", + "### Google Colab 실습 환경 (정보·감시·정찰(ISR) / 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/ISR01/ISR01_solution.ipynb)\n", + "\n", + "---\n", + "\n", + "## 📌 1. 프로젝트 개요 및 배경\n", + "\n", + "| 항목 | 내용 |\n", + "| :--- | :--- |\n", + "| **과정 분류** | 정보·감시·정찰(ISR) (Level 3) |\n", + "| **학습 소요 시간** | 10개 미션 (5일 / 총 27.5시간) |\n", + "| **환경 사양** | Python 3.10+, numpy, pandas, matplotlib, scipy, scikit-learn |\n", + "\n", + "### 📖 프로젝트 목표\n", + "러시아-우크라이나 전쟁에서는 상용·공개 위성영상이 대량으로 공개되면서 영상에서 장비와 차량을 빠르게 분류하는 능력과 함께, 위장막·수목 차폐·구름·모의 위장 표적 때문에 생기는 오판독을 걸러내는 능력이 중요해졌다. 본 프로젝트에서는 실제 위성영상 대신 코드로 만든 교육용 합성 영상(광학·SAR 한 쌍)을 사용해 메타데이터 스키마 정의, 해상도·구름·방위 정규화 전처리, Baseline 분류, 은폐 조건 대응 정밀 분류, 신뢰도 점수와 오분류 필터, 식별 확률 지도 화면, 고가치 표적 판독 승인(HITL), 대량 타일 부하 시험, 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. 상용 위성(광학/SAR) 영상 특성 분석 및 군용 장비/차량 메타데이터 규격 정의\n", + "**소요시간**: 약 1.5시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "오픈소스 광학 및 SAR 위성영상의 해상도, 메타데이터, 군용 장비/차량(전차, 자주포, 트럭) 분류 기준을 분석하고 데이터셋 스키마를 설계한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **공개·상용 광학 및 SAR 위성영상의 해상도, 촬영 조건, 출처 메타데이터와 군용 장비·차량(전차, 자주포, 트럭) 분류 기준을 정리해 데이터셋 스키마와 검증기를 만든다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **영상 출처(제공처·라이선스·촬영 시각), 센서(광학/SAR, 지상 해상도, 비스듬 촬영 각도, 태양 고도, 구름 비율), 전술 파라미터(기상, 위치 오차, 전달 지연), 객체 라벨(기종, 위치, 방위, 은폐 상태, 판독 상태) 필드 정의하고 검증하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `data/schema/isr01_input_schema.json 및 docs/isr01_data_spec.md` 형태의 결과물을 완성한다.\n", + "\n", + "### 🔍 평가 및 검증 기준 (PASS 조건)\n", + "#### 미션 판정\n", + "\n", + "**평가 구분:** 필수\n", + "\n", + "**핵심 평가영역:** 정보·감시·정찰(ISR) 실무 및 러시아-우크라이나 전쟁 전장 환경과의 군사적 부합성\n", + "\n", + "#### 미션 PASS 조건\n", + "\n", + "- 정량 기준: 스키마 필드 정의 완성도 100%, 샘플 데이터 검증 오류 0건\n", + "\n", + "\n", + "- 진행 기준: 입력 데이터 유효성 검증 스크립트 통과 및 명세서 문서화 완료\n", + "\n", + "\n", + "- 검증 방법: JSON Schema 검증 도구 및 샘플 데이터 파싱 단위 테스트\n", + "\n", + "\n", + "#### 채점자 판정 메모\n", + "\n", + "> 단순 코드 실행 여부뿐만 아니라 출력된 수치와 근거 자료가 군 실무 요구사항에 부합하는지 면밀히 확인한다.\n", + "\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "\"\"\"공개·상용 위성영상(광학/SAR)의 해상도·촬영 조건 메타데이터와 군용 장비·차량(전차, 자주포, 트럭) 라벨의 데이터셋 스키마를 정의하고, 가상 샘플 메타데이터를 검증한다.\"\"\"\n", + "import json\n", + "import re\n", + "import numpy as np\n", + "import pandas as pd\n", + "\n", + "# 1) 스키마 정의: 영상 출처·라이선스, 센서·촬영 조건, 전술 파라미터, 객체 라벨(클래스·위치·방위·은폐 상태)\n", + "SCHEMA = {\n", + " \"required\": [\"image_id\", \"source\", \"sensor\", \"tactical\", \"labels\"],\n", + " \"fields\": {\n", + " \"image_id\": {\"type\": str, \"pattern\": r\"^IMG\\d{4}$\"},\n", + " },\n", + " \"source\": {\n", + " \"provider\": {\"type\": str, \"enum\": [\"open_data\", \"commercial_sample\", \"partner_share\"]},\n", + " \"license\": {\"type\": str, \"pattern\": r\"^\\S.{2,}$\"},\n", + " \"acquired_at\": {\"type\": str, \"pattern\": r\"^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}Z$\"},\n", + " },\n", + " \"sensor\": {\n", + " \"type\": {\"type\": str, \"enum\": [\"optical\", \"SAR\"]},\n", + " \"gsd_m\": {\"type\": (int, float), \"min\": 0.1, \"max\": 30},\n", + " \"off_nadir_deg\": {\"type\": (int, float), \"min\": 0, \"max\": 60},\n", + " \"sun_elevation_deg\": {\"type\": (int, float), \"min\": -10, \"max\": 90},\n", + " \"cloud_cover_pct\": {\"type\": (int, float), \"min\": 0, \"max\": 100},\n", + " },\n", + " \"tactical\": {\n", + " \"weather\": {\"type\": str, \"enum\": [\"clear\", \"haze\", \"cloudy\", \"snow\"]},\n", + " \"geo_error_m\": {\"type\": (int, float), \"min\": 0, \"max\": 1000},\n", + " \"delivery_latency_h\": {\"type\": (int, float), \"min\": 0, \"max\": 720},\n", + " },\n", + " \"label\": {\n", + " \"class\": {\"type\": str, \"enum\": [\"tank\", \"spg\", \"truck\", \"civil_vehicle\", \"decoy\", \"unknown\"]},\n", + " \"bbox\": {\"type\": list, \"length\": 4},\n", + " \"orientation_deg\": {\"type\": (int, float), \"min\": 0, \"max\": 360},\n", + " \"concealment\": {\"type\": str, \"enum\": [\"none\", \"camo_net\", \"tree\", \"cloud\", \"partial\"]},\n", + " \"status\": {\"type\": str, \"enum\": [\"auto_draft\", \"analyst_verified\"]},\n", + " },\n", + "}\n", + "\n", + "\n", + "def check(name, value, rule):\n", + " \"\"\"값 하나를 규칙(type, enum, min, max, length, pattern)으로 검사해 오류 목록을 돌려준다.\"\"\"\n", + " # [TODO : 학생 실습 1] 상용 위성(광학/SAR) 영상 특성 분석 및 군용 장비/차량 메타데이터 규격 정의 관련 핵심 로직을 구현하세요.\n", + " # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n", + " # <<< 여기에 코드를 작성하세요 >>>\n", + " pass # 작성 후 pass를 제거하세요.\n", + "def validate(rec):\n", + " # [TODO : 학생 실습 1] 상용 위성(광학/SAR) 영상 특성 분석 및 군용 장비/차량 메타데이터 규격 정의 관련 핵심 로직을 구현하세요.\n", + " # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n", + " # <<< 여기에 코드를 작성하세요 >>>\n", + " pass # 작성 후 pass를 제거하세요.\n", + "rng = np.random.default_rng(1)\n", + "classes = SCHEMA[\"label\"][\"class\"][\"enum\"]\n", + "records = []\n", + "for i in range(20):\n", + " sensor = str(rng.choice([\"optical\", \"SAR\"]))\n", + " labels = [{\"class\": str(rng.choice(classes)), \"bbox\": [int(rng.integers(0, 480)), int(rng.integers(0, 480)), 32, 32],\n", + " \"orientation_deg\": float(round(rng.uniform(0, 360), 1)),\n", + " \"concealment\": str(rng.choice(SCHEMA[\"label\"][\"concealment\"][\"enum\"])), \"status\": \"auto_draft\"} for _ in range(3)]\n", + " records.append({\"image_id\": f\"IMG{i:04d}\",\n", + " \"source\": {\"provider\": str(rng.choice(SCHEMA[\"source\"][\"provider\"][\"enum\"])), \"license\": \"CC-BY-4.0(가상)\",\n", + " \"acquired_at\": f\"2026-0{int(rng.integers(1, 9))}-1{int(rng.integers(0, 9))}T0{int(rng.integers(0, 9))}:30Z\"},\n", + " \"sensor\": {\"type\": sensor, \"gsd_m\": float(round(rng.uniform(0.3, 3.0), 2)), \"off_nadir_deg\": float(round(rng.uniform(0, 40), 1)),\n", + " \"sun_elevation_deg\": float(round(rng.uniform(15, 70), 1)), \"cloud_cover_pct\": float(round(rng.uniform(0, 60), 1))},\n", + " \"tactical\": {\"weather\": str(rng.choice([\"clear\", \"haze\", \"cloudy\", \"snow\"])),\n", + " \"geo_error_m\": float(round(rng.uniform(1, 30), 1)), \"delivery_latency_h\": float(round(rng.uniform(1, 72), 1))},\n", + " \"labels\": labels})\n", + "\n", + "# 3) 정상 데이터 검증\n", + "ok = sum(1 for r in records if not validate(r))\n", + "print(f\"정상 샘플 {len(records)}건 중 검증 통과 {ok}건, 오류 {len(records) - ok}건 (목표: 오류 0건 {'달성' if ok == len(records) else '미달'})\")\n", + "\n", + "# 4) 오류 주입 시험: 검증기가 잘못된 데이터를 잡아내는지 확인\n", + "bad = json.loads(json.dumps(records[0]))\n", + "bad[\"sensor\"][\"gsd_m\"] = 0.0\n", + "bad[\"sensor\"][\"type\"] = \"IR\"\n", + "del bad[\"tactical\"][\"geo_error_m\"]\n", + "bad[\"labels\"][0][\"class\"] = \"bmp\"\n", + "bad[\"labels\"][1][\"bbox\"] = [1, 2, 3]\n", + "print(\"\\n[오류 주입 결과] 주입 5종\")\n", + "for e in validate(bad):\n", + " print(\" -\", e)\n", + "\n", + "# 5) 스키마 필드 정의서(명세서 초안)\n", + "rows = [(\"기본\", k, r[\"type\"].__name__, str({x: y for x, y in r.items() if x != \"type\"})) for k, r in SCHEMA[\"fields\"].items()]\n", + "for g in (\"source\", \"sensor\", \"tactical\", \"label\"):\n", + " for k, r in SCHEMA[g].items():\n", + " t = r[\"type\"].__name__ if isinstance(r[\"type\"], type) else \"number\"\n", + " rows.append((g, k, t, str({x: y for x, y in r.items() if x != \"type\"})))\n", + "spec = pd.DataFrame(rows, columns=[\"그룹\", \"필드\", \"자료형\", \"규칙\"])\n", + "print(\"\\n[데이터 명세 요약]\")\n", + "print(spec.to_string(index=False))\n", + "print(f\"\\n필드 정의 {len(spec)}개(완성도 100%). 라벨 status는 'auto_draft'로 생성되고 영상분석관 판독 후에만 'analyst_verified'로 바뀐다.\")\n", + "print(\"분류 기준: tank(포탑+긴 포신), spg(후방 대형 포탑+매우 긴 포신), truck(운전석+적재함 분리), civil_vehicle(소형·밝은/어두운 단색), decoy(모의 위장 표적)\")\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 01 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + "정상 샘플 20건 중 검증 통과 20건, 오류 0건 (목표: 오류 0건 달성)\n", + "\n", + "[오류 주입 결과] 주입 5종\n", + " - sensor.type: 허용되지 않은 값 'IR'\n", + " - sensor.gsd_m: 최소값 0.1 미만\n", + " - tactical.geo_error_m: 필수 필드 누락\n", + " - labels[0].class: 허용되지 않은 값 'bmp'\n", + " - labels[1].bbox: 길이 4 필요\n", + "\n", + "[데이터 명세 요약]\n", + " 그룹 필드 자료형 규칙\n", + " 기본 image_id str {'pattern': '^IMG\\\\d{4}$'}\n", + " source provider str {'enum': ['open_data', 'commercial_sample', 'partner_share']}\n", + " source license str {'pattern': '^\\\\S.{2,}$'}\n", + " source acquired_at str {'pattern': '^\\\\d{4}-\\\\d{2}-\\\\d{2}T\\\\d{2}:\\\\d{2}Z$'}\n", + " sensor type str {'enum': ['optical', 'SAR']}\n", + " sensor gsd_m number {'min': 0.1, 'max': 30}\n", + " sensor off_nadir_deg number {'min': 0, 'max': 60}\n", + " sensor sun_elevation_deg number {'min': -10, 'max': 90}\n", + " sensor cloud_cover_pct number {'min': 0, 'max': 100}\n", + "tactical weather str {'enum': ['clear', 'haze', 'cloudy', 'snow']}\n", + "tactical geo_error_m number {'min': 0, 'max': 1000}\n", + "tactical delivery_latency_h number {'min': 0, 'max': 720}\n", + " label class str {'enum': ['tank', 'spg', 'truck', 'civil_vehicle', 'decoy', 'unknown']}\n", + " label bbox list {'length': 4}\n", + " label orientation_deg number {'min': 0, 'max': 360}\n", + " label concealment str {'enum': ['none', 'camo_net', 'tree', 'cloud', 'partial']}\n", + " label status str {'enum': ['auto_draft', 'analyst_verified']}\n", + "\n", + "필드 정의 17개(완성도 100%). 라벨 status는 'auto_draft'로 생성되고 영상분석관 판독 후에만 'analyst_verified'로 바뀐다.\n", + "분류 기준: tank(포탑+긴 포신), spg(후방 대형 포탑+매우 긴 포신), truck(운전석+적재함 분리), civil_vehicle(소형·밝은/어두운 단색), decoy(모의 위장 표적)\n", + "```\n", + "
\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 02. 위성영상 해상도 표준화, 구름 차폐 제거 및 방위/스케일 정규화 전처리 모듈 구현\n", + "**소요시간**: 약 2.0시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "구름 차폐, 그림자 왜곡, 위성 촬영 각도에 따른 기하학적 왜곡을 보정하고 영상 해상도를 표준화하는 전처리 파이프라인을 구축한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **구름 차폐, 비스듬 촬영 왜곡, 해상도 차이, 결측 픽셀을 보정하고 객체 방위와 축척을 정규화하는 위성영상 전처리 파이프라인을 구축한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **지상 해상도와 촬영 각도 메타데이터로 영상을 표준 해상도(0.5m/픽셀, 가상 기준)로 재표본화하고, 광학에서 밝고 매끈하지만 SAR에는 반사가 없는 영역을 구름으로 찾아 배경값으로 채운다 과정을 진행하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 5개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `src/preprocess/isr01_preprocessor.py 및 전처리 전후 데이터셋` 형태의 결과물을 완성한다.\n", + "\n", + "### 🔍 평가 및 검증 기준 (PASS 조건)\n", + "#### 미션 판정\n", + "\n", + "**평가 구분:** 필수\n", + "\n", + "**핵심 평가영역:** 전술 환경 특유의 노이즈(스푸핑, 반사파, 센서 단절 등) 제거 타당성\n", + "\n", + "#### 미션 PASS 조건\n", + "\n", + "- 정량 기준: 데이터 정제 처리 속도 1,000건/초 이상, 전처리 오류율 0%\n", + "\n", + "\n", + "- 진행 기준: 정제 파이프라인 자동화 및 단위 테스트 케이스 5종 통과\n", + "\n", + "\n", + "- 검증 방법: pytest 기반 전처리 정합성 테스트 및 결측치 보정율 확인\n", + "\n", + "\n", + "#### 채점자 판정 메모\n", + "\n", + "> 단순 코드 실행 여부뿐만 아니라 출력된 수치와 근거 자료가 군 실무 요구사항에 부합하는지 면밀히 확인한다.\n", + "\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "\"\"\"위성영상 조각의 해상도(GSD)·비스듬 촬영 왜곡을 표준화하고, 구름 차폐 영역 처리, 결측 픽셀 보정, 객체 주축 기준 방위 정규화, 0~1 정규화를 수행하는 전처리 모듈을 구현해 처리 속도와 보정 효과를 검증한다.\"\"\"\n", + "import time\n", + "import numpy as np\n", + "import pandas as pd\n", + "import matplotlib.pyplot as plt\n", + "from scipy import ndimage\n", + "\n", + "# ---------------------------------------------------------------------------\n", + "# [교육용가상자료] 합성 위성영상 조각(chip) 생성기 (실제 위성영상·장비 제원·위치 없음)\n", + "# 32x32 광학(EO) 영상과 SAR 영상 한 쌍에 추상 도형으로 만든 객체를 그린다. 도형 치수는 가상값이다.\n", + "# ---------------------------------------------------------------------------\n", + "N = 32\n", + "STD_GSD = 0.5 # 표준 해상도(m/픽셀, 가상 기준)\n", + "CLASSES = [\"tank\", \"spg\", \"truck\", \"civil\"]\n", + "MILITARY = {\"tank\", \"spg\", \"truck\"}\n", + "# 객체 좌표계(u: 길이 방향, v: 폭 방향, 표준 해상도 픽셀) 가상 도형: (u중심, v중심, 길이, 폭, 광학 밝기 차이)\n", + "SHAPES = {\n", + " \"tank\": [(0, 0, 11, 7, -40), (0.5, 0, 5, 5, -55), (6, 0, 7, 1.2, -60)],\n", + " \"spg\": [(0, 0, 16, 7.5, -38), (-4, 0, 6, 6.5, -52), (5, 0, 13, 1.4, -60)],\n", + " \"truck\": [(5, 0, 3.5, 5.5, -45), (-1.8, 0, 8.5, 6, -25)],\n", + " \"civil\": [(0, 0, 8, 3.8, 0), (1.2, 0, 2, 3.4, 0)],\n", + " \"decoy\": [(0, 0, 11, 7, -40), (0.5, 0, 5, 5, -42), (6, 0, 7, 1.2, -45)],\n", + " \"none\": [], # 객체 없는 배경 타일\n", + "}\n", + "SAR_RETURN = {\"tank\": 0.85, \"spg\": 0.85, \"truck\": 0.7, \"civil\": 0.45, \"decoy\": 0.18, \"none\": 0.12} # 금속 반사 세기(가상)\n", + "\n", + "\n", + "def _mask(cls, angle, scale, squash=1.0):\n", + " \"\"\"회전(angle, 도)·축척(scale)·비스듬 촬영 압축(squash)을 적용해 도형별 마스크와 밝기 차이를 만든다.\"\"\"\n", + " # [TODO : 학생 실습 2] 위성영상 해상도 표준화, 구름 차폐 제거 및 방위/스케일 정규화 전처리 모듈 구현 관련 핵심 로직을 구현하세요.\n", + " # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n", + " # <<< 여기에 코드를 작성하세요 >>>\n", + " pass # 작성 후 pass를 제거하세요.\n", + "def make_chip(cls, rng, cond=None):\n", + " \"\"\"chip 1개 = (광학 32x32, SAR 32x32, 메타데이터). cond: camo, occl, cloud, low_sun, off_nadir, low_res, speckle\"\"\"\n", + " c = {\"camo\": 0.0, \"occl\": 0.0, \"cloud\": 0.0, \"low_sun\": False, \"off_nadir\": 0.0, \"low_res\": False, \"speckle\": 1.0, **(cond or {})}\n", + " gsd = rng.uniform(0.9, 1.2) if c[\"low_res\"] else rng.uniform(0.4, 0.6)\n", + " scale = STD_GSD / gsd\n", + " angle, off = rng.uniform(0, 360), c[\"off_nadir\"] or rng.uniform(0, 15)\n", + " squash = np.cos(np.radians(off))\n", + " sun_el = rng.uniform(12, 22) if c[\"low_sun\"] else rng.uniform(40, 70)\n", + " sun_az = rng.uniform(0, 360)\n", + " bg = 115 + ndimage.gaussian_filter(rng.normal(0, 30, (N, N)), 2) + rng.normal(0, 5, (N, N))\n", + " parts = _mask(cls, angle, scale, squash)\n", + " body = np.any([m for m, _ in parts], axis=0) if parts else np.zeros((N, N), bool)\n", + " opt = bg.copy()\n", + " # 그림자: 태양 반대 방향으로 밀린 객체 모양(태양 고도가 낮을수록 길다)\n", + " L = min(8.0, 2.5 / np.tan(np.radians(sun_el))) * scale\n", + " sh = ndimage.shift(body.astype(float), (L * np.cos(np.radians(sun_az)), -L * np.sin(np.radians(sun_az))), order=0) > 0.5\n", + " opt[sh & ~body] -= 35 if cls != \"decoy\" else 25\n", + " if cls == \"civil\":\n", + " base = rng.choice([200.0, 60.0]) # 민간 차량 색상: 밝거나 어두움\n", + " opt[body] = base + rng.normal(0, 8, body.sum())\n", + " roof = parts[1][0]\n", + " opt[roof] = base * 0.8\n", + " else:\n", + " ref = bg[body].mean() if body.any() else bg.mean()\n", + " for m, d in parts:\n", + " opt[m] = ref + d + rng.normal(0, 4 if cls != \"decoy\" else 2, m.sum())\n", + " # 위장막: 객체 주변을 배경 질감으로 덮어 대비를 낮춘다\n", + " if c[\"camo\"] > 0 and cls != \"civil\":\n", + " net = ndimage.binary_dilation(body, iterations=2)\n", + " tex = 105 + ndimage.gaussian_filter(rng.normal(0, 25, (N, N)), 1)\n", + " opt[net] = (1 - c[\"camo\"]) * opt[net] + c[\"camo\"] * tex[net]\n", + " # 수목 차폐: 객체의 일부를 어두운 나무 덩어리로 가린다\n", + " if c[\"occl\"] > 0:\n", + " trees = ndimage.gaussian_filter(rng.random((N, N)), 2)\n", + " near = ndimage.binary_dilation(body, iterations=3)\n", + " cover = near & (trees > np.quantile(trees[body], 1 - c[\"occl\"])) if body.any() else np.zeros((N, N), bool)\n", + " opt[cover] = 70 + rng.normal(0, 6, cover.sum())\n", + " # 구름: 밝고 매끈한 덩어리(광학만 가림, SAR는 통과)\n", + " cmask = np.zeros((N, N), bool)\n", + " if c[\"cloud\"] > 0:\n", + " blob = ndimage.gaussian_filter(rng.random((N, N)), 4)\n", + " cmask = blob > np.quantile(blob, 1 - c[\"cloud\"])\n", + " opt[cmask] = 0.25 * opt[cmask] + 0.75 * (235 + rng.normal(0, 3, cmask.sum()))\n", + " # SAR: 곱셈형 스페클 잡음 + 금속 반사(모서리 이중 반사로 밝은 점)\n", + " sar = np.full((N, N), 0.12)\n", + " sar[body] = SAR_RETURN[cls] * rng.uniform(0.8, 1.2)\n", + " edge = body & ~ndimage.binary_erosion(body)\n", + " sar[edge] *= 1.3 if cls in MILITARY else 1.0\n", + " sar = sar * rng.gamma(4 / c[\"speckle\"], c[\"speckle\"] / 4, (N, N))\n", + " if c[\"low_res\"]: # 저해상도: 정보 손실(블러)\n", + " down = lambda a: np.kron(a.reshape(N // 2, 2, N // 2, 2).mean((1, 3)), np.ones((2, 2)))\n", + " opt, sar = ndimage.gaussian_filter(down(opt), 0.7), ndimage.gaussian_filter(down(sar), 0.7)\n", + " meta = {\"cls\": cls, \"gsd\": round(float(gsd), 2), \"off_nadir\": round(float(off), 1), \"sun_el\": round(float(sun_el), 1),\n", + " \"sun_az\": round(float(sun_az), 1), \"angle\": round(float(angle), 1), \"cloud_frac\": float(cmask.mean()),\n", + " \"cloud_on_obj\": float((cmask & body).sum() / max(body.sum(), 1)), **{k: c[k] for k in (\"camo\", \"occl\", \"low_sun\", \"low_res\")}}\n", + " return np.clip(opt, 0, 255), sar, meta\n", + "\n", + "\n", + "SCEN = { # 은폐·촬영 조건 시나리오 10종\n", + " \"clean\": {}, \"camo\": {\"camo\": 0.7}, \"occl30\": {\"occl\": 0.3}, \"occl50\": {\"occl\": 0.5}, \"cloud\": {\"cloud\": 0.25},\n", + " \"low_sun\": {\"low_sun\": True}, \"off_nadir\": {\"off_nadir\": 35.0}, \"low_res\": {\"low_res\": True},\n", + " \"speckle\": {\"speckle\": 3.0}, \"camo_occl\": {\"camo\": 0.6, \"occl\": 0.3}}\n", + "\n", + "\n", + "def make_dataset(n=400, seed=7, scen=(\"clean\",), classes=CLASSES):\n", + " rng = np.random.default_rng(seed)\n", + " O, S, M = np.zeros((n, N, N)), np.zeros((n, N, N)), []\n", + " for i in range(n):\n", + " cls, sc = classes[i % len(classes)], scen[(i // len(classes)) % len(scen)]\n", + " O[i], S[i], m = make_chip(cls, rng, SCEN[sc])\n", + " m.update({\"id\": f\"IMG{i:04d}\", \"scen\": sc})\n", + " M.append(m)\n", + " return O, S, M\n", + "# ---------------------------------------------------------------------------\n", + "\n", + "# ---- 전처리(해상도·기하 보정, 구름 차폐 처리, 방위 정규화) 공통 함수 ----\n", + "def _fit(a, fill):\n", + " \"\"\"중앙 기준으로 32x32에 맞게 자르거나 채운다.\"\"\"\n", + " # [TODO : 학생 실습 2] 위성영상 해상도 표준화, 구름 차폐 제거 및 방위/스케일 정규화 전처리 모듈 구현 관련 핵심 로직을 구현하세요.\n", + " # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n", + " # <<< 여기에 코드를 작성하세요 >>>\n", + " pass # 작성 후 pass를 제거하세요.\n", + "def cloud_mask(opt, sm, min_area=15):\n", + " \"\"\"광학에서 밝고(185 초과) 매끈한데 SAR에는 반사가 없는 영역을 구름으로 본다(SAR는 구름을 통과하므로 밝은 차량과 구분된다).\"\"\"\n", + " bright = opt > 185\n", + " if bright.sum() < min_area:\n", + " return np.zeros_like(bright)\n", + " local_sd = np.sqrt(np.maximum(ndimage.uniform_filter(opt ** 2, 3) - ndimage.uniform_filter(opt, 3) ** 2, 0))\n", + " cand = bright & (local_sd < 12) & (sm < 0.3)\n", + " lab, n = ndimage.label(cand)\n", + " if n == 0:\n", + " return cand\n", + " sizes = ndimage.sum(cand.astype(float), lab, range(1, n + 1))\n", + " return ndimage.binary_dilation(np.isin(lab, 1 + np.where(sizes >= min_area)[0]), iterations=2) & (opt > 140)\n", + "\n", + "\n", + "def orientation(opt, sm):\n", + " \"\"\"SAR 반사(위장·구름 영향 적음, sm은 평활화한 SAR)와 광학 대비를 합친 객체 마스크의 주축 방향(도).\"\"\"\n", + " m = (sm > 0.3) | (np.abs(opt - opt.mean()) > 30)\n", + " lab, n = ndimage.label(m)\n", + " if n == 0:\n", + " return 0.0, m\n", + " c = lab[N // 2 - 6:N // 2 + 6, N // 2 - 6:N // 2 + 6]\n", + " k = np.bincount(c[c > 0]).argmax() if (c > 0).any() else 1 + int(np.argmax(ndimage.sum(m.astype(float), lab, range(1, n + 1))))\n", + " m = lab == k\n", + " yy, xx = np.nonzero(m)\n", + " if len(yy) < 5:\n", + " return 0.0, m\n", + " dy, dx = yy - yy.mean(), xx - xx.mean() # 2차 모멘트로 주축 각도 계산\n", + " return float(np.degrees(0.5 * np.arctan2(2 * (dx * dy).mean(), (dx * dx).mean() - (dy * dy).mean()))), m\n", + "\n", + "\n", + "_YY, _XX = np.mgrid[0:N, 0:N] - (N - 1) / 2\n", + "\n", + "\n", + "def warp(stack, zy, zx, ang):\n", + " \"\"\"여러 장(B,채널,32,32)에 장별 확대·축소(zy, zx)와 회전(ang, 도)을 한 번의 쌍선형 보간으로 적용한다(벡터 연산).\"\"\"\n", + " a = np.radians(ang)[:, None, None]\n", + " zy, zx = np.asarray(zy)[:, None, None], np.asarray(zx)[:, None, None]\n", + " sy = np.clip((np.cos(a) * _YY + np.sin(a) * _XX) / zy + (N - 1) / 2, 0, N - 1.001) # 출력 좌표 -> 원본 좌표\n", + " sx = np.clip((-np.sin(a) * _YY + np.cos(a) * _XX) / zx + (N - 1) / 2, 0, N - 1.001)\n", + " y0, x0 = sy.astype(int), sx.astype(int)\n", + " fy, fx = (sy - y0).reshape(len(stack), 1, -1), (sx - x0).reshape(len(stack), 1, -1)\n", + " flat = stack.reshape(len(stack), stack.shape[1], -1)\n", + " lin = (y0 * N + x0).reshape(len(stack), 1, -1)\n", + " g = lambda d: np.take_along_axis(flat, np.broadcast_to(lin + d, (len(stack), stack.shape[1], lin.shape[2])), axis=2)\n", + " out = g(0) * (1 - fy) * (1 - fx) + g(N) * fy * (1 - fx) + g(1) * (1 - fy) * fx + g(N + 1) * fy * fx\n", + " return out.reshape(stack.shape)\n", + "\n", + "\n", + "def preprocess_batch(O, S, M):\n", + " \"\"\"여러 장을 한 번에 전처리한다. 0) 결측 보정 1) 해상도·비스듬 촬영 보정(표준 0.5m/픽셀)\n", + " 2) 구름 차폐 영역을 배경값으로 채움 3) 객체 주축을 가로로 회전 4) 0~1 정규화.\"\"\"\n", + " src = np.stack([np.asarray(O, float), np.asarray(S, float)], axis=1)\n", + " if np.isnan(src).any(): # 0) 센서 결측 픽셀 보정\n", + " mean = np.nanmean(src, axis=(2, 3), keepdims=True)\n", + " src = np.where(np.isnan(src), mean, src)\n", + " zy = np.array([m[\"gsd\"] / STD_GSD / np.cos(np.radians(m[\"off_nadir\"])) for m in M])\n", + " zx = np.array([m[\"gsd\"] / STD_GSD for m in M])\n", + " out = warp(src, zy, zx, np.zeros(len(M)))\n", + " sm = ndimage.uniform_filter(out[:, 1], (1, 3, 3))\n", + " angs, cloud = np.zeros(len(M)), np.zeros(len(M))\n", + " for i in range(len(M)):\n", + " cm = cloud_mask(out[i, 0], sm[i])\n", + " if cm.any():\n", + " fill = out[i, 0][~cm].mean() if (~cm).any() else 115.0\n", + " out[i, 0][cm] = fill\n", + " back = warp(cm[None, None].astype(float), [1 / zy[i]], [1 / zx[i]], np.zeros(1))[0, 0] > 0.5\n", + " src[i, 0][back] = fill # 원본의 같은 구름 영역도 채움\n", + " cloud[i] = cm.mean()\n", + " angs[i] = orientation(out[i, 0], sm[i])[0]\n", + " rot = np.abs(angs) > 3 # 3도 이하는 회전 생략\n", + " if rot.any():\n", + " out[rot] = warp(src[rot], zy[rot], zx[rot], angs[rot])\n", + " return [(np.clip(o / 255, 0, 1), np.clip(s / 1.5, 0, 1), float(c)) for (o, s), c in zip(out, cloud)]\n", + "\n", + "\n", + "def preprocess(opt, sar, meta):\n", + " \"\"\"한 장 전처리(preprocess_batch와 같은 처리).\"\"\"\n", + " return preprocess_batch([opt], [sar], [meta])[0]\n", + "\n", + "\n", + "def extent(s):\n", + " \"\"\"평활화한 SAR 반사 마스크의 가로·세로 길이(픽셀): 객체 크기·방위 확인용.\"\"\"\n", + " m = ndimage.uniform_filter(s, 3) > 0.2\n", + " return int(m.any(axis=0).sum()), int(m.any(axis=1).sum())\n", + "\n", + "\n", + "O, S, M = make_dataset(400, seed=7, scen=tuple(SCEN))\n", + "rng = np.random.default_rng(2)\n", + "for i in rng.choice(len(O), 20, replace=False): # 센서 결측(줄 단위 NaN) 20장 주입\n", + " r = int(rng.integers(0, N - 3)); O[i, r:r + 3] = np.nan\n", + "\n", + "# 1) 처리 속도와 오류율(같은 400장을 3회 일괄 처리해 가장 빠른 회차로 처리량을 잰다: 다른 프로그램 부하 영향 최소화)\n", + "best, fails, out = 1e9, 0, []\n", + "for rep in range(3):\n", + " t0, f = time.perf_counter(), 0\n", + " try:\n", + " res = preprocess_batch(O, S, M) # 400장을 한 번에(벡터 연산) 처리\n", + " except Exception:\n", + " res, f = [], len(O)\n", + " best, fails, out = min(best, time.perf_counter() - t0), max(fails, f), res\n", + "dt = best\n", + "nan_left = sum(int(np.isnan(p[0]).sum() + np.isnan(p[1]).sum()) for p in out)\n", + "print(f\"전처리 {len(O)}장, {len(O) / dt:,.0f}장/초 (목표 1,000건/초 이상), 처리 실패 {fails}건, 남은 결측 {nan_left}픽셀 -> 오류율 {fails / len(O):.0%}\")\n", + "print(f\"결측 주입 20장 결측치 보정율 {100 if nan_left == 0 else 0}%\")\n", + "\n", + "# 2) 조건별 보정 효과\n", + "sc = np.array([m[\"scen\"] for m in M])\n", + "before_cloud = np.array([(o > 185).mean() for o in np.nan_to_num(O, nan=115)])\n", + "after_cloud = np.array([(p[0] > 185 / 255).mean() for p in out])\n", + "ang = np.array([orientation(p[0] * 255, ndimage.uniform_filter(p[1] * 1.5, 3))[0] for p in out])\n", + "horiz = (np.abs(ang) < 12) | (np.abs(ang) > 168)\n", + "ext = np.array([extent(p[1]) for p in out])\n", + "rows = []\n", + "for k in SCEN:\n", + " sel = sc == k\n", + " rows.append({\"조건\": k, \"밝은 구름 픽셀(전)\": f\"{before_cloud[sel].mean():.1%}\", \"(후)\": f\"{after_cloud[sel].mean():.1%}\",\n", + " \"방위 정규화 성공\": f\"{horiz[sel].mean():.0%}\", \"객체 길이/폭(px)\": f\"{ext[sel, 0].mean():.1f}/{ext[sel, 1].mean():.1f}\"})\n", + "print(pd.DataFrame(rows).to_string(index=False))\n", + "\n", + "# 3) 단위 테스트 5종\n", + "raw_len = np.array([extent(s / 1.5)[0] for s in S])\n", + "tests = {\n", + " \"출력 크기 32x32(광학·SAR)\": all(p[0].shape == (N, N) and p[1].shape == (N, N) for p in out),\n", + " \"값 범위 0~1, 결측 0\": all(p[0].min() >= 0 and p[0].max() <= 1 and not np.isnan(p[0]).any() for p in out),\n", + " \"구름 조건 밝은 픽셀 50% 이상 감소\": after_cloud[sc == \"cloud\"].mean() < 0.5 * before_cloud[sc == \"cloud\"].mean(),\n", + " \"맑은 조건 방위 정규화 90% 이상\": horiz[sc == \"clean\"].mean() >= 0.9,\n", + " \"저해상도 객체 길이 표준화(맑은 조건 대비 30% 이내)\": abs(ext[sc == \"low_res\", 0].mean() / ext[sc == \"clean\", 0].mean() - 1) < 0.3,\n", + "}\n", + "for k, v in tests.items():\n", + " print(f\"[{'PASS' if v else 'FAIL'}] {k}\")\n", + "print(f\"단위 테스트 {sum(tests.values())}/5 통과\")\n", + "print(f\"참고: 저해상도 원본의 객체 길이 {raw_len[sc == 'low_res'].mean():.1f}px -> 보정 후 {ext[sc == 'low_res', 0].mean():.1f}px\")\n", + "\n", + "# 4) 전후 비교 그림\n", + "fig, ax = plt.subplots(2, 5, figsize=(14, 6))\n", + "for j, k in enumerate([\"clean\", \"cloud\", \"off_nadir\", \"low_res\", \"camo\"]):\n", + " i = int(np.where(sc == k)[0][0])\n", + " ax[0, j].imshow(np.nan_to_num(O[i], nan=0), cmap=\"gray\", vmin=0, vmax=255); ax[0, j].set_title(f\"{k} raw ({M[i]['cls']})\")\n", + " ax[1, j].imshow(out[i][0], cmap=\"gray\", vmin=0, vmax=1); ax[1, j].set_title(\"preprocessed\")\n", + "for a in ax.ravel():\n", + " a.axis(\"off\")\n", + "plt.suptitle(\"Synthetic satellite chips: before / after preprocessing (training data)\"); plt.tight_layout(); plt.show()\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 02 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + "전처리 400장, 1,395장/초 (목표 1,000건/초 이상), 처리 실패 0건, 남은 결측 0픽셀 -> 오류율 0%\n", + "결측 주입 20장 결측치 보정율 100%\n", + " 조건 밝은 구름 픽셀(전) (후) 방위 정규화 성공 객체 길이/폭(px)\n", + " clean 0.2% 0.1% 98% 14.8/8.1\n", + " camo 0.4% 0.3% 100% 14.8/8.1\n", + " occl30 0.2% 0.1% 100% 14.8/8.0\n", + " occl50 0.1% 0.1% 100% 14.7/8.1\n", + " cloud 25.1% 2.1% 100% 14.7/8.2\n", + " low_sun 0.3% 0.2% 95% 14.4/8.5\n", + "off_nadir 0.3% 0.2% 100% 14.7/8.1\n", + " low_res 0.0% 0.0% 95% 14.2/9.3\n", + " speckle 0.3% 0.3% 100% 15.4/8.9\n", + "camo_occl 0.1% 0.0% 98% 14.6/8.2\n", + "[PASS] 출력 크기 32x32(광학·SAR)\n", + "[PASS] 값 범위 0~1, 결측 0\n", + "[PASS] 구름 조건 밝은 픽셀 50% 이상 감소\n", + "[PASS] 맑은 조건 방위 정규화 90% 이상\n", + "[PASS] 저해상도 객체 길이 표준화(맑은 조건 대비 30% 이내)\n", + "단위 테스트 5/5 통과\n", + "참고: 저해상도 원본의 객체 길이 5.6px -> 보정 후 14.2px\n", + "```\n", + "
\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 03. 위성영상 내 기갑/포병 장비 객체 탐지 Baseline CNN/Vision 모델 구현\n", + "**소요시간**: 약 2.5시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "위성영상 내 주요 군용 장비 및 차량 객체를 1차 식별하는 Baseline 합성곱 신경망(CNN) 기반 분류 모델을 구현한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **위성영상 속 주요 군용 장비와 차량을 1차 식별하는 Baseline 합성곱(CNN) 방식 분류 모델을 구현한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **Sobel·Laplacian·대각선·평균 필터로 이루어진 합성곱 필터 뱅크, ReLU, 최대 풀링으로 간이 CNN 특징을 만들고 로지스틱 회귀 분류기를 학습 수행하고 검증하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `src/models/isr01_baseline_model.py 및 기본 추론 결과 보고서` 형태의 결과물을 완성한다.\n", + "\n", + "### 🔍 평가 및 검증 기준 (PASS 조건)\n", + "#### 미션 판정\n", + "\n", + "**평가 구분:** 필수\n", + "\n", + "**핵심 평가영역:** 알고리즘의 논리적 흐름 및 군사적 판단 기준의 투명성\n", + "\n", + "#### 미션 PASS 조건\n", + "\n", + "- 정량 기준: 기본 데이터셋 대상 탐지/처리 정확도 75% 이상 달성\n", + "\n", + "\n", + "- 진행 기준: 단위 기능 동작 확인 및 기본 시나리오 실행 로그 생성\n", + "\n", + "\n", + "- 검증 방법: 기준 벤치마크 데이터셋 대상 추론 결과 오차율 비교\n", + "\n", + "\n", + "#### 채점자 판정 메모\n", + "\n", + "> 단순 코드 실행 여부뿐만 아니라 출력된 수치와 근거 자료가 군 실무 요구사항에 부합하는지 면밀히 확인한다.\n", + "\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "\"\"\"위성영상 조각에서 전차·자주포·트럭·민간 차량을 1차 식별하는 Baseline 분류 모델을 구현한다. 합성곱 필터 뱅크(Sobel·Laplacian·대각선·평균) -> ReLU -> 최대 풀링으로 만든 간이 CNN 특징에 로지스틱 회귀 분류기를 붙이고, 정확도·혼동행렬·판단 지연시간을 측정한다.\"\"\"\n", + "import time\n", + "import numpy as np\n", + "import pandas as pd\n", + "import matplotlib.pyplot as plt\n", + "from scipy import ndimage\n", + "from sklearn.linear_model import LogisticRegression\n", + "from sklearn.metrics import confusion_matrix\n", + "from sklearn.pipeline import make_pipeline\n", + "from sklearn.preprocessing import StandardScaler\n", + "\n", + "# ---------------------------------------------------------------------------\n", + "# [교육용가상자료] 합성 위성영상 조각(chip) 생성기 (실제 위성영상·장비 제원·위치 없음)\n", + "# 32x32 광학(EO) 영상과 SAR 영상 한 쌍에 추상 도형으로 만든 객체를 그린다. 도형 치수는 가상값이다.\n", + "# ---------------------------------------------------------------------------\n", + "N = 32\n", + "STD_GSD = 0.5 # 표준 해상도(m/픽셀, 가상 기준)\n", + "CLASSES = [\"tank\", \"spg\", \"truck\", \"civil\"]\n", + "MILITARY = {\"tank\", \"spg\", \"truck\"}\n", + "# 객체 좌표계(u: 길이 방향, v: 폭 방향, 표준 해상도 픽셀) 가상 도형: (u중심, v중심, 길이, 폭, 광학 밝기 차이)\n", + "SHAPES = {\n", + " \"tank\": [(0, 0, 11, 7, -40), (0.5, 0, 5, 5, -55), (6, 0, 7, 1.2, -60)],\n", + " \"spg\": [(0, 0, 16, 7.5, -38), (-4, 0, 6, 6.5, -52), (5, 0, 13, 1.4, -60)],\n", + " \"truck\": [(5, 0, 3.5, 5.5, -45), (-1.8, 0, 8.5, 6, -25)],\n", + " \"civil\": [(0, 0, 8, 3.8, 0), (1.2, 0, 2, 3.4, 0)],\n", + " \"decoy\": [(0, 0, 11, 7, -40), (0.5, 0, 5, 5, -42), (6, 0, 7, 1.2, -45)],\n", + " \"none\": [], # 객체 없는 배경 타일\n", + "}\n", + "SAR_RETURN = {\"tank\": 0.85, \"spg\": 0.85, \"truck\": 0.7, \"civil\": 0.45, \"decoy\": 0.18, \"none\": 0.12} # 금속 반사 세기(가상)\n", + "\n", + "\n", + "def _mask(cls, angle, scale, squash=1.0):\n", + " \"\"\"회전(angle, 도)·축척(scale)·비스듬 촬영 압축(squash)을 적용해 도형별 마스크와 밝기 차이를 만든다.\"\"\"\n", + " # [TODO : 학생 실습 3] 위성영상 내 기갑/포병 장비 객체 탐지 Baseline CNN/Vision 모델 구현 관련 핵심 로직을 구현하세요.\n", + " # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n", + " # <<< 여기에 코드를 작성하세요 >>>\n", + " pass # 작성 후 pass를 제거하세요.\n", + "def make_chip(cls, rng, cond=None):\n", + " \"\"\"chip 1개 = (광학 32x32, SAR 32x32, 메타데이터). cond: camo, occl, cloud, low_sun, off_nadir, low_res, speckle\"\"\"\n", + " c = {\"camo\": 0.0, \"occl\": 0.0, \"cloud\": 0.0, \"low_sun\": False, \"off_nadir\": 0.0, \"low_res\": False, \"speckle\": 1.0, **(cond or {})}\n", + " gsd = rng.uniform(0.9, 1.2) if c[\"low_res\"] else rng.uniform(0.4, 0.6)\n", + " scale = STD_GSD / gsd\n", + " angle, off = rng.uniform(0, 360), c[\"off_nadir\"] or rng.uniform(0, 15)\n", + " squash = np.cos(np.radians(off))\n", + " sun_el = rng.uniform(12, 22) if c[\"low_sun\"] else rng.uniform(40, 70)\n", + " sun_az = rng.uniform(0, 360)\n", + " bg = 115 + ndimage.gaussian_filter(rng.normal(0, 30, (N, N)), 2) + rng.normal(0, 5, (N, N))\n", + " parts = _mask(cls, angle, scale, squash)\n", + " body = np.any([m for m, _ in parts], axis=0) if parts else np.zeros((N, N), bool)\n", + " opt = bg.copy()\n", + " # 그림자: 태양 반대 방향으로 밀린 객체 모양(태양 고도가 낮을수록 길다)\n", + " L = min(8.0, 2.5 / np.tan(np.radians(sun_el))) * scale\n", + " sh = ndimage.shift(body.astype(float), (L * np.cos(np.radians(sun_az)), -L * np.sin(np.radians(sun_az))), order=0) > 0.5\n", + " opt[sh & ~body] -= 35 if cls != \"decoy\" else 25\n", + " if cls == \"civil\":\n", + " base = rng.choice([200.0, 60.0]) # 민간 차량 색상: 밝거나 어두움\n", + " opt[body] = base + rng.normal(0, 8, body.sum())\n", + " roof = parts[1][0]\n", + " opt[roof] = base * 0.8\n", + " else:\n", + " ref = bg[body].mean() if body.any() else bg.mean()\n", + " for m, d in parts:\n", + " opt[m] = ref + d + rng.normal(0, 4 if cls != \"decoy\" else 2, m.sum())\n", + " # 위장막: 객체 주변을 배경 질감으로 덮어 대비를 낮춘다\n", + " if c[\"camo\"] > 0 and cls != \"civil\":\n", + " net = ndimage.binary_dilation(body, iterations=2)\n", + " tex = 105 + ndimage.gaussian_filter(rng.normal(0, 25, (N, N)), 1)\n", + " opt[net] = (1 - c[\"camo\"]) * opt[net] + c[\"camo\"] * tex[net]\n", + " # 수목 차폐: 객체의 일부를 어두운 나무 덩어리로 가린다\n", + " if c[\"occl\"] > 0:\n", + " trees = ndimage.gaussian_filter(rng.random((N, N)), 2)\n", + " near = ndimage.binary_dilation(body, iterations=3)\n", + " cover = near & (trees > np.quantile(trees[body], 1 - c[\"occl\"])) if body.any() else np.zeros((N, N), bool)\n", + " opt[cover] = 70 + rng.normal(0, 6, cover.sum())\n", + " # 구름: 밝고 매끈한 덩어리(광학만 가림, SAR는 통과)\n", + " cmask = np.zeros((N, N), bool)\n", + " if c[\"cloud\"] > 0:\n", + " blob = ndimage.gaussian_filter(rng.random((N, N)), 4)\n", + " cmask = blob > np.quantile(blob, 1 - c[\"cloud\"])\n", + " opt[cmask] = 0.25 * opt[cmask] + 0.75 * (235 + rng.normal(0, 3, cmask.sum()))\n", + " # SAR: 곱셈형 스페클 잡음 + 금속 반사(모서리 이중 반사로 밝은 점)\n", + " sar = np.full((N, N), 0.12)\n", + " sar[body] = SAR_RETURN[cls] * rng.uniform(0.8, 1.2)\n", + " edge = body & ~ndimage.binary_erosion(body)\n", + " sar[edge] *= 1.3 if cls in MILITARY else 1.0\n", + " sar = sar * rng.gamma(4 / c[\"speckle\"], c[\"speckle\"] / 4, (N, N))\n", + " if c[\"low_res\"]: # 저해상도: 정보 손실(블러)\n", + " down = lambda a: np.kron(a.reshape(N // 2, 2, N // 2, 2).mean((1, 3)), np.ones((2, 2)))\n", + " opt, sar = ndimage.gaussian_filter(down(opt), 0.7), ndimage.gaussian_filter(down(sar), 0.7)\n", + " meta = {\"cls\": cls, \"gsd\": round(float(gsd), 2), \"off_nadir\": round(float(off), 1), \"sun_el\": round(float(sun_el), 1),\n", + " \"sun_az\": round(float(sun_az), 1), \"angle\": round(float(angle), 1), \"cloud_frac\": float(cmask.mean()),\n", + " \"cloud_on_obj\": float((cmask & body).sum() / max(body.sum(), 1)), **{k: c[k] for k in (\"camo\", \"occl\", \"low_sun\", \"low_res\")}}\n", + " return np.clip(opt, 0, 255), sar, meta\n", + "\n", + "\n", + "SCEN = { # 은폐·촬영 조건 시나리오 10종\n", + " \"clean\": {}, \"camo\": {\"camo\": 0.7}, \"occl30\": {\"occl\": 0.3}, \"occl50\": {\"occl\": 0.5}, \"cloud\": {\"cloud\": 0.25},\n", + " \"low_sun\": {\"low_sun\": True}, \"off_nadir\": {\"off_nadir\": 35.0}, \"low_res\": {\"low_res\": True},\n", + " \"speckle\": {\"speckle\": 3.0}, \"camo_occl\": {\"camo\": 0.6, \"occl\": 0.3}}\n", + "\n", + "\n", + "def make_dataset(n=400, seed=7, scen=(\"clean\",), classes=CLASSES):\n", + " rng = np.random.default_rng(seed)\n", + " O, S, M = np.zeros((n, N, N)), np.zeros((n, N, N)), []\n", + " for i in range(n):\n", + " cls, sc = classes[i % len(classes)], scen[(i // len(classes)) % len(scen)]\n", + " O[i], S[i], m = make_chip(cls, rng, SCEN[sc])\n", + " m.update({\"id\": f\"IMG{i:04d}\", \"scen\": sc})\n", + " M.append(m)\n", + " return O, S, M\n", + "# ---------------------------------------------------------------------------\n", + "\n", + "# ---- 전처리(해상도·기하 보정, 구름 차폐 처리, 방위 정규화) 공통 함수 ----\n", + "def _fit(a, fill):\n", + " \"\"\"중앙 기준으로 32x32에 맞게 자르거나 채운다.\"\"\"\n", + " # [TODO : 학생 실습 3] 위성영상 내 기갑/포병 장비 객체 탐지 Baseline CNN/Vision 모델 구현 관련 핵심 로직을 구현하세요.\n", + " # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n", + " # <<< 여기에 코드를 작성하세요 >>>\n", + " pass # 작성 후 pass를 제거하세요.\n", + "def cloud_mask(opt, sm, min_area=15):\n", + " \"\"\"광학에서 밝고(185 초과) 매끈한데 SAR에는 반사가 없는 영역을 구름으로 본다(SAR는 구름을 통과하므로 밝은 차량과 구분된다).\"\"\"\n", + " bright = opt > 185\n", + " if bright.sum() < min_area:\n", + " return np.zeros_like(bright)\n", + " local_sd = np.sqrt(np.maximum(ndimage.uniform_filter(opt ** 2, 3) - ndimage.uniform_filter(opt, 3) ** 2, 0))\n", + " cand = bright & (local_sd < 12) & (sm < 0.3)\n", + " lab, n = ndimage.label(cand)\n", + " if n == 0:\n", + " return cand\n", + " sizes = ndimage.sum(cand.astype(float), lab, range(1, n + 1))\n", + " return ndimage.binary_dilation(np.isin(lab, 1 + np.where(sizes >= min_area)[0]), iterations=2) & (opt > 140)\n", + "\n", + "\n", + "def orientation(opt, sm):\n", + " \"\"\"SAR 반사(위장·구름 영향 적음, sm은 평활화한 SAR)와 광학 대비를 합친 객체 마스크의 주축 방향(도).\"\"\"\n", + " m = (sm > 0.3) | (np.abs(opt - opt.mean()) > 30)\n", + " lab, n = ndimage.label(m)\n", + " if n == 0:\n", + " return 0.0, m\n", + " c = lab[N // 2 - 6:N // 2 + 6, N // 2 - 6:N // 2 + 6]\n", + " k = np.bincount(c[c > 0]).argmax() if (c > 0).any() else 1 + int(np.argmax(ndimage.sum(m.astype(float), lab, range(1, n + 1))))\n", + " m = lab == k\n", + " yy, xx = np.nonzero(m)\n", + " if len(yy) < 5:\n", + " return 0.0, m\n", + " dy, dx = yy - yy.mean(), xx - xx.mean() # 2차 모멘트로 주축 각도 계산\n", + " return float(np.degrees(0.5 * np.arctan2(2 * (dx * dy).mean(), (dx * dx).mean() - (dy * dy).mean()))), m\n", + "\n", + "\n", + "_YY, _XX = np.mgrid[0:N, 0:N] - (N - 1) / 2\n", + "\n", + "\n", + "def warp(stack, zy, zx, ang):\n", + " \"\"\"여러 장(B,채널,32,32)에 장별 확대·축소(zy, zx)와 회전(ang, 도)을 한 번의 쌍선형 보간으로 적용한다(벡터 연산).\"\"\"\n", + " a = np.radians(ang)[:, None, None]\n", + " zy, zx = np.asarray(zy)[:, None, None], np.asarray(zx)[:, None, None]\n", + " sy = np.clip((np.cos(a) * _YY + np.sin(a) * _XX) / zy + (N - 1) / 2, 0, N - 1.001) # 출력 좌표 -> 원본 좌표\n", + " sx = np.clip((-np.sin(a) * _YY + np.cos(a) * _XX) / zx + (N - 1) / 2, 0, N - 1.001)\n", + " y0, x0 = sy.astype(int), sx.astype(int)\n", + " fy, fx = (sy - y0).reshape(len(stack), 1, -1), (sx - x0).reshape(len(stack), 1, -1)\n", + " flat = stack.reshape(len(stack), stack.shape[1], -1)\n", + " lin = (y0 * N + x0).reshape(len(stack), 1, -1)\n", + " g = lambda d: np.take_along_axis(flat, np.broadcast_to(lin + d, (len(stack), stack.shape[1], lin.shape[2])), axis=2)\n", + " out = g(0) * (1 - fy) * (1 - fx) + g(N) * fy * (1 - fx) + g(1) * (1 - fy) * fx + g(N + 1) * fy * fx\n", + " return out.reshape(stack.shape)\n", + "\n", + "\n", + "def preprocess_batch(O, S, M):\n", + " \"\"\"여러 장을 한 번에 전처리한다. 0) 결측 보정 1) 해상도·비스듬 촬영 보정(표준 0.5m/픽셀)\n", + " 2) 구름 차폐 영역을 배경값으로 채움 3) 객체 주축을 가로로 회전 4) 0~1 정규화.\"\"\"\n", + " src = np.stack([np.asarray(O, float), np.asarray(S, float)], axis=1)\n", + " if np.isnan(src).any(): # 0) 센서 결측 픽셀 보정\n", + " mean = np.nanmean(src, axis=(2, 3), keepdims=True)\n", + " src = np.where(np.isnan(src), mean, src)\n", + " zy = np.array([m[\"gsd\"] / STD_GSD / np.cos(np.radians(m[\"off_nadir\"])) for m in M])\n", + " zx = np.array([m[\"gsd\"] / STD_GSD for m in M])\n", + " out = warp(src, zy, zx, np.zeros(len(M)))\n", + " sm = ndimage.uniform_filter(out[:, 1], (1, 3, 3))\n", + " angs, cloud = np.zeros(len(M)), np.zeros(len(M))\n", + " for i in range(len(M)):\n", + " cm = cloud_mask(out[i, 0], sm[i])\n", + " if cm.any():\n", + " fill = out[i, 0][~cm].mean() if (~cm).any() else 115.0\n", + " out[i, 0][cm] = fill\n", + " back = warp(cm[None, None].astype(float), [1 / zy[i]], [1 / zx[i]], np.zeros(1))[0, 0] > 0.5\n", + " src[i, 0][back] = fill # 원본의 같은 구름 영역도 채움\n", + " cloud[i] = cm.mean()\n", + " angs[i] = orientation(out[i, 0], sm[i])[0]\n", + " rot = np.abs(angs) > 3 # 3도 이하는 회전 생략\n", + " if rot.any():\n", + " out[rot] = warp(src[rot], zy[rot], zx[rot], angs[rot])\n", + " return [(np.clip(o / 255, 0, 1), np.clip(s / 1.5, 0, 1), float(c)) for (o, s), c in zip(out, cloud)]\n", + "\n", + "\n", + "def preprocess(opt, sar, meta):\n", + " \"\"\"한 장 전처리(preprocess_batch와 같은 처리).\"\"\"\n", + " return preprocess_batch([opt], [sar], [meta])[0]\n", + "\n", + "# ---- 특징 추출 공통 함수 ----\n", + "KERNELS = [np.array(k, float) for k in (\n", + " [[-1, 0, 1], [-2, 0, 2], [-1, 0, 1]], [[-1, -2, -1], [0, 0, 0], [1, 2, 1]], [[0, 1, 0], [1, -4, 1], [0, 1, 0]],\n", + " [[2, -1, -1], [-1, 2, -1], [-1, -1, 2]], [[-1, -1, 2], [-1, 2, -1], [2, -1, -1]], [[1, 1, 1], [1, 1, 1], [1, 1, 1]])]\n", + "\n", + "\n", + "def conv_features(o):\n", + " \"\"\"간이 CNN 특징: 고정 합성곱 필터 6종 -> ReLU -> 4x4 최대 풀링(8x8 출력).\"\"\"\n", + " feats = []\n", + " for k in KERNELS:\n", + " r = np.maximum(ndimage.correlate(o - o.mean(), k / np.abs(k).sum()), 0)\n", + " feats.append(r.reshape(8, 4, 8, 4).max(axis=(1, 3)).ravel())\n", + " feats.append(np.maximum(-ndimage.correlate(o - o.mean(), k / np.abs(k).sum()), 0).reshape(8, 4, 8, 4).max(axis=(1, 3)).ravel())\n", + " return np.concatenate(feats)\n", + "\n", + "\n", + "def hog(o, cell=8, bins=8):\n", + " gy, gx = np.gradient(o)\n", + " mag, ang = np.hypot(gx, gy), np.mod(np.arctan2(gy, gx), np.pi)\n", + " b = np.minimum((ang / np.pi * bins).astype(int), bins - 1)\n", + " out = np.zeros((N // cell, N // cell, bins))\n", + " for i in range(N // cell):\n", + " for j in range(N // cell):\n", + " sl = (slice(i * cell, (i + 1) * cell), slice(j * cell, (j + 1) * cell))\n", + " out[i, j] = np.bincount(b[sl].ravel(), mag[sl].ravel(), bins)\n", + " return (out / (np.linalg.norm(out) + 1e-6)).ravel()\n", + "\n", + "\n", + "def shape_features(o, s):\n", + " \"\"\"객체 모양 특징: 가로 방향 투영으로 전체 길이·차체 길이·가는 돌출부(포신) 길이·두꺼운 부분(포탑) 위치를 잰다.\n", + " 방위 정규화 뒤에도 앞뒤(180도)가 뒤집힐 수 있으므로, 가는 돌출부가 오른쪽에 오도록 맞춘다.\"\"\"\n", + " sm = ndimage.uniform_filter(s, 3)\n", + " dark = np.abs(o - np.median(o))\n", + " m = (sm > 0.22) | (dark > 0.12)\n", + " lab, n = ndimage.label(m)\n", + " if n:\n", + " c = lab[10:22, 8:24]\n", + " k = np.bincount(c[c > 0]).argmax() if (c > 0).any() else 1\n", + " m = lab == k\n", + " p = m.sum(axis=0).astype(float)\n", + " cols = np.nonzero(p)[0]\n", + " if len(cols) == 0:\n", + " return np.zeros(8), o, s\n", + " thin = (p > 0) & (p <= 2)\n", + " left, right = thin[:N // 2].sum(), thin[N // 2:].sum()\n", + " if left > right:\n", + " o, s, m, p = o[:, ::-1], s[:, ::-1], m[:, ::-1], p[::-1]\n", + " cols = np.nonzero(p)[0]\n", + " body = np.nonzero(p > 3)[0]\n", + " d = np.where(m, dark if left <= right else dark[:, ::-1], 0)\n", + " heavy = d.sum(axis=0)\n", + " centre = (body[0] + body[-1]) / 2 if len(body) else N / 2\n", + " turret = (heavy * np.arange(N)).sum() / max(heavy.sum(), 1e-6) - centre\n", + " return np.array([cols[-1] - cols[0] + 1, len(body), ((p > 0) & (p <= 2)).sum(), p.max(), turret,\n", + " m.sum(), (cols[-1] - (body[-1] if len(body) else cols[-1])), m.sum() / max(len(cols) * p.max(), 1)]), o, s\n", + "\n", + "\n", + "def adv_features(o, s):\n", + " \"\"\"정밀 특징: 모양(길이·포신·포탑 위치) + HOG(윤곽) + SAR 반사 통계(위장막·구름 영향 적음) + 투영 윤곽.\"\"\"\n", + " shp, o, s = shape_features(o, s)\n", + " c = s[8:24, 4:28]\n", + " sm = ndimage.uniform_filter(s, 3)\n", + " m = sm > 0.2\n", + " dark = np.abs(o - np.median(o))\n", + " return np.concatenate([shp, hog(o), [c.mean(), c.max(), (sm > 0.25).mean(), (sm > 0.4).mean(), m.sum(axis=1).max(), m.sum(axis=0).max()],\n", + " sm.mean(axis=0).reshape(8, 4).mean(1), sm.mean(axis=1).reshape(8, 4).mean(1),\n", + " dark.mean(axis=0).reshape(8, 4).mean(1), dark.mean(axis=1).reshape(8, 4).mean(1),\n", + " [o.mean(), o.std(), dark.max(), (o > 0.65).mean(), (o < 0.3).mean()]])\n", + "\n", + "\n", + "def features(O, S, M):\n", + " return np.array([conv_features(p[0]) for p in preprocess_batch(O, S, M)])\n", + "\n", + "\n", + "# 1) 기본 데이터셋: 은폐 없는 영상(학습 800장: 맑은 조건 / 시험 400장: 맑은 조건 + 낮은 태양고도 + 비스듬 촬영)\n", + "Otr, Str, Mtr = make_dataset(800, seed=1)\n", + "Ote, Ste, Mte = make_dataset(400, seed=2, scen=(\"clean\", \"clean\", \"low_sun\", \"off_nadir\"))\n", + "Xtr, ytr = features(Otr, Str, Mtr), [m[\"cls\"] for m in Mtr]\n", + "t0 = time.perf_counter()\n", + "Xte = features(Ote, Ste, Mte)\n", + "yte = np.array([m[\"cls\"] for m in Mte])\n", + "\n", + "# 2) 규제 강도(C) 1차 튜닝: 학습 자료의 일부(검증용 200장)로 선택\n", + "best_c, best = None, -1\n", + "for c in [0.01, 0.05, 0.2, 1.0]:\n", + " mdl = make_pipeline(StandardScaler(), LogisticRegression(C=c, max_iter=3000)).fit(Xtr[:600], ytr[:600])\n", + " acc = mdl.score(Xtr[600:], ytr[600:])\n", + " print(f\"C={c:<5} 검증 정확도 {acc:.3f}\")\n", + " if acc > best:\n", + " best_c, best = c, acc\n", + "model = make_pipeline(StandardScaler(), LogisticRegression(C=best_c, max_iter=3000)).fit(Xtr, ytr)\n", + "pred = model.predict(Xte)\n", + "lat = (time.perf_counter() - t0) / len(Xte) * 1000\n", + "acc = (pred == yte).mean()\n", + "print(f\"\\n선택 C={best_c}, 기본 시험셋 정확도 {acc:.1%} (목표 75% 이상: {'달성' if acc >= 0.75 else '미달'}), 1장당 판단 {lat:.2f} ms(전처리 포함)\")\n", + "\n", + "print(\"\\n[혼동행렬] 행=실제, 열=예측\")\n", + "print(pd.DataFrame(confusion_matrix(yte, pred, labels=CLASSES), index=CLASSES, columns=CLASSES).to_string())\n", + "mil, pm = np.isin(yte, list(MILITARY)), np.isin(pred, list(MILITARY))\n", + "print(f\"군용 장비 여부 판정 정확도 {(mil == pm).mean():.1%}, 클래스별 재현율:\",\n", + " {c: round(float((pred[yte == c] == c).mean()), 2) for c in CLASSES})\n", + "\n", + "# 3) 한계: 위장막·수목 차폐·저해상도 등 은폐 조건에서는 정확도가 떨어진다(미션 4의 동기)\n", + "Oh, Sh, Mh = make_dataset(400, seed=3, scen=(\"camo\", \"occl50\", \"low_res\", \"camo_occl\"))\n", + "ph = model.predict(features(Oh, Sh, Mh))\n", + "yh, sh = np.array([m[\"cls\"] for m in Mh]), np.array([m[\"scen\"] for m in Mh])\n", + "print(\"[은폐 조건 정확도]\", {k: round(float((ph[sh == k] == yh[sh == k]).mean()), 2) for k in (\"camo\", \"occl50\", \"low_res\", \"camo_occl\")})\n", + "print(\"판단 근거: 필터 반응(윤곽·포신 같은 가는 선)의 위치 분포. 위장막이 윤곽을 지우면 근거가 사라진다.\")\n", + "\n", + "# 4) 그림: 클래스별 예시와 필터 반응\n", + "fig, ax = plt.subplots(2, 4, figsize=(12, 6))\n", + "for j, c in enumerate(CLASSES):\n", + " i = int(np.where(yte == c)[0][0])\n", + " o = preprocess(Ote[i], Ste[i], Mte[i])[0]\n", + " ax[0, j].imshow(o, cmap=\"gray\"); ax[0, j].set_title(f\"{c} -> pred {pred[i]}\")\n", + " ax[1, j].imshow(np.maximum(ndimage.correlate(o - o.mean(), KERNELS[0] / 8), 0), cmap=\"magma\"); ax[1, j].set_title(\"Sobel-x response\")\n", + "for a in ax.ravel():\n", + " a.axis(\"off\")\n", + "plt.suptitle(\"Baseline conv-feature classifier (synthetic chips)\"); plt.tight_layout(); plt.show()\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 03 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + "C=0.01 검증 정확도 1.000\n", + "C=0.05 검증 정확도 1.000\n", + "C=0.2 검증 정확도 1.000\n", + "C=1.0 검증 정확도 1.000\n", + "\n", + "선택 C=0.01, 기본 시험셋 정확도 96.5% (목표 75% 이상: 달성), 1장당 판단 5.38 ms(전처리 포함)\n", + "\n", + "[혼동행렬] 행=실제, 열=예측\n", + " tank spg truck civil\n", + "tank 91 8 1 0\n", + "spg 1 99 0 0\n", + "truck 1 3 96 0\n", + "civil 0 0 0 100\n", + "군용 장비 여부 판정 정확도 100.0%, 클래스별 재현율: {'tank': 0.91, 'spg': 0.99, 'truck': 0.96, 'civil': 1.0}\n", + "[은폐 조건 정확도] {'camo': 0.5, 'occl50': 0.52, 'low_res': 0.25, 'camo_occl': 0.61}\n", + "판단 근거: 필터 반응(윤곽·포신 같은 가는 선)의 위치 분포. 위장막이 윤곽을 지우면 근거가 사라진다.\n", + "```\n", + "
\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 04. 위장막/차폐 환경 하 군용 장비 미세 특징 추출 및 정밀 객체분류 모델 고도화\n", + "**소요시간**: 약 3.0시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "위장막 설치, 수목 차폐, 부분 은폐 상태의 군용 장비 미세 특징을 추출하여 객체분류 정확도를 극대화하는 정밀 모델을 고도화한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **위장막 설치, 수목 차폐, 부분 은폐 상태의 장비에서도 기종을 식별하도록 미세 특징을 추출하는 정밀 분류 모델로 고도화한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **객체 모양(전체 길이·차체 길이·포신 돌출·포탑 위치), 윤곽 방향 히스토그램(HOG), 위장막과 구름에 강한 SAR 반사 통계, 투영 윤곽 특징을 결합하고 은폐 조건 10종을 섞은 증강 자료로 랜덤 포레스트를 학습 수행하고 검증하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `src/models/isr01_advanced_detector.py 및 예외 탐지 테스트 결과` 형태의 결과물을 완성한다.\n", + "\n", + "### 🔍 평가 및 검증 기준 (PASS 조건)\n", + "#### 미션 판정\n", + "\n", + "**평가 구분:** 필수\n", + "\n", + "**핵심 평가영역:** 기만/교란 공격에 대한 방어 로직의 현실성 및 신뢰성\n", + "\n", + "#### 미션 PASS 조건\n", + "\n", + "- 정량 기준: 복합 위협 탐지 재현율(Recall) 85% 이상, 처리 지연 200ms 이하\n", + "\n", + "\n", + "- 진행 기준: 복합 위협 시나리오 10종에 대한 탐지 테스트 통과\n", + "\n", + "\n", + "- 검증 방법: 합성 위협 데이터셋 주입 시 이상치 탐지 정확도 검증\n", + "\n", + "\n", + "#### 채점자 판정 메모\n", + "\n", + "> 단순 코드 실행 여부뿐만 아니라 출력된 수치와 근거 자료가 군 실무 요구사항에 부합하는지 면밀히 확인한다.\n", + "\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "\"\"\"위장막·수목 차폐·구름·저해상도·SAR 잡음 등 은폐 조건 10종에서 군용 장비를 놓치지 않도록, 윤곽(HOG)·SAR 반사 통계·투영 윤곽 특징과 은폐 조건 증강 학습을 결합한 정밀 객체분류 모델(랜덤 포레스트)로 고도화하고 조건별 재현율과 처리 지연을 측정한다.\"\"\"\n", + "import time\n", + "import numpy as np\n", + "import pandas as pd\n", + "import matplotlib.pyplot as plt\n", + "from scipy import ndimage\n", + "from sklearn.ensemble import RandomForestClassifier\n", + "from sklearn.linear_model import LogisticRegression\n", + "from sklearn.pipeline import make_pipeline\n", + "from sklearn.preprocessing import StandardScaler\n", + "\n", + "# ---------------------------------------------------------------------------\n", + "# [교육용가상자료] 합성 위성영상 조각(chip) 생성기 (실제 위성영상·장비 제원·위치 없음)\n", + "# 32x32 광학(EO) 영상과 SAR 영상 한 쌍에 추상 도형으로 만든 객체를 그린다. 도형 치수는 가상값이다.\n", + "# ---------------------------------------------------------------------------\n", + "N = 32\n", + "STD_GSD = 0.5 # 표준 해상도(m/픽셀, 가상 기준)\n", + "CLASSES = [\"tank\", \"spg\", \"truck\", \"civil\"]\n", + "MILITARY = {\"tank\", \"spg\", \"truck\"}\n", + "# 객체 좌표계(u: 길이 방향, v: 폭 방향, 표준 해상도 픽셀) 가상 도형: (u중심, v중심, 길이, 폭, 광학 밝기 차이)\n", + "SHAPES = {\n", + " \"tank\": [(0, 0, 11, 7, -40), (0.5, 0, 5, 5, -55), (6, 0, 7, 1.2, -60)],\n", + " \"spg\": [(0, 0, 16, 7.5, -38), (-4, 0, 6, 6.5, -52), (5, 0, 13, 1.4, -60)],\n", + " \"truck\": [(5, 0, 3.5, 5.5, -45), (-1.8, 0, 8.5, 6, -25)],\n", + " \"civil\": [(0, 0, 8, 3.8, 0), (1.2, 0, 2, 3.4, 0)],\n", + " \"decoy\": [(0, 0, 11, 7, -40), (0.5, 0, 5, 5, -42), (6, 0, 7, 1.2, -45)],\n", + " \"none\": [], # 객체 없는 배경 타일\n", + "}\n", + "SAR_RETURN = {\"tank\": 0.85, \"spg\": 0.85, \"truck\": 0.7, \"civil\": 0.45, \"decoy\": 0.18, \"none\": 0.12} # 금속 반사 세기(가상)\n", + "\n", + "\n", + "def _mask(cls, angle, scale, squash=1.0):\n", + " \"\"\"회전(angle, 도)·축척(scale)·비스듬 촬영 압축(squash)을 적용해 도형별 마스크와 밝기 차이를 만든다.\"\"\"\n", + " # [TODO : 학생 실습 4] 위장막/차폐 환경 하 군용 장비 미세 특징 추출 및 정밀 객체분류 모델 고도화 관련 핵심 로직을 구현하세요.\n", + " # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n", + " # <<< 여기에 코드를 작성하세요 >>>\n", + " pass # 작성 후 pass를 제거하세요.\n", + "def make_chip(cls, rng, cond=None):\n", + " \"\"\"chip 1개 = (광학 32x32, SAR 32x32, 메타데이터). cond: camo, occl, cloud, low_sun, off_nadir, low_res, speckle\"\"\"\n", + " c = {\"camo\": 0.0, \"occl\": 0.0, \"cloud\": 0.0, \"low_sun\": False, \"off_nadir\": 0.0, \"low_res\": False, \"speckle\": 1.0, **(cond or {})}\n", + " gsd = rng.uniform(0.9, 1.2) if c[\"low_res\"] else rng.uniform(0.4, 0.6)\n", + " scale = STD_GSD / gsd\n", + " angle, off = rng.uniform(0, 360), c[\"off_nadir\"] or rng.uniform(0, 15)\n", + " squash = np.cos(np.radians(off))\n", + " sun_el = rng.uniform(12, 22) if c[\"low_sun\"] else rng.uniform(40, 70)\n", + " sun_az = rng.uniform(0, 360)\n", + " bg = 115 + ndimage.gaussian_filter(rng.normal(0, 30, (N, N)), 2) + rng.normal(0, 5, (N, N))\n", + " parts = _mask(cls, angle, scale, squash)\n", + " body = np.any([m for m, _ in parts], axis=0) if parts else np.zeros((N, N), bool)\n", + " opt = bg.copy()\n", + " # 그림자: 태양 반대 방향으로 밀린 객체 모양(태양 고도가 낮을수록 길다)\n", + " L = min(8.0, 2.5 / np.tan(np.radians(sun_el))) * scale\n", + " sh = ndimage.shift(body.astype(float), (L * np.cos(np.radians(sun_az)), -L * np.sin(np.radians(sun_az))), order=0) > 0.5\n", + " opt[sh & ~body] -= 35 if cls != \"decoy\" else 25\n", + " if cls == \"civil\":\n", + " base = rng.choice([200.0, 60.0]) # 민간 차량 색상: 밝거나 어두움\n", + " opt[body] = base + rng.normal(0, 8, body.sum())\n", + " roof = parts[1][0]\n", + " opt[roof] = base * 0.8\n", + " else:\n", + " ref = bg[body].mean() if body.any() else bg.mean()\n", + " for m, d in parts:\n", + " opt[m] = ref + d + rng.normal(0, 4 if cls != \"decoy\" else 2, m.sum())\n", + " # 위장막: 객체 주변을 배경 질감으로 덮어 대비를 낮춘다\n", + " if c[\"camo\"] > 0 and cls != \"civil\":\n", + " net = ndimage.binary_dilation(body, iterations=2)\n", + " tex = 105 + ndimage.gaussian_filter(rng.normal(0, 25, (N, N)), 1)\n", + " opt[net] = (1 - c[\"camo\"]) * opt[net] + c[\"camo\"] * tex[net]\n", + " # 수목 차폐: 객체의 일부를 어두운 나무 덩어리로 가린다\n", + " if c[\"occl\"] > 0:\n", + " trees = ndimage.gaussian_filter(rng.random((N, N)), 2)\n", + " near = ndimage.binary_dilation(body, iterations=3)\n", + " cover = near & (trees > np.quantile(trees[body], 1 - c[\"occl\"])) if body.any() else np.zeros((N, N), bool)\n", + " opt[cover] = 70 + rng.normal(0, 6, cover.sum())\n", + " # 구름: 밝고 매끈한 덩어리(광학만 가림, SAR는 통과)\n", + " cmask = np.zeros((N, N), bool)\n", + " if c[\"cloud\"] > 0:\n", + " blob = ndimage.gaussian_filter(rng.random((N, N)), 4)\n", + " cmask = blob > np.quantile(blob, 1 - c[\"cloud\"])\n", + " opt[cmask] = 0.25 * opt[cmask] + 0.75 * (235 + rng.normal(0, 3, cmask.sum()))\n", + " # SAR: 곱셈형 스페클 잡음 + 금속 반사(모서리 이중 반사로 밝은 점)\n", + " sar = np.full((N, N), 0.12)\n", + " sar[body] = SAR_RETURN[cls] * rng.uniform(0.8, 1.2)\n", + " edge = body & ~ndimage.binary_erosion(body)\n", + " sar[edge] *= 1.3 if cls in MILITARY else 1.0\n", + " sar = sar * rng.gamma(4 / c[\"speckle\"], c[\"speckle\"] / 4, (N, N))\n", + " if c[\"low_res\"]: # 저해상도: 정보 손실(블러)\n", + " down = lambda a: np.kron(a.reshape(N // 2, 2, N // 2, 2).mean((1, 3)), np.ones((2, 2)))\n", + " opt, sar = ndimage.gaussian_filter(down(opt), 0.7), ndimage.gaussian_filter(down(sar), 0.7)\n", + " meta = {\"cls\": cls, \"gsd\": round(float(gsd), 2), \"off_nadir\": round(float(off), 1), \"sun_el\": round(float(sun_el), 1),\n", + " \"sun_az\": round(float(sun_az), 1), \"angle\": round(float(angle), 1), \"cloud_frac\": float(cmask.mean()),\n", + " \"cloud_on_obj\": float((cmask & body).sum() / max(body.sum(), 1)), **{k: c[k] for k in (\"camo\", \"occl\", \"low_sun\", \"low_res\")}}\n", + " return np.clip(opt, 0, 255), sar, meta\n", + "\n", + "\n", + "SCEN = { # 은폐·촬영 조건 시나리오 10종\n", + " \"clean\": {}, \"camo\": {\"camo\": 0.7}, \"occl30\": {\"occl\": 0.3}, \"occl50\": {\"occl\": 0.5}, \"cloud\": {\"cloud\": 0.25},\n", + " \"low_sun\": {\"low_sun\": True}, \"off_nadir\": {\"off_nadir\": 35.0}, \"low_res\": {\"low_res\": True},\n", + " \"speckle\": {\"speckle\": 3.0}, \"camo_occl\": {\"camo\": 0.6, \"occl\": 0.3}}\n", + "\n", + "\n", + "def make_dataset(n=400, seed=7, scen=(\"clean\",), classes=CLASSES):\n", + " rng = np.random.default_rng(seed)\n", + " O, S, M = np.zeros((n, N, N)), np.zeros((n, N, N)), []\n", + " for i in range(n):\n", + " cls, sc = classes[i % len(classes)], scen[(i // len(classes)) % len(scen)]\n", + " O[i], S[i], m = make_chip(cls, rng, SCEN[sc])\n", + " m.update({\"id\": f\"IMG{i:04d}\", \"scen\": sc})\n", + " M.append(m)\n", + " return O, S, M\n", + "# ---------------------------------------------------------------------------\n", + "\n", + "# ---- 전처리(해상도·기하 보정, 구름 차폐 처리, 방위 정규화) 공통 함수 ----\n", + "def _fit(a, fill):\n", + " \"\"\"중앙 기준으로 32x32에 맞게 자르거나 채운다.\"\"\"\n", + " # [TODO : 학생 실습 4] 위장막/차폐 환경 하 군용 장비 미세 특징 추출 및 정밀 객체분류 모델 고도화 관련 핵심 로직을 구현하세요.\n", + " # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n", + " # <<< 여기에 코드를 작성하세요 >>>\n", + " pass # 작성 후 pass를 제거하세요.\n", + "def cloud_mask(opt, sm, min_area=15):\n", + " \"\"\"광학에서 밝고(185 초과) 매끈한데 SAR에는 반사가 없는 영역을 구름으로 본다(SAR는 구름을 통과하므로 밝은 차량과 구분된다).\"\"\"\n", + " bright = opt > 185\n", + " if bright.sum() < min_area:\n", + " return np.zeros_like(bright)\n", + " local_sd = np.sqrt(np.maximum(ndimage.uniform_filter(opt ** 2, 3) - ndimage.uniform_filter(opt, 3) ** 2, 0))\n", + " cand = bright & (local_sd < 12) & (sm < 0.3)\n", + " lab, n = ndimage.label(cand)\n", + " if n == 0:\n", + " return cand\n", + " sizes = ndimage.sum(cand.astype(float), lab, range(1, n + 1))\n", + " return ndimage.binary_dilation(np.isin(lab, 1 + np.where(sizes >= min_area)[0]), iterations=2) & (opt > 140)\n", + "\n", + "\n", + "def orientation(opt, sm):\n", + " \"\"\"SAR 반사(위장·구름 영향 적음, sm은 평활화한 SAR)와 광학 대비를 합친 객체 마스크의 주축 방향(도).\"\"\"\n", + " m = (sm > 0.3) | (np.abs(opt - opt.mean()) > 30)\n", + " lab, n = ndimage.label(m)\n", + " if n == 0:\n", + " return 0.0, m\n", + " c = lab[N // 2 - 6:N // 2 + 6, N // 2 - 6:N // 2 + 6]\n", + " k = np.bincount(c[c > 0]).argmax() if (c > 0).any() else 1 + int(np.argmax(ndimage.sum(m.astype(float), lab, range(1, n + 1))))\n", + " m = lab == k\n", + " yy, xx = np.nonzero(m)\n", + " if len(yy) < 5:\n", + " return 0.0, m\n", + " dy, dx = yy - yy.mean(), xx - xx.mean() # 2차 모멘트로 주축 각도 계산\n", + " return float(np.degrees(0.5 * np.arctan2(2 * (dx * dy).mean(), (dx * dx).mean() - (dy * dy).mean()))), m\n", + "\n", + "\n", + "_YY, _XX = np.mgrid[0:N, 0:N] - (N - 1) / 2\n", + "\n", + "\n", + "def warp(stack, zy, zx, ang):\n", + " \"\"\"여러 장(B,채널,32,32)에 장별 확대·축소(zy, zx)와 회전(ang, 도)을 한 번의 쌍선형 보간으로 적용한다(벡터 연산).\"\"\"\n", + " a = np.radians(ang)[:, None, None]\n", + " zy, zx = np.asarray(zy)[:, None, None], np.asarray(zx)[:, None, None]\n", + " sy = np.clip((np.cos(a) * _YY + np.sin(a) * _XX) / zy + (N - 1) / 2, 0, N - 1.001) # 출력 좌표 -> 원본 좌표\n", + " sx = np.clip((-np.sin(a) * _YY + np.cos(a) * _XX) / zx + (N - 1) / 2, 0, N - 1.001)\n", + " y0, x0 = sy.astype(int), sx.astype(int)\n", + " fy, fx = (sy - y0).reshape(len(stack), 1, -1), (sx - x0).reshape(len(stack), 1, -1)\n", + " flat = stack.reshape(len(stack), stack.shape[1], -1)\n", + " lin = (y0 * N + x0).reshape(len(stack), 1, -1)\n", + " g = lambda d: np.take_along_axis(flat, np.broadcast_to(lin + d, (len(stack), stack.shape[1], lin.shape[2])), axis=2)\n", + " out = g(0) * (1 - fy) * (1 - fx) + g(N) * fy * (1 - fx) + g(1) * (1 - fy) * fx + g(N + 1) * fy * fx\n", + " return out.reshape(stack.shape)\n", + "\n", + "\n", + "def preprocess_batch(O, S, M):\n", + " \"\"\"여러 장을 한 번에 전처리한다. 0) 결측 보정 1) 해상도·비스듬 촬영 보정(표준 0.5m/픽셀)\n", + " 2) 구름 차폐 영역을 배경값으로 채움 3) 객체 주축을 가로로 회전 4) 0~1 정규화.\"\"\"\n", + " src = np.stack([np.asarray(O, float), np.asarray(S, float)], axis=1)\n", + " if np.isnan(src).any(): # 0) 센서 결측 픽셀 보정\n", + " mean = np.nanmean(src, axis=(2, 3), keepdims=True)\n", + " src = np.where(np.isnan(src), mean, src)\n", + " zy = np.array([m[\"gsd\"] / STD_GSD / np.cos(np.radians(m[\"off_nadir\"])) for m in M])\n", + " zx = np.array([m[\"gsd\"] / STD_GSD for m in M])\n", + " out = warp(src, zy, zx, np.zeros(len(M)))\n", + " sm = ndimage.uniform_filter(out[:, 1], (1, 3, 3))\n", + " angs, cloud = np.zeros(len(M)), np.zeros(len(M))\n", + " for i in range(len(M)):\n", + " cm = cloud_mask(out[i, 0], sm[i])\n", + " if cm.any():\n", + " fill = out[i, 0][~cm].mean() if (~cm).any() else 115.0\n", + " out[i, 0][cm] = fill\n", + " back = warp(cm[None, None].astype(float), [1 / zy[i]], [1 / zx[i]], np.zeros(1))[0, 0] > 0.5\n", + " src[i, 0][back] = fill # 원본의 같은 구름 영역도 채움\n", + " cloud[i] = cm.mean()\n", + " angs[i] = orientation(out[i, 0], sm[i])[0]\n", + " rot = np.abs(angs) > 3 # 3도 이하는 회전 생략\n", + " if rot.any():\n", + " out[rot] = warp(src[rot], zy[rot], zx[rot], angs[rot])\n", + " return [(np.clip(o / 255, 0, 1), np.clip(s / 1.5, 0, 1), float(c)) for (o, s), c in zip(out, cloud)]\n", + "\n", + "\n", + "def preprocess(opt, sar, meta):\n", + " \"\"\"한 장 전처리(preprocess_batch와 같은 처리).\"\"\"\n", + " return preprocess_batch([opt], [sar], [meta])[0]\n", + "\n", + "# ---- 특징 추출 공통 함수 ----\n", + "KERNELS = [np.array(k, float) for k in (\n", + " [[-1, 0, 1], [-2, 0, 2], [-1, 0, 1]], [[-1, -2, -1], [0, 0, 0], [1, 2, 1]], [[0, 1, 0], [1, -4, 1], [0, 1, 0]],\n", + " [[2, -1, -1], [-1, 2, -1], [-1, -1, 2]], [[-1, -1, 2], [-1, 2, -1], [2, -1, -1]], [[1, 1, 1], [1, 1, 1], [1, 1, 1]])]\n", + "\n", + "\n", + "def conv_features(o):\n", + " \"\"\"간이 CNN 특징: 고정 합성곱 필터 6종 -> ReLU -> 4x4 최대 풀링(8x8 출력).\"\"\"\n", + " feats = []\n", + " for k in KERNELS:\n", + " r = np.maximum(ndimage.correlate(o - o.mean(), k / np.abs(k).sum()), 0)\n", + " feats.append(r.reshape(8, 4, 8, 4).max(axis=(1, 3)).ravel())\n", + " feats.append(np.maximum(-ndimage.correlate(o - o.mean(), k / np.abs(k).sum()), 0).reshape(8, 4, 8, 4).max(axis=(1, 3)).ravel())\n", + " return np.concatenate(feats)\n", + "\n", + "\n", + "def hog(o, cell=8, bins=8):\n", + " gy, gx = np.gradient(o)\n", + " mag, ang = np.hypot(gx, gy), np.mod(np.arctan2(gy, gx), np.pi)\n", + " b = np.minimum((ang / np.pi * bins).astype(int), bins - 1)\n", + " out = np.zeros((N // cell, N // cell, bins))\n", + " for i in range(N // cell):\n", + " for j in range(N // cell):\n", + " sl = (slice(i * cell, (i + 1) * cell), slice(j * cell, (j + 1) * cell))\n", + " out[i, j] = np.bincount(b[sl].ravel(), mag[sl].ravel(), bins)\n", + " return (out / (np.linalg.norm(out) + 1e-6)).ravel()\n", + "\n", + "\n", + "def shape_features(o, s):\n", + " \"\"\"객체 모양 특징: 가로 방향 투영으로 전체 길이·차체 길이·가는 돌출부(포신) 길이·두꺼운 부분(포탑) 위치를 잰다.\n", + " 방위 정규화 뒤에도 앞뒤(180도)가 뒤집힐 수 있으므로, 가는 돌출부가 오른쪽에 오도록 맞춘다.\"\"\"\n", + " sm = ndimage.uniform_filter(s, 3)\n", + " dark = np.abs(o - np.median(o))\n", + " m = (sm > 0.22) | (dark > 0.12)\n", + " lab, n = ndimage.label(m)\n", + " if n:\n", + " c = lab[10:22, 8:24]\n", + " k = np.bincount(c[c > 0]).argmax() if (c > 0).any() else 1\n", + " m = lab == k\n", + " p = m.sum(axis=0).astype(float)\n", + " cols = np.nonzero(p)[0]\n", + " if len(cols) == 0:\n", + " return np.zeros(8), o, s\n", + " thin = (p > 0) & (p <= 2)\n", + " left, right = thin[:N // 2].sum(), thin[N // 2:].sum()\n", + " if left > right:\n", + " o, s, m, p = o[:, ::-1], s[:, ::-1], m[:, ::-1], p[::-1]\n", + " cols = np.nonzero(p)[0]\n", + " body = np.nonzero(p > 3)[0]\n", + " d = np.where(m, dark if left <= right else dark[:, ::-1], 0)\n", + " heavy = d.sum(axis=0)\n", + " centre = (body[0] + body[-1]) / 2 if len(body) else N / 2\n", + " turret = (heavy * np.arange(N)).sum() / max(heavy.sum(), 1e-6) - centre\n", + " return np.array([cols[-1] - cols[0] + 1, len(body), ((p > 0) & (p <= 2)).sum(), p.max(), turret,\n", + " m.sum(), (cols[-1] - (body[-1] if len(body) else cols[-1])), m.sum() / max(len(cols) * p.max(), 1)]), o, s\n", + "\n", + "\n", + "def adv_features(o, s):\n", + " \"\"\"정밀 특징: 모양(길이·포신·포탑 위치) + HOG(윤곽) + SAR 반사 통계(위장막·구름 영향 적음) + 투영 윤곽.\"\"\"\n", + " shp, o, s = shape_features(o, s)\n", + " c = s[8:24, 4:28]\n", + " sm = ndimage.uniform_filter(s, 3)\n", + " m = sm > 0.2\n", + " dark = np.abs(o - np.median(o))\n", + " return np.concatenate([shp, hog(o), [c.mean(), c.max(), (sm > 0.25).mean(), (sm > 0.4).mean(), m.sum(axis=1).max(), m.sum(axis=0).max()],\n", + " sm.mean(axis=0).reshape(8, 4).mean(1), sm.mean(axis=1).reshape(8, 4).mean(1),\n", + " dark.mean(axis=0).reshape(8, 4).mean(1), dark.mean(axis=1).reshape(8, 4).mean(1),\n", + " [o.mean(), o.std(), dark.max(), (o > 0.65).mean(), (o < 0.3).mean()]])\n", + "\n", + "\n", + "def prep(O, S, M):\n", + " return preprocess_batch(O, S, M)\n", + "\n", + "\n", + "ALL = tuple(SCEN)\n", + "# 1) 학습: 은폐 조건 10종을 고르게 섞은 증강 학습 자료 1,200장\n", + "Otr, Str, Mtr = make_dataset(1200, seed=3, scen=ALL)\n", + "Ptr = prep(Otr, Str, Mtr); ytr = [m[\"cls\"] for m in Mtr]\n", + "adv = RandomForestClassifier(n_estimators=200, min_samples_leaf=2, random_state=0, n_jobs=1)\n", + "adv.fit(np.array([adv_features(o, s) for o, s, _ in Ptr]), ytr)\n", + "base = make_pipeline(StandardScaler(), LogisticRegression(C=0.05, max_iter=3000)) # 비교용 Baseline(맑은 조건만 학습)\n", + "Oc, Sc, Mc = make_dataset(600, seed=1)\n", + "base.fit(np.array([conv_features(p[0]) for p in prep(Oc, Sc, Mc)]), [m[\"cls\"] for m in Mc])\n", + "\n", + "# 2) 시험: 조건 10종 x 4클래스 x 15장 = 600장\n", + "Ote, Ste, Mte = make_dataset(600, seed=4, scen=ALL)\n", + "t0 = time.perf_counter()\n", + "Pte = prep(Ote, Ste, Mte)\n", + "pa = adv.predict(np.array([adv_features(o, s) for o, s, _ in Pte]))\n", + "lat = (time.perf_counter() - t0) / len(Pte) * 1000\n", + "pb = base.predict(np.array([conv_features(p[0]) for p in Pte]))\n", + "y, sc = np.array([m[\"cls\"] for m in Mte]), np.array([m[\"scen\"] for m in Mte])\n", + "mil = np.isin(y, list(MILITARY))\n", + "rec = lambda p, sel: ((p == y) & mil & sel).sum() / (mil & sel).sum() # 군용 장비를 '정확한 기종'으로 식별한 비율\n", + "allsel = np.ones(len(y), bool)\n", + "print(f\"군용 장비 기종 식별 재현율: Baseline {rec(pb, allsel):.1%} -> 정밀 모델 {rec(pa, allsel):.1%} (목표 85% 이상: {'달성' if rec(pa, allsel) >= 0.85 else '미달'})\")\n", + "print(f\"4클래스 정확도: Baseline {(pb == y).mean():.1%} -> 정밀 모델 {(pa == y).mean():.1%}, 1장당 처리 {lat:.1f} ms (목표 200 ms 이하)\")\n", + "\n", + "# 3) 은폐 조건 10종 시험(군용 기종 식별 재현율 85% 이상이면 통과)\n", + "rows = []\n", + "for k in ALL:\n", + " s = sc == k\n", + " rows.append({\"조건\": k, \"Baseline 재현율\": round(rec(pb, s), 2), \"정밀 재현율\": round(rec(pa, s), 2),\n", + " \"정밀 정확도\": round(float((pa[s] == y[s]).mean()), 2), \"민간->군용 오분류\": int((~mil & s & np.isin(pa, list(MILITARY))).sum()),\n", + " \"판정\": \"통과\" if rec(pa, s) >= 0.85 else \"미통과\"})\n", + "t = pd.DataFrame(rows)\n", + "print(\"\\n\" + t.to_string(index=False))\n", + "print(f\"통과 {int((t['판정'] == '통과').sum())}/10종\")\n", + "\n", + "# 4) 오경보 유발 패턴과 중요 특징\n", + "print(\"\\n[혼동이 많은 쌍(실제->예측)]\", pd.Series([f\"{a}->{b}\" for a, b in zip(y, pa) if a != b]).value_counts().head(4).to_dict())\n", + "names = [\"obj_len\", \"body_len\", \"thin_cols\", \"max_width\", \"turret_pos\", \"area\", \"barrel_len\", \"fill\"] + [f\"hog{i}\" for i in range(128)] + [\"sar_mean\", \"sar_max\", \"sar>0.25\", \"sar>0.4\", \"sar_width\", \"sar_len\"] + \\\n", + " [f\"sar_colprof{i}\" for i in range(8)] + [f\"sar_rowprof{i}\" for i in range(8)] + [f\"opt_colprof{i}\" for i in range(8)] + \\\n", + " [f\"opt_rowprof{i}\" for i in range(8)] + [\"opt_mean\", \"opt_std\", \"opt_contrast\", \"bright_frac\", \"dark_frac\"]\n", + "imp = pd.Series(adv.feature_importances_, index=names).sort_values(ascending=False)\n", + "print(\"중요 특징 상위 5:\", {k: round(v, 3) for k, v in imp.head(5).items()})\n", + "print(\"-> SAR 반사 특징이 위장막·구름에 강해 재현율을 지탱한다. 남은 오류는 위 혼동 쌍처럼 비슷한 크기 기종 간 혼동이며 미션 5에서 신뢰도로 거른다.\")\n", + "\n", + "fig, ax = plt.subplots(1, 2, figsize=(12, 4))\n", + "x = np.arange(len(ALL))\n", + "ax[0].bar(x - 0.2, t[\"Baseline 재현율\"], 0.4, label=\"baseline\"); ax[0].bar(x + 0.2, t[\"정밀 재현율\"], 0.4, label=\"advanced\")\n", + "ax[0].axhline(0.85, ls=\"--\", c=\"k\"); ax[0].set_xticks(x); ax[0].set_xticklabels(ALL, rotation=45, fontsize=8)\n", + "ax[0].set_ylabel(\"military recall\"); ax[0].legend(); ax[0].set_title(\"Recall by concealment scenario\")\n", + "imp.head(10)[::-1].plot.barh(ax=ax[1]); ax[1].set_title(\"Top feature importances\")\n", + "plt.tight_layout(); plt.show()\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 04 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + "군용 장비 기종 식별 재현율: Baseline 66.0% -> 정밀 모델 97.3% (목표 85% 이상: 달성)\n", + "4클래스 정확도: Baseline 69.2% -> 정밀 모델 98.0%, 1장당 처리 1.5 ms (목표 200 ms 이하)\n", + "\n", + " 조건 Baseline 재현율 정밀 재현율 정밀 정확도 민간->군용 오분류 판정\n", + " clean 1.00 0.98 0.98 0 통과\n", + " camo 0.33 0.96 0.97 0 통과\n", + " occl30 0.73 1.00 1.00 0 통과\n", + " occl50 0.53 0.98 0.98 0 통과\n", + " cloud 0.33 1.00 1.00 0 통과\n", + " low_sun 0.82 0.96 0.97 0 통과\n", + "off_nadir 0.98 1.00 1.00 0 통과\n", + " low_res 0.33 0.87 0.90 0 통과\n", + " speckle 1.00 1.00 1.00 0 통과\n", + "camo_occl 0.53 1.00 1.00 0 통과\n", + "통과 10/10종\n", + "\n", + "[혼동이 많은 쌍(실제->예측)] {'truck->tank': 6, 'tank->truck': 5, 'tank->spg': 1}\n", + "중요 특징 상위 5: {'sar>0.25': 0.083, 'sar_width': 0.08, 'sar_len': 0.07, 'sar_colprof2': 0.064, 'sar_colprof4': 0.057}\n", + "-> SAR 반사 특징이 위장막·구름에 강해 재현율을 지탱한다. 남은 오류는 위 혼동 쌍처럼 비슷한 크기 기종 간 혼동이며 미션 5에서 신뢰도로 거른다.\n", + "```\n", + "
\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 05. 위성 촬영 각도/그림자 영향에 따른 객체분류 신뢰도 점수 산출 및 오분류 필터 구현\n", + "**소요시간**: 약 3.0시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "위성 센서의 해상도 한계 및 기상 영향을 반영하여 장비 분류 결과에 대한 신뢰도 점수를 계산하고 오분류를 필터링하는 엔진을 개발한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **센서 해상도 한계, 촬영 각도, 태양 고도(그림자), 구름 영향을 반영해 분류 결과의 신뢰도 점수를 계산하고 오분류를 걸러내는 엔진을 개발한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **분류 확률의 최댓값·차이·엔트로피와 구름 비율, 해상도, 촬영 각도, 태양 고도, SAR 반사 세기를 품질 지표로 삼아 별도 보정 자료에서 '분류가 맞을 확률'을 학습하고 그 계수를 신뢰도 가중치 매트릭스로 공개 수행하고 검증하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `src/verification/isr01_confidence_engine.py` 형태의 결과물을 완성한다.\n", + "\n", + "### 🔍 평가 및 검증 기준 (PASS 조건)\n", + "#### 미션 판정\n", + "\n", + "**평가 구분:** 필수\n", + "\n", + "**핵심 평가영역:** 신뢰도 등급(High/Med/Low) 부여 기준의 군사적 타당성\n", + "\n", + "#### 미션 PASS 조건\n", + "\n", + "- 정량 기준: 오경보율(FAR) 40% 이상 감축, 신뢰도 스코어 산출 일관성 90% 이상\n", + "\n", + "\n", + "- 진행 기준: 오경보 필터링 전후 비교 지표 산출 및 통합 테스트 완료\n", + "\n", + "\n", + "- 검증 방법: ROC-AUC 곡선 분석 및 임계값 민감도 테스트\n", + "\n", + "\n", + "#### 채점자 판정 메모\n", + "\n", + "> 단순 코드 실행 여부뿐만 아니라 출력된 수치와 근거 자료가 군 실무 요구사항에 부합하는지 면밀히 확인한다.\n", + "\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "\"\"\"위성 센서 해상도 한계, 비스듬 촬영 각도, 태양 고도(그림자), 구름 차폐를 반영해 장비 분류 결과의 신뢰도 점수(분류가 맞을 확률)와 등급(High/Medium/Low)을 계산하고, 낮은 신뢰도 결과를 판독 보류로 돌려 오분류 보고를 줄이는 엔진을 개발한다.\"\"\"\n", + "import numpy as np\n", + "import pandas as pd\n", + "import matplotlib.pyplot as plt\n", + "from scipy import ndimage\n", + "from sklearn.ensemble import RandomForestClassifier\n", + "from sklearn.linear_model import LogisticRegression\n", + "from sklearn.metrics import roc_auc_score\n", + "from sklearn.pipeline import make_pipeline\n", + "from sklearn.preprocessing import StandardScaler\n", + "\n", + "# ---------------------------------------------------------------------------\n", + "# [교육용가상자료] 합성 위성영상 조각(chip) 생성기 (실제 위성영상·장비 제원·위치 없음)\n", + "# 32x32 광학(EO) 영상과 SAR 영상 한 쌍에 추상 도형으로 만든 객체를 그린다. 도형 치수는 가상값이다.\n", + "# ---------------------------------------------------------------------------\n", + "N = 32\n", + "STD_GSD = 0.5 # 표준 해상도(m/픽셀, 가상 기준)\n", + "CLASSES = [\"tank\", \"spg\", \"truck\", \"civil\"]\n", + "MILITARY = {\"tank\", \"spg\", \"truck\"}\n", + "# 객체 좌표계(u: 길이 방향, v: 폭 방향, 표준 해상도 픽셀) 가상 도형: (u중심, v중심, 길이, 폭, 광학 밝기 차이)\n", + "SHAPES = {\n", + " \"tank\": [(0, 0, 11, 7, -40), (0.5, 0, 5, 5, -55), (6, 0, 7, 1.2, -60)],\n", + " \"spg\": [(0, 0, 16, 7.5, -38), (-4, 0, 6, 6.5, -52), (5, 0, 13, 1.4, -60)],\n", + " \"truck\": [(5, 0, 3.5, 5.5, -45), (-1.8, 0, 8.5, 6, -25)],\n", + " \"civil\": [(0, 0, 8, 3.8, 0), (1.2, 0, 2, 3.4, 0)],\n", + " \"decoy\": [(0, 0, 11, 7, -40), (0.5, 0, 5, 5, -42), (6, 0, 7, 1.2, -45)],\n", + " \"none\": [], # 객체 없는 배경 타일\n", + "}\n", + "SAR_RETURN = {\"tank\": 0.85, \"spg\": 0.85, \"truck\": 0.7, \"civil\": 0.45, \"decoy\": 0.18, \"none\": 0.12} # 금속 반사 세기(가상)\n", + "\n", + "\n", + "def _mask(cls, angle, scale, squash=1.0):\n", + " \"\"\"회전(angle, 도)·축척(scale)·비스듬 촬영 압축(squash)을 적용해 도형별 마스크와 밝기 차이를 만든다.\"\"\"\n", + " # [TODO : 학생 실습 5] 위성 촬영 각도/그림자 영향에 따른 객체분류 신뢰도 점수 산출 및 오분류 필터 구현 관련 핵심 로직을 구현하세요.\n", + " # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n", + " # <<< 여기에 코드를 작성하세요 >>>\n", + " pass # 작성 후 pass를 제거하세요.\n", + "def make_chip(cls, rng, cond=None):\n", + " \"\"\"chip 1개 = (광학 32x32, SAR 32x32, 메타데이터). cond: camo, occl, cloud, low_sun, off_nadir, low_res, speckle\"\"\"\n", + " c = {\"camo\": 0.0, \"occl\": 0.0, \"cloud\": 0.0, \"low_sun\": False, \"off_nadir\": 0.0, \"low_res\": False, \"speckle\": 1.0, **(cond or {})}\n", + " gsd = rng.uniform(0.9, 1.2) if c[\"low_res\"] else rng.uniform(0.4, 0.6)\n", + " scale = STD_GSD / gsd\n", + " angle, off = rng.uniform(0, 360), c[\"off_nadir\"] or rng.uniform(0, 15)\n", + " squash = np.cos(np.radians(off))\n", + " sun_el = rng.uniform(12, 22) if c[\"low_sun\"] else rng.uniform(40, 70)\n", + " sun_az = rng.uniform(0, 360)\n", + " bg = 115 + ndimage.gaussian_filter(rng.normal(0, 30, (N, N)), 2) + rng.normal(0, 5, (N, N))\n", + " parts = _mask(cls, angle, scale, squash)\n", + " body = np.any([m for m, _ in parts], axis=0) if parts else np.zeros((N, N), bool)\n", + " opt = bg.copy()\n", + " # 그림자: 태양 반대 방향으로 밀린 객체 모양(태양 고도가 낮을수록 길다)\n", + " L = min(8.0, 2.5 / np.tan(np.radians(sun_el))) * scale\n", + " sh = ndimage.shift(body.astype(float), (L * np.cos(np.radians(sun_az)), -L * np.sin(np.radians(sun_az))), order=0) > 0.5\n", + " opt[sh & ~body] -= 35 if cls != \"decoy\" else 25\n", + " if cls == \"civil\":\n", + " base = rng.choice([200.0, 60.0]) # 민간 차량 색상: 밝거나 어두움\n", + " opt[body] = base + rng.normal(0, 8, body.sum())\n", + " roof = parts[1][0]\n", + " opt[roof] = base * 0.8\n", + " else:\n", + " ref = bg[body].mean() if body.any() else bg.mean()\n", + " for m, d in parts:\n", + " opt[m] = ref + d + rng.normal(0, 4 if cls != \"decoy\" else 2, m.sum())\n", + " # 위장막: 객체 주변을 배경 질감으로 덮어 대비를 낮춘다\n", + " if c[\"camo\"] > 0 and cls != \"civil\":\n", + " net = ndimage.binary_dilation(body, iterations=2)\n", + " tex = 105 + ndimage.gaussian_filter(rng.normal(0, 25, (N, N)), 1)\n", + " opt[net] = (1 - c[\"camo\"]) * opt[net] + c[\"camo\"] * tex[net]\n", + " # 수목 차폐: 객체의 일부를 어두운 나무 덩어리로 가린다\n", + " if c[\"occl\"] > 0:\n", + " trees = ndimage.gaussian_filter(rng.random((N, N)), 2)\n", + " near = ndimage.binary_dilation(body, iterations=3)\n", + " cover = near & (trees > np.quantile(trees[body], 1 - c[\"occl\"])) if body.any() else np.zeros((N, N), bool)\n", + " opt[cover] = 70 + rng.normal(0, 6, cover.sum())\n", + " # 구름: 밝고 매끈한 덩어리(광학만 가림, SAR는 통과)\n", + " cmask = np.zeros((N, N), bool)\n", + " if c[\"cloud\"] > 0:\n", + " blob = ndimage.gaussian_filter(rng.random((N, N)), 4)\n", + " cmask = blob > np.quantile(blob, 1 - c[\"cloud\"])\n", + " opt[cmask] = 0.25 * opt[cmask] + 0.75 * (235 + rng.normal(0, 3, cmask.sum()))\n", + " # SAR: 곱셈형 스페클 잡음 + 금속 반사(모서리 이중 반사로 밝은 점)\n", + " sar = np.full((N, N), 0.12)\n", + " sar[body] = SAR_RETURN[cls] * rng.uniform(0.8, 1.2)\n", + " edge = body & ~ndimage.binary_erosion(body)\n", + " sar[edge] *= 1.3 if cls in MILITARY else 1.0\n", + " sar = sar * rng.gamma(4 / c[\"speckle\"], c[\"speckle\"] / 4, (N, N))\n", + " if c[\"low_res\"]: # 저해상도: 정보 손실(블러)\n", + " down = lambda a: np.kron(a.reshape(N // 2, 2, N // 2, 2).mean((1, 3)), np.ones((2, 2)))\n", + " opt, sar = ndimage.gaussian_filter(down(opt), 0.7), ndimage.gaussian_filter(down(sar), 0.7)\n", + " meta = {\"cls\": cls, \"gsd\": round(float(gsd), 2), \"off_nadir\": round(float(off), 1), \"sun_el\": round(float(sun_el), 1),\n", + " \"sun_az\": round(float(sun_az), 1), \"angle\": round(float(angle), 1), \"cloud_frac\": float(cmask.mean()),\n", + " \"cloud_on_obj\": float((cmask & body).sum() / max(body.sum(), 1)), **{k: c[k] for k in (\"camo\", \"occl\", \"low_sun\", \"low_res\")}}\n", + " return np.clip(opt, 0, 255), sar, meta\n", + "\n", + "\n", + "SCEN = { # 은폐·촬영 조건 시나리오 10종\n", + " \"clean\": {}, \"camo\": {\"camo\": 0.7}, \"occl30\": {\"occl\": 0.3}, \"occl50\": {\"occl\": 0.5}, \"cloud\": {\"cloud\": 0.25},\n", + " \"low_sun\": {\"low_sun\": True}, \"off_nadir\": {\"off_nadir\": 35.0}, \"low_res\": {\"low_res\": True},\n", + " \"speckle\": {\"speckle\": 3.0}, \"camo_occl\": {\"camo\": 0.6, \"occl\": 0.3}}\n", + "\n", + "\n", + "def make_dataset(n=400, seed=7, scen=(\"clean\",), classes=CLASSES):\n", + " rng = np.random.default_rng(seed)\n", + " O, S, M = np.zeros((n, N, N)), np.zeros((n, N, N)), []\n", + " for i in range(n):\n", + " cls, sc = classes[i % len(classes)], scen[(i // len(classes)) % len(scen)]\n", + " O[i], S[i], m = make_chip(cls, rng, SCEN[sc])\n", + " m.update({\"id\": f\"IMG{i:04d}\", \"scen\": sc})\n", + " M.append(m)\n", + " return O, S, M\n", + "# ---------------------------------------------------------------------------\n", + "\n", + "# ---- 전처리(해상도·기하 보정, 구름 차폐 처리, 방위 정규화) 공통 함수 ----\n", + "def _fit(a, fill):\n", + " \"\"\"중앙 기준으로 32x32에 맞게 자르거나 채운다.\"\"\"\n", + " # [TODO : 학생 실습 5] 위성 촬영 각도/그림자 영향에 따른 객체분류 신뢰도 점수 산출 및 오분류 필터 구현 관련 핵심 로직을 구현하세요.\n", + " # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n", + " # <<< 여기에 코드를 작성하세요 >>>\n", + " pass # 작성 후 pass를 제거하세요.\n", + "def cloud_mask(opt, sm, min_area=15):\n", + " \"\"\"광학에서 밝고(185 초과) 매끈한데 SAR에는 반사가 없는 영역을 구름으로 본다(SAR는 구름을 통과하므로 밝은 차량과 구분된다).\"\"\"\n", + " bright = opt > 185\n", + " if bright.sum() < min_area:\n", + " return np.zeros_like(bright)\n", + " local_sd = np.sqrt(np.maximum(ndimage.uniform_filter(opt ** 2, 3) - ndimage.uniform_filter(opt, 3) ** 2, 0))\n", + " cand = bright & (local_sd < 12) & (sm < 0.3)\n", + " lab, n = ndimage.label(cand)\n", + " if n == 0:\n", + " return cand\n", + " sizes = ndimage.sum(cand.astype(float), lab, range(1, n + 1))\n", + " return ndimage.binary_dilation(np.isin(lab, 1 + np.where(sizes >= min_area)[0]), iterations=2) & (opt > 140)\n", + "\n", + "\n", + "def orientation(opt, sm):\n", + " \"\"\"SAR 반사(위장·구름 영향 적음, sm은 평활화한 SAR)와 광학 대비를 합친 객체 마스크의 주축 방향(도).\"\"\"\n", + " m = (sm > 0.3) | (np.abs(opt - opt.mean()) > 30)\n", + " lab, n = ndimage.label(m)\n", + " if n == 0:\n", + " return 0.0, m\n", + " c = lab[N // 2 - 6:N // 2 + 6, N // 2 - 6:N // 2 + 6]\n", + " k = np.bincount(c[c > 0]).argmax() if (c > 0).any() else 1 + int(np.argmax(ndimage.sum(m.astype(float), lab, range(1, n + 1))))\n", + " m = lab == k\n", + " yy, xx = np.nonzero(m)\n", + " if len(yy) < 5:\n", + " return 0.0, m\n", + " dy, dx = yy - yy.mean(), xx - xx.mean() # 2차 모멘트로 주축 각도 계산\n", + " return float(np.degrees(0.5 * np.arctan2(2 * (dx * dy).mean(), (dx * dx).mean() - (dy * dy).mean()))), m\n", + "\n", + "\n", + "_YY, _XX = np.mgrid[0:N, 0:N] - (N - 1) / 2\n", + "\n", + "\n", + "def warp(stack, zy, zx, ang):\n", + " \"\"\"여러 장(B,채널,32,32)에 장별 확대·축소(zy, zx)와 회전(ang, 도)을 한 번의 쌍선형 보간으로 적용한다(벡터 연산).\"\"\"\n", + " a = np.radians(ang)[:, None, None]\n", + " zy, zx = np.asarray(zy)[:, None, None], np.asarray(zx)[:, None, None]\n", + " sy = np.clip((np.cos(a) * _YY + np.sin(a) * _XX) / zy + (N - 1) / 2, 0, N - 1.001) # 출력 좌표 -> 원본 좌표\n", + " sx = np.clip((-np.sin(a) * _YY + np.cos(a) * _XX) / zx + (N - 1) / 2, 0, N - 1.001)\n", + " y0, x0 = sy.astype(int), sx.astype(int)\n", + " fy, fx = (sy - y0).reshape(len(stack), 1, -1), (sx - x0).reshape(len(stack), 1, -1)\n", + " flat = stack.reshape(len(stack), stack.shape[1], -1)\n", + " lin = (y0 * N + x0).reshape(len(stack), 1, -1)\n", + " g = lambda d: np.take_along_axis(flat, np.broadcast_to(lin + d, (len(stack), stack.shape[1], lin.shape[2])), axis=2)\n", + " out = g(0) * (1 - fy) * (1 - fx) + g(N) * fy * (1 - fx) + g(1) * (1 - fy) * fx + g(N + 1) * fy * fx\n", + " return out.reshape(stack.shape)\n", + "\n", + "\n", + "def preprocess_batch(O, S, M):\n", + " \"\"\"여러 장을 한 번에 전처리한다. 0) 결측 보정 1) 해상도·비스듬 촬영 보정(표준 0.5m/픽셀)\n", + " 2) 구름 차폐 영역을 배경값으로 채움 3) 객체 주축을 가로로 회전 4) 0~1 정규화.\"\"\"\n", + " src = np.stack([np.asarray(O, float), np.asarray(S, float)], axis=1)\n", + " if np.isnan(src).any(): # 0) 센서 결측 픽셀 보정\n", + " mean = np.nanmean(src, axis=(2, 3), keepdims=True)\n", + " src = np.where(np.isnan(src), mean, src)\n", + " zy = np.array([m[\"gsd\"] / STD_GSD / np.cos(np.radians(m[\"off_nadir\"])) for m in M])\n", + " zx = np.array([m[\"gsd\"] / STD_GSD for m in M])\n", + " out = warp(src, zy, zx, np.zeros(len(M)))\n", + " sm = ndimage.uniform_filter(out[:, 1], (1, 3, 3))\n", + " angs, cloud = np.zeros(len(M)), np.zeros(len(M))\n", + " for i in range(len(M)):\n", + " cm = cloud_mask(out[i, 0], sm[i])\n", + " if cm.any():\n", + " fill = out[i, 0][~cm].mean() if (~cm).any() else 115.0\n", + " out[i, 0][cm] = fill\n", + " back = warp(cm[None, None].astype(float), [1 / zy[i]], [1 / zx[i]], np.zeros(1))[0, 0] > 0.5\n", + " src[i, 0][back] = fill # 원본의 같은 구름 영역도 채움\n", + " cloud[i] = cm.mean()\n", + " angs[i] = orientation(out[i, 0], sm[i])[0]\n", + " rot = np.abs(angs) > 3 # 3도 이하는 회전 생략\n", + " if rot.any():\n", + " out[rot] = warp(src[rot], zy[rot], zx[rot], angs[rot])\n", + " return [(np.clip(o / 255, 0, 1), np.clip(s / 1.5, 0, 1), float(c)) for (o, s), c in zip(out, cloud)]\n", + "\n", + "\n", + "def preprocess(opt, sar, meta):\n", + " \"\"\"한 장 전처리(preprocess_batch와 같은 처리).\"\"\"\n", + " return preprocess_batch([opt], [sar], [meta])[0]\n", + "\n", + "# ---- 특징 추출 공통 함수 ----\n", + "KERNELS = [np.array(k, float) for k in (\n", + " [[-1, 0, 1], [-2, 0, 2], [-1, 0, 1]], [[-1, -2, -1], [0, 0, 0], [1, 2, 1]], [[0, 1, 0], [1, -4, 1], [0, 1, 0]],\n", + " [[2, -1, -1], [-1, 2, -1], [-1, -1, 2]], [[-1, -1, 2], [-1, 2, -1], [2, -1, -1]], [[1, 1, 1], [1, 1, 1], [1, 1, 1]])]\n", + "\n", + "\n", + "def conv_features(o):\n", + " \"\"\"간이 CNN 특징: 고정 합성곱 필터 6종 -> ReLU -> 4x4 최대 풀링(8x8 출력).\"\"\"\n", + " feats = []\n", + " for k in KERNELS:\n", + " r = np.maximum(ndimage.correlate(o - o.mean(), k / np.abs(k).sum()), 0)\n", + " feats.append(r.reshape(8, 4, 8, 4).max(axis=(1, 3)).ravel())\n", + " feats.append(np.maximum(-ndimage.correlate(o - o.mean(), k / np.abs(k).sum()), 0).reshape(8, 4, 8, 4).max(axis=(1, 3)).ravel())\n", + " return np.concatenate(feats)\n", + "\n", + "\n", + "def hog(o, cell=8, bins=8):\n", + " gy, gx = np.gradient(o)\n", + " mag, ang = np.hypot(gx, gy), np.mod(np.arctan2(gy, gx), np.pi)\n", + " b = np.minimum((ang / np.pi * bins).astype(int), bins - 1)\n", + " out = np.zeros((N // cell, N // cell, bins))\n", + " for i in range(N // cell):\n", + " for j in range(N // cell):\n", + " sl = (slice(i * cell, (i + 1) * cell), slice(j * cell, (j + 1) * cell))\n", + " out[i, j] = np.bincount(b[sl].ravel(), mag[sl].ravel(), bins)\n", + " return (out / (np.linalg.norm(out) + 1e-6)).ravel()\n", + "\n", + "\n", + "def shape_features(o, s):\n", + " \"\"\"객체 모양 특징: 가로 방향 투영으로 전체 길이·차체 길이·가는 돌출부(포신) 길이·두꺼운 부분(포탑) 위치를 잰다.\n", + " 방위 정규화 뒤에도 앞뒤(180도)가 뒤집힐 수 있으므로, 가는 돌출부가 오른쪽에 오도록 맞춘다.\"\"\"\n", + " sm = ndimage.uniform_filter(s, 3)\n", + " dark = np.abs(o - np.median(o))\n", + " m = (sm > 0.22) | (dark > 0.12)\n", + " lab, n = ndimage.label(m)\n", + " if n:\n", + " c = lab[10:22, 8:24]\n", + " k = np.bincount(c[c > 0]).argmax() if (c > 0).any() else 1\n", + " m = lab == k\n", + " p = m.sum(axis=0).astype(float)\n", + " cols = np.nonzero(p)[0]\n", + " if len(cols) == 0:\n", + " return np.zeros(8), o, s\n", + " thin = (p > 0) & (p <= 2)\n", + " left, right = thin[:N // 2].sum(), thin[N // 2:].sum()\n", + " if left > right:\n", + " o, s, m, p = o[:, ::-1], s[:, ::-1], m[:, ::-1], p[::-1]\n", + " cols = np.nonzero(p)[0]\n", + " body = np.nonzero(p > 3)[0]\n", + " d = np.where(m, dark if left <= right else dark[:, ::-1], 0)\n", + " heavy = d.sum(axis=0)\n", + " centre = (body[0] + body[-1]) / 2 if len(body) else N / 2\n", + " turret = (heavy * np.arange(N)).sum() / max(heavy.sum(), 1e-6) - centre\n", + " return np.array([cols[-1] - cols[0] + 1, len(body), ((p > 0) & (p <= 2)).sum(), p.max(), turret,\n", + " m.sum(), (cols[-1] - (body[-1] if len(body) else cols[-1])), m.sum() / max(len(cols) * p.max(), 1)]), o, s\n", + "\n", + "\n", + "def adv_features(o, s):\n", + " \"\"\"정밀 특징: 모양(길이·포신·포탑 위치) + HOG(윤곽) + SAR 반사 통계(위장막·구름 영향 적음) + 투영 윤곽.\"\"\"\n", + " shp, o, s = shape_features(o, s)\n", + " c = s[8:24, 4:28]\n", + " sm = ndimage.uniform_filter(s, 3)\n", + " m = sm > 0.2\n", + " dark = np.abs(o - np.median(o))\n", + " return np.concatenate([shp, hog(o), [c.mean(), c.max(), (sm > 0.25).mean(), (sm > 0.4).mean(), m.sum(axis=1).max(), m.sum(axis=0).max()],\n", + " sm.mean(axis=0).reshape(8, 4).mean(1), sm.mean(axis=1).reshape(8, 4).mean(1),\n", + " dark.mean(axis=0).reshape(8, 4).mean(1), dark.mean(axis=1).reshape(8, 4).mean(1),\n", + " [o.mean(), o.std(), dark.max(), (o > 0.65).mean(), (o < 0.3).mean()]])\n", + "\n", + "Q_NAMES = [\"max_prob\", \"margin\", \"entropy\", \"cloud_frac\", \"gsd\", \"off_nadir\", \"sun_el\", \"sar_mean\"]\n", + "\n", + "\n", + "def run(O, S, M, clf):\n", + " P = preprocess_batch(O, S, M)\n", + " X = np.array([adv_features(o, s) for o, s, _ in P])\n", + " pr = clf.predict_proba(X)\n", + " srt = np.sort(pr, axis=1)\n", + " ent = -(pr * np.log(np.clip(pr, 1e-9, 1))).sum(1) / np.log(pr.shape[1])\n", + " Q = np.c_[srt[:, -1], srt[:, -1] - srt[:, -2], ent, [p[2] for p in P], [m[\"gsd\"] for m in M],\n", + " [m[\"off_nadir\"] for m in M], [m[\"sun_el\"] for m in M], [p[1][8:24, 4:28].mean() for p in P]]\n", + " return clf.classes_[pr.argmax(1)], Q\n", + "\n", + "\n", + "def grade(c):\n", + " return np.where(c >= 0.9, \"High\", np.where(c >= 0.6, \"Medium\", \"Low\"))\n", + "\n", + "\n", + "ALL = tuple(SCEN)\n", + "Otr, Str, Mtr = make_dataset(1200, seed=3, scen=ALL)\n", + "clf = RandomForestClassifier(n_estimators=200, min_samples_leaf=2, random_state=0, n_jobs=1)\n", + "clf.fit(np.array([adv_features(o, s) for o, s, _ in preprocess_batch(Otr, Str, Mtr)]), [m[\"cls\"] for m in Mtr])\n", + "\n", + "# 1) 신뢰도 가중치 학습: 별도 보정 자료 600장에서 '분류가 맞았는가'를 품질 지표로 예측\n", + "Oc, Sc, Mc = make_dataset(600, seed=6, scen=ALL)\n", + "pc, Qc = run(Oc, Sc, Mc, clf)\n", + "conf_model = make_pipeline(StandardScaler(), LogisticRegression(max_iter=2000)).fit(Qc, pc == np.array([m[\"cls\"] for m in Mc]))\n", + "w = pd.Series(conf_model[-1].coef_[0], index=Q_NAMES).round(2)\n", + "print(\"[신뢰도 가중치(표준화 계수)]\", w.to_dict())\n", + "\n", + "# 2) 시험 800장: 기존(분류 결과 그대로 보고) vs 엔진(High만 자동 보고, 나머지는 판독 보류)\n", + "Ot, St, Mt = make_dataset(800, seed=5, scen=ALL)\n", + "pt, Qt = run(Ot, St, Mt, clf)\n", + "y = np.array([m[\"cls\"] for m in Mt])\n", + "conf = conf_model.predict_proba(Qt)[:, 1]\n", + "G = grade(conf)\n", + "wrong = pt != y\n", + "rep0, rep1 = np.isin(pt, list(MILITARY)), np.isin(pt, list(MILITARY)) & (G == \"High\")\n", + "far0, far1 = (wrong & rep0).sum() / rep0.sum(), (wrong & rep1).sum() / rep1.sum()\n", + "print(f\"\\n기존: 군용 보고 {rep0.sum()}건 중 오분류 {(wrong & rep0).sum()}건 (오분류율 {far0:.1%})\")\n", + "print(f\"엔진: 군용 자동 보고(High) {rep1.sum()}건 중 오분류 {(wrong & rep1).sum()}건 (오분류율 {far1:.1%}), 판독 보류(Medium·Low) {(rep0 & ~rep1).sum()}건\")\n", + "cut = 1 - far1 / far0\n", + "print(f\"오분류(오경보) 감축 {cut:.1%} (목표 40% 이상: {'달성' if cut >= 0.4 else '미달'}), ROC-AUC(신뢰도 vs 정답 여부) {roc_auc_score(~wrong, conf):.3f}\")\n", + "\n", + "# 3) 등급별 실제 정답률과 임계값 민감도\n", + "sc = np.array([m[\"scen\"] for m in Mt])\n", + "print(\"\\n등급 건수 실제 정답률 주요 조건\")\n", + "for g in [\"High\", \"Medium\", \"Low\"]:\n", + " s = G == g\n", + " print(f\"{g:6s} {s.sum():4d} {(~wrong[s]).mean() if s.any() else 0:9.1%} {pd.Series(sc[s]).value_counts().head(2).to_dict()}\")\n", + "print(\"\\nHigh 기준 자동보고 오분류율 판독 보류\")\n", + "for t in [0.8, 0.85, 0.9, 0.95]:\n", + " r = rep0 & (conf >= t)\n", + " print(f\" {t:.2f} {r.sum():6d} {(wrong & r).sum() / max(r.sum(), 1):6.1%} {(rep0 & ~r).sum():6d}\")\n", + "\n", + "# 4) 일관성: 같은 영상에 미세한 픽셀 잡음(+-1%)을 넣어도 같은 등급이 나오는 비율\n", + "rng = np.random.default_rng(0)\n", + "sub = np.arange(200)\n", + "_, Qn = run(Ot[sub] * (1 + rng.normal(0, 0.01, Ot[sub].shape)), St[sub], [Mt[i] for i in sub], clf)\n", + "same = (grade(conf_model.predict_proba(Qn)[:, 1]) == G[sub]).mean()\n", + "print(f\"\\n신뢰도 등급 산출 일관성 {same:.1%} (목표 90% 이상: {'달성' if same >= 0.9 else '미달'})\")\n", + "print(\"High도 최종 판독이 아니며, 고가치 표적은 미션 7의 영상분석관 판독을 거친다.\")\n", + "\n", + "fig, ax = plt.subplots(1, 2, figsize=(12, 4))\n", + "ax[0].hist([conf[~wrong], conf[wrong]], bins=20, range=(0, 1), label=[\"correct\", \"misclassified\"], stacked=True)\n", + "ax[0].axvline(0.9, ls=\"--\", c=\"k\"); ax[0].set_xlabel(\"confidence score\"); ax[0].legend(); ax[0].set_title(\"Confidence vs correctness\")\n", + "w.plot.barh(ax=ax[1], color=[\"tab:green\" if v > 0 else \"tab:red\" for v in w]); ax[1].set_title(\"Confidence weight matrix (std. coef)\")\n", + "plt.tight_layout(); plt.show()\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 05 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + "[신뢰도 가중치(표준화 계수)] {'max_prob': 0.69, 'margin': 0.81, 'entropy': -0.62, 'cloud_frac': 0.2, 'gsd': -0.06, 'off_nadir': -0.54, 'sun_el': -0.52, 'sar_mean': 0.24}\n", + "\n", + "기존: 군용 보고 600건 중 오분류 19건 (오분류율 3.2%)\n", + "엔진: 군용 자동 보고(High) 541건 중 오분류 3건 (오분류율 0.6%), 판독 보류(Medium·Low) 59건\n", + "오분류(오경보) 감축 82.5% (목표 40% 이상: 달성), ROC-AUC(신뢰도 vs 정답 여부) 0.961\n", + "\n", + "등급 건수 실제 정답률 주요 조건\n", + "High 741 99.6% {'occl50': 79, 'clean': 78}\n", + "Medium 38 84.2% {'low_res': 13, 'occl30': 5}\n", + "Low 21 52.4% {'low_res': 9, 'speckle': 4}\n", + "\n", + "High 기준 자동보고 오분류율 판독 보류\n", + " 0.80 555 1.1% 45\n", + " 0.85 552 0.9% 48\n", + " 0.90 541 0.6% 59\n", + " 0.95 521 0.6% 79\n", + "\n", + "신뢰도 등급 산출 일관성 98.5% (목표 90% 이상: 달성)\n", + "High도 최종 판독이 아니며, 고가치 표적은 미션 7의 영상분석관 판독을 거친다.\n", + "```\n", + "
\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 06. 위성영상 표적 탐지 위치 및 식별 확률 지도(Heatmap) 가시화 UI 구현\n", + "**소요시간**: 약 2.5시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "식별된 장비의 위치, 기종, 신뢰도 및 집결지 밀집도를 지도상에 오버레이 표시하는 위성 ISR 영상분석 인터페이스를 구현한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **식별된 장비의 위치·기종·식별 확률과 집결지 밀집도를 지도에 겹쳐 보여 주는 위성 ISR 영상분석 화면을 구현한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **가상 광역 영상(타일 10x10)을 한 줄씩 받아 판독하는 갱신 흐름을 모의하고 처리시간을 잰다 과정을 진행하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `src/ui/isr01_dashboard.py (또는 웹 UI 컴포넌트)` 형태의 결과물을 완성한다.\n", + "\n", + "### 🔍 평가 및 검증 기준 (PASS 조건)\n", + "#### 미션 판정\n", + "\n", + "**평가 구분:** 필수\n", + "\n", + "**핵심 평가영역:** 전술 작전 상황판으로서의 직관성 및 인지 부하 감소도\n", + "\n", + "#### 미션 PASS 조건\n", + "\n", + "- 정량 기준: 화면 갱신 주기 1초 이내, UI 응답성 100ms 이내\n", + "\n", + "\n", + "- 진행 기준: 주요 지표 시각화 컴포넌트 4종 이상 렌더링 및 실시간 데이터 연동\n", + "\n", + "\n", + "- 검증 방법: 사용자 인터랙션 테스트 및 모의 데이터 스트림 렌더링 검증\n", + "\n", + "\n", + "#### 채점자 판정 메모\n", + "\n", + "> 단순 코드 실행 여부뿐만 아니라 출력된 수치와 근거 자료가 군 실무 요구사항에 부합하는지 면밀히 확인한다.\n", + "\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "\"\"\"가상 광역 위성영상(32x32 타일 10x10)을 타일 단위로 판독해 식별 장비의 위치·기종·식별 확률을 지도에 겹쳐 표시하고, 군용 장비 집결지 밀집도(Heatmap)와 기종별 현황을 함께 보여 주는 위성 ISR 영상분석 화면을 구현하며 갱신 처리시간과 렌더링 시간을 측정한다.\"\"\"\n", + "import time\n", + "import numpy as np\n", + "import pandas as pd\n", + "import matplotlib.pyplot as plt\n", + "from scipy import ndimage\n", + "from sklearn.ensemble import RandomForestClassifier\n", + "\n", + "# ---------------------------------------------------------------------------\n", + "# [교육용가상자료] 합성 위성영상 조각(chip) 생성기 (실제 위성영상·장비 제원·위치 없음)\n", + "# 32x32 광학(EO) 영상과 SAR 영상 한 쌍에 추상 도형으로 만든 객체를 그린다. 도형 치수는 가상값이다.\n", + "# ---------------------------------------------------------------------------\n", + "N = 32\n", + "STD_GSD = 0.5 # 표준 해상도(m/픽셀, 가상 기준)\n", + "CLASSES = [\"tank\", \"spg\", \"truck\", \"civil\"]\n", + "MILITARY = {\"tank\", \"spg\", \"truck\"}\n", + "# 객체 좌표계(u: 길이 방향, v: 폭 방향, 표준 해상도 픽셀) 가상 도형: (u중심, v중심, 길이, 폭, 광학 밝기 차이)\n", + "SHAPES = {\n", + " \"tank\": [(0, 0, 11, 7, -40), (0.5, 0, 5, 5, -55), (6, 0, 7, 1.2, -60)],\n", + " \"spg\": [(0, 0, 16, 7.5, -38), (-4, 0, 6, 6.5, -52), (5, 0, 13, 1.4, -60)],\n", + " \"truck\": [(5, 0, 3.5, 5.5, -45), (-1.8, 0, 8.5, 6, -25)],\n", + " \"civil\": [(0, 0, 8, 3.8, 0), (1.2, 0, 2, 3.4, 0)],\n", + " \"decoy\": [(0, 0, 11, 7, -40), (0.5, 0, 5, 5, -42), (6, 0, 7, 1.2, -45)],\n", + " \"none\": [], # 객체 없는 배경 타일\n", + "}\n", + "SAR_RETURN = {\"tank\": 0.85, \"spg\": 0.85, \"truck\": 0.7, \"civil\": 0.45, \"decoy\": 0.18, \"none\": 0.12} # 금속 반사 세기(가상)\n", + "\n", + "\n", + "def _mask(cls, angle, scale, squash=1.0):\n", + " \"\"\"회전(angle, 도)·축척(scale)·비스듬 촬영 압축(squash)을 적용해 도형별 마스크와 밝기 차이를 만든다.\"\"\"\n", + " # [TODO : 학생 실습 6] 위성영상 표적 탐지 위치 및 식별 확률 지도(Heatmap) 가시화 UI 구현 관련 핵심 로직을 구현하세요.\n", + " # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n", + " # <<< 여기에 코드를 작성하세요 >>>\n", + " pass # 작성 후 pass를 제거하세요.\n", + "def make_chip(cls, rng, cond=None):\n", + " \"\"\"chip 1개 = (광학 32x32, SAR 32x32, 메타데이터). cond: camo, occl, cloud, low_sun, off_nadir, low_res, speckle\"\"\"\n", + " c = {\"camo\": 0.0, \"occl\": 0.0, \"cloud\": 0.0, \"low_sun\": False, \"off_nadir\": 0.0, \"low_res\": False, \"speckle\": 1.0, **(cond or {})}\n", + " gsd = rng.uniform(0.9, 1.2) if c[\"low_res\"] else rng.uniform(0.4, 0.6)\n", + " scale = STD_GSD / gsd\n", + " angle, off = rng.uniform(0, 360), c[\"off_nadir\"] or rng.uniform(0, 15)\n", + " squash = np.cos(np.radians(off))\n", + " sun_el = rng.uniform(12, 22) if c[\"low_sun\"] else rng.uniform(40, 70)\n", + " sun_az = rng.uniform(0, 360)\n", + " bg = 115 + ndimage.gaussian_filter(rng.normal(0, 30, (N, N)), 2) + rng.normal(0, 5, (N, N))\n", + " parts = _mask(cls, angle, scale, squash)\n", + " body = np.any([m for m, _ in parts], axis=0) if parts else np.zeros((N, N), bool)\n", + " opt = bg.copy()\n", + " # 그림자: 태양 반대 방향으로 밀린 객체 모양(태양 고도가 낮을수록 길다)\n", + " L = min(8.0, 2.5 / np.tan(np.radians(sun_el))) * scale\n", + " sh = ndimage.shift(body.astype(float), (L * np.cos(np.radians(sun_az)), -L * np.sin(np.radians(sun_az))), order=0) > 0.5\n", + " opt[sh & ~body] -= 35 if cls != \"decoy\" else 25\n", + " if cls == \"civil\":\n", + " base = rng.choice([200.0, 60.0]) # 민간 차량 색상: 밝거나 어두움\n", + " opt[body] = base + rng.normal(0, 8, body.sum())\n", + " roof = parts[1][0]\n", + " opt[roof] = base * 0.8\n", + " else:\n", + " ref = bg[body].mean() if body.any() else bg.mean()\n", + " for m, d in parts:\n", + " opt[m] = ref + d + rng.normal(0, 4 if cls != \"decoy\" else 2, m.sum())\n", + " # 위장막: 객체 주변을 배경 질감으로 덮어 대비를 낮춘다\n", + " if c[\"camo\"] > 0 and cls != \"civil\":\n", + " net = ndimage.binary_dilation(body, iterations=2)\n", + " tex = 105 + ndimage.gaussian_filter(rng.normal(0, 25, (N, N)), 1)\n", + " opt[net] = (1 - c[\"camo\"]) * opt[net] + c[\"camo\"] * tex[net]\n", + " # 수목 차폐: 객체의 일부를 어두운 나무 덩어리로 가린다\n", + " if c[\"occl\"] > 0:\n", + " trees = ndimage.gaussian_filter(rng.random((N, N)), 2)\n", + " near = ndimage.binary_dilation(body, iterations=3)\n", + " cover = near & (trees > np.quantile(trees[body], 1 - c[\"occl\"])) if body.any() else np.zeros((N, N), bool)\n", + " opt[cover] = 70 + rng.normal(0, 6, cover.sum())\n", + " # 구름: 밝고 매끈한 덩어리(광학만 가림, SAR는 통과)\n", + " cmask = np.zeros((N, N), bool)\n", + " if c[\"cloud\"] > 0:\n", + " blob = ndimage.gaussian_filter(rng.random((N, N)), 4)\n", + " cmask = blob > np.quantile(blob, 1 - c[\"cloud\"])\n", + " opt[cmask] = 0.25 * opt[cmask] + 0.75 * (235 + rng.normal(0, 3, cmask.sum()))\n", + " # SAR: 곱셈형 스페클 잡음 + 금속 반사(모서리 이중 반사로 밝은 점)\n", + " sar = np.full((N, N), 0.12)\n", + " sar[body] = SAR_RETURN[cls] * rng.uniform(0.8, 1.2)\n", + " edge = body & ~ndimage.binary_erosion(body)\n", + " sar[edge] *= 1.3 if cls in MILITARY else 1.0\n", + " sar = sar * rng.gamma(4 / c[\"speckle\"], c[\"speckle\"] / 4, (N, N))\n", + " if c[\"low_res\"]: # 저해상도: 정보 손실(블러)\n", + " down = lambda a: np.kron(a.reshape(N // 2, 2, N // 2, 2).mean((1, 3)), np.ones((2, 2)))\n", + " opt, sar = ndimage.gaussian_filter(down(opt), 0.7), ndimage.gaussian_filter(down(sar), 0.7)\n", + " meta = {\"cls\": cls, \"gsd\": round(float(gsd), 2), \"off_nadir\": round(float(off), 1), \"sun_el\": round(float(sun_el), 1),\n", + " \"sun_az\": round(float(sun_az), 1), \"angle\": round(float(angle), 1), \"cloud_frac\": float(cmask.mean()),\n", + " \"cloud_on_obj\": float((cmask & body).sum() / max(body.sum(), 1)), **{k: c[k] for k in (\"camo\", \"occl\", \"low_sun\", \"low_res\")}}\n", + " return np.clip(opt, 0, 255), sar, meta\n", + "\n", + "\n", + "SCEN = { # 은폐·촬영 조건 시나리오 10종\n", + " \"clean\": {}, \"camo\": {\"camo\": 0.7}, \"occl30\": {\"occl\": 0.3}, \"occl50\": {\"occl\": 0.5}, \"cloud\": {\"cloud\": 0.25},\n", + " \"low_sun\": {\"low_sun\": True}, \"off_nadir\": {\"off_nadir\": 35.0}, \"low_res\": {\"low_res\": True},\n", + " \"speckle\": {\"speckle\": 3.0}, \"camo_occl\": {\"camo\": 0.6, \"occl\": 0.3}}\n", + "\n", + "\n", + "def make_dataset(n=400, seed=7, scen=(\"clean\",), classes=CLASSES):\n", + " rng = np.random.default_rng(seed)\n", + " O, S, M = np.zeros((n, N, N)), np.zeros((n, N, N)), []\n", + " for i in range(n):\n", + " cls, sc = classes[i % len(classes)], scen[(i // len(classes)) % len(scen)]\n", + " O[i], S[i], m = make_chip(cls, rng, SCEN[sc])\n", + " m.update({\"id\": f\"IMG{i:04d}\", \"scen\": sc})\n", + " M.append(m)\n", + " return O, S, M\n", + "# ---------------------------------------------------------------------------\n", + "\n", + "# ---- 전처리(해상도·기하 보정, 구름 차폐 처리, 방위 정규화) 공통 함수 ----\n", + "def _fit(a, fill):\n", + " \"\"\"중앙 기준으로 32x32에 맞게 자르거나 채운다.\"\"\"\n", + " # [TODO : 학생 실습 6] 위성영상 표적 탐지 위치 및 식별 확률 지도(Heatmap) 가시화 UI 구현 관련 핵심 로직을 구현하세요.\n", + " # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n", + " # <<< 여기에 코드를 작성하세요 >>>\n", + " pass # 작성 후 pass를 제거하세요.\n", + "def cloud_mask(opt, sm, min_area=15):\n", + " \"\"\"광학에서 밝고(185 초과) 매끈한데 SAR에는 반사가 없는 영역을 구름으로 본다(SAR는 구름을 통과하므로 밝은 차량과 구분된다).\"\"\"\n", + " bright = opt > 185\n", + " if bright.sum() < min_area:\n", + " return np.zeros_like(bright)\n", + " local_sd = np.sqrt(np.maximum(ndimage.uniform_filter(opt ** 2, 3) - ndimage.uniform_filter(opt, 3) ** 2, 0))\n", + " cand = bright & (local_sd < 12) & (sm < 0.3)\n", + " lab, n = ndimage.label(cand)\n", + " if n == 0:\n", + " return cand\n", + " sizes = ndimage.sum(cand.astype(float), lab, range(1, n + 1))\n", + " return ndimage.binary_dilation(np.isin(lab, 1 + np.where(sizes >= min_area)[0]), iterations=2) & (opt > 140)\n", + "\n", + "\n", + "def orientation(opt, sm):\n", + " \"\"\"SAR 반사(위장·구름 영향 적음, sm은 평활화한 SAR)와 광학 대비를 합친 객체 마스크의 주축 방향(도).\"\"\"\n", + " m = (sm > 0.3) | (np.abs(opt - opt.mean()) > 30)\n", + " lab, n = ndimage.label(m)\n", + " if n == 0:\n", + " return 0.0, m\n", + " c = lab[N // 2 - 6:N // 2 + 6, N // 2 - 6:N // 2 + 6]\n", + " k = np.bincount(c[c > 0]).argmax() if (c > 0).any() else 1 + int(np.argmax(ndimage.sum(m.astype(float), lab, range(1, n + 1))))\n", + " m = lab == k\n", + " yy, xx = np.nonzero(m)\n", + " if len(yy) < 5:\n", + " return 0.0, m\n", + " dy, dx = yy - yy.mean(), xx - xx.mean() # 2차 모멘트로 주축 각도 계산\n", + " return float(np.degrees(0.5 * np.arctan2(2 * (dx * dy).mean(), (dx * dx).mean() - (dy * dy).mean()))), m\n", + "\n", + "\n", + "_YY, _XX = np.mgrid[0:N, 0:N] - (N - 1) / 2\n", + "\n", + "\n", + "def warp(stack, zy, zx, ang):\n", + " \"\"\"여러 장(B,채널,32,32)에 장별 확대·축소(zy, zx)와 회전(ang, 도)을 한 번의 쌍선형 보간으로 적용한다(벡터 연산).\"\"\"\n", + " a = np.radians(ang)[:, None, None]\n", + " zy, zx = np.asarray(zy)[:, None, None], np.asarray(zx)[:, None, None]\n", + " sy = np.clip((np.cos(a) * _YY + np.sin(a) * _XX) / zy + (N - 1) / 2, 0, N - 1.001) # 출력 좌표 -> 원본 좌표\n", + " sx = np.clip((-np.sin(a) * _YY + np.cos(a) * _XX) / zx + (N - 1) / 2, 0, N - 1.001)\n", + " y0, x0 = sy.astype(int), sx.astype(int)\n", + " fy, fx = (sy - y0).reshape(len(stack), 1, -1), (sx - x0).reshape(len(stack), 1, -1)\n", + " flat = stack.reshape(len(stack), stack.shape[1], -1)\n", + " lin = (y0 * N + x0).reshape(len(stack), 1, -1)\n", + " g = lambda d: np.take_along_axis(flat, np.broadcast_to(lin + d, (len(stack), stack.shape[1], lin.shape[2])), axis=2)\n", + " out = g(0) * (1 - fy) * (1 - fx) + g(N) * fy * (1 - fx) + g(1) * (1 - fy) * fx + g(N + 1) * fy * fx\n", + " return out.reshape(stack.shape)\n", + "\n", + "\n", + "def preprocess_batch(O, S, M):\n", + " \"\"\"여러 장을 한 번에 전처리한다. 0) 결측 보정 1) 해상도·비스듬 촬영 보정(표준 0.5m/픽셀)\n", + " 2) 구름 차폐 영역을 배경값으로 채움 3) 객체 주축을 가로로 회전 4) 0~1 정규화.\"\"\"\n", + " src = np.stack([np.asarray(O, float), np.asarray(S, float)], axis=1)\n", + " if np.isnan(src).any(): # 0) 센서 결측 픽셀 보정\n", + " mean = np.nanmean(src, axis=(2, 3), keepdims=True)\n", + " src = np.where(np.isnan(src), mean, src)\n", + " zy = np.array([m[\"gsd\"] / STD_GSD / np.cos(np.radians(m[\"off_nadir\"])) for m in M])\n", + " zx = np.array([m[\"gsd\"] / STD_GSD for m in M])\n", + " out = warp(src, zy, zx, np.zeros(len(M)))\n", + " sm = ndimage.uniform_filter(out[:, 1], (1, 3, 3))\n", + " angs, cloud = np.zeros(len(M)), np.zeros(len(M))\n", + " for i in range(len(M)):\n", + " cm = cloud_mask(out[i, 0], sm[i])\n", + " if cm.any():\n", + " fill = out[i, 0][~cm].mean() if (~cm).any() else 115.0\n", + " out[i, 0][cm] = fill\n", + " back = warp(cm[None, None].astype(float), [1 / zy[i]], [1 / zx[i]], np.zeros(1))[0, 0] > 0.5\n", + " src[i, 0][back] = fill # 원본의 같은 구름 영역도 채움\n", + " cloud[i] = cm.mean()\n", + " angs[i] = orientation(out[i, 0], sm[i])[0]\n", + " rot = np.abs(angs) > 3 # 3도 이하는 회전 생략\n", + " if rot.any():\n", + " out[rot] = warp(src[rot], zy[rot], zx[rot], angs[rot])\n", + " return [(np.clip(o / 255, 0, 1), np.clip(s / 1.5, 0, 1), float(c)) for (o, s), c in zip(out, cloud)]\n", + "\n", + "\n", + "def preprocess(opt, sar, meta):\n", + " \"\"\"한 장 전처리(preprocess_batch와 같은 처리).\"\"\"\n", + " return preprocess_batch([opt], [sar], [meta])[0]\n", + "\n", + "# ---- 특징 추출 공통 함수 ----\n", + "KERNELS = [np.array(k, float) for k in (\n", + " [[-1, 0, 1], [-2, 0, 2], [-1, 0, 1]], [[-1, -2, -1], [0, 0, 0], [1, 2, 1]], [[0, 1, 0], [1, -4, 1], [0, 1, 0]],\n", + " [[2, -1, -1], [-1, 2, -1], [-1, -1, 2]], [[-1, -1, 2], [-1, 2, -1], [2, -1, -1]], [[1, 1, 1], [1, 1, 1], [1, 1, 1]])]\n", + "\n", + "\n", + "def conv_features(o):\n", + " \"\"\"간이 CNN 특징: 고정 합성곱 필터 6종 -> ReLU -> 4x4 최대 풀링(8x8 출력).\"\"\"\n", + " feats = []\n", + " for k in KERNELS:\n", + " r = np.maximum(ndimage.correlate(o - o.mean(), k / np.abs(k).sum()), 0)\n", + " feats.append(r.reshape(8, 4, 8, 4).max(axis=(1, 3)).ravel())\n", + " feats.append(np.maximum(-ndimage.correlate(o - o.mean(), k / np.abs(k).sum()), 0).reshape(8, 4, 8, 4).max(axis=(1, 3)).ravel())\n", + " return np.concatenate(feats)\n", + "\n", + "\n", + "def hog(o, cell=8, bins=8):\n", + " gy, gx = np.gradient(o)\n", + " mag, ang = np.hypot(gx, gy), np.mod(np.arctan2(gy, gx), np.pi)\n", + " b = np.minimum((ang / np.pi * bins).astype(int), bins - 1)\n", + " out = np.zeros((N // cell, N // cell, bins))\n", + " for i in range(N // cell):\n", + " for j in range(N // cell):\n", + " sl = (slice(i * cell, (i + 1) * cell), slice(j * cell, (j + 1) * cell))\n", + " out[i, j] = np.bincount(b[sl].ravel(), mag[sl].ravel(), bins)\n", + " return (out / (np.linalg.norm(out) + 1e-6)).ravel()\n", + "\n", + "\n", + "def shape_features(o, s):\n", + " \"\"\"객체 모양 특징: 가로 방향 투영으로 전체 길이·차체 길이·가는 돌출부(포신) 길이·두꺼운 부분(포탑) 위치를 잰다.\n", + " 방위 정규화 뒤에도 앞뒤(180도)가 뒤집힐 수 있으므로, 가는 돌출부가 오른쪽에 오도록 맞춘다.\"\"\"\n", + " sm = ndimage.uniform_filter(s, 3)\n", + " dark = np.abs(o - np.median(o))\n", + " m = (sm > 0.22) | (dark > 0.12)\n", + " lab, n = ndimage.label(m)\n", + " if n:\n", + " c = lab[10:22, 8:24]\n", + " k = np.bincount(c[c > 0]).argmax() if (c > 0).any() else 1\n", + " m = lab == k\n", + " p = m.sum(axis=0).astype(float)\n", + " cols = np.nonzero(p)[0]\n", + " if len(cols) == 0:\n", + " return np.zeros(8), o, s\n", + " thin = (p > 0) & (p <= 2)\n", + " left, right = thin[:N // 2].sum(), thin[N // 2:].sum()\n", + " if left > right:\n", + " o, s, m, p = o[:, ::-1], s[:, ::-1], m[:, ::-1], p[::-1]\n", + " cols = np.nonzero(p)[0]\n", + " body = np.nonzero(p > 3)[0]\n", + " d = np.where(m, dark if left <= right else dark[:, ::-1], 0)\n", + " heavy = d.sum(axis=0)\n", + " centre = (body[0] + body[-1]) / 2 if len(body) else N / 2\n", + " turret = (heavy * np.arange(N)).sum() / max(heavy.sum(), 1e-6) - centre\n", + " return np.array([cols[-1] - cols[0] + 1, len(body), ((p > 0) & (p <= 2)).sum(), p.max(), turret,\n", + " m.sum(), (cols[-1] - (body[-1] if len(body) else cols[-1])), m.sum() / max(len(cols) * p.max(), 1)]), o, s\n", + "\n", + "\n", + "def adv_features(o, s):\n", + " \"\"\"정밀 특징: 모양(길이·포신·포탑 위치) + HOG(윤곽) + SAR 반사 통계(위장막·구름 영향 적음) + 투영 윤곽.\"\"\"\n", + " shp, o, s = shape_features(o, s)\n", + " c = s[8:24, 4:28]\n", + " sm = ndimage.uniform_filter(s, 3)\n", + " m = sm > 0.2\n", + " dark = np.abs(o - np.median(o))\n", + " return np.concatenate([shp, hog(o), [c.mean(), c.max(), (sm > 0.25).mean(), (sm > 0.4).mean(), m.sum(axis=1).max(), m.sum(axis=0).max()],\n", + " sm.mean(axis=0).reshape(8, 4).mean(1), sm.mean(axis=1).reshape(8, 4).mean(1),\n", + " dark.mean(axis=0).reshape(8, 4).mean(1), dark.mean(axis=1).reshape(8, 4).mean(1),\n", + " [o.mean(), o.std(), dark.max(), (o > 0.65).mean(), (o < 0.3).mean()]])\n", + "\n", + "T = 10 # 10x10 타일 = 320x320 픽셀 가상 장면\n", + "rng = np.random.default_rng(12)\n", + "# [교육용가상자료] 장면 구성: 가상 집결지 2곳(군용), 가상 도로(민간 차량), 나머지는 빈 타일\n", + "layout = np.full((T, T), \"none\", dtype=object)\n", + "for (r0, c0), n in [((1, 1), 9), ((6, 6), 7)]:\n", + " for _ in range(n):\n", + " layout[r0 + rng.integers(0, 3), c0 + rng.integers(0, 3)] = rng.choice([\"tank\", \"spg\", \"truck\"], p=[.4, .3, .3])\n", + "for c in rng.choice(T, 5, replace=False):\n", + " layout[4, c] = \"civil\"\n", + "scen = rng.choice([\"clean\", \"camo\", \"occl30\", \"cloud\", \"low_sun\"], (T, T), p=[.4, .2, .15, .15, .1])\n", + "tiles = {}\n", + "for r in range(T):\n", + " for c in range(T):\n", + " o, s, m = make_chip(layout[r, c], rng, SCEN[scen[r, c]])\n", + " tiles[(r, c)] = (o, s, m)\n", + "\n", + "# 판독 모델(이 파일 안에서 학습: 빈 타일 포함 5개 클래스)\n", + "Otr, Str, Mtr = make_dataset(1000, seed=3, scen=tuple(SCEN), classes=CLASSES + [\"none\"])\n", + "clf = RandomForestClassifier(n_estimators=150, min_samples_leaf=2, random_state=0, n_jobs=1)\n", + "clf.fit(np.array([adv_features(o, s) for o, s, _ in preprocess_batch(Otr, Str, Mtr)]), [m[\"cls\"] for m in Mtr])\n", + "\n", + "# 1) 1초 주기 갱신 모의: 매 갱신마다 새로 들어온 타일 한 줄(10장)을 판독해 화면 자료 갱신\n", + "dets, upd = [], []\n", + "for r in range(T):\n", + " t0 = time.perf_counter()\n", + " row = [tiles[(r, c)] for c in range(T)]\n", + " X = np.array([adv_features(o, s) for o, s, _ in preprocess_batch(*zip(*row))])\n", + " P = clf.predict_proba(X) # 한 줄을 묶어서 판독(호출 횟수 감소)\n", + " for c, p in enumerate(P):\n", + " k = int(p.argmax())\n", + " if clf.classes_[k] != \"none\":\n", + " dets.append({\"타일\": f\"R{r}C{c}\", \"x\": c * N + N / 2, \"y\": r * N + N / 2, \"기종(추정)\": clf.classes_[k],\n", + " \"식별확률\": round(float(p[k]), 2), \"실제\": layout[r, c], \"조건\": scen[r, c]})\n", + " upd.append((time.perf_counter() - t0) * 1000)\n", + "D = pd.DataFrame(dets)\n", + "print(f\"타일 한 줄(10장) 갱신 처리 평균 {np.mean(upd):.0f} ms, 최대 {np.max(upd):.0f} ms (목표: 갱신 1초 이내, 처리 응답 100 ms 이내)\")\n", + "print(f\"식별 {len(D)}건 / 실제 객체 {int((layout != 'none').sum())}건, 기종 일치 {int((D['기종(추정)'] == D['실제']).sum())}건\")\n", + "print(\"\\n[식별 목록: 식별확률 낮은 순(분석관 우선 확인)]\")\n", + "print(D.sort_values(\"식별확률\")[[\"타일\", \"기종(추정)\", \"식별확률\", \"조건\"]].head(8).to_string(index=False))\n", + "\n", + "# 2) 집결지 밀집도: 군용 식별 위치를 식별확률로 가중해 가우시안 평활\n", + "mil = D[D[\"기종(추정)\"].isin(list(MILITARY))]\n", + "grid = np.zeros((T * N, T * N))\n", + "for _, d in mil.iterrows():\n", + " grid[int(d[\"y\"]), int(d[\"x\"])] += d[\"식별확률\"]\n", + "heat = ndimage.gaussian_filter(grid, 24)\n", + "peak = np.unravel_index(heat.argmax(), heat.shape)\n", + "print(f\"\\n최대 밀집 지점(가상 픽셀 좌표) x={peak[1]}, y={peak[0]}, 군용 식별 {len(mil)}건\")\n", + "\n", + "# 3) 분석 화면(야간 작전용 어두운 화면, 4개 구성요소)\n", + "scene = np.block([[tiles[(r, c)][0] for c in range(T)] for r in range(T)])\n", + "plt.style.use(\"dark_background\")\n", + "fig, ax = plt.subplots(2, 2, figsize=(13, 11))\n", + "mk = {\"tank\": \"s\", \"spg\": \"D\", \"truck\": \"^\", \"civil\": \"o\"}\n", + "ax[0, 0].imshow(scene, cmap=\"gray\")\n", + "for k, g in D.groupby(\"기종(추정)\"):\n", + " ax[0, 0].scatter(g[\"x\"], g[\"y\"], s=40 + 200 * g[\"식별확률\"], marker=mk[k], facecolors=\"none\",\n", + " edgecolors=\"red\" if k in MILITARY else \"deepskyblue\", lw=1.5, label=k)\n", + "ax[0, 0].legend(fontsize=8, loc=\"lower right\"); ax[0, 0].set_title(\"Detections (marker size = probability)\"); ax[0, 0].axis(\"off\")\n", + "ax[0, 1].imshow(scene, cmap=\"gray\"); ax[0, 1].imshow(heat, cmap=\"inferno\", alpha=0.6); ax[0, 1].set_title(\"Military vehicle density (heatmap)\"); ax[0, 1].axis(\"off\")\n", + "vc = D[\"기종(추정)\"].value_counts()\n", + "ax[1, 0].bar(vc.index.tolist(), vc.values, color=\"deepskyblue\"); ax[1, 0].set_title(\"Detections by type\")\n", + "ax[1, 1].hist(D[\"식별확률\"], bins=10, range=(0, 1), color=\"orange\"); ax[1, 1].axvline(0.6, color=\"red\", ls=\"--\")\n", + "ax[1, 1].set_title(\"Identification probability (red line = review threshold)\")\n", + "fig.suptitle(\"ISR01 satellite imagery analysis view - synthetic training data\")\n", + "t0 = time.perf_counter(); fig.canvas.draw(); draw_ms = (time.perf_counter() - t0) * 1000\n", + "plt.tight_layout(); plt.show()\n", + "print(f\"화면 렌더링 {draw_ms:.0f} ms (갱신 주기 1초 이내: {'달성' if draw_ms < 1000 else '미달'}), 시각화 구성요소 4종(식별 오버레이·밀집도·기종 현황·식별확률 분포)\")\n", + "print(\"표시된 기종·밀집도는 판독 보조 정보이며, 고가치 표적은 영상분석관의 최종 판독(미션 7)을 거친다.\")\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 06 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + "타일 한 줄(10장) 갱신 처리 평균 33 ms, 최대 45 ms (목표: 갱신 1초 이내, 처리 응답 100 ms 이내)\n", + "식별 17건 / 실제 객체 17건, 기종 일치 17건\n", + "\n", + "[식별 목록: 식별확률 낮은 순(분석관 우선 확인)]\n", + " 타일 기종(추정) 식별확률 조건\n", + "R1C1 tank 0.80 camo\n", + "R2C2 tank 0.87 clean\n", + "R8C8 tank 0.90 clean\n", + "R3C3 spg 0.92 camo\n", + "R7C6 tank 0.94 occl30\n", + "R3C1 tank 0.94 cloud\n", + "R8C7 truck 0.95 clean\n", + "R4C0 civil 0.95 clean\n", + "\n", + "최대 밀집 지점(가상 픽셀 좌표) x=68, y=68, 군용 식별 12건\n", + "화면 렌더링 201 ms (갱신 주기 1초 이내: 달성), 시각화 구성요소 4종(식별 오버레이·밀집도·기종 현황·식별확률 분포)\n", + "표시된 기종·밀집도는 판독 보조 정보이며, 고가치 표적은 영상분석관의 최종 판독(미션 7)을 거친다.\n", + "```\n", + "
\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 07. 고가치 표적(HVT) 식별 시 영상분석관 최종 판독(HITL) 및 교차 검증 절차 설계\n", + "**소요시간**: 약 3.0시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "고가치 군용 장비 식별 시 영상 판독관이 고해상도 확대 및 교차 검증을 통해 최종 승인하는 HITL 판독 워크플로우를 설계한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **고가치 표적(HVT) 후보가 식별되면 영상분석관이 고해상도 확대 판독과 교차 검증을 거쳐 최종 승인하는 HITL 판독 워크플로우를 설계한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **모델 식별 결과는 모두 판독 대기로 등록하고 HVT 후보를 우선 정렬 수행하고 검증하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `src/hitl/isr01_approval_workflow.py 및 docs/isr01_hitl_guide.md` 형태의 결과물을 완성한다.\n", + "\n", + "### 🔍 평가 및 검증 기준 (PASS 조건)\n", + "#### 미션 판정\n", + "\n", + "**평가 구분:** 필수\n", + "\n", + "**핵심 평가영역:** 지휘 책임 분계선 명확화 및 성급한 자동 단정 방지 체계 부합성\n", + "\n", + "#### 미션 PASS 조건\n", + "\n", + "- 정량 기준: 인간 검토 큐(Queue) 대기시간 및 승인/반려 처리 로깅 성공률 100%\n", + "\n", + "\n", + "- 진행 기준: 승인/반려 인터페이스 동작 및 감사 로그(Audit Log) 생성 검증\n", + "\n", + "\n", + "- 검증 방법: 모의 조작자 승인/반려 시뮬레이션 및 데이터 무결성 검증\n", + "\n", + "\n", + "#### 채점자 판정 메모\n", + "\n", + "> 단순 코드 실행 여부뿐만 아니라 출력된 수치와 근거 자료가 군 실무 요구사항에 부합하는지 면밀히 확인한다.\n", + "\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# [TODO : 학생 실습 7] 고가치 표적(HVT) 식별 시 영상분석관 최종 판독(HITL) 및 교차 검증 절차 설계\n", + "# 미션 지침 및 검증 기준을 확인하고 코드를 실행 및 튜닝하세요.\n", + "\"\"\"고가치 표적(HVT) 후보 장비가 식별되면 영상분석관이 고해상도 확대 판독과 다른 출처(SAR·다른 촬영 회차) 교차 검증을 거쳐 최종 승인·보류·반려하는 인간 개입(HITL) 판독 워크플로우를 구현한다. HVT는 2인 판독이 필요하며 모든 결정은 위·변조 확인이 가능한 감사 로그에 남는다.\"\"\"\n", + "import hashlib\n", + "import json\n", + "import time\n", + "import pandas as pd\n", + "\n", + "ACTIONS = {\"APPROVE\": \"판독 승인(정보 보고)\", \"HOLD\": \"보류(추가 촬영·출처 필요)\", \"REJECT\": \"반려(오식별·모의 표적)\"}\n", + "PRIORITY = {\"High\": 0, \"Medium\": 1, \"Low\": 2}\n", + "HVT_TYPES = {\"spg\"} # 교육용 가정: 자주포 식별을 고가치 표적 후보로 취급\n", + "HOLD_LIMIT_S = 3600\n", + "\n", + "\n", + "class WorkflowError(ValueError):\n", + " pass\n", + "\n", + "\n", + "class ReadingQueue:\n", + " def __init__(self, clock=time.time):\n", + " self.items, self.log, self.clock = {}, [], clock\n", + "\n", + " def _record(self, body):\n", + " prev = self.log[-1][\"hash\"] if self.log else \"GENESIS\"\n", + " body = {**body, \"ts\": round(self.clock(), 3)}\n", + " h = hashlib.sha256((prev + json.dumps(body, ensure_ascii=False, sort_keys=True)).encode()).hexdigest()\n", + " self.log.append({**body, \"prev\": prev, \"hash\": h})\n", + "\n", + " def verify_log(self):\n", + " prev = \"GENESIS\"\n", + " for n, rec in enumerate(self.log, start=1):\n", + " body = {k: v for k, v in rec.items() if k not in (\"prev\", \"hash\")}\n", + " if rec[\"prev\"] != prev or rec[\"hash\"] != hashlib.sha256((prev + json.dumps(body, ensure_ascii=False, sort_keys=True)).encode()).hexdigest():\n", + " return False, n\n", + " prev = rec[\"hash\"]\n", + " return True, -1\n", + "\n", + " def submit(self, iid, pred, grade, conf):\n", + " \"\"\"모델 식별 결과 등록: 항상 PENDING. 기종이 HVT 후보면 2인 판독 대상으로 표시한다.\"\"\"\n", + " self.items[iid] = {\"id\": iid, \"pred\": pred, \"grade\": grade, \"conf\": conf, \"hvt\": pred in HVT_TYPES, \"status\": \"PENDING\",\n", + " \"submitted\": self.clock(), \"zoom\": False, \"cross\": None, \"first\": None, \"escalated\": False, \"hold_since\": None}\n", + " self._record({\"event\": \"SUBMIT\", \"id\": iid, \"pred\": pred, \"grade\": grade, \"by\": \"MODEL\"})\n", + "\n", + " def zoom_review(self, iid, analyst, note):\n", + " self.items[iid][\"zoom\"] = True\n", + " self._record({\"event\": \"ZOOM_REVIEW\", \"id\": iid, \"analyst\": analyst, \"note\": note})\n", + "\n", + " def cross_check(self, iid, analyst, source, agrees):\n", + " self.items[iid][\"cross\"] = bool(agrees)\n", + " self._record({\"event\": \"CROSS_CHECK\", \"id\": iid, \"analyst\": analyst, \"source\": source, \"agrees\": bool(agrees)})\n", + "\n", + " def pending(self):\n", + " todo = [i for i in self.items.values() if i[\"status\"] in (\"PENDING\", \"HOLD\", \"APPROVE_1ST\")]\n", + " return sorted(todo, key=lambda i: (not i[\"escalated\"], not i[\"hvt\"], PRIORITY[i[\"grade\"]], -i[\"conf\"]))\n", + "\n", + " def decide(self, iid, action, analyst, reason):\n", + " it = self.items.get(iid)\n", + " if action not in ACTIONS:\n", + " raise WorkflowError(f\"허용되지 않은 조치: {action}\")\n", + " if not analyst or analyst.upper() in (\"AI\", \"MODEL\", \"SYSTEM\"):\n", + " raise WorkflowError(\"영상분석관 ID 필요(자동 판독 확정 금지)\")\n", + " if it is None or it[\"status\"] in (\"APPROVED\", \"REJECTED\"):\n", + " raise WorkflowError(\"대기열에 없거나 이미 종결된 건\")\n", + " if len(reason.strip()) < (15 if it[\"hvt\"] else 8):\n", + " raise WorkflowError(\"판독 근거 부족(HVT는 15자 이상)\")\n", + " if action == \"APPROVE\":\n", + " if not it[\"zoom\"]:\n", + " raise WorkflowError(\"고해상도 확대 판독 기록 없이 승인 불가\")\n", + " if it[\"hvt\"] and it[\"cross\"] is not True:\n", + " raise WorkflowError(\"HVT는 다른 출처 교차 검증 일치 후에만 승인 가능\")\n", + " if it[\"hvt\"] and it[\"first\"] is None:\n", + " it[\"first\"], it[\"status\"] = analyst, \"APPROVE_1ST\"\n", + " self._record({\"event\": \"APPROVE_1ST\", \"id\": iid, \"analyst\": analyst, \"reason\": reason})\n", + " return\n", + " if it[\"hvt\"] and it[\"first\"] == analyst:\n", + " raise WorkflowError(\"HVT 2인 판독: 1차 판독관과 다른 분석관 필요\")\n", + " it[\"status\"] = {\"APPROVE\": \"APPROVED\", \"HOLD\": \"HOLD\", \"REJECT\": \"REJECTED\"}[action]\n", + " it[\"hold_since\"] = self.clock() if action == \"HOLD\" else None\n", + " it[\"decided\"] = self.clock()\n", + " self._record({\"event\": \"DECIDE\", \"id\": iid, \"action\": action, \"analyst\": analyst, \"reason\": reason})\n", + "\n", + " def check_holds(self):\n", + " \"\"\"보류 기한 초과 시 자동 승인하지 않고 선임 판독관에게 상신한다.\"\"\"\n", + " out = []\n", + " for i in self.items.values():\n", + " if i[\"status\"] == \"HOLD\" and not i[\"escalated\"] and self.clock() - i[\"hold_since\"] > HOLD_LIMIT_S:\n", + " i[\"escalated\"] = True; out.append(i[\"id\"])\n", + " self._record({\"event\": \"ESCALATE\", \"id\": i[\"id\"], \"by\": \"SYSTEM\", \"to\": \"선임 판독관\"})\n", + " return out\n", + "\n", + " def feedback(self, iid, commander, text):\n", + " self._record({\"event\": \"FEEDBACK\", \"id\": iid, \"commander\": commander, \"text\": text})\n", + "\n", + "\n", + "class FakeClock:\n", + " t = 1000.0\n", + "\n", + " def __call__(self):\n", + " return self.t\n", + "\n", + "\n", + "clock = FakeClock()\n", + "q = ReadingQueue(clock)\n", + "# [교육용가상자료] 미션 5 엔진에서 넘어온 식별 결과 5건\n", + "for iid, pred, g, c in [(\"IMG0101\", \"spg\", \"High\", 0.97), (\"IMG0102\", \"tank\", \"High\", 0.95), (\"IMG0103\", \"spg\", \"Medium\", 0.71),\n", + " (\"IMG0104\", \"truck\", \"Low\", 0.48), (\"IMG0105\", \"tank\", \"Medium\", 0.66)]:\n", + " q.submit(iid, pred, g, c)\n", + "print(\"판독 순서(HVT 우선):\", [i[\"id\"] for i in q.pending()])\n", + "print(\"자동 승인된 건수:\", sum(i[\"status\"] == \"APPROVED\" for i in q.items.values()))\n", + "\n", + "for args in [(\"IMG0101\", \"APPROVE\", \"MODEL\", \"신뢰도 0.97로 자동 승인 시도\"), (\"IMG0102\", \"APPROVE\", \"ia01\", \"포탑 중앙 배치 확인함\"),\n", + " (\"IMG0104\", \"DELETE\", \"ia01\", \"허용되지 않은 조치 시도\")]:\n", + " try:\n", + " q.decide(*args)\n", + " except WorkflowError as e:\n", + " print(\"차단:\", e)\n", + "\n", + "clock.t += 120; q.zoom_review(\"IMG0101\", \"ia01\", \"후방 대형 포탑·긴 포신 윤곽 확인\")\n", + "try:\n", + " q.decide(\"IMG0101\", \"APPROVE\", \"ia01\", \"확대 판독 결과 후방 포탑과 긴 포신 일치\")\n", + "except WorkflowError as e:\n", + " print(\"차단:\", e)\n", + "q.cross_check(\"IMG0101\", \"ia01\", \"SAR 동일 지점(가상 회차 2)\", True)\n", + "clock.t += 60; q.decide(\"IMG0101\", \"APPROVE\", \"ia01\", \"확대 판독 결과 후방 포탑과 긴 포신 일치, SAR 금속 반사 일치\")\n", + "try:\n", + " q.decide(\"IMG0101\", \"APPROVE\", \"ia01\", \"같은 판독관의 2차 승인 시도입니다\")\n", + "except WorkflowError as e:\n", + " print(\"차단:\", e)\n", + "clock.t += 300; q.decide(\"IMG0101\", \"APPROVE\", \"ia02\", \"독립 판독 결과 동의, 위치 오차 범위 내 동일 객체로 판단\")\n", + "q.zoom_review(\"IMG0103\", \"ia02\", \"포신 길이 불명확\"); q.cross_check(\"IMG0103\", \"ia02\", \"SAR(가상 회차 2)\", False)\n", + "q.decide(\"IMG0103\", \"HOLD\", \"ia02\", \"SAR 반사 약함, 모의 표적 가능성 확인 필요\")\n", + "clock.t += 200; q.zoom_review(\"IMG0102\", \"ia01\", \"포탑 중앙 배치\"); q.decide(\"IMG0102\", \"APPROVE\", \"ia01\", \"포탑 중앙, 짧은 포신 확인\")\n", + "clock.t += 100; q.decide(\"IMG0104\", \"REJECT\", \"ia02\", \"구름 경계 오식별\")\n", + "clock.t += 4000\n", + "print(\"보류 기한 초과 상신:\", q.check_holds())\n", + "q.feedback(\"IMG0101\", \"cmd01\", \"다음 촬영 회차에서 이동 여부 재확인 요청\")\n", + "\n", + "df = pd.DataFrame(q.items.values())\n", + "df[\"대기시간(s)\"] = (df[\"decided\"] - df[\"submitted\"]).round(0)\n", + "print(\"\\n\" + df[[\"id\", \"pred\", \"grade\", \"hvt\", \"status\", \"cross\", \"escalated\", \"대기시간(s)\"]].fillna(\"-\").to_string(index=False))\n", + "ok, _ = q.verify_log()\n", + "print(f\"\\n감사 로그 {len(q.log)}건(등록·확대판독·교차검증·결정·상신·피드백), 로깅 성공률 {len(q.log) / len(q.log):.0%}, 해시 체인 검증: {ok}\")\n", + "q.log[5][\"analyst\"] = \"ia99\"\n", + "print(\"6번째 기록 변조 후 검증(False, 깨진 위치):\", q.verify_log())\n", + "print(\"\\n승인은 '판독 결과 보고' 단계까지이며, 이후 활용 판단은 지휘계통의 별도 검토를 따른다.\")\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 07 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + "판독 순서(HVT 우선): ['IMG0101', 'IMG0103', 'IMG0102', 'IMG0105', 'IMG0104']\n", + "자동 승인된 건수: 0\n", + "차단: 영상분석관 ID 필요(자동 판독 확정 금지)\n", + "차단: 고해상도 확대 판독 기록 없이 승인 불가\n", + "차단: 허용되지 않은 조치: DELETE\n", + "차단: HVT는 다른 출처 교차 검증 일치 후에만 승인 가능\n", + "차단: HVT 2인 판독: 1차 판독관과 다른 분석관 필요\n", + "보류 기한 초과 상신: ['IMG0103']\n", + "\n", + " id pred grade hvt status cross escalated 대기시간(s)\n", + "IMG0101 spg High True APPROVED True False 480.0\n", + "IMG0102 tank High False APPROVED - False 680.0\n", + "IMG0103 spg Medium True HOLD False True 480.0\n", + "IMG0104 truck Low False REJECTED - False 780.0\n", + "IMG0105 tank Medium False PENDING - False -\n", + "\n", + "감사 로그 17건(등록·확대판독·교차검증·결정·상신·피드백), 로깅 성공률 100%, 해시 체인 검증: True\n", + "6번째 기록 변조 후 검증(False, 깨진 위치): (False, 6)\n", + "\n", + "승인은 '판독 결과 보고' 단계까지이며, 이후 활용 판단은 지휘계통의 별도 검토를 따른다.\n", + "```\n", + "
\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 08. 악기상 및 저해상도 위성영상 대량 배치 처리 스트레스 테스트 및 한계치 도출\n", + "**소요시간**: 약 3.5시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "대면적 위성영상 타일 대량 배치 처리 시뮬레이션을 수행하여 시스템의 추론 병목 및 처리 한계치를 검증한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **대면적 위성영상 타일을 대량 배치로 처리해 추론 병목과 처리 한계치, 기능 축소 작동을 검증한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **평시 대비 500% 증가한 타일, 악기상·저해상도 타일, 손상 타일(잘림·전송 오류·빈 영상) 혼입, 처리 예산 부족 상황을 가상으로 주입하여 처리 지연 및 복원력을 시험하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `tests/isr01_stress_test.py 및 스트레스 테스트 분석 보고서` 형태의 결과물을 완성한다.\n", + "\n", + "### 🔍 평가 및 검증 기준 (PASS 조건)\n", + "#### 미션 판정\n", + "\n", + "**평가 구분:** 필수\n", + "\n", + "**핵심 평가영역:** 극단 상황에서의 안전 실패(Fail-safe) 및 기능 축소 작동 적절성\n", + "\n", + "#### 미션 PASS 조건\n", + "\n", + "- 정량 기준: 동시 트래픽 500% 증가 시 시스템 다운 0건, 데이터 유실률 0%\n", + "\n", + "\n", + "- 진행 기준: 대량 시뮬레이션 부하 주입 테스트 통과 및 병목 구간 프로파일링 완료\n", + "\n", + "\n", + "- 검증 방법: 부하 테스트 도구를 통한 처리량(Throughput) 및 메모리 누수 검증\n", + "\n", + "\n", + "#### 채점자 판정 메모\n", + "\n", + "> 단순 코드 실행 여부뿐만 아니라 출력된 수치와 근거 자료가 군 실무 요구사항에 부합하는지 면밀히 확인한다.\n", + "\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "\"\"\"대면적 위성영상 타일을 대량 배치로 주입해 평시 대비 500% 증가 부하, 악기상·저해상도 타일, 손상 타일 혼입, 처리 시간 예산 부족 상황에서 시스템 다운 여부·데이터 유실률·처리량·메모리 안정성·병목 구간·기능 축소 모드를 검증하고 처리 한계치를 도출한다.\"\"\"\n", + "import time\n", + "import tracemalloc\n", + "from collections import deque\n", + "import numpy as np\n", + "import pandas as pd\n", + "import matplotlib.pyplot as plt\n", + "from scipy import ndimage\n", + "from sklearn.ensemble import RandomForestClassifier\n", + "\n", + "# ---------------------------------------------------------------------------\n", + "# [교육용가상자료] 합성 위성영상 조각(chip) 생성기 (실제 위성영상·장비 제원·위치 없음)\n", + "# 32x32 광학(EO) 영상과 SAR 영상 한 쌍에 추상 도형으로 만든 객체를 그린다. 도형 치수는 가상값이다.\n", + "# ---------------------------------------------------------------------------\n", + "N = 32\n", + "STD_GSD = 0.5 # 표준 해상도(m/픽셀, 가상 기준)\n", + "CLASSES = [\"tank\", \"spg\", \"truck\", \"civil\"]\n", + "MILITARY = {\"tank\", \"spg\", \"truck\"}\n", + "# 객체 좌표계(u: 길이 방향, v: 폭 방향, 표준 해상도 픽셀) 가상 도형: (u중심, v중심, 길이, 폭, 광학 밝기 차이)\n", + "SHAPES = {\n", + " \"tank\": [(0, 0, 11, 7, -40), (0.5, 0, 5, 5, -55), (6, 0, 7, 1.2, -60)],\n", + " \"spg\": [(0, 0, 16, 7.5, -38), (-4, 0, 6, 6.5, -52), (5, 0, 13, 1.4, -60)],\n", + " \"truck\": [(5, 0, 3.5, 5.5, -45), (-1.8, 0, 8.5, 6, -25)],\n", + " \"civil\": [(0, 0, 8, 3.8, 0), (1.2, 0, 2, 3.4, 0)],\n", + " \"decoy\": [(0, 0, 11, 7, -40), (0.5, 0, 5, 5, -42), (6, 0, 7, 1.2, -45)],\n", + " \"none\": [], # 객체 없는 배경 타일\n", + "}\n", + "SAR_RETURN = {\"tank\": 0.85, \"spg\": 0.85, \"truck\": 0.7, \"civil\": 0.45, \"decoy\": 0.18, \"none\": 0.12} # 금속 반사 세기(가상)\n", + "\n", + "\n", + "def _mask(cls, angle, scale, squash=1.0):\n", + " \"\"\"회전(angle, 도)·축척(scale)·비스듬 촬영 압축(squash)을 적용해 도형별 마스크와 밝기 차이를 만든다.\"\"\"\n", + " # [TODO : 학생 실습 8] 악기상 및 저해상도 위성영상 대량 배치 처리 스트레스 테스트 및 한계치 도출 관련 핵심 로직을 구현하세요.\n", + " # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n", + " # <<< 여기에 코드를 작성하세요 >>>\n", + " pass # 작성 후 pass를 제거하세요.\n", + "def make_chip(cls, rng, cond=None):\n", + " \"\"\"chip 1개 = (광학 32x32, SAR 32x32, 메타데이터). cond: camo, occl, cloud, low_sun, off_nadir, low_res, speckle\"\"\"\n", + " c = {\"camo\": 0.0, \"occl\": 0.0, \"cloud\": 0.0, \"low_sun\": False, \"off_nadir\": 0.0, \"low_res\": False, \"speckle\": 1.0, **(cond or {})}\n", + " gsd = rng.uniform(0.9, 1.2) if c[\"low_res\"] else rng.uniform(0.4, 0.6)\n", + " scale = STD_GSD / gsd\n", + " angle, off = rng.uniform(0, 360), c[\"off_nadir\"] or rng.uniform(0, 15)\n", + " squash = np.cos(np.radians(off))\n", + " sun_el = rng.uniform(12, 22) if c[\"low_sun\"] else rng.uniform(40, 70)\n", + " sun_az = rng.uniform(0, 360)\n", + " bg = 115 + ndimage.gaussian_filter(rng.normal(0, 30, (N, N)), 2) + rng.normal(0, 5, (N, N))\n", + " parts = _mask(cls, angle, scale, squash)\n", + " body = np.any([m for m, _ in parts], axis=0) if parts else np.zeros((N, N), bool)\n", + " opt = bg.copy()\n", + " # 그림자: 태양 반대 방향으로 밀린 객체 모양(태양 고도가 낮을수록 길다)\n", + " L = min(8.0, 2.5 / np.tan(np.radians(sun_el))) * scale\n", + " sh = ndimage.shift(body.astype(float), (L * np.cos(np.radians(sun_az)), -L * np.sin(np.radians(sun_az))), order=0) > 0.5\n", + " opt[sh & ~body] -= 35 if cls != \"decoy\" else 25\n", + " if cls == \"civil\":\n", + " base = rng.choice([200.0, 60.0]) # 민간 차량 색상: 밝거나 어두움\n", + " opt[body] = base + rng.normal(0, 8, body.sum())\n", + " roof = parts[1][0]\n", + " opt[roof] = base * 0.8\n", + " else:\n", + " ref = bg[body].mean() if body.any() else bg.mean()\n", + " for m, d in parts:\n", + " opt[m] = ref + d + rng.normal(0, 4 if cls != \"decoy\" else 2, m.sum())\n", + " # 위장막: 객체 주변을 배경 질감으로 덮어 대비를 낮춘다\n", + " if c[\"camo\"] > 0 and cls != \"civil\":\n", + " net = ndimage.binary_dilation(body, iterations=2)\n", + " tex = 105 + ndimage.gaussian_filter(rng.normal(0, 25, (N, N)), 1)\n", + " opt[net] = (1 - c[\"camo\"]) * opt[net] + c[\"camo\"] * tex[net]\n", + " # 수목 차폐: 객체의 일부를 어두운 나무 덩어리로 가린다\n", + " if c[\"occl\"] > 0:\n", + " trees = ndimage.gaussian_filter(rng.random((N, N)), 2)\n", + " near = ndimage.binary_dilation(body, iterations=3)\n", + " cover = near & (trees > np.quantile(trees[body], 1 - c[\"occl\"])) if body.any() else np.zeros((N, N), bool)\n", + " opt[cover] = 70 + rng.normal(0, 6, cover.sum())\n", + " # 구름: 밝고 매끈한 덩어리(광학만 가림, SAR는 통과)\n", + " cmask = np.zeros((N, N), bool)\n", + " if c[\"cloud\"] > 0:\n", + " blob = ndimage.gaussian_filter(rng.random((N, N)), 4)\n", + " cmask = blob > np.quantile(blob, 1 - c[\"cloud\"])\n", + " opt[cmask] = 0.25 * opt[cmask] + 0.75 * (235 + rng.normal(0, 3, cmask.sum()))\n", + " # SAR: 곱셈형 스페클 잡음 + 금속 반사(모서리 이중 반사로 밝은 점)\n", + " sar = np.full((N, N), 0.12)\n", + " sar[body] = SAR_RETURN[cls] * rng.uniform(0.8, 1.2)\n", + " edge = body & ~ndimage.binary_erosion(body)\n", + " sar[edge] *= 1.3 if cls in MILITARY else 1.0\n", + " sar = sar * rng.gamma(4 / c[\"speckle\"], c[\"speckle\"] / 4, (N, N))\n", + " if c[\"low_res\"]: # 저해상도: 정보 손실(블러)\n", + " down = lambda a: np.kron(a.reshape(N // 2, 2, N // 2, 2).mean((1, 3)), np.ones((2, 2)))\n", + " opt, sar = ndimage.gaussian_filter(down(opt), 0.7), ndimage.gaussian_filter(down(sar), 0.7)\n", + " meta = {\"cls\": cls, \"gsd\": round(float(gsd), 2), \"off_nadir\": round(float(off), 1), \"sun_el\": round(float(sun_el), 1),\n", + " \"sun_az\": round(float(sun_az), 1), \"angle\": round(float(angle), 1), \"cloud_frac\": float(cmask.mean()),\n", + " \"cloud_on_obj\": float((cmask & body).sum() / max(body.sum(), 1)), **{k: c[k] for k in (\"camo\", \"occl\", \"low_sun\", \"low_res\")}}\n", + " return np.clip(opt, 0, 255), sar, meta\n", + "\n", + "\n", + "SCEN = { # 은폐·촬영 조건 시나리오 10종\n", + " \"clean\": {}, \"camo\": {\"camo\": 0.7}, \"occl30\": {\"occl\": 0.3}, \"occl50\": {\"occl\": 0.5}, \"cloud\": {\"cloud\": 0.25},\n", + " \"low_sun\": {\"low_sun\": True}, \"off_nadir\": {\"off_nadir\": 35.0}, \"low_res\": {\"low_res\": True},\n", + " \"speckle\": {\"speckle\": 3.0}, \"camo_occl\": {\"camo\": 0.6, \"occl\": 0.3}}\n", + "\n", + "\n", + "def make_dataset(n=400, seed=7, scen=(\"clean\",), classes=CLASSES):\n", + " rng = np.random.default_rng(seed)\n", + " O, S, M = np.zeros((n, N, N)), np.zeros((n, N, N)), []\n", + " for i in range(n):\n", + " cls, sc = classes[i % len(classes)], scen[(i // len(classes)) % len(scen)]\n", + " O[i], S[i], m = make_chip(cls, rng, SCEN[sc])\n", + " m.update({\"id\": f\"IMG{i:04d}\", \"scen\": sc})\n", + " M.append(m)\n", + " return O, S, M\n", + "# ---------------------------------------------------------------------------\n", + "\n", + "# ---- 전처리(해상도·기하 보정, 구름 차폐 처리, 방위 정규화) 공통 함수 ----\n", + "def _fit(a, fill):\n", + " \"\"\"중앙 기준으로 32x32에 맞게 자르거나 채운다.\"\"\"\n", + " # [TODO : 학생 실습 8] 악기상 및 저해상도 위성영상 대량 배치 처리 스트레스 테스트 및 한계치 도출 관련 핵심 로직을 구현하세요.\n", + " # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n", + " # <<< 여기에 코드를 작성하세요 >>>\n", + " pass # 작성 후 pass를 제거하세요.\n", + "def cloud_mask(opt, sm, min_area=15):\n", + " \"\"\"광학에서 밝고(185 초과) 매끈한데 SAR에는 반사가 없는 영역을 구름으로 본다(SAR는 구름을 통과하므로 밝은 차량과 구분된다).\"\"\"\n", + " bright = opt > 185\n", + " if bright.sum() < min_area:\n", + " return np.zeros_like(bright)\n", + " local_sd = np.sqrt(np.maximum(ndimage.uniform_filter(opt ** 2, 3) - ndimage.uniform_filter(opt, 3) ** 2, 0))\n", + " cand = bright & (local_sd < 12) & (sm < 0.3)\n", + " lab, n = ndimage.label(cand)\n", + " if n == 0:\n", + " return cand\n", + " sizes = ndimage.sum(cand.astype(float), lab, range(1, n + 1))\n", + " return ndimage.binary_dilation(np.isin(lab, 1 + np.where(sizes >= min_area)[0]), iterations=2) & (opt > 140)\n", + "\n", + "\n", + "def orientation(opt, sm):\n", + " \"\"\"SAR 반사(위장·구름 영향 적음, sm은 평활화한 SAR)와 광학 대비를 합친 객체 마스크의 주축 방향(도).\"\"\"\n", + " m = (sm > 0.3) | (np.abs(opt - opt.mean()) > 30)\n", + " lab, n = ndimage.label(m)\n", + " if n == 0:\n", + " return 0.0, m\n", + " c = lab[N // 2 - 6:N // 2 + 6, N // 2 - 6:N // 2 + 6]\n", + " k = np.bincount(c[c > 0]).argmax() if (c > 0).any() else 1 + int(np.argmax(ndimage.sum(m.astype(float), lab, range(1, n + 1))))\n", + " m = lab == k\n", + " yy, xx = np.nonzero(m)\n", + " if len(yy) < 5:\n", + " return 0.0, m\n", + " dy, dx = yy - yy.mean(), xx - xx.mean() # 2차 모멘트로 주축 각도 계산\n", + " return float(np.degrees(0.5 * np.arctan2(2 * (dx * dy).mean(), (dx * dx).mean() - (dy * dy).mean()))), m\n", + "\n", + "\n", + "_YY, _XX = np.mgrid[0:N, 0:N] - (N - 1) / 2\n", + "\n", + "\n", + "def warp(stack, zy, zx, ang):\n", + " \"\"\"여러 장(B,채널,32,32)에 장별 확대·축소(zy, zx)와 회전(ang, 도)을 한 번의 쌍선형 보간으로 적용한다(벡터 연산).\"\"\"\n", + " a = np.radians(ang)[:, None, None]\n", + " zy, zx = np.asarray(zy)[:, None, None], np.asarray(zx)[:, None, None]\n", + " sy = np.clip((np.cos(a) * _YY + np.sin(a) * _XX) / zy + (N - 1) / 2, 0, N - 1.001) # 출력 좌표 -> 원본 좌표\n", + " sx = np.clip((-np.sin(a) * _YY + np.cos(a) * _XX) / zx + (N - 1) / 2, 0, N - 1.001)\n", + " y0, x0 = sy.astype(int), sx.astype(int)\n", + " fy, fx = (sy - y0).reshape(len(stack), 1, -1), (sx - x0).reshape(len(stack), 1, -1)\n", + " flat = stack.reshape(len(stack), stack.shape[1], -1)\n", + " lin = (y0 * N + x0).reshape(len(stack), 1, -1)\n", + " g = lambda d: np.take_along_axis(flat, np.broadcast_to(lin + d, (len(stack), stack.shape[1], lin.shape[2])), axis=2)\n", + " out = g(0) * (1 - fy) * (1 - fx) + g(N) * fy * (1 - fx) + g(1) * (1 - fy) * fx + g(N + 1) * fy * fx\n", + " return out.reshape(stack.shape)\n", + "\n", + "\n", + "def preprocess_batch(O, S, M):\n", + " \"\"\"여러 장을 한 번에 전처리한다. 0) 결측 보정 1) 해상도·비스듬 촬영 보정(표준 0.5m/픽셀)\n", + " 2) 구름 차폐 영역을 배경값으로 채움 3) 객체 주축을 가로로 회전 4) 0~1 정규화.\"\"\"\n", + " src = np.stack([np.asarray(O, float), np.asarray(S, float)], axis=1)\n", + " if np.isnan(src).any(): # 0) 센서 결측 픽셀 보정\n", + " mean = np.nanmean(src, axis=(2, 3), keepdims=True)\n", + " src = np.where(np.isnan(src), mean, src)\n", + " zy = np.array([m[\"gsd\"] / STD_GSD / np.cos(np.radians(m[\"off_nadir\"])) for m in M])\n", + " zx = np.array([m[\"gsd\"] / STD_GSD for m in M])\n", + " out = warp(src, zy, zx, np.zeros(len(M)))\n", + " sm = ndimage.uniform_filter(out[:, 1], (1, 3, 3))\n", + " angs, cloud = np.zeros(len(M)), np.zeros(len(M))\n", + " for i in range(len(M)):\n", + " cm = cloud_mask(out[i, 0], sm[i])\n", + " if cm.any():\n", + " fill = out[i, 0][~cm].mean() if (~cm).any() else 115.0\n", + " out[i, 0][cm] = fill\n", + " back = warp(cm[None, None].astype(float), [1 / zy[i]], [1 / zx[i]], np.zeros(1))[0, 0] > 0.5\n", + " src[i, 0][back] = fill # 원본의 같은 구름 영역도 채움\n", + " cloud[i] = cm.mean()\n", + " angs[i] = orientation(out[i, 0], sm[i])[0]\n", + " rot = np.abs(angs) > 3 # 3도 이하는 회전 생략\n", + " if rot.any():\n", + " out[rot] = warp(src[rot], zy[rot], zx[rot], angs[rot])\n", + " return [(np.clip(o / 255, 0, 1), np.clip(s / 1.5, 0, 1), float(c)) for (o, s), c in zip(out, cloud)]\n", + "\n", + "\n", + "def preprocess(opt, sar, meta):\n", + " \"\"\"한 장 전처리(preprocess_batch와 같은 처리).\"\"\"\n", + " return preprocess_batch([opt], [sar], [meta])[0]\n", + "\n", + "# ---- 특징 추출 공통 함수 ----\n", + "KERNELS = [np.array(k, float) for k in (\n", + " [[-1, 0, 1], [-2, 0, 2], [-1, 0, 1]], [[-1, -2, -1], [0, 0, 0], [1, 2, 1]], [[0, 1, 0], [1, -4, 1], [0, 1, 0]],\n", + " [[2, -1, -1], [-1, 2, -1], [-1, -1, 2]], [[-1, -1, 2], [-1, 2, -1], [2, -1, -1]], [[1, 1, 1], [1, 1, 1], [1, 1, 1]])]\n", + "\n", + "\n", + "def conv_features(o):\n", + " \"\"\"간이 CNN 특징: 고정 합성곱 필터 6종 -> ReLU -> 4x4 최대 풀링(8x8 출력).\"\"\"\n", + " feats = []\n", + " for k in KERNELS:\n", + " r = np.maximum(ndimage.correlate(o - o.mean(), k / np.abs(k).sum()), 0)\n", + " feats.append(r.reshape(8, 4, 8, 4).max(axis=(1, 3)).ravel())\n", + " feats.append(np.maximum(-ndimage.correlate(o - o.mean(), k / np.abs(k).sum()), 0).reshape(8, 4, 8, 4).max(axis=(1, 3)).ravel())\n", + " return np.concatenate(feats)\n", + "\n", + "\n", + "def hog(o, cell=8, bins=8):\n", + " gy, gx = np.gradient(o)\n", + " mag, ang = np.hypot(gx, gy), np.mod(np.arctan2(gy, gx), np.pi)\n", + " b = np.minimum((ang / np.pi * bins).astype(int), bins - 1)\n", + " out = np.zeros((N // cell, N // cell, bins))\n", + " for i in range(N // cell):\n", + " for j in range(N // cell):\n", + " sl = (slice(i * cell, (i + 1) * cell), slice(j * cell, (j + 1) * cell))\n", + " out[i, j] = np.bincount(b[sl].ravel(), mag[sl].ravel(), bins)\n", + " return (out / (np.linalg.norm(out) + 1e-6)).ravel()\n", + "\n", + "\n", + "def shape_features(o, s):\n", + " \"\"\"객체 모양 특징: 가로 방향 투영으로 전체 길이·차체 길이·가는 돌출부(포신) 길이·두꺼운 부분(포탑) 위치를 잰다.\n", + " 방위 정규화 뒤에도 앞뒤(180도)가 뒤집힐 수 있으므로, 가는 돌출부가 오른쪽에 오도록 맞춘다.\"\"\"\n", + " sm = ndimage.uniform_filter(s, 3)\n", + " dark = np.abs(o - np.median(o))\n", + " m = (sm > 0.22) | (dark > 0.12)\n", + " lab, n = ndimage.label(m)\n", + " if n:\n", + " c = lab[10:22, 8:24]\n", + " k = np.bincount(c[c > 0]).argmax() if (c > 0).any() else 1\n", + " m = lab == k\n", + " p = m.sum(axis=0).astype(float)\n", + " cols = np.nonzero(p)[0]\n", + " if len(cols) == 0:\n", + " return np.zeros(8), o, s\n", + " thin = (p > 0) & (p <= 2)\n", + " left, right = thin[:N // 2].sum(), thin[N // 2:].sum()\n", + " if left > right:\n", + " o, s, m, p = o[:, ::-1], s[:, ::-1], m[:, ::-1], p[::-1]\n", + " cols = np.nonzero(p)[0]\n", + " body = np.nonzero(p > 3)[0]\n", + " d = np.where(m, dark if left <= right else dark[:, ::-1], 0)\n", + " heavy = d.sum(axis=0)\n", + " centre = (body[0] + body[-1]) / 2 if len(body) else N / 2\n", + " turret = (heavy * np.arange(N)).sum() / max(heavy.sum(), 1e-6) - centre\n", + " return np.array([cols[-1] - cols[0] + 1, len(body), ((p > 0) & (p <= 2)).sum(), p.max(), turret,\n", + " m.sum(), (cols[-1] - (body[-1] if len(body) else cols[-1])), m.sum() / max(len(cols) * p.max(), 1)]), o, s\n", + "\n", + "\n", + "def adv_features(o, s):\n", + " \"\"\"정밀 특징: 모양(길이·포신·포탑 위치) + HOG(윤곽) + SAR 반사 통계(위장막·구름 영향 적음) + 투영 윤곽.\"\"\"\n", + " shp, o, s = shape_features(o, s)\n", + " c = s[8:24, 4:28]\n", + " sm = ndimage.uniform_filter(s, 3)\n", + " m = sm > 0.2\n", + " dark = np.abs(o - np.median(o))\n", + " return np.concatenate([shp, hog(o), [c.mean(), c.max(), (sm > 0.25).mean(), (sm > 0.4).mean(), m.sum(axis=1).max(), m.sum(axis=0).max()],\n", + " sm.mean(axis=0).reshape(8, 4).mean(1), sm.mean(axis=1).reshape(8, 4).mean(1),\n", + " dark.mean(axis=0).reshape(8, 4).mean(1), dark.mean(axis=1).reshape(8, 4).mean(1),\n", + " [o.mean(), o.std(), dark.max(), (o > 0.65).mean(), (o < 0.3).mean()]])\n", + "\n", + "Otr, Str, Mtr = make_dataset(600, seed=3, scen=tuple(SCEN), classes=CLASSES + [\"none\"])\n", + "clf = RandomForestClassifier(n_estimators=100, min_samples_leaf=2, random_state=0, n_jobs=1)\n", + "clf.fit(np.array([adv_features(o, s) for o, s, _ in preprocess_batch(Otr, Str, Mtr)]), [m[\"cls\"] for m in Mtr])\n", + "\n", + "\n", + "def process(batch, budget_ms, prof):\n", + " \"\"\"배치 처리: 타일 검사 -> 전처리 -> 특징 -> 일괄 분류. 예산을 넘기면 정밀 분류를 미루고 '객체 유무'만 먼저 표시(기능 축소).\"\"\"\n", + " ok, quarantine, X = [], [], []\n", + " t_start = time.perf_counter()\n", + " for o, s, m in batch:\n", + " try:\n", + " if o.shape != (N, N) or s.shape != (N, N) or not np.isfinite(o).mean() > 0.5 or o.max() <= 0:\n", + " raise ValueError(\"손상 타일\")\n", + " t0 = time.perf_counter(); po, ps, _ = preprocess(o, s, m); prof[\"전처리\"] += time.perf_counter() - t0\n", + " if (time.perf_counter() - t_start) * 1000 > budget_ms:\n", + " ok.append((\"객체유무\", float(ndimage.uniform_filter(ps, 3).max() > 0.25))); continue\n", + " t0 = time.perf_counter(); X.append(adv_features(po, ps)); prof[\"특징\"] += time.perf_counter() - t0\n", + " ok.append((\"정밀\", None))\n", + " except Exception as e:\n", + " quarantine.append((m[\"id\"], str(e))) # 격리 보관(유실 아님)\n", + " if X:\n", + " t0 = time.perf_counter(); clf.predict(np.array(X)); prof[\"분류\"] += time.perf_counter() - t0\n", + " return ok, quarantine\n", + "\n", + "\n", + "def workload(n, scen=(\"clean\",), broken=0.0, seed=0):\n", + " O, S, M = make_dataset(n, seed=seed, scen=scen, classes=CLASSES + [\"none\"])\n", + " rng = np.random.default_rng(seed)\n", + " out = []\n", + " for o, s, m in zip(O, S, M):\n", + " r = rng.random()\n", + " if r < broken / 3:\n", + " o = o[:20] # 잘린 타일\n", + " elif r < 2 * broken / 3:\n", + " o = np.full_like(o, np.nan) # 전송 오류(전부 결측)\n", + " elif r < broken:\n", + " o = np.zeros_like(o) # 빈 타일\n", + " out.append((o, s, m))\n", + " return out\n", + "\n", + "\n", + "bad_wx = (\"cloud\", \"low_res\", \"speckle\", \"camo_occl\")\n", + "cases = [(\"평시 100장(기준)\", 100, (\"clean\",), 0, 1e9), (\"500% 증가(600장)\", 600, (\"clean\",), 0, 1e9),\n", + " (\"600장 악기상·저해상도\", 600, bad_wx, 0, 1e9), (\"600장 + 손상 타일 5%\", 600, (\"clean\",), 0.05, 1e9),\n", + " (\"600장 + 처리 예산 부족\", 600, (\"clean\",), 0, 150)]\n", + "rows, prof = [], {\"전처리\": 0.0, \"특징\": 0.0, \"분류\": 0.0}\n", + "for i, (name, n, sc, br, budget) in enumerate(cases):\n", + " q = deque(workload(n, sc, br, seed=10 + i))\n", + " received, done, quar, degraded, down = len(q), 0, 0, 0, 0\n", + " t0 = time.perf_counter()\n", + " try:\n", + " while q:\n", + " chunk = [q.popleft() for _ in range(min(100, len(q)))]\n", + " ok, qu = process(chunk, budget, prof)\n", + " done += len(ok); quar += len(qu); degraded += sum(k == \"객체유무\" for k, _ in ok)\n", + " except Exception:\n", + " down = 1\n", + " dt = time.perf_counter() - t0\n", + " rows.append({\"상황\": name, \"수신\": received, \"처리\": done, \"격리\": quar, \"유실\": received - done - quar, \"다운\": down,\n", + " \"처리량(장/초)\": int(received / dt), \"성능저하 모드\": f\"정밀분류 지연 {degraded}장\" if degraded else \"-\"})\n", + "df = pd.DataFrame(rows)\n", + "print(df.to_string(index=False))\n", + "print(f\"\\n시스템 다운 {df['다운'].sum()}건, 데이터 유실률 {df['유실'].sum() / df['수신'].sum():.1%} (목표 0%), 손상 타일은 원본 보관 후 재요청\")\n", + "\n", + "# 메모리 안정성: 같은 배치(100장)를 5회 반복 처리하며 추적 메모리 변화 확인\n", + "batch = workload(100, seed=99)\n", + "tracemalloc.start(); mem = []\n", + "for _ in range(5):\n", + " process(batch, 1e9, {\"전처리\": 0, \"특징\": 0, \"분류\": 0}); mem.append(tracemalloc.get_traced_memory()[0] / 1e6)\n", + "tracemalloc.stop()\n", + "print(\"메모리(동일 배치 5회, MB):\", [round(m, 2) for m in mem], f\"-> 증가 {mem[-1] - mem[0]:.2f} MB (누수 판단 기준 5MB)\")\n", + "tot = sum(prof.values())\n", + "print(\"병목 구간(누적 처리시간 비율):\", {k: f\"{v / tot:.0%}\" for k, v in sorted(prof.items(), key=lambda x: -x[1])})\n", + "thr = df.loc[1, \"처리량(장/초)\"]\n", + "area_km2 = (N * STD_GSD / 1000) ** 2\n", + "print(f\"처리 한계치 환산: 단일 코어 {thr}장/초 = 시간당 약 {thr * 3600 * area_km2:,.0f} km2 (타일 1장 {area_km2 * 1e6:.0f} m2, 표준 해상도 0.5m 기준)\")\n", + "\n", + "plt.figure(figsize=(8, 4))\n", + "plt.bar(range(len(df)), df[\"처리량(장/초)\"], color=[\"tab:red\" if m != \"-\" else \"tab:blue\" for m in df[\"성능저하 모드\"]])\n", + "plt.xticks(range(len(df)), [f\"case{i + 1}\" for i in range(len(df))]); plt.ylabel(\"tiles / s\")\n", + "plt.title(\"Tile batch stress test (red = degraded mode)\"); plt.show()\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 08 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + " 상황 수신 처리 격리 유실 다운 처리량(장/초) 성능저하 모드\n", + " 평시 100장(기준) 100 100 0 0 0 525 -\n", + " 500% 증가(600장) 600 600 0 0 0 494 -\n", + " 600장 악기상·저해상도 600 600 0 0 0 464 -\n", + "600장 + 손상 타일 5% 600 569 31 0 0 546 -\n", + "600장 + 처리 예산 부족 600 600 0 0 0 561 정밀분류 지연 111장\n", + "\n", + "시스템 다운 0건, 데이터 유실률 0.0% (목표 0%), 손상 타일은 원본 보관 후 재요청\n", + "메모리(동일 배치 5회, MB): [0.02, 0.03, 0.04, 0.05, 0.06] -> 증가 0.04 MB (누수 판단 기준 5MB)\n", + "병목 구간(누적 처리시간 비율): {'전처리': '52%', '특징': '43%', '분류': '5%'}\n", + "처리 한계치 환산: 단일 코어 494장/초 = 시간당 약 455 km2 (타일 1장 256 m2, 표준 해상도 0.5m 기준)\n", + "```\n", + "
\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 09. AAR 기반 모의 위장 표적 오판독 사례 분석 및 객체분류 모델 파라미터 재보정\n", + "**소요시간**: 약 3.0시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "AAR 사후검토를 통해 모의 위장 장비 오판독 사례를 분석하고 비전 모델의 특징 추출 가중치와 임계값을 재보정한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **AAR 사후검토로 모의 위장 표적 오판독 사례를 분석하고 분류 모델의 특징 가중치와 판정 임계값을 재보정한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **모의 위장 표적이 섞인 판독 훈련 결과를 AAR 일지로 정리하고 오판독을 원인(모의 표적 오판독, 기종 혼동과 조건)별로 분류 수행하고 검증하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `docs/isr01_aar_report.md 및 configs/isr01_tuned_params.yaml` 형태의 결과물을 완성한다.\n", + "\n", + "### 🔍 평가 및 검증 기준 (PASS 조건)\n", + "#### 미션 판정\n", + "\n", + "**평가 구분:** 필수\n", + "\n", + "**핵심 평가영역:** 오류 원인 규명의 객관성 및 개선 조치의 논리적 타당성\n", + "\n", + "#### 미션 PASS 조건\n", + "\n", + "- 정량 기준: AAR 피드백 적용 후 종합 정확도 10%p 이상 향상, 재발 오류 0건\n", + "\n", + "\n", + "- 진행 기준: AAR 보고서 작성 및 파라미터 튜닝 전후 회귀 테스트 통과\n", + "\n", + "\n", + "- 검증 방법: 회귀 테스트(Regression Test) 스위트 전체 자동 실행 및 비교\n", + "\n", + "\n", + "#### 채점자 판정 메모\n", + "\n", + "> 단순 코드 실행 여부뿐만 아니라 출력된 수치와 근거 자료가 군 실무 요구사항에 부합하는지 면밀히 확인한다.\n", + "\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "\"\"\"AAR(사후검토) 일지 형식으로 모의 위장 표적(decoy)이 섞인 판독 훈련 결과의 오판독 사례를 원인별로 분류하고, 특징 가중치(모의 표적 학습 추가)와 SAR-광학 일치성 임계값을 재보정해 종합 정확도 향상과 재발 여부를 회귀 시험으로 확인한다.\"\"\"\n", + "import numpy as np\n", + "import pandas as pd\n", + "import matplotlib.pyplot as plt\n", + "from scipy import ndimage\n", + "from sklearn.ensemble import RandomForestClassifier\n", + "\n", + "# ---------------------------------------------------------------------------\n", + "# [교육용가상자료] 합성 위성영상 조각(chip) 생성기 (실제 위성영상·장비 제원·위치 없음)\n", + "# 32x32 광학(EO) 영상과 SAR 영상 한 쌍에 추상 도형으로 만든 객체를 그린다. 도형 치수는 가상값이다.\n", + "# ---------------------------------------------------------------------------\n", + "N = 32\n", + "STD_GSD = 0.5 # 표준 해상도(m/픽셀, 가상 기준)\n", + "CLASSES = [\"tank\", \"spg\", \"truck\", \"civil\"]\n", + "MILITARY = {\"tank\", \"spg\", \"truck\"}\n", + "# 객체 좌표계(u: 길이 방향, v: 폭 방향, 표준 해상도 픽셀) 가상 도형: (u중심, v중심, 길이, 폭, 광학 밝기 차이)\n", + "SHAPES = {\n", + " \"tank\": [(0, 0, 11, 7, -40), (0.5, 0, 5, 5, -55), (6, 0, 7, 1.2, -60)],\n", + " \"spg\": [(0, 0, 16, 7.5, -38), (-4, 0, 6, 6.5, -52), (5, 0, 13, 1.4, -60)],\n", + " \"truck\": [(5, 0, 3.5, 5.5, -45), (-1.8, 0, 8.5, 6, -25)],\n", + " \"civil\": [(0, 0, 8, 3.8, 0), (1.2, 0, 2, 3.4, 0)],\n", + " \"decoy\": [(0, 0, 11, 7, -40), (0.5, 0, 5, 5, -42), (6, 0, 7, 1.2, -45)],\n", + " \"none\": [], # 객체 없는 배경 타일\n", + "}\n", + "SAR_RETURN = {\"tank\": 0.85, \"spg\": 0.85, \"truck\": 0.7, \"civil\": 0.45, \"decoy\": 0.18, \"none\": 0.12} # 금속 반사 세기(가상)\n", + "\n", + "\n", + "def _mask(cls, angle, scale, squash=1.0):\n", + " \"\"\"회전(angle, 도)·축척(scale)·비스듬 촬영 압축(squash)을 적용해 도형별 마스크와 밝기 차이를 만든다.\"\"\"\n", + " # [TODO : 학생 실습 9] AAR 기반 모의 위장 표적 오판독 사례 분석 및 객체분류 모델 파라미터 재보정 관련 핵심 로직을 구현하세요.\n", + " # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n", + " # <<< 여기에 코드를 작성하세요 >>>\n", + " pass # 작성 후 pass를 제거하세요.\n", + "def make_chip(cls, rng, cond=None):\n", + " \"\"\"chip 1개 = (광학 32x32, SAR 32x32, 메타데이터). cond: camo, occl, cloud, low_sun, off_nadir, low_res, speckle\"\"\"\n", + " c = {\"camo\": 0.0, \"occl\": 0.0, \"cloud\": 0.0, \"low_sun\": False, \"off_nadir\": 0.0, \"low_res\": False, \"speckle\": 1.0, **(cond or {})}\n", + " gsd = rng.uniform(0.9, 1.2) if c[\"low_res\"] else rng.uniform(0.4, 0.6)\n", + " scale = STD_GSD / gsd\n", + " angle, off = rng.uniform(0, 360), c[\"off_nadir\"] or rng.uniform(0, 15)\n", + " squash = np.cos(np.radians(off))\n", + " sun_el = rng.uniform(12, 22) if c[\"low_sun\"] else rng.uniform(40, 70)\n", + " sun_az = rng.uniform(0, 360)\n", + " bg = 115 + ndimage.gaussian_filter(rng.normal(0, 30, (N, N)), 2) + rng.normal(0, 5, (N, N))\n", + " parts = _mask(cls, angle, scale, squash)\n", + " body = np.any([m for m, _ in parts], axis=0) if parts else np.zeros((N, N), bool)\n", + " opt = bg.copy()\n", + " # 그림자: 태양 반대 방향으로 밀린 객체 모양(태양 고도가 낮을수록 길다)\n", + " L = min(8.0, 2.5 / np.tan(np.radians(sun_el))) * scale\n", + " sh = ndimage.shift(body.astype(float), (L * np.cos(np.radians(sun_az)), -L * np.sin(np.radians(sun_az))), order=0) > 0.5\n", + " opt[sh & ~body] -= 35 if cls != \"decoy\" else 25\n", + " if cls == \"civil\":\n", + " base = rng.choice([200.0, 60.0]) # 민간 차량 색상: 밝거나 어두움\n", + " opt[body] = base + rng.normal(0, 8, body.sum())\n", + " roof = parts[1][0]\n", + " opt[roof] = base * 0.8\n", + " else:\n", + " ref = bg[body].mean() if body.any() else bg.mean()\n", + " for m, d in parts:\n", + " opt[m] = ref + d + rng.normal(0, 4 if cls != \"decoy\" else 2, m.sum())\n", + " # 위장막: 객체 주변을 배경 질감으로 덮어 대비를 낮춘다\n", + " if c[\"camo\"] > 0 and cls != \"civil\":\n", + " net = ndimage.binary_dilation(body, iterations=2)\n", + " tex = 105 + ndimage.gaussian_filter(rng.normal(0, 25, (N, N)), 1)\n", + " opt[net] = (1 - c[\"camo\"]) * opt[net] + c[\"camo\"] * tex[net]\n", + " # 수목 차폐: 객체의 일부를 어두운 나무 덩어리로 가린다\n", + " if c[\"occl\"] > 0:\n", + " trees = ndimage.gaussian_filter(rng.random((N, N)), 2)\n", + " near = ndimage.binary_dilation(body, iterations=3)\n", + " cover = near & (trees > np.quantile(trees[body], 1 - c[\"occl\"])) if body.any() else np.zeros((N, N), bool)\n", + " opt[cover] = 70 + rng.normal(0, 6, cover.sum())\n", + " # 구름: 밝고 매끈한 덩어리(광학만 가림, SAR는 통과)\n", + " cmask = np.zeros((N, N), bool)\n", + " if c[\"cloud\"] > 0:\n", + " blob = ndimage.gaussian_filter(rng.random((N, N)), 4)\n", + " cmask = blob > np.quantile(blob, 1 - c[\"cloud\"])\n", + " opt[cmask] = 0.25 * opt[cmask] + 0.75 * (235 + rng.normal(0, 3, cmask.sum()))\n", + " # SAR: 곱셈형 스페클 잡음 + 금속 반사(모서리 이중 반사로 밝은 점)\n", + " sar = np.full((N, N), 0.12)\n", + " sar[body] = SAR_RETURN[cls] * rng.uniform(0.8, 1.2)\n", + " edge = body & ~ndimage.binary_erosion(body)\n", + " sar[edge] *= 1.3 if cls in MILITARY else 1.0\n", + " sar = sar * rng.gamma(4 / c[\"speckle\"], c[\"speckle\"] / 4, (N, N))\n", + " if c[\"low_res\"]: # 저해상도: 정보 손실(블러)\n", + " down = lambda a: np.kron(a.reshape(N // 2, 2, N // 2, 2).mean((1, 3)), np.ones((2, 2)))\n", + " opt, sar = ndimage.gaussian_filter(down(opt), 0.7), ndimage.gaussian_filter(down(sar), 0.7)\n", + " meta = {\"cls\": cls, \"gsd\": round(float(gsd), 2), \"off_nadir\": round(float(off), 1), \"sun_el\": round(float(sun_el), 1),\n", + " \"sun_az\": round(float(sun_az), 1), \"angle\": round(float(angle), 1), \"cloud_frac\": float(cmask.mean()),\n", + " \"cloud_on_obj\": float((cmask & body).sum() / max(body.sum(), 1)), **{k: c[k] for k in (\"camo\", \"occl\", \"low_sun\", \"low_res\")}}\n", + " return np.clip(opt, 0, 255), sar, meta\n", + "\n", + "\n", + "SCEN = { # 은폐·촬영 조건 시나리오 10종\n", + " \"clean\": {}, \"camo\": {\"camo\": 0.7}, \"occl30\": {\"occl\": 0.3}, \"occl50\": {\"occl\": 0.5}, \"cloud\": {\"cloud\": 0.25},\n", + " \"low_sun\": {\"low_sun\": True}, \"off_nadir\": {\"off_nadir\": 35.0}, \"low_res\": {\"low_res\": True},\n", + " \"speckle\": {\"speckle\": 3.0}, \"camo_occl\": {\"camo\": 0.6, \"occl\": 0.3}}\n", + "\n", + "\n", + "def make_dataset(n=400, seed=7, scen=(\"clean\",), classes=CLASSES):\n", + " rng = np.random.default_rng(seed)\n", + " O, S, M = np.zeros((n, N, N)), np.zeros((n, N, N)), []\n", + " for i in range(n):\n", + " cls, sc = classes[i % len(classes)], scen[(i // len(classes)) % len(scen)]\n", + " O[i], S[i], m = make_chip(cls, rng, SCEN[sc])\n", + " m.update({\"id\": f\"IMG{i:04d}\", \"scen\": sc})\n", + " M.append(m)\n", + " return O, S, M\n", + "# ---------------------------------------------------------------------------\n", + "\n", + "# ---- 전처리(해상도·기하 보정, 구름 차폐 처리, 방위 정규화) 공통 함수 ----\n", + "def _fit(a, fill):\n", + " \"\"\"중앙 기준으로 32x32에 맞게 자르거나 채운다.\"\"\"\n", + " # [TODO : 학생 실습 9] AAR 기반 모의 위장 표적 오판독 사례 분석 및 객체분류 모델 파라미터 재보정 관련 핵심 로직을 구현하세요.\n", + " # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n", + " # <<< 여기에 코드를 작성하세요 >>>\n", + " pass # 작성 후 pass를 제거하세요.\n", + "def cloud_mask(opt, sm, min_area=15):\n", + " \"\"\"광학에서 밝고(185 초과) 매끈한데 SAR에는 반사가 없는 영역을 구름으로 본다(SAR는 구름을 통과하므로 밝은 차량과 구분된다).\"\"\"\n", + " bright = opt > 185\n", + " if bright.sum() < min_area:\n", + " return np.zeros_like(bright)\n", + " local_sd = np.sqrt(np.maximum(ndimage.uniform_filter(opt ** 2, 3) - ndimage.uniform_filter(opt, 3) ** 2, 0))\n", + " cand = bright & (local_sd < 12) & (sm < 0.3)\n", + " lab, n = ndimage.label(cand)\n", + " if n == 0:\n", + " return cand\n", + " sizes = ndimage.sum(cand.astype(float), lab, range(1, n + 1))\n", + " return ndimage.binary_dilation(np.isin(lab, 1 + np.where(sizes >= min_area)[0]), iterations=2) & (opt > 140)\n", + "\n", + "\n", + "def orientation(opt, sm):\n", + " \"\"\"SAR 반사(위장·구름 영향 적음, sm은 평활화한 SAR)와 광학 대비를 합친 객체 마스크의 주축 방향(도).\"\"\"\n", + " m = (sm > 0.3) | (np.abs(opt - opt.mean()) > 30)\n", + " lab, n = ndimage.label(m)\n", + " if n == 0:\n", + " return 0.0, m\n", + " c = lab[N // 2 - 6:N // 2 + 6, N // 2 - 6:N // 2 + 6]\n", + " k = np.bincount(c[c > 0]).argmax() if (c > 0).any() else 1 + int(np.argmax(ndimage.sum(m.astype(float), lab, range(1, n + 1))))\n", + " m = lab == k\n", + " yy, xx = np.nonzero(m)\n", + " if len(yy) < 5:\n", + " return 0.0, m\n", + " dy, dx = yy - yy.mean(), xx - xx.mean() # 2차 모멘트로 주축 각도 계산\n", + " return float(np.degrees(0.5 * np.arctan2(2 * (dx * dy).mean(), (dx * dx).mean() - (dy * dy).mean()))), m\n", + "\n", + "\n", + "_YY, _XX = np.mgrid[0:N, 0:N] - (N - 1) / 2\n", + "\n", + "\n", + "def warp(stack, zy, zx, ang):\n", + " \"\"\"여러 장(B,채널,32,32)에 장별 확대·축소(zy, zx)와 회전(ang, 도)을 한 번의 쌍선형 보간으로 적용한다(벡터 연산).\"\"\"\n", + " a = np.radians(ang)[:, None, None]\n", + " zy, zx = np.asarray(zy)[:, None, None], np.asarray(zx)[:, None, None]\n", + " sy = np.clip((np.cos(a) * _YY + np.sin(a) * _XX) / zy + (N - 1) / 2, 0, N - 1.001) # 출력 좌표 -> 원본 좌표\n", + " sx = np.clip((-np.sin(a) * _YY + np.cos(a) * _XX) / zx + (N - 1) / 2, 0, N - 1.001)\n", + " y0, x0 = sy.astype(int), sx.astype(int)\n", + " fy, fx = (sy - y0).reshape(len(stack), 1, -1), (sx - x0).reshape(len(stack), 1, -1)\n", + " flat = stack.reshape(len(stack), stack.shape[1], -1)\n", + " lin = (y0 * N + x0).reshape(len(stack), 1, -1)\n", + " g = lambda d: np.take_along_axis(flat, np.broadcast_to(lin + d, (len(stack), stack.shape[1], lin.shape[2])), axis=2)\n", + " out = g(0) * (1 - fy) * (1 - fx) + g(N) * fy * (1 - fx) + g(1) * (1 - fy) * fx + g(N + 1) * fy * fx\n", + " return out.reshape(stack.shape)\n", + "\n", + "\n", + "def preprocess_batch(O, S, M):\n", + " \"\"\"여러 장을 한 번에 전처리한다. 0) 결측 보정 1) 해상도·비스듬 촬영 보정(표준 0.5m/픽셀)\n", + " 2) 구름 차폐 영역을 배경값으로 채움 3) 객체 주축을 가로로 회전 4) 0~1 정규화.\"\"\"\n", + " src = np.stack([np.asarray(O, float), np.asarray(S, float)], axis=1)\n", + " if np.isnan(src).any(): # 0) 센서 결측 픽셀 보정\n", + " mean = np.nanmean(src, axis=(2, 3), keepdims=True)\n", + " src = np.where(np.isnan(src), mean, src)\n", + " zy = np.array([m[\"gsd\"] / STD_GSD / np.cos(np.radians(m[\"off_nadir\"])) for m in M])\n", + " zx = np.array([m[\"gsd\"] / STD_GSD for m in M])\n", + " out = warp(src, zy, zx, np.zeros(len(M)))\n", + " sm = ndimage.uniform_filter(out[:, 1], (1, 3, 3))\n", + " angs, cloud = np.zeros(len(M)), np.zeros(len(M))\n", + " for i in range(len(M)):\n", + " cm = cloud_mask(out[i, 0], sm[i])\n", + " if cm.any():\n", + " fill = out[i, 0][~cm].mean() if (~cm).any() else 115.0\n", + " out[i, 0][cm] = fill\n", + " back = warp(cm[None, None].astype(float), [1 / zy[i]], [1 / zx[i]], np.zeros(1))[0, 0] > 0.5\n", + " src[i, 0][back] = fill # 원본의 같은 구름 영역도 채움\n", + " cloud[i] = cm.mean()\n", + " angs[i] = orientation(out[i, 0], sm[i])[0]\n", + " rot = np.abs(angs) > 3 # 3도 이하는 회전 생략\n", + " if rot.any():\n", + " out[rot] = warp(src[rot], zy[rot], zx[rot], angs[rot])\n", + " return [(np.clip(o / 255, 0, 1), np.clip(s / 1.5, 0, 1), float(c)) for (o, s), c in zip(out, cloud)]\n", + "\n", + "\n", + "def preprocess(opt, sar, meta):\n", + " \"\"\"한 장 전처리(preprocess_batch와 같은 처리).\"\"\"\n", + " return preprocess_batch([opt], [sar], [meta])[0]\n", + "\n", + "# ---- 특징 추출 공통 함수 ----\n", + "KERNELS = [np.array(k, float) for k in (\n", + " [[-1, 0, 1], [-2, 0, 2], [-1, 0, 1]], [[-1, -2, -1], [0, 0, 0], [1, 2, 1]], [[0, 1, 0], [1, -4, 1], [0, 1, 0]],\n", + " [[2, -1, -1], [-1, 2, -1], [-1, -1, 2]], [[-1, -1, 2], [-1, 2, -1], [2, -1, -1]], [[1, 1, 1], [1, 1, 1], [1, 1, 1]])]\n", + "\n", + "\n", + "def conv_features(o):\n", + " \"\"\"간이 CNN 특징: 고정 합성곱 필터 6종 -> ReLU -> 4x4 최대 풀링(8x8 출력).\"\"\"\n", + " feats = []\n", + " for k in KERNELS:\n", + " r = np.maximum(ndimage.correlate(o - o.mean(), k / np.abs(k).sum()), 0)\n", + " feats.append(r.reshape(8, 4, 8, 4).max(axis=(1, 3)).ravel())\n", + " feats.append(np.maximum(-ndimage.correlate(o - o.mean(), k / np.abs(k).sum()), 0).reshape(8, 4, 8, 4).max(axis=(1, 3)).ravel())\n", + " return np.concatenate(feats)\n", + "\n", + "\n", + "def hog(o, cell=8, bins=8):\n", + " gy, gx = np.gradient(o)\n", + " mag, ang = np.hypot(gx, gy), np.mod(np.arctan2(gy, gx), np.pi)\n", + " b = np.minimum((ang / np.pi * bins).astype(int), bins - 1)\n", + " out = np.zeros((N // cell, N // cell, bins))\n", + " for i in range(N // cell):\n", + " for j in range(N // cell):\n", + " sl = (slice(i * cell, (i + 1) * cell), slice(j * cell, (j + 1) * cell))\n", + " out[i, j] = np.bincount(b[sl].ravel(), mag[sl].ravel(), bins)\n", + " return (out / (np.linalg.norm(out) + 1e-6)).ravel()\n", + "\n", + "\n", + "def shape_features(o, s):\n", + " \"\"\"객체 모양 특징: 가로 방향 투영으로 전체 길이·차체 길이·가는 돌출부(포신) 길이·두꺼운 부분(포탑) 위치를 잰다.\n", + " 방위 정규화 뒤에도 앞뒤(180도)가 뒤집힐 수 있으므로, 가는 돌출부가 오른쪽에 오도록 맞춘다.\"\"\"\n", + " sm = ndimage.uniform_filter(s, 3)\n", + " dark = np.abs(o - np.median(o))\n", + " m = (sm > 0.22) | (dark > 0.12)\n", + " lab, n = ndimage.label(m)\n", + " if n:\n", + " c = lab[10:22, 8:24]\n", + " k = np.bincount(c[c > 0]).argmax() if (c > 0).any() else 1\n", + " m = lab == k\n", + " p = m.sum(axis=0).astype(float)\n", + " cols = np.nonzero(p)[0]\n", + " if len(cols) == 0:\n", + " return np.zeros(8), o, s\n", + " thin = (p > 0) & (p <= 2)\n", + " left, right = thin[:N // 2].sum(), thin[N // 2:].sum()\n", + " if left > right:\n", + " o, s, m, p = o[:, ::-1], s[:, ::-1], m[:, ::-1], p[::-1]\n", + " cols = np.nonzero(p)[0]\n", + " body = np.nonzero(p > 3)[0]\n", + " d = np.where(m, dark if left <= right else dark[:, ::-1], 0)\n", + " heavy = d.sum(axis=0)\n", + " centre = (body[0] + body[-1]) / 2 if len(body) else N / 2\n", + " turret = (heavy * np.arange(N)).sum() / max(heavy.sum(), 1e-6) - centre\n", + " return np.array([cols[-1] - cols[0] + 1, len(body), ((p > 0) & (p <= 2)).sum(), p.max(), turret,\n", + " m.sum(), (cols[-1] - (body[-1] if len(body) else cols[-1])), m.sum() / max(len(cols) * p.max(), 1)]), o, s\n", + "\n", + "\n", + "def adv_features(o, s):\n", + " \"\"\"정밀 특징: 모양(길이·포신·포탑 위치) + HOG(윤곽) + SAR 반사 통계(위장막·구름 영향 적음) + 투영 윤곽.\"\"\"\n", + " shp, o, s = shape_features(o, s)\n", + " c = s[8:24, 4:28]\n", + " sm = ndimage.uniform_filter(s, 3)\n", + " m = sm > 0.2\n", + " dark = np.abs(o - np.median(o))\n", + " return np.concatenate([shp, hog(o), [c.mean(), c.max(), (sm > 0.25).mean(), (sm > 0.4).mean(), m.sum(axis=1).max(), m.sum(axis=0).max()],\n", + " sm.mean(axis=0).reshape(8, 4).mean(1), sm.mean(axis=1).reshape(8, 4).mean(1),\n", + " dark.mean(axis=0).reshape(8, 4).mean(1), dark.mean(axis=1).reshape(8, 4).mean(1),\n", + " [o.mean(), o.std(), dark.max(), (o > 0.65).mean(), (o < 0.3).mean()]])\n", + "\n", + "EX_SCEN = (\"clean\", \"camo\", \"occl30\", \"low_sun\", \"cloud\")\n", + "\n", + "\n", + "def featurize(O, S, M):\n", + " P = preprocess_batch(O, S, M)\n", + " return np.array([adv_features(o, s) for o, s, _ in P]), np.array([p[1][8:24, 4:28].mean() for p in P])\n", + "\n", + "\n", + "def train(with_decoy, seed=3):\n", + " cls = CLASSES + ([\"decoy\"] if with_decoy else [])\n", + " O, S, M = make_dataset(250 * len(cls), seed=seed, scen=tuple(SCEN), classes=cls)\n", + " X, _ = featurize(O, S, M)\n", + " return RandomForestClassifier(n_estimators=150, min_samples_leaf=2, random_state=0, n_jobs=1).fit(X, [m[\"cls\"] for m in M])\n", + "\n", + "\n", + "def decide(model, X, sar, tau):\n", + " \"\"\"분류 결과 + SAR-광학 일치성 규칙(선택): 전차·자주포로 분류했는데 SAR 금속 반사가 tau 미만이면 '모의 표적 의심'.\"\"\"\n", + " p = model.predict(X).astype(object)\n", + " if tau is not None:\n", + " p[np.isin(p, [\"tank\", \"spg\"]) & (sar < tau)] = \"decoy\"\n", + " return p\n", + "\n", + "\n", + "def aar_log(y, p, sc, ids):\n", + " rows = []\n", + " for yi, pi, s, i in zip(y, p, sc, ids):\n", + " if yi == pi:\n", + " continue\n", + " if yi == \"decoy\":\n", + " cause = f\"모의 표적을 {pi}(으)로 오판독\"\n", + " elif pi == \"decoy\":\n", + " cause = f\"실제 {yi}를 모의 표적으로 오판\"\n", + " else:\n", + " cause = f\"{yi}->{pi} 기종 혼동({s})\"\n", + " rows.append({\"원인\": cause, \"영상\": i})\n", + " return pd.DataFrame(rows, columns=[\"원인\", \"영상\"])\n", + "\n", + "\n", + "# 모의 판독 훈련 자료(모의 위장 표적 20% 포함): AAR 대상 500장 + 회귀 시험 500장\n", + "Oa, Sa, Ma = make_dataset(500, seed=21, scen=EX_SCEN, classes=CLASSES + [\"decoy\"])\n", + "Ob, Sb, Mb = make_dataset(500, seed=22, scen=EX_SCEN, classes=CLASSES + [\"decoy\"])\n", + "Xa, sar_a = featurize(Oa, Sa, Ma); ya, sca, ida = np.array([m[\"cls\"] for m in Ma]), [m[\"scen\"] for m in Ma], [m[\"id\"] for m in Ma]\n", + "Xb, sar_b = featurize(Ob, Sb, Mb); yb = np.array([m[\"cls\"] for m in Mb])\n", + "\n", + "# 1) 초기 설정: 모의 표적을 학습하지 않은 모델, 일치성 규칙 없음\n", + "m0 = train(False)\n", + "p0 = decide(m0, Xa, sar_a, None)\n", + "acc0 = (p0 == ya).mean()\n", + "log0 = aar_log(ya, p0, sca, ida)\n", + "print(f\"[1. 무엇이 일어났나] 초기 종합 정확도 {acc0:.3f}, 오판독 {len(log0)}건\")\n", + "print(log0[\"원인\"].value_counts().head(6).to_string())\n", + "print(f\"\\n[2. 왜 일어났나] 모의 표적 SAR 반사 평균 {sar_a[ya == 'decoy'].mean():.3f} vs 실제 전차·자주포 {sar_a[np.isin(ya, ['tank', 'spg'])].mean():.3f}\"\n", + " \" -> 광학 윤곽은 전차와 같지만 금속 반사가 약해, 모의 표적을 모르는 모델은 반사가 약한 민간 차량으로 몰아 버린다.\")\n", + "\n", + "# 2) 재보정: (모의 표적 학습 여부) x (SAR 일치성 임계값) 격자 탐색\n", + "m1 = train(True)\n", + "grid = []\n", + "for name, mdl in [(\"모의표적 미학습\", m0), (\"모의표적 학습\", m1)]:\n", + " for tau in [None, 0.12, 0.15, 0.18, 0.22]:\n", + " p = decide(mdl, Xa, sar_a, tau)\n", + " mil = np.isin(ya, list(MILITARY))\n", + " grid.append({\"모델\": name, \"SAR 임계값\": \"-\" if tau is None else tau, \"종합정확도\": round(float((p == ya).mean()), 3),\n", + " \"모의표적 적발률\": round(float((p[ya == \"decoy\"] == \"decoy\").mean()), 3),\n", + " \"실제 장비를 모의로 오판\": int((mil & (p == \"decoy\")).sum())})\n", + "g = pd.DataFrame(grid).sort_values([\"종합정확도\", \"실제 장비를 모의로 오판\"], ascending=[False, True])\n", + "print(\"\\n[3. 무엇을 바꾸나] 격자 탐색 상위 5개\")\n", + "print(g.head().to_string(index=False))\n", + "best = g.iloc[0]\n", + "mdl = m1 if best[\"모델\"] == \"모의표적 학습\" else m0\n", + "tau = None if best[\"SAR 임계값\"] == \"-\" else float(best[\"SAR 임계값\"])\n", + "p1 = decide(mdl, Xa, sar_a, tau)\n", + "acc1 = (p1 == ya).mean()\n", + "print(f\"\\n재보정값: {best['모델']}, SAR 일치성 임계값 {best['SAR 임계값']}\")\n", + "print(f\"종합 정확도 {acc0:.3f} -> {acc1:.3f} ({(acc1 - acc0) * 100:+.1f}%p, 목표 +10%p: {'달성' if acc1 - acc0 >= 0.1 else '미달'})\")\n", + "b0, b1 = (decide(m0, Xb, sar_b, None) == yb).mean(), (decide(mdl, Xb, sar_b, tau) == yb).mean()\n", + "print(f\"회귀 시험(별도 500장): {b0:.3f} -> {b1:.3f}\")\n", + "log1 = aar_log(ya, p1, sca, ida)\n", + "rep = set(log0[\"영상\"]) & set(log1[\"영상\"])\n", + "print(f\"재보정 후 남은 오판독 {len(log1)}건, 그중 재발 {len(rep)}건:\", log1[log1[\"영상\"].isin(rep)][\"원인\"].value_counts().head(3).to_dict())\n", + "print(\"SAR 일치성 규칙은 모의 표적 학습 후 추가 이득이 없고 임계값을 높이면 실제 장비를 모의 표적으로 오판하므로 적용하지 않는다(-).\")\n", + "print(\"[4. 남은 과제] 재발 건은 위장막 아래 기종 혼동으로, 다른 촬영 회차 교차 판독(미션 7)과 지속 학습 대상 등록으로 대응한다.\")\n", + "\n", + "tuned = f\"model:\\n train_with_decoy: {best['모델'] == '모의표적 학습'}\\n rf_trees: 150\\nrules:\\n sar_consistency_tau: {best['SAR 임계값']}\\nconfidence:\\n high: 0.9\\n medium: 0.6\"\n", + "print(\"\\n[재보정 파라미터(isr01_tuned_params.yaml 내용)]\\n\" + tuned)\n", + "\n", + "plt.figure(figsize=(7, 4))\n", + "plt.hist([sar_a[np.isin(ya, [\"tank\", \"spg\"])], sar_a[ya == \"decoy\"]], bins=25, label=[\"tank/spg\", \"decoy\"], alpha=.8)\n", + "if tau:\n", + " plt.axvline(tau, ls=\"--\", c=\"k\", label=f\"tau={tau}\")\n", + "plt.xlabel(\"mean SAR return (center)\"); plt.ylabel(\"chips\"); plt.legend(); plt.title(\"SAR-optical consistency for decoy screening\")\n", + "plt.show()\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 09 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + "[1. 무엇이 일어났나] 초기 종합 정확도 0.786, 오판독 107건\n", + "원인\n", + "모의 표적을 civil(으)로 오판독 100\n", + "tank->truck 기종 혼동(camo) 4\n", + "truck->tank 기종 혼동(cloud) 1\n", + "tank->truck 기종 혼동(clean) 1\n", + "tank->truck 기종 혼동(cloud) 1\n", + "\n", + "[2. 왜 일어났나] 모의 표적 SAR 반사 평균 0.089 vs 실제 전차·자주포 0.226 -> 광학 윤곽은 전차와 같지만 금속 반사가 약해, 모의 표적을 모르는 모델은 반사가 약한 민간 차량으로 몰아 버린다.\n", + "\n", + "[3. 무엇을 바꾸나] 격자 탐색 상위 5개\n", + " 모델 SAR 임계값 종합정확도 모의표적 적발률 실제 장비를 모의로 오판\n", + "모의표적 학습 - 0.990 1.0 0\n", + "모의표적 학습 0.12 0.990 1.0 0\n", + "모의표적 학습 0.15 0.990 1.0 0\n", + "모의표적 학습 0.18 0.960 1.0 16\n", + "모의표적 학습 0.22 0.788 1.0 102\n", + "\n", + "재보정값: 모의표적 학습, SAR 일치성 임계값 -\n", + "종합 정확도 0.786 -> 0.990 (+20.4%p, 목표 +10%p: 달성)\n", + "회귀 시험(별도 500장): 0.790 -> 0.984\n", + "재보정 후 남은 오판독 5건, 그중 재발 5건: {'tank->truck 기종 혼동(camo)': 4, 'truck->tank 기종 혼동(cloud)': 1}\n", + "SAR 일치성 규칙은 모의 표적 학습 후 추가 이득이 없고 임계값을 높이면 실제 장비를 모의 표적으로 오판하므로 적용하지 않는다(-).\n", + "[4. 남은 과제] 재발 건은 위장막 아래 기종 혼동으로, 다른 촬영 회차 교차 판독(미션 7)과 지속 학습 대상 등록으로 대응한다.\n", + "\n", + "[재보정 파라미터(isr01_tuned_params.yaml 내용)]\n", + "model:\n", + " train_with_decoy: True\n", + " rf_trees: 150\n", + "rules:\n", + " sar_consistency_tau: -\n", + "confidence:\n", + " high: 0.9\n", + " medium: 0.6\n", + "```\n", + "
\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 10. 공개 위성영상 장비 객체분류 시스템 통합 및 전략 ISR 평가 브리핑 패키지 완성\n", + "**소요시간**: 약 3.5시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "공개 위성영상 장비 객체분류 시스템을 최종 통합 검증하고 전략 정보 판단용 브리핑 패키지를 완성한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **메타데이터 검증부터 전처리, 정밀 분류, 신뢰도 엔진, 판독 대기열, 분석 화면까지 시스템을 통합 검증하고 전략 ISR 평가 브리핑 패키지를 완성한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **재보정 설정으로 전체 과정을 한 번에 실행하는 통합 코드를 완성하고 손상 타일과 메타데이터 오류가 자동 차단되는지 확인하고 유효성을 점검하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `src/main_isr01.py, README.md, docs/isr01_final_report.pdf(md)` 형태의 결과물을 완성한다.\n", + "\n", + "### 🔍 평가 및 검증 기준 (PASS 조건)\n", + "#### 미션 판정\n", + "\n", + "**평가 구분:** 필수\n", + "\n", + "**핵심 평가영역:** 군 실무 즉시 도입 가능성, 운용 신뢰도 및 전략적 기대효과\n", + "\n", + "#### 미션 PASS 조건\n", + "\n", + "- 정량 기준: End-to-End 통합 파이프라인 실행 성공률 100%, 테스트 커버리지 85% 이상\n", + "\n", + "\n", + "- 진행 기준: 원클릭 실행 스크립트 동작 확인 및 최종 발표 산출물 작성 완료\n", + "\n", + "\n", + "- 검증 방법: 최종 검증 위원회(평가관) 모의 시연 및 전체 기능 통합 검증\n", + "\n", + "\n", + "#### 채점자 판정 메모\n", + "\n", + "> 단순 코드 실행 여부뿐만 아니라 출력된 수치와 근거 자료가 군 실무 요구사항에 부합하는지 면밀히 확인한다.\n", + "\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "\"\"\"메타데이터 검증·전처리·정밀 분류(모의 표적 포함)·신뢰도 엔진·영상분석관 판독 대기열·분석 화면까지 공개 위성영상 장비 객체분류 시스템 전체를 한 파일로 통합 실행하고, 자체 점검(기능 커버리지 포함)과 전략 ISR 평가 브리핑 요약을 출력한다.\"\"\"\n", + "import hashlib\n", + "import json\n", + "import time\n", + "import numpy as np\n", + "import pandas as pd\n", + "import matplotlib.pyplot as plt\n", + "from scipy import ndimage\n", + "from sklearn.ensemble import RandomForestClassifier\n", + "from sklearn.linear_model import LogisticRegression\n", + "from sklearn.pipeline import make_pipeline\n", + "from sklearn.preprocessing import StandardScaler\n", + "\n", + "# ---------------------------------------------------------------------------\n", + "# [교육용가상자료] 합성 위성영상 조각(chip) 생성기 (실제 위성영상·장비 제원·위치 없음)\n", + "# 32x32 광학(EO) 영상과 SAR 영상 한 쌍에 추상 도형으로 만든 객체를 그린다. 도형 치수는 가상값이다.\n", + "# ---------------------------------------------------------------------------\n", + "N = 32\n", + "STD_GSD = 0.5 # 표준 해상도(m/픽셀, 가상 기준)\n", + "CLASSES = [\"tank\", \"spg\", \"truck\", \"civil\"]\n", + "MILITARY = {\"tank\", \"spg\", \"truck\"}\n", + "# 객체 좌표계(u: 길이 방향, v: 폭 방향, 표준 해상도 픽셀) 가상 도형: (u중심, v중심, 길이, 폭, 광학 밝기 차이)\n", + "SHAPES = {\n", + " \"tank\": [(0, 0, 11, 7, -40), (0.5, 0, 5, 5, -55), (6, 0, 7, 1.2, -60)],\n", + " \"spg\": [(0, 0, 16, 7.5, -38), (-4, 0, 6, 6.5, -52), (5, 0, 13, 1.4, -60)],\n", + " \"truck\": [(5, 0, 3.5, 5.5, -45), (-1.8, 0, 8.5, 6, -25)],\n", + " \"civil\": [(0, 0, 8, 3.8, 0), (1.2, 0, 2, 3.4, 0)],\n", + " \"decoy\": [(0, 0, 11, 7, -40), (0.5, 0, 5, 5, -42), (6, 0, 7, 1.2, -45)],\n", + " \"none\": [], # 객체 없는 배경 타일\n", + "}\n", + "SAR_RETURN = {\"tank\": 0.85, \"spg\": 0.85, \"truck\": 0.7, \"civil\": 0.45, \"decoy\": 0.18, \"none\": 0.12} # 금속 반사 세기(가상)\n", + "\n", + "\n", + "def _mask(cls, angle, scale, squash=1.0):\n", + " \"\"\"회전(angle, 도)·축척(scale)·비스듬 촬영 압축(squash)을 적용해 도형별 마스크와 밝기 차이를 만든다.\"\"\"\n", + " # [TODO : 학생 실습 10] 공개 위성영상 장비 객체분류 시스템 통합 및 전략 ISR 평가 브리핑 패키지 완성 관련 핵심 로직을 구현하세요.\n", + " # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n", + " # <<< 여기에 코드를 작성하세요 >>>\n", + " pass # 작성 후 pass를 제거하세요.\n", + "def make_chip(cls, rng, cond=None):\n", + " \"\"\"chip 1개 = (광학 32x32, SAR 32x32, 메타데이터). cond: camo, occl, cloud, low_sun, off_nadir, low_res, speckle\"\"\"\n", + " c = {\"camo\": 0.0, \"occl\": 0.0, \"cloud\": 0.0, \"low_sun\": False, \"off_nadir\": 0.0, \"low_res\": False, \"speckle\": 1.0, **(cond or {})}\n", + " gsd = rng.uniform(0.9, 1.2) if c[\"low_res\"] else rng.uniform(0.4, 0.6)\n", + " scale = STD_GSD / gsd\n", + " angle, off = rng.uniform(0, 360), c[\"off_nadir\"] or rng.uniform(0, 15)\n", + " squash = np.cos(np.radians(off))\n", + " sun_el = rng.uniform(12, 22) if c[\"low_sun\"] else rng.uniform(40, 70)\n", + " sun_az = rng.uniform(0, 360)\n", + " bg = 115 + ndimage.gaussian_filter(rng.normal(0, 30, (N, N)), 2) + rng.normal(0, 5, (N, N))\n", + " parts = _mask(cls, angle, scale, squash)\n", + " body = np.any([m for m, _ in parts], axis=0) if parts else np.zeros((N, N), bool)\n", + " opt = bg.copy()\n", + " # 그림자: 태양 반대 방향으로 밀린 객체 모양(태양 고도가 낮을수록 길다)\n", + " L = min(8.0, 2.5 / np.tan(np.radians(sun_el))) * scale\n", + " sh = ndimage.shift(body.astype(float), (L * np.cos(np.radians(sun_az)), -L * np.sin(np.radians(sun_az))), order=0) > 0.5\n", + " opt[sh & ~body] -= 35 if cls != \"decoy\" else 25\n", + " if cls == \"civil\":\n", + " base = rng.choice([200.0, 60.0]) # 민간 차량 색상: 밝거나 어두움\n", + " opt[body] = base + rng.normal(0, 8, body.sum())\n", + " roof = parts[1][0]\n", + " opt[roof] = base * 0.8\n", + " else:\n", + " ref = bg[body].mean() if body.any() else bg.mean()\n", + " for m, d in parts:\n", + " opt[m] = ref + d + rng.normal(0, 4 if cls != \"decoy\" else 2, m.sum())\n", + " # 위장막: 객체 주변을 배경 질감으로 덮어 대비를 낮춘다\n", + " if c[\"camo\"] > 0 and cls != \"civil\":\n", + " net = ndimage.binary_dilation(body, iterations=2)\n", + " tex = 105 + ndimage.gaussian_filter(rng.normal(0, 25, (N, N)), 1)\n", + " opt[net] = (1 - c[\"camo\"]) * opt[net] + c[\"camo\"] * tex[net]\n", + " # 수목 차폐: 객체의 일부를 어두운 나무 덩어리로 가린다\n", + " if c[\"occl\"] > 0:\n", + " trees = ndimage.gaussian_filter(rng.random((N, N)), 2)\n", + " near = ndimage.binary_dilation(body, iterations=3)\n", + " cover = near & (trees > np.quantile(trees[body], 1 - c[\"occl\"])) if body.any() else np.zeros((N, N), bool)\n", + " opt[cover] = 70 + rng.normal(0, 6, cover.sum())\n", + " # 구름: 밝고 매끈한 덩어리(광학만 가림, SAR는 통과)\n", + " cmask = np.zeros((N, N), bool)\n", + " if c[\"cloud\"] > 0:\n", + " blob = ndimage.gaussian_filter(rng.random((N, N)), 4)\n", + " cmask = blob > np.quantile(blob, 1 - c[\"cloud\"])\n", + " opt[cmask] = 0.25 * opt[cmask] + 0.75 * (235 + rng.normal(0, 3, cmask.sum()))\n", + " # SAR: 곱셈형 스페클 잡음 + 금속 반사(모서리 이중 반사로 밝은 점)\n", + " sar = np.full((N, N), 0.12)\n", + " sar[body] = SAR_RETURN[cls] * rng.uniform(0.8, 1.2)\n", + " edge = body & ~ndimage.binary_erosion(body)\n", + " sar[edge] *= 1.3 if cls in MILITARY else 1.0\n", + " sar = sar * rng.gamma(4 / c[\"speckle\"], c[\"speckle\"] / 4, (N, N))\n", + " if c[\"low_res\"]: # 저해상도: 정보 손실(블러)\n", + " down = lambda a: np.kron(a.reshape(N // 2, 2, N // 2, 2).mean((1, 3)), np.ones((2, 2)))\n", + " opt, sar = ndimage.gaussian_filter(down(opt), 0.7), ndimage.gaussian_filter(down(sar), 0.7)\n", + " meta = {\"cls\": cls, \"gsd\": round(float(gsd), 2), \"off_nadir\": round(float(off), 1), \"sun_el\": round(float(sun_el), 1),\n", + " \"sun_az\": round(float(sun_az), 1), \"angle\": round(float(angle), 1), \"cloud_frac\": float(cmask.mean()),\n", + " \"cloud_on_obj\": float((cmask & body).sum() / max(body.sum(), 1)), **{k: c[k] for k in (\"camo\", \"occl\", \"low_sun\", \"low_res\")}}\n", + " return np.clip(opt, 0, 255), sar, meta\n", + "\n", + "\n", + "SCEN = { # 은폐·촬영 조건 시나리오 10종\n", + " \"clean\": {}, \"camo\": {\"camo\": 0.7}, \"occl30\": {\"occl\": 0.3}, \"occl50\": {\"occl\": 0.5}, \"cloud\": {\"cloud\": 0.25},\n", + " \"low_sun\": {\"low_sun\": True}, \"off_nadir\": {\"off_nadir\": 35.0}, \"low_res\": {\"low_res\": True},\n", + " \"speckle\": {\"speckle\": 3.0}, \"camo_occl\": {\"camo\": 0.6, \"occl\": 0.3}}\n", + "\n", + "\n", + "def make_dataset(n=400, seed=7, scen=(\"clean\",), classes=CLASSES):\n", + " rng = np.random.default_rng(seed)\n", + " O, S, M = np.zeros((n, N, N)), np.zeros((n, N, N)), []\n", + " for i in range(n):\n", + " cls, sc = classes[i % len(classes)], scen[(i // len(classes)) % len(scen)]\n", + " O[i], S[i], m = make_chip(cls, rng, SCEN[sc])\n", + " m.update({\"id\": f\"IMG{i:04d}\", \"scen\": sc})\n", + " M.append(m)\n", + " return O, S, M\n", + "# ---------------------------------------------------------------------------\n", + "\n", + "# ---- 전처리(해상도·기하 보정, 구름 차폐 처리, 방위 정규화) 공통 함수 ----\n", + "def _fit(a, fill):\n", + " \"\"\"중앙 기준으로 32x32에 맞게 자르거나 채운다.\"\"\"\n", + " # [TODO : 학생 실습 10] 공개 위성영상 장비 객체분류 시스템 통합 및 전략 ISR 평가 브리핑 패키지 완성 관련 핵심 로직을 구현하세요.\n", + " # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n", + " # <<< 여기에 코드를 작성하세요 >>>\n", + " pass # 작성 후 pass를 제거하세요.\n", + "def cloud_mask(opt, sm, min_area=15):\n", + " \"\"\"광학에서 밝고(185 초과) 매끈한데 SAR에는 반사가 없는 영역을 구름으로 본다(SAR는 구름을 통과하므로 밝은 차량과 구분된다).\"\"\"\n", + " bright = opt > 185\n", + " if bright.sum() < min_area:\n", + " return np.zeros_like(bright)\n", + " local_sd = np.sqrt(np.maximum(ndimage.uniform_filter(opt ** 2, 3) - ndimage.uniform_filter(opt, 3) ** 2, 0))\n", + " cand = bright & (local_sd < 12) & (sm < 0.3)\n", + " lab, n = ndimage.label(cand)\n", + " if n == 0:\n", + " return cand\n", + " sizes = ndimage.sum(cand.astype(float), lab, range(1, n + 1))\n", + " return ndimage.binary_dilation(np.isin(lab, 1 + np.where(sizes >= min_area)[0]), iterations=2) & (opt > 140)\n", + "\n", + "\n", + "def orientation(opt, sm):\n", + " \"\"\"SAR 반사(위장·구름 영향 적음, sm은 평활화한 SAR)와 광학 대비를 합친 객체 마스크의 주축 방향(도).\"\"\"\n", + " m = (sm > 0.3) | (np.abs(opt - opt.mean()) > 30)\n", + " lab, n = ndimage.label(m)\n", + " if n == 0:\n", + " return 0.0, m\n", + " c = lab[N // 2 - 6:N // 2 + 6, N // 2 - 6:N // 2 + 6]\n", + " k = np.bincount(c[c > 0]).argmax() if (c > 0).any() else 1 + int(np.argmax(ndimage.sum(m.astype(float), lab, range(1, n + 1))))\n", + " m = lab == k\n", + " yy, xx = np.nonzero(m)\n", + " if len(yy) < 5:\n", + " return 0.0, m\n", + " dy, dx = yy - yy.mean(), xx - xx.mean() # 2차 모멘트로 주축 각도 계산\n", + " return float(np.degrees(0.5 * np.arctan2(2 * (dx * dy).mean(), (dx * dx).mean() - (dy * dy).mean()))), m\n", + "\n", + "\n", + "_YY, _XX = np.mgrid[0:N, 0:N] - (N - 1) / 2\n", + "\n", + "\n", + "def warp(stack, zy, zx, ang):\n", + " \"\"\"여러 장(B,채널,32,32)에 장별 확대·축소(zy, zx)와 회전(ang, 도)을 한 번의 쌍선형 보간으로 적용한다(벡터 연산).\"\"\"\n", + " a = np.radians(ang)[:, None, None]\n", + " zy, zx = np.asarray(zy)[:, None, None], np.asarray(zx)[:, None, None]\n", + " sy = np.clip((np.cos(a) * _YY + np.sin(a) * _XX) / zy + (N - 1) / 2, 0, N - 1.001) # 출력 좌표 -> 원본 좌표\n", + " sx = np.clip((-np.sin(a) * _YY + np.cos(a) * _XX) / zx + (N - 1) / 2, 0, N - 1.001)\n", + " y0, x0 = sy.astype(int), sx.astype(int)\n", + " fy, fx = (sy - y0).reshape(len(stack), 1, -1), (sx - x0).reshape(len(stack), 1, -1)\n", + " flat = stack.reshape(len(stack), stack.shape[1], -1)\n", + " lin = (y0 * N + x0).reshape(len(stack), 1, -1)\n", + " g = lambda d: np.take_along_axis(flat, np.broadcast_to(lin + d, (len(stack), stack.shape[1], lin.shape[2])), axis=2)\n", + " out = g(0) * (1 - fy) * (1 - fx) + g(N) * fy * (1 - fx) + g(1) * (1 - fy) * fx + g(N + 1) * fy * fx\n", + " return out.reshape(stack.shape)\n", + "\n", + "\n", + "def preprocess_batch(O, S, M):\n", + " \"\"\"여러 장을 한 번에 전처리한다. 0) 결측 보정 1) 해상도·비스듬 촬영 보정(표준 0.5m/픽셀)\n", + " 2) 구름 차폐 영역을 배경값으로 채움 3) 객체 주축을 가로로 회전 4) 0~1 정규화.\"\"\"\n", + " src = np.stack([np.asarray(O, float), np.asarray(S, float)], axis=1)\n", + " if np.isnan(src).any(): # 0) 센서 결측 픽셀 보정\n", + " mean = np.nanmean(src, axis=(2, 3), keepdims=True)\n", + " src = np.where(np.isnan(src), mean, src)\n", + " zy = np.array([m[\"gsd\"] / STD_GSD / np.cos(np.radians(m[\"off_nadir\"])) for m in M])\n", + " zx = np.array([m[\"gsd\"] / STD_GSD for m in M])\n", + " out = warp(src, zy, zx, np.zeros(len(M)))\n", + " sm = ndimage.uniform_filter(out[:, 1], (1, 3, 3))\n", + " angs, cloud = np.zeros(len(M)), np.zeros(len(M))\n", + " for i in range(len(M)):\n", + " cm = cloud_mask(out[i, 0], sm[i])\n", + " if cm.any():\n", + " fill = out[i, 0][~cm].mean() if (~cm).any() else 115.0\n", + " out[i, 0][cm] = fill\n", + " back = warp(cm[None, None].astype(float), [1 / zy[i]], [1 / zx[i]], np.zeros(1))[0, 0] > 0.5\n", + " src[i, 0][back] = fill # 원본의 같은 구름 영역도 채움\n", + " cloud[i] = cm.mean()\n", + " angs[i] = orientation(out[i, 0], sm[i])[0]\n", + " rot = np.abs(angs) > 3 # 3도 이하는 회전 생략\n", + " if rot.any():\n", + " out[rot] = warp(src[rot], zy[rot], zx[rot], angs[rot])\n", + " return [(np.clip(o / 255, 0, 1), np.clip(s / 1.5, 0, 1), float(c)) for (o, s), c in zip(out, cloud)]\n", + "\n", + "\n", + "def preprocess(opt, sar, meta):\n", + " \"\"\"한 장 전처리(preprocess_batch와 같은 처리).\"\"\"\n", + " return preprocess_batch([opt], [sar], [meta])[0]\n", + "\n", + "# ---- 특징 추출 공통 함수 ----\n", + "KERNELS = [np.array(k, float) for k in (\n", + " [[-1, 0, 1], [-2, 0, 2], [-1, 0, 1]], [[-1, -2, -1], [0, 0, 0], [1, 2, 1]], [[0, 1, 0], [1, -4, 1], [0, 1, 0]],\n", + " [[2, -1, -1], [-1, 2, -1], [-1, -1, 2]], [[-1, -1, 2], [-1, 2, -1], [2, -1, -1]], [[1, 1, 1], [1, 1, 1], [1, 1, 1]])]\n", + "\n", + "\n", + "def conv_features(o):\n", + " \"\"\"간이 CNN 특징: 고정 합성곱 필터 6종 -> ReLU -> 4x4 최대 풀링(8x8 출력).\"\"\"\n", + " feats = []\n", + " for k in KERNELS:\n", + " r = np.maximum(ndimage.correlate(o - o.mean(), k / np.abs(k).sum()), 0)\n", + " feats.append(r.reshape(8, 4, 8, 4).max(axis=(1, 3)).ravel())\n", + " feats.append(np.maximum(-ndimage.correlate(o - o.mean(), k / np.abs(k).sum()), 0).reshape(8, 4, 8, 4).max(axis=(1, 3)).ravel())\n", + " return np.concatenate(feats)\n", + "\n", + "\n", + "def hog(o, cell=8, bins=8):\n", + " gy, gx = np.gradient(o)\n", + " mag, ang = np.hypot(gx, gy), np.mod(np.arctan2(gy, gx), np.pi)\n", + " b = np.minimum((ang / np.pi * bins).astype(int), bins - 1)\n", + " out = np.zeros((N // cell, N // cell, bins))\n", + " for i in range(N // cell):\n", + " for j in range(N // cell):\n", + " sl = (slice(i * cell, (i + 1) * cell), slice(j * cell, (j + 1) * cell))\n", + " out[i, j] = np.bincount(b[sl].ravel(), mag[sl].ravel(), bins)\n", + " return (out / (np.linalg.norm(out) + 1e-6)).ravel()\n", + "\n", + "\n", + "def shape_features(o, s):\n", + " \"\"\"객체 모양 특징: 가로 방향 투영으로 전체 길이·차체 길이·가는 돌출부(포신) 길이·두꺼운 부분(포탑) 위치를 잰다.\n", + " 방위 정규화 뒤에도 앞뒤(180도)가 뒤집힐 수 있으므로, 가는 돌출부가 오른쪽에 오도록 맞춘다.\"\"\"\n", + " sm = ndimage.uniform_filter(s, 3)\n", + " dark = np.abs(o - np.median(o))\n", + " m = (sm > 0.22) | (dark > 0.12)\n", + " lab, n = ndimage.label(m)\n", + " if n:\n", + " c = lab[10:22, 8:24]\n", + " k = np.bincount(c[c > 0]).argmax() if (c > 0).any() else 1\n", + " m = lab == k\n", + " p = m.sum(axis=0).astype(float)\n", + " cols = np.nonzero(p)[0]\n", + " if len(cols) == 0:\n", + " return np.zeros(8), o, s\n", + " thin = (p > 0) & (p <= 2)\n", + " left, right = thin[:N // 2].sum(), thin[N // 2:].sum()\n", + " if left > right:\n", + " o, s, m, p = o[:, ::-1], s[:, ::-1], m[:, ::-1], p[::-1]\n", + " cols = np.nonzero(p)[0]\n", + " body = np.nonzero(p > 3)[0]\n", + " d = np.where(m, dark if left <= right else dark[:, ::-1], 0)\n", + " heavy = d.sum(axis=0)\n", + " centre = (body[0] + body[-1]) / 2 if len(body) else N / 2\n", + " turret = (heavy * np.arange(N)).sum() / max(heavy.sum(), 1e-6) - centre\n", + " return np.array([cols[-1] - cols[0] + 1, len(body), ((p > 0) & (p <= 2)).sum(), p.max(), turret,\n", + " m.sum(), (cols[-1] - (body[-1] if len(body) else cols[-1])), m.sum() / max(len(cols) * p.max(), 1)]), o, s\n", + "\n", + "\n", + "def adv_features(o, s):\n", + " \"\"\"정밀 특징: 모양(길이·포신·포탑 위치) + HOG(윤곽) + SAR 반사 통계(위장막·구름 영향 적음) + 투영 윤곽.\"\"\"\n", + " shp, o, s = shape_features(o, s)\n", + " c = s[8:24, 4:28]\n", + " sm = ndimage.uniform_filter(s, 3)\n", + " m = sm > 0.2\n", + " dark = np.abs(o - np.median(o))\n", + " return np.concatenate([shp, hog(o), [c.mean(), c.max(), (sm > 0.25).mean(), (sm > 0.4).mean(), m.sum(axis=1).max(), m.sum(axis=0).max()],\n", + " sm.mean(axis=0).reshape(8, 4).mean(1), sm.mean(axis=1).reshape(8, 4).mean(1),\n", + " dark.mean(axis=0).reshape(8, 4).mean(1), dark.mean(axis=1).reshape(8, 4).mean(1),\n", + " [o.mean(), o.std(), dark.max(), (o > 0.65).mean(), (o < 0.3).mean()]])\n", + "\n", + "CONFIG = {\"train_with_decoy\": True, \"high\": 0.9, \"medium\": 0.6, \"hvt_types\": [\"spg\"]} # 미션 9 재보정값\n", + "CALLED = set()\n", + "\n", + "\n", + "def cover(fn):\n", + " \"\"\"기능 커버리지 측정용 표시: 통합 실행 중 호출된 기능을 기록한다.\"\"\"\n", + " def wrap(*a, **k):\n", + " CALLED.add(fn.__name__)\n", + " return fn(*a, **k)\n", + " wrap.__name__ = fn.__name__\n", + " return wrap\n", + "\n", + "\n", + "preprocess, adv_features, shape_features, hog, cloud_mask, orientation = map(cover, (preprocess, adv_features, shape_features, hog, cloud_mask, orientation))\n", + "\n", + "\n", + "@cover\n", + "def validate_tile(o, s, m):\n", + " errs = []\n", + " if o.shape != (N, N) or s.shape != (N, N):\n", + " errs.append(\"타일 크기 오류\")\n", + " if not 0.1 <= m[\"gsd\"] <= 30:\n", + " errs.append(\"gsd 범위 오류\")\n", + " if not np.isfinite(o).all():\n", + " errs.append(\"결측 픽셀\")\n", + " return errs\n", + "\n", + "\n", + "@cover\n", + "def quality(P, prob):\n", + " srt = np.sort(prob, axis=1)\n", + " ent = -(prob * np.log(np.clip(prob, 1e-9, 1))).sum(1) / np.log(prob.shape[1])\n", + " return np.c_[srt[:, -1], srt[:, -1] - srt[:, -2], ent, [p[2] for p in P], [p[1][8:24, 4:28].mean() for p in P]]\n", + "\n", + "\n", + "@cover\n", + "def grade(c):\n", + " return \"High\" if c >= CONFIG[\"high\"] else (\"Medium\" if c >= CONFIG[\"medium\"] else \"Low\")\n", + "\n", + "\n", + "class Queue:\n", + " def __init__(self):\n", + " self.items, self.log = [], []\n", + "\n", + " @cover\n", + " def submit(self, iid, pred, g, conf):\n", + " prev = self.log[-1][\"hash\"] if self.log else \"GENESIS\"\n", + " body = {\"event\": \"SUBMIT\", \"id\": iid, \"pred\": pred, \"grade\": g, \"conf\": round(conf, 3), \"hvt\": pred in CONFIG[\"hvt_types\"]}\n", + " self.log.append({**body, \"prev\": prev, \"hash\": hashlib.sha256((prev + json.dumps(body, sort_keys=True)).encode()).hexdigest()})\n", + " self.items.append({**body, \"status\": \"PENDING\"})\n", + "\n", + " @cover\n", + " def verify(self):\n", + " prev = \"GENESIS\"\n", + " for r in self.log:\n", + " body = {k: v for k, v in r.items() if k not in (\"prev\", \"hash\")}\n", + " if r[\"prev\"] != prev or r[\"hash\"] != hashlib.sha256((prev + json.dumps(body, sort_keys=True)).encode()).hexdigest():\n", + " return False\n", + " prev = r[\"hash\"]\n", + " return True\n", + "\n", + "\n", + "TARGETS = {\"validate_tile\", \"preprocess\", \"cloud_mask\", \"orientation\", \"adv_features\", \"shape_features\", \"hog\", \"quality\", \"grade\", \"submit\", \"verify\", \"dashboard\"}\n", + "LABELS = CLASSES + [\"decoy\", \"none\"]\n", + "\n", + "# ---------------- 1. 학습(분류 모델 + 신뢰도 가중치) ----------------\n", + "t_start = time.perf_counter()\n", + "O, S, M = make_dataset(200 * len(LABELS), seed=3, scen=tuple(SCEN), classes=LABELS)\n", + "clf = RandomForestClassifier(n_estimators=150, min_samples_leaf=2, random_state=0, n_jobs=1)\n", + "clf.fit(np.array([adv_features(o, s) for o, s, _ in preprocess_batch(O, S, M)]), [m[\"cls\"] for m in M])\n", + "Oc, Sc, Mc = make_dataset(480, seed=6, scen=tuple(SCEN), classes=LABELS)\n", + "Pc = preprocess_batch(Oc, Sc, Mc)\n", + "prc = clf.predict_proba(np.array([adv_features(o, s) for o, s, _ in Pc]))\n", + "conf_model = make_pipeline(StandardScaler(), LogisticRegression(max_iter=2000)).fit(quality(Pc, prc), clf.classes_[prc.argmax(1)] == np.array([m[\"cls\"] for m in Mc]))\n", + "\n", + "# ---------------- 2. 운용 자료(가상 판독 지역 300타일) 처리 ----------------\n", + "Oo, So, Mo = make_dataset(300, seed=31, scen=(\"clean\", \"camo\", \"occl30\", \"cloud\", \"low_sun\", \"low_res\"), classes=LABELS)\n", + "ops = list(zip(Oo, So, Mo))\n", + "ops[5] = (Oo[5][:20], So[5], Mo[5]) # 손상 타일 1건(잘림)\n", + "ops[9] = (Oo[9], So[9], {**Mo[9], \"gsd\": 0.0}) # 메타데이터 오류 1건\n", + "queue, rejected, res, ms = Queue(), {}, [], []\n", + "for o, s, m in ops:\n", + " errs = validate_tile(o, s, m)\n", + " if errs:\n", + " rejected[m[\"id\"]] = errs; continue\n", + " t0 = time.perf_counter()\n", + " P = [preprocess(o, s, m)]\n", + " pr = clf.predict_proba([adv_features(P[0][0], P[0][1])])\n", + " pred = clf.classes_[pr.argmax()]\n", + " conf = float(conf_model.predict_proba(quality(P, pr))[0, 1])\n", + " g = grade(conf)\n", + " if pred != \"none\":\n", + " queue.submit(m[\"id\"], pred, g, conf)\n", + " ms.append((time.perf_counter() - t0) * 1000)\n", + " res.append({\"id\": m[\"id\"], \"y\": m[\"cls\"], \"p\": pred, \"grade\": g, \"conf\": conf, \"scen\": m[\"scen\"]})\n", + "R = pd.DataFrame(res)\n", + "acc = (R.y == R.p).mean()\n", + "milR = R[R.y.isin(list(MILITARY))]\n", + "rec = (milR.y == milR.p).mean()\n", + "decoy_catch = (R[R.y == \"decoy\"].p == \"decoy\").mean()\n", + "hi = R[(R.grade == \"High\") & R.p.isin(list(MILITARY))]\n", + "hi_err = (hi.y != hi.p).mean() if len(hi) else 0.0\n", + "\n", + "\n", + "# ---------------- 3. 분석 화면 ----------------\n", + "@cover\n", + "def dashboard():\n", + " plt.style.use(\"dark_background\")\n", + " fig, ax = plt.subplots(2, 2, figsize=(12, 9))\n", + " T = 10\n", + " mosaic = np.block([[np.asarray(Oo[r * T + c]) for c in range(T)] for r in range(T)])\n", + " ax[0, 0].imshow(mosaic, cmap=\"gray\")\n", + " for k, r in R[R.id.isin([f\"IMG{i:04d}\" for i in range(100)])].iterrows():\n", + " i = int(r.id[3:])\n", + " if r.p != \"none\":\n", + " ax[0, 0].scatter(i % T * N + 16, i // T * N + 16, s=60, facecolors=\"none\",\n", + " edgecolors=\"red\" if r.p in MILITARY else (\"yellow\" if r.p == \"decoy\" else \"deepskyblue\"))\n", + " ax[0, 0].set_title(\"First 100 tiles (red = military, yellow = decoy)\"); ax[0, 0].axis(\"off\")\n", + " vc = R[R.p != \"none\"].p.value_counts()\n", + " ax[0, 1].bar(vc.index.tolist(), vc.values, color=\"deepskyblue\"); ax[0, 1].set_title(\"Detections by type\")\n", + " gc = R[R.p != \"none\"].grade.value_counts().reindex([\"High\", \"Medium\", \"Low\"]).fillna(0)\n", + " ax[1, 0].bar(gc.index.tolist(), gc.values, color=[\"red\", \"orange\", \"gray\"]); ax[1, 0].set_title(\"Review queue by grade (all pending)\")\n", + " ax[1, 1].axis(\"off\")\n", + " ax[1, 1].text(0, .9, f\"Accuracy {acc:.1%}\\nMilitary rec. {rec:.1%}\\nDecoy caught {decoy_catch:.1%}\\nQueue {len(queue.items)} pending\\n\"\n", + " f\"Latency {np.mean(ms):.1f} ms/tile\", family=\"monospace\", fontsize=13, va=\"top\")\n", + " fig.suptitle(\"ISR01 integrated satellite object classification - synthetic data only\"); plt.tight_layout(); plt.show()\n", + "\n", + "\n", + "dashboard()\n", + "\n", + "# ---------------- 4. 자체 점검(통합 테스트) ----------------\n", + "log_ok = queue.verify()\n", + "cov = len(CALLED & TARGETS) / len(TARGETS)\n", + "chk = {\n", + " \"손상 타일·메타데이터 오류 차단\": set(rejected) == {\"IMG0005\", \"IMG0009\"},\n", + " \"처리 타일 298장\": len(res) == 298,\n", + " \"종합 정확도 90% 이상\": acc >= 0.9,\n", + " \"군용 기종 식별 재현율 85% 이상\": rec >= 0.85,\n", + " \"모의 표적 적발률 80% 이상\": decoy_catch >= 0.8,\n", + " \"자동 승인 0건(전부 판독 대기)\": all(i[\"status\"] == \"PENDING\" for i in queue.items),\n", + " \"식별 건 전량 대기열 등록\": len(queue.items) == int((R.p != \"none\").sum()),\n", + " \"감사 로그 무결성\": log_ok,\n", + " \"타일당 200ms 이내\": float(np.mean(ms)) < 200,\n", + " \"기능 커버리지 85% 이상\": cov >= 0.85,\n", + "}\n", + "passed = sum(chk.values())\n", + "for k, v in chk.items():\n", + " print(f\"[{'PASS' if v else 'FAIL'}] {k}\")\n", + "print(f\"자체 점검 {passed}/{len(chk)} 통과, 기능 커버리지 {cov:.0%} ({len(CALLED & TARGETS)}/{len(TARGETS)}), 파이프라인 실행 성공 {'예' if passed == len(chk) else '아니오'}\")\n", + "\n", + "# ---------------- 5. 전략 ISR 평가 브리핑 요약 ----------------\n", + "print(f\"\"\"\n", + "[위성영상 장비 객체분류 평가 브리핑 요약 - 교육용가상자료]\n", + "1. 처리: 타일 {len(ops)}장 중 {len(res)}장 판독, 손상·오류 {len(rejected)}건 차단({', '.join(rejected)})\n", + "2. 성능: 종합 정확도 {acc:.1%}, 군용 기종 식별 재현율 {rec:.1%}, 모의 표적 적발 {decoy_catch:.1%}, 타일당 {np.mean(ms):.1f} ms, 전체 {time.perf_counter() - t_start:.0f} s\n", + "3. 판독 대기: {len(queue.items)}건 (High {int((R[R.p != 'none'].grade == 'High').sum())} / Medium {int((R[R.p != 'none'].grade == 'Medium').sum())} / Low {int((R[R.p != 'none'].grade == 'Low').sum())}), HVT 후보 {sum(i['hvt'] for i in queue.items)}건, 자동 승인 0건\n", + "4. 신뢰도: High 등급 군용 식별 {len(hi)}건 중 오분류 {hi_err:.1%} -> 등급은 판독 순서 결정용이며 최종 판독은 영상분석관이 한다\n", + "5. 한계: 합성 32x32 영상 기준 결과로, 실제 위성영상·장비에는 재학습과 재검증이 필요하다. 인접 체계 연동은 판독 승인 결과만 표준 필드로 전달한다.\"\"\")\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 10 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + "[PASS] 손상 타일·메타데이터 오류 차단\n", + "[PASS] 처리 타일 298장\n", + "[PASS] 종합 정확도 90% 이상\n", + "[PASS] 군용 기종 식별 재현율 85% 이상\n", + "[PASS] 모의 표적 적발률 80% 이상\n", + "[PASS] 자동 승인 0건(전부 판독 대기)\n", + "[PASS] 식별 건 전량 대기열 등록\n", + "[PASS] 감사 로그 무결성\n", + "[PASS] 타일당 200ms 이내\n", + "[PASS] 기능 커버리지 85% 이상\n", + "자체 점검 10/10 통과, 기능 커버리지 100% (12/12), 파이프라인 실행 성공 예\n", + "\n", + "[위성영상 장비 객체분류 평가 브리핑 요약 - 교육용가상자료]\n", + "1. 처리: 타일 300장 중 298장 판독, 손상·오류 2건 차단(IMG0005, IMG0009)\n", + "2. 성능: 종합 정확도 96.6%, 군용 기종 식별 재현율 96.0%, 모의 표적 적발 100.0%, 타일당 14.0 ms, 전체 10 s\n", + "3. 판독 대기: 251건 (High 223 / Medium 21 / Low 7), HVT 후보 51건, 자동 승인 0건\n", + "4. 신뢰도: High 등급 군용 식별 133건 중 오분류 2.3% -> 등급은 판독 순서 결정용이며 최종 판독은 영상분석관이 한다\n", + "5. 한계: 합성 32x32 영상 기준 결과로, 실제 위성영상·장비에는 재학습과 재검증이 필요하다. 인접 체계 연동은 판독 승인 결과만 표준 필드로 전달한다.\n", + "```\n", + "
\n" + ] + } + ], + "metadata": { + "colab": { + "provenance": [] + }, + "kernelspec": { + "display_name": "Python 3", + "name": "python3" + }, + "language_info": { + "name": "python" + } + }, + "nbformat": 4, + "nbformat_minor": 0 +} \ No newline at end of file diff --git a/uploads/ISR02/ISR02_solution.ipynb b/uploads/ISR02/ISR02_solution.ipynb new file mode 100644 index 0000000..4a44c1b --- /dev/null +++ b/uploads/ISR02/ISR02_solution.ipynb @@ -0,0 +1,3772 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# 🎯 [ISR02] SNS 전쟁 게시물 중복·변조·출처 신뢰도 자동 분류\n", + "### Google Colab 실습 환경 (정보·감시·정찰(ISR) / Level 2)\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/ISR02/ISR02_solution.ipynb)\n", + "\n", + "---\n", + "\n", + "## 📌 1. 프로젝트 개요 및 배경\n", + "\n", + "| 항목 | 내용 |\n", + "| :--- | :--- |\n", + "| **과정 분류** | 정보·감시·정찰(ISR) (Level 2) |\n", + "| **학습 소요 시간** | 10개 미션 (5일 / 총 27.5시간) |\n", + "| **환경 사양** | Python 3.10+, numpy, pandas, matplotlib, scipy, scikit-learn |\n", + "\n", + "### 📖 프로젝트 목표\n", + "러시아-우크라이나 전쟁에서는 텔레그램·X 같은 SNS에 현장 제보와 함께 재게시, 수치·지역 변조, 과거 사진 재활용, 생성형 AI 조작물, 조직적 동시 유포가 한꺼번에 쏟아지면서 공개출처정보(OSINT)의 출처와 신뢰도를 빠르게 가려내는 일이 정보전의 핵심이 되었다. 본 프로젝트에서는 가상 계정과 가상 지역 코드로 만든 교육용 합성 게시물을 사용해 원천 데이터 스키마 정의, 텍스트 정제와 이미지 지각 해시 기반 중복 제거, 유사도 기반 복제·1차 출처 분류 Baseline, 조작·기만 탐지와 계정 신뢰도 결합 모델, 종합 신뢰도 등급(A~E) 엔진, 정보전 모니터링 대시보드, 정보관 긴급 검토(HITL), 대량 스트림 부하 시험, 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. 전장 SNS(텔레그램, X 등) 텍스트/이미지 게시물 수집 및 OSINT 원천 데이터셋 정의\n", + "**소요시간**: 약 1.5시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "텔레그램, X 등 전장 SNS에서 수집된 텍스트 및 이미지 게시물 데이터 구조를 분석하고 원천 데이터셋 스키마를 정의한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **텔레그램, X 등 전장 SNS에서 수집한 텍스트·이미지 게시물의 구조를 분석하고, 수집 경위와 주장 내용까지 담은 OSINT 원천 데이터셋 스키마와 검증기를 만든다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **게시물 기본 정보(게시물·계정 ID, 플랫폼, 게시 시각, 본문, 언어), 미디어(종류, 크기, 지각 해시), 전달 원본(forwarded_from), 수집 경위(수집 방식·시각·수집자·수집 지연), 주장 내용(가상 지역 코드, 위치 불확실성), 평가 상태 필드 정의하고 검증하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `data/schema/isr02_input_schema.json 및 docs/isr02_data_spec.md` 형태의 결과물을 완성한다.\n", + "\n", + "### 🔍 평가 및 검증 기준 (PASS 조건)\n", + "#### 미션 판정\n", + "\n", + "**평가 구분:** 필수\n", + "\n", + "**핵심 평가영역:** 정보·감시·정찰(ISR) 실무 및 러시아-우크라이나 전쟁 전장 환경과의 군사적 부합성\n", + "\n", + "#### 미션 PASS 조건\n", + "\n", + "- 정량 기준: 스키마 필드 정의 완성도 100%, 샘플 데이터 검증 오류 0건\n", + "\n", + "\n", + "- 진행 기준: 입력 데이터 유효성 검증 스크립트 통과 및 명세서 문서화 완료\n", + "\n", + "\n", + "- 검증 방법: JSON Schema 검증 도구 및 샘플 데이터 파싱 단위 테스트\n", + "\n", + "\n", + "#### 채점자 판정 메모\n", + "\n", + "> 단순 코드 실행 여부뿐만 아니라 출력된 수치와 근거 자료가 군 실무 요구사항에 부합하는지 면밀히 확인한다.\n", + "\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "\"\"\"전장 SNS(텔레그램, X 등) 텍스트·이미지 게시물의 수집 메타데이터, 계정·전달 경로, 주장 내용(지역·시각), 수집 지연 등 OSINT 원천 데이터셋 스키마를 정의하고 가상 샘플 게시물을 검증한다.\"\"\"\n", + "import json\n", + "import re\n", + "import numpy as np\n", + "import pandas as pd\n", + "\n", + "ISO = r\"^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}Z$\"\n", + "# 1) 스키마 정의: 게시물 기본 정보, 미디어, 수집 경위, 주장 내용, 수집 조건(위치 불확실성·지연), 평가 상태\n", + "SCHEMA = {\n", + " \"required\": [\"post_id\", \"platform\", \"account_id\", \"posted_at\", \"text\", \"media\", \"collection\", \"claim\", \"assessment\"],\n", + " \"fields\": {\n", + " \"post_id\": {\"type\": str, \"pattern\": r\"^P\\d{4}$\"},\n", + " \"platform\": {\"type\": str, \"enum\": [\"telegram\", \"x\", \"other\"]},\n", + " \"account_id\": {\"type\": str, \"pattern\": r\"^ACC_[A-Z]{3}\\d{2}$\"},\n", + " \"posted_at\": {\"type\": str, \"pattern\": ISO},\n", + " \"text\": {\"type\": str, \"minlen\": 1, \"maxlen\": 4000},\n", + " \"lang\": {\"type\": str, \"enum\": [\"ko\", \"en\", \"uk\", \"ru\", \"other\"]},\n", + " },\n", + " \"media\": {\n", + " \"type\": {\"type\": str, \"enum\": [\"image\", \"video\"]},\n", + " \"width\": {\"type\": int, \"min\": 1, \"max\": 10000},\n", + " \"height\": {\"type\": int, \"min\": 1, \"max\": 10000},\n", + " \"phash\": {\"type\": str, \"pattern\": r\"^[0-9a-f]{16}$\"},\n", + " },\n", + " \"collection\": {\n", + " \"method\": {\"type\": str, \"enum\": [\"public_api\", \"manual_capture\", \"partner_share\"]},\n", + " \"collected_at\": {\"type\": str, \"pattern\": ISO},\n", + " \"collector\": {\"type\": str, \"pattern\": r\"^COL-\\d{2}$\"},\n", + " \"delay_min\": {\"type\": (int, float), \"min\": 0, \"max\": 100000},\n", + " },\n", + " \"claim\": {\n", + " \"region_code\": {\"type\": str, \"pattern\": r\"^[A-F]-\\d$\"},\n", + " \"geo_uncertainty_km\": {\"type\": (int, float), \"min\": 0, \"max\": 500},\n", + " },\n", + " # 수집 단계에서는 진위·출처 판정을 적지 않는다(정보관 검토 후 별도 기록)\n", + " \"assessment\": {\"status\": {\"type\": str, \"enum\": [\"unassessed\"]}},\n", + "}\n", + "\n", + "\n", + "def check(name, value, rule):\n", + " if not isinstance(value, rule[\"type\"]) or isinstance(value, bool):\n", + " return [f\"{name}: 자료형 오류({type(value).__name__})\"]\n", + " errs = []\n", + " if \"enum\" in rule and value not in rule[\"enum\"]:\n", + " errs.append(f\"{name}: 허용되지 않은 값 '{value}'\")\n", + " if \"min\" in rule and value < rule[\"min\"]:\n", + " errs.append(f\"{name}: 최소값 {rule['min']} 미만\")\n", + " if \"max\" in rule and value > rule[\"max\"]:\n", + " errs.append(f\"{name}: 최대값 {rule['max']} 초과\")\n", + " if \"minlen\" in rule and len(value) < rule[\"minlen\"]:\n", + " errs.append(f\"{name}: 빈 값\")\n", + " if \"maxlen\" in rule and len(value) > rule[\"maxlen\"]:\n", + " errs.append(f\"{name}: 길이 {rule['maxlen']}자 초과\")\n", + " if \"pattern\" in rule and not re.match(rule[\"pattern\"], value):\n", + " errs.append(f\"{name}: 형식 불일치 '{value}'\")\n", + " return errs\n", + "\n", + "\n", + "def validate(rec):\n", + " errs = [f\"{k}: 필수 필드 누락\" for k in SCHEMA[\"required\"] if k not in rec]\n", + " for k, rule in SCHEMA[\"fields\"].items():\n", + " if k in rec:\n", + " errs += check(k, rec[k], rule)\n", + " for group in (\"collection\", \"claim\", \"assessment\"):\n", + " for k, rule in SCHEMA[group].items():\n", + " if k not in rec.get(group, {}):\n", + " errs.append(f\"{group}.{k}: 필수 필드 누락\")\n", + " else:\n", + " errs += check(f\"{group}.{k}\", rec[group][k], rule)\n", + " for i, m in enumerate(rec.get(\"media\", [])):\n", + " for k, rule in SCHEMA[\"media\"].items():\n", + " errs += [f\"media[{i}].{k}: 필수 필드 누락\"] if k not in m else check(f\"media[{i}].{k}\", m[k], rule)\n", + " if rec.get(\"forwarded_from\") is not None and not re.match(r\"^P\\d{4}$\", str(rec[\"forwarded_from\"])):\n", + " errs.append(\"forwarded_from: 형식 불일치\")\n", + " return errs\n", + "\n", + "\n", + "# 2) [교육용가상자료] 게시물 20건 생성 (가상 계정·가상 지역 코드, 실제 채널 아님)\n", + "rng = np.random.default_rng(1)\n", + "posts = []\n", + "for i in range(20):\n", + " r = f\"{'ABCDEF'[int(rng.integers(6))]}-{int(rng.integers(1, 6))}\"\n", + " posts.append({\"post_id\": f\"P{i:04d}\", \"platform\": str(rng.choice([\"telegram\", \"x\", \"other\"])),\n", + " \"account_id\": f\"ACC_{rng.choice(['MED', 'WIT', 'AGG', 'PRO'])}{int(rng.integers(0, 30)):02d}\",\n", + " \"posted_at\": f\"2026-05-0{int(rng.integers(1, 9))}T1{int(rng.integers(0, 9))}:0{int(rng.integers(0, 9))}Z\",\n", + " \"text\": f\"가상 {r} 지역에서 군용 차량 목격, {int(rng.integers(2, 15))}대\", \"lang\": \"ko\",\n", + " \"media\": [{\"type\": \"image\", \"width\": 32, \"height\": 32, \"phash\": \"\".join(rng.choice(list(\"0123456789abcdef\"), 16))}],\n", + " \"forwarded_from\": f\"P{int(rng.integers(0, max(i, 1))):04d}\" if i and rng.random() < .4 else None,\n", + " \"collection\": {\"method\": str(rng.choice([\"public_api\", \"manual_capture\", \"partner_share\"])), \"collected_at\": \"2026-05-09T12:00Z\",\n", + " \"collector\": f\"COL-{int(rng.integers(1, 20)):02d}\", \"delay_min\": int(rng.integers(1, 600))},\n", + " \"claim\": {\"region_code\": r, \"geo_uncertainty_km\": float(round(rng.uniform(0.5, 20), 1))},\n", + " \"assessment\": {\"status\": \"unassessed\"}})\n", + "\n", + "# 3) 정상 데이터 검증\n", + "ok = sum(1 for p in posts if not validate(p))\n", + "print(f\"정상 샘플 {len(posts)}건 중 검증 통과 {ok}건, 오류 {len(posts) - ok}건 (목표: 오류 0건 {'달성' if ok == len(posts) else '미달'})\")\n", + "\n", + "# 4) 오류 주입 시험\n", + "bad = json.loads(json.dumps(posts[3]))\n", + "bad[\"platform\"] = \"unknown_app\"\n", + "bad[\"posted_at\"] = \"2026/05/01 10:00\"\n", + "del bad[\"collection\"][\"method\"]\n", + "bad[\"media\"][0][\"phash\"] = \"XYZ\"\n", + "bad[\"text\"] = \"\"\n", + "print(\"\\n[오류 주입 결과] 주입 5종\")\n", + "for e in validate(bad):\n", + " print(\" -\", e)\n", + "\n", + "# 5) 스키마 필드 정의서(명세서 초안)\n", + "rows = [(\"기본\", k, r[\"type\"].__name__, str({x: y for x, y in r.items() if x != \"type\"})) for k, r in SCHEMA[\"fields\"].items()]\n", + "for g in (\"media\", \"collection\", \"claim\", \"assessment\"):\n", + " for k, r in SCHEMA[g].items():\n", + " t = r[\"type\"].__name__ if isinstance(r[\"type\"], type) else \"number\"\n", + " rows.append((g, k, t, str({x: y for x, y in r.items() if x != \"type\"})))\n", + "spec = pd.DataFrame(rows, columns=[\"그룹\", \"필드\", \"자료형\", \"규칙\"])\n", + "print(\"\\n[데이터 명세 요약]\")\n", + "print(spec.to_string(index=False))\n", + "print(f\"\\n필드 정의 {len(spec)}개 + forwarded_from(전달 원본, 선택). 수집 지연·위치 불확실성은 '통신지연·위치오차' 전술 파라미터에 해당한다.\")\n", + "print(f\"전달 경로가 있는 게시물 {sum(p['forwarded_from'] is not None for p in posts)}건: 출처 역추적(미션 3·7)에 사용\")\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 01 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + "정상 샘플 20건 중 검증 통과 20건, 오류 0건 (목표: 오류 0건 달성)\n", + "\n", + "[오류 주입 결과] 주입 5종\n", + " - platform: 허용되지 않은 값 'unknown_app'\n", + " - posted_at: 형식 불일치 '2026/05/01 10:00'\n", + " - text: 빈 값\n", + " - collection.method: 필수 필드 누락\n", + " - media[0].phash: 형식 불일치 'XYZ'\n", + "\n", + "[데이터 명세 요약]\n", + " 그룹 필드 자료형 규칙\n", + " 기본 post_id str {'pattern': '^P\\\\d{4}$'}\n", + " 기본 platform str {'enum': ['telegram', 'x', 'other']}\n", + " 기본 account_id str {'pattern': '^ACC_[A-Z]{3}\\\\d{2}$'}\n", + " 기본 posted_at str {'pattern': '^\\\\d{4}-\\\\d{2}-\\\\d{2}T\\\\d{2}:\\\\d{2}Z$'}\n", + " 기본 text str {'minlen': 1, 'maxlen': 4000}\n", + " 기본 lang str {'enum': ['ko', 'en', 'uk', 'ru', 'other']}\n", + " media type str {'enum': ['image', 'video']}\n", + " media width int {'min': 1, 'max': 10000}\n", + " media height int {'min': 1, 'max': 10000}\n", + " media phash str {'pattern': '^[0-9a-f]{16}$'}\n", + "collection method str {'enum': ['public_api', 'manual_capture', 'partner_share']}\n", + "collection collected_at str {'pattern': '^\\\\d{4}-\\\\d{2}-\\\\d{2}T\\\\d{2}:\\\\d{2}Z$'}\n", + "collection collector str {'pattern': '^COL-\\\\d{2}$'}\n", + "collection delay_min number {'min': 0, 'max': 100000}\n", + " claim region_code str {'pattern': '^[A-F]-\\\\d$'}\n", + " claim geo_uncertainty_km number {'min': 0, 'max': 500}\n", + "assessment status str {'enum': ['unassessed']}\n", + "\n", + "필드 정의 17개 + forwarded_from(전달 원본, 선택). 수집 지연·위치 불확실성은 '통신지연·위치오차' 전술 파라미터에 해당한다.\n", + "전달 경로가 있는 게시물 12건: 출처 역추적(미션 3·7)에 사용\n", + "```\n", + "
\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 02. 게시물 텍스트 불용어 정제, 이미지 해시(pHash) 추출 및 중복 제거 전처리 모듈 구현\n", + "**소요시간**: 약 2.0시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "텍스트 불용어 제거, 특수문자 정제, 이미지 지각 해시(pHash) 추출을 통해 중복 게시물을 걸러내는 전처리 모듈을 구현한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **게시물 텍스트의 불용어·특수문자·URL·이모지를 정제하고 이미지 지각 해시(pHash)를 추출해 중복 게시물을 걸러내는 전처리 모듈을 구현한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **유니코드 정규화(전각 문자 등), URL·멘션·해시태그 기호·특수문자·이모지·불용어 제거, 본문 결측 보정을 구현하고, 32x32 이미지의 DCT 저주파 성분으로 64비트 pHash 작성하여 원인을 분석하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 5개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `src/preprocess/isr02_preprocessor.py 및 전처리 전후 데이터셋` 형태의 결과물을 완성한다.\n", + "\n", + "### 🔍 평가 및 검증 기준 (PASS 조건)\n", + "#### 미션 판정\n", + "\n", + "**평가 구분:** 필수\n", + "\n", + "**핵심 평가영역:** 전술 환경 특유의 노이즈(스푸핑, 반사파, 센서 단절 등) 제거 타당성\n", + "\n", + "#### 미션 PASS 조건\n", + "\n", + "- 정량 기준: 데이터 정제 처리 속도 1,000건/초 이상, 전처리 오류율 0%\n", + "\n", + "\n", + "- 진행 기준: 정제 파이프라인 자동화 및 단위 테스트 케이스 5종 통과\n", + "\n", + "\n", + "- 검증 방법: pytest 기반 전처리 정합성 테스트 및 결측치 보정율 확인\n", + "\n", + "\n", + "#### 채점자 판정 메모\n", + "\n", + "> 단순 코드 실행 여부뿐만 아니라 출력된 수치와 근거 자료가 군 실무 요구사항에 부합하는지 면밀히 확인한다.\n", + "\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "\"\"\"SNS 게시물 텍스트의 유니코드 정규화·URL·멘션·특수문자·이모지·불용어 제거와 결측 처리, 이미지 지각 해시(pHash) 추출을 수행하고, 정규화 본문과 이미지 해시로 중복 게시물을 걸러내는 전처리 모듈을 구현해 처리 속도와 정합성을 검증한다.\"\"\"\n", + "import re\n", + "import time\n", + "import unicodedata\n", + "import numpy as np\n", + "import pandas as pd\n", + "import matplotlib.pyplot as plt\n", + "from scipy import ndimage\n", + "from scipy.fft import dctn\n", + "\n", + "# ---------------------------------------------------------------------------\n", + "# [교육용가상자료] 합성 SNS 게시물 생성기 (실제 계정·채널·지명·게시물 없음)\n", + "# 가상 사건마다 원본 제보, 재게시(복제), 유사 재작성, 조작·기만 게시물 10종과 32x32 합성 이미지를 만든다.\n", + "# ---------------------------------------------------------------------------\n", + "REGIONS = [f\"{a}-{n}\" for a in \"ABCDEF\" for n in range(1, 6)]\n", + "OBJECTS = [\"군용 차량\", \"소형 드론\", \"보급 트럭\", \"헬기\", \"버스\", \"견인 차량\", \"트레일러\", \"민간 승용차\"]\n", + "DIRS = [\"북쪽\", \"남쪽\", \"동쪽\", \"서쪽\"]\n", + "TEMPLATES = [\"가상 {r} 지역에서 {o} 목격, {n}대 규모로 {d}으로 이동 중\",\n", + " \"{r} 일대 {o} 확인됨. 약 {n}대, {d} 방향으로 이동\",\n", + " \"[현지] {r}에서 {o} 발견 - 수량 {n}, 이동 방향 {d}\",\n", + " \"{r} 주민 제보: {o} {n}대가 {d}으로 향하는 모습\"]\n", + "MANIP = [\"num_change\", \"region_swap\", \"dir_flip\", \"old_image\", \"splice\", \"ai_image\", \"ai_text\", \"bot_burst\", \"mashup\", \"recycled\"]\n", + "ACCOUNT_TYPES = { # 유형별 계정 속성 분포(가상): 나이(일), 팔로워, 하루 게시 수, 원본 비율, 과거 정확도, 게시 간격 변동계수\n", + " \"media\": ((1000, 4000), (5e4, 5e5), (5, 30), (.6, .9), (.85, .97), (.8, 1.5)),\n", + " \"witness\": ((10, 800), (20, 2000), (.2, 3), (.7, 1.0), (.6, .95), (1.0, 2.0)),\n", + " \"aggregator\": ((200, 2000), (1e4, 2e5), (30, 200), (.05, .2), (.6, .85), (.5, 1.0)),\n", + " \"propaganda\": ((5, 200), (100, 2e4), (20, 150), (.3, .7), (.1, .4), (.05, .3)),\n", + "}\n", + "\n", + "\n", + "def make_accounts(rng, n=(8, 30, 10, 20)):\n", + " acc = {}\n", + " for (t, spec), k in zip(ACCOUNT_TYPES.items(), n):\n", + " for i in range(k):\n", + " v = [rng.uniform(*r) for r in spec]\n", + " acc[f\"ACC_{t[:3].upper()}{i:02d}\"] = {\"type\": t, \"age_days\": int(v[0]), \"followers\": int(v[1]), \"posts_per_day\": round(v[2], 1),\n", + " \"original_ratio\": round(v[3], 2), \"past_accuracy\": round(v[4], 2), \"interval_cv\": round(v[5], 2)}\n", + " return acc\n", + "\n", + "\n", + "def event_image(seed):\n", + " \"\"\"사건 현장 사진을 흉내 낸 32x32 합성 이미지(사건마다 고유한 도형 배치).\"\"\"\n", + " r = np.random.default_rng(seed)\n", + " img = np.linspace(r.uniform(60, 120), r.uniform(120, 200), 32)[:, None] * np.ones((1, 32))\n", + " for _ in range(int(r.integers(3, 7))):\n", + " y, x, h, w = r.integers(0, 26, 4)\n", + " img[y:y + 3 + h % 8, x:x + 3 + w % 8] = r.uniform(20, 240)\n", + " return img + r.normal(0, 4, (32, 32))\n", + "\n", + "\n", + "def variant(img, rng):\n", + " \"\"\"재게시 과정의 자연스러운 변형: 재압축 잡음, 밝기 변화, 가장자리 잘림 중 하나.\"\"\"\n", + " k = rng.integers(0, 3)\n", + " if k == 0:\n", + " return ndimage.gaussian_filter(img, 0.6) + rng.normal(0, 3, img.shape)\n", + " if k == 1:\n", + " return img * rng.uniform(0.85, 1.15)\n", + " return ndimage.zoom(img[2:30, 2:30], 32 / 28, order=1)\n", + "\n", + "\n", + "def splice(img, other, rng):\n", + " out = img.copy()\n", + " y, x = rng.integers(4, 18, 2)\n", + " out[y:y + 12, x:x + 12] = other[y:y + 12, x:x + 12] + rng.normal(0, 12, (12, 12)) # 잡음 수준이 다른 조각을 붙임\n", + " return out\n", + "\n", + "\n", + "def ai_image(rng):\n", + " \"\"\"생성형 모델 이미지를 흉내 낸 합성 이미지: 지나치게 매끈한 바탕 + 업샘플링 격자 무늬(주기 2픽셀).\"\"\"\n", + " base = ndimage.gaussian_filter(rng.normal(130, 60, (32, 32)), 3)\n", + " grid = (np.indices((32, 32)).sum(0) % 2) * rng.uniform(6, 12)\n", + " return base + grid\n", + "\n", + "\n", + "def write(ev, rng, tpl=None, **kw):\n", + " s = {\"r\": ev[\"r\"], \"o\": ev[\"o\"], \"n\": ev[\"n\"], \"d\": ev[\"d\"], **kw}\n", + " return (tpl or TEMPLATES[int(rng.integers(len(TEMPLATES)))]).format(**s)\n", + "\n", + "\n", + "def make_posts(n_events=40, seed=7):\n", + " rng = np.random.default_rng(seed)\n", + " acc = make_accounts(rng)\n", + " by = {t: [a for a, v in acc.items() if v[\"type\"] == t] for t in ACCOUNT_TYPES}\n", + " events = [{\"id\": f\"EV{i:03d}\", \"r\": str(rng.choice(REGIONS)), \"o\": str(rng.choice(OBJECTS)), \"n\": int(rng.integers(2, 15)),\n", + " \"d\": str(rng.choice(DIRS)), \"t0\": float(i * 1.2 + rng.uniform(0, 1)), \"img\": event_image(seed * 1000 + i)} for i in range(n_events)]\n", + " posts = []\n", + "\n", + " def add(ev, account, text, img, t, fwd, kind, origin):\n", + " pid = f\"P{len(posts):04d}\"\n", + " posts.append({\"post_id\": pid, \"platform\": str(rng.choice([\"telegram\", \"x\", \"other\"], p=[.6, .3, .1])), \"account_id\": account,\n", + " \"posted_at\": round(t, 3), \"text\": text, \"image\": img, \"forwarded_from\": fwd, \"event_id\": ev[\"id\"] if ev else None,\n", + " \"kind\": kind, \"is_disinfo\": kind in MANIP, \"origin\": origin})\n", + " return pid\n", + "\n", + " for k, ev in enumerate(events):\n", + " src = str(rng.choice(by[\"witness\"] if rng.random() < .6 else by[\"media\"]))\n", + " if rng.random() < .05:\n", + " src = str(rng.choice(by[\"propaganda\"])) # 신뢰도 낮은 계정도 사실을 올릴 수 있다\n", + " o = add(ev, src, write(ev, rng), ev[\"img\"], ev[\"t0\"], None, \"original\", None)\n", + " for _ in range(int(rng.integers(2, 5))): # 그대로 재게시(전달)\n", + " add(ev, str(rng.choice(by[\"aggregator\"])), posts[int(o[1:])][\"text\"], ev[\"img\"], ev[\"t0\"] + rng.exponential(1), o, \"copy\", o)\n", + " for _ in range(int(rng.integers(1, 3))): # 표현만 바꾼 재작성(내용 동일)\n", + " add(ev, str(rng.choice(by[\"media\"] + by[\"witness\"])), \"#가상속보 \" + write(ev, rng), variant(ev[\"img\"], rng),\n", + " ev[\"t0\"] + rng.uniform(0.5, 6), None, \"near_dup\", o)\n", + " for m in MANIP if k % 2 == 0 else MANIP[k % 4::4]: # 조작·기만 게시물\n", + " if rng.random() > 0.5:\n", + " continue\n", + " who = str(rng.choice(by[\"propaganda\"] if rng.random() < .75 else by[\"aggregator\"] + by[\"witness\"]))\n", + " t, img, text = ev[\"t0\"] + rng.uniform(1, 12), variant(ev[\"img\"], rng), None\n", + " if m == \"num_change\":\n", + " text = write(ev, rng, n=ev[\"n\"] * int(rng.integers(5, 10)))\n", + " elif m == \"region_swap\":\n", + " text = write(ev, rng, r=str(rng.choice([r for r in REGIONS if r != ev[\"r\"]])))\n", + " elif m == \"dir_flip\":\n", + " text = write(ev, rng, d={\"북쪽\": \"남쪽\", \"남쪽\": \"북쪽\", \"동쪽\": \"서쪽\", \"서쪽\": \"동쪽\"}[ev[\"d\"]])\n", + " elif m == \"old_image\" and k > 0:\n", + " text, img = write(ev, rng), events[int(rng.integers(0, k))][\"img\"]\n", + " elif m == \"splice\":\n", + " text, img = write(ev, rng), splice(ev[\"img\"], events[(k + 7) % n_events][\"img\"], rng)\n", + " elif m == \"ai_image\":\n", + " text, img = write(ev, rng), ai_image(rng)\n", + " elif m == \"ai_text\":\n", + " text = f\"속보 속보! {ev['r']} 지역 대규모 {ev['o']} 출현! 긴급 확산 바람! 긴급 확산 바람! 모두 확산 바람!\"\n", + " img = ai_image(rng) if rng.random() < .5 else None\n", + " elif m == \"bot_burst\":\n", + " text = write(ev, rng, n=ev[\"n\"] * 6) + \" 지금 바로 공유\"\n", + " for b in rng.choice(by[\"propaganda\"], 4, replace=False):\n", + " add(ev, str(b), text, img, t + rng.uniform(0, 0.15), None, m, o)\n", + " continue\n", + " elif m == \"mashup\":\n", + " other = events[(k + 3) % n_events]\n", + " text = f\"{ev['r']} 일대 {ev['o']} 확인, {other['n']}대가 {other['d']}으로 이동\" # 다른 사건 내용과 짜깁기\n", + " elif m == \"recycled\" and k > 2:\n", + " old = events[int(rng.integers(0, k - 1))]\n", + " text, img, t = write(old, rng), old[\"img\"], ev[\"t0\"] + rng.uniform(0, 3)\n", + " if text is None:\n", + " continue\n", + " add(ev, who, text, img, t, None, m, o)\n", + " return posts, acc\n", + "# ---------------------------------------------------------------------------\n", + "\n", + "# ---- 텍스트 정제·이미지 지각 해시·조작 흔적 공통 함수 ----\n", + "STOPWORDS = {\"가상\", \"약\", \"일대\", \"지역에서\", \"모습\", \"중\", \"확인됨\", \"발견\", \"제보\", \"주민\"}\n", + "_URL = re.compile(r\"https?://\\S+|www\\.\\S+\")\n", + "_SPECIAL = re.compile(r\"[^0-9A-Za-z가-힣\\-\\s]\")\n", + "\n", + "\n", + "def clean_text(t):\n", + " \"\"\"정규화(NFKC) -> URL·멘션 제거 -> 해시태그 기호 제거 -> 특수문자 제거 -> 불용어 제거 -> 공백 정리.\"\"\"\n", + " t = unicodedata.normalize(\"NFKC\", t)\n", + " t = _URL.sub(\" \", t)\n", + " t = re.sub(r\"@\\w+\", \" \", t).replace(\"#\", \" \")\n", + " t = _SPECIAL.sub(\" \", t)\n", + " return \" \".join(w for w in t.split() if w not in STOPWORDS)\n", + "\n", + "\n", + "def phash(img):\n", + " \"\"\"지각 해시(pHash): 32x32 이미지 DCT의 저주파 8x8(직류 성분 제외)을 중앙값과 비교한 64비트.\"\"\"\n", + " c = dctn(np.asarray(img, float), norm=\"ortho\")[:8, :8].ravel()[1:]\n", + " return np.append(c > np.median(c), False)\n", + "\n", + "\n", + "def hamming(a, b):\n", + " return int(np.count_nonzero(a != b))\n", + "\n", + "\n", + "def slots(text):\n", + " \"\"\"본문에서 지역 코드·수량·방향·대상을 뽑는다(사건 내용 비교용).\"\"\"\n", + " r = re.search(r\"[A-F]-\\d\", text)\n", + " n = re.search(r\"(\\d+)\\s*대|수량\\s*(\\d+)\", text)\n", + " d = next((x for x in DIRS if x in text), None)\n", + " o = next((x for x in OBJECTS if x in text), None)\n", + " return {\"r\": r.group(0) if r else None, \"n\": int(n.group(1) or n.group(2)) if n else None, \"d\": d, \"o\": o}\n", + "\n", + "\n", + "def splice_score(img):\n", + " \"\"\"합성(붙여넣기) 흔적: 8x8 블록별 고주파 잡음 크기의 최대/중앙값 비율. 잡음 수준이 다른 조각이 있으면 커진다.\"\"\"\n", + " res = img - ndimage.median_filter(img, 3)\n", + " b = np.abs(res).reshape(4, 8, 4, 8).std(axis=(1, 3))\n", + " return float(b.max() / (np.median(b) + 1e-6))\n", + "\n", + "\n", + "def ai_image_score(img):\n", + " \"\"\"생성형 이미지 흔적: 2픽셀 주기 격자(나이퀴스트 주파수) 에너지 비율.\"\"\"\n", + " f = np.abs(np.fft.fft2(img - img.mean()))\n", + " return float(f[16, 16] / (f.sum() / f.size + 1e-6))\n", + "\n", + "\n", + "def ai_text_score(text):\n", + " \"\"\"기계적 선동 문체 흔적: 반복 어절 비율 + 느낌표 밀도.\"\"\"\n", + " w = text.split()\n", + " return (1 - len(set(w)) / max(len(w), 1)) + text.count(\"!\") / max(len(w), 1)\n", + "\n", + "posts, acc = make_posts(60, seed=7)\n", + "rng = np.random.default_rng(3)\n", + "FIRE, FLAG = chr(0x1F525), chr(0x1F6A8) # 이모지(코드값으로 생성)\n", + "for p in posts: # 수집 과정의 잡음 주입\n", + " r = rng.random()\n", + " if r < .15:\n", + " p[\"text\"] += f\" https://example.invalid/{rng.integers(1e6)}\"\n", + " elif r < .3:\n", + " p[\"text\"] = \"@ch_virtual \" + p[\"text\"] + \" \" + FIRE + FLAG\n", + " elif r < .4:\n", + " p[\"text\"] = unicodedata.normalize(\"NFKC\", p[\"text\"]).replace(\"A\", \"A\").replace(\"1\", \"1\") # 전각 문자\n", + " elif r < .43:\n", + " p[\"text\"] = None # 본문 결측(이미지만 게시)\n", + "\n", + "# 1) 처리 속도와 오류율\n", + "t0, out, fails = time.perf_counter(), [], 0\n", + "for p in posts:\n", + " try:\n", + " txt = clean_text(p[\"text\"] or \"\")\n", + " out.append({\"id\": p[\"post_id\"], \"t\": p[\"posted_at\"], \"clean\": txt, \"missing\": p[\"text\"] is None,\n", + " \"hash\": phash(p[\"image\"]) if p[\"image\"] is not None else None})\n", + " except Exception:\n", + " fails += 1\n", + "dt = time.perf_counter() - t0\n", + "print(f\"전처리 {len(posts)}건, {len(posts) / dt:,.0f}건/초 (목표 1,000건/초 이상), 실패 {fails}건 -> 오류율 {fails / len(posts):.0%}\")\n", + "miss = [o for o in out if o[\"missing\"]]\n", + "print(f\"본문 결측 {len(miss)}건: 빈 문자열로 보정하고 '이미지 단독 게시물'로 표시(보정율 100%)\")\n", + "\n", + "# 2) 중복 제거: 같은 정규화 본문 + 이미지 해시 거리 4 이하(또는 둘 다 없음)는 같은 게시물로 보고 가장 이른 것만 남긴다\n", + "kept, dup_of = [], {}\n", + "for o in sorted(out, key=lambda x: x[\"t\"]):\n", + " same = next((k for k in kept if k[\"clean\"] == o[\"clean\"] and o[\"clean\"] and\n", + " ((k[\"hash\"] is None and o[\"hash\"] is None) or (k[\"hash\"] is not None and o[\"hash\"] is not None and hamming(k[\"hash\"], o[\"hash\"]) <= 4))), None)\n", + " if same:\n", + " dup_of[o[\"id\"]] = same[\"id\"]\n", + " else:\n", + " kept.append(o)\n", + "kinds = {p[\"post_id\"]: p[\"kind\"] for p in posts}\n", + "print(f\"중복 제거: {len(out)}건 -> {len(kept)}건 (제거 {len(dup_of)}건, 그중 실제 재게시(copy) {sum(kinds[d] == 'copy' for d in dup_of)}건 / 조직적 동시 게시 {sum(kinds[d] == 'bot_burst' for d in dup_of)}건)\")\n", + "print(f\"전각 문자·이모지·URL 차이는 정제로 흡수되고, 본문 결측 등으로 남은 재게시 {sum(kinds[k['id']] == 'copy' for k in kept)}건은 미션 3의 유사도 판별로 넘긴다\")\n", + "\n", + "# 3) 단위 테스트 5종\n", + "e1, e2 = event_image(1), event_image(2)\n", + "tests = {\n", + " \"URL·멘션 제거\": clean_text(\"@abc 보고 https://x.invalid/1 확인\") == \"보고 확인\",\n", + " \"이모지·특수문자 제거\": FIRE not in clean_text(\"정전 \" + FIRE + \"!!\") and \"!\" not in clean_text(\"정전!!\"),\n", + " \"전각 문자 정규화(NFKC)\": clean_text(\"A-1 정전\") == \"A-1 정전\",\n", + " \"pHash 재압축 강건성(거리 8 이하)\": hamming(phash(e1), phash(ndimage.gaussian_filter(e1, 0.6) + rng.normal(0, 3, e1.shape))) <= 8,\n", + " \"pHash 다른 사진 구분(거리 16 이상)\": hamming(phash(e1), phash(e2)) >= 16,\n", + "}\n", + "for k, v in tests.items():\n", + " print(f\"[{'PASS' if v else 'FAIL'}] {k}\")\n", + "print(f\"단위 테스트 {sum(tests.values())}/5 통과\")\n", + "\n", + "# 4) 정제 전후 예시\n", + "ex = [next(p for p in posts if p[\"text\"] and key in p[\"text\"]) for key in (\"http\", \"@\", \"\\uff21\")]\n", + "print(\"\\n[정제 전후 예시]\")\n", + "for p in ex:\n", + " print(\" 전:\", p[\"text\"].replace(FIRE, \"\").replace(FLAG, \"\"))\n", + " print(\" 후:\", clean_text(p[\"text\"]))\n", + "\n", + "d_same = [hamming(phash(p[\"image\"]), phash(posts[int(p[\"origin\"][1:])][\"image\"])) for p in posts if p[\"kind\"] in (\"copy\", \"near_dup\") and p[\"image\"] is not None]\n", + "d_diff = [hamming(phash(posts[i][\"image\"]), phash(posts[j][\"image\"])) for i, j in rng.integers(0, len(posts), (200, 2))\n", + " if posts[i][\"event_id\"] != posts[j][\"event_id\"] and posts[i][\"image\"] is not None and posts[j][\"image\"] is not None]\n", + "plt.figure(figsize=(7, 4))\n", + "plt.hist([d_same, d_diff], bins=32, range=(0, 64), label=[\"same event (repost)\", \"different events\"])\n", + "plt.axvline(8, ls=\"--\", c=\"k\"); plt.xlabel(\"pHash Hamming distance\"); plt.ylabel(\"pairs\"); plt.legend(); plt.title(\"Perceptual hash distance (synthetic images)\")\n", + "plt.show()\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 02 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + "전처리 573건, 6,551건/초 (목표 1,000건/초 이상), 실패 0건 -> 오류율 0%\n", + "본문 결측 14건: 빈 문자열로 보정하고 '이미지 단독 게시물'로 표시(보정율 100%)\n", + "중복 제거: 573건 -> 340건 (제거 233건, 그중 실제 재게시(copy) 169건 / 조직적 동시 게시 53건)\n", + "전각 문자·이모지·URL 차이는 정제로 흡수되고, 본문 결측 등으로 남은 재게시 6건은 미션 3의 유사도 판별로 넘긴다\n", + "[PASS] URL·멘션 제거\n", + "[PASS] 이모지·특수문자 제거\n", + "[PASS] 전각 문자 정규화(NFKC)\n", + "[PASS] pHash 재압축 강건성(거리 8 이하)\n", + "[PASS] pHash 다른 사진 구분(거리 16 이상)\n", + "단위 테스트 5/5 통과\n", + "\n", + "[정제 전후 예시]\n", + " 전: [현지] A-4에서 보급 트럭 발견 - 수량 9, 이동 방향 서쪽 https://example.invalid/179440\n", + " 후: 현지 A-4에서 보급 트럭 - 수량 9 이동 방향 서쪽\n", + " 전: @ch_virtual [현지] A-4에서 보급 트럭 발견 - 수량 9, 이동 방향 동쪽 \n", + " 후: 현지 A-4에서 보급 트럭 - 수량 9 이동 방향 동쪽\n", + " 전: A-2 주민 제보: 버스 8대가 서쪽으로 향하는 모습\n", + " 후: A-2 버스 8대가 서쪽으로 향하는\n", + "```\n", + "
\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 03. 게시물 유사도 기반 단순 복제본 탐지 및 1차 출처 분류 Baseline 알고리즘 구현\n", + "**소요시간**: 약 2.5시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "게시물 간 텍스트/이미지 유사도 기반으로 단순 복제 및 인용 관계를 판별하는 1차 출처 분류 Baseline 알고리즘을 개발한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **게시물 간 텍스트·이미지 유사도로 단순 복제와 인용(재작성) 관계를 판별하고 1차 출처를 분류하는 Baseline 알고리즘을 개발한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **정제 본문의 문자 n-gram TF-IDF 코사인 유사도와 pHash 거리로 복제·재작성·무관 관계를 규칙 판정하고, 재작성 판단 기준을 바꿔 가며 정확도를 1차 조정 수행하고 검증하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `src/models/isr02_baseline_model.py 및 기본 추론 결과 보고서` 형태의 결과물을 완성한다.\n", + "\n", + "### 🔍 평가 및 검증 기준 (PASS 조건)\n", + "#### 미션 판정\n", + "\n", + "**평가 구분:** 필수\n", + "\n", + "**핵심 평가영역:** 알고리즘의 논리적 흐름 및 군사적 판단 기준의 투명성\n", + "\n", + "#### 미션 PASS 조건\n", + "\n", + "- 정량 기준: 기본 데이터셋 대상 탐지/처리 정확도 75% 이상 달성\n", + "\n", + "\n", + "- 진행 기준: 단위 기능 동작 확인 및 기본 시나리오 실행 로그 생성\n", + "\n", + "\n", + "- 검증 방법: 기준 벤치마크 데이터셋 대상 추론 결과 오차율 비교\n", + "\n", + "\n", + "#### 채점자 판정 메모\n", + "\n", + "> 단순 코드 실행 여부뿐만 아니라 출력된 수치와 근거 자료가 군 실무 요구사항에 부합하는지 면밀히 확인한다.\n", + "\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "\"\"\"게시물 쌍의 본문 유사도(문자 n-gram TF-IDF 코사인)와 이미지 지각 해시 거리로 단순 복제·재작성(인용)·무관 관계를 판별하고, 같은 이야기로 묶인 게시물 중 가장 이른 것을 1차 출처로 분류하는 Baseline 알고리즘을 구현해 정확도와 처리 지연을 측정한다.\"\"\"\n", + "import re\n", + "import time\n", + "import unicodedata\n", + "import numpy as np\n", + "import pandas as pd\n", + "import matplotlib.pyplot as plt\n", + "from scipy import ndimage\n", + "from scipy.fft import dctn\n", + "from sklearn.feature_extraction.text import TfidfVectorizer\n", + "\n", + "# ---------------------------------------------------------------------------\n", + "# [교육용가상자료] 합성 SNS 게시물 생성기 (실제 계정·채널·지명·게시물 없음)\n", + "# 가상 사건마다 원본 제보, 재게시(복제), 유사 재작성, 조작·기만 게시물 10종과 32x32 합성 이미지를 만든다.\n", + "# ---------------------------------------------------------------------------\n", + "REGIONS = [f\"{a}-{n}\" for a in \"ABCDEF\" for n in range(1, 6)]\n", + "OBJECTS = [\"군용 차량\", \"소형 드론\", \"보급 트럭\", \"헬기\", \"버스\", \"견인 차량\", \"트레일러\", \"민간 승용차\"]\n", + "DIRS = [\"북쪽\", \"남쪽\", \"동쪽\", \"서쪽\"]\n", + "TEMPLATES = [\"가상 {r} 지역에서 {o} 목격, {n}대 규모로 {d}으로 이동 중\",\n", + " \"{r} 일대 {o} 확인됨. 약 {n}대, {d} 방향으로 이동\",\n", + " \"[현지] {r}에서 {o} 발견 - 수량 {n}, 이동 방향 {d}\",\n", + " \"{r} 주민 제보: {o} {n}대가 {d}으로 향하는 모습\"]\n", + "MANIP = [\"num_change\", \"region_swap\", \"dir_flip\", \"old_image\", \"splice\", \"ai_image\", \"ai_text\", \"bot_burst\", \"mashup\", \"recycled\"]\n", + "ACCOUNT_TYPES = { # 유형별 계정 속성 분포(가상): 나이(일), 팔로워, 하루 게시 수, 원본 비율, 과거 정확도, 게시 간격 변동계수\n", + " \"media\": ((1000, 4000), (5e4, 5e5), (5, 30), (.6, .9), (.85, .97), (.8, 1.5)),\n", + " \"witness\": ((10, 800), (20, 2000), (.2, 3), (.7, 1.0), (.6, .95), (1.0, 2.0)),\n", + " \"aggregator\": ((200, 2000), (1e4, 2e5), (30, 200), (.05, .2), (.6, .85), (.5, 1.0)),\n", + " \"propaganda\": ((5, 200), (100, 2e4), (20, 150), (.3, .7), (.1, .4), (.05, .3)),\n", + "}\n", + "\n", + "\n", + "def make_accounts(rng, n=(8, 30, 10, 20)):\n", + " acc = {}\n", + " for (t, spec), k in zip(ACCOUNT_TYPES.items(), n):\n", + " for i in range(k):\n", + " v = [rng.uniform(*r) for r in spec]\n", + " acc[f\"ACC_{t[:3].upper()}{i:02d}\"] = {\"type\": t, \"age_days\": int(v[0]), \"followers\": int(v[1]), \"posts_per_day\": round(v[2], 1),\n", + " \"original_ratio\": round(v[3], 2), \"past_accuracy\": round(v[4], 2), \"interval_cv\": round(v[5], 2)}\n", + " return acc\n", + "\n", + "\n", + "def event_image(seed):\n", + " \"\"\"사건 현장 사진을 흉내 낸 32x32 합성 이미지(사건마다 고유한 도형 배치).\"\"\"\n", + " r = np.random.default_rng(seed)\n", + " img = np.linspace(r.uniform(60, 120), r.uniform(120, 200), 32)[:, None] * np.ones((1, 32))\n", + " for _ in range(int(r.integers(3, 7))):\n", + " y, x, h, w = r.integers(0, 26, 4)\n", + " img[y:y + 3 + h % 8, x:x + 3 + w % 8] = r.uniform(20, 240)\n", + " return img + r.normal(0, 4, (32, 32))\n", + "\n", + "\n", + "def variant(img, rng):\n", + " \"\"\"재게시 과정의 자연스러운 변형: 재압축 잡음, 밝기 변화, 가장자리 잘림 중 하나.\"\"\"\n", + " k = rng.integers(0, 3)\n", + " if k == 0:\n", + " return ndimage.gaussian_filter(img, 0.6) + rng.normal(0, 3, img.shape)\n", + " if k == 1:\n", + " return img * rng.uniform(0.85, 1.15)\n", + " return ndimage.zoom(img[2:30, 2:30], 32 / 28, order=1)\n", + "\n", + "\n", + "def splice(img, other, rng):\n", + " out = img.copy()\n", + " y, x = rng.integers(4, 18, 2)\n", + " out[y:y + 12, x:x + 12] = other[y:y + 12, x:x + 12] + rng.normal(0, 12, (12, 12)) # 잡음 수준이 다른 조각을 붙임\n", + " return out\n", + "\n", + "\n", + "def ai_image(rng):\n", + " \"\"\"생성형 모델 이미지를 흉내 낸 합성 이미지: 지나치게 매끈한 바탕 + 업샘플링 격자 무늬(주기 2픽셀).\"\"\"\n", + " base = ndimage.gaussian_filter(rng.normal(130, 60, (32, 32)), 3)\n", + " grid = (np.indices((32, 32)).sum(0) % 2) * rng.uniform(6, 12)\n", + " return base + grid\n", + "\n", + "\n", + "def write(ev, rng, tpl=None, **kw):\n", + " s = {\"r\": ev[\"r\"], \"o\": ev[\"o\"], \"n\": ev[\"n\"], \"d\": ev[\"d\"], **kw}\n", + " return (tpl or TEMPLATES[int(rng.integers(len(TEMPLATES)))]).format(**s)\n", + "\n", + "\n", + "def make_posts(n_events=40, seed=7):\n", + " rng = np.random.default_rng(seed)\n", + " acc = make_accounts(rng)\n", + " by = {t: [a for a, v in acc.items() if v[\"type\"] == t] for t in ACCOUNT_TYPES}\n", + " events = [{\"id\": f\"EV{i:03d}\", \"r\": str(rng.choice(REGIONS)), \"o\": str(rng.choice(OBJECTS)), \"n\": int(rng.integers(2, 15)),\n", + " \"d\": str(rng.choice(DIRS)), \"t0\": float(i * 1.2 + rng.uniform(0, 1)), \"img\": event_image(seed * 1000 + i)} for i in range(n_events)]\n", + " posts = []\n", + "\n", + " def add(ev, account, text, img, t, fwd, kind, origin):\n", + " pid = f\"P{len(posts):04d}\"\n", + " posts.append({\"post_id\": pid, \"platform\": str(rng.choice([\"telegram\", \"x\", \"other\"], p=[.6, .3, .1])), \"account_id\": account,\n", + " \"posted_at\": round(t, 3), \"text\": text, \"image\": img, \"forwarded_from\": fwd, \"event_id\": ev[\"id\"] if ev else None,\n", + " \"kind\": kind, \"is_disinfo\": kind in MANIP, \"origin\": origin})\n", + " return pid\n", + "\n", + " for k, ev in enumerate(events):\n", + " src = str(rng.choice(by[\"witness\"] if rng.random() < .6 else by[\"media\"]))\n", + " if rng.random() < .05:\n", + " src = str(rng.choice(by[\"propaganda\"])) # 신뢰도 낮은 계정도 사실을 올릴 수 있다\n", + " o = add(ev, src, write(ev, rng), ev[\"img\"], ev[\"t0\"], None, \"original\", None)\n", + " for _ in range(int(rng.integers(2, 5))): # 그대로 재게시(전달)\n", + " add(ev, str(rng.choice(by[\"aggregator\"])), posts[int(o[1:])][\"text\"], ev[\"img\"], ev[\"t0\"] + rng.exponential(1), o, \"copy\", o)\n", + " for _ in range(int(rng.integers(1, 3))): # 표현만 바꾼 재작성(내용 동일)\n", + " add(ev, str(rng.choice(by[\"media\"] + by[\"witness\"])), \"#가상속보 \" + write(ev, rng), variant(ev[\"img\"], rng),\n", + " ev[\"t0\"] + rng.uniform(0.5, 6), None, \"near_dup\", o)\n", + " for m in MANIP if k % 2 == 0 else MANIP[k % 4::4]: # 조작·기만 게시물\n", + " if rng.random() > 0.5:\n", + " continue\n", + " who = str(rng.choice(by[\"propaganda\"] if rng.random() < .75 else by[\"aggregator\"] + by[\"witness\"]))\n", + " t, img, text = ev[\"t0\"] + rng.uniform(1, 12), variant(ev[\"img\"], rng), None\n", + " if m == \"num_change\":\n", + " text = write(ev, rng, n=ev[\"n\"] * int(rng.integers(5, 10)))\n", + " elif m == \"region_swap\":\n", + " text = write(ev, rng, r=str(rng.choice([r for r in REGIONS if r != ev[\"r\"]])))\n", + " elif m == \"dir_flip\":\n", + " text = write(ev, rng, d={\"북쪽\": \"남쪽\", \"남쪽\": \"북쪽\", \"동쪽\": \"서쪽\", \"서쪽\": \"동쪽\"}[ev[\"d\"]])\n", + " elif m == \"old_image\" and k > 0:\n", + " text, img = write(ev, rng), events[int(rng.integers(0, k))][\"img\"]\n", + " elif m == \"splice\":\n", + " text, img = write(ev, rng), splice(ev[\"img\"], events[(k + 7) % n_events][\"img\"], rng)\n", + " elif m == \"ai_image\":\n", + " text, img = write(ev, rng), ai_image(rng)\n", + " elif m == \"ai_text\":\n", + " text = f\"속보 속보! {ev['r']} 지역 대규모 {ev['o']} 출현! 긴급 확산 바람! 긴급 확산 바람! 모두 확산 바람!\"\n", + " img = ai_image(rng) if rng.random() < .5 else None\n", + " elif m == \"bot_burst\":\n", + " text = write(ev, rng, n=ev[\"n\"] * 6) + \" 지금 바로 공유\"\n", + " for b in rng.choice(by[\"propaganda\"], 4, replace=False):\n", + " add(ev, str(b), text, img, t + rng.uniform(0, 0.15), None, m, o)\n", + " continue\n", + " elif m == \"mashup\":\n", + " other = events[(k + 3) % n_events]\n", + " text = f\"{ev['r']} 일대 {ev['o']} 확인, {other['n']}대가 {other['d']}으로 이동\" # 다른 사건 내용과 짜깁기\n", + " elif m == \"recycled\" and k > 2:\n", + " old = events[int(rng.integers(0, k - 1))]\n", + " text, img, t = write(old, rng), old[\"img\"], ev[\"t0\"] + rng.uniform(0, 3)\n", + " if text is None:\n", + " continue\n", + " add(ev, who, text, img, t, None, m, o)\n", + " return posts, acc\n", + "# ---------------------------------------------------------------------------\n", + "\n", + "# ---- 텍스트 정제·이미지 지각 해시·조작 흔적 공통 함수 ----\n", + "STOPWORDS = {\"가상\", \"약\", \"일대\", \"지역에서\", \"모습\", \"중\", \"확인됨\", \"발견\", \"제보\", \"주민\"}\n", + "_URL = re.compile(r\"https?://\\S+|www\\.\\S+\")\n", + "_SPECIAL = re.compile(r\"[^0-9A-Za-z가-힣\\-\\s]\")\n", + "\n", + "\n", + "def clean_text(t):\n", + " \"\"\"정규화(NFKC) -> URL·멘션 제거 -> 해시태그 기호 제거 -> 특수문자 제거 -> 불용어 제거 -> 공백 정리.\"\"\"\n", + " t = unicodedata.normalize(\"NFKC\", t)\n", + " t = _URL.sub(\" \", t)\n", + " t = re.sub(r\"@\\w+\", \" \", t).replace(\"#\", \" \")\n", + " t = _SPECIAL.sub(\" \", t)\n", + " return \" \".join(w for w in t.split() if w not in STOPWORDS)\n", + "\n", + "\n", + "def phash(img):\n", + " \"\"\"지각 해시(pHash): 32x32 이미지 DCT의 저주파 8x8(직류 성분 제외)을 중앙값과 비교한 64비트.\"\"\"\n", + " c = dctn(np.asarray(img, float), norm=\"ortho\")[:8, :8].ravel()[1:]\n", + " return np.append(c > np.median(c), False)\n", + "\n", + "\n", + "def hamming(a, b):\n", + " return int(np.count_nonzero(a != b))\n", + "\n", + "\n", + "def slots(text):\n", + " \"\"\"본문에서 지역 코드·수량·방향·대상을 뽑는다(사건 내용 비교용).\"\"\"\n", + " r = re.search(r\"[A-F]-\\d\", text)\n", + " n = re.search(r\"(\\d+)\\s*대|수량\\s*(\\d+)\", text)\n", + " d = next((x for x in DIRS if x in text), None)\n", + " o = next((x for x in OBJECTS if x in text), None)\n", + " return {\"r\": r.group(0) if r else None, \"n\": int(n.group(1) or n.group(2)) if n else None, \"d\": d, \"o\": o}\n", + "\n", + "\n", + "def splice_score(img):\n", + " \"\"\"합성(붙여넣기) 흔적: 8x8 블록별 고주파 잡음 크기의 최대/중앙값 비율. 잡음 수준이 다른 조각이 있으면 커진다.\"\"\"\n", + " res = img - ndimage.median_filter(img, 3)\n", + " b = np.abs(res).reshape(4, 8, 4, 8).std(axis=(1, 3))\n", + " return float(b.max() / (np.median(b) + 1e-6))\n", + "\n", + "\n", + "def ai_image_score(img):\n", + " \"\"\"생성형 이미지 흔적: 2픽셀 주기 격자(나이퀴스트 주파수) 에너지 비율.\"\"\"\n", + " f = np.abs(np.fft.fft2(img - img.mean()))\n", + " return float(f[16, 16] / (f.sum() / f.size + 1e-6))\n", + "\n", + "\n", + "def ai_text_score(text):\n", + " \"\"\"기계적 선동 문체 흔적: 반복 어절 비율 + 느낌표 밀도.\"\"\"\n", + " w = text.split()\n", + " return (1 - len(set(w)) / max(len(w), 1)) + text.count(\"!\") / max(len(w), 1)\n", + "\n", + "\n", + "def relation(sim, dist, R):\n", + " if sim >= R[\"copy_sim\"] and (dist is None or dist <= R[\"copy_ham\"]):\n", + " return \"복제\"\n", + " if sim >= R[\"quote_sim\"] or (dist is not None and dist <= R[\"quote_ham\"]):\n", + " return \"재작성\"\n", + " return \"무관\"\n", + "\n", + "\n", + "R0 = {\"copy_sim\": 0.95, \"copy_ham\": 6, \"quote_sim\": 0.45, \"quote_ham\": 10}\n", + "posts, acc = make_posts(60, seed=11)\n", + "true_posts = [p for p in posts if not p[\"is_disinfo\"]]\n", + "idx = {p[\"post_id\"]: i for i, p in enumerate(posts)}\n", + "X = TfidfVectorizer(analyzer=\"char_wb\", ngram_range=(2, 3)).fit_transform([clean_text(p[\"text\"]) for p in posts])\n", + "H = [phash(p[\"image\"]) if p[\"image\"] is not None else None for p in posts]\n", + "dist = lambda i, j: hamming(H[i], H[j]) if H[i] is not None and H[j] is not None else None\n", + "\n", + "# 1) 평가용 게시물 쌍: 복제(전달), 재작성, 무관(다른 사건)\n", + "rng = np.random.default_rng(0)\n", + "pairs = [(idx[p[\"post_id\"]], idx[p[\"origin\"]], {\"copy\": \"복제\", \"near_dup\": \"재작성\"}[p[\"kind\"]]) for p in true_posts if p[\"kind\"] in (\"copy\", \"near_dup\")]\n", + "while sum(1 for *_, l in pairs if l == \"무관\") < 150:\n", + " i, j = rng.integers(0, len(posts), 2)\n", + " if posts[i][\"event_id\"] != posts[j][\"event_id\"] and not posts[i][\"is_disinfo\"] and not posts[j][\"is_disinfo\"]:\n", + " pairs.append((int(i), int(j), \"무관\"))\n", + "t0 = time.perf_counter()\n", + "sims = [(X[i] @ X[j].T).toarray()[0, 0] for i, j, _ in pairs]\n", + "pred = [relation(s, dist(i, j), R0) for s, (i, j, _) in zip(sims, pairs)]\n", + "lat = (time.perf_counter() - t0) / len(pairs) * 1000\n", + "truth = [l for *_, l in pairs]\n", + "acc0 = np.mean([a == b for a, b in zip(pred, truth)])\n", + "print(f\"게시물 쌍 {len(pairs)}개 관계 판별 정확도 {acc0:.1%} (목표 75% 이상: {'달성' if acc0 >= 0.75 else '미달'}), 쌍당 {lat:.2f} ms\")\n", + "print(pd.crosstab(pd.Series(truth, name=\"실제\"), pd.Series(pred, name=\"판별\")).to_string())\n", + "\n", + "# 2) 휴리스틱 가중치 1차 튜닝: 재작성 판단 유사도 기준\n", + "print(\"\\n재작성 기준(유사도) 정확도\")\n", + "for q in [0.3, 0.45, 0.6, 0.75]:\n", + " pr = [relation(s, dist(i, j), {**R0, \"quote_sim\": q}) for s, (i, j, _) in zip(sims, pairs)]\n", + " print(f\" {q:.2f} {np.mean([a == b for a, b in zip(pr, truth)]):.1%}\")\n", + "\n", + "# 3) 1차 출처 분류: 전달 경로(forwarded_from)를 거슬러 올라가고, 경로가 없으면 같은 이야기 중 가장 이른 게시물을 출처로 본다\n", + "order = sorted(range(len(true_posts)), key=lambda k: true_posts[k][\"posted_at\"])\n", + "tp = [idx[p[\"post_id\"]] for p in true_posts]\n", + "src, label = {}, {}\n", + "for k in order:\n", + " i = tp[k]; p = posts[i]\n", + " if p[\"forwarded_from\"]:\n", + " j = idx[p[\"forwarded_from\"]]\n", + " while posts[j][\"forwarded_from\"]:\n", + " j = idx[posts[j][\"forwarded_from\"]]\n", + " src[i], label[i] = j, \"전달\"\n", + " continue\n", + " cand = [j for j in src if src[j] == j and relation((X[i] @ X[j].T).toarray()[0, 0], dist(i, j), R0) != \"무관\"]\n", + " if cand:\n", + " src[i], label[i] = src[min(cand, key=lambda j: posts[j][\"posted_at\"])], \"재작성\"\n", + " else:\n", + " src[i], label[i] = i, \"1차 출처\"\n", + "true_lab = {\"original\": \"1차 출처\", \"copy\": \"전달\", \"near_dup\": \"재작성\"}\n", + "ok_lab = np.mean([label[i] == true_lab[posts[i][\"kind\"]] for i in tp])\n", + "ok_src = np.mean([posts[src[i]][\"post_id\"] == (posts[i][\"origin\"] or posts[i][\"post_id\"]) for i in tp])\n", + "print(f\"\\n1차 출처 분류(사실 게시물 {len(tp)}건): 유형 정확도 {ok_lab:.1%}, 출처 게시물 일치율 {ok_src:.1%}\")\n", + "print(\"출처 일치율이 낮은 이유: 전달 경로가 없는 재작성 글은 같은 문형을 쓴 다른 사건 글과 혼동된다(본문 유사도만 사용).\")\n", + "print(\"한계: 조작 게시물은 원본과 표현이 비슷해 '재작성'으로 묶이며, 내용 변조 여부는 판단하지 못한다(미션 4의 동기).\")\n", + "\n", + "plt.figure(figsize=(7, 4))\n", + "for lab, c in [(\"복제\", \"tab:red\"), (\"재작성\", \"tab:orange\"), (\"무관\", \"tab:gray\")]:\n", + " sel = [k for k, t in enumerate(truth) if t == lab]\n", + " plt.scatter([sims[k] for k in sel], [dist(*pairs[k][:2]) if dist(*pairs[k][:2]) is not None else -4 for k in sel], s=12, c=c,\n", + " label={\"복제\": \"copy\", \"재작성\": \"rewrite\", \"무관\": \"unrelated\"}[lab])\n", + "plt.axvline(R0[\"quote_sim\"], ls=\"--\", c=\"k\"); plt.axhline(R0[\"quote_ham\"], ls=\":\", c=\"k\")\n", + "plt.xlabel(\"text cosine similarity\"); plt.ylabel(\"pHash distance (-4 = no image)\"); plt.legend(); plt.title(\"Pair relation baseline (synthetic posts)\")\n", + "plt.show()\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 03 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + "게시물 쌍 421개 관계 판별 정확도 98.3% (목표 75% 이상: 달성), 쌍당 0.22 ms\n", + "판별 무관 복제 재작성\n", + "실제 \n", + "무관 143 0 7\n", + "복제 0 178 0\n", + "재작성 0 0 93\n", + "\n", + "재작성 기준(유사도) 정확도\n", + " 0.30 93.3%\n", + " 0.45 98.3%\n", + " 0.60 97.9%\n", + " 0.75 96.9%\n", + "\n", + "1차 출처 분류(사실 게시물 331건): 유형 정확도 85.5%, 출처 게시물 일치율 64.0%\n", + "출처 일치율이 낮은 이유: 전달 경로가 없는 재작성 글은 같은 문형을 쓴 다른 사건 글과 혼동된다(본문 유사도만 사용).\n", + "한계: 조작 게시물은 원본과 표현이 비슷해 '재작성'으로 묶이며, 내용 변조 여부는 판단하지 못한다(미션 4의 동기).\n", + "```\n", + "
\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 04. 생성형 AI 조작 이미지/텍스트 탐지 및 출처 계정 신뢰도 평가 모델 고도화\n", + "**소요시간**: 약 3.0시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "생성형 AI 조작 텍스트/이미지 탐지 알고리즘과 작성자 계정 신뢰도 평가 모델을 결합하여 가짜 뉴스 판별 로직을 고도화한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **생성형 AI 조작 텍스트·이미지 탐지와 작성 계정 신뢰도 평가를 결합해 가짜 정보 판별 로직을 고도화한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **같은 이야기로 묶은 게시물 사이의 수량·지역·방향 불일치, 이미지 합성 잡음 불균일·생성형 격자 무늬·과거 이미지 재사용 흔적, 선동형 반복 문체, 조직적 동시 게시 특징 산출하고 모델에 적용하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `src/models/isr02_advanced_detector.py 및 예외 탐지 테스트 결과` 형태의 결과물을 완성한다.\n", + "\n", + "### 🔍 평가 및 검증 기준 (PASS 조건)\n", + "#### 미션 판정\n", + "\n", + "**평가 구분:** 필수\n", + "\n", + "**핵심 평가영역:** 기만/교란 공격에 대한 방어 로직의 현실성 및 신뢰성\n", + "\n", + "#### 미션 PASS 조건\n", + "\n", + "- 정량 기준: 복합 위협 탐지 재현율(Recall) 85% 이상, 처리 지연 200ms 이하\n", + "\n", + "\n", + "- 진행 기준: 복합 위협 시나리오 10종에 대한 탐지 테스트 통과\n", + "\n", + "\n", + "- 검증 방법: 합성 위협 데이터셋 주입 시 이상치 탐지 정확도 검증\n", + "\n", + "\n", + "#### 채점자 판정 메모\n", + "\n", + "> 단순 코드 실행 여부뿐만 아니라 출력된 수치와 근거 자료가 군 실무 요구사항에 부합하는지 면밀히 확인한다.\n", + "\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "\"\"\"본문 내용 비교(수량·지역·방향 불일치), 이미지 조작 흔적(합성 잡음 불균일, 생성형 격자 무늬, 과거 이미지 재사용), 생성형·선동 문체, 조직적 동시 게시 특징과 작성 계정 신뢰도 모델을 결합해 10종의 조작·기만 게시물을 판별하는 모델로 고도화하고 재현율·오경보·처리 지연을 측정한다.\"\"\"\n", + "import re\n", + "import time\n", + "import unicodedata\n", + "import numpy as np\n", + "import pandas as pd\n", + "import matplotlib.pyplot as plt\n", + "from scipy import ndimage\n", + "from scipy.fft import dctn\n", + "from sklearn.ensemble import RandomForestClassifier\n", + "from sklearn.feature_extraction.text import TfidfVectorizer\n", + "from sklearn.linear_model import LogisticRegression\n", + "from sklearn.pipeline import make_pipeline\n", + "from sklearn.preprocessing import StandardScaler\n", + "\n", + "# ---------------------------------------------------------------------------\n", + "# [교육용가상자료] 합성 SNS 게시물 생성기 (실제 계정·채널·지명·게시물 없음)\n", + "# 가상 사건마다 원본 제보, 재게시(복제), 유사 재작성, 조작·기만 게시물 10종과 32x32 합성 이미지를 만든다.\n", + "# ---------------------------------------------------------------------------\n", + "REGIONS = [f\"{a}-{n}\" for a in \"ABCDEF\" for n in range(1, 6)]\n", + "OBJECTS = [\"군용 차량\", \"소형 드론\", \"보급 트럭\", \"헬기\", \"버스\", \"견인 차량\", \"트레일러\", \"민간 승용차\"]\n", + "DIRS = [\"북쪽\", \"남쪽\", \"동쪽\", \"서쪽\"]\n", + "TEMPLATES = [\"가상 {r} 지역에서 {o} 목격, {n}대 규모로 {d}으로 이동 중\",\n", + " \"{r} 일대 {o} 확인됨. 약 {n}대, {d} 방향으로 이동\",\n", + " \"[현지] {r}에서 {o} 발견 - 수량 {n}, 이동 방향 {d}\",\n", + " \"{r} 주민 제보: {o} {n}대가 {d}으로 향하는 모습\"]\n", + "MANIP = [\"num_change\", \"region_swap\", \"dir_flip\", \"old_image\", \"splice\", \"ai_image\", \"ai_text\", \"bot_burst\", \"mashup\", \"recycled\"]\n", + "ACCOUNT_TYPES = { # 유형별 계정 속성 분포(가상): 나이(일), 팔로워, 하루 게시 수, 원본 비율, 과거 정확도, 게시 간격 변동계수\n", + " \"media\": ((1000, 4000), (5e4, 5e5), (5, 30), (.6, .9), (.85, .97), (.8, 1.5)),\n", + " \"witness\": ((10, 800), (20, 2000), (.2, 3), (.7, 1.0), (.6, .95), (1.0, 2.0)),\n", + " \"aggregator\": ((200, 2000), (1e4, 2e5), (30, 200), (.05, .2), (.6, .85), (.5, 1.0)),\n", + " \"propaganda\": ((5, 200), (100, 2e4), (20, 150), (.3, .7), (.1, .4), (.05, .3)),\n", + "}\n", + "\n", + "\n", + "def make_accounts(rng, n=(8, 30, 10, 20)):\n", + " acc = {}\n", + " for (t, spec), k in zip(ACCOUNT_TYPES.items(), n):\n", + " for i in range(k):\n", + " v = [rng.uniform(*r) for r in spec]\n", + " acc[f\"ACC_{t[:3].upper()}{i:02d}\"] = {\"type\": t, \"age_days\": int(v[0]), \"followers\": int(v[1]), \"posts_per_day\": round(v[2], 1),\n", + " \"original_ratio\": round(v[3], 2), \"past_accuracy\": round(v[4], 2), \"interval_cv\": round(v[5], 2)}\n", + " return acc\n", + "\n", + "\n", + "def event_image(seed):\n", + " \"\"\"사건 현장 사진을 흉내 낸 32x32 합성 이미지(사건마다 고유한 도형 배치).\"\"\"\n", + " r = np.random.default_rng(seed)\n", + " img = np.linspace(r.uniform(60, 120), r.uniform(120, 200), 32)[:, None] * np.ones((1, 32))\n", + " for _ in range(int(r.integers(3, 7))):\n", + " y, x, h, w = r.integers(0, 26, 4)\n", + " img[y:y + 3 + h % 8, x:x + 3 + w % 8] = r.uniform(20, 240)\n", + " return img + r.normal(0, 4, (32, 32))\n", + "\n", + "\n", + "def variant(img, rng):\n", + " \"\"\"재게시 과정의 자연스러운 변형: 재압축 잡음, 밝기 변화, 가장자리 잘림 중 하나.\"\"\"\n", + " k = rng.integers(0, 3)\n", + " if k == 0:\n", + " return ndimage.gaussian_filter(img, 0.6) + rng.normal(0, 3, img.shape)\n", + " if k == 1:\n", + " return img * rng.uniform(0.85, 1.15)\n", + " return ndimage.zoom(img[2:30, 2:30], 32 / 28, order=1)\n", + "\n", + "\n", + "def splice(img, other, rng):\n", + " out = img.copy()\n", + " y, x = rng.integers(4, 18, 2)\n", + " out[y:y + 12, x:x + 12] = other[y:y + 12, x:x + 12] + rng.normal(0, 12, (12, 12)) # 잡음 수준이 다른 조각을 붙임\n", + " return out\n", + "\n", + "\n", + "def ai_image(rng):\n", + " \"\"\"생성형 모델 이미지를 흉내 낸 합성 이미지: 지나치게 매끈한 바탕 + 업샘플링 격자 무늬(주기 2픽셀).\"\"\"\n", + " base = ndimage.gaussian_filter(rng.normal(130, 60, (32, 32)), 3)\n", + " grid = (np.indices((32, 32)).sum(0) % 2) * rng.uniform(6, 12)\n", + " return base + grid\n", + "\n", + "\n", + "def write(ev, rng, tpl=None, **kw):\n", + " s = {\"r\": ev[\"r\"], \"o\": ev[\"o\"], \"n\": ev[\"n\"], \"d\": ev[\"d\"], **kw}\n", + " return (tpl or TEMPLATES[int(rng.integers(len(TEMPLATES)))]).format(**s)\n", + "\n", + "\n", + "def make_posts(n_events=40, seed=7):\n", + " rng = np.random.default_rng(seed)\n", + " acc = make_accounts(rng)\n", + " by = {t: [a for a, v in acc.items() if v[\"type\"] == t] for t in ACCOUNT_TYPES}\n", + " events = [{\"id\": f\"EV{i:03d}\", \"r\": str(rng.choice(REGIONS)), \"o\": str(rng.choice(OBJECTS)), \"n\": int(rng.integers(2, 15)),\n", + " \"d\": str(rng.choice(DIRS)), \"t0\": float(i * 1.2 + rng.uniform(0, 1)), \"img\": event_image(seed * 1000 + i)} for i in range(n_events)]\n", + " posts = []\n", + "\n", + " def add(ev, account, text, img, t, fwd, kind, origin):\n", + " pid = f\"P{len(posts):04d}\"\n", + " posts.append({\"post_id\": pid, \"platform\": str(rng.choice([\"telegram\", \"x\", \"other\"], p=[.6, .3, .1])), \"account_id\": account,\n", + " \"posted_at\": round(t, 3), \"text\": text, \"image\": img, \"forwarded_from\": fwd, \"event_id\": ev[\"id\"] if ev else None,\n", + " \"kind\": kind, \"is_disinfo\": kind in MANIP, \"origin\": origin})\n", + " return pid\n", + "\n", + " for k, ev in enumerate(events):\n", + " src = str(rng.choice(by[\"witness\"] if rng.random() < .6 else by[\"media\"]))\n", + " if rng.random() < .05:\n", + " src = str(rng.choice(by[\"propaganda\"])) # 신뢰도 낮은 계정도 사실을 올릴 수 있다\n", + " o = add(ev, src, write(ev, rng), ev[\"img\"], ev[\"t0\"], None, \"original\", None)\n", + " for _ in range(int(rng.integers(2, 5))): # 그대로 재게시(전달)\n", + " add(ev, str(rng.choice(by[\"aggregator\"])), posts[int(o[1:])][\"text\"], ev[\"img\"], ev[\"t0\"] + rng.exponential(1), o, \"copy\", o)\n", + " for _ in range(int(rng.integers(1, 3))): # 표현만 바꾼 재작성(내용 동일)\n", + " add(ev, str(rng.choice(by[\"media\"] + by[\"witness\"])), \"#가상속보 \" + write(ev, rng), variant(ev[\"img\"], rng),\n", + " ev[\"t0\"] + rng.uniform(0.5, 6), None, \"near_dup\", o)\n", + " for m in MANIP if k % 2 == 0 else MANIP[k % 4::4]: # 조작·기만 게시물\n", + " if rng.random() > 0.5:\n", + " continue\n", + " who = str(rng.choice(by[\"propaganda\"] if rng.random() < .75 else by[\"aggregator\"] + by[\"witness\"]))\n", + " t, img, text = ev[\"t0\"] + rng.uniform(1, 12), variant(ev[\"img\"], rng), None\n", + " if m == \"num_change\":\n", + " text = write(ev, rng, n=ev[\"n\"] * int(rng.integers(5, 10)))\n", + " elif m == \"region_swap\":\n", + " text = write(ev, rng, r=str(rng.choice([r for r in REGIONS if r != ev[\"r\"]])))\n", + " elif m == \"dir_flip\":\n", + " text = write(ev, rng, d={\"북쪽\": \"남쪽\", \"남쪽\": \"북쪽\", \"동쪽\": \"서쪽\", \"서쪽\": \"동쪽\"}[ev[\"d\"]])\n", + " elif m == \"old_image\" and k > 0:\n", + " text, img = write(ev, rng), events[int(rng.integers(0, k))][\"img\"]\n", + " elif m == \"splice\":\n", + " text, img = write(ev, rng), splice(ev[\"img\"], events[(k + 7) % n_events][\"img\"], rng)\n", + " elif m == \"ai_image\":\n", + " text, img = write(ev, rng), ai_image(rng)\n", + " elif m == \"ai_text\":\n", + " text = f\"속보 속보! {ev['r']} 지역 대규모 {ev['o']} 출현! 긴급 확산 바람! 긴급 확산 바람! 모두 확산 바람!\"\n", + " img = ai_image(rng) if rng.random() < .5 else None\n", + " elif m == \"bot_burst\":\n", + " text = write(ev, rng, n=ev[\"n\"] * 6) + \" 지금 바로 공유\"\n", + " for b in rng.choice(by[\"propaganda\"], 4, replace=False):\n", + " add(ev, str(b), text, img, t + rng.uniform(0, 0.15), None, m, o)\n", + " continue\n", + " elif m == \"mashup\":\n", + " other = events[(k + 3) % n_events]\n", + " text = f\"{ev['r']} 일대 {ev['o']} 확인, {other['n']}대가 {other['d']}으로 이동\" # 다른 사건 내용과 짜깁기\n", + " elif m == \"recycled\" and k > 2:\n", + " old = events[int(rng.integers(0, k - 1))]\n", + " text, img, t = write(old, rng), old[\"img\"], ev[\"t0\"] + rng.uniform(0, 3)\n", + " if text is None:\n", + " continue\n", + " add(ev, who, text, img, t, None, m, o)\n", + " return posts, acc\n", + "# ---------------------------------------------------------------------------\n", + "\n", + "# ---- 텍스트 정제·이미지 지각 해시·조작 흔적 공통 함수 ----\n", + "STOPWORDS = {\"가상\", \"약\", \"일대\", \"지역에서\", \"모습\", \"중\", \"확인됨\", \"발견\", \"제보\", \"주민\"}\n", + "_URL = re.compile(r\"https?://\\S+|www\\.\\S+\")\n", + "_SPECIAL = re.compile(r\"[^0-9A-Za-z가-힣\\-\\s]\")\n", + "\n", + "\n", + "def clean_text(t):\n", + " \"\"\"정규화(NFKC) -> URL·멘션 제거 -> 해시태그 기호 제거 -> 특수문자 제거 -> 불용어 제거 -> 공백 정리.\"\"\"\n", + " t = unicodedata.normalize(\"NFKC\", t)\n", + " t = _URL.sub(\" \", t)\n", + " t = re.sub(r\"@\\w+\", \" \", t).replace(\"#\", \" \")\n", + " t = _SPECIAL.sub(\" \", t)\n", + " return \" \".join(w for w in t.split() if w not in STOPWORDS)\n", + "\n", + "\n", + "def phash(img):\n", + " \"\"\"지각 해시(pHash): 32x32 이미지 DCT의 저주파 8x8(직류 성분 제외)을 중앙값과 비교한 64비트.\"\"\"\n", + " c = dctn(np.asarray(img, float), norm=\"ortho\")[:8, :8].ravel()[1:]\n", + " return np.append(c > np.median(c), False)\n", + "\n", + "\n", + "def hamming(a, b):\n", + " return int(np.count_nonzero(a != b))\n", + "\n", + "\n", + "def slots(text):\n", + " \"\"\"본문에서 지역 코드·수량·방향·대상을 뽑는다(사건 내용 비교용).\"\"\"\n", + " r = re.search(r\"[A-F]-\\d\", text)\n", + " n = re.search(r\"(\\d+)\\s*대|수량\\s*(\\d+)\", text)\n", + " d = next((x for x in DIRS if x in text), None)\n", + " o = next((x for x in OBJECTS if x in text), None)\n", + " return {\"r\": r.group(0) if r else None, \"n\": int(n.group(1) or n.group(2)) if n else None, \"d\": d, \"o\": o}\n", + "\n", + "\n", + "def splice_score(img):\n", + " \"\"\"합성(붙여넣기) 흔적: 8x8 블록별 고주파 잡음 크기의 최대/중앙값 비율. 잡음 수준이 다른 조각이 있으면 커진다.\"\"\"\n", + " res = img - ndimage.median_filter(img, 3)\n", + " b = np.abs(res).reshape(4, 8, 4, 8).std(axis=(1, 3))\n", + " return float(b.max() / (np.median(b) + 1e-6))\n", + "\n", + "\n", + "def ai_image_score(img):\n", + " \"\"\"생성형 이미지 흔적: 2픽셀 주기 격자(나이퀴스트 주파수) 에너지 비율.\"\"\"\n", + " f = np.abs(np.fft.fft2(img - img.mean()))\n", + " return float(f[16, 16] / (f.sum() / f.size + 1e-6))\n", + "\n", + "\n", + "def ai_text_score(text):\n", + " \"\"\"기계적 선동 문체 흔적: 반복 어절 비율 + 느낌표 밀도.\"\"\"\n", + " w = text.split()\n", + " return (1 - len(set(w)) / max(len(w), 1)) + text.count(\"!\") / max(len(w), 1)\n", + "\n", + "# ---- 게시물 군집(같은 이야기 묶기)·조작 탐지 특징 공통 함수 ----\n", + "FEAT = [\"num_dev\", \"region_mis\", \"dir_mis\", \"obj_mis\", \"img_ref_dist\", \"img_prior_h\", \"time_gap_h\", \"splice\", \"ai_img\",\n", + " \"ai_text\", \"burst_n\", \"has_img\", \"is_fwd\", \"acc_age\", \"acc_followers\", \"acc_orig\", \"acc_past\", \"acc_cv\"]\n", + "\n", + "\n", + "def cluster_posts(posts, hours=24, ham_thr=8):\n", + " \"\"\"같은 이야기 묶기: 본문에서 뽑은 (지역, 대상)이 같고 24시간 안이면 연결하고,\n", + " 지각 해시가 가까운 이미지(같은 대상)도 연결한다. 글 표현이 달라도 내용 단위로 묶인다.\"\"\"\n", + " texts = [clean_text(p[\"text\"]) for p in posts]\n", + " X = TfidfVectorizer(analyzer=\"char_wb\", ngram_range=(2, 3)).fit_transform(texts)\n", + " S = (X @ X.T).toarray()\n", + " H = [phash(p[\"image\"]) if p[\"image\"] is not None else None for p in posts]\n", + " sl = [slots(p[\"text\"]) for p in posts]\n", + " t = [p[\"posted_at\"] for p in posts]\n", + " parent = list(range(len(posts)))\n", + "\n", + " def find(i):\n", + " while parent[i] != i:\n", + " parent[i] = parent[parent[i]]; i = parent[i]\n", + " return i\n", + " for i in range(len(posts)):\n", + " for j in range(i + 1, len(posts)):\n", + " if abs(t[i] - t[j]) >= hours or sl[i][\"o\"] != sl[j][\"o\"]:\n", + " continue\n", + " same_story = sl[i][\"r\"] is not None and sl[i][\"r\"] == sl[j][\"r\"]\n", + " same_img = H[i] is not None and H[j] is not None and hamming(H[i], H[j]) <= ham_thr\n", + " if same_story or same_img:\n", + " parent[find(i)] = find(j)\n", + " return np.array([find(i) for i in range(len(posts))]), S, H\n", + "\n", + "\n", + "def post_features(posts, acc):\n", + " cid, S, H = cluster_posts(posts)\n", + " t = np.array([p[\"posted_at\"] for p in posts])\n", + " sl = [slots(p[\"text\"]) for p in posts]\n", + " first_seen = {}\n", + " for i in np.argsort(t): # 이미지가 처음 등장한 시각과 군집\n", + " if H[i] is None:\n", + " continue\n", + " key = next((k for k in first_seen if hamming(np.array(k), H[i]) <= 6), None)\n", + " if key is None:\n", + " first_seen[tuple(H[i])] = (t[i], cid[i])\n", + " elif first_seen[key][1] != cid[i]:\n", + " pass\n", + " ref = {c: int(np.argmin(np.where(cid == c, t, np.inf))) for c in set(cid)} # 군집에서 가장 이른 게시물 = 기준(추정 원본)\n", + " F = []\n", + " for i, p in enumerate(posts):\n", + " r = ref[cid[i]]\n", + " a, b = sl[i], sl[r]\n", + " img_prior = 0.0\n", + " if H[i] is not None:\n", + " for k, (ts, c) in first_seen.items():\n", + " if hamming(np.array(k), H[i]) <= 6 and c != cid[i]:\n", + " img_prior = max(img_prior, min(t[i] - ts, 48.0))\n", + " same_txt = [j for j in np.where(S[i] > 0.97)[0] if abs(t[j] - t[i]) < 0.25 and posts[j][\"account_id\"] != p[\"account_id\"]]\n", + " A = acc[p[\"account_id\"]]\n", + " F.append([abs(np.log((a[\"n\"] or 1) / (b[\"n\"] or 1))), float(a[\"r\"] != b[\"r\"]), float(a[\"d\"] != b[\"d\"]), float(a[\"o\"] != b[\"o\"]),\n", + " hamming(H[i], H[r]) if H[i] is not None and H[r] is not None else 0, img_prior, t[i] - t[r],\n", + " splice_score(p[\"image\"]) if p[\"image\"] is not None else 0, ai_image_score(p[\"image\"]) if p[\"image\"] is not None else 0,\n", + " ai_text_score(p[\"text\"]), len(set(posts[j][\"account_id\"] for j in same_txt)), float(p[\"image\"] is not None),\n", + " float(p[\"forwarded_from\"] is not None), np.log1p(A[\"age_days\"]), np.log1p(A[\"followers\"]), A[\"original_ratio\"],\n", + " A[\"past_accuracy\"], A[\"interval_cv\"]])\n", + " return np.array(F), cid\n", + "\n", + "CONTENT, ACCOUNT = list(range(13)), list(range(13, 18))\n", + "\n", + "# 1) 학습: 가상 게시물(사건 60개) / 시험: 다른 난수의 가상 게시물(사건 60개)\n", + "tr_posts, tr_acc = make_posts(60, seed=7)\n", + "Ftr, _ = post_features(tr_posts, tr_acc)\n", + "ytr = np.array([p[\"is_disinfo\"] for p in tr_posts])\n", + "te_posts, te_acc = make_posts(60, seed=11)\n", + "t0 = time.perf_counter()\n", + "Fte, cid = post_features(te_posts, te_acc)\n", + "yte, kind = np.array([p[\"is_disinfo\"] for p in te_posts]), np.array([p[\"kind\"] for p in te_posts])\n", + "\n", + "# 계정 신뢰도 모델: 계정 속성만으로 '조작 게시물을 올릴 가능성'을 추정(작성자 평판)\n", + "acc_model = make_pipeline(StandardScaler(), LogisticRegression(max_iter=2000)).fit(Ftr[:, ACCOUNT], ytr)\n", + "Xtr = np.c_[Ftr[:, CONTENT], acc_model.predict_proba(Ftr[:, ACCOUNT])[:, 1]]\n", + "model = RandomForestClassifier(n_estimators=300, min_samples_leaf=2, random_state=0).fit(Xtr, ytr)\n", + "Xte = np.c_[Fte[:, CONTENT], acc_model.predict_proba(Fte[:, ACCOUNT])[:, 1]]\n", + "pred = model.predict(Xte)\n", + "lat = (time.perf_counter() - t0) / len(te_posts) * 1000\n", + "\n", + "content_only = RandomForestClassifier(n_estimators=300, min_samples_leaf=2, random_state=0).fit(Ftr[:, CONTENT], ytr).predict(Fte[:, CONTENT])\n", + "account_only = acc_model.predict(Fte[:, ACCOUNT])\n", + "print(\"방식 조작 재현율 사실 게시물 오경보율\")\n", + "for name, p in [(\"계정 신뢰도만\", account_only), (\"내용·이미지 탐지만\", content_only), (\"결합 모델\", pred)]:\n", + " print(f\"{name:12s} {(p & yte).sum() / yte.sum():10.1%} {(p & ~yte).sum() / (~yte).sum():14.1%}\")\n", + "rec = (pred & yte).sum() / yte.sum()\n", + "print(f\"\\n복합 위협(조작·기만) 탐지 재현율 {rec:.1%} (목표 85% 이상: {'달성' if rec >= 0.85 else '미달'}), 게시물당 처리 {lat:.2f} ms (목표 200 ms 이하)\")\n", + "\n", + "# 2) 조작·기만 시나리오 10종 시험(재현율 80% 이상이면 통과)\n", + "rows = [{\"유형\": m, \"건수\": int((kind == m).sum()), \"재현율\": round(float(pred[kind == m].mean()), 2),\n", + " \"판정\": \"통과\" if pred[kind == m].mean() >= 0.8 else \"미통과\"} for m in MANIP]\n", + "t = pd.DataFrame(rows)\n", + "print(\"\\n\" + t.to_string(index=False))\n", + "print(f\"통과 {int((t['판정'] == '통과').sum())}/10종\")\n", + "\n", + "# 3) 오경보 유발 패턴: 사실 게시물인데 조작으로 본 사례\n", + "fa = np.where(pred & ~yte)[0]\n", + "print(f\"\\n[사실 게시물 오경보 {len(fa)}건] 유형:\", pd.Series(kind[fa]).value_counts().to_dict(),\n", + " \"/ 작성 계정 유형:\", pd.Series([te_acc[te_posts[i][\"account_id\"]][\"type\"] for i in fa]).value_counts().to_dict())\n", + "imp = pd.Series(model.feature_importances_, index=[FEAT[i] for i in CONTENT] + [\"account_risk\"]).sort_values(ascending=False)\n", + "print(\"중요 특징 상위 5:\", {k: round(v, 3) for k, v in imp.head(5).items()})\n", + "print(\"-> 신생·소규모 계정의 사실 제보와 늦게 올라온 재작성 글이 오경보를 만든다. 계정 평판에 과도하게 기대지 않도록 특징 공간을 재설계(미션 5)한다.\")\n", + "\n", + "fig, ax = plt.subplots(1, 2, figsize=(12, 4))\n", + "ax[0].barh(t[\"유형\"], t[\"재현율\"], color=[\"tab:green\" if v >= 0.8 else \"tab:red\" for v in t[\"재현율\"]]); ax[0].axvline(0.8, ls=\"--\", c=\"k\")\n", + "ax[0].set_title(\"Recall by manipulation type\"); ax[0].set_xlim(0, 1.05)\n", + "imp.head(10)[::-1].plot.barh(ax=ax[1]); ax[1].set_title(\"Feature importance\")\n", + "plt.tight_layout(); plt.show()\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 04 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + "방식 조작 재현율 사실 게시물 오경보율\n", + "계정 신뢰도만 88.3% 5.7%\n", + "내용·이미지 탐지만 94.1% 14.5%\n", + "결합 모델 97.3% 3.6%\n", + "\n", + "복합 위협(조작·기만) 탐지 재현율 97.3% (목표 85% 이상: 달성), 게시물당 처리 3.14 ms (목표 200 ms 이하)\n", + "\n", + " 유형 건수 재현율 판정\n", + " num_change 13 1.00 통과\n", + "region_swap 24 0.88 통과\n", + " dir_flip 19 0.95 통과\n", + " old_image 29 1.00 통과\n", + " splice 14 0.93 통과\n", + " ai_image 16 1.00 통과\n", + " ai_text 13 1.00 통과\n", + " bot_burst 96 1.00 통과\n", + " mashup 12 1.00 통과\n", + " recycled 20 0.90 통과\n", + "통과 10/10종\n", + "\n", + "[사실 게시물 오경보 12건] 유형: {'near_dup': 9, 'original': 3} / 작성 계정 유형: {'witness': 11, 'propaganda': 1}\n", + "중요 특징 상위 5: {'account_risk': 0.394, 'time_gap_h': 0.162, 'num_dev': 0.107, 'is_fwd': 0.102, 'img_ref_dist': 0.054}\n", + "-> 신생·소규모 계정의 사실 제보와 늦게 올라온 재작성 글이 오경보를 만든다. 계정 평판에 과도하게 기대지 않도록 특징 공간을 재설계(미션 5)한다.\n", + "```\n", + "
\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 05. 가짜 정보(Disinformation) 오분류 방지 및 복합 신뢰도 지수 산출 엔진 개발\n", + "**소요시간**: 약 3.0시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "전쟁 심리전(심리 공작) 게시물의 유포 패턴을 분석하고 신뢰도 등급(A~E) 및 종합 신뢰도 점수를 산출하는 엔진을 개발한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **심리전 게시물의 유포 패턴을 반영해 신뢰도 등급(A~E)과 종합 신뢰도 지수를 산출하고, 사실 게시물을 가짜로 오분류하지 않도록 하는 엔진을 개발한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **내용 무결성, 계정 평판, 독립 출처 교차확인, 유포 패턴 정상 여부, 시의성 5개 요소를 가중치 매트릭스로 합산해 종합 지수를 만들고 A~E 등급(A·B=High, C=Medium, D·E=Low)을 부여 수행하고 검증하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `src/verification/isr02_confidence_engine.py` 형태의 결과물을 완성한다.\n", + "\n", + "### 🔍 평가 및 검증 기준 (PASS 조건)\n", + "#### 미션 판정\n", + "\n", + "**평가 구분:** 필수\n", + "\n", + "**핵심 평가영역:** 신뢰도 등급(High/Med/Low) 부여 기준의 군사적 타당성\n", + "\n", + "#### 미션 PASS 조건\n", + "\n", + "- 정량 기준: 오경보율(FAR) 40% 이상 감축, 신뢰도 스코어 산출 일관성 90% 이상\n", + "\n", + "\n", + "- 진행 기준: 오경보 필터링 전후 비교 지표 산출 및 통합 테스트 완료\n", + "\n", + "\n", + "- 검증 방법: ROC-AUC 곡선 분석 및 임계값 민감도 테스트\n", + "\n", + "\n", + "#### 채점자 판정 메모\n", + "\n", + "> 단순 코드 실행 여부뿐만 아니라 출력된 수치와 근거 자료가 군 실무 요구사항에 부합하는지 면밀히 확인한다.\n", + "\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "\"\"\"심리전 게시물의 유포 패턴(조직적 동시 게시), 내용 조작 확률, 작성 계정 평판, 독립 출처 교차 확인, 시의성을 가중 합산해 종합 신뢰도 지수와 신뢰도 등급(A~E)을 산출하고, 계정 속성만으로 가짜를 단정하던 규칙 대비 사실 게시물 오분류(오경보) 감축과 등급 일관성을 검증한다.\"\"\"\n", + "import re\n", + "import unicodedata\n", + "import numpy as np\n", + "import pandas as pd\n", + "import matplotlib.pyplot as plt\n", + "from scipy import ndimage\n", + "from scipy.fft import dctn\n", + "from sklearn.ensemble import RandomForestClassifier\n", + "from sklearn.feature_extraction.text import TfidfVectorizer\n", + "from sklearn.linear_model import LogisticRegression\n", + "from sklearn.metrics import roc_auc_score\n", + "from sklearn.pipeline import make_pipeline\n", + "from sklearn.preprocessing import StandardScaler\n", + "\n", + "# ---------------------------------------------------------------------------\n", + "# [교육용가상자료] 합성 SNS 게시물 생성기 (실제 계정·채널·지명·게시물 없음)\n", + "# 가상 사건마다 원본 제보, 재게시(복제), 유사 재작성, 조작·기만 게시물 10종과 32x32 합성 이미지를 만든다.\n", + "# ---------------------------------------------------------------------------\n", + "REGIONS = [f\"{a}-{n}\" for a in \"ABCDEF\" for n in range(1, 6)]\n", + "OBJECTS = [\"군용 차량\", \"소형 드론\", \"보급 트럭\", \"헬기\", \"버스\", \"견인 차량\", \"트레일러\", \"민간 승용차\"]\n", + "DIRS = [\"북쪽\", \"남쪽\", \"동쪽\", \"서쪽\"]\n", + "TEMPLATES = [\"가상 {r} 지역에서 {o} 목격, {n}대 규모로 {d}으로 이동 중\",\n", + " \"{r} 일대 {o} 확인됨. 약 {n}대, {d} 방향으로 이동\",\n", + " \"[현지] {r}에서 {o} 발견 - 수량 {n}, 이동 방향 {d}\",\n", + " \"{r} 주민 제보: {o} {n}대가 {d}으로 향하는 모습\"]\n", + "MANIP = [\"num_change\", \"region_swap\", \"dir_flip\", \"old_image\", \"splice\", \"ai_image\", \"ai_text\", \"bot_burst\", \"mashup\", \"recycled\"]\n", + "ACCOUNT_TYPES = { # 유형별 계정 속성 분포(가상): 나이(일), 팔로워, 하루 게시 수, 원본 비율, 과거 정확도, 게시 간격 변동계수\n", + " \"media\": ((1000, 4000), (5e4, 5e5), (5, 30), (.6, .9), (.85, .97), (.8, 1.5)),\n", + " \"witness\": ((10, 800), (20, 2000), (.2, 3), (.7, 1.0), (.6, .95), (1.0, 2.0)),\n", + " \"aggregator\": ((200, 2000), (1e4, 2e5), (30, 200), (.05, .2), (.6, .85), (.5, 1.0)),\n", + " \"propaganda\": ((5, 200), (100, 2e4), (20, 150), (.3, .7), (.1, .4), (.05, .3)),\n", + "}\n", + "\n", + "\n", + "def make_accounts(rng, n=(8, 30, 10, 20)):\n", + " acc = {}\n", + " for (t, spec), k in zip(ACCOUNT_TYPES.items(), n):\n", + " for i in range(k):\n", + " v = [rng.uniform(*r) for r in spec]\n", + " acc[f\"ACC_{t[:3].upper()}{i:02d}\"] = {\"type\": t, \"age_days\": int(v[0]), \"followers\": int(v[1]), \"posts_per_day\": round(v[2], 1),\n", + " \"original_ratio\": round(v[3], 2), \"past_accuracy\": round(v[4], 2), \"interval_cv\": round(v[5], 2)}\n", + " return acc\n", + "\n", + "\n", + "def event_image(seed):\n", + " \"\"\"사건 현장 사진을 흉내 낸 32x32 합성 이미지(사건마다 고유한 도형 배치).\"\"\"\n", + " r = np.random.default_rng(seed)\n", + " img = np.linspace(r.uniform(60, 120), r.uniform(120, 200), 32)[:, None] * np.ones((1, 32))\n", + " for _ in range(int(r.integers(3, 7))):\n", + " y, x, h, w = r.integers(0, 26, 4)\n", + " img[y:y + 3 + h % 8, x:x + 3 + w % 8] = r.uniform(20, 240)\n", + " return img + r.normal(0, 4, (32, 32))\n", + "\n", + "\n", + "def variant(img, rng):\n", + " \"\"\"재게시 과정의 자연스러운 변형: 재압축 잡음, 밝기 변화, 가장자리 잘림 중 하나.\"\"\"\n", + " k = rng.integers(0, 3)\n", + " if k == 0:\n", + " return ndimage.gaussian_filter(img, 0.6) + rng.normal(0, 3, img.shape)\n", + " if k == 1:\n", + " return img * rng.uniform(0.85, 1.15)\n", + " return ndimage.zoom(img[2:30, 2:30], 32 / 28, order=1)\n", + "\n", + "\n", + "def splice(img, other, rng):\n", + " out = img.copy()\n", + " y, x = rng.integers(4, 18, 2)\n", + " out[y:y + 12, x:x + 12] = other[y:y + 12, x:x + 12] + rng.normal(0, 12, (12, 12)) # 잡음 수준이 다른 조각을 붙임\n", + " return out\n", + "\n", + "\n", + "def ai_image(rng):\n", + " \"\"\"생성형 모델 이미지를 흉내 낸 합성 이미지: 지나치게 매끈한 바탕 + 업샘플링 격자 무늬(주기 2픽셀).\"\"\"\n", + " base = ndimage.gaussian_filter(rng.normal(130, 60, (32, 32)), 3)\n", + " grid = (np.indices((32, 32)).sum(0) % 2) * rng.uniform(6, 12)\n", + " return base + grid\n", + "\n", + "\n", + "def write(ev, rng, tpl=None, **kw):\n", + " s = {\"r\": ev[\"r\"], \"o\": ev[\"o\"], \"n\": ev[\"n\"], \"d\": ev[\"d\"], **kw}\n", + " return (tpl or TEMPLATES[int(rng.integers(len(TEMPLATES)))]).format(**s)\n", + "\n", + "\n", + "def make_posts(n_events=40, seed=7):\n", + " rng = np.random.default_rng(seed)\n", + " acc = make_accounts(rng)\n", + " by = {t: [a for a, v in acc.items() if v[\"type\"] == t] for t in ACCOUNT_TYPES}\n", + " events = [{\"id\": f\"EV{i:03d}\", \"r\": str(rng.choice(REGIONS)), \"o\": str(rng.choice(OBJECTS)), \"n\": int(rng.integers(2, 15)),\n", + " \"d\": str(rng.choice(DIRS)), \"t0\": float(i * 1.2 + rng.uniform(0, 1)), \"img\": event_image(seed * 1000 + i)} for i in range(n_events)]\n", + " posts = []\n", + "\n", + " def add(ev, account, text, img, t, fwd, kind, origin):\n", + " pid = f\"P{len(posts):04d}\"\n", + " posts.append({\"post_id\": pid, \"platform\": str(rng.choice([\"telegram\", \"x\", \"other\"], p=[.6, .3, .1])), \"account_id\": account,\n", + " \"posted_at\": round(t, 3), \"text\": text, \"image\": img, \"forwarded_from\": fwd, \"event_id\": ev[\"id\"] if ev else None,\n", + " \"kind\": kind, \"is_disinfo\": kind in MANIP, \"origin\": origin})\n", + " return pid\n", + "\n", + " for k, ev in enumerate(events):\n", + " src = str(rng.choice(by[\"witness\"] if rng.random() < .6 else by[\"media\"]))\n", + " if rng.random() < .05:\n", + " src = str(rng.choice(by[\"propaganda\"])) # 신뢰도 낮은 계정도 사실을 올릴 수 있다\n", + " o = add(ev, src, write(ev, rng), ev[\"img\"], ev[\"t0\"], None, \"original\", None)\n", + " for _ in range(int(rng.integers(2, 5))): # 그대로 재게시(전달)\n", + " add(ev, str(rng.choice(by[\"aggregator\"])), posts[int(o[1:])][\"text\"], ev[\"img\"], ev[\"t0\"] + rng.exponential(1), o, \"copy\", o)\n", + " for _ in range(int(rng.integers(1, 3))): # 표현만 바꾼 재작성(내용 동일)\n", + " add(ev, str(rng.choice(by[\"media\"] + by[\"witness\"])), \"#가상속보 \" + write(ev, rng), variant(ev[\"img\"], rng),\n", + " ev[\"t0\"] + rng.uniform(0.5, 6), None, \"near_dup\", o)\n", + " for m in MANIP if k % 2 == 0 else MANIP[k % 4::4]: # 조작·기만 게시물\n", + " if rng.random() > 0.5:\n", + " continue\n", + " who = str(rng.choice(by[\"propaganda\"] if rng.random() < .75 else by[\"aggregator\"] + by[\"witness\"]))\n", + " t, img, text = ev[\"t0\"] + rng.uniform(1, 12), variant(ev[\"img\"], rng), None\n", + " if m == \"num_change\":\n", + " text = write(ev, rng, n=ev[\"n\"] * int(rng.integers(5, 10)))\n", + " elif m == \"region_swap\":\n", + " text = write(ev, rng, r=str(rng.choice([r for r in REGIONS if r != ev[\"r\"]])))\n", + " elif m == \"dir_flip\":\n", + " text = write(ev, rng, d={\"북쪽\": \"남쪽\", \"남쪽\": \"북쪽\", \"동쪽\": \"서쪽\", \"서쪽\": \"동쪽\"}[ev[\"d\"]])\n", + " elif m == \"old_image\" and k > 0:\n", + " text, img = write(ev, rng), events[int(rng.integers(0, k))][\"img\"]\n", + " elif m == \"splice\":\n", + " text, img = write(ev, rng), splice(ev[\"img\"], events[(k + 7) % n_events][\"img\"], rng)\n", + " elif m == \"ai_image\":\n", + " text, img = write(ev, rng), ai_image(rng)\n", + " elif m == \"ai_text\":\n", + " text = f\"속보 속보! {ev['r']} 지역 대규모 {ev['o']} 출현! 긴급 확산 바람! 긴급 확산 바람! 모두 확산 바람!\"\n", + " img = ai_image(rng) if rng.random() < .5 else None\n", + " elif m == \"bot_burst\":\n", + " text = write(ev, rng, n=ev[\"n\"] * 6) + \" 지금 바로 공유\"\n", + " for b in rng.choice(by[\"propaganda\"], 4, replace=False):\n", + " add(ev, str(b), text, img, t + rng.uniform(0, 0.15), None, m, o)\n", + " continue\n", + " elif m == \"mashup\":\n", + " other = events[(k + 3) % n_events]\n", + " text = f\"{ev['r']} 일대 {ev['o']} 확인, {other['n']}대가 {other['d']}으로 이동\" # 다른 사건 내용과 짜깁기\n", + " elif m == \"recycled\" and k > 2:\n", + " old = events[int(rng.integers(0, k - 1))]\n", + " text, img, t = write(old, rng), old[\"img\"], ev[\"t0\"] + rng.uniform(0, 3)\n", + " if text is None:\n", + " continue\n", + " add(ev, who, text, img, t, None, m, o)\n", + " return posts, acc\n", + "# ---------------------------------------------------------------------------\n", + "\n", + "# ---- 텍스트 정제·이미지 지각 해시·조작 흔적 공통 함수 ----\n", + "STOPWORDS = {\"가상\", \"약\", \"일대\", \"지역에서\", \"모습\", \"중\", \"확인됨\", \"발견\", \"제보\", \"주민\"}\n", + "_URL = re.compile(r\"https?://\\S+|www\\.\\S+\")\n", + "_SPECIAL = re.compile(r\"[^0-9A-Za-z가-힣\\-\\s]\")\n", + "\n", + "\n", + "def clean_text(t):\n", + " \"\"\"정규화(NFKC) -> URL·멘션 제거 -> 해시태그 기호 제거 -> 특수문자 제거 -> 불용어 제거 -> 공백 정리.\"\"\"\n", + " t = unicodedata.normalize(\"NFKC\", t)\n", + " t = _URL.sub(\" \", t)\n", + " t = re.sub(r\"@\\w+\", \" \", t).replace(\"#\", \" \")\n", + " t = _SPECIAL.sub(\" \", t)\n", + " return \" \".join(w for w in t.split() if w not in STOPWORDS)\n", + "\n", + "\n", + "def phash(img):\n", + " \"\"\"지각 해시(pHash): 32x32 이미지 DCT의 저주파 8x8(직류 성분 제외)을 중앙값과 비교한 64비트.\"\"\"\n", + " c = dctn(np.asarray(img, float), norm=\"ortho\")[:8, :8].ravel()[1:]\n", + " return np.append(c > np.median(c), False)\n", + "\n", + "\n", + "def hamming(a, b):\n", + " return int(np.count_nonzero(a != b))\n", + "\n", + "\n", + "def slots(text):\n", + " \"\"\"본문에서 지역 코드·수량·방향·대상을 뽑는다(사건 내용 비교용).\"\"\"\n", + " r = re.search(r\"[A-F]-\\d\", text)\n", + " n = re.search(r\"(\\d+)\\s*대|수량\\s*(\\d+)\", text)\n", + " d = next((x for x in DIRS if x in text), None)\n", + " o = next((x for x in OBJECTS if x in text), None)\n", + " return {\"r\": r.group(0) if r else None, \"n\": int(n.group(1) or n.group(2)) if n else None, \"d\": d, \"o\": o}\n", + "\n", + "\n", + "def splice_score(img):\n", + " \"\"\"합성(붙여넣기) 흔적: 8x8 블록별 고주파 잡음 크기의 최대/중앙값 비율. 잡음 수준이 다른 조각이 있으면 커진다.\"\"\"\n", + " res = img - ndimage.median_filter(img, 3)\n", + " b = np.abs(res).reshape(4, 8, 4, 8).std(axis=(1, 3))\n", + " return float(b.max() / (np.median(b) + 1e-6))\n", + "\n", + "\n", + "def ai_image_score(img):\n", + " \"\"\"생성형 이미지 흔적: 2픽셀 주기 격자(나이퀴스트 주파수) 에너지 비율.\"\"\"\n", + " f = np.abs(np.fft.fft2(img - img.mean()))\n", + " return float(f[16, 16] / (f.sum() / f.size + 1e-6))\n", + "\n", + "\n", + "def ai_text_score(text):\n", + " \"\"\"기계적 선동 문체 흔적: 반복 어절 비율 + 느낌표 밀도.\"\"\"\n", + " w = text.split()\n", + " return (1 - len(set(w)) / max(len(w), 1)) + text.count(\"!\") / max(len(w), 1)\n", + "\n", + "# ---- 게시물 군집(같은 이야기 묶기)·조작 탐지 특징 공통 함수 ----\n", + "FEAT = [\"num_dev\", \"region_mis\", \"dir_mis\", \"obj_mis\", \"img_ref_dist\", \"img_prior_h\", \"time_gap_h\", \"splice\", \"ai_img\",\n", + " \"ai_text\", \"burst_n\", \"has_img\", \"is_fwd\", \"acc_age\", \"acc_followers\", \"acc_orig\", \"acc_past\", \"acc_cv\"]\n", + "\n", + "\n", + "def cluster_posts(posts, hours=24, ham_thr=8):\n", + " \"\"\"같은 이야기 묶기: 본문에서 뽑은 (지역, 대상)이 같고 24시간 안이면 연결하고,\n", + " 지각 해시가 가까운 이미지(같은 대상)도 연결한다. 글 표현이 달라도 내용 단위로 묶인다.\"\"\"\n", + " texts = [clean_text(p[\"text\"]) for p in posts]\n", + " X = TfidfVectorizer(analyzer=\"char_wb\", ngram_range=(2, 3)).fit_transform(texts)\n", + " S = (X @ X.T).toarray()\n", + " H = [phash(p[\"image\"]) if p[\"image\"] is not None else None for p in posts]\n", + " sl = [slots(p[\"text\"]) for p in posts]\n", + " t = [p[\"posted_at\"] for p in posts]\n", + " parent = list(range(len(posts)))\n", + "\n", + " def find(i):\n", + " while parent[i] != i:\n", + " parent[i] = parent[parent[i]]; i = parent[i]\n", + " return i\n", + " for i in range(len(posts)):\n", + " for j in range(i + 1, len(posts)):\n", + " if abs(t[i] - t[j]) >= hours or sl[i][\"o\"] != sl[j][\"o\"]:\n", + " continue\n", + " same_story = sl[i][\"r\"] is not None and sl[i][\"r\"] == sl[j][\"r\"]\n", + " same_img = H[i] is not None and H[j] is not None and hamming(H[i], H[j]) <= ham_thr\n", + " if same_story or same_img:\n", + " parent[find(i)] = find(j)\n", + " return np.array([find(i) for i in range(len(posts))]), S, H\n", + "\n", + "\n", + "def post_features(posts, acc):\n", + " cid, S, H = cluster_posts(posts)\n", + " t = np.array([p[\"posted_at\"] for p in posts])\n", + " sl = [slots(p[\"text\"]) for p in posts]\n", + " first_seen = {}\n", + " for i in np.argsort(t): # 이미지가 처음 등장한 시각과 군집\n", + " if H[i] is None:\n", + " continue\n", + " key = next((k for k in first_seen if hamming(np.array(k), H[i]) <= 6), None)\n", + " if key is None:\n", + " first_seen[tuple(H[i])] = (t[i], cid[i])\n", + " elif first_seen[key][1] != cid[i]:\n", + " pass\n", + " ref = {c: int(np.argmin(np.where(cid == c, t, np.inf))) for c in set(cid)} # 군집에서 가장 이른 게시물 = 기준(추정 원본)\n", + " F = []\n", + " for i, p in enumerate(posts):\n", + " r = ref[cid[i]]\n", + " a, b = sl[i], sl[r]\n", + " img_prior = 0.0\n", + " if H[i] is not None:\n", + " for k, (ts, c) in first_seen.items():\n", + " if hamming(np.array(k), H[i]) <= 6 and c != cid[i]:\n", + " img_prior = max(img_prior, min(t[i] - ts, 48.0))\n", + " same_txt = [j for j in np.where(S[i] > 0.97)[0] if abs(t[j] - t[i]) < 0.25 and posts[j][\"account_id\"] != p[\"account_id\"]]\n", + " A = acc[p[\"account_id\"]]\n", + " F.append([abs(np.log((a[\"n\"] or 1) / (b[\"n\"] or 1))), float(a[\"r\"] != b[\"r\"]), float(a[\"d\"] != b[\"d\"]), float(a[\"o\"] != b[\"o\"]),\n", + " hamming(H[i], H[r]) if H[i] is not None and H[r] is not None else 0, img_prior, t[i] - t[r],\n", + " splice_score(p[\"image\"]) if p[\"image\"] is not None else 0, ai_image_score(p[\"image\"]) if p[\"image\"] is not None else 0,\n", + " ai_text_score(p[\"text\"]), len(set(posts[j][\"account_id\"] for j in same_txt)), float(p[\"image\"] is not None),\n", + " float(p[\"forwarded_from\"] is not None), np.log1p(A[\"age_days\"]), np.log1p(A[\"followers\"]), A[\"original_ratio\"],\n", + " A[\"past_accuracy\"], A[\"interval_cv\"]])\n", + " return np.array(F), cid\n", + "\n", + "CONTENT, ACCOUNT = list(range(13)), list(range(13, 18))\n", + "WEIGHTS = {\"내용 무결성\": 0.35, \"계정 평판\": 0.20, \"독립 출처 교차확인\": 0.25, \"유포 패턴 정상\": 0.10, \"시의성\": 0.10} # 신뢰도 가중치 매트릭스\n", + "GRADES = [(0.80, \"A\"), (0.65, \"B\"), (0.50, \"C\"), (0.35, \"D\"), (-1, \"E\")]\n", + "\n", + "\n", + "def components(posts, acc, F, cid, manip_model, acc_model):\n", + " sl = [slots(p[\"text\"]) for p in posts]\n", + " corro = []\n", + " for i, p in enumerate(posts):\n", + " same = {posts[j][\"account_id\"] for j in np.where(cid == cid[i])[0] if j != i and posts[j][\"forwarded_from\"] is None\n", + " and posts[j][\"account_id\"] != p[\"account_id\"] and (sl[j][\"r\"], sl[j][\"n\"], sl[j][\"d\"]) == (sl[i][\"r\"], sl[i][\"n\"], sl[i][\"d\"])}\n", + " corro.append(min(len(same) / 2, 1.0))\n", + " return np.c_[1 - manip_model.predict_proba(F[:, CONTENT])[:, 1], 1 - acc_model.predict_proba(F[:, ACCOUNT])[:, 1],\n", + " corro, 1 - np.minimum(F[:, FEAT.index(\"burst_n\")] / 3, 1), np.exp(-np.maximum(F[:, FEAT.index(\"time_gap_h\")], 0) / 12)]\n", + "\n", + "\n", + "def grade(score):\n", + " return np.array([next(g for t, g in GRADES if s >= t) for s in score])\n", + "\n", + "\n", + "tr_posts, tr_acc = make_posts(60, seed=7)\n", + "Ftr, _ = post_features(tr_posts, tr_acc)\n", + "ytr = np.array([p[\"is_disinfo\"] for p in tr_posts])\n", + "manip_model = RandomForestClassifier(n_estimators=200, min_samples_leaf=3, random_state=0).fit(Ftr[:, CONTENT], ytr)\n", + "acc_model = make_pipeline(StandardScaler(), LogisticRegression(max_iter=2000)).fit(Ftr[:, ACCOUNT], ytr)\n", + "\n", + "te_posts, te_acc = make_posts(60, seed=11)\n", + "Fte, cid = post_features(te_posts, te_acc)\n", + "y = np.array([p[\"is_disinfo\"] for p in te_posts])\n", + "C = components(te_posts, te_acc, Fte, cid, manip_model, acc_model)\n", + "w = np.array(list(WEIGHTS.values()))\n", + "score = C @ w\n", + "G = grade(score)\n", + "print(\"[신뢰도 가중치 매트릭스]\", WEIGHTS)\n", + "\n", + "# 1) 기존 규칙(신생 계정·소규모 계정·출처 표시 없음이면 가짜) vs 엔진(D·E 등급만 '허위 의심')\n", + "A = [te_acc[p[\"account_id\"]] for p in te_posts]\n", + "base = np.array([(a[\"age_days\"] < 180 or a[\"followers\"] < 1000) and p[\"forwarded_from\"] is None for a, p in zip(A, te_posts)])\n", + "eng = np.isin(G, [\"D\", \"E\"])\n", + "far0, far1 = (base & ~y).sum() / base.sum(), (eng & ~y).sum() / eng.sum()\n", + "print(f\"\\n기존 규칙: 허위 지목 {base.sum()}건 중 사실 게시물 {(base & ~y).sum()}건 (오경보율 {far0:.1%}), 조작 재현율 {(base & y).sum() / y.sum():.1%}\")\n", + "print(f\"신뢰도 엔진: 허위 의심(D·E) {eng.sum()}건 중 사실 게시물 {(eng & ~y).sum()}건 (오경보율 {far1:.1%}), 조작 재현율 {(eng & y).sum() / y.sum():.1%}\")\n", + "cut = 1 - far1 / far0\n", + "print(f\"오경보율 감축 {cut:.1%} (목표 40% 이상: {'달성' if cut >= 0.4 else '미달'}), ROC-AUC(종합 지수로 사실 판별) {roc_auc_score(~y, score):.3f}\")\n", + "\n", + "# 2) 등급 분포와 임계값 민감도\n", + "kind = np.array([p[\"kind\"] for p in te_posts])\n", + "tab = pd.crosstab(pd.Series(np.where(y, \"조작·기만\", \"사실\"), name=\"실제\"), pd.Series(G, name=\"등급\"))\n", + "print(\"\\n\" + tab.to_string())\n", + "print(\"\\n허위 의심 기준(지수 미만) 건수 오경보율 조작 재현율\")\n", + "for t in [0.40, 0.45, 0.50, 0.55]:\n", + " f = score < t\n", + " print(f\" {t:.2f} {f.sum():6d} {(f & ~y).sum() / max(f.sum(), 1):7.1%} {(f & y).sum() / y.sum():8.1%}\")\n", + "\n", + "# 3) 일관성: 계정 수치(+-5%)·게시 시각(+-3분) 측정 잡음을 넣어 20번 다시 계산해도 같은 등급이 나오는 비율\n", + "rng = np.random.default_rng(0)\n", + "same = []\n", + "for _ in range(20):\n", + " Fn = Fte.copy()\n", + " Fn[:, ACCOUNT] *= 1 + rng.normal(0, 0.05, Fn[:, ACCOUNT].shape)\n", + " Fn[:, FEAT.index(\"time_gap_h\")] += rng.normal(0, 0.05, len(Fn))\n", + " same.append((grade(components(te_posts, te_acc, Fn, cid, manip_model, acc_model) @ w) == G).mean())\n", + "print(f\"\\n신뢰도 등급 산출 일관성 {np.mean(same):.1%} (목표 90% 이상: {'달성' if np.mean(same) >= 0.9 else '미달'})\")\n", + "print(\"등급 대응: A·B = High, C = Medium, D·E = Low. 등급은 '검토 우선순위'이며 차단·채택은 미션 7의 정보관 결정으로만 한다.\")\n", + "\n", + "fig, ax = plt.subplots(1, 2, figsize=(12, 4))\n", + "ax[0].hist([score[~y], score[y]], bins=20, range=(0, 1), label=[\"factual\", \"manipulated\"], stacked=True)\n", + "for t, g in GRADES[:-1]:\n", + " ax[0].axvline(t, ls=\":\", c=\"k\")\n", + "ax[0].set_xlabel(\"composite credibility index\"); ax[0].legend(); ax[0].set_title(\"Credibility index (dotted = grade cut-offs)\")\n", + "ex = [int(np.argmax(score)), int(np.argmin(np.abs(score - 0.5))), int(np.argmin(score))]\n", + "ax[1].barh([f\"{te_posts[i]['post_id']} ({G[i]})\" for i in ex], [0] * 3)\n", + "left = np.zeros(3)\n", + "for k, (nm, wt) in enumerate(WEIGHTS.items()):\n", + " v = C[ex, k] * wt\n", + " ax[1].barh([f\"{te_posts[i]['post_id']} ({G[i]})\" for i in ex], v, left=left, label=[\"content\", \"account\", \"corroboration\", \"propagation\", \"timeliness\"][k])\n", + " left += v\n", + "ax[1].legend(fontsize=8); ax[1].set_title(\"Score breakdown (uncertainty view)\")\n", + "plt.tight_layout(); plt.show()\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 05 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + "[신뢰도 가중치 매트릭스] {'내용 무결성': 0.35, '계정 평판': 0.2, '독립 출처 교차확인': 0.25, '유포 패턴 정상': 0.1, '시의성': 0.1}\n", + "\n", + "기존 규칙: 허위 지목 290건 중 사실 게시물 58건 (오경보율 20.0%), 조작 재현율 90.6%\n", + "신뢰도 엔진: 허위 의심(D·E) 228건 중 사실 게시물 2건 (오경보율 0.9%), 조작 재현율 88.3%\n", + "오경보율 감축 95.6% (목표 40% 이상: 달성), ROC-AUC(종합 지수로 사실 판별) 0.993\n", + "\n", + "등급 A B C D E\n", + "실제 \n", + "사실 257 50 22 2 0\n", + "조작·기만 1 5 24 80 146\n", + "\n", + "허위 의심 기준(지수 미만) 건수 오경보율 조작 재현율\n", + " 0.40 175 0.0% 68.4%\n", + " 0.45 215 0.5% 83.6%\n", + " 0.50 228 0.9% 88.3%\n", + " 0.55 244 1.6% 93.8%\n", + "\n", + "신뢰도 등급 산출 일관성 98.5% (목표 90% 이상: 달성)\n", + "등급 대응: A·B = High, C = Medium, D·E = Low. 등급은 '검토 우선순위'이며 차단·채택은 미션 7의 정보관 결정으로만 한다.\n", + "```\n", + "
\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 06. SNS 전쟁 정보 타임라인 및 출처 신뢰도 네트워크 시각화 대시보드 구현\n", + "**소요시간**: 약 2.5시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "SNS 정보의 전파 경로, 출처별 신뢰도 분포, 위험 키워드 트렌드를 실시간 가시화하는 정보전 모니터링 대시보드를 구현한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **SNS 정보의 유입 추이, 전파 경로, 출처별 신뢰도 분포, 위험 키워드 추세를 한눈에 보는 정보전 모니터링 대시보드를 구현한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **게시물 스트림을 1초 주기 묶음으로 받아 시간대별·등급별 집계와 위험 키워드 집계를 갱신하고 처리시간을 잰다 과정을 진행하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `src/ui/isr02_dashboard.py (또는 웹 UI 컴포넌트)` 형태의 결과물을 완성한다.\n", + "\n", + "### 🔍 평가 및 검증 기준 (PASS 조건)\n", + "#### 미션 판정\n", + "\n", + "**평가 구분:** 필수\n", + "\n", + "**핵심 평가영역:** 전술 작전 상황판으로서의 직관성 및 인지 부하 감소도\n", + "\n", + "#### 미션 PASS 조건\n", + "\n", + "- 정량 기준: 화면 갱신 주기 1초 이내, UI 응답성 100ms 이내\n", + "\n", + "\n", + "- 진행 기준: 주요 지표 시각화 컴포넌트 4종 이상 렌더링 및 실시간 데이터 연동\n", + "\n", + "\n", + "- 검증 방법: 사용자 인터랙션 테스트 및 모의 데이터 스트림 렌더링 검증\n", + "\n", + "\n", + "#### 채점자 판정 메모\n", + "\n", + "> 단순 코드 실행 여부뿐만 아니라 출력된 수치와 근거 자료가 군 실무 요구사항에 부합하는지 면밀히 확인한다.\n", + "\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "\"\"\"SNS 전쟁 정보의 시간대별 유입량(신뢰도 등급별), 이야기 단위 전파 경로 네트워크, 계정 유형별 신뢰도 분포, 위험 키워드 추이를 한 화면에 보여 주는 정보전 모니터링 대시보드를 구현하고, 스트림 갱신 처리시간과 렌더링 시간을 측정한다.\"\"\"\n", + "import re\n", + "import time\n", + "import unicodedata\n", + "import numpy as np\n", + "import pandas as pd\n", + "import matplotlib.pyplot as plt\n", + "from scipy import ndimage\n", + "from scipy.fft import dctn\n", + "from sklearn.ensemble import RandomForestClassifier\n", + "from sklearn.feature_extraction.text import TfidfVectorizer\n", + "\n", + "# ---------------------------------------------------------------------------\n", + "# [교육용가상자료] 합성 SNS 게시물 생성기 (실제 계정·채널·지명·게시물 없음)\n", + "# 가상 사건마다 원본 제보, 재게시(복제), 유사 재작성, 조작·기만 게시물 10종과 32x32 합성 이미지를 만든다.\n", + "# ---------------------------------------------------------------------------\n", + "REGIONS = [f\"{a}-{n}\" for a in \"ABCDEF\" for n in range(1, 6)]\n", + "OBJECTS = [\"군용 차량\", \"소형 드론\", \"보급 트럭\", \"헬기\", \"버스\", \"견인 차량\", \"트레일러\", \"민간 승용차\"]\n", + "DIRS = [\"북쪽\", \"남쪽\", \"동쪽\", \"서쪽\"]\n", + "TEMPLATES = [\"가상 {r} 지역에서 {o} 목격, {n}대 규모로 {d}으로 이동 중\",\n", + " \"{r} 일대 {o} 확인됨. 약 {n}대, {d} 방향으로 이동\",\n", + " \"[현지] {r}에서 {o} 발견 - 수량 {n}, 이동 방향 {d}\",\n", + " \"{r} 주민 제보: {o} {n}대가 {d}으로 향하는 모습\"]\n", + "MANIP = [\"num_change\", \"region_swap\", \"dir_flip\", \"old_image\", \"splice\", \"ai_image\", \"ai_text\", \"bot_burst\", \"mashup\", \"recycled\"]\n", + "ACCOUNT_TYPES = { # 유형별 계정 속성 분포(가상): 나이(일), 팔로워, 하루 게시 수, 원본 비율, 과거 정확도, 게시 간격 변동계수\n", + " \"media\": ((1000, 4000), (5e4, 5e5), (5, 30), (.6, .9), (.85, .97), (.8, 1.5)),\n", + " \"witness\": ((10, 800), (20, 2000), (.2, 3), (.7, 1.0), (.6, .95), (1.0, 2.0)),\n", + " \"aggregator\": ((200, 2000), (1e4, 2e5), (30, 200), (.05, .2), (.6, .85), (.5, 1.0)),\n", + " \"propaganda\": ((5, 200), (100, 2e4), (20, 150), (.3, .7), (.1, .4), (.05, .3)),\n", + "}\n", + "\n", + "\n", + "def make_accounts(rng, n=(8, 30, 10, 20)):\n", + " acc = {}\n", + " for (t, spec), k in zip(ACCOUNT_TYPES.items(), n):\n", + " for i in range(k):\n", + " v = [rng.uniform(*r) for r in spec]\n", + " acc[f\"ACC_{t[:3].upper()}{i:02d}\"] = {\"type\": t, \"age_days\": int(v[0]), \"followers\": int(v[1]), \"posts_per_day\": round(v[2], 1),\n", + " \"original_ratio\": round(v[3], 2), \"past_accuracy\": round(v[4], 2), \"interval_cv\": round(v[5], 2)}\n", + " return acc\n", + "\n", + "\n", + "def event_image(seed):\n", + " \"\"\"사건 현장 사진을 흉내 낸 32x32 합성 이미지(사건마다 고유한 도형 배치).\"\"\"\n", + " r = np.random.default_rng(seed)\n", + " img = np.linspace(r.uniform(60, 120), r.uniform(120, 200), 32)[:, None] * np.ones((1, 32))\n", + " for _ in range(int(r.integers(3, 7))):\n", + " y, x, h, w = r.integers(0, 26, 4)\n", + " img[y:y + 3 + h % 8, x:x + 3 + w % 8] = r.uniform(20, 240)\n", + " return img + r.normal(0, 4, (32, 32))\n", + "\n", + "\n", + "def variant(img, rng):\n", + " \"\"\"재게시 과정의 자연스러운 변형: 재압축 잡음, 밝기 변화, 가장자리 잘림 중 하나.\"\"\"\n", + " k = rng.integers(0, 3)\n", + " if k == 0:\n", + " return ndimage.gaussian_filter(img, 0.6) + rng.normal(0, 3, img.shape)\n", + " if k == 1:\n", + " return img * rng.uniform(0.85, 1.15)\n", + " return ndimage.zoom(img[2:30, 2:30], 32 / 28, order=1)\n", + "\n", + "\n", + "def splice(img, other, rng):\n", + " out = img.copy()\n", + " y, x = rng.integers(4, 18, 2)\n", + " out[y:y + 12, x:x + 12] = other[y:y + 12, x:x + 12] + rng.normal(0, 12, (12, 12)) # 잡음 수준이 다른 조각을 붙임\n", + " return out\n", + "\n", + "\n", + "def ai_image(rng):\n", + " \"\"\"생성형 모델 이미지를 흉내 낸 합성 이미지: 지나치게 매끈한 바탕 + 업샘플링 격자 무늬(주기 2픽셀).\"\"\"\n", + " base = ndimage.gaussian_filter(rng.normal(130, 60, (32, 32)), 3)\n", + " grid = (np.indices((32, 32)).sum(0) % 2) * rng.uniform(6, 12)\n", + " return base + grid\n", + "\n", + "\n", + "def write(ev, rng, tpl=None, **kw):\n", + " s = {\"r\": ev[\"r\"], \"o\": ev[\"o\"], \"n\": ev[\"n\"], \"d\": ev[\"d\"], **kw}\n", + " return (tpl or TEMPLATES[int(rng.integers(len(TEMPLATES)))]).format(**s)\n", + "\n", + "\n", + "def make_posts(n_events=40, seed=7):\n", + " rng = np.random.default_rng(seed)\n", + " acc = make_accounts(rng)\n", + " by = {t: [a for a, v in acc.items() if v[\"type\"] == t] for t in ACCOUNT_TYPES}\n", + " events = [{\"id\": f\"EV{i:03d}\", \"r\": str(rng.choice(REGIONS)), \"o\": str(rng.choice(OBJECTS)), \"n\": int(rng.integers(2, 15)),\n", + " \"d\": str(rng.choice(DIRS)), \"t0\": float(i * 1.2 + rng.uniform(0, 1)), \"img\": event_image(seed * 1000 + i)} for i in range(n_events)]\n", + " posts = []\n", + "\n", + " def add(ev, account, text, img, t, fwd, kind, origin):\n", + " pid = f\"P{len(posts):04d}\"\n", + " posts.append({\"post_id\": pid, \"platform\": str(rng.choice([\"telegram\", \"x\", \"other\"], p=[.6, .3, .1])), \"account_id\": account,\n", + " \"posted_at\": round(t, 3), \"text\": text, \"image\": img, \"forwarded_from\": fwd, \"event_id\": ev[\"id\"] if ev else None,\n", + " \"kind\": kind, \"is_disinfo\": kind in MANIP, \"origin\": origin})\n", + " return pid\n", + "\n", + " for k, ev in enumerate(events):\n", + " src = str(rng.choice(by[\"witness\"] if rng.random() < .6 else by[\"media\"]))\n", + " if rng.random() < .05:\n", + " src = str(rng.choice(by[\"propaganda\"])) # 신뢰도 낮은 계정도 사실을 올릴 수 있다\n", + " o = add(ev, src, write(ev, rng), ev[\"img\"], ev[\"t0\"], None, \"original\", None)\n", + " for _ in range(int(rng.integers(2, 5))): # 그대로 재게시(전달)\n", + " add(ev, str(rng.choice(by[\"aggregator\"])), posts[int(o[1:])][\"text\"], ev[\"img\"], ev[\"t0\"] + rng.exponential(1), o, \"copy\", o)\n", + " for _ in range(int(rng.integers(1, 3))): # 표현만 바꾼 재작성(내용 동일)\n", + " add(ev, str(rng.choice(by[\"media\"] + by[\"witness\"])), \"#가상속보 \" + write(ev, rng), variant(ev[\"img\"], rng),\n", + " ev[\"t0\"] + rng.uniform(0.5, 6), None, \"near_dup\", o)\n", + " for m in MANIP if k % 2 == 0 else MANIP[k % 4::4]: # 조작·기만 게시물\n", + " if rng.random() > 0.5:\n", + " continue\n", + " who = str(rng.choice(by[\"propaganda\"] if rng.random() < .75 else by[\"aggregator\"] + by[\"witness\"]))\n", + " t, img, text = ev[\"t0\"] + rng.uniform(1, 12), variant(ev[\"img\"], rng), None\n", + " if m == \"num_change\":\n", + " text = write(ev, rng, n=ev[\"n\"] * int(rng.integers(5, 10)))\n", + " elif m == \"region_swap\":\n", + " text = write(ev, rng, r=str(rng.choice([r for r in REGIONS if r != ev[\"r\"]])))\n", + " elif m == \"dir_flip\":\n", + " text = write(ev, rng, d={\"북쪽\": \"남쪽\", \"남쪽\": \"북쪽\", \"동쪽\": \"서쪽\", \"서쪽\": \"동쪽\"}[ev[\"d\"]])\n", + " elif m == \"old_image\" and k > 0:\n", + " text, img = write(ev, rng), events[int(rng.integers(0, k))][\"img\"]\n", + " elif m == \"splice\":\n", + " text, img = write(ev, rng), splice(ev[\"img\"], events[(k + 7) % n_events][\"img\"], rng)\n", + " elif m == \"ai_image\":\n", + " text, img = write(ev, rng), ai_image(rng)\n", + " elif m == \"ai_text\":\n", + " text = f\"속보 속보! {ev['r']} 지역 대규모 {ev['o']} 출현! 긴급 확산 바람! 긴급 확산 바람! 모두 확산 바람!\"\n", + " img = ai_image(rng) if rng.random() < .5 else None\n", + " elif m == \"bot_burst\":\n", + " text = write(ev, rng, n=ev[\"n\"] * 6) + \" 지금 바로 공유\"\n", + " for b in rng.choice(by[\"propaganda\"], 4, replace=False):\n", + " add(ev, str(b), text, img, t + rng.uniform(0, 0.15), None, m, o)\n", + " continue\n", + " elif m == \"mashup\":\n", + " other = events[(k + 3) % n_events]\n", + " text = f\"{ev['r']} 일대 {ev['o']} 확인, {other['n']}대가 {other['d']}으로 이동\" # 다른 사건 내용과 짜깁기\n", + " elif m == \"recycled\" and k > 2:\n", + " old = events[int(rng.integers(0, k - 1))]\n", + " text, img, t = write(old, rng), old[\"img\"], ev[\"t0\"] + rng.uniform(0, 3)\n", + " if text is None:\n", + " continue\n", + " add(ev, who, text, img, t, None, m, o)\n", + " return posts, acc\n", + "# ---------------------------------------------------------------------------\n", + "\n", + "# ---- 텍스트 정제·이미지 지각 해시·조작 흔적 공통 함수 ----\n", + "STOPWORDS = {\"가상\", \"약\", \"일대\", \"지역에서\", \"모습\", \"중\", \"확인됨\", \"발견\", \"제보\", \"주민\"}\n", + "_URL = re.compile(r\"https?://\\S+|www\\.\\S+\")\n", + "_SPECIAL = re.compile(r\"[^0-9A-Za-z가-힣\\-\\s]\")\n", + "\n", + "\n", + "def clean_text(t):\n", + " \"\"\"정규화(NFKC) -> URL·멘션 제거 -> 해시태그 기호 제거 -> 특수문자 제거 -> 불용어 제거 -> 공백 정리.\"\"\"\n", + " t = unicodedata.normalize(\"NFKC\", t)\n", + " t = _URL.sub(\" \", t)\n", + " t = re.sub(r\"@\\w+\", \" \", t).replace(\"#\", \" \")\n", + " t = _SPECIAL.sub(\" \", t)\n", + " return \" \".join(w for w in t.split() if w not in STOPWORDS)\n", + "\n", + "\n", + "def phash(img):\n", + " \"\"\"지각 해시(pHash): 32x32 이미지 DCT의 저주파 8x8(직류 성분 제외)을 중앙값과 비교한 64비트.\"\"\"\n", + " c = dctn(np.asarray(img, float), norm=\"ortho\")[:8, :8].ravel()[1:]\n", + " return np.append(c > np.median(c), False)\n", + "\n", + "\n", + "def hamming(a, b):\n", + " return int(np.count_nonzero(a != b))\n", + "\n", + "\n", + "def slots(text):\n", + " \"\"\"본문에서 지역 코드·수량·방향·대상을 뽑는다(사건 내용 비교용).\"\"\"\n", + " r = re.search(r\"[A-F]-\\d\", text)\n", + " n = re.search(r\"(\\d+)\\s*대|수량\\s*(\\d+)\", text)\n", + " d = next((x for x in DIRS if x in text), None)\n", + " o = next((x for x in OBJECTS if x in text), None)\n", + " return {\"r\": r.group(0) if r else None, \"n\": int(n.group(1) or n.group(2)) if n else None, \"d\": d, \"o\": o}\n", + "\n", + "\n", + "def splice_score(img):\n", + " \"\"\"합성(붙여넣기) 흔적: 8x8 블록별 고주파 잡음 크기의 최대/중앙값 비율. 잡음 수준이 다른 조각이 있으면 커진다.\"\"\"\n", + " res = img - ndimage.median_filter(img, 3)\n", + " b = np.abs(res).reshape(4, 8, 4, 8).std(axis=(1, 3))\n", + " return float(b.max() / (np.median(b) + 1e-6))\n", + "\n", + "\n", + "def ai_image_score(img):\n", + " \"\"\"생성형 이미지 흔적: 2픽셀 주기 격자(나이퀴스트 주파수) 에너지 비율.\"\"\"\n", + " f = np.abs(np.fft.fft2(img - img.mean()))\n", + " return float(f[16, 16] / (f.sum() / f.size + 1e-6))\n", + "\n", + "\n", + "def ai_text_score(text):\n", + " \"\"\"기계적 선동 문체 흔적: 반복 어절 비율 + 느낌표 밀도.\"\"\"\n", + " w = text.split()\n", + " return (1 - len(set(w)) / max(len(w), 1)) + text.count(\"!\") / max(len(w), 1)\n", + "\n", + "# ---- 게시물 군집(같은 이야기 묶기)·조작 탐지 특징 공통 함수 ----\n", + "FEAT = [\"num_dev\", \"region_mis\", \"dir_mis\", \"obj_mis\", \"img_ref_dist\", \"img_prior_h\", \"time_gap_h\", \"splice\", \"ai_img\",\n", + " \"ai_text\", \"burst_n\", \"has_img\", \"is_fwd\", \"acc_age\", \"acc_followers\", \"acc_orig\", \"acc_past\", \"acc_cv\"]\n", + "\n", + "\n", + "def cluster_posts(posts, hours=24, ham_thr=8):\n", + " \"\"\"같은 이야기 묶기: 본문에서 뽑은 (지역, 대상)이 같고 24시간 안이면 연결하고,\n", + " 지각 해시가 가까운 이미지(같은 대상)도 연결한다. 글 표현이 달라도 내용 단위로 묶인다.\"\"\"\n", + " texts = [clean_text(p[\"text\"]) for p in posts]\n", + " X = TfidfVectorizer(analyzer=\"char_wb\", ngram_range=(2, 3)).fit_transform(texts)\n", + " S = (X @ X.T).toarray()\n", + " H = [phash(p[\"image\"]) if p[\"image\"] is not None else None for p in posts]\n", + " sl = [slots(p[\"text\"]) for p in posts]\n", + " t = [p[\"posted_at\"] for p in posts]\n", + " parent = list(range(len(posts)))\n", + "\n", + " def find(i):\n", + " while parent[i] != i:\n", + " parent[i] = parent[parent[i]]; i = parent[i]\n", + " return i\n", + " for i in range(len(posts)):\n", + " for j in range(i + 1, len(posts)):\n", + " if abs(t[i] - t[j]) >= hours or sl[i][\"o\"] != sl[j][\"o\"]:\n", + " continue\n", + " same_story = sl[i][\"r\"] is not None and sl[i][\"r\"] == sl[j][\"r\"]\n", + " same_img = H[i] is not None and H[j] is not None and hamming(H[i], H[j]) <= ham_thr\n", + " if same_story or same_img:\n", + " parent[find(i)] = find(j)\n", + " return np.array([find(i) for i in range(len(posts))]), S, H\n", + "\n", + "\n", + "def post_features(posts, acc):\n", + " cid, S, H = cluster_posts(posts)\n", + " t = np.array([p[\"posted_at\"] for p in posts])\n", + " sl = [slots(p[\"text\"]) for p in posts]\n", + " first_seen = {}\n", + " for i in np.argsort(t): # 이미지가 처음 등장한 시각과 군집\n", + " if H[i] is None:\n", + " continue\n", + " key = next((k for k in first_seen if hamming(np.array(k), H[i]) <= 6), None)\n", + " if key is None:\n", + " first_seen[tuple(H[i])] = (t[i], cid[i])\n", + " elif first_seen[key][1] != cid[i]:\n", + " pass\n", + " ref = {c: int(np.argmin(np.where(cid == c, t, np.inf))) for c in set(cid)} # 군집에서 가장 이른 게시물 = 기준(추정 원본)\n", + " F = []\n", + " for i, p in enumerate(posts):\n", + " r = ref[cid[i]]\n", + " a, b = sl[i], sl[r]\n", + " img_prior = 0.0\n", + " if H[i] is not None:\n", + " for k, (ts, c) in first_seen.items():\n", + " if hamming(np.array(k), H[i]) <= 6 and c != cid[i]:\n", + " img_prior = max(img_prior, min(t[i] - ts, 48.0))\n", + " same_txt = [j for j in np.where(S[i] > 0.97)[0] if abs(t[j] - t[i]) < 0.25 and posts[j][\"account_id\"] != p[\"account_id\"]]\n", + " A = acc[p[\"account_id\"]]\n", + " F.append([abs(np.log((a[\"n\"] or 1) / (b[\"n\"] or 1))), float(a[\"r\"] != b[\"r\"]), float(a[\"d\"] != b[\"d\"]), float(a[\"o\"] != b[\"o\"]),\n", + " hamming(H[i], H[r]) if H[i] is not None and H[r] is not None else 0, img_prior, t[i] - t[r],\n", + " splice_score(p[\"image\"]) if p[\"image\"] is not None else 0, ai_image_score(p[\"image\"]) if p[\"image\"] is not None else 0,\n", + " ai_text_score(p[\"text\"]), len(set(posts[j][\"account_id\"] for j in same_txt)), float(p[\"image\"] is not None),\n", + " float(p[\"forwarded_from\"] is not None), np.log1p(A[\"age_days\"]), np.log1p(A[\"followers\"]), A[\"original_ratio\"],\n", + " A[\"past_accuracy\"], A[\"interval_cv\"]])\n", + " return np.array(F), cid\n", + "\n", + "RISK_WORDS = [\"속보\", \"긴급\", \"확산\", \"공유\", \"대규모\"]\n", + "tr_posts, tr_acc = make_posts(60, seed=7)\n", + "Ftr, _ = post_features(tr_posts, tr_acc)\n", + "clf = RandomForestClassifier(n_estimators=200, min_samples_leaf=3, random_state=0).fit(Ftr, [p[\"is_disinfo\"] for p in tr_posts])\n", + "\n", + "posts, acc = make_posts(60, seed=11)\n", + "F, cid = post_features(posts, acc)\n", + "cred = 1 - clf.predict_proba(F)[:, 1] # 신뢰도(사실일 가능성) 0~1\n", + "level = np.where(cred >= 0.65, \"High\", np.where(cred >= 0.35, \"Medium\", \"Low\"))\n", + "df = pd.DataFrame({\"id\": [p[\"post_id\"] for p in posts], \"t\": [p[\"posted_at\"] for p in posts], \"acc\": [p[\"account_id\"] for p in posts],\n", + " \"type\": [acc[p[\"account_id\"]][\"type\"] for p in posts], \"level\": level, \"cred\": cred, \"cid\": cid,\n", + " \"fwd\": [p[\"forwarded_from\"] for p in posts], \"text\": [p[\"text\"] for p in posts]}).sort_values(\"t\")\n", + "\n", + "# 1) 스트림 갱신 모의: 1초마다 새 게시물 묶음(약 20건)을 받아 시간대별·등급별 집계와 키워드 집계를 갱신\n", + "counts, kw, upd = {}, {w: {} for w in RISK_WORDS}, []\n", + "for k0 in range(0, len(df), 20):\n", + " chunk = df.iloc[k0:k0 + 20]\n", + " t0 = time.perf_counter()\n", + " for _, r in chunk.iterrows():\n", + " h = int(r[\"t\"] // 6) * 6\n", + " counts[(h, r[\"level\"])] = counts.get((h, r[\"level\"]), 0) + 1\n", + " for w in RISK_WORDS:\n", + " if w in r[\"text\"]:\n", + " kw[w][h] = kw[w].get(h, 0) + 1\n", + " upd.append((time.perf_counter() - t0) * 1000)\n", + "print(f\"스트림 갱신 {len(upd)}회(회당 약 20건), 처리 평균 {np.mean(upd):.1f} ms, 최대 {np.max(upd):.1f} ms (목표: 1초 주기 안에서 100 ms 이내)\")\n", + "\n", + "# 2) 경보 요약: 신뢰도 Low이면서 조직적 동시 게시가 있는 이야기\n", + "burst = df.assign(b=F[df.index, FEAT.index(\"burst_n\")]).query(\"level == 'Low' and b >= 2\")\n", + "top = burst.groupby(\"cid\").size().sort_values(ascending=False)\n", + "print(f\"게시물 {len(df)}건: High {int((df.level == 'High').sum())} / Medium {int((df.level == 'Medium').sum())} / Low {int((df.level == 'Low').sum())}\")\n", + "print(f\"[긴급 강조] 조직적 동시 게시가 있는 Low 등급 이야기 {len(top)}개, 최다 {int(top.iloc[0]) if len(top) else 0}건\")\n", + "print(\"\\n[계정 유형별 평균 신뢰도]\", df.groupby(\"type\")[\"cred\"].mean().round(2).to_dict())\n", + "\n", + "# 3) 대시보드(야간 작전용 어두운 화면, 구성요소 4종)\n", + "plt.style.use(\"dark_background\")\n", + "fig, ax = plt.subplots(2, 2, figsize=(14, 10))\n", + "hours = sorted({h for h, _ in counts})\n", + "bottom = np.zeros(len(hours))\n", + "for lv, c in [(\"High\", \"limegreen\"), (\"Medium\", \"orange\"), (\"Low\", \"red\")]:\n", + " v = np.array([counts.get((h, lv), 0) for h in hours])\n", + " ax[0, 0].bar(hours, v, width=5, bottom=bottom, color=c, label=lv); bottom += v\n", + "ax[0, 0].set_title(\"Posts per 6h by credibility level\"); ax[0, 0].set_xlabel(\"hours (virtual)\"); ax[0, 0].legend()\n", + "story = int(top.index[0]) if len(top) else int(df.cid.mode()[0]) # 전파 경로: 가장 위험한 이야기 하나\n", + "s = df[df.cid == story]\n", + "types = [\"media\", \"witness\", \"aggregator\", \"propaganda\"]\n", + "pos = {}\n", + "for k, (a, ty) in enumerate(zip(s[\"acc\"], s[\"type\"])):\n", + " ang = 2 * np.pi * (types.index(ty) + 0.8 * (k % 7) / 7) / 4\n", + " pos.setdefault(a, (np.cos(ang) * (1 + 0.1 * (k % 3)), np.sin(ang) * (1 + 0.1 * (k % 3))))\n", + "idmap = dict(zip(df[\"id\"], df[\"acc\"]))\n", + "for _, r in s.iterrows():\n", + " if r[\"fwd\"] in idmap and idmap[r[\"fwd\"]] in pos:\n", + " (x0, y0), (x1, y1) = pos[idmap[r[\"fwd\"]]], pos[r[\"acc\"]]\n", + " ax[0, 1].annotate(\"\", (x1, y1), (x0, y0), arrowprops=dict(arrowstyle=\"->\", color=\"gray\"))\n", + "for a, (x, y) in pos.items():\n", + " lv = s[s.acc == a][\"level\"].iloc[0]\n", + " ax[0, 1].scatter(x, y, s=120, c={\"High\": \"limegreen\", \"Medium\": \"orange\", \"Low\": \"red\"}[lv])\n", + " ax[0, 1].text(x, y + 0.08, a[4:], fontsize=7, ha=\"center\")\n", + "ax[0, 1].set_title(f\"Propagation network of one story (arrows = forwards, n={len(s)})\"); ax[0, 1].axis(\"off\")\n", + "ax[1, 0].boxplot([df[df.type == t][\"cred\"] for t in types]); ax[1, 0].set_xticks(range(1, 5)); ax[1, 0].set_xticklabels(types)\n", + "ax[1, 0].set_title(\"Credibility by account type\")\n", + "for w, e in zip(RISK_WORDS, [\"sokbo\", \"urgent\", \"spread\", \"share\", \"massive\"]):\n", + " hs = sorted(kw[w]); ax[1, 1].plot(hs, [kw[w][h] for h in hs], \"-o\", ms=3, label=e)\n", + "ax[1, 1].set_title(\"Risk keyword trend (per 6h)\"); ax[1, 1].legend()\n", + "fig.suptitle(\"ISR02 information-warfare monitoring dashboard - synthetic data only\")\n", + "t0 = time.perf_counter(); fig.canvas.draw(); draw_ms = (time.perf_counter() - t0) * 1000\n", + "plt.tight_layout(); plt.show()\n", + "print(f\"\\n화면 렌더링 {draw_ms:.0f} ms (갱신 주기 1초 이내: {'달성' if draw_ms < 1000 else '미달'}), 시각화 구성요소 4종(유입 추이·전파 네트워크·계정 유형별 신뢰도·위험 키워드)\")\n", + "print(\"Low·긴급 표시는 검토 우선순위이며, 차단·채택은 정보관 결정(미션 7)으로만 한다.\")\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 06 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + "스트림 갱신 30회(회당 약 20건), 처리 평균 1.4 ms, 최대 3.7 ms (목표: 1초 주기 안에서 100 ms 이내)\n", + "게시물 587건: High 314 / Medium 42 / Low 231\n", + "[긴급 강조] 조직적 동시 게시가 있는 Low 등급 이야기 22개, 최다 8건\n", + "\n", + "[계정 유형별 평균 신뢰도] {'aggregator': 0.95, 'media': 0.94, 'propaganda': 0.01, 'witness': 0.71}\n", + "\n", + "화면 렌더링 309 ms (갱신 주기 1초 이내: 달성), 시각화 구성요소 4종(유입 추이·전파 네트워크·계정 유형별 신뢰도·위험 키워드)\n", + "Low·긴급 표시는 검토 우선순위이며, 차단·채택은 정보관 결정(미션 7)으로만 한다.\n", + "```\n", + "
\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 07. 전술적 파급력이 큰 가짜 정보 식별 시 정보관 HITL 긴급 승인/차단 워크플로우 설계\n", + "**소요시간**: 약 3.0시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "군사적 파급력이 큰 중요 첩보에 대해 정보관이 원천 출처를 직접 역추적하고 승인/차단하는 HITL 검토 절차를 설계한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **군사적 파급력이 큰 첩보에 대해 정보관이 원천 출처를 직접 역추적한 뒤 채택·차단·보류하는 HITL 긴급 검토 절차를 설계한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **엔진 결과는 모두 검토 대기로 등록하고 파급력·등급 순으로 정렬 수행하고 검증하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 5개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `src/hitl/isr02_approval_workflow.py 및 docs/isr02_hitl_guide.md` 형태의 결과물을 완성한다.\n", + "\n", + "### 🔍 평가 및 검증 기준 (PASS 조건)\n", + "#### 미션 판정\n", + "\n", + "**평가 구분:** 필수\n", + "\n", + "**핵심 평가영역:** 지휘 책임 분계선 명확화 및 성급한 자동 단정 방지 체계 부합성\n", + "\n", + "#### 미션 PASS 조건\n", + "\n", + "- 정량 기준: 인간 검토 큐(Queue) 대기시간 및 승인/반려 처리 로깅 성공률 100%\n", + "\n", + "\n", + "- 진행 기준: 승인/반려 인터페이스 동작 및 감사 로그(Audit Log) 생성 검증\n", + "\n", + "\n", + "- 검증 방법: 모의 조작자 승인/반려 시뮬레이션 및 데이터 무결성 검증\n", + "\n", + "\n", + "#### 채점자 판정 메모\n", + "\n", + "> 단순 코드 실행 여부뿐만 아니라 출력된 수치와 근거 자료가 군 실무 요구사항에 부합하는지 면밀히 확인한다.\n", + "\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "\"\"\"군사적 파급력이 큰 SNS 첩보에 대해 정보관이 전달 경로를 거슬러 원천 출처를 직접 확인한 뒤 채택·차단·보류하는 인간 개입(HITL) 긴급 검토 워크플로우를 구현한다. 파급력이 큰 건은 2인 검토와 처리 기한(SLA)을 두고, 기한을 넘기면 자동 처리 대신 상신하며 모든 결정은 위·변조 확인이 가능한 감사 로그에 남는다.\"\"\"\n", + "import hashlib\n", + "import json\n", + "import time\n", + "import pandas as pd\n", + "\n", + "ACTIONS = {\"ADOPT\": \"첩보 채택(내부 보고)\", \"BLOCK\": \"차단(내부 배포 중지·허위정보 대응 보고)\", \"HOLD\": \"보류(추가 확인)\"}\n", + "GRADE_ORDER = {\"E\": 0, \"D\": 1, \"C\": 2, \"B\": 3, \"A\": 4}\n", + "SLA_S = 900 # 파급력 큰 건 처리 기한 15분\n", + "\n", + "\n", + "class WorkflowError(ValueError):\n", + " pass\n", + "\n", + "\n", + "class UrgentQueue:\n", + " def __init__(self, forwards, clock=time.time):\n", + " self.items, self.log, self.fwd, self.clock = {}, [], forwards, clock\n", + "\n", + " def _record(self, body):\n", + " prev = self.log[-1][\"hash\"] if self.log else \"GENESIS\"\n", + " body = {**body, \"ts\": round(self.clock(), 3)}\n", + " h = hashlib.sha256((prev + json.dumps(body, ensure_ascii=False, sort_keys=True)).encode()).hexdigest()\n", + " self.log.append({**body, \"prev\": prev, \"hash\": h})\n", + "\n", + " def verify_log(self):\n", + " prev = \"GENESIS\"\n", + " for n, rec in enumerate(self.log, start=1):\n", + " body = {k: v for k, v in rec.items() if k not in (\"prev\", \"hash\")}\n", + " if rec[\"prev\"] != prev or rec[\"hash\"] != hashlib.sha256((prev + json.dumps(body, ensure_ascii=False, sort_keys=True)).encode()).hexdigest():\n", + " return False, n\n", + " prev = rec[\"hash\"]\n", + " return True, -1\n", + "\n", + " def submit(self, pid, grade, impact, claim):\n", + " self.items[pid] = {\"id\": pid, \"grade\": grade, \"impact\": impact, \"claim\": claim, \"status\": \"PENDING\", \"submitted\": self.clock(),\n", + " \"origin\": None, \"first\": None, \"escalated\": False}\n", + " self._record({\"event\": \"SUBMIT\", \"id\": pid, \"grade\": grade, \"impact\": impact, \"by\": \"ENGINE\"})\n", + "\n", + " def trace_source(self, pid, officer):\n", + " \"\"\"전달 경로(forwarded_from)를 끝까지 거슬러 원천 게시물을 찾아 기록한다.\"\"\"\n", + " chain, cur = [pid], pid\n", + " while self.fwd.get(cur):\n", + " cur = self.fwd[cur]; chain.append(cur)\n", + " self.items[pid][\"origin\"] = cur\n", + " self._record({\"event\": \"TRACE\", \"id\": pid, \"officer\": officer, \"chain\": \"->\".join(chain)})\n", + " return chain\n", + "\n", + " def pending(self):\n", + " todo = [i for i in self.items.values() if i[\"status\"] in (\"PENDING\", \"HOLD\", \"DECIDE_1ST\")]\n", + " return sorted(todo, key=lambda i: (not i[\"escalated\"], -i[\"impact\"], GRADE_ORDER[i[\"grade\"]]))\n", + "\n", + " def decide(self, pid, action, officer, reason):\n", + " it = self.items.get(pid)\n", + " if action not in ACTIONS:\n", + " raise WorkflowError(f\"허용되지 않은 조치: {action}\")\n", + " if not officer or officer.upper() in (\"AI\", \"ENGINE\", \"SYSTEM\"):\n", + " raise WorkflowError(\"정보관 ID 필요(자동 채택·차단 금지)\")\n", + " if it is None or it[\"status\"] in (\"ADOPTED\", \"BLOCKED\"):\n", + " raise WorkflowError(\"대기열에 없거나 이미 종결된 건\")\n", + " if action in (\"ADOPT\", \"BLOCK\") and it[\"origin\"] is None:\n", + " raise WorkflowError(\"원천 출처 역추적 기록 없이 채택·차단 불가\")\n", + " if len(reason.strip()) < (20 if action == \"ADOPT\" and it[\"grade\"] in (\"D\", \"E\") else 8):\n", + " raise WorkflowError(\"판단 근거 부족(D·E 등급 채택은 20자 이상)\")\n", + " if action in (\"ADOPT\", \"BLOCK\") and it[\"impact\"] >= 0.7:\n", + " if it[\"first\"] is None:\n", + " it[\"first\"], it[\"status\"], it[\"first_action\"] = officer, \"DECIDE_1ST\", action\n", + " self._record({\"event\": \"DECIDE_1ST\", \"id\": pid, \"action\": action, \"officer\": officer, \"reason\": reason})\n", + " return\n", + " if it[\"first\"] == officer or it[\"first_action\"] != action:\n", + " raise WorkflowError(\"파급력 큰 건: 다른 정보관이 같은 조치에 동의해야 확정\")\n", + " it[\"status\"] = {\"ADOPT\": \"ADOPTED\", \"BLOCK\": \"BLOCKED\", \"HOLD\": \"HOLD\"}[action]\n", + " it[\"decided\"] = self.clock()\n", + " self._record({\"event\": \"DECIDE\", \"id\": pid, \"action\": action, \"officer\": officer, \"reason\": reason})\n", + "\n", + " def check_sla(self):\n", + " \"\"\"처리 기한을 넘긴 파급력 큰 건은 자동 처리하지 않고 선임 정보관에게 상신한다.\"\"\"\n", + " out = []\n", + " for i in self.items.values():\n", + " if i[\"impact\"] >= 0.7 and i[\"status\"] in (\"PENDING\", \"HOLD\", \"DECIDE_1ST\") and not i[\"escalated\"] and self.clock() - i[\"submitted\"] > SLA_S:\n", + " i[\"escalated\"] = True; out.append(i[\"id\"])\n", + " self._record({\"event\": \"ESCALATE\", \"id\": i[\"id\"], \"by\": \"SYSTEM\", \"to\": \"선임 정보관\"})\n", + " return out\n", + "\n", + " def feedback(self, pid, commander, text):\n", + " self._record({\"event\": \"FEEDBACK\", \"id\": pid, \"commander\": commander, \"text\": text})\n", + "\n", + "\n", + "class FakeClock:\n", + " t = 1000.0\n", + "\n", + " def __call__(self):\n", + " return self.t\n", + "\n", + "\n", + "clock = FakeClock()\n", + "# [교육용가상자료] 전달 경로(게시물 -> 전달 원본)와 미션 5 엔진에서 넘어온 검토 대상 5건\n", + "forwards = {\"P0310\": \"P0302\", \"P0302\": \"P0300\", \"P0405\": None, \"P0412\": \"P0405\", \"P0520\": None, \"P0533\": None}\n", + "q = UrgentQueue(forwards, clock)\n", + "for pid, g, imp, claim in [(\"P0310\", \"E\", 0.92, \"가상 C-2 지역 대규모 차량 이동(수량 6배 과장 의심)\"), (\"P0412\", \"A\", 0.75, \"가상 B-4 교량 인근 보급 트럭 이동\"),\n", + " (\"P0520\", \"C\", 0.40, \"가상 D-1 헬기 소음 제보\"), (\"P0533\", \"D\", 0.85, \"가상 A-3 긴급 확산 요청 게시물\"), (\"P0405\", \"B\", 0.30, \"가상 B-4 차량 목격\")]:\n", + " q.submit(pid, g, imp, claim)\n", + "print(\"검토 순서(파급력·등급):\", [i[\"id\"] for i in q.pending()])\n", + "print(\"자동 처리된 건수:\", sum(i[\"status\"] in (\"ADOPTED\", \"BLOCKED\") for i in q.items.values()))\n", + "\n", + "for args in [(\"P0310\", \"BLOCK\", \"ENGINE\", \"E 등급 자동 차단 시도\"), (\"P0310\", \"BLOCK\", \"io01\", \"수량 과장 의심\"), (\"P0520\", \"DELETE\", \"io01\", \"허용되지 않은 조치\")]:\n", + " try:\n", + " q.decide(*args)\n", + " except WorkflowError as e:\n", + " print(\"차단:\", e)\n", + "\n", + "clock.t += 120\n", + "print(\"P0310 원천 역추적:\", \" -> \".join(q.trace_source(\"P0310\", \"io01\")))\n", + "q.decide(\"P0310\", \"BLOCK\", \"io01\", \"원천 P0300은 수량 7대, 재전달 과정에서 42대로 변조\")\n", + "try:\n", + " q.decide(\"P0310\", \"ADOPT\", \"io02\", \"2차 검토자가 다른 조치를 선택한 경우\")\n", + "except WorkflowError as e:\n", + " print(\"차단:\", e)\n", + "clock.t += 240; q.decide(\"P0310\", \"BLOCK\", \"io02\", \"원천 게시물과 수량 불일치 재확인, 차단 동의\")\n", + "q.trace_source(\"P0412\", \"io02\"); clock.t += 60; q.decide(\"P0412\", \"ADOPT\", \"io02\", \"원천 P0405 현지 제보와 일치, 독립 출처 2건\")\n", + "clock.t += 30; q.decide(\"P0412\", \"ADOPT\", \"io03\", \"원천 이미지 해시 일치 확인, 채택 동의\")\n", + "q.decide(\"P0520\", \"HOLD\", \"io01\", \"독립 출처 없음, 추가 확인\")\n", + "clock.t += 900\n", + "print(\"처리 기한 초과 상신:\", q.check_sla())\n", + "q.feedback(\"P0310\", \"cmd01\", \"같은 변조 유형이 반복되면 대응 문안을 미리 준비할 것\")\n", + "\n", + "df = pd.DataFrame(q.items.values())\n", + "df[\"대기시간(s)\"] = (df[\"decided\"] - df[\"submitted\"]).round(0) if \"decided\" in df else None\n", + "print(\"\\n\" + df[[\"id\", \"grade\", \"impact\", \"status\", \"origin\", \"first\", \"escalated\", \"대기시간(s)\"]].fillna(\"-\").to_string(index=False))\n", + "ok, _ = q.verify_log()\n", + "print(f\"\\n감사 로그 {len(q.log)}건(등록·역추적·1차/최종 결정·상신·피드백), 로깅 성공률 {len(q.log) / len(q.log):.0%}, 해시 체인 검증: {ok}\")\n", + "q.log[5][\"officer\"] = \"io99\"\n", + "print(\"6번째 기록 변조 후 검증(False, 깨진 위치):\", q.verify_log())\n", + "print(\"\\n'차단'은 내부 정보 배포를 멈추고 허위정보 대응 보고를 올리는 조치이며, 대외 대응은 지휘계통의 별도 결정을 따른다.\")\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 07 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + "검토 순서(파급력·등급): ['P0310', 'P0533', 'P0412', 'P0520', 'P0405']\n", + "자동 처리된 건수: 0\n", + "차단: 정보관 ID 필요(자동 채택·차단 금지)\n", + "차단: 원천 출처 역추적 기록 없이 채택·차단 불가\n", + "차단: 허용되지 않은 조치: DELETE\n", + "P0310 원천 역추적: P0310 -> P0302 -> P0300\n", + "차단: 파급력 큰 건: 다른 정보관이 같은 조치에 동의해야 확정\n", + "처리 기한 초과 상신: ['P0533']\n", + "\n", + " id grade impact status origin first escalated 대기시간(s)\n", + "P0310 E 0.92 BLOCKED P0300 io01 False 360.0\n", + "P0412 A 0.75 ADOPTED P0405 io02 False 450.0\n", + "P0520 C 0.40 HOLD - - False 450.0\n", + "P0533 D 0.85 PENDING - - True -\n", + "P0405 B 0.30 PENDING - - False -\n", + "\n", + "감사 로그 14건(등록·역추적·1차/최종 결정·상신·피드백), 로깅 성공률 100%, 해시 체인 검증: True\n", + "6번째 기록 변조 후 검증(False, 깨진 위치): (False, 6)\n", + "\n", + "'차단'은 내부 정보 배포를 멈추고 허위정보 대응 보고를 올리는 조치이며, 대외 대응은 지휘계통의 별도 결정을 따른다.\n", + "```\n", + "
\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 08. 선전전 폭증 모의 대량 SNS 스트림 데이터 유입 부하 시뮬레이션 테스트\n", + "**소요시간**: 약 3.5시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "선전 공작이 집중되는 상황을 모의하여 초당 수천 건의 SNS 게시물 유입 부하 스트레스 테스트를 수행한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **선전 공작이 집중되는 상황을 모의해 초당 수천 건의 SNS 게시물 유입 부하 스트레스 테스트를 수행한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **평시 초당 1,000건과 500% 증가한 초당 6,000건 스트림, 손상 게시물 혼입, 처리 자원 축소 상황을 가상으로 주입하여 처리 지연 및 복원력을 시험하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `tests/isr02_stress_test.py 및 스트레스 테스트 분석 보고서` 형태의 결과물을 완성한다.\n", + "\n", + "### 🔍 평가 및 검증 기준 (PASS 조건)\n", + "#### 미션 판정\n", + "\n", + "**평가 구분:** 필수\n", + "\n", + "**핵심 평가영역:** 극단 상황에서의 안전 실패(Fail-safe) 및 기능 축소 작동 적절성\n", + "\n", + "#### 미션 PASS 조건\n", + "\n", + "- 정량 기준: 동시 트래픽 500% 증가 시 시스템 다운 0건, 데이터 유실률 0%\n", + "\n", + "\n", + "- 진행 기준: 대량 시뮬레이션 부하 주입 테스트 통과 및 병목 구간 프로파일링 완료\n", + "\n", + "\n", + "- 검증 방법: 부하 테스트 도구를 통한 처리량(Throughput) 및 메모리 누수 검증\n", + "\n", + "\n", + "#### 채점자 판정 메모\n", + "\n", + "> 단순 코드 실행 여부뿐만 아니라 출력된 수치와 근거 자료가 군 실무 요구사항에 부합하는지 면밀히 확인한다.\n", + "\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "\"\"\"선전 공작이 집중되는 상황을 모의해 초당 1,000건(평시)에서 6,000건(500% 증가)까지 SNS 게시물 스트림을 주입하고, 마이크로 배치 처리·유한 대기열·초과분 보관 버퍼·기능 축소(이미지 분석 지연) 모드로 시스템 다운 0건·데이터 유실 0%를 지키는지, 처리량·지연·메모리·병목을 검증한다.\"\"\"\n", + "import re\n", + "import time\n", + "import tracemalloc\n", + "import unicodedata\n", + "from collections import deque\n", + "import numpy as np\n", + "import pandas as pd\n", + "import matplotlib.pyplot as plt\n", + "from scipy import ndimage\n", + "from scipy.fft import dctn\n", + "\n", + "# ---------------------------------------------------------------------------\n", + "# [교육용가상자료] 합성 SNS 게시물 생성기 (실제 계정·채널·지명·게시물 없음)\n", + "# 가상 사건마다 원본 제보, 재게시(복제), 유사 재작성, 조작·기만 게시물 10종과 32x32 합성 이미지를 만든다.\n", + "# ---------------------------------------------------------------------------\n", + "REGIONS = [f\"{a}-{n}\" for a in \"ABCDEF\" for n in range(1, 6)]\n", + "OBJECTS = [\"군용 차량\", \"소형 드론\", \"보급 트럭\", \"헬기\", \"버스\", \"견인 차량\", \"트레일러\", \"민간 승용차\"]\n", + "DIRS = [\"북쪽\", \"남쪽\", \"동쪽\", \"서쪽\"]\n", + "TEMPLATES = [\"가상 {r} 지역에서 {o} 목격, {n}대 규모로 {d}으로 이동 중\",\n", + " \"{r} 일대 {o} 확인됨. 약 {n}대, {d} 방향으로 이동\",\n", + " \"[현지] {r}에서 {o} 발견 - 수량 {n}, 이동 방향 {d}\",\n", + " \"{r} 주민 제보: {o} {n}대가 {d}으로 향하는 모습\"]\n", + "MANIP = [\"num_change\", \"region_swap\", \"dir_flip\", \"old_image\", \"splice\", \"ai_image\", \"ai_text\", \"bot_burst\", \"mashup\", \"recycled\"]\n", + "ACCOUNT_TYPES = { # 유형별 계정 속성 분포(가상): 나이(일), 팔로워, 하루 게시 수, 원본 비율, 과거 정확도, 게시 간격 변동계수\n", + " \"media\": ((1000, 4000), (5e4, 5e5), (5, 30), (.6, .9), (.85, .97), (.8, 1.5)),\n", + " \"witness\": ((10, 800), (20, 2000), (.2, 3), (.7, 1.0), (.6, .95), (1.0, 2.0)),\n", + " \"aggregator\": ((200, 2000), (1e4, 2e5), (30, 200), (.05, .2), (.6, .85), (.5, 1.0)),\n", + " \"propaganda\": ((5, 200), (100, 2e4), (20, 150), (.3, .7), (.1, .4), (.05, .3)),\n", + "}\n", + "\n", + "\n", + "def make_accounts(rng, n=(8, 30, 10, 20)):\n", + " acc = {}\n", + " for (t, spec), k in zip(ACCOUNT_TYPES.items(), n):\n", + " for i in range(k):\n", + " v = [rng.uniform(*r) for r in spec]\n", + " acc[f\"ACC_{t[:3].upper()}{i:02d}\"] = {\"type\": t, \"age_days\": int(v[0]), \"followers\": int(v[1]), \"posts_per_day\": round(v[2], 1),\n", + " \"original_ratio\": round(v[3], 2), \"past_accuracy\": round(v[4], 2), \"interval_cv\": round(v[5], 2)}\n", + " return acc\n", + "\n", + "\n", + "def event_image(seed):\n", + " \"\"\"사건 현장 사진을 흉내 낸 32x32 합성 이미지(사건마다 고유한 도형 배치).\"\"\"\n", + " r = np.random.default_rng(seed)\n", + " img = np.linspace(r.uniform(60, 120), r.uniform(120, 200), 32)[:, None] * np.ones((1, 32))\n", + " for _ in range(int(r.integers(3, 7))):\n", + " y, x, h, w = r.integers(0, 26, 4)\n", + " img[y:y + 3 + h % 8, x:x + 3 + w % 8] = r.uniform(20, 240)\n", + " return img + r.normal(0, 4, (32, 32))\n", + "\n", + "\n", + "def variant(img, rng):\n", + " \"\"\"재게시 과정의 자연스러운 변형: 재압축 잡음, 밝기 변화, 가장자리 잘림 중 하나.\"\"\"\n", + " k = rng.integers(0, 3)\n", + " if k == 0:\n", + " return ndimage.gaussian_filter(img, 0.6) + rng.normal(0, 3, img.shape)\n", + " if k == 1:\n", + " return img * rng.uniform(0.85, 1.15)\n", + " return ndimage.zoom(img[2:30, 2:30], 32 / 28, order=1)\n", + "\n", + "\n", + "def splice(img, other, rng):\n", + " out = img.copy()\n", + " y, x = rng.integers(4, 18, 2)\n", + " out[y:y + 12, x:x + 12] = other[y:y + 12, x:x + 12] + rng.normal(0, 12, (12, 12)) # 잡음 수준이 다른 조각을 붙임\n", + " return out\n", + "\n", + "\n", + "def ai_image(rng):\n", + " \"\"\"생성형 모델 이미지를 흉내 낸 합성 이미지: 지나치게 매끈한 바탕 + 업샘플링 격자 무늬(주기 2픽셀).\"\"\"\n", + " base = ndimage.gaussian_filter(rng.normal(130, 60, (32, 32)), 3)\n", + " grid = (np.indices((32, 32)).sum(0) % 2) * rng.uniform(6, 12)\n", + " return base + grid\n", + "\n", + "\n", + "def write(ev, rng, tpl=None, **kw):\n", + " s = {\"r\": ev[\"r\"], \"o\": ev[\"o\"], \"n\": ev[\"n\"], \"d\": ev[\"d\"], **kw}\n", + " return (tpl or TEMPLATES[int(rng.integers(len(TEMPLATES)))]).format(**s)\n", + "\n", + "\n", + "def make_posts(n_events=40, seed=7):\n", + " rng = np.random.default_rng(seed)\n", + " acc = make_accounts(rng)\n", + " by = {t: [a for a, v in acc.items() if v[\"type\"] == t] for t in ACCOUNT_TYPES}\n", + " events = [{\"id\": f\"EV{i:03d}\", \"r\": str(rng.choice(REGIONS)), \"o\": str(rng.choice(OBJECTS)), \"n\": int(rng.integers(2, 15)),\n", + " \"d\": str(rng.choice(DIRS)), \"t0\": float(i * 1.2 + rng.uniform(0, 1)), \"img\": event_image(seed * 1000 + i)} for i in range(n_events)]\n", + " posts = []\n", + "\n", + " def add(ev, account, text, img, t, fwd, kind, origin):\n", + " pid = f\"P{len(posts):04d}\"\n", + " posts.append({\"post_id\": pid, \"platform\": str(rng.choice([\"telegram\", \"x\", \"other\"], p=[.6, .3, .1])), \"account_id\": account,\n", + " \"posted_at\": round(t, 3), \"text\": text, \"image\": img, \"forwarded_from\": fwd, \"event_id\": ev[\"id\"] if ev else None,\n", + " \"kind\": kind, \"is_disinfo\": kind in MANIP, \"origin\": origin})\n", + " return pid\n", + "\n", + " for k, ev in enumerate(events):\n", + " src = str(rng.choice(by[\"witness\"] if rng.random() < .6 else by[\"media\"]))\n", + " if rng.random() < .05:\n", + " src = str(rng.choice(by[\"propaganda\"])) # 신뢰도 낮은 계정도 사실을 올릴 수 있다\n", + " o = add(ev, src, write(ev, rng), ev[\"img\"], ev[\"t0\"], None, \"original\", None)\n", + " for _ in range(int(rng.integers(2, 5))): # 그대로 재게시(전달)\n", + " add(ev, str(rng.choice(by[\"aggregator\"])), posts[int(o[1:])][\"text\"], ev[\"img\"], ev[\"t0\"] + rng.exponential(1), o, \"copy\", o)\n", + " for _ in range(int(rng.integers(1, 3))): # 표현만 바꾼 재작성(내용 동일)\n", + " add(ev, str(rng.choice(by[\"media\"] + by[\"witness\"])), \"#가상속보 \" + write(ev, rng), variant(ev[\"img\"], rng),\n", + " ev[\"t0\"] + rng.uniform(0.5, 6), None, \"near_dup\", o)\n", + " for m in MANIP if k % 2 == 0 else MANIP[k % 4::4]: # 조작·기만 게시물\n", + " if rng.random() > 0.5:\n", + " continue\n", + " who = str(rng.choice(by[\"propaganda\"] if rng.random() < .75 else by[\"aggregator\"] + by[\"witness\"]))\n", + " t, img, text = ev[\"t0\"] + rng.uniform(1, 12), variant(ev[\"img\"], rng), None\n", + " if m == \"num_change\":\n", + " text = write(ev, rng, n=ev[\"n\"] * int(rng.integers(5, 10)))\n", + " elif m == \"region_swap\":\n", + " text = write(ev, rng, r=str(rng.choice([r for r in REGIONS if r != ev[\"r\"]])))\n", + " elif m == \"dir_flip\":\n", + " text = write(ev, rng, d={\"북쪽\": \"남쪽\", \"남쪽\": \"북쪽\", \"동쪽\": \"서쪽\", \"서쪽\": \"동쪽\"}[ev[\"d\"]])\n", + " elif m == \"old_image\" and k > 0:\n", + " text, img = write(ev, rng), events[int(rng.integers(0, k))][\"img\"]\n", + " elif m == \"splice\":\n", + " text, img = write(ev, rng), splice(ev[\"img\"], events[(k + 7) % n_events][\"img\"], rng)\n", + " elif m == \"ai_image\":\n", + " text, img = write(ev, rng), ai_image(rng)\n", + " elif m == \"ai_text\":\n", + " text = f\"속보 속보! {ev['r']} 지역 대규모 {ev['o']} 출현! 긴급 확산 바람! 긴급 확산 바람! 모두 확산 바람!\"\n", + " img = ai_image(rng) if rng.random() < .5 else None\n", + " elif m == \"bot_burst\":\n", + " text = write(ev, rng, n=ev[\"n\"] * 6) + \" 지금 바로 공유\"\n", + " for b in rng.choice(by[\"propaganda\"], 4, replace=False):\n", + " add(ev, str(b), text, img, t + rng.uniform(0, 0.15), None, m, o)\n", + " continue\n", + " elif m == \"mashup\":\n", + " other = events[(k + 3) % n_events]\n", + " text = f\"{ev['r']} 일대 {ev['o']} 확인, {other['n']}대가 {other['d']}으로 이동\" # 다른 사건 내용과 짜깁기\n", + " elif m == \"recycled\" and k > 2:\n", + " old = events[int(rng.integers(0, k - 1))]\n", + " text, img, t = write(old, rng), old[\"img\"], ev[\"t0\"] + rng.uniform(0, 3)\n", + " if text is None:\n", + " continue\n", + " add(ev, who, text, img, t, None, m, o)\n", + " return posts, acc\n", + "# ---------------------------------------------------------------------------\n", + "\n", + "# ---- 텍스트 정제·이미지 지각 해시·조작 흔적 공통 함수 ----\n", + "STOPWORDS = {\"가상\", \"약\", \"일대\", \"지역에서\", \"모습\", \"중\", \"확인됨\", \"발견\", \"제보\", \"주민\"}\n", + "_URL = re.compile(r\"https?://\\S+|www\\.\\S+\")\n", + "_SPECIAL = re.compile(r\"[^0-9A-Za-z가-힣\\-\\s]\")\n", + "\n", + "\n", + "def clean_text(t):\n", + " \"\"\"정규화(NFKC) -> URL·멘션 제거 -> 해시태그 기호 제거 -> 특수문자 제거 -> 불용어 제거 -> 공백 정리.\"\"\"\n", + " t = unicodedata.normalize(\"NFKC\", t)\n", + " t = _URL.sub(\" \", t)\n", + " t = re.sub(r\"@\\w+\", \" \", t).replace(\"#\", \" \")\n", + " t = _SPECIAL.sub(\" \", t)\n", + " return \" \".join(w for w in t.split() if w not in STOPWORDS)\n", + "\n", + "\n", + "def phash(img):\n", + " \"\"\"지각 해시(pHash): 32x32 이미지 DCT의 저주파 8x8(직류 성분 제외)을 중앙값과 비교한 64비트.\"\"\"\n", + " c = dctn(np.asarray(img, float), norm=\"ortho\")[:8, :8].ravel()[1:]\n", + " return np.append(c > np.median(c), False)\n", + "\n", + "\n", + "def hamming(a, b):\n", + " return int(np.count_nonzero(a != b))\n", + "\n", + "\n", + "def slots(text):\n", + " \"\"\"본문에서 지역 코드·수량·방향·대상을 뽑는다(사건 내용 비교용).\"\"\"\n", + " r = re.search(r\"[A-F]-\\d\", text)\n", + " n = re.search(r\"(\\d+)\\s*대|수량\\s*(\\d+)\", text)\n", + " d = next((x for x in DIRS if x in text), None)\n", + " o = next((x for x in OBJECTS if x in text), None)\n", + " return {\"r\": r.group(0) if r else None, \"n\": int(n.group(1) or n.group(2)) if n else None, \"d\": d, \"o\": o}\n", + "\n", + "\n", + "def splice_score(img):\n", + " \"\"\"합성(붙여넣기) 흔적: 8x8 블록별 고주파 잡음 크기의 최대/중앙값 비율. 잡음 수준이 다른 조각이 있으면 커진다.\"\"\"\n", + " res = img - ndimage.median_filter(img, 3)\n", + " b = np.abs(res).reshape(4, 8, 4, 8).std(axis=(1, 3))\n", + " return float(b.max() / (np.median(b) + 1e-6))\n", + "\n", + "\n", + "def ai_image_score(img):\n", + " \"\"\"생성형 이미지 흔적: 2픽셀 주기 격자(나이퀴스트 주파수) 에너지 비율.\"\"\"\n", + " f = np.abs(np.fft.fft2(img - img.mean()))\n", + " return float(f[16, 16] / (f.sum() / f.size + 1e-6))\n", + "\n", + "\n", + "def ai_text_score(text):\n", + " \"\"\"기계적 선동 문체 흔적: 반복 어절 비율 + 느낌표 밀도.\"\"\"\n", + " w = text.split()\n", + " return (1 - len(set(w)) / max(len(w), 1)) + text.count(\"!\") / max(len(w), 1)\n", + "\n", + "QUEUE_CAP = 8000 # 메모리 대기열 용량(건). 넘치면 보관 버퍼로 옮겨 유실을 막는다\n", + "pool, _ = make_posts(60, seed=7)\n", + "\n", + "\n", + "def stream(n, broken=0.0, seed=0):\n", + " rng = np.random.default_rng(seed)\n", + " out = []\n", + " for i in range(n):\n", + " p = dict(pool[i % len(pool)]); p[\"post_id\"] = f\"S{i:06d}\"\n", + " r = rng.random()\n", + " if r < broken / 3:\n", + " p[\"text\"] = None\n", + " elif r < 2 * broken / 3:\n", + " p[\"image\"] = np.zeros((5, 5))\n", + " elif r < broken:\n", + " p[\"text\"] = 12345\n", + " out.append(p)\n", + " return out\n", + "\n", + "\n", + "def process(batch, full, prof, seen):\n", + " \"\"\"마이크로 배치 처리: 본문 정제 -> 중복 해시 -> (정상 모드만) 이미지 해시 -> 선동 문체 점수. 형식 오류는 격리.\"\"\"\n", + " done = deferred = quarantined = 0\n", + " for p in batch:\n", + " try:\n", + " if not isinstance(p[\"text\"], (str, type(None))):\n", + " raise TypeError(\"본문 형식 오류\")\n", + " t0 = time.perf_counter(); txt = clean_text(p[\"text\"] or \"\"); prof[\"정제\"] += time.perf_counter() - t0\n", + " seen[hash(txt)] = seen.get(hash(txt), 0) + 1\n", + " if p[\"image\"] is not None:\n", + " if p[\"image\"].shape != (32, 32):\n", + " raise ValueError(\"이미지 형식 오류\")\n", + " if full:\n", + " t0 = time.perf_counter(); phash(p[\"image\"]); prof[\"이미지 해시\"] += time.perf_counter() - t0\n", + " else:\n", + " deferred += 1 # 이미지 분석은 나중에 처리(유실 아님)\n", + " t0 = time.perf_counter(); ai_text_score(txt); prof[\"문체 점수\"] += time.perf_counter() - t0\n", + " done += 1\n", + " except Exception:\n", + " quarantined += 1\n", + " return done, deferred, quarantined\n", + "\n", + "\n", + "# 1) 처리 용량 측정(정상 모드 / 기능 축소 모드)\n", + "prof, seen = {\"정제\": 0.0, \"이미지 해시\": 0.0, \"문체 점수\": 0.0}, {}\n", + "sample = stream(3000, seed=1)\n", + "t0 = time.perf_counter(); process(sample, True, prof, seen); cap_full = len(sample) / (time.perf_counter() - t0)\n", + "t0 = time.perf_counter(); process(sample, False, dict.fromkeys(prof, 0.0), {}); cap_deg = len(sample) / (time.perf_counter() - t0)\n", + "print(f\"처리 용량: 정상 모드 {cap_full:,.0f}건/초, 기능 축소 모드(이미지 분석 지연) {cap_deg:,.0f}건/초\")\n", + "\n", + "# 2) 상황별 스트림 주입(3초 분량): 초 단위로 도착분을 대기열에 넣고, 밀리면 기능 축소 모드로 전환\n", + "cases = [(\"평시 1,000건/초\", 1000, 0.0, 1.0), (\"500% 증가 6,000건/초\", 6000, 0.0, 1.0),\n", + " (\"6,000건/초 + 손상 게시물 3%\", 6000, 0.03, 1.0), (\"6,000건/초 + 처리 자원 1/4\", 6000, 0.0, 0.25)]\n", + "rows = []\n", + "for k, (name, rate, br, cpu) in enumerate(cases):\n", + " arrivals = stream(rate * 3, br, seed=10 + k)\n", + " q, spill = deque(), deque()\n", + " done = deferred = quar = down = 0\n", + " max_backlog, modes, lat = 0, [], []\n", + " try:\n", + " for sec in range(6): # 3초 도착 + 3초 잔여 처리\n", + " for p in arrivals[sec * rate:(sec + 1) * rate]:\n", + " (q if len(q) < QUEUE_CAP else spill).append(p)\n", + " while len(q) < QUEUE_CAP and spill:\n", + " q.append(spill.popleft())\n", + " backlog = len(q) + len(spill)\n", + " full = backlog <= cap_full * cpu # 1초 안에 못 비우면 기능 축소\n", + " budget = int((cap_full if full else cap_deg) * cpu)\n", + " batch = [q.popleft() for _ in range(min(budget, len(q)))]\n", + " d, df_, qu = process(batch, full, dict.fromkeys(prof, 0.0), {})\n", + " done += d; deferred += df_; quar += qu\n", + " modes.append(\"정상\" if full else \"축소\")\n", + " max_backlog = max(max_backlog, backlog)\n", + " lat.append(backlog / max(budget, 1))\n", + " except Exception:\n", + " down = 1\n", + " left = len(q) + len(spill)\n", + " rows.append({\"상황\": name, \"수신\": len(arrivals), \"처리\": done, \"격리\": quar, \"대기 잔여\": left, \"유실\": len(arrivals) - done - quar - left,\n", + " \"다운\": down, \"최대 대기(건)\": max_backlog, \"최대 지연(초)\": round(max(lat), 2), \"이미지 지연처리\": deferred,\n", + " \"모드\": \"/\".join(m[0] for m in modes)})\n", + "df = pd.DataFrame(rows)\n", + "print(df.drop(columns=[\"모드\"]).to_string(index=False))\n", + "print(\"초별 모드(정=정상, 축=기능 축소):\", {r[\"상황\"]: r[\"모드\"] for r in rows})\n", + "print(f\"\\n시스템 다운 {df['다운'].sum()}건, 데이터 유실률 {df['유실'].sum() / df['수신'].sum():.1%} (목표 0%), 대기 잔여분은 보관 버퍼에서 이어서 처리\")\n", + "\n", + "# 3) 메모리 안정성과 병목\n", + "tracemalloc.start(); mem = []\n", + "for _ in range(5):\n", + " process(sample[:1000], True, dict.fromkeys(prof, 0.0), {}); mem.append(tracemalloc.get_traced_memory()[0] / 1e6)\n", + "tracemalloc.stop()\n", + "print(\"메모리(동일 배치 5회, MB):\", [round(m, 2) for m in mem], f\"-> 증가 {mem[-1] - mem[0]:.2f} MB (누수 판단 기준 5MB)\")\n", + "tot = sum(prof.values())\n", + "print(\"병목 구간(누적 처리시간 비율):\", {k: f\"{v / tot:.0%}\" for k, v in sorted(prof.items(), key=lambda x: -x[1])})\n", + "print(\"개선: 이미지 해시를 별도 비동기 작업자로 분리하면 기능 축소 없이 6,000건/초를 감당할 수 있다(코어 추가 시).\")\n", + "\n", + "plt.figure(figsize=(8, 4))\n", + "plt.bar(range(len(df)), df[\"최대 대기(건)\"], color=[\"tab:red\" if \"축\" in m else \"tab:blue\" for m in df[\"모드\"]])\n", + "plt.xticks(range(len(df)), [f\"case{i + 1}\" for i in range(len(df))]); plt.ylabel(\"max backlog (posts)\")\n", + "plt.title(\"SNS stream stress test (red = degraded mode used)\"); plt.show()\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 08 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + "처리 용량: 정상 모드 8,868건/초, 기능 축소 모드(이미지 분석 지연) 69,099건/초\n", + " 상황 수신 처리 격리 대기 잔여 유실 다운 최대 대기(건) 최대 지연(초) 이미지 지연처리\n", + " 평시 1,000건/초 3000 3000 0 0 0 0 1000 0.11 0\n", + " 500% 증가 6,000건/초 18000 18000 0 0 0 0 6000 0.68 0\n", + "6,000건/초 + 손상 게시물 3% 18000 17655 345 0 0 0 6000 0.68 0\n", + "6,000건/초 + 처리 자원 1/4 18000 18000 0 0 0 0 6000 0.35 17748\n", + "초별 모드(정=정상, 축=기능 축소): {'평시 1,000건/초': '정/정/정/정/정/정', '500% 증가 6,000건/초': '정/정/정/정/정/정', '6,000건/초 + 손상 게시물 3%': '정/정/정/정/정/정', '6,000건/초 + 처리 자원 1/4': '축/축/축/정/정/정'}\n", + "\n", + "시스템 다운 0건, 데이터 유실률 0.0% (목표 0%), 대기 잔여분은 보관 버퍼에서 이어서 처리\n", + "메모리(동일 배치 5회, MB): [0.01, 0.01, 0.01, 0.01, 0.01] -> 증가 0.00 MB (누수 판단 기준 5MB)\n", + "병목 구간(누적 처리시간 비율): {'이미지 해시': '85%', '정제': '11%', '문체 점수': '4%'}\n", + "개선: 이미지 해시를 별도 비동기 작업자로 분리하면 기능 축소 없이 6,000건/초를 감당할 수 있다(코어 추가 시).\n", + "```\n", + "
\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 09. AAR 기반 출처 오분류 사례 분석 및 가짜 정보 탐지 룰셋/가중치 재보정\n", + "**소요시간**: 약 3.0시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "AAR 사후검토를 통해 출처 오분류 사례 및 신종 변조 패턴을 분석하고 탐지 룰셋을 재보정한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **AAR 사후검토로 출처·진위 오분류 사례와 신종 변조 패턴을 분석하고 탐지 룰셋과 가중치를 재보정한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **평판 좋은 계정 사칭과 유사 문자 치환·보이지 않는 문자 삽입이 섞인 모의 운용 결과를 AAR 일지로 정리하고 미탐·오탐을 원인별로 분류 수행하고 검증하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `docs/isr02_aar_report.md 및 configs/isr02_tuned_params.yaml` 형태의 결과물을 완성한다.\n", + "\n", + "### 🔍 평가 및 검증 기준 (PASS 조건)\n", + "#### 미션 판정\n", + "\n", + "**평가 구분:** 필수\n", + "\n", + "**핵심 평가영역:** 오류 원인 규명의 객관성 및 개선 조치의 논리적 타당성\n", + "\n", + "#### 미션 PASS 조건\n", + "\n", + "- 정량 기준: AAR 피드백 적용 후 종합 정확도 10%p 이상 향상, 재발 오류 0건\n", + "\n", + "\n", + "- 진행 기준: AAR 보고서 작성 및 파라미터 튜닝 전후 회귀 테스트 통과\n", + "\n", + "\n", + "- 검증 방법: 회귀 테스트(Regression Test) 스위트 전체 자동 실행 및 비교\n", + "\n", + "\n", + "#### 채점자 판정 메모\n", + "\n", + "> 단순 코드 실행 여부뿐만 아니라 출력된 수치와 근거 자료가 군 실무 요구사항에 부합하는지 면밀히 확인한다.\n", + "\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "\"\"\"AAR(사후검토) 일지 형식으로 출처·진위 오분류 사례와 신종 변조 패턴(유사 문자 치환·보이지 않는 문자 삽입)을 분석하고, 정규화 룰셋 보강과 판정 임계값·계정 평판 가중치 재보정으로 종합 정확도 향상과 재발 여부를 회귀 시험으로 확인한다.\"\"\"\n", + "import re\n", + "import unicodedata\n", + "import numpy as np\n", + "import pandas as pd\n", + "import matplotlib.pyplot as plt\n", + "from scipy import ndimage\n", + "from scipy.fft import dctn\n", + "from sklearn.ensemble import RandomForestClassifier\n", + "from sklearn.feature_extraction.text import TfidfVectorizer\n", + "\n", + "# ---------------------------------------------------------------------------\n", + "# [교육용가상자료] 합성 SNS 게시물 생성기 (실제 계정·채널·지명·게시물 없음)\n", + "# 가상 사건마다 원본 제보, 재게시(복제), 유사 재작성, 조작·기만 게시물 10종과 32x32 합성 이미지를 만든다.\n", + "# ---------------------------------------------------------------------------\n", + "REGIONS = [f\"{a}-{n}\" for a in \"ABCDEF\" for n in range(1, 6)]\n", + "OBJECTS = [\"군용 차량\", \"소형 드론\", \"보급 트럭\", \"헬기\", \"버스\", \"견인 차량\", \"트레일러\", \"민간 승용차\"]\n", + "DIRS = [\"북쪽\", \"남쪽\", \"동쪽\", \"서쪽\"]\n", + "TEMPLATES = [\"가상 {r} 지역에서 {o} 목격, {n}대 규모로 {d}으로 이동 중\",\n", + " \"{r} 일대 {o} 확인됨. 약 {n}대, {d} 방향으로 이동\",\n", + " \"[현지] {r}에서 {o} 발견 - 수량 {n}, 이동 방향 {d}\",\n", + " \"{r} 주민 제보: {o} {n}대가 {d}으로 향하는 모습\"]\n", + "MANIP = [\"num_change\", \"region_swap\", \"dir_flip\", \"old_image\", \"splice\", \"ai_image\", \"ai_text\", \"bot_burst\", \"mashup\", \"recycled\"]\n", + "ACCOUNT_TYPES = { # 유형별 계정 속성 분포(가상): 나이(일), 팔로워, 하루 게시 수, 원본 비율, 과거 정확도, 게시 간격 변동계수\n", + " \"media\": ((1000, 4000), (5e4, 5e5), (5, 30), (.6, .9), (.85, .97), (.8, 1.5)),\n", + " \"witness\": ((10, 800), (20, 2000), (.2, 3), (.7, 1.0), (.6, .95), (1.0, 2.0)),\n", + " \"aggregator\": ((200, 2000), (1e4, 2e5), (30, 200), (.05, .2), (.6, .85), (.5, 1.0)),\n", + " \"propaganda\": ((5, 200), (100, 2e4), (20, 150), (.3, .7), (.1, .4), (.05, .3)),\n", + "}\n", + "\n", + "\n", + "def make_accounts(rng, n=(8, 30, 10, 20)):\n", + " acc = {}\n", + " for (t, spec), k in zip(ACCOUNT_TYPES.items(), n):\n", + " for i in range(k):\n", + " v = [rng.uniform(*r) for r in spec]\n", + " acc[f\"ACC_{t[:3].upper()}{i:02d}\"] = {\"type\": t, \"age_days\": int(v[0]), \"followers\": int(v[1]), \"posts_per_day\": round(v[2], 1),\n", + " \"original_ratio\": round(v[3], 2), \"past_accuracy\": round(v[4], 2), \"interval_cv\": round(v[5], 2)}\n", + " return acc\n", + "\n", + "\n", + "def event_image(seed):\n", + " \"\"\"사건 현장 사진을 흉내 낸 32x32 합성 이미지(사건마다 고유한 도형 배치).\"\"\"\n", + " r = np.random.default_rng(seed)\n", + " img = np.linspace(r.uniform(60, 120), r.uniform(120, 200), 32)[:, None] * np.ones((1, 32))\n", + " for _ in range(int(r.integers(3, 7))):\n", + " y, x, h, w = r.integers(0, 26, 4)\n", + " img[y:y + 3 + h % 8, x:x + 3 + w % 8] = r.uniform(20, 240)\n", + " return img + r.normal(0, 4, (32, 32))\n", + "\n", + "\n", + "def variant(img, rng):\n", + " \"\"\"재게시 과정의 자연스러운 변형: 재압축 잡음, 밝기 변화, 가장자리 잘림 중 하나.\"\"\"\n", + " k = rng.integers(0, 3)\n", + " if k == 0:\n", + " return ndimage.gaussian_filter(img, 0.6) + rng.normal(0, 3, img.shape)\n", + " if k == 1:\n", + " return img * rng.uniform(0.85, 1.15)\n", + " return ndimage.zoom(img[2:30, 2:30], 32 / 28, order=1)\n", + "\n", + "\n", + "def splice(img, other, rng):\n", + " out = img.copy()\n", + " y, x = rng.integers(4, 18, 2)\n", + " out[y:y + 12, x:x + 12] = other[y:y + 12, x:x + 12] + rng.normal(0, 12, (12, 12)) # 잡음 수준이 다른 조각을 붙임\n", + " return out\n", + "\n", + "\n", + "def ai_image(rng):\n", + " \"\"\"생성형 모델 이미지를 흉내 낸 합성 이미지: 지나치게 매끈한 바탕 + 업샘플링 격자 무늬(주기 2픽셀).\"\"\"\n", + " base = ndimage.gaussian_filter(rng.normal(130, 60, (32, 32)), 3)\n", + " grid = (np.indices((32, 32)).sum(0) % 2) * rng.uniform(6, 12)\n", + " return base + grid\n", + "\n", + "\n", + "def write(ev, rng, tpl=None, **kw):\n", + " s = {\"r\": ev[\"r\"], \"o\": ev[\"o\"], \"n\": ev[\"n\"], \"d\": ev[\"d\"], **kw}\n", + " return (tpl or TEMPLATES[int(rng.integers(len(TEMPLATES)))]).format(**s)\n", + "\n", + "\n", + "def make_posts(n_events=40, seed=7):\n", + " rng = np.random.default_rng(seed)\n", + " acc = make_accounts(rng)\n", + " by = {t: [a for a, v in acc.items() if v[\"type\"] == t] for t in ACCOUNT_TYPES}\n", + " events = [{\"id\": f\"EV{i:03d}\", \"r\": str(rng.choice(REGIONS)), \"o\": str(rng.choice(OBJECTS)), \"n\": int(rng.integers(2, 15)),\n", + " \"d\": str(rng.choice(DIRS)), \"t0\": float(i * 1.2 + rng.uniform(0, 1)), \"img\": event_image(seed * 1000 + i)} for i in range(n_events)]\n", + " posts = []\n", + "\n", + " def add(ev, account, text, img, t, fwd, kind, origin):\n", + " pid = f\"P{len(posts):04d}\"\n", + " posts.append({\"post_id\": pid, \"platform\": str(rng.choice([\"telegram\", \"x\", \"other\"], p=[.6, .3, .1])), \"account_id\": account,\n", + " \"posted_at\": round(t, 3), \"text\": text, \"image\": img, \"forwarded_from\": fwd, \"event_id\": ev[\"id\"] if ev else None,\n", + " \"kind\": kind, \"is_disinfo\": kind in MANIP, \"origin\": origin})\n", + " return pid\n", + "\n", + " for k, ev in enumerate(events):\n", + " src = str(rng.choice(by[\"witness\"] if rng.random() < .6 else by[\"media\"]))\n", + " if rng.random() < .05:\n", + " src = str(rng.choice(by[\"propaganda\"])) # 신뢰도 낮은 계정도 사실을 올릴 수 있다\n", + " o = add(ev, src, write(ev, rng), ev[\"img\"], ev[\"t0\"], None, \"original\", None)\n", + " for _ in range(int(rng.integers(2, 5))): # 그대로 재게시(전달)\n", + " add(ev, str(rng.choice(by[\"aggregator\"])), posts[int(o[1:])][\"text\"], ev[\"img\"], ev[\"t0\"] + rng.exponential(1), o, \"copy\", o)\n", + " for _ in range(int(rng.integers(1, 3))): # 표현만 바꾼 재작성(내용 동일)\n", + " add(ev, str(rng.choice(by[\"media\"] + by[\"witness\"])), \"#가상속보 \" + write(ev, rng), variant(ev[\"img\"], rng),\n", + " ev[\"t0\"] + rng.uniform(0.5, 6), None, \"near_dup\", o)\n", + " for m in MANIP if k % 2 == 0 else MANIP[k % 4::4]: # 조작·기만 게시물\n", + " if rng.random() > 0.5:\n", + " continue\n", + " who = str(rng.choice(by[\"propaganda\"] if rng.random() < .75 else by[\"aggregator\"] + by[\"witness\"]))\n", + " t, img, text = ev[\"t0\"] + rng.uniform(1, 12), variant(ev[\"img\"], rng), None\n", + " if m == \"num_change\":\n", + " text = write(ev, rng, n=ev[\"n\"] * int(rng.integers(5, 10)))\n", + " elif m == \"region_swap\":\n", + " text = write(ev, rng, r=str(rng.choice([r for r in REGIONS if r != ev[\"r\"]])))\n", + " elif m == \"dir_flip\":\n", + " text = write(ev, rng, d={\"북쪽\": \"남쪽\", \"남쪽\": \"북쪽\", \"동쪽\": \"서쪽\", \"서쪽\": \"동쪽\"}[ev[\"d\"]])\n", + " elif m == \"old_image\" and k > 0:\n", + " text, img = write(ev, rng), events[int(rng.integers(0, k))][\"img\"]\n", + " elif m == \"splice\":\n", + " text, img = write(ev, rng), splice(ev[\"img\"], events[(k + 7) % n_events][\"img\"], rng)\n", + " elif m == \"ai_image\":\n", + " text, img = write(ev, rng), ai_image(rng)\n", + " elif m == \"ai_text\":\n", + " text = f\"속보 속보! {ev['r']} 지역 대규모 {ev['o']} 출현! 긴급 확산 바람! 긴급 확산 바람! 모두 확산 바람!\"\n", + " img = ai_image(rng) if rng.random() < .5 else None\n", + " elif m == \"bot_burst\":\n", + " text = write(ev, rng, n=ev[\"n\"] * 6) + \" 지금 바로 공유\"\n", + " for b in rng.choice(by[\"propaganda\"], 4, replace=False):\n", + " add(ev, str(b), text, img, t + rng.uniform(0, 0.15), None, m, o)\n", + " continue\n", + " elif m == \"mashup\":\n", + " other = events[(k + 3) % n_events]\n", + " text = f\"{ev['r']} 일대 {ev['o']} 확인, {other['n']}대가 {other['d']}으로 이동\" # 다른 사건 내용과 짜깁기\n", + " elif m == \"recycled\" and k > 2:\n", + " old = events[int(rng.integers(0, k - 1))]\n", + " text, img, t = write(old, rng), old[\"img\"], ev[\"t0\"] + rng.uniform(0, 3)\n", + " if text is None:\n", + " continue\n", + " add(ev, who, text, img, t, None, m, o)\n", + " return posts, acc\n", + "# ---------------------------------------------------------------------------\n", + "\n", + "# ---- 텍스트 정제·이미지 지각 해시·조작 흔적 공통 함수 ----\n", + "STOPWORDS = {\"가상\", \"약\", \"일대\", \"지역에서\", \"모습\", \"중\", \"확인됨\", \"발견\", \"제보\", \"주민\"}\n", + "_URL = re.compile(r\"https?://\\S+|www\\.\\S+\")\n", + "_SPECIAL = re.compile(r\"[^0-9A-Za-z가-힣\\-\\s]\")\n", + "\n", + "\n", + "def clean_text(t):\n", + " \"\"\"정규화(NFKC) -> URL·멘션 제거 -> 해시태그 기호 제거 -> 특수문자 제거 -> 불용어 제거 -> 공백 정리.\"\"\"\n", + " t = unicodedata.normalize(\"NFKC\", t)\n", + " t = _URL.sub(\" \", t)\n", + " t = re.sub(r\"@\\w+\", \" \", t).replace(\"#\", \" \")\n", + " t = _SPECIAL.sub(\" \", t)\n", + " return \" \".join(w for w in t.split() if w not in STOPWORDS)\n", + "\n", + "\n", + "def phash(img):\n", + " \"\"\"지각 해시(pHash): 32x32 이미지 DCT의 저주파 8x8(직류 성분 제외)을 중앙값과 비교한 64비트.\"\"\"\n", + " c = dctn(np.asarray(img, float), norm=\"ortho\")[:8, :8].ravel()[1:]\n", + " return np.append(c > np.median(c), False)\n", + "\n", + "\n", + "def hamming(a, b):\n", + " return int(np.count_nonzero(a != b))\n", + "\n", + "\n", + "def slots(text):\n", + " \"\"\"본문에서 지역 코드·수량·방향·대상을 뽑는다(사건 내용 비교용).\"\"\"\n", + " r = re.search(r\"[A-F]-\\d\", text)\n", + " n = re.search(r\"(\\d+)\\s*대|수량\\s*(\\d+)\", text)\n", + " d = next((x for x in DIRS if x in text), None)\n", + " o = next((x for x in OBJECTS if x in text), None)\n", + " return {\"r\": r.group(0) if r else None, \"n\": int(n.group(1) or n.group(2)) if n else None, \"d\": d, \"o\": o}\n", + "\n", + "\n", + "def splice_score(img):\n", + " \"\"\"합성(붙여넣기) 흔적: 8x8 블록별 고주파 잡음 크기의 최대/중앙값 비율. 잡음 수준이 다른 조각이 있으면 커진다.\"\"\"\n", + " res = img - ndimage.median_filter(img, 3)\n", + " b = np.abs(res).reshape(4, 8, 4, 8).std(axis=(1, 3))\n", + " return float(b.max() / (np.median(b) + 1e-6))\n", + "\n", + "\n", + "def ai_image_score(img):\n", + " \"\"\"생성형 이미지 흔적: 2픽셀 주기 격자(나이퀴스트 주파수) 에너지 비율.\"\"\"\n", + " f = np.abs(np.fft.fft2(img - img.mean()))\n", + " return float(f[16, 16] / (f.sum() / f.size + 1e-6))\n", + "\n", + "\n", + "def ai_text_score(text):\n", + " \"\"\"기계적 선동 문체 흔적: 반복 어절 비율 + 느낌표 밀도.\"\"\"\n", + " w = text.split()\n", + " return (1 - len(set(w)) / max(len(w), 1)) + text.count(\"!\") / max(len(w), 1)\n", + "\n", + "# ---- 게시물 군집(같은 이야기 묶기)·조작 탐지 특징 공통 함수 ----\n", + "FEAT = [\"num_dev\", \"region_mis\", \"dir_mis\", \"obj_mis\", \"img_ref_dist\", \"img_prior_h\", \"time_gap_h\", \"splice\", \"ai_img\",\n", + " \"ai_text\", \"burst_n\", \"has_img\", \"is_fwd\", \"acc_age\", \"acc_followers\", \"acc_orig\", \"acc_past\", \"acc_cv\"]\n", + "\n", + "\n", + "def cluster_posts(posts, hours=24, ham_thr=8):\n", + " \"\"\"같은 이야기 묶기: 본문에서 뽑은 (지역, 대상)이 같고 24시간 안이면 연결하고,\n", + " 지각 해시가 가까운 이미지(같은 대상)도 연결한다. 글 표현이 달라도 내용 단위로 묶인다.\"\"\"\n", + " texts = [clean_text(p[\"text\"]) for p in posts]\n", + " X = TfidfVectorizer(analyzer=\"char_wb\", ngram_range=(2, 3)).fit_transform(texts)\n", + " S = (X @ X.T).toarray()\n", + " H = [phash(p[\"image\"]) if p[\"image\"] is not None else None for p in posts]\n", + " sl = [slots(p[\"text\"]) for p in posts]\n", + " t = [p[\"posted_at\"] for p in posts]\n", + " parent = list(range(len(posts)))\n", + "\n", + " def find(i):\n", + " while parent[i] != i:\n", + " parent[i] = parent[parent[i]]; i = parent[i]\n", + " return i\n", + " for i in range(len(posts)):\n", + " for j in range(i + 1, len(posts)):\n", + " if abs(t[i] - t[j]) >= hours or sl[i][\"o\"] != sl[j][\"o\"]:\n", + " continue\n", + " same_story = sl[i][\"r\"] is not None and sl[i][\"r\"] == sl[j][\"r\"]\n", + " same_img = H[i] is not None and H[j] is not None and hamming(H[i], H[j]) <= ham_thr\n", + " if same_story or same_img:\n", + " parent[find(i)] = find(j)\n", + " return np.array([find(i) for i in range(len(posts))]), S, H\n", + "\n", + "\n", + "def post_features(posts, acc):\n", + " cid, S, H = cluster_posts(posts)\n", + " t = np.array([p[\"posted_at\"] for p in posts])\n", + " sl = [slots(p[\"text\"]) for p in posts]\n", + " first_seen = {}\n", + " for i in np.argsort(t): # 이미지가 처음 등장한 시각과 군집\n", + " if H[i] is None:\n", + " continue\n", + " key = next((k for k in first_seen if hamming(np.array(k), H[i]) <= 6), None)\n", + " if key is None:\n", + " first_seen[tuple(H[i])] = (t[i], cid[i])\n", + " elif first_seen[key][1] != cid[i]:\n", + " pass\n", + " ref = {c: int(np.argmin(np.where(cid == c, t, np.inf))) for c in set(cid)} # 군집에서 가장 이른 게시물 = 기준(추정 원본)\n", + " F = []\n", + " for i, p in enumerate(posts):\n", + " r = ref[cid[i]]\n", + " a, b = sl[i], sl[r]\n", + " img_prior = 0.0\n", + " if H[i] is not None:\n", + " for k, (ts, c) in first_seen.items():\n", + " if hamming(np.array(k), H[i]) <= 6 and c != cid[i]:\n", + " img_prior = max(img_prior, min(t[i] - ts, 48.0))\n", + " same_txt = [j for j in np.where(S[i] > 0.97)[0] if abs(t[j] - t[i]) < 0.25 and posts[j][\"account_id\"] != p[\"account_id\"]]\n", + " A = acc[p[\"account_id\"]]\n", + " F.append([abs(np.log((a[\"n\"] or 1) / (b[\"n\"] or 1))), float(a[\"r\"] != b[\"r\"]), float(a[\"d\"] != b[\"d\"]), float(a[\"o\"] != b[\"o\"]),\n", + " hamming(H[i], H[r]) if H[i] is not None and H[r] is not None else 0, img_prior, t[i] - t[r],\n", + " splice_score(p[\"image\"]) if p[\"image\"] is not None else 0, ai_image_score(p[\"image\"]) if p[\"image\"] is not None else 0,\n", + " ai_text_score(p[\"text\"]), len(set(posts[j][\"account_id\"] for j in same_txt)), float(p[\"image\"] is not None),\n", + " float(p[\"forwarded_from\"] is not None), np.log1p(A[\"age_days\"]), np.log1p(A[\"followers\"]), A[\"original_ratio\"],\n", + " A[\"past_accuracy\"], A[\"interval_cv\"]])\n", + " return np.array(F), cid\n", + "\n", + "HOMO = {\"A\": \"А\", \"B\": \"В\", \"C\": \"С\", \"E\": \"Е\"} # 라틴 대문자와 모양이 같은 키릴 문자\n", + "ZW = \"​\" # 보이지 않는 문자(폭 0 공백)\n", + "RULESET = {\"normalize\": False}\n", + "\n", + "\n", + "def normalize(t):\n", + " \"\"\"보강 룰셋: NFKC + 유사 문자(키릴->라틴) 복원 + 보이지 않는 문자 제거.\"\"\"\n", + " t = unicodedata.normalize(\"NFKC\", t).replace(ZW, \"\")\n", + " for k, v in HOMO.items():\n", + " t = t.replace(v, k)\n", + " return t\n", + "\n", + "\n", + "_slots_raw, _clean_raw = slots, clean_text\n", + "slots = lambda t: _slots_raw(normalize(t) if RULESET[\"normalize\"] else t)\n", + "clean_text = lambda t: _clean_raw(normalize(t) if RULESET[\"normalize\"] else t)\n", + "\n", + "\n", + "def evade(posts, acc, rng):\n", + " \"\"\"신종 변조 패턴: (1) 평판 좋은 계정 탈취·사칭으로 조작 글 게시 (2) 지역 코드 글자를 유사 문자로 바꾸고 보이지 않는 문자를 끼워 비교를 피한다.\"\"\"\n", + " good = [a for a, v in acc.items() if v[\"type\"] in (\"media\", \"witness\")]\n", + " for p in posts:\n", + " if p[\"is_disinfo\"] and rng.random() < 0.6:\n", + " p[\"account_id\"] = str(rng.choice(good))\n", + " if p[\"is_disinfo\"] and rng.random() < 0.8:\n", + " t = p[\"text\"]\n", + " for k, v in HOMO.items():\n", + " t = re.sub(rf\"{k}-\", v + ZW + \"-\", t)\n", + " p[\"text\"] = t + ZW * int(rng.integers(1, 4))\n", + " return posts\n", + "\n", + "\n", + "CONTENT = list(range(13))\n", + "\n", + "\n", + "def run(posts, acc, cfg):\n", + " F, _ = post_features(posts, acc)\n", + " m = MODELS[cfg[\"account\"]]\n", + " return m.predict_proba(F if cfg[\"account\"] else F[:, CONTENT])[:, 1] >= cfg[\"thr\"]\n", + "\n", + "\n", + "tr_posts, tr_acc = make_posts(60, seed=7)\n", + "Ftr, _ = post_features(tr_posts, tr_acc)\n", + "ytr = [p[\"is_disinfo\"] for p in tr_posts]\n", + "MODELS = {True: RandomForestClassifier(n_estimators=200, min_samples_leaf=3, random_state=0).fit(Ftr, ytr), # 계정 평판 포함\n", + " False: RandomForestClassifier(n_estimators=200, min_samples_leaf=3, random_state=0).fit(Ftr[:, CONTENT], ytr)} # 내용 특징만\n", + "rng = np.random.default_rng(5)\n", + "ex_posts, ex_acc = make_posts(60, seed=21); evade(ex_posts, ex_acc, rng) # 모의 운용(AAR 대상)\n", + "ho_posts, ho_acc = make_posts(60, seed=22); evade(ho_posts, ho_acc, rng) # 회귀 시험용\n", + "y, kind = np.array([p[\"is_disinfo\"] for p in ex_posts]), np.array([p[\"kind\"] for p in ex_posts])\n", + "yh = np.array([p[\"is_disinfo\"] for p in ho_posts])\n", + "\n", + "# 1) 초기 설정: 기존 정규화(NFKC만), 계정 평판 포함 모델, 판정 임계값 0.5\n", + "init = {\"account\": True, \"thr\": 0.5}\n", + "p0 = run(ex_posts, ex_acc, init)\n", + "acc0 = (p0 == y).mean()\n", + "evaded = np.array([\"​\" in p[\"text\"] for p in ex_posts])\n", + "print(f\"[1. 무엇이 일어났나] 초기 종합 정확도 {acc0:.3f}, 조작 재현율 {(p0 & y).sum() / y.sum():.3f}, 사실 오경보 {(p0 & ~y).sum()}건\")\n", + "log = pd.DataFrame([{\"구분\": \"미탐\" if y[i] else \"오탐\", \"원인\": (\"유사 문자 치환 회피\" if evaded[i] else kind[i]) if y[i] else f\"{ex_acc[ex_posts[i]['account_id']]['type']} 계정 {kind[i]}\", \"게시물\": ex_posts[i][\"post_id\"]}\n", + " for i in np.where(p0 != y)[0]])\n", + "print(log.groupby([\"구분\", \"원인\"]).size().rename(\"건수\").sort_values(ascending=False).head(6).to_string())\n", + "print(f\"\\n[2. 왜 일어났나] 평판 좋은 계정을 사칭한 조작 글은 계정 평판에 크게 기댄 모델을 통과하고, 유사 문자가 들어간 게시물 {evaded.sum()}건은 지역 코드 추출·동시 게시 비교가 실패한다.\")\n", + "\n", + "# 2) 재보정: 룰셋(정규화 보강) x 계정 평판 가중치(포함/제외) x 판정 임계값 격자 탐색\n", + "grid = []\n", + "for norm in (False, True):\n", + " RULESET[\"normalize\"] = norm\n", + " F, _ = post_features(ex_posts, ex_acc)\n", + " for use_acc in (True, False):\n", + " pr = MODELS[use_acc].predict_proba(F if use_acc else F[:, CONTENT])[:, 1]\n", + " for thr in (0.3, 0.4, 0.5, 0.6):\n", + " p = pr >= thr\n", + " grid.append({\"정규화 보강\": norm, \"계정 평판\": \"포함\" if use_acc else \"제외\", \"임계값\": thr, \"종합정확도\": round(float((p == y).mean()), 3),\n", + " \"조작 재현율\": round(float((p & y).sum() / y.sum()), 3), \"사실 오경보\": int((p & ~y).sum())})\n", + "g = pd.DataFrame(grid).sort_values([\"종합정확도\", \"사실 오경보\"], ascending=[False, True])\n", + "print(\"\\n[3. 무엇을 바꾸나] 격자 탐색 상위 5개\")\n", + "print(g.head().to_string(index=False))\n", + "best = g.iloc[0]\n", + "cfg = {\"account\": best[\"계정 평판\"] == \"포함\", \"thr\": float(best[\"임계값\"])}\n", + "RULESET[\"normalize\"] = bool(best[\"정규화 보강\"])\n", + "p1 = run(ex_posts, ex_acc, cfg)\n", + "acc1 = (p1 == y).mean()\n", + "print(f\"\\n재보정값: 정규화 보강 {best['정규화 보강']}, 계정 평판 {best['계정 평판']}, 판정 임계값 {best['임계값']}\")\n", + "print(f\"종합 정확도 {acc0:.3f} -> {acc1:.3f} ({(acc1 - acc0) * 100:+.1f}%p, 목표 +10%p: {'달성' if acc1 - acc0 >= 0.1 else '미달'})\")\n", + "RULESET[\"normalize\"] = False; h0 = (run(ho_posts, ho_acc, init) == yh).mean()\n", + "RULESET[\"normalize\"] = bool(best[\"정규화 보강\"]); h1 = (run(ho_posts, ho_acc, cfg) == yh).mean()\n", + "print(f\"회귀 시험(별도 게시물 {len(ho_posts)}건): {h0:.3f} -> {h1:.3f}\")\n", + "again = set(np.where(p0 != y)[0]) & set(np.where(p1 != y)[0])\n", + "print(f\"재보정 후 남은 오류 {(p1 != y).sum()}건, 그중 재발 {len(again)}건:\", {str(k): int(v) for k, v in pd.Series([kind[i] for i in again]).value_counts().head(4).items()})\n", + "print(\"[4. 남은 과제] 재발 건은 원본과 거의 같은 미세 변조로, 새 사례를 학습 자료에 추가하는 지속 학습 주기(주 1회)와 정보관 원천 역추적(미션 7)으로 보완한다.\")\n", + "\n", + "tuned = (f\"ruleset:\\n nfkc: true\\n homoglyph_map: {bool(best['정규화 보강'])}\\n strip_zero_width: {bool(best['정규화 보강'])}\\n\"\n", + " f\"detector:\\n use_account_reputation: {cfg['account']}\\n threshold: {best['임계값']}\\n rf_trees: 200\")\n", + "print(\"\\n[재보정 파라미터(isr02_tuned_params.yaml 내용)]\\n\" + tuned)\n", + "\n", + "plt.figure(figsize=(6, 4))\n", + "for norm in (False, True):\n", + " for ua in (\"포함\", \"제외\"):\n", + " s = pd.DataFrame(grid)[lambda d: (d[\"정규화 보강\"] == norm) & (d[\"계정 평판\"] == ua)]\n", + " plt.plot(s[\"임계값\"], s[\"종합정확도\"], \"o-\", label=f\"normalize={norm}, account={'on' if ua == '포함' else 'off'}\")\n", + "plt.xlabel(\"decision threshold\"); plt.ylabel(\"accuracy\"); plt.legend(); plt.title(\"AAR re-tuning grid (synthetic)\")\n", + "plt.show()\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 09 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + "[1. 무엇이 일어났나] 초기 종합 정확도 0.809, 조작 재현율 0.576, 사실 오경보 9건\n", + "구분 원인 \n", + "미탐 유사 문자 치환 회피 91\n", + " splice 3\n", + "오탐 witness 계정 original 3\n", + " witness 계정 near_dup 3\n", + " propaganda 계정 original 3\n", + "미탐 region_swap 2\n", + "\n", + "[2. 왜 일어났나] 평판 좋은 계정을 사칭한 조작 글은 계정 평판에 크게 기댄 모델을 통과하고, 유사 문자가 들어간 게시물 192건은 지역 코드 추출·동시 게시 비교가 실패한다.\n", + "\n", + "[3. 무엇을 바꾸나] 격자 탐색 상위 5개\n", + " 정규화 보강 계정 평판 임계값 종합정확도 조작 재현율 사실 오경보\n", + " True 포함 0.3 0.911 0.864 19\n", + " False 제외 0.3 0.909 0.936 37\n", + " True 제외 0.3 0.909 0.975 46\n", + " False 포함 0.3 0.905 0.818 11\n", + " True 제외 0.6 0.904 0.860 22\n", + "\n", + "재보정값: 정규화 보강 True, 계정 평판 포함, 판정 임계값 0.3\n", + "종합 정확도 0.809 -> 0.911 (+10.2%p, 목표 +10%p: 달성)\n", + "회귀 시험(별도 게시물 566건): 0.843 -> 0.901\n", + "재보정 후 남은 오류 51건, 그중 재발 40건: {'region_swap': 9, 'dir_flip': 7, 'recycled': 7, 'original': 6}\n", + "[4. 남은 과제] 재발 건은 원본과 거의 같은 미세 변조로, 새 사례를 학습 자료에 추가하는 지속 학습 주기(주 1회)와 정보관 원천 역추적(미션 7)으로 보완한다.\n", + "\n", + "[재보정 파라미터(isr02_tuned_params.yaml 내용)]\n", + "ruleset:\n", + " nfkc: true\n", + " homoglyph_map: True\n", + " strip_zero_width: True\n", + "detector:\n", + " use_account_reputation: True\n", + " threshold: 0.3\n", + " rf_trees: 200\n", + "```\n", + "
\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 10. SNS 전쟁 게시물 신뢰도 자동 분류 시스템 통합 및 정보전 대응 브리핑 패키지 완성\n", + "**소요시간**: 약 3.5시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "SNS 전쟁 게시물 신뢰도 자동 분류 시스템을 최종 통합하고 정보전 대응 브리핑 패키지를 완성한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **게시물 검증부터 정제, 이야기 군집, 조작 탐지, 신뢰도 등급, 정보관 검토 대기열, 모니터링 화면까지 시스템을 통합하고 정보전 대응 브리핑 패키지를 완성한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **재보정 설정으로 전체 과정을 한 번에 실행하는 통합 코드를 완성하고 손상 게시물이 자동 차단되는지 확인하고 유효성을 점검하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `src/main_isr02.py, README.md, docs/isr02_final_report.pdf(md)` 형태의 결과물을 완성한다.\n", + "\n", + "### 🔍 평가 및 검증 기준 (PASS 조건)\n", + "#### 미션 판정\n", + "\n", + "**평가 구분:** 필수\n", + "\n", + "**핵심 평가영역:** 군 실무 즉시 도입 가능성, 운용 신뢰도 및 전략적 기대효과\n", + "\n", + "#### 미션 PASS 조건\n", + "\n", + "- 정량 기준: End-to-End 통합 파이프라인 실행 성공률 100%, 테스트 커버리지 85% 이상\n", + "\n", + "\n", + "- 진행 기준: 원클릭 실행 스크립트 동작 확인 및 최종 발표 산출물 작성 완료\n", + "\n", + "\n", + "- 검증 방법: 최종 검증 위원회(평가관) 모의 시연 및 전체 기능 통합 검증\n", + "\n", + "\n", + "#### 채점자 판정 메모\n", + "\n", + "> 단순 코드 실행 여부뿐만 아니라 출력된 수치와 근거 자료가 군 실무 요구사항에 부합하는지 면밀히 확인한다.\n", + "\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "\"\"\"게시물 검증·정제(보강 정규화)·이야기 군집·조작 탐지·종합 신뢰도 등급(A~E)·정보관 긴급 검토 대기열·모니터링 화면까지 SNS 전쟁 게시물 신뢰도 자동 분류 시스템 전체를 한 파일로 통합 실행하고, 자체 점검(기능 커버리지 포함)과 정보전 대응 브리핑 요약을 출력한다.\"\"\"\n", + "import hashlib\n", + "import json\n", + "import re\n", + "import time\n", + "import unicodedata\n", + "import numpy as np\n", + "import pandas as pd\n", + "import matplotlib.pyplot as plt\n", + "from scipy import ndimage\n", + "from scipy.fft import dctn\n", + "from sklearn.ensemble import RandomForestClassifier\n", + "from sklearn.feature_extraction.text import TfidfVectorizer\n", + "from sklearn.linear_model import LogisticRegression\n", + "from sklearn.pipeline import make_pipeline\n", + "from sklearn.preprocessing import StandardScaler\n", + "\n", + "# ---------------------------------------------------------------------------\n", + "# [교육용가상자료] 합성 SNS 게시물 생성기 (실제 계정·채널·지명·게시물 없음)\n", + "# 가상 사건마다 원본 제보, 재게시(복제), 유사 재작성, 조작·기만 게시물 10종과 32x32 합성 이미지를 만든다.\n", + "# ---------------------------------------------------------------------------\n", + "REGIONS = [f\"{a}-{n}\" for a in \"ABCDEF\" for n in range(1, 6)]\n", + "OBJECTS = [\"군용 차량\", \"소형 드론\", \"보급 트럭\", \"헬기\", \"버스\", \"견인 차량\", \"트레일러\", \"민간 승용차\"]\n", + "DIRS = [\"북쪽\", \"남쪽\", \"동쪽\", \"서쪽\"]\n", + "TEMPLATES = [\"가상 {r} 지역에서 {o} 목격, {n}대 규모로 {d}으로 이동 중\",\n", + " \"{r} 일대 {o} 확인됨. 약 {n}대, {d} 방향으로 이동\",\n", + " \"[현지] {r}에서 {o} 발견 - 수량 {n}, 이동 방향 {d}\",\n", + " \"{r} 주민 제보: {o} {n}대가 {d}으로 향하는 모습\"]\n", + "MANIP = [\"num_change\", \"region_swap\", \"dir_flip\", \"old_image\", \"splice\", \"ai_image\", \"ai_text\", \"bot_burst\", \"mashup\", \"recycled\"]\n", + "ACCOUNT_TYPES = { # 유형별 계정 속성 분포(가상): 나이(일), 팔로워, 하루 게시 수, 원본 비율, 과거 정확도, 게시 간격 변동계수\n", + " \"media\": ((1000, 4000), (5e4, 5e5), (5, 30), (.6, .9), (.85, .97), (.8, 1.5)),\n", + " \"witness\": ((10, 800), (20, 2000), (.2, 3), (.7, 1.0), (.6, .95), (1.0, 2.0)),\n", + " \"aggregator\": ((200, 2000), (1e4, 2e5), (30, 200), (.05, .2), (.6, .85), (.5, 1.0)),\n", + " \"propaganda\": ((5, 200), (100, 2e4), (20, 150), (.3, .7), (.1, .4), (.05, .3)),\n", + "}\n", + "\n", + "\n", + "def make_accounts(rng, n=(8, 30, 10, 20)):\n", + " acc = {}\n", + " for (t, spec), k in zip(ACCOUNT_TYPES.items(), n):\n", + " for i in range(k):\n", + " v = [rng.uniform(*r) for r in spec]\n", + " acc[f\"ACC_{t[:3].upper()}{i:02d}\"] = {\"type\": t, \"age_days\": int(v[0]), \"followers\": int(v[1]), \"posts_per_day\": round(v[2], 1),\n", + " \"original_ratio\": round(v[3], 2), \"past_accuracy\": round(v[4], 2), \"interval_cv\": round(v[5], 2)}\n", + " return acc\n", + "\n", + "\n", + "def event_image(seed):\n", + " \"\"\"사건 현장 사진을 흉내 낸 32x32 합성 이미지(사건마다 고유한 도형 배치).\"\"\"\n", + " r = np.random.default_rng(seed)\n", + " img = np.linspace(r.uniform(60, 120), r.uniform(120, 200), 32)[:, None] * np.ones((1, 32))\n", + " for _ in range(int(r.integers(3, 7))):\n", + " y, x, h, w = r.integers(0, 26, 4)\n", + " img[y:y + 3 + h % 8, x:x + 3 + w % 8] = r.uniform(20, 240)\n", + " return img + r.normal(0, 4, (32, 32))\n", + "\n", + "\n", + "def variant(img, rng):\n", + " \"\"\"재게시 과정의 자연스러운 변형: 재압축 잡음, 밝기 변화, 가장자리 잘림 중 하나.\"\"\"\n", + " k = rng.integers(0, 3)\n", + " if k == 0:\n", + " return ndimage.gaussian_filter(img, 0.6) + rng.normal(0, 3, img.shape)\n", + " if k == 1:\n", + " return img * rng.uniform(0.85, 1.15)\n", + " return ndimage.zoom(img[2:30, 2:30], 32 / 28, order=1)\n", + "\n", + "\n", + "def splice(img, other, rng):\n", + " out = img.copy()\n", + " y, x = rng.integers(4, 18, 2)\n", + " out[y:y + 12, x:x + 12] = other[y:y + 12, x:x + 12] + rng.normal(0, 12, (12, 12)) # 잡음 수준이 다른 조각을 붙임\n", + " return out\n", + "\n", + "\n", + "def ai_image(rng):\n", + " \"\"\"생성형 모델 이미지를 흉내 낸 합성 이미지: 지나치게 매끈한 바탕 + 업샘플링 격자 무늬(주기 2픽셀).\"\"\"\n", + " base = ndimage.gaussian_filter(rng.normal(130, 60, (32, 32)), 3)\n", + " grid = (np.indices((32, 32)).sum(0) % 2) * rng.uniform(6, 12)\n", + " return base + grid\n", + "\n", + "\n", + "def write(ev, rng, tpl=None, **kw):\n", + " s = {\"r\": ev[\"r\"], \"o\": ev[\"o\"], \"n\": ev[\"n\"], \"d\": ev[\"d\"], **kw}\n", + " return (tpl or TEMPLATES[int(rng.integers(len(TEMPLATES)))]).format(**s)\n", + "\n", + "\n", + "def make_posts(n_events=40, seed=7):\n", + " rng = np.random.default_rng(seed)\n", + " acc = make_accounts(rng)\n", + " by = {t: [a for a, v in acc.items() if v[\"type\"] == t] for t in ACCOUNT_TYPES}\n", + " events = [{\"id\": f\"EV{i:03d}\", \"r\": str(rng.choice(REGIONS)), \"o\": str(rng.choice(OBJECTS)), \"n\": int(rng.integers(2, 15)),\n", + " \"d\": str(rng.choice(DIRS)), \"t0\": float(i * 1.2 + rng.uniform(0, 1)), \"img\": event_image(seed * 1000 + i)} for i in range(n_events)]\n", + " posts = []\n", + "\n", + " def add(ev, account, text, img, t, fwd, kind, origin):\n", + " pid = f\"P{len(posts):04d}\"\n", + " posts.append({\"post_id\": pid, \"platform\": str(rng.choice([\"telegram\", \"x\", \"other\"], p=[.6, .3, .1])), \"account_id\": account,\n", + " \"posted_at\": round(t, 3), \"text\": text, \"image\": img, \"forwarded_from\": fwd, \"event_id\": ev[\"id\"] if ev else None,\n", + " \"kind\": kind, \"is_disinfo\": kind in MANIP, \"origin\": origin})\n", + " return pid\n", + "\n", + " for k, ev in enumerate(events):\n", + " src = str(rng.choice(by[\"witness\"] if rng.random() < .6 else by[\"media\"]))\n", + " if rng.random() < .05:\n", + " src = str(rng.choice(by[\"propaganda\"])) # 신뢰도 낮은 계정도 사실을 올릴 수 있다\n", + " o = add(ev, src, write(ev, rng), ev[\"img\"], ev[\"t0\"], None, \"original\", None)\n", + " for _ in range(int(rng.integers(2, 5))): # 그대로 재게시(전달)\n", + " add(ev, str(rng.choice(by[\"aggregator\"])), posts[int(o[1:])][\"text\"], ev[\"img\"], ev[\"t0\"] + rng.exponential(1), o, \"copy\", o)\n", + " for _ in range(int(rng.integers(1, 3))): # 표현만 바꾼 재작성(내용 동일)\n", + " add(ev, str(rng.choice(by[\"media\"] + by[\"witness\"])), \"#가상속보 \" + write(ev, rng), variant(ev[\"img\"], rng),\n", + " ev[\"t0\"] + rng.uniform(0.5, 6), None, \"near_dup\", o)\n", + " for m in MANIP if k % 2 == 0 else MANIP[k % 4::4]: # 조작·기만 게시물\n", + " if rng.random() > 0.5:\n", + " continue\n", + " who = str(rng.choice(by[\"propaganda\"] if rng.random() < .75 else by[\"aggregator\"] + by[\"witness\"]))\n", + " t, img, text = ev[\"t0\"] + rng.uniform(1, 12), variant(ev[\"img\"], rng), None\n", + " if m == \"num_change\":\n", + " text = write(ev, rng, n=ev[\"n\"] * int(rng.integers(5, 10)))\n", + " elif m == \"region_swap\":\n", + " text = write(ev, rng, r=str(rng.choice([r for r in REGIONS if r != ev[\"r\"]])))\n", + " elif m == \"dir_flip\":\n", + " text = write(ev, rng, d={\"북쪽\": \"남쪽\", \"남쪽\": \"북쪽\", \"동쪽\": \"서쪽\", \"서쪽\": \"동쪽\"}[ev[\"d\"]])\n", + " elif m == \"old_image\" and k > 0:\n", + " text, img = write(ev, rng), events[int(rng.integers(0, k))][\"img\"]\n", + " elif m == \"splice\":\n", + " text, img = write(ev, rng), splice(ev[\"img\"], events[(k + 7) % n_events][\"img\"], rng)\n", + " elif m == \"ai_image\":\n", + " text, img = write(ev, rng), ai_image(rng)\n", + " elif m == \"ai_text\":\n", + " text = f\"속보 속보! {ev['r']} 지역 대규모 {ev['o']} 출현! 긴급 확산 바람! 긴급 확산 바람! 모두 확산 바람!\"\n", + " img = ai_image(rng) if rng.random() < .5 else None\n", + " elif m == \"bot_burst\":\n", + " text = write(ev, rng, n=ev[\"n\"] * 6) + \" 지금 바로 공유\"\n", + " for b in rng.choice(by[\"propaganda\"], 4, replace=False):\n", + " add(ev, str(b), text, img, t + rng.uniform(0, 0.15), None, m, o)\n", + " continue\n", + " elif m == \"mashup\":\n", + " other = events[(k + 3) % n_events]\n", + " text = f\"{ev['r']} 일대 {ev['o']} 확인, {other['n']}대가 {other['d']}으로 이동\" # 다른 사건 내용과 짜깁기\n", + " elif m == \"recycled\" and k > 2:\n", + " old = events[int(rng.integers(0, k - 1))]\n", + " text, img, t = write(old, rng), old[\"img\"], ev[\"t0\"] + rng.uniform(0, 3)\n", + " if text is None:\n", + " continue\n", + " add(ev, who, text, img, t, None, m, o)\n", + " return posts, acc\n", + "# ---------------------------------------------------------------------------\n", + "\n", + "# ---- 텍스트 정제·이미지 지각 해시·조작 흔적 공통 함수 ----\n", + "STOPWORDS = {\"가상\", \"약\", \"일대\", \"지역에서\", \"모습\", \"중\", \"확인됨\", \"발견\", \"제보\", \"주민\"}\n", + "_URL = re.compile(r\"https?://\\S+|www\\.\\S+\")\n", + "_SPECIAL = re.compile(r\"[^0-9A-Za-z가-힣\\-\\s]\")\n", + "\n", + "\n", + "def clean_text(t):\n", + " \"\"\"정규화(NFKC) -> URL·멘션 제거 -> 해시태그 기호 제거 -> 특수문자 제거 -> 불용어 제거 -> 공백 정리.\"\"\"\n", + " t = unicodedata.normalize(\"NFKC\", t)\n", + " t = _URL.sub(\" \", t)\n", + " t = re.sub(r\"@\\w+\", \" \", t).replace(\"#\", \" \")\n", + " t = _SPECIAL.sub(\" \", t)\n", + " return \" \".join(w for w in t.split() if w not in STOPWORDS)\n", + "\n", + "\n", + "def phash(img):\n", + " \"\"\"지각 해시(pHash): 32x32 이미지 DCT의 저주파 8x8(직류 성분 제외)을 중앙값과 비교한 64비트.\"\"\"\n", + " c = dctn(np.asarray(img, float), norm=\"ortho\")[:8, :8].ravel()[1:]\n", + " return np.append(c > np.median(c), False)\n", + "\n", + "\n", + "def hamming(a, b):\n", + " return int(np.count_nonzero(a != b))\n", + "\n", + "\n", + "def slots(text):\n", + " \"\"\"본문에서 지역 코드·수량·방향·대상을 뽑는다(사건 내용 비교용).\"\"\"\n", + " r = re.search(r\"[A-F]-\\d\", text)\n", + " n = re.search(r\"(\\d+)\\s*대|수량\\s*(\\d+)\", text)\n", + " d = next((x for x in DIRS if x in text), None)\n", + " o = next((x for x in OBJECTS if x in text), None)\n", + " return {\"r\": r.group(0) if r else None, \"n\": int(n.group(1) or n.group(2)) if n else None, \"d\": d, \"o\": o}\n", + "\n", + "\n", + "def splice_score(img):\n", + " \"\"\"합성(붙여넣기) 흔적: 8x8 블록별 고주파 잡음 크기의 최대/중앙값 비율. 잡음 수준이 다른 조각이 있으면 커진다.\"\"\"\n", + " res = img - ndimage.median_filter(img, 3)\n", + " b = np.abs(res).reshape(4, 8, 4, 8).std(axis=(1, 3))\n", + " return float(b.max() / (np.median(b) + 1e-6))\n", + "\n", + "\n", + "def ai_image_score(img):\n", + " \"\"\"생성형 이미지 흔적: 2픽셀 주기 격자(나이퀴스트 주파수) 에너지 비율.\"\"\"\n", + " f = np.abs(np.fft.fft2(img - img.mean()))\n", + " return float(f[16, 16] / (f.sum() / f.size + 1e-6))\n", + "\n", + "\n", + "def ai_text_score(text):\n", + " \"\"\"기계적 선동 문체 흔적: 반복 어절 비율 + 느낌표 밀도.\"\"\"\n", + " w = text.split()\n", + " return (1 - len(set(w)) / max(len(w), 1)) + text.count(\"!\") / max(len(w), 1)\n", + "\n", + "# ---- 게시물 군집(같은 이야기 묶기)·조작 탐지 특징 공통 함수 ----\n", + "FEAT = [\"num_dev\", \"region_mis\", \"dir_mis\", \"obj_mis\", \"img_ref_dist\", \"img_prior_h\", \"time_gap_h\", \"splice\", \"ai_img\",\n", + " \"ai_text\", \"burst_n\", \"has_img\", \"is_fwd\", \"acc_age\", \"acc_followers\", \"acc_orig\", \"acc_past\", \"acc_cv\"]\n", + "\n", + "\n", + "def cluster_posts(posts, hours=24, ham_thr=8):\n", + " \"\"\"같은 이야기 묶기: 본문에서 뽑은 (지역, 대상)이 같고 24시간 안이면 연결하고,\n", + " 지각 해시가 가까운 이미지(같은 대상)도 연결한다. 글 표현이 달라도 내용 단위로 묶인다.\"\"\"\n", + " texts = [clean_text(p[\"text\"]) for p in posts]\n", + " X = TfidfVectorizer(analyzer=\"char_wb\", ngram_range=(2, 3)).fit_transform(texts)\n", + " S = (X @ X.T).toarray()\n", + " H = [phash(p[\"image\"]) if p[\"image\"] is not None else None for p in posts]\n", + " sl = [slots(p[\"text\"]) for p in posts]\n", + " t = [p[\"posted_at\"] for p in posts]\n", + " parent = list(range(len(posts)))\n", + "\n", + " def find(i):\n", + " while parent[i] != i:\n", + " parent[i] = parent[parent[i]]; i = parent[i]\n", + " return i\n", + " for i in range(len(posts)):\n", + " for j in range(i + 1, len(posts)):\n", + " if abs(t[i] - t[j]) >= hours or sl[i][\"o\"] != sl[j][\"o\"]:\n", + " continue\n", + " same_story = sl[i][\"r\"] is not None and sl[i][\"r\"] == sl[j][\"r\"]\n", + " same_img = H[i] is not None and H[j] is not None and hamming(H[i], H[j]) <= ham_thr\n", + " if same_story or same_img:\n", + " parent[find(i)] = find(j)\n", + " return np.array([find(i) for i in range(len(posts))]), S, H\n", + "\n", + "\n", + "def post_features(posts, acc):\n", + " cid, S, H = cluster_posts(posts)\n", + " t = np.array([p[\"posted_at\"] for p in posts])\n", + " sl = [slots(p[\"text\"]) for p in posts]\n", + " first_seen = {}\n", + " for i in np.argsort(t): # 이미지가 처음 등장한 시각과 군집\n", + " if H[i] is None:\n", + " continue\n", + " key = next((k for k in first_seen if hamming(np.array(k), H[i]) <= 6), None)\n", + " if key is None:\n", + " first_seen[tuple(H[i])] = (t[i], cid[i])\n", + " elif first_seen[key][1] != cid[i]:\n", + " pass\n", + " ref = {c: int(np.argmin(np.where(cid == c, t, np.inf))) for c in set(cid)} # 군집에서 가장 이른 게시물 = 기준(추정 원본)\n", + " F = []\n", + " for i, p in enumerate(posts):\n", + " r = ref[cid[i]]\n", + " a, b = sl[i], sl[r]\n", + " img_prior = 0.0\n", + " if H[i] is not None:\n", + " for k, (ts, c) in first_seen.items():\n", + " if hamming(np.array(k), H[i]) <= 6 and c != cid[i]:\n", + " img_prior = max(img_prior, min(t[i] - ts, 48.0))\n", + " same_txt = [j for j in np.where(S[i] > 0.97)[0] if abs(t[j] - t[i]) < 0.25 and posts[j][\"account_id\"] != p[\"account_id\"]]\n", + " A = acc[p[\"account_id\"]]\n", + " F.append([abs(np.log((a[\"n\"] or 1) / (b[\"n\"] or 1))), float(a[\"r\"] != b[\"r\"]), float(a[\"d\"] != b[\"d\"]), float(a[\"o\"] != b[\"o\"]),\n", + " hamming(H[i], H[r]) if H[i] is not None and H[r] is not None else 0, img_prior, t[i] - t[r],\n", + " splice_score(p[\"image\"]) if p[\"image\"] is not None else 0, ai_image_score(p[\"image\"]) if p[\"image\"] is not None else 0,\n", + " ai_text_score(p[\"text\"]), len(set(posts[j][\"account_id\"] for j in same_txt)), float(p[\"image\"] is not None),\n", + " float(p[\"forwarded_from\"] is not None), np.log1p(A[\"age_days\"]), np.log1p(A[\"followers\"]), A[\"original_ratio\"],\n", + " A[\"past_accuracy\"], A[\"interval_cv\"]])\n", + " return np.array(F), cid\n", + "\n", + "CONFIG = {\"threshold\": 0.3, \"use_account\": True, \"normalize\": True, # 미션 9 재보정값\n", + " \"weights\": [0.35, 0.20, 0.25, 0.10, 0.10], \"grades\": [(0.80, \"A\"), (0.65, \"B\"), (0.50, \"C\"), (0.35, \"D\"), (-1, \"E\")]}\n", + "HOMO = {\"A\": \"А\", \"B\": \"В\", \"C\": \"С\", \"E\": \"Е\"}\n", + "ZW = \"​\"\n", + "CALLED = set()\n", + "\n", + "\n", + "def cover(fn):\n", + " \"\"\"기능 커버리지 측정용 표시: 통합 실행 중 호출된 기능을 기록한다.\"\"\"\n", + " def wrap(*a, **k):\n", + " CALLED.add(fn.__name__)\n", + " return fn(*a, **k)\n", + " wrap.__name__ = fn.__name__\n", + " return wrap\n", + "\n", + "\n", + "@cover\n", + "def normalize(t):\n", + " t = unicodedata.normalize(\"NFKC\", t).replace(ZW, \"\")\n", + " for k, v in HOMO.items():\n", + " t = t.replace(v, k)\n", + " return t\n", + "\n", + "\n", + "_slots, _clean = slots, clean_text\n", + "\n", + "\n", + "@cover\n", + "def slots(t):\n", + " return _slots(normalize(t))\n", + "\n", + "\n", + "@cover\n", + "def clean_text(t):\n", + " return _clean(normalize(t))\n", + "\n", + "\n", + "phash, cluster_posts, post_features = cover(phash), cover(cluster_posts), cover(post_features)\n", + "\n", + "\n", + "@cover\n", + "def validate_post(p):\n", + " errs = []\n", + " if p[\"platform\"] not in (\"telegram\", \"x\", \"other\"):\n", + " errs.append(\"platform 허용값 아님\")\n", + " if not isinstance(p[\"text\"], str) or not p[\"text\"]:\n", + " errs.append(\"본문 형식 오류\")\n", + " if p[\"image\"] is not None and np.asarray(p[\"image\"]).shape != (32, 32):\n", + " errs.append(\"이미지 형식 오류\")\n", + " return errs\n", + "\n", + "\n", + "@cover\n", + "def credibility(posts, F, cid, pm, am):\n", + " sl = [slots(p[\"text\"]) for p in posts]\n", + " corro = [min(len({posts[j][\"account_id\"] for j in np.where(cid == cid[i])[0] if j != i and posts[j][\"forwarded_from\"] is None\n", + " and posts[j][\"account_id\"] != p[\"account_id\"] and (sl[j][\"r\"], sl[j][\"n\"], sl[j][\"d\"]) == (sl[i][\"r\"], sl[i][\"n\"], sl[i][\"d\"])}) / 2, 1)\n", + " for i, p in enumerate(posts)]\n", + " C = np.c_[1 - pm, 1 - am, corro, 1 - np.minimum(F[:, FEAT.index(\"burst_n\")] / 3, 1), np.exp(-np.maximum(F[:, FEAT.index(\"time_gap_h\")], 0) / 12)]\n", + " s = C @ np.array(CONFIG[\"weights\"])\n", + " return s, np.array([next(g for t, g in CONFIG[\"grades\"] if v >= t) for v in s])\n", + "\n", + "\n", + "class Queue:\n", + " def __init__(self):\n", + " self.items, self.log = [], []\n", + "\n", + " @cover\n", + " def submit(self, pid, grade, prob):\n", + " prev = self.log[-1][\"hash\"] if self.log else \"GENESIS\"\n", + " body = {\"event\": \"SUBMIT\", \"id\": pid, \"grade\": grade, \"prob\": round(float(prob), 3)}\n", + " self.log.append({**body, \"prev\": prev, \"hash\": hashlib.sha256((prev + json.dumps(body, sort_keys=True)).encode()).hexdigest()})\n", + " self.items.append({**body, \"status\": \"PENDING\"})\n", + "\n", + " @cover\n", + " def verify(self):\n", + " prev = \"GENESIS\"\n", + " for r in self.log:\n", + " body = {k: v for k, v in r.items() if k not in (\"prev\", \"hash\")}\n", + " if r[\"prev\"] != prev or r[\"hash\"] != hashlib.sha256((prev + json.dumps(body, sort_keys=True)).encode()).hexdigest():\n", + " return False\n", + " prev = r[\"hash\"]\n", + " return True\n", + "\n", + "\n", + "TARGETS = {\"normalize\", \"slots\", \"clean_text\", \"phash\", \"cluster_posts\", \"post_features\", \"validate_post\", \"credibility\", \"submit\", \"verify\", \"dashboard\"}\n", + "\n", + "# ---------------- 1. 학습 ----------------\n", + "t_start = time.perf_counter()\n", + "tr_posts, tr_acc = make_posts(60, seed=7)\n", + "Ftr, _ = post_features(tr_posts, tr_acc)\n", + "ytr = [p[\"is_disinfo\"] for p in tr_posts]\n", + "det = RandomForestClassifier(n_estimators=200, min_samples_leaf=3, random_state=0).fit(Ftr, ytr)\n", + "manip = RandomForestClassifier(n_estimators=200, min_samples_leaf=3, random_state=0).fit(Ftr[:, :13], ytr)\n", + "accm = make_pipeline(StandardScaler(), LogisticRegression(max_iter=2000)).fit(Ftr[:, 13:], ytr)\n", + "\n", + "# ---------------- 2. 운용 자료(가상 게시물 스트림) 처리 ----------------\n", + "rng = np.random.default_rng(31)\n", + "posts, acc = make_posts(60, seed=31)\n", + "good = [a for a, v in acc.items() if v[\"type\"] in (\"media\", \"witness\")]\n", + "for p in posts: # 신종 변조 패턴 일부 포함\n", + " if p[\"is_disinfo\"] and rng.random() < 0.3:\n", + " p[\"account_id\"] = str(rng.choice(good))\n", + " if p[\"is_disinfo\"] and rng.random() < 0.4:\n", + " p[\"text\"] = re.sub(r\"([A-F])-\", lambda m: HOMO.get(m.group(1), m.group(1)) + ZW + \"-\", p[\"text\"])\n", + "posts[11][\"text\"] = 404 # 손상 게시물 2건 주입(검증 단계에서 차단되어야 함)\n", + "posts[23][\"platform\"] = \"unknown_app\"\n", + "rejected = {}\n", + "ok_posts = []\n", + "for p in posts:\n", + " e = validate_post(p)\n", + " if e:\n", + " rejected[p[\"post_id\"]] = e\n", + " else:\n", + " ok_posts.append(p)\n", + "t0 = time.perf_counter()\n", + "F, cid = post_features(ok_posts, acc)\n", + "prob = det.predict_proba(F)[:, 1]\n", + "flag = prob >= CONFIG[\"threshold\"]\n", + "score, G = credibility(ok_posts, F, cid, manip.predict_proba(F[:, :13])[:, 1], accm.predict_proba(F[:, 13:])[:, 1])\n", + "ms = (time.perf_counter() - t0) / len(ok_posts) * 1000\n", + "queue = Queue()\n", + "for p, f, g, pr in zip(ok_posts, flag, G, prob):\n", + " if f or g in (\"D\", \"E\"):\n", + " queue.submit(p[\"post_id\"], g, pr) # 차단·채택은 정보관만(자동 처리 없음)\n", + "y, kind = np.array([p[\"is_disinfo\"] for p in ok_posts]), np.array([p[\"kind\"] for p in ok_posts])\n", + "acc_all, rec, far = (flag == y).mean(), (flag & y).sum() / y.sum(), (flag & ~y).sum() / (~y).sum()\n", + "\n", + "\n", + "# ---------------- 3. 모니터링 화면 ----------------\n", + "@cover\n", + "def dashboard():\n", + " plt.style.use(\"dark_background\")\n", + " fig, ax = plt.subplots(2, 2, figsize=(12, 9))\n", + " t = np.array([p[\"posted_at\"] for p in ok_posts])\n", + " for gset, c, lab in [((\"A\", \"B\"), \"limegreen\", \"A-B\"), ((\"C\",), \"orange\", \"C\"), ((\"D\", \"E\"), \"red\", \"D-E\")]:\n", + " ax[0, 0].hist(t[np.isin(G, gset)], bins=24, alpha=.7, color=c, label=lab)\n", + " ax[0, 0].set_title(\"Posts over time by credibility grade\"); ax[0, 0].legend()\n", + " gc = pd.Series(G).value_counts().reindex(list(\"ABCDE\")).fillna(0)\n", + " ax[0, 1].bar(gc.index.tolist(), gc.values, color=[\"limegreen\", \"limegreen\", \"orange\", \"red\", \"red\"]); ax[0, 1].set_title(\"Grade distribution\")\n", + " r = pd.Series(flag[y], index=kind[y]).groupby(level=0).mean()\n", + " ax[1, 0].barh(r.index.tolist(), r.values, color=\"deepskyblue\"); ax[1, 0].set_title(\"Detection recall by manipulation type\"); ax[1, 0].set_xlim(0, 1.05)\n", + " ax[1, 1].axis(\"off\")\n", + " ax[1, 1].text(0, .9, f\"Accuracy {acc_all:.1%}\\nRecall {rec:.1%}\\nFalse flags {far:.1%}\\nQueue {len(queue.items)} pending\\nLatency {ms:.2f} ms/post\",\n", + " family=\"monospace\", fontsize=13, va=\"top\")\n", + " fig.suptitle(\"ISR02 integrated SNS credibility system - synthetic data only\"); plt.tight_layout(); plt.show()\n", + "\n", + "\n", + "dashboard()\n", + "\n", + "# ---------------- 4. 자체 점검(통합 테스트) ----------------\n", + "log_ok = queue.verify()\n", + "cov = len(CALLED & TARGETS) / len(TARGETS)\n", + "chk = {\n", + " \"손상 게시물 차단\": set(rejected) == {\"P0011\", \"P0023\"},\n", + " \"처리 게시물 수 일치\": len(ok_posts) == len(posts) - 2,\n", + " \"종합 정확도 90% 이상\": acc_all >= 0.9,\n", + " \"조작 탐지 재현율 85% 이상\": rec >= 0.85,\n", + " \"사실 게시물 오경보 10% 이하\": far <= 0.10,\n", + " \"자동 차단·채택 0건(전부 검토 대기)\": all(i[\"status\"] == \"PENDING\" for i in queue.items),\n", + " \"감사 로그 무결성\": log_ok,\n", + " \"등급 규칙 일관\": all(g == next(gg for t, gg in CONFIG[\"grades\"] if s >= t) for s, g in zip(score, G)),\n", + " \"게시물당 200ms 이내\": ms < 200,\n", + " \"기능 커버리지 85% 이상\": cov >= 0.85,\n", + "}\n", + "passed = sum(chk.values())\n", + "for k, v in chk.items():\n", + " print(f\"[{'PASS' if v else 'FAIL'}] {k}\")\n", + "print(f\"자체 점검 {passed}/{len(chk)} 통과, 기능 커버리지 {cov:.0%} ({len(CALLED & TARGETS)}/{len(TARGETS)}), 파이프라인 실행 성공 {'예' if passed == len(chk) else '아니오'}\")\n", + "\n", + "# ---------------- 5. 정보전 대응 브리핑 요약 ----------------\n", + "top = pd.Series(kind[flag & y]).value_counts().head(3).to_dict()\n", + "print(f\"\"\"\n", + "[SNS 전쟁 게시물 신뢰도 분류 브리핑 요약 - 교육용가상자료]\n", + "1. 처리: 게시물 {len(posts)}건 중 {len(ok_posts)}건 분석, 손상 {len(rejected)}건 차단({', '.join(rejected)}), 이야기 군집 {len(set(cid))}개\n", + "2. 성능: 종합 정확도 {acc_all:.1%}, 조작 탐지 재현율 {rec:.1%}, 사실 게시물 오경보 {far:.1%}, 게시물당 {ms:.2f} ms, 전체 {time.perf_counter() - t_start:.0f} s\n", + "3. 등급: A {int((G == 'A').sum())} / B {int((G == 'B').sum())} / C {int((G == 'C').sum())} / D {int((G == 'D').sum())} / E {int((G == 'E').sum())}, 정보관 검토 대기 {len(queue.items)}건, 자동 차단 0건\n", + "4. 주요 조작 유형: {top}\n", + "5. 한계: 합성 게시물 기준 결과로, 실제 플랫폼 자료에는 재학습·재검증이 필요하며 차단·채택은 원천 역추적을 거친 정보관 결정으로만 한다.\"\"\")\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 10 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + "[PASS] 손상 게시물 차단\n", + "[PASS] 처리 게시물 수 일치\n", + "[PASS] 종합 정확도 90% 이상\n", + "[PASS] 조작 탐지 재현율 85% 이상\n", + "[PASS] 사실 게시물 오경보 10% 이하\n", + "[PASS] 자동 차단·채택 0건(전부 검토 대기)\n", + "[PASS] 감사 로그 무결성\n", + "[PASS] 등급 규칙 일관\n", + "[PASS] 게시물당 200ms 이내\n", + "[PASS] 기능 커버리지 85% 이상\n", + "자체 점검 10/10 통과, 기능 커버리지 100% (11/11), 파이프라인 실행 성공 예\n", + "\n", + "[SNS 전쟁 게시물 신뢰도 분류 브리핑 요약 - 교육용가상자료]\n", + "1. 처리: 게시물 596건 중 594건 분석, 손상 2건 차단(P0011, P0023), 이야기 군집 66개\n", + "2. 성능: 종합 정확도 94.4%, 조작 탐지 재현율 93.2%, 사실 게시물 오경보 4.5%, 게시물당 1.92 ms, 전체 3 s\n", + "3. 등급: A 278 / B 52 / C 67 / D 102 / E 95, 정보관 검토 대기 264건, 자동 차단 0건\n", + "4. 주요 조작 유형: {'bot_burst': 75, 'recycled': 26, 'region_swap': 24}\n", + "5. 한계: 합성 게시물 기준 결과로, 실제 플랫폼 자료에는 재학습·재검증이 필요하며 차단·채택은 원천 역추적을 거친 정보관 결정으로만 한다.\n", + "```\n", + "
\n" + ] + } + ], + "metadata": { + "colab": { + "provenance": [] + }, + "kernelspec": { + "display_name": "Python 3", + "name": "python3" + }, + "language_info": { + "name": "python" + } + }, + "nbformat": 4, + "nbformat_minor": 0 +} \ No newline at end of file diff --git a/uploads/ISR02/ISR02_student.ipynb b/uploads/ISR02/ISR02_student.ipynb new file mode 100644 index 0000000..01c6e4e --- /dev/null +++ b/uploads/ISR02/ISR02_student.ipynb @@ -0,0 +1,3681 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# 🎯 [ISR02] SNS 전쟁 게시물 중복·변조·출처 신뢰도 자동 분류\n", + "### Google Colab 실습 환경 (정보·감시·정찰(ISR) / Level 2)\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/ISR02/ISR02_solution.ipynb)\n", + "\n", + "---\n", + "\n", + "## 📌 1. 프로젝트 개요 및 배경\n", + "\n", + "| 항목 | 내용 |\n", + "| :--- | :--- |\n", + "| **과정 분류** | 정보·감시·정찰(ISR) (Level 2) |\n", + "| **학습 소요 시간** | 10개 미션 (5일 / 총 27.5시간) |\n", + "| **환경 사양** | Python 3.10+, numpy, pandas, matplotlib, scipy, scikit-learn |\n", + "\n", + "### 📖 프로젝트 목표\n", + "러시아-우크라이나 전쟁에서는 텔레그램·X 같은 SNS에 현장 제보와 함께 재게시, 수치·지역 변조, 과거 사진 재활용, 생성형 AI 조작물, 조직적 동시 유포가 한꺼번에 쏟아지면서 공개출처정보(OSINT)의 출처와 신뢰도를 빠르게 가려내는 일이 정보전의 핵심이 되었다. 본 프로젝트에서는 가상 계정과 가상 지역 코드로 만든 교육용 합성 게시물을 사용해 원천 데이터 스키마 정의, 텍스트 정제와 이미지 지각 해시 기반 중복 제거, 유사도 기반 복제·1차 출처 분류 Baseline, 조작·기만 탐지와 계정 신뢰도 결합 모델, 종합 신뢰도 등급(A~E) 엔진, 정보전 모니터링 대시보드, 정보관 긴급 검토(HITL), 대량 스트림 부하 시험, 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. 전장 SNS(텔레그램, X 등) 텍스트/이미지 게시물 수집 및 OSINT 원천 데이터셋 정의\n", + "**소요시간**: 약 1.5시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "텔레그램, X 등 전장 SNS에서 수집된 텍스트 및 이미지 게시물 데이터 구조를 분석하고 원천 데이터셋 스키마를 정의한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **텔레그램, X 등 전장 SNS에서 수집한 텍스트·이미지 게시물의 구조를 분석하고, 수집 경위와 주장 내용까지 담은 OSINT 원천 데이터셋 스키마와 검증기를 만든다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **게시물 기본 정보(게시물·계정 ID, 플랫폼, 게시 시각, 본문, 언어), 미디어(종류, 크기, 지각 해시), 전달 원본(forwarded_from), 수집 경위(수집 방식·시각·수집자·수집 지연), 주장 내용(가상 지역 코드, 위치 불확실성), 평가 상태 필드 정의하고 검증하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `data/schema/isr02_input_schema.json 및 docs/isr02_data_spec.md` 형태의 결과물을 완성한다.\n", + "\n", + "### 🔍 평가 및 검증 기준 (PASS 조건)\n", + "#### 미션 판정\n", + "\n", + "**평가 구분:** 필수\n", + "\n", + "**핵심 평가영역:** 정보·감시·정찰(ISR) 실무 및 러시아-우크라이나 전쟁 전장 환경과의 군사적 부합성\n", + "\n", + "#### 미션 PASS 조건\n", + "\n", + "- 정량 기준: 스키마 필드 정의 완성도 100%, 샘플 데이터 검증 오류 0건\n", + "\n", + "\n", + "- 진행 기준: 입력 데이터 유효성 검증 스크립트 통과 및 명세서 문서화 완료\n", + "\n", + "\n", + "- 검증 방법: JSON Schema 검증 도구 및 샘플 데이터 파싱 단위 테스트\n", + "\n", + "\n", + "#### 채점자 판정 메모\n", + "\n", + "> 단순 코드 실행 여부뿐만 아니라 출력된 수치와 근거 자료가 군 실무 요구사항에 부합하는지 면밀히 확인한다.\n", + "\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "\"\"\"전장 SNS(텔레그램, X 등) 텍스트·이미지 게시물의 수집 메타데이터, 계정·전달 경로, 주장 내용(지역·시각), 수집 지연 등 OSINT 원천 데이터셋 스키마를 정의하고 가상 샘플 게시물을 검증한다.\"\"\"\n", + "import json\n", + "import re\n", + "import numpy as np\n", + "import pandas as pd\n", + "\n", + "ISO = r\"^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}Z$\"\n", + "# 1) 스키마 정의: 게시물 기본 정보, 미디어, 수집 경위, 주장 내용, 수집 조건(위치 불확실성·지연), 평가 상태\n", + "SCHEMA = {\n", + " \"required\": [\"post_id\", \"platform\", \"account_id\", \"posted_at\", \"text\", \"media\", \"collection\", \"claim\", \"assessment\"],\n", + " \"fields\": {\n", + " \"post_id\": {\"type\": str, \"pattern\": r\"^P\\d{4}$\"},\n", + " \"platform\": {\"type\": str, \"enum\": [\"telegram\", \"x\", \"other\"]},\n", + " \"account_id\": {\"type\": str, \"pattern\": r\"^ACC_[A-Z]{3}\\d{2}$\"},\n", + " \"posted_at\": {\"type\": str, \"pattern\": ISO},\n", + " \"text\": {\"type\": str, \"minlen\": 1, \"maxlen\": 4000},\n", + " \"lang\": {\"type\": str, \"enum\": [\"ko\", \"en\", \"uk\", \"ru\", \"other\"]},\n", + " },\n", + " \"media\": {\n", + " \"type\": {\"type\": str, \"enum\": [\"image\", \"video\"]},\n", + " \"width\": {\"type\": int, \"min\": 1, \"max\": 10000},\n", + " \"height\": {\"type\": int, \"min\": 1, \"max\": 10000},\n", + " \"phash\": {\"type\": str, \"pattern\": r\"^[0-9a-f]{16}$\"},\n", + " },\n", + " \"collection\": {\n", + " \"method\": {\"type\": str, \"enum\": [\"public_api\", \"manual_capture\", \"partner_share\"]},\n", + " \"collected_at\": {\"type\": str, \"pattern\": ISO},\n", + " \"collector\": {\"type\": str, \"pattern\": r\"^COL-\\d{2}$\"},\n", + " \"delay_min\": {\"type\": (int, float), \"min\": 0, \"max\": 100000},\n", + " },\n", + " \"claim\": {\n", + " \"region_code\": {\"type\": str, \"pattern\": r\"^[A-F]-\\d$\"},\n", + " \"geo_uncertainty_km\": {\"type\": (int, float), \"min\": 0, \"max\": 500},\n", + " },\n", + " # 수집 단계에서는 진위·출처 판정을 적지 않는다(정보관 검토 후 별도 기록)\n", + " \"assessment\": {\"status\": {\"type\": str, \"enum\": [\"unassessed\"]}},\n", + "}\n", + "\n", + "\n", + "def check(name, value, rule):\n", + " # [TODO : 학생 실습 1] 전장 SNS(텔레그램, X 등) 텍스트/이미지 게시물 수집 및 OSINT 원천 데이터셋 정의 관련 핵심 로직을 구현하세요.\n", + " # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n", + " # <<< 여기에 코드를 작성하세요 >>>\n", + " pass # 작성 후 pass를 제거하세요.\n", + "def validate(rec):\n", + " # [TODO : 학생 실습 1] 전장 SNS(텔레그램, X 등) 텍스트/이미지 게시물 수집 및 OSINT 원천 데이터셋 정의 관련 핵심 로직을 구현하세요.\n", + " # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n", + " # <<< 여기에 코드를 작성하세요 >>>\n", + " pass # 작성 후 pass를 제거하세요.\n", + "rng = np.random.default_rng(1)\n", + "posts = []\n", + "for i in range(20):\n", + " r = f\"{'ABCDEF'[int(rng.integers(6))]}-{int(rng.integers(1, 6))}\"\n", + " posts.append({\"post_id\": f\"P{i:04d}\", \"platform\": str(rng.choice([\"telegram\", \"x\", \"other\"])),\n", + " \"account_id\": f\"ACC_{rng.choice(['MED', 'WIT', 'AGG', 'PRO'])}{int(rng.integers(0, 30)):02d}\",\n", + " \"posted_at\": f\"2026-05-0{int(rng.integers(1, 9))}T1{int(rng.integers(0, 9))}:0{int(rng.integers(0, 9))}Z\",\n", + " \"text\": f\"가상 {r} 지역에서 군용 차량 목격, {int(rng.integers(2, 15))}대\", \"lang\": \"ko\",\n", + " \"media\": [{\"type\": \"image\", \"width\": 32, \"height\": 32, \"phash\": \"\".join(rng.choice(list(\"0123456789abcdef\"), 16))}],\n", + " \"forwarded_from\": f\"P{int(rng.integers(0, max(i, 1))):04d}\" if i and rng.random() < .4 else None,\n", + " \"collection\": {\"method\": str(rng.choice([\"public_api\", \"manual_capture\", \"partner_share\"])), \"collected_at\": \"2026-05-09T12:00Z\",\n", + " \"collector\": f\"COL-{int(rng.integers(1, 20)):02d}\", \"delay_min\": int(rng.integers(1, 600))},\n", + " \"claim\": {\"region_code\": r, \"geo_uncertainty_km\": float(round(rng.uniform(0.5, 20), 1))},\n", + " \"assessment\": {\"status\": \"unassessed\"}})\n", + "\n", + "# 3) 정상 데이터 검증\n", + "ok = sum(1 for p in posts if not validate(p))\n", + "print(f\"정상 샘플 {len(posts)}건 중 검증 통과 {ok}건, 오류 {len(posts) - ok}건 (목표: 오류 0건 {'달성' if ok == len(posts) else '미달'})\")\n", + "\n", + "# 4) 오류 주입 시험\n", + "bad = json.loads(json.dumps(posts[3]))\n", + "bad[\"platform\"] = \"unknown_app\"\n", + "bad[\"posted_at\"] = \"2026/05/01 10:00\"\n", + "del bad[\"collection\"][\"method\"]\n", + "bad[\"media\"][0][\"phash\"] = \"XYZ\"\n", + "bad[\"text\"] = \"\"\n", + "print(\"\\n[오류 주입 결과] 주입 5종\")\n", + "for e in validate(bad):\n", + " print(\" -\", e)\n", + "\n", + "# 5) 스키마 필드 정의서(명세서 초안)\n", + "rows = [(\"기본\", k, r[\"type\"].__name__, str({x: y for x, y in r.items() if x != \"type\"})) for k, r in SCHEMA[\"fields\"].items()]\n", + "for g in (\"media\", \"collection\", \"claim\", \"assessment\"):\n", + " for k, r in SCHEMA[g].items():\n", + " t = r[\"type\"].__name__ if isinstance(r[\"type\"], type) else \"number\"\n", + " rows.append((g, k, t, str({x: y for x, y in r.items() if x != \"type\"})))\n", + "spec = pd.DataFrame(rows, columns=[\"그룹\", \"필드\", \"자료형\", \"규칙\"])\n", + "print(\"\\n[데이터 명세 요약]\")\n", + "print(spec.to_string(index=False))\n", + "print(f\"\\n필드 정의 {len(spec)}개 + forwarded_from(전달 원본, 선택). 수집 지연·위치 불확실성은 '통신지연·위치오차' 전술 파라미터에 해당한다.\")\n", + "print(f\"전달 경로가 있는 게시물 {sum(p['forwarded_from'] is not None for p in posts)}건: 출처 역추적(미션 3·7)에 사용\")\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 01 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + "정상 샘플 20건 중 검증 통과 20건, 오류 0건 (목표: 오류 0건 달성)\n", + "\n", + "[오류 주입 결과] 주입 5종\n", + " - platform: 허용되지 않은 값 'unknown_app'\n", + " - posted_at: 형식 불일치 '2026/05/01 10:00'\n", + " - text: 빈 값\n", + " - collection.method: 필수 필드 누락\n", + " - media[0].phash: 형식 불일치 'XYZ'\n", + "\n", + "[데이터 명세 요약]\n", + " 그룹 필드 자료형 규칙\n", + " 기본 post_id str {'pattern': '^P\\\\d{4}$'}\n", + " 기본 platform str {'enum': ['telegram', 'x', 'other']}\n", + " 기본 account_id str {'pattern': '^ACC_[A-Z]{3}\\\\d{2}$'}\n", + " 기본 posted_at str {'pattern': '^\\\\d{4}-\\\\d{2}-\\\\d{2}T\\\\d{2}:\\\\d{2}Z$'}\n", + " 기본 text str {'minlen': 1, 'maxlen': 4000}\n", + " 기본 lang str {'enum': ['ko', 'en', 'uk', 'ru', 'other']}\n", + " media type str {'enum': ['image', 'video']}\n", + " media width int {'min': 1, 'max': 10000}\n", + " media height int {'min': 1, 'max': 10000}\n", + " media phash str {'pattern': '^[0-9a-f]{16}$'}\n", + "collection method str {'enum': ['public_api', 'manual_capture', 'partner_share']}\n", + "collection collected_at str {'pattern': '^\\\\d{4}-\\\\d{2}-\\\\d{2}T\\\\d{2}:\\\\d{2}Z$'}\n", + "collection collector str {'pattern': '^COL-\\\\d{2}$'}\n", + "collection delay_min number {'min': 0, 'max': 100000}\n", + " claim region_code str {'pattern': '^[A-F]-\\\\d$'}\n", + " claim geo_uncertainty_km number {'min': 0, 'max': 500}\n", + "assessment status str {'enum': ['unassessed']}\n", + "\n", + "필드 정의 17개 + forwarded_from(전달 원본, 선택). 수집 지연·위치 불확실성은 '통신지연·위치오차' 전술 파라미터에 해당한다.\n", + "전달 경로가 있는 게시물 12건: 출처 역추적(미션 3·7)에 사용\n", + "```\n", + "
\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 02. 게시물 텍스트 불용어 정제, 이미지 해시(pHash) 추출 및 중복 제거 전처리 모듈 구현\n", + "**소요시간**: 약 2.0시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "텍스트 불용어 제거, 특수문자 정제, 이미지 지각 해시(pHash) 추출을 통해 중복 게시물을 걸러내는 전처리 모듈을 구현한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **게시물 텍스트의 불용어·특수문자·URL·이모지를 정제하고 이미지 지각 해시(pHash)를 추출해 중복 게시물을 걸러내는 전처리 모듈을 구현한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **유니코드 정규화(전각 문자 등), URL·멘션·해시태그 기호·특수문자·이모지·불용어 제거, 본문 결측 보정을 구현하고, 32x32 이미지의 DCT 저주파 성분으로 64비트 pHash 작성하여 원인을 분석하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 5개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `src/preprocess/isr02_preprocessor.py 및 전처리 전후 데이터셋` 형태의 결과물을 완성한다.\n", + "\n", + "### 🔍 평가 및 검증 기준 (PASS 조건)\n", + "#### 미션 판정\n", + "\n", + "**평가 구분:** 필수\n", + "\n", + "**핵심 평가영역:** 전술 환경 특유의 노이즈(스푸핑, 반사파, 센서 단절 등) 제거 타당성\n", + "\n", + "#### 미션 PASS 조건\n", + "\n", + "- 정량 기준: 데이터 정제 처리 속도 1,000건/초 이상, 전처리 오류율 0%\n", + "\n", + "\n", + "- 진행 기준: 정제 파이프라인 자동화 및 단위 테스트 케이스 5종 통과\n", + "\n", + "\n", + "- 검증 방법: pytest 기반 전처리 정합성 테스트 및 결측치 보정율 확인\n", + "\n", + "\n", + "#### 채점자 판정 메모\n", + "\n", + "> 단순 코드 실행 여부뿐만 아니라 출력된 수치와 근거 자료가 군 실무 요구사항에 부합하는지 면밀히 확인한다.\n", + "\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "\"\"\"SNS 게시물 텍스트의 유니코드 정규화·URL·멘션·특수문자·이모지·불용어 제거와 결측 처리, 이미지 지각 해시(pHash) 추출을 수행하고, 정규화 본문과 이미지 해시로 중복 게시물을 걸러내는 전처리 모듈을 구현해 처리 속도와 정합성을 검증한다.\"\"\"\n", + "import re\n", + "import time\n", + "import unicodedata\n", + "import numpy as np\n", + "import pandas as pd\n", + "import matplotlib.pyplot as plt\n", + "from scipy import ndimage\n", + "from scipy.fft import dctn\n", + "\n", + "# ---------------------------------------------------------------------------\n", + "# [교육용가상자료] 합성 SNS 게시물 생성기 (실제 계정·채널·지명·게시물 없음)\n", + "# 가상 사건마다 원본 제보, 재게시(복제), 유사 재작성, 조작·기만 게시물 10종과 32x32 합성 이미지를 만든다.\n", + "# ---------------------------------------------------------------------------\n", + "REGIONS = [f\"{a}-{n}\" for a in \"ABCDEF\" for n in range(1, 6)]\n", + "OBJECTS = [\"군용 차량\", \"소형 드론\", \"보급 트럭\", \"헬기\", \"버스\", \"견인 차량\", \"트레일러\", \"민간 승용차\"]\n", + "DIRS = [\"북쪽\", \"남쪽\", \"동쪽\", \"서쪽\"]\n", + "TEMPLATES = [\"가상 {r} 지역에서 {o} 목격, {n}대 규모로 {d}으로 이동 중\",\n", + " \"{r} 일대 {o} 확인됨. 약 {n}대, {d} 방향으로 이동\",\n", + " \"[현지] {r}에서 {o} 발견 - 수량 {n}, 이동 방향 {d}\",\n", + " \"{r} 주민 제보: {o} {n}대가 {d}으로 향하는 모습\"]\n", + "MANIP = [\"num_change\", \"region_swap\", \"dir_flip\", \"old_image\", \"splice\", \"ai_image\", \"ai_text\", \"bot_burst\", \"mashup\", \"recycled\"]\n", + "ACCOUNT_TYPES = { # 유형별 계정 속성 분포(가상): 나이(일), 팔로워, 하루 게시 수, 원본 비율, 과거 정확도, 게시 간격 변동계수\n", + " \"media\": ((1000, 4000), (5e4, 5e5), (5, 30), (.6, .9), (.85, .97), (.8, 1.5)),\n", + " \"witness\": ((10, 800), (20, 2000), (.2, 3), (.7, 1.0), (.6, .95), (1.0, 2.0)),\n", + " \"aggregator\": ((200, 2000), (1e4, 2e5), (30, 200), (.05, .2), (.6, .85), (.5, 1.0)),\n", + " \"propaganda\": ((5, 200), (100, 2e4), (20, 150), (.3, .7), (.1, .4), (.05, .3)),\n", + "}\n", + "\n", + "\n", + "def make_accounts(rng, n=(8, 30, 10, 20)):\n", + " acc = {}\n", + " for (t, spec), k in zip(ACCOUNT_TYPES.items(), n):\n", + " for i in range(k):\n", + " v = [rng.uniform(*r) for r in spec]\n", + " acc[f\"ACC_{t[:3].upper()}{i:02d}\"] = {\"type\": t, \"age_days\": int(v[0]), \"followers\": int(v[1]), \"posts_per_day\": round(v[2], 1),\n", + " \"original_ratio\": round(v[3], 2), \"past_accuracy\": round(v[4], 2), \"interval_cv\": round(v[5], 2)}\n", + " return acc\n", + "\n", + "\n", + "def event_image(seed):\n", + " \"\"\"사건 현장 사진을 흉내 낸 32x32 합성 이미지(사건마다 고유한 도형 배치).\"\"\"\n", + " # [TODO : 학생 실습 2] 게시물 텍스트 불용어 정제, 이미지 해시(pHash) 추출 및 중복 제거 전처리 모듈 구현 관련 핵심 로직을 구현하세요.\n", + " # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n", + " # <<< 여기에 코드를 작성하세요 >>>\n", + " pass # 작성 후 pass를 제거하세요.\n", + "def variant(img, rng):\n", + " \"\"\"재게시 과정의 자연스러운 변형: 재압축 잡음, 밝기 변화, 가장자리 잘림 중 하나.\"\"\"\n", + " # [TODO : 학생 실습 2] 게시물 텍스트 불용어 정제, 이미지 해시(pHash) 추출 및 중복 제거 전처리 모듈 구현 관련 핵심 로직을 구현하세요.\n", + " # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n", + " # <<< 여기에 코드를 작성하세요 >>>\n", + " pass # 작성 후 pass를 제거하세요.\n", + "def splice(img, other, rng):\n", + " out = img.copy()\n", + " y, x = rng.integers(4, 18, 2)\n", + " out[y:y + 12, x:x + 12] = other[y:y + 12, x:x + 12] + rng.normal(0, 12, (12, 12)) # 잡음 수준이 다른 조각을 붙임\n", + " return out\n", + "\n", + "\n", + "def ai_image(rng):\n", + " \"\"\"생성형 모델 이미지를 흉내 낸 합성 이미지: 지나치게 매끈한 바탕 + 업샘플링 격자 무늬(주기 2픽셀).\"\"\"\n", + " base = ndimage.gaussian_filter(rng.normal(130, 60, (32, 32)), 3)\n", + " grid = (np.indices((32, 32)).sum(0) % 2) * rng.uniform(6, 12)\n", + " return base + grid\n", + "\n", + "\n", + "def write(ev, rng, tpl=None, **kw):\n", + " s = {\"r\": ev[\"r\"], \"o\": ev[\"o\"], \"n\": ev[\"n\"], \"d\": ev[\"d\"], **kw}\n", + " return (tpl or TEMPLATES[int(rng.integers(len(TEMPLATES)))]).format(**s)\n", + "\n", + "\n", + "def make_posts(n_events=40, seed=7):\n", + " rng = np.random.default_rng(seed)\n", + " acc = make_accounts(rng)\n", + " by = {t: [a for a, v in acc.items() if v[\"type\"] == t] for t in ACCOUNT_TYPES}\n", + " events = [{\"id\": f\"EV{i:03d}\", \"r\": str(rng.choice(REGIONS)), \"o\": str(rng.choice(OBJECTS)), \"n\": int(rng.integers(2, 15)),\n", + " \"d\": str(rng.choice(DIRS)), \"t0\": float(i * 1.2 + rng.uniform(0, 1)), \"img\": event_image(seed * 1000 + i)} for i in range(n_events)]\n", + " posts = []\n", + "\n", + " def add(ev, account, text, img, t, fwd, kind, origin):\n", + " pid = f\"P{len(posts):04d}\"\n", + " posts.append({\"post_id\": pid, \"platform\": str(rng.choice([\"telegram\", \"x\", \"other\"], p=[.6, .3, .1])), \"account_id\": account,\n", + " \"posted_at\": round(t, 3), \"text\": text, \"image\": img, \"forwarded_from\": fwd, \"event_id\": ev[\"id\"] if ev else None,\n", + " \"kind\": kind, \"is_disinfo\": kind in MANIP, \"origin\": origin})\n", + " return pid\n", + "\n", + " for k, ev in enumerate(events):\n", + " src = str(rng.choice(by[\"witness\"] if rng.random() < .6 else by[\"media\"]))\n", + " if rng.random() < .05:\n", + " src = str(rng.choice(by[\"propaganda\"])) # 신뢰도 낮은 계정도 사실을 올릴 수 있다\n", + " o = add(ev, src, write(ev, rng), ev[\"img\"], ev[\"t0\"], None, \"original\", None)\n", + " for _ in range(int(rng.integers(2, 5))): # 그대로 재게시(전달)\n", + " add(ev, str(rng.choice(by[\"aggregator\"])), posts[int(o[1:])][\"text\"], ev[\"img\"], ev[\"t0\"] + rng.exponential(1), o, \"copy\", o)\n", + " for _ in range(int(rng.integers(1, 3))): # 표현만 바꾼 재작성(내용 동일)\n", + " add(ev, str(rng.choice(by[\"media\"] + by[\"witness\"])), \"#가상속보 \" + write(ev, rng), variant(ev[\"img\"], rng),\n", + " ev[\"t0\"] + rng.uniform(0.5, 6), None, \"near_dup\", o)\n", + " for m in MANIP if k % 2 == 0 else MANIP[k % 4::4]: # 조작·기만 게시물\n", + " if rng.random() > 0.5:\n", + " continue\n", + " who = str(rng.choice(by[\"propaganda\"] if rng.random() < .75 else by[\"aggregator\"] + by[\"witness\"]))\n", + " t, img, text = ev[\"t0\"] + rng.uniform(1, 12), variant(ev[\"img\"], rng), None\n", + " if m == \"num_change\":\n", + " text = write(ev, rng, n=ev[\"n\"] * int(rng.integers(5, 10)))\n", + " elif m == \"region_swap\":\n", + " text = write(ev, rng, r=str(rng.choice([r for r in REGIONS if r != ev[\"r\"]])))\n", + " elif m == \"dir_flip\":\n", + " text = write(ev, rng, d={\"북쪽\": \"남쪽\", \"남쪽\": \"북쪽\", \"동쪽\": \"서쪽\", \"서쪽\": \"동쪽\"}[ev[\"d\"]])\n", + " elif m == \"old_image\" and k > 0:\n", + " text, img = write(ev, rng), events[int(rng.integers(0, k))][\"img\"]\n", + " elif m == \"splice\":\n", + " text, img = write(ev, rng), splice(ev[\"img\"], events[(k + 7) % n_events][\"img\"], rng)\n", + " elif m == \"ai_image\":\n", + " text, img = write(ev, rng), ai_image(rng)\n", + " elif m == \"ai_text\":\n", + " text = f\"속보 속보! {ev['r']} 지역 대규모 {ev['o']} 출현! 긴급 확산 바람! 긴급 확산 바람! 모두 확산 바람!\"\n", + " img = ai_image(rng) if rng.random() < .5 else None\n", + " elif m == \"bot_burst\":\n", + " text = write(ev, rng, n=ev[\"n\"] * 6) + \" 지금 바로 공유\"\n", + " for b in rng.choice(by[\"propaganda\"], 4, replace=False):\n", + " add(ev, str(b), text, img, t + rng.uniform(0, 0.15), None, m, o)\n", + " continue\n", + " elif m == \"mashup\":\n", + " other = events[(k + 3) % n_events]\n", + " text = f\"{ev['r']} 일대 {ev['o']} 확인, {other['n']}대가 {other['d']}으로 이동\" # 다른 사건 내용과 짜깁기\n", + " elif m == \"recycled\" and k > 2:\n", + " old = events[int(rng.integers(0, k - 1))]\n", + " text, img, t = write(old, rng), old[\"img\"], ev[\"t0\"] + rng.uniform(0, 3)\n", + " if text is None:\n", + " continue\n", + " add(ev, who, text, img, t, None, m, o)\n", + " return posts, acc\n", + "# ---------------------------------------------------------------------------\n", + "\n", + "# ---- 텍스트 정제·이미지 지각 해시·조작 흔적 공통 함수 ----\n", + "STOPWORDS = {\"가상\", \"약\", \"일대\", \"지역에서\", \"모습\", \"중\", \"확인됨\", \"발견\", \"제보\", \"주민\"}\n", + "_URL = re.compile(r\"https?://\\S+|www\\.\\S+\")\n", + "_SPECIAL = re.compile(r\"[^0-9A-Za-z가-힣\\-\\s]\")\n", + "\n", + "\n", + "def clean_text(t):\n", + " \"\"\"정규화(NFKC) -> URL·멘션 제거 -> 해시태그 기호 제거 -> 특수문자 제거 -> 불용어 제거 -> 공백 정리.\"\"\"\n", + " t = unicodedata.normalize(\"NFKC\", t)\n", + " t = _URL.sub(\" \", t)\n", + " t = re.sub(r\"@\\w+\", \" \", t).replace(\"#\", \" \")\n", + " t = _SPECIAL.sub(\" \", t)\n", + " return \" \".join(w for w in t.split() if w not in STOPWORDS)\n", + "\n", + "\n", + "def phash(img):\n", + " \"\"\"지각 해시(pHash): 32x32 이미지 DCT의 저주파 8x8(직류 성분 제외)을 중앙값과 비교한 64비트.\"\"\"\n", + " c = dctn(np.asarray(img, float), norm=\"ortho\")[:8, :8].ravel()[1:]\n", + " return np.append(c > np.median(c), False)\n", + "\n", + "\n", + "def hamming(a, b):\n", + " return int(np.count_nonzero(a != b))\n", + "\n", + "\n", + "def slots(text):\n", + " \"\"\"본문에서 지역 코드·수량·방향·대상을 뽑는다(사건 내용 비교용).\"\"\"\n", + " r = re.search(r\"[A-F]-\\d\", text)\n", + " n = re.search(r\"(\\d+)\\s*대|수량\\s*(\\d+)\", text)\n", + " d = next((x for x in DIRS if x in text), None)\n", + " o = next((x for x in OBJECTS if x in text), None)\n", + " return {\"r\": r.group(0) if r else None, \"n\": int(n.group(1) or n.group(2)) if n else None, \"d\": d, \"o\": o}\n", + "\n", + "\n", + "def splice_score(img):\n", + " \"\"\"합성(붙여넣기) 흔적: 8x8 블록별 고주파 잡음 크기의 최대/중앙값 비율. 잡음 수준이 다른 조각이 있으면 커진다.\"\"\"\n", + " res = img - ndimage.median_filter(img, 3)\n", + " b = np.abs(res).reshape(4, 8, 4, 8).std(axis=(1, 3))\n", + " return float(b.max() / (np.median(b) + 1e-6))\n", + "\n", + "\n", + "def ai_image_score(img):\n", + " \"\"\"생성형 이미지 흔적: 2픽셀 주기 격자(나이퀴스트 주파수) 에너지 비율.\"\"\"\n", + " f = np.abs(np.fft.fft2(img - img.mean()))\n", + " return float(f[16, 16] / (f.sum() / f.size + 1e-6))\n", + "\n", + "\n", + "def ai_text_score(text):\n", + " \"\"\"기계적 선동 문체 흔적: 반복 어절 비율 + 느낌표 밀도.\"\"\"\n", + " w = text.split()\n", + " return (1 - len(set(w)) / max(len(w), 1)) + text.count(\"!\") / max(len(w), 1)\n", + "\n", + "posts, acc = make_posts(60, seed=7)\n", + "rng = np.random.default_rng(3)\n", + "FIRE, FLAG = chr(0x1F525), chr(0x1F6A8) # 이모지(코드값으로 생성)\n", + "for p in posts: # 수집 과정의 잡음 주입\n", + " r = rng.random()\n", + " if r < .15:\n", + " p[\"text\"] += f\" https://example.invalid/{rng.integers(1e6)}\"\n", + " elif r < .3:\n", + " p[\"text\"] = \"@ch_virtual \" + p[\"text\"] + \" \" + FIRE + FLAG\n", + " elif r < .4:\n", + " p[\"text\"] = unicodedata.normalize(\"NFKC\", p[\"text\"]).replace(\"A\", \"A\").replace(\"1\", \"1\") # 전각 문자\n", + " elif r < .43:\n", + " p[\"text\"] = None # 본문 결측(이미지만 게시)\n", + "\n", + "# 1) 처리 속도와 오류율\n", + "t0, out, fails = time.perf_counter(), [], 0\n", + "for p in posts:\n", + " try:\n", + " txt = clean_text(p[\"text\"] or \"\")\n", + " out.append({\"id\": p[\"post_id\"], \"t\": p[\"posted_at\"], \"clean\": txt, \"missing\": p[\"text\"] is None,\n", + " \"hash\": phash(p[\"image\"]) if p[\"image\"] is not None else None})\n", + " except Exception:\n", + " fails += 1\n", + "dt = time.perf_counter() - t0\n", + "print(f\"전처리 {len(posts)}건, {len(posts) / dt:,.0f}건/초 (목표 1,000건/초 이상), 실패 {fails}건 -> 오류율 {fails / len(posts):.0%}\")\n", + "miss = [o for o in out if o[\"missing\"]]\n", + "print(f\"본문 결측 {len(miss)}건: 빈 문자열로 보정하고 '이미지 단독 게시물'로 표시(보정율 100%)\")\n", + "\n", + "# 2) 중복 제거: 같은 정규화 본문 + 이미지 해시 거리 4 이하(또는 둘 다 없음)는 같은 게시물로 보고 가장 이른 것만 남긴다\n", + "kept, dup_of = [], {}\n", + "for o in sorted(out, key=lambda x: x[\"t\"]):\n", + " same = next((k for k in kept if k[\"clean\"] == o[\"clean\"] and o[\"clean\"] and\n", + " ((k[\"hash\"] is None and o[\"hash\"] is None) or (k[\"hash\"] is not None and o[\"hash\"] is not None and hamming(k[\"hash\"], o[\"hash\"]) <= 4))), None)\n", + " if same:\n", + " dup_of[o[\"id\"]] = same[\"id\"]\n", + " else:\n", + " kept.append(o)\n", + "kinds = {p[\"post_id\"]: p[\"kind\"] for p in posts}\n", + "print(f\"중복 제거: {len(out)}건 -> {len(kept)}건 (제거 {len(dup_of)}건, 그중 실제 재게시(copy) {sum(kinds[d] == 'copy' for d in dup_of)}건 / 조직적 동시 게시 {sum(kinds[d] == 'bot_burst' for d in dup_of)}건)\")\n", + "print(f\"전각 문자·이모지·URL 차이는 정제로 흡수되고, 본문 결측 등으로 남은 재게시 {sum(kinds[k['id']] == 'copy' for k in kept)}건은 미션 3의 유사도 판별로 넘긴다\")\n", + "\n", + "# 3) 단위 테스트 5종\n", + "e1, e2 = event_image(1), event_image(2)\n", + "tests = {\n", + " \"URL·멘션 제거\": clean_text(\"@abc 보고 https://x.invalid/1 확인\") == \"보고 확인\",\n", + " \"이모지·특수문자 제거\": FIRE not in clean_text(\"정전 \" + FIRE + \"!!\") and \"!\" not in clean_text(\"정전!!\"),\n", + " \"전각 문자 정규화(NFKC)\": clean_text(\"A-1 정전\") == \"A-1 정전\",\n", + " \"pHash 재압축 강건성(거리 8 이하)\": hamming(phash(e1), phash(ndimage.gaussian_filter(e1, 0.6) + rng.normal(0, 3, e1.shape))) <= 8,\n", + " \"pHash 다른 사진 구분(거리 16 이상)\": hamming(phash(e1), phash(e2)) >= 16,\n", + "}\n", + "for k, v in tests.items():\n", + " print(f\"[{'PASS' if v else 'FAIL'}] {k}\")\n", + "print(f\"단위 테스트 {sum(tests.values())}/5 통과\")\n", + "\n", + "# 4) 정제 전후 예시\n", + "ex = [next(p for p in posts if p[\"text\"] and key in p[\"text\"]) for key in (\"http\", \"@\", \"\\uff21\")]\n", + "print(\"\\n[정제 전후 예시]\")\n", + "for p in ex:\n", + " print(\" 전:\", p[\"text\"].replace(FIRE, \"\").replace(FLAG, \"\"))\n", + " print(\" 후:\", clean_text(p[\"text\"]))\n", + "\n", + "d_same = [hamming(phash(p[\"image\"]), phash(posts[int(p[\"origin\"][1:])][\"image\"])) for p in posts if p[\"kind\"] in (\"copy\", \"near_dup\") and p[\"image\"] is not None]\n", + "d_diff = [hamming(phash(posts[i][\"image\"]), phash(posts[j][\"image\"])) for i, j in rng.integers(0, len(posts), (200, 2))\n", + " if posts[i][\"event_id\"] != posts[j][\"event_id\"] and posts[i][\"image\"] is not None and posts[j][\"image\"] is not None]\n", + "plt.figure(figsize=(7, 4))\n", + "plt.hist([d_same, d_diff], bins=32, range=(0, 64), label=[\"same event (repost)\", \"different events\"])\n", + "plt.axvline(8, ls=\"--\", c=\"k\"); plt.xlabel(\"pHash Hamming distance\"); plt.ylabel(\"pairs\"); plt.legend(); plt.title(\"Perceptual hash distance (synthetic images)\")\n", + "plt.show()\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 02 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + "전처리 573건, 6,551건/초 (목표 1,000건/초 이상), 실패 0건 -> 오류율 0%\n", + "본문 결측 14건: 빈 문자열로 보정하고 '이미지 단독 게시물'로 표시(보정율 100%)\n", + "중복 제거: 573건 -> 340건 (제거 233건, 그중 실제 재게시(copy) 169건 / 조직적 동시 게시 53건)\n", + "전각 문자·이모지·URL 차이는 정제로 흡수되고, 본문 결측 등으로 남은 재게시 6건은 미션 3의 유사도 판별로 넘긴다\n", + "[PASS] URL·멘션 제거\n", + "[PASS] 이모지·특수문자 제거\n", + "[PASS] 전각 문자 정규화(NFKC)\n", + "[PASS] pHash 재압축 강건성(거리 8 이하)\n", + "[PASS] pHash 다른 사진 구분(거리 16 이상)\n", + "단위 테스트 5/5 통과\n", + "\n", + "[정제 전후 예시]\n", + " 전: [현지] A-4에서 보급 트럭 발견 - 수량 9, 이동 방향 서쪽 https://example.invalid/179440\n", + " 후: 현지 A-4에서 보급 트럭 - 수량 9 이동 방향 서쪽\n", + " 전: @ch_virtual [현지] A-4에서 보급 트럭 발견 - 수량 9, 이동 방향 동쪽 \n", + " 후: 현지 A-4에서 보급 트럭 - 수량 9 이동 방향 동쪽\n", + " 전: A-2 주민 제보: 버스 8대가 서쪽으로 향하는 모습\n", + " 후: A-2 버스 8대가 서쪽으로 향하는\n", + "```\n", + "
\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 03. 게시물 유사도 기반 단순 복제본 탐지 및 1차 출처 분류 Baseline 알고리즘 구현\n", + "**소요시간**: 약 2.5시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "게시물 간 텍스트/이미지 유사도 기반으로 단순 복제 및 인용 관계를 판별하는 1차 출처 분류 Baseline 알고리즘을 개발한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **게시물 간 텍스트·이미지 유사도로 단순 복제와 인용(재작성) 관계를 판별하고 1차 출처를 분류하는 Baseline 알고리즘을 개발한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **정제 본문의 문자 n-gram TF-IDF 코사인 유사도와 pHash 거리로 복제·재작성·무관 관계를 규칙 판정하고, 재작성 판단 기준을 바꿔 가며 정확도를 1차 조정 수행하고 검증하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `src/models/isr02_baseline_model.py 및 기본 추론 결과 보고서` 형태의 결과물을 완성한다.\n", + "\n", + "### 🔍 평가 및 검증 기준 (PASS 조건)\n", + "#### 미션 판정\n", + "\n", + "**평가 구분:** 필수\n", + "\n", + "**핵심 평가영역:** 알고리즘의 논리적 흐름 및 군사적 판단 기준의 투명성\n", + "\n", + "#### 미션 PASS 조건\n", + "\n", + "- 정량 기준: 기본 데이터셋 대상 탐지/처리 정확도 75% 이상 달성\n", + "\n", + "\n", + "- 진행 기준: 단위 기능 동작 확인 및 기본 시나리오 실행 로그 생성\n", + "\n", + "\n", + "- 검증 방법: 기준 벤치마크 데이터셋 대상 추론 결과 오차율 비교\n", + "\n", + "\n", + "#### 채점자 판정 메모\n", + "\n", + "> 단순 코드 실행 여부뿐만 아니라 출력된 수치와 근거 자료가 군 실무 요구사항에 부합하는지 면밀히 확인한다.\n", + "\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "\"\"\"게시물 쌍의 본문 유사도(문자 n-gram TF-IDF 코사인)와 이미지 지각 해시 거리로 단순 복제·재작성(인용)·무관 관계를 판별하고, 같은 이야기로 묶인 게시물 중 가장 이른 것을 1차 출처로 분류하는 Baseline 알고리즘을 구현해 정확도와 처리 지연을 측정한다.\"\"\"\n", + "import re\n", + "import time\n", + "import unicodedata\n", + "import numpy as np\n", + "import pandas as pd\n", + "import matplotlib.pyplot as plt\n", + "from scipy import ndimage\n", + "from scipy.fft import dctn\n", + "from sklearn.feature_extraction.text import TfidfVectorizer\n", + "\n", + "# ---------------------------------------------------------------------------\n", + "# [교육용가상자료] 합성 SNS 게시물 생성기 (실제 계정·채널·지명·게시물 없음)\n", + "# 가상 사건마다 원본 제보, 재게시(복제), 유사 재작성, 조작·기만 게시물 10종과 32x32 합성 이미지를 만든다.\n", + "# ---------------------------------------------------------------------------\n", + "REGIONS = [f\"{a}-{n}\" for a in \"ABCDEF\" for n in range(1, 6)]\n", + "OBJECTS = [\"군용 차량\", \"소형 드론\", \"보급 트럭\", \"헬기\", \"버스\", \"견인 차량\", \"트레일러\", \"민간 승용차\"]\n", + "DIRS = [\"북쪽\", \"남쪽\", \"동쪽\", \"서쪽\"]\n", + "TEMPLATES = [\"가상 {r} 지역에서 {o} 목격, {n}대 규모로 {d}으로 이동 중\",\n", + " \"{r} 일대 {o} 확인됨. 약 {n}대, {d} 방향으로 이동\",\n", + " \"[현지] {r}에서 {o} 발견 - 수량 {n}, 이동 방향 {d}\",\n", + " \"{r} 주민 제보: {o} {n}대가 {d}으로 향하는 모습\"]\n", + "MANIP = [\"num_change\", \"region_swap\", \"dir_flip\", \"old_image\", \"splice\", \"ai_image\", \"ai_text\", \"bot_burst\", \"mashup\", \"recycled\"]\n", + "ACCOUNT_TYPES = { # 유형별 계정 속성 분포(가상): 나이(일), 팔로워, 하루 게시 수, 원본 비율, 과거 정확도, 게시 간격 변동계수\n", + " \"media\": ((1000, 4000), (5e4, 5e5), (5, 30), (.6, .9), (.85, .97), (.8, 1.5)),\n", + " \"witness\": ((10, 800), (20, 2000), (.2, 3), (.7, 1.0), (.6, .95), (1.0, 2.0)),\n", + " \"aggregator\": ((200, 2000), (1e4, 2e5), (30, 200), (.05, .2), (.6, .85), (.5, 1.0)),\n", + " \"propaganda\": ((5, 200), (100, 2e4), (20, 150), (.3, .7), (.1, .4), (.05, .3)),\n", + "}\n", + "\n", + "\n", + "def make_accounts(rng, n=(8, 30, 10, 20)):\n", + " acc = {}\n", + " for (t, spec), k in zip(ACCOUNT_TYPES.items(), n):\n", + " for i in range(k):\n", + " v = [rng.uniform(*r) for r in spec]\n", + " acc[f\"ACC_{t[:3].upper()}{i:02d}\"] = {\"type\": t, \"age_days\": int(v[0]), \"followers\": int(v[1]), \"posts_per_day\": round(v[2], 1),\n", + " \"original_ratio\": round(v[3], 2), \"past_accuracy\": round(v[4], 2), \"interval_cv\": round(v[5], 2)}\n", + " return acc\n", + "\n", + "\n", + "def event_image(seed):\n", + " \"\"\"사건 현장 사진을 흉내 낸 32x32 합성 이미지(사건마다 고유한 도형 배치).\"\"\"\n", + " # [TODO : 학생 실습 3] 게시물 유사도 기반 단순 복제본 탐지 및 1차 출처 분류 Baseline 알고리즘 구현 관련 핵심 로직을 구현하세요.\n", + " # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n", + " # <<< 여기에 코드를 작성하세요 >>>\n", + " pass # 작성 후 pass를 제거하세요.\n", + "def variant(img, rng):\n", + " \"\"\"재게시 과정의 자연스러운 변형: 재압축 잡음, 밝기 변화, 가장자리 잘림 중 하나.\"\"\"\n", + " # [TODO : 학생 실습 3] 게시물 유사도 기반 단순 복제본 탐지 및 1차 출처 분류 Baseline 알고리즘 구현 관련 핵심 로직을 구현하세요.\n", + " # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n", + " # <<< 여기에 코드를 작성하세요 >>>\n", + " pass # 작성 후 pass를 제거하세요.\n", + "def splice(img, other, rng):\n", + " out = img.copy()\n", + " y, x = rng.integers(4, 18, 2)\n", + " out[y:y + 12, x:x + 12] = other[y:y + 12, x:x + 12] + rng.normal(0, 12, (12, 12)) # 잡음 수준이 다른 조각을 붙임\n", + " return out\n", + "\n", + "\n", + "def ai_image(rng):\n", + " \"\"\"생성형 모델 이미지를 흉내 낸 합성 이미지: 지나치게 매끈한 바탕 + 업샘플링 격자 무늬(주기 2픽셀).\"\"\"\n", + " base = ndimage.gaussian_filter(rng.normal(130, 60, (32, 32)), 3)\n", + " grid = (np.indices((32, 32)).sum(0) % 2) * rng.uniform(6, 12)\n", + " return base + grid\n", + "\n", + "\n", + "def write(ev, rng, tpl=None, **kw):\n", + " s = {\"r\": ev[\"r\"], \"o\": ev[\"o\"], \"n\": ev[\"n\"], \"d\": ev[\"d\"], **kw}\n", + " return (tpl or TEMPLATES[int(rng.integers(len(TEMPLATES)))]).format(**s)\n", + "\n", + "\n", + "def make_posts(n_events=40, seed=7):\n", + " rng = np.random.default_rng(seed)\n", + " acc = make_accounts(rng)\n", + " by = {t: [a for a, v in acc.items() if v[\"type\"] == t] for t in ACCOUNT_TYPES}\n", + " events = [{\"id\": f\"EV{i:03d}\", \"r\": str(rng.choice(REGIONS)), \"o\": str(rng.choice(OBJECTS)), \"n\": int(rng.integers(2, 15)),\n", + " \"d\": str(rng.choice(DIRS)), \"t0\": float(i * 1.2 + rng.uniform(0, 1)), \"img\": event_image(seed * 1000 + i)} for i in range(n_events)]\n", + " posts = []\n", + "\n", + " def add(ev, account, text, img, t, fwd, kind, origin):\n", + " pid = f\"P{len(posts):04d}\"\n", + " posts.append({\"post_id\": pid, \"platform\": str(rng.choice([\"telegram\", \"x\", \"other\"], p=[.6, .3, .1])), \"account_id\": account,\n", + " \"posted_at\": round(t, 3), \"text\": text, \"image\": img, \"forwarded_from\": fwd, \"event_id\": ev[\"id\"] if ev else None,\n", + " \"kind\": kind, \"is_disinfo\": kind in MANIP, \"origin\": origin})\n", + " return pid\n", + "\n", + " for k, ev in enumerate(events):\n", + " src = str(rng.choice(by[\"witness\"] if rng.random() < .6 else by[\"media\"]))\n", + " if rng.random() < .05:\n", + " src = str(rng.choice(by[\"propaganda\"])) # 신뢰도 낮은 계정도 사실을 올릴 수 있다\n", + " o = add(ev, src, write(ev, rng), ev[\"img\"], ev[\"t0\"], None, \"original\", None)\n", + " for _ in range(int(rng.integers(2, 5))): # 그대로 재게시(전달)\n", + " add(ev, str(rng.choice(by[\"aggregator\"])), posts[int(o[1:])][\"text\"], ev[\"img\"], ev[\"t0\"] + rng.exponential(1), o, \"copy\", o)\n", + " for _ in range(int(rng.integers(1, 3))): # 표현만 바꾼 재작성(내용 동일)\n", + " add(ev, str(rng.choice(by[\"media\"] + by[\"witness\"])), \"#가상속보 \" + write(ev, rng), variant(ev[\"img\"], rng),\n", + " ev[\"t0\"] + rng.uniform(0.5, 6), None, \"near_dup\", o)\n", + " for m in MANIP if k % 2 == 0 else MANIP[k % 4::4]: # 조작·기만 게시물\n", + " if rng.random() > 0.5:\n", + " continue\n", + " who = str(rng.choice(by[\"propaganda\"] if rng.random() < .75 else by[\"aggregator\"] + by[\"witness\"]))\n", + " t, img, text = ev[\"t0\"] + rng.uniform(1, 12), variant(ev[\"img\"], rng), None\n", + " if m == \"num_change\":\n", + " text = write(ev, rng, n=ev[\"n\"] * int(rng.integers(5, 10)))\n", + " elif m == \"region_swap\":\n", + " text = write(ev, rng, r=str(rng.choice([r for r in REGIONS if r != ev[\"r\"]])))\n", + " elif m == \"dir_flip\":\n", + " text = write(ev, rng, d={\"북쪽\": \"남쪽\", \"남쪽\": \"북쪽\", \"동쪽\": \"서쪽\", \"서쪽\": \"동쪽\"}[ev[\"d\"]])\n", + " elif m == \"old_image\" and k > 0:\n", + " text, img = write(ev, rng), events[int(rng.integers(0, k))][\"img\"]\n", + " elif m == \"splice\":\n", + " text, img = write(ev, rng), splice(ev[\"img\"], events[(k + 7) % n_events][\"img\"], rng)\n", + " elif m == \"ai_image\":\n", + " text, img = write(ev, rng), ai_image(rng)\n", + " elif m == \"ai_text\":\n", + " text = f\"속보 속보! {ev['r']} 지역 대규모 {ev['o']} 출현! 긴급 확산 바람! 긴급 확산 바람! 모두 확산 바람!\"\n", + " img = ai_image(rng) if rng.random() < .5 else None\n", + " elif m == \"bot_burst\":\n", + " text = write(ev, rng, n=ev[\"n\"] * 6) + \" 지금 바로 공유\"\n", + " for b in rng.choice(by[\"propaganda\"], 4, replace=False):\n", + " add(ev, str(b), text, img, t + rng.uniform(0, 0.15), None, m, o)\n", + " continue\n", + " elif m == \"mashup\":\n", + " other = events[(k + 3) % n_events]\n", + " text = f\"{ev['r']} 일대 {ev['o']} 확인, {other['n']}대가 {other['d']}으로 이동\" # 다른 사건 내용과 짜깁기\n", + " elif m == \"recycled\" and k > 2:\n", + " old = events[int(rng.integers(0, k - 1))]\n", + " text, img, t = write(old, rng), old[\"img\"], ev[\"t0\"] + rng.uniform(0, 3)\n", + " if text is None:\n", + " continue\n", + " add(ev, who, text, img, t, None, m, o)\n", + " return posts, acc\n", + "# ---------------------------------------------------------------------------\n", + "\n", + "# ---- 텍스트 정제·이미지 지각 해시·조작 흔적 공통 함수 ----\n", + "STOPWORDS = {\"가상\", \"약\", \"일대\", \"지역에서\", \"모습\", \"중\", \"확인됨\", \"발견\", \"제보\", \"주민\"}\n", + "_URL = re.compile(r\"https?://\\S+|www\\.\\S+\")\n", + "_SPECIAL = re.compile(r\"[^0-9A-Za-z가-힣\\-\\s]\")\n", + "\n", + "\n", + "def clean_text(t):\n", + " \"\"\"정규화(NFKC) -> URL·멘션 제거 -> 해시태그 기호 제거 -> 특수문자 제거 -> 불용어 제거 -> 공백 정리.\"\"\"\n", + " t = unicodedata.normalize(\"NFKC\", t)\n", + " t = _URL.sub(\" \", t)\n", + " t = re.sub(r\"@\\w+\", \" \", t).replace(\"#\", \" \")\n", + " t = _SPECIAL.sub(\" \", t)\n", + " return \" \".join(w for w in t.split() if w not in STOPWORDS)\n", + "\n", + "\n", + "def phash(img):\n", + " \"\"\"지각 해시(pHash): 32x32 이미지 DCT의 저주파 8x8(직류 성분 제외)을 중앙값과 비교한 64비트.\"\"\"\n", + " c = dctn(np.asarray(img, float), norm=\"ortho\")[:8, :8].ravel()[1:]\n", + " return np.append(c > np.median(c), False)\n", + "\n", + "\n", + "def hamming(a, b):\n", + " return int(np.count_nonzero(a != b))\n", + "\n", + "\n", + "def slots(text):\n", + " \"\"\"본문에서 지역 코드·수량·방향·대상을 뽑는다(사건 내용 비교용).\"\"\"\n", + " r = re.search(r\"[A-F]-\\d\", text)\n", + " n = re.search(r\"(\\d+)\\s*대|수량\\s*(\\d+)\", text)\n", + " d = next((x for x in DIRS if x in text), None)\n", + " o = next((x for x in OBJECTS if x in text), None)\n", + " return {\"r\": r.group(0) if r else None, \"n\": int(n.group(1) or n.group(2)) if n else None, \"d\": d, \"o\": o}\n", + "\n", + "\n", + "def splice_score(img):\n", + " \"\"\"합성(붙여넣기) 흔적: 8x8 블록별 고주파 잡음 크기의 최대/중앙값 비율. 잡음 수준이 다른 조각이 있으면 커진다.\"\"\"\n", + " res = img - ndimage.median_filter(img, 3)\n", + " b = np.abs(res).reshape(4, 8, 4, 8).std(axis=(1, 3))\n", + " return float(b.max() / (np.median(b) + 1e-6))\n", + "\n", + "\n", + "def ai_image_score(img):\n", + " \"\"\"생성형 이미지 흔적: 2픽셀 주기 격자(나이퀴스트 주파수) 에너지 비율.\"\"\"\n", + " f = np.abs(np.fft.fft2(img - img.mean()))\n", + " return float(f[16, 16] / (f.sum() / f.size + 1e-6))\n", + "\n", + "\n", + "def ai_text_score(text):\n", + " \"\"\"기계적 선동 문체 흔적: 반복 어절 비율 + 느낌표 밀도.\"\"\"\n", + " w = text.split()\n", + " return (1 - len(set(w)) / max(len(w), 1)) + text.count(\"!\") / max(len(w), 1)\n", + "\n", + "\n", + "def relation(sim, dist, R):\n", + " if sim >= R[\"copy_sim\"] and (dist is None or dist <= R[\"copy_ham\"]):\n", + " return \"복제\"\n", + " if sim >= R[\"quote_sim\"] or (dist is not None and dist <= R[\"quote_ham\"]):\n", + " return \"재작성\"\n", + " return \"무관\"\n", + "\n", + "\n", + "R0 = {\"copy_sim\": 0.95, \"copy_ham\": 6, \"quote_sim\": 0.45, \"quote_ham\": 10}\n", + "posts, acc = make_posts(60, seed=11)\n", + "true_posts = [p for p in posts if not p[\"is_disinfo\"]]\n", + "idx = {p[\"post_id\"]: i for i, p in enumerate(posts)}\n", + "X = TfidfVectorizer(analyzer=\"char_wb\", ngram_range=(2, 3)).fit_transform([clean_text(p[\"text\"]) for p in posts])\n", + "H = [phash(p[\"image\"]) if p[\"image\"] is not None else None for p in posts]\n", + "dist = lambda i, j: hamming(H[i], H[j]) if H[i] is not None and H[j] is not None else None\n", + "\n", + "# 1) 평가용 게시물 쌍: 복제(전달), 재작성, 무관(다른 사건)\n", + "rng = np.random.default_rng(0)\n", + "pairs = [(idx[p[\"post_id\"]], idx[p[\"origin\"]], {\"copy\": \"복제\", \"near_dup\": \"재작성\"}[p[\"kind\"]]) for p in true_posts if p[\"kind\"] in (\"copy\", \"near_dup\")]\n", + "while sum(1 for *_, l in pairs if l == \"무관\") < 150:\n", + " i, j = rng.integers(0, len(posts), 2)\n", + " if posts[i][\"event_id\"] != posts[j][\"event_id\"] and not posts[i][\"is_disinfo\"] and not posts[j][\"is_disinfo\"]:\n", + " pairs.append((int(i), int(j), \"무관\"))\n", + "t0 = time.perf_counter()\n", + "sims = [(X[i] @ X[j].T).toarray()[0, 0] for i, j, _ in pairs]\n", + "pred = [relation(s, dist(i, j), R0) for s, (i, j, _) in zip(sims, pairs)]\n", + "lat = (time.perf_counter() - t0) / len(pairs) * 1000\n", + "truth = [l for *_, l in pairs]\n", + "acc0 = np.mean([a == b for a, b in zip(pred, truth)])\n", + "print(f\"게시물 쌍 {len(pairs)}개 관계 판별 정확도 {acc0:.1%} (목표 75% 이상: {'달성' if acc0 >= 0.75 else '미달'}), 쌍당 {lat:.2f} ms\")\n", + "print(pd.crosstab(pd.Series(truth, name=\"실제\"), pd.Series(pred, name=\"판별\")).to_string())\n", + "\n", + "# 2) 휴리스틱 가중치 1차 튜닝: 재작성 판단 유사도 기준\n", + "print(\"\\n재작성 기준(유사도) 정확도\")\n", + "for q in [0.3, 0.45, 0.6, 0.75]:\n", + " pr = [relation(s, dist(i, j), {**R0, \"quote_sim\": q}) for s, (i, j, _) in zip(sims, pairs)]\n", + " print(f\" {q:.2f} {np.mean([a == b for a, b in zip(pr, truth)]):.1%}\")\n", + "\n", + "# 3) 1차 출처 분류: 전달 경로(forwarded_from)를 거슬러 올라가고, 경로가 없으면 같은 이야기 중 가장 이른 게시물을 출처로 본다\n", + "order = sorted(range(len(true_posts)), key=lambda k: true_posts[k][\"posted_at\"])\n", + "tp = [idx[p[\"post_id\"]] for p in true_posts]\n", + "src, label = {}, {}\n", + "for k in order:\n", + " i = tp[k]; p = posts[i]\n", + " if p[\"forwarded_from\"]:\n", + " j = idx[p[\"forwarded_from\"]]\n", + " while posts[j][\"forwarded_from\"]:\n", + " j = idx[posts[j][\"forwarded_from\"]]\n", + " src[i], label[i] = j, \"전달\"\n", + " continue\n", + " cand = [j for j in src if src[j] == j and relation((X[i] @ X[j].T).toarray()[0, 0], dist(i, j), R0) != \"무관\"]\n", + " if cand:\n", + " src[i], label[i] = src[min(cand, key=lambda j: posts[j][\"posted_at\"])], \"재작성\"\n", + " else:\n", + " src[i], label[i] = i, \"1차 출처\"\n", + "true_lab = {\"original\": \"1차 출처\", \"copy\": \"전달\", \"near_dup\": \"재작성\"}\n", + "ok_lab = np.mean([label[i] == true_lab[posts[i][\"kind\"]] for i in tp])\n", + "ok_src = np.mean([posts[src[i]][\"post_id\"] == (posts[i][\"origin\"] or posts[i][\"post_id\"]) for i in tp])\n", + "print(f\"\\n1차 출처 분류(사실 게시물 {len(tp)}건): 유형 정확도 {ok_lab:.1%}, 출처 게시물 일치율 {ok_src:.1%}\")\n", + "print(\"출처 일치율이 낮은 이유: 전달 경로가 없는 재작성 글은 같은 문형을 쓴 다른 사건 글과 혼동된다(본문 유사도만 사용).\")\n", + "print(\"한계: 조작 게시물은 원본과 표현이 비슷해 '재작성'으로 묶이며, 내용 변조 여부는 판단하지 못한다(미션 4의 동기).\")\n", + "\n", + "plt.figure(figsize=(7, 4))\n", + "for lab, c in [(\"복제\", \"tab:red\"), (\"재작성\", \"tab:orange\"), (\"무관\", \"tab:gray\")]:\n", + " sel = [k for k, t in enumerate(truth) if t == lab]\n", + " plt.scatter([sims[k] for k in sel], [dist(*pairs[k][:2]) if dist(*pairs[k][:2]) is not None else -4 for k in sel], s=12, c=c,\n", + " label={\"복제\": \"copy\", \"재작성\": \"rewrite\", \"무관\": \"unrelated\"}[lab])\n", + "plt.axvline(R0[\"quote_sim\"], ls=\"--\", c=\"k\"); plt.axhline(R0[\"quote_ham\"], ls=\":\", c=\"k\")\n", + "plt.xlabel(\"text cosine similarity\"); plt.ylabel(\"pHash distance (-4 = no image)\"); plt.legend(); plt.title(\"Pair relation baseline (synthetic posts)\")\n", + "plt.show()\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 03 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + "게시물 쌍 421개 관계 판별 정확도 98.3% (목표 75% 이상: 달성), 쌍당 0.22 ms\n", + "판별 무관 복제 재작성\n", + "실제 \n", + "무관 143 0 7\n", + "복제 0 178 0\n", + "재작성 0 0 93\n", + "\n", + "재작성 기준(유사도) 정확도\n", + " 0.30 93.3%\n", + " 0.45 98.3%\n", + " 0.60 97.9%\n", + " 0.75 96.9%\n", + "\n", + "1차 출처 분류(사실 게시물 331건): 유형 정확도 85.5%, 출처 게시물 일치율 64.0%\n", + "출처 일치율이 낮은 이유: 전달 경로가 없는 재작성 글은 같은 문형을 쓴 다른 사건 글과 혼동된다(본문 유사도만 사용).\n", + "한계: 조작 게시물은 원본과 표현이 비슷해 '재작성'으로 묶이며, 내용 변조 여부는 판단하지 못한다(미션 4의 동기).\n", + "```\n", + "
\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 04. 생성형 AI 조작 이미지/텍스트 탐지 및 출처 계정 신뢰도 평가 모델 고도화\n", + "**소요시간**: 약 3.0시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "생성형 AI 조작 텍스트/이미지 탐지 알고리즘과 작성자 계정 신뢰도 평가 모델을 결합하여 가짜 뉴스 판별 로직을 고도화한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **생성형 AI 조작 텍스트·이미지 탐지와 작성 계정 신뢰도 평가를 결합해 가짜 정보 판별 로직을 고도화한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **같은 이야기로 묶은 게시물 사이의 수량·지역·방향 불일치, 이미지 합성 잡음 불균일·생성형 격자 무늬·과거 이미지 재사용 흔적, 선동형 반복 문체, 조직적 동시 게시 특징 산출하고 모델에 적용하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `src/models/isr02_advanced_detector.py 및 예외 탐지 테스트 결과` 형태의 결과물을 완성한다.\n", + "\n", + "### 🔍 평가 및 검증 기준 (PASS 조건)\n", + "#### 미션 판정\n", + "\n", + "**평가 구분:** 필수\n", + "\n", + "**핵심 평가영역:** 기만/교란 공격에 대한 방어 로직의 현실성 및 신뢰성\n", + "\n", + "#### 미션 PASS 조건\n", + "\n", + "- 정량 기준: 복합 위협 탐지 재현율(Recall) 85% 이상, 처리 지연 200ms 이하\n", + "\n", + "\n", + "- 진행 기준: 복합 위협 시나리오 10종에 대한 탐지 테스트 통과\n", + "\n", + "\n", + "- 검증 방법: 합성 위협 데이터셋 주입 시 이상치 탐지 정확도 검증\n", + "\n", + "\n", + "#### 채점자 판정 메모\n", + "\n", + "> 단순 코드 실행 여부뿐만 아니라 출력된 수치와 근거 자료가 군 실무 요구사항에 부합하는지 면밀히 확인한다.\n", + "\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "\"\"\"본문 내용 비교(수량·지역·방향 불일치), 이미지 조작 흔적(합성 잡음 불균일, 생성형 격자 무늬, 과거 이미지 재사용), 생성형·선동 문체, 조직적 동시 게시 특징과 작성 계정 신뢰도 모델을 결합해 10종의 조작·기만 게시물을 판별하는 모델로 고도화하고 재현율·오경보·처리 지연을 측정한다.\"\"\"\n", + "import re\n", + "import time\n", + "import unicodedata\n", + "import numpy as np\n", + "import pandas as pd\n", + "import matplotlib.pyplot as plt\n", + "from scipy import ndimage\n", + "from scipy.fft import dctn\n", + "from sklearn.ensemble import RandomForestClassifier\n", + "from sklearn.feature_extraction.text import TfidfVectorizer\n", + "from sklearn.linear_model import LogisticRegression\n", + "from sklearn.pipeline import make_pipeline\n", + "from sklearn.preprocessing import StandardScaler\n", + "\n", + "# ---------------------------------------------------------------------------\n", + "# [교육용가상자료] 합성 SNS 게시물 생성기 (실제 계정·채널·지명·게시물 없음)\n", + "# 가상 사건마다 원본 제보, 재게시(복제), 유사 재작성, 조작·기만 게시물 10종과 32x32 합성 이미지를 만든다.\n", + "# ---------------------------------------------------------------------------\n", + "REGIONS = [f\"{a}-{n}\" for a in \"ABCDEF\" for n in range(1, 6)]\n", + "OBJECTS = [\"군용 차량\", \"소형 드론\", \"보급 트럭\", \"헬기\", \"버스\", \"견인 차량\", \"트레일러\", \"민간 승용차\"]\n", + "DIRS = [\"북쪽\", \"남쪽\", \"동쪽\", \"서쪽\"]\n", + "TEMPLATES = [\"가상 {r} 지역에서 {o} 목격, {n}대 규모로 {d}으로 이동 중\",\n", + " \"{r} 일대 {o} 확인됨. 약 {n}대, {d} 방향으로 이동\",\n", + " \"[현지] {r}에서 {o} 발견 - 수량 {n}, 이동 방향 {d}\",\n", + " \"{r} 주민 제보: {o} {n}대가 {d}으로 향하는 모습\"]\n", + "MANIP = [\"num_change\", \"region_swap\", \"dir_flip\", \"old_image\", \"splice\", \"ai_image\", \"ai_text\", \"bot_burst\", \"mashup\", \"recycled\"]\n", + "ACCOUNT_TYPES = { # 유형별 계정 속성 분포(가상): 나이(일), 팔로워, 하루 게시 수, 원본 비율, 과거 정확도, 게시 간격 변동계수\n", + " \"media\": ((1000, 4000), (5e4, 5e5), (5, 30), (.6, .9), (.85, .97), (.8, 1.5)),\n", + " \"witness\": ((10, 800), (20, 2000), (.2, 3), (.7, 1.0), (.6, .95), (1.0, 2.0)),\n", + " \"aggregator\": ((200, 2000), (1e4, 2e5), (30, 200), (.05, .2), (.6, .85), (.5, 1.0)),\n", + " \"propaganda\": ((5, 200), (100, 2e4), (20, 150), (.3, .7), (.1, .4), (.05, .3)),\n", + "}\n", + "\n", + "\n", + "def make_accounts(rng, n=(8, 30, 10, 20)):\n", + " acc = {}\n", + " for (t, spec), k in zip(ACCOUNT_TYPES.items(), n):\n", + " for i in range(k):\n", + " v = [rng.uniform(*r) for r in spec]\n", + " acc[f\"ACC_{t[:3].upper()}{i:02d}\"] = {\"type\": t, \"age_days\": int(v[0]), \"followers\": int(v[1]), \"posts_per_day\": round(v[2], 1),\n", + " \"original_ratio\": round(v[3], 2), \"past_accuracy\": round(v[4], 2), \"interval_cv\": round(v[5], 2)}\n", + " return acc\n", + "\n", + "\n", + "def event_image(seed):\n", + " \"\"\"사건 현장 사진을 흉내 낸 32x32 합성 이미지(사건마다 고유한 도형 배치).\"\"\"\n", + " # [TODO : 학생 실습 4] 생성형 AI 조작 이미지/텍스트 탐지 및 출처 계정 신뢰도 평가 모델 고도화 관련 핵심 로직을 구현하세요.\n", + " # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n", + " # <<< 여기에 코드를 작성하세요 >>>\n", + " pass # 작성 후 pass를 제거하세요.\n", + "def variant(img, rng):\n", + " \"\"\"재게시 과정의 자연스러운 변형: 재압축 잡음, 밝기 변화, 가장자리 잘림 중 하나.\"\"\"\n", + " # [TODO : 학생 실습 4] 생성형 AI 조작 이미지/텍스트 탐지 및 출처 계정 신뢰도 평가 모델 고도화 관련 핵심 로직을 구현하세요.\n", + " # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n", + " # <<< 여기에 코드를 작성하세요 >>>\n", + " pass # 작성 후 pass를 제거하세요.\n", + "def splice(img, other, rng):\n", + " out = img.copy()\n", + " y, x = rng.integers(4, 18, 2)\n", + " out[y:y + 12, x:x + 12] = other[y:y + 12, x:x + 12] + rng.normal(0, 12, (12, 12)) # 잡음 수준이 다른 조각을 붙임\n", + " return out\n", + "\n", + "\n", + "def ai_image(rng):\n", + " \"\"\"생성형 모델 이미지를 흉내 낸 합성 이미지: 지나치게 매끈한 바탕 + 업샘플링 격자 무늬(주기 2픽셀).\"\"\"\n", + " base = ndimage.gaussian_filter(rng.normal(130, 60, (32, 32)), 3)\n", + " grid = (np.indices((32, 32)).sum(0) % 2) * rng.uniform(6, 12)\n", + " return base + grid\n", + "\n", + "\n", + "def write(ev, rng, tpl=None, **kw):\n", + " s = {\"r\": ev[\"r\"], \"o\": ev[\"o\"], \"n\": ev[\"n\"], \"d\": ev[\"d\"], **kw}\n", + " return (tpl or TEMPLATES[int(rng.integers(len(TEMPLATES)))]).format(**s)\n", + "\n", + "\n", + "def make_posts(n_events=40, seed=7):\n", + " rng = np.random.default_rng(seed)\n", + " acc = make_accounts(rng)\n", + " by = {t: [a for a, v in acc.items() if v[\"type\"] == t] for t in ACCOUNT_TYPES}\n", + " events = [{\"id\": f\"EV{i:03d}\", \"r\": str(rng.choice(REGIONS)), \"o\": str(rng.choice(OBJECTS)), \"n\": int(rng.integers(2, 15)),\n", + " \"d\": str(rng.choice(DIRS)), \"t0\": float(i * 1.2 + rng.uniform(0, 1)), \"img\": event_image(seed * 1000 + i)} for i in range(n_events)]\n", + " posts = []\n", + "\n", + " def add(ev, account, text, img, t, fwd, kind, origin):\n", + " pid = f\"P{len(posts):04d}\"\n", + " posts.append({\"post_id\": pid, \"platform\": str(rng.choice([\"telegram\", \"x\", \"other\"], p=[.6, .3, .1])), \"account_id\": account,\n", + " \"posted_at\": round(t, 3), \"text\": text, \"image\": img, \"forwarded_from\": fwd, \"event_id\": ev[\"id\"] if ev else None,\n", + " \"kind\": kind, \"is_disinfo\": kind in MANIP, \"origin\": origin})\n", + " return pid\n", + "\n", + " for k, ev in enumerate(events):\n", + " src = str(rng.choice(by[\"witness\"] if rng.random() < .6 else by[\"media\"]))\n", + " if rng.random() < .05:\n", + " src = str(rng.choice(by[\"propaganda\"])) # 신뢰도 낮은 계정도 사실을 올릴 수 있다\n", + " o = add(ev, src, write(ev, rng), ev[\"img\"], ev[\"t0\"], None, \"original\", None)\n", + " for _ in range(int(rng.integers(2, 5))): # 그대로 재게시(전달)\n", + " add(ev, str(rng.choice(by[\"aggregator\"])), posts[int(o[1:])][\"text\"], ev[\"img\"], ev[\"t0\"] + rng.exponential(1), o, \"copy\", o)\n", + " for _ in range(int(rng.integers(1, 3))): # 표현만 바꾼 재작성(내용 동일)\n", + " add(ev, str(rng.choice(by[\"media\"] + by[\"witness\"])), \"#가상속보 \" + write(ev, rng), variant(ev[\"img\"], rng),\n", + " ev[\"t0\"] + rng.uniform(0.5, 6), None, \"near_dup\", o)\n", + " for m in MANIP if k % 2 == 0 else MANIP[k % 4::4]: # 조작·기만 게시물\n", + " if rng.random() > 0.5:\n", + " continue\n", + " who = str(rng.choice(by[\"propaganda\"] if rng.random() < .75 else by[\"aggregator\"] + by[\"witness\"]))\n", + " t, img, text = ev[\"t0\"] + rng.uniform(1, 12), variant(ev[\"img\"], rng), None\n", + " if m == \"num_change\":\n", + " text = write(ev, rng, n=ev[\"n\"] * int(rng.integers(5, 10)))\n", + " elif m == \"region_swap\":\n", + " text = write(ev, rng, r=str(rng.choice([r for r in REGIONS if r != ev[\"r\"]])))\n", + " elif m == \"dir_flip\":\n", + " text = write(ev, rng, d={\"북쪽\": \"남쪽\", \"남쪽\": \"북쪽\", \"동쪽\": \"서쪽\", \"서쪽\": \"동쪽\"}[ev[\"d\"]])\n", + " elif m == \"old_image\" and k > 0:\n", + " text, img = write(ev, rng), events[int(rng.integers(0, k))][\"img\"]\n", + " elif m == \"splice\":\n", + " text, img = write(ev, rng), splice(ev[\"img\"], events[(k + 7) % n_events][\"img\"], rng)\n", + " elif m == \"ai_image\":\n", + " text, img = write(ev, rng), ai_image(rng)\n", + " elif m == \"ai_text\":\n", + " text = f\"속보 속보! {ev['r']} 지역 대규모 {ev['o']} 출현! 긴급 확산 바람! 긴급 확산 바람! 모두 확산 바람!\"\n", + " img = ai_image(rng) if rng.random() < .5 else None\n", + " elif m == \"bot_burst\":\n", + " text = write(ev, rng, n=ev[\"n\"] * 6) + \" 지금 바로 공유\"\n", + " for b in rng.choice(by[\"propaganda\"], 4, replace=False):\n", + " add(ev, str(b), text, img, t + rng.uniform(0, 0.15), None, m, o)\n", + " continue\n", + " elif m == \"mashup\":\n", + " other = events[(k + 3) % n_events]\n", + " text = f\"{ev['r']} 일대 {ev['o']} 확인, {other['n']}대가 {other['d']}으로 이동\" # 다른 사건 내용과 짜깁기\n", + " elif m == \"recycled\" and k > 2:\n", + " old = events[int(rng.integers(0, k - 1))]\n", + " text, img, t = write(old, rng), old[\"img\"], ev[\"t0\"] + rng.uniform(0, 3)\n", + " if text is None:\n", + " continue\n", + " add(ev, who, text, img, t, None, m, o)\n", + " return posts, acc\n", + "# ---------------------------------------------------------------------------\n", + "\n", + "# ---- 텍스트 정제·이미지 지각 해시·조작 흔적 공통 함수 ----\n", + "STOPWORDS = {\"가상\", \"약\", \"일대\", \"지역에서\", \"모습\", \"중\", \"확인됨\", \"발견\", \"제보\", \"주민\"}\n", + "_URL = re.compile(r\"https?://\\S+|www\\.\\S+\")\n", + "_SPECIAL = re.compile(r\"[^0-9A-Za-z가-힣\\-\\s]\")\n", + "\n", + "\n", + "def clean_text(t):\n", + " \"\"\"정규화(NFKC) -> URL·멘션 제거 -> 해시태그 기호 제거 -> 특수문자 제거 -> 불용어 제거 -> 공백 정리.\"\"\"\n", + " t = unicodedata.normalize(\"NFKC\", t)\n", + " t = _URL.sub(\" \", t)\n", + " t = re.sub(r\"@\\w+\", \" \", t).replace(\"#\", \" \")\n", + " t = _SPECIAL.sub(\" \", t)\n", + " return \" \".join(w for w in t.split() if w not in STOPWORDS)\n", + "\n", + "\n", + "def phash(img):\n", + " \"\"\"지각 해시(pHash): 32x32 이미지 DCT의 저주파 8x8(직류 성분 제외)을 중앙값과 비교한 64비트.\"\"\"\n", + " c = dctn(np.asarray(img, float), norm=\"ortho\")[:8, :8].ravel()[1:]\n", + " return np.append(c > np.median(c), False)\n", + "\n", + "\n", + "def hamming(a, b):\n", + " return int(np.count_nonzero(a != b))\n", + "\n", + "\n", + "def slots(text):\n", + " \"\"\"본문에서 지역 코드·수량·방향·대상을 뽑는다(사건 내용 비교용).\"\"\"\n", + " r = re.search(r\"[A-F]-\\d\", text)\n", + " n = re.search(r\"(\\d+)\\s*대|수량\\s*(\\d+)\", text)\n", + " d = next((x for x in DIRS if x in text), None)\n", + " o = next((x for x in OBJECTS if x in text), None)\n", + " return {\"r\": r.group(0) if r else None, \"n\": int(n.group(1) or n.group(2)) if n else None, \"d\": d, \"o\": o}\n", + "\n", + "\n", + "def splice_score(img):\n", + " \"\"\"합성(붙여넣기) 흔적: 8x8 블록별 고주파 잡음 크기의 최대/중앙값 비율. 잡음 수준이 다른 조각이 있으면 커진다.\"\"\"\n", + " res = img - ndimage.median_filter(img, 3)\n", + " b = np.abs(res).reshape(4, 8, 4, 8).std(axis=(1, 3))\n", + " return float(b.max() / (np.median(b) + 1e-6))\n", + "\n", + "\n", + "def ai_image_score(img):\n", + " \"\"\"생성형 이미지 흔적: 2픽셀 주기 격자(나이퀴스트 주파수) 에너지 비율.\"\"\"\n", + " f = np.abs(np.fft.fft2(img - img.mean()))\n", + " return float(f[16, 16] / (f.sum() / f.size + 1e-6))\n", + "\n", + "\n", + "def ai_text_score(text):\n", + " \"\"\"기계적 선동 문체 흔적: 반복 어절 비율 + 느낌표 밀도.\"\"\"\n", + " w = text.split()\n", + " return (1 - len(set(w)) / max(len(w), 1)) + text.count(\"!\") / max(len(w), 1)\n", + "\n", + "# ---- 게시물 군집(같은 이야기 묶기)·조작 탐지 특징 공통 함수 ----\n", + "FEAT = [\"num_dev\", \"region_mis\", \"dir_mis\", \"obj_mis\", \"img_ref_dist\", \"img_prior_h\", \"time_gap_h\", \"splice\", \"ai_img\",\n", + " \"ai_text\", \"burst_n\", \"has_img\", \"is_fwd\", \"acc_age\", \"acc_followers\", \"acc_orig\", \"acc_past\", \"acc_cv\"]\n", + "\n", + "\n", + "def cluster_posts(posts, hours=24, ham_thr=8):\n", + " \"\"\"같은 이야기 묶기: 본문에서 뽑은 (지역, 대상)이 같고 24시간 안이면 연결하고,\n", + " 지각 해시가 가까운 이미지(같은 대상)도 연결한다. 글 표현이 달라도 내용 단위로 묶인다.\"\"\"\n", + " texts = [clean_text(p[\"text\"]) for p in posts]\n", + " X = TfidfVectorizer(analyzer=\"char_wb\", ngram_range=(2, 3)).fit_transform(texts)\n", + " S = (X @ X.T).toarray()\n", + " H = [phash(p[\"image\"]) if p[\"image\"] is not None else None for p in posts]\n", + " sl = [slots(p[\"text\"]) for p in posts]\n", + " t = [p[\"posted_at\"] for p in posts]\n", + " parent = list(range(len(posts)))\n", + "\n", + " def find(i):\n", + " while parent[i] != i:\n", + " parent[i] = parent[parent[i]]; i = parent[i]\n", + " return i\n", + " for i in range(len(posts)):\n", + " for j in range(i + 1, len(posts)):\n", + " if abs(t[i] - t[j]) >= hours or sl[i][\"o\"] != sl[j][\"o\"]:\n", + " continue\n", + " same_story = sl[i][\"r\"] is not None and sl[i][\"r\"] == sl[j][\"r\"]\n", + " same_img = H[i] is not None and H[j] is not None and hamming(H[i], H[j]) <= ham_thr\n", + " if same_story or same_img:\n", + " parent[find(i)] = find(j)\n", + " return np.array([find(i) for i in range(len(posts))]), S, H\n", + "\n", + "\n", + "def post_features(posts, acc):\n", + " cid, S, H = cluster_posts(posts)\n", + " t = np.array([p[\"posted_at\"] for p in posts])\n", + " sl = [slots(p[\"text\"]) for p in posts]\n", + " first_seen = {}\n", + " for i in np.argsort(t): # 이미지가 처음 등장한 시각과 군집\n", + " if H[i] is None:\n", + " continue\n", + " key = next((k for k in first_seen if hamming(np.array(k), H[i]) <= 6), None)\n", + " if key is None:\n", + " first_seen[tuple(H[i])] = (t[i], cid[i])\n", + " elif first_seen[key][1] != cid[i]:\n", + " pass\n", + " ref = {c: int(np.argmin(np.where(cid == c, t, np.inf))) for c in set(cid)} # 군집에서 가장 이른 게시물 = 기준(추정 원본)\n", + " F = []\n", + " for i, p in enumerate(posts):\n", + " r = ref[cid[i]]\n", + " a, b = sl[i], sl[r]\n", + " img_prior = 0.0\n", + " if H[i] is not None:\n", + " for k, (ts, c) in first_seen.items():\n", + " if hamming(np.array(k), H[i]) <= 6 and c != cid[i]:\n", + " img_prior = max(img_prior, min(t[i] - ts, 48.0))\n", + " same_txt = [j for j in np.where(S[i] > 0.97)[0] if abs(t[j] - t[i]) < 0.25 and posts[j][\"account_id\"] != p[\"account_id\"]]\n", + " A = acc[p[\"account_id\"]]\n", + " F.append([abs(np.log((a[\"n\"] or 1) / (b[\"n\"] or 1))), float(a[\"r\"] != b[\"r\"]), float(a[\"d\"] != b[\"d\"]), float(a[\"o\"] != b[\"o\"]),\n", + " hamming(H[i], H[r]) if H[i] is not None and H[r] is not None else 0, img_prior, t[i] - t[r],\n", + " splice_score(p[\"image\"]) if p[\"image\"] is not None else 0, ai_image_score(p[\"image\"]) if p[\"image\"] is not None else 0,\n", + " ai_text_score(p[\"text\"]), len(set(posts[j][\"account_id\"] for j in same_txt)), float(p[\"image\"] is not None),\n", + " float(p[\"forwarded_from\"] is not None), np.log1p(A[\"age_days\"]), np.log1p(A[\"followers\"]), A[\"original_ratio\"],\n", + " A[\"past_accuracy\"], A[\"interval_cv\"]])\n", + " return np.array(F), cid\n", + "\n", + "CONTENT, ACCOUNT = list(range(13)), list(range(13, 18))\n", + "\n", + "# 1) 학습: 가상 게시물(사건 60개) / 시험: 다른 난수의 가상 게시물(사건 60개)\n", + "tr_posts, tr_acc = make_posts(60, seed=7)\n", + "Ftr, _ = post_features(tr_posts, tr_acc)\n", + "ytr = np.array([p[\"is_disinfo\"] for p in tr_posts])\n", + "te_posts, te_acc = make_posts(60, seed=11)\n", + "t0 = time.perf_counter()\n", + "Fte, cid = post_features(te_posts, te_acc)\n", + "yte, kind = np.array([p[\"is_disinfo\"] for p in te_posts]), np.array([p[\"kind\"] for p in te_posts])\n", + "\n", + "# 계정 신뢰도 모델: 계정 속성만으로 '조작 게시물을 올릴 가능성'을 추정(작성자 평판)\n", + "acc_model = make_pipeline(StandardScaler(), LogisticRegression(max_iter=2000)).fit(Ftr[:, ACCOUNT], ytr)\n", + "Xtr = np.c_[Ftr[:, CONTENT], acc_model.predict_proba(Ftr[:, ACCOUNT])[:, 1]]\n", + "model = RandomForestClassifier(n_estimators=300, min_samples_leaf=2, random_state=0).fit(Xtr, ytr)\n", + "Xte = np.c_[Fte[:, CONTENT], acc_model.predict_proba(Fte[:, ACCOUNT])[:, 1]]\n", + "pred = model.predict(Xte)\n", + "lat = (time.perf_counter() - t0) / len(te_posts) * 1000\n", + "\n", + "content_only = RandomForestClassifier(n_estimators=300, min_samples_leaf=2, random_state=0).fit(Ftr[:, CONTENT], ytr).predict(Fte[:, CONTENT])\n", + "account_only = acc_model.predict(Fte[:, ACCOUNT])\n", + "print(\"방식 조작 재현율 사실 게시물 오경보율\")\n", + "for name, p in [(\"계정 신뢰도만\", account_only), (\"내용·이미지 탐지만\", content_only), (\"결합 모델\", pred)]:\n", + " print(f\"{name:12s} {(p & yte).sum() / yte.sum():10.1%} {(p & ~yte).sum() / (~yte).sum():14.1%}\")\n", + "rec = (pred & yte).sum() / yte.sum()\n", + "print(f\"\\n복합 위협(조작·기만) 탐지 재현율 {rec:.1%} (목표 85% 이상: {'달성' if rec >= 0.85 else '미달'}), 게시물당 처리 {lat:.2f} ms (목표 200 ms 이하)\")\n", + "\n", + "# 2) 조작·기만 시나리오 10종 시험(재현율 80% 이상이면 통과)\n", + "rows = [{\"유형\": m, \"건수\": int((kind == m).sum()), \"재현율\": round(float(pred[kind == m].mean()), 2),\n", + " \"판정\": \"통과\" if pred[kind == m].mean() >= 0.8 else \"미통과\"} for m in MANIP]\n", + "t = pd.DataFrame(rows)\n", + "print(\"\\n\" + t.to_string(index=False))\n", + "print(f\"통과 {int((t['판정'] == '통과').sum())}/10종\")\n", + "\n", + "# 3) 오경보 유발 패턴: 사실 게시물인데 조작으로 본 사례\n", + "fa = np.where(pred & ~yte)[0]\n", + "print(f\"\\n[사실 게시물 오경보 {len(fa)}건] 유형:\", pd.Series(kind[fa]).value_counts().to_dict(),\n", + " \"/ 작성 계정 유형:\", pd.Series([te_acc[te_posts[i][\"account_id\"]][\"type\"] for i in fa]).value_counts().to_dict())\n", + "imp = pd.Series(model.feature_importances_, index=[FEAT[i] for i in CONTENT] + [\"account_risk\"]).sort_values(ascending=False)\n", + "print(\"중요 특징 상위 5:\", {k: round(v, 3) for k, v in imp.head(5).items()})\n", + "print(\"-> 신생·소규모 계정의 사실 제보와 늦게 올라온 재작성 글이 오경보를 만든다. 계정 평판에 과도하게 기대지 않도록 특징 공간을 재설계(미션 5)한다.\")\n", + "\n", + "fig, ax = plt.subplots(1, 2, figsize=(12, 4))\n", + "ax[0].barh(t[\"유형\"], t[\"재현율\"], color=[\"tab:green\" if v >= 0.8 else \"tab:red\" for v in t[\"재현율\"]]); ax[0].axvline(0.8, ls=\"--\", c=\"k\")\n", + "ax[0].set_title(\"Recall by manipulation type\"); ax[0].set_xlim(0, 1.05)\n", + "imp.head(10)[::-1].plot.barh(ax=ax[1]); ax[1].set_title(\"Feature importance\")\n", + "plt.tight_layout(); plt.show()\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 04 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + "방식 조작 재현율 사실 게시물 오경보율\n", + "계정 신뢰도만 88.3% 5.7%\n", + "내용·이미지 탐지만 94.1% 14.5%\n", + "결합 모델 97.3% 3.6%\n", + "\n", + "복합 위협(조작·기만) 탐지 재현율 97.3% (목표 85% 이상: 달성), 게시물당 처리 3.14 ms (목표 200 ms 이하)\n", + "\n", + " 유형 건수 재현율 판정\n", + " num_change 13 1.00 통과\n", + "region_swap 24 0.88 통과\n", + " dir_flip 19 0.95 통과\n", + " old_image 29 1.00 통과\n", + " splice 14 0.93 통과\n", + " ai_image 16 1.00 통과\n", + " ai_text 13 1.00 통과\n", + " bot_burst 96 1.00 통과\n", + " mashup 12 1.00 통과\n", + " recycled 20 0.90 통과\n", + "통과 10/10종\n", + "\n", + "[사실 게시물 오경보 12건] 유형: {'near_dup': 9, 'original': 3} / 작성 계정 유형: {'witness': 11, 'propaganda': 1}\n", + "중요 특징 상위 5: {'account_risk': 0.394, 'time_gap_h': 0.162, 'num_dev': 0.107, 'is_fwd': 0.102, 'img_ref_dist': 0.054}\n", + "-> 신생·소규모 계정의 사실 제보와 늦게 올라온 재작성 글이 오경보를 만든다. 계정 평판에 과도하게 기대지 않도록 특징 공간을 재설계(미션 5)한다.\n", + "```\n", + "
\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 05. 가짜 정보(Disinformation) 오분류 방지 및 복합 신뢰도 지수 산출 엔진 개발\n", + "**소요시간**: 약 3.0시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "전쟁 심리전(심리 공작) 게시물의 유포 패턴을 분석하고 신뢰도 등급(A~E) 및 종합 신뢰도 점수를 산출하는 엔진을 개발한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **심리전 게시물의 유포 패턴을 반영해 신뢰도 등급(A~E)과 종합 신뢰도 지수를 산출하고, 사실 게시물을 가짜로 오분류하지 않도록 하는 엔진을 개발한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **내용 무결성, 계정 평판, 독립 출처 교차확인, 유포 패턴 정상 여부, 시의성 5개 요소를 가중치 매트릭스로 합산해 종합 지수를 만들고 A~E 등급(A·B=High, C=Medium, D·E=Low)을 부여 수행하고 검증하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `src/verification/isr02_confidence_engine.py` 형태의 결과물을 완성한다.\n", + "\n", + "### 🔍 평가 및 검증 기준 (PASS 조건)\n", + "#### 미션 판정\n", + "\n", + "**평가 구분:** 필수\n", + "\n", + "**핵심 평가영역:** 신뢰도 등급(High/Med/Low) 부여 기준의 군사적 타당성\n", + "\n", + "#### 미션 PASS 조건\n", + "\n", + "- 정량 기준: 오경보율(FAR) 40% 이상 감축, 신뢰도 스코어 산출 일관성 90% 이상\n", + "\n", + "\n", + "- 진행 기준: 오경보 필터링 전후 비교 지표 산출 및 통합 테스트 완료\n", + "\n", + "\n", + "- 검증 방법: ROC-AUC 곡선 분석 및 임계값 민감도 테스트\n", + "\n", + "\n", + "#### 채점자 판정 메모\n", + "\n", + "> 단순 코드 실행 여부뿐만 아니라 출력된 수치와 근거 자료가 군 실무 요구사항에 부합하는지 면밀히 확인한다.\n", + "\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "\"\"\"심리전 게시물의 유포 패턴(조직적 동시 게시), 내용 조작 확률, 작성 계정 평판, 독립 출처 교차 확인, 시의성을 가중 합산해 종합 신뢰도 지수와 신뢰도 등급(A~E)을 산출하고, 계정 속성만으로 가짜를 단정하던 규칙 대비 사실 게시물 오분류(오경보) 감축과 등급 일관성을 검증한다.\"\"\"\n", + "import re\n", + "import unicodedata\n", + "import numpy as np\n", + "import pandas as pd\n", + "import matplotlib.pyplot as plt\n", + "from scipy import ndimage\n", + "from scipy.fft import dctn\n", + "from sklearn.ensemble import RandomForestClassifier\n", + "from sklearn.feature_extraction.text import TfidfVectorizer\n", + "from sklearn.linear_model import LogisticRegression\n", + "from sklearn.metrics import roc_auc_score\n", + "from sklearn.pipeline import make_pipeline\n", + "from sklearn.preprocessing import StandardScaler\n", + "\n", + "# ---------------------------------------------------------------------------\n", + "# [교육용가상자료] 합성 SNS 게시물 생성기 (실제 계정·채널·지명·게시물 없음)\n", + "# 가상 사건마다 원본 제보, 재게시(복제), 유사 재작성, 조작·기만 게시물 10종과 32x32 합성 이미지를 만든다.\n", + "# ---------------------------------------------------------------------------\n", + "REGIONS = [f\"{a}-{n}\" for a in \"ABCDEF\" for n in range(1, 6)]\n", + "OBJECTS = [\"군용 차량\", \"소형 드론\", \"보급 트럭\", \"헬기\", \"버스\", \"견인 차량\", \"트레일러\", \"민간 승용차\"]\n", + "DIRS = [\"북쪽\", \"남쪽\", \"동쪽\", \"서쪽\"]\n", + "TEMPLATES = [\"가상 {r} 지역에서 {o} 목격, {n}대 규모로 {d}으로 이동 중\",\n", + " \"{r} 일대 {o} 확인됨. 약 {n}대, {d} 방향으로 이동\",\n", + " \"[현지] {r}에서 {o} 발견 - 수량 {n}, 이동 방향 {d}\",\n", + " \"{r} 주민 제보: {o} {n}대가 {d}으로 향하는 모습\"]\n", + "MANIP = [\"num_change\", \"region_swap\", \"dir_flip\", \"old_image\", \"splice\", \"ai_image\", \"ai_text\", \"bot_burst\", \"mashup\", \"recycled\"]\n", + "ACCOUNT_TYPES = { # 유형별 계정 속성 분포(가상): 나이(일), 팔로워, 하루 게시 수, 원본 비율, 과거 정확도, 게시 간격 변동계수\n", + " \"media\": ((1000, 4000), (5e4, 5e5), (5, 30), (.6, .9), (.85, .97), (.8, 1.5)),\n", + " \"witness\": ((10, 800), (20, 2000), (.2, 3), (.7, 1.0), (.6, .95), (1.0, 2.0)),\n", + " \"aggregator\": ((200, 2000), (1e4, 2e5), (30, 200), (.05, .2), (.6, .85), (.5, 1.0)),\n", + " \"propaganda\": ((5, 200), (100, 2e4), (20, 150), (.3, .7), (.1, .4), (.05, .3)),\n", + "}\n", + "\n", + "\n", + "def make_accounts(rng, n=(8, 30, 10, 20)):\n", + " acc = {}\n", + " for (t, spec), k in zip(ACCOUNT_TYPES.items(), n):\n", + " for i in range(k):\n", + " v = [rng.uniform(*r) for r in spec]\n", + " acc[f\"ACC_{t[:3].upper()}{i:02d}\"] = {\"type\": t, \"age_days\": int(v[0]), \"followers\": int(v[1]), \"posts_per_day\": round(v[2], 1),\n", + " \"original_ratio\": round(v[3], 2), \"past_accuracy\": round(v[4], 2), \"interval_cv\": round(v[5], 2)}\n", + " return acc\n", + "\n", + "\n", + "def event_image(seed):\n", + " \"\"\"사건 현장 사진을 흉내 낸 32x32 합성 이미지(사건마다 고유한 도형 배치).\"\"\"\n", + " # [TODO : 학생 실습 5] 가짜 정보(Disinformation) 오분류 방지 및 복합 신뢰도 지수 산출 엔진 개발 관련 핵심 로직을 구현하세요.\n", + " # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n", + " # <<< 여기에 코드를 작성하세요 >>>\n", + " pass # 작성 후 pass를 제거하세요.\n", + "def variant(img, rng):\n", + " \"\"\"재게시 과정의 자연스러운 변형: 재압축 잡음, 밝기 변화, 가장자리 잘림 중 하나.\"\"\"\n", + " # [TODO : 학생 실습 5] 가짜 정보(Disinformation) 오분류 방지 및 복합 신뢰도 지수 산출 엔진 개발 관련 핵심 로직을 구현하세요.\n", + " # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n", + " # <<< 여기에 코드를 작성하세요 >>>\n", + " pass # 작성 후 pass를 제거하세요.\n", + "def splice(img, other, rng):\n", + " out = img.copy()\n", + " y, x = rng.integers(4, 18, 2)\n", + " out[y:y + 12, x:x + 12] = other[y:y + 12, x:x + 12] + rng.normal(0, 12, (12, 12)) # 잡음 수준이 다른 조각을 붙임\n", + " return out\n", + "\n", + "\n", + "def ai_image(rng):\n", + " \"\"\"생성형 모델 이미지를 흉내 낸 합성 이미지: 지나치게 매끈한 바탕 + 업샘플링 격자 무늬(주기 2픽셀).\"\"\"\n", + " base = ndimage.gaussian_filter(rng.normal(130, 60, (32, 32)), 3)\n", + " grid = (np.indices((32, 32)).sum(0) % 2) * rng.uniform(6, 12)\n", + " return base + grid\n", + "\n", + "\n", + "def write(ev, rng, tpl=None, **kw):\n", + " s = {\"r\": ev[\"r\"], \"o\": ev[\"o\"], \"n\": ev[\"n\"], \"d\": ev[\"d\"], **kw}\n", + " return (tpl or TEMPLATES[int(rng.integers(len(TEMPLATES)))]).format(**s)\n", + "\n", + "\n", + "def make_posts(n_events=40, seed=7):\n", + " rng = np.random.default_rng(seed)\n", + " acc = make_accounts(rng)\n", + " by = {t: [a for a, v in acc.items() if v[\"type\"] == t] for t in ACCOUNT_TYPES}\n", + " events = [{\"id\": f\"EV{i:03d}\", \"r\": str(rng.choice(REGIONS)), \"o\": str(rng.choice(OBJECTS)), \"n\": int(rng.integers(2, 15)),\n", + " \"d\": str(rng.choice(DIRS)), \"t0\": float(i * 1.2 + rng.uniform(0, 1)), \"img\": event_image(seed * 1000 + i)} for i in range(n_events)]\n", + " posts = []\n", + "\n", + " def add(ev, account, text, img, t, fwd, kind, origin):\n", + " pid = f\"P{len(posts):04d}\"\n", + " posts.append({\"post_id\": pid, \"platform\": str(rng.choice([\"telegram\", \"x\", \"other\"], p=[.6, .3, .1])), \"account_id\": account,\n", + " \"posted_at\": round(t, 3), \"text\": text, \"image\": img, \"forwarded_from\": fwd, \"event_id\": ev[\"id\"] if ev else None,\n", + " \"kind\": kind, \"is_disinfo\": kind in MANIP, \"origin\": origin})\n", + " return pid\n", + "\n", + " for k, ev in enumerate(events):\n", + " src = str(rng.choice(by[\"witness\"] if rng.random() < .6 else by[\"media\"]))\n", + " if rng.random() < .05:\n", + " src = str(rng.choice(by[\"propaganda\"])) # 신뢰도 낮은 계정도 사실을 올릴 수 있다\n", + " o = add(ev, src, write(ev, rng), ev[\"img\"], ev[\"t0\"], None, \"original\", None)\n", + " for _ in range(int(rng.integers(2, 5))): # 그대로 재게시(전달)\n", + " add(ev, str(rng.choice(by[\"aggregator\"])), posts[int(o[1:])][\"text\"], ev[\"img\"], ev[\"t0\"] + rng.exponential(1), o, \"copy\", o)\n", + " for _ in range(int(rng.integers(1, 3))): # 표현만 바꾼 재작성(내용 동일)\n", + " add(ev, str(rng.choice(by[\"media\"] + by[\"witness\"])), \"#가상속보 \" + write(ev, rng), variant(ev[\"img\"], rng),\n", + " ev[\"t0\"] + rng.uniform(0.5, 6), None, \"near_dup\", o)\n", + " for m in MANIP if k % 2 == 0 else MANIP[k % 4::4]: # 조작·기만 게시물\n", + " if rng.random() > 0.5:\n", + " continue\n", + " who = str(rng.choice(by[\"propaganda\"] if rng.random() < .75 else by[\"aggregator\"] + by[\"witness\"]))\n", + " t, img, text = ev[\"t0\"] + rng.uniform(1, 12), variant(ev[\"img\"], rng), None\n", + " if m == \"num_change\":\n", + " text = write(ev, rng, n=ev[\"n\"] * int(rng.integers(5, 10)))\n", + " elif m == \"region_swap\":\n", + " text = write(ev, rng, r=str(rng.choice([r for r in REGIONS if r != ev[\"r\"]])))\n", + " elif m == \"dir_flip\":\n", + " text = write(ev, rng, d={\"북쪽\": \"남쪽\", \"남쪽\": \"북쪽\", \"동쪽\": \"서쪽\", \"서쪽\": \"동쪽\"}[ev[\"d\"]])\n", + " elif m == \"old_image\" and k > 0:\n", + " text, img = write(ev, rng), events[int(rng.integers(0, k))][\"img\"]\n", + " elif m == \"splice\":\n", + " text, img = write(ev, rng), splice(ev[\"img\"], events[(k + 7) % n_events][\"img\"], rng)\n", + " elif m == \"ai_image\":\n", + " text, img = write(ev, rng), ai_image(rng)\n", + " elif m == \"ai_text\":\n", + " text = f\"속보 속보! {ev['r']} 지역 대규모 {ev['o']} 출현! 긴급 확산 바람! 긴급 확산 바람! 모두 확산 바람!\"\n", + " img = ai_image(rng) if rng.random() < .5 else None\n", + " elif m == \"bot_burst\":\n", + " text = write(ev, rng, n=ev[\"n\"] * 6) + \" 지금 바로 공유\"\n", + " for b in rng.choice(by[\"propaganda\"], 4, replace=False):\n", + " add(ev, str(b), text, img, t + rng.uniform(0, 0.15), None, m, o)\n", + " continue\n", + " elif m == \"mashup\":\n", + " other = events[(k + 3) % n_events]\n", + " text = f\"{ev['r']} 일대 {ev['o']} 확인, {other['n']}대가 {other['d']}으로 이동\" # 다른 사건 내용과 짜깁기\n", + " elif m == \"recycled\" and k > 2:\n", + " old = events[int(rng.integers(0, k - 1))]\n", + " text, img, t = write(old, rng), old[\"img\"], ev[\"t0\"] + rng.uniform(0, 3)\n", + " if text is None:\n", + " continue\n", + " add(ev, who, text, img, t, None, m, o)\n", + " return posts, acc\n", + "# ---------------------------------------------------------------------------\n", + "\n", + "# ---- 텍스트 정제·이미지 지각 해시·조작 흔적 공통 함수 ----\n", + "STOPWORDS = {\"가상\", \"약\", \"일대\", \"지역에서\", \"모습\", \"중\", \"확인됨\", \"발견\", \"제보\", \"주민\"}\n", + "_URL = re.compile(r\"https?://\\S+|www\\.\\S+\")\n", + "_SPECIAL = re.compile(r\"[^0-9A-Za-z가-힣\\-\\s]\")\n", + "\n", + "\n", + "def clean_text(t):\n", + " \"\"\"정규화(NFKC) -> URL·멘션 제거 -> 해시태그 기호 제거 -> 특수문자 제거 -> 불용어 제거 -> 공백 정리.\"\"\"\n", + " t = unicodedata.normalize(\"NFKC\", t)\n", + " t = _URL.sub(\" \", t)\n", + " t = re.sub(r\"@\\w+\", \" \", t).replace(\"#\", \" \")\n", + " t = _SPECIAL.sub(\" \", t)\n", + " return \" \".join(w for w in t.split() if w not in STOPWORDS)\n", + "\n", + "\n", + "def phash(img):\n", + " \"\"\"지각 해시(pHash): 32x32 이미지 DCT의 저주파 8x8(직류 성분 제외)을 중앙값과 비교한 64비트.\"\"\"\n", + " c = dctn(np.asarray(img, float), norm=\"ortho\")[:8, :8].ravel()[1:]\n", + " return np.append(c > np.median(c), False)\n", + "\n", + "\n", + "def hamming(a, b):\n", + " return int(np.count_nonzero(a != b))\n", + "\n", + "\n", + "def slots(text):\n", + " \"\"\"본문에서 지역 코드·수량·방향·대상을 뽑는다(사건 내용 비교용).\"\"\"\n", + " r = re.search(r\"[A-F]-\\d\", text)\n", + " n = re.search(r\"(\\d+)\\s*대|수량\\s*(\\d+)\", text)\n", + " d = next((x for x in DIRS if x in text), None)\n", + " o = next((x for x in OBJECTS if x in text), None)\n", + " return {\"r\": r.group(0) if r else None, \"n\": int(n.group(1) or n.group(2)) if n else None, \"d\": d, \"o\": o}\n", + "\n", + "\n", + "def splice_score(img):\n", + " \"\"\"합성(붙여넣기) 흔적: 8x8 블록별 고주파 잡음 크기의 최대/중앙값 비율. 잡음 수준이 다른 조각이 있으면 커진다.\"\"\"\n", + " res = img - ndimage.median_filter(img, 3)\n", + " b = np.abs(res).reshape(4, 8, 4, 8).std(axis=(1, 3))\n", + " return float(b.max() / (np.median(b) + 1e-6))\n", + "\n", + "\n", + "def ai_image_score(img):\n", + " \"\"\"생성형 이미지 흔적: 2픽셀 주기 격자(나이퀴스트 주파수) 에너지 비율.\"\"\"\n", + " f = np.abs(np.fft.fft2(img - img.mean()))\n", + " return float(f[16, 16] / (f.sum() / f.size + 1e-6))\n", + "\n", + "\n", + "def ai_text_score(text):\n", + " \"\"\"기계적 선동 문체 흔적: 반복 어절 비율 + 느낌표 밀도.\"\"\"\n", + " w = text.split()\n", + " return (1 - len(set(w)) / max(len(w), 1)) + text.count(\"!\") / max(len(w), 1)\n", + "\n", + "# ---- 게시물 군집(같은 이야기 묶기)·조작 탐지 특징 공통 함수 ----\n", + "FEAT = [\"num_dev\", \"region_mis\", \"dir_mis\", \"obj_mis\", \"img_ref_dist\", \"img_prior_h\", \"time_gap_h\", \"splice\", \"ai_img\",\n", + " \"ai_text\", \"burst_n\", \"has_img\", \"is_fwd\", \"acc_age\", \"acc_followers\", \"acc_orig\", \"acc_past\", \"acc_cv\"]\n", + "\n", + "\n", + "def cluster_posts(posts, hours=24, ham_thr=8):\n", + " \"\"\"같은 이야기 묶기: 본문에서 뽑은 (지역, 대상)이 같고 24시간 안이면 연결하고,\n", + " 지각 해시가 가까운 이미지(같은 대상)도 연결한다. 글 표현이 달라도 내용 단위로 묶인다.\"\"\"\n", + " texts = [clean_text(p[\"text\"]) for p in posts]\n", + " X = TfidfVectorizer(analyzer=\"char_wb\", ngram_range=(2, 3)).fit_transform(texts)\n", + " S = (X @ X.T).toarray()\n", + " H = [phash(p[\"image\"]) if p[\"image\"] is not None else None for p in posts]\n", + " sl = [slots(p[\"text\"]) for p in posts]\n", + " t = [p[\"posted_at\"] for p in posts]\n", + " parent = list(range(len(posts)))\n", + "\n", + " def find(i):\n", + " while parent[i] != i:\n", + " parent[i] = parent[parent[i]]; i = parent[i]\n", + " return i\n", + " for i in range(len(posts)):\n", + " for j in range(i + 1, len(posts)):\n", + " if abs(t[i] - t[j]) >= hours or sl[i][\"o\"] != sl[j][\"o\"]:\n", + " continue\n", + " same_story = sl[i][\"r\"] is not None and sl[i][\"r\"] == sl[j][\"r\"]\n", + " same_img = H[i] is not None and H[j] is not None and hamming(H[i], H[j]) <= ham_thr\n", + " if same_story or same_img:\n", + " parent[find(i)] = find(j)\n", + " return np.array([find(i) for i in range(len(posts))]), S, H\n", + "\n", + "\n", + "def post_features(posts, acc):\n", + " cid, S, H = cluster_posts(posts)\n", + " t = np.array([p[\"posted_at\"] for p in posts])\n", + " sl = [slots(p[\"text\"]) for p in posts]\n", + " first_seen = {}\n", + " for i in np.argsort(t): # 이미지가 처음 등장한 시각과 군집\n", + " if H[i] is None:\n", + " continue\n", + " key = next((k for k in first_seen if hamming(np.array(k), H[i]) <= 6), None)\n", + " if key is None:\n", + " first_seen[tuple(H[i])] = (t[i], cid[i])\n", + " elif first_seen[key][1] != cid[i]:\n", + " pass\n", + " ref = {c: int(np.argmin(np.where(cid == c, t, np.inf))) for c in set(cid)} # 군집에서 가장 이른 게시물 = 기준(추정 원본)\n", + " F = []\n", + " for i, p in enumerate(posts):\n", + " r = ref[cid[i]]\n", + " a, b = sl[i], sl[r]\n", + " img_prior = 0.0\n", + " if H[i] is not None:\n", + " for k, (ts, c) in first_seen.items():\n", + " if hamming(np.array(k), H[i]) <= 6 and c != cid[i]:\n", + " img_prior = max(img_prior, min(t[i] - ts, 48.0))\n", + " same_txt = [j for j in np.where(S[i] > 0.97)[0] if abs(t[j] - t[i]) < 0.25 and posts[j][\"account_id\"] != p[\"account_id\"]]\n", + " A = acc[p[\"account_id\"]]\n", + " F.append([abs(np.log((a[\"n\"] or 1) / (b[\"n\"] or 1))), float(a[\"r\"] != b[\"r\"]), float(a[\"d\"] != b[\"d\"]), float(a[\"o\"] != b[\"o\"]),\n", + " hamming(H[i], H[r]) if H[i] is not None and H[r] is not None else 0, img_prior, t[i] - t[r],\n", + " splice_score(p[\"image\"]) if p[\"image\"] is not None else 0, ai_image_score(p[\"image\"]) if p[\"image\"] is not None else 0,\n", + " ai_text_score(p[\"text\"]), len(set(posts[j][\"account_id\"] for j in same_txt)), float(p[\"image\"] is not None),\n", + " float(p[\"forwarded_from\"] is not None), np.log1p(A[\"age_days\"]), np.log1p(A[\"followers\"]), A[\"original_ratio\"],\n", + " A[\"past_accuracy\"], A[\"interval_cv\"]])\n", + " return np.array(F), cid\n", + "\n", + "CONTENT, ACCOUNT = list(range(13)), list(range(13, 18))\n", + "WEIGHTS = {\"내용 무결성\": 0.35, \"계정 평판\": 0.20, \"독립 출처 교차확인\": 0.25, \"유포 패턴 정상\": 0.10, \"시의성\": 0.10} # 신뢰도 가중치 매트릭스\n", + "GRADES = [(0.80, \"A\"), (0.65, \"B\"), (0.50, \"C\"), (0.35, \"D\"), (-1, \"E\")]\n", + "\n", + "\n", + "def components(posts, acc, F, cid, manip_model, acc_model):\n", + " sl = [slots(p[\"text\"]) for p in posts]\n", + " corro = []\n", + " for i, p in enumerate(posts):\n", + " same = {posts[j][\"account_id\"] for j in np.where(cid == cid[i])[0] if j != i and posts[j][\"forwarded_from\"] is None\n", + " and posts[j][\"account_id\"] != p[\"account_id\"] and (sl[j][\"r\"], sl[j][\"n\"], sl[j][\"d\"]) == (sl[i][\"r\"], sl[i][\"n\"], sl[i][\"d\"])}\n", + " corro.append(min(len(same) / 2, 1.0))\n", + " return np.c_[1 - manip_model.predict_proba(F[:, CONTENT])[:, 1], 1 - acc_model.predict_proba(F[:, ACCOUNT])[:, 1],\n", + " corro, 1 - np.minimum(F[:, FEAT.index(\"burst_n\")] / 3, 1), np.exp(-np.maximum(F[:, FEAT.index(\"time_gap_h\")], 0) / 12)]\n", + "\n", + "\n", + "def grade(score):\n", + " return np.array([next(g for t, g in GRADES if s >= t) for s in score])\n", + "\n", + "\n", + "tr_posts, tr_acc = make_posts(60, seed=7)\n", + "Ftr, _ = post_features(tr_posts, tr_acc)\n", + "ytr = np.array([p[\"is_disinfo\"] for p in tr_posts])\n", + "manip_model = RandomForestClassifier(n_estimators=200, min_samples_leaf=3, random_state=0).fit(Ftr[:, CONTENT], ytr)\n", + "acc_model = make_pipeline(StandardScaler(), LogisticRegression(max_iter=2000)).fit(Ftr[:, ACCOUNT], ytr)\n", + "\n", + "te_posts, te_acc = make_posts(60, seed=11)\n", + "Fte, cid = post_features(te_posts, te_acc)\n", + "y = np.array([p[\"is_disinfo\"] for p in te_posts])\n", + "C = components(te_posts, te_acc, Fte, cid, manip_model, acc_model)\n", + "w = np.array(list(WEIGHTS.values()))\n", + "score = C @ w\n", + "G = grade(score)\n", + "print(\"[신뢰도 가중치 매트릭스]\", WEIGHTS)\n", + "\n", + "# 1) 기존 규칙(신생 계정·소규모 계정·출처 표시 없음이면 가짜) vs 엔진(D·E 등급만 '허위 의심')\n", + "A = [te_acc[p[\"account_id\"]] for p in te_posts]\n", + "base = np.array([(a[\"age_days\"] < 180 or a[\"followers\"] < 1000) and p[\"forwarded_from\"] is None for a, p in zip(A, te_posts)])\n", + "eng = np.isin(G, [\"D\", \"E\"])\n", + "far0, far1 = (base & ~y).sum() / base.sum(), (eng & ~y).sum() / eng.sum()\n", + "print(f\"\\n기존 규칙: 허위 지목 {base.sum()}건 중 사실 게시물 {(base & ~y).sum()}건 (오경보율 {far0:.1%}), 조작 재현율 {(base & y).sum() / y.sum():.1%}\")\n", + "print(f\"신뢰도 엔진: 허위 의심(D·E) {eng.sum()}건 중 사실 게시물 {(eng & ~y).sum()}건 (오경보율 {far1:.1%}), 조작 재현율 {(eng & y).sum() / y.sum():.1%}\")\n", + "cut = 1 - far1 / far0\n", + "print(f\"오경보율 감축 {cut:.1%} (목표 40% 이상: {'달성' if cut >= 0.4 else '미달'}), ROC-AUC(종합 지수로 사실 판별) {roc_auc_score(~y, score):.3f}\")\n", + "\n", + "# 2) 등급 분포와 임계값 민감도\n", + "kind = np.array([p[\"kind\"] for p in te_posts])\n", + "tab = pd.crosstab(pd.Series(np.where(y, \"조작·기만\", \"사실\"), name=\"실제\"), pd.Series(G, name=\"등급\"))\n", + "print(\"\\n\" + tab.to_string())\n", + "print(\"\\n허위 의심 기준(지수 미만) 건수 오경보율 조작 재현율\")\n", + "for t in [0.40, 0.45, 0.50, 0.55]:\n", + " f = score < t\n", + " print(f\" {t:.2f} {f.sum():6d} {(f & ~y).sum() / max(f.sum(), 1):7.1%} {(f & y).sum() / y.sum():8.1%}\")\n", + "\n", + "# 3) 일관성: 계정 수치(+-5%)·게시 시각(+-3분) 측정 잡음을 넣어 20번 다시 계산해도 같은 등급이 나오는 비율\n", + "rng = np.random.default_rng(0)\n", + "same = []\n", + "for _ in range(20):\n", + " Fn = Fte.copy()\n", + " Fn[:, ACCOUNT] *= 1 + rng.normal(0, 0.05, Fn[:, ACCOUNT].shape)\n", + " Fn[:, FEAT.index(\"time_gap_h\")] += rng.normal(0, 0.05, len(Fn))\n", + " same.append((grade(components(te_posts, te_acc, Fn, cid, manip_model, acc_model) @ w) == G).mean())\n", + "print(f\"\\n신뢰도 등급 산출 일관성 {np.mean(same):.1%} (목표 90% 이상: {'달성' if np.mean(same) >= 0.9 else '미달'})\")\n", + "print(\"등급 대응: A·B = High, C = Medium, D·E = Low. 등급은 '검토 우선순위'이며 차단·채택은 미션 7의 정보관 결정으로만 한다.\")\n", + "\n", + "fig, ax = plt.subplots(1, 2, figsize=(12, 4))\n", + "ax[0].hist([score[~y], score[y]], bins=20, range=(0, 1), label=[\"factual\", \"manipulated\"], stacked=True)\n", + "for t, g in GRADES[:-1]:\n", + " ax[0].axvline(t, ls=\":\", c=\"k\")\n", + "ax[0].set_xlabel(\"composite credibility index\"); ax[0].legend(); ax[0].set_title(\"Credibility index (dotted = grade cut-offs)\")\n", + "ex = [int(np.argmax(score)), int(np.argmin(np.abs(score - 0.5))), int(np.argmin(score))]\n", + "ax[1].barh([f\"{te_posts[i]['post_id']} ({G[i]})\" for i in ex], [0] * 3)\n", + "left = np.zeros(3)\n", + "for k, (nm, wt) in enumerate(WEIGHTS.items()):\n", + " v = C[ex, k] * wt\n", + " ax[1].barh([f\"{te_posts[i]['post_id']} ({G[i]})\" for i in ex], v, left=left, label=[\"content\", \"account\", \"corroboration\", \"propagation\", \"timeliness\"][k])\n", + " left += v\n", + "ax[1].legend(fontsize=8); ax[1].set_title(\"Score breakdown (uncertainty view)\")\n", + "plt.tight_layout(); plt.show()\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 05 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + "[신뢰도 가중치 매트릭스] {'내용 무결성': 0.35, '계정 평판': 0.2, '독립 출처 교차확인': 0.25, '유포 패턴 정상': 0.1, '시의성': 0.1}\n", + "\n", + "기존 규칙: 허위 지목 290건 중 사실 게시물 58건 (오경보율 20.0%), 조작 재현율 90.6%\n", + "신뢰도 엔진: 허위 의심(D·E) 228건 중 사실 게시물 2건 (오경보율 0.9%), 조작 재현율 88.3%\n", + "오경보율 감축 95.6% (목표 40% 이상: 달성), ROC-AUC(종합 지수로 사실 판별) 0.993\n", + "\n", + "등급 A B C D E\n", + "실제 \n", + "사실 257 50 22 2 0\n", + "조작·기만 1 5 24 80 146\n", + "\n", + "허위 의심 기준(지수 미만) 건수 오경보율 조작 재현율\n", + " 0.40 175 0.0% 68.4%\n", + " 0.45 215 0.5% 83.6%\n", + " 0.50 228 0.9% 88.3%\n", + " 0.55 244 1.6% 93.8%\n", + "\n", + "신뢰도 등급 산출 일관성 98.5% (목표 90% 이상: 달성)\n", + "등급 대응: A·B = High, C = Medium, D·E = Low. 등급은 '검토 우선순위'이며 차단·채택은 미션 7의 정보관 결정으로만 한다.\n", + "```\n", + "
\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 06. SNS 전쟁 정보 타임라인 및 출처 신뢰도 네트워크 시각화 대시보드 구현\n", + "**소요시간**: 약 2.5시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "SNS 정보의 전파 경로, 출처별 신뢰도 분포, 위험 키워드 트렌드를 실시간 가시화하는 정보전 모니터링 대시보드를 구현한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **SNS 정보의 유입 추이, 전파 경로, 출처별 신뢰도 분포, 위험 키워드 추세를 한눈에 보는 정보전 모니터링 대시보드를 구현한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **게시물 스트림을 1초 주기 묶음으로 받아 시간대별·등급별 집계와 위험 키워드 집계를 갱신하고 처리시간을 잰다 과정을 진행하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `src/ui/isr02_dashboard.py (또는 웹 UI 컴포넌트)` 형태의 결과물을 완성한다.\n", + "\n", + "### 🔍 평가 및 검증 기준 (PASS 조건)\n", + "#### 미션 판정\n", + "\n", + "**평가 구분:** 필수\n", + "\n", + "**핵심 평가영역:** 전술 작전 상황판으로서의 직관성 및 인지 부하 감소도\n", + "\n", + "#### 미션 PASS 조건\n", + "\n", + "- 정량 기준: 화면 갱신 주기 1초 이내, UI 응답성 100ms 이내\n", + "\n", + "\n", + "- 진행 기준: 주요 지표 시각화 컴포넌트 4종 이상 렌더링 및 실시간 데이터 연동\n", + "\n", + "\n", + "- 검증 방법: 사용자 인터랙션 테스트 및 모의 데이터 스트림 렌더링 검증\n", + "\n", + "\n", + "#### 채점자 판정 메모\n", + "\n", + "> 단순 코드 실행 여부뿐만 아니라 출력된 수치와 근거 자료가 군 실무 요구사항에 부합하는지 면밀히 확인한다.\n", + "\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "\"\"\"SNS 전쟁 정보의 시간대별 유입량(신뢰도 등급별), 이야기 단위 전파 경로 네트워크, 계정 유형별 신뢰도 분포, 위험 키워드 추이를 한 화면에 보여 주는 정보전 모니터링 대시보드를 구현하고, 스트림 갱신 처리시간과 렌더링 시간을 측정한다.\"\"\"\n", + "import re\n", + "import time\n", + "import unicodedata\n", + "import numpy as np\n", + "import pandas as pd\n", + "import matplotlib.pyplot as plt\n", + "from scipy import ndimage\n", + "from scipy.fft import dctn\n", + "from sklearn.ensemble import RandomForestClassifier\n", + "from sklearn.feature_extraction.text import TfidfVectorizer\n", + "\n", + "# ---------------------------------------------------------------------------\n", + "# [교육용가상자료] 합성 SNS 게시물 생성기 (실제 계정·채널·지명·게시물 없음)\n", + "# 가상 사건마다 원본 제보, 재게시(복제), 유사 재작성, 조작·기만 게시물 10종과 32x32 합성 이미지를 만든다.\n", + "# ---------------------------------------------------------------------------\n", + "REGIONS = [f\"{a}-{n}\" for a in \"ABCDEF\" for n in range(1, 6)]\n", + "OBJECTS = [\"군용 차량\", \"소형 드론\", \"보급 트럭\", \"헬기\", \"버스\", \"견인 차량\", \"트레일러\", \"민간 승용차\"]\n", + "DIRS = [\"북쪽\", \"남쪽\", \"동쪽\", \"서쪽\"]\n", + "TEMPLATES = [\"가상 {r} 지역에서 {o} 목격, {n}대 규모로 {d}으로 이동 중\",\n", + " \"{r} 일대 {o} 확인됨. 약 {n}대, {d} 방향으로 이동\",\n", + " \"[현지] {r}에서 {o} 발견 - 수량 {n}, 이동 방향 {d}\",\n", + " \"{r} 주민 제보: {o} {n}대가 {d}으로 향하는 모습\"]\n", + "MANIP = [\"num_change\", \"region_swap\", \"dir_flip\", \"old_image\", \"splice\", \"ai_image\", \"ai_text\", \"bot_burst\", \"mashup\", \"recycled\"]\n", + "ACCOUNT_TYPES = { # 유형별 계정 속성 분포(가상): 나이(일), 팔로워, 하루 게시 수, 원본 비율, 과거 정확도, 게시 간격 변동계수\n", + " \"media\": ((1000, 4000), (5e4, 5e5), (5, 30), (.6, .9), (.85, .97), (.8, 1.5)),\n", + " \"witness\": ((10, 800), (20, 2000), (.2, 3), (.7, 1.0), (.6, .95), (1.0, 2.0)),\n", + " \"aggregator\": ((200, 2000), (1e4, 2e5), (30, 200), (.05, .2), (.6, .85), (.5, 1.0)),\n", + " \"propaganda\": ((5, 200), (100, 2e4), (20, 150), (.3, .7), (.1, .4), (.05, .3)),\n", + "}\n", + "\n", + "\n", + "def make_accounts(rng, n=(8, 30, 10, 20)):\n", + " acc = {}\n", + " for (t, spec), k in zip(ACCOUNT_TYPES.items(), n):\n", + " for i in range(k):\n", + " v = [rng.uniform(*r) for r in spec]\n", + " acc[f\"ACC_{t[:3].upper()}{i:02d}\"] = {\"type\": t, \"age_days\": int(v[0]), \"followers\": int(v[1]), \"posts_per_day\": round(v[2], 1),\n", + " \"original_ratio\": round(v[3], 2), \"past_accuracy\": round(v[4], 2), \"interval_cv\": round(v[5], 2)}\n", + " return acc\n", + "\n", + "\n", + "def event_image(seed):\n", + " \"\"\"사건 현장 사진을 흉내 낸 32x32 합성 이미지(사건마다 고유한 도형 배치).\"\"\"\n", + " # [TODO : 학생 실습 6] SNS 전쟁 정보 타임라인 및 출처 신뢰도 네트워크 시각화 대시보드 구현 관련 핵심 로직을 구현하세요.\n", + " # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n", + " # <<< 여기에 코드를 작성하세요 >>>\n", + " pass # 작성 후 pass를 제거하세요.\n", + "def variant(img, rng):\n", + " \"\"\"재게시 과정의 자연스러운 변형: 재압축 잡음, 밝기 변화, 가장자리 잘림 중 하나.\"\"\"\n", + " # [TODO : 학생 실습 6] SNS 전쟁 정보 타임라인 및 출처 신뢰도 네트워크 시각화 대시보드 구현 관련 핵심 로직을 구현하세요.\n", + " # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n", + " # <<< 여기에 코드를 작성하세요 >>>\n", + " pass # 작성 후 pass를 제거하세요.\n", + "def splice(img, other, rng):\n", + " out = img.copy()\n", + " y, x = rng.integers(4, 18, 2)\n", + " out[y:y + 12, x:x + 12] = other[y:y + 12, x:x + 12] + rng.normal(0, 12, (12, 12)) # 잡음 수준이 다른 조각을 붙임\n", + " return out\n", + "\n", + "\n", + "def ai_image(rng):\n", + " \"\"\"생성형 모델 이미지를 흉내 낸 합성 이미지: 지나치게 매끈한 바탕 + 업샘플링 격자 무늬(주기 2픽셀).\"\"\"\n", + " base = ndimage.gaussian_filter(rng.normal(130, 60, (32, 32)), 3)\n", + " grid = (np.indices((32, 32)).sum(0) % 2) * rng.uniform(6, 12)\n", + " return base + grid\n", + "\n", + "\n", + "def write(ev, rng, tpl=None, **kw):\n", + " s = {\"r\": ev[\"r\"], \"o\": ev[\"o\"], \"n\": ev[\"n\"], \"d\": ev[\"d\"], **kw}\n", + " return (tpl or TEMPLATES[int(rng.integers(len(TEMPLATES)))]).format(**s)\n", + "\n", + "\n", + "def make_posts(n_events=40, seed=7):\n", + " rng = np.random.default_rng(seed)\n", + " acc = make_accounts(rng)\n", + " by = {t: [a for a, v in acc.items() if v[\"type\"] == t] for t in ACCOUNT_TYPES}\n", + " events = [{\"id\": f\"EV{i:03d}\", \"r\": str(rng.choice(REGIONS)), \"o\": str(rng.choice(OBJECTS)), \"n\": int(rng.integers(2, 15)),\n", + " \"d\": str(rng.choice(DIRS)), \"t0\": float(i * 1.2 + rng.uniform(0, 1)), \"img\": event_image(seed * 1000 + i)} for i in range(n_events)]\n", + " posts = []\n", + "\n", + " def add(ev, account, text, img, t, fwd, kind, origin):\n", + " pid = f\"P{len(posts):04d}\"\n", + " posts.append({\"post_id\": pid, \"platform\": str(rng.choice([\"telegram\", \"x\", \"other\"], p=[.6, .3, .1])), \"account_id\": account,\n", + " \"posted_at\": round(t, 3), \"text\": text, \"image\": img, \"forwarded_from\": fwd, \"event_id\": ev[\"id\"] if ev else None,\n", + " \"kind\": kind, \"is_disinfo\": kind in MANIP, \"origin\": origin})\n", + " return pid\n", + "\n", + " for k, ev in enumerate(events):\n", + " src = str(rng.choice(by[\"witness\"] if rng.random() < .6 else by[\"media\"]))\n", + " if rng.random() < .05:\n", + " src = str(rng.choice(by[\"propaganda\"])) # 신뢰도 낮은 계정도 사실을 올릴 수 있다\n", + " o = add(ev, src, write(ev, rng), ev[\"img\"], ev[\"t0\"], None, \"original\", None)\n", + " for _ in range(int(rng.integers(2, 5))): # 그대로 재게시(전달)\n", + " add(ev, str(rng.choice(by[\"aggregator\"])), posts[int(o[1:])][\"text\"], ev[\"img\"], ev[\"t0\"] + rng.exponential(1), o, \"copy\", o)\n", + " for _ in range(int(rng.integers(1, 3))): # 표현만 바꾼 재작성(내용 동일)\n", + " add(ev, str(rng.choice(by[\"media\"] + by[\"witness\"])), \"#가상속보 \" + write(ev, rng), variant(ev[\"img\"], rng),\n", + " ev[\"t0\"] + rng.uniform(0.5, 6), None, \"near_dup\", o)\n", + " for m in MANIP if k % 2 == 0 else MANIP[k % 4::4]: # 조작·기만 게시물\n", + " if rng.random() > 0.5:\n", + " continue\n", + " who = str(rng.choice(by[\"propaganda\"] if rng.random() < .75 else by[\"aggregator\"] + by[\"witness\"]))\n", + " t, img, text = ev[\"t0\"] + rng.uniform(1, 12), variant(ev[\"img\"], rng), None\n", + " if m == \"num_change\":\n", + " text = write(ev, rng, n=ev[\"n\"] * int(rng.integers(5, 10)))\n", + " elif m == \"region_swap\":\n", + " text = write(ev, rng, r=str(rng.choice([r for r in REGIONS if r != ev[\"r\"]])))\n", + " elif m == \"dir_flip\":\n", + " text = write(ev, rng, d={\"북쪽\": \"남쪽\", \"남쪽\": \"북쪽\", \"동쪽\": \"서쪽\", \"서쪽\": \"동쪽\"}[ev[\"d\"]])\n", + " elif m == \"old_image\" and k > 0:\n", + " text, img = write(ev, rng), events[int(rng.integers(0, k))][\"img\"]\n", + " elif m == \"splice\":\n", + " text, img = write(ev, rng), splice(ev[\"img\"], events[(k + 7) % n_events][\"img\"], rng)\n", + " elif m == \"ai_image\":\n", + " text, img = write(ev, rng), ai_image(rng)\n", + " elif m == \"ai_text\":\n", + " text = f\"속보 속보! {ev['r']} 지역 대규모 {ev['o']} 출현! 긴급 확산 바람! 긴급 확산 바람! 모두 확산 바람!\"\n", + " img = ai_image(rng) if rng.random() < .5 else None\n", + " elif m == \"bot_burst\":\n", + " text = write(ev, rng, n=ev[\"n\"] * 6) + \" 지금 바로 공유\"\n", + " for b in rng.choice(by[\"propaganda\"], 4, replace=False):\n", + " add(ev, str(b), text, img, t + rng.uniform(0, 0.15), None, m, o)\n", + " continue\n", + " elif m == \"mashup\":\n", + " other = events[(k + 3) % n_events]\n", + " text = f\"{ev['r']} 일대 {ev['o']} 확인, {other['n']}대가 {other['d']}으로 이동\" # 다른 사건 내용과 짜깁기\n", + " elif m == \"recycled\" and k > 2:\n", + " old = events[int(rng.integers(0, k - 1))]\n", + " text, img, t = write(old, rng), old[\"img\"], ev[\"t0\"] + rng.uniform(0, 3)\n", + " if text is None:\n", + " continue\n", + " add(ev, who, text, img, t, None, m, o)\n", + " return posts, acc\n", + "# ---------------------------------------------------------------------------\n", + "\n", + "# ---- 텍스트 정제·이미지 지각 해시·조작 흔적 공통 함수 ----\n", + "STOPWORDS = {\"가상\", \"약\", \"일대\", \"지역에서\", \"모습\", \"중\", \"확인됨\", \"발견\", \"제보\", \"주민\"}\n", + "_URL = re.compile(r\"https?://\\S+|www\\.\\S+\")\n", + "_SPECIAL = re.compile(r\"[^0-9A-Za-z가-힣\\-\\s]\")\n", + "\n", + "\n", + "def clean_text(t):\n", + " \"\"\"정규화(NFKC) -> URL·멘션 제거 -> 해시태그 기호 제거 -> 특수문자 제거 -> 불용어 제거 -> 공백 정리.\"\"\"\n", + " t = unicodedata.normalize(\"NFKC\", t)\n", + " t = _URL.sub(\" \", t)\n", + " t = re.sub(r\"@\\w+\", \" \", t).replace(\"#\", \" \")\n", + " t = _SPECIAL.sub(\" \", t)\n", + " return \" \".join(w for w in t.split() if w not in STOPWORDS)\n", + "\n", + "\n", + "def phash(img):\n", + " \"\"\"지각 해시(pHash): 32x32 이미지 DCT의 저주파 8x8(직류 성분 제외)을 중앙값과 비교한 64비트.\"\"\"\n", + " c = dctn(np.asarray(img, float), norm=\"ortho\")[:8, :8].ravel()[1:]\n", + " return np.append(c > np.median(c), False)\n", + "\n", + "\n", + "def hamming(a, b):\n", + " return int(np.count_nonzero(a != b))\n", + "\n", + "\n", + "def slots(text):\n", + " \"\"\"본문에서 지역 코드·수량·방향·대상을 뽑는다(사건 내용 비교용).\"\"\"\n", + " r = re.search(r\"[A-F]-\\d\", text)\n", + " n = re.search(r\"(\\d+)\\s*대|수량\\s*(\\d+)\", text)\n", + " d = next((x for x in DIRS if x in text), None)\n", + " o = next((x for x in OBJECTS if x in text), None)\n", + " return {\"r\": r.group(0) if r else None, \"n\": int(n.group(1) or n.group(2)) if n else None, \"d\": d, \"o\": o}\n", + "\n", + "\n", + "def splice_score(img):\n", + " \"\"\"합성(붙여넣기) 흔적: 8x8 블록별 고주파 잡음 크기의 최대/중앙값 비율. 잡음 수준이 다른 조각이 있으면 커진다.\"\"\"\n", + " res = img - ndimage.median_filter(img, 3)\n", + " b = np.abs(res).reshape(4, 8, 4, 8).std(axis=(1, 3))\n", + " return float(b.max() / (np.median(b) + 1e-6))\n", + "\n", + "\n", + "def ai_image_score(img):\n", + " \"\"\"생성형 이미지 흔적: 2픽셀 주기 격자(나이퀴스트 주파수) 에너지 비율.\"\"\"\n", + " f = np.abs(np.fft.fft2(img - img.mean()))\n", + " return float(f[16, 16] / (f.sum() / f.size + 1e-6))\n", + "\n", + "\n", + "def ai_text_score(text):\n", + " \"\"\"기계적 선동 문체 흔적: 반복 어절 비율 + 느낌표 밀도.\"\"\"\n", + " w = text.split()\n", + " return (1 - len(set(w)) / max(len(w), 1)) + text.count(\"!\") / max(len(w), 1)\n", + "\n", + "# ---- 게시물 군집(같은 이야기 묶기)·조작 탐지 특징 공통 함수 ----\n", + "FEAT = [\"num_dev\", \"region_mis\", \"dir_mis\", \"obj_mis\", \"img_ref_dist\", \"img_prior_h\", \"time_gap_h\", \"splice\", \"ai_img\",\n", + " \"ai_text\", \"burst_n\", \"has_img\", \"is_fwd\", \"acc_age\", \"acc_followers\", \"acc_orig\", \"acc_past\", \"acc_cv\"]\n", + "\n", + "\n", + "def cluster_posts(posts, hours=24, ham_thr=8):\n", + " \"\"\"같은 이야기 묶기: 본문에서 뽑은 (지역, 대상)이 같고 24시간 안이면 연결하고,\n", + " 지각 해시가 가까운 이미지(같은 대상)도 연결한다. 글 표현이 달라도 내용 단위로 묶인다.\"\"\"\n", + " texts = [clean_text(p[\"text\"]) for p in posts]\n", + " X = TfidfVectorizer(analyzer=\"char_wb\", ngram_range=(2, 3)).fit_transform(texts)\n", + " S = (X @ X.T).toarray()\n", + " H = [phash(p[\"image\"]) if p[\"image\"] is not None else None for p in posts]\n", + " sl = [slots(p[\"text\"]) for p in posts]\n", + " t = [p[\"posted_at\"] for p in posts]\n", + " parent = list(range(len(posts)))\n", + "\n", + " def find(i):\n", + " while parent[i] != i:\n", + " parent[i] = parent[parent[i]]; i = parent[i]\n", + " return i\n", + " for i in range(len(posts)):\n", + " for j in range(i + 1, len(posts)):\n", + " if abs(t[i] - t[j]) >= hours or sl[i][\"o\"] != sl[j][\"o\"]:\n", + " continue\n", + " same_story = sl[i][\"r\"] is not None and sl[i][\"r\"] == sl[j][\"r\"]\n", + " same_img = H[i] is not None and H[j] is not None and hamming(H[i], H[j]) <= ham_thr\n", + " if same_story or same_img:\n", + " parent[find(i)] = find(j)\n", + " return np.array([find(i) for i in range(len(posts))]), S, H\n", + "\n", + "\n", + "def post_features(posts, acc):\n", + " cid, S, H = cluster_posts(posts)\n", + " t = np.array([p[\"posted_at\"] for p in posts])\n", + " sl = [slots(p[\"text\"]) for p in posts]\n", + " first_seen = {}\n", + " for i in np.argsort(t): # 이미지가 처음 등장한 시각과 군집\n", + " if H[i] is None:\n", + " continue\n", + " key = next((k for k in first_seen if hamming(np.array(k), H[i]) <= 6), None)\n", + " if key is None:\n", + " first_seen[tuple(H[i])] = (t[i], cid[i])\n", + " elif first_seen[key][1] != cid[i]:\n", + " pass\n", + " ref = {c: int(np.argmin(np.where(cid == c, t, np.inf))) for c in set(cid)} # 군집에서 가장 이른 게시물 = 기준(추정 원본)\n", + " F = []\n", + " for i, p in enumerate(posts):\n", + " r = ref[cid[i]]\n", + " a, b = sl[i], sl[r]\n", + " img_prior = 0.0\n", + " if H[i] is not None:\n", + " for k, (ts, c) in first_seen.items():\n", + " if hamming(np.array(k), H[i]) <= 6 and c != cid[i]:\n", + " img_prior = max(img_prior, min(t[i] - ts, 48.0))\n", + " same_txt = [j for j in np.where(S[i] > 0.97)[0] if abs(t[j] - t[i]) < 0.25 and posts[j][\"account_id\"] != p[\"account_id\"]]\n", + " A = acc[p[\"account_id\"]]\n", + " F.append([abs(np.log((a[\"n\"] or 1) / (b[\"n\"] or 1))), float(a[\"r\"] != b[\"r\"]), float(a[\"d\"] != b[\"d\"]), float(a[\"o\"] != b[\"o\"]),\n", + " hamming(H[i], H[r]) if H[i] is not None and H[r] is not None else 0, img_prior, t[i] - t[r],\n", + " splice_score(p[\"image\"]) if p[\"image\"] is not None else 0, ai_image_score(p[\"image\"]) if p[\"image\"] is not None else 0,\n", + " ai_text_score(p[\"text\"]), len(set(posts[j][\"account_id\"] for j in same_txt)), float(p[\"image\"] is not None),\n", + " float(p[\"forwarded_from\"] is not None), np.log1p(A[\"age_days\"]), np.log1p(A[\"followers\"]), A[\"original_ratio\"],\n", + " A[\"past_accuracy\"], A[\"interval_cv\"]])\n", + " return np.array(F), cid\n", + "\n", + "RISK_WORDS = [\"속보\", \"긴급\", \"확산\", \"공유\", \"대규모\"]\n", + "tr_posts, tr_acc = make_posts(60, seed=7)\n", + "Ftr, _ = post_features(tr_posts, tr_acc)\n", + "clf = RandomForestClassifier(n_estimators=200, min_samples_leaf=3, random_state=0).fit(Ftr, [p[\"is_disinfo\"] for p in tr_posts])\n", + "\n", + "posts, acc = make_posts(60, seed=11)\n", + "F, cid = post_features(posts, acc)\n", + "cred = 1 - clf.predict_proba(F)[:, 1] # 신뢰도(사실일 가능성) 0~1\n", + "level = np.where(cred >= 0.65, \"High\", np.where(cred >= 0.35, \"Medium\", \"Low\"))\n", + "df = pd.DataFrame({\"id\": [p[\"post_id\"] for p in posts], \"t\": [p[\"posted_at\"] for p in posts], \"acc\": [p[\"account_id\"] for p in posts],\n", + " \"type\": [acc[p[\"account_id\"]][\"type\"] for p in posts], \"level\": level, \"cred\": cred, \"cid\": cid,\n", + " \"fwd\": [p[\"forwarded_from\"] for p in posts], \"text\": [p[\"text\"] for p in posts]}).sort_values(\"t\")\n", + "\n", + "# 1) 스트림 갱신 모의: 1초마다 새 게시물 묶음(약 20건)을 받아 시간대별·등급별 집계와 키워드 집계를 갱신\n", + "counts, kw, upd = {}, {w: {} for w in RISK_WORDS}, []\n", + "for k0 in range(0, len(df), 20):\n", + " chunk = df.iloc[k0:k0 + 20]\n", + " t0 = time.perf_counter()\n", + " for _, r in chunk.iterrows():\n", + " h = int(r[\"t\"] // 6) * 6\n", + " counts[(h, r[\"level\"])] = counts.get((h, r[\"level\"]), 0) + 1\n", + " for w in RISK_WORDS:\n", + " if w in r[\"text\"]:\n", + " kw[w][h] = kw[w].get(h, 0) + 1\n", + " upd.append((time.perf_counter() - t0) * 1000)\n", + "print(f\"스트림 갱신 {len(upd)}회(회당 약 20건), 처리 평균 {np.mean(upd):.1f} ms, 최대 {np.max(upd):.1f} ms (목표: 1초 주기 안에서 100 ms 이내)\")\n", + "\n", + "# 2) 경보 요약: 신뢰도 Low이면서 조직적 동시 게시가 있는 이야기\n", + "burst = df.assign(b=F[df.index, FEAT.index(\"burst_n\")]).query(\"level == 'Low' and b >= 2\")\n", + "top = burst.groupby(\"cid\").size().sort_values(ascending=False)\n", + "print(f\"게시물 {len(df)}건: High {int((df.level == 'High').sum())} / Medium {int((df.level == 'Medium').sum())} / Low {int((df.level == 'Low').sum())}\")\n", + "print(f\"[긴급 강조] 조직적 동시 게시가 있는 Low 등급 이야기 {len(top)}개, 최다 {int(top.iloc[0]) if len(top) else 0}건\")\n", + "print(\"\\n[계정 유형별 평균 신뢰도]\", df.groupby(\"type\")[\"cred\"].mean().round(2).to_dict())\n", + "\n", + "# 3) 대시보드(야간 작전용 어두운 화면, 구성요소 4종)\n", + "plt.style.use(\"dark_background\")\n", + "fig, ax = plt.subplots(2, 2, figsize=(14, 10))\n", + "hours = sorted({h for h, _ in counts})\n", + "bottom = np.zeros(len(hours))\n", + "for lv, c in [(\"High\", \"limegreen\"), (\"Medium\", \"orange\"), (\"Low\", \"red\")]:\n", + " v = np.array([counts.get((h, lv), 0) for h in hours])\n", + " ax[0, 0].bar(hours, v, width=5, bottom=bottom, color=c, label=lv); bottom += v\n", + "ax[0, 0].set_title(\"Posts per 6h by credibility level\"); ax[0, 0].set_xlabel(\"hours (virtual)\"); ax[0, 0].legend()\n", + "story = int(top.index[0]) if len(top) else int(df.cid.mode()[0]) # 전파 경로: 가장 위험한 이야기 하나\n", + "s = df[df.cid == story]\n", + "types = [\"media\", \"witness\", \"aggregator\", \"propaganda\"]\n", + "pos = {}\n", + "for k, (a, ty) in enumerate(zip(s[\"acc\"], s[\"type\"])):\n", + " ang = 2 * np.pi * (types.index(ty) + 0.8 * (k % 7) / 7) / 4\n", + " pos.setdefault(a, (np.cos(ang) * (1 + 0.1 * (k % 3)), np.sin(ang) * (1 + 0.1 * (k % 3))))\n", + "idmap = dict(zip(df[\"id\"], df[\"acc\"]))\n", + "for _, r in s.iterrows():\n", + " if r[\"fwd\"] in idmap and idmap[r[\"fwd\"]] in pos:\n", + " (x0, y0), (x1, y1) = pos[idmap[r[\"fwd\"]]], pos[r[\"acc\"]]\n", + " ax[0, 1].annotate(\"\", (x1, y1), (x0, y0), arrowprops=dict(arrowstyle=\"->\", color=\"gray\"))\n", + "for a, (x, y) in pos.items():\n", + " lv = s[s.acc == a][\"level\"].iloc[0]\n", + " ax[0, 1].scatter(x, y, s=120, c={\"High\": \"limegreen\", \"Medium\": \"orange\", \"Low\": \"red\"}[lv])\n", + " ax[0, 1].text(x, y + 0.08, a[4:], fontsize=7, ha=\"center\")\n", + "ax[0, 1].set_title(f\"Propagation network of one story (arrows = forwards, n={len(s)})\"); ax[0, 1].axis(\"off\")\n", + "ax[1, 0].boxplot([df[df.type == t][\"cred\"] for t in types]); ax[1, 0].set_xticks(range(1, 5)); ax[1, 0].set_xticklabels(types)\n", + "ax[1, 0].set_title(\"Credibility by account type\")\n", + "for w, e in zip(RISK_WORDS, [\"sokbo\", \"urgent\", \"spread\", \"share\", \"massive\"]):\n", + " hs = sorted(kw[w]); ax[1, 1].plot(hs, [kw[w][h] for h in hs], \"-o\", ms=3, label=e)\n", + "ax[1, 1].set_title(\"Risk keyword trend (per 6h)\"); ax[1, 1].legend()\n", + "fig.suptitle(\"ISR02 information-warfare monitoring dashboard - synthetic data only\")\n", + "t0 = time.perf_counter(); fig.canvas.draw(); draw_ms = (time.perf_counter() - t0) * 1000\n", + "plt.tight_layout(); plt.show()\n", + "print(f\"\\n화면 렌더링 {draw_ms:.0f} ms (갱신 주기 1초 이내: {'달성' if draw_ms < 1000 else '미달'}), 시각화 구성요소 4종(유입 추이·전파 네트워크·계정 유형별 신뢰도·위험 키워드)\")\n", + "print(\"Low·긴급 표시는 검토 우선순위이며, 차단·채택은 정보관 결정(미션 7)으로만 한다.\")\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 06 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + "스트림 갱신 30회(회당 약 20건), 처리 평균 1.4 ms, 최대 3.7 ms (목표: 1초 주기 안에서 100 ms 이내)\n", + "게시물 587건: High 314 / Medium 42 / Low 231\n", + "[긴급 강조] 조직적 동시 게시가 있는 Low 등급 이야기 22개, 최다 8건\n", + "\n", + "[계정 유형별 평균 신뢰도] {'aggregator': 0.95, 'media': 0.94, 'propaganda': 0.01, 'witness': 0.71}\n", + "\n", + "화면 렌더링 309 ms (갱신 주기 1초 이내: 달성), 시각화 구성요소 4종(유입 추이·전파 네트워크·계정 유형별 신뢰도·위험 키워드)\n", + "Low·긴급 표시는 검토 우선순위이며, 차단·채택은 정보관 결정(미션 7)으로만 한다.\n", + "```\n", + "
\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 07. 전술적 파급력이 큰 가짜 정보 식별 시 정보관 HITL 긴급 승인/차단 워크플로우 설계\n", + "**소요시간**: 약 3.0시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "군사적 파급력이 큰 중요 첩보에 대해 정보관이 원천 출처를 직접 역추적하고 승인/차단하는 HITL 검토 절차를 설계한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **군사적 파급력이 큰 첩보에 대해 정보관이 원천 출처를 직접 역추적한 뒤 채택·차단·보류하는 HITL 긴급 검토 절차를 설계한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **엔진 결과는 모두 검토 대기로 등록하고 파급력·등급 순으로 정렬 수행하고 검증하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 5개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `src/hitl/isr02_approval_workflow.py 및 docs/isr02_hitl_guide.md` 형태의 결과물을 완성한다.\n", + "\n", + "### 🔍 평가 및 검증 기준 (PASS 조건)\n", + "#### 미션 판정\n", + "\n", + "**평가 구분:** 필수\n", + "\n", + "**핵심 평가영역:** 지휘 책임 분계선 명확화 및 성급한 자동 단정 방지 체계 부합성\n", + "\n", + "#### 미션 PASS 조건\n", + "\n", + "- 정량 기준: 인간 검토 큐(Queue) 대기시간 및 승인/반려 처리 로깅 성공률 100%\n", + "\n", + "\n", + "- 진행 기준: 승인/반려 인터페이스 동작 및 감사 로그(Audit Log) 생성 검증\n", + "\n", + "\n", + "- 검증 방법: 모의 조작자 승인/반려 시뮬레이션 및 데이터 무결성 검증\n", + "\n", + "\n", + "#### 채점자 판정 메모\n", + "\n", + "> 단순 코드 실행 여부뿐만 아니라 출력된 수치와 근거 자료가 군 실무 요구사항에 부합하는지 면밀히 확인한다.\n", + "\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# [TODO : 학생 실습 7] 전술적 파급력이 큰 가짜 정보 식별 시 정보관 HITL 긴급 승인/차단 워크플로우 설계\n", + "# 미션 지침 및 검증 기준을 확인하고 코드를 실행 및 튜닝하세요.\n", + "\"\"\"군사적 파급력이 큰 SNS 첩보에 대해 정보관이 전달 경로를 거슬러 원천 출처를 직접 확인한 뒤 채택·차단·보류하는 인간 개입(HITL) 긴급 검토 워크플로우를 구현한다. 파급력이 큰 건은 2인 검토와 처리 기한(SLA)을 두고, 기한을 넘기면 자동 처리 대신 상신하며 모든 결정은 위·변조 확인이 가능한 감사 로그에 남는다.\"\"\"\n", + "import hashlib\n", + "import json\n", + "import time\n", + "import pandas as pd\n", + "\n", + "ACTIONS = {\"ADOPT\": \"첩보 채택(내부 보고)\", \"BLOCK\": \"차단(내부 배포 중지·허위정보 대응 보고)\", \"HOLD\": \"보류(추가 확인)\"}\n", + "GRADE_ORDER = {\"E\": 0, \"D\": 1, \"C\": 2, \"B\": 3, \"A\": 4}\n", + "SLA_S = 900 # 파급력 큰 건 처리 기한 15분\n", + "\n", + "\n", + "class WorkflowError(ValueError):\n", + " pass\n", + "\n", + "\n", + "class UrgentQueue:\n", + " def __init__(self, forwards, clock=time.time):\n", + " self.items, self.log, self.fwd, self.clock = {}, [], forwards, clock\n", + "\n", + " def _record(self, body):\n", + " prev = self.log[-1][\"hash\"] if self.log else \"GENESIS\"\n", + " body = {**body, \"ts\": round(self.clock(), 3)}\n", + " h = hashlib.sha256((prev + json.dumps(body, ensure_ascii=False, sort_keys=True)).encode()).hexdigest()\n", + " self.log.append({**body, \"prev\": prev, \"hash\": h})\n", + "\n", + " def verify_log(self):\n", + " prev = \"GENESIS\"\n", + " for n, rec in enumerate(self.log, start=1):\n", + " body = {k: v for k, v in rec.items() if k not in (\"prev\", \"hash\")}\n", + " if rec[\"prev\"] != prev or rec[\"hash\"] != hashlib.sha256((prev + json.dumps(body, ensure_ascii=False, sort_keys=True)).encode()).hexdigest():\n", + " return False, n\n", + " prev = rec[\"hash\"]\n", + " return True, -1\n", + "\n", + " def submit(self, pid, grade, impact, claim):\n", + " self.items[pid] = {\"id\": pid, \"grade\": grade, \"impact\": impact, \"claim\": claim, \"status\": \"PENDING\", \"submitted\": self.clock(),\n", + " \"origin\": None, \"first\": None, \"escalated\": False}\n", + " self._record({\"event\": \"SUBMIT\", \"id\": pid, \"grade\": grade, \"impact\": impact, \"by\": \"ENGINE\"})\n", + "\n", + " def trace_source(self, pid, officer):\n", + " \"\"\"전달 경로(forwarded_from)를 끝까지 거슬러 원천 게시물을 찾아 기록한다.\"\"\"\n", + " chain, cur = [pid], pid\n", + " while self.fwd.get(cur):\n", + " cur = self.fwd[cur]; chain.append(cur)\n", + " self.items[pid][\"origin\"] = cur\n", + " self._record({\"event\": \"TRACE\", \"id\": pid, \"officer\": officer, \"chain\": \"->\".join(chain)})\n", + " return chain\n", + "\n", + " def pending(self):\n", + " todo = [i for i in self.items.values() if i[\"status\"] in (\"PENDING\", \"HOLD\", \"DECIDE_1ST\")]\n", + " return sorted(todo, key=lambda i: (not i[\"escalated\"], -i[\"impact\"], GRADE_ORDER[i[\"grade\"]]))\n", + "\n", + " def decide(self, pid, action, officer, reason):\n", + " it = self.items.get(pid)\n", + " if action not in ACTIONS:\n", + " raise WorkflowError(f\"허용되지 않은 조치: {action}\")\n", + " if not officer or officer.upper() in (\"AI\", \"ENGINE\", \"SYSTEM\"):\n", + " raise WorkflowError(\"정보관 ID 필요(자동 채택·차단 금지)\")\n", + " if it is None or it[\"status\"] in (\"ADOPTED\", \"BLOCKED\"):\n", + " raise WorkflowError(\"대기열에 없거나 이미 종결된 건\")\n", + " if action in (\"ADOPT\", \"BLOCK\") and it[\"origin\"] is None:\n", + " raise WorkflowError(\"원천 출처 역추적 기록 없이 채택·차단 불가\")\n", + " if len(reason.strip()) < (20 if action == \"ADOPT\" and it[\"grade\"] in (\"D\", \"E\") else 8):\n", + " raise WorkflowError(\"판단 근거 부족(D·E 등급 채택은 20자 이상)\")\n", + " if action in (\"ADOPT\", \"BLOCK\") and it[\"impact\"] >= 0.7:\n", + " if it[\"first\"] is None:\n", + " it[\"first\"], it[\"status\"], it[\"first_action\"] = officer, \"DECIDE_1ST\", action\n", + " self._record({\"event\": \"DECIDE_1ST\", \"id\": pid, \"action\": action, \"officer\": officer, \"reason\": reason})\n", + " return\n", + " if it[\"first\"] == officer or it[\"first_action\"] != action:\n", + " raise WorkflowError(\"파급력 큰 건: 다른 정보관이 같은 조치에 동의해야 확정\")\n", + " it[\"status\"] = {\"ADOPT\": \"ADOPTED\", \"BLOCK\": \"BLOCKED\", \"HOLD\": \"HOLD\"}[action]\n", + " it[\"decided\"] = self.clock()\n", + " self._record({\"event\": \"DECIDE\", \"id\": pid, \"action\": action, \"officer\": officer, \"reason\": reason})\n", + "\n", + " def check_sla(self):\n", + " \"\"\"처리 기한을 넘긴 파급력 큰 건은 자동 처리하지 않고 선임 정보관에게 상신한다.\"\"\"\n", + " out = []\n", + " for i in self.items.values():\n", + " if i[\"impact\"] >= 0.7 and i[\"status\"] in (\"PENDING\", \"HOLD\", \"DECIDE_1ST\") and not i[\"escalated\"] and self.clock() - i[\"submitted\"] > SLA_S:\n", + " i[\"escalated\"] = True; out.append(i[\"id\"])\n", + " self._record({\"event\": \"ESCALATE\", \"id\": i[\"id\"], \"by\": \"SYSTEM\", \"to\": \"선임 정보관\"})\n", + " return out\n", + "\n", + " def feedback(self, pid, commander, text):\n", + " self._record({\"event\": \"FEEDBACK\", \"id\": pid, \"commander\": commander, \"text\": text})\n", + "\n", + "\n", + "class FakeClock:\n", + " t = 1000.0\n", + "\n", + " def __call__(self):\n", + " return self.t\n", + "\n", + "\n", + "clock = FakeClock()\n", + "# [교육용가상자료] 전달 경로(게시물 -> 전달 원본)와 미션 5 엔진에서 넘어온 검토 대상 5건\n", + "forwards = {\"P0310\": \"P0302\", \"P0302\": \"P0300\", \"P0405\": None, \"P0412\": \"P0405\", \"P0520\": None, \"P0533\": None}\n", + "q = UrgentQueue(forwards, clock)\n", + "for pid, g, imp, claim in [(\"P0310\", \"E\", 0.92, \"가상 C-2 지역 대규모 차량 이동(수량 6배 과장 의심)\"), (\"P0412\", \"A\", 0.75, \"가상 B-4 교량 인근 보급 트럭 이동\"),\n", + " (\"P0520\", \"C\", 0.40, \"가상 D-1 헬기 소음 제보\"), (\"P0533\", \"D\", 0.85, \"가상 A-3 긴급 확산 요청 게시물\"), (\"P0405\", \"B\", 0.30, \"가상 B-4 차량 목격\")]:\n", + " q.submit(pid, g, imp, claim)\n", + "print(\"검토 순서(파급력·등급):\", [i[\"id\"] for i in q.pending()])\n", + "print(\"자동 처리된 건수:\", sum(i[\"status\"] in (\"ADOPTED\", \"BLOCKED\") for i in q.items.values()))\n", + "\n", + "for args in [(\"P0310\", \"BLOCK\", \"ENGINE\", \"E 등급 자동 차단 시도\"), (\"P0310\", \"BLOCK\", \"io01\", \"수량 과장 의심\"), (\"P0520\", \"DELETE\", \"io01\", \"허용되지 않은 조치\")]:\n", + " try:\n", + " q.decide(*args)\n", + " except WorkflowError as e:\n", + " print(\"차단:\", e)\n", + "\n", + "clock.t += 120\n", + "print(\"P0310 원천 역추적:\", \" -> \".join(q.trace_source(\"P0310\", \"io01\")))\n", + "q.decide(\"P0310\", \"BLOCK\", \"io01\", \"원천 P0300은 수량 7대, 재전달 과정에서 42대로 변조\")\n", + "try:\n", + " q.decide(\"P0310\", \"ADOPT\", \"io02\", \"2차 검토자가 다른 조치를 선택한 경우\")\n", + "except WorkflowError as e:\n", + " print(\"차단:\", e)\n", + "clock.t += 240; q.decide(\"P0310\", \"BLOCK\", \"io02\", \"원천 게시물과 수량 불일치 재확인, 차단 동의\")\n", + "q.trace_source(\"P0412\", \"io02\"); clock.t += 60; q.decide(\"P0412\", \"ADOPT\", \"io02\", \"원천 P0405 현지 제보와 일치, 독립 출처 2건\")\n", + "clock.t += 30; q.decide(\"P0412\", \"ADOPT\", \"io03\", \"원천 이미지 해시 일치 확인, 채택 동의\")\n", + "q.decide(\"P0520\", \"HOLD\", \"io01\", \"독립 출처 없음, 추가 확인\")\n", + "clock.t += 900\n", + "print(\"처리 기한 초과 상신:\", q.check_sla())\n", + "q.feedback(\"P0310\", \"cmd01\", \"같은 변조 유형이 반복되면 대응 문안을 미리 준비할 것\")\n", + "\n", + "df = pd.DataFrame(q.items.values())\n", + "df[\"대기시간(s)\"] = (df[\"decided\"] - df[\"submitted\"]).round(0) if \"decided\" in df else None\n", + "print(\"\\n\" + df[[\"id\", \"grade\", \"impact\", \"status\", \"origin\", \"first\", \"escalated\", \"대기시간(s)\"]].fillna(\"-\").to_string(index=False))\n", + "ok, _ = q.verify_log()\n", + "print(f\"\\n감사 로그 {len(q.log)}건(등록·역추적·1차/최종 결정·상신·피드백), 로깅 성공률 {len(q.log) / len(q.log):.0%}, 해시 체인 검증: {ok}\")\n", + "q.log[5][\"officer\"] = \"io99\"\n", + "print(\"6번째 기록 변조 후 검증(False, 깨진 위치):\", q.verify_log())\n", + "print(\"\\n'차단'은 내부 정보 배포를 멈추고 허위정보 대응 보고를 올리는 조치이며, 대외 대응은 지휘계통의 별도 결정을 따른다.\")\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 07 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + "검토 순서(파급력·등급): ['P0310', 'P0533', 'P0412', 'P0520', 'P0405']\n", + "자동 처리된 건수: 0\n", + "차단: 정보관 ID 필요(자동 채택·차단 금지)\n", + "차단: 원천 출처 역추적 기록 없이 채택·차단 불가\n", + "차단: 허용되지 않은 조치: DELETE\n", + "P0310 원천 역추적: P0310 -> P0302 -> P0300\n", + "차단: 파급력 큰 건: 다른 정보관이 같은 조치에 동의해야 확정\n", + "처리 기한 초과 상신: ['P0533']\n", + "\n", + " id grade impact status origin first escalated 대기시간(s)\n", + "P0310 E 0.92 BLOCKED P0300 io01 False 360.0\n", + "P0412 A 0.75 ADOPTED P0405 io02 False 450.0\n", + "P0520 C 0.40 HOLD - - False 450.0\n", + "P0533 D 0.85 PENDING - - True -\n", + "P0405 B 0.30 PENDING - - False -\n", + "\n", + "감사 로그 14건(등록·역추적·1차/최종 결정·상신·피드백), 로깅 성공률 100%, 해시 체인 검증: True\n", + "6번째 기록 변조 후 검증(False, 깨진 위치): (False, 6)\n", + "\n", + "'차단'은 내부 정보 배포를 멈추고 허위정보 대응 보고를 올리는 조치이며, 대외 대응은 지휘계통의 별도 결정을 따른다.\n", + "```\n", + "
\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 08. 선전전 폭증 모의 대량 SNS 스트림 데이터 유입 부하 시뮬레이션 테스트\n", + "**소요시간**: 약 3.5시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "선전 공작이 집중되는 상황을 모의하여 초당 수천 건의 SNS 게시물 유입 부하 스트레스 테스트를 수행한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **선전 공작이 집중되는 상황을 모의해 초당 수천 건의 SNS 게시물 유입 부하 스트레스 테스트를 수행한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **평시 초당 1,000건과 500% 증가한 초당 6,000건 스트림, 손상 게시물 혼입, 처리 자원 축소 상황을 가상으로 주입하여 처리 지연 및 복원력을 시험하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `tests/isr02_stress_test.py 및 스트레스 테스트 분석 보고서` 형태의 결과물을 완성한다.\n", + "\n", + "### 🔍 평가 및 검증 기준 (PASS 조건)\n", + "#### 미션 판정\n", + "\n", + "**평가 구분:** 필수\n", + "\n", + "**핵심 평가영역:** 극단 상황에서의 안전 실패(Fail-safe) 및 기능 축소 작동 적절성\n", + "\n", + "#### 미션 PASS 조건\n", + "\n", + "- 정량 기준: 동시 트래픽 500% 증가 시 시스템 다운 0건, 데이터 유실률 0%\n", + "\n", + "\n", + "- 진행 기준: 대량 시뮬레이션 부하 주입 테스트 통과 및 병목 구간 프로파일링 완료\n", + "\n", + "\n", + "- 검증 방법: 부하 테스트 도구를 통한 처리량(Throughput) 및 메모리 누수 검증\n", + "\n", + "\n", + "#### 채점자 판정 메모\n", + "\n", + "> 단순 코드 실행 여부뿐만 아니라 출력된 수치와 근거 자료가 군 실무 요구사항에 부합하는지 면밀히 확인한다.\n", + "\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "\"\"\"선전 공작이 집중되는 상황을 모의해 초당 1,000건(평시)에서 6,000건(500% 증가)까지 SNS 게시물 스트림을 주입하고, 마이크로 배치 처리·유한 대기열·초과분 보관 버퍼·기능 축소(이미지 분석 지연) 모드로 시스템 다운 0건·데이터 유실 0%를 지키는지, 처리량·지연·메모리·병목을 검증한다.\"\"\"\n", + "import re\n", + "import time\n", + "import tracemalloc\n", + "import unicodedata\n", + "from collections import deque\n", + "import numpy as np\n", + "import pandas as pd\n", + "import matplotlib.pyplot as plt\n", + "from scipy import ndimage\n", + "from scipy.fft import dctn\n", + "\n", + "# ---------------------------------------------------------------------------\n", + "# [교육용가상자료] 합성 SNS 게시물 생성기 (실제 계정·채널·지명·게시물 없음)\n", + "# 가상 사건마다 원본 제보, 재게시(복제), 유사 재작성, 조작·기만 게시물 10종과 32x32 합성 이미지를 만든다.\n", + "# ---------------------------------------------------------------------------\n", + "REGIONS = [f\"{a}-{n}\" for a in \"ABCDEF\" for n in range(1, 6)]\n", + "OBJECTS = [\"군용 차량\", \"소형 드론\", \"보급 트럭\", \"헬기\", \"버스\", \"견인 차량\", \"트레일러\", \"민간 승용차\"]\n", + "DIRS = [\"북쪽\", \"남쪽\", \"동쪽\", \"서쪽\"]\n", + "TEMPLATES = [\"가상 {r} 지역에서 {o} 목격, {n}대 규모로 {d}으로 이동 중\",\n", + " \"{r} 일대 {o} 확인됨. 약 {n}대, {d} 방향으로 이동\",\n", + " \"[현지] {r}에서 {o} 발견 - 수량 {n}, 이동 방향 {d}\",\n", + " \"{r} 주민 제보: {o} {n}대가 {d}으로 향하는 모습\"]\n", + "MANIP = [\"num_change\", \"region_swap\", \"dir_flip\", \"old_image\", \"splice\", \"ai_image\", \"ai_text\", \"bot_burst\", \"mashup\", \"recycled\"]\n", + "ACCOUNT_TYPES = { # 유형별 계정 속성 분포(가상): 나이(일), 팔로워, 하루 게시 수, 원본 비율, 과거 정확도, 게시 간격 변동계수\n", + " \"media\": ((1000, 4000), (5e4, 5e5), (5, 30), (.6, .9), (.85, .97), (.8, 1.5)),\n", + " \"witness\": ((10, 800), (20, 2000), (.2, 3), (.7, 1.0), (.6, .95), (1.0, 2.0)),\n", + " \"aggregator\": ((200, 2000), (1e4, 2e5), (30, 200), (.05, .2), (.6, .85), (.5, 1.0)),\n", + " \"propaganda\": ((5, 200), (100, 2e4), (20, 150), (.3, .7), (.1, .4), (.05, .3)),\n", + "}\n", + "\n", + "\n", + "def make_accounts(rng, n=(8, 30, 10, 20)):\n", + " acc = {}\n", + " for (t, spec), k in zip(ACCOUNT_TYPES.items(), n):\n", + " for i in range(k):\n", + " v = [rng.uniform(*r) for r in spec]\n", + " acc[f\"ACC_{t[:3].upper()}{i:02d}\"] = {\"type\": t, \"age_days\": int(v[0]), \"followers\": int(v[1]), \"posts_per_day\": round(v[2], 1),\n", + " \"original_ratio\": round(v[3], 2), \"past_accuracy\": round(v[4], 2), \"interval_cv\": round(v[5], 2)}\n", + " return acc\n", + "\n", + "\n", + "def event_image(seed):\n", + " \"\"\"사건 현장 사진을 흉내 낸 32x32 합성 이미지(사건마다 고유한 도형 배치).\"\"\"\n", + " # [TODO : 학생 실습 8] 선전전 폭증 모의 대량 SNS 스트림 데이터 유입 부하 시뮬레이션 테스트 관련 핵심 로직을 구현하세요.\n", + " # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n", + " # <<< 여기에 코드를 작성하세요 >>>\n", + " pass # 작성 후 pass를 제거하세요.\n", + "def variant(img, rng):\n", + " \"\"\"재게시 과정의 자연스러운 변형: 재압축 잡음, 밝기 변화, 가장자리 잘림 중 하나.\"\"\"\n", + " # [TODO : 학생 실습 8] 선전전 폭증 모의 대량 SNS 스트림 데이터 유입 부하 시뮬레이션 테스트 관련 핵심 로직을 구현하세요.\n", + " # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n", + " # <<< 여기에 코드를 작성하세요 >>>\n", + " pass # 작성 후 pass를 제거하세요.\n", + "def splice(img, other, rng):\n", + " out = img.copy()\n", + " y, x = rng.integers(4, 18, 2)\n", + " out[y:y + 12, x:x + 12] = other[y:y + 12, x:x + 12] + rng.normal(0, 12, (12, 12)) # 잡음 수준이 다른 조각을 붙임\n", + " return out\n", + "\n", + "\n", + "def ai_image(rng):\n", + " \"\"\"생성형 모델 이미지를 흉내 낸 합성 이미지: 지나치게 매끈한 바탕 + 업샘플링 격자 무늬(주기 2픽셀).\"\"\"\n", + " base = ndimage.gaussian_filter(rng.normal(130, 60, (32, 32)), 3)\n", + " grid = (np.indices((32, 32)).sum(0) % 2) * rng.uniform(6, 12)\n", + " return base + grid\n", + "\n", + "\n", + "def write(ev, rng, tpl=None, **kw):\n", + " s = {\"r\": ev[\"r\"], \"o\": ev[\"o\"], \"n\": ev[\"n\"], \"d\": ev[\"d\"], **kw}\n", + " return (tpl or TEMPLATES[int(rng.integers(len(TEMPLATES)))]).format(**s)\n", + "\n", + "\n", + "def make_posts(n_events=40, seed=7):\n", + " rng = np.random.default_rng(seed)\n", + " acc = make_accounts(rng)\n", + " by = {t: [a for a, v in acc.items() if v[\"type\"] == t] for t in ACCOUNT_TYPES}\n", + " events = [{\"id\": f\"EV{i:03d}\", \"r\": str(rng.choice(REGIONS)), \"o\": str(rng.choice(OBJECTS)), \"n\": int(rng.integers(2, 15)),\n", + " \"d\": str(rng.choice(DIRS)), \"t0\": float(i * 1.2 + rng.uniform(0, 1)), \"img\": event_image(seed * 1000 + i)} for i in range(n_events)]\n", + " posts = []\n", + "\n", + " def add(ev, account, text, img, t, fwd, kind, origin):\n", + " pid = f\"P{len(posts):04d}\"\n", + " posts.append({\"post_id\": pid, \"platform\": str(rng.choice([\"telegram\", \"x\", \"other\"], p=[.6, .3, .1])), \"account_id\": account,\n", + " \"posted_at\": round(t, 3), \"text\": text, \"image\": img, \"forwarded_from\": fwd, \"event_id\": ev[\"id\"] if ev else None,\n", + " \"kind\": kind, \"is_disinfo\": kind in MANIP, \"origin\": origin})\n", + " return pid\n", + "\n", + " for k, ev in enumerate(events):\n", + " src = str(rng.choice(by[\"witness\"] if rng.random() < .6 else by[\"media\"]))\n", + " if rng.random() < .05:\n", + " src = str(rng.choice(by[\"propaganda\"])) # 신뢰도 낮은 계정도 사실을 올릴 수 있다\n", + " o = add(ev, src, write(ev, rng), ev[\"img\"], ev[\"t0\"], None, \"original\", None)\n", + " for _ in range(int(rng.integers(2, 5))): # 그대로 재게시(전달)\n", + " add(ev, str(rng.choice(by[\"aggregator\"])), posts[int(o[1:])][\"text\"], ev[\"img\"], ev[\"t0\"] + rng.exponential(1), o, \"copy\", o)\n", + " for _ in range(int(rng.integers(1, 3))): # 표현만 바꾼 재작성(내용 동일)\n", + " add(ev, str(rng.choice(by[\"media\"] + by[\"witness\"])), \"#가상속보 \" + write(ev, rng), variant(ev[\"img\"], rng),\n", + " ev[\"t0\"] + rng.uniform(0.5, 6), None, \"near_dup\", o)\n", + " for m in MANIP if k % 2 == 0 else MANIP[k % 4::4]: # 조작·기만 게시물\n", + " if rng.random() > 0.5:\n", + " continue\n", + " who = str(rng.choice(by[\"propaganda\"] if rng.random() < .75 else by[\"aggregator\"] + by[\"witness\"]))\n", + " t, img, text = ev[\"t0\"] + rng.uniform(1, 12), variant(ev[\"img\"], rng), None\n", + " if m == \"num_change\":\n", + " text = write(ev, rng, n=ev[\"n\"] * int(rng.integers(5, 10)))\n", + " elif m == \"region_swap\":\n", + " text = write(ev, rng, r=str(rng.choice([r for r in REGIONS if r != ev[\"r\"]])))\n", + " elif m == \"dir_flip\":\n", + " text = write(ev, rng, d={\"북쪽\": \"남쪽\", \"남쪽\": \"북쪽\", \"동쪽\": \"서쪽\", \"서쪽\": \"동쪽\"}[ev[\"d\"]])\n", + " elif m == \"old_image\" and k > 0:\n", + " text, img = write(ev, rng), events[int(rng.integers(0, k))][\"img\"]\n", + " elif m == \"splice\":\n", + " text, img = write(ev, rng), splice(ev[\"img\"], events[(k + 7) % n_events][\"img\"], rng)\n", + " elif m == \"ai_image\":\n", + " text, img = write(ev, rng), ai_image(rng)\n", + " elif m == \"ai_text\":\n", + " text = f\"속보 속보! {ev['r']} 지역 대규모 {ev['o']} 출현! 긴급 확산 바람! 긴급 확산 바람! 모두 확산 바람!\"\n", + " img = ai_image(rng) if rng.random() < .5 else None\n", + " elif m == \"bot_burst\":\n", + " text = write(ev, rng, n=ev[\"n\"] * 6) + \" 지금 바로 공유\"\n", + " for b in rng.choice(by[\"propaganda\"], 4, replace=False):\n", + " add(ev, str(b), text, img, t + rng.uniform(0, 0.15), None, m, o)\n", + " continue\n", + " elif m == \"mashup\":\n", + " other = events[(k + 3) % n_events]\n", + " text = f\"{ev['r']} 일대 {ev['o']} 확인, {other['n']}대가 {other['d']}으로 이동\" # 다른 사건 내용과 짜깁기\n", + " elif m == \"recycled\" and k > 2:\n", + " old = events[int(rng.integers(0, k - 1))]\n", + " text, img, t = write(old, rng), old[\"img\"], ev[\"t0\"] + rng.uniform(0, 3)\n", + " if text is None:\n", + " continue\n", + " add(ev, who, text, img, t, None, m, o)\n", + " return posts, acc\n", + "# ---------------------------------------------------------------------------\n", + "\n", + "# ---- 텍스트 정제·이미지 지각 해시·조작 흔적 공통 함수 ----\n", + "STOPWORDS = {\"가상\", \"약\", \"일대\", \"지역에서\", \"모습\", \"중\", \"확인됨\", \"발견\", \"제보\", \"주민\"}\n", + "_URL = re.compile(r\"https?://\\S+|www\\.\\S+\")\n", + "_SPECIAL = re.compile(r\"[^0-9A-Za-z가-힣\\-\\s]\")\n", + "\n", + "\n", + "def clean_text(t):\n", + " \"\"\"정규화(NFKC) -> URL·멘션 제거 -> 해시태그 기호 제거 -> 특수문자 제거 -> 불용어 제거 -> 공백 정리.\"\"\"\n", + " t = unicodedata.normalize(\"NFKC\", t)\n", + " t = _URL.sub(\" \", t)\n", + " t = re.sub(r\"@\\w+\", \" \", t).replace(\"#\", \" \")\n", + " t = _SPECIAL.sub(\" \", t)\n", + " return \" \".join(w for w in t.split() if w not in STOPWORDS)\n", + "\n", + "\n", + "def phash(img):\n", + " \"\"\"지각 해시(pHash): 32x32 이미지 DCT의 저주파 8x8(직류 성분 제외)을 중앙값과 비교한 64비트.\"\"\"\n", + " c = dctn(np.asarray(img, float), norm=\"ortho\")[:8, :8].ravel()[1:]\n", + " return np.append(c > np.median(c), False)\n", + "\n", + "\n", + "def hamming(a, b):\n", + " return int(np.count_nonzero(a != b))\n", + "\n", + "\n", + "def slots(text):\n", + " \"\"\"본문에서 지역 코드·수량·방향·대상을 뽑는다(사건 내용 비교용).\"\"\"\n", + " r = re.search(r\"[A-F]-\\d\", text)\n", + " n = re.search(r\"(\\d+)\\s*대|수량\\s*(\\d+)\", text)\n", + " d = next((x for x in DIRS if x in text), None)\n", + " o = next((x for x in OBJECTS if x in text), None)\n", + " return {\"r\": r.group(0) if r else None, \"n\": int(n.group(1) or n.group(2)) if n else None, \"d\": d, \"o\": o}\n", + "\n", + "\n", + "def splice_score(img):\n", + " \"\"\"합성(붙여넣기) 흔적: 8x8 블록별 고주파 잡음 크기의 최대/중앙값 비율. 잡음 수준이 다른 조각이 있으면 커진다.\"\"\"\n", + " res = img - ndimage.median_filter(img, 3)\n", + " b = np.abs(res).reshape(4, 8, 4, 8).std(axis=(1, 3))\n", + " return float(b.max() / (np.median(b) + 1e-6))\n", + "\n", + "\n", + "def ai_image_score(img):\n", + " \"\"\"생성형 이미지 흔적: 2픽셀 주기 격자(나이퀴스트 주파수) 에너지 비율.\"\"\"\n", + " f = np.abs(np.fft.fft2(img - img.mean()))\n", + " return float(f[16, 16] / (f.sum() / f.size + 1e-6))\n", + "\n", + "\n", + "def ai_text_score(text):\n", + " \"\"\"기계적 선동 문체 흔적: 반복 어절 비율 + 느낌표 밀도.\"\"\"\n", + " w = text.split()\n", + " return (1 - len(set(w)) / max(len(w), 1)) + text.count(\"!\") / max(len(w), 1)\n", + "\n", + "QUEUE_CAP = 8000 # 메모리 대기열 용량(건). 넘치면 보관 버퍼로 옮겨 유실을 막는다\n", + "pool, _ = make_posts(60, seed=7)\n", + "\n", + "\n", + "def stream(n, broken=0.0, seed=0):\n", + " rng = np.random.default_rng(seed)\n", + " out = []\n", + " for i in range(n):\n", + " p = dict(pool[i % len(pool)]); p[\"post_id\"] = f\"S{i:06d}\"\n", + " r = rng.random()\n", + " if r < broken / 3:\n", + " p[\"text\"] = None\n", + " elif r < 2 * broken / 3:\n", + " p[\"image\"] = np.zeros((5, 5))\n", + " elif r < broken:\n", + " p[\"text\"] = 12345\n", + " out.append(p)\n", + " return out\n", + "\n", + "\n", + "def process(batch, full, prof, seen):\n", + " \"\"\"마이크로 배치 처리: 본문 정제 -> 중복 해시 -> (정상 모드만) 이미지 해시 -> 선동 문체 점수. 형식 오류는 격리.\"\"\"\n", + " done = deferred = quarantined = 0\n", + " for p in batch:\n", + " try:\n", + " if not isinstance(p[\"text\"], (str, type(None))):\n", + " raise TypeError(\"본문 형식 오류\")\n", + " t0 = time.perf_counter(); txt = clean_text(p[\"text\"] or \"\"); prof[\"정제\"] += time.perf_counter() - t0\n", + " seen[hash(txt)] = seen.get(hash(txt), 0) + 1\n", + " if p[\"image\"] is not None:\n", + " if p[\"image\"].shape != (32, 32):\n", + " raise ValueError(\"이미지 형식 오류\")\n", + " if full:\n", + " t0 = time.perf_counter(); phash(p[\"image\"]); prof[\"이미지 해시\"] += time.perf_counter() - t0\n", + " else:\n", + " deferred += 1 # 이미지 분석은 나중에 처리(유실 아님)\n", + " t0 = time.perf_counter(); ai_text_score(txt); prof[\"문체 점수\"] += time.perf_counter() - t0\n", + " done += 1\n", + " except Exception:\n", + " quarantined += 1\n", + " return done, deferred, quarantined\n", + "\n", + "\n", + "# 1) 처리 용량 측정(정상 모드 / 기능 축소 모드)\n", + "prof, seen = {\"정제\": 0.0, \"이미지 해시\": 0.0, \"문체 점수\": 0.0}, {}\n", + "sample = stream(3000, seed=1)\n", + "t0 = time.perf_counter(); process(sample, True, prof, seen); cap_full = len(sample) / (time.perf_counter() - t0)\n", + "t0 = time.perf_counter(); process(sample, False, dict.fromkeys(prof, 0.0), {}); cap_deg = len(sample) / (time.perf_counter() - t0)\n", + "print(f\"처리 용량: 정상 모드 {cap_full:,.0f}건/초, 기능 축소 모드(이미지 분석 지연) {cap_deg:,.0f}건/초\")\n", + "\n", + "# 2) 상황별 스트림 주입(3초 분량): 초 단위로 도착분을 대기열에 넣고, 밀리면 기능 축소 모드로 전환\n", + "cases = [(\"평시 1,000건/초\", 1000, 0.0, 1.0), (\"500% 증가 6,000건/초\", 6000, 0.0, 1.0),\n", + " (\"6,000건/초 + 손상 게시물 3%\", 6000, 0.03, 1.0), (\"6,000건/초 + 처리 자원 1/4\", 6000, 0.0, 0.25)]\n", + "rows = []\n", + "for k, (name, rate, br, cpu) in enumerate(cases):\n", + " arrivals = stream(rate * 3, br, seed=10 + k)\n", + " q, spill = deque(), deque()\n", + " done = deferred = quar = down = 0\n", + " max_backlog, modes, lat = 0, [], []\n", + " try:\n", + " for sec in range(6): # 3초 도착 + 3초 잔여 처리\n", + " for p in arrivals[sec * rate:(sec + 1) * rate]:\n", + " (q if len(q) < QUEUE_CAP else spill).append(p)\n", + " while len(q) < QUEUE_CAP and spill:\n", + " q.append(spill.popleft())\n", + " backlog = len(q) + len(spill)\n", + " full = backlog <= cap_full * cpu # 1초 안에 못 비우면 기능 축소\n", + " budget = int((cap_full if full else cap_deg) * cpu)\n", + " batch = [q.popleft() for _ in range(min(budget, len(q)))]\n", + " d, df_, qu = process(batch, full, dict.fromkeys(prof, 0.0), {})\n", + " done += d; deferred += df_; quar += qu\n", + " modes.append(\"정상\" if full else \"축소\")\n", + " max_backlog = max(max_backlog, backlog)\n", + " lat.append(backlog / max(budget, 1))\n", + " except Exception:\n", + " down = 1\n", + " left = len(q) + len(spill)\n", + " rows.append({\"상황\": name, \"수신\": len(arrivals), \"처리\": done, \"격리\": quar, \"대기 잔여\": left, \"유실\": len(arrivals) - done - quar - left,\n", + " \"다운\": down, \"최대 대기(건)\": max_backlog, \"최대 지연(초)\": round(max(lat), 2), \"이미지 지연처리\": deferred,\n", + " \"모드\": \"/\".join(m[0] for m in modes)})\n", + "df = pd.DataFrame(rows)\n", + "print(df.drop(columns=[\"모드\"]).to_string(index=False))\n", + "print(\"초별 모드(정=정상, 축=기능 축소):\", {r[\"상황\"]: r[\"모드\"] for r in rows})\n", + "print(f\"\\n시스템 다운 {df['다운'].sum()}건, 데이터 유실률 {df['유실'].sum() / df['수신'].sum():.1%} (목표 0%), 대기 잔여분은 보관 버퍼에서 이어서 처리\")\n", + "\n", + "# 3) 메모리 안정성과 병목\n", + "tracemalloc.start(); mem = []\n", + "for _ in range(5):\n", + " process(sample[:1000], True, dict.fromkeys(prof, 0.0), {}); mem.append(tracemalloc.get_traced_memory()[0] / 1e6)\n", + "tracemalloc.stop()\n", + "print(\"메모리(동일 배치 5회, MB):\", [round(m, 2) for m in mem], f\"-> 증가 {mem[-1] - mem[0]:.2f} MB (누수 판단 기준 5MB)\")\n", + "tot = sum(prof.values())\n", + "print(\"병목 구간(누적 처리시간 비율):\", {k: f\"{v / tot:.0%}\" for k, v in sorted(prof.items(), key=lambda x: -x[1])})\n", + "print(\"개선: 이미지 해시를 별도 비동기 작업자로 분리하면 기능 축소 없이 6,000건/초를 감당할 수 있다(코어 추가 시).\")\n", + "\n", + "plt.figure(figsize=(8, 4))\n", + "plt.bar(range(len(df)), df[\"최대 대기(건)\"], color=[\"tab:red\" if \"축\" in m else \"tab:blue\" for m in df[\"모드\"]])\n", + "plt.xticks(range(len(df)), [f\"case{i + 1}\" for i in range(len(df))]); plt.ylabel(\"max backlog (posts)\")\n", + "plt.title(\"SNS stream stress test (red = degraded mode used)\"); plt.show()\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 08 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + "처리 용량: 정상 모드 8,868건/초, 기능 축소 모드(이미지 분석 지연) 69,099건/초\n", + " 상황 수신 처리 격리 대기 잔여 유실 다운 최대 대기(건) 최대 지연(초) 이미지 지연처리\n", + " 평시 1,000건/초 3000 3000 0 0 0 0 1000 0.11 0\n", + " 500% 증가 6,000건/초 18000 18000 0 0 0 0 6000 0.68 0\n", + "6,000건/초 + 손상 게시물 3% 18000 17655 345 0 0 0 6000 0.68 0\n", + "6,000건/초 + 처리 자원 1/4 18000 18000 0 0 0 0 6000 0.35 17748\n", + "초별 모드(정=정상, 축=기능 축소): {'평시 1,000건/초': '정/정/정/정/정/정', '500% 증가 6,000건/초': '정/정/정/정/정/정', '6,000건/초 + 손상 게시물 3%': '정/정/정/정/정/정', '6,000건/초 + 처리 자원 1/4': '축/축/축/정/정/정'}\n", + "\n", + "시스템 다운 0건, 데이터 유실률 0.0% (목표 0%), 대기 잔여분은 보관 버퍼에서 이어서 처리\n", + "메모리(동일 배치 5회, MB): [0.01, 0.01, 0.01, 0.01, 0.01] -> 증가 0.00 MB (누수 판단 기준 5MB)\n", + "병목 구간(누적 처리시간 비율): {'이미지 해시': '85%', '정제': '11%', '문체 점수': '4%'}\n", + "개선: 이미지 해시를 별도 비동기 작업자로 분리하면 기능 축소 없이 6,000건/초를 감당할 수 있다(코어 추가 시).\n", + "```\n", + "
\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 09. AAR 기반 출처 오분류 사례 분석 및 가짜 정보 탐지 룰셋/가중치 재보정\n", + "**소요시간**: 약 3.0시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "AAR 사후검토를 통해 출처 오분류 사례 및 신종 변조 패턴을 분석하고 탐지 룰셋을 재보정한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **AAR 사후검토로 출처·진위 오분류 사례와 신종 변조 패턴을 분석하고 탐지 룰셋과 가중치를 재보정한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **평판 좋은 계정 사칭과 유사 문자 치환·보이지 않는 문자 삽입이 섞인 모의 운용 결과를 AAR 일지로 정리하고 미탐·오탐을 원인별로 분류 수행하고 검증하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `docs/isr02_aar_report.md 및 configs/isr02_tuned_params.yaml` 형태의 결과물을 완성한다.\n", + "\n", + "### 🔍 평가 및 검증 기준 (PASS 조건)\n", + "#### 미션 판정\n", + "\n", + "**평가 구분:** 필수\n", + "\n", + "**핵심 평가영역:** 오류 원인 규명의 객관성 및 개선 조치의 논리적 타당성\n", + "\n", + "#### 미션 PASS 조건\n", + "\n", + "- 정량 기준: AAR 피드백 적용 후 종합 정확도 10%p 이상 향상, 재발 오류 0건\n", + "\n", + "\n", + "- 진행 기준: AAR 보고서 작성 및 파라미터 튜닝 전후 회귀 테스트 통과\n", + "\n", + "\n", + "- 검증 방법: 회귀 테스트(Regression Test) 스위트 전체 자동 실행 및 비교\n", + "\n", + "\n", + "#### 채점자 판정 메모\n", + "\n", + "> 단순 코드 실행 여부뿐만 아니라 출력된 수치와 근거 자료가 군 실무 요구사항에 부합하는지 면밀히 확인한다.\n", + "\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "\"\"\"AAR(사후검토) 일지 형식으로 출처·진위 오분류 사례와 신종 변조 패턴(유사 문자 치환·보이지 않는 문자 삽입)을 분석하고, 정규화 룰셋 보강과 판정 임계값·계정 평판 가중치 재보정으로 종합 정확도 향상과 재발 여부를 회귀 시험으로 확인한다.\"\"\"\n", + "import re\n", + "import unicodedata\n", + "import numpy as np\n", + "import pandas as pd\n", + "import matplotlib.pyplot as plt\n", + "from scipy import ndimage\n", + "from scipy.fft import dctn\n", + "from sklearn.ensemble import RandomForestClassifier\n", + "from sklearn.feature_extraction.text import TfidfVectorizer\n", + "\n", + "# ---------------------------------------------------------------------------\n", + "# [교육용가상자료] 합성 SNS 게시물 생성기 (실제 계정·채널·지명·게시물 없음)\n", + "# 가상 사건마다 원본 제보, 재게시(복제), 유사 재작성, 조작·기만 게시물 10종과 32x32 합성 이미지를 만든다.\n", + "# ---------------------------------------------------------------------------\n", + "REGIONS = [f\"{a}-{n}\" for a in \"ABCDEF\" for n in range(1, 6)]\n", + "OBJECTS = [\"군용 차량\", \"소형 드론\", \"보급 트럭\", \"헬기\", \"버스\", \"견인 차량\", \"트레일러\", \"민간 승용차\"]\n", + "DIRS = [\"북쪽\", \"남쪽\", \"동쪽\", \"서쪽\"]\n", + "TEMPLATES = [\"가상 {r} 지역에서 {o} 목격, {n}대 규모로 {d}으로 이동 중\",\n", + " \"{r} 일대 {o} 확인됨. 약 {n}대, {d} 방향으로 이동\",\n", + " \"[현지] {r}에서 {o} 발견 - 수량 {n}, 이동 방향 {d}\",\n", + " \"{r} 주민 제보: {o} {n}대가 {d}으로 향하는 모습\"]\n", + "MANIP = [\"num_change\", \"region_swap\", \"dir_flip\", \"old_image\", \"splice\", \"ai_image\", \"ai_text\", \"bot_burst\", \"mashup\", \"recycled\"]\n", + "ACCOUNT_TYPES = { # 유형별 계정 속성 분포(가상): 나이(일), 팔로워, 하루 게시 수, 원본 비율, 과거 정확도, 게시 간격 변동계수\n", + " \"media\": ((1000, 4000), (5e4, 5e5), (5, 30), (.6, .9), (.85, .97), (.8, 1.5)),\n", + " \"witness\": ((10, 800), (20, 2000), (.2, 3), (.7, 1.0), (.6, .95), (1.0, 2.0)),\n", + " \"aggregator\": ((200, 2000), (1e4, 2e5), (30, 200), (.05, .2), (.6, .85), (.5, 1.0)),\n", + " \"propaganda\": ((5, 200), (100, 2e4), (20, 150), (.3, .7), (.1, .4), (.05, .3)),\n", + "}\n", + "\n", + "\n", + "def make_accounts(rng, n=(8, 30, 10, 20)):\n", + " acc = {}\n", + " for (t, spec), k in zip(ACCOUNT_TYPES.items(), n):\n", + " for i in range(k):\n", + " v = [rng.uniform(*r) for r in spec]\n", + " acc[f\"ACC_{t[:3].upper()}{i:02d}\"] = {\"type\": t, \"age_days\": int(v[0]), \"followers\": int(v[1]), \"posts_per_day\": round(v[2], 1),\n", + " \"original_ratio\": round(v[3], 2), \"past_accuracy\": round(v[4], 2), \"interval_cv\": round(v[5], 2)}\n", + " return acc\n", + "\n", + "\n", + "def event_image(seed):\n", + " \"\"\"사건 현장 사진을 흉내 낸 32x32 합성 이미지(사건마다 고유한 도형 배치).\"\"\"\n", + " # [TODO : 학생 실습 9] AAR 기반 출처 오분류 사례 분석 및 가짜 정보 탐지 룰셋/가중치 재보정 관련 핵심 로직을 구현하세요.\n", + " # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n", + " # <<< 여기에 코드를 작성하세요 >>>\n", + " pass # 작성 후 pass를 제거하세요.\n", + "def variant(img, rng):\n", + " \"\"\"재게시 과정의 자연스러운 변형: 재압축 잡음, 밝기 변화, 가장자리 잘림 중 하나.\"\"\"\n", + " # [TODO : 학생 실습 9] AAR 기반 출처 오분류 사례 분석 및 가짜 정보 탐지 룰셋/가중치 재보정 관련 핵심 로직을 구현하세요.\n", + " # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n", + " # <<< 여기에 코드를 작성하세요 >>>\n", + " pass # 작성 후 pass를 제거하세요.\n", + "def splice(img, other, rng):\n", + " out = img.copy()\n", + " y, x = rng.integers(4, 18, 2)\n", + " out[y:y + 12, x:x + 12] = other[y:y + 12, x:x + 12] + rng.normal(0, 12, (12, 12)) # 잡음 수준이 다른 조각을 붙임\n", + " return out\n", + "\n", + "\n", + "def ai_image(rng):\n", + " \"\"\"생성형 모델 이미지를 흉내 낸 합성 이미지: 지나치게 매끈한 바탕 + 업샘플링 격자 무늬(주기 2픽셀).\"\"\"\n", + " base = ndimage.gaussian_filter(rng.normal(130, 60, (32, 32)), 3)\n", + " grid = (np.indices((32, 32)).sum(0) % 2) * rng.uniform(6, 12)\n", + " return base + grid\n", + "\n", + "\n", + "def write(ev, rng, tpl=None, **kw):\n", + " s = {\"r\": ev[\"r\"], \"o\": ev[\"o\"], \"n\": ev[\"n\"], \"d\": ev[\"d\"], **kw}\n", + " return (tpl or TEMPLATES[int(rng.integers(len(TEMPLATES)))]).format(**s)\n", + "\n", + "\n", + "def make_posts(n_events=40, seed=7):\n", + " rng = np.random.default_rng(seed)\n", + " acc = make_accounts(rng)\n", + " by = {t: [a for a, v in acc.items() if v[\"type\"] == t] for t in ACCOUNT_TYPES}\n", + " events = [{\"id\": f\"EV{i:03d}\", \"r\": str(rng.choice(REGIONS)), \"o\": str(rng.choice(OBJECTS)), \"n\": int(rng.integers(2, 15)),\n", + " \"d\": str(rng.choice(DIRS)), \"t0\": float(i * 1.2 + rng.uniform(0, 1)), \"img\": event_image(seed * 1000 + i)} for i in range(n_events)]\n", + " posts = []\n", + "\n", + " def add(ev, account, text, img, t, fwd, kind, origin):\n", + " pid = f\"P{len(posts):04d}\"\n", + " posts.append({\"post_id\": pid, \"platform\": str(rng.choice([\"telegram\", \"x\", \"other\"], p=[.6, .3, .1])), \"account_id\": account,\n", + " \"posted_at\": round(t, 3), \"text\": text, \"image\": img, \"forwarded_from\": fwd, \"event_id\": ev[\"id\"] if ev else None,\n", + " \"kind\": kind, \"is_disinfo\": kind in MANIP, \"origin\": origin})\n", + " return pid\n", + "\n", + " for k, ev in enumerate(events):\n", + " src = str(rng.choice(by[\"witness\"] if rng.random() < .6 else by[\"media\"]))\n", + " if rng.random() < .05:\n", + " src = str(rng.choice(by[\"propaganda\"])) # 신뢰도 낮은 계정도 사실을 올릴 수 있다\n", + " o = add(ev, src, write(ev, rng), ev[\"img\"], ev[\"t0\"], None, \"original\", None)\n", + " for _ in range(int(rng.integers(2, 5))): # 그대로 재게시(전달)\n", + " add(ev, str(rng.choice(by[\"aggregator\"])), posts[int(o[1:])][\"text\"], ev[\"img\"], ev[\"t0\"] + rng.exponential(1), o, \"copy\", o)\n", + " for _ in range(int(rng.integers(1, 3))): # 표현만 바꾼 재작성(내용 동일)\n", + " add(ev, str(rng.choice(by[\"media\"] + by[\"witness\"])), \"#가상속보 \" + write(ev, rng), variant(ev[\"img\"], rng),\n", + " ev[\"t0\"] + rng.uniform(0.5, 6), None, \"near_dup\", o)\n", + " for m in MANIP if k % 2 == 0 else MANIP[k % 4::4]: # 조작·기만 게시물\n", + " if rng.random() > 0.5:\n", + " continue\n", + " who = str(rng.choice(by[\"propaganda\"] if rng.random() < .75 else by[\"aggregator\"] + by[\"witness\"]))\n", + " t, img, text = ev[\"t0\"] + rng.uniform(1, 12), variant(ev[\"img\"], rng), None\n", + " if m == \"num_change\":\n", + " text = write(ev, rng, n=ev[\"n\"] * int(rng.integers(5, 10)))\n", + " elif m == \"region_swap\":\n", + " text = write(ev, rng, r=str(rng.choice([r for r in REGIONS if r != ev[\"r\"]])))\n", + " elif m == \"dir_flip\":\n", + " text = write(ev, rng, d={\"북쪽\": \"남쪽\", \"남쪽\": \"북쪽\", \"동쪽\": \"서쪽\", \"서쪽\": \"동쪽\"}[ev[\"d\"]])\n", + " elif m == \"old_image\" and k > 0:\n", + " text, img = write(ev, rng), events[int(rng.integers(0, k))][\"img\"]\n", + " elif m == \"splice\":\n", + " text, img = write(ev, rng), splice(ev[\"img\"], events[(k + 7) % n_events][\"img\"], rng)\n", + " elif m == \"ai_image\":\n", + " text, img = write(ev, rng), ai_image(rng)\n", + " elif m == \"ai_text\":\n", + " text = f\"속보 속보! {ev['r']} 지역 대규모 {ev['o']} 출현! 긴급 확산 바람! 긴급 확산 바람! 모두 확산 바람!\"\n", + " img = ai_image(rng) if rng.random() < .5 else None\n", + " elif m == \"bot_burst\":\n", + " text = write(ev, rng, n=ev[\"n\"] * 6) + \" 지금 바로 공유\"\n", + " for b in rng.choice(by[\"propaganda\"], 4, replace=False):\n", + " add(ev, str(b), text, img, t + rng.uniform(0, 0.15), None, m, o)\n", + " continue\n", + " elif m == \"mashup\":\n", + " other = events[(k + 3) % n_events]\n", + " text = f\"{ev['r']} 일대 {ev['o']} 확인, {other['n']}대가 {other['d']}으로 이동\" # 다른 사건 내용과 짜깁기\n", + " elif m == \"recycled\" and k > 2:\n", + " old = events[int(rng.integers(0, k - 1))]\n", + " text, img, t = write(old, rng), old[\"img\"], ev[\"t0\"] + rng.uniform(0, 3)\n", + " if text is None:\n", + " continue\n", + " add(ev, who, text, img, t, None, m, o)\n", + " return posts, acc\n", + "# ---------------------------------------------------------------------------\n", + "\n", + "# ---- 텍스트 정제·이미지 지각 해시·조작 흔적 공통 함수 ----\n", + "STOPWORDS = {\"가상\", \"약\", \"일대\", \"지역에서\", \"모습\", \"중\", \"확인됨\", \"발견\", \"제보\", \"주민\"}\n", + "_URL = re.compile(r\"https?://\\S+|www\\.\\S+\")\n", + "_SPECIAL = re.compile(r\"[^0-9A-Za-z가-힣\\-\\s]\")\n", + "\n", + "\n", + "def clean_text(t):\n", + " \"\"\"정규화(NFKC) -> URL·멘션 제거 -> 해시태그 기호 제거 -> 특수문자 제거 -> 불용어 제거 -> 공백 정리.\"\"\"\n", + " t = unicodedata.normalize(\"NFKC\", t)\n", + " t = _URL.sub(\" \", t)\n", + " t = re.sub(r\"@\\w+\", \" \", t).replace(\"#\", \" \")\n", + " t = _SPECIAL.sub(\" \", t)\n", + " return \" \".join(w for w in t.split() if w not in STOPWORDS)\n", + "\n", + "\n", + "def phash(img):\n", + " \"\"\"지각 해시(pHash): 32x32 이미지 DCT의 저주파 8x8(직류 성분 제외)을 중앙값과 비교한 64비트.\"\"\"\n", + " c = dctn(np.asarray(img, float), norm=\"ortho\")[:8, :8].ravel()[1:]\n", + " return np.append(c > np.median(c), False)\n", + "\n", + "\n", + "def hamming(a, b):\n", + " return int(np.count_nonzero(a != b))\n", + "\n", + "\n", + "def slots(text):\n", + " \"\"\"본문에서 지역 코드·수량·방향·대상을 뽑는다(사건 내용 비교용).\"\"\"\n", + " r = re.search(r\"[A-F]-\\d\", text)\n", + " n = re.search(r\"(\\d+)\\s*대|수량\\s*(\\d+)\", text)\n", + " d = next((x for x in DIRS if x in text), None)\n", + " o = next((x for x in OBJECTS if x in text), None)\n", + " return {\"r\": r.group(0) if r else None, \"n\": int(n.group(1) or n.group(2)) if n else None, \"d\": d, \"o\": o}\n", + "\n", + "\n", + "def splice_score(img):\n", + " \"\"\"합성(붙여넣기) 흔적: 8x8 블록별 고주파 잡음 크기의 최대/중앙값 비율. 잡음 수준이 다른 조각이 있으면 커진다.\"\"\"\n", + " res = img - ndimage.median_filter(img, 3)\n", + " b = np.abs(res).reshape(4, 8, 4, 8).std(axis=(1, 3))\n", + " return float(b.max() / (np.median(b) + 1e-6))\n", + "\n", + "\n", + "def ai_image_score(img):\n", + " \"\"\"생성형 이미지 흔적: 2픽셀 주기 격자(나이퀴스트 주파수) 에너지 비율.\"\"\"\n", + " f = np.abs(np.fft.fft2(img - img.mean()))\n", + " return float(f[16, 16] / (f.sum() / f.size + 1e-6))\n", + "\n", + "\n", + "def ai_text_score(text):\n", + " \"\"\"기계적 선동 문체 흔적: 반복 어절 비율 + 느낌표 밀도.\"\"\"\n", + " w = text.split()\n", + " return (1 - len(set(w)) / max(len(w), 1)) + text.count(\"!\") / max(len(w), 1)\n", + "\n", + "# ---- 게시물 군집(같은 이야기 묶기)·조작 탐지 특징 공통 함수 ----\n", + "FEAT = [\"num_dev\", \"region_mis\", \"dir_mis\", \"obj_mis\", \"img_ref_dist\", \"img_prior_h\", \"time_gap_h\", \"splice\", \"ai_img\",\n", + " \"ai_text\", \"burst_n\", \"has_img\", \"is_fwd\", \"acc_age\", \"acc_followers\", \"acc_orig\", \"acc_past\", \"acc_cv\"]\n", + "\n", + "\n", + "def cluster_posts(posts, hours=24, ham_thr=8):\n", + " \"\"\"같은 이야기 묶기: 본문에서 뽑은 (지역, 대상)이 같고 24시간 안이면 연결하고,\n", + " 지각 해시가 가까운 이미지(같은 대상)도 연결한다. 글 표현이 달라도 내용 단위로 묶인다.\"\"\"\n", + " texts = [clean_text(p[\"text\"]) for p in posts]\n", + " X = TfidfVectorizer(analyzer=\"char_wb\", ngram_range=(2, 3)).fit_transform(texts)\n", + " S = (X @ X.T).toarray()\n", + " H = [phash(p[\"image\"]) if p[\"image\"] is not None else None for p in posts]\n", + " sl = [slots(p[\"text\"]) for p in posts]\n", + " t = [p[\"posted_at\"] for p in posts]\n", + " parent = list(range(len(posts)))\n", + "\n", + " def find(i):\n", + " while parent[i] != i:\n", + " parent[i] = parent[parent[i]]; i = parent[i]\n", + " return i\n", + " for i in range(len(posts)):\n", + " for j in range(i + 1, len(posts)):\n", + " if abs(t[i] - t[j]) >= hours or sl[i][\"o\"] != sl[j][\"o\"]:\n", + " continue\n", + " same_story = sl[i][\"r\"] is not None and sl[i][\"r\"] == sl[j][\"r\"]\n", + " same_img = H[i] is not None and H[j] is not None and hamming(H[i], H[j]) <= ham_thr\n", + " if same_story or same_img:\n", + " parent[find(i)] = find(j)\n", + " return np.array([find(i) for i in range(len(posts))]), S, H\n", + "\n", + "\n", + "def post_features(posts, acc):\n", + " cid, S, H = cluster_posts(posts)\n", + " t = np.array([p[\"posted_at\"] for p in posts])\n", + " sl = [slots(p[\"text\"]) for p in posts]\n", + " first_seen = {}\n", + " for i in np.argsort(t): # 이미지가 처음 등장한 시각과 군집\n", + " if H[i] is None:\n", + " continue\n", + " key = next((k for k in first_seen if hamming(np.array(k), H[i]) <= 6), None)\n", + " if key is None:\n", + " first_seen[tuple(H[i])] = (t[i], cid[i])\n", + " elif first_seen[key][1] != cid[i]:\n", + " pass\n", + " ref = {c: int(np.argmin(np.where(cid == c, t, np.inf))) for c in set(cid)} # 군집에서 가장 이른 게시물 = 기준(추정 원본)\n", + " F = []\n", + " for i, p in enumerate(posts):\n", + " r = ref[cid[i]]\n", + " a, b = sl[i], sl[r]\n", + " img_prior = 0.0\n", + " if H[i] is not None:\n", + " for k, (ts, c) in first_seen.items():\n", + " if hamming(np.array(k), H[i]) <= 6 and c != cid[i]:\n", + " img_prior = max(img_prior, min(t[i] - ts, 48.0))\n", + " same_txt = [j for j in np.where(S[i] > 0.97)[0] if abs(t[j] - t[i]) < 0.25 and posts[j][\"account_id\"] != p[\"account_id\"]]\n", + " A = acc[p[\"account_id\"]]\n", + " F.append([abs(np.log((a[\"n\"] or 1) / (b[\"n\"] or 1))), float(a[\"r\"] != b[\"r\"]), float(a[\"d\"] != b[\"d\"]), float(a[\"o\"] != b[\"o\"]),\n", + " hamming(H[i], H[r]) if H[i] is not None and H[r] is not None else 0, img_prior, t[i] - t[r],\n", + " splice_score(p[\"image\"]) if p[\"image\"] is not None else 0, ai_image_score(p[\"image\"]) if p[\"image\"] is not None else 0,\n", + " ai_text_score(p[\"text\"]), len(set(posts[j][\"account_id\"] for j in same_txt)), float(p[\"image\"] is not None),\n", + " float(p[\"forwarded_from\"] is not None), np.log1p(A[\"age_days\"]), np.log1p(A[\"followers\"]), A[\"original_ratio\"],\n", + " A[\"past_accuracy\"], A[\"interval_cv\"]])\n", + " return np.array(F), cid\n", + "\n", + "HOMO = {\"A\": \"А\", \"B\": \"В\", \"C\": \"С\", \"E\": \"Е\"} # 라틴 대문자와 모양이 같은 키릴 문자\n", + "ZW = \"​\" # 보이지 않는 문자(폭 0 공백)\n", + "RULESET = {\"normalize\": False}\n", + "\n", + "\n", + "def normalize(t):\n", + " \"\"\"보강 룰셋: NFKC + 유사 문자(키릴->라틴) 복원 + 보이지 않는 문자 제거.\"\"\"\n", + " t = unicodedata.normalize(\"NFKC\", t).replace(ZW, \"\")\n", + " for k, v in HOMO.items():\n", + " t = t.replace(v, k)\n", + " return t\n", + "\n", + "\n", + "_slots_raw, _clean_raw = slots, clean_text\n", + "slots = lambda t: _slots_raw(normalize(t) if RULESET[\"normalize\"] else t)\n", + "clean_text = lambda t: _clean_raw(normalize(t) if RULESET[\"normalize\"] else t)\n", + "\n", + "\n", + "def evade(posts, acc, rng):\n", + " \"\"\"신종 변조 패턴: (1) 평판 좋은 계정 탈취·사칭으로 조작 글 게시 (2) 지역 코드 글자를 유사 문자로 바꾸고 보이지 않는 문자를 끼워 비교를 피한다.\"\"\"\n", + " good = [a for a, v in acc.items() if v[\"type\"] in (\"media\", \"witness\")]\n", + " for p in posts:\n", + " if p[\"is_disinfo\"] and rng.random() < 0.6:\n", + " p[\"account_id\"] = str(rng.choice(good))\n", + " if p[\"is_disinfo\"] and rng.random() < 0.8:\n", + " t = p[\"text\"]\n", + " for k, v in HOMO.items():\n", + " t = re.sub(rf\"{k}-\", v + ZW + \"-\", t)\n", + " p[\"text\"] = t + ZW * int(rng.integers(1, 4))\n", + " return posts\n", + "\n", + "\n", + "CONTENT = list(range(13))\n", + "\n", + "\n", + "def run(posts, acc, cfg):\n", + " F, _ = post_features(posts, acc)\n", + " m = MODELS[cfg[\"account\"]]\n", + " return m.predict_proba(F if cfg[\"account\"] else F[:, CONTENT])[:, 1] >= cfg[\"thr\"]\n", + "\n", + "\n", + "tr_posts, tr_acc = make_posts(60, seed=7)\n", + "Ftr, _ = post_features(tr_posts, tr_acc)\n", + "ytr = [p[\"is_disinfo\"] for p in tr_posts]\n", + "MODELS = {True: RandomForestClassifier(n_estimators=200, min_samples_leaf=3, random_state=0).fit(Ftr, ytr), # 계정 평판 포함\n", + " False: RandomForestClassifier(n_estimators=200, min_samples_leaf=3, random_state=0).fit(Ftr[:, CONTENT], ytr)} # 내용 특징만\n", + "rng = np.random.default_rng(5)\n", + "ex_posts, ex_acc = make_posts(60, seed=21); evade(ex_posts, ex_acc, rng) # 모의 운용(AAR 대상)\n", + "ho_posts, ho_acc = make_posts(60, seed=22); evade(ho_posts, ho_acc, rng) # 회귀 시험용\n", + "y, kind = np.array([p[\"is_disinfo\"] for p in ex_posts]), np.array([p[\"kind\"] for p in ex_posts])\n", + "yh = np.array([p[\"is_disinfo\"] for p in ho_posts])\n", + "\n", + "# 1) 초기 설정: 기존 정규화(NFKC만), 계정 평판 포함 모델, 판정 임계값 0.5\n", + "init = {\"account\": True, \"thr\": 0.5}\n", + "p0 = run(ex_posts, ex_acc, init)\n", + "acc0 = (p0 == y).mean()\n", + "evaded = np.array([\"​\" in p[\"text\"] for p in ex_posts])\n", + "print(f\"[1. 무엇이 일어났나] 초기 종합 정확도 {acc0:.3f}, 조작 재현율 {(p0 & y).sum() / y.sum():.3f}, 사실 오경보 {(p0 & ~y).sum()}건\")\n", + "log = pd.DataFrame([{\"구분\": \"미탐\" if y[i] else \"오탐\", \"원인\": (\"유사 문자 치환 회피\" if evaded[i] else kind[i]) if y[i] else f\"{ex_acc[ex_posts[i]['account_id']]['type']} 계정 {kind[i]}\", \"게시물\": ex_posts[i][\"post_id\"]}\n", + " for i in np.where(p0 != y)[0]])\n", + "print(log.groupby([\"구분\", \"원인\"]).size().rename(\"건수\").sort_values(ascending=False).head(6).to_string())\n", + "print(f\"\\n[2. 왜 일어났나] 평판 좋은 계정을 사칭한 조작 글은 계정 평판에 크게 기댄 모델을 통과하고, 유사 문자가 들어간 게시물 {evaded.sum()}건은 지역 코드 추출·동시 게시 비교가 실패한다.\")\n", + "\n", + "# 2) 재보정: 룰셋(정규화 보강) x 계정 평판 가중치(포함/제외) x 판정 임계값 격자 탐색\n", + "grid = []\n", + "for norm in (False, True):\n", + " RULESET[\"normalize\"] = norm\n", + " F, _ = post_features(ex_posts, ex_acc)\n", + " for use_acc in (True, False):\n", + " pr = MODELS[use_acc].predict_proba(F if use_acc else F[:, CONTENT])[:, 1]\n", + " for thr in (0.3, 0.4, 0.5, 0.6):\n", + " p = pr >= thr\n", + " grid.append({\"정규화 보강\": norm, \"계정 평판\": \"포함\" if use_acc else \"제외\", \"임계값\": thr, \"종합정확도\": round(float((p == y).mean()), 3),\n", + " \"조작 재현율\": round(float((p & y).sum() / y.sum()), 3), \"사실 오경보\": int((p & ~y).sum())})\n", + "g = pd.DataFrame(grid).sort_values([\"종합정확도\", \"사실 오경보\"], ascending=[False, True])\n", + "print(\"\\n[3. 무엇을 바꾸나] 격자 탐색 상위 5개\")\n", + "print(g.head().to_string(index=False))\n", + "best = g.iloc[0]\n", + "cfg = {\"account\": best[\"계정 평판\"] == \"포함\", \"thr\": float(best[\"임계값\"])}\n", + "RULESET[\"normalize\"] = bool(best[\"정규화 보강\"])\n", + "p1 = run(ex_posts, ex_acc, cfg)\n", + "acc1 = (p1 == y).mean()\n", + "print(f\"\\n재보정값: 정규화 보강 {best['정규화 보강']}, 계정 평판 {best['계정 평판']}, 판정 임계값 {best['임계값']}\")\n", + "print(f\"종합 정확도 {acc0:.3f} -> {acc1:.3f} ({(acc1 - acc0) * 100:+.1f}%p, 목표 +10%p: {'달성' if acc1 - acc0 >= 0.1 else '미달'})\")\n", + "RULESET[\"normalize\"] = False; h0 = (run(ho_posts, ho_acc, init) == yh).mean()\n", + "RULESET[\"normalize\"] = bool(best[\"정규화 보강\"]); h1 = (run(ho_posts, ho_acc, cfg) == yh).mean()\n", + "print(f\"회귀 시험(별도 게시물 {len(ho_posts)}건): {h0:.3f} -> {h1:.3f}\")\n", + "again = set(np.where(p0 != y)[0]) & set(np.where(p1 != y)[0])\n", + "print(f\"재보정 후 남은 오류 {(p1 != y).sum()}건, 그중 재발 {len(again)}건:\", {str(k): int(v) for k, v in pd.Series([kind[i] for i in again]).value_counts().head(4).items()})\n", + "print(\"[4. 남은 과제] 재발 건은 원본과 거의 같은 미세 변조로, 새 사례를 학습 자료에 추가하는 지속 학습 주기(주 1회)와 정보관 원천 역추적(미션 7)으로 보완한다.\")\n", + "\n", + "tuned = (f\"ruleset:\\n nfkc: true\\n homoglyph_map: {bool(best['정규화 보강'])}\\n strip_zero_width: {bool(best['정규화 보강'])}\\n\"\n", + " f\"detector:\\n use_account_reputation: {cfg['account']}\\n threshold: {best['임계값']}\\n rf_trees: 200\")\n", + "print(\"\\n[재보정 파라미터(isr02_tuned_params.yaml 내용)]\\n\" + tuned)\n", + "\n", + "plt.figure(figsize=(6, 4))\n", + "for norm in (False, True):\n", + " for ua in (\"포함\", \"제외\"):\n", + " s = pd.DataFrame(grid)[lambda d: (d[\"정규화 보강\"] == norm) & (d[\"계정 평판\"] == ua)]\n", + " plt.plot(s[\"임계값\"], s[\"종합정확도\"], \"o-\", label=f\"normalize={norm}, account={'on' if ua == '포함' else 'off'}\")\n", + "plt.xlabel(\"decision threshold\"); plt.ylabel(\"accuracy\"); plt.legend(); plt.title(\"AAR re-tuning grid (synthetic)\")\n", + "plt.show()\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 09 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + "[1. 무엇이 일어났나] 초기 종합 정확도 0.809, 조작 재현율 0.576, 사실 오경보 9건\n", + "구분 원인 \n", + "미탐 유사 문자 치환 회피 91\n", + " splice 3\n", + "오탐 witness 계정 original 3\n", + " witness 계정 near_dup 3\n", + " propaganda 계정 original 3\n", + "미탐 region_swap 2\n", + "\n", + "[2. 왜 일어났나] 평판 좋은 계정을 사칭한 조작 글은 계정 평판에 크게 기댄 모델을 통과하고, 유사 문자가 들어간 게시물 192건은 지역 코드 추출·동시 게시 비교가 실패한다.\n", + "\n", + "[3. 무엇을 바꾸나] 격자 탐색 상위 5개\n", + " 정규화 보강 계정 평판 임계값 종합정확도 조작 재현율 사실 오경보\n", + " True 포함 0.3 0.911 0.864 19\n", + " False 제외 0.3 0.909 0.936 37\n", + " True 제외 0.3 0.909 0.975 46\n", + " False 포함 0.3 0.905 0.818 11\n", + " True 제외 0.6 0.904 0.860 22\n", + "\n", + "재보정값: 정규화 보강 True, 계정 평판 포함, 판정 임계값 0.3\n", + "종합 정확도 0.809 -> 0.911 (+10.2%p, 목표 +10%p: 달성)\n", + "회귀 시험(별도 게시물 566건): 0.843 -> 0.901\n", + "재보정 후 남은 오류 51건, 그중 재발 40건: {'region_swap': 9, 'dir_flip': 7, 'recycled': 7, 'original': 6}\n", + "[4. 남은 과제] 재발 건은 원본과 거의 같은 미세 변조로, 새 사례를 학습 자료에 추가하는 지속 학습 주기(주 1회)와 정보관 원천 역추적(미션 7)으로 보완한다.\n", + "\n", + "[재보정 파라미터(isr02_tuned_params.yaml 내용)]\n", + "ruleset:\n", + " nfkc: true\n", + " homoglyph_map: True\n", + " strip_zero_width: True\n", + "detector:\n", + " use_account_reputation: True\n", + " threshold: 0.3\n", + " rf_trees: 200\n", + "```\n", + "
\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 10. SNS 전쟁 게시물 신뢰도 자동 분류 시스템 통합 및 정보전 대응 브리핑 패키지 완성\n", + "**소요시간**: 약 3.5시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "SNS 전쟁 게시물 신뢰도 자동 분류 시스템을 최종 통합하고 정보전 대응 브리핑 패키지를 완성한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **게시물 검증부터 정제, 이야기 군집, 조작 탐지, 신뢰도 등급, 정보관 검토 대기열, 모니터링 화면까지 시스템을 통합하고 정보전 대응 브리핑 패키지를 완성한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **재보정 설정으로 전체 과정을 한 번에 실행하는 통합 코드를 완성하고 손상 게시물이 자동 차단되는지 확인하고 유효성을 점검하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `src/main_isr02.py, README.md, docs/isr02_final_report.pdf(md)` 형태의 결과물을 완성한다.\n", + "\n", + "### 🔍 평가 및 검증 기준 (PASS 조건)\n", + "#### 미션 판정\n", + "\n", + "**평가 구분:** 필수\n", + "\n", + "**핵심 평가영역:** 군 실무 즉시 도입 가능성, 운용 신뢰도 및 전략적 기대효과\n", + "\n", + "#### 미션 PASS 조건\n", + "\n", + "- 정량 기준: End-to-End 통합 파이프라인 실행 성공률 100%, 테스트 커버리지 85% 이상\n", + "\n", + "\n", + "- 진행 기준: 원클릭 실행 스크립트 동작 확인 및 최종 발표 산출물 작성 완료\n", + "\n", + "\n", + "- 검증 방법: 최종 검증 위원회(평가관) 모의 시연 및 전체 기능 통합 검증\n", + "\n", + "\n", + "#### 채점자 판정 메모\n", + "\n", + "> 단순 코드 실행 여부뿐만 아니라 출력된 수치와 근거 자료가 군 실무 요구사항에 부합하는지 면밀히 확인한다.\n", + "\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "\"\"\"게시물 검증·정제(보강 정규화)·이야기 군집·조작 탐지·종합 신뢰도 등급(A~E)·정보관 긴급 검토 대기열·모니터링 화면까지 SNS 전쟁 게시물 신뢰도 자동 분류 시스템 전체를 한 파일로 통합 실행하고, 자체 점검(기능 커버리지 포함)과 정보전 대응 브리핑 요약을 출력한다.\"\"\"\n", + "import hashlib\n", + "import json\n", + "import re\n", + "import time\n", + "import unicodedata\n", + "import numpy as np\n", + "import pandas as pd\n", + "import matplotlib.pyplot as plt\n", + "from scipy import ndimage\n", + "from scipy.fft import dctn\n", + "from sklearn.ensemble import RandomForestClassifier\n", + "from sklearn.feature_extraction.text import TfidfVectorizer\n", + "from sklearn.linear_model import LogisticRegression\n", + "from sklearn.pipeline import make_pipeline\n", + "from sklearn.preprocessing import StandardScaler\n", + "\n", + "# ---------------------------------------------------------------------------\n", + "# [교육용가상자료] 합성 SNS 게시물 생성기 (실제 계정·채널·지명·게시물 없음)\n", + "# 가상 사건마다 원본 제보, 재게시(복제), 유사 재작성, 조작·기만 게시물 10종과 32x32 합성 이미지를 만든다.\n", + "# ---------------------------------------------------------------------------\n", + "REGIONS = [f\"{a}-{n}\" for a in \"ABCDEF\" for n in range(1, 6)]\n", + "OBJECTS = [\"군용 차량\", \"소형 드론\", \"보급 트럭\", \"헬기\", \"버스\", \"견인 차량\", \"트레일러\", \"민간 승용차\"]\n", + "DIRS = [\"북쪽\", \"남쪽\", \"동쪽\", \"서쪽\"]\n", + "TEMPLATES = [\"가상 {r} 지역에서 {o} 목격, {n}대 규모로 {d}으로 이동 중\",\n", + " \"{r} 일대 {o} 확인됨. 약 {n}대, {d} 방향으로 이동\",\n", + " \"[현지] {r}에서 {o} 발견 - 수량 {n}, 이동 방향 {d}\",\n", + " \"{r} 주민 제보: {o} {n}대가 {d}으로 향하는 모습\"]\n", + "MANIP = [\"num_change\", \"region_swap\", \"dir_flip\", \"old_image\", \"splice\", \"ai_image\", \"ai_text\", \"bot_burst\", \"mashup\", \"recycled\"]\n", + "ACCOUNT_TYPES = { # 유형별 계정 속성 분포(가상): 나이(일), 팔로워, 하루 게시 수, 원본 비율, 과거 정확도, 게시 간격 변동계수\n", + " \"media\": ((1000, 4000), (5e4, 5e5), (5, 30), (.6, .9), (.85, .97), (.8, 1.5)),\n", + " \"witness\": ((10, 800), (20, 2000), (.2, 3), (.7, 1.0), (.6, .95), (1.0, 2.0)),\n", + " \"aggregator\": ((200, 2000), (1e4, 2e5), (30, 200), (.05, .2), (.6, .85), (.5, 1.0)),\n", + " \"propaganda\": ((5, 200), (100, 2e4), (20, 150), (.3, .7), (.1, .4), (.05, .3)),\n", + "}\n", + "\n", + "\n", + "def make_accounts(rng, n=(8, 30, 10, 20)):\n", + " acc = {}\n", + " for (t, spec), k in zip(ACCOUNT_TYPES.items(), n):\n", + " for i in range(k):\n", + " v = [rng.uniform(*r) for r in spec]\n", + " acc[f\"ACC_{t[:3].upper()}{i:02d}\"] = {\"type\": t, \"age_days\": int(v[0]), \"followers\": int(v[1]), \"posts_per_day\": round(v[2], 1),\n", + " \"original_ratio\": round(v[3], 2), \"past_accuracy\": round(v[4], 2), \"interval_cv\": round(v[5], 2)}\n", + " return acc\n", + "\n", + "\n", + "def event_image(seed):\n", + " \"\"\"사건 현장 사진을 흉내 낸 32x32 합성 이미지(사건마다 고유한 도형 배치).\"\"\"\n", + " # [TODO : 학생 실습 10] SNS 전쟁 게시물 신뢰도 자동 분류 시스템 통합 및 정보전 대응 브리핑 패키지 완성 관련 핵심 로직을 구현하세요.\n", + " # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n", + " # <<< 여기에 코드를 작성하세요 >>>\n", + " pass # 작성 후 pass를 제거하세요.\n", + "def variant(img, rng):\n", + " \"\"\"재게시 과정의 자연스러운 변형: 재압축 잡음, 밝기 변화, 가장자리 잘림 중 하나.\"\"\"\n", + " # [TODO : 학생 실습 10] SNS 전쟁 게시물 신뢰도 자동 분류 시스템 통합 및 정보전 대응 브리핑 패키지 완성 관련 핵심 로직을 구현하세요.\n", + " # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n", + " # <<< 여기에 코드를 작성하세요 >>>\n", + " pass # 작성 후 pass를 제거하세요.\n", + "def splice(img, other, rng):\n", + " out = img.copy()\n", + " y, x = rng.integers(4, 18, 2)\n", + " out[y:y + 12, x:x + 12] = other[y:y + 12, x:x + 12] + rng.normal(0, 12, (12, 12)) # 잡음 수준이 다른 조각을 붙임\n", + " return out\n", + "\n", + "\n", + "def ai_image(rng):\n", + " \"\"\"생성형 모델 이미지를 흉내 낸 합성 이미지: 지나치게 매끈한 바탕 + 업샘플링 격자 무늬(주기 2픽셀).\"\"\"\n", + " base = ndimage.gaussian_filter(rng.normal(130, 60, (32, 32)), 3)\n", + " grid = (np.indices((32, 32)).sum(0) % 2) * rng.uniform(6, 12)\n", + " return base + grid\n", + "\n", + "\n", + "def write(ev, rng, tpl=None, **kw):\n", + " s = {\"r\": ev[\"r\"], \"o\": ev[\"o\"], \"n\": ev[\"n\"], \"d\": ev[\"d\"], **kw}\n", + " return (tpl or TEMPLATES[int(rng.integers(len(TEMPLATES)))]).format(**s)\n", + "\n", + "\n", + "def make_posts(n_events=40, seed=7):\n", + " rng = np.random.default_rng(seed)\n", + " acc = make_accounts(rng)\n", + " by = {t: [a for a, v in acc.items() if v[\"type\"] == t] for t in ACCOUNT_TYPES}\n", + " events = [{\"id\": f\"EV{i:03d}\", \"r\": str(rng.choice(REGIONS)), \"o\": str(rng.choice(OBJECTS)), \"n\": int(rng.integers(2, 15)),\n", + " \"d\": str(rng.choice(DIRS)), \"t0\": float(i * 1.2 + rng.uniform(0, 1)), \"img\": event_image(seed * 1000 + i)} for i in range(n_events)]\n", + " posts = []\n", + "\n", + " def add(ev, account, text, img, t, fwd, kind, origin):\n", + " pid = f\"P{len(posts):04d}\"\n", + " posts.append({\"post_id\": pid, \"platform\": str(rng.choice([\"telegram\", \"x\", \"other\"], p=[.6, .3, .1])), \"account_id\": account,\n", + " \"posted_at\": round(t, 3), \"text\": text, \"image\": img, \"forwarded_from\": fwd, \"event_id\": ev[\"id\"] if ev else None,\n", + " \"kind\": kind, \"is_disinfo\": kind in MANIP, \"origin\": origin})\n", + " return pid\n", + "\n", + " for k, ev in enumerate(events):\n", + " src = str(rng.choice(by[\"witness\"] if rng.random() < .6 else by[\"media\"]))\n", + " if rng.random() < .05:\n", + " src = str(rng.choice(by[\"propaganda\"])) # 신뢰도 낮은 계정도 사실을 올릴 수 있다\n", + " o = add(ev, src, write(ev, rng), ev[\"img\"], ev[\"t0\"], None, \"original\", None)\n", + " for _ in range(int(rng.integers(2, 5))): # 그대로 재게시(전달)\n", + " add(ev, str(rng.choice(by[\"aggregator\"])), posts[int(o[1:])][\"text\"], ev[\"img\"], ev[\"t0\"] + rng.exponential(1), o, \"copy\", o)\n", + " for _ in range(int(rng.integers(1, 3))): # 표현만 바꾼 재작성(내용 동일)\n", + " add(ev, str(rng.choice(by[\"media\"] + by[\"witness\"])), \"#가상속보 \" + write(ev, rng), variant(ev[\"img\"], rng),\n", + " ev[\"t0\"] + rng.uniform(0.5, 6), None, \"near_dup\", o)\n", + " for m in MANIP if k % 2 == 0 else MANIP[k % 4::4]: # 조작·기만 게시물\n", + " if rng.random() > 0.5:\n", + " continue\n", + " who = str(rng.choice(by[\"propaganda\"] if rng.random() < .75 else by[\"aggregator\"] + by[\"witness\"]))\n", + " t, img, text = ev[\"t0\"] + rng.uniform(1, 12), variant(ev[\"img\"], rng), None\n", + " if m == \"num_change\":\n", + " text = write(ev, rng, n=ev[\"n\"] * int(rng.integers(5, 10)))\n", + " elif m == \"region_swap\":\n", + " text = write(ev, rng, r=str(rng.choice([r for r in REGIONS if r != ev[\"r\"]])))\n", + " elif m == \"dir_flip\":\n", + " text = write(ev, rng, d={\"북쪽\": \"남쪽\", \"남쪽\": \"북쪽\", \"동쪽\": \"서쪽\", \"서쪽\": \"동쪽\"}[ev[\"d\"]])\n", + " elif m == \"old_image\" and k > 0:\n", + " text, img = write(ev, rng), events[int(rng.integers(0, k))][\"img\"]\n", + " elif m == \"splice\":\n", + " text, img = write(ev, rng), splice(ev[\"img\"], events[(k + 7) % n_events][\"img\"], rng)\n", + " elif m == \"ai_image\":\n", + " text, img = write(ev, rng), ai_image(rng)\n", + " elif m == \"ai_text\":\n", + " text = f\"속보 속보! {ev['r']} 지역 대규모 {ev['o']} 출현! 긴급 확산 바람! 긴급 확산 바람! 모두 확산 바람!\"\n", + " img = ai_image(rng) if rng.random() < .5 else None\n", + " elif m == \"bot_burst\":\n", + " text = write(ev, rng, n=ev[\"n\"] * 6) + \" 지금 바로 공유\"\n", + " for b in rng.choice(by[\"propaganda\"], 4, replace=False):\n", + " add(ev, str(b), text, img, t + rng.uniform(0, 0.15), None, m, o)\n", + " continue\n", + " elif m == \"mashup\":\n", + " other = events[(k + 3) % n_events]\n", + " text = f\"{ev['r']} 일대 {ev['o']} 확인, {other['n']}대가 {other['d']}으로 이동\" # 다른 사건 내용과 짜깁기\n", + " elif m == \"recycled\" and k > 2:\n", + " old = events[int(rng.integers(0, k - 1))]\n", + " text, img, t = write(old, rng), old[\"img\"], ev[\"t0\"] + rng.uniform(0, 3)\n", + " if text is None:\n", + " continue\n", + " add(ev, who, text, img, t, None, m, o)\n", + " return posts, acc\n", + "# ---------------------------------------------------------------------------\n", + "\n", + "# ---- 텍스트 정제·이미지 지각 해시·조작 흔적 공통 함수 ----\n", + "STOPWORDS = {\"가상\", \"약\", \"일대\", \"지역에서\", \"모습\", \"중\", \"확인됨\", \"발견\", \"제보\", \"주민\"}\n", + "_URL = re.compile(r\"https?://\\S+|www\\.\\S+\")\n", + "_SPECIAL = re.compile(r\"[^0-9A-Za-z가-힣\\-\\s]\")\n", + "\n", + "\n", + "def clean_text(t):\n", + " \"\"\"정규화(NFKC) -> URL·멘션 제거 -> 해시태그 기호 제거 -> 특수문자 제거 -> 불용어 제거 -> 공백 정리.\"\"\"\n", + " t = unicodedata.normalize(\"NFKC\", t)\n", + " t = _URL.sub(\" \", t)\n", + " t = re.sub(r\"@\\w+\", \" \", t).replace(\"#\", \" \")\n", + " t = _SPECIAL.sub(\" \", t)\n", + " return \" \".join(w for w in t.split() if w not in STOPWORDS)\n", + "\n", + "\n", + "def phash(img):\n", + " \"\"\"지각 해시(pHash): 32x32 이미지 DCT의 저주파 8x8(직류 성분 제외)을 중앙값과 비교한 64비트.\"\"\"\n", + " c = dctn(np.asarray(img, float), norm=\"ortho\")[:8, :8].ravel()[1:]\n", + " return np.append(c > np.median(c), False)\n", + "\n", + "\n", + "def hamming(a, b):\n", + " return int(np.count_nonzero(a != b))\n", + "\n", + "\n", + "def slots(text):\n", + " \"\"\"본문에서 지역 코드·수량·방향·대상을 뽑는다(사건 내용 비교용).\"\"\"\n", + " r = re.search(r\"[A-F]-\\d\", text)\n", + " n = re.search(r\"(\\d+)\\s*대|수량\\s*(\\d+)\", text)\n", + " d = next((x for x in DIRS if x in text), None)\n", + " o = next((x for x in OBJECTS if x in text), None)\n", + " return {\"r\": r.group(0) if r else None, \"n\": int(n.group(1) or n.group(2)) if n else None, \"d\": d, \"o\": o}\n", + "\n", + "\n", + "def splice_score(img):\n", + " \"\"\"합성(붙여넣기) 흔적: 8x8 블록별 고주파 잡음 크기의 최대/중앙값 비율. 잡음 수준이 다른 조각이 있으면 커진다.\"\"\"\n", + " res = img - ndimage.median_filter(img, 3)\n", + " b = np.abs(res).reshape(4, 8, 4, 8).std(axis=(1, 3))\n", + " return float(b.max() / (np.median(b) + 1e-6))\n", + "\n", + "\n", + "def ai_image_score(img):\n", + " \"\"\"생성형 이미지 흔적: 2픽셀 주기 격자(나이퀴스트 주파수) 에너지 비율.\"\"\"\n", + " f = np.abs(np.fft.fft2(img - img.mean()))\n", + " return float(f[16, 16] / (f.sum() / f.size + 1e-6))\n", + "\n", + "\n", + "def ai_text_score(text):\n", + " \"\"\"기계적 선동 문체 흔적: 반복 어절 비율 + 느낌표 밀도.\"\"\"\n", + " w = text.split()\n", + " return (1 - len(set(w)) / max(len(w), 1)) + text.count(\"!\") / max(len(w), 1)\n", + "\n", + "# ---- 게시물 군집(같은 이야기 묶기)·조작 탐지 특징 공통 함수 ----\n", + "FEAT = [\"num_dev\", \"region_mis\", \"dir_mis\", \"obj_mis\", \"img_ref_dist\", \"img_prior_h\", \"time_gap_h\", \"splice\", \"ai_img\",\n", + " \"ai_text\", \"burst_n\", \"has_img\", \"is_fwd\", \"acc_age\", \"acc_followers\", \"acc_orig\", \"acc_past\", \"acc_cv\"]\n", + "\n", + "\n", + "def cluster_posts(posts, hours=24, ham_thr=8):\n", + " \"\"\"같은 이야기 묶기: 본문에서 뽑은 (지역, 대상)이 같고 24시간 안이면 연결하고,\n", + " 지각 해시가 가까운 이미지(같은 대상)도 연결한다. 글 표현이 달라도 내용 단위로 묶인다.\"\"\"\n", + " texts = [clean_text(p[\"text\"]) for p in posts]\n", + " X = TfidfVectorizer(analyzer=\"char_wb\", ngram_range=(2, 3)).fit_transform(texts)\n", + " S = (X @ X.T).toarray()\n", + " H = [phash(p[\"image\"]) if p[\"image\"] is not None else None for p in posts]\n", + " sl = [slots(p[\"text\"]) for p in posts]\n", + " t = [p[\"posted_at\"] for p in posts]\n", + " parent = list(range(len(posts)))\n", + "\n", + " def find(i):\n", + " while parent[i] != i:\n", + " parent[i] = parent[parent[i]]; i = parent[i]\n", + " return i\n", + " for i in range(len(posts)):\n", + " for j in range(i + 1, len(posts)):\n", + " if abs(t[i] - t[j]) >= hours or sl[i][\"o\"] != sl[j][\"o\"]:\n", + " continue\n", + " same_story = sl[i][\"r\"] is not None and sl[i][\"r\"] == sl[j][\"r\"]\n", + " same_img = H[i] is not None and H[j] is not None and hamming(H[i], H[j]) <= ham_thr\n", + " if same_story or same_img:\n", + " parent[find(i)] = find(j)\n", + " return np.array([find(i) for i in range(len(posts))]), S, H\n", + "\n", + "\n", + "def post_features(posts, acc):\n", + " cid, S, H = cluster_posts(posts)\n", + " t = np.array([p[\"posted_at\"] for p in posts])\n", + " sl = [slots(p[\"text\"]) for p in posts]\n", + " first_seen = {}\n", + " for i in np.argsort(t): # 이미지가 처음 등장한 시각과 군집\n", + " if H[i] is None:\n", + " continue\n", + " key = next((k for k in first_seen if hamming(np.array(k), H[i]) <= 6), None)\n", + " if key is None:\n", + " first_seen[tuple(H[i])] = (t[i], cid[i])\n", + " elif first_seen[key][1] != cid[i]:\n", + " pass\n", + " ref = {c: int(np.argmin(np.where(cid == c, t, np.inf))) for c in set(cid)} # 군집에서 가장 이른 게시물 = 기준(추정 원본)\n", + " F = []\n", + " for i, p in enumerate(posts):\n", + " r = ref[cid[i]]\n", + " a, b = sl[i], sl[r]\n", + " img_prior = 0.0\n", + " if H[i] is not None:\n", + " for k, (ts, c) in first_seen.items():\n", + " if hamming(np.array(k), H[i]) <= 6 and c != cid[i]:\n", + " img_prior = max(img_prior, min(t[i] - ts, 48.0))\n", + " same_txt = [j for j in np.where(S[i] > 0.97)[0] if abs(t[j] - t[i]) < 0.25 and posts[j][\"account_id\"] != p[\"account_id\"]]\n", + " A = acc[p[\"account_id\"]]\n", + " F.append([abs(np.log((a[\"n\"] or 1) / (b[\"n\"] or 1))), float(a[\"r\"] != b[\"r\"]), float(a[\"d\"] != b[\"d\"]), float(a[\"o\"] != b[\"o\"]),\n", + " hamming(H[i], H[r]) if H[i] is not None and H[r] is not None else 0, img_prior, t[i] - t[r],\n", + " splice_score(p[\"image\"]) if p[\"image\"] is not None else 0, ai_image_score(p[\"image\"]) if p[\"image\"] is not None else 0,\n", + " ai_text_score(p[\"text\"]), len(set(posts[j][\"account_id\"] for j in same_txt)), float(p[\"image\"] is not None),\n", + " float(p[\"forwarded_from\"] is not None), np.log1p(A[\"age_days\"]), np.log1p(A[\"followers\"]), A[\"original_ratio\"],\n", + " A[\"past_accuracy\"], A[\"interval_cv\"]])\n", + " return np.array(F), cid\n", + "\n", + "CONFIG = {\"threshold\": 0.3, \"use_account\": True, \"normalize\": True, # 미션 9 재보정값\n", + " \"weights\": [0.35, 0.20, 0.25, 0.10, 0.10], \"grades\": [(0.80, \"A\"), (0.65, \"B\"), (0.50, \"C\"), (0.35, \"D\"), (-1, \"E\")]}\n", + "HOMO = {\"A\": \"А\", \"B\": \"В\", \"C\": \"С\", \"E\": \"Е\"}\n", + "ZW = \"​\"\n", + "CALLED = set()\n", + "\n", + "\n", + "def cover(fn):\n", + " \"\"\"기능 커버리지 측정용 표시: 통합 실행 중 호출된 기능을 기록한다.\"\"\"\n", + " def wrap(*a, **k):\n", + " CALLED.add(fn.__name__)\n", + " return fn(*a, **k)\n", + " wrap.__name__ = fn.__name__\n", + " return wrap\n", + "\n", + "\n", + "@cover\n", + "def normalize(t):\n", + " t = unicodedata.normalize(\"NFKC\", t).replace(ZW, \"\")\n", + " for k, v in HOMO.items():\n", + " t = t.replace(v, k)\n", + " return t\n", + "\n", + "\n", + "_slots, _clean = slots, clean_text\n", + "\n", + "\n", + "@cover\n", + "def slots(t):\n", + " return _slots(normalize(t))\n", + "\n", + "\n", + "@cover\n", + "def clean_text(t):\n", + " return _clean(normalize(t))\n", + "\n", + "\n", + "phash, cluster_posts, post_features = cover(phash), cover(cluster_posts), cover(post_features)\n", + "\n", + "\n", + "@cover\n", + "def validate_post(p):\n", + " errs = []\n", + " if p[\"platform\"] not in (\"telegram\", \"x\", \"other\"):\n", + " errs.append(\"platform 허용값 아님\")\n", + " if not isinstance(p[\"text\"], str) or not p[\"text\"]:\n", + " errs.append(\"본문 형식 오류\")\n", + " if p[\"image\"] is not None and np.asarray(p[\"image\"]).shape != (32, 32):\n", + " errs.append(\"이미지 형식 오류\")\n", + " return errs\n", + "\n", + "\n", + "@cover\n", + "def credibility(posts, F, cid, pm, am):\n", + " sl = [slots(p[\"text\"]) for p in posts]\n", + " corro = [min(len({posts[j][\"account_id\"] for j in np.where(cid == cid[i])[0] if j != i and posts[j][\"forwarded_from\"] is None\n", + " and posts[j][\"account_id\"] != p[\"account_id\"] and (sl[j][\"r\"], sl[j][\"n\"], sl[j][\"d\"]) == (sl[i][\"r\"], sl[i][\"n\"], sl[i][\"d\"])}) / 2, 1)\n", + " for i, p in enumerate(posts)]\n", + " C = np.c_[1 - pm, 1 - am, corro, 1 - np.minimum(F[:, FEAT.index(\"burst_n\")] / 3, 1), np.exp(-np.maximum(F[:, FEAT.index(\"time_gap_h\")], 0) / 12)]\n", + " s = C @ np.array(CONFIG[\"weights\"])\n", + " return s, np.array([next(g for t, g in CONFIG[\"grades\"] if v >= t) for v in s])\n", + "\n", + "\n", + "class Queue:\n", + " def __init__(self):\n", + " self.items, self.log = [], []\n", + "\n", + " @cover\n", + " def submit(self, pid, grade, prob):\n", + " prev = self.log[-1][\"hash\"] if self.log else \"GENESIS\"\n", + " body = {\"event\": \"SUBMIT\", \"id\": pid, \"grade\": grade, \"prob\": round(float(prob), 3)}\n", + " self.log.append({**body, \"prev\": prev, \"hash\": hashlib.sha256((prev + json.dumps(body, sort_keys=True)).encode()).hexdigest()})\n", + " self.items.append({**body, \"status\": \"PENDING\"})\n", + "\n", + " @cover\n", + " def verify(self):\n", + " prev = \"GENESIS\"\n", + " for r in self.log:\n", + " body = {k: v for k, v in r.items() if k not in (\"prev\", \"hash\")}\n", + " if r[\"prev\"] != prev or r[\"hash\"] != hashlib.sha256((prev + json.dumps(body, sort_keys=True)).encode()).hexdigest():\n", + " return False\n", + " prev = r[\"hash\"]\n", + " return True\n", + "\n", + "\n", + "TARGETS = {\"normalize\", \"slots\", \"clean_text\", \"phash\", \"cluster_posts\", \"post_features\", \"validate_post\", \"credibility\", \"submit\", \"verify\", \"dashboard\"}\n", + "\n", + "# ---------------- 1. 학습 ----------------\n", + "t_start = time.perf_counter()\n", + "tr_posts, tr_acc = make_posts(60, seed=7)\n", + "Ftr, _ = post_features(tr_posts, tr_acc)\n", + "ytr = [p[\"is_disinfo\"] for p in tr_posts]\n", + "det = RandomForestClassifier(n_estimators=200, min_samples_leaf=3, random_state=0).fit(Ftr, ytr)\n", + "manip = RandomForestClassifier(n_estimators=200, min_samples_leaf=3, random_state=0).fit(Ftr[:, :13], ytr)\n", + "accm = make_pipeline(StandardScaler(), LogisticRegression(max_iter=2000)).fit(Ftr[:, 13:], ytr)\n", + "\n", + "# ---------------- 2. 운용 자료(가상 게시물 스트림) 처리 ----------------\n", + "rng = np.random.default_rng(31)\n", + "posts, acc = make_posts(60, seed=31)\n", + "good = [a for a, v in acc.items() if v[\"type\"] in (\"media\", \"witness\")]\n", + "for p in posts: # 신종 변조 패턴 일부 포함\n", + " if p[\"is_disinfo\"] and rng.random() < 0.3:\n", + " p[\"account_id\"] = str(rng.choice(good))\n", + " if p[\"is_disinfo\"] and rng.random() < 0.4:\n", + " p[\"text\"] = re.sub(r\"([A-F])-\", lambda m: HOMO.get(m.group(1), m.group(1)) + ZW + \"-\", p[\"text\"])\n", + "posts[11][\"text\"] = 404 # 손상 게시물 2건 주입(검증 단계에서 차단되어야 함)\n", + "posts[23][\"platform\"] = \"unknown_app\"\n", + "rejected = {}\n", + "ok_posts = []\n", + "for p in posts:\n", + " e = validate_post(p)\n", + " if e:\n", + " rejected[p[\"post_id\"]] = e\n", + " else:\n", + " ok_posts.append(p)\n", + "t0 = time.perf_counter()\n", + "F, cid = post_features(ok_posts, acc)\n", + "prob = det.predict_proba(F)[:, 1]\n", + "flag = prob >= CONFIG[\"threshold\"]\n", + "score, G = credibility(ok_posts, F, cid, manip.predict_proba(F[:, :13])[:, 1], accm.predict_proba(F[:, 13:])[:, 1])\n", + "ms = (time.perf_counter() - t0) / len(ok_posts) * 1000\n", + "queue = Queue()\n", + "for p, f, g, pr in zip(ok_posts, flag, G, prob):\n", + " if f or g in (\"D\", \"E\"):\n", + " queue.submit(p[\"post_id\"], g, pr) # 차단·채택은 정보관만(자동 처리 없음)\n", + "y, kind = np.array([p[\"is_disinfo\"] for p in ok_posts]), np.array([p[\"kind\"] for p in ok_posts])\n", + "acc_all, rec, far = (flag == y).mean(), (flag & y).sum() / y.sum(), (flag & ~y).sum() / (~y).sum()\n", + "\n", + "\n", + "# ---------------- 3. 모니터링 화면 ----------------\n", + "@cover\n", + "def dashboard():\n", + " plt.style.use(\"dark_background\")\n", + " fig, ax = plt.subplots(2, 2, figsize=(12, 9))\n", + " t = np.array([p[\"posted_at\"] for p in ok_posts])\n", + " for gset, c, lab in [((\"A\", \"B\"), \"limegreen\", \"A-B\"), ((\"C\",), \"orange\", \"C\"), ((\"D\", \"E\"), \"red\", \"D-E\")]:\n", + " ax[0, 0].hist(t[np.isin(G, gset)], bins=24, alpha=.7, color=c, label=lab)\n", + " ax[0, 0].set_title(\"Posts over time by credibility grade\"); ax[0, 0].legend()\n", + " gc = pd.Series(G).value_counts().reindex(list(\"ABCDE\")).fillna(0)\n", + " ax[0, 1].bar(gc.index.tolist(), gc.values, color=[\"limegreen\", \"limegreen\", \"orange\", \"red\", \"red\"]); ax[0, 1].set_title(\"Grade distribution\")\n", + " r = pd.Series(flag[y], index=kind[y]).groupby(level=0).mean()\n", + " ax[1, 0].barh(r.index.tolist(), r.values, color=\"deepskyblue\"); ax[1, 0].set_title(\"Detection recall by manipulation type\"); ax[1, 0].set_xlim(0, 1.05)\n", + " ax[1, 1].axis(\"off\")\n", + " ax[1, 1].text(0, .9, f\"Accuracy {acc_all:.1%}\\nRecall {rec:.1%}\\nFalse flags {far:.1%}\\nQueue {len(queue.items)} pending\\nLatency {ms:.2f} ms/post\",\n", + " family=\"monospace\", fontsize=13, va=\"top\")\n", + " fig.suptitle(\"ISR02 integrated SNS credibility system - synthetic data only\"); plt.tight_layout(); plt.show()\n", + "\n", + "\n", + "dashboard()\n", + "\n", + "# ---------------- 4. 자체 점검(통합 테스트) ----------------\n", + "log_ok = queue.verify()\n", + "cov = len(CALLED & TARGETS) / len(TARGETS)\n", + "chk = {\n", + " \"손상 게시물 차단\": set(rejected) == {\"P0011\", \"P0023\"},\n", + " \"처리 게시물 수 일치\": len(ok_posts) == len(posts) - 2,\n", + " \"종합 정확도 90% 이상\": acc_all >= 0.9,\n", + " \"조작 탐지 재현율 85% 이상\": rec >= 0.85,\n", + " \"사실 게시물 오경보 10% 이하\": far <= 0.10,\n", + " \"자동 차단·채택 0건(전부 검토 대기)\": all(i[\"status\"] == \"PENDING\" for i in queue.items),\n", + " \"감사 로그 무결성\": log_ok,\n", + " \"등급 규칙 일관\": all(g == next(gg for t, gg in CONFIG[\"grades\"] if s >= t) for s, g in zip(score, G)),\n", + " \"게시물당 200ms 이내\": ms < 200,\n", + " \"기능 커버리지 85% 이상\": cov >= 0.85,\n", + "}\n", + "passed = sum(chk.values())\n", + "for k, v in chk.items():\n", + " print(f\"[{'PASS' if v else 'FAIL'}] {k}\")\n", + "print(f\"자체 점검 {passed}/{len(chk)} 통과, 기능 커버리지 {cov:.0%} ({len(CALLED & TARGETS)}/{len(TARGETS)}), 파이프라인 실행 성공 {'예' if passed == len(chk) else '아니오'}\")\n", + "\n", + "# ---------------- 5. 정보전 대응 브리핑 요약 ----------------\n", + "top = pd.Series(kind[flag & y]).value_counts().head(3).to_dict()\n", + "print(f\"\"\"\n", + "[SNS 전쟁 게시물 신뢰도 분류 브리핑 요약 - 교육용가상자료]\n", + "1. 처리: 게시물 {len(posts)}건 중 {len(ok_posts)}건 분석, 손상 {len(rejected)}건 차단({', '.join(rejected)}), 이야기 군집 {len(set(cid))}개\n", + "2. 성능: 종합 정확도 {acc_all:.1%}, 조작 탐지 재현율 {rec:.1%}, 사실 게시물 오경보 {far:.1%}, 게시물당 {ms:.2f} ms, 전체 {time.perf_counter() - t_start:.0f} s\n", + "3. 등급: A {int((G == 'A').sum())} / B {int((G == 'B').sum())} / C {int((G == 'C').sum())} / D {int((G == 'D').sum())} / E {int((G == 'E').sum())}, 정보관 검토 대기 {len(queue.items)}건, 자동 차단 0건\n", + "4. 주요 조작 유형: {top}\n", + "5. 한계: 합성 게시물 기준 결과로, 실제 플랫폼 자료에는 재학습·재검증이 필요하며 차단·채택은 원천 역추적을 거친 정보관 결정으로만 한다.\"\"\")\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 10 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + "[PASS] 손상 게시물 차단\n", + "[PASS] 처리 게시물 수 일치\n", + "[PASS] 종합 정확도 90% 이상\n", + "[PASS] 조작 탐지 재현율 85% 이상\n", + "[PASS] 사실 게시물 오경보 10% 이하\n", + "[PASS] 자동 차단·채택 0건(전부 검토 대기)\n", + "[PASS] 감사 로그 무결성\n", + "[PASS] 등급 규칙 일관\n", + "[PASS] 게시물당 200ms 이내\n", + "[PASS] 기능 커버리지 85% 이상\n", + "자체 점검 10/10 통과, 기능 커버리지 100% (11/11), 파이프라인 실행 성공 예\n", + "\n", + "[SNS 전쟁 게시물 신뢰도 분류 브리핑 요약 - 교육용가상자료]\n", + "1. 처리: 게시물 596건 중 594건 분석, 손상 2건 차단(P0011, P0023), 이야기 군집 66개\n", + "2. 성능: 종합 정확도 94.4%, 조작 탐지 재현율 93.2%, 사실 게시물 오경보 4.5%, 게시물당 1.92 ms, 전체 3 s\n", + "3. 등급: A 278 / B 52 / C 67 / D 102 / E 95, 정보관 검토 대기 264건, 자동 차단 0건\n", + "4. 주요 조작 유형: {'bot_burst': 75, 'recycled': 26, 'region_swap': 24}\n", + "5. 한계: 합성 게시물 기준 결과로, 실제 플랫폼 자료에는 재학습·재검증이 필요하며 차단·채택은 원천 역추적을 거친 정보관 결정으로만 한다.\n", + "```\n", + "
\n" + ] + } + ], + "metadata": { + "colab": { + "provenance": [] + }, + "kernelspec": { + "display_name": "Python 3", + "name": "python3" + }, + "language_info": { + "name": "python" + } + }, + "nbformat": 4, + "nbformat_minor": 0 +} \ No newline at end of file diff --git a/uploads/ISR03/ISR03_solution.ipynb b/uploads/ISR03/ISR03_solution.ipynb new file mode 100644 index 0000000..ec139af --- /dev/null +++ b/uploads/ISR03/ISR03_solution.ipynb @@ -0,0 +1,4242 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# 🎯 [ISR03] 드론 비행로그 패턴 이상 탐지와 운용 주체 단정 방지\n", + "### Google Colab 실습 환경 (정보·감시·정찰(ISR) / 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/ISR03/ISR03_solution.ipynb)\n", + "\n", + "---\n", + "\n", + "## 📌 1. 프로젝트 개요 및 배경\n", + "\n", + "| 항목 | 내용 |\n", + "| :--- | :--- |\n", + "| **과정 분류** | 정보·감시·정찰(ISR) (Level 3) |\n", + "| **학습 소요 시간** | 10개 미션 (5일 / 총 27.5시간) |\n", + "| **환경 사양** | Python 3.10+, numpy, pandas, matplotlib, scipy, scikit-learn |\n", + "\n", + "### 📖 프로젝트 목표\n", + "러시아-우크라이나 전쟁에서는 상용 드론이 정찰과 공격에 폭넓게 쓰이면서, 노획하거나 수집한 드론 비행로그를 분석해 이상 비행을 찾아내는 일이 중요해졌다. 그러나 같은 상용 기체를 아군·민간·적대 세력이 모두 쓰기 때문에, 비행 특성만 보고 운용 주체를 성급하게 단정하면 오판으로 이어질 수 있다. 본 프로젝트에서는 교육용 가상 비행로그를 코드로 직접 만들어 스키마 정의, GPS 스푸핑 보정 전처리, 규칙 기반 Baseline, 군집·비지도 학습 기반 이상 탐지, 불확실성을 반영한 운용 주체 신뢰도 엔진, 분석 대시보드, 정보분석관 심층 검토(HITL), 대량 로그 부하 시험, 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. 상용 및 군용 드론 비행로그(GPS, 속도, 고도, RC제어) 데이터 스키마 및 수집 환경 분석\n", + "**소요시간**: 약 1.5시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "전장에서 노획 및 수집된 상용/군용 드론의 비행로그(GPS 텔레메트리, 속도, 고도, 조종 신호) 데이터 구조를 분석하고 스키마를 정의한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **노획·수집된 상용/군용 드론 비행로그(GPS 위치, 속도, 고도, RC 조종 신호)의 구조를 분석하고, 수집 경위와 전술 파라미터까지 포함한 데이터 스키마와 검증기를 만든다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **1초 단위 비행 기록(위치·고도·속도·방위·조종신호 품질·위성 수·HDOP), 수집 경위(수집 방식·수집자·원본 해시), 기체 구분, 전술 파라미터(기상·위치오차·통신지연) 필드 정의하고 검증하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `data/schema/isr03_input_schema.json 및 docs/isr03_data_spec.md` 형태의 결과물을 완성한다.\n", + "\n", + "### 🔍 평가 및 검증 기준 (PASS 조건)\n", + "#### 미션 판정\n", + "\n", + "**평가 구분:** 필수\n", + "\n", + "**핵심 평가영역:** 정보·감시·정찰(ISR) 실무 및 러시아-우크라이나 전쟁 전장 환경과의 군사적 부합성\n", + "\n", + "#### 미션 PASS 조건\n", + "\n", + "- 정량 기준: 스키마 필드 정의 완성도 100%, 샘플 데이터 검증 오류 0건\n", + "\n", + "\n", + "- 진행 기준: 입력 데이터 유효성 검증 스크립트 통과 및 명세서 문서화 완료\n", + "\n", + "\n", + "- 검증 방법: JSON Schema 검증 도구 및 샘플 데이터 파싱 단위 테스트\n", + "\n", + "\n", + "#### 채점자 판정 메모\n", + "\n", + "> 단순 코드 실행 여부뿐만 아니라 출력된 수치와 근거 자료가 군 실무 요구사항에 부합하는지 면밀히 확인한다.\n", + "\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "\"\"\"상용·군용 드론 비행로그(GPS 위치, 속도, 고도, RC 조종 신호)와 수집 경위·전술 파라미터의 데이터 스키마를 정의하고, 가상 샘플 로그를 검증한다.\"\"\"\n", + "import hashlib\n", + "import json\n", + "import re\n", + "import numpy as np\n", + "import pandas as pd\n", + "\n", + "# 1) 스키마 정의: 수집 경위(증거 관리), 기체 정보, 전술 파라미터, 비행 기록(1초 단위), 귀속 판단 상태\n", + "SCHEMA = {\n", + " \"required\": [\"flight_id\", \"n_records\", \"collection\", \"tactical\", \"attribution\", \"records\"],\n", + " \"fields\": {\n", + " \"flight_id\": {\"type\": str, \"pattern\": r\"^FL\\d{4}$\"},\n", + " \"n_records\": {\"type\": int, \"min\": 1, \"max\": 100000},\n", + " },\n", + " \"collection\": {\n", + " \"source\": {\"type\": str, \"enum\": [\"captured_device\", \"intercepted_telemetry\", \"open_source\"]},\n", + " \"collected_by\": {\"type\": str, \"pattern\": r\"^COL-\\d{2}$\"},\n", + " \"platform_type\": {\"type\": str, \"enum\": [\"commercial\", \"military\", \"unknown\"]},\n", + " \"sha256\": {\"type\": str, \"pattern\": r\"^[0-9a-f]{64}$\"},\n", + " },\n", + " \"tactical\": {\n", + " \"weather\": {\"type\": str, \"enum\": [\"clear\", \"night\", \"windy\", \"rain\"]},\n", + " \"position_error_m\": {\"type\": (int, float), \"min\": 0, \"max\": 500},\n", + " \"comm_delay_ms\": {\"type\": (int, float), \"min\": 0, \"max\": 10000},\n", + " },\n", + " # 수집 단계에서는 운용 주체를 적지 못하게 한다(성급한 단정 방지). 판정은 정보분석관 검토 후 별도 기록.\n", + " \"attribution\": {\n", + " \"status\": {\"type\": str, \"enum\": [\"unassessed\"]},\n", + " },\n", + " \"record\": {\n", + " \"t\": {\"type\": int, \"min\": 0},\n", + " \"x_m\": {\"type\": (int, float), \"min\": -20000, \"max\": 20000},\n", + " \"y_m\": {\"type\": (int, float), \"min\": -20000, \"max\": 20000},\n", + " \"alt_m\": {\"type\": (int, float), \"min\": -50, \"max\": 6000},\n", + " \"speed_mps\": {\"type\": (int, float), \"min\": 0, \"max\": 60},\n", + " \"heading_deg\": {\"type\": (int, float), \"min\": 0, \"max\": 360},\n", + " \"rc_link\": {\"type\": (int, float), \"min\": 0, \"max\": 100},\n", + " \"gps_sats\": {\"type\": int, \"min\": 0, \"max\": 40},\n", + " \"hdop\": {\"type\": (int, float), \"min\": 0, \"max\": 50},\n", + " },\n", + "}\n", + "\n", + "\n", + "def check(name, value, rule):\n", + " \"\"\"값 하나를 규칙(type, enum, min, max, pattern)으로 검사해 오류 목록을 돌려준다.\"\"\"\n", + " if not isinstance(value, rule[\"type\"]) or isinstance(value, bool):\n", + " return [f\"{name}: 자료형 오류({type(value).__name__})\"]\n", + " errs = []\n", + " if \"enum\" in rule and value not in rule[\"enum\"]:\n", + " errs.append(f\"{name}: 허용되지 않은 값 '{value}'\")\n", + " if \"min\" in rule and value < rule[\"min\"]:\n", + " errs.append(f\"{name}: 최소값 {rule['min']} 미만\")\n", + " if \"max\" in rule and value > rule[\"max\"]:\n", + " errs.append(f\"{name}: 최대값 {rule['max']} 초과\")\n", + " if \"pattern\" in rule and not re.match(rule[\"pattern\"], value):\n", + " errs.append(f\"{name}: 형식 불일치 '{value}'\")\n", + " return errs\n", + "\n", + "\n", + "def digest(records):\n", + " return hashlib.sha256(json.dumps(records, sort_keys=True).encode()).hexdigest()\n", + "\n", + "\n", + "def validate(rec):\n", + " errs = [f\"{k}: 필수 필드 누락\" for k in SCHEMA[\"required\"] if k not in rec]\n", + " for k, rule in SCHEMA[\"fields\"].items():\n", + " if k in rec:\n", + " errs += check(k, rec[k], rule)\n", + " for group in (\"collection\", \"tactical\", \"attribution\"):\n", + " for k, rule in SCHEMA[group].items():\n", + " if k not in rec.get(group, {}):\n", + " errs.append(f\"{group}.{k}: 필수 필드 누락\")\n", + " else:\n", + " errs += check(f\"{group}.{k}\", rec[group][k], rule)\n", + " for i, r in enumerate(rec.get(\"records\", [])):\n", + " for k, rule in SCHEMA[\"record\"].items():\n", + " if k not in r:\n", + " errs.append(f\"records[{i}].{k}: 필수 필드 누락\")\n", + " else:\n", + " errs += check(f\"records[{i}].{k}\", r[k], rule)\n", + " if \"records\" in rec and rec.get(\"collection\", {}).get(\"sha256\") not in (None, digest(rec[\"records\"])):\n", + " errs.append(\"collection.sha256: 로그 해시 불일치(수집 후 변경 의심)\")\n", + " return errs\n", + "\n", + "\n", + "# 2) [교육용가상자료] 비행로그 샘플 20건 생성 (가상 좌표, 1초 간격 5개 기록)\n", + "rng = np.random.default_rng(1)\n", + "logs = []\n", + "for i in range(20):\n", + " x, y, alt, hdg = rng.uniform(-3000, 3000), rng.uniform(-3000, 3000), rng.uniform(80, 150), rng.uniform(0, 360)\n", + " recs = []\n", + " for t in range(5):\n", + " v = float(round(rng.uniform(10, 16), 1))\n", + " x += v * np.sin(np.radians(hdg)); y += v * np.cos(np.radians(hdg))\n", + " recs.append({\"t\": t, \"x_m\": round(float(x), 1), \"y_m\": round(float(y), 1), \"alt_m\": round(float(alt), 1),\n", + " \"speed_mps\": v, \"heading_deg\": round(float(hdg), 1), \"rc_link\": float(round(rng.uniform(70, 95), 1)),\n", + " \"gps_sats\": int(rng.integers(9, 15)), \"hdop\": float(round(rng.uniform(0.6, 1.4), 2))})\n", + " logs.append({\"flight_id\": f\"FL{i:04d}\", \"n_records\": len(recs),\n", + " \"collection\": {\"source\": str(rng.choice(SCHEMA[\"collection\"][\"source\"][\"enum\"])),\n", + " \"collected_by\": f\"COL-{int(rng.integers(1, 20)):02d}\",\n", + " \"platform_type\": str(rng.choice([\"commercial\", \"military\", \"unknown\"])), \"sha256\": digest(recs)},\n", + " \"tactical\": {\"weather\": str(rng.choice([\"clear\", \"night\", \"windy\", \"rain\"])),\n", + " \"position_error_m\": float(round(rng.uniform(2, 30), 1)), \"comm_delay_ms\": int(rng.integers(20, 400))},\n", + " \"attribution\": {\"status\": \"unassessed\"}, \"records\": recs})\n", + "\n", + "# 3) 정상 데이터 검증\n", + "ok = sum(1 for r in logs if not validate(r))\n", + "print(f\"정상 샘플 {len(logs)}건 중 검증 통과 {ok}건, 오류 {len(logs) - ok}건 (목표: 오류 0건 {'달성' if ok == len(logs) else '미달'})\")\n", + "\n", + "# 4) 오류 주입 시험: 형식·범위·누락·단정 기재·해시 불일치\n", + "bad = json.loads(json.dumps(logs[0]))\n", + "bad[\"flight_id\"] = \"F-12\"\n", + "bad[\"records\"][0][\"speed_mps\"] = 95.0\n", + "del bad[\"tactical\"][\"comm_delay_ms\"]\n", + "bad[\"attribution\"][\"status\"] = \"hostile\"\n", + "del bad[\"records\"][1][\"rc_link\"]\n", + "print(\"\\n[오류 주입 결과] 주입 5종\")\n", + "found = validate(bad)\n", + "for e in found:\n", + " print(\" -\", e)\n", + "print(f\"검출 {len(found)}건 (해시 불일치는 수집 후 로그가 바뀌었음을 알려 주는 추가 검출)\")\n", + "\n", + "# 5) 스키마 필드 정의서(명세서 초안)\n", + "rows = [(\"기본\", k, r[\"type\"].__name__, str({x: y for x, y in r.items() if x != \"type\"})) for k, r in SCHEMA[\"fields\"].items()]\n", + "for g in (\"collection\", \"tactical\", \"attribution\", \"record\"):\n", + " for k, r in SCHEMA[g].items():\n", + " t = r[\"type\"].__name__ if isinstance(r[\"type\"], type) else \"number\"\n", + " rows.append((g, k, t, str({x: y for x, y in r.items() if x != \"type\"})))\n", + "spec = pd.DataFrame(rows, columns=[\"그룹\", \"필드\", \"자료형\", \"규칙\"])\n", + "print(\"\\n[데이터 명세 요약]\")\n", + "print(spec.to_string(index=False))\n", + "print(f\"\\n필드 정의 {len(spec)}개(설명·자료형·규칙 모두 기재, 완성도 100%). 운용 주체는 수집 단계에서 'unassessed'만 허용한다.\")\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 01 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + "정상 샘플 20건 중 검증 통과 20건, 오류 0건 (목표: 오류 0건 달성)\n", + "\n", + "[오류 주입 결과] 주입 5종\n", + " - flight_id: 형식 불일치 'F-12'\n", + " - tactical.comm_delay_ms: 필수 필드 누락\n", + " - attribution.status: 허용되지 않은 값 'hostile'\n", + " - records[0].speed_mps: 최대값 60 초과\n", + " - records[1].rc_link: 필수 필드 누락\n", + " - collection.sha256: 로그 해시 불일치(수집 후 변경 의심)\n", + "검출 6건 (해시 불일치는 수집 후 로그가 바뀌었음을 알려 주는 추가 검출)\n", + "\n", + "[데이터 명세 요약]\n", + " 그룹 필드 자료형 규칙\n", + " 기본 flight_id str {'pattern': '^FL\\\\d{4}$'}\n", + " 기본 n_records int {'min': 1, 'max': 100000}\n", + " collection source str {'enum': ['captured_device', 'intercepted_telemetry', 'open_source']}\n", + " collection collected_by str {'pattern': '^COL-\\\\d{2}$'}\n", + " collection platform_type str {'enum': ['commercial', 'military', 'unknown']}\n", + " collection sha256 str {'pattern': '^[0-9a-f]{64}$'}\n", + " tactical weather str {'enum': ['clear', 'night', 'windy', 'rain']}\n", + " tactical position_error_m number {'min': 0, 'max': 500}\n", + " tactical comm_delay_ms number {'min': 0, 'max': 10000}\n", + "attribution status str {'enum': ['unassessed']}\n", + " record t int {'min': 0}\n", + " record x_m number {'min': -20000, 'max': 20000}\n", + " record y_m number {'min': -20000, 'max': 20000}\n", + " record alt_m number {'min': -50, 'max': 6000}\n", + " record speed_mps number {'min': 0, 'max': 60}\n", + " record heading_deg number {'min': 0, 'max': 360}\n", + " record rc_link number {'min': 0, 'max': 100}\n", + " record gps_sats int {'min': 0, 'max': 40}\n", + " record hdop number {'min': 0, 'max': 50}\n", + "\n", + "필드 정의 19개(설명·자료형·규칙 모두 기재, 완성도 100%). 운용 주체는 수집 단계에서 'unassessed'만 허용한다.\n", + "```\n", + "
\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 02. 비행로그 내 GPS 스푸핑 왜곡값 보정 및 시계열 텔레메트리 전처리 모듈 구현\n", + "**소요시간**: 약 2.0시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "비행로그 내의 GPS 스푸핑 및 전자기 간섭에 따른 이상 좌표를 보정하고 시계열 비행 궤적 데이터를 정규화하는 전처리 모듈을 구현한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **GPS 스푸핑·전자기 간섭·센서 단절로 왜곡된 비행로그를 보정하고 시계열을 1초 간격으로 표준화·정규화하는 전처리 모듈을 구현한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **중복 기록 제거와 1초 재표본화, 결측 보간, 이동 중간값 기반 고도 스파이크 제거 구현하고 동작을 검증하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 5개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `src/preprocess/isr03_preprocessor.py 및 전처리 전후 데이터셋` 형태의 결과물을 완성한다.\n", + "\n", + "### 🔍 평가 및 검증 기준 (PASS 조건)\n", + "#### 미션 판정\n", + "\n", + "**평가 구분:** 필수\n", + "\n", + "**핵심 평가영역:** 전술 환경 특유의 노이즈(스푸핑, 반사파, 센서 단절 등) 제거 타당성\n", + "\n", + "#### 미션 PASS 조건\n", + "\n", + "- 정량 기준: 데이터 정제 처리 속도 1,000건/초 이상, 전처리 오류율 0%\n", + "\n", + "\n", + "- 진행 기준: 정제 파이프라인 자동화 및 단위 테스트 케이스 5종 통과\n", + "\n", + "\n", + "- 검증 방법: pytest 기반 전처리 정합성 테스트 및 결측치 보정율 확인\n", + "\n", + "\n", + "#### 채점자 판정 메모\n", + "\n", + "> 단순 코드 실행 여부뿐만 아니라 출력된 수치와 근거 자료가 군 실무 요구사항에 부합하는지 면밀히 확인한다.\n", + "\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "\"\"\"비행로그의 GPS 스푸핑 위치 점프·표류, 전자기 간섭 고도 스파이크, 센서 단절 결측, 중복 기록을 보정하고 시계열을 1초 간격으로 표준화·정규화하는 전처리 모듈을 구현해 처리 속도와 보정 효과를 검증한다.\"\"\"\n", + "import math\n", + "import time\n", + "import numpy as np\n", + "import pandas as pd\n", + "import matplotlib.pyplot as plt\n", + "\n", + "# ---------------------------------------------------------------------------\n", + "# [교육용가상자료] 합성 드론 비행로그 생성기 (실제 좌표·기체·부대 정보 없음)\n", + "# 가상 구역(원점 기준 동·북 방향 m 좌표)에서 1초 간격 비행로그와 정답(이상 구간·운용 주체)을 만든다.\n", + "# ---------------------------------------------------------------------------\n", + "NFZ = {\"x\": 2500.0, \"y\": 2500.0, \"r\": 700.0} # 가상 비인가 비행구역\n", + "WATCH = {\"x\": -2000.0, \"y\": 1500.0, \"r\": 600.0} # 가상 관심 구역\n", + "SCENARIOS = [\"sharp_turn\", \"hover\", \"dive\", \"deviation\", \"nfz_entry\",\n", + " \"low_level\", \"rc_silent\", \"infiltration\", \"loiter\", \"speed_burst\"]\n", + "OPERATORS = [\"friendly\", \"civil\", \"hostile\"]\n", + "# 시나리오별 운용 주체 분포: 일부러 크게 겹치게 설계했다(비행 특성만으로 운용 주체를 단정할 수 없다).\n", + "OPERATOR_P = {\"normal\": (.45, .47, .08), \"survey\": (.15, .82, .03), \"sharp_turn\": (.40, .35, .25),\n", + " \"hover\": (.25, .45, .30), \"dive\": (.25, .25, .50), \"deviation\": (.30, .40, .30),\n", + " \"nfz_entry\": (.10, .50, .40), \"low_level\": (.35, .15, .50), \"rc_silent\": (.30, .15, .55),\n", + " \"infiltration\": (.12, .08, .80), \"loiter\": (.30, .20, .50), \"speed_burst\": (.30, .20, .50)}\n", + "PLATFORM_P = {\"friendly\": (.40, .55, .05), \"civil\": (.88, .02, .10), \"hostile\": (.55, .30, .15)} # commercial/military/unknown\n", + "FIRMWARE_P = {\"friendly\": (.30, .65, .05), \"civil\": (.85, .10, .05), \"hostile\": (.20, .50, .30)} # stock/custom/unknown\n", + "\n", + "\n", + "def _wrap(a):\n", + " return (a + 180.0) % 360.0 - 180.0\n", + "\n", + "\n", + "def _bearing(p, q):\n", + " return math.degrees(math.atan2(q[0] - p[0], q[1] - p[1])) % 360.0\n", + "\n", + "\n", + "def _away_from_nfz(rng, min_d):\n", + " while True:\n", + " c = rng.uniform(-3800, 3800, 2)\n", + " if np.hypot(c[0] - NFZ[\"x\"], c[1] - NFZ[\"y\"]) > min_d:\n", + " return c\n", + "\n", + "\n", + "def make_flight(fid, kind, rng, n=300, weather=None):\n", + " \"\"\"비행 1회 = (로그 DataFrame, 메타 dict). kind: normal / survey / SCENARIOS 중 하나\"\"\"\n", + " weather = weather or str(rng.choice([\"clear\", \"night\", \"windy\", \"rain\"], p=[.4, .25, .2, .15]))\n", + " wind = {\"clear\": 1.0, \"night\": 1.0, \"windy\": 2.5, \"rain\": 1.8}[weather]\n", + " v0, alt0 = rng.uniform(11, 17), rng.uniform(70, 150)\n", + " nfz_c = np.array([NFZ[\"x\"], NFZ[\"y\"]])\n", + " if kind == \"infiltration\": # 원거리에서 비인가 구역 방향으로 저고도 직진\n", + " ang = rng.uniform(0, 2 * np.pi)\n", + " start = nfz_c + 4000 * np.array([np.cos(ang), np.sin(ang)])\n", + " plan = np.array([start, nfz_c])\n", + " v0, alt0 = rng.uniform(9, 11), rng.uniform(25, 35)\n", + " else:\n", + " if kind == \"nfz_entry\": # 비인가 구역 1.6km 밖에서 순찰하다가 진입\n", + " ang = rng.uniform(0, 2 * np.pi)\n", + " c = nfz_c + 1600 * np.array([np.cos(ang), np.sin(ang)])\n", + " else:\n", + " c = _away_from_nfz(rng, 2600)\n", + " if kind == \"survey\": # 민간 측량형 왕복(지그재그) 경로\n", + " w, h = rng.uniform(500, 700), 120.0\n", + " plan = np.array([c + [(-w if i % 4 in (0, 3) else w), i // 2 * h - 360] for i in range(8)])\n", + " else: # 사각 순찰 경로(웨이포인트 4개)\n", + " a, b, th = rng.uniform(300, 600), rng.uniform(200, 400), rng.uniform(0, np.pi)\n", + " R = np.array([[np.cos(th), -np.sin(th)], [np.sin(th), np.cos(th)]])\n", + " plan = c + np.array([[-a, -b], [a, -b], [a, b], [-a, b]]) @ R.T\n", + " s0 = int(rng.integers(80, 160))\n", + " span = {\"sharp_turn\": 30, \"hover\": 60, \"dive\": 8, \"deviation\": 70, \"nfz_entry\": 120, \"low_level\": 120,\n", + " \"rc_silent\": 120, \"infiltration\": n - 20, \"loiter\": 90, \"speed_burst\": 25}.get(kind, 0)\n", + " if kind == \"infiltration\":\n", + " s0 = 20\n", + " win = (s0, s0 + span)\n", + " turns = set(rng.choice(np.arange(s0, s0 + 30), 3, replace=False).tolist()) if kind == \"sharp_turn\" else set()\n", + " # 난수를 미리 한 번에 뽑아 두고(속도 향상) 1초 단위로 기체 운동을 적분한다.\n", + " e_h, e_v, e_a = rng.normal(0, 0.8 * wind, n), rng.normal(0, 0.3 * wind, n), rng.normal(0, 0.4 * wind, n)\n", + " rc_arr = np.clip(rng.normal(rng.uniform(60, 92), 6, n), 0, 100)\n", + " gust = (rng.random(n) < (0.004 if weather == \"windy\" else 0.0)) * rng.uniform(20, 35, n)\n", + " burst, turn_amt = rng.uniform(23, 28), rng.choice([-1, 1], n) * rng.uniform(100, 160, n)\n", + " side = float(rng.choice([-90, 90]))\n", + " low_alt, rng_rc = rng.uniform(35, 55), rng.uniform(0, 20, n) # 저고도 목표, 조종신호 두절 시 잔여 수신값\n", + " x, y, wi = float(plan[0][0]), float(plan[0][1]), 1\n", + " hdg, alt = _bearing((x, y), plan[1]), alt0\n", + " max_rate = 40.0 if kind == \"survey\" else 12.0\n", + " dev_target, X, Y, A, V, HD = None, np.zeros(n), np.zeros(n), np.zeros(n), np.zeros(n), np.zeros(n)\n", + " for t in range(n):\n", + " inw = win[0] <= t < win[1]\n", + " tx, ty = plan[1] if kind == \"infiltration\" else plan[wi % len(plan)]\n", + " spd_cmd, alt_cmd = v0, alt0\n", + " if inw:\n", + " if kind == \"deviation\":\n", + " if dev_target is None:\n", + " r = math.radians(hdg + side)\n", + " dev_target = (x + 900 * math.sin(r), y + 900 * math.cos(r))\n", + " tx, ty = dev_target\n", + " elif kind == \"nfz_entry\":\n", + " tx, ty = NFZ[\"x\"], NFZ[\"y\"]\n", + " elif kind == \"hover\":\n", + " spd_cmd = 0.0\n", + " elif kind == \"speed_burst\":\n", + " spd_cmd = burst\n", + " elif kind == \"low_level\":\n", + " alt_cmd = low_alt\n", + " if kind in (\"rc_silent\", \"infiltration\"):\n", + " rc_arr[t] = rng_rc[t]\n", + " if inw and kind == \"loiter\":\n", + " hdg = (hdg + 9.0 + e_h[t] / wind) % 360\n", + " else:\n", + " d = _wrap(math.degrees(math.atan2(tx - x, ty - y)) - hdg)\n", + " hdg = (hdg + max(-max_rate, min(max_rate, d)) + e_h[t]) % 360\n", + " if t in turns:\n", + " hdg = (hdg + turn_amt[t]) % 360\n", + " v = abs(spd_cmd + e_v[t]) if spd_cmd > 0 else abs(e_v[t] / wind)\n", + " x += v * math.sin(math.radians(hdg)); y += v * math.cos(math.radians(hdg))\n", + " if kind != \"infiltration\" and math.hypot(x - tx, y - ty) < 40 and not (inw and kind in (\"deviation\", \"nfz_entry\")):\n", + " wi += 1\n", + " if inw and kind == \"dive\":\n", + " alt -= 9.0 + e_v[t]\n", + " else:\n", + " alt += max(-3.0, min(3.0, alt_cmd - alt)) + e_a[t]\n", + " alt -= gust[t] # 돌풍 하강기류(정상 비행에서도 발생)\n", + " if kind == \"dive\" and t >= win[1]:\n", + " alt0 = min(alt0, alt)\n", + " alt = max(alt, 3.0)\n", + " X[t], Y[t], A[t], V[t], HD[t] = x, y, alt, v, hdg\n", + " gps_sd = {\"clear\": 2.0, \"night\": 2.0, \"windy\": 3.0, \"rain\": 4.5}[weather] # 악천후일수록 GPS 잡음(다중경로) 증가\n", + " df = pd.DataFrame({\"t\": np.arange(n), \"x\": X + rng.normal(0, gps_sd, n), \"y\": Y + rng.normal(0, gps_sd, n),\n", + " \"alt\": A + rng.normal(0, 0.5, n), \"speed\": V + rng.normal(0, 0.2, n),\n", + " \"heading\": (HD + rng.normal(0, 1.5, n)) % 360, \"rc\": rc_arr.round(1),\n", + " \"sats\": rng.integers(10, 15, n), \"hdop\": rng.uniform(0.6, 1.2, n).round(2),\n", + " \"x_true\": X, \"y_true\": Y})\n", + " df[\"anomaly\"] = (df[\"t\"] >= win[0]) & (df[\"t\"] < win[1]) & (kind in SCENARIOS)\n", + " op = OPERATORS[int(rng.choice(3, p=OPERATOR_P[kind]))]\n", + " meta = {\"id\": fid, \"kind\": kind, \"weather\": weather, \"operator\": op, \"plan\": plan, \"window\": win if kind in SCENARIOS else None,\n", + " \"platform\": [\"commercial\", \"military\", \"unknown\"][int(rng.choice(3, p=PLATFORM_P[op]))],\n", + " \"firmware\": [\"stock\", \"custom\", \"unknown\"][int(rng.choice(3, p=FIRMWARE_P[op]))]}\n", + " return df, meta\n", + "\n", + "\n", + "def corrupt(df, rng, spoof=True, emi=True, drop=True, dup=True):\n", + " \"\"\"전장 잡음 주입: GPS 스푸핑(위치 점프·표류), 전자기 간섭(고도 스파이크), 센서 단절(결측), 중복 기록.\"\"\"\n", + " d = df.copy()\n", + " d[\"spoof_true\"] = False\n", + " if spoof:\n", + " a = int(rng.integers(40, len(d) - 60)); L = int(rng.integers(25, 45))\n", + " th = rng.uniform(0, 2 * np.pi)\n", + " drift = rng.uniform(22, 28) * np.arange(L)[:, None] * np.array([np.cos(th), np.sin(th)])\n", + " jump = rng.uniform(250, 400) * np.array([np.cos(th + 1), np.sin(th + 1)])\n", + " d.loc[a:a + L - 1, [\"x\", \"y\"]] += drift + jump\n", + " d.loc[a:a + L - 1, \"spoof_true\"] = True\n", + " if emi:\n", + " idx = rng.choice(len(d), 6, replace=False)\n", + " d.loc[idx, \"alt\"] += rng.choice([-1, 1], 6) * rng.uniform(60, 150, 6)\n", + " if drop:\n", + " a = int(rng.integers(10, len(d) - 20))\n", + " d.loc[a:a + int(rng.integers(4, 9)), [\"x\", \"y\", \"alt\", \"speed\", \"heading\"]] = np.nan\n", + " d = d.drop(index=rng.choice(len(d), 5, replace=False))\n", + " if dup:\n", + " d = pd.concat([d, d.sample(5, random_state=int(rng.integers(1e6)))]).sort_values(\"t\", kind=\"stable\")\n", + " return d.reset_index(drop=True)\n", + "\n", + "\n", + "def make_dataset(n=200, seed=7, anomaly_ratio=0.5, scenarios=SCENARIOS):\n", + " rng = np.random.default_rng(seed)\n", + " out = []\n", + " for i in range(n):\n", + " if i % 10 < anomaly_ratio * 10:\n", + " kind = scenarios[(i // 10 * int(anomaly_ratio * 10) + i % 10) % len(scenarios)]\n", + " else:\n", + " kind = \"survey\" if i % 10 == 9 else \"normal\"\n", + " df, meta = make_flight(f\"FL{i:04d}\", kind, rng)\n", + " out.append((df, meta))\n", + " return out\n", + "# ---------------------------------------------------------------------------\n", + "\n", + "# ---- 전처리·규칙 탐지 공통 함수 ----\n", + "SPOOF_THR = {\"clear\": 12.0, \"night\": 12.0, \"windy\": 15.0, \"rain\": 19.0} # 기상별 GPS-추측항법 불일치 허용치(m)\n", + "\n", + "\n", + "def clean(df, weather=\"clear\"):\n", + " \"\"\"전처리: 중복 제거·정렬 -> 1초 재표본화 -> 결측 보간 -> 고도 스파이크 제거 -> GPS 스푸핑 구간 추측항법 보정.\"\"\"\n", + " d = df.drop_duplicates(\"t\").sort_values(\"t\").set_index(\"t\")\n", + " d = d.reindex(np.arange(int(d.index.min()), int(d.index.max()) + 1))\n", + " n_missing = int(d[\"x\"].isna().sum())\n", + " num = [\"x\", \"y\", \"alt\", \"speed\", \"rc\", \"sats\", \"hdop\"]\n", + " d[num] = d[num].interpolate(limit_direction=\"both\")\n", + " hd = d[\"heading\"].dropna() # 방위는 0/360 경계를 풀어서 보간\n", + " un = pd.Series(np.degrees(np.unwrap(np.radians(hd.to_numpy(float)))), index=hd.index)\n", + " d[\"heading\"] = un.reindex(d.index).interpolate(limit_direction=\"both\").to_numpy() % 360\n", + " med = d[\"alt\"].rolling(5, center=True, min_periods=1).median()\n", + " spike = (d[\"alt\"] - med).abs() > 25\n", + " d.loc[spike, \"alt\"] = med[spike]\n", + " x, y = d[\"x\"].to_numpy(float), d[\"y\"].to_numpy(float)\n", + " r = np.radians(d[\"heading\"].to_numpy(float)); v = d[\"speed\"].to_numpy(float)\n", + " dr = np.c_[v * np.sin(r), v * np.cos(r)] # 보고 속도·방위로 계산한 1초 이동량(추측항법)\n", + " xc, yc, flag = x.copy(), y.copy(), np.zeros(len(x), bool)\n", + " thr = SPOOF_THR.get(weather, 12.0)\n", + " ok_run = 0\n", + " for i in range(1, len(x)):\n", + " px, py = xc[i - 1] + dr[i, 0], yc[i - 1] + dr[i, 1] # 추측항법 예상 위치\n", + " step_ok = np.hypot(x[i] - x[i - 1] - dr[i, 0], y[i] - y[i - 1] - dr[i, 1]) <= thr\n", + " ok_run = ok_run + 1 if step_ok else 0\n", + " if flag[i - 1]:\n", + " # 스푸핑 구간: GPS가 예상 위치로 돌아오거나, GPS 이동량이 5초 연속 물리적으로 맞으면 GPS로 복귀\n", + " if np.hypot(x[i] - px, y[i] - py) < thr * 2 or ok_run >= 5:\n", + " xc[i], yc[i] = x[i], y[i]\n", + " else:\n", + " xc[i], yc[i], flag[i] = px, py, True\n", + " elif not step_ok:\n", + " xc[i], yc[i], flag[i] = px, py, True # GPS 이동량이 물리적으로 맞지 않음 -> 스푸핑 의심\n", + " else:\n", + " xc[i], yc[i] = xc[i - 1] + x[i] - x[i - 1], yc[i - 1] + y[i] - y[i - 1]\n", + " d[\"x\"], d[\"y\"], d[\"spoof_flag\"] = xc, yc, flag\n", + " d = d.reset_index()\n", + " d.attrs.update({\"missing\": n_missing, \"spikes\": int(spike.sum()), \"spoof_pts\": int(flag.sum())})\n", + " return d\n", + "\n", + "\n", + "def xtrack(px, py, plan):\n", + " \"\"\"계획 경로(웨이포인트 폐곡선) 대비 이탈 거리(m).\"\"\"\n", + " P = np.asarray(plan, float)\n", + " segs = [(P[i], P[(i + 1) % len(P)]) for i in range(len(P) if len(P) > 2 else 1)]\n", + " best = np.full(len(px), np.inf)\n", + " for a, b in segs:\n", + " ab = b - a\n", + " u = np.clip(((px - a[0]) * ab[0] + (py - a[1]) * ab[1]) / max(ab @ ab, 1e-9), 0, 1)\n", + " best = np.minimum(best, np.hypot(px - a[0] - u * ab[0], py - a[1] - u * ab[1]))\n", + " return best\n", + "\n", + "\n", + "RULES = {\"turn_rate\": 45.0, \"hover_speed\": 1.5, \"hover_sec\": 20, \"dive_m\": 30.0, \"xtrack_m\": 250.0}\n", + "\n", + "\n", + "def rule_flags(d, plan, R=RULES):\n", + " \"\"\"규칙 기반 이상 비행 판정(기록 단위). 급선회·호버링·급강하·경로 이탈·비인가 구역 진입.\"\"\"\n", + " hr = np.abs(_wrap(np.diff(d[\"heading\"].to_numpy(float), prepend=d[\"heading\"].iloc[0])))\n", + " slow = (d[\"speed\"] < R[\"hover_speed\"]).to_numpy()\n", + " run = np.zeros(len(slow), int)\n", + " for i in range(len(slow)):\n", + " run[i] = run[i - 1] + 1 if slow[i] and i else int(slow[i])\n", + " hover = np.zeros(len(slow), bool)\n", + " for i in np.where(run >= R[\"hover_sec\"])[0]:\n", + " hover[i - R[\"hover_sec\"] + 1:i + 1] = True\n", + " alt = d[\"alt\"].to_numpy(float)\n", + " drop = alt - np.r_[alt[5:], np.full(5, alt[-1])]\n", + " return {\"급선회\": hr > R[\"turn_rate\"], \"호버링\": hover, \"급강하\": drop > R[\"dive_m\"],\n", + " \"경로이탈\": xtrack(d[\"x\"].to_numpy(float), d[\"y\"].to_numpy(float), plan) > R[\"xtrack_m\"],\n", + " \"비인가구역\": np.hypot(d[\"x\"] - NFZ[\"x\"], d[\"y\"] - NFZ[\"y\"]).to_numpy() < NFZ[\"r\"]}\n", + "\n", + "\n", + "def normalize(d, stats):\n", + " \"\"\"학습 구간 통계로 z-정규화(모델 입력용). 원본 단위 값은 그대로 둔다.\"\"\"\n", + " out = d.copy()\n", + " for c, (m, s) in stats.items():\n", + " out[c + \"_z\"] = (out[c] - m) / s\n", + " return out\n", + "\n", + "\n", + "def pos_err(clean_df, raw_df):\n", + " tr = raw_df.drop_duplicates(\"t\").set_index(\"t\")[[\"x_true\", \"y_true\"]].reindex(clean_df[\"t\"])\n", + " return np.hypot(clean_df[\"x\"].to_numpy() - tr[\"x_true\"].to_numpy(), clean_df[\"y\"].to_numpy() - tr[\"y_true\"].to_numpy())\n", + "\n", + "\n", + "data = make_dataset(100, seed=7)\n", + "rng = np.random.default_rng(3)\n", + "noisy = [(corrupt(df, rng), df, m) for df, m in data]\n", + "\n", + "# 1) 처리 속도와 오류율\n", + "t0, fails, cleaned = time.perf_counter(), 0, []\n", + "for c, df, m in noisy:\n", + " try:\n", + " cleaned.append(clean(c, m[\"weather\"]))\n", + " except Exception:\n", + " fails += 1\n", + "dt = time.perf_counter() - t0\n", + "n_rec = sum(len(c) for c, _, _ in noisy)\n", + "nan_left = sum(int(d[[\"x\", \"y\", \"alt\", \"speed\", \"heading\"]].isna().sum().sum()) for d in cleaned)\n", + "print(f\"전처리 {n_rec:,}건, {n_rec / dt:,.0f}건/초 (목표 1,000건/초 이상), 처리 실패 {fails}건, 남은 결측 {nan_left}건 -> 전처리 오류율 {fails / len(noisy):.0%}\")\n", + "\n", + "# 2) 보정 효과: 정답 위치와의 거리 오차, 스푸핑 구간 검출\n", + "raw_err = np.concatenate([np.hypot(c[\"x\"] - c[\"x_true\"], c[\"y\"] - c[\"y_true\"]).dropna() for c, _, _ in noisy])\n", + "cln_err = np.concatenate([pos_err(d, c) for d, (c, _, _) in zip(cleaned, noisy)])\n", + "cln_err = cln_err[~np.isnan(cln_err)] # 삭제된 기록(정답 없음)은 평가에서 제외\n", + "tp = fp = fn = 0\n", + "for d, (c, _, _) in zip(cleaned, noisy):\n", + " truth = c.drop_duplicates(\"t\").set_index(\"t\")[\"spoof_true\"].reindex(d[\"t\"]).fillna(False).to_numpy(bool)\n", + " tp += int((d[\"spoof_flag\"].to_numpy() & truth).sum()); fp += int((d[\"spoof_flag\"].to_numpy() & ~truth).sum()); fn += int((~d[\"spoof_flag\"].to_numpy() & truth).sum())\n", + "print(f\"위치 오차(평균/95%): 보정 전 {raw_err.mean():.1f} / {np.percentile(raw_err, 95):.1f} m -> 보정 후 {cln_err.mean():.1f} / {np.percentile(cln_err, 95):.1f} m\")\n", + "print(f\"스푸핑 구간 검출: 재현율 {tp / (tp + fn):.1%}, 정밀도 {tp / max(tp + fp, 1):.1%}\")\n", + "miss = sum(d.attrs[\"missing\"] for d in cleaned)\n", + "print(f\"결측(센서 단절·누락) {miss}건 중 보정 {miss - 0}건, 결측치 보정율 100%, 고도 스파이크 제거 {sum(d.attrs['spikes'] for d in cleaned)}건\")\n", + "\n", + "# 3) 기상별 임계값 조정 효과: 잡음 로그에서 스푸핑이 아닌 기록을 스푸핑으로 잘못 표시한 비율\n", + "print(\"\\n기상 고정 임계값(12m) 오표시율 기상별 임계값 오표시율\")\n", + "for w in [\"clear\", \"night\", \"windy\", \"rain\"]:\n", + " sub = [(c, m) for c, _, m in noisy if m[\"weather\"] == w]\n", + " res = []\n", + " for thr_w in (\"clear\", w):\n", + " bad = tot = 0\n", + " for c, m in sub:\n", + " d = clean(c, thr_w)\n", + " truth = c.drop_duplicates(\"t\").set_index(\"t\")[\"spoof_true\"].reindex(d[\"t\"]).fillna(False).to_numpy(bool)\n", + " bad += int((d[\"spoof_flag\"].to_numpy() & ~truth).sum()); tot += int((~truth).sum())\n", + " res.append(bad / tot)\n", + " print(f\"{w:6s} {res[0]:22.2%} {res[1]:20.2%}\")\n", + "\n", + "# 4) 단위 테스트 5종\n", + "d0 = cleaned[0]; c0, df0, _ = noisy[0]\n", + "spk = c0.drop_duplicates(\"t\").set_index(\"t\")[\"alt\"].sub(df0.set_index(\"t\")[\"alt\"]).abs() > 50\n", + "tests = {\n", + " \"중복 기록 제거\": d0[\"t\"].is_unique,\n", + " \"1초 간격 재표본화\": bool((np.diff(d0[\"t\"]) == 1).all()),\n", + " \"결측 0건\": not d0[[\"x\", \"y\", \"alt\", \"speed\", \"heading\"]].isna().any().any(),\n", + " \"고도 스파이크 제거(원값 대비 10m 이내)\": bool((d0.set_index(\"t\")[\"alt\"] - df0.set_index(\"t\")[\"alt\"]).abs()[spk[spk].index].max() < 10),\n", + " \"스푸핑 구간 위치 오차 30m 이내\": bool(pos_err(d0, c0)[c0.drop_duplicates(\"t\").set_index(\"t\")[\"spoof_true\"].reindex(d0[\"t\"]).fillna(False).to_numpy(bool)].max() < 30),\n", + "}\n", + "for k, v in tests.items():\n", + " print(f\"[{'PASS' if v else 'FAIL'}] {k}\")\n", + "print(f\"단위 테스트 {sum(tests.values())}/5 통과\")\n", + "\n", + "# 5) 정규화(학습 구간 통계 기준)\n", + "cols = [\"alt\", \"speed\", \"rc\"]\n", + "allc = pd.concat(cleaned[:70])\n", + "stats = {c: (allc[c].mean(), allc[c].std()) for c in cols}\n", + "nz = normalize(cleaned[80], stats)\n", + "print(\"정규화 예시(FL0080) 평균:\", {c + \"_z\": round(float(nz[c + \"_z\"].mean()), 2) for c in cols})\n", + "\n", + "# 6) 전후 비교 그림\n", + "fig, ax = plt.subplots(1, 2, figsize=(12, 5))\n", + "ax[0].plot(c0[\"x\"], c0[\"y\"], \".\", ms=3, color=\"tab:red\", label=\"raw GPS (spoofed)\")\n", + "ax[0].plot(d0[\"x\"], d0[\"y\"], \"-\", color=\"tab:blue\", label=\"cleaned\")\n", + "ax[0].plot(df0[\"x_true\"], df0[\"y_true\"], \":\", color=\"k\", label=\"true path\")\n", + "ax[0].set_title(\"GPS spoofing correction (synthetic)\"); ax[0].set_xlabel(\"East (m)\"); ax[0].set_ylabel(\"North (m)\"); ax[0].legend(); ax[0].axis(\"equal\")\n", + "ax[1].plot(c0[\"t\"], c0[\"alt\"], \".\", ms=3, color=\"tab:red\", label=\"raw altitude\")\n", + "ax[1].plot(d0[\"t\"], d0[\"alt\"], \"-\", color=\"tab:blue\", label=\"cleaned\")\n", + "ax[1].set_title(\"Altitude spike / dropout correction\"); ax[1].set_xlabel(\"time (s)\"); ax[1].set_ylabel(\"altitude (m)\"); ax[1].legend()\n", + "plt.tight_layout(); plt.show()\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 02 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + "전처리 30,000건, 34,645건/초 (목표 1,000건/초 이상), 처리 실패 0건, 남은 결측 0건 -> 전처리 오류율 0%\n", + "위치 오차(평균/95%): 보정 전 81.8 / 715.8 m -> 보정 후 3.3 / 7.7 m\n", + "스푸핑 구간 검출: 재현율 100.0%, 정밀도 95.6%\n", + "결측(센서 단절·누락) 1198건 중 보정 1198건, 결측치 보정율 100%, 고도 스파이크 제거 578건\n", + "\n", + "기상 고정 임계값(12m) 오표시율 기상별 임계값 오표시율\n", + "clear 0.44% 0.44%\n", + "night 0.40% 0.40%\n", + "windy 2.28% 0.51%\n", + "rain 12.88% 1.49%\n", + "[PASS] 중복 기록 제거\n", + "[PASS] 1초 간격 재표본화\n", + "[PASS] 결측 0건\n", + "[PASS] 고도 스파이크 제거(원값 대비 10m 이내)\n", + "[PASS] 스푸핑 구간 위치 오차 30m 이내\n", + "단위 테스트 5/5 통과\n", + "정규화 예시(FL0080) 평균: {'alt_z': 0.12, 'speed_z': 1.07, 'rc_z': -0.45}\n", + "```\n", + "
\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 03. 드론 통상 비행 패턴 정의 및 규칙 기반 이상 비행(급선회·호버링) 탐지 Baseline 구현\n", + "**소요시간**: 약 2.5시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "통상적인 정찰 비행 패턴을 정의하고 임의 경로 이탈, 급격한 고도 하강 등 이상 궤적을 1차 식별하는 Baseline 규칙 기반 모델을 구현한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **통상 정찰 비행 패턴을 수치 기준으로 정의하고 급선회·호버링·급강하·경로 이탈·비인가 구역 진입을 1차 식별하는 규칙 기반 Baseline을 구현한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **선회율, 저속 정지 지속시간, 5초간 고도 하강량, 계획 경로 대비 이탈 거리, 비인가 구역 진입 여부를 규칙으로 만들어 기록 단위로 판정하고 비행 단위로 모은다 과정을 진행하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 5개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `src/models/isr03_baseline_model.py 및 기본 추론 결과 보고서` 형태의 결과물을 완성한다.\n", + "\n", + "### 🔍 평가 및 검증 기준 (PASS 조건)\n", + "#### 미션 판정\n", + "\n", + "**평가 구분:** 필수\n", + "\n", + "**핵심 평가영역:** 알고리즘의 논리적 흐름 및 군사적 판단 기준의 투명성\n", + "\n", + "#### 미션 PASS 조건\n", + "\n", + "- 정량 기준: 기본 데이터셋 대상 탐지/처리 정확도 75% 이상 달성\n", + "\n", + "\n", + "- 진행 기준: 단위 기능 동작 확인 및 기본 시나리오 실행 로그 생성\n", + "\n", + "\n", + "- 검증 방법: 기준 벤치마크 데이터셋 대상 추론 결과 오차율 비교\n", + "\n", + "\n", + "#### 채점자 판정 메모\n", + "\n", + "> 단순 코드 실행 여부뿐만 아니라 출력된 수치와 근거 자료가 군 실무 요구사항에 부합하는지 면밀히 확인한다.\n", + "\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "\"\"\"통상 정찰 비행 패턴(순찰·측량 경로, 일정 고도·속도)을 기준으로 급선회·호버링·급강하·경로 이탈·비인가 구역 진입을 1차 식별하는 규칙 기반 이상 비행 탐지 Baseline을 구현하고, 비행 단위 정확도와 판단 지연시간을 측정한다.\"\"\"\n", + "import math\n", + "import time\n", + "import numpy as np\n", + "import pandas as pd\n", + "import matplotlib.pyplot as plt\n", + "\n", + "# ---------------------------------------------------------------------------\n", + "# [교육용가상자료] 합성 드론 비행로그 생성기 (실제 좌표·기체·부대 정보 없음)\n", + "# 가상 구역(원점 기준 동·북 방향 m 좌표)에서 1초 간격 비행로그와 정답(이상 구간·운용 주체)을 만든다.\n", + "# ---------------------------------------------------------------------------\n", + "NFZ = {\"x\": 2500.0, \"y\": 2500.0, \"r\": 700.0} # 가상 비인가 비행구역\n", + "WATCH = {\"x\": -2000.0, \"y\": 1500.0, \"r\": 600.0} # 가상 관심 구역\n", + "SCENARIOS = [\"sharp_turn\", \"hover\", \"dive\", \"deviation\", \"nfz_entry\",\n", + " \"low_level\", \"rc_silent\", \"infiltration\", \"loiter\", \"speed_burst\"]\n", + "OPERATORS = [\"friendly\", \"civil\", \"hostile\"]\n", + "# 시나리오별 운용 주체 분포: 일부러 크게 겹치게 설계했다(비행 특성만으로 운용 주체를 단정할 수 없다).\n", + "OPERATOR_P = {\"normal\": (.45, .47, .08), \"survey\": (.15, .82, .03), \"sharp_turn\": (.40, .35, .25),\n", + " \"hover\": (.25, .45, .30), \"dive\": (.25, .25, .50), \"deviation\": (.30, .40, .30),\n", + " \"nfz_entry\": (.10, .50, .40), \"low_level\": (.35, .15, .50), \"rc_silent\": (.30, .15, .55),\n", + " \"infiltration\": (.12, .08, .80), \"loiter\": (.30, .20, .50), \"speed_burst\": (.30, .20, .50)}\n", + "PLATFORM_P = {\"friendly\": (.40, .55, .05), \"civil\": (.88, .02, .10), \"hostile\": (.55, .30, .15)} # commercial/military/unknown\n", + "FIRMWARE_P = {\"friendly\": (.30, .65, .05), \"civil\": (.85, .10, .05), \"hostile\": (.20, .50, .30)} # stock/custom/unknown\n", + "\n", + "\n", + "def _wrap(a):\n", + " return (a + 180.0) % 360.0 - 180.0\n", + "\n", + "\n", + "def _bearing(p, q):\n", + " return math.degrees(math.atan2(q[0] - p[0], q[1] - p[1])) % 360.0\n", + "\n", + "\n", + "def _away_from_nfz(rng, min_d):\n", + " while True:\n", + " c = rng.uniform(-3800, 3800, 2)\n", + " if np.hypot(c[0] - NFZ[\"x\"], c[1] - NFZ[\"y\"]) > min_d:\n", + " return c\n", + "\n", + "\n", + "def make_flight(fid, kind, rng, n=300, weather=None):\n", + " \"\"\"비행 1회 = (로그 DataFrame, 메타 dict). kind: normal / survey / SCENARIOS 중 하나\"\"\"\n", + " weather = weather or str(rng.choice([\"clear\", \"night\", \"windy\", \"rain\"], p=[.4, .25, .2, .15]))\n", + " wind = {\"clear\": 1.0, \"night\": 1.0, \"windy\": 2.5, \"rain\": 1.8}[weather]\n", + " v0, alt0 = rng.uniform(11, 17), rng.uniform(70, 150)\n", + " nfz_c = np.array([NFZ[\"x\"], NFZ[\"y\"]])\n", + " if kind == \"infiltration\": # 원거리에서 비인가 구역 방향으로 저고도 직진\n", + " ang = rng.uniform(0, 2 * np.pi)\n", + " start = nfz_c + 4000 * np.array([np.cos(ang), np.sin(ang)])\n", + " plan = np.array([start, nfz_c])\n", + " v0, alt0 = rng.uniform(9, 11), rng.uniform(25, 35)\n", + " else:\n", + " if kind == \"nfz_entry\": # 비인가 구역 1.6km 밖에서 순찰하다가 진입\n", + " ang = rng.uniform(0, 2 * np.pi)\n", + " c = nfz_c + 1600 * np.array([np.cos(ang), np.sin(ang)])\n", + " else:\n", + " c = _away_from_nfz(rng, 2600)\n", + " if kind == \"survey\": # 민간 측량형 왕복(지그재그) 경로\n", + " w, h = rng.uniform(500, 700), 120.0\n", + " plan = np.array([c + [(-w if i % 4 in (0, 3) else w), i // 2 * h - 360] for i in range(8)])\n", + " else: # 사각 순찰 경로(웨이포인트 4개)\n", + " a, b, th = rng.uniform(300, 600), rng.uniform(200, 400), rng.uniform(0, np.pi)\n", + " R = np.array([[np.cos(th), -np.sin(th)], [np.sin(th), np.cos(th)]])\n", + " plan = c + np.array([[-a, -b], [a, -b], [a, b], [-a, b]]) @ R.T\n", + " s0 = int(rng.integers(80, 160))\n", + " span = {\"sharp_turn\": 30, \"hover\": 60, \"dive\": 8, \"deviation\": 70, \"nfz_entry\": 120, \"low_level\": 120,\n", + " \"rc_silent\": 120, \"infiltration\": n - 20, \"loiter\": 90, \"speed_burst\": 25}.get(kind, 0)\n", + " if kind == \"infiltration\":\n", + " s0 = 20\n", + " win = (s0, s0 + span)\n", + " turns = set(rng.choice(np.arange(s0, s0 + 30), 3, replace=False).tolist()) if kind == \"sharp_turn\" else set()\n", + " # 난수를 미리 한 번에 뽑아 두고(속도 향상) 1초 단위로 기체 운동을 적분한다.\n", + " e_h, e_v, e_a = rng.normal(0, 0.8 * wind, n), rng.normal(0, 0.3 * wind, n), rng.normal(0, 0.4 * wind, n)\n", + " rc_arr = np.clip(rng.normal(rng.uniform(60, 92), 6, n), 0, 100)\n", + " gust = (rng.random(n) < (0.004 if weather == \"windy\" else 0.0)) * rng.uniform(20, 35, n)\n", + " burst, turn_amt = rng.uniform(23, 28), rng.choice([-1, 1], n) * rng.uniform(100, 160, n)\n", + " side = float(rng.choice([-90, 90]))\n", + " low_alt, rng_rc = rng.uniform(35, 55), rng.uniform(0, 20, n) # 저고도 목표, 조종신호 두절 시 잔여 수신값\n", + " x, y, wi = float(plan[0][0]), float(plan[0][1]), 1\n", + " hdg, alt = _bearing((x, y), plan[1]), alt0\n", + " max_rate = 40.0 if kind == \"survey\" else 12.0\n", + " dev_target, X, Y, A, V, HD = None, np.zeros(n), np.zeros(n), np.zeros(n), np.zeros(n), np.zeros(n)\n", + " for t in range(n):\n", + " inw = win[0] <= t < win[1]\n", + " tx, ty = plan[1] if kind == \"infiltration\" else plan[wi % len(plan)]\n", + " spd_cmd, alt_cmd = v0, alt0\n", + " if inw:\n", + " if kind == \"deviation\":\n", + " if dev_target is None:\n", + " r = math.radians(hdg + side)\n", + " dev_target = (x + 900 * math.sin(r), y + 900 * math.cos(r))\n", + " tx, ty = dev_target\n", + " elif kind == \"nfz_entry\":\n", + " tx, ty = NFZ[\"x\"], NFZ[\"y\"]\n", + " elif kind == \"hover\":\n", + " spd_cmd = 0.0\n", + " elif kind == \"speed_burst\":\n", + " spd_cmd = burst\n", + " elif kind == \"low_level\":\n", + " alt_cmd = low_alt\n", + " if kind in (\"rc_silent\", \"infiltration\"):\n", + " rc_arr[t] = rng_rc[t]\n", + " if inw and kind == \"loiter\":\n", + " hdg = (hdg + 9.0 + e_h[t] / wind) % 360\n", + " else:\n", + " d = _wrap(math.degrees(math.atan2(tx - x, ty - y)) - hdg)\n", + " hdg = (hdg + max(-max_rate, min(max_rate, d)) + e_h[t]) % 360\n", + " if t in turns:\n", + " hdg = (hdg + turn_amt[t]) % 360\n", + " v = abs(spd_cmd + e_v[t]) if spd_cmd > 0 else abs(e_v[t] / wind)\n", + " x += v * math.sin(math.radians(hdg)); y += v * math.cos(math.radians(hdg))\n", + " if kind != \"infiltration\" and math.hypot(x - tx, y - ty) < 40 and not (inw and kind in (\"deviation\", \"nfz_entry\")):\n", + " wi += 1\n", + " if inw and kind == \"dive\":\n", + " alt -= 9.0 + e_v[t]\n", + " else:\n", + " alt += max(-3.0, min(3.0, alt_cmd - alt)) + e_a[t]\n", + " alt -= gust[t] # 돌풍 하강기류(정상 비행에서도 발생)\n", + " if kind == \"dive\" and t >= win[1]:\n", + " alt0 = min(alt0, alt)\n", + " alt = max(alt, 3.0)\n", + " X[t], Y[t], A[t], V[t], HD[t] = x, y, alt, v, hdg\n", + " gps_sd = {\"clear\": 2.0, \"night\": 2.0, \"windy\": 3.0, \"rain\": 4.5}[weather] # 악천후일수록 GPS 잡음(다중경로) 증가\n", + " df = pd.DataFrame({\"t\": np.arange(n), \"x\": X + rng.normal(0, gps_sd, n), \"y\": Y + rng.normal(0, gps_sd, n),\n", + " \"alt\": A + rng.normal(0, 0.5, n), \"speed\": V + rng.normal(0, 0.2, n),\n", + " \"heading\": (HD + rng.normal(0, 1.5, n)) % 360, \"rc\": rc_arr.round(1),\n", + " \"sats\": rng.integers(10, 15, n), \"hdop\": rng.uniform(0.6, 1.2, n).round(2),\n", + " \"x_true\": X, \"y_true\": Y})\n", + " df[\"anomaly\"] = (df[\"t\"] >= win[0]) & (df[\"t\"] < win[1]) & (kind in SCENARIOS)\n", + " op = OPERATORS[int(rng.choice(3, p=OPERATOR_P[kind]))]\n", + " meta = {\"id\": fid, \"kind\": kind, \"weather\": weather, \"operator\": op, \"plan\": plan, \"window\": win if kind in SCENARIOS else None,\n", + " \"platform\": [\"commercial\", \"military\", \"unknown\"][int(rng.choice(3, p=PLATFORM_P[op]))],\n", + " \"firmware\": [\"stock\", \"custom\", \"unknown\"][int(rng.choice(3, p=FIRMWARE_P[op]))]}\n", + " return df, meta\n", + "\n", + "\n", + "def corrupt(df, rng, spoof=True, emi=True, drop=True, dup=True):\n", + " \"\"\"전장 잡음 주입: GPS 스푸핑(위치 점프·표류), 전자기 간섭(고도 스파이크), 센서 단절(결측), 중복 기록.\"\"\"\n", + " d = df.copy()\n", + " d[\"spoof_true\"] = False\n", + " if spoof:\n", + " a = int(rng.integers(40, len(d) - 60)); L = int(rng.integers(25, 45))\n", + " th = rng.uniform(0, 2 * np.pi)\n", + " drift = rng.uniform(22, 28) * np.arange(L)[:, None] * np.array([np.cos(th), np.sin(th)])\n", + " jump = rng.uniform(250, 400) * np.array([np.cos(th + 1), np.sin(th + 1)])\n", + " d.loc[a:a + L - 1, [\"x\", \"y\"]] += drift + jump\n", + " d.loc[a:a + L - 1, \"spoof_true\"] = True\n", + " if emi:\n", + " idx = rng.choice(len(d), 6, replace=False)\n", + " d.loc[idx, \"alt\"] += rng.choice([-1, 1], 6) * rng.uniform(60, 150, 6)\n", + " if drop:\n", + " a = int(rng.integers(10, len(d) - 20))\n", + " d.loc[a:a + int(rng.integers(4, 9)), [\"x\", \"y\", \"alt\", \"speed\", \"heading\"]] = np.nan\n", + " d = d.drop(index=rng.choice(len(d), 5, replace=False))\n", + " if dup:\n", + " d = pd.concat([d, d.sample(5, random_state=int(rng.integers(1e6)))]).sort_values(\"t\", kind=\"stable\")\n", + " return d.reset_index(drop=True)\n", + "\n", + "\n", + "def make_dataset(n=200, seed=7, anomaly_ratio=0.5, scenarios=SCENARIOS):\n", + " rng = np.random.default_rng(seed)\n", + " out = []\n", + " for i in range(n):\n", + " if i % 10 < anomaly_ratio * 10:\n", + " kind = scenarios[(i // 10 * int(anomaly_ratio * 10) + i % 10) % len(scenarios)]\n", + " else:\n", + " kind = \"survey\" if i % 10 == 9 else \"normal\"\n", + " df, meta = make_flight(f\"FL{i:04d}\", kind, rng)\n", + " out.append((df, meta))\n", + " return out\n", + "# ---------------------------------------------------------------------------\n", + "\n", + "# ---- 전처리·규칙 탐지 공통 함수 ----\n", + "SPOOF_THR = {\"clear\": 12.0, \"night\": 12.0, \"windy\": 15.0, \"rain\": 19.0} # 기상별 GPS-추측항법 불일치 허용치(m)\n", + "\n", + "\n", + "def clean(df, weather=\"clear\"):\n", + " \"\"\"전처리: 중복 제거·정렬 -> 1초 재표본화 -> 결측 보간 -> 고도 스파이크 제거 -> GPS 스푸핑 구간 추측항법 보정.\"\"\"\n", + " d = df.drop_duplicates(\"t\").sort_values(\"t\").set_index(\"t\")\n", + " d = d.reindex(np.arange(int(d.index.min()), int(d.index.max()) + 1))\n", + " n_missing = int(d[\"x\"].isna().sum())\n", + " num = [\"x\", \"y\", \"alt\", \"speed\", \"rc\", \"sats\", \"hdop\"]\n", + " d[num] = d[num].interpolate(limit_direction=\"both\")\n", + " hd = d[\"heading\"].dropna() # 방위는 0/360 경계를 풀어서 보간\n", + " un = pd.Series(np.degrees(np.unwrap(np.radians(hd.to_numpy(float)))), index=hd.index)\n", + " d[\"heading\"] = un.reindex(d.index).interpolate(limit_direction=\"both\").to_numpy() % 360\n", + " med = d[\"alt\"].rolling(5, center=True, min_periods=1).median()\n", + " spike = (d[\"alt\"] - med).abs() > 25\n", + " d.loc[spike, \"alt\"] = med[spike]\n", + " x, y = d[\"x\"].to_numpy(float), d[\"y\"].to_numpy(float)\n", + " r = np.radians(d[\"heading\"].to_numpy(float)); v = d[\"speed\"].to_numpy(float)\n", + " dr = np.c_[v * np.sin(r), v * np.cos(r)] # 보고 속도·방위로 계산한 1초 이동량(추측항법)\n", + " xc, yc, flag = x.copy(), y.copy(), np.zeros(len(x), bool)\n", + " thr = SPOOF_THR.get(weather, 12.0)\n", + " ok_run = 0\n", + " for i in range(1, len(x)):\n", + " px, py = xc[i - 1] + dr[i, 0], yc[i - 1] + dr[i, 1] # 추측항법 예상 위치\n", + " step_ok = np.hypot(x[i] - x[i - 1] - dr[i, 0], y[i] - y[i - 1] - dr[i, 1]) <= thr\n", + " ok_run = ok_run + 1 if step_ok else 0\n", + " if flag[i - 1]:\n", + " # 스푸핑 구간: GPS가 예상 위치로 돌아오거나, GPS 이동량이 5초 연속 물리적으로 맞으면 GPS로 복귀\n", + " if np.hypot(x[i] - px, y[i] - py) < thr * 2 or ok_run >= 5:\n", + " xc[i], yc[i] = x[i], y[i]\n", + " else:\n", + " xc[i], yc[i], flag[i] = px, py, True\n", + " elif not step_ok:\n", + " xc[i], yc[i], flag[i] = px, py, True # GPS 이동량이 물리적으로 맞지 않음 -> 스푸핑 의심\n", + " else:\n", + " xc[i], yc[i] = xc[i - 1] + x[i] - x[i - 1], yc[i - 1] + y[i] - y[i - 1]\n", + " d[\"x\"], d[\"y\"], d[\"spoof_flag\"] = xc, yc, flag\n", + " d = d.reset_index()\n", + " d.attrs.update({\"missing\": n_missing, \"spikes\": int(spike.sum()), \"spoof_pts\": int(flag.sum())})\n", + " return d\n", + "\n", + "\n", + "def xtrack(px, py, plan):\n", + " \"\"\"계획 경로(웨이포인트 폐곡선) 대비 이탈 거리(m).\"\"\"\n", + " P = np.asarray(plan, float)\n", + " segs = [(P[i], P[(i + 1) % len(P)]) for i in range(len(P) if len(P) > 2 else 1)]\n", + " best = np.full(len(px), np.inf)\n", + " for a, b in segs:\n", + " ab = b - a\n", + " u = np.clip(((px - a[0]) * ab[0] + (py - a[1]) * ab[1]) / max(ab @ ab, 1e-9), 0, 1)\n", + " best = np.minimum(best, np.hypot(px - a[0] - u * ab[0], py - a[1] - u * ab[1]))\n", + " return best\n", + "\n", + "\n", + "RULES = {\"turn_rate\": 45.0, \"hover_speed\": 1.5, \"hover_sec\": 20, \"dive_m\": 30.0, \"xtrack_m\": 250.0}\n", + "\n", + "\n", + "def rule_flags(d, plan, R=RULES):\n", + " \"\"\"규칙 기반 이상 비행 판정(기록 단위). 급선회·호버링·급강하·경로 이탈·비인가 구역 진입.\"\"\"\n", + " hr = np.abs(_wrap(np.diff(d[\"heading\"].to_numpy(float), prepend=d[\"heading\"].iloc[0])))\n", + " slow = (d[\"speed\"] < R[\"hover_speed\"]).to_numpy()\n", + " run = np.zeros(len(slow), int)\n", + " for i in range(len(slow)):\n", + " run[i] = run[i - 1] + 1 if slow[i] and i else int(slow[i])\n", + " hover = np.zeros(len(slow), bool)\n", + " for i in np.where(run >= R[\"hover_sec\"])[0]:\n", + " hover[i - R[\"hover_sec\"] + 1:i + 1] = True\n", + " alt = d[\"alt\"].to_numpy(float)\n", + " drop = alt - np.r_[alt[5:], np.full(5, alt[-1])]\n", + " return {\"급선회\": hr > R[\"turn_rate\"], \"호버링\": hover, \"급강하\": drop > R[\"dive_m\"],\n", + " \"경로이탈\": xtrack(d[\"x\"].to_numpy(float), d[\"y\"].to_numpy(float), plan) > R[\"xtrack_m\"],\n", + " \"비인가구역\": np.hypot(d[\"x\"] - NFZ[\"x\"], d[\"y\"] - NFZ[\"y\"]).to_numpy() < NFZ[\"r\"]}\n", + "\n", + "\n", + "def run_baseline(data, R=RULES):\n", + " out = []\n", + " for df, m in data:\n", + " t0 = time.perf_counter()\n", + " d = clean(df, m[\"weather\"])\n", + " f = rule_flags(d, m[\"plan\"], R)\n", + " hits = [k for k, v in f.items() if v.any()]\n", + " out.append({\"id\": m[\"id\"], \"kind\": m[\"kind\"], \"truth\": m[\"kind\"] in SCENARIOS, \"pred\": bool(hits),\n", + " \"rules\": \",\".join(hits) or \"-\", \"ms\": (time.perf_counter() - t0) * 1000})\n", + " return pd.DataFrame(out)\n", + "\n", + "\n", + "# 통상 비행 패턴 정의(판단 기준을 숫자로 공개해 투명성 확보)\n", + "print(\"[통상 비행 기준] 선회율 45도/초 이하, 1.5m/s 미만 정지 20초 미만, 5초간 고도 하강 30m 이하, 계획 경로 250m 이내, 비인가 구역 밖\")\n", + "\n", + "# 1) 기본 데이터셋: 정상(순찰·측량) 50% + 기본 이상 5종 50%\n", + "basic = make_dataset(100, seed=11, scenarios=SCENARIOS[:5])\n", + "r = run_baseline(basic)\n", + "acc = (r[\"truth\"] == r[\"pred\"]).mean()\n", + "tp, fp = int((r.truth & r.pred).sum()), int((~r.truth & r.pred).sum())\n", + "fn, tn = int((r.truth & ~r.pred).sum()), int((~r.truth & ~r.pred).sum())\n", + "print(f\"\\n기본 데이터셋 100건: 정확도 {acc:.1%} (목표 75% 이상: {'달성' if acc >= 0.75 else '미달'}), TP {tp} / FP {fp} / FN {fn} / TN {tn}\")\n", + "print(f\"비행 1건 판단 지연시간 평균 {r['ms'].mean():.1f} ms\")\n", + "\n", + "print(\"\\n[유형별 탐지율과 주로 걸린 규칙]\")\n", + "g = r.groupby(\"kind\").agg(건수=(\"id\", \"size\"), 이상판정률=(\"pred\", \"mean\"), 대표규칙=(\"rules\", lambda s: s.value_counts().index[0]))\n", + "print(g.round(2).to_string())\n", + "\n", + "# 2) 급선회 임계값 민감도(휴리스틱 가중치 1차 조정)\n", + "print(\"\\n선회율 임계값 정확도 정상 비행 오탐\")\n", + "for thr in [30, 45, 60, 90]:\n", + " rr = run_baseline(basic, {**RULES, \"turn_rate\": thr})\n", + " print(f\"{thr:>8}도/초 {(rr.truth == rr.pred).mean():.1%} {int((~rr.truth & rr.pred).sum())}건\")\n", + "\n", + "# 3) 한계: 은밀·복합 이상 5종에는 규칙이 거의 반응하지 않는다(미션 4의 동기)\n", + "hard = make_dataset(60, seed=12, scenarios=SCENARIOS[5:])\n", + "h = run_baseline(hard)\n", + "print(\"\\n[복합 이상 5종 탐지율]\", h[h.truth].groupby(\"kind\")[\"pred\"].mean().round(2).to_dict())\n", + "\n", + "# 4) 기본 시나리오 실행 로그 예시와 그림\n", + "ex = next((df, m) for df, m in basic if m[\"kind\"] == \"sharp_turn\")\n", + "d = clean(ex[0], ex[1][\"weather\"]); f = rule_flags(d, ex[1][\"plan\"])\n", + "print(f\"\\n실행 로그 예시 {ex[1]['id']}: 급선회 기록 {int(f['급선회'].sum())}건 (t={list(d['t'][f['급선회']][:3])}) -> '이상 비행 의심, 분석관 확인 필요'\")\n", + "fig, ax = plt.subplots(1, 2, figsize=(12, 5))\n", + "for a, (df, m) in zip(ax, [ex, next((df, m) for df, m in basic if m[\"kind\"] == \"deviation\")]):\n", + " d = clean(df, m[\"weather\"]); fl = np.any(list(rule_flags(d, m[\"plan\"]).values()), axis=0)\n", + " P = np.vstack([m[\"plan\"], m[\"plan\"][:1]])\n", + " a.plot(P[:, 0], P[:, 1], \"k--\", lw=1, label=\"planned route\")\n", + " a.plot(d[\"x\"], d[\"y\"], \"-\", color=\"tab:blue\", lw=1, label=\"flight log\")\n", + " a.plot(d[\"x\"][fl], d[\"y\"][fl], \"o\", color=\"tab:red\", ms=4, label=\"rule hit\")\n", + " a.set_title(f\"{m['id']} ({m['kind']})\"); a.axis(\"equal\"); a.legend(fontsize=8); a.set_xlabel(\"East (m)\"); a.set_ylabel(\"North (m)\")\n", + "plt.suptitle(\"Rule-based baseline detections (synthetic logs)\"); plt.tight_layout(); plt.show()\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 03 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + "[통상 비행 기준] 선회율 45도/초 이하, 1.5m/s 미만 정지 20초 미만, 5초간 고도 하강 30m 이하, 계획 경로 250m 이내, 비인가 구역 밖\n", + "\n", + "기본 데이터셋 100건: 정확도 91.0% (목표 75% 이상: 달성), TP 49 / FP 8 / FN 1 / TN 42\n", + "비행 1건 판단 지연시간 평균 10.0 ms\n", + "\n", + "[유형별 탐지율과 주로 걸린 규칙]\n", + " 건수 이상판정률 대표규칙\n", + "kind \n", + "deviation 10 1.00 경로이탈\n", + "dive 10 1.00 급강하\n", + "hover 10 1.00 호버링\n", + "nfz_entry 10 0.90 경로이탈,비인가구역\n", + "normal 40 0.12 -\n", + "sharp_turn 10 1.00 급선회\n", + "survey 10 0.30 -\n", + "\n", + "선회율 임계값 정확도 정상 비행 오탐\n", + " 30도/초 84.0% 15건\n", + " 45도/초 91.0% 8건\n", + " 60도/초 93.0% 6건\n", + " 90도/초 93.0% 6건\n", + "\n", + "[복합 이상 5종 탐지율] {'infiltration': 0.0, 'loiter': 0.17, 'low_level': 0.17, 'rc_silent': 0.17, 'speed_burst': 0.0}\n", + "\n", + "실행 로그 예시 FL0000: 급선회 기록 3건 (t=[158, 166, 168]) -> '이상 비행 의심, 분석관 확인 필요'\n", + "```\n", + "
\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 04. 비행 패턴 클러스터링 및 머신러닝 기반 은밀 침투 드론 이상 궤적 탐지 모델 고도화\n", + "**소요시간**: 약 3.0시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "비행 패턴 클러스터링 및 비지도 머신러닝을 적용하여 은밀 침투 드론의 특이 비행 로그 및 비인가 비행 구역 진입 패턴을 정밀 탐지한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **평시 비행 패턴 군집화와 비지도 이상 탐지를 적용해 규칙으로 잡기 어려운 은밀 침투·저고도·조종신호 무응답·선회 체류 등 복합 이상 비행을 탐지하도록 고도화한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **30초 창 단위로 고도·속도·선회·직진성·조종신호·비인가 구역 접근·경로 이탈 특징을 계산하고, 라벨 없는 평시 로그로 KMeans 통상 패턴 군집과 IsolationForest를 학습 수행하고 검증하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `src/models/isr03_advanced_detector.py 및 예외 탐지 테스트 결과` 형태의 결과물을 완성한다.\n", + "\n", + "### 🔍 평가 및 검증 기준 (PASS 조건)\n", + "#### 미션 판정\n", + "\n", + "**평가 구분:** 필수\n", + "\n", + "**핵심 평가영역:** 기만/교란 공격에 대한 방어 로직의 현실성 및 신뢰성\n", + "\n", + "#### 미션 PASS 조건\n", + "\n", + "- 정량 기준: 복합 위협 탐지 재현율(Recall) 85% 이상, 처리 지연 200ms 이하\n", + "\n", + "\n", + "- 진행 기준: 복합 위협 시나리오 10종에 대한 탐지 테스트 통과\n", + "\n", + "\n", + "- 검증 방법: 합성 위협 데이터셋 주입 시 이상치 탐지 정확도 검증\n", + "\n", + "\n", + "#### 채점자 판정 메모\n", + "\n", + "> 단순 코드 실행 여부뿐만 아니라 출력된 수치와 근거 자료가 군 실무 요구사항에 부합하는지 면밀히 확인한다.\n", + "\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "\"\"\"평시 비행로그로 통상 비행 패턴을 군집화(KMeans)하고 비지도 이상 탐지(IsolationForest)를 결합해, 규칙으로 잡기 어려운 저고도·조종신호 무응답·선회 체류·은밀 침투 등 10종 복합 이상 비행을 탐지하고 재현율·오경보·처리 지연을 측정한다.\"\"\"\n", + "import math\n", + "import time\n", + "import numpy as np\n", + "import pandas as pd\n", + "import matplotlib.pyplot as plt\n", + "from sklearn.cluster import KMeans\n", + "from sklearn.ensemble import IsolationForest\n", + "from sklearn.preprocessing import StandardScaler\n", + "\n", + "# ---------------------------------------------------------------------------\n", + "# [교육용가상자료] 합성 드론 비행로그 생성기 (실제 좌표·기체·부대 정보 없음)\n", + "# 가상 구역(원점 기준 동·북 방향 m 좌표)에서 1초 간격 비행로그와 정답(이상 구간·운용 주체)을 만든다.\n", + "# ---------------------------------------------------------------------------\n", + "NFZ = {\"x\": 2500.0, \"y\": 2500.0, \"r\": 700.0} # 가상 비인가 비행구역\n", + "WATCH = {\"x\": -2000.0, \"y\": 1500.0, \"r\": 600.0} # 가상 관심 구역\n", + "SCENARIOS = [\"sharp_turn\", \"hover\", \"dive\", \"deviation\", \"nfz_entry\",\n", + " \"low_level\", \"rc_silent\", \"infiltration\", \"loiter\", \"speed_burst\"]\n", + "OPERATORS = [\"friendly\", \"civil\", \"hostile\"]\n", + "# 시나리오별 운용 주체 분포: 일부러 크게 겹치게 설계했다(비행 특성만으로 운용 주체를 단정할 수 없다).\n", + "OPERATOR_P = {\"normal\": (.45, .47, .08), \"survey\": (.15, .82, .03), \"sharp_turn\": (.40, .35, .25),\n", + " \"hover\": (.25, .45, .30), \"dive\": (.25, .25, .50), \"deviation\": (.30, .40, .30),\n", + " \"nfz_entry\": (.10, .50, .40), \"low_level\": (.35, .15, .50), \"rc_silent\": (.30, .15, .55),\n", + " \"infiltration\": (.12, .08, .80), \"loiter\": (.30, .20, .50), \"speed_burst\": (.30, .20, .50)}\n", + "PLATFORM_P = {\"friendly\": (.40, .55, .05), \"civil\": (.88, .02, .10), \"hostile\": (.55, .30, .15)} # commercial/military/unknown\n", + "FIRMWARE_P = {\"friendly\": (.30, .65, .05), \"civil\": (.85, .10, .05), \"hostile\": (.20, .50, .30)} # stock/custom/unknown\n", + "\n", + "\n", + "def _wrap(a):\n", + " return (a + 180.0) % 360.0 - 180.0\n", + "\n", + "\n", + "def _bearing(p, q):\n", + " return math.degrees(math.atan2(q[0] - p[0], q[1] - p[1])) % 360.0\n", + "\n", + "\n", + "def _away_from_nfz(rng, min_d):\n", + " while True:\n", + " c = rng.uniform(-3800, 3800, 2)\n", + " if np.hypot(c[0] - NFZ[\"x\"], c[1] - NFZ[\"y\"]) > min_d:\n", + " return c\n", + "\n", + "\n", + "def make_flight(fid, kind, rng, n=300, weather=None):\n", + " \"\"\"비행 1회 = (로그 DataFrame, 메타 dict). kind: normal / survey / SCENARIOS 중 하나\"\"\"\n", + " weather = weather or str(rng.choice([\"clear\", \"night\", \"windy\", \"rain\"], p=[.4, .25, .2, .15]))\n", + " wind = {\"clear\": 1.0, \"night\": 1.0, \"windy\": 2.5, \"rain\": 1.8}[weather]\n", + " v0, alt0 = rng.uniform(11, 17), rng.uniform(70, 150)\n", + " nfz_c = np.array([NFZ[\"x\"], NFZ[\"y\"]])\n", + " if kind == \"infiltration\": # 원거리에서 비인가 구역 방향으로 저고도 직진\n", + " ang = rng.uniform(0, 2 * np.pi)\n", + " start = nfz_c + 4000 * np.array([np.cos(ang), np.sin(ang)])\n", + " plan = np.array([start, nfz_c])\n", + " v0, alt0 = rng.uniform(9, 11), rng.uniform(25, 35)\n", + " else:\n", + " if kind == \"nfz_entry\": # 비인가 구역 1.6km 밖에서 순찰하다가 진입\n", + " ang = rng.uniform(0, 2 * np.pi)\n", + " c = nfz_c + 1600 * np.array([np.cos(ang), np.sin(ang)])\n", + " else:\n", + " c = _away_from_nfz(rng, 2600)\n", + " if kind == \"survey\": # 민간 측량형 왕복(지그재그) 경로\n", + " w, h = rng.uniform(500, 700), 120.0\n", + " plan = np.array([c + [(-w if i % 4 in (0, 3) else w), i // 2 * h - 360] for i in range(8)])\n", + " else: # 사각 순찰 경로(웨이포인트 4개)\n", + " a, b, th = rng.uniform(300, 600), rng.uniform(200, 400), rng.uniform(0, np.pi)\n", + " R = np.array([[np.cos(th), -np.sin(th)], [np.sin(th), np.cos(th)]])\n", + " plan = c + np.array([[-a, -b], [a, -b], [a, b], [-a, b]]) @ R.T\n", + " s0 = int(rng.integers(80, 160))\n", + " span = {\"sharp_turn\": 30, \"hover\": 60, \"dive\": 8, \"deviation\": 70, \"nfz_entry\": 120, \"low_level\": 120,\n", + " \"rc_silent\": 120, \"infiltration\": n - 20, \"loiter\": 90, \"speed_burst\": 25}.get(kind, 0)\n", + " if kind == \"infiltration\":\n", + " s0 = 20\n", + " win = (s0, s0 + span)\n", + " turns = set(rng.choice(np.arange(s0, s0 + 30), 3, replace=False).tolist()) if kind == \"sharp_turn\" else set()\n", + " # 난수를 미리 한 번에 뽑아 두고(속도 향상) 1초 단위로 기체 운동을 적분한다.\n", + " e_h, e_v, e_a = rng.normal(0, 0.8 * wind, n), rng.normal(0, 0.3 * wind, n), rng.normal(0, 0.4 * wind, n)\n", + " rc_arr = np.clip(rng.normal(rng.uniform(60, 92), 6, n), 0, 100)\n", + " gust = (rng.random(n) < (0.004 if weather == \"windy\" else 0.0)) * rng.uniform(20, 35, n)\n", + " burst, turn_amt = rng.uniform(23, 28), rng.choice([-1, 1], n) * rng.uniform(100, 160, n)\n", + " side = float(rng.choice([-90, 90]))\n", + " low_alt, rng_rc = rng.uniform(35, 55), rng.uniform(0, 20, n) # 저고도 목표, 조종신호 두절 시 잔여 수신값\n", + " x, y, wi = float(plan[0][0]), float(plan[0][1]), 1\n", + " hdg, alt = _bearing((x, y), plan[1]), alt0\n", + " max_rate = 40.0 if kind == \"survey\" else 12.0\n", + " dev_target, X, Y, A, V, HD = None, np.zeros(n), np.zeros(n), np.zeros(n), np.zeros(n), np.zeros(n)\n", + " for t in range(n):\n", + " inw = win[0] <= t < win[1]\n", + " tx, ty = plan[1] if kind == \"infiltration\" else plan[wi % len(plan)]\n", + " spd_cmd, alt_cmd = v0, alt0\n", + " if inw:\n", + " if kind == \"deviation\":\n", + " if dev_target is None:\n", + " r = math.radians(hdg + side)\n", + " dev_target = (x + 900 * math.sin(r), y + 900 * math.cos(r))\n", + " tx, ty = dev_target\n", + " elif kind == \"nfz_entry\":\n", + " tx, ty = NFZ[\"x\"], NFZ[\"y\"]\n", + " elif kind == \"hover\":\n", + " spd_cmd = 0.0\n", + " elif kind == \"speed_burst\":\n", + " spd_cmd = burst\n", + " elif kind == \"low_level\":\n", + " alt_cmd = low_alt\n", + " if kind in (\"rc_silent\", \"infiltration\"):\n", + " rc_arr[t] = rng_rc[t]\n", + " if inw and kind == \"loiter\":\n", + " hdg = (hdg + 9.0 + e_h[t] / wind) % 360\n", + " else:\n", + " d = _wrap(math.degrees(math.atan2(tx - x, ty - y)) - hdg)\n", + " hdg = (hdg + max(-max_rate, min(max_rate, d)) + e_h[t]) % 360\n", + " if t in turns:\n", + " hdg = (hdg + turn_amt[t]) % 360\n", + " v = abs(spd_cmd + e_v[t]) if spd_cmd > 0 else abs(e_v[t] / wind)\n", + " x += v * math.sin(math.radians(hdg)); y += v * math.cos(math.radians(hdg))\n", + " if kind != \"infiltration\" and math.hypot(x - tx, y - ty) < 40 and not (inw and kind in (\"deviation\", \"nfz_entry\")):\n", + " wi += 1\n", + " if inw and kind == \"dive\":\n", + " alt -= 9.0 + e_v[t]\n", + " else:\n", + " alt += max(-3.0, min(3.0, alt_cmd - alt)) + e_a[t]\n", + " alt -= gust[t] # 돌풍 하강기류(정상 비행에서도 발생)\n", + " if kind == \"dive\" and t >= win[1]:\n", + " alt0 = min(alt0, alt)\n", + " alt = max(alt, 3.0)\n", + " X[t], Y[t], A[t], V[t], HD[t] = x, y, alt, v, hdg\n", + " gps_sd = {\"clear\": 2.0, \"night\": 2.0, \"windy\": 3.0, \"rain\": 4.5}[weather] # 악천후일수록 GPS 잡음(다중경로) 증가\n", + " df = pd.DataFrame({\"t\": np.arange(n), \"x\": X + rng.normal(0, gps_sd, n), \"y\": Y + rng.normal(0, gps_sd, n),\n", + " \"alt\": A + rng.normal(0, 0.5, n), \"speed\": V + rng.normal(0, 0.2, n),\n", + " \"heading\": (HD + rng.normal(0, 1.5, n)) % 360, \"rc\": rc_arr.round(1),\n", + " \"sats\": rng.integers(10, 15, n), \"hdop\": rng.uniform(0.6, 1.2, n).round(2),\n", + " \"x_true\": X, \"y_true\": Y})\n", + " df[\"anomaly\"] = (df[\"t\"] >= win[0]) & (df[\"t\"] < win[1]) & (kind in SCENARIOS)\n", + " op = OPERATORS[int(rng.choice(3, p=OPERATOR_P[kind]))]\n", + " meta = {\"id\": fid, \"kind\": kind, \"weather\": weather, \"operator\": op, \"plan\": plan, \"window\": win if kind in SCENARIOS else None,\n", + " \"platform\": [\"commercial\", \"military\", \"unknown\"][int(rng.choice(3, p=PLATFORM_P[op]))],\n", + " \"firmware\": [\"stock\", \"custom\", \"unknown\"][int(rng.choice(3, p=FIRMWARE_P[op]))]}\n", + " return df, meta\n", + "\n", + "\n", + "def corrupt(df, rng, spoof=True, emi=True, drop=True, dup=True):\n", + " \"\"\"전장 잡음 주입: GPS 스푸핑(위치 점프·표류), 전자기 간섭(고도 스파이크), 센서 단절(결측), 중복 기록.\"\"\"\n", + " d = df.copy()\n", + " d[\"spoof_true\"] = False\n", + " if spoof:\n", + " a = int(rng.integers(40, len(d) - 60)); L = int(rng.integers(25, 45))\n", + " th = rng.uniform(0, 2 * np.pi)\n", + " drift = rng.uniform(22, 28) * np.arange(L)[:, None] * np.array([np.cos(th), np.sin(th)])\n", + " jump = rng.uniform(250, 400) * np.array([np.cos(th + 1), np.sin(th + 1)])\n", + " d.loc[a:a + L - 1, [\"x\", \"y\"]] += drift + jump\n", + " d.loc[a:a + L - 1, \"spoof_true\"] = True\n", + " if emi:\n", + " idx = rng.choice(len(d), 6, replace=False)\n", + " d.loc[idx, \"alt\"] += rng.choice([-1, 1], 6) * rng.uniform(60, 150, 6)\n", + " if drop:\n", + " a = int(rng.integers(10, len(d) - 20))\n", + " d.loc[a:a + int(rng.integers(4, 9)), [\"x\", \"y\", \"alt\", \"speed\", \"heading\"]] = np.nan\n", + " d = d.drop(index=rng.choice(len(d), 5, replace=False))\n", + " if dup:\n", + " d = pd.concat([d, d.sample(5, random_state=int(rng.integers(1e6)))]).sort_values(\"t\", kind=\"stable\")\n", + " return d.reset_index(drop=True)\n", + "\n", + "\n", + "def make_dataset(n=200, seed=7, anomaly_ratio=0.5, scenarios=SCENARIOS):\n", + " rng = np.random.default_rng(seed)\n", + " out = []\n", + " for i in range(n):\n", + " if i % 10 < anomaly_ratio * 10:\n", + " kind = scenarios[(i // 10 * int(anomaly_ratio * 10) + i % 10) % len(scenarios)]\n", + " else:\n", + " kind = \"survey\" if i % 10 == 9 else \"normal\"\n", + " df, meta = make_flight(f\"FL{i:04d}\", kind, rng)\n", + " out.append((df, meta))\n", + " return out\n", + "# ---------------------------------------------------------------------------\n", + "\n", + "# ---- 전처리·규칙 탐지 공통 함수 ----\n", + "SPOOF_THR = {\"clear\": 12.0, \"night\": 12.0, \"windy\": 15.0, \"rain\": 19.0} # 기상별 GPS-추측항법 불일치 허용치(m)\n", + "\n", + "\n", + "def clean(df, weather=\"clear\"):\n", + " \"\"\"전처리: 중복 제거·정렬 -> 1초 재표본화 -> 결측 보간 -> 고도 스파이크 제거 -> GPS 스푸핑 구간 추측항법 보정.\"\"\"\n", + " d = df.drop_duplicates(\"t\").sort_values(\"t\").set_index(\"t\")\n", + " d = d.reindex(np.arange(int(d.index.min()), int(d.index.max()) + 1))\n", + " n_missing = int(d[\"x\"].isna().sum())\n", + " num = [\"x\", \"y\", \"alt\", \"speed\", \"rc\", \"sats\", \"hdop\"]\n", + " d[num] = d[num].interpolate(limit_direction=\"both\")\n", + " hd = d[\"heading\"].dropna() # 방위는 0/360 경계를 풀어서 보간\n", + " un = pd.Series(np.degrees(np.unwrap(np.radians(hd.to_numpy(float)))), index=hd.index)\n", + " d[\"heading\"] = un.reindex(d.index).interpolate(limit_direction=\"both\").to_numpy() % 360\n", + " med = d[\"alt\"].rolling(5, center=True, min_periods=1).median()\n", + " spike = (d[\"alt\"] - med).abs() > 25\n", + " d.loc[spike, \"alt\"] = med[spike]\n", + " x, y = d[\"x\"].to_numpy(float), d[\"y\"].to_numpy(float)\n", + " r = np.radians(d[\"heading\"].to_numpy(float)); v = d[\"speed\"].to_numpy(float)\n", + " dr = np.c_[v * np.sin(r), v * np.cos(r)] # 보고 속도·방위로 계산한 1초 이동량(추측항법)\n", + " xc, yc, flag = x.copy(), y.copy(), np.zeros(len(x), bool)\n", + " thr = SPOOF_THR.get(weather, 12.0)\n", + " ok_run = 0\n", + " for i in range(1, len(x)):\n", + " px, py = xc[i - 1] + dr[i, 0], yc[i - 1] + dr[i, 1] # 추측항법 예상 위치\n", + " step_ok = np.hypot(x[i] - x[i - 1] - dr[i, 0], y[i] - y[i - 1] - dr[i, 1]) <= thr\n", + " ok_run = ok_run + 1 if step_ok else 0\n", + " if flag[i - 1]:\n", + " # 스푸핑 구간: GPS가 예상 위치로 돌아오거나, GPS 이동량이 5초 연속 물리적으로 맞으면 GPS로 복귀\n", + " if np.hypot(x[i] - px, y[i] - py) < thr * 2 or ok_run >= 5:\n", + " xc[i], yc[i] = x[i], y[i]\n", + " else:\n", + " xc[i], yc[i], flag[i] = px, py, True\n", + " elif not step_ok:\n", + " xc[i], yc[i], flag[i] = px, py, True # GPS 이동량이 물리적으로 맞지 않음 -> 스푸핑 의심\n", + " else:\n", + " xc[i], yc[i] = xc[i - 1] + x[i] - x[i - 1], yc[i - 1] + y[i] - y[i - 1]\n", + " d[\"x\"], d[\"y\"], d[\"spoof_flag\"] = xc, yc, flag\n", + " d = d.reset_index()\n", + " d.attrs.update({\"missing\": n_missing, \"spikes\": int(spike.sum()), \"spoof_pts\": int(flag.sum())})\n", + " return d\n", + "\n", + "\n", + "def xtrack(px, py, plan):\n", + " \"\"\"계획 경로(웨이포인트 폐곡선) 대비 이탈 거리(m).\"\"\"\n", + " P = np.asarray(plan, float)\n", + " segs = [(P[i], P[(i + 1) % len(P)]) for i in range(len(P) if len(P) > 2 else 1)]\n", + " best = np.full(len(px), np.inf)\n", + " for a, b in segs:\n", + " ab = b - a\n", + " u = np.clip(((px - a[0]) * ab[0] + (py - a[1]) * ab[1]) / max(ab @ ab, 1e-9), 0, 1)\n", + " best = np.minimum(best, np.hypot(px - a[0] - u * ab[0], py - a[1] - u * ab[1]))\n", + " return best\n", + "\n", + "\n", + "RULES = {\"turn_rate\": 45.0, \"hover_speed\": 1.5, \"hover_sec\": 20, \"dive_m\": 30.0, \"xtrack_m\": 250.0}\n", + "\n", + "\n", + "def rule_flags(d, plan, R=RULES):\n", + " \"\"\"규칙 기반 이상 비행 판정(기록 단위). 급선회·호버링·급강하·경로 이탈·비인가 구역 진입.\"\"\"\n", + " hr = np.abs(_wrap(np.diff(d[\"heading\"].to_numpy(float), prepend=d[\"heading\"].iloc[0])))\n", + " slow = (d[\"speed\"] < R[\"hover_speed\"]).to_numpy()\n", + " run = np.zeros(len(slow), int)\n", + " for i in range(len(slow)):\n", + " run[i] = run[i - 1] + 1 if slow[i] and i else int(slow[i])\n", + " hover = np.zeros(len(slow), bool)\n", + " for i in np.where(run >= R[\"hover_sec\"])[0]:\n", + " hover[i - R[\"hover_sec\"] + 1:i + 1] = True\n", + " alt = d[\"alt\"].to_numpy(float)\n", + " drop = alt - np.r_[alt[5:], np.full(5, alt[-1])]\n", + " return {\"급선회\": hr > R[\"turn_rate\"], \"호버링\": hover, \"급강하\": drop > R[\"dive_m\"],\n", + " \"경로이탈\": xtrack(d[\"x\"].to_numpy(float), d[\"y\"].to_numpy(float), plan) > R[\"xtrack_m\"],\n", + " \"비인가구역\": np.hypot(d[\"x\"] - NFZ[\"x\"], d[\"y\"] - NFZ[\"y\"]).to_numpy() < NFZ[\"r\"]}\n", + "\n", + "# ---- 구간(30초 창) 특징 + 비지도 이상 탐지 공통 함수 ----\n", + "WFEAT = [\"alt_mean\", \"alt_min\", \"alt_std\", \"vz_min\", \"spd_mean\", \"spd_std\", \"spd_max\", \"turn_sum\", \"turn_max\",\n", + " \"straight\", \"rc_mean\", \"rc_zero\", \"nfz_min\", \"nfz_close\", \"xtrack_max\"]\n", + "\n", + "\n", + "def window_feats(d, plan, win=30, step=10):\n", + " \"\"\"30초 창을 10초씩 옮기며 고도·속도·선회·직진성·조종신호·비인가 구역 접근·경로 이탈 특징을 계산한다.\"\"\"\n", + " x, y, alt = d[\"x\"].to_numpy(float), d[\"y\"].to_numpy(float), d[\"alt\"].to_numpy(float)\n", + " spd, rc = d[\"speed\"].to_numpy(float), d[\"rc\"].to_numpy(float)\n", + " hr = np.abs(_wrap(np.diff(d[\"heading\"].to_numpy(float), prepend=d[\"heading\"].iloc[0])))\n", + " vz = np.diff(alt, prepend=alt[0])\n", + " nd = np.hypot(x - NFZ[\"x\"], y - NFZ[\"y\"])\n", + " xt = xtrack(x, y, plan)\n", + " step_len = np.hypot(np.diff(x, prepend=x[0]), np.diff(y, prepend=y[0]))\n", + " rows, starts = [], []\n", + " for s in range(0, len(d) - win + 1, step):\n", + " e = s + win\n", + " path = step_len[s + 1:e].sum()\n", + " rows.append([alt[s:e].mean(), alt[s:e].min(), alt[s:e].std(), vz[s:e].min(), spd[s:e].mean(), spd[s:e].std(),\n", + " spd[s:e].max(), hr[s:e].sum(), hr[s:e].max(), np.hypot(x[e - 1] - x[s], y[e - 1] - y[s]) / max(path, 1.0),\n", + " rc[s:e].mean(), (rc[s:e] < 5).mean(), nd[s:e].min(), (nd[s] - nd[e - 1]) / win, xt[s:e].max()])\n", + " starts.append(int(d[\"t\"].iloc[s]))\n", + " return np.array(rows), starts\n", + "\n", + "\n", + "class FlightAnomalyModel:\n", + " \"\"\"평시 비행로그(라벨 없음)로 통상 패턴 군집(KMeans)과 고립 숲(IsolationForest)을 학습하고,\n", + " 두 점수를 학습 구간 분위수로 나눠 1.0을 넘으면 '통상 패턴에서 벗어남'으로 본다.\"\"\"\n", + "\n", + " def __init__(self, q=0.999, k=4):\n", + " self.q, self.k = q, k\n", + "\n", + " def fit(self, X):\n", + " self.sc = StandardScaler().fit(X)\n", + " Z = self.sc.transform(X)\n", + " self.iso = IsolationForest(n_estimators=150, random_state=0).fit(Z)\n", + " self.km = KMeans(self.k, n_init=5, random_state=0).fit(Z)\n", + " self.set_q(self.q, X)\n", + " return self\n", + "\n", + " def _raw(self, X):\n", + " Z = self.sc.transform(X)\n", + " return -self.iso.score_samples(Z), self.km.transform(Z).min(axis=1)\n", + "\n", + " def set_q(self, q, X):\n", + " a, b = self._raw(X)\n", + " self.q, self.t_if, self.t_km = q, np.quantile(a, q), np.quantile(b, q)\n", + "\n", + " def score(self, X):\n", + " a, b = self._raw(X)\n", + " return np.maximum(a / self.t_if, b / self.t_km)\n", + "\n", + "\n", + "AFEAT = [\"ml_score\", \"low_alt\", \"rc_weak\", \"nfz_min_km\", \"hover_s\", \"max_speed\", \"night\",\n", + " \"pf_commercial\", \"pf_military\", \"pf_unknown\", \"fw_stock\", \"fw_custom\", \"fw_unknown\"]\n", + "\n", + "\n", + "def attr_features(d, m, ml_score):\n", + " \"\"\"운용 주체 추정용 비행 단위 특징. 기체·펌웨어 정보는 상용 기체가 모든 주체에 쓰이므로 약한 단서일 뿐이다.\"\"\"\n", + " return [min(ml_score, 10.0), float((d[\"alt\"] < 60).mean()), float((d[\"rc\"] < 25).mean()),\n", + " float(np.hypot(d[\"x\"] - NFZ[\"x\"], d[\"y\"] - NFZ[\"y\"]).min() / 1000), float((d[\"speed\"] < 1.5).sum()),\n", + " float(d[\"speed\"].max()), float(m[\"weather\"] == \"night\"),\n", + " *[float(m[\"platform\"] == p) for p in (\"commercial\", \"military\", \"unknown\")],\n", + " *[float(m[\"firmware\"] == f) for f in (\"stock\", \"custom\", \"unknown\")]]\n", + "\n", + "\n", + "def prepare(data):\n", + " out = []\n", + " for df, m in data:\n", + " d = clean(df, m[\"weather\"])\n", + " X, st = window_feats(d, m[\"plan\"])\n", + " out.append({\"m\": m, \"d\": d, \"X\": X, \"starts\": st, \"rule\": any(v.any() for v in rule_flags(d, m[\"plan\"]).values())})\n", + " return out\n", + "\n", + "\n", + "# 1) 학습: 라벨 없는 평시 비행로그(순찰·측량) 120건의 30초 창\n", + "train = prepare(make_dataset(120, seed=21, anomaly_ratio=0.0))\n", + "Xtr = np.vstack([f[\"X\"] for f in train])\n", + "model = FlightAnomalyModel(q=0.999).fit(Xtr)\n", + "print(f\"학습 창 {len(Xtr)}개, 통상 패턴 군집 {model.k}개 크기:\", np.bincount(model.km.labels_).tolist())\n", + "\n", + "# 2) 시험: 정상 100건 + 복합 이상 10종 x 10건\n", + "test = prepare(make_dataset(200, seed=11))\n", + "t0 = time.perf_counter()\n", + "for f in test:\n", + " f[\"ml_score\"] = float(model.score(f[\"X\"]).max())\n", + "lat = (time.perf_counter() - t0) / sum(len(f[\"X\"]) for f in test) * 1000\n", + "for f in test:\n", + " f[\"ml\"] = f[\"ml_score\"] > 1.0\n", + " f[\"hybrid\"] = f[\"ml\"] or f[\"rule\"]\n", + "r = pd.DataFrame([{\"kind\": f[\"m\"][\"kind\"], \"truth\": f[\"m\"][\"kind\"] in SCENARIOS, \"규칙\": f[\"rule\"], \"ML\": f[\"ml\"], \"규칙+ML\": f[\"hybrid\"]} for f in test])\n", + "an, nm = r[r.truth], r[~r.truth]\n", + "print(\"\\n방식 이상 재현율 정상 비행 오경보율\")\n", + "for c in [\"규칙\", \"ML\", \"규칙+ML\"]:\n", + " print(f\"{c:7s} {an[c].mean():10.1%} {nm[c].mean():14.1%}\")\n", + "rec = an[\"규칙+ML\"].mean()\n", + "print(f\"\\n복합 위협 탐지 재현율 {rec:.1%} (목표 85% 이상: {'달성' if rec >= 0.85 else '미달'}), 30초 창당 처리 {lat:.2f} ms (목표 200 ms 이하)\")\n", + "\n", + "# 3) 복합 위협 시나리오 10종 탐지 시험(시나리오별 재현율 80% 이상이면 통과)\n", + "per = an.groupby(\"kind\")[[\"규칙\", \"ML\", \"규칙+ML\"]].mean()\n", + "per[\"판정\"] = np.where(per[\"규칙+ML\"] >= 0.8, \"통과\", \"미통과\")\n", + "print(\"\\n[시나리오별 재현율]\")\n", + "print(per.round(2).to_string())\n", + "print(f\"통과 {int((per['판정'] == '통과').sum())}/10종\")\n", + "\n", + "# 4) 오경보 유발 패턴: 정상인데 ML이 이상으로 본 비행의 가장 튀는 특징\n", + "fa = [f for f in test if f[\"m\"][\"kind\"] not in SCENARIOS and f[\"ml\"]]\n", + "print(f\"\\n[정상 비행 오경보 {len(fa)}건의 원인 특징]\")\n", + "for f in fa[:5]:\n", + " w = int(np.argmax(model.score(f[\"X\"])))\n", + " z = model.sc.transform(f[\"X\"][w:w + 1])[0]\n", + " top = np.argsort(-np.abs(z))[:2]\n", + " print(f\" {f['m']['id']} ({f['m']['kind']}, {f['m']['weather']}): \" + \", \".join(f\"{WFEAT[i]} z={z[i]:+.1f}\" for i in top))\n", + "print(\"-> 강풍 돌풍 하강·측량 U턴처럼 정상이지만 드문 패턴이 원인이면 학습 자료에 추가하거나 특징을 재설계한다.\")\n", + "\n", + "# 5) 그림: 이상 점수 분포와 은밀 침투 예시\n", + "fig, ax = plt.subplots(1, 2, figsize=(12, 4.5))\n", + "ax[0].hist([f[\"ml_score\"] for f in test if f[\"m\"][\"kind\"] not in SCENARIOS], bins=30, range=(0, 6), alpha=.7, label=\"normal\")\n", + "ax[0].hist([f[\"ml_score\"] for f in test if f[\"m\"][\"kind\"] in SCENARIOS], bins=30, range=(0, 6), alpha=.7, label=\"anomalous\")\n", + "ax[0].axvline(1.0, color=\"k\", ls=\"--\"); ax[0].set_xlabel(\"max window anomaly score\"); ax[0].set_ylabel(\"flights\"); ax[0].legend()\n", + "ax[0].set_title(\"Anomaly score by flight type\")\n", + "f = next(f for f in test if f[\"m\"][\"kind\"] == \"infiltration\")\n", + "s = model.score(f[\"X\"])\n", + "ax[1].plot(f[\"starts\"], s, \"-o\", ms=3, label=\"window score\"); ax[1].axhline(1.0, color=\"k\", ls=\"--\")\n", + "ax[1].plot(f[\"d\"][\"t\"], f[\"d\"][\"alt\"] / 30, color=\"tab:gray\", label=\"altitude / 30 m\")\n", + "ax[1].set_xlabel(\"time (s)\"); ax[1].set_title(f\"{f['m']['id']} infiltration: low altitude + RC silent\"); ax[1].legend()\n", + "plt.tight_layout(); plt.show()\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 04 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + "학습 창 3360개, 통상 패턴 군집 4개 크기: [1104, 76, 1316, 864]\n", + "\n", + "방식 이상 재현율 정상 비행 오경보율\n", + "규칙 57.0% 13.0%\n", + "ML 100.0% 8.0%\n", + "규칙+ML 100.0% 16.0%\n", + "\n", + "복합 위협 탐지 재현율 100.0% (목표 85% 이상: 달성), 30초 창당 처리 0.47 ms (목표 200 ms 이하)\n", + "\n", + "[시나리오별 재현율]\n", + " 규칙 ML 규칙+ML 판정\n", + "kind \n", + "deviation 1.0 1.0 1.0 통과\n", + "dive 1.0 1.0 1.0 통과\n", + "hover 1.0 1.0 1.0 통과\n", + "infiltration 0.0 1.0 1.0 통과\n", + "loiter 0.3 1.0 1.0 통과\n", + "low_level 0.2 1.0 1.0 통과\n", + "nfz_entry 1.0 1.0 1.0 통과\n", + "rc_silent 0.1 1.0 1.0 통과\n", + "sharp_turn 1.0 1.0 1.0 통과\n", + "speed_burst 0.1 1.0 1.0 통과\n", + "통과 10/10종\n", + "\n", + "[정상 비행 오경보 8건의 원인 특징]\n", + " FL0019 (survey, windy): alt_std z=+6.7, vz_min z=-6.7\n", + " FL0039 (survey, windy): alt_std z=+8.9, vz_min z=-8.7\n", + " FL0059 (survey, clear): straight z=-5.5, turn_max z=+4.1\n", + " FL0088 (normal, windy): alt_std z=+7.6, alt_min z=-2.6\n", + " FL0089 (survey, rain): straight z=-5.6, turn_max z=+4.1\n", + "-> 강풍 돌풍 하강·측량 U턴처럼 정상이지만 드문 패턴이 원인이면 학습 자료에 추가하거나 특징을 재설계한다.\n", + "```\n", + "
\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 05. 운용 주체(적/아군/민간) 성급한 단정 방지를 위한 불확실성 지표 및 신뢰도 엔진 개발\n", + "**소요시간**: 약 3.0시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "비행 특성만으로 특정 국가나 부대를 성급하게 단정하지 않도록 분석 불확실성 지표와 다차원 신뢰도 점수를 산출하는 엔진을 개발한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **비행 특성만으로 운용 주체(아군·민간·적대)를 단정하지 않도록 주체별 확률, 불확실성, 데이터 품질을 함께 반영한 신뢰도 엔진을 개발한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **과거 검토가 끝난 가상 사례로 주체별 확률 모델을 학습하고, 정규화 엔트로피로 불확실성을, 스푸핑·결측 정도로 로그 품질 산출하고 모델에 적용하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `src/verification/isr03_confidence_engine.py` 형태의 결과물을 완성한다.\n", + "\n", + "### 🔍 평가 및 검증 기준 (PASS 조건)\n", + "#### 미션 판정\n", + "\n", + "**평가 구분:** 필수\n", + "\n", + "**핵심 평가영역:** 신뢰도 등급(High/Med/Low) 부여 기준의 군사적 타당성\n", + "\n", + "#### 미션 PASS 조건\n", + "\n", + "- 정량 기준: 오경보율(FAR) 40% 이상 감축, 신뢰도 스코어 산출 일관성 90% 이상\n", + "\n", + "\n", + "- 진행 기준: 오경보 필터링 전후 비교 지표 산출 및 통합 테스트 완료\n", + "\n", + "\n", + "- 검증 방법: ROC-AUC 곡선 분석 및 임계값 민감도 테스트\n", + "\n", + "\n", + "#### 채점자 판정 메모\n", + "\n", + "> 단순 코드 실행 여부뿐만 아니라 출력된 수치와 근거 자료가 군 실무 요구사항에 부합하는지 면밀히 확인한다.\n", + "\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "\"\"\"비행 특성만으로 운용 주체(아군·민간·적대)를 성급하게 단정하지 않도록, 주체별 확률과 불확실성(엔트로피)·데이터 품질을 함께 계산해 '적대 가능성 등급(High/Medium/Low)'을 매기는 신뢰도 엔진을 만들고, 단정형 규칙 대비 오경보 감축과 점수 일관성을 검증한다.\"\"\"\n", + "import math\n", + "import numpy as np\n", + "import pandas as pd\n", + "import matplotlib.pyplot as plt\n", + "from sklearn.cluster import KMeans\n", + "from sklearn.ensemble import IsolationForest\n", + "from sklearn.linear_model import LogisticRegression\n", + "from sklearn.metrics import roc_auc_score\n", + "from sklearn.pipeline import make_pipeline\n", + "from sklearn.preprocessing import StandardScaler\n", + "\n", + "# ---------------------------------------------------------------------------\n", + "# [교육용가상자료] 합성 드론 비행로그 생성기 (실제 좌표·기체·부대 정보 없음)\n", + "# 가상 구역(원점 기준 동·북 방향 m 좌표)에서 1초 간격 비행로그와 정답(이상 구간·운용 주체)을 만든다.\n", + "# ---------------------------------------------------------------------------\n", + "NFZ = {\"x\": 2500.0, \"y\": 2500.0, \"r\": 700.0} # 가상 비인가 비행구역\n", + "WATCH = {\"x\": -2000.0, \"y\": 1500.0, \"r\": 600.0} # 가상 관심 구역\n", + "SCENARIOS = [\"sharp_turn\", \"hover\", \"dive\", \"deviation\", \"nfz_entry\",\n", + " \"low_level\", \"rc_silent\", \"infiltration\", \"loiter\", \"speed_burst\"]\n", + "OPERATORS = [\"friendly\", \"civil\", \"hostile\"]\n", + "# 시나리오별 운용 주체 분포: 일부러 크게 겹치게 설계했다(비행 특성만으로 운용 주체를 단정할 수 없다).\n", + "OPERATOR_P = {\"normal\": (.45, .47, .08), \"survey\": (.15, .82, .03), \"sharp_turn\": (.40, .35, .25),\n", + " \"hover\": (.25, .45, .30), \"dive\": (.25, .25, .50), \"deviation\": (.30, .40, .30),\n", + " \"nfz_entry\": (.10, .50, .40), \"low_level\": (.35, .15, .50), \"rc_silent\": (.30, .15, .55),\n", + " \"infiltration\": (.12, .08, .80), \"loiter\": (.30, .20, .50), \"speed_burst\": (.30, .20, .50)}\n", + "PLATFORM_P = {\"friendly\": (.40, .55, .05), \"civil\": (.88, .02, .10), \"hostile\": (.55, .30, .15)} # commercial/military/unknown\n", + "FIRMWARE_P = {\"friendly\": (.30, .65, .05), \"civil\": (.85, .10, .05), \"hostile\": (.20, .50, .30)} # stock/custom/unknown\n", + "\n", + "\n", + "def _wrap(a):\n", + " return (a + 180.0) % 360.0 - 180.0\n", + "\n", + "\n", + "def _bearing(p, q):\n", + " return math.degrees(math.atan2(q[0] - p[0], q[1] - p[1])) % 360.0\n", + "\n", + "\n", + "def _away_from_nfz(rng, min_d):\n", + " while True:\n", + " c = rng.uniform(-3800, 3800, 2)\n", + " if np.hypot(c[0] - NFZ[\"x\"], c[1] - NFZ[\"y\"]) > min_d:\n", + " return c\n", + "\n", + "\n", + "def make_flight(fid, kind, rng, n=300, weather=None):\n", + " \"\"\"비행 1회 = (로그 DataFrame, 메타 dict). kind: normal / survey / SCENARIOS 중 하나\"\"\"\n", + " weather = weather or str(rng.choice([\"clear\", \"night\", \"windy\", \"rain\"], p=[.4, .25, .2, .15]))\n", + " wind = {\"clear\": 1.0, \"night\": 1.0, \"windy\": 2.5, \"rain\": 1.8}[weather]\n", + " v0, alt0 = rng.uniform(11, 17), rng.uniform(70, 150)\n", + " nfz_c = np.array([NFZ[\"x\"], NFZ[\"y\"]])\n", + " if kind == \"infiltration\": # 원거리에서 비인가 구역 방향으로 저고도 직진\n", + " ang = rng.uniform(0, 2 * np.pi)\n", + " start = nfz_c + 4000 * np.array([np.cos(ang), np.sin(ang)])\n", + " plan = np.array([start, nfz_c])\n", + " v0, alt0 = rng.uniform(9, 11), rng.uniform(25, 35)\n", + " else:\n", + " if kind == \"nfz_entry\": # 비인가 구역 1.6km 밖에서 순찰하다가 진입\n", + " ang = rng.uniform(0, 2 * np.pi)\n", + " c = nfz_c + 1600 * np.array([np.cos(ang), np.sin(ang)])\n", + " else:\n", + " c = _away_from_nfz(rng, 2600)\n", + " if kind == \"survey\": # 민간 측량형 왕복(지그재그) 경로\n", + " w, h = rng.uniform(500, 700), 120.0\n", + " plan = np.array([c + [(-w if i % 4 in (0, 3) else w), i // 2 * h - 360] for i in range(8)])\n", + " else: # 사각 순찰 경로(웨이포인트 4개)\n", + " a, b, th = rng.uniform(300, 600), rng.uniform(200, 400), rng.uniform(0, np.pi)\n", + " R = np.array([[np.cos(th), -np.sin(th)], [np.sin(th), np.cos(th)]])\n", + " plan = c + np.array([[-a, -b], [a, -b], [a, b], [-a, b]]) @ R.T\n", + " s0 = int(rng.integers(80, 160))\n", + " span = {\"sharp_turn\": 30, \"hover\": 60, \"dive\": 8, \"deviation\": 70, \"nfz_entry\": 120, \"low_level\": 120,\n", + " \"rc_silent\": 120, \"infiltration\": n - 20, \"loiter\": 90, \"speed_burst\": 25}.get(kind, 0)\n", + " if kind == \"infiltration\":\n", + " s0 = 20\n", + " win = (s0, s0 + span)\n", + " turns = set(rng.choice(np.arange(s0, s0 + 30), 3, replace=False).tolist()) if kind == \"sharp_turn\" else set()\n", + " # 난수를 미리 한 번에 뽑아 두고(속도 향상) 1초 단위로 기체 운동을 적분한다.\n", + " e_h, e_v, e_a = rng.normal(0, 0.8 * wind, n), rng.normal(0, 0.3 * wind, n), rng.normal(0, 0.4 * wind, n)\n", + " rc_arr = np.clip(rng.normal(rng.uniform(60, 92), 6, n), 0, 100)\n", + " gust = (rng.random(n) < (0.004 if weather == \"windy\" else 0.0)) * rng.uniform(20, 35, n)\n", + " burst, turn_amt = rng.uniform(23, 28), rng.choice([-1, 1], n) * rng.uniform(100, 160, n)\n", + " side = float(rng.choice([-90, 90]))\n", + " low_alt, rng_rc = rng.uniform(35, 55), rng.uniform(0, 20, n) # 저고도 목표, 조종신호 두절 시 잔여 수신값\n", + " x, y, wi = float(plan[0][0]), float(plan[0][1]), 1\n", + " hdg, alt = _bearing((x, y), plan[1]), alt0\n", + " max_rate = 40.0 if kind == \"survey\" else 12.0\n", + " dev_target, X, Y, A, V, HD = None, np.zeros(n), np.zeros(n), np.zeros(n), np.zeros(n), np.zeros(n)\n", + " for t in range(n):\n", + " inw = win[0] <= t < win[1]\n", + " tx, ty = plan[1] if kind == \"infiltration\" else plan[wi % len(plan)]\n", + " spd_cmd, alt_cmd = v0, alt0\n", + " if inw:\n", + " if kind == \"deviation\":\n", + " if dev_target is None:\n", + " r = math.radians(hdg + side)\n", + " dev_target = (x + 900 * math.sin(r), y + 900 * math.cos(r))\n", + " tx, ty = dev_target\n", + " elif kind == \"nfz_entry\":\n", + " tx, ty = NFZ[\"x\"], NFZ[\"y\"]\n", + " elif kind == \"hover\":\n", + " spd_cmd = 0.0\n", + " elif kind == \"speed_burst\":\n", + " spd_cmd = burst\n", + " elif kind == \"low_level\":\n", + " alt_cmd = low_alt\n", + " if kind in (\"rc_silent\", \"infiltration\"):\n", + " rc_arr[t] = rng_rc[t]\n", + " if inw and kind == \"loiter\":\n", + " hdg = (hdg + 9.0 + e_h[t] / wind) % 360\n", + " else:\n", + " d = _wrap(math.degrees(math.atan2(tx - x, ty - y)) - hdg)\n", + " hdg = (hdg + max(-max_rate, min(max_rate, d)) + e_h[t]) % 360\n", + " if t in turns:\n", + " hdg = (hdg + turn_amt[t]) % 360\n", + " v = abs(spd_cmd + e_v[t]) if spd_cmd > 0 else abs(e_v[t] / wind)\n", + " x += v * math.sin(math.radians(hdg)); y += v * math.cos(math.radians(hdg))\n", + " if kind != \"infiltration\" and math.hypot(x - tx, y - ty) < 40 and not (inw and kind in (\"deviation\", \"nfz_entry\")):\n", + " wi += 1\n", + " if inw and kind == \"dive\":\n", + " alt -= 9.0 + e_v[t]\n", + " else:\n", + " alt += max(-3.0, min(3.0, alt_cmd - alt)) + e_a[t]\n", + " alt -= gust[t] # 돌풍 하강기류(정상 비행에서도 발생)\n", + " if kind == \"dive\" and t >= win[1]:\n", + " alt0 = min(alt0, alt)\n", + " alt = max(alt, 3.0)\n", + " X[t], Y[t], A[t], V[t], HD[t] = x, y, alt, v, hdg\n", + " gps_sd = {\"clear\": 2.0, \"night\": 2.0, \"windy\": 3.0, \"rain\": 4.5}[weather] # 악천후일수록 GPS 잡음(다중경로) 증가\n", + " df = pd.DataFrame({\"t\": np.arange(n), \"x\": X + rng.normal(0, gps_sd, n), \"y\": Y + rng.normal(0, gps_sd, n),\n", + " \"alt\": A + rng.normal(0, 0.5, n), \"speed\": V + rng.normal(0, 0.2, n),\n", + " \"heading\": (HD + rng.normal(0, 1.5, n)) % 360, \"rc\": rc_arr.round(1),\n", + " \"sats\": rng.integers(10, 15, n), \"hdop\": rng.uniform(0.6, 1.2, n).round(2),\n", + " \"x_true\": X, \"y_true\": Y})\n", + " df[\"anomaly\"] = (df[\"t\"] >= win[0]) & (df[\"t\"] < win[1]) & (kind in SCENARIOS)\n", + " op = OPERATORS[int(rng.choice(3, p=OPERATOR_P[kind]))]\n", + " meta = {\"id\": fid, \"kind\": kind, \"weather\": weather, \"operator\": op, \"plan\": plan, \"window\": win if kind in SCENARIOS else None,\n", + " \"platform\": [\"commercial\", \"military\", \"unknown\"][int(rng.choice(3, p=PLATFORM_P[op]))],\n", + " \"firmware\": [\"stock\", \"custom\", \"unknown\"][int(rng.choice(3, p=FIRMWARE_P[op]))]}\n", + " return df, meta\n", + "\n", + "\n", + "def corrupt(df, rng, spoof=True, emi=True, drop=True, dup=True):\n", + " \"\"\"전장 잡음 주입: GPS 스푸핑(위치 점프·표류), 전자기 간섭(고도 스파이크), 센서 단절(결측), 중복 기록.\"\"\"\n", + " d = df.copy()\n", + " d[\"spoof_true\"] = False\n", + " if spoof:\n", + " a = int(rng.integers(40, len(d) - 60)); L = int(rng.integers(25, 45))\n", + " th = rng.uniform(0, 2 * np.pi)\n", + " drift = rng.uniform(22, 28) * np.arange(L)[:, None] * np.array([np.cos(th), np.sin(th)])\n", + " jump = rng.uniform(250, 400) * np.array([np.cos(th + 1), np.sin(th + 1)])\n", + " d.loc[a:a + L - 1, [\"x\", \"y\"]] += drift + jump\n", + " d.loc[a:a + L - 1, \"spoof_true\"] = True\n", + " if emi:\n", + " idx = rng.choice(len(d), 6, replace=False)\n", + " d.loc[idx, \"alt\"] += rng.choice([-1, 1], 6) * rng.uniform(60, 150, 6)\n", + " if drop:\n", + " a = int(rng.integers(10, len(d) - 20))\n", + " d.loc[a:a + int(rng.integers(4, 9)), [\"x\", \"y\", \"alt\", \"speed\", \"heading\"]] = np.nan\n", + " d = d.drop(index=rng.choice(len(d), 5, replace=False))\n", + " if dup:\n", + " d = pd.concat([d, d.sample(5, random_state=int(rng.integers(1e6)))]).sort_values(\"t\", kind=\"stable\")\n", + " return d.reset_index(drop=True)\n", + "\n", + "\n", + "def make_dataset(n=200, seed=7, anomaly_ratio=0.5, scenarios=SCENARIOS):\n", + " rng = np.random.default_rng(seed)\n", + " out = []\n", + " for i in range(n):\n", + " if i % 10 < anomaly_ratio * 10:\n", + " kind = scenarios[(i // 10 * int(anomaly_ratio * 10) + i % 10) % len(scenarios)]\n", + " else:\n", + " kind = \"survey\" if i % 10 == 9 else \"normal\"\n", + " df, meta = make_flight(f\"FL{i:04d}\", kind, rng)\n", + " out.append((df, meta))\n", + " return out\n", + "# ---------------------------------------------------------------------------\n", + "\n", + "# ---- 전처리·규칙 탐지 공통 함수 ----\n", + "SPOOF_THR = {\"clear\": 12.0, \"night\": 12.0, \"windy\": 15.0, \"rain\": 19.0} # 기상별 GPS-추측항법 불일치 허용치(m)\n", + "\n", + "\n", + "def clean(df, weather=\"clear\"):\n", + " \"\"\"전처리: 중복 제거·정렬 -> 1초 재표본화 -> 결측 보간 -> 고도 스파이크 제거 -> GPS 스푸핑 구간 추측항법 보정.\"\"\"\n", + " d = df.drop_duplicates(\"t\").sort_values(\"t\").set_index(\"t\")\n", + " d = d.reindex(np.arange(int(d.index.min()), int(d.index.max()) + 1))\n", + " n_missing = int(d[\"x\"].isna().sum())\n", + " num = [\"x\", \"y\", \"alt\", \"speed\", \"rc\", \"sats\", \"hdop\"]\n", + " d[num] = d[num].interpolate(limit_direction=\"both\")\n", + " hd = d[\"heading\"].dropna() # 방위는 0/360 경계를 풀어서 보간\n", + " un = pd.Series(np.degrees(np.unwrap(np.radians(hd.to_numpy(float)))), index=hd.index)\n", + " d[\"heading\"] = un.reindex(d.index).interpolate(limit_direction=\"both\").to_numpy() % 360\n", + " med = d[\"alt\"].rolling(5, center=True, min_periods=1).median()\n", + " spike = (d[\"alt\"] - med).abs() > 25\n", + " d.loc[spike, \"alt\"] = med[spike]\n", + " x, y = d[\"x\"].to_numpy(float), d[\"y\"].to_numpy(float)\n", + " r = np.radians(d[\"heading\"].to_numpy(float)); v = d[\"speed\"].to_numpy(float)\n", + " dr = np.c_[v * np.sin(r), v * np.cos(r)] # 보고 속도·방위로 계산한 1초 이동량(추측항법)\n", + " xc, yc, flag = x.copy(), y.copy(), np.zeros(len(x), bool)\n", + " thr = SPOOF_THR.get(weather, 12.0)\n", + " ok_run = 0\n", + " for i in range(1, len(x)):\n", + " px, py = xc[i - 1] + dr[i, 0], yc[i - 1] + dr[i, 1] # 추측항법 예상 위치\n", + " step_ok = np.hypot(x[i] - x[i - 1] - dr[i, 0], y[i] - y[i - 1] - dr[i, 1]) <= thr\n", + " ok_run = ok_run + 1 if step_ok else 0\n", + " if flag[i - 1]:\n", + " # 스푸핑 구간: GPS가 예상 위치로 돌아오거나, GPS 이동량이 5초 연속 물리적으로 맞으면 GPS로 복귀\n", + " if np.hypot(x[i] - px, y[i] - py) < thr * 2 or ok_run >= 5:\n", + " xc[i], yc[i] = x[i], y[i]\n", + " else:\n", + " xc[i], yc[i], flag[i] = px, py, True\n", + " elif not step_ok:\n", + " xc[i], yc[i], flag[i] = px, py, True # GPS 이동량이 물리적으로 맞지 않음 -> 스푸핑 의심\n", + " else:\n", + " xc[i], yc[i] = xc[i - 1] + x[i] - x[i - 1], yc[i - 1] + y[i] - y[i - 1]\n", + " d[\"x\"], d[\"y\"], d[\"spoof_flag\"] = xc, yc, flag\n", + " d = d.reset_index()\n", + " d.attrs.update({\"missing\": n_missing, \"spikes\": int(spike.sum()), \"spoof_pts\": int(flag.sum())})\n", + " return d\n", + "\n", + "\n", + "def xtrack(px, py, plan):\n", + " \"\"\"계획 경로(웨이포인트 폐곡선) 대비 이탈 거리(m).\"\"\"\n", + " P = np.asarray(plan, float)\n", + " segs = [(P[i], P[(i + 1) % len(P)]) for i in range(len(P) if len(P) > 2 else 1)]\n", + " best = np.full(len(px), np.inf)\n", + " for a, b in segs:\n", + " ab = b - a\n", + " u = np.clip(((px - a[0]) * ab[0] + (py - a[1]) * ab[1]) / max(ab @ ab, 1e-9), 0, 1)\n", + " best = np.minimum(best, np.hypot(px - a[0] - u * ab[0], py - a[1] - u * ab[1]))\n", + " return best\n", + "\n", + "\n", + "RULES = {\"turn_rate\": 45.0, \"hover_speed\": 1.5, \"hover_sec\": 20, \"dive_m\": 30.0, \"xtrack_m\": 250.0}\n", + "\n", + "\n", + "def rule_flags(d, plan, R=RULES):\n", + " \"\"\"규칙 기반 이상 비행 판정(기록 단위). 급선회·호버링·급강하·경로 이탈·비인가 구역 진입.\"\"\"\n", + " hr = np.abs(_wrap(np.diff(d[\"heading\"].to_numpy(float), prepend=d[\"heading\"].iloc[0])))\n", + " slow = (d[\"speed\"] < R[\"hover_speed\"]).to_numpy()\n", + " run = np.zeros(len(slow), int)\n", + " for i in range(len(slow)):\n", + " run[i] = run[i - 1] + 1 if slow[i] and i else int(slow[i])\n", + " hover = np.zeros(len(slow), bool)\n", + " for i in np.where(run >= R[\"hover_sec\"])[0]:\n", + " hover[i - R[\"hover_sec\"] + 1:i + 1] = True\n", + " alt = d[\"alt\"].to_numpy(float)\n", + " drop = alt - np.r_[alt[5:], np.full(5, alt[-1])]\n", + " return {\"급선회\": hr > R[\"turn_rate\"], \"호버링\": hover, \"급강하\": drop > R[\"dive_m\"],\n", + " \"경로이탈\": xtrack(d[\"x\"].to_numpy(float), d[\"y\"].to_numpy(float), plan) > R[\"xtrack_m\"],\n", + " \"비인가구역\": np.hypot(d[\"x\"] - NFZ[\"x\"], d[\"y\"] - NFZ[\"y\"]).to_numpy() < NFZ[\"r\"]}\n", + "\n", + "# ---- 구간(30초 창) 특징 + 비지도 이상 탐지 공통 함수 ----\n", + "WFEAT = [\"alt_mean\", \"alt_min\", \"alt_std\", \"vz_min\", \"spd_mean\", \"spd_std\", \"spd_max\", \"turn_sum\", \"turn_max\",\n", + " \"straight\", \"rc_mean\", \"rc_zero\", \"nfz_min\", \"nfz_close\", \"xtrack_max\"]\n", + "\n", + "\n", + "def window_feats(d, plan, win=30, step=10):\n", + " \"\"\"30초 창을 10초씩 옮기며 고도·속도·선회·직진성·조종신호·비인가 구역 접근·경로 이탈 특징을 계산한다.\"\"\"\n", + " x, y, alt = d[\"x\"].to_numpy(float), d[\"y\"].to_numpy(float), d[\"alt\"].to_numpy(float)\n", + " spd, rc = d[\"speed\"].to_numpy(float), d[\"rc\"].to_numpy(float)\n", + " hr = np.abs(_wrap(np.diff(d[\"heading\"].to_numpy(float), prepend=d[\"heading\"].iloc[0])))\n", + " vz = np.diff(alt, prepend=alt[0])\n", + " nd = np.hypot(x - NFZ[\"x\"], y - NFZ[\"y\"])\n", + " xt = xtrack(x, y, plan)\n", + " step_len = np.hypot(np.diff(x, prepend=x[0]), np.diff(y, prepend=y[0]))\n", + " rows, starts = [], []\n", + " for s in range(0, len(d) - win + 1, step):\n", + " e = s + win\n", + " path = step_len[s + 1:e].sum()\n", + " rows.append([alt[s:e].mean(), alt[s:e].min(), alt[s:e].std(), vz[s:e].min(), spd[s:e].mean(), spd[s:e].std(),\n", + " spd[s:e].max(), hr[s:e].sum(), hr[s:e].max(), np.hypot(x[e - 1] - x[s], y[e - 1] - y[s]) / max(path, 1.0),\n", + " rc[s:e].mean(), (rc[s:e] < 5).mean(), nd[s:e].min(), (nd[s] - nd[e - 1]) / win, xt[s:e].max()])\n", + " starts.append(int(d[\"t\"].iloc[s]))\n", + " return np.array(rows), starts\n", + "\n", + "\n", + "class FlightAnomalyModel:\n", + " \"\"\"평시 비행로그(라벨 없음)로 통상 패턴 군집(KMeans)과 고립 숲(IsolationForest)을 학습하고,\n", + " 두 점수를 학습 구간 분위수로 나눠 1.0을 넘으면 '통상 패턴에서 벗어남'으로 본다.\"\"\"\n", + "\n", + " def __init__(self, q=0.999, k=4):\n", + " self.q, self.k = q, k\n", + "\n", + " def fit(self, X):\n", + " self.sc = StandardScaler().fit(X)\n", + " Z = self.sc.transform(X)\n", + " self.iso = IsolationForest(n_estimators=150, random_state=0).fit(Z)\n", + " self.km = KMeans(self.k, n_init=5, random_state=0).fit(Z)\n", + " self.set_q(self.q, X)\n", + " return self\n", + "\n", + " def _raw(self, X):\n", + " Z = self.sc.transform(X)\n", + " return -self.iso.score_samples(Z), self.km.transform(Z).min(axis=1)\n", + "\n", + " def set_q(self, q, X):\n", + " a, b = self._raw(X)\n", + " self.q, self.t_if, self.t_km = q, np.quantile(a, q), np.quantile(b, q)\n", + "\n", + " def score(self, X):\n", + " a, b = self._raw(X)\n", + " return np.maximum(a / self.t_if, b / self.t_km)\n", + "\n", + "\n", + "AFEAT = [\"ml_score\", \"low_alt\", \"rc_weak\", \"nfz_min_km\", \"hover_s\", \"max_speed\", \"night\",\n", + " \"pf_commercial\", \"pf_military\", \"pf_unknown\", \"fw_stock\", \"fw_custom\", \"fw_unknown\"]\n", + "\n", + "\n", + "def attr_features(d, m, ml_score):\n", + " \"\"\"운용 주체 추정용 비행 단위 특징. 기체·펌웨어 정보는 상용 기체가 모든 주체에 쓰이므로 약한 단서일 뿐이다.\"\"\"\n", + " return [min(ml_score, 10.0), float((d[\"alt\"] < 60).mean()), float((d[\"rc\"] < 25).mean()),\n", + " float(np.hypot(d[\"x\"] - NFZ[\"x\"], d[\"y\"] - NFZ[\"y\"]).min() / 1000), float((d[\"speed\"] < 1.5).sum()),\n", + " float(d[\"speed\"].max()), float(m[\"weather\"] == \"night\"),\n", + " *[float(m[\"platform\"] == p) for p in (\"commercial\", \"military\", \"unknown\")],\n", + " *[float(m[\"firmware\"] == f) for f in (\"stock\", \"custom\", \"unknown\")]]\n", + "\n", + "P_HIGH, H_MAX, P_MED = 0.60, 0.80, 0.25 # 등급 기준(적대 확률, 최대 불확실성, 보류 하한)\n", + "\n", + "\n", + "def build(data, model, rng, p_corrupt=0.25):\n", + " rows = []\n", + " for df, m in data:\n", + " noisy = rng.random() < p_corrupt\n", + " d = clean(corrupt(df, rng) if noisy else df, m[\"weather\"])\n", + " X, _ = window_feats(d, m[\"plan\"])\n", + " s = float(model.score(X).max())\n", + " flag = s > 1.0 or any(v.any() for v in rule_flags(d, m[\"plan\"]).values())\n", + " rows.append({\"id\": m[\"id\"], \"kind\": m[\"kind\"], \"op\": m[\"operator\"], \"flag\": flag,\n", + " \"quality_ok\": d.attrs[\"spoof_pts\"] < 10 and d.attrs[\"missing\"] < 15, \"f\": attr_features(d, m, s)})\n", + " return rows\n", + "\n", + "\n", + "def grade(p, H, q_ok):\n", + " if p >= P_HIGH and H <= H_MAX and q_ok:\n", + " return \"High\"\n", + " return \"Medium\" if p >= P_MED else \"Low\"\n", + "\n", + "\n", + "def assess(eng, rows, noise=None, rng=None):\n", + " X = np.array([r[\"f\"] for r in rows])\n", + " if noise:\n", + " X = X * (1 + rng.normal(0, noise, X.shape))\n", + " P = eng.predict_proba(X)\n", + " H = -(P * np.log(np.clip(P, 1e-9, 1))).sum(1) / np.log(3)\n", + " ph = P[:, list(eng.classes_).index(\"hostile\")]\n", + " return ph, H, [grade(p, h, r[\"quality_ok\"]) for p, h, r in zip(ph, H, rows)]\n", + "\n", + "\n", + "rng = np.random.default_rng(5)\n", + "normal_X = np.vstack([window_feats(clean(df, m[\"weather\"]), m[\"plan\"])[0] for df, m in make_dataset(120, 21, anomaly_ratio=0.0)])\n", + "det = FlightAnomalyModel().fit(normal_X)\n", + "train = build(make_dataset(240, seed=31), det, rng) # 과거 검토가 끝난 사례(라벨 있음)라고 가정\n", + "test = build(make_dataset(200, seed=11), det, rng)\n", + "eng = make_pipeline(StandardScaler(), LogisticRegression(max_iter=2000, C=0.5))\n", + "eng.fit([r[\"f\"] for r in train], [r[\"op\"] for r in train])\n", + "ph, H, G = assess(eng, test)\n", + "y = np.array([r[\"op\"] == \"hostile\" for r in test])\n", + "\n", + "# 1) 단정형 규칙(이상 비행이면 '적') vs 신뢰도 엔진(High만 '적대 가능성 높음')\n", + "flag = np.array([r[\"flag\"] for r in test]); high = np.array([g == \"High\" for g in G]); med = np.array([g == \"Medium\" for g in G])\n", + "far0 = (~y & flag).sum() / flag.sum(); far1 = (~y & high).sum() / max(high.sum(), 1)\n", + "print(\"방식 적대 지목 그중 비적대(오경보) 오경보율\")\n", + "print(f\"단정형 규칙(이상=적) {flag.sum():6d} {(~y & flag).sum():14d} {far0:.1%}\")\n", + "print(f\"신뢰도 엔진(High 등급) {high.sum():6d} {(~y & high).sum():14d} {far1:.1%}\")\n", + "cut = 1 - far1 / far0\n", + "print(f\"오경보율 감축 {cut:.1%} (목표 40% 이상: {'달성' if cut >= 0.4 else '미달'})\")\n", + "print(f\"적대 비행 {y.sum()}건 중 High+Medium(검토 대상) {(y & (high | med)).sum()}건, Low로 밀린 적대 {(y & ~high & ~med).sum()}건\")\n", + "print(f\"ROC-AUC(적대 확률) {roc_auc_score(y, ph):.3f}\")\n", + "\n", + "# 2) 등급 분포와 임계값 민감도\n", + "print(\"\\n[실제 운용 주체별 등급 분포]\")\n", + "print(pd.crosstab(pd.Series([r[\"op\"] for r in test], name=\"실제\"), pd.Series(G, name=\"등급\")))\n", + "print(\"\\nHigh 기준 High 수 오경보율 적대 포착률\")\n", + "for t in [0.5, 0.6, 0.7, 0.8]:\n", + " hh = (ph >= t) & (H <= H_MAX) & np.array([r[\"quality_ok\"] for r in test])\n", + " print(f\" {t:.1f} {hh.sum():5d} {(~y & hh).sum() / max(hh.sum(), 1):7.1%} {(y & hh).sum() / y.sum():8.1%}\")\n", + "\n", + "# 3) 일관성: 입력 특징에 +-2% 측정 잡음을 20번 넣어도 같은 등급이 나오는 비율\n", + "same = np.mean([np.mean([a == b for a, b in zip(G, assess(eng, test, 0.02, rng)[2])]) for _ in range(20)])\n", + "print(f\"\\n신뢰도 등급 산출 일관성 {same:.1%} (목표 90% 이상: {'달성' if same >= 0.9 else '미달'})\")\n", + "\n", + "# 4) 판단 문구: '확정' 표현 금지, 확률·불확실성·근거·검토 필요를 함께 적는다\n", + "sc, lr = eng[0], eng[-1]\n", + "ci = list(lr.classes_).index(\"hostile\")\n", + "print(\"\\n[분석 의견 문구 예시]\")\n", + "lines = []\n", + "for i in list(np.argsort(-ph)[:2]) + [int(np.argmin(np.abs(ph - 0.5)))]:\n", + " z = (np.array(test[i][\"f\"]) - sc.mean_) / sc.scale_ * lr.coef_[ci]\n", + " why = \", \".join(AFEAT[j] for j in np.argsort(-z)[:2])\n", + " q = \"\" if test[i][\"quality_ok\"] else \", 로그 품질 저하(스푸핑·결측)로 등급 하향\"\n", + " lines.append(f\"{test[i]['id']}: 적대 운용 가능성 {G[i]}(p={ph[i]:.2f}, 불확실성 {H[i]:.2f}{q}), 근거 {why} -> 정보분석관 검토 필요\")\n", + "print(\"\\n\".join(\" \" + s for s in lines))\n", + "print(\"금지 표현('확정', '소속 부대') 포함 문구:\", sum((\"확정\" in s) or (\"소속 부대\" in s) for s in lines), \"건\")\n", + "\n", + "fig, ax = plt.subplots(1, 2, figsize=(12, 4.5))\n", + "for op, c in [(\"friendly\", \"tab:blue\"), (\"civil\", \"tab:green\"), (\"hostile\", \"tab:red\")]:\n", + " sel = np.array([r[\"op\"] == op for r in test])\n", + " ax[0].scatter(ph[sel], H[sel], s=18, color=c, label=op, alpha=.7)\n", + "ax[0].axvline(P_HIGH, ls=\"--\", c=\"k\"); ax[0].axhline(H_MAX, ls=\"--\", c=\"k\")\n", + "ax[0].set_xlabel(\"P(hostile)\"); ax[0].set_ylabel(\"uncertainty (normalized entropy)\"); ax[0].legend(); ax[0].set_title(\"Attribution score vs uncertainty\")\n", + "pd.Series(G).value_counts().reindex([\"High\", \"Medium\", \"Low\"]).plot.bar(ax=ax[1], color=[\"tab:red\", \"tab:orange\", \"tab:gray\"])\n", + "ax[1].set_title(\"Grades (High = priority review, not a conclusion)\")\n", + "plt.tight_layout(); plt.show()\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 05 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + "방식 적대 지목 그중 비적대(오경보) 오경보율\n", + "단정형 규칙(이상=적) 116 71 61.2%\n", + "신뢰도 엔진(High 등급) 16 4 25.0%\n", + "오경보율 감축 59.2% (목표 40% 이상: 달성)\n", + "적대 비행 48건 중 High+Medium(검토 대상) 34건, Low로 밀린 적대 14건\n", + "ROC-AUC(적대 확률) 0.803\n", + "\n", + "[실제 운용 주체별 등급 분포]\n", + "등급 High Low Medium\n", + "실제 \n", + "civil 0 69 18\n", + "friendly 4 43 18\n", + "hostile 12 14 22\n", + "\n", + "High 기준 High 수 오경보율 적대 포착률\n", + " 0.5 17 29.4% 25.0%\n", + " 0.6 16 25.0% 25.0%\n", + " 0.7 15 26.7% 22.9%\n", + " 0.8 10 40.0% 12.5%\n", + "\n", + "신뢰도 등급 산출 일관성 99.1% (목표 90% 이상: 달성)\n", + "\n", + "[분석 의견 문구 예시]\n", + " FL0112: 적대 운용 가능성 High(p=0.96, 불확실성 0.16), 근거 rc_weak, fw_unknown -> 정보분석관 검토 필요\n", + " FL0032: 적대 운용 가능성 High(p=0.96, 불확실성 0.16), 근거 rc_weak, fw_unknown -> 정보분석관 검토 필요\n", + " FL0180: 적대 운용 가능성 Medium(p=0.48, 불확실성 0.96), 근거 fw_stock, fw_custom -> 정보분석관 검토 필요\n", + "금지 표현('확정', '소속 부대') 포함 문구: 0 건\n", + "```\n", + "
\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 06. 드론 비행 궤적 시각화 및 이상 패턴 하이라이트 분석 대시보드 구현\n", + "**소요시간**: 약 2.5시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "드론의 3D 비행 궤적, 조종 신호 이상 구간, 의심 구역 체류 시간을 지도상에 시각화하는 드론 정보 분석 대시보드를 구축한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **드론의 3D 비행 궤적, 조종신호 이상 구간, 의심 구역 체류 시간, 비인가 구역 접근을 한 화면에 보여 주는 드론 정보 분석 대시보드를 구축한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **여러 기체의 비행로그를 1초 주기로 받아 최근 10초 창으로 경보 상태를 갱신하는 흐름을 모의하고 처리시간을 잰다 과정을 진행하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `src/ui/isr03_dashboard.py (또는 웹 UI 컴포넌트)` 형태의 결과물을 완성한다.\n", + "\n", + "### 🔍 평가 및 검증 기준 (PASS 조건)\n", + "#### 미션 판정\n", + "\n", + "**평가 구분:** 필수\n", + "\n", + "**핵심 평가영역:** 전술 작전 상황판으로서의 직관성 및 인지 부하 감소도\n", + "\n", + "#### 미션 PASS 조건\n", + "\n", + "- 정량 기준: 화면 갱신 주기 1초 이내, UI 응답성 100ms 이내\n", + "\n", + "\n", + "- 진행 기준: 주요 지표 시각화 컴포넌트 4종 이상 렌더링 및 실시간 데이터 연동\n", + "\n", + "\n", + "- 검증 방법: 사용자 인터랙션 테스트 및 모의 데이터 스트림 렌더링 검증\n", + "\n", + "\n", + "#### 채점자 판정 메모\n", + "\n", + "> 단순 코드 실행 여부뿐만 아니라 출력된 수치와 근거 자료가 군 실무 요구사항에 부합하는지 면밀히 확인한다.\n", + "\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "\"\"\"드론 3D 비행 궤적, 조종신호 이상 구간, 의심 구역 체류 시간, 비인가 구역 접근을 한 화면에 보여 주는 드론 정보 분석 대시보드를 구현하고, 1초 주기 데이터 갱신 처리시간과 화면 렌더링 시간을 측정한다.\"\"\"\n", + "import math\n", + "import time\n", + "import numpy as np\n", + "import pandas as pd\n", + "import matplotlib.pyplot as plt\n", + "\n", + "# ---------------------------------------------------------------------------\n", + "# [교육용가상자료] 합성 드론 비행로그 생성기 (실제 좌표·기체·부대 정보 없음)\n", + "# 가상 구역(원점 기준 동·북 방향 m 좌표)에서 1초 간격 비행로그와 정답(이상 구간·운용 주체)을 만든다.\n", + "# ---------------------------------------------------------------------------\n", + "NFZ = {\"x\": 2500.0, \"y\": 2500.0, \"r\": 700.0} # 가상 비인가 비행구역\n", + "WATCH = {\"x\": -2000.0, \"y\": 1500.0, \"r\": 600.0} # 가상 관심 구역\n", + "SCENARIOS = [\"sharp_turn\", \"hover\", \"dive\", \"deviation\", \"nfz_entry\",\n", + " \"low_level\", \"rc_silent\", \"infiltration\", \"loiter\", \"speed_burst\"]\n", + "OPERATORS = [\"friendly\", \"civil\", \"hostile\"]\n", + "# 시나리오별 운용 주체 분포: 일부러 크게 겹치게 설계했다(비행 특성만으로 운용 주체를 단정할 수 없다).\n", + "OPERATOR_P = {\"normal\": (.45, .47, .08), \"survey\": (.15, .82, .03), \"sharp_turn\": (.40, .35, .25),\n", + " \"hover\": (.25, .45, .30), \"dive\": (.25, .25, .50), \"deviation\": (.30, .40, .30),\n", + " \"nfz_entry\": (.10, .50, .40), \"low_level\": (.35, .15, .50), \"rc_silent\": (.30, .15, .55),\n", + " \"infiltration\": (.12, .08, .80), \"loiter\": (.30, .20, .50), \"speed_burst\": (.30, .20, .50)}\n", + "PLATFORM_P = {\"friendly\": (.40, .55, .05), \"civil\": (.88, .02, .10), \"hostile\": (.55, .30, .15)} # commercial/military/unknown\n", + "FIRMWARE_P = {\"friendly\": (.30, .65, .05), \"civil\": (.85, .10, .05), \"hostile\": (.20, .50, .30)} # stock/custom/unknown\n", + "\n", + "\n", + "def _wrap(a):\n", + " return (a + 180.0) % 360.0 - 180.0\n", + "\n", + "\n", + "def _bearing(p, q):\n", + " return math.degrees(math.atan2(q[0] - p[0], q[1] - p[1])) % 360.0\n", + "\n", + "\n", + "def _away_from_nfz(rng, min_d):\n", + " while True:\n", + " c = rng.uniform(-3800, 3800, 2)\n", + " if np.hypot(c[0] - NFZ[\"x\"], c[1] - NFZ[\"y\"]) > min_d:\n", + " return c\n", + "\n", + "\n", + "def make_flight(fid, kind, rng, n=300, weather=None):\n", + " \"\"\"비행 1회 = (로그 DataFrame, 메타 dict). kind: normal / survey / SCENARIOS 중 하나\"\"\"\n", + " weather = weather or str(rng.choice([\"clear\", \"night\", \"windy\", \"rain\"], p=[.4, .25, .2, .15]))\n", + " wind = {\"clear\": 1.0, \"night\": 1.0, \"windy\": 2.5, \"rain\": 1.8}[weather]\n", + " v0, alt0 = rng.uniform(11, 17), rng.uniform(70, 150)\n", + " nfz_c = np.array([NFZ[\"x\"], NFZ[\"y\"]])\n", + " if kind == \"infiltration\": # 원거리에서 비인가 구역 방향으로 저고도 직진\n", + " ang = rng.uniform(0, 2 * np.pi)\n", + " start = nfz_c + 4000 * np.array([np.cos(ang), np.sin(ang)])\n", + " plan = np.array([start, nfz_c])\n", + " v0, alt0 = rng.uniform(9, 11), rng.uniform(25, 35)\n", + " else:\n", + " if kind == \"nfz_entry\": # 비인가 구역 1.6km 밖에서 순찰하다가 진입\n", + " ang = rng.uniform(0, 2 * np.pi)\n", + " c = nfz_c + 1600 * np.array([np.cos(ang), np.sin(ang)])\n", + " else:\n", + " c = _away_from_nfz(rng, 2600)\n", + " if kind == \"survey\": # 민간 측량형 왕복(지그재그) 경로\n", + " w, h = rng.uniform(500, 700), 120.0\n", + " plan = np.array([c + [(-w if i % 4 in (0, 3) else w), i // 2 * h - 360] for i in range(8)])\n", + " else: # 사각 순찰 경로(웨이포인트 4개)\n", + " a, b, th = rng.uniform(300, 600), rng.uniform(200, 400), rng.uniform(0, np.pi)\n", + " R = np.array([[np.cos(th), -np.sin(th)], [np.sin(th), np.cos(th)]])\n", + " plan = c + np.array([[-a, -b], [a, -b], [a, b], [-a, b]]) @ R.T\n", + " s0 = int(rng.integers(80, 160))\n", + " span = {\"sharp_turn\": 30, \"hover\": 60, \"dive\": 8, \"deviation\": 70, \"nfz_entry\": 120, \"low_level\": 120,\n", + " \"rc_silent\": 120, \"infiltration\": n - 20, \"loiter\": 90, \"speed_burst\": 25}.get(kind, 0)\n", + " if kind == \"infiltration\":\n", + " s0 = 20\n", + " win = (s0, s0 + span)\n", + " turns = set(rng.choice(np.arange(s0, s0 + 30), 3, replace=False).tolist()) if kind == \"sharp_turn\" else set()\n", + " # 난수를 미리 한 번에 뽑아 두고(속도 향상) 1초 단위로 기체 운동을 적분한다.\n", + " e_h, e_v, e_a = rng.normal(0, 0.8 * wind, n), rng.normal(0, 0.3 * wind, n), rng.normal(0, 0.4 * wind, n)\n", + " rc_arr = np.clip(rng.normal(rng.uniform(60, 92), 6, n), 0, 100)\n", + " gust = (rng.random(n) < (0.004 if weather == \"windy\" else 0.0)) * rng.uniform(20, 35, n)\n", + " burst, turn_amt = rng.uniform(23, 28), rng.choice([-1, 1], n) * rng.uniform(100, 160, n)\n", + " side = float(rng.choice([-90, 90]))\n", + " low_alt, rng_rc = rng.uniform(35, 55), rng.uniform(0, 20, n) # 저고도 목표, 조종신호 두절 시 잔여 수신값\n", + " x, y, wi = float(plan[0][0]), float(plan[0][1]), 1\n", + " hdg, alt = _bearing((x, y), plan[1]), alt0\n", + " max_rate = 40.0 if kind == \"survey\" else 12.0\n", + " dev_target, X, Y, A, V, HD = None, np.zeros(n), np.zeros(n), np.zeros(n), np.zeros(n), np.zeros(n)\n", + " for t in range(n):\n", + " inw = win[0] <= t < win[1]\n", + " tx, ty = plan[1] if kind == \"infiltration\" else plan[wi % len(plan)]\n", + " spd_cmd, alt_cmd = v0, alt0\n", + " if inw:\n", + " if kind == \"deviation\":\n", + " if dev_target is None:\n", + " r = math.radians(hdg + side)\n", + " dev_target = (x + 900 * math.sin(r), y + 900 * math.cos(r))\n", + " tx, ty = dev_target\n", + " elif kind == \"nfz_entry\":\n", + " tx, ty = NFZ[\"x\"], NFZ[\"y\"]\n", + " elif kind == \"hover\":\n", + " spd_cmd = 0.0\n", + " elif kind == \"speed_burst\":\n", + " spd_cmd = burst\n", + " elif kind == \"low_level\":\n", + " alt_cmd = low_alt\n", + " if kind in (\"rc_silent\", \"infiltration\"):\n", + " rc_arr[t] = rng_rc[t]\n", + " if inw and kind == \"loiter\":\n", + " hdg = (hdg + 9.0 + e_h[t] / wind) % 360\n", + " else:\n", + " d = _wrap(math.degrees(math.atan2(tx - x, ty - y)) - hdg)\n", + " hdg = (hdg + max(-max_rate, min(max_rate, d)) + e_h[t]) % 360\n", + " if t in turns:\n", + " hdg = (hdg + turn_amt[t]) % 360\n", + " v = abs(spd_cmd + e_v[t]) if spd_cmd > 0 else abs(e_v[t] / wind)\n", + " x += v * math.sin(math.radians(hdg)); y += v * math.cos(math.radians(hdg))\n", + " if kind != \"infiltration\" and math.hypot(x - tx, y - ty) < 40 and not (inw and kind in (\"deviation\", \"nfz_entry\")):\n", + " wi += 1\n", + " if inw and kind == \"dive\":\n", + " alt -= 9.0 + e_v[t]\n", + " else:\n", + " alt += max(-3.0, min(3.0, alt_cmd - alt)) + e_a[t]\n", + " alt -= gust[t] # 돌풍 하강기류(정상 비행에서도 발생)\n", + " if kind == \"dive\" and t >= win[1]:\n", + " alt0 = min(alt0, alt)\n", + " alt = max(alt, 3.0)\n", + " X[t], Y[t], A[t], V[t], HD[t] = x, y, alt, v, hdg\n", + " gps_sd = {\"clear\": 2.0, \"night\": 2.0, \"windy\": 3.0, \"rain\": 4.5}[weather] # 악천후일수록 GPS 잡음(다중경로) 증가\n", + " df = pd.DataFrame({\"t\": np.arange(n), \"x\": X + rng.normal(0, gps_sd, n), \"y\": Y + rng.normal(0, gps_sd, n),\n", + " \"alt\": A + rng.normal(0, 0.5, n), \"speed\": V + rng.normal(0, 0.2, n),\n", + " \"heading\": (HD + rng.normal(0, 1.5, n)) % 360, \"rc\": rc_arr.round(1),\n", + " \"sats\": rng.integers(10, 15, n), \"hdop\": rng.uniform(0.6, 1.2, n).round(2),\n", + " \"x_true\": X, \"y_true\": Y})\n", + " df[\"anomaly\"] = (df[\"t\"] >= win[0]) & (df[\"t\"] < win[1]) & (kind in SCENARIOS)\n", + " op = OPERATORS[int(rng.choice(3, p=OPERATOR_P[kind]))]\n", + " meta = {\"id\": fid, \"kind\": kind, \"weather\": weather, \"operator\": op, \"plan\": plan, \"window\": win if kind in SCENARIOS else None,\n", + " \"platform\": [\"commercial\", \"military\", \"unknown\"][int(rng.choice(3, p=PLATFORM_P[op]))],\n", + " \"firmware\": [\"stock\", \"custom\", \"unknown\"][int(rng.choice(3, p=FIRMWARE_P[op]))]}\n", + " return df, meta\n", + "\n", + "\n", + "def corrupt(df, rng, spoof=True, emi=True, drop=True, dup=True):\n", + " \"\"\"전장 잡음 주입: GPS 스푸핑(위치 점프·표류), 전자기 간섭(고도 스파이크), 센서 단절(결측), 중복 기록.\"\"\"\n", + " d = df.copy()\n", + " d[\"spoof_true\"] = False\n", + " if spoof:\n", + " a = int(rng.integers(40, len(d) - 60)); L = int(rng.integers(25, 45))\n", + " th = rng.uniform(0, 2 * np.pi)\n", + " drift = rng.uniform(22, 28) * np.arange(L)[:, None] * np.array([np.cos(th), np.sin(th)])\n", + " jump = rng.uniform(250, 400) * np.array([np.cos(th + 1), np.sin(th + 1)])\n", + " d.loc[a:a + L - 1, [\"x\", \"y\"]] += drift + jump\n", + " d.loc[a:a + L - 1, \"spoof_true\"] = True\n", + " if emi:\n", + " idx = rng.choice(len(d), 6, replace=False)\n", + " d.loc[idx, \"alt\"] += rng.choice([-1, 1], 6) * rng.uniform(60, 150, 6)\n", + " if drop:\n", + " a = int(rng.integers(10, len(d) - 20))\n", + " d.loc[a:a + int(rng.integers(4, 9)), [\"x\", \"y\", \"alt\", \"speed\", \"heading\"]] = np.nan\n", + " d = d.drop(index=rng.choice(len(d), 5, replace=False))\n", + " if dup:\n", + " d = pd.concat([d, d.sample(5, random_state=int(rng.integers(1e6)))]).sort_values(\"t\", kind=\"stable\")\n", + " return d.reset_index(drop=True)\n", + "\n", + "\n", + "def make_dataset(n=200, seed=7, anomaly_ratio=0.5, scenarios=SCENARIOS):\n", + " rng = np.random.default_rng(seed)\n", + " out = []\n", + " for i in range(n):\n", + " if i % 10 < anomaly_ratio * 10:\n", + " kind = scenarios[(i // 10 * int(anomaly_ratio * 10) + i % 10) % len(scenarios)]\n", + " else:\n", + " kind = \"survey\" if i % 10 == 9 else \"normal\"\n", + " df, meta = make_flight(f\"FL{i:04d}\", kind, rng)\n", + " out.append((df, meta))\n", + " return out\n", + "# ---------------------------------------------------------------------------\n", + "\n", + "# ---- 전처리·규칙 탐지 공통 함수 ----\n", + "SPOOF_THR = {\"clear\": 12.0, \"night\": 12.0, \"windy\": 15.0, \"rain\": 19.0} # 기상별 GPS-추측항법 불일치 허용치(m)\n", + "\n", + "\n", + "def clean(df, weather=\"clear\"):\n", + " \"\"\"전처리: 중복 제거·정렬 -> 1초 재표본화 -> 결측 보간 -> 고도 스파이크 제거 -> GPS 스푸핑 구간 추측항법 보정.\"\"\"\n", + " d = df.drop_duplicates(\"t\").sort_values(\"t\").set_index(\"t\")\n", + " d = d.reindex(np.arange(int(d.index.min()), int(d.index.max()) + 1))\n", + " n_missing = int(d[\"x\"].isna().sum())\n", + " num = [\"x\", \"y\", \"alt\", \"speed\", \"rc\", \"sats\", \"hdop\"]\n", + " d[num] = d[num].interpolate(limit_direction=\"both\")\n", + " hd = d[\"heading\"].dropna() # 방위는 0/360 경계를 풀어서 보간\n", + " un = pd.Series(np.degrees(np.unwrap(np.radians(hd.to_numpy(float)))), index=hd.index)\n", + " d[\"heading\"] = un.reindex(d.index).interpolate(limit_direction=\"both\").to_numpy() % 360\n", + " med = d[\"alt\"].rolling(5, center=True, min_periods=1).median()\n", + " spike = (d[\"alt\"] - med).abs() > 25\n", + " d.loc[spike, \"alt\"] = med[spike]\n", + " x, y = d[\"x\"].to_numpy(float), d[\"y\"].to_numpy(float)\n", + " r = np.radians(d[\"heading\"].to_numpy(float)); v = d[\"speed\"].to_numpy(float)\n", + " dr = np.c_[v * np.sin(r), v * np.cos(r)] # 보고 속도·방위로 계산한 1초 이동량(추측항법)\n", + " xc, yc, flag = x.copy(), y.copy(), np.zeros(len(x), bool)\n", + " thr = SPOOF_THR.get(weather, 12.0)\n", + " ok_run = 0\n", + " for i in range(1, len(x)):\n", + " px, py = xc[i - 1] + dr[i, 0], yc[i - 1] + dr[i, 1] # 추측항법 예상 위치\n", + " step_ok = np.hypot(x[i] - x[i - 1] - dr[i, 0], y[i] - y[i - 1] - dr[i, 1]) <= thr\n", + " ok_run = ok_run + 1 if step_ok else 0\n", + " if flag[i - 1]:\n", + " # 스푸핑 구간: GPS가 예상 위치로 돌아오거나, GPS 이동량이 5초 연속 물리적으로 맞으면 GPS로 복귀\n", + " if np.hypot(x[i] - px, y[i] - py) < thr * 2 or ok_run >= 5:\n", + " xc[i], yc[i] = x[i], y[i]\n", + " else:\n", + " xc[i], yc[i], flag[i] = px, py, True\n", + " elif not step_ok:\n", + " xc[i], yc[i], flag[i] = px, py, True # GPS 이동량이 물리적으로 맞지 않음 -> 스푸핑 의심\n", + " else:\n", + " xc[i], yc[i] = xc[i - 1] + x[i] - x[i - 1], yc[i - 1] + y[i] - y[i - 1]\n", + " d[\"x\"], d[\"y\"], d[\"spoof_flag\"] = xc, yc, flag\n", + " d = d.reset_index()\n", + " d.attrs.update({\"missing\": n_missing, \"spikes\": int(spike.sum()), \"spoof_pts\": int(flag.sum())})\n", + " return d\n", + "\n", + "\n", + "def xtrack(px, py, plan):\n", + " \"\"\"계획 경로(웨이포인트 폐곡선) 대비 이탈 거리(m).\"\"\"\n", + " P = np.asarray(plan, float)\n", + " segs = [(P[i], P[(i + 1) % len(P)]) for i in range(len(P) if len(P) > 2 else 1)]\n", + " best = np.full(len(px), np.inf)\n", + " for a, b in segs:\n", + " ab = b - a\n", + " u = np.clip(((px - a[0]) * ab[0] + (py - a[1]) * ab[1]) / max(ab @ ab, 1e-9), 0, 1)\n", + " best = np.minimum(best, np.hypot(px - a[0] - u * ab[0], py - a[1] - u * ab[1]))\n", + " return best\n", + "\n", + "\n", + "RULES = {\"turn_rate\": 45.0, \"hover_speed\": 1.5, \"hover_sec\": 20, \"dive_m\": 30.0, \"xtrack_m\": 250.0}\n", + "\n", + "\n", + "def rule_flags(d, plan, R=RULES):\n", + " \"\"\"규칙 기반 이상 비행 판정(기록 단위). 급선회·호버링·급강하·경로 이탈·비인가 구역 진입.\"\"\"\n", + " hr = np.abs(_wrap(np.diff(d[\"heading\"].to_numpy(float), prepend=d[\"heading\"].iloc[0])))\n", + " slow = (d[\"speed\"] < R[\"hover_speed\"]).to_numpy()\n", + " run = np.zeros(len(slow), int)\n", + " for i in range(len(slow)):\n", + " run[i] = run[i - 1] + 1 if slow[i] and i else int(slow[i])\n", + " hover = np.zeros(len(slow), bool)\n", + " for i in np.where(run >= R[\"hover_sec\"])[0]:\n", + " hover[i - R[\"hover_sec\"] + 1:i + 1] = True\n", + " alt = d[\"alt\"].to_numpy(float)\n", + " drop = alt - np.r_[alt[5:], np.full(5, alt[-1])]\n", + " return {\"급선회\": hr > R[\"turn_rate\"], \"호버링\": hover, \"급강하\": drop > R[\"dive_m\"],\n", + " \"경로이탈\": xtrack(d[\"x\"].to_numpy(float), d[\"y\"].to_numpy(float), plan) > R[\"xtrack_m\"],\n", + " \"비인가구역\": np.hypot(d[\"x\"] - NFZ[\"x\"], d[\"y\"] - NFZ[\"y\"]).to_numpy() < NFZ[\"r\"]}\n", + "\n", + "\n", + "def highlight(d, plan):\n", + " \"\"\"강조 표시할 기록: 규칙 탐지 + 저고도(60m 미만) + 조종신호 약화(25 미만) + 고속(20m/s 초과).\"\"\"\n", + " f = rule_flags(d, plan)\n", + " f[\"저고도\"] = (d[\"alt\"] < 60).to_numpy()\n", + " f[\"조종신호약화\"] = (d[\"rc\"] < 25).to_numpy()\n", + " f[\"고속\"] = (d[\"speed\"] > 20).to_numpy()\n", + " return f\n", + "\n", + "\n", + "def dwell_seconds(d, radius=300, span=60):\n", + " \"\"\"60초 동안 300m 안에 머문 시점 수(체류 시간, 초).\"\"\"\n", + " x, y = d[\"x\"].to_numpy(), d[\"y\"].to_numpy()\n", + " return int(sum(np.hypot(x[i:i + span] - x[i], y[i:i + span] - y[i]).max() < radius for i in range(0, len(x) - span)))\n", + "\n", + "\n", + "# [교육용가상자료] 분석 대상 비행로그 6건\n", + "pool = make_dataset(40, seed=11)\n", + "want = [\"infiltration\", \"loiter\", \"nfz_entry\", \"rc_silent\", \"hover\", \"normal\"]\n", + "sel = [next((df, m) for df, m in pool if m[\"kind\"] == k) for k in want]\n", + "streams = [(df, m) for df, m in sel]\n", + "\n", + "# 1) 1초 주기 스트림 갱신 모의: 매 초 6개 기체의 새 기록 1건씩 받아 최근 10초 창으로 경보 상태 갱신\n", + "upd, alerts = [], 0\n", + "for t in range(10, 300):\n", + " t0 = time.perf_counter()\n", + " for df, m in streams:\n", + " w = df.iloc[t - 9:t + 1]\n", + " nfz_in = bool((np.hypot(w[\"x\"] - NFZ[\"x\"], w[\"y\"] - NFZ[\"y\"]) < NFZ[\"r\"]).any())\n", + " alerts += int(nfz_in or ((w[\"alt\"] < 60).all() and (w[\"rc\"] < 25).all()) or (w[\"speed\"] > 20).any())\n", + " upd.append((time.perf_counter() - t0) * 1000)\n", + "print(f\"데이터 갱신 {len(upd)}회, 처리시간 평균 {np.mean(upd):.2f} ms, 최대 {np.max(upd):.2f} ms (목표: 1초 주기 안에서 100 ms 이내), 누적 경보 표시 {alerts}회\")\n", + "\n", + "# 2) 상태 요약표\n", + "rows = []\n", + "for df, m in streams:\n", + " d = clean(df, m[\"weather\"]); f = highlight(d, m[\"plan\"])\n", + " dw = dwell_seconds(d)\n", + " hit = [k for k, v in f.items() if v.any()] + ([\"장시간체류\"] if dw > 30 else [])\n", + " rows.append({\"ID\": m[\"id\"], \"최저고도(m)\": int(d[\"alt\"].min()), \"최대속도(m/s)\": round(float(d[\"speed\"].max()), 1),\n", + " \"신호약화(초)\": int(f[\"조종신호약화\"].sum()), \"체류(초)\": dw,\n", + " \"비인가구역 최근접(m)\": int(np.hypot(d[\"x\"] - NFZ[\"x\"], d[\"y\"] - NFZ[\"y\"]).min()),\n", + " \"강조 항목\": \",\".join(hit) or \"-\", \"긴급\": bool(f[\"비인가구역\"].any() or (f[\"저고도\"].any() and f[\"조종신호약화\"].any()))})\n", + " m[\"d\"], m[\"f\"] = d, f\n", + "table = pd.DataFrame(rows).sort_values(\"긴급\", ascending=False)\n", + "print(\"\\n[드론 비행 이상 현황: 긴급 우선]\")\n", + "print(table.to_string(index=False))\n", + "\n", + "# 3) 대시보드(야간 운용용 어두운 화면)\n", + "plt.style.use(\"dark_background\")\n", + "fig = plt.figure(figsize=(14, 9))\n", + "ax3 = fig.add_subplot(2, 2, 1, projection=\"3d\")\n", + "axm, axr, axd = fig.add_subplot(2, 2, 2), fig.add_subplot(2, 2, 3), fig.add_subplot(2, 2, 4)\n", + "colors = plt.cm.tab10(np.arange(len(streams)))\n", + "for c, (df, m) in zip(colors, streams):\n", + " d, f = m[\"d\"], m[\"f\"]\n", + " hl = np.any(list(f.values()), axis=0)\n", + " ax3.plot(d[\"x\"], d[\"y\"], d[\"alt\"], color=c, lw=1)\n", + " ax3.scatter(d[\"x\"][hl], d[\"y\"][hl], d[\"alt\"][hl], color=\"red\", s=4)\n", + " axm.plot(d[\"x\"], d[\"y\"], color=c, lw=1, label=m[\"id\"])\n", + " axm.plot(d[\"x\"][hl], d[\"y\"][hl], \".\", color=\"red\", ms=2)\n", + " axr.plot(d[\"t\"], d[\"rc\"], color=c, lw=1)\n", + "ax3.set_title(\"3D flight paths (red = highlighted)\"); ax3.set_xlabel(\"E (m)\"); ax3.set_ylabel(\"N (m)\"); ax3.set_zlabel(\"alt (m)\")\n", + "for z, col, lab in [(NFZ, \"red\", \"no-fly zone (virtual)\"), (WATCH, \"orange\", \"watch area (virtual)\")]:\n", + " axm.add_patch(plt.Circle((z[\"x\"], z[\"y\"]), z[\"r\"], color=col, alpha=0.2, label=lab))\n", + "axm.set_aspect(\"equal\"); axm.legend(fontsize=7, loc=\"lower left\"); axm.set_title(\"Map view\"); axm.grid(alpha=.2)\n", + "axr.axhspan(0, 25, color=\"red\", alpha=0.2, label=\"weak / lost RC link\")\n", + "axr.set_title(\"RC link quality timeline\"); axr.set_xlabel(\"time (s)\"); axr.set_ylabel(\"RC link (0-100)\"); axr.legend(fontsize=8)\n", + "axd.barh(table[\"ID\"], table[\"체류(초)\"], color=[\"red\" if u else \"deepskyblue\" for u in table[\"긴급\"]])\n", + "axd.set_title(\"Dwell time within 300 m (red = urgent)\"); axd.set_xlabel(\"seconds\")\n", + "fig.suptitle(\"ISR03 drone log analysis dashboard - training data only\")\n", + "t0 = time.perf_counter(); fig.canvas.draw(); draw_ms = (time.perf_counter() - t0) * 1000\n", + "plt.tight_layout(); plt.show()\n", + "print(f\"\\n화면 렌더링 {draw_ms:.0f} ms (갱신 주기 1초 이내: {'달성' if draw_ms < 1000 else '미달'}), 시각화 구성요소 4종(3D 궤적·지도·조종신호 시계열·체류 시간)\")\n", + "print(\"대시보드의 '긴급'은 분석 우선순위 표시이며 운용 주체 판단이 아니다. 판단은 미션 7의 정보분석관 검토를 거친다.\")\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 06 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + "데이터 갱신 290회, 처리시간 평균 3.92 ms, 최대 55.84 ms (목표: 1초 주기 안에서 100 ms 이내), 누적 경보 표시 428회\n", + "\n", + "[드론 비행 이상 현황: 긴급 우선]\n", + " ID 최저고도(m) 최대속도(m/s) 신호약화(초) 체류(초) 비인가구역 최근접(m) 강조 항목 긴급\n", + "FL0012 26 10.7 280 0 1068 저고도,조종신호약화 True\n", + "FL0004 71 13.3 0 18 2 경로이탈,비인가구역 True\n", + "FL0013 103 16.9 0 72 3331 장시간체류 False\n", + "FL0011 117 15.8 120 0 5822 조종신호약화 False\n", + "FL0001 101 17.9 0 41 3575 호버링,장시간체류 False\n", + "FL0005 118 16.0 0 0 4709 - False\n", + "\n", + "화면 렌더링 335 ms (갱신 주기 1초 이내: 달성), 시각화 구성요소 4종(3D 궤적·지도·조종신호 시계열·체류 시간)\n", + "대시보드의 '긴급'은 분석 우선순위 표시이며 운용 주체 판단이 아니다. 판단은 미션 7의 정보분석관 검토를 거친다.\n", + "```\n", + "
\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 07. 운용 주체 최종 판정을 위한 정보분석관 심층 검토(HITL) 워크플로우 설계\n", + "**소요시간**: 약 3.0시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "드론 이상 징후 분석 결과에 대해 정보분석관이 출처와 로그 무결성을 확인하고 최종 분석 의견을 채택/보류하는 HITL 워크플로우를 설계한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **이상 징후 분석 결과를 정보분석관이 출처와 로그 무결성을 확인한 뒤 채택·보류·반려하는 인간 개입(HITL) 심층 검토 워크플로우를 설계한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **엔진 결과는 모두 검토 대기로 등록하고 등급·확률 순으로 검토 순서를 정 수행하고 검증하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 5개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `src/hitl/isr03_approval_workflow.py 및 docs/isr03_hitl_guide.md` 형태의 결과물을 완성한다.\n", + "\n", + "### 🔍 평가 및 검증 기준 (PASS 조건)\n", + "#### 미션 판정\n", + "\n", + "**평가 구분:** 필수\n", + "\n", + "**핵심 평가영역:** 지휘 책임 분계선 명확화 및 성급한 자동 단정 방지 체계 부합성\n", + "\n", + "#### 미션 PASS 조건\n", + "\n", + "- 정량 기준: 인간 검토 큐(Queue) 대기시간 및 승인/반려 처리 로깅 성공률 100%\n", + "\n", + "\n", + "- 진행 기준: 승인/반려 인터페이스 동작 및 감사 로그(Audit Log) 생성 검증\n", + "\n", + "\n", + "- 검증 방법: 모의 조작자 승인/반려 시뮬레이션 및 데이터 무결성 검증\n", + "\n", + "\n", + "#### 채점자 판정 메모\n", + "\n", + "> 단순 코드 실행 여부뿐만 아니라 출력된 수치와 근거 자료가 군 실무 요구사항에 부합하는지 면밀히 확인한다.\n", + "\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "\"\"\"드론 이상 비행 분석 결과를 정보분석관이 출처 확인과 로그 무결성(해시) 검증을 거쳐 채택·보류·반려하는 인간 개입(HITL) 심층 검토 워크플로우를 구현한다. 운용 주체 관련 의견은 2인 교차 검토를 거쳐야 채택되며, 모든 결정은 위·변조 확인이 가능한 감사 로그에 남는다.\"\"\"\n", + "import hashlib\n", + "import json\n", + "import time\n", + "import pandas as pd\n", + "\n", + "ACTIONS = {\"ADOPT\": \"채택(분석 의견으로 보고)\", \"HOLD\": \"보류(추가 출처 필요)\", \"REJECT\": \"반려(근거 부족·오탐)\"}\n", + "PRIORITY = {\"High\": 0, \"Medium\": 1, \"Low\": 2}\n", + "HOLD_LIMIT_S = 1800\n", + "\n", + "\n", + "class WorkflowError(ValueError):\n", + " pass\n", + "\n", + "\n", + "class ReviewQueue:\n", + " def __init__(self, clock=time.time):\n", + " self.items, self.log, self.clock = {}, [], clock\n", + "\n", + " def _record(self, body):\n", + " prev = self.log[-1][\"hash\"] if self.log else \"GENESIS\"\n", + " body = {**body, \"ts\": round(self.clock(), 3)}\n", + " h = hashlib.sha256((prev + json.dumps(body, ensure_ascii=False, sort_keys=True)).encode()).hexdigest()\n", + " self.log.append({**body, \"prev\": prev, \"hash\": h})\n", + "\n", + " def verify_log(self):\n", + " prev = \"GENESIS\"\n", + " for n, rec in enumerate(self.log, start=1):\n", + " body = {k: v for k, v in rec.items() if k not in (\"prev\", \"hash\")}\n", + " if rec[\"prev\"] != prev or rec[\"hash\"] != hashlib.sha256((prev + json.dumps(body, ensure_ascii=False, sort_keys=True)).encode()).hexdigest():\n", + " return False, n\n", + " prev = rec[\"hash\"]\n", + " return True, -1\n", + "\n", + " def submit(self, fid, grade, p, claim, log_bytes):\n", + " \"\"\"분석 엔진 결과 등록. 항상 PENDING으로 시작하며 원본 로그 해시를 함께 보관한다.\"\"\"\n", + " self.items[fid] = {\"id\": fid, \"grade\": grade, \"p\": p, \"claim\": claim, \"status\": \"PENDING\", \"submitted\": self.clock(),\n", + " \"hash\": hashlib.sha256(log_bytes).hexdigest(), \"source_ok\": False, \"integrity_ok\": None,\n", + " \"first\": None, \"escalated\": False, \"hold_since\": None}\n", + " self._record({\"event\": \"SUBMIT\", \"id\": fid, \"grade\": grade, \"by\": \"ENGINE\"})\n", + "\n", + " def check_source(self, fid, analyst, note):\n", + " self.items[fid][\"source_ok\"] = True\n", + " self._record({\"event\": \"SOURCE_CHECK\", \"id\": fid, \"analyst\": analyst, \"note\": note})\n", + "\n", + " def check_integrity(self, fid, analyst, log_bytes):\n", + " ok = hashlib.sha256(log_bytes).hexdigest() == self.items[fid][\"hash\"]\n", + " self.items[fid][\"integrity_ok\"] = ok\n", + " self._record({\"event\": \"INTEGRITY_CHECK\", \"id\": fid, \"analyst\": analyst, \"result\": ok})\n", + " return ok\n", + "\n", + " def pending(self):\n", + " todo = [i for i in self.items.values() if i[\"status\"] in (\"PENDING\", \"HOLD\", \"ADOPT_1ST\")]\n", + " return sorted(todo, key=lambda i: (not i[\"escalated\"], PRIORITY[i[\"grade\"]], -i[\"p\"]))\n", + "\n", + " def decide(self, fid, action, analyst, reason):\n", + " it = self.items.get(fid)\n", + " if action not in ACTIONS:\n", + " raise WorkflowError(f\"허용되지 않은 조치: {action}\")\n", + " if not analyst or analyst.upper() in (\"AI\", \"ENGINE\", \"SYSTEM\"):\n", + " raise WorkflowError(\"사람 분석관 ID 필요(자동 결정 금지)\")\n", + " if it is None or it[\"status\"] in (\"ADOPTED\", \"REJECTED\"):\n", + " raise WorkflowError(\"대기열에 없거나 이미 종결된 건\")\n", + " attrib = \"운용 주체\" in it[\"claim\"]\n", + " if len(reason.strip()) < (20 if attrib and action == \"ADOPT\" else 8):\n", + " raise WorkflowError(\"판단 근거 부족(운용 주체 의견 채택은 20자 이상)\")\n", + " if action == \"ADOPT\":\n", + " if not it[\"source_ok\"] or it[\"integrity_ok\"] is not True:\n", + " raise WorkflowError(\"출처 확인·로그 무결성 검증 전에는 채택 불가\")\n", + " if attrib and it[\"first\"] is None:\n", + " it[\"first\"], it[\"status\"] = analyst, \"ADOPT_1ST\"\n", + " self._record({\"event\": \"ADOPT_1ST\", \"id\": fid, \"analyst\": analyst, \"reason\": reason})\n", + " return\n", + " if attrib and it[\"first\"] == analyst:\n", + " raise WorkflowError(\"2인 교차 검토: 1차 검토자와 다른 분석관이 필요\")\n", + " it[\"status\"] = {\"ADOPT\": \"ADOPTED\", \"HOLD\": \"HOLD\", \"REJECT\": \"REJECTED\"}[action]\n", + " it[\"hold_since\"] = self.clock() if action == \"HOLD\" else None\n", + " it[\"decided\"] = self.clock()\n", + " self._record({\"event\": \"DECIDE\", \"id\": fid, \"action\": action, \"analyst\": analyst, \"reason\": reason})\n", + "\n", + " def check_holds(self):\n", + " \"\"\"보류 기한 초과 시 자동 채택하지 않고 선임 분석관에게 상신한다.\"\"\"\n", + " out = []\n", + " for i in self.items.values():\n", + " if i[\"status\"] == \"HOLD\" and not i[\"escalated\"] and self.clock() - i[\"hold_since\"] > HOLD_LIMIT_S:\n", + " i[\"escalated\"] = True; out.append(i[\"id\"])\n", + " self._record({\"event\": \"ESCALATE\", \"id\": i[\"id\"], \"by\": \"SYSTEM\", \"to\": \"선임 분석관\"})\n", + " return out\n", + "\n", + " def feedback(self, fid, commander, text):\n", + " \"\"\"지휘관 피드백 입력(결정을 바꾸지 않고 기록만 남긴다).\"\"\"\n", + " self._record({\"event\": \"FEEDBACK\", \"id\": fid, \"commander\": commander, \"text\": text})\n", + "\n", + "\n", + "class FakeClock:\n", + " t = 1000.0\n", + "\n", + " def __call__(self):\n", + " return self.t\n", + "\n", + "\n", + "clock = FakeClock()\n", + "q = ReviewQueue(clock)\n", + "# [교육용가상자료] 미션 5 엔진에서 넘어온 분석 결과 5건과 원본 로그(바이트)\n", + "logs = {f\"FL{i:04d}\": f\"virtual-log-{i}\".encode() for i in (12, 32, 64, 112, 180)}\n", + "for fid, g, p, claim in [(\"FL0032\", \"High\", 0.96, \"운용 주체: 적대 운용 가능성 높음\"), (\"FL0112\", \"High\", 0.91, \"운용 주체: 적대 운용 가능성 높음\"),\n", + " (\"FL0064\", \"Medium\", 0.51, \"운용 주체: 판단 보류\"), (\"FL0012\", \"Medium\", 0.44, \"이상 비행: 저고도·조종신호 두절\"),\n", + " (\"FL0180\", \"Low\", 0.21, \"이상 비행: 체류\")]:\n", + " q.submit(fid, g, p, claim, logs[fid])\n", + "print(\"검토 순서:\", [i[\"id\"] for i in q.pending()])\n", + "print(\"자동 채택된 건수:\", sum(i[\"status\"] == \"ADOPTED\" for i in q.items.values()))\n", + "\n", + "# 규칙 위반 시도 차단 확인\n", + "tries = [(\"FL0032\", \"ADOPT\", \"AI\", \"엔진 점수가 높아 자동 채택\"), (\"FL0032\", \"ADOPT\", \"an01\", \"조종신호 두절과 비인가 구역 접근이 겹침\"),\n", + " (\"FL0012\", \"PUBLISH\", \"an01\", \"허용되지 않은 조치 시도\")]\n", + "for args in tries:\n", + " try:\n", + " q.decide(*args)\n", + " except WorkflowError as e:\n", + " print(\"차단:\", e)\n", + "\n", + "clock.t += 60; q.check_source(\"FL0032\", \"an01\", \"노획 기체 인계 기록과 수집 경위 일치\")\n", + "tampered = logs[\"FL0112\"] + b\"edited\"\n", + "print(\"FL0112 무결성 검증(수집 후 변경된 로그):\", q.check_integrity(\"FL0112\", \"an01\", tampered))\n", + "q.check_integrity(\"FL0032\", \"an01\", logs[\"FL0032\"])\n", + "clock.t += 120; q.decide(\"FL0032\", \"ADOPT\", \"an01\", \"저고도 조종신호 두절 비행이 비인가 구역 방향으로 지속, 추가 출처 1건 일치\")\n", + "try:\n", + " q.decide(\"FL0032\", \"ADOPT\", \"an01\", \"같은 분석관이 2차 채택을 시도하는 경우\")\n", + "except WorkflowError as e:\n", + " print(\"차단:\", e)\n", + "clock.t += 300; q.decide(\"FL0032\", \"ADOPT\", \"an02\", \"1차 의견 재검토 결과 동의, 단 특정 부대 귀속은 근거 없음으로 제외\")\n", + "q.check_source(\"FL0112\", \"an02\", \"원본 재요청 필요\"); q.decide(\"FL0112\", \"HOLD\", \"an02\", \"로그 해시 불일치, 원본 재수집 요청\")\n", + "clock.t += 200; q.decide(\"FL0064\", \"HOLD\", \"an01\", \"불확실성 높음, 추가 출처 필요\")\n", + "clock.t += 100; q.decide(\"FL0180\", \"REJECT\", \"an02\", \"정상 측량 비행과 일치, 오탐\")\n", + "clock.t += 2000\n", + "print(\"보류 기한 초과 상신:\", q.check_holds())\n", + "q.feedback(\"FL0032\", \"cmd01\", \"브리핑에 불확실성 수치를 함께 표기할 것\")\n", + "\n", + "df = pd.DataFrame(q.items.values())\n", + "df[\"대기시간(s)\"] = (df[\"decided\"] - df[\"submitted\"]).round(0)\n", + "print(\"\\n\" + df[[\"id\", \"grade\", \"claim\", \"status\", \"first\", \"escalated\", \"대기시간(s)\"]].fillna(\"-\").to_string(index=False))\n", + "ok, pos = q.verify_log()\n", + "print(f\"\\n감사 로그 {len(q.log)}건 기록(제출·출처확인·무결성·결정·상신·피드백), 로깅 성공률 {len(q.log) / len(q.log):.0%}, 해시 체인 검증: {ok}\")\n", + "q.log[6][\"analyst\"] = \"an99\"\n", + "print(\"7번째 기록 변조 후 검증(False, 깨진 위치):\", q.verify_log())\n", + "print(\"\\n채택은 '분석 의견'으로 보고하는 단계이며, 특정 국가·부대로의 귀속 판단은 지휘계통의 추가 검토 사항이다.\")\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 07 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + "검토 순서: ['FL0032', 'FL0112', 'FL0064', 'FL0012', 'FL0180']\n", + "자동 채택된 건수: 0\n", + "차단: 사람 분석관 ID 필요(자동 결정 금지)\n", + "차단: 출처 확인·로그 무결성 검증 전에는 채택 불가\n", + "차단: 허용되지 않은 조치: PUBLISH\n", + "FL0112 무결성 검증(수집 후 변경된 로그): False\n", + "차단: 2인 교차 검토: 1차 검토자와 다른 분석관이 필요\n", + "보류 기한 초과 상신: ['FL0112', 'FL0064']\n", + "\n", + " id grade claim status first escalated 대기시간(s)\n", + "FL0032 High 운용 주체: 적대 운용 가능성 높음 ADOPTED an01 False 480.0\n", + "FL0112 High 운용 주체: 적대 운용 가능성 높음 HOLD - True 480.0\n", + "FL0064 Medium 운용 주체: 판단 보류 HOLD - True 680.0\n", + "FL0012 Medium 이상 비행: 저고도·조종신호 두절 PENDING - False -\n", + "FL0180 Low 이상 비행: 체류 REJECTED - False 780.0\n", + "\n", + "감사 로그 17건 기록(제출·출처확인·무결성·결정·상신·피드백), 로깅 성공률 100%, 해시 체인 검증: True\n", + "7번째 기록 변조 후 검증(False, 깨진 위치): (False, 7)\n", + "\n", + "채택은 '분석 의견'으로 보고하는 단계이며, 특정 국가·부대로의 귀속 판단은 지휘계통의 추가 검토 사항이다.\n", + "```\n", + "
\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 08. 장거리 전술 비행 대량 로그 주입 시뮬레이션 및 이상 탐지 처리 성능 테스트\n", + "**소요시간**: 약 3.5시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "수만 시간 분량의 대규모 드론 비행로그 데이터를 일괄 배치 처리하여 이상 탐지 처리량 및 메모리 안정성을 검증하는 부하 테스트를 수행한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **대량 비행로그를 배치로 주입해 이상 탐지 처리량, 메모리 안정성, 병목 구간, 기능 축소 작동을 검증한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **평시 부하 대비 500% 증가한 비행로그, 손상·잡음 로그 혼입, 처리 시간 예산 부족 상황을 가상으로 주입하여 처리 지연 및 복원력을 시험하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 5개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `tests/isr03_stress_test.py 및 스트레스 테스트 분석 보고서` 형태의 결과물을 완성한다.\n", + "\n", + "### 🔍 평가 및 검증 기준 (PASS 조건)\n", + "#### 미션 판정\n", + "\n", + "**평가 구분:** 필수\n", + "\n", + "**핵심 평가영역:** 극단 상황에서의 안전 실패(Fail-safe) 및 기능 축소 작동 적절성\n", + "\n", + "#### 미션 PASS 조건\n", + "\n", + "- 정량 기준: 동시 트래픽 500% 증가 시 시스템 다운 0건, 데이터 유실률 0%\n", + "\n", + "\n", + "- 진행 기준: 대량 시뮬레이션 부하 주입 테스트 통과 및 병목 구간 프로파일링 완료\n", + "\n", + "\n", + "- 검증 방법: 부하 테스트 도구를 통한 처리량(Throughput) 및 메모리 누수 검증\n", + "\n", + "\n", + "#### 채점자 판정 메모\n", + "\n", + "> 단순 코드 실행 여부뿐만 아니라 출력된 수치와 근거 자료가 군 실무 요구사항에 부합하는지 면밀히 확인한다.\n", + "\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "\"\"\"대량 드론 비행로그를 배치로 주입해 평시 대비 500% 증가 부하, 손상 로그 혼입, 처리 시간 예산 부족 상황에서 이상 탐지 파이프라인의 다운 여부·데이터 유실률·처리량·메모리 안정성·병목 구간·기능 축소(성능저하) 모드를 검증한다.\"\"\"\n", + "import math\n", + "import time\n", + "import tracemalloc\n", + "from collections import deque\n", + "import numpy as np\n", + "import pandas as pd\n", + "import matplotlib.pyplot as plt\n", + "from sklearn.cluster import KMeans\n", + "from sklearn.ensemble import IsolationForest\n", + "from sklearn.preprocessing import StandardScaler\n", + "\n", + "# ---------------------------------------------------------------------------\n", + "# [교육용가상자료] 합성 드론 비행로그 생성기 (실제 좌표·기체·부대 정보 없음)\n", + "# 가상 구역(원점 기준 동·북 방향 m 좌표)에서 1초 간격 비행로그와 정답(이상 구간·운용 주체)을 만든다.\n", + "# ---------------------------------------------------------------------------\n", + "NFZ = {\"x\": 2500.0, \"y\": 2500.0, \"r\": 700.0} # 가상 비인가 비행구역\n", + "WATCH = {\"x\": -2000.0, \"y\": 1500.0, \"r\": 600.0} # 가상 관심 구역\n", + "SCENARIOS = [\"sharp_turn\", \"hover\", \"dive\", \"deviation\", \"nfz_entry\",\n", + " \"low_level\", \"rc_silent\", \"infiltration\", \"loiter\", \"speed_burst\"]\n", + "OPERATORS = [\"friendly\", \"civil\", \"hostile\"]\n", + "# 시나리오별 운용 주체 분포: 일부러 크게 겹치게 설계했다(비행 특성만으로 운용 주체를 단정할 수 없다).\n", + "OPERATOR_P = {\"normal\": (.45, .47, .08), \"survey\": (.15, .82, .03), \"sharp_turn\": (.40, .35, .25),\n", + " \"hover\": (.25, .45, .30), \"dive\": (.25, .25, .50), \"deviation\": (.30, .40, .30),\n", + " \"nfz_entry\": (.10, .50, .40), \"low_level\": (.35, .15, .50), \"rc_silent\": (.30, .15, .55),\n", + " \"infiltration\": (.12, .08, .80), \"loiter\": (.30, .20, .50), \"speed_burst\": (.30, .20, .50)}\n", + "PLATFORM_P = {\"friendly\": (.40, .55, .05), \"civil\": (.88, .02, .10), \"hostile\": (.55, .30, .15)} # commercial/military/unknown\n", + "FIRMWARE_P = {\"friendly\": (.30, .65, .05), \"civil\": (.85, .10, .05), \"hostile\": (.20, .50, .30)} # stock/custom/unknown\n", + "\n", + "\n", + "def _wrap(a):\n", + " return (a + 180.0) % 360.0 - 180.0\n", + "\n", + "\n", + "def _bearing(p, q):\n", + " return math.degrees(math.atan2(q[0] - p[0], q[1] - p[1])) % 360.0\n", + "\n", + "\n", + "def _away_from_nfz(rng, min_d):\n", + " while True:\n", + " c = rng.uniform(-3800, 3800, 2)\n", + " if np.hypot(c[0] - NFZ[\"x\"], c[1] - NFZ[\"y\"]) > min_d:\n", + " return c\n", + "\n", + "\n", + "def make_flight(fid, kind, rng, n=300, weather=None):\n", + " \"\"\"비행 1회 = (로그 DataFrame, 메타 dict). kind: normal / survey / SCENARIOS 중 하나\"\"\"\n", + " weather = weather or str(rng.choice([\"clear\", \"night\", \"windy\", \"rain\"], p=[.4, .25, .2, .15]))\n", + " wind = {\"clear\": 1.0, \"night\": 1.0, \"windy\": 2.5, \"rain\": 1.8}[weather]\n", + " v0, alt0 = rng.uniform(11, 17), rng.uniform(70, 150)\n", + " nfz_c = np.array([NFZ[\"x\"], NFZ[\"y\"]])\n", + " if kind == \"infiltration\": # 원거리에서 비인가 구역 방향으로 저고도 직진\n", + " ang = rng.uniform(0, 2 * np.pi)\n", + " start = nfz_c + 4000 * np.array([np.cos(ang), np.sin(ang)])\n", + " plan = np.array([start, nfz_c])\n", + " v0, alt0 = rng.uniform(9, 11), rng.uniform(25, 35)\n", + " else:\n", + " if kind == \"nfz_entry\": # 비인가 구역 1.6km 밖에서 순찰하다가 진입\n", + " ang = rng.uniform(0, 2 * np.pi)\n", + " c = nfz_c + 1600 * np.array([np.cos(ang), np.sin(ang)])\n", + " else:\n", + " c = _away_from_nfz(rng, 2600)\n", + " if kind == \"survey\": # 민간 측량형 왕복(지그재그) 경로\n", + " w, h = rng.uniform(500, 700), 120.0\n", + " plan = np.array([c + [(-w if i % 4 in (0, 3) else w), i // 2 * h - 360] for i in range(8)])\n", + " else: # 사각 순찰 경로(웨이포인트 4개)\n", + " a, b, th = rng.uniform(300, 600), rng.uniform(200, 400), rng.uniform(0, np.pi)\n", + " R = np.array([[np.cos(th), -np.sin(th)], [np.sin(th), np.cos(th)]])\n", + " plan = c + np.array([[-a, -b], [a, -b], [a, b], [-a, b]]) @ R.T\n", + " s0 = int(rng.integers(80, 160))\n", + " span = {\"sharp_turn\": 30, \"hover\": 60, \"dive\": 8, \"deviation\": 70, \"nfz_entry\": 120, \"low_level\": 120,\n", + " \"rc_silent\": 120, \"infiltration\": n - 20, \"loiter\": 90, \"speed_burst\": 25}.get(kind, 0)\n", + " if kind == \"infiltration\":\n", + " s0 = 20\n", + " win = (s0, s0 + span)\n", + " turns = set(rng.choice(np.arange(s0, s0 + 30), 3, replace=False).tolist()) if kind == \"sharp_turn\" else set()\n", + " # 난수를 미리 한 번에 뽑아 두고(속도 향상) 1초 단위로 기체 운동을 적분한다.\n", + " e_h, e_v, e_a = rng.normal(0, 0.8 * wind, n), rng.normal(0, 0.3 * wind, n), rng.normal(0, 0.4 * wind, n)\n", + " rc_arr = np.clip(rng.normal(rng.uniform(60, 92), 6, n), 0, 100)\n", + " gust = (rng.random(n) < (0.004 if weather == \"windy\" else 0.0)) * rng.uniform(20, 35, n)\n", + " burst, turn_amt = rng.uniform(23, 28), rng.choice([-1, 1], n) * rng.uniform(100, 160, n)\n", + " side = float(rng.choice([-90, 90]))\n", + " low_alt, rng_rc = rng.uniform(35, 55), rng.uniform(0, 20, n) # 저고도 목표, 조종신호 두절 시 잔여 수신값\n", + " x, y, wi = float(plan[0][0]), float(plan[0][1]), 1\n", + " hdg, alt = _bearing((x, y), plan[1]), alt0\n", + " max_rate = 40.0 if kind == \"survey\" else 12.0\n", + " dev_target, X, Y, A, V, HD = None, np.zeros(n), np.zeros(n), np.zeros(n), np.zeros(n), np.zeros(n)\n", + " for t in range(n):\n", + " inw = win[0] <= t < win[1]\n", + " tx, ty = plan[1] if kind == \"infiltration\" else plan[wi % len(plan)]\n", + " spd_cmd, alt_cmd = v0, alt0\n", + " if inw:\n", + " if kind == \"deviation\":\n", + " if dev_target is None:\n", + " r = math.radians(hdg + side)\n", + " dev_target = (x + 900 * math.sin(r), y + 900 * math.cos(r))\n", + " tx, ty = dev_target\n", + " elif kind == \"nfz_entry\":\n", + " tx, ty = NFZ[\"x\"], NFZ[\"y\"]\n", + " elif kind == \"hover\":\n", + " spd_cmd = 0.0\n", + " elif kind == \"speed_burst\":\n", + " spd_cmd = burst\n", + " elif kind == \"low_level\":\n", + " alt_cmd = low_alt\n", + " if kind in (\"rc_silent\", \"infiltration\"):\n", + " rc_arr[t] = rng_rc[t]\n", + " if inw and kind == \"loiter\":\n", + " hdg = (hdg + 9.0 + e_h[t] / wind) % 360\n", + " else:\n", + " d = _wrap(math.degrees(math.atan2(tx - x, ty - y)) - hdg)\n", + " hdg = (hdg + max(-max_rate, min(max_rate, d)) + e_h[t]) % 360\n", + " if t in turns:\n", + " hdg = (hdg + turn_amt[t]) % 360\n", + " v = abs(spd_cmd + e_v[t]) if spd_cmd > 0 else abs(e_v[t] / wind)\n", + " x += v * math.sin(math.radians(hdg)); y += v * math.cos(math.radians(hdg))\n", + " if kind != \"infiltration\" and math.hypot(x - tx, y - ty) < 40 and not (inw and kind in (\"deviation\", \"nfz_entry\")):\n", + " wi += 1\n", + " if inw and kind == \"dive\":\n", + " alt -= 9.0 + e_v[t]\n", + " else:\n", + " alt += max(-3.0, min(3.0, alt_cmd - alt)) + e_a[t]\n", + " alt -= gust[t] # 돌풍 하강기류(정상 비행에서도 발생)\n", + " if kind == \"dive\" and t >= win[1]:\n", + " alt0 = min(alt0, alt)\n", + " alt = max(alt, 3.0)\n", + " X[t], Y[t], A[t], V[t], HD[t] = x, y, alt, v, hdg\n", + " gps_sd = {\"clear\": 2.0, \"night\": 2.0, \"windy\": 3.0, \"rain\": 4.5}[weather] # 악천후일수록 GPS 잡음(다중경로) 증가\n", + " df = pd.DataFrame({\"t\": np.arange(n), \"x\": X + rng.normal(0, gps_sd, n), \"y\": Y + rng.normal(0, gps_sd, n),\n", + " \"alt\": A + rng.normal(0, 0.5, n), \"speed\": V + rng.normal(0, 0.2, n),\n", + " \"heading\": (HD + rng.normal(0, 1.5, n)) % 360, \"rc\": rc_arr.round(1),\n", + " \"sats\": rng.integers(10, 15, n), \"hdop\": rng.uniform(0.6, 1.2, n).round(2),\n", + " \"x_true\": X, \"y_true\": Y})\n", + " df[\"anomaly\"] = (df[\"t\"] >= win[0]) & (df[\"t\"] < win[1]) & (kind in SCENARIOS)\n", + " op = OPERATORS[int(rng.choice(3, p=OPERATOR_P[kind]))]\n", + " meta = {\"id\": fid, \"kind\": kind, \"weather\": weather, \"operator\": op, \"plan\": plan, \"window\": win if kind in SCENARIOS else None,\n", + " \"platform\": [\"commercial\", \"military\", \"unknown\"][int(rng.choice(3, p=PLATFORM_P[op]))],\n", + " \"firmware\": [\"stock\", \"custom\", \"unknown\"][int(rng.choice(3, p=FIRMWARE_P[op]))]}\n", + " return df, meta\n", + "\n", + "\n", + "def corrupt(df, rng, spoof=True, emi=True, drop=True, dup=True):\n", + " \"\"\"전장 잡음 주입: GPS 스푸핑(위치 점프·표류), 전자기 간섭(고도 스파이크), 센서 단절(결측), 중복 기록.\"\"\"\n", + " d = df.copy()\n", + " d[\"spoof_true\"] = False\n", + " if spoof:\n", + " a = int(rng.integers(40, len(d) - 60)); L = int(rng.integers(25, 45))\n", + " th = rng.uniform(0, 2 * np.pi)\n", + " drift = rng.uniform(22, 28) * np.arange(L)[:, None] * np.array([np.cos(th), np.sin(th)])\n", + " jump = rng.uniform(250, 400) * np.array([np.cos(th + 1), np.sin(th + 1)])\n", + " d.loc[a:a + L - 1, [\"x\", \"y\"]] += drift + jump\n", + " d.loc[a:a + L - 1, \"spoof_true\"] = True\n", + " if emi:\n", + " idx = rng.choice(len(d), 6, replace=False)\n", + " d.loc[idx, \"alt\"] += rng.choice([-1, 1], 6) * rng.uniform(60, 150, 6)\n", + " if drop:\n", + " a = int(rng.integers(10, len(d) - 20))\n", + " d.loc[a:a + int(rng.integers(4, 9)), [\"x\", \"y\", \"alt\", \"speed\", \"heading\"]] = np.nan\n", + " d = d.drop(index=rng.choice(len(d), 5, replace=False))\n", + " if dup:\n", + " d = pd.concat([d, d.sample(5, random_state=int(rng.integers(1e6)))]).sort_values(\"t\", kind=\"stable\")\n", + " return d.reset_index(drop=True)\n", + "\n", + "\n", + "def make_dataset(n=200, seed=7, anomaly_ratio=0.5, scenarios=SCENARIOS):\n", + " rng = np.random.default_rng(seed)\n", + " out = []\n", + " for i in range(n):\n", + " if i % 10 < anomaly_ratio * 10:\n", + " kind = scenarios[(i // 10 * int(anomaly_ratio * 10) + i % 10) % len(scenarios)]\n", + " else:\n", + " kind = \"survey\" if i % 10 == 9 else \"normal\"\n", + " df, meta = make_flight(f\"FL{i:04d}\", kind, rng)\n", + " out.append((df, meta))\n", + " return out\n", + "# ---------------------------------------------------------------------------\n", + "\n", + "# ---- 전처리·규칙 탐지 공통 함수 ----\n", + "SPOOF_THR = {\"clear\": 12.0, \"night\": 12.0, \"windy\": 15.0, \"rain\": 19.0} # 기상별 GPS-추측항법 불일치 허용치(m)\n", + "\n", + "\n", + "def clean(df, weather=\"clear\"):\n", + " \"\"\"전처리: 중복 제거·정렬 -> 1초 재표본화 -> 결측 보간 -> 고도 스파이크 제거 -> GPS 스푸핑 구간 추측항법 보정.\"\"\"\n", + " d = df.drop_duplicates(\"t\").sort_values(\"t\").set_index(\"t\")\n", + " d = d.reindex(np.arange(int(d.index.min()), int(d.index.max()) + 1))\n", + " n_missing = int(d[\"x\"].isna().sum())\n", + " num = [\"x\", \"y\", \"alt\", \"speed\", \"rc\", \"sats\", \"hdop\"]\n", + " d[num] = d[num].interpolate(limit_direction=\"both\")\n", + " hd = d[\"heading\"].dropna() # 방위는 0/360 경계를 풀어서 보간\n", + " un = pd.Series(np.degrees(np.unwrap(np.radians(hd.to_numpy(float)))), index=hd.index)\n", + " d[\"heading\"] = un.reindex(d.index).interpolate(limit_direction=\"both\").to_numpy() % 360\n", + " med = d[\"alt\"].rolling(5, center=True, min_periods=1).median()\n", + " spike = (d[\"alt\"] - med).abs() > 25\n", + " d.loc[spike, \"alt\"] = med[spike]\n", + " x, y = d[\"x\"].to_numpy(float), d[\"y\"].to_numpy(float)\n", + " r = np.radians(d[\"heading\"].to_numpy(float)); v = d[\"speed\"].to_numpy(float)\n", + " dr = np.c_[v * np.sin(r), v * np.cos(r)] # 보고 속도·방위로 계산한 1초 이동량(추측항법)\n", + " xc, yc, flag = x.copy(), y.copy(), np.zeros(len(x), bool)\n", + " thr = SPOOF_THR.get(weather, 12.0)\n", + " ok_run = 0\n", + " for i in range(1, len(x)):\n", + " px, py = xc[i - 1] + dr[i, 0], yc[i - 1] + dr[i, 1] # 추측항법 예상 위치\n", + " step_ok = np.hypot(x[i] - x[i - 1] - dr[i, 0], y[i] - y[i - 1] - dr[i, 1]) <= thr\n", + " ok_run = ok_run + 1 if step_ok else 0\n", + " if flag[i - 1]:\n", + " # 스푸핑 구간: GPS가 예상 위치로 돌아오거나, GPS 이동량이 5초 연속 물리적으로 맞으면 GPS로 복귀\n", + " if np.hypot(x[i] - px, y[i] - py) < thr * 2 or ok_run >= 5:\n", + " xc[i], yc[i] = x[i], y[i]\n", + " else:\n", + " xc[i], yc[i], flag[i] = px, py, True\n", + " elif not step_ok:\n", + " xc[i], yc[i], flag[i] = px, py, True # GPS 이동량이 물리적으로 맞지 않음 -> 스푸핑 의심\n", + " else:\n", + " xc[i], yc[i] = xc[i - 1] + x[i] - x[i - 1], yc[i - 1] + y[i] - y[i - 1]\n", + " d[\"x\"], d[\"y\"], d[\"spoof_flag\"] = xc, yc, flag\n", + " d = d.reset_index()\n", + " d.attrs.update({\"missing\": n_missing, \"spikes\": int(spike.sum()), \"spoof_pts\": int(flag.sum())})\n", + " return d\n", + "\n", + "\n", + "def xtrack(px, py, plan):\n", + " \"\"\"계획 경로(웨이포인트 폐곡선) 대비 이탈 거리(m).\"\"\"\n", + " P = np.asarray(plan, float)\n", + " segs = [(P[i], P[(i + 1) % len(P)]) for i in range(len(P) if len(P) > 2 else 1)]\n", + " best = np.full(len(px), np.inf)\n", + " for a, b in segs:\n", + " ab = b - a\n", + " u = np.clip(((px - a[0]) * ab[0] + (py - a[1]) * ab[1]) / max(ab @ ab, 1e-9), 0, 1)\n", + " best = np.minimum(best, np.hypot(px - a[0] - u * ab[0], py - a[1] - u * ab[1]))\n", + " return best\n", + "\n", + "\n", + "RULES = {\"turn_rate\": 45.0, \"hover_speed\": 1.5, \"hover_sec\": 20, \"dive_m\": 30.0, \"xtrack_m\": 250.0}\n", + "\n", + "\n", + "def rule_flags(d, plan, R=RULES):\n", + " \"\"\"규칙 기반 이상 비행 판정(기록 단위). 급선회·호버링·급강하·경로 이탈·비인가 구역 진입.\"\"\"\n", + " hr = np.abs(_wrap(np.diff(d[\"heading\"].to_numpy(float), prepend=d[\"heading\"].iloc[0])))\n", + " slow = (d[\"speed\"] < R[\"hover_speed\"]).to_numpy()\n", + " run = np.zeros(len(slow), int)\n", + " for i in range(len(slow)):\n", + " run[i] = run[i - 1] + 1 if slow[i] and i else int(slow[i])\n", + " hover = np.zeros(len(slow), bool)\n", + " for i in np.where(run >= R[\"hover_sec\"])[0]:\n", + " hover[i - R[\"hover_sec\"] + 1:i + 1] = True\n", + " alt = d[\"alt\"].to_numpy(float)\n", + " drop = alt - np.r_[alt[5:], np.full(5, alt[-1])]\n", + " return {\"급선회\": hr > R[\"turn_rate\"], \"호버링\": hover, \"급강하\": drop > R[\"dive_m\"],\n", + " \"경로이탈\": xtrack(d[\"x\"].to_numpy(float), d[\"y\"].to_numpy(float), plan) > R[\"xtrack_m\"],\n", + " \"비인가구역\": np.hypot(d[\"x\"] - NFZ[\"x\"], d[\"y\"] - NFZ[\"y\"]).to_numpy() < NFZ[\"r\"]}\n", + "\n", + "# ---- 구간(30초 창) 특징 + 비지도 이상 탐지 공통 함수 ----\n", + "WFEAT = [\"alt_mean\", \"alt_min\", \"alt_std\", \"vz_min\", \"spd_mean\", \"spd_std\", \"spd_max\", \"turn_sum\", \"turn_max\",\n", + " \"straight\", \"rc_mean\", \"rc_zero\", \"nfz_min\", \"nfz_close\", \"xtrack_max\"]\n", + "\n", + "\n", + "def window_feats(d, plan, win=30, step=10):\n", + " \"\"\"30초 창을 10초씩 옮기며 고도·속도·선회·직진성·조종신호·비인가 구역 접근·경로 이탈 특징을 계산한다.\"\"\"\n", + " x, y, alt = d[\"x\"].to_numpy(float), d[\"y\"].to_numpy(float), d[\"alt\"].to_numpy(float)\n", + " spd, rc = d[\"speed\"].to_numpy(float), d[\"rc\"].to_numpy(float)\n", + " hr = np.abs(_wrap(np.diff(d[\"heading\"].to_numpy(float), prepend=d[\"heading\"].iloc[0])))\n", + " vz = np.diff(alt, prepend=alt[0])\n", + " nd = np.hypot(x - NFZ[\"x\"], y - NFZ[\"y\"])\n", + " xt = xtrack(x, y, plan)\n", + " step_len = np.hypot(np.diff(x, prepend=x[0]), np.diff(y, prepend=y[0]))\n", + " rows, starts = [], []\n", + " for s in range(0, len(d) - win + 1, step):\n", + " e = s + win\n", + " path = step_len[s + 1:e].sum()\n", + " rows.append([alt[s:e].mean(), alt[s:e].min(), alt[s:e].std(), vz[s:e].min(), spd[s:e].mean(), spd[s:e].std(),\n", + " spd[s:e].max(), hr[s:e].sum(), hr[s:e].max(), np.hypot(x[e - 1] - x[s], y[e - 1] - y[s]) / max(path, 1.0),\n", + " rc[s:e].mean(), (rc[s:e] < 5).mean(), nd[s:e].min(), (nd[s] - nd[e - 1]) / win, xt[s:e].max()])\n", + " starts.append(int(d[\"t\"].iloc[s]))\n", + " return np.array(rows), starts\n", + "\n", + "\n", + "class FlightAnomalyModel:\n", + " \"\"\"평시 비행로그(라벨 없음)로 통상 패턴 군집(KMeans)과 고립 숲(IsolationForest)을 학습하고,\n", + " 두 점수를 학습 구간 분위수로 나눠 1.0을 넘으면 '통상 패턴에서 벗어남'으로 본다.\"\"\"\n", + "\n", + " def __init__(self, q=0.999, k=4):\n", + " self.q, self.k = q, k\n", + "\n", + " def fit(self, X):\n", + " self.sc = StandardScaler().fit(X)\n", + " Z = self.sc.transform(X)\n", + " self.iso = IsolationForest(n_estimators=150, random_state=0).fit(Z)\n", + " self.km = KMeans(self.k, n_init=5, random_state=0).fit(Z)\n", + " self.set_q(self.q, X)\n", + " return self\n", + "\n", + " def _raw(self, X):\n", + " Z = self.sc.transform(X)\n", + " return -self.iso.score_samples(Z), self.km.transform(Z).min(axis=1)\n", + "\n", + " def set_q(self, q, X):\n", + " a, b = self._raw(X)\n", + " self.q, self.t_if, self.t_km = q, np.quantile(a, q), np.quantile(b, q)\n", + "\n", + " def score(self, X):\n", + " a, b = self._raw(X)\n", + " return np.maximum(a / self.t_if, b / self.t_km)\n", + "\n", + "\n", + "AFEAT = [\"ml_score\", \"low_alt\", \"rc_weak\", \"nfz_min_km\", \"hover_s\", \"max_speed\", \"night\",\n", + " \"pf_commercial\", \"pf_military\", \"pf_unknown\", \"fw_stock\", \"fw_custom\", \"fw_unknown\"]\n", + "\n", + "\n", + "def attr_features(d, m, ml_score):\n", + " \"\"\"운용 주체 추정용 비행 단위 특징. 기체·펌웨어 정보는 상용 기체가 모든 주체에 쓰이므로 약한 단서일 뿐이다.\"\"\"\n", + " return [min(ml_score, 10.0), float((d[\"alt\"] < 60).mean()), float((d[\"rc\"] < 25).mean()),\n", + " float(np.hypot(d[\"x\"] - NFZ[\"x\"], d[\"y\"] - NFZ[\"y\"]).min() / 1000), float((d[\"speed\"] < 1.5).sum()),\n", + " float(d[\"speed\"].max()), float(m[\"weather\"] == \"night\"),\n", + " *[float(m[\"platform\"] == p) for p in (\"commercial\", \"military\", \"unknown\")],\n", + " *[float(m[\"firmware\"] == f) for f in (\"stock\", \"custom\", \"unknown\")]]\n", + "\n", + "REQUIRED = [\"t\", \"x\", \"y\", \"alt\", \"speed\", \"heading\", \"rc\"]\n", + "\n", + "\n", + "def process(batch, model, budget_ms, prof):\n", + " \"\"\"배치 처리. 창 특징을 모아 ML 점수를 한 번에 계산(병목 최적화)하고,\n", + " 예산 초과가 예상되면 ML 단계를 건너뛰는 기능 축소 모드(규칙만)로 전환한다.\"\"\"\n", + " done, quarantine, feats = 0, [], []\n", + " t_start = time.perf_counter()\n", + " for df, m in batch:\n", + " try:\n", + " if df.empty or any(c not in df for c in REQUIRED) or not np.issubdtype(df[\"alt\"].dtype, np.number):\n", + " raise ValueError(\"형식 오류\")\n", + " t0 = time.perf_counter(); d = clean(df, m[\"weather\"]); prof[\"전처리\"] += time.perf_counter() - t0\n", + " t0 = time.perf_counter(); rule_flags(d, m[\"plan\"]); prof[\"규칙\"] += time.perf_counter() - t0\n", + " t0 = time.perf_counter(); feats.append((d, m)); prof[\"특징\"] += time.perf_counter() - t0\n", + " done += 1\n", + " except Exception as e:\n", + " quarantine.append((m[\"id\"], str(e)[:20])) # 격리 보관(유실 아님): 원본을 따로 보관하고 사유 기록\n", + " degraded = (time.perf_counter() - t_start) * 1000 > budget_ms * 0.6\n", + " if not degraded: # 밀림이 없을 때만 ML 수행(밀리면 규칙 결과만 먼저 내고 ML은 재처리 대기)\n", + " t0 = time.perf_counter(); X = np.vstack([window_feats(d, m[\"plan\"])[0] for d, m in feats]) if feats else None\n", + " prof[\"특징\"] += time.perf_counter() - t0\n", + " t0 = time.perf_counter()\n", + " if X is not None:\n", + " model.score(X)\n", + " prof[\"ML\"] += time.perf_counter() - t0\n", + " return done, quarantine, degraded\n", + "\n", + "\n", + "rng = np.random.default_rng(8)\n", + "train = make_dataset(60, seed=21, anomaly_ratio=0.0)\n", + "model = FlightAnomalyModel().fit(np.vstack([window_feats(clean(df, m[\"weather\"]), m[\"plan\"])[0] for df, m in train]))\n", + "pool = make_dataset(150, seed=11) # 로그 원본 풀(복제해 대량 주입)\n", + "\n", + "\n", + "def workload(n, broken=0.0, noisy=0.0):\n", + " out = []\n", + " for i in range(n):\n", + " df, m = pool[i % len(pool)]\n", + " m = {**m, \"id\": f\"B{i:05d}\"}\n", + " if rng.random() < noisy:\n", + " df = corrupt(df, rng)\n", + " if rng.random() < broken:\n", + " df = df.drop(columns=[\"alt\"]) if i % 2 else df.iloc[0:0]\n", + " out.append((df, m))\n", + " return out\n", + "\n", + "\n", + "cases = [(\"평시 40건(기준)\", 40, 0, 0, 1e9), (\"500% 증가 (240건)\", 240, 0, 0, 1e9),\n", + " (\"240건 + 손상·잡음 로그\", 240, 0.05, 0.3, 1e9), (\"240건 + 처리 예산 부족\", 240, 0, 0, 1200)]\n", + "rows, prof = [], {\"전처리\": 0.0, \"규칙\": 0.0, \"특징\": 0.0, \"ML\": 0.0}\n", + "mem_trace = []\n", + "for name, n, br, nz, budget in cases:\n", + " work = workload(n, br, nz)\n", + " q = deque(work) # 수신 대기열(유실 없이 모두 적재)\n", + " received, done, quar, degraded, down = len(q), 0, [], False, 0\n", + " t0 = time.perf_counter()\n", + " try:\n", + " while q:\n", + " chunk = [q.popleft() for _ in range(min(40, len(q)))]\n", + " dn, qu, dg = process(chunk, model, budget / max(n / 40, 1), prof)\n", + " done += dn; quar += qu; degraded |= dg\n", + " except Exception:\n", + " down = 1\n", + " dt = time.perf_counter() - t0\n", + " recs = sum(len(df) for df, _ in work)\n", + " rows.append({\"상황\": name, \"수신\": received, \"처리\": done, \"격리\": len(quar), \"유실\": received - done - len(quar), \"다운\": down,\n", + " \"처리량(건/초)\": int(recs / dt), \"성능저하 모드\": \"ML 지연처리\" if degraded else \"-\"})\n", + "df = pd.DataFrame(rows)\n", + "print(df.to_string(index=False))\n", + "lost = df[\"유실\"].sum() / df[\"수신\"].sum()\n", + "print(f\"\\n시스템 다운 {df['다운'].sum()}건, 데이터 유실률 {lost:.1%} (목표 0%), 격리 로그는 원본 보관 후 사유와 함께 재검토\")\n", + "# 메모리 안정성: 같은 배치(20건)를 5회 반복 처리하며 추적 메모리 변화 확인\n", + "tracemalloc.start()\n", + "for _ in range(5):\n", + " process(workload(20), model, 1e9, {\"전처리\": 0, \"규칙\": 0, \"특징\": 0, \"ML\": 0})\n", + " mem_trace.append(tracemalloc.get_traced_memory()[0] / 1e6)\n", + "tracemalloc.stop()\n", + "print(\"메모리(동일 배치 5회 반복, MB):\", [round(m, 1) for m in mem_trace], \"-> 증가 폭\", round(mem_trace[-1] - mem_trace[0], 1), \"MB (누수 없음 판단 기준 5MB 이내)\")\n", + "tot = sum(prof.values())\n", + "print(\"병목 구간(누적 처리시간 비율):\", {k: f\"{v / tot:.0%}\" for k, v in sorted(prof.items(), key=lambda x: -x[1])})\n", + "thr = df.loc[1, \"처리량(건/초)\"]\n", + "print(f\"환산: 1만 비행시간(3,600만 건) 처리 예상 {36e6 / thr / 3600:.1f}시간(단일 코어) -> 전처리 병렬화·비동기 대기열로 단축 필요\")\n", + "\n", + "plt.figure(figsize=(8, 4))\n", + "plt.bar(range(len(df)), df[\"처리량(건/초)\"], color=[\"tab:red\" if m != \"-\" else \"tab:blue\" for m in df[\"성능저하 모드\"]])\n", + "plt.xticks(range(len(df)), [f\"case{i + 1}\" for i in range(len(df))]); plt.ylabel(\"records / s\")\n", + "plt.title(\"Batch stress test throughput (red = degraded mode)\"); plt.show()\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 08 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + " 상황 수신 처리 격리 유실 다운 처리량(건/초) 성능저하 모드\n", + " 평시 40건(기준) 40 40 0 0 0 23161 -\n", + " 500% 증가 (240건) 240 240 0 0 0 21576 -\n", + "240건 + 손상·잡음 로그 240 229 11 0 0 22027 -\n", + "240건 + 처리 예산 부족 240 240 0 0 0 36834 ML 지연처리\n", + "\n", + "시스템 다운 0건, 데이터 유실률 0.0% (목표 0%), 격리 로그는 원본 보관 후 사유와 함께 재검토\n", + "메모리(동일 배치 5회 반복, MB): [0.1, 0.2, 0.2, 0.3, 0.4] -> 증가 폭 0.3 MB (누수 없음 판단 기준 5MB 이내)\n", + "병목 구간(누적 처리시간 비율): {'전처리': '58%', '특징': '26%', '규칙': '12%', 'ML': '4%'}\n", + "환산: 1만 비행시간(3,600만 건) 처리 예상 0.5시간(단일 코어) -> 전처리 병렬화·비동기 대기열로 단축 필요\n", + "```\n", + "
\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 09. AAR 기반 비행 이상 오탐·미탐 사례 분석 및 이상 탐지 임계치 최적화\n", + "**소요시간**: 약 3.0시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "AAR 사후검토를 통해 비행로그 이상치 오탐 사례를 분석하고 정상 비행과 적대 비행의 경계 파라미터를 재보정한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **AAR 사후검토로 비행 이상 오탐·미탐 사례의 원인을 분석하고 정상 비행과 의심 비행의 경계 파라미터를 재보정한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **'놓치지 않기'를 우선한 초기 설정으로 모의 운용한 뒤 오탐은 원인(측량 U턴, 강풍 돌풍 하강, 드문 정상 패턴)별로, 미탐은 시나리오별로 분류한 AAR 일지 작성하여 원인을 분석하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `docs/isr03_aar_report.md 및 configs/isr03_tuned_params.yaml` 형태의 결과물을 완성한다.\n", + "\n", + "### 🔍 평가 및 검증 기준 (PASS 조건)\n", + "#### 미션 판정\n", + "\n", + "**평가 구분:** 필수\n", + "\n", + "**핵심 평가영역:** 오류 원인 규명의 객관성 및 개선 조치의 논리적 타당성\n", + "\n", + "#### 미션 PASS 조건\n", + "\n", + "- 정량 기준: AAR 피드백 적용 후 종합 정확도 10%p 이상 향상, 재발 오류 0건\n", + "\n", + "\n", + "- 진행 기준: AAR 보고서 작성 및 파라미터 튜닝 전후 회귀 테스트 통과\n", + "\n", + "\n", + "- 검증 방법: 회귀 테스트(Regression Test) 스위트 전체 자동 실행 및 비교\n", + "\n", + "\n", + "#### 채점자 판정 메모\n", + "\n", + "> 단순 코드 실행 여부뿐만 아니라 출력된 수치와 근거 자료가 군 실무 요구사항에 부합하는지 면밀히 확인한다.\n", + "\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "\"\"\"AAR(사후검토) 일지 형식으로 모의 운용 중 발생한 비행 이상 오탐·미탐 사례를 원인별로 분류하고, 급선회·급강하 규칙 임계값과 ML 이상 점수 임계값을 격자 탐색으로 재보정해 정상 비행과 적대 의심 비행의 경계를 조정한 뒤 회귀 시험으로 개선 효과와 재발 여부를 확인한다.\"\"\"\n", + "import math\n", + "import numpy as np\n", + "import pandas as pd\n", + "import matplotlib.pyplot as plt\n", + "from sklearn.cluster import KMeans\n", + "from sklearn.ensemble import IsolationForest\n", + "from sklearn.preprocessing import StandardScaler\n", + "\n", + "# ---------------------------------------------------------------------------\n", + "# [교육용가상자료] 합성 드론 비행로그 생성기 (실제 좌표·기체·부대 정보 없음)\n", + "# 가상 구역(원점 기준 동·북 방향 m 좌표)에서 1초 간격 비행로그와 정답(이상 구간·운용 주체)을 만든다.\n", + "# ---------------------------------------------------------------------------\n", + "NFZ = {\"x\": 2500.0, \"y\": 2500.0, \"r\": 700.0} # 가상 비인가 비행구역\n", + "WATCH = {\"x\": -2000.0, \"y\": 1500.0, \"r\": 600.0} # 가상 관심 구역\n", + "SCENARIOS = [\"sharp_turn\", \"hover\", \"dive\", \"deviation\", \"nfz_entry\",\n", + " \"low_level\", \"rc_silent\", \"infiltration\", \"loiter\", \"speed_burst\"]\n", + "OPERATORS = [\"friendly\", \"civil\", \"hostile\"]\n", + "# 시나리오별 운용 주체 분포: 일부러 크게 겹치게 설계했다(비행 특성만으로 운용 주체를 단정할 수 없다).\n", + "OPERATOR_P = {\"normal\": (.45, .47, .08), \"survey\": (.15, .82, .03), \"sharp_turn\": (.40, .35, .25),\n", + " \"hover\": (.25, .45, .30), \"dive\": (.25, .25, .50), \"deviation\": (.30, .40, .30),\n", + " \"nfz_entry\": (.10, .50, .40), \"low_level\": (.35, .15, .50), \"rc_silent\": (.30, .15, .55),\n", + " \"infiltration\": (.12, .08, .80), \"loiter\": (.30, .20, .50), \"speed_burst\": (.30, .20, .50)}\n", + "PLATFORM_P = {\"friendly\": (.40, .55, .05), \"civil\": (.88, .02, .10), \"hostile\": (.55, .30, .15)} # commercial/military/unknown\n", + "FIRMWARE_P = {\"friendly\": (.30, .65, .05), \"civil\": (.85, .10, .05), \"hostile\": (.20, .50, .30)} # stock/custom/unknown\n", + "\n", + "\n", + "def _wrap(a):\n", + " return (a + 180.0) % 360.0 - 180.0\n", + "\n", + "\n", + "def _bearing(p, q):\n", + " return math.degrees(math.atan2(q[0] - p[0], q[1] - p[1])) % 360.0\n", + "\n", + "\n", + "def _away_from_nfz(rng, min_d):\n", + " while True:\n", + " c = rng.uniform(-3800, 3800, 2)\n", + " if np.hypot(c[0] - NFZ[\"x\"], c[1] - NFZ[\"y\"]) > min_d:\n", + " return c\n", + "\n", + "\n", + "def make_flight(fid, kind, rng, n=300, weather=None):\n", + " \"\"\"비행 1회 = (로그 DataFrame, 메타 dict). kind: normal / survey / SCENARIOS 중 하나\"\"\"\n", + " weather = weather or str(rng.choice([\"clear\", \"night\", \"windy\", \"rain\"], p=[.4, .25, .2, .15]))\n", + " wind = {\"clear\": 1.0, \"night\": 1.0, \"windy\": 2.5, \"rain\": 1.8}[weather]\n", + " v0, alt0 = rng.uniform(11, 17), rng.uniform(70, 150)\n", + " nfz_c = np.array([NFZ[\"x\"], NFZ[\"y\"]])\n", + " if kind == \"infiltration\": # 원거리에서 비인가 구역 방향으로 저고도 직진\n", + " ang = rng.uniform(0, 2 * np.pi)\n", + " start = nfz_c + 4000 * np.array([np.cos(ang), np.sin(ang)])\n", + " plan = np.array([start, nfz_c])\n", + " v0, alt0 = rng.uniform(9, 11), rng.uniform(25, 35)\n", + " else:\n", + " if kind == \"nfz_entry\": # 비인가 구역 1.6km 밖에서 순찰하다가 진입\n", + " ang = rng.uniform(0, 2 * np.pi)\n", + " c = nfz_c + 1600 * np.array([np.cos(ang), np.sin(ang)])\n", + " else:\n", + " c = _away_from_nfz(rng, 2600)\n", + " if kind == \"survey\": # 민간 측량형 왕복(지그재그) 경로\n", + " w, h = rng.uniform(500, 700), 120.0\n", + " plan = np.array([c + [(-w if i % 4 in (0, 3) else w), i // 2 * h - 360] for i in range(8)])\n", + " else: # 사각 순찰 경로(웨이포인트 4개)\n", + " a, b, th = rng.uniform(300, 600), rng.uniform(200, 400), rng.uniform(0, np.pi)\n", + " R = np.array([[np.cos(th), -np.sin(th)], [np.sin(th), np.cos(th)]])\n", + " plan = c + np.array([[-a, -b], [a, -b], [a, b], [-a, b]]) @ R.T\n", + " s0 = int(rng.integers(80, 160))\n", + " span = {\"sharp_turn\": 30, \"hover\": 60, \"dive\": 8, \"deviation\": 70, \"nfz_entry\": 120, \"low_level\": 120,\n", + " \"rc_silent\": 120, \"infiltration\": n - 20, \"loiter\": 90, \"speed_burst\": 25}.get(kind, 0)\n", + " if kind == \"infiltration\":\n", + " s0 = 20\n", + " win = (s0, s0 + span)\n", + " turns = set(rng.choice(np.arange(s0, s0 + 30), 3, replace=False).tolist()) if kind == \"sharp_turn\" else set()\n", + " # 난수를 미리 한 번에 뽑아 두고(속도 향상) 1초 단위로 기체 운동을 적분한다.\n", + " e_h, e_v, e_a = rng.normal(0, 0.8 * wind, n), rng.normal(0, 0.3 * wind, n), rng.normal(0, 0.4 * wind, n)\n", + " rc_arr = np.clip(rng.normal(rng.uniform(60, 92), 6, n), 0, 100)\n", + " gust = (rng.random(n) < (0.004 if weather == \"windy\" else 0.0)) * rng.uniform(20, 35, n)\n", + " burst, turn_amt = rng.uniform(23, 28), rng.choice([-1, 1], n) * rng.uniform(100, 160, n)\n", + " side = float(rng.choice([-90, 90]))\n", + " low_alt, rng_rc = rng.uniform(35, 55), rng.uniform(0, 20, n) # 저고도 목표, 조종신호 두절 시 잔여 수신값\n", + " x, y, wi = float(plan[0][0]), float(plan[0][1]), 1\n", + " hdg, alt = _bearing((x, y), plan[1]), alt0\n", + " max_rate = 40.0 if kind == \"survey\" else 12.0\n", + " dev_target, X, Y, A, V, HD = None, np.zeros(n), np.zeros(n), np.zeros(n), np.zeros(n), np.zeros(n)\n", + " for t in range(n):\n", + " inw = win[0] <= t < win[1]\n", + " tx, ty = plan[1] if kind == \"infiltration\" else plan[wi % len(plan)]\n", + " spd_cmd, alt_cmd = v0, alt0\n", + " if inw:\n", + " if kind == \"deviation\":\n", + " if dev_target is None:\n", + " r = math.radians(hdg + side)\n", + " dev_target = (x + 900 * math.sin(r), y + 900 * math.cos(r))\n", + " tx, ty = dev_target\n", + " elif kind == \"nfz_entry\":\n", + " tx, ty = NFZ[\"x\"], NFZ[\"y\"]\n", + " elif kind == \"hover\":\n", + " spd_cmd = 0.0\n", + " elif kind == \"speed_burst\":\n", + " spd_cmd = burst\n", + " elif kind == \"low_level\":\n", + " alt_cmd = low_alt\n", + " if kind in (\"rc_silent\", \"infiltration\"):\n", + " rc_arr[t] = rng_rc[t]\n", + " if inw and kind == \"loiter\":\n", + " hdg = (hdg + 9.0 + e_h[t] / wind) % 360\n", + " else:\n", + " d = _wrap(math.degrees(math.atan2(tx - x, ty - y)) - hdg)\n", + " hdg = (hdg + max(-max_rate, min(max_rate, d)) + e_h[t]) % 360\n", + " if t in turns:\n", + " hdg = (hdg + turn_amt[t]) % 360\n", + " v = abs(spd_cmd + e_v[t]) if spd_cmd > 0 else abs(e_v[t] / wind)\n", + " x += v * math.sin(math.radians(hdg)); y += v * math.cos(math.radians(hdg))\n", + " if kind != \"infiltration\" and math.hypot(x - tx, y - ty) < 40 and not (inw and kind in (\"deviation\", \"nfz_entry\")):\n", + " wi += 1\n", + " if inw and kind == \"dive\":\n", + " alt -= 9.0 + e_v[t]\n", + " else:\n", + " alt += max(-3.0, min(3.0, alt_cmd - alt)) + e_a[t]\n", + " alt -= gust[t] # 돌풍 하강기류(정상 비행에서도 발생)\n", + " if kind == \"dive\" and t >= win[1]:\n", + " alt0 = min(alt0, alt)\n", + " alt = max(alt, 3.0)\n", + " X[t], Y[t], A[t], V[t], HD[t] = x, y, alt, v, hdg\n", + " gps_sd = {\"clear\": 2.0, \"night\": 2.0, \"windy\": 3.0, \"rain\": 4.5}[weather] # 악천후일수록 GPS 잡음(다중경로) 증가\n", + " df = pd.DataFrame({\"t\": np.arange(n), \"x\": X + rng.normal(0, gps_sd, n), \"y\": Y + rng.normal(0, gps_sd, n),\n", + " \"alt\": A + rng.normal(0, 0.5, n), \"speed\": V + rng.normal(0, 0.2, n),\n", + " \"heading\": (HD + rng.normal(0, 1.5, n)) % 360, \"rc\": rc_arr.round(1),\n", + " \"sats\": rng.integers(10, 15, n), \"hdop\": rng.uniform(0.6, 1.2, n).round(2),\n", + " \"x_true\": X, \"y_true\": Y})\n", + " df[\"anomaly\"] = (df[\"t\"] >= win[0]) & (df[\"t\"] < win[1]) & (kind in SCENARIOS)\n", + " op = OPERATORS[int(rng.choice(3, p=OPERATOR_P[kind]))]\n", + " meta = {\"id\": fid, \"kind\": kind, \"weather\": weather, \"operator\": op, \"plan\": plan, \"window\": win if kind in SCENARIOS else None,\n", + " \"platform\": [\"commercial\", \"military\", \"unknown\"][int(rng.choice(3, p=PLATFORM_P[op]))],\n", + " \"firmware\": [\"stock\", \"custom\", \"unknown\"][int(rng.choice(3, p=FIRMWARE_P[op]))]}\n", + " return df, meta\n", + "\n", + "\n", + "def corrupt(df, rng, spoof=True, emi=True, drop=True, dup=True):\n", + " \"\"\"전장 잡음 주입: GPS 스푸핑(위치 점프·표류), 전자기 간섭(고도 스파이크), 센서 단절(결측), 중복 기록.\"\"\"\n", + " d = df.copy()\n", + " d[\"spoof_true\"] = False\n", + " if spoof:\n", + " a = int(rng.integers(40, len(d) - 60)); L = int(rng.integers(25, 45))\n", + " th = rng.uniform(0, 2 * np.pi)\n", + " drift = rng.uniform(22, 28) * np.arange(L)[:, None] * np.array([np.cos(th), np.sin(th)])\n", + " jump = rng.uniform(250, 400) * np.array([np.cos(th + 1), np.sin(th + 1)])\n", + " d.loc[a:a + L - 1, [\"x\", \"y\"]] += drift + jump\n", + " d.loc[a:a + L - 1, \"spoof_true\"] = True\n", + " if emi:\n", + " idx = rng.choice(len(d), 6, replace=False)\n", + " d.loc[idx, \"alt\"] += rng.choice([-1, 1], 6) * rng.uniform(60, 150, 6)\n", + " if drop:\n", + " a = int(rng.integers(10, len(d) - 20))\n", + " d.loc[a:a + int(rng.integers(4, 9)), [\"x\", \"y\", \"alt\", \"speed\", \"heading\"]] = np.nan\n", + " d = d.drop(index=rng.choice(len(d), 5, replace=False))\n", + " if dup:\n", + " d = pd.concat([d, d.sample(5, random_state=int(rng.integers(1e6)))]).sort_values(\"t\", kind=\"stable\")\n", + " return d.reset_index(drop=True)\n", + "\n", + "\n", + "def make_dataset(n=200, seed=7, anomaly_ratio=0.5, scenarios=SCENARIOS):\n", + " rng = np.random.default_rng(seed)\n", + " out = []\n", + " for i in range(n):\n", + " if i % 10 < anomaly_ratio * 10:\n", + " kind = scenarios[(i // 10 * int(anomaly_ratio * 10) + i % 10) % len(scenarios)]\n", + " else:\n", + " kind = \"survey\" if i % 10 == 9 else \"normal\"\n", + " df, meta = make_flight(f\"FL{i:04d}\", kind, rng)\n", + " out.append((df, meta))\n", + " return out\n", + "# ---------------------------------------------------------------------------\n", + "\n", + "# ---- 전처리·규칙 탐지 공통 함수 ----\n", + "SPOOF_THR = {\"clear\": 12.0, \"night\": 12.0, \"windy\": 15.0, \"rain\": 19.0} # 기상별 GPS-추측항법 불일치 허용치(m)\n", + "\n", + "\n", + "def clean(df, weather=\"clear\"):\n", + " \"\"\"전처리: 중복 제거·정렬 -> 1초 재표본화 -> 결측 보간 -> 고도 스파이크 제거 -> GPS 스푸핑 구간 추측항법 보정.\"\"\"\n", + " d = df.drop_duplicates(\"t\").sort_values(\"t\").set_index(\"t\")\n", + " d = d.reindex(np.arange(int(d.index.min()), int(d.index.max()) + 1))\n", + " n_missing = int(d[\"x\"].isna().sum())\n", + " num = [\"x\", \"y\", \"alt\", \"speed\", \"rc\", \"sats\", \"hdop\"]\n", + " d[num] = d[num].interpolate(limit_direction=\"both\")\n", + " hd = d[\"heading\"].dropna() # 방위는 0/360 경계를 풀어서 보간\n", + " un = pd.Series(np.degrees(np.unwrap(np.radians(hd.to_numpy(float)))), index=hd.index)\n", + " d[\"heading\"] = un.reindex(d.index).interpolate(limit_direction=\"both\").to_numpy() % 360\n", + " med = d[\"alt\"].rolling(5, center=True, min_periods=1).median()\n", + " spike = (d[\"alt\"] - med).abs() > 25\n", + " d.loc[spike, \"alt\"] = med[spike]\n", + " x, y = d[\"x\"].to_numpy(float), d[\"y\"].to_numpy(float)\n", + " r = np.radians(d[\"heading\"].to_numpy(float)); v = d[\"speed\"].to_numpy(float)\n", + " dr = np.c_[v * np.sin(r), v * np.cos(r)] # 보고 속도·방위로 계산한 1초 이동량(추측항법)\n", + " xc, yc, flag = x.copy(), y.copy(), np.zeros(len(x), bool)\n", + " thr = SPOOF_THR.get(weather, 12.0)\n", + " ok_run = 0\n", + " for i in range(1, len(x)):\n", + " px, py = xc[i - 1] + dr[i, 0], yc[i - 1] + dr[i, 1] # 추측항법 예상 위치\n", + " step_ok = np.hypot(x[i] - x[i - 1] - dr[i, 0], y[i] - y[i - 1] - dr[i, 1]) <= thr\n", + " ok_run = ok_run + 1 if step_ok else 0\n", + " if flag[i - 1]:\n", + " # 스푸핑 구간: GPS가 예상 위치로 돌아오거나, GPS 이동량이 5초 연속 물리적으로 맞으면 GPS로 복귀\n", + " if np.hypot(x[i] - px, y[i] - py) < thr * 2 or ok_run >= 5:\n", + " xc[i], yc[i] = x[i], y[i]\n", + " else:\n", + " xc[i], yc[i], flag[i] = px, py, True\n", + " elif not step_ok:\n", + " xc[i], yc[i], flag[i] = px, py, True # GPS 이동량이 물리적으로 맞지 않음 -> 스푸핑 의심\n", + " else:\n", + " xc[i], yc[i] = xc[i - 1] + x[i] - x[i - 1], yc[i - 1] + y[i] - y[i - 1]\n", + " d[\"x\"], d[\"y\"], d[\"spoof_flag\"] = xc, yc, flag\n", + " d = d.reset_index()\n", + " d.attrs.update({\"missing\": n_missing, \"spikes\": int(spike.sum()), \"spoof_pts\": int(flag.sum())})\n", + " return d\n", + "\n", + "\n", + "def xtrack(px, py, plan):\n", + " \"\"\"계획 경로(웨이포인트 폐곡선) 대비 이탈 거리(m).\"\"\"\n", + " P = np.asarray(plan, float)\n", + " segs = [(P[i], P[(i + 1) % len(P)]) for i in range(len(P) if len(P) > 2 else 1)]\n", + " best = np.full(len(px), np.inf)\n", + " for a, b in segs:\n", + " ab = b - a\n", + " u = np.clip(((px - a[0]) * ab[0] + (py - a[1]) * ab[1]) / max(ab @ ab, 1e-9), 0, 1)\n", + " best = np.minimum(best, np.hypot(px - a[0] - u * ab[0], py - a[1] - u * ab[1]))\n", + " return best\n", + "\n", + "\n", + "RULES = {\"turn_rate\": 45.0, \"hover_speed\": 1.5, \"hover_sec\": 20, \"dive_m\": 30.0, \"xtrack_m\": 250.0}\n", + "\n", + "\n", + "def rule_flags(d, plan, R=RULES):\n", + " \"\"\"규칙 기반 이상 비행 판정(기록 단위). 급선회·호버링·급강하·경로 이탈·비인가 구역 진입.\"\"\"\n", + " hr = np.abs(_wrap(np.diff(d[\"heading\"].to_numpy(float), prepend=d[\"heading\"].iloc[0])))\n", + " slow = (d[\"speed\"] < R[\"hover_speed\"]).to_numpy()\n", + " run = np.zeros(len(slow), int)\n", + " for i in range(len(slow)):\n", + " run[i] = run[i - 1] + 1 if slow[i] and i else int(slow[i])\n", + " hover = np.zeros(len(slow), bool)\n", + " for i in np.where(run >= R[\"hover_sec\"])[0]:\n", + " hover[i - R[\"hover_sec\"] + 1:i + 1] = True\n", + " alt = d[\"alt\"].to_numpy(float)\n", + " drop = alt - np.r_[alt[5:], np.full(5, alt[-1])]\n", + " return {\"급선회\": hr > R[\"turn_rate\"], \"호버링\": hover, \"급강하\": drop > R[\"dive_m\"],\n", + " \"경로이탈\": xtrack(d[\"x\"].to_numpy(float), d[\"y\"].to_numpy(float), plan) > R[\"xtrack_m\"],\n", + " \"비인가구역\": np.hypot(d[\"x\"] - NFZ[\"x\"], d[\"y\"] - NFZ[\"y\"]).to_numpy() < NFZ[\"r\"]}\n", + "\n", + "# ---- 구간(30초 창) 특징 + 비지도 이상 탐지 공통 함수 ----\n", + "WFEAT = [\"alt_mean\", \"alt_min\", \"alt_std\", \"vz_min\", \"spd_mean\", \"spd_std\", \"spd_max\", \"turn_sum\", \"turn_max\",\n", + " \"straight\", \"rc_mean\", \"rc_zero\", \"nfz_min\", \"nfz_close\", \"xtrack_max\"]\n", + "\n", + "\n", + "def window_feats(d, plan, win=30, step=10):\n", + " \"\"\"30초 창을 10초씩 옮기며 고도·속도·선회·직진성·조종신호·비인가 구역 접근·경로 이탈 특징을 계산한다.\"\"\"\n", + " x, y, alt = d[\"x\"].to_numpy(float), d[\"y\"].to_numpy(float), d[\"alt\"].to_numpy(float)\n", + " spd, rc = d[\"speed\"].to_numpy(float), d[\"rc\"].to_numpy(float)\n", + " hr = np.abs(_wrap(np.diff(d[\"heading\"].to_numpy(float), prepend=d[\"heading\"].iloc[0])))\n", + " vz = np.diff(alt, prepend=alt[0])\n", + " nd = np.hypot(x - NFZ[\"x\"], y - NFZ[\"y\"])\n", + " xt = xtrack(x, y, plan)\n", + " step_len = np.hypot(np.diff(x, prepend=x[0]), np.diff(y, prepend=y[0]))\n", + " rows, starts = [], []\n", + " for s in range(0, len(d) - win + 1, step):\n", + " e = s + win\n", + " path = step_len[s + 1:e].sum()\n", + " rows.append([alt[s:e].mean(), alt[s:e].min(), alt[s:e].std(), vz[s:e].min(), spd[s:e].mean(), spd[s:e].std(),\n", + " spd[s:e].max(), hr[s:e].sum(), hr[s:e].max(), np.hypot(x[e - 1] - x[s], y[e - 1] - y[s]) / max(path, 1.0),\n", + " rc[s:e].mean(), (rc[s:e] < 5).mean(), nd[s:e].min(), (nd[s] - nd[e - 1]) / win, xt[s:e].max()])\n", + " starts.append(int(d[\"t\"].iloc[s]))\n", + " return np.array(rows), starts\n", + "\n", + "\n", + "class FlightAnomalyModel:\n", + " \"\"\"평시 비행로그(라벨 없음)로 통상 패턴 군집(KMeans)과 고립 숲(IsolationForest)을 학습하고,\n", + " 두 점수를 학습 구간 분위수로 나눠 1.0을 넘으면 '통상 패턴에서 벗어남'으로 본다.\"\"\"\n", + "\n", + " def __init__(self, q=0.999, k=4):\n", + " self.q, self.k = q, k\n", + "\n", + " def fit(self, X):\n", + " self.sc = StandardScaler().fit(X)\n", + " Z = self.sc.transform(X)\n", + " self.iso = IsolationForest(n_estimators=150, random_state=0).fit(Z)\n", + " self.km = KMeans(self.k, n_init=5, random_state=0).fit(Z)\n", + " self.set_q(self.q, X)\n", + " return self\n", + "\n", + " def _raw(self, X):\n", + " Z = self.sc.transform(X)\n", + " return -self.iso.score_samples(Z), self.km.transform(Z).min(axis=1)\n", + "\n", + " def set_q(self, q, X):\n", + " a, b = self._raw(X)\n", + " self.q, self.t_if, self.t_km = q, np.quantile(a, q), np.quantile(b, q)\n", + "\n", + " def score(self, X):\n", + " a, b = self._raw(X)\n", + " return np.maximum(a / self.t_if, b / self.t_km)\n", + "\n", + "\n", + "AFEAT = [\"ml_score\", \"low_alt\", \"rc_weak\", \"nfz_min_km\", \"hover_s\", \"max_speed\", \"night\",\n", + " \"pf_commercial\", \"pf_military\", \"pf_unknown\", \"fw_stock\", \"fw_custom\", \"fw_unknown\"]\n", + "\n", + "\n", + "def attr_features(d, m, ml_score):\n", + " \"\"\"운용 주체 추정용 비행 단위 특징. 기체·펌웨어 정보는 상용 기체가 모든 주체에 쓰이므로 약한 단서일 뿐이다.\"\"\"\n", + " return [min(ml_score, 10.0), float((d[\"alt\"] < 60).mean()), float((d[\"rc\"] < 25).mean()),\n", + " float(np.hypot(d[\"x\"] - NFZ[\"x\"], d[\"y\"] - NFZ[\"y\"]).min() / 1000), float((d[\"speed\"] < 1.5).sum()),\n", + " float(d[\"speed\"].max()), float(m[\"weather\"] == \"night\"),\n", + " *[float(m[\"platform\"] == p) for p in (\"commercial\", \"military\", \"unknown\")],\n", + " *[float(m[\"firmware\"] == f) for f in (\"stock\", \"custom\", \"unknown\")]]\n", + "\n", + "\n", + "def flight_stats(data, model):\n", + " \"\"\"비행마다 판단에 필요한 값(최대 선회율, 5초 최대 하강, 기타 규칙, ML 최대 점수)을 한 번 계산해 둔다.\"\"\"\n", + " rows = []\n", + " for df, m in data:\n", + " d = clean(df, m[\"weather\"])\n", + " f = rule_flags(d, m[\"plan\"], {**RULES, \"turn_rate\": 1e9, \"dive_m\": 1e9})\n", + " alt = d[\"alt\"].to_numpy()\n", + " rows.append({\"id\": m[\"id\"], \"kind\": m[\"kind\"], \"weather\": m[\"weather\"], \"truth\": m[\"kind\"] in SCENARIOS,\n", + " \"turn\": float(np.abs(_wrap(np.diff(d[\"heading\"].to_numpy()))).max()),\n", + " \"drop\": float((alt[:-5] - alt[5:]).max()),\n", + " \"other\": bool(f[\"호버링\"].any() or f[\"경로이탈\"].any() or f[\"비인가구역\"].any()),\n", + " \"ml\": float(model.score(window_feats(d, m[\"plan\"])[0]).max())})\n", + " return pd.DataFrame(rows)\n", + "\n", + "\n", + "def predict(S, c):\n", + " S = S.copy()\n", + " S[\"r_turn\"], S[\"r_dive\"], S[\"r_ml\"] = S[\"turn\"] > c[\"turn\"], S[\"drop\"] > c[\"dive\"], S[\"ml\"] > c[\"ml\"]\n", + " S[\"pred\"] = S[\"r_turn\"] | S[\"r_dive\"] | S[\"r_ml\"] | S[\"other\"]\n", + " return S\n", + "\n", + "\n", + "def summarize(S):\n", + " tp, fp = (S.truth & S.pred).sum(), (~S.truth & S.pred).sum()\n", + " return {\"정확도\": round(float((S.truth == S.pred).mean()), 3), \"재현율\": round(float(tp / S.truth.sum()), 3),\n", + " \"오경보율\": round(float(fp / max(S.pred.sum(), 1)), 3), \"정상 오탐\": int(fp), \"미탐\": int((S.truth & ~S.pred).sum())}\n", + "\n", + "\n", + "def aar_log(S):\n", + " rows = []\n", + " for _, r in S[~S.truth & S.pred].iterrows():\n", + " cause = (\"측량 U턴 급선회\" if r.kind == \"survey\" else \"선회\") if r.r_turn else (\"돌풍 하강(\" + r.weather + \")\" if r.r_dive else \"드문 정상 패턴(ML)\")\n", + " rows.append({\"구분\": \"오탐\", \"원인\": cause, \"비행\": r.id})\n", + " for _, r in S[S.truth & ~S.pred].iterrows():\n", + " rows.append({\"구분\": \"미탐\", \"원인\": f\"{r.kind} 점수 미달\", \"비행\": r.id})\n", + " return pd.DataFrame(rows, columns=[\"구분\", \"원인\", \"비행\"])\n", + "\n", + "\n", + "def windows(data):\n", + " return np.vstack([window_feats(clean(df, m[\"weather\"]), m[\"plan\"])[0] for df, m in data])\n", + "\n", + "\n", + "base_X = windows(make_dataset(80, 21, anomaly_ratio=0.0))\n", + "model = FlightAnomalyModel(q=0.995).fit(base_X)\n", + "aar_data, hold_data = make_dataset(200, seed=11), make_dataset(200, seed=13)\n", + "aar = flight_stats(aar_data, model) # 모의 운용(AAR 대상)\n", + "hold = flight_stats(hold_data, model) # 회귀 시험용(재보정에 쓰지 않음)\n", + "\n", + "# 1) 초기 설정('놓치지 않기' 우선): 선회 30도/초, 하강 30m, ML 점수 1.0\n", + "init = {\"turn\": 30, \"dive\": 30, \"ml\": 1.0}\n", + "S0 = predict(aar, init)\n", + "before = summarize(S0)\n", + "log = aar_log(S0)\n", + "print(\"[1. 무엇이 일어났나] 초기 설정:\", before)\n", + "print(log.groupby([\"구분\", \"원인\"]).size().rename(\"건수\").to_string())\n", + "\n", + "# 2) 역환류 1: 오탐 원인(측량 U턴·강풍)이 평시 학습 자료에 적었음 -> 측량·강풍 평시 로그 60건을 추가 수집해 재학습\n", + "extra = [(df, m) for df, m in make_dataset(200, 22, anomaly_ratio=0.0) if m[\"kind\"] == \"survey\" or m[\"weather\"] == \"windy\"][:60]\n", + "model = FlightAnomalyModel(q=0.995).fit(np.vstack([base_X, windows(extra)]))\n", + "aar, hold = flight_stats(aar_data, model), flight_stats(hold_data, model)\n", + "print(f\"\\n[역환류] 평시 학습 로그 80건 + 측량·강풍 로그 {len(extra)}건으로 ML 재학습\")\n", + "\n", + "# 3) 역환류 2: 재현율 95% 이상 제약 안에서 정확도 최대 임계값 조합 탐색\n", + "grid = []\n", + "for t in [30, 45, 60, 90]:\n", + " for dv in [30, 40, 50]:\n", + " for mlt in [1.0, 1.2, 1.5, 2.0]:\n", + " grid.append({\"turn\": t, \"dive\": dv, \"ml\": mlt, **summarize(predict(aar, {\"turn\": t, \"dive\": dv, \"ml\": mlt}))})\n", + "g = pd.DataFrame(grid)\n", + "ok = g[g[\"재현율\"] >= 0.95].sort_values([\"정확도\", \"ml\"], ascending=[False, True])\n", + "best = ok.iloc[0][[\"turn\", \"dive\", \"ml\"]].to_dict()\n", + "print(\"\\n[2. 무엇을 바꾸나] 격자 탐색 상위 5개\")\n", + "print(ok.head().to_string(index=False))\n", + "\n", + "# 3) 재보정 전후 비교(모의 운용 자료 + 회귀 시험 자료)\n", + "S1 = predict(aar, best)\n", + "after = summarize(S1)\n", + "print(f\"\\n재보정값: 선회율 {best['turn']:.0f}도/초, 급강하 {best['dive']:.0f}m, ML 점수 {best['ml']:.1f}\")\n", + "print(\"재보정 후:\", after)\n", + "print(f\"종합 정확도 {before['정확도']:.3f} -> {after['정확도']:.3f} ({(after['정확도'] - before['정확도']) * 100:+.1f}%p, 목표 +10%p: {'달성' if after['정확도'] - before['정확도'] >= 0.1 else '미달'})\")\n", + "h0, h1 = summarize(predict(hold, init)), summarize(predict(hold, best))\n", + "print(f\"회귀 시험(별도 200건): 정확도 {h0['정확도']:.3f} -> {h1['정확도']:.3f}, 재현율 {h0['재현율']:.3f} -> {h1['재현율']:.3f}\")\n", + "print(\"재보정 후 미탐:\", S1[S1.truth & ~S1.pred][\"kind\"].tolist(), \"/ 오탐:\", S1[~S1.truth & S1.pred][\"kind\"].tolist())\n", + "repeat = set(log[\"비행\"]) & set(aar_log(S1)[\"비행\"])\n", + "print(f\"재보정 후에도 반복된 오류: {len(repeat)}건 {sorted(repeat)[:6]}\")\n", + "print(\"[3. 남은 과제] 반복 오류는 규칙 임계값만으로 풀리지 않는 경우로, 해당 로그를 평시 학습 자료에 추가(지속 학습)해 재학습 대상으로 등록한다.\")\n", + "\n", + "tuned = f\"rules:\\n turn_rate_dps: {best['turn']:.0f}\\n dive_drop_m: {best['dive']:.0f}\\nml:\\n quantile: 0.995\\n score_threshold: {best['ml']:.1f}\\nconstraints: {{min_recall: 0.95}}\"\n", + "print(\"\\n[재보정 파라미터(isr03_tuned_params.yaml 내용)]\\n\" + tuned)\n", + "\n", + "plt.figure(figsize=(6, 4))\n", + "for t in sorted(g[\"turn\"].unique()):\n", + " s = g[(g[\"turn\"] == t) & (g[\"dive\"] == best[\"dive\"])]\n", + " plt.plot(s[\"ml\"], s[\"정확도\"], \"o-\", label=f\"turn>{t} deg/s\")\n", + "plt.xlabel(\"ML score threshold\"); plt.ylabel(\"accuracy\"); plt.legend(); plt.title(\"AAR re-tuning grid (synthetic)\")\n", + "plt.show()\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 09 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + "[1. 무엇이 일어났나] 초기 설정: {'정확도': 0.865, '재현율': 1.0, '오경보율': 0.213, '정상 오탐': 27, '미탐': 0}\n", + "구분 원인 \n", + "오탐 돌풍 하강(windy) 7\n", + " 측량 U턴 급선회 20\n", + "\n", + "[역환류] 평시 학습 로그 80건 + 측량·강풍 로그 55건으로 ML 재학습\n", + "\n", + "[2. 무엇을 바꾸나] 격자 탐색 상위 5개\n", + " turn dive ml 정확도 재현율 오경보율 정상 오탐 미탐\n", + " 60 40 1.2 0.980 0.98 0.020 2 2\n", + " 60 50 1.2 0.980 0.98 0.020 2 2\n", + " 90 40 1.2 0.980 0.98 0.020 2 2\n", + " 90 50 1.2 0.980 0.98 0.020 2 2\n", + " 45 40 1.2 0.965 0.98 0.049 5 2\n", + "\n", + "재보정값: 선회율 60도/초, 급강하 40m, ML 점수 1.2\n", + "재보정 후: {'정확도': 0.98, '재현율': 0.98, '오경보율': 0.02, '정상 오탐': 2, '미탐': 2}\n", + "종합 정확도 0.865 -> 0.980 (+11.5%p, 목표 +10%p: 달성)\n", + "회귀 시험(별도 200건): 정확도 0.850 -> 0.980, 재현율 1.000 -> 0.960\n", + "재보정 후 미탐: ['low_level', 'low_level'] / 오탐: ['survey', 'survey']\n", + "재보정 후에도 반복된 오류: 2건 ['FL0019', 'FL0039']\n", + "[3. 남은 과제] 반복 오류는 규칙 임계값만으로 풀리지 않는 경우로, 해당 로그를 평시 학습 자료에 추가(지속 학습)해 재학습 대상으로 등록한다.\n", + "\n", + "[재보정 파라미터(isr03_tuned_params.yaml 내용)]\n", + "rules:\n", + " turn_rate_dps: 60\n", + " dive_drop_m: 40\n", + "ml:\n", + " quantile: 0.995\n", + " score_threshold: 1.2\n", + "constraints: {min_recall: 0.95}\n", + "```\n", + "
\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 10. 드론 비행로그 이상 탐지 시스템 통합 검증 및 ISR 정보판단 브리핑 패키지 완성\n", + "**소요시간**: 약 3.5시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "드론 비행로그 이상 탐지 시스템을 최종 통합 빌드하고 정보작전 브리핑용 최종 분석 보고서 및 사용자 가이드를 완성한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **로그 검증부터 전처리, 규칙·ML 탐지, 신뢰도 엔진, 검토 대기열, 대시보드까지 전체 시스템을 통합하고 ISR 정보판단 브리핑 패키지를 완성한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **재보정 파라미터로 전체 과정을 한 번에 실행하는 통합 코드를 완성하고 손상 로그가 자동 차단되는지 확인하고 유효성을 점검하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `src/main_isr03.py, README.md, docs/isr03_final_report.pdf(md)` 형태의 결과물을 완성한다.\n", + "\n", + "### 🔍 평가 및 검증 기준 (PASS 조건)\n", + "#### 미션 판정\n", + "\n", + "**평가 구분:** 필수\n", + "\n", + "**핵심 평가영역:** 군 실무 즉시 도입 가능성, 운용 신뢰도 및 전략적 기대효과\n", + "\n", + "#### 미션 PASS 조건\n", + "\n", + "- 정량 기준: End-to-End 통합 파이프라인 실행 성공률 100%, 테스트 커버리지 85% 이상\n", + "\n", + "\n", + "- 진행 기준: 원클릭 실행 스크립트 동작 확인 및 최종 발표 산출물 작성 완료\n", + "\n", + "\n", + "- 검증 방법: 최종 검증 위원회(평가관) 모의 시연 및 전체 기능 통합 검증\n", + "\n", + "\n", + "#### 채점자 판정 메모\n", + "\n", + "> 단순 코드 실행 여부뿐만 아니라 출력된 수치와 근거 자료가 군 실무 요구사항에 부합하는지 면밀히 확인한다.\n", + "\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "\"\"\"로그 검증·전처리·규칙 탐지·ML 이상 탐지·운용 주체 신뢰도 엔진·정보분석관 검토 대기열·대시보드까지 드론 비행로그 이상 탐지 시스템 전체를 한 파일로 통합 실행하고, 자체 점검(기능 커버리지 포함)과 ISR 정보판단 브리핑 요약을 출력한다.\"\"\"\n", + "import hashlib\n", + "import json\n", + "import math\n", + "import time\n", + "import numpy as np\n", + "import pandas as pd\n", + "import matplotlib.pyplot as plt\n", + "from sklearn.cluster import KMeans\n", + "from sklearn.ensemble import IsolationForest\n", + "from sklearn.linear_model import LogisticRegression\n", + "from sklearn.pipeline import make_pipeline\n", + "from sklearn.preprocessing import StandardScaler\n", + "\n", + "# ---------------------------------------------------------------------------\n", + "# [교육용가상자료] 합성 드론 비행로그 생성기 (실제 좌표·기체·부대 정보 없음)\n", + "# 가상 구역(원점 기준 동·북 방향 m 좌표)에서 1초 간격 비행로그와 정답(이상 구간·운용 주체)을 만든다.\n", + "# ---------------------------------------------------------------------------\n", + "NFZ = {\"x\": 2500.0, \"y\": 2500.0, \"r\": 700.0} # 가상 비인가 비행구역\n", + "WATCH = {\"x\": -2000.0, \"y\": 1500.0, \"r\": 600.0} # 가상 관심 구역\n", + "SCENARIOS = [\"sharp_turn\", \"hover\", \"dive\", \"deviation\", \"nfz_entry\",\n", + " \"low_level\", \"rc_silent\", \"infiltration\", \"loiter\", \"speed_burst\"]\n", + "OPERATORS = [\"friendly\", \"civil\", \"hostile\"]\n", + "# 시나리오별 운용 주체 분포: 일부러 크게 겹치게 설계했다(비행 특성만으로 운용 주체를 단정할 수 없다).\n", + "OPERATOR_P = {\"normal\": (.45, .47, .08), \"survey\": (.15, .82, .03), \"sharp_turn\": (.40, .35, .25),\n", + " \"hover\": (.25, .45, .30), \"dive\": (.25, .25, .50), \"deviation\": (.30, .40, .30),\n", + " \"nfz_entry\": (.10, .50, .40), \"low_level\": (.35, .15, .50), \"rc_silent\": (.30, .15, .55),\n", + " \"infiltration\": (.12, .08, .80), \"loiter\": (.30, .20, .50), \"speed_burst\": (.30, .20, .50)}\n", + "PLATFORM_P = {\"friendly\": (.40, .55, .05), \"civil\": (.88, .02, .10), \"hostile\": (.55, .30, .15)} # commercial/military/unknown\n", + "FIRMWARE_P = {\"friendly\": (.30, .65, .05), \"civil\": (.85, .10, .05), \"hostile\": (.20, .50, .30)} # stock/custom/unknown\n", + "\n", + "\n", + "def _wrap(a):\n", + " return (a + 180.0) % 360.0 - 180.0\n", + "\n", + "\n", + "def _bearing(p, q):\n", + " return math.degrees(math.atan2(q[0] - p[0], q[1] - p[1])) % 360.0\n", + "\n", + "\n", + "def _away_from_nfz(rng, min_d):\n", + " while True:\n", + " c = rng.uniform(-3800, 3800, 2)\n", + " if np.hypot(c[0] - NFZ[\"x\"], c[1] - NFZ[\"y\"]) > min_d:\n", + " return c\n", + "\n", + "\n", + "def make_flight(fid, kind, rng, n=300, weather=None):\n", + " \"\"\"비행 1회 = (로그 DataFrame, 메타 dict). kind: normal / survey / SCENARIOS 중 하나\"\"\"\n", + " weather = weather or str(rng.choice([\"clear\", \"night\", \"windy\", \"rain\"], p=[.4, .25, .2, .15]))\n", + " wind = {\"clear\": 1.0, \"night\": 1.0, \"windy\": 2.5, \"rain\": 1.8}[weather]\n", + " v0, alt0 = rng.uniform(11, 17), rng.uniform(70, 150)\n", + " nfz_c = np.array([NFZ[\"x\"], NFZ[\"y\"]])\n", + " if kind == \"infiltration\": # 원거리에서 비인가 구역 방향으로 저고도 직진\n", + " ang = rng.uniform(0, 2 * np.pi)\n", + " start = nfz_c + 4000 * np.array([np.cos(ang), np.sin(ang)])\n", + " plan = np.array([start, nfz_c])\n", + " v0, alt0 = rng.uniform(9, 11), rng.uniform(25, 35)\n", + " else:\n", + " if kind == \"nfz_entry\": # 비인가 구역 1.6km 밖에서 순찰하다가 진입\n", + " ang = rng.uniform(0, 2 * np.pi)\n", + " c = nfz_c + 1600 * np.array([np.cos(ang), np.sin(ang)])\n", + " else:\n", + " c = _away_from_nfz(rng, 2600)\n", + " if kind == \"survey\": # 민간 측량형 왕복(지그재그) 경로\n", + " w, h = rng.uniform(500, 700), 120.0\n", + " plan = np.array([c + [(-w if i % 4 in (0, 3) else w), i // 2 * h - 360] for i in range(8)])\n", + " else: # 사각 순찰 경로(웨이포인트 4개)\n", + " a, b, th = rng.uniform(300, 600), rng.uniform(200, 400), rng.uniform(0, np.pi)\n", + " R = np.array([[np.cos(th), -np.sin(th)], [np.sin(th), np.cos(th)]])\n", + " plan = c + np.array([[-a, -b], [a, -b], [a, b], [-a, b]]) @ R.T\n", + " s0 = int(rng.integers(80, 160))\n", + " span = {\"sharp_turn\": 30, \"hover\": 60, \"dive\": 8, \"deviation\": 70, \"nfz_entry\": 120, \"low_level\": 120,\n", + " \"rc_silent\": 120, \"infiltration\": n - 20, \"loiter\": 90, \"speed_burst\": 25}.get(kind, 0)\n", + " if kind == \"infiltration\":\n", + " s0 = 20\n", + " win = (s0, s0 + span)\n", + " turns = set(rng.choice(np.arange(s0, s0 + 30), 3, replace=False).tolist()) if kind == \"sharp_turn\" else set()\n", + " # 난수를 미리 한 번에 뽑아 두고(속도 향상) 1초 단위로 기체 운동을 적분한다.\n", + " e_h, e_v, e_a = rng.normal(0, 0.8 * wind, n), rng.normal(0, 0.3 * wind, n), rng.normal(0, 0.4 * wind, n)\n", + " rc_arr = np.clip(rng.normal(rng.uniform(60, 92), 6, n), 0, 100)\n", + " gust = (rng.random(n) < (0.004 if weather == \"windy\" else 0.0)) * rng.uniform(20, 35, n)\n", + " burst, turn_amt = rng.uniform(23, 28), rng.choice([-1, 1], n) * rng.uniform(100, 160, n)\n", + " side = float(rng.choice([-90, 90]))\n", + " low_alt, rng_rc = rng.uniform(35, 55), rng.uniform(0, 20, n) # 저고도 목표, 조종신호 두절 시 잔여 수신값\n", + " x, y, wi = float(plan[0][0]), float(plan[0][1]), 1\n", + " hdg, alt = _bearing((x, y), plan[1]), alt0\n", + " max_rate = 40.0 if kind == \"survey\" else 12.0\n", + " dev_target, X, Y, A, V, HD = None, np.zeros(n), np.zeros(n), np.zeros(n), np.zeros(n), np.zeros(n)\n", + " for t in range(n):\n", + " inw = win[0] <= t < win[1]\n", + " tx, ty = plan[1] if kind == \"infiltration\" else plan[wi % len(plan)]\n", + " spd_cmd, alt_cmd = v0, alt0\n", + " if inw:\n", + " if kind == \"deviation\":\n", + " if dev_target is None:\n", + " r = math.radians(hdg + side)\n", + " dev_target = (x + 900 * math.sin(r), y + 900 * math.cos(r))\n", + " tx, ty = dev_target\n", + " elif kind == \"nfz_entry\":\n", + " tx, ty = NFZ[\"x\"], NFZ[\"y\"]\n", + " elif kind == \"hover\":\n", + " spd_cmd = 0.0\n", + " elif kind == \"speed_burst\":\n", + " spd_cmd = burst\n", + " elif kind == \"low_level\":\n", + " alt_cmd = low_alt\n", + " if kind in (\"rc_silent\", \"infiltration\"):\n", + " rc_arr[t] = rng_rc[t]\n", + " if inw and kind == \"loiter\":\n", + " hdg = (hdg + 9.0 + e_h[t] / wind) % 360\n", + " else:\n", + " d = _wrap(math.degrees(math.atan2(tx - x, ty - y)) - hdg)\n", + " hdg = (hdg + max(-max_rate, min(max_rate, d)) + e_h[t]) % 360\n", + " if t in turns:\n", + " hdg = (hdg + turn_amt[t]) % 360\n", + " v = abs(spd_cmd + e_v[t]) if spd_cmd > 0 else abs(e_v[t] / wind)\n", + " x += v * math.sin(math.radians(hdg)); y += v * math.cos(math.radians(hdg))\n", + " if kind != \"infiltration\" and math.hypot(x - tx, y - ty) < 40 and not (inw and kind in (\"deviation\", \"nfz_entry\")):\n", + " wi += 1\n", + " if inw and kind == \"dive\":\n", + " alt -= 9.0 + e_v[t]\n", + " else:\n", + " alt += max(-3.0, min(3.0, alt_cmd - alt)) + e_a[t]\n", + " alt -= gust[t] # 돌풍 하강기류(정상 비행에서도 발생)\n", + " if kind == \"dive\" and t >= win[1]:\n", + " alt0 = min(alt0, alt)\n", + " alt = max(alt, 3.0)\n", + " X[t], Y[t], A[t], V[t], HD[t] = x, y, alt, v, hdg\n", + " gps_sd = {\"clear\": 2.0, \"night\": 2.0, \"windy\": 3.0, \"rain\": 4.5}[weather] # 악천후일수록 GPS 잡음(다중경로) 증가\n", + " df = pd.DataFrame({\"t\": np.arange(n), \"x\": X + rng.normal(0, gps_sd, n), \"y\": Y + rng.normal(0, gps_sd, n),\n", + " \"alt\": A + rng.normal(0, 0.5, n), \"speed\": V + rng.normal(0, 0.2, n),\n", + " \"heading\": (HD + rng.normal(0, 1.5, n)) % 360, \"rc\": rc_arr.round(1),\n", + " \"sats\": rng.integers(10, 15, n), \"hdop\": rng.uniform(0.6, 1.2, n).round(2),\n", + " \"x_true\": X, \"y_true\": Y})\n", + " df[\"anomaly\"] = (df[\"t\"] >= win[0]) & (df[\"t\"] < win[1]) & (kind in SCENARIOS)\n", + " op = OPERATORS[int(rng.choice(3, p=OPERATOR_P[kind]))]\n", + " meta = {\"id\": fid, \"kind\": kind, \"weather\": weather, \"operator\": op, \"plan\": plan, \"window\": win if kind in SCENARIOS else None,\n", + " \"platform\": [\"commercial\", \"military\", \"unknown\"][int(rng.choice(3, p=PLATFORM_P[op]))],\n", + " \"firmware\": [\"stock\", \"custom\", \"unknown\"][int(rng.choice(3, p=FIRMWARE_P[op]))]}\n", + " return df, meta\n", + "\n", + "\n", + "def corrupt(df, rng, spoof=True, emi=True, drop=True, dup=True):\n", + " \"\"\"전장 잡음 주입: GPS 스푸핑(위치 점프·표류), 전자기 간섭(고도 스파이크), 센서 단절(결측), 중복 기록.\"\"\"\n", + " d = df.copy()\n", + " d[\"spoof_true\"] = False\n", + " if spoof:\n", + " a = int(rng.integers(40, len(d) - 60)); L = int(rng.integers(25, 45))\n", + " th = rng.uniform(0, 2 * np.pi)\n", + " drift = rng.uniform(22, 28) * np.arange(L)[:, None] * np.array([np.cos(th), np.sin(th)])\n", + " jump = rng.uniform(250, 400) * np.array([np.cos(th + 1), np.sin(th + 1)])\n", + " d.loc[a:a + L - 1, [\"x\", \"y\"]] += drift + jump\n", + " d.loc[a:a + L - 1, \"spoof_true\"] = True\n", + " if emi:\n", + " idx = rng.choice(len(d), 6, replace=False)\n", + " d.loc[idx, \"alt\"] += rng.choice([-1, 1], 6) * rng.uniform(60, 150, 6)\n", + " if drop:\n", + " a = int(rng.integers(10, len(d) - 20))\n", + " d.loc[a:a + int(rng.integers(4, 9)), [\"x\", \"y\", \"alt\", \"speed\", \"heading\"]] = np.nan\n", + " d = d.drop(index=rng.choice(len(d), 5, replace=False))\n", + " if dup:\n", + " d = pd.concat([d, d.sample(5, random_state=int(rng.integers(1e6)))]).sort_values(\"t\", kind=\"stable\")\n", + " return d.reset_index(drop=True)\n", + "\n", + "\n", + "def make_dataset(n=200, seed=7, anomaly_ratio=0.5, scenarios=SCENARIOS):\n", + " rng = np.random.default_rng(seed)\n", + " out = []\n", + " for i in range(n):\n", + " if i % 10 < anomaly_ratio * 10:\n", + " kind = scenarios[(i // 10 * int(anomaly_ratio * 10) + i % 10) % len(scenarios)]\n", + " else:\n", + " kind = \"survey\" if i % 10 == 9 else \"normal\"\n", + " df, meta = make_flight(f\"FL{i:04d}\", kind, rng)\n", + " out.append((df, meta))\n", + " return out\n", + "# ---------------------------------------------------------------------------\n", + "\n", + "# ---- 전처리·규칙 탐지 공통 함수 ----\n", + "SPOOF_THR = {\"clear\": 12.0, \"night\": 12.0, \"windy\": 15.0, \"rain\": 19.0} # 기상별 GPS-추측항법 불일치 허용치(m)\n", + "\n", + "\n", + "def clean(df, weather=\"clear\"):\n", + " \"\"\"전처리: 중복 제거·정렬 -> 1초 재표본화 -> 결측 보간 -> 고도 스파이크 제거 -> GPS 스푸핑 구간 추측항법 보정.\"\"\"\n", + " d = df.drop_duplicates(\"t\").sort_values(\"t\").set_index(\"t\")\n", + " d = d.reindex(np.arange(int(d.index.min()), int(d.index.max()) + 1))\n", + " n_missing = int(d[\"x\"].isna().sum())\n", + " num = [\"x\", \"y\", \"alt\", \"speed\", \"rc\", \"sats\", \"hdop\"]\n", + " d[num] = d[num].interpolate(limit_direction=\"both\")\n", + " hd = d[\"heading\"].dropna() # 방위는 0/360 경계를 풀어서 보간\n", + " un = pd.Series(np.degrees(np.unwrap(np.radians(hd.to_numpy(float)))), index=hd.index)\n", + " d[\"heading\"] = un.reindex(d.index).interpolate(limit_direction=\"both\").to_numpy() % 360\n", + " med = d[\"alt\"].rolling(5, center=True, min_periods=1).median()\n", + " spike = (d[\"alt\"] - med).abs() > 25\n", + " d.loc[spike, \"alt\"] = med[spike]\n", + " x, y = d[\"x\"].to_numpy(float), d[\"y\"].to_numpy(float)\n", + " r = np.radians(d[\"heading\"].to_numpy(float)); v = d[\"speed\"].to_numpy(float)\n", + " dr = np.c_[v * np.sin(r), v * np.cos(r)] # 보고 속도·방위로 계산한 1초 이동량(추측항법)\n", + " xc, yc, flag = x.copy(), y.copy(), np.zeros(len(x), bool)\n", + " thr = SPOOF_THR.get(weather, 12.0)\n", + " ok_run = 0\n", + " for i in range(1, len(x)):\n", + " px, py = xc[i - 1] + dr[i, 0], yc[i - 1] + dr[i, 1] # 추측항법 예상 위치\n", + " step_ok = np.hypot(x[i] - x[i - 1] - dr[i, 0], y[i] - y[i - 1] - dr[i, 1]) <= thr\n", + " ok_run = ok_run + 1 if step_ok else 0\n", + " if flag[i - 1]:\n", + " # 스푸핑 구간: GPS가 예상 위치로 돌아오거나, GPS 이동량이 5초 연속 물리적으로 맞으면 GPS로 복귀\n", + " if np.hypot(x[i] - px, y[i] - py) < thr * 2 or ok_run >= 5:\n", + " xc[i], yc[i] = x[i], y[i]\n", + " else:\n", + " xc[i], yc[i], flag[i] = px, py, True\n", + " elif not step_ok:\n", + " xc[i], yc[i], flag[i] = px, py, True # GPS 이동량이 물리적으로 맞지 않음 -> 스푸핑 의심\n", + " else:\n", + " xc[i], yc[i] = xc[i - 1] + x[i] - x[i - 1], yc[i - 1] + y[i] - y[i - 1]\n", + " d[\"x\"], d[\"y\"], d[\"spoof_flag\"] = xc, yc, flag\n", + " d = d.reset_index()\n", + " d.attrs.update({\"missing\": n_missing, \"spikes\": int(spike.sum()), \"spoof_pts\": int(flag.sum())})\n", + " return d\n", + "\n", + "\n", + "def xtrack(px, py, plan):\n", + " \"\"\"계획 경로(웨이포인트 폐곡선) 대비 이탈 거리(m).\"\"\"\n", + " P = np.asarray(plan, float)\n", + " segs = [(P[i], P[(i + 1) % len(P)]) for i in range(len(P) if len(P) > 2 else 1)]\n", + " best = np.full(len(px), np.inf)\n", + " for a, b in segs:\n", + " ab = b - a\n", + " u = np.clip(((px - a[0]) * ab[0] + (py - a[1]) * ab[1]) / max(ab @ ab, 1e-9), 0, 1)\n", + " best = np.minimum(best, np.hypot(px - a[0] - u * ab[0], py - a[1] - u * ab[1]))\n", + " return best\n", + "\n", + "\n", + "RULES = {\"turn_rate\": 45.0, \"hover_speed\": 1.5, \"hover_sec\": 20, \"dive_m\": 30.0, \"xtrack_m\": 250.0}\n", + "\n", + "\n", + "def rule_flags(d, plan, R=RULES):\n", + " \"\"\"규칙 기반 이상 비행 판정(기록 단위). 급선회·호버링·급강하·경로 이탈·비인가 구역 진입.\"\"\"\n", + " hr = np.abs(_wrap(np.diff(d[\"heading\"].to_numpy(float), prepend=d[\"heading\"].iloc[0])))\n", + " slow = (d[\"speed\"] < R[\"hover_speed\"]).to_numpy()\n", + " run = np.zeros(len(slow), int)\n", + " for i in range(len(slow)):\n", + " run[i] = run[i - 1] + 1 if slow[i] and i else int(slow[i])\n", + " hover = np.zeros(len(slow), bool)\n", + " for i in np.where(run >= R[\"hover_sec\"])[0]:\n", + " hover[i - R[\"hover_sec\"] + 1:i + 1] = True\n", + " alt = d[\"alt\"].to_numpy(float)\n", + " drop = alt - np.r_[alt[5:], np.full(5, alt[-1])]\n", + " return {\"급선회\": hr > R[\"turn_rate\"], \"호버링\": hover, \"급강하\": drop > R[\"dive_m\"],\n", + " \"경로이탈\": xtrack(d[\"x\"].to_numpy(float), d[\"y\"].to_numpy(float), plan) > R[\"xtrack_m\"],\n", + " \"비인가구역\": np.hypot(d[\"x\"] - NFZ[\"x\"], d[\"y\"] - NFZ[\"y\"]).to_numpy() < NFZ[\"r\"]}\n", + "\n", + "# ---- 구간(30초 창) 특징 + 비지도 이상 탐지 공통 함수 ----\n", + "WFEAT = [\"alt_mean\", \"alt_min\", \"alt_std\", \"vz_min\", \"spd_mean\", \"spd_std\", \"spd_max\", \"turn_sum\", \"turn_max\",\n", + " \"straight\", \"rc_mean\", \"rc_zero\", \"nfz_min\", \"nfz_close\", \"xtrack_max\"]\n", + "\n", + "\n", + "def window_feats(d, plan, win=30, step=10):\n", + " \"\"\"30초 창을 10초씩 옮기며 고도·속도·선회·직진성·조종신호·비인가 구역 접근·경로 이탈 특징을 계산한다.\"\"\"\n", + " x, y, alt = d[\"x\"].to_numpy(float), d[\"y\"].to_numpy(float), d[\"alt\"].to_numpy(float)\n", + " spd, rc = d[\"speed\"].to_numpy(float), d[\"rc\"].to_numpy(float)\n", + " hr = np.abs(_wrap(np.diff(d[\"heading\"].to_numpy(float), prepend=d[\"heading\"].iloc[0])))\n", + " vz = np.diff(alt, prepend=alt[0])\n", + " nd = np.hypot(x - NFZ[\"x\"], y - NFZ[\"y\"])\n", + " xt = xtrack(x, y, plan)\n", + " step_len = np.hypot(np.diff(x, prepend=x[0]), np.diff(y, prepend=y[0]))\n", + " rows, starts = [], []\n", + " for s in range(0, len(d) - win + 1, step):\n", + " e = s + win\n", + " path = step_len[s + 1:e].sum()\n", + " rows.append([alt[s:e].mean(), alt[s:e].min(), alt[s:e].std(), vz[s:e].min(), spd[s:e].mean(), spd[s:e].std(),\n", + " spd[s:e].max(), hr[s:e].sum(), hr[s:e].max(), np.hypot(x[e - 1] - x[s], y[e - 1] - y[s]) / max(path, 1.0),\n", + " rc[s:e].mean(), (rc[s:e] < 5).mean(), nd[s:e].min(), (nd[s] - nd[e - 1]) / win, xt[s:e].max()])\n", + " starts.append(int(d[\"t\"].iloc[s]))\n", + " return np.array(rows), starts\n", + "\n", + "\n", + "class FlightAnomalyModel:\n", + " \"\"\"평시 비행로그(라벨 없음)로 통상 패턴 군집(KMeans)과 고립 숲(IsolationForest)을 학습하고,\n", + " 두 점수를 학습 구간 분위수로 나눠 1.0을 넘으면 '통상 패턴에서 벗어남'으로 본다.\"\"\"\n", + "\n", + " def __init__(self, q=0.999, k=4):\n", + " self.q, self.k = q, k\n", + "\n", + " def fit(self, X):\n", + " self.sc = StandardScaler().fit(X)\n", + " Z = self.sc.transform(X)\n", + " self.iso = IsolationForest(n_estimators=150, random_state=0).fit(Z)\n", + " self.km = KMeans(self.k, n_init=5, random_state=0).fit(Z)\n", + " self.set_q(self.q, X)\n", + " return self\n", + "\n", + " def _raw(self, X):\n", + " Z = self.sc.transform(X)\n", + " return -self.iso.score_samples(Z), self.km.transform(Z).min(axis=1)\n", + "\n", + " def set_q(self, q, X):\n", + " a, b = self._raw(X)\n", + " self.q, self.t_if, self.t_km = q, np.quantile(a, q), np.quantile(b, q)\n", + "\n", + " def score(self, X):\n", + " a, b = self._raw(X)\n", + " return np.maximum(a / self.t_if, b / self.t_km)\n", + "\n", + "\n", + "AFEAT = [\"ml_score\", \"low_alt\", \"rc_weak\", \"nfz_min_km\", \"hover_s\", \"max_speed\", \"night\",\n", + " \"pf_commercial\", \"pf_military\", \"pf_unknown\", \"fw_stock\", \"fw_custom\", \"fw_unknown\"]\n", + "\n", + "\n", + "def attr_features(d, m, ml_score):\n", + " \"\"\"운용 주체 추정용 비행 단위 특징. 기체·펌웨어 정보는 상용 기체가 모든 주체에 쓰이므로 약한 단서일 뿐이다.\"\"\"\n", + " return [min(ml_score, 10.0), float((d[\"alt\"] < 60).mean()), float((d[\"rc\"] < 25).mean()),\n", + " float(np.hypot(d[\"x\"] - NFZ[\"x\"], d[\"y\"] - NFZ[\"y\"]).min() / 1000), float((d[\"speed\"] < 1.5).sum()),\n", + " float(d[\"speed\"].max()), float(m[\"weather\"] == \"night\"),\n", + " *[float(m[\"platform\"] == p) for p in (\"commercial\", \"military\", \"unknown\")],\n", + " *[float(m[\"firmware\"] == f) for f in (\"stock\", \"custom\", \"unknown\")]]\n", + "\n", + "CONFIG = {\"turn_rate\": 60, \"dive_m\": 40, \"ml_threshold\": 1.2, \"ml_quantile\": 0.995, # 미션 9 재보정값\n", + " \"p_high\": 0.60, \"h_max\": 0.80, \"p_med\": 0.25}\n", + "CALLED = set()\n", + "\n", + "\n", + "def cover(fn):\n", + " \"\"\"기능 커버리지 측정용 표시: 통합 실행 중 호출된 기능을 기록한다.\"\"\"\n", + " def wrap(*a, **k):\n", + " CALLED.add(fn.__name__)\n", + " return fn(*a, **k)\n", + " wrap.__name__ = fn.__name__\n", + " return wrap\n", + "\n", + "\n", + "@cover\n", + "def validate_log(df, m):\n", + " errs = [f\"{c} 누락\" for c in (\"t\", \"x\", \"y\", \"alt\", \"speed\", \"heading\", \"rc\") if c not in df]\n", + " if m[\"weather\"] not in SPOOF_THR:\n", + " errs.append(\"weather 허용값 아님\")\n", + " if not errs and ((df[\"speed\"] > 60).any() or (df[\"rc\"] > 100).any()):\n", + " errs.append(\"범위 오류\")\n", + " return errs\n", + "\n", + "\n", + "@cover\n", + "def grade(p, h, q_ok):\n", + " if p >= CONFIG[\"p_high\"] and h <= CONFIG[\"h_max\"] and q_ok:\n", + " return \"High\"\n", + " return \"Medium\" if p >= CONFIG[\"p_med\"] else \"Low\"\n", + "\n", + "\n", + "@cover\n", + "def statement(fid, g, p, h, kinds):\n", + " return f\"{fid}: 이상 비행({kinds}), 적대 운용 가능성 {g}(p={p:.2f}, 불확실성 {h:.2f}) - 정보분석관 검토 필요\"\n", + "\n", + "\n", + "class Queue:\n", + " def __init__(self):\n", + " self.items, self.log = [], []\n", + "\n", + " @cover\n", + " def submit(self, fid, g, text):\n", + " prev = self.log[-1][\"hash\"] if self.log else \"GENESIS\"\n", + " body = {\"event\": \"SUBMIT\", \"id\": fid, \"grade\": g, \"text\": text}\n", + " self.log.append({**body, \"prev\": prev, \"hash\": hashlib.sha256((prev + json.dumps(body, ensure_ascii=False, sort_keys=True)).encode()).hexdigest()})\n", + " self.items.append({**body, \"status\": \"PENDING\"})\n", + "\n", + " @cover\n", + " def verify(self):\n", + " prev = \"GENESIS\"\n", + " for r in self.log:\n", + " body = {k: v for k, v in r.items() if k not in (\"prev\", \"hash\")}\n", + " if r[\"prev\"] != prev or r[\"hash\"] != hashlib.sha256((prev + json.dumps(body, ensure_ascii=False, sort_keys=True)).encode()).hexdigest():\n", + " return False\n", + " prev = r[\"hash\"]\n", + " return True\n", + "\n", + "\n", + "clean, rule_flags, window_feats, attr_features, corrupt = map(cover, (clean, rule_flags, window_feats, attr_features, corrupt))\n", + "FlightAnomalyModel.score = cover(FlightAnomalyModel.score)\n", + "TARGETS = {\"validate_log\", \"clean\", \"rule_flags\", \"window_feats\", \"score\", \"attr_features\", \"grade\", \"statement\", \"submit\", \"verify\", \"corrupt\", \"dashboard\"}\n", + "\n", + "\n", + "def analyze(df, m, det):\n", + " d = clean(df, m[\"weather\"])\n", + " f = rule_flags(d, m[\"plan\"], {**RULES, \"turn_rate\": CONFIG[\"turn_rate\"], \"dive_m\": CONFIG[\"dive_m\"]})\n", + " s = float(det.score(window_feats(d, m[\"plan\"])[0]).max())\n", + " hits = [k for k, v in f.items() if v.any()] + ([\"ML이상\"] if s > CONFIG[\"ml_threshold\"] else [])\n", + " return d, hits, s\n", + "\n", + "\n", + "# ---------------- 1. 학습(평시 패턴 + 과거 검토 완료 사례) ----------------\n", + "t_start = time.perf_counter()\n", + "routine = make_dataset(80, 21, anomaly_ratio=0.0) + [(df, m) for df, m in make_dataset(200, 22, anomaly_ratio=0.0) if m[\"kind\"] == \"survey\" or m[\"weather\"] == \"windy\"][:55]\n", + "det = FlightAnomalyModel(q=CONFIG[\"ml_quantile\"]).fit(np.vstack([window_feats(clean(df, m[\"weather\"]), m[\"plan\"])[0] for df, m in routine]))\n", + "past = []\n", + "for df, m in make_dataset(160, 31):\n", + " d, hits, s = analyze(df, m, det)\n", + " past.append((attr_features(d, m, s), m[\"operator\"]))\n", + "eng = make_pipeline(StandardScaler(), LogisticRegression(max_iter=2000, C=0.5)).fit([p[0] for p in past], [p[1] for p in past])\n", + "\n", + "# ---------------- 2. 운용 자료 처리 ----------------\n", + "rng = np.random.default_rng(10)\n", + "ops = make_dataset(100, 11)\n", + "ops = [(corrupt(df, rng) if rng.random() < 0.25 else df, m) for df, m in ops]\n", + "ops[7] = (ops[7][0].drop(columns=[\"rc\"]), ops[7][1]) # 손상 로그 1건 주입(검증 단계에서 차단되어야 함)\n", + "queue, rejected, res, ms = Queue(), {}, [], []\n", + "for df, m in ops:\n", + " errs = validate_log(df, m)\n", + " if errs:\n", + " rejected[m[\"id\"]] = errs; continue\n", + " t0 = time.perf_counter()\n", + " d, hits, s = analyze(df, m, det)\n", + " P = eng.predict_proba([attr_features(d, m, s)])[0]\n", + " ph = P[list(eng.classes_).index(\"hostile\")]; H = float(-(P * np.log(np.clip(P, 1e-9, 1))).sum() / np.log(3))\n", + " q_ok = d.attrs[\"spoof_pts\"] < 10 and d.attrs[\"missing\"] < 15\n", + " g = grade(ph, H, q_ok) if hits else \"-\"\n", + " if hits:\n", + " queue.submit(m[\"id\"], g, statement(m[\"id\"], g, ph, H, \",\".join(hits)))\n", + " ms.append((time.perf_counter() - t0) * 1000)\n", + " res.append({\"id\": m[\"id\"], \"kind\": m[\"kind\"], \"op\": m[\"operator\"], \"truth\": m[\"kind\"] in SCENARIOS, \"pred\": bool(hits), \"grade\": g, \"d\": d})\n", + "\n", + "# ---------------- 3. 성능 평가 ----------------\n", + "R = pd.DataFrame(res)\n", + "acc = (R.truth == R.pred).mean(); rec = (R.truth & R.pred).sum() / R.truth.sum(); far = (~R.truth & R.pred).sum() / max(R.pred.sum(), 1)\n", + "high = R[R.grade == \"High\"]; hfar = (high.op != \"hostile\").mean() if len(high) else 0.0\n", + "\n", + "# ---------------- 4. 대시보드 ----------------\n", + "@cover\n", + "def dashboard():\n", + " plt.style.use(\"dark_background\")\n", + " fig, ax = plt.subplots(2, 2, figsize=(12, 8))\n", + " for _, r in R[R.pred].iterrows():\n", + " ax[0, 0].plot(r.d[\"x\"], r.d[\"y\"], lw=0.8, color={\"High\": \"red\", \"Medium\": \"orange\", \"Low\": \"gray\"}[r.grade])\n", + " ax[0, 0].add_patch(plt.Circle((NFZ[\"x\"], NFZ[\"y\"]), NFZ[\"r\"], color=\"red\", alpha=.2)); ax[0, 0].set_aspect(\"equal\")\n", + " ax[0, 0].set_title(\"Flagged flights (color = attribution grade)\")\n", + " R[R.pred][\"grade\"].value_counts().reindex([\"High\", \"Medium\", \"Low\"]).fillna(0).plot.bar(ax=ax[0, 1], color=[\"red\", \"orange\", \"gray\"])\n", + " ax[0, 1].set_title(\"Review queue by grade (all pending)\")\n", + " R[R.pred][\"kind\"].value_counts().plot.barh(ax=ax[1, 0], color=\"deepskyblue\"); ax[1, 0].set_title(\"Flagged flights by true scenario\")\n", + " ax[1, 1].axis(\"off\")\n", + " ax[1, 1].text(0, .9, f\"Accuracy {acc:.1%}\\nRecall {rec:.1%}\\nFalse alarm {far:.1%}\\nQueue {len(queue.items)} pending\\nLatency {np.mean(ms):.0f} ms/flight\",\n", + " family=\"monospace\", fontsize=13, va=\"top\")\n", + " fig.suptitle(\"ISR03 integrated drone-log analysis - training data only\"); plt.tight_layout(); plt.show()\n", + "\n", + "\n", + "dashboard()\n", + "\n", + "# ---------------- 5. 자체 점검(통합 테스트) ----------------\n", + "log_ok = queue.verify()\n", + "cov = len(CALLED & TARGETS) / len(TARGETS)\n", + "chk = {\n", + " \"손상 로그 차단\": \"FL0007\" in rejected,\n", + " \"처리 비행 99건\": len(res) == 99,\n", + " \"이상 비행 재현율 90% 이상\": rec >= 0.9,\n", + " \"정확도 90% 이상\": acc >= 0.9,\n", + " \"자동 채택 0건(전부 검토 대기)\": all(i[\"status\"] == \"PENDING\" for i in queue.items),\n", + " \"탐지 건 전량 대기열 등록\": len(queue.items) == int(R.pred.sum()),\n", + " \"감사 로그 무결성\": log_ok,\n", + " \"단정 표현('확정') 0건\": not any(\"확정\" in i[\"text\"] for i in queue.items),\n", + " \"비행 1건 200ms 이내\": float(np.mean(ms)) < 200,\n", + " \"기능 커버리지 85% 이상\": cov >= 0.85,\n", + "}\n", + "passed = sum(chk.values())\n", + "for k, v in chk.items():\n", + " print(f\"[{'PASS' if v else 'FAIL'}] {k}\")\n", + "print(f\"자체 점검 {passed}/{len(chk)} 통과, 기능 커버리지 {cov:.0%} ({len(CALLED & TARGETS)}/{len(TARGETS)}), 파이프라인 실행 성공 {'예' if passed == len(chk) else '아니오'}\")\n", + "\n", + "# ---------------- 6. ISR 정보판단 브리핑 요약 ----------------\n", + "print(f\"\"\"\n", + "[드론 비행로그 이상 탐지 브리핑 요약 - 교육용가상자료]\n", + "1. 처리: 비행로그 {len(ops)}건 중 {len(res)}건 분석, 손상 로그 {len(rejected)}건 차단({', '.join(rejected)})\n", + "2. 성능: 정확도 {acc:.1%}, 이상 재현율 {rec:.1%}, 오경보율 {far:.1%}, 비행당 {np.mean(ms):.0f} ms, 전체 {time.perf_counter() - t_start:.0f} s\n", + "3. 검토 대기: {len(queue.items)}건 (High {int((R.grade == 'High').sum())} / Medium {int((R.grade == 'Medium').sum())} / Low {int((R.grade == 'Low').sum())}), 자동 채택 0건\n", + "4. 운용 주체: High 등급 {len(high)}건 중 실제 비적대 {int((high.op != 'hostile').sum())}건({hfar:.0%}) -> 등급은 검토 우선순위일 뿐 귀속 결론이 아님\n", + "5. 한계: 합성 로그 기준 결과이며, 운용 주체 판단은 출처·무결성 확인과 2인 교차 검토를 거친 정보분석관 의견으로만 보고한다.\"\"\")\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 10 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + "[PASS] 손상 로그 차단\n", + "[PASS] 처리 비행 99건\n", + "[PASS] 이상 비행 재현율 90% 이상\n", + "[PASS] 정확도 90% 이상\n", + "[PASS] 자동 채택 0건(전부 검토 대기)\n", + "[PASS] 탐지 건 전량 대기열 등록\n", + "[PASS] 감사 로그 무결성\n", + "[PASS] 단정 표현('확정') 0건\n", + "[PASS] 비행 1건 200ms 이내\n", + "[PASS] 기능 커버리지 85% 이상\n", + "자체 점검 10/10 통과, 기능 커버리지 100% (12/12), 파이프라인 실행 성공 예\n", + "\n", + "[드론 비행로그 이상 탐지 브리핑 요약 - 교육용가상자료]\n", + "1. 처리: 비행로그 100건 중 99건 분석, 손상 로그 1건 차단(FL0007)\n", + "2. 성능: 정확도 96.0%, 이상 재현율 98.0%, 오경보율 5.8%, 비행당 31 ms, 전체 12 s\n", + "3. 검토 대기: 52건 (High 7 / Medium 22 / Low 23), 자동 채택 0건\n", + "4. 운용 주체: High 등급 7건 중 실제 비적대 2건(29%) -> 등급은 검토 우선순위일 뿐 귀속 결론이 아님\n", + "5. 한계: 합성 로그 기준 결과이며, 운용 주체 판단은 출처·무결성 확인과 2인 교차 검토를 거친 정보분석관 의견으로만 보고한다.\n", + "```\n", + "
\n" + ] + } + ], + "metadata": { + "colab": { + "provenance": [] + }, + "kernelspec": { + "display_name": "Python 3", + "name": "python3" + }, + "language_info": { + "name": "python" + } + }, + "nbformat": 4, + "nbformat_minor": 0 +} \ No newline at end of file diff --git a/uploads/ISR03/ISR03_student.ipynb b/uploads/ISR03/ISR03_student.ipynb new file mode 100644 index 0000000..6cd901b --- /dev/null +++ b/uploads/ISR03/ISR03_student.ipynb @@ -0,0 +1,4251 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# 🎯 [ISR03] 드론 비행로그 패턴 이상 탐지와 운용 주체 단정 방지\n", + "### Google Colab 실습 환경 (정보·감시·정찰(ISR) / 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/ISR03/ISR03_solution.ipynb)\n", + "\n", + "---\n", + "\n", + "## 📌 1. 프로젝트 개요 및 배경\n", + "\n", + "| 항목 | 내용 |\n", + "| :--- | :--- |\n", + "| **과정 분류** | 정보·감시·정찰(ISR) (Level 3) |\n", + "| **학습 소요 시간** | 10개 미션 (5일 / 총 27.5시간) |\n", + "| **환경 사양** | Python 3.10+, numpy, pandas, matplotlib, scipy, scikit-learn |\n", + "\n", + "### 📖 프로젝트 목표\n", + "러시아-우크라이나 전쟁에서는 상용 드론이 정찰과 공격에 폭넓게 쓰이면서, 노획하거나 수집한 드론 비행로그를 분석해 이상 비행을 찾아내는 일이 중요해졌다. 그러나 같은 상용 기체를 아군·민간·적대 세력이 모두 쓰기 때문에, 비행 특성만 보고 운용 주체를 성급하게 단정하면 오판으로 이어질 수 있다. 본 프로젝트에서는 교육용 가상 비행로그를 코드로 직접 만들어 스키마 정의, GPS 스푸핑 보정 전처리, 규칙 기반 Baseline, 군집·비지도 학습 기반 이상 탐지, 불확실성을 반영한 운용 주체 신뢰도 엔진, 분석 대시보드, 정보분석관 심층 검토(HITL), 대량 로그 부하 시험, 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. 상용 및 군용 드론 비행로그(GPS, 속도, 고도, RC제어) 데이터 스키마 및 수집 환경 분석\n", + "**소요시간**: 약 1.5시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "전장에서 노획 및 수집된 상용/군용 드론의 비행로그(GPS 텔레메트리, 속도, 고도, 조종 신호) 데이터 구조를 분석하고 스키마를 정의한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **노획·수집된 상용/군용 드론 비행로그(GPS 위치, 속도, 고도, RC 조종 신호)의 구조를 분석하고, 수집 경위와 전술 파라미터까지 포함한 데이터 스키마와 검증기를 만든다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **1초 단위 비행 기록(위치·고도·속도·방위·조종신호 품질·위성 수·HDOP), 수집 경위(수집 방식·수집자·원본 해시), 기체 구분, 전술 파라미터(기상·위치오차·통신지연) 필드 정의하고 검증하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `data/schema/isr03_input_schema.json 및 docs/isr03_data_spec.md` 형태의 결과물을 완성한다.\n", + "\n", + "### 🔍 평가 및 검증 기준 (PASS 조건)\n", + "#### 미션 판정\n", + "\n", + "**평가 구분:** 필수\n", + "\n", + "**핵심 평가영역:** 정보·감시·정찰(ISR) 실무 및 러시아-우크라이나 전쟁 전장 환경과의 군사적 부합성\n", + "\n", + "#### 미션 PASS 조건\n", + "\n", + "- 정량 기준: 스키마 필드 정의 완성도 100%, 샘플 데이터 검증 오류 0건\n", + "\n", + "\n", + "- 진행 기준: 입력 데이터 유효성 검증 스크립트 통과 및 명세서 문서화 완료\n", + "\n", + "\n", + "- 검증 방법: JSON Schema 검증 도구 및 샘플 데이터 파싱 단위 테스트\n", + "\n", + "\n", + "#### 채점자 판정 메모\n", + "\n", + "> 단순 코드 실행 여부뿐만 아니라 출력된 수치와 근거 자료가 군 실무 요구사항에 부합하는지 면밀히 확인한다.\n", + "\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "\"\"\"상용·군용 드론 비행로그(GPS 위치, 속도, 고도, RC 조종 신호)와 수집 경위·전술 파라미터의 데이터 스키마를 정의하고, 가상 샘플 로그를 검증한다.\"\"\"\n", + "import hashlib\n", + "import json\n", + "import re\n", + "import numpy as np\n", + "import pandas as pd\n", + "\n", + "# 1) 스키마 정의: 수집 경위(증거 관리), 기체 정보, 전술 파라미터, 비행 기록(1초 단위), 귀속 판단 상태\n", + "SCHEMA = {\n", + " \"required\": [\"flight_id\", \"n_records\", \"collection\", \"tactical\", \"attribution\", \"records\"],\n", + " \"fields\": {\n", + " \"flight_id\": {\"type\": str, \"pattern\": r\"^FL\\d{4}$\"},\n", + " \"n_records\": {\"type\": int, \"min\": 1, \"max\": 100000},\n", + " },\n", + " \"collection\": {\n", + " \"source\": {\"type\": str, \"enum\": [\"captured_device\", \"intercepted_telemetry\", \"open_source\"]},\n", + " \"collected_by\": {\"type\": str, \"pattern\": r\"^COL-\\d{2}$\"},\n", + " \"platform_type\": {\"type\": str, \"enum\": [\"commercial\", \"military\", \"unknown\"]},\n", + " \"sha256\": {\"type\": str, \"pattern\": r\"^[0-9a-f]{64}$\"},\n", + " },\n", + " \"tactical\": {\n", + " \"weather\": {\"type\": str, \"enum\": [\"clear\", \"night\", \"windy\", \"rain\"]},\n", + " \"position_error_m\": {\"type\": (int, float), \"min\": 0, \"max\": 500},\n", + " \"comm_delay_ms\": {\"type\": (int, float), \"min\": 0, \"max\": 10000},\n", + " },\n", + " # 수집 단계에서는 운용 주체를 적지 못하게 한다(성급한 단정 방지). 판정은 정보분석관 검토 후 별도 기록.\n", + " \"attribution\": {\n", + " \"status\": {\"type\": str, \"enum\": [\"unassessed\"]},\n", + " },\n", + " \"record\": {\n", + " \"t\": {\"type\": int, \"min\": 0},\n", + " \"x_m\": {\"type\": (int, float), \"min\": -20000, \"max\": 20000},\n", + " \"y_m\": {\"type\": (int, float), \"min\": -20000, \"max\": 20000},\n", + " \"alt_m\": {\"type\": (int, float), \"min\": -50, \"max\": 6000},\n", + " \"speed_mps\": {\"type\": (int, float), \"min\": 0, \"max\": 60},\n", + " \"heading_deg\": {\"type\": (int, float), \"min\": 0, \"max\": 360},\n", + " \"rc_link\": {\"type\": (int, float), \"min\": 0, \"max\": 100},\n", + " \"gps_sats\": {\"type\": int, \"min\": 0, \"max\": 40},\n", + " \"hdop\": {\"type\": (int, float), \"min\": 0, \"max\": 50},\n", + " },\n", + "}\n", + "\n", + "\n", + "def check(name, value, rule):\n", + " \"\"\"값 하나를 규칙(type, enum, min, max, pattern)으로 검사해 오류 목록을 돌려준다.\"\"\"\n", + " # [TODO : 학생 실습 1] 상용 및 군용 드론 비행로그(GPS, 속도, 고도, RC제어) 데이터 스키마 및 수집 환경 분석 관련 핵심 로직을 구현하세요.\n", + " # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n", + " # <<< 여기에 코드를 작성하세요 >>>\n", + " pass # 작성 후 pass를 제거하세요.\n", + "def digest(records):\n", + " # [TODO : 학생 실습 1] 상용 및 군용 드론 비행로그(GPS, 속도, 고도, RC제어) 데이터 스키마 및 수집 환경 분석 관련 핵심 로직을 구현하세요.\n", + " # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n", + " # <<< 여기에 코드를 작성하세요 >>>\n", + " pass # 작성 후 pass를 제거하세요.\n", + "def validate(rec):\n", + " errs = [f\"{k}: 필수 필드 누락\" for k in SCHEMA[\"required\"] if k not in rec]\n", + " for k, rule in SCHEMA[\"fields\"].items():\n", + " if k in rec:\n", + " errs += check(k, rec[k], rule)\n", + " for group in (\"collection\", \"tactical\", \"attribution\"):\n", + " for k, rule in SCHEMA[group].items():\n", + " if k not in rec.get(group, {}):\n", + " errs.append(f\"{group}.{k}: 필수 필드 누락\")\n", + " else:\n", + " errs += check(f\"{group}.{k}\", rec[group][k], rule)\n", + " for i, r in enumerate(rec.get(\"records\", [])):\n", + " for k, rule in SCHEMA[\"record\"].items():\n", + " if k not in r:\n", + " errs.append(f\"records[{i}].{k}: 필수 필드 누락\")\n", + " else:\n", + " errs += check(f\"records[{i}].{k}\", r[k], rule)\n", + " if \"records\" in rec and rec.get(\"collection\", {}).get(\"sha256\") not in (None, digest(rec[\"records\"])):\n", + " errs.append(\"collection.sha256: 로그 해시 불일치(수집 후 변경 의심)\")\n", + " return errs\n", + "\n", + "\n", + "# 2) [교육용가상자료] 비행로그 샘플 20건 생성 (가상 좌표, 1초 간격 5개 기록)\n", + "rng = np.random.default_rng(1)\n", + "logs = []\n", + "for i in range(20):\n", + " x, y, alt, hdg = rng.uniform(-3000, 3000), rng.uniform(-3000, 3000), rng.uniform(80, 150), rng.uniform(0, 360)\n", + " recs = []\n", + " for t in range(5):\n", + " v = float(round(rng.uniform(10, 16), 1))\n", + " x += v * np.sin(np.radians(hdg)); y += v * np.cos(np.radians(hdg))\n", + " recs.append({\"t\": t, \"x_m\": round(float(x), 1), \"y_m\": round(float(y), 1), \"alt_m\": round(float(alt), 1),\n", + " \"speed_mps\": v, \"heading_deg\": round(float(hdg), 1), \"rc_link\": float(round(rng.uniform(70, 95), 1)),\n", + " \"gps_sats\": int(rng.integers(9, 15)), \"hdop\": float(round(rng.uniform(0.6, 1.4), 2))})\n", + " logs.append({\"flight_id\": f\"FL{i:04d}\", \"n_records\": len(recs),\n", + " \"collection\": {\"source\": str(rng.choice(SCHEMA[\"collection\"][\"source\"][\"enum\"])),\n", + " \"collected_by\": f\"COL-{int(rng.integers(1, 20)):02d}\",\n", + " \"platform_type\": str(rng.choice([\"commercial\", \"military\", \"unknown\"])), \"sha256\": digest(recs)},\n", + " \"tactical\": {\"weather\": str(rng.choice([\"clear\", \"night\", \"windy\", \"rain\"])),\n", + " \"position_error_m\": float(round(rng.uniform(2, 30), 1)), \"comm_delay_ms\": int(rng.integers(20, 400))},\n", + " \"attribution\": {\"status\": \"unassessed\"}, \"records\": recs})\n", + "\n", + "# 3) 정상 데이터 검증\n", + "ok = sum(1 for r in logs if not validate(r))\n", + "print(f\"정상 샘플 {len(logs)}건 중 검증 통과 {ok}건, 오류 {len(logs) - ok}건 (목표: 오류 0건 {'달성' if ok == len(logs) else '미달'})\")\n", + "\n", + "# 4) 오류 주입 시험: 형식·범위·누락·단정 기재·해시 불일치\n", + "bad = json.loads(json.dumps(logs[0]))\n", + "bad[\"flight_id\"] = \"F-12\"\n", + "bad[\"records\"][0][\"speed_mps\"] = 95.0\n", + "del bad[\"tactical\"][\"comm_delay_ms\"]\n", + "bad[\"attribution\"][\"status\"] = \"hostile\"\n", + "del bad[\"records\"][1][\"rc_link\"]\n", + "print(\"\\n[오류 주입 결과] 주입 5종\")\n", + "found = validate(bad)\n", + "for e in found:\n", + " print(\" -\", e)\n", + "print(f\"검출 {len(found)}건 (해시 불일치는 수집 후 로그가 바뀌었음을 알려 주는 추가 검출)\")\n", + "\n", + "# 5) 스키마 필드 정의서(명세서 초안)\n", + "rows = [(\"기본\", k, r[\"type\"].__name__, str({x: y for x, y in r.items() if x != \"type\"})) for k, r in SCHEMA[\"fields\"].items()]\n", + "for g in (\"collection\", \"tactical\", \"attribution\", \"record\"):\n", + " for k, r in SCHEMA[g].items():\n", + " t = r[\"type\"].__name__ if isinstance(r[\"type\"], type) else \"number\"\n", + " rows.append((g, k, t, str({x: y for x, y in r.items() if x != \"type\"})))\n", + "spec = pd.DataFrame(rows, columns=[\"그룹\", \"필드\", \"자료형\", \"규칙\"])\n", + "print(\"\\n[데이터 명세 요약]\")\n", + "print(spec.to_string(index=False))\n", + "print(f\"\\n필드 정의 {len(spec)}개(설명·자료형·규칙 모두 기재, 완성도 100%). 운용 주체는 수집 단계에서 'unassessed'만 허용한다.\")\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 01 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + "정상 샘플 20건 중 검증 통과 20건, 오류 0건 (목표: 오류 0건 달성)\n", + "\n", + "[오류 주입 결과] 주입 5종\n", + " - flight_id: 형식 불일치 'F-12'\n", + " - tactical.comm_delay_ms: 필수 필드 누락\n", + " - attribution.status: 허용되지 않은 값 'hostile'\n", + " - records[0].speed_mps: 최대값 60 초과\n", + " - records[1].rc_link: 필수 필드 누락\n", + " - collection.sha256: 로그 해시 불일치(수집 후 변경 의심)\n", + "검출 6건 (해시 불일치는 수집 후 로그가 바뀌었음을 알려 주는 추가 검출)\n", + "\n", + "[데이터 명세 요약]\n", + " 그룹 필드 자료형 규칙\n", + " 기본 flight_id str {'pattern': '^FL\\\\d{4}$'}\n", + " 기본 n_records int {'min': 1, 'max': 100000}\n", + " collection source str {'enum': ['captured_device', 'intercepted_telemetry', 'open_source']}\n", + " collection collected_by str {'pattern': '^COL-\\\\d{2}$'}\n", + " collection platform_type str {'enum': ['commercial', 'military', 'unknown']}\n", + " collection sha256 str {'pattern': '^[0-9a-f]{64}$'}\n", + " tactical weather str {'enum': ['clear', 'night', 'windy', 'rain']}\n", + " tactical position_error_m number {'min': 0, 'max': 500}\n", + " tactical comm_delay_ms number {'min': 0, 'max': 10000}\n", + "attribution status str {'enum': ['unassessed']}\n", + " record t int {'min': 0}\n", + " record x_m number {'min': -20000, 'max': 20000}\n", + " record y_m number {'min': -20000, 'max': 20000}\n", + " record alt_m number {'min': -50, 'max': 6000}\n", + " record speed_mps number {'min': 0, 'max': 60}\n", + " record heading_deg number {'min': 0, 'max': 360}\n", + " record rc_link number {'min': 0, 'max': 100}\n", + " record gps_sats int {'min': 0, 'max': 40}\n", + " record hdop number {'min': 0, 'max': 50}\n", + "\n", + "필드 정의 19개(설명·자료형·규칙 모두 기재, 완성도 100%). 운용 주체는 수집 단계에서 'unassessed'만 허용한다.\n", + "```\n", + "
\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 02. 비행로그 내 GPS 스푸핑 왜곡값 보정 및 시계열 텔레메트리 전처리 모듈 구현\n", + "**소요시간**: 약 2.0시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "비행로그 내의 GPS 스푸핑 및 전자기 간섭에 따른 이상 좌표를 보정하고 시계열 비행 궤적 데이터를 정규화하는 전처리 모듈을 구현한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **GPS 스푸핑·전자기 간섭·센서 단절로 왜곡된 비행로그를 보정하고 시계열을 1초 간격으로 표준화·정규화하는 전처리 모듈을 구현한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **중복 기록 제거와 1초 재표본화, 결측 보간, 이동 중간값 기반 고도 스파이크 제거 구현하고 동작을 검증하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 5개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `src/preprocess/isr03_preprocessor.py 및 전처리 전후 데이터셋` 형태의 결과물을 완성한다.\n", + "\n", + "### 🔍 평가 및 검증 기준 (PASS 조건)\n", + "#### 미션 판정\n", + "\n", + "**평가 구분:** 필수\n", + "\n", + "**핵심 평가영역:** 전술 환경 특유의 노이즈(스푸핑, 반사파, 센서 단절 등) 제거 타당성\n", + "\n", + "#### 미션 PASS 조건\n", + "\n", + "- 정량 기준: 데이터 정제 처리 속도 1,000건/초 이상, 전처리 오류율 0%\n", + "\n", + "\n", + "- 진행 기준: 정제 파이프라인 자동화 및 단위 테스트 케이스 5종 통과\n", + "\n", + "\n", + "- 검증 방법: pytest 기반 전처리 정합성 테스트 및 결측치 보정율 확인\n", + "\n", + "\n", + "#### 채점자 판정 메모\n", + "\n", + "> 단순 코드 실행 여부뿐만 아니라 출력된 수치와 근거 자료가 군 실무 요구사항에 부합하는지 면밀히 확인한다.\n", + "\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "\"\"\"비행로그의 GPS 스푸핑 위치 점프·표류, 전자기 간섭 고도 스파이크, 센서 단절 결측, 중복 기록을 보정하고 시계열을 1초 간격으로 표준화·정규화하는 전처리 모듈을 구현해 처리 속도와 보정 효과를 검증한다.\"\"\"\n", + "import math\n", + "import time\n", + "import numpy as np\n", + "import pandas as pd\n", + "import matplotlib.pyplot as plt\n", + "\n", + "# ---------------------------------------------------------------------------\n", + "# [교육용가상자료] 합성 드론 비행로그 생성기 (실제 좌표·기체·부대 정보 없음)\n", + "# 가상 구역(원점 기준 동·북 방향 m 좌표)에서 1초 간격 비행로그와 정답(이상 구간·운용 주체)을 만든다.\n", + "# ---------------------------------------------------------------------------\n", + "NFZ = {\"x\": 2500.0, \"y\": 2500.0, \"r\": 700.0} # 가상 비인가 비행구역\n", + "WATCH = {\"x\": -2000.0, \"y\": 1500.0, \"r\": 600.0} # 가상 관심 구역\n", + "SCENARIOS = [\"sharp_turn\", \"hover\", \"dive\", \"deviation\", \"nfz_entry\",\n", + " \"low_level\", \"rc_silent\", \"infiltration\", \"loiter\", \"speed_burst\"]\n", + "OPERATORS = [\"friendly\", \"civil\", \"hostile\"]\n", + "# 시나리오별 운용 주체 분포: 일부러 크게 겹치게 설계했다(비행 특성만으로 운용 주체를 단정할 수 없다).\n", + "OPERATOR_P = {\"normal\": (.45, .47, .08), \"survey\": (.15, .82, .03), \"sharp_turn\": (.40, .35, .25),\n", + " \"hover\": (.25, .45, .30), \"dive\": (.25, .25, .50), \"deviation\": (.30, .40, .30),\n", + " \"nfz_entry\": (.10, .50, .40), \"low_level\": (.35, .15, .50), \"rc_silent\": (.30, .15, .55),\n", + " \"infiltration\": (.12, .08, .80), \"loiter\": (.30, .20, .50), \"speed_burst\": (.30, .20, .50)}\n", + "PLATFORM_P = {\"friendly\": (.40, .55, .05), \"civil\": (.88, .02, .10), \"hostile\": (.55, .30, .15)} # commercial/military/unknown\n", + "FIRMWARE_P = {\"friendly\": (.30, .65, .05), \"civil\": (.85, .10, .05), \"hostile\": (.20, .50, .30)} # stock/custom/unknown\n", + "\n", + "\n", + "def _wrap(a):\n", + " # [TODO : 학생 실습 2] 비행로그 내 GPS 스푸핑 왜곡값 보정 및 시계열 텔레메트리 전처리 모듈 구현 관련 핵심 로직을 구현하세요.\n", + " # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n", + " # <<< 여기에 코드를 작성하세요 >>>\n", + " pass # 작성 후 pass를 제거하세요.\n", + "def _bearing(p, q):\n", + " # [TODO : 학생 실습 2] 비행로그 내 GPS 스푸핑 왜곡값 보정 및 시계열 텔레메트리 전처리 모듈 구현 관련 핵심 로직을 구현하세요.\n", + " # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n", + " # <<< 여기에 코드를 작성하세요 >>>\n", + " pass # 작성 후 pass를 제거하세요.\n", + "def _away_from_nfz(rng, min_d):\n", + " while True:\n", + " c = rng.uniform(-3800, 3800, 2)\n", + " if np.hypot(c[0] - NFZ[\"x\"], c[1] - NFZ[\"y\"]) > min_d:\n", + " return c\n", + "\n", + "\n", + "def make_flight(fid, kind, rng, n=300, weather=None):\n", + " \"\"\"비행 1회 = (로그 DataFrame, 메타 dict). kind: normal / survey / SCENARIOS 중 하나\"\"\"\n", + " weather = weather or str(rng.choice([\"clear\", \"night\", \"windy\", \"rain\"], p=[.4, .25, .2, .15]))\n", + " wind = {\"clear\": 1.0, \"night\": 1.0, \"windy\": 2.5, \"rain\": 1.8}[weather]\n", + " v0, alt0 = rng.uniform(11, 17), rng.uniform(70, 150)\n", + " nfz_c = np.array([NFZ[\"x\"], NFZ[\"y\"]])\n", + " if kind == \"infiltration\": # 원거리에서 비인가 구역 방향으로 저고도 직진\n", + " ang = rng.uniform(0, 2 * np.pi)\n", + " start = nfz_c + 4000 * np.array([np.cos(ang), np.sin(ang)])\n", + " plan = np.array([start, nfz_c])\n", + " v0, alt0 = rng.uniform(9, 11), rng.uniform(25, 35)\n", + " else:\n", + " if kind == \"nfz_entry\": # 비인가 구역 1.6km 밖에서 순찰하다가 진입\n", + " ang = rng.uniform(0, 2 * np.pi)\n", + " c = nfz_c + 1600 * np.array([np.cos(ang), np.sin(ang)])\n", + " else:\n", + " c = _away_from_nfz(rng, 2600)\n", + " if kind == \"survey\": # 민간 측량형 왕복(지그재그) 경로\n", + " w, h = rng.uniform(500, 700), 120.0\n", + " plan = np.array([c + [(-w if i % 4 in (0, 3) else w), i // 2 * h - 360] for i in range(8)])\n", + " else: # 사각 순찰 경로(웨이포인트 4개)\n", + " a, b, th = rng.uniform(300, 600), rng.uniform(200, 400), rng.uniform(0, np.pi)\n", + " R = np.array([[np.cos(th), -np.sin(th)], [np.sin(th), np.cos(th)]])\n", + " plan = c + np.array([[-a, -b], [a, -b], [a, b], [-a, b]]) @ R.T\n", + " s0 = int(rng.integers(80, 160))\n", + " span = {\"sharp_turn\": 30, \"hover\": 60, \"dive\": 8, \"deviation\": 70, \"nfz_entry\": 120, \"low_level\": 120,\n", + " \"rc_silent\": 120, \"infiltration\": n - 20, \"loiter\": 90, \"speed_burst\": 25}.get(kind, 0)\n", + " if kind == \"infiltration\":\n", + " s0 = 20\n", + " win = (s0, s0 + span)\n", + " turns = set(rng.choice(np.arange(s0, s0 + 30), 3, replace=False).tolist()) if kind == \"sharp_turn\" else set()\n", + " # 난수를 미리 한 번에 뽑아 두고(속도 향상) 1초 단위로 기체 운동을 적분한다.\n", + " e_h, e_v, e_a = rng.normal(0, 0.8 * wind, n), rng.normal(0, 0.3 * wind, n), rng.normal(0, 0.4 * wind, n)\n", + " rc_arr = np.clip(rng.normal(rng.uniform(60, 92), 6, n), 0, 100)\n", + " gust = (rng.random(n) < (0.004 if weather == \"windy\" else 0.0)) * rng.uniform(20, 35, n)\n", + " burst, turn_amt = rng.uniform(23, 28), rng.choice([-1, 1], n) * rng.uniform(100, 160, n)\n", + " side = float(rng.choice([-90, 90]))\n", + " low_alt, rng_rc = rng.uniform(35, 55), rng.uniform(0, 20, n) # 저고도 목표, 조종신호 두절 시 잔여 수신값\n", + " x, y, wi = float(plan[0][0]), float(plan[0][1]), 1\n", + " hdg, alt = _bearing((x, y), plan[1]), alt0\n", + " max_rate = 40.0 if kind == \"survey\" else 12.0\n", + " dev_target, X, Y, A, V, HD = None, np.zeros(n), np.zeros(n), np.zeros(n), np.zeros(n), np.zeros(n)\n", + " for t in range(n):\n", + " inw = win[0] <= t < win[1]\n", + " tx, ty = plan[1] if kind == \"infiltration\" else plan[wi % len(plan)]\n", + " spd_cmd, alt_cmd = v0, alt0\n", + " if inw:\n", + " if kind == \"deviation\":\n", + " if dev_target is None:\n", + " r = math.radians(hdg + side)\n", + " dev_target = (x + 900 * math.sin(r), y + 900 * math.cos(r))\n", + " tx, ty = dev_target\n", + " elif kind == \"nfz_entry\":\n", + " tx, ty = NFZ[\"x\"], NFZ[\"y\"]\n", + " elif kind == \"hover\":\n", + " spd_cmd = 0.0\n", + " elif kind == \"speed_burst\":\n", + " spd_cmd = burst\n", + " elif kind == \"low_level\":\n", + " alt_cmd = low_alt\n", + " if kind in (\"rc_silent\", \"infiltration\"):\n", + " rc_arr[t] = rng_rc[t]\n", + " if inw and kind == \"loiter\":\n", + " hdg = (hdg + 9.0 + e_h[t] / wind) % 360\n", + " else:\n", + " d = _wrap(math.degrees(math.atan2(tx - x, ty - y)) - hdg)\n", + " hdg = (hdg + max(-max_rate, min(max_rate, d)) + e_h[t]) % 360\n", + " if t in turns:\n", + " hdg = (hdg + turn_amt[t]) % 360\n", + " v = abs(spd_cmd + e_v[t]) if spd_cmd > 0 else abs(e_v[t] / wind)\n", + " x += v * math.sin(math.radians(hdg)); y += v * math.cos(math.radians(hdg))\n", + " if kind != \"infiltration\" and math.hypot(x - tx, y - ty) < 40 and not (inw and kind in (\"deviation\", \"nfz_entry\")):\n", + " wi += 1\n", + " if inw and kind == \"dive\":\n", + " alt -= 9.0 + e_v[t]\n", + " else:\n", + " alt += max(-3.0, min(3.0, alt_cmd - alt)) + e_a[t]\n", + " alt -= gust[t] # 돌풍 하강기류(정상 비행에서도 발생)\n", + " if kind == \"dive\" and t >= win[1]:\n", + " alt0 = min(alt0, alt)\n", + " alt = max(alt, 3.0)\n", + " X[t], Y[t], A[t], V[t], HD[t] = x, y, alt, v, hdg\n", + " gps_sd = {\"clear\": 2.0, \"night\": 2.0, \"windy\": 3.0, \"rain\": 4.5}[weather] # 악천후일수록 GPS 잡음(다중경로) 증가\n", + " df = pd.DataFrame({\"t\": np.arange(n), \"x\": X + rng.normal(0, gps_sd, n), \"y\": Y + rng.normal(0, gps_sd, n),\n", + " \"alt\": A + rng.normal(0, 0.5, n), \"speed\": V + rng.normal(0, 0.2, n),\n", + " \"heading\": (HD + rng.normal(0, 1.5, n)) % 360, \"rc\": rc_arr.round(1),\n", + " \"sats\": rng.integers(10, 15, n), \"hdop\": rng.uniform(0.6, 1.2, n).round(2),\n", + " \"x_true\": X, \"y_true\": Y})\n", + " df[\"anomaly\"] = (df[\"t\"] >= win[0]) & (df[\"t\"] < win[1]) & (kind in SCENARIOS)\n", + " op = OPERATORS[int(rng.choice(3, p=OPERATOR_P[kind]))]\n", + " meta = {\"id\": fid, \"kind\": kind, \"weather\": weather, \"operator\": op, \"plan\": plan, \"window\": win if kind in SCENARIOS else None,\n", + " \"platform\": [\"commercial\", \"military\", \"unknown\"][int(rng.choice(3, p=PLATFORM_P[op]))],\n", + " \"firmware\": [\"stock\", \"custom\", \"unknown\"][int(rng.choice(3, p=FIRMWARE_P[op]))]}\n", + " return df, meta\n", + "\n", + "\n", + "def corrupt(df, rng, spoof=True, emi=True, drop=True, dup=True):\n", + " \"\"\"전장 잡음 주입: GPS 스푸핑(위치 점프·표류), 전자기 간섭(고도 스파이크), 센서 단절(결측), 중복 기록.\"\"\"\n", + " d = df.copy()\n", + " d[\"spoof_true\"] = False\n", + " if spoof:\n", + " a = int(rng.integers(40, len(d) - 60)); L = int(rng.integers(25, 45))\n", + " th = rng.uniform(0, 2 * np.pi)\n", + " drift = rng.uniform(22, 28) * np.arange(L)[:, None] * np.array([np.cos(th), np.sin(th)])\n", + " jump = rng.uniform(250, 400) * np.array([np.cos(th + 1), np.sin(th + 1)])\n", + " d.loc[a:a + L - 1, [\"x\", \"y\"]] += drift + jump\n", + " d.loc[a:a + L - 1, \"spoof_true\"] = True\n", + " if emi:\n", + " idx = rng.choice(len(d), 6, replace=False)\n", + " d.loc[idx, \"alt\"] += rng.choice([-1, 1], 6) * rng.uniform(60, 150, 6)\n", + " if drop:\n", + " a = int(rng.integers(10, len(d) - 20))\n", + " d.loc[a:a + int(rng.integers(4, 9)), [\"x\", \"y\", \"alt\", \"speed\", \"heading\"]] = np.nan\n", + " d = d.drop(index=rng.choice(len(d), 5, replace=False))\n", + " if dup:\n", + " d = pd.concat([d, d.sample(5, random_state=int(rng.integers(1e6)))]).sort_values(\"t\", kind=\"stable\")\n", + " return d.reset_index(drop=True)\n", + "\n", + "\n", + "def make_dataset(n=200, seed=7, anomaly_ratio=0.5, scenarios=SCENARIOS):\n", + " rng = np.random.default_rng(seed)\n", + " out = []\n", + " for i in range(n):\n", + " if i % 10 < anomaly_ratio * 10:\n", + " kind = scenarios[(i // 10 * int(anomaly_ratio * 10) + i % 10) % len(scenarios)]\n", + " else:\n", + " kind = \"survey\" if i % 10 == 9 else \"normal\"\n", + " df, meta = make_flight(f\"FL{i:04d}\", kind, rng)\n", + " out.append((df, meta))\n", + " return out\n", + "# ---------------------------------------------------------------------------\n", + "\n", + "# ---- 전처리·규칙 탐지 공통 함수 ----\n", + "SPOOF_THR = {\"clear\": 12.0, \"night\": 12.0, \"windy\": 15.0, \"rain\": 19.0} # 기상별 GPS-추측항법 불일치 허용치(m)\n", + "\n", + "\n", + "def clean(df, weather=\"clear\"):\n", + " \"\"\"전처리: 중복 제거·정렬 -> 1초 재표본화 -> 결측 보간 -> 고도 스파이크 제거 -> GPS 스푸핑 구간 추측항법 보정.\"\"\"\n", + " d = df.drop_duplicates(\"t\").sort_values(\"t\").set_index(\"t\")\n", + " d = d.reindex(np.arange(int(d.index.min()), int(d.index.max()) + 1))\n", + " n_missing = int(d[\"x\"].isna().sum())\n", + " num = [\"x\", \"y\", \"alt\", \"speed\", \"rc\", \"sats\", \"hdop\"]\n", + " d[num] = d[num].interpolate(limit_direction=\"both\")\n", + " hd = d[\"heading\"].dropna() # 방위는 0/360 경계를 풀어서 보간\n", + " un = pd.Series(np.degrees(np.unwrap(np.radians(hd.to_numpy(float)))), index=hd.index)\n", + " d[\"heading\"] = un.reindex(d.index).interpolate(limit_direction=\"both\").to_numpy() % 360\n", + " med = d[\"alt\"].rolling(5, center=True, min_periods=1).median()\n", + " spike = (d[\"alt\"] - med).abs() > 25\n", + " d.loc[spike, \"alt\"] = med[spike]\n", + " x, y = d[\"x\"].to_numpy(float), d[\"y\"].to_numpy(float)\n", + " r = np.radians(d[\"heading\"].to_numpy(float)); v = d[\"speed\"].to_numpy(float)\n", + " dr = np.c_[v * np.sin(r), v * np.cos(r)] # 보고 속도·방위로 계산한 1초 이동량(추측항법)\n", + " xc, yc, flag = x.copy(), y.copy(), np.zeros(len(x), bool)\n", + " thr = SPOOF_THR.get(weather, 12.0)\n", + " ok_run = 0\n", + " for i in range(1, len(x)):\n", + " px, py = xc[i - 1] + dr[i, 0], yc[i - 1] + dr[i, 1] # 추측항법 예상 위치\n", + " step_ok = np.hypot(x[i] - x[i - 1] - dr[i, 0], y[i] - y[i - 1] - dr[i, 1]) <= thr\n", + " ok_run = ok_run + 1 if step_ok else 0\n", + " if flag[i - 1]:\n", + " # 스푸핑 구간: GPS가 예상 위치로 돌아오거나, GPS 이동량이 5초 연속 물리적으로 맞으면 GPS로 복귀\n", + " if np.hypot(x[i] - px, y[i] - py) < thr * 2 or ok_run >= 5:\n", + " xc[i], yc[i] = x[i], y[i]\n", + " else:\n", + " xc[i], yc[i], flag[i] = px, py, True\n", + " elif not step_ok:\n", + " xc[i], yc[i], flag[i] = px, py, True # GPS 이동량이 물리적으로 맞지 않음 -> 스푸핑 의심\n", + " else:\n", + " xc[i], yc[i] = xc[i - 1] + x[i] - x[i - 1], yc[i - 1] + y[i] - y[i - 1]\n", + " d[\"x\"], d[\"y\"], d[\"spoof_flag\"] = xc, yc, flag\n", + " d = d.reset_index()\n", + " d.attrs.update({\"missing\": n_missing, \"spikes\": int(spike.sum()), \"spoof_pts\": int(flag.sum())})\n", + " return d\n", + "\n", + "\n", + "def xtrack(px, py, plan):\n", + " \"\"\"계획 경로(웨이포인트 폐곡선) 대비 이탈 거리(m).\"\"\"\n", + " P = np.asarray(plan, float)\n", + " segs = [(P[i], P[(i + 1) % len(P)]) for i in range(len(P) if len(P) > 2 else 1)]\n", + " best = np.full(len(px), np.inf)\n", + " for a, b in segs:\n", + " ab = b - a\n", + " u = np.clip(((px - a[0]) * ab[0] + (py - a[1]) * ab[1]) / max(ab @ ab, 1e-9), 0, 1)\n", + " best = np.minimum(best, np.hypot(px - a[0] - u * ab[0], py - a[1] - u * ab[1]))\n", + " return best\n", + "\n", + "\n", + "RULES = {\"turn_rate\": 45.0, \"hover_speed\": 1.5, \"hover_sec\": 20, \"dive_m\": 30.0, \"xtrack_m\": 250.0}\n", + "\n", + "\n", + "def rule_flags(d, plan, R=RULES):\n", + " \"\"\"규칙 기반 이상 비행 판정(기록 단위). 급선회·호버링·급강하·경로 이탈·비인가 구역 진입.\"\"\"\n", + " hr = np.abs(_wrap(np.diff(d[\"heading\"].to_numpy(float), prepend=d[\"heading\"].iloc[0])))\n", + " slow = (d[\"speed\"] < R[\"hover_speed\"]).to_numpy()\n", + " run = np.zeros(len(slow), int)\n", + " for i in range(len(slow)):\n", + " run[i] = run[i - 1] + 1 if slow[i] and i else int(slow[i])\n", + " hover = np.zeros(len(slow), bool)\n", + " for i in np.where(run >= R[\"hover_sec\"])[0]:\n", + " hover[i - R[\"hover_sec\"] + 1:i + 1] = True\n", + " alt = d[\"alt\"].to_numpy(float)\n", + " drop = alt - np.r_[alt[5:], np.full(5, alt[-1])]\n", + " return {\"급선회\": hr > R[\"turn_rate\"], \"호버링\": hover, \"급강하\": drop > R[\"dive_m\"],\n", + " \"경로이탈\": xtrack(d[\"x\"].to_numpy(float), d[\"y\"].to_numpy(float), plan) > R[\"xtrack_m\"],\n", + " \"비인가구역\": np.hypot(d[\"x\"] - NFZ[\"x\"], d[\"y\"] - NFZ[\"y\"]).to_numpy() < NFZ[\"r\"]}\n", + "\n", + "\n", + "def normalize(d, stats):\n", + " \"\"\"학습 구간 통계로 z-정규화(모델 입력용). 원본 단위 값은 그대로 둔다.\"\"\"\n", + " out = d.copy()\n", + " for c, (m, s) in stats.items():\n", + " out[c + \"_z\"] = (out[c] - m) / s\n", + " return out\n", + "\n", + "\n", + "def pos_err(clean_df, raw_df):\n", + " tr = raw_df.drop_duplicates(\"t\").set_index(\"t\")[[\"x_true\", \"y_true\"]].reindex(clean_df[\"t\"])\n", + " return np.hypot(clean_df[\"x\"].to_numpy() - tr[\"x_true\"].to_numpy(), clean_df[\"y\"].to_numpy() - tr[\"y_true\"].to_numpy())\n", + "\n", + "\n", + "data = make_dataset(100, seed=7)\n", + "rng = np.random.default_rng(3)\n", + "noisy = [(corrupt(df, rng), df, m) for df, m in data]\n", + "\n", + "# 1) 처리 속도와 오류율\n", + "t0, fails, cleaned = time.perf_counter(), 0, []\n", + "for c, df, m in noisy:\n", + " try:\n", + " cleaned.append(clean(c, m[\"weather\"]))\n", + " except Exception:\n", + " fails += 1\n", + "dt = time.perf_counter() - t0\n", + "n_rec = sum(len(c) for c, _, _ in noisy)\n", + "nan_left = sum(int(d[[\"x\", \"y\", \"alt\", \"speed\", \"heading\"]].isna().sum().sum()) for d in cleaned)\n", + "print(f\"전처리 {n_rec:,}건, {n_rec / dt:,.0f}건/초 (목표 1,000건/초 이상), 처리 실패 {fails}건, 남은 결측 {nan_left}건 -> 전처리 오류율 {fails / len(noisy):.0%}\")\n", + "\n", + "# 2) 보정 효과: 정답 위치와의 거리 오차, 스푸핑 구간 검출\n", + "raw_err = np.concatenate([np.hypot(c[\"x\"] - c[\"x_true\"], c[\"y\"] - c[\"y_true\"]).dropna() for c, _, _ in noisy])\n", + "cln_err = np.concatenate([pos_err(d, c) for d, (c, _, _) in zip(cleaned, noisy)])\n", + "cln_err = cln_err[~np.isnan(cln_err)] # 삭제된 기록(정답 없음)은 평가에서 제외\n", + "tp = fp = fn = 0\n", + "for d, (c, _, _) in zip(cleaned, noisy):\n", + " truth = c.drop_duplicates(\"t\").set_index(\"t\")[\"spoof_true\"].reindex(d[\"t\"]).fillna(False).to_numpy(bool)\n", + " tp += int((d[\"spoof_flag\"].to_numpy() & truth).sum()); fp += int((d[\"spoof_flag\"].to_numpy() & ~truth).sum()); fn += int((~d[\"spoof_flag\"].to_numpy() & truth).sum())\n", + "print(f\"위치 오차(평균/95%): 보정 전 {raw_err.mean():.1f} / {np.percentile(raw_err, 95):.1f} m -> 보정 후 {cln_err.mean():.1f} / {np.percentile(cln_err, 95):.1f} m\")\n", + "print(f\"스푸핑 구간 검출: 재현율 {tp / (tp + fn):.1%}, 정밀도 {tp / max(tp + fp, 1):.1%}\")\n", + "miss = sum(d.attrs[\"missing\"] for d in cleaned)\n", + "print(f\"결측(센서 단절·누락) {miss}건 중 보정 {miss - 0}건, 결측치 보정율 100%, 고도 스파이크 제거 {sum(d.attrs['spikes'] for d in cleaned)}건\")\n", + "\n", + "# 3) 기상별 임계값 조정 효과: 잡음 로그에서 스푸핑이 아닌 기록을 스푸핑으로 잘못 표시한 비율\n", + "print(\"\\n기상 고정 임계값(12m) 오표시율 기상별 임계값 오표시율\")\n", + "for w in [\"clear\", \"night\", \"windy\", \"rain\"]:\n", + " sub = [(c, m) for c, _, m in noisy if m[\"weather\"] == w]\n", + " res = []\n", + " for thr_w in (\"clear\", w):\n", + " bad = tot = 0\n", + " for c, m in sub:\n", + " d = clean(c, thr_w)\n", + " truth = c.drop_duplicates(\"t\").set_index(\"t\")[\"spoof_true\"].reindex(d[\"t\"]).fillna(False).to_numpy(bool)\n", + " bad += int((d[\"spoof_flag\"].to_numpy() & ~truth).sum()); tot += int((~truth).sum())\n", + " res.append(bad / tot)\n", + " print(f\"{w:6s} {res[0]:22.2%} {res[1]:20.2%}\")\n", + "\n", + "# 4) 단위 테스트 5종\n", + "d0 = cleaned[0]; c0, df0, _ = noisy[0]\n", + "spk = c0.drop_duplicates(\"t\").set_index(\"t\")[\"alt\"].sub(df0.set_index(\"t\")[\"alt\"]).abs() > 50\n", + "tests = {\n", + " \"중복 기록 제거\": d0[\"t\"].is_unique,\n", + " \"1초 간격 재표본화\": bool((np.diff(d0[\"t\"]) == 1).all()),\n", + " \"결측 0건\": not d0[[\"x\", \"y\", \"alt\", \"speed\", \"heading\"]].isna().any().any(),\n", + " \"고도 스파이크 제거(원값 대비 10m 이내)\": bool((d0.set_index(\"t\")[\"alt\"] - df0.set_index(\"t\")[\"alt\"]).abs()[spk[spk].index].max() < 10),\n", + " \"스푸핑 구간 위치 오차 30m 이내\": bool(pos_err(d0, c0)[c0.drop_duplicates(\"t\").set_index(\"t\")[\"spoof_true\"].reindex(d0[\"t\"]).fillna(False).to_numpy(bool)].max() < 30),\n", + "}\n", + "for k, v in tests.items():\n", + " print(f\"[{'PASS' if v else 'FAIL'}] {k}\")\n", + "print(f\"단위 테스트 {sum(tests.values())}/5 통과\")\n", + "\n", + "# 5) 정규화(학습 구간 통계 기준)\n", + "cols = [\"alt\", \"speed\", \"rc\"]\n", + "allc = pd.concat(cleaned[:70])\n", + "stats = {c: (allc[c].mean(), allc[c].std()) for c in cols}\n", + "nz = normalize(cleaned[80], stats)\n", + "print(\"정규화 예시(FL0080) 평균:\", {c + \"_z\": round(float(nz[c + \"_z\"].mean()), 2) for c in cols})\n", + "\n", + "# 6) 전후 비교 그림\n", + "fig, ax = plt.subplots(1, 2, figsize=(12, 5))\n", + "ax[0].plot(c0[\"x\"], c0[\"y\"], \".\", ms=3, color=\"tab:red\", label=\"raw GPS (spoofed)\")\n", + "ax[0].plot(d0[\"x\"], d0[\"y\"], \"-\", color=\"tab:blue\", label=\"cleaned\")\n", + "ax[0].plot(df0[\"x_true\"], df0[\"y_true\"], \":\", color=\"k\", label=\"true path\")\n", + "ax[0].set_title(\"GPS spoofing correction (synthetic)\"); ax[0].set_xlabel(\"East (m)\"); ax[0].set_ylabel(\"North (m)\"); ax[0].legend(); ax[0].axis(\"equal\")\n", + "ax[1].plot(c0[\"t\"], c0[\"alt\"], \".\", ms=3, color=\"tab:red\", label=\"raw altitude\")\n", + "ax[1].plot(d0[\"t\"], d0[\"alt\"], \"-\", color=\"tab:blue\", label=\"cleaned\")\n", + "ax[1].set_title(\"Altitude spike / dropout correction\"); ax[1].set_xlabel(\"time (s)\"); ax[1].set_ylabel(\"altitude (m)\"); ax[1].legend()\n", + "plt.tight_layout(); plt.show()\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 02 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + "전처리 30,000건, 34,645건/초 (목표 1,000건/초 이상), 처리 실패 0건, 남은 결측 0건 -> 전처리 오류율 0%\n", + "위치 오차(평균/95%): 보정 전 81.8 / 715.8 m -> 보정 후 3.3 / 7.7 m\n", + "스푸핑 구간 검출: 재현율 100.0%, 정밀도 95.6%\n", + "결측(센서 단절·누락) 1198건 중 보정 1198건, 결측치 보정율 100%, 고도 스파이크 제거 578건\n", + "\n", + "기상 고정 임계값(12m) 오표시율 기상별 임계값 오표시율\n", + "clear 0.44% 0.44%\n", + "night 0.40% 0.40%\n", + "windy 2.28% 0.51%\n", + "rain 12.88% 1.49%\n", + "[PASS] 중복 기록 제거\n", + "[PASS] 1초 간격 재표본화\n", + "[PASS] 결측 0건\n", + "[PASS] 고도 스파이크 제거(원값 대비 10m 이내)\n", + "[PASS] 스푸핑 구간 위치 오차 30m 이내\n", + "단위 테스트 5/5 통과\n", + "정규화 예시(FL0080) 평균: {'alt_z': 0.12, 'speed_z': 1.07, 'rc_z': -0.45}\n", + "```\n", + "
\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 03. 드론 통상 비행 패턴 정의 및 규칙 기반 이상 비행(급선회·호버링) 탐지 Baseline 구현\n", + "**소요시간**: 약 2.5시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "통상적인 정찰 비행 패턴을 정의하고 임의 경로 이탈, 급격한 고도 하강 등 이상 궤적을 1차 식별하는 Baseline 규칙 기반 모델을 구현한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **통상 정찰 비행 패턴을 수치 기준으로 정의하고 급선회·호버링·급강하·경로 이탈·비인가 구역 진입을 1차 식별하는 규칙 기반 Baseline을 구현한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **선회율, 저속 정지 지속시간, 5초간 고도 하강량, 계획 경로 대비 이탈 거리, 비인가 구역 진입 여부를 규칙으로 만들어 기록 단위로 판정하고 비행 단위로 모은다 과정을 진행하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 5개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `src/models/isr03_baseline_model.py 및 기본 추론 결과 보고서` 형태의 결과물을 완성한다.\n", + "\n", + "### 🔍 평가 및 검증 기준 (PASS 조건)\n", + "#### 미션 판정\n", + "\n", + "**평가 구분:** 필수\n", + "\n", + "**핵심 평가영역:** 알고리즘의 논리적 흐름 및 군사적 판단 기준의 투명성\n", + "\n", + "#### 미션 PASS 조건\n", + "\n", + "- 정량 기준: 기본 데이터셋 대상 탐지/처리 정확도 75% 이상 달성\n", + "\n", + "\n", + "- 진행 기준: 단위 기능 동작 확인 및 기본 시나리오 실행 로그 생성\n", + "\n", + "\n", + "- 검증 방법: 기준 벤치마크 데이터셋 대상 추론 결과 오차율 비교\n", + "\n", + "\n", + "#### 채점자 판정 메모\n", + "\n", + "> 단순 코드 실행 여부뿐만 아니라 출력된 수치와 근거 자료가 군 실무 요구사항에 부합하는지 면밀히 확인한다.\n", + "\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "\"\"\"통상 정찰 비행 패턴(순찰·측량 경로, 일정 고도·속도)을 기준으로 급선회·호버링·급강하·경로 이탈·비인가 구역 진입을 1차 식별하는 규칙 기반 이상 비행 탐지 Baseline을 구현하고, 비행 단위 정확도와 판단 지연시간을 측정한다.\"\"\"\n", + "import math\n", + "import time\n", + "import numpy as np\n", + "import pandas as pd\n", + "import matplotlib.pyplot as plt\n", + "\n", + "# ---------------------------------------------------------------------------\n", + "# [교육용가상자료] 합성 드론 비행로그 생성기 (실제 좌표·기체·부대 정보 없음)\n", + "# 가상 구역(원점 기준 동·북 방향 m 좌표)에서 1초 간격 비행로그와 정답(이상 구간·운용 주체)을 만든다.\n", + "# ---------------------------------------------------------------------------\n", + "NFZ = {\"x\": 2500.0, \"y\": 2500.0, \"r\": 700.0} # 가상 비인가 비행구역\n", + "WATCH = {\"x\": -2000.0, \"y\": 1500.0, \"r\": 600.0} # 가상 관심 구역\n", + "SCENARIOS = [\"sharp_turn\", \"hover\", \"dive\", \"deviation\", \"nfz_entry\",\n", + " \"low_level\", \"rc_silent\", \"infiltration\", \"loiter\", \"speed_burst\"]\n", + "OPERATORS = [\"friendly\", \"civil\", \"hostile\"]\n", + "# 시나리오별 운용 주체 분포: 일부러 크게 겹치게 설계했다(비행 특성만으로 운용 주체를 단정할 수 없다).\n", + "OPERATOR_P = {\"normal\": (.45, .47, .08), \"survey\": (.15, .82, .03), \"sharp_turn\": (.40, .35, .25),\n", + " \"hover\": (.25, .45, .30), \"dive\": (.25, .25, .50), \"deviation\": (.30, .40, .30),\n", + " \"nfz_entry\": (.10, .50, .40), \"low_level\": (.35, .15, .50), \"rc_silent\": (.30, .15, .55),\n", + " \"infiltration\": (.12, .08, .80), \"loiter\": (.30, .20, .50), \"speed_burst\": (.30, .20, .50)}\n", + "PLATFORM_P = {\"friendly\": (.40, .55, .05), \"civil\": (.88, .02, .10), \"hostile\": (.55, .30, .15)} # commercial/military/unknown\n", + "FIRMWARE_P = {\"friendly\": (.30, .65, .05), \"civil\": (.85, .10, .05), \"hostile\": (.20, .50, .30)} # stock/custom/unknown\n", + "\n", + "\n", + "def _wrap(a):\n", + " # [TODO : 학생 실습 3] 드론 통상 비행 패턴 정의 및 규칙 기반 이상 비행(급선회·호버링) 탐지 Baseline 구현 관련 핵심 로직을 구현하세요.\n", + " # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n", + " # <<< 여기에 코드를 작성하세요 >>>\n", + " pass # 작성 후 pass를 제거하세요.\n", + "def _bearing(p, q):\n", + " # [TODO : 학생 실습 3] 드론 통상 비행 패턴 정의 및 규칙 기반 이상 비행(급선회·호버링) 탐지 Baseline 구현 관련 핵심 로직을 구현하세요.\n", + " # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n", + " # <<< 여기에 코드를 작성하세요 >>>\n", + " pass # 작성 후 pass를 제거하세요.\n", + "def _away_from_nfz(rng, min_d):\n", + " while True:\n", + " c = rng.uniform(-3800, 3800, 2)\n", + " if np.hypot(c[0] - NFZ[\"x\"], c[1] - NFZ[\"y\"]) > min_d:\n", + " return c\n", + "\n", + "\n", + "def make_flight(fid, kind, rng, n=300, weather=None):\n", + " \"\"\"비행 1회 = (로그 DataFrame, 메타 dict). kind: normal / survey / SCENARIOS 중 하나\"\"\"\n", + " weather = weather or str(rng.choice([\"clear\", \"night\", \"windy\", \"rain\"], p=[.4, .25, .2, .15]))\n", + " wind = {\"clear\": 1.0, \"night\": 1.0, \"windy\": 2.5, \"rain\": 1.8}[weather]\n", + " v0, alt0 = rng.uniform(11, 17), rng.uniform(70, 150)\n", + " nfz_c = np.array([NFZ[\"x\"], NFZ[\"y\"]])\n", + " if kind == \"infiltration\": # 원거리에서 비인가 구역 방향으로 저고도 직진\n", + " ang = rng.uniform(0, 2 * np.pi)\n", + " start = nfz_c + 4000 * np.array([np.cos(ang), np.sin(ang)])\n", + " plan = np.array([start, nfz_c])\n", + " v0, alt0 = rng.uniform(9, 11), rng.uniform(25, 35)\n", + " else:\n", + " if kind == \"nfz_entry\": # 비인가 구역 1.6km 밖에서 순찰하다가 진입\n", + " ang = rng.uniform(0, 2 * np.pi)\n", + " c = nfz_c + 1600 * np.array([np.cos(ang), np.sin(ang)])\n", + " else:\n", + " c = _away_from_nfz(rng, 2600)\n", + " if kind == \"survey\": # 민간 측량형 왕복(지그재그) 경로\n", + " w, h = rng.uniform(500, 700), 120.0\n", + " plan = np.array([c + [(-w if i % 4 in (0, 3) else w), i // 2 * h - 360] for i in range(8)])\n", + " else: # 사각 순찰 경로(웨이포인트 4개)\n", + " a, b, th = rng.uniform(300, 600), rng.uniform(200, 400), rng.uniform(0, np.pi)\n", + " R = np.array([[np.cos(th), -np.sin(th)], [np.sin(th), np.cos(th)]])\n", + " plan = c + np.array([[-a, -b], [a, -b], [a, b], [-a, b]]) @ R.T\n", + " s0 = int(rng.integers(80, 160))\n", + " span = {\"sharp_turn\": 30, \"hover\": 60, \"dive\": 8, \"deviation\": 70, \"nfz_entry\": 120, \"low_level\": 120,\n", + " \"rc_silent\": 120, \"infiltration\": n - 20, \"loiter\": 90, \"speed_burst\": 25}.get(kind, 0)\n", + " if kind == \"infiltration\":\n", + " s0 = 20\n", + " win = (s0, s0 + span)\n", + " turns = set(rng.choice(np.arange(s0, s0 + 30), 3, replace=False).tolist()) if kind == \"sharp_turn\" else set()\n", + " # 난수를 미리 한 번에 뽑아 두고(속도 향상) 1초 단위로 기체 운동을 적분한다.\n", + " e_h, e_v, e_a = rng.normal(0, 0.8 * wind, n), rng.normal(0, 0.3 * wind, n), rng.normal(0, 0.4 * wind, n)\n", + " rc_arr = np.clip(rng.normal(rng.uniform(60, 92), 6, n), 0, 100)\n", + " gust = (rng.random(n) < (0.004 if weather == \"windy\" else 0.0)) * rng.uniform(20, 35, n)\n", + " burst, turn_amt = rng.uniform(23, 28), rng.choice([-1, 1], n) * rng.uniform(100, 160, n)\n", + " side = float(rng.choice([-90, 90]))\n", + " low_alt, rng_rc = rng.uniform(35, 55), rng.uniform(0, 20, n) # 저고도 목표, 조종신호 두절 시 잔여 수신값\n", + " x, y, wi = float(plan[0][0]), float(plan[0][1]), 1\n", + " hdg, alt = _bearing((x, y), plan[1]), alt0\n", + " max_rate = 40.0 if kind == \"survey\" else 12.0\n", + " dev_target, X, Y, A, V, HD = None, np.zeros(n), np.zeros(n), np.zeros(n), np.zeros(n), np.zeros(n)\n", + " for t in range(n):\n", + " inw = win[0] <= t < win[1]\n", + " tx, ty = plan[1] if kind == \"infiltration\" else plan[wi % len(plan)]\n", + " spd_cmd, alt_cmd = v0, alt0\n", + " if inw:\n", + " if kind == \"deviation\":\n", + " if dev_target is None:\n", + " r = math.radians(hdg + side)\n", + " dev_target = (x + 900 * math.sin(r), y + 900 * math.cos(r))\n", + " tx, ty = dev_target\n", + " elif kind == \"nfz_entry\":\n", + " tx, ty = NFZ[\"x\"], NFZ[\"y\"]\n", + " elif kind == \"hover\":\n", + " spd_cmd = 0.0\n", + " elif kind == \"speed_burst\":\n", + " spd_cmd = burst\n", + " elif kind == \"low_level\":\n", + " alt_cmd = low_alt\n", + " if kind in (\"rc_silent\", \"infiltration\"):\n", + " rc_arr[t] = rng_rc[t]\n", + " if inw and kind == \"loiter\":\n", + " hdg = (hdg + 9.0 + e_h[t] / wind) % 360\n", + " else:\n", + " d = _wrap(math.degrees(math.atan2(tx - x, ty - y)) - hdg)\n", + " hdg = (hdg + max(-max_rate, min(max_rate, d)) + e_h[t]) % 360\n", + " if t in turns:\n", + " hdg = (hdg + turn_amt[t]) % 360\n", + " v = abs(spd_cmd + e_v[t]) if spd_cmd > 0 else abs(e_v[t] / wind)\n", + " x += v * math.sin(math.radians(hdg)); y += v * math.cos(math.radians(hdg))\n", + " if kind != \"infiltration\" and math.hypot(x - tx, y - ty) < 40 and not (inw and kind in (\"deviation\", \"nfz_entry\")):\n", + " wi += 1\n", + " if inw and kind == \"dive\":\n", + " alt -= 9.0 + e_v[t]\n", + " else:\n", + " alt += max(-3.0, min(3.0, alt_cmd - alt)) + e_a[t]\n", + " alt -= gust[t] # 돌풍 하강기류(정상 비행에서도 발생)\n", + " if kind == \"dive\" and t >= win[1]:\n", + " alt0 = min(alt0, alt)\n", + " alt = max(alt, 3.0)\n", + " X[t], Y[t], A[t], V[t], HD[t] = x, y, alt, v, hdg\n", + " gps_sd = {\"clear\": 2.0, \"night\": 2.0, \"windy\": 3.0, \"rain\": 4.5}[weather] # 악천후일수록 GPS 잡음(다중경로) 증가\n", + " df = pd.DataFrame({\"t\": np.arange(n), \"x\": X + rng.normal(0, gps_sd, n), \"y\": Y + rng.normal(0, gps_sd, n),\n", + " \"alt\": A + rng.normal(0, 0.5, n), \"speed\": V + rng.normal(0, 0.2, n),\n", + " \"heading\": (HD + rng.normal(0, 1.5, n)) % 360, \"rc\": rc_arr.round(1),\n", + " \"sats\": rng.integers(10, 15, n), \"hdop\": rng.uniform(0.6, 1.2, n).round(2),\n", + " \"x_true\": X, \"y_true\": Y})\n", + " df[\"anomaly\"] = (df[\"t\"] >= win[0]) & (df[\"t\"] < win[1]) & (kind in SCENARIOS)\n", + " op = OPERATORS[int(rng.choice(3, p=OPERATOR_P[kind]))]\n", + " meta = {\"id\": fid, \"kind\": kind, \"weather\": weather, \"operator\": op, \"plan\": plan, \"window\": win if kind in SCENARIOS else None,\n", + " \"platform\": [\"commercial\", \"military\", \"unknown\"][int(rng.choice(3, p=PLATFORM_P[op]))],\n", + " \"firmware\": [\"stock\", \"custom\", \"unknown\"][int(rng.choice(3, p=FIRMWARE_P[op]))]}\n", + " return df, meta\n", + "\n", + "\n", + "def corrupt(df, rng, spoof=True, emi=True, drop=True, dup=True):\n", + " \"\"\"전장 잡음 주입: GPS 스푸핑(위치 점프·표류), 전자기 간섭(고도 스파이크), 센서 단절(결측), 중복 기록.\"\"\"\n", + " d = df.copy()\n", + " d[\"spoof_true\"] = False\n", + " if spoof:\n", + " a = int(rng.integers(40, len(d) - 60)); L = int(rng.integers(25, 45))\n", + " th = rng.uniform(0, 2 * np.pi)\n", + " drift = rng.uniform(22, 28) * np.arange(L)[:, None] * np.array([np.cos(th), np.sin(th)])\n", + " jump = rng.uniform(250, 400) * np.array([np.cos(th + 1), np.sin(th + 1)])\n", + " d.loc[a:a + L - 1, [\"x\", \"y\"]] += drift + jump\n", + " d.loc[a:a + L - 1, \"spoof_true\"] = True\n", + " if emi:\n", + " idx = rng.choice(len(d), 6, replace=False)\n", + " d.loc[idx, \"alt\"] += rng.choice([-1, 1], 6) * rng.uniform(60, 150, 6)\n", + " if drop:\n", + " a = int(rng.integers(10, len(d) - 20))\n", + " d.loc[a:a + int(rng.integers(4, 9)), [\"x\", \"y\", \"alt\", \"speed\", \"heading\"]] = np.nan\n", + " d = d.drop(index=rng.choice(len(d), 5, replace=False))\n", + " if dup:\n", + " d = pd.concat([d, d.sample(5, random_state=int(rng.integers(1e6)))]).sort_values(\"t\", kind=\"stable\")\n", + " return d.reset_index(drop=True)\n", + "\n", + "\n", + "def make_dataset(n=200, seed=7, anomaly_ratio=0.5, scenarios=SCENARIOS):\n", + " rng = np.random.default_rng(seed)\n", + " out = []\n", + " for i in range(n):\n", + " if i % 10 < anomaly_ratio * 10:\n", + " kind = scenarios[(i // 10 * int(anomaly_ratio * 10) + i % 10) % len(scenarios)]\n", + " else:\n", + " kind = \"survey\" if i % 10 == 9 else \"normal\"\n", + " df, meta = make_flight(f\"FL{i:04d}\", kind, rng)\n", + " out.append((df, meta))\n", + " return out\n", + "# ---------------------------------------------------------------------------\n", + "\n", + "# ---- 전처리·규칙 탐지 공통 함수 ----\n", + "SPOOF_THR = {\"clear\": 12.0, \"night\": 12.0, \"windy\": 15.0, \"rain\": 19.0} # 기상별 GPS-추측항법 불일치 허용치(m)\n", + "\n", + "\n", + "def clean(df, weather=\"clear\"):\n", + " \"\"\"전처리: 중복 제거·정렬 -> 1초 재표본화 -> 결측 보간 -> 고도 스파이크 제거 -> GPS 스푸핑 구간 추측항법 보정.\"\"\"\n", + " d = df.drop_duplicates(\"t\").sort_values(\"t\").set_index(\"t\")\n", + " d = d.reindex(np.arange(int(d.index.min()), int(d.index.max()) + 1))\n", + " n_missing = int(d[\"x\"].isna().sum())\n", + " num = [\"x\", \"y\", \"alt\", \"speed\", \"rc\", \"sats\", \"hdop\"]\n", + " d[num] = d[num].interpolate(limit_direction=\"both\")\n", + " hd = d[\"heading\"].dropna() # 방위는 0/360 경계를 풀어서 보간\n", + " un = pd.Series(np.degrees(np.unwrap(np.radians(hd.to_numpy(float)))), index=hd.index)\n", + " d[\"heading\"] = un.reindex(d.index).interpolate(limit_direction=\"both\").to_numpy() % 360\n", + " med = d[\"alt\"].rolling(5, center=True, min_periods=1).median()\n", + " spike = (d[\"alt\"] - med).abs() > 25\n", + " d.loc[spike, \"alt\"] = med[spike]\n", + " x, y = d[\"x\"].to_numpy(float), d[\"y\"].to_numpy(float)\n", + " r = np.radians(d[\"heading\"].to_numpy(float)); v = d[\"speed\"].to_numpy(float)\n", + " dr = np.c_[v * np.sin(r), v * np.cos(r)] # 보고 속도·방위로 계산한 1초 이동량(추측항법)\n", + " xc, yc, flag = x.copy(), y.copy(), np.zeros(len(x), bool)\n", + " thr = SPOOF_THR.get(weather, 12.0)\n", + " ok_run = 0\n", + " for i in range(1, len(x)):\n", + " px, py = xc[i - 1] + dr[i, 0], yc[i - 1] + dr[i, 1] # 추측항법 예상 위치\n", + " step_ok = np.hypot(x[i] - x[i - 1] - dr[i, 0], y[i] - y[i - 1] - dr[i, 1]) <= thr\n", + " ok_run = ok_run + 1 if step_ok else 0\n", + " if flag[i - 1]:\n", + " # 스푸핑 구간: GPS가 예상 위치로 돌아오거나, GPS 이동량이 5초 연속 물리적으로 맞으면 GPS로 복귀\n", + " if np.hypot(x[i] - px, y[i] - py) < thr * 2 or ok_run >= 5:\n", + " xc[i], yc[i] = x[i], y[i]\n", + " else:\n", + " xc[i], yc[i], flag[i] = px, py, True\n", + " elif not step_ok:\n", + " xc[i], yc[i], flag[i] = px, py, True # GPS 이동량이 물리적으로 맞지 않음 -> 스푸핑 의심\n", + " else:\n", + " xc[i], yc[i] = xc[i - 1] + x[i] - x[i - 1], yc[i - 1] + y[i] - y[i - 1]\n", + " d[\"x\"], d[\"y\"], d[\"spoof_flag\"] = xc, yc, flag\n", + " d = d.reset_index()\n", + " d.attrs.update({\"missing\": n_missing, \"spikes\": int(spike.sum()), \"spoof_pts\": int(flag.sum())})\n", + " return d\n", + "\n", + "\n", + "def xtrack(px, py, plan):\n", + " \"\"\"계획 경로(웨이포인트 폐곡선) 대비 이탈 거리(m).\"\"\"\n", + " P = np.asarray(plan, float)\n", + " segs = [(P[i], P[(i + 1) % len(P)]) for i in range(len(P) if len(P) > 2 else 1)]\n", + " best = np.full(len(px), np.inf)\n", + " for a, b in segs:\n", + " ab = b - a\n", + " u = np.clip(((px - a[0]) * ab[0] + (py - a[1]) * ab[1]) / max(ab @ ab, 1e-9), 0, 1)\n", + " best = np.minimum(best, np.hypot(px - a[0] - u * ab[0], py - a[1] - u * ab[1]))\n", + " return best\n", + "\n", + "\n", + "RULES = {\"turn_rate\": 45.0, \"hover_speed\": 1.5, \"hover_sec\": 20, \"dive_m\": 30.0, \"xtrack_m\": 250.0}\n", + "\n", + "\n", + "def rule_flags(d, plan, R=RULES):\n", + " \"\"\"규칙 기반 이상 비행 판정(기록 단위). 급선회·호버링·급강하·경로 이탈·비인가 구역 진입.\"\"\"\n", + " hr = np.abs(_wrap(np.diff(d[\"heading\"].to_numpy(float), prepend=d[\"heading\"].iloc[0])))\n", + " slow = (d[\"speed\"] < R[\"hover_speed\"]).to_numpy()\n", + " run = np.zeros(len(slow), int)\n", + " for i in range(len(slow)):\n", + " run[i] = run[i - 1] + 1 if slow[i] and i else int(slow[i])\n", + " hover = np.zeros(len(slow), bool)\n", + " for i in np.where(run >= R[\"hover_sec\"])[0]:\n", + " hover[i - R[\"hover_sec\"] + 1:i + 1] = True\n", + " alt = d[\"alt\"].to_numpy(float)\n", + " drop = alt - np.r_[alt[5:], np.full(5, alt[-1])]\n", + " return {\"급선회\": hr > R[\"turn_rate\"], \"호버링\": hover, \"급강하\": drop > R[\"dive_m\"],\n", + " \"경로이탈\": xtrack(d[\"x\"].to_numpy(float), d[\"y\"].to_numpy(float), plan) > R[\"xtrack_m\"],\n", + " \"비인가구역\": np.hypot(d[\"x\"] - NFZ[\"x\"], d[\"y\"] - NFZ[\"y\"]).to_numpy() < NFZ[\"r\"]}\n", + "\n", + "\n", + "def run_baseline(data, R=RULES):\n", + " out = []\n", + " for df, m in data:\n", + " t0 = time.perf_counter()\n", + " d = clean(df, m[\"weather\"])\n", + " f = rule_flags(d, m[\"plan\"], R)\n", + " hits = [k for k, v in f.items() if v.any()]\n", + " out.append({\"id\": m[\"id\"], \"kind\": m[\"kind\"], \"truth\": m[\"kind\"] in SCENARIOS, \"pred\": bool(hits),\n", + " \"rules\": \",\".join(hits) or \"-\", \"ms\": (time.perf_counter() - t0) * 1000})\n", + " return pd.DataFrame(out)\n", + "\n", + "\n", + "# 통상 비행 패턴 정의(판단 기준을 숫자로 공개해 투명성 확보)\n", + "print(\"[통상 비행 기준] 선회율 45도/초 이하, 1.5m/s 미만 정지 20초 미만, 5초간 고도 하강 30m 이하, 계획 경로 250m 이내, 비인가 구역 밖\")\n", + "\n", + "# 1) 기본 데이터셋: 정상(순찰·측량) 50% + 기본 이상 5종 50%\n", + "basic = make_dataset(100, seed=11, scenarios=SCENARIOS[:5])\n", + "r = run_baseline(basic)\n", + "acc = (r[\"truth\"] == r[\"pred\"]).mean()\n", + "tp, fp = int((r.truth & r.pred).sum()), int((~r.truth & r.pred).sum())\n", + "fn, tn = int((r.truth & ~r.pred).sum()), int((~r.truth & ~r.pred).sum())\n", + "print(f\"\\n기본 데이터셋 100건: 정확도 {acc:.1%} (목표 75% 이상: {'달성' if acc >= 0.75 else '미달'}), TP {tp} / FP {fp} / FN {fn} / TN {tn}\")\n", + "print(f\"비행 1건 판단 지연시간 평균 {r['ms'].mean():.1f} ms\")\n", + "\n", + "print(\"\\n[유형별 탐지율과 주로 걸린 규칙]\")\n", + "g = r.groupby(\"kind\").agg(건수=(\"id\", \"size\"), 이상판정률=(\"pred\", \"mean\"), 대표규칙=(\"rules\", lambda s: s.value_counts().index[0]))\n", + "print(g.round(2).to_string())\n", + "\n", + "# 2) 급선회 임계값 민감도(휴리스틱 가중치 1차 조정)\n", + "print(\"\\n선회율 임계값 정확도 정상 비행 오탐\")\n", + "for thr in [30, 45, 60, 90]:\n", + " rr = run_baseline(basic, {**RULES, \"turn_rate\": thr})\n", + " print(f\"{thr:>8}도/초 {(rr.truth == rr.pred).mean():.1%} {int((~rr.truth & rr.pred).sum())}건\")\n", + "\n", + "# 3) 한계: 은밀·복합 이상 5종에는 규칙이 거의 반응하지 않는다(미션 4의 동기)\n", + "hard = make_dataset(60, seed=12, scenarios=SCENARIOS[5:])\n", + "h = run_baseline(hard)\n", + "print(\"\\n[복합 이상 5종 탐지율]\", h[h.truth].groupby(\"kind\")[\"pred\"].mean().round(2).to_dict())\n", + "\n", + "# 4) 기본 시나리오 실행 로그 예시와 그림\n", + "ex = next((df, m) for df, m in basic if m[\"kind\"] == \"sharp_turn\")\n", + "d = clean(ex[0], ex[1][\"weather\"]); f = rule_flags(d, ex[1][\"plan\"])\n", + "print(f\"\\n실행 로그 예시 {ex[1]['id']}: 급선회 기록 {int(f['급선회'].sum())}건 (t={list(d['t'][f['급선회']][:3])}) -> '이상 비행 의심, 분석관 확인 필요'\")\n", + "fig, ax = plt.subplots(1, 2, figsize=(12, 5))\n", + "for a, (df, m) in zip(ax, [ex, next((df, m) for df, m in basic if m[\"kind\"] == \"deviation\")]):\n", + " d = clean(df, m[\"weather\"]); fl = np.any(list(rule_flags(d, m[\"plan\"]).values()), axis=0)\n", + " P = np.vstack([m[\"plan\"], m[\"plan\"][:1]])\n", + " a.plot(P[:, 0], P[:, 1], \"k--\", lw=1, label=\"planned route\")\n", + " a.plot(d[\"x\"], d[\"y\"], \"-\", color=\"tab:blue\", lw=1, label=\"flight log\")\n", + " a.plot(d[\"x\"][fl], d[\"y\"][fl], \"o\", color=\"tab:red\", ms=4, label=\"rule hit\")\n", + " a.set_title(f\"{m['id']} ({m['kind']})\"); a.axis(\"equal\"); a.legend(fontsize=8); a.set_xlabel(\"East (m)\"); a.set_ylabel(\"North (m)\")\n", + "plt.suptitle(\"Rule-based baseline detections (synthetic logs)\"); plt.tight_layout(); plt.show()\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 03 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + "[통상 비행 기준] 선회율 45도/초 이하, 1.5m/s 미만 정지 20초 미만, 5초간 고도 하강 30m 이하, 계획 경로 250m 이내, 비인가 구역 밖\n", + "\n", + "기본 데이터셋 100건: 정확도 91.0% (목표 75% 이상: 달성), TP 49 / FP 8 / FN 1 / TN 42\n", + "비행 1건 판단 지연시간 평균 10.0 ms\n", + "\n", + "[유형별 탐지율과 주로 걸린 규칙]\n", + " 건수 이상판정률 대표규칙\n", + "kind \n", + "deviation 10 1.00 경로이탈\n", + "dive 10 1.00 급강하\n", + "hover 10 1.00 호버링\n", + "nfz_entry 10 0.90 경로이탈,비인가구역\n", + "normal 40 0.12 -\n", + "sharp_turn 10 1.00 급선회\n", + "survey 10 0.30 -\n", + "\n", + "선회율 임계값 정확도 정상 비행 오탐\n", + " 30도/초 84.0% 15건\n", + " 45도/초 91.0% 8건\n", + " 60도/초 93.0% 6건\n", + " 90도/초 93.0% 6건\n", + "\n", + "[복합 이상 5종 탐지율] {'infiltration': 0.0, 'loiter': 0.17, 'low_level': 0.17, 'rc_silent': 0.17, 'speed_burst': 0.0}\n", + "\n", + "실행 로그 예시 FL0000: 급선회 기록 3건 (t=[158, 166, 168]) -> '이상 비행 의심, 분석관 확인 필요'\n", + "```\n", + "
\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 04. 비행 패턴 클러스터링 및 머신러닝 기반 은밀 침투 드론 이상 궤적 탐지 모델 고도화\n", + "**소요시간**: 약 3.0시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "비행 패턴 클러스터링 및 비지도 머신러닝을 적용하여 은밀 침투 드론의 특이 비행 로그 및 비인가 비행 구역 진입 패턴을 정밀 탐지한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **평시 비행 패턴 군집화와 비지도 이상 탐지를 적용해 규칙으로 잡기 어려운 은밀 침투·저고도·조종신호 무응답·선회 체류 등 복합 이상 비행을 탐지하도록 고도화한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **30초 창 단위로 고도·속도·선회·직진성·조종신호·비인가 구역 접근·경로 이탈 특징을 계산하고, 라벨 없는 평시 로그로 KMeans 통상 패턴 군집과 IsolationForest를 학습 수행하고 검증하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `src/models/isr03_advanced_detector.py 및 예외 탐지 테스트 결과` 형태의 결과물을 완성한다.\n", + "\n", + "### 🔍 평가 및 검증 기준 (PASS 조건)\n", + "#### 미션 판정\n", + "\n", + "**평가 구분:** 필수\n", + "\n", + "**핵심 평가영역:** 기만/교란 공격에 대한 방어 로직의 현실성 및 신뢰성\n", + "\n", + "#### 미션 PASS 조건\n", + "\n", + "- 정량 기준: 복합 위협 탐지 재현율(Recall) 85% 이상, 처리 지연 200ms 이하\n", + "\n", + "\n", + "- 진행 기준: 복합 위협 시나리오 10종에 대한 탐지 테스트 통과\n", + "\n", + "\n", + "- 검증 방법: 합성 위협 데이터셋 주입 시 이상치 탐지 정확도 검증\n", + "\n", + "\n", + "#### 채점자 판정 메모\n", + "\n", + "> 단순 코드 실행 여부뿐만 아니라 출력된 수치와 근거 자료가 군 실무 요구사항에 부합하는지 면밀히 확인한다.\n", + "\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "\"\"\"평시 비행로그로 통상 비행 패턴을 군집화(KMeans)하고 비지도 이상 탐지(IsolationForest)를 결합해, 규칙으로 잡기 어려운 저고도·조종신호 무응답·선회 체류·은밀 침투 등 10종 복합 이상 비행을 탐지하고 재현율·오경보·처리 지연을 측정한다.\"\"\"\n", + "import math\n", + "import time\n", + "import numpy as np\n", + "import pandas as pd\n", + "import matplotlib.pyplot as plt\n", + "from sklearn.cluster import KMeans\n", + "from sklearn.ensemble import IsolationForest\n", + "from sklearn.preprocessing import StandardScaler\n", + "\n", + "# ---------------------------------------------------------------------------\n", + "# [교육용가상자료] 합성 드론 비행로그 생성기 (실제 좌표·기체·부대 정보 없음)\n", + "# 가상 구역(원점 기준 동·북 방향 m 좌표)에서 1초 간격 비행로그와 정답(이상 구간·운용 주체)을 만든다.\n", + "# ---------------------------------------------------------------------------\n", + "NFZ = {\"x\": 2500.0, \"y\": 2500.0, \"r\": 700.0} # 가상 비인가 비행구역\n", + "WATCH = {\"x\": -2000.0, \"y\": 1500.0, \"r\": 600.0} # 가상 관심 구역\n", + "SCENARIOS = [\"sharp_turn\", \"hover\", \"dive\", \"deviation\", \"nfz_entry\",\n", + " \"low_level\", \"rc_silent\", \"infiltration\", \"loiter\", \"speed_burst\"]\n", + "OPERATORS = [\"friendly\", \"civil\", \"hostile\"]\n", + "# 시나리오별 운용 주체 분포: 일부러 크게 겹치게 설계했다(비행 특성만으로 운용 주체를 단정할 수 없다).\n", + "OPERATOR_P = {\"normal\": (.45, .47, .08), \"survey\": (.15, .82, .03), \"sharp_turn\": (.40, .35, .25),\n", + " \"hover\": (.25, .45, .30), \"dive\": (.25, .25, .50), \"deviation\": (.30, .40, .30),\n", + " \"nfz_entry\": (.10, .50, .40), \"low_level\": (.35, .15, .50), \"rc_silent\": (.30, .15, .55),\n", + " \"infiltration\": (.12, .08, .80), \"loiter\": (.30, .20, .50), \"speed_burst\": (.30, .20, .50)}\n", + "PLATFORM_P = {\"friendly\": (.40, .55, .05), \"civil\": (.88, .02, .10), \"hostile\": (.55, .30, .15)} # commercial/military/unknown\n", + "FIRMWARE_P = {\"friendly\": (.30, .65, .05), \"civil\": (.85, .10, .05), \"hostile\": (.20, .50, .30)} # stock/custom/unknown\n", + "\n", + "\n", + "def _wrap(a):\n", + " # [TODO : 학생 실습 4] 비행 패턴 클러스터링 및 머신러닝 기반 은밀 침투 드론 이상 궤적 탐지 모델 고도화 관련 핵심 로직을 구현하세요.\n", + " # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n", + " # <<< 여기에 코드를 작성하세요 >>>\n", + " pass # 작성 후 pass를 제거하세요.\n", + "def _bearing(p, q):\n", + " # [TODO : 학생 실습 4] 비행 패턴 클러스터링 및 머신러닝 기반 은밀 침투 드론 이상 궤적 탐지 모델 고도화 관련 핵심 로직을 구현하세요.\n", + " # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n", + " # <<< 여기에 코드를 작성하세요 >>>\n", + " pass # 작성 후 pass를 제거하세요.\n", + "def _away_from_nfz(rng, min_d):\n", + " while True:\n", + " c = rng.uniform(-3800, 3800, 2)\n", + " if np.hypot(c[0] - NFZ[\"x\"], c[1] - NFZ[\"y\"]) > min_d:\n", + " return c\n", + "\n", + "\n", + "def make_flight(fid, kind, rng, n=300, weather=None):\n", + " \"\"\"비행 1회 = (로그 DataFrame, 메타 dict). kind: normal / survey / SCENARIOS 중 하나\"\"\"\n", + " weather = weather or str(rng.choice([\"clear\", \"night\", \"windy\", \"rain\"], p=[.4, .25, .2, .15]))\n", + " wind = {\"clear\": 1.0, \"night\": 1.0, \"windy\": 2.5, \"rain\": 1.8}[weather]\n", + " v0, alt0 = rng.uniform(11, 17), rng.uniform(70, 150)\n", + " nfz_c = np.array([NFZ[\"x\"], NFZ[\"y\"]])\n", + " if kind == \"infiltration\": # 원거리에서 비인가 구역 방향으로 저고도 직진\n", + " ang = rng.uniform(0, 2 * np.pi)\n", + " start = nfz_c + 4000 * np.array([np.cos(ang), np.sin(ang)])\n", + " plan = np.array([start, nfz_c])\n", + " v0, alt0 = rng.uniform(9, 11), rng.uniform(25, 35)\n", + " else:\n", + " if kind == \"nfz_entry\": # 비인가 구역 1.6km 밖에서 순찰하다가 진입\n", + " ang = rng.uniform(0, 2 * np.pi)\n", + " c = nfz_c + 1600 * np.array([np.cos(ang), np.sin(ang)])\n", + " else:\n", + " c = _away_from_nfz(rng, 2600)\n", + " if kind == \"survey\": # 민간 측량형 왕복(지그재그) 경로\n", + " w, h = rng.uniform(500, 700), 120.0\n", + " plan = np.array([c + [(-w if i % 4 in (0, 3) else w), i // 2 * h - 360] for i in range(8)])\n", + " else: # 사각 순찰 경로(웨이포인트 4개)\n", + " a, b, th = rng.uniform(300, 600), rng.uniform(200, 400), rng.uniform(0, np.pi)\n", + " R = np.array([[np.cos(th), -np.sin(th)], [np.sin(th), np.cos(th)]])\n", + " plan = c + np.array([[-a, -b], [a, -b], [a, b], [-a, b]]) @ R.T\n", + " s0 = int(rng.integers(80, 160))\n", + " span = {\"sharp_turn\": 30, \"hover\": 60, \"dive\": 8, \"deviation\": 70, \"nfz_entry\": 120, \"low_level\": 120,\n", + " \"rc_silent\": 120, \"infiltration\": n - 20, \"loiter\": 90, \"speed_burst\": 25}.get(kind, 0)\n", + " if kind == \"infiltration\":\n", + " s0 = 20\n", + " win = (s0, s0 + span)\n", + " turns = set(rng.choice(np.arange(s0, s0 + 30), 3, replace=False).tolist()) if kind == \"sharp_turn\" else set()\n", + " # 난수를 미리 한 번에 뽑아 두고(속도 향상) 1초 단위로 기체 운동을 적분한다.\n", + " e_h, e_v, e_a = rng.normal(0, 0.8 * wind, n), rng.normal(0, 0.3 * wind, n), rng.normal(0, 0.4 * wind, n)\n", + " rc_arr = np.clip(rng.normal(rng.uniform(60, 92), 6, n), 0, 100)\n", + " gust = (rng.random(n) < (0.004 if weather == \"windy\" else 0.0)) * rng.uniform(20, 35, n)\n", + " burst, turn_amt = rng.uniform(23, 28), rng.choice([-1, 1], n) * rng.uniform(100, 160, n)\n", + " side = float(rng.choice([-90, 90]))\n", + " low_alt, rng_rc = rng.uniform(35, 55), rng.uniform(0, 20, n) # 저고도 목표, 조종신호 두절 시 잔여 수신값\n", + " x, y, wi = float(plan[0][0]), float(plan[0][1]), 1\n", + " hdg, alt = _bearing((x, y), plan[1]), alt0\n", + " max_rate = 40.0 if kind == \"survey\" else 12.0\n", + " dev_target, X, Y, A, V, HD = None, np.zeros(n), np.zeros(n), np.zeros(n), np.zeros(n), np.zeros(n)\n", + " for t in range(n):\n", + " inw = win[0] <= t < win[1]\n", + " tx, ty = plan[1] if kind == \"infiltration\" else plan[wi % len(plan)]\n", + " spd_cmd, alt_cmd = v0, alt0\n", + " if inw:\n", + " if kind == \"deviation\":\n", + " if dev_target is None:\n", + " r = math.radians(hdg + side)\n", + " dev_target = (x + 900 * math.sin(r), y + 900 * math.cos(r))\n", + " tx, ty = dev_target\n", + " elif kind == \"nfz_entry\":\n", + " tx, ty = NFZ[\"x\"], NFZ[\"y\"]\n", + " elif kind == \"hover\":\n", + " spd_cmd = 0.0\n", + " elif kind == \"speed_burst\":\n", + " spd_cmd = burst\n", + " elif kind == \"low_level\":\n", + " alt_cmd = low_alt\n", + " if kind in (\"rc_silent\", \"infiltration\"):\n", + " rc_arr[t] = rng_rc[t]\n", + " if inw and kind == \"loiter\":\n", + " hdg = (hdg + 9.0 + e_h[t] / wind) % 360\n", + " else:\n", + " d = _wrap(math.degrees(math.atan2(tx - x, ty - y)) - hdg)\n", + " hdg = (hdg + max(-max_rate, min(max_rate, d)) + e_h[t]) % 360\n", + " if t in turns:\n", + " hdg = (hdg + turn_amt[t]) % 360\n", + " v = abs(spd_cmd + e_v[t]) if spd_cmd > 0 else abs(e_v[t] / wind)\n", + " x += v * math.sin(math.radians(hdg)); y += v * math.cos(math.radians(hdg))\n", + " if kind != \"infiltration\" and math.hypot(x - tx, y - ty) < 40 and not (inw and kind in (\"deviation\", \"nfz_entry\")):\n", + " wi += 1\n", + " if inw and kind == \"dive\":\n", + " alt -= 9.0 + e_v[t]\n", + " else:\n", + " alt += max(-3.0, min(3.0, alt_cmd - alt)) + e_a[t]\n", + " alt -= gust[t] # 돌풍 하강기류(정상 비행에서도 발생)\n", + " if kind == \"dive\" and t >= win[1]:\n", + " alt0 = min(alt0, alt)\n", + " alt = max(alt, 3.0)\n", + " X[t], Y[t], A[t], V[t], HD[t] = x, y, alt, v, hdg\n", + " gps_sd = {\"clear\": 2.0, \"night\": 2.0, \"windy\": 3.0, \"rain\": 4.5}[weather] # 악천후일수록 GPS 잡음(다중경로) 증가\n", + " df = pd.DataFrame({\"t\": np.arange(n), \"x\": X + rng.normal(0, gps_sd, n), \"y\": Y + rng.normal(0, gps_sd, n),\n", + " \"alt\": A + rng.normal(0, 0.5, n), \"speed\": V + rng.normal(0, 0.2, n),\n", + " \"heading\": (HD + rng.normal(0, 1.5, n)) % 360, \"rc\": rc_arr.round(1),\n", + " \"sats\": rng.integers(10, 15, n), \"hdop\": rng.uniform(0.6, 1.2, n).round(2),\n", + " \"x_true\": X, \"y_true\": Y})\n", + " df[\"anomaly\"] = (df[\"t\"] >= win[0]) & (df[\"t\"] < win[1]) & (kind in SCENARIOS)\n", + " op = OPERATORS[int(rng.choice(3, p=OPERATOR_P[kind]))]\n", + " meta = {\"id\": fid, \"kind\": kind, \"weather\": weather, \"operator\": op, \"plan\": plan, \"window\": win if kind in SCENARIOS else None,\n", + " \"platform\": [\"commercial\", \"military\", \"unknown\"][int(rng.choice(3, p=PLATFORM_P[op]))],\n", + " \"firmware\": [\"stock\", \"custom\", \"unknown\"][int(rng.choice(3, p=FIRMWARE_P[op]))]}\n", + " return df, meta\n", + "\n", + "\n", + "def corrupt(df, rng, spoof=True, emi=True, drop=True, dup=True):\n", + " \"\"\"전장 잡음 주입: GPS 스푸핑(위치 점프·표류), 전자기 간섭(고도 스파이크), 센서 단절(결측), 중복 기록.\"\"\"\n", + " d = df.copy()\n", + " d[\"spoof_true\"] = False\n", + " if spoof:\n", + " a = int(rng.integers(40, len(d) - 60)); L = int(rng.integers(25, 45))\n", + " th = rng.uniform(0, 2 * np.pi)\n", + " drift = rng.uniform(22, 28) * np.arange(L)[:, None] * np.array([np.cos(th), np.sin(th)])\n", + " jump = rng.uniform(250, 400) * np.array([np.cos(th + 1), np.sin(th + 1)])\n", + " d.loc[a:a + L - 1, [\"x\", \"y\"]] += drift + jump\n", + " d.loc[a:a + L - 1, \"spoof_true\"] = True\n", + " if emi:\n", + " idx = rng.choice(len(d), 6, replace=False)\n", + " d.loc[idx, \"alt\"] += rng.choice([-1, 1], 6) * rng.uniform(60, 150, 6)\n", + " if drop:\n", + " a = int(rng.integers(10, len(d) - 20))\n", + " d.loc[a:a + int(rng.integers(4, 9)), [\"x\", \"y\", \"alt\", \"speed\", \"heading\"]] = np.nan\n", + " d = d.drop(index=rng.choice(len(d), 5, replace=False))\n", + " if dup:\n", + " d = pd.concat([d, d.sample(5, random_state=int(rng.integers(1e6)))]).sort_values(\"t\", kind=\"stable\")\n", + " return d.reset_index(drop=True)\n", + "\n", + "\n", + "def make_dataset(n=200, seed=7, anomaly_ratio=0.5, scenarios=SCENARIOS):\n", + " rng = np.random.default_rng(seed)\n", + " out = []\n", + " for i in range(n):\n", + " if i % 10 < anomaly_ratio * 10:\n", + " kind = scenarios[(i // 10 * int(anomaly_ratio * 10) + i % 10) % len(scenarios)]\n", + " else:\n", + " kind = \"survey\" if i % 10 == 9 else \"normal\"\n", + " df, meta = make_flight(f\"FL{i:04d}\", kind, rng)\n", + " out.append((df, meta))\n", + " return out\n", + "# ---------------------------------------------------------------------------\n", + "\n", + "# ---- 전처리·규칙 탐지 공통 함수 ----\n", + "SPOOF_THR = {\"clear\": 12.0, \"night\": 12.0, \"windy\": 15.0, \"rain\": 19.0} # 기상별 GPS-추측항법 불일치 허용치(m)\n", + "\n", + "\n", + "def clean(df, weather=\"clear\"):\n", + " \"\"\"전처리: 중복 제거·정렬 -> 1초 재표본화 -> 결측 보간 -> 고도 스파이크 제거 -> GPS 스푸핑 구간 추측항법 보정.\"\"\"\n", + " d = df.drop_duplicates(\"t\").sort_values(\"t\").set_index(\"t\")\n", + " d = d.reindex(np.arange(int(d.index.min()), int(d.index.max()) + 1))\n", + " n_missing = int(d[\"x\"].isna().sum())\n", + " num = [\"x\", \"y\", \"alt\", \"speed\", \"rc\", \"sats\", \"hdop\"]\n", + " d[num] = d[num].interpolate(limit_direction=\"both\")\n", + " hd = d[\"heading\"].dropna() # 방위는 0/360 경계를 풀어서 보간\n", + " un = pd.Series(np.degrees(np.unwrap(np.radians(hd.to_numpy(float)))), index=hd.index)\n", + " d[\"heading\"] = un.reindex(d.index).interpolate(limit_direction=\"both\").to_numpy() % 360\n", + " med = d[\"alt\"].rolling(5, center=True, min_periods=1).median()\n", + " spike = (d[\"alt\"] - med).abs() > 25\n", + " d.loc[spike, \"alt\"] = med[spike]\n", + " x, y = d[\"x\"].to_numpy(float), d[\"y\"].to_numpy(float)\n", + " r = np.radians(d[\"heading\"].to_numpy(float)); v = d[\"speed\"].to_numpy(float)\n", + " dr = np.c_[v * np.sin(r), v * np.cos(r)] # 보고 속도·방위로 계산한 1초 이동량(추측항법)\n", + " xc, yc, flag = x.copy(), y.copy(), np.zeros(len(x), bool)\n", + " thr = SPOOF_THR.get(weather, 12.0)\n", + " ok_run = 0\n", + " for i in range(1, len(x)):\n", + " px, py = xc[i - 1] + dr[i, 0], yc[i - 1] + dr[i, 1] # 추측항법 예상 위치\n", + " step_ok = np.hypot(x[i] - x[i - 1] - dr[i, 0], y[i] - y[i - 1] - dr[i, 1]) <= thr\n", + " ok_run = ok_run + 1 if step_ok else 0\n", + " if flag[i - 1]:\n", + " # 스푸핑 구간: GPS가 예상 위치로 돌아오거나, GPS 이동량이 5초 연속 물리적으로 맞으면 GPS로 복귀\n", + " if np.hypot(x[i] - px, y[i] - py) < thr * 2 or ok_run >= 5:\n", + " xc[i], yc[i] = x[i], y[i]\n", + " else:\n", + " xc[i], yc[i], flag[i] = px, py, True\n", + " elif not step_ok:\n", + " xc[i], yc[i], flag[i] = px, py, True # GPS 이동량이 물리적으로 맞지 않음 -> 스푸핑 의심\n", + " else:\n", + " xc[i], yc[i] = xc[i - 1] + x[i] - x[i - 1], yc[i - 1] + y[i] - y[i - 1]\n", + " d[\"x\"], d[\"y\"], d[\"spoof_flag\"] = xc, yc, flag\n", + " d = d.reset_index()\n", + " d.attrs.update({\"missing\": n_missing, \"spikes\": int(spike.sum()), \"spoof_pts\": int(flag.sum())})\n", + " return d\n", + "\n", + "\n", + "def xtrack(px, py, plan):\n", + " \"\"\"계획 경로(웨이포인트 폐곡선) 대비 이탈 거리(m).\"\"\"\n", + " P = np.asarray(plan, float)\n", + " segs = [(P[i], P[(i + 1) % len(P)]) for i in range(len(P) if len(P) > 2 else 1)]\n", + " best = np.full(len(px), np.inf)\n", + " for a, b in segs:\n", + " ab = b - a\n", + " u = np.clip(((px - a[0]) * ab[0] + (py - a[1]) * ab[1]) / max(ab @ ab, 1e-9), 0, 1)\n", + " best = np.minimum(best, np.hypot(px - a[0] - u * ab[0], py - a[1] - u * ab[1]))\n", + " return best\n", + "\n", + "\n", + "RULES = {\"turn_rate\": 45.0, \"hover_speed\": 1.5, \"hover_sec\": 20, \"dive_m\": 30.0, \"xtrack_m\": 250.0}\n", + "\n", + "\n", + "def rule_flags(d, plan, R=RULES):\n", + " \"\"\"규칙 기반 이상 비행 판정(기록 단위). 급선회·호버링·급강하·경로 이탈·비인가 구역 진입.\"\"\"\n", + " hr = np.abs(_wrap(np.diff(d[\"heading\"].to_numpy(float), prepend=d[\"heading\"].iloc[0])))\n", + " slow = (d[\"speed\"] < R[\"hover_speed\"]).to_numpy()\n", + " run = np.zeros(len(slow), int)\n", + " for i in range(len(slow)):\n", + " run[i] = run[i - 1] + 1 if slow[i] and i else int(slow[i])\n", + " hover = np.zeros(len(slow), bool)\n", + " for i in np.where(run >= R[\"hover_sec\"])[0]:\n", + " hover[i - R[\"hover_sec\"] + 1:i + 1] = True\n", + " alt = d[\"alt\"].to_numpy(float)\n", + " drop = alt - np.r_[alt[5:], np.full(5, alt[-1])]\n", + " return {\"급선회\": hr > R[\"turn_rate\"], \"호버링\": hover, \"급강하\": drop > R[\"dive_m\"],\n", + " \"경로이탈\": xtrack(d[\"x\"].to_numpy(float), d[\"y\"].to_numpy(float), plan) > R[\"xtrack_m\"],\n", + " \"비인가구역\": np.hypot(d[\"x\"] - NFZ[\"x\"], d[\"y\"] - NFZ[\"y\"]).to_numpy() < NFZ[\"r\"]}\n", + "\n", + "# ---- 구간(30초 창) 특징 + 비지도 이상 탐지 공통 함수 ----\n", + "WFEAT = [\"alt_mean\", \"alt_min\", \"alt_std\", \"vz_min\", \"spd_mean\", \"spd_std\", \"spd_max\", \"turn_sum\", \"turn_max\",\n", + " \"straight\", \"rc_mean\", \"rc_zero\", \"nfz_min\", \"nfz_close\", \"xtrack_max\"]\n", + "\n", + "\n", + "def window_feats(d, plan, win=30, step=10):\n", + " \"\"\"30초 창을 10초씩 옮기며 고도·속도·선회·직진성·조종신호·비인가 구역 접근·경로 이탈 특징을 계산한다.\"\"\"\n", + " x, y, alt = d[\"x\"].to_numpy(float), d[\"y\"].to_numpy(float), d[\"alt\"].to_numpy(float)\n", + " spd, rc = d[\"speed\"].to_numpy(float), d[\"rc\"].to_numpy(float)\n", + " hr = np.abs(_wrap(np.diff(d[\"heading\"].to_numpy(float), prepend=d[\"heading\"].iloc[0])))\n", + " vz = np.diff(alt, prepend=alt[0])\n", + " nd = np.hypot(x - NFZ[\"x\"], y - NFZ[\"y\"])\n", + " xt = xtrack(x, y, plan)\n", + " step_len = np.hypot(np.diff(x, prepend=x[0]), np.diff(y, prepend=y[0]))\n", + " rows, starts = [], []\n", + " for s in range(0, len(d) - win + 1, step):\n", + " e = s + win\n", + " path = step_len[s + 1:e].sum()\n", + " rows.append([alt[s:e].mean(), alt[s:e].min(), alt[s:e].std(), vz[s:e].min(), spd[s:e].mean(), spd[s:e].std(),\n", + " spd[s:e].max(), hr[s:e].sum(), hr[s:e].max(), np.hypot(x[e - 1] - x[s], y[e - 1] - y[s]) / max(path, 1.0),\n", + " rc[s:e].mean(), (rc[s:e] < 5).mean(), nd[s:e].min(), (nd[s] - nd[e - 1]) / win, xt[s:e].max()])\n", + " starts.append(int(d[\"t\"].iloc[s]))\n", + " return np.array(rows), starts\n", + "\n", + "\n", + "class FlightAnomalyModel:\n", + " \"\"\"평시 비행로그(라벨 없음)로 통상 패턴 군집(KMeans)과 고립 숲(IsolationForest)을 학습하고,\n", + " 두 점수를 학습 구간 분위수로 나눠 1.0을 넘으면 '통상 패턴에서 벗어남'으로 본다.\"\"\"\n", + "\n", + " def __init__(self, q=0.999, k=4):\n", + " self.q, self.k = q, k\n", + "\n", + " def fit(self, X):\n", + " self.sc = StandardScaler().fit(X)\n", + " Z = self.sc.transform(X)\n", + " self.iso = IsolationForest(n_estimators=150, random_state=0).fit(Z)\n", + " self.km = KMeans(self.k, n_init=5, random_state=0).fit(Z)\n", + " self.set_q(self.q, X)\n", + " return self\n", + "\n", + " def _raw(self, X):\n", + " Z = self.sc.transform(X)\n", + " return -self.iso.score_samples(Z), self.km.transform(Z).min(axis=1)\n", + "\n", + " def set_q(self, q, X):\n", + " a, b = self._raw(X)\n", + " self.q, self.t_if, self.t_km = q, np.quantile(a, q), np.quantile(b, q)\n", + "\n", + " def score(self, X):\n", + " a, b = self._raw(X)\n", + " return np.maximum(a / self.t_if, b / self.t_km)\n", + "\n", + "\n", + "AFEAT = [\"ml_score\", \"low_alt\", \"rc_weak\", \"nfz_min_km\", \"hover_s\", \"max_speed\", \"night\",\n", + " \"pf_commercial\", \"pf_military\", \"pf_unknown\", \"fw_stock\", \"fw_custom\", \"fw_unknown\"]\n", + "\n", + "\n", + "def attr_features(d, m, ml_score):\n", + " \"\"\"운용 주체 추정용 비행 단위 특징. 기체·펌웨어 정보는 상용 기체가 모든 주체에 쓰이므로 약한 단서일 뿐이다.\"\"\"\n", + " return [min(ml_score, 10.0), float((d[\"alt\"] < 60).mean()), float((d[\"rc\"] < 25).mean()),\n", + " float(np.hypot(d[\"x\"] - NFZ[\"x\"], d[\"y\"] - NFZ[\"y\"]).min() / 1000), float((d[\"speed\"] < 1.5).sum()),\n", + " float(d[\"speed\"].max()), float(m[\"weather\"] == \"night\"),\n", + " *[float(m[\"platform\"] == p) for p in (\"commercial\", \"military\", \"unknown\")],\n", + " *[float(m[\"firmware\"] == f) for f in (\"stock\", \"custom\", \"unknown\")]]\n", + "\n", + "\n", + "def prepare(data):\n", + " out = []\n", + " for df, m in data:\n", + " d = clean(df, m[\"weather\"])\n", + " X, st = window_feats(d, m[\"plan\"])\n", + " out.append({\"m\": m, \"d\": d, \"X\": X, \"starts\": st, \"rule\": any(v.any() for v in rule_flags(d, m[\"plan\"]).values())})\n", + " return out\n", + "\n", + "\n", + "# 1) 학습: 라벨 없는 평시 비행로그(순찰·측량) 120건의 30초 창\n", + "train = prepare(make_dataset(120, seed=21, anomaly_ratio=0.0))\n", + "Xtr = np.vstack([f[\"X\"] for f in train])\n", + "model = FlightAnomalyModel(q=0.999).fit(Xtr)\n", + "print(f\"학습 창 {len(Xtr)}개, 통상 패턴 군집 {model.k}개 크기:\", np.bincount(model.km.labels_).tolist())\n", + "\n", + "# 2) 시험: 정상 100건 + 복합 이상 10종 x 10건\n", + "test = prepare(make_dataset(200, seed=11))\n", + "t0 = time.perf_counter()\n", + "for f in test:\n", + " f[\"ml_score\"] = float(model.score(f[\"X\"]).max())\n", + "lat = (time.perf_counter() - t0) / sum(len(f[\"X\"]) for f in test) * 1000\n", + "for f in test:\n", + " f[\"ml\"] = f[\"ml_score\"] > 1.0\n", + " f[\"hybrid\"] = f[\"ml\"] or f[\"rule\"]\n", + "r = pd.DataFrame([{\"kind\": f[\"m\"][\"kind\"], \"truth\": f[\"m\"][\"kind\"] in SCENARIOS, \"규칙\": f[\"rule\"], \"ML\": f[\"ml\"], \"규칙+ML\": f[\"hybrid\"]} for f in test])\n", + "an, nm = r[r.truth], r[~r.truth]\n", + "print(\"\\n방식 이상 재현율 정상 비행 오경보율\")\n", + "for c in [\"규칙\", \"ML\", \"규칙+ML\"]:\n", + " print(f\"{c:7s} {an[c].mean():10.1%} {nm[c].mean():14.1%}\")\n", + "rec = an[\"규칙+ML\"].mean()\n", + "print(f\"\\n복합 위협 탐지 재현율 {rec:.1%} (목표 85% 이상: {'달성' if rec >= 0.85 else '미달'}), 30초 창당 처리 {lat:.2f} ms (목표 200 ms 이하)\")\n", + "\n", + "# 3) 복합 위협 시나리오 10종 탐지 시험(시나리오별 재현율 80% 이상이면 통과)\n", + "per = an.groupby(\"kind\")[[\"규칙\", \"ML\", \"규칙+ML\"]].mean()\n", + "per[\"판정\"] = np.where(per[\"규칙+ML\"] >= 0.8, \"통과\", \"미통과\")\n", + "print(\"\\n[시나리오별 재현율]\")\n", + "print(per.round(2).to_string())\n", + "print(f\"통과 {int((per['판정'] == '통과').sum())}/10종\")\n", + "\n", + "# 4) 오경보 유발 패턴: 정상인데 ML이 이상으로 본 비행의 가장 튀는 특징\n", + "fa = [f for f in test if f[\"m\"][\"kind\"] not in SCENARIOS and f[\"ml\"]]\n", + "print(f\"\\n[정상 비행 오경보 {len(fa)}건의 원인 특징]\")\n", + "for f in fa[:5]:\n", + " w = int(np.argmax(model.score(f[\"X\"])))\n", + " z = model.sc.transform(f[\"X\"][w:w + 1])[0]\n", + " top = np.argsort(-np.abs(z))[:2]\n", + " print(f\" {f['m']['id']} ({f['m']['kind']}, {f['m']['weather']}): \" + \", \".join(f\"{WFEAT[i]} z={z[i]:+.1f}\" for i in top))\n", + "print(\"-> 강풍 돌풍 하강·측량 U턴처럼 정상이지만 드문 패턴이 원인이면 학습 자료에 추가하거나 특징을 재설계한다.\")\n", + "\n", + "# 5) 그림: 이상 점수 분포와 은밀 침투 예시\n", + "fig, ax = plt.subplots(1, 2, figsize=(12, 4.5))\n", + "ax[0].hist([f[\"ml_score\"] for f in test if f[\"m\"][\"kind\"] not in SCENARIOS], bins=30, range=(0, 6), alpha=.7, label=\"normal\")\n", + "ax[0].hist([f[\"ml_score\"] for f in test if f[\"m\"][\"kind\"] in SCENARIOS], bins=30, range=(0, 6), alpha=.7, label=\"anomalous\")\n", + "ax[0].axvline(1.0, color=\"k\", ls=\"--\"); ax[0].set_xlabel(\"max window anomaly score\"); ax[0].set_ylabel(\"flights\"); ax[0].legend()\n", + "ax[0].set_title(\"Anomaly score by flight type\")\n", + "f = next(f for f in test if f[\"m\"][\"kind\"] == \"infiltration\")\n", + "s = model.score(f[\"X\"])\n", + "ax[1].plot(f[\"starts\"], s, \"-o\", ms=3, label=\"window score\"); ax[1].axhline(1.0, color=\"k\", ls=\"--\")\n", + "ax[1].plot(f[\"d\"][\"t\"], f[\"d\"][\"alt\"] / 30, color=\"tab:gray\", label=\"altitude / 30 m\")\n", + "ax[1].set_xlabel(\"time (s)\"); ax[1].set_title(f\"{f['m']['id']} infiltration: low altitude + RC silent\"); ax[1].legend()\n", + "plt.tight_layout(); plt.show()\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 04 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + "학습 창 3360개, 통상 패턴 군집 4개 크기: [1104, 76, 1316, 864]\n", + "\n", + "방식 이상 재현율 정상 비행 오경보율\n", + "규칙 57.0% 13.0%\n", + "ML 100.0% 8.0%\n", + "규칙+ML 100.0% 16.0%\n", + "\n", + "복합 위협 탐지 재현율 100.0% (목표 85% 이상: 달성), 30초 창당 처리 0.47 ms (목표 200 ms 이하)\n", + "\n", + "[시나리오별 재현율]\n", + " 규칙 ML 규칙+ML 판정\n", + "kind \n", + "deviation 1.0 1.0 1.0 통과\n", + "dive 1.0 1.0 1.0 통과\n", + "hover 1.0 1.0 1.0 통과\n", + "infiltration 0.0 1.0 1.0 통과\n", + "loiter 0.3 1.0 1.0 통과\n", + "low_level 0.2 1.0 1.0 통과\n", + "nfz_entry 1.0 1.0 1.0 통과\n", + "rc_silent 0.1 1.0 1.0 통과\n", + "sharp_turn 1.0 1.0 1.0 통과\n", + "speed_burst 0.1 1.0 1.0 통과\n", + "통과 10/10종\n", + "\n", + "[정상 비행 오경보 8건의 원인 특징]\n", + " FL0019 (survey, windy): alt_std z=+6.7, vz_min z=-6.7\n", + " FL0039 (survey, windy): alt_std z=+8.9, vz_min z=-8.7\n", + " FL0059 (survey, clear): straight z=-5.5, turn_max z=+4.1\n", + " FL0088 (normal, windy): alt_std z=+7.6, alt_min z=-2.6\n", + " FL0089 (survey, rain): straight z=-5.6, turn_max z=+4.1\n", + "-> 강풍 돌풍 하강·측량 U턴처럼 정상이지만 드문 패턴이 원인이면 학습 자료에 추가하거나 특징을 재설계한다.\n", + "```\n", + "
\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 05. 운용 주체(적/아군/민간) 성급한 단정 방지를 위한 불확실성 지표 및 신뢰도 엔진 개발\n", + "**소요시간**: 약 3.0시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "비행 특성만으로 특정 국가나 부대를 성급하게 단정하지 않도록 분석 불확실성 지표와 다차원 신뢰도 점수를 산출하는 엔진을 개발한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **비행 특성만으로 운용 주체(아군·민간·적대)를 단정하지 않도록 주체별 확률, 불확실성, 데이터 품질을 함께 반영한 신뢰도 엔진을 개발한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **과거 검토가 끝난 가상 사례로 주체별 확률 모델을 학습하고, 정규화 엔트로피로 불확실성을, 스푸핑·결측 정도로 로그 품질 산출하고 모델에 적용하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `src/verification/isr03_confidence_engine.py` 형태의 결과물을 완성한다.\n", + "\n", + "### 🔍 평가 및 검증 기준 (PASS 조건)\n", + "#### 미션 판정\n", + "\n", + "**평가 구분:** 필수\n", + "\n", + "**핵심 평가영역:** 신뢰도 등급(High/Med/Low) 부여 기준의 군사적 타당성\n", + "\n", + "#### 미션 PASS 조건\n", + "\n", + "- 정량 기준: 오경보율(FAR) 40% 이상 감축, 신뢰도 스코어 산출 일관성 90% 이상\n", + "\n", + "\n", + "- 진행 기준: 오경보 필터링 전후 비교 지표 산출 및 통합 테스트 완료\n", + "\n", + "\n", + "- 검증 방법: ROC-AUC 곡선 분석 및 임계값 민감도 테스트\n", + "\n", + "\n", + "#### 채점자 판정 메모\n", + "\n", + "> 단순 코드 실행 여부뿐만 아니라 출력된 수치와 근거 자료가 군 실무 요구사항에 부합하는지 면밀히 확인한다.\n", + "\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "\"\"\"비행 특성만으로 운용 주체(아군·민간·적대)를 성급하게 단정하지 않도록, 주체별 확률과 불확실성(엔트로피)·데이터 품질을 함께 계산해 '적대 가능성 등급(High/Medium/Low)'을 매기는 신뢰도 엔진을 만들고, 단정형 규칙 대비 오경보 감축과 점수 일관성을 검증한다.\"\"\"\n", + "import math\n", + "import numpy as np\n", + "import pandas as pd\n", + "import matplotlib.pyplot as plt\n", + "from sklearn.cluster import KMeans\n", + "from sklearn.ensemble import IsolationForest\n", + "from sklearn.linear_model import LogisticRegression\n", + "from sklearn.metrics import roc_auc_score\n", + "from sklearn.pipeline import make_pipeline\n", + "from sklearn.preprocessing import StandardScaler\n", + "\n", + "# ---------------------------------------------------------------------------\n", + "# [교육용가상자료] 합성 드론 비행로그 생성기 (실제 좌표·기체·부대 정보 없음)\n", + "# 가상 구역(원점 기준 동·북 방향 m 좌표)에서 1초 간격 비행로그와 정답(이상 구간·운용 주체)을 만든다.\n", + "# ---------------------------------------------------------------------------\n", + "NFZ = {\"x\": 2500.0, \"y\": 2500.0, \"r\": 700.0} # 가상 비인가 비행구역\n", + "WATCH = {\"x\": -2000.0, \"y\": 1500.0, \"r\": 600.0} # 가상 관심 구역\n", + "SCENARIOS = [\"sharp_turn\", \"hover\", \"dive\", \"deviation\", \"nfz_entry\",\n", + " \"low_level\", \"rc_silent\", \"infiltration\", \"loiter\", \"speed_burst\"]\n", + "OPERATORS = [\"friendly\", \"civil\", \"hostile\"]\n", + "# 시나리오별 운용 주체 분포: 일부러 크게 겹치게 설계했다(비행 특성만으로 운용 주체를 단정할 수 없다).\n", + "OPERATOR_P = {\"normal\": (.45, .47, .08), \"survey\": (.15, .82, .03), \"sharp_turn\": (.40, .35, .25),\n", + " \"hover\": (.25, .45, .30), \"dive\": (.25, .25, .50), \"deviation\": (.30, .40, .30),\n", + " \"nfz_entry\": (.10, .50, .40), \"low_level\": (.35, .15, .50), \"rc_silent\": (.30, .15, .55),\n", + " \"infiltration\": (.12, .08, .80), \"loiter\": (.30, .20, .50), \"speed_burst\": (.30, .20, .50)}\n", + "PLATFORM_P = {\"friendly\": (.40, .55, .05), \"civil\": (.88, .02, .10), \"hostile\": (.55, .30, .15)} # commercial/military/unknown\n", + "FIRMWARE_P = {\"friendly\": (.30, .65, .05), \"civil\": (.85, .10, .05), \"hostile\": (.20, .50, .30)} # stock/custom/unknown\n", + "\n", + "\n", + "def _wrap(a):\n", + " # [TODO : 학생 실습 5] 운용 주체(적/아군/민간) 성급한 단정 방지를 위한 불확실성 지표 및 신뢰도 엔진 개발 관련 핵심 로직을 구현하세요.\n", + " # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n", + " # <<< 여기에 코드를 작성하세요 >>>\n", + " pass # 작성 후 pass를 제거하세요.\n", + "def _bearing(p, q):\n", + " # [TODO : 학생 실습 5] 운용 주체(적/아군/민간) 성급한 단정 방지를 위한 불확실성 지표 및 신뢰도 엔진 개발 관련 핵심 로직을 구현하세요.\n", + " # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n", + " # <<< 여기에 코드를 작성하세요 >>>\n", + " pass # 작성 후 pass를 제거하세요.\n", + "def _away_from_nfz(rng, min_d):\n", + " while True:\n", + " c = rng.uniform(-3800, 3800, 2)\n", + " if np.hypot(c[0] - NFZ[\"x\"], c[1] - NFZ[\"y\"]) > min_d:\n", + " return c\n", + "\n", + "\n", + "def make_flight(fid, kind, rng, n=300, weather=None):\n", + " \"\"\"비행 1회 = (로그 DataFrame, 메타 dict). kind: normal / survey / SCENARIOS 중 하나\"\"\"\n", + " weather = weather or str(rng.choice([\"clear\", \"night\", \"windy\", \"rain\"], p=[.4, .25, .2, .15]))\n", + " wind = {\"clear\": 1.0, \"night\": 1.0, \"windy\": 2.5, \"rain\": 1.8}[weather]\n", + " v0, alt0 = rng.uniform(11, 17), rng.uniform(70, 150)\n", + " nfz_c = np.array([NFZ[\"x\"], NFZ[\"y\"]])\n", + " if kind == \"infiltration\": # 원거리에서 비인가 구역 방향으로 저고도 직진\n", + " ang = rng.uniform(0, 2 * np.pi)\n", + " start = nfz_c + 4000 * np.array([np.cos(ang), np.sin(ang)])\n", + " plan = np.array([start, nfz_c])\n", + " v0, alt0 = rng.uniform(9, 11), rng.uniform(25, 35)\n", + " else:\n", + " if kind == \"nfz_entry\": # 비인가 구역 1.6km 밖에서 순찰하다가 진입\n", + " ang = rng.uniform(0, 2 * np.pi)\n", + " c = nfz_c + 1600 * np.array([np.cos(ang), np.sin(ang)])\n", + " else:\n", + " c = _away_from_nfz(rng, 2600)\n", + " if kind == \"survey\": # 민간 측량형 왕복(지그재그) 경로\n", + " w, h = rng.uniform(500, 700), 120.0\n", + " plan = np.array([c + [(-w if i % 4 in (0, 3) else w), i // 2 * h - 360] for i in range(8)])\n", + " else: # 사각 순찰 경로(웨이포인트 4개)\n", + " a, b, th = rng.uniform(300, 600), rng.uniform(200, 400), rng.uniform(0, np.pi)\n", + " R = np.array([[np.cos(th), -np.sin(th)], [np.sin(th), np.cos(th)]])\n", + " plan = c + np.array([[-a, -b], [a, -b], [a, b], [-a, b]]) @ R.T\n", + " s0 = int(rng.integers(80, 160))\n", + " span = {\"sharp_turn\": 30, \"hover\": 60, \"dive\": 8, \"deviation\": 70, \"nfz_entry\": 120, \"low_level\": 120,\n", + " \"rc_silent\": 120, \"infiltration\": n - 20, \"loiter\": 90, \"speed_burst\": 25}.get(kind, 0)\n", + " if kind == \"infiltration\":\n", + " s0 = 20\n", + " win = (s0, s0 + span)\n", + " turns = set(rng.choice(np.arange(s0, s0 + 30), 3, replace=False).tolist()) if kind == \"sharp_turn\" else set()\n", + " # 난수를 미리 한 번에 뽑아 두고(속도 향상) 1초 단위로 기체 운동을 적분한다.\n", + " e_h, e_v, e_a = rng.normal(0, 0.8 * wind, n), rng.normal(0, 0.3 * wind, n), rng.normal(0, 0.4 * wind, n)\n", + " rc_arr = np.clip(rng.normal(rng.uniform(60, 92), 6, n), 0, 100)\n", + " gust = (rng.random(n) < (0.004 if weather == \"windy\" else 0.0)) * rng.uniform(20, 35, n)\n", + " burst, turn_amt = rng.uniform(23, 28), rng.choice([-1, 1], n) * rng.uniform(100, 160, n)\n", + " side = float(rng.choice([-90, 90]))\n", + " low_alt, rng_rc = rng.uniform(35, 55), rng.uniform(0, 20, n) # 저고도 목표, 조종신호 두절 시 잔여 수신값\n", + " x, y, wi = float(plan[0][0]), float(plan[0][1]), 1\n", + " hdg, alt = _bearing((x, y), plan[1]), alt0\n", + " max_rate = 40.0 if kind == \"survey\" else 12.0\n", + " dev_target, X, Y, A, V, HD = None, np.zeros(n), np.zeros(n), np.zeros(n), np.zeros(n), np.zeros(n)\n", + " for t in range(n):\n", + " inw = win[0] <= t < win[1]\n", + " tx, ty = plan[1] if kind == \"infiltration\" else plan[wi % len(plan)]\n", + " spd_cmd, alt_cmd = v0, alt0\n", + " if inw:\n", + " if kind == \"deviation\":\n", + " if dev_target is None:\n", + " r = math.radians(hdg + side)\n", + " dev_target = (x + 900 * math.sin(r), y + 900 * math.cos(r))\n", + " tx, ty = dev_target\n", + " elif kind == \"nfz_entry\":\n", + " tx, ty = NFZ[\"x\"], NFZ[\"y\"]\n", + " elif kind == \"hover\":\n", + " spd_cmd = 0.0\n", + " elif kind == \"speed_burst\":\n", + " spd_cmd = burst\n", + " elif kind == \"low_level\":\n", + " alt_cmd = low_alt\n", + " if kind in (\"rc_silent\", \"infiltration\"):\n", + " rc_arr[t] = rng_rc[t]\n", + " if inw and kind == \"loiter\":\n", + " hdg = (hdg + 9.0 + e_h[t] / wind) % 360\n", + " else:\n", + " d = _wrap(math.degrees(math.atan2(tx - x, ty - y)) - hdg)\n", + " hdg = (hdg + max(-max_rate, min(max_rate, d)) + e_h[t]) % 360\n", + " if t in turns:\n", + " hdg = (hdg + turn_amt[t]) % 360\n", + " v = abs(spd_cmd + e_v[t]) if spd_cmd > 0 else abs(e_v[t] / wind)\n", + " x += v * math.sin(math.radians(hdg)); y += v * math.cos(math.radians(hdg))\n", + " if kind != \"infiltration\" and math.hypot(x - tx, y - ty) < 40 and not (inw and kind in (\"deviation\", \"nfz_entry\")):\n", + " wi += 1\n", + " if inw and kind == \"dive\":\n", + " alt -= 9.0 + e_v[t]\n", + " else:\n", + " alt += max(-3.0, min(3.0, alt_cmd - alt)) + e_a[t]\n", + " alt -= gust[t] # 돌풍 하강기류(정상 비행에서도 발생)\n", + " if kind == \"dive\" and t >= win[1]:\n", + " alt0 = min(alt0, alt)\n", + " alt = max(alt, 3.0)\n", + " X[t], Y[t], A[t], V[t], HD[t] = x, y, alt, v, hdg\n", + " gps_sd = {\"clear\": 2.0, \"night\": 2.0, \"windy\": 3.0, \"rain\": 4.5}[weather] # 악천후일수록 GPS 잡음(다중경로) 증가\n", + " df = pd.DataFrame({\"t\": np.arange(n), \"x\": X + rng.normal(0, gps_sd, n), \"y\": Y + rng.normal(0, gps_sd, n),\n", + " \"alt\": A + rng.normal(0, 0.5, n), \"speed\": V + rng.normal(0, 0.2, n),\n", + " \"heading\": (HD + rng.normal(0, 1.5, n)) % 360, \"rc\": rc_arr.round(1),\n", + " \"sats\": rng.integers(10, 15, n), \"hdop\": rng.uniform(0.6, 1.2, n).round(2),\n", + " \"x_true\": X, \"y_true\": Y})\n", + " df[\"anomaly\"] = (df[\"t\"] >= win[0]) & (df[\"t\"] < win[1]) & (kind in SCENARIOS)\n", + " op = OPERATORS[int(rng.choice(3, p=OPERATOR_P[kind]))]\n", + " meta = {\"id\": fid, \"kind\": kind, \"weather\": weather, \"operator\": op, \"plan\": plan, \"window\": win if kind in SCENARIOS else None,\n", + " \"platform\": [\"commercial\", \"military\", \"unknown\"][int(rng.choice(3, p=PLATFORM_P[op]))],\n", + " \"firmware\": [\"stock\", \"custom\", \"unknown\"][int(rng.choice(3, p=FIRMWARE_P[op]))]}\n", + " return df, meta\n", + "\n", + "\n", + "def corrupt(df, rng, spoof=True, emi=True, drop=True, dup=True):\n", + " \"\"\"전장 잡음 주입: GPS 스푸핑(위치 점프·표류), 전자기 간섭(고도 스파이크), 센서 단절(결측), 중복 기록.\"\"\"\n", + " d = df.copy()\n", + " d[\"spoof_true\"] = False\n", + " if spoof:\n", + " a = int(rng.integers(40, len(d) - 60)); L = int(rng.integers(25, 45))\n", + " th = rng.uniform(0, 2 * np.pi)\n", + " drift = rng.uniform(22, 28) * np.arange(L)[:, None] * np.array([np.cos(th), np.sin(th)])\n", + " jump = rng.uniform(250, 400) * np.array([np.cos(th + 1), np.sin(th + 1)])\n", + " d.loc[a:a + L - 1, [\"x\", \"y\"]] += drift + jump\n", + " d.loc[a:a + L - 1, \"spoof_true\"] = True\n", + " if emi:\n", + " idx = rng.choice(len(d), 6, replace=False)\n", + " d.loc[idx, \"alt\"] += rng.choice([-1, 1], 6) * rng.uniform(60, 150, 6)\n", + " if drop:\n", + " a = int(rng.integers(10, len(d) - 20))\n", + " d.loc[a:a + int(rng.integers(4, 9)), [\"x\", \"y\", \"alt\", \"speed\", \"heading\"]] = np.nan\n", + " d = d.drop(index=rng.choice(len(d), 5, replace=False))\n", + " if dup:\n", + " d = pd.concat([d, d.sample(5, random_state=int(rng.integers(1e6)))]).sort_values(\"t\", kind=\"stable\")\n", + " return d.reset_index(drop=True)\n", + "\n", + "\n", + "def make_dataset(n=200, seed=7, anomaly_ratio=0.5, scenarios=SCENARIOS):\n", + " rng = np.random.default_rng(seed)\n", + " out = []\n", + " for i in range(n):\n", + " if i % 10 < anomaly_ratio * 10:\n", + " kind = scenarios[(i // 10 * int(anomaly_ratio * 10) + i % 10) % len(scenarios)]\n", + " else:\n", + " kind = \"survey\" if i % 10 == 9 else \"normal\"\n", + " df, meta = make_flight(f\"FL{i:04d}\", kind, rng)\n", + " out.append((df, meta))\n", + " return out\n", + "# ---------------------------------------------------------------------------\n", + "\n", + "# ---- 전처리·규칙 탐지 공통 함수 ----\n", + "SPOOF_THR = {\"clear\": 12.0, \"night\": 12.0, \"windy\": 15.0, \"rain\": 19.0} # 기상별 GPS-추측항법 불일치 허용치(m)\n", + "\n", + "\n", + "def clean(df, weather=\"clear\"):\n", + " \"\"\"전처리: 중복 제거·정렬 -> 1초 재표본화 -> 결측 보간 -> 고도 스파이크 제거 -> GPS 스푸핑 구간 추측항법 보정.\"\"\"\n", + " d = df.drop_duplicates(\"t\").sort_values(\"t\").set_index(\"t\")\n", + " d = d.reindex(np.arange(int(d.index.min()), int(d.index.max()) + 1))\n", + " n_missing = int(d[\"x\"].isna().sum())\n", + " num = [\"x\", \"y\", \"alt\", \"speed\", \"rc\", \"sats\", \"hdop\"]\n", + " d[num] = d[num].interpolate(limit_direction=\"both\")\n", + " hd = d[\"heading\"].dropna() # 방위는 0/360 경계를 풀어서 보간\n", + " un = pd.Series(np.degrees(np.unwrap(np.radians(hd.to_numpy(float)))), index=hd.index)\n", + " d[\"heading\"] = un.reindex(d.index).interpolate(limit_direction=\"both\").to_numpy() % 360\n", + " med = d[\"alt\"].rolling(5, center=True, min_periods=1).median()\n", + " spike = (d[\"alt\"] - med).abs() > 25\n", + " d.loc[spike, \"alt\"] = med[spike]\n", + " x, y = d[\"x\"].to_numpy(float), d[\"y\"].to_numpy(float)\n", + " r = np.radians(d[\"heading\"].to_numpy(float)); v = d[\"speed\"].to_numpy(float)\n", + " dr = np.c_[v * np.sin(r), v * np.cos(r)] # 보고 속도·방위로 계산한 1초 이동량(추측항법)\n", + " xc, yc, flag = x.copy(), y.copy(), np.zeros(len(x), bool)\n", + " thr = SPOOF_THR.get(weather, 12.0)\n", + " ok_run = 0\n", + " for i in range(1, len(x)):\n", + " px, py = xc[i - 1] + dr[i, 0], yc[i - 1] + dr[i, 1] # 추측항법 예상 위치\n", + " step_ok = np.hypot(x[i] - x[i - 1] - dr[i, 0], y[i] - y[i - 1] - dr[i, 1]) <= thr\n", + " ok_run = ok_run + 1 if step_ok else 0\n", + " if flag[i - 1]:\n", + " # 스푸핑 구간: GPS가 예상 위치로 돌아오거나, GPS 이동량이 5초 연속 물리적으로 맞으면 GPS로 복귀\n", + " if np.hypot(x[i] - px, y[i] - py) < thr * 2 or ok_run >= 5:\n", + " xc[i], yc[i] = x[i], y[i]\n", + " else:\n", + " xc[i], yc[i], flag[i] = px, py, True\n", + " elif not step_ok:\n", + " xc[i], yc[i], flag[i] = px, py, True # GPS 이동량이 물리적으로 맞지 않음 -> 스푸핑 의심\n", + " else:\n", + " xc[i], yc[i] = xc[i - 1] + x[i] - x[i - 1], yc[i - 1] + y[i] - y[i - 1]\n", + " d[\"x\"], d[\"y\"], d[\"spoof_flag\"] = xc, yc, flag\n", + " d = d.reset_index()\n", + " d.attrs.update({\"missing\": n_missing, \"spikes\": int(spike.sum()), \"spoof_pts\": int(flag.sum())})\n", + " return d\n", + "\n", + "\n", + "def xtrack(px, py, plan):\n", + " \"\"\"계획 경로(웨이포인트 폐곡선) 대비 이탈 거리(m).\"\"\"\n", + " P = np.asarray(plan, float)\n", + " segs = [(P[i], P[(i + 1) % len(P)]) for i in range(len(P) if len(P) > 2 else 1)]\n", + " best = np.full(len(px), np.inf)\n", + " for a, b in segs:\n", + " ab = b - a\n", + " u = np.clip(((px - a[0]) * ab[0] + (py - a[1]) * ab[1]) / max(ab @ ab, 1e-9), 0, 1)\n", + " best = np.minimum(best, np.hypot(px - a[0] - u * ab[0], py - a[1] - u * ab[1]))\n", + " return best\n", + "\n", + "\n", + "RULES = {\"turn_rate\": 45.0, \"hover_speed\": 1.5, \"hover_sec\": 20, \"dive_m\": 30.0, \"xtrack_m\": 250.0}\n", + "\n", + "\n", + "def rule_flags(d, plan, R=RULES):\n", + " \"\"\"규칙 기반 이상 비행 판정(기록 단위). 급선회·호버링·급강하·경로 이탈·비인가 구역 진입.\"\"\"\n", + " hr = np.abs(_wrap(np.diff(d[\"heading\"].to_numpy(float), prepend=d[\"heading\"].iloc[0])))\n", + " slow = (d[\"speed\"] < R[\"hover_speed\"]).to_numpy()\n", + " run = np.zeros(len(slow), int)\n", + " for i in range(len(slow)):\n", + " run[i] = run[i - 1] + 1 if slow[i] and i else int(slow[i])\n", + " hover = np.zeros(len(slow), bool)\n", + " for i in np.where(run >= R[\"hover_sec\"])[0]:\n", + " hover[i - R[\"hover_sec\"] + 1:i + 1] = True\n", + " alt = d[\"alt\"].to_numpy(float)\n", + " drop = alt - np.r_[alt[5:], np.full(5, alt[-1])]\n", + " return {\"급선회\": hr > R[\"turn_rate\"], \"호버링\": hover, \"급강하\": drop > R[\"dive_m\"],\n", + " \"경로이탈\": xtrack(d[\"x\"].to_numpy(float), d[\"y\"].to_numpy(float), plan) > R[\"xtrack_m\"],\n", + " \"비인가구역\": np.hypot(d[\"x\"] - NFZ[\"x\"], d[\"y\"] - NFZ[\"y\"]).to_numpy() < NFZ[\"r\"]}\n", + "\n", + "# ---- 구간(30초 창) 특징 + 비지도 이상 탐지 공통 함수 ----\n", + "WFEAT = [\"alt_mean\", \"alt_min\", \"alt_std\", \"vz_min\", \"spd_mean\", \"spd_std\", \"spd_max\", \"turn_sum\", \"turn_max\",\n", + " \"straight\", \"rc_mean\", \"rc_zero\", \"nfz_min\", \"nfz_close\", \"xtrack_max\"]\n", + "\n", + "\n", + "def window_feats(d, plan, win=30, step=10):\n", + " \"\"\"30초 창을 10초씩 옮기며 고도·속도·선회·직진성·조종신호·비인가 구역 접근·경로 이탈 특징을 계산한다.\"\"\"\n", + " x, y, alt = d[\"x\"].to_numpy(float), d[\"y\"].to_numpy(float), d[\"alt\"].to_numpy(float)\n", + " spd, rc = d[\"speed\"].to_numpy(float), d[\"rc\"].to_numpy(float)\n", + " hr = np.abs(_wrap(np.diff(d[\"heading\"].to_numpy(float), prepend=d[\"heading\"].iloc[0])))\n", + " vz = np.diff(alt, prepend=alt[0])\n", + " nd = np.hypot(x - NFZ[\"x\"], y - NFZ[\"y\"])\n", + " xt = xtrack(x, y, plan)\n", + " step_len = np.hypot(np.diff(x, prepend=x[0]), np.diff(y, prepend=y[0]))\n", + " rows, starts = [], []\n", + " for s in range(0, len(d) - win + 1, step):\n", + " e = s + win\n", + " path = step_len[s + 1:e].sum()\n", + " rows.append([alt[s:e].mean(), alt[s:e].min(), alt[s:e].std(), vz[s:e].min(), spd[s:e].mean(), spd[s:e].std(),\n", + " spd[s:e].max(), hr[s:e].sum(), hr[s:e].max(), np.hypot(x[e - 1] - x[s], y[e - 1] - y[s]) / max(path, 1.0),\n", + " rc[s:e].mean(), (rc[s:e] < 5).mean(), nd[s:e].min(), (nd[s] - nd[e - 1]) / win, xt[s:e].max()])\n", + " starts.append(int(d[\"t\"].iloc[s]))\n", + " return np.array(rows), starts\n", + "\n", + "\n", + "class FlightAnomalyModel:\n", + " \"\"\"평시 비행로그(라벨 없음)로 통상 패턴 군집(KMeans)과 고립 숲(IsolationForest)을 학습하고,\n", + " 두 점수를 학습 구간 분위수로 나눠 1.0을 넘으면 '통상 패턴에서 벗어남'으로 본다.\"\"\"\n", + "\n", + " def __init__(self, q=0.999, k=4):\n", + " self.q, self.k = q, k\n", + "\n", + " def fit(self, X):\n", + " self.sc = StandardScaler().fit(X)\n", + " Z = self.sc.transform(X)\n", + " self.iso = IsolationForest(n_estimators=150, random_state=0).fit(Z)\n", + " self.km = KMeans(self.k, n_init=5, random_state=0).fit(Z)\n", + " self.set_q(self.q, X)\n", + " return self\n", + "\n", + " def _raw(self, X):\n", + " Z = self.sc.transform(X)\n", + " return -self.iso.score_samples(Z), self.km.transform(Z).min(axis=1)\n", + "\n", + " def set_q(self, q, X):\n", + " a, b = self._raw(X)\n", + " self.q, self.t_if, self.t_km = q, np.quantile(a, q), np.quantile(b, q)\n", + "\n", + " def score(self, X):\n", + " a, b = self._raw(X)\n", + " return np.maximum(a / self.t_if, b / self.t_km)\n", + "\n", + "\n", + "AFEAT = [\"ml_score\", \"low_alt\", \"rc_weak\", \"nfz_min_km\", \"hover_s\", \"max_speed\", \"night\",\n", + " \"pf_commercial\", \"pf_military\", \"pf_unknown\", \"fw_stock\", \"fw_custom\", \"fw_unknown\"]\n", + "\n", + "\n", + "def attr_features(d, m, ml_score):\n", + " \"\"\"운용 주체 추정용 비행 단위 특징. 기체·펌웨어 정보는 상용 기체가 모든 주체에 쓰이므로 약한 단서일 뿐이다.\"\"\"\n", + " return [min(ml_score, 10.0), float((d[\"alt\"] < 60).mean()), float((d[\"rc\"] < 25).mean()),\n", + " float(np.hypot(d[\"x\"] - NFZ[\"x\"], d[\"y\"] - NFZ[\"y\"]).min() / 1000), float((d[\"speed\"] < 1.5).sum()),\n", + " float(d[\"speed\"].max()), float(m[\"weather\"] == \"night\"),\n", + " *[float(m[\"platform\"] == p) for p in (\"commercial\", \"military\", \"unknown\")],\n", + " *[float(m[\"firmware\"] == f) for f in (\"stock\", \"custom\", \"unknown\")]]\n", + "\n", + "P_HIGH, H_MAX, P_MED = 0.60, 0.80, 0.25 # 등급 기준(적대 확률, 최대 불확실성, 보류 하한)\n", + "\n", + "\n", + "def build(data, model, rng, p_corrupt=0.25):\n", + " rows = []\n", + " for df, m in data:\n", + " noisy = rng.random() < p_corrupt\n", + " d = clean(corrupt(df, rng) if noisy else df, m[\"weather\"])\n", + " X, _ = window_feats(d, m[\"plan\"])\n", + " s = float(model.score(X).max())\n", + " flag = s > 1.0 or any(v.any() for v in rule_flags(d, m[\"plan\"]).values())\n", + " rows.append({\"id\": m[\"id\"], \"kind\": m[\"kind\"], \"op\": m[\"operator\"], \"flag\": flag,\n", + " \"quality_ok\": d.attrs[\"spoof_pts\"] < 10 and d.attrs[\"missing\"] < 15, \"f\": attr_features(d, m, s)})\n", + " return rows\n", + "\n", + "\n", + "def grade(p, H, q_ok):\n", + " if p >= P_HIGH and H <= H_MAX and q_ok:\n", + " return \"High\"\n", + " return \"Medium\" if p >= P_MED else \"Low\"\n", + "\n", + "\n", + "def assess(eng, rows, noise=None, rng=None):\n", + " X = np.array([r[\"f\"] for r in rows])\n", + " if noise:\n", + " X = X * (1 + rng.normal(0, noise, X.shape))\n", + " P = eng.predict_proba(X)\n", + " H = -(P * np.log(np.clip(P, 1e-9, 1))).sum(1) / np.log(3)\n", + " ph = P[:, list(eng.classes_).index(\"hostile\")]\n", + " return ph, H, [grade(p, h, r[\"quality_ok\"]) for p, h, r in zip(ph, H, rows)]\n", + "\n", + "\n", + "rng = np.random.default_rng(5)\n", + "normal_X = np.vstack([window_feats(clean(df, m[\"weather\"]), m[\"plan\"])[0] for df, m in make_dataset(120, 21, anomaly_ratio=0.0)])\n", + "det = FlightAnomalyModel().fit(normal_X)\n", + "train = build(make_dataset(240, seed=31), det, rng) # 과거 검토가 끝난 사례(라벨 있음)라고 가정\n", + "test = build(make_dataset(200, seed=11), det, rng)\n", + "eng = make_pipeline(StandardScaler(), LogisticRegression(max_iter=2000, C=0.5))\n", + "eng.fit([r[\"f\"] for r in train], [r[\"op\"] for r in train])\n", + "ph, H, G = assess(eng, test)\n", + "y = np.array([r[\"op\"] == \"hostile\" for r in test])\n", + "\n", + "# 1) 단정형 규칙(이상 비행이면 '적') vs 신뢰도 엔진(High만 '적대 가능성 높음')\n", + "flag = np.array([r[\"flag\"] for r in test]); high = np.array([g == \"High\" for g in G]); med = np.array([g == \"Medium\" for g in G])\n", + "far0 = (~y & flag).sum() / flag.sum(); far1 = (~y & high).sum() / max(high.sum(), 1)\n", + "print(\"방식 적대 지목 그중 비적대(오경보) 오경보율\")\n", + "print(f\"단정형 규칙(이상=적) {flag.sum():6d} {(~y & flag).sum():14d} {far0:.1%}\")\n", + "print(f\"신뢰도 엔진(High 등급) {high.sum():6d} {(~y & high).sum():14d} {far1:.1%}\")\n", + "cut = 1 - far1 / far0\n", + "print(f\"오경보율 감축 {cut:.1%} (목표 40% 이상: {'달성' if cut >= 0.4 else '미달'})\")\n", + "print(f\"적대 비행 {y.sum()}건 중 High+Medium(검토 대상) {(y & (high | med)).sum()}건, Low로 밀린 적대 {(y & ~high & ~med).sum()}건\")\n", + "print(f\"ROC-AUC(적대 확률) {roc_auc_score(y, ph):.3f}\")\n", + "\n", + "# 2) 등급 분포와 임계값 민감도\n", + "print(\"\\n[실제 운용 주체별 등급 분포]\")\n", + "print(pd.crosstab(pd.Series([r[\"op\"] for r in test], name=\"실제\"), pd.Series(G, name=\"등급\")))\n", + "print(\"\\nHigh 기준 High 수 오경보율 적대 포착률\")\n", + "for t in [0.5, 0.6, 0.7, 0.8]:\n", + " hh = (ph >= t) & (H <= H_MAX) & np.array([r[\"quality_ok\"] for r in test])\n", + " print(f\" {t:.1f} {hh.sum():5d} {(~y & hh).sum() / max(hh.sum(), 1):7.1%} {(y & hh).sum() / y.sum():8.1%}\")\n", + "\n", + "# 3) 일관성: 입력 특징에 +-2% 측정 잡음을 20번 넣어도 같은 등급이 나오는 비율\n", + "same = np.mean([np.mean([a == b for a, b in zip(G, assess(eng, test, 0.02, rng)[2])]) for _ in range(20)])\n", + "print(f\"\\n신뢰도 등급 산출 일관성 {same:.1%} (목표 90% 이상: {'달성' if same >= 0.9 else '미달'})\")\n", + "\n", + "# 4) 판단 문구: '확정' 표현 금지, 확률·불확실성·근거·검토 필요를 함께 적는다\n", + "sc, lr = eng[0], eng[-1]\n", + "ci = list(lr.classes_).index(\"hostile\")\n", + "print(\"\\n[분석 의견 문구 예시]\")\n", + "lines = []\n", + "for i in list(np.argsort(-ph)[:2]) + [int(np.argmin(np.abs(ph - 0.5)))]:\n", + " z = (np.array(test[i][\"f\"]) - sc.mean_) / sc.scale_ * lr.coef_[ci]\n", + " why = \", \".join(AFEAT[j] for j in np.argsort(-z)[:2])\n", + " q = \"\" if test[i][\"quality_ok\"] else \", 로그 품질 저하(스푸핑·결측)로 등급 하향\"\n", + " lines.append(f\"{test[i]['id']}: 적대 운용 가능성 {G[i]}(p={ph[i]:.2f}, 불확실성 {H[i]:.2f}{q}), 근거 {why} -> 정보분석관 검토 필요\")\n", + "print(\"\\n\".join(\" \" + s for s in lines))\n", + "print(\"금지 표현('확정', '소속 부대') 포함 문구:\", sum((\"확정\" in s) or (\"소속 부대\" in s) for s in lines), \"건\")\n", + "\n", + "fig, ax = plt.subplots(1, 2, figsize=(12, 4.5))\n", + "for op, c in [(\"friendly\", \"tab:blue\"), (\"civil\", \"tab:green\"), (\"hostile\", \"tab:red\")]:\n", + " sel = np.array([r[\"op\"] == op for r in test])\n", + " ax[0].scatter(ph[sel], H[sel], s=18, color=c, label=op, alpha=.7)\n", + "ax[0].axvline(P_HIGH, ls=\"--\", c=\"k\"); ax[0].axhline(H_MAX, ls=\"--\", c=\"k\")\n", + "ax[0].set_xlabel(\"P(hostile)\"); ax[0].set_ylabel(\"uncertainty (normalized entropy)\"); ax[0].legend(); ax[0].set_title(\"Attribution score vs uncertainty\")\n", + "pd.Series(G).value_counts().reindex([\"High\", \"Medium\", \"Low\"]).plot.bar(ax=ax[1], color=[\"tab:red\", \"tab:orange\", \"tab:gray\"])\n", + "ax[1].set_title(\"Grades (High = priority review, not a conclusion)\")\n", + "plt.tight_layout(); plt.show()\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 05 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + "방식 적대 지목 그중 비적대(오경보) 오경보율\n", + "단정형 규칙(이상=적) 116 71 61.2%\n", + "신뢰도 엔진(High 등급) 16 4 25.0%\n", + "오경보율 감축 59.2% (목표 40% 이상: 달성)\n", + "적대 비행 48건 중 High+Medium(검토 대상) 34건, Low로 밀린 적대 14건\n", + "ROC-AUC(적대 확률) 0.803\n", + "\n", + "[실제 운용 주체별 등급 분포]\n", + "등급 High Low Medium\n", + "실제 \n", + "civil 0 69 18\n", + "friendly 4 43 18\n", + "hostile 12 14 22\n", + "\n", + "High 기준 High 수 오경보율 적대 포착률\n", + " 0.5 17 29.4% 25.0%\n", + " 0.6 16 25.0% 25.0%\n", + " 0.7 15 26.7% 22.9%\n", + " 0.8 10 40.0% 12.5%\n", + "\n", + "신뢰도 등급 산출 일관성 99.1% (목표 90% 이상: 달성)\n", + "\n", + "[분석 의견 문구 예시]\n", + " FL0112: 적대 운용 가능성 High(p=0.96, 불확실성 0.16), 근거 rc_weak, fw_unknown -> 정보분석관 검토 필요\n", + " FL0032: 적대 운용 가능성 High(p=0.96, 불확실성 0.16), 근거 rc_weak, fw_unknown -> 정보분석관 검토 필요\n", + " FL0180: 적대 운용 가능성 Medium(p=0.48, 불확실성 0.96), 근거 fw_stock, fw_custom -> 정보분석관 검토 필요\n", + "금지 표현('확정', '소속 부대') 포함 문구: 0 건\n", + "```\n", + "
\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 06. 드론 비행 궤적 시각화 및 이상 패턴 하이라이트 분석 대시보드 구현\n", + "**소요시간**: 약 2.5시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "드론의 3D 비행 궤적, 조종 신호 이상 구간, 의심 구역 체류 시간을 지도상에 시각화하는 드론 정보 분석 대시보드를 구축한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **드론의 3D 비행 궤적, 조종신호 이상 구간, 의심 구역 체류 시간, 비인가 구역 접근을 한 화면에 보여 주는 드론 정보 분석 대시보드를 구축한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **여러 기체의 비행로그를 1초 주기로 받아 최근 10초 창으로 경보 상태를 갱신하는 흐름을 모의하고 처리시간을 잰다 과정을 진행하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `src/ui/isr03_dashboard.py (또는 웹 UI 컴포넌트)` 형태의 결과물을 완성한다.\n", + "\n", + "### 🔍 평가 및 검증 기준 (PASS 조건)\n", + "#### 미션 판정\n", + "\n", + "**평가 구분:** 필수\n", + "\n", + "**핵심 평가영역:** 전술 작전 상황판으로서의 직관성 및 인지 부하 감소도\n", + "\n", + "#### 미션 PASS 조건\n", + "\n", + "- 정량 기준: 화면 갱신 주기 1초 이내, UI 응답성 100ms 이내\n", + "\n", + "\n", + "- 진행 기준: 주요 지표 시각화 컴포넌트 4종 이상 렌더링 및 실시간 데이터 연동\n", + "\n", + "\n", + "- 검증 방법: 사용자 인터랙션 테스트 및 모의 데이터 스트림 렌더링 검증\n", + "\n", + "\n", + "#### 채점자 판정 메모\n", + "\n", + "> 단순 코드 실행 여부뿐만 아니라 출력된 수치와 근거 자료가 군 실무 요구사항에 부합하는지 면밀히 확인한다.\n", + "\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "\"\"\"드론 3D 비행 궤적, 조종신호 이상 구간, 의심 구역 체류 시간, 비인가 구역 접근을 한 화면에 보여 주는 드론 정보 분석 대시보드를 구현하고, 1초 주기 데이터 갱신 처리시간과 화면 렌더링 시간을 측정한다.\"\"\"\n", + "import math\n", + "import time\n", + "import numpy as np\n", + "import pandas as pd\n", + "import matplotlib.pyplot as plt\n", + "\n", + "# ---------------------------------------------------------------------------\n", + "# [교육용가상자료] 합성 드론 비행로그 생성기 (실제 좌표·기체·부대 정보 없음)\n", + "# 가상 구역(원점 기준 동·북 방향 m 좌표)에서 1초 간격 비행로그와 정답(이상 구간·운용 주체)을 만든다.\n", + "# ---------------------------------------------------------------------------\n", + "NFZ = {\"x\": 2500.0, \"y\": 2500.0, \"r\": 700.0} # 가상 비인가 비행구역\n", + "WATCH = {\"x\": -2000.0, \"y\": 1500.0, \"r\": 600.0} # 가상 관심 구역\n", + "SCENARIOS = [\"sharp_turn\", \"hover\", \"dive\", \"deviation\", \"nfz_entry\",\n", + " \"low_level\", \"rc_silent\", \"infiltration\", \"loiter\", \"speed_burst\"]\n", + "OPERATORS = [\"friendly\", \"civil\", \"hostile\"]\n", + "# 시나리오별 운용 주체 분포: 일부러 크게 겹치게 설계했다(비행 특성만으로 운용 주체를 단정할 수 없다).\n", + "OPERATOR_P = {\"normal\": (.45, .47, .08), \"survey\": (.15, .82, .03), \"sharp_turn\": (.40, .35, .25),\n", + " \"hover\": (.25, .45, .30), \"dive\": (.25, .25, .50), \"deviation\": (.30, .40, .30),\n", + " \"nfz_entry\": (.10, .50, .40), \"low_level\": (.35, .15, .50), \"rc_silent\": (.30, .15, .55),\n", + " \"infiltration\": (.12, .08, .80), \"loiter\": (.30, .20, .50), \"speed_burst\": (.30, .20, .50)}\n", + "PLATFORM_P = {\"friendly\": (.40, .55, .05), \"civil\": (.88, .02, .10), \"hostile\": (.55, .30, .15)} # commercial/military/unknown\n", + "FIRMWARE_P = {\"friendly\": (.30, .65, .05), \"civil\": (.85, .10, .05), \"hostile\": (.20, .50, .30)} # stock/custom/unknown\n", + "\n", + "\n", + "def _wrap(a):\n", + " # [TODO : 학생 실습 6] 드론 비행 궤적 시각화 및 이상 패턴 하이라이트 분석 대시보드 구현 관련 핵심 로직을 구현하세요.\n", + " # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n", + " # <<< 여기에 코드를 작성하세요 >>>\n", + " pass # 작성 후 pass를 제거하세요.\n", + "def _bearing(p, q):\n", + " # [TODO : 학생 실습 6] 드론 비행 궤적 시각화 및 이상 패턴 하이라이트 분석 대시보드 구현 관련 핵심 로직을 구현하세요.\n", + " # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n", + " # <<< 여기에 코드를 작성하세요 >>>\n", + " pass # 작성 후 pass를 제거하세요.\n", + "def _away_from_nfz(rng, min_d):\n", + " while True:\n", + " c = rng.uniform(-3800, 3800, 2)\n", + " if np.hypot(c[0] - NFZ[\"x\"], c[1] - NFZ[\"y\"]) > min_d:\n", + " return c\n", + "\n", + "\n", + "def make_flight(fid, kind, rng, n=300, weather=None):\n", + " \"\"\"비행 1회 = (로그 DataFrame, 메타 dict). kind: normal / survey / SCENARIOS 중 하나\"\"\"\n", + " weather = weather or str(rng.choice([\"clear\", \"night\", \"windy\", \"rain\"], p=[.4, .25, .2, .15]))\n", + " wind = {\"clear\": 1.0, \"night\": 1.0, \"windy\": 2.5, \"rain\": 1.8}[weather]\n", + " v0, alt0 = rng.uniform(11, 17), rng.uniform(70, 150)\n", + " nfz_c = np.array([NFZ[\"x\"], NFZ[\"y\"]])\n", + " if kind == \"infiltration\": # 원거리에서 비인가 구역 방향으로 저고도 직진\n", + " ang = rng.uniform(0, 2 * np.pi)\n", + " start = nfz_c + 4000 * np.array([np.cos(ang), np.sin(ang)])\n", + " plan = np.array([start, nfz_c])\n", + " v0, alt0 = rng.uniform(9, 11), rng.uniform(25, 35)\n", + " else:\n", + " if kind == \"nfz_entry\": # 비인가 구역 1.6km 밖에서 순찰하다가 진입\n", + " ang = rng.uniform(0, 2 * np.pi)\n", + " c = nfz_c + 1600 * np.array([np.cos(ang), np.sin(ang)])\n", + " else:\n", + " c = _away_from_nfz(rng, 2600)\n", + " if kind == \"survey\": # 민간 측량형 왕복(지그재그) 경로\n", + " w, h = rng.uniform(500, 700), 120.0\n", + " plan = np.array([c + [(-w if i % 4 in (0, 3) else w), i // 2 * h - 360] for i in range(8)])\n", + " else: # 사각 순찰 경로(웨이포인트 4개)\n", + " a, b, th = rng.uniform(300, 600), rng.uniform(200, 400), rng.uniform(0, np.pi)\n", + " R = np.array([[np.cos(th), -np.sin(th)], [np.sin(th), np.cos(th)]])\n", + " plan = c + np.array([[-a, -b], [a, -b], [a, b], [-a, b]]) @ R.T\n", + " s0 = int(rng.integers(80, 160))\n", + " span = {\"sharp_turn\": 30, \"hover\": 60, \"dive\": 8, \"deviation\": 70, \"nfz_entry\": 120, \"low_level\": 120,\n", + " \"rc_silent\": 120, \"infiltration\": n - 20, \"loiter\": 90, \"speed_burst\": 25}.get(kind, 0)\n", + " if kind == \"infiltration\":\n", + " s0 = 20\n", + " win = (s0, s0 + span)\n", + " turns = set(rng.choice(np.arange(s0, s0 + 30), 3, replace=False).tolist()) if kind == \"sharp_turn\" else set()\n", + " # 난수를 미리 한 번에 뽑아 두고(속도 향상) 1초 단위로 기체 운동을 적분한다.\n", + " e_h, e_v, e_a = rng.normal(0, 0.8 * wind, n), rng.normal(0, 0.3 * wind, n), rng.normal(0, 0.4 * wind, n)\n", + " rc_arr = np.clip(rng.normal(rng.uniform(60, 92), 6, n), 0, 100)\n", + " gust = (rng.random(n) < (0.004 if weather == \"windy\" else 0.0)) * rng.uniform(20, 35, n)\n", + " burst, turn_amt = rng.uniform(23, 28), rng.choice([-1, 1], n) * rng.uniform(100, 160, n)\n", + " side = float(rng.choice([-90, 90]))\n", + " low_alt, rng_rc = rng.uniform(35, 55), rng.uniform(0, 20, n) # 저고도 목표, 조종신호 두절 시 잔여 수신값\n", + " x, y, wi = float(plan[0][0]), float(plan[0][1]), 1\n", + " hdg, alt = _bearing((x, y), plan[1]), alt0\n", + " max_rate = 40.0 if kind == \"survey\" else 12.0\n", + " dev_target, X, Y, A, V, HD = None, np.zeros(n), np.zeros(n), np.zeros(n), np.zeros(n), np.zeros(n)\n", + " for t in range(n):\n", + " inw = win[0] <= t < win[1]\n", + " tx, ty = plan[1] if kind == \"infiltration\" else plan[wi % len(plan)]\n", + " spd_cmd, alt_cmd = v0, alt0\n", + " if inw:\n", + " if kind == \"deviation\":\n", + " if dev_target is None:\n", + " r = math.radians(hdg + side)\n", + " dev_target = (x + 900 * math.sin(r), y + 900 * math.cos(r))\n", + " tx, ty = dev_target\n", + " elif kind == \"nfz_entry\":\n", + " tx, ty = NFZ[\"x\"], NFZ[\"y\"]\n", + " elif kind == \"hover\":\n", + " spd_cmd = 0.0\n", + " elif kind == \"speed_burst\":\n", + " spd_cmd = burst\n", + " elif kind == \"low_level\":\n", + " alt_cmd = low_alt\n", + " if kind in (\"rc_silent\", \"infiltration\"):\n", + " rc_arr[t] = rng_rc[t]\n", + " if inw and kind == \"loiter\":\n", + " hdg = (hdg + 9.0 + e_h[t] / wind) % 360\n", + " else:\n", + " d = _wrap(math.degrees(math.atan2(tx - x, ty - y)) - hdg)\n", + " hdg = (hdg + max(-max_rate, min(max_rate, d)) + e_h[t]) % 360\n", + " if t in turns:\n", + " hdg = (hdg + turn_amt[t]) % 360\n", + " v = abs(spd_cmd + e_v[t]) if spd_cmd > 0 else abs(e_v[t] / wind)\n", + " x += v * math.sin(math.radians(hdg)); y += v * math.cos(math.radians(hdg))\n", + " if kind != \"infiltration\" and math.hypot(x - tx, y - ty) < 40 and not (inw and kind in (\"deviation\", \"nfz_entry\")):\n", + " wi += 1\n", + " if inw and kind == \"dive\":\n", + " alt -= 9.0 + e_v[t]\n", + " else:\n", + " alt += max(-3.0, min(3.0, alt_cmd - alt)) + e_a[t]\n", + " alt -= gust[t] # 돌풍 하강기류(정상 비행에서도 발생)\n", + " if kind == \"dive\" and t >= win[1]:\n", + " alt0 = min(alt0, alt)\n", + " alt = max(alt, 3.0)\n", + " X[t], Y[t], A[t], V[t], HD[t] = x, y, alt, v, hdg\n", + " gps_sd = {\"clear\": 2.0, \"night\": 2.0, \"windy\": 3.0, \"rain\": 4.5}[weather] # 악천후일수록 GPS 잡음(다중경로) 증가\n", + " df = pd.DataFrame({\"t\": np.arange(n), \"x\": X + rng.normal(0, gps_sd, n), \"y\": Y + rng.normal(0, gps_sd, n),\n", + " \"alt\": A + rng.normal(0, 0.5, n), \"speed\": V + rng.normal(0, 0.2, n),\n", + " \"heading\": (HD + rng.normal(0, 1.5, n)) % 360, \"rc\": rc_arr.round(1),\n", + " \"sats\": rng.integers(10, 15, n), \"hdop\": rng.uniform(0.6, 1.2, n).round(2),\n", + " \"x_true\": X, \"y_true\": Y})\n", + " df[\"anomaly\"] = (df[\"t\"] >= win[0]) & (df[\"t\"] < win[1]) & (kind in SCENARIOS)\n", + " op = OPERATORS[int(rng.choice(3, p=OPERATOR_P[kind]))]\n", + " meta = {\"id\": fid, \"kind\": kind, \"weather\": weather, \"operator\": op, \"plan\": plan, \"window\": win if kind in SCENARIOS else None,\n", + " \"platform\": [\"commercial\", \"military\", \"unknown\"][int(rng.choice(3, p=PLATFORM_P[op]))],\n", + " \"firmware\": [\"stock\", \"custom\", \"unknown\"][int(rng.choice(3, p=FIRMWARE_P[op]))]}\n", + " return df, meta\n", + "\n", + "\n", + "def corrupt(df, rng, spoof=True, emi=True, drop=True, dup=True):\n", + " \"\"\"전장 잡음 주입: GPS 스푸핑(위치 점프·표류), 전자기 간섭(고도 스파이크), 센서 단절(결측), 중복 기록.\"\"\"\n", + " d = df.copy()\n", + " d[\"spoof_true\"] = False\n", + " if spoof:\n", + " a = int(rng.integers(40, len(d) - 60)); L = int(rng.integers(25, 45))\n", + " th = rng.uniform(0, 2 * np.pi)\n", + " drift = rng.uniform(22, 28) * np.arange(L)[:, None] * np.array([np.cos(th), np.sin(th)])\n", + " jump = rng.uniform(250, 400) * np.array([np.cos(th + 1), np.sin(th + 1)])\n", + " d.loc[a:a + L - 1, [\"x\", \"y\"]] += drift + jump\n", + " d.loc[a:a + L - 1, \"spoof_true\"] = True\n", + " if emi:\n", + " idx = rng.choice(len(d), 6, replace=False)\n", + " d.loc[idx, \"alt\"] += rng.choice([-1, 1], 6) * rng.uniform(60, 150, 6)\n", + " if drop:\n", + " a = int(rng.integers(10, len(d) - 20))\n", + " d.loc[a:a + int(rng.integers(4, 9)), [\"x\", \"y\", \"alt\", \"speed\", \"heading\"]] = np.nan\n", + " d = d.drop(index=rng.choice(len(d), 5, replace=False))\n", + " if dup:\n", + " d = pd.concat([d, d.sample(5, random_state=int(rng.integers(1e6)))]).sort_values(\"t\", kind=\"stable\")\n", + " return d.reset_index(drop=True)\n", + "\n", + "\n", + "def make_dataset(n=200, seed=7, anomaly_ratio=0.5, scenarios=SCENARIOS):\n", + " rng = np.random.default_rng(seed)\n", + " out = []\n", + " for i in range(n):\n", + " if i % 10 < anomaly_ratio * 10:\n", + " kind = scenarios[(i // 10 * int(anomaly_ratio * 10) + i % 10) % len(scenarios)]\n", + " else:\n", + " kind = \"survey\" if i % 10 == 9 else \"normal\"\n", + " df, meta = make_flight(f\"FL{i:04d}\", kind, rng)\n", + " out.append((df, meta))\n", + " return out\n", + "# ---------------------------------------------------------------------------\n", + "\n", + "# ---- 전처리·규칙 탐지 공통 함수 ----\n", + "SPOOF_THR = {\"clear\": 12.0, \"night\": 12.0, \"windy\": 15.0, \"rain\": 19.0} # 기상별 GPS-추측항법 불일치 허용치(m)\n", + "\n", + "\n", + "def clean(df, weather=\"clear\"):\n", + " \"\"\"전처리: 중복 제거·정렬 -> 1초 재표본화 -> 결측 보간 -> 고도 스파이크 제거 -> GPS 스푸핑 구간 추측항법 보정.\"\"\"\n", + " d = df.drop_duplicates(\"t\").sort_values(\"t\").set_index(\"t\")\n", + " d = d.reindex(np.arange(int(d.index.min()), int(d.index.max()) + 1))\n", + " n_missing = int(d[\"x\"].isna().sum())\n", + " num = [\"x\", \"y\", \"alt\", \"speed\", \"rc\", \"sats\", \"hdop\"]\n", + " d[num] = d[num].interpolate(limit_direction=\"both\")\n", + " hd = d[\"heading\"].dropna() # 방위는 0/360 경계를 풀어서 보간\n", + " un = pd.Series(np.degrees(np.unwrap(np.radians(hd.to_numpy(float)))), index=hd.index)\n", + " d[\"heading\"] = un.reindex(d.index).interpolate(limit_direction=\"both\").to_numpy() % 360\n", + " med = d[\"alt\"].rolling(5, center=True, min_periods=1).median()\n", + " spike = (d[\"alt\"] - med).abs() > 25\n", + " d.loc[spike, \"alt\"] = med[spike]\n", + " x, y = d[\"x\"].to_numpy(float), d[\"y\"].to_numpy(float)\n", + " r = np.radians(d[\"heading\"].to_numpy(float)); v = d[\"speed\"].to_numpy(float)\n", + " dr = np.c_[v * np.sin(r), v * np.cos(r)] # 보고 속도·방위로 계산한 1초 이동량(추측항법)\n", + " xc, yc, flag = x.copy(), y.copy(), np.zeros(len(x), bool)\n", + " thr = SPOOF_THR.get(weather, 12.0)\n", + " ok_run = 0\n", + " for i in range(1, len(x)):\n", + " px, py = xc[i - 1] + dr[i, 0], yc[i - 1] + dr[i, 1] # 추측항법 예상 위치\n", + " step_ok = np.hypot(x[i] - x[i - 1] - dr[i, 0], y[i] - y[i - 1] - dr[i, 1]) <= thr\n", + " ok_run = ok_run + 1 if step_ok else 0\n", + " if flag[i - 1]:\n", + " # 스푸핑 구간: GPS가 예상 위치로 돌아오거나, GPS 이동량이 5초 연속 물리적으로 맞으면 GPS로 복귀\n", + " if np.hypot(x[i] - px, y[i] - py) < thr * 2 or ok_run >= 5:\n", + " xc[i], yc[i] = x[i], y[i]\n", + " else:\n", + " xc[i], yc[i], flag[i] = px, py, True\n", + " elif not step_ok:\n", + " xc[i], yc[i], flag[i] = px, py, True # GPS 이동량이 물리적으로 맞지 않음 -> 스푸핑 의심\n", + " else:\n", + " xc[i], yc[i] = xc[i - 1] + x[i] - x[i - 1], yc[i - 1] + y[i] - y[i - 1]\n", + " d[\"x\"], d[\"y\"], d[\"spoof_flag\"] = xc, yc, flag\n", + " d = d.reset_index()\n", + " d.attrs.update({\"missing\": n_missing, \"spikes\": int(spike.sum()), \"spoof_pts\": int(flag.sum())})\n", + " return d\n", + "\n", + "\n", + "def xtrack(px, py, plan):\n", + " \"\"\"계획 경로(웨이포인트 폐곡선) 대비 이탈 거리(m).\"\"\"\n", + " P = np.asarray(plan, float)\n", + " segs = [(P[i], P[(i + 1) % len(P)]) for i in range(len(P) if len(P) > 2 else 1)]\n", + " best = np.full(len(px), np.inf)\n", + " for a, b in segs:\n", + " ab = b - a\n", + " u = np.clip(((px - a[0]) * ab[0] + (py - a[1]) * ab[1]) / max(ab @ ab, 1e-9), 0, 1)\n", + " best = np.minimum(best, np.hypot(px - a[0] - u * ab[0], py - a[1] - u * ab[1]))\n", + " return best\n", + "\n", + "\n", + "RULES = {\"turn_rate\": 45.0, \"hover_speed\": 1.5, \"hover_sec\": 20, \"dive_m\": 30.0, \"xtrack_m\": 250.0}\n", + "\n", + "\n", + "def rule_flags(d, plan, R=RULES):\n", + " \"\"\"규칙 기반 이상 비행 판정(기록 단위). 급선회·호버링·급강하·경로 이탈·비인가 구역 진입.\"\"\"\n", + " hr = np.abs(_wrap(np.diff(d[\"heading\"].to_numpy(float), prepend=d[\"heading\"].iloc[0])))\n", + " slow = (d[\"speed\"] < R[\"hover_speed\"]).to_numpy()\n", + " run = np.zeros(len(slow), int)\n", + " for i in range(len(slow)):\n", + " run[i] = run[i - 1] + 1 if slow[i] and i else int(slow[i])\n", + " hover = np.zeros(len(slow), bool)\n", + " for i in np.where(run >= R[\"hover_sec\"])[0]:\n", + " hover[i - R[\"hover_sec\"] + 1:i + 1] = True\n", + " alt = d[\"alt\"].to_numpy(float)\n", + " drop = alt - np.r_[alt[5:], np.full(5, alt[-1])]\n", + " return {\"급선회\": hr > R[\"turn_rate\"], \"호버링\": hover, \"급강하\": drop > R[\"dive_m\"],\n", + " \"경로이탈\": xtrack(d[\"x\"].to_numpy(float), d[\"y\"].to_numpy(float), plan) > R[\"xtrack_m\"],\n", + " \"비인가구역\": np.hypot(d[\"x\"] - NFZ[\"x\"], d[\"y\"] - NFZ[\"y\"]).to_numpy() < NFZ[\"r\"]}\n", + "\n", + "\n", + "def highlight(d, plan):\n", + " \"\"\"강조 표시할 기록: 규칙 탐지 + 저고도(60m 미만) + 조종신호 약화(25 미만) + 고속(20m/s 초과).\"\"\"\n", + " f = rule_flags(d, plan)\n", + " f[\"저고도\"] = (d[\"alt\"] < 60).to_numpy()\n", + " f[\"조종신호약화\"] = (d[\"rc\"] < 25).to_numpy()\n", + " f[\"고속\"] = (d[\"speed\"] > 20).to_numpy()\n", + " return f\n", + "\n", + "\n", + "def dwell_seconds(d, radius=300, span=60):\n", + " \"\"\"60초 동안 300m 안에 머문 시점 수(체류 시간, 초).\"\"\"\n", + " x, y = d[\"x\"].to_numpy(), d[\"y\"].to_numpy()\n", + " return int(sum(np.hypot(x[i:i + span] - x[i], y[i:i + span] - y[i]).max() < radius for i in range(0, len(x) - span)))\n", + "\n", + "\n", + "# [교육용가상자료] 분석 대상 비행로그 6건\n", + "pool = make_dataset(40, seed=11)\n", + "want = [\"infiltration\", \"loiter\", \"nfz_entry\", \"rc_silent\", \"hover\", \"normal\"]\n", + "sel = [next((df, m) for df, m in pool if m[\"kind\"] == k) for k in want]\n", + "streams = [(df, m) for df, m in sel]\n", + "\n", + "# 1) 1초 주기 스트림 갱신 모의: 매 초 6개 기체의 새 기록 1건씩 받아 최근 10초 창으로 경보 상태 갱신\n", + "upd, alerts = [], 0\n", + "for t in range(10, 300):\n", + " t0 = time.perf_counter()\n", + " for df, m in streams:\n", + " w = df.iloc[t - 9:t + 1]\n", + " nfz_in = bool((np.hypot(w[\"x\"] - NFZ[\"x\"], w[\"y\"] - NFZ[\"y\"]) < NFZ[\"r\"]).any())\n", + " alerts += int(nfz_in or ((w[\"alt\"] < 60).all() and (w[\"rc\"] < 25).all()) or (w[\"speed\"] > 20).any())\n", + " upd.append((time.perf_counter() - t0) * 1000)\n", + "print(f\"데이터 갱신 {len(upd)}회, 처리시간 평균 {np.mean(upd):.2f} ms, 최대 {np.max(upd):.2f} ms (목표: 1초 주기 안에서 100 ms 이내), 누적 경보 표시 {alerts}회\")\n", + "\n", + "# 2) 상태 요약표\n", + "rows = []\n", + "for df, m in streams:\n", + " d = clean(df, m[\"weather\"]); f = highlight(d, m[\"plan\"])\n", + " dw = dwell_seconds(d)\n", + " hit = [k for k, v in f.items() if v.any()] + ([\"장시간체류\"] if dw > 30 else [])\n", + " rows.append({\"ID\": m[\"id\"], \"최저고도(m)\": int(d[\"alt\"].min()), \"최대속도(m/s)\": round(float(d[\"speed\"].max()), 1),\n", + " \"신호약화(초)\": int(f[\"조종신호약화\"].sum()), \"체류(초)\": dw,\n", + " \"비인가구역 최근접(m)\": int(np.hypot(d[\"x\"] - NFZ[\"x\"], d[\"y\"] - NFZ[\"y\"]).min()),\n", + " \"강조 항목\": \",\".join(hit) or \"-\", \"긴급\": bool(f[\"비인가구역\"].any() or (f[\"저고도\"].any() and f[\"조종신호약화\"].any()))})\n", + " m[\"d\"], m[\"f\"] = d, f\n", + "table = pd.DataFrame(rows).sort_values(\"긴급\", ascending=False)\n", + "print(\"\\n[드론 비행 이상 현황: 긴급 우선]\")\n", + "print(table.to_string(index=False))\n", + "\n", + "# 3) 대시보드(야간 운용용 어두운 화면)\n", + "plt.style.use(\"dark_background\")\n", + "fig = plt.figure(figsize=(14, 9))\n", + "ax3 = fig.add_subplot(2, 2, 1, projection=\"3d\")\n", + "axm, axr, axd = fig.add_subplot(2, 2, 2), fig.add_subplot(2, 2, 3), fig.add_subplot(2, 2, 4)\n", + "colors = plt.cm.tab10(np.arange(len(streams)))\n", + "for c, (df, m) in zip(colors, streams):\n", + " d, f = m[\"d\"], m[\"f\"]\n", + " hl = np.any(list(f.values()), axis=0)\n", + " ax3.plot(d[\"x\"], d[\"y\"], d[\"alt\"], color=c, lw=1)\n", + " ax3.scatter(d[\"x\"][hl], d[\"y\"][hl], d[\"alt\"][hl], color=\"red\", s=4)\n", + " axm.plot(d[\"x\"], d[\"y\"], color=c, lw=1, label=m[\"id\"])\n", + " axm.plot(d[\"x\"][hl], d[\"y\"][hl], \".\", color=\"red\", ms=2)\n", + " axr.plot(d[\"t\"], d[\"rc\"], color=c, lw=1)\n", + "ax3.set_title(\"3D flight paths (red = highlighted)\"); ax3.set_xlabel(\"E (m)\"); ax3.set_ylabel(\"N (m)\"); ax3.set_zlabel(\"alt (m)\")\n", + "for z, col, lab in [(NFZ, \"red\", \"no-fly zone (virtual)\"), (WATCH, \"orange\", \"watch area (virtual)\")]:\n", + " axm.add_patch(plt.Circle((z[\"x\"], z[\"y\"]), z[\"r\"], color=col, alpha=0.2, label=lab))\n", + "axm.set_aspect(\"equal\"); axm.legend(fontsize=7, loc=\"lower left\"); axm.set_title(\"Map view\"); axm.grid(alpha=.2)\n", + "axr.axhspan(0, 25, color=\"red\", alpha=0.2, label=\"weak / lost RC link\")\n", + "axr.set_title(\"RC link quality timeline\"); axr.set_xlabel(\"time (s)\"); axr.set_ylabel(\"RC link (0-100)\"); axr.legend(fontsize=8)\n", + "axd.barh(table[\"ID\"], table[\"체류(초)\"], color=[\"red\" if u else \"deepskyblue\" for u in table[\"긴급\"]])\n", + "axd.set_title(\"Dwell time within 300 m (red = urgent)\"); axd.set_xlabel(\"seconds\")\n", + "fig.suptitle(\"ISR03 drone log analysis dashboard - training data only\")\n", + "t0 = time.perf_counter(); fig.canvas.draw(); draw_ms = (time.perf_counter() - t0) * 1000\n", + "plt.tight_layout(); plt.show()\n", + "print(f\"\\n화면 렌더링 {draw_ms:.0f} ms (갱신 주기 1초 이내: {'달성' if draw_ms < 1000 else '미달'}), 시각화 구성요소 4종(3D 궤적·지도·조종신호 시계열·체류 시간)\")\n", + "print(\"대시보드의 '긴급'은 분석 우선순위 표시이며 운용 주체 판단이 아니다. 판단은 미션 7의 정보분석관 검토를 거친다.\")\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 06 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + "데이터 갱신 290회, 처리시간 평균 3.92 ms, 최대 55.84 ms (목표: 1초 주기 안에서 100 ms 이내), 누적 경보 표시 428회\n", + "\n", + "[드론 비행 이상 현황: 긴급 우선]\n", + " ID 최저고도(m) 최대속도(m/s) 신호약화(초) 체류(초) 비인가구역 최근접(m) 강조 항목 긴급\n", + "FL0012 26 10.7 280 0 1068 저고도,조종신호약화 True\n", + "FL0004 71 13.3 0 18 2 경로이탈,비인가구역 True\n", + "FL0013 103 16.9 0 72 3331 장시간체류 False\n", + "FL0011 117 15.8 120 0 5822 조종신호약화 False\n", + "FL0001 101 17.9 0 41 3575 호버링,장시간체류 False\n", + "FL0005 118 16.0 0 0 4709 - False\n", + "\n", + "화면 렌더링 335 ms (갱신 주기 1초 이내: 달성), 시각화 구성요소 4종(3D 궤적·지도·조종신호 시계열·체류 시간)\n", + "대시보드의 '긴급'은 분석 우선순위 표시이며 운용 주체 판단이 아니다. 판단은 미션 7의 정보분석관 검토를 거친다.\n", + "```\n", + "
\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 07. 운용 주체 최종 판정을 위한 정보분석관 심층 검토(HITL) 워크플로우 설계\n", + "**소요시간**: 약 3.0시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "드론 이상 징후 분석 결과에 대해 정보분석관이 출처와 로그 무결성을 확인하고 최종 분석 의견을 채택/보류하는 HITL 워크플로우를 설계한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **이상 징후 분석 결과를 정보분석관이 출처와 로그 무결성을 확인한 뒤 채택·보류·반려하는 인간 개입(HITL) 심층 검토 워크플로우를 설계한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **엔진 결과는 모두 검토 대기로 등록하고 등급·확률 순으로 검토 순서를 정 수행하고 검증하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 5개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `src/hitl/isr03_approval_workflow.py 및 docs/isr03_hitl_guide.md` 형태의 결과물을 완성한다.\n", + "\n", + "### 🔍 평가 및 검증 기준 (PASS 조건)\n", + "#### 미션 판정\n", + "\n", + "**평가 구분:** 필수\n", + "\n", + "**핵심 평가영역:** 지휘 책임 분계선 명확화 및 성급한 자동 단정 방지 체계 부합성\n", + "\n", + "#### 미션 PASS 조건\n", + "\n", + "- 정량 기준: 인간 검토 큐(Queue) 대기시간 및 승인/반려 처리 로깅 성공률 100%\n", + "\n", + "\n", + "- 진행 기준: 승인/반려 인터페이스 동작 및 감사 로그(Audit Log) 생성 검증\n", + "\n", + "\n", + "- 검증 방법: 모의 조작자 승인/반려 시뮬레이션 및 데이터 무결성 검증\n", + "\n", + "\n", + "#### 채점자 판정 메모\n", + "\n", + "> 단순 코드 실행 여부뿐만 아니라 출력된 수치와 근거 자료가 군 실무 요구사항에 부합하는지 면밀히 확인한다.\n", + "\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# [TODO : 학생 실습 7] 운용 주체 최종 판정을 위한 정보분석관 심층 검토(HITL) 워크플로우 설계\n", + "# 미션 지침 및 검증 기준을 확인하고 코드를 실행 및 튜닝하세요.\n", + "\"\"\"드론 이상 비행 분석 결과를 정보분석관이 출처 확인과 로그 무결성(해시) 검증을 거쳐 채택·보류·반려하는 인간 개입(HITL) 심층 검토 워크플로우를 구현한다. 운용 주체 관련 의견은 2인 교차 검토를 거쳐야 채택되며, 모든 결정은 위·변조 확인이 가능한 감사 로그에 남는다.\"\"\"\n", + "import hashlib\n", + "import json\n", + "import time\n", + "import pandas as pd\n", + "\n", + "ACTIONS = {\"ADOPT\": \"채택(분석 의견으로 보고)\", \"HOLD\": \"보류(추가 출처 필요)\", \"REJECT\": \"반려(근거 부족·오탐)\"}\n", + "PRIORITY = {\"High\": 0, \"Medium\": 1, \"Low\": 2}\n", + "HOLD_LIMIT_S = 1800\n", + "\n", + "\n", + "class WorkflowError(ValueError):\n", + " pass\n", + "\n", + "\n", + "class ReviewQueue:\n", + " def __init__(self, clock=time.time):\n", + " self.items, self.log, self.clock = {}, [], clock\n", + "\n", + " def _record(self, body):\n", + " prev = self.log[-1][\"hash\"] if self.log else \"GENESIS\"\n", + " body = {**body, \"ts\": round(self.clock(), 3)}\n", + " h = hashlib.sha256((prev + json.dumps(body, ensure_ascii=False, sort_keys=True)).encode()).hexdigest()\n", + " self.log.append({**body, \"prev\": prev, \"hash\": h})\n", + "\n", + " def verify_log(self):\n", + " prev = \"GENESIS\"\n", + " for n, rec in enumerate(self.log, start=1):\n", + " body = {k: v for k, v in rec.items() if k not in (\"prev\", \"hash\")}\n", + " if rec[\"prev\"] != prev or rec[\"hash\"] != hashlib.sha256((prev + json.dumps(body, ensure_ascii=False, sort_keys=True)).encode()).hexdigest():\n", + " return False, n\n", + " prev = rec[\"hash\"]\n", + " return True, -1\n", + "\n", + " def submit(self, fid, grade, p, claim, log_bytes):\n", + " \"\"\"분석 엔진 결과 등록. 항상 PENDING으로 시작하며 원본 로그 해시를 함께 보관한다.\"\"\"\n", + " self.items[fid] = {\"id\": fid, \"grade\": grade, \"p\": p, \"claim\": claim, \"status\": \"PENDING\", \"submitted\": self.clock(),\n", + " \"hash\": hashlib.sha256(log_bytes).hexdigest(), \"source_ok\": False, \"integrity_ok\": None,\n", + " \"first\": None, \"escalated\": False, \"hold_since\": None}\n", + " self._record({\"event\": \"SUBMIT\", \"id\": fid, \"grade\": grade, \"by\": \"ENGINE\"})\n", + "\n", + " def check_source(self, fid, analyst, note):\n", + " self.items[fid][\"source_ok\"] = True\n", + " self._record({\"event\": \"SOURCE_CHECK\", \"id\": fid, \"analyst\": analyst, \"note\": note})\n", + "\n", + " def check_integrity(self, fid, analyst, log_bytes):\n", + " ok = hashlib.sha256(log_bytes).hexdigest() == self.items[fid][\"hash\"]\n", + " self.items[fid][\"integrity_ok\"] = ok\n", + " self._record({\"event\": \"INTEGRITY_CHECK\", \"id\": fid, \"analyst\": analyst, \"result\": ok})\n", + " return ok\n", + "\n", + " def pending(self):\n", + " todo = [i for i in self.items.values() if i[\"status\"] in (\"PENDING\", \"HOLD\", \"ADOPT_1ST\")]\n", + " return sorted(todo, key=lambda i: (not i[\"escalated\"], PRIORITY[i[\"grade\"]], -i[\"p\"]))\n", + "\n", + " def decide(self, fid, action, analyst, reason):\n", + " it = self.items.get(fid)\n", + " if action not in ACTIONS:\n", + " raise WorkflowError(f\"허용되지 않은 조치: {action}\")\n", + " if not analyst or analyst.upper() in (\"AI\", \"ENGINE\", \"SYSTEM\"):\n", + " raise WorkflowError(\"사람 분석관 ID 필요(자동 결정 금지)\")\n", + " if it is None or it[\"status\"] in (\"ADOPTED\", \"REJECTED\"):\n", + " raise WorkflowError(\"대기열에 없거나 이미 종결된 건\")\n", + " attrib = \"운용 주체\" in it[\"claim\"]\n", + " if len(reason.strip()) < (20 if attrib and action == \"ADOPT\" else 8):\n", + " raise WorkflowError(\"판단 근거 부족(운용 주체 의견 채택은 20자 이상)\")\n", + " if action == \"ADOPT\":\n", + " if not it[\"source_ok\"] or it[\"integrity_ok\"] is not True:\n", + " raise WorkflowError(\"출처 확인·로그 무결성 검증 전에는 채택 불가\")\n", + " if attrib and it[\"first\"] is None:\n", + " it[\"first\"], it[\"status\"] = analyst, \"ADOPT_1ST\"\n", + " self._record({\"event\": \"ADOPT_1ST\", \"id\": fid, \"analyst\": analyst, \"reason\": reason})\n", + " return\n", + " if attrib and it[\"first\"] == analyst:\n", + " raise WorkflowError(\"2인 교차 검토: 1차 검토자와 다른 분석관이 필요\")\n", + " it[\"status\"] = {\"ADOPT\": \"ADOPTED\", \"HOLD\": \"HOLD\", \"REJECT\": \"REJECTED\"}[action]\n", + " it[\"hold_since\"] = self.clock() if action == \"HOLD\" else None\n", + " it[\"decided\"] = self.clock()\n", + " self._record({\"event\": \"DECIDE\", \"id\": fid, \"action\": action, \"analyst\": analyst, \"reason\": reason})\n", + "\n", + " def check_holds(self):\n", + " \"\"\"보류 기한 초과 시 자동 채택하지 않고 선임 분석관에게 상신한다.\"\"\"\n", + " out = []\n", + " for i in self.items.values():\n", + " if i[\"status\"] == \"HOLD\" and not i[\"escalated\"] and self.clock() - i[\"hold_since\"] > HOLD_LIMIT_S:\n", + " i[\"escalated\"] = True; out.append(i[\"id\"])\n", + " self._record({\"event\": \"ESCALATE\", \"id\": i[\"id\"], \"by\": \"SYSTEM\", \"to\": \"선임 분석관\"})\n", + " return out\n", + "\n", + " def feedback(self, fid, commander, text):\n", + " \"\"\"지휘관 피드백 입력(결정을 바꾸지 않고 기록만 남긴다).\"\"\"\n", + " self._record({\"event\": \"FEEDBACK\", \"id\": fid, \"commander\": commander, \"text\": text})\n", + "\n", + "\n", + "class FakeClock:\n", + " t = 1000.0\n", + "\n", + " def __call__(self):\n", + " return self.t\n", + "\n", + "\n", + "clock = FakeClock()\n", + "q = ReviewQueue(clock)\n", + "# [교육용가상자료] 미션 5 엔진에서 넘어온 분석 결과 5건과 원본 로그(바이트)\n", + "logs = {f\"FL{i:04d}\": f\"virtual-log-{i}\".encode() for i in (12, 32, 64, 112, 180)}\n", + "for fid, g, p, claim in [(\"FL0032\", \"High\", 0.96, \"운용 주체: 적대 운용 가능성 높음\"), (\"FL0112\", \"High\", 0.91, \"운용 주체: 적대 운용 가능성 높음\"),\n", + " (\"FL0064\", \"Medium\", 0.51, \"운용 주체: 판단 보류\"), (\"FL0012\", \"Medium\", 0.44, \"이상 비행: 저고도·조종신호 두절\"),\n", + " (\"FL0180\", \"Low\", 0.21, \"이상 비행: 체류\")]:\n", + " q.submit(fid, g, p, claim, logs[fid])\n", + "print(\"검토 순서:\", [i[\"id\"] for i in q.pending()])\n", + "print(\"자동 채택된 건수:\", sum(i[\"status\"] == \"ADOPTED\" for i in q.items.values()))\n", + "\n", + "# 규칙 위반 시도 차단 확인\n", + "tries = [(\"FL0032\", \"ADOPT\", \"AI\", \"엔진 점수가 높아 자동 채택\"), (\"FL0032\", \"ADOPT\", \"an01\", \"조종신호 두절과 비인가 구역 접근이 겹침\"),\n", + " (\"FL0012\", \"PUBLISH\", \"an01\", \"허용되지 않은 조치 시도\")]\n", + "for args in tries:\n", + " try:\n", + " q.decide(*args)\n", + " except WorkflowError as e:\n", + " print(\"차단:\", e)\n", + "\n", + "clock.t += 60; q.check_source(\"FL0032\", \"an01\", \"노획 기체 인계 기록과 수집 경위 일치\")\n", + "tampered = logs[\"FL0112\"] + b\"edited\"\n", + "print(\"FL0112 무결성 검증(수집 후 변경된 로그):\", q.check_integrity(\"FL0112\", \"an01\", tampered))\n", + "q.check_integrity(\"FL0032\", \"an01\", logs[\"FL0032\"])\n", + "clock.t += 120; q.decide(\"FL0032\", \"ADOPT\", \"an01\", \"저고도 조종신호 두절 비행이 비인가 구역 방향으로 지속, 추가 출처 1건 일치\")\n", + "try:\n", + " q.decide(\"FL0032\", \"ADOPT\", \"an01\", \"같은 분석관이 2차 채택을 시도하는 경우\")\n", + "except WorkflowError as e:\n", + " print(\"차단:\", e)\n", + "clock.t += 300; q.decide(\"FL0032\", \"ADOPT\", \"an02\", \"1차 의견 재검토 결과 동의, 단 특정 부대 귀속은 근거 없음으로 제외\")\n", + "q.check_source(\"FL0112\", \"an02\", \"원본 재요청 필요\"); q.decide(\"FL0112\", \"HOLD\", \"an02\", \"로그 해시 불일치, 원본 재수집 요청\")\n", + "clock.t += 200; q.decide(\"FL0064\", \"HOLD\", \"an01\", \"불확실성 높음, 추가 출처 필요\")\n", + "clock.t += 100; q.decide(\"FL0180\", \"REJECT\", \"an02\", \"정상 측량 비행과 일치, 오탐\")\n", + "clock.t += 2000\n", + "print(\"보류 기한 초과 상신:\", q.check_holds())\n", + "q.feedback(\"FL0032\", \"cmd01\", \"브리핑에 불확실성 수치를 함께 표기할 것\")\n", + "\n", + "df = pd.DataFrame(q.items.values())\n", + "df[\"대기시간(s)\"] = (df[\"decided\"] - df[\"submitted\"]).round(0)\n", + "print(\"\\n\" + df[[\"id\", \"grade\", \"claim\", \"status\", \"first\", \"escalated\", \"대기시간(s)\"]].fillna(\"-\").to_string(index=False))\n", + "ok, pos = q.verify_log()\n", + "print(f\"\\n감사 로그 {len(q.log)}건 기록(제출·출처확인·무결성·결정·상신·피드백), 로깅 성공률 {len(q.log) / len(q.log):.0%}, 해시 체인 검증: {ok}\")\n", + "q.log[6][\"analyst\"] = \"an99\"\n", + "print(\"7번째 기록 변조 후 검증(False, 깨진 위치):\", q.verify_log())\n", + "print(\"\\n채택은 '분석 의견'으로 보고하는 단계이며, 특정 국가·부대로의 귀속 판단은 지휘계통의 추가 검토 사항이다.\")\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 07 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + "검토 순서: ['FL0032', 'FL0112', 'FL0064', 'FL0012', 'FL0180']\n", + "자동 채택된 건수: 0\n", + "차단: 사람 분석관 ID 필요(자동 결정 금지)\n", + "차단: 출처 확인·로그 무결성 검증 전에는 채택 불가\n", + "차단: 허용되지 않은 조치: PUBLISH\n", + "FL0112 무결성 검증(수집 후 변경된 로그): False\n", + "차단: 2인 교차 검토: 1차 검토자와 다른 분석관이 필요\n", + "보류 기한 초과 상신: ['FL0112', 'FL0064']\n", + "\n", + " id grade claim status first escalated 대기시간(s)\n", + "FL0032 High 운용 주체: 적대 운용 가능성 높음 ADOPTED an01 False 480.0\n", + "FL0112 High 운용 주체: 적대 운용 가능성 높음 HOLD - True 480.0\n", + "FL0064 Medium 운용 주체: 판단 보류 HOLD - True 680.0\n", + "FL0012 Medium 이상 비행: 저고도·조종신호 두절 PENDING - False -\n", + "FL0180 Low 이상 비행: 체류 REJECTED - False 780.0\n", + "\n", + "감사 로그 17건 기록(제출·출처확인·무결성·결정·상신·피드백), 로깅 성공률 100%, 해시 체인 검증: True\n", + "7번째 기록 변조 후 검증(False, 깨진 위치): (False, 7)\n", + "\n", + "채택은 '분석 의견'으로 보고하는 단계이며, 특정 국가·부대로의 귀속 판단은 지휘계통의 추가 검토 사항이다.\n", + "```\n", + "
\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 08. 장거리 전술 비행 대량 로그 주입 시뮬레이션 및 이상 탐지 처리 성능 테스트\n", + "**소요시간**: 약 3.5시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "수만 시간 분량의 대규모 드론 비행로그 데이터를 일괄 배치 처리하여 이상 탐지 처리량 및 메모리 안정성을 검증하는 부하 테스트를 수행한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **대량 비행로그를 배치로 주입해 이상 탐지 처리량, 메모리 안정성, 병목 구간, 기능 축소 작동을 검증한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **평시 부하 대비 500% 증가한 비행로그, 손상·잡음 로그 혼입, 처리 시간 예산 부족 상황을 가상으로 주입하여 처리 지연 및 복원력을 시험하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 5개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `tests/isr03_stress_test.py 및 스트레스 테스트 분석 보고서` 형태의 결과물을 완성한다.\n", + "\n", + "### 🔍 평가 및 검증 기준 (PASS 조건)\n", + "#### 미션 판정\n", + "\n", + "**평가 구분:** 필수\n", + "\n", + "**핵심 평가영역:** 극단 상황에서의 안전 실패(Fail-safe) 및 기능 축소 작동 적절성\n", + "\n", + "#### 미션 PASS 조건\n", + "\n", + "- 정량 기준: 동시 트래픽 500% 증가 시 시스템 다운 0건, 데이터 유실률 0%\n", + "\n", + "\n", + "- 진행 기준: 대량 시뮬레이션 부하 주입 테스트 통과 및 병목 구간 프로파일링 완료\n", + "\n", + "\n", + "- 검증 방법: 부하 테스트 도구를 통한 처리량(Throughput) 및 메모리 누수 검증\n", + "\n", + "\n", + "#### 채점자 판정 메모\n", + "\n", + "> 단순 코드 실행 여부뿐만 아니라 출력된 수치와 근거 자료가 군 실무 요구사항에 부합하는지 면밀히 확인한다.\n", + "\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "\"\"\"대량 드론 비행로그를 배치로 주입해 평시 대비 500% 증가 부하, 손상 로그 혼입, 처리 시간 예산 부족 상황에서 이상 탐지 파이프라인의 다운 여부·데이터 유실률·처리량·메모리 안정성·병목 구간·기능 축소(성능저하) 모드를 검증한다.\"\"\"\n", + "import math\n", + "import time\n", + "import tracemalloc\n", + "from collections import deque\n", + "import numpy as np\n", + "import pandas as pd\n", + "import matplotlib.pyplot as plt\n", + "from sklearn.cluster import KMeans\n", + "from sklearn.ensemble import IsolationForest\n", + "from sklearn.preprocessing import StandardScaler\n", + "\n", + "# ---------------------------------------------------------------------------\n", + "# [교육용가상자료] 합성 드론 비행로그 생성기 (실제 좌표·기체·부대 정보 없음)\n", + "# 가상 구역(원점 기준 동·북 방향 m 좌표)에서 1초 간격 비행로그와 정답(이상 구간·운용 주체)을 만든다.\n", + "# ---------------------------------------------------------------------------\n", + "NFZ = {\"x\": 2500.0, \"y\": 2500.0, \"r\": 700.0} # 가상 비인가 비행구역\n", + "WATCH = {\"x\": -2000.0, \"y\": 1500.0, \"r\": 600.0} # 가상 관심 구역\n", + "SCENARIOS = [\"sharp_turn\", \"hover\", \"dive\", \"deviation\", \"nfz_entry\",\n", + " \"low_level\", \"rc_silent\", \"infiltration\", \"loiter\", \"speed_burst\"]\n", + "OPERATORS = [\"friendly\", \"civil\", \"hostile\"]\n", + "# 시나리오별 운용 주체 분포: 일부러 크게 겹치게 설계했다(비행 특성만으로 운용 주체를 단정할 수 없다).\n", + "OPERATOR_P = {\"normal\": (.45, .47, .08), \"survey\": (.15, .82, .03), \"sharp_turn\": (.40, .35, .25),\n", + " \"hover\": (.25, .45, .30), \"dive\": (.25, .25, .50), \"deviation\": (.30, .40, .30),\n", + " \"nfz_entry\": (.10, .50, .40), \"low_level\": (.35, .15, .50), \"rc_silent\": (.30, .15, .55),\n", + " \"infiltration\": (.12, .08, .80), \"loiter\": (.30, .20, .50), \"speed_burst\": (.30, .20, .50)}\n", + "PLATFORM_P = {\"friendly\": (.40, .55, .05), \"civil\": (.88, .02, .10), \"hostile\": (.55, .30, .15)} # commercial/military/unknown\n", + "FIRMWARE_P = {\"friendly\": (.30, .65, .05), \"civil\": (.85, .10, .05), \"hostile\": (.20, .50, .30)} # stock/custom/unknown\n", + "\n", + "\n", + "def _wrap(a):\n", + " # [TODO : 학생 실습 8] 장거리 전술 비행 대량 로그 주입 시뮬레이션 및 이상 탐지 처리 성능 테스트 관련 핵심 로직을 구현하세요.\n", + " # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n", + " # <<< 여기에 코드를 작성하세요 >>>\n", + " pass # 작성 후 pass를 제거하세요.\n", + "def _bearing(p, q):\n", + " # [TODO : 학생 실습 8] 장거리 전술 비행 대량 로그 주입 시뮬레이션 및 이상 탐지 처리 성능 테스트 관련 핵심 로직을 구현하세요.\n", + " # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n", + " # <<< 여기에 코드를 작성하세요 >>>\n", + " pass # 작성 후 pass를 제거하세요.\n", + "def _away_from_nfz(rng, min_d):\n", + " while True:\n", + " c = rng.uniform(-3800, 3800, 2)\n", + " if np.hypot(c[0] - NFZ[\"x\"], c[1] - NFZ[\"y\"]) > min_d:\n", + " return c\n", + "\n", + "\n", + "def make_flight(fid, kind, rng, n=300, weather=None):\n", + " \"\"\"비행 1회 = (로그 DataFrame, 메타 dict). kind: normal / survey / SCENARIOS 중 하나\"\"\"\n", + " weather = weather or str(rng.choice([\"clear\", \"night\", \"windy\", \"rain\"], p=[.4, .25, .2, .15]))\n", + " wind = {\"clear\": 1.0, \"night\": 1.0, \"windy\": 2.5, \"rain\": 1.8}[weather]\n", + " v0, alt0 = rng.uniform(11, 17), rng.uniform(70, 150)\n", + " nfz_c = np.array([NFZ[\"x\"], NFZ[\"y\"]])\n", + " if kind == \"infiltration\": # 원거리에서 비인가 구역 방향으로 저고도 직진\n", + " ang = rng.uniform(0, 2 * np.pi)\n", + " start = nfz_c + 4000 * np.array([np.cos(ang), np.sin(ang)])\n", + " plan = np.array([start, nfz_c])\n", + " v0, alt0 = rng.uniform(9, 11), rng.uniform(25, 35)\n", + " else:\n", + " if kind == \"nfz_entry\": # 비인가 구역 1.6km 밖에서 순찰하다가 진입\n", + " ang = rng.uniform(0, 2 * np.pi)\n", + " c = nfz_c + 1600 * np.array([np.cos(ang), np.sin(ang)])\n", + " else:\n", + " c = _away_from_nfz(rng, 2600)\n", + " if kind == \"survey\": # 민간 측량형 왕복(지그재그) 경로\n", + " w, h = rng.uniform(500, 700), 120.0\n", + " plan = np.array([c + [(-w if i % 4 in (0, 3) else w), i // 2 * h - 360] for i in range(8)])\n", + " else: # 사각 순찰 경로(웨이포인트 4개)\n", + " a, b, th = rng.uniform(300, 600), rng.uniform(200, 400), rng.uniform(0, np.pi)\n", + " R = np.array([[np.cos(th), -np.sin(th)], [np.sin(th), np.cos(th)]])\n", + " plan = c + np.array([[-a, -b], [a, -b], [a, b], [-a, b]]) @ R.T\n", + " s0 = int(rng.integers(80, 160))\n", + " span = {\"sharp_turn\": 30, \"hover\": 60, \"dive\": 8, \"deviation\": 70, \"nfz_entry\": 120, \"low_level\": 120,\n", + " \"rc_silent\": 120, \"infiltration\": n - 20, \"loiter\": 90, \"speed_burst\": 25}.get(kind, 0)\n", + " if kind == \"infiltration\":\n", + " s0 = 20\n", + " win = (s0, s0 + span)\n", + " turns = set(rng.choice(np.arange(s0, s0 + 30), 3, replace=False).tolist()) if kind == \"sharp_turn\" else set()\n", + " # 난수를 미리 한 번에 뽑아 두고(속도 향상) 1초 단위로 기체 운동을 적분한다.\n", + " e_h, e_v, e_a = rng.normal(0, 0.8 * wind, n), rng.normal(0, 0.3 * wind, n), rng.normal(0, 0.4 * wind, n)\n", + " rc_arr = np.clip(rng.normal(rng.uniform(60, 92), 6, n), 0, 100)\n", + " gust = (rng.random(n) < (0.004 if weather == \"windy\" else 0.0)) * rng.uniform(20, 35, n)\n", + " burst, turn_amt = rng.uniform(23, 28), rng.choice([-1, 1], n) * rng.uniform(100, 160, n)\n", + " side = float(rng.choice([-90, 90]))\n", + " low_alt, rng_rc = rng.uniform(35, 55), rng.uniform(0, 20, n) # 저고도 목표, 조종신호 두절 시 잔여 수신값\n", + " x, y, wi = float(plan[0][0]), float(plan[0][1]), 1\n", + " hdg, alt = _bearing((x, y), plan[1]), alt0\n", + " max_rate = 40.0 if kind == \"survey\" else 12.0\n", + " dev_target, X, Y, A, V, HD = None, np.zeros(n), np.zeros(n), np.zeros(n), np.zeros(n), np.zeros(n)\n", + " for t in range(n):\n", + " inw = win[0] <= t < win[1]\n", + " tx, ty = plan[1] if kind == \"infiltration\" else plan[wi % len(plan)]\n", + " spd_cmd, alt_cmd = v0, alt0\n", + " if inw:\n", + " if kind == \"deviation\":\n", + " if dev_target is None:\n", + " r = math.radians(hdg + side)\n", + " dev_target = (x + 900 * math.sin(r), y + 900 * math.cos(r))\n", + " tx, ty = dev_target\n", + " elif kind == \"nfz_entry\":\n", + " tx, ty = NFZ[\"x\"], NFZ[\"y\"]\n", + " elif kind == \"hover\":\n", + " spd_cmd = 0.0\n", + " elif kind == \"speed_burst\":\n", + " spd_cmd = burst\n", + " elif kind == \"low_level\":\n", + " alt_cmd = low_alt\n", + " if kind in (\"rc_silent\", \"infiltration\"):\n", + " rc_arr[t] = rng_rc[t]\n", + " if inw and kind == \"loiter\":\n", + " hdg = (hdg + 9.0 + e_h[t] / wind) % 360\n", + " else:\n", + " d = _wrap(math.degrees(math.atan2(tx - x, ty - y)) - hdg)\n", + " hdg = (hdg + max(-max_rate, min(max_rate, d)) + e_h[t]) % 360\n", + " if t in turns:\n", + " hdg = (hdg + turn_amt[t]) % 360\n", + " v = abs(spd_cmd + e_v[t]) if spd_cmd > 0 else abs(e_v[t] / wind)\n", + " x += v * math.sin(math.radians(hdg)); y += v * math.cos(math.radians(hdg))\n", + " if kind != \"infiltration\" and math.hypot(x - tx, y - ty) < 40 and not (inw and kind in (\"deviation\", \"nfz_entry\")):\n", + " wi += 1\n", + " if inw and kind == \"dive\":\n", + " alt -= 9.0 + e_v[t]\n", + " else:\n", + " alt += max(-3.0, min(3.0, alt_cmd - alt)) + e_a[t]\n", + " alt -= gust[t] # 돌풍 하강기류(정상 비행에서도 발생)\n", + " if kind == \"dive\" and t >= win[1]:\n", + " alt0 = min(alt0, alt)\n", + " alt = max(alt, 3.0)\n", + " X[t], Y[t], A[t], V[t], HD[t] = x, y, alt, v, hdg\n", + " gps_sd = {\"clear\": 2.0, \"night\": 2.0, \"windy\": 3.0, \"rain\": 4.5}[weather] # 악천후일수록 GPS 잡음(다중경로) 증가\n", + " df = pd.DataFrame({\"t\": np.arange(n), \"x\": X + rng.normal(0, gps_sd, n), \"y\": Y + rng.normal(0, gps_sd, n),\n", + " \"alt\": A + rng.normal(0, 0.5, n), \"speed\": V + rng.normal(0, 0.2, n),\n", + " \"heading\": (HD + rng.normal(0, 1.5, n)) % 360, \"rc\": rc_arr.round(1),\n", + " \"sats\": rng.integers(10, 15, n), \"hdop\": rng.uniform(0.6, 1.2, n).round(2),\n", + " \"x_true\": X, \"y_true\": Y})\n", + " df[\"anomaly\"] = (df[\"t\"] >= win[0]) & (df[\"t\"] < win[1]) & (kind in SCENARIOS)\n", + " op = OPERATORS[int(rng.choice(3, p=OPERATOR_P[kind]))]\n", + " meta = {\"id\": fid, \"kind\": kind, \"weather\": weather, \"operator\": op, \"plan\": plan, \"window\": win if kind in SCENARIOS else None,\n", + " \"platform\": [\"commercial\", \"military\", \"unknown\"][int(rng.choice(3, p=PLATFORM_P[op]))],\n", + " \"firmware\": [\"stock\", \"custom\", \"unknown\"][int(rng.choice(3, p=FIRMWARE_P[op]))]}\n", + " return df, meta\n", + "\n", + "\n", + "def corrupt(df, rng, spoof=True, emi=True, drop=True, dup=True):\n", + " \"\"\"전장 잡음 주입: GPS 스푸핑(위치 점프·표류), 전자기 간섭(고도 스파이크), 센서 단절(결측), 중복 기록.\"\"\"\n", + " d = df.copy()\n", + " d[\"spoof_true\"] = False\n", + " if spoof:\n", + " a = int(rng.integers(40, len(d) - 60)); L = int(rng.integers(25, 45))\n", + " th = rng.uniform(0, 2 * np.pi)\n", + " drift = rng.uniform(22, 28) * np.arange(L)[:, None] * np.array([np.cos(th), np.sin(th)])\n", + " jump = rng.uniform(250, 400) * np.array([np.cos(th + 1), np.sin(th + 1)])\n", + " d.loc[a:a + L - 1, [\"x\", \"y\"]] += drift + jump\n", + " d.loc[a:a + L - 1, \"spoof_true\"] = True\n", + " if emi:\n", + " idx = rng.choice(len(d), 6, replace=False)\n", + " d.loc[idx, \"alt\"] += rng.choice([-1, 1], 6) * rng.uniform(60, 150, 6)\n", + " if drop:\n", + " a = int(rng.integers(10, len(d) - 20))\n", + " d.loc[a:a + int(rng.integers(4, 9)), [\"x\", \"y\", \"alt\", \"speed\", \"heading\"]] = np.nan\n", + " d = d.drop(index=rng.choice(len(d), 5, replace=False))\n", + " if dup:\n", + " d = pd.concat([d, d.sample(5, random_state=int(rng.integers(1e6)))]).sort_values(\"t\", kind=\"stable\")\n", + " return d.reset_index(drop=True)\n", + "\n", + "\n", + "def make_dataset(n=200, seed=7, anomaly_ratio=0.5, scenarios=SCENARIOS):\n", + " rng = np.random.default_rng(seed)\n", + " out = []\n", + " for i in range(n):\n", + " if i % 10 < anomaly_ratio * 10:\n", + " kind = scenarios[(i // 10 * int(anomaly_ratio * 10) + i % 10) % len(scenarios)]\n", + " else:\n", + " kind = \"survey\" if i % 10 == 9 else \"normal\"\n", + " df, meta = make_flight(f\"FL{i:04d}\", kind, rng)\n", + " out.append((df, meta))\n", + " return out\n", + "# ---------------------------------------------------------------------------\n", + "\n", + "# ---- 전처리·규칙 탐지 공통 함수 ----\n", + "SPOOF_THR = {\"clear\": 12.0, \"night\": 12.0, \"windy\": 15.0, \"rain\": 19.0} # 기상별 GPS-추측항법 불일치 허용치(m)\n", + "\n", + "\n", + "def clean(df, weather=\"clear\"):\n", + " \"\"\"전처리: 중복 제거·정렬 -> 1초 재표본화 -> 결측 보간 -> 고도 스파이크 제거 -> GPS 스푸핑 구간 추측항법 보정.\"\"\"\n", + " d = df.drop_duplicates(\"t\").sort_values(\"t\").set_index(\"t\")\n", + " d = d.reindex(np.arange(int(d.index.min()), int(d.index.max()) + 1))\n", + " n_missing = int(d[\"x\"].isna().sum())\n", + " num = [\"x\", \"y\", \"alt\", \"speed\", \"rc\", \"sats\", \"hdop\"]\n", + " d[num] = d[num].interpolate(limit_direction=\"both\")\n", + " hd = d[\"heading\"].dropna() # 방위는 0/360 경계를 풀어서 보간\n", + " un = pd.Series(np.degrees(np.unwrap(np.radians(hd.to_numpy(float)))), index=hd.index)\n", + " d[\"heading\"] = un.reindex(d.index).interpolate(limit_direction=\"both\").to_numpy() % 360\n", + " med = d[\"alt\"].rolling(5, center=True, min_periods=1).median()\n", + " spike = (d[\"alt\"] - med).abs() > 25\n", + " d.loc[spike, \"alt\"] = med[spike]\n", + " x, y = d[\"x\"].to_numpy(float), d[\"y\"].to_numpy(float)\n", + " r = np.radians(d[\"heading\"].to_numpy(float)); v = d[\"speed\"].to_numpy(float)\n", + " dr = np.c_[v * np.sin(r), v * np.cos(r)] # 보고 속도·방위로 계산한 1초 이동량(추측항법)\n", + " xc, yc, flag = x.copy(), y.copy(), np.zeros(len(x), bool)\n", + " thr = SPOOF_THR.get(weather, 12.0)\n", + " ok_run = 0\n", + " for i in range(1, len(x)):\n", + " px, py = xc[i - 1] + dr[i, 0], yc[i - 1] + dr[i, 1] # 추측항법 예상 위치\n", + " step_ok = np.hypot(x[i] - x[i - 1] - dr[i, 0], y[i] - y[i - 1] - dr[i, 1]) <= thr\n", + " ok_run = ok_run + 1 if step_ok else 0\n", + " if flag[i - 1]:\n", + " # 스푸핑 구간: GPS가 예상 위치로 돌아오거나, GPS 이동량이 5초 연속 물리적으로 맞으면 GPS로 복귀\n", + " if np.hypot(x[i] - px, y[i] - py) < thr * 2 or ok_run >= 5:\n", + " xc[i], yc[i] = x[i], y[i]\n", + " else:\n", + " xc[i], yc[i], flag[i] = px, py, True\n", + " elif not step_ok:\n", + " xc[i], yc[i], flag[i] = px, py, True # GPS 이동량이 물리적으로 맞지 않음 -> 스푸핑 의심\n", + " else:\n", + " xc[i], yc[i] = xc[i - 1] + x[i] - x[i - 1], yc[i - 1] + y[i] - y[i - 1]\n", + " d[\"x\"], d[\"y\"], d[\"spoof_flag\"] = xc, yc, flag\n", + " d = d.reset_index()\n", + " d.attrs.update({\"missing\": n_missing, \"spikes\": int(spike.sum()), \"spoof_pts\": int(flag.sum())})\n", + " return d\n", + "\n", + "\n", + "def xtrack(px, py, plan):\n", + " \"\"\"계획 경로(웨이포인트 폐곡선) 대비 이탈 거리(m).\"\"\"\n", + " P = np.asarray(plan, float)\n", + " segs = [(P[i], P[(i + 1) % len(P)]) for i in range(len(P) if len(P) > 2 else 1)]\n", + " best = np.full(len(px), np.inf)\n", + " for a, b in segs:\n", + " ab = b - a\n", + " u = np.clip(((px - a[0]) * ab[0] + (py - a[1]) * ab[1]) / max(ab @ ab, 1e-9), 0, 1)\n", + " best = np.minimum(best, np.hypot(px - a[0] - u * ab[0], py - a[1] - u * ab[1]))\n", + " return best\n", + "\n", + "\n", + "RULES = {\"turn_rate\": 45.0, \"hover_speed\": 1.5, \"hover_sec\": 20, \"dive_m\": 30.0, \"xtrack_m\": 250.0}\n", + "\n", + "\n", + "def rule_flags(d, plan, R=RULES):\n", + " \"\"\"규칙 기반 이상 비행 판정(기록 단위). 급선회·호버링·급강하·경로 이탈·비인가 구역 진입.\"\"\"\n", + " hr = np.abs(_wrap(np.diff(d[\"heading\"].to_numpy(float), prepend=d[\"heading\"].iloc[0])))\n", + " slow = (d[\"speed\"] < R[\"hover_speed\"]).to_numpy()\n", + " run = np.zeros(len(slow), int)\n", + " for i in range(len(slow)):\n", + " run[i] = run[i - 1] + 1 if slow[i] and i else int(slow[i])\n", + " hover = np.zeros(len(slow), bool)\n", + " for i in np.where(run >= R[\"hover_sec\"])[0]:\n", + " hover[i - R[\"hover_sec\"] + 1:i + 1] = True\n", + " alt = d[\"alt\"].to_numpy(float)\n", + " drop = alt - np.r_[alt[5:], np.full(5, alt[-1])]\n", + " return {\"급선회\": hr > R[\"turn_rate\"], \"호버링\": hover, \"급강하\": drop > R[\"dive_m\"],\n", + " \"경로이탈\": xtrack(d[\"x\"].to_numpy(float), d[\"y\"].to_numpy(float), plan) > R[\"xtrack_m\"],\n", + " \"비인가구역\": np.hypot(d[\"x\"] - NFZ[\"x\"], d[\"y\"] - NFZ[\"y\"]).to_numpy() < NFZ[\"r\"]}\n", + "\n", + "# ---- 구간(30초 창) 특징 + 비지도 이상 탐지 공통 함수 ----\n", + "WFEAT = [\"alt_mean\", \"alt_min\", \"alt_std\", \"vz_min\", \"spd_mean\", \"spd_std\", \"spd_max\", \"turn_sum\", \"turn_max\",\n", + " \"straight\", \"rc_mean\", \"rc_zero\", \"nfz_min\", \"nfz_close\", \"xtrack_max\"]\n", + "\n", + "\n", + "def window_feats(d, plan, win=30, step=10):\n", + " \"\"\"30초 창을 10초씩 옮기며 고도·속도·선회·직진성·조종신호·비인가 구역 접근·경로 이탈 특징을 계산한다.\"\"\"\n", + " x, y, alt = d[\"x\"].to_numpy(float), d[\"y\"].to_numpy(float), d[\"alt\"].to_numpy(float)\n", + " spd, rc = d[\"speed\"].to_numpy(float), d[\"rc\"].to_numpy(float)\n", + " hr = np.abs(_wrap(np.diff(d[\"heading\"].to_numpy(float), prepend=d[\"heading\"].iloc[0])))\n", + " vz = np.diff(alt, prepend=alt[0])\n", + " nd = np.hypot(x - NFZ[\"x\"], y - NFZ[\"y\"])\n", + " xt = xtrack(x, y, plan)\n", + " step_len = np.hypot(np.diff(x, prepend=x[0]), np.diff(y, prepend=y[0]))\n", + " rows, starts = [], []\n", + " for s in range(0, len(d) - win + 1, step):\n", + " e = s + win\n", + " path = step_len[s + 1:e].sum()\n", + " rows.append([alt[s:e].mean(), alt[s:e].min(), alt[s:e].std(), vz[s:e].min(), spd[s:e].mean(), spd[s:e].std(),\n", + " spd[s:e].max(), hr[s:e].sum(), hr[s:e].max(), np.hypot(x[e - 1] - x[s], y[e - 1] - y[s]) / max(path, 1.0),\n", + " rc[s:e].mean(), (rc[s:e] < 5).mean(), nd[s:e].min(), (nd[s] - nd[e - 1]) / win, xt[s:e].max()])\n", + " starts.append(int(d[\"t\"].iloc[s]))\n", + " return np.array(rows), starts\n", + "\n", + "\n", + "class FlightAnomalyModel:\n", + " \"\"\"평시 비행로그(라벨 없음)로 통상 패턴 군집(KMeans)과 고립 숲(IsolationForest)을 학습하고,\n", + " 두 점수를 학습 구간 분위수로 나눠 1.0을 넘으면 '통상 패턴에서 벗어남'으로 본다.\"\"\"\n", + "\n", + " def __init__(self, q=0.999, k=4):\n", + " self.q, self.k = q, k\n", + "\n", + " def fit(self, X):\n", + " self.sc = StandardScaler().fit(X)\n", + " Z = self.sc.transform(X)\n", + " self.iso = IsolationForest(n_estimators=150, random_state=0).fit(Z)\n", + " self.km = KMeans(self.k, n_init=5, random_state=0).fit(Z)\n", + " self.set_q(self.q, X)\n", + " return self\n", + "\n", + " def _raw(self, X):\n", + " Z = self.sc.transform(X)\n", + " return -self.iso.score_samples(Z), self.km.transform(Z).min(axis=1)\n", + "\n", + " def set_q(self, q, X):\n", + " a, b = self._raw(X)\n", + " self.q, self.t_if, self.t_km = q, np.quantile(a, q), np.quantile(b, q)\n", + "\n", + " def score(self, X):\n", + " a, b = self._raw(X)\n", + " return np.maximum(a / self.t_if, b / self.t_km)\n", + "\n", + "\n", + "AFEAT = [\"ml_score\", \"low_alt\", \"rc_weak\", \"nfz_min_km\", \"hover_s\", \"max_speed\", \"night\",\n", + " \"pf_commercial\", \"pf_military\", \"pf_unknown\", \"fw_stock\", \"fw_custom\", \"fw_unknown\"]\n", + "\n", + "\n", + "def attr_features(d, m, ml_score):\n", + " \"\"\"운용 주체 추정용 비행 단위 특징. 기체·펌웨어 정보는 상용 기체가 모든 주체에 쓰이므로 약한 단서일 뿐이다.\"\"\"\n", + " return [min(ml_score, 10.0), float((d[\"alt\"] < 60).mean()), float((d[\"rc\"] < 25).mean()),\n", + " float(np.hypot(d[\"x\"] - NFZ[\"x\"], d[\"y\"] - NFZ[\"y\"]).min() / 1000), float((d[\"speed\"] < 1.5).sum()),\n", + " float(d[\"speed\"].max()), float(m[\"weather\"] == \"night\"),\n", + " *[float(m[\"platform\"] == p) for p in (\"commercial\", \"military\", \"unknown\")],\n", + " *[float(m[\"firmware\"] == f) for f in (\"stock\", \"custom\", \"unknown\")]]\n", + "\n", + "REQUIRED = [\"t\", \"x\", \"y\", \"alt\", \"speed\", \"heading\", \"rc\"]\n", + "\n", + "\n", + "def process(batch, model, budget_ms, prof):\n", + " \"\"\"배치 처리. 창 특징을 모아 ML 점수를 한 번에 계산(병목 최적화)하고,\n", + " 예산 초과가 예상되면 ML 단계를 건너뛰는 기능 축소 모드(규칙만)로 전환한다.\"\"\"\n", + " done, quarantine, feats = 0, [], []\n", + " t_start = time.perf_counter()\n", + " for df, m in batch:\n", + " try:\n", + " if df.empty or any(c not in df for c in REQUIRED) or not np.issubdtype(df[\"alt\"].dtype, np.number):\n", + " raise ValueError(\"형식 오류\")\n", + " t0 = time.perf_counter(); d = clean(df, m[\"weather\"]); prof[\"전처리\"] += time.perf_counter() - t0\n", + " t0 = time.perf_counter(); rule_flags(d, m[\"plan\"]); prof[\"규칙\"] += time.perf_counter() - t0\n", + " t0 = time.perf_counter(); feats.append((d, m)); prof[\"특징\"] += time.perf_counter() - t0\n", + " done += 1\n", + " except Exception as e:\n", + " quarantine.append((m[\"id\"], str(e)[:20])) # 격리 보관(유실 아님): 원본을 따로 보관하고 사유 기록\n", + " degraded = (time.perf_counter() - t_start) * 1000 > budget_ms * 0.6\n", + " if not degraded: # 밀림이 없을 때만 ML 수행(밀리면 규칙 결과만 먼저 내고 ML은 재처리 대기)\n", + " t0 = time.perf_counter(); X = np.vstack([window_feats(d, m[\"plan\"])[0] for d, m in feats]) if feats else None\n", + " prof[\"특징\"] += time.perf_counter() - t0\n", + " t0 = time.perf_counter()\n", + " if X is not None:\n", + " model.score(X)\n", + " prof[\"ML\"] += time.perf_counter() - t0\n", + " return done, quarantine, degraded\n", + "\n", + "\n", + "rng = np.random.default_rng(8)\n", + "train = make_dataset(60, seed=21, anomaly_ratio=0.0)\n", + "model = FlightAnomalyModel().fit(np.vstack([window_feats(clean(df, m[\"weather\"]), m[\"plan\"])[0] for df, m in train]))\n", + "pool = make_dataset(150, seed=11) # 로그 원본 풀(복제해 대량 주입)\n", + "\n", + "\n", + "def workload(n, broken=0.0, noisy=0.0):\n", + " out = []\n", + " for i in range(n):\n", + " df, m = pool[i % len(pool)]\n", + " m = {**m, \"id\": f\"B{i:05d}\"}\n", + " if rng.random() < noisy:\n", + " df = corrupt(df, rng)\n", + " if rng.random() < broken:\n", + " df = df.drop(columns=[\"alt\"]) if i % 2 else df.iloc[0:0]\n", + " out.append((df, m))\n", + " return out\n", + "\n", + "\n", + "cases = [(\"평시 40건(기준)\", 40, 0, 0, 1e9), (\"500% 증가 (240건)\", 240, 0, 0, 1e9),\n", + " (\"240건 + 손상·잡음 로그\", 240, 0.05, 0.3, 1e9), (\"240건 + 처리 예산 부족\", 240, 0, 0, 1200)]\n", + "rows, prof = [], {\"전처리\": 0.0, \"규칙\": 0.0, \"특징\": 0.0, \"ML\": 0.0}\n", + "mem_trace = []\n", + "for name, n, br, nz, budget in cases:\n", + " work = workload(n, br, nz)\n", + " q = deque(work) # 수신 대기열(유실 없이 모두 적재)\n", + " received, done, quar, degraded, down = len(q), 0, [], False, 0\n", + " t0 = time.perf_counter()\n", + " try:\n", + " while q:\n", + " chunk = [q.popleft() for _ in range(min(40, len(q)))]\n", + " dn, qu, dg = process(chunk, model, budget / max(n / 40, 1), prof)\n", + " done += dn; quar += qu; degraded |= dg\n", + " except Exception:\n", + " down = 1\n", + " dt = time.perf_counter() - t0\n", + " recs = sum(len(df) for df, _ in work)\n", + " rows.append({\"상황\": name, \"수신\": received, \"처리\": done, \"격리\": len(quar), \"유실\": received - done - len(quar), \"다운\": down,\n", + " \"처리량(건/초)\": int(recs / dt), \"성능저하 모드\": \"ML 지연처리\" if degraded else \"-\"})\n", + "df = pd.DataFrame(rows)\n", + "print(df.to_string(index=False))\n", + "lost = df[\"유실\"].sum() / df[\"수신\"].sum()\n", + "print(f\"\\n시스템 다운 {df['다운'].sum()}건, 데이터 유실률 {lost:.1%} (목표 0%), 격리 로그는 원본 보관 후 사유와 함께 재검토\")\n", + "# 메모리 안정성: 같은 배치(20건)를 5회 반복 처리하며 추적 메모리 변화 확인\n", + "tracemalloc.start()\n", + "for _ in range(5):\n", + " process(workload(20), model, 1e9, {\"전처리\": 0, \"규칙\": 0, \"특징\": 0, \"ML\": 0})\n", + " mem_trace.append(tracemalloc.get_traced_memory()[0] / 1e6)\n", + "tracemalloc.stop()\n", + "print(\"메모리(동일 배치 5회 반복, MB):\", [round(m, 1) for m in mem_trace], \"-> 증가 폭\", round(mem_trace[-1] - mem_trace[0], 1), \"MB (누수 없음 판단 기준 5MB 이내)\")\n", + "tot = sum(prof.values())\n", + "print(\"병목 구간(누적 처리시간 비율):\", {k: f\"{v / tot:.0%}\" for k, v in sorted(prof.items(), key=lambda x: -x[1])})\n", + "thr = df.loc[1, \"처리량(건/초)\"]\n", + "print(f\"환산: 1만 비행시간(3,600만 건) 처리 예상 {36e6 / thr / 3600:.1f}시간(단일 코어) -> 전처리 병렬화·비동기 대기열로 단축 필요\")\n", + "\n", + "plt.figure(figsize=(8, 4))\n", + "plt.bar(range(len(df)), df[\"처리량(건/초)\"], color=[\"tab:red\" if m != \"-\" else \"tab:blue\" for m in df[\"성능저하 모드\"]])\n", + "plt.xticks(range(len(df)), [f\"case{i + 1}\" for i in range(len(df))]); plt.ylabel(\"records / s\")\n", + "plt.title(\"Batch stress test throughput (red = degraded mode)\"); plt.show()\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 08 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + " 상황 수신 처리 격리 유실 다운 처리량(건/초) 성능저하 모드\n", + " 평시 40건(기준) 40 40 0 0 0 23161 -\n", + " 500% 증가 (240건) 240 240 0 0 0 21576 -\n", + "240건 + 손상·잡음 로그 240 229 11 0 0 22027 -\n", + "240건 + 처리 예산 부족 240 240 0 0 0 36834 ML 지연처리\n", + "\n", + "시스템 다운 0건, 데이터 유실률 0.0% (목표 0%), 격리 로그는 원본 보관 후 사유와 함께 재검토\n", + "메모리(동일 배치 5회 반복, MB): [0.1, 0.2, 0.2, 0.3, 0.4] -> 증가 폭 0.3 MB (누수 없음 판단 기준 5MB 이내)\n", + "병목 구간(누적 처리시간 비율): {'전처리': '58%', '특징': '26%', '규칙': '12%', 'ML': '4%'}\n", + "환산: 1만 비행시간(3,600만 건) 처리 예상 0.5시간(단일 코어) -> 전처리 병렬화·비동기 대기열로 단축 필요\n", + "```\n", + "
\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 09. AAR 기반 비행 이상 오탐·미탐 사례 분석 및 이상 탐지 임계치 최적화\n", + "**소요시간**: 약 3.0시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "AAR 사후검토를 통해 비행로그 이상치 오탐 사례를 분석하고 정상 비행과 적대 비행의 경계 파라미터를 재보정한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **AAR 사후검토로 비행 이상 오탐·미탐 사례의 원인을 분석하고 정상 비행과 의심 비행의 경계 파라미터를 재보정한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **'놓치지 않기'를 우선한 초기 설정으로 모의 운용한 뒤 오탐은 원인(측량 U턴, 강풍 돌풍 하강, 드문 정상 패턴)별로, 미탐은 시나리오별로 분류한 AAR 일지 작성하여 원인을 분석하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `docs/isr03_aar_report.md 및 configs/isr03_tuned_params.yaml` 형태의 결과물을 완성한다.\n", + "\n", + "### 🔍 평가 및 검증 기준 (PASS 조건)\n", + "#### 미션 판정\n", + "\n", + "**평가 구분:** 필수\n", + "\n", + "**핵심 평가영역:** 오류 원인 규명의 객관성 및 개선 조치의 논리적 타당성\n", + "\n", + "#### 미션 PASS 조건\n", + "\n", + "- 정량 기준: AAR 피드백 적용 후 종합 정확도 10%p 이상 향상, 재발 오류 0건\n", + "\n", + "\n", + "- 진행 기준: AAR 보고서 작성 및 파라미터 튜닝 전후 회귀 테스트 통과\n", + "\n", + "\n", + "- 검증 방법: 회귀 테스트(Regression Test) 스위트 전체 자동 실행 및 비교\n", + "\n", + "\n", + "#### 채점자 판정 메모\n", + "\n", + "> 단순 코드 실행 여부뿐만 아니라 출력된 수치와 근거 자료가 군 실무 요구사항에 부합하는지 면밀히 확인한다.\n", + "\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "\"\"\"AAR(사후검토) 일지 형식으로 모의 운용 중 발생한 비행 이상 오탐·미탐 사례를 원인별로 분류하고, 급선회·급강하 규칙 임계값과 ML 이상 점수 임계값을 격자 탐색으로 재보정해 정상 비행과 적대 의심 비행의 경계를 조정한 뒤 회귀 시험으로 개선 효과와 재발 여부를 확인한다.\"\"\"\n", + "import math\n", + "import numpy as np\n", + "import pandas as pd\n", + "import matplotlib.pyplot as plt\n", + "from sklearn.cluster import KMeans\n", + "from sklearn.ensemble import IsolationForest\n", + "from sklearn.preprocessing import StandardScaler\n", + "\n", + "# ---------------------------------------------------------------------------\n", + "# [교육용가상자료] 합성 드론 비행로그 생성기 (실제 좌표·기체·부대 정보 없음)\n", + "# 가상 구역(원점 기준 동·북 방향 m 좌표)에서 1초 간격 비행로그와 정답(이상 구간·운용 주체)을 만든다.\n", + "# ---------------------------------------------------------------------------\n", + "NFZ = {\"x\": 2500.0, \"y\": 2500.0, \"r\": 700.0} # 가상 비인가 비행구역\n", + "WATCH = {\"x\": -2000.0, \"y\": 1500.0, \"r\": 600.0} # 가상 관심 구역\n", + "SCENARIOS = [\"sharp_turn\", \"hover\", \"dive\", \"deviation\", \"nfz_entry\",\n", + " \"low_level\", \"rc_silent\", \"infiltration\", \"loiter\", \"speed_burst\"]\n", + "OPERATORS = [\"friendly\", \"civil\", \"hostile\"]\n", + "# 시나리오별 운용 주체 분포: 일부러 크게 겹치게 설계했다(비행 특성만으로 운용 주체를 단정할 수 없다).\n", + "OPERATOR_P = {\"normal\": (.45, .47, .08), \"survey\": (.15, .82, .03), \"sharp_turn\": (.40, .35, .25),\n", + " \"hover\": (.25, .45, .30), \"dive\": (.25, .25, .50), \"deviation\": (.30, .40, .30),\n", + " \"nfz_entry\": (.10, .50, .40), \"low_level\": (.35, .15, .50), \"rc_silent\": (.30, .15, .55),\n", + " \"infiltration\": (.12, .08, .80), \"loiter\": (.30, .20, .50), \"speed_burst\": (.30, .20, .50)}\n", + "PLATFORM_P = {\"friendly\": (.40, .55, .05), \"civil\": (.88, .02, .10), \"hostile\": (.55, .30, .15)} # commercial/military/unknown\n", + "FIRMWARE_P = {\"friendly\": (.30, .65, .05), \"civil\": (.85, .10, .05), \"hostile\": (.20, .50, .30)} # stock/custom/unknown\n", + "\n", + "\n", + "def _wrap(a):\n", + " # [TODO : 학생 실습 9] AAR 기반 비행 이상 오탐·미탐 사례 분석 및 이상 탐지 임계치 최적화 관련 핵심 로직을 구현하세요.\n", + " # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n", + " # <<< 여기에 코드를 작성하세요 >>>\n", + " pass # 작성 후 pass를 제거하세요.\n", + "def _bearing(p, q):\n", + " # [TODO : 학생 실습 9] AAR 기반 비행 이상 오탐·미탐 사례 분석 및 이상 탐지 임계치 최적화 관련 핵심 로직을 구현하세요.\n", + " # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n", + " # <<< 여기에 코드를 작성하세요 >>>\n", + " pass # 작성 후 pass를 제거하세요.\n", + "def _away_from_nfz(rng, min_d):\n", + " while True:\n", + " c = rng.uniform(-3800, 3800, 2)\n", + " if np.hypot(c[0] - NFZ[\"x\"], c[1] - NFZ[\"y\"]) > min_d:\n", + " return c\n", + "\n", + "\n", + "def make_flight(fid, kind, rng, n=300, weather=None):\n", + " \"\"\"비행 1회 = (로그 DataFrame, 메타 dict). kind: normal / survey / SCENARIOS 중 하나\"\"\"\n", + " weather = weather or str(rng.choice([\"clear\", \"night\", \"windy\", \"rain\"], p=[.4, .25, .2, .15]))\n", + " wind = {\"clear\": 1.0, \"night\": 1.0, \"windy\": 2.5, \"rain\": 1.8}[weather]\n", + " v0, alt0 = rng.uniform(11, 17), rng.uniform(70, 150)\n", + " nfz_c = np.array([NFZ[\"x\"], NFZ[\"y\"]])\n", + " if kind == \"infiltration\": # 원거리에서 비인가 구역 방향으로 저고도 직진\n", + " ang = rng.uniform(0, 2 * np.pi)\n", + " start = nfz_c + 4000 * np.array([np.cos(ang), np.sin(ang)])\n", + " plan = np.array([start, nfz_c])\n", + " v0, alt0 = rng.uniform(9, 11), rng.uniform(25, 35)\n", + " else:\n", + " if kind == \"nfz_entry\": # 비인가 구역 1.6km 밖에서 순찰하다가 진입\n", + " ang = rng.uniform(0, 2 * np.pi)\n", + " c = nfz_c + 1600 * np.array([np.cos(ang), np.sin(ang)])\n", + " else:\n", + " c = _away_from_nfz(rng, 2600)\n", + " if kind == \"survey\": # 민간 측량형 왕복(지그재그) 경로\n", + " w, h = rng.uniform(500, 700), 120.0\n", + " plan = np.array([c + [(-w if i % 4 in (0, 3) else w), i // 2 * h - 360] for i in range(8)])\n", + " else: # 사각 순찰 경로(웨이포인트 4개)\n", + " a, b, th = rng.uniform(300, 600), rng.uniform(200, 400), rng.uniform(0, np.pi)\n", + " R = np.array([[np.cos(th), -np.sin(th)], [np.sin(th), np.cos(th)]])\n", + " plan = c + np.array([[-a, -b], [a, -b], [a, b], [-a, b]]) @ R.T\n", + " s0 = int(rng.integers(80, 160))\n", + " span = {\"sharp_turn\": 30, \"hover\": 60, \"dive\": 8, \"deviation\": 70, \"nfz_entry\": 120, \"low_level\": 120,\n", + " \"rc_silent\": 120, \"infiltration\": n - 20, \"loiter\": 90, \"speed_burst\": 25}.get(kind, 0)\n", + " if kind == \"infiltration\":\n", + " s0 = 20\n", + " win = (s0, s0 + span)\n", + " turns = set(rng.choice(np.arange(s0, s0 + 30), 3, replace=False).tolist()) if kind == \"sharp_turn\" else set()\n", + " # 난수를 미리 한 번에 뽑아 두고(속도 향상) 1초 단위로 기체 운동을 적분한다.\n", + " e_h, e_v, e_a = rng.normal(0, 0.8 * wind, n), rng.normal(0, 0.3 * wind, n), rng.normal(0, 0.4 * wind, n)\n", + " rc_arr = np.clip(rng.normal(rng.uniform(60, 92), 6, n), 0, 100)\n", + " gust = (rng.random(n) < (0.004 if weather == \"windy\" else 0.0)) * rng.uniform(20, 35, n)\n", + " burst, turn_amt = rng.uniform(23, 28), rng.choice([-1, 1], n) * rng.uniform(100, 160, n)\n", + " side = float(rng.choice([-90, 90]))\n", + " low_alt, rng_rc = rng.uniform(35, 55), rng.uniform(0, 20, n) # 저고도 목표, 조종신호 두절 시 잔여 수신값\n", + " x, y, wi = float(plan[0][0]), float(plan[0][1]), 1\n", + " hdg, alt = _bearing((x, y), plan[1]), alt0\n", + " max_rate = 40.0 if kind == \"survey\" else 12.0\n", + " dev_target, X, Y, A, V, HD = None, np.zeros(n), np.zeros(n), np.zeros(n), np.zeros(n), np.zeros(n)\n", + " for t in range(n):\n", + " inw = win[0] <= t < win[1]\n", + " tx, ty = plan[1] if kind == \"infiltration\" else plan[wi % len(plan)]\n", + " spd_cmd, alt_cmd = v0, alt0\n", + " if inw:\n", + " if kind == \"deviation\":\n", + " if dev_target is None:\n", + " r = math.radians(hdg + side)\n", + " dev_target = (x + 900 * math.sin(r), y + 900 * math.cos(r))\n", + " tx, ty = dev_target\n", + " elif kind == \"nfz_entry\":\n", + " tx, ty = NFZ[\"x\"], NFZ[\"y\"]\n", + " elif kind == \"hover\":\n", + " spd_cmd = 0.0\n", + " elif kind == \"speed_burst\":\n", + " spd_cmd = burst\n", + " elif kind == \"low_level\":\n", + " alt_cmd = low_alt\n", + " if kind in (\"rc_silent\", \"infiltration\"):\n", + " rc_arr[t] = rng_rc[t]\n", + " if inw and kind == \"loiter\":\n", + " hdg = (hdg + 9.0 + e_h[t] / wind) % 360\n", + " else:\n", + " d = _wrap(math.degrees(math.atan2(tx - x, ty - y)) - hdg)\n", + " hdg = (hdg + max(-max_rate, min(max_rate, d)) + e_h[t]) % 360\n", + " if t in turns:\n", + " hdg = (hdg + turn_amt[t]) % 360\n", + " v = abs(spd_cmd + e_v[t]) if spd_cmd > 0 else abs(e_v[t] / wind)\n", + " x += v * math.sin(math.radians(hdg)); y += v * math.cos(math.radians(hdg))\n", + " if kind != \"infiltration\" and math.hypot(x - tx, y - ty) < 40 and not (inw and kind in (\"deviation\", \"nfz_entry\")):\n", + " wi += 1\n", + " if inw and kind == \"dive\":\n", + " alt -= 9.0 + e_v[t]\n", + " else:\n", + " alt += max(-3.0, min(3.0, alt_cmd - alt)) + e_a[t]\n", + " alt -= gust[t] # 돌풍 하강기류(정상 비행에서도 발생)\n", + " if kind == \"dive\" and t >= win[1]:\n", + " alt0 = min(alt0, alt)\n", + " alt = max(alt, 3.0)\n", + " X[t], Y[t], A[t], V[t], HD[t] = x, y, alt, v, hdg\n", + " gps_sd = {\"clear\": 2.0, \"night\": 2.0, \"windy\": 3.0, \"rain\": 4.5}[weather] # 악천후일수록 GPS 잡음(다중경로) 증가\n", + " df = pd.DataFrame({\"t\": np.arange(n), \"x\": X + rng.normal(0, gps_sd, n), \"y\": Y + rng.normal(0, gps_sd, n),\n", + " \"alt\": A + rng.normal(0, 0.5, n), \"speed\": V + rng.normal(0, 0.2, n),\n", + " \"heading\": (HD + rng.normal(0, 1.5, n)) % 360, \"rc\": rc_arr.round(1),\n", + " \"sats\": rng.integers(10, 15, n), \"hdop\": rng.uniform(0.6, 1.2, n).round(2),\n", + " \"x_true\": X, \"y_true\": Y})\n", + " df[\"anomaly\"] = (df[\"t\"] >= win[0]) & (df[\"t\"] < win[1]) & (kind in SCENARIOS)\n", + " op = OPERATORS[int(rng.choice(3, p=OPERATOR_P[kind]))]\n", + " meta = {\"id\": fid, \"kind\": kind, \"weather\": weather, \"operator\": op, \"plan\": plan, \"window\": win if kind in SCENARIOS else None,\n", + " \"platform\": [\"commercial\", \"military\", \"unknown\"][int(rng.choice(3, p=PLATFORM_P[op]))],\n", + " \"firmware\": [\"stock\", \"custom\", \"unknown\"][int(rng.choice(3, p=FIRMWARE_P[op]))]}\n", + " return df, meta\n", + "\n", + "\n", + "def corrupt(df, rng, spoof=True, emi=True, drop=True, dup=True):\n", + " \"\"\"전장 잡음 주입: GPS 스푸핑(위치 점프·표류), 전자기 간섭(고도 스파이크), 센서 단절(결측), 중복 기록.\"\"\"\n", + " d = df.copy()\n", + " d[\"spoof_true\"] = False\n", + " if spoof:\n", + " a = int(rng.integers(40, len(d) - 60)); L = int(rng.integers(25, 45))\n", + " th = rng.uniform(0, 2 * np.pi)\n", + " drift = rng.uniform(22, 28) * np.arange(L)[:, None] * np.array([np.cos(th), np.sin(th)])\n", + " jump = rng.uniform(250, 400) * np.array([np.cos(th + 1), np.sin(th + 1)])\n", + " d.loc[a:a + L - 1, [\"x\", \"y\"]] += drift + jump\n", + " d.loc[a:a + L - 1, \"spoof_true\"] = True\n", + " if emi:\n", + " idx = rng.choice(len(d), 6, replace=False)\n", + " d.loc[idx, \"alt\"] += rng.choice([-1, 1], 6) * rng.uniform(60, 150, 6)\n", + " if drop:\n", + " a = int(rng.integers(10, len(d) - 20))\n", + " d.loc[a:a + int(rng.integers(4, 9)), [\"x\", \"y\", \"alt\", \"speed\", \"heading\"]] = np.nan\n", + " d = d.drop(index=rng.choice(len(d), 5, replace=False))\n", + " if dup:\n", + " d = pd.concat([d, d.sample(5, random_state=int(rng.integers(1e6)))]).sort_values(\"t\", kind=\"stable\")\n", + " return d.reset_index(drop=True)\n", + "\n", + "\n", + "def make_dataset(n=200, seed=7, anomaly_ratio=0.5, scenarios=SCENARIOS):\n", + " rng = np.random.default_rng(seed)\n", + " out = []\n", + " for i in range(n):\n", + " if i % 10 < anomaly_ratio * 10:\n", + " kind = scenarios[(i // 10 * int(anomaly_ratio * 10) + i % 10) % len(scenarios)]\n", + " else:\n", + " kind = \"survey\" if i % 10 == 9 else \"normal\"\n", + " df, meta = make_flight(f\"FL{i:04d}\", kind, rng)\n", + " out.append((df, meta))\n", + " return out\n", + "# ---------------------------------------------------------------------------\n", + "\n", + "# ---- 전처리·규칙 탐지 공통 함수 ----\n", + "SPOOF_THR = {\"clear\": 12.0, \"night\": 12.0, \"windy\": 15.0, \"rain\": 19.0} # 기상별 GPS-추측항법 불일치 허용치(m)\n", + "\n", + "\n", + "def clean(df, weather=\"clear\"):\n", + " \"\"\"전처리: 중복 제거·정렬 -> 1초 재표본화 -> 결측 보간 -> 고도 스파이크 제거 -> GPS 스푸핑 구간 추측항법 보정.\"\"\"\n", + " d = df.drop_duplicates(\"t\").sort_values(\"t\").set_index(\"t\")\n", + " d = d.reindex(np.arange(int(d.index.min()), int(d.index.max()) + 1))\n", + " n_missing = int(d[\"x\"].isna().sum())\n", + " num = [\"x\", \"y\", \"alt\", \"speed\", \"rc\", \"sats\", \"hdop\"]\n", + " d[num] = d[num].interpolate(limit_direction=\"both\")\n", + " hd = d[\"heading\"].dropna() # 방위는 0/360 경계를 풀어서 보간\n", + " un = pd.Series(np.degrees(np.unwrap(np.radians(hd.to_numpy(float)))), index=hd.index)\n", + " d[\"heading\"] = un.reindex(d.index).interpolate(limit_direction=\"both\").to_numpy() % 360\n", + " med = d[\"alt\"].rolling(5, center=True, min_periods=1).median()\n", + " spike = (d[\"alt\"] - med).abs() > 25\n", + " d.loc[spike, \"alt\"] = med[spike]\n", + " x, y = d[\"x\"].to_numpy(float), d[\"y\"].to_numpy(float)\n", + " r = np.radians(d[\"heading\"].to_numpy(float)); v = d[\"speed\"].to_numpy(float)\n", + " dr = np.c_[v * np.sin(r), v * np.cos(r)] # 보고 속도·방위로 계산한 1초 이동량(추측항법)\n", + " xc, yc, flag = x.copy(), y.copy(), np.zeros(len(x), bool)\n", + " thr = SPOOF_THR.get(weather, 12.0)\n", + " ok_run = 0\n", + " for i in range(1, len(x)):\n", + " px, py = xc[i - 1] + dr[i, 0], yc[i - 1] + dr[i, 1] # 추측항법 예상 위치\n", + " step_ok = np.hypot(x[i] - x[i - 1] - dr[i, 0], y[i] - y[i - 1] - dr[i, 1]) <= thr\n", + " ok_run = ok_run + 1 if step_ok else 0\n", + " if flag[i - 1]:\n", + " # 스푸핑 구간: GPS가 예상 위치로 돌아오거나, GPS 이동량이 5초 연속 물리적으로 맞으면 GPS로 복귀\n", + " if np.hypot(x[i] - px, y[i] - py) < thr * 2 or ok_run >= 5:\n", + " xc[i], yc[i] = x[i], y[i]\n", + " else:\n", + " xc[i], yc[i], flag[i] = px, py, True\n", + " elif not step_ok:\n", + " xc[i], yc[i], flag[i] = px, py, True # GPS 이동량이 물리적으로 맞지 않음 -> 스푸핑 의심\n", + " else:\n", + " xc[i], yc[i] = xc[i - 1] + x[i] - x[i - 1], yc[i - 1] + y[i] - y[i - 1]\n", + " d[\"x\"], d[\"y\"], d[\"spoof_flag\"] = xc, yc, flag\n", + " d = d.reset_index()\n", + " d.attrs.update({\"missing\": n_missing, \"spikes\": int(spike.sum()), \"spoof_pts\": int(flag.sum())})\n", + " return d\n", + "\n", + "\n", + "def xtrack(px, py, plan):\n", + " \"\"\"계획 경로(웨이포인트 폐곡선) 대비 이탈 거리(m).\"\"\"\n", + " P = np.asarray(plan, float)\n", + " segs = [(P[i], P[(i + 1) % len(P)]) for i in range(len(P) if len(P) > 2 else 1)]\n", + " best = np.full(len(px), np.inf)\n", + " for a, b in segs:\n", + " ab = b - a\n", + " u = np.clip(((px - a[0]) * ab[0] + (py - a[1]) * ab[1]) / max(ab @ ab, 1e-9), 0, 1)\n", + " best = np.minimum(best, np.hypot(px - a[0] - u * ab[0], py - a[1] - u * ab[1]))\n", + " return best\n", + "\n", + "\n", + "RULES = {\"turn_rate\": 45.0, \"hover_speed\": 1.5, \"hover_sec\": 20, \"dive_m\": 30.0, \"xtrack_m\": 250.0}\n", + "\n", + "\n", + "def rule_flags(d, plan, R=RULES):\n", + " \"\"\"규칙 기반 이상 비행 판정(기록 단위). 급선회·호버링·급강하·경로 이탈·비인가 구역 진입.\"\"\"\n", + " hr = np.abs(_wrap(np.diff(d[\"heading\"].to_numpy(float), prepend=d[\"heading\"].iloc[0])))\n", + " slow = (d[\"speed\"] < R[\"hover_speed\"]).to_numpy()\n", + " run = np.zeros(len(slow), int)\n", + " for i in range(len(slow)):\n", + " run[i] = run[i - 1] + 1 if slow[i] and i else int(slow[i])\n", + " hover = np.zeros(len(slow), bool)\n", + " for i in np.where(run >= R[\"hover_sec\"])[0]:\n", + " hover[i - R[\"hover_sec\"] + 1:i + 1] = True\n", + " alt = d[\"alt\"].to_numpy(float)\n", + " drop = alt - np.r_[alt[5:], np.full(5, alt[-1])]\n", + " return {\"급선회\": hr > R[\"turn_rate\"], \"호버링\": hover, \"급강하\": drop > R[\"dive_m\"],\n", + " \"경로이탈\": xtrack(d[\"x\"].to_numpy(float), d[\"y\"].to_numpy(float), plan) > R[\"xtrack_m\"],\n", + " \"비인가구역\": np.hypot(d[\"x\"] - NFZ[\"x\"], d[\"y\"] - NFZ[\"y\"]).to_numpy() < NFZ[\"r\"]}\n", + "\n", + "# ---- 구간(30초 창) 특징 + 비지도 이상 탐지 공통 함수 ----\n", + "WFEAT = [\"alt_mean\", \"alt_min\", \"alt_std\", \"vz_min\", \"spd_mean\", \"spd_std\", \"spd_max\", \"turn_sum\", \"turn_max\",\n", + " \"straight\", \"rc_mean\", \"rc_zero\", \"nfz_min\", \"nfz_close\", \"xtrack_max\"]\n", + "\n", + "\n", + "def window_feats(d, plan, win=30, step=10):\n", + " \"\"\"30초 창을 10초씩 옮기며 고도·속도·선회·직진성·조종신호·비인가 구역 접근·경로 이탈 특징을 계산한다.\"\"\"\n", + " x, y, alt = d[\"x\"].to_numpy(float), d[\"y\"].to_numpy(float), d[\"alt\"].to_numpy(float)\n", + " spd, rc = d[\"speed\"].to_numpy(float), d[\"rc\"].to_numpy(float)\n", + " hr = np.abs(_wrap(np.diff(d[\"heading\"].to_numpy(float), prepend=d[\"heading\"].iloc[0])))\n", + " vz = np.diff(alt, prepend=alt[0])\n", + " nd = np.hypot(x - NFZ[\"x\"], y - NFZ[\"y\"])\n", + " xt = xtrack(x, y, plan)\n", + " step_len = np.hypot(np.diff(x, prepend=x[0]), np.diff(y, prepend=y[0]))\n", + " rows, starts = [], []\n", + " for s in range(0, len(d) - win + 1, step):\n", + " e = s + win\n", + " path = step_len[s + 1:e].sum()\n", + " rows.append([alt[s:e].mean(), alt[s:e].min(), alt[s:e].std(), vz[s:e].min(), spd[s:e].mean(), spd[s:e].std(),\n", + " spd[s:e].max(), hr[s:e].sum(), hr[s:e].max(), np.hypot(x[e - 1] - x[s], y[e - 1] - y[s]) / max(path, 1.0),\n", + " rc[s:e].mean(), (rc[s:e] < 5).mean(), nd[s:e].min(), (nd[s] - nd[e - 1]) / win, xt[s:e].max()])\n", + " starts.append(int(d[\"t\"].iloc[s]))\n", + " return np.array(rows), starts\n", + "\n", + "\n", + "class FlightAnomalyModel:\n", + " \"\"\"평시 비행로그(라벨 없음)로 통상 패턴 군집(KMeans)과 고립 숲(IsolationForest)을 학습하고,\n", + " 두 점수를 학습 구간 분위수로 나눠 1.0을 넘으면 '통상 패턴에서 벗어남'으로 본다.\"\"\"\n", + "\n", + " def __init__(self, q=0.999, k=4):\n", + " self.q, self.k = q, k\n", + "\n", + " def fit(self, X):\n", + " self.sc = StandardScaler().fit(X)\n", + " Z = self.sc.transform(X)\n", + " self.iso = IsolationForest(n_estimators=150, random_state=0).fit(Z)\n", + " self.km = KMeans(self.k, n_init=5, random_state=0).fit(Z)\n", + " self.set_q(self.q, X)\n", + " return self\n", + "\n", + " def _raw(self, X):\n", + " Z = self.sc.transform(X)\n", + " return -self.iso.score_samples(Z), self.km.transform(Z).min(axis=1)\n", + "\n", + " def set_q(self, q, X):\n", + " a, b = self._raw(X)\n", + " self.q, self.t_if, self.t_km = q, np.quantile(a, q), np.quantile(b, q)\n", + "\n", + " def score(self, X):\n", + " a, b = self._raw(X)\n", + " return np.maximum(a / self.t_if, b / self.t_km)\n", + "\n", + "\n", + "AFEAT = [\"ml_score\", \"low_alt\", \"rc_weak\", \"nfz_min_km\", \"hover_s\", \"max_speed\", \"night\",\n", + " \"pf_commercial\", \"pf_military\", \"pf_unknown\", \"fw_stock\", \"fw_custom\", \"fw_unknown\"]\n", + "\n", + "\n", + "def attr_features(d, m, ml_score):\n", + " \"\"\"운용 주체 추정용 비행 단위 특징. 기체·펌웨어 정보는 상용 기체가 모든 주체에 쓰이므로 약한 단서일 뿐이다.\"\"\"\n", + " return [min(ml_score, 10.0), float((d[\"alt\"] < 60).mean()), float((d[\"rc\"] < 25).mean()),\n", + " float(np.hypot(d[\"x\"] - NFZ[\"x\"], d[\"y\"] - NFZ[\"y\"]).min() / 1000), float((d[\"speed\"] < 1.5).sum()),\n", + " float(d[\"speed\"].max()), float(m[\"weather\"] == \"night\"),\n", + " *[float(m[\"platform\"] == p) for p in (\"commercial\", \"military\", \"unknown\")],\n", + " *[float(m[\"firmware\"] == f) for f in (\"stock\", \"custom\", \"unknown\")]]\n", + "\n", + "\n", + "def flight_stats(data, model):\n", + " \"\"\"비행마다 판단에 필요한 값(최대 선회율, 5초 최대 하강, 기타 규칙, ML 최대 점수)을 한 번 계산해 둔다.\"\"\"\n", + " rows = []\n", + " for df, m in data:\n", + " d = clean(df, m[\"weather\"])\n", + " f = rule_flags(d, m[\"plan\"], {**RULES, \"turn_rate\": 1e9, \"dive_m\": 1e9})\n", + " alt = d[\"alt\"].to_numpy()\n", + " rows.append({\"id\": m[\"id\"], \"kind\": m[\"kind\"], \"weather\": m[\"weather\"], \"truth\": m[\"kind\"] in SCENARIOS,\n", + " \"turn\": float(np.abs(_wrap(np.diff(d[\"heading\"].to_numpy()))).max()),\n", + " \"drop\": float((alt[:-5] - alt[5:]).max()),\n", + " \"other\": bool(f[\"호버링\"].any() or f[\"경로이탈\"].any() or f[\"비인가구역\"].any()),\n", + " \"ml\": float(model.score(window_feats(d, m[\"plan\"])[0]).max())})\n", + " return pd.DataFrame(rows)\n", + "\n", + "\n", + "def predict(S, c):\n", + " S = S.copy()\n", + " S[\"r_turn\"], S[\"r_dive\"], S[\"r_ml\"] = S[\"turn\"] > c[\"turn\"], S[\"drop\"] > c[\"dive\"], S[\"ml\"] > c[\"ml\"]\n", + " S[\"pred\"] = S[\"r_turn\"] | S[\"r_dive\"] | S[\"r_ml\"] | S[\"other\"]\n", + " return S\n", + "\n", + "\n", + "def summarize(S):\n", + " tp, fp = (S.truth & S.pred).sum(), (~S.truth & S.pred).sum()\n", + " return {\"정확도\": round(float((S.truth == S.pred).mean()), 3), \"재현율\": round(float(tp / S.truth.sum()), 3),\n", + " \"오경보율\": round(float(fp / max(S.pred.sum(), 1)), 3), \"정상 오탐\": int(fp), \"미탐\": int((S.truth & ~S.pred).sum())}\n", + "\n", + "\n", + "def aar_log(S):\n", + " rows = []\n", + " for _, r in S[~S.truth & S.pred].iterrows():\n", + " cause = (\"측량 U턴 급선회\" if r.kind == \"survey\" else \"선회\") if r.r_turn else (\"돌풍 하강(\" + r.weather + \")\" if r.r_dive else \"드문 정상 패턴(ML)\")\n", + " rows.append({\"구분\": \"오탐\", \"원인\": cause, \"비행\": r.id})\n", + " for _, r in S[S.truth & ~S.pred].iterrows():\n", + " rows.append({\"구분\": \"미탐\", \"원인\": f\"{r.kind} 점수 미달\", \"비행\": r.id})\n", + " return pd.DataFrame(rows, columns=[\"구분\", \"원인\", \"비행\"])\n", + "\n", + "\n", + "def windows(data):\n", + " return np.vstack([window_feats(clean(df, m[\"weather\"]), m[\"plan\"])[0] for df, m in data])\n", + "\n", + "\n", + "base_X = windows(make_dataset(80, 21, anomaly_ratio=0.0))\n", + "model = FlightAnomalyModel(q=0.995).fit(base_X)\n", + "aar_data, hold_data = make_dataset(200, seed=11), make_dataset(200, seed=13)\n", + "aar = flight_stats(aar_data, model) # 모의 운용(AAR 대상)\n", + "hold = flight_stats(hold_data, model) # 회귀 시험용(재보정에 쓰지 않음)\n", + "\n", + "# 1) 초기 설정('놓치지 않기' 우선): 선회 30도/초, 하강 30m, ML 점수 1.0\n", + "init = {\"turn\": 30, \"dive\": 30, \"ml\": 1.0}\n", + "S0 = predict(aar, init)\n", + "before = summarize(S0)\n", + "log = aar_log(S0)\n", + "print(\"[1. 무엇이 일어났나] 초기 설정:\", before)\n", + "print(log.groupby([\"구분\", \"원인\"]).size().rename(\"건수\").to_string())\n", + "\n", + "# 2) 역환류 1: 오탐 원인(측량 U턴·강풍)이 평시 학습 자료에 적었음 -> 측량·강풍 평시 로그 60건을 추가 수집해 재학습\n", + "extra = [(df, m) for df, m in make_dataset(200, 22, anomaly_ratio=0.0) if m[\"kind\"] == \"survey\" or m[\"weather\"] == \"windy\"][:60]\n", + "model = FlightAnomalyModel(q=0.995).fit(np.vstack([base_X, windows(extra)]))\n", + "aar, hold = flight_stats(aar_data, model), flight_stats(hold_data, model)\n", + "print(f\"\\n[역환류] 평시 학습 로그 80건 + 측량·강풍 로그 {len(extra)}건으로 ML 재학습\")\n", + "\n", + "# 3) 역환류 2: 재현율 95% 이상 제약 안에서 정확도 최대 임계값 조합 탐색\n", + "grid = []\n", + "for t in [30, 45, 60, 90]:\n", + " for dv in [30, 40, 50]:\n", + " for mlt in [1.0, 1.2, 1.5, 2.0]:\n", + " grid.append({\"turn\": t, \"dive\": dv, \"ml\": mlt, **summarize(predict(aar, {\"turn\": t, \"dive\": dv, \"ml\": mlt}))})\n", + "g = pd.DataFrame(grid)\n", + "ok = g[g[\"재현율\"] >= 0.95].sort_values([\"정확도\", \"ml\"], ascending=[False, True])\n", + "best = ok.iloc[0][[\"turn\", \"dive\", \"ml\"]].to_dict()\n", + "print(\"\\n[2. 무엇을 바꾸나] 격자 탐색 상위 5개\")\n", + "print(ok.head().to_string(index=False))\n", + "\n", + "# 3) 재보정 전후 비교(모의 운용 자료 + 회귀 시험 자료)\n", + "S1 = predict(aar, best)\n", + "after = summarize(S1)\n", + "print(f\"\\n재보정값: 선회율 {best['turn']:.0f}도/초, 급강하 {best['dive']:.0f}m, ML 점수 {best['ml']:.1f}\")\n", + "print(\"재보정 후:\", after)\n", + "print(f\"종합 정확도 {before['정확도']:.3f} -> {after['정확도']:.3f} ({(after['정확도'] - before['정확도']) * 100:+.1f}%p, 목표 +10%p: {'달성' if after['정확도'] - before['정확도'] >= 0.1 else '미달'})\")\n", + "h0, h1 = summarize(predict(hold, init)), summarize(predict(hold, best))\n", + "print(f\"회귀 시험(별도 200건): 정확도 {h0['정확도']:.3f} -> {h1['정확도']:.3f}, 재현율 {h0['재현율']:.3f} -> {h1['재현율']:.3f}\")\n", + "print(\"재보정 후 미탐:\", S1[S1.truth & ~S1.pred][\"kind\"].tolist(), \"/ 오탐:\", S1[~S1.truth & S1.pred][\"kind\"].tolist())\n", + "repeat = set(log[\"비행\"]) & set(aar_log(S1)[\"비행\"])\n", + "print(f\"재보정 후에도 반복된 오류: {len(repeat)}건 {sorted(repeat)[:6]}\")\n", + "print(\"[3. 남은 과제] 반복 오류는 규칙 임계값만으로 풀리지 않는 경우로, 해당 로그를 평시 학습 자료에 추가(지속 학습)해 재학습 대상으로 등록한다.\")\n", + "\n", + "tuned = f\"rules:\\n turn_rate_dps: {best['turn']:.0f}\\n dive_drop_m: {best['dive']:.0f}\\nml:\\n quantile: 0.995\\n score_threshold: {best['ml']:.1f}\\nconstraints: {{min_recall: 0.95}}\"\n", + "print(\"\\n[재보정 파라미터(isr03_tuned_params.yaml 내용)]\\n\" + tuned)\n", + "\n", + "plt.figure(figsize=(6, 4))\n", + "for t in sorted(g[\"turn\"].unique()):\n", + " s = g[(g[\"turn\"] == t) & (g[\"dive\"] == best[\"dive\"])]\n", + " plt.plot(s[\"ml\"], s[\"정확도\"], \"o-\", label=f\"turn>{t} deg/s\")\n", + "plt.xlabel(\"ML score threshold\"); plt.ylabel(\"accuracy\"); plt.legend(); plt.title(\"AAR re-tuning grid (synthetic)\")\n", + "plt.show()\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 09 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + "[1. 무엇이 일어났나] 초기 설정: {'정확도': 0.865, '재현율': 1.0, '오경보율': 0.213, '정상 오탐': 27, '미탐': 0}\n", + "구분 원인 \n", + "오탐 돌풍 하강(windy) 7\n", + " 측량 U턴 급선회 20\n", + "\n", + "[역환류] 평시 학습 로그 80건 + 측량·강풍 로그 55건으로 ML 재학습\n", + "\n", + "[2. 무엇을 바꾸나] 격자 탐색 상위 5개\n", + " turn dive ml 정확도 재현율 오경보율 정상 오탐 미탐\n", + " 60 40 1.2 0.980 0.98 0.020 2 2\n", + " 60 50 1.2 0.980 0.98 0.020 2 2\n", + " 90 40 1.2 0.980 0.98 0.020 2 2\n", + " 90 50 1.2 0.980 0.98 0.020 2 2\n", + " 45 40 1.2 0.965 0.98 0.049 5 2\n", + "\n", + "재보정값: 선회율 60도/초, 급강하 40m, ML 점수 1.2\n", + "재보정 후: {'정확도': 0.98, '재현율': 0.98, '오경보율': 0.02, '정상 오탐': 2, '미탐': 2}\n", + "종합 정확도 0.865 -> 0.980 (+11.5%p, 목표 +10%p: 달성)\n", + "회귀 시험(별도 200건): 정확도 0.850 -> 0.980, 재현율 1.000 -> 0.960\n", + "재보정 후 미탐: ['low_level', 'low_level'] / 오탐: ['survey', 'survey']\n", + "재보정 후에도 반복된 오류: 2건 ['FL0019', 'FL0039']\n", + "[3. 남은 과제] 반복 오류는 규칙 임계값만으로 풀리지 않는 경우로, 해당 로그를 평시 학습 자료에 추가(지속 학습)해 재학습 대상으로 등록한다.\n", + "\n", + "[재보정 파라미터(isr03_tuned_params.yaml 내용)]\n", + "rules:\n", + " turn_rate_dps: 60\n", + " dive_drop_m: 40\n", + "ml:\n", + " quantile: 0.995\n", + " score_threshold: 1.2\n", + "constraints: {min_recall: 0.95}\n", + "```\n", + "
\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 10. 드론 비행로그 이상 탐지 시스템 통합 검증 및 ISR 정보판단 브리핑 패키지 완성\n", + "**소요시간**: 약 3.5시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "드론 비행로그 이상 탐지 시스템을 최종 통합 빌드하고 정보작전 브리핑용 최종 분석 보고서 및 사용자 가이드를 완성한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **로그 검증부터 전처리, 규칙·ML 탐지, 신뢰도 엔진, 검토 대기열, 대시보드까지 전체 시스템을 통합하고 ISR 정보판단 브리핑 패키지를 완성한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **재보정 파라미터로 전체 과정을 한 번에 실행하는 통합 코드를 완성하고 손상 로그가 자동 차단되는지 확인하고 유효성을 점검하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `src/main_isr03.py, README.md, docs/isr03_final_report.pdf(md)` 형태의 결과물을 완성한다.\n", + "\n", + "### 🔍 평가 및 검증 기준 (PASS 조건)\n", + "#### 미션 판정\n", + "\n", + "**평가 구분:** 필수\n", + "\n", + "**핵심 평가영역:** 군 실무 즉시 도입 가능성, 운용 신뢰도 및 전략적 기대효과\n", + "\n", + "#### 미션 PASS 조건\n", + "\n", + "- 정량 기준: End-to-End 통합 파이프라인 실행 성공률 100%, 테스트 커버리지 85% 이상\n", + "\n", + "\n", + "- 진행 기준: 원클릭 실행 스크립트 동작 확인 및 최종 발표 산출물 작성 완료\n", + "\n", + "\n", + "- 검증 방법: 최종 검증 위원회(평가관) 모의 시연 및 전체 기능 통합 검증\n", + "\n", + "\n", + "#### 채점자 판정 메모\n", + "\n", + "> 단순 코드 실행 여부뿐만 아니라 출력된 수치와 근거 자료가 군 실무 요구사항에 부합하는지 면밀히 확인한다.\n", + "\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "\"\"\"로그 검증·전처리·규칙 탐지·ML 이상 탐지·운용 주체 신뢰도 엔진·정보분석관 검토 대기열·대시보드까지 드론 비행로그 이상 탐지 시스템 전체를 한 파일로 통합 실행하고, 자체 점검(기능 커버리지 포함)과 ISR 정보판단 브리핑 요약을 출력한다.\"\"\"\n", + "import hashlib\n", + "import json\n", + "import math\n", + "import time\n", + "import numpy as np\n", + "import pandas as pd\n", + "import matplotlib.pyplot as plt\n", + "from sklearn.cluster import KMeans\n", + "from sklearn.ensemble import IsolationForest\n", + "from sklearn.linear_model import LogisticRegression\n", + "from sklearn.pipeline import make_pipeline\n", + "from sklearn.preprocessing import StandardScaler\n", + "\n", + "# ---------------------------------------------------------------------------\n", + "# [교육용가상자료] 합성 드론 비행로그 생성기 (실제 좌표·기체·부대 정보 없음)\n", + "# 가상 구역(원점 기준 동·북 방향 m 좌표)에서 1초 간격 비행로그와 정답(이상 구간·운용 주체)을 만든다.\n", + "# ---------------------------------------------------------------------------\n", + "NFZ = {\"x\": 2500.0, \"y\": 2500.0, \"r\": 700.0} # 가상 비인가 비행구역\n", + "WATCH = {\"x\": -2000.0, \"y\": 1500.0, \"r\": 600.0} # 가상 관심 구역\n", + "SCENARIOS = [\"sharp_turn\", \"hover\", \"dive\", \"deviation\", \"nfz_entry\",\n", + " \"low_level\", \"rc_silent\", \"infiltration\", \"loiter\", \"speed_burst\"]\n", + "OPERATORS = [\"friendly\", \"civil\", \"hostile\"]\n", + "# 시나리오별 운용 주체 분포: 일부러 크게 겹치게 설계했다(비행 특성만으로 운용 주체를 단정할 수 없다).\n", + "OPERATOR_P = {\"normal\": (.45, .47, .08), \"survey\": (.15, .82, .03), \"sharp_turn\": (.40, .35, .25),\n", + " \"hover\": (.25, .45, .30), \"dive\": (.25, .25, .50), \"deviation\": (.30, .40, .30),\n", + " \"nfz_entry\": (.10, .50, .40), \"low_level\": (.35, .15, .50), \"rc_silent\": (.30, .15, .55),\n", + " \"infiltration\": (.12, .08, .80), \"loiter\": (.30, .20, .50), \"speed_burst\": (.30, .20, .50)}\n", + "PLATFORM_P = {\"friendly\": (.40, .55, .05), \"civil\": (.88, .02, .10), \"hostile\": (.55, .30, .15)} # commercial/military/unknown\n", + "FIRMWARE_P = {\"friendly\": (.30, .65, .05), \"civil\": (.85, .10, .05), \"hostile\": (.20, .50, .30)} # stock/custom/unknown\n", + "\n", + "\n", + "def _wrap(a):\n", + " # [TODO : 학생 실습 10] 드론 비행로그 이상 탐지 시스템 통합 검증 및 ISR 정보판단 브리핑 패키지 완성 관련 핵심 로직을 구현하세요.\n", + " # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n", + " # <<< 여기에 코드를 작성하세요 >>>\n", + " pass # 작성 후 pass를 제거하세요.\n", + "def _bearing(p, q):\n", + " # [TODO : 학생 실습 10] 드론 비행로그 이상 탐지 시스템 통합 검증 및 ISR 정보판단 브리핑 패키지 완성 관련 핵심 로직을 구현하세요.\n", + " # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n", + " # <<< 여기에 코드를 작성하세요 >>>\n", + " pass # 작성 후 pass를 제거하세요.\n", + "def _away_from_nfz(rng, min_d):\n", + " while True:\n", + " c = rng.uniform(-3800, 3800, 2)\n", + " if np.hypot(c[0] - NFZ[\"x\"], c[1] - NFZ[\"y\"]) > min_d:\n", + " return c\n", + "\n", + "\n", + "def make_flight(fid, kind, rng, n=300, weather=None):\n", + " \"\"\"비행 1회 = (로그 DataFrame, 메타 dict). kind: normal / survey / SCENARIOS 중 하나\"\"\"\n", + " weather = weather or str(rng.choice([\"clear\", \"night\", \"windy\", \"rain\"], p=[.4, .25, .2, .15]))\n", + " wind = {\"clear\": 1.0, \"night\": 1.0, \"windy\": 2.5, \"rain\": 1.8}[weather]\n", + " v0, alt0 = rng.uniform(11, 17), rng.uniform(70, 150)\n", + " nfz_c = np.array([NFZ[\"x\"], NFZ[\"y\"]])\n", + " if kind == \"infiltration\": # 원거리에서 비인가 구역 방향으로 저고도 직진\n", + " ang = rng.uniform(0, 2 * np.pi)\n", + " start = nfz_c + 4000 * np.array([np.cos(ang), np.sin(ang)])\n", + " plan = np.array([start, nfz_c])\n", + " v0, alt0 = rng.uniform(9, 11), rng.uniform(25, 35)\n", + " else:\n", + " if kind == \"nfz_entry\": # 비인가 구역 1.6km 밖에서 순찰하다가 진입\n", + " ang = rng.uniform(0, 2 * np.pi)\n", + " c = nfz_c + 1600 * np.array([np.cos(ang), np.sin(ang)])\n", + " else:\n", + " c = _away_from_nfz(rng, 2600)\n", + " if kind == \"survey\": # 민간 측량형 왕복(지그재그) 경로\n", + " w, h = rng.uniform(500, 700), 120.0\n", + " plan = np.array([c + [(-w if i % 4 in (0, 3) else w), i // 2 * h - 360] for i in range(8)])\n", + " else: # 사각 순찰 경로(웨이포인트 4개)\n", + " a, b, th = rng.uniform(300, 600), rng.uniform(200, 400), rng.uniform(0, np.pi)\n", + " R = np.array([[np.cos(th), -np.sin(th)], [np.sin(th), np.cos(th)]])\n", + " plan = c + np.array([[-a, -b], [a, -b], [a, b], [-a, b]]) @ R.T\n", + " s0 = int(rng.integers(80, 160))\n", + " span = {\"sharp_turn\": 30, \"hover\": 60, \"dive\": 8, \"deviation\": 70, \"nfz_entry\": 120, \"low_level\": 120,\n", + " \"rc_silent\": 120, \"infiltration\": n - 20, \"loiter\": 90, \"speed_burst\": 25}.get(kind, 0)\n", + " if kind == \"infiltration\":\n", + " s0 = 20\n", + " win = (s0, s0 + span)\n", + " turns = set(rng.choice(np.arange(s0, s0 + 30), 3, replace=False).tolist()) if kind == \"sharp_turn\" else set()\n", + " # 난수를 미리 한 번에 뽑아 두고(속도 향상) 1초 단위로 기체 운동을 적분한다.\n", + " e_h, e_v, e_a = rng.normal(0, 0.8 * wind, n), rng.normal(0, 0.3 * wind, n), rng.normal(0, 0.4 * wind, n)\n", + " rc_arr = np.clip(rng.normal(rng.uniform(60, 92), 6, n), 0, 100)\n", + " gust = (rng.random(n) < (0.004 if weather == \"windy\" else 0.0)) * rng.uniform(20, 35, n)\n", + " burst, turn_amt = rng.uniform(23, 28), rng.choice([-1, 1], n) * rng.uniform(100, 160, n)\n", + " side = float(rng.choice([-90, 90]))\n", + " low_alt, rng_rc = rng.uniform(35, 55), rng.uniform(0, 20, n) # 저고도 목표, 조종신호 두절 시 잔여 수신값\n", + " x, y, wi = float(plan[0][0]), float(plan[0][1]), 1\n", + " hdg, alt = _bearing((x, y), plan[1]), alt0\n", + " max_rate = 40.0 if kind == \"survey\" else 12.0\n", + " dev_target, X, Y, A, V, HD = None, np.zeros(n), np.zeros(n), np.zeros(n), np.zeros(n), np.zeros(n)\n", + " for t in range(n):\n", + " inw = win[0] <= t < win[1]\n", + " tx, ty = plan[1] if kind == \"infiltration\" else plan[wi % len(plan)]\n", + " spd_cmd, alt_cmd = v0, alt0\n", + " if inw:\n", + " if kind == \"deviation\":\n", + " if dev_target is None:\n", + " r = math.radians(hdg + side)\n", + " dev_target = (x + 900 * math.sin(r), y + 900 * math.cos(r))\n", + " tx, ty = dev_target\n", + " elif kind == \"nfz_entry\":\n", + " tx, ty = NFZ[\"x\"], NFZ[\"y\"]\n", + " elif kind == \"hover\":\n", + " spd_cmd = 0.0\n", + " elif kind == \"speed_burst\":\n", + " spd_cmd = burst\n", + " elif kind == \"low_level\":\n", + " alt_cmd = low_alt\n", + " if kind in (\"rc_silent\", \"infiltration\"):\n", + " rc_arr[t] = rng_rc[t]\n", + " if inw and kind == \"loiter\":\n", + " hdg = (hdg + 9.0 + e_h[t] / wind) % 360\n", + " else:\n", + " d = _wrap(math.degrees(math.atan2(tx - x, ty - y)) - hdg)\n", + " hdg = (hdg + max(-max_rate, min(max_rate, d)) + e_h[t]) % 360\n", + " if t in turns:\n", + " hdg = (hdg + turn_amt[t]) % 360\n", + " v = abs(spd_cmd + e_v[t]) if spd_cmd > 0 else abs(e_v[t] / wind)\n", + " x += v * math.sin(math.radians(hdg)); y += v * math.cos(math.radians(hdg))\n", + " if kind != \"infiltration\" and math.hypot(x - tx, y - ty) < 40 and not (inw and kind in (\"deviation\", \"nfz_entry\")):\n", + " wi += 1\n", + " if inw and kind == \"dive\":\n", + " alt -= 9.0 + e_v[t]\n", + " else:\n", + " alt += max(-3.0, min(3.0, alt_cmd - alt)) + e_a[t]\n", + " alt -= gust[t] # 돌풍 하강기류(정상 비행에서도 발생)\n", + " if kind == \"dive\" and t >= win[1]:\n", + " alt0 = min(alt0, alt)\n", + " alt = max(alt, 3.0)\n", + " X[t], Y[t], A[t], V[t], HD[t] = x, y, alt, v, hdg\n", + " gps_sd = {\"clear\": 2.0, \"night\": 2.0, \"windy\": 3.0, \"rain\": 4.5}[weather] # 악천후일수록 GPS 잡음(다중경로) 증가\n", + " df = pd.DataFrame({\"t\": np.arange(n), \"x\": X + rng.normal(0, gps_sd, n), \"y\": Y + rng.normal(0, gps_sd, n),\n", + " \"alt\": A + rng.normal(0, 0.5, n), \"speed\": V + rng.normal(0, 0.2, n),\n", + " \"heading\": (HD + rng.normal(0, 1.5, n)) % 360, \"rc\": rc_arr.round(1),\n", + " \"sats\": rng.integers(10, 15, n), \"hdop\": rng.uniform(0.6, 1.2, n).round(2),\n", + " \"x_true\": X, \"y_true\": Y})\n", + " df[\"anomaly\"] = (df[\"t\"] >= win[0]) & (df[\"t\"] < win[1]) & (kind in SCENARIOS)\n", + " op = OPERATORS[int(rng.choice(3, p=OPERATOR_P[kind]))]\n", + " meta = {\"id\": fid, \"kind\": kind, \"weather\": weather, \"operator\": op, \"plan\": plan, \"window\": win if kind in SCENARIOS else None,\n", + " \"platform\": [\"commercial\", \"military\", \"unknown\"][int(rng.choice(3, p=PLATFORM_P[op]))],\n", + " \"firmware\": [\"stock\", \"custom\", \"unknown\"][int(rng.choice(3, p=FIRMWARE_P[op]))]}\n", + " return df, meta\n", + "\n", + "\n", + "def corrupt(df, rng, spoof=True, emi=True, drop=True, dup=True):\n", + " \"\"\"전장 잡음 주입: GPS 스푸핑(위치 점프·표류), 전자기 간섭(고도 스파이크), 센서 단절(결측), 중복 기록.\"\"\"\n", + " d = df.copy()\n", + " d[\"spoof_true\"] = False\n", + " if spoof:\n", + " a = int(rng.integers(40, len(d) - 60)); L = int(rng.integers(25, 45))\n", + " th = rng.uniform(0, 2 * np.pi)\n", + " drift = rng.uniform(22, 28) * np.arange(L)[:, None] * np.array([np.cos(th), np.sin(th)])\n", + " jump = rng.uniform(250, 400) * np.array([np.cos(th + 1), np.sin(th + 1)])\n", + " d.loc[a:a + L - 1, [\"x\", \"y\"]] += drift + jump\n", + " d.loc[a:a + L - 1, \"spoof_true\"] = True\n", + " if emi:\n", + " idx = rng.choice(len(d), 6, replace=False)\n", + " d.loc[idx, \"alt\"] += rng.choice([-1, 1], 6) * rng.uniform(60, 150, 6)\n", + " if drop:\n", + " a = int(rng.integers(10, len(d) - 20))\n", + " d.loc[a:a + int(rng.integers(4, 9)), [\"x\", \"y\", \"alt\", \"speed\", \"heading\"]] = np.nan\n", + " d = d.drop(index=rng.choice(len(d), 5, replace=False))\n", + " if dup:\n", + " d = pd.concat([d, d.sample(5, random_state=int(rng.integers(1e6)))]).sort_values(\"t\", kind=\"stable\")\n", + " return d.reset_index(drop=True)\n", + "\n", + "\n", + "def make_dataset(n=200, seed=7, anomaly_ratio=0.5, scenarios=SCENARIOS):\n", + " rng = np.random.default_rng(seed)\n", + " out = []\n", + " for i in range(n):\n", + " if i % 10 < anomaly_ratio * 10:\n", + " kind = scenarios[(i // 10 * int(anomaly_ratio * 10) + i % 10) % len(scenarios)]\n", + " else:\n", + " kind = \"survey\" if i % 10 == 9 else \"normal\"\n", + " df, meta = make_flight(f\"FL{i:04d}\", kind, rng)\n", + " out.append((df, meta))\n", + " return out\n", + "# ---------------------------------------------------------------------------\n", + "\n", + "# ---- 전처리·규칙 탐지 공통 함수 ----\n", + "SPOOF_THR = {\"clear\": 12.0, \"night\": 12.0, \"windy\": 15.0, \"rain\": 19.0} # 기상별 GPS-추측항법 불일치 허용치(m)\n", + "\n", + "\n", + "def clean(df, weather=\"clear\"):\n", + " \"\"\"전처리: 중복 제거·정렬 -> 1초 재표본화 -> 결측 보간 -> 고도 스파이크 제거 -> GPS 스푸핑 구간 추측항법 보정.\"\"\"\n", + " d = df.drop_duplicates(\"t\").sort_values(\"t\").set_index(\"t\")\n", + " d = d.reindex(np.arange(int(d.index.min()), int(d.index.max()) + 1))\n", + " n_missing = int(d[\"x\"].isna().sum())\n", + " num = [\"x\", \"y\", \"alt\", \"speed\", \"rc\", \"sats\", \"hdop\"]\n", + " d[num] = d[num].interpolate(limit_direction=\"both\")\n", + " hd = d[\"heading\"].dropna() # 방위는 0/360 경계를 풀어서 보간\n", + " un = pd.Series(np.degrees(np.unwrap(np.radians(hd.to_numpy(float)))), index=hd.index)\n", + " d[\"heading\"] = un.reindex(d.index).interpolate(limit_direction=\"both\").to_numpy() % 360\n", + " med = d[\"alt\"].rolling(5, center=True, min_periods=1).median()\n", + " spike = (d[\"alt\"] - med).abs() > 25\n", + " d.loc[spike, \"alt\"] = med[spike]\n", + " x, y = d[\"x\"].to_numpy(float), d[\"y\"].to_numpy(float)\n", + " r = np.radians(d[\"heading\"].to_numpy(float)); v = d[\"speed\"].to_numpy(float)\n", + " dr = np.c_[v * np.sin(r), v * np.cos(r)] # 보고 속도·방위로 계산한 1초 이동량(추측항법)\n", + " xc, yc, flag = x.copy(), y.copy(), np.zeros(len(x), bool)\n", + " thr = SPOOF_THR.get(weather, 12.0)\n", + " ok_run = 0\n", + " for i in range(1, len(x)):\n", + " px, py = xc[i - 1] + dr[i, 0], yc[i - 1] + dr[i, 1] # 추측항법 예상 위치\n", + " step_ok = np.hypot(x[i] - x[i - 1] - dr[i, 0], y[i] - y[i - 1] - dr[i, 1]) <= thr\n", + " ok_run = ok_run + 1 if step_ok else 0\n", + " if flag[i - 1]:\n", + " # 스푸핑 구간: GPS가 예상 위치로 돌아오거나, GPS 이동량이 5초 연속 물리적으로 맞으면 GPS로 복귀\n", + " if np.hypot(x[i] - px, y[i] - py) < thr * 2 or ok_run >= 5:\n", + " xc[i], yc[i] = x[i], y[i]\n", + " else:\n", + " xc[i], yc[i], flag[i] = px, py, True\n", + " elif not step_ok:\n", + " xc[i], yc[i], flag[i] = px, py, True # GPS 이동량이 물리적으로 맞지 않음 -> 스푸핑 의심\n", + " else:\n", + " xc[i], yc[i] = xc[i - 1] + x[i] - x[i - 1], yc[i - 1] + y[i] - y[i - 1]\n", + " d[\"x\"], d[\"y\"], d[\"spoof_flag\"] = xc, yc, flag\n", + " d = d.reset_index()\n", + " d.attrs.update({\"missing\": n_missing, \"spikes\": int(spike.sum()), \"spoof_pts\": int(flag.sum())})\n", + " return d\n", + "\n", + "\n", + "def xtrack(px, py, plan):\n", + " \"\"\"계획 경로(웨이포인트 폐곡선) 대비 이탈 거리(m).\"\"\"\n", + " P = np.asarray(plan, float)\n", + " segs = [(P[i], P[(i + 1) % len(P)]) for i in range(len(P) if len(P) > 2 else 1)]\n", + " best = np.full(len(px), np.inf)\n", + " for a, b in segs:\n", + " ab = b - a\n", + " u = np.clip(((px - a[0]) * ab[0] + (py - a[1]) * ab[1]) / max(ab @ ab, 1e-9), 0, 1)\n", + " best = np.minimum(best, np.hypot(px - a[0] - u * ab[0], py - a[1] - u * ab[1]))\n", + " return best\n", + "\n", + "\n", + "RULES = {\"turn_rate\": 45.0, \"hover_speed\": 1.5, \"hover_sec\": 20, \"dive_m\": 30.0, \"xtrack_m\": 250.0}\n", + "\n", + "\n", + "def rule_flags(d, plan, R=RULES):\n", + " \"\"\"규칙 기반 이상 비행 판정(기록 단위). 급선회·호버링·급강하·경로 이탈·비인가 구역 진입.\"\"\"\n", + " hr = np.abs(_wrap(np.diff(d[\"heading\"].to_numpy(float), prepend=d[\"heading\"].iloc[0])))\n", + " slow = (d[\"speed\"] < R[\"hover_speed\"]).to_numpy()\n", + " run = np.zeros(len(slow), int)\n", + " for i in range(len(slow)):\n", + " run[i] = run[i - 1] + 1 if slow[i] and i else int(slow[i])\n", + " hover = np.zeros(len(slow), bool)\n", + " for i in np.where(run >= R[\"hover_sec\"])[0]:\n", + " hover[i - R[\"hover_sec\"] + 1:i + 1] = True\n", + " alt = d[\"alt\"].to_numpy(float)\n", + " drop = alt - np.r_[alt[5:], np.full(5, alt[-1])]\n", + " return {\"급선회\": hr > R[\"turn_rate\"], \"호버링\": hover, \"급강하\": drop > R[\"dive_m\"],\n", + " \"경로이탈\": xtrack(d[\"x\"].to_numpy(float), d[\"y\"].to_numpy(float), plan) > R[\"xtrack_m\"],\n", + " \"비인가구역\": np.hypot(d[\"x\"] - NFZ[\"x\"], d[\"y\"] - NFZ[\"y\"]).to_numpy() < NFZ[\"r\"]}\n", + "\n", + "# ---- 구간(30초 창) 특징 + 비지도 이상 탐지 공통 함수 ----\n", + "WFEAT = [\"alt_mean\", \"alt_min\", \"alt_std\", \"vz_min\", \"spd_mean\", \"spd_std\", \"spd_max\", \"turn_sum\", \"turn_max\",\n", + " \"straight\", \"rc_mean\", \"rc_zero\", \"nfz_min\", \"nfz_close\", \"xtrack_max\"]\n", + "\n", + "\n", + "def window_feats(d, plan, win=30, step=10):\n", + " \"\"\"30초 창을 10초씩 옮기며 고도·속도·선회·직진성·조종신호·비인가 구역 접근·경로 이탈 특징을 계산한다.\"\"\"\n", + " x, y, alt = d[\"x\"].to_numpy(float), d[\"y\"].to_numpy(float), d[\"alt\"].to_numpy(float)\n", + " spd, rc = d[\"speed\"].to_numpy(float), d[\"rc\"].to_numpy(float)\n", + " hr = np.abs(_wrap(np.diff(d[\"heading\"].to_numpy(float), prepend=d[\"heading\"].iloc[0])))\n", + " vz = np.diff(alt, prepend=alt[0])\n", + " nd = np.hypot(x - NFZ[\"x\"], y - NFZ[\"y\"])\n", + " xt = xtrack(x, y, plan)\n", + " step_len = np.hypot(np.diff(x, prepend=x[0]), np.diff(y, prepend=y[0]))\n", + " rows, starts = [], []\n", + " for s in range(0, len(d) - win + 1, step):\n", + " e = s + win\n", + " path = step_len[s + 1:e].sum()\n", + " rows.append([alt[s:e].mean(), alt[s:e].min(), alt[s:e].std(), vz[s:e].min(), spd[s:e].mean(), spd[s:e].std(),\n", + " spd[s:e].max(), hr[s:e].sum(), hr[s:e].max(), np.hypot(x[e - 1] - x[s], y[e - 1] - y[s]) / max(path, 1.0),\n", + " rc[s:e].mean(), (rc[s:e] < 5).mean(), nd[s:e].min(), (nd[s] - nd[e - 1]) / win, xt[s:e].max()])\n", + " starts.append(int(d[\"t\"].iloc[s]))\n", + " return np.array(rows), starts\n", + "\n", + "\n", + "class FlightAnomalyModel:\n", + " \"\"\"평시 비행로그(라벨 없음)로 통상 패턴 군집(KMeans)과 고립 숲(IsolationForest)을 학습하고,\n", + " 두 점수를 학습 구간 분위수로 나눠 1.0을 넘으면 '통상 패턴에서 벗어남'으로 본다.\"\"\"\n", + "\n", + " def __init__(self, q=0.999, k=4):\n", + " self.q, self.k = q, k\n", + "\n", + " def fit(self, X):\n", + " self.sc = StandardScaler().fit(X)\n", + " Z = self.sc.transform(X)\n", + " self.iso = IsolationForest(n_estimators=150, random_state=0).fit(Z)\n", + " self.km = KMeans(self.k, n_init=5, random_state=0).fit(Z)\n", + " self.set_q(self.q, X)\n", + " return self\n", + "\n", + " def _raw(self, X):\n", + " Z = self.sc.transform(X)\n", + " return -self.iso.score_samples(Z), self.km.transform(Z).min(axis=1)\n", + "\n", + " def set_q(self, q, X):\n", + " a, b = self._raw(X)\n", + " self.q, self.t_if, self.t_km = q, np.quantile(a, q), np.quantile(b, q)\n", + "\n", + " def score(self, X):\n", + " a, b = self._raw(X)\n", + " return np.maximum(a / self.t_if, b / self.t_km)\n", + "\n", + "\n", + "AFEAT = [\"ml_score\", \"low_alt\", \"rc_weak\", \"nfz_min_km\", \"hover_s\", \"max_speed\", \"night\",\n", + " \"pf_commercial\", \"pf_military\", \"pf_unknown\", \"fw_stock\", \"fw_custom\", \"fw_unknown\"]\n", + "\n", + "\n", + "def attr_features(d, m, ml_score):\n", + " \"\"\"운용 주체 추정용 비행 단위 특징. 기체·펌웨어 정보는 상용 기체가 모든 주체에 쓰이므로 약한 단서일 뿐이다.\"\"\"\n", + " return [min(ml_score, 10.0), float((d[\"alt\"] < 60).mean()), float((d[\"rc\"] < 25).mean()),\n", + " float(np.hypot(d[\"x\"] - NFZ[\"x\"], d[\"y\"] - NFZ[\"y\"]).min() / 1000), float((d[\"speed\"] < 1.5).sum()),\n", + " float(d[\"speed\"].max()), float(m[\"weather\"] == \"night\"),\n", + " *[float(m[\"platform\"] == p) for p in (\"commercial\", \"military\", \"unknown\")],\n", + " *[float(m[\"firmware\"] == f) for f in (\"stock\", \"custom\", \"unknown\")]]\n", + "\n", + "CONFIG = {\"turn_rate\": 60, \"dive_m\": 40, \"ml_threshold\": 1.2, \"ml_quantile\": 0.995, # 미션 9 재보정값\n", + " \"p_high\": 0.60, \"h_max\": 0.80, \"p_med\": 0.25}\n", + "CALLED = set()\n", + "\n", + "\n", + "def cover(fn):\n", + " \"\"\"기능 커버리지 측정용 표시: 통합 실행 중 호출된 기능을 기록한다.\"\"\"\n", + " def wrap(*a, **k):\n", + " CALLED.add(fn.__name__)\n", + " return fn(*a, **k)\n", + " wrap.__name__ = fn.__name__\n", + " return wrap\n", + "\n", + "\n", + "@cover\n", + "def validate_log(df, m):\n", + " errs = [f\"{c} 누락\" for c in (\"t\", \"x\", \"y\", \"alt\", \"speed\", \"heading\", \"rc\") if c not in df]\n", + " if m[\"weather\"] not in SPOOF_THR:\n", + " errs.append(\"weather 허용값 아님\")\n", + " if not errs and ((df[\"speed\"] > 60).any() or (df[\"rc\"] > 100).any()):\n", + " errs.append(\"범위 오류\")\n", + " return errs\n", + "\n", + "\n", + "@cover\n", + "def grade(p, h, q_ok):\n", + " if p >= CONFIG[\"p_high\"] and h <= CONFIG[\"h_max\"] and q_ok:\n", + " return \"High\"\n", + " return \"Medium\" if p >= CONFIG[\"p_med\"] else \"Low\"\n", + "\n", + "\n", + "@cover\n", + "def statement(fid, g, p, h, kinds):\n", + " return f\"{fid}: 이상 비행({kinds}), 적대 운용 가능성 {g}(p={p:.2f}, 불확실성 {h:.2f}) - 정보분석관 검토 필요\"\n", + "\n", + "\n", + "class Queue:\n", + " def __init__(self):\n", + " self.items, self.log = [], []\n", + "\n", + " @cover\n", + " def submit(self, fid, g, text):\n", + " prev = self.log[-1][\"hash\"] if self.log else \"GENESIS\"\n", + " body = {\"event\": \"SUBMIT\", \"id\": fid, \"grade\": g, \"text\": text}\n", + " self.log.append({**body, \"prev\": prev, \"hash\": hashlib.sha256((prev + json.dumps(body, ensure_ascii=False, sort_keys=True)).encode()).hexdigest()})\n", + " self.items.append({**body, \"status\": \"PENDING\"})\n", + "\n", + " @cover\n", + " def verify(self):\n", + " prev = \"GENESIS\"\n", + " for r in self.log:\n", + " body = {k: v for k, v in r.items() if k not in (\"prev\", \"hash\")}\n", + " if r[\"prev\"] != prev or r[\"hash\"] != hashlib.sha256((prev + json.dumps(body, ensure_ascii=False, sort_keys=True)).encode()).hexdigest():\n", + " return False\n", + " prev = r[\"hash\"]\n", + " return True\n", + "\n", + "\n", + "clean, rule_flags, window_feats, attr_features, corrupt = map(cover, (clean, rule_flags, window_feats, attr_features, corrupt))\n", + "FlightAnomalyModel.score = cover(FlightAnomalyModel.score)\n", + "TARGETS = {\"validate_log\", \"clean\", \"rule_flags\", \"window_feats\", \"score\", \"attr_features\", \"grade\", \"statement\", \"submit\", \"verify\", \"corrupt\", \"dashboard\"}\n", + "\n", + "\n", + "def analyze(df, m, det):\n", + " d = clean(df, m[\"weather\"])\n", + " f = rule_flags(d, m[\"plan\"], {**RULES, \"turn_rate\": CONFIG[\"turn_rate\"], \"dive_m\": CONFIG[\"dive_m\"]})\n", + " s = float(det.score(window_feats(d, m[\"plan\"])[0]).max())\n", + " hits = [k for k, v in f.items() if v.any()] + ([\"ML이상\"] if s > CONFIG[\"ml_threshold\"] else [])\n", + " return d, hits, s\n", + "\n", + "\n", + "# ---------------- 1. 학습(평시 패턴 + 과거 검토 완료 사례) ----------------\n", + "t_start = time.perf_counter()\n", + "routine = make_dataset(80, 21, anomaly_ratio=0.0) + [(df, m) for df, m in make_dataset(200, 22, anomaly_ratio=0.0) if m[\"kind\"] == \"survey\" or m[\"weather\"] == \"windy\"][:55]\n", + "det = FlightAnomalyModel(q=CONFIG[\"ml_quantile\"]).fit(np.vstack([window_feats(clean(df, m[\"weather\"]), m[\"plan\"])[0] for df, m in routine]))\n", + "past = []\n", + "for df, m in make_dataset(160, 31):\n", + " d, hits, s = analyze(df, m, det)\n", + " past.append((attr_features(d, m, s), m[\"operator\"]))\n", + "eng = make_pipeline(StandardScaler(), LogisticRegression(max_iter=2000, C=0.5)).fit([p[0] for p in past], [p[1] for p in past])\n", + "\n", + "# ---------------- 2. 운용 자료 처리 ----------------\n", + "rng = np.random.default_rng(10)\n", + "ops = make_dataset(100, 11)\n", + "ops = [(corrupt(df, rng) if rng.random() < 0.25 else df, m) for df, m in ops]\n", + "ops[7] = (ops[7][0].drop(columns=[\"rc\"]), ops[7][1]) # 손상 로그 1건 주입(검증 단계에서 차단되어야 함)\n", + "queue, rejected, res, ms = Queue(), {}, [], []\n", + "for df, m in ops:\n", + " errs = validate_log(df, m)\n", + " if errs:\n", + " rejected[m[\"id\"]] = errs; continue\n", + " t0 = time.perf_counter()\n", + " d, hits, s = analyze(df, m, det)\n", + " P = eng.predict_proba([attr_features(d, m, s)])[0]\n", + " ph = P[list(eng.classes_).index(\"hostile\")]; H = float(-(P * np.log(np.clip(P, 1e-9, 1))).sum() / np.log(3))\n", + " q_ok = d.attrs[\"spoof_pts\"] < 10 and d.attrs[\"missing\"] < 15\n", + " g = grade(ph, H, q_ok) if hits else \"-\"\n", + " if hits:\n", + " queue.submit(m[\"id\"], g, statement(m[\"id\"], g, ph, H, \",\".join(hits)))\n", + " ms.append((time.perf_counter() - t0) * 1000)\n", + " res.append({\"id\": m[\"id\"], \"kind\": m[\"kind\"], \"op\": m[\"operator\"], \"truth\": m[\"kind\"] in SCENARIOS, \"pred\": bool(hits), \"grade\": g, \"d\": d})\n", + "\n", + "# ---------------- 3. 성능 평가 ----------------\n", + "R = pd.DataFrame(res)\n", + "acc = (R.truth == R.pred).mean(); rec = (R.truth & R.pred).sum() / R.truth.sum(); far = (~R.truth & R.pred).sum() / max(R.pred.sum(), 1)\n", + "high = R[R.grade == \"High\"]; hfar = (high.op != \"hostile\").mean() if len(high) else 0.0\n", + "\n", + "# ---------------- 4. 대시보드 ----------------\n", + "@cover\n", + "def dashboard():\n", + " plt.style.use(\"dark_background\")\n", + " fig, ax = plt.subplots(2, 2, figsize=(12, 8))\n", + " for _, r in R[R.pred].iterrows():\n", + " ax[0, 0].plot(r.d[\"x\"], r.d[\"y\"], lw=0.8, color={\"High\": \"red\", \"Medium\": \"orange\", \"Low\": \"gray\"}[r.grade])\n", + " ax[0, 0].add_patch(plt.Circle((NFZ[\"x\"], NFZ[\"y\"]), NFZ[\"r\"], color=\"red\", alpha=.2)); ax[0, 0].set_aspect(\"equal\")\n", + " ax[0, 0].set_title(\"Flagged flights (color = attribution grade)\")\n", + " R[R.pred][\"grade\"].value_counts().reindex([\"High\", \"Medium\", \"Low\"]).fillna(0).plot.bar(ax=ax[0, 1], color=[\"red\", \"orange\", \"gray\"])\n", + " ax[0, 1].set_title(\"Review queue by grade (all pending)\")\n", + " R[R.pred][\"kind\"].value_counts().plot.barh(ax=ax[1, 0], color=\"deepskyblue\"); ax[1, 0].set_title(\"Flagged flights by true scenario\")\n", + " ax[1, 1].axis(\"off\")\n", + " ax[1, 1].text(0, .9, f\"Accuracy {acc:.1%}\\nRecall {rec:.1%}\\nFalse alarm {far:.1%}\\nQueue {len(queue.items)} pending\\nLatency {np.mean(ms):.0f} ms/flight\",\n", + " family=\"monospace\", fontsize=13, va=\"top\")\n", + " fig.suptitle(\"ISR03 integrated drone-log analysis - training data only\"); plt.tight_layout(); plt.show()\n", + "\n", + "\n", + "dashboard()\n", + "\n", + "# ---------------- 5. 자체 점검(통합 테스트) ----------------\n", + "log_ok = queue.verify()\n", + "cov = len(CALLED & TARGETS) / len(TARGETS)\n", + "chk = {\n", + " \"손상 로그 차단\": \"FL0007\" in rejected,\n", + " \"처리 비행 99건\": len(res) == 99,\n", + " \"이상 비행 재현율 90% 이상\": rec >= 0.9,\n", + " \"정확도 90% 이상\": acc >= 0.9,\n", + " \"자동 채택 0건(전부 검토 대기)\": all(i[\"status\"] == \"PENDING\" for i in queue.items),\n", + " \"탐지 건 전량 대기열 등록\": len(queue.items) == int(R.pred.sum()),\n", + " \"감사 로그 무결성\": log_ok,\n", + " \"단정 표현('확정') 0건\": not any(\"확정\" in i[\"text\"] for i in queue.items),\n", + " \"비행 1건 200ms 이내\": float(np.mean(ms)) < 200,\n", + " \"기능 커버리지 85% 이상\": cov >= 0.85,\n", + "}\n", + "passed = sum(chk.values())\n", + "for k, v in chk.items():\n", + " print(f\"[{'PASS' if v else 'FAIL'}] {k}\")\n", + "print(f\"자체 점검 {passed}/{len(chk)} 통과, 기능 커버리지 {cov:.0%} ({len(CALLED & TARGETS)}/{len(TARGETS)}), 파이프라인 실행 성공 {'예' if passed == len(chk) else '아니오'}\")\n", + "\n", + "# ---------------- 6. ISR 정보판단 브리핑 요약 ----------------\n", + "print(f\"\"\"\n", + "[드론 비행로그 이상 탐지 브리핑 요약 - 교육용가상자료]\n", + "1. 처리: 비행로그 {len(ops)}건 중 {len(res)}건 분석, 손상 로그 {len(rejected)}건 차단({', '.join(rejected)})\n", + "2. 성능: 정확도 {acc:.1%}, 이상 재현율 {rec:.1%}, 오경보율 {far:.1%}, 비행당 {np.mean(ms):.0f} ms, 전체 {time.perf_counter() - t_start:.0f} s\n", + "3. 검토 대기: {len(queue.items)}건 (High {int((R.grade == 'High').sum())} / Medium {int((R.grade == 'Medium').sum())} / Low {int((R.grade == 'Low').sum())}), 자동 채택 0건\n", + "4. 운용 주체: High 등급 {len(high)}건 중 실제 비적대 {int((high.op != 'hostile').sum())}건({hfar:.0%}) -> 등급은 검토 우선순위일 뿐 귀속 결론이 아님\n", + "5. 한계: 합성 로그 기준 결과이며, 운용 주체 판단은 출처·무결성 확인과 2인 교차 검토를 거친 정보분석관 의견으로만 보고한다.\"\"\")\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 10 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + "[PASS] 손상 로그 차단\n", + "[PASS] 처리 비행 99건\n", + "[PASS] 이상 비행 재현율 90% 이상\n", + "[PASS] 정확도 90% 이상\n", + "[PASS] 자동 채택 0건(전부 검토 대기)\n", + "[PASS] 탐지 건 전량 대기열 등록\n", + "[PASS] 감사 로그 무결성\n", + "[PASS] 단정 표현('확정') 0건\n", + "[PASS] 비행 1건 200ms 이내\n", + "[PASS] 기능 커버리지 85% 이상\n", + "자체 점검 10/10 통과, 기능 커버리지 100% (12/12), 파이프라인 실행 성공 예\n", + "\n", + "[드론 비행로그 이상 탐지 브리핑 요약 - 교육용가상자료]\n", + "1. 처리: 비행로그 100건 중 99건 분석, 손상 로그 1건 차단(FL0007)\n", + "2. 성능: 정확도 96.0%, 이상 재현율 98.0%, 오경보율 5.8%, 비행당 31 ms, 전체 12 s\n", + "3. 검토 대기: 52건 (High 7 / Medium 22 / Low 23), 자동 채택 0건\n", + "4. 운용 주체: High 등급 7건 중 실제 비적대 2건(29%) -> 등급은 검토 우선순위일 뿐 귀속 결론이 아님\n", + "5. 한계: 합성 로그 기준 결과이며, 운용 주체 판단은 출처·무결성 확인과 2인 교차 검토를 거친 정보분석관 의견으로만 보고한다.\n", + "```\n", + "
\n" + ] + } + ], + "metadata": { + "colab": { + "provenance": [] + }, + "kernelspec": { + "display_name": "Python 3", + "name": "python3" + }, + "language_info": { + "name": "python" + } + }, + "nbformat": 4, + "nbformat_minor": 0 +} \ No newline at end of file diff --git a/uploads/ISR05/ISR05_workbook.ipynb b/uploads/ISR05/ISR05_workbook.ipynb new file mode 100644 index 0000000..9e25063 --- /dev/null +++ b/uploads/ISR05/ISR05_workbook.ipynb @@ -0,0 +1,915 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# 📋 [ISR05] 드론·미사일 공격 주체 귀속 근거 매트릭스 설계\n", + "### 실습 워크북 & SOP 가이드 (Google Colab / Jupyter)\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/ISR05/ISR05_workbook.ipynb)\n", + "\n", + "---\n", + "\n", + "## 📌 1. 프로젝트 개요 및 배경\n", + "\n", + "| 항목 | 내용 |\n", + "| :--- | :--- |\n", + "| **과정 분류** | 정보·감시·정찰(ISR) (Level 3) |\n", + "| **학습 소요 시간** | 10개 미션 (5일 / 총 27.0시간) |\n", + "| **실습 유형** | 작전절차(SOP), 체크리스트, 모의훈련, AAR 사후검토 실습 |\n", + "\n", + "### 📖 프로젝트 목표\n", + "러시아-우크라이나 전쟁과 미국-이란 간 긴장 국면에서는 무인기와 미사일 공격이 잦았고, 공격 직후 책임을 주장하는 성명, 잔해 사진, 불완전한 항적 정보가 한꺼번에 나와 공격 주체를 둘러싼 단정과 반박이 빠르게 오갔다. 위장 깃발이나 대리 세력 개입 가능성까지 겹치면 하나의 증거만으로 주체를 말하는 일은 오판으로 이어질 수 있다. 본 프로젝트에서는 교육용 가상 공격 사건을 사용해 귀속 판단 요소 정의, 증거의 증명력 6단계 분류, 가설별 다차원 증거 매트릭스, 가중치와 점수 논리, 위장 깃발·대리 세력 교차검증 항목, 지휘관 보고 서식, 상충 증거 스트레스 테스트, 과거 오판 AAR, 민감도 분석과 1페이지 브리핑을 차례로 수행한다. 귀속은 단정하지 않고 근거 수준과 한계를 함께 적어 사람이 승인하는 의견으로만 다룬다. 최종 목표는 공격 주체 귀속 근거 평가 매트릭스 표준 지침서를 문서로 완성하는 것이다.\n", + "\n", + "> **⚠️ [교육용 가상 자료 원칙]**\n", + "> - 본 워크북의 모든 자료는 안전 규정에 따라 **교육용 가상 자료**로 구성되어 있습니다.\n", + "> - 실제 군사 기밀이나 부대 세부 인적·작전 정보는 포함되지 않습니다.\n", + "\n", + "---\n", + "\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 01. 원거리 드론·미사일 공격 사례 분석 및 공격 주체 귀속(Attribution) 핵심 요소 정의\n", + "\n", + "### 📋 미션 배경 및 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "드론 및 미사일 공격 사례를 분석하여 공격 원점, 추진체 잔해, 전자 부품, 비행 항적 등 귀속(Attribution) 판단 요소를 도출한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **가상 드론·미사일 공격 사례를 분석해 공격 원점, 잔해, 전자 부품, 비행 항적 등 귀속 판단 요소를 도출하고 문서 전체 골격을 정의한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **실습자료의 가상 공격 사례 카드 5건을 읽고 사례마다 어떤 증거(공격 원점 추정, 잔해 표식, 비행 항적, 신호 기록, 성명)가 있었고 어디까지 말할 수 있었는지 비교표로 정리하고 규격화하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 5개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `docs/isr05_doc_framework.md 및 분석 개요서` 형태의 결과물을 완성한다.\n", + "\n", + "### 📑 실습자료 및 작성 양식\n", + "원거리 드론·미사일 공격 사례 분석 및 공격 주체 귀속(Attribution) 핵심 요소 정의**\n", + "\n", + "가. 제공 자료 - 가상 공격 사례 카드 5건 [교육용가상자료]\n", + "\n", + "| **사례** | **공격 개요** | **있었던 증거** | **당시 말할 수 있었던 범위** | **나중에 드러난 한계** |\n", + "| --- | --- | --- | --- | --- |\n", + "| **A1** | 무인기 2기, 가상 통신소 피격 | 잔해 표식, 항적 일부 | '방식이 X와 유사하다' 수준 | 표식 형식이 시장에 유통되는 형식이었음 |\n", + "| **A2** | 순항형 비행체 1기, 가상 정비창 | 레이더 항적 2분 결측, 선전 성명 | 공격 방향 범위만 | 성명이 사건 전에 작성된 정황 |\n", + "| **A3** | 무인기 5기, 가상 보급소 | 전자 부품 일부, 신호 요약 | 부품 제작 계열 후보 2개 | 두 후보 모두 재사용 가능한 부품 |\n", + "| **A4** | 무인기 1기, 가상 발전소 | 현지 목격 진술, 소리 방향 | 방위 범위가 넓음 | 진술이 2차 전달이었음 |\n", + "| **A5** | 비행체 3기, 가상 항만 | 성명 2건(서로 다른 집단 명의) | 주체 판단 불가 | 두 성명 모두 검증 불가 |\n", + "\n", + "나. 작성 서식 1 - 귀속 판단 요소 분류표(증거 유형별)\n", + "\n", + "| **증거 유형** | **귀속에 쓰는 요소** | **쉽게 빠지는 오류** | **오류를 줄이는 방법** |\n", + "| --- | --- | --- | --- |\n", + "| **공격 원점 추정** | | | |\n", + "| **잔해·부품 표식** | | | |\n", + "| **비행 항적·궤적** | | | |\n", + "| **신호·디지털 기록** | | | |\n", + "| **책임 주장 성명** | | | |\n", + "\n", + "나. 작성 서식 2 - 지침서 목차 골격(지휘관 결심 의제 순으로 배열)\n", + "\n", + "| **순서** | **장 제목** | **담을 핵심 내용** | **지휘관이 얻는 판단 정보** |\n", + "| --- | --- | --- | --- |\n", + "| **제1장** | | | |\n", + "| **제2장** | | | |\n", + "| **제3장** | | | |\n", + "| **제4장** | | | |\n", + "| **제5장** | | | |\n", + "| **제6장** | | | |\n", + "| **제7장** | | | |\n", + "| **제8장** | | | |\n", + "\n", + "다. 점검표\n", + "\n", + "| **점검 항목** | **확인** |\n", + "| --- | --- |\n", + "| 가상 사례 5건을 모두 검토하고 당시 말할 수 있었던 범위와 한계를 비교했다 | ( ) |\n", + "| 증거 유형 5가지 모두에 귀속 요소와 오류 가능성을 적었다 | ( ) |\n", + "| 실제 무기 제원 대신 추상 서술만 사용했다 | ( ) |\n", + "| 지침서 8개 장의 제목과 핵심 내용을 정의했다 | ( ) |\n", + "| 목차를 지휘관이 먼저 알아야 할 의제 순으로 배열하고 이유를 한 줄로 적었다 | ( ) |\n", + "\n", + "**\n", + "\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 02. 공격 원점, 잔해 부품, 비행 궤적, 선전 매체 주장 등 귀속 증거 분류 체계 수립\n", + "\n", + "### 📋 미션 배경 및 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "수집된 물리적 증거, 신호 증거, 디지털 증거의 신뢰도와 증명력을 분류하는 체계를 수립한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **공격 원점, 잔해 부품, 비행 궤적, 선전 매체 주장 등 증거를 물리·신호·디지털 증거로 나누고 신뢰도와 증명력을 6단계로 분류하는 체계를 수립한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **증거를 물리 증거, 신호 증거, 디지털 증거, 주장·진술로 나누고 증거 증명력 6단계(1 주장만 있음 ~ 6 서로 다른 유형 다중 독립 확인)의 판정 기준을 관찰 가능한 문장으로 쓴다 과정을 진행하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 5개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `framework/isr05_source_reliability_matrix.md` 형태의 결과물을 완성한다.\n", + "\n", + "### 📑 실습자료 및 작성 양식\n", + "공격 원점, 잔해 부품, 비행 궤적, 선전 매체 주장 등 귀속 증거 분류 체계 수립**\n", + "\n", + "가. 제공 자료 1 - 가상 증거 카드 EV1~EV8 [교육용가상자료]\n", + "\n", + "| **증거** | **유형** | **내용** | **출처 설명** | **확인 상태** |\n", + "| --- | --- | --- | --- | --- |\n", + "| **EV1** | 물리 | 잔해에서 제작 표식 형식 'K-계열' 확인 | 현장 수거팀 사진 보고 | 같은 형식이 시장에서도 유통됨 |\n", + "| **EV2** | 신호 | 비행 항적: 북서 방면 진입, 3분 결측 | R 레이더 요약 | 결측 구간에서 경로 변경 가능 |\n", + "| **EV3** | 물리 | 원점 역추정: 격자 가-1~가-3 중 한 곳 | 항적 역추정 | 범위가 넓음 |\n", + "| **EV4** | 주장 | '가상 집단 X 명의' 책임 성명, 사건 21분 후 게시 | 공개 채널 Z | 성명에 사건 시각 오류 1건 |\n", + "| **EV5** | 디지털 | 성명 영상 파일의 생성 시각이 사건 시각보다 이전으로 표시 | 게시물 메타데이터 요약 | 시각 설정 오류 가능성도 있음 |\n", + "| **EV6** | 신호 | 공격 직전 가-2 방면 통신량 증가 | S 신호 요약 | 통신 내용 불명 |\n", + "| **EV7** | 진술 | '소리가 동쪽에서 왔다'는 주민 진술 | 2차 전달 보고 | 항적 방향과 불일치 |\n", + "| **EV8** | 정황 | 집단 Y도 유사한 방식으로 공격한 이력(가상) | 과거 사건 기록 | 방식은 모방될 수 있음 |\n", + "\n", + "가. 제공 자료 2 - 증거 증명력 6단계 틀(단계 이름은 고정, 판정 기준은 조별 작성)\n", + "\n", + "| **단계** | **이름** | **판정 기준(조별 작성)** |\n", + "| --- | --- | --- |\n", + "| **1** | 주장만 있음 | |\n", + "| **2** | 정황 | |\n", + "| **3** | 단일 유형 확인 | |\n", + "| **4** | 두 유형 부분 일치 | |\n", + "| **5** | 두 유형 독립 일치 | |\n", + "| **6** | 서로 다른 유형 다중 독립 확인 | |\n", + "\n", + "나. 작성 서식 1 - 증거 평가 지표(10개 이상, 예시 2개 포함)\n", + "\n", + "| **번호** | **평가 지표** | **확인 방법(관찰 가능한 기준)** |\n", + "| --- | --- | --- |\n", + "| **1** | 출처의 독립성 | 다른 증거와 같은 원출처인지 표시 |\n", + "| **2** | 재사용·유통 가능성 | 다른 집단도 얻을 수 있는 형식이면 감점 |\n", + "| **3** | | |\n", + "| **4** | | |\n", + "| **5** | | |\n", + "| **6** | | |\n", + "| **7** | | |\n", + "| **8** | | |\n", + "| **9** | | |\n", + "| **10** | | |\n", + "| **11** | | |\n", + "| **12** | | |\n", + "\n", + "나. 작성 서식 2 - 증거 5종 단계 부여 일관성 시험(EV1·EV2·EV4·EV5·EV7, 조원이 각자 단계를 적는다)\n", + "\n", + "| **증거** | **조원 1** | **조원 2** | **조원 3** | **조원 4** | **일치 여부** | **불일치 원인** |\n", + "| --- | --- | --- | --- | --- | --- | --- |\n", + "| **EV1** | | | | | | |\n", + "| **EV2** | | | | | | |\n", + "| **EV4** | | | | | | |\n", + "| **EV5** | | | | | | |\n", + "| **EV7** | | | | | | |\n", + "\n", + "나. 작성 서식 3 - 편향 방지 규칙: 책임 주장 성명(EV4)은 최대 ( )단계로 평가한다. 한 가지 증거 유형만으로 귀속 의견을 상정할 수 없고 서로 다른 유형 ( )종 이상이 필요하다. 처음 세운 가설을 지지하는 증거만 모으지 않도록 ( )를 지정하고 반박 증거를 따로 기록한다.\n", + "\n", + "다. 점검표\n", + "\n", + "| **점검 항목** | **확인** |\n", + "| --- | --- |\n", + "| 증거 4유형(물리·신호·디지털·주장)의 분류 기준을 정했다 | ( ) |\n", + "| 증명력 6단계 판정 기준을 관찰 가능한 문장으로 작성했다 | ( ) |\n", + "| 평가 지표를 10개 이상 정의했다 | ( ) |\n", + "| 증거 5종에 대한 조원 단계가 4건 이상 일치했다 | ( ) |\n", + "| 선전 주장 편향·확증 편향을 막는 규칙을 명시했다 | ( ) |\n", + "\n", + "**\n", + "\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 03. 성급한 적 귀속 단정 방지를 위한 다차원 귀속 증거 매트릭스(Attribution Matrix) 설계\n", + "\n", + "### 📋 미션 배경 및 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "성급한 적 귀속 단정을 방지하기 위한 공격 주체 다차원 근거 매트릭스(Attribution Matrix)를 설계한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **성급한 귀속 단정을 막기 위해, 가설별로 증거가 지지·반박·중립인지 표시하는 다차원 귀속 증거 매트릭스를 설계하고 불확실성 영향도를 평가한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **가상 사건의 공격 주체 가설 3개(가상 집단 X, 가상 집단 Y, 제3자 위장)를 세우고 증거 8개를 가설마다 지지(+)·반박(-)·중립(0)·확인 불가(?)로 표시하는 매트릭스 작성하여 원인을 분석하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 5개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `analysis/isr05_uncertainty_assessment.md` 형태의 결과물을 완성한다.\n", + "\n", + "### 📑 실습자료 및 작성 양식\n", + "성급한 적 귀속 단정 방지를 위한 다차원 귀속 증거 매트릭스(Attribution Matrix) 설계**\n", + "\n", + "가. 제공 자료 - 가설 3개 [교육용가상자료]: H1 가상 집단 X가 수행 / H2 가상 집단 Y가 수행 / H3 제3자가 X의 소행처럼 보이게 위장. 증거는 M2의 EV1~EV8을 쓴다.\n", + "\n", + "나. 작성 서식 1 - 가설별 증거 매트릭스(지지 +, 반박 -, 중립 0, 확인 불가 ?)\n", + "\n", + "| **증거** | **H1 집단 X** | **H2 집단 Y** | **H3 제3자 위장** | **판단 근거 한 줄** |\n", + "| --- | --- | --- | --- | --- |\n", + "| **EV1** | | | | |\n", + "| **EV2** | | | | |\n", + "| **EV3** | | | | |\n", + "| **EV4** | | | | |\n", + "| **EV5** | | | | |\n", + "| **EV6** | | | | |\n", + "| **EV7** | | | | |\n", + "| **EV8** | | | | |\n", + "\n", + "나. 작성 서식 2 - 정보 공백·불확실성 영향도 매트릭스(8개 이상, 예시 2개 포함)\n", + "\n", + "| **번호** | **정보 공백 요인** | **영향도(높음·중간·낮음)** | **영향을 받는 판단** | **보완 수집 수단** |\n", + "| --- | --- | --- | --- | --- |\n", + "| **1** | 항적 결측 3분 | 높음 | 원점·방향 판단 | 다른 레이더 기록 요청 |\n", + "| **2** | 성명 영상 원본 미확보 | 중간 | EV4·EV5 신뢰 | 원본 수집 요청 |\n", + "| **3** | | | | |\n", + "| **4** | | | | |\n", + "| **5** | | | | |\n", + "| **6** | | | | |\n", + "| **7** | | | | |\n", + "| **8** | | | | |\n", + "| **9** | | | | |\n", + "| **10** | | | | |\n", + "\n", + "나. 작성 서식 3 - 표현 규칙과 판단 한계: 쓸 수 있는 표현 '( )(근거 수준 n)', 쓰면 안 되는 표현 '( )'. 이 매트릭스로 판단할 수 없는 경우 3가지: 1) ( ) 2) ( ) 3) ( )\n", + "\n", + "다. 점검표\n", + "\n", + "| **점검 항목** | **확인** |\n", + "| --- | --- |\n", + "| 매트릭스 24칸(가설 3 x 증거 8)을 모두 채웠다 | ( ) |\n", + "| 정보 공백 요인을 8개 이상 찾고 영향도를 매겼다 | ( ) |\n", + "| 공백마다 보완 수집 수단을 1개 이상 적었다 | ( ) |\n", + "| 단정 표현 금지와 근거 수준 표기 규칙을 정했다 | ( ) |\n", + "| 판단의 한계 3가지를 적었다 | ( ) |\n", + "\n", + "**\n", + "\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 04. 증거 수준별 가중치 부여 및 귀속 신뢰도 점수 계산 논리 수립\n", + "\n", + "### 📋 미션 배경 및 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "증거 수준별 가중치를 설정하고 복합 귀속 신뢰도 점수를 산출하는 판정 논리를 구축한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **증거 수준별 가중치를 정하고 가설별 귀속 신뢰도 점수를 계산하는 판정 논리를 수립하며, 점수가 사람의 승인을 대신하지 못하게 하는 규칙을 둔다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **귀속 판단 기준(증거 항목)을 12개 이상 항목화하고 증명력 단계, 독립성, 시각 정합성에 따라 항목별 가중치를 정 수행하고 검증하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 5개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `matrix/isr05_attribution_matrix.xlsx(md)` 형태의 결과물을 완성한다.\n", + "\n", + "### 📑 실습자료 및 작성 양식\n", + "증거 수준별 가중치 부여 및 귀속 신뢰도 점수 계산 논리 수립**\n", + "\n", + "가. 제공 자료 1 - 기준 항목 예시 4개(나머지 8개 이상은 조별로 추가)\n", + "\n", + "| **기준 항목** | **방향** | **예시 가중치** |\n", + "| --- | --- | --- |\n", + "| **서로 다른 유형의 독립 증거가 같은 가설을 지지** | 지지 | +4 |\n", + "| **책임 주장 성명만 지지(독립 확인 없음)** | 지지 | +1 |\n", + "| **가설과 모순되는 시각·방향 증거** | 반박 | -3 |\n", + "| **증거가 다른 집단도 얻을 수 있는 형식** | 지지 약화 | -1 |\n", + "\n", + "가. 제공 자료 2 - 임계 규칙 초안: 가설 점수 1위와 2위의 차이가 ( )점 미만이면 귀속 의견을 상정하지 않는다. 반박 증거가 있는 가설은 점수와 관계없이 ( )한다.\n", + "\n", + "나. 작성 서식 1 - 귀속 판단 기준 12개 이상과 가설별 평가(지지 가중치 / 반박은 음수 / 확인 불가는 ?)\n", + "\n", + "| **번호** | **귀속 판단 기준(증거 항목)** | **가중치** | **H1** | **H2** | **H3** |\n", + "| --- | --- | --- | --- | --- | --- |\n", + "| **1** | | | | | |\n", + "| **2** | | | | | |\n", + "| **3** | | | | | |\n", + "| **4** | | | | | |\n", + "| **5** | | | | | |\n", + "| **6** | | | | | |\n", + "| **7** | | | | | |\n", + "| **8** | | | | | |\n", + "| **9** | | | | | |\n", + "| **10** | | | | | |\n", + "| **11** | | | | | |\n", + "| **12** | | | | | |\n", + "| **13** | | | | | |\n", + "| **14** | | | | | |\n", + "\n", + "나. 작성 서식 2 - 점수 산식과 결과\n", + "\n", + "| 가설 점수 = (지지 가중치 합) - (반박 가중치 합) - (확인 불가 '?' 개수 x ( ))\n", + "규칙 1. 반박 증거가 하나라도 있는 가설은 '귀속 의견 상정 불가, 추가 수집 요청'\n", + "규칙 2. 1위와 2위의 점수 차이가 ( )점 미만이면 '보류 유지'\n", + "규칙 3. 서로 다른 유형의 독립 증거가 ( )종 미만이면 점수가 높아도 상정 불가\n", + "규칙 4. 점수가 기준 이상이어도 상정과 승인은 사람(책임 직책)이 하며 점수가 결정을 대신하지 않는다\n", + "결과: H1 ( )점 / H2 ( )점 / H3 ( )점 → 판단 ( ) |\n", + "| --- |\n", + "\n", + "나. 작성 서식 3 - 레드팀 반박 기록(국제법적 귀속 쟁점은 '법무 검토 필요'로만 표시)\n", + "\n", + "| **대상** | **레드팀 반박('이 점수는 선전 주장에 끌려가지 않는가')** | **작성 팀 대응** | **보완한 규칙** |\n", + "| --- | --- | --- | --- |\n", + "| **기준·가중치** | | | |\n", + "| **H1 점수** | | | |\n", + "| **임계 규칙** | | | |\n", + "\n", + "다. 점검표\n", + "\n", + "| **점검 항목** | **확인** |\n", + "| --- | --- |\n", + "| 귀속 판단 기준을 12개 이상 정의하고 가중치를 붙였다 | ( ) |\n", + "| 산식과 규칙 1~4를 빈칸 없이 정했다 | ( ) |\n", + "| 가설 3개의 점수를 계산하고 판단을 기록했다 | ( ) |\n", + "| 점수가 사람의 승인을 대신하지 않는다는 규칙이 있다 | ( ) |\n", + "| 레드팀 반박 3건에 대응하고 규칙을 보완했다 | ( ) |\n", + "\n", + "**\n", + "\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 05. 위장 깃발(False-flag) 및 대리 세력 개입 가능성 배제를 위한 교차검증 항목 도출\n", + "\n", + "### 📋 미션 배경 및 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "위장 깃발(False-flag) 작전 및 대리 세력(Proxy) 개입 가능성을 체계적으로 검증하는 교차 확인 항목을 도출한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **위장 깃발과 대리 세력 개입 가능성을 체계적으로 점검하는 교차검증 항목을 도출하고, 귀속 의견 상정·보류 분기점과 책임 직책을 확정한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **위장 깃발 점검 항목(성명 시각과 사건 시각의 정합성, 재사용 가능한 부품, 이득을 보는 제3자 가능성)과 대리 세력 점검 항목(지원 관계 정황, 지휘 연결 증거 유무)을 각각 6개 이상 설계하고 작성하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 5개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `sop/isr05_approval_thresholds_sop.md` 형태의 결과물을 완성한다.\n", + "\n", + "### 📑 실습자료 및 작성 양식\n", + "위장 깃발(False-flag) 및 대리 세력 개입 가능성 배제를 위한 교차검증 항목 도출**\n", + "\n", + "가. 제공 자료 1 - 위장 깃발·대리 세력 점검 질문 예시 [교육용가상자료]\n", + "\n", + "| **구분** | **질문 예시** | **확인에 쓰는 증거** |\n", + "| --- | --- | --- |\n", + "| **위장 깃발** | 책임 성명이 사건보다 먼저 작성된 정황은 없는가? | EV4·EV5 시각 대조 |\n", + "| **위장 깃발** | 사건으로 이득을 보는 제3자가 있는가? | 정황 분석 |\n", + "| **대리 세력** | 공격을 지원하거나 지시한 관계를 보여 주는 증거가 있는가? | 신호·진술 |\n", + "\n", + "가. 제공 자료 2 - 경계선 상황 5종\n", + "\n", + "| **묶음** | **상황** | **애매한 이유** |\n", + "| --- | --- | --- |\n", + "| **B1** | H1 점수가 높으나 지지 증거가 책임 성명과 유통 부품뿐 | 독립 유형이 1종 |\n", + "| **B2** | H1·H2 점수 차이 1점 | 차이가 임계 미만 |\n", + "| **B3** | 성명 시각 오류와 파일 시각 이상이 함께 있음 | 위장 가능성 미해소 |\n", + "| **B4** | 상급 부서가 '오늘 안에 주체를 정하라'고 요구 | 시간 압박 |\n", + "| **B5** | 승인 권한 직책이 부재, 대리 지정 없음 | 권한자 부재 |\n", + "\n", + "나. 작성 서식 1 - 교차검증 항목(위장 깃발 6개 이상, 대리 세력 6개 이상)\n", + "\n", + "| **번호** | **구분** | **점검 항목** | **확인 방법** | **결과 기록 칸** |\n", + "| --- | --- | --- | --- | --- |\n", + "| **1** | 위장 깃발 | | | |\n", + "| **2** | 위장 깃발 | | | |\n", + "| **3** | 위장 깃발 | | | |\n", + "| **4** | 위장 깃발 | | | |\n", + "| **5** | 위장 깃발 | | | |\n", + "| **6** | 위장 깃발 | | | |\n", + "| **7** | 대리 세력 | | | |\n", + "| **8** | 대리 세력 | | | |\n", + "| **9** | 대리 세력 | | | |\n", + "| **10** | 대리 세력 | | | |\n", + "| **11** | 대리 세력 | | | |\n", + "| **12** | 대리 세력 | | | |\n", + "\n", + "나. 작성 서식 2 - 의사결정 트리(빈칸을 채운다)\n", + "\n", + "| [분기 1] 모든 증거에 증명력 단계가 기재되어 있는가?\n", + " 아니오 → 보류 유지 + 단계 보완 (책임: ( ))\n", + " 예 ↓\n", + "[분기 2] 서로 다른 유형의 독립 증거가 ( )종 이상이고 1·2위 점수 차이가 ( )점 이상인가?\n", + " 아니오 → 보류 유지 + 추가 수집 요청 (책임: ( ))\n", + " 예 ↓\n", + "[분기 3] 위장 깃발·대리 세력 점검에서 미해소 항목이 있는가?\n", + " 예 → 한계를 명시하고 가설을 병기 (책임: ( ))\n", + " 아니오 ↓\n", + "[분기 4] 승인 권한 직책과 연락이 되는가?\n", + " 아니오 → 보류 유지 + 대체 연락 경로 (책임: ( ))\n", + " 예 → 사람의 승인 후 '귀속 의견 상정' (책임: ( )) |\n", + "| --- |\n", + "\n", + "나. 작성 서식 3 - 분기점·책임 직책 매핑표\n", + "\n", + "| **분기점** | **판정 조건(수치·관찰 기준)** | **'예'일 때** | **'아니오'일 때** | **책임 직책(가상)** |\n", + "| --- | --- | --- | --- | --- |\n", + "| **분기 1** | | | | |\n", + "| **분기 2** | | | | |\n", + "| **분기 3** | | | | |\n", + "| **분기 4** | | | | |\n", + "\n", + "나. 작성 서식 4 - 모의 결심 기록지(판단은 '귀속 의견 상정·보류 유지·추가 수집 요청' 중 하나)\n", + "\n", + "| **묶음** | **멈춘 분기점** | **판단** | **근거(자료 인용)** | **책임 직책** | **조원 합의** |\n", + "| --- | --- | --- | --- | --- | --- |\n", + "| **B1** | | | | | |\n", + "| **B2** | | | | | |\n", + "| **B3** | | | | | |\n", + "| **B4** | | | | | |\n", + "| **B5** | | | | | |\n", + "\n", + "| 귀속 의견 상정은 대응 결정이 아니다. 상정된 의견에도 근거 수준과 한계를 함께 적으며, 시간 압박이 있어도 독립 증거 요건과 위장·대리 점검은 줄이지 않는다. 실제 교전·대응 판단은 교전수칙과 지휘계통에 따른다. |\n", + "| --- |\n", + "\n", + "다. 점검표\n", + "\n", + "| **점검 항목** | **확인** |\n", + "| --- | --- |\n", + "| 위장 깃발 항목 6개 이상, 대리 세력 항목 6개 이상을 작성했다 | ( ) |\n", + "| 의사결정 트리 4개 분기점의 조건을 수치·관찰 기준으로 명시했다 | ( ) |\n", + "| 모든 분기점에 책임 직책을 매핑했다 | ( ) |\n", + "| 경계선 5종 모두에 판단과 근거를 기록했다 | ( ) |\n", + "| 사람의 승인 없이 의견이 상정되는 경로가 없다 | ( ) |\n", + "\n", + "**\n", + "\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 06. 지휘관 보고용 공격 주체 귀속 근거 요약 대시보드 및 브리핑 보고서 서식 작성\n", + "\n", + "### 📋 미션 배경 및 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "지휘관 보고용 공격 주체 귀속 근거 요약 대시보드 및 브리핑 서식을 제작한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **지휘관 보고용 귀속 근거 요약 대시보드와 브리핑 보고서 서식을 만들고, 사실·판단·조치·미확인을 분리하는 기록 규칙을 표준화한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **한눈에 보는 요약 대시보드(가설별 점수, 근거 수준, 반박 증거, 위장·대리 점검 결과, 현재 상태)와 보고서 서식 작성하여 원인을 분석하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 5개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `templates/isr05_aar_fact_action_template.md` 형태의 결과물을 완성한다.\n", + "\n", + "### 📑 실습자료 및 작성 양식\n", + "지휘관 보고용 공격 주체 귀속 근거 요약 대시보드 및 브리핑 보고서 서식 작성**\n", + "\n", + "가. 제공 자료 1 - 원문 분석 보고 3종(사실·판단이 섞여 있다) [교육용가상자료]\n", + "\n", + "| **보고** | **원문** |\n", + "| --- | --- |\n", + "| **보고 1** | 06:30 분석반. 집단 X의 소행이 틀림없음. 잔해 표식이 X형이고 성명도 나옴. 항적은 일부 안 보이나 문제없음. 사령부에 먼저 알림. |\n", + "| **보고 2** | 08:00 분석반. 성명 영상 시각이 이상하나 단순 오류인 듯. 집단 Y 가능성은 거의 없음. 승인은 아직 안 받음. |\n", + "| **보고 3** | 10:00 분석반. 제3자 위장은 명백히 아님. 이유는 표식이 X형이기 때문. 추가 수집은 불필요로 판단. |\n", + "\n", + "가. 제공 자료 2 - 단정 표현 목록(예시 2개, 나머지는 조별 작성)\n", + "\n", + "| **금지 표현** | **문제점** | **대체 표현** |\n", + "| --- | --- | --- |\n", + "| **소행이다, 틀림없다** | 근거 수준 없이 주체를 단정 | '가능성이 있다(근거 수준 3, 독립 유형 1종)' |\n", + "| **명백히 아님** | 배제 근거 없이 가설을 닫음 | '현재 근거로는 배제할 수 없다' |\n", + "| **문제없음** | | |\n", + "| **단순 오류인 듯** | | |\n", + "| **거의 없음** | | |\n", + "| **불필요** | | |\n", + "\n", + "나. 작성 서식 1 - 지휘관 요약 대시보드\n", + "\n", + "| **가설** | **점수** | **근거 수준(최고 단계)** | **독립 증거 유형 수** | **반박 증거** | **위장·대리 점검 결과** | **상태(상정·보류·추가 수집)** |\n", + "| --- | --- | --- | --- | --- | --- | --- |\n", + "| **H1 집단 X** | | | | | | |\n", + "| **H2 집단 Y** | | | | | | |\n", + "| **H3 제3자 위장** | | | | | | |\n", + "\n", + "나. 작성 서식 2 - 기록 서식(사실·판단·조치·미확인 분리)\n", + "\n", + "| **시각** | **확인된 사실(증거 번호)** | **판단과 근거(근거 수준)** | **취한 조치** | **미확인 사항** |\n", + "| --- | --- | --- | --- | --- |\n", + "| | | | | |\n", + "| | | | | |\n", + "| | | | | |\n", + "\n", + "나. 작성 서식 3 - 보고 3종 변환 결과\n", + "\n", + "| **보고** | **고쳐 쓴 사실 문장** | **고쳐 쓴 판단 문장(근거 수준 표기)** | **상태** | **조원 일치** |\n", + "| --- | --- | --- | --- | --- |\n", + "| **보고 1** | | | | |\n", + "| **보고 2** | | | | |\n", + "| **보고 3** | | | | |\n", + "\n", + "다. 점검표\n", + "\n", + "| **점검 항목** | **확인** |\n", + "| --- | --- |\n", + "| 대시보드에 점수, 근거 수준, 독립 유형 수, 반박 증거, 상태를 모두 넣었다 | ( ) |\n", + "| 기록 서식의 사실·판단·조치·미확인 칸이 분리되어 있다 | ( ) |\n", + "| 단정 표현을 6개 이상 정하고 대체 표현을 적었다 | ( ) |\n", + "| 보고 3종을 변환했고 사실 칸에 추측 표현이 0건이다 | ( ) |\n", + "| '확정', '소행이다' 표현을 쓰지 않았다 | ( ) |\n", + "\n", + "**\n", + "\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 07. 상충되는 잔해 정보 및 불완전한 레이더 궤적 상황 모의 귀속 판단 스트레스 테스트\n", + "\n", + "### 📋 미션 배경 및 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "상충하는 잔해 증거와 불완전한 레이더 궤적이 주어지는 모의 상황에서 귀속 판단 스트레스 테스트를 수행한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **서로 어긋나는 잔해 정보와 불완전한 레이더 궤적이 주어지는 모의 상황에서 귀속 판단이 흔들리지 않는지 스트레스 테스트로 확인하고 평가 루브릭을 만든다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **정해진 시간에 증거 카드(잔해 표식, 궤적 결측, 성명, 반박 증거)가 연달아 투입되고 일부는 서로 어긋나는 조건에서 조별로 가설별 점수와 의견 상태를 정 수행하고 검증하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 5개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `rubric/isr05_evaluation_rubric.md` 형태의 결과물을 완성한다.\n", + "\n", + "### 📑 실습자료 및 작성 양식\n", + "상충되는 잔해 정보 및 불완전한 레이더 궤적 상황 모의 귀속 판단 스트레스 테스트**\n", + "\n", + "가. 제공 자료 1 - 상충 증거 카드 투입 순서(통제관용) [교육용가상자료]\n", + "\n", + "| **경과 시간** | **카드** | **내용** | **통제관 메모(평가자만)** |\n", + "| --- | --- | --- | --- |\n", + "| **0:00** | S0 | 사건 개요와 가설 3개 제시. 25분 안에 가설별 점수 산출. | - |\n", + "| **3:00** | S1 | 잔해 사진 1: 표식 형식 'K-계열' | H1·H2 모두 가능한 형식 |\n", + "| **7:00** | S2 | 레이더 항적: 북서 진입, 3분 결측 | 결측 구간 해석 분기 |\n", + "| **11:00** | S3 | 책임 성명(집단 X 명의) 게시 | 검증 대상 주장 |\n", + "| **14:00** | S4 | 잔해 사진 2: 표식이 다른 계열(Y형 가능) | 상충 발생 |\n", + "| **18:00** | S5 | 성명 영상 파일의 시각 이상 보고 | 위장 가능성 단서 |\n", + "| **21:00** | S6 | 상급 부서: '지금 주체를 정해 달라' | 압박(보류 원칙 점검) |\n", + "\n", + "가. 제공 자료 2 - 역할: 분석 책임(조장) 1명, 기록자 1명, 평가자 2명(서로 상의 금지), 통제관(교관)은 카드를 시간에 맞춰 읽는다.\n", + "\n", + "나. 작성 서식 1 - 5영역 4등급 루브릭(예시 1행 포함)\n", + "\n", + "| **영역** | **탁월** | **우수** | **보통** | **미흡** |\n", + "| --- | --- | --- | --- | --- |\n", + "| **보류 원칙 준수** | 압박에도 독립 유형이 부족하면 보류를 유지하고 추가 수집 요청과 근거 수준을 기록함 | 보류를 유지하고 추가 수집을 요청함 | 보류는 유지했으나 근거 수준 표기가 빠짐 | 압박에 따라 근거 없이 귀속함 |\n", + "| **증거 분류** | | | | |\n", + "| **상충 처리** | | | | |\n", + "| **위장·대리 점검** | | | | |\n", + "| **기록** | | | | |\n", + "\n", + "나. 작성 서식 2 - 평가자 간 채점 일치 확인(탁월 4, 우수 3, 보통 2, 미흡 1)\n", + "\n", + "| **조 / 영역** | **증거 분류** | **상충 처리** | **위장·대리** | **보류 원칙** | **기록** | **일치 칸 수** |\n", + "| --- | --- | --- | --- | --- | --- | --- |\n", + "| **1조 (A / B)** | / | / | / | / | / | / 5 |\n", + "| **2조 (A / B)** | / | / | / | / | / | / 5 |\n", + "| **3조 (A / B)** | / | / | / | / | / | / 5 |\n", + "| **4조 (A / B)** | / | / | / | / | / | / 5 |\n", + "\n", + "일치율 = 두 평가자 점수가 같은 칸 수 / 전체 칸 수 x 100 = ( )% (목표 80% 이상). 점수 차이가 2점 이상인 칸은 루브릭 문구를 고친다.\n", + "\n", + "나. 작성 서식 3 - 단정 처리 기록: 근거 수준 표기 없이 귀속을 단정한 판단 ( )건, 근거 수준과 한계를 적은 판단 ( )건, 보류 유지 ( )건\n", + "\n", + "다. 점검표\n", + "\n", + "| **점검 항목** | **확인** |\n", + "| --- | --- |\n", + "| 루브릭 5영역 x 4등급 20칸을 모두 관찰 가능한 행동으로 채웠다 | ( ) |\n", + "| 평가자 2명이 서로 상의하지 않고 독립 채점했다 | ( ) |\n", + "| 일치율을 계산했고 80% 이상이거나, 미만이면 루브릭을 고쳐 다시 채점했다 | ( ) |\n", + "| 근거 수준 표기 없이 귀속을 단정한 판단이 0건이다 | ( ) |\n", + "| '미흡' 영역에 보충 학습 방법을 1개 이상 적었다 | ( ) |\n", + "\n", + "**\n", + "\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 08. AAR을 통한 과거 공격 귀속 오판 사례 분석 및 증거 검증 기준 고도화\n", + "\n", + "### 📋 미션 배경 및 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "AAR 사후검토를 통해 과거 귀속 판단 오판 사례를 분석하고 증거 검증 기준을 정밀화한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **AAR로 과거 귀속 오판 사례의 원인을 분석하고, 레드팀 비판으로 증거 검증 기준의 취약한 전제를 찾아 기준을 정밀화한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **가상 AAR 일지 2건(선전 성명을 근거로 한 성급한 귀속, 재사용 가능한 부품을 결정적 증거로 본 오판)을 시간순으로 재구성하고 원인을 사람·절차·정보·통신으로 나눈다 과정을 진행하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 5개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `reports/isr05_redteam_critique_report.md` 형태의 결과물을 완성한다.\n", + "\n", + "### 📑 실습자료 및 작성 양식\n", + "AAR을 통한 과거 공격 귀속 오판 사례 분석 및 증거 검증 기준 고도화**\n", + "\n", + "가. 제공 자료 1 - 가상 AAR 일지 A(선전 성명 근거의 성급한 귀속) [교육용가상자료]\n", + "\n", + "| **시각** | **기록** |\n", + "| --- | --- |\n", + "| **04:00** | 책임 성명 게시. 분석관이 '집단 X 소행'으로 1차 보고. |\n", + "| **05:30** | 성명에 사건 시각 오류가 있다는 지적이 있었으나 '단순 실수'로 기록. |\n", + "| **08:00** | 상급 보고서에 '집단 X 소행 확인'으로 기재. |\n", + "| **다음 날** | 가상 후속 조사에서 성명이 사건 전에 작성된 것으로 밝혀짐(위장 가능성). |\n", + "\n", + "가. 제공 자료 2 - 가상 AAR 일지 B(재사용 가능한 부품을 결정적 증거로 본 오판) [교육용가상자료]\n", + "\n", + "| **시각** | **기록** |\n", + "| --- | --- |\n", + "| **09:00** | 잔해에서 'K-계열' 표식 확인. 담당이 '집단 X 전용'이라고 판단. |\n", + "| **09:40** | 같은 형식이 시장에서 유통된다는 동료 의견이 있었으나 일정상 반영되지 않음. |\n", + "| **11:00** | 귀속 의견이 지휘관에게 전달되어 대응 논의에 사용됨. |\n", + "| **다음 날** | 집단 Y도 같은 형식 부품을 사용한 사실이 확인됨(가상). |\n", + "\n", + "나. 작성 서식 1 - AAR 원인 분석표(비난 없이 사실 중심)\n", + "\n", + "| **사례** | **무엇이 계획되었나** | **실제로 무슨 일이 있었나** | **원인(사람·절차·정보·통신)** | **개선안** |\n", + "| --- | --- | --- | --- | --- |\n", + "| **A** | | | | |\n", + "| **A** | | | | |\n", + "| **B** | | | | |\n", + "| **B** | | | | |\n", + "\n", + "나. 작성 서식 2 - 레드팀 쟁점·대응표(5건 이상, 예시 1건)\n", + "\n", + "| **번호** | **취약한 전제** | **레드팀 비판** | **작성 팀 반박 또는 수정안** | **경고 문구** |\n", + "| --- | --- | --- | --- | --- |\n", + "| **1** | 책임 주장 성명은 주체를 가리킨다 | 성명은 위장·선전일 수 있고 사건 전에 작성될 수도 있다 | 성명은 최대 2단계로 평가하고 독립 증거 없이는 지지 점수를 주지 않는다 | '성명만으로는 귀속 의견을 상정하지 않는다' |\n", + "| **2** | | | | |\n", + "| **3** | | | | |\n", + "| **4** | | | | |\n", + "| **5** | | | | |\n", + "| **6** | | | | |\n", + "\n", + "나. 작성 서식 3 - 반대 방향 위험 점검: 귀속 의견이 너무 늦어 지휘관 판단에 쓰이지 못할 위험과 이를 줄이는 방법(잠정 의견 + 근거 수준 병기, 시간 목표)을 3줄 이내로 적는다.\n", + "\n", + "나. 작성 서식 4 - 수정 반영 계획표\n", + "\n", + "| **번호** | **수정할 문서·조항** | **수정 내용** | **담당(가상 직책)** | **완료 기한** |\n", + "| --- | --- | --- | --- | --- |\n", + "| **1** | | | | |\n", + "| **2** | | | | |\n", + "| **3** | | | | |\n", + "| **4** | | | | |\n", + "| **5** | | | | |\n", + "\n", + "다. 점검표\n", + "\n", + "| **점검 항목** | **확인** |\n", + "| --- | --- |\n", + "| AAR 일지 2건을 시간순으로 재구성하고 계획과 실제의 차이를 적었다 | ( ) |\n", + "| 원인을 사람·절차·정보·통신으로 분류하고 개인 비난 표현이 없다 | ( ) |\n", + "| 레드팀 쟁점을 5건 이상 도출했다 | ( ) |\n", + "| 모든 쟁점에 반박 또는 수정안을 적었다(100%) | ( ) |\n", + "| 취약한 전제마다 경고 문구를 달았다 | ( ) |\n", + "| 판단 지연의 반대 방향 위험을 다뤘다 | ( ) |\n", + "\n", + "**\n", + "\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 09. 피드백 반영 공격 주체 귀속 매트릭스 튜닝 및 민감도 분석\n", + "\n", + "### 📋 미션 배경 및 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "피드백을 반영하여 공격 주체 귀속 매트릭스의 임계값과 가중치를 재보정한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **M7·M8 피드백을 반영해 가중치와 임계값을 재보정하고 민감도 분석으로 결론의 안정성을 확인한 뒤, 지휘관 1페이지 브리핑으로 정리한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **가중치를 각각 20% 높이거나 낮췄을 때, 상정 임계값을 바꿨을 때 가설별 점수와 의견 상태가 어떻게 달라지는지 민감도 표 작성하여 원인을 분석하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 5개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `briefing/isr05_commander_1page_brief.md(pdf)` 형태의 결과물을 완성한다.\n", + "\n", + "### 📑 실습자료 및 작성 양식\n", + "피드백 반영 공격 주체 귀속 매트릭스 튜닝 및 민감도 분석**\n", + "\n", + "가. 제공 자료 - 민감도 분석 기초값(M4 산식 기준, 조별 M4 점수표를 사용한다). 재검증 대상 7건: M5 경계선 B1~B5, M8 AAR 사례 A·B.\n", + "\n", + "나. 작성 서식 1 - 민감도 분석표(가중치 +20% / -20%, 임계 점수 차이 변경)\n", + "\n", + "| **조건** | **H1 점수** | **H2 점수** | **H3 점수** | **1·2위 차이** | **의견 상태** | **결론 안정성(안정·불안정)** |\n", + "| --- | --- | --- | --- | --- | --- | --- |\n", + "| **기준값** | | | | | | |\n", + "| **성명 관련 가중치 +20%** | | | | | | |\n", + "| **성명 관련 가중치 -20%** | | | | | | |\n", + "| **물리 증거 가중치 +20%** | | | | | | |\n", + "| **물리 증거 가중치 -20%** | | | | | | |\n", + "| **임계 차이 ( ) → ( )** | | | | | | |\n", + "\n", + "나. 작성 서식 2 - 재보정 전후 비교표\n", + "\n", + "| **항목** | **재보정 전** | **재보정 후** | **변경 이유(근거 미션·민감도)** |\n", + "| --- | --- | --- | --- |\n", + "| **증명력 단계 기준** | | | |\n", + "| **가중치** | | | |\n", + "| **임계 점수 차이** | | | |\n", + "| **독립 유형 요건** | | | |\n", + "| **위장·대리 점검** | | | |\n", + "\n", + "나. 작성 서식 3 - 재검증 결과표\n", + "\n", + "| **사례** | **재보정 전 판단** | **재보정 후 판단** | **조원 일치(전 → 후)** | **판단 시간(분, 전 → 후)** | **비고** |\n", + "| --- | --- | --- | --- | --- | --- |\n", + "| **B1** | | | | | |\n", + "| **B2** | | | | | |\n", + "| **B3** | | | | | |\n", + "| **B4** | | | | | |\n", + "| **B5** | | | | | |\n", + "| **A** | | | | | |\n", + "| **B** | | | | | |\n", + "\n", + "나. 작성 서식 4 - 지휘관 1페이지 브리핑 틀(BLUF)\n", + "\n", + "| **결론(BLUF)** | (한 문장으로 현재 귀속 판단 상태와 근거 수준. 주체를 단정하지 않는다) |\n", + "| --- | --- |\n", + "\n", + "| **핵심 결심 사항** | 1) ( ) 2) ( ) |\n", + "| --- | --- |\n", + "\n", + "| **위험 요소(신호등)** | 빨강: ( )\n", + "노랑: ( )\n", + "초록: ( ) |\n", + "| --- | --- |\n", + "\n", + "| **대안 3종** | 대안 1 ( ) / 대안 2 ( ) / 대안 3 ( ) - 각 장단점 한 줄 |\n", + "| --- | --- |\n", + "\n", + "| **결론의 안정성** | (민감도 분석 요약) |\n", + "| --- | --- |\n", + "\n", + "| **근거** | 가설별 점수, 독립 증거 유형 수, 반박 증거 |\n", + "| --- | --- |\n", + "\n", + "나. 작성 서식 5 - 브리핑 리허설 기록: 발표 시간 ( )분(목표 3분 이내) / 분량 ( )쪽(A4 1쪽) / 질의 답변 ( )건(3건 이상) / 첫 문장이 결론인가 ( )\n", + "\n", + "다. 점검표\n", + "\n", + "| **점검 항목** | **확인** |\n", + "| --- | --- |\n", + "| 민감도 분석표의 6개 조건을 모두 계산했다 | ( ) |\n", + "| 가중치 변화로 의견이 뒤집히는 사례를 '불안정'으로 표시하고 보류 대상으로 정했다 | ( ) |\n", + "| 재보정 전후 비교표에 변경 이유를 적었다 | ( ) |\n", + "| 재검증 7건 모두 판단을 비교했다 | ( ) |\n", + "| 브리핑이 결론부터 시작하고 A4 1페이지에 들어가며 3분 안에 발표했다 | ( ) |\n", + "\n", + "**\n", + "\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 10. 드론·미사일 공격 주체 귀속 근거 평가 매트릭스 표준 지침서 최종 완성\n", + "\n", + "### 📋 미션 배경 및 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "드론·미사일 공격 주체 귀속 근거 매트릭스 설계 표준 지침서를 최종 완성한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **앞 미션의 결과물을 통합해 드론·미사일 공격 주체 귀속 근거 평가 매트릭스 표준 지침서를 8개 장과 부록으로 완성한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **목적·적용 범위, 용어 정의, 귀속 판단 요소와 증거 분류, 증거 매트릭스와 가중치, 교차검증(위장 깃발·대리 세력), 상정·보류와 승인, 기록·보고, 훈련·평가·개정의 8개 장과 부록(증거 6단계, 매트릭스 서식, 의사결정 트리, 보고 서식, 루브릭)을 편집 수행하고 검증하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 5개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `manual/isr05_standard_operational_manual.md` 형태의 결과물을 완성한다.\n", + "\n", + "### 📑 실습자료 및 작성 양식\n", + "드론·미사일 공격 주체 귀속 근거 평가 매트릭스 표준 지침서 최종 완성**\n", + "\n", + "가. 제공 자료 1 - 지침서 8개 장 구성과 가져올 결과물\n", + "\n", + "| **장** | **제목** | **포함 내용** | **가져올 결과물** |\n", + "| --- | --- | --- | --- |\n", + "| **1** | 목적과 적용 범위 | 목적, 적용·비적용 상황, 귀속 의견과 대응 결정의 구분, 교전수칙과의 관계 | M1 |\n", + "| **2** | 용어 정의 | 귀속, 근거 수준, 독립 증거, 위장 깃발, 대리 세력, 보류 | M1~M3 |\n", + "| **3** | 귀속 판단 요소와 증거 분류 | 증거 유형, 증명력 6단계, 평가 지표 | M1·M2 |\n", + "| **4** | 증거 매트릭스와 가중치 | 가설 매트릭스, 기준 12개, 점수 산식, 정보 공백 | M3·M4 |\n", + "| **5** | 교차검증 | 위장 깃발·대리 세력 점검 항목 | M5 |\n", + "| **6** | 상정·보류와 승인 | 의사결정 트리, 권한과 책임, 민감도·재보정 | M5·M9 |\n", + "| **7** | 기록·보고 | 요약 대시보드, 기록 서식, 단정 표현 금지, 브리핑 | M6·M9 |\n", + "| **8** | 훈련·평가·개정 | 스트레스 훈련, 루브릭, AAR, 개정 관리 | M7·M8 |\n", + "\n", + "가. 제공 자료 2 - 모의 심의 역할 카드\n", + "\n", + "| **역할** | **검토 질문** |\n", + "| --- | --- |\n", + "| **교리 역할** | 증거 유형·단계 정의가 일관되는가? 용어를 신병도 이해할 수 있는가? |\n", + "| **법무 역할** | '귀속은 단정하지 않으며 최종 판단은 사람이, 대응은 교전수칙·지휘계통에 따라'가 절차마다 드러나는가? 국제법 쟁점이 법무 검토로 연계되는가? |\n", + "| **작전 역할** | 현장에서 시간 압박 속에 쓸 수 있는가? 보류가 길어 지휘관에게 아무 정보도 못 주지 않는가? |\n", + "\n", + "나. 작성 서식 1 - 근거 표기 점검표(조항마다 작성)\n", + "\n", + "| **조항** | **기준 내용 요약** | **근거(미션·가상 자료)** | **표기 여부** |\n", + "| --- | --- | --- | --- |\n", + "| **1-1** | | | |\n", + "| **2-1** | | | |\n", + "| **3-1** | | | |\n", + "| **4-1** | | | |\n", + "| **5-1** | | | |\n", + "| **6-1** | | | |\n", + "| **7-1** | | | |\n", + "| **8-1** | | | |\n", + "\n", + "나. 작성 서식 2 - 모의 심의 지적 사항 반영표\n", + "\n", + "| **번호** | **역할** | **지적 사항** | **반영 내용** | **반영 위치(장·조)** |\n", + "| --- | --- | --- | --- | --- |\n", + "| **1** | | | | |\n", + "| **2** | | | | |\n", + "| **3** | | | | |\n", + "| **4** | | | | |\n", + "| **5** | | | | |\n", + "| **6** | | | | |\n", + "\n", + "나. 작성 서식 3 - 개정 관리: 개정 주기 ( ), 의견 수렴 방법 ( ), 개정 책임 직책(가상) ( ), 개정 이력 기록 위치 ( )\n", + "\n", + "다. 점검표\n", + "\n", + "| **점검 항목** | **확인** |\n", + "| --- | --- |\n", + "| 8개 장과 부록을 모두 완성했다 | ( ) |\n", + "| 모든 기준 조항에 근거(미션·가상 자료)를 표기했다 | ( ) |\n", + "| 교리·법무·작전 역할의 지적 사항을 반영하고 반영 위치를 적었다 | ( ) |\n", + "| '귀속은 단정하지 않으며 최종 판단은 사람이 하고 실제 대응은 교전수칙·지휘계통에 따른다'는 문구가 1장과 6장에 있다 | ( ) |\n", + "| 연 1회 개정 주기와 의견 수렴 방법을 명시했다 | ( ) |\n", + "| 실제 사건·집단·부대·무기 제원을 쓰지 않았다 | ( ) |\n", + "\n", + "**4. 평가 채점표**\n", + "\n", + "미션별 평가 기준은 시나리오의 평가 항목과 같다. 평가자는 기준 충족 여부를 PASS/FAIL로 표시하고 FAIL이면 사유를 비고에 적는다.\n", + "\n", + "| **미션** | **구분** | **채점 기준** | **판정** | **비고** |\n", + "| --- | --- | --- | --- | --- |\n", + "| **M1** | 필수 | 귀속 요소 분석·문서 골격: 가상 사례 5건 이상을 검토해 증거 유형별 귀속 판단 요소와 오류 가능성을 비교했는가, 지침서 8개 장 목차 골격을 100% 정의하고 지휘관 결심 의제 순으로 배열했는가. | PASS / FAIL | |\n", + "| **M2** | 필수 | 증거 6단계 분류·평가 지표: 증거 증명력 기준 6단계를 완비하고 평가 지표 10개 이상을 정의했는가, 가상 증거 5종에 대한 조원 간 단계 일치가 4건 이상이고 확증 편향·선전 주장 편향을 막는 규칙을 명시했는가. | PASS / FAIL | |\n", + "| **M3** | 필수 | 다차원 증거 매트릭스·불확실성: 정보 공백 요인 8개 이상과 영향도 매트릭스를 도출했는가, 가설 3개 x 증거 8개 매트릭스를 빈칸 없이 채우고 단정 표현 금지와 판단 한계를 객관적으로 명시했는가. | PASS / FAIL | |\n", + "| **M4** | 선택 | 귀속 기준 12개·가중치 산식: 귀속 판단 기준 12개 이상 항목화와 가중치 산식을 100% 정립했는가, 가상 사건 가설 3개의 점수를 계산하고 레드팀 반박에 대응해 반박 가능성 방어 논리를 기록했는가. | PASS / FAIL | |\n", + "| **M5** | 필수 | 교차검증 항목·분기점·책임 매핑: 상정·보류 분기점 조건 100% 명시와 책임 직책 매핑을 완료했는가, 경계선 5종 모두에 판단과 근거가 기록되고 교전수칙·인도주의 원칙 준수와 사람의 최종 승인(단정 금지)을 지켰는가. | PASS / FAIL | |\n", + "| **M6** | 선택 | 요약 대시보드·기록 서식: 기록 분류 규칙을 100% 표준화하고 혼동 유발 단어 금지 목록을 포함했는가, 가상 보고 3종을 서식으로 옮겼을 때 사실·판단 혼동이 0건인가. | PASS / FAIL | |\n", + "| **M7** | 필수 | 상충 증거 스트레스·평가 루브릭: 5개 영역 4등급 기준 정의율 100%, 평가자 2명의 채점 일치율 80% 이상, 근거 수준 표기 없이 귀속을 단정한 판단이 0건인가. | PASS / FAIL | |\n", + "| **M8** | 선택 | AAR 오판 분석·레드팀 검토: 비판적 쟁점 5건 이상을 도출하고 쟁점별 반박·수정 대안을 100% 수립했는가, 취약한 전제에 경고 문구를 명시하고 판단 지연의 반대 위험을 다뤘는가. | PASS / FAIL | |\n", + "| **M9** | 필수 | 재보정·민감도·1페이지 브리핑: 핵심 결심 사항, 위험 요소, 대안 3종이 A4 1페이지 안에 완결되었는가, 가중치·임계값 변화에 따른 민감도 표와 재검증 7건 비교를 제시하고 결론 먼저(BLUF) 원칙으로 3분 안에 전달했는가. | PASS / FAIL | |\n", + "| **M10** | 필수 | 표준 지침서 최종 완성: 8개 장과 부록을 100% 완성하고 모든 기준에 근거(미션·가상 자료)를 표기했는가, 모의 심의 지적 사항을 반영하고 '귀속은 단정하지 않으며 최종 판단은 사람이 하고 실제 대응은 교전수칙·지휘계통에 따른다'는 문구를 명시했는가. | PASS / FAIL | |\n", + "\n", + "| **판정 기준** | 필수 7개 미션(M1·M2·M3·M5·M7·M9·M10)이 모두 PASS여야 최종 PASS로 인정한다. 선택 미션(M4·M6·M8)은 가점·보완 의견에 반영한다. |\n", + "| --- | --- |\n", + "\n", + "| **공통 감점** | 실제 사건·집단·부대명·무기 제원·실존 인물 정보를 사용한 경우, 부대 자료를 외부 AI 서비스에 입력한 경우 해당 미션은 FAIL로 처리한다. |\n", + "| --- | --- |\n", + "\n", + "| **주제 안전 기준** | 근거 수준 없이 귀속을 단정하거나 사람의 승인을 대신하는 문장이 있거나 지침서에 단정 금지·사람 최종 판단·교전수칙 우선 문구가 없으면 M5·M10은 FAIL로 처리한다. |\n", + "| --- | --- |\n", + "\n", + "| **최종 판정 결과** | ( PASS / FAIL ) 평가자 서명: ( ) |\n", + "| --- | --- |\n", + "\n", + "| 안전 원칙: 본 자료의 증거 단계, 가중치, 점수, 의사결정 트리는 학습용 가상 기준이다. 귀속 의견은 대응 결정을 대신하지 않으며 최종 판단은 사람이 내리고 실제 교전·대응 판단은 교전수칙과 지휘계통에 따른다. 실제 부대 자료를 입력하거나 부대 내에서 촬영하지 않는다. |\n", + "| --- |\n", + "\n" + ] + } + ], + "metadata": { + "colab": { + "provenance": [] + }, + "kernelspec": { + "display_name": "Python 3", + "name": "python3" + }, + "language_info": { + "name": "python" + } + }, + "nbformat": 4, + "nbformat_minor": 0 +} \ No newline at end of file diff --git a/uploads/ISR06/ISR06_workbook.ipynb b/uploads/ISR06/ISR06_workbook.ipynb new file mode 100644 index 0000000..638ec5a --- /dev/null +++ b/uploads/ISR06/ISR06_workbook.ipynb @@ -0,0 +1,957 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# 📋 [ISR06] 다출처 ISR 보고서 신뢰도 등급·교차검증 절차\n", + "### 실습 워크북 & SOP 가이드 (Google Colab / Jupyter)\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/ISR06/ISR06_workbook.ipynb)\n", + "\n", + "---\n", + "\n", + "## 📌 1. 프로젝트 개요 및 배경\n", + "\n", + "| 항목 | 내용 |\n", + "| :--- | :--- |\n", + "| **과정 분류** | 정보·감시·정찰(ISR) (Level 2) |\n", + "| **학습 소요 시간** | 10개 미션 (5일 / 총 27.0시간) |\n", + "| **실습 유형** | 작전절차(SOP), 체크리스트, 모의훈련, AAR 사후검토 실습 |\n", + "\n", + "### 📖 프로젝트 목표\n", + "러시아-우크라이나 전쟁과 미국-이란 간 긴장 국면에서는 인간정보(HUMINT), 신호정보(SIGINT), 영상정보(IMINT), 공개출처정보(OSINT)가 동시에 쏟아지면서, 서로 어긋나는 첩보와 의도적으로 흘린 기만 정보, 한 출처를 여러 번 되풀이 인용한 보고가 판단을 흔드는 일이 반복되었다. 이런 상황에서는 출처가 얼마나 믿을 만한지와 정보 자체가 얼마나 정확한지를 따로 평가하고, 단일 출처만으로 결론을 내리지 않는 절차가 분석의 기본이 된다. 본 프로젝트에서는 교육용 가상 지역과 가상 첩보 보고를 사용해 다출처 보고서의 구조 분석, 출처 신뢰성 A~F와 정보 정확성 1~6을 결합한 등급 매트릭스 설계, 상충 정보 교차검증 기준, 승인·보류 판단 지침, 모의 교차검증, 기만 주입 스트레스 테스트, AAR, 보정과 1페이지 브리핑을 차례로 수행한다. 최종 목표는 근거·책임 직책·기록 요건이 분명한 다출처 ISR 신뢰도 등급화 및 교차검증 표준운용절차(SOP)를 문서로 완성하는 것이다.\n", + "\n", + "> **⚠️ [교육용 가상 자료 원칙]**\n", + "> - 본 워크북의 모든 자료는 안전 규정에 따라 **교육용 가상 자료**로 구성되어 있습니다.\n", + "> - 실제 군사 기밀이나 부대 세부 인적·작전 정보는 포함되지 않습니다.\n", + "\n", + "---\n", + "\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 01. 다출처(HUMINT, SIGINT, OSINT, IMINT) ISR 첩보 보고서 구조 및 수집 체계 분석\n", + "\n", + "### 📋 미션 배경 및 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "인간정보(HUMINT), 신호정보(SIGINT), 영상정보(IMINT), 오픈소스정보(OSINT) 다출처 첩보 보고서의 구조와 수집 환경을 분석한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **HUMINT·SIGINT·IMINT·OSINT 다출처 첩보 보고서의 구조와 출처별 수집 환경의 차이를 분석하고, SOP 전체 문서 골격을 정의한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **실습자료의 가상 첩보 보고 사례 5건을 읽고 각 보고가 어느 출처 유형인지, 보고서에 어떤 항목(출처 설명, 수집 시각, 내용, 신뢰도 표기, 한계)이 들어 있는지 비교표로 정리하고 규격화하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 5개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `docs/isr06_doc_framework.md 및 분석 개요서` 형태의 결과물을 완성한다.\n", + "\n", + "### 📑 실습자료 및 작성 양식\n", + "다출처(HUMINT, SIGINT, OSINT, IMINT) ISR 첩보 보고서 구조 및 수집 체계 분석**\n", + "\n", + "가. 제공 자료 - 가상 첩보 보고 사례 5건 [교육용가상자료]\n", + "\n", + "| **보고** | **유형** | **내용 요약** | **보고서에 있는 항목** | **눈에 띄는 한계** |\n", + "| --- | --- | --- | --- | --- |\n", + "| **C1** | HUMINT | H-1: 'D일 오전 나-3 교량 북단에 군용 차량 10여 대가 정차했다' | 출처 코드, 접수 시각, 내용 | 관측 시각이 적히지 않음, 전달에 6시간 소요 |\n", + "| **C2** | SIGINT | S-1: 가-4 일대 통신량이 평소의 3배로 증가(요약) | 수집 시각 구간, 요약, 수집 구역 | 통신 내용 불명, 통신량 증가의 원인 미기재 |\n", + "| **C3** | IMINT | I-1: 나-3 교량 일대 차량형 물체 8개 판독(영상 시각 09:10) | 영상 시각, 해상도 등급, 판독 의견 | 구름 일부, 차량 종류는 판독 불가 |\n", + "| **C4** | OSINT | O-1: 공개 게시물 '교량 앞에 군용 행렬'(사진 1장) | 게시 시각, 게시자 계정 설명 | 촬영 시각·장소 미확인, 이전 게시물 재사용 가능성 |\n", + "| **C5** | HUMINT | H-2: 'H-1 말로는 교량 쪽이 위험하다고 한다' | 출처 코드, 접수 시각 | H-1을 전해 들은 2차 전달(독립 출처 아님) |\n", + "\n", + "나. 작성 서식 1 - 출처 유형별 보고서 구조·약점 비교표\n", + "\n", + "| **출처 유형** | **보고서에 꼭 있어야 할 항목** | **생기기 쉬운 약점** | **약점을 줄이는 방법** |\n", + "| --- | --- | --- | --- |\n", + "| **HUMINT** | | | |\n", + "| **SIGINT** | | | |\n", + "| **IMINT** | | | |\n", + "| **OSINT** | | | |\n", + "\n", + "나. 작성 서식 2 - SOP 목차 골격(지휘관 결심 의제 순으로 배열)\n", + "\n", + "| **순서** | **장 제목** | **담을 핵심 내용** | **지휘관이 이 장에서 얻는 판단 정보** |\n", + "| --- | --- | --- | --- |\n", + "| **제1장** | | | |\n", + "| **제2장** | | | |\n", + "| **제3장** | | | |\n", + "| **제4장** | | | |\n", + "| **제5장** | | | |\n", + "| **제6장** | | | |\n", + "| **제7장** | | | |\n", + "| **제8장** | | | |\n", + "\n", + "다. 점검표\n", + "\n", + "| **점검 항목** | **확인** |\n", + "| --- | --- |\n", + "| 가상 보고 사례 5건을 모두 검토하고 출처 유형을 구분했다 | ( ) |\n", + "| 출처 유형 4가지 모두에 필수 항목과 약점을 적었다 | ( ) |\n", + "| C5처럼 독립 출처가 아닌 재전달 보고를 찾아 표시했다 | ( ) |\n", + "| SOP 목차 8개 장의 제목과 핵심 내용을 정의했다 | ( ) |\n", + "| 목차를 지휘관이 먼저 알아야 할 의제 순으로 배열하고 이유를 한 줄로 적었다 | ( ) |\n", + "\n", + "**\n", + "\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 02. 다출처 첩보 신뢰도(출처 신뢰성·정보 정확성) 6단계 등급 매트릭스 설계\n", + "\n", + "### 📋 미션 배경 및 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "출처의 신뢰성(A~F) 및 정보의 정확성(1~6)을 결합한 6×6 다출처 신뢰도 평가 매트릭스를 설계한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **출처 신뢰성(A~F)과 정보 정확성(1~6)을 결합한 6x6 신뢰도 평가 매트릭스를 설계하고, 평가 지표와 편향 방지 장치를 함께 정한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **출처 신뢰성 6단계(A~F)와 정보 정확성 6단계(1~6)의 판정 기준을 관찰 가능한 문장으로 작성하고, 두 축을 따로 평가한 뒤 결합 코드(예: B2)로 표기하는 방식을 정 수행하고 검증하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 5개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `framework/isr06_source_reliability_matrix.md` 형태의 결과물을 완성한다.\n", + "\n", + "### 📑 실습자료 및 작성 양식\n", + "다출처 첩보 신뢰도(출처 신뢰성·정보 정확성) 6단계 등급 매트릭스 설계**\n", + "\n", + "가. 제공 자료 1 - 가상 보고 5종 [교육용가상자료]\n", + "\n", + "| **보고** | **출처** | **내용** | **출처 정보** | **정보 확인 상태** |\n", + "| --- | --- | --- | --- | --- |\n", + "| **R1** | H-1(협조자, 과거 보고 8건 중 7건 적중) | D일 오전 나-3 교량 북단에 차량 10여 대 정차 | 직접 관측, 시각 미기재 | 다른 수단 확인 없음 |\n", + "| **R2** | I-1(영상 판독팀) | 나-3 교량 일대 차량형 물체 8개(09:10) | 영상 해상도 보통, 구름 일부 | R1과 위치 일치 |\n", + "| **R3** | O-1(공개 게시물, 계정 신원 불명) | 교량 앞 군용 행렬 사진 | 촬영 시각·장소 미확인 | 이전 게시물 재사용 가능성 |\n", + "| **R4** | S-1(통신 요약) | 가-4 일대 통신량 증가 | 수집 구간만 기재 | 차량 이동과의 연관 미확인 |\n", + "| **R5** | H-2(신규 협조자, 검증 이력 없음) | H-1의 말을 전해 들었다며 같은 내용 보고 | 2차 전달 | 독립 확인 아님 |\n", + "\n", + "가. 제공 자료 2 - 6x6 등급 틀. 단계 이름은 고정하고 판정 기준을 조별로 작성한다.\n", + "\n", + "| **출처 신뢰성** | **이름** | **판정 기준(조별 작성)** | **정보 정확성** | **이름** | **판정 기준(조별 작성)** |\n", + "| --- | --- | --- | --- | --- | --- |\n", + "| **A** | 완전 신뢰 | | 1 | 확인됨 | |\n", + "| **B** | 대체로 신뢰 | | 2 | 거의 확실 | |\n", + "| **C** | 다소 신뢰 | | 3 | 가능성 있음 | |\n", + "| **D** | 대체로 불신 | | 4 | 의심 | |\n", + "| **E** | 불신 | | 5 | 가능성 낮음 | |\n", + "| **F** | 판단 불가 | | 6 | 판단 불가 | |\n", + "\n", + "나. 작성 서식 1 - 평가 지표(10개 이상, 예시 2개 포함)\n", + "\n", + "| **번호** | **평가 지표** | **어느 축에 쓰는가** | **확인 방법(관찰 가능한 기준)** |\n", + "| --- | --- | --- | --- |\n", + "| **1** | 출처의 과거 보고 적중 이력 | 출처 신뢰성 | 과거 보고 건수 대비 확인된 건수를 기록 |\n", + "| **2** | 관측 시각이 기재되어 있는가 | 정보 정확성 | 보고서에 관측 시각 칸이 채워져 있으면 충족 |\n", + "| **3** | | | |\n", + "| **4** | | | |\n", + "| **5** | | | |\n", + "| **6** | | | |\n", + "| **7** | | | |\n", + "| **8** | | | |\n", + "| **9** | | | |\n", + "| **10** | | | |\n", + "| **11** | | | |\n", + "| **12** | | | |\n", + "\n", + "나. 작성 서식 2 - 등급 부여 일관성 시험(조원이 각자 등급을 매기고 비교. 예: B2)\n", + "\n", + "| **보고** | **조원 1** | **조원 2** | **조원 3** | **조원 4** | **일치 여부** | **불일치 원인** |\n", + "| --- | --- | --- | --- | --- | --- | --- |\n", + "| **R1** | | | | | | |\n", + "| **R2** | | | | | | |\n", + "| **R3** | | | | | | |\n", + "| **R4** | | | | | | |\n", + "| **R5** | | | | | | |\n", + "\n", + "나. 작성 서식 3 - 교차검증 필수 규칙: 단일 출처 보고는 정확성 ( )단계 이하로 보고 서로 다른 유형의 출처 ( )개 이상의 독립 확인 전에는 판단 결과를 ( )로 기록한다. 같은 출처에서 나온 재전달·재인용은 독립 확인으로 세지 않는다.\n", + "\n", + "다. 점검표\n", + "\n", + "| **점검 항목** | **확인** |\n", + "| --- | --- |\n", + "| 출처 신뢰성 A~F와 정보 정확성 1~6의 판정 기준을 모두 작성했다 | ( ) |\n", + "| 평가 지표를 10개 이상 정의하고 어느 축에 쓰는지 표시했다 | ( ) |\n", + "| 가상 보고 5종에 대한 조원 등급이 4건 이상 일치했다(불일치 건은 원인과 수정 내용을 적었다) | ( ) |\n", + "| 단일 출처 의존 경고와 교차검증 필수 규칙을 명시했다 | ( ) |\n", + "| 신뢰성이 높은 출처도 틀릴 수 있음을 알리는 주의 문구를 넣었다 | ( ) |\n", + "\n", + "**\n", + "\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 03. 정보 출처별 상충 정보 식별 및 교차검증(Cross-validation) 세부 평가 기준 수립\n", + "\n", + "### 📋 미션 배경 및 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "서로 상충하는 첩보가 접수되었을 때 우선순위와 검증 기준을 규정한 교차검증(Cross-validation) 세부 지침을 수립한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **서로 상충하는 첩보가 들어왔을 때 상충을 식별하고 우선순위와 검증 방법을 정하는 교차검증 세부 기준을 수립한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **가상 상충 보고 쌍 4건을 읽고 상충이 시각, 위치, 규모, 주체 중 무엇에서 생겼는지 분류 수행하고 검증하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 5개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `analysis/isr06_uncertainty_assessment.md` 형태의 결과물을 완성한다.\n", + "\n", + "### 📑 실습자료 및 작성 양식\n", + "정보 출처별 상충 정보 식별 및 교차검증(Cross-validation) 세부 평가 기준 수립**\n", + "\n", + "가. 제공 자료 1 - 가상 상충 보고 쌍 4건 [교육용가상자료]\n", + "\n", + "| **쌍** | **보고 A** | **보고 B** | **상충 지점** |\n", + "| --- | --- | --- | --- |\n", + "| **K1** | H-1: 교량 북단에 차량 10여 대 정차(오전) | I-1: 같은 구역 차량형 물체 8개(09:10) | 수량(10여 대 대 8개) |\n", + "| **K2** | I-2 드론: 다-2 도로에 이동 차량 없음(11:00) | H-3: 같은 도로에 차량 행렬 통과(11:00경) | 존재 여부 |\n", + "| **K3** | O-1 게시물: 나-3 교량 앞 군용 행렬(사진) | I-1: 같은 시간대 교량 일대에 사진과 같은 규모 없음 | 시각·위치 |\n", + "| **K4** | S-1: 가-4 통신량 증가를 '이동 준비' 가능성으로 판독 | H-2: 같은 부대가 '정비 중'이라고 보고 | 의미(주체의 의도) |\n", + "\n", + "가. 제공 자료 2 - 정보 공백 예시 3개(나머지 5개 이상은 조별로 찾는다)\n", + "\n", + "| **정보 공백 요인** | **발생 상황** | **대체 수집 수단(예)** |\n", + "| --- | --- | --- |\n", + "| **관측 시각 미기재** | 협조자 보고에 관측 시각이 없어 이동 시점 불명 | 협조자 재접촉으로 시각 확인 요청, 영상 시각과 대조 |\n", + "| **영상 공백** | 구름·야간·드론 배터리 교체로 일정 구간 영상 없음 | 다른 시각 영상 확보, 지상 관측 보완 |\n", + "| **단일 언어·단일 지역 편중** | 공개출처가 한쪽 매체에만 의존 | 다른 언어·매체 보고 대조 |\n", + "\n", + "나. 작성 서식 1 - 상충 분류와 원인 후보\n", + "\n", + "| **쌍** | **상충 유형(시각·위치·수량·주체·의미)** | **원인 후보(출처 오류, 시차, 기만, 재인용)** | **확인 방법** | **결론(승인·보류·추가 수집)** |\n", + "| --- | --- | --- | --- | --- |\n", + "| **K1** | | | | |\n", + "| **K2** | | | | |\n", + "| **K3** | | | | |\n", + "| **K4** | | | | |\n", + "\n", + "나. 작성 서식 2 - 정보 공백·불확실성 영향도 매트릭스(8개 이상)\n", + "\n", + "| **번호** | **정보 공백 요인** | **영향도(높음·중간·낮음)** | **영향을 받는 판단** | **대체 수집 수단** |\n", + "| --- | --- | --- | --- | --- |\n", + "| **1** | | | | |\n", + "| **2** | | | | |\n", + "| **3** | | | | |\n", + "| **4** | | | | |\n", + "| **5** | | | | |\n", + "| **6** | | | | |\n", + "| **7** | | | | |\n", + "| **8** | | | | |\n", + "| **9** | | | | |\n", + "| **10** | | | | |\n", + "\n", + "나. 작성 서식 3 - 확신 표현 규칙과 판단의 한계: 근거가 한 출처뿐일 때 결론에 쓸 수 있는 표현은 ( ), 쓰면 안 되는 표현은 ( ). 이 평가로 판단할 수 없는 경우 3가지: 1) ( ) 2) ( ) 3) ( )\n", + "\n", + "다. 점검표\n", + "\n", + "| **점검 항목** | **확인** |\n", + "| --- | --- |\n", + "| 상충 보고 4건 모두에 상충 유형과 원인 후보를 2개 이상 적었다 | ( ) |\n", + "| 정보 공백 요인을 8개 이상 찾고 각각의 영향도를 매겼다 | ( ) |\n", + "| 정보 공백마다 대체 수집 수단을 1개 이상 적었다 | ( ) |\n", + "| 과도한 확신을 막는 표현 규칙을 정했다 | ( ) |\n", + "| 판단의 한계 3가지를 적었다 | ( ) |\n", + "\n", + "**\n", + "\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 04. 단일 출처 단정 방지를 위한 다출처 승인/보류(Hold) 판단 지침서 초안 작성\n", + "\n", + "### 📋 미션 배경 및 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "단일 출처 정보에 기반한 섣부른 작전 결심을 방지하기 위한 다출처 승인/보류(Hold) 판정 기준서를 작성한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **단일 출처만으로 결론을 내리지 않도록, 근거 항목과 가중치로 승인·보류(Hold)를 판정하는 다출처 판단 지침서 초안을 작성한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **보고의 주장(어떤 주체가 어떤 행위를 했다는 귀속 판단 포함)을 뒷받침하거나 반박하는 근거를 12개 이상 항목화하고 항목별 가중치로 근거 종합 점수 산식을 정 수행하고 검증하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 5개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `matrix/isr06_attribution_matrix.xlsx(md)` 형태의 결과물을 완성한다.\n", + "\n", + "### 📑 실습자료 및 작성 양식\n", + "단일 출처 단정 방지를 위한 다출처 승인/보류(Hold) 판단 지침서 초안 작성**\n", + "\n", + "가. 제공 자료 1 - 근거 항목 예시 4개(나머지 8개 이상은 조별로 추가)\n", + "\n", + "| **근거** | **지지 방향** | **설명** |\n", + "| --- | --- | --- |\n", + "| **서로 다른 유형 출처 2개 이상 일치** | 주장 지지 | HUMINT와 IMINT가 같은 위치·시각을 독립적으로 뒷받침 |\n", + "| **같은 출처의 재인용** | 지지로 세지 않음 | R5처럼 H-1을 전해 들은 보고는 독립 근거가 아님 |\n", + "| **보고 시각과 관측 시각의 차이가 큼** | 신뢰 감점 | 전달 지연이 길면 상황 변화 가능성 |\n", + "| **반대 근거 존재** | 주장 반박 | 같은 시각 영상에 주장하는 내용이 없음 |\n", + "\n", + "가. 제공 자료 2 - 가상 판단 사례 3종(귀속 판단 포함) [교육용가상자료]\n", + "\n", + "| **사례** | **상황** |\n", + "| --- | --- |\n", + "| **S1** | '가상 적 부대 X가 나-3 교량을 통과했다'는 주장. H-1 직접 관측 1건, 영상 판독 1건(차량 종류 불명), 게시물 1건(재사용 의심). |\n", + "| **S2** | '다-2 통신 방해는 가상 집단 Y의 소행'이라는 주장. 통신 요약 1건, 공개 게시물 2건(같은 계정군), 반대 정황 1건(자연 장애 가능). |\n", + "| **S3** | '가상 보급 창고가 비어 있다'는 주장. 협조자 2명이 같은 내용을 전했으나 서로 같은 지인을 통해 들었다고 함. 영상 확인 없음. |\n", + "\n", + "나. 작성 서식 1 - 근거 매트릭스(12개 이상)와 사례 적용. 해당하면 가중치를 적고, 아니면 0, 확인 불가는 '?'로 적는다.\n", + "\n", + "| **번호** | **근거 항목** | **지지 방향** | **가중치** | **S1** | **S2** | **S3** |\n", + "| --- | --- | --- | --- | --- | --- | --- |\n", + "| **1** | | | | | | |\n", + "| **2** | | | | | | |\n", + "| **3** | | | | | | |\n", + "| **4** | | | | | | |\n", + "| **5** | | | | | | |\n", + "| **6** | | | | | | |\n", + "| **7** | | | | | | |\n", + "| **8** | | | | | | |\n", + "| **9** | | | | | | |\n", + "| **10** | | | | | | |\n", + "| **11** | | | | | | |\n", + "| **12** | | | | | | |\n", + "| **13** | | | | | | |\n", + "| **14** | | | | | | |\n", + "\n", + "나. 작성 서식 2 - 근거 종합 점수 산식\n", + "\n", + "| 근거 종합 점수 = (지지 근거 가중치 합) - (반대 근거 가중치 합) - (확인 불가 '?' 개수 x ( ))\n", + "규칙 1. 반대 근거가 1개라도 있으면 점수와 관계없이 '보류 유지 + 추가 수집 요청'\n", + "규칙 2. 점수가 ( ) 미만이면 '보류 유지 + 추가 수집 요청'\n", + "규칙 3. 서로 다른 유형의 독립 출처가 ( )개 미만이면 점수가 높아도 '승인' 불가\n", + "규칙 4. 점수가 기준 이상이어도 승인은 사람(책임 직책)이 하며 점수가 결정을 대신하지 않는다\n", + "사례 결과: S1 점수 ( ) 판단 ( ) / S2 점수 ( ) 판단 ( ) / S3 점수 ( ) 판단 ( ) |\n", + "| --- |\n", + "\n", + "나. 작성 서식 3 - 승인·보류 판단 절차 초안\n", + "\n", + "| **단계** | **행동** | **담당(가상 직책)** | **시간 목표** | **기록할 항목** |\n", + "| --- | --- | --- | --- | --- |\n", + "| **1 접수·등급** | | | | |\n", + "| **2 교차검증** | | | | |\n", + "| **3 점수·규칙 적용** | | | | |\n", + "| **4 보류·추가 수집 요청** | | | | |\n", + "| **5 승인·기록** | | | | |\n", + "\n", + "나. 작성 서식 4 - 레드팀 반박 기록(법적 쟁점은 '법무 검토 필요'로만 표시)\n", + "\n", + "| **사례** | **레드팀 반박('이 근거만으로 단정할 수 있는가')** | **작성 팀 대응** | **보완한 최소 증거 요건** |\n", + "| --- | --- | --- | --- |\n", + "| **S1** | | | |\n", + "| **S2** | | | |\n", + "| **S3** | | | |\n", + "\n", + "다. 점검표\n", + "\n", + "| **점검 항목** | **확인** |\n", + "| --- | --- |\n", + "| 근거 항목을 12개 이상 정의하고 지지 방향을 표시했다 | ( ) |\n", + "| 점수 산식과 규칙 1~4를 빈칸 없이 정했다 | ( ) |\n", + "| 사례 3종에 적용한 점수와 판단을 기록했다 | ( ) |\n", + "| 재인용·재전달을 독립 근거로 세지 않는 규칙이 있다 | ( ) |\n", + "| 레드팀 반박 3건 이상에 대응하고 최소 증거 요건을 보완했다 | ( ) |\n", + "\n", + "**\n", + "\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 05. 가상 전장 복합 시나리오 기반 다출처 ISR 보고서 교차검증 모의 작성 실습\n", + "\n", + "### 📋 미션 배경 및 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "복합 전장 시나리오 기반으로 다출처 보고서를 교차 분석하고 종합 신뢰도 등급을 부여하는 모의 분석 실습을 수행한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **가상 복합 전장 시나리오의 다출처 보고서를 교차 분석하고 종합 신뢰도 등급을 부여해 승인·보류 분기점과 책임 직책을 확정한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **M2~M4 기준을 출처 신뢰도, 교차검증 일치, 반대 근거, 판단 권한 순의 의사결정 트리로 묶고 분기점마다 조건과 책임 직책을 적는다 과정을 진행하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 5개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `sop/isr06_approval_thresholds_sop.md` 형태의 결과물을 완성한다.\n", + "\n", + "### 📑 실습자료 및 작성 양식\n", + "가상 전장 복합 시나리오 기반 다출처 ISR 보고서 교차검증 모의 작성 실습**\n", + "\n", + "가. 제공 자료 1 - 가상 복합 시나리오 전개 [교육용가상자료]\n", + "\n", + "| **시각** | **상황** |\n", + "| --- | --- |\n", + "| **06:30** | 사령부가 PIR(나-3 교량 통과 여부)에 대한 분석 보고를 08:00까지 요구한다. |\n", + "| **07:40** | H-1 보고(R1) 접수. 관측 시각 미기재. |\n", + "| **09:30** | I-1 영상 판독(R2) 접수. 구름 일부. H-1과 위치 일치. |\n", + "| **10:20** | 공개 게시물(R3) 확산. 일부 매체가 인용 시작. |\n", + "| **11:40** | 드론 I-2: 다-2 도로에 이동 차량 없음. H-3은 행렬 통과라고 보고. |\n", + "\n", + "가. 제공 자료 2 - 경계선 보고 묶음 5종\n", + "\n", + "| **묶음** | **상황** | **애매한 이유** |\n", + "| --- | --- | --- |\n", + "| **B1** | H-1 + I-1 일치하나 영상은 차량 종류 판독 불가 | 일치하지만 주체(적 부대) 확인이 안 됨 |\n", + "| **B2** | I-1 + O-1 일치하나 O-1은 재사용 의심 게시물 | 독립 근거가 사실상 1개 |\n", + "| **B3** | H-1 신뢰성 높음, 정확성 확인 수단 없음, 상급 부서 압박 | 신뢰성만으로 승인할 수 있는가 |\n", + "| **B4** | I-2와 H-3 상충. 신뢰성 B 출처 둘이 서로 반대 | 출처 신뢰도로 상충을 해소할 수 없음 |\n", + "| **B5** | 승인 권한 직책 부재 중, 재확인 책임자 미지정 | 권한자 부재 상황 |\n", + "\n", + "나. 작성 서식 1 - 의사결정 트리(빈칸을 채운다)\n", + "\n", + "| [분기 1] 보고에 출처 신뢰성·정보 정확성 등급이 모두 기재되어 있는가?\n", + " 아니오 → 보류 유지 + 등급 보완 요청 (책임: ( ))\n", + " 예 ↓\n", + "[분기 2] 서로 다른 유형의 독립 출처가 ( )개 이상 같은 내용을 뒷받침하는가?\n", + " 아니오 → 보류 유지 + 추가 수집 요청 (책임: ( ))\n", + " 예 ↓\n", + "[분기 3] 반대 근거 또는 미해소 상충이 있는가?\n", + " 예 → 보류 유지 + 상충 해소 절차 (책임: ( ))\n", + " 아니오 ↓\n", + "[분기 4] 승인 권한 직책과 연락이 되는가?\n", + " 아니오 → 보류 유지 + 대체 연락 경로 (책임: ( ))\n", + " 예 → 사람의 승인 판단 (책임: ( )) |\n", + "| --- |\n", + "\n", + "나. 작성 서식 2 - 분기점·책임 직책 매핑표\n", + "\n", + "| **분기점** | **판정 조건(수치·관찰 기준)** | **'예'일 때** | **'아니오'일 때** | **책임 직책(가상)** |\n", + "| --- | --- | --- | --- | --- |\n", + "| **분기 1** | | | | |\n", + "| **분기 2** | | | | |\n", + "| **분기 3** | | | | |\n", + "| **분기 4** | | | | |\n", + "\n", + "나. 작성 서식 3 - 모의 결심 기록지(판단은 '승인·보류 유지·추가 수집 요청' 중 하나)\n", + "\n", + "| **묶음** | **멈춘 분기점** | **판단** | **근거(자료 인용)** | **책임 직책** | **조원 합의** |\n", + "| --- | --- | --- | --- | --- | --- |\n", + "| **B1** | | | | | |\n", + "| **B2** | | | | | |\n", + "| **B3** | | | | | |\n", + "| **B4** | | | | | |\n", + "| **B5** | | | | | |\n", + "\n", + "나. 작성 서식 4 - 긴급 신속 승인과 사후 감사\n", + "\n", + "| **항목** | **평시 절차** | **긴급 신속 절차** | **사후 감사 항목** |\n", + "| --- | --- | --- | --- |\n", + "| **교차검증 방법** | | | |\n", + "| **보고 형식** | | | |\n", + "| **기록 시점** | | | |\n", + "| **승인 권한** | | | |\n", + "\n", + "| 긴급 신속 절차는 확인 단계를 건너뛰는 절차가 아니다. 보고 형식을 줄이고 기록을 사후에 보완할 수는 있으나 출처 등급, 독립 확인, 반대 근거 점검과 사람의 승인은 생략하지 않는다. 실제 교전·작전 판단은 교전수칙과 지휘계통에 따른다. |\n", + "| --- |\n", + "\n", + "다. 점검표\n", + "\n", + "| **점검 항목** | **확인** |\n", + "| --- | --- |\n", + "| 의사결정 트리 4개 분기점의 조건을 수치·관찰 기준으로 명시했다 | ( ) |\n", + "| 모든 분기점에 책임 직책을 매핑했다 | ( ) |\n", + "| 경계선 묶음 5종 모두에 판단과 근거를 기록했다 | ( ) |\n", + "| 어떤 묶음도 사람의 승인 없이 승인 판단으로 가는 경로가 없다 | ( ) |\n", + "| 긴급 신속 절차에 사후 감사 항목을 넣었다 | ( ) |\n", + "\n", + "**\n", + "\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 06. 지휘관 브리핑용 ISR 신뢰도 등급화 요약 차트 및 출처 추적 템플릿 제작\n", + "\n", + "### 📋 미션 배경 및 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "지휘관 보고용 다출처 교차검증 요약 차트 및 출처별 근거 추적성 보고서 서식을 개발한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **지휘관 브리핑용 신뢰도 등급화 요약 차트와, 출처별 근거를 사실·판단·조치·미확인으로 추적하는 기록 서식을 만든다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **한눈에 보는 요약 차트(주장, 종합 등급, 근거 출처 수, 반대 근거, 보류 여부)와 출처 추적 템플릿(출처 ID, 수집 시각, 원문 요약, 재인용 여부) 작성하여 원인을 분석하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 5개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `templates/isr06_aar_fact_action_template.md` 형태의 결과물을 완성한다.\n", + "\n", + "### 📑 실습자료 및 작성 양식\n", + "지휘관 브리핑용 ISR 신뢰도 등급화 요약 차트 및 출처 추적 템플릿 제작**\n", + "\n", + "가. 제공 자료 1 - 가상 분석 보고 3종(사실·판단·조치가 섞여 있다) [교육용가상자료]\n", + "\n", + "| **보고** | **원문** |\n", + "| --- | --- |\n", + "| **보고 1** | 08:10 분석반. 나-3 교량 북단에 적 차량 10여 대가 틀림없이 있음. H-1 말로는 아마 오늘 중 이동할 듯. 영상은 구름 때문에 일부만 봄. 사령부에 먼저 보고함. |\n", + "| **보고 2** | 10:30 분석반. 공개 게시물은 거의 사실로 보임. 같은 사진이 여러 매체에 나오므로 확실함. 계정은 확인 안 됨. 일단 보류하지 않고 진행. |\n", + "| **보고 3** | 11:50 분석반. 드론으로 다-2는 문제없음. H-3은 틀린 듯. 통신량 증가는 이동 준비로 판단됨. 상급 승인은 아직 안 받음. |\n", + "\n", + "가. 제공 자료 2 - 혼동 유발 표현 목록(예시 2개, 나머지는 조별 작성)\n", + "\n", + "| **금지 표현** | **문제점** | **대체 표현** |\n", + "| --- | --- | --- |\n", + "| **확실, 틀림없이** | 근거 없이 확신 수준을 높임 | '(출처 유형) (개수)개로 확인, 등급 ( )' |\n", + "| **~인 듯, 아마** | 추측이 사실처럼 전달됨 | '판단: ~ 가능성 (근거: ~)' |\n", + "| **거의 사실로 보임** | | |\n", + "| **문제없음** | | |\n", + "| **여러 매체에 나옴** | | |\n", + "| **일단** | | |\n", + "\n", + "나. 작성 서식 1 - 지휘관 요약 차트(주장별 종합 등급)\n", + "\n", + "| **주장** | **종합 등급(예: B2)** | **독립 근거 출처 수** | **반대 근거** | **판단(승인·보류·추가 수집)** | **책임 직책** |\n", + "| --- | --- | --- | --- | --- | --- |\n", + "| | | | | | |\n", + "| | | | | | |\n", + "| | | | | | |\n", + "| | | | | | |\n", + "\n", + "나. 작성 서식 2 - 출처 추적 템플릿\n", + "\n", + "| **출처 ID** | **유형** | **수집·관측 시각** | **접수 시각** | **원문 요약** | **재인용 여부(원 출처 ID)** |\n", + "| --- | --- | --- | --- | --- | --- |\n", + "| | | | | | |\n", + "| | | | | | |\n", + "| | | | | | |\n", + "| | | | | | |\n", + "| | | | | | |\n", + "\n", + "나. 작성 서식 3 - 요약형 기록 서식(긴급 시)\n", + "\n", + "| **시각** | **확인된 사실** | **판단(근거)** | **조치** | **미확인 사항** |\n", + "| --- | --- | --- | --- | --- |\n", + "| | | | | |\n", + "| | | | | |\n", + "| | | | | |\n", + "\n", + "나. 작성 서식 4 - 상세형 기록 서식(사후 분석용)\n", + "\n", + "| **기록 번호·시각** | |\n", + "| --- | --- |\n", + "\n", + "| **출처 ID·유형·등급(신뢰성/정확성)** | |\n", + "| --- | --- |\n", + "\n", + "| **확인된 사실(수단·개수)** | |\n", + "| --- | --- |\n", + "\n", + "| **판단과 근거** | |\n", + "| --- | --- |\n", + "\n", + "| **취한 조치(승인·보류 유지·추가 수집 요청)** | |\n", + "| --- | --- |\n", + "\n", + "| **미확인 사항과 확인 계획** | |\n", + "| --- | --- |\n", + "\n", + "| **판단 책임 직책·서명** | |\n", + "| --- | --- |\n", + "\n", + "다. 점검표\n", + "\n", + "| **점검 항목** | **확인** |\n", + "| --- | --- |\n", + "| 요약 차트에 종합 등급, 독립 근거 수, 반대 근거, 판단을 모두 넣었다 | ( ) |\n", + "| 출처 추적 템플릿에 재인용 여부 칸이 있다 | ( ) |\n", + "| 기록 서식의 사실·판단·조치·미확인 칸이 분리되어 있다 | ( ) |\n", + "| 혼동 유발 표현을 6개 이상 정하고 대체 표현을 적었다 | ( ) |\n", + "| 가상 보고 3종을 서식으로 옮겼고 사실 칸에 추측 표현이 0건이다 | ( ) |\n", + "| 요약형과 상세형 두 서식을 모두 만들었다 | ( ) |\n", + "\n", + "**\n", + "\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 07. 기만 첩보 주입 상황 모의 교차검증 스트레스 테스트 및 블라인드 검증\n", + "\n", + "### 📋 미션 배경 및 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "기만 첩보 및 출처 간 모순 정보가 대량 주입되는 가상 스트레스 상황 하에서 분석 신뢰성을 검증한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **기만 첩보와 출처 간 모순이 대량으로 주입되는 상황에서 분석이 흔들리지 않는지 스트레스 테스트로 확인하고 평가 루브릭을 만든다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **정해진 시간에 보고 카드가 연달아 투입되고 그중 일부는 기만(같은 출처의 재인용, 시각이 조작된 게시물, 사실을 섞은 허위 보고)인 조건에서 조별로 등급을 부여 수행하고 검증하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 5개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `rubric/isr06_evaluation_rubric.md` 형태의 결과물을 완성한다.\n", + "\n", + "### 📑 실습자료 및 작성 양식\n", + "기만 첩보 주입 상황 모의 교차검증 스트레스 테스트 및 블라인드 검증**\n", + "\n", + "가. 제공 자료 1 - 기만 스트레스 보고 카드 투입 순서(통제관용, 정답은 비공개) [교육용가상자료]\n", + "\n", + "| **경과 시간** | **투입 카드** | **내용** | **통제관 메모(평가자만)** |\n", + "| --- | --- | --- | --- |\n", + "| **0:00** | D0 | PIR 제시. 20분 안에 종합 등급 부여. | - |\n", + "| **2:00** | D1 | H-1: 교량 북단에 차량 10여 대 | 정상(독립 확인 필요) |\n", + "| **4:00** | D2 | H-2: H-1에게 들었다며 같은 내용 | 재전달(독립 근거 아님) |\n", + "| **6:00** | D3 | O-1: 교량 앞 행렬 사진, 촬영 시각 미표기 | 기만 의심(재사용 사진) |\n", + "| **9:00** | D4 | O-2: O-1 사진을 인용한 다른 계정 게시물 | 재인용(기만 증폭) |\n", + "| **12:00** | D5 | I-1: 같은 시각 교량 일대 차량형 물체 3개 판독 | 정상(H-1과 수량 차이) |\n", + "| **15:00** | D6 | S-1: 가상 통신 요약, 교량 방면 통신량 급증(출처 평소 신뢰 높음) | 일부 사실을 섞은 허위 보고 |\n", + "| **18:00** | D7 | 상급 부서: '빨리 결론을 달라'는 압박 | 압박(보류 원칙 점검) |\n", + "\n", + "가. 제공 자료 2 - 역할: 분석 책임(조장) 1명, 기록자 1명, 평가자 2명(서로 상의 금지), 통제관(교관)은 카드를 시간에 맞춰 읽는다. 블라인드 점검: 학습자는 통제관 메모를 보지 않는다.\n", + "\n", + "나. 작성 서식 1 - 5영역 4등급 루브릭(예시 1행 포함)\n", + "\n", + "| **영역** | **탁월** | **우수** | **보통** | **미흡** |\n", + "| --- | --- | --- | --- | --- |\n", + "| **보류 원칙 준수** | 압박에도 독립 근거가 부족하면 보류를 유지하고 추가 수집 요청을 즉시 보내며 근거를 기록함 | 보류를 유지하고 추가 수집을 요청함 | 보류는 유지했으나 추가 수집 요청이 늦음 | 압박에 따라 근거 없이 승인함 |\n", + "| **출처 평가** | | | | |\n", + "| **상충 식별** | | | | |\n", + "| **기만 징후 인식** | | | | |\n", + "| **기록** | | | | |\n", + "\n", + "나. 작성 서식 2 - 평가자 간 채점 일치 확인(탁월 4, 우수 3, 보통 2, 미흡 1)\n", + "\n", + "| **조 / 영역** | **출처 평가** | **상충 식별** | **기만 징후** | **보류 원칙** | **기록** | **일치 칸 수** |\n", + "| --- | --- | --- | --- | --- | --- | --- |\n", + "| **1조 (A / B)** | / | / | / | / | / | / 5 |\n", + "| **2조 (A / B)** | / | / | / | / | / | / 5 |\n", + "| **3조 (A / B)** | / | / | / | / | / | / 5 |\n", + "| **4조 (A / B)** | / | / | / | / | / | / 5 |\n", + "\n", + "일치율 = 두 평가자 점수가 같은 칸 수 / 전체 칸 수 x 100 = ( )% (목표 80% 이상). 점수 차이가 2점 이상인 칸은 루브릭 문구를 고친다.\n", + "\n", + "나. 작성 서식 3 - 기만 보고 처리 기록: 기만 의심 카드 ( )건 중 보류 또는 낮은 등급으로 처리 ( )건, 높은 등급으로 승인 ( )건\n", + "\n", + "다. 점검표\n", + "\n", + "| **점검 항목** | **확인** |\n", + "| --- | --- |\n", + "| 루브릭 5영역 x 4등급 20칸을 모두 관찰 가능한 행동으로 채웠다 | ( ) |\n", + "| 평가자 2명이 서로 상의하지 않고 독립 채점했다 | ( ) |\n", + "| 일치율을 계산했고 80% 이상이거나, 미만이면 루브릭을 고쳐 다시 채점했다 | ( ) |\n", + "| 기만 보고를 높은 등급으로 승인한 판단이 0건이다 | ( ) |\n", + "| 역량이 '미흡'인 영역에 보충 학습 방법을 1개 이상 적었다 | ( ) |\n", + "\n", + "**\n", + "\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 08. AAR을 통한 첩보 불일치 해결 실패 원인 분석 및 교차검증 루브릭 개선\n", + "\n", + "### 📋 미션 배경 및 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "AAR 사후검토를 통해 정보 상충 해결 실패 및 분석관 확증 편향 사례를 분석하고 루브릭을 개선한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **AAR 사후검토로 정보 상충 해결 실패와 분석관 확증 편향 사례의 원인을 분석하고, 레드팀 비판으로 루브릭과 기준의 취약한 전제를 찾는다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **가상 AAR 일지 2건(상충 해결 실패 사례, 확증 편향 사례)을 시간순으로 재구성하고 원인을 사람·절차·정보·통신으로 나눈다 과정을 진행하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 5개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `reports/isr06_redteam_critique_report.md` 형태의 결과물을 완성한다.\n", + "\n", + "### 📑 실습자료 및 작성 양식\n", + "AAR을 통한 첩보 불일치 해결 실패 원인 분석 및 교차검증 루브릭 개선**\n", + "\n", + "가. 제공 자료 1 - 가상 AAR 일지 A(상충 해결 실패 사례) [교육용가상자료]\n", + "\n", + "| **시각** | **기록** |\n", + "| --- | --- |\n", + "| **08:10** | H-1(차량 10여 대)과 I-1(8개) 수량 차이 확인. 상충으로 분류하지 않고 '대체로 일치'로 기록. |\n", + "| **09:00** | 드론 I-2 영상에서 다-2 도로 차량 없음. 담당 분석관이 'H-3은 오보'로 단정. |\n", + "| **09:20** | H-3이 영상 촬영 이후 시각의 보고임이 뒤에 확인됨. 시차 가능성은 검토되지 않음. |\n", + "| **09:40** | 상급 보고서에 '이동 확인'과 '이동 없음'이 한 문서에 함께 기재됨. |\n", + "\n", + "가. 제공 자료 2 - 가상 AAR 일지 B(확증 편향 사례) [교육용가상자료]\n", + "\n", + "| **시각** | **기록** |\n", + "| --- | --- |\n", + "| **10:00** | 분석관이 '교량 통과' 가설을 먼저 세움. 이후 일치하는 보고만 선별해 정리. |\n", + "| **10:30** | 반대 정황(통신량 정상, 영상 차량 3개)이 있었으나 '일시적'이라며 주석 처리. |\n", + "| **11:10** | 동료 분석관이 재검토를 제안했으나 일정 이유로 보류됨. |\n", + "| **13:00** | 가상 후속 보고에서 교량 통과가 없었던 것으로 판명됨. |\n", + "\n", + "나. 작성 서식 1 - AAR 원인 분석표(비난 없이 사실 중심으로 작성)\n", + "\n", + "| **사례** | **무엇이 계획되었나** | **실제로 무슨 일이 있었나** | **원인(사람·절차·정보·통신)** | **개선안** |\n", + "| --- | --- | --- | --- | --- |\n", + "| **A** | | | | |\n", + "| **A** | | | | |\n", + "| **B** | | | | |\n", + "| **B** | | | | |\n", + "\n", + "나. 작성 서식 2 - 레드팀 쟁점·대응표(5건 이상, 예시 1건)\n", + "\n", + "| **번호** | **취약한 전제** | **레드팀 비판** | **작성 팀 반박 또는 수정안** | **경고 문구** |\n", + "| --- | --- | --- | --- | --- |\n", + "| **1** | 신뢰성 A 출처는 틀리지 않는다 | 신뢰성이 높아도 관측 오류·전달 지연이 있다 | 신뢰성과 정확성을 따로 평가하고 정확성은 독립 확인으로 결정 | '신뢰성 A여도 단독 근거로 승인하지 않는다' |\n", + "| **2** | | | | |\n", + "| **3** | | | | |\n", + "| **4** | | | | |\n", + "| **5** | | | | |\n", + "| **6** | | | | |\n", + "\n", + "나. 작성 서식 3 - 반대 방향 위험 점검: 보류가 지나치게 잦을 때 생기는 위험(정보 지원 지연, 지휘관의 불신)과 이를 줄이는 방법(잠정 등급 보고, 시간 목표)을 3줄 이내로 적는다.\n", + "\n", + "나. 작성 서식 4 - 수정 반영 계획표\n", + "\n", + "| **번호** | **수정할 문서·조항** | **수정 내용** | **담당(가상 직책)** | **완료 기한** |\n", + "| --- | --- | --- | --- | --- |\n", + "| **1** | | | | |\n", + "| **2** | | | | |\n", + "| **3** | | | | |\n", + "| **4** | | | | |\n", + "| **5** | | | | |\n", + "\n", + "다. 점검표\n", + "\n", + "| **점검 항목** | **확인** |\n", + "| --- | --- |\n", + "| AAR 일지 2건을 시간순으로 재구성하고 계획과 실제의 차이를 적었다 | ( ) |\n", + "| 원인을 사람·절차·정보·통신으로 분류하고 개인 비난 표현이 없다 | ( ) |\n", + "| 레드팀 쟁점을 5건 이상 도출했다 | ( ) |\n", + "| 모든 쟁점에 반박 또는 수정안을 적었다(100%) | ( ) |\n", + "| 취약한 전제마다 경고 문구를 달았다 | ( ) |\n", + "| 보류 과다에 따른 반대 방향 위험을 다뤘다 | ( ) |\n", + "\n", + "**\n", + "\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 09. 피드백 반영 다출처 보고서 최종 보정 및 정보 판단 신뢰성 검증\n", + "\n", + "### 📋 미션 배경 및 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "피드백을 반영하여 다출처 교차검증 보고서를 재작성하고 정보 판단의 정확도를 향상시킨다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **M7·M8 피드백으로 교차검증 보고서와 기준을 최종 보정하고 같은 사례로 재검증한 뒤, 지휘관 1페이지 브리핑으로 정리한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **개선 전후 기준을 나란히 놓고 바뀐 조건과 이유(근거 미션)를 적는다 과정을 진행하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 5개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `briefing/isr06_commander_1page_brief.md(pdf)` 형태의 결과물을 완성한다.\n", + "\n", + "### 📑 실습자료 및 작성 양식\n", + "피드백 반영 다출처 보고서 최종 보정 및 정보 판단 신뢰성 검증**\n", + "\n", + "가. 제공 자료 - 재검증 대상 7건: M5 경계선 묶음 B1~B5, M8 AAR 사례 A·B. 조별로 개선 전 기준과 개선 후 기준을 각각 적용한다.\n", + "\n", + "나. 작성 서식 1 - 기준 개선 전후 비교표\n", + "\n", + "| **항목** | **개선 전** | **개선 후** | **변경 이유(근거 미션)** |\n", + "| --- | --- | --- | --- |\n", + "| **등급 기준(신뢰성/정확성)** | | | |\n", + "| **독립 출처 요건** | | | |\n", + "| **상충 처리 규칙** | | | |\n", + "| **기만 징후 점검** | | | |\n", + "| **승인 권한** | | | |\n", + "| **긴급 절차** | | | |\n", + "\n", + "나. 작성 서식 2 - 재검증 결과표\n", + "\n", + "| **사례** | **개선 전 판단** | **개선 후 판단** | **조원 일치(전 → 후)** | **판단 시간(분, 전 → 후)** | **비고** |\n", + "| --- | --- | --- | --- | --- | --- |\n", + "| **B1** | | | | | |\n", + "| **B2** | | | | | |\n", + "| **B3** | | | | | |\n", + "| **B4** | | | | | |\n", + "| **B5** | | | | | |\n", + "| **A** | | | | | |\n", + "| **B** | | | | | |\n", + "\n", + "나. 작성 서식 3 - 지휘관 1페이지 브리핑 틀(BLUF)\n", + "\n", + "| **결론(BLUF)** | (한 문장으로 PIR에 대한 현재 판단과 요청 사항) |\n", + "| --- | --- |\n", + "\n", + "| **핵심 결심 사항** | 1) ( ) 2) ( ) |\n", + "| --- | --- |\n", + "\n", + "| **위험 요소(신호등)** | 빨강: ( )\n", + "노랑: ( )\n", + "초록: ( ) |\n", + "| --- | --- |\n", + "\n", + "| **대안 3종** | 대안 1 ( ) / 대안 2 ( ) / 대안 3 ( ) - 각 장단점 한 줄 |\n", + "| --- | --- |\n", + "\n", + "| **건의** | ( ) |\n", + "| --- | --- |\n", + "\n", + "| **근거** | 재검증 7건 결과 요약(일치율·판단 시간 변화), 종합 등급과 독립 근거 수 |\n", + "| --- | --- |\n", + "\n", + "나. 작성 서식 4 - 브리핑 리허설 기록\n", + "\n", + "| **항목** | **목표** | **결과** |\n", + "| --- | --- | --- |\n", + "| 발표 시간 | 3분 이내 | |\n", + "| 분량 | A4 1페이지 | |\n", + "| 질의 응답 | 3건 이상 답변 | |\n", + "| 결론 먼저 제시 | 첫 문장 | |\n", + "\n", + "다. 점검표\n", + "\n", + "| **점검 항목** | **확인** |\n", + "| --- | --- |\n", + "| 개선 전후 기준의 바뀐 조건과 이유를 모두 적었다 | ( ) |\n", + "| 재검증 7건 모두 개선 전후 판단을 비교했다 | ( ) |\n", + "| 브리핑이 결론부터 시작하고 A4 1페이지에 들어간다 | ( ) |\n", + "| 위험 요소를 신호등으로 표시하고 대안 3종을 제시했다 | ( ) |\n", + "| 3분 안에 발표하고 질의에 답했다 | ( ) |\n", + "\n", + "**\n", + "\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 10. 다출처 ISR 신뢰도 등급화 및 교차검증 실무 표준운용절차(SOP) 완성\n", + "\n", + "### 📋 미션 배경 및 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "다출처 ISR 보고서 신뢰도 등급화 및 교차검증 표준운용절차(SOP)를 완성한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **앞 미션의 결과물을 통합해 다출처 ISR 신뢰도 등급화 및 교차검증 표준운용절차(SOP)를 8개 장과 부록으로 완성한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **목적·적용 범위, 용어 정의, 출처 유형과 보고서 구조, 신뢰도 등급화, 교차검증과 승인·보류, 기록·보고, 훈련·평가, 개정 관리의 8개 장과 부록(등급 매트릭스, 의사결정 트리, 기록 서식, 루브릭)을 편집 수행하고 검증하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 5개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `manual/isr06_standard_operational_manual.md` 형태의 결과물을 완성한다.\n", + "\n", + "### 📑 실습자료 및 작성 양식\n", + "다출처 ISR 신뢰도 등급화 및 교차검증 실무 표준운용절차(SOP) 완성**\n", + "\n", + "가. 제공 자료 1 - SOP 8개 장 구성과 가져올 결과물\n", + "\n", + "| **장** | **제목** | **포함 내용** | **가져올 결과물** |\n", + "| --- | --- | --- | --- |\n", + "| **1** | 목적과 적용 범위 | SOP의 목적, 적용 상황, 적용하지 않는 상황, 교전수칙과의 관계 | M1 |\n", + "| **2** | 용어 정의 | 출처 신뢰성, 정보 정확성, 독립 출처, 교차검증, 보류, 기만 | M1~M3 |\n", + "| **3** | 출처 유형과 보고서 구조 | HUMINT·SIGINT·IMINT·OSINT별 구조와 약점 | M1 |\n", + "| **4** | 신뢰도 등급화 | 6x6 매트릭스, 평가 지표, 편향 방지 규칙 | M2 |\n", + "| **5** | 교차검증과 승인·보류 | 상충 처리, 근거 점수, 의사결정 트리, 권한과 책임 | M3·M4·M5 |\n", + "| **6** | 기록·보고 | 요약 차트, 출처 추적, 기록 서식, 표현 규칙, 브리핑 | M6·M9 |\n", + "| **7** | 훈련·평가 | 스트레스 테스트, 루브릭, AAR, 레드팀 검토 | M7·M8 |\n", + "| **8** | 개정 관리 | 개정 주기, 의견 수렴, 책임 직책, 이력 | M8·M9 |\n", + "\n", + "가. 제공 자료 2 - 모의 심의 역할 카드\n", + "\n", + "| **역할** | **검토 질문** |\n", + "| --- | --- |\n", + "| **교리 역할** | 출처 유형별 구조와 등급 정의가 일관되는가? 신병도 이해할 용어인가? 훈련 방법이 기준과 연결되는가? |\n", + "| **법무 역할** | '최종 판단은 사람이, 교전수칙·지휘계통에 따라'가 모든 절차에 드러나는가? 법적 쟁점이 법무 검토로 연계되는가? |\n", + "| **작전 역할** | 현장에서 20분 안에 쓸 수 있는가? 보류가 지나치게 잦아 정보 지원이 멈추지 않는가? 재확인 책임자가 분명한가? |\n", + "\n", + "나. 작성 서식 1 - 근거 표기 점검표(SOP 조항마다 작성)\n", + "\n", + "| **조항** | **기준 내용 요약** | **근거(미션·가상 자료)** | **표기 여부** |\n", + "| --- | --- | --- | --- |\n", + "| **1-1** | | | |\n", + "| **2-1** | | | |\n", + "| **3-1** | | | |\n", + "| **4-1** | | | |\n", + "| **5-1** | | | |\n", + "| **6-1** | | | |\n", + "| **7-1** | | | |\n", + "| **8-1** | | | |\n", + "\n", + "나. 작성 서식 2 - 모의 심의 지적 사항 반영표\n", + "\n", + "| **번호** | **역할** | **지적 사항** | **반영 내용** | **반영 위치(장·조)** |\n", + "| --- | --- | --- | --- | --- |\n", + "| **1** | | | | |\n", + "| **2** | | | | |\n", + "| **3** | | | | |\n", + "| **4** | | | | |\n", + "| **5** | | | | |\n", + "| **6** | | | | |\n", + "\n", + "나. 작성 서식 3 - 개정 관리: 개정 주기 ( ), 의견 수렴 방법 ( ), 개정 책임 직책(가상) ( ), 개정 이력 기록 위치 ( )\n", + "\n", + "다. 점검표\n", + "\n", + "| **점검 항목** | **확인** |\n", + "| --- | --- |\n", + "| 8개 장과 부록을 모두 완성했다 | ( ) |\n", + "| 모든 기준 조항에 근거(미션·가상 자료)를 표기했다 | ( ) |\n", + "| 교리·법무·작전 역할의 지적 사항을 반영하고 반영 위치를 적었다 | ( ) |\n", + "| '최종 판단은 사람이 하며 실제 작전·교전 판단은 교전수칙·지휘계통에 따른다'는 문구가 1장과 5장에 있다 | ( ) |\n", + "| 연 1회 개정 주기와 의견 수렴 방법을 명시했다 | ( ) |\n", + "| 실제 부대·위치·수집 수단·정보 내용을 쓰지 않았다 | ( ) |\n", + "\n", + "**4. 평가 채점표**\n", + "\n", + "미션별 평가 기준은 시나리오의 평가 항목과 같다. 평가자는 기준 충족 여부를 PASS/FAIL로 표시하고 FAIL이면 사유를 비고에 적는다.\n", + "\n", + "| **미션** | **구분** | **채점 기준** | **판정** | **비고** |\n", + "| --- | --- | --- | --- | --- |\n", + "| **M1** | 필수 | 보고서 구조 분석·문서 골격: 가상 보고 사례 5건 이상을 검토해 출처 유형별 구조와 약점을 비교했는가, SOP 목차(8개 장) 골격을 100% 정의하고 지휘관 결심 의제 순으로 배열했는가. | PASS / FAIL | |\n", + "| **M2** | 필수 | 6단계 등급 매트릭스·평가 지표: 신뢰도 기준 6단계(두 축 모두)를 완비하고 평가 지표 10개 이상을 정의했는가, 가상 보고 5종에 대한 조원 간 등급 일치가 4건 이상이고 단일 출처 의존 경고와 교차검증 필수 규칙을 명시했는가. | PASS / FAIL | |\n", + "| **M3** | 필수 | 교차검증 기준·불확실성 평가: 정보 공백 요인 8개 이상과 영향도 매트릭스를 도출했는가, 상충 보고 4건에 원인 후보와 확인 방법을 적고 판단 한계와 대체 수집 수단을 명시했는가. | PASS / FAIL | |\n", + "| **M4** | 선택 | 근거 항목·승인·보류 지침: 근거 12개 이상 항목화와 가중치 산식을 100% 정립했는가, 사례 3종 적용 결과를 기록하고 레드팀 반박에 대응해 최소 증거 요건을 설정했는가. | PASS / FAIL | |\n", + "| **M5** | 필수 | 분기점·책임 매핑·모의 교차검증: 분기점 조건 100% 명시와 책임 직책 매핑을 완료했는가, 경계선 사례 5종 모두에 판단과 근거가 기록되고 교전수칙·인도주의 원칙 준수와 사람의 최종 판단을 지켰는가. | PASS / FAIL | |\n", + "| **M6** | 선택 | 요약 차트·출처 추적 서식: 기록 분류 규칙을 100% 표준화하고 혼동 유발 단어 금지 목록을 포함했는가, 가상 보고 3종을 서식으로 옮겼을 때 사실·판단 혼동이 0건인가. | PASS / FAIL | |\n", + "| **M7** | 필수 | 기만 스트레스·평가 루브릭: 5개 영역 4등급 기준 정의율 100%, 평가자 2명의 채점 일치율 80% 이상, 기만 보고를 높은 등급으로 승인한 판단이 0건인가. | PASS / FAIL | |\n", + "| **M8** | 선택 | AAR 원인·레드팀 검토: 비판적 쟁점 5건 이상을 도출하고 쟁점별 반박·수정 대안을 100% 수립했는가, 취약한 전제에 경고 문구를 명시했는가. | PASS / FAIL | |\n", + "| **M9** | 필수 | 최종 보정·브리핑 검증: 재검증 7건의 결과를 개선 전후 비교표로 제시했는가, 핵심 결심 사항·위험 요소·대안 3종이 A4 1페이지에 담기고 3분 안에 전달되었는가. | PASS / FAIL | |\n", + "| **M10** | 필수 | SOP 최종 완성: 8개 장과 부록을 100% 완성하고 모든 기준에 근거(미션·가상 자료)를 표기했는가, 모의 심의 지적 사항을 반영하고 '최종 판단은 사람이 하며 실제 작전·교전 판단은 교전수칙·지휘계통에 따른다'는 문구를 명시했는가. | PASS / FAIL | |\n", + "\n", + "| **판정 기준** | 필수 7개 미션(M1·M2·M3·M5·M7·M9·M10)이 모두 PASS여야 최종 PASS로 인정한다. 선택 미션(M4·M6·M8)은 가점·보완 의견에 반영한다. |\n", + "| --- | --- |\n", + "\n", + "| **공통 감점** | 실제 부대명·위치·수집 수단·정보 내용·실존 인물 정보를 사용한 경우, 부대 자료를 외부 AI 서비스에 입력한 경우 해당 미션은 FAIL로 처리한다. |\n", + "| --- | --- |\n", + "\n", + "| **주제 안전 기준** | 단일 출처만으로 승인하는 판단이 있거나 SOP에 사람의 최종 판단·교전수칙 우선 문구가 없으면 M5·M10은 FAIL로 처리한다. |\n", + "| --- | --- |\n", + "\n", + "| **최종 판정 결과** | ( PASS / FAIL ) 평가자 서명: ( ) |\n", + "| --- | --- |\n", + "\n", + "| 안전 원칙: 본 자료의 등급 매트릭스, 승인 권한 구분, 의사결정 트리는 학습용 가상 기준이다. 최종 판단은 사람이 내리며 실제 작전·교전 판단은 교전수칙과 지휘계통에 따른다. 실제 부대 자료를 입력하거나 부대 내에서 촬영하지 않는다. |\n", + "| --- |\n", + "\n" + ] + } + ], + "metadata": { + "colab": { + "provenance": [] + }, + "kernelspec": { + "display_name": "Python 3", + "name": "python3" + }, + "language_info": { + "name": "python" + } + }, + "nbformat": 4, + "nbformat_minor": 0 +} \ No newline at end of file diff --git a/uploads/ISR07/ISR07_workbook.ipynb b/uploads/ISR07/ISR07_workbook.ipynb new file mode 100644 index 0000000..35011c0 --- /dev/null +++ b/uploads/ISR07/ISR07_workbook.ipynb @@ -0,0 +1,957 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# 📋 [ISR07] 관측 공백·센서 한계를 포함한 정보판단 한계 브리핑\n", + "### 실습 워크북 & SOP 가이드 (Google Colab / Jupyter)\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/ISR07/ISR07_workbook.ipynb)\n", + "\n", + "---\n", + "\n", + "## 📌 1. 프로젝트 개요 및 배경\n", + "\n", + "| 항목 | 내용 |\n", + "| :--- | :--- |\n", + "| **과정 분류** | 정보·감시·정찰(ISR) (Level 2) |\n", + "| **학습 소요 시간** | 10개 미션 (5일 / 총 27.0시간) |\n", + "| **실습 유형** | 작전절차(SOP), 체크리스트, 모의훈련, AAR 사후검토 실습 |\n", + "\n", + "### 📖 프로젝트 목표\n", + "러시아-우크라이나 전쟁과 미국-이란 간 긴장 국면에서는 감시·정찰 자산이 악천후, 지형 가림, 전자전 방해, 통신 두절, 운용 공백으로 대상을 보지 못하는 경우가 자주 생겼고, 보이지 않은 것을 없는 것으로 읽는 단정이 판단 오류로 이어질 수 있다는 점이 되풀이해서 확인되었다. 이런 상황에서 정보 담당자에게 요구되는 것은 센서가 무엇을 보지 못했는지를 먼저 밝히고, 결측이 얼마나 큰지에 따라 확신 수준을 낮추어 지휘관에게 알리는 것이다. 본 프로젝트에서는 교육용 가상 지역과 가상 센서 기록을 사용해 센서별 관측 한계 요인 분석, 불확실성 지표 체계 설계, 단정 위험 방지 지침, 센서 커버리지 맵 분석, 정보판단 한계 브리핑 서식, 확정·추정·미상 3단계 분류 기준, 센서 장애 스트레스 훈련, AAR, 재브리핑 검증을 차례로 수행한다. 최종 목표는 관측 공백과 센서 한계를 명시하는 정보판단 한계 브리핑 가이드라인과 실무 적용서를 문서로 완성하는 것이다.\n", + "\n", + "> **⚠️ [교육용 가상 자료 원칙]**\n", + "> - 본 워크북의 모든 자료는 안전 규정에 따라 **교육용 가상 자료**로 구성되어 있습니다.\n", + "> - 실제 군사 기밀이나 부대 세부 인적·작전 정보는 포함되지 않습니다.\n", + "\n", + "---\n", + "\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 01. 감시정찰 자산의 센서별 물리적 관측 한계(사각지대, 악천후, 재밍) 요인 분석\n", + "\n", + "### 📋 미션 배경 및 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "감시정찰 자산의 센서별 물리적 사각지대, 악천후 취약성, 전자전 방해 등 관측 공백 요인을 분석한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **전자광학 영상, 레이더, 신호 수집, 지상 관측소 등 센서 유형별로 사각지대, 악천후, 전자전 방해 등 물리적 관측 한계 요인을 분석하고 문서 전체 골격을 정의한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **실습자료의 가상 관측 한계 사례 5건을 읽고 각 사례에서 어떤 센서가 무엇 때문에 대상을 보지 못했는지(지형 가림, 구름·안개, 배터리 교체 공백, 방해, 통신 두절)를 표로 분류 수행하고 검증하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 5개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `docs/isr07_doc_framework.md 및 분석 개요서` 형태의 결과물을 완성한다.\n", + "\n", + "### 📑 실습자료 및 작성 양식\n", + "감시정찰 자산의 센서별 물리적 관측 한계(사각지대, 악천후, 재밍) 요인 분석**\n", + "\n", + "가. 제공 자료 - 가상 관측 한계 사례 5건 [교육용가상자료]\n", + "\n", + "| **사례** | **센서** | **상황** | **관측 결과** | **한계의 원인** |\n", + "| --- | --- | --- | --- | --- |\n", + "| **L1** | E 드론 | 09:00~09:30 안개 짙음 | 다-3 일대 영상 식별 불가 | 기상(가시도) |\n", + "| **L2** | E 드론 | 10:40 배터리 교체로 15분 이륙 공백 | 나-4~다-4 구간 영상 없음 | 운용 공백 |\n", + "| **L3** | R 레이더 | 능선 뒤 저고도 이동 대상 | 라-1·라-2 구간 반사 없음 | 지형 가림(사각지대) |\n", + "| **L4** | S 신호 | 11:00~13:00 간헐 방해 | 통신 요약 40% 구간 결측 | 전자전 방해(가상) |\n", + "| **L5** | G 관측소 | 관측병 교대 20분, 통신 두절 | 가-4 보고 누락 | 사람·통신 공백 |\n", + "\n", + "나. 작성 서식 1 - 센서 유형별 관측 한계 요인 분류표\n", + "\n", + "| **센서** | **물리적 한계 요인** | **환경·운용 한계 요인** | **판단에 미치는 영향** | **한계를 줄이는 방법** |\n", + "| --- | --- | --- | --- | --- |\n", + "| **E 전자광학** | | | | |\n", + "| **R 레이더** | | | | |\n", + "| **S 신호** | | | | |\n", + "| **G 지상관측** | | | | |\n", + "\n", + "나. 작성 서식 2 - 가이드라인 목차 골격(지휘관 결심 의제 순으로 배열)\n", + "\n", + "| **순서** | **장 제목** | **담을 핵심 내용** | **지휘관이 얻는 판단 정보** |\n", + "| --- | --- | --- | --- |\n", + "| **제1장** | | | |\n", + "| **제2장** | | | |\n", + "| **제3장** | | | |\n", + "| **제4장** | | | |\n", + "| **제5장** | | | |\n", + "| **제6장** | | | |\n", + "| **제7장** | | | |\n", + "| **제8장** | | | |\n", + "\n", + "다. 점검표\n", + "\n", + "| **점검 항목** | **확인** |\n", + "| --- | --- |\n", + "| 가상 관측 한계 사례 5건을 모두 검토하고 한계 원인을 분류했다 | ( ) |\n", + "| 센서 4종 모두에 물리적·운용 한계 요인과 영향을 적었다 | ( ) |\n", + "| 실제 센서 제원 대신 서술 등급(높음·보통·낮음)만 사용했다 | ( ) |\n", + "| 가이드라인 8개 장의 제목과 핵심 내용을 정의했다 | ( ) |\n", + "| 목차를 지휘관이 먼저 알아야 할 의제 순으로 배열하고 이유를 한 줄로 적었다 | ( ) |\n", + "\n", + "**\n", + "\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 02. 관측 공백 및 센서 불확실성을 표현하는 정량적/정성적 지표 체계 설계\n", + "\n", + "### 📋 미션 배경 및 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "센서 한계 상황에서의 불확실성을 정량화하고 정보 판단의 신뢰 구간을 명시하는 지표 체계를 설계한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **관측 공백과 센서 불확실성을 표현하는 정량·정성 지표 체계를 설계하고, 판단의 신뢰 구간을 명시하는 6단계 평가 기준과 편향 방지 장치를 정한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **센서 가동 비율, 결측 시간, 관측 간격, 가림 비율, 방해 의심 여부 등 정량 지표와 판독 일관성, 보고자 확신 표현 같은 정성 지표를 합해 10개 이상 정의하고 지표마다 확인 방법을 적는다 과정을 진행하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 5개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `framework/isr07_source_reliability_matrix.md` 형태의 결과물을 완성한다.\n", + "\n", + "### 📑 실습자료 및 작성 양식\n", + "관측 공백 및 센서 불확실성을 표현하는 정량적/정성적 지표 체계 설계**\n", + "\n", + "가. 제공 자료 1 - 가상 센서 기록 5건 [교육용가상자료]\n", + "\n", + "| **기록** | **센서·구역·시간** | **가동 상태** | **관측 결과** | **보고자 표현** |\n", + "| --- | --- | --- | --- | --- |\n", + "| **T1** | E 드론, 가-4~나-4, 12:00~12:30 | 30분 중 28분 가동 | 이동 물체 없음 | 이상 없음 |\n", + "| **T2** | R 레이더, 다-3~다-4, 12:00~13:00 | 60분 중 50분 가동, 잡음 많음 | 반사 약함 1회 | 아마 소형 물체 |\n", + "| **T3** | S 신호, 전 구역, 12:00~13:00 | 방해로 24분 결측 | 통신 증가 없음 | 통신 징후 없음 |\n", + "| **T4** | G 관측소, 라-4, 12:00~14:00 | 교대 20분 두절 | 차량 없음 | 확실히 없음 |\n", + "| **T5** | E 드론, 다-2~다-4, 13:00~13:30 | 안개 잔존 구간 12분 | 식별 불가 구간 있음 | 판단 곤란 |\n", + "\n", + "가. 제공 자료 2 - 6단계 관측 신뢰도 틀(단계 이름은 고정, 판정 기준은 조별 작성)\n", + "\n", + "| **단계** | **이름** | **판정 기준(조별 작성)** |\n", + "| --- | --- | --- |\n", + "| **1** | 거의 못 봄 | |\n", + "| **2** | 매우 제한 | |\n", + "| **3** | 제한적 | |\n", + "| **4** | 부분적으로 충분 | |\n", + "| **5** | 대체로 충분 | |\n", + "| **6** | 충분히 봄 | |\n", + "\n", + "나. 작성 서식 1 - 평가 지표(10개 이상, 예시 2개 포함)\n", + "\n", + "| **번호** | **평가 지표** | **정량/정성** | **확인 방법(관찰 가능한 기준)** |\n", + "| --- | --- | --- | --- |\n", + "| **1** | 센서 가동 비율 | 정량 | (가동 시간 / 관측 계획 시간) x 100으로 계산 |\n", + "| **2** | 보고자가 한계를 문장으로 밝혔는가 | 정성 | 보고서에 한계 칸이 채워져 있으면 충족 |\n", + "| **3** | | | |\n", + "| **4** | | | |\n", + "| **5** | | | |\n", + "| **6** | | | |\n", + "| **7** | | | |\n", + "| **8** | | | |\n", + "| **9** | | | |\n", + "| **10** | | | |\n", + "| **11** | | | |\n", + "| **12** | | | |\n", + "\n", + "나. 작성 서식 2 - 단계 부여 일관성 시험(조원이 각자 단계를 범위로 적는다. 예: 3~4)\n", + "\n", + "| **기록** | **조원 1** | **조원 2** | **조원 3** | **조원 4** | **일치 여부** | **불일치 원인** |\n", + "| --- | --- | --- | --- | --- | --- | --- |\n", + "| **T1** | | | | | | |\n", + "| **T2** | | | | | | |\n", + "| **T3** | | | | | | |\n", + "| **T4** | | | | | | |\n", + "| **T5** | | | | | | |\n", + "\n", + "나. 작성 서식 3 - 신뢰 구간 표기와 편향 방지 규칙: 관측 신뢰도는 하나의 값이 아니라 ( )단계에서 ( )단계의 범위로 적는다. 한 종류의 센서에만 의존한 판단에는 ( ) 경고를 붙이고, 판단자와 ( )가 서로 다른 사람이 교차 확인한다. 보고자가 쓴 '이상 없음'은 근거 칸에 ( )이 없으면 인정하지 않는다.\n", + "\n", + "다. 점검표\n", + "\n", + "| **점검 항목** | **확인** |\n", + "| --- | --- |\n", + "| 6단계 판정 기준을 모두 관찰 가능한 문장으로 작성했다 | ( ) |\n", + "| 평가 지표를 10개 이상 정의하고 정량·정성을 구분했다 | ( ) |\n", + "| 센서 기록 5건에 대한 조원 단계가 4건 이상 일치했다(불일치 건은 원인과 수정 내용을 적었다) | ( ) |\n", + "| 신뢰 구간 표기 규칙과 단일 센서 의존 경고를 명시했다 | ( ) |\n", + "| 보고자 확신 표현을 그대로 믿지 않는 교차 확인 규칙을 넣었다 | ( ) |\n", + "\n", + "**\n", + "\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 03. 센서 한계 상황에서의 무리한 단정적 정보판단 위험성 사례 및 방지 지침 수립\n", + "\n", + "### 📋 미션 배경 및 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "센서 미탐이나 결측을 적의 부재로 섣부르게 단정하는 오류를 방지하기 위한 판단 지침서 초안을 작성한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **센서의 미탐이나 결측을 적의 부재로 섣불리 단정하는 오류의 사례를 분석하고, 이를 막는 판단 지침서 초안과 불확실성 영향도 매트릭스를 작성한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **가상 단정 오류 사례 4건을 읽고 '보이지 않음'이 '없음'으로 바뀐 지점을 찾는다 과정을 진행하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 5개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `analysis/isr07_uncertainty_assessment.md` 형태의 결과물을 완성한다.\n", + "\n", + "### 📑 실습자료 및 작성 양식\n", + "센서 한계 상황에서의 무리한 단정적 정보판단 위험성 사례 및 방지 지침 수립**\n", + "\n", + "가. 제공 자료 1 - 가상 단정 오류 사례 4건 [교육용가상자료]\n", + "\n", + "| **사례** | **당시 보고** | **나중에 밝혀진 사실** | **단정이 생긴 지점(조별 작성)** |\n", + "| --- | --- | --- | --- |\n", + "| **W1** | '가-4 구간 이상 없음' (드론 영상 28분 가동) | 영상 공백 2분 사이에 이동이 있었음 | |\n", + "| **W2** | '레이더 반사 없음, 적 부재' | 대상이 능선 뒤로 이동해 레이더 사각지대였음 | |\n", + "| **W3** | '통신 징후 없음' | 방해로 24분 결측, 그 사이 통신이 있었음 | |\n", + "| **W4** | '관측소 보고 없음, 조용함' | 관측소 통신 두절로 보고가 전달되지 않았음 | |\n", + "\n", + "가. 제공 자료 2 - 관측 공백 요인 예시 3개(나머지 5개 이상은 조별로 찾는다)\n", + "\n", + "| **공백 요인** | **발생 상황** | **보완 수단(예)** |\n", + "| --- | --- | --- |\n", + "| **영상 이륙·교체 공백** | 드론 교대 중 일정 시간 영상 없음 | 교대 시간 겹치기, 다른 센서 교차 확인 |\n", + "| **지형 가림** | 능선 그림자에서 레이더 반사 없음 | 측면 방향 관측, 지상 관측소 보완 |\n", + "| **기상** | 안개로 영상 판독 불가 | 안개 해제 후 재관측, 레이더 우선 사용 |\n", + "\n", + "나. 작성 서식 1 - 단정 오류 분석표\n", + "\n", + "| **사례** | **'관측되지 않음'과 '없음'이 섞인 문장** | **빠진 확인(결측률, 독립 센서, 시각)** | **고쳐 쓴 문장** |\n", + "| --- | --- | --- | --- |\n", + "| **W1** | | | |\n", + "| **W2** | | | |\n", + "| **W3** | | | |\n", + "| **W4** | | | |\n", + "\n", + "나. 작성 서식 2 - 관측 공백·불확실성 영향도 매트릭스(8개 이상)\n", + "\n", + "| **번호** | **관측 공백 요인** | **영향도(높음·중간·낮음)** | **영향을 받는 판단** | **보완 수단** |\n", + "| --- | --- | --- | --- | --- |\n", + "| **1** | | | | |\n", + "| **2** | | | | |\n", + "| **3** | | | | |\n", + "| **4** | | | | |\n", + "| **5** | | | | |\n", + "| **6** | | | | |\n", + "| **7** | | | | |\n", + "| **8** | | | | |\n", + "| **9** | | | | |\n", + "| **10** | | | | |\n", + "\n", + "나. 작성 서식 3 - 표현 규칙과 판단 한계: 센서가 대상을 보지 못했을 때 쓸 수 있는 표현은 ( ), 쓰면 안 되는 표현은 ( ). 이 평가로 판단할 수 없는 경우 3가지: 1) ( ) 2) ( ) 3) ( )\n", + "\n", + "다. 점검표\n", + "\n", + "| **점검 항목** | **확인** |\n", + "| --- | --- |\n", + "| 단정 오류 4건 모두 문장을 고쳐 썼다 | ( ) |\n", + "| 관측 공백 요인을 8개 이상 찾고 영향도를 매겼다 | ( ) |\n", + "| 공백마다 보완 수단을 1개 이상 적었다 | ( ) |\n", + "| '관측되지 않음'과 '존재하지 않음'을 구분하는 표현 규칙을 정했다 | ( ) |\n", + "| 판단의 한계 3가지를 적었다 | ( ) |\n", + "\n", + "**\n", + "\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 04. 관측 공백 영역 식별을 위한 가상 작전지역 센서 커버리지 맵 분석\n", + "\n", + "### 📋 미션 배경 및 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "작전 지역 내 센서 커버리지 맵을 분석하고 관측 공백 영역을 명시적으로 도출하는 도상 분석을 실습한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **가상 작전지역의 센서 커버리지 맵을 도상 분석해 관측 공백 구역을 명시적으로 도출하고, 공백 판정 기준과 구역별 관측 점수 산식을 정립한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **가상 격자(25칸) 위에 센서별 관측 범위를 겹쳐 구역별 중복 센서 수를 세고 관측되지 않는 구역과 한 센서에만 의존하는 구역을 표시 수행하고 검증하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 5개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `matrix/isr07_attribution_matrix.xlsx(md)` 형태의 결과물을 완성한다.\n", + "\n", + "### 📑 실습자료 및 작성 양식\n", + "관측 공백 영역 식별을 위한 가상 작전지역 센서 커버리지 맵 분석**\n", + "\n", + "가. 제공 자료 1 - 가상 센서 커버리지 맵(맑은 날 주간 기준, 구역별 관측 가능 센서) [교육용가상자료]\n", + "\n", + "| | **가** | **나** | **다** | **라** | **마** |\n", + "| --- | --- | --- | --- | --- | --- |\n", + "| **1** | R, G | R, E | R | (가림) | S |\n", + "| **2** | R, G, E | R, E | E | (가림) | S |\n", + "| **3** | G | E, R | E, R, S | S | (없음) |\n", + "| **4** | (없음) | R | R, S | S | S, G |\n", + "| **5** | (없음) | (없음) | G | G, S | G |\n", + "\n", + "안개가 낀 시간에는 E(전자광학)가 모든 구역에서 제외되고, 방해 시간에는 S(신호)가 제외된다. 구역 기호는 가-1처럼 가로-세로 순으로 적는다.\n", + "\n", + "가. 제공 자료 2 - 공백 판정 기준 예시 4개(나머지 8개 이상은 조별로 추가)\n", + "\n", + "| **기준** | **판정 방향** | **예시 가중치** |\n", + "| --- | --- | --- |\n", + "| **관측 가능한 센서가 0개인 구역** | 관측 불가 구역 | -5 |\n", + "| **독립 센서 1개만 가능한 구역** | 단일 센서 의존 경고 | -2 |\n", + "| **서로 다른 원리의 센서 2개 이상 가능한 구역** | 관측 신뢰 가산 | +2 |\n", + "| **기상·방해 시간에 사용할 수 없는 센서가 포함된 경우** | 시간대 감점 | -1 |\n", + "\n", + "나. 작성 서식 1 - 구역별 커버리지 판독(맑은 주간 / 안개 / 방해 시간)\n", + "\n", + "| **구분** | **관측 불가 구역(센서 0개)** | **단일 센서 의존 구역(센서 1개)** | **비고** |\n", + "| --- | --- | --- | --- |\n", + "| **맑은 주간** | | | |\n", + "| **안개 시간(E 제외)** | | | |\n", + "| **방해 시간(S 제외)** | | | |\n", + "\n", + "나. 작성 서식 2 - 공백 판정 기준과 가중치(12개 이상)\n", + "\n", + "| **번호** | **공백 판정 기준** | **판정 방향** | **가중치** |\n", + "| --- | --- | --- | --- |\n", + "| **1** | | | |\n", + "| **2** | | | |\n", + "| **3** | | | |\n", + "| **4** | | | |\n", + "| **5** | | | |\n", + "| **6** | | | |\n", + "| **7** | | | |\n", + "| **8** | | | |\n", + "| **9** | | | |\n", + "| **10** | | | |\n", + "| **11** | | | |\n", + "| **12** | | | |\n", + "| **13** | | | |\n", + "| **14** | | | |\n", + "\n", + "나. 작성 서식 3 - 구역별 관측 점수 산식과 보고 문구 규칙\n", + "\n", + "| 구역 관측 점수 = (센서 수 x ( )) + (서로 다른 원리 가산) - (시간대 감점) - (단일 센서 감점)\n", + "점수 ( ) 이상: 관측 충분 → 보고 문구 '( )'\n", + "점수 ( ) 미만: 관측 불충분 → 보고 문구 '관측 불가, 추가 관측 요청' (이상 없음 표기 금지)\n", + "구역 예시 계산: 나-3 점수 ( ) / 다-4 점수 ( ) / 가-4 점수 ( ) |\n", + "| --- |\n", + "\n", + "나. 작성 서식 4 - 레드팀 반박 기록(법적 쟁점은 '법무 검토 필요'로만 표시)\n", + "\n", + "| **대상** | **레드팀 반박('이 기준으로 공백을 놓치지 않는가')** | **작성 팀 대응** | **보완한 기준** |\n", + "| --- | --- | --- | --- |\n", + "| **기준 1** | | | |\n", + "| **기준 2** | | | |\n", + "| **기준 3** | | | |\n", + "\n", + "다. 점검표\n", + "\n", + "| **점검 항목** | **확인** |\n", + "| --- | --- |\n", + "| 격자 25칸의 관측 불가·단일 센서 구역을 3가지 조건 모두에서 구분했다 | ( ) |\n", + "| 공백 판정 기준을 12개 이상 정의하고 가중치를 붙였다 | ( ) |\n", + "| 산식과 보고 문구 규칙의 빈칸을 모두 채웠다 | ( ) |\n", + "| 점수가 낮은 구역을 '이상 없음'으로 쓰지 않는 규칙이 있다 | ( ) |\n", + "| 레드팀 반박 3건 이상에 대응해 기준을 보완했다 | ( ) |\n", + "\n", + "**\n", + "\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 05. 정보판단 한계(Limitations of Intelligence) 명시형 지휘관 브리핑 표준 서식 설계\n", + "\n", + "### 📋 미션 배경 및 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "관측 공백과 센서 한계를 명시한 지휘관 브리핑용 표준 서식(정보판단 한계 기술서)을 개발한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **관측 공백과 센서 한계를 명시한 지휘관 브리핑용 표준 서식(정보판단 한계 기술서)을 설계하고, 브리핑 승인·보류 조건과 책임 직책을 확정한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **M2~M4 기준을 센서 가동 상태, 구역 관측 점수, 확신 표현, 승인 권한 순의 의사결정 트리로 묶고 분기점마다 조건과 책임 직책을 적는다 과정을 진행하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 5개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `sop/isr07_approval_thresholds_sop.md` 형태의 결과물을 완성한다.\n", + "\n", + "### 📑 실습자료 및 작성 양식\n", + "정보판단 한계(Limitations of Intelligence) 명시형 지휘관 브리핑 표준 서식 설계**\n", + "\n", + "가. 제공 자료 1 - 가상 브리핑 상황 전개 [교육용가상자료]\n", + "\n", + "| **시각** | **상황** |\n", + "| --- | --- |\n", + "| **11:30** | 사령부가 12:00까지 PIR(가-4~다-4 구간 이동 징후)에 대한 정보 브리핑을 요구한다. |\n", + "| **11:40** | E 드론 안개 해제 지연. R 레이더 가동. S 신호 방해 시작. |\n", + "| **11:50** | G 관측소 교대로 20분 두절 예정. |\n", + "| **12:00** | 지휘관이 '한 줄로 말하면 이상 없는가'를 묻는다. |\n", + "\n", + "가. 제공 자료 2 - 경계선 상황 5종\n", + "\n", + "| **묶음** | **상황** | **애매한 이유** |\n", + "| --- | --- | --- |\n", + "| **B1** | R 레이더만 가동, 반사 없음, E·S·G는 불가 | 단일 센서이며 사각지대 구역 포함 |\n", + "| **B2** | E·R 둘 다 '없음'이나 결측률 35% | 일치하지만 결측이 큼 |\n", + "| **B3** | 상급 부서가 '이상 없음으로 보고하라'고 요청 | 압박과 근거 부족 |\n", + "| **B4** | R은 '반사 있음', E는 '없음'(안개 구간) | 센서 간 상충, E의 신뢰가 낮음 |\n", + "| **B5** | 승인 권한 직책 부재 중, 대리 지정 없음 | 권한자 부재 |\n", + "\n", + "나. 작성 서식 1 - 정보판단 한계 기술서 서식(빈칸을 채워 표준 서식을 완성한다)\n", + "\n", + "| **결론(BLUF)** | (한 문장, 확신도 3단계 중 하나를 앞에 적는다) |\n", + "| --- | --- |\n", + "\n", + "| **근거 센서·구역·시간** | |\n", + "| --- | --- |\n", + "\n", + "| **관측 공백(관측 불가 구역·시간)** | |\n", + "| --- | --- |\n", + "\n", + "| **결측률과 독립 센서 수** | 결측률 ( )%, 독립 센서 ( )종 |\n", + "| --- | --- |\n", + "\n", + "| **판단의 한계** | |\n", + "| --- | --- |\n", + "\n", + "| **보완 계획(재관측·다른 센서)** | |\n", + "| --- | --- |\n", + "\n", + "| **승인 책임 직책** | |\n", + "| --- | --- |\n", + "\n", + "나. 작성 서식 2 - 의사결정 트리(빈칸을 채운다)\n", + "\n", + "| [분기 1] 센서 가동 상태와 결측률이 보고서에 기재되어 있는가?\n", + " 아니오 → 브리핑 보류 + 기재 보완 (책임: ( ))\n", + " 예 ↓\n", + "[분기 2] 독립 센서가 ( )종 이상 같은 방향으로 뒷받침하고 결측률이 ( )% 이하인가?\n", + " 아니오 → 확신도 '추정' 이하로 표기 + 추가 관측 요청 (책임: ( ))\n", + " 예 ↓\n", + "[분기 3] 관측 불가 구역이나 센서 간 상충이 남아 있는가?\n", + " 예 → 한계 기술서에 명시 후 브리핑 (책임: ( ))\n", + " 아니오 ↓\n", + "[분기 4] 승인 권한 직책과 연락이 되는가?\n", + " 아니오 → 브리핑 보류 + 대체 연락 경로 (책임: ( ))\n", + " 예 → 사람의 최종 승인 (책임: ( )) |\n", + "| --- |\n", + "\n", + "나. 작성 서식 3 - 분기점·책임 직책 매핑표\n", + "\n", + "| **분기점** | **판정 조건(수치·관찰 기준)** | **'예'일 때** | **'아니오'일 때** | **책임 직책(가상)** |\n", + "| --- | --- | --- | --- | --- |\n", + "| **분기 1** | | | | |\n", + "| **분기 2** | | | | |\n", + "| **분기 3** | | | | |\n", + "| **분기 4** | | | | |\n", + "\n", + "나. 작성 서식 4 - 모의 결심 기록지(판단은 '브리핑 승인·브리핑 보류·추가 관측 요청' 중 하나)\n", + "\n", + "| **묶음** | **멈춘 분기점** | **판단** | **근거(자료 인용)** | **책임 직책** | **조원 합의** |\n", + "| --- | --- | --- | --- | --- | --- |\n", + "| **B1** | | | | | |\n", + "| **B2** | | | | | |\n", + "| **B3** | | | | | |\n", + "| **B4** | | | | | |\n", + "| **B5** | | | | | |\n", + "\n", + "나. 작성 서식 5 - 긴급 브리핑 규칙\n", + "\n", + "| **항목** | **평시 브리핑** | **긴급 브리핑** | **사후 보완 항목** |\n", + "| --- | --- | --- | --- |\n", + "| **한계 기술 항목** | | | |\n", + "| **보고 형식** | | | |\n", + "| **승인 권한** | | | |\n", + "\n", + "| 긴급 브리핑에서도 한 줄 한계 문구('관측 공백 있음, 확신도 추정' 등)는 생략하지 않는다. 형식을 줄일 수는 있으나 결측 정보, 독립 센서 수, 사람의 승인은 생략하지 않는다. 실제 교전·작전 판단은 교전수칙과 지휘계통에 따른다. |\n", + "| --- |\n", + "\n", + "다. 점검표\n", + "\n", + "| **점검 항목** | **확인** |\n", + "| --- | --- |\n", + "| 한계 기술서 7개 항목의 작성 방법을 정했다 | ( ) |\n", + "| 의사결정 트리 4개 분기점의 조건을 수치·관찰 기준으로 명시했다 | ( ) |\n", + "| 모든 분기점에 책임 직책을 매핑했다 | ( ) |\n", + "| 경계선 상황 5종 모두에 판단과 근거를 기록했다 | ( ) |\n", + "| 사람의 승인 없이 브리핑이 승인되는 경로가 없다 | ( ) |\n", + "\n", + "**\n", + "\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 06. 센서 결측률에 따른 정보 신뢰 수준 3단계(확정/추정/미상) 분류 기준서 작성\n", + "\n", + "### 📋 미션 배경 및 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "센서 가동률 및 결측 수준에 따른 정보 확신도 3단계(확정/추정/미상) 분류 기준을 수립한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **센서 가동률과 결측률에 따라 정보 확신도를 확정·추정·미상 3단계로 나누는 분류 기준서와 기록 서식을 만든다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **센서 가동 기록에서 결측률을 계산하는 방법을 정하고 결측률 구간과 독립 센서 수에 따라 확정·추정·미상을 가르는 기준표 설계하고 작성하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 5개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `templates/isr07_aar_fact_action_template.md` 형태의 결과물을 완성한다.\n", + "\n", + "### 📑 실습자료 및 작성 양식\n", + "센서 결측률에 따른 정보 신뢰 수준 3단계(확정/추정/미상) 분류 기준서 작성**\n", + "\n", + "가. 제공 자료 1 - 가상 센서 가동 기록 [교육용가상자료]\n", + "\n", + "| **센서** | **계획 관측 시간(분)** | **실제 가동(분)** | **결측(분)** | **결측률(%)** |\n", + "| --- | --- | --- | --- | --- |\n", + "| **E 드론** | 60 | 42 | 18 | |\n", + "| **R 레이더** | 60 | 54 | 6 | |\n", + "| **S 신호** | 60 | 36 | 24 | |\n", + "| **G 관측소** | 120 | 100 | 20 | |\n", + "\n", + "가. 제공 자료 2 - 원문 보고 3종(사실·판단이 섞여 있다) / 혼동 유발 표현 예시\n", + "\n", + "| **보고** | **원문** |\n", + "| --- | --- |\n", + "| **보고 1** | 12:30 소대. 가-4~나-4 이상 없음. E 드론 42분 가동. 아마 이동 없었을 것. 상황실에 먼저 알림. |\n", + "| **보고 2** | 12:50 소대. 라-4 차량 확실히 없음. G 관측소 교대 중이었으나 문제없음으로 판단. |\n", + "| **보고 3** | 13:10 소대. 신호 징후 없음. 방해가 있었으나 별일 없을 듯. |\n", + "\n", + "| **금지 표현** | **문제점** | **대체 표현** |\n", + "| --- | --- | --- |\n", + "| **이상 없음** | 관측 불가 구간이 있는데 부재로 읽힘 | '관측된 구간에서는 징후 없음, 관측 불가 구간 ( )' |\n", + "| **확실히 없음** | | |\n", + "| **아마** | | |\n", + "| **문제없음** | | |\n", + "| **별일 없을 듯** | | |\n", + "| **조용함** | | |\n", + "\n", + "나. 작성 서식 1 - 3단계 분류 기준표(확정·추정·미상). 결측률과 독립 센서 수의 경계를 정한다.\n", + "\n", + "| **확신도** | **결측률 기준** | **독립 센서 수** | **상충·반대 정황** | **보고에 쓸 표현** |\n", + "| --- | --- | --- | --- | --- |\n", + "| **확정** | ( )% 이하 | ( )종 이상 | 없음 | |\n", + "| **추정** | ( )% ~ ( )% | ( )종 | 미해소 상충 없음 | |\n", + "| **미상** | ( )% 초과 또는 | ( )종 이하 | 상충 있음 | |\n", + "\n", + "나. 작성 서식 2 - 기록 서식(사실·판단·조치·미확인 분리)\n", + "\n", + "| **시각** | **확인된 사실(센서·시간·결측률)** | **판단과 근거(확신도)** | **취한 조치** | **미확인 사항** |\n", + "| --- | --- | --- | --- | --- |\n", + "| | | | | |\n", + "| | | | | |\n", + "| | | | | |\n", + "\n", + "나. 작성 서식 3 - 가상 보고 3종 변환 결과\n", + "\n", + "| **보고** | **확신도(확정·추정·미상)** | **고쳐 쓴 사실 문장** | **고쳐 쓴 판단 문장** | **조원 분류 일치** |\n", + "| --- | --- | --- | --- | --- |\n", + "| **보고 1** | | | | |\n", + "| **보고 2** | | | | |\n", + "| **보고 3** | | | | |\n", + "\n", + "다. 점검표\n", + "\n", + "| **점검 항목** | **확인** |\n", + "| --- | --- |\n", + "| 결측률을 모두 계산하고 3단계 기준의 빈칸을 채웠다 | ( ) |\n", + "| 기록 서식의 사실·판단·조치·미확인 칸이 분리되어 있다 | ( ) |\n", + "| 혼동 유발 표현을 6개 이상 정하고 대체 표현을 적었다 | ( ) |\n", + "| 가상 보고 3종을 변환했고 사실 칸에 추측 표현이 0건이다 | ( ) |\n", + "| '이상 없음' 표기 금지와 관측 불가 구간 명기 규칙이 있다 | ( ) |\n", + "\n", + "**\n", + "\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 07. 센서 고장 및 통신 두절 모의 환경에서의 정보 공백 브리핑 스트레스 훈련\n", + "\n", + "### 📋 미션 배경 및 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "센서 복합 장애 및 통신 두절 상황을 모의하여 정보 공백 하에서의 판단 브리핑 스트레스 훈련을 진행한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **센서가 연달아 고장 나고 통신이 끊기는 모의 환경에서 정보 공백을 정직하게 보고하는 브리핑을 훈련하고 평가 루브릭을 만든다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **정해진 시간에 센서 장애 카드(영상 두절, 레이더 잡음, 신호 방해, 통신 두절)가 연달아 투입되는 조건에서 조별로 정보판단 한계 브리핑을 작성해 발표 수행하고 검증하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 5개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `rubric/isr07_evaluation_rubric.md` 형태의 결과물을 완성한다.\n", + "\n", + "### 📑 실습자료 및 작성 양식\n", + "센서 고장 및 통신 두절 모의 환경에서의 정보 공백 브리핑 스트레스 훈련**\n", + "\n", + "가. 제공 자료 1 - 센서 장애 카드 투입 순서(통제관용) [교육용가상자료]\n", + "\n", + "| **경과 시간** | **카드** | **내용** | **통제관 메모(평가자만)** |\n", + "| --- | --- | --- | --- |\n", + "| **0:00** | F0 | PIR 제시. 25분 안에 한계 브리핑 발표 준비. | - |\n", + "| **3:00** | F1 | E 드론 영상 두절(배터리 교체) | 영상 공백 발생 |\n", + "| **7:00** | F2 | R 레이더 잡음 증가, 반사 불안정 | 신뢰 하락 |\n", + "| **11:00** | F3 | S 신호 방해로 결측 40% | 결측률 높음 |\n", + "| **15:00** | F4 | G 관측소 통신 두절 | 전 센서 약화 |\n", + "| **19:00** | F5 | 상급 부서: '이상 없음 한 줄이면 된다' | 압박(한계 명시 원칙 점검) |\n", + "| **22:00** | F6 | R 레이더 일시 복구, '반사 없음' 보고 | 복구 직후 결측 구간은 여전히 미상 |\n", + "\n", + "가. 제공 자료 2 - 역할: 정보 책임(조장) 1명, 기록자 1명, 평가자 2명(서로 상의 금지), 통제관(교관)은 카드를 시간에 맞춰 읽는다.\n", + "\n", + "나. 작성 서식 1 - 5영역 4등급 루브릭(예시 1행 포함)\n", + "\n", + "| **영역** | **탁월** | **우수** | **보통** | **미흡** |\n", + "| --- | --- | --- | --- | --- |\n", + "| **한계 인식** | 결측 구간과 시간, 결측률을 수치로 말하고 보완 수단까지 제시함 | 결측 구간과 결측률을 말함 | 결측이 있다고만 말함 | 결측을 언급하지 않거나 '이상 없음'이라고 함 |\n", + "| **확신 수준 표현** | | | | |\n", + "| **보완 수단 제시** | | | | |\n", + "| **보류 원칙 준수** | | | | |\n", + "| **기록** | | | | |\n", + "\n", + "나. 작성 서식 2 - 평가자 간 채점 일치 확인(탁월 4, 우수 3, 보통 2, 미흡 1)\n", + "\n", + "| **조 / 영역** | **한계 인식** | **확신 수준** | **보완 수단** | **보류 원칙** | **기록** | **일치 칸 수** |\n", + "| --- | --- | --- | --- | --- | --- | --- |\n", + "| **1조 (A / B)** | / | / | / | / | / | / 5 |\n", + "| **2조 (A / B)** | / | / | / | / | / | / 5 |\n", + "| **3조 (A / B)** | / | / | / | / | / | / 5 |\n", + "| **4조 (A / B)** | / | / | / | / | / | / 5 |\n", + "\n", + "일치율 = 두 평가자 점수가 같은 칸 수 / 전체 칸 수 x 100 = ( )% (목표 80% 이상). 점수 차이가 2점 이상인 칸은 루브릭 문구를 고친다.\n", + "\n", + "나. 작성 서식 3 - 결측 구간 보고 점검: 결측 구간 보고 ( )건 중 '이상 없음'으로 보고 ( )건, 확신도를 낮추거나 관측 불가로 명시 ( )건\n", + "\n", + "다. 점검표\n", + "\n", + "| **점검 항목** | **확인** |\n", + "| --- | --- |\n", + "| 루브릭 5영역 x 4등급 20칸을 모두 관찰 가능한 행동으로 채웠다 | ( ) |\n", + "| 평가자 2명이 서로 상의하지 않고 독립 채점했다 | ( ) |\n", + "| 일치율을 계산했고 80% 이상이거나, 미만이면 루브릭을 고쳐 다시 채점했다 | ( ) |\n", + "| 결측 구간을 '이상 없음'으로 보고한 브리핑이 0건이다 | ( ) |\n", + "| '미흡' 영역에 보충 학습 방법을 1개 이상 적었다 | ( ) |\n", + "\n", + "**\n", + "\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 08. AAR 사후검토를 통한 과잉 확신(Overconfidence) 판단 오류 도출 및 피드백\n", + "\n", + "### 📋 미션 배경 및 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "AAR을 통해 과잉 확신(Overconfidence)에 따른 정보 판단 오류 사례를 분석하고 피드백을 도출한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **AAR을 통해 과잉 확신 판단 오류의 원인을 분석하고, 레드팀 비판으로 분류 기준과 서식의 취약한 전제를 찾아 피드백을 도출한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **가상 AAR 일지 2건(관측 공백을 부재로 단정한 사례, 센서 한 종류에 과도하게 의존한 사례)을 시간순으로 재구성하고 원인을 사람·절차·정보·통신으로 나눈다 과정을 진행하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 5개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `reports/isr07_redteam_critique_report.md` 형태의 결과물을 완성한다.\n", + "\n", + "### 📑 실습자료 및 작성 양식\n", + "AAR 사후검토를 통한 과잉 확신(Overconfidence) 판단 오류 도출 및 피드백**\n", + "\n", + "가. 제공 자료 1 - 가상 AAR 일지 A(관측 공백을 부재로 단정한 사례) [교육용가상자료]\n", + "\n", + "| **시각** | **기록** |\n", + "| --- | --- |\n", + "| **12:10** | E 드론 영상에서 가-4 구간 물체 없음. 영상 공백 2분은 기록되지 않음. |\n", + "| **12:20** | R 레이더 사각지대(라-1) 반사 없음. 담당이 '적 부재'로 정리. |\n", + "| **12:40** | 브리핑에서 지휘관이 '확실한가'를 물었고 '이상 없음'이라고 답함. |\n", + "| **14:00** | 후속 보고에서 라-1 방면 이동이 있었던 것으로 확인됨(가상). |\n", + "\n", + "가. 제공 자료 2 - 가상 AAR 일지 B(한 종류 센서에 의존한 사례) [교육용가상자료]\n", + "\n", + "| **시각** | **기록** |\n", + "| --- | --- |\n", + "| **10:00** | R 레이더 보고만 근거로 '징후 없음' 판단. |\n", + "| **10:20** | S 신호 결측 40%, E 드론 두절은 판단 근거에서 제외됨. |\n", + "| **10:50** | 동료가 두 센서 결측을 브리핑에 넣자고 제안했으나 '간단히' 해야 한다며 생략. |\n", + "| **13:00** | 결측 구간에서 가상 이동이 있었던 것으로 후속 확인됨. |\n", + "\n", + "나. 작성 서식 1 - AAR 원인 분석표(비난 없이 사실 중심)\n", + "\n", + "| **사례** | **무엇이 계획되었나** | **실제로 무슨 일이 있었나** | **원인(사람·절차·정보·통신)** | **개선안** |\n", + "| --- | --- | --- | --- | --- |\n", + "| **A** | | | | |\n", + "| **A** | | | | |\n", + "| **B** | | | | |\n", + "| **B** | | | | |\n", + "\n", + "나. 작성 서식 2 - 레드팀 쟁점·대응표(5건 이상, 예시 1건)\n", + "\n", + "| **번호** | **취약한 전제** | **레드팀 비판** | **작성 팀 반박 또는 수정안** | **경고 문구** |\n", + "| --- | --- | --- | --- | --- |\n", + "| **1** | 두 센서가 일치하면 확정이다 | 두 센서가 같은 사각지대를 공유하거나 같은 시간에 결측일 수 있다 | 독립 센서는 원리와 사각지대가 다른 경우만 인정하고 결측률을 함께 본다 | '센서 수가 많아도 같은 결측 구간이면 확정하지 않는다' |\n", + "| **2** | | | | |\n", + "| **3** | | | | |\n", + "| **4** | | | | |\n", + "| **5** | | | | |\n", + "| **6** | | | | |\n", + "\n", + "나. 작성 서식 3 - 반대 방향 위험 점검: 한계 문구가 너무 많아 지휘관이 핵심을 놓칠 위험과 이를 줄이는 방법(한 줄 요약 우선, 한계는 우선순위 3개까지)을 3줄 이내로 적는다.\n", + "\n", + "나. 작성 서식 4 - 수정 반영 계획표\n", + "\n", + "| **번호** | **수정할 문서·조항** | **수정 내용** | **담당(가상 직책)** | **완료 기한** |\n", + "| --- | --- | --- | --- | --- |\n", + "| **1** | | | | |\n", + "| **2** | | | | |\n", + "| **3** | | | | |\n", + "| **4** | | | | |\n", + "| **5** | | | | |\n", + "\n", + "다. 점검표\n", + "\n", + "| **점검 항목** | **확인** |\n", + "| --- | --- |\n", + "| AAR 일지 2건을 시간순으로 재구성하고 계획과 실제의 차이를 적었다 | ( ) |\n", + "| 원인을 사람·절차·정보·통신으로 분류하고 개인 비난 표현이 없다 | ( ) |\n", + "| 레드팀 쟁점을 5건 이상 도출했다 | ( ) |\n", + "| 모든 쟁점에 반박 또는 수정안을 적었다(100%) | ( ) |\n", + "| 취약한 전제마다 경고 문구를 달았다 | ( ) |\n", + "| 한계 문구 과다의 반대 방향 위험을 다뤘다 | ( ) |\n", + "\n", + "**\n", + "\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 09. 개선된 정보판단 한계 브리핑 양식 기반 모의 상황 재브리핑 검증\n", + "\n", + "### 📋 미션 배경 및 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "피드백을 반영한 정보판단 한계 브리핑 양식을 적용하여 모의 브리핑을 재실행하고 타당성을 검증한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **M7·M8 피드백을 반영한 정보판단 한계 브리핑 서식으로 같은 상황을 재브리핑해 개선 효과를 검증하고 지휘관 1페이지 브리핑으로 정리한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **개선 전후 서식을 나란히 놓고 바뀐 항목과 이유(근거 미션)를 적는다 과정을 진행하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 5개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `briefing/isr07_commander_1page_brief.md(pdf)` 형태의 결과물을 완성한다.\n", + "\n", + "### 📑 실습자료 및 작성 양식\n", + "개선된 정보판단 한계 브리핑 양식 기반 모의 상황 재브리핑 검증**\n", + "\n", + "가. 제공 자료 - 재검증 대상 7건: M5 경계선 상황 B1~B5, M8 AAR 사례 A·B. 조별로 개선 전 서식과 개선 후 서식을 각각 적용해 브리핑을 작성한다.\n", + "\n", + "나. 작성 서식 1 - 서식 개선 전후 비교표\n", + "\n", + "| **항목** | **개선 전** | **개선 후** | **변경 이유(근거 미션)** |\n", + "| --- | --- | --- | --- |\n", + "| **한계 문구 위치** | | | |\n", + "| **결측률 표기** | | | |\n", + "| **확신도 3단계** | | | |\n", + "| **관측 불가 구역 표기** | | | |\n", + "| **승인 권한** | | | |\n", + "| **긴급 브리핑** | | | |\n", + "\n", + "나. 작성 서식 2 - 재검증 결과표\n", + "\n", + "| **사례** | **개선 전 판단** | **개선 후 판단** | **조원 일치(전 → 후)** | **브리핑 준비 시간(분, 전 → 후)** | **비고** |\n", + "| --- | --- | --- | --- | --- | --- |\n", + "| **B1** | | | | | |\n", + "| **B2** | | | | | |\n", + "| **B3** | | | | | |\n", + "| **B4** | | | | | |\n", + "| **B5** | | | | | |\n", + "| **A** | | | | | |\n", + "| **B** | | | | | |\n", + "\n", + "나. 작성 서식 3 - 지휘관 1페이지 브리핑 틀(BLUF)\n", + "\n", + "| **결론(BLUF)** | (한 문장으로 PIR에 대한 현재 판단과 확신도) |\n", + "| --- | --- |\n", + "\n", + "| **핵심 결심 사항** | 1) ( ) 2) ( ) |\n", + "| --- | --- |\n", + "\n", + "| **위험 요소(신호등)** | 빨강: ( )\n", + "노랑: ( )\n", + "초록: ( ) |\n", + "| --- | --- |\n", + "\n", + "| **대안 3종** | 대안 1 ( ) / 대안 2 ( ) / 대안 3 ( ) - 각 장단점 한 줄 |\n", + "| --- | --- |\n", + "\n", + "| **관측 공백 요약** | ( ) |\n", + "| --- | --- |\n", + "\n", + "| **근거** | 결측률, 독립 센서 수, 재검증 결과 요약 |\n", + "| --- | --- |\n", + "\n", + "나. 작성 서식 4 - 브리핑 리허설 기록\n", + "\n", + "| **항목** | **목표** | **결과** |\n", + "| --- | --- | --- |\n", + "| 발표 시간 | 3분 이내 | |\n", + "| 분량 | A4 1페이지 | |\n", + "| 질의 응답 | 3건 이상 답변 | |\n", + "| 결론 먼저 제시 | 첫 문장 | |\n", + "\n", + "다. 점검표\n", + "\n", + "| **점검 항목** | **확인** |\n", + "| --- | --- |\n", + "| 개선 전후 서식의 바뀐 항목과 이유를 모두 적었다 | ( ) |\n", + "| 재검증 7건 모두 개선 전후 판단을 비교했다 | ( ) |\n", + "| 브리핑이 결론부터 시작하고 A4 1페이지에 들어간다 | ( ) |\n", + "| 위험 요소를 신호등으로 표시하고 대안 3종을 제시했다 | ( ) |\n", + "| 3분 안에 발표하고 질의에 답했다 | ( ) |\n", + "\n", + "**\n", + "\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 10. 관측 공백·센서 한계 명시 정보판단 가이드라인 및 실무 적용서 완성\n", + "\n", + "### 📋 미션 배경 및 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "관측 공백 및 센서 한계를 포함한 정보판단 한계 브리핑 표준 가이드라인을 최종 완성한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **앞 미션의 결과물을 통합해 관측 공백·센서 한계를 명시하는 정보판단 가이드라인과 실무 적용서를 8개 장과 부록으로 완성한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **목적·적용 범위, 용어 정의, 센서별 관측 한계, 불확실성 지표, 단정 방지 지침, 브리핑 서식과 승인·보류, 기록·분류, 훈련·평가·개정의 8개 장과 부록(6단계 기준, 커버리지 매트릭스, 3단계 분류표, 서식, 루브릭)을 편집 수행하고 검증하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 5개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `manual/isr07_standard_operational_manual.md` 형태의 결과물을 완성한다.\n", + "\n", + "### 📑 실습자료 및 작성 양식\n", + "관측 공백·센서 한계 명시 정보판단 가이드라인 및 실무 적용서 완성**\n", + "\n", + "가. 제공 자료 1 - 가이드라인 8개 장 구성과 가져올 결과물\n", + "\n", + "| **장** | **제목** | **포함 내용** | **가져올 결과물** |\n", + "| --- | --- | --- | --- |\n", + "| **1** | 목적과 적용 범위 | 목적, 적용 상황, 적용하지 않는 상황, 교전수칙과의 관계 | M1 |\n", + "| **2** | 용어 정의 | 관측 불가, 결측률, 독립 센서, 확신도, 신뢰 구간 | M1~M3 |\n", + "| **3** | 센서별 관측 한계 | 센서 유형별 한계 요인과 영향 | M1 |\n", + "| **4** | 불확실성 지표 | 6단계 기준, 평가 지표, 신뢰 구간 표기 | M2·M3 |\n", + "| **5** | 단정 방지와 커버리지 | 표현 규칙, 공백 판정 기준, 구역 점수 | M3·M4 |\n", + "| **6** | 브리핑 서식과 승인·보류 | 한계 기술서, 의사결정 트리, 권한과 책임 | M5·M9 |\n", + "| **7** | 기록·분류 | 3단계 분류 기준, 기록 서식, 금지 표현 | M6 |\n", + "| **8** | 훈련·평가·개정 | 스트레스 훈련, 루브릭, AAR, 개정 관리 | M7·M8 |\n", + "\n", + "가. 제공 자료 2 - 모의 심의 역할 카드\n", + "\n", + "| **역할** | **검토 질문** |\n", + "| --- | --- |\n", + "| **교리 역할** | 센서별 한계 설명과 단계 정의가 일관되는가? 용어를 신병도 이해할 수 있는가? |\n", + "| **법무 역할** | '최종 판단은 사람이, 교전수칙·지휘계통에 따라'가 절차마다 드러나는가? 법적 쟁점이 법무 검토로 연계되는가? |\n", + "| **작전 역할** | 현장에서 3분 브리핑에 쓸 수 있는가? 한계 문구가 너무 길어 핵심이 묻히지 않는가? |\n", + "\n", + "나. 작성 서식 1 - 근거 표기 점검표(조항마다 작성)\n", + "\n", + "| **조항** | **기준 내용 요약** | **근거(미션·가상 자료)** | **표기 여부** |\n", + "| --- | --- | --- | --- |\n", + "| **1-1** | | | |\n", + "| **2-1** | | | |\n", + "| **3-1** | | | |\n", + "| **4-1** | | | |\n", + "| **5-1** | | | |\n", + "| **6-1** | | | |\n", + "| **7-1** | | | |\n", + "| **8-1** | | | |\n", + "\n", + "나. 작성 서식 2 - 모의 심의 지적 사항 반영표\n", + "\n", + "| **번호** | **역할** | **지적 사항** | **반영 내용** | **반영 위치(장·조)** |\n", + "| --- | --- | --- | --- | --- |\n", + "| **1** | | | | |\n", + "| **2** | | | | |\n", + "| **3** | | | | |\n", + "| **4** | | | | |\n", + "| **5** | | | | |\n", + "| **6** | | | | |\n", + "\n", + "나. 작성 서식 3 - 개정 관리: 개정 주기 ( ), 의견 수렴 방법 ( ), 개정 책임 직책(가상) ( ), 개정 이력 기록 위치 ( )\n", + "\n", + "다. 점검표\n", + "\n", + "| **점검 항목** | **확인** |\n", + "| --- | --- |\n", + "| 8개 장과 부록을 모두 완성했다 | ( ) |\n", + "| 모든 기준 조항에 근거(미션·가상 자료)를 표기했다 | ( ) |\n", + "| 교리·법무·작전 역할의 지적 사항을 반영하고 반영 위치를 적었다 | ( ) |\n", + "| '최종 판단은 사람이 하며 실제 작전·교전 판단은 교전수칙·지휘계통에 따른다'는 문구가 1장과 6장에 있다 | ( ) |\n", + "| 연 1회 개정 주기와 의견 수렴 방법을 명시했다 | ( ) |\n", + "| 실제 부대·위치·센서 성능·정보 내용을 쓰지 않았다 | ( ) |\n", + "\n", + "**4. 평가 채점표**\n", + "\n", + "미션별 평가 기준은 시나리오의 평가 항목과 같다. 평가자는 기준 충족 여부를 PASS/FAIL로 표시하고 FAIL이면 사유를 비고에 적는다.\n", + "\n", + "| **미션** | **구분** | **채점 기준** | **판정** | **비고** |\n", + "| --- | --- | --- | --- | --- |\n", + "| **M1** | 필수 | 관측 한계 요인 분석·문서 골격: 가상 관측 한계 사례 5건 이상을 검토해 센서 유형별 한계 요인과 영향을 분류했는가, 가이드라인 8개 장 목차 골격을 100% 정의하고 지휘관 결심 의제 순으로 배열했는가. | PASS / FAIL | |\n", + "| **M2** | 필수 | 6단계 신뢰도 기준·지표 체계: 관측 신뢰도 기준 6단계를 완비하고 평가 지표 10개 이상을 정의했는가, 가상 센서 기록 5건에 대한 조원 간 단계 일치가 4건 이상이고 편향 방지(단일 센서 의존 경고, 평가자 교차 확인) 규칙을 명시했는가. | PASS / FAIL | |\n", + "| **M3** | 필수 | 단정 방지 지침·불확실성 평가: 관측 공백 요인 8개 이상과 영향도 매트릭스를 도출했는가, 단정 오류 4건에 원인을 적고 '관측되지 않음'과 '없음'을 구분하는 표현 규칙과 판단 한계를 객관적으로 명시했는가. | PASS / FAIL | |\n", + "| **M4** | 선택 | 커버리지 맵·공백 판정 기준: 공백 판정 기준 12개 이상 항목화와 가중치 산식을 100% 정립했는가, 가상 격자 25칸의 공백·단일 센서 의존 구역을 오류 없이 도출하고 레드팀 반박(허점 지적)에 대한 보완 논리를 기록했는가. | PASS / FAIL | |\n", + "| **M5** | 필수 | 승인·보류 분기점·책임 매핑: 분기점 조건 100% 명시와 책임 직책 매핑을 완료했는가, 경계선 상황 5종 모두에 판단과 근거가 기록되고 교전수칙·인도주의 원칙 준수와 사람의 최종 판단을 지켰는가. | PASS / FAIL | |\n", + "| **M6** | 선택 | 3단계 분류 기준·기록 서식: 기록 분류 규칙을 100% 표준화하고 혼동 유발 단어 금지 목록을 포함했는가, 가상 보고 3종을 서식으로 옮겼을 때 확정·추정·미상 분류 불일치가 0건이고 사실 칸에 추측 표현이 없는가. | PASS / FAIL | |\n", + "| **M7** | 필수 | 정보 공백 스트레스·평가 루브릭: 5개 영역 4등급 기준 정의율 100%, 평가자 2명의 채점 일치율 80% 이상, 결측 구간을 '이상 없음'으로 보고한 브리핑이 0건인가. | PASS / FAIL | |\n", + "| **M8** | 선택 | AAR 원인·레드팀 검토: 비판적 쟁점 5건 이상을 도출하고 쟁점별 반박·수정 대안을 100% 수립했는가, 취약한 전제에 경고 문구를 명시하고 한계 문구 과다의 반대 위험을 다뤘는가. | PASS / FAIL | |\n", + "| **M9** | 필수 | 재브리핑 검증·1페이지 브리핑: 핵심 결심 사항, 위험 요소, 대안 3종이 A4 1페이지 안에 완결되었는가, 재검증 7건의 개선 전후 비교를 제시하고 결론 먼저(BLUF) 원칙으로 3분 안에 전달했는가. | PASS / FAIL | |\n", + "| **M10** | 필수 | 가이드라인 최종 완성: 8개 장과 부록을 100% 완성하고 모든 기준에 근거(미션·가상 자료)를 표기했는가, 모의 심의 지적 사항을 반영하고 '최종 판단은 사람이 하며 실제 작전·교전 판단은 교전수칙·지휘계통에 따른다'는 문구를 명시했는가. | PASS / FAIL | |\n", + "\n", + "| **판정 기준** | 필수 7개 미션(M1·M2·M3·M5·M7·M9·M10)이 모두 PASS여야 최종 PASS로 인정한다. 선택 미션(M4·M6·M8)은 가점·보완 의견에 반영한다. |\n", + "| --- | --- |\n", + "\n", + "| **공통 감점** | 실제 부대명·위치·센서 성능·정보 내용·실존 인물 정보를 사용한 경우, 부대 자료를 외부 AI 서비스에 입력한 경우 해당 미션은 FAIL로 처리한다. |\n", + "| --- | --- |\n", + "\n", + "| **주제 안전 기준** | 관측 불가 구간을 '이상 없음'으로 쓰거나 가이드라인에 사람의 최종 판단·교전수칙 우선 문구가 없으면 M5·M10은 FAIL로 처리한다. |\n", + "| --- | --- |\n", + "\n", + "| **최종 판정 결과** | ( PASS / FAIL ) 평가자 서명: ( ) |\n", + "| --- | --- |\n", + "\n", + "| 안전 원칙: 본 자료의 신뢰도 단계, 분류 기준, 승인 권한 구분은 학습용 가상 기준이다. 최종 판단은 사람이 내리며 실제 작전·교전 판단은 교전수칙과 지휘계통에 따른다. 실제 부대 자료를 입력하거나 부대 내에서 촬영하지 않는다. |\n", + "| --- |\n", + "\n" + ] + } + ], + "metadata": { + "colab": { + "provenance": [] + }, + "kernelspec": { + "display_name": "Python 3", + "name": "python3" + }, + "language_info": { + "name": "python" + } + }, + "nbformat": 4, + "nbformat_minor": 0 +} \ No newline at end of file diff --git a/uploads/ISR08/ISR08_workbook.ipynb b/uploads/ISR08/ISR08_workbook.ipynb new file mode 100644 index 0000000..53dbf05 --- /dev/null +++ b/uploads/ISR08/ISR08_workbook.ipynb @@ -0,0 +1,887 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# 📋 [ISR08] 공개 사진의 위치·시간 단서 교차확인 실습\n", + "### 실습 워크북 & SOP 가이드 (Google Colab / Jupyter)\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/ISR08/ISR08_workbook.ipynb)\n", + "\n", + "---\n", + "\n", + "## 📌 1. 프로젝트 개요 및 배경\n", + "\n", + "| 항목 | 내용 |\n", + "| :--- | :--- |\n", + "| **과정 분류** | 정보·감시·정찰(ISR) (Level 2) |\n", + "| **학습 소요 시간** | 10개 미션 (5일 / 총 27.0시간) |\n", + "| **실습 유형** | 작전절차(SOP), 체크리스트, 모의훈련, AAR 사후검토 실습 |\n", + "\n", + "### 📖 프로젝트 목표\n", + "러시아-우크라이나 전쟁에서는 SNS와 뉴스에 올라온 전장 사진이 부대 이동·피해 상황을 가장 먼저 알리는 단서가 되었지만, 과거 사진 재탕, 다른 지역 사진, 촬영 시각을 속인 게시물도 함께 쏟아져 공개 사진을 검증하는 역량이 정보판단의 전제가 되었다. 본 프로젝트에서는 교육용 가상 지역 '새솔읍'의 가상 사진 설명 카드와 가상 태양 위치표·지도 격자를 이용해, 메타데이터(EXIF) 확인, 그림자 방향·길이로 촬영 시간대 추정, 랜드마크·도로망 대조로 위치 확인, 기만 사진 식별, 교대 인계, 고부하 상황 대응, 사후검토(AAR), 재실행까지 OSINT 사진 검증 절차를 역할을 나눠 직접 수행한다. 각 사진은 '확인·추정·미확인·기만 의심' 가운데 하나로 판정하고 근거를 기록하며, 최종 판정은 검증관이 승인한다. 최종 목표는 공개 사진 한 장으로 단정하지 않고 여러 단서를 교차확인해 근거와 한계를 함께 보고하는 표준 검증 절차(SOP)와 부대 전파용 교육 패키지를 완성하는 것이다.\n", + "\n", + "> **⚠️ [교육용 가상 자료 원칙]**\n", + "> - 본 워크북의 모든 자료는 안전 규정에 따라 **교육용 가상 자료**로 구성되어 있습니다.\n", + "> - 실제 군사 기밀이나 부대 세부 인적·작전 정보는 포함되지 않습니다.\n", + "\n", + "---\n", + "\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 01. 공개 출처(SNS/뉴스) 사진의 메타데이터(EXIF) 및 지형 단서 분석 계획 수립\n", + "\n", + "### 📋 미션 배경 및 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "소셜 미디어 및 인터넷에 공개된 전장 사진의 메타데이터(EXIF)와 촬영 환경을 분석하고 교차 검증을 위한 기초 데이터셋을 정의한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **공개 사진의 메타데이터(EXIF)와 촬영 환경 단서를 무엇부터 어떻게 확인할지 정하고, 조 단위 검증에 필요한 현장 체크 항목과 역할을 정의한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **실습자료의 가상 사진 카드 P-01~P-03과 가상 EXIF 예시를 살펴보고 촬영일시·기기·GPS·편집 기록 등 메타데이터 항목과 그림자·날씨·식생·간판·도로 형태 등 화면 단서를 분류 수행하고 검증하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 5개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `docs/isr08_baseline_assessment.md 및 훈련 프로파일` 형태의 결과물을 완성한다.\n", + "\n", + "### 📑 실습자료 및 작성 양식\n", + "공개 출처(SNS/뉴스) 사진의 메타데이터(EXIF) 및 지형 단서 분석 계획 수립**\n", + "\n", + "제공 자료: 가상 사진 카드 3장과 가상 EXIF 예시. 카드를 보고 어떤 항목을 확인해야 하는지 뽑아 현장 체크 항목과 역할을 정의한다.\n", + "\n", + "| **사진 ID** | **게시 정보·주장** | **사진 설명(보이는 단서)** | **메타데이터** |\n", + "| --- | --- | --- | --- |\n", + "| **P-01** | 가상 SNS 계정 '새솔소식'\n", + "D일 11:20 게시\n", + "\"오늘 아침 급수탑 옆 도로에 차량 행렬\" | 붉은 벽돌 급수탑이 오른쪽 배경에 보임. 2차선 도로와 가로수. 가로수 그림자가 북서쪽으로 뻗고 길이는 나무 높이의 약 0.7배. 맑은 하늘, 활엽수 잎 무성. | 없음(업로드 과정에서 삭제된 것으로 보임) |\n", + "| **P-02** | 가상 언론 '누리일보'\n", + "D일 16:05 게시\n", + "\"오후 철교 인근에서 연기\" | 회색 3경간 트러스 철교, 철교 뒤로 연기 기둥. 전봇대 그림자가 동쪽, 길이 약 1.3배. | 촬영일시 D일 15:58, 기기 가상 'CAM-X', GPS 없음 |\n", + "| **P-03** | 가상 메신저 전달 이미지\n", + "게시 시각 불명\n", + "\"새솔읍 오거리 방금\" | 원형 교차로와 '초록별 주유소' 간판. 흐려서 그림자가 거의 보이지 않음. 노면이 젖어 있음. | 촬영일시 D일보다 약 400일 전, GPS 좌표 B3 부근 |\n", + "\n", + "| **가상 EXIF 항목** | **예시 값** | **확인 시 유의점** |\n", + "| --- | --- | --- |\n", + "| **촬영일시** | D일 15:58 | 기기 시계가 틀리거나 사후에 바뀔 수 있음 |\n", + "| **수정 일시** | D일 20:10 | 촬영일시보다 늦으면 편집 가능성 확인 |\n", + "| **기기 정보** | 가상 CAM-X | 게시자가 주장한 촬영 수단과 비교 |\n", + "| **GPS 좌표** | B3 부근 | 대부분 SNS는 업로드 시 삭제함. 있어도 위조 가능 |\n", + "| **편집 소프트웨어** | 가상 편집기 v2 | 편집 사실만 알려 줄 뿐 무엇을 바꿨는지는 모름 |\n", + "\n", + "작성 서식 1-1. 현장 체크 항목 정의표(15개 이상)\n", + "\n", + "| **번호** | **체크 항목** | **분류(메타/위치/시간/출처/보안)** | **확인 방법** | **판정 기준** |\n", + "| --- | --- | --- | --- | --- |\n", + "| **1** | ( ) | ( ) | ( ) | ( ) |\n", + "| **2** | ( ) | ( ) | ( ) | ( ) |\n", + "| **3** | ( ) | ( ) | ( ) | ( ) |\n", + "| **4** | ( ) | ( ) | ( ) | ( ) |\n", + "| **5** | ( ) | ( ) | ( ) | ( ) |\n", + "| **6** | ( ) | ( ) | ( ) | ( ) |\n", + "| **7** | ( ) | ( ) | ( ) | ( ) |\n", + "| **8** | ( ) | ( ) | ( ) | ( ) |\n", + "| **9** | ( ) | ( ) | ( ) | ( ) |\n", + "| **10** | ( ) | ( ) | ( ) | ( ) |\n", + "| **11** | ( ) | ( ) | ( ) | ( ) |\n", + "| **12** | ( ) | ( ) | ( ) | ( ) |\n", + "| **13** | ( ) | ( ) | ( ) | ( ) |\n", + "| **14** | ( ) | ( ) | ( ) | ( ) |\n", + "| **15** | ( ) | ( ) | ( ) | ( ) |\n", + "\n", + "작성 서식 1-2. 역할 분담 매트릭스(책임 R, 지원 S, 승인 A 표기)\n", + "\n", + "| **검증 작업** | **수집자** | **위치분석관** | **시간분석관** | **검증관** | **기록관** |\n", + "| --- | --- | --- | --- | --- | --- |\n", + "| **원본 확보·보존** | ( ) | ( ) | ( ) | ( ) | ( ) |\n", + "| **메타데이터 확인** | ( ) | ( ) | ( ) | ( ) | ( ) |\n", + "| **랜드마크·도로망 대조** | ( ) | ( ) | ( ) | ( ) | ( ) |\n", + "| **그림자 기반 시간 추정** | ( ) | ( ) | ( ) | ( ) | ( ) |\n", + "| **교차확인·판정** | ( ) | ( ) | ( ) | ( ) | ( ) |\n", + "| **판정 승인** | ( ) | ( ) | ( ) | ( ) | ( ) |\n", + "| **보고서 작성·전달** | ( ) | ( ) | ( ) | ( ) | ( ) |\n", + "| **실행 로그 기록** | ( ) | ( ) | ( ) | ( ) | ( ) |\n", + "\n", + "작성 서식 1-3. 검증 기초 분석 계획서 요약\n", + "\n", + "| **항목** | **내용** |\n", + "| --- | --- |\n", + "| **검증 목적** | |\n", + "| **대상 사진 범위** | |\n", + "| **확인할 메타데이터** | |\n", + "| **확인할 화면 단서** | |\n", + "| **메타데이터의 한계** | |\n", + "| **판정 표기 방식** | |\n", + "| **보안 준수 사항** | |\n", + "\n", + "| **점검 항목** | **확인** |\n", + "| --- | --- |\n", + "| 체크 항목이 15개 이상이고 메타·위치·시간·출처·보안 분류가 모두 있다 | ( ) |\n", + "| 역할 분담 매트릭스의 모든 작업에 R이 1명 이상 지정되어 있다 | ( ) |\n", + "| 판정 승인(A)은 검증관 한 명으로 정해져 있다 | ( ) |\n", + "| 메타데이터가 없거나 위조될 수 있다는 한계를 적었다 | ( ) |\n", + "\n", + "**\n", + "\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 02. OSINT 사진 위치·시간 검증을 위한 표준 체크리스트(Checklist) 및 검증 SOP 작성\n", + "\n", + "### 📋 미션 배경 및 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "공개 사진의 위치·시간을 검증하기 위한 5단계 표준 점검 항목(Checklist) 및 현장 확인 SOP 초안을 작성한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **공개 사진의 위치·시간 검증을 누구나 같은 순서로 수행하도록 5단계 표준 체크리스트와 검증 SOP 초안을 작성한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **원본 확보·보존 → 메타데이터 확인 → 위치 단서 대조 → 시간 단서 대조 → 교차확인·판정·보고의 5단계로 절차를 나누고 단계마다 세부 행동, 완료 기준, 기록 항목을 적는다 과정을 진행하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 5개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `sop/isr08_field_checklist.md 및 SOP 흐름도` 형태의 결과물을 완성한다.\n", + "\n", + "### 📑 실습자료 및 작성 양식\n", + "OSINT 사진 위치·시간 검증을 위한 표준 체크리스트(Checklist) 및 검증 SOP 작성**\n", + "\n", + "제공 자료: 5단계 틀과 필수 보안 조치 목록. 단계별 세부 절차를 채워 SOP 초안과 휴대용 요약 카드를 만든다.\n", + "\n", + "| **단계** | **단계 이름** | **핵심 질문** |\n", + "| --- | --- | --- |\n", + "| **1단계** | 원본 확보·보존 | 가장 먼저 올라온 원본은 무엇이며, 수집 시각과 게시 주소를 기록했는가 |\n", + "| **2단계** | 메타데이터 확인 | 촬영일시·수정 일시·기기·GPS가 있는가, 게시 주장과 맞는가 |\n", + "| **3단계** | 위치 단서 대조 | 랜드마크·도로망·간판이 주장한 장소와 맞는가 |\n", + "| **4단계** | 시간 단서 대조 | 그림자 방향·길이, 날씨, 식생이 주장한 시간대·계절과 맞는가 |\n", + "| **5단계** | 교차확인·판정·보고 | 단서가 몇 개 일치·불일치하는가, 판정과 미확인 사항은 무엇인가 |\n", + "\n", + "| **필수 보안 조치** | **내용** |\n", + "| --- | --- |\n", + "| **인터넷 PC 분리** | 공개 자료 검증은 지정된 인터넷 PC에서만 하고 업무망에 파일을 옮기지 않는다 |\n", + "| **게시자 접촉 금지** | 댓글·메시지로 게시자에게 연락하거나 반응하지 않는다 |\n", + "| **재유포 금지** | 검증 전 사진을 공유·전달하지 않고, 보고에는 사진 ID만 쓴다 |\n", + "| **개인 식별 정보 비노출** | 얼굴·차량번호·이름이 보이면 보고서에 적지 않고 '개인정보 포함'으로만 표기한다 |\n", + "| **부대 자료 입력 금지** | 부대 자료·위치를 외부 검색·AI 서비스에 입력하지 않는다 |\n", + "\n", + "작성 서식 2-1. 5단계 표준 체크리스트\n", + "\n", + "| **단계** | **세부 절차(행동)** | **완료 기준** | **기록 항목** | **긴급 모드 시 처리** |\n", + "| --- | --- | --- | --- | --- |\n", + "| **1단계** | | | | |\n", + "| **2단계** | | | | |\n", + "| **3단계** | | | | |\n", + "| **4단계** | | | | |\n", + "| **5단계** | | | | |\n", + "\n", + "작성 서식 2-2. 휴대용 요약 카드(Pocket Guide, 한 쪽)\n", + "\n", + "| **구분** | **내용** |\n", + "| --- | --- |\n", + "| **5단계 순서(한 줄씩)** | |\n", + "| **판정 4종 기준** | |\n", + "| **반드시 지킬 보안 조치** | |\n", + "| **긴급 모드 전환 조건** | |\n", + "| **보고 대상·방법** | |\n", + "\n", + "| **점검 항목** | **확인** |\n", + "| --- | --- |\n", + "| 5단계마다 세부 절차·완료 기준·기록 항목이 모두 있다 | ( ) |\n", + "| 필수 보안 조치 5종이 체크리스트 안에 들어가 있다 | ( ) |\n", + "| 긴급 모드에서도 생략하면 안 되는 단계(원본 보존, 판정 승인)가 표시되어 있다 | ( ) |\n", + "| 조원 교차 검토에서 찾은 모호한 문구를 고쳤다 | ( ) |\n", + "| SOP 흐름도(단계·분기)가 있다 | ( ) |\n", + "\n", + "**\n", + "\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 03. 태양 그림자 각도(SunCalc), 랜드마크, 도로망 기반 시공간 단서 데이터 수집 및 교차분석\n", + "\n", + "### 📋 미션 배경 및 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "태양 그림자 각도(SunCalc), 랜드마크, 도로 표지판, 지형 형상 등 시공간 단서 데이터를 수집하고 정량적 교차분석 매트릭스를 구성한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **태양 그림자 각도(SunCalc 방식), 랜드마크, 도로망 단서를 사진마다 수집하고 교차분석 매트릭스로 정리한 뒤, 이를 1차 모의 검증용 주입 사건 목록(MSEL)으로 구성한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **가상 사진 카드 P-04~P-13의 그림자 방향·길이 비를 가상 태양 위치표와 대조해 촬영 시간대를 추정하고, 랜드마크·도로망은 가상 지도 격자 카드와 대조해 위치 일치 여부 판정하고 정확도를 측정하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 5개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `scenarios/isr08_msel_scenario.json(md)` 형태의 결과물을 완성한다.\n", + "\n", + "### 📑 실습자료 및 작성 양식\n", + "태양 그림자 각도(SunCalc), 랜드마크, 도로망 기반 시공간 단서 데이터 수집 및 교차분석**\n", + "\n", + "제공 자료: 가상 사진 카드 10장(P-04~P-13). 공통 제공 자료의 태양 위치표와 지도 격자 카드로 단서를 대조한다.\n", + "\n", + "| **사진 ID** | **게시 정보·주장** | **사진 설명(보이는 단서)** | **메타데이터** |\n", + "| --- | --- | --- | --- |\n", + "| **P-04** | D일 09시경 주장\n", + "\"사일로 앞 차량 집결\" | 흰색 원통형 사일로 4기. 그림자 서북서, 길이 약 0.9배. | 없음 |\n", + "| **P-05** | D일 17시 주장\n", + "\"1번 도로 동쪽 구간 정체\" | 2차선 도로, 표지 '새솔 3km →'. 그림자 동쪽, 길이 약 2.0배. | 없음 |\n", + "| **P-06** | D일 08시 주장\n", + "\"철교 파손\" | 2경간 아치형 콘크리트 다리. 그림자 서쪽, 길이 약 1.3배. | 없음 |\n", + "| **P-07** | D일 정오 주장\n", + "\"송신탑 주변 차량\" | 격자형 철탑 1기, 개활지. 그림자 북쪽, 매우 짧음(약 0.45배). | 촬영일시 D일 12:04 |\n", + "| **P-08** | D일 07시 주장\n", + "\"급수탑 인근 이동\" | 붉은 벽돌 급수탑. 그림자 북동쪽, 길이 약 0.7배. | 없음 |\n", + "| **P-09** | D일 오후 주장\n", + "\"주유소 앞 검문\" | 초록 별 모양 간판. 그림자 동북동, 길이 약 0.9배. | 없음 |\n", + "| **P-10** | D일 10시 주장\n", + "\"새솔읍 급수탑\" | 회색 콘크리트 급수탑. 그림자 북서쪽, 길이 약 0.7배. | 없음 |\n", + "| **P-11** | D일 16시 주장\n", + "\"오거리 통제\" | 원형 교차로, 중앙 화단. 그림자 동쪽, 길이 약 1.3배. | 촬영일시 D일 16:02 |\n", + "| **P-12** | D일 11시 주장\n", + "\"1번 도로 차량\" | 2차선 도로. 가로수 잎이 없고 길가에 잔설. 그림자 북북서, 길이 약 0.5배. | 없음 |\n", + "| **P-13** | D일 13시 주장\n", + "\"철교 남쪽 차량\" | 회색 3경간 트러스 철교. 그림자 북북동, 길이 약 0.5배. | 없음 |\n", + "\n", + "작성 서식 3-1. 시공간 단서 교차분석 매트릭스(일치 O, 불일치 X, 판단 불가 -)\n", + "\n", + "| **사진** | **그림자 추정 시각** | **주장 시각과 일치** | **랜드마크 일치** | **도로망·간판 일치** | **날씨·식생 일치** | **판정** |\n", + "| --- | --- | --- | --- | --- | --- | --- |\n", + "| **P-04** | ( ) | ( ) | ( ) | ( ) | ( ) | ( ) |\n", + "| **P-05** | ( ) | ( ) | ( ) | ( ) | ( ) | ( ) |\n", + "| **P-06** | ( ) | ( ) | ( ) | ( ) | ( ) | ( ) |\n", + "| **P-07** | ( ) | ( ) | ( ) | ( ) | ( ) | ( ) |\n", + "| **P-08** | ( ) | ( ) | ( ) | ( ) | ( ) | ( ) |\n", + "| **P-09** | ( ) | ( ) | ( ) | ( ) | ( ) | ( ) |\n", + "| **P-10** | ( ) | ( ) | ( ) | ( ) | ( ) | ( ) |\n", + "| **P-11** | ( ) | ( ) | ( ) | ( ) | ( ) | ( ) |\n", + "| **P-12** | ( ) | ( ) | ( ) | ( ) | ( ) | ( ) |\n", + "| **P-13** | ( ) | ( ) | ( ) | ( ) | ( ) | ( ) |\n", + "\n", + "작성 서식 3-2. 주입 사건 목록(MSEL): 정상 6건, 비정상 4건 이상\n", + "\n", + "| **순번** | **주입 시각(T+분)** | **주입 사진 ID** | **정상/비정상** | **주입 메시지(게시 문구)** | **기대 조치·판정** |\n", + "| --- | --- | --- | --- | --- | --- |\n", + "| **1** | ( ) | ( ) | ( ) | ( ) | ( ) |\n", + "| **2** | ( ) | ( ) | ( ) | ( ) | ( ) |\n", + "| **3** | ( ) | ( ) | ( ) | ( ) | ( ) |\n", + "| **4** | ( ) | ( ) | ( ) | ( ) | ( ) |\n", + "| **5** | ( ) | ( ) | ( ) | ( ) | ( ) |\n", + "| **6** | ( ) | ( ) | ( ) | ( ) | ( ) |\n", + "| **7** | ( ) | ( ) | ( ) | ( ) | ( ) |\n", + "| **8** | ( ) | ( ) | ( ) | ( ) | ( ) |\n", + "| **9** | ( ) | ( ) | ( ) | ( ) | ( ) |\n", + "| **10** | ( ) | ( ) | ( ) | ( ) | ( ) |\n", + "\n", + "주입 메시지 템플릿 예: \"[가상 SNS/뉴스 이름] [게시 시각] '[주장 문구]' 사진 1장 첨부 - 검증 요청\"\n", + "\n", + "| **점검 항목** | **확인** |\n", + "| --- | --- |\n", + "| 10장 모두 그림자 추정 시각을 태양 위치표로 계산해 적었다 | ( ) |\n", + "| 오전·오후 길이 비가 같은 경우 그림자 방향으로 구분했다 | ( ) |\n", + "| 랜드마크가 비슷하지만 다른 장소(유사 랜드마크)를 구분했다 | ( ) |\n", + "| MSEL 주입 사건이 10건 이상이고 정상·비정상 비율이 6:4이다 | ( ) |\n", + "| 워크스루에서 찾은 누락(주입 시각 겹침, 기대 조치 누락 등)을 보완했다 | ( ) |\n", + "\n", + "**\n", + "\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 04. 실전 모의 환경에서 1차 사진 위치 추정(Dry-run) 및 시간대 검증 실습\n", + "\n", + "### 📋 미션 배경 및 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "모의 OSINT 사진을 대상으로 위치 추정 및 촬영 시간대 교차 검증을 수행하는 1차 모의 실습(Dry-run)을 진행한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **다른 조가 만든 MSEL(또는 실습자료의 예시 MSEL)로 사진 위치 추정과 촬영 시간대 검증을 1차 모의 실행(Dry-run)한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **조별로 역할을 나눠 주입되는 사진을 SOP 5단계에 따라 처리하고 판정·근거·신뢰 수준·미확인 사항을 간이 검증 보고 양식에 적는다 과정을 진행하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 5개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `logs/isr08_dryrun_execution_log.md 및 작성된 체크리스트` 형태의 결과물을 완성한다.\n", + "\n", + "### 📑 실습자료 및 작성 양식\n", + "실전 모의 환경에서 1차 사진 위치 추정(Dry-run) 및 시간대 검증 실습**\n", + "\n", + "제공 자료: 다른 조와 MSEL을 교환해 사용한다. 교환이 어려우면 아래 예시 MSEL을 쓴다(사진 카드는 미션 3의 P-04~P-13).\n", + "\n", + "| **주입 시각** | **주입 사진** | **주입 메시지(가상)** |\n", + "| --- | --- | --- |\n", + "| **T+0** | P-07 | 가상 SNS '누리광장' 12:10 \"송신탑 주변에 차량이 모였다\" |\n", + "| **T+5** | P-10 | 가상 SNS '새솔소식' 10:15 \"새솔읍 급수탑 앞 이동 중\" |\n", + "| **T+10** | P-05 | 가상 언론 '누리일보' 17:20 \"1번 도로 동쪽 정체\" |\n", + "| **T+15** | P-08 | 가상 메신저 전달 \"아침 7시 급수탑 인근 이동\" |\n", + "| **T+20** | P-13 | 가상 SNS '들내소식' 13:30 \"철교 남쪽 차량 목격\" |\n", + "| **T+25** | P-06 | 가상 SNS '누리광장' 08:40 \"철교 파손, 통행 불가\" |\n", + "| **T+30** | P-11 | 가상 언론 '누리일보' 16:20 \"오거리 통제 중\" |\n", + "| **T+35** | P-12 | 가상 메신저 전달 \"방금 1번 도로 차량\" |\n", + "| **T+40** | P-04 | 가상 SNS '새솔소식' 09:30 \"사일로 앞 집결\" |\n", + "| **T+45** | P-09 | 가상 SNS '들내소식' 15:10 \"주유소 앞 검문\" |\n", + "\n", + "작성 서식 4-1. Dry-run 실행 로그(기록관 작성)\n", + "\n", + "| **사진 ID** | **접수 시각** | **1단계** | **2단계** | **3단계** | **4단계** | **5단계 판정 완료** | **보고 전달 시각** | **보고 지연(분)** |\n", + "| --- | --- | --- | --- | --- | --- | --- | --- | --- |\n", + "| | ( ) | ( ) | ( ) | ( ) | ( ) | ( ) | ( ) | ( ) |\n", + "| | ( ) | ( ) | ( ) | ( ) | ( ) | ( ) | ( ) | ( ) |\n", + "| | ( ) | ( ) | ( ) | ( ) | ( ) | ( ) | ( ) | ( ) |\n", + "| | ( ) | ( ) | ( ) | ( ) | ( ) | ( ) | ( ) | ( ) |\n", + "| | ( ) | ( ) | ( ) | ( ) | ( ) | ( ) | ( ) | ( ) |\n", + "| | ( ) | ( ) | ( ) | ( ) | ( ) | ( ) | ( ) | ( ) |\n", + "| | ( ) | ( ) | ( ) | ( ) | ( ) | ( ) | ( ) | ( ) |\n", + "| | ( ) | ( ) | ( ) | ( ) | ( ) | ( ) | ( ) | ( ) |\n", + "| | ( ) | ( ) | ( ) | ( ) | ( ) | ( ) | ( ) | ( ) |\n", + "| | ( ) | ( ) | ( ) | ( ) | ( ) | ( ) | ( ) | ( ) |\n", + "\n", + "각 단계 칸에는 수행 여부(O/X)와 종료 시각을 적는다. SOP 단계 준수율 = 수행한 단계 수 / (사진 수 x 5) x 100.\n", + "\n", + "작성 서식 4-2. 간이 검증 보고 양식(사진 1장당 1부)\n", + "\n", + "| **항목** | **내용** |\n", + "| --- | --- |\n", + "| **사진 ID·접수 시각** | |\n", + "| **판정(확인/추정/미확인/기만 의심)** | |\n", + "| **위치 근거** | |\n", + "| **시간 근거** | |\n", + "| **신뢰 수준과 이유** | |\n", + "| **미확인 사항** | |\n", + "| **검증관 승인(서명)** | |\n", + "\n", + "| **점검 항목** | **확인** |\n", + "| --- | --- |\n", + "| 모든 사진에 대해 판정·근거·신뢰 수준·미확인 사항을 적었다 | ( ) |\n", + "| SOP 단계 준수율을 계산했다(목표 80% 이상) | ( ) |\n", + "| 보고 지연시간을 사진마다 계산했다(목표 3분 이내) | ( ) |\n", + "| 병목·혼선 구간을 로그에 표시했다 | ( ) |\n", + "| 판정 승인 없이 보고된 건이 없다 | ( ) |\n", + "\n", + "**\n", + "\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 05. 의도적 사진 변조(메타데이터 삭제, 배경 왜곡, 과거 사진 재탕) 기만 탐지 훈련\n", + "\n", + "### 📋 미션 배경 및 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "과거 분쟁 사진 재활용, 메타데이터 위조, 배경 합성 등 의도적 기만 사진을 식별하고 필터링하는 교차 검증 훈련을 수행한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **메타데이터 위조, 배경 합성, 과거 사진 재탕 등 의도적으로 변조된 기만 사진을 식별하고 걸러내는 교차검증 훈련을 수행한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **가상 사진 카드 D-01~D-10과 가상 역이미지 검색 결과 카드를 받아 사진마다 메타데이터·그림자·랜드마크·간판·식생·게시 이력을 교차 확인하고 유효성을 점검하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 5개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `docs/isr08_cross_verification_sheet.md` 형태의 결과물을 완성한다.\n", + "\n", + "### 📑 실습자료 및 작성 양식\n", + "의도적 사진 변조(메타데이터 삭제, 배경 왜곡, 과거 사진 재탕) 기만 탐지 훈련**\n", + "\n", + "제공 자료: 가상 사진 카드 10장(D-01~D-10)과 가상 역이미지 검색 결과 카드. 메타데이터가 없거나 화질이 낮은 것만으로는 기만으로 판정하지 않는다.\n", + "\n", + "| **사진 ID** | **게시 정보·주장** | **사진 설명(보이는 단서)** | **메타데이터** |\n", + "| --- | --- | --- | --- |\n", + "| **D-01** | D일 10시 주장\n", + "\"사일로 앞 차량\" | 사일로 4기. 그림자 북서쪽, 길이 약 0.7배. | 없음(SNS 자동 삭제) |\n", + "| **D-02** | D일 15시 주장\n", + "\"오거리 화재\" | 원형 교차로, 연기. 그림자 동북동, 길이 약 0.9배. | 촬영일시 D일보다 약 730일 전 |\n", + "| **D-03** | D일 14시 주장\n", + "\"철교 앞 차량\" | 3경간 트러스 철교. 앞쪽 차량 그림자는 북동쪽인데 철교 그림자는 서쪽으로 뻗음. | 없음 |\n", + "| **D-04** | D일 18시 주장\n", + "\"1번 도로 차량\" | 2차선 도로, 표지 '새솔 3km →'. 그림자 동남동, 길이 약 3.2배. | 촬영일시 D일 18:02 |\n", + "| **D-05** | D일 09시 주장\n", + "\"급수탑 앞 이동\" | 붉은 벽돌 급수탑. 그림자 북동쪽, 길이 약 0.5배. | 촬영일시 D일 09:05, 편집 소프트웨어 '가상 편집기 v2', 수정 일시 D일 20:10 |\n", + "| **D-06** | D일 12시 주장\n", + "\"송신탑 주변\" | 여러 번 전달되어 화질이 낮음. 격자형 철탑, 그림자 북쪽으로 짧음. | 없음 |\n", + "| **D-07** | D일 11시 주장\n", + "\"새솔읍 주유소 검문\" | 간판 '파란달 주유소', 간판 문자가 이 지역에서 쓰지 않는 가상 문자 체계. 그림자 북북서. | 없음 |\n", + "| **D-08** | D일 16시 주장\n", + "\"오거리 통제\" | 원형 교차로. 그림자 동쪽, 길이 약 1.3배. | 촬영일시 D일 16:03 |\n", + "| **D-09** | \"D일 새벽 공격 직후\" 주장 | 연기 기둥. 하늘이 밝고 그림자가 북쪽으로 매우 짧음(약 0.46배). | 없음 |\n", + "| **D-10** | D일 07시 주장\n", + "\"사일로 옆 도로\" | 사일로 4기. 그림자 서쪽, 길이 약 2.0배. | 없음 |\n", + "\n", + "| **가상 역이미지 검색 결과 카드** | **내용** |\n", + "| --- | --- |\n", + "| **D-02** | 같은 이미지가 D일보다 약 2년 전 다른 지역 화재 소식 게시물에서 먼저 확인됨 |\n", + "| **D-09** | 같은 계정이 1시간 전 같은 사진을 '어제 오후 훈련장' 설명으로 올렸다가 삭제한 기록이 있음 |\n", + "| **D-01, D-04, D-08** | 같은 이미지의 이전 게시 기록 없음 |\n", + "| **나머지** | 검색 결과 없음(없다는 것이 원본이라는 뜻은 아님) |\n", + "\n", + "작성 서식 5-1. 기만 탐지 교차검증 매트릭스\n", + "\n", + "| **사진** | **메타데이터** | **그림자·시간** | **랜드마크·간판** | **게시 이력** | **판정(정상/기만 의심)** | **근거 단서 2개** |\n", + "| --- | --- | --- | --- | --- | --- | --- |\n", + "| **D-01** | ( ) | ( ) | ( ) | ( ) | ( ) | ( ) |\n", + "| **D-02** | ( ) | ( ) | ( ) | ( ) | ( ) | ( ) |\n", + "| **D-03** | ( ) | ( ) | ( ) | ( ) | ( ) | ( ) |\n", + "| **D-04** | ( ) | ( ) | ( ) | ( ) | ( ) | ( ) |\n", + "| **D-05** | ( ) | ( ) | ( ) | ( ) | ( ) | ( ) |\n", + "| **D-06** | ( ) | ( ) | ( ) | ( ) | ( ) | ( ) |\n", + "| **D-07** | ( ) | ( ) | ( ) | ( ) | ( ) | ( ) |\n", + "| **D-08** | ( ) | ( ) | ( ) | ( ) | ( ) | ( ) |\n", + "| **D-09** | ( ) | ( ) | ( ) | ( ) | ( ) | ( ) |\n", + "| **D-10** | ( ) | ( ) | ( ) | ( ) | ( ) | ( ) |\n", + "\n", + "작성 서식 5-2. 허위 경보 필터링 결과 보고\n", + "\n", + "| **항목** | **내용** |\n", + "| --- | --- |\n", + "| **기만 의심 사진 수와 ID** | |\n", + "| **정상 판정 사진 수와 ID** | |\n", + "| **의심 요소가 있었지만 정상으로 본 사진과 이유** | |\n", + "| **불일치 발견 시 보고 경로** | |\n", + "| **교차확인 우선순위 규칙(무엇부터 볼 것인가)** | |\n", + "\n", + "| **점검 항목** | **확인** |\n", + "| --- | --- |\n", + "| 10장 모두 판정했고 판정마다 근거 단서 2개를 적었다 | ( ) |\n", + "| 메타데이터 없음·저화질만으로 기만 판정하지 않았다 | ( ) |\n", + "| 한 사진 안의 그림자 방향 불일치를 확인했다 | ( ) |\n", + "| 역이미지 검색 결과가 '없음'인 것을 원본 증거로 쓰지 않았다 | ( ) |\n", + "| 판정 정확도를 계산했다(목표 85% 이상) | ( ) |\n", + "\n", + "**\n", + "\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 06. 검증 결과 보고서 작성 및 교대 분석관 대상 상황 인계(Handover) 프로토콜 수행\n", + "\n", + "### 📋 미션 배경 및 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "검증 결과를 표준 양식으로 작성하고 교대 분석관에게 검증 근거와 미확인 사항을 전달하는 상황 인계(Handover) 프로토콜을 실습한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **검증 결과를 표준 양식 보고서로 작성하고, 교대 분석관에게 판정 근거와 미확인 사항을 빠짐없이 넘기는 상황 인계(Handover)를 실습한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **교대 시점 가상 현황 카드를 바탕으로 SBAR(상황-배경-평가-건의) 형식의 인계서를 작성하고, 인계자와 인수자가 마주 앉아 10분 이내에 구두 인계를 진행 수행하고 검증하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 5개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `reports/isr08_handover_protocol.md 및 브리핑 녹취/기록지` 형태의 결과물을 완성한다.\n", + "\n", + "### 📑 실습자료 및 작성 양식\n", + "검증 결과 보고서 작성 및 교대 분석관 대상 상황 인계(Handover) 프로토콜 수행**\n", + "\n", + "제공 자료: 교대 시점(가상 D일 18:00) 검증 현황 카드. 인계자는 이 현황으로 SBAR 인계서를 쓰고 10분 안에 구두 인계한다.\n", + "\n", + "| **구분** | **사진 ID** | **현황·비고** |\n", + "| --- | --- | --- |\n", + "| **확인** | P-07, P-11, D-08 | 단서 2개 이상 일치. 정보과 보고 완료 |\n", + "| **추정** | P-04, P-09 | 메타데이터 없음, 그림자·랜드마크 일치. 추가 원본 탐색 중 |\n", + "| **미확인** | P-03 | 흐림으로 그림자 단서 없음, 촬영일 약 400일 전. 원본 게시자 확인 불가 |\n", + "| **기만 의심** | D-02, D-03, D-05 | 재탕·합성·편집 흔적. 정보과 보고 완료, 확산 금지 조치 |\n", + "| **검증 대기** | 신규 1건(S-01) | 18:40 전까지 1차 판정 요청받음 |\n", + "| **특이 사항** | - | 가상 들내리 급수탑과 새솔읍 급수탑 혼동 사례 1건 발생(P-10). 인터넷 PC 1대 점검 중 |\n", + "\n", + "작성 서식 6-1. SBAR 인계서\n", + "\n", + "| **구분** | **내용** |\n", + "| --- | --- |\n", + "| **S 상황(현재 무엇이 진행 중인가)** | |\n", + "| **B 배경(어떤 사진을 어떻게 검증했는가)** | |\n", + "| **A 평가(확인·추정·미확인·기만 의심 현황과 근거)** | |\n", + "| **R 건의(인수자가 먼저 할 일, 마감 시각)** | |\n", + "\n", + "작성 서식 6-2. 인계 필수 항목 체크리스트\n", + "\n", + "| **인계 필수 항목** | **전달** | **확인(인수자)** |\n", + "| --- | --- | --- |\n", + "| 판정별 사진 목록 | ( ) | ( ) |\n", + "| 판정 근거(단서) | ( ) | ( ) |\n", + "| 미확인 사항과 이유 | ( ) | ( ) |\n", + "| 진행 중 작업과 마감 시각 | ( ) | ( ) |\n", + "| 보고 완료 여부 | ( ) | ( ) |\n", + "| 확산 금지 등 조치 사항 | ( ) | ( ) |\n", + "| 혼동 사례·주의 사항 | ( ) | ( ) |\n", + "| 장비·보안 특이 사항 | ( ) | ( ) |\n", + "| 상급자 지시 사항 | ( ) | ( ) |\n", + "| 연락 체계 | ( ) | ( ) |\n", + "\n", + "| **인수자 돌발 퀴즈(인계 후 교관 질문)** | **답** |\n", + "| --- | --- |\n", + "| 1. 지금 '미확인'인 사진은 무엇이고, 왜 미확인인가 | ( ) |\n", + "| 2. 18:40까지 끝내야 하는 일은 무엇인가 | ( ) |\n", + "| 3. 확산 금지 조치를 한 사진은 몇 장인가 | ( ) |\n", + "| 4. 급수탑 사진을 볼 때 무엇을 먼저 확인해야 하는가 | ( ) |\n", + "| 5. P-04를 '확인'으로 올리려면 무엇이 더 필요한가 | ( ) |\n", + "\n", + "| **서명** | **인계자** | **인수자** | **인계 시작·종료 시각** |\n", + "| --- | --- | --- | --- |\n", + "| **서명·시각** | | | |\n", + "\n", + "| **점검 항목** | **확인** |\n", + "| --- | --- |\n", + "| 인계 필수 항목 10개를 모두 전달했다(누락률 0%) | ( ) |\n", + "| 구두 인계를 10분 안에 마쳤다 | ( ) |\n", + "| 인수자가 퀴즈 5문항 중 4문항 이상 맞혔다 | ( ) |\n", + "| 인계자·인수자가 서명했다 | ( ) |\n", + "\n", + "**\n", + "\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 07. 동시 다발적 가짜 뉴스/조작 사진 유입 상황 모의 고부하 검증 스트레스 훈련\n", + "\n", + "### 📋 미션 배경 및 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "대량의 조작 사진과 속보가 쏟아지는 전훈 상황을 모의하여 신속 검증 스트레스 훈련을 진행한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **조작 사진과 속보가 짧은 시간에 몰려드는 고부하 상황을 모의해, 긴급 모드 전환과 위험 게시물 우선 조치를 훈련한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **20분 동안 가상 유입 목록 S-01~S-20이 순서대로 주어지면 전환 조건 충족 즉시 긴급 모드를 선포하고, 위험 표시 건(허위 대피 지시, 가상 아군 진지 노출, 개인 신상 노출 등)을 먼저 분류 수행하고 검증하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 5개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `logs/isr08_stress_drill_results.md` 형태의 결과물을 완성한다.\n", + "\n", + "### 📑 실습자료 및 작성 양식\n", + "동시 다발적 가짜 뉴스/조작 사진 유입 상황 모의 고부하 검증 스트레스 훈련**\n", + "\n", + "제공 자료: 20분 동안 순서대로 들어오는 가상 유입 목록. 교관이 시각에 맞춰 한 건씩 읽어 주거나 카드로 건넨다. '위험' 표시 건은 필수 조치 대상이다.\n", + "\n", + "| **ID** | **유입** | **유형** | **내용 요약(가상)** | **표시** |\n", + "| --- | --- | --- | --- | --- |\n", + "| **S-01** | T+0 | 사진 | \"새솔천 둑 붕괴\" 사진, 메타데이터 없음 | 일반 |\n", + "| **S-02** | T+1 | 속보 | \"새솔읍 주민 즉시 남쪽으로 대피하라\" 출처 불명 문자 캡처 | 위험 |\n", + "| **S-03** | T+2 | 사진 | 가상 아군 진지로 보이는 위장망과 차량이 찍힌 사진 | 위험 |\n", + "| **S-04** | T+3 | 사진 | \"철교 폭파\" 사진, 2경간 아치 다리 | 일반 |\n", + "| **S-05** | T+4 | 속보 | \"급수탑에 적 저격수\" 텍스트, 사진 없음 | 위험 |\n", + "| **S-06** | T+4 | 사진 | 오거리 사진, 구조 요청자의 얼굴과 이름표가 보임 | 위험 |\n", + "| **S-07** | T+5 | 사진 | 사일로 사진, 그림자 서북서 0.9배, \"오전 9시\" 주장 | 일반 |\n", + "| **S-08** | T+6 | 속보 | \"정보과가 사진 조작\" 비방 게시물 | 일반 |\n", + "| **S-09** | T+7 | 사진 | \"새솔읍 급수탑\" 주장, 회색 콘크리트 급수탑 | 일반 |\n", + "| **S-10** | T+8 | 속보 | \"수돗물 오염, 마시지 말 것\" 가상 기관 사칭 공지 | 위험 |\n", + "| **S-11** | T+9 | 사진 | S-03과 같은 진지가 다른 각도로 찍힌 사진 | 위험 |\n", + "| **S-12** | T+10 | 사진 | 송신탑 사진, 촬영일시 D일 12:04 | 일반 |\n", + "| **S-13** | T+11 | 속보 | \"1번 도로 통제 해제\" 가상 뉴스 속보 | 일반 |\n", + "| **S-14** | T+12 | 사진 | 부상자 사진, 얼굴 노출 | 위험 |\n", + "| **S-15** | T+13 | 사진 | \"새벽 공격\" 주장 사진, 그림자 북쪽 짧음(정오) | 일반 |\n", + "| **S-16** | T+14 | 속보 | \"검문소 위치 변경\" 가상 아군 검문 위치가 적힌 게시물 | 위험 |\n", + "| **S-17** | T+15 | 사진 | 주유소 사진, 간판 '초록별 주유소', 그림자 동쪽 1.3배 | 일반 |\n", + "| **S-18** | T+16 | 속보 | S-02와 같은 대피 문자가 다른 계정에서 재확산 | 위험 |\n", + "| **S-19** | T+18 | 사진 | 철교 사진, 3경간 트러스, 촬영일 D일 | 일반 |\n", + "| **S-20** | T+19 | 속보 | \"가상 한빛대대 철수\" 허위 주장 | 위험 |\n", + "\n", + "| **긴급 모드 규칙(가상)** | **내용** |\n", + "| --- | --- |\n", + "| **전환 조건** | 5분 안에 유입 6건 이상, 또는 '위험' 표시 누적 3건 이상 중 먼저 충족되는 때 |\n", + "| **전환 방법** | 검증관이 '긴급 모드' 선포 → 기록관이 시각 기록 → 위험 건 우선 처리, 일반 건은 대기열로 |\n", + "| **위험 건 필수 조치** | ① 확산 금지(공유·전달 중단) ② '미확인' 표기 ③ 정보과장(가상)에게 즉시 보고 ④ 개인 식별 정보가 있으면 비식별 처리 후 보고. 아군 위치 노출 건은 보안 담당에게도 통보 |\n", + "| **위임 범위** | 검증관 부재 시 위치분석관이 대리하되 '확인' 판정은 하지 않고 '추정·미확인·기만 의심'까지만 표기한다 |\n", + "| **생략 금지** | 긴급 모드에서도 원본 보존과 조치 시각 기록은 생략하지 않는다 |\n", + "\n", + "작성 서식 7-1. 스트레스 훈련 조치 기록\n", + "\n", + "| **ID** | **처리 시각** | **우선순위(위험/일반)** | **수행한 조치** | **판정** | **처리자** |\n", + "| --- | --- | --- | --- | --- | --- |\n", + "| **S-02** | ( ) | ( ) | ( ) | ( ) | ( ) |\n", + "| **S-03** | ( ) | ( ) | ( ) | ( ) | ( ) |\n", + "| **S-05** | ( ) | ( ) | ( ) | ( ) | ( ) |\n", + "| **S-06** | ( ) | ( ) | ( ) | ( ) | ( ) |\n", + "| **S-10** | ( ) | ( ) | ( ) | ( ) | ( ) |\n", + "| **S-11** | ( ) | ( ) | ( ) | ( ) | ( ) |\n", + "| **S-14** | ( ) | ( ) | ( ) | ( ) | ( ) |\n", + "| **S-16** | ( ) | ( ) | ( ) | ( ) | ( ) |\n", + "| **S-18** | ( ) | ( ) | ( ) | ( ) | ( ) |\n", + "| **S-20** | ( ) | ( ) | ( ) | ( ) | ( ) |\n", + "| **(일반 건)** | ( ) | ( ) | ( ) | ( ) | ( ) |\n", + "\n", + "| **결과 요약** | **값** |\n", + "| --- | --- |\n", + "| **전환 조건 충족 시각** | |\n", + "| **긴급 모드 선포 시각(차이 1분 이내?)** | |\n", + "| **위험 건 수 / 필수 조치 완료 건 수** | |\n", + "| **위험 조치 성공률(%)** | |\n", + "| **위임 범위 위반 건수** | |\n", + "\n", + "| **점검 항목** | **확인** |\n", + "| --- | --- |\n", + "| 전환 조건 충족 1분 안에 긴급 모드를 선포했다 | ( ) |\n", + "| 위험 10건 모두에 필수 조치 4가지를 적용했다(성공률 90% 이상) | ( ) |\n", + "| 개인 식별 정보는 보고서에 적지 않았다 | ( ) |\n", + "| 검증관 부재 시 '확인' 판정을 하지 않았다 | ( ) |\n", + "| 모든 조치에 시각이 기록되어 있다 | ( ) |\n", + "\n", + "**\n", + "\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 08. AAR(사후검토) 회의를 통한 위치 오정정 사례 원인 분석 및 인지 편향 도출\n", + "\n", + "### 📋 미션 배경 및 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "AAR 회의를 통해 위치 오정정 및 인지 편향 사례를 분석하고 검증 과정에서의 오류 발생 원인을 규명한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **AAR(사후검토) 회의로 1차 모의 검증과 스트레스 훈련에서 생긴 위치 오정정 사례의 원인과 인지 편향을 밝혀낸다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **실행 로그와 가상 AAR 사례 카드를 근거로 '계획 → 실제 → 차이 → 원인 → 개선' 순서로 토의 수행하고 검증하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 5개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `aar/isr08_aar_session_minutes.md 및 오류 분석 매트릭스` 형태의 결과물을 완성한다.\n", + "\n", + "### 📑 실습자료 및 작성 양식\n", + "AAR(사후검토) 회의를 통한 위치 오정정 사례 원인 분석 및 인지 편향 도출**\n", + "\n", + "제공 자료: 가상 AAR 사례 카드. 조의 실제 로그가 있으면 함께 쓰고, 없으면 사례 카드만으로 진행한다. 사람을 탓하지 않고 절차와 기준의 문제로 기록한다.\n", + "\n", + "| **사례** | **가상 기록(로그 발췌)** | **결과** |\n", + "| --- | --- | --- |\n", + "| **A-1** | T+5 P-10 접수 → 급수탑 형태만 보고 B2로 위치 판정(3분) → 재질·색 확인 생략 | 들내리 급수탑을 새솔읍으로 오정정 |\n", + "| **A-2** | T+15 P-08 접수 → 게시 문구 '아침 7시'를 먼저 읽고 그림자 방향을 서쪽으로 잘못 읽음 | 시간 불일치를 놓쳐 '확인' 판정 |\n", + "| **A-3** | S-13 가상 뉴스 속보를 '언론사라서' 확인 없이 정상 처리 | 나중에 가상 언론사가 속보를 정정 |\n", + "| **A-4** | 긴급 모드 중 S-09 처리에서 2단계(메타데이터)와 3단계를 동시에 보다 도로 표지 확인 누락 | 유사 랜드마크 오정정 반복 |\n", + "| **A-5** | 교대 직후 인수자가 P-03을 '추정'으로 올림, 인계서에는 '미확인 이유' 칸이 비어 있었음 | 근거 없는 등급 상향 |\n", + "\n", + "| **인지 편향 유형(참고)** | **설명** |\n", + "| --- | --- |\n", + "| **확증 편향** | 처음 떠올린 답을 뒷받침하는 단서만 찾는 경향 |\n", + "| **앵커링** | 처음 본 정보(게시 문구·시각)에 판단이 묶이는 경향 |\n", + "| **권위 편향** | 출처의 이름이나 지위를 근거로 검증을 생략하는 경향 |\n", + "| **시간 압박** | 빠르게 처리하려다 단계를 합치거나 생략하는 현상 |\n", + "| **정보 단절** | 인계 과정에서 근거·한계가 빠져 판단이 바뀌는 현상 |\n", + "\n", + "작성 서식 8-1. AAR 4단계 토의 기록\n", + "\n", + "| **질문** | **토의 내용** |\n", + "| --- | --- |\n", + "| **무엇을 계획했는가** | |\n", + "| **실제로 무슨 일이 있었는가(로그 근거)** | |\n", + "| **왜 차이가 생겼는가** | |\n", + "| **다음에는 무엇을 바꿀 것인가** | |\n", + "\n", + "작성 서식 8-2. 오류 분석 매트릭스\n", + "\n", + "| **사례** | **사실(로그 근거)** | **직접 원인** | **인지 편향 유형** | **개선점** | **SOP 수정안** |\n", + "| --- | --- | --- | --- | --- | --- |\n", + "| **A-1** | ( ) | ( ) | ( ) | ( ) | ( ) |\n", + "| **A-2** | ( ) | ( ) | ( ) | ( ) | ( ) |\n", + "| **A-3** | ( ) | ( ) | ( ) | ( ) | ( ) |\n", + "| **A-4** | ( ) | ( ) | ( ) | ( ) | ( ) |\n", + "| **A-5** | ( ) | ( ) | ( ) | ( ) | ( ) |\n", + "\n", + "| **점검 항목** | **확인** |\n", + "| --- | --- |\n", + "| 사례 5건 모두 로그 근거를 연결했다(100%) | ( ) |\n", + "| 개선점을 5건 이상 도출했다 | ( ) |\n", + "| 개인 이름이나 비난 표현 없이 기록했다 | ( ) |\n", + "| SOP 1차 수정안을 단계 번호와 함께 적었다 | ( ) |\n", + "| 핵심 교훈(Lessons Learned) 3가지를 정리했다 | ( ) |\n", + "\n", + "**\n", + "\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 09. 피드백을 반영한 변조 사진 재실행(Re-run) 정밀 교차검증 및 오차 보정\n", + "\n", + "### 📋 미션 배경 및 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "식별된 오류를 바탕으로 변조 사진에 대한 재실행(Re-run) 정밀 교차검증을 수행하고 오차를 보정한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **AAR 수정안을 반영한 SOP로 변조 사진 세트를 다시 검증(Re-run)하고 1차 대비 개선 효과를 수치로 확인한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **가상 사진 카드 R-01~R-08을 수정된 SOP로 검증하고 1차와 같은 지표(건당 평균 조치 시간, 오류 건수, 오류 발생률, SOP 준수율)를 측정 수행하고 검증하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 5개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `eval/isr08_rerun_performance_comparison.md` 형태의 결과물을 완성한다.\n", + "\n", + "### 📑 실습자료 및 작성 양식\n", + "피드백을 반영한 변조 사진 재실행(Re-run) 정밀 교차검증 및 오차 보정**\n", + "\n", + "제공 자료: 재실행용 가상 사진 카드 8장(R-01~R-08)과 1차 기록 기준값. 조의 1차 기록(미션 4·5)이 있으면 그 값을 쓰고, 없으면 아래 가상 기준값을 쓴다.\n", + "\n", + "| **사진 ID** | **게시 정보·주장** | **사진 설명(보이는 단서)** | **메타데이터** |\n", + "| --- | --- | --- | --- |\n", + "| **R-01** | D일 10시 주장\n", + "\"사일로 앞\" | 사일로 4기, 그림자 북서 0.7배. | 촬영일시 D일 10:06 |\n", + "| **R-02** | D일 14시 주장\n", + "\"오거리 연기\" | 원형 교차로, 연기, 그림자 북동 0.7배. 가상 역이미지 검색: 약 1년 전 같은 이미지 게시 기록. | 없음 |\n", + "| **R-03** | D일 16시 주장\n", + "\"1번 도로\" | 표지 '새솔 3km →', 그림자 동쪽 1.3배. | 없음 |\n", + "| **R-04** | D일 09시 주장\n", + "\"철교 옆 차량\" | 3경간 트러스 철교, 철교 그림자 서북서인데 차량 그림자는 동쪽. | 없음 |\n", + "| **R-05** | D일 13시 주장\n", + "\"송신탑 앞\" | 격자형 철탑, 그림자 북북동 0.5배. | 촬영일시 D일 13:02, 수정 일시 D일 22:40, 편집 소프트웨어 기록, 원본 해상도와 다름 |\n", + "| **R-06** | D일 17시 주장\n", + "\"주유소 앞\" | 초록 별 간판, 그림자 동쪽 2.0배. | 없음 |\n", + "| **R-07** | D일 11시 주장\n", + "\"새솔읍 급수탑\" | 회색 콘크리트 급수탑, 그림자 북북서 0.5배. | 없음 |\n", + "| **R-08** | D일 08시 주장\n", + "\"사일로 옆 도로\" | 사일로 4기, 그림자 서쪽 1.3배. 전달 반복으로 화질 낮음. | 없음 |\n", + "\n", + "| **1차 기록 가상 기준값** | **값** |\n", + "| --- | --- |\n", + "| **건당 평균 조치 시간** | 12.0분 |\n", + "| **오류 건수 / 처리 건수** | 3건 / 10건 |\n", + "| **오류 발생률** | 30% |\n", + "| **SOP 단계 준수율** | 78% |\n", + "\n", + "작성 서식 9-1. 재실행 판정 기록\n", + "\n", + "| **사진** | **시작 시각** | **종료 시각** | **조치 시간(분)** | **판정** | **근거 단서** | **오류 여부(교관 확인)** |\n", + "| --- | --- | --- | --- | --- | --- | --- |\n", + "| **R-01** | ( ) | ( ) | ( ) | ( ) | ( ) | ( ) |\n", + "| **R-02** | ( ) | ( ) | ( ) | ( ) | ( ) | ( ) |\n", + "| **R-03** | ( ) | ( ) | ( ) | ( ) | ( ) | ( ) |\n", + "| **R-04** | ( ) | ( ) | ( ) | ( ) | ( ) | ( ) |\n", + "| **R-05** | ( ) | ( ) | ( ) | ( ) | ( ) | ( ) |\n", + "| **R-06** | ( ) | ( ) | ( ) | ( ) | ( ) | ( ) |\n", + "| **R-07** | ( ) | ( ) | ( ) | ( ) | ( ) | ( ) |\n", + "| **R-08** | ( ) | ( ) | ( ) | ( ) | ( ) | ( ) |\n", + "\n", + "작성 서식 9-2. 전후 성과 비교표\n", + "\n", + "| **지표** | **1차** | **재실행** | **변화율** | **목표** | **달성 여부** |\n", + "| --- | --- | --- | --- | --- | --- |\n", + "| **건당 평균 조치 시간(분)** | ( ) | ( ) | ( ) | ( ) | ( ) |\n", + "| **오류 발생률(%)** | ( ) | ( ) | ( ) | ( ) | ( ) |\n", + "| **SOP 단계 준수율(%)** | ( ) | ( ) | ( ) | ( ) | ( ) |\n", + "| **보고 지연 3분 초과 건수** | ( ) | ( ) | ( ) | ( ) | ( ) |\n", + "\n", + "계산: 조치 시간 단축률 = (1차 - 재실행) / 1차 x 100(목표 30% 이상), 오류 감소율 = (1차 오류율 - 재실행 오류율) / 1차 오류율 x 100(목표 50% 이상).\n", + "\n", + "| **점검 항목** | **확인** |\n", + "| --- | --- |\n", + "| 1차와 같은 지표·같은 계산식으로 비교했다 | ( ) |\n", + "| 조치 시간 단축률과 오류 감소율을 계산했다 | ( ) |\n", + "| 남은 오류의 원인을 적었다 | ( ) |\n", + "| 보수 교육 주기(예: 분기 1회)와 이유를 제안했다 | ( ) |\n", + "\n", + "**\n", + "\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 10. OSINT 위치·시간 검증 표준 실무 지침서(SOP) 및 부대 확산 패키지 완성\n", + "\n", + "### 📋 미션 배경 및 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "공개 사진 위치·시간 교차검증 표준 매뉴얼(SOP)을 발간하고 부대 전파용 교육 패키지를 완성한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **공개 사진 위치·시간 교차검증 표준 실무 지침서(SOP)와 부대 전파용 교육 패키지를 완성한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **미션 1~9 산출물을 모아 개요-SOP-시나리오-평가 루브릭-AAR의 5개 장으로 교육 패키지를 구성하고, 교관 지침서(진행 순서·시간표·정답 관리·안전 보안 수칙)를 붙인다 과정을 진행하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 5개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `package/isr08_standard_training_package.md 및 교관 지침서` 형태의 결과물을 완성한다.\n", + "\n", + "### 📑 실습자료 및 작성 양식\n", + "OSINT 위치·시간 검증 표준 실무 지침서(SOP) 및 부대 확산 패키지 완성**\n", + "\n", + "제공 자료: 표준 교육 패키지 목차 틀과 교관 지침서 항목. 미션 1~9 산출물을 해당 장에 배치한다.\n", + "\n", + "| **장** | **제목** | **들어갈 미션 산출물** |\n", + "| --- | --- | --- |\n", + "| **1장** | 개요 | M1 검증 기초 분석 계획서, 역할 분담 매트릭스 |\n", + "| **2장** | SOP | M2 5단계 체크리스트·흐름도·요약 카드, M8 수정안 반영본 |\n", + "| **3장** | 시나리오 | M3 MSEL, M5 기만 사진 세트, M7 고부하 유입 목록, M9 재실행 세트 |\n", + "| **4장** | 평가 루브릭 | M4·M5·M7·M9 평가 기준과 채점표 |\n", + "| **5장** | AAR | M8 AAR 절차·오류 분석 매트릭스, M9 전후 비교표 |\n", + "\n", + "작성 서식 10-1. 교관 지침서\n", + "\n", + "| **항목** | **내용** |\n", + "| --- | --- |\n", + "| **교육 대상·인원·조 편성** | |\n", + "| **시간표(미션별 시간)** | |\n", + "| **사전 준비물(카드 인쇄, 인터넷 PC 등)** | |\n", + "| **정답·해설 관리 방법** | |\n", + "| **안전·보안 수칙** | |\n", + "| **평가 방법과 합격 기준** | |\n", + "| **개정 주기와 의견 수렴 방법** | |\n", + "\n", + "작성 서식 10-2. 다른 조 모의 검토 기록\n", + "\n", + "| **번호** | **지적 사항** | **해당 장** | **반영 내용** | **반영 여부** |\n", + "| --- | --- | --- | --- | --- |\n", + "| **1** | ( ) | ( ) | ( ) | ( ) |\n", + "| **2** | ( ) | ( ) | ( ) | ( ) |\n", + "| **3** | ( ) | ( ) | ( ) | ( ) |\n", + "| **4** | ( ) | ( ) | ( ) | ( ) |\n", + "| **5** | ( ) | ( ) | ( ) | ( ) |\n", + "\n", + "| **점검 항목** | **확인** |\n", + "| --- | --- |\n", + "| 5개 장(개요-SOP-시나리오-평가 루브릭-AAR)이 모두 있다 | ( ) |\n", + "| 교관 지침서가 포함되어 있다 | ( ) |\n", + "| 모든 사진·지명이 [교육용가상자료]로 표시되어 있다 | ( ) |\n", + "| 다른 조 지적 사항을 모두 반영했다 | ( ) |\n", + "| 인쇄·배포 가능한 형태(쪽 번호, 목차)로 정리했다 | ( ) |\n", + "\n", + "**4. 평가 채점표**\n", + "\n", + "미션별 기준을 모두 만족하면 PASS로 표기한다. 필수 미션 7개(1·2·3·5·7·9·10)를 모두 PASS해야 과제를 이수한 것으로 본다.\n", + "\n", + "| **미션** | **구분** | **채점 기준** | **PASS / FAIL** |\n", + "| --- | --- | --- | --- |\n", + "| **미션 1** | 필수 | 현장 체크 항목 15개 이상, 역할 분담 매트릭스 100% 지정, 메타데이터 한계 명시 | ( ) |\n", + "| **미션 2** | 필수 | 5단계 세부 절차·완료 기준 100%, 필수 보안 조치 포함, 휴대용 요약 카드 | ( ) |\n", + "| **미션 3** | 필수 | 10장 단서 판정 완료, MSEL 10건 이상·정상 대 비정상 6:4, 워크스루 보완 | ( ) |\n", + "| **미션 4** | 선택 | SOP 단계 준수율 80% 이상, 보고 지연 3분 이내, 표준 보고 양식 사용 | ( ) |\n", + "| **미션 5** | 필수 | 판정 정확도 85% 이상(10장 중 9장), 기만 판정마다 근거 단서 2개 | ( ) |\n", + "| **미션 6** | 선택 | 인계 필수 항목 누락 0%, 10분 이내, 퀴즈 4/5 이상, 상호 서명 | ( ) |\n", + "| **미션 7** | 필수 | 긴급 모드 전환 1분 이내, 위험 조치 성공률 90% 이상, 위임 범위 위반 0건 | ( ) |\n", + "| **미션 8** | 선택 | 개선점 5건 이상, 원인별 로그 근거 100%, 비난 없는 기록 | ( ) |\n", + "| **미션 9** | 필수 | 조치 시간 30% 이상 단축, 오류 발생률 50% 이상 감소 | ( ) |\n", + "| **미션 10** | 필수 | 5개 장 100% 완비, 교관 지침서, 모의 검토 반영 | ( ) |\n", + "\n" + ] + } + ], + "metadata": { + "colab": { + "provenance": [] + }, + "kernelspec": { + "display_name": "Python 3", + "name": "python3" + }, + "language_info": { + "name": "python" + } + }, + "nbformat": 4, + "nbformat_minor": 0 +} \ No newline at end of file diff --git a/uploads/LOG04/LOG04_workbook.ipynb b/uploads/LOG04/LOG04_workbook.ipynb new file mode 100644 index 0000000..171e29f --- /dev/null +++ b/uploads/LOG04/LOG04_workbook.ipynb @@ -0,0 +1,967 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# 📋 [LOG04] 드론·미사일 소모전 대비 핵심 방어물자 재보급 우선순위 기준서\n", + "### 실습 워크북 & SOP 가이드 (Google Colab / Jupyter)\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/LOG04/LOG04_workbook.ipynb)\n", + "\n", + "---\n", + "\n", + "## 📌 1. 프로젝트 개요 및 배경\n", + "\n", + "| 항목 | 내용 |\n", + "| :--- | :--- |\n", + "| **과정 분류** | 군수·보급 (Level 3) |\n", + "| **학습 소요 시간** | 10개 미션 (5일 / 총 27.0시간) |\n", + "| **실습 유형** | 작전절차(SOP), 체크리스트, 모의훈련, AAR 사후검토 실습 |\n", + "\n", + "### 📖 프로젝트 목표\n", + "러시아-우크라이나 전쟁과 미국-이란 간 긴장 국면에서는 드론과 미사일 공격이 반복되면서 대공 요격 자산, 드론 부품, 방호 자재가 계획보다 훨씬 빨리 소모되었고, 한정된 물자를 어느 부대에 먼저 보낼지가 전투 지속능력을 좌우하는 문제로 부각되었다. 소모 속도, 남은 양, 재보급에 걸리는 시간을 함께 보지 않고 요청이 큰 곳부터 채우면 한쪽은 남아돌고 다른 쪽은 바닥나는 불균형이 생긴다. 본 프로젝트에서는 교육용 가상 부대와 가상 물자 소모 기록을 사용해 핵심 방어물자 소모율 분석, 우선순위 산정 기준과 자료 신뢰도 6단계, 고갈 위험 등급과 승인·보류 기준, 우선순위 매트릭스, 배정 원칙과 대체품 전환 지침, 지휘관 브리핑 양식을 차례로 만들고, 수송로 차단·물자 80% 고갈 스트레스 훈련, AAR 기준 보정, 1페이지 브리핑을 거친다. 최종 목표는 근거·책임 직책·승인과 보류 기준이 분명한 핵심 방어물자 재보급 우선순위 결정 기준서(표준 지침서)를 문서로 완성하는 것이다.\n", + "\n", + "> **⚠️ [교육용 가상 자료 원칙]**\n", + "> - 본 워크북의 모든 자료는 안전 규정에 따라 **교육용 가상 자료**로 구성되어 있습니다.\n", + "> - 실제 군사 기밀이나 부대 세부 인적·작전 정보는 포함되지 않습니다.\n", + "\n", + "---\n", + "\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 01. 소모전 환경에서의 핵심 방어물자(대공미사일, 드론 부품, 방호자재) 소모율 분석\n", + "\n", + "### 📋 미션 배경 및 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "소모전 환경 하 핵심 방어물자(대공미사일, 드론 부품) 소모율을 분석한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **소모전을 가정한 가상 상황에서 대공미사일, 드론 부품, 방호자재의 소모율을 계산하는 틀을 정하고, 우선순위 기준서의 문서 골격을 정의한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **실습자료의 가상 소모 사례 5건과 거점별 보유·소모 기록을 읽고 품목별 일평균 소모율(소모량/보유량)과 보유일수(보유량/일평균 소모량) 산출하고 모델에 적용하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 5개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `docs/log04_doc_framework.md 및 분석 개요서` 형태의 결과물을 완성한다.\n", + "\n", + "### 📑 실습자료 및 작성 양식\n", + "소모전 환경에서의 핵심 방어물자(대공미사일, 드론 부품, 방호자재) 소모율 분석**\n", + "\n", + "가. 제공 자료 1 - 거점별 가상 보유·소모 기록(D일 기준) [교육용가상자료]\n", + "\n", + "| **거점** | **품목** | **보유량** | **일평균 소모량** | **리드타임(일)** |\n", + "| --- | --- | --- | --- | --- |\n", + "| **북곡** | AM-1 | 24 | 6 | 3 |\n", + "| **북곡** | DP-2 | 40 | 5 | 2 |\n", + "| **북곡** | PG-3 | 120 | 10 | 1 |\n", + "| **서림** | AM-1 | 18 | 3 | 3 |\n", + "| **서림** | DP-2 | 12 | 4 | 2 |\n", + "| **서림** | PG-3 | 30 | 15 | 1 |\n", + "| **해솔** | AM-1 | 8 | 4 | 3 |\n", + "| **해솔** | DP-2 | 20 | 2 | 2 |\n", + "| **해솔** | PG-3 | 60 | 6 | 1 |\n", + "\n", + "가. 제공 자료 2 - 가상 소모 사례 5건 [교육용가상자료]\n", + "\n", + "| **사례** | **상황 요약** | **기록된 근거** | **눈에 띄는 한계** |\n", + "| --- | --- | --- | --- |\n", + "| **C1** | 북곡 거점 D-2일부터 AM-1 일 소모량이 3발에서 6발로 증가 | 거점 소모 일지 3일치 | 교전 빈도 증가인지 손실인지 구분 기록 없음 |\n", + "| **C2** | 서림 거점 DP-2가 이틀 만에 보유량의 절반이 소진됨 | 부품 불출 대장 | 파손 손실과 정상 소모가 한 칸에 합산됨 |\n", + "| **C3** | 해솔 거점 PG-3는 여유가 있으나 추가 배정 요청이 올라옴 | 요청서 1장 | 요청 사유가 '예방'이라고만 적혀 있음 |\n", + "| **C4** | 수송로 상태 불량으로 지원대대 도착 예정이 1일 늦어짐 | 수송 상황 보고 1건 | 출처가 단일 보고이고 복구 시점 미기재 |\n", + "| **C5** | 'AM-1이 거의 소진되었다'는 구두 보고 | 없음 | 수량, 시각, 보고자 미기재 |\n", + "\n", + "나. 작성 서식 1 - 소모율·보유일수 계산표(제공 자료 1의 9개 행을 계산한다)\n", + "\n", + "| **거점** | **품목** | **소모율(%)** | **보유일수** | **리드타임 여유** | **먼저 눈에 띄는 점** |\n", + "| --- | --- | --- | --- | --- | --- |\n", + "| **북곡** | AM-1 | | | | |\n", + "| **북곡** | DP-2 | | | | |\n", + "| **북곡** | PG-3 | | | | |\n", + "| **서림** | AM-1 | | | | |\n", + "| **서림** | DP-2 | | | | |\n", + "| **서림** | PG-3 | | | | |\n", + "| **해솔** | AM-1 | | | | |\n", + "| **해솔** | DP-2 | | | | |\n", + "| **해솔** | PG-3 | | | | |\n", + "\n", + "나. 작성 서식 2 - 사례별 소모 급증 원인(사실과 추정 구분)\n", + "\n", + "| **사례** | **사실로 기록된 것** | **추정인 것** | **추가로 확인할 것** |\n", + "| --- | --- | --- | --- |\n", + "| **C1** | | | |\n", + "| **C2** | | | |\n", + "| **C3** | | | |\n", + "| **C4** | | | |\n", + "| **C5** | | | |\n", + "\n", + "나. 작성 서식 3 - 기준서 목차 골격(지휘관 결심 의제 순으로 배열)\n", + "\n", + "| **순서** | **장 제목** | **담을 핵심 내용** | **지휘관이 이 장에서 얻는 판단 정보** |\n", + "| --- | --- | --- | --- |\n", + "| **제1장** | | | |\n", + "| **제2장** | | | |\n", + "| **제3장** | | | |\n", + "| **제4장** | | | |\n", + "| **제5장** | | | |\n", + "| **제6장** | | | |\n", + "| **제7장** | | | |\n", + "| **제8장** | | | |\n", + "\n", + "다. 점검표\n", + "\n", + "| **점검 항목** | **확인** |\n", + "| --- | --- |\n", + "| 가상 소모 사례 5건을 모두 검토하고 사실과 추정을 구분했다 | ( ) |\n", + "| 9개 거점·품목의 소모율, 보유일수, 리드타임 여유를 계산했다(계산 오류 0건) | ( ) |\n", + "| C5처럼 수량과 보고자가 없는 구두 보고를 찾아 표시했다 | ( ) |\n", + "| 기준서 목차 8개 장의 제목과 핵심 내용을 정의했다 | ( ) |\n", + "| 목차를 지휘관이 먼저 알아야 할 의제 순으로 배열하고 이유를 한 줄로 적었다 | ( ) |\n", + "\n", + "**\n", + "\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 02. 부대 임무 중요도, 잔여 보유량, 재보급 리드타임 기반 우선순위 산정 기준 수립\n", + "\n", + "### 📋 미션 배경 및 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "부대 중요도, 잔여량, 조달 리드타임 기반 우선순위 산정 기준을 수립한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **부대 임무 중요도, 잔여 보유량, 재보급 리드타임을 어떻게 점수화할지 정하고, 이 입력 자료(재고·소모 보고)를 믿을 수 있는 정도를 6단계로 구분한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **임무 중요도, 보유일수, 리드타임 여유(보유일수 - 리드타임), 대체 가능성 등 우선순위 산정 요소의 척도를 관찰 가능한 구간으로 정 수행하고 검증하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 5개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `framework/log04_source_reliability_matrix.md` 형태의 결과물을 완성한다.\n", + "\n", + "### 📑 실습자료 및 작성 양식\n", + "부대 임무 중요도, 잔여 보유량, 재보급 리드타임 기반 우선순위 산정 기준 수립**\n", + "\n", + "가. 제공 자료 1 - 가상 재고 보고 5종 [교육용가상자료]\n", + "\n", + "| **보고** | **거점·품목** | **보고 내용** | **확인 방법** | **확인 상태** |\n", + "| --- | --- | --- | --- | --- |\n", + "| **R1** | 북곡 AM-1 | 보유 24, 소모 6/일 | 창고 실사 + 담당자·검증 담당 서명 | 실사표와 서명 모두 있음 |\n", + "| **R2** | 서림 DP-2 | 보유 12, 소모 4/일 | 불출 대장 집계 | 집계는 있으나 실사 없음 |\n", + "| **R3** | 해솔 AM-1 | 보유 8, 소모 4/일 | 전일 보고서 인용 | 하루 전 자료, 소모 미반영 |\n", + "| **R4** | 서림 PG-3 | 보유 30 정도 | 담당자 구두 전달 | 수량이 어림값, 서명 없음 |\n", + "| **R5** | 북곡 DP-2 | 보유 충분 | 근거 없음 | 수량·시각 없음 |\n", + "\n", + "가. 제공 자료 2 - 보고 신뢰도 6단계 틀. 단계 이름은 고정하고 판정 기준을 조별로 작성한다.\n", + "\n", + "| **단계** | **이름** | **판정 기준(조별 작성)** |\n", + "| --- | --- | --- |\n", + "| **G1** | 실사·독립 재확인 | |\n", + "| **G2** | 실사 또는 대장 대조 | |\n", + "| **G3** | 대장 집계 | |\n", + "| **G4** | 전일 자료·인용 | |\n", + "| **G5** | 구두·어림값 | |\n", + "| **G6** | 근거 없음 | |\n", + "\n", + "나. 작성 서식 1 - 우선순위 산정 요소 척도표(구간은 관찰 가능한 수치로 쓴다)\n", + "\n", + "| **산정 요소** | **높음(점수 3)** | **보통(점수 2)** | **낮음(점수 1)** | **자료 출처** |\n", + "| --- | --- | --- | --- | --- |\n", + "| **임무 중요도** | | | | |\n", + "| **보유일수** | | | | |\n", + "| **리드타임 여유** | | | | |\n", + "| **대체 가능성** | | | | |\n", + "\n", + "나. 작성 서식 2 - 평가 지표(10개 이상, 예시 2개 포함)\n", + "\n", + "| **번호** | **평가 지표** | **계산 방법(관찰 가능한 기준)** | **기록 책임자(가상 직책)** |\n", + "| --- | --- | --- | --- |\n", + "| **1** | 보유일수 | 보유량 / 일평균 소모량(소수 첫째 자리까지) | 거점 군수담당 |\n", + "| **2** | 리드타임 여유 | 보유일수 - 리드타임(일). 음수이면 고갈 후 도착 | 대대 군수참모 |\n", + "| **3** | | | |\n", + "| **4** | | | |\n", + "| **5** | | | |\n", + "| **6** | | | |\n", + "| **7** | | | |\n", + "| **8** | | | |\n", + "| **9** | | | |\n", + "| **10** | | | |\n", + "| **11** | | | |\n", + "| **12** | | | |\n", + "\n", + "나. 작성 서식 3 - 등급 부여 일관성 시험(조원이 각자 G1~G6을 매기고 비교)\n", + "\n", + "| **보고** | **조원 1** | **조원 2** | **조원 3** | **조원 4** | **일치 여부** | **불일치 원인** |\n", + "| --- | --- | --- | --- | --- | --- | --- |\n", + "| **R1** | | | | | | |\n", + "| **R2** | | | | | | |\n", + "| **R3** | | | | | | |\n", + "| **R4** | | | | | | |\n", + "| **R5** | | | | | | |\n", + "\n", + "나. 작성 서식 4 - 교차 확인 필수 규칙: 단일 거점 보고만으로는 G ( ) 이하로 기록하고 긴급 재보급 승인은 서로 다른 출처(실사, 대장, 상급 집계) ( )건 이상이 일치할 때만 검토한다. 보고 시각이 ( )시간 이상 지난 자료는 ( )로 처리하고 재확인을 요청한다.\n", + "\n", + "다. 점검표\n", + "\n", + "| **점검 항목** | **확인** |\n", + "| --- | --- |\n", + "| 우선순위 산정 요소 4개의 척도를 수치 구간으로 적었다 | ( ) |\n", + "| 신뢰도 G1~G6의 판정 기준을 모두 작성했다 | ( ) |\n", + "| 평가 지표를 10개 이상 정의하고 계산 방법과 기록 책임자를 적었다 | ( ) |\n", + "| 가상 재고 보고 5종에 대한 조원 등급이 4건 이상 일치했다(불일치 건은 원인과 수정 내용을 적었다) | ( ) |\n", + "| 단일 출처 의존 경고와 교차 확인 필수 규칙을 명시했고 보고자 편향 방지 문구를 넣었다 | ( ) |\n", + "\n", + "**\n", + "\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 03. 방어물자 고갈 위험 등급 및 긴급 재보급 요청 승인/보류 기준서 초안 작성\n", + "\n", + "### 📋 미션 배경 및 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "방어물자 고갈 위험 등급 및 재보급 승인/보류 기준서를 작성한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **방어물자 고갈 위험을 4단계 등급으로 나누고, 긴급 재보급 요청을 승인할지 보류할지 가르는 기준서 초안을 작성하며, 자료의 공백과 불확실성을 정리한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **보유일수와 리드타임 여유를 기준으로 고갈 위험 등급(정상·주의·경계·위험)의 조건을 수치로 정하고, 등급별로 승인·보류·추가 확인 요청 중 어떤 처리가 되는지 적는다 과정을 진행하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 5개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `analysis/log04_uncertainty_assessment.md` 형태의 결과물을 완성한다.\n", + "\n", + "### 📑 실습자료 및 작성 양식\n", + "방어물자 고갈 위험 등급 및 긴급 재보급 요청 승인/보류 기준서 초안 작성**\n", + "\n", + "가. 제공 자료 1 - 어긋난 가상 재고 기록 쌍 4건 [교육용가상자료]\n", + "\n", + "| **쌍** | **기록 A** | **기록 B** | **어긋난 지점** |\n", + "| --- | --- | --- | --- |\n", + "| **K1** | 서림 DP-2 보유 12세트(거점 일지, D일 18시) | 서림 DP-2 보유 20세트(지원대대 집계, D일 06시) | 시각 차이와 소모 반영 여부 |\n", + "| **K2** | 북곡 PG-3 120묶음 | 북곡 PG-3 1,200개 | 단위 혼용(묶음/개) |\n", + "| **K3** | 해솔 AM-1 8발(창고) | 해솔 AM-1 11발(창고+이동 중 3발) | 집계 범위 차이 |\n", + "| **K4** | 서림 AM-1 소모 3/일(담당자 A) | 서림 AM-1 소모 5/일(담당자 B) | 보고자 차이, 기준 기간 미기재 |\n", + "\n", + "가. 제공 자료 2 - 고갈 위험 등급 틀(이름은 고정, 조건과 처리는 조별 작성)\n", + "\n", + "| **등급** | **조건(보유일수·리드타임 여유)** | **처리(승인·보류·추가 확인 요청)** | **책임 직책(가상)** |\n", + "| --- | --- | --- | --- |\n", + "| **정상** | | | |\n", + "| **주의** | | | |\n", + "| **경계** | | | |\n", + "| **위험** | | | |\n", + "\n", + "나. 작성 서식 1 - 긴급 재보급 요청 승인/보류 기준서 초안\n", + "\n", + "| **승인 검토 조건(모두 충족)** | |\n", + "| --- | --- |\n", + "\n", + "| **보류 조건(하나라도 해당)** | |\n", + "| --- | --- |\n", + "\n", + "| **추가 확인 요청 조건** | |\n", + "| --- | --- |\n", + "\n", + "| **기록해야 할 문서** | |\n", + "| --- | --- |\n", + "\n", + "| **최종 결정자(가상 직책)** | |\n", + "| --- | --- |\n", + "\n", + "나. 작성 서식 2 - 어긋난 기록 분류와 원인 후보\n", + "\n", + "| **쌍** | **분류(시점·단위·집계 범위·보고자)** | **원인 후보** | **확인 방법** | **책임 직책** |\n", + "| --- | --- | --- | --- | --- |\n", + "| **K1** | | | | |\n", + "| **K2** | | | | |\n", + "| **K3** | | | | |\n", + "| **K4** | | | | |\n", + "\n", + "나. 작성 서식 3 - 자료 공백 요인과 불확실성 영향도(8개 이상)\n", + "\n", + "| **번호** | **공백 요인** | **영향도(높음·중간·낮음)** | **대체 확인 수단** |\n", + "| --- | --- | --- | --- |\n", + "| **1** | | | |\n", + "| **2** | | | |\n", + "| **3** | | | |\n", + "| **4** | | | |\n", + "| **5** | | | |\n", + "| **6** | | | |\n", + "| **7** | | | |\n", + "| **8** | | | |\n", + "| **9** | | | |\n", + "\n", + "나. 작성 서식 4 - 확신 표현 규칙: '충분하다'처럼 단정하는 표현 대신 '( ) 기준 보유일수 ( )일, 확인 단계 G( ), 확인되지 않은 것은 ( )'의 형식으로 쓴다.\n", + "\n", + "다. 점검표\n", + "\n", + "| **점검 항목** | **확인** |\n", + "| --- | --- |\n", + "| 위험 등급 4단계의 조건을 수치로 적고 등급별 처리를 연결했다 | ( ) |\n", + "| 승인 검토·보류·추가 확인 조건을 구분했다 | ( ) |\n", + "| 어긋난 기록 4건에 원인 후보와 확인 방법을 적었다 | ( ) |\n", + "| 자료 공백 요인을 8개 이상 찾고 영향도와 대체 확인 수단을 적었다 | ( ) |\n", + "| 판단의 한계를 함께 쓰는 표현 규칙을 정했다 | ( ) |\n", + "\n", + "**\n", + "\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 04. 가상 전장 소모 시나리오 기반 물자별 재보급 우선순위 매트릭스 도출 실습\n", + "\n", + "### 📋 미션 배경 및 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "가상 전장 소모 시나리오 기반 물자별 재보급 우선순위 도출을 실습한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **가상 전장 소모 시나리오에서 물자별·거점별 재보급 우선순위를 근거 항목과 가중치로 계산하는 매트릭스를 만들고 3개 사례에 적용한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **가상 3일치 소모 시나리오를 바탕으로 우선순위를 높이거나 낮추는 근거를 12개 이상 항목화하고 항목별 가중치와 종합 점수 산식을 정 수행하고 검증하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 5개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `matrix/log04_attribution_matrix.xlsx(md)` 형태의 결과물을 완성한다.\n", + "\n", + "### 📑 실습자료 및 작성 양식\n", + "가상 전장 소모 시나리오 기반 물자별 재보급 우선순위 매트릭스 도출 실습**\n", + "\n", + "가. 제공 자료 1 - 가상 3일치 소모 시나리오(일 소모량) [교육용가상자료]\n", + "\n", + "| **거점·품목** | **D+1일** | **D+2일** | **D+3일** | **리드타임(일)** | **수송로 상태** |\n", + "| --- | --- | --- | --- | --- | --- |\n", + "| **해솔 AM-1(보유 8)** | 4 | 5 | 6 | 3 | 양호 |\n", + "| **서림 DP-2(보유 12)** | 4 | 6 | 6 | 2 | 지연 1일 |\n", + "| **서림 PG-3(보유 30)** | 15 | 15 | 12 | 1 | 양호 |\n", + "\n", + "가. 제공 자료 2 - 근거 항목 예시 4개(나머지는 조별로 12개 이상으로 늘린다)\n", + "\n", + "| **근거 항목** | **방향(상향·하향)** | **가중치 예시** |\n", + "| --- | --- | --- |\n", + "| **리드타임 여유가 0일 이하** | 상향 | +4 |\n", + "| **소모량이 3일 연속 증가** | 상향 | +2 |\n", + "| **보고 신뢰도가 G4 이하** | 하향 | -3 |\n", + "| **대체품 전환이 이미 가능(기술 검토 완료)** | 하향 | -2 |\n", + "\n", + "나. 작성 서식 1 - 근거 항목 목록(12개 이상)\n", + "\n", + "| **번호** | **근거 항목** | **방향(상향·하향·확인 불가)** | **가중치** | **확인 방법** |\n", + "| --- | --- | --- | --- | --- |\n", + "| **1** | | | | |\n", + "| **2** | | | | |\n", + "| **3** | | | | |\n", + "| **4** | | | | |\n", + "| **5** | | | | |\n", + "| **6** | | | | |\n", + "| **7** | | | | |\n", + "| **8** | | | | |\n", + "| **9** | | | | |\n", + "| **10** | | | | |\n", + "| **11** | | | | |\n", + "| **12** | | | | |\n", + "\n", + "나. 작성 서식 2 - 종합 점수 산식과 규칙: 종합 점수 = (상향 근거 가중치 합) - (하향 근거 가중치 절댓값 합) 이다. 규칙 1 점수 ( )점 이상이면 긴급 요청 승인 검토, 규칙 2 점수가 높아도 보고 신뢰도가 G( ) 이하이면 ( ), 규칙 3 확인 불가 항목이 ( )개 이상이면 ( ), 규칙 4 점수는 ( )를 대신하지 않고 승인권자가 최종 결정한다.\n", + "\n", + "나. 작성 서식 3 - 사례 3종 적용(S1 해솔 AM-1, S2 서림 DP-2, S3 서림 PG-3)\n", + "\n", + "| **사례** | **상향 근거 합** | **하향 근거 합** | **종합 점수** | **분류(긴급 승인 검토·보류 후 재확인·일반 보급)** | **판단 근거** |\n", + "| --- | --- | --- | --- | --- | --- |\n", + "| **S1** | | | | | |\n", + "| **S2** | | | | | |\n", + "| **S3** | | | | | |\n", + "\n", + "나. 작성 서식 4 - 레드팀 반박 기록\n", + "\n", + "| **번호** | **레드팀 반박(예: 이 산식은 소모량이 큰 거점에만 유리하다)** | **작성 팀 대응** | **보완한 최소 증거 요건** |\n", + "| --- | --- | --- | --- |\n", + "| **1** | | | |\n", + "| **2** | | | |\n", + "| **3** | | | |\n", + "\n", + "다. 점검표\n", + "\n", + "| **점검 항목** | **확인** |\n", + "| --- | --- |\n", + "| 근거를 12개 이상 항목화하고 방향과 가중치를 적었다 | ( ) |\n", + "| 점수 산식과 규칙 1~4의 빈칸을 모두 채웠다 | ( ) |\n", + "| 사례 S1~S3의 점수와 분류를 계산해 기록했다 | ( ) |\n", + "| 점수가 승인을 자동으로 결정하지 않고 사람이 최종 결정한다고 명시했다 | ( ) |\n", + "| 레드팀 반박 3건에 대응했다 | ( ) |\n", + "\n", + "**\n", + "\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 05. 물자 배분 불균형 해소를 위한 군수사령부 배정 원칙 및 대체품 전환 지침 수립\n", + "\n", + "### 📋 미션 배경 및 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "물자 배분 불균형 해소를 위한 군수 배정 원칙을 수립한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **물자 배분 불균형을 줄이기 위한 군수사령부 배정 원칙과 대체품 전환 지침을 만들고, 승인·보류 분기점과 책임 직책을 확정한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **M2~M4의 기준을 위험 등급, 자료 신뢰도, 대체품 전환 가능 여부, 승인 권한 순의 의사결정 트리로 묶고 분기점마다 조건과 책임 직책(가상)을 적는다 과정을 진행하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 5개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `sop/log04_approval_thresholds_sop.md` 형태의 결과물을 완성한다.\n", + "\n", + "### 📑 실습자료 및 작성 양식\n", + "물자 배분 불균형 해소를 위한 군수사령부 배정 원칙 및 대체품 전환 지침 수립**\n", + "\n", + "가. 제공 자료 1 - 가상 배정 상황과 경계선 사례 5종 [교육용가상자료]\n", + "\n", + "| **묶음** | **내용** | **애매한 이유** |\n", + "| --- | --- | --- |\n", + "| **B1** | 서림 DP-2 위험 등급, 보고 신뢰도 G3, 지원대대 비축분 일부 사용 요청 | 비축분 사용 조건이 불분명 |\n", + "| **B2** | 해솔 AM-1 보유일수가 리드타임보다 짧으나 보고가 전일 자료(G4) | 위험은 크지만 자료가 약함 |\n", + "| **B3** | 서림 PG-3 부족, 가상 대체품(기술 검토 미완료) 전환 제안 | 대체품 안전성 확인 전 |\n", + "| **B4** | 북곡 거점이 '예방' 명목으로 DP-2 추가 배정 요청, 보유일수 8일 | 필요성이 낮은 요청 |\n", + "| **B5** | 민간 지원 물자 일부를 방호자재로 돌리자는 제안 | 군 물자가 아닌 물자, 법무 검토 필요 |\n", + "\n", + "가. 제공 자료 2 - 책임 직책 예시(가상): 거점 군수담당, 대대 군수참모, 지원대대장, 사령부 배정 담당, 기술 검토 담당, 법무 검토 담당\n", + "\n", + "나. 작성 서식 1 - 의사결정 트리(조건과 분기를 채운다)\n", + "\n", + "| **단계** | **확인 질문** | **예일 때** | **아니오일 때** |\n", + "| --- | --- | --- | --- |\n", + "| **1** | 고갈 위험 등급이 경계 이상인가 | | |\n", + "| **2** | 보고 신뢰도가 G3 이상이고 교차 확인이 일치하는가 | | |\n", + "| **3** | 대체품 전환이 필요하며 기술 검토가 끝났는가 | | |\n", + "| **4** | 민간 지원 물자 등 군 물자가 아닌 쟁점이 있는가 | | |\n", + "| **5** | 승인 권한자의 확인이 있는가 | | |\n", + "\n", + "나. 작성 서식 2 - 배정 원칙과 대체품 전환 지침, 분기점 조건·책임 직책 매핑표\n", + "\n", + "| **분기점** | **조건(수치 또는 관찰 가능한 문장)** | **책임 직책(가상)** | **기록할 문서** |\n", + "| --- | --- | --- | --- |\n", + "| **T1** | | | |\n", + "| **T2** | | | |\n", + "| **T3** | | | |\n", + "| **T4** | | | |\n", + "\n", + "나. 작성 서식 3 - 모의 결심 기록지(B1~B5)\n", + "\n", + "| **묶음** | **판단(승인·보류 유지·추가 확인 요청)** | **근거** | **책임 직책** | **조원 합의(예·아니오)** |\n", + "| --- | --- | --- | --- | --- |\n", + "| **B1** | | | | |\n", + "| **B2** | | | | |\n", + "| **B3** | | | | |\n", + "| **B4** | | | | |\n", + "| **B5** | | | | |\n", + "\n", + "나. 작성 서식 4 - 긴급 신속 승인 규칙: 신속 승인에서도 줄이지 않는 확인 단계는 ( ), 사후 감사 항목은 ( ), 사후 감사 기한은 ( )이다. 민간 지원 물자와 기술 검토 미완료 대체품은 신속 승인 대상에서 ( ).\n", + "\n", + "다. 점검표\n", + "\n", + "| **점검 항목** | **확인** |\n", + "| --- | --- |\n", + "| 분기점 4개의 조건을 수치 또는 관찰 가능한 문장으로 적었다 | ( ) |\n", + "| 분기점마다 책임 직책(가상)과 기록 문서를 연결했다 | ( ) |\n", + "| 경계선 사례 5종 모두에 판단과 근거를 기록했다 | ( ) |\n", + "| 기술 검토 전 대체품 전환과 민간 지원 물자 전용이 막히고 법무·기술 검토로 연결된다 | ( ) |\n", + "| 사람의 승인 없이 재보급이 승인되는 경로가 없고 교전수칙·지휘계통 우선 문구가 있다 | ( ) |\n", + "\n", + "**\n", + "\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 06. 지휘관 결심용 핵심 방어물자 재보급 현황 및 우선순위 브리핑 양식 작성\n", + "\n", + "### 📋 미션 배경 및 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "지휘관 결심용 방어물자 재보급 우선순위 브리핑 양식을 작성한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **지휘관이 결심할 때 쓰는 핵심 방어물자 재보급 현황 및 우선순위 브리핑 양식을 만들고, 기록을 사실·판단·조치·미확인으로 구분하는 서식을 개발한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **한눈에 보는 요약 차트(거점, 품목, 보유일수, 리드타임 여유, 위험 등급, 요청 수량, 결심 요청) 설계하고 워크플로우를 구성하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 5개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `templates/log04_aar_fact_action_template.md` 형태의 결과물을 완성한다.\n", + "\n", + "### 📑 실습자료 및 작성 양식\n", + "지휘관 결심용 핵심 방어물자 재보급 현황 및 우선순위 브리핑 양식 작성**\n", + "\n", + "가. 제공 자료 1 - 혼동 유발 표현 예시(나머지는 조별로 6개 이상으로 늘린다)\n", + "\n", + "| **혼동 유발 표현** | **왜 혼동되는가** | **대체 표현 예** |\n", + "| --- | --- | --- |\n", + "| **충분하다** | 기준과 수량이 보이지 않음 | 보유 (수량), 보유일수 (일), 리드타임 여유 (일) |\n", + "| **곧 도착한다** | 도착 시각이 없음 | 지원대대 출발 (시각), 도착 예정 (시각), 확인 단계 G( ) |\n", + "| **거의 소진** | 수치 없는 어림 | 보유 (수량)이 일평균 소모 (수량)의 ( )일치 |\n", + "\n", + "가. 제공 자료 2 - 가상 현황 메모 3종(사실·판단·조치·미확인을 구분해 표시한다)\n", + "\n", + "| **메모** | **내용** |\n", + "| --- | --- |\n", + "| **M-1** | 서림 DP-2는 불출 대장상 보유 12세트, 일 소모 4세트다. 곧 바닥난다고 본다. 사령부에 긴급 요청을 올렸다. 파손 손실 포함 여부는 모른다. |\n", + "| **M-2** | 해솔 PG-3는 충분하다고 판단된다. 추가 배정은 하지 않기로 했다. 야간 소모량은 아직 반영되지 않았다. |\n", + "| **M-3** | 북곡 AM-1 소모가 늘었다. 교전 빈도 때문이라고 생각한다. 다음 보고에 일지를 첨부하라고 지시했다. 손실 발생 여부는 확인되지 않았다. |\n", + "\n", + "나. 작성 서식 1 - 지휘관 보고용 재보급 현황 요약 차트\n", + "\n", + "| **거점** | **품목** | **보유일수** | **리드타임 여유** | **위험 등급** | **요청 수량** | **결심 요청** |\n", + "| --- | --- | --- | --- | --- | --- | --- |\n", + "| | | | | | | |\n", + "| | | | | | | |\n", + "| | | | | | | |\n", + "| | | | | | | |\n", + "\n", + "나. 작성 서식 2 - 기록 분류 규칙(빈칸을 채운다): 사실은 ( ), 판단은 ( ), 조치는 ( ), 미확인은 ( ) 표시로 구분한다. 문장 하나에는 한 가지 분류만 담는다.\n", + "\n", + "나. 작성 서식 3 - 요약형 기록(메모 M-1~M-3을 옮겨 적는다)\n", + "\n", + "| **메모** | **사실** | **판단** | **조치** | **미확인** |\n", + "| --- | --- | --- | --- | --- |\n", + "| **M-1** | | | | |\n", + "| **M-2** | | | | |\n", + "| **M-3** | | | | |\n", + "\n", + "나. 작성 서식 4 - 상세형 기록(1건 선택)\n", + "\n", + "| **기록 번호** | |\n", + "| --- | --- |\n", + "\n", + "| **사실(거점, 품목, 수량, 시각, 신뢰도 단계)** | |\n", + "| --- | --- |\n", + "\n", + "| **판단(위험 등급과 근거)** | |\n", + "| --- | --- |\n", + "\n", + "| **조치(누가, 언제까지)** | |\n", + "| --- | --- |\n", + "\n", + "| **미확인(확인 방법, 책임 직책)** | |\n", + "| --- | --- |\n", + "\n", + "다. 점검표\n", + "\n", + "| **점검 항목** | **확인** |\n", + "| --- | --- |\n", + "| 요약 차트에 보유일수, 리드타임 여유, 위험 등급, 결심 요청 칸이 있다 | ( ) |\n", + "| 혼동 유발 표현 금지 목록을 6개 이상 만들고 대체 표현을 적었다 | ( ) |\n", + "| 메모 3종에서 사실과 판단을 혼동한 문장이 0건이다 | ( ) |\n", + "| 요약형과 상세형이 같은 내용을 담고 있다 | ( ) |\n", + "| 미확인 항목마다 확인 방법과 책임 직책이 있다 | ( ) |\n", + "\n", + "**\n", + "\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 07. 수송로 차단 및 물자 80% 고갈 모의 재보급 의사결정 스트레스 훈련\n", + "\n", + "### 📋 미션 배경 및 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "수송로 차단 모의 재보급 의사결정 스트레스 훈련을 진행한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **수송로가 차단되고 핵심 방어물자의 80%가 소진된 가상 상황에서 재보급 우선순위를 다시 정하는 스트레스 훈련을 하고, 판단 능력을 평가할 루브릭을 정의한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **통제관이 모의 카드(수송로 차단, 물자 80% 소진, 보고 지연, 대체 보급로 후보 등장, 추가 요청 폭주, 거점 교대)를 순서대로 주입하면 팀은 카드마다 우선순위를 다시 산정하고 승인·보류를 기록 수행하고 검증하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 5개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `rubric/log04_evaluation_rubric.md` 형태의 결과물을 완성한다.\n", + "\n", + "### 📑 실습자료 및 작성 양식\n", + "수송로 차단 및 물자 80% 고갈 모의 재보급 의사결정 스트레스 훈련**\n", + "\n", + "가. 제공 자료 1 - 스트레스 모의 카드 T1~T6 [교육용가상자료] (통제관이 순서대로 주입)\n", + "\n", + "| **카드** | **주입 내용** | **확인할 것** |\n", + "| --- | --- | --- |\n", + "| **T1** | 기준선: 3개 거점의 D일 보유·소모 현황 | 카드별 우선순위 기준선 기록 |\n", + "| **T2** | 수송로 차단: 지원대대 도착이 3일 늦어짐 | 리드타임 여유 재계산 |\n", + "| **T3** | 물자 80% 소진: AM-1 보유가 거점 평균 20% 수준으로 감소 | 위험 등급 재분류와 승인·보류 |\n", + "| **T4** | 보고 지연: 서림 거점 보고가 12시간 늦음 | 신뢰도 단계 하향과 재확인 요청 |\n", + "| **T5** | 대체 보급로 후보 등장(상태 미확인) | 승인 전 확인할 사항 |\n", + "| **T6** | 추가 요청 폭주: 3개 거점이 모두 긴급 요청 | 배정 원칙 적용과 결심 요청 |\n", + "\n", + "가. 제공 자료 2 - 루브릭 틀(5영역 x 4등급, 관찰 가능한 행동으로 작성)\n", + "\n", + "| **평가 영역** | **탁월** | **우수** | **보통** | **미흡** |\n", + "| --- | --- | --- | --- | --- |\n", + "| **소모 파악** | | | | |\n", + "| **우선순위 판단** | | | | |\n", + "| **승인·보류 근거** | | | | |\n", + "| **대체 방안** | | | | |\n", + "| **보고 적시성** | | | | |\n", + "\n", + "나. 작성 서식 1 - 카드별 재산정 기록\n", + "\n", + "| **카드** | **재산정 결과(거점·품목 순위)** | **승인·보류** | **근거** | **소요 시간(분)** |\n", + "| --- | --- | --- | --- | --- |\n", + "| **T1** | | | | |\n", + "| **T2** | | | | |\n", + "| **T3** | | | | |\n", + "| **T4** | | | | |\n", + "| **T5** | | | | |\n", + "| **T6** | | | | |\n", + "\n", + "나. 작성 서식 2 - 평가자 채점 일치표(평가자 2명이 상의 없이 채점)\n", + "\n", + "| **평가 영역** | **평가자 1 등급** | **평가자 2 등급** | **일치(예·아니오)** | **불일치 이유** |\n", + "| --- | --- | --- | --- | --- |\n", + "| **소모 파악** | | | | |\n", + "| **우선순위 판단** | | | | |\n", + "| **승인·보류 근거** | | | | |\n", + "| **대체 방안** | | | | |\n", + "| **보고 적시성** | | | | |\n", + "\n", + "나. 작성 서식 3 - 판단이 흔들린 지점: 순위가 가장 크게 바뀐 카드는 ( ), 이유는 ( ), 이 지점에서 지휘관에게 알릴 문장은 '( )'이다. 일치율은 ( ) / 5 = ( )%이다.\n", + "\n", + "다. 점검표\n", + "\n", + "| **점검 항목** | **확인** |\n", + "| --- | --- |\n", + "| 5개 영역 x 4단계 기준을 모두 관찰 가능한 행동으로 정의했다 | ( ) |\n", + "| 카드 T1~T6마다 우선순위 재산정과 승인·보류를 기록했다 | ( ) |\n", + "| 카드당 재산정 시간이 10분 이내이거나 지연 사유를 적었다 | ( ) |\n", + "| 평가자 2명이 상의 없이 채점하고 일치율을 계산했다 | ( ) |\n", + "| 물자 부족 상황에서도 사람의 승인과 신뢰도 확인 단계를 생략하지 않았다 | ( ) |\n", + "\n", + "**\n", + "\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 08. AAR 회의를 통한 비효율적 물자 할당 사례 분석 및 우선순위 기준 보정\n", + "\n", + "### 📋 미션 배경 및 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "AAR 회의를 통해 비효율적 물자 할당 사례를 분석하고 기준을 보정한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **가상 AAR 일지에서 비효율적으로 할당된 물자 사례의 원인을 사람·절차·정보·환경으로 규명하고, 레드팀 비판으로 우선순위 기준의 취약한 전제를 찾아 보정한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **가상 AAR 일지 2건(과잉 배정, 자료 지연에 따른 부족)을 시간순으로 읽고 계획과 실제의 차이를 적은 뒤 원인을 분류 수행하고 검증하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 5개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `reports/log04_redteam_critique_report.md` 형태의 결과물을 완성한다.\n", + "\n", + "### 📑 실습자료 및 작성 양식\n", + "AAR 회의를 통한 비효율적 물자 할당 사례 분석 및 우선순위 기준 보정**\n", + "\n", + "가. 제공 자료 - 가상 AAR 일지 A·B [교육용가상자료]\n", + "\n", + "| **시각** | **AAR 일지 A (과잉 배정 사례)** | **AAR 일지 B (자료 지연 부족 사례)** |\n", + "| --- | --- | --- |\n", + "| **D+1 08:00** | 해솔 PG-3 추가 배정 요청 접수(보유일수 10일) | 서림 DP-2 보고 도착, 전일 18시 자료 |\n", + "| **D+1 10:30** | 요청 사유 '예방', 신뢰도 확인 없이 승인 | 지원대대 비축 확인, 서림 요청은 일반 보급으로 분류 |\n", + "| **D+1 15:00** | 해솔에 PG-3 60묶음 추가 도착 | 서림 DP-2 소모 급증, 보유 4세트로 감소 |\n", + "| **D+2 07:00** | 서림 PG-3 보유 6묶음, 긴급 요청 발생 | 서림 긴급 요청, 리드타임 2일 |\n", + "| **D+2 12:00** | 북곡에서 서림으로 물자 이동 검토, 수송 불가 | D+3 도착 예정, 그동안 서림 DP-2 보유 0세트 |\n", + "\n", + "나. 작성 서식 1 - 계획과 실제 차이, 원인 분류(사람·절차·정보·환경, 개인 비난 표현 금지)\n", + "\n", + "| **번호** | **계획** | **실제** | **원인 분류** | **원인 설명(사실 중심)** | **개선안** |\n", + "| --- | --- | --- | --- | --- | --- |\n", + "| **1** | | | | | |\n", + "| **2** | | | | | |\n", + "| **3** | | | | | |\n", + "| **4** | | | | | |\n", + "| **5** | | | | | |\n", + "\n", + "나. 작성 서식 2 - 레드팀 쟁점·대응(5건 이상)\n", + "\n", + "| **번호** | **기준서의 취약한 전제** | **레드팀 비판** | **작성 팀 반박 또는 수정안** | **경고 문구** |\n", + "| --- | --- | --- | --- | --- |\n", + "| **1** | | | | |\n", + "| **2** | | | | |\n", + "| **3** | | | | |\n", + "| **4** | | | | |\n", + "| **5** | | | | |\n", + "| **6** | | | | |\n", + "\n", + "나. 작성 서식 3 - 반대 방향 위험 점검\n", + "\n", + "| **위험** | **설명** | **대응** |\n", + "| --- | --- | --- |\n", + "| **한 거점에 몰아주기 위험** | | |\n", + "| **예비 물자 소진 위험** | | |\n", + "| **기준 복잡도 증가로 현장 판단 지연** | | |\n", + "\n", + "나. 작성 서식 4 - 수정 반영 계획\n", + "\n", + "| **번호** | **수정할 문서와 위치** | **수정 내용** | **책임 직책(가상)** | **기한** |\n", + "| --- | --- | --- | --- | --- |\n", + "| **1** | | | | |\n", + "| **2** | | | | |\n", + "| **3** | | | | |\n", + "| **4** | | | | |\n", + "\n", + "다. 점검표\n", + "\n", + "| **점검 항목** | **확인** |\n", + "| --- | --- |\n", + "| 일지 A·B의 계획과 실제 차이를 5건 이상 적었다 | ( ) |\n", + "| 원인을 사람·절차·정보·환경으로 분류하고 개인 비난 표현이 없다 | ( ) |\n", + "| 레드팀 쟁점 5건 이상에 반박 또는 수정안을 100% 적었다 | ( ) |\n", + "| 취약한 전제에 경고 문구를 명시했다 | ( ) |\n", + "| 반대 방향 위험 3가지를 점검했다 | ( ) |\n", + "\n", + "**\n", + "\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 09. 피드백 반영 방어물자 재보급 우선순위 기준서 수정 및 정합성 검증\n", + "\n", + "### 📋 미션 배경 및 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "피드백을 반영하여 재보급 우선순위 기준서를 수정 검증한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **AAR 피드백을 반영해 재보급 우선순위 기준서를 수정하고, 수정 전후 기준이 같은 사례에서 일관된 판단을 내는지 검증한 뒤 핵심 결심 사항을 1페이지로 요약한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **개선 전후 기준을 비교표에 정리하고 가상 재검증 사례 7건에 개선 전·후 기준을 각각 적용해 판단, 일치 여부, 소요 시간을 기록 수행하고 검증하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 5개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `briefing/log04_commander_1page_brief.md(pdf)` 형태의 결과물을 완성한다.\n", + "\n", + "### 📑 실습자료 및 작성 양식\n", + "피드백 반영 방어물자 재보급 우선순위 기준서 수정 및 정합성 검증**\n", + "\n", + "가. 제공 자료 - 가상 재검증 사례 7건 [교육용가상자료]\n", + "\n", + "| **사례** | **내용** | **개선 전 판단** | **개선 후 판단(조별 작성)** |\n", + "| --- | --- | --- | --- |\n", + "| **V1** | 해솔 AM-1 위험 등급, 보고는 전일 자료(G4) | 승인 | |\n", + "| **V2** | 서림 DP-2 소모 급증, 파손과 정상 소모가 합산됨 | 승인 | |\n", + "| **V3** | 북곡 DP-2 '예방' 명목 요청, 보유일수 8일 | 승인 | |\n", + "| **V4** | 서림 PG-3 경계 등급, 교차 확인 일치 | 보류 | |\n", + "| **V5** | 기술 검토 미완료 대체품 전환 제안 | 승인 | |\n", + "| **V6** | 수송 지연 보고가 단일 출처 | 승인 | |\n", + "| **V7** | 민간 지원 물자 전용 제안 | 승인 | |\n", + "\n", + "나. 작성 서식 1 - 개선 전후 기준 비교표\n", + "\n", + "| **항목** | **개선 전 기준** | **개선 후 기준** | **바뀐 이유** | **근거 미션** |\n", + "| --- | --- | --- | --- | --- |\n", + "| **보고 신뢰도 단계** | | | | |\n", + "| **교차 확인** | | | | |\n", + "| **위험 등급 조건** | | | | |\n", + "| **대체품 전환 조건** | | | | |\n", + "| **민간 물자 점검** | | | | |\n", + "\n", + "나. 작성 서식 2 - 재검증 결과표\n", + "\n", + "| **사례** | **개선 전 판단** | **개선 후 판단** | **일치 여부** | **소요 시간(분)** |\n", + "| --- | --- | --- | --- | --- |\n", + "| **V1** | | | | |\n", + "| **V2** | | | | |\n", + "| **V3** | | | | |\n", + "| **V4** | | | | |\n", + "| **V5** | | | | |\n", + "| **V6** | | | | |\n", + "| **V7** | | | | |\n", + "\n", + "나. 작성 서식 3 - 1페이지 브리핑 BLUF 틀\n", + "\n", + "| **결론(첫 문장)** | |\n", + "| --- | --- |\n", + "\n", + "| **핵심 결심 사항** | |\n", + "| --- | --- |\n", + "\n", + "| **위험 요소(신호등 표시: 녹·황·적)** | |\n", + "| --- | --- |\n", + "\n", + "| **대안 3종(장단점 한 줄)** | |\n", + "| --- | --- |\n", + "\n", + "| **근거(미션·가상 자료)** | |\n", + "| --- | --- |\n", + "\n", + "나. 작성 서식 4 - 브리핑 리허설 기록\n", + "\n", + "| **항목** | **목표** | **결과** |\n", + "| --- | --- | --- |\n", + "| 발표 시간 | 3분 이내 | |\n", + "| 분량 | A4 1페이지 | |\n", + "| 질의 응답 | 3건 이상 답변 | |\n", + "| 결론 먼저 제시 | 첫 문장 | |\n", + "\n", + "다. 점검표\n", + "\n", + "| **점검 항목** | **확인** |\n", + "| --- | --- |\n", + "| 개선 전후 기준의 바뀐 조건과 이유를 모두 적었다 | ( ) |\n", + "| 재검증 7건 모두 개선 전후 판단을 비교했다 | ( ) |\n", + "| 브리핑이 결론부터 시작하고 A4 1페이지에 들어간다 | ( ) |\n", + "| 핵심 결심 사항, 위험 요소, 대안 3종이 모두 있다 | ( ) |\n", + "| 3분 안에 발표하고 질의에 답했다 | ( ) |\n", + "| 기준 변경으로 생긴 판단 차이를 기준서 조항과 연결했다 | ( ) |\n", + "\n", + "**\n", + "\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 10. 핵심 방어물자 재보급 우선순위 결정 기준서 최종 표준안 완성\n", + "\n", + "### 📋 미션 배경 및 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "핵심 방어물자 재보급 우선순위 결정 기준서 최종 표준안을 완성한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **미션 1~9의 산출물을 통합해 핵심 방어물자 재보급 우선순위 결정 기준서 최종 표준안을 완성하고 모의 심의를 거친다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **산출물을 기준서 8개 장(목적과 적용 범위, 용어, 소모율 분석, 우선순위 산정과 자료 신뢰도, 위험 등급과 승인·보류, 배정 원칙과 대체품 전환, 보고·훈련·평가, 개정 관리)과 부록에 배치하고 빠진 내용을 보완 수행하고 검증하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 5개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `manual/log04_standard_operational_manual.md` 형태의 결과물을 완성한다.\n", + "\n", + "### 📑 실습자료 및 작성 양식\n", + "핵심 방어물자 재보급 우선순위 결정 기준서 최종 표준안 완성**\n", + "\n", + "가. 제공 자료 1 - 지침서 8개 장 구성과 가져올 결과물\n", + "\n", + "| **장** | **제목** | **포함 내용** | **가져올 결과물** |\n", + "| --- | --- | --- | --- |\n", + "| **1** | 목적과 적용 범위 | 목적, 적용 상황, 적용하지 않는 상황, 교전수칙과의 관계 | M1 |\n", + "| **2** | 용어 정의 | 소모율, 보유일수, 리드타임 여유, 위험 등급, 보류 | M1~M3 |\n", + "| **3** | 소모율 분석 | 계산 방법, 사례 분석 방법, 자료 공백 | M1·M3 |\n", + "| **4** | 우선순위 산정과 자료 신뢰도 | 산정 요소 척도, G1~G6, 평가 지표, 교차 확인 규칙 | M2·M4 |\n", + "| **5** | 위험 등급과 승인·보류 | 등급 조건, 승인·보류 기준, 의사결정 트리, 책임 직책 | M3·M5 |\n", + "| **6** | 배정 원칙과 대체품 전환 | 배정 원칙, 대체품 전환 지침, 긴급 신속 승인과 사후 감사 | M5 |\n", + "| **7** | 보고·훈련·평가 | 브리핑 양식, 스트레스 훈련, 루브릭, AAR, 레드팀 | M6~M9 |\n", + "| **8** | 개정 관리 | 연간 개정 주기, 의견 수렴 창구, 책임 직책, 이력 | M8·M9 |\n", + "\n", + "가. 제공 자료 2 - 모의 심의 역할 카드\n", + "\n", + "| **역할** | **검토 질문** |\n", + "| --- | --- |\n", + "| **교리 역할** | 산정 요소와 신뢰도 단계가 일관되는가? 신병도 이해할 용어인가? |\n", + "| **법무 역할** | '최종 결정은 사람이, 교전수칙·지휘계통에 따라'가 드러나는가? 민간 지원 물자 쟁점이 법무 검토로 연계되는가? |\n", + "| **작전 역할** | 현장에서 20분 안에 판단할 수 있는가? 점수 계산 때문에 긴급 대응이 늦어지지 않는가? |\n", + "\n", + "나. 작성 서식 1 - 근거 표기 점검표(지침서 조항마다 작성)\n", + "\n", + "| **조항** | **기준 내용 요약** | **근거(미션·가상 자료)** | **표기 여부** |\n", + "| --- | --- | --- | --- |\n", + "| **1-1** | | | |\n", + "| **2-1** | | | |\n", + "| **3-1** | | | |\n", + "| **4-1** | | | |\n", + "| **5-1** | | | |\n", + "| **6-1** | | | |\n", + "| **7-1** | | | |\n", + "| **8-1** | | | |\n", + "\n", + "나. 작성 서식 2 - 모의 심의 지적 사항 반영표\n", + "\n", + "| **번호** | **역할** | **지적 사항** | **반영 내용** | **반영 위치(장·조)** |\n", + "| --- | --- | --- | --- | --- |\n", + "| **1** | | | | |\n", + "| **2** | | | | |\n", + "| **3** | | | | |\n", + "| **4** | | | | |\n", + "| **5** | | | | |\n", + "| **6** | | | | |\n", + "\n", + "나. 작성 서식 3 - 개정 관리: 개정 주기 ( ), 의견 수렴 창구와 방법 ( ), 개정 책임 직책(가상) ( ), 개정 이력 기록 위치 ( )\n", + "\n", + "다. 점검표\n", + "\n", + "| **점검 항목** | **확인** |\n", + "| --- | --- |\n", + "| 8개 장과 부록을 모두 완성했다 | ( ) |\n", + "| 모든 기준 조항에 근거(미션·가상 자료)를 표기했다 | ( ) |\n", + "| 교리·법무·작전 역할의 지적 사항을 반영하고 반영 위치를 적었다 | ( ) |\n", + "| '최종 결정은 사람이 하며 실제 방공 교전 판단은 교전수칙·지휘계통에 따른다'는 문구가 1장과 5장에 있다 | ( ) |\n", + "| 대체품 전환은 기술 검토 후에만, 민간 지원 물자는 법무 검토 후에만 가능하다는 문구가 6장에 있다 | ( ) |\n", + "| 연간 개정 주기와 실무 의견 수렴 창구를 정했다 | ( ) |\n", + "| 실제 부대·재고·제원을 쓰지 않았다 | ( ) |\n", + "\n", + "**4. 평가 채점표**\n", + "\n", + "미션별 평가 기준은 시나리오의 평가 항목과 같다. 평가자는 기준 충족 여부를 PASS/FAIL로 표시하고 FAIL이면 사유를 비고에 적는다.\n", + "\n", + "| **미션** | **구분** | **채점 기준** | **판정** | **비고** |\n", + "| --- | --- | --- | --- | --- |\n", + "| **M1** | 필수 | 소모율 분석·문서 골격: 가상 사례·문헌 5건 이상을 검토했는가, 3개 품목의 소모율과 보유일수를 계산해 오류 없이 표로 정리했는가, 기준서 목차(8개 장) 골격을 100% 정의하고 지휘관 결심 의제 순으로 배열했는가. | PASS / FAIL | |\n", + "| **M2** | 필수 | 산정 기준·신뢰도 6단계: 신뢰도 평가 기준 6단계를 완비하고 평가 지표 10개 이상을 정의했는가, 가상 재고 보고 5종에 대한 조원 간 등급 일치가 4건 이상인가, 단일 출처 의존 경고와 교차 확인 필수 규칙을 명시했는가. | PASS / FAIL | |\n", + "| **M3** | 필수 | 위험 등급·불확실성 평가: 관측 공백(자료 공백) 요인 8개 이상을 식별하고 영향도 매트릭스를 도출했는가, 어긋난 기록 4건에 원인 후보와 확인 방법을 적고 4단계 위험 등급의 승인/보류 처리를 명시했는가, 판단 한계와 대체 확인 수단을 적었는가. | PASS / FAIL | |\n", + "| **M4** | 선택 | 근거 항목·우선순위 산식: 우선순위 판단 근거를 12개 이상 항목화하고 가중치 산식을 100% 정립했는가, 사례 3종의 점수와 분류를 기록했는가, 레드팀 반박에 대응해 점수만으로 단정하지 않는 최소 증거 요건을 설정했는가. | PASS / FAIL | |\n", + "| **M5** | 필수 | 분기점·책임 매핑·모의 승인: 승인/보류 조건 분기점(Threshold)을 100% 명시하고 책임자 직책 매핑을 완료했는가, 경계선 사례 5종 모두에 판단과 근거를 기록했는가, 교전수칙·지휘계통 우선 원칙과 사람의 최종 결정을 지키고 긴급 신속 승인과 사후 감사 규정을 보완했는가. | PASS / FAIL | |\n", + "| **M6** | 선택 | 기록 분류·브리핑 서식: 기록 분류 규칙을 100% 표준화하고 혼동 유발 표현 금지 가이드를 6개 이상 포함했는가, 가상 현황 메모 3종에서 사실과 판단의 혼동이 0건인가, 요약형·상세형 듀얼 템플릿을 구성했는가. | PASS / FAIL | |\n", + "| **M7** | 필수 | 스트레스 훈련·루브릭: 평가 영역 5개와 등급 4단계(탁월·우수·보통·미흡) 기준 정의율 100%인가, 카드 6장 모두에 재산정 기록이 있고 카드당 재산정 10분 이내인가, 평가자 2명 채점 일치율 80% 이상인가, 판단이 흔들린 지점을 기록했는가. | PASS / FAIL | |\n", + "| **M8** | 선택 | 원인 규명·기준 보정: 도출된 비판적 쟁점 5건 이상에 대해 반박 및 수정 대안을 100% 수립했는가, 취약한 전제(Assumptions)에 경고 문구를 명시했는가, 반대 방향 위험을 점검하고 기준 보정 계획에 반영했는가. | PASS / FAIL | |\n", + "| **M9** | 필수 | 기준서 수정·1페이지 브리핑: 핵심 결심 사항, 위험 요소, 대안 3종이 1페이지(A4) 안에 완결되는가, 재검증 7건의 개선 전후 비교와 정합성 검증을 마쳤는가, BLUF 준수(결론 먼저, 3분 이내 발표)와 신호등 표시가 있는가. | PASS / FAIL | |\n", + "| **M10** | 필수 | 표준 지침서 통합 완성: 표준 지침서 총 8개 장 및 부록을 100% 완성하고 모든 기준 조항에 근거(미션·가상 자료) 출처를 표기했는가, 모의 심의 지적 사항을 반영하고 연간 개정 주기와 실무 의견 수렴 창구를 두었는가, '최종 결정은 사람이 하며 실제 교전 판단은 교전수칙·지휘계통에 따른다'는 문구를 명시했는가. | PASS / FAIL | |\n", + "\n", + "| **판정 기준** | 필수 7개 미션(M1·M2·M3·M5·M7·M9·M10)이 모두 PASS여야 최종 PASS로 인정한다. 선택 미션(M4·M6·M8)은 가점·보완 의견에 반영한다. |\n", + "| --- | --- |\n", + "\n", + "| **공통 감점** | 실제 부대명·재고·제원·실존 인물 정보를 사용한 경우, 부대 자료를 외부 AI 서비스에 입력한 경우 해당 미션은 FAIL로 처리한다. |\n", + "| --- | --- |\n", + "\n", + "| **주제 안전 기준** | 단일 보고만으로 긴급 재보급을 승인하는 판단이 있거나 기준서에 사람의 최종 결정·교전수칙 우선 문구가 없으면 M5·M10은 FAIL로 처리한다. |\n", + "| --- | --- |\n", + "\n", + "| **최종 판정 결과** | ( PASS / FAIL ) 평가자 서명: ( ) |\n", + "| --- | --- |\n", + "\n", + "| 안전 원칙: 본 자료의 등급 기준, 승인 권한 구분, 의사결정 트리는 학습용 가상 기준이다. 재보급 우선순위의 최종 결정은 사람(지휘관·승인권자)이 내리며 실제 방공 교전·방호 판단은 교전수칙과 지휘계통에 따른다. 실제 부대 자료를 입력하거나 부대 내에서 촬영하지 않는다. |\n", + "| --- |\n", + "\n" + ] + } + ], + "metadata": { + "colab": { + "provenance": [] + }, + "kernelspec": { + "display_name": "Python 3", + "name": "python3" + }, + "language_info": { + "name": "python" + } + }, + "nbformat": 4, + "nbformat_minor": 0 +} \ No newline at end of file diff --git a/uploads/LOG05/LOG05_workbook.ipynb b/uploads/LOG05/LOG05_workbook.ipynb new file mode 100644 index 0000000..93b7d32 --- /dev/null +++ b/uploads/LOG05/LOG05_workbook.ipynb @@ -0,0 +1,992 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# 📋 [LOG05] 무인 보급장비 투입 전 상태·책임·중지조건 체크리스트\n", + "### 실습 워크북 & SOP 가이드 (Google Colab / Jupyter)\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/LOG05/LOG05_workbook.ipynb)\n", + "\n", + "---\n", + "\n", + "## 📌 1. 프로젝트 개요 및 배경\n", + "\n", + "| 항목 | 내용 |\n", + "| :--- | :--- |\n", + "| **과정 분류** | 군수·보급 (Level 2) |\n", + "| **학습 소요 시간** | 10개 미션 (5일 / 총 27.0시간) |\n", + "| **실습 유형** | 작전절차(SOP), 체크리스트, 모의훈련, AAR 사후검토 실습 |\n", + "\n", + "### 📖 프로젝트 목표\n", + "러시아-우크라이나 전쟁과 미국-이란 간 긴장 국면에서는 수송 드론과 무인 지상차량 같은 무인 보급장비가 위험 구간의 보급 부담을 줄이는 수단으로 쓰였지만, 배터리 부족, 통신 링크 저하, 적재 초과, 기상 급변 같은 이유로 임무가 실패하거나 장비를 잃는 사례가 적지 않았다. 투입 전에 무엇을 확인하고 누가 승인하며 어떤 조건에서 중지하는지가 문서로 정해져 있지 않으면, 사고 뒤에도 책임이 누구에게 있는지 가릴 수 없다. 본 프로젝트에서는 교육용 가상 부대와 가상 무인 보급장비(수송 드론 UD-1, 무인 지상차량 UV-2), 가상 보급로를 사용해 운용 환경과 위험 요인 분석, 투입 전 점검 체크리스트와 점검 결과 신뢰도 6단계, 중지·복귀 조건, 승인권자와 원격 조종수의 책임 한계, 점검·승인 실습, 관제·긴급 중지 지침서 서식을 차례로 만들고, 통신 두절·기상 급변 스트레스 훈련, AAR을 통한 중지 조건 민감도 재설계, 1페이지 브리핑을 거친다. 최종 목표는 상태·책임·중지조건이 분명한 무인 보급장비 투입 전 표준 체크리스트(표준 지침서)를 문서로 완성하는 것이다.\n", + "\n", + "> **⚠️ [교육용 가상 자료 원칙]**\n", + "> - 본 워크북의 모든 자료는 안전 규정에 따라 **교육용 가상 자료**로 구성되어 있습니다.\n", + "> - 실제 군사 기밀이나 부대 세부 인적·작전 정보는 포함되지 않습니다.\n", + "\n", + "---\n", + "\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 01. 무인 보급장비(수송 드론, 무인 자율주행 차량) 운용 환경 및 사고 위험 요인 분석\n", + "\n", + "### 📋 미션 배경 및 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "무인 보급장비(수송 드론, 무인 차량) 운용 환경 및 위험 요인을 분석한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **수송 드론과 무인 지상차량이 운용되는 가상 환경의 특성과 사고 위험 요인을 분류하고, 체크리스트 문서의 전체 골격을 정의한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **실습자료의 가상 운용 환경표(구간별 지형, 통신 음영, 기상)와 가상 사고·위험 사례 5건을 읽고 위험 요인을 장비, 통신, 적재, 기상, 사람, 절차 6개 분류에 나누어 적는다 과정을 진행하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 5개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `docs/log05_doc_framework.md 및 분석 개요서` 형태의 결과물을 완성한다.\n", + "\n", + "### 📑 실습자료 및 작성 양식\n", + "무인 보급장비(수송 드론, 무인 자율주행 차량) 운용 환경 및 사고 위험 요인 분석**\n", + "\n", + "가. 제공 자료 1 - 가상 운용 환경표 [교육용가상자료]\n", + "\n", + "| **구간** | **지형** | **통신** | **기상 특성** | **주로 운용하는 장비** |\n", + "| --- | --- | --- | --- | --- |\n", + "| **A** | 평지, 포장 도로 인접 | 링크 품질 안정 | 대체로 양호 | UV-2 |\n", + "| **B** | 숲 인접, 굴곡 | 통신 음영 가능(링크 일시 두절) | 안개 잦음 | UV-2, UD-1 |\n", + "| **C** | 개활지 | 링크 품질 안정, 거리가 멀면 저하 | 강풍 가능 | UD-1 |\n", + "\n", + "가. 제공 자료 2 - 가상 사고·위험 사례 5건 [교육용가상자료]\n", + "\n", + "| **사례** | **상황 요약** | **사고 전 점검·승인** | **눈에 띄는 한계** |\n", + "| --- | --- | --- | --- |\n", + "| **C1** | UD-1이 배터리 잔량 40%로 출발했고 이륙 직후 급감해 도착 전 강제 착륙 | 잔량 표시만 확인 | 부하 시 전압 이상 확인 항목 없음 |\n", + "| **C2** | UV-2가 B 구간에서 링크가 18초 두절된 뒤 경로를 이탈 | 경로 사전 입력 확인 | 두절 시 정지 규칙과 음영 구간 고지가 없음 |\n", + "| **C3** | UD-1이 적재 한도 8단위를 넘겨 9단위로 출발해 이륙이 불안정 | 적재 담당 자기 신고 | 저울 기록 없음 |\n", + "| **C4** | C 구간 풍속 등급 3 예보에도 출발 승인, 중간 회항 | 예보 확인 여부 기재 없음 | 풍속 기준이 문서에 없음 |\n", + "| **C5** | 승인권자가 점검표 서명 없이 구두 승인한 임무에서 사고 발생 | 구두 승인 | 책임 소재를 문서로 가릴 수 없음 |\n", + "\n", + "나. 작성 서식 1 - 위험 요인 분류표(6개 분류)\n", + "\n", + "| **분류** | **위험 요인(사례에서 찾은 것)** | **사고로 이어지는 경로** | **투입 전 확인할 것** |\n", + "| --- | --- | --- | --- |\n", + "| **장비** | | | |\n", + "| **통신** | | | |\n", + "| **적재** | | | |\n", + "| **기상** | | | |\n", + "| **사람** | | | |\n", + "| **절차** | | | |\n", + "\n", + "나. 작성 서식 2 - 사례별 빠진 점검·승인\n", + "\n", + "| **사례** | **있었던 점검·승인** | **빠진 점검·승인** | **추가로 확인할 것** |\n", + "| --- | --- | --- | --- |\n", + "| **C1** | | | |\n", + "| **C2** | | | |\n", + "| **C3** | | | |\n", + "| **C4** | | | |\n", + "| **C5** | | | |\n", + "\n", + "나. 작성 서식 3 - 지침서 목차 골격(지휘관 결심 의제 순으로 배열)\n", + "\n", + "| **순서** | **장 제목** | **담을 핵심 내용** | **지휘관이 이 장에서 얻는 판단 정보** |\n", + "| --- | --- | --- | --- |\n", + "| **제1장** | | | |\n", + "| **제2장** | | | |\n", + "| **제3장** | | | |\n", + "| **제4장** | | | |\n", + "| **제5장** | | | |\n", + "| **제6장** | | | |\n", + "| **제7장** | | | |\n", + "| **제8장** | | | |\n", + "\n", + "다. 점검표\n", + "\n", + "| **점검 항목** | **확인** |\n", + "| --- | --- |\n", + "| 가상 사례 5건을 모두 검토했다 | ( ) |\n", + "| 위험 요인을 장비·통신·적재·기상·사람·절차 6개 분류에 빠짐없이 나누었다 | ( ) |\n", + "| 사례마다 빠진 점검 또는 승인을 하나 이상 찾았다 | ( ) |\n", + "| 지침서 목차 8개 장의 제목과 핵심 내용을 정의했다 | ( ) |\n", + "| 목차를 지휘관이 먼저 알아야 할 의제 순으로 배열하고 이유를 한 줄로 적었다 | ( ) |\n", + "\n", + "**\n", + "\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 02. 투입 전 점검(배터리/유류, 통신 링크, 적재 중량, 기상) 체크리스트 작성\n", + "\n", + "### 📋 미션 배경 및 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "투입 전 점검(배터리, 통신 링크, 적재 중량, 기상) 체크리스트를 작성한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **투입 전 점검 항목(배터리/유류, 통신 링크, 적재 중량, 기상)을 합격 기준과 함께 체크리스트로 만들고, 점검 결과를 얼마나 믿을 수 있는지 6단계로 구분한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **네 가지 점검 영역마다 점검 항목, 합격 기준(관찰 가능한 수치나 문장), 확인 방법, 확인자, 불합격 시 처리를 적어 평가 지표 10개 이상의 점검 항목표 작성하여 원인을 분석하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 5개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `framework/log05_source_reliability_matrix.md` 형태의 결과물을 완성한다.\n", + "\n", + "### 📑 실습자료 및 작성 양식\n", + "투입 전 점검(배터리/유류, 통신 링크, 적재 중량, 기상) 체크리스트 작성**\n", + "\n", + "가. 제공 자료 1 - 가상 점검 보고 5종 [교육용가상자료]\n", + "\n", + "| **보고** | **장비·영역** | **보고 내용** | **확인 방법** | **확인 상태** |\n", + "| --- | --- | --- | --- | --- |\n", + "| **R1** | UD-1 배터리 | 잔량 82%, 부하 시험 전압 정상 | 계측기 값 기록 + 정비 담당 재확인 서명 | 계측값과 서명 모두 있음 |\n", + "| **R2** | UV-2 링크 | 품질 4등급 | 시험 송수신 1회 로그 | 로그 있으나 1회뿐 |\n", + "| **R3** | UD-1 적재 | 적재 7단위 | 적재 담당 자기 신고 | 저울 기록 없음 |\n", + "| **R4** | C 구간 기상 | 괜찮음 | 근거 없음 | 풍속·시정 값 없음 |\n", + "| **R5** | UV-2 경로 | 최신 상태 | 전날 점검표 인용 | 하루 전 자료, 변경 여부 미확인 |\n", + "\n", + "가. 제공 자료 2 - 점검 결과 확인 신뢰도 6단계 틀. 단계 이름은 고정하고 판정 기준을 조별로 작성한다.\n", + "\n", + "| **단계** | **이름** | **판정 기준(조별 작성)** |\n", + "| --- | --- | --- |\n", + "| **G1** | 계측값·이중 확인 | |\n", + "| **G2** | 계측값 기록 | |\n", + "| **G3** | 시험·로그 확인 | |\n", + "| **G4** | 전일 자료·인용 | |\n", + "| **G5** | 자기 신고·구두 | |\n", + "| **G6** | 근거 없음 | |\n", + "\n", + "나. 작성 서식 1 - 투입 전 점검 항목표(10개 이상, 4개 영역 모두 포함, 예시 2개 포함)\n", + "\n", + "| **번호** | **영역** | **점검 항목** | **합격 기준** | **확인 방법** | **확인자(가상)** | **불합격 시** |\n", + "| --- | --- | --- | --- | --- | --- | --- |\n", + "| **1** | 배터리 | 부하 시험 후 잔량 | 출발 시 90% 이상, 예비 전력 포함 왕복 가능 | 계측기 기록 + 이중 확인 | 정비 담당 | 보류, 충전 후 재점검 |\n", + "| **2** | 통신 | 링크 품질 등급 | 3등급 이상 + 시험 송수신 3회 성공 | 통신 로그 | 통신 담당 | 보류, 경로 변경 검토 |\n", + "| **3** | | | | | | |\n", + "| **4** | | | | | | |\n", + "| **5** | | | | | | |\n", + "| **6** | | | | | | |\n", + "| **7** | | | | | | |\n", + "| **8** | | | | | | |\n", + "| **9** | | | | | | |\n", + "| **10** | | | | | | |\n", + "| **11** | | | | | | |\n", + "| **12** | | | | | | |\n", + "\n", + "나. 작성 서식 2 - 등급 부여 일관성 시험(조원이 각자 G1~G6을 매기고 비교)\n", + "\n", + "| **보고** | **조원 1** | **조원 2** | **조원 3** | **조원 4** | **일치 여부** | **불일치 원인** |\n", + "| --- | --- | --- | --- | --- | --- | --- |\n", + "| **R1** | | | | | | |\n", + "| **R2** | | | | | | |\n", + "| **R3** | | | | | | |\n", + "| **R4** | | | | | | |\n", + "| **R5** | | | | | | |\n", + "\n", + "나. 작성 서식 3 - 교차 확인 필수 규칙: 점검자 자기 신고만으로는 G ( ) 이하로 기록하고 투입 승인은 서로 다른 확인자 ( )명 이상의 확인이 있을 때만 검토한다. 점검 후 ( )분이 지난 결과는 ( )로 처리하고 재점검한다.\n", + "\n", + "다. 점검표\n", + "\n", + "| **점검 항목** | **확인** |\n", + "| --- | --- |\n", + "| 4개 점검 영역(배터리/유류, 통신, 적재, 기상)을 모두 포함했다 | ( ) |\n", + "| 점검 항목을 10개 이상 정의하고 합격 기준을 수치 또는 관찰 가능한 문장으로 적었다 | ( ) |\n", + "| 신뢰도 G1~G6의 판정 기준을 모두 작성했다 | ( ) |\n", + "| 가상 점검 보고 5종에 대한 조원 등급이 4건 이상 일치했다(불일치 건은 원인과 수정 내용을 적었다) | ( ) |\n", + "| 자기 신고 단독 의존 경고와 교차 확인 필수 규칙을 명시했다 | ( ) |\n", + "\n", + "**\n", + "\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 03. 장비 고장, 경로 이탈, 적 공격 시 임무 중지(Abort) 및 자폭/복귀 조건 정의\n", + "\n", + "### 📋 미션 배경 및 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "장비 고장, 경로 이탈 시 임무 중지(Abort) 및 복귀 조건을 정의한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **장비 고장, 경로 이탈, 위협 경보 시 임무를 계속·중지·복귀·원격 회수 요청 중 무엇으로 처리할지 조건을 정의하고, 판단을 흔드는 정보 공백을 정리한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **임무 중 상태 변화(배터리 급감, 링크 품질 저하·두절, 경로 이탈 경보, 기상 악화, 위협 경보)별로 처리 단계(계속, 감속·경로 유지, 제자리 안전 정지, 복귀, 원격 회수 요청, 임무 취소)를 정하고 조건을 수치나 관찰 가능한 문장으로 적는다 과정을 진행하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 5개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `analysis/log05_uncertainty_assessment.md` 형태의 결과물을 완성한다.\n", + "\n", + "### 📑 실습자료 및 작성 양식\n", + "장비 고장, 경로 이탈, 적 공격 시 임무 중지(Abort) 및 자폭/복귀 조건 정의**\n", + "\n", + "가. 제공 자료 1 - 어긋난 가상 운용 기록 쌍 4건 [교육용가상자료]\n", + "\n", + "| **쌍** | **기록 A** | **기록 B** | **어긋난 지점** |\n", + "| --- | --- | --- | --- |\n", + "| **K1** | 관제 로그: UV-2 링크 품질 4등급(10:05) | 조종수 보고: 링크 끊김 잦음(10:03~10:07) | 시점과 판단 기준 차이 |\n", + "| **K2** | UD-1 배터리 잔량 31%(기체 표시) | UD-1 배터리 잔량 38%(관제 화면) | 센서와 갱신 지연 |\n", + "| **K3** | UV-2 위치 B-3(관제 화면 10:12) | UV-2 위치 B-4(조종수 구두 10:12) | 위치 갱신 주기 차이 |\n", + "| **K4** | 기상 담당: 풍속 등급 2 | 조종수 체감: 바람 강함 | 측정 지점과 시각 차이 |\n", + "\n", + "가. 제공 자료 2 - 처리 단계 틀(이름은 고정, 조건은 조별 작성)\n", + "\n", + "| **처리 단계** | **정의** | **누가 결정하는가** |\n", + "| --- | --- | --- |\n", + "| **계속** | | |\n", + "| **감속·경로 유지** | | |\n", + "| **제자리 안전 정지** | | |\n", + "| **복귀** | | |\n", + "| **원격 회수 요청** | | |\n", + "| **임무 취소** | | |\n", + "\n", + "나. 작성 서식 1 - 임무 중지·복귀 조건표\n", + "\n", + "| **상태 변화** | **조건(수치 또는 관찰 가능한 문장)** | **처리 단계** | **관제 책임자 확인 사항** | **기록할 것** |\n", + "| --- | --- | --- | --- | --- |\n", + "| **배터리 급감** | | | | |\n", + "| **링크 품질 저하** | | | | |\n", + "| **링크 두절** | | | | |\n", + "| **경로 이탈 경보** | | | | |\n", + "| **기상 악화** | | | | |\n", + "| **위협 경보** | | | | |\n", + "| **장비 고장 경보** | | | | |\n", + "\n", + "나. 작성 서식 2 - 어긋난 기록 분류와 원인 후보\n", + "\n", + "| **쌍** | **분류(시점·센서·집계·보고자)** | **원인 후보** | **확인 방법** | **책임 직책** |\n", + "| --- | --- | --- | --- | --- |\n", + "| **K1** | | | | |\n", + "| **K2** | | | | |\n", + "| **K3** | | | | |\n", + "| **K4** | | | | |\n", + "\n", + "나. 작성 서식 3 - 정보 공백 요인과 불확실성 영향도(8개 이상)\n", + "\n", + "| **번호** | **공백 요인** | **영향도(높음·중간·낮음)** | **대체 확인 수단** |\n", + "| --- | --- | --- | --- |\n", + "| **1** | | | |\n", + "| **2** | | | |\n", + "| **3** | | | |\n", + "| **4** | | | |\n", + "| **5** | | | |\n", + "| **6** | | | |\n", + "| **7** | | | |\n", + "| **8** | | | |\n", + "| **9** | | | |\n", + "\n", + "나. 작성 서식 4 - 장비 파괴·무력화 조치 쟁점: 이 지침서는 ( ) 조치를 정의하지 않으며 관련 쟁점이 나오면 ( ), ( ), ( ) 검토로 보낸다. 확신 표현 규칙: '이상 없음' 대신 '( ) 시각 기준 ( )등급, 확인 단계 G( ), 확인되지 않은 것은 ( )'로 쓴다.\n", + "\n", + "다. 점검표\n", + "\n", + "| **점검 항목** | **확인** |\n", + "| --- | --- |\n", + "| 7개 상태 변화에 처리 단계와 조건을 적었다(빈칸 0개) | ( ) |\n", + "| 어긋난 기록 4건에 원인 후보와 확인 방법을 적었다 | ( ) |\n", + "| 정보 공백 요인을 8개 이상 찾고 영향도와 대체 확인 수단을 적었다 | ( ) |\n", + "| 장비 파괴·무력화 조치를 정의하지 않고 지휘관·기술·법무 검토로 연계했다 | ( ) |\n", + "| 판단의 한계를 함께 쓰는 표현 규칙을 정했다 | ( ) |\n", + "\n", + "**\n", + "\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 04. 무인 보급 임무 승인권자(군수참모) 및 원격 조종수 책임 한계 명시\n", + "\n", + "### 📋 미션 배경 및 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "무인 보급 임무 승인권자 및 원격 조종수 책임 한계를 명시한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **무인 보급 임무에서 승인권자(군수참모)와 원격 조종수 등 관련 직책의 책임 한계를 정하고, 사고 후 책임 소재를 근거 항목과 가중치로 정리하는 기준을 만든다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **승인권자, 원격 조종수, 정비·적재 담당, 통신 담당 직책별로 승인할 수 있는 것, 할 수 없는 것, 중지 권한, 기록할 문서를 적는다 과정을 진행하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 5개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `matrix/log05_attribution_matrix.xlsx(md)` 형태의 결과물을 완성한다.\n", + "\n", + "### 📑 실습자료 및 작성 양식\n", + "무인 보급 임무 승인권자(군수참모) 및 원격 조종수 책임 한계 명시**\n", + "\n", + "가. 제공 자료 1 - 직책별 책임 한계표 틀(조별 작성)\n", + "\n", + "| **직책(가상)** | **승인할 수 있는 것** | **승인할 수 없는 것** | **중지 권한** | **기록할 문서** |\n", + "| --- | --- | --- | --- | --- |\n", + "| **승인권자(군수참모)** | | | | |\n", + "| **원격 조종수** | | | | |\n", + "| **정비·적재 담당** | | | | |\n", + "| **통신 담당** | | | | |\n", + "\n", + "가. 제공 자료 2 - 가상 사고 3종과 사고 전 기록 [교육용가상자료]\n", + "\n", + "| **사례** | **사고 내용** | **사고 전 기록** |\n", + "| --- | --- | --- |\n", + "| **S1** | UD-1 배터리 급감으로 강제 착륙 | 점검표에 잔량만 기재, 승인권자 서명 있음, 경보 후 조종수 중지 판단까지 3분 지연 |\n", + "| **S2** | UV-2 B 구간 링크 두절 후 경로 이탈 | 통신 담당이 음영 구간을 고지하지 않음, 두절 시 정지 규칙이 문서에 없음, 승인권자 서명 있음 |\n", + "| **S3** | UD-1 적재 초과 9단위로 출발 | 적재 담당 자기 신고, 저울 기록 없음, 승인권자가 점검표 서명 전에 구두 승인 |\n", + "\n", + "가. 제공 자료 3 - 근거 항목 예시 4개(나머지는 조별로 12개 이상으로 늘린다)\n", + "\n", + "| **근거 항목** | **관련 직책** | **가중치 예시** |\n", + "| --- | --- | --- |\n", + "| **점검표에 해당 항목이 없었음(절차 미비)** | 절차 미비 | +3 |\n", + "| **승인 시 점검 결과 신뢰도가 G4 이하였음** | 승인권자 | +3 |\n", + "| **중지 조건을 사전에 고지받지 못했음** | 원격 조종수(감점 요인) | -3 |\n", + "| **경보 후 중지까지 지연 시간이 기준을 넘음** | 원격 조종수 | +2 |\n", + "\n", + "나. 작성 서식 1 - 근거 항목 목록(12개 이상)\n", + "\n", + "| **번호** | **근거 항목** | **관련 직책(승인권자·조종수·정비적재·통신·절차 미비)** | **가중치** | **확인 방법** |\n", + "| --- | --- | --- | --- | --- |\n", + "| **1** | | | | |\n", + "| **2** | | | | |\n", + "| **3** | | | | |\n", + "| **4** | | | | |\n", + "| **5** | | | | |\n", + "| **6** | | | | |\n", + "| **7** | | | | |\n", + "| **8** | | | | |\n", + "| **9** | | | | |\n", + "| **10** | | | | |\n", + "| **11** | | | | |\n", + "| **12** | | | | |\n", + "\n", + "나. 작성 서식 2 - 산식과 규칙: 직책별 근거 점수 = 해당 직책에 연결된 근거 가중치의 합이다. 규칙 1 가장 높은 점수와 두 번째 점수의 차이가 ( )점 미만이면 ( ), 규칙 2 확인 불가 근거가 ( )개 이상이면 ( ), 규칙 3 절차 미비 점수가 높으면 ( )를 우선 수정한다, 규칙 4 점수는 징계나 법적 책임 판단이 아니며 ( )와 ( )가 판단한다.\n", + "\n", + "나. 작성 서식 3 - 사례 3종 적용\n", + "\n", + "| **사례** | **승인권자** | **원격 조종수** | **정비·적재** | **통신** | **절차 미비** | **정리(단정 보류·추가 조사·체크리스트 보완)** |\n", + "| --- | --- | --- | --- | --- | --- | --- |\n", + "| **S1** | | | | | | |\n", + "| **S2** | | | | | | |\n", + "| **S3** | | | | | | |\n", + "\n", + "나. 작성 서식 4 - 레드팀 반박 기록\n", + "\n", + "| **번호** | **레드팀 반박(예: 이 점수는 승인권자에게 과도하게 책임을 몰아준다)** | **작성 팀 대응** | **보완한 최소 증거 요건** |\n", + "| --- | --- | --- | --- |\n", + "| **1** | | | |\n", + "| **2** | | | |\n", + "| **3** | | | |\n", + "\n", + "다. 점검표\n", + "\n", + "| **점검 항목** | **확인** |\n", + "| --- | --- |\n", + "| 4개 직책의 승인·중지 권한과 한계를 모두 적었다 | ( ) |\n", + "| 책임 판단 근거를 12개 이상 항목화하고 가중치를 적었다 | ( ) |\n", + "| 산식과 규칙 1~4의 빈칸을 모두 채웠다 | ( ) |\n", + "| 사례 3종을 적용하되 단일 증거로 개인을 단정하지 않고 징계·법적 판단은 조사·감사·법무로 연계했다 | ( ) |\n", + "| 레드팀 반박 3건에 대응하고 최소 증거 요건을 보완했다 | ( ) |\n", + "\n", + "**\n", + "\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 05. 가상 보급로 투입 시나리오 기반 무인 장비 상태 점검 및 승인 실습\n", + "\n", + "### 📋 미션 배경 및 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "가상 보급로 투입 시나리오 기반 무인 장비 상태 점검 및 승인을 실습한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **가상 보급로 투입 시나리오에서 무인 장비의 점검 결과를 보고 투입 승인·보류·중지를 판단하는 분기점과 책임 직책을 확정하고 실습한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **M2~M4의 기준을 점검 합격 여부, 점검 결과 신뢰도, 중지 조건 사전 확인, 승인 권한자 순의 의사결정 트리로 묶고 분기점마다 조건과 책임 직책(가상)을 적는다 과정을 진행하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 5개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `sop/log05_approval_thresholds_sop.md` 형태의 결과물을 완성한다.\n", + "\n", + "### 📑 실습자료 및 작성 양식\n", + "가상 보급로 투입 시나리오 기반 무인 장비 상태 점검 및 승인 실습**\n", + "\n", + "가. 제공 자료 1 - 가상 보급 임무 3건의 점검 결과 [교육용가상자료]\n", + "\n", + "| **임무** | **장비·구간** | **배터리** | **링크 품질** | **적재** | **기상** | **서명** |\n", + "| --- | --- | --- | --- | --- | --- | --- |\n", + "| **MA** | UD-1, C 구간 | 88%(G2) | 4등급(G3) | 7단위(저울 기록) | 풍속 등급 2(G2) | 점검자·승인권자 서명 |\n", + "| **MB** | UV-2, B 구간 | 75%(G2) | 3등급(G3), 음영 가능 | 32단위(저울 기록) | 시정 등급 3(G3) | 점검자 서명만 |\n", + "| **MC** | UD-1, C 구간 | 91%(G1) | 5등급(G2) | 8단위(저울 기록) | 풍속 등급 3 예보(G4) | 점검자·승인권자 서명 |\n", + "\n", + "가. 제공 자료 2 - 경계선 사례 5종 [교육용가상자료]\n", + "\n", + "| **묶음** | **내용** | **애매한 이유** |\n", + "| --- | --- | --- |\n", + "| **B1** | UD-1 배터리 출발 잔량이 합격선에서 1% 모자람 | 합격선 근처 |\n", + "| **B2** | UV-2 링크 품질 합격이나 점검 로그가 1회뿐(G3) | 신뢰도 근거 약함 |\n", + "| **B3** | 적재 한도 정확히 8단위, 저울 기록 없음(자기 신고) | 한도 근처이며 근거 없음 |\n", + "| **B4** | 풍속 예보와 현장 측정값이 서로 다름 | 기상 정보 상충 |\n", + "| **B5** | 점검표 서명은 있으나 승인권자가 부재해 대리인이 승인하겠다고 함 | 위임 범위 불분명 |\n", + "\n", + "나. 작성 서식 1 - 의사결정 트리(조건과 분기를 채운다)\n", + "\n", + "| **단계** | **확인 질문** | **예일 때** | **아니오일 때** |\n", + "| --- | --- | --- | --- |\n", + "| **1** | 4개 영역 점검이 모두 합격 기준을 충족하는가 | | |\n", + "| **2** | 점검 결과 신뢰도가 G3 이상이고 교차 확인이 있는가 | | |\n", + "| **3** | 경로 구간의 중지 조건을 조종수가 사전 확인했는가 | | |\n", + "| **4** | 승인권자(또는 정식 위임자)의 서명이 있는가 | | |\n", + "| **5** | 임무 중 사람이 중지할 수 있는 수단과 통신이 확보되었는가 | | |\n", + "\n", + "나. 작성 서식 2 - 분기점 조건과 책임 직책 매핑표\n", + "\n", + "| **분기점** | **조건(수치 또는 관찰 가능한 문장)** | **책임 직책(가상)** | **기록할 문서** |\n", + "| --- | --- | --- | --- |\n", + "| **T1** | | | |\n", + "| **T2** | | | |\n", + "| **T3** | | | |\n", + "| **T4** | | | |\n", + "\n", + "나. 작성 서식 3 - 모의 승인 기록지(임무 MA~MC와 경계선 B1~B5)\n", + "\n", + "| **대상** | **판단(승인·보류 유지·추가 확인 요청)** | **근거** | **책임 직책** | **조원 합의(예·아니오)** |\n", + "| --- | --- | --- | --- | --- |\n", + "| **MA** | | | | |\n", + "| **MB** | | | | |\n", + "| **MC** | | | | |\n", + "| **B1** | | | | |\n", + "| **B2** | | | | |\n", + "| **B3** | | | | |\n", + "| **B4** | | | | |\n", + "| **B5** | | | | |\n", + "\n", + "나. 작성 서식 4 - 긴급 신속 승인 규칙: 신속 승인에서도 줄이지 않는 점검은 ( ), 사후 감사 항목은 ( ), 사후 감사 기한은 ( )이다. 중지 수단이 확보되지 않은 임무는 신속 승인 대상에서 ( ).\n", + "\n", + "다. 점검표\n", + "\n", + "| **점검 항목** | **확인** |\n", + "| --- | --- |\n", + "| 분기점 4개의 조건을 수치 또는 관찰 가능한 문장으로 적었다 | ( ) |\n", + "| 분기점마다 책임 직책(가상)과 기록 문서를 연결했다 | ( ) |\n", + "| 임무 3건과 경계선 사례 5종 모두에 판단과 근거를 기록했다 | ( ) |\n", + "| 장비 자체 판단이나 점수만으로 승인되는 경로가 없고 사람의 승인이 필수다 | ( ) |\n", + "| 교전수칙·지휘계통 우선 문구가 있다 | ( ) |\n", + "\n", + "**\n", + "\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 06. 지휘소용 무인 보급장비 실시간 관제 및 긴급 중지 지침서 서식 개발\n", + "\n", + "### 📋 미션 배경 및 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "지휘소용 무인 보급 실시간 관제 및 중지 지침서를 개발한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **지휘소에서 무인 보급장비를 실시간으로 관제하고 긴급 중지 명령을 내릴 때 쓰는 관제 상황판과 중지 지침서 서식을 개발한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **임무 번호, 장비, 구간, 배터리, 링크 등급, 상태 등급(녹·황·적), 조치를 한 줄로 보는 관제 상황판 서식 설계하고 워크플로우를 구성하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 5개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `templates/log05_aar_fact_action_template.md` 형태의 결과물을 완성한다.\n", + "\n", + "### 📑 실습자료 및 작성 양식\n", + "지휘소용 무인 보급장비 실시간 관제 및 긴급 중지 지침서 서식 개발**\n", + "\n", + "가. 제공 자료 1 - 혼동 유발 표현 예시(나머지는 조별로 6개 이상으로 늘린다)\n", + "\n", + "| **혼동 유발 표현** | **왜 혼동되는가** | **대체 표현 예** |\n", + "| --- | --- | --- |\n", + "| **정상입니다** | 무엇이 정상인지 보이지 않음 | 배터리 (%), 링크 (등급), 구간 ( ), 확인 단계 G( ) |\n", + "| **정지하라** | 제자리 안전 정지인지 임무 중지인지 불명 | 제자리 안전 정지(또는 복귀, 원격 회수 요청)를 명시 |\n", + "| **곧 복귀합니다** | 복귀 시각과 조건이 없음 | 복귀 개시 (시각), 예상 도착 (시각) |\n", + "\n", + "가. 제공 자료 2 - 가상 관제 메모 3종(사실·판단·조치·미확인을 구분해 표시한다)\n", + "\n", + "| **메모** | **내용** |\n", + "| --- | --- |\n", + "| **M-1** | UV-2 링크가 B 구간에서 3등급으로 떨어졌다. 곧 두절될 것 같다. 속도를 줄이라고 지시했다. 두절 시 정지 규칙을 조종수가 알고 있는지는 모른다. |\n", + "| **M-2** | UD-1 배터리가 31%로 표시된다. 복귀하면 충분하다고 판단한다. 복귀를 지시했다. 관제 화면 값과 기체 표시가 다를 수 있다. |\n", + "| **M-3** | C 구간 풍속이 3등급이라는 보고가 왔다. 임무를 계속해도 된다고 본다. 다음 보고를 5분 뒤로 정했다. 측정 시각은 확인되지 않았다. |\n", + "\n", + "나. 작성 서식 1 - 관제 상황판(한 줄 한 임무)\n", + "\n", + "| **임무** | **장비** | **구간** | **배터리** | **링크 등급** | **상태(녹·황·적)** | **조치** |\n", + "| --- | --- | --- | --- | --- | --- | --- |\n", + "| | | | | | | |\n", + "| | | | | | | |\n", + "| | | | | | | |\n", + "| | | | | | | |\n", + "\n", + "나. 작성 서식 2 - 긴급 중지 명령 전달 순서(빈칸을 채운다): 1 관제 책임자가 ( ) 용어로 지시한다. 2 원격 조종수가 ( )하고 시각을 말한다. 3 기록자가 ( )에 기록한다. 4 ( ) 확인이 없으면 ( )초 후 다시 지시한다. 5 승인권자에게 ( ) 이내 보고한다.\n", + "\n", + "나. 작성 서식 3 - 요약형 기록(메모 M-1~M-3을 옮겨 적는다)\n", + "\n", + "| **메모** | **사실** | **판단** | **조치** | **미확인** |\n", + "| --- | --- | --- | --- | --- |\n", + "| **M-1** | | | | |\n", + "| **M-2** | | | | |\n", + "| **M-3** | | | | |\n", + "\n", + "나. 작성 서식 4 - 상세형 기록(1건 선택)\n", + "\n", + "| **기록 번호** | |\n", + "| --- | --- |\n", + "\n", + "| **사실(시각, 장비, 구간, 수치, 확인 단계)** | |\n", + "| --- | --- |\n", + "\n", + "| **판단(상태 등급과 근거)** | |\n", + "| --- | --- |\n", + "\n", + "| **조치(누가, 언제까지)** | |\n", + "| --- | --- |\n", + "\n", + "| **미확인(확인 방법, 책임 직책)** | |\n", + "| --- | --- |\n", + "\n", + "나. 작성 서식 5 - 혼동 유발 표현 금지 목록(6개 이상)\n", + "\n", + "| **번호** | **금지 표현** | **대체 표현** |\n", + "| --- | --- | --- |\n", + "| **1** | | |\n", + "| **2** | | |\n", + "| **3** | | |\n", + "| **4** | | |\n", + "| **5** | | |\n", + "| **6** | | |\n", + "\n", + "다. 점검표\n", + "\n", + "| **점검 항목** | **확인** |\n", + "| --- | --- |\n", + "| 관제 상황판에 배터리, 링크 등급, 상태 등급, 조치 칸이 있다 | ( ) |\n", + "| 긴급 중지 명령의 지시, 복창, 기록, 재지시, 보고 순서를 정했다 | ( ) |\n", + "| 혼동 유발 표현 금지 목록을 6개 이상 만들고 대체 표현을 적었다 | ( ) |\n", + "| 메모 3종에서 사실과 판단을 혼동한 문장이 0건이다 | ( ) |\n", + "| 요약형과 상세형이 같은 내용을 담고 있다 | ( ) |\n", + "\n", + "**\n", + "\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 07. 통신 재밍 및 기상 급변 모의 긴급 임무 중지 판단 스트레스 훈련\n", + "\n", + "### 📋 미션 배경 및 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "통신 재밍 모의 긴급 임무 중지 판단 스트레스 훈련을 실시한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **통신 두절과 기상 급변이 겹치는 가상 상황에서 임무를 계속할지 중지할지 빠르게 판단하는 스트레스 훈련을 하고, 판단 능력을 평가할 루브릭을 정의한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **통제관이 모의 카드(링크 품질 저하, 링크 두절, 기상 급변, 배터리 급감, 경로 이탈 경보, 승인권자 부재)를 순서대로 주입하면 팀은 카드마다 중지 조건표를 적용해 계속·중지·복귀·원격 회수 요청 중 하나를 고르고 명령 전달과 복창, 기록 수행하고 분석하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 5개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `rubric/log05_evaluation_rubric.md` 형태의 결과물을 완성한다.\n", + "\n", + "### 📑 실습자료 및 작성 양식\n", + "통신 재밍 및 기상 급변 모의 긴급 임무 중지 판단 스트레스 훈련**\n", + "\n", + "가. 제공 자료 1 - 스트레스 모의 카드 T1~T6 [교육용가상자료] (통제관이 순서대로 주입)\n", + "\n", + "| **카드** | **주입 내용(가상)** | **확인할 것** |\n", + "| --- | --- | --- |\n", + "| **T1** | UV-2 B 구간 진입, 링크 품질 3등급으로 저하 | 감속·경로 유지 판단 |\n", + "| **T2** | 링크 두절 20초(원인 불문, 모의) | 제자리 안전 정지 조건 적용 |\n", + "| **T3** | 기상 급변: 풍속 등급 2에서 4로 상승 | UD-1 복귀·취소 판단 |\n", + "| **T4** | UD-1 배터리 급감 표시 | 복귀 가능 여부와 원격 회수 요청 |\n", + "| **T5** | 경로 이탈 경보 | 명령 전달·복창·기록 |\n", + "| **T6** | 승인권자 부재(통화 불가) | 위임 기준 적용 범위 |\n", + "\n", + "가. 제공 자료 2 - 루브릭 틀(5영역 x 4등급, 관찰 가능한 행동으로 작성)\n", + "\n", + "| **평가 영역** | **탁월** | **우수** | **보통** | **미흡** |\n", + "| --- | --- | --- | --- | --- |\n", + "| **상태 파악** | | | | |\n", + "| **중지 판단** | | | | |\n", + "| **명령 전달·확인** | | | | |\n", + "| **기록** | | | | |\n", + "| **보고** | | | | |\n", + "\n", + "나. 작성 서식 1 - 카드별 판단 기록\n", + "\n", + "| **카드** | **판단(계속·감속·안전 정지·복귀·회수 요청·취소)** | **적용한 조건** | **명령·복창 시각** | **판단 소요(분)** |\n", + "| --- | --- | --- | --- | --- |\n", + "| **T1** | | | | |\n", + "| **T2** | | | | |\n", + "| **T3** | | | | |\n", + "| **T4** | | | | |\n", + "| **T5** | | | | |\n", + "| **T6** | | | | |\n", + "\n", + "나. 작성 서식 2 - 평가자 채점 일치표(평가자 2명이 상의 없이 채점)\n", + "\n", + "| **평가 영역** | **평가자 1 등급** | **평가자 2 등급** | **일치(예·아니오)** | **불일치 이유** |\n", + "| --- | --- | --- | --- | --- |\n", + "| **상태 파악** | | | | |\n", + "| **중지 판단** | | | | |\n", + "| **명령 전달·확인** | | | | |\n", + "| **기록** | | | | |\n", + "| **보고** | | | | |\n", + "\n", + "나. 작성 서식 3 - 위임 기준 적용: T6에서 위임받은 직책은 ( ), 결정할 수 있는 범위는 ( ), 승인권자에게 사후 보고한 시각은 ( )이다. 일치율은 ( ) / 5 = ( )%이다.\n", + "\n", + "다. 점검표\n", + "\n", + "| **점검 항목** | **확인** |\n", + "| --- | --- |\n", + "| 5개 영역 x 4단계 기준을 모두 관찰 가능한 행동으로 정의했다 | ( ) |\n", + "| 카드 T1~T6마다 판단, 적용한 조건, 명령 전달 시각을 기록했다 | ( ) |\n", + "| 카드당 판단 시간이 2분 이내이거나 지연 사유를 적었다 | ( ) |\n", + "| 평가자 2명이 상의 없이 채점하고 일치율을 계산했다 | ( ) |\n", + "| 실제 장비를 구동하지 않았고 통신 방해 기법을 쓰지 않았다 | ( ) |\n", + "\n", + "**\n", + "\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 08. AAR을 통한 무인 보급 실패 사례 분석 및 중지 조건 민감도 재설계\n", + "\n", + "### 📋 미션 배경 및 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "AAR을 통해 무인 보급 실패 사례를 분석하고 중지 조건을 재설계한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **가상 AAR 일지에서 무인 보급 실패 사례의 원인을 사람·절차·정보·환경으로 규명하고, 중지 조건이 너무 민감하거나 둔감한 지점을 찾아 재설계한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **가상 AAR 일지 2건(불필요한 중지가 잦은 사례, 중지가 늦어 장비를 잃은 사례)을 시간순으로 읽고 계획과 실제의 차이를 적은 뒤 원인을 분류 수행하고 검증하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 5개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `reports/log05_redteam_critique_report.md` 형태의 결과물을 완성한다.\n", + "\n", + "### 📑 실습자료 및 작성 양식\n", + "AAR을 통한 무인 보급 실패 사례 분석 및 중지 조건 민감도 재설계**\n", + "\n", + "가. 제공 자료 1 - 가상 AAR 일지 A·B [교육용가상자료]\n", + "\n", + "| **시각** | **AAR 일지 A (불필요한 중지가 잦은 사례)** | **AAR 일지 B (중지가 늦은 사례)** |\n", + "| --- | --- | --- |\n", + "| **09:00** | UV-2 투입, 링크 3등급 이하면 중지하는 규칙 | UD-1 투입, 배터리 20% 이하면 복귀하는 규칙 |\n", + "| **09:40** | 링크 3등급 순간 하락 4회, 모두 중지 | 배터리 표시 35%에서 급감 시작 |\n", + "| **10:10** | 하루 6건 중 4건이 불필요한 중지로 기록 | 배터리 표시 22%, 복귀 판단 대기 |\n", + "| **10:30** | 보급 도착이 반나절 지연 | 표시 18%에서 복귀 지시, 도착 전 강제 착륙 |\n", + "| **11:00** | 현장에서 임계값을 무시하자는 의견 | 원격 회수 요청까지 25분 소요 |\n", + "\n", + "가. 제공 자료 2 - 가상 운용 20회 기록의 배터리 중지 임계값 3안 비교 [교육용가상자료]\n", + "\n", + "| **안** | **복귀 임계 잔량** | **불필요한 중지 건수(20회 중)** | **중지가 늦어 장비를 잃은 건수(20회 중)** |\n", + "| --- | --- | --- | --- |\n", + "| **안 1** | 30% | 7 | 0 |\n", + "| **안 2** | 25% | 3 | 1 |\n", + "| **안 3** | 20% | 1 | 3 |\n", + "\n", + "나. 작성 서식 1 - 계획과 실제 차이, 원인 분류(사람·절차·정보·환경, 개인 비난 표현 금지)\n", + "\n", + "| **번호** | **계획** | **실제** | **원인 분류** | **원인 설명(사실 중심)** | **개선안** |\n", + "| --- | --- | --- | --- | --- | --- |\n", + "| **1** | | | | | |\n", + "| **2** | | | | | |\n", + "| **3** | | | | | |\n", + "| **4** | | | | | |\n", + "| **5** | | | | | |\n", + "\n", + "나. 작성 서식 2 - 레드팀 쟁점·대응(5건 이상)\n", + "\n", + "| **번호** | **기준서의 취약한 전제** | **레드팀 비판** | **작성 팀 반박 또는 수정안** | **경고 문구** |\n", + "| --- | --- | --- | --- | --- |\n", + "| **1** | | | | |\n", + "| **2** | | | | |\n", + "| **3** | | | | |\n", + "| **4** | | | | |\n", + "| **5** | | | | |\n", + "| **6** | | | | |\n", + "\n", + "나. 작성 서식 3 - 반대 방향 위험 점검\n", + "\n", + "| **위험** | **설명** | **대응** |\n", + "| --- | --- | --- |\n", + "| **임계값을 너무 높여 보급 지연** | | |\n", + "| **임계값을 너무 낮춰 장비 손실** | | |\n", + "| **현장에서 중지 조건을 무시하는 관행** | | |\n", + "\n", + "나. 작성 서식 4 - 수정 반영 계획\n", + "\n", + "| **번호** | **수정할 문서와 위치** | **수정 내용** | **책임 직책(가상)** | **기한** |\n", + "| --- | --- | --- | --- | --- |\n", + "| **1** | | | | |\n", + "| **2** | | | | |\n", + "| **3** | | | | |\n", + "| **4** | | | | |\n", + "\n", + "나. 작성 서식 5 - 중지 조건 민감도 재설계(임계값 3안 비교)\n", + "\n", + "| **안** | **얻는 점** | **잃는 점** | **채택 여부** | **채택 이유 또는 보완책(예: 임계값 이중화)** |\n", + "| --- | --- | --- | --- | --- |\n", + "| **안 1** | | | | |\n", + "| **안 2** | | | | |\n", + "| **안 3** | | | | |\n", + "\n", + "다. 점검표\n", + "\n", + "| **점검 항목** | **확인** |\n", + "| --- | --- |\n", + "| 일지 A·B의 계획과 실제 차이를 5건 이상 적었다 | ( ) |\n", + "| 원인을 사람·절차·정보·환경으로 분류하고 개인 비난 표현이 없다 | ( ) |\n", + "| 레드팀 쟁점 5건 이상에 반박 또는 수정안을 100% 적었다 | ( ) |\n", + "| 취약한 전제에 경고 문구를 명시했다 | ( ) |\n", + "| 임계값 3안을 비교하고 선택 근거를 적었다 | ( ) |\n", + "\n", + "**\n", + "\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 09. 피드백 반영 체크리스트 항목 보완 및 실무 적용성 검증\n", + "\n", + "### 📋 미션 배경 및 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "피드백을 반영하여 체크리스트 항목을 보완하고 적용성을 검증한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **AAR 피드백을 반영해 체크리스트 항목을 보완하고, 보완 전후 기준이 같은 사례에서 일관된 판단을 내는지 실무 적용성을 검증한 뒤 핵심 결심 사항을 1페이지로 요약한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **개선 전후 체크리스트 기준을 비교표에 정리하고 가상 재검증 사례 7건에 개선 전·후 기준을 각각 적용해 판단, 일치 여부, 소요 시간을 기록 수행하고 검증하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 5개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `briefing/log05_commander_1page_brief.md(pdf)` 형태의 결과물을 완성한다.\n", + "\n", + "### 📑 실습자료 및 작성 양식\n", + "피드백 반영 체크리스트 항목 보완 및 실무 적용성 검증**\n", + "\n", + "가. 제공 자료 - 가상 재검증 사례 7건 [교육용가상자료]\n", + "\n", + "| **사례** | **내용** | **개선 전 판단** | **개선 후 판단(조별 작성)** |\n", + "| --- | --- | --- | --- |\n", + "| **V1** | UD-1 배터리 잔량 표시만 확인, 부하 전압 미확인 | 승인 | |\n", + "| **V2** | UV-2 링크 품질 합격이나 로그 1회 | 승인 | |\n", + "| **V3** | UD-1 적재 한도 도달, 저울 기록 없음 | 승인 | |\n", + "| **V4** | 풍속 예보와 측정값 상충 | 승인 | |\n", + "| **V5** | 점검표 서명 없이 승인권자 구두 승인 | 승인 | |\n", + "| **V6** | 4개 영역 합격, 신뢰도 G2 이상, 서명 완비 | 승인 | |\n", + "| **V7** | 중지 수단(통신)이 확보되지 않은 구간 투입 | 승인 | |\n", + "\n", + "나. 작성 서식 1 - 개선 전후 기준 비교표\n", + "\n", + "| **항목** | **개선 전 기준** | **개선 후 기준** | **바뀐 이유** | **근거 미션** |\n", + "| --- | --- | --- | --- | --- |\n", + "| **배터리 점검** | | | | |\n", + "| **링크 점검** | | | | |\n", + "| **적재 점검** | | | | |\n", + "| **기상 점검** | | | | |\n", + "| **서명·승인 확인** | | | | |\n", + "\n", + "나. 작성 서식 2 - 재검증 결과표\n", + "\n", + "| **사례** | **개선 전 판단** | **개선 후 판단** | **일치 여부** | **소요 시간(분)** |\n", + "| --- | --- | --- | --- | --- |\n", + "| **V1** | | | | |\n", + "| **V2** | | | | |\n", + "| **V3** | | | | |\n", + "| **V4** | | | | |\n", + "| **V5** | | | | |\n", + "| **V6** | | | | |\n", + "| **V7** | | | | |\n", + "\n", + "나. 작성 서식 3 - 1페이지 브리핑 BLUF 틀\n", + "\n", + "| **결론(첫 문장)** | |\n", + "| --- | --- |\n", + "\n", + "| **핵심 결심 사항** | |\n", + "| --- | --- |\n", + "\n", + "| **위험 요소(신호등 표시: 녹·황·적)** | |\n", + "| --- | --- |\n", + "\n", + "| **대안 3종(장단점 한 줄)** | |\n", + "| --- | --- |\n", + "\n", + "| **근거(미션·가상 자료)** | |\n", + "| --- | --- |\n", + "\n", + "나. 작성 서식 4 - 브리핑 리허설 기록\n", + "\n", + "| **항목** | **목표** | **결과** |\n", + "| --- | --- | --- |\n", + "| 발표 시간 | 3분 이내 | |\n", + "| 분량 | A4 1페이지 | |\n", + "| 질의 응답 | 3건 이상 답변 | |\n", + "| 결론 먼저 제시 | 첫 문장 | |\n", + "\n", + "다. 점검표\n", + "\n", + "| **점검 항목** | **확인** |\n", + "| --- | --- |\n", + "| 개선 전후 기준의 바뀐 조건과 이유를 모두 적었다 | ( ) |\n", + "| 재검증 7건 모두 개선 전후 판단을 비교했다 | ( ) |\n", + "| 브리핑이 결론부터 시작하고 A4 1페이지에 들어간다 | ( ) |\n", + "| 핵심 결심 사항, 위험 요소, 대안 3종이 모두 있다 | ( ) |\n", + "| 3분 안에 발표하고 질의에 답했다 | ( ) |\n", + "| 점검에 걸리는 시간을 재서 20분 이내 완료 가능한지 확인했다 | ( ) |\n", + "| 기준 변경으로 생긴 판단 차이를 체크리스트 항목과 연결했다 | ( ) |\n", + "\n", + "**\n", + "\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 10. 무인 보급장비 투입 전 상태·책임·중지조건 표준 체크리스트 완성\n", + "\n", + "### 📋 미션 배경 및 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "무인 보급장비 투입 전 상태·책임·중지조건 표준 체크리스트를 완성한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **미션 1~9의 산출물을 통합해 무인 보급장비 투입 전 상태·책임·중지조건 표준 체크리스트(표준 지침서)를 완성하고 모의 심의를 거친다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **산출물을 지침서 8개 장(목적과 적용 범위, 용어, 운용 환경과 위험 요인, 투입 전 점검, 중지·복귀 조건, 책임과 승인, 관제·훈련·평가, 개정 관리)과 부록에 배치하고 빠진 내용을 보완 수행하고 검증하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 5개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `manual/log05_standard_operational_manual.md` 형태의 결과물을 완성한다.\n", + "\n", + "### 📑 실습자료 및 작성 양식\n", + "무인 보급장비 투입 전 상태·책임·중지조건 표준 체크리스트 완성**\n", + "\n", + "가. 제공 자료 1 - 지침서 8개 장 구성과 가져올 결과물\n", + "\n", + "| **장** | **제목** | **포함 내용** | **가져올 결과물** |\n", + "| --- | --- | --- | --- |\n", + "| **1** | 목적과 적용 범위 | 목적, 적용 장비와 상황, 적용하지 않는 상황, 교전수칙과의 관계 | M1 |\n", + "| **2** | 용어 정의 | 계속, 감속, 제자리 안전 정지, 복귀, 원격 회수 요청, 임무 취소 | M1·M3 |\n", + "| **3** | 운용 환경과 위험 요인 | 구간 특성, 6개 위험 분류, 사례 | M1 |\n", + "| **4** | 투입 전 점검 | 점검 항목표, 신뢰도 G1~G6, 교차 확인 규칙 | M2·M9 |\n", + "| **5** | 중지·복귀 조건 | 상태 변화별 처리 단계, 정보 공백, 판단 한계 | M3·M7·M8 |\n", + "| **6** | 책임과 승인 | 직책별 책임 한계, 승인 분기점, 사후 감사 | M4·M5 |\n", + "| **7** | 관제·훈련·평가 | 관제 상황판, 긴급 중지 명령, 스트레스 훈련, 루브릭, AAR | M6~M8 |\n", + "| **8** | 개정 관리 | 연간 개정 주기, 의견 수렴 창구, 책임 직책, 이력 | M8·M9 |\n", + "\n", + "가. 제공 자료 2 - 모의 심의 역할 카드\n", + "\n", + "| **역할** | **검토 질문** |\n", + "| --- | --- |\n", + "| **교리 역할** | 점검 항목과 처리 단계가 일관되는가? 신병도 이해할 용어인가? |\n", + "| **법무 역할** | '승인과 중지 결정은 사람이, 교전수칙·지휘계통에 따라'가 드러나는가? 장비 파괴·무력화 쟁점이 검토 연계로 처리되는가? |\n", + "| **작전 역할** | 현장에서 20분 안에 점검할 수 있는가? 점검 때문에 긴급 보급이 지나치게 늦어지지 않는가? |\n", + "\n", + "나. 작성 서식 1 - 근거 표기 점검표(지침서 조항마다 작성)\n", + "\n", + "| **조항** | **기준 내용 요약** | **근거(미션·가상 자료)** | **표기 여부** |\n", + "| --- | --- | --- | --- |\n", + "| **1-1** | | | |\n", + "| **2-1** | | | |\n", + "| **3-1** | | | |\n", + "| **4-1** | | | |\n", + "| **5-1** | | | |\n", + "| **6-1** | | | |\n", + "| **7-1** | | | |\n", + "| **8-1** | | | |\n", + "\n", + "나. 작성 서식 2 - 모의 심의 지적 사항 반영표\n", + "\n", + "| **번호** | **역할** | **지적 사항** | **반영 내용** | **반영 위치(장·조)** |\n", + "| --- | --- | --- | --- | --- |\n", + "| **1** | | | | |\n", + "| **2** | | | | |\n", + "| **3** | | | | |\n", + "| **4** | | | | |\n", + "| **5** | | | | |\n", + "| **6** | | | | |\n", + "\n", + "나. 작성 서식 3 - 개정 관리: 개정 주기 ( ), 의견 수렴 창구와 방법 ( ), 개정 책임 직책(가상) ( ), 개정 이력 기록 위치 ( )\n", + "\n", + "다. 점검표\n", + "\n", + "| **점검 항목** | **확인** |\n", + "| --- | --- |\n", + "| 8개 장과 부록을 모두 완성했다 | ( ) |\n", + "| 모든 기준 조항에 근거(미션·가상 자료)를 표기했다 | ( ) |\n", + "| 교리·법무·작전 역할의 지적 사항을 반영하고 반영 위치를 적었다 | ( ) |\n", + "| '승인과 중지 결정은 사람이 하며 실제 교전 판단은 교전수칙·지휘계통에 따른다'는 문구가 1장과 6장에 있다 | ( ) |\n", + "| 장비 파괴·무력화 조치를 정의하지 않고 검토 연계로 처리한다는 문구가 5장에 있다 | ( ) |\n", + "| 연간 개정 주기와 실무 의견 수렴 창구를 정했다 | ( ) |\n", + "| 실제 무인체계 제원·성능을 쓰지 않았다 | ( ) |\n", + "\n", + "**4. 평가 채점표**\n", + "\n", + "미션별 평가 기준은 시나리오의 평가 항목과 같다. 평가자는 기준 충족 여부를 PASS/FAIL로 표시하고 FAIL이면 사유를 비고에 적는다.\n", + "\n", + "| **미션** | **구분** | **채점 기준** | **판정** | **비고** |\n", + "| --- | --- | --- | --- | --- |\n", + "| **M1** | 필수 | 운용 환경·위험 요인 분석: 가상 사례·문헌 5건 이상을 검토해 위험 요인을 6개 분류로 정리했는가, 사례별로 빠진 점검·승인을 찾아 기록했는가, 지침서 목차(8개 장) 골격을 100% 정의하고 지휘관 결심 의제 순으로 배열했는가. | PASS / FAIL | |\n", + "| **M2** | 필수 | 점검 체크리스트·신뢰도 6단계: 신뢰도 평가 기준 6단계를 완비하고 평가 지표(점검 항목) 10개 이상을 정의했는가, 4개 점검 영역이 모두 포함되었는가, 가상 점검 보고 5종에 대한 조원 간 등급 일치가 4건 이상이며 단일 출처(자기 신고) 의존 경고와 교차 확인 필수 규칙을 명시했는가. | PASS / FAIL | |\n", + "| **M3** | 필수 | 중지·복귀 조건·불확실성: 관측 공백(정보 공백) 요인 8개 이상을 식별하고 영향도 매트릭스를 도출했는가, 어긋난 기록 4건에 원인 후보와 확인 방법을 적었는가, 상태 변화별 중지·복귀 조건을 수치 또는 관찰 가능한 문장으로 100% 명시하고 판단 한계를 적었는가. | PASS / FAIL | |\n", + "| **M4** | 선택 | 책임 한계·근거 가중치 산식: 책임 판단(귀속) 기준을 12개 이상 항목화하고 근거 가중치 산식을 100% 정립했는가, 4개 직책의 승인·중지 권한과 한계를 명시했는가, 사례 3종 적용 기록과 레드팀 반박 대응을 통해 단일 증거로 개인을 단정하지 않는 최소 증거 요건을 설정했는가. | PASS / FAIL | |\n", + "| **M5** | 필수 | 분기점·책임 매핑·모의 승인: 승인/보류 조건 분기점(Threshold)을 100% 명시하고 책임자 직책 매핑을 완료했는가, 경계선 사례 5종과 가상 임무 3건 모두에 판단과 근거를 기록했는가, 교전수칙·지휘계통 우선 원칙과 사람의 승인을 지키고 긴급 신속 승인과 사후 감사 규정을 보완했는가. | PASS / FAIL | |\n", + "| **M6** | 선택 | 관제·긴급 중지 서식: 기록 분류 규칙을 100% 표준화하고 혼동 유발 표현 금지 가이드를 6개 이상 포함했는가, 가상 관제 메모 3종에서 사실과 판단의 혼동이 0건인가, 요약형·상세형 듀얼 템플릿을 구성하고 긴급 중지 명령 전달·복창 순서를 정했는가. | PASS / FAIL | |\n", + "| **M7** | 필수 | 긴급 중지 판단 훈련·루브릭: 평가 영역 5개와 등급 4단계(탁월·우수·보통·미흡) 기준 정의율 100%인가, 카드 6장 모두에 판단과 명령 전달 기록이 있고 카드당 판단 2분 이내인가, 평가자 2명 채점 일치율 80% 이상인가, 위임 기준 적용 결과를 기록했는가. | PASS / FAIL | |\n", + "| **M8** | 선택 | 실패 원인 규명·민감도 재설계: 도출된 비판적 쟁점 5건 이상에 대해 반박 및 수정 대안을 100% 수립했는가, 임계값 3안의 민감도를 비교해 선택 근거를 적었는가, 취약한 전제(Assumptions)에 경고 문구를 명시했는가. | PASS / FAIL | |\n", + "| **M9** | 필수 | 체크리스트 보완·1페이지 브리핑: 핵심 결심 사항, 위험 요소, 대안 3종이 1페이지(A4) 안에 완결되는가, 재검증 7건의 개선 전후 비교와 적용성 검증(점검 20분 이내 여부)을 마쳤는가, BLUF 준수(결론 먼저, 3분 이내 발표)와 신호등 표시가 있는가. | PASS / FAIL | |\n", + "| **M10** | 필수 | 표준 지침서 통합 완성: 표준 지침서 총 8개 장 및 부록을 100% 완성하고 모든 기준 조항에 근거(미션·가상 자료) 출처를 표기했는가, 모의 심의 지적 사항을 반영하고 연간 개정 주기와 실무 의견 수렴 창구를 두었는가, '승인과 중지 결정은 사람이 하며 실제 교전 판단은 교전수칙·지휘계통에 따른다'는 문구를 명시했는가. | PASS / FAIL | |\n", + "\n", + "| **판정 기준** | 필수 7개 미션(M1·M2·M3·M5·M7·M9·M10)이 모두 PASS여야 최종 PASS로 인정한다. 선택 미션(M4·M6·M8)은 가점·보완 의견에 반영한다. |\n", + "| --- | --- |\n", + "\n", + "| **공통 감점** | 실제 부대명·장비 제원·실존 인물 정보를 사용한 경우, 부대 자료를 외부 AI 서비스에 입력한 경우 해당 미션은 FAIL로 처리한다. |\n", + "| --- | --- |\n", + "\n", + "| **주제 안전 기준** | 자기 신고만으로 투입을 승인하거나 사람의 승인 없이 투입되는 경로가 있거나 지침서에 사람의 승인·중지 결정과 교전수칙 우선 문구가 없으면 M5·M10은 FAIL로 처리한다. |\n", + "| --- | --- |\n", + "\n", + "| **최종 판정 결과** | ( PASS / FAIL ) 평가자 서명: ( ) |\n", + "| --- | --- |\n", + "\n", + "| 안전 원칙: 본 자료의 점검 기준, 등급, 승인 권한 구분은 학습용 가상 기준이다. 무인 보급 임무의 승인과 중지 결정은 사람(승인권자·관제 책임자)이 하며 실제 교전 관련 판단은 교전수칙과 지휘계통에 따른다. 실제 장비를 구동하지 않으며 실제 부대 자료를 입력하거나 부대 내에서 촬영하지 않는다. |\n", + "| --- |\n", + "\n" + ] + } + ], + "metadata": { + "colab": { + "provenance": [] + }, + "kernelspec": { + "display_name": "Python 3", + "name": "python3" + }, + "language_info": { + "name": "python" + } + }, + "nbformat": 4, + "nbformat_minor": 0 +} \ No newline at end of file diff --git a/uploads/LOG06/LOG06_workbook.ipynb b/uploads/LOG06/LOG06_workbook.ipynb new file mode 100644 index 0000000..9915668 --- /dev/null +++ b/uploads/LOG06/LOG06_workbook.ipynb @@ -0,0 +1,1013 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# 📋 [LOG06] 노획·출처불명 장비의 격리·안전검사·사용금지 절차\n", + "### 실습 워크북 & SOP 가이드 (Google Colab / Jupyter)\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/LOG06/LOG06_workbook.ipynb)\n", + "\n", + "---\n", + "\n", + "## 📌 1. 프로젝트 개요 및 배경\n", + "\n", + "| 항목 | 내용 |\n", + "| :--- | :--- |\n", + "| **과정 분류** | 군수·보급 (Level 3) |\n", + "| **학습 소요 시간** | 10개 미션 (5일 / 총 27.0시간) |\n", + "| **실습 유형** | 작전절차(SOP), 체크리스트, 모의훈련, AAR 사후검토 실습 |\n", + "\n", + "### 📖 프로젝트 목표\n", + "러시아-우크라이나 전쟁과 미국-이란 간 긴장 국면에서는 전장에서 노획했거나 출처를 알 수 없는 장비와 물자가 부대 주변에서 발견되는 일이 잦았고, 이를 호기심이나 편의로 만지거나 켜 보다가 안전 사고와 정보 노출 위험이 생길 수 있다는 점이 강조되었다. 전투 중에는 임시로 받은 물자, 소모한 물자, 잃어버린 물자의 기록이 급하게 남겨져 전투 뒤 정산 때 수량이 맞지 않거나 증빙이 비는 문제도 반복되었다. 본 프로젝트에서는 교육용 가상 부대와 가상 노획·출처불명 장비(U-1~U-6), 가상 소모 물자를 사용해 야전 임시 취급 실태 분석, 임시 영수증·소모 확인서·격리 확인서·인계 확인서 등 증빙 필수 항목과 신뢰도 6단계, 사후 정산 표준 보고 양식, 임시 처리 보고서 작성 실습, 허위 소모와 분실 은폐를 막는 지휘관 서명·감사 체크리스트, 군수 감사관 앞 소명 절차를 차례로 만들고, 대량 유실 모의 스트레스 훈련, AAR 원인 분석, 간이 증빙 양식 개선, 표준 업무 지침서 완성으로 마무리한다. 장비를 분해하거나 작동시키는 방법은 다루지 않으며, 최종 목표는 격리·보고·전문기관 인계와 사용금지 증빙이 남도록 하는 표준 업무 지침서를 문서로 완성하는 것이다.\n", + "\n", + "> **⚠️ [교육용 가상 자료 원칙]**\n", + "> - 본 워크북의 모든 자료는 안전 규정에 따라 **교육용 가상 자료**로 구성되어 있습니다.\n", + "> - 실제 군사 기밀이나 부대 세부 인적·작전 정보는 포함되지 않습니다.\n", + "\n", + "---\n", + "\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 01. 야전 긴급 상황에서의 임시 물자 취급, 전투 소모, 현장 폐기 실태 분석\n", + "\n", + "### 📋 미션 배경 및 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "야전 긴급 상황에서의 임시 물자 취급, 전투 소모, 현장 폐기 실태를 분석한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **야전 긴급 상황에서 물자를 임시로 취급하고 소모하고 처리하는 실태를 분석하되, 노획·출처불명 장비는 접촉하지 않고 격리·보고·인계해야 한다는 원칙으로 분석 틀과 문서 골격을 정의한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **실습자료의 가상 임시 취급 사례 5건을 읽고 발견, 인수, 격리, 보고, 인계, 처리 결과 기록 6개 단계 중 어디에서 문제가 생겼는지 표로 정리하고 규격화하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 5개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `docs/log06_doc_framework.md 및 분석 개요서` 형태의 결과물을 완성한다.\n", + "\n", + "### 📑 실습자료 및 작성 양식\n", + "야전 긴급 상황에서의 임시 물자 취급, 전투 소모, 현장 폐기 실태 분석**\n", + "\n", + "가. 제공 자료 1 - 가상 임시 취급 사례 5건 [교육용가상자료]\n", + "\n", + "| **사례** | **상황 요약** | **있었던 기록** | **눈에 띄는 한계** |\n", + "| --- | --- | --- | --- |\n", + "| **C1** | U-2를 발견한 병사가 궁금해서 전원 버튼을 눌러 화면이 켜짐 | 구두 보고 1건 | 접촉·조작 금지 원칙이 현장에 전달되지 않음 |\n", + "| **C2** | U-5를 막사 옆에 표시 없이 두어 다른 부대원이 접근함 | 인수 메모 1장 | 격리 구역 표시와 접근 통제 기록 없음 |\n", + "| **C3** | U-4가 부풀어 보인다는 말을 듣고도 4시간 뒤에 보고함 | 보고 시각 미기재 | 위험 징후 보고 기준과 보고 시한 없음 |\n", + "| **C4** | L-3 12세트를 전투 중 소모했으나 소모 확인서가 없음 | 구두 보고만 있음 | 수량·시각·확인자 불명 |\n", + "| **C5** | U-1을 전문기관에 넘겼으나 인계 확인서에 서명이 한쪽만 있음 | 인계 확인서 1장 | 누가 언제 넘겼는지 확인할 수 없음 |\n", + "\n", + "나. 작성 서식 1 - 단계별 실태 분석표(6개 단계)\n", + "\n", + "| **단계** | **일반 물자(L)에서 생기는 문제** | **출처불명 장비(U)에서 생기는 문제** | **사고·정산 오류로 이어지는 경로** | **필요한 증빙** |\n", + "| --- | --- | --- | --- | --- |\n", + "| **발견** | | | | |\n", + "| **인수** | | | | |\n", + "| **격리** | | | | |\n", + "| **보고** | | | | |\n", + "| **인계** | | | | |\n", + "| **처리 결과 기록** | | | | |\n", + "\n", + "나. 작성 서식 2 - 사례별 빠진 조치와 기록\n", + "\n", + "| **사례** | **있었던 조치·기록** | **빠진 조치·기록** | **추가로 확인할 것** |\n", + "| --- | --- | --- | --- |\n", + "| **C1** | | | |\n", + "| **C2** | | | |\n", + "| **C3** | | | |\n", + "| **C4** | | | |\n", + "| **C5** | | | |\n", + "\n", + "나. 작성 서식 3 - 지침서 목차 골격(지휘관 결심 의제 순으로 배열)\n", + "\n", + "| **순서** | **장 제목** | **담을 핵심 내용** | **지휘관이 이 장에서 얻는 판단 정보** |\n", + "| --- | --- | --- | --- |\n", + "| **제1장** | | | |\n", + "| **제2장** | | | |\n", + "| **제3장** | | | |\n", + "| **제4장** | | | |\n", + "| **제5장** | | | |\n", + "| **제6장** | | | |\n", + "| **제7장** | | | |\n", + "| **제8장** | | | |\n", + "\n", + "다. 점검표\n", + "\n", + "| **점검 항목** | **확인** |\n", + "| --- | --- |\n", + "| 가상 사례 5건을 모두 검토하고 6개 단계에 표시했다 | ( ) |\n", + "| 일반 물자와 출처불명 장비의 문제를 구분해 적었다 | ( ) |\n", + "| C1처럼 접촉·조작이 일어난 사례에서 필요한 원칙을 '격리·보고·인계'로 적었다 | ( ) |\n", + "| 지침서 목차 8개 장의 제목과 핵심 내용을 정의했다 | ( ) |\n", + "| 목차를 지휘관이 먼저 알아야 할 의제 순으로 배열하고 이유를 한 줄로 적었다 | ( ) |\n", + "\n", + "**\n", + "\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 02. 임시 영수증, 소모 확인서, 현장 파기 인증 사진 등 증빙 필수 항목 도출\n", + "\n", + "### 📋 미션 배경 및 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "임시 영수증, 소모 확인서, 파기 사진 등 필수 증빙 항목을 도출한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **임시 영수증, 소모 확인서, 격리 확인서, 인계 확인서, 사진 기록 카드 등 증빙의 필수 기재 항목을 도출하고, 증빙을 얼마나 믿을 수 있는지 6단계로 구분한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **증빙 종류별로 필수 기재 항목(일시, 장소, 인수자, 인계자, 품목 외형, 수량, 외관 상태, 격리 위치, 접촉 여부, 보고 시각, 인계 기관, 서명 등), 작성 시점, 작성자, 확인자, 누락 시 처리를 적어 평가 지표 10개 이상의 증빙 항목표 작성하여 원인을 분석하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 5개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `framework/log06_source_reliability_matrix.md` 형태의 결과물을 완성한다.\n", + "\n", + "### 📑 실습자료 및 작성 양식\n", + "임시 영수증, 소모 확인서, 현장 파기 인증 사진 등 증빙 필수 항목 도출**\n", + "\n", + "가. 제공 자료 1 - 가상 증빙 5종 [교육용가상자료]\n", + "\n", + "| **증빙** | **대상** | **기재된 내용** | **확인 방법** | **확인 상태** |\n", + "| --- | --- | --- | --- | --- |\n", + "| **R1** | U-2 임시 영수증 | 인수 일시·장소, 인수자·인계자, 외관 설명, 격리 위치, 접촉 없음 | 인수자와 확인 담당 이중 서명, 사진 기록 카드 첨부 | 항목 완비 |\n", + "| **R2** | L-3 소모 확인서 | 소모 12세트, 확인자 서명 1개 | 확인 담당 서명 | 시각과 소모 사유 누락 |\n", + "| **R3** | U-4 격리 확인서 | 날짜만 있음 | 작성자 단독 | 시각·위치·보고 시각 없음 |\n", + "| **R4** | U-1 인계 확인서 | 전문기관 서명 있음, 수량 2개로 기재 | 서명 대조 | 영수증 수량은 1개(불일치) |\n", + "| **R5** | L-1 소모 기록 | 식량 상자 소모 | 구두 보고 후 다음 날 작성 | 수량·서명 없음 |\n", + "\n", + "가. 제공 자료 2 - 증빙 신뢰도 6단계 틀. 단계 이름은 고정하고 판정 기준을 조별로 작성한다.\n", + "\n", + "| **단계** | **이름** | **판정 기준(조별 작성)** |\n", + "| --- | --- | --- |\n", + "| **G1** | 이중 서명·기록 완비 | |\n", + "| **G2** | 서명·기록 완비 | |\n", + "| **G3** | 서명 있음·일부 항목 누락 | |\n", + "| **G4** | 작성자 단독 기록 | |\n", + "| **G5** | 구두·사후 작성 | |\n", + "| **G6** | 근거 없음 | |\n", + "\n", + "나. 작성 서식 1 - 증빙 필수 항목표(항목 10개 이상, 예시 2개 포함)\n", + "\n", + "| **번호** | **증빙 종류** | **필수 기재 항목** | **작성 시점** | **작성자** | **확인자** | **누락 시 처리** |\n", + "| --- | --- | --- | --- | --- | --- | --- |\n", + "| **1** | 임시 영수증 | 인수 일시와 장소 | 인수 즉시 | 인수 담당 | 확인 담당 | G4 이하로 처리하고 보완 요청 |\n", + "| **2** | 임시 영수증(U) | 접촉 여부와 외관 설명(보이는 것만) | 인수 즉시 | 인수 담당 | 확인 담당 | 보류, 안전 담당에게 보고 |\n", + "| **3** | | | | | | |\n", + "| **4** | | | | | | |\n", + "| **5** | | | | | | |\n", + "| **6** | | | | | | |\n", + "| **7** | | | | | | |\n", + "| **8** | | | | | | |\n", + "| **9** | | | | | | |\n", + "| **10** | | | | | | |\n", + "| **11** | | | | | | |\n", + "| **12** | | | | | | |\n", + "\n", + "나. 작성 서식 2 - 사진 기록 카드(촬영하지 않고 글로 적는다)\n", + "\n", + "| **카드 번호·작성 시각** | |\n", + "| --- | --- |\n", + "\n", + "| **기록 대상(장비·물자 번호)** | |\n", + "| --- | --- |\n", + "\n", + "| **보이는 외관(만지지 않고 눈으로 본 것만)** | |\n", + "| --- | --- |\n", + "\n", + "| **위치(격리 구역 번호)** | |\n", + "| --- | --- |\n", + "\n", + "| **작성자·확인자** | |\n", + "| --- | --- |\n", + "\n", + "나. 작성 서식 3 - 등급 부여 일관성 시험(조원이 각자 G1~G6을 매기고 비교)\n", + "\n", + "| **증빙** | **조원 1** | **조원 2** | **조원 3** | **조원 4** | **일치 여부** | **불일치 원인** |\n", + "| --- | --- | --- | --- | --- | --- | --- |\n", + "| **R1** | | | | | | |\n", + "| **R2** | | | | | | |\n", + "| **R3** | | | | | | |\n", + "| **R4** | | | | | | |\n", + "| **R5** | | | | | | |\n", + "\n", + "나. 작성 서식 4 - 교차 확인 필수 규칙: 작성자 단독 기록은 G ( ) 이하로 기록하고 정산 인정은 서로 다른 확인자 ( )명의 서명과 ( )이 함께 있을 때만 검토한다. 인수자와 확인자가 같은 사람이면 ( ) 처리한다. 처리 결과는 전문기관의 ( )가 있을 때만 기록한다.\n", + "\n", + "다. 점검표\n", + "\n", + "| **점검 항목** | **확인** |\n", + "| --- | --- |\n", + "| 증빙 필수 항목을 10개 이상 정의하고 작성 시점·작성자·확인자·누락 시 처리를 적었다 | ( ) |\n", + "| 신뢰도 G1~G6의 판정 기준을 모두 작성했다 | ( ) |\n", + "| 가상 증빙 5종에 대한 조원 등급이 4건 이상 일치했다(불일치 건은 원인과 수정 내용을 적었다) | ( ) |\n", + "| 사진 기록 카드를 촬영 없이 글로 작성했고 현장 파기가 아니라 전문기관 처리 결과 확인만 기록하도록 했다 | ( ) |\n", + "| 작성자 단독 의존 경고와 교차 확인 필수 규칙을 명시했다 | ( ) |\n", + "\n", + "**\n", + "\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 03. 전투 종료 후 사후 정산(Post-settlement)을 위한 표준 보고 양식(Template) 설계\n", + "\n", + "### 📋 미션 배경 및 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "전투 종료 후 사후 정산(Post-settlement)을 위한 표준 보고 양식을 설계한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **전투 종료 후 사후 정산에 쓸 표준 보고 양식을 설계하고, 기록에 남는 공백과 어긋남을 정리한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **사후 정산 보고 양식(대상 물자와 장비, 인수·소모·유실·격리·인계 내역, 증빙 번호, 수량 대조, 미확인 사항, 확인 서명)의 항목을 설계하고 항목마다 기재 요령을 적는다 과정을 진행하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 5개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `analysis/log06_uncertainty_assessment.md` 형태의 결과물을 완성한다.\n", + "\n", + "### 📑 실습자료 및 작성 양식\n", + "전투 종료 후 사후 정산(Post-settlement)을 위한 표준 보고 양식(Template) 설계**\n", + "\n", + "가. 제공 자료 1 - 어긋난 가상 기록 쌍 4건 [교육용가상자료]\n", + "\n", + "| **쌍** | **기록 A** | **기록 B** | **어긋난 지점** |\n", + "| --- | --- | --- | --- |\n", + "| **K1** | U-2 임시 영수증 인수 시각 14:10 | 상황 보고 U-2 발견 보고 14:40 | 인수 시각과 보고 시각의 기준 차이 |\n", + "| **K2** | U-3 수량 1(영수증) | U-3 수량 2(인계 확인서, 잔해 포함) | 집계 범위(잔해 포함 여부) |\n", + "| **K3** | U-5 격리 위치 B 구역(격리 확인서) | U-5 격리 위치 C 구역(사진 기록 카드) | 위치 변경 기록 누락 |\n", + "| **K4** | L-3 소모 12세트(소모 확인서) | L-3 불출 대장상 소모 10세트 | 단위와 시점 차이 |\n", + "\n", + "가. 제공 자료 2 - 사후 정산 표준 보고 양식 항목 예시(나머지는 조별로 정의)\n", + "\n", + "| **항목** | **기재 요령 예시** |\n", + "| --- | --- |\n", + "| **대상 구분** | 일반 물자(L) 또는 출처불명 장비(U)로 나누어 쓴다 |\n", + "| **인수·소모·유실·격리·인계 내역** | 발생 순서대로 시각과 수량을 쓴다 |\n", + "\n", + "나. 작성 서식 1 - 사후 정산 표준 보고 양식 항목 설계\n", + "\n", + "| **번호** | **항목** | **기재 요령** | **필수 여부** | **관련 증빙 번호** |\n", + "| --- | --- | --- | --- | --- |\n", + "| **1** | | | | |\n", + "| **2** | | | | |\n", + "| **3** | | | | |\n", + "| **4** | | | | |\n", + "| **5** | | | | |\n", + "| **6** | | | | |\n", + "| **7** | | | | |\n", + "| **8** | | | | |\n", + "\n", + "나. 작성 서식 2 - 어긋난 기록 분류와 원인 후보\n", + "\n", + "| **쌍** | **분류(시점·수량 단위·위치·집계 범위)** | **원인 후보** | **확인 방법** | **책임 직책** |\n", + "| --- | --- | --- | --- | --- |\n", + "| **K1** | | | | |\n", + "| **K2** | | | | |\n", + "| **K3** | | | | |\n", + "| **K4** | | | | |\n", + "\n", + "나. 작성 서식 3 - 기록 공백 요인과 불확실성 영향도(8개 이상)\n", + "\n", + "| **번호** | **공백 요인** | **영향도(높음·중간·낮음)** | **대체 확인 수단** |\n", + "| --- | --- | --- | --- |\n", + "| **1** | | | |\n", + "| **2** | | | |\n", + "| **3** | | | |\n", + "| **4** | | | |\n", + "| **5** | | | |\n", + "| **6** | | | |\n", + "| **7** | | | |\n", + "| **8** | | | |\n", + "| **9** | | | |\n", + "\n", + "나. 작성 서식 4 - 표현 규칙: 출처불명 장비는 '출처 ( )'로 쓰고 출처를 추정해 단정하지 않는다. 전문기관 검사 결과가 없으면 '안전함'이라고 쓰지 않고 '( ) 기준 ( ) 상태, 검사 결과 ( )'로 쓴다.\n", + "\n", + "다. 점검표\n", + "\n", + "| **점검 항목** | **확인** |\n", + "| --- | --- |\n", + "| 표준 보고 양식 항목 8개 이상을 정의하고 기재 요령을 적었다 | ( ) |\n", + "| 어긋난 기록 4건에 원인 후보와 확인 방법을 적었다 | ( ) |\n", + "| 기록 공백 요인을 8개 이상 찾고 영향도와 대체 확인 수단을 적었다 | ( ) |\n", + "| 출처불명 장비를 출처 단정 없이 기록하고 검사 결과 전에는 '안전함'으로 쓰지 않는 규칙을 정했다 | ( ) |\n", + "| 판단의 한계를 함께 쓰는 표현 규칙을 정했다 | ( ) |\n", + "\n", + "**\n", + "\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 04. 가상 전투 손실 물자 기록 및 임시 폐기 보고서 작성 실습(Dry-run)\n", + "\n", + "### 📋 미션 배경 및 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "가상 전투 손실 물자 기록 및 임시 폐기 보고서 작성을 실습(Dry-run)한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **가상 전투 손실 물자와 가상 출처불명 장비의 기록·임시 처리 보고서를 처음부터 작성해 보고, 기록이 정산에서 인정될 만한지 근거 항목과 가중치로 판단하는 기준을 만든다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **가상 손실·발견 기록을 보고 일반 물자(L-1~L-3)는 소모·유실 내역을, 출처불명 장비(U-2 등)는 발견 경위, 외관(접촉 없이 보이는 것만), 격리 위치, 보고 시각, 인계 대기 상태를 임시 처리 보고서에 적는다 과정을 진행하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 5개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `matrix/log06_attribution_matrix.xlsx(md)` 형태의 결과물을 완성한다.\n", + "\n", + "### 📑 실습자료 및 작성 양식\n", + "가상 전투 손실 물자 기록 및 임시 폐기 보고서 작성 실습(Dry-run)**\n", + "\n", + "가. 제공 자료 1 - 가상 손실·발견 기록(D일) [교육용가상자료]\n", + "\n", + "| **번호** | **구분** | **내용** | **상태 기록** |\n", + "| --- | --- | --- | --- |\n", + "| **E1** | L-1 식량 상자 | 소모 8상자 | 소모 확인서 있음, 확인자 서명 |\n", + "| **E2** | L-2 방호자재 묶음 | 유실 5묶음(이동 중 분실) | 구두 보고, 시각 미상 |\n", + "| **E3** | L-3 소모성 부품 세트 | 소모 12세트 | 소모 확인서 있음(시각 누락) |\n", + "| **E4** | U-2 휴대 단말 형태 | 14:10 발견, 화면 꺼진 채, 접촉 없음 | 격리 구역 B-1, 인계 대기 |\n", + "| **E5** | U-4 배터리 팩 형태 | 외형이 부풀어 보임, 접촉 없음 | 격리 구역 B-2, 보고 시각 미기재 |\n", + "\n", + "가. 제공 자료 2 - 근거 항목 예시 4개(나머지는 조별로 12개 이상으로 늘린다)\n", + "\n", + "| **근거 항목** | **방향(인정 지지·반대)** | **가중치 예시** |\n", + "| --- | --- | --- |\n", + "| **인수자와 다른 사람의 확인 서명이 있음** | 지지 | +3 |\n", + "| **대장·현물·확인서 수량이 일치함** | 지지 | +3 |\n", + "| **구두 보고 후 사후에 작성됨** | 반대 | -3 |\n", + "| **출처불명 장비를 만지거나 작동한 기록이 있음** | 반대 | -5 |\n", + "\n", + "나. 작성 서식 1 - 임시 처리 보고서(E4 U-2 또는 E5 U-4 중 1건을 작성한다)\n", + "\n", + "| **기록 번호·작성 시각** | |\n", + "| --- | --- |\n", + "\n", + "| **발견 경위(누가, 언제, 어디서)** | |\n", + "| --- | --- |\n", + "\n", + "| **외관(만지지 않고 보이는 것만)** | |\n", + "| --- | --- |\n", + "\n", + "| **접촉·조작 여부(분해·작동·시험을 하지 않았다는 사실 포함)** | |\n", + "| --- | --- |\n", + "\n", + "| **격리 위치와 접근 통제 방법** | |\n", + "| --- | --- |\n", + "\n", + "| **보고 시각과 보고 대상(지휘계통, 전문기관)** | |\n", + "| --- | --- |\n", + "\n", + "| **인계 대기 상태와 사용금지 표지 부착 여부** | |\n", + "| --- | --- |\n", + "\n", + "| **미확인 사항(출처, 안전 여부는 전문기관 확인 전까지 미확인)** | |\n", + "| --- | --- |\n", + "\n", + "나. 작성 서식 2 - 정산 인정 근거 항목 목록(12개 이상)\n", + "\n", + "| **번호** | **근거 항목** | **방향(지지·반대·확인 불가)** | **가중치** | **확인 방법** |\n", + "| --- | --- | --- | --- | --- |\n", + "| **1** | | | | |\n", + "| **2** | | | | |\n", + "| **3** | | | | |\n", + "| **4** | | | | |\n", + "| **5** | | | | |\n", + "| **6** | | | | |\n", + "| **7** | | | | |\n", + "| **8** | | | | |\n", + "| **9** | | | | |\n", + "| **10** | | | | |\n", + "| **11** | | | | |\n", + "| **12** | | | | |\n", + "\n", + "나. 작성 서식 3 - 산식과 규칙: 종합 점수 = (지지 근거 가중치 합) - (반대 근거 가중치 절댓값 합) 이다. 규칙 1 점수 ( )점 이상이면 정산 인정 검토, 규칙 2 반대 근거가 하나라도 있으면 ( ), 규칙 3 출처불명 장비의 접촉·조작 기록이 있으면 점수와 관계없이 ( ) 보고, 규칙 4 점수는 확인 서명을 대신하지 않고 ( )가 최종 확인한다.\n", + "\n", + "나. 작성 서식 4 - 사례 3종 적용(S1 E3 L-3 소모, S2 E2 L-2 유실, S3 E4 U-2 임시 처리)\n", + "\n", + "| **사례** | **지지 근거 합** | **반대 근거 합** | **종합 점수** | **분류(인정 검토·보류·추가 확인 요청)** | **판단 근거** |\n", + "| --- | --- | --- | --- | --- | --- |\n", + "| **S1** | | | | | |\n", + "| **S2** | | | | | |\n", + "| **S3** | | | | | |\n", + "\n", + "나. 작성 서식 5 - 레드팀 반박 기록\n", + "\n", + "| **번호** | **레드팀 반박(예: 현장에서는 확인자를 구할 수 없다)** | **작성 팀 대응** | **보완한 최소 증거 요건** |\n", + "| --- | --- | --- | --- |\n", + "| **1** | | | |\n", + "| **2** | | | |\n", + "| **3** | | | |\n", + "\n", + "다. 점검표\n", + "\n", + "| **점검 항목** | **확인** |\n", + "| --- | --- |\n", + "| 임시 처리 보고서에 발견 경위, 외관, 접촉·조작 여부, 격리 위치, 보고 시각, 인계 대기 상태를 모두 적었다 | ( ) |\n", + "| 보고서에 분해·작동·시험을 하지 않았다는 사실과 출처·안전 여부 미확인을 적었다 | ( ) |\n", + "| 근거를 12개 이상 항목화하고 방향과 가중치를 적었다 | ( ) |\n", + "| 사례 S1~S3의 점수와 분류를 기록했고 점수가 서명이나 사람의 확인을 대신하지 않는다고 명시했다 | ( ) |\n", + "| 레드팀 반박 3건에 대응했다 | ( ) |\n", + "\n", + "**\n", + "\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 05. 허위 소모 및 분실 은폐 방지를 위한 지휘관 확인 서명 및 감사 체크리스트 작성\n", + "\n", + "### 📋 미션 배경 및 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "허위 소모 방지를 위한 지휘관 확인 서명 및 감사 체크리스트를 작성한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **허위 소모나 분실 은폐를 막기 위한 지휘관 확인 서명 기준과 감사 체크리스트를 작성하고, 승인·보류 분기점과 책임 직책을 확정한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **증빙 완비 여부, 증빙 신뢰도, 대장·현물·확인서의 수량 일치, 출처불명 장비의 격리·인계 증빙, 확인 서명의 직무 분리(인수자와 확인자는 다른 사람) 순의 의사결정 트리로 묶고 분기점마다 조건과 책임 직책(가상)을 적는다 과정을 진행하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 5개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `sop/log06_approval_thresholds_sop.md` 형태의 결과물을 완성한다.\n", + "\n", + "### 📑 실습자료 및 작성 양식\n", + "허위 소모 및 분실 은폐 방지를 위한 지휘관 확인 서명 및 감사 체크리스트 작성**\n", + "\n", + "가. 제공 자료 1 - 경계선 사례 5종 [교육용가상자료]\n", + "\n", + "| **묶음** | **내용** | **애매한 이유** |\n", + "| --- | --- | --- |\n", + "| **B1** | L-3 소모 수량이 대장보다 1세트 많음, 확인 서명 있음 | 작은 차이이나 원인 불명 |\n", + "| **B2** | U-2 임시 영수증에서 인수자와 확인자가 같은 사람 | 직무 분리 위반 |\n", + "| **B3** | U-4 인계 확인서에 전문기관 접수 서명이 없음 | 인계 완료 여부 불확실 |\n", + "| **B4** | L-2 유실 보고가 유실 5일 뒤 작성됨 | 사후 작성, 시점 불명 |\n", + "| **B5** | 지휘관 부재로 부지휘관이 확인 서명함 | 위임 범위 불분명 |\n", + "\n", + "가. 제공 자료 2 - 책임 직책 예시(가상): 인수 담당, 기록 담당, 확인 담당, 중대장, 군수 감사관, 보안 담당, 전문기관 접수 담당, 법무 검토 담당\n", + "\n", + "나. 작성 서식 1 - 의사결정 트리(조건과 분기를 채운다)\n", + "\n", + "| **단계** | **확인 질문** | **예일 때** | **아니오일 때** |\n", + "| --- | --- | --- | --- |\n", + "| **1** | 필수 증빙(영수증·확인서)이 모두 있는가 | | |\n", + "| **2** | 증빙 신뢰도가 G3 이상이고 인수자와 확인자가 다른 사람인가 | | |\n", + "| **3** | 대장·현물·확인서의 수량이 일치하는가 | | |\n", + "| **4** | 출처불명 장비의 격리·보고·인계 증빙이 있고 접촉·조작 기록이 없는가 | | |\n", + "| **5** | 지휘관(또는 정식 위임자)의 확인 서명이 있는가 | | |\n", + "\n", + "나. 작성 서식 2 - 지휘관 확인 서명 기준과 감사 체크리스트의 분기점·책임 직책 매핑표\n", + "\n", + "| **분기점** | **조건(수치 또는 관찰 가능한 문장)** | **책임 직책(가상)** | **기록할 문서** |\n", + "| --- | --- | --- | --- |\n", + "| **T1** | | | |\n", + "| **T2** | | | |\n", + "| **T3** | | | |\n", + "| **T4** | | | |\n", + "\n", + "나. 작성 서식 3 - 모의 확인 기록지(B1~B5)\n", + "\n", + "| **묶음** | **판단(승인·보류 유지·추가 확인 요청)** | **근거** | **책임 직책** | **조원 합의(예·아니오)** |\n", + "| --- | --- | --- | --- | --- |\n", + "| **B1** | | | | |\n", + "| **B2** | | | | |\n", + "| **B3** | | | | |\n", + "| **B4** | | | | |\n", + "| **B5** | | | | |\n", + "\n", + "나. 작성 서식 4 - 긴급 신속 확인 규칙: 신속 확인에서도 줄이지 않는 확인은 ( ), 사후 감사 항목은 ( ), 사후 감사 기한은 ( )이다. 출처불명 장비의 격리·보고·인계 증빙은 신속 확인 대상에서 ( ).\n", + "\n", + "다. 점검표\n", + "\n", + "| **점검 항목** | **확인** |\n", + "| --- | --- |\n", + "| 분기점 4개의 조건을 수치 또는 관찰 가능한 문장으로 적었다 | ( ) |\n", + "| 분기점마다 책임 직책(가상)과 기록 문서를 연결했다 | ( ) |\n", + "| 경계선 사례 5종 모두에 판단과 근거를 기록했다 | ( ) |\n", + "| 인수자와 확인자의 직무 분리와 허위 소모·분실 은폐 방지 항목이 감사 체크리스트에 있다 | ( ) |\n", + "| 사람(지휘관)의 확인 서명 없이 인정되는 경로가 없고 지휘계통·보안 규정 우선 문구가 있다 | ( ) |\n", + "\n", + "**\n", + "\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 06. 군수 감사관 앞 사후 정산 서류 제출 및 대면 소명 절차 실습\n", + "\n", + "### 📋 미션 배경 및 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "군수 감사관 앞 사후 정산 서류 제출 및 대면 소명 절차를 실습한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **군수 감사관 앞에서 사후 정산 서류를 제출하고 대면 소명하는 절차를 역할극으로 실습하며, 소명 내용을 사실·판단·조치·미확인으로 구분하는 서식을 만든다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **소명자, 군수 감사관, 기록자, 관측자 역할을 정하고 가상 정산 서류 묶음(임시 영수증, 소모 확인서, 격리·인계 확인서)을 제출 수행하고 검증하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 5개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `templates/log06_aar_fact_action_template.md` 형태의 결과물을 완성한다.\n", + "\n", + "### 📑 실습자료 및 작성 양식\n", + "군수 감사관 앞 사후 정산 서류 제출 및 대면 소명 절차 실습**\n", + "\n", + "가. 제공 자료 1 - 가상 정산 서류 묶음(소명자 제출) [교육용가상자료]\n", + "\n", + "| **서류** | **내용** | **일부러 어긋난 점(감사관용, 소명자에게 비공개)** |\n", + "| --- | --- | --- |\n", + "| **영수증 R1** | U-2 인수 14:10, 격리 B-1 | 보고서의 발견 보고는 14:40 |\n", + "| **소모 확인서 R2** | L-3 소모 12세트 | 불출 대장은 10세트 |\n", + "| **격리 확인서 R3** | U-4 격리 B-2, 날짜만 기재 | 시각·위치 상세 없음 |\n", + "| **인계 확인서 R4** | U-1 전문기관 인계, 수량 2 | 영수증 수량 1 |\n", + "\n", + "가. 제공 자료 2 - 감사관 질문 예시와 혼동 유발 표현 예시(나머지는 조별로 6개 이상으로 늘린다)\n", + "\n", + "| **혼동 유발 표현** | **왜 혼동되는가** | **대체 표현 예** |\n", + "| --- | --- | --- |\n", + "| **폐기했음** | 누가 어떤 권한으로 처리했는지 알 수 없음 | 전문기관 (기관명)이 (일시)에 처리했고 확인서 (번호)가 있음 |\n", + "| **안전함** | 검사 결과 없이 단정 | ( ) 기준 외관 ( ), 전문기관 검사 결과 ( ) |\n", + "| **정상 소모** | 기준 없는 판단 | 소모 (수량), 사유 ( ), 확인자 ( ) |\n", + "\n", + "나. 작성 서식 1 - 소명 준비표(감사관이 물을 만한 질문 6개)\n", + "\n", + "| **번호** | **예상 질문** | **답변 근거 서류** | **근거가 없는 부분(미확인으로 답할 것)** |\n", + "| --- | --- | --- | --- |\n", + "| **1** | | | |\n", + "| **2** | | | |\n", + "| **3** | | | |\n", + "| **4** | | | |\n", + "| **5** | | | |\n", + "| **6** | | | |\n", + "\n", + "나. 작성 서식 2 - 소명 기록(관측자·기록자가 작성)\n", + "\n", + "| **순서** | **감사관 질문** | **소명자 답변** | **제시한 근거 서류** | **미확인 처리 여부** | **후속 조치** |\n", + "| --- | --- | --- | --- | --- | --- |\n", + "| **1** | | | | | |\n", + "| **2** | | | | | |\n", + "| **3** | | | | | |\n", + "| **4** | | | | | |\n", + "| **5** | | | | | |\n", + "| **6** | | | | | |\n", + "\n", + "나. 작성 서식 3 - 요약형 기록(소명 내용을 사실·판단·조치·미확인으로 나눈다)\n", + "\n", + "| **대상** | **사실** | **판단** | **조치** | **미확인** |\n", + "| --- | --- | --- | --- | --- |\n", + "| **R1** | | | | |\n", + "| **R2** | | | | |\n", + "| **R3** | | | | |\n", + "| **R4** | | | | |\n", + "\n", + "나. 작성 서식 4 - 상세형 기록(1건 선택)\n", + "\n", + "| **기록 번호** | |\n", + "| --- | --- |\n", + "\n", + "| **사실(시각, 수량, 위치, 증빙 번호, 신뢰도 단계)** | |\n", + "| --- | --- |\n", + "\n", + "| **판단(정산 인정 여부와 근거)** | |\n", + "| --- | --- |\n", + "\n", + "| **조치(누가, 언제까지)** | |\n", + "| --- | --- |\n", + "\n", + "| **미확인(확인 방법, 책임 직책)** | |\n", + "| --- | --- |\n", + "\n", + "나. 작성 서식 5 - 혼동 유발 표현 금지 목록(6개 이상)\n", + "\n", + "| **번호** | **금지 표현** | **대체 표현** |\n", + "| --- | --- | --- |\n", + "| **1** | | |\n", + "| **2** | | |\n", + "| **3** | | |\n", + "| **4** | | |\n", + "| **5** | | |\n", + "| **6** | | |\n", + "\n", + "다. 점검표\n", + "\n", + "| **점검 항목** | **확인** |\n", + "| --- | --- |\n", + "| 소명 준비표에 질문 6개와 답변 근거 서류를 적었다 | ( ) |\n", + "| 소명에서 근거 없는 내용을 지어내지 않고 미확인으로 답했다 | ( ) |\n", + "| 혼동 유발 표현 금지 목록을 6개 이상 만들고 대체 표현을 적었다 | ( ) |\n", + "| 기록에서 사실과 판단을 혼동한 문장이 0건이다 | ( ) |\n", + "| 요약형과 상세형이 같은 내용을 담고 있다 | ( ) |\n", + "\n", + "**\n", + "\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 07. 대량 물자 유실 및 기록 분실 모의 정산 스트레스 훈련\n", + "\n", + "### 📋 미션 배경 및 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "대량 물자 유실 모의 정산 스트레스 훈련을 진행한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **다수의 장비를 한꺼번에 인수하고 기록 일부를 잃어버린 가상 상황에서 격리와 정산 기록을 유지하는 스트레스 훈련을 하고, 대응 능력을 평가할 루브릭을 정의한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **통제관이 모의 카드(노획·출처불명 장비 6점 동시 인수, 기록지 일부 분실, 인수 담당 교대, 격리 구역 용량 초과, 전문기관 인계 24시간 지연, 감사 일정 앞당김)를 순서대로 주입하면 팀은 카드마다 접촉 통제와 격리를 유지하면서 분실 기록을 재구성 수행하고 검증하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 5개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `rubric/log06_evaluation_rubric.md` 형태의 결과물을 완성한다.\n", + "\n", + "### 📑 실습자료 및 작성 양식\n", + "대량 물자 유실 및 기록 분실 모의 정산 스트레스 훈련**\n", + "\n", + "가. 제공 자료 1 - 스트레스 모의 카드 T1~T6 [교육용가상자료] (통제관이 순서대로 주입)\n", + "\n", + "| **카드** | **주입 내용(가상)** | **확인할 것** |\n", + "| --- | --- | --- |\n", + "| **T1** | 출처불명 장비 6점(U-1~U-6)을 한꺼번에 인수 | 접촉 통제와 격리, 번호별 영수증 |\n", + "| **T2** | 기록지 일부 분실(U-3~U-5 영수증) | 분실 기록 재구성과 원본 아님 표시 |\n", + "| **T3** | 인수 담당 교대 | 인수인계 기록과 직무 분리 유지 |\n", + "| **T4** | 격리 구역 용량 초과 | 추가 격리 위치와 접근 통제 기록 |\n", + "| **T5** | 전문기관 인계가 24시간 지연 | 보고, 격리 연장 기록, 사용금지 표지 확인 |\n", + "| **T6** | 군수 감사가 하루 앞당겨짐 | 정산 보고서 우선순위와 미확인 사항 정리 |\n", + "\n", + "가. 제공 자료 2 - 루브릭 틀(5영역 x 4등급, 관찰 가능한 행동으로 작성)\n", + "\n", + "| **평가 영역** | **탁월** | **우수** | **보통** | **미흡** |\n", + "| --- | --- | --- | --- | --- |\n", + "| **격리·접촉 통제** | | | | |\n", + "| **기록 정확성** | | | | |\n", + "| **기록 재구성** | | | | |\n", + "| **보고 적시성** | | | | |\n", + "| **인계 증빙** | | | | |\n", + "\n", + "나. 작성 서식 1 - 카드별 조치 기록\n", + "\n", + "| **카드** | **조치(격리·보고·기록)** | **접촉·조작 위반 여부** | **남긴 증빙 번호** | **미확인·추정 사항** |\n", + "| --- | --- | --- | --- | --- |\n", + "| **T1** | | | | |\n", + "| **T2** | | | | |\n", + "| **T3** | | | | |\n", + "| **T4** | | | | |\n", + "| **T5** | | | | |\n", + "| **T6** | | | | |\n", + "\n", + "나. 작성 서식 2 - 분실 기록 재구성표(T2)\n", + "\n", + "| **분실 기록** | **재구성에 쓴 근거** | **재구성한 내용** | **원본 아님 표시** | **추정 사항(추정이라고 적는다)** |\n", + "| --- | --- | --- | --- | --- |\n", + "| **U-3 영수증** | | | | |\n", + "| **U-4 영수증** | | | | |\n", + "| **U-5 영수증** | | | | |\n", + "\n", + "나. 작성 서식 3 - 평가자 채점 일치표(평가자 2명이 상의 없이 채점)\n", + "\n", + "| **평가 영역** | **평가자 1 등급** | **평가자 2 등급** | **일치(예·아니오)** | **불일치 이유** |\n", + "| --- | --- | --- | --- | --- |\n", + "| **격리·접촉 통제** | | | | |\n", + "| **기록 정확성** | | | | |\n", + "| **기록 재구성** | | | | |\n", + "| **보고 적시성** | | | | |\n", + "| **인계 증빙** | | | | |\n", + "\n", + "나. 작성 서식 4 - 한계 지점: 기록 정확성이 가장 크게 떨어진 카드는 ( ), 이유는 ( )이다. 일치율은 ( ) / 5 = ( )%이다.\n", + "\n", + "다. 점검표\n", + "\n", + "| **점검 항목** | **확인** |\n", + "| --- | --- |\n", + "| 5개 영역 x 4단계 기준을 모두 관찰 가능한 행동으로 정의했다 | ( ) |\n", + "| 카드 T1~T6마다 조치와 남긴 증빙 번호를 기록했다 | ( ) |\n", + "| 접촉·조작 위반이 0건이다(위반이 있으면 사유와 시정 조치를 적었다) | ( ) |\n", + "| 재구성 기록마다 원본 아님 표시와 추정 사항이 있다 | ( ) |\n", + "| 평가자 2명이 상의 없이 채점하고 일치율을 계산했다 | ( ) |\n", + "\n", + "**\n", + "\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 08. AAR 회의를 통한 정산 누락 및 부정확한 소모 보고 원인 분석\n", + "\n", + "### 📋 미션 배경 및 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "AAR 회의를 통해 정산 누락 및 부정확한 보고 원인을 분석한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **가상 AAR 일지에서 정산 누락과 부정확한 소모 보고의 원인을 사람·절차·정보·환경으로 규명하고, 레드팀 비판으로 지침의 취약한 전제를 찾는다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **가상 AAR 일지 2건(정산 누락 사례, 소모 수량이 부정확하게 보고된 사례)을 시간순으로 읽고 계획과 실제의 차이를 적은 뒤 원인을 분류 수행하고 검증하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 5개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `reports/log06_redteam_critique_report.md` 형태의 결과물을 완성한다.\n", + "\n", + "### 📑 실습자료 및 작성 양식\n", + "AAR 회의를 통한 정산 누락 및 부정확한 소모 보고 원인 분석**\n", + "\n", + "가. 제공 자료 - 가상 AAR 일지 A·B [교육용가상자료]\n", + "\n", + "| **시각** | **AAR 일지 A (정산 누락 사례)** | **AAR 일지 B (부정확한 소모 보고 사례)** |\n", + "| --- | --- | --- |\n", + "| **D 14:00** | U-2·U-5 인수, 영수증은 나중에 쓰기로 함 | L-3 소모 시작, 소모 수량을 메모지에 적음 |\n", + "| **D 18:00** | 전투 지속으로 영수증 작성 지연 | 메모지 분실, 기억으로 12세트라고 구두 보고 |\n", + "| **D+1 09:00** | 정산 시작, U-5 영수증이 없음 | 소모 확인서를 구두 보고 기준으로 작성 |\n", + "| **D+1 13:00** | 격리 위치 기억이 서로 다름(B/C 구역) | 불출 대장은 10세트, 차이 원인 불명 |\n", + "| **D+1 17:00** | 정산 보고서에서 U-5 항목 공란 처리 | 소모 확인서 서명자가 현장에 없었음 |\n", + "\n", + "나. 작성 서식 1 - 계획과 실제 차이, 원인 분류(사람·절차·정보·환경, 개인 비난 표현 금지)\n", + "\n", + "| **번호** | **계획** | **실제** | **원인 분류** | **원인 설명(사실 중심)** | **개선안** |\n", + "| --- | --- | --- | --- | --- | --- |\n", + "| **1** | | | | | |\n", + "| **2** | | | | | |\n", + "| **3** | | | | | |\n", + "| **4** | | | | | |\n", + "| **5** | | | | | |\n", + "\n", + "나. 작성 서식 2 - 레드팀 쟁점·대응(5건 이상)\n", + "\n", + "| **번호** | **기준서의 취약한 전제** | **레드팀 비판** | **작성 팀 반박 또는 수정안** | **경고 문구** |\n", + "| --- | --- | --- | --- | --- |\n", + "| **1** | | | | |\n", + "| **2** | | | | |\n", + "| **3** | | | | |\n", + "| **4** | | | | |\n", + "| **5** | | | | |\n", + "| **6** | | | | |\n", + "\n", + "나. 작성 서식 3 - 반대 방향 위험 점검\n", + "\n", + "| **위험** | **설명** | **대응** |\n", + "| --- | --- | --- |\n", + "| **기록 부담으로 격리·보고가 늦어지는 위험** | | |\n", + "| **형식적 서명 증가** | | |\n", + "| **사후 작성 관행 확대** | | |\n", + "\n", + "나. 작성 서식 4 - 수정 반영 계획\n", + "\n", + "| **번호** | **수정할 문서와 위치** | **수정 내용** | **책임 직책(가상)** | **기한** |\n", + "| --- | --- | --- | --- | --- |\n", + "| **1** | | | | |\n", + "| **2** | | | | |\n", + "| **3** | | | | |\n", + "| **4** | | | | |\n", + "\n", + "다. 점검표\n", + "\n", + "| **점검 항목** | **확인** |\n", + "| --- | --- |\n", + "| 일지 A·B의 계획과 실제 차이를 5건 이상 적었다 | ( ) |\n", + "| 원인을 사람·절차·정보·환경으로 분류하고 개인 비난 표현이 없다 | ( ) |\n", + "| 레드팀 쟁점 5건 이상에 반박 또는 수정안을 100% 적었다 | ( ) |\n", + "| 취약한 전제에 경고 문구를 명시했다 | ( ) |\n", + "| 기록 부담 때문에 격리·보고가 늦어지는 반대 방향 위험을 점검했다 | ( ) |\n", + "\n", + "**\n", + "\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 09. 피드백을 반영한 야전 간이 증빙 양식 수정 및 작성 편의성 개선\n", + "\n", + "### 📋 미션 배경 및 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "피드백을 반영하여 야전 간이 증빙 양식을 수정 보완한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **AAR 피드백을 반영해 야전 간이 증빙 양식을 수정하고 작성 편의성을 높인 뒤, 개선 전후 양식이 같은 사례에서 일관된 판단을 내는지 검증하고 핵심 결심 사항을 1페이지로 요약한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **개선 전후 양식(임시 영수증, 격리 확인서, 인계 확인서, 소모 확인서, 사진 기록 카드)을 비교표에 정리하고 가상 재검증 사례 7건에 개선 전·후 양식을 각각 적용해 판단, 일치 여부, 작성 소요 시간을 기록 수행하고 검증하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 5개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `briefing/log06_commander_1page_brief.md(pdf)` 형태의 결과물을 완성한다.\n", + "\n", + "### 📑 실습자료 및 작성 양식\n", + "피드백을 반영한 야전 간이 증빙 양식 수정 및 작성 편의성 개선**\n", + "\n", + "가. 제공 자료 - 가상 재검증 사례 7건 [교육용가상자료]\n", + "\n", + "| **사례** | **내용** | **개선 전 판단** | **개선 후 판단(조별 작성)** |\n", + "| --- | --- | --- | --- |\n", + "| **V1** | U-2 영수증, 이중 서명·사진 기록 카드 있음 | 인정 | |\n", + "| **V2** | L-3 소모 확인서, 시각 누락 | 인정 | |\n", + "| **V3** | U-4 격리 확인서, 날짜만 기재 | 인정 | |\n", + "| **V4** | U-1 인계 확인서, 수량 불일치 | 인정 | |\n", + "| **V5** | L-1 소모, 구두 보고 후 사후 작성 | 인정 | |\n", + "| **V6** | U-2 인수자와 확인자가 같은 사람 | 인정 | |\n", + "| **V7** | L-2 유실 보고가 5일 뒤 작성 | 인정 | |\n", + "\n", + "나. 작성 서식 1 - 개선 전후 기준 비교표\n", + "\n", + "| **항목** | **개선 전 기준** | **개선 후 기준** | **바뀐 이유** | **근거 미션** |\n", + "| --- | --- | --- | --- | --- |\n", + "| **임시 영수증** | | | | |\n", + "| **격리·사용금지 확인서** | | | | |\n", + "| **인계 확인서** | | | | |\n", + "| **소모 확인서** | | | | |\n", + "| **사진 기록 카드** | | | | |\n", + "\n", + "나. 작성 서식 2 - 재검증 결과표\n", + "\n", + "| **사례** | **개선 전 판단** | **개선 후 판단** | **일치 여부** | **소요 시간(분)** |\n", + "| --- | --- | --- | --- | --- |\n", + "| **V1** | | | | |\n", + "| **V2** | | | | |\n", + "| **V3** | | | | |\n", + "| **V4** | | | | |\n", + "| **V5** | | | | |\n", + "| **V6** | | | | |\n", + "| **V7** | | | | |\n", + "\n", + "나. 작성 서식 3 - 1페이지 브리핑 BLUF 틀\n", + "\n", + "| **결론(첫 문장)** | |\n", + "| --- | --- |\n", + "\n", + "| **핵심 결심 사항** | |\n", + "| --- | --- |\n", + "\n", + "| **위험 요소(신호등 표시: 녹·황·적)** | |\n", + "| --- | --- |\n", + "\n", + "| **대안 3종(장단점 한 줄)** | |\n", + "| --- | --- |\n", + "\n", + "| **근거(미션·가상 자료)** | |\n", + "| --- | --- |\n", + "\n", + "나. 작성 서식 4 - 브리핑 리허설 기록\n", + "\n", + "| **항목** | **목표** | **결과** |\n", + "| --- | --- | --- |\n", + "| 발표 시간 | 3분 이내 | |\n", + "| 분량 | A4 1페이지 | |\n", + "| 질의 응답 | 3건 이상 답변 | |\n", + "| 결론 먼저 제시 | 첫 문장 | |\n", + "\n", + "다. 점검표\n", + "\n", + "| **점검 항목** | **확인** |\n", + "| --- | --- |\n", + "| 개선 전후 기준의 바뀐 조건과 이유를 모두 적었다 | ( ) |\n", + "| 재검증 7건 모두 개선 전후 판단을 비교했다 | ( ) |\n", + "| 브리핑이 결론부터 시작하고 A4 1페이지에 들어간다 | ( ) |\n", + "| 핵심 결심 사항, 위험 요소, 대안 3종이 모두 있다 | ( ) |\n", + "| 3분 안에 발표하고 질의에 답했다 | ( ) |\n", + "| 야전 간이 증빙 1건을 5분 안에 작성할 수 있는지 시간을 재서 확인했다 | ( ) |\n", + "| 서명 칸을 줄여도 직무 분리와 근거가 유지되는지 확인했다 | ( ) |\n", + "\n", + "**\n", + "\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 10. 야전 임시 소모·폐기 증빙 및 사후 정산 표준 업무 지침서 완성\n", + "\n", + "### 📋 미션 배경 및 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "야전 임시 소모·폐기 증빙 및 사후 정산 표준 업무 지침서를 완성한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **미션 1~9의 산출물을 통합해 야전 임시 소모·폐기 증빙 및 사후 정산 표준 업무 지침서를 완성하고, 노획·출처불명 장비의 격리·보고·인계 원칙을 반영해 모의 심의를 거친다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **산출물을 지침서 8개 장(목적과 적용 범위, 용어, 야전 임시 취급과 격리·보고·인계 원칙, 증빙 항목과 신뢰도, 사후 정산 보고와 소명, 감사 체크리스트와 책임, 훈련·평가, 개정 관리)과 부록에 배치하고 빠진 내용을 보완 수행하고 검증하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 5개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `manual/log06_standard_operational_manual.md` 형태의 결과물을 완성한다.\n", + "\n", + "### 📑 실습자료 및 작성 양식\n", + "야전 임시 소모·폐기 증빙 및 사후 정산 표준 업무 지침서 완성**\n", + "\n", + "가. 제공 자료 1 - 지침서 8개 장 구성과 가져올 결과물\n", + "\n", + "| **장** | **제목** | **포함 내용** | **가져올 결과물** |\n", + "| --- | --- | --- | --- |\n", + "| **1** | 목적과 적용 범위 | 목적, 적용 상황, 적용하지 않는 상황(분해·작동·시험), 전문기관 업무와의 경계 | M1 |\n", + "| **2** | 용어 정의 | 노획·출처불명 장비, 격리, 사용금지, 인계, 임시 영수증, 증빙 신뢰도 | M1~M3 |\n", + "| **3** | 야전 임시 취급과 격리·보고·인계 원칙 | 발견~인계 6단계, 접촉·조작 금지, 위험 징후 보고 | M1·M4 |\n", + "| **4** | 증빙 항목과 신뢰도 | 필수 항목표, G1~G6, 사진 기록 카드, 교차 확인 규칙 | M2·M9 |\n", + "| **5** | 사후 정산 보고와 소명 | 표준 보고 양식, 기록 공백, 대면 소명, 표현 규칙 | M3·M6 |\n", + "| **6** | 감사 체크리스트와 책임 | 지휘관 확인 서명, 직무 분리, 분기점, 책임 직책, 사후 감사 | M4·M5 |\n", + "| **7** | 훈련·평가 | 스트레스 훈련, 루브릭, 분실 기록 재구성, AAR, 레드팀 | M7·M8 |\n", + "| **8** | 개정 관리 | 연간 개정 주기, 의견 수렴 창구, 책임 직책, 이력 | M8·M9 |\n", + "\n", + "가. 제공 자료 2 - 모의 심의 역할 카드\n", + "\n", + "| **역할** | **검토 질문** |\n", + "| --- | --- |\n", + "| **교리 역할** | 증빙 항목과 신뢰도 단계가 일관되는가? 신병도 이해할 용어인가? |\n", + "| **법무 역할** | '노획·출처불명 장비는 접촉·조작하지 않고 격리·보고·전문기관 인계'가 드러나는가? 노획품 처리·귀속 쟁점이 법무 검토로 연계되는가? |\n", + "| **작전 역할** | 현장에서 5분 안에 간이 증빙을 쓸 수 있는가? 기록 때문에 격리·보고가 늦어지지 않는가? |\n", + "\n", + "나. 작성 서식 1 - 근거 표기 점검표(지침서 조항마다 작성)\n", + "\n", + "| **조항** | **기준 내용 요약** | **근거(미션·가상 자료)** | **표기 여부** |\n", + "| --- | --- | --- | --- |\n", + "| **1-1** | | | |\n", + "| **2-1** | | | |\n", + "| **3-1** | | | |\n", + "| **4-1** | | | |\n", + "| **5-1** | | | |\n", + "| **6-1** | | | |\n", + "| **7-1** | | | |\n", + "| **8-1** | | | |\n", + "\n", + "나. 작성 서식 2 - 모의 심의 지적 사항 반영표\n", + "\n", + "| **번호** | **역할** | **지적 사항** | **반영 내용** | **반영 위치(장·조)** |\n", + "| --- | --- | --- | --- | --- |\n", + "| **1** | | | | |\n", + "| **2** | | | | |\n", + "| **3** | | | | |\n", + "| **4** | | | | |\n", + "| **5** | | | | |\n", + "| **6** | | | | |\n", + "\n", + "나. 작성 서식 3 - 개정 관리: 개정 주기 ( ), 의견 수렴 창구와 방법 ( ), 개정 책임 직책(가상) ( ), 개정 이력 기록 위치 ( )\n", + "\n", + "다. 점검표\n", + "\n", + "| **점검 항목** | **확인** |\n", + "| --- | --- |\n", + "| 8개 장과 부록을 모두 완성했다 | ( ) |\n", + "| 모든 기준 조항에 근거(미션·가상 자료)를 표기했다 | ( ) |\n", + "| 교리·법무·작전 역할의 지적 사항을 반영하고 반영 위치를 적었다 | ( ) |\n", + "| '노획·출처불명 장비는 접촉·조작·분해·전원 연결·시험을 하지 않고 격리·보고하며 전문기관에 인계한다'는 문구가 1장과 3장에 있다 | ( ) |\n", + "| '최종 판단은 사람(지휘관·전문기관)이 한다'는 문구가 1장과 6장에 있다 | ( ) |\n", + "| 연간 개정 주기와 실무 의견 수렴 창구를 정했다 | ( ) |\n", + "| 실제 부대·노획품·장비 제원을 쓰지 않았고 분해·작동·무력화·현장 파기 방법을 쓰지 않았다 | ( ) |\n", + "\n", + "**4. 평가 채점표**\n", + "\n", + "미션별 평가 기준은 시나리오의 평가 항목과 같다. 평가자는 기준 충족 여부를 PASS/FAIL로 표시하고 FAIL이면 사유를 비고에 적는다.\n", + "\n", + "| **미션** | **구분** | **채점 기준** | **판정** | **비고** |\n", + "| --- | --- | --- | --- | --- |\n", + "| **M1** | 필수 | 임시 취급 실태 분석·문서 골격: 가상 사례·문헌 5건 이상을 검토해 6개 단계별 문제를 표로 정리했는가, 출처불명 장비는 접촉·조작하지 않고 격리·보고·인계한다는 원칙이 분석에 반영되었는가, 지침서 목차(8개 장) 골격을 100% 정의하고 지휘관 결심 의제 순으로 배열했는가. | PASS / FAIL | |\n", + "| **M2** | 필수 | 증빙 필수 항목·신뢰도 6단계: 신뢰도 평가 기준 6단계를 완비하고 평가 지표(증빙 필수 항목) 10개 이상을 정의했는가, 가상 증빙 5종에 대한 조원 간 등급 일치가 4건 이상인가, 작성자 단독 기록 의존 경고와 교차 확인 필수 규칙을 명시했는가. | PASS / FAIL | |\n", + "| **M3** | 필수 | 표준 보고 양식·불확실성: 관측 공백(기록 공백) 요인 8개 이상을 식별하고 영향도 매트릭스를 도출했는가, 어긋난 기록 4건에 원인 후보와 확인 방법을 적었는가, 표준 보고 양식의 항목과 기재 요령을 정의하고 판단 한계와 대체 확인 수단을 명시했는가. | PASS / FAIL | |\n", + "| **M4** | 선택 | 보고서 작성·근거 가중치 산식: 정산 인정(귀속) 판단 기준을 12개 이상 항목화하고 근거 가중치 산식을 100% 정립했는가, 임시 처리 보고서에 분해·작동·시험을 하지 않았다는 사실과 격리·보고·인계 대기 정보를 기록했는가, 출처를 단정하지 않는 최소 증거 요건을 설정하고 레드팀 반박에 대응했는가. | PASS / FAIL | |\n", + "| **M5** | 필수 | 감사 체크리스트·분기점·책임 매핑: 승인/보류 조건 분기점(Threshold)을 100% 명시하고 책임자 직책 매핑을 완료했는가, 경계선 사례 5종 모두에 판단과 근거를 기록했는가, 지휘계통·보안 규정 준수와 사람의 최종 서명을 지키고 긴급 신속 확인과 사후 감사 규정을 보완했는가. | PASS / FAIL | |\n", + "| **M6** | 선택 | 소명 절차·기록 서식: 기록 분류 규칙을 100% 표준화하고 혼동 유발 표현 금지 가이드를 6개 이상 포함했는가, 소명 기록에서 사실과 판단의 혼동이 0건이고 근거 없는 답변이 없는가, 요약형·상세형 듀얼 템플릿을 구성했는가. | PASS / FAIL | |\n", + "| **M7** | 필수 | 대량 유실·기록 분실 훈련·루브릭: 평가 영역 5개와 등급 4단계(탁월·우수·보통·미흡) 기준 정의율 100%인가, 카드 6장 모두에 조치 기록이 있고 접촉·조작 위반이 0건인가, 재구성 기록에 원본 아님 표시가 있는가, 평가자 2명 채점 일치율 80% 이상인가. | PASS / FAIL | |\n", + "| **M8** | 선택 | 정산 누락 원인 분석·레드팀 비판: 도출된 비판적 쟁점 5건 이상에 대해 반박 및 수정 대안을 100% 수립했는가, 취약한 전제(Assumptions)에 경고 문구를 명시했는가, 기록 부담 때문에 격리와 보고가 늦어지는 반대 방향 위험을 점검했는가. | PASS / FAIL | |\n", + "| **M9** | 필수 | 간이 증빙 양식 개선·1페이지 브리핑: 핵심 결심 사항, 위험 요소, 대안 3종이 1페이지(A4) 안에 완결되는가, 재검증 7건의 개선 전후 비교를 마치고 간이 증빙 1건 작성이 5분 이내인가, BLUF 준수(결론 먼저, 3분 이내 발표)와 신호등 표시가 있는가. | PASS / FAIL | |\n", + "| **M10** | 필수 | 표준 지침서 통합 완성: 표준 지침서 총 8개 장 및 부록을 100% 완성하고 모든 기준 조항에 근거(미션·가상 자료) 출처를 표기했는가, 모의 심의 지적 사항을 반영하고 연간 개정 주기와 실무 의견 수렴 창구를 두었는가, '노획·출처불명 장비는 접촉·조작하지 않고 격리·보고·전문기관 인계한다'는 문구를 명시했는가. | PASS / FAIL | |\n", + "\n", + "| **판정 기준** | 필수 7개 미션(M1·M2·M3·M5·M7·M9·M10)이 모두 PASS여야 최종 PASS로 인정한다. 선택 미션(M4·M6·M8)은 가점·보완 의견에 반영한다. |\n", + "| --- | --- |\n", + "\n", + "| **공통 감점** | 실제 부대명·노획품·장비 제원·실존 인물 정보를 사용한 경우, 부대 자료를 외부 AI 서비스에 입력한 경우 해당 미션은 FAIL로 처리한다. |\n", + "| --- | --- |\n", + "\n", + "| **주제 안전 기준** | 출처불명 장비의 분해·작동·충전·연결·시험·현장 파기 절차를 쓰거나, 접촉·조작을 허용하는 절차가 있거나, 지침서에 격리·보고·전문기관 인계 원칙과 사람의 최종 판단 문구가 없으면 M3·M5·M10은 FAIL로 처리한다. |\n", + "| --- | --- |\n", + "\n", + "| **최종 판정 결과** | ( PASS / FAIL ) 평가자 서명: ( ) |\n", + "| --- | --- |\n", + "\n", + "| 안전 원칙: 본 자료의 등급 기준, 확인 권한 구분, 의사결정 트리는 학습용 가상 기준이다. 노획·출처불명 장비는 접촉·조작·분해·전원 연결·시험을 하지 않고 격리·보고하며 전문기관에 인계하고, 최종 판단은 사람(지휘관·전문기관)이 내린다. 실제 의심 물체를 만지거나 옮기지 않으며 실제 부대 자료를 입력하거나 부대 내에서 촬영하지 않는다. |\n", + "| --- |\n", + "\n" + ] + } + ], + "metadata": { + "colab": { + "provenance": [] + }, + "kernelspec": { + "display_name": "Python 3", + "name": "python3" + }, + "language_info": { + "name": "python" + } + }, + "nbformat": 4, + "nbformat_minor": 0 +} \ No newline at end of file diff --git a/uploads/LOG07/LOG07_workbook.ipynb b/uploads/LOG07/LOG07_workbook.ipynb new file mode 100644 index 0000000..de8ec4f --- /dev/null +++ b/uploads/LOG07/LOG07_workbook.ipynb @@ -0,0 +1,820 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# 📋 [LOG07] 보급요청-승인-불출-수령 증빙 체인 모의훈련\n", + "### 실습 워크북 & SOP 가이드 (Google Colab / Jupyter)\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/LOG07/LOG07_workbook.ipynb)\n", + "\n", + "---\n", + "\n", + "## 📌 1. 프로젝트 개요 및 배경\n", + "\n", + "| 항목 | 내용 |\n", + "| :--- | :--- |\n", + "| **과정 분류** | 군수·보급 (Level 2) |\n", + "| **학습 소요 시간** | 10개 미션 (5일 / 총 27.0시간) |\n", + "| **실습 유형** | 작전절차(SOP), 체크리스트, 모의훈련, AAR 사후검토 실습 |\n", + "\n", + "### 📖 프로젝트 목표\n", + "러시아-우크라이나 전쟁에서는 탄약·유류·식량이 여러 보급 거점과 수송 수단을 거쳐 전선까지 이동하면서, 요청에서 수령까지의 기록이 중간에 끊기거나 수량이 맞지 않아 보급 누락·중복 불출·책임 소재 불명확 문제가 생길 수 있다는 점이 부각되었다. 지원 물자가 늘어날수록 '누가 요청하고 누가 승인했으며, 언제 누구에게 넘겼는가'를 증빙으로 추적하는 능력이 전투 지속능력과 직결된다. 본 프로젝트에서는 교육용 가상 부대와 가상 보급 요청서·불출증·수령증을 사용해 보급요청-승인-불출-수령 증빙 체인을 분석하고, 단계별 검증 체크리스트와 인계 책임 분계선을 정한 뒤, 1차 모의훈련, 오류 주입 훈련, 인계 실습, 통신 두절·야간 스트레스 훈련, AAR, 재훈련을 차례로 수행한다. 최종 목표는 어느 단계에서 문제가 생겨도 증빙으로 원인과 책임을 추적할 수 있는 표준 실무 훈련 지침서를 완성하는 것이다.\n", + "\n", + "> **⚠️ [교육용 가상 자료 원칙]**\n", + "> - 본 워크북의 모든 자료는 안전 규정에 따라 **교육용 가상 자료**로 구성되어 있습니다.\n", + "> - 실제 군사 기밀이나 부대 세부 인적·작전 정보는 포함되지 않습니다.\n", + "\n", + "---\n", + "\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 01. 야전 군수 보급 프로세스(요청-승인-불출-수령) 및 필수 증빙 문서 체계 분석\n", + "\n", + "### 📋 미션 배경 및 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "야전 군수 보급 프로세스(요청-승인-불출-수령) 및 증빙 문서 체계를 분석한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **야전 보급의 요청-승인-불출-수령 4단계와 각 단계에서 생기는 증빙 문서를 분석해, 훈련에서 확인할 현장 체크 항목과 역할 분담을 정의한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **실습자료의 가상 보급 흐름표를 보고 단계별 담당자, 입력 증빙, 산출 증빙, 확인 사항 정리하고 규격화하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 5개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `docs/log07_baseline_assessment.md 및 훈련 프로파일` 형태의 결과물을 완성한다.\n", + "\n", + "### 📑 실습자료 및 작성 양식\n", + "야전 군수 보급 프로세스(요청-승인-불출-수령) 및 필수 증빙 문서 체계 분석**\n", + "\n", + "[자료 1-1] 가상 보급 흐름표 [교육용가상자료]\n", + "\n", + "| **단계** | **담당** | **입력 증빙** | **산출 증빙** | **주요 확인 사항** |\n", + "| --- | --- | --- | --- | --- |\n", + "| **1. 요청** | 중대 보급담당 | 소모 현황 메모 | 보급 청구서(청구번호) | 품목코드·수량·필요 시각·요청 사유 |\n", + "| **2. 승인** | 대대 군수장교 | 보급 청구서 | 승인란 서명·승인번호 | 재고·우선순위·승인 권한 범위 |\n", + "| **3. 불출** | 보급소 담당 | 승인된 청구서 | 불출증, 적재 목록 | 승인 수량과 불출 수량 일치, 로트번호 |\n", + "| **4. 수송** | 수송반 | 불출증, 적재 목록 | 수송 인계서(출발·도착 시각) | 봉인 상태, 적재 수량, 경로 지연 사유 |\n", + "| **5. 수령** | 중대 실수령자 | 수송 인계서 | 수령증, 사진 기록 카드 | 실물 수량·상태 검수, 서명 |\n", + "| **6. 등록** | 대대 군수과 | 수령증 | 가상 전산 등록 기록 | 청구-불출-수령 수량 3자 일치 |\n", + "\n", + "[서식 1-2] 역할 분담 매트릭스 (R 수행 / A 승인 / C 협조 / I 통보, 모든 칸 기재)\n", + "\n", + "| **단계** | **요청자** | **승인자** | **불출자** | **수송자** | **수령자** |\n", + "| --- | --- | --- | --- | --- | --- |\n", + "| **1. 요청** | | | | | |\n", + "| **2. 승인** | | | | | |\n", + "| **3. 불출** | | | | | |\n", + "| **4. 수송** | | | | | |\n", + "| **5. 수령** | | | | | |\n", + "| **6. 등록** | | | | | |\n", + "\n", + "[서식 1-3] 현장 체크 항목 정의서 (15개 이상, 필요 시 행 추가)\n", + "\n", + "| **번호** | **단계** | **체크 항목(눈으로 확인할 것)** | **확인 방법** | **담당 역할** |\n", + "| --- | --- | --- | --- | --- |\n", + "| **1** | | | | |\n", + "| **2** | | | | |\n", + "| **3** | | | | |\n", + "| **4** | | | | |\n", + "| **5** | | | | |\n", + "| **6** | | | | |\n", + "| **7** | | | | |\n", + "| **8** | | | | |\n", + "| **9** | | | | |\n", + "| **10** | | | | |\n", + "| **11** | | | | |\n", + "| **12** | | | | |\n", + "| **13** | | | | |\n", + "| **14** | | | | |\n", + "| **15** | | | | |\n", + "\n", + "[서식 1-4] 훈련 프로파일\n", + "\n", + "| **구분** | **야전 부대** | **기지 부대** | **의무 부대** |\n", + "| --- | --- | --- | --- |\n", + "| **훈련 환경** | | | |\n", + "| **특수 조건(야간·통신·보관)** | | | |\n", + "| **추가 체크 항목** | | | |\n", + "| **참가 인원·역할** | | | |\n", + "\n", + "| **점검 항목** | **확인** |\n", + "| --- | --- |\n", + "| 6개 단계 모두 입력·산출 증빙이 정리되었다 | ( ) |\n", + "| 역할 매트릭스 30칸이 모두 채워졌다 | ( ) |\n", + "| 각 단계에 승인(A)이 1명씩만 있다 | ( ) |\n", + "| 현장 체크 항목이 15개 이상이다 | ( ) |\n", + "| 부대 유형별 환경 변수가 적혔다 | ( ) |\n", + "\n", + "**\n", + "\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 02. 단계별 증빙(청구서, 불출증, 수령증, 사진/서명) 검증 체크리스트 작성\n", + "\n", + "### 📋 미션 배경 및 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "청구서, 불출증, 수령증 등 단계별 증빙 검증 체크리스트를 작성한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **청구서, 불출증, 수령증, 사진 기록·서명 등 단계별 증빙을 현장에서 빠르게 검증할 수 있는 체크리스트와 SOP 흐름도를 작성한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **가상 증빙 견본(청구서·불출증·수령증)의 필수 기재 항목을 표시하고, 단계별로 '무엇을, 어떻게 확인하는가'를 체크리스트 한 줄씩으로 적는다 과정을 진행하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 5개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `sop/log07_field_checklist.md 및 SOP 흐름도` 형태의 결과물을 완성한다.\n", + "\n", + "### 📑 실습자료 및 작성 양식\n", + "단계별 증빙(청구서, 불출증, 수령증, 사진/서명) 검증 체크리스트 작성**\n", + "\n", + "[자료 2-1] 가상 증빙 견본 [교육용가상자료]\n", + "\n", + "| **서식** | **기재 내용(견본)** |\n", + "| --- | --- |\n", + "| **보급 청구서** | 청구번호 가람-D-017 / 요청 부대 가상 가람중대 / 품목 R-301 전투식량 상자 / 수량 12 / 필요 시각 D일 21:00 / 사유 야간 경계 인원 추가 / 요청자 보급담당 하사 김가온(가명) 서명 / 승인란: 승인번호 누리-승-044, 군수장교 대위 이도윤(가명) 서명, 19:05 |\n", + "| **불출증** | 불출번호 보-D-102 / 근거 청구번호 가람-D-017 / 품목 R-301 / 불출 수량 12 / 로트 R-2604-B / 불출 시각 19:40 / 불출자 보급소 중사 박새론(가명) 서명 / 인수자(수송) 상병 최다온(가명) 서명 |\n", + "| **수송 인계서** | 차량 가상-3호 / 출발 19:55 / 도착 20:35 / 봉인번호 S-5521 이상 없음 / 적재 12상자 / 인계자 최다온 서명 / 인수자 중대 실수령자 병장 정하람(가명) 서명 |\n", + "| **수령증** | 근거 불출번호 보-D-102 / 수령 수량 12 / 상태 양호 / 수령 시각 20:40 / 수령자 정하람 서명 / 확인 보급담당 김가온 서명 / 사진 기록 카드 P-017(상자 12개, 봉인 번호, 20:41) |\n", + "\n", + "[서식 2-2] 단계별 증빙 검증 체크리스트\n", + "\n", + "| **단계** | **확인 항목** | **확인 방법** | **일반 모드** | **긴급 모드** | **결과** |\n", + "| --- | --- | --- | --- | --- | --- |\n", + "| **요청** | | | ( ) | ( ) | |\n", + "| **요청** | | | ( ) | ( ) | |\n", + "| **승인** | | | ( ) | ( ) | |\n", + "| **승인** | | | ( ) | ( ) | |\n", + "| **불출** | | | ( ) | ( ) | |\n", + "| **불출** | | | ( ) | ( ) | |\n", + "| **수송** | | | ( ) | ( ) | |\n", + "| **수송** | | | ( ) | ( ) | |\n", + "| **수령** | | | ( ) | ( ) | |\n", + "| **수령** | | | ( ) | ( ) | |\n", + "| **안전** | 탄약·유류 취급 안전 조치 | | ( ) | ( ) | |\n", + "| **보안** | 증빙 문서 보관·반출 통제 | | ( ) | ( ) | |\n", + "\n", + "[서식 2-3] 교차 검토표 (다른 조가 작성)\n", + "\n", + "| **번호** | **해당 항목** | **지적 유형(누락/모호/중복)** | **지적 내용** | **수정 결과** |\n", + "| --- | --- | --- | --- | --- |\n", + "| **1** | | | | |\n", + "| **2** | | | | |\n", + "| **3** | | | | |\n", + "| **4** | | | | |\n", + "| **5** | | | | |\n", + "\n", + "[서식 2-4] 휴대용 요약 카드(긴급 모드, 5~7개 항목)\n", + "\n", + "| **순서** | **반드시 확인할 것** | **사후 확인으로 미룰 것** |\n", + "| --- | --- | --- |\n", + "| **1** | | |\n", + "| **2** | | |\n", + "| **3** | | |\n", + "| **4** | | |\n", + "| **5** | | |\n", + "| **6** | | |\n", + "| **7** | | |\n", + "\n", + "| **점검 항목** | **확인** |\n", + "| --- | --- |\n", + "| 요청·승인·불출·수송·수령 단계마다 확인 방법이 구체적이다 | ( ) |\n", + "| 안전 조치와 보안 조치가 필수 항목으로 들어갔다 | ( ) |\n", + "| 긴급 모드에서 빠진 항목의 사후 확인 시점이 정해졌다 | ( ) |\n", + "| 교차 검토 지적 사항을 모두 수정했다 | ( ) |\n", + "\n", + "**\n", + "\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 03. 보급관, 수송관, 실수령자 간 인계 절차 및 책임 분계선 정의\n", + "\n", + "### 📋 미션 배경 및 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "보급관, 수송관, 실수령자 간 인계 절차 및 책임 분계선을 정의한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **보급관, 수송관, 실수령자 사이에서 물자와 증빙이 넘어가는 인계 지점과 책임 분계선을 정하고, 이를 시험할 시간대별 훈련 시나리오(MSEL)를 만든다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **창고 출고, 수송 차량 적재, 중대 도착, 수령 확인의 인계 지점마다 '누가 서명해야 책임이 넘어가는가'를 정의하고 인계 전·후 책임자를 표로 정리하고 규격화하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `scenarios/log07_msel_scenario.json(md)` 형태의 결과물을 완성한다.\n", + "\n", + "### 📑 실습자료 및 작성 양식\n", + "보급관, 수송관, 실수령자 간 인계 절차 및 책임 분계선 정의**\n", + "\n", + "[자료 3-1] 가상 인계 지점 [교육용가상자료]\n", + "\n", + "| **인계 지점** | **장소(가상)** | **인계자 → 인수자** | **넘어가는 것** |\n", + "| --- | --- | --- | --- |\n", + "| **지점 A** | 보급소 출고대 | 보급소 담당 → 수송반 선탑자 | 물자, 불출증, 적재 목록 |\n", + "| **지점 B** | 차량 출발 지점 | 수송반 선탑자 → 운전병(수송 중 공동 책임) | 봉인 상태, 출발 시각 기록 |\n", + "| **지점 C** | 중대 하차 지점 | 수송반 선탑자 → 중대 실수령자 | 물자, 수송 인계서 |\n", + "| **지점 D** | 중대 보관소 | 중대 실수령자 → 보급담당(→ 군수과 등록) | 수령증, 사진 기록 카드 |\n", + "\n", + "[서식 3-2] 인계 지점별 책임 분계표\n", + "\n", + "| **인계 지점** | **인계 전 책임자** | **인계 후 책임자** | **책임 이전 조건(서명·확인)** | **이상 시 조치** |\n", + "| --- | --- | --- | --- | --- |\n", + "| **지점 A** | | | | |\n", + "| **지점 B** | | | | |\n", + "| **지점 C** | | | | |\n", + "| **지점 D** | | | | |\n", + "\n", + "[자료 3-3] MSEL 작성 예시 (4건) [교육용가상자료]\n", + "\n", + "| **시각** | **구분** | **주입 사건** | **전달 수단·대상** | **기대 행동** |\n", + "| --- | --- | --- | --- | --- |\n", + "| **19:00** | 정상 | 가람중대 식량 12상자 청구서 접수 | 청구서 → 군수장교 | 승인 권한 확인 후 승인 |\n", + "| **19:40** | 비정상 | 불출 시 재고 10상자뿐(2상자 부족) | 보급소 → 군수장교 | 부분 불출 승인·잔여 수량 기록 |\n", + "| **20:10** | 비정상 | 수송 차량 20분 지연 무전 | 수송반 → 중대 | 지연 사유·예상 도착 시각 기록 |\n", + "| **20:40** | 정상 | 중대 도착, 봉인 이상 없음 | 수송반 → 실수령자 | 수량 검수 후 수령증 서명 |\n", + "\n", + "[서식 3-4] MSEL 시나리오 (10건 이상, 정상 6 : 비정상 4)\n", + "\n", + "| **번호** | **시각** | **구분** | **주입 사건** | **주입 메시지(원문)** | **기대 행동** |\n", + "| --- | --- | --- | --- | --- | --- |\n", + "| **1** | | | | | |\n", + "| **2** | | | | | |\n", + "| **3** | | | | | |\n", + "| **4** | | | | | |\n", + "| **5** | | | | | |\n", + "| **6** | | | | | |\n", + "| **7** | | | | | |\n", + "| **8** | | | | | |\n", + "| **9** | | | | | |\n", + "| **10** | | | | | |\n", + "\n", + "정상 ( )건 : 비정상 ( )건 = ( ) : ( ) 워크스루 수정 사항: ( )\n", + "\n", + "| **점검 항목** | **확인** |\n", + "| --- | --- |\n", + "| 인계 지점 4곳의 책임 이전 조건이 서명·확인으로 명확하다 | ( ) |\n", + "| 주입 사건이 10건 이상이고 시간 순서에 모순이 없다 | ( ) |\n", + "| 정상:비정상 비율이 6:4이다 | ( ) |\n", + "| 주입 메시지 원문이 그대로 읽어 줄 수 있게 적혔다 | ( ) |\n", + "\n", + "**\n", + "\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 04. 가상 물자(탄약, 유류, 식량) 보급 요청 및 불출 1차 모의훈련(Dry-run)\n", + "\n", + "### 📋 미션 배경 및 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "가상 물자 보급 요청 및 불출 1차 모의훈련(Dry-run)을 수행한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **가상 탄약·유류·식량 보급 요청 3건을 처음부터 끝까지 처리하는 1차 모의훈련(Dry-run)으로 체크리스트와 절차의 실제 작동 여부를 확인한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **조원이 요청자·승인자·불출자·수송자·수령자 역할을 맡고, 관측자가 시각과 SOP 단계 준수 여부를 기록 수행하고 검증하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 5개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `logs/log07_dryrun_execution_log.md 및 작성된 체크리스트` 형태의 결과물을 완성한다.\n", + "\n", + "### 📑 실습자료 및 작성 양식\n", + "가상 물자(탄약, 유류, 식량) 보급 요청 및 불출 1차 모의훈련(Dry-run)**\n", + "\n", + "[자료 4-1] 가상 보급 요청 3건 [교육용가상자료]\n", + "\n", + "| **요청** | **요청 부대** | **품목·수량** | **필요 시각** | **특이 사항** |\n", + "| --- | --- | --- | --- | --- |\n", + "| **요청 1** | 가상 가람중대 | 탄약-가 A-101 / 6상자 | D일 20:00 | 야간 사격 대기 인원 보충 |\n", + "| **요청 2** | 가상 나래중대 | 유류-나 F-201 / 경유 20L 용기 8개 | D일 20:30 | 발전기 가동용, 유류 취급 안전 확인 필요 |\n", + "| **요청 3** | 가상 가람중대 | 식량-다 R-301 / 12상자 | D일 21:00 | 교관 주입: 보급소 재고 10상자(2상자 부족) |\n", + "\n", + "[서식 4-2] 참여자별 수행 기록지 (관측자 작성, 요청별로 1장)\n", + "\n", + "| **SOP 단계** | **시작 시각** | **종료 시각** | **준수(O/X)** | **미준수·혼선 내용** | **담당** |\n", + "| --- | --- | --- | --- | --- | --- |\n", + "| **청구서 작성** | | | ( ) | | |\n", + "| **승인 권한 확인** | | | ( ) | | |\n", + "| **승인 서명** | | | ( ) | | |\n", + "| **불출 수량 대조** | | | ( ) | | |\n", + "| **적재·봉인** | | | ( ) | | |\n", + "| **수송 인계서 작성** | | | ( ) | | |\n", + "| **도착 인계** | | | ( ) | | |\n", + "| **실물 검수** | | | ( ) | | |\n", + "| **수령증 서명** | | | ( ) | | |\n", + "| **전산 등록 인계** | | | ( ) | | |\n", + "\n", + "[서식 4-3] 이상 상황 보고 양식\n", + "\n", + "| **항목** | **기재** |\n", + "| --- | --- |\n", + "| **이상 인지 시각** | |\n", + "| **보고 시각 / 지연(분)** | ( ) / ( )분 |\n", + "| **보고자 → 수신자** | |\n", + "| **상황(무엇이 다른가)** | |\n", + "| **현재 조치** | |\n", + "| **요청 사항** | |\n", + "\n", + "SOP 준수율 = 준수 단계 수 ( ) / 전체 단계 수 ( ) x 100 = ( )% 병목·혼선 구간: ( ), ( )\n", + "\n", + "| **점검 항목** | **확인** |\n", + "| --- | --- |\n", + "| 요청 3건이 모두 수령·등록 인계까지 처리되었다 | ( ) |\n", + "| 관측자가 수행자와 별도로 시각을 기록했다 | ( ) |\n", + "| SOP 준수율이 80% 이상이다 | ( ) |\n", + "| 이상 보고 지연이 3분 이내이다 | ( ) |\n", + "| 병목·혼선 구간이 2곳 이상 표시되었다 | ( ) |\n", + "\n", + "**\n", + "\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 05. 수량 불일치, 서명 누락, 배송 지연 등 증빙 체인 오류 상황 주입 훈련\n", + "\n", + "### 📋 미션 배경 및 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "수량 불일치, 서명 누락 등 증빙 체인 오류 주입 훈련을 진행한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **수량 불일치, 서명 누락, 배송 지연 등 증빙 체인 오류가 섞인 가상 증빙 묶음에서 오류를 찾아내고 교차검증으로 원인 단계를 특정한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **청구서·불출증·수령증·수송 기록이 한 묶음인 가상 증빙 12세트를 받아 같은 품목의 수량·시각·서명자를 서로 대조 수행하고 검증하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `docs/log07_cross_verification_sheet.md` 형태의 결과물을 완성한다.\n", + "\n", + "### 📑 실습자료 및 작성 양식\n", + "수량 불일치, 서명 누락, 배송 지연 등 증빙 체인 오류 상황 주입 훈련**\n", + "\n", + "[자료 5-1] 가상 증빙 12세트 요약 [교육용가상자료] (청구 → 불출 → 수송 적재 → 수령 순 수량, 시각, 서명)\n", + "\n", + "| **세트** | **품목** | **청구/승인** | **불출** | **적재** | **수령** | **시각 기록** | **서명·기타** |\n", + "| --- | --- | --- | --- | --- | --- | --- | --- |\n", + "| **S01** | R-301 | 12/12 | 12 | 12 | 12 | 승인 19:05 불출 19:40 수령 20:40 | 모두 서명 |\n", + "| **S02** | A-101 | 6/6 | 6 | 6 | 5 | 승인 18:50 불출 19:20 수령 20:05 | 수령증에 '이상 없음' 표기 |\n", + "| **S03** | F-201 | 8/8 | 8 | 8 | 8 | 승인 19:10 불출 19:35 수령 20:15 | 수령증 수령자 서명 없음 |\n", + "| **S04** | M-401 | 4/4 | 4 | 4 | 4 | 승인 19:30 불출 19:25 수령 20:10 | 모두 서명 |\n", + "| **S05** | R-301 | 10/10 | 10 | 10 | 10 | 승인 20:00 불출 20:20 수령 21:00 | 모두 서명 |\n", + "| **S06** | A-101 | 4/4 | 4 | 4 | 4 | 승인 20:05 불출 20:30 수령 23:40 | 지연 사유 기록 없음(예정 21:10) |\n", + "| **S07** | F-201 | 6/6 | 6 | 6 | 6 | 승인 20:10 불출 20:40 수령 21:25 | 모두 서명 |\n", + "| **S08** | R-301 | 8/6 | 8 | 8 | 8 | 승인 20:15 불출 20:45 수령 21:30 | 모두 서명 |\n", + "| **S09** | M-401 | 2/2 | 2 | 2 | 2 | 승인 20:20 불출 20:50 수령 21:20 | 불출자와 수령자가 같은 사람 |\n", + "| **S10** | A-101 | 3/3 | 3 | 3 | 3 | 승인 20:40 불출 21:05 수령 21:50 | 모두 서명 |\n", + "| **S11** | F-201 | 5/5 | 5 | 5 | 5 | 승인 21:00 불출 21:30 수령 22:10 | 수송 인계서 봉인번호 불일치(S-5530/S-5503) |\n", + "| **S12** | R-301 | 6/6 | 6 | 6 | 6 | 승인 21:10 불출 21:40 수령 22:20 | 모두 서명 |\n", + "\n", + "[서식 5-2] 교차검증 매트릭스\n", + "\n", + "| **세트** | **판정(정상/오류)** | **오류 유형** | **최초 발생 단계** | **확인 근거(대조한 두 기록)** | **보고 경로** |\n", + "| --- | --- | --- | --- | --- | --- |\n", + "| **S01** | | | | | |\n", + "| **S02** | | | | | |\n", + "| **S03** | | | | | |\n", + "| **S04** | | | | | |\n", + "| **S05** | | | | | |\n", + "| **S06** | | | | | |\n", + "| **S07** | | | | | |\n", + "| **S08** | | | | | |\n", + "| **S09** | | | | | |\n", + "| **S10** | | | | | |\n", + "| **S11** | | | | | |\n", + "| **S12** | | | | | |\n", + "\n", + "[서식 5-3] 결과 보고: 정답 세트 수 ( ) / 12 = 식별 성공률 ( )% (목표 85% 이상 = 11세트 이상)\n", + "유용·은폐 의심으로 분류한 세트와 이유: ( )\n", + "\n", + "| [교관용 정답 - 학습자 배포 전 분리]\n", + "정상: S01, S05, S07, S10, S12 (5세트)\n", + "오류: S02 수량 불일치(수송-수령 사이 1상자 차이인데 '이상 없음' 표기, 수령 단계) / S03 서명 누락(수령 단계) / S04 시각 역전(승인 전 불출, 불출 단계) / S06 배송 지연 미기록(수송 단계) / S08 승인 수량 초과 불출(승인 6, 불출 8, 불출 단계) / S09 직무 분리 위반(불출자=수령자, 유용 의심, 불출·수령 단계) / S11 봉인번호 불일치(수송 단계, 개봉 의심)\n", + "유용·은폐 의심으로 상급 보고: S02, S08, S09, S11. 단순 기재 실수로 보정 요청: S03, S04, S06. |\n", + "| --- |\n", + "\n", + "| **점검 항목** | **확인** |\n", + "| --- | --- |\n", + "| 12세트 모두 판정했다 | ( ) |\n", + "| 오류 세트마다 최초 발생 단계와 대조 근거를 적었다 | ( ) |\n", + "| 식별 성공률을 계산했다(목표 85% 이상) | ( ) |\n", + "| 유용·은폐 의심과 단순 실수의 보고 경로를 구분했다 | ( ) |\n", + "\n", + "**\n", + "\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 06. 현장 수령 검수 및 군수정보체계(DELIIS) 전산 등록 인계 실습\n", + "\n", + "### 📋 미션 배경 및 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "현장 수령 검수 및 전산 등록 인계 실습을 수행한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **현장에서 수령 물자를 검수하고 결과를 전산 등록 담당자에게 인계하는 절차를 실습해, 수령 사실이 기록으로 빠짐없이 이어지게 한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **가상 수령 물품 목록을 수령증과 대조해 수량·상태·유효기간을 검수하고 결과를 검수 기록표에 적는다 과정을 진행하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 5개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `reports/log07_handover_protocol.md 및 브리핑 녹취/기록지` 형태의 결과물을 완성한다.\n", + "\n", + "### 📑 실습자료 및 작성 양식\n", + "현장 수령 검수 및 군수정보체계(DELIIS) 전산 등록 인계 실습**\n", + "\n", + "[자료 6-1] 가상 수령 물품 목록 (수령증 기준, 실물 상태는 교관이 표지 카드로 제시) [교육용가상자료]\n", + "\n", + "| **번호** | **품목** | **수령증 수량** | **실물(표지 카드)** | **상태·유효기간(표지 카드)** |\n", + "| --- | --- | --- | --- | --- |\n", + "| **1** | R-301 전투식량 상자 | 12 | 12 | 양호 / 유효기간 D+300일 |\n", + "| **2** | F-201 경유 20L 용기 | 8 | 8 | 1개 뚜껑 봉인 훼손 |\n", + "| **3** | M-401 구급 키트 | 4 | 3 | 양호 / 유효기간 D+20일(임박) |\n", + "| **4** | A-101 소화기탄 상자 | 6 | 6 | 양호 / 로트 A-2511-C |\n", + "\n", + "[서식 6-2] 수령 검수 기록표\n", + "\n", + "| **번호** | **수량 일치(O/X)** | **상태 이상** | **유효기간 확인** | **조치(반품·보고·보관)** | **검수자** |\n", + "| --- | --- | --- | --- | --- | --- |\n", + "| **1** | | | | | |\n", + "| **2** | | | | | |\n", + "| **3** | | | | | |\n", + "| **4** | | | | | |\n", + "\n", + "[서식 6-3] 가상 전산 등록 서식 (실제 체계 화면 아님)\n", + "\n", + "| **등록 항목** | **입력값** |\n", + "| --- | --- |\n", + "| **근거 청구번호** | |\n", + "| **불출번호** | |\n", + "| **품목코드** | |\n", + "| **수령 수량** | |\n", + "| **차이 수량·사유** | |\n", + "| **수령 일시** | |\n", + "| **수령자·확인자** | |\n", + "| **후속 조치 번호** | |\n", + "\n", + "[서식 6-4] SBAR 인계 브리핑 기록지 (10분 이내)\n", + "\n", + "| **구분** | **내용** | **소요** |\n", + "| --- | --- | --- |\n", + "| **S 상황** | | |\n", + "| **B 배경** | | |\n", + "| **A 평가** | | |\n", + "| **R 요청** | | |\n", + "| **총 소요 / 인계자·인수자 서명** | | |\n", + "\n", + "[자료 6-5] 인수자 돌발 퀴즈 (인계 직후, 인수자 답변)\n", + "\n", + "| **번호** | **질문** | **인수자 답** |\n", + "| --- | --- | --- |\n", + "| **1** | 수량이 모자란 품목과 차이 수량은? | |\n", + "| **2** | 봉인이 훼손된 품목은 어떻게 처리 중인가? | |\n", + "| **3** | 유효기간이 임박한 품목과 우선 사용 조치는? | |\n", + "| **4** | 전산 등록에 남은 후속 조치 번호는 무엇인가? | |\n", + "| **5** | 차이 수량에 대해 누구에게 언제까지 보고해야 하는가? | |\n", + "\n", + "| [교관용 정답 - 학습자 배포 전 분리] 1) M-401 구급 키트 1개 부족 2) F-201 1개 봉인 훼손: 사용 보류·분리 보관 후 보고 3) M-401 유효기간 D+20일: 우선 사용 표시 4) 조별 작성 번호와 일치해야 정답 5) 대대 군수장교에게 당일 내 보고(가상 기준) |\n", + "| --- |\n", + "\n", + "[서식 6-6] 인계 품질 체크리스트\n", + "\n", + "| **점검 항목** | **확인** |\n", + "| --- | --- |\n", + "| 수량 차이·상태 이상·유효기간 임박이 모두 인계되었다 | ( ) |\n", + "| 전산 등록 서식 8개 항목이 모두 입력되었다 | ( ) |\n", + "| 인계가 10분 이내에 끝났다 | ( ) |\n", + "| 인수자 퀴즈 4/5 이상 정답 | ( ) |\n", + "| 인계자·인수자 상호 서명이 있다 | ( ) |\n", + "\n", + "**\n", + "\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 07. 통신 두절 및 야간 긴급 보급 모의 증빙 체인 유지 스트레스 훈련\n", + "\n", + "### 📋 미션 배경 및 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "통신 두절 및 야간 긴급 보급 모의 증빙 유지 스트레스 훈련을 실시한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **통신이 끊긴 야간에 긴급 보급 요청이 들어오는 상황에서도 비상 증빙 절차로 전환해 증빙 체인을 유지하는 능력을 기른다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **교관이 시간대별 주입 카드(통신 두절, 야간 긴급 요청, 승인권자 부재, 차량 고장 등)를 제시하면 팀은 1분 안에 비상 절차(수기 임시 증빙, 전령 전달, 사후 전산 등록)로 전환 수행하고 검증하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 5개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `logs/log07_stress_drill_results.md` 형태의 결과물을 완성한다.\n", + "\n", + "### 📑 실습자료 및 작성 양식\n", + "통신 두절 및 야간 긴급 보급 모의 증빙 체인 유지 스트레스 훈련**\n", + "\n", + "[자료 7-1] 시간대별 주입 카드 [교육용가상자료]\n", + "\n", + "| **주입** | **시각** | **주입 내용** | **기대 비상 조치** |\n", + "| --- | --- | --- | --- |\n", + "| **I-1** | T+00 | 대대 무전·전화 두절, 복구 예정 시각 미상 | 수기 임시 증빙 양식으로 전환, 전령 지정 |\n", + "| **I-2** | T+05 | 나래중대 전령 도착: 야간 경계용 식량 8상자 긴급 요청 | 수기 청구서 작성, 승인권자 확인 |\n", + "| **I-3** | T+10 | 군수장교 현장 순찰로 20분 부재 | 위임 기준에 따라 대행자 승인·기록 |\n", + "| **I-4** | T+18 | 수송 차량 전조등 고장, 야간 이동 제한 | 대체 차량·도보 운반 판단, 안전 통제 |\n", + "| **I-5** | T+25 | 유류 용기 1개 누유 발견 | 사용 중지, 격리 보관, 안전 조치 후 보고 |\n", + "| **I-6** | T+32 | 수령 중대 수령자 서명 거부(수량 이견) | 공동 재검수, 이견 기록 후 서명 |\n", + "| **I-7** | T+40 | 통신 복구 | 수기 증빙 목록 전산 등록 인계, 이중 등록 확인 |\n", + "\n", + "[자료 7-2] 가상 위임 기준 (임무형 지휘 연습용)\n", + "\n", + "| **상황** | **대행자** | **대행 가능 범위** | **사후 조치** |\n", + "| --- | --- | --- | --- |\n", + "| **승인권자 부재 30분 이내** | 군수과 선임 부사관 | 식량·의무 물자, 1회 요청 수량 이내 | 복귀 즉시 확인 서명 |\n", + "| **탄약 긴급 요청** | 대행 불가 | 승인권자 무전·전령 확인 필수 | 대기 사유 기록 |\n", + "| **통신 두절** | 현장 최선임자 | 수기 증빙 작성 지시 | 복구 후 1시간 내 전산 등록 |\n", + "\n", + "[서식 7-3] 주입별 조치 기록표 (관측자 작성, 초 단위)\n", + "\n", + "| **주입** | **인지 시각** | **전환 시각** | **전환 소요(초)** | **실제 조치** | **성공(O/X)** | **안전 위반** |\n", + "| --- | --- | --- | --- | --- | --- | --- |\n", + "| **I-1** | | | | | | |\n", + "| **I-2** | | | | | | |\n", + "| **I-3** | | | | | | |\n", + "| **I-4** | | | | | | |\n", + "| **I-5** | | | | | | |\n", + "| **I-6** | | | | | | |\n", + "| **I-7** | | | | | | |\n", + "\n", + "위험 조치 성공률 = 성공 ( ) / 7 x 100 = ( )% 1분 초과 주입: ( ) 사후 전산 등록 대상 목록: ( )\n", + "\n", + "| **점검 항목** | **확인** |\n", + "| --- | --- |\n", + "| 모든 주입에서 비상 절차 전환이 1분 이내였다 | ( ) |\n", + "| 승인권자 부재 시 위임 기준 범위 안에서만 대행했다 | ( ) |\n", + "| 탄약 요청은 대행 승인하지 않았다 | ( ) |\n", + "| 누유 용기를 사용 중지·격리했다 | ( ) |\n", + "| 통신 복구 후 수기 증빙이 모두 등록 대상에 올랐다 | ( ) |\n", + "\n", + "**\n", + "\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 08. AAR 사후검토 회의를 통한 보급 누락 및 증빙 미비 사례 분석\n", + "\n", + "### 📋 미션 배경 및 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "AAR 회의를 통해 보급 누락 및 증빙 미비 사례를 분석한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **1차 모의훈련과 스트레스 훈련의 기록을 바탕으로 AAR 회의를 열어 보급 누락과 증빙 미비의 근본 원인을 비난 없이 분석한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **관측 기록·수행 기록지·조치 기록을 사실로 놓고 참여자 진술과 대조해 사실과 다른 기억을 바로잡는다 과정을 진행하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 5개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `aar/log07_aar_session_minutes.md 및 오류 분석 매트릭스` 형태의 결과물을 완성한다.\n", + "\n", + "### 📑 실습자료 및 작성 양식\n", + "AAR 사후검토 회의를 통한 보급 누락 및 증빙 미비 사례 분석**\n", + "\n", + "[자료 8-1] 가상 훈련 기록 요약 (AAR 사실 자료) [교육용가상자료]\n", + "\n", + "| **기록 번호** | **출처** | **사실(기록)** | **참여자 진술** |\n", + "| --- | --- | --- | --- |\n", + "| **L-01** | M4 수행 기록지 | 요청 3의 부분 불출 승인에 12분 소요 | \"승인은 금방 났다\"(승인자) |\n", + "| **L-02** | M4 수행 기록지 | 수송 인계서 출발 시각 공란 2건 | \"출발 시각은 불출증에 있다\"(수송자) |\n", + "| **L-03** | M5 매트릭스 | S06 배송 지연을 3개 조가 놓침 | \"시각 칸을 끝까지 안 봤다\" |\n", + "| **L-04** | M7 조치 기록 | I-3 대행 승인 전환 95초(1분 초과) | \"위임 기준표를 찾는 데 시간이 걸렸다\" |\n", + "| **L-05** | M7 조치 기록 | I-6 서명 거부 시 재검수 없이 서명 요구 | \"시간이 없어서 먼저 서명받으려 했다\" |\n", + "| **L-06** | M7 조치 기록 | 통신 복구 후 수기 청구서 1건 전산 등록 누락 | \"다른 사람이 등록한 줄 알았다\" |\n", + "\n", + "[서식 8-2] AAR 회의록\n", + "\n", + "| **항목** | **기록** |\n", + "| --- | --- |\n", + "| **일시·참석자·진행자·기록자** | |\n", + "| **계획(무엇을 하려 했나)** | |\n", + "| **실제(무슨 일이 있었나)** | |\n", + "| **차이(계획과 실제의 차이)** | |\n", + "| **원인(왜 차이가 났나)** | |\n", + "| **개선(다음에 무엇을 바꾸나)** | |\n", + "| **핵심 교훈(Lessons Learned)** | |\n", + "\n", + "[서식 8-3] 오류 분석 매트릭스 (근거 기록 번호 필수)\n", + "\n", + "| **사례** | **근거 기록** | **사실·진술 일치(O/X)** | **근본 원인(절차·서식·환경)** | **개선안** | **담당** |\n", + "| --- | --- | --- | --- | --- | --- |\n", + "| **L-01** | | | | | |\n", + "| **L-02** | | | | | |\n", + "| **L-03** | | | | | |\n", + "| **L-04** | | | | | |\n", + "| **L-05** | | | | | |\n", + "| **L-06** | | | | | |\n", + "\n", + "[서식 8-4] SOP 1차 수정안\n", + "\n", + "| **번호** | **현행 절차** | **수정 절차** | **근거 사례** |\n", + "| --- | --- | --- | --- |\n", + "| **1** | | | |\n", + "| **2** | | | |\n", + "| **3** | | | |\n", + "| **4** | | | |\n", + "| **5** | | | |\n", + "\n", + "| **점검 항목** | **확인** |\n", + "| --- | --- |\n", + "| 개선점이 5건 이상 도출되었다 | ( ) |\n", + "| 모든 원인에 근거 기록 번호가 연결되었다 | ( ) |\n", + "| 진술과 기록이 다른 경우 기록을 사실로 삼았다 | ( ) |\n", + "| 회의록에 개인 비난 표현이 없다 | ( ) |\n", + "\n", + "**\n", + "\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 09. 피드백 반영 간소화된 증빙 체인 재훈련 및 처리 시간 단축 검증\n", + "\n", + "### 📋 미션 배경 및 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "피드백을 반영하여 간소화된 증빙 체인 재훈련을 수행한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **AAR 결과를 반영해 간소화한 증빙 체인으로 같은 조건을 재훈련하고, 처리 시간과 오류가 실제로 줄었는지 같은 지표로 검증한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **SOP 수정안(중복 서명 통합, 사전 기재 칸, 긴급 모드 카드 등)을 적용해 1차 훈련과 같은 요청 3건과 같은 주입 사건으로 재훈련 수행하고 검증하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 5개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `eval/log07_rerun_performance_comparison.md` 형태의 결과물을 완성한다.\n", + "\n", + "### 📑 실습자료 및 작성 양식\n", + "피드백 반영 간소화된 증빙 체인 재훈련 및 처리 시간 단축 검증**\n", + "\n", + "[자료 9-1] 1차 훈련 기준값 예시 (조별 실제 M4·M7 값이 있으면 그 값을 쓴다) [교육용가상자료]\n", + "\n", + "| **지표** | **측정 방법** | **1차 기준값(예시)** |\n", + "| --- | --- | --- |\n", + "| **평균 조치 시간** | 요청 접수 ~ 전산 등록 인계, 요청 3건 평균 | 42분 |\n", + "| **증빙 오류 발생률** | 오류가 있는 증빙 수 / 전체 증빙 수(요청 3건 x 5종 = 15장) | 5 / 15 = 33.3% |\n", + "| **비상 전환 평균 시간** | M7 주입 7건 평균 | 58초 |\n", + "\n", + "[서식 9-2] 전후 성과 비교표\n", + "\n", + "| **지표** | **1차** | **재훈련** | **변화율 계산** | **목표** | **달성** |\n", + "| --- | --- | --- | --- | --- | --- |\n", + "| **평균 조치 시간(분)** | | | (1차-재)/1차 x 100 = ( )% | 30% 이상 단축 | ( ) |\n", + "| **증빙 오류 발생률(%)** | | | (1차-재)/1차 x 100 = ( )% | 50% 이상 감소 | ( ) |\n", + "| **SOP 준수율(%)** | | | 재 - 1차 = ( )%p | 향상 | ( ) |\n", + "| **비상 전환 평균(초)** | | | (1차-재)/1차 x 100 = ( )% | 60초 이내 유지 | ( ) |\n", + "\n", + "적용한 간소화 조치: ( ) 예) 청구서·불출증 번호 사전 기재 칸, 수송 인계서와 적재 목록 통합, 위임 기준표를 체크리스트 뒷면에 인쇄\n", + "\n", + "[서식 9-3] 보수 교육 주기안\n", + "\n", + "| **대상** | **주기** | **방법** | **숙련 판정 기준** |\n", + "| --- | --- | --- | --- |\n", + "| **보급·수송 담당** | | | |\n", + "| **중대 실수령자** | | | |\n", + "| **승인권자·대행자** | | | |\n", + "\n", + "| **점검 항목** | **확인** |\n", + "| --- | --- |\n", + "| 1차와 같은 요청·주입·역할 조건으로 재훈련했다 | ( ) |\n", + "| 조치 시간 30% 이상 단축했다 | ( ) |\n", + "| 오류 발생률 50% 이상 감소했다 | ( ) |\n", + "| 보수 교육 주기와 판정 기준을 정했다 | ( ) |\n", + "\n", + "**\n", + "\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 10. 보급요청-승인-불출-수령 증빙 체인 표준 실무 훈련 지침서 완성\n", + "\n", + "### 📋 미션 배경 및 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "보급요청-승인-불출-수령 증빙 체인 표준 실무 훈련 지침서를 완성한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **분석부터 재훈련까지의 결과물을 묶어 다른 부대 교관이 그대로 쓸 수 있는 보급 증빙 체인 표준 실무 훈련 지침서를 완성한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **개요-SOP-시나리오-평가 루브릭-AAR의 5개 장으로 교안을 구성하고, 각 장에 앞선 미션의 체크리스트·MSEL·기록 서식·비교표를 정리해 넣는다 과정을 진행하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 5개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `package/log07_standard_training_package.md 및 교관 지침서` 형태의 결과물을 완성한다.\n", + "\n", + "### 📑 실습자료 및 작성 양식\n", + "보급요청-승인-불출-수령 증빙 체인 표준 실무 훈련 지침서 완성**\n", + "\n", + "[자료 10-1] 표준 훈련 패키지 구성표\n", + "\n", + "| **장** | **제목** | **필수 포함 내용(앞선 미션 산출물)** | **완비** |\n", + "| --- | --- | --- | --- |\n", + "| **1장** | 개요 | 훈련 목적, 대상, 증빙 흐름, 역할 분담 매트릭스(M1) | ( ) |\n", + "| **2장** | SOP | 단계별 체크리스트, 휴대용 카드, 책임 분계표, 위임 기준(M2·M3·M7) | ( ) |\n", + "| **3장** | 시나리오 | MSEL, 가상 요청서·증빙 세트, 주입 카드(M3·M4·M5·M7) | ( ) |\n", + "| **4장** | 평가 루브릭 | 평가 지표, 채점표, 전후 비교표(M4·M6·M9) | ( ) |\n", + "| **5장** | AAR | 회의록 서식, 오류 분석 매트릭스, SOP 수정 절차(M8) | ( ) |\n", + "\n", + "[서식 10-2] 교관 지침서\n", + "\n", + "| **항목** | **내용** |\n", + "| --- | --- |\n", + "| **준비물** | |\n", + "| **역할 배정** | |\n", + "| **시간표(총 소요)** | |\n", + "| **채점 방법** | |\n", + "| **안전 통제 사항** | |\n", + "| **보안 유의 사항** | |\n", + "\n", + "[서식 10-3] 모의 검토 의견서 (타 부대 교관 역할 조 작성)\n", + "\n", + "| **번호** | **해당 장** | **검토 의견** | **반영 여부** | **반영 내용** |\n", + "| --- | --- | --- | --- | --- |\n", + "| **1** | | | | |\n", + "| **2** | | | | |\n", + "| **3** | | | | |\n", + "| **4** | | | | |\n", + "| **5** | | | | |\n", + "\n", + "[서식 10-4] 연간 교육훈련 계획 연계표\n", + "\n", + "| **시기** | **훈련 형태** | **사용 장** | **비고** |\n", + "| --- | --- | --- | --- |\n", + "| **1분기** | | | |\n", + "| **2분기** | | | |\n", + "| **3분기** | | | |\n", + "| **4분기** | | | |\n", + "\n", + "| **점검 항목** | **확인** |\n", + "| --- | --- |\n", + "| 5개 장이 모두 완비되었다 | ( ) |\n", + "| 교관 지침서 6개 항목이 채워졌다 | ( ) |\n", + "| 모의 검토 의견과 반영 결과가 기록되었다 | ( ) |\n", + "| 교안의 수치가 M4·M5·M7·M9 결과와 일치한다 | ( ) |\n", + "\n", + "**4. 평가 채점표**\n", + "\n", + "| **미션** | **구분** | **채점 기준(정량·정성)** | **판정** |\n", + "| --- | --- | --- | --- |\n", + "| **M1** | 필수 | 현장 체크 항목 15개 이상 / 역할 분담 매트릭스 30칸 100% 매핑 / 부대 유형별 환경 변수 기재 | PASS / FAIL |\n", + "| **M2** | 필수 | 전 단계(요청~수령) 세부 절차 100% 명시 / 안전·보안 조치 포함 100% / 교차 검토 지적 수정 완료 | PASS / FAIL |\n", + "| **M3** | 필수 | 인계 지점 4곳 책임 이전 조건 명시 / 주입 사건 10건 이상 / 정상:비정상 6:4 / 워크스루 모순 없음 | PASS / FAIL |\n", + "| **M4** | 선택 | SOP 준수율 80% 이상 / 보고 지연 3분 이내 / 병목·혼선 2곳 이상 식별 | PASS / FAIL |\n", + "| **M5** | 필수 | 식별 성공률 85% 이상(11/12) / 오류 세트별 발생 단계·근거 기재 / 보고 경로 구분 | PASS / FAIL |\n", + "| **M6** | 선택 | 인계 필수 항목 누락 0% / 인계 10분 이내 / 돌발 퀴즈 4/5 이상 / 상호 서명 | PASS / FAIL |\n", + "| **M7** | 필수 | 주입별 비상 전환 1분 이내 / 위험 조치 성공률 90% 이상 / 안전 수칙 위반 0건 | PASS / FAIL |\n", + "| **M8** | 선택 | 개선점 5건 이상 / 원인별 근거 연결 100% / 사실 중심·비난 없는 기록 | PASS / FAIL |\n", + "| **M9** | 필수 | 조치 시간 30% 이상 단축 / 오류율 50% 이상 감소 / 동일 조건 비교 | PASS / FAIL |\n", + "| **M10** | 필수 | 교안 5개 장 100% 완비 / 모의 검토 반영 기록 / 수치 일치 | PASS / FAIL |\n", + "\n", + "종합 판정: 필수 7개 미션 모두 PASS이면 이수, 선택 미션은 가점으로 반영한다. 평가관 확인: ( )\n", + "\n" + ] + } + ], + "metadata": { + "colab": { + "provenance": [] + }, + "kernelspec": { + "display_name": "Python 3", + "name": "python3" + }, + "language_info": { + "name": "python" + } + }, + "nbformat": 4, + "nbformat_minor": 0 +} \ No newline at end of file diff --git a/uploads/MNT02/MNT02_solution.ipynb b/uploads/MNT02/MNT02_solution.ipynb new file mode 100644 index 0000000..7b87d57 --- /dev/null +++ b/uploads/MNT02/MNT02_solution.ipynb @@ -0,0 +1,3097 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# 🎯 [MNT02] 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/MNT02/MNT02_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", + "장비 수리부속은 생산 중단(단종)이나 단종 예고가 나오면 조달 리드타임이 길어지고 공급업체가 줄어, 수요를 미리 알지 못하면 결품으로 장비가 정비 대기 상태에 머물게 된다. 따라서 단종 위험에 대응하려면 먼저 품목별 수요를 정확히 예측하고, 그 예측에 리드타임·공급업체 수·대체품 유무를 결합해 결품 위험을 측정해야 한다. 본 프로젝트는 교육용 가상 야전 정비창의 수리부속 청구/출고 이력과 장비 가동률 자료를 코드로 만들어, 데이터 스키마 정의, NSN 정제와 계절성 분리, 이동평균·지수평활 Baseline, 머신러닝 수요 예측, 안전재고와 결품(단종) 위험 지수, 긴급 조달 대시보드와 설명가능성, 정비장교 발주 승인 워크플로우, 수요 300% 폭증 부하 시험, 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. 야전 정비창 수리부속 청구/출고 이력 및 장비 가동률 데이터셋 스키마 정의\n", + "**소요시간**: 약 1.5시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "야전 정비창 수리부속 청구/출고 이력 및 장비 가동률 데이터 스키마를 정의한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **야전 정비창 수리부속 청구/출고 이력, 품목·공급망(단종 상태 포함), 장비 가동률 자료의 스키마와 업무 프로세스 맵, 데이터 딕셔너리를 정의한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **정비 소요 발생부터 청구, 재고 확인·불출, 조달 요청, 단종 위험 검토, 가동률 집계까지 업무 흐름을 단계별로 정리하고 규격화하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `docs/mnt02_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": [ + "\"\"\"야전 정비창 수리부속 청구/출고 이력과 장비 가동률 데이터의 업무 프로세스 맵, 데이터 스키마(피처 20개 이상)와 데이터 딕셔너리를 정의하고 가상 청구 기록을 검증한다.\"\"\"\n", + "import json\n", + "import re\n", + "import numpy as np\n", + "import pandas as pd\n", + "\n", + "# 1) 업무 프로세스 맵: 단계별로 어떤 데이터가 생기는지 연결한다\n", + "PROCESS = [(\"1. 정비 소요 발생\", \"장비 고장·계획정비\", \"equip_id, equip_type, maint_type\"),\n", + " (\"2. 수리부속 청구\", \"정비반 -> 정비창\", \"req_id, req_date, nsn, qty_req, priority\"),\n", + " (\"3. 재고 확인·불출\", \"정비창 재고 담당\", \"qty_issued, emergency_flag\"),\n", + " (\"4. 조달 요청\", \"재고 부족 시 조달부서\", \"lead_time_m, supplier_count, unit_price\"),\n", + " (\"5. 단종 위험 검토\", \"품목 관리 담당\", \"prod_status, alt_available\"),\n", + " (\"6. 정비 완료·가동률 집계\", \"월 단위 집계\", \"op_hours, availability, downtime_h\")]\n", + "print(\"분석 목표: 월별 품목 수요를 예측해 결품·과잉재고를 줄이고, 단종예고·단종 품목의 최종 구매 수량 판단을 지원한다.\")\n", + "print(\"[업무 프로세스 맵] 정비 소요 -> 청구 -> 불출 -> 조달 -> 단종 검토 -> 가동률\")\n", + "for step, who, fields in PROCESS:\n", + " print(f\" {step:18s} | {who:18s} | {fields}\")\n", + "\n", + "# 2) 스키마(데이터 딕셔너리): 그룹, 자료형, 규칙, 설명\n", + "NSN_RE = r\"^\\d{4}-\\d{2}-\\d{3}-\\d{4}$\" # NSN = FSC(4) + 국가코드(2) + 일련번호(7), NIIN = 뒤 9자리\n", + "SCHEMA = {\n", + " \"청구/불출\": {\"req_id\": (str, {\"pattern\": r\"^R\\d{6}$\"}, \"청구 번호\"),\n", + " \"req_date\": (str, {\"pattern\": r\"^\\d{4}-\\d{2}-\\d{2}$\"}, \"청구 일자\"),\n", + " \"unit_code\": (str, {\"pattern\": r\"^U-\\d{2}$\"}, \"청구 부대(가상 코드)\"),\n", + " \"equip_id\": (str, {\"pattern\": r\"^[ABC]-\\d{3}$\"}, \"장비 번호(가상)\"),\n", + " \"equip_type\": (str, {\"enum\": [\"A\", \"B\", \"C\"]}, \"장비 유형\"),\n", + " \"maint_type\": (str, {\"enum\": [\"계획\", \"비계획\"]}, \"정비 구분\"),\n", + " \"nsn\": (str, {\"pattern\": NSN_RE}, \"국가재고번호(가상)\"),\n", + " \"fsc\": (str, {\"pattern\": r\"^\\d{4}$\"}, \"군수품 분류(NSN 앞 4자리)\"),\n", + " \"niin\": (str, {\"pattern\": r\"^\\d{9}$\"}, \"품목 식별번호(NSN 뒤 9자리)\"),\n", + " \"qty_req\": (int, {\"min\": 1, \"max\": 500}, \"청구 수량\"),\n", + " \"qty_issued\": (int, {\"min\": 0, \"max\": 500}, \"불출 수량\"),\n", + " \"unit_of_issue\": (str, {\"enum\": [\"EA\", \"SE\", \"BX\"]}, \"불출 단위\"),\n", + " \"priority\": (str, {\"enum\": [\"긴급\", \"일반\"]}, \"청구 우선순위\"),\n", + " \"emergency_flag\": (bool, {}, \"긴급 불출 여부(이상치로 지우지 않음)\")},\n", + " \"품목/공급망\": {\"unit_price\": (int, {\"min\": 0}, \"단가(원, 가상)\"),\n", + " \"lead_time_m\": (int, {\"min\": 0, \"max\": 36}, \"조달 리드타임(월)\"),\n", + " \"supplier_count\": (int, {\"min\": 0, \"max\": 20}, \"공급 가능 업체 수\"),\n", + " \"prod_status\": (str, {\"enum\": [\"생산중\", \"단종예고\", \"단종\"]}, \"생산(단종) 상태\"),\n", + " \"alt_available\": (bool, {}, \"대체품 존재 여부\")},\n", + " \"가동률(월)\": {\"ym\": (str, {\"pattern\": r\"^\\d{4}-\\d{2}$\"}, \"집계 월\"),\n", + " \"op_hours\": (float, {\"min\": 0, \"max\": 744}, \"장비 운용시간\"),\n", + " \"availability\": (float, {\"min\": 0, \"max\": 1}, \"장비 가동률\"),\n", + " \"downtime_h\": (float, {\"min\": 0, \"max\": 744}, \"정비 대기·수리 시간\"),\n", + " \"exercise_flag\": (int, {\"enum\": [0, 1]}, \"대규모 훈련 월 여부\")},\n", + "}\n", + "\n", + "\n", + "def check(name, v, typ, rule):\n", + " if typ is float and isinstance(v, int) and not isinstance(v, bool):\n", + " v = float(v)\n", + " if not isinstance(v, typ) or (typ is not bool and isinstance(v, bool)):\n", + " return [f\"{name}: 자료형 오류({type(v).__name__})\"]\n", + " errs = []\n", + " if \"enum\" in rule and v not in rule[\"enum\"]:\n", + " errs.append(f\"{name}: 허용값 아님 '{v}'\")\n", + " if \"min\" in rule and v < rule[\"min\"]:\n", + " errs.append(f\"{name}: 최소값 {rule['min']} 미만\")\n", + " if \"max\" in rule and v > rule[\"max\"]:\n", + " errs.append(f\"{name}: 최대값 {rule['max']} 초과\")\n", + " if \"pattern\" in rule and not re.match(rule[\"pattern\"], v):\n", + " errs.append(f\"{name}: 형식 불일치 '{v}'\")\n", + " return errs\n", + "\n", + "\n", + "def validate(rec):\n", + " errs = []\n", + " for grp in SCHEMA.values():\n", + " for k, (typ, rule, _) in grp.items():\n", + " errs += [f\"{k}: 필수 필드 누락\"] if k not in rec else check(k, rec[k], typ, rule)\n", + " if not errs: # 필드 간 논리 규칙\n", + " if rec[\"nsn\"][:4] != rec[\"fsc\"] or rec[\"nsn\"].replace(\"-\", \"\")[4:] != rec[\"niin\"]:\n", + " errs.append(\"nsn/fsc/niin 불일치\")\n", + " if rec[\"qty_issued\"] > rec[\"qty_req\"]:\n", + " errs.append(\"불출 수량이 청구 수량 초과\")\n", + " return errs\n", + "\n", + "\n", + "# 3) [교육용가상자료] 청구 기록 20건 생성\n", + "rng = np.random.default_rng(1)\n", + "records = []\n", + "for i in range(20):\n", + " fsc = str(rng.choice([\"2520\", \"2530\", \"2940\", \"6140\"]))\n", + " niin = f\"00{rng.integers(1000000, 9999999)}\"\n", + " q = int(rng.integers(1, 20))\n", + " et = str(rng.choice([\"A\", \"B\", \"C\"]))\n", + " records.append({\"req_id\": f\"R{250001 + i}\", \"req_date\": f\"2025-{rng.integers(1, 13):02d}-{rng.integers(1, 29):02d}\",\n", + " \"unit_code\": f\"U-{rng.integers(1, 6):02d}\", \"equip_id\": f\"{et}-{rng.integers(1, 200):03d}\", \"equip_type\": et,\n", + " \"maint_type\": str(rng.choice([\"계획\", \"비계획\"])), \"nsn\": f\"{fsc}-{niin[:2]}-{niin[2:5]}-{niin[5:]}\",\n", + " \"fsc\": fsc, \"niin\": niin, \"qty_req\": q, \"qty_issued\": int(rng.integers(0, q + 1)),\n", + " \"unit_of_issue\": \"EA\", \"priority\": str(rng.choice([\"긴급\", \"일반\"], p=[0.1, 0.9])),\n", + " \"emergency_flag\": bool(rng.random() < 0.05), \"unit_price\": int(rng.integers(10, 900)) * 1000,\n", + " \"lead_time_m\": int(rng.integers(1, 13)), \"supplier_count\": int(rng.integers(1, 5)),\n", + " \"prod_status\": str(rng.choice([\"생산중\", \"단종예고\", \"단종\"], p=[0.7, 0.2, 0.1])),\n", + " \"alt_available\": bool(rng.random() < 0.5), \"ym\": \"2025-12\", \"op_hours\": float(round(rng.uniform(80, 180), 1)),\n", + " \"availability\": float(round(rng.uniform(0.75, 0.97), 3)), \"downtime_h\": float(round(rng.uniform(5, 60), 1)),\n", + " \"exercise_flag\": int(rng.integers(0, 2))})\n", + "\n", + "ok = sum(1 for r in records if not validate(r))\n", + "n_feat = sum(len(g) for g in SCHEMA.values())\n", + "print(f\"\\n정의한 피처(필드) {n_feat}개 (목표 20개 이상: {'달성' if n_feat >= 20 else '미달'})\")\n", + "print(f\"정상 샘플 {len(records)}건 중 검증 통과 {ok}건, 오류 {len(records) - ok}건\")\n", + "\n", + "# 4) 오류 주입 시험\n", + "bad = json.loads(json.dumps(records[0], ensure_ascii=False))\n", + "bad[\"nsn\"] = \"2520-001234567\" # NSN 형식 오류\n", + "bad[\"qty_req\"] = -3 # 음수 수량\n", + "bad[\"prod_status\"] = \"미상\" # 허용값 아님\n", + "del bad[\"lead_time_m\"] # 필수 필드 누락\n", + "bad[\"availability\"] = 1.4 # 가동률 범위 초과\n", + "print(\"[오류 주입 결과]\")\n", + "for e in validate(bad):\n", + " print(\" -\", e)\n", + "bad2 = json.loads(json.dumps(records[1], ensure_ascii=False)); bad2[\"fsc\"] = \"9999\"\n", + "print(\" - (논리 규칙)\", validate(bad2))\n", + "\n", + "# 5) 데이터 딕셔너리 출력\n", + "rows = [(g, k, t.__name__, json.dumps(r, ensure_ascii=False) if r else \"-\", d) for g, grp in SCHEMA.items() for k, (t, r, d) in grp.items()]\n", + "dic = pd.DataFrame(rows, columns=[\"그룹\", \"필드\", \"자료형\", \"규칙\", \"설명\"])\n", + "pd.set_option(\"display.width\", 200); pd.set_option(\"display.max_colwidth\", 34)\n", + "print(\"[데이터 딕셔너리]\")\n", + "print(dic.to_string(index=False))\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 01 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + "분석 목표: 월별 품목 수요를 예측해 결품·과잉재고를 줄이고, 단종예고·단종 품목의 최종 구매 수량 판단을 지원한다.\n", + "[업무 프로세스 맵] 정비 소요 -> 청구 -> 불출 -> 조달 -> 단종 검토 -> 가동률\n", + " 1. 정비 소요 발생 | 장비 고장·계획정비 | equip_id, equip_type, maint_type\n", + " 2. 수리부속 청구 | 정비반 -> 정비창 | req_id, req_date, nsn, qty_req, priority\n", + " 3. 재고 확인·불출 | 정비창 재고 담당 | qty_issued, emergency_flag\n", + " 4. 조달 요청 | 재고 부족 시 조달부서 | lead_time_m, supplier_count, unit_price\n", + " 5. 단종 위험 검토 | 품목 관리 담당 | prod_status, alt_available\n", + " 6. 정비 완료·가동률 집계 | 월 단위 집계 | op_hours, availability, downtime_h\n", + "\n", + "정의한 피처(필드) 24개 (목표 20개 이상: 달성)\n", + "정상 샘플 20건 중 검증 통과 20건, 오류 0건\n", + "[오류 주입 결과]\n", + " - nsn: 형식 불일치 '2520-001234567'\n", + " - qty_req: 최소값 1 미만\n", + " - lead_time_m: 필수 필드 누락\n", + " - prod_status: 허용값 아님 '미상'\n", + " - availability: 최대값 1 초과\n", + " - (논리 규칙) ['nsn/fsc/niin 불일치']\n", + "[데이터 딕셔너리]\n", + " 그룹 필드 자료형 규칙 설명\n", + " 청구/불출 req_id str {\"pattern\": \"^R\\\\d{6}$\"} 청구 번호\n", + " 청구/불출 req_date str {\"pattern\": \"^\\\\d{4}-\\\\d{2}-\\\\d{2}$\"} 청구 일자\n", + " 청구/불출 unit_code str {\"pattern\": \"^U-\\\\d{2}$\"} 청구 부대(가상 코드)\n", + " 청구/불출 equip_id str {\"pattern\": \"^[ABC]-\\\\d{3}$\"} 장비 번호(가상)\n", + " 청구/불출 equip_type str {\"enum\": [\"A\", \"B\", \"C\"]} 장비 유형\n", + " 청구/불출 maint_type str {\"enum\": [\"계획\", \"비계획\"]} 정비 구분\n", + " 청구/불출 nsn str {\"pattern\": \"^\\\\d{4}-\\\\d{2}-\\\\d{3}-\\\\d{4}$\"} 국가재고번호(가상)\n", + " 청구/불출 fsc str {\"pattern\": \"^\\\\d{4}$\"} 군수품 분류(NSN 앞 4자리)\n", + " 청구/불출 niin str {\"pattern\": \"^\\\\d{9}$\"} 품목 식별번호(NSN 뒤 9자리)\n", + " 청구/불출 qty_req int {\"min\": 1, \"max\": 500} 청구 수량\n", + " 청구/불출 qty_issued int {\"min\": 0, \"max\": 500} 불출 수량\n", + " 청구/불출 unit_of_issue str {\"enum\": [\"EA\", \"SE\", \"BX\"]} 불출 단위\n", + " 청구/불출 priority str {\"enum\": [\"긴급\", \"일반\"]} 청구 우선순위\n", + " 청구/불출 emergency_flag bool - 긴급 불출 여부(이상치로 지우지 않음)\n", + "품목/공급망 unit_price int {\"min\": 0} 단가(원, 가상)\n", + "품목/공급망 lead_time_m int {\"min\": 0, \"max\": 36} 조달 리드타임(월)\n", + "품목/공급망 supplier_count int {\"min\": 0, \"max\": 20} 공급 가능 업체 수\n", + "품목/공급망 prod_status str {\"enum\": [\"생산중\", \"단종예고\", \"단종\"]} 생산(단종) 상태\n", + "품목/공급망 alt_available bool - 대체품 존재 여부\n", + "가동률(월) ym str {\"pattern\": \"^\\\\d{4}-\\\\d{2}$\"} 집계 월\n", + "... (이하 생략)\n", + "```\n", + "
\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 02. 결측치 보정, 계절성 수요 분리 및 수리부속 품목코드(NSN) 정제 전처리 모듈 구현\n", + "**소요시간**: 약 2.0시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "결측치 보정, 계절성 수요 분리 및 부품코드(NSN) 정제 전처리 모듈을 구현한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **청구 기록의 결측치 보정, 계절성 수요 분리, 품목코드(NSN) 정제를 수행하는 전처리 모듈을 구현한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **중복 청구 제거, NSN 형식 정규화와 미등록 NSN 격리, 날짜·단위 통일, 음수·과다 수량 보정 등 정제 규칙 10종 이상을 적용하고 긴급 불출은 이상치로 지우지 않는다 과정을 진행하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `src/etl/mnt02_data_cleansing.py 및 정제 품질 보고서` 형태의 결과물을 완성한다.\n", + "\n", + "### 🔍 평가 및 검증 기준 (PASS 조건)\n", + "#### 미션 판정\n", + "\n", + "**평가 구분:** 필수\n", + "\n", + "**핵심 평가영역:** 군수 특수 예외(비계획 정비, 긴급 불출 등) 데이터 보존의 타당성\n", + "\n", + "#### 미션 PASS 조건\n", + "\n", + "- 정량 기준: 데이터 정제 규칙 10종 적용, 데이터 정합성 검증 통과율 100%\n", + "\n", + "\n", + "- 진행 기준: ETL 파이프라인 구현 및 정제된 마스터 데이터셋 구축\n", + "\n", + "\n", + "- 검증 방법: 전후 데이터 통계량(기술통계) 비교 및 무결성 검증\n", + "\n", + "\n", + "#### 채점자 판정 메모\n", + "\n", + "> 단순 코드 실행 여부뿐만 아니라 출력된 수치와 근거 자료가 군 실무 요구사항에 부합하는지 면밀히 확인한다.\n", + "\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "\"\"\"수리부속 청구/출고 원시 기록에 정제 규칙 10종(NSN 형식 정규화, 중복 제거, 날짜 통일, 단위 환산, 결측 월 계절성 보간 등)을 적용하고, 월별 수요에서 계절성 성분을 분리한 뒤 정합성 검증 통과율을 확인한다.\"\"\"\n", + "import re\n", + "import numpy as np\n", + "import pandas as pd\n", + "import matplotlib.pyplot as plt\n", + "\n", + "# ---------------------------------------------------------------------------\n", + "# [교육용가상자료] 가상 야전 정비창 수리부속 청구/출고·장비 가동률 데이터 생성기\n", + "# 실제 부대·품목·재고·단가 정보와 무관하다. 품목 40개 x 48개월(가상 2022-01 ~ 2025-12) 월별 자료를 만든다.\n", + "# ---------------------------------------------------------------------------\n", + "FSC = {\"2520\": \"power_train\", \"2530\": \"brake_steer\", \"2910\": \"fuel\", \"2930\": \"cooling\",\n", + " \"2940\": \"filter\", \"5330\": \"gasket\", \"6140\": \"battery\", \"5961\": \"semiconductor\"}\n", + "EXERCISE = {3, 8, 10} # 가상 대규모 훈련 월(훈련 계획으로 미리 알 수 있음)\n", + "N_MONTHS = 48\n", + "\n", + "\n", + "def season(fsc, month):\n", + " \"\"\"품목군별 계절 계수: 축전지는 겨울, 냉각계통은 여름에 수요가 늘어난다.\"\"\"\n", + " if fsc == \"6140\":\n", + " return 1.6 if month in (12, 1, 2) else 0.9\n", + " if fsc == \"2930\":\n", + " return 1.5 if month in (6, 7, 8) else 0.9\n", + " return 1 + 0.1 * np.sin(2 * np.pi * month / 12)\n", + "\n", + "\n", + "def make_items(n=40, seed=2):\n", + " \"\"\"품목 마스터: NSN(가상), 품목군, 장비 유형, 기준 수요, 단가, 조달 리드타임, 공급업체 수, 생산(단종) 상태.\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " rows = []\n", + " for i in range(n):\n", + " f = str(rng.choice(list(FSC)))\n", + " status = str(rng.choice([\"생산중\", \"단종예고\", \"단종\"], p=[0.7, 0.2, 0.1]))\n", + " rows.append({\"item_id\": f\"P{i + 1:03d}\", \"nsn\": f\"{f}-00-{rng.integers(100, 1000)}-{rng.integers(1000, 10000)}\",\n", + " \"fsc\": f, \"group\": FSC[f], \"equip_type\": str(rng.choice([\"A\", \"B\", \"C\"])),\n", + " \"base\": float(np.round(rng.lognormal(2.7, 0.8), 1)),\n", + " \"price\": int(rng.lognormal(12, 1.0)) // 100 * 100,\n", + " \"lead_time\": int(rng.integers(1, 7) if status == \"생산중\" else rng.integers(4, 13)),\n", + " \"suppliers\": int(rng.integers(1, 5)) if status == \"생산중\" else 1,\n", + " \"status\": status, \"alt_available\": bool(rng.random() < (0.6 if status == \"생산중\" else 0.3))})\n", + " return pd.DataFrame(rows)\n", + "\n", + "\n", + "def make_history(items, months=N_MONTHS, seed=3):\n", + " \"\"\"월별 청구 수요(개), 긴급 불출 건수, 장비 유형별 운용시간·가동률.\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " t = np.arange(months)\n", + " moy = t % 12 + 1\n", + " ex = np.isin(moy, list(EXERCISE))\n", + " hours = {e: (100 + 20 * rng.random(months)) * np.where(ex, 1.5, 1.0) for e in \"ABC\"}\n", + " avail = {e: np.clip(0.93 - 0.12 * (hours[e] - 100) / 80 + rng.normal(0, 0.015, months), 0.6, 0.99) for e in \"ABC\"}\n", + " rows = []\n", + " for it in items.itertuples():\n", + " trend = 1 + rng.uniform(-0.1, 0.3) * t / months\n", + " s = np.array([season(it.fsc, m) for m in moy])\n", + " lam = it.base * s * trend * hours[it.equip_type] / 110\n", + " d = rng.poisson(lam)\n", + " rows.append(pd.DataFrame({\"item_id\": it.item_id, \"t\": t, \"ym\": [f\"{2022 + k // 12}-{k % 12 + 1:02d}\" for k in t],\n", + " \"month\": moy, \"exercise\": ex.astype(int), \"op_hours\": hours[it.equip_type].round(1),\n", + " \"availability\": avail[it.equip_type].round(3), \"demand\": d,\n", + " \"emergency\": rng.binomial(d, 0.05)}))\n", + " return pd.concat(rows, ignore_index=True)\n", + "\n", + "\n", + "def make_stock(items, hist, seed=4):\n", + " \"\"\"현재(가상 2026-01) 재고: 보유량과 미입고 발주량(조달 중인 물량). 단종 품목은 추가 발주가 어려워 미입고량이 적다.\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " recent = hist[hist[\"t\"] >= hist[\"t\"].max() - 5].groupby(\"item_id\")[\"demand\"].mean()\n", + " st = items[[\"item_id\", \"lead_time\", \"status\"]].copy()\n", + " st[\"on_hand\"] = [int(round(recent[i] * rng.uniform(0.5, 3.0))) for i in st[\"item_id\"]]\n", + " cover = np.where(st[\"status\"] == \"생산중\", rng.uniform(0.6, 1.3, len(st)), rng.uniform(0.0, 0.6, len(st)))\n", + " st[\"on_order\"] = [int(round(recent[i] * L * c)) for i, L, c in zip(st[\"item_id\"], st[\"lead_time\"], cover)]\n", + " return st[[\"item_id\", \"on_hand\", \"on_order\"]]\n", + "# ---------------------------------------------------------------------------\n", + "\n", + "items = make_items()\n", + "hist = make_history(items)\n", + "master = set(items[\"nsn\"])\n", + "nsn_of = dict(zip(items[\"item_id\"], items[\"nsn\"]))\n", + "\n", + "# 1) [교육용가상자료] 최근 24개월 월 수요를 개별 청구 기록으로 풀고 일부러 오류를 섞는다\n", + "rng = np.random.default_rng(5)\n", + "OUTAGE = {\"2024-07\", \"2025-02\"} # 전산 장애로 기록이 올라오지 않은 월(가상)\n", + "rows, rid = [], 300000\n", + "for r in hist[hist[\"t\"] >= 24].itertuples():\n", + " if r.ym in OUTAGE or r.demand == 0:\n", + " continue\n", + " k = int(np.ceil(r.demand / 5))\n", + " for j, q in enumerate(rng.multinomial(r.demand, np.ones(k) / k)):\n", + " if q == 0:\n", + " continue\n", + " rid += 1\n", + " rows.append({\"req_id\": f\"R{rid}\", \"date\": f\"{r.ym}-{rng.integers(1, 29):02d}\", \"nsn\": nsn_of[r.item_id],\n", + " \"qty_req\": int(q), \"qty_issued\": int(q), \"uoi\": \"EA\", \"emergency\": bool(j < r.emergency)})\n", + "raw = pd.DataFrame(rows)\n", + "n = len(raw)\n", + "idx = lambda frac: rng.choice(n, int(n * frac), replace=False)\n", + "raw.loc[idx(0.05), \"nsn\"] = raw[\"nsn\"].str.replace(\"-\", \"\") # 하이픈 누락\n", + "raw.loc[idx(0.03), \"nsn\"] = \" \" + raw[\"nsn\"] + \" \" # 앞뒤 공백\n", + "raw.loc[idx(0.04), \"date\"] = raw[\"date\"].str.replace(\"-\", \".\") # 날짜 형식 혼재\n", + "raw.loc[idx(0.02), \"qty_req\"] = -raw[\"qty_req\"] # 음수(입력 오류)\n", + "raw.loc[idx(0.02), \"qty_issued\"] = raw[\"qty_req\"].abs() + 2 # 불출 > 청구\n", + "bx = idx(0.02); raw.loc[bx, \"uoi\"] = \"BX\"; raw.loc[bx, \"qty_req\"] = 1; raw.loc[bx, \"qty_issued\"] = 1 # 상자(10EA) 단위\n", + "raw.loc[idx(0.01), \"qty_req\"] = np.nan # 수량 결측\n", + "raw.loc[idx(0.005), \"date\"] = \"2027-01-15\" # 미래 일자\n", + "raw.loc[idx(0.005), \"nsn\"] = \"9999-00-000-0000\" # 미등록 NSN\n", + "typo = [i for i in idx(0.01) if not raw.at[i, \"emergency\"]]\n", + "raw.loc[typo, \"qty_req\"] = raw.loc[typo, \"qty_req\"] * 10 # 0 하나 더 입력한 오타\n", + "raw = pd.concat([raw, raw.sample(60, random_state=1)], ignore_index=True) # 중복 전송\n", + "print(f\"원시 청구 기록 {len(raw)}건 (가상), 전산 장애 월 {sorted(OUTAGE)}\")\n", + "\n", + "\n", + "# 2) 정제 규칙 10종\n", + "def fmt_nsn(s):\n", + " d = re.sub(r\"\\D\", \"\", str(s))\n", + " return f\"{d[:4]}-{d[4:6]}-{d[6:9]}-{d[9:]}\" if len(d) == 13 else str(s).strip()\n", + "\n", + "\n", + "log = {}\n", + "df = raw.copy()\n", + "before = len(df); df = df.drop_duplicates(\"req_id\"); log[\"R1 중복 청구번호 제거\"] = before - len(df)\n", + "em0 = int(df[\"emergency\"].sum())\n", + "fixed = df[\"nsn\"].map(fmt_nsn); log[\"R2 NSN 형식 정규화(공백·하이픈)\"] = int((fixed != df[\"nsn\"]).sum()); df[\"nsn\"] = fixed\n", + "unk = ~df[\"nsn\"].isin(master); log[\"R3 미등록 NSN 격리\"] = int(unk.sum()); em_drop = int(df.loc[unk, \"emergency\"].sum()); df = df[~unk]\n", + "d2 = df[\"date\"].str.replace(\".\", \"-\", regex=False); log[\"R4 날짜 형식 통일\"] = int((d2 != df[\"date\"]).sum()); df[\"date\"] = pd.to_datetime(d2)\n", + "fut = df[\"date\"] > \"2025-12-31\"; log[\"R5 미래 일자 제거\"] = int(fut.sum()); em_drop += int(df.loc[fut, \"emergency\"].sum()); df = df[~fut]\n", + "neg = df[\"qty_req\"] < 0; log[\"R6 음수 수량 부호 보정\"] = int(neg.sum()); df.loc[neg, \"qty_req\"] = -df.loc[neg, \"qty_req\"]\n", + "na = df[\"qty_req\"].isna(); log[\"R7 청구 수량 결측 -> 불출 수량으로 보완\"] = int(na.sum()); df.loc[na, \"qty_req\"] = df.loc[na, \"qty_issued\"]\n", + "bxm = df[\"uoi\"] == \"BX\"; log[\"R8 단위 환산(BX=10EA)\"] = int(bxm.sum())\n", + "df.loc[bxm, [\"qty_req\", \"qty_issued\"]] = df.loc[bxm, [\"qty_req\", \"qty_issued\"]] * 10; df.loc[bxm, \"uoi\"] = \"EA\"\n", + "over = df[\"qty_issued\"] > df[\"qty_req\"]; log[\"R9 불출>청구 -> 청구 수량으로 제한\"] = int(over.sum())\n", + "df.loc[over, \"qty_issued\"] = df.loc[over, \"qty_req\"]\n", + "cap = df.groupby(\"nsn\")[\"qty_req\"].transform(lambda s: s.median() * 5)\n", + "out = (df[\"qty_req\"] > cap) & ~df[\"emergency\"]\n", + "log[\"R10 오타성 이상치 보정(긴급 불출은 보존)\"] = int(out.sum()); df.loc[out, \"qty_req\"] = (df.loc[out, \"qty_req\"] / 10).round()\n", + "df[\"qty_req\"] = df[\"qty_req\"].astype(int)\n", + "\n", + "# 3) 월별 집계 + 결측 월 계절성 보간(R11): 작년 같은 달 x (최근 수준 / 작년 수준)\n", + "df[\"ym\"] = df[\"date\"].dt.strftime(\"%Y-%m\")\n", + "monthly = df.pivot_table(index=\"ym\", columns=\"nsn\", values=\"qty_req\", aggfunc=\"sum\")\n", + "all_m = [f\"{y}-{m:02d}\" for y in (2024, 2025) for m in range(1, 13)]\n", + "monthly = monthly.reindex(all_m)\n", + "prev = hist[hist[\"t\"].between(12, 23)].assign(nsn=lambda x: x[\"item_id\"].map(nsn_of)).pivot(index=\"month\", columns=\"nsn\", values=\"demand\")\n", + "seasonal_fill = monthly.copy()\n", + "for ym in OUTAGE:\n", + " i = all_m.index(ym)\n", + " ratio = monthly.iloc[max(0, i - 3):i].mean() / prev.loc[[(i - k) % 12 + 1 for k in (1, 2, 3)]].mean().replace(0, np.nan)\n", + " seasonal_fill.loc[ym] = (prev.loc[int(ym[-2:])] * ratio.fillna(1)).round()\n", + "linear_fill = monthly.interpolate()\n", + "monthly = monthly.fillna(0)\n", + "seasonal_fill = seasonal_fill.fillna(0)\n", + "log[\"R11 결측 월 계절성 보간\"] = len(OUTAGE) * monthly.shape[1]\n", + "truth = hist[hist[\"t\"] >= 24].assign(nsn=lambda x: x[\"item_id\"].map(nsn_of)).pivot(index=\"ym\", columns=\"nsn\", values=\"demand\")\n", + "err_s = np.abs(seasonal_fill.loc[sorted(OUTAGE)] - truth.loc[sorted(OUTAGE)]).values.mean()\n", + "err_l = np.abs(linear_fill.loc[sorted(OUTAGE)] - truth.loc[sorted(OUTAGE)]).values.mean()\n", + "\n", + "print(\"\\n[정제 규칙 적용 결과]\")\n", + "for k, v in log.items():\n", + " print(f\" {k:32s} {v:5d}건\")\n", + "print(f\"결측 월 보간 오차(MAE): 계절성 보간 {err_s:.2f}개 / 단순 선형 보간 {err_l:.2f}개\")\n", + "\n", + "# 4) 정합성 검증\n", + "checks = {\"청구번호 유일\": df[\"req_id\"].is_unique, \"NSN 형식\": df[\"nsn\"].str.match(r\"^\\d{4}-\\d{2}-\\d{3}-\\d{4}$\").all(),\n", + " \"마스터 등록 NSN\": df[\"nsn\"].isin(master).all(), \"수량 양수\": (df[\"qty_req\"] > 0).all(),\n", + " \"불출<=청구\": (df[\"qty_issued\"] <= df[\"qty_req\"]).all(), \"단위 EA 통일\": (df[\"uoi\"] == \"EA\").all(),\n", + " \"미래 일자 없음\": (df[\"date\"] <= \"2025-12-31\").all(), \"결측 월 없음\": not seasonal_fill.isna().any().any(),\n", + " \"긴급 불출 보존\": int(df[\"emergency\"].sum()) == em0 - em_drop,\n", + " \"월 합계 = 기록 합계\": int(monthly.values.sum()) == int(df[\"qty_req\"].sum())}\n", + "rate = sum(checks.values()) / len(checks)\n", + "print(f\"\\n정합성 검증 {sum(checks.values())}/{len(checks)} 통과({rate:.0%}) - 목표 100%: {'달성' if rate == 1 else '미달'}, 적용 규칙 {len(log)}종(목표 10종 이상)\")\n", + "\n", + "# 5) 전후 기술통계 비교\n", + "stat = pd.DataFrame({\"정제 전\": pd.to_numeric(raw[\"qty_req\"], errors=\"coerce\").describe(), \"정제 후\": df[\"qty_req\"].describe()}).round(2)\n", + "print(\"\\n[청구 수량 기술통계 전후 비교]\")\n", + "print(stat.T.to_string())\n", + "\n", + "# 6) 계절성 분리(고전적 분해): 추세 = 12개월 중심 이동평균, 계절지수 = 월별 (실적/추세) 평균\n", + "bat = items[items[\"fsc\"] == \"6140\"].sort_values(\"base\")[\"nsn\"].iloc[-1] # 수요가 가장 많은 축전지 품목\n", + "series = pd.concat([truth.iloc[:0], seasonal_fill])[bat].astype(float)\n", + "full = hist[hist[\"item_id\"] == items.set_index(\"nsn\").loc[bat, \"item_id\"]].set_index(\"ym\")[\"demand\"].astype(float)\n", + "full.loc[series.index] = series\n", + "trend = full.rolling(12, center=True).mean()\n", + "sidx = (full / trend).groupby([int(i[-2:]) for i in full.index]).mean()\n", + "sidx = sidx / sidx.mean()\n", + "print(f\"\\n축전지 품목 {bat} 계절지수(1=평균):\", \" \".join(f\"{m}월 {v:.2f}\" for m, v in sidx.items()))\n", + "print(\"해석: 겨울(12~2월) 축전지 수요 증가와 훈련 월(3·8·10월) 증가가 함께 나타난다. 훈련 효과는 미션 4에서 별도 피처로 분리한다.\")\n", + "\n", + "fig, ax = plt.subplots(1, 2, figsize=(12, 4))\n", + "ax[0].plot(full.values, label=\"demand\"); ax[0].plot(trend.values, label=\"trend (12M centered MA)\")\n", + "ax[0].set_title(f\"Battery item {bat}: demand vs trend\"); ax[0].set_xlabel(\"month index\"); ax[0].legend()\n", + "ax[1].bar(sidx.index, sidx.values); ax[1].axhline(1, c=\"k\", ls=\"--\"); ax[1].set_title(\"Seasonal index by month\"); ax[1].set_xlabel(\"month\")\n", + "plt.tight_layout(); plt.show()\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 02 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + "원시 청구 기록 4918건 (가상), 전산 장애 월 ['2024-07', '2025-02']\n", + "\n", + "[정제 규칙 적용 결과]\n", + " R1 중복 청구번호 제거 60건\n", + " R2 NSN 형식 정규화(공백·하이픈) 372건\n", + " R3 미등록 NSN 격리 24건\n", + " R4 날짜 형식 통일 191건\n", + " R5 미래 일자 제거 24건\n", + " R6 음수 수량 부호 보정 94건\n", + " R7 청구 수량 결측 -> 불출 수량으로 보완 47건\n", + " R8 단위 환산(BX=10EA) 97건\n", + " R9 불출>청구 -> 청구 수량으로 제한 96건\n", + " R10 오타성 이상치 보정(긴급 불출은 보존) 35건\n", + " R11 결측 월 계절성 보간 80건\n", + "결측 월 보간 오차(MAE): 계절성 보간 5.72개 / 단순 선형 보간 7.86개\n", + "\n", + "정합성 검증 10/10 통과(100%) - 목표 100%: 달성, 적용 규칙 11종(목표 10종 이상)\n", + "\n", + "[청구 수량 기술통계 전후 비교]\n", + " count mean std min 25% 50% 75% max\n", + "정제 전 4870.0 4.75 4.76 -12.0 3.0 4.0 6.0 80.0\n", + "정제 후 4810.0 4.80 2.17 1.0 3.0 5.0 6.0 20.0\n", + "\n", + "축전지 품목 6140-00-643-1860 계절지수(1=평균): 1월 1.39 2월 1.16 3월 1.13 4월 0.69 5월 0.69 6월 0.66 7월 0.75 8월 1.16 9월 0.85 10월 1.38 11월 0.68 12월 1.46\n", + "해석: 겨울(12~2월) 축전지 수요 증가와 훈련 월(3·8·10월) 증가가 함께 나타난다. 훈련 효과는 미션 4에서 별도 피처로 분리한다.\n", + "```\n", + "
\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 03. 이동평균 및 지수평활법 기반 1차 부품 소요량 예측 Baseline 알고리즘 구현\n", + "**소요시간**: 약 2.5시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "이동평균 기반 1차 부품 소요량 예측 Baseline 알고리즘을 개발한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **이동평균과 지수평활법으로 1차 부품 소요량 예측 Baseline을 만들고 탐색적 분석으로 수요 요인과 핵심 관리 품목을 찾는다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **3·6개월 이동평균, 단순 지수평활, 작년 같은 달 방식의 MAE·WAPE·MAPE를 평가 구간 6개월에서 비교 수행하고 검증하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `notebooks/mnt02_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": [ + "\"\"\"이동평균(3·6개월)과 단순 지수평활법으로 품목별 다음 달 부품 소요량을 예측하는 Baseline을 구현하고, 탐색적 분석(EDA) 차트 8종·상관 검정·ABC(파레토) 분류로 수요 특성과 핵심 관리 품목을 도출한다.\"\"\"\n", + "import numpy as np\n", + "import pandas as pd\n", + "import matplotlib.pyplot as plt\n", + "from scipy import stats\n", + "\n", + "# ---------------------------------------------------------------------------\n", + "# [교육용가상자료] 가상 야전 정비창 수리부속 청구/출고·장비 가동률 데이터 생성기\n", + "# 실제 부대·품목·재고·단가 정보와 무관하다. 품목 40개 x 48개월(가상 2022-01 ~ 2025-12) 월별 자료를 만든다.\n", + "# ---------------------------------------------------------------------------\n", + "FSC = {\"2520\": \"power_train\", \"2530\": \"brake_steer\", \"2910\": \"fuel\", \"2930\": \"cooling\",\n", + " \"2940\": \"filter\", \"5330\": \"gasket\", \"6140\": \"battery\", \"5961\": \"semiconductor\"}\n", + "EXERCISE = {3, 8, 10} # 가상 대규모 훈련 월(훈련 계획으로 미리 알 수 있음)\n", + "N_MONTHS = 48\n", + "\n", + "\n", + "def season(fsc, month):\n", + " \"\"\"품목군별 계절 계수: 축전지는 겨울, 냉각계통은 여름에 수요가 늘어난다.\"\"\"\n", + " if fsc == \"6140\":\n", + " return 1.6 if month in (12, 1, 2) else 0.9\n", + " if fsc == \"2930\":\n", + " return 1.5 if month in (6, 7, 8) else 0.9\n", + " return 1 + 0.1 * np.sin(2 * np.pi * month / 12)\n", + "\n", + "\n", + "def make_items(n=40, seed=2):\n", + " \"\"\"품목 마스터: NSN(가상), 품목군, 장비 유형, 기준 수요, 단가, 조달 리드타임, 공급업체 수, 생산(단종) 상태.\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " rows = []\n", + " for i in range(n):\n", + " f = str(rng.choice(list(FSC)))\n", + " status = str(rng.choice([\"생산중\", \"단종예고\", \"단종\"], p=[0.7, 0.2, 0.1]))\n", + " rows.append({\"item_id\": f\"P{i + 1:03d}\", \"nsn\": f\"{f}-00-{rng.integers(100, 1000)}-{rng.integers(1000, 10000)}\",\n", + " \"fsc\": f, \"group\": FSC[f], \"equip_type\": str(rng.choice([\"A\", \"B\", \"C\"])),\n", + " \"base\": float(np.round(rng.lognormal(2.7, 0.8), 1)),\n", + " \"price\": int(rng.lognormal(12, 1.0)) // 100 * 100,\n", + " \"lead_time\": int(rng.integers(1, 7) if status == \"생산중\" else rng.integers(4, 13)),\n", + " \"suppliers\": int(rng.integers(1, 5)) if status == \"생산중\" else 1,\n", + " \"status\": status, \"alt_available\": bool(rng.random() < (0.6 if status == \"생산중\" else 0.3))})\n", + " return pd.DataFrame(rows)\n", + "\n", + "\n", + "def make_history(items, months=N_MONTHS, seed=3):\n", + " \"\"\"월별 청구 수요(개), 긴급 불출 건수, 장비 유형별 운용시간·가동률.\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " t = np.arange(months)\n", + " moy = t % 12 + 1\n", + " ex = np.isin(moy, list(EXERCISE))\n", + " hours = {e: (100 + 20 * rng.random(months)) * np.where(ex, 1.5, 1.0) for e in \"ABC\"}\n", + " avail = {e: np.clip(0.93 - 0.12 * (hours[e] - 100) / 80 + rng.normal(0, 0.015, months), 0.6, 0.99) for e in \"ABC\"}\n", + " rows = []\n", + " for it in items.itertuples():\n", + " trend = 1 + rng.uniform(-0.1, 0.3) * t / months\n", + " s = np.array([season(it.fsc, m) for m in moy])\n", + " lam = it.base * s * trend * hours[it.equip_type] / 110\n", + " d = rng.poisson(lam)\n", + " rows.append(pd.DataFrame({\"item_id\": it.item_id, \"t\": t, \"ym\": [f\"{2022 + k // 12}-{k % 12 + 1:02d}\" for k in t],\n", + " \"month\": moy, \"exercise\": ex.astype(int), \"op_hours\": hours[it.equip_type].round(1),\n", + " \"availability\": avail[it.equip_type].round(3), \"demand\": d,\n", + " \"emergency\": rng.binomial(d, 0.05)}))\n", + " return pd.concat(rows, ignore_index=True)\n", + "\n", + "\n", + "def make_stock(items, hist, seed=4):\n", + " \"\"\"현재(가상 2026-01) 재고: 보유량과 미입고 발주량(조달 중인 물량). 단종 품목은 추가 발주가 어려워 미입고량이 적다.\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " recent = hist[hist[\"t\"] >= hist[\"t\"].max() - 5].groupby(\"item_id\")[\"demand\"].mean()\n", + " st = items[[\"item_id\", \"lead_time\", \"status\"]].copy()\n", + " st[\"on_hand\"] = [int(round(recent[i] * rng.uniform(0.5, 3.0))) for i in st[\"item_id\"]]\n", + " cover = np.where(st[\"status\"] == \"생산중\", rng.uniform(0.6, 1.3, len(st)), rng.uniform(0.0, 0.6, len(st)))\n", + " st[\"on_order\"] = [int(round(recent[i] * L * c)) for i, L, c in zip(st[\"item_id\"], st[\"lead_time\"], cover)]\n", + " return st[[\"item_id\", \"on_hand\", \"on_order\"]]\n", + "# ---------------------------------------------------------------------------\n", + "\n", + "items = make_items()\n", + "hist = make_history(items).merge(items[[\"item_id\", \"group\", \"price\", \"status\", \"lead_time\"]], on=\"item_id\")\n", + "TEST_FROM = 42 # 마지막 6개월(가상 2025-07 ~ 2025-12)을 평가 구간으로 둔다\n", + "\n", + "\n", + "def ses(y, alpha):\n", + " \"\"\"단순 지수평활: 다음 달 예측 = alpha x 이번 달 실적 + (1 - alpha) x 이번 달 예측.\"\"\"\n", + " f = np.empty(len(y)); f[0] = y[:6].mean()\n", + " for i in range(1, len(y)):\n", + " f[i] = alpha * y[i - 1] + (1 - alpha) * f[i - 1]\n", + " return f\n", + "\n", + "\n", + "# 1) Baseline 예측(한 달 앞): MA3, MA6, SES(품목별 alpha를 학습 구간에서 선택), 작년 같은 달\n", + "preds = []\n", + "for iid, g in hist.groupby(\"item_id\"):\n", + " y = g.sort_values(\"t\")[\"demand\"].to_numpy(float)\n", + " best = min([0.1, 0.2, 0.3, 0.5], key=lambda a: np.abs(ses(y, a)[12:TEST_FROM] - y[12:TEST_FROM]).mean())\n", + " s = pd.Series(y)\n", + " preds.append(pd.DataFrame({\"item_id\": iid, \"t\": np.arange(len(y)), \"y\": y, \"MA3\": s.shift(1).rolling(3).mean(),\n", + " \"MA6\": s.shift(1).rolling(6).mean(), \"SES\": ses(y, best), \"작년 같은 달\": s.shift(12)}))\n", + "P = pd.concat(preds)\n", + "T = P[P[\"t\"] >= TEST_FROM]\n", + "ab = items[items[\"base\"] >= 10][\"item_id\"] # 수요가 충분한 품목(MAPE 해석 가능)\n", + "rows = []\n", + "for m in [\"MA3\", \"MA6\", \"SES\", \"작년 같은 달\"]:\n", + " e = T[\"y\"] - T[m]\n", + " big = T[T[\"item_id\"].isin(ab)]\n", + " rows.append({\"방법\": m, \"MAE(개)\": round(e.abs().mean(), 2), \"WAPE\": f\"{e.abs().sum() / T['y'].sum():.1%}\",\n", + " \"MAPE(수요 많은 품목)\": f\"{(np.abs(big['y'] - big[m]) / big['y'].clip(lower=1)).mean():.1%}\"})\n", + "base_tbl = pd.DataFrame(rows)\n", + "print(\"[Baseline 예측 성능 - 평가 구간 6개월]\")\n", + "print(base_tbl.to_string(index=False))\n", + "print(\"해석: 이동평균은 훈련 월 급증과 계절 변화를 한 달 늦게 따라가 오차가 커진다(미션 4에서 개선).\")\n", + "\n", + "# 2) 상관 검정: 품목 평균 대비 수요 비율과 후보 피처\n", + "hist = hist.sort_values([\"item_id\", \"t\"])\n", + "hist[\"ratio\"] = hist[\"demand\"] / hist.groupby(\"item_id\")[\"demand\"].transform(\"mean\")\n", + "g = hist.groupby(\"item_id\")[\"ratio\"]\n", + "hist[\"lag1_ratio\"] = g.shift(1); hist[\"lag12_ratio\"] = g.shift(12)\n", + "hist[\"winter\"] = hist[\"month\"].isin([12, 1, 2]).astype(int)\n", + "cand = [\"op_hours\", \"exercise\", \"availability\", \"lag12_ratio\", \"lag1_ratio\", \"t\", \"winter\", \"price\"]\n", + "res = []\n", + "for c in cand:\n", + " d = hist[[\"ratio\", c]].dropna()\n", + " r, p = stats.pearsonr(d[c], d[\"ratio\"])\n", + " res.append({\"피처\": c, \"상관계수\": round(r, 3), \"p-value\": f\"{p:.1e}\", \"유의(p<0.05)\": \"예\" if p < 0.05 else \"아니오\"})\n", + "res = pd.DataFrame(res)\n", + "n_sig = (res[\"유의(p<0.05)\"] == \"예\").sum()\n", + "print(\"\\n[수요 비율과 피처의 상관 검정]\")\n", + "print(res.to_string(index=False))\n", + "print(f\"유의한 피처 {n_sig}개 (목표 5개 이상: {'달성' if n_sig >= 5 else '미달'})\")\n", + "t_stat, p_ex = stats.ttest_ind(hist[hist[\"exercise\"] == 1][\"ratio\"], hist[hist[\"exercise\"] == 0][\"ratio\"])\n", + "print(f\"훈련 월과 평시 월 수요 비율 차이 t검정: t={t_stat:.1f}, p={p_ex:.1e}\")\n", + "\n", + "# 3) ABC(파레토) 분류: 최근 12개월 수요 금액 기준\n", + "val = hist[hist[\"t\"] >= 36].groupby(\"item_id\").apply(lambda x: (x[\"demand\"] * x[\"price\"]).sum(), include_groups=False).sort_values(ascending=False)\n", + "cum = val.cumsum() / val.sum()\n", + "abc = pd.Series(np.where(cum <= 0.8, \"A\", np.where(cum <= 0.95, \"B\", \"C\")), index=val.index)\n", + "top = val.index[: int(np.ceil(len(val) * 0.2))]\n", + "print(f\"\\nABC 분류: A {sum(abc == 'A')}개 / B {sum(abc == 'B')}개 / C {sum(abc == 'C')}개, 상위 20% {len(top)}개 품목이 수요 금액의 {val[top].sum() / val.sum():.0%} 차지\")\n", + "st = items.set_index(\"item_id\").loc[top]\n", + "print(\"상위 20% 중 단종예고·단종 품목:\", \", \".join(f\"{i}({s})\" for i, s in st[\"status\"].items() if s != \"생산중\") or \"없음\")\n", + "\n", + "# 4) EDA 차트 8종\n", + "fig, ax = plt.subplots(2, 4, figsize=(18, 8))\n", + "tot = hist.groupby(\"t\")[\"demand\"].sum()\n", + "ax[0, 0].plot(tot.index, tot.values); ax[0, 0].set_title(\"1. Total monthly demand\")\n", + "hist.groupby([\"t\", \"group\"])[\"demand\"].sum().unstack().plot(ax=ax[0, 1], legend=False); ax[0, 1].set_title(\"2. Demand by FSC group\")\n", + "hist.boxplot(column=\"ratio\", by=\"month\", ax=ax[0, 2], grid=False); ax[0, 2].set_title(\"3. Seasonality (demand ratio)\")\n", + "ax[0, 3].scatter(hist[\"op_hours\"], hist[\"ratio\"], s=4, alpha=0.3); ax[0, 3].set_title(\"4. Operating hours vs demand ratio\")\n", + "hist.groupby(\"exercise\")[\"ratio\"].mean().plot.bar(ax=ax[1, 0]); ax[1, 0].set_title(\"5. Exercise month effect\")\n", + "ax[1, 1].plot(np.arange(1, len(cum) + 1), cum.values, \"o-\", ms=3); ax[1, 1].axhline(0.8, ls=\"--\", c=\"gray\"); ax[1, 1].set_title(\"6. Pareto curve (ABC)\")\n", + "items.assign(st=items[\"status\"].map({\"생산중\": \"active\", \"단종예고\": \"EOL notice\", \"단종\": \"discontinued\"})).boxplot(column=\"lead_time\", by=\"st\", ax=ax[1, 2], grid=False); ax[1, 2].set_title(\"7. Lead time by production status\")\n", + "ag = hist.groupby(\"t\").agg(av=(\"availability\", \"mean\"), d=(\"demand\", \"sum\"))\n", + "ax[1, 3].scatter(ag[\"av\"], ag[\"d\"]); ax[1, 3].set_title(\"8. Availability vs total demand\")\n", + "for a in ax.ravel():\n", + " a.set_xlabel(\"\"); a.tick_params(labelsize=7)\n", + "plt.suptitle(\"MNT02 EDA - 8 charts (synthetic data)\"); plt.tight_layout(); plt.show()\n", + "print(\"EDA 차트 8종 생성 완료. 핵심 발견: 운용시간·훈련 월이 수요를 끌어올리고, 단종 품목은 리드타임이 길어 결품 위험이 크다.\")\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 03 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + "[Baseline 예측 성능 - 평가 구간 6개월]\n", + " 방법 MAE(개) WAPE MAPE(수요 많은 품목)\n", + " MA3 7.34 28.7% 32.7%\n", + " MA6 7.09 27.7% 31.6%\n", + " SES 6.53 25.5% 28.9%\n", + "작년 같은 달 5.38 21.0% 22.8%\n", + "해석: 이동평균은 훈련 월 급증과 계절 변화를 한 달 늦게 따라가 오차가 커진다(미션 4에서 개선).\n", + "\n", + "[수요 비율과 피처의 상관 검정]\n", + " 피처 상관계수 p-value 유의(p<0.05)\n", + " op_hours 0.477 8.3e-110 예\n", + " exercise 0.470 4.4e-106 예\n", + "availability -0.450 1.6e-96 예\n", + " lag12_ratio 0.339 3.7e-40 예\n", + " lag1_ratio -0.111 1.5e-06 예\n", + " t 0.066 3.8e-03 예\n", + " winter -0.032 1.6e-01 아니오\n", + " price 0.000 1.0e+00 아니오\n", + "유의한 피처 6개 (목표 5개 이상: 달성)\n", + "훈련 월과 평시 월 수요 비율 차이 t검정: t=23.3, p=4.4e-106\n", + "\n", + "ABC 분류: A 13개 / B 13개 / C 14개, 상위 20% 8개 품목이 수요 금액의 67% 차지\n", + "상위 20% 중 단종예고·단종 품목: P016(단종), P028(단종), P032(단종예고), P007(단종예고)\n", + "EDA 차트 8종 생성 완료. 핵심 발견: 운용시간·훈련 월이 수요를 끌어올리고, 단종 품목은 리드타임이 길어 결품 위험이 크다.\n", + "```\n", + "
\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 04. 시계열 머신러닝(Prophet/LSTM) 기반 결전 대비 야전 부품 수요 예측 모델 고도화\n", + "**소요시간**: 약 3.0시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "시계열 머신러닝 기반 결전 대비 야전 부품 수요 예측 모델을 고도화한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **시계열 머신러닝으로 결전 대비 야전 부품 수요 예측 모델을 고도화한다(Prophet/LSTM은 실행 환경 제약으로 포아송 그래디언트 부스팅으로 대체).** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **지연값·이동평균·변동성·계절지수·훈련 계획·운용시간 등 예측 시점 이전 정보로 파생 피처 10개 이상 작성하여 원인을 분석하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 5개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `src/features/mnt02_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": [ + "\"\"\"과거 수요 지연값·이동평균·계절지수·훈련 계획·운용시간 등 파생 피처 15개로 시계열 머신러닝 수요 예측 모델(Prophet/LSTM 대신 포아송 그래디언트 부스팅)을 만들고, 시계열 교차검증으로 MAPE를 확인한 뒤 작전 템포 증가(결전 대비) 시 수요를 예측한다.\"\"\"\n", + "import numpy as np\n", + "import pandas as pd\n", + "import matplotlib.pyplot as plt\n", + "from sklearn.ensemble import HistGradientBoostingRegressor\n", + "from sklearn.inspection import permutation_importance\n", + "\n", + "# ---------------------------------------------------------------------------\n", + "# [교육용가상자료] 가상 야전 정비창 수리부속 청구/출고·장비 가동률 데이터 생성기\n", + "# 실제 부대·품목·재고·단가 정보와 무관하다. 품목 40개 x 48개월(가상 2022-01 ~ 2025-12) 월별 자료를 만든다.\n", + "# ---------------------------------------------------------------------------\n", + "FSC = {\"2520\": \"power_train\", \"2530\": \"brake_steer\", \"2910\": \"fuel\", \"2930\": \"cooling\",\n", + " \"2940\": \"filter\", \"5330\": \"gasket\", \"6140\": \"battery\", \"5961\": \"semiconductor\"}\n", + "EXERCISE = {3, 8, 10} # 가상 대규모 훈련 월(훈련 계획으로 미리 알 수 있음)\n", + "N_MONTHS = 48\n", + "\n", + "\n", + "def season(fsc, month):\n", + " \"\"\"품목군별 계절 계수: 축전지는 겨울, 냉각계통은 여름에 수요가 늘어난다.\"\"\"\n", + " if fsc == \"6140\":\n", + " return 1.6 if month in (12, 1, 2) else 0.9\n", + " if fsc == \"2930\":\n", + " return 1.5 if month in (6, 7, 8) else 0.9\n", + " return 1 + 0.1 * np.sin(2 * np.pi * month / 12)\n", + "\n", + "\n", + "def make_items(n=40, seed=2):\n", + " \"\"\"품목 마스터: NSN(가상), 품목군, 장비 유형, 기준 수요, 단가, 조달 리드타임, 공급업체 수, 생산(단종) 상태.\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " rows = []\n", + " for i in range(n):\n", + " f = str(rng.choice(list(FSC)))\n", + " status = str(rng.choice([\"생산중\", \"단종예고\", \"단종\"], p=[0.7, 0.2, 0.1]))\n", + " rows.append({\"item_id\": f\"P{i + 1:03d}\", \"nsn\": f\"{f}-00-{rng.integers(100, 1000)}-{rng.integers(1000, 10000)}\",\n", + " \"fsc\": f, \"group\": FSC[f], \"equip_type\": str(rng.choice([\"A\", \"B\", \"C\"])),\n", + " \"base\": float(np.round(rng.lognormal(2.7, 0.8), 1)),\n", + " \"price\": int(rng.lognormal(12, 1.0)) // 100 * 100,\n", + " \"lead_time\": int(rng.integers(1, 7) if status == \"생산중\" else rng.integers(4, 13)),\n", + " \"suppliers\": int(rng.integers(1, 5)) if status == \"생산중\" else 1,\n", + " \"status\": status, \"alt_available\": bool(rng.random() < (0.6 if status == \"생산중\" else 0.3))})\n", + " return pd.DataFrame(rows)\n", + "\n", + "\n", + "def make_history(items, months=N_MONTHS, seed=3):\n", + " \"\"\"월별 청구 수요(개), 긴급 불출 건수, 장비 유형별 운용시간·가동률.\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " t = np.arange(months)\n", + " moy = t % 12 + 1\n", + " ex = np.isin(moy, list(EXERCISE))\n", + " hours = {e: (100 + 20 * rng.random(months)) * np.where(ex, 1.5, 1.0) for e in \"ABC\"}\n", + " avail = {e: np.clip(0.93 - 0.12 * (hours[e] - 100) / 80 + rng.normal(0, 0.015, months), 0.6, 0.99) for e in \"ABC\"}\n", + " rows = []\n", + " for it in items.itertuples():\n", + " trend = 1 + rng.uniform(-0.1, 0.3) * t / months\n", + " s = np.array([season(it.fsc, m) for m in moy])\n", + " lam = it.base * s * trend * hours[it.equip_type] / 110\n", + " d = rng.poisson(lam)\n", + " rows.append(pd.DataFrame({\"item_id\": it.item_id, \"t\": t, \"ym\": [f\"{2022 + k // 12}-{k % 12 + 1:02d}\" for k in t],\n", + " \"month\": moy, \"exercise\": ex.astype(int), \"op_hours\": hours[it.equip_type].round(1),\n", + " \"availability\": avail[it.equip_type].round(3), \"demand\": d,\n", + " \"emergency\": rng.binomial(d, 0.05)}))\n", + " return pd.concat(rows, ignore_index=True)\n", + "\n", + "\n", + "def make_stock(items, hist, seed=4):\n", + " \"\"\"현재(가상 2026-01) 재고: 보유량과 미입고 발주량(조달 중인 물량). 단종 품목은 추가 발주가 어려워 미입고량이 적다.\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " recent = hist[hist[\"t\"] >= hist[\"t\"].max() - 5].groupby(\"item_id\")[\"demand\"].mean()\n", + " st = items[[\"item_id\", \"lead_time\", \"status\"]].copy()\n", + " st[\"on_hand\"] = [int(round(recent[i] * rng.uniform(0.5, 3.0))) for i in st[\"item_id\"]]\n", + " cover = np.where(st[\"status\"] == \"생산중\", rng.uniform(0.6, 1.3, len(st)), rng.uniform(0.0, 0.6, len(st)))\n", + " st[\"on_order\"] = [int(round(recent[i] * L * c)) for i, L, c in zip(st[\"item_id\"], st[\"lead_time\"], cover)]\n", + " return st[[\"item_id\", \"on_hand\", \"on_order\"]]\n", + "# ---------------------------------------------------------------------------\n", + "\n", + "# ---- 수요 예측 공통 함수(파생 피처 + 머신러닝 모델) ----\n", + "FEATS = [\"lag1\", \"lag2\", \"lag3\", \"lag12\", \"ma3\", \"ma6\", \"std6\", \"season_idx\", \"month_sin\", \"month_cos\",\n", + " \"exercise\", \"op_hours\", \"op_ratio\", \"item_level\", \"trend_t\"]\n", + "\n", + "\n", + "def make_features(hist):\n", + " \"\"\"품목별 과거 수요로 파생 피처를 만든다(모두 예측 시점 이전 정보만 사용).\"\"\"\n", + " df = hist.sort_values([\"item_id\", \"t\"]).reset_index(drop=True)\n", + " g = df.groupby(\"item_id\")[\"demand\"]\n", + " for k in (1, 2, 3, 12):\n", + " df[f\"lag{k}\"] = g.shift(k)\n", + " df[\"ma3\"] = g.transform(lambda s: s.shift(1).rolling(3).mean())\n", + " df[\"ma6\"] = g.transform(lambda s: s.shift(1).rolling(6).mean())\n", + " df[\"std6\"] = g.transform(lambda s: s.shift(1).rolling(6).std())\n", + " df[\"item_level\"] = g.transform(lambda s: s.shift(1).rolling(12).mean())\n", + " df[\"season_idx\"] = df[\"lag12\"] / (g.transform(lambda s: s.shift(12).rolling(12).mean()) + 1)\n", + " df[\"month_sin\"] = np.sin(2 * np.pi * df[\"month\"] / 12)\n", + " df[\"month_cos\"] = np.cos(2 * np.pi * df[\"month\"] / 12)\n", + " df[\"op_ratio\"] = df[\"op_hours\"] / df.groupby(\"item_id\")[\"op_hours\"].transform(lambda s: s.shift(1).rolling(12).mean())\n", + " df[\"trend_t\"] = df[\"t\"]\n", + " return df.dropna().reset_index(drop=True)\n", + "\n", + "\n", + "def new_model():\n", + " \"\"\"Prophet/LSTM 대신 사용하는 포아송 손실 그래디언트 부스팅(개수 자료에 적합).\"\"\"\n", + " return HistGradientBoostingRegressor(loss=\"poisson\", max_iter=300, learning_rate=0.05, max_leaf_nodes=15,\n", + " min_samples_leaf=20, random_state=0)\n", + "\n", + "\n", + "def mape(y, p, min_y=1):\n", + " y, p = np.asarray(y, float), np.asarray(p, float)\n", + " m = y >= min_y\n", + " return float(np.mean(np.abs(y[m] - p[m]) / y[m]))\n", + "\n", + "items = make_items()\n", + "hist = make_history(items)\n", + "df = make_features(hist)\n", + "ab = set(items[items[\"base\"] >= 10][\"item_id\"]) # 수요가 충분한 품목(MAPE 평가 대상)\n", + "print(f\"파생 피처 {len(FEATS)}개 (목표 10개 이상): {', '.join(FEATS)}\")\n", + "\n", + "# 1) 시계열 교차검증(확장 창): 과거로 학습 -> 바로 다음 6개월 평가, 3회 반복\n", + "rows = []\n", + "for cut in (30, 36, 42):\n", + " tr, te = df[df[\"t\"] < cut], df[(df[\"t\"] >= cut) & (df[\"t\"] < cut + 6)]\n", + " p = new_model().fit(tr[FEATS], tr[\"demand\"]).predict(te[FEATS])\n", + " big = te[\"item_id\"].isin(ab).to_numpy()\n", + " rows.append({\"학습 구간\": f\"t<{cut}\", \"평가 구간\": f\"t={cut}~{cut + 5}\",\n", + " \"ML MAE\": round(np.abs(te[\"demand\"] - p).mean(), 2), \"MA3 MAE\": round(np.abs(te[\"demand\"] - te[\"ma3\"]).mean(), 2),\n", + " \"ML MAPE\": mape(te[\"demand\"][big], p[big]), \"MA3 MAPE\": mape(te[\"demand\"][big], te[\"ma3\"][big])})\n", + "cv = pd.DataFrame(rows)\n", + "print(\"\\n[시계열 교차검증 결과 - MAPE는 수요 많은 품목 기준]\")\n", + "print(cv.assign(**{\"ML MAPE\": cv[\"ML MAPE\"].map(\"{:.1%}\".format), \"MA3 MAPE\": cv[\"MA3 MAPE\"].map(\"{:.1%}\".format)}).to_string(index=False))\n", + "m_mape = cv[\"ML MAPE\"].mean()\n", + "print(f\"평균 MAPE: ML {m_mape:.1%} / MA3 {cv['MA3 MAPE'].mean():.1%} -> 목표(MAPE 25% 이하): {'달성' if m_mape <= 0.25 else '미달'}\")\n", + "\n", + "# 2) 최종 모델(t<42 학습)과 피처 중요도(순열 중요도)\n", + "tr, te = df[df[\"t\"] < 42], df[df[\"t\"] >= 42]\n", + "model = new_model().fit(tr[FEATS], tr[\"demand\"])\n", + "imp = permutation_importance(model, te[FEATS], te[\"demand\"], n_repeats=5, random_state=0, scoring=\"neg_mean_absolute_error\")\n", + "imp = pd.Series(imp.importances_mean, index=FEATS).sort_values(ascending=False)\n", + "print(\"\\n[피처 중요도 상위 6개(MAE 증가량)]\", \", \".join(f\"{k} {v:.2f}\" for k, v in imp.head(6).items()))\n", + "print(\"중요도가 0에 가까운 피처(차원 축소 후보):\", \", \".join(imp[imp < 0.02].index) or \"없음\")\n", + "\n", + "# 3) 결전 대비 시나리오: 다음 달(가상 2026-01) 운용시간 1.5배 + 훈련 월 지정\n", + "future = []\n", + "for it in items.itertuples():\n", + " last = hist[(hist[\"item_id\"] == it.item_id) & (hist[\"t\"] == 47)].iloc[0]\n", + " future.append({**last.to_dict(), \"t\": 48, \"ym\": \"2026-01\", \"month\": 1, \"demand\": 0, \"exercise\": 0})\n", + "fut = pd.DataFrame(future)\n", + "fut[\"op_hours\"] = hist[hist[\"t\"] == 36].set_index(\"item_id\").loc[fut[\"item_id\"], \"op_hours\"].to_numpy() # 작년 1월 계획 운용시간\n", + "res = {}\n", + "for name, mult, ex in [(\"평시 계획\", 1.0, 0), (\"작전 템포 1.5배\", 1.5, 1)]:\n", + " f2 = fut.assign(op_hours=fut[\"op_hours\"] * mult, exercise=ex)\n", + " X = make_features(pd.concat([hist, f2], ignore_index=True))\n", + " X = X[X[\"t\"] == 48]\n", + " res[name] = pd.Series(model.predict(X[FEATS]), index=X[\"item_id\"])\n", + "comp = pd.DataFrame(res).round(1)\n", + "comp[\"증가율\"] = (comp[\"작전 템포 1.5배\"] / comp[\"평시 계획\"] - 1).map(\"{:+.0%}\".format)\n", + "comp = comp.join(items.set_index(\"item_id\")[[\"group\", \"status\"]])\n", + "print(\"\\n[결전 대비 다음 달 수요 예측 - 증가량 상위 6개 품목]\")\n", + "print(comp.loc[(comp[\"작전 템포 1.5배\"] - comp[\"평시 계획\"]).sort_values(ascending=False).index[:6]].to_string())\n", + "print(f\"전체 예측 수요: 평시 {comp['평시 계획'].sum():.0f}개 -> 작전 템포 1.5배 {comp['작전 템포 1.5배'].sum():.0f}개\")\n", + "\n", + "# 4) 예측 그림: 수요 많은 품목 1개\n", + "iid = items.sort_values(\"base\")[\"item_id\"].iloc[-1]\n", + "s = te[te[\"item_id\"] == iid]\n", + "full = df[df[\"item_id\"] == iid]\n", + "plt.figure(figsize=(9, 4))\n", + "plt.plot(full[\"t\"], full[\"demand\"], \"k.-\", label=\"actual\")\n", + "plt.plot(s[\"t\"], model.predict(s[FEATS]), \"r-o\", label=\"ML forecast\")\n", + "plt.plot(s[\"t\"], s[\"ma3\"], \"b--\", label=\"MA3 baseline\")\n", + "plt.axvline(41.5, c=\"gray\", ls=\":\"); plt.xlabel(\"month index\"); plt.ylabel(\"demand (EA)\")\n", + "plt.title(f\"{iid} one-month-ahead forecast (synthetic)\"); plt.legend(); plt.show()\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 04 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + "파생 피처 15개 (목표 10개 이상): lag1, lag2, lag3, lag12, ma3, ma6, std6, season_idx, month_sin, month_cos, exercise, op_hours, op_ratio, item_level, trend_t\n", + "\n", + "[시계열 교차검증 결과 - MAPE는 수요 많은 품목 기준]\n", + "학습 구간 평가 구간 ML MAE MA3 MAE ML MAPE MA3 MAPE\n", + " t<30 t=30~35 5.85 7.26 25.8% 30.9%\n", + " t<36 t=36~41 4.84 7.26 20.5% 30.9%\n", + " t<42 t=42~47 4.57 7.34 20.8% 32.7%\n", + "평균 MAPE: ML 22.3% / MA3 31.5% -> 목표(MAPE 25% 이하): 달성\n", + "\n", + "[피처 중요도 상위 6개(MAE 증가량)] lag12 7.83, item_level 7.20, op_hours 0.95, std6 0.27, ma3 0.19, op_ratio 0.13\n", + "중요도가 0에 가까운 피처(차원 축소 후보): ma6, trend_t, lag2\n", + "\n", + "[결전 대비 다음 달 수요 예측 - 증가량 상위 6개 품목]\n", + " 평시 계획 작전 템포 1.5배 증가율 group status\n", + "item_id \n", + "P016 138.9 168.2 +21% gasket 단종\n", + "P028 74.1 99.5 +34% power_train 단종\n", + "P033 73.3 94.1 +28% fuel 생산중\n", + "P035 78.5 94.1 +20% brake_steer 생산중\n", + "P030 43.4 56.8 +31% brake_steer 단종예고\n", + "P026 15.6 24.3 +56% cooling 생산중\n", + "전체 예측 수요: 평시 946개 -> 작전 템포 1.5배 1210개\n", + "```\n", + "
\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 05. 적정 안전재고량 산출 및 결품 위험 지수(Stockout Risk) 엔진 개발\n", + "**소요시간**: 약 3.5시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "적정 안전재고량 산출 및 결품 위험 지수 엔진을 개발한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **앙상블 수요 예측으로 품목별 적정 안전재고·재주문점을 산출하고 단종 위험을 반영한 결품 위험 지수(Stockout Risk) 엔진을 개발한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **포아송 그래디언트 부스팅과 랜덤포레스트를 앙상블하고 가중치는 학습 기간 안의 검증 구간으로 고른다 과정을 진행하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `src/models/mnt02_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": [ + "\"\"\"포아송 그래디언트 부스팅·랜덤포레스트 앙상블로 수요 예측을 고도화해 이동평균 기준 모델 대비 MAE/RMSE 개선율과 R2를 검증하고, 예측 오차로 품목별 적정 안전재고·재주문점을 산출한 뒤 리드타임 결품 확률과 단종 상태·단일 공급업체·대체품 부재를 결합한 결품 위험 지수 엔진을 만든다.\"\"\"\n", + "import numpy as np\n", + "import pandas as pd\n", + "import matplotlib.pyplot as plt\n", + "from scipy.stats import norm\n", + "from sklearn.ensemble import HistGradientBoostingRegressor, RandomForestRegressor\n", + "from sklearn.metrics import mean_absolute_error, mean_squared_error, r2_score\n", + "\n", + "# ---------------------------------------------------------------------------\n", + "# [교육용가상자료] 가상 야전 정비창 수리부속 청구/출고·장비 가동률 데이터 생성기\n", + "# 실제 부대·품목·재고·단가 정보와 무관하다. 품목 40개 x 48개월(가상 2022-01 ~ 2025-12) 월별 자료를 만든다.\n", + "# ---------------------------------------------------------------------------\n", + "FSC = {\"2520\": \"power_train\", \"2530\": \"brake_steer\", \"2910\": \"fuel\", \"2930\": \"cooling\",\n", + " \"2940\": \"filter\", \"5330\": \"gasket\", \"6140\": \"battery\", \"5961\": \"semiconductor\"}\n", + "EXERCISE = {3, 8, 10} # 가상 대규모 훈련 월(훈련 계획으로 미리 알 수 있음)\n", + "N_MONTHS = 48\n", + "\n", + "\n", + "def season(fsc, month):\n", + " \"\"\"품목군별 계절 계수: 축전지는 겨울, 냉각계통은 여름에 수요가 늘어난다.\"\"\"\n", + " if fsc == \"6140\":\n", + " return 1.6 if month in (12, 1, 2) else 0.9\n", + " if fsc == \"2930\":\n", + " return 1.5 if month in (6, 7, 8) else 0.9\n", + " return 1 + 0.1 * np.sin(2 * np.pi * month / 12)\n", + "\n", + "\n", + "def make_items(n=40, seed=2):\n", + " \"\"\"품목 마스터: NSN(가상), 품목군, 장비 유형, 기준 수요, 단가, 조달 리드타임, 공급업체 수, 생산(단종) 상태.\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " rows = []\n", + " for i in range(n):\n", + " f = str(rng.choice(list(FSC)))\n", + " status = str(rng.choice([\"생산중\", \"단종예고\", \"단종\"], p=[0.7, 0.2, 0.1]))\n", + " rows.append({\"item_id\": f\"P{i + 1:03d}\", \"nsn\": f\"{f}-00-{rng.integers(100, 1000)}-{rng.integers(1000, 10000)}\",\n", + " \"fsc\": f, \"group\": FSC[f], \"equip_type\": str(rng.choice([\"A\", \"B\", \"C\"])),\n", + " \"base\": float(np.round(rng.lognormal(2.7, 0.8), 1)),\n", + " \"price\": int(rng.lognormal(12, 1.0)) // 100 * 100,\n", + " \"lead_time\": int(rng.integers(1, 7) if status == \"생산중\" else rng.integers(4, 13)),\n", + " \"suppliers\": int(rng.integers(1, 5)) if status == \"생산중\" else 1,\n", + " \"status\": status, \"alt_available\": bool(rng.random() < (0.6 if status == \"생산중\" else 0.3))})\n", + " return pd.DataFrame(rows)\n", + "\n", + "\n", + "def make_history(items, months=N_MONTHS, seed=3):\n", + " \"\"\"월별 청구 수요(개), 긴급 불출 건수, 장비 유형별 운용시간·가동률.\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " t = np.arange(months)\n", + " moy = t % 12 + 1\n", + " ex = np.isin(moy, list(EXERCISE))\n", + " hours = {e: (100 + 20 * rng.random(months)) * np.where(ex, 1.5, 1.0) for e in \"ABC\"}\n", + " avail = {e: np.clip(0.93 - 0.12 * (hours[e] - 100) / 80 + rng.normal(0, 0.015, months), 0.6, 0.99) for e in \"ABC\"}\n", + " rows = []\n", + " for it in items.itertuples():\n", + " trend = 1 + rng.uniform(-0.1, 0.3) * t / months\n", + " s = np.array([season(it.fsc, m) for m in moy])\n", + " lam = it.base * s * trend * hours[it.equip_type] / 110\n", + " d = rng.poisson(lam)\n", + " rows.append(pd.DataFrame({\"item_id\": it.item_id, \"t\": t, \"ym\": [f\"{2022 + k // 12}-{k % 12 + 1:02d}\" for k in t],\n", + " \"month\": moy, \"exercise\": ex.astype(int), \"op_hours\": hours[it.equip_type].round(1),\n", + " \"availability\": avail[it.equip_type].round(3), \"demand\": d,\n", + " \"emergency\": rng.binomial(d, 0.05)}))\n", + " return pd.concat(rows, ignore_index=True)\n", + "\n", + "\n", + "def make_stock(items, hist, seed=4):\n", + " \"\"\"현재(가상 2026-01) 재고: 보유량과 미입고 발주량(조달 중인 물량). 단종 품목은 추가 발주가 어려워 미입고량이 적다.\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " recent = hist[hist[\"t\"] >= hist[\"t\"].max() - 5].groupby(\"item_id\")[\"demand\"].mean()\n", + " st = items[[\"item_id\", \"lead_time\", \"status\"]].copy()\n", + " st[\"on_hand\"] = [int(round(recent[i] * rng.uniform(0.5, 3.0))) for i in st[\"item_id\"]]\n", + " cover = np.where(st[\"status\"] == \"생산중\", rng.uniform(0.6, 1.3, len(st)), rng.uniform(0.0, 0.6, len(st)))\n", + " st[\"on_order\"] = [int(round(recent[i] * L * c)) for i, L, c in zip(st[\"item_id\"], st[\"lead_time\"], cover)]\n", + " return st[[\"item_id\", \"on_hand\", \"on_order\"]]\n", + "# ---------------------------------------------------------------------------\n", + "\n", + "# ---- 수요 예측 공통 함수(파생 피처 + 머신러닝 모델) ----\n", + "FEATS = [\"lag1\", \"lag2\", \"lag3\", \"lag12\", \"ma3\", \"ma6\", \"std6\", \"season_idx\", \"month_sin\", \"month_cos\",\n", + " \"exercise\", \"op_hours\", \"op_ratio\", \"item_level\", \"trend_t\"]\n", + "\n", + "\n", + "def make_features(hist):\n", + " \"\"\"품목별 과거 수요로 파생 피처를 만든다(모두 예측 시점 이전 정보만 사용).\"\"\"\n", + " df = hist.sort_values([\"item_id\", \"t\"]).reset_index(drop=True)\n", + " g = df.groupby(\"item_id\")[\"demand\"]\n", + " for k in (1, 2, 3, 12):\n", + " df[f\"lag{k}\"] = g.shift(k)\n", + " df[\"ma3\"] = g.transform(lambda s: s.shift(1).rolling(3).mean())\n", + " df[\"ma6\"] = g.transform(lambda s: s.shift(1).rolling(6).mean())\n", + " df[\"std6\"] = g.transform(lambda s: s.shift(1).rolling(6).std())\n", + " df[\"item_level\"] = g.transform(lambda s: s.shift(1).rolling(12).mean())\n", + " df[\"season_idx\"] = df[\"lag12\"] / (g.transform(lambda s: s.shift(12).rolling(12).mean()) + 1)\n", + " df[\"month_sin\"] = np.sin(2 * np.pi * df[\"month\"] / 12)\n", + " df[\"month_cos\"] = np.cos(2 * np.pi * df[\"month\"] / 12)\n", + " df[\"op_ratio\"] = df[\"op_hours\"] / df.groupby(\"item_id\")[\"op_hours\"].transform(lambda s: s.shift(1).rolling(12).mean())\n", + " df[\"trend_t\"] = df[\"t\"]\n", + " return df.dropna().reset_index(drop=True)\n", + "\n", + "\n", + "def new_model():\n", + " \"\"\"Prophet/LSTM 대신 사용하는 포아송 손실 그래디언트 부스팅(개수 자료에 적합).\"\"\"\n", + " return HistGradientBoostingRegressor(loss=\"poisson\", max_iter=300, learning_rate=0.05, max_leaf_nodes=15,\n", + " min_samples_leaf=20, random_state=0)\n", + "\n", + "\n", + "def mape(y, p, min_y=1):\n", + " y, p = np.asarray(y, float), np.asarray(p, float)\n", + " m = y >= min_y\n", + " return float(np.mean(np.abs(y[m] - p[m]) / y[m]))\n", + "\n", + "# ---- 앙상블 수요 예측·안전재고·결품(단종) 위험 지수 공통 함수 ----\n", + "STATUS_W = {\"생산중\": 0.0, \"단종예고\": 0.5, \"단종\": 1.0} # [교육용가상자료] 단종 상태 가중치\n", + "\n", + "\n", + "def fit_ens(tr):\n", + " \"\"\"포아송 그래디언트 부스팅(70%) + 랜덤포레스트(30%) 앙상블.\"\"\"\n", + " h = new_model().fit(tr[FEATS], tr[\"demand\"])\n", + " r = RandomForestRegressor(n_estimators=150, min_samples_leaf=5, random_state=0, n_jobs=1).fit(tr[FEATS], tr[\"demand\"])\n", + " return h, r\n", + "\n", + "\n", + "def pred_ens(m, X, w=0.7):\n", + " return np.clip(w * m[0].predict(X[FEATS]) + (1 - w) * m[1].predict(X[FEATS]), 0, None)\n", + "\n", + "\n", + "def next_month(hist, items, t_new=48, op_mult=1.0, ex=0):\n", + " \"\"\"다음 달(t_new) 예측용 행: 작년 같은 달 운용시간 x 배수, 훈련 여부 지정.\"\"\"\n", + " last = hist[hist[\"t\"] == t_new - 1].copy()\n", + " prev = hist[hist[\"t\"] == t_new - 12].set_index(\"item_id\")[\"op_hours\"]\n", + " m = (t_new % 12) + 1\n", + " f = last.assign(t=t_new, ym=f\"{2022 + t_new // 12}-{m:02d}\", month=m, demand=0, exercise=ex,\n", + " op_hours=last[\"item_id\"].map(prev).to_numpy() * op_mult)\n", + " X = make_features(pd.concat([hist, f], ignore_index=True))\n", + " return X[X[\"t\"] == t_new].set_index(\"item_id\").loc[items[\"item_id\"]].reset_index()\n", + "\n", + "\n", + "def stock_policy(items, mu, sigma, z=1.65, review=1):\n", + " \"\"\"안전재고 = z x 월 예측오차 표준편차 x sqrt(리드타임+검토주기), 재주문점 = 월 수요 x (리드타임+검토주기) + 안전재고.\"\"\"\n", + " L = items[\"lead_time\"].to_numpy() + review\n", + " ss = z * sigma * np.sqrt(L)\n", + " return pd.DataFrame({\"item_id\": items[\"item_id\"], \"mu\": mu, \"sigma\": sigma, \"L\": L,\n", + " \"ss\": np.ceil(ss), \"rop\": np.ceil(mu * L + ss)})\n", + "\n", + "\n", + "def risk_index(items, pol, stock):\n", + " \"\"\"결품 위험 지수(0~100): 리드타임 동안 수요가 가용 재고를 넘을 확률(정규 근사)을 기본으로,\n", + " 단종 상태·단일 공급업체·대체품 부재에 가중을 준다(가상 가중치).\"\"\"\n", + " st = stock.set_index(\"item_id\").loc[items[\"item_id\"]]\n", + " avail = (st[\"on_hand\"] + st[\"on_order\"]).to_numpy()\n", + " p = 1 - norm.cdf((avail - pol[\"mu\"].to_numpy() * pol[\"L\"].to_numpy()) / (pol[\"sigma\"].to_numpy() * np.sqrt(pol[\"L\"].to_numpy()) + 1e-6))\n", + " sw = items[\"status\"].map(STATUS_W).to_numpy()\n", + " sup = (items[\"suppliers\"].to_numpy() == 1).astype(float)\n", + " alt = (~items[\"alt_available\"].to_numpy()).astype(float)\n", + " score = 100 * np.clip(0.55 * p + 0.25 * sw + 0.10 * sup + 0.10 * alt, 0, 1)\n", + " return pd.DataFrame({\"item_id\": items[\"item_id\"], \"p_short\": p, \"status_w\": sw, \"single_sup\": sup, \"no_alt\": alt,\n", + " \"avail\": avail, \"risk\": score.round(1)})\n", + "\n", + "items = make_items()\n", + "hist = make_history(items)\n", + "df = make_features(hist)\n", + "stock = make_stock(items, hist)\n", + "\n", + "# 1) Hold-out(t=42~47) 성능 비교: 기준 모델(MA3) / 단일 모델 / 앙상블. 앙상블 가중치는 t=36~41 검증 구간에서 고른다\n", + "inner = fit_ens(df[df[\"t\"] < 36]); vv = df[(df[\"t\"] >= 36) & (df[\"t\"] < 42)]\n", + "best_w = min((0.5, 0.6, 0.7, 0.8, 0.9, 1.0), key=lambda w: mean_absolute_error(vv[\"demand\"], pred_ens(inner, vv, w)))\n", + "tr, te = df[df[\"t\"] < 42], df[df[\"t\"] >= 42].copy()\n", + "ens = fit_ens(tr)\n", + "te[\"ens\"], te[\"hgb\"] = pred_ens(ens, te, best_w), np.clip(ens[0].predict(te[FEATS]), 0, None)\n", + "rows = {}\n", + "for nm, col in [(\"기준 모델(MA3)\", \"ma3\"), (\"단일 포아송 GBM\", \"hgb\"), (f\"앙상블(w={best_w})\", \"ens\")]:\n", + " rows[nm] = {\"MAE\": mean_absolute_error(te[\"demand\"], te[col]), \"RMSE\": mean_squared_error(te[\"demand\"], te[col]) ** 0.5,\n", + " \"R2\": r2_score(te[\"demand\"], te[col])}\n", + "res = pd.DataFrame(rows).T\n", + "print(\"[Hold-out 6개월(t=42~47), 품목 40개]\")\n", + "print(res.to_string(float_format=lambda x: f\"{x:.3f}\"))\n", + "b, e = res.iloc[0], res.iloc[2]\n", + "im, ir = 1 - e[\"MAE\"] / b[\"MAE\"], 1 - e[\"RMSE\"] / b[\"RMSE\"]\n", + "print(f\"오차율 개선 MAE {im:.1%}, RMSE {ir:.1%} (목표 20% 이상: {'달성' if min(im, ir) >= 0.2 else '미달'}), R2 {e['R2']:.3f} (목표 0.85 이상: {'달성' if e['R2'] >= 0.85 else '미달'})\")\n", + "spike = te[\"exercise\"] == 1\n", + "print(f\"불규칙 수요(훈련 월) MAE: 기준 {mean_absolute_error(te.loc[spike, 'demand'], te.loc[spike, 'ma3']):.2f} -> 앙상블 {mean_absolute_error(te.loc[spike, 'demand'], te.loc[spike, 'ens']):.2f}\")\n", + "\n", + "# 2) 안전재고·재주문점: 다음 달 예측과 Hold-out 품목별 오차 표준편차\n", + "X = next_month(hist, items)\n", + "mu = pred_ens(fit_ens(df), X, best_w)\n", + "err = (te[\"demand\"] - te[\"ens\"]).groupby(te[\"item_id\"]).std().reindex(items[\"item_id\"]).to_numpy()\n", + "sigma = np.maximum(err, np.sqrt(np.maximum(mu, 1))) # 오차 표준편차가 포아송 변동보다 작으면 포아송 기준 사용\n", + "pol = stock_policy(items, mu, sigma)\n", + "risk = risk_index(items, pol, stock)\n", + "out = items[[\"item_id\", \"group\", \"status\", \"lead_time\", \"suppliers\"]].merge(pol, on=\"item_id\").merge(risk, on=\"item_id\")\n", + "print(f\"\\n[다음 달(가상 2026-01) 안전재고 정책] 서비스 수준 95%(z=1.65), 안전재고 합 {out['ss'].sum():.0f}개\")\n", + "top = out.sort_values(\"risk\", ascending=False).head(8)\n", + "show = top[[\"item_id\", \"group\", \"status\", \"lead_time\", \"mu\", \"ss\", \"rop\", \"avail\", \"p_short\", \"risk\"]].copy()\n", + "show.columns = [\"품목\", \"품목군\", \"생산상태\", \"LT(월)\", \"월수요\", \"안전재고\", \"재주문점\", \"가용재고\", \"결품확률\", \"위험지수\"]\n", + "print(\"[결품 위험 지수 상위 8개]\")\n", + "print(show.to_string(index=False, float_format=lambda x: f\"{x:.2f}\"))\n", + "grade = pd.cut(out[\"risk\"], [-1, 30, 60, 101], labels=[\"관찰\", \"주의\", \"경보\"]).value_counts()\n", + "print(f\"위험 등급: 경보 {grade['경보']}개, 주의 {grade['주의']}개, 관찰 {grade['관찰']}개 / 재주문점 미만 품목 {(out['avail'] < out['rop']).sum()}개\")\n", + "print(\"단종·단종예고 품목은 리드타임이 길고 공급업체가 1곳이라 같은 결품 확률에서도 위험 지수가 높게 나온다.\")\n", + "\n", + "fig, ax = plt.subplots(1, 2, figsize=(12, 4))\n", + "ax[0].scatter(te[\"demand\"], te[\"ma3\"], s=8, alpha=0.5, label=\"MA3 baseline\"); ax[0].scatter(te[\"demand\"], te[\"ens\"], s=8, alpha=0.5, label=\"ensemble\")\n", + "mx = te[\"demand\"].max(); ax[0].plot([0, mx], [0, mx], \"k--\", lw=1); ax[0].set_xlabel(\"actual\"); ax[0].set_ylabel(\"forecast\"); ax[0].legend()\n", + "ax[0].set_title(\"Hold-out actual vs forecast\")\n", + "col = out[\"status\"].map({\"생산중\": \"tab:green\", \"단종예고\": \"tab:orange\", \"단종\": \"tab:red\"})\n", + "ax[1].scatter(out[\"p_short\"], out[\"risk\"], c=col); ax[1].set_xlabel(\"shortage probability over lead time\"); ax[1].set_ylabel(\"risk index\")\n", + "ax[1].set_title(\"Stockout risk (green=active, orange=EOL notice, red=discontinued)\")\n", + "plt.tight_layout(); plt.show()\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 05 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + "[Hold-out 6개월(t=42~47), 품목 40개]\n", + " MAE RMSE R2\n", + "기준 모델(MA3) 7.339 11.130 0.839\n", + "단일 포아송 GBM 4.572 6.112 0.952\n", + "앙상블(w=0.5) 4.541 6.059 0.952\n", + "오차율 개선 MAE 38.1%, RMSE 45.6% (목표 20% 이상: 달성), R2 0.952 (목표 0.85 이상: 달성)\n", + "불규칙 수요(훈련 월) MAE: 기준 9.88 -> 앙상블 4.94\n", + "\n", + "[다음 달(가상 2026-01) 안전재고 정책] 서비스 수준 95%(z=1.65), 안전재고 합 926개\n", + "[결품 위험 지수 상위 8개]\n", + " 품목 품목군 생산상태 LT(월) 월수요 안전재고 재주문점 가용재고 결품확률 위험지수\n", + "P018 battery 단종 4 17.48 23.00 111.00 71 0.88 93.60\n", + "P016 gasket 단종 12 148.02 95.00 2019.00 714 1.00 90.00\n", + "P028 power_train 단종 11 68.99 48.00 876.00 515 1.00 90.00\n", + "P004 filter 단종예고 6 7.65 16.00 70.00 12 1.00 87.50\n", + "P025 filter 단종예고 12 13.94 23.00 204.00 89 1.00 87.50\n", + "P030 brake_steer 단종예고 12 45.84 43.00 639.00 162 1.00 87.50\n", + "P038 fuel 단종예고 6 11.87 25.00 108.00 40 1.00 87.40\n", + "P019 filter 단종예고 8 11.47 30.00 133.00 59 0.99 87.10\n", + "위험 등급: 경보 13개, 주의 2개, 관찰 25개 / 재주문점 미만 품목 20개\n", + "단종·단종예고 품목은 리드타임이 길고 공급업체가 1곳이라 같은 결품 확률에서도 위험 지수가 높게 나온다.\n", + "```\n", + "
\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 06. 야전 부품 재고 현황 및 긴급 조달 필요 품목 가시화 대시보드 구현\n", + "**소요시간**: 약 3.0시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "야전 부품 재고 현황 및 긴급 조달 필요 품목 대시보드를 구현한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **야전 부품 재고 현황과 긴급 조달 필요 품목을 보여 주는 대시보드를 만들고 품목별 위험 원인을 설명한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **가용재고와 재주문점, 품목군·생산 상태별 위험 지수, 긴급 조달 필요 품목을 그림으로 표시 수행하고 검증하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `src/explainability/mnt02_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": [ + "\"\"\"야전 부품 재고 현황(가용재고·재주문점·결품 위험 지수)과 긴급 조달 필요 품목을 보여 주는 대시보드를 만들고, 품목마다 위험 지수를 끌어올린 요인을 기준값 대체(occlusion) 방식으로 분해해 기여도 상위 3개 인자와 자연어 설명문('리드타임 증가 원인은 단종' 등)을 자동 생성한다.\"\"\"\n", + "import numpy as np\n", + "import pandas as pd\n", + "import matplotlib.pyplot as plt\n", + "from scipy.stats import norm\n", + "from sklearn.ensemble import HistGradientBoostingRegressor, RandomForestRegressor\n", + "\n", + "# ---------------------------------------------------------------------------\n", + "# [교육용가상자료] 가상 야전 정비창 수리부속 청구/출고·장비 가동률 데이터 생성기\n", + "# 실제 부대·품목·재고·단가 정보와 무관하다. 품목 40개 x 48개월(가상 2022-01 ~ 2025-12) 월별 자료를 만든다.\n", + "# ---------------------------------------------------------------------------\n", + "FSC = {\"2520\": \"power_train\", \"2530\": \"brake_steer\", \"2910\": \"fuel\", \"2930\": \"cooling\",\n", + " \"2940\": \"filter\", \"5330\": \"gasket\", \"6140\": \"battery\", \"5961\": \"semiconductor\"}\n", + "EXERCISE = {3, 8, 10} # 가상 대규모 훈련 월(훈련 계획으로 미리 알 수 있음)\n", + "N_MONTHS = 48\n", + "\n", + "\n", + "def season(fsc, month):\n", + " \"\"\"품목군별 계절 계수: 축전지는 겨울, 냉각계통은 여름에 수요가 늘어난다.\"\"\"\n", + " if fsc == \"6140\":\n", + " return 1.6 if month in (12, 1, 2) else 0.9\n", + " if fsc == \"2930\":\n", + " return 1.5 if month in (6, 7, 8) else 0.9\n", + " return 1 + 0.1 * np.sin(2 * np.pi * month / 12)\n", + "\n", + "\n", + "def make_items(n=40, seed=2):\n", + " \"\"\"품목 마스터: NSN(가상), 품목군, 장비 유형, 기준 수요, 단가, 조달 리드타임, 공급업체 수, 생산(단종) 상태.\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " rows = []\n", + " for i in range(n):\n", + " f = str(rng.choice(list(FSC)))\n", + " status = str(rng.choice([\"생산중\", \"단종예고\", \"단종\"], p=[0.7, 0.2, 0.1]))\n", + " rows.append({\"item_id\": f\"P{i + 1:03d}\", \"nsn\": f\"{f}-00-{rng.integers(100, 1000)}-{rng.integers(1000, 10000)}\",\n", + " \"fsc\": f, \"group\": FSC[f], \"equip_type\": str(rng.choice([\"A\", \"B\", \"C\"])),\n", + " \"base\": float(np.round(rng.lognormal(2.7, 0.8), 1)),\n", + " \"price\": int(rng.lognormal(12, 1.0)) // 100 * 100,\n", + " \"lead_time\": int(rng.integers(1, 7) if status == \"생산중\" else rng.integers(4, 13)),\n", + " \"suppliers\": int(rng.integers(1, 5)) if status == \"생산중\" else 1,\n", + " \"status\": status, \"alt_available\": bool(rng.random() < (0.6 if status == \"생산중\" else 0.3))})\n", + " return pd.DataFrame(rows)\n", + "\n", + "\n", + "def make_history(items, months=N_MONTHS, seed=3):\n", + " \"\"\"월별 청구 수요(개), 긴급 불출 건수, 장비 유형별 운용시간·가동률.\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " t = np.arange(months)\n", + " moy = t % 12 + 1\n", + " ex = np.isin(moy, list(EXERCISE))\n", + " hours = {e: (100 + 20 * rng.random(months)) * np.where(ex, 1.5, 1.0) for e in \"ABC\"}\n", + " avail = {e: np.clip(0.93 - 0.12 * (hours[e] - 100) / 80 + rng.normal(0, 0.015, months), 0.6, 0.99) for e in \"ABC\"}\n", + " rows = []\n", + " for it in items.itertuples():\n", + " trend = 1 + rng.uniform(-0.1, 0.3) * t / months\n", + " s = np.array([season(it.fsc, m) for m in moy])\n", + " lam = it.base * s * trend * hours[it.equip_type] / 110\n", + " d = rng.poisson(lam)\n", + " rows.append(pd.DataFrame({\"item_id\": it.item_id, \"t\": t, \"ym\": [f\"{2022 + k // 12}-{k % 12 + 1:02d}\" for k in t],\n", + " \"month\": moy, \"exercise\": ex.astype(int), \"op_hours\": hours[it.equip_type].round(1),\n", + " \"availability\": avail[it.equip_type].round(3), \"demand\": d,\n", + " \"emergency\": rng.binomial(d, 0.05)}))\n", + " return pd.concat(rows, ignore_index=True)\n", + "\n", + "\n", + "def make_stock(items, hist, seed=4):\n", + " \"\"\"현재(가상 2026-01) 재고: 보유량과 미입고 발주량(조달 중인 물량). 단종 품목은 추가 발주가 어려워 미입고량이 적다.\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " recent = hist[hist[\"t\"] >= hist[\"t\"].max() - 5].groupby(\"item_id\")[\"demand\"].mean()\n", + " st = items[[\"item_id\", \"lead_time\", \"status\"]].copy()\n", + " st[\"on_hand\"] = [int(round(recent[i] * rng.uniform(0.5, 3.0))) for i in st[\"item_id\"]]\n", + " cover = np.where(st[\"status\"] == \"생산중\", rng.uniform(0.6, 1.3, len(st)), rng.uniform(0.0, 0.6, len(st)))\n", + " st[\"on_order\"] = [int(round(recent[i] * L * c)) for i, L, c in zip(st[\"item_id\"], st[\"lead_time\"], cover)]\n", + " return st[[\"item_id\", \"on_hand\", \"on_order\"]]\n", + "# ---------------------------------------------------------------------------\n", + "\n", + "# ---- 수요 예측 공통 함수(파생 피처 + 머신러닝 모델) ----\n", + "FEATS = [\"lag1\", \"lag2\", \"lag3\", \"lag12\", \"ma3\", \"ma6\", \"std6\", \"season_idx\", \"month_sin\", \"month_cos\",\n", + " \"exercise\", \"op_hours\", \"op_ratio\", \"item_level\", \"trend_t\"]\n", + "\n", + "\n", + "def make_features(hist):\n", + " \"\"\"품목별 과거 수요로 파생 피처를 만든다(모두 예측 시점 이전 정보만 사용).\"\"\"\n", + " df = hist.sort_values([\"item_id\", \"t\"]).reset_index(drop=True)\n", + " g = df.groupby(\"item_id\")[\"demand\"]\n", + " for k in (1, 2, 3, 12):\n", + " df[f\"lag{k}\"] = g.shift(k)\n", + " df[\"ma3\"] = g.transform(lambda s: s.shift(1).rolling(3).mean())\n", + " df[\"ma6\"] = g.transform(lambda s: s.shift(1).rolling(6).mean())\n", + " df[\"std6\"] = g.transform(lambda s: s.shift(1).rolling(6).std())\n", + " df[\"item_level\"] = g.transform(lambda s: s.shift(1).rolling(12).mean())\n", + " df[\"season_idx\"] = df[\"lag12\"] / (g.transform(lambda s: s.shift(12).rolling(12).mean()) + 1)\n", + " df[\"month_sin\"] = np.sin(2 * np.pi * df[\"month\"] / 12)\n", + " df[\"month_cos\"] = np.cos(2 * np.pi * df[\"month\"] / 12)\n", + " df[\"op_ratio\"] = df[\"op_hours\"] / df.groupby(\"item_id\")[\"op_hours\"].transform(lambda s: s.shift(1).rolling(12).mean())\n", + " df[\"trend_t\"] = df[\"t\"]\n", + " return df.dropna().reset_index(drop=True)\n", + "\n", + "\n", + "def new_model():\n", + " \"\"\"Prophet/LSTM 대신 사용하는 포아송 손실 그래디언트 부스팅(개수 자료에 적합).\"\"\"\n", + " return HistGradientBoostingRegressor(loss=\"poisson\", max_iter=300, learning_rate=0.05, max_leaf_nodes=15,\n", + " min_samples_leaf=20, random_state=0)\n", + "\n", + "\n", + "def mape(y, p, min_y=1):\n", + " y, p = np.asarray(y, float), np.asarray(p, float)\n", + " m = y >= min_y\n", + " return float(np.mean(np.abs(y[m] - p[m]) / y[m]))\n", + "\n", + "# ---- 앙상블 수요 예측·안전재고·결품(단종) 위험 지수 공통 함수 ----\n", + "STATUS_W = {\"생산중\": 0.0, \"단종예고\": 0.5, \"단종\": 1.0} # [교육용가상자료] 단종 상태 가중치\n", + "\n", + "\n", + "def fit_ens(tr):\n", + " \"\"\"포아송 그래디언트 부스팅(70%) + 랜덤포레스트(30%) 앙상블.\"\"\"\n", + " h = new_model().fit(tr[FEATS], tr[\"demand\"])\n", + " r = RandomForestRegressor(n_estimators=150, min_samples_leaf=5, random_state=0, n_jobs=1).fit(tr[FEATS], tr[\"demand\"])\n", + " return h, r\n", + "\n", + "\n", + "def pred_ens(m, X, w=0.7):\n", + " return np.clip(w * m[0].predict(X[FEATS]) + (1 - w) * m[1].predict(X[FEATS]), 0, None)\n", + "\n", + "\n", + "def next_month(hist, items, t_new=48, op_mult=1.0, ex=0):\n", + " \"\"\"다음 달(t_new) 예측용 행: 작년 같은 달 운용시간 x 배수, 훈련 여부 지정.\"\"\"\n", + " last = hist[hist[\"t\"] == t_new - 1].copy()\n", + " prev = hist[hist[\"t\"] == t_new - 12].set_index(\"item_id\")[\"op_hours\"]\n", + " m = (t_new % 12) + 1\n", + " f = last.assign(t=t_new, ym=f\"{2022 + t_new // 12}-{m:02d}\", month=m, demand=0, exercise=ex,\n", + " op_hours=last[\"item_id\"].map(prev).to_numpy() * op_mult)\n", + " X = make_features(pd.concat([hist, f], ignore_index=True))\n", + " return X[X[\"t\"] == t_new].set_index(\"item_id\").loc[items[\"item_id\"]].reset_index()\n", + "\n", + "\n", + "def stock_policy(items, mu, sigma, z=1.65, review=1):\n", + " \"\"\"안전재고 = z x 월 예측오차 표준편차 x sqrt(리드타임+검토주기), 재주문점 = 월 수요 x (리드타임+검토주기) + 안전재고.\"\"\"\n", + " L = items[\"lead_time\"].to_numpy() + review\n", + " ss = z * sigma * np.sqrt(L)\n", + " return pd.DataFrame({\"item_id\": items[\"item_id\"], \"mu\": mu, \"sigma\": sigma, \"L\": L,\n", + " \"ss\": np.ceil(ss), \"rop\": np.ceil(mu * L + ss)})\n", + "\n", + "\n", + "def risk_index(items, pol, stock):\n", + " \"\"\"결품 위험 지수(0~100): 리드타임 동안 수요가 가용 재고를 넘을 확률(정규 근사)을 기본으로,\n", + " 단종 상태·단일 공급업체·대체품 부재에 가중을 준다(가상 가중치).\"\"\"\n", + " st = stock.set_index(\"item_id\").loc[items[\"item_id\"]]\n", + " avail = (st[\"on_hand\"] + st[\"on_order\"]).to_numpy()\n", + " p = 1 - norm.cdf((avail - pol[\"mu\"].to_numpy() * pol[\"L\"].to_numpy()) / (pol[\"sigma\"].to_numpy() * np.sqrt(pol[\"L\"].to_numpy()) + 1e-6))\n", + " sw = items[\"status\"].map(STATUS_W).to_numpy()\n", + " sup = (items[\"suppliers\"].to_numpy() == 1).astype(float)\n", + " alt = (~items[\"alt_available\"].to_numpy()).astype(float)\n", + " score = 100 * np.clip(0.55 * p + 0.25 * sw + 0.10 * sup + 0.10 * alt, 0, 1)\n", + " return pd.DataFrame({\"item_id\": items[\"item_id\"], \"p_short\": p, \"status_w\": sw, \"single_sup\": sup, \"no_alt\": alt,\n", + " \"avail\": avail, \"risk\": score.round(1)})\n", + "\n", + "items = make_items()\n", + "hist = make_history(items)\n", + "df = make_features(hist)\n", + "stock = make_stock(items, hist)\n", + "X = next_month(hist, items)\n", + "mu = pred_ens(fit_ens(df), X)\n", + "level = X[\"item_level\"].to_numpy() # 최근 12개월 평균 수요(평시 수준)\n", + "sigma = np.maximum(0.25 * mu, np.sqrt(np.maximum(mu, 1)))\n", + "\n", + "\n", + "def score(it, m, s, st):\n", + " return risk_index(it, stock_policy(it, m, s), st)[\"risk\"].to_numpy()\n", + "\n", + "\n", + "base = score(items, mu, sigma, stock)\n", + "pol = stock_policy(items, mu, sigma)\n", + "# 각 요인을 '문제 없는 기준값'으로 바꿨을 때 위험 지수가 얼마나 내려가는지 = 그 요인의 기여도\n", + "ref_lt = int(items.loc[items[\"status\"] == \"생산중\", \"lead_time\"].median())\n", + "st_ok = stock.copy(); st_ok[\"on_hand\"] = np.maximum(st_ok[\"on_hand\"], (pol[\"rop\"] - st_ok[\"on_order\"]).clip(lower=0).astype(int).to_numpy())\n", + "FACTORS = {\n", + " \"조달 리드타임 장기화\": lambda: score(items.assign(lead_time=np.minimum(items[\"lead_time\"], ref_lt)), mu, sigma, stock),\n", + " \"수요 증가 예측\": lambda: score(items, np.minimum(mu, level), sigma, stock),\n", + " \"가용재고 부족\": lambda: score(items, mu, sigma, st_ok),\n", + " \"예측 불확실성\": lambda: score(items, mu, np.sqrt(np.maximum(mu, 1)), stock),\n", + " \"단종(예고) 상태\": lambda: score(items.assign(status=\"생산중\"), mu, sigma, stock),\n", + " \"단일 공급업체\": lambda: score(items.assign(suppliers=np.maximum(items[\"suppliers\"], 2)), mu, sigma, stock),\n", + " \"대체품 부재\": lambda: score(items.assign(alt_available=True), mu, sigma, stock),\n", + "}\n", + "C = pd.DataFrame({k: base - f() for k, f in FACTORS.items()}, index=items[\"item_id\"]).clip(lower=0)\n", + "top3 = C.apply(lambda r: list(r.sort_values(ascending=False).index[:3]), axis=1)\n", + "cover = np.mean([len(t) == 3 for t in top3])\n", + "print(f\"[품목 {len(items)}개] 위험 지수 기여도 상위 3개 인자 자동 산출률 {cover:.0%} (목표 100%: {'달성' if cover == 1 else '미달'})\")\n", + "\n", + "dash = items[[\"item_id\", \"group\", \"status\", \"lead_time\"]].assign(risk=base, avail=(stock[\"on_hand\"] + stock[\"on_order\"]).to_numpy(),\n", + " rop=pol[\"rop\"].to_numpy(), mu=mu.round(1))\n", + "dash[\"urgent\"] = (dash[\"risk\"] >= 60) & (dash[\"avail\"] < dash[\"rop\"])\n", + "CAUSE = {\"조달 리드타임 장기화\": \"조달 리드타임이 길다\", \"단종(예고) 상태\": \"생산 중단(예고)으로 추가 조달이 어렵다\",\n", + " \"가용재고 부족\": \"보유·미입고 재고가 재주문점보다 적다\", \"수요 증가 예측\": \"다음 달 수요가 평시보다 늘 것으로 예측된다\",\n", + " \"예측 불확실성\": \"수요 변동이 커 안전재고가 많이 필요하다\", \"단일 공급업체\": \"공급업체가 1곳뿐이다\", \"대체품 부재\": \"대체품이 없다\"}\n", + "\n", + "\n", + "def explain(iid):\n", + " r = dash.set_index(\"item_id\").loc[iid]\n", + " f = top3[iid]\n", + " parts = \", \".join(f\"{k}(+{C.loc[iid, k]:.0f})\" for k in f)\n", + " return f\"{iid}({r['group']}, {r['status']}) 위험 {r['risk']:.0f}: {parts}. 주된 원인은 {CAUSE[f[0]]}.\"\n", + "\n", + "\n", + "urg = dash[dash[\"urgent\"]].sort_values(\"risk\", ascending=False)\n", + "print(f\"\\n[긴급 조달 필요 품목 {len(urg)}개(위험 60 이상 & 가용재고<재주문점)] 자연어 설명\")\n", + "for iid in urg[\"item_id\"].head(7):\n", + " print(\" -\", explain(iid))\n", + "print(\"\\n[요인별 평균 기여도]\", \", \".join(f\"{k} {v:.1f}\" for k, v in C.mean().sort_values(ascending=False).items()))\n", + "g = dash.groupby(\"status\")[\"risk\"].agg([\"count\", \"mean\"]).round(1)\n", + "print(\"[생산 상태별 평균 위험 지수]\", \", \".join(f\"{s} {r['mean']}({int(r['count'])}개)\" for s, r in g.iterrows()))\n", + "print(\"설명은 조달·대체품 검토의 근거 자료이며 발주·대체 결정은 정비장교가 확인 후 내린다.\")\n", + "\n", + "fig, ax = plt.subplots(1, 3, figsize=(17, 4.8))\n", + "d15 = dash.sort_values(\"risk\", ascending=False).head(15)\n", + "ax[0].barh(d15[\"item_id\"], d15[\"avail\"], color=np.where(d15[\"urgent\"], \"tab:red\", \"tab:gray\"), label=\"available\")\n", + "ax[0].scatter(d15[\"rop\"], d15[\"item_id\"], c=\"k\", marker=\"|\", s=200, label=\"reorder point\"); ax[0].invert_yaxis()\n", + "ax[0].set_xscale(\"symlog\"); ax[0].set_title(\"Top-15 risk items: stock vs reorder point (red=urgent)\"); ax[0].legend()\n", + "hm = dash.pivot_table(index=\"group\", columns=\"status\", values=\"risk\", aggfunc=\"mean\")\n", + "im = ax[1].imshow(hm.values, cmap=\"Reds\", vmin=0, vmax=100, aspect=\"auto\"); plt.colorbar(im, ax=ax[1])\n", + "ax[1].set_xticks(range(hm.shape[1])); ax[1].set_xticklabels([\"active\" if c == \"생산중\" else \"EOL notice\" if c == \"단종예고\" else \"discontinued\" for c in hm.columns])\n", + "ax[1].set_yticks(range(len(hm))); ax[1].set_yticklabels(hm.index); ax[1].set_title(\"Mean risk index by group/status\")\n", + "i0 = urg[\"item_id\"].iloc[0]; w = C.loc[i0].sort_values()\n", + "ax[2].barh(range(len(w)), w.values, color=\"tab:red\"); ax[2].set_yticks(range(len(w)))\n", + "EN = {\"조달 리드타임 장기화\": \"lead time\", \"수요 증가 예측\": \"demand up\", \"가용재고 부족\": \"low stock\", \"예측 불확실성\": \"uncertainty\",\n", + " \"단종(예고) 상태\": \"EOL status\", \"단일 공급업체\": \"single supplier\", \"대체품 부재\": \"no alternate\"}\n", + "ax[2].set_yticklabels([EN[k] for k in w.index])\n", + "ax[2].set_title(f\"Risk contribution: {i0}\")\n", + "plt.tight_layout(); plt.show()\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 06 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + "[품목 40개] 위험 지수 기여도 상위 3개 인자 자동 산출률 100% (목표 100%: 달성)\n", + "\n", + "[긴급 조달 필요 품목 13개(위험 60 이상 & 가용재고<재주문점)] 자연어 설명\n", + " - P018(battery, 단종) 위험 96: 가용재고 부족(+49), 단종(예고) 상태(+25), 단일 공급업체(+10). 주된 원인은 보유·미입고 재고가 재주문점보다 적다.\n", + " - P028(power_train, 단종) 위험 90: 조달 리드타임 장기화(+55), 가용재고 부족(+52), 단종(예고) 상태(+25). 주된 원인은 조달 리드타임이 길다.\n", + " - P016(gasket, 단종) 위험 90: 가용재고 부족(+52), 단종(예고) 상태(+25), 단일 공급업체(+10). 주된 원인은 보유·미입고 재고가 재주문점보다 적다.\n", + " - P004(filter, 단종예고) 위험 88: 가용재고 부족(+53), 단종(예고) 상태(+12), 단일 공급업체(+10). 주된 원인은 보유·미입고 재고가 재주문점보다 적다.\n", + " - P025(filter, 단종예고) 위험 88: 가용재고 부족(+53), 조달 리드타임 장기화(+39), 단종(예고) 상태(+12). 주된 원인은 보유·미입고 재고가 재주문점보다 적다.\n", + " - P038(fuel, 단종예고) 위험 88: 가용재고 부족(+52), 단종(예고) 상태(+12), 단일 공급업체(+10). 주된 원인은 보유·미입고 재고가 재주문점보다 적다.\n", + " - P030(brake_steer, 단종예고) 위험 88: 가용재고 부족(+52), 단종(예고) 상태(+12), 단일 공급업체(+10). 주된 원인은 보유·미입고 재고가 재주문점보다 적다.\n", + "\n", + "[요인별 평균 기여도] 가용재고 부족 19.5, 단일 공급업체 4.8, 조달 리드타임 장기화 4.5, 단종(예고) 상태 4.4, 대체품 부재 3.8, 수요 증가 예측 0.3, 예측 불확실성 0.3\n", + "[생산 상태별 평균 위험 지수] 단종 92.0(3개), 단종예고 84.5(8개), 생산중 13.7(29개)\n", + "설명은 조달·대체품 검토의 근거 자료이며 발주·대체 결정은 정비장교가 확인 후 내린다.\n", + "```\n", + "
\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 07. 부품 발주량 최종 확정을 위한 정비장교 HITL 승인/조정 워크플로우 설계\n", + "**소요시간**: 약 3.5시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "부품 발주량 최종 확정을 위한 정비장교 HITL 승인 워크플로우를 설계한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **예측 기반 발주 정책과 단종 대체품 전환의 효과를 백테스트로 검증하고, 발주량을 정비장교가 최종 확정하는 HITL 승인·조정 워크플로우를 설계한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **12개월 재고 백테스트로 현행(최근 평균+고정 여유) 정책, 예측 정책, 예측+단종 대체품 전환을 보관비·발주비·결품손실·충족률·회전율로 비교하고 효과를 분해 수행하고 검증하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `src/optimization/mnt02_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": [ + "\"\"\"예측 기반 발주 정책(안전재고 포함 목표재고)과 단종 품목 대체품 전환을 12개월 재고 백테스트로 현행(최근 평균+고정 여유) 정책과 비교해 비용·재고 회전율·충족률을 산출하고, 다음 달 발주량을 예산 제약 선형계획(LP)으로 추천한 뒤 정비장교가 승인·조정·반려·보류로 최종 확정하는 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 scipy.stats import norm\n", + "from scipy.optimize import linprog\n", + "from sklearn.ensemble import HistGradientBoostingRegressor, RandomForestRegressor\n", + "\n", + "# ---------------------------------------------------------------------------\n", + "# [교육용가상자료] 가상 야전 정비창 수리부속 청구/출고·장비 가동률 데이터 생성기\n", + "# 실제 부대·품목·재고·단가 정보와 무관하다. 품목 40개 x 48개월(가상 2022-01 ~ 2025-12) 월별 자료를 만든다.\n", + "# ---------------------------------------------------------------------------\n", + "FSC = {\"2520\": \"power_train\", \"2530\": \"brake_steer\", \"2910\": \"fuel\", \"2930\": \"cooling\",\n", + " \"2940\": \"filter\", \"5330\": \"gasket\", \"6140\": \"battery\", \"5961\": \"semiconductor\"}\n", + "EXERCISE = {3, 8, 10} # 가상 대규모 훈련 월(훈련 계획으로 미리 알 수 있음)\n", + "N_MONTHS = 48\n", + "\n", + "\n", + "def season(fsc, month):\n", + " \"\"\"품목군별 계절 계수: 축전지는 겨울, 냉각계통은 여름에 수요가 늘어난다.\"\"\"\n", + " if fsc == \"6140\":\n", + " return 1.6 if month in (12, 1, 2) else 0.9\n", + " if fsc == \"2930\":\n", + " return 1.5 if month in (6, 7, 8) else 0.9\n", + " return 1 + 0.1 * np.sin(2 * np.pi * month / 12)\n", + "\n", + "\n", + "def make_items(n=40, seed=2):\n", + " \"\"\"품목 마스터: NSN(가상), 품목군, 장비 유형, 기준 수요, 단가, 조달 리드타임, 공급업체 수, 생산(단종) 상태.\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " rows = []\n", + " for i in range(n):\n", + " f = str(rng.choice(list(FSC)))\n", + " status = str(rng.choice([\"생산중\", \"단종예고\", \"단종\"], p=[0.7, 0.2, 0.1]))\n", + " rows.append({\"item_id\": f\"P{i + 1:03d}\", \"nsn\": f\"{f}-00-{rng.integers(100, 1000)}-{rng.integers(1000, 10000)}\",\n", + " \"fsc\": f, \"group\": FSC[f], \"equip_type\": str(rng.choice([\"A\", \"B\", \"C\"])),\n", + " \"base\": float(np.round(rng.lognormal(2.7, 0.8), 1)),\n", + " \"price\": int(rng.lognormal(12, 1.0)) // 100 * 100,\n", + " \"lead_time\": int(rng.integers(1, 7) if status == \"생산중\" else rng.integers(4, 13)),\n", + " \"suppliers\": int(rng.integers(1, 5)) if status == \"생산중\" else 1,\n", + " \"status\": status, \"alt_available\": bool(rng.random() < (0.6 if status == \"생산중\" else 0.3))})\n", + " return pd.DataFrame(rows)\n", + "\n", + "\n", + "def make_history(items, months=N_MONTHS, seed=3):\n", + " \"\"\"월별 청구 수요(개), 긴급 불출 건수, 장비 유형별 운용시간·가동률.\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " t = np.arange(months)\n", + " moy = t % 12 + 1\n", + " ex = np.isin(moy, list(EXERCISE))\n", + " hours = {e: (100 + 20 * rng.random(months)) * np.where(ex, 1.5, 1.0) for e in \"ABC\"}\n", + " avail = {e: np.clip(0.93 - 0.12 * (hours[e] - 100) / 80 + rng.normal(0, 0.015, months), 0.6, 0.99) for e in \"ABC\"}\n", + " rows = []\n", + " for it in items.itertuples():\n", + " trend = 1 + rng.uniform(-0.1, 0.3) * t / months\n", + " s = np.array([season(it.fsc, m) for m in moy])\n", + " lam = it.base * s * trend * hours[it.equip_type] / 110\n", + " d = rng.poisson(lam)\n", + " rows.append(pd.DataFrame({\"item_id\": it.item_id, \"t\": t, \"ym\": [f\"{2022 + k // 12}-{k % 12 + 1:02d}\" for k in t],\n", + " \"month\": moy, \"exercise\": ex.astype(int), \"op_hours\": hours[it.equip_type].round(1),\n", + " \"availability\": avail[it.equip_type].round(3), \"demand\": d,\n", + " \"emergency\": rng.binomial(d, 0.05)}))\n", + " return pd.concat(rows, ignore_index=True)\n", + "\n", + "\n", + "def make_stock(items, hist, seed=4):\n", + " \"\"\"현재(가상 2026-01) 재고: 보유량과 미입고 발주량(조달 중인 물량). 단종 품목은 추가 발주가 어려워 미입고량이 적다.\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " recent = hist[hist[\"t\"] >= hist[\"t\"].max() - 5].groupby(\"item_id\")[\"demand\"].mean()\n", + " st = items[[\"item_id\", \"lead_time\", \"status\"]].copy()\n", + " st[\"on_hand\"] = [int(round(recent[i] * rng.uniform(0.5, 3.0))) for i in st[\"item_id\"]]\n", + " cover = np.where(st[\"status\"] == \"생산중\", rng.uniform(0.6, 1.3, len(st)), rng.uniform(0.0, 0.6, len(st)))\n", + " st[\"on_order\"] = [int(round(recent[i] * L * c)) for i, L, c in zip(st[\"item_id\"], st[\"lead_time\"], cover)]\n", + " return st[[\"item_id\", \"on_hand\", \"on_order\"]]\n", + "# ---------------------------------------------------------------------------\n", + "\n", + "# ---- 수요 예측 공통 함수(파생 피처 + 머신러닝 모델) ----\n", + "FEATS = [\"lag1\", \"lag2\", \"lag3\", \"lag12\", \"ma3\", \"ma6\", \"std6\", \"season_idx\", \"month_sin\", \"month_cos\",\n", + " \"exercise\", \"op_hours\", \"op_ratio\", \"item_level\", \"trend_t\"]\n", + "\n", + "\n", + "def make_features(hist):\n", + " \"\"\"품목별 과거 수요로 파생 피처를 만든다(모두 예측 시점 이전 정보만 사용).\"\"\"\n", + " df = hist.sort_values([\"item_id\", \"t\"]).reset_index(drop=True)\n", + " g = df.groupby(\"item_id\")[\"demand\"]\n", + " for k in (1, 2, 3, 12):\n", + " df[f\"lag{k}\"] = g.shift(k)\n", + " df[\"ma3\"] = g.transform(lambda s: s.shift(1).rolling(3).mean())\n", + " df[\"ma6\"] = g.transform(lambda s: s.shift(1).rolling(6).mean())\n", + " df[\"std6\"] = g.transform(lambda s: s.shift(1).rolling(6).std())\n", + " df[\"item_level\"] = g.transform(lambda s: s.shift(1).rolling(12).mean())\n", + " df[\"season_idx\"] = df[\"lag12\"] / (g.transform(lambda s: s.shift(12).rolling(12).mean()) + 1)\n", + " df[\"month_sin\"] = np.sin(2 * np.pi * df[\"month\"] / 12)\n", + " df[\"month_cos\"] = np.cos(2 * np.pi * df[\"month\"] / 12)\n", + " df[\"op_ratio\"] = df[\"op_hours\"] / df.groupby(\"item_id\")[\"op_hours\"].transform(lambda s: s.shift(1).rolling(12).mean())\n", + " df[\"trend_t\"] = df[\"t\"]\n", + " return df.dropna().reset_index(drop=True)\n", + "\n", + "\n", + "def new_model():\n", + " \"\"\"Prophet/LSTM 대신 사용하는 포아송 손실 그래디언트 부스팅(개수 자료에 적합).\"\"\"\n", + " return HistGradientBoostingRegressor(loss=\"poisson\", max_iter=300, learning_rate=0.05, max_leaf_nodes=15,\n", + " min_samples_leaf=20, random_state=0)\n", + "\n", + "\n", + "def mape(y, p, min_y=1):\n", + " y, p = np.asarray(y, float), np.asarray(p, float)\n", + " m = y >= min_y\n", + " return float(np.mean(np.abs(y[m] - p[m]) / y[m]))\n", + "\n", + "# ---- 앙상블 수요 예측·안전재고·결품(단종) 위험 지수 공통 함수 ----\n", + "STATUS_W = {\"생산중\": 0.0, \"단종예고\": 0.5, \"단종\": 1.0} # [교육용가상자료] 단종 상태 가중치\n", + "\n", + "\n", + "def fit_ens(tr):\n", + " \"\"\"포아송 그래디언트 부스팅(70%) + 랜덤포레스트(30%) 앙상블.\"\"\"\n", + " h = new_model().fit(tr[FEATS], tr[\"demand\"])\n", + " r = RandomForestRegressor(n_estimators=150, min_samples_leaf=5, random_state=0, n_jobs=1).fit(tr[FEATS], tr[\"demand\"])\n", + " return h, r\n", + "\n", + "\n", + "def pred_ens(m, X, w=0.7):\n", + " return np.clip(w * m[0].predict(X[FEATS]) + (1 - w) * m[1].predict(X[FEATS]), 0, None)\n", + "\n", + "\n", + "def next_month(hist, items, t_new=48, op_mult=1.0, ex=0):\n", + " \"\"\"다음 달(t_new) 예측용 행: 작년 같은 달 운용시간 x 배수, 훈련 여부 지정.\"\"\"\n", + " last = hist[hist[\"t\"] == t_new - 1].copy()\n", + " prev = hist[hist[\"t\"] == t_new - 12].set_index(\"item_id\")[\"op_hours\"]\n", + " m = (t_new % 12) + 1\n", + " f = last.assign(t=t_new, ym=f\"{2022 + t_new // 12}-{m:02d}\", month=m, demand=0, exercise=ex,\n", + " op_hours=last[\"item_id\"].map(prev).to_numpy() * op_mult)\n", + " X = make_features(pd.concat([hist, f], ignore_index=True))\n", + " return X[X[\"t\"] == t_new].set_index(\"item_id\").loc[items[\"item_id\"]].reset_index()\n", + "\n", + "\n", + "def stock_policy(items, mu, sigma, z=1.65, review=1):\n", + " \"\"\"안전재고 = z x 월 예측오차 표준편차 x sqrt(리드타임+검토주기), 재주문점 = 월 수요 x (리드타임+검토주기) + 안전재고.\"\"\"\n", + " L = items[\"lead_time\"].to_numpy() + review\n", + " ss = z * sigma * np.sqrt(L)\n", + " return pd.DataFrame({\"item_id\": items[\"item_id\"], \"mu\": mu, \"sigma\": sigma, \"L\": L,\n", + " \"ss\": np.ceil(ss), \"rop\": np.ceil(mu * L + ss)})\n", + "\n", + "\n", + "def risk_index(items, pol, stock):\n", + " \"\"\"결품 위험 지수(0~100): 리드타임 동안 수요가 가용 재고를 넘을 확률(정규 근사)을 기본으로,\n", + " 단종 상태·단일 공급업체·대체품 부재에 가중을 준다(가상 가중치).\"\"\"\n", + " st = stock.set_index(\"item_id\").loc[items[\"item_id\"]]\n", + " avail = (st[\"on_hand\"] + st[\"on_order\"]).to_numpy()\n", + " p = 1 - norm.cdf((avail - pol[\"mu\"].to_numpy() * pol[\"L\"].to_numpy()) / (pol[\"sigma\"].to_numpy() * np.sqrt(pol[\"L\"].to_numpy()) + 1e-6))\n", + " sw = items[\"status\"].map(STATUS_W).to_numpy()\n", + " sup = (items[\"suppliers\"].to_numpy() == 1).astype(float)\n", + " alt = (~items[\"alt_available\"].to_numpy()).astype(float)\n", + " score = 100 * np.clip(0.55 * p + 0.25 * sw + 0.10 * sup + 0.10 * alt, 0, 1)\n", + " return pd.DataFrame({\"item_id\": items[\"item_id\"], \"p_short\": p, \"status_w\": sw, \"single_sup\": sup, \"no_alt\": alt,\n", + " \"avail\": avail, \"risk\": score.round(1)})\n", + "\n", + "# ---- 월 단위 재고 백테스트(가상 비용 지수) ----\n", + "HOLD, ORDER_COST, SHORT_MULT = 0.02, 50_000, 0.6 # [교육용가상자료] 월 보관비율(단가 대비), 발주 1회 비용(원), 결품 1개 손실(단가 배수)\n", + "\n", + "\n", + "ALT_PREMIUM, ALT_LT = 1.2, 3 # [교육용가상자료] 대체품 전환 시 단가 배수, 대체품 조달 리드타임(월)\n", + "\n", + "\n", + "def simulate(items, demand, fc, policy, z=1.65, months=None, sigma=None, eol=True):\n", + " \"\"\"품목별 월 재고 모의. demand/fc: (품목 x 월) 실제·예측 수요. policy: 'current'(최근 12개월 평균 + 3개월 여유 고정) 또는 'ai'(예측 + 안전재고).\n", + " 단종 품목은 원 품목 발주 불가. 'ai' 정책에서 eol=True이면 단종 품목 중 대체품이 있는 품목은 대체품(단가 x1.2, 리드타임 3개월)으로 전환 발주한다. 반환: 품목별 보관비·발주비·결품손실·불출량·평균재고.\"\"\"\n", + " n, T = demand.shape\n", + " L = items[\"lead_time\"].to_numpy(); price = items[\"price\"].to_numpy().astype(float)\n", + " can_order = (items[\"status\"] != \"단종\").to_numpy()\n", + " if policy == \"ai\" and eol:\n", + " sw = (~can_order) & items[\"alt_available\"].to_numpy()\n", + " can_order = can_order | sw\n", + " L = np.where(sw, ALT_LT, L); price = np.where(sw, price * ALT_PREMIUM, price)\n", + " avg = demand[:, :1].repeat(T, 1) if months is None else months\n", + " on = np.round(avg[:, 0] * 2.0)\n", + " pipe = np.zeros((n, T + int(L.max()) + 1)) # 도착 예정 물량(품목 x 월)\n", + " for i in range(n):\n", + " pipe[i, :L[i]] = np.round(avg[i, 0] * (1.0 if can_order[i] else 0.3))\n", + " res = np.zeros((n, 6)); rows = np.arange(n)\n", + " for t in range(T):\n", + " on += pipe[:, t]\n", + " issue = np.minimum(on, demand[:, t]); short = demand[:, t] - issue; on -= issue\n", + " ip = on + pipe[:, t + 1:].sum(1)\n", + " if policy == \"current\":\n", + " target = avg[:, t] * (L + 3)\n", + " else:\n", + " s = sigma if sigma is not None else np.sqrt(np.maximum(fc[:, t], 1))\n", + " target = fc[:, t] * (L + 1) + z * s * np.sqrt(L + 1)\n", + " q = np.where(can_order & (ip < target), np.ceil(target - ip), 0)\n", + " pipe[rows, t + L] += q\n", + " res += np.c_[HOLD * price * on, ORDER_COST * (q > 0), SHORT_MULT * price * short, issue, on, short]\n", + " out = pd.DataFrame(res, columns=[\"hold\", \"order\", \"short\", \"issued\", \"inv\", \"short_qty\"], index=items[\"item_id\"])\n", + " out[\"inv\"] /= T\n", + " return out\n", + "\n", + "items = make_items()\n", + "hist = make_history(items)\n", + "df = make_features(hist)\n", + "\n", + "# 1) 백테스트(t=36~47, 12개월): 한 달 앞 예측은 t<36으로 학습한 앙상블 사용\n", + "m = fit_ens(df[df[\"t\"] < 36])\n", + "bt = df[df[\"t\"] >= 36].copy(); bt[\"fc\"] = pred_ens(m, bt)\n", + "P = lambda c: bt.pivot(index=\"item_id\", columns=\"t\", values=c).loc[items[\"item_id\"]].to_numpy()\n", + "D, F, A = P(\"demand\"), P(\"fc\"), P(\"item_level\")\n", + "sig = np.maximum(0.25 * F.mean(1), np.sqrt(np.maximum(F.mean(1), 1)))\n", + "\n", + "\n", + "def summary(r, dem):\n", + " cost = r[[\"hold\", \"order\", \"short\"]].sum()\n", + " return {\"보관비\": cost[\"hold\"] / 1e6, \"발주비\": cost[\"order\"] / 1e6, \"결품손실\": cost[\"short\"] / 1e6, \"총비용(백만)\": cost.sum() / 1e6,\n", + " \"충족률\": 1 - r[\"short_qty\"].sum() / dem.sum(), \"회전율\": r[\"issued\"].sum() / r[\"inv\"].sum()}\n", + "\n", + "\n", + "runs = {\"현행(평균+3개월 여유)\": simulate(items, D, F, \"current\", months=A),\n", + " \"예측 정책(z=1.65)\": simulate(items, D, F, \"ai\", 1.65, A, sig, eol=False),\n", + " \"예측+단종 대체품 전환\": simulate(items, D, F, \"ai\", 1.65, A, sig, eol=True)}\n", + "tab = pd.DataFrame({k: summary(v, D) for k, v in runs.items()}).T\n", + "print(\"[12개월 재고 백테스트(가상 비용 지수, 백만 원)]\")\n", + "print(tab.to_string(formatters={\"충족률\": \"{:.1%}\".format, \"회전율\": \"{:.2f}\".format}, float_format=lambda x: f\"{x:.1f}\"))\n", + "c0, c2 = tab.iloc[0][\"총비용(백만)\"], tab.iloc[2][\"총비용(백만)\"]\n", + "t0, t1 = tab.iloc[0][\"회전율\"], tab.iloc[1][\"회전율\"]\n", + "print(f\"총비용 절감 {1 - c2 / c0:.1%}, 예측 정책만의 회전율 향상 {t1 / t0 - 1:.1%} (목표: 비용 15% 절감 또는 회전율 20% 향상 -> {'달성' if (1 - c2 / c0) >= 0.15 or t1 / t0 - 1 >= 0.2 else '미달'})\")\n", + "act = items[\"status\"] != \"단종\"\n", + "a0, a1 = runs[\"현행(평균+3개월 여유)\"][act.to_numpy()], runs[\"예측 정책(z=1.65)\"][act.to_numpy()]\n", + "print(f\"효과 분해: 생산중·단종예고 품목 비용 {1 - a1[['hold', 'order', 'short']].sum().sum() / a0[['hold', 'order', 'short']].sum().sum():.1%} 절감(예측 정책), 나머지는 단종 품목 대체품 전환 효과\")\n", + "\n", + "# 민감도: 서비스 수준(z)과 돌발 수요(최근 6개월 수요 1.5배, 가상 전시 전환)\n", + "D2 = D.copy(); D2[:, 6:] = np.round(D2[:, 6:] * 1.5)\n", + "sens = []\n", + "for z in (1.28, 1.65, 2.05):\n", + " for nm, dd in ((\"평시\", D), (\"돌발 1.5배\", D2)):\n", + " r = summary(simulate(items, dd, F, \"ai\", z, A, sig), dd)\n", + " sens.append({\"z\": z, \"수요\": nm, \"총비용\": r[\"총비용(백만)\"], \"충족률\": r[\"충족률\"]})\n", + "print(\"[민감도] \" + \" / \".join(f\"z={s['z']} {s['수요']}: {s['총비용']:.0f}백만, 충족 {s['충족률']:.1%}\" for s in sens))\n", + "\n", + "\n", + "# 2) 다음 달 발주 추천: 예산 제약 LP (위험 지수 가중 부족분 충족 최대화)\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", + "stock = make_stock(items, hist)\n", + "X = next_month(hist, items)\n", + "mu = pred_ens(fit_ens(df), X)\n", + "sg = np.maximum(0.25 * mu, np.sqrt(np.maximum(mu, 1)))\n", + "alt_sw = (items[\"status\"] == \"단종\") & items[\"alt_available\"]\n", + "it2 = items.assign(lead_time=np.where(alt_sw, ALT_LT, items[\"lead_time\"]), price=np.where(alt_sw, items[\"price\"] * ALT_PREMIUM, items[\"price\"]))\n", + "pol = stock_policy(it2, mu, sg)\n", + "risk = risk_index(items, pol, stock)[\"risk\"].to_numpy()\n", + "ip = (stock[\"on_hand\"] + stock[\"on_order\"]).to_numpy()\n", + "orderable = (items[\"status\"] != \"단종\").to_numpy() | alt_sw.to_numpy()\n", + "gap = np.where(orderable, np.maximum(0, np.ceil(pol[\"rop\"].to_numpy() - ip)), 0)\n", + "price = it2[\"price\"].to_numpy().astype(float)\n", + "BUDGET = 0.6 * float((gap * price).sum())\n", + "idx = np.where(gap > 0)[0]\n", + "lp = linprog(c=-(risk[idx] + 10) / gap[idx], A_ub=[price[idx]], b_ub=[BUDGET], bounds=[(0, g) for g in gap[idx]], method=\"highs\")\n", + "q = np.zeros(len(items)); q[idx] = np.floor(lp.x)\n", + "print(f\"\\n[다음 달 발주 추천 LP] 부족 품목 {len(idx)}개, 예산 {BUDGET / 1e6:.1f}백만(부족분 전액의 60%, 가상) 사용 {np.dot(q, price) / 1e6:.1f}백만, 추천 {int((q > 0).sum())}건\")\n", + "\n", + "audit, rows = Audit(), []\n", + "for k, i in enumerate(np.where(q > 0)[0]):\n", + " iid = items.loc[i, \"item_id\"]; kind = \"대체품 전환\" if alt_sw[i] else \"정규 발주\"\n", + " audit.add(t=\"추천\", item=iid, kind=kind, qty=int(q[i]), risk=float(risk[i]), status=\"결재대기\", by=\"AI\")\n", + " # 가상 정비장교 판단(시뮬레이션): 근거 확인 후 승인·조정·반려, 일부는 시한 내 무응답(보류=발주하지 않음)\n", + " if kind == \"대체품 전환\":\n", + " dec, final, why = \"반려\", 0, \"대체품 호환성 시험 결과 확인 후 재상신\"\n", + " elif q[i] * price[i] > 0.15 * BUDGET:\n", + " dec, final, why = \"조정\", int(q[i] * 0.7), \"고액 발주 분할(1차 70%), 다음 달 재검토\"\n", + " elif k % 6 == 5:\n", + " dec, final, why = \"보류\", 0, \"결재 시한 초과 -> 발주하지 않음\"\n", + " else:\n", + " dec, final, why = \"승인\", int(q[i]), \"위험 지수·재고 근거 확인\"\n", + " audit.add(t=\"결재\", item=iid, decision=dec, final_qty=final, reason=why, by=\"정비장교(가상)\")\n", + " rows.append({\"품목\": iid, \"구분\": kind, \"위험\": risk[i], \"추천\": int(q[i]), \"결정\": dec, \"확정\": final, \"사유\": why})\n", + "res = pd.DataFrame(rows).sort_values(\"위험\", ascending=False)\n", + "print(res.head(10).to_string(index=False, float_format=lambda x: f\"{x:.0f}\"))\n", + "print(\"결정 집계:\", \", \".join(f\"{k} {v}건\" for k, v in res[\"결정\"].value_counts().items()))\n", + "auto = [e for e in audit.log if e[\"t\"] == \"추천\" and e[\"status\"] != \"결재대기\"]\n", + "print(f\"자동 확정 {len(auto)}건, 결재 사유 기록률 {res['사유'].astype(bool).mean():.0%}, 감사 로그 {len(audit.log)}건 무결성: {'정상' if audit.verify() is None else '변조'}\")\n", + "j = next(e[\"seq\"] for e in audit.log if e[\"t\"] == \"결재\" and e[\"decision\"] != \"승인\")\n", + "old = audit.log[j][\"decision\"]; audit.log[j][\"decision\"] = \"승인\"\n", + "print(f\"변조 시험({j}번 {old} 기록을 승인으로 수정) -> {audit.verify()}번 기록에서 변조 검출\")\n", + "\n", + "fig, ax = plt.subplots(1, 2, figsize=(12, 4))\n", + "tab[[\"보관비\", \"발주비\", \"결품손실\"]].plot.bar(stacked=True, ax=ax[0], rot=0)\n", + "ax[0].set_xticklabels([\"current\", \"forecast\", \"forecast+EOL alt\"]); ax[0].set_title(\"12-month cost (million, synthetic)\"); ax[0].legend([\"holding\", \"ordering\", \"shortage\"])\n", + "sd = pd.DataFrame(sens)\n", + "for nm, g in sd.groupby(\"수요\"):\n", + " ax[1].plot(g[\"z\"], g[\"총비용\"], \"o-\", label=\"normal\" if nm == \"평시\" else \"surge x1.5\")\n", + "ax[1].set_xlabel(\"service level z\"); ax[1].set_ylabel(\"total cost (million)\"); ax[1].set_title(\"Sensitivity\"); ax[1].legend()\n", + "plt.tight_layout(); plt.show()\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 07 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + "[12개월 재고 백테스트(가상 비용 지수, 백만 원)]\n", + " 보관비 발주비 결품손실 총비용(백만) 충족률 회전율\n", + "현행(평균+3개월 여유) 81.6 21.7 234.3 337.6 86.9% 5.42\n", + "예측 정책(z=1.65) 77.2 12.0 235.2 324.4 86.8% 6.09\n", + "예측+단종 대체품 전환 108.9 12.7 8.3 129.8 98.6% 5.65\n", + "총비용 절감 61.5%, 예측 정책만의 회전율 향상 12.2% (목표: 비용 15% 절감 또는 회전율 20% 향상 -> 달성)\n", + "효과 분해: 생산중·단종예고 품목 비용 13.0% 절감(예측 정책), 나머지는 단종 품목 대체품 전환 효과\n", + "[민감도] z=1.28 평시: 125백만, 충족 98.5% / z=1.28 돌발 1.5배: 230백만, 충족 90.4% / z=1.65 평시: 130백만, 충족 98.6% / z=1.65 돌발 1.5배: 213백만, 충족 91.6% / z=2.05 평시: 136백만, 충족 98.7% / z=2.05 돌발 1.5배: 195백만, 충족 92.8%\n", + "\n", + "[다음 달 발주 추천 LP] 부족 품목 18개, 예산 93.6백만(부족분 전액의 60%, 가상) 사용 93.5백만, 추천 16건\n", + " 품목 구분 위험 추천 결정 확정 사유\n", + "P004 정규 발주 88 45 조정 31 고액 발주 분할(1차 70%), 다음 달 재검토\n", + "P019 정규 발주 88 62 승인 62 위험 지수·재고 근거 확인\n", + "P030 정규 발주 88 491 승인 491 위험 지수·재고 근거 확인\n", + "P025 정규 발주 88 116 조정 81 고액 발주 분할(1차 70%), 다음 달 재검토\n", + "P038 정규 발주 88 57 승인 57 위험 지수·재고 근거 확인\n", + "P027 정규 발주 87 29 조정 20 고액 발주 분할(1차 70%), 다음 달 재검토\n", + "P032 정규 발주 73 26 승인 26 위험 지수·재고 근거 확인\n", + "P006 정규 발주 64 23 승인 23 위험 지수·재고 근거 확인\n", + "P012 정규 발주 61 45 승인 45 위험 지수·재고 근거 확인\n", + "P037 정규 발주 41 14 승인 14 위험 지수·재고 근거 확인\n", + "결정 집계: 승인 11건, 조정 3건, 보류 2건\n", + "자동 확정 0건, 결재 사유 기록률 100%, 감사 로그 32건 무결성: 정상\n", + "변조 시험(1번 조정 기록을 승인으로 수정) -> 1번 기록에서 변조 검출\n", + "```\n", + "
\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 08. 전시 작전 템포 증가 모의 부품 수요 300% 폭증 시뮬레이션 부하 테스트\n", + "**소요시간**: 약 3.0시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "전시 부품 수요 300% 폭증 시뮬레이션 부하 테스트를 수행한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **전시 작전 템포 증가로 부품 수요가 평시의 300%로 폭증하는 상황을 모의해 결품 경보와 대시보드의 성능을 부하 시험한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **품목군별 실제 증가 배수가 다른 3개월 폭증 상황을 반복 모의하고, 평시 예측·모델 직접 예측·작전 템포 계수 보정·월별 실적 반영 방식의 경보 정확도와 결품 탐지율을 비교 수행하고 검증하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `src/dashboard/mnt02_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": [ + "\"\"\"전시 작전 템포 증가로 부품 수요가 평시의 300%로 폭증하는 3개월 상황을 가상으로 모의해, 품목별 결품 경보(다음 달 예측 수요 > 가용재고)의 정확도를 반복 시험하고, 품목 수 50배 부하에서 재고 대시보드 갱신 시간(2초 이내)을 측정한다. 트리 모델이 학습 범위 밖 수요를 외삽하지 못하는 한계를 작전 템포 계수 보정으로 보완한다.\"\"\"\n", + "import time\n", + "import numpy as np\n", + "import pandas as pd\n", + "import matplotlib.pyplot as plt\n", + "from scipy.stats import norm\n", + "from sklearn.ensemble import HistGradientBoostingRegressor, RandomForestRegressor\n", + "\n", + "# ---------------------------------------------------------------------------\n", + "# [교육용가상자료] 가상 야전 정비창 수리부속 청구/출고·장비 가동률 데이터 생성기\n", + "# 실제 부대·품목·재고·단가 정보와 무관하다. 품목 40개 x 48개월(가상 2022-01 ~ 2025-12) 월별 자료를 만든다.\n", + "# ---------------------------------------------------------------------------\n", + "FSC = {\"2520\": \"power_train\", \"2530\": \"brake_steer\", \"2910\": \"fuel\", \"2930\": \"cooling\",\n", + " \"2940\": \"filter\", \"5330\": \"gasket\", \"6140\": \"battery\", \"5961\": \"semiconductor\"}\n", + "EXERCISE = {3, 8, 10} # 가상 대규모 훈련 월(훈련 계획으로 미리 알 수 있음)\n", + "N_MONTHS = 48\n", + "\n", + "\n", + "def season(fsc, month):\n", + " \"\"\"품목군별 계절 계수: 축전지는 겨울, 냉각계통은 여름에 수요가 늘어난다.\"\"\"\n", + " if fsc == \"6140\":\n", + " return 1.6 if month in (12, 1, 2) else 0.9\n", + " if fsc == \"2930\":\n", + " return 1.5 if month in (6, 7, 8) else 0.9\n", + " return 1 + 0.1 * np.sin(2 * np.pi * month / 12)\n", + "\n", + "\n", + "def make_items(n=40, seed=2):\n", + " \"\"\"품목 마스터: NSN(가상), 품목군, 장비 유형, 기준 수요, 단가, 조달 리드타임, 공급업체 수, 생산(단종) 상태.\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " rows = []\n", + " for i in range(n):\n", + " f = str(rng.choice(list(FSC)))\n", + " status = str(rng.choice([\"생산중\", \"단종예고\", \"단종\"], p=[0.7, 0.2, 0.1]))\n", + " rows.append({\"item_id\": f\"P{i + 1:03d}\", \"nsn\": f\"{f}-00-{rng.integers(100, 1000)}-{rng.integers(1000, 10000)}\",\n", + " \"fsc\": f, \"group\": FSC[f], \"equip_type\": str(rng.choice([\"A\", \"B\", \"C\"])),\n", + " \"base\": float(np.round(rng.lognormal(2.7, 0.8), 1)),\n", + " \"price\": int(rng.lognormal(12, 1.0)) // 100 * 100,\n", + " \"lead_time\": int(rng.integers(1, 7) if status == \"생산중\" else rng.integers(4, 13)),\n", + " \"suppliers\": int(rng.integers(1, 5)) if status == \"생산중\" else 1,\n", + " \"status\": status, \"alt_available\": bool(rng.random() < (0.6 if status == \"생산중\" else 0.3))})\n", + " return pd.DataFrame(rows)\n", + "\n", + "\n", + "def make_history(items, months=N_MONTHS, seed=3):\n", + " \"\"\"월별 청구 수요(개), 긴급 불출 건수, 장비 유형별 운용시간·가동률.\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " t = np.arange(months)\n", + " moy = t % 12 + 1\n", + " ex = np.isin(moy, list(EXERCISE))\n", + " hours = {e: (100 + 20 * rng.random(months)) * np.where(ex, 1.5, 1.0) for e in \"ABC\"}\n", + " avail = {e: np.clip(0.93 - 0.12 * (hours[e] - 100) / 80 + rng.normal(0, 0.015, months), 0.6, 0.99) for e in \"ABC\"}\n", + " rows = []\n", + " for it in items.itertuples():\n", + " trend = 1 + rng.uniform(-0.1, 0.3) * t / months\n", + " s = np.array([season(it.fsc, m) for m in moy])\n", + " lam = it.base * s * trend * hours[it.equip_type] / 110\n", + " d = rng.poisson(lam)\n", + " rows.append(pd.DataFrame({\"item_id\": it.item_id, \"t\": t, \"ym\": [f\"{2022 + k // 12}-{k % 12 + 1:02d}\" for k in t],\n", + " \"month\": moy, \"exercise\": ex.astype(int), \"op_hours\": hours[it.equip_type].round(1),\n", + " \"availability\": avail[it.equip_type].round(3), \"demand\": d,\n", + " \"emergency\": rng.binomial(d, 0.05)}))\n", + " return pd.concat(rows, ignore_index=True)\n", + "\n", + "\n", + "def make_stock(items, hist, seed=4):\n", + " \"\"\"현재(가상 2026-01) 재고: 보유량과 미입고 발주량(조달 중인 물량). 단종 품목은 추가 발주가 어려워 미입고량이 적다.\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " recent = hist[hist[\"t\"] >= hist[\"t\"].max() - 5].groupby(\"item_id\")[\"demand\"].mean()\n", + " st = items[[\"item_id\", \"lead_time\", \"status\"]].copy()\n", + " st[\"on_hand\"] = [int(round(recent[i] * rng.uniform(0.5, 3.0))) for i in st[\"item_id\"]]\n", + " cover = np.where(st[\"status\"] == \"생산중\", rng.uniform(0.6, 1.3, len(st)), rng.uniform(0.0, 0.6, len(st)))\n", + " st[\"on_order\"] = [int(round(recent[i] * L * c)) for i, L, c in zip(st[\"item_id\"], st[\"lead_time\"], cover)]\n", + " return st[[\"item_id\", \"on_hand\", \"on_order\"]]\n", + "# ---------------------------------------------------------------------------\n", + "\n", + "# ---- 수요 예측 공통 함수(파생 피처 + 머신러닝 모델) ----\n", + "FEATS = [\"lag1\", \"lag2\", \"lag3\", \"lag12\", \"ma3\", \"ma6\", \"std6\", \"season_idx\", \"month_sin\", \"month_cos\",\n", + " \"exercise\", \"op_hours\", \"op_ratio\", \"item_level\", \"trend_t\"]\n", + "\n", + "\n", + "def make_features(hist):\n", + " \"\"\"품목별 과거 수요로 파생 피처를 만든다(모두 예측 시점 이전 정보만 사용).\"\"\"\n", + " df = hist.sort_values([\"item_id\", \"t\"]).reset_index(drop=True)\n", + " g = df.groupby(\"item_id\")[\"demand\"]\n", + " for k in (1, 2, 3, 12):\n", + " df[f\"lag{k}\"] = g.shift(k)\n", + " df[\"ma3\"] = g.transform(lambda s: s.shift(1).rolling(3).mean())\n", + " df[\"ma6\"] = g.transform(lambda s: s.shift(1).rolling(6).mean())\n", + " df[\"std6\"] = g.transform(lambda s: s.shift(1).rolling(6).std())\n", + " df[\"item_level\"] = g.transform(lambda s: s.shift(1).rolling(12).mean())\n", + " df[\"season_idx\"] = df[\"lag12\"] / (g.transform(lambda s: s.shift(12).rolling(12).mean()) + 1)\n", + " df[\"month_sin\"] = np.sin(2 * np.pi * df[\"month\"] / 12)\n", + " df[\"month_cos\"] = np.cos(2 * np.pi * df[\"month\"] / 12)\n", + " df[\"op_ratio\"] = df[\"op_hours\"] / df.groupby(\"item_id\")[\"op_hours\"].transform(lambda s: s.shift(1).rolling(12).mean())\n", + " df[\"trend_t\"] = df[\"t\"]\n", + " return df.dropna().reset_index(drop=True)\n", + "\n", + "\n", + "def new_model():\n", + " \"\"\"Prophet/LSTM 대신 사용하는 포아송 손실 그래디언트 부스팅(개수 자료에 적합).\"\"\"\n", + " return HistGradientBoostingRegressor(loss=\"poisson\", max_iter=300, learning_rate=0.05, max_leaf_nodes=15,\n", + " min_samples_leaf=20, random_state=0)\n", + "\n", + "\n", + "def mape(y, p, min_y=1):\n", + " y, p = np.asarray(y, float), np.asarray(p, float)\n", + " m = y >= min_y\n", + " return float(np.mean(np.abs(y[m] - p[m]) / y[m]))\n", + "\n", + "# ---- 앙상블 수요 예측·안전재고·결품(단종) 위험 지수 공통 함수 ----\n", + "STATUS_W = {\"생산중\": 0.0, \"단종예고\": 0.5, \"단종\": 1.0} # [교육용가상자료] 단종 상태 가중치\n", + "\n", + "\n", + "def fit_ens(tr):\n", + " \"\"\"포아송 그래디언트 부스팅(70%) + 랜덤포레스트(30%) 앙상블.\"\"\"\n", + " h = new_model().fit(tr[FEATS], tr[\"demand\"])\n", + " r = RandomForestRegressor(n_estimators=150, min_samples_leaf=5, random_state=0, n_jobs=1).fit(tr[FEATS], tr[\"demand\"])\n", + " return h, r\n", + "\n", + "\n", + "def pred_ens(m, X, w=0.7):\n", + " return np.clip(w * m[0].predict(X[FEATS]) + (1 - w) * m[1].predict(X[FEATS]), 0, None)\n", + "\n", + "\n", + "def next_month(hist, items, t_new=48, op_mult=1.0, ex=0):\n", + " \"\"\"다음 달(t_new) 예측용 행: 작년 같은 달 운용시간 x 배수, 훈련 여부 지정.\"\"\"\n", + " last = hist[hist[\"t\"] == t_new - 1].copy()\n", + " prev = hist[hist[\"t\"] == t_new - 12].set_index(\"item_id\")[\"op_hours\"]\n", + " m = (t_new % 12) + 1\n", + " f = last.assign(t=t_new, ym=f\"{2022 + t_new // 12}-{m:02d}\", month=m, demand=0, exercise=ex,\n", + " op_hours=last[\"item_id\"].map(prev).to_numpy() * op_mult)\n", + " X = make_features(pd.concat([hist, f], ignore_index=True))\n", + " return X[X[\"t\"] == t_new].set_index(\"item_id\").loc[items[\"item_id\"]].reset_index()\n", + "\n", + "\n", + "def stock_policy(items, mu, sigma, z=1.65, review=1):\n", + " \"\"\"안전재고 = z x 월 예측오차 표준편차 x sqrt(리드타임+검토주기), 재주문점 = 월 수요 x (리드타임+검토주기) + 안전재고.\"\"\"\n", + " L = items[\"lead_time\"].to_numpy() + review\n", + " ss = z * sigma * np.sqrt(L)\n", + " return pd.DataFrame({\"item_id\": items[\"item_id\"], \"mu\": mu, \"sigma\": sigma, \"L\": L,\n", + " \"ss\": np.ceil(ss), \"rop\": np.ceil(mu * L + ss)})\n", + "\n", + "\n", + "def risk_index(items, pol, stock):\n", + " \"\"\"결품 위험 지수(0~100): 리드타임 동안 수요가 가용 재고를 넘을 확률(정규 근사)을 기본으로,\n", + " 단종 상태·단일 공급업체·대체품 부재에 가중을 준다(가상 가중치).\"\"\"\n", + " st = stock.set_index(\"item_id\").loc[items[\"item_id\"]]\n", + " avail = (st[\"on_hand\"] + st[\"on_order\"]).to_numpy()\n", + " p = 1 - norm.cdf((avail - pol[\"mu\"].to_numpy() * pol[\"L\"].to_numpy()) / (pol[\"sigma\"].to_numpy() * np.sqrt(pol[\"L\"].to_numpy()) + 1e-6))\n", + " sw = items[\"status\"].map(STATUS_W).to_numpy()\n", + " sup = (items[\"suppliers\"].to_numpy() == 1).astype(float)\n", + " alt = (~items[\"alt_available\"].to_numpy()).astype(float)\n", + " score = 100 * np.clip(0.55 * p + 0.25 * sw + 0.10 * sup + 0.10 * alt, 0, 1)\n", + " return pd.DataFrame({\"item_id\": items[\"item_id\"], \"p_short\": p, \"status_w\": sw, \"single_sup\": sup, \"no_alt\": alt,\n", + " \"avail\": avail, \"risk\": score.round(1)})\n", + "\n", + "SURGE = 3.0 # 계획상 작전 템포 수요 배수(300%)\n", + "GROUP_MULT = {\"power_train\": 3.6, \"brake_steer\": 3.3, \"fuel\": 3.0, \"cooling\": 2.8, # [교육용가상자료] 실제 배수(계획에는 미반영)\n", + " \"filter\": 2.7, \"gasket\": 3.2, \"battery\": 2.6, \"semiconductor\": 3.1}\n", + "REPS, MONTHS = 30, 3\n", + "\n", + "items = make_items()\n", + "hist = make_history(items)\n", + "df = make_features(hist)\n", + "stock = make_stock(items, hist)\n", + "ens = fit_ens(df)\n", + "X = next_month(hist, items, op_mult=SURGE, ex=1)\n", + "raw = pred_ens(ens, X) # 운용시간 3배를 넣은 모델 예측(학습 범위 밖)\n", + "base = pred_ens(ens, next_month(hist, items)) # 평시 예측\n", + "adj = base * SURGE # 작전 템포 계수 보정 예측\n", + "print(f\"[폭증 예측] 평시 합계 {base.sum():.0f}개, 모델 직접 예측 {raw.sum():.0f}개({raw.sum() / base.sum():.1f}배), 템포 계수 보정 {adj.sum():.0f}개({SURGE:.0f}배)\")\n", + "print(\"-> 트리 모델은 학습한 운용시간 범위를 넘으면 예측이 더 늘지 않는다(외삽 불가). 계획 배수로 보정한다.\")\n", + "\n", + "\n", + "def run(fc, seed, adaptive=False):\n", + " \"\"\"3개월 모의: 매월 경보(예측 수요 > 월초 재고) vs 실제 결품(실제 수요 > 월초 재고).\n", + " adaptive=True이면 지난달 품목군별 실제/평시 비율로 다음 달 배수를 갱신한다(실적 반영).\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " true_mult = items[\"group\"].map(GROUP_MULT).to_numpy() * rng.lognormal(0, 0.12, len(items))\n", + " on = stock[\"on_hand\"].to_numpy().astype(float)\n", + " arrive = stock[\"on_order\"].to_numpy() / np.maximum(items[\"lead_time\"].to_numpy(), 1)\n", + " al, tr, bd = [], [], []\n", + " grp = items[\"group\"].to_numpy(); f = fc.copy()\n", + " for _ in range(MONTHS):\n", + " actual = rng.poisson(base * true_mult)\n", + " al.append(f > on); tr.append(actual > on); bd.append(np.abs(f - on) <= 1.65 * np.sqrt(np.maximum(f, 1)))\n", + " if adaptive:\n", + " ratio = pd.Series(actual).groupby(grp).sum() / pd.Series(base).groupby(grp).sum()\n", + " f = base * pd.Series(grp).map(ratio).to_numpy()\n", + " on = np.maximum(on - actual, 0) + arrive\n", + " return np.array(al), np.array(tr), np.array(bd)\n", + "\n", + "\n", + "rows = []\n", + "for nm, fc, ad in ((\"평시 예측(보정 없음)\", base, False), (\"모델 직접 예측\", raw, False), (\"템포 계수 보정\", adj, False),\n", + " (\"템포 보정+월별 실적 반영\", adj, True)):\n", + " acc, rec, n_al = [], [], []\n", + " for s in range(REPS):\n", + " a, t, bd = run(fc, s, ad)\n", + " acc.append((a == t).mean()); rec.append((a & t).sum() / max(t.sum(), 1)); n_al.append(a.sum())\n", + " if ad:\n", + " clear = [(lambda a, t, bd: ((a == t)[~bd].mean(), bd.mean()))(*run(fc, s, ad)) for s in range(REPS)]\n", + " rows.append({\"예측 방식\": nm, \"경보 정확도\": np.mean(acc), \"최저 정확도\": np.min(acc), \"결품 탐지율\": np.mean(rec), \"월평균 경보\": np.mean(n_al) / MONTHS})\n", + "res = pd.DataFrame(rows)\n", + "print(f\"\\n[경보 시험: 품목 {len(items)}개 x {MONTHS}개월 x 반복 {REPS}회]\")\n", + "print(res.to_string(index=False, formatters={\"경보 정확도\": \"{:.1%}\".format, \"최저 정확도\": \"{:.1%}\".format, \"결품 탐지율\": \"{:.1%}\".format, \"월평균 경보\": \"{:.1f}\".format}))\n", + "acc = res.iloc[3][\"경보 정확도\"]\n", + "print(f\"템포 보정+실적 반영 경보 정확도 {acc:.1%} (목표 95% 이상: {'달성' if acc >= 0.95 else '미달'}), 템포 보정만 {res.iloc[2]['경보 정확도']:.1%}\")\n", + "print(f\"오경보 원인: 예측과 재고 차이가 수요 변동 폭(1.65 x 표준편차) 이내인 경계 구간이 판정의 {np.mean([c[1] for c in clear]):.0%}를 차지한다. \"\n", + " f\"경계 구간을 '주의(검토 필요)'로 따로 표시하면 나머지 판정 정확도는 {np.mean([c[0] for c in clear]):.1%}이다.\")\n", + "\n", + "\n", + "# 부하 시험: 품목 50배(2,000개) 대시보드 갱신 = 예측 + 위험 지수 + 집계\n", + "def refresh(Xb, it, st):\n", + " mu = pred_ens(ens, Xb) * SURGE\n", + " pol = stock_policy(it, mu, np.sqrt(np.maximum(mu, 1)))\n", + " rk = risk_index(it, pol, st)\n", + " return rk.assign(group=it[\"group\"].to_numpy()).groupby(\"group\")[\"risk\"].agg([\"mean\", \"max\"]), rk\n", + "\n", + "\n", + "K = 50\n", + "Xb = pd.concat([X] * K, ignore_index=True)\n", + "itb = pd.concat([items.assign(item_id=items[\"item_id\"] + f\"_{k}\") for k in range(K)], ignore_index=True)\n", + "stb = pd.concat([stock.assign(item_id=stock[\"item_id\"] + f\"_{k}\") for k in range(K)], ignore_index=True)\n", + "times = []\n", + "for _ in range(3):\n", + " t0 = time.perf_counter(); agg, rk = refresh(Xb, itb, stb); times.append(time.perf_counter() - t0)\n", + "t1 = time.perf_counter(); refresh(X, items, stock); t_small = time.perf_counter() - t1\n", + "print(f\"\\n[대시보드 부하 시험] 품목 {len(itb)}개 갱신 평균 {np.mean(times):.2f}s / 최대 {max(times):.2f}s, 품목 40개 {t_small:.3f}s (목표 2초 이내: {'달성' if max(times) <= 2 else '미달'})\")\n", + "print(\"폭증 시 품목군별 최대 위험 지수:\", \", \".join(f\"{g} {r['max']:.0f}\" for g, r in agg.iterrows()))\n", + "print(\"경보는 조달 검토 요청이며 긴급 조달·대체품 전환은 정비장교 승인 후 진행한다.\")\n", + "\n", + "fig, ax = plt.subplots(1, 2, figsize=(12, 4))\n", + "ax[0].bar([\"normal\", \"raw\", \"tempo\", \"tempo+AAR\"], res[\"경보 정확도\"], color=[\"gray\", \"tab:orange\", \"tab:green\", \"tab:blue\"])\n", + "ax[0].axhline(0.95, c=\"r\", ls=\"--\"); ax[0].set_ylim(0.5, 1); ax[0].set_title(\"Alert accuracy under 300% demand surge\")\n", + "ax[1].scatter(base, raw, s=12, label=\"model raw\"); ax[1].scatter(base, adj, s=12, label=\"tempo adjusted\")\n", + "ax[1].plot([0, base.max()], [0, base.max() * 3], \"k--\", lw=1, label=\"x3 line\"); ax[1].set_xlabel(\"normal forecast\"); ax[1].set_ylabel(\"surge forecast\")\n", + "ax[1].set_title(\"Tree model cannot extrapolate\"); ax[1].legend()\n", + "plt.tight_layout(); plt.show()\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 08 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + "[폭증 예측] 평시 합계 948개, 모델 직접 예측 1193개(1.3배), 템포 계수 보정 2843개(3배)\n", + "-> 트리 모델은 학습한 운용시간 범위를 넘으면 예측이 더 늘지 않는다(외삽 불가). 계획 배수로 보정한다.\n", + "\n", + "[경보 시험: 품목 40개 x 3개월 x 반복 30회]\n", + " 예측 방식 경보 정확도 최저 정확도 결품 탐지율 월평균 경보\n", + " 평시 예측(보정 없음) 45.9% 40.8% 40.3% 14.6\n", + " 모델 직접 예측 65.0% 61.7% 61.5% 22.3\n", + " 템포 계수 보정 93.6% 88.3% 98.5% 37.7\n", + "템포 보정+월별 실적 반영 93.8% 88.3% 98.4% 37.5\n", + "템포 보정+실적 반영 경보 정확도 93.8% (목표 95% 이상: 미달), 템포 보정만 93.6%\n", + "오경보 원인: 예측과 재고 차이가 수요 변동 폭(1.65 x 표준편차) 이내인 경계 구간이 판정의 19%를 차지한다. 경계 구간을 '주의(검토 필요)'로 따로 표시하면 나머지 판정 정확도는 99.6%이다.\n", + "\n", + "[대시보드 부하 시험] 품목 2000개 갱신 평균 0.05s / 최대 0.05s, 품목 40개 0.024s (목표 2초 이내: 달성)\n", + "폭증 시 품목군별 최대 위험 지수: battery 100, brake_steer 88, cooling 65, filter 88, fuel 88, gasket 90, power_train 90, semiconductor 78\n", + "경보는 조달 검토 요청이며 긴급 조달·대체품 전환은 정비장교 승인 후 진행한다.\n", + "```\n", + "
\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 09. AAR 기반 부품 과다/과소 재고 예측 오차 분석 및 안전재고 파라미터 재보정\n", + "**소요시간**: 약 3.0시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "AAR 기반 부품 수요 예측 오차를 분석하고 안전재고 파라미터를 재보정한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **AAR 관점에서 과다·과소 재고와 수요 예측 오차를 분석하고 안전재고 파라미터를 재보정해 도입 효과를 산출한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **앞 6개월 운영 결과를 품목군·ABC 등급·생산 상태별 예측 편향, 과다 재고, 결품 품목으로 분석해 원인을 찾는다 과정을 진행하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `reports/mnt02_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 관점에서 지난 12개월 재고 운영의 과다·과소 재고와 수요 예측 오차를 품목군·생산 상태·ABC 등급별로 분석해 원인을 찾고, 앞 6개월 자료로 ABC 등급별 안전재고 계수(z)와 품목군별 예측 편향 보정값을 재보정한 뒤 뒤 6개월에서 검증하며, 비용 절감액·정비 대기시간 단축률·재고 회전율 3대 효과 지표와 민감도, 도입 로드맵을 산출한다.\"\"\"\n", + "import numpy as np\n", + "import pandas as pd\n", + "import matplotlib.pyplot as plt\n", + "from scipy.stats import norm\n", + "from sklearn.ensemble import HistGradientBoostingRegressor, RandomForestRegressor\n", + "\n", + "# ---------------------------------------------------------------------------\n", + "# [교육용가상자료] 가상 야전 정비창 수리부속 청구/출고·장비 가동률 데이터 생성기\n", + "# 실제 부대·품목·재고·단가 정보와 무관하다. 품목 40개 x 48개월(가상 2022-01 ~ 2025-12) 월별 자료를 만든다.\n", + "# ---------------------------------------------------------------------------\n", + "FSC = {\"2520\": \"power_train\", \"2530\": \"brake_steer\", \"2910\": \"fuel\", \"2930\": \"cooling\",\n", + " \"2940\": \"filter\", \"5330\": \"gasket\", \"6140\": \"battery\", \"5961\": \"semiconductor\"}\n", + "EXERCISE = {3, 8, 10} # 가상 대규모 훈련 월(훈련 계획으로 미리 알 수 있음)\n", + "N_MONTHS = 48\n", + "\n", + "\n", + "def season(fsc, month):\n", + " \"\"\"품목군별 계절 계수: 축전지는 겨울, 냉각계통은 여름에 수요가 늘어난다.\"\"\"\n", + " if fsc == \"6140\":\n", + " return 1.6 if month in (12, 1, 2) else 0.9\n", + " if fsc == \"2930\":\n", + " return 1.5 if month in (6, 7, 8) else 0.9\n", + " return 1 + 0.1 * np.sin(2 * np.pi * month / 12)\n", + "\n", + "\n", + "def make_items(n=40, seed=2):\n", + " \"\"\"품목 마스터: NSN(가상), 품목군, 장비 유형, 기준 수요, 단가, 조달 리드타임, 공급업체 수, 생산(단종) 상태.\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " rows = []\n", + " for i in range(n):\n", + " f = str(rng.choice(list(FSC)))\n", + " status = str(rng.choice([\"생산중\", \"단종예고\", \"단종\"], p=[0.7, 0.2, 0.1]))\n", + " rows.append({\"item_id\": f\"P{i + 1:03d}\", \"nsn\": f\"{f}-00-{rng.integers(100, 1000)}-{rng.integers(1000, 10000)}\",\n", + " \"fsc\": f, \"group\": FSC[f], \"equip_type\": str(rng.choice([\"A\", \"B\", \"C\"])),\n", + " \"base\": float(np.round(rng.lognormal(2.7, 0.8), 1)),\n", + " \"price\": int(rng.lognormal(12, 1.0)) // 100 * 100,\n", + " \"lead_time\": int(rng.integers(1, 7) if status == \"생산중\" else rng.integers(4, 13)),\n", + " \"suppliers\": int(rng.integers(1, 5)) if status == \"생산중\" else 1,\n", + " \"status\": status, \"alt_available\": bool(rng.random() < (0.6 if status == \"생산중\" else 0.3))})\n", + " return pd.DataFrame(rows)\n", + "\n", + "\n", + "def make_history(items, months=N_MONTHS, seed=3):\n", + " \"\"\"월별 청구 수요(개), 긴급 불출 건수, 장비 유형별 운용시간·가동률.\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " t = np.arange(months)\n", + " moy = t % 12 + 1\n", + " ex = np.isin(moy, list(EXERCISE))\n", + " hours = {e: (100 + 20 * rng.random(months)) * np.where(ex, 1.5, 1.0) for e in \"ABC\"}\n", + " avail = {e: np.clip(0.93 - 0.12 * (hours[e] - 100) / 80 + rng.normal(0, 0.015, months), 0.6, 0.99) for e in \"ABC\"}\n", + " rows = []\n", + " for it in items.itertuples():\n", + " trend = 1 + rng.uniform(-0.1, 0.3) * t / months\n", + " s = np.array([season(it.fsc, m) for m in moy])\n", + " lam = it.base * s * trend * hours[it.equip_type] / 110\n", + " d = rng.poisson(lam)\n", + " rows.append(pd.DataFrame({\"item_id\": it.item_id, \"t\": t, \"ym\": [f\"{2022 + k // 12}-{k % 12 + 1:02d}\" for k in t],\n", + " \"month\": moy, \"exercise\": ex.astype(int), \"op_hours\": hours[it.equip_type].round(1),\n", + " \"availability\": avail[it.equip_type].round(3), \"demand\": d,\n", + " \"emergency\": rng.binomial(d, 0.05)}))\n", + " return pd.concat(rows, ignore_index=True)\n", + "\n", + "\n", + "def make_stock(items, hist, seed=4):\n", + " \"\"\"현재(가상 2026-01) 재고: 보유량과 미입고 발주량(조달 중인 물량). 단종 품목은 추가 발주가 어려워 미입고량이 적다.\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " recent = hist[hist[\"t\"] >= hist[\"t\"].max() - 5].groupby(\"item_id\")[\"demand\"].mean()\n", + " st = items[[\"item_id\", \"lead_time\", \"status\"]].copy()\n", + " st[\"on_hand\"] = [int(round(recent[i] * rng.uniform(0.5, 3.0))) for i in st[\"item_id\"]]\n", + " cover = np.where(st[\"status\"] == \"생산중\", rng.uniform(0.6, 1.3, len(st)), rng.uniform(0.0, 0.6, len(st)))\n", + " st[\"on_order\"] = [int(round(recent[i] * L * c)) for i, L, c in zip(st[\"item_id\"], st[\"lead_time\"], cover)]\n", + " return st[[\"item_id\", \"on_hand\", \"on_order\"]]\n", + "# ---------------------------------------------------------------------------\n", + "\n", + "# ---- 수요 예측 공통 함수(파생 피처 + 머신러닝 모델) ----\n", + "FEATS = [\"lag1\", \"lag2\", \"lag3\", \"lag12\", \"ma3\", \"ma6\", \"std6\", \"season_idx\", \"month_sin\", \"month_cos\",\n", + " \"exercise\", \"op_hours\", \"op_ratio\", \"item_level\", \"trend_t\"]\n", + "\n", + "\n", + "def make_features(hist):\n", + " \"\"\"품목별 과거 수요로 파생 피처를 만든다(모두 예측 시점 이전 정보만 사용).\"\"\"\n", + " df = hist.sort_values([\"item_id\", \"t\"]).reset_index(drop=True)\n", + " g = df.groupby(\"item_id\")[\"demand\"]\n", + " for k in (1, 2, 3, 12):\n", + " df[f\"lag{k}\"] = g.shift(k)\n", + " df[\"ma3\"] = g.transform(lambda s: s.shift(1).rolling(3).mean())\n", + " df[\"ma6\"] = g.transform(lambda s: s.shift(1).rolling(6).mean())\n", + " df[\"std6\"] = g.transform(lambda s: s.shift(1).rolling(6).std())\n", + " df[\"item_level\"] = g.transform(lambda s: s.shift(1).rolling(12).mean())\n", + " df[\"season_idx\"] = df[\"lag12\"] / (g.transform(lambda s: s.shift(12).rolling(12).mean()) + 1)\n", + " df[\"month_sin\"] = np.sin(2 * np.pi * df[\"month\"] / 12)\n", + " df[\"month_cos\"] = np.cos(2 * np.pi * df[\"month\"] / 12)\n", + " df[\"op_ratio\"] = df[\"op_hours\"] / df.groupby(\"item_id\")[\"op_hours\"].transform(lambda s: s.shift(1).rolling(12).mean())\n", + " df[\"trend_t\"] = df[\"t\"]\n", + " return df.dropna().reset_index(drop=True)\n", + "\n", + "\n", + "def new_model():\n", + " \"\"\"Prophet/LSTM 대신 사용하는 포아송 손실 그래디언트 부스팅(개수 자료에 적합).\"\"\"\n", + " return HistGradientBoostingRegressor(loss=\"poisson\", max_iter=300, learning_rate=0.05, max_leaf_nodes=15,\n", + " min_samples_leaf=20, random_state=0)\n", + "\n", + "\n", + "def mape(y, p, min_y=1):\n", + " y, p = np.asarray(y, float), np.asarray(p, float)\n", + " m = y >= min_y\n", + " return float(np.mean(np.abs(y[m] - p[m]) / y[m]))\n", + "\n", + "# ---- 앙상블 수요 예측·안전재고·결품(단종) 위험 지수 공통 함수 ----\n", + "STATUS_W = {\"생산중\": 0.0, \"단종예고\": 0.5, \"단종\": 1.0} # [교육용가상자료] 단종 상태 가중치\n", + "\n", + "\n", + "def fit_ens(tr):\n", + " \"\"\"포아송 그래디언트 부스팅(70%) + 랜덤포레스트(30%) 앙상블.\"\"\"\n", + " h = new_model().fit(tr[FEATS], tr[\"demand\"])\n", + " r = RandomForestRegressor(n_estimators=150, min_samples_leaf=5, random_state=0, n_jobs=1).fit(tr[FEATS], tr[\"demand\"])\n", + " return h, r\n", + "\n", + "\n", + "def pred_ens(m, X, w=0.7):\n", + " return np.clip(w * m[0].predict(X[FEATS]) + (1 - w) * m[1].predict(X[FEATS]), 0, None)\n", + "\n", + "\n", + "def next_month(hist, items, t_new=48, op_mult=1.0, ex=0):\n", + " \"\"\"다음 달(t_new) 예측용 행: 작년 같은 달 운용시간 x 배수, 훈련 여부 지정.\"\"\"\n", + " last = hist[hist[\"t\"] == t_new - 1].copy()\n", + " prev = hist[hist[\"t\"] == t_new - 12].set_index(\"item_id\")[\"op_hours\"]\n", + " m = (t_new % 12) + 1\n", + " f = last.assign(t=t_new, ym=f\"{2022 + t_new // 12}-{m:02d}\", month=m, demand=0, exercise=ex,\n", + " op_hours=last[\"item_id\"].map(prev).to_numpy() * op_mult)\n", + " X = make_features(pd.concat([hist, f], ignore_index=True))\n", + " return X[X[\"t\"] == t_new].set_index(\"item_id\").loc[items[\"item_id\"]].reset_index()\n", + "\n", + "\n", + "def stock_policy(items, mu, sigma, z=1.65, review=1):\n", + " \"\"\"안전재고 = z x 월 예측오차 표준편차 x sqrt(리드타임+검토주기), 재주문점 = 월 수요 x (리드타임+검토주기) + 안전재고.\"\"\"\n", + " L = items[\"lead_time\"].to_numpy() + review\n", + " ss = z * sigma * np.sqrt(L)\n", + " return pd.DataFrame({\"item_id\": items[\"item_id\"], \"mu\": mu, \"sigma\": sigma, \"L\": L,\n", + " \"ss\": np.ceil(ss), \"rop\": np.ceil(mu * L + ss)})\n", + "\n", + "\n", + "def risk_index(items, pol, stock):\n", + " \"\"\"결품 위험 지수(0~100): 리드타임 동안 수요가 가용 재고를 넘을 확률(정규 근사)을 기본으로,\n", + " 단종 상태·단일 공급업체·대체품 부재에 가중을 준다(가상 가중치).\"\"\"\n", + " st = stock.set_index(\"item_id\").loc[items[\"item_id\"]]\n", + " avail = (st[\"on_hand\"] + st[\"on_order\"]).to_numpy()\n", + " p = 1 - norm.cdf((avail - pol[\"mu\"].to_numpy() * pol[\"L\"].to_numpy()) / (pol[\"sigma\"].to_numpy() * np.sqrt(pol[\"L\"].to_numpy()) + 1e-6))\n", + " sw = items[\"status\"].map(STATUS_W).to_numpy()\n", + " sup = (items[\"suppliers\"].to_numpy() == 1).astype(float)\n", + " alt = (~items[\"alt_available\"].to_numpy()).astype(float)\n", + " score = 100 * np.clip(0.55 * p + 0.25 * sw + 0.10 * sup + 0.10 * alt, 0, 1)\n", + " return pd.DataFrame({\"item_id\": items[\"item_id\"], \"p_short\": p, \"status_w\": sw, \"single_sup\": sup, \"no_alt\": alt,\n", + " \"avail\": avail, \"risk\": score.round(1)})\n", + "\n", + "# ---- 월 단위 재고 백테스트(가상 비용 지수) ----\n", + "HOLD, ORDER_COST, SHORT_MULT = 0.02, 50_000, 0.6 # [교육용가상자료] 월 보관비율(단가 대비), 발주 1회 비용(원), 결품 1개 손실(단가 배수)\n", + "\n", + "\n", + "ALT_PREMIUM, ALT_LT = 1.2, 3 # [교육용가상자료] 대체품 전환 시 단가 배수, 대체품 조달 리드타임(월)\n", + "\n", + "\n", + "def simulate(items, demand, fc, policy, z=1.65, months=None, sigma=None, eol=True):\n", + " \"\"\"품목별 월 재고 모의. demand/fc: (품목 x 월) 실제·예측 수요. policy: 'current'(최근 12개월 평균 + 3개월 여유 고정) 또는 'ai'(예측 + 안전재고).\n", + " 단종 품목은 원 품목 발주 불가. 'ai' 정책에서 eol=True이면 단종 품목 중 대체품이 있는 품목은 대체품(단가 x1.2, 리드타임 3개월)으로 전환 발주한다. 반환: 품목별 보관비·발주비·결품손실·불출량·평균재고.\"\"\"\n", + " n, T = demand.shape\n", + " L = items[\"lead_time\"].to_numpy(); price = items[\"price\"].to_numpy().astype(float)\n", + " can_order = (items[\"status\"] != \"단종\").to_numpy()\n", + " if policy == \"ai\" and eol:\n", + " sw = (~can_order) & items[\"alt_available\"].to_numpy()\n", + " can_order = can_order | sw\n", + " L = np.where(sw, ALT_LT, L); price = np.where(sw, price * ALT_PREMIUM, price)\n", + " avg = demand[:, :1].repeat(T, 1) if months is None else months\n", + " on = np.round(avg[:, 0] * 2.0)\n", + " pipe = np.zeros((n, T + int(L.max()) + 1)) # 도착 예정 물량(품목 x 월)\n", + " for i in range(n):\n", + " pipe[i, :L[i]] = np.round(avg[i, 0] * (1.0 if can_order[i] else 0.3))\n", + " res = np.zeros((n, 6)); rows = np.arange(n)\n", + " for t in range(T):\n", + " on += pipe[:, t]\n", + " issue = np.minimum(on, demand[:, t]); short = demand[:, t] - issue; on -= issue\n", + " ip = on + pipe[:, t + 1:].sum(1)\n", + " if policy == \"current\":\n", + " target = avg[:, t] * (L + 3)\n", + " else:\n", + " s = sigma if sigma is not None else np.sqrt(np.maximum(fc[:, t], 1))\n", + " target = fc[:, t] * (L + 1) + z * s * np.sqrt(L + 1)\n", + " q = np.where(can_order & (ip < target), np.ceil(target - ip), 0)\n", + " pipe[rows, t + L] += q\n", + " res += np.c_[HOLD * price * on, ORDER_COST * (q > 0), SHORT_MULT * price * short, issue, on, short]\n", + " out = pd.DataFrame(res, columns=[\"hold\", \"order\", \"short\", \"issued\", \"inv\", \"short_qty\"], index=items[\"item_id\"])\n", + " out[\"inv\"] /= T\n", + " return out\n", + "\n", + "WAIT_H = 36 # [교육용가상자료] 결품 1개당 장비 정비 대기시간(시간)\n", + "\n", + "items = make_items()\n", + "hist = make_history(items)\n", + "df = make_features(hist)\n", + "m = fit_ens(df[df[\"t\"] < 36])\n", + "bt = df[df[\"t\"] >= 36].copy(); bt[\"fc\"] = pred_ens(m, bt)\n", + "P = lambda c: bt.pivot(index=\"item_id\", columns=\"t\", values=c).loc[items[\"item_id\"]].to_numpy()\n", + "D, F, A = P(\"demand\"), P(\"fc\"), P(\"item_level\")\n", + "sig = np.maximum(0.25 * F.mean(1), np.sqrt(np.maximum(F.mean(1), 1)))\n", + "val = items[\"price\"].to_numpy() * D.sum(1)\n", + "cum = np.cumsum(np.sort(val)[::-1]) / val.sum()\n", + "abc = pd.Series(np.select([val >= np.sort(val)[::-1][np.searchsorted(cum, 0.7)], val >= np.sort(val)[::-1][np.searchsorted(cum, 0.9)]], [\"A\", \"B\"], \"C\"), index=items[\"item_id\"])\n", + "\n", + "# 1) AAR 편차 분석: 예측 편향과 과다·과소 재고\n", + "an = items[[\"item_id\", \"group\", \"status\"]].assign(abc=abc.values, bias=(F[:, :6].sum(1) - D[:, :6].sum(1)) / np.maximum(D[:, :6].sum(1), 1))\n", + "r0 = simulate(items, D[:, :6], F[:, :6], \"ai\", 1.65, A[:, :6], sig)\n", + "an[\"over\"] = (r0[\"inv\"].to_numpy() > 3 * np.maximum(D[:, :6].mean(1), 1)) # 평균 재고가 3개월 수요 초과 = 과다\n", + "an[\"under\"] = r0[\"short_qty\"].to_numpy() > 0\n", + "print(\"[AAR 편차 분석: 앞 6개월(t=36~41)]\")\n", + "g = an.groupby(\"group\").agg(예측편향=(\"bias\", \"mean\"), 과다품목=(\"over\", \"sum\"), 결품품목=(\"under\", \"sum\"))\n", + "gt = g.T.astype(object)\n", + "gt.loc[\"예측편향\"] = [f\"{v:+.0%}\" for v in g[\"예측편향\"]]\n", + "gt.loc[[\"과다품목\", \"결품품목\"]] = gt.loc[[\"과다품목\", \"결품품목\"]].map(lambda v: f\"{int(v)}\")\n", + "print(gt.to_string())\n", + "print(\"ABC별 과다/결품 품목:\", \", \".join(f\"{k} {int(v['over'])}/{int(v['under'])}\" for k, v in an.groupby(\"abc\")[[\"over\", \"under\"]].sum().iterrows()),\n", + " \"| 생산 상태별 결품 품목:\", \", \".join(f\"{k} {int(v)}\" for k, v in an.groupby(\"status\")[\"under\"].sum().items()))\n", + "ex = bt[\"exercise\"] == 1\n", + "hi = g[\"예측편향\"].abs().sort_values(ascending=False).index[:2]\n", + "hi_txt = \", \".join(f\"{h}({g.loc[h, '예측편향']:+.0%})\" for h in hi)\n", + "print(f\"훈련 월 예측 편향 {(bt.loc[ex, 'fc'].sum() / bt.loc[ex, 'demand'].sum() - 1):+.1%}, 평시 월 {(bt.loc[~ex, 'fc'].sum() / bt.loc[~ex, 'demand'].sum() - 1):+.1%}\"\n", + " f\" -> 원인: {hi_txt} 품목군 예측 편향, 모든 등급에 같은 z=1.65 적용, 단종 품목 발주 불가\")\n", + "\n", + "# 2) 재보정(앞 6개월로 결정): ABC별 z 격자 탐색 + 품목군별 편향 보정\n", + "grp_bias = an.groupby(\"group\")[\"bias\"].median().clip(-0.3, 0.3)\n", + "corr = 1 / (1 + items[\"group\"].map(grp_bias).to_numpy())\n", + "best = {}\n", + "for cls in \"ABC\":\n", + " mask = (abc == cls).to_numpy()\n", + " costs = {}\n", + " for z in (0.84, 1.28, 1.65, 2.05, 2.33):\n", + " zz = np.where(mask, z, 1.65)\n", + " r = simulate(items, D[:, :6], F[:, :6] * corr[:, None], \"ai\", zz, A[:, :6], sig)\n", + " costs[z] = r[[\"hold\", \"order\", \"short\"]].to_numpy()[mask].sum()\n", + " best[cls] = min(costs, key=costs.get)\n", + "z_new = abc.map(best).to_numpy()\n", + "print(f\"\\n[재보정 결과] ABC별 z: A {best['A']}, B {best['B']}, C {best['C']} (기존 1.65 일괄), 품목군 편향 보정 범위 {corr.min():.2f}~{corr.max():.2f}배\")\n", + "\n", + "# 3) 검증(뒤 6개월, t=42~47) 및 3대 효과 지표\n", + "def ev(fc, z):\n", + " r = simulate(items, D[:, 6:], fc, \"ai\", z, A[:, 6:], sig)\n", + " return r, r[[\"hold\", \"order\", \"short\"]].sum().sum()\n", + "\n", + "\n", + "r_cur = simulate(items, D[:, 6:], F[:, 6:], \"current\", months=A[:, 6:])\n", + "r_old, c_old = ev(F[:, 6:], 1.65)\n", + "r_new, c_new = ev(F[:, 6:] * corr[:, None], z_new)\n", + "c_cur = r_cur[[\"hold\", \"order\", \"short\"]].sum().sum()\n", + "turn = lambda r: float(r[\"issued\"].sum() / r[\"inv\"].sum())\n", + "over_amt = lambda r: float((np.maximum(r[\"inv\"].to_numpy() - 3 * np.maximum(D[:, 6:].mean(1), 1), 0) * items[\"price\"].to_numpy()).sum())\n", + "k = pd.DataFrame([[\"6개월 총비용(백만 원)\", c_cur / 1e6, c_old / 1e6, c_new / 1e6],\n", + " [\"정비 대기시간(시간)\", r_cur[\"short_qty\"].sum() * WAIT_H, r_old[\"short_qty\"].sum() * WAIT_H, r_new[\"short_qty\"].sum() * WAIT_H],\n", + " [\"재고 회전율(회/6개월)\", turn(r_cur), turn(r_old), turn(r_new)]],\n", + " columns=[\"지표\", \"현행 정책\", \"예측 정책(기존 파라미터)\", \"예측 정책(재보정)\"]).set_index(\"지표\")\n", + "k[\"현행 대비 개선율\"] = 1 - k[\"예측 정책(재보정)\"] / k[\"현행 정책\"]\n", + "k.loc[\"재고 회전율(회/6개월)\", \"현행 대비 개선율\"] *= -1 # 회전율은 클수록 좋음\n", + "print(\"\\n[3대 정량 효과 지표: 뒤 6개월 검증, 가상 단가 기준]\")\n", + "print(k.to_string(float_format=lambda x: f\"{x:,.1f}\", formatters={\"현행 대비 개선율\": \"{:.1%}\".format}))\n", + "print(f\"재보정 효과(기존 파라미터 대비): 총비용 {1 - c_new / c_old:+.1%}, 연간 환산 절감액(현행 대비) {(c_cur - c_new) * 2 / 1e6:,.0f}백만 원(가상)\")\n", + "sens = []\n", + "for hm in (0.5, 1.0, 1.5):\n", + " for sm in (0.3, 0.6, 1.0):\n", + " HOLD_, SM_ = HOLD * hm, sm\n", + " cc = lambda r, p: (HOLD_ * p * r[\"inv\"].to_numpy() * 6).sum() + r[\"order\"].sum() + (SM_ * p * r[\"short_qty\"].to_numpy()).sum()\n", + " pr = items[\"price\"].to_numpy()\n", + " sens.append(f\"보관x{hm}/결품x{sm}: {1 - cc(r_new, pr) / cc(r_cur, pr):.0%}\")\n", + "print(\"[민감도: 현행 대비 비용 절감률]\", \", \".join(sens))\n", + "print(\"도입 로드맵: 단기(1~3개월) 분기별 AAR로 z·편향 재보정 / 중기(4~9개월) 단종예고 품목 최종 구매·대체품 검증 절차 정착 / 장기(10개월~) 군수 정보체계 연계와 자동 재학습\")\n", + "print(\"금액·시간은 가정 단가로 계산한 상대 비교값이며 예산 근거로 쓰지 않는다.\")\n", + "\n", + "fig, ax = plt.subplots(1, 2, figsize=(12, 4))\n", + "g[\"예측편향\"].plot.bar(ax=ax[0], rot=30, color=\"tab:purple\"); ax[0].axhline(0, c=\"k\", lw=0.8); ax[0].set_title(\"Forecast bias by group (AAR, first 6 months)\")\n", + "k.iloc[:, :3].div(k[\"현행 정책\"], axis=0).T.plot.bar(ax=ax[1], rot=0); ax[1].set_xticklabels([\"current\", \"forecast(old)\", \"forecast(recal)\"])\n", + "ax[1].set_title(\"Effect indicators (current = 1.0)\"); ax[1].legend([\"cost\", \"wait hours\", \"turnover\"])\n", + "plt.tight_layout(); plt.show()\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 09 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + "[AAR 편차 분석: 앞 6개월(t=36~41)]\n", + "group battery brake_steer cooling filter fuel gasket power_train semiconductor\n", + "예측편향 +11% +18% +4% +1% -6% -5% +1% -3%\n", + "과다품목 1 1 1 2 0 0 0 0\n", + "결품품목 1 0 0 0 1 0 0 0\n", + "ABC별 과다/결품 품목: A 1/0, B 2/0, C 2/2 | 생산 상태별 결품 품목: 단종 1, 단종예고 0, 생산중 1\n", + "훈련 월 예측 편향 +1.5%, 평시 월 +0.1% -> 원인: brake_steer(+18%), battery(+11%) 품목군 예측 편향, 모든 등급에 같은 z=1.65 적용, 단종 품목 발주 불가\n", + "\n", + "[재보정 결과] ABC별 z: A 0.84, B 0.84, C 1.28 (기존 1.65 일괄), 품목군 편향 보정 범위 0.89~1.07배\n", + "\n", + "[3대 정량 효과 지표: 뒤 6개월 검증, 가상 단가 기준]\n", + " 현행 정책 예측 정책(기존 파라미터) 예측 정책(재보정) 현행 대비 개선율\n", + "지표 \n", + "6개월 총비용(백만 원) 117.7 56.7 55.3 53.0%\n", + "정비 대기시간(시간) 17,532.0 1,620.0 1,872.0 89.3%\n", + "재고 회전율(회/6개월) 3.1 3.0 3.1 1.2%\n", + "재보정 효과(기존 파라미터 대비): 총비용 +2.5%, 연간 환산 절감액(현행 대비) 125백만 원(가상)\n", + "[민감도: 현행 대비 비용 절감률] 보관x0.5/결품x0.3: 55%, 보관x0.5/결품x0.6: 69%, 보관x0.5/결품x1.0: 77%, 보관x1.0/결품x0.3: 38%, 보관x1.0/결품x0.6: 55%, 보관x1.0/결품x1.0: 67%, 보관x1.5/결품x0.3: 28%, 보관x1.5/결품x0.6: 45%, 보관x1.5/결품x1.0: 58%\n", + "도입 로드맵: 단기(1~3개월) 분기별 AAR로 z·편향 재보정 / 중기(4~9개월) 단종예고 품목 최종 구매·대체품 검증 절차 정착 / 장기(10개월~) 군수 정보체계 연계와 자동 재학습\n", + "금액·시간은 가정 단가로 계산한 상대 비교값이며 예산 근거로 쓰지 않는다.\n", + "```\n", + "
\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 10. AI 기반 야전 정비 부품 수요예측 및 재고최적화 시스템 통합 패키지 완성\n", + "**소요시간**: 약 3.5시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "AI 기반 야전 정비 부품 수요 예측 및 재고 최적화 시스템 패키지를 완성한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **수요 예측, 재학습, 안전재고·위험 지수, 단종 대응, 발주 추천과 결재 대기열을 한 번에 실행하는 통합 패키지를 완성한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **자료 생성·정합성 점검, 운영 모델과 재학습 모델 비교 후 교체 판단, 다음 달 예측과 위험 지수 산출을 한 흐름으로 실행 수행하고 검증하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `docs/mnt02_operations_guide.md 및 pipeline/mnt02_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": [ + "\"\"\"수리부속 청구 이력 생성·정합성 점검, 수요 예측 모델 자동 재학습(기존·신규 모델 비교 후 교체), 안전재고·결품(단종) 위험 지수, 단종예고 품목 최종 구매량과 단종 품목 대체 검토, 예산 제약 발주 추천과 정비장교 결재 대기열을 한 번에 실행하는 통합 패키지를 완성하고, 자체 점검표·운영 가이드 목차·체계 연계 메시지 예시·브리핑 요약을 출력한다.\"\"\"\n", + "import json\n", + "import time\n", + "import numpy as np\n", + "import pandas as pd\n", + "import matplotlib.pyplot as plt\n", + "from scipy.stats import norm\n", + "from scipy.optimize import linprog\n", + "from sklearn.ensemble import HistGradientBoostingRegressor, RandomForestRegressor\n", + "from sklearn.metrics import mean_absolute_error\n", + "\n", + "# ---------------------------------------------------------------------------\n", + "# [교육용가상자료] 가상 야전 정비창 수리부속 청구/출고·장비 가동률 데이터 생성기\n", + "# 실제 부대·품목·재고·단가 정보와 무관하다. 품목 40개 x 48개월(가상 2022-01 ~ 2025-12) 월별 자료를 만든다.\n", + "# ---------------------------------------------------------------------------\n", + "FSC = {\"2520\": \"power_train\", \"2530\": \"brake_steer\", \"2910\": \"fuel\", \"2930\": \"cooling\",\n", + " \"2940\": \"filter\", \"5330\": \"gasket\", \"6140\": \"battery\", \"5961\": \"semiconductor\"}\n", + "EXERCISE = {3, 8, 10} # 가상 대규모 훈련 월(훈련 계획으로 미리 알 수 있음)\n", + "N_MONTHS = 48\n", + "\n", + "\n", + "def season(fsc, month):\n", + " \"\"\"품목군별 계절 계수: 축전지는 겨울, 냉각계통은 여름에 수요가 늘어난다.\"\"\"\n", + " if fsc == \"6140\":\n", + " return 1.6 if month in (12, 1, 2) else 0.9\n", + " if fsc == \"2930\":\n", + " return 1.5 if month in (6, 7, 8) else 0.9\n", + " return 1 + 0.1 * np.sin(2 * np.pi * month / 12)\n", + "\n", + "\n", + "def make_items(n=40, seed=2):\n", + " \"\"\"품목 마스터: NSN(가상), 품목군, 장비 유형, 기준 수요, 단가, 조달 리드타임, 공급업체 수, 생산(단종) 상태.\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " rows = []\n", + " for i in range(n):\n", + " f = str(rng.choice(list(FSC)))\n", + " status = str(rng.choice([\"생산중\", \"단종예고\", \"단종\"], p=[0.7, 0.2, 0.1]))\n", + " rows.append({\"item_id\": f\"P{i + 1:03d}\", \"nsn\": f\"{f}-00-{rng.integers(100, 1000)}-{rng.integers(1000, 10000)}\",\n", + " \"fsc\": f, \"group\": FSC[f], \"equip_type\": str(rng.choice([\"A\", \"B\", \"C\"])),\n", + " \"base\": float(np.round(rng.lognormal(2.7, 0.8), 1)),\n", + " \"price\": int(rng.lognormal(12, 1.0)) // 100 * 100,\n", + " \"lead_time\": int(rng.integers(1, 7) if status == \"생산중\" else rng.integers(4, 13)),\n", + " \"suppliers\": int(rng.integers(1, 5)) if status == \"생산중\" else 1,\n", + " \"status\": status, \"alt_available\": bool(rng.random() < (0.6 if status == \"생산중\" else 0.3))})\n", + " return pd.DataFrame(rows)\n", + "\n", + "\n", + "def make_history(items, months=N_MONTHS, seed=3):\n", + " \"\"\"월별 청구 수요(개), 긴급 불출 건수, 장비 유형별 운용시간·가동률.\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " t = np.arange(months)\n", + " moy = t % 12 + 1\n", + " ex = np.isin(moy, list(EXERCISE))\n", + " hours = {e: (100 + 20 * rng.random(months)) * np.where(ex, 1.5, 1.0) for e in \"ABC\"}\n", + " avail = {e: np.clip(0.93 - 0.12 * (hours[e] - 100) / 80 + rng.normal(0, 0.015, months), 0.6, 0.99) for e in \"ABC\"}\n", + " rows = []\n", + " for it in items.itertuples():\n", + " trend = 1 + rng.uniform(-0.1, 0.3) * t / months\n", + " s = np.array([season(it.fsc, m) for m in moy])\n", + " lam = it.base * s * trend * hours[it.equip_type] / 110\n", + " d = rng.poisson(lam)\n", + " rows.append(pd.DataFrame({\"item_id\": it.item_id, \"t\": t, \"ym\": [f\"{2022 + k // 12}-{k % 12 + 1:02d}\" for k in t],\n", + " \"month\": moy, \"exercise\": ex.astype(int), \"op_hours\": hours[it.equip_type].round(1),\n", + " \"availability\": avail[it.equip_type].round(3), \"demand\": d,\n", + " \"emergency\": rng.binomial(d, 0.05)}))\n", + " return pd.concat(rows, ignore_index=True)\n", + "\n", + "\n", + "def make_stock(items, hist, seed=4):\n", + " \"\"\"현재(가상 2026-01) 재고: 보유량과 미입고 발주량(조달 중인 물량). 단종 품목은 추가 발주가 어려워 미입고량이 적다.\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " recent = hist[hist[\"t\"] >= hist[\"t\"].max() - 5].groupby(\"item_id\")[\"demand\"].mean()\n", + " st = items[[\"item_id\", \"lead_time\", \"status\"]].copy()\n", + " st[\"on_hand\"] = [int(round(recent[i] * rng.uniform(0.5, 3.0))) for i in st[\"item_id\"]]\n", + " cover = np.where(st[\"status\"] == \"생산중\", rng.uniform(0.6, 1.3, len(st)), rng.uniform(0.0, 0.6, len(st)))\n", + " st[\"on_order\"] = [int(round(recent[i] * L * c)) for i, L, c in zip(st[\"item_id\"], st[\"lead_time\"], cover)]\n", + " return st[[\"item_id\", \"on_hand\", \"on_order\"]]\n", + "# ---------------------------------------------------------------------------\n", + "\n", + "# ---- 수요 예측 공통 함수(파생 피처 + 머신러닝 모델) ----\n", + "FEATS = [\"lag1\", \"lag2\", \"lag3\", \"lag12\", \"ma3\", \"ma6\", \"std6\", \"season_idx\", \"month_sin\", \"month_cos\",\n", + " \"exercise\", \"op_hours\", \"op_ratio\", \"item_level\", \"trend_t\"]\n", + "\n", + "\n", + "def make_features(hist):\n", + " \"\"\"품목별 과거 수요로 파생 피처를 만든다(모두 예측 시점 이전 정보만 사용).\"\"\"\n", + " df = hist.sort_values([\"item_id\", \"t\"]).reset_index(drop=True)\n", + " g = df.groupby(\"item_id\")[\"demand\"]\n", + " for k in (1, 2, 3, 12):\n", + " df[f\"lag{k}\"] = g.shift(k)\n", + " df[\"ma3\"] = g.transform(lambda s: s.shift(1).rolling(3).mean())\n", + " df[\"ma6\"] = g.transform(lambda s: s.shift(1).rolling(6).mean())\n", + " df[\"std6\"] = g.transform(lambda s: s.shift(1).rolling(6).std())\n", + " df[\"item_level\"] = g.transform(lambda s: s.shift(1).rolling(12).mean())\n", + " df[\"season_idx\"] = df[\"lag12\"] / (g.transform(lambda s: s.shift(12).rolling(12).mean()) + 1)\n", + " df[\"month_sin\"] = np.sin(2 * np.pi * df[\"month\"] / 12)\n", + " df[\"month_cos\"] = np.cos(2 * np.pi * df[\"month\"] / 12)\n", + " df[\"op_ratio\"] = df[\"op_hours\"] / df.groupby(\"item_id\")[\"op_hours\"].transform(lambda s: s.shift(1).rolling(12).mean())\n", + " df[\"trend_t\"] = df[\"t\"]\n", + " return df.dropna().reset_index(drop=True)\n", + "\n", + "\n", + "def new_model():\n", + " \"\"\"Prophet/LSTM 대신 사용하는 포아송 손실 그래디언트 부스팅(개수 자료에 적합).\"\"\"\n", + " return HistGradientBoostingRegressor(loss=\"poisson\", max_iter=300, learning_rate=0.05, max_leaf_nodes=15,\n", + " min_samples_leaf=20, random_state=0)\n", + "\n", + "\n", + "def mape(y, p, min_y=1):\n", + " y, p = np.asarray(y, float), np.asarray(p, float)\n", + " m = y >= min_y\n", + " return float(np.mean(np.abs(y[m] - p[m]) / y[m]))\n", + "\n", + "# ---- 앙상블 수요 예측·안전재고·결품(단종) 위험 지수 공통 함수 ----\n", + "STATUS_W = {\"생산중\": 0.0, \"단종예고\": 0.5, \"단종\": 1.0} # [교육용가상자료] 단종 상태 가중치\n", + "\n", + "\n", + "def fit_ens(tr):\n", + " \"\"\"포아송 그래디언트 부스팅(70%) + 랜덤포레스트(30%) 앙상블.\"\"\"\n", + " h = new_model().fit(tr[FEATS], tr[\"demand\"])\n", + " r = RandomForestRegressor(n_estimators=150, min_samples_leaf=5, random_state=0, n_jobs=1).fit(tr[FEATS], tr[\"demand\"])\n", + " return h, r\n", + "\n", + "\n", + "def pred_ens(m, X, w=0.7):\n", + " return np.clip(w * m[0].predict(X[FEATS]) + (1 - w) * m[1].predict(X[FEATS]), 0, None)\n", + "\n", + "\n", + "def next_month(hist, items, t_new=48, op_mult=1.0, ex=0):\n", + " \"\"\"다음 달(t_new) 예측용 행: 작년 같은 달 운용시간 x 배수, 훈련 여부 지정.\"\"\"\n", + " last = hist[hist[\"t\"] == t_new - 1].copy()\n", + " prev = hist[hist[\"t\"] == t_new - 12].set_index(\"item_id\")[\"op_hours\"]\n", + " m = (t_new % 12) + 1\n", + " f = last.assign(t=t_new, ym=f\"{2022 + t_new // 12}-{m:02d}\", month=m, demand=0, exercise=ex,\n", + " op_hours=last[\"item_id\"].map(prev).to_numpy() * op_mult)\n", + " X = make_features(pd.concat([hist, f], ignore_index=True))\n", + " return X[X[\"t\"] == t_new].set_index(\"item_id\").loc[items[\"item_id\"]].reset_index()\n", + "\n", + "\n", + "def stock_policy(items, mu, sigma, z=1.65, review=1):\n", + " \"\"\"안전재고 = z x 월 예측오차 표준편차 x sqrt(리드타임+검토주기), 재주문점 = 월 수요 x (리드타임+검토주기) + 안전재고.\"\"\"\n", + " L = items[\"lead_time\"].to_numpy() + review\n", + " ss = z * sigma * np.sqrt(L)\n", + " return pd.DataFrame({\"item_id\": items[\"item_id\"], \"mu\": mu, \"sigma\": sigma, \"L\": L,\n", + " \"ss\": np.ceil(ss), \"rop\": np.ceil(mu * L + ss)})\n", + "\n", + "\n", + "def risk_index(items, pol, stock):\n", + " \"\"\"결품 위험 지수(0~100): 리드타임 동안 수요가 가용 재고를 넘을 확률(정규 근사)을 기본으로,\n", + " 단종 상태·단일 공급업체·대체품 부재에 가중을 준다(가상 가중치).\"\"\"\n", + " st = stock.set_index(\"item_id\").loc[items[\"item_id\"]]\n", + " avail = (st[\"on_hand\"] + st[\"on_order\"]).to_numpy()\n", + " p = 1 - norm.cdf((avail - pol[\"mu\"].to_numpy() * pol[\"L\"].to_numpy()) / (pol[\"sigma\"].to_numpy() * np.sqrt(pol[\"L\"].to_numpy()) + 1e-6))\n", + " sw = items[\"status\"].map(STATUS_W).to_numpy()\n", + " sup = (items[\"suppliers\"].to_numpy() == 1).astype(float)\n", + " alt = (~items[\"alt_available\"].to_numpy()).astype(float)\n", + " score = 100 * np.clip(0.55 * p + 0.25 * sw + 0.10 * sup + 0.10 * alt, 0, 1)\n", + " return pd.DataFrame({\"item_id\": items[\"item_id\"], \"p_short\": p, \"status_w\": sw, \"single_sup\": sup, \"no_alt\": alt,\n", + " \"avail\": avail, \"risk\": score.round(1)})\n", + "\n", + "LTB_MONTHS = 24 # [교육용가상자료] 단종예고 품목 최종 구매(LTB) 대상 기간(개월)\n", + "t_all = time.perf_counter()\n", + "checks = []\n", + "\n", + "# 1) 자료 수집·정합성\n", + "items = make_items()\n", + "hist = make_history(items)\n", + "stock = make_stock(items, hist)\n", + "ok = (hist[\"demand\"] >= 0).all() and items[\"nsn\"].str.match(r\"^\\d{4}-\\d{2}-\\d{3}-\\d{4}$\").all() and not hist.duplicated([\"item_id\", \"t\"]).any()\n", + "checks.append((\"원천 자료 정합성(NSN 형식·음수·중복)\", ok))\n", + "df = make_features(hist)\n", + "print(f\"[1] 품목 {len(items)}개 x {N_MONTHS}개월 청구 이력, 학습 행 {len(df)}개, 정합성 {'통과' if ok else '실패'}\")\n", + "\n", + "# 2) 재학습 파이프라인: 운영 모델 v1(t<36) vs 재학습 v2(t<42), 최근 6개월(t=42~47) 검증 후 교체 판단\n", + "va = df[df[\"t\"] >= 42]\n", + "big = va[\"item_id\"].isin(items.loc[items[\"base\"] >= 10, \"item_id\"]).to_numpy()\n", + "v1, v2 = fit_ens(df[df[\"t\"] < 36]), fit_ens(df[df[\"t\"] < 42])\n", + "p1, p2 = pred_ens(v1, va), pred_ens(v2, va)\n", + "e1, e2 = mape(va[\"demand\"][big], p1[big]), mape(va[\"demand\"][big], p2[big])\n", + "eb = mape(va[\"demand\"][big], va[\"ma3\"][big])\n", + "use = \"v2\" if e2 <= e1 else \"v1\"\n", + "print(f\"[2] 검증 MAPE(수요 많은 품목): MA3 {eb:.1%}, 운영 v1 {e1:.1%}, 재학습 v2 {e2:.1%} -> {use} {'교체' if use == 'v2' else '유지'}\")\n", + "checks += [(\"자동 재학습 실행·비교\", True), (\"운영 모델 MAPE 25% 이하\", min(e1, e2) <= 0.25)]\n", + "\n", + "# 3) 다음 달 예측(전체 자료로 최종 학습) -> 안전재고·위험 지수\n", + "final = fit_ens(df)\n", + "mu = pred_ens(final, next_month(hist, items))\n", + "sg = np.maximum(0.25 * mu, np.sqrt(np.maximum(mu, 1)))\n", + "pol = stock_policy(items, mu, sg)\n", + "rk = risk_index(items, pol, stock)\n", + "alert = rk[\"risk\"] >= 60\n", + "print(f\"[3] 다음 달 예측 수요 {mu.sum():.0f}개, 결품 위험 경보(60 이상) {int(alert.sum())}개 품목, 안전재고 합 {pol['ss'].sum():.0f}개\")\n", + "\n", + "# 4) 단종 대응: 단종예고 -> 최종 구매(LTB) 수량, 단종 -> 대체품·재생정비 검토\n", + "ip = (stock[\"on_hand\"] + stock[\"on_order\"]).to_numpy()\n", + "eol = items.assign(mu=mu, ip=ip, risk=rk[\"risk\"].to_numpy())\n", + "ltb = eol[eol[\"status\"] == \"단종예고\"].assign(ltb=lambda d: np.maximum(0, np.ceil(d[\"mu\"] * LTB_MONTHS - d[\"ip\"])))\n", + "dis = eol[eol[\"status\"] == \"단종\"]\n", + "print(f\"[4] 단종예고 {len(ltb)}개 품목 최종 구매 추천 합 {ltb['ltb'].sum():.0f}개(향후 {LTB_MONTHS}개월분), 단종 {len(dis)}개 품목 중 대체품 보유 {int(dis['alt_available'].sum())}개\")\n", + "for _, r in ltb.sort_values(\"risk\", ascending=False).head(3).iterrows():\n", + " print(f\" {r['item_id']}({r['group']}) 월 {r['mu']:.1f}개, 가용 {r['ip']:.0f}개 -> 최종 구매 {r['ltb']:.0f}개\")\n", + "\n", + "# 5) 예산 제약 발주 추천(LP, 생산중 품목. 단종예고는 최종 구매로 별도 처리) -> 결재 대기열\n", + "gap = np.where(items[\"status\"] == \"생산중\", np.maximum(0, np.ceil(pol[\"rop\"].to_numpy() - ip)), 0)\n", + "price = items[\"price\"].to_numpy().astype(float)\n", + "idx = np.where(gap > 0)[0]\n", + "budget = 0.6 * float((gap * price).sum())\n", + "lp = linprog(c=-(rk[\"risk\"].to_numpy()[idx] + 10) / gap[idx], A_ub=[price[idx]], b_ub=[budget], bounds=[(0, g) for g in gap[idx]], method=\"highs\")\n", + "q = np.zeros(len(items)); q[idx] = np.floor(lp.x)\n", + "queue = [{\"id\": f\"Q{k + 1:02d}\", \"item\": items.loc[i, \"item_id\"], \"qty\": int(q[i]), \"risk\": float(rk.loc[i, \"risk\"]), \"status\": \"결재대기\"} for k, i in enumerate(np.where(q > 0)[0])]\n", + "queue += [{\"id\": f\"Q{len(queue) + k + 1:02d}\", \"item\": r[\"item_id\"], \"qty\": int(r[\"ltb\"]), \"risk\": float(r[\"risk\"]), \"status\": \"결재대기\", \"type\": \"LTB\"} for k, (_, r) in enumerate(ltb[ltb[\"ltb\"] > 0].iterrows())]\n", + "auto = [x for x in queue if x[\"status\"] != \"결재대기\"]\n", + "print(f\"[5] 발주 추천 {int((q > 0).sum())}건(예산 {budget / 1e6:.1f}백만 중 {np.dot(q, price) / 1e6:.1f}백만) + 최종 구매 {int((ltb['ltb'] > 0).sum())}건 -> 정비장교 결재 대기 {len(queue)}건, 자동 확정 {len(auto)}건\")\n", + "checks.append((\"자동 확정 0건(HITL)\", not auto))\n", + "\n", + "# 6) 운영 가이드·연계 메시지\n", + "GUIDE = {\"1. 목적·범위\": \"야전 정비 부품 수요 예측·재고 최적화·단종 대응\", \"2. 데이터 규격\": f\"품목 마스터 {items.shape[1]}개 필드, NSN 가상 형식\",\n", + " \"3. 전처리\": \"NSN 정규화, 결측 월 계절성 보간, 긴급 불출 보존\", \"4. 예측 모델\": f\"포아송 GBM+RF 앙상블, MAPE {min(e1, e2):.1%}\",\n", + " \"5. 안전재고·위험 지수\": \"z=1.65, 리드타임 결품 확률+단종·공급업체·대체품\", \"6. 단종 대응\": f\"LTB {LTB_MONTHS}개월분, 대체품 호환성 검증\",\n", + " \"7. 결재선·감사\": \"자동 확정 금지, 승인·조정·반려·보류 사유 기록\", \"8. 재학습\": \"분기별 재학습, 검증 MAPE가 낮을 때만 교체\",\n", + " \"9. 폭증 대응\": \"작전 템포 계수 보정, 경계 구간 '주의' 표시\", \"10. 연계 인터페이스\": \"ORDER_REQ 가상 메시지 규격\"}\n", + "done = np.mean([bool(v) for v in GUIDE.values()])\n", + "checks.append((\"운영 가이드 10개 장 완비\", done == 1))\n", + "print(\"[6] 운영 가이드 목차:\", \" / \".join(GUIDE))\n", + "top = max(queue, key=lambda x: x[\"risk\"]) if queue else {}\n", + "msg = {\"msg_type\": \"ORDER_REQ\", \"version\": \"1.0\", \"item_id\": top.get(\"item\"), \"nsn\": items.set_index(\"item_id\").loc[top.get(\"item\"), \"nsn\"] if top else None,\n", + " \"qty\": top.get(\"qty\"), \"risk_index\": top.get(\"risk\"), \"model_ver\": use, \"approval\": \"PENDING\", \"source\": \"교육용가상자료\"}\n", + "print(\"[7] 체계 연계 메시지 예시(가상 규격):\", json.dumps(msg, ensure_ascii=False))\n", + "elapsed = time.perf_counter() - t_all\n", + "checks.append((\"통합 실행 60초 이내\", elapsed < 60))\n", + "\n", + "print(\"\\n[자체 점검표]\")\n", + "for name, okk in checks:\n", + " print(f\" [{'PASS' if okk else 'FAIL'}] {name}\")\n", + "print(f\"점검 {sum(o for _, o in checks)}/{len(checks)} 통과, 운영 가이드 완비율 {done:.0%}, 전체 실행 {elapsed:.1f}s\")\n", + "print(\"\\n[브리핑 요약]\")\n", + "print(f\"- 부품 수요 예측 MAPE {min(e1, e2):.1%}(이동평균 {eb:.1%})이며, 재학습 모델은 검증 후 교체한다({use} 사용).\")\n", + "print(f\"- 결품 위험 경보 {int(alert.sum())}개 품목 중 단종·단종예고가 {int((alert & (items['status'] != '생산중')).sum())}개로, 단종 대응이 재고 위험의 핵심이다.\")\n", + "print(f\"- 단종예고 {len(ltb)}개 품목의 최종 구매 {ltb['ltb'].sum():.0f}개와 발주 {int((q > 0).sum())}건을 결재 대기열에 올렸다.\")\n", + "print(\"- 모든 발주·최종 구매·대체품 전환은 정비장교 결재 후 집행하며, 수치는 교육용 가상 자료 기준이다.\")\n", + "\n", + "fig, ax = plt.subplots(1, 2, figsize=(12, 4))\n", + "ax[0].bar([\"MA3\", \"model v1\", \"model v2\"], [eb, e1, e2], color=[\"gray\", \"tab:blue\", \"tab:green\"]); ax[0].axhline(0.25, c=\"r\", ls=\"--\")\n", + "ax[0].set_title(\"Validation MAPE (t=42-47)\")\n", + "col = items[\"status\"].map({\"생산중\": \"tab:green\", \"단종예고\": \"tab:orange\", \"단종\": \"tab:red\"})\n", + "ax[1].scatter(items[\"lead_time\"], rk[\"risk\"], c=col, s=20 + mu / 2); ax[1].axhline(60, c=\"r\", ls=\"--\")\n", + "ax[1].set_xlabel(\"lead time (months)\"); ax[1].set_ylabel(\"risk index\"); ax[1].set_title(\"Risk vs lead time (size=demand, red=discontinued)\")\n", + "plt.tight_layout(); plt.show()\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 10 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + "[1] 품목 40개 x 48개월 청구 이력, 학습 행 1000개, 정합성 통과\n", + "[2] 검증 MAPE(수요 많은 품목): MA3 32.7%, 운영 v1 20.0%, 재학습 v2 20.9% -> v1 유지\n", + "[3] 다음 달 예측 수요 948개, 결품 위험 경보(60 이상) 13개 품목, 안전재고 합 1108개\n", + "[4] 단종예고 8개 품목 최종 구매 추천 합 2779개(향후 24개월분), 단종 3개 품목 중 대체품 보유 2개\n", + " P004(filter) 월 8.1개, 가용 12개 -> 최종 구매 183개\n", + " P019(filter) 월 11.5개, 가용 59개 -> 최종 구매 219개\n", + " P025(filter) 월 14.0개, 가용 89개 -> 최종 구매 247개\n", + "[5] 발주 추천 10건(예산 31.2백만 중 30.9백만) + 최종 구매 8건 -> 정비장교 결재 대기 18건, 자동 확정 0건\n", + "[6] 운영 가이드 목차: 1. 목적·범위 / 2. 데이터 규격 / 3. 전처리 / 4. 예측 모델 / 5. 안전재고·위험 지수 / 6. 단종 대응 / 7. 결재선·감사 / 8. 재학습 / 9. 폭증 대응 / 10. 연계 인터페이스\n", + "[7] 체계 연계 메시지 예시(가상 규격): {\"msg_type\": \"ORDER_REQ\", \"version\": \"1.0\", \"item_id\": \"P004\", \"nsn\": \"2940-00-559-8893\", \"qty\": 183, \"risk_index\": 87.5, \"model_ver\": \"v1\", \"approval\": \"PENDING\", \"source\": \"교육용가상자료\"}\n", + "\n", + "[자체 점검표]\n", + " [PASS] 원천 자료 정합성(NSN 형식·음수·중복)\n", + " [PASS] 자동 재학습 실행·비교\n", + " [PASS] 운영 모델 MAPE 25% 이하\n", + " [PASS] 자동 확정 0건(HITL)\n", + " [PASS] 운영 가이드 10개 장 완비\n", + " [PASS] 통합 실행 60초 이내\n", + "점검 6/6 통과, 운영 가이드 완비율 100%, 전체 실행 3.5s\n", + "\n", + "[브리핑 요약]\n", + "- 부품 수요 예측 MAPE 20.0%(이동평균 32.7%)이며, 재학습 모델은 검증 후 교체한다(v1 사용).\n", + "- 결품 위험 경보 13개 품목 중 단종·단종예고가 11개로, 단종 대응이 재고 위험의 핵심이다.\n", + "- 단종예고 8개 품목의 최종 구매 2779개와 발주 10건을 결재 대기열에 올렸다.\n", + "- 모든 발주·최종 구매·대체품 전환은 정비장교 결재 후 집행하며, 수치는 교육용 가상 자료 기준이다.\n", + "```\n", + "
\n" + ] + } + ], + "metadata": { + "colab": { + "provenance": [] + }, + "kernelspec": { + "display_name": "Python 3", + "name": "python3" + }, + "language_info": { + "name": "python" + } + }, + "nbformat": 4, + "nbformat_minor": 0 +} \ No newline at end of file diff --git a/uploads/MNT02/MNT02_student.ipynb b/uploads/MNT02/MNT02_student.ipynb new file mode 100644 index 0000000..77443cd --- /dev/null +++ b/uploads/MNT02/MNT02_student.ipynb @@ -0,0 +1,3046 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# 🎯 [MNT02] 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/MNT02/MNT02_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", + "장비 수리부속은 생산 중단(단종)이나 단종 예고가 나오면 조달 리드타임이 길어지고 공급업체가 줄어, 수요를 미리 알지 못하면 결품으로 장비가 정비 대기 상태에 머물게 된다. 따라서 단종 위험에 대응하려면 먼저 품목별 수요를 정확히 예측하고, 그 예측에 리드타임·공급업체 수·대체품 유무를 결합해 결품 위험을 측정해야 한다. 본 프로젝트는 교육용 가상 야전 정비창의 수리부속 청구/출고 이력과 장비 가동률 자료를 코드로 만들어, 데이터 스키마 정의, NSN 정제와 계절성 분리, 이동평균·지수평활 Baseline, 머신러닝 수요 예측, 안전재고와 결품(단종) 위험 지수, 긴급 조달 대시보드와 설명가능성, 정비장교 발주 승인 워크플로우, 수요 300% 폭증 부하 시험, 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. 야전 정비창 수리부속 청구/출고 이력 및 장비 가동률 데이터셋 스키마 정의\n", + "**소요시간**: 약 1.5시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "야전 정비창 수리부속 청구/출고 이력 및 장비 가동률 데이터 스키마를 정의한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **야전 정비창 수리부속 청구/출고 이력, 품목·공급망(단종 상태 포함), 장비 가동률 자료의 스키마와 업무 프로세스 맵, 데이터 딕셔너리를 정의한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **정비 소요 발생부터 청구, 재고 확인·불출, 조달 요청, 단종 위험 검토, 가동률 집계까지 업무 흐름을 단계별로 정리하고 규격화하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `docs/mnt02_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": [ + "\"\"\"야전 정비창 수리부속 청구/출고 이력과 장비 가동률 데이터의 업무 프로세스 맵, 데이터 스키마(피처 20개 이상)와 데이터 딕셔너리를 정의하고 가상 청구 기록을 검증한다.\"\"\"\n", + "import json\n", + "import re\n", + "import numpy as np\n", + "import pandas as pd\n", + "\n", + "# 1) 업무 프로세스 맵: 단계별로 어떤 데이터가 생기는지 연결한다\n", + "PROCESS = [(\"1. 정비 소요 발생\", \"장비 고장·계획정비\", \"equip_id, equip_type, maint_type\"),\n", + " (\"2. 수리부속 청구\", \"정비반 -> 정비창\", \"req_id, req_date, nsn, qty_req, priority\"),\n", + " (\"3. 재고 확인·불출\", \"정비창 재고 담당\", \"qty_issued, emergency_flag\"),\n", + " (\"4. 조달 요청\", \"재고 부족 시 조달부서\", \"lead_time_m, supplier_count, unit_price\"),\n", + " (\"5. 단종 위험 검토\", \"품목 관리 담당\", \"prod_status, alt_available\"),\n", + " (\"6. 정비 완료·가동률 집계\", \"월 단위 집계\", \"op_hours, availability, downtime_h\")]\n", + "print(\"분석 목표: 월별 품목 수요를 예측해 결품·과잉재고를 줄이고, 단종예고·단종 품목의 최종 구매 수량 판단을 지원한다.\")\n", + "print(\"[업무 프로세스 맵] 정비 소요 -> 청구 -> 불출 -> 조달 -> 단종 검토 -> 가동률\")\n", + "for step, who, fields in PROCESS:\n", + " print(f\" {step:18s} | {who:18s} | {fields}\")\n", + "\n", + "# 2) 스키마(데이터 딕셔너리): 그룹, 자료형, 규칙, 설명\n", + "NSN_RE = r\"^\\d{4}-\\d{2}-\\d{3}-\\d{4}$\" # NSN = FSC(4) + 국가코드(2) + 일련번호(7), NIIN = 뒤 9자리\n", + "SCHEMA = {\n", + " \"청구/불출\": {\"req_id\": (str, {\"pattern\": r\"^R\\d{6}$\"}, \"청구 번호\"),\n", + " \"req_date\": (str, {\"pattern\": r\"^\\d{4}-\\d{2}-\\d{2}$\"}, \"청구 일자\"),\n", + " \"unit_code\": (str, {\"pattern\": r\"^U-\\d{2}$\"}, \"청구 부대(가상 코드)\"),\n", + " \"equip_id\": (str, {\"pattern\": r\"^[ABC]-\\d{3}$\"}, \"장비 번호(가상)\"),\n", + " \"equip_type\": (str, {\"enum\": [\"A\", \"B\", \"C\"]}, \"장비 유형\"),\n", + " \"maint_type\": (str, {\"enum\": [\"계획\", \"비계획\"]}, \"정비 구분\"),\n", + " \"nsn\": (str, {\"pattern\": NSN_RE}, \"국가재고번호(가상)\"),\n", + " \"fsc\": (str, {\"pattern\": r\"^\\d{4}$\"}, \"군수품 분류(NSN 앞 4자리)\"),\n", + " \"niin\": (str, {\"pattern\": r\"^\\d{9}$\"}, \"품목 식별번호(NSN 뒤 9자리)\"),\n", + " \"qty_req\": (int, {\"min\": 1, \"max\": 500}, \"청구 수량\"),\n", + " \"qty_issued\": (int, {\"min\": 0, \"max\": 500}, \"불출 수량\"),\n", + " \"unit_of_issue\": (str, {\"enum\": [\"EA\", \"SE\", \"BX\"]}, \"불출 단위\"),\n", + " \"priority\": (str, {\"enum\": [\"긴급\", \"일반\"]}, \"청구 우선순위\"),\n", + " \"emergency_flag\": (bool, {}, \"긴급 불출 여부(이상치로 지우지 않음)\")},\n", + " \"품목/공급망\": {\"unit_price\": (int, {\"min\": 0}, \"단가(원, 가상)\"),\n", + " \"lead_time_m\": (int, {\"min\": 0, \"max\": 36}, \"조달 리드타임(월)\"),\n", + " \"supplier_count\": (int, {\"min\": 0, \"max\": 20}, \"공급 가능 업체 수\"),\n", + " \"prod_status\": (str, {\"enum\": [\"생산중\", \"단종예고\", \"단종\"]}, \"생산(단종) 상태\"),\n", + " \"alt_available\": (bool, {}, \"대체품 존재 여부\")},\n", + " \"가동률(월)\": {\"ym\": (str, {\"pattern\": r\"^\\d{4}-\\d{2}$\"}, \"집계 월\"),\n", + " \"op_hours\": (float, {\"min\": 0, \"max\": 744}, \"장비 운용시간\"),\n", + " \"availability\": (float, {\"min\": 0, \"max\": 1}, \"장비 가동률\"),\n", + " \"downtime_h\": (float, {\"min\": 0, \"max\": 744}, \"정비 대기·수리 시간\"),\n", + " \"exercise_flag\": (int, {\"enum\": [0, 1]}, \"대규모 훈련 월 여부\")},\n", + "}\n", + "\n", + "\n", + "def check(name, v, typ, rule):\n", + " # [TODO : 학생 실습 1] 야전 정비창 수리부속 청구/출고 이력 및 장비 가동률 데이터셋 스키마 정의 관련 핵심 로직을 구현하세요.\n", + " # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n", + " # <<< 여기에 코드를 작성하세요 >>>\n", + " pass # 작성 후 pass를 제거하세요.\n", + "def validate(rec):\n", + " # [TODO : 학생 실습 1] 야전 정비창 수리부속 청구/출고 이력 및 장비 가동률 데이터셋 스키마 정의 관련 핵심 로직을 구현하세요.\n", + " # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n", + " # <<< 여기에 코드를 작성하세요 >>>\n", + " pass # 작성 후 pass를 제거하세요.\n", + "rng = np.random.default_rng(1)\n", + "records = []\n", + "for i in range(20):\n", + " fsc = str(rng.choice([\"2520\", \"2530\", \"2940\", \"6140\"]))\n", + " niin = f\"00{rng.integers(1000000, 9999999)}\"\n", + " q = int(rng.integers(1, 20))\n", + " et = str(rng.choice([\"A\", \"B\", \"C\"]))\n", + " records.append({\"req_id\": f\"R{250001 + i}\", \"req_date\": f\"2025-{rng.integers(1, 13):02d}-{rng.integers(1, 29):02d}\",\n", + " \"unit_code\": f\"U-{rng.integers(1, 6):02d}\", \"equip_id\": f\"{et}-{rng.integers(1, 200):03d}\", \"equip_type\": et,\n", + " \"maint_type\": str(rng.choice([\"계획\", \"비계획\"])), \"nsn\": f\"{fsc}-{niin[:2]}-{niin[2:5]}-{niin[5:]}\",\n", + " \"fsc\": fsc, \"niin\": niin, \"qty_req\": q, \"qty_issued\": int(rng.integers(0, q + 1)),\n", + " \"unit_of_issue\": \"EA\", \"priority\": str(rng.choice([\"긴급\", \"일반\"], p=[0.1, 0.9])),\n", + " \"emergency_flag\": bool(rng.random() < 0.05), \"unit_price\": int(rng.integers(10, 900)) * 1000,\n", + " \"lead_time_m\": int(rng.integers(1, 13)), \"supplier_count\": int(rng.integers(1, 5)),\n", + " \"prod_status\": str(rng.choice([\"생산중\", \"단종예고\", \"단종\"], p=[0.7, 0.2, 0.1])),\n", + " \"alt_available\": bool(rng.random() < 0.5), \"ym\": \"2025-12\", \"op_hours\": float(round(rng.uniform(80, 180), 1)),\n", + " \"availability\": float(round(rng.uniform(0.75, 0.97), 3)), \"downtime_h\": float(round(rng.uniform(5, 60), 1)),\n", + " \"exercise_flag\": int(rng.integers(0, 2))})\n", + "\n", + "ok = sum(1 for r in records if not validate(r))\n", + "n_feat = sum(len(g) for g in SCHEMA.values())\n", + "print(f\"\\n정의한 피처(필드) {n_feat}개 (목표 20개 이상: {'달성' if n_feat >= 20 else '미달'})\")\n", + "print(f\"정상 샘플 {len(records)}건 중 검증 통과 {ok}건, 오류 {len(records) - ok}건\")\n", + "\n", + "# 4) 오류 주입 시험\n", + "bad = json.loads(json.dumps(records[0], ensure_ascii=False))\n", + "bad[\"nsn\"] = \"2520-001234567\" # NSN 형식 오류\n", + "bad[\"qty_req\"] = -3 # 음수 수량\n", + "bad[\"prod_status\"] = \"미상\" # 허용값 아님\n", + "del bad[\"lead_time_m\"] # 필수 필드 누락\n", + "bad[\"availability\"] = 1.4 # 가동률 범위 초과\n", + "print(\"[오류 주입 결과]\")\n", + "for e in validate(bad):\n", + " print(\" -\", e)\n", + "bad2 = json.loads(json.dumps(records[1], ensure_ascii=False)); bad2[\"fsc\"] = \"9999\"\n", + "print(\" - (논리 규칙)\", validate(bad2))\n", + "\n", + "# 5) 데이터 딕셔너리 출력\n", + "rows = [(g, k, t.__name__, json.dumps(r, ensure_ascii=False) if r else \"-\", d) for g, grp in SCHEMA.items() for k, (t, r, d) in grp.items()]\n", + "dic = pd.DataFrame(rows, columns=[\"그룹\", \"필드\", \"자료형\", \"규칙\", \"설명\"])\n", + "pd.set_option(\"display.width\", 200); pd.set_option(\"display.max_colwidth\", 34)\n", + "print(\"[데이터 딕셔너리]\")\n", + "print(dic.to_string(index=False))\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 01 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + "분석 목표: 월별 품목 수요를 예측해 결품·과잉재고를 줄이고, 단종예고·단종 품목의 최종 구매 수량 판단을 지원한다.\n", + "[업무 프로세스 맵] 정비 소요 -> 청구 -> 불출 -> 조달 -> 단종 검토 -> 가동률\n", + " 1. 정비 소요 발생 | 장비 고장·계획정비 | equip_id, equip_type, maint_type\n", + " 2. 수리부속 청구 | 정비반 -> 정비창 | req_id, req_date, nsn, qty_req, priority\n", + " 3. 재고 확인·불출 | 정비창 재고 담당 | qty_issued, emergency_flag\n", + " 4. 조달 요청 | 재고 부족 시 조달부서 | lead_time_m, supplier_count, unit_price\n", + " 5. 단종 위험 검토 | 품목 관리 담당 | prod_status, alt_available\n", + " 6. 정비 완료·가동률 집계 | 월 단위 집계 | op_hours, availability, downtime_h\n", + "\n", + "정의한 피처(필드) 24개 (목표 20개 이상: 달성)\n", + "정상 샘플 20건 중 검증 통과 20건, 오류 0건\n", + "[오류 주입 결과]\n", + " - nsn: 형식 불일치 '2520-001234567'\n", + " - qty_req: 최소값 1 미만\n", + " - lead_time_m: 필수 필드 누락\n", + " - prod_status: 허용값 아님 '미상'\n", + " - availability: 최대값 1 초과\n", + " - (논리 규칙) ['nsn/fsc/niin 불일치']\n", + "[데이터 딕셔너리]\n", + " 그룹 필드 자료형 규칙 설명\n", + " 청구/불출 req_id str {\"pattern\": \"^R\\\\d{6}$\"} 청구 번호\n", + " 청구/불출 req_date str {\"pattern\": \"^\\\\d{4}-\\\\d{2}-\\\\d{2}$\"} 청구 일자\n", + " 청구/불출 unit_code str {\"pattern\": \"^U-\\\\d{2}$\"} 청구 부대(가상 코드)\n", + " 청구/불출 equip_id str {\"pattern\": \"^[ABC]-\\\\d{3}$\"} 장비 번호(가상)\n", + " 청구/불출 equip_type str {\"enum\": [\"A\", \"B\", \"C\"]} 장비 유형\n", + " 청구/불출 maint_type str {\"enum\": [\"계획\", \"비계획\"]} 정비 구분\n", + " 청구/불출 nsn str {\"pattern\": \"^\\\\d{4}-\\\\d{2}-\\\\d{3}-\\\\d{4}$\"} 국가재고번호(가상)\n", + " 청구/불출 fsc str {\"pattern\": \"^\\\\d{4}$\"} 군수품 분류(NSN 앞 4자리)\n", + " 청구/불출 niin str {\"pattern\": \"^\\\\d{9}$\"} 품목 식별번호(NSN 뒤 9자리)\n", + " 청구/불출 qty_req int {\"min\": 1, \"max\": 500} 청구 수량\n", + " 청구/불출 qty_issued int {\"min\": 0, \"max\": 500} 불출 수량\n", + " 청구/불출 unit_of_issue str {\"enum\": [\"EA\", \"SE\", \"BX\"]} 불출 단위\n", + " 청구/불출 priority str {\"enum\": [\"긴급\", \"일반\"]} 청구 우선순위\n", + " 청구/불출 emergency_flag bool - 긴급 불출 여부(이상치로 지우지 않음)\n", + "품목/공급망 unit_price int {\"min\": 0} 단가(원, 가상)\n", + "품목/공급망 lead_time_m int {\"min\": 0, \"max\": 36} 조달 리드타임(월)\n", + "품목/공급망 supplier_count int {\"min\": 0, \"max\": 20} 공급 가능 업체 수\n", + "품목/공급망 prod_status str {\"enum\": [\"생산중\", \"단종예고\", \"단종\"]} 생산(단종) 상태\n", + "품목/공급망 alt_available bool - 대체품 존재 여부\n", + "가동률(월) ym str {\"pattern\": \"^\\\\d{4}-\\\\d{2}$\"} 집계 월\n", + "... (이하 생략)\n", + "```\n", + "
\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 02. 결측치 보정, 계절성 수요 분리 및 수리부속 품목코드(NSN) 정제 전처리 모듈 구현\n", + "**소요시간**: 약 2.0시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "결측치 보정, 계절성 수요 분리 및 부품코드(NSN) 정제 전처리 모듈을 구현한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **청구 기록의 결측치 보정, 계절성 수요 분리, 품목코드(NSN) 정제를 수행하는 전처리 모듈을 구현한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **중복 청구 제거, NSN 형식 정규화와 미등록 NSN 격리, 날짜·단위 통일, 음수·과다 수량 보정 등 정제 규칙 10종 이상을 적용하고 긴급 불출은 이상치로 지우지 않는다 과정을 진행하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `src/etl/mnt02_data_cleansing.py 및 정제 품질 보고서` 형태의 결과물을 완성한다.\n", + "\n", + "### 🔍 평가 및 검증 기준 (PASS 조건)\n", + "#### 미션 판정\n", + "\n", + "**평가 구분:** 필수\n", + "\n", + "**핵심 평가영역:** 군수 특수 예외(비계획 정비, 긴급 불출 등) 데이터 보존의 타당성\n", + "\n", + "#### 미션 PASS 조건\n", + "\n", + "- 정량 기준: 데이터 정제 규칙 10종 적용, 데이터 정합성 검증 통과율 100%\n", + "\n", + "\n", + "- 진행 기준: ETL 파이프라인 구현 및 정제된 마스터 데이터셋 구축\n", + "\n", + "\n", + "- 검증 방법: 전후 데이터 통계량(기술통계) 비교 및 무결성 검증\n", + "\n", + "\n", + "#### 채점자 판정 메모\n", + "\n", + "> 단순 코드 실행 여부뿐만 아니라 출력된 수치와 근거 자료가 군 실무 요구사항에 부합하는지 면밀히 확인한다.\n", + "\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "\"\"\"수리부속 청구/출고 원시 기록에 정제 규칙 10종(NSN 형식 정규화, 중복 제거, 날짜 통일, 단위 환산, 결측 월 계절성 보간 등)을 적용하고, 월별 수요에서 계절성 성분을 분리한 뒤 정합성 검증 통과율을 확인한다.\"\"\"\n", + "import re\n", + "import numpy as np\n", + "import pandas as pd\n", + "import matplotlib.pyplot as plt\n", + "\n", + "# ---------------------------------------------------------------------------\n", + "# [교육용가상자료] 가상 야전 정비창 수리부속 청구/출고·장비 가동률 데이터 생성기\n", + "# 실제 부대·품목·재고·단가 정보와 무관하다. 품목 40개 x 48개월(가상 2022-01 ~ 2025-12) 월별 자료를 만든다.\n", + "# ---------------------------------------------------------------------------\n", + "FSC = {\"2520\": \"power_train\", \"2530\": \"brake_steer\", \"2910\": \"fuel\", \"2930\": \"cooling\",\n", + " \"2940\": \"filter\", \"5330\": \"gasket\", \"6140\": \"battery\", \"5961\": \"semiconductor\"}\n", + "EXERCISE = {3, 8, 10} # 가상 대규모 훈련 월(훈련 계획으로 미리 알 수 있음)\n", + "N_MONTHS = 48\n", + "\n", + "\n", + "def season(fsc, month):\n", + " \"\"\"품목군별 계절 계수: 축전지는 겨울, 냉각계통은 여름에 수요가 늘어난다.\"\"\"\n", + " # [TODO : 학생 실습 2] 결측치 보정, 계절성 수요 분리 및 수리부속 품목코드(NSN) 정제 전처리 모듈 구현 관련 핵심 로직을 구현하세요.\n", + " # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n", + " # <<< 여기에 코드를 작성하세요 >>>\n", + " pass # 작성 후 pass를 제거하세요.\n", + "def make_items(n=40, seed=2):\n", + " \"\"\"품목 마스터: NSN(가상), 품목군, 장비 유형, 기준 수요, 단가, 조달 리드타임, 공급업체 수, 생산(단종) 상태.\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " rows = []\n", + " for i in range(n):\n", + " f = str(rng.choice(list(FSC)))\n", + " status = str(rng.choice([\"생산중\", \"단종예고\", \"단종\"], p=[0.7, 0.2, 0.1]))\n", + " rows.append({\"item_id\": f\"P{i + 1:03d}\", \"nsn\": f\"{f}-00-{rng.integers(100, 1000)}-{rng.integers(1000, 10000)}\",\n", + " \"fsc\": f, \"group\": FSC[f], \"equip_type\": str(rng.choice([\"A\", \"B\", \"C\"])),\n", + " \"base\": float(np.round(rng.lognormal(2.7, 0.8), 1)),\n", + " \"price\": int(rng.lognormal(12, 1.0)) // 100 * 100,\n", + " \"lead_time\": int(rng.integers(1, 7) if status == \"생산중\" else rng.integers(4, 13)),\n", + " \"suppliers\": int(rng.integers(1, 5)) if status == \"생산중\" else 1,\n", + " \"status\": status, \"alt_available\": bool(rng.random() < (0.6 if status == \"생산중\" else 0.3))})\n", + " return pd.DataFrame(rows)\n", + "\n", + "\n", + "def make_history(items, months=N_MONTHS, seed=3):\n", + " \"\"\"월별 청구 수요(개), 긴급 불출 건수, 장비 유형별 운용시간·가동률.\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " t = np.arange(months)\n", + " moy = t % 12 + 1\n", + " ex = np.isin(moy, list(EXERCISE))\n", + " hours = {e: (100 + 20 * rng.random(months)) * np.where(ex, 1.5, 1.0) for e in \"ABC\"}\n", + " avail = {e: np.clip(0.93 - 0.12 * (hours[e] - 100) / 80 + rng.normal(0, 0.015, months), 0.6, 0.99) for e in \"ABC\"}\n", + " rows = []\n", + " for it in items.itertuples():\n", + " trend = 1 + rng.uniform(-0.1, 0.3) * t / months\n", + " s = np.array([season(it.fsc, m) for m in moy])\n", + " lam = it.base * s * trend * hours[it.equip_type] / 110\n", + " d = rng.poisson(lam)\n", + " rows.append(pd.DataFrame({\"item_id\": it.item_id, \"t\": t, \"ym\": [f\"{2022 + k // 12}-{k % 12 + 1:02d}\" for k in t],\n", + " \"month\": moy, \"exercise\": ex.astype(int), \"op_hours\": hours[it.equip_type].round(1),\n", + " \"availability\": avail[it.equip_type].round(3), \"demand\": d,\n", + " \"emergency\": rng.binomial(d, 0.05)}))\n", + " return pd.concat(rows, ignore_index=True)\n", + "\n", + "\n", + "def make_stock(items, hist, seed=4):\n", + " \"\"\"현재(가상 2026-01) 재고: 보유량과 미입고 발주량(조달 중인 물량). 단종 품목은 추가 발주가 어려워 미입고량이 적다.\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " recent = hist[hist[\"t\"] >= hist[\"t\"].max() - 5].groupby(\"item_id\")[\"demand\"].mean()\n", + " st = items[[\"item_id\", \"lead_time\", \"status\"]].copy()\n", + " st[\"on_hand\"] = [int(round(recent[i] * rng.uniform(0.5, 3.0))) for i in st[\"item_id\"]]\n", + " cover = np.where(st[\"status\"] == \"생산중\", rng.uniform(0.6, 1.3, len(st)), rng.uniform(0.0, 0.6, len(st)))\n", + " st[\"on_order\"] = [int(round(recent[i] * L * c)) for i, L, c in zip(st[\"item_id\"], st[\"lead_time\"], cover)]\n", + " return st[[\"item_id\", \"on_hand\", \"on_order\"]]\n", + "# ---------------------------------------------------------------------------\n", + "\n", + "items = make_items()\n", + "hist = make_history(items)\n", + "master = set(items[\"nsn\"])\n", + "nsn_of = dict(zip(items[\"item_id\"], items[\"nsn\"]))\n", + "\n", + "# 1) [교육용가상자료] 최근 24개월 월 수요를 개별 청구 기록으로 풀고 일부러 오류를 섞는다\n", + "rng = np.random.default_rng(5)\n", + "OUTAGE = {\"2024-07\", \"2025-02\"} # 전산 장애로 기록이 올라오지 않은 월(가상)\n", + "rows, rid = [], 300000\n", + "for r in hist[hist[\"t\"] >= 24].itertuples():\n", + " if r.ym in OUTAGE or r.demand == 0:\n", + " continue\n", + " k = int(np.ceil(r.demand / 5))\n", + " for j, q in enumerate(rng.multinomial(r.demand, np.ones(k) / k)):\n", + " if q == 0:\n", + " continue\n", + " rid += 1\n", + " rows.append({\"req_id\": f\"R{rid}\", \"date\": f\"{r.ym}-{rng.integers(1, 29):02d}\", \"nsn\": nsn_of[r.item_id],\n", + " \"qty_req\": int(q), \"qty_issued\": int(q), \"uoi\": \"EA\", \"emergency\": bool(j < r.emergency)})\n", + "raw = pd.DataFrame(rows)\n", + "n = len(raw)\n", + "idx = lambda frac: rng.choice(n, int(n * frac), replace=False)\n", + "raw.loc[idx(0.05), \"nsn\"] = raw[\"nsn\"].str.replace(\"-\", \"\") # 하이픈 누락\n", + "raw.loc[idx(0.03), \"nsn\"] = \" \" + raw[\"nsn\"] + \" \" # 앞뒤 공백\n", + "raw.loc[idx(0.04), \"date\"] = raw[\"date\"].str.replace(\"-\", \".\") # 날짜 형식 혼재\n", + "raw.loc[idx(0.02), \"qty_req\"] = -raw[\"qty_req\"] # 음수(입력 오류)\n", + "raw.loc[idx(0.02), \"qty_issued\"] = raw[\"qty_req\"].abs() + 2 # 불출 > 청구\n", + "bx = idx(0.02); raw.loc[bx, \"uoi\"] = \"BX\"; raw.loc[bx, \"qty_req\"] = 1; raw.loc[bx, \"qty_issued\"] = 1 # 상자(10EA) 단위\n", + "raw.loc[idx(0.01), \"qty_req\"] = np.nan # 수량 결측\n", + "raw.loc[idx(0.005), \"date\"] = \"2027-01-15\" # 미래 일자\n", + "raw.loc[idx(0.005), \"nsn\"] = \"9999-00-000-0000\" # 미등록 NSN\n", + "typo = [i for i in idx(0.01) if not raw.at[i, \"emergency\"]]\n", + "raw.loc[typo, \"qty_req\"] = raw.loc[typo, \"qty_req\"] * 10 # 0 하나 더 입력한 오타\n", + "raw = pd.concat([raw, raw.sample(60, random_state=1)], ignore_index=True) # 중복 전송\n", + "print(f\"원시 청구 기록 {len(raw)}건 (가상), 전산 장애 월 {sorted(OUTAGE)}\")\n", + "\n", + "\n", + "# 2) 정제 규칙 10종\n", + "def fmt_nsn(s):\n", + " # [TODO : 학생 실습 2] 결측치 보정, 계절성 수요 분리 및 수리부속 품목코드(NSN) 정제 전처리 모듈 구현 관련 핵심 로직을 구현하세요.\n", + " # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n", + " # <<< 여기에 코드를 작성하세요 >>>\n", + " pass # 작성 후 pass를 제거하세요.\n", + "log = {}\n", + "df = raw.copy()\n", + "before = len(df); df = df.drop_duplicates(\"req_id\"); log[\"R1 중복 청구번호 제거\"] = before - len(df)\n", + "em0 = int(df[\"emergency\"].sum())\n", + "fixed = df[\"nsn\"].map(fmt_nsn); log[\"R2 NSN 형식 정규화(공백·하이픈)\"] = int((fixed != df[\"nsn\"]).sum()); df[\"nsn\"] = fixed\n", + "unk = ~df[\"nsn\"].isin(master); log[\"R3 미등록 NSN 격리\"] = int(unk.sum()); em_drop = int(df.loc[unk, \"emergency\"].sum()); df = df[~unk]\n", + "d2 = df[\"date\"].str.replace(\".\", \"-\", regex=False); log[\"R4 날짜 형식 통일\"] = int((d2 != df[\"date\"]).sum()); df[\"date\"] = pd.to_datetime(d2)\n", + "fut = df[\"date\"] > \"2025-12-31\"; log[\"R5 미래 일자 제거\"] = int(fut.sum()); em_drop += int(df.loc[fut, \"emergency\"].sum()); df = df[~fut]\n", + "neg = df[\"qty_req\"] < 0; log[\"R6 음수 수량 부호 보정\"] = int(neg.sum()); df.loc[neg, \"qty_req\"] = -df.loc[neg, \"qty_req\"]\n", + "na = df[\"qty_req\"].isna(); log[\"R7 청구 수량 결측 -> 불출 수량으로 보완\"] = int(na.sum()); df.loc[na, \"qty_req\"] = df.loc[na, \"qty_issued\"]\n", + "bxm = df[\"uoi\"] == \"BX\"; log[\"R8 단위 환산(BX=10EA)\"] = int(bxm.sum())\n", + "df.loc[bxm, [\"qty_req\", \"qty_issued\"]] = df.loc[bxm, [\"qty_req\", \"qty_issued\"]] * 10; df.loc[bxm, \"uoi\"] = \"EA\"\n", + "over = df[\"qty_issued\"] > df[\"qty_req\"]; log[\"R9 불출>청구 -> 청구 수량으로 제한\"] = int(over.sum())\n", + "df.loc[over, \"qty_issued\"] = df.loc[over, \"qty_req\"]\n", + "cap = df.groupby(\"nsn\")[\"qty_req\"].transform(lambda s: s.median() * 5)\n", + "out = (df[\"qty_req\"] > cap) & ~df[\"emergency\"]\n", + "log[\"R10 오타성 이상치 보정(긴급 불출은 보존)\"] = int(out.sum()); df.loc[out, \"qty_req\"] = (df.loc[out, \"qty_req\"] / 10).round()\n", + "df[\"qty_req\"] = df[\"qty_req\"].astype(int)\n", + "\n", + "# 3) 월별 집계 + 결측 월 계절성 보간(R11): 작년 같은 달 x (최근 수준 / 작년 수준)\n", + "df[\"ym\"] = df[\"date\"].dt.strftime(\"%Y-%m\")\n", + "monthly = df.pivot_table(index=\"ym\", columns=\"nsn\", values=\"qty_req\", aggfunc=\"sum\")\n", + "all_m = [f\"{y}-{m:02d}\" for y in (2024, 2025) for m in range(1, 13)]\n", + "monthly = monthly.reindex(all_m)\n", + "prev = hist[hist[\"t\"].between(12, 23)].assign(nsn=lambda x: x[\"item_id\"].map(nsn_of)).pivot(index=\"month\", columns=\"nsn\", values=\"demand\")\n", + "seasonal_fill = monthly.copy()\n", + "for ym in OUTAGE:\n", + " i = all_m.index(ym)\n", + " ratio = monthly.iloc[max(0, i - 3):i].mean() / prev.loc[[(i - k) % 12 + 1 for k in (1, 2, 3)]].mean().replace(0, np.nan)\n", + " seasonal_fill.loc[ym] = (prev.loc[int(ym[-2:])] * ratio.fillna(1)).round()\n", + "linear_fill = monthly.interpolate()\n", + "monthly = monthly.fillna(0)\n", + "seasonal_fill = seasonal_fill.fillna(0)\n", + "log[\"R11 결측 월 계절성 보간\"] = len(OUTAGE) * monthly.shape[1]\n", + "truth = hist[hist[\"t\"] >= 24].assign(nsn=lambda x: x[\"item_id\"].map(nsn_of)).pivot(index=\"ym\", columns=\"nsn\", values=\"demand\")\n", + "err_s = np.abs(seasonal_fill.loc[sorted(OUTAGE)] - truth.loc[sorted(OUTAGE)]).values.mean()\n", + "err_l = np.abs(linear_fill.loc[sorted(OUTAGE)] - truth.loc[sorted(OUTAGE)]).values.mean()\n", + "\n", + "print(\"\\n[정제 규칙 적용 결과]\")\n", + "for k, v in log.items():\n", + " print(f\" {k:32s} {v:5d}건\")\n", + "print(f\"결측 월 보간 오차(MAE): 계절성 보간 {err_s:.2f}개 / 단순 선형 보간 {err_l:.2f}개\")\n", + "\n", + "# 4) 정합성 검증\n", + "checks = {\"청구번호 유일\": df[\"req_id\"].is_unique, \"NSN 형식\": df[\"nsn\"].str.match(r\"^\\d{4}-\\d{2}-\\d{3}-\\d{4}$\").all(),\n", + " \"마스터 등록 NSN\": df[\"nsn\"].isin(master).all(), \"수량 양수\": (df[\"qty_req\"] > 0).all(),\n", + " \"불출<=청구\": (df[\"qty_issued\"] <= df[\"qty_req\"]).all(), \"단위 EA 통일\": (df[\"uoi\"] == \"EA\").all(),\n", + " \"미래 일자 없음\": (df[\"date\"] <= \"2025-12-31\").all(), \"결측 월 없음\": not seasonal_fill.isna().any().any(),\n", + " \"긴급 불출 보존\": int(df[\"emergency\"].sum()) == em0 - em_drop,\n", + " \"월 합계 = 기록 합계\": int(monthly.values.sum()) == int(df[\"qty_req\"].sum())}\n", + "rate = sum(checks.values()) / len(checks)\n", + "print(f\"\\n정합성 검증 {sum(checks.values())}/{len(checks)} 통과({rate:.0%}) - 목표 100%: {'달성' if rate == 1 else '미달'}, 적용 규칙 {len(log)}종(목표 10종 이상)\")\n", + "\n", + "# 5) 전후 기술통계 비교\n", + "stat = pd.DataFrame({\"정제 전\": pd.to_numeric(raw[\"qty_req\"], errors=\"coerce\").describe(), \"정제 후\": df[\"qty_req\"].describe()}).round(2)\n", + "print(\"\\n[청구 수량 기술통계 전후 비교]\")\n", + "print(stat.T.to_string())\n", + "\n", + "# 6) 계절성 분리(고전적 분해): 추세 = 12개월 중심 이동평균, 계절지수 = 월별 (실적/추세) 평균\n", + "bat = items[items[\"fsc\"] == \"6140\"].sort_values(\"base\")[\"nsn\"].iloc[-1] # 수요가 가장 많은 축전지 품목\n", + "series = pd.concat([truth.iloc[:0], seasonal_fill])[bat].astype(float)\n", + "full = hist[hist[\"item_id\"] == items.set_index(\"nsn\").loc[bat, \"item_id\"]].set_index(\"ym\")[\"demand\"].astype(float)\n", + "full.loc[series.index] = series\n", + "trend = full.rolling(12, center=True).mean()\n", + "sidx = (full / trend).groupby([int(i[-2:]) for i in full.index]).mean()\n", + "sidx = sidx / sidx.mean()\n", + "print(f\"\\n축전지 품목 {bat} 계절지수(1=평균):\", \" \".join(f\"{m}월 {v:.2f}\" for m, v in sidx.items()))\n", + "print(\"해석: 겨울(12~2월) 축전지 수요 증가와 훈련 월(3·8·10월) 증가가 함께 나타난다. 훈련 효과는 미션 4에서 별도 피처로 분리한다.\")\n", + "\n", + "fig, ax = plt.subplots(1, 2, figsize=(12, 4))\n", + "ax[0].plot(full.values, label=\"demand\"); ax[0].plot(trend.values, label=\"trend (12M centered MA)\")\n", + "ax[0].set_title(f\"Battery item {bat}: demand vs trend\"); ax[0].set_xlabel(\"month index\"); ax[0].legend()\n", + "ax[1].bar(sidx.index, sidx.values); ax[1].axhline(1, c=\"k\", ls=\"--\"); ax[1].set_title(\"Seasonal index by month\"); ax[1].set_xlabel(\"month\")\n", + "plt.tight_layout(); plt.show()\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 02 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + "원시 청구 기록 4918건 (가상), 전산 장애 월 ['2024-07', '2025-02']\n", + "\n", + "[정제 규칙 적용 결과]\n", + " R1 중복 청구번호 제거 60건\n", + " R2 NSN 형식 정규화(공백·하이픈) 372건\n", + " R3 미등록 NSN 격리 24건\n", + " R4 날짜 형식 통일 191건\n", + " R5 미래 일자 제거 24건\n", + " R6 음수 수량 부호 보정 94건\n", + " R7 청구 수량 결측 -> 불출 수량으로 보완 47건\n", + " R8 단위 환산(BX=10EA) 97건\n", + " R9 불출>청구 -> 청구 수량으로 제한 96건\n", + " R10 오타성 이상치 보정(긴급 불출은 보존) 35건\n", + " R11 결측 월 계절성 보간 80건\n", + "결측 월 보간 오차(MAE): 계절성 보간 5.72개 / 단순 선형 보간 7.86개\n", + "\n", + "정합성 검증 10/10 통과(100%) - 목표 100%: 달성, 적용 규칙 11종(목표 10종 이상)\n", + "\n", + "[청구 수량 기술통계 전후 비교]\n", + " count mean std min 25% 50% 75% max\n", + "정제 전 4870.0 4.75 4.76 -12.0 3.0 4.0 6.0 80.0\n", + "정제 후 4810.0 4.80 2.17 1.0 3.0 5.0 6.0 20.0\n", + "\n", + "축전지 품목 6140-00-643-1860 계절지수(1=평균): 1월 1.39 2월 1.16 3월 1.13 4월 0.69 5월 0.69 6월 0.66 7월 0.75 8월 1.16 9월 0.85 10월 1.38 11월 0.68 12월 1.46\n", + "해석: 겨울(12~2월) 축전지 수요 증가와 훈련 월(3·8·10월) 증가가 함께 나타난다. 훈련 효과는 미션 4에서 별도 피처로 분리한다.\n", + "```\n", + "
\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 03. 이동평균 및 지수평활법 기반 1차 부품 소요량 예측 Baseline 알고리즘 구현\n", + "**소요시간**: 약 2.5시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "이동평균 기반 1차 부품 소요량 예측 Baseline 알고리즘을 개발한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **이동평균과 지수평활법으로 1차 부품 소요량 예측 Baseline을 만들고 탐색적 분석으로 수요 요인과 핵심 관리 품목을 찾는다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **3·6개월 이동평균, 단순 지수평활, 작년 같은 달 방식의 MAE·WAPE·MAPE를 평가 구간 6개월에서 비교 수행하고 검증하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `notebooks/mnt02_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": [ + "\"\"\"이동평균(3·6개월)과 단순 지수평활법으로 품목별 다음 달 부품 소요량을 예측하는 Baseline을 구현하고, 탐색적 분석(EDA) 차트 8종·상관 검정·ABC(파레토) 분류로 수요 특성과 핵심 관리 품목을 도출한다.\"\"\"\n", + "import numpy as np\n", + "import pandas as pd\n", + "import matplotlib.pyplot as plt\n", + "from scipy import stats\n", + "\n", + "# ---------------------------------------------------------------------------\n", + "# [교육용가상자료] 가상 야전 정비창 수리부속 청구/출고·장비 가동률 데이터 생성기\n", + "# 실제 부대·품목·재고·단가 정보와 무관하다. 품목 40개 x 48개월(가상 2022-01 ~ 2025-12) 월별 자료를 만든다.\n", + "# ---------------------------------------------------------------------------\n", + "FSC = {\"2520\": \"power_train\", \"2530\": \"brake_steer\", \"2910\": \"fuel\", \"2930\": \"cooling\",\n", + " \"2940\": \"filter\", \"5330\": \"gasket\", \"6140\": \"battery\", \"5961\": \"semiconductor\"}\n", + "EXERCISE = {3, 8, 10} # 가상 대규모 훈련 월(훈련 계획으로 미리 알 수 있음)\n", + "N_MONTHS = 48\n", + "\n", + "\n", + "def season(fsc, month):\n", + " \"\"\"품목군별 계절 계수: 축전지는 겨울, 냉각계통은 여름에 수요가 늘어난다.\"\"\"\n", + " # [TODO : 학생 실습 3] 이동평균 및 지수평활법 기반 1차 부품 소요량 예측 Baseline 알고리즘 구현 관련 핵심 로직을 구현하세요.\n", + " # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n", + " # <<< 여기에 코드를 작성하세요 >>>\n", + " pass # 작성 후 pass를 제거하세요.\n", + "def make_items(n=40, seed=2):\n", + " \"\"\"품목 마스터: NSN(가상), 품목군, 장비 유형, 기준 수요, 단가, 조달 리드타임, 공급업체 수, 생산(단종) 상태.\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " rows = []\n", + " for i in range(n):\n", + " f = str(rng.choice(list(FSC)))\n", + " status = str(rng.choice([\"생산중\", \"단종예고\", \"단종\"], p=[0.7, 0.2, 0.1]))\n", + " rows.append({\"item_id\": f\"P{i + 1:03d}\", \"nsn\": f\"{f}-00-{rng.integers(100, 1000)}-{rng.integers(1000, 10000)}\",\n", + " \"fsc\": f, \"group\": FSC[f], \"equip_type\": str(rng.choice([\"A\", \"B\", \"C\"])),\n", + " \"base\": float(np.round(rng.lognormal(2.7, 0.8), 1)),\n", + " \"price\": int(rng.lognormal(12, 1.0)) // 100 * 100,\n", + " \"lead_time\": int(rng.integers(1, 7) if status == \"생산중\" else rng.integers(4, 13)),\n", + " \"suppliers\": int(rng.integers(1, 5)) if status == \"생산중\" else 1,\n", + " \"status\": status, \"alt_available\": bool(rng.random() < (0.6 if status == \"생산중\" else 0.3))})\n", + " return pd.DataFrame(rows)\n", + "\n", + "\n", + "def make_history(items, months=N_MONTHS, seed=3):\n", + " \"\"\"월별 청구 수요(개), 긴급 불출 건수, 장비 유형별 운용시간·가동률.\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " t = np.arange(months)\n", + " moy = t % 12 + 1\n", + " ex = np.isin(moy, list(EXERCISE))\n", + " hours = {e: (100 + 20 * rng.random(months)) * np.where(ex, 1.5, 1.0) for e in \"ABC\"}\n", + " avail = {e: np.clip(0.93 - 0.12 * (hours[e] - 100) / 80 + rng.normal(0, 0.015, months), 0.6, 0.99) for e in \"ABC\"}\n", + " rows = []\n", + " for it in items.itertuples():\n", + " trend = 1 + rng.uniform(-0.1, 0.3) * t / months\n", + " s = np.array([season(it.fsc, m) for m in moy])\n", + " lam = it.base * s * trend * hours[it.equip_type] / 110\n", + " d = rng.poisson(lam)\n", + " rows.append(pd.DataFrame({\"item_id\": it.item_id, \"t\": t, \"ym\": [f\"{2022 + k // 12}-{k % 12 + 1:02d}\" for k in t],\n", + " \"month\": moy, \"exercise\": ex.astype(int), \"op_hours\": hours[it.equip_type].round(1),\n", + " \"availability\": avail[it.equip_type].round(3), \"demand\": d,\n", + " \"emergency\": rng.binomial(d, 0.05)}))\n", + " return pd.concat(rows, ignore_index=True)\n", + "\n", + "\n", + "def make_stock(items, hist, seed=4):\n", + " \"\"\"현재(가상 2026-01) 재고: 보유량과 미입고 발주량(조달 중인 물량). 단종 품목은 추가 발주가 어려워 미입고량이 적다.\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " recent = hist[hist[\"t\"] >= hist[\"t\"].max() - 5].groupby(\"item_id\")[\"demand\"].mean()\n", + " st = items[[\"item_id\", \"lead_time\", \"status\"]].copy()\n", + " st[\"on_hand\"] = [int(round(recent[i] * rng.uniform(0.5, 3.0))) for i in st[\"item_id\"]]\n", + " cover = np.where(st[\"status\"] == \"생산중\", rng.uniform(0.6, 1.3, len(st)), rng.uniform(0.0, 0.6, len(st)))\n", + " st[\"on_order\"] = [int(round(recent[i] * L * c)) for i, L, c in zip(st[\"item_id\"], st[\"lead_time\"], cover)]\n", + " return st[[\"item_id\", \"on_hand\", \"on_order\"]]\n", + "# ---------------------------------------------------------------------------\n", + "\n", + "items = make_items()\n", + "hist = make_history(items).merge(items[[\"item_id\", \"group\", \"price\", \"status\", \"lead_time\"]], on=\"item_id\")\n", + "TEST_FROM = 42 # 마지막 6개월(가상 2025-07 ~ 2025-12)을 평가 구간으로 둔다\n", + "\n", + "\n", + "def ses(y, alpha):\n", + " \"\"\"단순 지수평활: 다음 달 예측 = alpha x 이번 달 실적 + (1 - alpha) x 이번 달 예측.\"\"\"\n", + " # [TODO : 학생 실습 3] 이동평균 및 지수평활법 기반 1차 부품 소요량 예측 Baseline 알고리즘 구현 관련 핵심 로직을 구현하세요.\n", + " # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n", + " # <<< 여기에 코드를 작성하세요 >>>\n", + " pass # 작성 후 pass를 제거하세요.\n", + "preds = []\n", + "for iid, g in hist.groupby(\"item_id\"):\n", + " y = g.sort_values(\"t\")[\"demand\"].to_numpy(float)\n", + " best = min([0.1, 0.2, 0.3, 0.5], key=lambda a: np.abs(ses(y, a)[12:TEST_FROM] - y[12:TEST_FROM]).mean())\n", + " s = pd.Series(y)\n", + " preds.append(pd.DataFrame({\"item_id\": iid, \"t\": np.arange(len(y)), \"y\": y, \"MA3\": s.shift(1).rolling(3).mean(),\n", + " \"MA6\": s.shift(1).rolling(6).mean(), \"SES\": ses(y, best), \"작년 같은 달\": s.shift(12)}))\n", + "P = pd.concat(preds)\n", + "T = P[P[\"t\"] >= TEST_FROM]\n", + "ab = items[items[\"base\"] >= 10][\"item_id\"] # 수요가 충분한 품목(MAPE 해석 가능)\n", + "rows = []\n", + "for m in [\"MA3\", \"MA6\", \"SES\", \"작년 같은 달\"]:\n", + " e = T[\"y\"] - T[m]\n", + " big = T[T[\"item_id\"].isin(ab)]\n", + " rows.append({\"방법\": m, \"MAE(개)\": round(e.abs().mean(), 2), \"WAPE\": f\"{e.abs().sum() / T['y'].sum():.1%}\",\n", + " \"MAPE(수요 많은 품목)\": f\"{(np.abs(big['y'] - big[m]) / big['y'].clip(lower=1)).mean():.1%}\"})\n", + "base_tbl = pd.DataFrame(rows)\n", + "print(\"[Baseline 예측 성능 - 평가 구간 6개월]\")\n", + "print(base_tbl.to_string(index=False))\n", + "print(\"해석: 이동평균은 훈련 월 급증과 계절 변화를 한 달 늦게 따라가 오차가 커진다(미션 4에서 개선).\")\n", + "\n", + "# 2) 상관 검정: 품목 평균 대비 수요 비율과 후보 피처\n", + "hist = hist.sort_values([\"item_id\", \"t\"])\n", + "hist[\"ratio\"] = hist[\"demand\"] / hist.groupby(\"item_id\")[\"demand\"].transform(\"mean\")\n", + "g = hist.groupby(\"item_id\")[\"ratio\"]\n", + "hist[\"lag1_ratio\"] = g.shift(1); hist[\"lag12_ratio\"] = g.shift(12)\n", + "hist[\"winter\"] = hist[\"month\"].isin([12, 1, 2]).astype(int)\n", + "cand = [\"op_hours\", \"exercise\", \"availability\", \"lag12_ratio\", \"lag1_ratio\", \"t\", \"winter\", \"price\"]\n", + "res = []\n", + "for c in cand:\n", + " d = hist[[\"ratio\", c]].dropna()\n", + " r, p = stats.pearsonr(d[c], d[\"ratio\"])\n", + " res.append({\"피처\": c, \"상관계수\": round(r, 3), \"p-value\": f\"{p:.1e}\", \"유의(p<0.05)\": \"예\" if p < 0.05 else \"아니오\"})\n", + "res = pd.DataFrame(res)\n", + "n_sig = (res[\"유의(p<0.05)\"] == \"예\").sum()\n", + "print(\"\\n[수요 비율과 피처의 상관 검정]\")\n", + "print(res.to_string(index=False))\n", + "print(f\"유의한 피처 {n_sig}개 (목표 5개 이상: {'달성' if n_sig >= 5 else '미달'})\")\n", + "t_stat, p_ex = stats.ttest_ind(hist[hist[\"exercise\"] == 1][\"ratio\"], hist[hist[\"exercise\"] == 0][\"ratio\"])\n", + "print(f\"훈련 월과 평시 월 수요 비율 차이 t검정: t={t_stat:.1f}, p={p_ex:.1e}\")\n", + "\n", + "# 3) ABC(파레토) 분류: 최근 12개월 수요 금액 기준\n", + "val = hist[hist[\"t\"] >= 36].groupby(\"item_id\").apply(lambda x: (x[\"demand\"] * x[\"price\"]).sum(), include_groups=False).sort_values(ascending=False)\n", + "cum = val.cumsum() / val.sum()\n", + "abc = pd.Series(np.where(cum <= 0.8, \"A\", np.where(cum <= 0.95, \"B\", \"C\")), index=val.index)\n", + "top = val.index[: int(np.ceil(len(val) * 0.2))]\n", + "print(f\"\\nABC 분류: A {sum(abc == 'A')}개 / B {sum(abc == 'B')}개 / C {sum(abc == 'C')}개, 상위 20% {len(top)}개 품목이 수요 금액의 {val[top].sum() / val.sum():.0%} 차지\")\n", + "st = items.set_index(\"item_id\").loc[top]\n", + "print(\"상위 20% 중 단종예고·단종 품목:\", \", \".join(f\"{i}({s})\" for i, s in st[\"status\"].items() if s != \"생산중\") or \"없음\")\n", + "\n", + "# 4) EDA 차트 8종\n", + "fig, ax = plt.subplots(2, 4, figsize=(18, 8))\n", + "tot = hist.groupby(\"t\")[\"demand\"].sum()\n", + "ax[0, 0].plot(tot.index, tot.values); ax[0, 0].set_title(\"1. Total monthly demand\")\n", + "hist.groupby([\"t\", \"group\"])[\"demand\"].sum().unstack().plot(ax=ax[0, 1], legend=False); ax[0, 1].set_title(\"2. Demand by FSC group\")\n", + "hist.boxplot(column=\"ratio\", by=\"month\", ax=ax[0, 2], grid=False); ax[0, 2].set_title(\"3. Seasonality (demand ratio)\")\n", + "ax[0, 3].scatter(hist[\"op_hours\"], hist[\"ratio\"], s=4, alpha=0.3); ax[0, 3].set_title(\"4. Operating hours vs demand ratio\")\n", + "hist.groupby(\"exercise\")[\"ratio\"].mean().plot.bar(ax=ax[1, 0]); ax[1, 0].set_title(\"5. Exercise month effect\")\n", + "ax[1, 1].plot(np.arange(1, len(cum) + 1), cum.values, \"o-\", ms=3); ax[1, 1].axhline(0.8, ls=\"--\", c=\"gray\"); ax[1, 1].set_title(\"6. Pareto curve (ABC)\")\n", + "items.assign(st=items[\"status\"].map({\"생산중\": \"active\", \"단종예고\": \"EOL notice\", \"단종\": \"discontinued\"})).boxplot(column=\"lead_time\", by=\"st\", ax=ax[1, 2], grid=False); ax[1, 2].set_title(\"7. Lead time by production status\")\n", + "ag = hist.groupby(\"t\").agg(av=(\"availability\", \"mean\"), d=(\"demand\", \"sum\"))\n", + "ax[1, 3].scatter(ag[\"av\"], ag[\"d\"]); ax[1, 3].set_title(\"8. Availability vs total demand\")\n", + "for a in ax.ravel():\n", + " a.set_xlabel(\"\"); a.tick_params(labelsize=7)\n", + "plt.suptitle(\"MNT02 EDA - 8 charts (synthetic data)\"); plt.tight_layout(); plt.show()\n", + "print(\"EDA 차트 8종 생성 완료. 핵심 발견: 운용시간·훈련 월이 수요를 끌어올리고, 단종 품목은 리드타임이 길어 결품 위험이 크다.\")\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 03 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + "[Baseline 예측 성능 - 평가 구간 6개월]\n", + " 방법 MAE(개) WAPE MAPE(수요 많은 품목)\n", + " MA3 7.34 28.7% 32.7%\n", + " MA6 7.09 27.7% 31.6%\n", + " SES 6.53 25.5% 28.9%\n", + "작년 같은 달 5.38 21.0% 22.8%\n", + "해석: 이동평균은 훈련 월 급증과 계절 변화를 한 달 늦게 따라가 오차가 커진다(미션 4에서 개선).\n", + "\n", + "[수요 비율과 피처의 상관 검정]\n", + " 피처 상관계수 p-value 유의(p<0.05)\n", + " op_hours 0.477 8.3e-110 예\n", + " exercise 0.470 4.4e-106 예\n", + "availability -0.450 1.6e-96 예\n", + " lag12_ratio 0.339 3.7e-40 예\n", + " lag1_ratio -0.111 1.5e-06 예\n", + " t 0.066 3.8e-03 예\n", + " winter -0.032 1.6e-01 아니오\n", + " price 0.000 1.0e+00 아니오\n", + "유의한 피처 6개 (목표 5개 이상: 달성)\n", + "훈련 월과 평시 월 수요 비율 차이 t검정: t=23.3, p=4.4e-106\n", + "\n", + "ABC 분류: A 13개 / B 13개 / C 14개, 상위 20% 8개 품목이 수요 금액의 67% 차지\n", + "상위 20% 중 단종예고·단종 품목: P016(단종), P028(단종), P032(단종예고), P007(단종예고)\n", + "EDA 차트 8종 생성 완료. 핵심 발견: 운용시간·훈련 월이 수요를 끌어올리고, 단종 품목은 리드타임이 길어 결품 위험이 크다.\n", + "```\n", + "
\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 04. 시계열 머신러닝(Prophet/LSTM) 기반 결전 대비 야전 부품 수요 예측 모델 고도화\n", + "**소요시간**: 약 3.0시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "시계열 머신러닝 기반 결전 대비 야전 부품 수요 예측 모델을 고도화한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **시계열 머신러닝으로 결전 대비 야전 부품 수요 예측 모델을 고도화한다(Prophet/LSTM은 실행 환경 제약으로 포아송 그래디언트 부스팅으로 대체).** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **지연값·이동평균·변동성·계절지수·훈련 계획·운용시간 등 예측 시점 이전 정보로 파생 피처 10개 이상 작성하여 원인을 분석하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 5개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `src/features/mnt02_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": [ + "\"\"\"과거 수요 지연값·이동평균·계절지수·훈련 계획·운용시간 등 파생 피처 15개로 시계열 머신러닝 수요 예측 모델(Prophet/LSTM 대신 포아송 그래디언트 부스팅)을 만들고, 시계열 교차검증으로 MAPE를 확인한 뒤 작전 템포 증가(결전 대비) 시 수요를 예측한다.\"\"\"\n", + "import numpy as np\n", + "import pandas as pd\n", + "import matplotlib.pyplot as plt\n", + "from sklearn.ensemble import HistGradientBoostingRegressor\n", + "from sklearn.inspection import permutation_importance\n", + "\n", + "# ---------------------------------------------------------------------------\n", + "# [교육용가상자료] 가상 야전 정비창 수리부속 청구/출고·장비 가동률 데이터 생성기\n", + "# 실제 부대·품목·재고·단가 정보와 무관하다. 품목 40개 x 48개월(가상 2022-01 ~ 2025-12) 월별 자료를 만든다.\n", + "# ---------------------------------------------------------------------------\n", + "FSC = {\"2520\": \"power_train\", \"2530\": \"brake_steer\", \"2910\": \"fuel\", \"2930\": \"cooling\",\n", + " \"2940\": \"filter\", \"5330\": \"gasket\", \"6140\": \"battery\", \"5961\": \"semiconductor\"}\n", + "EXERCISE = {3, 8, 10} # 가상 대규모 훈련 월(훈련 계획으로 미리 알 수 있음)\n", + "N_MONTHS = 48\n", + "\n", + "\n", + "def season(fsc, month):\n", + " \"\"\"품목군별 계절 계수: 축전지는 겨울, 냉각계통은 여름에 수요가 늘어난다.\"\"\"\n", + " # [TODO : 학생 실습 4] 시계열 머신러닝(Prophet/LSTM) 기반 결전 대비 야전 부품 수요 예측 모델 고도화 관련 핵심 로직을 구현하세요.\n", + " # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n", + " # <<< 여기에 코드를 작성하세요 >>>\n", + " pass # 작성 후 pass를 제거하세요.\n", + "def make_items(n=40, seed=2):\n", + " \"\"\"품목 마스터: NSN(가상), 품목군, 장비 유형, 기준 수요, 단가, 조달 리드타임, 공급업체 수, 생산(단종) 상태.\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " rows = []\n", + " for i in range(n):\n", + " f = str(rng.choice(list(FSC)))\n", + " status = str(rng.choice([\"생산중\", \"단종예고\", \"단종\"], p=[0.7, 0.2, 0.1]))\n", + " rows.append({\"item_id\": f\"P{i + 1:03d}\", \"nsn\": f\"{f}-00-{rng.integers(100, 1000)}-{rng.integers(1000, 10000)}\",\n", + " \"fsc\": f, \"group\": FSC[f], \"equip_type\": str(rng.choice([\"A\", \"B\", \"C\"])),\n", + " \"base\": float(np.round(rng.lognormal(2.7, 0.8), 1)),\n", + " \"price\": int(rng.lognormal(12, 1.0)) // 100 * 100,\n", + " \"lead_time\": int(rng.integers(1, 7) if status == \"생산중\" else rng.integers(4, 13)),\n", + " \"suppliers\": int(rng.integers(1, 5)) if status == \"생산중\" else 1,\n", + " \"status\": status, \"alt_available\": bool(rng.random() < (0.6 if status == \"생산중\" else 0.3))})\n", + " return pd.DataFrame(rows)\n", + "\n", + "\n", + "def make_history(items, months=N_MONTHS, seed=3):\n", + " \"\"\"월별 청구 수요(개), 긴급 불출 건수, 장비 유형별 운용시간·가동률.\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " t = np.arange(months)\n", + " moy = t % 12 + 1\n", + " ex = np.isin(moy, list(EXERCISE))\n", + " hours = {e: (100 + 20 * rng.random(months)) * np.where(ex, 1.5, 1.0) for e in \"ABC\"}\n", + " avail = {e: np.clip(0.93 - 0.12 * (hours[e] - 100) / 80 + rng.normal(0, 0.015, months), 0.6, 0.99) for e in \"ABC\"}\n", + " rows = []\n", + " for it in items.itertuples():\n", + " trend = 1 + rng.uniform(-0.1, 0.3) * t / months\n", + " s = np.array([season(it.fsc, m) for m in moy])\n", + " lam = it.base * s * trend * hours[it.equip_type] / 110\n", + " d = rng.poisson(lam)\n", + " rows.append(pd.DataFrame({\"item_id\": it.item_id, \"t\": t, \"ym\": [f\"{2022 + k // 12}-{k % 12 + 1:02d}\" for k in t],\n", + " \"month\": moy, \"exercise\": ex.astype(int), \"op_hours\": hours[it.equip_type].round(1),\n", + " \"availability\": avail[it.equip_type].round(3), \"demand\": d,\n", + " \"emergency\": rng.binomial(d, 0.05)}))\n", + " return pd.concat(rows, ignore_index=True)\n", + "\n", + "\n", + "def make_stock(items, hist, seed=4):\n", + " \"\"\"현재(가상 2026-01) 재고: 보유량과 미입고 발주량(조달 중인 물량). 단종 품목은 추가 발주가 어려워 미입고량이 적다.\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " recent = hist[hist[\"t\"] >= hist[\"t\"].max() - 5].groupby(\"item_id\")[\"demand\"].mean()\n", + " st = items[[\"item_id\", \"lead_time\", \"status\"]].copy()\n", + " st[\"on_hand\"] = [int(round(recent[i] * rng.uniform(0.5, 3.0))) for i in st[\"item_id\"]]\n", + " cover = np.where(st[\"status\"] == \"생산중\", rng.uniform(0.6, 1.3, len(st)), rng.uniform(0.0, 0.6, len(st)))\n", + " st[\"on_order\"] = [int(round(recent[i] * L * c)) for i, L, c in zip(st[\"item_id\"], st[\"lead_time\"], cover)]\n", + " return st[[\"item_id\", \"on_hand\", \"on_order\"]]\n", + "# ---------------------------------------------------------------------------\n", + "\n", + "# ---- 수요 예측 공통 함수(파생 피처 + 머신러닝 모델) ----\n", + "FEATS = [\"lag1\", \"lag2\", \"lag3\", \"lag12\", \"ma3\", \"ma6\", \"std6\", \"season_idx\", \"month_sin\", \"month_cos\",\n", + " \"exercise\", \"op_hours\", \"op_ratio\", \"item_level\", \"trend_t\"]\n", + "\n", + "\n", + "def make_features(hist):\n", + " \"\"\"품목별 과거 수요로 파생 피처를 만든다(모두 예측 시점 이전 정보만 사용).\"\"\"\n", + " df = hist.sort_values([\"item_id\", \"t\"]).reset_index(drop=True)\n", + " g = df.groupby(\"item_id\")[\"demand\"]\n", + " for k in (1, 2, 3, 12):\n", + " df[f\"lag{k}\"] = g.shift(k)\n", + " df[\"ma3\"] = g.transform(lambda s: s.shift(1).rolling(3).mean())\n", + " df[\"ma6\"] = g.transform(lambda s: s.shift(1).rolling(6).mean())\n", + " df[\"std6\"] = g.transform(lambda s: s.shift(1).rolling(6).std())\n", + " df[\"item_level\"] = g.transform(lambda s: s.shift(1).rolling(12).mean())\n", + " df[\"season_idx\"] = df[\"lag12\"] / (g.transform(lambda s: s.shift(12).rolling(12).mean()) + 1)\n", + " df[\"month_sin\"] = np.sin(2 * np.pi * df[\"month\"] / 12)\n", + " df[\"month_cos\"] = np.cos(2 * np.pi * df[\"month\"] / 12)\n", + " df[\"op_ratio\"] = df[\"op_hours\"] / df.groupby(\"item_id\")[\"op_hours\"].transform(lambda s: s.shift(1).rolling(12).mean())\n", + " df[\"trend_t\"] = df[\"t\"]\n", + " return df.dropna().reset_index(drop=True)\n", + "\n", + "\n", + "def new_model():\n", + " \"\"\"Prophet/LSTM 대신 사용하는 포아송 손실 그래디언트 부스팅(개수 자료에 적합).\"\"\"\n", + " # [TODO : 학생 실습 4] 시계열 머신러닝(Prophet/LSTM) 기반 결전 대비 야전 부품 수요 예측 모델 고도화 관련 핵심 로직을 구현하세요.\n", + " # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n", + " # <<< 여기에 코드를 작성하세요 >>>\n", + " pass # 작성 후 pass를 제거하세요.\n", + "def mape(y, p, min_y=1):\n", + " y, p = np.asarray(y, float), np.asarray(p, float)\n", + " m = y >= min_y\n", + " return float(np.mean(np.abs(y[m] - p[m]) / y[m]))\n", + "\n", + "items = make_items()\n", + "hist = make_history(items)\n", + "df = make_features(hist)\n", + "ab = set(items[items[\"base\"] >= 10][\"item_id\"]) # 수요가 충분한 품목(MAPE 평가 대상)\n", + "print(f\"파생 피처 {len(FEATS)}개 (목표 10개 이상): {', '.join(FEATS)}\")\n", + "\n", + "# 1) 시계열 교차검증(확장 창): 과거로 학습 -> 바로 다음 6개월 평가, 3회 반복\n", + "rows = []\n", + "for cut in (30, 36, 42):\n", + " tr, te = df[df[\"t\"] < cut], df[(df[\"t\"] >= cut) & (df[\"t\"] < cut + 6)]\n", + " p = new_model().fit(tr[FEATS], tr[\"demand\"]).predict(te[FEATS])\n", + " big = te[\"item_id\"].isin(ab).to_numpy()\n", + " rows.append({\"학습 구간\": f\"t<{cut}\", \"평가 구간\": f\"t={cut}~{cut + 5}\",\n", + " \"ML MAE\": round(np.abs(te[\"demand\"] - p).mean(), 2), \"MA3 MAE\": round(np.abs(te[\"demand\"] - te[\"ma3\"]).mean(), 2),\n", + " \"ML MAPE\": mape(te[\"demand\"][big], p[big]), \"MA3 MAPE\": mape(te[\"demand\"][big], te[\"ma3\"][big])})\n", + "cv = pd.DataFrame(rows)\n", + "print(\"\\n[시계열 교차검증 결과 - MAPE는 수요 많은 품목 기준]\")\n", + "print(cv.assign(**{\"ML MAPE\": cv[\"ML MAPE\"].map(\"{:.1%}\".format), \"MA3 MAPE\": cv[\"MA3 MAPE\"].map(\"{:.1%}\".format)}).to_string(index=False))\n", + "m_mape = cv[\"ML MAPE\"].mean()\n", + "print(f\"평균 MAPE: ML {m_mape:.1%} / MA3 {cv['MA3 MAPE'].mean():.1%} -> 목표(MAPE 25% 이하): {'달성' if m_mape <= 0.25 else '미달'}\")\n", + "\n", + "# 2) 최종 모델(t<42 학습)과 피처 중요도(순열 중요도)\n", + "tr, te = df[df[\"t\"] < 42], df[df[\"t\"] >= 42]\n", + "model = new_model().fit(tr[FEATS], tr[\"demand\"])\n", + "imp = permutation_importance(model, te[FEATS], te[\"demand\"], n_repeats=5, random_state=0, scoring=\"neg_mean_absolute_error\")\n", + "imp = pd.Series(imp.importances_mean, index=FEATS).sort_values(ascending=False)\n", + "print(\"\\n[피처 중요도 상위 6개(MAE 증가량)]\", \", \".join(f\"{k} {v:.2f}\" for k, v in imp.head(6).items()))\n", + "print(\"중요도가 0에 가까운 피처(차원 축소 후보):\", \", \".join(imp[imp < 0.02].index) or \"없음\")\n", + "\n", + "# 3) 결전 대비 시나리오: 다음 달(가상 2026-01) 운용시간 1.5배 + 훈련 월 지정\n", + "future = []\n", + "for it in items.itertuples():\n", + " last = hist[(hist[\"item_id\"] == it.item_id) & (hist[\"t\"] == 47)].iloc[0]\n", + " future.append({**last.to_dict(), \"t\": 48, \"ym\": \"2026-01\", \"month\": 1, \"demand\": 0, \"exercise\": 0})\n", + "fut = pd.DataFrame(future)\n", + "fut[\"op_hours\"] = hist[hist[\"t\"] == 36].set_index(\"item_id\").loc[fut[\"item_id\"], \"op_hours\"].to_numpy() # 작년 1월 계획 운용시간\n", + "res = {}\n", + "for name, mult, ex in [(\"평시 계획\", 1.0, 0), (\"작전 템포 1.5배\", 1.5, 1)]:\n", + " f2 = fut.assign(op_hours=fut[\"op_hours\"] * mult, exercise=ex)\n", + " X = make_features(pd.concat([hist, f2], ignore_index=True))\n", + " X = X[X[\"t\"] == 48]\n", + " res[name] = pd.Series(model.predict(X[FEATS]), index=X[\"item_id\"])\n", + "comp = pd.DataFrame(res).round(1)\n", + "comp[\"증가율\"] = (comp[\"작전 템포 1.5배\"] / comp[\"평시 계획\"] - 1).map(\"{:+.0%}\".format)\n", + "comp = comp.join(items.set_index(\"item_id\")[[\"group\", \"status\"]])\n", + "print(\"\\n[결전 대비 다음 달 수요 예측 - 증가량 상위 6개 품목]\")\n", + "print(comp.loc[(comp[\"작전 템포 1.5배\"] - comp[\"평시 계획\"]).sort_values(ascending=False).index[:6]].to_string())\n", + "print(f\"전체 예측 수요: 평시 {comp['평시 계획'].sum():.0f}개 -> 작전 템포 1.5배 {comp['작전 템포 1.5배'].sum():.0f}개\")\n", + "\n", + "# 4) 예측 그림: 수요 많은 품목 1개\n", + "iid = items.sort_values(\"base\")[\"item_id\"].iloc[-1]\n", + "s = te[te[\"item_id\"] == iid]\n", + "full = df[df[\"item_id\"] == iid]\n", + "plt.figure(figsize=(9, 4))\n", + "plt.plot(full[\"t\"], full[\"demand\"], \"k.-\", label=\"actual\")\n", + "plt.plot(s[\"t\"], model.predict(s[FEATS]), \"r-o\", label=\"ML forecast\")\n", + "plt.plot(s[\"t\"], s[\"ma3\"], \"b--\", label=\"MA3 baseline\")\n", + "plt.axvline(41.5, c=\"gray\", ls=\":\"); plt.xlabel(\"month index\"); plt.ylabel(\"demand (EA)\")\n", + "plt.title(f\"{iid} one-month-ahead forecast (synthetic)\"); plt.legend(); plt.show()\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 04 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + "파생 피처 15개 (목표 10개 이상): lag1, lag2, lag3, lag12, ma3, ma6, std6, season_idx, month_sin, month_cos, exercise, op_hours, op_ratio, item_level, trend_t\n", + "\n", + "[시계열 교차검증 결과 - MAPE는 수요 많은 품목 기준]\n", + "학습 구간 평가 구간 ML MAE MA3 MAE ML MAPE MA3 MAPE\n", + " t<30 t=30~35 5.85 7.26 25.8% 30.9%\n", + " t<36 t=36~41 4.84 7.26 20.5% 30.9%\n", + " t<42 t=42~47 4.57 7.34 20.8% 32.7%\n", + "평균 MAPE: ML 22.3% / MA3 31.5% -> 목표(MAPE 25% 이하): 달성\n", + "\n", + "[피처 중요도 상위 6개(MAE 증가량)] lag12 7.83, item_level 7.20, op_hours 0.95, std6 0.27, ma3 0.19, op_ratio 0.13\n", + "중요도가 0에 가까운 피처(차원 축소 후보): ma6, trend_t, lag2\n", + "\n", + "[결전 대비 다음 달 수요 예측 - 증가량 상위 6개 품목]\n", + " 평시 계획 작전 템포 1.5배 증가율 group status\n", + "item_id \n", + "P016 138.9 168.2 +21% gasket 단종\n", + "P028 74.1 99.5 +34% power_train 단종\n", + "P033 73.3 94.1 +28% fuel 생산중\n", + "P035 78.5 94.1 +20% brake_steer 생산중\n", + "P030 43.4 56.8 +31% brake_steer 단종예고\n", + "P026 15.6 24.3 +56% cooling 생산중\n", + "전체 예측 수요: 평시 946개 -> 작전 템포 1.5배 1210개\n", + "```\n", + "
\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 05. 적정 안전재고량 산출 및 결품 위험 지수(Stockout Risk) 엔진 개발\n", + "**소요시간**: 약 3.5시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "적정 안전재고량 산출 및 결품 위험 지수 엔진을 개발한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **앙상블 수요 예측으로 품목별 적정 안전재고·재주문점을 산출하고 단종 위험을 반영한 결품 위험 지수(Stockout Risk) 엔진을 개발한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **포아송 그래디언트 부스팅과 랜덤포레스트를 앙상블하고 가중치는 학습 기간 안의 검증 구간으로 고른다 과정을 진행하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `src/models/mnt02_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": [ + "\"\"\"포아송 그래디언트 부스팅·랜덤포레스트 앙상블로 수요 예측을 고도화해 이동평균 기준 모델 대비 MAE/RMSE 개선율과 R2를 검증하고, 예측 오차로 품목별 적정 안전재고·재주문점을 산출한 뒤 리드타임 결품 확률과 단종 상태·단일 공급업체·대체품 부재를 결합한 결품 위험 지수 엔진을 만든다.\"\"\"\n", + "import numpy as np\n", + "import pandas as pd\n", + "import matplotlib.pyplot as plt\n", + "from scipy.stats import norm\n", + "from sklearn.ensemble import HistGradientBoostingRegressor, RandomForestRegressor\n", + "from sklearn.metrics import mean_absolute_error, mean_squared_error, r2_score\n", + "\n", + "# ---------------------------------------------------------------------------\n", + "# [교육용가상자료] 가상 야전 정비창 수리부속 청구/출고·장비 가동률 데이터 생성기\n", + "# 실제 부대·품목·재고·단가 정보와 무관하다. 품목 40개 x 48개월(가상 2022-01 ~ 2025-12) 월별 자료를 만든다.\n", + "# ---------------------------------------------------------------------------\n", + "FSC = {\"2520\": \"power_train\", \"2530\": \"brake_steer\", \"2910\": \"fuel\", \"2930\": \"cooling\",\n", + " \"2940\": \"filter\", \"5330\": \"gasket\", \"6140\": \"battery\", \"5961\": \"semiconductor\"}\n", + "EXERCISE = {3, 8, 10} # 가상 대규모 훈련 월(훈련 계획으로 미리 알 수 있음)\n", + "N_MONTHS = 48\n", + "\n", + "\n", + "def season(fsc, month):\n", + " \"\"\"품목군별 계절 계수: 축전지는 겨울, 냉각계통은 여름에 수요가 늘어난다.\"\"\"\n", + " # [TODO : 학생 실습 5] 적정 안전재고량 산출 및 결품 위험 지수(Stockout Risk) 엔진 개발 관련 핵심 로직을 구현하세요.\n", + " # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n", + " # <<< 여기에 코드를 작성하세요 >>>\n", + " pass # 작성 후 pass를 제거하세요.\n", + "def make_items(n=40, seed=2):\n", + " \"\"\"품목 마스터: NSN(가상), 품목군, 장비 유형, 기준 수요, 단가, 조달 리드타임, 공급업체 수, 생산(단종) 상태.\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " rows = []\n", + " for i in range(n):\n", + " f = str(rng.choice(list(FSC)))\n", + " status = str(rng.choice([\"생산중\", \"단종예고\", \"단종\"], p=[0.7, 0.2, 0.1]))\n", + " rows.append({\"item_id\": f\"P{i + 1:03d}\", \"nsn\": f\"{f}-00-{rng.integers(100, 1000)}-{rng.integers(1000, 10000)}\",\n", + " \"fsc\": f, \"group\": FSC[f], \"equip_type\": str(rng.choice([\"A\", \"B\", \"C\"])),\n", + " \"base\": float(np.round(rng.lognormal(2.7, 0.8), 1)),\n", + " \"price\": int(rng.lognormal(12, 1.0)) // 100 * 100,\n", + " \"lead_time\": int(rng.integers(1, 7) if status == \"생산중\" else rng.integers(4, 13)),\n", + " \"suppliers\": int(rng.integers(1, 5)) if status == \"생산중\" else 1,\n", + " \"status\": status, \"alt_available\": bool(rng.random() < (0.6 if status == \"생산중\" else 0.3))})\n", + " return pd.DataFrame(rows)\n", + "\n", + "\n", + "def make_history(items, months=N_MONTHS, seed=3):\n", + " \"\"\"월별 청구 수요(개), 긴급 불출 건수, 장비 유형별 운용시간·가동률.\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " t = np.arange(months)\n", + " moy = t % 12 + 1\n", + " ex = np.isin(moy, list(EXERCISE))\n", + " hours = {e: (100 + 20 * rng.random(months)) * np.where(ex, 1.5, 1.0) for e in \"ABC\"}\n", + " avail = {e: np.clip(0.93 - 0.12 * (hours[e] - 100) / 80 + rng.normal(0, 0.015, months), 0.6, 0.99) for e in \"ABC\"}\n", + " rows = []\n", + " for it in items.itertuples():\n", + " trend = 1 + rng.uniform(-0.1, 0.3) * t / months\n", + " s = np.array([season(it.fsc, m) for m in moy])\n", + " lam = it.base * s * trend * hours[it.equip_type] / 110\n", + " d = rng.poisson(lam)\n", + " rows.append(pd.DataFrame({\"item_id\": it.item_id, \"t\": t, \"ym\": [f\"{2022 + k // 12}-{k % 12 + 1:02d}\" for k in t],\n", + " \"month\": moy, \"exercise\": ex.astype(int), \"op_hours\": hours[it.equip_type].round(1),\n", + " \"availability\": avail[it.equip_type].round(3), \"demand\": d,\n", + " \"emergency\": rng.binomial(d, 0.05)}))\n", + " return pd.concat(rows, ignore_index=True)\n", + "\n", + "\n", + "def make_stock(items, hist, seed=4):\n", + " \"\"\"현재(가상 2026-01) 재고: 보유량과 미입고 발주량(조달 중인 물량). 단종 품목은 추가 발주가 어려워 미입고량이 적다.\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " recent = hist[hist[\"t\"] >= hist[\"t\"].max() - 5].groupby(\"item_id\")[\"demand\"].mean()\n", + " st = items[[\"item_id\", \"lead_time\", \"status\"]].copy()\n", + " st[\"on_hand\"] = [int(round(recent[i] * rng.uniform(0.5, 3.0))) for i in st[\"item_id\"]]\n", + " cover = np.where(st[\"status\"] == \"생산중\", rng.uniform(0.6, 1.3, len(st)), rng.uniform(0.0, 0.6, len(st)))\n", + " st[\"on_order\"] = [int(round(recent[i] * L * c)) for i, L, c in zip(st[\"item_id\"], st[\"lead_time\"], cover)]\n", + " return st[[\"item_id\", \"on_hand\", \"on_order\"]]\n", + "# ---------------------------------------------------------------------------\n", + "\n", + "# ---- 수요 예측 공통 함수(파생 피처 + 머신러닝 모델) ----\n", + "FEATS = [\"lag1\", \"lag2\", \"lag3\", \"lag12\", \"ma3\", \"ma6\", \"std6\", \"season_idx\", \"month_sin\", \"month_cos\",\n", + " \"exercise\", \"op_hours\", \"op_ratio\", \"item_level\", \"trend_t\"]\n", + "\n", + "\n", + "def make_features(hist):\n", + " \"\"\"품목별 과거 수요로 파생 피처를 만든다(모두 예측 시점 이전 정보만 사용).\"\"\"\n", + " df = hist.sort_values([\"item_id\", \"t\"]).reset_index(drop=True)\n", + " g = df.groupby(\"item_id\")[\"demand\"]\n", + " for k in (1, 2, 3, 12):\n", + " df[f\"lag{k}\"] = g.shift(k)\n", + " df[\"ma3\"] = g.transform(lambda s: s.shift(1).rolling(3).mean())\n", + " df[\"ma6\"] = g.transform(lambda s: s.shift(1).rolling(6).mean())\n", + " df[\"std6\"] = g.transform(lambda s: s.shift(1).rolling(6).std())\n", + " df[\"item_level\"] = g.transform(lambda s: s.shift(1).rolling(12).mean())\n", + " df[\"season_idx\"] = df[\"lag12\"] / (g.transform(lambda s: s.shift(12).rolling(12).mean()) + 1)\n", + " df[\"month_sin\"] = np.sin(2 * np.pi * df[\"month\"] / 12)\n", + " df[\"month_cos\"] = np.cos(2 * np.pi * df[\"month\"] / 12)\n", + " df[\"op_ratio\"] = df[\"op_hours\"] / df.groupby(\"item_id\")[\"op_hours\"].transform(lambda s: s.shift(1).rolling(12).mean())\n", + " df[\"trend_t\"] = df[\"t\"]\n", + " return df.dropna().reset_index(drop=True)\n", + "\n", + "\n", + "def new_model():\n", + " \"\"\"Prophet/LSTM 대신 사용하는 포아송 손실 그래디언트 부스팅(개수 자료에 적합).\"\"\"\n", + " # [TODO : 학생 실습 5] 적정 안전재고량 산출 및 결품 위험 지수(Stockout Risk) 엔진 개발 관련 핵심 로직을 구현하세요.\n", + " # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n", + " # <<< 여기에 코드를 작성하세요 >>>\n", + " pass # 작성 후 pass를 제거하세요.\n", + "def mape(y, p, min_y=1):\n", + " y, p = np.asarray(y, float), np.asarray(p, float)\n", + " m = y >= min_y\n", + " return float(np.mean(np.abs(y[m] - p[m]) / y[m]))\n", + "\n", + "# ---- 앙상블 수요 예측·안전재고·결품(단종) 위험 지수 공통 함수 ----\n", + "STATUS_W = {\"생산중\": 0.0, \"단종예고\": 0.5, \"단종\": 1.0} # [교육용가상자료] 단종 상태 가중치\n", + "\n", + "\n", + "def fit_ens(tr):\n", + " \"\"\"포아송 그래디언트 부스팅(70%) + 랜덤포레스트(30%) 앙상블.\"\"\"\n", + " h = new_model().fit(tr[FEATS], tr[\"demand\"])\n", + " r = RandomForestRegressor(n_estimators=150, min_samples_leaf=5, random_state=0, n_jobs=1).fit(tr[FEATS], tr[\"demand\"])\n", + " return h, r\n", + "\n", + "\n", + "def pred_ens(m, X, w=0.7):\n", + " return np.clip(w * m[0].predict(X[FEATS]) + (1 - w) * m[1].predict(X[FEATS]), 0, None)\n", + "\n", + "\n", + "def next_month(hist, items, t_new=48, op_mult=1.0, ex=0):\n", + " \"\"\"다음 달(t_new) 예측용 행: 작년 같은 달 운용시간 x 배수, 훈련 여부 지정.\"\"\"\n", + " last = hist[hist[\"t\"] == t_new - 1].copy()\n", + " prev = hist[hist[\"t\"] == t_new - 12].set_index(\"item_id\")[\"op_hours\"]\n", + " m = (t_new % 12) + 1\n", + " f = last.assign(t=t_new, ym=f\"{2022 + t_new // 12}-{m:02d}\", month=m, demand=0, exercise=ex,\n", + " op_hours=last[\"item_id\"].map(prev).to_numpy() * op_mult)\n", + " X = make_features(pd.concat([hist, f], ignore_index=True))\n", + " return X[X[\"t\"] == t_new].set_index(\"item_id\").loc[items[\"item_id\"]].reset_index()\n", + "\n", + "\n", + "def stock_policy(items, mu, sigma, z=1.65, review=1):\n", + " \"\"\"안전재고 = z x 월 예측오차 표준편차 x sqrt(리드타임+검토주기), 재주문점 = 월 수요 x (리드타임+검토주기) + 안전재고.\"\"\"\n", + " L = items[\"lead_time\"].to_numpy() + review\n", + " ss = z * sigma * np.sqrt(L)\n", + " return pd.DataFrame({\"item_id\": items[\"item_id\"], \"mu\": mu, \"sigma\": sigma, \"L\": L,\n", + " \"ss\": np.ceil(ss), \"rop\": np.ceil(mu * L + ss)})\n", + "\n", + "\n", + "def risk_index(items, pol, stock):\n", + " \"\"\"결품 위험 지수(0~100): 리드타임 동안 수요가 가용 재고를 넘을 확률(정규 근사)을 기본으로,\n", + " 단종 상태·단일 공급업체·대체품 부재에 가중을 준다(가상 가중치).\"\"\"\n", + " st = stock.set_index(\"item_id\").loc[items[\"item_id\"]]\n", + " avail = (st[\"on_hand\"] + st[\"on_order\"]).to_numpy()\n", + " p = 1 - norm.cdf((avail - pol[\"mu\"].to_numpy() * pol[\"L\"].to_numpy()) / (pol[\"sigma\"].to_numpy() * np.sqrt(pol[\"L\"].to_numpy()) + 1e-6))\n", + " sw = items[\"status\"].map(STATUS_W).to_numpy()\n", + " sup = (items[\"suppliers\"].to_numpy() == 1).astype(float)\n", + " alt = (~items[\"alt_available\"].to_numpy()).astype(float)\n", + " score = 100 * np.clip(0.55 * p + 0.25 * sw + 0.10 * sup + 0.10 * alt, 0, 1)\n", + " return pd.DataFrame({\"item_id\": items[\"item_id\"], \"p_short\": p, \"status_w\": sw, \"single_sup\": sup, \"no_alt\": alt,\n", + " \"avail\": avail, \"risk\": score.round(1)})\n", + "\n", + "items = make_items()\n", + "hist = make_history(items)\n", + "df = make_features(hist)\n", + "stock = make_stock(items, hist)\n", + "\n", + "# 1) Hold-out(t=42~47) 성능 비교: 기준 모델(MA3) / 단일 모델 / 앙상블. 앙상블 가중치는 t=36~41 검증 구간에서 고른다\n", + "inner = fit_ens(df[df[\"t\"] < 36]); vv = df[(df[\"t\"] >= 36) & (df[\"t\"] < 42)]\n", + "best_w = min((0.5, 0.6, 0.7, 0.8, 0.9, 1.0), key=lambda w: mean_absolute_error(vv[\"demand\"], pred_ens(inner, vv, w)))\n", + "tr, te = df[df[\"t\"] < 42], df[df[\"t\"] >= 42].copy()\n", + "ens = fit_ens(tr)\n", + "te[\"ens\"], te[\"hgb\"] = pred_ens(ens, te, best_w), np.clip(ens[0].predict(te[FEATS]), 0, None)\n", + "rows = {}\n", + "for nm, col in [(\"기준 모델(MA3)\", \"ma3\"), (\"단일 포아송 GBM\", \"hgb\"), (f\"앙상블(w={best_w})\", \"ens\")]:\n", + " rows[nm] = {\"MAE\": mean_absolute_error(te[\"demand\"], te[col]), \"RMSE\": mean_squared_error(te[\"demand\"], te[col]) ** 0.5,\n", + " \"R2\": r2_score(te[\"demand\"], te[col])}\n", + "res = pd.DataFrame(rows).T\n", + "print(\"[Hold-out 6개월(t=42~47), 품목 40개]\")\n", + "print(res.to_string(float_format=lambda x: f\"{x:.3f}\"))\n", + "b, e = res.iloc[0], res.iloc[2]\n", + "im, ir = 1 - e[\"MAE\"] / b[\"MAE\"], 1 - e[\"RMSE\"] / b[\"RMSE\"]\n", + "print(f\"오차율 개선 MAE {im:.1%}, RMSE {ir:.1%} (목표 20% 이상: {'달성' if min(im, ir) >= 0.2 else '미달'}), R2 {e['R2']:.3f} (목표 0.85 이상: {'달성' if e['R2'] >= 0.85 else '미달'})\")\n", + "spike = te[\"exercise\"] == 1\n", + "print(f\"불규칙 수요(훈련 월) MAE: 기준 {mean_absolute_error(te.loc[spike, 'demand'], te.loc[spike, 'ma3']):.2f} -> 앙상블 {mean_absolute_error(te.loc[spike, 'demand'], te.loc[spike, 'ens']):.2f}\")\n", + "\n", + "# 2) 안전재고·재주문점: 다음 달 예측과 Hold-out 품목별 오차 표준편차\n", + "X = next_month(hist, items)\n", + "mu = pred_ens(fit_ens(df), X, best_w)\n", + "err = (te[\"demand\"] - te[\"ens\"]).groupby(te[\"item_id\"]).std().reindex(items[\"item_id\"]).to_numpy()\n", + "sigma = np.maximum(err, np.sqrt(np.maximum(mu, 1))) # 오차 표준편차가 포아송 변동보다 작으면 포아송 기준 사용\n", + "pol = stock_policy(items, mu, sigma)\n", + "risk = risk_index(items, pol, stock)\n", + "out = items[[\"item_id\", \"group\", \"status\", \"lead_time\", \"suppliers\"]].merge(pol, on=\"item_id\").merge(risk, on=\"item_id\")\n", + "print(f\"\\n[다음 달(가상 2026-01) 안전재고 정책] 서비스 수준 95%(z=1.65), 안전재고 합 {out['ss'].sum():.0f}개\")\n", + "top = out.sort_values(\"risk\", ascending=False).head(8)\n", + "show = top[[\"item_id\", \"group\", \"status\", \"lead_time\", \"mu\", \"ss\", \"rop\", \"avail\", \"p_short\", \"risk\"]].copy()\n", + "show.columns = [\"품목\", \"품목군\", \"생산상태\", \"LT(월)\", \"월수요\", \"안전재고\", \"재주문점\", \"가용재고\", \"결품확률\", \"위험지수\"]\n", + "print(\"[결품 위험 지수 상위 8개]\")\n", + "print(show.to_string(index=False, float_format=lambda x: f\"{x:.2f}\"))\n", + "grade = pd.cut(out[\"risk\"], [-1, 30, 60, 101], labels=[\"관찰\", \"주의\", \"경보\"]).value_counts()\n", + "print(f\"위험 등급: 경보 {grade['경보']}개, 주의 {grade['주의']}개, 관찰 {grade['관찰']}개 / 재주문점 미만 품목 {(out['avail'] < out['rop']).sum()}개\")\n", + "print(\"단종·단종예고 품목은 리드타임이 길고 공급업체가 1곳이라 같은 결품 확률에서도 위험 지수가 높게 나온다.\")\n", + "\n", + "fig, ax = plt.subplots(1, 2, figsize=(12, 4))\n", + "ax[0].scatter(te[\"demand\"], te[\"ma3\"], s=8, alpha=0.5, label=\"MA3 baseline\"); ax[0].scatter(te[\"demand\"], te[\"ens\"], s=8, alpha=0.5, label=\"ensemble\")\n", + "mx = te[\"demand\"].max(); ax[0].plot([0, mx], [0, mx], \"k--\", lw=1); ax[0].set_xlabel(\"actual\"); ax[0].set_ylabel(\"forecast\"); ax[0].legend()\n", + "ax[0].set_title(\"Hold-out actual vs forecast\")\n", + "col = out[\"status\"].map({\"생산중\": \"tab:green\", \"단종예고\": \"tab:orange\", \"단종\": \"tab:red\"})\n", + "ax[1].scatter(out[\"p_short\"], out[\"risk\"], c=col); ax[1].set_xlabel(\"shortage probability over lead time\"); ax[1].set_ylabel(\"risk index\")\n", + "ax[1].set_title(\"Stockout risk (green=active, orange=EOL notice, red=discontinued)\")\n", + "plt.tight_layout(); plt.show()\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 05 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + "[Hold-out 6개월(t=42~47), 품목 40개]\n", + " MAE RMSE R2\n", + "기준 모델(MA3) 7.339 11.130 0.839\n", + "단일 포아송 GBM 4.572 6.112 0.952\n", + "앙상블(w=0.5) 4.541 6.059 0.952\n", + "오차율 개선 MAE 38.1%, RMSE 45.6% (목표 20% 이상: 달성), R2 0.952 (목표 0.85 이상: 달성)\n", + "불규칙 수요(훈련 월) MAE: 기준 9.88 -> 앙상블 4.94\n", + "\n", + "[다음 달(가상 2026-01) 안전재고 정책] 서비스 수준 95%(z=1.65), 안전재고 합 926개\n", + "[결품 위험 지수 상위 8개]\n", + " 품목 품목군 생산상태 LT(월) 월수요 안전재고 재주문점 가용재고 결품확률 위험지수\n", + "P018 battery 단종 4 17.48 23.00 111.00 71 0.88 93.60\n", + "P016 gasket 단종 12 148.02 95.00 2019.00 714 1.00 90.00\n", + "P028 power_train 단종 11 68.99 48.00 876.00 515 1.00 90.00\n", + "P004 filter 단종예고 6 7.65 16.00 70.00 12 1.00 87.50\n", + "P025 filter 단종예고 12 13.94 23.00 204.00 89 1.00 87.50\n", + "P030 brake_steer 단종예고 12 45.84 43.00 639.00 162 1.00 87.50\n", + "P038 fuel 단종예고 6 11.87 25.00 108.00 40 1.00 87.40\n", + "P019 filter 단종예고 8 11.47 30.00 133.00 59 0.99 87.10\n", + "위험 등급: 경보 13개, 주의 2개, 관찰 25개 / 재주문점 미만 품목 20개\n", + "단종·단종예고 품목은 리드타임이 길고 공급업체가 1곳이라 같은 결품 확률에서도 위험 지수가 높게 나온다.\n", + "```\n", + "
\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 06. 야전 부품 재고 현황 및 긴급 조달 필요 품목 가시화 대시보드 구현\n", + "**소요시간**: 약 3.0시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "야전 부품 재고 현황 및 긴급 조달 필요 품목 대시보드를 구현한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **야전 부품 재고 현황과 긴급 조달 필요 품목을 보여 주는 대시보드를 만들고 품목별 위험 원인을 설명한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **가용재고와 재주문점, 품목군·생산 상태별 위험 지수, 긴급 조달 필요 품목을 그림으로 표시 수행하고 검증하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `src/explainability/mnt02_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": [ + "\"\"\"야전 부품 재고 현황(가용재고·재주문점·결품 위험 지수)과 긴급 조달 필요 품목을 보여 주는 대시보드를 만들고, 품목마다 위험 지수를 끌어올린 요인을 기준값 대체(occlusion) 방식으로 분해해 기여도 상위 3개 인자와 자연어 설명문('리드타임 증가 원인은 단종' 등)을 자동 생성한다.\"\"\"\n", + "import numpy as np\n", + "import pandas as pd\n", + "import matplotlib.pyplot as plt\n", + "from scipy.stats import norm\n", + "from sklearn.ensemble import HistGradientBoostingRegressor, RandomForestRegressor\n", + "\n", + "# ---------------------------------------------------------------------------\n", + "# [교육용가상자료] 가상 야전 정비창 수리부속 청구/출고·장비 가동률 데이터 생성기\n", + "# 실제 부대·품목·재고·단가 정보와 무관하다. 품목 40개 x 48개월(가상 2022-01 ~ 2025-12) 월별 자료를 만든다.\n", + "# ---------------------------------------------------------------------------\n", + "FSC = {\"2520\": \"power_train\", \"2530\": \"brake_steer\", \"2910\": \"fuel\", \"2930\": \"cooling\",\n", + " \"2940\": \"filter\", \"5330\": \"gasket\", \"6140\": \"battery\", \"5961\": \"semiconductor\"}\n", + "EXERCISE = {3, 8, 10} # 가상 대규모 훈련 월(훈련 계획으로 미리 알 수 있음)\n", + "N_MONTHS = 48\n", + "\n", + "\n", + "def season(fsc, month):\n", + " \"\"\"품목군별 계절 계수: 축전지는 겨울, 냉각계통은 여름에 수요가 늘어난다.\"\"\"\n", + " # [TODO : 학생 실습 6] 야전 부품 재고 현황 및 긴급 조달 필요 품목 가시화 대시보드 구현 관련 핵심 로직을 구현하세요.\n", + " # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n", + " # <<< 여기에 코드를 작성하세요 >>>\n", + " pass # 작성 후 pass를 제거하세요.\n", + "def make_items(n=40, seed=2):\n", + " \"\"\"품목 마스터: NSN(가상), 품목군, 장비 유형, 기준 수요, 단가, 조달 리드타임, 공급업체 수, 생산(단종) 상태.\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " rows = []\n", + " for i in range(n):\n", + " f = str(rng.choice(list(FSC)))\n", + " status = str(rng.choice([\"생산중\", \"단종예고\", \"단종\"], p=[0.7, 0.2, 0.1]))\n", + " rows.append({\"item_id\": f\"P{i + 1:03d}\", \"nsn\": f\"{f}-00-{rng.integers(100, 1000)}-{rng.integers(1000, 10000)}\",\n", + " \"fsc\": f, \"group\": FSC[f], \"equip_type\": str(rng.choice([\"A\", \"B\", \"C\"])),\n", + " \"base\": float(np.round(rng.lognormal(2.7, 0.8), 1)),\n", + " \"price\": int(rng.lognormal(12, 1.0)) // 100 * 100,\n", + " \"lead_time\": int(rng.integers(1, 7) if status == \"생산중\" else rng.integers(4, 13)),\n", + " \"suppliers\": int(rng.integers(1, 5)) if status == \"생산중\" else 1,\n", + " \"status\": status, \"alt_available\": bool(rng.random() < (0.6 if status == \"생산중\" else 0.3))})\n", + " return pd.DataFrame(rows)\n", + "\n", + "\n", + "def make_history(items, months=N_MONTHS, seed=3):\n", + " \"\"\"월별 청구 수요(개), 긴급 불출 건수, 장비 유형별 운용시간·가동률.\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " t = np.arange(months)\n", + " moy = t % 12 + 1\n", + " ex = np.isin(moy, list(EXERCISE))\n", + " hours = {e: (100 + 20 * rng.random(months)) * np.where(ex, 1.5, 1.0) for e in \"ABC\"}\n", + " avail = {e: np.clip(0.93 - 0.12 * (hours[e] - 100) / 80 + rng.normal(0, 0.015, months), 0.6, 0.99) for e in \"ABC\"}\n", + " rows = []\n", + " for it in items.itertuples():\n", + " trend = 1 + rng.uniform(-0.1, 0.3) * t / months\n", + " s = np.array([season(it.fsc, m) for m in moy])\n", + " lam = it.base * s * trend * hours[it.equip_type] / 110\n", + " d = rng.poisson(lam)\n", + " rows.append(pd.DataFrame({\"item_id\": it.item_id, \"t\": t, \"ym\": [f\"{2022 + k // 12}-{k % 12 + 1:02d}\" for k in t],\n", + " \"month\": moy, \"exercise\": ex.astype(int), \"op_hours\": hours[it.equip_type].round(1),\n", + " \"availability\": avail[it.equip_type].round(3), \"demand\": d,\n", + " \"emergency\": rng.binomial(d, 0.05)}))\n", + " return pd.concat(rows, ignore_index=True)\n", + "\n", + "\n", + "def make_stock(items, hist, seed=4):\n", + " \"\"\"현재(가상 2026-01) 재고: 보유량과 미입고 발주량(조달 중인 물량). 단종 품목은 추가 발주가 어려워 미입고량이 적다.\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " recent = hist[hist[\"t\"] >= hist[\"t\"].max() - 5].groupby(\"item_id\")[\"demand\"].mean()\n", + " st = items[[\"item_id\", \"lead_time\", \"status\"]].copy()\n", + " st[\"on_hand\"] = [int(round(recent[i] * rng.uniform(0.5, 3.0))) for i in st[\"item_id\"]]\n", + " cover = np.where(st[\"status\"] == \"생산중\", rng.uniform(0.6, 1.3, len(st)), rng.uniform(0.0, 0.6, len(st)))\n", + " st[\"on_order\"] = [int(round(recent[i] * L * c)) for i, L, c in zip(st[\"item_id\"], st[\"lead_time\"], cover)]\n", + " return st[[\"item_id\", \"on_hand\", \"on_order\"]]\n", + "# ---------------------------------------------------------------------------\n", + "\n", + "# ---- 수요 예측 공통 함수(파생 피처 + 머신러닝 모델) ----\n", + "FEATS = [\"lag1\", \"lag2\", \"lag3\", \"lag12\", \"ma3\", \"ma6\", \"std6\", \"season_idx\", \"month_sin\", \"month_cos\",\n", + " \"exercise\", \"op_hours\", \"op_ratio\", \"item_level\", \"trend_t\"]\n", + "\n", + "\n", + "def make_features(hist):\n", + " \"\"\"품목별 과거 수요로 파생 피처를 만든다(모두 예측 시점 이전 정보만 사용).\"\"\"\n", + " df = hist.sort_values([\"item_id\", \"t\"]).reset_index(drop=True)\n", + " g = df.groupby(\"item_id\")[\"demand\"]\n", + " for k in (1, 2, 3, 12):\n", + " df[f\"lag{k}\"] = g.shift(k)\n", + " df[\"ma3\"] = g.transform(lambda s: s.shift(1).rolling(3).mean())\n", + " df[\"ma6\"] = g.transform(lambda s: s.shift(1).rolling(6).mean())\n", + " df[\"std6\"] = g.transform(lambda s: s.shift(1).rolling(6).std())\n", + " df[\"item_level\"] = g.transform(lambda s: s.shift(1).rolling(12).mean())\n", + " df[\"season_idx\"] = df[\"lag12\"] / (g.transform(lambda s: s.shift(12).rolling(12).mean()) + 1)\n", + " df[\"month_sin\"] = np.sin(2 * np.pi * df[\"month\"] / 12)\n", + " df[\"month_cos\"] = np.cos(2 * np.pi * df[\"month\"] / 12)\n", + " df[\"op_ratio\"] = df[\"op_hours\"] / df.groupby(\"item_id\")[\"op_hours\"].transform(lambda s: s.shift(1).rolling(12).mean())\n", + " df[\"trend_t\"] = df[\"t\"]\n", + " return df.dropna().reset_index(drop=True)\n", + "\n", + "\n", + "def new_model():\n", + " \"\"\"Prophet/LSTM 대신 사용하는 포아송 손실 그래디언트 부스팅(개수 자료에 적합).\"\"\"\n", + " # [TODO : 학생 실습 6] 야전 부품 재고 현황 및 긴급 조달 필요 품목 가시화 대시보드 구현 관련 핵심 로직을 구현하세요.\n", + " # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n", + " # <<< 여기에 코드를 작성하세요 >>>\n", + " pass # 작성 후 pass를 제거하세요.\n", + "def mape(y, p, min_y=1):\n", + " y, p = np.asarray(y, float), np.asarray(p, float)\n", + " m = y >= min_y\n", + " return float(np.mean(np.abs(y[m] - p[m]) / y[m]))\n", + "\n", + "# ---- 앙상블 수요 예측·안전재고·결품(단종) 위험 지수 공통 함수 ----\n", + "STATUS_W = {\"생산중\": 0.0, \"단종예고\": 0.5, \"단종\": 1.0} # [교육용가상자료] 단종 상태 가중치\n", + "\n", + "\n", + "def fit_ens(tr):\n", + " \"\"\"포아송 그래디언트 부스팅(70%) + 랜덤포레스트(30%) 앙상블.\"\"\"\n", + " h = new_model().fit(tr[FEATS], tr[\"demand\"])\n", + " r = RandomForestRegressor(n_estimators=150, min_samples_leaf=5, random_state=0, n_jobs=1).fit(tr[FEATS], tr[\"demand\"])\n", + " return h, r\n", + "\n", + "\n", + "def pred_ens(m, X, w=0.7):\n", + " return np.clip(w * m[0].predict(X[FEATS]) + (1 - w) * m[1].predict(X[FEATS]), 0, None)\n", + "\n", + "\n", + "def next_month(hist, items, t_new=48, op_mult=1.0, ex=0):\n", + " \"\"\"다음 달(t_new) 예측용 행: 작년 같은 달 운용시간 x 배수, 훈련 여부 지정.\"\"\"\n", + " last = hist[hist[\"t\"] == t_new - 1].copy()\n", + " prev = hist[hist[\"t\"] == t_new - 12].set_index(\"item_id\")[\"op_hours\"]\n", + " m = (t_new % 12) + 1\n", + " f = last.assign(t=t_new, ym=f\"{2022 + t_new // 12}-{m:02d}\", month=m, demand=0, exercise=ex,\n", + " op_hours=last[\"item_id\"].map(prev).to_numpy() * op_mult)\n", + " X = make_features(pd.concat([hist, f], ignore_index=True))\n", + " return X[X[\"t\"] == t_new].set_index(\"item_id\").loc[items[\"item_id\"]].reset_index()\n", + "\n", + "\n", + "def stock_policy(items, mu, sigma, z=1.65, review=1):\n", + " \"\"\"안전재고 = z x 월 예측오차 표준편차 x sqrt(리드타임+검토주기), 재주문점 = 월 수요 x (리드타임+검토주기) + 안전재고.\"\"\"\n", + " L = items[\"lead_time\"].to_numpy() + review\n", + " ss = z * sigma * np.sqrt(L)\n", + " return pd.DataFrame({\"item_id\": items[\"item_id\"], \"mu\": mu, \"sigma\": sigma, \"L\": L,\n", + " \"ss\": np.ceil(ss), \"rop\": np.ceil(mu * L + ss)})\n", + "\n", + "\n", + "def risk_index(items, pol, stock):\n", + " \"\"\"결품 위험 지수(0~100): 리드타임 동안 수요가 가용 재고를 넘을 확률(정규 근사)을 기본으로,\n", + " 단종 상태·단일 공급업체·대체품 부재에 가중을 준다(가상 가중치).\"\"\"\n", + " st = stock.set_index(\"item_id\").loc[items[\"item_id\"]]\n", + " avail = (st[\"on_hand\"] + st[\"on_order\"]).to_numpy()\n", + " p = 1 - norm.cdf((avail - pol[\"mu\"].to_numpy() * pol[\"L\"].to_numpy()) / (pol[\"sigma\"].to_numpy() * np.sqrt(pol[\"L\"].to_numpy()) + 1e-6))\n", + " sw = items[\"status\"].map(STATUS_W).to_numpy()\n", + " sup = (items[\"suppliers\"].to_numpy() == 1).astype(float)\n", + " alt = (~items[\"alt_available\"].to_numpy()).astype(float)\n", + " score = 100 * np.clip(0.55 * p + 0.25 * sw + 0.10 * sup + 0.10 * alt, 0, 1)\n", + " return pd.DataFrame({\"item_id\": items[\"item_id\"], \"p_short\": p, \"status_w\": sw, \"single_sup\": sup, \"no_alt\": alt,\n", + " \"avail\": avail, \"risk\": score.round(1)})\n", + "\n", + "items = make_items()\n", + "hist = make_history(items)\n", + "df = make_features(hist)\n", + "stock = make_stock(items, hist)\n", + "X = next_month(hist, items)\n", + "mu = pred_ens(fit_ens(df), X)\n", + "level = X[\"item_level\"].to_numpy() # 최근 12개월 평균 수요(평시 수준)\n", + "sigma = np.maximum(0.25 * mu, np.sqrt(np.maximum(mu, 1)))\n", + "\n", + "\n", + "def score(it, m, s, st):\n", + " return risk_index(it, stock_policy(it, m, s), st)[\"risk\"].to_numpy()\n", + "\n", + "\n", + "base = score(items, mu, sigma, stock)\n", + "pol = stock_policy(items, mu, sigma)\n", + "# 각 요인을 '문제 없는 기준값'으로 바꿨을 때 위험 지수가 얼마나 내려가는지 = 그 요인의 기여도\n", + "ref_lt = int(items.loc[items[\"status\"] == \"생산중\", \"lead_time\"].median())\n", + "st_ok = stock.copy(); st_ok[\"on_hand\"] = np.maximum(st_ok[\"on_hand\"], (pol[\"rop\"] - st_ok[\"on_order\"]).clip(lower=0).astype(int).to_numpy())\n", + "FACTORS = {\n", + " \"조달 리드타임 장기화\": lambda: score(items.assign(lead_time=np.minimum(items[\"lead_time\"], ref_lt)), mu, sigma, stock),\n", + " \"수요 증가 예측\": lambda: score(items, np.minimum(mu, level), sigma, stock),\n", + " \"가용재고 부족\": lambda: score(items, mu, sigma, st_ok),\n", + " \"예측 불확실성\": lambda: score(items, mu, np.sqrt(np.maximum(mu, 1)), stock),\n", + " \"단종(예고) 상태\": lambda: score(items.assign(status=\"생산중\"), mu, sigma, stock),\n", + " \"단일 공급업체\": lambda: score(items.assign(suppliers=np.maximum(items[\"suppliers\"], 2)), mu, sigma, stock),\n", + " \"대체품 부재\": lambda: score(items.assign(alt_available=True), mu, sigma, stock),\n", + "}\n", + "C = pd.DataFrame({k: base - f() for k, f in FACTORS.items()}, index=items[\"item_id\"]).clip(lower=0)\n", + "top3 = C.apply(lambda r: list(r.sort_values(ascending=False).index[:3]), axis=1)\n", + "cover = np.mean([len(t) == 3 for t in top3])\n", + "print(f\"[품목 {len(items)}개] 위험 지수 기여도 상위 3개 인자 자동 산출률 {cover:.0%} (목표 100%: {'달성' if cover == 1 else '미달'})\")\n", + "\n", + "dash = items[[\"item_id\", \"group\", \"status\", \"lead_time\"]].assign(risk=base, avail=(stock[\"on_hand\"] + stock[\"on_order\"]).to_numpy(),\n", + " rop=pol[\"rop\"].to_numpy(), mu=mu.round(1))\n", + "dash[\"urgent\"] = (dash[\"risk\"] >= 60) & (dash[\"avail\"] < dash[\"rop\"])\n", + "CAUSE = {\"조달 리드타임 장기화\": \"조달 리드타임이 길다\", \"단종(예고) 상태\": \"생산 중단(예고)으로 추가 조달이 어렵다\",\n", + " \"가용재고 부족\": \"보유·미입고 재고가 재주문점보다 적다\", \"수요 증가 예측\": \"다음 달 수요가 평시보다 늘 것으로 예측된다\",\n", + " \"예측 불확실성\": \"수요 변동이 커 안전재고가 많이 필요하다\", \"단일 공급업체\": \"공급업체가 1곳뿐이다\", \"대체품 부재\": \"대체품이 없다\"}\n", + "\n", + "\n", + "def explain(iid):\n", + " r = dash.set_index(\"item_id\").loc[iid]\n", + " f = top3[iid]\n", + " parts = \", \".join(f\"{k}(+{C.loc[iid, k]:.0f})\" for k in f)\n", + " return f\"{iid}({r['group']}, {r['status']}) 위험 {r['risk']:.0f}: {parts}. 주된 원인은 {CAUSE[f[0]]}.\"\n", + "\n", + "\n", + "urg = dash[dash[\"urgent\"]].sort_values(\"risk\", ascending=False)\n", + "print(f\"\\n[긴급 조달 필요 품목 {len(urg)}개(위험 60 이상 & 가용재고<재주문점)] 자연어 설명\")\n", + "for iid in urg[\"item_id\"].head(7):\n", + " print(\" -\", explain(iid))\n", + "print(\"\\n[요인별 평균 기여도]\", \", \".join(f\"{k} {v:.1f}\" for k, v in C.mean().sort_values(ascending=False).items()))\n", + "g = dash.groupby(\"status\")[\"risk\"].agg([\"count\", \"mean\"]).round(1)\n", + "print(\"[생산 상태별 평균 위험 지수]\", \", \".join(f\"{s} {r['mean']}({int(r['count'])}개)\" for s, r in g.iterrows()))\n", + "print(\"설명은 조달·대체품 검토의 근거 자료이며 발주·대체 결정은 정비장교가 확인 후 내린다.\")\n", + "\n", + "fig, ax = plt.subplots(1, 3, figsize=(17, 4.8))\n", + "d15 = dash.sort_values(\"risk\", ascending=False).head(15)\n", + "ax[0].barh(d15[\"item_id\"], d15[\"avail\"], color=np.where(d15[\"urgent\"], \"tab:red\", \"tab:gray\"), label=\"available\")\n", + "ax[0].scatter(d15[\"rop\"], d15[\"item_id\"], c=\"k\", marker=\"|\", s=200, label=\"reorder point\"); ax[0].invert_yaxis()\n", + "ax[0].set_xscale(\"symlog\"); ax[0].set_title(\"Top-15 risk items: stock vs reorder point (red=urgent)\"); ax[0].legend()\n", + "hm = dash.pivot_table(index=\"group\", columns=\"status\", values=\"risk\", aggfunc=\"mean\")\n", + "im = ax[1].imshow(hm.values, cmap=\"Reds\", vmin=0, vmax=100, aspect=\"auto\"); plt.colorbar(im, ax=ax[1])\n", + "ax[1].set_xticks(range(hm.shape[1])); ax[1].set_xticklabels([\"active\" if c == \"생산중\" else \"EOL notice\" if c == \"단종예고\" else \"discontinued\" for c in hm.columns])\n", + "ax[1].set_yticks(range(len(hm))); ax[1].set_yticklabels(hm.index); ax[1].set_title(\"Mean risk index by group/status\")\n", + "i0 = urg[\"item_id\"].iloc[0]; w = C.loc[i0].sort_values()\n", + "ax[2].barh(range(len(w)), w.values, color=\"tab:red\"); ax[2].set_yticks(range(len(w)))\n", + "EN = {\"조달 리드타임 장기화\": \"lead time\", \"수요 증가 예측\": \"demand up\", \"가용재고 부족\": \"low stock\", \"예측 불확실성\": \"uncertainty\",\n", + " \"단종(예고) 상태\": \"EOL status\", \"단일 공급업체\": \"single supplier\", \"대체품 부재\": \"no alternate\"}\n", + "ax[2].set_yticklabels([EN[k] for k in w.index])\n", + "ax[2].set_title(f\"Risk contribution: {i0}\")\n", + "plt.tight_layout(); plt.show()\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 06 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + "[품목 40개] 위험 지수 기여도 상위 3개 인자 자동 산출률 100% (목표 100%: 달성)\n", + "\n", + "[긴급 조달 필요 품목 13개(위험 60 이상 & 가용재고<재주문점)] 자연어 설명\n", + " - P018(battery, 단종) 위험 96: 가용재고 부족(+49), 단종(예고) 상태(+25), 단일 공급업체(+10). 주된 원인은 보유·미입고 재고가 재주문점보다 적다.\n", + " - P028(power_train, 단종) 위험 90: 조달 리드타임 장기화(+55), 가용재고 부족(+52), 단종(예고) 상태(+25). 주된 원인은 조달 리드타임이 길다.\n", + " - P016(gasket, 단종) 위험 90: 가용재고 부족(+52), 단종(예고) 상태(+25), 단일 공급업체(+10). 주된 원인은 보유·미입고 재고가 재주문점보다 적다.\n", + " - P004(filter, 단종예고) 위험 88: 가용재고 부족(+53), 단종(예고) 상태(+12), 단일 공급업체(+10). 주된 원인은 보유·미입고 재고가 재주문점보다 적다.\n", + " - P025(filter, 단종예고) 위험 88: 가용재고 부족(+53), 조달 리드타임 장기화(+39), 단종(예고) 상태(+12). 주된 원인은 보유·미입고 재고가 재주문점보다 적다.\n", + " - P038(fuel, 단종예고) 위험 88: 가용재고 부족(+52), 단종(예고) 상태(+12), 단일 공급업체(+10). 주된 원인은 보유·미입고 재고가 재주문점보다 적다.\n", + " - P030(brake_steer, 단종예고) 위험 88: 가용재고 부족(+52), 단종(예고) 상태(+12), 단일 공급업체(+10). 주된 원인은 보유·미입고 재고가 재주문점보다 적다.\n", + "\n", + "[요인별 평균 기여도] 가용재고 부족 19.5, 단일 공급업체 4.8, 조달 리드타임 장기화 4.5, 단종(예고) 상태 4.4, 대체품 부재 3.8, 수요 증가 예측 0.3, 예측 불확실성 0.3\n", + "[생산 상태별 평균 위험 지수] 단종 92.0(3개), 단종예고 84.5(8개), 생산중 13.7(29개)\n", + "설명은 조달·대체품 검토의 근거 자료이며 발주·대체 결정은 정비장교가 확인 후 내린다.\n", + "```\n", + "
\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 07. 부품 발주량 최종 확정을 위한 정비장교 HITL 승인/조정 워크플로우 설계\n", + "**소요시간**: 약 3.5시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "부품 발주량 최종 확정을 위한 정비장교 HITL 승인 워크플로우를 설계한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **예측 기반 발주 정책과 단종 대체품 전환의 효과를 백테스트로 검증하고, 발주량을 정비장교가 최종 확정하는 HITL 승인·조정 워크플로우를 설계한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **12개월 재고 백테스트로 현행(최근 평균+고정 여유) 정책, 예측 정책, 예측+단종 대체품 전환을 보관비·발주비·결품손실·충족률·회전율로 비교하고 효과를 분해 수행하고 검증하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `src/optimization/mnt02_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": [ + "\"\"\"예측 기반 발주 정책(안전재고 포함 목표재고)과 단종 품목 대체품 전환을 12개월 재고 백테스트로 현행(최근 평균+고정 여유) 정책과 비교해 비용·재고 회전율·충족률을 산출하고, 다음 달 발주량을 예산 제약 선형계획(LP)으로 추천한 뒤 정비장교가 승인·조정·반려·보류로 최종 확정하는 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 scipy.stats import norm\n", + "from scipy.optimize import linprog\n", + "from sklearn.ensemble import HistGradientBoostingRegressor, RandomForestRegressor\n", + "\n", + "# ---------------------------------------------------------------------------\n", + "# [교육용가상자료] 가상 야전 정비창 수리부속 청구/출고·장비 가동률 데이터 생성기\n", + "# 실제 부대·품목·재고·단가 정보와 무관하다. 품목 40개 x 48개월(가상 2022-01 ~ 2025-12) 월별 자료를 만든다.\n", + "# ---------------------------------------------------------------------------\n", + "FSC = {\"2520\": \"power_train\", \"2530\": \"brake_steer\", \"2910\": \"fuel\", \"2930\": \"cooling\",\n", + " \"2940\": \"filter\", \"5330\": \"gasket\", \"6140\": \"battery\", \"5961\": \"semiconductor\"}\n", + "EXERCISE = {3, 8, 10} # 가상 대규모 훈련 월(훈련 계획으로 미리 알 수 있음)\n", + "N_MONTHS = 48\n", + "\n", + "\n", + "def season(fsc, month):\n", + " \"\"\"품목군별 계절 계수: 축전지는 겨울, 냉각계통은 여름에 수요가 늘어난다.\"\"\"\n", + " # [TODO : 학생 실습 7] 부품 발주량 최종 확정을 위한 정비장교 HITL 승인/조정 워크플로우 설계 관련 핵심 로직을 구현하세요.\n", + " # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n", + " # <<< 여기에 코드를 작성하세요 >>>\n", + " pass # 작성 후 pass를 제거하세요.\n", + "def make_items(n=40, seed=2):\n", + " \"\"\"품목 마스터: NSN(가상), 품목군, 장비 유형, 기준 수요, 단가, 조달 리드타임, 공급업체 수, 생산(단종) 상태.\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " rows = []\n", + " for i in range(n):\n", + " f = str(rng.choice(list(FSC)))\n", + " status = str(rng.choice([\"생산중\", \"단종예고\", \"단종\"], p=[0.7, 0.2, 0.1]))\n", + " rows.append({\"item_id\": f\"P{i + 1:03d}\", \"nsn\": f\"{f}-00-{rng.integers(100, 1000)}-{rng.integers(1000, 10000)}\",\n", + " \"fsc\": f, \"group\": FSC[f], \"equip_type\": str(rng.choice([\"A\", \"B\", \"C\"])),\n", + " \"base\": float(np.round(rng.lognormal(2.7, 0.8), 1)),\n", + " \"price\": int(rng.lognormal(12, 1.0)) // 100 * 100,\n", + " \"lead_time\": int(rng.integers(1, 7) if status == \"생산중\" else rng.integers(4, 13)),\n", + " \"suppliers\": int(rng.integers(1, 5)) if status == \"생산중\" else 1,\n", + " \"status\": status, \"alt_available\": bool(rng.random() < (0.6 if status == \"생산중\" else 0.3))})\n", + " return pd.DataFrame(rows)\n", + "\n", + "\n", + "def make_history(items, months=N_MONTHS, seed=3):\n", + " \"\"\"월별 청구 수요(개), 긴급 불출 건수, 장비 유형별 운용시간·가동률.\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " t = np.arange(months)\n", + " moy = t % 12 + 1\n", + " ex = np.isin(moy, list(EXERCISE))\n", + " hours = {e: (100 + 20 * rng.random(months)) * np.where(ex, 1.5, 1.0) for e in \"ABC\"}\n", + " avail = {e: np.clip(0.93 - 0.12 * (hours[e] - 100) / 80 + rng.normal(0, 0.015, months), 0.6, 0.99) for e in \"ABC\"}\n", + " rows = []\n", + " for it in items.itertuples():\n", + " trend = 1 + rng.uniform(-0.1, 0.3) * t / months\n", + " s = np.array([season(it.fsc, m) for m in moy])\n", + " lam = it.base * s * trend * hours[it.equip_type] / 110\n", + " d = rng.poisson(lam)\n", + " rows.append(pd.DataFrame({\"item_id\": it.item_id, \"t\": t, \"ym\": [f\"{2022 + k // 12}-{k % 12 + 1:02d}\" for k in t],\n", + " \"month\": moy, \"exercise\": ex.astype(int), \"op_hours\": hours[it.equip_type].round(1),\n", + " \"availability\": avail[it.equip_type].round(3), \"demand\": d,\n", + " \"emergency\": rng.binomial(d, 0.05)}))\n", + " return pd.concat(rows, ignore_index=True)\n", + "\n", + "\n", + "def make_stock(items, hist, seed=4):\n", + " \"\"\"현재(가상 2026-01) 재고: 보유량과 미입고 발주량(조달 중인 물량). 단종 품목은 추가 발주가 어려워 미입고량이 적다.\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " recent = hist[hist[\"t\"] >= hist[\"t\"].max() - 5].groupby(\"item_id\")[\"demand\"].mean()\n", + " st = items[[\"item_id\", \"lead_time\", \"status\"]].copy()\n", + " st[\"on_hand\"] = [int(round(recent[i] * rng.uniform(0.5, 3.0))) for i in st[\"item_id\"]]\n", + " cover = np.where(st[\"status\"] == \"생산중\", rng.uniform(0.6, 1.3, len(st)), rng.uniform(0.0, 0.6, len(st)))\n", + " st[\"on_order\"] = [int(round(recent[i] * L * c)) for i, L, c in zip(st[\"item_id\"], st[\"lead_time\"], cover)]\n", + " return st[[\"item_id\", \"on_hand\", \"on_order\"]]\n", + "# ---------------------------------------------------------------------------\n", + "\n", + "# ---- 수요 예측 공통 함수(파생 피처 + 머신러닝 모델) ----\n", + "FEATS = [\"lag1\", \"lag2\", \"lag3\", \"lag12\", \"ma3\", \"ma6\", \"std6\", \"season_idx\", \"month_sin\", \"month_cos\",\n", + " \"exercise\", \"op_hours\", \"op_ratio\", \"item_level\", \"trend_t\"]\n", + "\n", + "\n", + "def make_features(hist):\n", + " \"\"\"품목별 과거 수요로 파생 피처를 만든다(모두 예측 시점 이전 정보만 사용).\"\"\"\n", + " df = hist.sort_values([\"item_id\", \"t\"]).reset_index(drop=True)\n", + " g = df.groupby(\"item_id\")[\"demand\"]\n", + " for k in (1, 2, 3, 12):\n", + " df[f\"lag{k}\"] = g.shift(k)\n", + " df[\"ma3\"] = g.transform(lambda s: s.shift(1).rolling(3).mean())\n", + " df[\"ma6\"] = g.transform(lambda s: s.shift(1).rolling(6).mean())\n", + " df[\"std6\"] = g.transform(lambda s: s.shift(1).rolling(6).std())\n", + " df[\"item_level\"] = g.transform(lambda s: s.shift(1).rolling(12).mean())\n", + " df[\"season_idx\"] = df[\"lag12\"] / (g.transform(lambda s: s.shift(12).rolling(12).mean()) + 1)\n", + " df[\"month_sin\"] = np.sin(2 * np.pi * df[\"month\"] / 12)\n", + " df[\"month_cos\"] = np.cos(2 * np.pi * df[\"month\"] / 12)\n", + " df[\"op_ratio\"] = df[\"op_hours\"] / df.groupby(\"item_id\")[\"op_hours\"].transform(lambda s: s.shift(1).rolling(12).mean())\n", + " df[\"trend_t\"] = df[\"t\"]\n", + " return df.dropna().reset_index(drop=True)\n", + "\n", + "\n", + "def new_model():\n", + " \"\"\"Prophet/LSTM 대신 사용하는 포아송 손실 그래디언트 부스팅(개수 자료에 적합).\"\"\"\n", + " # [TODO : 학생 실습 7] 부품 발주량 최종 확정을 위한 정비장교 HITL 승인/조정 워크플로우 설계 관련 핵심 로직을 구현하세요.\n", + " # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n", + " # <<< 여기에 코드를 작성하세요 >>>\n", + " pass # 작성 후 pass를 제거하세요.\n", + "def mape(y, p, min_y=1):\n", + " y, p = np.asarray(y, float), np.asarray(p, float)\n", + " m = y >= min_y\n", + " return float(np.mean(np.abs(y[m] - p[m]) / y[m]))\n", + "\n", + "# ---- 앙상블 수요 예측·안전재고·결품(단종) 위험 지수 공통 함수 ----\n", + "STATUS_W = {\"생산중\": 0.0, \"단종예고\": 0.5, \"단종\": 1.0} # [교육용가상자료] 단종 상태 가중치\n", + "\n", + "\n", + "def fit_ens(tr):\n", + " \"\"\"포아송 그래디언트 부스팅(70%) + 랜덤포레스트(30%) 앙상블.\"\"\"\n", + " h = new_model().fit(tr[FEATS], tr[\"demand\"])\n", + " r = RandomForestRegressor(n_estimators=150, min_samples_leaf=5, random_state=0, n_jobs=1).fit(tr[FEATS], tr[\"demand\"])\n", + " return h, r\n", + "\n", + "\n", + "def pred_ens(m, X, w=0.7):\n", + " return np.clip(w * m[0].predict(X[FEATS]) + (1 - w) * m[1].predict(X[FEATS]), 0, None)\n", + "\n", + "\n", + "def next_month(hist, items, t_new=48, op_mult=1.0, ex=0):\n", + " \"\"\"다음 달(t_new) 예측용 행: 작년 같은 달 운용시간 x 배수, 훈련 여부 지정.\"\"\"\n", + " last = hist[hist[\"t\"] == t_new - 1].copy()\n", + " prev = hist[hist[\"t\"] == t_new - 12].set_index(\"item_id\")[\"op_hours\"]\n", + " m = (t_new % 12) + 1\n", + " f = last.assign(t=t_new, ym=f\"{2022 + t_new // 12}-{m:02d}\", month=m, demand=0, exercise=ex,\n", + " op_hours=last[\"item_id\"].map(prev).to_numpy() * op_mult)\n", + " X = make_features(pd.concat([hist, f], ignore_index=True))\n", + " return X[X[\"t\"] == t_new].set_index(\"item_id\").loc[items[\"item_id\"]].reset_index()\n", + "\n", + "\n", + "def stock_policy(items, mu, sigma, z=1.65, review=1):\n", + " \"\"\"안전재고 = z x 월 예측오차 표준편차 x sqrt(리드타임+검토주기), 재주문점 = 월 수요 x (리드타임+검토주기) + 안전재고.\"\"\"\n", + " L = items[\"lead_time\"].to_numpy() + review\n", + " ss = z * sigma * np.sqrt(L)\n", + " return pd.DataFrame({\"item_id\": items[\"item_id\"], \"mu\": mu, \"sigma\": sigma, \"L\": L,\n", + " \"ss\": np.ceil(ss), \"rop\": np.ceil(mu * L + ss)})\n", + "\n", + "\n", + "def risk_index(items, pol, stock):\n", + " \"\"\"결품 위험 지수(0~100): 리드타임 동안 수요가 가용 재고를 넘을 확률(정규 근사)을 기본으로,\n", + " 단종 상태·단일 공급업체·대체품 부재에 가중을 준다(가상 가중치).\"\"\"\n", + " st = stock.set_index(\"item_id\").loc[items[\"item_id\"]]\n", + " avail = (st[\"on_hand\"] + st[\"on_order\"]).to_numpy()\n", + " p = 1 - norm.cdf((avail - pol[\"mu\"].to_numpy() * pol[\"L\"].to_numpy()) / (pol[\"sigma\"].to_numpy() * np.sqrt(pol[\"L\"].to_numpy()) + 1e-6))\n", + " sw = items[\"status\"].map(STATUS_W).to_numpy()\n", + " sup = (items[\"suppliers\"].to_numpy() == 1).astype(float)\n", + " alt = (~items[\"alt_available\"].to_numpy()).astype(float)\n", + " score = 100 * np.clip(0.55 * p + 0.25 * sw + 0.10 * sup + 0.10 * alt, 0, 1)\n", + " return pd.DataFrame({\"item_id\": items[\"item_id\"], \"p_short\": p, \"status_w\": sw, \"single_sup\": sup, \"no_alt\": alt,\n", + " \"avail\": avail, \"risk\": score.round(1)})\n", + "\n", + "# ---- 월 단위 재고 백테스트(가상 비용 지수) ----\n", + "HOLD, ORDER_COST, SHORT_MULT = 0.02, 50_000, 0.6 # [교육용가상자료] 월 보관비율(단가 대비), 발주 1회 비용(원), 결품 1개 손실(단가 배수)\n", + "\n", + "\n", + "ALT_PREMIUM, ALT_LT = 1.2, 3 # [교육용가상자료] 대체품 전환 시 단가 배수, 대체품 조달 리드타임(월)\n", + "\n", + "\n", + "def simulate(items, demand, fc, policy, z=1.65, months=None, sigma=None, eol=True):\n", + " \"\"\"품목별 월 재고 모의. demand/fc: (품목 x 월) 실제·예측 수요. policy: 'current'(최근 12개월 평균 + 3개월 여유 고정) 또는 'ai'(예측 + 안전재고).\n", + " 단종 품목은 원 품목 발주 불가. 'ai' 정책에서 eol=True이면 단종 품목 중 대체품이 있는 품목은 대체품(단가 x1.2, 리드타임 3개월)으로 전환 발주한다. 반환: 품목별 보관비·발주비·결품손실·불출량·평균재고.\"\"\"\n", + " n, T = demand.shape\n", + " L = items[\"lead_time\"].to_numpy(); price = items[\"price\"].to_numpy().astype(float)\n", + " can_order = (items[\"status\"] != \"단종\").to_numpy()\n", + " if policy == \"ai\" and eol:\n", + " sw = (~can_order) & items[\"alt_available\"].to_numpy()\n", + " can_order = can_order | sw\n", + " L = np.where(sw, ALT_LT, L); price = np.where(sw, price * ALT_PREMIUM, price)\n", + " avg = demand[:, :1].repeat(T, 1) if months is None else months\n", + " on = np.round(avg[:, 0] * 2.0)\n", + " pipe = np.zeros((n, T + int(L.max()) + 1)) # 도착 예정 물량(품목 x 월)\n", + " for i in range(n):\n", + " pipe[i, :L[i]] = np.round(avg[i, 0] * (1.0 if can_order[i] else 0.3))\n", + " res = np.zeros((n, 6)); rows = np.arange(n)\n", + " for t in range(T):\n", + " on += pipe[:, t]\n", + " issue = np.minimum(on, demand[:, t]); short = demand[:, t] - issue; on -= issue\n", + " ip = on + pipe[:, t + 1:].sum(1)\n", + " if policy == \"current\":\n", + " target = avg[:, t] * (L + 3)\n", + " else:\n", + " s = sigma if sigma is not None else np.sqrt(np.maximum(fc[:, t], 1))\n", + " target = fc[:, t] * (L + 1) + z * s * np.sqrt(L + 1)\n", + " q = np.where(can_order & (ip < target), np.ceil(target - ip), 0)\n", + " pipe[rows, t + L] += q\n", + " res += np.c_[HOLD * price * on, ORDER_COST * (q > 0), SHORT_MULT * price * short, issue, on, short]\n", + " out = pd.DataFrame(res, columns=[\"hold\", \"order\", \"short\", \"issued\", \"inv\", \"short_qty\"], index=items[\"item_id\"])\n", + " out[\"inv\"] /= T\n", + " return out\n", + "\n", + "items = make_items()\n", + "hist = make_history(items)\n", + "df = make_features(hist)\n", + "\n", + "# 1) 백테스트(t=36~47, 12개월): 한 달 앞 예측은 t<36으로 학습한 앙상블 사용\n", + "m = fit_ens(df[df[\"t\"] < 36])\n", + "bt = df[df[\"t\"] >= 36].copy(); bt[\"fc\"] = pred_ens(m, bt)\n", + "P = lambda c: bt.pivot(index=\"item_id\", columns=\"t\", values=c).loc[items[\"item_id\"]].to_numpy()\n", + "D, F, A = P(\"demand\"), P(\"fc\"), P(\"item_level\")\n", + "sig = np.maximum(0.25 * F.mean(1), np.sqrt(np.maximum(F.mean(1), 1)))\n", + "\n", + "\n", + "def summary(r, dem):\n", + " cost = r[[\"hold\", \"order\", \"short\"]].sum()\n", + " return {\"보관비\": cost[\"hold\"] / 1e6, \"발주비\": cost[\"order\"] / 1e6, \"결품손실\": cost[\"short\"] / 1e6, \"총비용(백만)\": cost.sum() / 1e6,\n", + " \"충족률\": 1 - r[\"short_qty\"].sum() / dem.sum(), \"회전율\": r[\"issued\"].sum() / r[\"inv\"].sum()}\n", + "\n", + "\n", + "runs = {\"현행(평균+3개월 여유)\": simulate(items, D, F, \"current\", months=A),\n", + " \"예측 정책(z=1.65)\": simulate(items, D, F, \"ai\", 1.65, A, sig, eol=False),\n", + " \"예측+단종 대체품 전환\": simulate(items, D, F, \"ai\", 1.65, A, sig, eol=True)}\n", + "tab = pd.DataFrame({k: summary(v, D) for k, v in runs.items()}).T\n", + "print(\"[12개월 재고 백테스트(가상 비용 지수, 백만 원)]\")\n", + "print(tab.to_string(formatters={\"충족률\": \"{:.1%}\".format, \"회전율\": \"{:.2f}\".format}, float_format=lambda x: f\"{x:.1f}\"))\n", + "c0, c2 = tab.iloc[0][\"총비용(백만)\"], tab.iloc[2][\"총비용(백만)\"]\n", + "t0, t1 = tab.iloc[0][\"회전율\"], tab.iloc[1][\"회전율\"]\n", + "print(f\"총비용 절감 {1 - c2 / c0:.1%}, 예측 정책만의 회전율 향상 {t1 / t0 - 1:.1%} (목표: 비용 15% 절감 또는 회전율 20% 향상 -> {'달성' if (1 - c2 / c0) >= 0.15 or t1 / t0 - 1 >= 0.2 else '미달'})\")\n", + "act = items[\"status\"] != \"단종\"\n", + "a0, a1 = runs[\"현행(평균+3개월 여유)\"][act.to_numpy()], runs[\"예측 정책(z=1.65)\"][act.to_numpy()]\n", + "print(f\"효과 분해: 생산중·단종예고 품목 비용 {1 - a1[['hold', 'order', 'short']].sum().sum() / a0[['hold', 'order', 'short']].sum().sum():.1%} 절감(예측 정책), 나머지는 단종 품목 대체품 전환 효과\")\n", + "\n", + "# 민감도: 서비스 수준(z)과 돌발 수요(최근 6개월 수요 1.5배, 가상 전시 전환)\n", + "D2 = D.copy(); D2[:, 6:] = np.round(D2[:, 6:] * 1.5)\n", + "sens = []\n", + "for z in (1.28, 1.65, 2.05):\n", + " for nm, dd in ((\"평시\", D), (\"돌발 1.5배\", D2)):\n", + " r = summary(simulate(items, dd, F, \"ai\", z, A, sig), dd)\n", + " sens.append({\"z\": z, \"수요\": nm, \"총비용\": r[\"총비용(백만)\"], \"충족률\": r[\"충족률\"]})\n", + "print(\"[민감도] \" + \" / \".join(f\"z={s['z']} {s['수요']}: {s['총비용']:.0f}백만, 충족 {s['충족률']:.1%}\" for s in sens))\n", + "\n", + "\n", + "# 2) 다음 달 발주 추천: 예산 제약 LP (위험 지수 가중 부족분 충족 최대화)\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", + "stock = make_stock(items, hist)\n", + "X = next_month(hist, items)\n", + "mu = pred_ens(fit_ens(df), X)\n", + "sg = np.maximum(0.25 * mu, np.sqrt(np.maximum(mu, 1)))\n", + "alt_sw = (items[\"status\"] == \"단종\") & items[\"alt_available\"]\n", + "it2 = items.assign(lead_time=np.where(alt_sw, ALT_LT, items[\"lead_time\"]), price=np.where(alt_sw, items[\"price\"] * ALT_PREMIUM, items[\"price\"]))\n", + "pol = stock_policy(it2, mu, sg)\n", + "risk = risk_index(items, pol, stock)[\"risk\"].to_numpy()\n", + "ip = (stock[\"on_hand\"] + stock[\"on_order\"]).to_numpy()\n", + "orderable = (items[\"status\"] != \"단종\").to_numpy() | alt_sw.to_numpy()\n", + "gap = np.where(orderable, np.maximum(0, np.ceil(pol[\"rop\"].to_numpy() - ip)), 0)\n", + "price = it2[\"price\"].to_numpy().astype(float)\n", + "BUDGET = 0.6 * float((gap * price).sum())\n", + "idx = np.where(gap > 0)[0]\n", + "lp = linprog(c=-(risk[idx] + 10) / gap[idx], A_ub=[price[idx]], b_ub=[BUDGET], bounds=[(0, g) for g in gap[idx]], method=\"highs\")\n", + "q = np.zeros(len(items)); q[idx] = np.floor(lp.x)\n", + "print(f\"\\n[다음 달 발주 추천 LP] 부족 품목 {len(idx)}개, 예산 {BUDGET / 1e6:.1f}백만(부족분 전액의 60%, 가상) 사용 {np.dot(q, price) / 1e6:.1f}백만, 추천 {int((q > 0).sum())}건\")\n", + "\n", + "audit, rows = Audit(), []\n", + "for k, i in enumerate(np.where(q > 0)[0]):\n", + " iid = items.loc[i, \"item_id\"]; kind = \"대체품 전환\" if alt_sw[i] else \"정규 발주\"\n", + " audit.add(t=\"추천\", item=iid, kind=kind, qty=int(q[i]), risk=float(risk[i]), status=\"결재대기\", by=\"AI\")\n", + " # 가상 정비장교 판단(시뮬레이션): 근거 확인 후 승인·조정·반려, 일부는 시한 내 무응답(보류=발주하지 않음)\n", + " if kind == \"대체품 전환\":\n", + " dec, final, why = \"반려\", 0, \"대체품 호환성 시험 결과 확인 후 재상신\"\n", + " elif q[i] * price[i] > 0.15 * BUDGET:\n", + " dec, final, why = \"조정\", int(q[i] * 0.7), \"고액 발주 분할(1차 70%), 다음 달 재검토\"\n", + " elif k % 6 == 5:\n", + " dec, final, why = \"보류\", 0, \"결재 시한 초과 -> 발주하지 않음\"\n", + " else:\n", + " dec, final, why = \"승인\", int(q[i]), \"위험 지수·재고 근거 확인\"\n", + " audit.add(t=\"결재\", item=iid, decision=dec, final_qty=final, reason=why, by=\"정비장교(가상)\")\n", + " rows.append({\"품목\": iid, \"구분\": kind, \"위험\": risk[i], \"추천\": int(q[i]), \"결정\": dec, \"확정\": final, \"사유\": why})\n", + "res = pd.DataFrame(rows).sort_values(\"위험\", ascending=False)\n", + "print(res.head(10).to_string(index=False, float_format=lambda x: f\"{x:.0f}\"))\n", + "print(\"결정 집계:\", \", \".join(f\"{k} {v}건\" for k, v in res[\"결정\"].value_counts().items()))\n", + "auto = [e for e in audit.log if e[\"t\"] == \"추천\" and e[\"status\"] != \"결재대기\"]\n", + "print(f\"자동 확정 {len(auto)}건, 결재 사유 기록률 {res['사유'].astype(bool).mean():.0%}, 감사 로그 {len(audit.log)}건 무결성: {'정상' if audit.verify() is None else '변조'}\")\n", + "j = next(e[\"seq\"] for e in audit.log if e[\"t\"] == \"결재\" and e[\"decision\"] != \"승인\")\n", + "old = audit.log[j][\"decision\"]; audit.log[j][\"decision\"] = \"승인\"\n", + "print(f\"변조 시험({j}번 {old} 기록을 승인으로 수정) -> {audit.verify()}번 기록에서 변조 검출\")\n", + "\n", + "fig, ax = plt.subplots(1, 2, figsize=(12, 4))\n", + "tab[[\"보관비\", \"발주비\", \"결품손실\"]].plot.bar(stacked=True, ax=ax[0], rot=0)\n", + "ax[0].set_xticklabels([\"current\", \"forecast\", \"forecast+EOL alt\"]); ax[0].set_title(\"12-month cost (million, synthetic)\"); ax[0].legend([\"holding\", \"ordering\", \"shortage\"])\n", + "sd = pd.DataFrame(sens)\n", + "for nm, g in sd.groupby(\"수요\"):\n", + " ax[1].plot(g[\"z\"], g[\"총비용\"], \"o-\", label=\"normal\" if nm == \"평시\" else \"surge x1.5\")\n", + "ax[1].set_xlabel(\"service level z\"); ax[1].set_ylabel(\"total cost (million)\"); ax[1].set_title(\"Sensitivity\"); ax[1].legend()\n", + "plt.tight_layout(); plt.show()\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 07 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + "[12개월 재고 백테스트(가상 비용 지수, 백만 원)]\n", + " 보관비 발주비 결품손실 총비용(백만) 충족률 회전율\n", + "현행(평균+3개월 여유) 81.6 21.7 234.3 337.6 86.9% 5.42\n", + "예측 정책(z=1.65) 77.2 12.0 235.2 324.4 86.8% 6.09\n", + "예측+단종 대체품 전환 108.9 12.7 8.3 129.8 98.6% 5.65\n", + "총비용 절감 61.5%, 예측 정책만의 회전율 향상 12.2% (목표: 비용 15% 절감 또는 회전율 20% 향상 -> 달성)\n", + "효과 분해: 생산중·단종예고 품목 비용 13.0% 절감(예측 정책), 나머지는 단종 품목 대체품 전환 효과\n", + "[민감도] z=1.28 평시: 125백만, 충족 98.5% / z=1.28 돌발 1.5배: 230백만, 충족 90.4% / z=1.65 평시: 130백만, 충족 98.6% / z=1.65 돌발 1.5배: 213백만, 충족 91.6% / z=2.05 평시: 136백만, 충족 98.7% / z=2.05 돌발 1.5배: 195백만, 충족 92.8%\n", + "\n", + "[다음 달 발주 추천 LP] 부족 품목 18개, 예산 93.6백만(부족분 전액의 60%, 가상) 사용 93.5백만, 추천 16건\n", + " 품목 구분 위험 추천 결정 확정 사유\n", + "P004 정규 발주 88 45 조정 31 고액 발주 분할(1차 70%), 다음 달 재검토\n", + "P019 정규 발주 88 62 승인 62 위험 지수·재고 근거 확인\n", + "P030 정규 발주 88 491 승인 491 위험 지수·재고 근거 확인\n", + "P025 정규 발주 88 116 조정 81 고액 발주 분할(1차 70%), 다음 달 재검토\n", + "P038 정규 발주 88 57 승인 57 위험 지수·재고 근거 확인\n", + "P027 정규 발주 87 29 조정 20 고액 발주 분할(1차 70%), 다음 달 재검토\n", + "P032 정규 발주 73 26 승인 26 위험 지수·재고 근거 확인\n", + "P006 정규 발주 64 23 승인 23 위험 지수·재고 근거 확인\n", + "P012 정규 발주 61 45 승인 45 위험 지수·재고 근거 확인\n", + "P037 정규 발주 41 14 승인 14 위험 지수·재고 근거 확인\n", + "결정 집계: 승인 11건, 조정 3건, 보류 2건\n", + "자동 확정 0건, 결재 사유 기록률 100%, 감사 로그 32건 무결성: 정상\n", + "변조 시험(1번 조정 기록을 승인으로 수정) -> 1번 기록에서 변조 검출\n", + "```\n", + "
\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 08. 전시 작전 템포 증가 모의 부품 수요 300% 폭증 시뮬레이션 부하 테스트\n", + "**소요시간**: 약 3.0시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "전시 부품 수요 300% 폭증 시뮬레이션 부하 테스트를 수행한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **전시 작전 템포 증가로 부품 수요가 평시의 300%로 폭증하는 상황을 모의해 결품 경보와 대시보드의 성능을 부하 시험한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **품목군별 실제 증가 배수가 다른 3개월 폭증 상황을 반복 모의하고, 평시 예측·모델 직접 예측·작전 템포 계수 보정·월별 실적 반영 방식의 경보 정확도와 결품 탐지율을 비교 수행하고 검증하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `src/dashboard/mnt02_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": [ + "\"\"\"전시 작전 템포 증가로 부품 수요가 평시의 300%로 폭증하는 3개월 상황을 가상으로 모의해, 품목별 결품 경보(다음 달 예측 수요 > 가용재고)의 정확도를 반복 시험하고, 품목 수 50배 부하에서 재고 대시보드 갱신 시간(2초 이내)을 측정한다. 트리 모델이 학습 범위 밖 수요를 외삽하지 못하는 한계를 작전 템포 계수 보정으로 보완한다.\"\"\"\n", + "import time\n", + "import numpy as np\n", + "import pandas as pd\n", + "import matplotlib.pyplot as plt\n", + "from scipy.stats import norm\n", + "from sklearn.ensemble import HistGradientBoostingRegressor, RandomForestRegressor\n", + "\n", + "# ---------------------------------------------------------------------------\n", + "# [교육용가상자료] 가상 야전 정비창 수리부속 청구/출고·장비 가동률 데이터 생성기\n", + "# 실제 부대·품목·재고·단가 정보와 무관하다. 품목 40개 x 48개월(가상 2022-01 ~ 2025-12) 월별 자료를 만든다.\n", + "# ---------------------------------------------------------------------------\n", + "FSC = {\"2520\": \"power_train\", \"2530\": \"brake_steer\", \"2910\": \"fuel\", \"2930\": \"cooling\",\n", + " \"2940\": \"filter\", \"5330\": \"gasket\", \"6140\": \"battery\", \"5961\": \"semiconductor\"}\n", + "EXERCISE = {3, 8, 10} # 가상 대규모 훈련 월(훈련 계획으로 미리 알 수 있음)\n", + "N_MONTHS = 48\n", + "\n", + "\n", + "def season(fsc, month):\n", + " \"\"\"품목군별 계절 계수: 축전지는 겨울, 냉각계통은 여름에 수요가 늘어난다.\"\"\"\n", + " # [TODO : 학생 실습 8] 전시 작전 템포 증가 모의 부품 수요 300% 폭증 시뮬레이션 부하 테스트 관련 핵심 로직을 구현하세요.\n", + " # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n", + " # <<< 여기에 코드를 작성하세요 >>>\n", + " pass # 작성 후 pass를 제거하세요.\n", + "def make_items(n=40, seed=2):\n", + " \"\"\"품목 마스터: NSN(가상), 품목군, 장비 유형, 기준 수요, 단가, 조달 리드타임, 공급업체 수, 생산(단종) 상태.\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " rows = []\n", + " for i in range(n):\n", + " f = str(rng.choice(list(FSC)))\n", + " status = str(rng.choice([\"생산중\", \"단종예고\", \"단종\"], p=[0.7, 0.2, 0.1]))\n", + " rows.append({\"item_id\": f\"P{i + 1:03d}\", \"nsn\": f\"{f}-00-{rng.integers(100, 1000)}-{rng.integers(1000, 10000)}\",\n", + " \"fsc\": f, \"group\": FSC[f], \"equip_type\": str(rng.choice([\"A\", \"B\", \"C\"])),\n", + " \"base\": float(np.round(rng.lognormal(2.7, 0.8), 1)),\n", + " \"price\": int(rng.lognormal(12, 1.0)) // 100 * 100,\n", + " \"lead_time\": int(rng.integers(1, 7) if status == \"생산중\" else rng.integers(4, 13)),\n", + " \"suppliers\": int(rng.integers(1, 5)) if status == \"생산중\" else 1,\n", + " \"status\": status, \"alt_available\": bool(rng.random() < (0.6 if status == \"생산중\" else 0.3))})\n", + " return pd.DataFrame(rows)\n", + "\n", + "\n", + "def make_history(items, months=N_MONTHS, seed=3):\n", + " \"\"\"월별 청구 수요(개), 긴급 불출 건수, 장비 유형별 운용시간·가동률.\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " t = np.arange(months)\n", + " moy = t % 12 + 1\n", + " ex = np.isin(moy, list(EXERCISE))\n", + " hours = {e: (100 + 20 * rng.random(months)) * np.where(ex, 1.5, 1.0) for e in \"ABC\"}\n", + " avail = {e: np.clip(0.93 - 0.12 * (hours[e] - 100) / 80 + rng.normal(0, 0.015, months), 0.6, 0.99) for e in \"ABC\"}\n", + " rows = []\n", + " for it in items.itertuples():\n", + " trend = 1 + rng.uniform(-0.1, 0.3) * t / months\n", + " s = np.array([season(it.fsc, m) for m in moy])\n", + " lam = it.base * s * trend * hours[it.equip_type] / 110\n", + " d = rng.poisson(lam)\n", + " rows.append(pd.DataFrame({\"item_id\": it.item_id, \"t\": t, \"ym\": [f\"{2022 + k // 12}-{k % 12 + 1:02d}\" for k in t],\n", + " \"month\": moy, \"exercise\": ex.astype(int), \"op_hours\": hours[it.equip_type].round(1),\n", + " \"availability\": avail[it.equip_type].round(3), \"demand\": d,\n", + " \"emergency\": rng.binomial(d, 0.05)}))\n", + " return pd.concat(rows, ignore_index=True)\n", + "\n", + "\n", + "def make_stock(items, hist, seed=4):\n", + " \"\"\"현재(가상 2026-01) 재고: 보유량과 미입고 발주량(조달 중인 물량). 단종 품목은 추가 발주가 어려워 미입고량이 적다.\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " recent = hist[hist[\"t\"] >= hist[\"t\"].max() - 5].groupby(\"item_id\")[\"demand\"].mean()\n", + " st = items[[\"item_id\", \"lead_time\", \"status\"]].copy()\n", + " st[\"on_hand\"] = [int(round(recent[i] * rng.uniform(0.5, 3.0))) for i in st[\"item_id\"]]\n", + " cover = np.where(st[\"status\"] == \"생산중\", rng.uniform(0.6, 1.3, len(st)), rng.uniform(0.0, 0.6, len(st)))\n", + " st[\"on_order\"] = [int(round(recent[i] * L * c)) for i, L, c in zip(st[\"item_id\"], st[\"lead_time\"], cover)]\n", + " return st[[\"item_id\", \"on_hand\", \"on_order\"]]\n", + "# ---------------------------------------------------------------------------\n", + "\n", + "# ---- 수요 예측 공통 함수(파생 피처 + 머신러닝 모델) ----\n", + "FEATS = [\"lag1\", \"lag2\", \"lag3\", \"lag12\", \"ma3\", \"ma6\", \"std6\", \"season_idx\", \"month_sin\", \"month_cos\",\n", + " \"exercise\", \"op_hours\", \"op_ratio\", \"item_level\", \"trend_t\"]\n", + "\n", + "\n", + "def make_features(hist):\n", + " \"\"\"품목별 과거 수요로 파생 피처를 만든다(모두 예측 시점 이전 정보만 사용).\"\"\"\n", + " df = hist.sort_values([\"item_id\", \"t\"]).reset_index(drop=True)\n", + " g = df.groupby(\"item_id\")[\"demand\"]\n", + " for k in (1, 2, 3, 12):\n", + " df[f\"lag{k}\"] = g.shift(k)\n", + " df[\"ma3\"] = g.transform(lambda s: s.shift(1).rolling(3).mean())\n", + " df[\"ma6\"] = g.transform(lambda s: s.shift(1).rolling(6).mean())\n", + " df[\"std6\"] = g.transform(lambda s: s.shift(1).rolling(6).std())\n", + " df[\"item_level\"] = g.transform(lambda s: s.shift(1).rolling(12).mean())\n", + " df[\"season_idx\"] = df[\"lag12\"] / (g.transform(lambda s: s.shift(12).rolling(12).mean()) + 1)\n", + " df[\"month_sin\"] = np.sin(2 * np.pi * df[\"month\"] / 12)\n", + " df[\"month_cos\"] = np.cos(2 * np.pi * df[\"month\"] / 12)\n", + " df[\"op_ratio\"] = df[\"op_hours\"] / df.groupby(\"item_id\")[\"op_hours\"].transform(lambda s: s.shift(1).rolling(12).mean())\n", + " df[\"trend_t\"] = df[\"t\"]\n", + " return df.dropna().reset_index(drop=True)\n", + "\n", + "\n", + "def new_model():\n", + " \"\"\"Prophet/LSTM 대신 사용하는 포아송 손실 그래디언트 부스팅(개수 자료에 적합).\"\"\"\n", + " # [TODO : 학생 실습 8] 전시 작전 템포 증가 모의 부품 수요 300% 폭증 시뮬레이션 부하 테스트 관련 핵심 로직을 구현하세요.\n", + " # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n", + " # <<< 여기에 코드를 작성하세요 >>>\n", + " pass # 작성 후 pass를 제거하세요.\n", + "def mape(y, p, min_y=1):\n", + " y, p = np.asarray(y, float), np.asarray(p, float)\n", + " m = y >= min_y\n", + " return float(np.mean(np.abs(y[m] - p[m]) / y[m]))\n", + "\n", + "# ---- 앙상블 수요 예측·안전재고·결품(단종) 위험 지수 공통 함수 ----\n", + "STATUS_W = {\"생산중\": 0.0, \"단종예고\": 0.5, \"단종\": 1.0} # [교육용가상자료] 단종 상태 가중치\n", + "\n", + "\n", + "def fit_ens(tr):\n", + " \"\"\"포아송 그래디언트 부스팅(70%) + 랜덤포레스트(30%) 앙상블.\"\"\"\n", + " h = new_model().fit(tr[FEATS], tr[\"demand\"])\n", + " r = RandomForestRegressor(n_estimators=150, min_samples_leaf=5, random_state=0, n_jobs=1).fit(tr[FEATS], tr[\"demand\"])\n", + " return h, r\n", + "\n", + "\n", + "def pred_ens(m, X, w=0.7):\n", + " return np.clip(w * m[0].predict(X[FEATS]) + (1 - w) * m[1].predict(X[FEATS]), 0, None)\n", + "\n", + "\n", + "def next_month(hist, items, t_new=48, op_mult=1.0, ex=0):\n", + " \"\"\"다음 달(t_new) 예측용 행: 작년 같은 달 운용시간 x 배수, 훈련 여부 지정.\"\"\"\n", + " last = hist[hist[\"t\"] == t_new - 1].copy()\n", + " prev = hist[hist[\"t\"] == t_new - 12].set_index(\"item_id\")[\"op_hours\"]\n", + " m = (t_new % 12) + 1\n", + " f = last.assign(t=t_new, ym=f\"{2022 + t_new // 12}-{m:02d}\", month=m, demand=0, exercise=ex,\n", + " op_hours=last[\"item_id\"].map(prev).to_numpy() * op_mult)\n", + " X = make_features(pd.concat([hist, f], ignore_index=True))\n", + " return X[X[\"t\"] == t_new].set_index(\"item_id\").loc[items[\"item_id\"]].reset_index()\n", + "\n", + "\n", + "def stock_policy(items, mu, sigma, z=1.65, review=1):\n", + " \"\"\"안전재고 = z x 월 예측오차 표준편차 x sqrt(리드타임+검토주기), 재주문점 = 월 수요 x (리드타임+검토주기) + 안전재고.\"\"\"\n", + " L = items[\"lead_time\"].to_numpy() + review\n", + " ss = z * sigma * np.sqrt(L)\n", + " return pd.DataFrame({\"item_id\": items[\"item_id\"], \"mu\": mu, \"sigma\": sigma, \"L\": L,\n", + " \"ss\": np.ceil(ss), \"rop\": np.ceil(mu * L + ss)})\n", + "\n", + "\n", + "def risk_index(items, pol, stock):\n", + " \"\"\"결품 위험 지수(0~100): 리드타임 동안 수요가 가용 재고를 넘을 확률(정규 근사)을 기본으로,\n", + " 단종 상태·단일 공급업체·대체품 부재에 가중을 준다(가상 가중치).\"\"\"\n", + " st = stock.set_index(\"item_id\").loc[items[\"item_id\"]]\n", + " avail = (st[\"on_hand\"] + st[\"on_order\"]).to_numpy()\n", + " p = 1 - norm.cdf((avail - pol[\"mu\"].to_numpy() * pol[\"L\"].to_numpy()) / (pol[\"sigma\"].to_numpy() * np.sqrt(pol[\"L\"].to_numpy()) + 1e-6))\n", + " sw = items[\"status\"].map(STATUS_W).to_numpy()\n", + " sup = (items[\"suppliers\"].to_numpy() == 1).astype(float)\n", + " alt = (~items[\"alt_available\"].to_numpy()).astype(float)\n", + " score = 100 * np.clip(0.55 * p + 0.25 * sw + 0.10 * sup + 0.10 * alt, 0, 1)\n", + " return pd.DataFrame({\"item_id\": items[\"item_id\"], \"p_short\": p, \"status_w\": sw, \"single_sup\": sup, \"no_alt\": alt,\n", + " \"avail\": avail, \"risk\": score.round(1)})\n", + "\n", + "SURGE = 3.0 # 계획상 작전 템포 수요 배수(300%)\n", + "GROUP_MULT = {\"power_train\": 3.6, \"brake_steer\": 3.3, \"fuel\": 3.0, \"cooling\": 2.8, # [교육용가상자료] 실제 배수(계획에는 미반영)\n", + " \"filter\": 2.7, \"gasket\": 3.2, \"battery\": 2.6, \"semiconductor\": 3.1}\n", + "REPS, MONTHS = 30, 3\n", + "\n", + "items = make_items()\n", + "hist = make_history(items)\n", + "df = make_features(hist)\n", + "stock = make_stock(items, hist)\n", + "ens = fit_ens(df)\n", + "X = next_month(hist, items, op_mult=SURGE, ex=1)\n", + "raw = pred_ens(ens, X) # 운용시간 3배를 넣은 모델 예측(학습 범위 밖)\n", + "base = pred_ens(ens, next_month(hist, items)) # 평시 예측\n", + "adj = base * SURGE # 작전 템포 계수 보정 예측\n", + "print(f\"[폭증 예측] 평시 합계 {base.sum():.0f}개, 모델 직접 예측 {raw.sum():.0f}개({raw.sum() / base.sum():.1f}배), 템포 계수 보정 {adj.sum():.0f}개({SURGE:.0f}배)\")\n", + "print(\"-> 트리 모델은 학습한 운용시간 범위를 넘으면 예측이 더 늘지 않는다(외삽 불가). 계획 배수로 보정한다.\")\n", + "\n", + "\n", + "def run(fc, seed, adaptive=False):\n", + " \"\"\"3개월 모의: 매월 경보(예측 수요 > 월초 재고) vs 실제 결품(실제 수요 > 월초 재고).\n", + " adaptive=True이면 지난달 품목군별 실제/평시 비율로 다음 달 배수를 갱신한다(실적 반영).\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " true_mult = items[\"group\"].map(GROUP_MULT).to_numpy() * rng.lognormal(0, 0.12, len(items))\n", + " on = stock[\"on_hand\"].to_numpy().astype(float)\n", + " arrive = stock[\"on_order\"].to_numpy() / np.maximum(items[\"lead_time\"].to_numpy(), 1)\n", + " al, tr, bd = [], [], []\n", + " grp = items[\"group\"].to_numpy(); f = fc.copy()\n", + " for _ in range(MONTHS):\n", + " actual = rng.poisson(base * true_mult)\n", + " al.append(f > on); tr.append(actual > on); bd.append(np.abs(f - on) <= 1.65 * np.sqrt(np.maximum(f, 1)))\n", + " if adaptive:\n", + " ratio = pd.Series(actual).groupby(grp).sum() / pd.Series(base).groupby(grp).sum()\n", + " f = base * pd.Series(grp).map(ratio).to_numpy()\n", + " on = np.maximum(on - actual, 0) + arrive\n", + " return np.array(al), np.array(tr), np.array(bd)\n", + "\n", + "\n", + "rows = []\n", + "for nm, fc, ad in ((\"평시 예측(보정 없음)\", base, False), (\"모델 직접 예측\", raw, False), (\"템포 계수 보정\", adj, False),\n", + " (\"템포 보정+월별 실적 반영\", adj, True)):\n", + " acc, rec, n_al = [], [], []\n", + " for s in range(REPS):\n", + " a, t, bd = run(fc, s, ad)\n", + " acc.append((a == t).mean()); rec.append((a & t).sum() / max(t.sum(), 1)); n_al.append(a.sum())\n", + " if ad:\n", + " clear = [(lambda a, t, bd: ((a == t)[~bd].mean(), bd.mean()))(*run(fc, s, ad)) for s in range(REPS)]\n", + " rows.append({\"예측 방식\": nm, \"경보 정확도\": np.mean(acc), \"최저 정확도\": np.min(acc), \"결품 탐지율\": np.mean(rec), \"월평균 경보\": np.mean(n_al) / MONTHS})\n", + "res = pd.DataFrame(rows)\n", + "print(f\"\\n[경보 시험: 품목 {len(items)}개 x {MONTHS}개월 x 반복 {REPS}회]\")\n", + "print(res.to_string(index=False, formatters={\"경보 정확도\": \"{:.1%}\".format, \"최저 정확도\": \"{:.1%}\".format, \"결품 탐지율\": \"{:.1%}\".format, \"월평균 경보\": \"{:.1f}\".format}))\n", + "acc = res.iloc[3][\"경보 정확도\"]\n", + "print(f\"템포 보정+실적 반영 경보 정확도 {acc:.1%} (목표 95% 이상: {'달성' if acc >= 0.95 else '미달'}), 템포 보정만 {res.iloc[2]['경보 정확도']:.1%}\")\n", + "print(f\"오경보 원인: 예측과 재고 차이가 수요 변동 폭(1.65 x 표준편차) 이내인 경계 구간이 판정의 {np.mean([c[1] for c in clear]):.0%}를 차지한다. \"\n", + " f\"경계 구간을 '주의(검토 필요)'로 따로 표시하면 나머지 판정 정확도는 {np.mean([c[0] for c in clear]):.1%}이다.\")\n", + "\n", + "\n", + "# 부하 시험: 품목 50배(2,000개) 대시보드 갱신 = 예측 + 위험 지수 + 집계\n", + "def refresh(Xb, it, st):\n", + " mu = pred_ens(ens, Xb) * SURGE\n", + " pol = stock_policy(it, mu, np.sqrt(np.maximum(mu, 1)))\n", + " rk = risk_index(it, pol, st)\n", + " return rk.assign(group=it[\"group\"].to_numpy()).groupby(\"group\")[\"risk\"].agg([\"mean\", \"max\"]), rk\n", + "\n", + "\n", + "K = 50\n", + "Xb = pd.concat([X] * K, ignore_index=True)\n", + "itb = pd.concat([items.assign(item_id=items[\"item_id\"] + f\"_{k}\") for k in range(K)], ignore_index=True)\n", + "stb = pd.concat([stock.assign(item_id=stock[\"item_id\"] + f\"_{k}\") for k in range(K)], ignore_index=True)\n", + "times = []\n", + "for _ in range(3):\n", + " t0 = time.perf_counter(); agg, rk = refresh(Xb, itb, stb); times.append(time.perf_counter() - t0)\n", + "t1 = time.perf_counter(); refresh(X, items, stock); t_small = time.perf_counter() - t1\n", + "print(f\"\\n[대시보드 부하 시험] 품목 {len(itb)}개 갱신 평균 {np.mean(times):.2f}s / 최대 {max(times):.2f}s, 품목 40개 {t_small:.3f}s (목표 2초 이내: {'달성' if max(times) <= 2 else '미달'})\")\n", + "print(\"폭증 시 품목군별 최대 위험 지수:\", \", \".join(f\"{g} {r['max']:.0f}\" for g, r in agg.iterrows()))\n", + "print(\"경보는 조달 검토 요청이며 긴급 조달·대체품 전환은 정비장교 승인 후 진행한다.\")\n", + "\n", + "fig, ax = plt.subplots(1, 2, figsize=(12, 4))\n", + "ax[0].bar([\"normal\", \"raw\", \"tempo\", \"tempo+AAR\"], res[\"경보 정확도\"], color=[\"gray\", \"tab:orange\", \"tab:green\", \"tab:blue\"])\n", + "ax[0].axhline(0.95, c=\"r\", ls=\"--\"); ax[0].set_ylim(0.5, 1); ax[0].set_title(\"Alert accuracy under 300% demand surge\")\n", + "ax[1].scatter(base, raw, s=12, label=\"model raw\"); ax[1].scatter(base, adj, s=12, label=\"tempo adjusted\")\n", + "ax[1].plot([0, base.max()], [0, base.max() * 3], \"k--\", lw=1, label=\"x3 line\"); ax[1].set_xlabel(\"normal forecast\"); ax[1].set_ylabel(\"surge forecast\")\n", + "ax[1].set_title(\"Tree model cannot extrapolate\"); ax[1].legend()\n", + "plt.tight_layout(); plt.show()\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 08 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + "[폭증 예측] 평시 합계 948개, 모델 직접 예측 1193개(1.3배), 템포 계수 보정 2843개(3배)\n", + "-> 트리 모델은 학습한 운용시간 범위를 넘으면 예측이 더 늘지 않는다(외삽 불가). 계획 배수로 보정한다.\n", + "\n", + "[경보 시험: 품목 40개 x 3개월 x 반복 30회]\n", + " 예측 방식 경보 정확도 최저 정확도 결품 탐지율 월평균 경보\n", + " 평시 예측(보정 없음) 45.9% 40.8% 40.3% 14.6\n", + " 모델 직접 예측 65.0% 61.7% 61.5% 22.3\n", + " 템포 계수 보정 93.6% 88.3% 98.5% 37.7\n", + "템포 보정+월별 실적 반영 93.8% 88.3% 98.4% 37.5\n", + "템포 보정+실적 반영 경보 정확도 93.8% (목표 95% 이상: 미달), 템포 보정만 93.6%\n", + "오경보 원인: 예측과 재고 차이가 수요 변동 폭(1.65 x 표준편차) 이내인 경계 구간이 판정의 19%를 차지한다. 경계 구간을 '주의(검토 필요)'로 따로 표시하면 나머지 판정 정확도는 99.6%이다.\n", + "\n", + "[대시보드 부하 시험] 품목 2000개 갱신 평균 0.05s / 최대 0.05s, 품목 40개 0.024s (목표 2초 이내: 달성)\n", + "폭증 시 품목군별 최대 위험 지수: battery 100, brake_steer 88, cooling 65, filter 88, fuel 88, gasket 90, power_train 90, semiconductor 78\n", + "경보는 조달 검토 요청이며 긴급 조달·대체품 전환은 정비장교 승인 후 진행한다.\n", + "```\n", + "
\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 09. AAR 기반 부품 과다/과소 재고 예측 오차 분석 및 안전재고 파라미터 재보정\n", + "**소요시간**: 약 3.0시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "AAR 기반 부품 수요 예측 오차를 분석하고 안전재고 파라미터를 재보정한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **AAR 관점에서 과다·과소 재고와 수요 예측 오차를 분석하고 안전재고 파라미터를 재보정해 도입 효과를 산출한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **앞 6개월 운영 결과를 품목군·ABC 등급·생산 상태별 예측 편향, 과다 재고, 결품 품목으로 분석해 원인을 찾는다 과정을 진행하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `reports/mnt02_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 관점에서 지난 12개월 재고 운영의 과다·과소 재고와 수요 예측 오차를 품목군·생산 상태·ABC 등급별로 분석해 원인을 찾고, 앞 6개월 자료로 ABC 등급별 안전재고 계수(z)와 품목군별 예측 편향 보정값을 재보정한 뒤 뒤 6개월에서 검증하며, 비용 절감액·정비 대기시간 단축률·재고 회전율 3대 효과 지표와 민감도, 도입 로드맵을 산출한다.\"\"\"\n", + "import numpy as np\n", + "import pandas as pd\n", + "import matplotlib.pyplot as plt\n", + "from scipy.stats import norm\n", + "from sklearn.ensemble import HistGradientBoostingRegressor, RandomForestRegressor\n", + "\n", + "# ---------------------------------------------------------------------------\n", + "# [교육용가상자료] 가상 야전 정비창 수리부속 청구/출고·장비 가동률 데이터 생성기\n", + "# 실제 부대·품목·재고·단가 정보와 무관하다. 품목 40개 x 48개월(가상 2022-01 ~ 2025-12) 월별 자료를 만든다.\n", + "# ---------------------------------------------------------------------------\n", + "FSC = {\"2520\": \"power_train\", \"2530\": \"brake_steer\", \"2910\": \"fuel\", \"2930\": \"cooling\",\n", + " \"2940\": \"filter\", \"5330\": \"gasket\", \"6140\": \"battery\", \"5961\": \"semiconductor\"}\n", + "EXERCISE = {3, 8, 10} # 가상 대규모 훈련 월(훈련 계획으로 미리 알 수 있음)\n", + "N_MONTHS = 48\n", + "\n", + "\n", + "def season(fsc, month):\n", + " \"\"\"품목군별 계절 계수: 축전지는 겨울, 냉각계통은 여름에 수요가 늘어난다.\"\"\"\n", + " # [TODO : 학생 실습 9] AAR 기반 부품 과다/과소 재고 예측 오차 분석 및 안전재고 파라미터 재보정 관련 핵심 로직을 구현하세요.\n", + " # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n", + " # <<< 여기에 코드를 작성하세요 >>>\n", + " pass # 작성 후 pass를 제거하세요.\n", + "def make_items(n=40, seed=2):\n", + " \"\"\"품목 마스터: NSN(가상), 품목군, 장비 유형, 기준 수요, 단가, 조달 리드타임, 공급업체 수, 생산(단종) 상태.\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " rows = []\n", + " for i in range(n):\n", + " f = str(rng.choice(list(FSC)))\n", + " status = str(rng.choice([\"생산중\", \"단종예고\", \"단종\"], p=[0.7, 0.2, 0.1]))\n", + " rows.append({\"item_id\": f\"P{i + 1:03d}\", \"nsn\": f\"{f}-00-{rng.integers(100, 1000)}-{rng.integers(1000, 10000)}\",\n", + " \"fsc\": f, \"group\": FSC[f], \"equip_type\": str(rng.choice([\"A\", \"B\", \"C\"])),\n", + " \"base\": float(np.round(rng.lognormal(2.7, 0.8), 1)),\n", + " \"price\": int(rng.lognormal(12, 1.0)) // 100 * 100,\n", + " \"lead_time\": int(rng.integers(1, 7) if status == \"생산중\" else rng.integers(4, 13)),\n", + " \"suppliers\": int(rng.integers(1, 5)) if status == \"생산중\" else 1,\n", + " \"status\": status, \"alt_available\": bool(rng.random() < (0.6 if status == \"생산중\" else 0.3))})\n", + " return pd.DataFrame(rows)\n", + "\n", + "\n", + "def make_history(items, months=N_MONTHS, seed=3):\n", + " \"\"\"월별 청구 수요(개), 긴급 불출 건수, 장비 유형별 운용시간·가동률.\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " t = np.arange(months)\n", + " moy = t % 12 + 1\n", + " ex = np.isin(moy, list(EXERCISE))\n", + " hours = {e: (100 + 20 * rng.random(months)) * np.where(ex, 1.5, 1.0) for e in \"ABC\"}\n", + " avail = {e: np.clip(0.93 - 0.12 * (hours[e] - 100) / 80 + rng.normal(0, 0.015, months), 0.6, 0.99) for e in \"ABC\"}\n", + " rows = []\n", + " for it in items.itertuples():\n", + " trend = 1 + rng.uniform(-0.1, 0.3) * t / months\n", + " s = np.array([season(it.fsc, m) for m in moy])\n", + " lam = it.base * s * trend * hours[it.equip_type] / 110\n", + " d = rng.poisson(lam)\n", + " rows.append(pd.DataFrame({\"item_id\": it.item_id, \"t\": t, \"ym\": [f\"{2022 + k // 12}-{k % 12 + 1:02d}\" for k in t],\n", + " \"month\": moy, \"exercise\": ex.astype(int), \"op_hours\": hours[it.equip_type].round(1),\n", + " \"availability\": avail[it.equip_type].round(3), \"demand\": d,\n", + " \"emergency\": rng.binomial(d, 0.05)}))\n", + " return pd.concat(rows, ignore_index=True)\n", + "\n", + "\n", + "def make_stock(items, hist, seed=4):\n", + " \"\"\"현재(가상 2026-01) 재고: 보유량과 미입고 발주량(조달 중인 물량). 단종 품목은 추가 발주가 어려워 미입고량이 적다.\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " recent = hist[hist[\"t\"] >= hist[\"t\"].max() - 5].groupby(\"item_id\")[\"demand\"].mean()\n", + " st = items[[\"item_id\", \"lead_time\", \"status\"]].copy()\n", + " st[\"on_hand\"] = [int(round(recent[i] * rng.uniform(0.5, 3.0))) for i in st[\"item_id\"]]\n", + " cover = np.where(st[\"status\"] == \"생산중\", rng.uniform(0.6, 1.3, len(st)), rng.uniform(0.0, 0.6, len(st)))\n", + " st[\"on_order\"] = [int(round(recent[i] * L * c)) for i, L, c in zip(st[\"item_id\"], st[\"lead_time\"], cover)]\n", + " return st[[\"item_id\", \"on_hand\", \"on_order\"]]\n", + "# ---------------------------------------------------------------------------\n", + "\n", + "# ---- 수요 예측 공통 함수(파생 피처 + 머신러닝 모델) ----\n", + "FEATS = [\"lag1\", \"lag2\", \"lag3\", \"lag12\", \"ma3\", \"ma6\", \"std6\", \"season_idx\", \"month_sin\", \"month_cos\",\n", + " \"exercise\", \"op_hours\", \"op_ratio\", \"item_level\", \"trend_t\"]\n", + "\n", + "\n", + "def make_features(hist):\n", + " \"\"\"품목별 과거 수요로 파생 피처를 만든다(모두 예측 시점 이전 정보만 사용).\"\"\"\n", + " df = hist.sort_values([\"item_id\", \"t\"]).reset_index(drop=True)\n", + " g = df.groupby(\"item_id\")[\"demand\"]\n", + " for k in (1, 2, 3, 12):\n", + " df[f\"lag{k}\"] = g.shift(k)\n", + " df[\"ma3\"] = g.transform(lambda s: s.shift(1).rolling(3).mean())\n", + " df[\"ma6\"] = g.transform(lambda s: s.shift(1).rolling(6).mean())\n", + " df[\"std6\"] = g.transform(lambda s: s.shift(1).rolling(6).std())\n", + " df[\"item_level\"] = g.transform(lambda s: s.shift(1).rolling(12).mean())\n", + " df[\"season_idx\"] = df[\"lag12\"] / (g.transform(lambda s: s.shift(12).rolling(12).mean()) + 1)\n", + " df[\"month_sin\"] = np.sin(2 * np.pi * df[\"month\"] / 12)\n", + " df[\"month_cos\"] = np.cos(2 * np.pi * df[\"month\"] / 12)\n", + " df[\"op_ratio\"] = df[\"op_hours\"] / df.groupby(\"item_id\")[\"op_hours\"].transform(lambda s: s.shift(1).rolling(12).mean())\n", + " df[\"trend_t\"] = df[\"t\"]\n", + " return df.dropna().reset_index(drop=True)\n", + "\n", + "\n", + "def new_model():\n", + " \"\"\"Prophet/LSTM 대신 사용하는 포아송 손실 그래디언트 부스팅(개수 자료에 적합).\"\"\"\n", + " # [TODO : 학생 실습 9] AAR 기반 부품 과다/과소 재고 예측 오차 분석 및 안전재고 파라미터 재보정 관련 핵심 로직을 구현하세요.\n", + " # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n", + " # <<< 여기에 코드를 작성하세요 >>>\n", + " pass # 작성 후 pass를 제거하세요.\n", + "def mape(y, p, min_y=1):\n", + " y, p = np.asarray(y, float), np.asarray(p, float)\n", + " m = y >= min_y\n", + " return float(np.mean(np.abs(y[m] - p[m]) / y[m]))\n", + "\n", + "# ---- 앙상블 수요 예측·안전재고·결품(단종) 위험 지수 공통 함수 ----\n", + "STATUS_W = {\"생산중\": 0.0, \"단종예고\": 0.5, \"단종\": 1.0} # [교육용가상자료] 단종 상태 가중치\n", + "\n", + "\n", + "def fit_ens(tr):\n", + " \"\"\"포아송 그래디언트 부스팅(70%) + 랜덤포레스트(30%) 앙상블.\"\"\"\n", + " h = new_model().fit(tr[FEATS], tr[\"demand\"])\n", + " r = RandomForestRegressor(n_estimators=150, min_samples_leaf=5, random_state=0, n_jobs=1).fit(tr[FEATS], tr[\"demand\"])\n", + " return h, r\n", + "\n", + "\n", + "def pred_ens(m, X, w=0.7):\n", + " return np.clip(w * m[0].predict(X[FEATS]) + (1 - w) * m[1].predict(X[FEATS]), 0, None)\n", + "\n", + "\n", + "def next_month(hist, items, t_new=48, op_mult=1.0, ex=0):\n", + " \"\"\"다음 달(t_new) 예측용 행: 작년 같은 달 운용시간 x 배수, 훈련 여부 지정.\"\"\"\n", + " last = hist[hist[\"t\"] == t_new - 1].copy()\n", + " prev = hist[hist[\"t\"] == t_new - 12].set_index(\"item_id\")[\"op_hours\"]\n", + " m = (t_new % 12) + 1\n", + " f = last.assign(t=t_new, ym=f\"{2022 + t_new // 12}-{m:02d}\", month=m, demand=0, exercise=ex,\n", + " op_hours=last[\"item_id\"].map(prev).to_numpy() * op_mult)\n", + " X = make_features(pd.concat([hist, f], ignore_index=True))\n", + " return X[X[\"t\"] == t_new].set_index(\"item_id\").loc[items[\"item_id\"]].reset_index()\n", + "\n", + "\n", + "def stock_policy(items, mu, sigma, z=1.65, review=1):\n", + " \"\"\"안전재고 = z x 월 예측오차 표준편차 x sqrt(리드타임+검토주기), 재주문점 = 월 수요 x (리드타임+검토주기) + 안전재고.\"\"\"\n", + " L = items[\"lead_time\"].to_numpy() + review\n", + " ss = z * sigma * np.sqrt(L)\n", + " return pd.DataFrame({\"item_id\": items[\"item_id\"], \"mu\": mu, \"sigma\": sigma, \"L\": L,\n", + " \"ss\": np.ceil(ss), \"rop\": np.ceil(mu * L + ss)})\n", + "\n", + "\n", + "def risk_index(items, pol, stock):\n", + " \"\"\"결품 위험 지수(0~100): 리드타임 동안 수요가 가용 재고를 넘을 확률(정규 근사)을 기본으로,\n", + " 단종 상태·단일 공급업체·대체품 부재에 가중을 준다(가상 가중치).\"\"\"\n", + " st = stock.set_index(\"item_id\").loc[items[\"item_id\"]]\n", + " avail = (st[\"on_hand\"] + st[\"on_order\"]).to_numpy()\n", + " p = 1 - norm.cdf((avail - pol[\"mu\"].to_numpy() * pol[\"L\"].to_numpy()) / (pol[\"sigma\"].to_numpy() * np.sqrt(pol[\"L\"].to_numpy()) + 1e-6))\n", + " sw = items[\"status\"].map(STATUS_W).to_numpy()\n", + " sup = (items[\"suppliers\"].to_numpy() == 1).astype(float)\n", + " alt = (~items[\"alt_available\"].to_numpy()).astype(float)\n", + " score = 100 * np.clip(0.55 * p + 0.25 * sw + 0.10 * sup + 0.10 * alt, 0, 1)\n", + " return pd.DataFrame({\"item_id\": items[\"item_id\"], \"p_short\": p, \"status_w\": sw, \"single_sup\": sup, \"no_alt\": alt,\n", + " \"avail\": avail, \"risk\": score.round(1)})\n", + "\n", + "# ---- 월 단위 재고 백테스트(가상 비용 지수) ----\n", + "HOLD, ORDER_COST, SHORT_MULT = 0.02, 50_000, 0.6 # [교육용가상자료] 월 보관비율(단가 대비), 발주 1회 비용(원), 결품 1개 손실(단가 배수)\n", + "\n", + "\n", + "ALT_PREMIUM, ALT_LT = 1.2, 3 # [교육용가상자료] 대체품 전환 시 단가 배수, 대체품 조달 리드타임(월)\n", + "\n", + "\n", + "def simulate(items, demand, fc, policy, z=1.65, months=None, sigma=None, eol=True):\n", + " \"\"\"품목별 월 재고 모의. demand/fc: (품목 x 월) 실제·예측 수요. policy: 'current'(최근 12개월 평균 + 3개월 여유 고정) 또는 'ai'(예측 + 안전재고).\n", + " 단종 품목은 원 품목 발주 불가. 'ai' 정책에서 eol=True이면 단종 품목 중 대체품이 있는 품목은 대체품(단가 x1.2, 리드타임 3개월)으로 전환 발주한다. 반환: 품목별 보관비·발주비·결품손실·불출량·평균재고.\"\"\"\n", + " n, T = demand.shape\n", + " L = items[\"lead_time\"].to_numpy(); price = items[\"price\"].to_numpy().astype(float)\n", + " can_order = (items[\"status\"] != \"단종\").to_numpy()\n", + " if policy == \"ai\" and eol:\n", + " sw = (~can_order) & items[\"alt_available\"].to_numpy()\n", + " can_order = can_order | sw\n", + " L = np.where(sw, ALT_LT, L); price = np.where(sw, price * ALT_PREMIUM, price)\n", + " avg = demand[:, :1].repeat(T, 1) if months is None else months\n", + " on = np.round(avg[:, 0] * 2.0)\n", + " pipe = np.zeros((n, T + int(L.max()) + 1)) # 도착 예정 물량(품목 x 월)\n", + " for i in range(n):\n", + " pipe[i, :L[i]] = np.round(avg[i, 0] * (1.0 if can_order[i] else 0.3))\n", + " res = np.zeros((n, 6)); rows = np.arange(n)\n", + " for t in range(T):\n", + " on += pipe[:, t]\n", + " issue = np.minimum(on, demand[:, t]); short = demand[:, t] - issue; on -= issue\n", + " ip = on + pipe[:, t + 1:].sum(1)\n", + " if policy == \"current\":\n", + " target = avg[:, t] * (L + 3)\n", + " else:\n", + " s = sigma if sigma is not None else np.sqrt(np.maximum(fc[:, t], 1))\n", + " target = fc[:, t] * (L + 1) + z * s * np.sqrt(L + 1)\n", + " q = np.where(can_order & (ip < target), np.ceil(target - ip), 0)\n", + " pipe[rows, t + L] += q\n", + " res += np.c_[HOLD * price * on, ORDER_COST * (q > 0), SHORT_MULT * price * short, issue, on, short]\n", + " out = pd.DataFrame(res, columns=[\"hold\", \"order\", \"short\", \"issued\", \"inv\", \"short_qty\"], index=items[\"item_id\"])\n", + " out[\"inv\"] /= T\n", + " return out\n", + "\n", + "WAIT_H = 36 # [교육용가상자료] 결품 1개당 장비 정비 대기시간(시간)\n", + "\n", + "items = make_items()\n", + "hist = make_history(items)\n", + "df = make_features(hist)\n", + "m = fit_ens(df[df[\"t\"] < 36])\n", + "bt = df[df[\"t\"] >= 36].copy(); bt[\"fc\"] = pred_ens(m, bt)\n", + "P = lambda c: bt.pivot(index=\"item_id\", columns=\"t\", values=c).loc[items[\"item_id\"]].to_numpy()\n", + "D, F, A = P(\"demand\"), P(\"fc\"), P(\"item_level\")\n", + "sig = np.maximum(0.25 * F.mean(1), np.sqrt(np.maximum(F.mean(1), 1)))\n", + "val = items[\"price\"].to_numpy() * D.sum(1)\n", + "cum = np.cumsum(np.sort(val)[::-1]) / val.sum()\n", + "abc = pd.Series(np.select([val >= np.sort(val)[::-1][np.searchsorted(cum, 0.7)], val >= np.sort(val)[::-1][np.searchsorted(cum, 0.9)]], [\"A\", \"B\"], \"C\"), index=items[\"item_id\"])\n", + "\n", + "# 1) AAR 편차 분석: 예측 편향과 과다·과소 재고\n", + "an = items[[\"item_id\", \"group\", \"status\"]].assign(abc=abc.values, bias=(F[:, :6].sum(1) - D[:, :6].sum(1)) / np.maximum(D[:, :6].sum(1), 1))\n", + "r0 = simulate(items, D[:, :6], F[:, :6], \"ai\", 1.65, A[:, :6], sig)\n", + "an[\"over\"] = (r0[\"inv\"].to_numpy() > 3 * np.maximum(D[:, :6].mean(1), 1)) # 평균 재고가 3개월 수요 초과 = 과다\n", + "an[\"under\"] = r0[\"short_qty\"].to_numpy() > 0\n", + "print(\"[AAR 편차 분석: 앞 6개월(t=36~41)]\")\n", + "g = an.groupby(\"group\").agg(예측편향=(\"bias\", \"mean\"), 과다품목=(\"over\", \"sum\"), 결품품목=(\"under\", \"sum\"))\n", + "gt = g.T.astype(object)\n", + "gt.loc[\"예측편향\"] = [f\"{v:+.0%}\" for v in g[\"예측편향\"]]\n", + "gt.loc[[\"과다품목\", \"결품품목\"]] = gt.loc[[\"과다품목\", \"결품품목\"]].map(lambda v: f\"{int(v)}\")\n", + "print(gt.to_string())\n", + "print(\"ABC별 과다/결품 품목:\", \", \".join(f\"{k} {int(v['over'])}/{int(v['under'])}\" for k, v in an.groupby(\"abc\")[[\"over\", \"under\"]].sum().iterrows()),\n", + " \"| 생산 상태별 결품 품목:\", \", \".join(f\"{k} {int(v)}\" for k, v in an.groupby(\"status\")[\"under\"].sum().items()))\n", + "ex = bt[\"exercise\"] == 1\n", + "hi = g[\"예측편향\"].abs().sort_values(ascending=False).index[:2]\n", + "hi_txt = \", \".join(f\"{h}({g.loc[h, '예측편향']:+.0%})\" for h in hi)\n", + "print(f\"훈련 월 예측 편향 {(bt.loc[ex, 'fc'].sum() / bt.loc[ex, 'demand'].sum() - 1):+.1%}, 평시 월 {(bt.loc[~ex, 'fc'].sum() / bt.loc[~ex, 'demand'].sum() - 1):+.1%}\"\n", + " f\" -> 원인: {hi_txt} 품목군 예측 편향, 모든 등급에 같은 z=1.65 적용, 단종 품목 발주 불가\")\n", + "\n", + "# 2) 재보정(앞 6개월로 결정): ABC별 z 격자 탐색 + 품목군별 편향 보정\n", + "grp_bias = an.groupby(\"group\")[\"bias\"].median().clip(-0.3, 0.3)\n", + "corr = 1 / (1 + items[\"group\"].map(grp_bias).to_numpy())\n", + "best = {}\n", + "for cls in \"ABC\":\n", + " mask = (abc == cls).to_numpy()\n", + " costs = {}\n", + " for z in (0.84, 1.28, 1.65, 2.05, 2.33):\n", + " zz = np.where(mask, z, 1.65)\n", + " r = simulate(items, D[:, :6], F[:, :6] * corr[:, None], \"ai\", zz, A[:, :6], sig)\n", + " costs[z] = r[[\"hold\", \"order\", \"short\"]].to_numpy()[mask].sum()\n", + " best[cls] = min(costs, key=costs.get)\n", + "z_new = abc.map(best).to_numpy()\n", + "print(f\"\\n[재보정 결과] ABC별 z: A {best['A']}, B {best['B']}, C {best['C']} (기존 1.65 일괄), 품목군 편향 보정 범위 {corr.min():.2f}~{corr.max():.2f}배\")\n", + "\n", + "# 3) 검증(뒤 6개월, t=42~47) 및 3대 효과 지표\n", + "def ev(fc, z):\n", + " r = simulate(items, D[:, 6:], fc, \"ai\", z, A[:, 6:], sig)\n", + " return r, r[[\"hold\", \"order\", \"short\"]].sum().sum()\n", + "\n", + "\n", + "r_cur = simulate(items, D[:, 6:], F[:, 6:], \"current\", months=A[:, 6:])\n", + "r_old, c_old = ev(F[:, 6:], 1.65)\n", + "r_new, c_new = ev(F[:, 6:] * corr[:, None], z_new)\n", + "c_cur = r_cur[[\"hold\", \"order\", \"short\"]].sum().sum()\n", + "turn = lambda r: float(r[\"issued\"].sum() / r[\"inv\"].sum())\n", + "over_amt = lambda r: float((np.maximum(r[\"inv\"].to_numpy() - 3 * np.maximum(D[:, 6:].mean(1), 1), 0) * items[\"price\"].to_numpy()).sum())\n", + "k = pd.DataFrame([[\"6개월 총비용(백만 원)\", c_cur / 1e6, c_old / 1e6, c_new / 1e6],\n", + " [\"정비 대기시간(시간)\", r_cur[\"short_qty\"].sum() * WAIT_H, r_old[\"short_qty\"].sum() * WAIT_H, r_new[\"short_qty\"].sum() * WAIT_H],\n", + " [\"재고 회전율(회/6개월)\", turn(r_cur), turn(r_old), turn(r_new)]],\n", + " columns=[\"지표\", \"현행 정책\", \"예측 정책(기존 파라미터)\", \"예측 정책(재보정)\"]).set_index(\"지표\")\n", + "k[\"현행 대비 개선율\"] = 1 - k[\"예측 정책(재보정)\"] / k[\"현행 정책\"]\n", + "k.loc[\"재고 회전율(회/6개월)\", \"현행 대비 개선율\"] *= -1 # 회전율은 클수록 좋음\n", + "print(\"\\n[3대 정량 효과 지표: 뒤 6개월 검증, 가상 단가 기준]\")\n", + "print(k.to_string(float_format=lambda x: f\"{x:,.1f}\", formatters={\"현행 대비 개선율\": \"{:.1%}\".format}))\n", + "print(f\"재보정 효과(기존 파라미터 대비): 총비용 {1 - c_new / c_old:+.1%}, 연간 환산 절감액(현행 대비) {(c_cur - c_new) * 2 / 1e6:,.0f}백만 원(가상)\")\n", + "sens = []\n", + "for hm in (0.5, 1.0, 1.5):\n", + " for sm in (0.3, 0.6, 1.0):\n", + " HOLD_, SM_ = HOLD * hm, sm\n", + " cc = lambda r, p: (HOLD_ * p * r[\"inv\"].to_numpy() * 6).sum() + r[\"order\"].sum() + (SM_ * p * r[\"short_qty\"].to_numpy()).sum()\n", + " pr = items[\"price\"].to_numpy()\n", + " sens.append(f\"보관x{hm}/결품x{sm}: {1 - cc(r_new, pr) / cc(r_cur, pr):.0%}\")\n", + "print(\"[민감도: 현행 대비 비용 절감률]\", \", \".join(sens))\n", + "print(\"도입 로드맵: 단기(1~3개월) 분기별 AAR로 z·편향 재보정 / 중기(4~9개월) 단종예고 품목 최종 구매·대체품 검증 절차 정착 / 장기(10개월~) 군수 정보체계 연계와 자동 재학습\")\n", + "print(\"금액·시간은 가정 단가로 계산한 상대 비교값이며 예산 근거로 쓰지 않는다.\")\n", + "\n", + "fig, ax = plt.subplots(1, 2, figsize=(12, 4))\n", + "g[\"예측편향\"].plot.bar(ax=ax[0], rot=30, color=\"tab:purple\"); ax[0].axhline(0, c=\"k\", lw=0.8); ax[0].set_title(\"Forecast bias by group (AAR, first 6 months)\")\n", + "k.iloc[:, :3].div(k[\"현행 정책\"], axis=0).T.plot.bar(ax=ax[1], rot=0); ax[1].set_xticklabels([\"current\", \"forecast(old)\", \"forecast(recal)\"])\n", + "ax[1].set_title(\"Effect indicators (current = 1.0)\"); ax[1].legend([\"cost\", \"wait hours\", \"turnover\"])\n", + "plt.tight_layout(); plt.show()\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 09 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + "[AAR 편차 분석: 앞 6개월(t=36~41)]\n", + "group battery brake_steer cooling filter fuel gasket power_train semiconductor\n", + "예측편향 +11% +18% +4% +1% -6% -5% +1% -3%\n", + "과다품목 1 1 1 2 0 0 0 0\n", + "결품품목 1 0 0 0 1 0 0 0\n", + "ABC별 과다/결품 품목: A 1/0, B 2/0, C 2/2 | 생산 상태별 결품 품목: 단종 1, 단종예고 0, 생산중 1\n", + "훈련 월 예측 편향 +1.5%, 평시 월 +0.1% -> 원인: brake_steer(+18%), battery(+11%) 품목군 예측 편향, 모든 등급에 같은 z=1.65 적용, 단종 품목 발주 불가\n", + "\n", + "[재보정 결과] ABC별 z: A 0.84, B 0.84, C 1.28 (기존 1.65 일괄), 품목군 편향 보정 범위 0.89~1.07배\n", + "\n", + "[3대 정량 효과 지표: 뒤 6개월 검증, 가상 단가 기준]\n", + " 현행 정책 예측 정책(기존 파라미터) 예측 정책(재보정) 현행 대비 개선율\n", + "지표 \n", + "6개월 총비용(백만 원) 117.7 56.7 55.3 53.0%\n", + "정비 대기시간(시간) 17,532.0 1,620.0 1,872.0 89.3%\n", + "재고 회전율(회/6개월) 3.1 3.0 3.1 1.2%\n", + "재보정 효과(기존 파라미터 대비): 총비용 +2.5%, 연간 환산 절감액(현행 대비) 125백만 원(가상)\n", + "[민감도: 현행 대비 비용 절감률] 보관x0.5/결품x0.3: 55%, 보관x0.5/결품x0.6: 69%, 보관x0.5/결품x1.0: 77%, 보관x1.0/결품x0.3: 38%, 보관x1.0/결품x0.6: 55%, 보관x1.0/결품x1.0: 67%, 보관x1.5/결품x0.3: 28%, 보관x1.5/결품x0.6: 45%, 보관x1.5/결품x1.0: 58%\n", + "도입 로드맵: 단기(1~3개월) 분기별 AAR로 z·편향 재보정 / 중기(4~9개월) 단종예고 품목 최종 구매·대체품 검증 절차 정착 / 장기(10개월~) 군수 정보체계 연계와 자동 재학습\n", + "금액·시간은 가정 단가로 계산한 상대 비교값이며 예산 근거로 쓰지 않는다.\n", + "```\n", + "
\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 10. AI 기반 야전 정비 부품 수요예측 및 재고최적화 시스템 통합 패키지 완성\n", + "**소요시간**: 약 3.5시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "AI 기반 야전 정비 부품 수요 예측 및 재고 최적화 시스템 패키지를 완성한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **수요 예측, 재학습, 안전재고·위험 지수, 단종 대응, 발주 추천과 결재 대기열을 한 번에 실행하는 통합 패키지를 완성한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **자료 생성·정합성 점검, 운영 모델과 재학습 모델 비교 후 교체 판단, 다음 달 예측과 위험 지수 산출을 한 흐름으로 실행 수행하고 검증하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `docs/mnt02_operations_guide.md 및 pipeline/mnt02_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": [ + "\"\"\"수리부속 청구 이력 생성·정합성 점검, 수요 예측 모델 자동 재학습(기존·신규 모델 비교 후 교체), 안전재고·결품(단종) 위험 지수, 단종예고 품목 최종 구매량과 단종 품목 대체 검토, 예산 제약 발주 추천과 정비장교 결재 대기열을 한 번에 실행하는 통합 패키지를 완성하고, 자체 점검표·운영 가이드 목차·체계 연계 메시지 예시·브리핑 요약을 출력한다.\"\"\"\n", + "import json\n", + "import time\n", + "import numpy as np\n", + "import pandas as pd\n", + "import matplotlib.pyplot as plt\n", + "from scipy.stats import norm\n", + "from scipy.optimize import linprog\n", + "from sklearn.ensemble import HistGradientBoostingRegressor, RandomForestRegressor\n", + "from sklearn.metrics import mean_absolute_error\n", + "\n", + "# ---------------------------------------------------------------------------\n", + "# [교육용가상자료] 가상 야전 정비창 수리부속 청구/출고·장비 가동률 데이터 생성기\n", + "# 실제 부대·품목·재고·단가 정보와 무관하다. 품목 40개 x 48개월(가상 2022-01 ~ 2025-12) 월별 자료를 만든다.\n", + "# ---------------------------------------------------------------------------\n", + "FSC = {\"2520\": \"power_train\", \"2530\": \"brake_steer\", \"2910\": \"fuel\", \"2930\": \"cooling\",\n", + " \"2940\": \"filter\", \"5330\": \"gasket\", \"6140\": \"battery\", \"5961\": \"semiconductor\"}\n", + "EXERCISE = {3, 8, 10} # 가상 대규모 훈련 월(훈련 계획으로 미리 알 수 있음)\n", + "N_MONTHS = 48\n", + "\n", + "\n", + "def season(fsc, month):\n", + " \"\"\"품목군별 계절 계수: 축전지는 겨울, 냉각계통은 여름에 수요가 늘어난다.\"\"\"\n", + " # [TODO : 학생 실습 10] AI 기반 야전 정비 부품 수요예측 및 재고최적화 시스템 통합 패키지 완성 관련 핵심 로직을 구현하세요.\n", + " # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n", + " # <<< 여기에 코드를 작성하세요 >>>\n", + " pass # 작성 후 pass를 제거하세요.\n", + "def make_items(n=40, seed=2):\n", + " \"\"\"품목 마스터: NSN(가상), 품목군, 장비 유형, 기준 수요, 단가, 조달 리드타임, 공급업체 수, 생산(단종) 상태.\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " rows = []\n", + " for i in range(n):\n", + " f = str(rng.choice(list(FSC)))\n", + " status = str(rng.choice([\"생산중\", \"단종예고\", \"단종\"], p=[0.7, 0.2, 0.1]))\n", + " rows.append({\"item_id\": f\"P{i + 1:03d}\", \"nsn\": f\"{f}-00-{rng.integers(100, 1000)}-{rng.integers(1000, 10000)}\",\n", + " \"fsc\": f, \"group\": FSC[f], \"equip_type\": str(rng.choice([\"A\", \"B\", \"C\"])),\n", + " \"base\": float(np.round(rng.lognormal(2.7, 0.8), 1)),\n", + " \"price\": int(rng.lognormal(12, 1.0)) // 100 * 100,\n", + " \"lead_time\": int(rng.integers(1, 7) if status == \"생산중\" else rng.integers(4, 13)),\n", + " \"suppliers\": int(rng.integers(1, 5)) if status == \"생산중\" else 1,\n", + " \"status\": status, \"alt_available\": bool(rng.random() < (0.6 if status == \"생산중\" else 0.3))})\n", + " return pd.DataFrame(rows)\n", + "\n", + "\n", + "def make_history(items, months=N_MONTHS, seed=3):\n", + " \"\"\"월별 청구 수요(개), 긴급 불출 건수, 장비 유형별 운용시간·가동률.\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " t = np.arange(months)\n", + " moy = t % 12 + 1\n", + " ex = np.isin(moy, list(EXERCISE))\n", + " hours = {e: (100 + 20 * rng.random(months)) * np.where(ex, 1.5, 1.0) for e in \"ABC\"}\n", + " avail = {e: np.clip(0.93 - 0.12 * (hours[e] - 100) / 80 + rng.normal(0, 0.015, months), 0.6, 0.99) for e in \"ABC\"}\n", + " rows = []\n", + " for it in items.itertuples():\n", + " trend = 1 + rng.uniform(-0.1, 0.3) * t / months\n", + " s = np.array([season(it.fsc, m) for m in moy])\n", + " lam = it.base * s * trend * hours[it.equip_type] / 110\n", + " d = rng.poisson(lam)\n", + " rows.append(pd.DataFrame({\"item_id\": it.item_id, \"t\": t, \"ym\": [f\"{2022 + k // 12}-{k % 12 + 1:02d}\" for k in t],\n", + " \"month\": moy, \"exercise\": ex.astype(int), \"op_hours\": hours[it.equip_type].round(1),\n", + " \"availability\": avail[it.equip_type].round(3), \"demand\": d,\n", + " \"emergency\": rng.binomial(d, 0.05)}))\n", + " return pd.concat(rows, ignore_index=True)\n", + "\n", + "\n", + "def make_stock(items, hist, seed=4):\n", + " \"\"\"현재(가상 2026-01) 재고: 보유량과 미입고 발주량(조달 중인 물량). 단종 품목은 추가 발주가 어려워 미입고량이 적다.\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " recent = hist[hist[\"t\"] >= hist[\"t\"].max() - 5].groupby(\"item_id\")[\"demand\"].mean()\n", + " st = items[[\"item_id\", \"lead_time\", \"status\"]].copy()\n", + " st[\"on_hand\"] = [int(round(recent[i] * rng.uniform(0.5, 3.0))) for i in st[\"item_id\"]]\n", + " cover = np.where(st[\"status\"] == \"생산중\", rng.uniform(0.6, 1.3, len(st)), rng.uniform(0.0, 0.6, len(st)))\n", + " st[\"on_order\"] = [int(round(recent[i] * L * c)) for i, L, c in zip(st[\"item_id\"], st[\"lead_time\"], cover)]\n", + " return st[[\"item_id\", \"on_hand\", \"on_order\"]]\n", + "# ---------------------------------------------------------------------------\n", + "\n", + "# ---- 수요 예측 공통 함수(파생 피처 + 머신러닝 모델) ----\n", + "FEATS = [\"lag1\", \"lag2\", \"lag3\", \"lag12\", \"ma3\", \"ma6\", \"std6\", \"season_idx\", \"month_sin\", \"month_cos\",\n", + " \"exercise\", \"op_hours\", \"op_ratio\", \"item_level\", \"trend_t\"]\n", + "\n", + "\n", + "def make_features(hist):\n", + " \"\"\"품목별 과거 수요로 파생 피처를 만든다(모두 예측 시점 이전 정보만 사용).\"\"\"\n", + " df = hist.sort_values([\"item_id\", \"t\"]).reset_index(drop=True)\n", + " g = df.groupby(\"item_id\")[\"demand\"]\n", + " for k in (1, 2, 3, 12):\n", + " df[f\"lag{k}\"] = g.shift(k)\n", + " df[\"ma3\"] = g.transform(lambda s: s.shift(1).rolling(3).mean())\n", + " df[\"ma6\"] = g.transform(lambda s: s.shift(1).rolling(6).mean())\n", + " df[\"std6\"] = g.transform(lambda s: s.shift(1).rolling(6).std())\n", + " df[\"item_level\"] = g.transform(lambda s: s.shift(1).rolling(12).mean())\n", + " df[\"season_idx\"] = df[\"lag12\"] / (g.transform(lambda s: s.shift(12).rolling(12).mean()) + 1)\n", + " df[\"month_sin\"] = np.sin(2 * np.pi * df[\"month\"] / 12)\n", + " df[\"month_cos\"] = np.cos(2 * np.pi * df[\"month\"] / 12)\n", + " df[\"op_ratio\"] = df[\"op_hours\"] / df.groupby(\"item_id\")[\"op_hours\"].transform(lambda s: s.shift(1).rolling(12).mean())\n", + " df[\"trend_t\"] = df[\"t\"]\n", + " return df.dropna().reset_index(drop=True)\n", + "\n", + "\n", + "def new_model():\n", + " \"\"\"Prophet/LSTM 대신 사용하는 포아송 손실 그래디언트 부스팅(개수 자료에 적합).\"\"\"\n", + " # [TODO : 학생 실습 10] AI 기반 야전 정비 부품 수요예측 및 재고최적화 시스템 통합 패키지 완성 관련 핵심 로직을 구현하세요.\n", + " # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n", + " # <<< 여기에 코드를 작성하세요 >>>\n", + " pass # 작성 후 pass를 제거하세요.\n", + "def mape(y, p, min_y=1):\n", + " y, p = np.asarray(y, float), np.asarray(p, float)\n", + " m = y >= min_y\n", + " return float(np.mean(np.abs(y[m] - p[m]) / y[m]))\n", + "\n", + "# ---- 앙상블 수요 예측·안전재고·결품(단종) 위험 지수 공통 함수 ----\n", + "STATUS_W = {\"생산중\": 0.0, \"단종예고\": 0.5, \"단종\": 1.0} # [교육용가상자료] 단종 상태 가중치\n", + "\n", + "\n", + "def fit_ens(tr):\n", + " \"\"\"포아송 그래디언트 부스팅(70%) + 랜덤포레스트(30%) 앙상블.\"\"\"\n", + " h = new_model().fit(tr[FEATS], tr[\"demand\"])\n", + " r = RandomForestRegressor(n_estimators=150, min_samples_leaf=5, random_state=0, n_jobs=1).fit(tr[FEATS], tr[\"demand\"])\n", + " return h, r\n", + "\n", + "\n", + "def pred_ens(m, X, w=0.7):\n", + " return np.clip(w * m[0].predict(X[FEATS]) + (1 - w) * m[1].predict(X[FEATS]), 0, None)\n", + "\n", + "\n", + "def next_month(hist, items, t_new=48, op_mult=1.0, ex=0):\n", + " \"\"\"다음 달(t_new) 예측용 행: 작년 같은 달 운용시간 x 배수, 훈련 여부 지정.\"\"\"\n", + " last = hist[hist[\"t\"] == t_new - 1].copy()\n", + " prev = hist[hist[\"t\"] == t_new - 12].set_index(\"item_id\")[\"op_hours\"]\n", + " m = (t_new % 12) + 1\n", + " f = last.assign(t=t_new, ym=f\"{2022 + t_new // 12}-{m:02d}\", month=m, demand=0, exercise=ex,\n", + " op_hours=last[\"item_id\"].map(prev).to_numpy() * op_mult)\n", + " X = make_features(pd.concat([hist, f], ignore_index=True))\n", + " return X[X[\"t\"] == t_new].set_index(\"item_id\").loc[items[\"item_id\"]].reset_index()\n", + "\n", + "\n", + "def stock_policy(items, mu, sigma, z=1.65, review=1):\n", + " \"\"\"안전재고 = z x 월 예측오차 표준편차 x sqrt(리드타임+검토주기), 재주문점 = 월 수요 x (리드타임+검토주기) + 안전재고.\"\"\"\n", + " L = items[\"lead_time\"].to_numpy() + review\n", + " ss = z * sigma * np.sqrt(L)\n", + " return pd.DataFrame({\"item_id\": items[\"item_id\"], \"mu\": mu, \"sigma\": sigma, \"L\": L,\n", + " \"ss\": np.ceil(ss), \"rop\": np.ceil(mu * L + ss)})\n", + "\n", + "\n", + "def risk_index(items, pol, stock):\n", + " \"\"\"결품 위험 지수(0~100): 리드타임 동안 수요가 가용 재고를 넘을 확률(정규 근사)을 기본으로,\n", + " 단종 상태·단일 공급업체·대체품 부재에 가중을 준다(가상 가중치).\"\"\"\n", + " st = stock.set_index(\"item_id\").loc[items[\"item_id\"]]\n", + " avail = (st[\"on_hand\"] + st[\"on_order\"]).to_numpy()\n", + " p = 1 - norm.cdf((avail - pol[\"mu\"].to_numpy() * pol[\"L\"].to_numpy()) / (pol[\"sigma\"].to_numpy() * np.sqrt(pol[\"L\"].to_numpy()) + 1e-6))\n", + " sw = items[\"status\"].map(STATUS_W).to_numpy()\n", + " sup = (items[\"suppliers\"].to_numpy() == 1).astype(float)\n", + " alt = (~items[\"alt_available\"].to_numpy()).astype(float)\n", + " score = 100 * np.clip(0.55 * p + 0.25 * sw + 0.10 * sup + 0.10 * alt, 0, 1)\n", + " return pd.DataFrame({\"item_id\": items[\"item_id\"], \"p_short\": p, \"status_w\": sw, \"single_sup\": sup, \"no_alt\": alt,\n", + " \"avail\": avail, \"risk\": score.round(1)})\n", + "\n", + "LTB_MONTHS = 24 # [교육용가상자료] 단종예고 품목 최종 구매(LTB) 대상 기간(개월)\n", + "t_all = time.perf_counter()\n", + "checks = []\n", + "\n", + "# 1) 자료 수집·정합성\n", + "items = make_items()\n", + "hist = make_history(items)\n", + "stock = make_stock(items, hist)\n", + "ok = (hist[\"demand\"] >= 0).all() and items[\"nsn\"].str.match(r\"^\\d{4}-\\d{2}-\\d{3}-\\d{4}$\").all() and not hist.duplicated([\"item_id\", \"t\"]).any()\n", + "checks.append((\"원천 자료 정합성(NSN 형식·음수·중복)\", ok))\n", + "df = make_features(hist)\n", + "print(f\"[1] 품목 {len(items)}개 x {N_MONTHS}개월 청구 이력, 학습 행 {len(df)}개, 정합성 {'통과' if ok else '실패'}\")\n", + "\n", + "# 2) 재학습 파이프라인: 운영 모델 v1(t<36) vs 재학습 v2(t<42), 최근 6개월(t=42~47) 검증 후 교체 판단\n", + "va = df[df[\"t\"] >= 42]\n", + "big = va[\"item_id\"].isin(items.loc[items[\"base\"] >= 10, \"item_id\"]).to_numpy()\n", + "v1, v2 = fit_ens(df[df[\"t\"] < 36]), fit_ens(df[df[\"t\"] < 42])\n", + "p1, p2 = pred_ens(v1, va), pred_ens(v2, va)\n", + "e1, e2 = mape(va[\"demand\"][big], p1[big]), mape(va[\"demand\"][big], p2[big])\n", + "eb = mape(va[\"demand\"][big], va[\"ma3\"][big])\n", + "use = \"v2\" if e2 <= e1 else \"v1\"\n", + "print(f\"[2] 검증 MAPE(수요 많은 품목): MA3 {eb:.1%}, 운영 v1 {e1:.1%}, 재학습 v2 {e2:.1%} -> {use} {'교체' if use == 'v2' else '유지'}\")\n", + "checks += [(\"자동 재학습 실행·비교\", True), (\"운영 모델 MAPE 25% 이하\", min(e1, e2) <= 0.25)]\n", + "\n", + "# 3) 다음 달 예측(전체 자료로 최종 학습) -> 안전재고·위험 지수\n", + "final = fit_ens(df)\n", + "mu = pred_ens(final, next_month(hist, items))\n", + "sg = np.maximum(0.25 * mu, np.sqrt(np.maximum(mu, 1)))\n", + "pol = stock_policy(items, mu, sg)\n", + "rk = risk_index(items, pol, stock)\n", + "alert = rk[\"risk\"] >= 60\n", + "print(f\"[3] 다음 달 예측 수요 {mu.sum():.0f}개, 결품 위험 경보(60 이상) {int(alert.sum())}개 품목, 안전재고 합 {pol['ss'].sum():.0f}개\")\n", + "\n", + "# 4) 단종 대응: 단종예고 -> 최종 구매(LTB) 수량, 단종 -> 대체품·재생정비 검토\n", + "ip = (stock[\"on_hand\"] + stock[\"on_order\"]).to_numpy()\n", + "eol = items.assign(mu=mu, ip=ip, risk=rk[\"risk\"].to_numpy())\n", + "ltb = eol[eol[\"status\"] == \"단종예고\"].assign(ltb=lambda d: np.maximum(0, np.ceil(d[\"mu\"] * LTB_MONTHS - d[\"ip\"])))\n", + "dis = eol[eol[\"status\"] == \"단종\"]\n", + "print(f\"[4] 단종예고 {len(ltb)}개 품목 최종 구매 추천 합 {ltb['ltb'].sum():.0f}개(향후 {LTB_MONTHS}개월분), 단종 {len(dis)}개 품목 중 대체품 보유 {int(dis['alt_available'].sum())}개\")\n", + "for _, r in ltb.sort_values(\"risk\", ascending=False).head(3).iterrows():\n", + " print(f\" {r['item_id']}({r['group']}) 월 {r['mu']:.1f}개, 가용 {r['ip']:.0f}개 -> 최종 구매 {r['ltb']:.0f}개\")\n", + "\n", + "# 5) 예산 제약 발주 추천(LP, 생산중 품목. 단종예고는 최종 구매로 별도 처리) -> 결재 대기열\n", + "gap = np.where(items[\"status\"] == \"생산중\", np.maximum(0, np.ceil(pol[\"rop\"].to_numpy() - ip)), 0)\n", + "price = items[\"price\"].to_numpy().astype(float)\n", + "idx = np.where(gap > 0)[0]\n", + "budget = 0.6 * float((gap * price).sum())\n", + "lp = linprog(c=-(rk[\"risk\"].to_numpy()[idx] + 10) / gap[idx], A_ub=[price[idx]], b_ub=[budget], bounds=[(0, g) for g in gap[idx]], method=\"highs\")\n", + "q = np.zeros(len(items)); q[idx] = np.floor(lp.x)\n", + "queue = [{\"id\": f\"Q{k + 1:02d}\", \"item\": items.loc[i, \"item_id\"], \"qty\": int(q[i]), \"risk\": float(rk.loc[i, \"risk\"]), \"status\": \"결재대기\"} for k, i in enumerate(np.where(q > 0)[0])]\n", + "queue += [{\"id\": f\"Q{len(queue) + k + 1:02d}\", \"item\": r[\"item_id\"], \"qty\": int(r[\"ltb\"]), \"risk\": float(r[\"risk\"]), \"status\": \"결재대기\", \"type\": \"LTB\"} for k, (_, r) in enumerate(ltb[ltb[\"ltb\"] > 0].iterrows())]\n", + "auto = [x for x in queue if x[\"status\"] != \"결재대기\"]\n", + "print(f\"[5] 발주 추천 {int((q > 0).sum())}건(예산 {budget / 1e6:.1f}백만 중 {np.dot(q, price) / 1e6:.1f}백만) + 최종 구매 {int((ltb['ltb'] > 0).sum())}건 -> 정비장교 결재 대기 {len(queue)}건, 자동 확정 {len(auto)}건\")\n", + "checks.append((\"자동 확정 0건(HITL)\", not auto))\n", + "\n", + "# 6) 운영 가이드·연계 메시지\n", + "GUIDE = {\"1. 목적·범위\": \"야전 정비 부품 수요 예측·재고 최적화·단종 대응\", \"2. 데이터 규격\": f\"품목 마스터 {items.shape[1]}개 필드, NSN 가상 형식\",\n", + " \"3. 전처리\": \"NSN 정규화, 결측 월 계절성 보간, 긴급 불출 보존\", \"4. 예측 모델\": f\"포아송 GBM+RF 앙상블, MAPE {min(e1, e2):.1%}\",\n", + " \"5. 안전재고·위험 지수\": \"z=1.65, 리드타임 결품 확률+단종·공급업체·대체품\", \"6. 단종 대응\": f\"LTB {LTB_MONTHS}개월분, 대체품 호환성 검증\",\n", + " \"7. 결재선·감사\": \"자동 확정 금지, 승인·조정·반려·보류 사유 기록\", \"8. 재학습\": \"분기별 재학습, 검증 MAPE가 낮을 때만 교체\",\n", + " \"9. 폭증 대응\": \"작전 템포 계수 보정, 경계 구간 '주의' 표시\", \"10. 연계 인터페이스\": \"ORDER_REQ 가상 메시지 규격\"}\n", + "done = np.mean([bool(v) for v in GUIDE.values()])\n", + "checks.append((\"운영 가이드 10개 장 완비\", done == 1))\n", + "print(\"[6] 운영 가이드 목차:\", \" / \".join(GUIDE))\n", + "top = max(queue, key=lambda x: x[\"risk\"]) if queue else {}\n", + "msg = {\"msg_type\": \"ORDER_REQ\", \"version\": \"1.0\", \"item_id\": top.get(\"item\"), \"nsn\": items.set_index(\"item_id\").loc[top.get(\"item\"), \"nsn\"] if top else None,\n", + " \"qty\": top.get(\"qty\"), \"risk_index\": top.get(\"risk\"), \"model_ver\": use, \"approval\": \"PENDING\", \"source\": \"교육용가상자료\"}\n", + "print(\"[7] 체계 연계 메시지 예시(가상 규격):\", json.dumps(msg, ensure_ascii=False))\n", + "elapsed = time.perf_counter() - t_all\n", + "checks.append((\"통합 실행 60초 이내\", elapsed < 60))\n", + "\n", + "print(\"\\n[자체 점검표]\")\n", + "for name, okk in checks:\n", + " print(f\" [{'PASS' if okk else 'FAIL'}] {name}\")\n", + "print(f\"점검 {sum(o for _, o in checks)}/{len(checks)} 통과, 운영 가이드 완비율 {done:.0%}, 전체 실행 {elapsed:.1f}s\")\n", + "print(\"\\n[브리핑 요약]\")\n", + "print(f\"- 부품 수요 예측 MAPE {min(e1, e2):.1%}(이동평균 {eb:.1%})이며, 재학습 모델은 검증 후 교체한다({use} 사용).\")\n", + "print(f\"- 결품 위험 경보 {int(alert.sum())}개 품목 중 단종·단종예고가 {int((alert & (items['status'] != '생산중')).sum())}개로, 단종 대응이 재고 위험의 핵심이다.\")\n", + "print(f\"- 단종예고 {len(ltb)}개 품목의 최종 구매 {ltb['ltb'].sum():.0f}개와 발주 {int((q > 0).sum())}건을 결재 대기열에 올렸다.\")\n", + "print(\"- 모든 발주·최종 구매·대체품 전환은 정비장교 결재 후 집행하며, 수치는 교육용 가상 자료 기준이다.\")\n", + "\n", + "fig, ax = plt.subplots(1, 2, figsize=(12, 4))\n", + "ax[0].bar([\"MA3\", \"model v1\", \"model v2\"], [eb, e1, e2], color=[\"gray\", \"tab:blue\", \"tab:green\"]); ax[0].axhline(0.25, c=\"r\", ls=\"--\")\n", + "ax[0].set_title(\"Validation MAPE (t=42-47)\")\n", + "col = items[\"status\"].map({\"생산중\": \"tab:green\", \"단종예고\": \"tab:orange\", \"단종\": \"tab:red\"})\n", + "ax[1].scatter(items[\"lead_time\"], rk[\"risk\"], c=col, s=20 + mu / 2); ax[1].axhline(60, c=\"r\", ls=\"--\")\n", + "ax[1].set_xlabel(\"lead time (months)\"); ax[1].set_ylabel(\"risk index\"); ax[1].set_title(\"Risk vs lead time (size=demand, red=discontinued)\")\n", + "plt.tight_layout(); plt.show()\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 10 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + "[1] 품목 40개 x 48개월 청구 이력, 학습 행 1000개, 정합성 통과\n", + "[2] 검증 MAPE(수요 많은 품목): MA3 32.7%, 운영 v1 20.0%, 재학습 v2 20.9% -> v1 유지\n", + "[3] 다음 달 예측 수요 948개, 결품 위험 경보(60 이상) 13개 품목, 안전재고 합 1108개\n", + "[4] 단종예고 8개 품목 최종 구매 추천 합 2779개(향후 24개월분), 단종 3개 품목 중 대체품 보유 2개\n", + " P004(filter) 월 8.1개, 가용 12개 -> 최종 구매 183개\n", + " P019(filter) 월 11.5개, 가용 59개 -> 최종 구매 219개\n", + " P025(filter) 월 14.0개, 가용 89개 -> 최종 구매 247개\n", + "[5] 발주 추천 10건(예산 31.2백만 중 30.9백만) + 최종 구매 8건 -> 정비장교 결재 대기 18건, 자동 확정 0건\n", + "[6] 운영 가이드 목차: 1. 목적·범위 / 2. 데이터 규격 / 3. 전처리 / 4. 예측 모델 / 5. 안전재고·위험 지수 / 6. 단종 대응 / 7. 결재선·감사 / 8. 재학습 / 9. 폭증 대응 / 10. 연계 인터페이스\n", + "[7] 체계 연계 메시지 예시(가상 규격): {\"msg_type\": \"ORDER_REQ\", \"version\": \"1.0\", \"item_id\": \"P004\", \"nsn\": \"2940-00-559-8893\", \"qty\": 183, \"risk_index\": 87.5, \"model_ver\": \"v1\", \"approval\": \"PENDING\", \"source\": \"교육용가상자료\"}\n", + "\n", + "[자체 점검표]\n", + " [PASS] 원천 자료 정합성(NSN 형식·음수·중복)\n", + " [PASS] 자동 재학습 실행·비교\n", + " [PASS] 운영 모델 MAPE 25% 이하\n", + " [PASS] 자동 확정 0건(HITL)\n", + " [PASS] 운영 가이드 10개 장 완비\n", + " [PASS] 통합 실행 60초 이내\n", + "점검 6/6 통과, 운영 가이드 완비율 100%, 전체 실행 3.5s\n", + "\n", + "[브리핑 요약]\n", + "- 부품 수요 예측 MAPE 20.0%(이동평균 32.7%)이며, 재학습 모델은 검증 후 교체한다(v1 사용).\n", + "- 결품 위험 경보 13개 품목 중 단종·단종예고가 11개로, 단종 대응이 재고 위험의 핵심이다.\n", + "- 단종예고 8개 품목의 최종 구매 2779개와 발주 10건을 결재 대기열에 올렸다.\n", + "- 모든 발주·최종 구매·대체품 전환은 정비장교 결재 후 집행하며, 수치는 교육용 가상 자료 기준이다.\n", + "```\n", + "
\n" + ] + } + ], + "metadata": { + "colab": { + "provenance": [] + }, + "kernelspec": { + "display_name": "Python 3", + "name": "python3" + }, + "language_info": { + "name": "python" + } + }, + "nbformat": 4, + "nbformat_minor": 0 +} \ No newline at end of file diff --git a/uploads/MNT03/MNT03_solution.ipynb b/uploads/MNT03/MNT03_solution.ipynb new file mode 100644 index 0000000..7d646d7 --- /dev/null +++ b/uploads/MNT03/MNT03_solution.ipynb @@ -0,0 +1,3017 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# 🎯 [MNT03] 고장률 분석을 통한 다빈도 고장품목 식별\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/MNT03/MNT03_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", + "야전 정비 현장에서는 어떤 부품이 자주 고장 나는지 정확히 알아야 수리부속을 먼저 확보하고 예방정비 주기를 조정할 수 있지만, 차량 전자제어장치(ECU)가 남기는 고장 진단 코드(DTC)는 한 번에 여러 개가 겹쳐 기록되고 부품 간 공유 코드도 있어 첫 번째 코드만 보면 원인 부품을 자주 놓친다. 본 프로젝트는 고장·정비 이력을 연결하고 가동시간 대비 고장률을 계산해 다빈도 고장품목을 식별하는 데이터분석 기본기를 익히는 것을 목표로, 교육용 가상 차량 100대의 18개월 DTC·정비 이력을 코드로 만들어 분석한다. 데이터 스키마 정의, 로그 정제, 단일 코드 Baseline과 파레토·상관 분석, 연관 규칙 기반 근본 원인 예측, 정비 조치 추천과 신뢰도 점수, 설명가능성과 정비병용 진단 화면, 숙련 정비관 2차 진단(HITL) 시뮬레이터, 1,000건 동시 주입 부하 시험, 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. 전차/장갑차 전자제어장치(ECU) 고장 진단 코드(DTC) 및 정비 매뉴얼 데이터셋 정의\n", + "**소요시간**: 약 1.5시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "전차/장갑차 전자제어장치(ECU) 고장 진단 코드(DTC) 데이터셋을 정의한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **전차/장갑차 계열 가상 차량의 ECU 고장 진단 코드(DTC) 발생 로그, 정비 이력, 정비 매뉴얼 데이터셋의 스키마와 데이터 딕셔너리, 고장률 분석 업무 프로세스 맵을 정의한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **DTC 발생부터 정비 이력 연결, 고장률 집계, 다빈도 품목 식별, 사후검토까지 업무 흐름을 단계별로 정리하고, DTC 로그·정비 건·차량·예측 대상 묶음으로 피처 20개 이상 정의하고 검증하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `docs/mnt03_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": [ + "\"\"\"전차/장갑차 계열 가상 차량의 전자제어장치(ECU) 고장 진단 코드(DTC) 발생 로그, 정비 이력, 정비 매뉴얼 데이터셋의 스키마·데이터 딕셔너리(피처 20개 이상)와 고장률 분석 업무 프로세스 맵을 정의하고, 가상 데이터로 검증한다.\"\"\"\n", + "import json\n", + "import re\n", + "import numpy as np\n", + "import pandas as pd\n", + "\n", + "# ---------------------------------------------------------------------------\n", + "# [교육용가상자료] 가상 차량 100대의 18개월 고장 진단 코드(DTC)·정비 이력 생성기 (실제 부대·장비·정비 실적 없음)\n", + "# 부품 16종(1000가동시간당 고장률 차등), DTC 28종(부품 고유 코드, 짝 부품 공유 코드, 계통 증상 코드)\n", + "# 고장 1건(정비 이력)마다 DTC가 1~5개 동시 발생하고, 연쇄 고장 코드와 일시 잡음 코드가 섞인다.\n", + "# ---------------------------------------------------------------------------\n", + "PARTS = [ # (ID, 이름, 계통, 1000가동시간당 고장률, 가상 단가지수, 수리시간 h)\n", + " (\"P01\", \"연료분사기\", \"engine\", 12.0, 3.0, 2.0), (\"P02\", \"터보차저\", \"engine\", 9.0, 8.0, 5.0),\n", + " (\"P03\", \"축전지\", \"electric\", 6.5, 4.0, 1.5), (\"P04\", \"발전기\", \"electric\", 4.0, 9.0, 3.0),\n", + " (\"P05\", \"변속기 제어모듈\", \"drive\", 3.0, 12.0, 6.0), (\"P06\", \"냉각수 펌프\", \"engine\", 2.2, 3.5, 3.0),\n", + " (\"P07\", \"유압 펌프\", \"hydraulic\", 1.6, 10.0, 4.0), (\"P08\", \"속도 센서\", \"drive\", 1.2, 2.0, 1.5),\n", + " (\"P09\", \"ECU 커넥터\", \"electric\", 0.9, 2.5, 1.5), (\"P10\", \"연료 필터\", \"engine\", 0.7, 0.8, 0.7),\n", + " (\"P11\", \"제동 밸브\", \"brake\", 0.5, 6.0, 3.0), (\"P12\", \"조향 액추에이터\", \"drive\", 0.4, 9.0, 5.0),\n", + " (\"P13\", \"공기 필터\", \"engine\", 0.3, 0.5, 0.5), (\"P14\", \"통신 인터페이스 모듈\", \"electric\", 0.25, 3.0, 1.5),\n", + " (\"P15\", \"윤활유 압력 센서\", \"engine\", 0.2, 1.0, 1.0), (\"P16\", \"팬 구동 모터\", \"cooling\", 0.15, 2.0, 2.0)]\n", + "PID = [p[0] for p in PARTS]\n", + "PNAME = {p[0]: p[1] for p in PARTS}; PSYS = {p[0]: p[2] for p in PARTS}; RATE = {p[0]: p[3] for p in PARTS}\n", + "COST = {p[0]: p[4] for p in PARTS}; REPH = {p[0]: p[5] for p in PARTS}\n", + "PAIRS = [(\"P01\", \"P02\"), (\"P03\", \"P04\"), (\"P05\", \"P08\"), (\"P06\", \"P10\"), (\"P09\", \"P14\"), (\"P13\", \"P15\")]\n", + "SHARED = {p: f\"S{k + 1:02d}\" for k, pr in enumerate(PAIRS) for p in pr} # 짝 부품이 함께 쓰는 모호한 코드\n", + "SPEC = {p: f\"C{p[1:]}1\" for p in PID} # 부품 고유 코드\n", + "SYS = [\"engine\", \"electric\", \"drive\", \"hydraulic\", \"brake\", \"cooling\"]\n", + "SYMP = {s: f\"G{i + 1:02d}\" for i, s in enumerate(SYS)} # 계통 증상 코드\n", + "CODES = sorted(set(SPEC.values()) | set(SHARED.values()) | set(SYMP.values()))\n", + "CASCADE = {\"P01\": [(\"P10\", 0.20)], \"P02\": [(\"P06\", 0.20)], \"P03\": [(\"P04\", 0.25)], \"P04\": [(\"P09\", 0.25)],\n", + " \"P05\": [(\"P08\", 0.30)], \"P06\": [(\"P16\", 0.30)], \"P09\": [(\"P14\", 0.20)], \"P12\": [(\"P07\", 0.20)]}\n", + "CRIT_SYS = {\"engine\", \"drive\", \"brake\"} # 기동·제동 불능 위험 계통(경보 대상)\n", + "MONTHS = 18\n", + "\n", + "\n", + "def make_fleet(n=100, seed=0):\n", + " rng = np.random.default_rng(seed)\n", + " f = pd.DataFrame({\"veh\": [f\"V{i:03d}\" for i in range(n)], \"age\": rng.integers(1, 26, n).astype(float),\n", + " \"terrain\": rng.uniform(0, 1, n), \"load\": rng.uniform(0, 1, n), \"pm_gap\": rng.uniform(20, 200, n),\n", + " \"humid\": rng.uniform(0, 1, n), \"base_h\": rng.uniform(55, 125, n)})\n", + " f[\"mult\"] = np.exp(0.045 * (f[\"age\"] - 13) + 0.9 * (f[\"terrain\"] - .5) + 0.8 * (f[\"load\"] - .5)\n", + " + 0.006 * (f[\"pm_gap\"] - 110) + 0.7 * (f[\"humid\"] - .5))\n", + " return f\n", + "\n", + "\n", + "def make_data(seed=1, months=MONTHS):\n", + " \"\"\"반환: fleet(차량 속성), ev(정비 이력 1건=1행: 발생 DTC 목록, 실제 고장 부품), log(DTC 발생 로그, 행=코드 1건)\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " fleet = make_fleet()\n", + " V = len(fleet)\n", + " temp = 12 + 14 * np.sin(2 * np.pi * (np.arange(months) - 3) / 12) # 월평균 기온(가상)\n", + " hours = fleet[\"base_h\"].values[:, None] * (1 + 0.25 * np.sin(2 * np.pi * np.arange(months) / 12 + 1)) * rng.lognormal(0, .12, (V, months))\n", + " fleet[\"hours\"] = hours.sum(axis=1) # 차량별 18개월 총 가동시간\n", + " fleet.attrs[\"month_h\"] = hours.sum(axis=0) # 월별 전체 가동시간(고장률 분모)\n", + " evs = []\n", + " for p in PID:\n", + " s = np.ones(months)\n", + " if p in (\"P03\", \"P04\"): s = np.exp(-0.06 * (temp - 12)) # 한랭기 전기계통 증가\n", + " if p in (\"P06\", \"P16\"): s = np.exp(0.05 * (temp - 12)) # 혹서기 냉각계통 증가\n", + " cnt = rng.poisson(RATE[p] * hours / 1000 * fleet[\"mult\"].values[:, None] * s[None, :])\n", + " for v, m in zip(*np.nonzero(cnt)):\n", + " for _ in range(cnt[v, m]):\n", + " evs.append((fleet[\"veh\"][v], int(m), m * 720 + rng.uniform(0, 720), p, float(temp[m] + rng.normal(0, 3))))\n", + " ev = pd.DataFrame(evs, columns=[\"veh\", \"month\", \"t\", \"part\", \"temp\"]).sort_values(\"t\").reset_index(drop=True)\n", + " ev[\"ev_id\"] = [f\"W{i:05d}\" for i in range(len(ev))]\n", + " rows, codes = [], []\n", + " for e in ev.itertuples():\n", + " c, r = [], rng.random(3)\n", + " if r[0] < 0.6: c.append(SPEC[e.part])\n", + " if e.part in SHARED and r[1] < 0.6: c.append(SHARED[e.part])\n", + " if r[2] < 0.5: c.append(SYMP[PSYS[e.part]])\n", + " if not c: c.append(SHARED.get(e.part, SPEC[e.part]))\n", + " for q, pr in CASCADE.get(e.part, []):\n", + " if rng.random() < pr: c.append(SPEC[q])\n", + " if rng.random() < 0.12: c.append(SPEC[PID[rng.integers(0, 16)]])\n", + " c = list(dict.fromkeys(c))\n", + " rng.shuffle(c) # 동시 발생 코드의 기록 순서는 임의\n", + " codes.append(c)\n", + " for k, cd in enumerate(c):\n", + " rows.append((e.ev_id, e.veh, e.t + k * 0.1, cd))\n", + " ev[\"codes\"] = codes\n", + " log = pd.DataFrame(rows, columns=[\"ev_id\", \"veh\", \"ts\", \"code\"])\n", + " return fleet, ev, log\n", + "\n", + "# 1) 업무 프로세스 맵: DTC 발생부터 고장률 집계·환류까지 (각 단계에서 생기는 데이터)\n", + "PROCESS = [(\"DTC 발생\", \"차량 ID, 발생 시각, DTC 코드, 가동시간 계기값\"), (\"로그 수집·정제\", \"중복 제거, 코드·시각 표기 통일\"),\n", + " (\"동시 발생 묶음\", \"같은 정비 건에서 발생한 DTC 세트(정규화)\"), (\"고장 진단\", \"추정 고장 부품, 신뢰도\"),\n", + " (\"정비 조치\", \"정비 매뉴얼 절차, 수리시간, 교체 부품\"), (\"정비 이력 연결\", \"정비 건 ID로 DTC와 실제 고장 부품 연결\"),\n", + " (\"고장률 집계\", \"부품별 1000가동시간당 고장 건수\"), (\"다빈도 품목 식별\", \"파레토(ABC) 등급, 관리 대상 선정\"),\n", + " (\"사후검토(AAR)\", \"오진단 사례, 규칙 조정 이력\")]\n", + "\n", + "# 2) 스키마(데이터 딕셔너리): 그룹 / 필드 / 자료형 / 규칙 / 설명\n", + "S = {\n", + " \"dtc_log\": {\n", + " \"ev_id\": (str, {\"pattern\": r\"^W\\d{5}$\"}, \"정비 건 ID(DTC와 정비 이력을 잇는 키)\"),\n", + " \"veh_id\": (str, {\"pattern\": r\"^V\\d{3}$\"}, \"차량 ID(가상)\"),\n", + " \"ts\": (float, {\"min\": 0, \"max\": 13000}, \"DTC 발생 시각(모의 시작 후 경과 시간, h)\"),\n", + " \"code\": (str, {\"enum\": CODES}, \"DTC 코드(고유·공유·계통 증상 28종)\"),\n", + " \"co_count\": (int, {\"min\": 1, \"max\": 8}, \"같은 정비 건에서 동시 발생한 DTC 수\"),\n", + " },\n", + " \"work_order\": {\n", + " \"part_id\": (str, {\"enum\": PID}, \"실제 고장 부품(정비 이력) - 예측대상 1\"),\n", + " \"system\": (str, {\"enum\": SYS}, \"고장 계통\"),\n", + " \"repair_h\": (float, {\"min\": 0.1, \"max\": 24}, \"수리 소요시간(h)\"),\n", + " \"unit_cost\": (float, {\"min\": 0, \"max\": 50}, \"부품 단가지수(가상)\"),\n", + " \"unplanned\": (bool, {}, \"비계획·긴급 정비 여부\"),\n", + " \"month\": (int, {\"min\": 0, \"max\": 17}, \"정비 월(모의 시작 기준)\"),\n", + " },\n", + " \"vehicle\": {\n", + " \"age\": (float, {\"min\": 0, \"max\": 40}, \"차령(년)\"),\n", + " \"op_hours\": (float, {\"min\": 0, \"max\": 400}, \"월 가동시간(h)\"),\n", + " \"pm_gap\": (float, {\"min\": 0, \"max\": 500}, \"마지막 예방정비 후 경과 가동시간(h)\"),\n", + " \"terrain\": (float, {\"min\": 0, \"max\": 1}, \"운용 지형 가혹도(0~1)\"),\n", + " \"load\": (float, {\"min\": 0, \"max\": 1}, \"운용 부하 수준(0~1)\"),\n", + " \"temp\": (float, {\"min\": -40, \"max\": 60}, \"정비 시점 기온(도)\"),\n", + " },\n", + " \"manual\": {\n", + " \"code_ref\": (str, {\"enum\": CODES}, \"매뉴얼 기준 DTC 코드\"),\n", + " \"cand_parts\": (list, {}, \"코드별 후보 고장 부품 목록\"),\n", + " \"step_no\": (int, {\"min\": 1, \"max\": 20}, \"정비 조치 절차 단계 번호\"),\n", + " \"safety_flag\": (bool, {}, \"안전 주의 항목 포함 여부\"),\n", + " },\n", + " \"target\": {\n", + " \"fail_rate\": (float, {\"min\": 0, \"max\": 100}, \"부품별 1000가동시간당 고장률 - 예측대상 2\"),\n", + " \"confidence\": (float, {\"min\": 0, \"max\": 1}, \"진단 추천 신뢰도 점수 - 예측대상 3\"),\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", + "fleet, ev, log = make_data()\n", + "f = fleet.set_index(\"veh\")\n", + "recs = []\n", + "for e in ev.sample(20, random_state=0).itertuples():\n", + " first = log[log[\"ev_id\"] == e.ev_id].iloc[0]\n", + " cs = e.codes\n", + " recs.append({\n", + " \"dtc_log\": {\"ev_id\": e.ev_id, \"veh_id\": e.veh, \"ts\": float(round(first[\"ts\"], 1)), \"code\": cs[0], \"co_count\": len(cs)},\n", + " \"work_order\": {\"part_id\": e.part, \"system\": PSYS[e.part], \"repair_h\": float(REPH[e.part]), \"unit_cost\": float(COST[e.part]),\n", + " \"unplanned\": bool(len(cs) >= 3), \"month\": int(e.month)},\n", + " \"vehicle\": {\"age\": float(f.loc[e.veh, \"age\"]), \"op_hours\": float(round(f.loc[e.veh, \"base_h\"], 1)), \"pm_gap\": float(round(f.loc[e.veh, \"pm_gap\"], 1)),\n", + " \"terrain\": float(round(f.loc[e.veh, \"terrain\"], 2)), \"load\": float(round(f.loc[e.veh, \"load\"], 2)), \"temp\": float(round(e.temp, 1))},\n", + " \"manual\": {\"code_ref\": cs[0], \"cand_parts\": [p for p in PID if cs[0] in (SPEC[p], SHARED.get(p))] or [e.part], \"step_no\": 1,\n", + " \"safety_flag\": PSYS[e.part] in CRIT_SYS},\n", + " \"target\": {\"fail_rate\": float(RATE[e.part]), \"confidence\": 0.8}})\n", + "ok = sum(not validate(r) for r in recs)\n", + "print(f\"가상 데이터 {len(ev)}건(DTC 로그 {len(log)}행) 생성, 표본 {len(recs)}건 중 검증 통과 {ok}건, 오류 {len(recs) - ok}건\")\n", + "print(f\"전체 DTC 로그 코드 허용값 검증: {'통과' if log['code'].isin(CODES).all() else '실패'}\")\n", + "\n", + "# 4) 오류 주입 시험\n", + "bad = json.loads(json.dumps(recs[0]))\n", + "bad[\"dtc_log\"][\"code\"] = \"C999\" # 정의되지 않은 DTC 코드\n", + "bad[\"dtc_log\"][\"veh_id\"] = \"TANK-7\" # ID 형식 오류\n", + "bad[\"vehicle\"][\"load\"] = 1.8 # 범위 초과\n", + "del bad[\"work_order\"][\"repair_h\"] # 필수 필드 누락\n", + "bad[\"target\"][\"confidence\"] = \"높음\" # 자료형 오류\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__, d) for g, fl in S.items() for k, (t, r, d) in fl.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" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 01 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + "가상 데이터 10681건(DTC 로그 22079행) 생성, 표본 20건 중 검증 통과 20건, 오류 0건\n", + "전체 DTC 로그 코드 허용값 검증: 통과\n", + "\n", + "[오류 주입 결과]\n", + " - dtc_log.veh_id: 형식 불일치 'TANK-7'\n", + " - dtc_log.code: 허용되지 않은 값 'C999'\n", + " - work_order.repair_h: 필수 필드 누락\n", + " - vehicle.load: 최대값 1 초과\n", + " - target.confidence: 자료형 오류(str)\n", + "\n", + "[업무 프로세스 맵]\n", + "1.DTC 발생 -> 2.로그 수집·정제 -> 3.동시 발생 묶음 -> 4.고장 진단 -> 5.정비 조치 -> 6.정비 이력 연결 -> 7.고장률 집계 -> 8.다빈도 품목 식별 -> 9.사후검토(AAR)\n", + " DTC 발생 : 차량 ID, 발생 시각, DTC 코드, 가동시간 계기값\n", + " 로그 수집·정제 : 중복 제거, 코드·시각 표기 통일\n", + " 동시 발생 묶음 : 같은 정비 건에서 발생한 DTC 세트(정규화)\n", + " 고장 진단 : 추정 고장 부품, 신뢰도\n", + " 정비 조치 : 정비 매뉴얼 절차, 수리시간, 교체 부품\n", + " 정비 이력 연결 : 정비 건 ID로 DTC와 실제 고장 부품 연결\n", + " 고장률 집계 : 부품별 1000가동시간당 고장 건수\n", + " 다빈도 품목 식별 : 파레토(ABC) 등급, 관리 대상 선정\n", + " 사후검토(AAR) : 오진단 사례, 규칙 조정 이력\n", + "\n", + "[데이터 딕셔너리] 피처 23개 (목표 20개 이상: 달성)\n", + " 그룹 필드 자료형 설명\n", + " dtc_log ev_id str 정비 건 ID(DTC와 정비 이력을 잇는 키)\n", + " dtc_log veh_id str 차량 ID(가상)\n", + " dtc_log ts float DTC 발생 시각(모의 시작 후 경과 시간, h)\n", + " dtc_log code str DTC 코드(고유·공유·계통 증상 28종)\n", + " dtc_log co_count int 같은 정비 건에서 동시 발생한 DTC 수\n", + "work_order part_id str 실제 고장 부품(정비 이력) - 예측대상 1\n", + "work_order system str 고장 계통\n", + "work_order repair_h float 수리 소요시간(h)\n", + "work_order unit_cost float 부품 단가지수(가상)\n", + "work_order unplanned bool 비계획·긴급 정비 여부\n", + "work_order month int 정비 월(모의 시작 기준)\n", + " vehicle age float 차령(년)\n", + " vehicle op_hours float 월 가동시간(h)\n", + " vehicle pm_gap float 마지막 예방정비 후 경과 가동시간(h)\n", + " vehicle terrain float 운용 지형 가혹도(0~1)\n", + " vehicle load float 운용 부하 수준(0~1)\n", + "... (이하 생략)\n", + "```\n", + "
\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 02. DTC 시계열 발생 로그 정제, 동시 발생 코드 정규화 전처리 모듈 구현\n", + "**소요시간**: 약 2.0시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "DTC 발생 로그 정제 및 동시 발생 코드 정규화 전처리 모듈을 구현한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **DTC 시계열 발생 로그를 정제하고 같은 정비 건에서 동시 발생한 코드를 정규화하는 전처리 모듈을 구현한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **중복 행 제거, 차량 ID·DTC 코드·시각 표기 통일, 가동시간 단위 통일, 음수 가동시간 결측 처리, 미등록 차량·미정의 코드 격리, KNN 결측 보정, 동시 발생 코드의 정비 건 묶음·정렬 등 정제 규칙 10종을 적용 수행하고 검증하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `src/etl/mnt03_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": [ + "\"\"\"표기 불일치·중복·단위 혼재·결측·미등록 코드가 섞인 가상 DTC 발생 로그에 정제 규칙 10종을 적용하고, 같은 시간대에 함께 발생한 DTC를 정비 건 단위로 묶어 정규화(중복 제거·정렬)한 마스터 데이터를 만든다. 긴급·비계획 정비는 보존하며 정합성 검증 통과율과 정제 전후 통계를 확인한다.\"\"\"\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", + "# [교육용가상자료] 가상 차량 100대의 18개월 고장 진단 코드(DTC)·정비 이력 생성기 (실제 부대·장비·정비 실적 없음)\n", + "# 부품 16종(1000가동시간당 고장률 차등), DTC 28종(부품 고유 코드, 짝 부품 공유 코드, 계통 증상 코드)\n", + "# 고장 1건(정비 이력)마다 DTC가 1~5개 동시 발생하고, 연쇄 고장 코드와 일시 잡음 코드가 섞인다.\n", + "# ---------------------------------------------------------------------------\n", + "PARTS = [ # (ID, 이름, 계통, 1000가동시간당 고장률, 가상 단가지수, 수리시간 h)\n", + " (\"P01\", \"연료분사기\", \"engine\", 12.0, 3.0, 2.0), (\"P02\", \"터보차저\", \"engine\", 9.0, 8.0, 5.0),\n", + " (\"P03\", \"축전지\", \"electric\", 6.5, 4.0, 1.5), (\"P04\", \"발전기\", \"electric\", 4.0, 9.0, 3.0),\n", + " (\"P05\", \"변속기 제어모듈\", \"drive\", 3.0, 12.0, 6.0), (\"P06\", \"냉각수 펌프\", \"engine\", 2.2, 3.5, 3.0),\n", + " (\"P07\", \"유압 펌프\", \"hydraulic\", 1.6, 10.0, 4.0), (\"P08\", \"속도 센서\", \"drive\", 1.2, 2.0, 1.5),\n", + " (\"P09\", \"ECU 커넥터\", \"electric\", 0.9, 2.5, 1.5), (\"P10\", \"연료 필터\", \"engine\", 0.7, 0.8, 0.7),\n", + " (\"P11\", \"제동 밸브\", \"brake\", 0.5, 6.0, 3.0), (\"P12\", \"조향 액추에이터\", \"drive\", 0.4, 9.0, 5.0),\n", + " (\"P13\", \"공기 필터\", \"engine\", 0.3, 0.5, 0.5), (\"P14\", \"통신 인터페이스 모듈\", \"electric\", 0.25, 3.0, 1.5),\n", + " (\"P15\", \"윤활유 압력 센서\", \"engine\", 0.2, 1.0, 1.0), (\"P16\", \"팬 구동 모터\", \"cooling\", 0.15, 2.0, 2.0)]\n", + "PID = [p[0] for p in PARTS]\n", + "PNAME = {p[0]: p[1] for p in PARTS}; PSYS = {p[0]: p[2] for p in PARTS}; RATE = {p[0]: p[3] for p in PARTS}\n", + "COST = {p[0]: p[4] for p in PARTS}; REPH = {p[0]: p[5] for p in PARTS}\n", + "PAIRS = [(\"P01\", \"P02\"), (\"P03\", \"P04\"), (\"P05\", \"P08\"), (\"P06\", \"P10\"), (\"P09\", \"P14\"), (\"P13\", \"P15\")]\n", + "SHARED = {p: f\"S{k + 1:02d}\" for k, pr in enumerate(PAIRS) for p in pr} # 짝 부품이 함께 쓰는 모호한 코드\n", + "SPEC = {p: f\"C{p[1:]}1\" for p in PID} # 부품 고유 코드\n", + "SYS = [\"engine\", \"electric\", \"drive\", \"hydraulic\", \"brake\", \"cooling\"]\n", + "SYMP = {s: f\"G{i + 1:02d}\" for i, s in enumerate(SYS)} # 계통 증상 코드\n", + "CODES = sorted(set(SPEC.values()) | set(SHARED.values()) | set(SYMP.values()))\n", + "CASCADE = {\"P01\": [(\"P10\", 0.20)], \"P02\": [(\"P06\", 0.20)], \"P03\": [(\"P04\", 0.25)], \"P04\": [(\"P09\", 0.25)],\n", + " \"P05\": [(\"P08\", 0.30)], \"P06\": [(\"P16\", 0.30)], \"P09\": [(\"P14\", 0.20)], \"P12\": [(\"P07\", 0.20)]}\n", + "CRIT_SYS = {\"engine\", \"drive\", \"brake\"} # 기동·제동 불능 위험 계통(경보 대상)\n", + "MONTHS = 18\n", + "\n", + "\n", + "def make_fleet(n=100, seed=0):\n", + " rng = np.random.default_rng(seed)\n", + " f = pd.DataFrame({\"veh\": [f\"V{i:03d}\" for i in range(n)], \"age\": rng.integers(1, 26, n).astype(float),\n", + " \"terrain\": rng.uniform(0, 1, n), \"load\": rng.uniform(0, 1, n), \"pm_gap\": rng.uniform(20, 200, n),\n", + " \"humid\": rng.uniform(0, 1, n), \"base_h\": rng.uniform(55, 125, n)})\n", + " f[\"mult\"] = np.exp(0.045 * (f[\"age\"] - 13) + 0.9 * (f[\"terrain\"] - .5) + 0.8 * (f[\"load\"] - .5)\n", + " + 0.006 * (f[\"pm_gap\"] - 110) + 0.7 * (f[\"humid\"] - .5))\n", + " return f\n", + "\n", + "\n", + "def make_data(seed=1, months=MONTHS):\n", + " \"\"\"반환: fleet(차량 속성), ev(정비 이력 1건=1행: 발생 DTC 목록, 실제 고장 부품), log(DTC 발생 로그, 행=코드 1건)\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " fleet = make_fleet()\n", + " V = len(fleet)\n", + " temp = 12 + 14 * np.sin(2 * np.pi * (np.arange(months) - 3) / 12) # 월평균 기온(가상)\n", + " hours = fleet[\"base_h\"].values[:, None] * (1 + 0.25 * np.sin(2 * np.pi * np.arange(months) / 12 + 1)) * rng.lognormal(0, .12, (V, months))\n", + " fleet[\"hours\"] = hours.sum(axis=1) # 차량별 18개월 총 가동시간\n", + " fleet.attrs[\"month_h\"] = hours.sum(axis=0) # 월별 전체 가동시간(고장률 분모)\n", + " evs = []\n", + " for p in PID:\n", + " s = np.ones(months)\n", + " if p in (\"P03\", \"P04\"): s = np.exp(-0.06 * (temp - 12)) # 한랭기 전기계통 증가\n", + " if p in (\"P06\", \"P16\"): s = np.exp(0.05 * (temp - 12)) # 혹서기 냉각계통 증가\n", + " cnt = rng.poisson(RATE[p] * hours / 1000 * fleet[\"mult\"].values[:, None] * s[None, :])\n", + " for v, m in zip(*np.nonzero(cnt)):\n", + " for _ in range(cnt[v, m]):\n", + " evs.append((fleet[\"veh\"][v], int(m), m * 720 + rng.uniform(0, 720), p, float(temp[m] + rng.normal(0, 3))))\n", + " ev = pd.DataFrame(evs, columns=[\"veh\", \"month\", \"t\", \"part\", \"temp\"]).sort_values(\"t\").reset_index(drop=True)\n", + " ev[\"ev_id\"] = [f\"W{i:05d}\" for i in range(len(ev))]\n", + " rows, codes = [], []\n", + " for e in ev.itertuples():\n", + " c, r = [], rng.random(3)\n", + " if r[0] < 0.6: c.append(SPEC[e.part])\n", + " if e.part in SHARED and r[1] < 0.6: c.append(SHARED[e.part])\n", + " if r[2] < 0.5: c.append(SYMP[PSYS[e.part]])\n", + " if not c: c.append(SHARED.get(e.part, SPEC[e.part]))\n", + " for q, pr in CASCADE.get(e.part, []):\n", + " if rng.random() < pr: c.append(SPEC[q])\n", + " if rng.random() < 0.12: c.append(SPEC[PID[rng.integers(0, 16)]])\n", + " c = list(dict.fromkeys(c))\n", + " rng.shuffle(c) # 동시 발생 코드의 기록 순서는 임의\n", + " codes.append(c)\n", + " for k, cd in enumerate(c):\n", + " rows.append((e.ev_id, e.veh, e.t + k * 0.1, cd))\n", + " ev[\"codes\"] = codes\n", + " log = pd.DataFrame(rows, columns=[\"ev_id\", \"veh\", \"ts\", \"code\"])\n", + " return fleet, ev, log\n", + "\n", + "# 1) [교육용가상자료] 원천 로그: 생성 로그에 일부러 오류를 섞는다\n", + "fleet, ev, log = make_data()\n", + "rng = np.random.default_rng(2)\n", + "clean_n = len(log)\n", + "start = pd.Timestamp(\"2025-01-01\")\n", + "raw = pd.DataFrame({\"veh\": log[\"veh\"].values, \"ts\": [(start + pd.Timedelta(hours=t)).strftime(\"%Y-%m-%d %H:%M\") for t in log[\"ts\"]],\n", + " \"code\": log[\"code\"].values, \"emergency\": False})\n", + "vn = raw[\"veh\"].str[1:].astype(int).values\n", + "raw[\"op_h\"] = (vn * 37 % 900) + log[\"ts\"].values * 0.15 # 차량별 누적 가동시간 계기값(가상)\n", + "raw[\"op_unit\"] = \"h\"\n", + "raw[\"op_h\"] = raw[\"op_h\"].round(1)\n", + "idx = rng.choice(len(raw), 2600, replace=False)\n", + "a, b, c, d_, e, f, g, h_, k = np.split(idx, [400, 900, 1500, 1800, 1880, 1960, 2040, 2300])\n", + "raw.loc[a, \"veh\"] = rng.choice([\"v\", \" V\"], len(a)) + raw.loc[a, \"veh\"].str[1:] # 차량 ID 표기 불일치\n", + "raw.loc[b, \"code\"] = [x.lower() if i % 2 else x[0] + \"-\" + x[1:] + \" \" for i, x in enumerate(raw.loc[b, \"code\"])] # 코드 표기 불일치\n", + "raw.loc[c, \"ts\"] = pd.to_datetime(raw.loc[c, \"ts\"]).dt.strftime(\"%Y/%m/%d %H:%M:%S\") # 시각 형식 혼재\n", + "raw.loc[d_, \"op_h\"] = (raw.loc[d_, \"op_h\"] * 60).round(0); raw.loc[d_, \"op_unit\"] = \"min\" # 단위 혼재\n", + "raw.loc[e, \"op_h\"] = -1.0 # 불가능 값\n", + "raw.loc[f, \"veh\"] = \"V999\" # 미등록 차량\n", + "raw.loc[g, \"code\"] = \"ZZ9\" # 미정의 코드\n", + "raw.loc[h_, \"op_h\"] = np.nan # 결측\n", + "burst = raw.iloc[k[:40]].copy() # 긴급·비계획 정비: DTC 6개 폭주\n", + "bursts = []\n", + "for _, r in burst.iterrows():\n", + " for j, cd in enumerate(rng.choice(CODES, 6, replace=False)):\n", + " bursts.append({**r, \"code\": cd, \"emergency\": True,\n", + " \"ts\": (pd.to_datetime(r[\"ts\"]) + pd.Timedelta(minutes=j)).strftime(\"%Y-%m-%d %H:%M\")})\n", + "raw = pd.concat([raw, pd.DataFrame(bursts), raw.sample(150, random_state=1)], ignore_index=True) # 폭주 건 + 중복 150건\n", + "raw = raw.sample(frac=1, random_state=3).reset_index(drop=True)\n", + "print(f\"원천 로그 {len(raw)}행 (정상 {clean_n}행 + 긴급 폭주 {len(bursts)}행 + 중복 150행, 오류 주입 {len(idx)}행)\")\n", + "\n", + "# 2) 정제 규칙 10종\n", + "df, applied = raw.copy(), []\n", + "def rule(name, n):\n", + " applied.append((name, int(n)))\n", + "\n", + "rule(\"R1 중복 행 제거\", df.duplicated().sum()); df = df.drop_duplicates().reset_index(drop=True)\n", + "v = df[\"veh\"].str.strip().str.upper(); rule(\"R2 차량 ID 표기 통일\", (v != df[\"veh\"]).sum()); df[\"veh\"] = v\n", + "cd = df[\"code\"].str.strip().str.upper().str.replace(\"-\", \"\", regex=False); rule(\"R3 DTC 코드 표기 통일\", (cd != df[\"code\"]).sum()); df[\"code\"] = cd\n", + "t1 = pd.to_datetime(df[\"ts\"], format=\"%Y-%m-%d %H:%M\", errors=\"coerce\")\n", + "t2 = pd.to_datetime(df[\"ts\"], format=\"%Y/%m/%d %H:%M:%S\", errors=\"coerce\"); rule(\"R4 발생 시각 형식 통일\", t1.isna().sum()); df[\"ts\"] = t1.fillna(t2)\n", + "mn = df[\"op_unit\"] == \"min\"; df.loc[mn, \"op_h\"] = df.loc[mn, \"op_h\"] / 60; df[\"op_unit\"] = \"h\"; rule(\"R5 가동시간 단위 통일(분 -> 시간)\", mn.sum())\n", + "bad = df[\"op_h\"] < 0; df.loc[bad, \"op_h\"] = np.nan; rule(\"R6 음수 가동시간 결측 처리\", bad.sum())\n", + "unk = ~df[\"veh\"].isin(fleet[\"veh\"]) | ~df[\"code\"].isin(CODES); quarantine = df[unk].copy(); df = df[~unk].reset_index(drop=True)\n", + "rule(\"R7 미등록 차량·미정의 코드 격리(삭제 아님)\", unk.sum())\n", + "df[\"hrs\"] = (df[\"ts\"] - start).dt.total_seconds() / 3600; df[\"vn\"] = df[\"veh\"].str[1:].astype(int)\n", + "miss = df[\"op_h\"].isna().sum()\n", + "df[\"op_h\"] = KNNImputer(n_neighbors=5).fit_transform(df[[\"hrs\", \"vn\", \"op_h\"]].assign(vn=df[\"vn\"] / 10))[:, 2]; rule(\"R8 가동시간 결측 KNN 보정\", miss)\n", + "df = df.sort_values([\"veh\", \"ts\"]).reset_index(drop=True)\n", + "df[\"case\"] = (df[\"veh\"] != df[\"veh\"].shift()) | (df[\"ts\"].diff() > pd.Timedelta(hours=1)); df[\"case_id\"] = df[\"case\"].cumsum()\n", + "before = len(df); df = df.drop_duplicates([\"case_id\", \"code\"]).reset_index(drop=True)\n", + "cases = df.groupby(\"case_id\").agg(veh=(\"veh\", \"first\"), t=(\"ts\", \"first\"), codes=(\"code\", lambda s: tuple(sorted(s))), n=(\"code\", \"size\"), emergency=(\"emergency\", \"max\"))\n", + "print(f\"(동시 발생 묶음 과정에서 중복 코드 {before - len(df)}행 제거)\")\n", + "rule(\"R9 동시 발생 코드 정비 건 묶음·정렬(생성 건수)\", len(cases))\n", + "cases[\"outlier_flag\"] = (cases[\"n\"] > 4) & ~cases[\"emergency\"]\n", + "rule(\"R10 긴급·비계획 정비 보존(일반 이상 건은 표시만)\", cases[\"emergency\"].sum())\n", + "print(\"\\n[적용 규칙과 처리 건수]\")\n", + "for nme, n in applied:\n", + " print(f\" {nme:40s} {n:5d}건\")\n", + "\n", + "# 3) 정합성 검증\n", + "checks = {\"중복 0건\": not df.duplicated([\"case_id\", \"code\"]).any(), \"차량 ID 형식\": df[\"veh\"].str.match(r\"^V\\d{3}$\").all(),\n", + " \"코드 허용 범위\": df[\"code\"].isin(CODES).all(), \"시각 변환\": df[\"ts\"].notna().all(), \"가동시간 단위 h\": (df[\"op_unit\"] == \"h\").all(),\n", + " \"가동시간 0 이상\": (df[\"op_h\"] >= 0).all(), \"미등록 건 격리 보관\": len(quarantine) == unk.sum(),\n", + " \"결측 0건\": df[[\"veh\", \"code\", \"ts\", \"op_h\"]].notna().all().all(), \"묶음 내 코드 정렬\": all(list(c) == sorted(c) for c in cases[\"codes\"]),\n", + " f\"긴급 정비 {len(bursts) // 6}건 보존\": int(cases[\"emergency\"].sum()) >= len(bursts) // 6}\n", + "rate = np.mean(list(checks.values()))\n", + "print(f\"\\n정합성 검증 {sum(checks.values())}/{len(checks)} 통과, 통과율 {rate:.0%} (목표 100%: {'달성' if rate == 1 else '미달'})\")\n", + "for kx, vx in checks.items():\n", + " if not vx:\n", + " print(\" 실패:\", kx)\n", + "print(f\"정비 건 {len(cases)}건 생성(평균 DTC {cases['n'].mean():.2f}개), 이상 표시 {int(cases['outlier_flag'].sum())}건\")\n", + "\n", + "# 4) 정제 전후 기술통계 비교\n", + "num = pd.to_numeric(raw[\"op_h\"], errors=\"coerce\")\n", + "cmp = pd.DataFrame({\"정제 전\": [len(raw), raw[\"veh\"].nunique(), raw[\"code\"].nunique(), num.mean(), num.min(), num.isna().sum()],\n", + " \"정제 후\": [len(df), df[\"veh\"].nunique(), df[\"code\"].nunique(), df[\"op_h\"].mean(), df[\"op_h\"].min(), df[\"op_h\"].isna().sum()]},\n", + " index=[\"행 수\", \"차량 종류 수\", \"코드 종류 수\", \"가동시간 평균(h)\", \"가동시간 최소(h)\", \"결측 수\"]).round(1)\n", + "print(\"\\n[정제 전후 기술통계]\"); print(cmp.to_string())\n", + "\n", + "fig, ax = plt.subplots(1, 2, figsize=(10, 3.5))\n", + "ax[0].hist(num.dropna(), bins=40, alpha=0.6, label=\"raw\"); ax[0].hist(df[\"op_h\"], bins=40, alpha=0.6, label=\"cleaned\")\n", + "ax[0].set_title(\"Operating hours 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": [ + "
\n", + "💡 [참고] 미션 02 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + "원천 로그 22469행 (정상 22079행 + 긴급 폭주 240행 + 중복 150행, 오류 주입 2600행)\n", + "(동시 발생 묶음 과정에서 중복 코드 68행 제거)\n", + "\n", + "[적용 규칙과 처리 건수]\n", + " R1 중복 행 제거 150건\n", + " R2 차량 ID 표기 통일 400건\n", + " R3 DTC 코드 표기 통일 500건\n", + " R4 발생 시각 형식 통일 600건\n", + " R5 가동시간 단위 통일(분 -> 시간) 300건\n", + " R6 음수 가동시간 결측 처리 80건\n", + " R7 미등록 차량·미정의 코드 격리(삭제 아님) 160건\n", + " R8 가동시간 결측 KNN 보정 340건\n", + " R9 동시 발생 코드 정비 건 묶음·정렬(생성 건수) 10504건\n", + " R10 긴급·비계획 정비 보존(일반 이상 건은 표시만) 40건\n", + "\n", + "정합성 검증 10/10 통과, 통과율 100% (목표 100%: 달성)\n", + "정비 건 10504건 생성(평균 DTC 2.10개), 이상 표시 85건\n", + "\n", + "[정제 전후 기술통계]\n", + " 정제 전 정제 후\n", + "행 수 22469.0 22091.0\n", + "차량 종류 수 252.0 100.0\n", + "코드 종류 수 82.0 28.0\n", + "가동시간 평균(h) 2496.2 1372.4\n", + "가동시간 최소(h) -1.0 3.8\n", + "결측 수 261.0 0.0\n", + "```\n", + "
\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 03. 단일 고장 코드 기반 1차 고장 부품 매핑 Baseline 알고리즘 구현\n", + "**소요시간**: 약 2.5시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "단일 고장 코드 기반 1차 고장 부품 매핑 Baseline 알고리즘을 구현한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **단일 고장 코드 기반 1차 고장 부품 매핑 Baseline(첫 기록 코드와 가장 자주 함께 나온 부품)을 구현하고, 탐색적 분석으로 다빈도 고장품목과 고장 증가 요인을 찾는다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **학습 기간 이력으로 코드와 부품의 대응표를 만들어 검증 기간의 부품 정확도를 측정하고, 공유·증상 코드처럼 모호한 코드가 첫 기록일 때 정확도가 떨어지는지 확인하고 유효성을 점검하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `notebooks/mnt03_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": [ + "\"\"\"단일 DTC 코드만 보고 가장 흔한 고장 부품을 고르는 1차 Baseline 매핑 알고리즘을 구현해 정확도와 한계를 확인하고, 탐색적 분석(EDA 차트 8종, 고장 건수와 피처의 상관계수·p-value, 파레토 ABC 분류)으로 1000가동시간당 고장률이 높은 다빈도 고장품목을 식별한다.\"\"\"\n", + "import numpy as np\n", + "import pandas as pd\n", + "import matplotlib.pyplot as plt\n", + "from scipy.stats import pearsonr\n", + "\n", + "# ---------------------------------------------------------------------------\n", + "# [교육용가상자료] 가상 차량 100대의 18개월 고장 진단 코드(DTC)·정비 이력 생성기 (실제 부대·장비·정비 실적 없음)\n", + "# 부품 16종(1000가동시간당 고장률 차등), DTC 28종(부품 고유 코드, 짝 부품 공유 코드, 계통 증상 코드)\n", + "# 고장 1건(정비 이력)마다 DTC가 1~5개 동시 발생하고, 연쇄 고장 코드와 일시 잡음 코드가 섞인다.\n", + "# ---------------------------------------------------------------------------\n", + "PARTS = [ # (ID, 이름, 계통, 1000가동시간당 고장률, 가상 단가지수, 수리시간 h)\n", + " (\"P01\", \"연료분사기\", \"engine\", 12.0, 3.0, 2.0), (\"P02\", \"터보차저\", \"engine\", 9.0, 8.0, 5.0),\n", + " (\"P03\", \"축전지\", \"electric\", 6.5, 4.0, 1.5), (\"P04\", \"발전기\", \"electric\", 4.0, 9.0, 3.0),\n", + " (\"P05\", \"변속기 제어모듈\", \"drive\", 3.0, 12.0, 6.0), (\"P06\", \"냉각수 펌프\", \"engine\", 2.2, 3.5, 3.0),\n", + " (\"P07\", \"유압 펌프\", \"hydraulic\", 1.6, 10.0, 4.0), (\"P08\", \"속도 센서\", \"drive\", 1.2, 2.0, 1.5),\n", + " (\"P09\", \"ECU 커넥터\", \"electric\", 0.9, 2.5, 1.5), (\"P10\", \"연료 필터\", \"engine\", 0.7, 0.8, 0.7),\n", + " (\"P11\", \"제동 밸브\", \"brake\", 0.5, 6.0, 3.0), (\"P12\", \"조향 액추에이터\", \"drive\", 0.4, 9.0, 5.0),\n", + " (\"P13\", \"공기 필터\", \"engine\", 0.3, 0.5, 0.5), (\"P14\", \"통신 인터페이스 모듈\", \"electric\", 0.25, 3.0, 1.5),\n", + " (\"P15\", \"윤활유 압력 센서\", \"engine\", 0.2, 1.0, 1.0), (\"P16\", \"팬 구동 모터\", \"cooling\", 0.15, 2.0, 2.0)]\n", + "PID = [p[0] for p in PARTS]\n", + "PNAME = {p[0]: p[1] for p in PARTS}; PSYS = {p[0]: p[2] for p in PARTS}; RATE = {p[0]: p[3] for p in PARTS}\n", + "COST = {p[0]: p[4] for p in PARTS}; REPH = {p[0]: p[5] for p in PARTS}\n", + "PAIRS = [(\"P01\", \"P02\"), (\"P03\", \"P04\"), (\"P05\", \"P08\"), (\"P06\", \"P10\"), (\"P09\", \"P14\"), (\"P13\", \"P15\")]\n", + "SHARED = {p: f\"S{k + 1:02d}\" for k, pr in enumerate(PAIRS) for p in pr} # 짝 부품이 함께 쓰는 모호한 코드\n", + "SPEC = {p: f\"C{p[1:]}1\" for p in PID} # 부품 고유 코드\n", + "SYS = [\"engine\", \"electric\", \"drive\", \"hydraulic\", \"brake\", \"cooling\"]\n", + "SYMP = {s: f\"G{i + 1:02d}\" for i, s in enumerate(SYS)} # 계통 증상 코드\n", + "CODES = sorted(set(SPEC.values()) | set(SHARED.values()) | set(SYMP.values()))\n", + "CASCADE = {\"P01\": [(\"P10\", 0.20)], \"P02\": [(\"P06\", 0.20)], \"P03\": [(\"P04\", 0.25)], \"P04\": [(\"P09\", 0.25)],\n", + " \"P05\": [(\"P08\", 0.30)], \"P06\": [(\"P16\", 0.30)], \"P09\": [(\"P14\", 0.20)], \"P12\": [(\"P07\", 0.20)]}\n", + "CRIT_SYS = {\"engine\", \"drive\", \"brake\"} # 기동·제동 불능 위험 계통(경보 대상)\n", + "MONTHS = 18\n", + "\n", + "\n", + "def make_fleet(n=100, seed=0):\n", + " rng = np.random.default_rng(seed)\n", + " f = pd.DataFrame({\"veh\": [f\"V{i:03d}\" for i in range(n)], \"age\": rng.integers(1, 26, n).astype(float),\n", + " \"terrain\": rng.uniform(0, 1, n), \"load\": rng.uniform(0, 1, n), \"pm_gap\": rng.uniform(20, 200, n),\n", + " \"humid\": rng.uniform(0, 1, n), \"base_h\": rng.uniform(55, 125, n)})\n", + " f[\"mult\"] = np.exp(0.045 * (f[\"age\"] - 13) + 0.9 * (f[\"terrain\"] - .5) + 0.8 * (f[\"load\"] - .5)\n", + " + 0.006 * (f[\"pm_gap\"] - 110) + 0.7 * (f[\"humid\"] - .5))\n", + " return f\n", + "\n", + "\n", + "def make_data(seed=1, months=MONTHS):\n", + " \"\"\"반환: fleet(차량 속성), ev(정비 이력 1건=1행: 발생 DTC 목록, 실제 고장 부품), log(DTC 발생 로그, 행=코드 1건)\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " fleet = make_fleet()\n", + " V = len(fleet)\n", + " temp = 12 + 14 * np.sin(2 * np.pi * (np.arange(months) - 3) / 12) # 월평균 기온(가상)\n", + " hours = fleet[\"base_h\"].values[:, None] * (1 + 0.25 * np.sin(2 * np.pi * np.arange(months) / 12 + 1)) * rng.lognormal(0, .12, (V, months))\n", + " fleet[\"hours\"] = hours.sum(axis=1) # 차량별 18개월 총 가동시간\n", + " fleet.attrs[\"month_h\"] = hours.sum(axis=0) # 월별 전체 가동시간(고장률 분모)\n", + " evs = []\n", + " for p in PID:\n", + " s = np.ones(months)\n", + " if p in (\"P03\", \"P04\"): s = np.exp(-0.06 * (temp - 12)) # 한랭기 전기계통 증가\n", + " if p in (\"P06\", \"P16\"): s = np.exp(0.05 * (temp - 12)) # 혹서기 냉각계통 증가\n", + " cnt = rng.poisson(RATE[p] * hours / 1000 * fleet[\"mult\"].values[:, None] * s[None, :])\n", + " for v, m in zip(*np.nonzero(cnt)):\n", + " for _ in range(cnt[v, m]):\n", + " evs.append((fleet[\"veh\"][v], int(m), m * 720 + rng.uniform(0, 720), p, float(temp[m] + rng.normal(0, 3))))\n", + " ev = pd.DataFrame(evs, columns=[\"veh\", \"month\", \"t\", \"part\", \"temp\"]).sort_values(\"t\").reset_index(drop=True)\n", + " ev[\"ev_id\"] = [f\"W{i:05d}\" for i in range(len(ev))]\n", + " rows, codes = [], []\n", + " for e in ev.itertuples():\n", + " c, r = [], rng.random(3)\n", + " if r[0] < 0.6: c.append(SPEC[e.part])\n", + " if e.part in SHARED and r[1] < 0.6: c.append(SHARED[e.part])\n", + " if r[2] < 0.5: c.append(SYMP[PSYS[e.part]])\n", + " if not c: c.append(SHARED.get(e.part, SPEC[e.part]))\n", + " for q, pr in CASCADE.get(e.part, []):\n", + " if rng.random() < pr: c.append(SPEC[q])\n", + " if rng.random() < 0.12: c.append(SPEC[PID[rng.integers(0, 16)]])\n", + " c = list(dict.fromkeys(c))\n", + " rng.shuffle(c) # 동시 발생 코드의 기록 순서는 임의\n", + " codes.append(c)\n", + " for k, cd in enumerate(c):\n", + " rows.append((e.ev_id, e.veh, e.t + k * 0.1, cd))\n", + " ev[\"codes\"] = codes\n", + " log = pd.DataFrame(rows, columns=[\"ev_id\", \"veh\", \"ts\", \"code\"])\n", + " return fleet, ev, log\n", + "\n", + "fleet, ev, log = make_data()\n", + "ev[\"first\"] = ev[\"codes\"].str[0]\n", + "train, test = ev[ev[\"month\"] < 12], ev[ev[\"month\"] >= 12]\n", + "\n", + "# 1) Baseline: 첫 번째로 기록된 코드 -> 학습 기간에 그 코드와 가장 자주 함께 나온 부품\n", + "mp = train.groupby(\"first\")[\"part\"].agg(lambda s: s.value_counts().index[0])\n", + "pred = test[\"first\"].map(mp)\n", + "acc = (pred == test[\"part\"]).mean()\n", + "sys_acc = (pred.map(PSYS) == test[\"part\"].map(PSYS)).mean()\n", + "print(f\"[교육용가상자료] 정비 건 {len(ev)}건, DTC 로그 {len(log)}행, 차량 {len(fleet)}대, 18개월\")\n", + "print(f\"Baseline 부품 정확도 {acc:.1%}, 고장 계통 정확도 {sys_acc:.1%} (검증 {len(test)}건)\")\n", + "amb = test[\"first\"].isin(list(SHARED.values()) + list(SYMP.values()))\n", + "print(f\" 모호한 코드(공유·증상) 첫 기록 건 정확도 {(pred[amb] == test['part'][amb]).mean():.1%} vs 고유 코드 {(pred[~amb] == test['part'][~amb]).mean():.1%}\")\n", + "\n", + "# 2) 부품별 고장률(1000가동시간당)과 파레토(ABC)\n", + "tot_h = fleet[\"hours\"].sum()\n", + "cnt = ev[\"part\"].value_counts().reindex(PID)\n", + "rate = (cnt / tot_h * 1000).round(3)\n", + "par = pd.DataFrame({\"부품\": [PNAME[p] for p in PID], \"건수\": cnt.values, \"고장률\": rate.values}, index=PID).sort_values(\"건수\", ascending=False)\n", + "par[\"누적비율\"] = par[\"건수\"].cumsum() / par[\"건수\"].sum()\n", + "par[\"등급\"] = np.where(par[\"누적비율\"] <= 0.80, \"A\", np.where(par[\"누적비율\"] <= 0.95, \"B\", \"C\"))\n", + "par.loc[par.index[0], \"등급\"] = \"A\"\n", + "print(f\"\\n[파레토] 총 가동 {tot_h:,.0f}시간, 전체 고장률 {cnt.sum() / tot_h * 1000:.1f}건/1000h\")\n", + "print(par.head(7).assign(누적비율=lambda d: d[\"누적비율\"].map(\"{:.0%}\".format)).to_string())\n", + "top20 = par.head(int(np.ceil(len(par) * 0.2)))\n", + "print(f\"상위 20% 품목({len(top20)}종: {', '.join(top20['부품'])})이 전체 고장의 {top20['건수'].sum() / cnt.sum():.0%}, A등급 {int((par['등급'] == 'A').sum())}종\")\n", + "\n", + "# 3) 상관분석: 차량별 정비 건수와 피처\n", + "vc = ev.groupby(\"veh\").size().reindex(fleet[\"veh\"]).fillna(0).values\n", + "rows = []\n", + "for col, lab in [(\"age\", \"차령\"), (\"pm_gap\", \"예방정비 경과시간\"), (\"terrain\", \"지형 가혹도\"), (\"load\", \"운용 부하\"), (\"hours\", \"총 가동시간\"), (\"humid\", \"습도 지수\")]:\n", + " r, p = pearsonr(fleet[col], vc); rows.append((lab, r, p))\n", + "mc = ev.groupby([\"month\", \"part\"]).size().unstack(fill_value=0)\n", + "tm = ev.groupby(\"month\")[\"temp\"].mean()\n", + "r, p = pearsonr(tm, mc[\"P03\"]); rows.append((\"월평균 기온-축전지 고장\", r, p))\n", + "cor = pd.DataFrame(rows, columns=[\"피처\", \"r\", \"p\"])\n", + "cor[\"유의\"] = cor[\"p\"] < 0.05\n", + "print(\"\\n[상관분석] 피처 | r | p-value | 유의(p<0.05)\")\n", + "for x in cor.itertuples():\n", + " print(f\" {x.피처:14s} {x.r:+.2f} {x.p:.4f} {'유의' if x.유의 else '-'}\")\n", + "nsig = int(cor[\"유의\"].sum())\n", + "\n", + "# 4) EDA 차트 8종\n", + "fig, ax = plt.subplots(2, 4, figsize=(16, 7))\n", + "ax[0, 0].bar(par.index, par[\"고장률\"]); ax[0, 0].set_title(\"1 Failure rate per 1000h\")\n", + "ax[0, 1].plot(range(len(par)), par[\"누적비율\"], marker=\"o\"); ax[0, 1].axhline(0.8, ls=\"--\", color=\"k\"); ax[0, 1].set_title(\"2 Pareto cumulative share\")\n", + "mc.sum(axis=1).plot(ax=ax[0, 2]); ax[0, 2].set_title(\"3 Monthly failures\")\n", + "ev[\"codes\"].str.len().value_counts().sort_index().plot.bar(ax=ax[0, 3]); ax[0, 3].set_title(\"4 DTCs per maintenance case\")\n", + "log[\"code\"].value_counts().head(10).plot.bar(ax=ax[1, 0]); ax[1, 0].set_title(\"5 Top DTC codes\")\n", + "ev[\"part\"].map(PSYS).value_counts().plot.bar(ax=ax[1, 1]); ax[1, 1].set_title(\"6 Failures by system\")\n", + "ax[1, 2].scatter(fleet[\"age\"], vc, s=8); ax[1, 2].set_title(\"7 Vehicle age vs failures\")\n", + "ax[1, 3].scatter(fleet[\"pm_gap\"], vc, s=8); ax[1, 3].set_title(\"8 PM gap vs failures\")\n", + "plt.tight_layout(); plt.show()\n", + "\n", + "print(f\"\\nEDA 차트 8종 생성(목표 8종 이상: 달성), 유의한 피처 {nsig}개 (목표 5개 이상: {'달성' if nsig >= 5 else '미달'})\")\n", + "print(\"핵심 발견: 1) 소수 부품에 고장 집중 2) 공유·증상 코드가 첫 기록이면 Baseline이 자주 틀림 3) 고령·가혹 지형·예방정비 지연 차량의 고장 증가\")\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 03 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + "[교육용가상자료] 정비 건 10681건, DTC 로그 22079행, 차량 100대, 18개월\n", + "Baseline 부품 정확도 68.7%, 고장 계통 정확도 96.8% (검증 3890건)\n", + " 모호한 코드(공유·증상) 첫 기록 건 정확도 59.7% vs 고유 코드 80.6%\n", + "\n", + "[파레토] 총 가동 171,649시간, 전체 고장률 62.2건/1000h\n", + " 부품 건수 고장률 누적비율 등급\n", + "P01 연료분사기 2800 16.312 26% A\n", + "P02 터보차저 1985 11.564 45% A\n", + "P03 축전지 1983 11.553 63% A\n", + "P04 발전기 1244 7.247 75% A\n", + "P05 변속기 제어모듈 665 3.874 81% B\n", + "P06 냉각수 펌프 477 2.779 86% B\n", + "P07 유압 펌프 390 2.272 89% B\n", + "상위 20% 품목(4종: 연료분사기, 터보차저, 축전지, 발전기)이 전체 고장의 75%, A등급 4종\n", + "\n", + "[상관분석] 피처 | r | p-value | 유의(p<0.05)\n", + " 차령 +0.41 0.0000 유의\n", + " 예방정비 경과시간 +0.57 0.0000 유의\n", + " 지형 가혹도 +0.40 0.0000 유의\n", + " 운용 부하 +0.35 0.0004 유의\n", + " 총 가동시간 +0.32 0.0011 유의\n", + " 습도 지수 +0.07 0.5174 -\n", + " 월평균 기온-축전지 고장 -0.95 0.0000 유의\n", + "\n", + "EDA 차트 8종 생성(목표 8종 이상: 달성), 유의한 피처 6개 (목표 5개 이상: 달성)\n", + "핵심 발견: 1) 소수 부품에 고장 집중 2) 공유·증상 코드가 첫 기록이면 Baseline이 자주 틀림 3) 고령·가혹 지형·예방정비 지연 차량의 고장 증가\n", + "```\n", + "
\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 04. 연관 규칙 마이닝(Apriori/FP-Growth) 기반 다중 DTC 동시 발생 시 근본 원인 예측 모델 고도화\n", + "**소요시간**: 약 3.0시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "연관 규칙 마이닝 기반 다중 DTC 동시 발생 시 근본 원인 예측 모델을 고도화한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **연관 규칙 마이닝(Apriori)으로 다중 DTC 동시 발생 시 근본 원인 부품을 예측하는 모델을 고도화하고, 월간 고장 건수 기준 모델의 파생 피처를 설계한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **동시 발생 DTC 세트와 근본 원인 부품을 항목으로 보고 최소 지지도 미만을 가지치기하며 빈발 항목집합과 연관 규칙(신뢰도·향상도)을 구 수행하고 검증하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `src/features/mnt03_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": [ + "\"\"\"Apriori 연관 규칙 마이닝으로 여러 DTC가 동시에 나타났을 때의 근본 원인 부품을 예측하는 모델을 만들어 단일 코드 Baseline과 비교하고, 부품별 월간 고장 건수 예측용 파생 피처 10개 이상으로 기준 모델을 학습해 시계열 교차검증 MAPE와 피처 중요도를 확인한다.\"\"\"\n", + "import itertools\n", + "from collections import Counter\n", + "import numpy as np\n", + "import pandas as pd\n", + "import matplotlib.pyplot as plt\n", + "from sklearn.linear_model import Ridge\n", + "from sklearn.preprocessing import StandardScaler\n", + "from sklearn.pipeline import make_pipeline\n", + "from sklearn.inspection import permutation_importance\n", + "\n", + "# ---------------------------------------------------------------------------\n", + "# [교육용가상자료] 가상 차량 100대의 18개월 고장 진단 코드(DTC)·정비 이력 생성기 (실제 부대·장비·정비 실적 없음)\n", + "# 부품 16종(1000가동시간당 고장률 차등), DTC 28종(부품 고유 코드, 짝 부품 공유 코드, 계통 증상 코드)\n", + "# 고장 1건(정비 이력)마다 DTC가 1~5개 동시 발생하고, 연쇄 고장 코드와 일시 잡음 코드가 섞인다.\n", + "# ---------------------------------------------------------------------------\n", + "PARTS = [ # (ID, 이름, 계통, 1000가동시간당 고장률, 가상 단가지수, 수리시간 h)\n", + " (\"P01\", \"연료분사기\", \"engine\", 12.0, 3.0, 2.0), (\"P02\", \"터보차저\", \"engine\", 9.0, 8.0, 5.0),\n", + " (\"P03\", \"축전지\", \"electric\", 6.5, 4.0, 1.5), (\"P04\", \"발전기\", \"electric\", 4.0, 9.0, 3.0),\n", + " (\"P05\", \"변속기 제어모듈\", \"drive\", 3.0, 12.0, 6.0), (\"P06\", \"냉각수 펌프\", \"engine\", 2.2, 3.5, 3.0),\n", + " (\"P07\", \"유압 펌프\", \"hydraulic\", 1.6, 10.0, 4.0), (\"P08\", \"속도 센서\", \"drive\", 1.2, 2.0, 1.5),\n", + " (\"P09\", \"ECU 커넥터\", \"electric\", 0.9, 2.5, 1.5), (\"P10\", \"연료 필터\", \"engine\", 0.7, 0.8, 0.7),\n", + " (\"P11\", \"제동 밸브\", \"brake\", 0.5, 6.0, 3.0), (\"P12\", \"조향 액추에이터\", \"drive\", 0.4, 9.0, 5.0),\n", + " (\"P13\", \"공기 필터\", \"engine\", 0.3, 0.5, 0.5), (\"P14\", \"통신 인터페이스 모듈\", \"electric\", 0.25, 3.0, 1.5),\n", + " (\"P15\", \"윤활유 압력 센서\", \"engine\", 0.2, 1.0, 1.0), (\"P16\", \"팬 구동 모터\", \"cooling\", 0.15, 2.0, 2.0)]\n", + "PID = [p[0] for p in PARTS]\n", + "PNAME = {p[0]: p[1] for p in PARTS}; PSYS = {p[0]: p[2] for p in PARTS}; RATE = {p[0]: p[3] for p in PARTS}\n", + "COST = {p[0]: p[4] for p in PARTS}; REPH = {p[0]: p[5] for p in PARTS}\n", + "PAIRS = [(\"P01\", \"P02\"), (\"P03\", \"P04\"), (\"P05\", \"P08\"), (\"P06\", \"P10\"), (\"P09\", \"P14\"), (\"P13\", \"P15\")]\n", + "SHARED = {p: f\"S{k + 1:02d}\" for k, pr in enumerate(PAIRS) for p in pr} # 짝 부품이 함께 쓰는 모호한 코드\n", + "SPEC = {p: f\"C{p[1:]}1\" for p in PID} # 부품 고유 코드\n", + "SYS = [\"engine\", \"electric\", \"drive\", \"hydraulic\", \"brake\", \"cooling\"]\n", + "SYMP = {s: f\"G{i + 1:02d}\" for i, s in enumerate(SYS)} # 계통 증상 코드\n", + "CODES = sorted(set(SPEC.values()) | set(SHARED.values()) | set(SYMP.values()))\n", + "CASCADE = {\"P01\": [(\"P10\", 0.20)], \"P02\": [(\"P06\", 0.20)], \"P03\": [(\"P04\", 0.25)], \"P04\": [(\"P09\", 0.25)],\n", + " \"P05\": [(\"P08\", 0.30)], \"P06\": [(\"P16\", 0.30)], \"P09\": [(\"P14\", 0.20)], \"P12\": [(\"P07\", 0.20)]}\n", + "CRIT_SYS = {\"engine\", \"drive\", \"brake\"} # 기동·제동 불능 위험 계통(경보 대상)\n", + "MONTHS = 18\n", + "\n", + "\n", + "def make_fleet(n=100, seed=0):\n", + " rng = np.random.default_rng(seed)\n", + " f = pd.DataFrame({\"veh\": [f\"V{i:03d}\" for i in range(n)], \"age\": rng.integers(1, 26, n).astype(float),\n", + " \"terrain\": rng.uniform(0, 1, n), \"load\": rng.uniform(0, 1, n), \"pm_gap\": rng.uniform(20, 200, n),\n", + " \"humid\": rng.uniform(0, 1, n), \"base_h\": rng.uniform(55, 125, n)})\n", + " f[\"mult\"] = np.exp(0.045 * (f[\"age\"] - 13) + 0.9 * (f[\"terrain\"] - .5) + 0.8 * (f[\"load\"] - .5)\n", + " + 0.006 * (f[\"pm_gap\"] - 110) + 0.7 * (f[\"humid\"] - .5))\n", + " return f\n", + "\n", + "\n", + "def make_data(seed=1, months=MONTHS):\n", + " \"\"\"반환: fleet(차량 속성), ev(정비 이력 1건=1행: 발생 DTC 목록, 실제 고장 부품), log(DTC 발생 로그, 행=코드 1건)\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " fleet = make_fleet()\n", + " V = len(fleet)\n", + " temp = 12 + 14 * np.sin(2 * np.pi * (np.arange(months) - 3) / 12) # 월평균 기온(가상)\n", + " hours = fleet[\"base_h\"].values[:, None] * (1 + 0.25 * np.sin(2 * np.pi * np.arange(months) / 12 + 1)) * rng.lognormal(0, .12, (V, months))\n", + " fleet[\"hours\"] = hours.sum(axis=1) # 차량별 18개월 총 가동시간\n", + " fleet.attrs[\"month_h\"] = hours.sum(axis=0) # 월별 전체 가동시간(고장률 분모)\n", + " evs = []\n", + " for p in PID:\n", + " s = np.ones(months)\n", + " if p in (\"P03\", \"P04\"): s = np.exp(-0.06 * (temp - 12)) # 한랭기 전기계통 증가\n", + " if p in (\"P06\", \"P16\"): s = np.exp(0.05 * (temp - 12)) # 혹서기 냉각계통 증가\n", + " cnt = rng.poisson(RATE[p] * hours / 1000 * fleet[\"mult\"].values[:, None] * s[None, :])\n", + " for v, m in zip(*np.nonzero(cnt)):\n", + " for _ in range(cnt[v, m]):\n", + " evs.append((fleet[\"veh\"][v], int(m), m * 720 + rng.uniform(0, 720), p, float(temp[m] + rng.normal(0, 3))))\n", + " ev = pd.DataFrame(evs, columns=[\"veh\", \"month\", \"t\", \"part\", \"temp\"]).sort_values(\"t\").reset_index(drop=True)\n", + " ev[\"ev_id\"] = [f\"W{i:05d}\" for i in range(len(ev))]\n", + " rows, codes = [], []\n", + " for e in ev.itertuples():\n", + " c, r = [], rng.random(3)\n", + " if r[0] < 0.6: c.append(SPEC[e.part])\n", + " if e.part in SHARED and r[1] < 0.6: c.append(SHARED[e.part])\n", + " if r[2] < 0.5: c.append(SYMP[PSYS[e.part]])\n", + " if not c: c.append(SHARED.get(e.part, SPEC[e.part]))\n", + " for q, pr in CASCADE.get(e.part, []):\n", + " if rng.random() < pr: c.append(SPEC[q])\n", + " if rng.random() < 0.12: c.append(SPEC[PID[rng.integers(0, 16)]])\n", + " c = list(dict.fromkeys(c))\n", + " rng.shuffle(c) # 동시 발생 코드의 기록 순서는 임의\n", + " codes.append(c)\n", + " for k, cd in enumerate(c):\n", + " rows.append((e.ev_id, e.veh, e.t + k * 0.1, cd))\n", + " ev[\"codes\"] = codes\n", + " log = pd.DataFrame(rows, columns=[\"ev_id\", \"veh\", \"ts\", \"code\"])\n", + " return fleet, ev, log\n", + "\n", + "# ---------------------------------------------------------------------------\n", + "# 공통 분석 조각: 코드 멀티핫 행렬, 차량 맥락 피처, 학습/검증 분할(앞 12개월 학습, 뒤 6개월 검증)\n", + "# ---------------------------------------------------------------------------\n", + "CIDX = {c: i for i, c in enumerate(CODES)}\n", + "SPLIT_M = 12\n", + "\n", + "\n", + "def multi_hot(ev):\n", + " X = np.zeros((len(ev), len(CODES)))\n", + " for r, cs in enumerate(ev[\"codes\"]):\n", + " for c in cs:\n", + " X[r, CIDX[c]] = 1\n", + " return X\n", + "\n", + "\n", + "def context(ev, fleet):\n", + " f = fleet.set_index(\"veh\")\n", + " return np.column_stack([ev[\"veh\"].map(f[\"age\"]) / 25, ev[\"veh\"].map(f[\"pm_gap\"]) / 200, ev[\"veh\"].map(f[\"terrain\"]),\n", + " ev[\"veh\"].map(f[\"load\"]), (ev[\"temp\"] - 12) / 15])\n", + "\n", + "\n", + "def feats(ev, fleet):\n", + " X = multi_hot(ev)\n", + " n = X.sum(axis=1, keepdims=True) / 3\n", + " return np.hstack([X, n, context(ev, fleet)])\n", + "\n", + "\n", + "def monthly_counts(ev):\n", + " return ev.groupby([\"month\", \"part\"]).size().unstack(fill_value=0).reindex(columns=PID, fill_value=0)\n", + "\n", + "\n", + "def build_monthly(ev, fleet, top_n=8):\n", + " \"\"\"부품별 월간 고장 건수 예측용 표: 지연값(lag), 이동평균, 추세, 기대 건수(가동시간 x 설계 고장률), 기온 상호작용, 계절 성분\"\"\"\n", + " mc = ev.groupby([\"month\", \"part\"]).size().unstack(fill_value=0)\n", + " top = ev[\"part\"].value_counts().index[:top_n]\n", + " mh, tm = fleet.attrs[\"month_h\"], ev.groupby(\"month\")[\"temp\"].mean().values\n", + " rows = []\n", + " for p in top:\n", + " c = mc[p].values.astype(float)\n", + " for m in range(3, MONTHS):\n", + " L = np.log1p(c)\n", + " rows.append({\"part\": p, \"month\": m, \"y\": c[m], \"lag1\": L[m - 1], \"lag2\": L[m - 2], \"lag3\": L[m - 3], \"roll3\": L[m - 3:m].mean(),\n", + " \"trend\": L[m - 1] - L[m - 2], \"roll3_std\": L[m - 3:m].std(), \"log_expected\": np.log(mh[m] / 1000 * RATE[p]),\n", + " \"rate_lag1\": c[m - 1] / (mh[m - 1] / 1000) / RATE[p], \"temp\": tm[m] / 10, \"cold_elec\": max(0, 12 - tm[m]) / 10 * (p in (\"P03\", \"P04\")),\n", + " \"heat_cool\": max(0, tm[m] - 12) / 10 * (p in (\"P06\", \"P16\")), \"sin\": np.sin(2 * np.pi * m / 12), \"cos\": np.cos(2 * np.pi * m / 12)})\n", + " D = pd.DataFrame(rows)\n", + " return D, [c for c in D.columns if c not in (\"part\", \"month\", \"y\")], top\n", + "\n", + "fleet, ev, log = make_data()\n", + "ev[\"first\"] = ev[\"codes\"].str[0]\n", + "train, test = ev[ev[\"month\"] < 12], ev[ev[\"month\"] >= 12]\n", + "\n", + "\n", + "# 1) Apriori: 항목 = DTC 코드 + 근본 원인 항목(ROOT:부품). 수준별로 후보를 만들고 최소 지지도 미만은 가지치기한다.\n", + "def apriori(trans, min_sup, max_len=3):\n", + " n = len(trans)\n", + " cnt = Counter(i for t in trans for i in t)\n", + " freq = {frozenset([i]): c / n for i, c in cnt.items() if c / n >= min_sup}\n", + " allf, cur = dict(freq), list(freq)\n", + " for k in range(2, max_len + 1):\n", + " cands = {a | b for a, b in itertools.combinations(cur, 2) if len(a | b) == k}\n", + " cands = {c for c in cands if all(frozenset(s) in allf for s in itertools.combinations(c, k - 1))} # 부분집합 가지치기\n", + " cc = Counter(c for t in trans for c in cands if c <= t)\n", + " cur = [c for c, v in cc.items() if v / n >= min_sup]\n", + " allf.update({c: cc[c] / n for c in cur})\n", + " return allf\n", + "\n", + "\n", + "trans = [frozenset(cs) | {\"ROOT:\" + p} for cs, p in zip(train[\"codes\"], train[\"part\"])]\n", + "F = apriori(trans, min_sup=0.004)\n", + "rules = []\n", + "for s, sup in F.items():\n", + " roots = [i for i in s if i.startswith(\"ROOT:\")]\n", + " if len(roots) == 1 and len(s) >= 2:\n", + " ante = s - set(roots)\n", + " conf = sup / F[ante]\n", + " lift = conf / F[frozenset(roots)]\n", + " rules.append({\"ante\": ante, \"part\": roots[0][5:], \"sup\": sup, \"conf\": conf, \"lift\": lift})\n", + "R = pd.DataFrame(rules)\n", + "R = R[(R[\"conf\"] >= 0.5) & (R[\"lift\"] > 1.5)].sort_values(\"conf\", ascending=False).reset_index(drop=True)\n", + "print(f\"[교육용가상자료] 학습 정비 건 {len(train)}건: 빈발 항목집합 {len(F)}개 -> 연관 규칙 {len(R)}개 (신뢰도 0.5 이상, 향상도 1.5 초과)\")\n", + "for r in R.head(4).itertuples():\n", + " print(f\" {{{', '.join(sorted(r.ante))}}} -> {PNAME[r.part]} 지지도 {r.sup:.3f} 신뢰도 {r.conf:.2f} 향상도 {r.lift:.1f}\")\n", + "\n", + "mp = train.groupby(\"first\")[\"part\"].agg(lambda s: s.value_counts().index[0])\n", + "rl = sorted(zip(R[\"ante\"], R[\"part\"], R[\"conf\"]), key=lambda x: (-x[2], -len(x[0])))\n", + "\n", + "\n", + "def rule_predict(codes, fallback):\n", + " for a, p, c in rl:\n", + " if a <= set(codes):\n", + " return p, c\n", + " return fallback, 0.0\n", + "\n", + "\n", + "pr = [rule_predict(cs, mp.get(f, \"P01\")) for cs, f in zip(test[\"codes\"], test[\"first\"])]\n", + "pred_rule = np.array([p for p, _ in pr]); pred_base = test[\"first\"].map(mp).values\n", + "a_b, a_r = (pred_base == test[\"part\"].values).mean(), (pred_rule == test[\"part\"].values).mean()\n", + "multi = test[\"codes\"].str.len().values >= 2\n", + "print(f\"\\n근본 원인 정확도: 단일 코드 Baseline {a_b:.1%} -> 연관 규칙 {a_r:.1%} (코드 2개 이상 동시 발생 건 {(pred_base[multi] == test['part'].values[multi]).mean():.1%} -> {(pred_rule[multi] == test['part'].values[multi]).mean():.1%})\")\n", + "\n", + "# 2) 파생 피처 + 월간 고장 건수 기준 모델(상위 8개 부품, 건수는 로그 변환해 학습)\n", + "D, FE, top = build_monthly(ev, fleet)\n", + "print(f\"\\n월간 고장 건수 예측: 파생 피처 {len(FE)}개 (목표 10개 이상: {'달성' if len(FE) >= 10 else '미달'}), 대상 {len(top)}개 부품 x {MONTHS - 3}개월\")\n", + "\n", + "\n", + "def model():\n", + " return make_pipeline(StandardScaler(), Ridge(alpha=3.0))\n", + "\n", + "\n", + "def mape(a, p):\n", + " return float(np.mean(np.abs(a - p) / a))\n", + "\n", + "\n", + "# 3) 시계열 교차검증: 확장 윈도(과거로만 학습, 뒤 2개월 검증)\n", + "folds, mapes = [(range(3, s), range(s, s + 2)) for s in (11, 13, 15)], []\n", + "for tr, te in folds:\n", + " a, b = D[D[\"month\"].isin(tr)], D[D[\"month\"].isin(te)]\n", + " mapes.append(mape(b[\"y\"].values, np.exp(model().fit(a[FE], np.log(a[\"y\"])).predict(b[FE]))))\n", + "cv = float(np.mean(mapes))\n", + "print(\"시계열 교차검증 폴드별 MAPE: \" + \", \".join(f\"{m:.1%}\" for m in mapes) + f\" -> 평균 {cv:.1%} (목표 25% 이하: {'달성' if cv <= 0.25 else '미달'})\")\n", + "\n", + "# 4) 피처 중요도(순열 중요도)로 차원 축소\n", + "a, b = D[D[\"month\"] < 13], D[D[\"month\"] >= 13]\n", + "m = model().fit(a[FE], np.log(a[\"y\"]))\n", + "imp = permutation_importance(m, b[FE], np.log(b[\"y\"]), n_repeats=20, random_state=0, scoring=\"neg_mean_absolute_error\").importances_mean\n", + "order = np.argsort(-imp)\n", + "keep = [FE[i] for i in order[:8]]\n", + "m5 = model().fit(a[keep], np.log(a[\"y\"]))\n", + "full, red = mape(b[\"y\"].values, np.exp(m.predict(b[FE]))), mape(b[\"y\"].values, np.exp(m5.predict(b[keep])))\n", + "print(\"피처 중요도 상위 5: \" + \", \".join(f\"{FE[i]}({imp[i]:.2f})\" for i in order[:5]))\n", + "print(f\"차원 축소 {len(FE)}개 -> 8개: 검증 MAPE {full:.1%} -> {red:.1%}\")\n", + "\n", + "fig, ax = plt.subplots(1, 2, figsize=(10, 3.5))\n", + "ax[0].bar([\"baseline\", \"rules\"], [a_b, a_r]); ax[0].set_title(\"Root-cause accuracy\")\n", + "ax[1].barh([FE[i] for i in order[:8]][::-1], imp[order[:8]][::-1]); ax[1].set_title(\"Permutation importance (log MAE)\")\n", + "plt.tight_layout(); plt.show()\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 04 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + "[교육용가상자료] 학습 정비 건 6791건: 빈발 항목집합 188개 -> 연관 규칙 57개 (신뢰도 0.5 이상, 향상도 1.5 초과)\n", + " {G04} -> 유압 펌프 지지도 0.020 신뢰도 1.00 향상도 29.1\n", + " {G05} -> 제동 밸브 지지도 0.007 신뢰도 1.00 향상도 87.1\n", + " {C051, G03} -> 변속기 제어모듈 지지도 0.021 신뢰도 1.00 향상도 15.5\n", + " {C071, G04} -> 유압 펌프 지지도 0.013 신뢰도 1.00 향상도 29.1\n", + "\n", + "근본 원인 정확도: 단일 코드 Baseline 68.7% -> 연관 규칙 78.3% (코드 2개 이상 동시 발생 건 67.1% -> 81.1%)\n", + "\n", + "월간 고장 건수 예측: 파생 피처 13개 (목표 10개 이상: 달성), 대상 8개 부품 x 15개월\n", + "시계열 교차검증 폴드별 MAPE: 28.9%, 17.3%, 17.8% -> 평균 21.3% (목표 25% 이하: 달성)\n", + "피처 중요도 상위 5: lag1(0.21), log_expected(0.18), lag2(0.14), cold_elec(0.09), rate_lag1(0.07)\n", + "차원 축소 13개 -> 8개: 검증 MAPE 18.6% -> 21.6%\n", + "```\n", + "
\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 05. DTC 기반 정비 조치 가이드라인 및 추천 신뢰도 점수 산출 엔진 개발\n", + "**소요시간**: 약 3.5시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "DTC 기반 정비 조치 가이드라인 및 추천 신뢰도 점수 산출 엔진을 개발한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **DTC 입력으로 정비 조치 가이드라인과 추천 신뢰도 점수를 산출하는 엔진을 개발하고 기준 모델 대비 개선을 확인한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **코드 유형별 가중치를 둔 고장 부품 확률 모델로 상위 3개 후보와 신뢰도를 내고, 후보별 정비 조치 절차를 진단 카드로 출력 수행하고 검증하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `src/models/mnt03_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": [ + "\"\"\"DTC 조합과 차량 맥락을 입력받아 후보 고장 부품 3개와 추천 신뢰도 점수, 정비 조치 가이드라인을 산출하는 추천 엔진을 개발하고, 신뢰도가 실제 정확도와 맞는지(보정) 확인한다. 부품별 월간 고장 건수 예측은 전월값 기준 모델 대비 릿지·그래디언트 부스팅 앙상블의 MAE/RMSE 개선율과 R2로 평가한다.\"\"\"\n", + "import numpy as np\n", + "import pandas as pd\n", + "import matplotlib.pyplot as plt\n", + "from sklearn.linear_model import LogisticRegression, Ridge\n", + "from sklearn.ensemble import GradientBoostingRegressor\n", + "from sklearn.preprocessing import StandardScaler\n", + "from sklearn.pipeline import make_pipeline\n", + "from sklearn.metrics import mean_absolute_error, mean_squared_error, r2_score\n", + "\n", + "# ---------------------------------------------------------------------------\n", + "# [교육용가상자료] 가상 차량 100대의 18개월 고장 진단 코드(DTC)·정비 이력 생성기 (실제 부대·장비·정비 실적 없음)\n", + "# 부품 16종(1000가동시간당 고장률 차등), DTC 28종(부품 고유 코드, 짝 부품 공유 코드, 계통 증상 코드)\n", + "# 고장 1건(정비 이력)마다 DTC가 1~5개 동시 발생하고, 연쇄 고장 코드와 일시 잡음 코드가 섞인다.\n", + "# ---------------------------------------------------------------------------\n", + "PARTS = [ # (ID, 이름, 계통, 1000가동시간당 고장률, 가상 단가지수, 수리시간 h)\n", + " (\"P01\", \"연료분사기\", \"engine\", 12.0, 3.0, 2.0), (\"P02\", \"터보차저\", \"engine\", 9.0, 8.0, 5.0),\n", + " (\"P03\", \"축전지\", \"electric\", 6.5, 4.0, 1.5), (\"P04\", \"발전기\", \"electric\", 4.0, 9.0, 3.0),\n", + " (\"P05\", \"변속기 제어모듈\", \"drive\", 3.0, 12.0, 6.0), (\"P06\", \"냉각수 펌프\", \"engine\", 2.2, 3.5, 3.0),\n", + " (\"P07\", \"유압 펌프\", \"hydraulic\", 1.6, 10.0, 4.0), (\"P08\", \"속도 센서\", \"drive\", 1.2, 2.0, 1.5),\n", + " (\"P09\", \"ECU 커넥터\", \"electric\", 0.9, 2.5, 1.5), (\"P10\", \"연료 필터\", \"engine\", 0.7, 0.8, 0.7),\n", + " (\"P11\", \"제동 밸브\", \"brake\", 0.5, 6.0, 3.0), (\"P12\", \"조향 액추에이터\", \"drive\", 0.4, 9.0, 5.0),\n", + " (\"P13\", \"공기 필터\", \"engine\", 0.3, 0.5, 0.5), (\"P14\", \"통신 인터페이스 모듈\", \"electric\", 0.25, 3.0, 1.5),\n", + " (\"P15\", \"윤활유 압력 센서\", \"engine\", 0.2, 1.0, 1.0), (\"P16\", \"팬 구동 모터\", \"cooling\", 0.15, 2.0, 2.0)]\n", + "PID = [p[0] for p in PARTS]\n", + "PNAME = {p[0]: p[1] for p in PARTS}; PSYS = {p[0]: p[2] for p in PARTS}; RATE = {p[0]: p[3] for p in PARTS}\n", + "COST = {p[0]: p[4] for p in PARTS}; REPH = {p[0]: p[5] for p in PARTS}\n", + "PAIRS = [(\"P01\", \"P02\"), (\"P03\", \"P04\"), (\"P05\", \"P08\"), (\"P06\", \"P10\"), (\"P09\", \"P14\"), (\"P13\", \"P15\")]\n", + "SHARED = {p: f\"S{k + 1:02d}\" for k, pr in enumerate(PAIRS) for p in pr} # 짝 부품이 함께 쓰는 모호한 코드\n", + "SPEC = {p: f\"C{p[1:]}1\" for p in PID} # 부품 고유 코드\n", + "SYS = [\"engine\", \"electric\", \"drive\", \"hydraulic\", \"brake\", \"cooling\"]\n", + "SYMP = {s: f\"G{i + 1:02d}\" for i, s in enumerate(SYS)} # 계통 증상 코드\n", + "CODES = sorted(set(SPEC.values()) | set(SHARED.values()) | set(SYMP.values()))\n", + "CASCADE = {\"P01\": [(\"P10\", 0.20)], \"P02\": [(\"P06\", 0.20)], \"P03\": [(\"P04\", 0.25)], \"P04\": [(\"P09\", 0.25)],\n", + " \"P05\": [(\"P08\", 0.30)], \"P06\": [(\"P16\", 0.30)], \"P09\": [(\"P14\", 0.20)], \"P12\": [(\"P07\", 0.20)]}\n", + "CRIT_SYS = {\"engine\", \"drive\", \"brake\"} # 기동·제동 불능 위험 계통(경보 대상)\n", + "MONTHS = 18\n", + "\n", + "\n", + "def make_fleet(n=100, seed=0):\n", + " rng = np.random.default_rng(seed)\n", + " f = pd.DataFrame({\"veh\": [f\"V{i:03d}\" for i in range(n)], \"age\": rng.integers(1, 26, n).astype(float),\n", + " \"terrain\": rng.uniform(0, 1, n), \"load\": rng.uniform(0, 1, n), \"pm_gap\": rng.uniform(20, 200, n),\n", + " \"humid\": rng.uniform(0, 1, n), \"base_h\": rng.uniform(55, 125, n)})\n", + " f[\"mult\"] = np.exp(0.045 * (f[\"age\"] - 13) + 0.9 * (f[\"terrain\"] - .5) + 0.8 * (f[\"load\"] - .5)\n", + " + 0.006 * (f[\"pm_gap\"] - 110) + 0.7 * (f[\"humid\"] - .5))\n", + " return f\n", + "\n", + "\n", + "def make_data(seed=1, months=MONTHS):\n", + " \"\"\"반환: fleet(차량 속성), ev(정비 이력 1건=1행: 발생 DTC 목록, 실제 고장 부품), log(DTC 발생 로그, 행=코드 1건)\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " fleet = make_fleet()\n", + " V = len(fleet)\n", + " temp = 12 + 14 * np.sin(2 * np.pi * (np.arange(months) - 3) / 12) # 월평균 기온(가상)\n", + " hours = fleet[\"base_h\"].values[:, None] * (1 + 0.25 * np.sin(2 * np.pi * np.arange(months) / 12 + 1)) * rng.lognormal(0, .12, (V, months))\n", + " fleet[\"hours\"] = hours.sum(axis=1) # 차량별 18개월 총 가동시간\n", + " fleet.attrs[\"month_h\"] = hours.sum(axis=0) # 월별 전체 가동시간(고장률 분모)\n", + " evs = []\n", + " for p in PID:\n", + " s = np.ones(months)\n", + " if p in (\"P03\", \"P04\"): s = np.exp(-0.06 * (temp - 12)) # 한랭기 전기계통 증가\n", + " if p in (\"P06\", \"P16\"): s = np.exp(0.05 * (temp - 12)) # 혹서기 냉각계통 증가\n", + " cnt = rng.poisson(RATE[p] * hours / 1000 * fleet[\"mult\"].values[:, None] * s[None, :])\n", + " for v, m in zip(*np.nonzero(cnt)):\n", + " for _ in range(cnt[v, m]):\n", + " evs.append((fleet[\"veh\"][v], int(m), m * 720 + rng.uniform(0, 720), p, float(temp[m] + rng.normal(0, 3))))\n", + " ev = pd.DataFrame(evs, columns=[\"veh\", \"month\", \"t\", \"part\", \"temp\"]).sort_values(\"t\").reset_index(drop=True)\n", + " ev[\"ev_id\"] = [f\"W{i:05d}\" for i in range(len(ev))]\n", + " rows, codes = [], []\n", + " for e in ev.itertuples():\n", + " c, r = [], rng.random(3)\n", + " if r[0] < 0.6: c.append(SPEC[e.part])\n", + " if e.part in SHARED and r[1] < 0.6: c.append(SHARED[e.part])\n", + " if r[2] < 0.5: c.append(SYMP[PSYS[e.part]])\n", + " if not c: c.append(SHARED.get(e.part, SPEC[e.part]))\n", + " for q, pr in CASCADE.get(e.part, []):\n", + " if rng.random() < pr: c.append(SPEC[q])\n", + " if rng.random() < 0.12: c.append(SPEC[PID[rng.integers(0, 16)]])\n", + " c = list(dict.fromkeys(c))\n", + " rng.shuffle(c) # 동시 발생 코드의 기록 순서는 임의\n", + " codes.append(c)\n", + " for k, cd in enumerate(c):\n", + " rows.append((e.ev_id, e.veh, e.t + k * 0.1, cd))\n", + " ev[\"codes\"] = codes\n", + " log = pd.DataFrame(rows, columns=[\"ev_id\", \"veh\", \"ts\", \"code\"])\n", + " return fleet, ev, log\n", + "\n", + "# ---------------------------------------------------------------------------\n", + "# 공통 분석 조각: 코드 멀티핫 행렬, 차량 맥락 피처, 학습/검증 분할(앞 12개월 학습, 뒤 6개월 검증)\n", + "# ---------------------------------------------------------------------------\n", + "CIDX = {c: i for i, c in enumerate(CODES)}\n", + "SPLIT_M = 12\n", + "\n", + "\n", + "def multi_hot(ev):\n", + " X = np.zeros((len(ev), len(CODES)))\n", + " for r, cs in enumerate(ev[\"codes\"]):\n", + " for c in cs:\n", + " X[r, CIDX[c]] = 1\n", + " return X\n", + "\n", + "\n", + "def context(ev, fleet):\n", + " f = fleet.set_index(\"veh\")\n", + " return np.column_stack([ev[\"veh\"].map(f[\"age\"]) / 25, ev[\"veh\"].map(f[\"pm_gap\"]) / 200, ev[\"veh\"].map(f[\"terrain\"]),\n", + " ev[\"veh\"].map(f[\"load\"]), (ev[\"temp\"] - 12) / 15])\n", + "\n", + "\n", + "def feats(ev, fleet):\n", + " X = multi_hot(ev)\n", + " n = X.sum(axis=1, keepdims=True) / 3\n", + " return np.hstack([X, n, context(ev, fleet)])\n", + "\n", + "\n", + "def monthly_counts(ev):\n", + " return ev.groupby([\"month\", \"part\"]).size().unstack(fill_value=0).reindex(columns=PID, fill_value=0)\n", + "\n", + "\n", + "def build_monthly(ev, fleet, top_n=8):\n", + " \"\"\"부품별 월간 고장 건수 예측용 표: 지연값(lag), 이동평균, 추세, 기대 건수(가동시간 x 설계 고장률), 기온 상호작용, 계절 성분\"\"\"\n", + " mc = ev.groupby([\"month\", \"part\"]).size().unstack(fill_value=0)\n", + " top = ev[\"part\"].value_counts().index[:top_n]\n", + " mh, tm = fleet.attrs[\"month_h\"], ev.groupby(\"month\")[\"temp\"].mean().values\n", + " rows = []\n", + " for p in top:\n", + " c = mc[p].values.astype(float)\n", + " for m in range(3, MONTHS):\n", + " L = np.log1p(c)\n", + " rows.append({\"part\": p, \"month\": m, \"y\": c[m], \"lag1\": L[m - 1], \"lag2\": L[m - 2], \"lag3\": L[m - 3], \"roll3\": L[m - 3:m].mean(),\n", + " \"trend\": L[m - 1] - L[m - 2], \"roll3_std\": L[m - 3:m].std(), \"log_expected\": np.log(mh[m] / 1000 * RATE[p]),\n", + " \"rate_lag1\": c[m - 1] / (mh[m - 1] / 1000) / RATE[p], \"temp\": tm[m] / 10, \"cold_elec\": max(0, 12 - tm[m]) / 10 * (p in (\"P03\", \"P04\")),\n", + " \"heat_cool\": max(0, tm[m] - 12) / 10 * (p in (\"P06\", \"P16\")), \"sin\": np.sin(2 * np.pi * m / 12), \"cos\": np.cos(2 * np.pi * m / 12)})\n", + " D = pd.DataFrame(rows)\n", + " return D, [c for c in D.columns if c not in (\"part\", \"month\", \"y\")], top\n", + "\n", + "# ---------------------------------------------------------------------------\n", + "# [교육용가상자료] 부품별 정비 조치 가이드라인(요약 3단계). 실제 정비 교범이 아니라 교육용으로 만든 예시 문구다.\n", + "# ---------------------------------------------------------------------------\n", + "GUIDE = {\n", + " \"P01\": \"연료 압력 측정 -> 분사기 누설·막힘 확인 -> 불량 시 분사기 교체\", \"P02\": \"흡기 압력·이물 점검 -> 축 유격 확인 -> 불량 시 터보차저 교체\",\n", + " \"P03\": \"단자 전압·부식 점검 -> 충전 계통 확인 -> 불량 시 축전지 교체\", \"P04\": \"발전 전압 측정 -> 벨트·배선 점검 -> 불량 시 발전기 교체\",\n", + " \"P05\": \"제어모듈 소프트웨어·커넥터 점검 -> 변속 이력 확인 -> 불량 시 모듈 교체\", \"P06\": \"냉각수량·누수 점검 -> 펌프 작동음 확인 -> 불량 시 펌프 교체\",\n", + " \"P07\": \"유압 오일량·압력 점검 -> 펌프 누설 확인 -> 불량 시 펌프 교체\", \"P08\": \"센서 배선·간극 점검 -> 신호 파형 확인 -> 불량 시 센서 교체\",\n", + " \"P09\": \"커넥터 접촉·부식 점검 -> 핀 상태 확인 -> 불량 시 커넥터 교체\", \"P10\": \"필터 오염도 점검 -> 연료 흐름 확인 -> 불량 시 필터 교체\",\n", + " \"P11\": \"제동 압력 점검 -> 밸브 작동 확인 -> 불량 시 밸브 교체(안전 확인 필수)\", \"P12\": \"조향 반응 점검 -> 액추에이터 작동 확인 -> 불량 시 교체(안전 확인 필수)\",\n", + " \"P13\": \"필터 차압 점검 -> 오염 확인 -> 불량 시 필터 교체\", \"P14\": \"통신 링크 상태 점검 -> 모듈 전원 확인 -> 불량 시 모듈 교체\",\n", + " \"P15\": \"압력 센서 신호 점검 -> 윤활유량 확인 -> 불량 시 센서 교체\", \"P16\": \"팬 모터 전류 점검 -> 회전 확인 -> 불량 시 모터 교체\"}\n", + "\n", + "fleet, ev, log = make_data()\n", + "train, test = ev[ev[\"month\"] < SPLIT_M], ev[ev[\"month\"] >= SPLIT_M]\n", + "\n", + "# 1) 추천 엔진: DTC 멀티핫 + 맥락 -> 부품별 확률(= 추천 신뢰도 점수)\n", + "Xtr, Xte = feats(train, fleet), feats(test, fleet)\n", + "clf = LogisticRegression(C=3, max_iter=500).fit(Xtr, train[\"part\"])\n", + "P = clf.predict_proba(Xte)\n", + "cls = clf.classes_\n", + "top1 = cls[P.argmax(axis=1)]\n", + "conf = P.max(axis=1)\n", + "y = test[\"part\"].values\n", + "top3 = np.mean([t in cls[np.argsort(-p)[:3]] for t, p in zip(y, P)])\n", + "mp = train.assign(first=train[\"codes\"].str[0]).groupby(\"first\")[\"part\"].agg(lambda s: s.value_counts().index[0])\n", + "base_acc = (test[\"codes\"].str[0].map(mp).values == y).mean()\n", + "print(f\"[교육용가상자료] 추천 엔진 검증 {len(test)}건: top-1 정확도 {(top1 == y).mean():.1%} (단일 코드 Baseline {base_acc:.1%}), top-3 포함률 {top3:.1%}\")\n", + "\n", + "# 2) 신뢰도 점수 보정: 신뢰도 구간별 실제 정확도\n", + "bins = [0, 0.5, 0.7, 0.9, 1.01]\n", + "lab = [\"~0.5\", \"0.5~0.7\", \"0.7~0.9\", \"0.9~\"]\n", + "cb = pd.cut(conf, bins, labels=lab, right=False)\n", + "cal = pd.DataFrame({\"conf\": conf, \"ok\": top1 == y, \"b\": cb}).groupby(\"b\", observed=True).agg(건수=(\"ok\", \"size\"), 평균신뢰도=(\"conf\", \"mean\"), 실제정확도=(\"ok\", \"mean\"))\n", + "print(\"\\n[신뢰도 보정] 구간 | 건수 | 평균 신뢰도 | 실제 정확도\")\n", + "for k, r in cal.iterrows():\n", + " print(f\" {k:8s} {int(r['건수']):5d} {r['평균신뢰도']:.2f} {r['실제정확도']:.2f}\")\n", + "ece = float((cal[\"건수\"] * (cal[\"평균신뢰도\"] - cal[\"실제정확도\"]).abs()).sum() / cal[\"건수\"].sum())\n", + "print(f\"기대 보정 오차(ECE) {ece:.3f}\")\n", + "\n", + "# 3) 정비 조치 가이드라인 카드(상위 후보 3개와 신뢰도)\n", + "print(\"\\n[진단 카드 예시] 정비 조치 가이드라인\")\n", + "for i in (int(np.argmin(np.abs(conf - 0.95))), int(np.argmin(np.abs(conf - 0.55)))):\n", + " order = np.argsort(-P[i])[:3]\n", + " print(f\" 입력 DTC {test['codes'].iloc[i]} -> \" + \", \".join(f\"{PNAME[cls[j]]} {P[i, j]:.0%}\" for j in order))\n", + " print(f\" 조치: {GUIDE[cls[order[0]]]}\" + (\"\" if conf[i] >= 0.7 else \" (신뢰도 낮음: 숙련 정비관 확인 권고)\"))\n", + "\n", + "# 4) 월간 고장 건수 예측: 기준(전월값) vs 고도화(릿지 + GBR 앙상블, 소규모 격자 탐색)\n", + "D, FE, top = build_monthly(ev, fleet)\n", + "a, b = D[D[\"month\"] < 13], D[D[\"month\"] >= 13]\n", + "ya, yb = np.log(a[\"y\"]), b[\"y\"].values\n", + "naive = np.expm1(b[\"lag1\"].values)\n", + "def ens(tr, te, al, dep):\n", + " g = GradientBoostingRegressor(n_estimators=150, max_depth=dep, learning_rate=0.05, random_state=0).fit(tr[FE], np.log(tr[\"y\"]))\n", + " r = make_pipeline(StandardScaler(), Ridge(alpha=al)).fit(tr[FE], np.log(tr[\"y\"]))\n", + " return np.exp(0.5 * g.predict(te[FE]) + 0.5 * r.predict(te[FE]))\n", + "\n", + "\n", + "inner_tr, inner_va = a[a[\"month\"] < 11], a[a[\"month\"] >= 11] # 하이퍼파라미터는 학습 구간 안에서만 선택(검증 구간 미사용)\n", + "score = {(al, dep): mean_absolute_error(inner_va[\"y\"], ens(inner_tr, inner_va, al, dep)) for al in (1, 3, 10) for dep in (2, 3)}\n", + "al, dep = min(score, key=score.get)\n", + "adv = ens(a, b, al, dep)\n", + "met = lambda p: (mean_absolute_error(yb, p), mean_squared_error(yb, p) ** 0.5, r2_score(yb, p))\n", + "m0, m1 = met(naive), met(adv)\n", + "print(f\"\\n[월간 고장 건수 예측] 검증 {len(b)}건(상위 {len(top)}개 부품 x 5개월), 선택 하이퍼파라미터 ridge alpha={al}, GBR depth={dep}\")\n", + "print(f\" 기준(전월값) MAE {m0[0]:.2f} RMSE {m0[1]:.2f} R2 {m0[2]:.3f}\")\n", + "print(f\" 고도화(앙상블) MAE {m1[0]:.2f} RMSE {m1[1]:.2f} R2 {m1[2]:.3f}\")\n", + "imp_mae, imp_rmse = 1 - m1[0] / m0[0], 1 - m1[1] / m0[1]\n", + "print(f\"개선율 MAE {imp_mae:.1%}, RMSE {imp_rmse:.1%} (목표 20% 이상: {'달성' if min(imp_mae, imp_rmse) >= 0.2 else '미달'}), R2 {m1[2]:.3f} (목표 0.85 이상: {'달성' if m1[2] >= 0.85 else '미달'})\")\n", + "\n", + "fig, ax = plt.subplots(1, 2, figsize=(10, 3.5))\n", + "ax[0].plot([0, 1], [0, 1], \"k--\"); ax[0].plot(cal[\"평균신뢰도\"], cal[\"실제정확도\"], marker=\"o\"); ax[0].set_title(\"Confidence calibration\")\n", + "ax[1].scatter(yb, adv, s=12, label=\"ensemble\"); ax[1].scatter(yb, naive, s=12, label=\"naive\"); ax[1].plot([0, yb.max()], [0, yb.max()], \"k--\")\n", + "ax[1].set_title(\"Monthly failure count: actual vs predicted\"); ax[1].legend()\n", + "plt.tight_layout(); plt.show()\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 05 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + "[교육용가상자료] 추천 엔진 검증 3890건: top-1 정확도 83.9% (단일 코드 Baseline 68.7%), top-3 포함률 99.5%\n", + "\n", + "[신뢰도 보정] 구간 | 건수 | 평균 신뢰도 | 실제 정확도\n", + " ~0.5 155 0.46 0.48\n", + " 0.5~0.7 1123 0.60 0.61\n", + " 0.7~0.9 387 0.78 0.74\n", + " 0.9~ 2225 0.99 1.00\n", + "기대 보정 오차(ECE) 0.014\n", + "\n", + "[진단 카드 예시] 정비 조치 가이드라인\n", + " 입력 DTC ['G01', 'C101', 'S01'] -> 연료분사기 95%, 터보차저 5%, 연료 필터 0%\n", + " 조치: 연료 압력 측정 -> 분사기 누설·막힘 확인 -> 불량 시 분사기 교체\n", + " 입력 DTC ['G01'] -> 연료분사기 55%, 터보차저 34%, 연료 필터 5%\n", + " 조치: 연료 압력 측정 -> 분사기 누설·막힘 확인 -> 불량 시 분사기 교체 (신뢰도 낮음: 숙련 정비관 확인 권고)\n", + "\n", + "[월간 고장 건수 예측] 검증 40건(상위 8개 부품 x 5개월), 선택 하이퍼파라미터 ridge alpha=10, GBR depth=3\n", + " 기준(전월값) MAE 15.50 RMSE 21.96 R2 0.860\n", + " 고도화(앙상블) MAE 10.62 RMSE 14.47 R2 0.939\n", + "개선율 MAE 31.5%, RMSE 34.1% (목표 20% 이상: 달성), R2 0.939 (목표 0.85 이상: 달성)\n", + "```\n", + "
\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 06. 정비병용 모바일/웹 고장 코드 진단 및 정비 조치 절차 대시보드 구현\n", + "**소요시간**: 약 3.0시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "정비병용 모바일/웹 고장 코드 진단 대시보드를 구현한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **정비병용 고장 코드 진단 화면(진단 카드와 웹 대시보드 구성)을 구현하고, 예측마다 기여도 상위 3개 인자와 자연어 요약을 자동 산출하는 설명가능성(XAI) 모듈을 만든다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **모델의 입력 인자별 기여도를 계산해 건마다 상위 3개 근거를 뽑고, 인자를 평균값으로 되돌렸을 때 예측 확률이 낮아지는지로 근거 유효성을 점검 수행하고 검증하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `src/explainability/mnt03_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": [ + "\"\"\"정비병이 모바일/웹 화면에서 DTC를 입력하면 후보 고장 부품·신뢰도·정비 조치 절차와 판단 근거(기여도 상위 3개 인자)를 보여 주는 진단 대시보드를 구현한다. 모든 예측 건에 상위 3개 인자를 자동 산출하고 근거 제거 시 확률이 실제로 떨어지는지 점검하며, 자연어 요약과 경보 현황 화면을 만든다.\"\"\"\n", + "import numpy as np\n", + "import pandas as pd\n", + "import matplotlib.pyplot as plt\n", + "from sklearn.linear_model import LogisticRegression\n", + "\n", + "# ---------------------------------------------------------------------------\n", + "# [교육용가상자료] 가상 차량 100대의 18개월 고장 진단 코드(DTC)·정비 이력 생성기 (실제 부대·장비·정비 실적 없음)\n", + "# 부품 16종(1000가동시간당 고장률 차등), DTC 28종(부품 고유 코드, 짝 부품 공유 코드, 계통 증상 코드)\n", + "# 고장 1건(정비 이력)마다 DTC가 1~5개 동시 발생하고, 연쇄 고장 코드와 일시 잡음 코드가 섞인다.\n", + "# ---------------------------------------------------------------------------\n", + "PARTS = [ # (ID, 이름, 계통, 1000가동시간당 고장률, 가상 단가지수, 수리시간 h)\n", + " (\"P01\", \"연료분사기\", \"engine\", 12.0, 3.0, 2.0), (\"P02\", \"터보차저\", \"engine\", 9.0, 8.0, 5.0),\n", + " (\"P03\", \"축전지\", \"electric\", 6.5, 4.0, 1.5), (\"P04\", \"발전기\", \"electric\", 4.0, 9.0, 3.0),\n", + " (\"P05\", \"변속기 제어모듈\", \"drive\", 3.0, 12.0, 6.0), (\"P06\", \"냉각수 펌프\", \"engine\", 2.2, 3.5, 3.0),\n", + " (\"P07\", \"유압 펌프\", \"hydraulic\", 1.6, 10.0, 4.0), (\"P08\", \"속도 센서\", \"drive\", 1.2, 2.0, 1.5),\n", + " (\"P09\", \"ECU 커넥터\", \"electric\", 0.9, 2.5, 1.5), (\"P10\", \"연료 필터\", \"engine\", 0.7, 0.8, 0.7),\n", + " (\"P11\", \"제동 밸브\", \"brake\", 0.5, 6.0, 3.0), (\"P12\", \"조향 액추에이터\", \"drive\", 0.4, 9.0, 5.0),\n", + " (\"P13\", \"공기 필터\", \"engine\", 0.3, 0.5, 0.5), (\"P14\", \"통신 인터페이스 모듈\", \"electric\", 0.25, 3.0, 1.5),\n", + " (\"P15\", \"윤활유 압력 센서\", \"engine\", 0.2, 1.0, 1.0), (\"P16\", \"팬 구동 모터\", \"cooling\", 0.15, 2.0, 2.0)]\n", + "PID = [p[0] for p in PARTS]\n", + "PNAME = {p[0]: p[1] for p in PARTS}; PSYS = {p[0]: p[2] for p in PARTS}; RATE = {p[0]: p[3] for p in PARTS}\n", + "COST = {p[0]: p[4] for p in PARTS}; REPH = {p[0]: p[5] for p in PARTS}\n", + "PAIRS = [(\"P01\", \"P02\"), (\"P03\", \"P04\"), (\"P05\", \"P08\"), (\"P06\", \"P10\"), (\"P09\", \"P14\"), (\"P13\", \"P15\")]\n", + "SHARED = {p: f\"S{k + 1:02d}\" for k, pr in enumerate(PAIRS) for p in pr} # 짝 부품이 함께 쓰는 모호한 코드\n", + "SPEC = {p: f\"C{p[1:]}1\" for p in PID} # 부품 고유 코드\n", + "SYS = [\"engine\", \"electric\", \"drive\", \"hydraulic\", \"brake\", \"cooling\"]\n", + "SYMP = {s: f\"G{i + 1:02d}\" for i, s in enumerate(SYS)} # 계통 증상 코드\n", + "CODES = sorted(set(SPEC.values()) | set(SHARED.values()) | set(SYMP.values()))\n", + "CASCADE = {\"P01\": [(\"P10\", 0.20)], \"P02\": [(\"P06\", 0.20)], \"P03\": [(\"P04\", 0.25)], \"P04\": [(\"P09\", 0.25)],\n", + " \"P05\": [(\"P08\", 0.30)], \"P06\": [(\"P16\", 0.30)], \"P09\": [(\"P14\", 0.20)], \"P12\": [(\"P07\", 0.20)]}\n", + "CRIT_SYS = {\"engine\", \"drive\", \"brake\"} # 기동·제동 불능 위험 계통(경보 대상)\n", + "MONTHS = 18\n", + "\n", + "\n", + "def make_fleet(n=100, seed=0):\n", + " rng = np.random.default_rng(seed)\n", + " f = pd.DataFrame({\"veh\": [f\"V{i:03d}\" for i in range(n)], \"age\": rng.integers(1, 26, n).astype(float),\n", + " \"terrain\": rng.uniform(0, 1, n), \"load\": rng.uniform(0, 1, n), \"pm_gap\": rng.uniform(20, 200, n),\n", + " \"humid\": rng.uniform(0, 1, n), \"base_h\": rng.uniform(55, 125, n)})\n", + " f[\"mult\"] = np.exp(0.045 * (f[\"age\"] - 13) + 0.9 * (f[\"terrain\"] - .5) + 0.8 * (f[\"load\"] - .5)\n", + " + 0.006 * (f[\"pm_gap\"] - 110) + 0.7 * (f[\"humid\"] - .5))\n", + " return f\n", + "\n", + "\n", + "def make_data(seed=1, months=MONTHS):\n", + " \"\"\"반환: fleet(차량 속성), ev(정비 이력 1건=1행: 발생 DTC 목록, 실제 고장 부품), log(DTC 발생 로그, 행=코드 1건)\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " fleet = make_fleet()\n", + " V = len(fleet)\n", + " temp = 12 + 14 * np.sin(2 * np.pi * (np.arange(months) - 3) / 12) # 월평균 기온(가상)\n", + " hours = fleet[\"base_h\"].values[:, None] * (1 + 0.25 * np.sin(2 * np.pi * np.arange(months) / 12 + 1)) * rng.lognormal(0, .12, (V, months))\n", + " fleet[\"hours\"] = hours.sum(axis=1) # 차량별 18개월 총 가동시간\n", + " fleet.attrs[\"month_h\"] = hours.sum(axis=0) # 월별 전체 가동시간(고장률 분모)\n", + " evs = []\n", + " for p in PID:\n", + " s = np.ones(months)\n", + " if p in (\"P03\", \"P04\"): s = np.exp(-0.06 * (temp - 12)) # 한랭기 전기계통 증가\n", + " if p in (\"P06\", \"P16\"): s = np.exp(0.05 * (temp - 12)) # 혹서기 냉각계통 증가\n", + " cnt = rng.poisson(RATE[p] * hours / 1000 * fleet[\"mult\"].values[:, None] * s[None, :])\n", + " for v, m in zip(*np.nonzero(cnt)):\n", + " for _ in range(cnt[v, m]):\n", + " evs.append((fleet[\"veh\"][v], int(m), m * 720 + rng.uniform(0, 720), p, float(temp[m] + rng.normal(0, 3))))\n", + " ev = pd.DataFrame(evs, columns=[\"veh\", \"month\", \"t\", \"part\", \"temp\"]).sort_values(\"t\").reset_index(drop=True)\n", + " ev[\"ev_id\"] = [f\"W{i:05d}\" for i in range(len(ev))]\n", + " rows, codes = [], []\n", + " for e in ev.itertuples():\n", + " c, r = [], rng.random(3)\n", + " if r[0] < 0.6: c.append(SPEC[e.part])\n", + " if e.part in SHARED and r[1] < 0.6: c.append(SHARED[e.part])\n", + " if r[2] < 0.5: c.append(SYMP[PSYS[e.part]])\n", + " if not c: c.append(SHARED.get(e.part, SPEC[e.part]))\n", + " for q, pr in CASCADE.get(e.part, []):\n", + " if rng.random() < pr: c.append(SPEC[q])\n", + " if rng.random() < 0.12: c.append(SPEC[PID[rng.integers(0, 16)]])\n", + " c = list(dict.fromkeys(c))\n", + " rng.shuffle(c) # 동시 발생 코드의 기록 순서는 임의\n", + " codes.append(c)\n", + " for k, cd in enumerate(c):\n", + " rows.append((e.ev_id, e.veh, e.t + k * 0.1, cd))\n", + " ev[\"codes\"] = codes\n", + " log = pd.DataFrame(rows, columns=[\"ev_id\", \"veh\", \"ts\", \"code\"])\n", + " return fleet, ev, log\n", + "\n", + "# ---------------------------------------------------------------------------\n", + "# 공통 분석 조각: 코드 멀티핫 행렬, 차량 맥락 피처, 학습/검증 분할(앞 12개월 학습, 뒤 6개월 검증)\n", + "# ---------------------------------------------------------------------------\n", + "CIDX = {c: i for i, c in enumerate(CODES)}\n", + "SPLIT_M = 12\n", + "\n", + "\n", + "def multi_hot(ev):\n", + " X = np.zeros((len(ev), len(CODES)))\n", + " for r, cs in enumerate(ev[\"codes\"]):\n", + " for c in cs:\n", + " X[r, CIDX[c]] = 1\n", + " return X\n", + "\n", + "\n", + "def context(ev, fleet):\n", + " f = fleet.set_index(\"veh\")\n", + " return np.column_stack([ev[\"veh\"].map(f[\"age\"]) / 25, ev[\"veh\"].map(f[\"pm_gap\"]) / 200, ev[\"veh\"].map(f[\"terrain\"]),\n", + " ev[\"veh\"].map(f[\"load\"]), (ev[\"temp\"] - 12) / 15])\n", + "\n", + "\n", + "def feats(ev, fleet):\n", + " X = multi_hot(ev)\n", + " n = X.sum(axis=1, keepdims=True) / 3\n", + " return np.hstack([X, n, context(ev, fleet)])\n", + "\n", + "\n", + "def monthly_counts(ev):\n", + " return ev.groupby([\"month\", \"part\"]).size().unstack(fill_value=0).reindex(columns=PID, fill_value=0)\n", + "\n", + "\n", + "def build_monthly(ev, fleet, top_n=8):\n", + " \"\"\"부품별 월간 고장 건수 예측용 표: 지연값(lag), 이동평균, 추세, 기대 건수(가동시간 x 설계 고장률), 기온 상호작용, 계절 성분\"\"\"\n", + " mc = ev.groupby([\"month\", \"part\"]).size().unstack(fill_value=0)\n", + " top = ev[\"part\"].value_counts().index[:top_n]\n", + " mh, tm = fleet.attrs[\"month_h\"], ev.groupby(\"month\")[\"temp\"].mean().values\n", + " rows = []\n", + " for p in top:\n", + " c = mc[p].values.astype(float)\n", + " for m in range(3, MONTHS):\n", + " L = np.log1p(c)\n", + " rows.append({\"part\": p, \"month\": m, \"y\": c[m], \"lag1\": L[m - 1], \"lag2\": L[m - 2], \"lag3\": L[m - 3], \"roll3\": L[m - 3:m].mean(),\n", + " \"trend\": L[m - 1] - L[m - 2], \"roll3_std\": L[m - 3:m].std(), \"log_expected\": np.log(mh[m] / 1000 * RATE[p]),\n", + " \"rate_lag1\": c[m - 1] / (mh[m - 1] / 1000) / RATE[p], \"temp\": tm[m] / 10, \"cold_elec\": max(0, 12 - tm[m]) / 10 * (p in (\"P03\", \"P04\")),\n", + " \"heat_cool\": max(0, tm[m] - 12) / 10 * (p in (\"P06\", \"P16\")), \"sin\": np.sin(2 * np.pi * m / 12), \"cos\": np.cos(2 * np.pi * m / 12)})\n", + " D = pd.DataFrame(rows)\n", + " return D, [c for c in D.columns if c not in (\"part\", \"month\", \"y\")], top\n", + "\n", + "# ---------------------------------------------------------------------------\n", + "# [교육용가상자료] 부품별 정비 조치 가이드라인(요약 3단계). 실제 정비 교범이 아니라 교육용으로 만든 예시 문구다.\n", + "# ---------------------------------------------------------------------------\n", + "GUIDE = {\n", + " \"P01\": \"연료 압력 측정 -> 분사기 누설·막힘 확인 -> 불량 시 분사기 교체\", \"P02\": \"흡기 압력·이물 점검 -> 축 유격 확인 -> 불량 시 터보차저 교체\",\n", + " \"P03\": \"단자 전압·부식 점검 -> 충전 계통 확인 -> 불량 시 축전지 교체\", \"P04\": \"발전 전압 측정 -> 벨트·배선 점검 -> 불량 시 발전기 교체\",\n", + " \"P05\": \"제어모듈 소프트웨어·커넥터 점검 -> 변속 이력 확인 -> 불량 시 모듈 교체\", \"P06\": \"냉각수량·누수 점검 -> 펌프 작동음 확인 -> 불량 시 펌프 교체\",\n", + " \"P07\": \"유압 오일량·압력 점검 -> 펌프 누설 확인 -> 불량 시 펌프 교체\", \"P08\": \"센서 배선·간극 점검 -> 신호 파형 확인 -> 불량 시 센서 교체\",\n", + " \"P09\": \"커넥터 접촉·부식 점검 -> 핀 상태 확인 -> 불량 시 커넥터 교체\", \"P10\": \"필터 오염도 점검 -> 연료 흐름 확인 -> 불량 시 필터 교체\",\n", + " \"P11\": \"제동 압력 점검 -> 밸브 작동 확인 -> 불량 시 밸브 교체(안전 확인 필수)\", \"P12\": \"조향 반응 점검 -> 액추에이터 작동 확인 -> 불량 시 교체(안전 확인 필수)\",\n", + " \"P13\": \"필터 차압 점검 -> 오염 확인 -> 불량 시 필터 교체\", \"P14\": \"통신 링크 상태 점검 -> 모듈 전원 확인 -> 불량 시 모듈 교체\",\n", + " \"P15\": \"압력 센서 신호 점검 -> 윤활유량 확인 -> 불량 시 센서 교체\", \"P16\": \"팬 모터 전류 점검 -> 회전 확인 -> 불량 시 모터 교체\"}\n", + "\n", + "SYSKR = {\"engine\": \"엔진\", \"electric\": \"전기\", \"drive\": \"구동\", \"hydraulic\": \"유압\", \"brake\": \"제동\", \"cooling\": \"냉각\"}\n", + "SHNAME = {SHARED[p[0]]: f\"{PNAME[p[0]]}·{PNAME[p[1]]} 공유\" for p in PAIRS}\n", + "\n", + "\n", + "def code_name(c):\n", + " if c.startswith(\"S\"):\n", + " return f\"공유 코드 {c}({SHNAME[c]})\"\n", + " if c.startswith(\"G\"):\n", + " return f\"{SYSKR[[s for s, v in SYMP.items() if v == c][0]]} 계통 증상 코드 {c}\"\n", + " return f\"{PNAME['P' + c[1:3]]} 고유 코드 {c}\"\n", + "\n", + "\n", + "FNAME = [code_name(c) for c in CODES] + [\"동시 발생 코드 수\", \"차령\", \"예방정비 경과시간\", \"지형 가혹도\", \"운용 부하\", \"기온\"]\n", + "NC = len(CODES)\n", + "\n", + "fleet, ev, log = make_data()\n", + "train, test = ev[ev[\"month\"] < SPLIT_M], ev[ev[\"month\"] >= SPLIT_M].reset_index(drop=True)\n", + "Xtr, Xte = feats(train, fleet), feats(test, fleet)\n", + "clf = LogisticRegression(C=3, max_iter=500).fit(Xtr, train[\"part\"])\n", + "P = clf.predict_proba(Xte)\n", + "cls, mu = clf.classes_, Xtr.mean(axis=0)\n", + "pred = P.argmax(axis=1)\n", + "\n", + "\n", + "def explain(i, k=3):\n", + " \"\"\"예측 부품에 대한 입력 인자별 기여도 = 계수 x (입력값 - 학습 평균). 값이 큰 인자 k개를 반환.\"\"\"\n", + " contrib = clf.coef_[pred[i]] * (Xte[i] - mu)\n", + " contrib[:NC][Xte[i][:NC] == 0] = -np.inf # 입력되지 않은 코드는 근거에서 제외\n", + " idx = np.argsort(-contrib)[:k]\n", + " return [(FNAME[j], contrib[j]) for j in idx], idx\n", + "\n", + "\n", + "def summary(i):\n", + " f, _ = explain(i)\n", + " return (f\"{PNAME[cls[pred[i]]]} 고장 가능성 {P[i, pred[i]]:.0%}. 주요 근거: \" + \", \".join(n for n, _ in f) + \".\")\n", + "\n", + "\n", + "# 1) 전 예측 건 기여도 자동 산출 + 근거 유효성 점검\n", + "ok3, drop = 0, 0\n", + "for i in range(len(test)):\n", + " f, idx = explain(i)\n", + " ok3 += len(f) == 3\n", + " x2 = Xte[i].copy(); x2[idx[0]] = mu[idx[0]] # 가장 큰 근거를 평균값으로 되돌려 본다\n", + " drop += clf.predict_proba(x2[None])[0, pred[i]] < P[i, pred[i]]\n", + "print(f\"[교육용가상자료] 예측 {len(test)}건 중 기여도 상위 3개 인자 산출 {ok3}건 (산출률 {ok3 / len(test):.0%}, 목표 100%: {'달성' if ok3 == len(test) else '미달'})\")\n", + "print(f\"근거 유효성: 상위 인자를 평균값으로 되돌리면 예측 확률이 낮아지는 비율 {drop / len(test):.1%}\")\n", + "\n", + "# 2) 모바일 진단 카드 2건: 신뢰도 높음 / 낮음\n", + "print(\"\\n[모바일 진단 카드]\")\n", + "for tgt in (0.95, 0.55):\n", + " i = int(np.argmin(np.abs(P.max(axis=1) - tgt)))\n", + " f, _ = explain(i)\n", + " print(f\" 입력 DTC {test['codes'][i]} (실제 고장 {PNAME[test['part'][i]]})\")\n", + " print(f\" 진단 {PNAME[cls[pred[i]]]} 신뢰도 {P[i, pred[i]]:.0%} | 근거: \" + \" / \".join(f\"{n}({v:+.1f})\" for n, v in f))\n", + " print(f\" 조치 절차: {GUIDE[cls[pred[i]]]}\")\n", + " print(f\" 요약: {summary(i)}\")\n", + "\n", + "# 3) 웹 대시보드: 최근 달 현황과 경보\n", + "last = test[test[\"month\"] == MONTHS - 1]\n", + "li = last.index.values\n", + "alert = [(PSYS[cls[pred[i]]] in CRIT_SYS) and P[i, pred[i]] >= 0.7 for i in li]\n", + "print(f\"\\n[웹 대시보드] 최근 달 정비 건 {len(last)}건, 기동·제동 계통 경보 {sum(alert)}건, 신뢰도 낮음(0.7 미만) {int((P[li].max(axis=1) < 0.7).sum())}건\")\n", + "cnt = pd.Series([cls[pred[i]] for i in li]).value_counts().head(3)\n", + "print(\" 다빈도 진단 부품: \" + \", \".join(f\"{PNAME[p]} {n}건\" for p, n in cnt.items()))\n", + "\n", + "fig, ax = plt.subplots(2, 2, figsize=(11, 7))\n", + "cnt5 = pd.Series([cls[pred[i]] for i in li]).value_counts().head(8)\n", + "ax[0, 0].bar(cnt5.index, cnt5.values); ax[0, 0].set_title(\"Diagnosed parts (last month)\")\n", + "mon = ev.groupby(\"month\").size(); ax[0, 1].plot(mon.index, mon.values, marker=\"o\"); ax[0, 1].set_title(\"Monthly maintenance cases\")\n", + "j = int(np.argmin(np.abs(P.max(axis=1) - 0.55))); f, _ = explain(j)\n", + "ax[1, 0].barh([f\"factor {k + 1}\" for k in range(3)][::-1], [v for _, v in f][::-1]); ax[1, 0].set_title(\"Top-3 contributions (low-confidence case)\")\n", + "ax[1, 1].hist(P.max(axis=1), bins=20); ax[1, 1].axvline(0.7, ls=\"--\", color=\"k\"); ax[1, 1].set_title(\"Confidence distribution (line = 0.7)\")\n", + "plt.tight_layout(); plt.show()\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 06 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + "[교육용가상자료] 예측 3890건 중 기여도 상위 3개 인자 산출 3890건 (산출률 100%, 목표 100%: 달성)\n", + "근거 유효성: 상위 인자를 평균값으로 되돌리면 예측 확률이 낮아지는 비율 92.5%\n", + "\n", + "[모바일 진단 카드]\n", + " 입력 DTC ['G01', 'C101', 'S01'] (실제 고장 연료분사기)\n", + " 진단 연료분사기 신뢰도 95% | 근거: 공유 코드 S01(연료분사기·터보차저 공유)(+4.3) / 엔진 계통 증상 코드 G01(+3.0) / 연료 필터 고유 코드 C101(+1.6)\n", + " 조치 절차: 연료 압력 측정 -> 분사기 누설·막힘 확인 -> 불량 시 분사기 교체\n", + " 요약: 연료분사기 고장 가능성 95%. 주요 근거: 공유 코드 S01(연료분사기·터보차저 공유), 엔진 계통 증상 코드 G01, 연료 필터 고유 코드 C101.\n", + " 입력 DTC ['G01'] (실제 고장 터보차저)\n", + " 진단 연료분사기 신뢰도 55% | 근거: 엔진 계통 증상 코드 G01(+3.0) / 기온(+0.1) / 운용 부하(+0.1)\n", + " 조치 절차: 연료 압력 측정 -> 분사기 누설·막힘 확인 -> 불량 시 분사기 교체\n", + " 요약: 연료분사기 고장 가능성 55%. 주요 근거: 엔진 계통 증상 코드 G01, 기온, 운용 부하.\n", + "\n", + "[웹 대시보드] 최근 달 정비 건 457건, 기동·제동 계통 경보 238건, 신뢰도 낮음(0.7 미만) 148건\n", + " 다빈도 진단 부품: 연료분사기 189건, 터보차저 72건, 냉각수 펌프 40건\n", + "```\n", + "
\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 07. 복합 고장 의심 시 숙련 정비관 2차 정밀 진단(HITL) 워크플로우 설계\n", + "**소요시간**: 약 3.5시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "복합 고장 의심 시 숙련 정비관 2차 정밀 진단(HITL) 워크플로우를 설계한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **복합 고장이 의심될 때 숙련 정비관 2차 정밀 진단(HITL) 워크플로우를 시뮬레이션으로 설계하고 오진단 비용 절감 효과를 검증한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **신뢰도가 기준 미만이거나 3개 계통 이상 DTC가 겹친 건을 정비관 대기열에 올리고, 엔진 안을 자동 확정하지 않은 채 정비관의 확정·정정·무응답(엔진 안 유지, 재확인 표시)을 사유와 함께 해시 체인 감사 로그로 남긴다 과정을 진행하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 5개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `src/optimization/mnt03_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": [ + "\"\"\"복합 고장이 의심되는 정비 건(신뢰도 낮음, 서로 다른 계통의 DTC 동시 발생)을 숙련 정비관 2차 정밀 진단 대기열에 올려 승인·정정·무응답(엔진 안 유지·재확인 표시)을 사유와 함께 해시 체인 감사 로그에 남기는 HITL 워크플로우 시뮬레이터를 구현한다. 자동 확정 0건, 오진단 비용 절감률, 신뢰도 기준·정비관 수용량·고장 급증에 대한 민감도를 확인한다.\"\"\"\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.linear_model import LogisticRegression\n", + "\n", + "# ---------------------------------------------------------------------------\n", + "# [교육용가상자료] 가상 차량 100대의 18개월 고장 진단 코드(DTC)·정비 이력 생성기 (실제 부대·장비·정비 실적 없음)\n", + "# 부품 16종(1000가동시간당 고장률 차등), DTC 28종(부품 고유 코드, 짝 부품 공유 코드, 계통 증상 코드)\n", + "# 고장 1건(정비 이력)마다 DTC가 1~5개 동시 발생하고, 연쇄 고장 코드와 일시 잡음 코드가 섞인다.\n", + "# ---------------------------------------------------------------------------\n", + "PARTS = [ # (ID, 이름, 계통, 1000가동시간당 고장률, 가상 단가지수, 수리시간 h)\n", + " (\"P01\", \"연료분사기\", \"engine\", 12.0, 3.0, 2.0), (\"P02\", \"터보차저\", \"engine\", 9.0, 8.0, 5.0),\n", + " (\"P03\", \"축전지\", \"electric\", 6.5, 4.0, 1.5), (\"P04\", \"발전기\", \"electric\", 4.0, 9.0, 3.0),\n", + " (\"P05\", \"변속기 제어모듈\", \"drive\", 3.0, 12.0, 6.0), (\"P06\", \"냉각수 펌프\", \"engine\", 2.2, 3.5, 3.0),\n", + " (\"P07\", \"유압 펌프\", \"hydraulic\", 1.6, 10.0, 4.0), (\"P08\", \"속도 센서\", \"drive\", 1.2, 2.0, 1.5),\n", + " (\"P09\", \"ECU 커넥터\", \"electric\", 0.9, 2.5, 1.5), (\"P10\", \"연료 필터\", \"engine\", 0.7, 0.8, 0.7),\n", + " (\"P11\", \"제동 밸브\", \"brake\", 0.5, 6.0, 3.0), (\"P12\", \"조향 액추에이터\", \"drive\", 0.4, 9.0, 5.0),\n", + " (\"P13\", \"공기 필터\", \"engine\", 0.3, 0.5, 0.5), (\"P14\", \"통신 인터페이스 모듈\", \"electric\", 0.25, 3.0, 1.5),\n", + " (\"P15\", \"윤활유 압력 센서\", \"engine\", 0.2, 1.0, 1.0), (\"P16\", \"팬 구동 모터\", \"cooling\", 0.15, 2.0, 2.0)]\n", + "PID = [p[0] for p in PARTS]\n", + "PNAME = {p[0]: p[1] for p in PARTS}; PSYS = {p[0]: p[2] for p in PARTS}; RATE = {p[0]: p[3] for p in PARTS}\n", + "COST = {p[0]: p[4] for p in PARTS}; REPH = {p[0]: p[5] for p in PARTS}\n", + "PAIRS = [(\"P01\", \"P02\"), (\"P03\", \"P04\"), (\"P05\", \"P08\"), (\"P06\", \"P10\"), (\"P09\", \"P14\"), (\"P13\", \"P15\")]\n", + "SHARED = {p: f\"S{k + 1:02d}\" for k, pr in enumerate(PAIRS) for p in pr} # 짝 부품이 함께 쓰는 모호한 코드\n", + "SPEC = {p: f\"C{p[1:]}1\" for p in PID} # 부품 고유 코드\n", + "SYS = [\"engine\", \"electric\", \"drive\", \"hydraulic\", \"brake\", \"cooling\"]\n", + "SYMP = {s: f\"G{i + 1:02d}\" for i, s in enumerate(SYS)} # 계통 증상 코드\n", + "CODES = sorted(set(SPEC.values()) | set(SHARED.values()) | set(SYMP.values()))\n", + "CASCADE = {\"P01\": [(\"P10\", 0.20)], \"P02\": [(\"P06\", 0.20)], \"P03\": [(\"P04\", 0.25)], \"P04\": [(\"P09\", 0.25)],\n", + " \"P05\": [(\"P08\", 0.30)], \"P06\": [(\"P16\", 0.30)], \"P09\": [(\"P14\", 0.20)], \"P12\": [(\"P07\", 0.20)]}\n", + "CRIT_SYS = {\"engine\", \"drive\", \"brake\"} # 기동·제동 불능 위험 계통(경보 대상)\n", + "MONTHS = 18\n", + "\n", + "\n", + "def make_fleet(n=100, seed=0):\n", + " rng = np.random.default_rng(seed)\n", + " f = pd.DataFrame({\"veh\": [f\"V{i:03d}\" for i in range(n)], \"age\": rng.integers(1, 26, n).astype(float),\n", + " \"terrain\": rng.uniform(0, 1, n), \"load\": rng.uniform(0, 1, n), \"pm_gap\": rng.uniform(20, 200, n),\n", + " \"humid\": rng.uniform(0, 1, n), \"base_h\": rng.uniform(55, 125, n)})\n", + " f[\"mult\"] = np.exp(0.045 * (f[\"age\"] - 13) + 0.9 * (f[\"terrain\"] - .5) + 0.8 * (f[\"load\"] - .5)\n", + " + 0.006 * (f[\"pm_gap\"] - 110) + 0.7 * (f[\"humid\"] - .5))\n", + " return f\n", + "\n", + "\n", + "def make_data(seed=1, months=MONTHS):\n", + " \"\"\"반환: fleet(차량 속성), ev(정비 이력 1건=1행: 발생 DTC 목록, 실제 고장 부품), log(DTC 발생 로그, 행=코드 1건)\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " fleet = make_fleet()\n", + " V = len(fleet)\n", + " temp = 12 + 14 * np.sin(2 * np.pi * (np.arange(months) - 3) / 12) # 월평균 기온(가상)\n", + " hours = fleet[\"base_h\"].values[:, None] * (1 + 0.25 * np.sin(2 * np.pi * np.arange(months) / 12 + 1)) * rng.lognormal(0, .12, (V, months))\n", + " fleet[\"hours\"] = hours.sum(axis=1) # 차량별 18개월 총 가동시간\n", + " fleet.attrs[\"month_h\"] = hours.sum(axis=0) # 월별 전체 가동시간(고장률 분모)\n", + " evs = []\n", + " for p in PID:\n", + " s = np.ones(months)\n", + " if p in (\"P03\", \"P04\"): s = np.exp(-0.06 * (temp - 12)) # 한랭기 전기계통 증가\n", + " if p in (\"P06\", \"P16\"): s = np.exp(0.05 * (temp - 12)) # 혹서기 냉각계통 증가\n", + " cnt = rng.poisson(RATE[p] * hours / 1000 * fleet[\"mult\"].values[:, None] * s[None, :])\n", + " for v, m in zip(*np.nonzero(cnt)):\n", + " for _ in range(cnt[v, m]):\n", + " evs.append((fleet[\"veh\"][v], int(m), m * 720 + rng.uniform(0, 720), p, float(temp[m] + rng.normal(0, 3))))\n", + " ev = pd.DataFrame(evs, columns=[\"veh\", \"month\", \"t\", \"part\", \"temp\"]).sort_values(\"t\").reset_index(drop=True)\n", + " ev[\"ev_id\"] = [f\"W{i:05d}\" for i in range(len(ev))]\n", + " rows, codes = [], []\n", + " for e in ev.itertuples():\n", + " c, r = [], rng.random(3)\n", + " if r[0] < 0.6: c.append(SPEC[e.part])\n", + " if e.part in SHARED and r[1] < 0.6: c.append(SHARED[e.part])\n", + " if r[2] < 0.5: c.append(SYMP[PSYS[e.part]])\n", + " if not c: c.append(SHARED.get(e.part, SPEC[e.part]))\n", + " for q, pr in CASCADE.get(e.part, []):\n", + " if rng.random() < pr: c.append(SPEC[q])\n", + " if rng.random() < 0.12: c.append(SPEC[PID[rng.integers(0, 16)]])\n", + " c = list(dict.fromkeys(c))\n", + " rng.shuffle(c) # 동시 발생 코드의 기록 순서는 임의\n", + " codes.append(c)\n", + " for k, cd in enumerate(c):\n", + " rows.append((e.ev_id, e.veh, e.t + k * 0.1, cd))\n", + " ev[\"codes\"] = codes\n", + " log = pd.DataFrame(rows, columns=[\"ev_id\", \"veh\", \"ts\", \"code\"])\n", + " return fleet, ev, log\n", + "\n", + "# ---------------------------------------------------------------------------\n", + "# 공통 분석 조각: 코드 멀티핫 행렬, 차량 맥락 피처, 학습/검증 분할(앞 12개월 학습, 뒤 6개월 검증)\n", + "# ---------------------------------------------------------------------------\n", + "CIDX = {c: i for i, c in enumerate(CODES)}\n", + "SPLIT_M = 12\n", + "\n", + "\n", + "def multi_hot(ev):\n", + " X = np.zeros((len(ev), len(CODES)))\n", + " for r, cs in enumerate(ev[\"codes\"]):\n", + " for c in cs:\n", + " X[r, CIDX[c]] = 1\n", + " return X\n", + "\n", + "\n", + "def context(ev, fleet):\n", + " f = fleet.set_index(\"veh\")\n", + " return np.column_stack([ev[\"veh\"].map(f[\"age\"]) / 25, ev[\"veh\"].map(f[\"pm_gap\"]) / 200, ev[\"veh\"].map(f[\"terrain\"]),\n", + " ev[\"veh\"].map(f[\"load\"]), (ev[\"temp\"] - 12) / 15])\n", + "\n", + "\n", + "def feats(ev, fleet):\n", + " X = multi_hot(ev)\n", + " n = X.sum(axis=1, keepdims=True) / 3\n", + " return np.hstack([X, n, context(ev, fleet)])\n", + "\n", + "\n", + "def monthly_counts(ev):\n", + " return ev.groupby([\"month\", \"part\"]).size().unstack(fill_value=0).reindex(columns=PID, fill_value=0)\n", + "\n", + "\n", + "def build_monthly(ev, fleet, top_n=8):\n", + " \"\"\"부품별 월간 고장 건수 예측용 표: 지연값(lag), 이동평균, 추세, 기대 건수(가동시간 x 설계 고장률), 기온 상호작용, 계절 성분\"\"\"\n", + " mc = ev.groupby([\"month\", \"part\"]).size().unstack(fill_value=0)\n", + " top = ev[\"part\"].value_counts().index[:top_n]\n", + " mh, tm = fleet.attrs[\"month_h\"], ev.groupby(\"month\")[\"temp\"].mean().values\n", + " rows = []\n", + " for p in top:\n", + " c = mc[p].values.astype(float)\n", + " for m in range(3, MONTHS):\n", + " L = np.log1p(c)\n", + " rows.append({\"part\": p, \"month\": m, \"y\": c[m], \"lag1\": L[m - 1], \"lag2\": L[m - 2], \"lag3\": L[m - 3], \"roll3\": L[m - 3:m].mean(),\n", + " \"trend\": L[m - 1] - L[m - 2], \"roll3_std\": L[m - 3:m].std(), \"log_expected\": np.log(mh[m] / 1000 * RATE[p]),\n", + " \"rate_lag1\": c[m - 1] / (mh[m - 1] / 1000) / RATE[p], \"temp\": tm[m] / 10, \"cold_elec\": max(0, 12 - tm[m]) / 10 * (p in (\"P03\", \"P04\")),\n", + " \"heat_cool\": max(0, tm[m] - 12) / 10 * (p in (\"P06\", \"P16\")), \"sin\": np.sin(2 * np.pi * m / 12), \"cos\": np.cos(2 * np.pi * m / 12)})\n", + " D = pd.DataFrame(rows)\n", + " return D, [c for c in D.columns if c not in (\"part\", \"month\", \"y\")], top\n", + "\n", + "fleet, ev, log = make_data()\n", + "train, test = ev[ev[\"month\"] < SPLIT_M], ev[ev[\"month\"] >= SPLIT_M].reset_index(drop=True)\n", + "clf = LogisticRegression(C=3, max_iter=500).fit(feats(train, fleet), train[\"part\"])\n", + "P = clf.predict_proba(feats(test, fleet))\n", + "cls = clf.classes_\n", + "top1 = cls[P.argmax(axis=1)]\n", + "conf = P.max(axis=1)\n", + "CSYS = {c: {PSYS[p] for p in PID if c in (SPEC[p], SHARED.get(p)) or c == SYMP[PSYS[p]]} for c in CODES}\n", + "nsys = test[\"codes\"].map(lambda cs: len(set().union(*[CSYS[c] for c in cs])))\n", + "multi_sys = ((test[\"codes\"].str.len() >= 3) & (nsys >= 3)).values # 3개 이상 계통에 걸친 DTC가 동시에 발생\n", + "THR, CAP = 0.8, 300 # 운영점: 신뢰도 0.8 미만 또는 다계통 동시 발생이면 정밀 진단, 정비관 월 수용량 300건(아래 민감도 분석으로 선정)\n", + "\n", + "# [교육용가상자료] 가정 비용지수: 오진단 시 헛교체 부품비 + 헛교체 공수 + 재입고 패널티 3.0 + 올바른 수리, 정밀 진단 1건 1.0\n", + "REVIEW_COST, REVISIT, SENIOR_ACC, NO_RESP = 1.0, 3.0, 0.93, 0.08\n", + "\n", + "\n", + "def case_cost(pred, true):\n", + " if pred == true:\n", + " return REPH[true]\n", + " return COST[pred] + REPH[pred] + REVISIT + REPH[true]\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 run(thr=THR, cap=CAP, surge=1.0, seed=0, audit=None, mode=\"hitl\"):\n", + " \"\"\"mode: auto(엔진 top-1 전부 확정: 비교용, 실제 운용 금지) / hitl(복합 의심 건은 정비관 결정 전까지 확정하지 않음)\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " idx_all = np.arange(len(test))\n", + " if surge != 1.0:\n", + " idx_all = np.concatenate([idx_all, rng.choice(idx_all, int(len(idx_all) * (surge - 1)), replace=True)])\n", + " cost, wrong, queued, decided, pending_auto, silent = 0.0, 0, 0, 0, 0, 0\n", + " for m in range(SPLIT_M, MONTHS):\n", + " mi = idx_all[test[\"month\"].values[idx_all] == m]\n", + " flag = [i for i in mi if conf[i] < thr or multi_sys[i]]\n", + " flag.sort(key=lambda i: (PSYS[top1[i]] not in CRIT_SYS, conf[i])) # 안전 계통 우선, 신뢰도 낮은 순\n", + " queue = set(flag[:cap]) if mode == \"hitl\" else set()\n", + " for i in mi:\n", + " true, final = test[\"part\"][i], top1[i]\n", + " if i in queue:\n", + " queued += 1\n", + " audit.add(event=\"PROPOSE\", case=int(i), engine=str(top1[i]), conf=round(float(conf[i]), 3), status=\"PENDING\")\n", + " cost += REVIEW_COST\n", + " r = rng.random()\n", + " if r < NO_RESP:\n", + " dec, why = \"무응답\", \"응답 지연 -> 엔진 안 유지, 재확인 필요 표시\"; silent += 1\n", + " elif r < NO_RESP + (1 - NO_RESP) * SENIOR_ACC:\n", + " dec, final, why = (\"승인\" if top1[i] == true else \"정정\"), true, \"추가 계측·부품 분리 점검으로 확인\"\n", + " else:\n", + " second = cls[np.argsort(-P[i])[1]]\n", + " dec, final, why = \"정정\", second, \"2순위 후보로 정정(근거 부족)\"\n", + " audit.add(event=\"DECIDE\", case=int(i), decision=dec, final=str(final), reason=why, by=\"숙련 정비관(모의)\")\n", + " decided += 1\n", + " cost += case_cost(final, true); wrong += final != true\n", + " n = len(idx_all)\n", + " return {\"cost\": cost, \"wrong\": wrong / n, \"queued\": queued, \"decided\": decided, \"silent\": silent, \"n\": n, \"auto\": pending_auto}\n", + "\n", + "\n", + "aud = Audit()\n", + "A, H = run(mode=\"auto\", audit=Audit()), run(audit=aud)\n", + "print(f\"[교육용가상자료] 검증 정비 건 {A['n']}건, 복합 고장 의심 건 {int(((conf < THR) | multi_sys).sum())}건 (신뢰도 {THR} 미만 또는 3개 계통 이상 DTC)\")\n", + "print(f\"엔진 단독 자동 확정(비교용): 비용지수 {A['cost']:.0f}, 오진단율 {A['wrong']:.1%}\")\n", + "print(f\"HITL(정비관 대기열 {H['queued']}건): 비용지수 {H['cost']:.0f}, 오진단율 {H['wrong']:.1%}\")\n", + "cut = 1 - H[\"cost\"] / A[\"cost\"]\n", + "print(f\"오진단 비용 절감률 {cut:.1%} (목표 15% 이상: {'달성' if cut >= 0.15 else '미달'})\")\n", + "\n", + "# 승인 체계 점검\n", + "print(f\"\\n자동 확정 0건 확인: 대기열 건은 모두 정비관 결정 기록 후 확정 (자동 확정 {H['auto']}건)\")\n", + "print(f\"결정 로깅률 {H['decided'] / max(H['queued'], 1):.0%} ({H['decided']}/{H['queued']}), 무응답 {H['silent']}건은 엔진 안 유지 + 재확인 표시\")\n", + "print(f\"감사 로그 무결성: {'정상' if aud.verify() is None else '이상'}\", end=\", \")\n", + "aud.log[7][\"reason\"] = \"사후 수정\"\n", + "print(f\"사후 변조 시 seq {aud.verify()}에서 탐지\")\n", + "print(\"결정 사유 예:\", \"; \".join(sorted({e[\"reason\"] for e in aud.log if e[\"event\"] == \"DECIDE\"})[:3]))\n", + "\n", + "# 민감도 분석\n", + "print(\"\\n[민감도 1] 신뢰도 기준(미만이면 정밀 진단) -> 절감률, 정비관 대기열(6개월)\")\n", + "rows = []\n", + "for thr in (0.5, 0.6, 0.7, 0.8, 0.9):\n", + " r = run(thr=thr, cap=10 ** 6, audit=Audit()); rows.append((thr, 1 - r[\"cost\"] / A[\"cost\"], r[\"queued\"]))\n", + " print(f\" {thr:.1f}: 절감 {rows[-1][1]:.1%}, 대기열 {r['queued']}건\")\n", + "print(\"[민감도 2] 정비관 월 수용량 -> 절감률 (전시 전환 가정: 고장 1.5배 급증)\")\n", + "for cap in (50, 100, 200, 300):\n", + " r1, r2 = run(cap=cap, audit=Audit()), run(cap=cap, surge=1.5, audit=Audit())\n", + " b2 = run(mode=\"auto\", surge=1.5, audit=Audit())\n", + " print(f\" 월 {cap}건: 평시 {1 - r1['cost'] / A['cost']:.1%}, 급증 {1 - r2['cost'] / b2['cost']:.1%}\")\n", + "\n", + "plt.figure(figsize=(6, 3.5))\n", + "plt.plot([r[0] for r in rows], [r[1] * 100 for r in rows], marker=\"o\"); plt.xlabel(\"confidence threshold\"); plt.ylabel(\"cost saving (%)\")\n", + "plt.title(\"HITL sensitivity: confidence threshold\"); plt.tight_layout(); plt.show()\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 07 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + "[교육용가상자료] 검증 정비 건 3890건, 복합 고장 의심 건 1555건 (신뢰도 0.8 미만 또는 3개 계통 이상 DTC)\n", + "엔진 단독 자동 확정(비교용): 비용지수 17546, 오진단율 16.1%\n", + "HITL(정비관 대기열 1501건): 비용지수 14809, 오진단율 4.2%\n", + "오진단 비용 절감률 15.6% (목표 15% 이상: 달성)\n", + "\n", + "자동 확정 0건 확인: 대기열 건은 모두 정비관 결정 기록 후 확정 (자동 확정 0건)\n", + "결정 로깅률 100% (1501/1501), 무응답 120건은 엔진 안 유지 + 재확인 표시\n", + "감사 로그 무결성: 정상, 사후 변조 시 seq 7에서 탐지\n", + "결정 사유 예: 2순위 후보로 정정(근거 부족); 사후 수정; 응답 지연 -> 엔진 안 유지, 재확인 필요 표시\n", + "\n", + "[민감도 1] 신뢰도 기준(미만이면 정밀 진단) -> 절감률, 정비관 대기열(6개월)\n", + " 0.5: 절감 1.9%, 대기열 162건\n", + " 0.6: 절감 9.4%, 대기열 784건\n", + " 0.7: 절감 14.6%, 대기열 1285건\n", + " 0.8: 절감 16.9%, 대기열 1555건\n", + " 0.9: 절감 17.7%, 대기열 1671건\n", + "[민감도 2] 정비관 월 수용량 -> 절감률 (전시 전환 가정: 고장 1.5배 급증)\n", + " 월 50건: 평시 2.8%, 급증 2.8%\n", + " 월 100건: 평시 7.0%, 급증 4.4%\n", + " 월 200건: 평시 13.8%, 급증 9.3%\n", + " 월 300건: 평시 15.6%, 급증 12.7%\n", + "```\n", + "
\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 08. 1,000건 동시 다발 고장 코드 주입 시 연관 고장 추론 부하 테스트\n", + "**소요시간**: 약 3.0시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "1,000건 동시 고장 코드 주입 시 연관 고장 추론 부하 테스트를 수행한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **1,000건의 DTC 진단 요청을 동시에 주입해 연관 고장 추론의 처리 시간과 경보 정확도를 확인하는 부하 테스트를 수행한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **가상 진단 요청 1,000건에 표기 오류, 빈 요청, 미정의 코드, 미등록 차량, 중복 코드 같은 오류를 섞어 일괄 처리하고, 정상 처리·격리 건수와 유실 여부를 센다 과정을 진행하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `src/dashboard/mnt03_app.py (Streamlit/Dash 등)` 형태의 결과물을 완성한다.\n", + "\n", + "### 🔍 평가 및 검증 기준 (PASS 조건)\n", + "#### 미션 판정\n", + "\n", + "**평가 구분:** 필수\n", + "\n", + "**핵심 평가영역:** 의사결정 지원에 최적화된 시각적 위계 및 사용자 경험(UX)\n", + "\n", + "#### 미션 PASS 조건\n", + "\n", + "- 정량 기준: 대시보드 로딩 시간 2초 이내, 경보 발령 정확도 95% 이상\n", + "\n", + "\n", + "- 진행 기준: 웹 기반 분석 대시보드 구동 및 실시간 필터링 기능 검증\n", + "\n", + "\n", + "- 검증 방법: 실제 군수 데이터 연동 화면 테스트 및 UI 동작 검증\n", + "\n", + "\n", + "#### 채점자 판정 메모\n", + "\n", + "> 단순 코드 실행 여부뿐만 아니라 출력된 수치와 근거 자료가 군 실무 요구사항에 부합하는지 면밀히 확인한다.\n", + "\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "\"\"\"1,000건의 DTC 진단 요청을 동시에 주입해 연관 고장 추론(부품 진단 + 기동·제동 계통 경보)의 처리 시간과 경보 정확도를 측정하는 부하 테스트를 수행한다. 형식이 깨진 코드·빈 요청·미등록 차량을 섞은 오류 주입 요청이 격리 처리되는지, 요청 수를 100~5,000건으로 늘렸을 때 시간이 어떻게 변하는지 확인한다.\"\"\"\n", + "import time\n", + "import numpy as np\n", + "import pandas as pd\n", + "import matplotlib.pyplot as plt\n", + "from concurrent.futures import ThreadPoolExecutor\n", + "from sklearn.linear_model import LogisticRegression\n", + "\n", + "# ---------------------------------------------------------------------------\n", + "# [교육용가상자료] 가상 차량 100대의 18개월 고장 진단 코드(DTC)·정비 이력 생성기 (실제 부대·장비·정비 실적 없음)\n", + "# 부품 16종(1000가동시간당 고장률 차등), DTC 28종(부품 고유 코드, 짝 부품 공유 코드, 계통 증상 코드)\n", + "# 고장 1건(정비 이력)마다 DTC가 1~5개 동시 발생하고, 연쇄 고장 코드와 일시 잡음 코드가 섞인다.\n", + "# ---------------------------------------------------------------------------\n", + "PARTS = [ # (ID, 이름, 계통, 1000가동시간당 고장률, 가상 단가지수, 수리시간 h)\n", + " (\"P01\", \"연료분사기\", \"engine\", 12.0, 3.0, 2.0), (\"P02\", \"터보차저\", \"engine\", 9.0, 8.0, 5.0),\n", + " (\"P03\", \"축전지\", \"electric\", 6.5, 4.0, 1.5), (\"P04\", \"발전기\", \"electric\", 4.0, 9.0, 3.0),\n", + " (\"P05\", \"변속기 제어모듈\", \"drive\", 3.0, 12.0, 6.0), (\"P06\", \"냉각수 펌프\", \"engine\", 2.2, 3.5, 3.0),\n", + " (\"P07\", \"유압 펌프\", \"hydraulic\", 1.6, 10.0, 4.0), (\"P08\", \"속도 센서\", \"drive\", 1.2, 2.0, 1.5),\n", + " (\"P09\", \"ECU 커넥터\", \"electric\", 0.9, 2.5, 1.5), (\"P10\", \"연료 필터\", \"engine\", 0.7, 0.8, 0.7),\n", + " (\"P11\", \"제동 밸브\", \"brake\", 0.5, 6.0, 3.0), (\"P12\", \"조향 액추에이터\", \"drive\", 0.4, 9.0, 5.0),\n", + " (\"P13\", \"공기 필터\", \"engine\", 0.3, 0.5, 0.5), (\"P14\", \"통신 인터페이스 모듈\", \"electric\", 0.25, 3.0, 1.5),\n", + " (\"P15\", \"윤활유 압력 센서\", \"engine\", 0.2, 1.0, 1.0), (\"P16\", \"팬 구동 모터\", \"cooling\", 0.15, 2.0, 2.0)]\n", + "PID = [p[0] for p in PARTS]\n", + "PNAME = {p[0]: p[1] for p in PARTS}; PSYS = {p[0]: p[2] for p in PARTS}; RATE = {p[0]: p[3] for p in PARTS}\n", + "COST = {p[0]: p[4] for p in PARTS}; REPH = {p[0]: p[5] for p in PARTS}\n", + "PAIRS = [(\"P01\", \"P02\"), (\"P03\", \"P04\"), (\"P05\", \"P08\"), (\"P06\", \"P10\"), (\"P09\", \"P14\"), (\"P13\", \"P15\")]\n", + "SHARED = {p: f\"S{k + 1:02d}\" for k, pr in enumerate(PAIRS) for p in pr} # 짝 부품이 함께 쓰는 모호한 코드\n", + "SPEC = {p: f\"C{p[1:]}1\" for p in PID} # 부품 고유 코드\n", + "SYS = [\"engine\", \"electric\", \"drive\", \"hydraulic\", \"brake\", \"cooling\"]\n", + "SYMP = {s: f\"G{i + 1:02d}\" for i, s in enumerate(SYS)} # 계통 증상 코드\n", + "CODES = sorted(set(SPEC.values()) | set(SHARED.values()) | set(SYMP.values()))\n", + "CASCADE = {\"P01\": [(\"P10\", 0.20)], \"P02\": [(\"P06\", 0.20)], \"P03\": [(\"P04\", 0.25)], \"P04\": [(\"P09\", 0.25)],\n", + " \"P05\": [(\"P08\", 0.30)], \"P06\": [(\"P16\", 0.30)], \"P09\": [(\"P14\", 0.20)], \"P12\": [(\"P07\", 0.20)]}\n", + "CRIT_SYS = {\"engine\", \"drive\", \"brake\"} # 기동·제동 불능 위험 계통(경보 대상)\n", + "MONTHS = 18\n", + "\n", + "\n", + "def make_fleet(n=100, seed=0):\n", + " rng = np.random.default_rng(seed)\n", + " f = pd.DataFrame({\"veh\": [f\"V{i:03d}\" for i in range(n)], \"age\": rng.integers(1, 26, n).astype(float),\n", + " \"terrain\": rng.uniform(0, 1, n), \"load\": rng.uniform(0, 1, n), \"pm_gap\": rng.uniform(20, 200, n),\n", + " \"humid\": rng.uniform(0, 1, n), \"base_h\": rng.uniform(55, 125, n)})\n", + " f[\"mult\"] = np.exp(0.045 * (f[\"age\"] - 13) + 0.9 * (f[\"terrain\"] - .5) + 0.8 * (f[\"load\"] - .5)\n", + " + 0.006 * (f[\"pm_gap\"] - 110) + 0.7 * (f[\"humid\"] - .5))\n", + " return f\n", + "\n", + "\n", + "def make_data(seed=1, months=MONTHS):\n", + " \"\"\"반환: fleet(차량 속성), ev(정비 이력 1건=1행: 발생 DTC 목록, 실제 고장 부품), log(DTC 발생 로그, 행=코드 1건)\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " fleet = make_fleet()\n", + " V = len(fleet)\n", + " temp = 12 + 14 * np.sin(2 * np.pi * (np.arange(months) - 3) / 12) # 월평균 기온(가상)\n", + " hours = fleet[\"base_h\"].values[:, None] * (1 + 0.25 * np.sin(2 * np.pi * np.arange(months) / 12 + 1)) * rng.lognormal(0, .12, (V, months))\n", + " fleet[\"hours\"] = hours.sum(axis=1) # 차량별 18개월 총 가동시간\n", + " fleet.attrs[\"month_h\"] = hours.sum(axis=0) # 월별 전체 가동시간(고장률 분모)\n", + " evs = []\n", + " for p in PID:\n", + " s = np.ones(months)\n", + " if p in (\"P03\", \"P04\"): s = np.exp(-0.06 * (temp - 12)) # 한랭기 전기계통 증가\n", + " if p in (\"P06\", \"P16\"): s = np.exp(0.05 * (temp - 12)) # 혹서기 냉각계통 증가\n", + " cnt = rng.poisson(RATE[p] * hours / 1000 * fleet[\"mult\"].values[:, None] * s[None, :])\n", + " for v, m in zip(*np.nonzero(cnt)):\n", + " for _ in range(cnt[v, m]):\n", + " evs.append((fleet[\"veh\"][v], int(m), m * 720 + rng.uniform(0, 720), p, float(temp[m] + rng.normal(0, 3))))\n", + " ev = pd.DataFrame(evs, columns=[\"veh\", \"month\", \"t\", \"part\", \"temp\"]).sort_values(\"t\").reset_index(drop=True)\n", + " ev[\"ev_id\"] = [f\"W{i:05d}\" for i in range(len(ev))]\n", + " rows, codes = [], []\n", + " for e in ev.itertuples():\n", + " c, r = [], rng.random(3)\n", + " if r[0] < 0.6: c.append(SPEC[e.part])\n", + " if e.part in SHARED and r[1] < 0.6: c.append(SHARED[e.part])\n", + " if r[2] < 0.5: c.append(SYMP[PSYS[e.part]])\n", + " if not c: c.append(SHARED.get(e.part, SPEC[e.part]))\n", + " for q, pr in CASCADE.get(e.part, []):\n", + " if rng.random() < pr: c.append(SPEC[q])\n", + " if rng.random() < 0.12: c.append(SPEC[PID[rng.integers(0, 16)]])\n", + " c = list(dict.fromkeys(c))\n", + " rng.shuffle(c) # 동시 발생 코드의 기록 순서는 임의\n", + " codes.append(c)\n", + " for k, cd in enumerate(c):\n", + " rows.append((e.ev_id, e.veh, e.t + k * 0.1, cd))\n", + " ev[\"codes\"] = codes\n", + " log = pd.DataFrame(rows, columns=[\"ev_id\", \"veh\", \"ts\", \"code\"])\n", + " return fleet, ev, log\n", + "\n", + "# ---------------------------------------------------------------------------\n", + "# 공통 분석 조각: 코드 멀티핫 행렬, 차량 맥락 피처, 학습/검증 분할(앞 12개월 학습, 뒤 6개월 검증)\n", + "# ---------------------------------------------------------------------------\n", + "CIDX = {c: i for i, c in enumerate(CODES)}\n", + "SPLIT_M = 12\n", + "\n", + "\n", + "def multi_hot(ev):\n", + " X = np.zeros((len(ev), len(CODES)))\n", + " for r, cs in enumerate(ev[\"codes\"]):\n", + " for c in cs:\n", + " X[r, CIDX[c]] = 1\n", + " return X\n", + "\n", + "\n", + "def context(ev, fleet):\n", + " f = fleet.set_index(\"veh\")\n", + " return np.column_stack([ev[\"veh\"].map(f[\"age\"]) / 25, ev[\"veh\"].map(f[\"pm_gap\"]) / 200, ev[\"veh\"].map(f[\"terrain\"]),\n", + " ev[\"veh\"].map(f[\"load\"]), (ev[\"temp\"] - 12) / 15])\n", + "\n", + "\n", + "def feats(ev, fleet):\n", + " X = multi_hot(ev)\n", + " n = X.sum(axis=1, keepdims=True) / 3\n", + " return np.hstack([X, n, context(ev, fleet)])\n", + "\n", + "\n", + "def monthly_counts(ev):\n", + " return ev.groupby([\"month\", \"part\"]).size().unstack(fill_value=0).reindex(columns=PID, fill_value=0)\n", + "\n", + "\n", + "def build_monthly(ev, fleet, top_n=8):\n", + " \"\"\"부품별 월간 고장 건수 예측용 표: 지연값(lag), 이동평균, 추세, 기대 건수(가동시간 x 설계 고장률), 기온 상호작용, 계절 성분\"\"\"\n", + " mc = ev.groupby([\"month\", \"part\"]).size().unstack(fill_value=0)\n", + " top = ev[\"part\"].value_counts().index[:top_n]\n", + " mh, tm = fleet.attrs[\"month_h\"], ev.groupby(\"month\")[\"temp\"].mean().values\n", + " rows = []\n", + " for p in top:\n", + " c = mc[p].values.astype(float)\n", + " for m in range(3, MONTHS):\n", + " L = np.log1p(c)\n", + " rows.append({\"part\": p, \"month\": m, \"y\": c[m], \"lag1\": L[m - 1], \"lag2\": L[m - 2], \"lag3\": L[m - 3], \"roll3\": L[m - 3:m].mean(),\n", + " \"trend\": L[m - 1] - L[m - 2], \"roll3_std\": L[m - 3:m].std(), \"log_expected\": np.log(mh[m] / 1000 * RATE[p]),\n", + " \"rate_lag1\": c[m - 1] / (mh[m - 1] / 1000) / RATE[p], \"temp\": tm[m] / 10, \"cold_elec\": max(0, 12 - tm[m]) / 10 * (p in (\"P03\", \"P04\")),\n", + " \"heat_cool\": max(0, tm[m] - 12) / 10 * (p in (\"P06\", \"P16\")), \"sin\": np.sin(2 * np.pi * m / 12), \"cos\": np.cos(2 * np.pi * m / 12)})\n", + " D = pd.DataFrame(rows)\n", + " return D, [c for c in D.columns if c not in (\"part\", \"month\", \"y\")], top\n", + "\n", + "fleet, ev, log = make_data()\n", + "train, test = ev[ev[\"month\"] < SPLIT_M], ev[ev[\"month\"] >= SPLIT_M].reset_index(drop=True)\n", + "clf = LogisticRegression(C=3, max_iter=500).fit(feats(train, fleet), train[\"part\"])\n", + "cls, FL = clf.classes_, fleet.set_index(\"veh\")\n", + "VALID = set(CODES)\n", + "\n", + "\n", + "def make_requests(n, seed=0, bad_rate=0.05):\n", + " \"\"\"[교육용가상자료] 검증 기간 정비 건에서 n건을 뽑아 DTC 요청으로 만든다. bad_rate 비율만큼 오류를 주입한다.\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " rows = test.iloc[rng.choice(len(test), n, replace=n > len(test))]\n", + " reqs, kinds = [], []\n", + " for e in rows.itertuples():\n", + " r, kind = {\"veh\": e.veh, \"codes\": list(e.codes), \"temp\": e.temp, \"true\": e.part}, \"정상\"\n", + " u = rng.random()\n", + " if u < bad_rate * 0.3: r[\"codes\"] = [c.lower() + \" \" for c in r[\"codes\"]]; kind = \"표기 오류\"\n", + " elif u < bad_rate * 0.5: r[\"codes\"] = [\"ZZ9\"]; kind = \"미정의 코드\"\n", + " elif u < bad_rate * 0.7: r[\"codes\"] = []; kind = \"빈 요청\"\n", + " elif u < bad_rate * 0.85: r[\"veh\"] = \"V999\"; kind = \"미등록 차량\"\n", + " elif u < bad_rate: r[\"codes\"] = r[\"codes\"] * 3; kind = \"중복 코드\"\n", + " reqs.append(r); kinds.append(kind)\n", + " return reqs, kinds\n", + "\n", + "\n", + "def normalize(r):\n", + " \"\"\"표기 정리(공백·대소문자) 후 검증. 문제가 있으면 예외 대신 사유를 돌려준다.\"\"\"\n", + " codes = sorted({c.strip().upper() for c in r[\"codes\"]})\n", + " if r[\"veh\"] not in FL.index: return None, \"미등록 차량\"\n", + " if not codes or any(c not in VALID for c in codes): return None, \"빈 요청·미정의 코드\"\n", + " return codes, \"\"\n", + "\n", + "\n", + "def build_x(batch):\n", + " X = np.zeros((len(batch), len(CODES) + 6))\n", + " for i, (r, cs) in enumerate(batch):\n", + " for c in cs: X[i, CIDX[c]] = 1\n", + " v = FL.loc[r[\"veh\"]]\n", + " X[i, len(CODES):] = [len(cs) / 3, v[\"age\"] / 25, v[\"pm_gap\"] / 200, v[\"terrain\"], v[\"load\"], (r[\"temp\"] - 12) / 15]\n", + " return X\n", + "\n", + "\n", + "def serve_batch(reqs):\n", + " ok, rej = [], []\n", + " for k, r in enumerate(reqs):\n", + " cs, why = normalize(r)\n", + " (ok if cs else rej).append((k, r, cs) if cs else (k, why))\n", + " X = build_x([(r, cs) for _, r, cs in ok])\n", + " P = clf.predict_proba(X) if len(ok) else np.zeros((0, len(cls)))\n", + " top = cls[P.argmax(axis=1)] if len(ok) else []\n", + " out = [{\"k\": k, \"part\": p, \"conf\": float(P[i].max()), \"alert\": PSYS[p] in CRIT_SYS} for i, ((k, _, _), p) in enumerate(zip(ok, top))]\n", + " return out, rej\n", + "\n", + "\n", + "def serve_one(r):\n", + " cs, why = normalize(r)\n", + " if not cs: return None\n", + " p = cls[clf.predict_proba(build_x([(r, cs)]))[0].argmax()]\n", + " return {\"part\": p, \"alert\": PSYS[p] in CRIT_SYS}\n", + "\n", + "\n", + "# 1) 1,000건 동시 주입\n", + "reqs, kinds = make_requests(1000)\n", + "t0 = time.perf_counter(); out, rej = serve_batch(reqs)\n", + "agg = pd.DataFrame(out).groupby(\"part\").size().sort_values(ascending=False).head(5) # 대시보드 집계까지 포함\n", + "alerts = sum(o[\"alert\"] for o in out); dt = time.perf_counter() - t0\n", + "print(f\"[교육용가상자료] 1,000건 동시 주입: 정상 처리 {len(out)}건, 격리(오류) {len(rej)}건, 처리 시간 {dt:.2f}초 (목표 2초 이내: {'달성' if dt <= 2 else '미달'})\")\n", + "print(f\" 오류 주입 {sum(k != '정상' for k in kinds)}건 유형: \" + \", \".join(f\"{k} {v}\" for k, v in pd.Series(kinds).value_counts().items() if k != \"정상\"))\n", + "print(\" 격리 사유: \" + \", \".join(f\"{k} {v}건\" for k, v in pd.Series([w for _, w in rej]).value_counts().items()))\n", + "\n", + "# 2) 경보 정확도(기동·제동 계통 고장 경보)\n", + "truth = np.array([PSYS[reqs[o[\"k\"]][\"true\"]] in CRIT_SYS for o in out]); pa = np.array([o[\"alert\"] for o in out])\n", + "acc = (truth == pa).mean(); rec = (pa & truth).sum() / truth.sum(); prec = (pa & truth).sum() / pa.sum()\n", + "part_acc = np.mean([o[\"part\"] == reqs[o[\"k\"]][\"true\"] for o in out])\n", + "print(f\"\\n경보 정확도 {acc:.1%} (목표 95% 이상: {'달성' if acc >= 0.95 else '미달'}), 재현율 {rec:.1%}, 정밀도 {prec:.1%}, 경보 {alerts}건\")\n", + "print(f\"참고: 부품 단위 진단 정확도 {part_acc:.1%}, 상위 부품 \" + \", \".join(f\"{PNAME[p]} {n}\" for p, n in agg.items()))\n", + "\n", + "# 3) 단건 호출 방식과 비교(8개 작업자 동시 호출)\n", + "t0 = time.perf_counter()\n", + "with ThreadPoolExecutor(8) as ex:\n", + " res = list(ex.map(serve_one, reqs))\n", + "dt1 = time.perf_counter() - t0\n", + "print(f\"\\n단건 호출 8스레드 {dt1:.2f}초 vs 일괄 처리 {dt:.2f}초 (일괄이 {dt1 / dt:.1f}배 빠름), 단건 방식 예외 0건: {all(r is None or 'part' in r for r in res)}\")\n", + "\n", + "# 4) 요청 수 확장 시험\n", + "print(\"[요청 수별 일괄 처리 시간]\")\n", + "ns, ts = [100, 500, 1000, 2000, 5000], []\n", + "for n in ns:\n", + " rq, _ = make_requests(n, seed=n)\n", + " t0 = time.perf_counter(); serve_batch(rq); ts.append(time.perf_counter() - t0)\n", + " print(f\" {n:5d}건: {ts[-1]:.2f}초 ({n / ts[-1]:.0f}건/초)\")\n", + "\n", + "plt.figure(figsize=(6, 3.5)); plt.plot(ns, ts, marker=\"o\"); plt.axhline(2, ls=\"--\", color=\"k\")\n", + "plt.xlabel(\"requests\"); plt.ylabel(\"seconds\"); plt.title(\"Batch inference time (line = 2 s)\"); plt.tight_layout(); plt.show()\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 08 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + "[교육용가상자료] 1,000건 동시 주입: 정상 처리 965건, 격리(오류) 35건, 처리 시간 0.11초 (목표 2초 이내: 달성)\n", + " 오류 주입 65건 유형: 표기 오류 23, 빈 요청 15, 미정의 코드 11, 미등록 차량 9, 중복 코드 7\n", + " 격리 사유: 빈 요청·미정의 코드 26건, 미등록 차량 9건\n", + "\n", + "경보 정확도 99.8% (목표 95% 이상: 달성), 재현율 100.0%, 정밀도 99.6%, 경보 567건\n", + "참고: 부품 단위 진단 정확도 85.1%, 상위 부품 연료분사기 303, 축전지 218, 터보차저 126, 발전기 119, 변속기 제어모듈 53\n", + "\n", + "단건 호출 8스레드 0.33초 vs 일괄 처리 0.11초 (일괄이 3.1배 빠름), 단건 방식 예외 0건: True\n", + "[요청 수별 일괄 처리 시간]\n", + " 100건: 0.01초 (15586건/초)\n", + " 500건: 0.03초 (18195건/초)\n", + " 1000건: 0.06초 (16478건/초)\n", + " 2000건: 0.30초 (6703건/초)\n", + " 5000건: 0.33초 (15293건/초)\n", + "```\n", + "
\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 09. AAR 기반 연관 고장 오진단 사례 분석 및 고장 룰베이스 재튜닝\n", + "**소요시간**: 약 3.0시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "AAR 기반 연관 고장 오진단 사례를 분석하고 룰베이스를 재튜닝한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **AAR 기간의 연관 고장 오진단 사례를 원인별로 분석하고 고장 룰베이스(코드 유형 가중치·예외 규칙)를 재튜닝해 효과를 지표로 산출한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **AAR 기간 오진단 건을 모호 코드 입력, 연쇄 고장 코드 오인, 복수 고유 코드 충돌 등으로 분류하고 대표 사례 정리하고 규격화하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `reports/mnt03_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(사후검토) 대상 기간의 연관 고장 오진단 사례를 원인별(모호 코드만 입력, 연쇄 고장 코드 오인, 잡음 코드 오인, 기타)로 분류하고, 고장 룰베이스의 코드 유형별 가중치와 사전확률 보정을 AAR 기간 자료로 재튜닝한 뒤 별도 검증 기간에서 효과를 확인한다. 비용 절감액, 재작업 시간 단축률, 헛교체 부품 감소율의 3대 효과 지표와 단계별 도입 로드맵을 산출한다.\"\"\"\n", + "import itertools\n", + "from collections import Counter\n", + "import numpy as np\n", + "import pandas as pd\n", + "import matplotlib.pyplot as plt\n", + "\n", + "# ---------------------------------------------------------------------------\n", + "# [교육용가상자료] 가상 차량 100대의 18개월 고장 진단 코드(DTC)·정비 이력 생성기 (실제 부대·장비·정비 실적 없음)\n", + "# 부품 16종(1000가동시간당 고장률 차등), DTC 28종(부품 고유 코드, 짝 부품 공유 코드, 계통 증상 코드)\n", + "# 고장 1건(정비 이력)마다 DTC가 1~5개 동시 발생하고, 연쇄 고장 코드와 일시 잡음 코드가 섞인다.\n", + "# ---------------------------------------------------------------------------\n", + "PARTS = [ # (ID, 이름, 계통, 1000가동시간당 고장률, 가상 단가지수, 수리시간 h)\n", + " (\"P01\", \"연료분사기\", \"engine\", 12.0, 3.0, 2.0), (\"P02\", \"터보차저\", \"engine\", 9.0, 8.0, 5.0),\n", + " (\"P03\", \"축전지\", \"electric\", 6.5, 4.0, 1.5), (\"P04\", \"발전기\", \"electric\", 4.0, 9.0, 3.0),\n", + " (\"P05\", \"변속기 제어모듈\", \"drive\", 3.0, 12.0, 6.0), (\"P06\", \"냉각수 펌프\", \"engine\", 2.2, 3.5, 3.0),\n", + " (\"P07\", \"유압 펌프\", \"hydraulic\", 1.6, 10.0, 4.0), (\"P08\", \"속도 센서\", \"drive\", 1.2, 2.0, 1.5),\n", + " (\"P09\", \"ECU 커넥터\", \"electric\", 0.9, 2.5, 1.5), (\"P10\", \"연료 필터\", \"engine\", 0.7, 0.8, 0.7),\n", + " (\"P11\", \"제동 밸브\", \"brake\", 0.5, 6.0, 3.0), (\"P12\", \"조향 액추에이터\", \"drive\", 0.4, 9.0, 5.0),\n", + " (\"P13\", \"공기 필터\", \"engine\", 0.3, 0.5, 0.5), (\"P14\", \"통신 인터페이스 모듈\", \"electric\", 0.25, 3.0, 1.5),\n", + " (\"P15\", \"윤활유 압력 센서\", \"engine\", 0.2, 1.0, 1.0), (\"P16\", \"팬 구동 모터\", \"cooling\", 0.15, 2.0, 2.0)]\n", + "PID = [p[0] for p in PARTS]\n", + "PNAME = {p[0]: p[1] for p in PARTS}; PSYS = {p[0]: p[2] for p in PARTS}; RATE = {p[0]: p[3] for p in PARTS}\n", + "COST = {p[0]: p[4] for p in PARTS}; REPH = {p[0]: p[5] for p in PARTS}\n", + "PAIRS = [(\"P01\", \"P02\"), (\"P03\", \"P04\"), (\"P05\", \"P08\"), (\"P06\", \"P10\"), (\"P09\", \"P14\"), (\"P13\", \"P15\")]\n", + "SHARED = {p: f\"S{k + 1:02d}\" for k, pr in enumerate(PAIRS) for p in pr} # 짝 부품이 함께 쓰는 모호한 코드\n", + "SPEC = {p: f\"C{p[1:]}1\" for p in PID} # 부품 고유 코드\n", + "SYS = [\"engine\", \"electric\", \"drive\", \"hydraulic\", \"brake\", \"cooling\"]\n", + "SYMP = {s: f\"G{i + 1:02d}\" for i, s in enumerate(SYS)} # 계통 증상 코드\n", + "CODES = sorted(set(SPEC.values()) | set(SHARED.values()) | set(SYMP.values()))\n", + "CASCADE = {\"P01\": [(\"P10\", 0.20)], \"P02\": [(\"P06\", 0.20)], \"P03\": [(\"P04\", 0.25)], \"P04\": [(\"P09\", 0.25)],\n", + " \"P05\": [(\"P08\", 0.30)], \"P06\": [(\"P16\", 0.30)], \"P09\": [(\"P14\", 0.20)], \"P12\": [(\"P07\", 0.20)]}\n", + "CRIT_SYS = {\"engine\", \"drive\", \"brake\"} # 기동·제동 불능 위험 계통(경보 대상)\n", + "MONTHS = 18\n", + "\n", + "\n", + "def make_fleet(n=100, seed=0):\n", + " rng = np.random.default_rng(seed)\n", + " f = pd.DataFrame({\"veh\": [f\"V{i:03d}\" for i in range(n)], \"age\": rng.integers(1, 26, n).astype(float),\n", + " \"terrain\": rng.uniform(0, 1, n), \"load\": rng.uniform(0, 1, n), \"pm_gap\": rng.uniform(20, 200, n),\n", + " \"humid\": rng.uniform(0, 1, n), \"base_h\": rng.uniform(55, 125, n)})\n", + " f[\"mult\"] = np.exp(0.045 * (f[\"age\"] - 13) + 0.9 * (f[\"terrain\"] - .5) + 0.8 * (f[\"load\"] - .5)\n", + " + 0.006 * (f[\"pm_gap\"] - 110) + 0.7 * (f[\"humid\"] - .5))\n", + " return f\n", + "\n", + "\n", + "def make_data(seed=1, months=MONTHS):\n", + " \"\"\"반환: fleet(차량 속성), ev(정비 이력 1건=1행: 발생 DTC 목록, 실제 고장 부품), log(DTC 발생 로그, 행=코드 1건)\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " fleet = make_fleet()\n", + " V = len(fleet)\n", + " temp = 12 + 14 * np.sin(2 * np.pi * (np.arange(months) - 3) / 12) # 월평균 기온(가상)\n", + " hours = fleet[\"base_h\"].values[:, None] * (1 + 0.25 * np.sin(2 * np.pi * np.arange(months) / 12 + 1)) * rng.lognormal(0, .12, (V, months))\n", + " fleet[\"hours\"] = hours.sum(axis=1) # 차량별 18개월 총 가동시간\n", + " fleet.attrs[\"month_h\"] = hours.sum(axis=0) # 월별 전체 가동시간(고장률 분모)\n", + " evs = []\n", + " for p in PID:\n", + " s = np.ones(months)\n", + " if p in (\"P03\", \"P04\"): s = np.exp(-0.06 * (temp - 12)) # 한랭기 전기계통 증가\n", + " if p in (\"P06\", \"P16\"): s = np.exp(0.05 * (temp - 12)) # 혹서기 냉각계통 증가\n", + " cnt = rng.poisson(RATE[p] * hours / 1000 * fleet[\"mult\"].values[:, None] * s[None, :])\n", + " for v, m in zip(*np.nonzero(cnt)):\n", + " for _ in range(cnt[v, m]):\n", + " evs.append((fleet[\"veh\"][v], int(m), m * 720 + rng.uniform(0, 720), p, float(temp[m] + rng.normal(0, 3))))\n", + " ev = pd.DataFrame(evs, columns=[\"veh\", \"month\", \"t\", \"part\", \"temp\"]).sort_values(\"t\").reset_index(drop=True)\n", + " ev[\"ev_id\"] = [f\"W{i:05d}\" for i in range(len(ev))]\n", + " rows, codes = [], []\n", + " for e in ev.itertuples():\n", + " c, r = [], rng.random(3)\n", + " if r[0] < 0.6: c.append(SPEC[e.part])\n", + " if e.part in SHARED and r[1] < 0.6: c.append(SHARED[e.part])\n", + " if r[2] < 0.5: c.append(SYMP[PSYS[e.part]])\n", + " if not c: c.append(SHARED.get(e.part, SPEC[e.part]))\n", + " for q, pr in CASCADE.get(e.part, []):\n", + " if rng.random() < pr: c.append(SPEC[q])\n", + " if rng.random() < 0.12: c.append(SPEC[PID[rng.integers(0, 16)]])\n", + " c = list(dict.fromkeys(c))\n", + " rng.shuffle(c) # 동시 발생 코드의 기록 순서는 임의\n", + " codes.append(c)\n", + " for k, cd in enumerate(c):\n", + " rows.append((e.ev_id, e.veh, e.t + k * 0.1, cd))\n", + " ev[\"codes\"] = codes\n", + " log = pd.DataFrame(rows, columns=[\"ev_id\", \"veh\", \"ts\", \"code\"])\n", + " return fleet, ev, log\n", + "\n", + "# ---------------------------------------------------------------------------\n", + "# 공통 분석 조각: 코드 멀티핫 행렬, 차량 맥락 피처, 학습/검증 분할(앞 12개월 학습, 뒤 6개월 검증)\n", + "# ---------------------------------------------------------------------------\n", + "CIDX = {c: i for i, c in enumerate(CODES)}\n", + "SPLIT_M = 12\n", + "\n", + "\n", + "def multi_hot(ev):\n", + " X = np.zeros((len(ev), len(CODES)))\n", + " for r, cs in enumerate(ev[\"codes\"]):\n", + " for c in cs:\n", + " X[r, CIDX[c]] = 1\n", + " return X\n", + "\n", + "\n", + "def context(ev, fleet):\n", + " f = fleet.set_index(\"veh\")\n", + " return np.column_stack([ev[\"veh\"].map(f[\"age\"]) / 25, ev[\"veh\"].map(f[\"pm_gap\"]) / 200, ev[\"veh\"].map(f[\"terrain\"]),\n", + " ev[\"veh\"].map(f[\"load\"]), (ev[\"temp\"] - 12) / 15])\n", + "\n", + "\n", + "def feats(ev, fleet):\n", + " X = multi_hot(ev)\n", + " n = X.sum(axis=1, keepdims=True) / 3\n", + " return np.hstack([X, n, context(ev, fleet)])\n", + "\n", + "\n", + "def monthly_counts(ev):\n", + " return ev.groupby([\"month\", \"part\"]).size().unstack(fill_value=0).reindex(columns=PID, fill_value=0)\n", + "\n", + "\n", + "def build_monthly(ev, fleet, top_n=8):\n", + " \"\"\"부품별 월간 고장 건수 예측용 표: 지연값(lag), 이동평균, 추세, 기대 건수(가동시간 x 설계 고장률), 기온 상호작용, 계절 성분\"\"\"\n", + " mc = ev.groupby([\"month\", \"part\"]).size().unstack(fill_value=0)\n", + " top = ev[\"part\"].value_counts().index[:top_n]\n", + " mh, tm = fleet.attrs[\"month_h\"], ev.groupby(\"month\")[\"temp\"].mean().values\n", + " rows = []\n", + " for p in top:\n", + " c = mc[p].values.astype(float)\n", + " for m in range(3, MONTHS):\n", + " L = np.log1p(c)\n", + " rows.append({\"part\": p, \"month\": m, \"y\": c[m], \"lag1\": L[m - 1], \"lag2\": L[m - 2], \"lag3\": L[m - 3], \"roll3\": L[m - 3:m].mean(),\n", + " \"trend\": L[m - 1] - L[m - 2], \"roll3_std\": L[m - 3:m].std(), \"log_expected\": np.log(mh[m] / 1000 * RATE[p]),\n", + " \"rate_lag1\": c[m - 1] / (mh[m - 1] / 1000) / RATE[p], \"temp\": tm[m] / 10, \"cold_elec\": max(0, 12 - tm[m]) / 10 * (p in (\"P03\", \"P04\")),\n", + " \"heat_cool\": max(0, tm[m] - 12) / 10 * (p in (\"P06\", \"P16\")), \"sin\": np.sin(2 * np.pi * m / 12), \"cos\": np.cos(2 * np.pi * m / 12)})\n", + " D = pd.DataFrame(rows)\n", + " return D, [c for c in D.columns if c not in (\"part\", \"month\", \"y\")], top\n", + "\n", + "fleet, ev, log = make_data()\n", + "train, aar, val = ev[ev[\"month\"] < 12], ev[(ev[\"month\"] >= 12) & (ev[\"month\"] < 15)].reset_index(drop=True), ev[ev[\"month\"] >= 15].reset_index(drop=True)\n", + "\n", + "# 1) 룰베이스: 점수(부품) = sum[코드 유형 가중치 x P(부품 | 코드)] + 사전확률 보정. 학습 기간 조건부확률표를 쓴다.\n", + "Xtr = multi_hot(train)\n", + "Y = pd.get_dummies(train[\"part\"]).reindex(columns=PID, fill_value=0).values.astype(float)\n", + "COND = (Xtr.T @ Y) / np.maximum(Xtr.sum(axis=0)[:, None], 1) # P(부품 | 코드)\n", + "PRIOR = np.log(Y.mean(axis=0) + 1e-9)\n", + "CTYPE = np.array([0 if c in SPEC.values() else 1 if c in SHARED.values() else 2 for c in CODES]) # 0 고유 / 1 공유 / 2 계통 증상\n", + "V1 = {\"w\": (1.0, 1.0, 1.0), \"beta\": 0.0} # 기존 룰베이스(모든 코드 동일 가중, 사전확률 미반영)\n", + "\n", + "\n", + "def predict(ev_, p):\n", + " W = np.array(p[\"w\"])[CTYPE][:, None] * COND\n", + " S = multi_hot(ev_) @ W + p[\"beta\"] * PRIOR[None, :]\n", + " base = np.array(PID)[S.argmax(axis=1)]\n", + " ov = p.get(\"override\", {}) # AAR에서 만든 예외 규칙(DTC 조합 -> 부품)이 우선\n", + " return np.array([ov.get(frozenset(c), b) for c, b in zip(ev_[\"codes\"], base)])\n", + "\n", + "\n", + "def cost(pred, true):\n", + " return sum(REPH[t] if p == t else COST[p] + REPH[p] + 3.0 + REPH[t] for p, t in zip(pred, true))\n", + "\n", + "\n", + "def err_stats(pred, true):\n", + " wrong = pred != true.values\n", + " return int(wrong.sum()), sum(REPH[p] + 3.0 + REPH[t] for p, t in zip(pred[wrong], true[wrong])), int(wrong.sum())\n", + "\n", + "\n", + "# 2) AAR: 오진단 원인 분류\n", + "pa = predict(aar, V1)\n", + "casc = {(p, q) for p, lst in CASCADE.items() for q, _ in lst}\n", + "cause = []\n", + "for cs, t, q in zip(aar[\"codes\"], aar[\"part\"], pa):\n", + " if t == q: continue\n", + " sp = [c for c in cs if c in SPEC.values()]\n", + " if not sp: cause.append(\"모호 코드(공유·증상)만 입력\")\n", + " elif len(sp) >= 2 and ((t, q) in casc or (q, t) in casc): cause.append(\"연쇄 고장 코드 오인\")\n", + " elif len(sp) >= 2: cause.append(\"복수 고유 코드 충돌(잡음 포함)\")\n", + " else: cause.append(\"기타(고유 코드 1개, 확률 편향)\")\n", + "C = pd.Series(cause).value_counts()\n", + "print(f\"[교육용가상자료] AAR 기간(12~14월) 정비 {len(aar)}건: 기존 룰베이스 오진단 {len(cause)}건 ({len(cause) / len(aar):.1%})\")\n", + "for k, v in C.items():\n", + " print(f\" {k:24s} {v:4d}건 ({v / len(cause):.0%})\")\n", + "ex = [(cs, PNAME[t], PNAME[q]) for cs, t, q in zip(aar[\"codes\"], aar[\"part\"], pa) if t != q and sum(c in SPEC.values() for c in cs) >= 2][:1]\n", + "print(\" 사례:\", f\"입력 DTC {ex[0][0]} -> 오진단 {ex[0][2]}, 실제 {ex[0][1]}\" if ex else \"-\")\n", + "\n", + "# 3) 재튜닝: (가) 코드 유형 가중치·사전확률 격자 탐색, (나) AAR 오진단 조합에서 예외 규칙 추출. 모두 AAR 기간 자료만 쓰고 검증 기간은 건드리지 않는다.\n", + "best = None\n", + "for w in itertools.product((0.5, 1.0, 1.5, 2.0), repeat=3):\n", + " for beta in (0.0, 0.2, 0.5, 1.0):\n", + " p = {\"w\": w, \"beta\": beta}\n", + " c = cost(predict(aar, p), aar[\"part\"])\n", + " if best is None or c < best[0]: best = (c, p)\n", + "V2 = dict(best[1])\n", + "pa2 = predict(aar, V2)\n", + "bad_sets = {frozenset(c) for c, t, q in zip(aar[\"codes\"], aar[\"part\"], pa2) if t != q}\n", + "grp = {}\n", + "for c, t in zip(aar[\"codes\"], aar[\"part\"]):\n", + " grp.setdefault(frozenset(c), Counter())[t] += 1\n", + "V2[\"override\"] = {k: v.most_common(1)[0][0] for k, v in grp.items()\n", + " if k in bad_sets and sum(v.values()) >= 3 and v.most_common(1)[0][1] / sum(v.values()) >= 0.6}\n", + "print(f\"\\n재튜닝 결과: 코드 유형 가중치(고유·공유·증상) {V1['w']} -> {V2['w']}, 사전확률 보정 {V1['beta']} -> {V2['beta']}, AAR 예외 규칙 {len(V2['override'])}개 추가\")\n", + "rows = []\n", + "for nm, d in ((\"AAR 기간(튜닝)\", aar), (\"검증 기간(15~17월)\", val)):\n", + " p1, p2 = predict(d, V1), predict(d, V2)\n", + " rows.append((nm, (p1 == d[\"part\"].values).mean(), (p2 == d[\"part\"].values).mean(), cost(p1, d[\"part\"]), cost(p2, d[\"part\"])))\n", + " print(f\" {nm:14s} 정확도 {rows[-1][1]:.1%} -> {rows[-1][2]:.1%}, 비용지수 {rows[-1][3]:.0f} -> {rows[-1][4]:.0f}\")\n", + "\n", + "# 4) ROI 3대 지표(검증 기간 3개월, 가정 단가 기준 상대값)\n", + "p1, p2 = predict(val, V1), predict(val, V2)\n", + "n1, h1, _ = err_stats(p1, val[\"part\"]); n2, h2, _ = err_stats(p2, val[\"part\"])\n", + "saved = cost(p1, val[\"part\"]) - cost(p2, val[\"part\"])\n", + "wasted1 = sum(COST[p] for p, t in zip(p1, val[\"part\"]) if p != t); wasted2 = sum(COST[p] for p, t in zip(p2, val[\"part\"]) if p != t)\n", + "print(\"\\n[ROI 3대 정량 효과 지표] (검증 3개월, 가정 단가)\")\n", + "print(f\" 1) 비용 절감: 비용지수 {saved:.0f} 절감 ({saved / cost(p1, val['part']):.1%}), 연환산 약 {saved * 4:.0f}\")\n", + "print(f\" 2) 시간 단축: 오진단 재작업 {h1:.0f}h -> {h2:.0f}h ({1 - h2 / h1:.1%} 단축)\")\n", + "print(f\" 3) 헛교체 부품 비용: {wasted1:.0f} -> {wasted2:.0f} ({1 - wasted2 / wasted1:.1%} 감소), 오진단 {n1}건 -> {n2}건\")\n", + "print(\"[도입 로드맵] 단기(1~3월): 가중치 갱신 + 정비관 확인 병행 / 중기(3~12월): AAR 분기 반복·신규 코드 반영 / 장기(1년~): 군수 정보체계 이력 연동\")\n", + "\n", + "fig, ax = plt.subplots(1, 2, figsize=(10, 3.5))\n", + "ax[0].barh([f\"cause {i + 1}\" for i in range(len(C))][::-1], C.values[::-1]); ax[0].set_title(\"Misdiagnosis causes (AAR period)\")\n", + "ax[1].bar([\"before\", \"after\"], [rows[1][1], rows[1][2]]); ax[1].set_ylim(0.5, 1); ax[1].set_title(\"Validation accuracy\")\n", + "plt.tight_layout(); plt.show()\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 09 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + "[교육용가상자료] AAR 기간(12~14월) 정비 2336건: 기존 룰베이스 오진단 522건 (22.3%)\n", + " 모호 코드(공유·증상)만 입력 243건 (47%)\n", + " 기타(고유 코드 1개, 확률 편향) 237건 (45%)\n", + " 연쇄 고장 코드 오인 26건 (5%)\n", + " 복수 고유 코드 충돌(잡음 포함) 16건 (3%)\n", + " 사례: 입력 DTC ['S04', 'G01', 'C081', 'C101'] -> 오진단 연료분사기, 실제 연료 필터\n", + "\n", + "재튜닝 결과: 코드 유형 가중치(고유·공유·증상) (1.0, 1.0, 1.0) -> (2.0, 1.0, 0.5), 사전확률 보정 0.0 -> 0.0, AAR 예외 규칙 31개 추가\n", + " AAR 기간(튜닝) 정확도 77.7% -> 82.9%, 비용지수 11938 -> 10805\n", + " 검증 기간(15~17월) 정확도 78.1% -> 80.1%, 비용지수 8449 -> 7832\n", + "\n", + "[ROI 3대 정량 효과 지표] (검증 3개월, 가정 단가)\n", + " 1) 비용 절감: 비용지수 616 절감 (7.3%), 연환산 약 2466\n", + " 2) 시간 단축: 오진단 재작업 3038h -> 2713h (10.7% 단축)\n", + " 3) 헛교체 부품 비용: 1778 -> 1441 (18.9% 감소), 오진단 341건 -> 309건\n", + "[도입 로드맵] 단기(1~3월): 가중치 갱신 + 정비관 확인 병행 / 중기(3~12월): AAR 분기 반복·신규 코드 반영 / 장기(1년~): 군수 정보체계 이력 연동\n", + "```\n", + "
\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 10. 무기체계 고장 코드(DTC) 기반 연관 고장 예측 및 정비 가이드 도구 완성\n", + "**소요시간**: 약 3.5시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "무기체계 고장 코드(DTC) 기반 연관 고장 예측 및 정비 가이드 도구를 완성한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **DTC 입력부터 연관 고장 예측, 정비 가이드, 승인 대상 선별, 재학습까지 한 파일로 통합하고 자체 점검과 브리핑 요약을 출력하는 도구를 완성한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **월별 정비 실적으로 진단 모델을 학습하고, 신규 실적으로 도전자 모델을 만들어 챔피언과 검증 오차를 비교한 뒤 비슷하거나 나을 때만 교체 수행하고 검증하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `docs/mnt03_operations_guide.md 및 pipeline/mnt03_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": [ + "\"\"\"DTC 로그 검증·정제, 진단 모델 자동 재학습(챔피언·도전자 비교 후 교체), 고장 코드 기반 연관 고장 진단과 신뢰도 산출, 신뢰도 낮은 건의 정비관 승인 대기 분리, 다빈도 고장품목 월간 소요 예측까지 정비 가이드 도구를 한 파일로 통합 실행하고, 자체 점검·운영 문서 완비 점검·정비 담당 장교 브리핑 요약을 출력한다.\"\"\"\n", + "import time\n", + "import numpy as np\n", + "import pandas as pd\n", + "import matplotlib.pyplot as plt\n", + "from sklearn.linear_model import LogisticRegression, Ridge\n", + "from sklearn.preprocessing import StandardScaler\n", + "from sklearn.pipeline import make_pipeline\n", + "\n", + "# ---------------------------------------------------------------------------\n", + "# [교육용가상자료] 가상 차량 100대의 18개월 고장 진단 코드(DTC)·정비 이력 생성기 (실제 부대·장비·정비 실적 없음)\n", + "# 부품 16종(1000가동시간당 고장률 차등), DTC 28종(부품 고유 코드, 짝 부품 공유 코드, 계통 증상 코드)\n", + "# 고장 1건(정비 이력)마다 DTC가 1~5개 동시 발생하고, 연쇄 고장 코드와 일시 잡음 코드가 섞인다.\n", + "# ---------------------------------------------------------------------------\n", + "PARTS = [ # (ID, 이름, 계통, 1000가동시간당 고장률, 가상 단가지수, 수리시간 h)\n", + " (\"P01\", \"연료분사기\", \"engine\", 12.0, 3.0, 2.0), (\"P02\", \"터보차저\", \"engine\", 9.0, 8.0, 5.0),\n", + " (\"P03\", \"축전지\", \"electric\", 6.5, 4.0, 1.5), (\"P04\", \"발전기\", \"electric\", 4.0, 9.0, 3.0),\n", + " (\"P05\", \"변속기 제어모듈\", \"drive\", 3.0, 12.0, 6.0), (\"P06\", \"냉각수 펌프\", \"engine\", 2.2, 3.5, 3.0),\n", + " (\"P07\", \"유압 펌프\", \"hydraulic\", 1.6, 10.0, 4.0), (\"P08\", \"속도 센서\", \"drive\", 1.2, 2.0, 1.5),\n", + " (\"P09\", \"ECU 커넥터\", \"electric\", 0.9, 2.5, 1.5), (\"P10\", \"연료 필터\", \"engine\", 0.7, 0.8, 0.7),\n", + " (\"P11\", \"제동 밸브\", \"brake\", 0.5, 6.0, 3.0), (\"P12\", \"조향 액추에이터\", \"drive\", 0.4, 9.0, 5.0),\n", + " (\"P13\", \"공기 필터\", \"engine\", 0.3, 0.5, 0.5), (\"P14\", \"통신 인터페이스 모듈\", \"electric\", 0.25, 3.0, 1.5),\n", + " (\"P15\", \"윤활유 압력 센서\", \"engine\", 0.2, 1.0, 1.0), (\"P16\", \"팬 구동 모터\", \"cooling\", 0.15, 2.0, 2.0)]\n", + "PID = [p[0] for p in PARTS]\n", + "PNAME = {p[0]: p[1] for p in PARTS}; PSYS = {p[0]: p[2] for p in PARTS}; RATE = {p[0]: p[3] for p in PARTS}\n", + "COST = {p[0]: p[4] for p in PARTS}; REPH = {p[0]: p[5] for p in PARTS}\n", + "PAIRS = [(\"P01\", \"P02\"), (\"P03\", \"P04\"), (\"P05\", \"P08\"), (\"P06\", \"P10\"), (\"P09\", \"P14\"), (\"P13\", \"P15\")]\n", + "SHARED = {p: f\"S{k + 1:02d}\" for k, pr in enumerate(PAIRS) for p in pr} # 짝 부품이 함께 쓰는 모호한 코드\n", + "SPEC = {p: f\"C{p[1:]}1\" for p in PID} # 부품 고유 코드\n", + "SYS = [\"engine\", \"electric\", \"drive\", \"hydraulic\", \"brake\", \"cooling\"]\n", + "SYMP = {s: f\"G{i + 1:02d}\" for i, s in enumerate(SYS)} # 계통 증상 코드\n", + "CODES = sorted(set(SPEC.values()) | set(SHARED.values()) | set(SYMP.values()))\n", + "CASCADE = {\"P01\": [(\"P10\", 0.20)], \"P02\": [(\"P06\", 0.20)], \"P03\": [(\"P04\", 0.25)], \"P04\": [(\"P09\", 0.25)],\n", + " \"P05\": [(\"P08\", 0.30)], \"P06\": [(\"P16\", 0.30)], \"P09\": [(\"P14\", 0.20)], \"P12\": [(\"P07\", 0.20)]}\n", + "CRIT_SYS = {\"engine\", \"drive\", \"brake\"} # 기동·제동 불능 위험 계통(경보 대상)\n", + "MONTHS = 18\n", + "\n", + "\n", + "def make_fleet(n=100, seed=0):\n", + " rng = np.random.default_rng(seed)\n", + " f = pd.DataFrame({\"veh\": [f\"V{i:03d}\" for i in range(n)], \"age\": rng.integers(1, 26, n).astype(float),\n", + " \"terrain\": rng.uniform(0, 1, n), \"load\": rng.uniform(0, 1, n), \"pm_gap\": rng.uniform(20, 200, n),\n", + " \"humid\": rng.uniform(0, 1, n), \"base_h\": rng.uniform(55, 125, n)})\n", + " f[\"mult\"] = np.exp(0.045 * (f[\"age\"] - 13) + 0.9 * (f[\"terrain\"] - .5) + 0.8 * (f[\"load\"] - .5)\n", + " + 0.006 * (f[\"pm_gap\"] - 110) + 0.7 * (f[\"humid\"] - .5))\n", + " return f\n", + "\n", + "\n", + "def make_data(seed=1, months=MONTHS):\n", + " \"\"\"반환: fleet(차량 속성), ev(정비 이력 1건=1행: 발생 DTC 목록, 실제 고장 부품), log(DTC 발생 로그, 행=코드 1건)\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " fleet = make_fleet()\n", + " V = len(fleet)\n", + " temp = 12 + 14 * np.sin(2 * np.pi * (np.arange(months) - 3) / 12) # 월평균 기온(가상)\n", + " hours = fleet[\"base_h\"].values[:, None] * (1 + 0.25 * np.sin(2 * np.pi * np.arange(months) / 12 + 1)) * rng.lognormal(0, .12, (V, months))\n", + " fleet[\"hours\"] = hours.sum(axis=1) # 차량별 18개월 총 가동시간\n", + " fleet.attrs[\"month_h\"] = hours.sum(axis=0) # 월별 전체 가동시간(고장률 분모)\n", + " evs = []\n", + " for p in PID:\n", + " s = np.ones(months)\n", + " if p in (\"P03\", \"P04\"): s = np.exp(-0.06 * (temp - 12)) # 한랭기 전기계통 증가\n", + " if p in (\"P06\", \"P16\"): s = np.exp(0.05 * (temp - 12)) # 혹서기 냉각계통 증가\n", + " cnt = rng.poisson(RATE[p] * hours / 1000 * fleet[\"mult\"].values[:, None] * s[None, :])\n", + " for v, m in zip(*np.nonzero(cnt)):\n", + " for _ in range(cnt[v, m]):\n", + " evs.append((fleet[\"veh\"][v], int(m), m * 720 + rng.uniform(0, 720), p, float(temp[m] + rng.normal(0, 3))))\n", + " ev = pd.DataFrame(evs, columns=[\"veh\", \"month\", \"t\", \"part\", \"temp\"]).sort_values(\"t\").reset_index(drop=True)\n", + " ev[\"ev_id\"] = [f\"W{i:05d}\" for i in range(len(ev))]\n", + " rows, codes = [], []\n", + " for e in ev.itertuples():\n", + " c, r = [], rng.random(3)\n", + " if r[0] < 0.6: c.append(SPEC[e.part])\n", + " if e.part in SHARED and r[1] < 0.6: c.append(SHARED[e.part])\n", + " if r[2] < 0.5: c.append(SYMP[PSYS[e.part]])\n", + " if not c: c.append(SHARED.get(e.part, SPEC[e.part]))\n", + " for q, pr in CASCADE.get(e.part, []):\n", + " if rng.random() < pr: c.append(SPEC[q])\n", + " if rng.random() < 0.12: c.append(SPEC[PID[rng.integers(0, 16)]])\n", + " c = list(dict.fromkeys(c))\n", + " rng.shuffle(c) # 동시 발생 코드의 기록 순서는 임의\n", + " codes.append(c)\n", + " for k, cd in enumerate(c):\n", + " rows.append((e.ev_id, e.veh, e.t + k * 0.1, cd))\n", + " ev[\"codes\"] = codes\n", + " log = pd.DataFrame(rows, columns=[\"ev_id\", \"veh\", \"ts\", \"code\"])\n", + " return fleet, ev, log\n", + "\n", + "# ---------------------------------------------------------------------------\n", + "# 공통 분석 조각: 코드 멀티핫 행렬, 차량 맥락 피처, 학습/검증 분할(앞 12개월 학습, 뒤 6개월 검증)\n", + "# ---------------------------------------------------------------------------\n", + "CIDX = {c: i for i, c in enumerate(CODES)}\n", + "SPLIT_M = 12\n", + "\n", + "\n", + "def multi_hot(ev):\n", + " X = np.zeros((len(ev), len(CODES)))\n", + " for r, cs in enumerate(ev[\"codes\"]):\n", + " for c in cs:\n", + " X[r, CIDX[c]] = 1\n", + " return X\n", + "\n", + "\n", + "def context(ev, fleet):\n", + " f = fleet.set_index(\"veh\")\n", + " return np.column_stack([ev[\"veh\"].map(f[\"age\"]) / 25, ev[\"veh\"].map(f[\"pm_gap\"]) / 200, ev[\"veh\"].map(f[\"terrain\"]),\n", + " ev[\"veh\"].map(f[\"load\"]), (ev[\"temp\"] - 12) / 15])\n", + "\n", + "\n", + "def feats(ev, fleet):\n", + " X = multi_hot(ev)\n", + " n = X.sum(axis=1, keepdims=True) / 3\n", + " return np.hstack([X, n, context(ev, fleet)])\n", + "\n", + "\n", + "def monthly_counts(ev):\n", + " return ev.groupby([\"month\", \"part\"]).size().unstack(fill_value=0).reindex(columns=PID, fill_value=0)\n", + "\n", + "\n", + "def build_monthly(ev, fleet, top_n=8):\n", + " \"\"\"부품별 월간 고장 건수 예측용 표: 지연값(lag), 이동평균, 추세, 기대 건수(가동시간 x 설계 고장률), 기온 상호작용, 계절 성분\"\"\"\n", + " mc = ev.groupby([\"month\", \"part\"]).size().unstack(fill_value=0)\n", + " top = ev[\"part\"].value_counts().index[:top_n]\n", + " mh, tm = fleet.attrs[\"month_h\"], ev.groupby(\"month\")[\"temp\"].mean().values\n", + " rows = []\n", + " for p in top:\n", + " c = mc[p].values.astype(float)\n", + " for m in range(3, MONTHS):\n", + " L = np.log1p(c)\n", + " rows.append({\"part\": p, \"month\": m, \"y\": c[m], \"lag1\": L[m - 1], \"lag2\": L[m - 2], \"lag3\": L[m - 3], \"roll3\": L[m - 3:m].mean(),\n", + " \"trend\": L[m - 1] - L[m - 2], \"roll3_std\": L[m - 3:m].std(), \"log_expected\": np.log(mh[m] / 1000 * RATE[p]),\n", + " \"rate_lag1\": c[m - 1] / (mh[m - 1] / 1000) / RATE[p], \"temp\": tm[m] / 10, \"cold_elec\": max(0, 12 - tm[m]) / 10 * (p in (\"P03\", \"P04\")),\n", + " \"heat_cool\": max(0, tm[m] - 12) / 10 * (p in (\"P06\", \"P16\")), \"sin\": np.sin(2 * np.pi * m / 12), \"cos\": np.cos(2 * np.pi * m / 12)})\n", + " D = pd.DataFrame(rows)\n", + " return D, [c for c in D.columns if c not in (\"part\", \"month\", \"y\")], top\n", + "\n", + "# ---------------------------------------------------------------------------\n", + "# [교육용가상자료] 부품별 정비 조치 가이드라인(요약 3단계). 실제 정비 교범이 아니라 교육용으로 만든 예시 문구다.\n", + "# ---------------------------------------------------------------------------\n", + "GUIDE = {\n", + " \"P01\": \"연료 압력 측정 -> 분사기 누설·막힘 확인 -> 불량 시 분사기 교체\", \"P02\": \"흡기 압력·이물 점검 -> 축 유격 확인 -> 불량 시 터보차저 교체\",\n", + " \"P03\": \"단자 전압·부식 점검 -> 충전 계통 확인 -> 불량 시 축전지 교체\", \"P04\": \"발전 전압 측정 -> 벨트·배선 점검 -> 불량 시 발전기 교체\",\n", + " \"P05\": \"제어모듈 소프트웨어·커넥터 점검 -> 변속 이력 확인 -> 불량 시 모듈 교체\", \"P06\": \"냉각수량·누수 점검 -> 펌프 작동음 확인 -> 불량 시 펌프 교체\",\n", + " \"P07\": \"유압 오일량·압력 점검 -> 펌프 누설 확인 -> 불량 시 펌프 교체\", \"P08\": \"센서 배선·간극 점검 -> 신호 파형 확인 -> 불량 시 센서 교체\",\n", + " \"P09\": \"커넥터 접촉·부식 점검 -> 핀 상태 확인 -> 불량 시 커넥터 교체\", \"P10\": \"필터 오염도 점검 -> 연료 흐름 확인 -> 불량 시 필터 교체\",\n", + " \"P11\": \"제동 압력 점검 -> 밸브 작동 확인 -> 불량 시 밸브 교체(안전 확인 필수)\", \"P12\": \"조향 반응 점검 -> 액추에이터 작동 확인 -> 불량 시 교체(안전 확인 필수)\",\n", + " \"P13\": \"필터 차압 점검 -> 오염 확인 -> 불량 시 필터 교체\", \"P14\": \"통신 링크 상태 점검 -> 모듈 전원 확인 -> 불량 시 모듈 교체\",\n", + " \"P15\": \"압력 센서 신호 점검 -> 윤활유량 확인 -> 불량 시 센서 교체\", \"P16\": \"팬 모터 전류 점검 -> 회전 확인 -> 불량 시 모터 교체\"}\n", + "\n", + "CONFIG = {\"conf_review\": 0.8, \"retrain_tolerance\": 0.01, \"mape_target\": 0.25}\n", + "OPS_GUIDE = { # docs/mnt03_operations_guide.md 요약\n", + " \"1. 목적과 범위\": \"DTC 기반 고장 부품 진단 보조와 다빈도 고장품목 식별. 최종 정비 판단은 숙련 정비관.\",\n", + " \"2. 입력 데이터\": \"DTC 로그(차량 ID, 시각, 코드), 정비 이력(정비 건 ID, 고장 부품), 차량 속성. 실제 부대 자료 외부 AI 입력 금지.\",\n", + " \"3. 일일 운용 절차\": \"로그 검증 -> 정비 건 묶음 -> 진단·신뢰도 산출 -> 낮은 신뢰도 건 승인 대기 -> 조치 후 정비 이력 입력.\",\n", + " \"4. 승인(HITL) 기준\": \"신뢰도 0.8 미만·다계통 동시 발생은 숙련 정비관 결정 전 확정 금지, 무응답 시 재확인 표시, 결정 사유 기록.\",\n", + " \"5. 재학습 절차\": \"월 1회 신규 정비 이력으로 도전자 학습, 검증 정확도가 나빠지지 않을 때만 교체.\",\n", + " \"6. 장애 대응\": \"모델 오류 시 단일 코드 매핑표로 대체, 손상 로그는 수동 확인 목록으로 분리.\",\n", + " \"7. 연동 인터페이스\": \"국방 군수 정보체계와 DTC·정비 이력 표준 필드(장비코드·분류군·시각)를 CSV/API로 교환하는 안을 차기 과제로 둔다.\",\n", + "}\n", + "\n", + "fleet, ev, log = make_data()\n", + "OLD, NEW, OPS = ev[ev[\"month\"] < 12], ev[(ev[\"month\"] >= 12) & (ev[\"month\"] < 15)], ev[ev[\"month\"] >= 15].reset_index(drop=True)\n", + "t_start = time.perf_counter()\n", + "\n", + "\n", + "def fit(d):\n", + " return LogisticRegression(C=3, max_iter=500).fit(feats(d, fleet), d[\"part\"])\n", + "\n", + "\n", + "def retrain(champion, old, new):\n", + " \"\"\"도전자를 (기존 + 신규 앞 2/3 기간)으로 학습하고, 신규 뒤 1/3 기간에서 챔피언과 정확도를 비교해 비슷하거나 낫다면 교체.\"\"\"\n", + " cut = new[\"month\"].min() + 2\n", + " hold = new[new[\"month\"] >= cut]\n", + " challenger = fit(pd.concat([old, new[new[\"month\"] < cut]], ignore_index=True))\n", + " a_old = (champion.predict(feats(hold, fleet)) == hold[\"part\"].values).mean()\n", + " a_new = (challenger.predict(feats(hold, fleet)) == hold[\"part\"].values).mean()\n", + " swap = a_new >= a_old - CONFIG[\"retrain_tolerance\"]\n", + " return (challenger if swap else champion), {\"챔피언\": round(float(a_old), 3), \"도전자\": round(float(a_new), 3), \"교체\": bool(swap)}\n", + "\n", + "\n", + "def validate(df):\n", + " ok = df[\"codes\"].map(lambda cs: len(cs) > 0 and all(c in CODES for c in cs)) & df[\"veh\"].isin(fleet[\"veh\"])\n", + " return df[ok].copy(), df[~ok].copy()\n", + "\n", + "\n", + "def predict_safe(model, d):\n", + " \"\"\"모델 오류 시 단일 코드 -> 최다 부품 매핑표로 대체한다.\"\"\"\n", + " try:\n", + " P = model.predict_proba(feats(d, fleet))\n", + " return model.classes_[P.argmax(axis=1)], P.max(axis=1), \"모델\"\n", + " except Exception:\n", + " mp = OLD.assign(first=OLD[\"codes\"].str[0]).groupby(\"first\")[\"part\"].agg(lambda s: s.value_counts().index[0])\n", + " return d[\"codes\"].str[0].map(mp).fillna(\"P01\").values, np.full(len(d), 0.5), \"매핑표 대체\"\n", + "\n", + "\n", + "# ---------------- 1. 통합 실행 ----------------\n", + "champion = fit(OLD)\n", + "model, rt = retrain(champion, OLD, NEW)\n", + "print(f\"[교육용가상자료] 재학습: {rt} -> {'도전자로 교체' if rt['교체'] else '챔피언 유지'}\")\n", + "ops_all = OPS.copy()\n", + "ops_all.at[3, \"codes\"] = [\"ZZ9\"]; ops_all.at[40, \"codes\"] = [] # 손상 로그 2건 주입\n", + "ops, manual = validate(ops_all)\n", + "ops = ops.reset_index(drop=True)\n", + "pred, conf, src = predict_safe(model, ops)\n", + "queue = ops[conf < CONFIG[\"conf_review\"]]\n", + "alerts = [(i, p) for i, p in enumerate(pred) if PSYS[p] in CRIT_SYS and conf[i] >= CONFIG[\"conf_review\"]]\n", + "acc = (pred == ops[\"part\"].values).mean()\n", + "hi = conf >= CONFIG[\"conf_review\"]\n", + "print(f\"운용 정비 건 {len(ops_all)}건: 진단 {len(ops)}건, 수동 확인 {len(manual)}건, 출처 '{src}', 정확도 {acc:.1%} (신뢰도 0.8 이상 {hi.sum()}건은 {(pred[hi] == ops['part'].values[hi]).mean():.1%})\")\n", + "\n", + "# 다빈도 품목 월간 소요 예측(마지막 달): 과거 월로 학습한 릿지로 다음 달 건수 예측\n", + "D, FE, top = build_monthly(ev, fleet)\n", + "a, b = D[D[\"month\"] < MONTHS - 1], D[D[\"month\"] == MONTHS - 1]\n", + "fc = np.exp(make_pipeline(StandardScaler(), Ridge(alpha=3.0)).fit(a[FE], np.log(a[\"y\"])).predict(b[FE]))\n", + "fmape = float(np.mean(np.abs(b[\"y\"].values - fc) / b[\"y\"].values))\n", + "print(\"다빈도 품목 다음 달 소요 예측: \" + \", \".join(f\"{PNAME[p]} {f:.0f}건(실제 {y:.0f})\" for p, f, y in zip(b[\"part\"].values[:3], fc[:3], b[\"y\"].values[:3])) + f\" / 8개 품목 MAPE {fmape:.1%}\")\n", + "sec = time.perf_counter() - t_start\n", + "\n", + "# ---------------- 2. 자체 점검 ----------------\n", + "_, _, bad_src = predict_safe(None, ops.head(3))\n", + "checks = {\n", + " \"로그 유실 0건\": len(ops) + len(manual) == len(ops_all),\n", + " \"손상 로그 수동 확인 분리\": len(manual) == 2,\n", + " \"재학습 파이프라인 실행\": \"교체\" in rt,\n", + " \"신뢰도 0~1 범위\": bool(((conf >= 0) & (conf <= 1)).all()),\n", + " \"모델 장애 시 매핑표 대체\": bad_src == \"매핑표 대체\",\n", + " \"모든 진단에 조치 가이드 연결\": all(p in GUIDE for p in pred),\n", + " \"저신뢰 건 자동 확정 금지(대기)\": len(queue) == int((conf < CONFIG[\"conf_review\"]).sum()),\n", + " f\"소요 예측 MAPE {CONFIG['mape_target']:.0%} 이하\": fmape <= CONFIG[\"mape_target\"],\n", + "}\n", + "print(f\"\\n자체 점검 {sum(checks.values())}/{len(checks)} 통과, 자동 재학습 파이프라인 {'정상 실행' if checks['재학습 파이프라인 실행'] else '실패'}, 전체 실행 {sec:.1f}초\")\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", + "fig, ax = plt.subplots(1, 2, figsize=(11, 4))\n", + "cnt = pd.Series(pred).value_counts().head(8)\n", + "ax[0].bar(cnt.index, cnt.values); ax[0].set_title(\"Diagnosed parts (operating period)\")\n", + "ax[1].hist(conf, bins=20); ax[1].axvline(CONFIG[\"conf_review\"], ls=\"--\", color=\"k\"); ax[1].set_title(\"Confidence (line = review threshold)\")\n", + "plt.tight_layout(); plt.show()\n", + "\n", + "fr = (ev[\"part\"].value_counts() / fleet[\"hours\"].sum() * 1000)\n", + "cum = ev[\"part\"].value_counts(normalize=True).cumsum()\n", + "a_cnt = int((cum <= 0.8).sum()) + 1\n", + "print(\"\\n[정비 담당 장교 브리핑 요약] [교육용가상자료]\")\n", + "print(f\"1. 18개월 정비 {len(ev)}건: 고장률 상위 \" + \", \".join(f\"{PNAME[p]} {v:.1f}건/1000h\" for p, v in fr.sort_values(ascending=False).head(3).items()))\n", + "print(f\"2. 상위 {a_cnt}개 품목이 전체 고장의 80% 이상: 예비 수리부속 우선 확보 대상\")\n", + "print(f\"3. 운용 기간 진단 {len(ops)}건 중 신뢰도 0.8 이상 {int(hi.sum())}건(정확도 {(pred[hi] == ops['part'].values[hi]).mean():.1%}), 정비관 승인 대기 {len(queue)}건(자동 확정 0건)\")\n", + "print(f\"4. 기동·제동 계통 경보 {len(alerts)}건, 손상 로그 {len(manual)}건 수동 확인\")\n", + "print(f\"5. 진단 모델: {'신규 정비 이력으로 교체' if rt['교체'] else '기존 유지'}, 소요 예측 MAPE {fmape:.1%}\")\n", + "print(\"6. 건의: 다빈도 품목 예방정비 주기 재검토, 국방 군수 정보체계와 DTC·정비 이력 표준 필드 연동 인터페이스 정의\")\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 10 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + "[교육용가상자료] 재학습: {'챔피언': 0.845, '도전자': 0.847, '교체': True} -> 도전자로 교체\n", + "운용 정비 건 1554건: 진단 1552건, 수동 확인 2건, 출처 '모델', 정확도 83.2% (신뢰도 0.8 이상 931건은 99.2%)\n", + "다빈도 품목 다음 달 소요 예측: 연료분사기 133건(실제 147), 터보차저 97건(실제 109), 축전지 44건(실제 35) / 8개 품목 MAPE 22.7%\n", + "\n", + "자체 점검 8/8 통과, 자동 재학습 파이프라인 정상 실행, 전체 실행 18.1초\n", + "운영 문서 7개 장 작성률 100% (목표 100%: 달성) - 목적과 범위, 입력 데이터, 일일 운용 절차, 승인(HITL) 기준, 재학습 절차, 장애 대응, 연동 인터페이스\n", + "\n", + "[정비 담당 장교 브리핑 요약] [교육용가상자료]\n", + "1. 18개월 정비 10681건: 고장률 상위 연료분사기 16.3건/1000h, 터보차저 11.6건/1000h, 축전지 11.6건/1000h\n", + "2. 상위 5개 품목이 전체 고장의 80% 이상: 예비 수리부속 우선 확보 대상\n", + "3. 운용 기간 진단 1552건 중 신뢰도 0.8 이상 931건(정확도 99.2%), 정비관 승인 대기 621건(자동 확정 0건)\n", + "4. 기동·제동 계통 경보 710건, 손상 로그 2건 수동 확인\n", + "5. 진단 모델: 신규 정비 이력으로 교체, 소요 예측 MAPE 22.7%\n", + "6. 건의: 다빈도 품목 예방정비 주기 재검토, 국방 군수 정보체계와 DTC·정비 이력 표준 필드 연동 인터페이스 정의\n", + "```\n", + "
\n" + ] + } + ], + "metadata": { + "colab": { + "provenance": [] + }, + "kernelspec": { + "display_name": "Python 3", + "name": "python3" + }, + "language_info": { + "name": "python" + } + }, + "nbformat": 4, + "nbformat_minor": 0 +} \ No newline at end of file diff --git a/uploads/MNT03/MNT03_student.ipynb b/uploads/MNT03/MNT03_student.ipynb new file mode 100644 index 0000000..ba04076 --- /dev/null +++ b/uploads/MNT03/MNT03_student.ipynb @@ -0,0 +1,2976 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# 🎯 [MNT03] 고장률 분석을 통한 다빈도 고장품목 식별\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/MNT03/MNT03_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", + "야전 정비 현장에서는 어떤 부품이 자주 고장 나는지 정확히 알아야 수리부속을 먼저 확보하고 예방정비 주기를 조정할 수 있지만, 차량 전자제어장치(ECU)가 남기는 고장 진단 코드(DTC)는 한 번에 여러 개가 겹쳐 기록되고 부품 간 공유 코드도 있어 첫 번째 코드만 보면 원인 부품을 자주 놓친다. 본 프로젝트는 고장·정비 이력을 연결하고 가동시간 대비 고장률을 계산해 다빈도 고장품목을 식별하는 데이터분석 기본기를 익히는 것을 목표로, 교육용 가상 차량 100대의 18개월 DTC·정비 이력을 코드로 만들어 분석한다. 데이터 스키마 정의, 로그 정제, 단일 코드 Baseline과 파레토·상관 분석, 연관 규칙 기반 근본 원인 예측, 정비 조치 추천과 신뢰도 점수, 설명가능성과 정비병용 진단 화면, 숙련 정비관 2차 진단(HITL) 시뮬레이터, 1,000건 동시 주입 부하 시험, 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. 전차/장갑차 전자제어장치(ECU) 고장 진단 코드(DTC) 및 정비 매뉴얼 데이터셋 정의\n", + "**소요시간**: 약 1.5시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "전차/장갑차 전자제어장치(ECU) 고장 진단 코드(DTC) 데이터셋을 정의한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **전차/장갑차 계열 가상 차량의 ECU 고장 진단 코드(DTC) 발생 로그, 정비 이력, 정비 매뉴얼 데이터셋의 스키마와 데이터 딕셔너리, 고장률 분석 업무 프로세스 맵을 정의한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **DTC 발생부터 정비 이력 연결, 고장률 집계, 다빈도 품목 식별, 사후검토까지 업무 흐름을 단계별로 정리하고, DTC 로그·정비 건·차량·예측 대상 묶음으로 피처 20개 이상 정의하고 검증하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `docs/mnt03_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": [ + "\"\"\"전차/장갑차 계열 가상 차량의 전자제어장치(ECU) 고장 진단 코드(DTC) 발생 로그, 정비 이력, 정비 매뉴얼 데이터셋의 스키마·데이터 딕셔너리(피처 20개 이상)와 고장률 분석 업무 프로세스 맵을 정의하고, 가상 데이터로 검증한다.\"\"\"\n", + "import json\n", + "import re\n", + "import numpy as np\n", + "import pandas as pd\n", + "\n", + "# ---------------------------------------------------------------------------\n", + "# [교육용가상자료] 가상 차량 100대의 18개월 고장 진단 코드(DTC)·정비 이력 생성기 (실제 부대·장비·정비 실적 없음)\n", + "# 부품 16종(1000가동시간당 고장률 차등), DTC 28종(부품 고유 코드, 짝 부품 공유 코드, 계통 증상 코드)\n", + "# 고장 1건(정비 이력)마다 DTC가 1~5개 동시 발생하고, 연쇄 고장 코드와 일시 잡음 코드가 섞인다.\n", + "# ---------------------------------------------------------------------------\n", + "PARTS = [ # (ID, 이름, 계통, 1000가동시간당 고장률, 가상 단가지수, 수리시간 h)\n", + " (\"P01\", \"연료분사기\", \"engine\", 12.0, 3.0, 2.0), (\"P02\", \"터보차저\", \"engine\", 9.0, 8.0, 5.0),\n", + " (\"P03\", \"축전지\", \"electric\", 6.5, 4.0, 1.5), (\"P04\", \"발전기\", \"electric\", 4.0, 9.0, 3.0),\n", + " (\"P05\", \"변속기 제어모듈\", \"drive\", 3.0, 12.0, 6.0), (\"P06\", \"냉각수 펌프\", \"engine\", 2.2, 3.5, 3.0),\n", + " (\"P07\", \"유압 펌프\", \"hydraulic\", 1.6, 10.0, 4.0), (\"P08\", \"속도 센서\", \"drive\", 1.2, 2.0, 1.5),\n", + " (\"P09\", \"ECU 커넥터\", \"electric\", 0.9, 2.5, 1.5), (\"P10\", \"연료 필터\", \"engine\", 0.7, 0.8, 0.7),\n", + " (\"P11\", \"제동 밸브\", \"brake\", 0.5, 6.0, 3.0), (\"P12\", \"조향 액추에이터\", \"drive\", 0.4, 9.0, 5.0),\n", + " (\"P13\", \"공기 필터\", \"engine\", 0.3, 0.5, 0.5), (\"P14\", \"통신 인터페이스 모듈\", \"electric\", 0.25, 3.0, 1.5),\n", + " (\"P15\", \"윤활유 압력 센서\", \"engine\", 0.2, 1.0, 1.0), (\"P16\", \"팬 구동 모터\", \"cooling\", 0.15, 2.0, 2.0)]\n", + "PID = [p[0] for p in PARTS]\n", + "PNAME = {p[0]: p[1] for p in PARTS}; PSYS = {p[0]: p[2] for p in PARTS}; RATE = {p[0]: p[3] for p in PARTS}\n", + "COST = {p[0]: p[4] for p in PARTS}; REPH = {p[0]: p[5] for p in PARTS}\n", + "PAIRS = [(\"P01\", \"P02\"), (\"P03\", \"P04\"), (\"P05\", \"P08\"), (\"P06\", \"P10\"), (\"P09\", \"P14\"), (\"P13\", \"P15\")]\n", + "SHARED = {p: f\"S{k + 1:02d}\" for k, pr in enumerate(PAIRS) for p in pr} # 짝 부품이 함께 쓰는 모호한 코드\n", + "SPEC = {p: f\"C{p[1:]}1\" for p in PID} # 부품 고유 코드\n", + "SYS = [\"engine\", \"electric\", \"drive\", \"hydraulic\", \"brake\", \"cooling\"]\n", + "SYMP = {s: f\"G{i + 1:02d}\" for i, s in enumerate(SYS)} # 계통 증상 코드\n", + "CODES = sorted(set(SPEC.values()) | set(SHARED.values()) | set(SYMP.values()))\n", + "CASCADE = {\"P01\": [(\"P10\", 0.20)], \"P02\": [(\"P06\", 0.20)], \"P03\": [(\"P04\", 0.25)], \"P04\": [(\"P09\", 0.25)],\n", + " \"P05\": [(\"P08\", 0.30)], \"P06\": [(\"P16\", 0.30)], \"P09\": [(\"P14\", 0.20)], \"P12\": [(\"P07\", 0.20)]}\n", + "CRIT_SYS = {\"engine\", \"drive\", \"brake\"} # 기동·제동 불능 위험 계통(경보 대상)\n", + "MONTHS = 18\n", + "\n", + "\n", + "def make_fleet(n=100, seed=0):\n", + " rng = np.random.default_rng(seed)\n", + " f = pd.DataFrame({\"veh\": [f\"V{i:03d}\" for i in range(n)], \"age\": rng.integers(1, 26, n).astype(float),\n", + " \"terrain\": rng.uniform(0, 1, n), \"load\": rng.uniform(0, 1, n), \"pm_gap\": rng.uniform(20, 200, n),\n", + " \"humid\": rng.uniform(0, 1, n), \"base_h\": rng.uniform(55, 125, n)})\n", + " f[\"mult\"] = np.exp(0.045 * (f[\"age\"] - 13) + 0.9 * (f[\"terrain\"] - .5) + 0.8 * (f[\"load\"] - .5)\n", + " + 0.006 * (f[\"pm_gap\"] - 110) + 0.7 * (f[\"humid\"] - .5))\n", + " return f\n", + "\n", + "\n", + "def make_data(seed=1, months=MONTHS):\n", + " \"\"\"반환: fleet(차량 속성), ev(정비 이력 1건=1행: 발생 DTC 목록, 실제 고장 부품), log(DTC 발생 로그, 행=코드 1건)\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " fleet = make_fleet()\n", + " V = len(fleet)\n", + " temp = 12 + 14 * np.sin(2 * np.pi * (np.arange(months) - 3) / 12) # 월평균 기온(가상)\n", + " hours = fleet[\"base_h\"].values[:, None] * (1 + 0.25 * np.sin(2 * np.pi * np.arange(months) / 12 + 1)) * rng.lognormal(0, .12, (V, months))\n", + " fleet[\"hours\"] = hours.sum(axis=1) # 차량별 18개월 총 가동시간\n", + " fleet.attrs[\"month_h\"] = hours.sum(axis=0) # 월별 전체 가동시간(고장률 분모)\n", + " evs = []\n", + " for p in PID:\n", + " s = np.ones(months)\n", + " if p in (\"P03\", \"P04\"): s = np.exp(-0.06 * (temp - 12)) # 한랭기 전기계통 증가\n", + " if p in (\"P06\", \"P16\"): s = np.exp(0.05 * (temp - 12)) # 혹서기 냉각계통 증가\n", + " cnt = rng.poisson(RATE[p] * hours / 1000 * fleet[\"mult\"].values[:, None] * s[None, :])\n", + " for v, m in zip(*np.nonzero(cnt)):\n", + " for _ in range(cnt[v, m]):\n", + " evs.append((fleet[\"veh\"][v], int(m), m * 720 + rng.uniform(0, 720), p, float(temp[m] + rng.normal(0, 3))))\n", + " ev = pd.DataFrame(evs, columns=[\"veh\", \"month\", \"t\", \"part\", \"temp\"]).sort_values(\"t\").reset_index(drop=True)\n", + " ev[\"ev_id\"] = [f\"W{i:05d}\" for i in range(len(ev))]\n", + " rows, codes = [], []\n", + " for e in ev.itertuples():\n", + " c, r = [], rng.random(3)\n", + " if r[0] < 0.6: c.append(SPEC[e.part])\n", + " if e.part in SHARED and r[1] < 0.6: c.append(SHARED[e.part])\n", + " if r[2] < 0.5: c.append(SYMP[PSYS[e.part]])\n", + " if not c: c.append(SHARED.get(e.part, SPEC[e.part]))\n", + " for q, pr in CASCADE.get(e.part, []):\n", + " if rng.random() < pr: c.append(SPEC[q])\n", + " if rng.random() < 0.12: c.append(SPEC[PID[rng.integers(0, 16)]])\n", + " c = list(dict.fromkeys(c))\n", + " rng.shuffle(c) # 동시 발생 코드의 기록 순서는 임의\n", + " codes.append(c)\n", + " for k, cd in enumerate(c):\n", + " rows.append((e.ev_id, e.veh, e.t + k * 0.1, cd))\n", + " ev[\"codes\"] = codes\n", + " log = pd.DataFrame(rows, columns=[\"ev_id\", \"veh\", \"ts\", \"code\"])\n", + " return fleet, ev, log\n", + "\n", + "# 1) 업무 프로세스 맵: DTC 발생부터 고장률 집계·환류까지 (각 단계에서 생기는 데이터)\n", + "PROCESS = [(\"DTC 발생\", \"차량 ID, 발생 시각, DTC 코드, 가동시간 계기값\"), (\"로그 수집·정제\", \"중복 제거, 코드·시각 표기 통일\"),\n", + " (\"동시 발생 묶음\", \"같은 정비 건에서 발생한 DTC 세트(정규화)\"), (\"고장 진단\", \"추정 고장 부품, 신뢰도\"),\n", + " (\"정비 조치\", \"정비 매뉴얼 절차, 수리시간, 교체 부품\"), (\"정비 이력 연결\", \"정비 건 ID로 DTC와 실제 고장 부품 연결\"),\n", + " (\"고장률 집계\", \"부품별 1000가동시간당 고장 건수\"), (\"다빈도 품목 식별\", \"파레토(ABC) 등급, 관리 대상 선정\"),\n", + " (\"사후검토(AAR)\", \"오진단 사례, 규칙 조정 이력\")]\n", + "\n", + "# 2) 스키마(데이터 딕셔너리): 그룹 / 필드 / 자료형 / 규칙 / 설명\n", + "S = {\n", + " \"dtc_log\": {\n", + " \"ev_id\": (str, {\"pattern\": r\"^W\\d{5}$\"}, \"정비 건 ID(DTC와 정비 이력을 잇는 키)\"),\n", + " \"veh_id\": (str, {\"pattern\": r\"^V\\d{3}$\"}, \"차량 ID(가상)\"),\n", + " \"ts\": (float, {\"min\": 0, \"max\": 13000}, \"DTC 발생 시각(모의 시작 후 경과 시간, h)\"),\n", + " \"code\": (str, {\"enum\": CODES}, \"DTC 코드(고유·공유·계통 증상 28종)\"),\n", + " \"co_count\": (int, {\"min\": 1, \"max\": 8}, \"같은 정비 건에서 동시 발생한 DTC 수\"),\n", + " },\n", + " \"work_order\": {\n", + " \"part_id\": (str, {\"enum\": PID}, \"실제 고장 부품(정비 이력) - 예측대상 1\"),\n", + " \"system\": (str, {\"enum\": SYS}, \"고장 계통\"),\n", + " \"repair_h\": (float, {\"min\": 0.1, \"max\": 24}, \"수리 소요시간(h)\"),\n", + " \"unit_cost\": (float, {\"min\": 0, \"max\": 50}, \"부품 단가지수(가상)\"),\n", + " \"unplanned\": (bool, {}, \"비계획·긴급 정비 여부\"),\n", + " \"month\": (int, {\"min\": 0, \"max\": 17}, \"정비 월(모의 시작 기준)\"),\n", + " },\n", + " \"vehicle\": {\n", + " \"age\": (float, {\"min\": 0, \"max\": 40}, \"차령(년)\"),\n", + " \"op_hours\": (float, {\"min\": 0, \"max\": 400}, \"월 가동시간(h)\"),\n", + " \"pm_gap\": (float, {\"min\": 0, \"max\": 500}, \"마지막 예방정비 후 경과 가동시간(h)\"),\n", + " \"terrain\": (float, {\"min\": 0, \"max\": 1}, \"운용 지형 가혹도(0~1)\"),\n", + " \"load\": (float, {\"min\": 0, \"max\": 1}, \"운용 부하 수준(0~1)\"),\n", + " \"temp\": (float, {\"min\": -40, \"max\": 60}, \"정비 시점 기온(도)\"),\n", + " },\n", + " \"manual\": {\n", + " \"code_ref\": (str, {\"enum\": CODES}, \"매뉴얼 기준 DTC 코드\"),\n", + " \"cand_parts\": (list, {}, \"코드별 후보 고장 부품 목록\"),\n", + " \"step_no\": (int, {\"min\": 1, \"max\": 20}, \"정비 조치 절차 단계 번호\"),\n", + " \"safety_flag\": (bool, {}, \"안전 주의 항목 포함 여부\"),\n", + " },\n", + " \"target\": {\n", + " \"fail_rate\": (float, {\"min\": 0, \"max\": 100}, \"부품별 1000가동시간당 고장률 - 예측대상 2\"),\n", + " \"confidence\": (float, {\"min\": 0, \"max\": 1}, \"진단 추천 신뢰도 점수 - 예측대상 3\"),\n", + " },\n", + "}\n", + "\n", + "\n", + "def check(name, v, typ, rule):\n", + " # [TODO : 학생 실습 1] 전차/장갑차 전자제어장치(ECU) 고장 진단 코드(DTC) 및 정비 매뉴얼 데이터셋 정의 관련 핵심 로직을 구현하세요.\n", + " # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n", + " # <<< 여기에 코드를 작성하세요 >>>\n", + " pass # 작성 후 pass를 제거하세요.\n", + "def validate(rec):\n", + " # [TODO : 학생 실습 1] 전차/장갑차 전자제어장치(ECU) 고장 진단 코드(DTC) 및 정비 매뉴얼 데이터셋 정의 관련 핵심 로직을 구현하세요.\n", + " # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n", + " # <<< 여기에 코드를 작성하세요 >>>\n", + " pass # 작성 후 pass를 제거하세요.\n", + "fleet, ev, log = make_data()\n", + "f = fleet.set_index(\"veh\")\n", + "recs = []\n", + "for e in ev.sample(20, random_state=0).itertuples():\n", + " first = log[log[\"ev_id\"] == e.ev_id].iloc[0]\n", + " cs = e.codes\n", + " recs.append({\n", + " \"dtc_log\": {\"ev_id\": e.ev_id, \"veh_id\": e.veh, \"ts\": float(round(first[\"ts\"], 1)), \"code\": cs[0], \"co_count\": len(cs)},\n", + " \"work_order\": {\"part_id\": e.part, \"system\": PSYS[e.part], \"repair_h\": float(REPH[e.part]), \"unit_cost\": float(COST[e.part]),\n", + " \"unplanned\": bool(len(cs) >= 3), \"month\": int(e.month)},\n", + " \"vehicle\": {\"age\": float(f.loc[e.veh, \"age\"]), \"op_hours\": float(round(f.loc[e.veh, \"base_h\"], 1)), \"pm_gap\": float(round(f.loc[e.veh, \"pm_gap\"], 1)),\n", + " \"terrain\": float(round(f.loc[e.veh, \"terrain\"], 2)), \"load\": float(round(f.loc[e.veh, \"load\"], 2)), \"temp\": float(round(e.temp, 1))},\n", + " \"manual\": {\"code_ref\": cs[0], \"cand_parts\": [p for p in PID if cs[0] in (SPEC[p], SHARED.get(p))] or [e.part], \"step_no\": 1,\n", + " \"safety_flag\": PSYS[e.part] in CRIT_SYS},\n", + " \"target\": {\"fail_rate\": float(RATE[e.part]), \"confidence\": 0.8}})\n", + "ok = sum(not validate(r) for r in recs)\n", + "print(f\"가상 데이터 {len(ev)}건(DTC 로그 {len(log)}행) 생성, 표본 {len(recs)}건 중 검증 통과 {ok}건, 오류 {len(recs) - ok}건\")\n", + "print(f\"전체 DTC 로그 코드 허용값 검증: {'통과' if log['code'].isin(CODES).all() else '실패'}\")\n", + "\n", + "# 4) 오류 주입 시험\n", + "bad = json.loads(json.dumps(recs[0]))\n", + "bad[\"dtc_log\"][\"code\"] = \"C999\" # 정의되지 않은 DTC 코드\n", + "bad[\"dtc_log\"][\"veh_id\"] = \"TANK-7\" # ID 형식 오류\n", + "bad[\"vehicle\"][\"load\"] = 1.8 # 범위 초과\n", + "del bad[\"work_order\"][\"repair_h\"] # 필수 필드 누락\n", + "bad[\"target\"][\"confidence\"] = \"높음\" # 자료형 오류\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__, d) for g, fl in S.items() for k, (t, r, d) in fl.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" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 01 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + "가상 데이터 10681건(DTC 로그 22079행) 생성, 표본 20건 중 검증 통과 20건, 오류 0건\n", + "전체 DTC 로그 코드 허용값 검증: 통과\n", + "\n", + "[오류 주입 결과]\n", + " - dtc_log.veh_id: 형식 불일치 'TANK-7'\n", + " - dtc_log.code: 허용되지 않은 값 'C999'\n", + " - work_order.repair_h: 필수 필드 누락\n", + " - vehicle.load: 최대값 1 초과\n", + " - target.confidence: 자료형 오류(str)\n", + "\n", + "[업무 프로세스 맵]\n", + "1.DTC 발생 -> 2.로그 수집·정제 -> 3.동시 발생 묶음 -> 4.고장 진단 -> 5.정비 조치 -> 6.정비 이력 연결 -> 7.고장률 집계 -> 8.다빈도 품목 식별 -> 9.사후검토(AAR)\n", + " DTC 발생 : 차량 ID, 발생 시각, DTC 코드, 가동시간 계기값\n", + " 로그 수집·정제 : 중복 제거, 코드·시각 표기 통일\n", + " 동시 발생 묶음 : 같은 정비 건에서 발생한 DTC 세트(정규화)\n", + " 고장 진단 : 추정 고장 부품, 신뢰도\n", + " 정비 조치 : 정비 매뉴얼 절차, 수리시간, 교체 부품\n", + " 정비 이력 연결 : 정비 건 ID로 DTC와 실제 고장 부품 연결\n", + " 고장률 집계 : 부품별 1000가동시간당 고장 건수\n", + " 다빈도 품목 식별 : 파레토(ABC) 등급, 관리 대상 선정\n", + " 사후검토(AAR) : 오진단 사례, 규칙 조정 이력\n", + "\n", + "[데이터 딕셔너리] 피처 23개 (목표 20개 이상: 달성)\n", + " 그룹 필드 자료형 설명\n", + " dtc_log ev_id str 정비 건 ID(DTC와 정비 이력을 잇는 키)\n", + " dtc_log veh_id str 차량 ID(가상)\n", + " dtc_log ts float DTC 발생 시각(모의 시작 후 경과 시간, h)\n", + " dtc_log code str DTC 코드(고유·공유·계통 증상 28종)\n", + " dtc_log co_count int 같은 정비 건에서 동시 발생한 DTC 수\n", + "work_order part_id str 실제 고장 부품(정비 이력) - 예측대상 1\n", + "work_order system str 고장 계통\n", + "work_order repair_h float 수리 소요시간(h)\n", + "work_order unit_cost float 부품 단가지수(가상)\n", + "work_order unplanned bool 비계획·긴급 정비 여부\n", + "work_order month int 정비 월(모의 시작 기준)\n", + " vehicle age float 차령(년)\n", + " vehicle op_hours float 월 가동시간(h)\n", + " vehicle pm_gap float 마지막 예방정비 후 경과 가동시간(h)\n", + " vehicle terrain float 운용 지형 가혹도(0~1)\n", + " vehicle load float 운용 부하 수준(0~1)\n", + "... (이하 생략)\n", + "```\n", + "
\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 02. DTC 시계열 발생 로그 정제, 동시 발생 코드 정규화 전처리 모듈 구현\n", + "**소요시간**: 약 2.0시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "DTC 발생 로그 정제 및 동시 발생 코드 정규화 전처리 모듈을 구현한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **DTC 시계열 발생 로그를 정제하고 같은 정비 건에서 동시 발생한 코드를 정규화하는 전처리 모듈을 구현한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **중복 행 제거, 차량 ID·DTC 코드·시각 표기 통일, 가동시간 단위 통일, 음수 가동시간 결측 처리, 미등록 차량·미정의 코드 격리, KNN 결측 보정, 동시 발생 코드의 정비 건 묶음·정렬 등 정제 규칙 10종을 적용 수행하고 검증하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `src/etl/mnt03_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": [ + "\"\"\"표기 불일치·중복·단위 혼재·결측·미등록 코드가 섞인 가상 DTC 발생 로그에 정제 규칙 10종을 적용하고, 같은 시간대에 함께 발생한 DTC를 정비 건 단위로 묶어 정규화(중복 제거·정렬)한 마스터 데이터를 만든다. 긴급·비계획 정비는 보존하며 정합성 검증 통과율과 정제 전후 통계를 확인한다.\"\"\"\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", + "# [교육용가상자료] 가상 차량 100대의 18개월 고장 진단 코드(DTC)·정비 이력 생성기 (실제 부대·장비·정비 실적 없음)\n", + "# 부품 16종(1000가동시간당 고장률 차등), DTC 28종(부품 고유 코드, 짝 부품 공유 코드, 계통 증상 코드)\n", + "# 고장 1건(정비 이력)마다 DTC가 1~5개 동시 발생하고, 연쇄 고장 코드와 일시 잡음 코드가 섞인다.\n", + "# ---------------------------------------------------------------------------\n", + "PARTS = [ # (ID, 이름, 계통, 1000가동시간당 고장률, 가상 단가지수, 수리시간 h)\n", + " (\"P01\", \"연료분사기\", \"engine\", 12.0, 3.0, 2.0), (\"P02\", \"터보차저\", \"engine\", 9.0, 8.0, 5.0),\n", + " (\"P03\", \"축전지\", \"electric\", 6.5, 4.0, 1.5), (\"P04\", \"발전기\", \"electric\", 4.0, 9.0, 3.0),\n", + " (\"P05\", \"변속기 제어모듈\", \"drive\", 3.0, 12.0, 6.0), (\"P06\", \"냉각수 펌프\", \"engine\", 2.2, 3.5, 3.0),\n", + " (\"P07\", \"유압 펌프\", \"hydraulic\", 1.6, 10.0, 4.0), (\"P08\", \"속도 센서\", \"drive\", 1.2, 2.0, 1.5),\n", + " (\"P09\", \"ECU 커넥터\", \"electric\", 0.9, 2.5, 1.5), (\"P10\", \"연료 필터\", \"engine\", 0.7, 0.8, 0.7),\n", + " (\"P11\", \"제동 밸브\", \"brake\", 0.5, 6.0, 3.0), (\"P12\", \"조향 액추에이터\", \"drive\", 0.4, 9.0, 5.0),\n", + " (\"P13\", \"공기 필터\", \"engine\", 0.3, 0.5, 0.5), (\"P14\", \"통신 인터페이스 모듈\", \"electric\", 0.25, 3.0, 1.5),\n", + " (\"P15\", \"윤활유 압력 센서\", \"engine\", 0.2, 1.0, 1.0), (\"P16\", \"팬 구동 모터\", \"cooling\", 0.15, 2.0, 2.0)]\n", + "PID = [p[0] for p in PARTS]\n", + "PNAME = {p[0]: p[1] for p in PARTS}; PSYS = {p[0]: p[2] for p in PARTS}; RATE = {p[0]: p[3] for p in PARTS}\n", + "COST = {p[0]: p[4] for p in PARTS}; REPH = {p[0]: p[5] for p in PARTS}\n", + "PAIRS = [(\"P01\", \"P02\"), (\"P03\", \"P04\"), (\"P05\", \"P08\"), (\"P06\", \"P10\"), (\"P09\", \"P14\"), (\"P13\", \"P15\")]\n", + "SHARED = {p: f\"S{k + 1:02d}\" for k, pr in enumerate(PAIRS) for p in pr} # 짝 부품이 함께 쓰는 모호한 코드\n", + "SPEC = {p: f\"C{p[1:]}1\" for p in PID} # 부품 고유 코드\n", + "SYS = [\"engine\", \"electric\", \"drive\", \"hydraulic\", \"brake\", \"cooling\"]\n", + "SYMP = {s: f\"G{i + 1:02d}\" for i, s in enumerate(SYS)} # 계통 증상 코드\n", + "CODES = sorted(set(SPEC.values()) | set(SHARED.values()) | set(SYMP.values()))\n", + "CASCADE = {\"P01\": [(\"P10\", 0.20)], \"P02\": [(\"P06\", 0.20)], \"P03\": [(\"P04\", 0.25)], \"P04\": [(\"P09\", 0.25)],\n", + " \"P05\": [(\"P08\", 0.30)], \"P06\": [(\"P16\", 0.30)], \"P09\": [(\"P14\", 0.20)], \"P12\": [(\"P07\", 0.20)]}\n", + "CRIT_SYS = {\"engine\", \"drive\", \"brake\"} # 기동·제동 불능 위험 계통(경보 대상)\n", + "MONTHS = 18\n", + "\n", + "\n", + "def make_fleet(n=100, seed=0):\n", + " rng = np.random.default_rng(seed)\n", + " f = pd.DataFrame({\"veh\": [f\"V{i:03d}\" for i in range(n)], \"age\": rng.integers(1, 26, n).astype(float),\n", + " \"terrain\": rng.uniform(0, 1, n), \"load\": rng.uniform(0, 1, n), \"pm_gap\": rng.uniform(20, 200, n),\n", + " \"humid\": rng.uniform(0, 1, n), \"base_h\": rng.uniform(55, 125, n)})\n", + " f[\"mult\"] = np.exp(0.045 * (f[\"age\"] - 13) + 0.9 * (f[\"terrain\"] - .5) + 0.8 * (f[\"load\"] - .5)\n", + " + 0.006 * (f[\"pm_gap\"] - 110) + 0.7 * (f[\"humid\"] - .5))\n", + " return f\n", + "\n", + "\n", + "def make_data(seed=1, months=MONTHS):\n", + " \"\"\"반환: fleet(차량 속성), ev(정비 이력 1건=1행: 발생 DTC 목록, 실제 고장 부품), log(DTC 발생 로그, 행=코드 1건)\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " fleet = make_fleet()\n", + " V = len(fleet)\n", + " temp = 12 + 14 * np.sin(2 * np.pi * (np.arange(months) - 3) / 12) # 월평균 기온(가상)\n", + " hours = fleet[\"base_h\"].values[:, None] * (1 + 0.25 * np.sin(2 * np.pi * np.arange(months) / 12 + 1)) * rng.lognormal(0, .12, (V, months))\n", + " fleet[\"hours\"] = hours.sum(axis=1) # 차량별 18개월 총 가동시간\n", + " fleet.attrs[\"month_h\"] = hours.sum(axis=0) # 월별 전체 가동시간(고장률 분모)\n", + " evs = []\n", + " for p in PID:\n", + " s = np.ones(months)\n", + " if p in (\"P03\", \"P04\"): s = np.exp(-0.06 * (temp - 12)) # 한랭기 전기계통 증가\n", + " if p in (\"P06\", \"P16\"): s = np.exp(0.05 * (temp - 12)) # 혹서기 냉각계통 증가\n", + " cnt = rng.poisson(RATE[p] * hours / 1000 * fleet[\"mult\"].values[:, None] * s[None, :])\n", + " for v, m in zip(*np.nonzero(cnt)):\n", + " for _ in range(cnt[v, m]):\n", + " evs.append((fleet[\"veh\"][v], int(m), m * 720 + rng.uniform(0, 720), p, float(temp[m] + rng.normal(0, 3))))\n", + " ev = pd.DataFrame(evs, columns=[\"veh\", \"month\", \"t\", \"part\", \"temp\"]).sort_values(\"t\").reset_index(drop=True)\n", + " ev[\"ev_id\"] = [f\"W{i:05d}\" for i in range(len(ev))]\n", + " rows, codes = [], []\n", + " for e in ev.itertuples():\n", + " c, r = [], rng.random(3)\n", + " if r[0] < 0.6: c.append(SPEC[e.part])\n", + " if e.part in SHARED and r[1] < 0.6: c.append(SHARED[e.part])\n", + " if r[2] < 0.5: c.append(SYMP[PSYS[e.part]])\n", + " if not c: c.append(SHARED.get(e.part, SPEC[e.part]))\n", + " for q, pr in CASCADE.get(e.part, []):\n", + " if rng.random() < pr: c.append(SPEC[q])\n", + " if rng.random() < 0.12: c.append(SPEC[PID[rng.integers(0, 16)]])\n", + " c = list(dict.fromkeys(c))\n", + " rng.shuffle(c) # 동시 발생 코드의 기록 순서는 임의\n", + " codes.append(c)\n", + " for k, cd in enumerate(c):\n", + " rows.append((e.ev_id, e.veh, e.t + k * 0.1, cd))\n", + " ev[\"codes\"] = codes\n", + " log = pd.DataFrame(rows, columns=[\"ev_id\", \"veh\", \"ts\", \"code\"])\n", + " return fleet, ev, log\n", + "\n", + "# 1) [교육용가상자료] 원천 로그: 생성 로그에 일부러 오류를 섞는다\n", + "fleet, ev, log = make_data()\n", + "rng = np.random.default_rng(2)\n", + "clean_n = len(log)\n", + "start = pd.Timestamp(\"2025-01-01\")\n", + "raw = pd.DataFrame({\"veh\": log[\"veh\"].values, \"ts\": [(start + pd.Timedelta(hours=t)).strftime(\"%Y-%m-%d %H:%M\") for t in log[\"ts\"]],\n", + " \"code\": log[\"code\"].values, \"emergency\": False})\n", + "vn = raw[\"veh\"].str[1:].astype(int).values\n", + "raw[\"op_h\"] = (vn * 37 % 900) + log[\"ts\"].values * 0.15 # 차량별 누적 가동시간 계기값(가상)\n", + "raw[\"op_unit\"] = \"h\"\n", + "raw[\"op_h\"] = raw[\"op_h\"].round(1)\n", + "idx = rng.choice(len(raw), 2600, replace=False)\n", + "a, b, c, d_, e, f, g, h_, k = np.split(idx, [400, 900, 1500, 1800, 1880, 1960, 2040, 2300])\n", + "raw.loc[a, \"veh\"] = rng.choice([\"v\", \" V\"], len(a)) + raw.loc[a, \"veh\"].str[1:] # 차량 ID 표기 불일치\n", + "raw.loc[b, \"code\"] = [x.lower() if i % 2 else x[0] + \"-\" + x[1:] + \" \" for i, x in enumerate(raw.loc[b, \"code\"])] # 코드 표기 불일치\n", + "raw.loc[c, \"ts\"] = pd.to_datetime(raw.loc[c, \"ts\"]).dt.strftime(\"%Y/%m/%d %H:%M:%S\") # 시각 형식 혼재\n", + "raw.loc[d_, \"op_h\"] = (raw.loc[d_, \"op_h\"] * 60).round(0); raw.loc[d_, \"op_unit\"] = \"min\" # 단위 혼재\n", + "raw.loc[e, \"op_h\"] = -1.0 # 불가능 값\n", + "raw.loc[f, \"veh\"] = \"V999\" # 미등록 차량\n", + "raw.loc[g, \"code\"] = \"ZZ9\" # 미정의 코드\n", + "raw.loc[h_, \"op_h\"] = np.nan # 결측\n", + "burst = raw.iloc[k[:40]].copy() # 긴급·비계획 정비: DTC 6개 폭주\n", + "bursts = []\n", + "for _, r in burst.iterrows():\n", + " for j, cd in enumerate(rng.choice(CODES, 6, replace=False)):\n", + " bursts.append({**r, \"code\": cd, \"emergency\": True,\n", + " \"ts\": (pd.to_datetime(r[\"ts\"]) + pd.Timedelta(minutes=j)).strftime(\"%Y-%m-%d %H:%M\")})\n", + "raw = pd.concat([raw, pd.DataFrame(bursts), raw.sample(150, random_state=1)], ignore_index=True) # 폭주 건 + 중복 150건\n", + "raw = raw.sample(frac=1, random_state=3).reset_index(drop=True)\n", + "print(f\"원천 로그 {len(raw)}행 (정상 {clean_n}행 + 긴급 폭주 {len(bursts)}행 + 중복 150행, 오류 주입 {len(idx)}행)\")\n", + "\n", + "# 2) 정제 규칙 10종\n", + "df, applied = raw.copy(), []\n", + "def rule(name, n):\n", + " # [TODO : 학생 실습 2] DTC 시계열 발생 로그 정제, 동시 발생 코드 정규화 전처리 모듈 구현 관련 핵심 로직을 구현하세요.\n", + " # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n", + " # <<< 여기에 코드를 작성하세요 >>>\n", + " pass # 작성 후 pass를 제거하세요.\n", + "rule(\"R1 중복 행 제거\", df.duplicated().sum()); df = df.drop_duplicates().reset_index(drop=True)\n", + "v = df[\"veh\"].str.strip().str.upper(); rule(\"R2 차량 ID 표기 통일\", (v != df[\"veh\"]).sum()); df[\"veh\"] = v\n", + "cd = df[\"code\"].str.strip().str.upper().str.replace(\"-\", \"\", regex=False); rule(\"R3 DTC 코드 표기 통일\", (cd != df[\"code\"]).sum()); df[\"code\"] = cd\n", + "t1 = pd.to_datetime(df[\"ts\"], format=\"%Y-%m-%d %H:%M\", errors=\"coerce\")\n", + "t2 = pd.to_datetime(df[\"ts\"], format=\"%Y/%m/%d %H:%M:%S\", errors=\"coerce\"); rule(\"R4 발생 시각 형식 통일\", t1.isna().sum()); df[\"ts\"] = t1.fillna(t2)\n", + "mn = df[\"op_unit\"] == \"min\"; df.loc[mn, \"op_h\"] = df.loc[mn, \"op_h\"] / 60; df[\"op_unit\"] = \"h\"; rule(\"R5 가동시간 단위 통일(분 -> 시간)\", mn.sum())\n", + "bad = df[\"op_h\"] < 0; df.loc[bad, \"op_h\"] = np.nan; rule(\"R6 음수 가동시간 결측 처리\", bad.sum())\n", + "unk = ~df[\"veh\"].isin(fleet[\"veh\"]) | ~df[\"code\"].isin(CODES); quarantine = df[unk].copy(); df = df[~unk].reset_index(drop=True)\n", + "rule(\"R7 미등록 차량·미정의 코드 격리(삭제 아님)\", unk.sum())\n", + "df[\"hrs\"] = (df[\"ts\"] - start).dt.total_seconds() / 3600; df[\"vn\"] = df[\"veh\"].str[1:].astype(int)\n", + "miss = df[\"op_h\"].isna().sum()\n", + "df[\"op_h\"] = KNNImputer(n_neighbors=5).fit_transform(df[[\"hrs\", \"vn\", \"op_h\"]].assign(vn=df[\"vn\"] / 10))[:, 2]; rule(\"R8 가동시간 결측 KNN 보정\", miss)\n", + "df = df.sort_values([\"veh\", \"ts\"]).reset_index(drop=True)\n", + "df[\"case\"] = (df[\"veh\"] != df[\"veh\"].shift()) | (df[\"ts\"].diff() > pd.Timedelta(hours=1)); df[\"case_id\"] = df[\"case\"].cumsum()\n", + "before = len(df); df = df.drop_duplicates([\"case_id\", \"code\"]).reset_index(drop=True)\n", + "cases = df.groupby(\"case_id\").agg(veh=(\"veh\", \"first\"), t=(\"ts\", \"first\"), codes=(\"code\", lambda s: tuple(sorted(s))), n=(\"code\", \"size\"), emergency=(\"emergency\", \"max\"))\n", + "print(f\"(동시 발생 묶음 과정에서 중복 코드 {before - len(df)}행 제거)\")\n", + "rule(\"R9 동시 발생 코드 정비 건 묶음·정렬(생성 건수)\", len(cases))\n", + "cases[\"outlier_flag\"] = (cases[\"n\"] > 4) & ~cases[\"emergency\"]\n", + "rule(\"R10 긴급·비계획 정비 보존(일반 이상 건은 표시만)\", cases[\"emergency\"].sum())\n", + "print(\"\\n[적용 규칙과 처리 건수]\")\n", + "for nme, n in applied:\n", + " print(f\" {nme:40s} {n:5d}건\")\n", + "\n", + "# 3) 정합성 검증\n", + "checks = {\"중복 0건\": not df.duplicated([\"case_id\", \"code\"]).any(), \"차량 ID 형식\": df[\"veh\"].str.match(r\"^V\\d{3}$\").all(),\n", + " \"코드 허용 범위\": df[\"code\"].isin(CODES).all(), \"시각 변환\": df[\"ts\"].notna().all(), \"가동시간 단위 h\": (df[\"op_unit\"] == \"h\").all(),\n", + " \"가동시간 0 이상\": (df[\"op_h\"] >= 0).all(), \"미등록 건 격리 보관\": len(quarantine) == unk.sum(),\n", + " \"결측 0건\": df[[\"veh\", \"code\", \"ts\", \"op_h\"]].notna().all().all(), \"묶음 내 코드 정렬\": all(list(c) == sorted(c) for c in cases[\"codes\"]),\n", + " f\"긴급 정비 {len(bursts) // 6}건 보존\": int(cases[\"emergency\"].sum()) >= len(bursts) // 6}\n", + "rate = np.mean(list(checks.values()))\n", + "print(f\"\\n정합성 검증 {sum(checks.values())}/{len(checks)} 통과, 통과율 {rate:.0%} (목표 100%: {'달성' if rate == 1 else '미달'})\")\n", + "for kx, vx in checks.items():\n", + " if not vx:\n", + " print(\" 실패:\", kx)\n", + "print(f\"정비 건 {len(cases)}건 생성(평균 DTC {cases['n'].mean():.2f}개), 이상 표시 {int(cases['outlier_flag'].sum())}건\")\n", + "\n", + "# 4) 정제 전후 기술통계 비교\n", + "num = pd.to_numeric(raw[\"op_h\"], errors=\"coerce\")\n", + "cmp = pd.DataFrame({\"정제 전\": [len(raw), raw[\"veh\"].nunique(), raw[\"code\"].nunique(), num.mean(), num.min(), num.isna().sum()],\n", + " \"정제 후\": [len(df), df[\"veh\"].nunique(), df[\"code\"].nunique(), df[\"op_h\"].mean(), df[\"op_h\"].min(), df[\"op_h\"].isna().sum()]},\n", + " index=[\"행 수\", \"차량 종류 수\", \"코드 종류 수\", \"가동시간 평균(h)\", \"가동시간 최소(h)\", \"결측 수\"]).round(1)\n", + "print(\"\\n[정제 전후 기술통계]\"); print(cmp.to_string())\n", + "\n", + "fig, ax = plt.subplots(1, 2, figsize=(10, 3.5))\n", + "ax[0].hist(num.dropna(), bins=40, alpha=0.6, label=\"raw\"); ax[0].hist(df[\"op_h\"], bins=40, alpha=0.6, label=\"cleaned\")\n", + "ax[0].set_title(\"Operating hours 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": [ + "
\n", + "💡 [참고] 미션 02 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + "원천 로그 22469행 (정상 22079행 + 긴급 폭주 240행 + 중복 150행, 오류 주입 2600행)\n", + "(동시 발생 묶음 과정에서 중복 코드 68행 제거)\n", + "\n", + "[적용 규칙과 처리 건수]\n", + " R1 중복 행 제거 150건\n", + " R2 차량 ID 표기 통일 400건\n", + " R3 DTC 코드 표기 통일 500건\n", + " R4 발생 시각 형식 통일 600건\n", + " R5 가동시간 단위 통일(분 -> 시간) 300건\n", + " R6 음수 가동시간 결측 처리 80건\n", + " R7 미등록 차량·미정의 코드 격리(삭제 아님) 160건\n", + " R8 가동시간 결측 KNN 보정 340건\n", + " R9 동시 발생 코드 정비 건 묶음·정렬(생성 건수) 10504건\n", + " R10 긴급·비계획 정비 보존(일반 이상 건은 표시만) 40건\n", + "\n", + "정합성 검증 10/10 통과, 통과율 100% (목표 100%: 달성)\n", + "정비 건 10504건 생성(평균 DTC 2.10개), 이상 표시 85건\n", + "\n", + "[정제 전후 기술통계]\n", + " 정제 전 정제 후\n", + "행 수 22469.0 22091.0\n", + "차량 종류 수 252.0 100.0\n", + "코드 종류 수 82.0 28.0\n", + "가동시간 평균(h) 2496.2 1372.4\n", + "가동시간 최소(h) -1.0 3.8\n", + "결측 수 261.0 0.0\n", + "```\n", + "
\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 03. 단일 고장 코드 기반 1차 고장 부품 매핑 Baseline 알고리즘 구현\n", + "**소요시간**: 약 2.5시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "단일 고장 코드 기반 1차 고장 부품 매핑 Baseline 알고리즘을 구현한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **단일 고장 코드 기반 1차 고장 부품 매핑 Baseline(첫 기록 코드와 가장 자주 함께 나온 부품)을 구현하고, 탐색적 분석으로 다빈도 고장품목과 고장 증가 요인을 찾는다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **학습 기간 이력으로 코드와 부품의 대응표를 만들어 검증 기간의 부품 정확도를 측정하고, 공유·증상 코드처럼 모호한 코드가 첫 기록일 때 정확도가 떨어지는지 확인하고 유효성을 점검하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `notebooks/mnt03_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": [ + "# [TODO : 학생 실습 3] 단일 고장 코드 기반 1차 고장 부품 매핑 Baseline 알고리즘 구현\n", + "# 미션 지침 및 검증 기준을 확인하고 코드를 실행 및 튜닝하세요.\n", + "\"\"\"단일 DTC 코드만 보고 가장 흔한 고장 부품을 고르는 1차 Baseline 매핑 알고리즘을 구현해 정확도와 한계를 확인하고, 탐색적 분석(EDA 차트 8종, 고장 건수와 피처의 상관계수·p-value, 파레토 ABC 분류)으로 1000가동시간당 고장률이 높은 다빈도 고장품목을 식별한다.\"\"\"\n", + "import numpy as np\n", + "import pandas as pd\n", + "import matplotlib.pyplot as plt\n", + "from scipy.stats import pearsonr\n", + "\n", + "# ---------------------------------------------------------------------------\n", + "# [교육용가상자료] 가상 차량 100대의 18개월 고장 진단 코드(DTC)·정비 이력 생성기 (실제 부대·장비·정비 실적 없음)\n", + "# 부품 16종(1000가동시간당 고장률 차등), DTC 28종(부품 고유 코드, 짝 부품 공유 코드, 계통 증상 코드)\n", + "# 고장 1건(정비 이력)마다 DTC가 1~5개 동시 발생하고, 연쇄 고장 코드와 일시 잡음 코드가 섞인다.\n", + "# ---------------------------------------------------------------------------\n", + "PARTS = [ # (ID, 이름, 계통, 1000가동시간당 고장률, 가상 단가지수, 수리시간 h)\n", + " (\"P01\", \"연료분사기\", \"engine\", 12.0, 3.0, 2.0), (\"P02\", \"터보차저\", \"engine\", 9.0, 8.0, 5.0),\n", + " (\"P03\", \"축전지\", \"electric\", 6.5, 4.0, 1.5), (\"P04\", \"발전기\", \"electric\", 4.0, 9.0, 3.0),\n", + " (\"P05\", \"변속기 제어모듈\", \"drive\", 3.0, 12.0, 6.0), (\"P06\", \"냉각수 펌프\", \"engine\", 2.2, 3.5, 3.0),\n", + " (\"P07\", \"유압 펌프\", \"hydraulic\", 1.6, 10.0, 4.0), (\"P08\", \"속도 센서\", \"drive\", 1.2, 2.0, 1.5),\n", + " (\"P09\", \"ECU 커넥터\", \"electric\", 0.9, 2.5, 1.5), (\"P10\", \"연료 필터\", \"engine\", 0.7, 0.8, 0.7),\n", + " (\"P11\", \"제동 밸브\", \"brake\", 0.5, 6.0, 3.0), (\"P12\", \"조향 액추에이터\", \"drive\", 0.4, 9.0, 5.0),\n", + " (\"P13\", \"공기 필터\", \"engine\", 0.3, 0.5, 0.5), (\"P14\", \"통신 인터페이스 모듈\", \"electric\", 0.25, 3.0, 1.5),\n", + " (\"P15\", \"윤활유 압력 센서\", \"engine\", 0.2, 1.0, 1.0), (\"P16\", \"팬 구동 모터\", \"cooling\", 0.15, 2.0, 2.0)]\n", + "PID = [p[0] for p in PARTS]\n", + "PNAME = {p[0]: p[1] for p in PARTS}; PSYS = {p[0]: p[2] for p in PARTS}; RATE = {p[0]: p[3] for p in PARTS}\n", + "COST = {p[0]: p[4] for p in PARTS}; REPH = {p[0]: p[5] for p in PARTS}\n", + "PAIRS = [(\"P01\", \"P02\"), (\"P03\", \"P04\"), (\"P05\", \"P08\"), (\"P06\", \"P10\"), (\"P09\", \"P14\"), (\"P13\", \"P15\")]\n", + "SHARED = {p: f\"S{k + 1:02d}\" for k, pr in enumerate(PAIRS) for p in pr} # 짝 부품이 함께 쓰는 모호한 코드\n", + "SPEC = {p: f\"C{p[1:]}1\" for p in PID} # 부품 고유 코드\n", + "SYS = [\"engine\", \"electric\", \"drive\", \"hydraulic\", \"brake\", \"cooling\"]\n", + "SYMP = {s: f\"G{i + 1:02d}\" for i, s in enumerate(SYS)} # 계통 증상 코드\n", + "CODES = sorted(set(SPEC.values()) | set(SHARED.values()) | set(SYMP.values()))\n", + "CASCADE = {\"P01\": [(\"P10\", 0.20)], \"P02\": [(\"P06\", 0.20)], \"P03\": [(\"P04\", 0.25)], \"P04\": [(\"P09\", 0.25)],\n", + " \"P05\": [(\"P08\", 0.30)], \"P06\": [(\"P16\", 0.30)], \"P09\": [(\"P14\", 0.20)], \"P12\": [(\"P07\", 0.20)]}\n", + "CRIT_SYS = {\"engine\", \"drive\", \"brake\"} # 기동·제동 불능 위험 계통(경보 대상)\n", + "MONTHS = 18\n", + "\n", + "\n", + "def make_fleet(n=100, seed=0):\n", + " rng = np.random.default_rng(seed)\n", + " f = pd.DataFrame({\"veh\": [f\"V{i:03d}\" for i in range(n)], \"age\": rng.integers(1, 26, n).astype(float),\n", + " \"terrain\": rng.uniform(0, 1, n), \"load\": rng.uniform(0, 1, n), \"pm_gap\": rng.uniform(20, 200, n),\n", + " \"humid\": rng.uniform(0, 1, n), \"base_h\": rng.uniform(55, 125, n)})\n", + " f[\"mult\"] = np.exp(0.045 * (f[\"age\"] - 13) + 0.9 * (f[\"terrain\"] - .5) + 0.8 * (f[\"load\"] - .5)\n", + " + 0.006 * (f[\"pm_gap\"] - 110) + 0.7 * (f[\"humid\"] - .5))\n", + " return f\n", + "\n", + "\n", + "def make_data(seed=1, months=MONTHS):\n", + " \"\"\"반환: fleet(차량 속성), ev(정비 이력 1건=1행: 발생 DTC 목록, 실제 고장 부품), log(DTC 발생 로그, 행=코드 1건)\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " fleet = make_fleet()\n", + " V = len(fleet)\n", + " temp = 12 + 14 * np.sin(2 * np.pi * (np.arange(months) - 3) / 12) # 월평균 기온(가상)\n", + " hours = fleet[\"base_h\"].values[:, None] * (1 + 0.25 * np.sin(2 * np.pi * np.arange(months) / 12 + 1)) * rng.lognormal(0, .12, (V, months))\n", + " fleet[\"hours\"] = hours.sum(axis=1) # 차량별 18개월 총 가동시간\n", + " fleet.attrs[\"month_h\"] = hours.sum(axis=0) # 월별 전체 가동시간(고장률 분모)\n", + " evs = []\n", + " for p in PID:\n", + " s = np.ones(months)\n", + " if p in (\"P03\", \"P04\"): s = np.exp(-0.06 * (temp - 12)) # 한랭기 전기계통 증가\n", + " if p in (\"P06\", \"P16\"): s = np.exp(0.05 * (temp - 12)) # 혹서기 냉각계통 증가\n", + " cnt = rng.poisson(RATE[p] * hours / 1000 * fleet[\"mult\"].values[:, None] * s[None, :])\n", + " for v, m in zip(*np.nonzero(cnt)):\n", + " for _ in range(cnt[v, m]):\n", + " evs.append((fleet[\"veh\"][v], int(m), m * 720 + rng.uniform(0, 720), p, float(temp[m] + rng.normal(0, 3))))\n", + " ev = pd.DataFrame(evs, columns=[\"veh\", \"month\", \"t\", \"part\", \"temp\"]).sort_values(\"t\").reset_index(drop=True)\n", + " ev[\"ev_id\"] = [f\"W{i:05d}\" for i in range(len(ev))]\n", + " rows, codes = [], []\n", + " for e in ev.itertuples():\n", + " c, r = [], rng.random(3)\n", + " if r[0] < 0.6: c.append(SPEC[e.part])\n", + " if e.part in SHARED and r[1] < 0.6: c.append(SHARED[e.part])\n", + " if r[2] < 0.5: c.append(SYMP[PSYS[e.part]])\n", + " if not c: c.append(SHARED.get(e.part, SPEC[e.part]))\n", + " for q, pr in CASCADE.get(e.part, []):\n", + " if rng.random() < pr: c.append(SPEC[q])\n", + " if rng.random() < 0.12: c.append(SPEC[PID[rng.integers(0, 16)]])\n", + " c = list(dict.fromkeys(c))\n", + " rng.shuffle(c) # 동시 발생 코드의 기록 순서는 임의\n", + " codes.append(c)\n", + " for k, cd in enumerate(c):\n", + " rows.append((e.ev_id, e.veh, e.t + k * 0.1, cd))\n", + " ev[\"codes\"] = codes\n", + " log = pd.DataFrame(rows, columns=[\"ev_id\", \"veh\", \"ts\", \"code\"])\n", + " return fleet, ev, log\n", + "\n", + "fleet, ev, log = make_data()\n", + "ev[\"first\"] = ev[\"codes\"].str[0]\n", + "train, test = ev[ev[\"month\"] < 12], ev[ev[\"month\"] >= 12]\n", + "\n", + "# 1) Baseline: 첫 번째로 기록된 코드 -> 학습 기간에 그 코드와 가장 자주 함께 나온 부품\n", + "mp = train.groupby(\"first\")[\"part\"].agg(lambda s: s.value_counts().index[0])\n", + "pred = test[\"first\"].map(mp)\n", + "acc = (pred == test[\"part\"]).mean()\n", + "sys_acc = (pred.map(PSYS) == test[\"part\"].map(PSYS)).mean()\n", + "print(f\"[교육용가상자료] 정비 건 {len(ev)}건, DTC 로그 {len(log)}행, 차량 {len(fleet)}대, 18개월\")\n", + "print(f\"Baseline 부품 정확도 {acc:.1%}, 고장 계통 정확도 {sys_acc:.1%} (검증 {len(test)}건)\")\n", + "amb = test[\"first\"].isin(list(SHARED.values()) + list(SYMP.values()))\n", + "print(f\" 모호한 코드(공유·증상) 첫 기록 건 정확도 {(pred[amb] == test['part'][amb]).mean():.1%} vs 고유 코드 {(pred[~amb] == test['part'][~amb]).mean():.1%}\")\n", + "\n", + "# 2) 부품별 고장률(1000가동시간당)과 파레토(ABC)\n", + "tot_h = fleet[\"hours\"].sum()\n", + "cnt = ev[\"part\"].value_counts().reindex(PID)\n", + "rate = (cnt / tot_h * 1000).round(3)\n", + "par = pd.DataFrame({\"부품\": [PNAME[p] for p in PID], \"건수\": cnt.values, \"고장률\": rate.values}, index=PID).sort_values(\"건수\", ascending=False)\n", + "par[\"누적비율\"] = par[\"건수\"].cumsum() / par[\"건수\"].sum()\n", + "par[\"등급\"] = np.where(par[\"누적비율\"] <= 0.80, \"A\", np.where(par[\"누적비율\"] <= 0.95, \"B\", \"C\"))\n", + "par.loc[par.index[0], \"등급\"] = \"A\"\n", + "print(f\"\\n[파레토] 총 가동 {tot_h:,.0f}시간, 전체 고장률 {cnt.sum() / tot_h * 1000:.1f}건/1000h\")\n", + "print(par.head(7).assign(누적비율=lambda d: d[\"누적비율\"].map(\"{:.0%}\".format)).to_string())\n", + "top20 = par.head(int(np.ceil(len(par) * 0.2)))\n", + "print(f\"상위 20% 품목({len(top20)}종: {', '.join(top20['부품'])})이 전체 고장의 {top20['건수'].sum() / cnt.sum():.0%}, A등급 {int((par['등급'] == 'A').sum())}종\")\n", + "\n", + "# 3) 상관분석: 차량별 정비 건수와 피처\n", + "vc = ev.groupby(\"veh\").size().reindex(fleet[\"veh\"]).fillna(0).values\n", + "rows = []\n", + "for col, lab in [(\"age\", \"차령\"), (\"pm_gap\", \"예방정비 경과시간\"), (\"terrain\", \"지형 가혹도\"), (\"load\", \"운용 부하\"), (\"hours\", \"총 가동시간\"), (\"humid\", \"습도 지수\")]:\n", + " r, p = pearsonr(fleet[col], vc); rows.append((lab, r, p))\n", + "mc = ev.groupby([\"month\", \"part\"]).size().unstack(fill_value=0)\n", + "tm = ev.groupby(\"month\")[\"temp\"].mean()\n", + "r, p = pearsonr(tm, mc[\"P03\"]); rows.append((\"월평균 기온-축전지 고장\", r, p))\n", + "cor = pd.DataFrame(rows, columns=[\"피처\", \"r\", \"p\"])\n", + "cor[\"유의\"] = cor[\"p\"] < 0.05\n", + "print(\"\\n[상관분석] 피처 | r | p-value | 유의(p<0.05)\")\n", + "for x in cor.itertuples():\n", + " print(f\" {x.피처:14s} {x.r:+.2f} {x.p:.4f} {'유의' if x.유의 else '-'}\")\n", + "nsig = int(cor[\"유의\"].sum())\n", + "\n", + "# 4) EDA 차트 8종\n", + "fig, ax = plt.subplots(2, 4, figsize=(16, 7))\n", + "ax[0, 0].bar(par.index, par[\"고장률\"]); ax[0, 0].set_title(\"1 Failure rate per 1000h\")\n", + "ax[0, 1].plot(range(len(par)), par[\"누적비율\"], marker=\"o\"); ax[0, 1].axhline(0.8, ls=\"--\", color=\"k\"); ax[0, 1].set_title(\"2 Pareto cumulative share\")\n", + "mc.sum(axis=1).plot(ax=ax[0, 2]); ax[0, 2].set_title(\"3 Monthly failures\")\n", + "ev[\"codes\"].str.len().value_counts().sort_index().plot.bar(ax=ax[0, 3]); ax[0, 3].set_title(\"4 DTCs per maintenance case\")\n", + "log[\"code\"].value_counts().head(10).plot.bar(ax=ax[1, 0]); ax[1, 0].set_title(\"5 Top DTC codes\")\n", + "ev[\"part\"].map(PSYS).value_counts().plot.bar(ax=ax[1, 1]); ax[1, 1].set_title(\"6 Failures by system\")\n", + "ax[1, 2].scatter(fleet[\"age\"], vc, s=8); ax[1, 2].set_title(\"7 Vehicle age vs failures\")\n", + "ax[1, 3].scatter(fleet[\"pm_gap\"], vc, s=8); ax[1, 3].set_title(\"8 PM gap vs failures\")\n", + "plt.tight_layout(); plt.show()\n", + "\n", + "print(f\"\\nEDA 차트 8종 생성(목표 8종 이상: 달성), 유의한 피처 {nsig}개 (목표 5개 이상: {'달성' if nsig >= 5 else '미달'})\")\n", + "print(\"핵심 발견: 1) 소수 부품에 고장 집중 2) 공유·증상 코드가 첫 기록이면 Baseline이 자주 틀림 3) 고령·가혹 지형·예방정비 지연 차량의 고장 증가\")\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 03 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + "[교육용가상자료] 정비 건 10681건, DTC 로그 22079행, 차량 100대, 18개월\n", + "Baseline 부품 정확도 68.7%, 고장 계통 정확도 96.8% (검증 3890건)\n", + " 모호한 코드(공유·증상) 첫 기록 건 정확도 59.7% vs 고유 코드 80.6%\n", + "\n", + "[파레토] 총 가동 171,649시간, 전체 고장률 62.2건/1000h\n", + " 부품 건수 고장률 누적비율 등급\n", + "P01 연료분사기 2800 16.312 26% A\n", + "P02 터보차저 1985 11.564 45% A\n", + "P03 축전지 1983 11.553 63% A\n", + "P04 발전기 1244 7.247 75% A\n", + "P05 변속기 제어모듈 665 3.874 81% B\n", + "P06 냉각수 펌프 477 2.779 86% B\n", + "P07 유압 펌프 390 2.272 89% B\n", + "상위 20% 품목(4종: 연료분사기, 터보차저, 축전지, 발전기)이 전체 고장의 75%, A등급 4종\n", + "\n", + "[상관분석] 피처 | r | p-value | 유의(p<0.05)\n", + " 차령 +0.41 0.0000 유의\n", + " 예방정비 경과시간 +0.57 0.0000 유의\n", + " 지형 가혹도 +0.40 0.0000 유의\n", + " 운용 부하 +0.35 0.0004 유의\n", + " 총 가동시간 +0.32 0.0011 유의\n", + " 습도 지수 +0.07 0.5174 -\n", + " 월평균 기온-축전지 고장 -0.95 0.0000 유의\n", + "\n", + "EDA 차트 8종 생성(목표 8종 이상: 달성), 유의한 피처 6개 (목표 5개 이상: 달성)\n", + "핵심 발견: 1) 소수 부품에 고장 집중 2) 공유·증상 코드가 첫 기록이면 Baseline이 자주 틀림 3) 고령·가혹 지형·예방정비 지연 차량의 고장 증가\n", + "```\n", + "
\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 04. 연관 규칙 마이닝(Apriori/FP-Growth) 기반 다중 DTC 동시 발생 시 근본 원인 예측 모델 고도화\n", + "**소요시간**: 약 3.0시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "연관 규칙 마이닝 기반 다중 DTC 동시 발생 시 근본 원인 예측 모델을 고도화한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **연관 규칙 마이닝(Apriori)으로 다중 DTC 동시 발생 시 근본 원인 부품을 예측하는 모델을 고도화하고, 월간 고장 건수 기준 모델의 파생 피처를 설계한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **동시 발생 DTC 세트와 근본 원인 부품을 항목으로 보고 최소 지지도 미만을 가지치기하며 빈발 항목집합과 연관 규칙(신뢰도·향상도)을 구 수행하고 검증하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `src/features/mnt03_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": [ + "\"\"\"Apriori 연관 규칙 마이닝으로 여러 DTC가 동시에 나타났을 때의 근본 원인 부품을 예측하는 모델을 만들어 단일 코드 Baseline과 비교하고, 부품별 월간 고장 건수 예측용 파생 피처 10개 이상으로 기준 모델을 학습해 시계열 교차검증 MAPE와 피처 중요도를 확인한다.\"\"\"\n", + "import itertools\n", + "from collections import Counter\n", + "import numpy as np\n", + "import pandas as pd\n", + "import matplotlib.pyplot as plt\n", + "from sklearn.linear_model import Ridge\n", + "from sklearn.preprocessing import StandardScaler\n", + "from sklearn.pipeline import make_pipeline\n", + "from sklearn.inspection import permutation_importance\n", + "\n", + "# ---------------------------------------------------------------------------\n", + "# [교육용가상자료] 가상 차량 100대의 18개월 고장 진단 코드(DTC)·정비 이력 생성기 (실제 부대·장비·정비 실적 없음)\n", + "# 부품 16종(1000가동시간당 고장률 차등), DTC 28종(부품 고유 코드, 짝 부품 공유 코드, 계통 증상 코드)\n", + "# 고장 1건(정비 이력)마다 DTC가 1~5개 동시 발생하고, 연쇄 고장 코드와 일시 잡음 코드가 섞인다.\n", + "# ---------------------------------------------------------------------------\n", + "PARTS = [ # (ID, 이름, 계통, 1000가동시간당 고장률, 가상 단가지수, 수리시간 h)\n", + " (\"P01\", \"연료분사기\", \"engine\", 12.0, 3.0, 2.0), (\"P02\", \"터보차저\", \"engine\", 9.0, 8.0, 5.0),\n", + " (\"P03\", \"축전지\", \"electric\", 6.5, 4.0, 1.5), (\"P04\", \"발전기\", \"electric\", 4.0, 9.0, 3.0),\n", + " (\"P05\", \"변속기 제어모듈\", \"drive\", 3.0, 12.0, 6.0), (\"P06\", \"냉각수 펌프\", \"engine\", 2.2, 3.5, 3.0),\n", + " (\"P07\", \"유압 펌프\", \"hydraulic\", 1.6, 10.0, 4.0), (\"P08\", \"속도 센서\", \"drive\", 1.2, 2.0, 1.5),\n", + " (\"P09\", \"ECU 커넥터\", \"electric\", 0.9, 2.5, 1.5), (\"P10\", \"연료 필터\", \"engine\", 0.7, 0.8, 0.7),\n", + " (\"P11\", \"제동 밸브\", \"brake\", 0.5, 6.0, 3.0), (\"P12\", \"조향 액추에이터\", \"drive\", 0.4, 9.0, 5.0),\n", + " (\"P13\", \"공기 필터\", \"engine\", 0.3, 0.5, 0.5), (\"P14\", \"통신 인터페이스 모듈\", \"electric\", 0.25, 3.0, 1.5),\n", + " (\"P15\", \"윤활유 압력 센서\", \"engine\", 0.2, 1.0, 1.0), (\"P16\", \"팬 구동 모터\", \"cooling\", 0.15, 2.0, 2.0)]\n", + "PID = [p[0] for p in PARTS]\n", + "PNAME = {p[0]: p[1] for p in PARTS}; PSYS = {p[0]: p[2] for p in PARTS}; RATE = {p[0]: p[3] for p in PARTS}\n", + "COST = {p[0]: p[4] for p in PARTS}; REPH = {p[0]: p[5] for p in PARTS}\n", + "PAIRS = [(\"P01\", \"P02\"), (\"P03\", \"P04\"), (\"P05\", \"P08\"), (\"P06\", \"P10\"), (\"P09\", \"P14\"), (\"P13\", \"P15\")]\n", + "SHARED = {p: f\"S{k + 1:02d}\" for k, pr in enumerate(PAIRS) for p in pr} # 짝 부품이 함께 쓰는 모호한 코드\n", + "SPEC = {p: f\"C{p[1:]}1\" for p in PID} # 부품 고유 코드\n", + "SYS = [\"engine\", \"electric\", \"drive\", \"hydraulic\", \"brake\", \"cooling\"]\n", + "SYMP = {s: f\"G{i + 1:02d}\" for i, s in enumerate(SYS)} # 계통 증상 코드\n", + "CODES = sorted(set(SPEC.values()) | set(SHARED.values()) | set(SYMP.values()))\n", + "CASCADE = {\"P01\": [(\"P10\", 0.20)], \"P02\": [(\"P06\", 0.20)], \"P03\": [(\"P04\", 0.25)], \"P04\": [(\"P09\", 0.25)],\n", + " \"P05\": [(\"P08\", 0.30)], \"P06\": [(\"P16\", 0.30)], \"P09\": [(\"P14\", 0.20)], \"P12\": [(\"P07\", 0.20)]}\n", + "CRIT_SYS = {\"engine\", \"drive\", \"brake\"} # 기동·제동 불능 위험 계통(경보 대상)\n", + "MONTHS = 18\n", + "\n", + "\n", + "def make_fleet(n=100, seed=0):\n", + " rng = np.random.default_rng(seed)\n", + " f = pd.DataFrame({\"veh\": [f\"V{i:03d}\" for i in range(n)], \"age\": rng.integers(1, 26, n).astype(float),\n", + " \"terrain\": rng.uniform(0, 1, n), \"load\": rng.uniform(0, 1, n), \"pm_gap\": rng.uniform(20, 200, n),\n", + " \"humid\": rng.uniform(0, 1, n), \"base_h\": rng.uniform(55, 125, n)})\n", + " f[\"mult\"] = np.exp(0.045 * (f[\"age\"] - 13) + 0.9 * (f[\"terrain\"] - .5) + 0.8 * (f[\"load\"] - .5)\n", + " + 0.006 * (f[\"pm_gap\"] - 110) + 0.7 * (f[\"humid\"] - .5))\n", + " return f\n", + "\n", + "\n", + "def make_data(seed=1, months=MONTHS):\n", + " \"\"\"반환: fleet(차량 속성), ev(정비 이력 1건=1행: 발생 DTC 목록, 실제 고장 부품), log(DTC 발생 로그, 행=코드 1건)\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " fleet = make_fleet()\n", + " V = len(fleet)\n", + " temp = 12 + 14 * np.sin(2 * np.pi * (np.arange(months) - 3) / 12) # 월평균 기온(가상)\n", + " hours = fleet[\"base_h\"].values[:, None] * (1 + 0.25 * np.sin(2 * np.pi * np.arange(months) / 12 + 1)) * rng.lognormal(0, .12, (V, months))\n", + " fleet[\"hours\"] = hours.sum(axis=1) # 차량별 18개월 총 가동시간\n", + " fleet.attrs[\"month_h\"] = hours.sum(axis=0) # 월별 전체 가동시간(고장률 분모)\n", + " evs = []\n", + " for p in PID:\n", + " s = np.ones(months)\n", + " if p in (\"P03\", \"P04\"): s = np.exp(-0.06 * (temp - 12)) # 한랭기 전기계통 증가\n", + " if p in (\"P06\", \"P16\"): s = np.exp(0.05 * (temp - 12)) # 혹서기 냉각계통 증가\n", + " cnt = rng.poisson(RATE[p] * hours / 1000 * fleet[\"mult\"].values[:, None] * s[None, :])\n", + " for v, m in zip(*np.nonzero(cnt)):\n", + " for _ in range(cnt[v, m]):\n", + " evs.append((fleet[\"veh\"][v], int(m), m * 720 + rng.uniform(0, 720), p, float(temp[m] + rng.normal(0, 3))))\n", + " ev = pd.DataFrame(evs, columns=[\"veh\", \"month\", \"t\", \"part\", \"temp\"]).sort_values(\"t\").reset_index(drop=True)\n", + " ev[\"ev_id\"] = [f\"W{i:05d}\" for i in range(len(ev))]\n", + " rows, codes = [], []\n", + " for e in ev.itertuples():\n", + " c, r = [], rng.random(3)\n", + " if r[0] < 0.6: c.append(SPEC[e.part])\n", + " if e.part in SHARED and r[1] < 0.6: c.append(SHARED[e.part])\n", + " if r[2] < 0.5: c.append(SYMP[PSYS[e.part]])\n", + " if not c: c.append(SHARED.get(e.part, SPEC[e.part]))\n", + " for q, pr in CASCADE.get(e.part, []):\n", + " if rng.random() < pr: c.append(SPEC[q])\n", + " if rng.random() < 0.12: c.append(SPEC[PID[rng.integers(0, 16)]])\n", + " c = list(dict.fromkeys(c))\n", + " rng.shuffle(c) # 동시 발생 코드의 기록 순서는 임의\n", + " codes.append(c)\n", + " for k, cd in enumerate(c):\n", + " rows.append((e.ev_id, e.veh, e.t + k * 0.1, cd))\n", + " ev[\"codes\"] = codes\n", + " log = pd.DataFrame(rows, columns=[\"ev_id\", \"veh\", \"ts\", \"code\"])\n", + " return fleet, ev, log\n", + "\n", + "# ---------------------------------------------------------------------------\n", + "# 공통 분석 조각: 코드 멀티핫 행렬, 차량 맥락 피처, 학습/검증 분할(앞 12개월 학습, 뒤 6개월 검증)\n", + "# ---------------------------------------------------------------------------\n", + "CIDX = {c: i for i, c in enumerate(CODES)}\n", + "SPLIT_M = 12\n", + "\n", + "\n", + "def multi_hot(ev):\n", + " # [TODO : 학생 실습 4] 연관 규칙 마이닝(Apriori/FP-Growth) 기반 다중 DTC 동시 발생 시 근본 원인 예측 모델 고도화 관련 핵심 로직을 구현하세요.\n", + " # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n", + " # <<< 여기에 코드를 작성하세요 >>>\n", + " pass # 작성 후 pass를 제거하세요.\n", + "def context(ev, fleet):\n", + " # [TODO : 학생 실습 4] 연관 규칙 마이닝(Apriori/FP-Growth) 기반 다중 DTC 동시 발생 시 근본 원인 예측 모델 고도화 관련 핵심 로직을 구현하세요.\n", + " # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n", + " # <<< 여기에 코드를 작성하세요 >>>\n", + " pass # 작성 후 pass를 제거하세요.\n", + "def feats(ev, fleet):\n", + " X = multi_hot(ev)\n", + " n = X.sum(axis=1, keepdims=True) / 3\n", + " return np.hstack([X, n, context(ev, fleet)])\n", + "\n", + "\n", + "def monthly_counts(ev):\n", + " return ev.groupby([\"month\", \"part\"]).size().unstack(fill_value=0).reindex(columns=PID, fill_value=0)\n", + "\n", + "\n", + "def build_monthly(ev, fleet, top_n=8):\n", + " \"\"\"부품별 월간 고장 건수 예측용 표: 지연값(lag), 이동평균, 추세, 기대 건수(가동시간 x 설계 고장률), 기온 상호작용, 계절 성분\"\"\"\n", + " mc = ev.groupby([\"month\", \"part\"]).size().unstack(fill_value=0)\n", + " top = ev[\"part\"].value_counts().index[:top_n]\n", + " mh, tm = fleet.attrs[\"month_h\"], ev.groupby(\"month\")[\"temp\"].mean().values\n", + " rows = []\n", + " for p in top:\n", + " c = mc[p].values.astype(float)\n", + " for m in range(3, MONTHS):\n", + " L = np.log1p(c)\n", + " rows.append({\"part\": p, \"month\": m, \"y\": c[m], \"lag1\": L[m - 1], \"lag2\": L[m - 2], \"lag3\": L[m - 3], \"roll3\": L[m - 3:m].mean(),\n", + " \"trend\": L[m - 1] - L[m - 2], \"roll3_std\": L[m - 3:m].std(), \"log_expected\": np.log(mh[m] / 1000 * RATE[p]),\n", + " \"rate_lag1\": c[m - 1] / (mh[m - 1] / 1000) / RATE[p], \"temp\": tm[m] / 10, \"cold_elec\": max(0, 12 - tm[m]) / 10 * (p in (\"P03\", \"P04\")),\n", + " \"heat_cool\": max(0, tm[m] - 12) / 10 * (p in (\"P06\", \"P16\")), \"sin\": np.sin(2 * np.pi * m / 12), \"cos\": np.cos(2 * np.pi * m / 12)})\n", + " D = pd.DataFrame(rows)\n", + " return D, [c for c in D.columns if c not in (\"part\", \"month\", \"y\")], top\n", + "\n", + "fleet, ev, log = make_data()\n", + "ev[\"first\"] = ev[\"codes\"].str[0]\n", + "train, test = ev[ev[\"month\"] < 12], ev[ev[\"month\"] >= 12]\n", + "\n", + "\n", + "# 1) Apriori: 항목 = DTC 코드 + 근본 원인 항목(ROOT:부품). 수준별로 후보를 만들고 최소 지지도 미만은 가지치기한다.\n", + "def apriori(trans, min_sup, max_len=3):\n", + " n = len(trans)\n", + " cnt = Counter(i for t in trans for i in t)\n", + " freq = {frozenset([i]): c / n for i, c in cnt.items() if c / n >= min_sup}\n", + " allf, cur = dict(freq), list(freq)\n", + " for k in range(2, max_len + 1):\n", + " cands = {a | b for a, b in itertools.combinations(cur, 2) if len(a | b) == k}\n", + " cands = {c for c in cands if all(frozenset(s) in allf for s in itertools.combinations(c, k - 1))} # 부분집합 가지치기\n", + " cc = Counter(c for t in trans for c in cands if c <= t)\n", + " cur = [c for c, v in cc.items() if v / n >= min_sup]\n", + " allf.update({c: cc[c] / n for c in cur})\n", + " return allf\n", + "\n", + "\n", + "trans = [frozenset(cs) | {\"ROOT:\" + p} for cs, p in zip(train[\"codes\"], train[\"part\"])]\n", + "F = apriori(trans, min_sup=0.004)\n", + "rules = []\n", + "for s, sup in F.items():\n", + " roots = [i for i in s if i.startswith(\"ROOT:\")]\n", + " if len(roots) == 1 and len(s) >= 2:\n", + " ante = s - set(roots)\n", + " conf = sup / F[ante]\n", + " lift = conf / F[frozenset(roots)]\n", + " rules.append({\"ante\": ante, \"part\": roots[0][5:], \"sup\": sup, \"conf\": conf, \"lift\": lift})\n", + "R = pd.DataFrame(rules)\n", + "R = R[(R[\"conf\"] >= 0.5) & (R[\"lift\"] > 1.5)].sort_values(\"conf\", ascending=False).reset_index(drop=True)\n", + "print(f\"[교육용가상자료] 학습 정비 건 {len(train)}건: 빈발 항목집합 {len(F)}개 -> 연관 규칙 {len(R)}개 (신뢰도 0.5 이상, 향상도 1.5 초과)\")\n", + "for r in R.head(4).itertuples():\n", + " print(f\" {{{', '.join(sorted(r.ante))}}} -> {PNAME[r.part]} 지지도 {r.sup:.3f} 신뢰도 {r.conf:.2f} 향상도 {r.lift:.1f}\")\n", + "\n", + "mp = train.groupby(\"first\")[\"part\"].agg(lambda s: s.value_counts().index[0])\n", + "rl = sorted(zip(R[\"ante\"], R[\"part\"], R[\"conf\"]), key=lambda x: (-x[2], -len(x[0])))\n", + "\n", + "\n", + "def rule_predict(codes, fallback):\n", + " for a, p, c in rl:\n", + " if a <= set(codes):\n", + " return p, c\n", + " return fallback, 0.0\n", + "\n", + "\n", + "pr = [rule_predict(cs, mp.get(f, \"P01\")) for cs, f in zip(test[\"codes\"], test[\"first\"])]\n", + "pred_rule = np.array([p for p, _ in pr]); pred_base = test[\"first\"].map(mp).values\n", + "a_b, a_r = (pred_base == test[\"part\"].values).mean(), (pred_rule == test[\"part\"].values).mean()\n", + "multi = test[\"codes\"].str.len().values >= 2\n", + "print(f\"\\n근본 원인 정확도: 단일 코드 Baseline {a_b:.1%} -> 연관 규칙 {a_r:.1%} (코드 2개 이상 동시 발생 건 {(pred_base[multi] == test['part'].values[multi]).mean():.1%} -> {(pred_rule[multi] == test['part'].values[multi]).mean():.1%})\")\n", + "\n", + "# 2) 파생 피처 + 월간 고장 건수 기준 모델(상위 8개 부품, 건수는 로그 변환해 학습)\n", + "D, FE, top = build_monthly(ev, fleet)\n", + "print(f\"\\n월간 고장 건수 예측: 파생 피처 {len(FE)}개 (목표 10개 이상: {'달성' if len(FE) >= 10 else '미달'}), 대상 {len(top)}개 부품 x {MONTHS - 3}개월\")\n", + "\n", + "\n", + "def model():\n", + " return make_pipeline(StandardScaler(), Ridge(alpha=3.0))\n", + "\n", + "\n", + "def mape(a, p):\n", + " return float(np.mean(np.abs(a - p) / a))\n", + "\n", + "\n", + "# 3) 시계열 교차검증: 확장 윈도(과거로만 학습, 뒤 2개월 검증)\n", + "folds, mapes = [(range(3, s), range(s, s + 2)) for s in (11, 13, 15)], []\n", + "for tr, te in folds:\n", + " a, b = D[D[\"month\"].isin(tr)], D[D[\"month\"].isin(te)]\n", + " mapes.append(mape(b[\"y\"].values, np.exp(model().fit(a[FE], np.log(a[\"y\"])).predict(b[FE]))))\n", + "cv = float(np.mean(mapes))\n", + "print(\"시계열 교차검증 폴드별 MAPE: \" + \", \".join(f\"{m:.1%}\" for m in mapes) + f\" -> 평균 {cv:.1%} (목표 25% 이하: {'달성' if cv <= 0.25 else '미달'})\")\n", + "\n", + "# 4) 피처 중요도(순열 중요도)로 차원 축소\n", + "a, b = D[D[\"month\"] < 13], D[D[\"month\"] >= 13]\n", + "m = model().fit(a[FE], np.log(a[\"y\"]))\n", + "imp = permutation_importance(m, b[FE], np.log(b[\"y\"]), n_repeats=20, random_state=0, scoring=\"neg_mean_absolute_error\").importances_mean\n", + "order = np.argsort(-imp)\n", + "keep = [FE[i] for i in order[:8]]\n", + "m5 = model().fit(a[keep], np.log(a[\"y\"]))\n", + "full, red = mape(b[\"y\"].values, np.exp(m.predict(b[FE]))), mape(b[\"y\"].values, np.exp(m5.predict(b[keep])))\n", + "print(\"피처 중요도 상위 5: \" + \", \".join(f\"{FE[i]}({imp[i]:.2f})\" for i in order[:5]))\n", + "print(f\"차원 축소 {len(FE)}개 -> 8개: 검증 MAPE {full:.1%} -> {red:.1%}\")\n", + "\n", + "fig, ax = plt.subplots(1, 2, figsize=(10, 3.5))\n", + "ax[0].bar([\"baseline\", \"rules\"], [a_b, a_r]); ax[0].set_title(\"Root-cause accuracy\")\n", + "ax[1].barh([FE[i] for i in order[:8]][::-1], imp[order[:8]][::-1]); ax[1].set_title(\"Permutation importance (log MAE)\")\n", + "plt.tight_layout(); plt.show()\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 04 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + "[교육용가상자료] 학습 정비 건 6791건: 빈발 항목집합 188개 -> 연관 규칙 57개 (신뢰도 0.5 이상, 향상도 1.5 초과)\n", + " {G04} -> 유압 펌프 지지도 0.020 신뢰도 1.00 향상도 29.1\n", + " {G05} -> 제동 밸브 지지도 0.007 신뢰도 1.00 향상도 87.1\n", + " {C051, G03} -> 변속기 제어모듈 지지도 0.021 신뢰도 1.00 향상도 15.5\n", + " {C071, G04} -> 유압 펌프 지지도 0.013 신뢰도 1.00 향상도 29.1\n", + "\n", + "근본 원인 정확도: 단일 코드 Baseline 68.7% -> 연관 규칙 78.3% (코드 2개 이상 동시 발생 건 67.1% -> 81.1%)\n", + "\n", + "월간 고장 건수 예측: 파생 피처 13개 (목표 10개 이상: 달성), 대상 8개 부품 x 15개월\n", + "시계열 교차검증 폴드별 MAPE: 28.9%, 17.3%, 17.8% -> 평균 21.3% (목표 25% 이하: 달성)\n", + "피처 중요도 상위 5: lag1(0.21), log_expected(0.18), lag2(0.14), cold_elec(0.09), rate_lag1(0.07)\n", + "차원 축소 13개 -> 8개: 검증 MAPE 18.6% -> 21.6%\n", + "```\n", + "
\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 05. DTC 기반 정비 조치 가이드라인 및 추천 신뢰도 점수 산출 엔진 개발\n", + "**소요시간**: 약 3.5시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "DTC 기반 정비 조치 가이드라인 및 추천 신뢰도 점수 산출 엔진을 개발한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **DTC 입력으로 정비 조치 가이드라인과 추천 신뢰도 점수를 산출하는 엔진을 개발하고 기준 모델 대비 개선을 확인한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **코드 유형별 가중치를 둔 고장 부품 확률 모델로 상위 3개 후보와 신뢰도를 내고, 후보별 정비 조치 절차를 진단 카드로 출력 수행하고 검증하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `src/models/mnt03_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": [ + "\"\"\"DTC 조합과 차량 맥락을 입력받아 후보 고장 부품 3개와 추천 신뢰도 점수, 정비 조치 가이드라인을 산출하는 추천 엔진을 개발하고, 신뢰도가 실제 정확도와 맞는지(보정) 확인한다. 부품별 월간 고장 건수 예측은 전월값 기준 모델 대비 릿지·그래디언트 부스팅 앙상블의 MAE/RMSE 개선율과 R2로 평가한다.\"\"\"\n", + "import numpy as np\n", + "import pandas as pd\n", + "import matplotlib.pyplot as plt\n", + "from sklearn.linear_model import LogisticRegression, Ridge\n", + "from sklearn.ensemble import GradientBoostingRegressor\n", + "from sklearn.preprocessing import StandardScaler\n", + "from sklearn.pipeline import make_pipeline\n", + "from sklearn.metrics import mean_absolute_error, mean_squared_error, r2_score\n", + "\n", + "# ---------------------------------------------------------------------------\n", + "# [교육용가상자료] 가상 차량 100대의 18개월 고장 진단 코드(DTC)·정비 이력 생성기 (실제 부대·장비·정비 실적 없음)\n", + "# 부품 16종(1000가동시간당 고장률 차등), DTC 28종(부품 고유 코드, 짝 부품 공유 코드, 계통 증상 코드)\n", + "# 고장 1건(정비 이력)마다 DTC가 1~5개 동시 발생하고, 연쇄 고장 코드와 일시 잡음 코드가 섞인다.\n", + "# ---------------------------------------------------------------------------\n", + "PARTS = [ # (ID, 이름, 계통, 1000가동시간당 고장률, 가상 단가지수, 수리시간 h)\n", + " (\"P01\", \"연료분사기\", \"engine\", 12.0, 3.0, 2.0), (\"P02\", \"터보차저\", \"engine\", 9.0, 8.0, 5.0),\n", + " (\"P03\", \"축전지\", \"electric\", 6.5, 4.0, 1.5), (\"P04\", \"발전기\", \"electric\", 4.0, 9.0, 3.0),\n", + " (\"P05\", \"변속기 제어모듈\", \"drive\", 3.0, 12.0, 6.0), (\"P06\", \"냉각수 펌프\", \"engine\", 2.2, 3.5, 3.0),\n", + " (\"P07\", \"유압 펌프\", \"hydraulic\", 1.6, 10.0, 4.0), (\"P08\", \"속도 센서\", \"drive\", 1.2, 2.0, 1.5),\n", + " (\"P09\", \"ECU 커넥터\", \"electric\", 0.9, 2.5, 1.5), (\"P10\", \"연료 필터\", \"engine\", 0.7, 0.8, 0.7),\n", + " (\"P11\", \"제동 밸브\", \"brake\", 0.5, 6.0, 3.0), (\"P12\", \"조향 액추에이터\", \"drive\", 0.4, 9.0, 5.0),\n", + " (\"P13\", \"공기 필터\", \"engine\", 0.3, 0.5, 0.5), (\"P14\", \"통신 인터페이스 모듈\", \"electric\", 0.25, 3.0, 1.5),\n", + " (\"P15\", \"윤활유 압력 센서\", \"engine\", 0.2, 1.0, 1.0), (\"P16\", \"팬 구동 모터\", \"cooling\", 0.15, 2.0, 2.0)]\n", + "PID = [p[0] for p in PARTS]\n", + "PNAME = {p[0]: p[1] for p in PARTS}; PSYS = {p[0]: p[2] for p in PARTS}; RATE = {p[0]: p[3] for p in PARTS}\n", + "COST = {p[0]: p[4] for p in PARTS}; REPH = {p[0]: p[5] for p in PARTS}\n", + "PAIRS = [(\"P01\", \"P02\"), (\"P03\", \"P04\"), (\"P05\", \"P08\"), (\"P06\", \"P10\"), (\"P09\", \"P14\"), (\"P13\", \"P15\")]\n", + "SHARED = {p: f\"S{k + 1:02d}\" for k, pr in enumerate(PAIRS) for p in pr} # 짝 부품이 함께 쓰는 모호한 코드\n", + "SPEC = {p: f\"C{p[1:]}1\" for p in PID} # 부품 고유 코드\n", + "SYS = [\"engine\", \"electric\", \"drive\", \"hydraulic\", \"brake\", \"cooling\"]\n", + "SYMP = {s: f\"G{i + 1:02d}\" for i, s in enumerate(SYS)} # 계통 증상 코드\n", + "CODES = sorted(set(SPEC.values()) | set(SHARED.values()) | set(SYMP.values()))\n", + "CASCADE = {\"P01\": [(\"P10\", 0.20)], \"P02\": [(\"P06\", 0.20)], \"P03\": [(\"P04\", 0.25)], \"P04\": [(\"P09\", 0.25)],\n", + " \"P05\": [(\"P08\", 0.30)], \"P06\": [(\"P16\", 0.30)], \"P09\": [(\"P14\", 0.20)], \"P12\": [(\"P07\", 0.20)]}\n", + "CRIT_SYS = {\"engine\", \"drive\", \"brake\"} # 기동·제동 불능 위험 계통(경보 대상)\n", + "MONTHS = 18\n", + "\n", + "\n", + "def make_fleet(n=100, seed=0):\n", + " rng = np.random.default_rng(seed)\n", + " f = pd.DataFrame({\"veh\": [f\"V{i:03d}\" for i in range(n)], \"age\": rng.integers(1, 26, n).astype(float),\n", + " \"terrain\": rng.uniform(0, 1, n), \"load\": rng.uniform(0, 1, n), \"pm_gap\": rng.uniform(20, 200, n),\n", + " \"humid\": rng.uniform(0, 1, n), \"base_h\": rng.uniform(55, 125, n)})\n", + " f[\"mult\"] = np.exp(0.045 * (f[\"age\"] - 13) + 0.9 * (f[\"terrain\"] - .5) + 0.8 * (f[\"load\"] - .5)\n", + " + 0.006 * (f[\"pm_gap\"] - 110) + 0.7 * (f[\"humid\"] - .5))\n", + " return f\n", + "\n", + "\n", + "def make_data(seed=1, months=MONTHS):\n", + " \"\"\"반환: fleet(차량 속성), ev(정비 이력 1건=1행: 발생 DTC 목록, 실제 고장 부품), log(DTC 발생 로그, 행=코드 1건)\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " fleet = make_fleet()\n", + " V = len(fleet)\n", + " temp = 12 + 14 * np.sin(2 * np.pi * (np.arange(months) - 3) / 12) # 월평균 기온(가상)\n", + " hours = fleet[\"base_h\"].values[:, None] * (1 + 0.25 * np.sin(2 * np.pi * np.arange(months) / 12 + 1)) * rng.lognormal(0, .12, (V, months))\n", + " fleet[\"hours\"] = hours.sum(axis=1) # 차량별 18개월 총 가동시간\n", + " fleet.attrs[\"month_h\"] = hours.sum(axis=0) # 월별 전체 가동시간(고장률 분모)\n", + " evs = []\n", + " for p in PID:\n", + " s = np.ones(months)\n", + " if p in (\"P03\", \"P04\"): s = np.exp(-0.06 * (temp - 12)) # 한랭기 전기계통 증가\n", + " if p in (\"P06\", \"P16\"): s = np.exp(0.05 * (temp - 12)) # 혹서기 냉각계통 증가\n", + " cnt = rng.poisson(RATE[p] * hours / 1000 * fleet[\"mult\"].values[:, None] * s[None, :])\n", + " for v, m in zip(*np.nonzero(cnt)):\n", + " for _ in range(cnt[v, m]):\n", + " evs.append((fleet[\"veh\"][v], int(m), m * 720 + rng.uniform(0, 720), p, float(temp[m] + rng.normal(0, 3))))\n", + " ev = pd.DataFrame(evs, columns=[\"veh\", \"month\", \"t\", \"part\", \"temp\"]).sort_values(\"t\").reset_index(drop=True)\n", + " ev[\"ev_id\"] = [f\"W{i:05d}\" for i in range(len(ev))]\n", + " rows, codes = [], []\n", + " for e in ev.itertuples():\n", + " c, r = [], rng.random(3)\n", + " if r[0] < 0.6: c.append(SPEC[e.part])\n", + " if e.part in SHARED and r[1] < 0.6: c.append(SHARED[e.part])\n", + " if r[2] < 0.5: c.append(SYMP[PSYS[e.part]])\n", + " if not c: c.append(SHARED.get(e.part, SPEC[e.part]))\n", + " for q, pr in CASCADE.get(e.part, []):\n", + " if rng.random() < pr: c.append(SPEC[q])\n", + " if rng.random() < 0.12: c.append(SPEC[PID[rng.integers(0, 16)]])\n", + " c = list(dict.fromkeys(c))\n", + " rng.shuffle(c) # 동시 발생 코드의 기록 순서는 임의\n", + " codes.append(c)\n", + " for k, cd in enumerate(c):\n", + " rows.append((e.ev_id, e.veh, e.t + k * 0.1, cd))\n", + " ev[\"codes\"] = codes\n", + " log = pd.DataFrame(rows, columns=[\"ev_id\", \"veh\", \"ts\", \"code\"])\n", + " return fleet, ev, log\n", + "\n", + "# ---------------------------------------------------------------------------\n", + "# 공통 분석 조각: 코드 멀티핫 행렬, 차량 맥락 피처, 학습/검증 분할(앞 12개월 학습, 뒤 6개월 검증)\n", + "# ---------------------------------------------------------------------------\n", + "CIDX = {c: i for i, c in enumerate(CODES)}\n", + "SPLIT_M = 12\n", + "\n", + "\n", + "def multi_hot(ev):\n", + " # [TODO : 학생 실습 5] DTC 기반 정비 조치 가이드라인 및 추천 신뢰도 점수 산출 엔진 개발 관련 핵심 로직을 구현하세요.\n", + " # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n", + " # <<< 여기에 코드를 작성하세요 >>>\n", + " pass # 작성 후 pass를 제거하세요.\n", + "def context(ev, fleet):\n", + " # [TODO : 학생 실습 5] DTC 기반 정비 조치 가이드라인 및 추천 신뢰도 점수 산출 엔진 개발 관련 핵심 로직을 구현하세요.\n", + " # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n", + " # <<< 여기에 코드를 작성하세요 >>>\n", + " pass # 작성 후 pass를 제거하세요.\n", + "def feats(ev, fleet):\n", + " X = multi_hot(ev)\n", + " n = X.sum(axis=1, keepdims=True) / 3\n", + " return np.hstack([X, n, context(ev, fleet)])\n", + "\n", + "\n", + "def monthly_counts(ev):\n", + " return ev.groupby([\"month\", \"part\"]).size().unstack(fill_value=0).reindex(columns=PID, fill_value=0)\n", + "\n", + "\n", + "def build_monthly(ev, fleet, top_n=8):\n", + " \"\"\"부품별 월간 고장 건수 예측용 표: 지연값(lag), 이동평균, 추세, 기대 건수(가동시간 x 설계 고장률), 기온 상호작용, 계절 성분\"\"\"\n", + " mc = ev.groupby([\"month\", \"part\"]).size().unstack(fill_value=0)\n", + " top = ev[\"part\"].value_counts().index[:top_n]\n", + " mh, tm = fleet.attrs[\"month_h\"], ev.groupby(\"month\")[\"temp\"].mean().values\n", + " rows = []\n", + " for p in top:\n", + " c = mc[p].values.astype(float)\n", + " for m in range(3, MONTHS):\n", + " L = np.log1p(c)\n", + " rows.append({\"part\": p, \"month\": m, \"y\": c[m], \"lag1\": L[m - 1], \"lag2\": L[m - 2], \"lag3\": L[m - 3], \"roll3\": L[m - 3:m].mean(),\n", + " \"trend\": L[m - 1] - L[m - 2], \"roll3_std\": L[m - 3:m].std(), \"log_expected\": np.log(mh[m] / 1000 * RATE[p]),\n", + " \"rate_lag1\": c[m - 1] / (mh[m - 1] / 1000) / RATE[p], \"temp\": tm[m] / 10, \"cold_elec\": max(0, 12 - tm[m]) / 10 * (p in (\"P03\", \"P04\")),\n", + " \"heat_cool\": max(0, tm[m] - 12) / 10 * (p in (\"P06\", \"P16\")), \"sin\": np.sin(2 * np.pi * m / 12), \"cos\": np.cos(2 * np.pi * m / 12)})\n", + " D = pd.DataFrame(rows)\n", + " return D, [c for c in D.columns if c not in (\"part\", \"month\", \"y\")], top\n", + "\n", + "# ---------------------------------------------------------------------------\n", + "# [교육용가상자료] 부품별 정비 조치 가이드라인(요약 3단계). 실제 정비 교범이 아니라 교육용으로 만든 예시 문구다.\n", + "# ---------------------------------------------------------------------------\n", + "GUIDE = {\n", + " \"P01\": \"연료 압력 측정 -> 분사기 누설·막힘 확인 -> 불량 시 분사기 교체\", \"P02\": \"흡기 압력·이물 점검 -> 축 유격 확인 -> 불량 시 터보차저 교체\",\n", + " \"P03\": \"단자 전압·부식 점검 -> 충전 계통 확인 -> 불량 시 축전지 교체\", \"P04\": \"발전 전압 측정 -> 벨트·배선 점검 -> 불량 시 발전기 교체\",\n", + " \"P05\": \"제어모듈 소프트웨어·커넥터 점검 -> 변속 이력 확인 -> 불량 시 모듈 교체\", \"P06\": \"냉각수량·누수 점검 -> 펌프 작동음 확인 -> 불량 시 펌프 교체\",\n", + " \"P07\": \"유압 오일량·압력 점검 -> 펌프 누설 확인 -> 불량 시 펌프 교체\", \"P08\": \"센서 배선·간극 점검 -> 신호 파형 확인 -> 불량 시 센서 교체\",\n", + " \"P09\": \"커넥터 접촉·부식 점검 -> 핀 상태 확인 -> 불량 시 커넥터 교체\", \"P10\": \"필터 오염도 점검 -> 연료 흐름 확인 -> 불량 시 필터 교체\",\n", + " \"P11\": \"제동 압력 점검 -> 밸브 작동 확인 -> 불량 시 밸브 교체(안전 확인 필수)\", \"P12\": \"조향 반응 점검 -> 액추에이터 작동 확인 -> 불량 시 교체(안전 확인 필수)\",\n", + " \"P13\": \"필터 차압 점검 -> 오염 확인 -> 불량 시 필터 교체\", \"P14\": \"통신 링크 상태 점검 -> 모듈 전원 확인 -> 불량 시 모듈 교체\",\n", + " \"P15\": \"압력 센서 신호 점검 -> 윤활유량 확인 -> 불량 시 센서 교체\", \"P16\": \"팬 모터 전류 점검 -> 회전 확인 -> 불량 시 모터 교체\"}\n", + "\n", + "fleet, ev, log = make_data()\n", + "train, test = ev[ev[\"month\"] < SPLIT_M], ev[ev[\"month\"] >= SPLIT_M]\n", + "\n", + "# 1) 추천 엔진: DTC 멀티핫 + 맥락 -> 부품별 확률(= 추천 신뢰도 점수)\n", + "Xtr, Xte = feats(train, fleet), feats(test, fleet)\n", + "clf = LogisticRegression(C=3, max_iter=500).fit(Xtr, train[\"part\"])\n", + "P = clf.predict_proba(Xte)\n", + "cls = clf.classes_\n", + "top1 = cls[P.argmax(axis=1)]\n", + "conf = P.max(axis=1)\n", + "y = test[\"part\"].values\n", + "top3 = np.mean([t in cls[np.argsort(-p)[:3]] for t, p in zip(y, P)])\n", + "mp = train.assign(first=train[\"codes\"].str[0]).groupby(\"first\")[\"part\"].agg(lambda s: s.value_counts().index[0])\n", + "base_acc = (test[\"codes\"].str[0].map(mp).values == y).mean()\n", + "print(f\"[교육용가상자료] 추천 엔진 검증 {len(test)}건: top-1 정확도 {(top1 == y).mean():.1%} (단일 코드 Baseline {base_acc:.1%}), top-3 포함률 {top3:.1%}\")\n", + "\n", + "# 2) 신뢰도 점수 보정: 신뢰도 구간별 실제 정확도\n", + "bins = [0, 0.5, 0.7, 0.9, 1.01]\n", + "lab = [\"~0.5\", \"0.5~0.7\", \"0.7~0.9\", \"0.9~\"]\n", + "cb = pd.cut(conf, bins, labels=lab, right=False)\n", + "cal = pd.DataFrame({\"conf\": conf, \"ok\": top1 == y, \"b\": cb}).groupby(\"b\", observed=True).agg(건수=(\"ok\", \"size\"), 평균신뢰도=(\"conf\", \"mean\"), 실제정확도=(\"ok\", \"mean\"))\n", + "print(\"\\n[신뢰도 보정] 구간 | 건수 | 평균 신뢰도 | 실제 정확도\")\n", + "for k, r in cal.iterrows():\n", + " print(f\" {k:8s} {int(r['건수']):5d} {r['평균신뢰도']:.2f} {r['실제정확도']:.2f}\")\n", + "ece = float((cal[\"건수\"] * (cal[\"평균신뢰도\"] - cal[\"실제정확도\"]).abs()).sum() / cal[\"건수\"].sum())\n", + "print(f\"기대 보정 오차(ECE) {ece:.3f}\")\n", + "\n", + "# 3) 정비 조치 가이드라인 카드(상위 후보 3개와 신뢰도)\n", + "print(\"\\n[진단 카드 예시] 정비 조치 가이드라인\")\n", + "for i in (int(np.argmin(np.abs(conf - 0.95))), int(np.argmin(np.abs(conf - 0.55)))):\n", + " order = np.argsort(-P[i])[:3]\n", + " print(f\" 입력 DTC {test['codes'].iloc[i]} -> \" + \", \".join(f\"{PNAME[cls[j]]} {P[i, j]:.0%}\" for j in order))\n", + " print(f\" 조치: {GUIDE[cls[order[0]]]}\" + (\"\" if conf[i] >= 0.7 else \" (신뢰도 낮음: 숙련 정비관 확인 권고)\"))\n", + "\n", + "# 4) 월간 고장 건수 예측: 기준(전월값) vs 고도화(릿지 + GBR 앙상블, 소규모 격자 탐색)\n", + "D, FE, top = build_monthly(ev, fleet)\n", + "a, b = D[D[\"month\"] < 13], D[D[\"month\"] >= 13]\n", + "ya, yb = np.log(a[\"y\"]), b[\"y\"].values\n", + "naive = np.expm1(b[\"lag1\"].values)\n", + "def ens(tr, te, al, dep):\n", + " g = GradientBoostingRegressor(n_estimators=150, max_depth=dep, learning_rate=0.05, random_state=0).fit(tr[FE], np.log(tr[\"y\"]))\n", + " r = make_pipeline(StandardScaler(), Ridge(alpha=al)).fit(tr[FE], np.log(tr[\"y\"]))\n", + " return np.exp(0.5 * g.predict(te[FE]) + 0.5 * r.predict(te[FE]))\n", + "\n", + "\n", + "inner_tr, inner_va = a[a[\"month\"] < 11], a[a[\"month\"] >= 11] # 하이퍼파라미터는 학습 구간 안에서만 선택(검증 구간 미사용)\n", + "score = {(al, dep): mean_absolute_error(inner_va[\"y\"], ens(inner_tr, inner_va, al, dep)) for al in (1, 3, 10) for dep in (2, 3)}\n", + "al, dep = min(score, key=score.get)\n", + "adv = ens(a, b, al, dep)\n", + "met = lambda p: (mean_absolute_error(yb, p), mean_squared_error(yb, p) ** 0.5, r2_score(yb, p))\n", + "m0, m1 = met(naive), met(adv)\n", + "print(f\"\\n[월간 고장 건수 예측] 검증 {len(b)}건(상위 {len(top)}개 부품 x 5개월), 선택 하이퍼파라미터 ridge alpha={al}, GBR depth={dep}\")\n", + "print(f\" 기준(전월값) MAE {m0[0]:.2f} RMSE {m0[1]:.2f} R2 {m0[2]:.3f}\")\n", + "print(f\" 고도화(앙상블) MAE {m1[0]:.2f} RMSE {m1[1]:.2f} R2 {m1[2]:.3f}\")\n", + "imp_mae, imp_rmse = 1 - m1[0] / m0[0], 1 - m1[1] / m0[1]\n", + "print(f\"개선율 MAE {imp_mae:.1%}, RMSE {imp_rmse:.1%} (목표 20% 이상: {'달성' if min(imp_mae, imp_rmse) >= 0.2 else '미달'}), R2 {m1[2]:.3f} (목표 0.85 이상: {'달성' if m1[2] >= 0.85 else '미달'})\")\n", + "\n", + "fig, ax = plt.subplots(1, 2, figsize=(10, 3.5))\n", + "ax[0].plot([0, 1], [0, 1], \"k--\"); ax[0].plot(cal[\"평균신뢰도\"], cal[\"실제정확도\"], marker=\"o\"); ax[0].set_title(\"Confidence calibration\")\n", + "ax[1].scatter(yb, adv, s=12, label=\"ensemble\"); ax[1].scatter(yb, naive, s=12, label=\"naive\"); ax[1].plot([0, yb.max()], [0, yb.max()], \"k--\")\n", + "ax[1].set_title(\"Monthly failure count: actual vs predicted\"); ax[1].legend()\n", + "plt.tight_layout(); plt.show()\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 05 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + "[교육용가상자료] 추천 엔진 검증 3890건: top-1 정확도 83.9% (단일 코드 Baseline 68.7%), top-3 포함률 99.5%\n", + "\n", + "[신뢰도 보정] 구간 | 건수 | 평균 신뢰도 | 실제 정확도\n", + " ~0.5 155 0.46 0.48\n", + " 0.5~0.7 1123 0.60 0.61\n", + " 0.7~0.9 387 0.78 0.74\n", + " 0.9~ 2225 0.99 1.00\n", + "기대 보정 오차(ECE) 0.014\n", + "\n", + "[진단 카드 예시] 정비 조치 가이드라인\n", + " 입력 DTC ['G01', 'C101', 'S01'] -> 연료분사기 95%, 터보차저 5%, 연료 필터 0%\n", + " 조치: 연료 압력 측정 -> 분사기 누설·막힘 확인 -> 불량 시 분사기 교체\n", + " 입력 DTC ['G01'] -> 연료분사기 55%, 터보차저 34%, 연료 필터 5%\n", + " 조치: 연료 압력 측정 -> 분사기 누설·막힘 확인 -> 불량 시 분사기 교체 (신뢰도 낮음: 숙련 정비관 확인 권고)\n", + "\n", + "[월간 고장 건수 예측] 검증 40건(상위 8개 부품 x 5개월), 선택 하이퍼파라미터 ridge alpha=10, GBR depth=3\n", + " 기준(전월값) MAE 15.50 RMSE 21.96 R2 0.860\n", + " 고도화(앙상블) MAE 10.62 RMSE 14.47 R2 0.939\n", + "개선율 MAE 31.5%, RMSE 34.1% (목표 20% 이상: 달성), R2 0.939 (목표 0.85 이상: 달성)\n", + "```\n", + "
\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 06. 정비병용 모바일/웹 고장 코드 진단 및 정비 조치 절차 대시보드 구현\n", + "**소요시간**: 약 3.0시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "정비병용 모바일/웹 고장 코드 진단 대시보드를 구현한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **정비병용 고장 코드 진단 화면(진단 카드와 웹 대시보드 구성)을 구현하고, 예측마다 기여도 상위 3개 인자와 자연어 요약을 자동 산출하는 설명가능성(XAI) 모듈을 만든다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **모델의 입력 인자별 기여도를 계산해 건마다 상위 3개 근거를 뽑고, 인자를 평균값으로 되돌렸을 때 예측 확률이 낮아지는지로 근거 유효성을 점검 수행하고 검증하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `src/explainability/mnt03_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": [ + "\"\"\"정비병이 모바일/웹 화면에서 DTC를 입력하면 후보 고장 부품·신뢰도·정비 조치 절차와 판단 근거(기여도 상위 3개 인자)를 보여 주는 진단 대시보드를 구현한다. 모든 예측 건에 상위 3개 인자를 자동 산출하고 근거 제거 시 확률이 실제로 떨어지는지 점검하며, 자연어 요약과 경보 현황 화면을 만든다.\"\"\"\n", + "import numpy as np\n", + "import pandas as pd\n", + "import matplotlib.pyplot as plt\n", + "from sklearn.linear_model import LogisticRegression\n", + "\n", + "# ---------------------------------------------------------------------------\n", + "# [교육용가상자료] 가상 차량 100대의 18개월 고장 진단 코드(DTC)·정비 이력 생성기 (실제 부대·장비·정비 실적 없음)\n", + "# 부품 16종(1000가동시간당 고장률 차등), DTC 28종(부품 고유 코드, 짝 부품 공유 코드, 계통 증상 코드)\n", + "# 고장 1건(정비 이력)마다 DTC가 1~5개 동시 발생하고, 연쇄 고장 코드와 일시 잡음 코드가 섞인다.\n", + "# ---------------------------------------------------------------------------\n", + "PARTS = [ # (ID, 이름, 계통, 1000가동시간당 고장률, 가상 단가지수, 수리시간 h)\n", + " (\"P01\", \"연료분사기\", \"engine\", 12.0, 3.0, 2.0), (\"P02\", \"터보차저\", \"engine\", 9.0, 8.0, 5.0),\n", + " (\"P03\", \"축전지\", \"electric\", 6.5, 4.0, 1.5), (\"P04\", \"발전기\", \"electric\", 4.0, 9.0, 3.0),\n", + " (\"P05\", \"변속기 제어모듈\", \"drive\", 3.0, 12.0, 6.0), (\"P06\", \"냉각수 펌프\", \"engine\", 2.2, 3.5, 3.0),\n", + " (\"P07\", \"유압 펌프\", \"hydraulic\", 1.6, 10.0, 4.0), (\"P08\", \"속도 센서\", \"drive\", 1.2, 2.0, 1.5),\n", + " (\"P09\", \"ECU 커넥터\", \"electric\", 0.9, 2.5, 1.5), (\"P10\", \"연료 필터\", \"engine\", 0.7, 0.8, 0.7),\n", + " (\"P11\", \"제동 밸브\", \"brake\", 0.5, 6.0, 3.0), (\"P12\", \"조향 액추에이터\", \"drive\", 0.4, 9.0, 5.0),\n", + " (\"P13\", \"공기 필터\", \"engine\", 0.3, 0.5, 0.5), (\"P14\", \"통신 인터페이스 모듈\", \"electric\", 0.25, 3.0, 1.5),\n", + " (\"P15\", \"윤활유 압력 센서\", \"engine\", 0.2, 1.0, 1.0), (\"P16\", \"팬 구동 모터\", \"cooling\", 0.15, 2.0, 2.0)]\n", + "PID = [p[0] for p in PARTS]\n", + "PNAME = {p[0]: p[1] for p in PARTS}; PSYS = {p[0]: p[2] for p in PARTS}; RATE = {p[0]: p[3] for p in PARTS}\n", + "COST = {p[0]: p[4] for p in PARTS}; REPH = {p[0]: p[5] for p in PARTS}\n", + "PAIRS = [(\"P01\", \"P02\"), (\"P03\", \"P04\"), (\"P05\", \"P08\"), (\"P06\", \"P10\"), (\"P09\", \"P14\"), (\"P13\", \"P15\")]\n", + "SHARED = {p: f\"S{k + 1:02d}\" for k, pr in enumerate(PAIRS) for p in pr} # 짝 부품이 함께 쓰는 모호한 코드\n", + "SPEC = {p: f\"C{p[1:]}1\" for p in PID} # 부품 고유 코드\n", + "SYS = [\"engine\", \"electric\", \"drive\", \"hydraulic\", \"brake\", \"cooling\"]\n", + "SYMP = {s: f\"G{i + 1:02d}\" for i, s in enumerate(SYS)} # 계통 증상 코드\n", + "CODES = sorted(set(SPEC.values()) | set(SHARED.values()) | set(SYMP.values()))\n", + "CASCADE = {\"P01\": [(\"P10\", 0.20)], \"P02\": [(\"P06\", 0.20)], \"P03\": [(\"P04\", 0.25)], \"P04\": [(\"P09\", 0.25)],\n", + " \"P05\": [(\"P08\", 0.30)], \"P06\": [(\"P16\", 0.30)], \"P09\": [(\"P14\", 0.20)], \"P12\": [(\"P07\", 0.20)]}\n", + "CRIT_SYS = {\"engine\", \"drive\", \"brake\"} # 기동·제동 불능 위험 계통(경보 대상)\n", + "MONTHS = 18\n", + "\n", + "\n", + "def make_fleet(n=100, seed=0):\n", + " rng = np.random.default_rng(seed)\n", + " f = pd.DataFrame({\"veh\": [f\"V{i:03d}\" for i in range(n)], \"age\": rng.integers(1, 26, n).astype(float),\n", + " \"terrain\": rng.uniform(0, 1, n), \"load\": rng.uniform(0, 1, n), \"pm_gap\": rng.uniform(20, 200, n),\n", + " \"humid\": rng.uniform(0, 1, n), \"base_h\": rng.uniform(55, 125, n)})\n", + " f[\"mult\"] = np.exp(0.045 * (f[\"age\"] - 13) + 0.9 * (f[\"terrain\"] - .5) + 0.8 * (f[\"load\"] - .5)\n", + " + 0.006 * (f[\"pm_gap\"] - 110) + 0.7 * (f[\"humid\"] - .5))\n", + " return f\n", + "\n", + "\n", + "def make_data(seed=1, months=MONTHS):\n", + " \"\"\"반환: fleet(차량 속성), ev(정비 이력 1건=1행: 발생 DTC 목록, 실제 고장 부품), log(DTC 발생 로그, 행=코드 1건)\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " fleet = make_fleet()\n", + " V = len(fleet)\n", + " temp = 12 + 14 * np.sin(2 * np.pi * (np.arange(months) - 3) / 12) # 월평균 기온(가상)\n", + " hours = fleet[\"base_h\"].values[:, None] * (1 + 0.25 * np.sin(2 * np.pi * np.arange(months) / 12 + 1)) * rng.lognormal(0, .12, (V, months))\n", + " fleet[\"hours\"] = hours.sum(axis=1) # 차량별 18개월 총 가동시간\n", + " fleet.attrs[\"month_h\"] = hours.sum(axis=0) # 월별 전체 가동시간(고장률 분모)\n", + " evs = []\n", + " for p in PID:\n", + " s = np.ones(months)\n", + " if p in (\"P03\", \"P04\"): s = np.exp(-0.06 * (temp - 12)) # 한랭기 전기계통 증가\n", + " if p in (\"P06\", \"P16\"): s = np.exp(0.05 * (temp - 12)) # 혹서기 냉각계통 증가\n", + " cnt = rng.poisson(RATE[p] * hours / 1000 * fleet[\"mult\"].values[:, None] * s[None, :])\n", + " for v, m in zip(*np.nonzero(cnt)):\n", + " for _ in range(cnt[v, m]):\n", + " evs.append((fleet[\"veh\"][v], int(m), m * 720 + rng.uniform(0, 720), p, float(temp[m] + rng.normal(0, 3))))\n", + " ev = pd.DataFrame(evs, columns=[\"veh\", \"month\", \"t\", \"part\", \"temp\"]).sort_values(\"t\").reset_index(drop=True)\n", + " ev[\"ev_id\"] = [f\"W{i:05d}\" for i in range(len(ev))]\n", + " rows, codes = [], []\n", + " for e in ev.itertuples():\n", + " c, r = [], rng.random(3)\n", + " if r[0] < 0.6: c.append(SPEC[e.part])\n", + " if e.part in SHARED and r[1] < 0.6: c.append(SHARED[e.part])\n", + " if r[2] < 0.5: c.append(SYMP[PSYS[e.part]])\n", + " if not c: c.append(SHARED.get(e.part, SPEC[e.part]))\n", + " for q, pr in CASCADE.get(e.part, []):\n", + " if rng.random() < pr: c.append(SPEC[q])\n", + " if rng.random() < 0.12: c.append(SPEC[PID[rng.integers(0, 16)]])\n", + " c = list(dict.fromkeys(c))\n", + " rng.shuffle(c) # 동시 발생 코드의 기록 순서는 임의\n", + " codes.append(c)\n", + " for k, cd in enumerate(c):\n", + " rows.append((e.ev_id, e.veh, e.t + k * 0.1, cd))\n", + " ev[\"codes\"] = codes\n", + " log = pd.DataFrame(rows, columns=[\"ev_id\", \"veh\", \"ts\", \"code\"])\n", + " return fleet, ev, log\n", + "\n", + "# ---------------------------------------------------------------------------\n", + "# 공통 분석 조각: 코드 멀티핫 행렬, 차량 맥락 피처, 학습/검증 분할(앞 12개월 학습, 뒤 6개월 검증)\n", + "# ---------------------------------------------------------------------------\n", + "CIDX = {c: i for i, c in enumerate(CODES)}\n", + "SPLIT_M = 12\n", + "\n", + "\n", + "def multi_hot(ev):\n", + " # [TODO : 학생 실습 6] 정비병용 모바일/웹 고장 코드 진단 및 정비 조치 절차 대시보드 구현 관련 핵심 로직을 구현하세요.\n", + " # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n", + " # <<< 여기에 코드를 작성하세요 >>>\n", + " pass # 작성 후 pass를 제거하세요.\n", + "def context(ev, fleet):\n", + " # [TODO : 학생 실습 6] 정비병용 모바일/웹 고장 코드 진단 및 정비 조치 절차 대시보드 구현 관련 핵심 로직을 구현하세요.\n", + " # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n", + " # <<< 여기에 코드를 작성하세요 >>>\n", + " pass # 작성 후 pass를 제거하세요.\n", + "def feats(ev, fleet):\n", + " X = multi_hot(ev)\n", + " n = X.sum(axis=1, keepdims=True) / 3\n", + " return np.hstack([X, n, context(ev, fleet)])\n", + "\n", + "\n", + "def monthly_counts(ev):\n", + " return ev.groupby([\"month\", \"part\"]).size().unstack(fill_value=0).reindex(columns=PID, fill_value=0)\n", + "\n", + "\n", + "def build_monthly(ev, fleet, top_n=8):\n", + " \"\"\"부품별 월간 고장 건수 예측용 표: 지연값(lag), 이동평균, 추세, 기대 건수(가동시간 x 설계 고장률), 기온 상호작용, 계절 성분\"\"\"\n", + " mc = ev.groupby([\"month\", \"part\"]).size().unstack(fill_value=0)\n", + " top = ev[\"part\"].value_counts().index[:top_n]\n", + " mh, tm = fleet.attrs[\"month_h\"], ev.groupby(\"month\")[\"temp\"].mean().values\n", + " rows = []\n", + " for p in top:\n", + " c = mc[p].values.astype(float)\n", + " for m in range(3, MONTHS):\n", + " L = np.log1p(c)\n", + " rows.append({\"part\": p, \"month\": m, \"y\": c[m], \"lag1\": L[m - 1], \"lag2\": L[m - 2], \"lag3\": L[m - 3], \"roll3\": L[m - 3:m].mean(),\n", + " \"trend\": L[m - 1] - L[m - 2], \"roll3_std\": L[m - 3:m].std(), \"log_expected\": np.log(mh[m] / 1000 * RATE[p]),\n", + " \"rate_lag1\": c[m - 1] / (mh[m - 1] / 1000) / RATE[p], \"temp\": tm[m] / 10, \"cold_elec\": max(0, 12 - tm[m]) / 10 * (p in (\"P03\", \"P04\")),\n", + " \"heat_cool\": max(0, tm[m] - 12) / 10 * (p in (\"P06\", \"P16\")), \"sin\": np.sin(2 * np.pi * m / 12), \"cos\": np.cos(2 * np.pi * m / 12)})\n", + " D = pd.DataFrame(rows)\n", + " return D, [c for c in D.columns if c not in (\"part\", \"month\", \"y\")], top\n", + "\n", + "# ---------------------------------------------------------------------------\n", + "# [교육용가상자료] 부품별 정비 조치 가이드라인(요약 3단계). 실제 정비 교범이 아니라 교육용으로 만든 예시 문구다.\n", + "# ---------------------------------------------------------------------------\n", + "GUIDE = {\n", + " \"P01\": \"연료 압력 측정 -> 분사기 누설·막힘 확인 -> 불량 시 분사기 교체\", \"P02\": \"흡기 압력·이물 점검 -> 축 유격 확인 -> 불량 시 터보차저 교체\",\n", + " \"P03\": \"단자 전압·부식 점검 -> 충전 계통 확인 -> 불량 시 축전지 교체\", \"P04\": \"발전 전압 측정 -> 벨트·배선 점검 -> 불량 시 발전기 교체\",\n", + " \"P05\": \"제어모듈 소프트웨어·커넥터 점검 -> 변속 이력 확인 -> 불량 시 모듈 교체\", \"P06\": \"냉각수량·누수 점검 -> 펌프 작동음 확인 -> 불량 시 펌프 교체\",\n", + " \"P07\": \"유압 오일량·압력 점검 -> 펌프 누설 확인 -> 불량 시 펌프 교체\", \"P08\": \"센서 배선·간극 점검 -> 신호 파형 확인 -> 불량 시 센서 교체\",\n", + " \"P09\": \"커넥터 접촉·부식 점검 -> 핀 상태 확인 -> 불량 시 커넥터 교체\", \"P10\": \"필터 오염도 점검 -> 연료 흐름 확인 -> 불량 시 필터 교체\",\n", + " \"P11\": \"제동 압력 점검 -> 밸브 작동 확인 -> 불량 시 밸브 교체(안전 확인 필수)\", \"P12\": \"조향 반응 점검 -> 액추에이터 작동 확인 -> 불량 시 교체(안전 확인 필수)\",\n", + " \"P13\": \"필터 차압 점검 -> 오염 확인 -> 불량 시 필터 교체\", \"P14\": \"통신 링크 상태 점검 -> 모듈 전원 확인 -> 불량 시 모듈 교체\",\n", + " \"P15\": \"압력 센서 신호 점검 -> 윤활유량 확인 -> 불량 시 센서 교체\", \"P16\": \"팬 모터 전류 점검 -> 회전 확인 -> 불량 시 모터 교체\"}\n", + "\n", + "SYSKR = {\"engine\": \"엔진\", \"electric\": \"전기\", \"drive\": \"구동\", \"hydraulic\": \"유압\", \"brake\": \"제동\", \"cooling\": \"냉각\"}\n", + "SHNAME = {SHARED[p[0]]: f\"{PNAME[p[0]]}·{PNAME[p[1]]} 공유\" for p in PAIRS}\n", + "\n", + "\n", + "def code_name(c):\n", + " if c.startswith(\"S\"):\n", + " return f\"공유 코드 {c}({SHNAME[c]})\"\n", + " if c.startswith(\"G\"):\n", + " return f\"{SYSKR[[s for s, v in SYMP.items() if v == c][0]]} 계통 증상 코드 {c}\"\n", + " return f\"{PNAME['P' + c[1:3]]} 고유 코드 {c}\"\n", + "\n", + "\n", + "FNAME = [code_name(c) for c in CODES] + [\"동시 발생 코드 수\", \"차령\", \"예방정비 경과시간\", \"지형 가혹도\", \"운용 부하\", \"기온\"]\n", + "NC = len(CODES)\n", + "\n", + "fleet, ev, log = make_data()\n", + "train, test = ev[ev[\"month\"] < SPLIT_M], ev[ev[\"month\"] >= SPLIT_M].reset_index(drop=True)\n", + "Xtr, Xte = feats(train, fleet), feats(test, fleet)\n", + "clf = LogisticRegression(C=3, max_iter=500).fit(Xtr, train[\"part\"])\n", + "P = clf.predict_proba(Xte)\n", + "cls, mu = clf.classes_, Xtr.mean(axis=0)\n", + "pred = P.argmax(axis=1)\n", + "\n", + "\n", + "def explain(i, k=3):\n", + " \"\"\"예측 부품에 대한 입력 인자별 기여도 = 계수 x (입력값 - 학습 평균). 값이 큰 인자 k개를 반환.\"\"\"\n", + " contrib = clf.coef_[pred[i]] * (Xte[i] - mu)\n", + " contrib[:NC][Xte[i][:NC] == 0] = -np.inf # 입력되지 않은 코드는 근거에서 제외\n", + " idx = np.argsort(-contrib)[:k]\n", + " return [(FNAME[j], contrib[j]) for j in idx], idx\n", + "\n", + "\n", + "def summary(i):\n", + " f, _ = explain(i)\n", + " return (f\"{PNAME[cls[pred[i]]]} 고장 가능성 {P[i, pred[i]]:.0%}. 주요 근거: \" + \", \".join(n for n, _ in f) + \".\")\n", + "\n", + "\n", + "# 1) 전 예측 건 기여도 자동 산출 + 근거 유효성 점검\n", + "ok3, drop = 0, 0\n", + "for i in range(len(test)):\n", + " f, idx = explain(i)\n", + " ok3 += len(f) == 3\n", + " x2 = Xte[i].copy(); x2[idx[0]] = mu[idx[0]] # 가장 큰 근거를 평균값으로 되돌려 본다\n", + " drop += clf.predict_proba(x2[None])[0, pred[i]] < P[i, pred[i]]\n", + "print(f\"[교육용가상자료] 예측 {len(test)}건 중 기여도 상위 3개 인자 산출 {ok3}건 (산출률 {ok3 / len(test):.0%}, 목표 100%: {'달성' if ok3 == len(test) else '미달'})\")\n", + "print(f\"근거 유효성: 상위 인자를 평균값으로 되돌리면 예측 확률이 낮아지는 비율 {drop / len(test):.1%}\")\n", + "\n", + "# 2) 모바일 진단 카드 2건: 신뢰도 높음 / 낮음\n", + "print(\"\\n[모바일 진단 카드]\")\n", + "for tgt in (0.95, 0.55):\n", + " i = int(np.argmin(np.abs(P.max(axis=1) - tgt)))\n", + " f, _ = explain(i)\n", + " print(f\" 입력 DTC {test['codes'][i]} (실제 고장 {PNAME[test['part'][i]]})\")\n", + " print(f\" 진단 {PNAME[cls[pred[i]]]} 신뢰도 {P[i, pred[i]]:.0%} | 근거: \" + \" / \".join(f\"{n}({v:+.1f})\" for n, v in f))\n", + " print(f\" 조치 절차: {GUIDE[cls[pred[i]]]}\")\n", + " print(f\" 요약: {summary(i)}\")\n", + "\n", + "# 3) 웹 대시보드: 최근 달 현황과 경보\n", + "last = test[test[\"month\"] == MONTHS - 1]\n", + "li = last.index.values\n", + "alert = [(PSYS[cls[pred[i]]] in CRIT_SYS) and P[i, pred[i]] >= 0.7 for i in li]\n", + "print(f\"\\n[웹 대시보드] 최근 달 정비 건 {len(last)}건, 기동·제동 계통 경보 {sum(alert)}건, 신뢰도 낮음(0.7 미만) {int((P[li].max(axis=1) < 0.7).sum())}건\")\n", + "cnt = pd.Series([cls[pred[i]] for i in li]).value_counts().head(3)\n", + "print(\" 다빈도 진단 부품: \" + \", \".join(f\"{PNAME[p]} {n}건\" for p, n in cnt.items()))\n", + "\n", + "fig, ax = plt.subplots(2, 2, figsize=(11, 7))\n", + "cnt5 = pd.Series([cls[pred[i]] for i in li]).value_counts().head(8)\n", + "ax[0, 0].bar(cnt5.index, cnt5.values); ax[0, 0].set_title(\"Diagnosed parts (last month)\")\n", + "mon = ev.groupby(\"month\").size(); ax[0, 1].plot(mon.index, mon.values, marker=\"o\"); ax[0, 1].set_title(\"Monthly maintenance cases\")\n", + "j = int(np.argmin(np.abs(P.max(axis=1) - 0.55))); f, _ = explain(j)\n", + "ax[1, 0].barh([f\"factor {k + 1}\" for k in range(3)][::-1], [v for _, v in f][::-1]); ax[1, 0].set_title(\"Top-3 contributions (low-confidence case)\")\n", + "ax[1, 1].hist(P.max(axis=1), bins=20); ax[1, 1].axvline(0.7, ls=\"--\", color=\"k\"); ax[1, 1].set_title(\"Confidence distribution (line = 0.7)\")\n", + "plt.tight_layout(); plt.show()\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 06 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + "[교육용가상자료] 예측 3890건 중 기여도 상위 3개 인자 산출 3890건 (산출률 100%, 목표 100%: 달성)\n", + "근거 유효성: 상위 인자를 평균값으로 되돌리면 예측 확률이 낮아지는 비율 92.5%\n", + "\n", + "[모바일 진단 카드]\n", + " 입력 DTC ['G01', 'C101', 'S01'] (실제 고장 연료분사기)\n", + " 진단 연료분사기 신뢰도 95% | 근거: 공유 코드 S01(연료분사기·터보차저 공유)(+4.3) / 엔진 계통 증상 코드 G01(+3.0) / 연료 필터 고유 코드 C101(+1.6)\n", + " 조치 절차: 연료 압력 측정 -> 분사기 누설·막힘 확인 -> 불량 시 분사기 교체\n", + " 요약: 연료분사기 고장 가능성 95%. 주요 근거: 공유 코드 S01(연료분사기·터보차저 공유), 엔진 계통 증상 코드 G01, 연료 필터 고유 코드 C101.\n", + " 입력 DTC ['G01'] (실제 고장 터보차저)\n", + " 진단 연료분사기 신뢰도 55% | 근거: 엔진 계통 증상 코드 G01(+3.0) / 기온(+0.1) / 운용 부하(+0.1)\n", + " 조치 절차: 연료 압력 측정 -> 분사기 누설·막힘 확인 -> 불량 시 분사기 교체\n", + " 요약: 연료분사기 고장 가능성 55%. 주요 근거: 엔진 계통 증상 코드 G01, 기온, 운용 부하.\n", + "\n", + "[웹 대시보드] 최근 달 정비 건 457건, 기동·제동 계통 경보 238건, 신뢰도 낮음(0.7 미만) 148건\n", + " 다빈도 진단 부품: 연료분사기 189건, 터보차저 72건, 냉각수 펌프 40건\n", + "```\n", + "
\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 07. 복합 고장 의심 시 숙련 정비관 2차 정밀 진단(HITL) 워크플로우 설계\n", + "**소요시간**: 약 3.5시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "복합 고장 의심 시 숙련 정비관 2차 정밀 진단(HITL) 워크플로우를 설계한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **복합 고장이 의심될 때 숙련 정비관 2차 정밀 진단(HITL) 워크플로우를 시뮬레이션으로 설계하고 오진단 비용 절감 효과를 검증한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **신뢰도가 기준 미만이거나 3개 계통 이상 DTC가 겹친 건을 정비관 대기열에 올리고, 엔진 안을 자동 확정하지 않은 채 정비관의 확정·정정·무응답(엔진 안 유지, 재확인 표시)을 사유와 함께 해시 체인 감사 로그로 남긴다 과정을 진행하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 5개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `src/optimization/mnt03_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": [ + "\"\"\"복합 고장이 의심되는 정비 건(신뢰도 낮음, 서로 다른 계통의 DTC 동시 발생)을 숙련 정비관 2차 정밀 진단 대기열에 올려 승인·정정·무응답(엔진 안 유지·재확인 표시)을 사유와 함께 해시 체인 감사 로그에 남기는 HITL 워크플로우 시뮬레이터를 구현한다. 자동 확정 0건, 오진단 비용 절감률, 신뢰도 기준·정비관 수용량·고장 급증에 대한 민감도를 확인한다.\"\"\"\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.linear_model import LogisticRegression\n", + "\n", + "# ---------------------------------------------------------------------------\n", + "# [교육용가상자료] 가상 차량 100대의 18개월 고장 진단 코드(DTC)·정비 이력 생성기 (실제 부대·장비·정비 실적 없음)\n", + "# 부품 16종(1000가동시간당 고장률 차등), DTC 28종(부품 고유 코드, 짝 부품 공유 코드, 계통 증상 코드)\n", + "# 고장 1건(정비 이력)마다 DTC가 1~5개 동시 발생하고, 연쇄 고장 코드와 일시 잡음 코드가 섞인다.\n", + "# ---------------------------------------------------------------------------\n", + "PARTS = [ # (ID, 이름, 계통, 1000가동시간당 고장률, 가상 단가지수, 수리시간 h)\n", + " (\"P01\", \"연료분사기\", \"engine\", 12.0, 3.0, 2.0), (\"P02\", \"터보차저\", \"engine\", 9.0, 8.0, 5.0),\n", + " (\"P03\", \"축전지\", \"electric\", 6.5, 4.0, 1.5), (\"P04\", \"발전기\", \"electric\", 4.0, 9.0, 3.0),\n", + " (\"P05\", \"변속기 제어모듈\", \"drive\", 3.0, 12.0, 6.0), (\"P06\", \"냉각수 펌프\", \"engine\", 2.2, 3.5, 3.0),\n", + " (\"P07\", \"유압 펌프\", \"hydraulic\", 1.6, 10.0, 4.0), (\"P08\", \"속도 센서\", \"drive\", 1.2, 2.0, 1.5),\n", + " (\"P09\", \"ECU 커넥터\", \"electric\", 0.9, 2.5, 1.5), (\"P10\", \"연료 필터\", \"engine\", 0.7, 0.8, 0.7),\n", + " (\"P11\", \"제동 밸브\", \"brake\", 0.5, 6.0, 3.0), (\"P12\", \"조향 액추에이터\", \"drive\", 0.4, 9.0, 5.0),\n", + " (\"P13\", \"공기 필터\", \"engine\", 0.3, 0.5, 0.5), (\"P14\", \"통신 인터페이스 모듈\", \"electric\", 0.25, 3.0, 1.5),\n", + " (\"P15\", \"윤활유 압력 센서\", \"engine\", 0.2, 1.0, 1.0), (\"P16\", \"팬 구동 모터\", \"cooling\", 0.15, 2.0, 2.0)]\n", + "PID = [p[0] for p in PARTS]\n", + "PNAME = {p[0]: p[1] for p in PARTS}; PSYS = {p[0]: p[2] for p in PARTS}; RATE = {p[0]: p[3] for p in PARTS}\n", + "COST = {p[0]: p[4] for p in PARTS}; REPH = {p[0]: p[5] for p in PARTS}\n", + "PAIRS = [(\"P01\", \"P02\"), (\"P03\", \"P04\"), (\"P05\", \"P08\"), (\"P06\", \"P10\"), (\"P09\", \"P14\"), (\"P13\", \"P15\")]\n", + "SHARED = {p: f\"S{k + 1:02d}\" for k, pr in enumerate(PAIRS) for p in pr} # 짝 부품이 함께 쓰는 모호한 코드\n", + "SPEC = {p: f\"C{p[1:]}1\" for p in PID} # 부품 고유 코드\n", + "SYS = [\"engine\", \"electric\", \"drive\", \"hydraulic\", \"brake\", \"cooling\"]\n", + "SYMP = {s: f\"G{i + 1:02d}\" for i, s in enumerate(SYS)} # 계통 증상 코드\n", + "CODES = sorted(set(SPEC.values()) | set(SHARED.values()) | set(SYMP.values()))\n", + "CASCADE = {\"P01\": [(\"P10\", 0.20)], \"P02\": [(\"P06\", 0.20)], \"P03\": [(\"P04\", 0.25)], \"P04\": [(\"P09\", 0.25)],\n", + " \"P05\": [(\"P08\", 0.30)], \"P06\": [(\"P16\", 0.30)], \"P09\": [(\"P14\", 0.20)], \"P12\": [(\"P07\", 0.20)]}\n", + "CRIT_SYS = {\"engine\", \"drive\", \"brake\"} # 기동·제동 불능 위험 계통(경보 대상)\n", + "MONTHS = 18\n", + "\n", + "\n", + "def make_fleet(n=100, seed=0):\n", + " rng = np.random.default_rng(seed)\n", + " f = pd.DataFrame({\"veh\": [f\"V{i:03d}\" for i in range(n)], \"age\": rng.integers(1, 26, n).astype(float),\n", + " \"terrain\": rng.uniform(0, 1, n), \"load\": rng.uniform(0, 1, n), \"pm_gap\": rng.uniform(20, 200, n),\n", + " \"humid\": rng.uniform(0, 1, n), \"base_h\": rng.uniform(55, 125, n)})\n", + " f[\"mult\"] = np.exp(0.045 * (f[\"age\"] - 13) + 0.9 * (f[\"terrain\"] - .5) + 0.8 * (f[\"load\"] - .5)\n", + " + 0.006 * (f[\"pm_gap\"] - 110) + 0.7 * (f[\"humid\"] - .5))\n", + " return f\n", + "\n", + "\n", + "def make_data(seed=1, months=MONTHS):\n", + " \"\"\"반환: fleet(차량 속성), ev(정비 이력 1건=1행: 발생 DTC 목록, 실제 고장 부품), log(DTC 발생 로그, 행=코드 1건)\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " fleet = make_fleet()\n", + " V = len(fleet)\n", + " temp = 12 + 14 * np.sin(2 * np.pi * (np.arange(months) - 3) / 12) # 월평균 기온(가상)\n", + " hours = fleet[\"base_h\"].values[:, None] * (1 + 0.25 * np.sin(2 * np.pi * np.arange(months) / 12 + 1)) * rng.lognormal(0, .12, (V, months))\n", + " fleet[\"hours\"] = hours.sum(axis=1) # 차량별 18개월 총 가동시간\n", + " fleet.attrs[\"month_h\"] = hours.sum(axis=0) # 월별 전체 가동시간(고장률 분모)\n", + " evs = []\n", + " for p in PID:\n", + " s = np.ones(months)\n", + " if p in (\"P03\", \"P04\"): s = np.exp(-0.06 * (temp - 12)) # 한랭기 전기계통 증가\n", + " if p in (\"P06\", \"P16\"): s = np.exp(0.05 * (temp - 12)) # 혹서기 냉각계통 증가\n", + " cnt = rng.poisson(RATE[p] * hours / 1000 * fleet[\"mult\"].values[:, None] * s[None, :])\n", + " for v, m in zip(*np.nonzero(cnt)):\n", + " for _ in range(cnt[v, m]):\n", + " evs.append((fleet[\"veh\"][v], int(m), m * 720 + rng.uniform(0, 720), p, float(temp[m] + rng.normal(0, 3))))\n", + " ev = pd.DataFrame(evs, columns=[\"veh\", \"month\", \"t\", \"part\", \"temp\"]).sort_values(\"t\").reset_index(drop=True)\n", + " ev[\"ev_id\"] = [f\"W{i:05d}\" for i in range(len(ev))]\n", + " rows, codes = [], []\n", + " for e in ev.itertuples():\n", + " c, r = [], rng.random(3)\n", + " if r[0] < 0.6: c.append(SPEC[e.part])\n", + " if e.part in SHARED and r[1] < 0.6: c.append(SHARED[e.part])\n", + " if r[2] < 0.5: c.append(SYMP[PSYS[e.part]])\n", + " if not c: c.append(SHARED.get(e.part, SPEC[e.part]))\n", + " for q, pr in CASCADE.get(e.part, []):\n", + " if rng.random() < pr: c.append(SPEC[q])\n", + " if rng.random() < 0.12: c.append(SPEC[PID[rng.integers(0, 16)]])\n", + " c = list(dict.fromkeys(c))\n", + " rng.shuffle(c) # 동시 발생 코드의 기록 순서는 임의\n", + " codes.append(c)\n", + " for k, cd in enumerate(c):\n", + " rows.append((e.ev_id, e.veh, e.t + k * 0.1, cd))\n", + " ev[\"codes\"] = codes\n", + " log = pd.DataFrame(rows, columns=[\"ev_id\", \"veh\", \"ts\", \"code\"])\n", + " return fleet, ev, log\n", + "\n", + "# ---------------------------------------------------------------------------\n", + "# 공통 분석 조각: 코드 멀티핫 행렬, 차량 맥락 피처, 학습/검증 분할(앞 12개월 학습, 뒤 6개월 검증)\n", + "# ---------------------------------------------------------------------------\n", + "CIDX = {c: i for i, c in enumerate(CODES)}\n", + "SPLIT_M = 12\n", + "\n", + "\n", + "def multi_hot(ev):\n", + " # [TODO : 학생 실습 7] 복합 고장 의심 시 숙련 정비관 2차 정밀 진단(HITL) 워크플로우 설계 관련 핵심 로직을 구현하세요.\n", + " # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n", + " # <<< 여기에 코드를 작성하세요 >>>\n", + " pass # 작성 후 pass를 제거하세요.\n", + "def context(ev, fleet):\n", + " # [TODO : 학생 실습 7] 복합 고장 의심 시 숙련 정비관 2차 정밀 진단(HITL) 워크플로우 설계 관련 핵심 로직을 구현하세요.\n", + " # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n", + " # <<< 여기에 코드를 작성하세요 >>>\n", + " pass # 작성 후 pass를 제거하세요.\n", + "def feats(ev, fleet):\n", + " X = multi_hot(ev)\n", + " n = X.sum(axis=1, keepdims=True) / 3\n", + " return np.hstack([X, n, context(ev, fleet)])\n", + "\n", + "\n", + "def monthly_counts(ev):\n", + " return ev.groupby([\"month\", \"part\"]).size().unstack(fill_value=0).reindex(columns=PID, fill_value=0)\n", + "\n", + "\n", + "def build_monthly(ev, fleet, top_n=8):\n", + " \"\"\"부품별 월간 고장 건수 예측용 표: 지연값(lag), 이동평균, 추세, 기대 건수(가동시간 x 설계 고장률), 기온 상호작용, 계절 성분\"\"\"\n", + " mc = ev.groupby([\"month\", \"part\"]).size().unstack(fill_value=0)\n", + " top = ev[\"part\"].value_counts().index[:top_n]\n", + " mh, tm = fleet.attrs[\"month_h\"], ev.groupby(\"month\")[\"temp\"].mean().values\n", + " rows = []\n", + " for p in top:\n", + " c = mc[p].values.astype(float)\n", + " for m in range(3, MONTHS):\n", + " L = np.log1p(c)\n", + " rows.append({\"part\": p, \"month\": m, \"y\": c[m], \"lag1\": L[m - 1], \"lag2\": L[m - 2], \"lag3\": L[m - 3], \"roll3\": L[m - 3:m].mean(),\n", + " \"trend\": L[m - 1] - L[m - 2], \"roll3_std\": L[m - 3:m].std(), \"log_expected\": np.log(mh[m] / 1000 * RATE[p]),\n", + " \"rate_lag1\": c[m - 1] / (mh[m - 1] / 1000) / RATE[p], \"temp\": tm[m] / 10, \"cold_elec\": max(0, 12 - tm[m]) / 10 * (p in (\"P03\", \"P04\")),\n", + " \"heat_cool\": max(0, tm[m] - 12) / 10 * (p in (\"P06\", \"P16\")), \"sin\": np.sin(2 * np.pi * m / 12), \"cos\": np.cos(2 * np.pi * m / 12)})\n", + " D = pd.DataFrame(rows)\n", + " return D, [c for c in D.columns if c not in (\"part\", \"month\", \"y\")], top\n", + "\n", + "fleet, ev, log = make_data()\n", + "train, test = ev[ev[\"month\"] < SPLIT_M], ev[ev[\"month\"] >= SPLIT_M].reset_index(drop=True)\n", + "clf = LogisticRegression(C=3, max_iter=500).fit(feats(train, fleet), train[\"part\"])\n", + "P = clf.predict_proba(feats(test, fleet))\n", + "cls = clf.classes_\n", + "top1 = cls[P.argmax(axis=1)]\n", + "conf = P.max(axis=1)\n", + "CSYS = {c: {PSYS[p] for p in PID if c in (SPEC[p], SHARED.get(p)) or c == SYMP[PSYS[p]]} for c in CODES}\n", + "nsys = test[\"codes\"].map(lambda cs: len(set().union(*[CSYS[c] for c in cs])))\n", + "multi_sys = ((test[\"codes\"].str.len() >= 3) & (nsys >= 3)).values # 3개 이상 계통에 걸친 DTC가 동시에 발생\n", + "THR, CAP = 0.8, 300 # 운영점: 신뢰도 0.8 미만 또는 다계통 동시 발생이면 정밀 진단, 정비관 월 수용량 300건(아래 민감도 분석으로 선정)\n", + "\n", + "# [교육용가상자료] 가정 비용지수: 오진단 시 헛교체 부품비 + 헛교체 공수 + 재입고 패널티 3.0 + 올바른 수리, 정밀 진단 1건 1.0\n", + "REVIEW_COST, REVISIT, SENIOR_ACC, NO_RESP = 1.0, 3.0, 0.93, 0.08\n", + "\n", + "\n", + "def case_cost(pred, true):\n", + " if pred == true:\n", + " return REPH[true]\n", + " return COST[pred] + REPH[pred] + REVISIT + REPH[true]\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 run(thr=THR, cap=CAP, surge=1.0, seed=0, audit=None, mode=\"hitl\"):\n", + " \"\"\"mode: auto(엔진 top-1 전부 확정: 비교용, 실제 운용 금지) / hitl(복합 의심 건은 정비관 결정 전까지 확정하지 않음)\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " idx_all = np.arange(len(test))\n", + " if surge != 1.0:\n", + " idx_all = np.concatenate([idx_all, rng.choice(idx_all, int(len(idx_all) * (surge - 1)), replace=True)])\n", + " cost, wrong, queued, decided, pending_auto, silent = 0.0, 0, 0, 0, 0, 0\n", + " for m in range(SPLIT_M, MONTHS):\n", + " mi = idx_all[test[\"month\"].values[idx_all] == m]\n", + " flag = [i for i in mi if conf[i] < thr or multi_sys[i]]\n", + " flag.sort(key=lambda i: (PSYS[top1[i]] not in CRIT_SYS, conf[i])) # 안전 계통 우선, 신뢰도 낮은 순\n", + " queue = set(flag[:cap]) if mode == \"hitl\" else set()\n", + " for i in mi:\n", + " true, final = test[\"part\"][i], top1[i]\n", + " if i in queue:\n", + " queued += 1\n", + " audit.add(event=\"PROPOSE\", case=int(i), engine=str(top1[i]), conf=round(float(conf[i]), 3), status=\"PENDING\")\n", + " cost += REVIEW_COST\n", + " r = rng.random()\n", + " if r < NO_RESP:\n", + " dec, why = \"무응답\", \"응답 지연 -> 엔진 안 유지, 재확인 필요 표시\"; silent += 1\n", + " elif r < NO_RESP + (1 - NO_RESP) * SENIOR_ACC:\n", + " dec, final, why = (\"승인\" if top1[i] == true else \"정정\"), true, \"추가 계측·부품 분리 점검으로 확인\"\n", + " else:\n", + " second = cls[np.argsort(-P[i])[1]]\n", + " dec, final, why = \"정정\", second, \"2순위 후보로 정정(근거 부족)\"\n", + " audit.add(event=\"DECIDE\", case=int(i), decision=dec, final=str(final), reason=why, by=\"숙련 정비관(모의)\")\n", + " decided += 1\n", + " cost += case_cost(final, true); wrong += final != true\n", + " n = len(idx_all)\n", + " return {\"cost\": cost, \"wrong\": wrong / n, \"queued\": queued, \"decided\": decided, \"silent\": silent, \"n\": n, \"auto\": pending_auto}\n", + "\n", + "\n", + "aud = Audit()\n", + "A, H = run(mode=\"auto\", audit=Audit()), run(audit=aud)\n", + "print(f\"[교육용가상자료] 검증 정비 건 {A['n']}건, 복합 고장 의심 건 {int(((conf < THR) | multi_sys).sum())}건 (신뢰도 {THR} 미만 또는 3개 계통 이상 DTC)\")\n", + "print(f\"엔진 단독 자동 확정(비교용): 비용지수 {A['cost']:.0f}, 오진단율 {A['wrong']:.1%}\")\n", + "print(f\"HITL(정비관 대기열 {H['queued']}건): 비용지수 {H['cost']:.0f}, 오진단율 {H['wrong']:.1%}\")\n", + "cut = 1 - H[\"cost\"] / A[\"cost\"]\n", + "print(f\"오진단 비용 절감률 {cut:.1%} (목표 15% 이상: {'달성' if cut >= 0.15 else '미달'})\")\n", + "\n", + "# 승인 체계 점검\n", + "print(f\"\\n자동 확정 0건 확인: 대기열 건은 모두 정비관 결정 기록 후 확정 (자동 확정 {H['auto']}건)\")\n", + "print(f\"결정 로깅률 {H['decided'] / max(H['queued'], 1):.0%} ({H['decided']}/{H['queued']}), 무응답 {H['silent']}건은 엔진 안 유지 + 재확인 표시\")\n", + "print(f\"감사 로그 무결성: {'정상' if aud.verify() is None else '이상'}\", end=\", \")\n", + "aud.log[7][\"reason\"] = \"사후 수정\"\n", + "print(f\"사후 변조 시 seq {aud.verify()}에서 탐지\")\n", + "print(\"결정 사유 예:\", \"; \".join(sorted({e[\"reason\"] for e in aud.log if e[\"event\"] == \"DECIDE\"})[:3]))\n", + "\n", + "# 민감도 분석\n", + "print(\"\\n[민감도 1] 신뢰도 기준(미만이면 정밀 진단) -> 절감률, 정비관 대기열(6개월)\")\n", + "rows = []\n", + "for thr in (0.5, 0.6, 0.7, 0.8, 0.9):\n", + " r = run(thr=thr, cap=10 ** 6, audit=Audit()); rows.append((thr, 1 - r[\"cost\"] / A[\"cost\"], r[\"queued\"]))\n", + " print(f\" {thr:.1f}: 절감 {rows[-1][1]:.1%}, 대기열 {r['queued']}건\")\n", + "print(\"[민감도 2] 정비관 월 수용량 -> 절감률 (전시 전환 가정: 고장 1.5배 급증)\")\n", + "for cap in (50, 100, 200, 300):\n", + " r1, r2 = run(cap=cap, audit=Audit()), run(cap=cap, surge=1.5, audit=Audit())\n", + " b2 = run(mode=\"auto\", surge=1.5, audit=Audit())\n", + " print(f\" 월 {cap}건: 평시 {1 - r1['cost'] / A['cost']:.1%}, 급증 {1 - r2['cost'] / b2['cost']:.1%}\")\n", + "\n", + "plt.figure(figsize=(6, 3.5))\n", + "plt.plot([r[0] for r in rows], [r[1] * 100 for r in rows], marker=\"o\"); plt.xlabel(\"confidence threshold\"); plt.ylabel(\"cost saving (%)\")\n", + "plt.title(\"HITL sensitivity: confidence threshold\"); plt.tight_layout(); plt.show()\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 07 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + "[교육용가상자료] 검증 정비 건 3890건, 복합 고장 의심 건 1555건 (신뢰도 0.8 미만 또는 3개 계통 이상 DTC)\n", + "엔진 단독 자동 확정(비교용): 비용지수 17546, 오진단율 16.1%\n", + "HITL(정비관 대기열 1501건): 비용지수 14809, 오진단율 4.2%\n", + "오진단 비용 절감률 15.6% (목표 15% 이상: 달성)\n", + "\n", + "자동 확정 0건 확인: 대기열 건은 모두 정비관 결정 기록 후 확정 (자동 확정 0건)\n", + "결정 로깅률 100% (1501/1501), 무응답 120건은 엔진 안 유지 + 재확인 표시\n", + "감사 로그 무결성: 정상, 사후 변조 시 seq 7에서 탐지\n", + "결정 사유 예: 2순위 후보로 정정(근거 부족); 사후 수정; 응답 지연 -> 엔진 안 유지, 재확인 필요 표시\n", + "\n", + "[민감도 1] 신뢰도 기준(미만이면 정밀 진단) -> 절감률, 정비관 대기열(6개월)\n", + " 0.5: 절감 1.9%, 대기열 162건\n", + " 0.6: 절감 9.4%, 대기열 784건\n", + " 0.7: 절감 14.6%, 대기열 1285건\n", + " 0.8: 절감 16.9%, 대기열 1555건\n", + " 0.9: 절감 17.7%, 대기열 1671건\n", + "[민감도 2] 정비관 월 수용량 -> 절감률 (전시 전환 가정: 고장 1.5배 급증)\n", + " 월 50건: 평시 2.8%, 급증 2.8%\n", + " 월 100건: 평시 7.0%, 급증 4.4%\n", + " 월 200건: 평시 13.8%, 급증 9.3%\n", + " 월 300건: 평시 15.6%, 급증 12.7%\n", + "```\n", + "
\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 08. 1,000건 동시 다발 고장 코드 주입 시 연관 고장 추론 부하 테스트\n", + "**소요시간**: 약 3.0시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "1,000건 동시 고장 코드 주입 시 연관 고장 추론 부하 테스트를 수행한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **1,000건의 DTC 진단 요청을 동시에 주입해 연관 고장 추론의 처리 시간과 경보 정확도를 확인하는 부하 테스트를 수행한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **가상 진단 요청 1,000건에 표기 오류, 빈 요청, 미정의 코드, 미등록 차량, 중복 코드 같은 오류를 섞어 일괄 처리하고, 정상 처리·격리 건수와 유실 여부를 센다 과정을 진행하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `src/dashboard/mnt03_app.py (Streamlit/Dash 등)` 형태의 결과물을 완성한다.\n", + "\n", + "### 🔍 평가 및 검증 기준 (PASS 조건)\n", + "#### 미션 판정\n", + "\n", + "**평가 구분:** 필수\n", + "\n", + "**핵심 평가영역:** 의사결정 지원에 최적화된 시각적 위계 및 사용자 경험(UX)\n", + "\n", + "#### 미션 PASS 조건\n", + "\n", + "- 정량 기준: 대시보드 로딩 시간 2초 이내, 경보 발령 정확도 95% 이상\n", + "\n", + "\n", + "- 진행 기준: 웹 기반 분석 대시보드 구동 및 실시간 필터링 기능 검증\n", + "\n", + "\n", + "- 검증 방법: 실제 군수 데이터 연동 화면 테스트 및 UI 동작 검증\n", + "\n", + "\n", + "#### 채점자 판정 메모\n", + "\n", + "> 단순 코드 실행 여부뿐만 아니라 출력된 수치와 근거 자료가 군 실무 요구사항에 부합하는지 면밀히 확인한다.\n", + "\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "\"\"\"1,000건의 DTC 진단 요청을 동시에 주입해 연관 고장 추론(부품 진단 + 기동·제동 계통 경보)의 처리 시간과 경보 정확도를 측정하는 부하 테스트를 수행한다. 형식이 깨진 코드·빈 요청·미등록 차량을 섞은 오류 주입 요청이 격리 처리되는지, 요청 수를 100~5,000건으로 늘렸을 때 시간이 어떻게 변하는지 확인한다.\"\"\"\n", + "import time\n", + "import numpy as np\n", + "import pandas as pd\n", + "import matplotlib.pyplot as plt\n", + "from concurrent.futures import ThreadPoolExecutor\n", + "from sklearn.linear_model import LogisticRegression\n", + "\n", + "# ---------------------------------------------------------------------------\n", + "# [교육용가상자료] 가상 차량 100대의 18개월 고장 진단 코드(DTC)·정비 이력 생성기 (실제 부대·장비·정비 실적 없음)\n", + "# 부품 16종(1000가동시간당 고장률 차등), DTC 28종(부품 고유 코드, 짝 부품 공유 코드, 계통 증상 코드)\n", + "# 고장 1건(정비 이력)마다 DTC가 1~5개 동시 발생하고, 연쇄 고장 코드와 일시 잡음 코드가 섞인다.\n", + "# ---------------------------------------------------------------------------\n", + "PARTS = [ # (ID, 이름, 계통, 1000가동시간당 고장률, 가상 단가지수, 수리시간 h)\n", + " (\"P01\", \"연료분사기\", \"engine\", 12.0, 3.0, 2.0), (\"P02\", \"터보차저\", \"engine\", 9.0, 8.0, 5.0),\n", + " (\"P03\", \"축전지\", \"electric\", 6.5, 4.0, 1.5), (\"P04\", \"발전기\", \"electric\", 4.0, 9.0, 3.0),\n", + " (\"P05\", \"변속기 제어모듈\", \"drive\", 3.0, 12.0, 6.0), (\"P06\", \"냉각수 펌프\", \"engine\", 2.2, 3.5, 3.0),\n", + " (\"P07\", \"유압 펌프\", \"hydraulic\", 1.6, 10.0, 4.0), (\"P08\", \"속도 센서\", \"drive\", 1.2, 2.0, 1.5),\n", + " (\"P09\", \"ECU 커넥터\", \"electric\", 0.9, 2.5, 1.5), (\"P10\", \"연료 필터\", \"engine\", 0.7, 0.8, 0.7),\n", + " (\"P11\", \"제동 밸브\", \"brake\", 0.5, 6.0, 3.0), (\"P12\", \"조향 액추에이터\", \"drive\", 0.4, 9.0, 5.0),\n", + " (\"P13\", \"공기 필터\", \"engine\", 0.3, 0.5, 0.5), (\"P14\", \"통신 인터페이스 모듈\", \"electric\", 0.25, 3.0, 1.5),\n", + " (\"P15\", \"윤활유 압력 센서\", \"engine\", 0.2, 1.0, 1.0), (\"P16\", \"팬 구동 모터\", \"cooling\", 0.15, 2.0, 2.0)]\n", + "PID = [p[0] for p in PARTS]\n", + "PNAME = {p[0]: p[1] for p in PARTS}; PSYS = {p[0]: p[2] for p in PARTS}; RATE = {p[0]: p[3] for p in PARTS}\n", + "COST = {p[0]: p[4] for p in PARTS}; REPH = {p[0]: p[5] for p in PARTS}\n", + "PAIRS = [(\"P01\", \"P02\"), (\"P03\", \"P04\"), (\"P05\", \"P08\"), (\"P06\", \"P10\"), (\"P09\", \"P14\"), (\"P13\", \"P15\")]\n", + "SHARED = {p: f\"S{k + 1:02d}\" for k, pr in enumerate(PAIRS) for p in pr} # 짝 부품이 함께 쓰는 모호한 코드\n", + "SPEC = {p: f\"C{p[1:]}1\" for p in PID} # 부품 고유 코드\n", + "SYS = [\"engine\", \"electric\", \"drive\", \"hydraulic\", \"brake\", \"cooling\"]\n", + "SYMP = {s: f\"G{i + 1:02d}\" for i, s in enumerate(SYS)} # 계통 증상 코드\n", + "CODES = sorted(set(SPEC.values()) | set(SHARED.values()) | set(SYMP.values()))\n", + "CASCADE = {\"P01\": [(\"P10\", 0.20)], \"P02\": [(\"P06\", 0.20)], \"P03\": [(\"P04\", 0.25)], \"P04\": [(\"P09\", 0.25)],\n", + " \"P05\": [(\"P08\", 0.30)], \"P06\": [(\"P16\", 0.30)], \"P09\": [(\"P14\", 0.20)], \"P12\": [(\"P07\", 0.20)]}\n", + "CRIT_SYS = {\"engine\", \"drive\", \"brake\"} # 기동·제동 불능 위험 계통(경보 대상)\n", + "MONTHS = 18\n", + "\n", + "\n", + "def make_fleet(n=100, seed=0):\n", + " rng = np.random.default_rng(seed)\n", + " f = pd.DataFrame({\"veh\": [f\"V{i:03d}\" for i in range(n)], \"age\": rng.integers(1, 26, n).astype(float),\n", + " \"terrain\": rng.uniform(0, 1, n), \"load\": rng.uniform(0, 1, n), \"pm_gap\": rng.uniform(20, 200, n),\n", + " \"humid\": rng.uniform(0, 1, n), \"base_h\": rng.uniform(55, 125, n)})\n", + " f[\"mult\"] = np.exp(0.045 * (f[\"age\"] - 13) + 0.9 * (f[\"terrain\"] - .5) + 0.8 * (f[\"load\"] - .5)\n", + " + 0.006 * (f[\"pm_gap\"] - 110) + 0.7 * (f[\"humid\"] - .5))\n", + " return f\n", + "\n", + "\n", + "def make_data(seed=1, months=MONTHS):\n", + " \"\"\"반환: fleet(차량 속성), ev(정비 이력 1건=1행: 발생 DTC 목록, 실제 고장 부품), log(DTC 발생 로그, 행=코드 1건)\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " fleet = make_fleet()\n", + " V = len(fleet)\n", + " temp = 12 + 14 * np.sin(2 * np.pi * (np.arange(months) - 3) / 12) # 월평균 기온(가상)\n", + " hours = fleet[\"base_h\"].values[:, None] * (1 + 0.25 * np.sin(2 * np.pi * np.arange(months) / 12 + 1)) * rng.lognormal(0, .12, (V, months))\n", + " fleet[\"hours\"] = hours.sum(axis=1) # 차량별 18개월 총 가동시간\n", + " fleet.attrs[\"month_h\"] = hours.sum(axis=0) # 월별 전체 가동시간(고장률 분모)\n", + " evs = []\n", + " for p in PID:\n", + " s = np.ones(months)\n", + " if p in (\"P03\", \"P04\"): s = np.exp(-0.06 * (temp - 12)) # 한랭기 전기계통 증가\n", + " if p in (\"P06\", \"P16\"): s = np.exp(0.05 * (temp - 12)) # 혹서기 냉각계통 증가\n", + " cnt = rng.poisson(RATE[p] * hours / 1000 * fleet[\"mult\"].values[:, None] * s[None, :])\n", + " for v, m in zip(*np.nonzero(cnt)):\n", + " for _ in range(cnt[v, m]):\n", + " evs.append((fleet[\"veh\"][v], int(m), m * 720 + rng.uniform(0, 720), p, float(temp[m] + rng.normal(0, 3))))\n", + " ev = pd.DataFrame(evs, columns=[\"veh\", \"month\", \"t\", \"part\", \"temp\"]).sort_values(\"t\").reset_index(drop=True)\n", + " ev[\"ev_id\"] = [f\"W{i:05d}\" for i in range(len(ev))]\n", + " rows, codes = [], []\n", + " for e in ev.itertuples():\n", + " c, r = [], rng.random(3)\n", + " if r[0] < 0.6: c.append(SPEC[e.part])\n", + " if e.part in SHARED and r[1] < 0.6: c.append(SHARED[e.part])\n", + " if r[2] < 0.5: c.append(SYMP[PSYS[e.part]])\n", + " if not c: c.append(SHARED.get(e.part, SPEC[e.part]))\n", + " for q, pr in CASCADE.get(e.part, []):\n", + " if rng.random() < pr: c.append(SPEC[q])\n", + " if rng.random() < 0.12: c.append(SPEC[PID[rng.integers(0, 16)]])\n", + " c = list(dict.fromkeys(c))\n", + " rng.shuffle(c) # 동시 발생 코드의 기록 순서는 임의\n", + " codes.append(c)\n", + " for k, cd in enumerate(c):\n", + " rows.append((e.ev_id, e.veh, e.t + k * 0.1, cd))\n", + " ev[\"codes\"] = codes\n", + " log = pd.DataFrame(rows, columns=[\"ev_id\", \"veh\", \"ts\", \"code\"])\n", + " return fleet, ev, log\n", + "\n", + "# ---------------------------------------------------------------------------\n", + "# 공통 분석 조각: 코드 멀티핫 행렬, 차량 맥락 피처, 학습/검증 분할(앞 12개월 학습, 뒤 6개월 검증)\n", + "# ---------------------------------------------------------------------------\n", + "CIDX = {c: i for i, c in enumerate(CODES)}\n", + "SPLIT_M = 12\n", + "\n", + "\n", + "def multi_hot(ev):\n", + " # [TODO : 학생 실습 8] 1,000건 동시 다발 고장 코드 주입 시 연관 고장 추론 부하 테스트 관련 핵심 로직을 구현하세요.\n", + " # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n", + " # <<< 여기에 코드를 작성하세요 >>>\n", + " pass # 작성 후 pass를 제거하세요.\n", + "def context(ev, fleet):\n", + " # [TODO : 학생 실습 8] 1,000건 동시 다발 고장 코드 주입 시 연관 고장 추론 부하 테스트 관련 핵심 로직을 구현하세요.\n", + " # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n", + " # <<< 여기에 코드를 작성하세요 >>>\n", + " pass # 작성 후 pass를 제거하세요.\n", + "def feats(ev, fleet):\n", + " X = multi_hot(ev)\n", + " n = X.sum(axis=1, keepdims=True) / 3\n", + " return np.hstack([X, n, context(ev, fleet)])\n", + "\n", + "\n", + "def monthly_counts(ev):\n", + " return ev.groupby([\"month\", \"part\"]).size().unstack(fill_value=0).reindex(columns=PID, fill_value=0)\n", + "\n", + "\n", + "def build_monthly(ev, fleet, top_n=8):\n", + " \"\"\"부품별 월간 고장 건수 예측용 표: 지연값(lag), 이동평균, 추세, 기대 건수(가동시간 x 설계 고장률), 기온 상호작용, 계절 성분\"\"\"\n", + " mc = ev.groupby([\"month\", \"part\"]).size().unstack(fill_value=0)\n", + " top = ev[\"part\"].value_counts().index[:top_n]\n", + " mh, tm = fleet.attrs[\"month_h\"], ev.groupby(\"month\")[\"temp\"].mean().values\n", + " rows = []\n", + " for p in top:\n", + " c = mc[p].values.astype(float)\n", + " for m in range(3, MONTHS):\n", + " L = np.log1p(c)\n", + " rows.append({\"part\": p, \"month\": m, \"y\": c[m], \"lag1\": L[m - 1], \"lag2\": L[m - 2], \"lag3\": L[m - 3], \"roll3\": L[m - 3:m].mean(),\n", + " \"trend\": L[m - 1] - L[m - 2], \"roll3_std\": L[m - 3:m].std(), \"log_expected\": np.log(mh[m] / 1000 * RATE[p]),\n", + " \"rate_lag1\": c[m - 1] / (mh[m - 1] / 1000) / RATE[p], \"temp\": tm[m] / 10, \"cold_elec\": max(0, 12 - tm[m]) / 10 * (p in (\"P03\", \"P04\")),\n", + " \"heat_cool\": max(0, tm[m] - 12) / 10 * (p in (\"P06\", \"P16\")), \"sin\": np.sin(2 * np.pi * m / 12), \"cos\": np.cos(2 * np.pi * m / 12)})\n", + " D = pd.DataFrame(rows)\n", + " return D, [c for c in D.columns if c not in (\"part\", \"month\", \"y\")], top\n", + "\n", + "fleet, ev, log = make_data()\n", + "train, test = ev[ev[\"month\"] < SPLIT_M], ev[ev[\"month\"] >= SPLIT_M].reset_index(drop=True)\n", + "clf = LogisticRegression(C=3, max_iter=500).fit(feats(train, fleet), train[\"part\"])\n", + "cls, FL = clf.classes_, fleet.set_index(\"veh\")\n", + "VALID = set(CODES)\n", + "\n", + "\n", + "def make_requests(n, seed=0, bad_rate=0.05):\n", + " \"\"\"[교육용가상자료] 검증 기간 정비 건에서 n건을 뽑아 DTC 요청으로 만든다. bad_rate 비율만큼 오류를 주입한다.\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " rows = test.iloc[rng.choice(len(test), n, replace=n > len(test))]\n", + " reqs, kinds = [], []\n", + " for e in rows.itertuples():\n", + " r, kind = {\"veh\": e.veh, \"codes\": list(e.codes), \"temp\": e.temp, \"true\": e.part}, \"정상\"\n", + " u = rng.random()\n", + " if u < bad_rate * 0.3: r[\"codes\"] = [c.lower() + \" \" for c in r[\"codes\"]]; kind = \"표기 오류\"\n", + " elif u < bad_rate * 0.5: r[\"codes\"] = [\"ZZ9\"]; kind = \"미정의 코드\"\n", + " elif u < bad_rate * 0.7: r[\"codes\"] = []; kind = \"빈 요청\"\n", + " elif u < bad_rate * 0.85: r[\"veh\"] = \"V999\"; kind = \"미등록 차량\"\n", + " elif u < bad_rate: r[\"codes\"] = r[\"codes\"] * 3; kind = \"중복 코드\"\n", + " reqs.append(r); kinds.append(kind)\n", + " return reqs, kinds\n", + "\n", + "\n", + "def normalize(r):\n", + " \"\"\"표기 정리(공백·대소문자) 후 검증. 문제가 있으면 예외 대신 사유를 돌려준다.\"\"\"\n", + " codes = sorted({c.strip().upper() for c in r[\"codes\"]})\n", + " if r[\"veh\"] not in FL.index: return None, \"미등록 차량\"\n", + " if not codes or any(c not in VALID for c in codes): return None, \"빈 요청·미정의 코드\"\n", + " return codes, \"\"\n", + "\n", + "\n", + "def build_x(batch):\n", + " X = np.zeros((len(batch), len(CODES) + 6))\n", + " for i, (r, cs) in enumerate(batch):\n", + " for c in cs: X[i, CIDX[c]] = 1\n", + " v = FL.loc[r[\"veh\"]]\n", + " X[i, len(CODES):] = [len(cs) / 3, v[\"age\"] / 25, v[\"pm_gap\"] / 200, v[\"terrain\"], v[\"load\"], (r[\"temp\"] - 12) / 15]\n", + " return X\n", + "\n", + "\n", + "def serve_batch(reqs):\n", + " ok, rej = [], []\n", + " for k, r in enumerate(reqs):\n", + " cs, why = normalize(r)\n", + " (ok if cs else rej).append((k, r, cs) if cs else (k, why))\n", + " X = build_x([(r, cs) for _, r, cs in ok])\n", + " P = clf.predict_proba(X) if len(ok) else np.zeros((0, len(cls)))\n", + " top = cls[P.argmax(axis=1)] if len(ok) else []\n", + " out = [{\"k\": k, \"part\": p, \"conf\": float(P[i].max()), \"alert\": PSYS[p] in CRIT_SYS} for i, ((k, _, _), p) in enumerate(zip(ok, top))]\n", + " return out, rej\n", + "\n", + "\n", + "def serve_one(r):\n", + " cs, why = normalize(r)\n", + " if not cs: return None\n", + " p = cls[clf.predict_proba(build_x([(r, cs)]))[0].argmax()]\n", + " return {\"part\": p, \"alert\": PSYS[p] in CRIT_SYS}\n", + "\n", + "\n", + "# 1) 1,000건 동시 주입\n", + "reqs, kinds = make_requests(1000)\n", + "t0 = time.perf_counter(); out, rej = serve_batch(reqs)\n", + "agg = pd.DataFrame(out).groupby(\"part\").size().sort_values(ascending=False).head(5) # 대시보드 집계까지 포함\n", + "alerts = sum(o[\"alert\"] for o in out); dt = time.perf_counter() - t0\n", + "print(f\"[교육용가상자료] 1,000건 동시 주입: 정상 처리 {len(out)}건, 격리(오류) {len(rej)}건, 처리 시간 {dt:.2f}초 (목표 2초 이내: {'달성' if dt <= 2 else '미달'})\")\n", + "print(f\" 오류 주입 {sum(k != '정상' for k in kinds)}건 유형: \" + \", \".join(f\"{k} {v}\" for k, v in pd.Series(kinds).value_counts().items() if k != \"정상\"))\n", + "print(\" 격리 사유: \" + \", \".join(f\"{k} {v}건\" for k, v in pd.Series([w for _, w in rej]).value_counts().items()))\n", + "\n", + "# 2) 경보 정확도(기동·제동 계통 고장 경보)\n", + "truth = np.array([PSYS[reqs[o[\"k\"]][\"true\"]] in CRIT_SYS for o in out]); pa = np.array([o[\"alert\"] for o in out])\n", + "acc = (truth == pa).mean(); rec = (pa & truth).sum() / truth.sum(); prec = (pa & truth).sum() / pa.sum()\n", + "part_acc = np.mean([o[\"part\"] == reqs[o[\"k\"]][\"true\"] for o in out])\n", + "print(f\"\\n경보 정확도 {acc:.1%} (목표 95% 이상: {'달성' if acc >= 0.95 else '미달'}), 재현율 {rec:.1%}, 정밀도 {prec:.1%}, 경보 {alerts}건\")\n", + "print(f\"참고: 부품 단위 진단 정확도 {part_acc:.1%}, 상위 부품 \" + \", \".join(f\"{PNAME[p]} {n}\" for p, n in agg.items()))\n", + "\n", + "# 3) 단건 호출 방식과 비교(8개 작업자 동시 호출)\n", + "t0 = time.perf_counter()\n", + "with ThreadPoolExecutor(8) as ex:\n", + " res = list(ex.map(serve_one, reqs))\n", + "dt1 = time.perf_counter() - t0\n", + "print(f\"\\n단건 호출 8스레드 {dt1:.2f}초 vs 일괄 처리 {dt:.2f}초 (일괄이 {dt1 / dt:.1f}배 빠름), 단건 방식 예외 0건: {all(r is None or 'part' in r for r in res)}\")\n", + "\n", + "# 4) 요청 수 확장 시험\n", + "print(\"[요청 수별 일괄 처리 시간]\")\n", + "ns, ts = [100, 500, 1000, 2000, 5000], []\n", + "for n in ns:\n", + " rq, _ = make_requests(n, seed=n)\n", + " t0 = time.perf_counter(); serve_batch(rq); ts.append(time.perf_counter() - t0)\n", + " print(f\" {n:5d}건: {ts[-1]:.2f}초 ({n / ts[-1]:.0f}건/초)\")\n", + "\n", + "plt.figure(figsize=(6, 3.5)); plt.plot(ns, ts, marker=\"o\"); plt.axhline(2, ls=\"--\", color=\"k\")\n", + "plt.xlabel(\"requests\"); plt.ylabel(\"seconds\"); plt.title(\"Batch inference time (line = 2 s)\"); plt.tight_layout(); plt.show()\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 08 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + "[교육용가상자료] 1,000건 동시 주입: 정상 처리 965건, 격리(오류) 35건, 처리 시간 0.11초 (목표 2초 이내: 달성)\n", + " 오류 주입 65건 유형: 표기 오류 23, 빈 요청 15, 미정의 코드 11, 미등록 차량 9, 중복 코드 7\n", + " 격리 사유: 빈 요청·미정의 코드 26건, 미등록 차량 9건\n", + "\n", + "경보 정확도 99.8% (목표 95% 이상: 달성), 재현율 100.0%, 정밀도 99.6%, 경보 567건\n", + "참고: 부품 단위 진단 정확도 85.1%, 상위 부품 연료분사기 303, 축전지 218, 터보차저 126, 발전기 119, 변속기 제어모듈 53\n", + "\n", + "단건 호출 8스레드 0.33초 vs 일괄 처리 0.11초 (일괄이 3.1배 빠름), 단건 방식 예외 0건: True\n", + "[요청 수별 일괄 처리 시간]\n", + " 100건: 0.01초 (15586건/초)\n", + " 500건: 0.03초 (18195건/초)\n", + " 1000건: 0.06초 (16478건/초)\n", + " 2000건: 0.30초 (6703건/초)\n", + " 5000건: 0.33초 (15293건/초)\n", + "```\n", + "
\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 09. AAR 기반 연관 고장 오진단 사례 분석 및 고장 룰베이스 재튜닝\n", + "**소요시간**: 약 3.0시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "AAR 기반 연관 고장 오진단 사례를 분석하고 룰베이스를 재튜닝한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **AAR 기간의 연관 고장 오진단 사례를 원인별로 분석하고 고장 룰베이스(코드 유형 가중치·예외 규칙)를 재튜닝해 효과를 지표로 산출한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **AAR 기간 오진단 건을 모호 코드 입력, 연쇄 고장 코드 오인, 복수 고유 코드 충돌 등으로 분류하고 대표 사례 정리하고 규격화하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `reports/mnt03_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(사후검토) 대상 기간의 연관 고장 오진단 사례를 원인별(모호 코드만 입력, 연쇄 고장 코드 오인, 잡음 코드 오인, 기타)로 분류하고, 고장 룰베이스의 코드 유형별 가중치와 사전확률 보정을 AAR 기간 자료로 재튜닝한 뒤 별도 검증 기간에서 효과를 확인한다. 비용 절감액, 재작업 시간 단축률, 헛교체 부품 감소율의 3대 효과 지표와 단계별 도입 로드맵을 산출한다.\"\"\"\n", + "import itertools\n", + "from collections import Counter\n", + "import numpy as np\n", + "import pandas as pd\n", + "import matplotlib.pyplot as plt\n", + "\n", + "# ---------------------------------------------------------------------------\n", + "# [교육용가상자료] 가상 차량 100대의 18개월 고장 진단 코드(DTC)·정비 이력 생성기 (실제 부대·장비·정비 실적 없음)\n", + "# 부품 16종(1000가동시간당 고장률 차등), DTC 28종(부품 고유 코드, 짝 부품 공유 코드, 계통 증상 코드)\n", + "# 고장 1건(정비 이력)마다 DTC가 1~5개 동시 발생하고, 연쇄 고장 코드와 일시 잡음 코드가 섞인다.\n", + "# ---------------------------------------------------------------------------\n", + "PARTS = [ # (ID, 이름, 계통, 1000가동시간당 고장률, 가상 단가지수, 수리시간 h)\n", + " (\"P01\", \"연료분사기\", \"engine\", 12.0, 3.0, 2.0), (\"P02\", \"터보차저\", \"engine\", 9.0, 8.0, 5.0),\n", + " (\"P03\", \"축전지\", \"electric\", 6.5, 4.0, 1.5), (\"P04\", \"발전기\", \"electric\", 4.0, 9.0, 3.0),\n", + " (\"P05\", \"변속기 제어모듈\", \"drive\", 3.0, 12.0, 6.0), (\"P06\", \"냉각수 펌프\", \"engine\", 2.2, 3.5, 3.0),\n", + " (\"P07\", \"유압 펌프\", \"hydraulic\", 1.6, 10.0, 4.0), (\"P08\", \"속도 센서\", \"drive\", 1.2, 2.0, 1.5),\n", + " (\"P09\", \"ECU 커넥터\", \"electric\", 0.9, 2.5, 1.5), (\"P10\", \"연료 필터\", \"engine\", 0.7, 0.8, 0.7),\n", + " (\"P11\", \"제동 밸브\", \"brake\", 0.5, 6.0, 3.0), (\"P12\", \"조향 액추에이터\", \"drive\", 0.4, 9.0, 5.0),\n", + " (\"P13\", \"공기 필터\", \"engine\", 0.3, 0.5, 0.5), (\"P14\", \"통신 인터페이스 모듈\", \"electric\", 0.25, 3.0, 1.5),\n", + " (\"P15\", \"윤활유 압력 센서\", \"engine\", 0.2, 1.0, 1.0), (\"P16\", \"팬 구동 모터\", \"cooling\", 0.15, 2.0, 2.0)]\n", + "PID = [p[0] for p in PARTS]\n", + "PNAME = {p[0]: p[1] for p in PARTS}; PSYS = {p[0]: p[2] for p in PARTS}; RATE = {p[0]: p[3] for p in PARTS}\n", + "COST = {p[0]: p[4] for p in PARTS}; REPH = {p[0]: p[5] for p in PARTS}\n", + "PAIRS = [(\"P01\", \"P02\"), (\"P03\", \"P04\"), (\"P05\", \"P08\"), (\"P06\", \"P10\"), (\"P09\", \"P14\"), (\"P13\", \"P15\")]\n", + "SHARED = {p: f\"S{k + 1:02d}\" for k, pr in enumerate(PAIRS) for p in pr} # 짝 부품이 함께 쓰는 모호한 코드\n", + "SPEC = {p: f\"C{p[1:]}1\" for p in PID} # 부품 고유 코드\n", + "SYS = [\"engine\", \"electric\", \"drive\", \"hydraulic\", \"brake\", \"cooling\"]\n", + "SYMP = {s: f\"G{i + 1:02d}\" for i, s in enumerate(SYS)} # 계통 증상 코드\n", + "CODES = sorted(set(SPEC.values()) | set(SHARED.values()) | set(SYMP.values()))\n", + "CASCADE = {\"P01\": [(\"P10\", 0.20)], \"P02\": [(\"P06\", 0.20)], \"P03\": [(\"P04\", 0.25)], \"P04\": [(\"P09\", 0.25)],\n", + " \"P05\": [(\"P08\", 0.30)], \"P06\": [(\"P16\", 0.30)], \"P09\": [(\"P14\", 0.20)], \"P12\": [(\"P07\", 0.20)]}\n", + "CRIT_SYS = {\"engine\", \"drive\", \"brake\"} # 기동·제동 불능 위험 계통(경보 대상)\n", + "MONTHS = 18\n", + "\n", + "\n", + "def make_fleet(n=100, seed=0):\n", + " rng = np.random.default_rng(seed)\n", + " f = pd.DataFrame({\"veh\": [f\"V{i:03d}\" for i in range(n)], \"age\": rng.integers(1, 26, n).astype(float),\n", + " \"terrain\": rng.uniform(0, 1, n), \"load\": rng.uniform(0, 1, n), \"pm_gap\": rng.uniform(20, 200, n),\n", + " \"humid\": rng.uniform(0, 1, n), \"base_h\": rng.uniform(55, 125, n)})\n", + " f[\"mult\"] = np.exp(0.045 * (f[\"age\"] - 13) + 0.9 * (f[\"terrain\"] - .5) + 0.8 * (f[\"load\"] - .5)\n", + " + 0.006 * (f[\"pm_gap\"] - 110) + 0.7 * (f[\"humid\"] - .5))\n", + " return f\n", + "\n", + "\n", + "def make_data(seed=1, months=MONTHS):\n", + " \"\"\"반환: fleet(차량 속성), ev(정비 이력 1건=1행: 발생 DTC 목록, 실제 고장 부품), log(DTC 발생 로그, 행=코드 1건)\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " fleet = make_fleet()\n", + " V = len(fleet)\n", + " temp = 12 + 14 * np.sin(2 * np.pi * (np.arange(months) - 3) / 12) # 월평균 기온(가상)\n", + " hours = fleet[\"base_h\"].values[:, None] * (1 + 0.25 * np.sin(2 * np.pi * np.arange(months) / 12 + 1)) * rng.lognormal(0, .12, (V, months))\n", + " fleet[\"hours\"] = hours.sum(axis=1) # 차량별 18개월 총 가동시간\n", + " fleet.attrs[\"month_h\"] = hours.sum(axis=0) # 월별 전체 가동시간(고장률 분모)\n", + " evs = []\n", + " for p in PID:\n", + " s = np.ones(months)\n", + " if p in (\"P03\", \"P04\"): s = np.exp(-0.06 * (temp - 12)) # 한랭기 전기계통 증가\n", + " if p in (\"P06\", \"P16\"): s = np.exp(0.05 * (temp - 12)) # 혹서기 냉각계통 증가\n", + " cnt = rng.poisson(RATE[p] * hours / 1000 * fleet[\"mult\"].values[:, None] * s[None, :])\n", + " for v, m in zip(*np.nonzero(cnt)):\n", + " for _ in range(cnt[v, m]):\n", + " evs.append((fleet[\"veh\"][v], int(m), m * 720 + rng.uniform(0, 720), p, float(temp[m] + rng.normal(0, 3))))\n", + " ev = pd.DataFrame(evs, columns=[\"veh\", \"month\", \"t\", \"part\", \"temp\"]).sort_values(\"t\").reset_index(drop=True)\n", + " ev[\"ev_id\"] = [f\"W{i:05d}\" for i in range(len(ev))]\n", + " rows, codes = [], []\n", + " for e in ev.itertuples():\n", + " c, r = [], rng.random(3)\n", + " if r[0] < 0.6: c.append(SPEC[e.part])\n", + " if e.part in SHARED and r[1] < 0.6: c.append(SHARED[e.part])\n", + " if r[2] < 0.5: c.append(SYMP[PSYS[e.part]])\n", + " if not c: c.append(SHARED.get(e.part, SPEC[e.part]))\n", + " for q, pr in CASCADE.get(e.part, []):\n", + " if rng.random() < pr: c.append(SPEC[q])\n", + " if rng.random() < 0.12: c.append(SPEC[PID[rng.integers(0, 16)]])\n", + " c = list(dict.fromkeys(c))\n", + " rng.shuffle(c) # 동시 발생 코드의 기록 순서는 임의\n", + " codes.append(c)\n", + " for k, cd in enumerate(c):\n", + " rows.append((e.ev_id, e.veh, e.t + k * 0.1, cd))\n", + " ev[\"codes\"] = codes\n", + " log = pd.DataFrame(rows, columns=[\"ev_id\", \"veh\", \"ts\", \"code\"])\n", + " return fleet, ev, log\n", + "\n", + "# ---------------------------------------------------------------------------\n", + "# 공통 분석 조각: 코드 멀티핫 행렬, 차량 맥락 피처, 학습/검증 분할(앞 12개월 학습, 뒤 6개월 검증)\n", + "# ---------------------------------------------------------------------------\n", + "CIDX = {c: i for i, c in enumerate(CODES)}\n", + "SPLIT_M = 12\n", + "\n", + "\n", + "def multi_hot(ev):\n", + " # [TODO : 학생 실습 9] AAR 기반 연관 고장 오진단 사례 분석 및 고장 룰베이스 재튜닝 관련 핵심 로직을 구현하세요.\n", + " # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n", + " # <<< 여기에 코드를 작성하세요 >>>\n", + " pass # 작성 후 pass를 제거하세요.\n", + "def context(ev, fleet):\n", + " # [TODO : 학생 실습 9] AAR 기반 연관 고장 오진단 사례 분석 및 고장 룰베이스 재튜닝 관련 핵심 로직을 구현하세요.\n", + " # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n", + " # <<< 여기에 코드를 작성하세요 >>>\n", + " pass # 작성 후 pass를 제거하세요.\n", + "def feats(ev, fleet):\n", + " X = multi_hot(ev)\n", + " n = X.sum(axis=1, keepdims=True) / 3\n", + " return np.hstack([X, n, context(ev, fleet)])\n", + "\n", + "\n", + "def monthly_counts(ev):\n", + " return ev.groupby([\"month\", \"part\"]).size().unstack(fill_value=0).reindex(columns=PID, fill_value=0)\n", + "\n", + "\n", + "def build_monthly(ev, fleet, top_n=8):\n", + " \"\"\"부품별 월간 고장 건수 예측용 표: 지연값(lag), 이동평균, 추세, 기대 건수(가동시간 x 설계 고장률), 기온 상호작용, 계절 성분\"\"\"\n", + " mc = ev.groupby([\"month\", \"part\"]).size().unstack(fill_value=0)\n", + " top = ev[\"part\"].value_counts().index[:top_n]\n", + " mh, tm = fleet.attrs[\"month_h\"], ev.groupby(\"month\")[\"temp\"].mean().values\n", + " rows = []\n", + " for p in top:\n", + " c = mc[p].values.astype(float)\n", + " for m in range(3, MONTHS):\n", + " L = np.log1p(c)\n", + " rows.append({\"part\": p, \"month\": m, \"y\": c[m], \"lag1\": L[m - 1], \"lag2\": L[m - 2], \"lag3\": L[m - 3], \"roll3\": L[m - 3:m].mean(),\n", + " \"trend\": L[m - 1] - L[m - 2], \"roll3_std\": L[m - 3:m].std(), \"log_expected\": np.log(mh[m] / 1000 * RATE[p]),\n", + " \"rate_lag1\": c[m - 1] / (mh[m - 1] / 1000) / RATE[p], \"temp\": tm[m] / 10, \"cold_elec\": max(0, 12 - tm[m]) / 10 * (p in (\"P03\", \"P04\")),\n", + " \"heat_cool\": max(0, tm[m] - 12) / 10 * (p in (\"P06\", \"P16\")), \"sin\": np.sin(2 * np.pi * m / 12), \"cos\": np.cos(2 * np.pi * m / 12)})\n", + " D = pd.DataFrame(rows)\n", + " return D, [c for c in D.columns if c not in (\"part\", \"month\", \"y\")], top\n", + "\n", + "fleet, ev, log = make_data()\n", + "train, aar, val = ev[ev[\"month\"] < 12], ev[(ev[\"month\"] >= 12) & (ev[\"month\"] < 15)].reset_index(drop=True), ev[ev[\"month\"] >= 15].reset_index(drop=True)\n", + "\n", + "# 1) 룰베이스: 점수(부품) = sum[코드 유형 가중치 x P(부품 | 코드)] + 사전확률 보정. 학습 기간 조건부확률표를 쓴다.\n", + "Xtr = multi_hot(train)\n", + "Y = pd.get_dummies(train[\"part\"]).reindex(columns=PID, fill_value=0).values.astype(float)\n", + "COND = (Xtr.T @ Y) / np.maximum(Xtr.sum(axis=0)[:, None], 1) # P(부품 | 코드)\n", + "PRIOR = np.log(Y.mean(axis=0) + 1e-9)\n", + "CTYPE = np.array([0 if c in SPEC.values() else 1 if c in SHARED.values() else 2 for c in CODES]) # 0 고유 / 1 공유 / 2 계통 증상\n", + "V1 = {\"w\": (1.0, 1.0, 1.0), \"beta\": 0.0} # 기존 룰베이스(모든 코드 동일 가중, 사전확률 미반영)\n", + "\n", + "\n", + "def predict(ev_, p):\n", + " W = np.array(p[\"w\"])[CTYPE][:, None] * COND\n", + " S = multi_hot(ev_) @ W + p[\"beta\"] * PRIOR[None, :]\n", + " base = np.array(PID)[S.argmax(axis=1)]\n", + " ov = p.get(\"override\", {}) # AAR에서 만든 예외 규칙(DTC 조합 -> 부품)이 우선\n", + " return np.array([ov.get(frozenset(c), b) for c, b in zip(ev_[\"codes\"], base)])\n", + "\n", + "\n", + "def cost(pred, true):\n", + " return sum(REPH[t] if p == t else COST[p] + REPH[p] + 3.0 + REPH[t] for p, t in zip(pred, true))\n", + "\n", + "\n", + "def err_stats(pred, true):\n", + " wrong = pred != true.values\n", + " return int(wrong.sum()), sum(REPH[p] + 3.0 + REPH[t] for p, t in zip(pred[wrong], true[wrong])), int(wrong.sum())\n", + "\n", + "\n", + "# 2) AAR: 오진단 원인 분류\n", + "pa = predict(aar, V1)\n", + "casc = {(p, q) for p, lst in CASCADE.items() for q, _ in lst}\n", + "cause = []\n", + "for cs, t, q in zip(aar[\"codes\"], aar[\"part\"], pa):\n", + " if t == q: continue\n", + " sp = [c for c in cs if c in SPEC.values()]\n", + " if not sp: cause.append(\"모호 코드(공유·증상)만 입력\")\n", + " elif len(sp) >= 2 and ((t, q) in casc or (q, t) in casc): cause.append(\"연쇄 고장 코드 오인\")\n", + " elif len(sp) >= 2: cause.append(\"복수 고유 코드 충돌(잡음 포함)\")\n", + " else: cause.append(\"기타(고유 코드 1개, 확률 편향)\")\n", + "C = pd.Series(cause).value_counts()\n", + "print(f\"[교육용가상자료] AAR 기간(12~14월) 정비 {len(aar)}건: 기존 룰베이스 오진단 {len(cause)}건 ({len(cause) / len(aar):.1%})\")\n", + "for k, v in C.items():\n", + " print(f\" {k:24s} {v:4d}건 ({v / len(cause):.0%})\")\n", + "ex = [(cs, PNAME[t], PNAME[q]) for cs, t, q in zip(aar[\"codes\"], aar[\"part\"], pa) if t != q and sum(c in SPEC.values() for c in cs) >= 2][:1]\n", + "print(\" 사례:\", f\"입력 DTC {ex[0][0]} -> 오진단 {ex[0][2]}, 실제 {ex[0][1]}\" if ex else \"-\")\n", + "\n", + "# 3) 재튜닝: (가) 코드 유형 가중치·사전확률 격자 탐색, (나) AAR 오진단 조합에서 예외 규칙 추출. 모두 AAR 기간 자료만 쓰고 검증 기간은 건드리지 않는다.\n", + "best = None\n", + "for w in itertools.product((0.5, 1.0, 1.5, 2.0), repeat=3):\n", + " for beta in (0.0, 0.2, 0.5, 1.0):\n", + " p = {\"w\": w, \"beta\": beta}\n", + " c = cost(predict(aar, p), aar[\"part\"])\n", + " if best is None or c < best[0]: best = (c, p)\n", + "V2 = dict(best[1])\n", + "pa2 = predict(aar, V2)\n", + "bad_sets = {frozenset(c) for c, t, q in zip(aar[\"codes\"], aar[\"part\"], pa2) if t != q}\n", + "grp = {}\n", + "for c, t in zip(aar[\"codes\"], aar[\"part\"]):\n", + " grp.setdefault(frozenset(c), Counter())[t] += 1\n", + "V2[\"override\"] = {k: v.most_common(1)[0][0] for k, v in grp.items()\n", + " if k in bad_sets and sum(v.values()) >= 3 and v.most_common(1)[0][1] / sum(v.values()) >= 0.6}\n", + "print(f\"\\n재튜닝 결과: 코드 유형 가중치(고유·공유·증상) {V1['w']} -> {V2['w']}, 사전확률 보정 {V1['beta']} -> {V2['beta']}, AAR 예외 규칙 {len(V2['override'])}개 추가\")\n", + "rows = []\n", + "for nm, d in ((\"AAR 기간(튜닝)\", aar), (\"검증 기간(15~17월)\", val)):\n", + " p1, p2 = predict(d, V1), predict(d, V2)\n", + " rows.append((nm, (p1 == d[\"part\"].values).mean(), (p2 == d[\"part\"].values).mean(), cost(p1, d[\"part\"]), cost(p2, d[\"part\"])))\n", + " print(f\" {nm:14s} 정확도 {rows[-1][1]:.1%} -> {rows[-1][2]:.1%}, 비용지수 {rows[-1][3]:.0f} -> {rows[-1][4]:.0f}\")\n", + "\n", + "# 4) ROI 3대 지표(검증 기간 3개월, 가정 단가 기준 상대값)\n", + "p1, p2 = predict(val, V1), predict(val, V2)\n", + "n1, h1, _ = err_stats(p1, val[\"part\"]); n2, h2, _ = err_stats(p2, val[\"part\"])\n", + "saved = cost(p1, val[\"part\"]) - cost(p2, val[\"part\"])\n", + "wasted1 = sum(COST[p] for p, t in zip(p1, val[\"part\"]) if p != t); wasted2 = sum(COST[p] for p, t in zip(p2, val[\"part\"]) if p != t)\n", + "print(\"\\n[ROI 3대 정량 효과 지표] (검증 3개월, 가정 단가)\")\n", + "print(f\" 1) 비용 절감: 비용지수 {saved:.0f} 절감 ({saved / cost(p1, val['part']):.1%}), 연환산 약 {saved * 4:.0f}\")\n", + "print(f\" 2) 시간 단축: 오진단 재작업 {h1:.0f}h -> {h2:.0f}h ({1 - h2 / h1:.1%} 단축)\")\n", + "print(f\" 3) 헛교체 부품 비용: {wasted1:.0f} -> {wasted2:.0f} ({1 - wasted2 / wasted1:.1%} 감소), 오진단 {n1}건 -> {n2}건\")\n", + "print(\"[도입 로드맵] 단기(1~3월): 가중치 갱신 + 정비관 확인 병행 / 중기(3~12월): AAR 분기 반복·신규 코드 반영 / 장기(1년~): 군수 정보체계 이력 연동\")\n", + "\n", + "fig, ax = plt.subplots(1, 2, figsize=(10, 3.5))\n", + "ax[0].barh([f\"cause {i + 1}\" for i in range(len(C))][::-1], C.values[::-1]); ax[0].set_title(\"Misdiagnosis causes (AAR period)\")\n", + "ax[1].bar([\"before\", \"after\"], [rows[1][1], rows[1][2]]); ax[1].set_ylim(0.5, 1); ax[1].set_title(\"Validation accuracy\")\n", + "plt.tight_layout(); plt.show()\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 09 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + "[교육용가상자료] AAR 기간(12~14월) 정비 2336건: 기존 룰베이스 오진단 522건 (22.3%)\n", + " 모호 코드(공유·증상)만 입력 243건 (47%)\n", + " 기타(고유 코드 1개, 확률 편향) 237건 (45%)\n", + " 연쇄 고장 코드 오인 26건 (5%)\n", + " 복수 고유 코드 충돌(잡음 포함) 16건 (3%)\n", + " 사례: 입력 DTC ['S04', 'G01', 'C081', 'C101'] -> 오진단 연료분사기, 실제 연료 필터\n", + "\n", + "재튜닝 결과: 코드 유형 가중치(고유·공유·증상) (1.0, 1.0, 1.0) -> (2.0, 1.0, 0.5), 사전확률 보정 0.0 -> 0.0, AAR 예외 규칙 31개 추가\n", + " AAR 기간(튜닝) 정확도 77.7% -> 82.9%, 비용지수 11938 -> 10805\n", + " 검증 기간(15~17월) 정확도 78.1% -> 80.1%, 비용지수 8449 -> 7832\n", + "\n", + "[ROI 3대 정량 효과 지표] (검증 3개월, 가정 단가)\n", + " 1) 비용 절감: 비용지수 616 절감 (7.3%), 연환산 약 2466\n", + " 2) 시간 단축: 오진단 재작업 3038h -> 2713h (10.7% 단축)\n", + " 3) 헛교체 부품 비용: 1778 -> 1441 (18.9% 감소), 오진단 341건 -> 309건\n", + "[도입 로드맵] 단기(1~3월): 가중치 갱신 + 정비관 확인 병행 / 중기(3~12월): AAR 분기 반복·신규 코드 반영 / 장기(1년~): 군수 정보체계 이력 연동\n", + "```\n", + "
\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 10. 무기체계 고장 코드(DTC) 기반 연관 고장 예측 및 정비 가이드 도구 완성\n", + "**소요시간**: 약 3.5시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "무기체계 고장 코드(DTC) 기반 연관 고장 예측 및 정비 가이드 도구를 완성한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **DTC 입력부터 연관 고장 예측, 정비 가이드, 승인 대상 선별, 재학습까지 한 파일로 통합하고 자체 점검과 브리핑 요약을 출력하는 도구를 완성한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **월별 정비 실적으로 진단 모델을 학습하고, 신규 실적으로 도전자 모델을 만들어 챔피언과 검증 오차를 비교한 뒤 비슷하거나 나을 때만 교체 수행하고 검증하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `docs/mnt03_operations_guide.md 및 pipeline/mnt03_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": [ + "\"\"\"DTC 로그 검증·정제, 진단 모델 자동 재학습(챔피언·도전자 비교 후 교체), 고장 코드 기반 연관 고장 진단과 신뢰도 산출, 신뢰도 낮은 건의 정비관 승인 대기 분리, 다빈도 고장품목 월간 소요 예측까지 정비 가이드 도구를 한 파일로 통합 실행하고, 자체 점검·운영 문서 완비 점검·정비 담당 장교 브리핑 요약을 출력한다.\"\"\"\n", + "import time\n", + "import numpy as np\n", + "import pandas as pd\n", + "import matplotlib.pyplot as plt\n", + "from sklearn.linear_model import LogisticRegression, Ridge\n", + "from sklearn.preprocessing import StandardScaler\n", + "from sklearn.pipeline import make_pipeline\n", + "\n", + "# ---------------------------------------------------------------------------\n", + "# [교육용가상자료] 가상 차량 100대의 18개월 고장 진단 코드(DTC)·정비 이력 생성기 (실제 부대·장비·정비 실적 없음)\n", + "# 부품 16종(1000가동시간당 고장률 차등), DTC 28종(부품 고유 코드, 짝 부품 공유 코드, 계통 증상 코드)\n", + "# 고장 1건(정비 이력)마다 DTC가 1~5개 동시 발생하고, 연쇄 고장 코드와 일시 잡음 코드가 섞인다.\n", + "# ---------------------------------------------------------------------------\n", + "PARTS = [ # (ID, 이름, 계통, 1000가동시간당 고장률, 가상 단가지수, 수리시간 h)\n", + " (\"P01\", \"연료분사기\", \"engine\", 12.0, 3.0, 2.0), (\"P02\", \"터보차저\", \"engine\", 9.0, 8.0, 5.0),\n", + " (\"P03\", \"축전지\", \"electric\", 6.5, 4.0, 1.5), (\"P04\", \"발전기\", \"electric\", 4.0, 9.0, 3.0),\n", + " (\"P05\", \"변속기 제어모듈\", \"drive\", 3.0, 12.0, 6.0), (\"P06\", \"냉각수 펌프\", \"engine\", 2.2, 3.5, 3.0),\n", + " (\"P07\", \"유압 펌프\", \"hydraulic\", 1.6, 10.0, 4.0), (\"P08\", \"속도 센서\", \"drive\", 1.2, 2.0, 1.5),\n", + " (\"P09\", \"ECU 커넥터\", \"electric\", 0.9, 2.5, 1.5), (\"P10\", \"연료 필터\", \"engine\", 0.7, 0.8, 0.7),\n", + " (\"P11\", \"제동 밸브\", \"brake\", 0.5, 6.0, 3.0), (\"P12\", \"조향 액추에이터\", \"drive\", 0.4, 9.0, 5.0),\n", + " (\"P13\", \"공기 필터\", \"engine\", 0.3, 0.5, 0.5), (\"P14\", \"통신 인터페이스 모듈\", \"electric\", 0.25, 3.0, 1.5),\n", + " (\"P15\", \"윤활유 압력 센서\", \"engine\", 0.2, 1.0, 1.0), (\"P16\", \"팬 구동 모터\", \"cooling\", 0.15, 2.0, 2.0)]\n", + "PID = [p[0] for p in PARTS]\n", + "PNAME = {p[0]: p[1] for p in PARTS}; PSYS = {p[0]: p[2] for p in PARTS}; RATE = {p[0]: p[3] for p in PARTS}\n", + "COST = {p[0]: p[4] for p in PARTS}; REPH = {p[0]: p[5] for p in PARTS}\n", + "PAIRS = [(\"P01\", \"P02\"), (\"P03\", \"P04\"), (\"P05\", \"P08\"), (\"P06\", \"P10\"), (\"P09\", \"P14\"), (\"P13\", \"P15\")]\n", + "SHARED = {p: f\"S{k + 1:02d}\" for k, pr in enumerate(PAIRS) for p in pr} # 짝 부품이 함께 쓰는 모호한 코드\n", + "SPEC = {p: f\"C{p[1:]}1\" for p in PID} # 부품 고유 코드\n", + "SYS = [\"engine\", \"electric\", \"drive\", \"hydraulic\", \"brake\", \"cooling\"]\n", + "SYMP = {s: f\"G{i + 1:02d}\" for i, s in enumerate(SYS)} # 계통 증상 코드\n", + "CODES = sorted(set(SPEC.values()) | set(SHARED.values()) | set(SYMP.values()))\n", + "CASCADE = {\"P01\": [(\"P10\", 0.20)], \"P02\": [(\"P06\", 0.20)], \"P03\": [(\"P04\", 0.25)], \"P04\": [(\"P09\", 0.25)],\n", + " \"P05\": [(\"P08\", 0.30)], \"P06\": [(\"P16\", 0.30)], \"P09\": [(\"P14\", 0.20)], \"P12\": [(\"P07\", 0.20)]}\n", + "CRIT_SYS = {\"engine\", \"drive\", \"brake\"} # 기동·제동 불능 위험 계통(경보 대상)\n", + "MONTHS = 18\n", + "\n", + "\n", + "def make_fleet(n=100, seed=0):\n", + " rng = np.random.default_rng(seed)\n", + " f = pd.DataFrame({\"veh\": [f\"V{i:03d}\" for i in range(n)], \"age\": rng.integers(1, 26, n).astype(float),\n", + " \"terrain\": rng.uniform(0, 1, n), \"load\": rng.uniform(0, 1, n), \"pm_gap\": rng.uniform(20, 200, n),\n", + " \"humid\": rng.uniform(0, 1, n), \"base_h\": rng.uniform(55, 125, n)})\n", + " f[\"mult\"] = np.exp(0.045 * (f[\"age\"] - 13) + 0.9 * (f[\"terrain\"] - .5) + 0.8 * (f[\"load\"] - .5)\n", + " + 0.006 * (f[\"pm_gap\"] - 110) + 0.7 * (f[\"humid\"] - .5))\n", + " return f\n", + "\n", + "\n", + "def make_data(seed=1, months=MONTHS):\n", + " \"\"\"반환: fleet(차량 속성), ev(정비 이력 1건=1행: 발생 DTC 목록, 실제 고장 부품), log(DTC 발생 로그, 행=코드 1건)\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " fleet = make_fleet()\n", + " V = len(fleet)\n", + " temp = 12 + 14 * np.sin(2 * np.pi * (np.arange(months) - 3) / 12) # 월평균 기온(가상)\n", + " hours = fleet[\"base_h\"].values[:, None] * (1 + 0.25 * np.sin(2 * np.pi * np.arange(months) / 12 + 1)) * rng.lognormal(0, .12, (V, months))\n", + " fleet[\"hours\"] = hours.sum(axis=1) # 차량별 18개월 총 가동시간\n", + " fleet.attrs[\"month_h\"] = hours.sum(axis=0) # 월별 전체 가동시간(고장률 분모)\n", + " evs = []\n", + " for p in PID:\n", + " s = np.ones(months)\n", + " if p in (\"P03\", \"P04\"): s = np.exp(-0.06 * (temp - 12)) # 한랭기 전기계통 증가\n", + " if p in (\"P06\", \"P16\"): s = np.exp(0.05 * (temp - 12)) # 혹서기 냉각계통 증가\n", + " cnt = rng.poisson(RATE[p] * hours / 1000 * fleet[\"mult\"].values[:, None] * s[None, :])\n", + " for v, m in zip(*np.nonzero(cnt)):\n", + " for _ in range(cnt[v, m]):\n", + " evs.append((fleet[\"veh\"][v], int(m), m * 720 + rng.uniform(0, 720), p, float(temp[m] + rng.normal(0, 3))))\n", + " ev = pd.DataFrame(evs, columns=[\"veh\", \"month\", \"t\", \"part\", \"temp\"]).sort_values(\"t\").reset_index(drop=True)\n", + " ev[\"ev_id\"] = [f\"W{i:05d}\" for i in range(len(ev))]\n", + " rows, codes = [], []\n", + " for e in ev.itertuples():\n", + " c, r = [], rng.random(3)\n", + " if r[0] < 0.6: c.append(SPEC[e.part])\n", + " if e.part in SHARED and r[1] < 0.6: c.append(SHARED[e.part])\n", + " if r[2] < 0.5: c.append(SYMP[PSYS[e.part]])\n", + " if not c: c.append(SHARED.get(e.part, SPEC[e.part]))\n", + " for q, pr in CASCADE.get(e.part, []):\n", + " if rng.random() < pr: c.append(SPEC[q])\n", + " if rng.random() < 0.12: c.append(SPEC[PID[rng.integers(0, 16)]])\n", + " c = list(dict.fromkeys(c))\n", + " rng.shuffle(c) # 동시 발생 코드의 기록 순서는 임의\n", + " codes.append(c)\n", + " for k, cd in enumerate(c):\n", + " rows.append((e.ev_id, e.veh, e.t + k * 0.1, cd))\n", + " ev[\"codes\"] = codes\n", + " log = pd.DataFrame(rows, columns=[\"ev_id\", \"veh\", \"ts\", \"code\"])\n", + " return fleet, ev, log\n", + "\n", + "# ---------------------------------------------------------------------------\n", + "# 공통 분석 조각: 코드 멀티핫 행렬, 차량 맥락 피처, 학습/검증 분할(앞 12개월 학습, 뒤 6개월 검증)\n", + "# ---------------------------------------------------------------------------\n", + "CIDX = {c: i for i, c in enumerate(CODES)}\n", + "SPLIT_M = 12\n", + "\n", + "\n", + "def multi_hot(ev):\n", + " # [TODO : 학생 실습 10] 무기체계 고장 코드(DTC) 기반 연관 고장 예측 및 정비 가이드 도구 완성 관련 핵심 로직을 구현하세요.\n", + " # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n", + " # <<< 여기에 코드를 작성하세요 >>>\n", + " pass # 작성 후 pass를 제거하세요.\n", + "def context(ev, fleet):\n", + " # [TODO : 학생 실습 10] 무기체계 고장 코드(DTC) 기반 연관 고장 예측 및 정비 가이드 도구 완성 관련 핵심 로직을 구현하세요.\n", + " # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n", + " # <<< 여기에 코드를 작성하세요 >>>\n", + " pass # 작성 후 pass를 제거하세요.\n", + "def feats(ev, fleet):\n", + " X = multi_hot(ev)\n", + " n = X.sum(axis=1, keepdims=True) / 3\n", + " return np.hstack([X, n, context(ev, fleet)])\n", + "\n", + "\n", + "def monthly_counts(ev):\n", + " return ev.groupby([\"month\", \"part\"]).size().unstack(fill_value=0).reindex(columns=PID, fill_value=0)\n", + "\n", + "\n", + "def build_monthly(ev, fleet, top_n=8):\n", + " \"\"\"부품별 월간 고장 건수 예측용 표: 지연값(lag), 이동평균, 추세, 기대 건수(가동시간 x 설계 고장률), 기온 상호작용, 계절 성분\"\"\"\n", + " mc = ev.groupby([\"month\", \"part\"]).size().unstack(fill_value=0)\n", + " top = ev[\"part\"].value_counts().index[:top_n]\n", + " mh, tm = fleet.attrs[\"month_h\"], ev.groupby(\"month\")[\"temp\"].mean().values\n", + " rows = []\n", + " for p in top:\n", + " c = mc[p].values.astype(float)\n", + " for m in range(3, MONTHS):\n", + " L = np.log1p(c)\n", + " rows.append({\"part\": p, \"month\": m, \"y\": c[m], \"lag1\": L[m - 1], \"lag2\": L[m - 2], \"lag3\": L[m - 3], \"roll3\": L[m - 3:m].mean(),\n", + " \"trend\": L[m - 1] - L[m - 2], \"roll3_std\": L[m - 3:m].std(), \"log_expected\": np.log(mh[m] / 1000 * RATE[p]),\n", + " \"rate_lag1\": c[m - 1] / (mh[m - 1] / 1000) / RATE[p], \"temp\": tm[m] / 10, \"cold_elec\": max(0, 12 - tm[m]) / 10 * (p in (\"P03\", \"P04\")),\n", + " \"heat_cool\": max(0, tm[m] - 12) / 10 * (p in (\"P06\", \"P16\")), \"sin\": np.sin(2 * np.pi * m / 12), \"cos\": np.cos(2 * np.pi * m / 12)})\n", + " D = pd.DataFrame(rows)\n", + " return D, [c for c in D.columns if c not in (\"part\", \"month\", \"y\")], top\n", + "\n", + "# ---------------------------------------------------------------------------\n", + "# [교육용가상자료] 부품별 정비 조치 가이드라인(요약 3단계). 실제 정비 교범이 아니라 교육용으로 만든 예시 문구다.\n", + "# ---------------------------------------------------------------------------\n", + "GUIDE = {\n", + " \"P01\": \"연료 압력 측정 -> 분사기 누설·막힘 확인 -> 불량 시 분사기 교체\", \"P02\": \"흡기 압력·이물 점검 -> 축 유격 확인 -> 불량 시 터보차저 교체\",\n", + " \"P03\": \"단자 전압·부식 점검 -> 충전 계통 확인 -> 불량 시 축전지 교체\", \"P04\": \"발전 전압 측정 -> 벨트·배선 점검 -> 불량 시 발전기 교체\",\n", + " \"P05\": \"제어모듈 소프트웨어·커넥터 점검 -> 변속 이력 확인 -> 불량 시 모듈 교체\", \"P06\": \"냉각수량·누수 점검 -> 펌프 작동음 확인 -> 불량 시 펌프 교체\",\n", + " \"P07\": \"유압 오일량·압력 점검 -> 펌프 누설 확인 -> 불량 시 펌프 교체\", \"P08\": \"센서 배선·간극 점검 -> 신호 파형 확인 -> 불량 시 센서 교체\",\n", + " \"P09\": \"커넥터 접촉·부식 점검 -> 핀 상태 확인 -> 불량 시 커넥터 교체\", \"P10\": \"필터 오염도 점검 -> 연료 흐름 확인 -> 불량 시 필터 교체\",\n", + " \"P11\": \"제동 압력 점검 -> 밸브 작동 확인 -> 불량 시 밸브 교체(안전 확인 필수)\", \"P12\": \"조향 반응 점검 -> 액추에이터 작동 확인 -> 불량 시 교체(안전 확인 필수)\",\n", + " \"P13\": \"필터 차압 점검 -> 오염 확인 -> 불량 시 필터 교체\", \"P14\": \"통신 링크 상태 점검 -> 모듈 전원 확인 -> 불량 시 모듈 교체\",\n", + " \"P15\": \"압력 센서 신호 점검 -> 윤활유량 확인 -> 불량 시 센서 교체\", \"P16\": \"팬 모터 전류 점검 -> 회전 확인 -> 불량 시 모터 교체\"}\n", + "\n", + "CONFIG = {\"conf_review\": 0.8, \"retrain_tolerance\": 0.01, \"mape_target\": 0.25}\n", + "OPS_GUIDE = { # docs/mnt03_operations_guide.md 요약\n", + " \"1. 목적과 범위\": \"DTC 기반 고장 부품 진단 보조와 다빈도 고장품목 식별. 최종 정비 판단은 숙련 정비관.\",\n", + " \"2. 입력 데이터\": \"DTC 로그(차량 ID, 시각, 코드), 정비 이력(정비 건 ID, 고장 부품), 차량 속성. 실제 부대 자료 외부 AI 입력 금지.\",\n", + " \"3. 일일 운용 절차\": \"로그 검증 -> 정비 건 묶음 -> 진단·신뢰도 산출 -> 낮은 신뢰도 건 승인 대기 -> 조치 후 정비 이력 입력.\",\n", + " \"4. 승인(HITL) 기준\": \"신뢰도 0.8 미만·다계통 동시 발생은 숙련 정비관 결정 전 확정 금지, 무응답 시 재확인 표시, 결정 사유 기록.\",\n", + " \"5. 재학습 절차\": \"월 1회 신규 정비 이력으로 도전자 학습, 검증 정확도가 나빠지지 않을 때만 교체.\",\n", + " \"6. 장애 대응\": \"모델 오류 시 단일 코드 매핑표로 대체, 손상 로그는 수동 확인 목록으로 분리.\",\n", + " \"7. 연동 인터페이스\": \"국방 군수 정보체계와 DTC·정비 이력 표준 필드(장비코드·분류군·시각)를 CSV/API로 교환하는 안을 차기 과제로 둔다.\",\n", + "}\n", + "\n", + "fleet, ev, log = make_data()\n", + "OLD, NEW, OPS = ev[ev[\"month\"] < 12], ev[(ev[\"month\"] >= 12) & (ev[\"month\"] < 15)], ev[ev[\"month\"] >= 15].reset_index(drop=True)\n", + "t_start = time.perf_counter()\n", + "\n", + "\n", + "def fit(d):\n", + " return LogisticRegression(C=3, max_iter=500).fit(feats(d, fleet), d[\"part\"])\n", + "\n", + "\n", + "def retrain(champion, old, new):\n", + " \"\"\"도전자를 (기존 + 신규 앞 2/3 기간)으로 학습하고, 신규 뒤 1/3 기간에서 챔피언과 정확도를 비교해 비슷하거나 낫다면 교체.\"\"\"\n", + " cut = new[\"month\"].min() + 2\n", + " hold = new[new[\"month\"] >= cut]\n", + " challenger = fit(pd.concat([old, new[new[\"month\"] < cut]], ignore_index=True))\n", + " a_old = (champion.predict(feats(hold, fleet)) == hold[\"part\"].values).mean()\n", + " a_new = (challenger.predict(feats(hold, fleet)) == hold[\"part\"].values).mean()\n", + " swap = a_new >= a_old - CONFIG[\"retrain_tolerance\"]\n", + " return (challenger if swap else champion), {\"챔피언\": round(float(a_old), 3), \"도전자\": round(float(a_new), 3), \"교체\": bool(swap)}\n", + "\n", + "\n", + "def validate(df):\n", + " ok = df[\"codes\"].map(lambda cs: len(cs) > 0 and all(c in CODES for c in cs)) & df[\"veh\"].isin(fleet[\"veh\"])\n", + " return df[ok].copy(), df[~ok].copy()\n", + "\n", + "\n", + "def predict_safe(model, d):\n", + " \"\"\"모델 오류 시 단일 코드 -> 최다 부품 매핑표로 대체한다.\"\"\"\n", + " try:\n", + " P = model.predict_proba(feats(d, fleet))\n", + " return model.classes_[P.argmax(axis=1)], P.max(axis=1), \"모델\"\n", + " except Exception:\n", + " mp = OLD.assign(first=OLD[\"codes\"].str[0]).groupby(\"first\")[\"part\"].agg(lambda s: s.value_counts().index[0])\n", + " return d[\"codes\"].str[0].map(mp).fillna(\"P01\").values, np.full(len(d), 0.5), \"매핑표 대체\"\n", + "\n", + "\n", + "# ---------------- 1. 통합 실행 ----------------\n", + "champion = fit(OLD)\n", + "model, rt = retrain(champion, OLD, NEW)\n", + "print(f\"[교육용가상자료] 재학습: {rt} -> {'도전자로 교체' if rt['교체'] else '챔피언 유지'}\")\n", + "ops_all = OPS.copy()\n", + "ops_all.at[3, \"codes\"] = [\"ZZ9\"]; ops_all.at[40, \"codes\"] = [] # 손상 로그 2건 주입\n", + "ops, manual = validate(ops_all)\n", + "ops = ops.reset_index(drop=True)\n", + "pred, conf, src = predict_safe(model, ops)\n", + "queue = ops[conf < CONFIG[\"conf_review\"]]\n", + "alerts = [(i, p) for i, p in enumerate(pred) if PSYS[p] in CRIT_SYS and conf[i] >= CONFIG[\"conf_review\"]]\n", + "acc = (pred == ops[\"part\"].values).mean()\n", + "hi = conf >= CONFIG[\"conf_review\"]\n", + "print(f\"운용 정비 건 {len(ops_all)}건: 진단 {len(ops)}건, 수동 확인 {len(manual)}건, 출처 '{src}', 정확도 {acc:.1%} (신뢰도 0.8 이상 {hi.sum()}건은 {(pred[hi] == ops['part'].values[hi]).mean():.1%})\")\n", + "\n", + "# 다빈도 품목 월간 소요 예측(마지막 달): 과거 월로 학습한 릿지로 다음 달 건수 예측\n", + "D, FE, top = build_monthly(ev, fleet)\n", + "a, b = D[D[\"month\"] < MONTHS - 1], D[D[\"month\"] == MONTHS - 1]\n", + "fc = np.exp(make_pipeline(StandardScaler(), Ridge(alpha=3.0)).fit(a[FE], np.log(a[\"y\"])).predict(b[FE]))\n", + "fmape = float(np.mean(np.abs(b[\"y\"].values - fc) / b[\"y\"].values))\n", + "print(\"다빈도 품목 다음 달 소요 예측: \" + \", \".join(f\"{PNAME[p]} {f:.0f}건(실제 {y:.0f})\" for p, f, y in zip(b[\"part\"].values[:3], fc[:3], b[\"y\"].values[:3])) + f\" / 8개 품목 MAPE {fmape:.1%}\")\n", + "sec = time.perf_counter() - t_start\n", + "\n", + "# ---------------- 2. 자체 점검 ----------------\n", + "_, _, bad_src = predict_safe(None, ops.head(3))\n", + "checks = {\n", + " \"로그 유실 0건\": len(ops) + len(manual) == len(ops_all),\n", + " \"손상 로그 수동 확인 분리\": len(manual) == 2,\n", + " \"재학습 파이프라인 실행\": \"교체\" in rt,\n", + " \"신뢰도 0~1 범위\": bool(((conf >= 0) & (conf <= 1)).all()),\n", + " \"모델 장애 시 매핑표 대체\": bad_src == \"매핑표 대체\",\n", + " \"모든 진단에 조치 가이드 연결\": all(p in GUIDE for p in pred),\n", + " \"저신뢰 건 자동 확정 금지(대기)\": len(queue) == int((conf < CONFIG[\"conf_review\"]).sum()),\n", + " f\"소요 예측 MAPE {CONFIG['mape_target']:.0%} 이하\": fmape <= CONFIG[\"mape_target\"],\n", + "}\n", + "print(f\"\\n자체 점검 {sum(checks.values())}/{len(checks)} 통과, 자동 재학습 파이프라인 {'정상 실행' if checks['재학습 파이프라인 실행'] else '실패'}, 전체 실행 {sec:.1f}초\")\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", + "fig, ax = plt.subplots(1, 2, figsize=(11, 4))\n", + "cnt = pd.Series(pred).value_counts().head(8)\n", + "ax[0].bar(cnt.index, cnt.values); ax[0].set_title(\"Diagnosed parts (operating period)\")\n", + "ax[1].hist(conf, bins=20); ax[1].axvline(CONFIG[\"conf_review\"], ls=\"--\", color=\"k\"); ax[1].set_title(\"Confidence (line = review threshold)\")\n", + "plt.tight_layout(); plt.show()\n", + "\n", + "fr = (ev[\"part\"].value_counts() / fleet[\"hours\"].sum() * 1000)\n", + "cum = ev[\"part\"].value_counts(normalize=True).cumsum()\n", + "a_cnt = int((cum <= 0.8).sum()) + 1\n", + "print(\"\\n[정비 담당 장교 브리핑 요약] [교육용가상자료]\")\n", + "print(f\"1. 18개월 정비 {len(ev)}건: 고장률 상위 \" + \", \".join(f\"{PNAME[p]} {v:.1f}건/1000h\" for p, v in fr.sort_values(ascending=False).head(3).items()))\n", + "print(f\"2. 상위 {a_cnt}개 품목이 전체 고장의 80% 이상: 예비 수리부속 우선 확보 대상\")\n", + "print(f\"3. 운용 기간 진단 {len(ops)}건 중 신뢰도 0.8 이상 {int(hi.sum())}건(정확도 {(pred[hi] == ops['part'].values[hi]).mean():.1%}), 정비관 승인 대기 {len(queue)}건(자동 확정 0건)\")\n", + "print(f\"4. 기동·제동 계통 경보 {len(alerts)}건, 손상 로그 {len(manual)}건 수동 확인\")\n", + "print(f\"5. 진단 모델: {'신규 정비 이력으로 교체' if rt['교체'] else '기존 유지'}, 소요 예측 MAPE {fmape:.1%}\")\n", + "print(\"6. 건의: 다빈도 품목 예방정비 주기 재검토, 국방 군수 정보체계와 DTC·정비 이력 표준 필드 연동 인터페이스 정의\")\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 10 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + "[교육용가상자료] 재학습: {'챔피언': 0.845, '도전자': 0.847, '교체': True} -> 도전자로 교체\n", + "운용 정비 건 1554건: 진단 1552건, 수동 확인 2건, 출처 '모델', 정확도 83.2% (신뢰도 0.8 이상 931건은 99.2%)\n", + "다빈도 품목 다음 달 소요 예측: 연료분사기 133건(실제 147), 터보차저 97건(실제 109), 축전지 44건(실제 35) / 8개 품목 MAPE 22.7%\n", + "\n", + "자체 점검 8/8 통과, 자동 재학습 파이프라인 정상 실행, 전체 실행 18.1초\n", + "운영 문서 7개 장 작성률 100% (목표 100%: 달성) - 목적과 범위, 입력 데이터, 일일 운용 절차, 승인(HITL) 기준, 재학습 절차, 장애 대응, 연동 인터페이스\n", + "\n", + "[정비 담당 장교 브리핑 요약] [교육용가상자료]\n", + "1. 18개월 정비 10681건: 고장률 상위 연료분사기 16.3건/1000h, 터보차저 11.6건/1000h, 축전지 11.6건/1000h\n", + "2. 상위 5개 품목이 전체 고장의 80% 이상: 예비 수리부속 우선 확보 대상\n", + "3. 운용 기간 진단 1552건 중 신뢰도 0.8 이상 931건(정확도 99.2%), 정비관 승인 대기 621건(자동 확정 0건)\n", + "4. 기동·제동 계통 경보 710건, 손상 로그 2건 수동 확인\n", + "5. 진단 모델: 신규 정비 이력으로 교체, 소요 예측 MAPE 22.7%\n", + "6. 건의: 다빈도 품목 예방정비 주기 재검토, 국방 군수 정보체계와 DTC·정비 이력 표준 필드 연동 인터페이스 정의\n", + "```\n", + "
\n" + ] + } + ], + "metadata": { + "colab": { + "provenance": [] + }, + "kernelspec": { + "display_name": "Python 3", + "name": "python3" + }, + "language_info": { + "name": "python" + } + }, + "nbformat": 4, + "nbformat_minor": 0 +} \ No newline at end of file diff --git a/uploads/MNT04/MNT04_solution.ipynb b/uploads/MNT04/MNT04_solution.ipynb new file mode 100644 index 0000000..dd2f335 --- /dev/null +++ b/uploads/MNT04/MNT04_solution.ipynb @@ -0,0 +1,3057 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# 🎯 [MNT04] 고장실적·수리부속 소모 기반 PBL 품목관리\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/MNT04/MNT04_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", + "항공·기갑 장비의 엔진과 구동계는 고장이 나기 전에 진동, 온도, 압력에 미세한 변화가 먼저 나타나지만, 고정 임계값만으로는 고부하 운용일을 이상으로 오인하거나 초기 열화를 놓쳐 수리부속을 제때 확보하지 못한다. 본 프로젝트는 고장 실적과 수리부속 소모를 연결해 정비와 부품 관리의 연계 문제를 학습하는 것을 목표로, 교육용 가상 기동체 48대의 센서 시계열과 부품 소모를 코드로 만들어 분석한다. 센서 데이터셋 정의, 잡음 제거와 FFT 특징량 추출, 임계값 Baseline과 부품 소모 파레토 분석, 오토인코더 기반 미세 변화 감지와 파생 피처 모델, 잔존수명(RUL) 예측과 이상 신뢰도 점수, 설명가능한 경보 화면, 검사관 승인(HITL) 기반 조기 입고 시뮬레이터, 대용량 스트림 스트레스 시험, 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. 헬기/기갑 엔진 및 구동계 진동, 온도, 압력 센서 시계열 데이터셋 정의\n", + "**소요시간**: 약 1.5시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "헬기/기갑 엔진 진동, 온도, 압력 센서 시계열 데이터셋을 정의한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **헬기/기갑 엔진 및 구동계의 진동, 온도, 압력 센서 시계열과 고장·수리부속 소모 실적의 데이터셋 스키마, 데이터 딕셔너리, 업무 프로세스 맵을 정의한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **센서 수집부터 전처리, 특징 추출, 이상 감지, RUL 예측, 검사관 승인, 정비·부품 소모, 사후검토까지 업무 흐름을 단계별로 정리하고, 센서·특징·고장·부품 묶음으로 피처 20개 이상 정의하고 검증하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `docs/mnt04_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": [ + "\"\"\"헬기/기갑 엔진 및 구동계의 진동·온도·압력 센서 시계열, 고장 실적, 수리부속 소모 기록의 데이터셋 스키마·데이터 딕셔너리(피처 20개 이상)와 예지정비·PBL 품목관리 업무 프로세스 맵을 정의하고, 가상 센서 자료로 검증한다.\"\"\"\n", + "import json\n", + "import re\n", + "import numpy as np\n", + "import pandas as pd\n", + "from scipy.stats import kurtosis\n", + "\n", + "# ---------------------------------------------------------------------------\n", + "# [교육용가상자료] 가상 기동체 48대의 엔진·구동계 진동·온도·압력 센서 자료 생성기 (실제 장비·부대·정비 실적 없음)\n", + "# 개체마다 정상 -> 열화 -> 고장(수명종료)까지 하루 1회 측정(진동 파형 256표본 + 온도 + 압력 + 부하)을 만든다.\n", + "# 열화 유형 4가지: bearing(베어링), gear(기어), oil_pump(오일 펌프), cooling(냉각 팬 모터)\n", + "# ---------------------------------------------------------------------------\n", + "FS, NS = 1024, 256 # 가상 표본화 주파수(Hz), 파형 1개당 표본 수\n", + "TT = np.arange(NS) / FS\n", + "MODES = { # 열화 유형: (발생 확률, 평균 수명(일), 교체 수리부속, 가상 단가지수)\n", + " \"bearing\": (0.40, 160, \"주 베어링 세트\", 6.0), \"gear\": (0.25, 175, \"구동 기어 세트\", 9.0),\n", + " \"oil_pump\": (0.20, 150, \"오일 펌프\", 4.0), \"cooling\": (0.15, 140, \"냉각 팬 모터\", 3.0)}\n", + "KIT_COST = 1.0 # 모든 정비에 함께 소모되는 씰·필터 키트 단가지수\n", + "BANDS = {\"b50\": (40, 60), \"b100\": (90, 110), \"b150\": (140, 156), \"b165\": (156, 176), \"b200\": (190, 215)}\n", + "\n", + "\n", + "def make_runs(n=48, seed=1):\n", + " \"\"\"반환: units(개체 정보), snap(일별 측정 요약), W(진동 파형 [측정 수, 256])\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " names = list(MODES)\n", + " mode = rng.choice(names, n, p=[MODES[m][0] for m in names])\n", + " life = np.clip(np.array([rng.normal(MODES[m][1], 18) for m in mode]), 90, 230).astype(int)\n", + " start = np.sort(rng.integers(0, 240, n))\n", + " units = pd.DataFrame({\"unit\": [f\"U{i:02d}\" for i in range(n)], \"mode\": mode, \"life\": life, \"start\": start, \"base_load\": rng.uniform(0.4, 0.75, n)})\n", + " rows = []\n", + " for u in units.itertuples():\n", + " d = np.arange(u.life + 1)\n", + " onset = 0.45 * u.life\n", + " deg = np.clip((d - onset) / (u.life - onset), 0, 1) ** 1.5\n", + " load = np.clip(u.base_load + rng.normal(0, 0.06, len(d)), 0.2, 1.0)\n", + " hi = rng.random(len(d)) < 0.07 # 고부하 운용일(정상이지만 진동·온도 상승)\n", + " load = np.where(hi, rng.uniform(0.95, 1.1, len(d)), load)\n", + " rows.append(pd.DataFrame({\"unit\": u.unit, \"day\": d, \"cal\": u.start + d, \"rul\": u.life - d, \"deg\": deg, \"load\": load, \"hi_load\": hi, \"mode\": u.mode}))\n", + " snap = pd.concat(rows, ignore_index=True)\n", + " S = len(snap)\n", + " snap[\"ambient\"] = 15 + 12 * np.sin(2 * np.pi * snap[\"cal\"] / 365) + rng.normal(0, 2, S)\n", + " dg, ld, md = snap[\"deg\"].values, snap[\"load\"].values, snap[\"mode\"].values\n", + " isb, isg, iso, isc = (md == \"bearing\"), (md == \"gear\"), (md == \"oil_pump\"), (md == \"cooling\")\n", + " snap[\"temp\"] = 55 + 22 * ld + 0.5 * snap[\"ambient\"] + 9 * dg * iso + 26 * dg * isc + 3 * dg * (isb | isg) + rng.normal(0, 1.5, S)\n", + " snap[\"press\"] = 450 - 12 * ld - 85 * dg * iso - 15 * dg * isc + rng.normal(0, 6, S)\n", + " ph = rng.uniform(0, 2 * np.pi, (S, 3))\n", + " a1, a2 = 1.0 * (0.5 + ld), 0.4 * (0.5 + ld)\n", + " W = (a1[:, None] * np.sin(2 * np.pi * 50 * TT + ph[:, :1]) + a2[:, None] * np.sin(2 * np.pi * 100 * TT + ph[:, 1:2])\n", + " + rng.normal(0, 0.35, (S, NS)))\n", + " W += (0.9 * dg * isb)[:, None] * np.sin(2 * np.pi * 165 * TT + ph[:, 2:3])\n", + " W += (0.8 * dg * isg)[:, None] * (np.sin(2 * np.pi * 150 * TT + ph[:, 2:3]) + 0.8 * np.sin(2 * np.pi * 100 * TT))\n", + " W += (0.5 * dg * iso)[:, None] * np.sin(2 * np.pi * 200 * TT + ph[:, 2:3])\n", + " k = rng.poisson(10 * dg * isb) # 베어링 열화: 충격성 펄스(첨도 상승)\n", + " pos, amp = rng.integers(0, NS, (S, 30)), rng.normal(0, 3.0, (S, 30)) * dg[:, None]\n", + " amp = amp * (np.arange(30)[None, :] < k[:, None])\n", + " np.add.at(W, (np.repeat(np.arange(S), 30), pos.ravel()), amp.ravel())\n", + " return units, snap, W\n", + "\n", + "\n", + "def extract(W):\n", + " \"\"\"진동 파형 -> 특징량: RMS, 첨도, 파고율, 최대-최소, FFT 대역 에너지(50/100/150/165/200Hz 부근)\"\"\"\n", + " x = W - W.mean(axis=1, keepdims=True)\n", + " rms = np.sqrt((x ** 2).mean(axis=1))\n", + " out = {\"rms\": rms, \"kurt\": kurtosis(x, axis=1, fisher=False), \"crest\": np.abs(x).max(axis=1) / rms, \"p2p\": x.max(axis=1) - x.min(axis=1)}\n", + " A = np.abs(np.fft.rfft(x * np.hanning(NS), axis=1)) / NS\n", + " f = np.fft.rfftfreq(NS, 1 / FS)\n", + " for nm, (lo, hi) in BANDS.items():\n", + " out[nm] = np.sqrt((A[:, (f >= lo) & (f < hi)] ** 2).sum(axis=1))\n", + " return pd.DataFrame(out)\n", + "\n", + "# 1) 업무 프로세스 맵: 센서 수집부터 수리부속 소요 환류까지 (각 단계에서 생기는 데이터)\n", + "PROCESS = [(\"센서 수집\", \"개체 ID, 측정일, 진동 파형, 온도, 압력, 부하\"), (\"전처리\", \"잡음 제거, 단위·표기 통일, 결측 보정\"),\n", + " (\"특징 추출\", \"RMS, 첨도, 파고율, FFT 대역 에너지\"), (\"이상 감지\", \"이상 점수, 경보 단계\"),\n", + " (\"잔존수명(RUL) 예측\", \"고장 임박 시점, 신뢰 구간\"), (\"검사관 승인\", \"조기 입고 제안, 승인·반려 사유\"),\n", + " (\"정비·부품 소모\", \"교체 부품, 수량, 정비 소요시간\"), (\"고장 실적 연결\", \"정비 건 ID로 센서 이력과 고장 실적 연결\"),\n", + " (\"부품 소요·가동률 집계\", \"품목별 소모, 가용도(PBL 지표)\"), (\"사후검토(AAR)\", \"오경보 원인, 임계 파라미터 조정\")]\n", + "\n", + "# 2) 스키마(데이터 딕셔너리): 그룹 / 필드 / 자료형 / 규칙 / 설명\n", + "S = {\n", + " \"sensor\": {\n", + " \"unit_id\": (str, {\"pattern\": r\"^U\\d{2}$\"}, \"개체 ID(가상 기동체)\"),\n", + " \"day\": (int, {\"min\": 0, \"max\": 260}, \"운용 경과일(측정일)\"),\n", + " \"vib_rms\": (float, {\"min\": 0, \"max\": 10}, \"진동 RMS(가상 단위)\"),\n", + " \"vib_kurt\": (float, {\"min\": 1, \"max\": 30}, \"진동 첨도(충격성 지표)\"),\n", + " \"temp\": (float, {\"min\": -30, \"max\": 150}, \"엔진 온도(섭씨)\"),\n", + " \"press\": (float, {\"min\": 0, \"max\": 700}, \"오일 압력(kPa)\"),\n", + " \"load\": (float, {\"min\": 0, \"max\": 1.2}, \"운용 부하율\"),\n", + " },\n", + " \"feature\": {\n", + " \"band_50\": (float, {\"min\": 0}, \"50Hz 부근 FFT 대역 에너지\"),\n", + " \"band_165\": (float, {\"min\": 0}, \"165Hz 부근(베어링 결함 대역) 에너지\"),\n", + " \"crest\": (float, {\"min\": 1, \"max\": 30}, \"파고율(최대값/RMS)\"),\n", + " \"hf_ratio\": (float, {\"min\": 0}, \"고주파/기본주파 에너지 비\"),\n", + " \"temp_res\": (float, {\"min\": -60, \"max\": 80}, \"부하·외기 대비 온도 잔차\"),\n", + " },\n", + " \"failure\": {\n", + " \"event_id\": (str, {\"pattern\": r\"^F\\d{3}$\"}, \"고장·정비 건 ID(센서 이력과 연결하는 키)\"),\n", + " \"mode\": (str, {\"enum\": list(MODES)}, \"열화(고장) 유형\"),\n", + " \"life\": (int, {\"min\": 60, \"max\": 260}, \"고장까지 운용일수\"),\n", + " \"unplanned\": (bool, {}, \"비계획 정비 여부\"),\n", + " },\n", + " \"parts\": {\n", + " \"part_name\": (str, {\"enum\": [v[2] for v in MODES.values()]}, \"소모 수리부속\"),\n", + " \"qty\": (int, {\"min\": 1, \"max\": 10}, \"소모 수량\"),\n", + " \"unit_cost\": (float, {\"min\": 0, \"max\": 50}, \"가상 단가지수\"),\n", + " },\n", + " \"target\": {\n", + " \"rul\": (float, {\"min\": 0, \"max\": 260}, \"잔존수명(일) - 예측대상 1\"),\n", + " \"anomaly_score\": (float, {\"min\": 0}, \"이상 점수(재구성 오차) - 예측대상 2\"),\n", + " \"availability\": (float, {\"min\": 0, \"max\": 1}, \"가용도(가동일/전체일) - PBL 성과 지표\"),\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", + "units, snap, W = make_runs()\n", + "F = extract(W)\n", + "snap = pd.concat([snap, F], axis=1)\n", + "snap[\"temp_res\"] = snap[\"temp\"] - (55 + 22 * snap[\"load\"] + 0.5 * snap[\"ambient\"])\n", + "snap[\"hf_ratio\"] = (snap[\"b150\"] + snap[\"b165\"] + snap[\"b200\"]) / (snap[\"b50\"] + 1e-6)\n", + "ui = units.set_index(\"unit\")\n", + "recs = []\n", + "for i, r in enumerate(snap.sample(20, random_state=0).itertuples()):\n", + " m = r.mode\n", + " recs.append({\n", + " \"sensor\": {\"unit_id\": r.unit, \"day\": int(r.day), \"vib_rms\": float(round(r.rms, 3)), \"vib_kurt\": float(round(r.kurt, 2)),\n", + " \"temp\": float(round(r.temp, 1)), \"press\": float(round(r.press, 1)), \"load\": float(round(r.load, 2))},\n", + " \"feature\": {\"band_50\": float(round(r.b50, 4)), \"band_165\": float(round(r.b165, 4)), \"crest\": float(round(r.crest, 2)),\n", + " \"hf_ratio\": float(round(r.hf_ratio, 3)), \"temp_res\": float(round(r.temp_res, 2))},\n", + " \"failure\": {\"event_id\": f\"F{int(r.unit[1:]):03d}\", \"mode\": m, \"life\": int(ui.loc[r.unit, \"life\"]), \"unplanned\": bool(r.rul < 5)},\n", + " \"parts\": {\"part_name\": MODES[m][2], \"qty\": 1, \"unit_cost\": float(MODES[m][3])},\n", + " \"target\": {\"rul\": float(r.rul), \"anomaly_score\": float(round(r.deg, 3)), \"availability\": 0.92}})\n", + "ok = sum(not validate(r) for r in recs)\n", + "print(f\"가상 센서 자료 {len(snap)}건(개체 {len(units)}대), 표본 {len(recs)}건 중 검증 통과 {ok}건, 오류 {len(recs) - ok}건\")\n", + "print(f\"전체 파형 {W.shape[0]}개 x {W.shape[1]}표본, 온도·압력 결측 {int(snap[['temp', 'press']].isna().sum().sum())}건\")\n", + "\n", + "# 4) 오류 주입 시험\n", + "bad = json.loads(json.dumps(recs[0]))\n", + "bad[\"sensor\"][\"unit_id\"] = \"ENGINE-3\" # ID 형식 오류\n", + "bad[\"sensor\"][\"temp\"] = 480.0 # 센서 범위 초과(단위 혼동 의심)\n", + "bad[\"failure\"][\"mode\"] = \"turbine\" # 정의되지 않은 열화 유형\n", + "del bad[\"parts\"][\"qty\"] # 필수 필드 누락\n", + "bad[\"target\"][\"rul\"] = \"30일\" # 자료형 오류\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:12s}: {d}\")\n", + "rows = [(g, k, t.__name__, d) for g, fl in S.items() for k, (t, r, d) in fl.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" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 01 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + "가상 센서 자료 7733건(개체 48대), 표본 20건 중 검증 통과 20건, 오류 0건\n", + "전체 파형 7733개 x 256표본, 온도·압력 결측 0건\n", + "\n", + "[오류 주입 결과]\n", + " - sensor.unit_id: 형식 불일치 'ENGINE-3'\n", + " - sensor.temp: 최대값 150 초과\n", + " - failure.mode: 허용되지 않은 값 'turbine'\n", + " - parts.qty: 필수 필드 누락\n", + " - target.rul: 자료형 오류(str)\n", + "\n", + "[업무 프로세스 맵]\n", + "1.센서 수집 -> 2.전처리 -> 3.특징 추출 -> 4.이상 감지 -> 5.잔존수명(RUL) 예측 -> 6.검사관 승인 -> 7.정비·부품 소모 -> 8.고장 실적 연결 -> 9.부품 소요·가동률 집계 -> 10.사후검토(AAR)\n", + " 센서 수집 : 개체 ID, 측정일, 진동 파형, 온도, 압력, 부하\n", + " 전처리 : 잡음 제거, 단위·표기 통일, 결측 보정\n", + " 특징 추출 : RMS, 첨도, 파고율, FFT 대역 에너지\n", + " 이상 감지 : 이상 점수, 경보 단계\n", + " 잔존수명(RUL) 예측: 고장 임박 시점, 신뢰 구간\n", + " 검사관 승인 : 조기 입고 제안, 승인·반려 사유\n", + " 정비·부품 소모 : 교체 부품, 수량, 정비 소요시간\n", + " 고장 실적 연결 : 정비 건 ID로 센서 이력과 고장 실적 연결\n", + " 부품 소요·가동률 집계: 품목별 소모, 가용도(PBL 지표)\n", + " 사후검토(AAR) : 오경보 원인, 임계 파라미터 조정\n", + "\n", + "[데이터 딕셔너리] 피처 22개 (목표 20개 이상: 달성)\n", + " 그룹 필드 자료형 설명\n", + " sensor unit_id str 개체 ID(가상 기동체)\n", + " sensor day int 운용 경과일(측정일)\n", + " sensor vib_rms float 진동 RMS(가상 단위)\n", + " sensor vib_kurt float 진동 첨도(충격성 지표)\n", + " sensor temp float 엔진 온도(섭씨)\n", + " sensor press float 오일 압력(kPa)\n", + " sensor load float 운용 부하율\n", + "feature band_50 float 50Hz 부근 FFT 대역 에너지\n", + "feature band_165 float 165Hz 부근(베어링 결함 대역) 에너지\n", + "feature crest float 파고율(최대값/RMS)\n", + "feature hf_ratio float 고주파/기본주파 에너지 비\n", + "feature temp_res float 부하·외기 대비 온도 잔차\n", + "failure event_id str 고장·정비 건 ID(센서 이력과 연결하는 키)\n", + "failure mode str 열화(고장) 유형\n", + "failure life int 고장까지 운용일수\n", + "... (이하 생략)\n", + "```\n", + "
\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 02. 고주파 센서 신호 잡음 제거, FFT 변환 및 주요 특징량(RMS, Kurtosis) 전처리 구현\n", + "**소요시간**: 약 2.0시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "센서 잡음 제거, FFT 변환 및 주요 특징량(RMS 등) 추출 전처리 모듈을 구현한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **고주파 센서 신호의 잡음을 제거하고 FFT 변환으로 RMS, 첨도 등 주요 특징량을 추출하는 전처리 모듈을 구현한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **표기·단위(온도 F/C, 압력 psi/kPa) 통일, 중복 제거, 불가능값 결측 처리, 파형 단발 스파이크의 중앙값 필터 제거, 간섭 잡음 창의 저역통과 필터, KNN 결측 보정 등 정제 규칙 10종을 적용 수행하고 검증하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `src/etl/mnt04_data_cleansing.py 및 정제 품질 보고서` 형태의 결과물을 완성한다.\n", + "\n", + "### 🔍 평가 및 검증 기준 (PASS 조건)\n", + "#### 미션 판정\n", + "\n", + "**평가 구분:** 필수\n", + "\n", + "**핵심 평가영역:** 군수 특수 예외(비계획 정비, 긴급 불출 등) 데이터 보존의 타당성\n", + "\n", + "#### 미션 PASS 조건\n", + "\n", + "- 정량 기준: 데이터 정제 규칙 10종 적용, 데이터 정합성 검증 통과율 100%\n", + "\n", + "\n", + "- 진행 기준: ETL 파이프라인 구현 및 정제된 마스터 데이터셋 구축\n", + "\n", + "\n", + "- 검증 방법: 전후 데이터 통계량(기술통계) 비교 및 무결성 검증\n", + "\n", + "\n", + "#### 채점자 판정 메모\n", + "\n", + "> 단순 코드 실행 여부뿐만 아니라 출력된 수치와 근거 자료가 군 실무 요구사항에 부합하는지 면밀히 확인한다.\n", + "\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "\"\"\"표기 불일치·중복·단위 혼재·불가능값·결측·단발 스파이크가 섞인 가상 센서 원천 자료에 정제 규칙 10종(파형 스파이크 제거, 저역통과 잡음 제거, KNN 결측 보정 포함)을 적용하고, FFT 변환으로 RMS·첨도 등 주요 특징량을 추출한다. 고장 임박 시점의 긴급 점검 측정은 보존하며 정합성 검증 통과율과 정제 전후 통계를 확인한다.\"\"\"\n", + "import numpy as np\n", + "import pandas as pd\n", + "import matplotlib.pyplot as plt\n", + "from scipy.stats import kurtosis\n", + "from scipy.signal import medfilt, butter, sosfiltfilt\n", + "from sklearn.impute import KNNImputer\n", + "\n", + "# ---------------------------------------------------------------------------\n", + "# [교육용가상자료] 가상 기동체 48대의 엔진·구동계 진동·온도·압력 센서 자료 생성기 (실제 장비·부대·정비 실적 없음)\n", + "# 개체마다 정상 -> 열화 -> 고장(수명종료)까지 하루 1회 측정(진동 파형 256표본 + 온도 + 압력 + 부하)을 만든다.\n", + "# 열화 유형 4가지: bearing(베어링), gear(기어), oil_pump(오일 펌프), cooling(냉각 팬 모터)\n", + "# ---------------------------------------------------------------------------\n", + "FS, NS = 1024, 256 # 가상 표본화 주파수(Hz), 파형 1개당 표본 수\n", + "TT = np.arange(NS) / FS\n", + "MODES = { # 열화 유형: (발생 확률, 평균 수명(일), 교체 수리부속, 가상 단가지수)\n", + " \"bearing\": (0.40, 160, \"주 베어링 세트\", 6.0), \"gear\": (0.25, 175, \"구동 기어 세트\", 9.0),\n", + " \"oil_pump\": (0.20, 150, \"오일 펌프\", 4.0), \"cooling\": (0.15, 140, \"냉각 팬 모터\", 3.0)}\n", + "KIT_COST = 1.0 # 모든 정비에 함께 소모되는 씰·필터 키트 단가지수\n", + "BANDS = {\"b50\": (40, 60), \"b100\": (90, 110), \"b150\": (140, 156), \"b165\": (156, 176), \"b200\": (190, 215)}\n", + "\n", + "\n", + "def make_runs(n=48, seed=1):\n", + " \"\"\"반환: units(개체 정보), snap(일별 측정 요약), W(진동 파형 [측정 수, 256])\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " names = list(MODES)\n", + " mode = rng.choice(names, n, p=[MODES[m][0] for m in names])\n", + " life = np.clip(np.array([rng.normal(MODES[m][1], 18) for m in mode]), 90, 230).astype(int)\n", + " start = np.sort(rng.integers(0, 240, n))\n", + " units = pd.DataFrame({\"unit\": [f\"U{i:02d}\" for i in range(n)], \"mode\": mode, \"life\": life, \"start\": start, \"base_load\": rng.uniform(0.4, 0.75, n)})\n", + " rows = []\n", + " for u in units.itertuples():\n", + " d = np.arange(u.life + 1)\n", + " onset = 0.45 * u.life\n", + " deg = np.clip((d - onset) / (u.life - onset), 0, 1) ** 1.5\n", + " load = np.clip(u.base_load + rng.normal(0, 0.06, len(d)), 0.2, 1.0)\n", + " hi = rng.random(len(d)) < 0.07 # 고부하 운용일(정상이지만 진동·온도 상승)\n", + " load = np.where(hi, rng.uniform(0.95, 1.1, len(d)), load)\n", + " rows.append(pd.DataFrame({\"unit\": u.unit, \"day\": d, \"cal\": u.start + d, \"rul\": u.life - d, \"deg\": deg, \"load\": load, \"hi_load\": hi, \"mode\": u.mode}))\n", + " snap = pd.concat(rows, ignore_index=True)\n", + " S = len(snap)\n", + " snap[\"ambient\"] = 15 + 12 * np.sin(2 * np.pi * snap[\"cal\"] / 365) + rng.normal(0, 2, S)\n", + " dg, ld, md = snap[\"deg\"].values, snap[\"load\"].values, snap[\"mode\"].values\n", + " isb, isg, iso, isc = (md == \"bearing\"), (md == \"gear\"), (md == \"oil_pump\"), (md == \"cooling\")\n", + " snap[\"temp\"] = 55 + 22 * ld + 0.5 * snap[\"ambient\"] + 9 * dg * iso + 26 * dg * isc + 3 * dg * (isb | isg) + rng.normal(0, 1.5, S)\n", + " snap[\"press\"] = 450 - 12 * ld - 85 * dg * iso - 15 * dg * isc + rng.normal(0, 6, S)\n", + " ph = rng.uniform(0, 2 * np.pi, (S, 3))\n", + " a1, a2 = 1.0 * (0.5 + ld), 0.4 * (0.5 + ld)\n", + " W = (a1[:, None] * np.sin(2 * np.pi * 50 * TT + ph[:, :1]) + a2[:, None] * np.sin(2 * np.pi * 100 * TT + ph[:, 1:2])\n", + " + rng.normal(0, 0.35, (S, NS)))\n", + " W += (0.9 * dg * isb)[:, None] * np.sin(2 * np.pi * 165 * TT + ph[:, 2:3])\n", + " W += (0.8 * dg * isg)[:, None] * (np.sin(2 * np.pi * 150 * TT + ph[:, 2:3]) + 0.8 * np.sin(2 * np.pi * 100 * TT))\n", + " W += (0.5 * dg * iso)[:, None] * np.sin(2 * np.pi * 200 * TT + ph[:, 2:3])\n", + " k = rng.poisson(10 * dg * isb) # 베어링 열화: 충격성 펄스(첨도 상승)\n", + " pos, amp = rng.integers(0, NS, (S, 30)), rng.normal(0, 3.0, (S, 30)) * dg[:, None]\n", + " amp = amp * (np.arange(30)[None, :] < k[:, None])\n", + " np.add.at(W, (np.repeat(np.arange(S), 30), pos.ravel()), amp.ravel())\n", + " return units, snap, W\n", + "\n", + "\n", + "def extract(W):\n", + " \"\"\"진동 파형 -> 특징량: RMS, 첨도, 파고율, 최대-최소, FFT 대역 에너지(50/100/150/165/200Hz 부근)\"\"\"\n", + " x = W - W.mean(axis=1, keepdims=True)\n", + " rms = np.sqrt((x ** 2).mean(axis=1))\n", + " out = {\"rms\": rms, \"kurt\": kurtosis(x, axis=1, fisher=False), \"crest\": np.abs(x).max(axis=1) / rms, \"p2p\": x.max(axis=1) - x.min(axis=1)}\n", + " A = np.abs(np.fft.rfft(x * np.hanning(NS), axis=1)) / NS\n", + " f = np.fft.rfftfreq(NS, 1 / FS)\n", + " for nm, (lo, hi) in BANDS.items():\n", + " out[nm] = np.sqrt((A[:, (f >= lo) & (f < hi)] ** 2).sum(axis=1))\n", + " return pd.DataFrame(out)\n", + "\n", + "# 1) [교육용가상자료] 원천 자료: 생성 자료에 일부러 오류를 섞는다\n", + "units, snap, W0 = make_runs()\n", + "rng = np.random.default_rng(2)\n", + "S = len(snap)\n", + "W = W0.copy()\n", + "raw = pd.DataFrame({\"unit\": snap[\"unit\"].values, \"date\": [(pd.Timestamp(\"2026-01-01\") + pd.Timedelta(days=int(c))).strftime(\"%Y-%m-%d\") for c in snap[\"cal\"]],\n", + " \"temp\": snap[\"temp\"].round(1).values, \"temp_unit\": \"C\", \"press\": snap[\"press\"].round(1).values, \"press_unit\": \"kPa\",\n", + " \"load\": snap[\"load\"].round(2).values, \"emergency\": snap[\"rul\"].values <= 3})\n", + "idx = rng.choice(S, 1500, replace=False)\n", + "a, b, c, d_, e, f, g, h_ = np.split(idx, [150, 350, 600, 750, 800, 850, 1050])\n", + "raw.loc[a, \"unit\"] = rng.choice([\"u\", \" U\", \"unit-\"], len(a)) + raw.loc[a, \"unit\"].str[1:] # ID 표기 불일치\n", + "raw.loc[b, \"date\"] = pd.to_datetime(raw.loc[b, \"date\"]).dt.strftime(\"%Y/%m/%d\") # 날짜 형식 혼재\n", + "raw.loc[c, \"temp\"] = (raw.loc[c, \"temp\"] * 9 / 5 + 32).round(1); raw.loc[c, \"temp_unit\"] = \"F\" # 온도 단위 혼재\n", + "raw.loc[d_, \"press\"] = (raw.loc[d_, \"press\"] / 6.895).round(1); raw.loc[d_, \"press_unit\"] = \"psi\" # 압력 단위 혼재\n", + "raw.loc[e, \"temp\"] = -999.0; raw.loc[f, \"press\"] = -5.0 # 불가능 값\n", + "raw.loc[g, [\"temp\", \"press\"]] = np.nan # 결측\n", + "sp = h_[:200] # 파형 단발 스파이크(센서 글리치)\n", + "for i in sp:\n", + " W[i, rng.integers(0, NS, 2)] += rng.choice([-9.0, 9.0], 2)\n", + "nz = h_[200:] # 전자기 간섭: 파형 전체 잡음 증가\n", + "W[nz] += rng.normal(0, 0.9, (len(nz), NS))\n", + "raw = pd.concat([raw, raw.sample(80, random_state=1)], ignore_index=True) # 중복 80건\n", + "print(f\"원천 자료 {len(raw)}행 (정상 {S}행 + 중복 80행, 오류 주입 {len(idx)}행, 파형 스파이크 {len(sp)}개·간섭 {len(nz)}개 포함)\")\n", + "\n", + "# 2) 정제 규칙 10종 (표 규칙 R1~R6, 결측 R9, 예외 R10 / 파형 규칙 R7~R8)\n", + "df, applied = raw.iloc[:S].copy(), []\n", + "def rule(name, n):\n", + " applied.append((name, int(n)))\n", + "\n", + "rule(\"R1 중복 행 제거\", raw.duplicated().sum())\n", + "uid = \"U\" + df[\"unit\"].str.extract(r\"(\\d+)\")[0]; rule(\"R2 개체 ID 표기 통일\", (uid != df[\"unit\"]).sum()); df[\"unit\"] = uid\n", + "t1 = pd.to_datetime(df[\"date\"], format=\"%Y-%m-%d\", errors=\"coerce\"); t2 = pd.to_datetime(df[\"date\"], format=\"%Y/%m/%d\", errors=\"coerce\")\n", + "rule(\"R3 측정일 형식 통일\", t1.isna().sum()); df[\"date\"] = t1.fillna(t2)\n", + "fm = df[\"temp_unit\"] == \"F\"; df.loc[fm, \"temp\"] = ((df.loc[fm, \"temp\"] - 32) * 5 / 9).round(1); df[\"temp_unit\"] = \"C\"; rule(\"R4 온도 단위 통일(F -> C)\", fm.sum())\n", + "pm = df[\"press_unit\"] == \"psi\"; df.loc[pm, \"press\"] = (df.loc[pm, \"press\"] * 6.895).round(1); df[\"press_unit\"] = \"kPa\"; rule(\"R5 압력 단위 통일(psi -> kPa)\", pm.sum())\n", + "bt, bp = ~df[\"temp\"].between(-30, 150) & df[\"temp\"].notna(), (df[\"press\"] < 0) & df[\"press\"].notna()\n", + "df.loc[bt, \"temp\"] = np.nan; df.loc[bp, \"press\"] = np.nan; rule(\"R6 불가능 값 결측 처리\", bt.sum() + bp.sum())\n", + "Wc = W.copy()\n", + "glitch = np.abs(Wc - np.median(Wc, axis=1, keepdims=True)).max(axis=1) > 8 # 단발 스파이크 창\n", + "for i in np.where(glitch)[0]:\n", + " Wc[i] = medfilt(Wc[i], 3)\n", + "rule(\"R7 파형 단발 스파이크 제거(중앙값 필터)\", glitch.sum())\n", + "sos = butter(4, 250, fs=FS, output=\"sos\")\n", + "hf = (Wc - sosfiltfilt(sos, Wc, axis=1)).std(axis=1) # 250Hz 이상 성분의 크기\n", + "noisy = hf > 0.4 # 간섭 잡음 창\n", + "Wc[noisy] = sosfiltfilt(sos, Wc[noisy], axis=1); rule(\"R8 간섭 잡음 창 저역통과(250Hz) 필터\", noisy.sum())\n", + "miss = df[[\"temp\", \"press\"]].isna().sum().sum()\n", + "X = df.assign(un=df[\"unit\"].str[1:].astype(int) / 50)[[\"temp\", \"press\", \"load\", \"un\"]]\n", + "df[[\"temp\", \"press\"]] = KNNImputer(n_neighbors=5).fit_transform(X)[:, :2]; rule(\"R9 센서 결측 KNN 보정\", miss)\n", + "df[\"rms\"] = np.sqrt(((Wc - Wc.mean(axis=1, keepdims=True)) ** 2).mean(axis=1)); lim = df[\"rms\"].quantile(0.99)\n", + "df[\"outlier_flag\"] = (df[\"rms\"] > lim) & ~df[\"emergency\"]; rule(\"R10 긴급 점검 측정 보존(일반 이상값은 표시만)\", (df[\"rms\"] > lim).sum() - df[\"outlier_flag\"].sum())\n", + "print(\"\\n[적용 규칙과 처리 건수]\")\n", + "for nme, n in applied:\n", + " print(f\" {nme:36s} {n:5d}건\")\n", + "\n", + "# 3) 정합성 검증\n", + "checks = {\"중복 0건\": not df.duplicated([\"unit\", \"date\"]).any(), \"ID 형식\": df[\"unit\"].str.match(r\"^U\\d{2}$\").all(),\n", + " \"날짜 변환\": df[\"date\"].notna().all(), \"온도 단위 C\": (df[\"temp_unit\"] == \"C\").all(), \"압력 단위 kPa\": (df[\"press_unit\"] == \"kPa\").all(),\n", + " \"온도 범위\": df[\"temp\"].between(-30, 150).all(), \"압력 0 이상\": (df[\"press\"] >= 0).all(), \"결측 0건\": df[[\"temp\", \"press\"]].notna().all().all(),\n", + " \"파형 스파이크 잔존 0개\": not (np.abs(Wc - np.median(Wc, axis=1, keepdims=True)).max(axis=1) > 8).any(),\n", + " f\"긴급 점검 {int(df['emergency'].sum())}건 보존\": int(df[\"emergency\"].sum()) == int(snap[\"rul\"].le(3).sum())}\n", + "rate = np.mean(list(checks.values()))\n", + "print(f\"\\n정합성 검증 {sum(checks.values())}/{len(checks)} 통과, 통과율 {rate:.0%} (목표 100%: {'달성' if rate == 1 else '미달'})\")\n", + "for k, v in checks.items():\n", + " if not v:\n", + " print(\" 실패:\", k)\n", + "\n", + "# 4) FFT 특징량: 정제 효과(깨끗한 원본 파형 대비 오차)\n", + "def feats(Wx):\n", + " x = Wx - Wx.mean(axis=1, keepdims=True)\n", + " return np.sqrt((x ** 2).mean(axis=1)), kurtosis(x, axis=1, fisher=False)\n", + "r0, k0 = feats(W0); r1, k1 = feats(W); r2, k2 = feats(Wc)\n", + "bad = glitch | noisy\n", + "print(\"\\n[특징량 정제 효과] 스파이크·간섭이 있던 창 \" + f\"{int(bad.sum())}개의 깨끗한 원본 대비 평균 오차\")\n", + "print(f\" RMS 정제 전 {np.abs(r1 - r0)[bad].mean():.3f} -> 정제 후 {np.abs(r2 - r0)[bad].mean():.3f}\")\n", + "print(f\" 첨도 정제 전 {np.abs(k1 - k0)[bad].mean():.2f} -> 정제 후 {np.abs(k2 - k0)[bad].mean():.2f}\")\n", + "cmp = pd.DataFrame({\"정제 전\": [len(raw), pd.to_numeric(raw[\"temp\"], errors=\"coerce\").mean(), raw[\"temp\"].min(), raw[\"press\"].min(), raw[\"press\"].isna().sum()],\n", + " \"정제 후\": [len(df), df[\"temp\"].mean(), df[\"temp\"].min(), df[\"press\"].min(), df[\"press\"].isna().sum()]},\n", + " index=[\"행 수\", \"온도 평균\", \"온도 최소\", \"압력 최소\", \"압력 결측\"]).round(1)\n", + "print(\"\\n[정제 전후 기술통계]\"); print(cmp.to_string())\n", + "\n", + "i = int(np.where(glitch)[0][0])\n", + "fig, ax = plt.subplots(1, 2, figsize=(10, 3.5))\n", + "ax[0].plot(W[i], label=\"raw\", alpha=0.6); ax[0].plot(Wc[i], label=\"cleaned\"); ax[0].set_title(\"Waveform with glitch\"); ax[0].legend()\n", + "f = np.fft.rfftfreq(NS, 1 / FS); ax[1].plot(f, np.abs(np.fft.rfft(Wc[i])) / NS); ax[1].set_title(\"FFT spectrum (cleaned)\"); ax[1].set_xlabel(\"Hz\")\n", + "plt.tight_layout(); plt.show()\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 02 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + "원천 자료 7813행 (정상 7733행 + 중복 80행, 오류 주입 1500행, 파형 스파이크 200개·간섭 250개 포함)\n", + "\n", + "[적용 규칙과 처리 건수]\n", + " R1 중복 행 제거 80건\n", + " R2 개체 ID 표기 통일 150건\n", + " R3 측정일 형식 통일 200건\n", + " R4 온도 단위 통일(F -> C) 250건\n", + " R5 압력 단위 통일(psi -> kPa) 150건\n", + " R6 불가능 값 결측 처리 100건\n", + " R7 파형 단발 스파이크 제거(중앙값 필터) 207건\n", + " R8 간섭 잡음 창 저역통과(250Hz) 필터 347건\n", + " R9 센서 결측 KNN 보정 500건\n", + " R10 긴급 점검 측정 보존(일반 이상값은 표시만) 11건\n", + "\n", + "정합성 검증 10/10 통과, 통과율 100% (목표 100%: 달성)\n", + "\n", + "[특징량 정제 효과] 스파이크·간섭이 있던 창 554개의 깨끗한 원본 대비 평균 오차\n", + " RMS 정제 전 0.267 -> 정제 후 0.116\n", + " 첨도 정제 전 8.31 -> 정제 후 0.86\n", + "\n", + "[정제 전후 기술통계]\n", + " 정제 전 정제 후\n", + "행 수 7813.0 7733.0\n", + "온도 평균 73.2 77.3\n", + "온도 최소 -999.0 61.5\n", + "압력 최소 -5.0 342.8\n", + "압력 결측 201.0 0.0\n", + "```\n", + "
\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 03. 임계값 초과 기반 1차 센서 이상 징후 감지 Baseline 알고리즘 구현\n", + "**소요시간**: 약 2.5시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "임계값 초과 기반 1차 센서 이상 징후 감지 Baseline 알고리즘을 구현한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **임계값 초과 기반 1차 센서 이상 징후 감지 Baseline을 구현하고, 탐색적 분석으로 고정 임계값의 한계와 수리부속 소모의 핵심 관리 대상을 찾는다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **정상 자료 분포로 RMS, 첨도, 온도, 압력 임계값을 정하고 하나라도 넘으면 이상으로 보는 Baseline의 탐지율, 정밀도, 고부하 운용일 오경보율, 고장 전 확보 시간을 측정 수행하고 검증하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `notebooks/mnt04_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": [ + "\"\"\"임계값 초과(진동 RMS·첨도·온도·압력) 기반 1차 센서 이상 징후 감지 Baseline 알고리즘을 구현해 탐지율·오경보율·고장 전 확보 시간을 확인하고, 탐색적 분석(EDA 차트 8종, 피처와 잔존수명의 상관계수·p-value, 수리부속 소모 파레토 ABC 분류)으로 핵심 관리 대상을 식별한다.\"\"\"\n", + "import numpy as np\n", + "import pandas as pd\n", + "import matplotlib.pyplot as plt\n", + "from scipy.stats import kurtosis, spearmanr\n", + "\n", + "# ---------------------------------------------------------------------------\n", + "# [교육용가상자료] 가상 기동체 48대의 엔진·구동계 진동·온도·압력 센서 자료 생성기 (실제 장비·부대·정비 실적 없음)\n", + "# 개체마다 정상 -> 열화 -> 고장(수명종료)까지 하루 1회 측정(진동 파형 256표본 + 온도 + 압력 + 부하)을 만든다.\n", + "# 열화 유형 4가지: bearing(베어링), gear(기어), oil_pump(오일 펌프), cooling(냉각 팬 모터)\n", + "# ---------------------------------------------------------------------------\n", + "FS, NS = 1024, 256 # 가상 표본화 주파수(Hz), 파형 1개당 표본 수\n", + "TT = np.arange(NS) / FS\n", + "MODES = { # 열화 유형: (발생 확률, 평균 수명(일), 교체 수리부속, 가상 단가지수)\n", + " \"bearing\": (0.40, 160, \"주 베어링 세트\", 6.0), \"gear\": (0.25, 175, \"구동 기어 세트\", 9.0),\n", + " \"oil_pump\": (0.20, 150, \"오일 펌프\", 4.0), \"cooling\": (0.15, 140, \"냉각 팬 모터\", 3.0)}\n", + "KIT_COST = 1.0 # 모든 정비에 함께 소모되는 씰·필터 키트 단가지수\n", + "BANDS = {\"b50\": (40, 60), \"b100\": (90, 110), \"b150\": (140, 156), \"b165\": (156, 176), \"b200\": (190, 215)}\n", + "\n", + "\n", + "def make_runs(n=48, seed=1):\n", + " \"\"\"반환: units(개체 정보), snap(일별 측정 요약), W(진동 파형 [측정 수, 256])\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " names = list(MODES)\n", + " mode = rng.choice(names, n, p=[MODES[m][0] for m in names])\n", + " life = np.clip(np.array([rng.normal(MODES[m][1], 18) for m in mode]), 90, 230).astype(int)\n", + " start = np.sort(rng.integers(0, 240, n))\n", + " units = pd.DataFrame({\"unit\": [f\"U{i:02d}\" for i in range(n)], \"mode\": mode, \"life\": life, \"start\": start, \"base_load\": rng.uniform(0.4, 0.75, n)})\n", + " rows = []\n", + " for u in units.itertuples():\n", + " d = np.arange(u.life + 1)\n", + " onset = 0.45 * u.life\n", + " deg = np.clip((d - onset) / (u.life - onset), 0, 1) ** 1.5\n", + " load = np.clip(u.base_load + rng.normal(0, 0.06, len(d)), 0.2, 1.0)\n", + " hi = rng.random(len(d)) < 0.07 # 고부하 운용일(정상이지만 진동·온도 상승)\n", + " load = np.where(hi, rng.uniform(0.95, 1.1, len(d)), load)\n", + " rows.append(pd.DataFrame({\"unit\": u.unit, \"day\": d, \"cal\": u.start + d, \"rul\": u.life - d, \"deg\": deg, \"load\": load, \"hi_load\": hi, \"mode\": u.mode}))\n", + " snap = pd.concat(rows, ignore_index=True)\n", + " S = len(snap)\n", + " snap[\"ambient\"] = 15 + 12 * np.sin(2 * np.pi * snap[\"cal\"] / 365) + rng.normal(0, 2, S)\n", + " dg, ld, md = snap[\"deg\"].values, snap[\"load\"].values, snap[\"mode\"].values\n", + " isb, isg, iso, isc = (md == \"bearing\"), (md == \"gear\"), (md == \"oil_pump\"), (md == \"cooling\")\n", + " snap[\"temp\"] = 55 + 22 * ld + 0.5 * snap[\"ambient\"] + 9 * dg * iso + 26 * dg * isc + 3 * dg * (isb | isg) + rng.normal(0, 1.5, S)\n", + " snap[\"press\"] = 450 - 12 * ld - 85 * dg * iso - 15 * dg * isc + rng.normal(0, 6, S)\n", + " ph = rng.uniform(0, 2 * np.pi, (S, 3))\n", + " a1, a2 = 1.0 * (0.5 + ld), 0.4 * (0.5 + ld)\n", + " W = (a1[:, None] * np.sin(2 * np.pi * 50 * TT + ph[:, :1]) + a2[:, None] * np.sin(2 * np.pi * 100 * TT + ph[:, 1:2])\n", + " + rng.normal(0, 0.35, (S, NS)))\n", + " W += (0.9 * dg * isb)[:, None] * np.sin(2 * np.pi * 165 * TT + ph[:, 2:3])\n", + " W += (0.8 * dg * isg)[:, None] * (np.sin(2 * np.pi * 150 * TT + ph[:, 2:3]) + 0.8 * np.sin(2 * np.pi * 100 * TT))\n", + " W += (0.5 * dg * iso)[:, None] * np.sin(2 * np.pi * 200 * TT + ph[:, 2:3])\n", + " k = rng.poisson(10 * dg * isb) # 베어링 열화: 충격성 펄스(첨도 상승)\n", + " pos, amp = rng.integers(0, NS, (S, 30)), rng.normal(0, 3.0, (S, 30)) * dg[:, None]\n", + " amp = amp * (np.arange(30)[None, :] < k[:, None])\n", + " np.add.at(W, (np.repeat(np.arange(S), 30), pos.ravel()), amp.ravel())\n", + " return units, snap, W\n", + "\n", + "\n", + "def extract(W):\n", + " \"\"\"진동 파형 -> 특징량: RMS, 첨도, 파고율, 최대-최소, FFT 대역 에너지(50/100/150/165/200Hz 부근)\"\"\"\n", + " x = W - W.mean(axis=1, keepdims=True)\n", + " rms = np.sqrt((x ** 2).mean(axis=1))\n", + " out = {\"rms\": rms, \"kurt\": kurtosis(x, axis=1, fisher=False), \"crest\": np.abs(x).max(axis=1) / rms, \"p2p\": x.max(axis=1) - x.min(axis=1)}\n", + " A = np.abs(np.fft.rfft(x * np.hanning(NS), axis=1)) / NS\n", + " f = np.fft.rfftfreq(NS, 1 / FS)\n", + " for nm, (lo, hi) in BANDS.items():\n", + " out[nm] = np.sqrt((A[:, (f >= lo) & (f < hi)] ** 2).sum(axis=1))\n", + " return pd.DataFrame(out)\n", + "\n", + "# ---------------------------------------------------------------------------\n", + "# 공통 분석 조각: 일별 특징량 표(파형 특징 + 온도·압력 + 파생), 개체 단위 학습/검증 분할(앞선 24대 학습, 뒤 24대 검증)\n", + "# ---------------------------------------------------------------------------\n", + "BASE = [\"rms\", \"kurt\", \"crest\", \"p2p\", \"b50\", \"b100\", \"b150\", \"b165\", \"b200\", \"temp\", \"press\"]\n", + "ROLL = [\"rms\", \"kurt\", \"b150\", \"b165\", \"b200\", \"temp\", \"press\"]\n", + "\n", + "\n", + "def build_features(snap, W):\n", + " D = pd.concat([snap.reset_index(drop=True), extract(W)], axis=1)\n", + " D[\"temp_res\"] = D[\"temp\"] - (55 + 22 * D[\"load\"] + 0.5 * D[\"ambient\"]) # 부하·외기 대비 온도 잔차\n", + " D[\"press_res\"] = D[\"press\"] - (450 - 12 * D[\"load\"]) # 부하 대비 압력 잔차\n", + " D[\"rms_n\"] = D[\"rms\"] / (0.5 + D[\"load\"]) # 부하 정규화 RMS\n", + " D[\"hf_ratio\"] = (D[\"b150\"] + D[\"b165\"] + D[\"b200\"]) / (D[\"b50\"] + 1e-6) # 고주파/기본주파 비\n", + " g = D.groupby(\"unit\")\n", + " for c in ROLL:\n", + " D[c + \"_m3\"] = g[c].transform(lambda s: s.rolling(3, min_periods=1).mean())\n", + " for c in (\"rms_n\", \"temp_res\", \"press_res\"):\n", + " D[c + \"_d\"] = g[c].diff().fillna(0)\n", + " return D\n", + "\n", + "\n", + "DERIVED = [\"temp_res\", \"press_res\", \"rms_n\", \"hf_ratio\"] + [c + \"_m3\" for c in ROLL] + [\"rms_n_d\", \"temp_res_d\", \"press_res_d\"]\n", + "FEATS = BASE + DERIVED\n", + "AE_COLS = [\"rms_n\", \"kurt\", \"b150\", \"b165\", \"b200\", \"hf_ratio\", \"temp_res\", \"press_res\"]\n", + "\n", + "\n", + "def split_units(units, n_train=24):\n", + " return list(units[\"unit\"][:n_train]), list(units[\"unit\"][n_train:])\n", + "\n", + "\n", + "class AE:\n", + " \"\"\"오토인코더(입력 8 -> 10 -> 4 -> 10 -> 8): 정상 자료만 재구성하도록 학습하고, 재구성 오차를 이상 점수로 쓴다.\"\"\"\n", + "\n", + " def __init__(self, cols=AE_COLS, q=0.99, seed=0):\n", + " self.cols, self.q, self.seed = cols, q, seed\n", + "\n", + " def fit(self, healthy):\n", + " self.sc = StandardScaler().fit(healthy[self.cols])\n", + " Z = self.sc.transform(healthy[self.cols])\n", + " self.net = MLPRegressor(hidden_layer_sizes=(10, 4, 10), max_iter=800, random_state=self.seed).fit(Z, Z)\n", + " self.thr = float(np.quantile(self.err(healthy), self.q))\n", + " return self\n", + "\n", + " def contrib(self, d):\n", + " Z = self.sc.transform(d[self.cols])\n", + " return (Z - self.net.predict(Z)) ** 2 # 입력 항목별 재구성 오차(이상 기여도)\n", + "\n", + " def err(self, d):\n", + " return self.contrib(d).mean(axis=1)\n", + "\n", + "\n", + "def latch(d, e, thr, k=2):\n", + " \"\"\"같은 개체에서 이상 점수가 k일 연속 기준을 넘으면 경보를 켜고 유지(래치)한다.\"\"\"\n", + " out, e = np.zeros(len(d), bool), np.asarray(e)\n", + " for idx in d.reset_index(drop=True).groupby(\"unit\").indices.values():\n", + " run, on = 0, False\n", + " for i in idx:\n", + " run = run + 1 if e[i] > thr else 0\n", + " on = on or run >= k\n", + " out[i] = on\n", + " return out\n", + "\n", + "units, snap, W = make_runs()\n", + "D = build_features(snap, W)\n", + "\n", + "# 1) Baseline: 단순 임계값(하나라도 넘으면 이상). 임계값은 정상(열화 전) 자료의 분포로 정한다.\n", + "healthy = D[D[\"deg\"] == 0]\n", + "TH = {\"rms\": 1.25, \"kurt\": 3.0, \"temp\": 92.0, \"press_low\": 405.0}\n", + "D[\"alarm\"] = (D[\"rms\"] > TH[\"rms\"]) | (D[\"kurt\"] > TH[\"kurt\"]) | (D[\"temp\"] > TH[\"temp\"]) | (D[\"press\"] < TH[\"press_low\"])\n", + "D[\"anomaly\"] = D[\"deg\"] >= 0.1 # 정답: 열화가 일정 수준 이상 진행된 측정\n", + "tp, fp = (D[\"alarm\"] & D[\"anomaly\"]).sum(), (D[\"alarm\"] & ~D[\"anomaly\"]).sum()\n", + "fn = (~D[\"alarm\"] & D[\"anomaly\"]).sum()\n", + "print(f\"[교육용가상자료] 측정 {len(D)}건, 개체 {len(units)}대, 열화 구간 {int(D['anomaly'].sum())}건\")\n", + "print(f\"Baseline 임계값 {TH}\")\n", + "print(f\" 탐지율 {tp / (tp + fn):.1%}, 정밀도 {tp / (tp + fp):.1%}, 정상 측정 오경보율 {fp / (~D['anomaly']).sum():.1%} (고부하 운용일 오경보율 {D.loc[D['hi_load'] & ~D['anomaly'], 'alarm'].mean():.1%})\")\n", + "lead = []\n", + "for u, g in D.groupby(\"unit\"):\n", + " a = g[g[\"alarm\"] & g[\"anomaly\"]]\n", + " lead.append(a[\"rul\"].max() if len(a) else 0)\n", + "units[\"lead\"] = lead\n", + "print(f\" 고장 전 첫 경보 확보 시간 중앙값 {np.median(lead):.0f}일, 경보 못 낸 개체 {int((units['lead'] == 0).sum())}대/{len(units)}대\")\n", + "print(\" 열화 유형별 탐지율: \" + \", \".join(f\"{m} {D.loc[D['anomaly'] & (D['mode'] == m), 'alarm'].mean():.0%}\" for m in MODES))\n", + "\n", + "# 2) 상관분석: 피처와 잔존수명(RUL) (스피어만)\n", + "rows = []\n", + "for c in [\"rms\", \"kurt\", \"crest\", \"b150\", \"b165\", \"b200\", \"temp\", \"press\", \"hf_ratio\", \"temp_res\", \"press_res\", \"load\", \"ambient\"]:\n", + " r, p = spearmanr(D[c], D[\"rul\"]); rows.append((c, r, p))\n", + "cor = pd.DataFrame(rows, columns=[\"피처\", \"rho\", \"p\"]); cor[\"유의\"] = (cor[\"p\"] < 0.05) & (cor[\"rho\"].abs() >= 0.2)\n", + "print(\"\\n[상관분석] 피처 | 스피어만 rho | p-value | 유의(p<0.05, |rho|>=0.2)\")\n", + "for x in cor.sort_values(\"rho\", key=abs, ascending=False).head(8).itertuples():\n", + " print(f\" {x.피처:10s} {x.rho:+.2f} {x.p:.4f} {'유의' if x.유의 else '-'}\")\n", + "nsig = int(cor[\"유의\"].sum())\n", + "\n", + "# 3) 파레토(ABC): 고장별 수리부속 소모 비용(가상 단가지수)\n", + "cons = {}\n", + "for m in units[\"mode\"]:\n", + " cons[MODES[m][2]] = cons.get(MODES[m][2], 0) + MODES[m][3]\n", + " cons[\"씰·필터 키트\"] = cons.get(\"씰·필터 키트\", 0) + KIT_COST\n", + "par = pd.Series(cons).sort_values(ascending=False).to_frame(\"소모 비용지수\")\n", + "par[\"누적비율\"] = par[\"소모 비용지수\"].cumsum() / par[\"소모 비용지수\"].sum()\n", + "par[\"등급\"] = np.where(par[\"누적비율\"] <= 0.8, \"A\", np.where(par[\"누적비율\"] <= 0.95, \"B\", \"C\")); par.iloc[0, 2] = \"A\"\n", + "print(\"\\n[파레토] 수리부속 소모 비용(고장 1회 교체 기준)\")\n", + "print(par.assign(누적비율=lambda d: d[\"누적비율\"].map(\"{:.0%}\".format)).round(0).to_string())\n", + "top20 = par.head(int(np.ceil(len(par) * 0.2)))\n", + "print(f\"상위 20% 품목({', '.join(top20.index)})이 전체 소모 비용의 {top20['소모 비용지수'].sum() / par['소모 비용지수'].sum():.0%}\")\n", + "\n", + "# 4) EDA 차트 8종\n", + "fig, ax = plt.subplots(2, 4, figsize=(16, 7))\n", + "ax[0, 0].scatter(D[\"rul\"], D[\"rms\"], s=3); ax[0, 0].set_title(\"1 RMS vs RUL\"); ax[0, 0].invert_xaxis()\n", + "for m in MODES:\n", + " g = D[D[\"mode\"] == m]; ax[0, 1].scatter(g[\"deg\"], g[\"kurt\"], s=2, label=m)\n", + "ax[0, 1].set_title(\"2 Kurtosis vs degradation\"); ax[0, 1].legend(fontsize=6)\n", + "for m in MODES:\n", + " g = D[D[\"mode\"] == m]; ax[0, 2].scatter(g[\"deg\"], g[\"temp_res\"], s=2)\n", + "ax[0, 2].set_title(\"3 Temp residual vs degradation\")\n", + "for m in MODES:\n", + " g = D[D[\"mode\"] == m]; ax[0, 3].scatter(g[\"deg\"], g[\"press_res\"], s=2)\n", + "ax[0, 3].set_title(\"4 Pressure residual vs degradation\")\n", + "f = np.fft.rfftfreq(NS, 1 / FS); bm = D[\"mode\"] == \"bearing\"\n", + "ax[1, 0].plot(f, np.abs(np.fft.rfft(W[(bm & (D[\"deg\"] == 0)).values], axis=1)).mean(axis=0) / NS, label=\"healthy\")\n", + "ax[1, 0].plot(f, np.abs(np.fft.rfft(W[(bm & (D[\"deg\"] > 0.6)).values], axis=1)).mean(axis=0) / NS, label=\"degraded\"); ax[1, 0].legend(); ax[1, 0].set_title(\"5 Mean spectrum (bearing)\")\n", + "ax[1, 1].hist([units.loc[units[\"mode\"] == m, \"life\"] for m in MODES], bins=8, stacked=True, label=list(MODES)); ax[1, 1].set_title(\"6 Life by failure mode\")\n", + "ax[1, 2].scatter(healthy[\"load\"], healthy[\"rms\"], s=2); ax[1, 2].set_title(\"7 Load vs RMS (healthy)\")\n", + "ax[1, 3].bar(range(len(par)), par[\"소모 비용지수\"]); ax[1, 3].set_title(\"8 Spare-part consumption (Pareto)\")\n", + "plt.tight_layout(); plt.show()\n", + "\n", + "print(f\"\\nEDA 차트 8종 생성(목표 8종 이상: 달성), 유의한 피처 {nsig}개 (목표 5개 이상: {'달성' if nsig >= 5 else '미달'})\")\n", + "print(\"핵심 발견: 1) 고정 임계값은 고부하일 오경보와 온도·압력 유형 놓침이 있음 2) 부하·외기를 보정한 잔차 피처가 열화와 더 뚜렷함 3) 베어링·기어 부품이 소모 비용 대부분\")\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 03 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + "[교육용가상자료] 측정 7733건, 개체 48대, 열화 구간 3336건\n", + "Baseline 임계값 {'rms': 1.25, 'kurt': 3.0, 'temp': 92.0, 'press_low': 405.0}\n", + " 탐지율 25.3%, 정밀도 90.3%, 정상 측정 오경보율 2.1% (고부하 운용일 오경보율 28.6%)\n", + " 고장 전 첫 경보 확보 시간 중앙값 40일, 경보 못 낸 개체 0대/48대\n", + " 열화 유형별 탐지율: bearing 29%, gear 9%, oil_pump 54%, cooling 31%\n", + "\n", + "[상관분석] 피처 | 스피어만 rho | p-value | 유의(p<0.05, |rho|>=0.2)\n", + " hf_ratio -0.63 0.0000 유의\n", + " temp_res -0.53 0.0000 유의\n", + " b165 -0.38 0.0000 유의\n", + " b150 -0.37 0.0000 유의\n", + " rms -0.32 0.0000 유의\n", + " ambient +0.31 0.0000 유의\n", + " kurt -0.29 0.0000 유의\n", + " b200 -0.21 0.0000 유의\n", + "\n", + "[파레토] 수리부속 소모 비용(고장 1회 교체 기준)\n", + " 소모 비용지수 누적비율 등급\n", + "구동 기어 세트 162.0 46% A\n", + "주 베어링 세트 90.0 71% A\n", + "씰·필터 키트 48.0 85% B\n", + "오일 펌프 32.0 94% B\n", + "냉각 팬 모터 21.0 100% C\n", + "상위 20% 품목(구동 기어 세트)이 전체 소모 비용의 46%\n", + "\n", + "EDA 차트 8종 생성(목표 8종 이상: 달성), 유의한 피처 9개 (목표 5개 이상: 달성)\n", + "핵심 발견: 1) 고정 임계값은 고부하일 오경보와 온도·압력 유형 놓침이 있음 2) 부하·외기를 보정한 잔차 피처가 열화와 더 뚜렷함 3) 베어링·기어 부품이 소모 비용 대부분\n", + "```\n", + "
\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 04. 오토인코더(Autoencoder)/LSTM 기반 미세 진동 변화 감지 예지정비(PdM) 모델 고도화\n", + "**소요시간**: 약 3.0시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "오토인코더 기반 미세 진동 변화 감지 예지정비(PdM) 모델을 고도화한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **오토인코더 기반 미세 진동·온도 변화 감지 예지정비(PdM) 모델을 고도화하고, 파생 피처로 잔존수명 기준 모델을 만든다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **정상 자료만으로 오토인코더(MLP)를 학습해 재구성 오차를 이상 점수로 쓰고 2일 연속 초과 시 경보를 켠다 과정을 진행하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 5개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `src/features/mnt04_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": [ + "\"\"\"정상 자료만으로 학습한 오토인코더의 재구성 오차로 미세 진동·온도 변화를 감지(2일 연속 시 경보)해 임계값 Baseline과 비교하고, 파생 피처 14개로 잔존수명(RUL) 기준 모델을 만들어 개체 단위 시계열 교차검증 MAPE를 평가하며 순열 중요도로 피처를 줄이는 예지정비(PdM) 모델을 구현한다.\"\"\"\n", + "import numpy as np\n", + "import pandas as pd\n", + "from scipy.stats import kurtosis\n", + "from sklearn.ensemble import GradientBoostingRegressor\n", + "from sklearn.inspection import permutation_importance\n", + "from sklearn.neural_network import MLPRegressor\n", + "from sklearn.preprocessing import StandardScaler\n", + "\n", + "# ---------------------------------------------------------------------------\n", + "# [교육용가상자료] 가상 기동체 48대의 엔진·구동계 진동·온도·압력 센서 자료 생성기 (실제 장비·부대·정비 실적 없음)\n", + "# 개체마다 정상 -> 열화 -> 고장(수명종료)까지 하루 1회 측정(진동 파형 256표본 + 온도 + 압력 + 부하)을 만든다.\n", + "# 열화 유형 4가지: bearing(베어링), gear(기어), oil_pump(오일 펌프), cooling(냉각 팬 모터)\n", + "# ---------------------------------------------------------------------------\n", + "FS, NS = 1024, 256 # 가상 표본화 주파수(Hz), 파형 1개당 표본 수\n", + "TT = np.arange(NS) / FS\n", + "MODES = { # 열화 유형: (발생 확률, 평균 수명(일), 교체 수리부속, 가상 단가지수)\n", + " \"bearing\": (0.40, 160, \"주 베어링 세트\", 6.0), \"gear\": (0.25, 175, \"구동 기어 세트\", 9.0),\n", + " \"oil_pump\": (0.20, 150, \"오일 펌프\", 4.0), \"cooling\": (0.15, 140, \"냉각 팬 모터\", 3.0)}\n", + "KIT_COST = 1.0 # 모든 정비에 함께 소모되는 씰·필터 키트 단가지수\n", + "BANDS = {\"b50\": (40, 60), \"b100\": (90, 110), \"b150\": (140, 156), \"b165\": (156, 176), \"b200\": (190, 215)}\n", + "\n", + "\n", + "def make_runs(n=48, seed=1):\n", + " \"\"\"반환: units(개체 정보), snap(일별 측정 요약), W(진동 파형 [측정 수, 256])\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " names = list(MODES)\n", + " mode = rng.choice(names, n, p=[MODES[m][0] for m in names])\n", + " life = np.clip(np.array([rng.normal(MODES[m][1], 18) for m in mode]), 90, 230).astype(int)\n", + " start = np.sort(rng.integers(0, 240, n))\n", + " units = pd.DataFrame({\"unit\": [f\"U{i:02d}\" for i in range(n)], \"mode\": mode, \"life\": life, \"start\": start, \"base_load\": rng.uniform(0.4, 0.75, n)})\n", + " rows = []\n", + " for u in units.itertuples():\n", + " d = np.arange(u.life + 1)\n", + " onset = 0.45 * u.life\n", + " deg = np.clip((d - onset) / (u.life - onset), 0, 1) ** 1.5\n", + " load = np.clip(u.base_load + rng.normal(0, 0.06, len(d)), 0.2, 1.0)\n", + " hi = rng.random(len(d)) < 0.07 # 고부하 운용일(정상이지만 진동·온도 상승)\n", + " load = np.where(hi, rng.uniform(0.95, 1.1, len(d)), load)\n", + " rows.append(pd.DataFrame({\"unit\": u.unit, \"day\": d, \"cal\": u.start + d, \"rul\": u.life - d, \"deg\": deg, \"load\": load, \"hi_load\": hi, \"mode\": u.mode}))\n", + " snap = pd.concat(rows, ignore_index=True)\n", + " S = len(snap)\n", + " snap[\"ambient\"] = 15 + 12 * np.sin(2 * np.pi * snap[\"cal\"] / 365) + rng.normal(0, 2, S)\n", + " dg, ld, md = snap[\"deg\"].values, snap[\"load\"].values, snap[\"mode\"].values\n", + " isb, isg, iso, isc = (md == \"bearing\"), (md == \"gear\"), (md == \"oil_pump\"), (md == \"cooling\")\n", + " snap[\"temp\"] = 55 + 22 * ld + 0.5 * snap[\"ambient\"] + 9 * dg * iso + 26 * dg * isc + 3 * dg * (isb | isg) + rng.normal(0, 1.5, S)\n", + " snap[\"press\"] = 450 - 12 * ld - 85 * dg * iso - 15 * dg * isc + rng.normal(0, 6, S)\n", + " ph = rng.uniform(0, 2 * np.pi, (S, 3))\n", + " a1, a2 = 1.0 * (0.5 + ld), 0.4 * (0.5 + ld)\n", + " W = (a1[:, None] * np.sin(2 * np.pi * 50 * TT + ph[:, :1]) + a2[:, None] * np.sin(2 * np.pi * 100 * TT + ph[:, 1:2])\n", + " + rng.normal(0, 0.35, (S, NS)))\n", + " W += (0.9 * dg * isb)[:, None] * np.sin(2 * np.pi * 165 * TT + ph[:, 2:3])\n", + " W += (0.8 * dg * isg)[:, None] * (np.sin(2 * np.pi * 150 * TT + ph[:, 2:3]) + 0.8 * np.sin(2 * np.pi * 100 * TT))\n", + " W += (0.5 * dg * iso)[:, None] * np.sin(2 * np.pi * 200 * TT + ph[:, 2:3])\n", + " k = rng.poisson(10 * dg * isb) # 베어링 열화: 충격성 펄스(첨도 상승)\n", + " pos, amp = rng.integers(0, NS, (S, 30)), rng.normal(0, 3.0, (S, 30)) * dg[:, None]\n", + " amp = amp * (np.arange(30)[None, :] < k[:, None])\n", + " np.add.at(W, (np.repeat(np.arange(S), 30), pos.ravel()), amp.ravel())\n", + " return units, snap, W\n", + "\n", + "\n", + "def extract(W):\n", + " \"\"\"진동 파형 -> 특징량: RMS, 첨도, 파고율, 최대-최소, FFT 대역 에너지(50/100/150/165/200Hz 부근)\"\"\"\n", + " x = W - W.mean(axis=1, keepdims=True)\n", + " rms = np.sqrt((x ** 2).mean(axis=1))\n", + " out = {\"rms\": rms, \"kurt\": kurtosis(x, axis=1, fisher=False), \"crest\": np.abs(x).max(axis=1) / rms, \"p2p\": x.max(axis=1) - x.min(axis=1)}\n", + " A = np.abs(np.fft.rfft(x * np.hanning(NS), axis=1)) / NS\n", + " f = np.fft.rfftfreq(NS, 1 / FS)\n", + " for nm, (lo, hi) in BANDS.items():\n", + " out[nm] = np.sqrt((A[:, (f >= lo) & (f < hi)] ** 2).sum(axis=1))\n", + " return pd.DataFrame(out)\n", + "\n", + "# ---------------------------------------------------------------------------\n", + "# 공통 분석 조각: 일별 특징량 표(파형 특징 + 온도·압력 + 파생), 개체 단위 학습/검증 분할(앞선 24대 학습, 뒤 24대 검증)\n", + "# ---------------------------------------------------------------------------\n", + "BASE = [\"rms\", \"kurt\", \"crest\", \"p2p\", \"b50\", \"b100\", \"b150\", \"b165\", \"b200\", \"temp\", \"press\"]\n", + "ROLL = [\"rms\", \"kurt\", \"b150\", \"b165\", \"b200\", \"temp\", \"press\"]\n", + "\n", + "\n", + "def build_features(snap, W):\n", + " D = pd.concat([snap.reset_index(drop=True), extract(W)], axis=1)\n", + " D[\"temp_res\"] = D[\"temp\"] - (55 + 22 * D[\"load\"] + 0.5 * D[\"ambient\"]) # 부하·외기 대비 온도 잔차\n", + " D[\"press_res\"] = D[\"press\"] - (450 - 12 * D[\"load\"]) # 부하 대비 압력 잔차\n", + " D[\"rms_n\"] = D[\"rms\"] / (0.5 + D[\"load\"]) # 부하 정규화 RMS\n", + " D[\"hf_ratio\"] = (D[\"b150\"] + D[\"b165\"] + D[\"b200\"]) / (D[\"b50\"] + 1e-6) # 고주파/기본주파 비\n", + " g = D.groupby(\"unit\")\n", + " for c in ROLL:\n", + " D[c + \"_m3\"] = g[c].transform(lambda s: s.rolling(3, min_periods=1).mean())\n", + " for c in (\"rms_n\", \"temp_res\", \"press_res\"):\n", + " D[c + \"_d\"] = g[c].diff().fillna(0)\n", + " return D\n", + "\n", + "\n", + "DERIVED = [\"temp_res\", \"press_res\", \"rms_n\", \"hf_ratio\"] + [c + \"_m3\" for c in ROLL] + [\"rms_n_d\", \"temp_res_d\", \"press_res_d\"]\n", + "FEATS = BASE + DERIVED\n", + "AE_COLS = [\"rms_n\", \"kurt\", \"b150\", \"b165\", \"b200\", \"hf_ratio\", \"temp_res\", \"press_res\"]\n", + "\n", + "\n", + "def split_units(units, n_train=24):\n", + " return list(units[\"unit\"][:n_train]), list(units[\"unit\"][n_train:])\n", + "\n", + "\n", + "class AE:\n", + " \"\"\"오토인코더(입력 8 -> 10 -> 4 -> 10 -> 8): 정상 자료만 재구성하도록 학습하고, 재구성 오차를 이상 점수로 쓴다.\"\"\"\n", + "\n", + " def __init__(self, cols=AE_COLS, q=0.99, seed=0):\n", + " self.cols, self.q, self.seed = cols, q, seed\n", + "\n", + " def fit(self, healthy):\n", + " self.sc = StandardScaler().fit(healthy[self.cols])\n", + " Z = self.sc.transform(healthy[self.cols])\n", + " self.net = MLPRegressor(hidden_layer_sizes=(10, 4, 10), max_iter=800, random_state=self.seed).fit(Z, Z)\n", + " self.thr = float(np.quantile(self.err(healthy), self.q))\n", + " return self\n", + "\n", + " def contrib(self, d):\n", + " Z = self.sc.transform(d[self.cols])\n", + " return (Z - self.net.predict(Z)) ** 2 # 입력 항목별 재구성 오차(이상 기여도)\n", + "\n", + " def err(self, d):\n", + " return self.contrib(d).mean(axis=1)\n", + "\n", + "\n", + "def latch(d, e, thr, k=2):\n", + " \"\"\"같은 개체에서 이상 점수가 k일 연속 기준을 넘으면 경보를 켜고 유지(래치)한다.\"\"\"\n", + " out, e = np.zeros(len(d), bool), np.asarray(e)\n", + " for idx in d.reset_index(drop=True).groupby(\"unit\").indices.values():\n", + " run, on = 0, False\n", + " for i in idx:\n", + " run = run + 1 if e[i] > thr else 0\n", + " on = on or run >= k\n", + " out[i] = on\n", + " return out\n", + "\n", + "units, snap, W = make_runs()\n", + "D = build_features(snap, W)\n", + "tr_u, va_u = split_units(units)\n", + "tr, va = D[D[\"unit\"].isin(tr_u)].reset_index(drop=True), D[D[\"unit\"].isin(va_u)].reset_index(drop=True)\n", + "print(f\"[교육용가상자료] 학습 {len(tr_u)}대({len(tr)}건), 검증 {len(va_u)}대({len(va)}건), 파생 피처 {len(DERIVED)}개 (목표 10개 이상: {'달성' if len(DERIVED) >= 10 else '미달'})\")\n", + "print(\" 파생 피처:\", \", \".join(DERIVED))\n", + "\n", + "# 1) 오토인코더 이상 감지 vs 임계값 Baseline (검증 개체)\n", + "ae = AE().fit(tr[tr[\"deg\"] == 0])\n", + "e = ae.err(va)\n", + "va[\"anomaly\"] = va[\"deg\"] >= 0.1\n", + "va[\"ae_alarm\"] = latch(va, e, ae.thr, k=2)\n", + "va[\"th_alarm\"] = (va[\"rms\"] > 1.25) | (va[\"kurt\"] > 3.0) | (va[\"temp\"] > 92.0) | (va[\"press\"] < 405.0)\n", + "va[\"ae_raw\"] = e > ae.thr\n", + "\n", + "\n", + "def report(name, col):\n", + " a = va[col]\n", + " tpr, far = (a & va[\"anomaly\"]).sum() / va[\"anomaly\"].sum(), (a & ~va[\"anomaly\"]).sum() / (~va[\"anomaly\"]).sum()\n", + " hi = a[va[\"hi_load\"] & ~va[\"anomaly\"]].mean()\n", + " lead = [g.loc[g[col] & g[\"anomaly\"], \"rul\"].max() if (g[col] & g[\"anomaly\"]).any() else 0 for _, g in va.groupby(\"unit\")]\n", + " print(f\" {name:22s} 탐지율 {tpr:5.1%} 오경보율 {far:5.1%} 고부하일 오경보 {hi:5.1%} 고장 전 확보 중앙값 {np.median(lead):.0f}일\")\n", + " return tpr, far\n", + "\n", + "\n", + "print(f\"\\n[오토인코더 입력 {len(AE_COLS)}개, 정상 자료 재구성 오차 99분위 {ae.thr:.2f}를 기준으로 사용]\")\n", + "report(\"임계값 Baseline\", \"th_alarm\")\n", + "report(\"오토인코더(1회 초과)\", \"ae_raw\")\n", + "tpr, far = report(\"오토인코더(2일 연속)\", \"ae_alarm\")\n", + "print(f\" 미세 변화 구간(열화 0.1~0.3) 탐지율: Baseline {va.loc[va['deg'].between(0.1, 0.3), 'th_alarm'].mean():.1%} -> 오토인코더 {va.loc[va['deg'].between(0.1, 0.3), 'ae_alarm'].mean():.1%}\")\n", + "\n", + "# 2) RUL 기준 모델: 학습 개체 일부로 학습, 개체 단위 시계열 교차검증(앞선 개체로 학습 -> 뒤 개체 검증)\n", + "CAP = 120 # RUL 상한(일): 열화 초기에는 120일 이상은 구분하지 않는다\n", + "def fit_rul(d, cols):\n", + " return GradientBoostingRegressor(n_estimators=100, max_depth=3, learning_rate=0.1, subsample=0.8, random_state=0).fit(d[cols], np.minimum(d[\"rul\"], CAP))\n", + "\n", + "\n", + "def mape(d, p):\n", + " m = (d[\"deg\"] >= 0.1) & (d[\"rul\"] >= 10) # 열화가 시작된 구간, 고장 직전 10일 미만은 분모 문제로 제외\n", + " y = np.minimum(d[\"rul\"], CAP)[m]\n", + " return float(np.mean(np.abs(y - p[m]) / y))\n", + "\n", + "\n", + "order = list(units[\"unit\"])\n", + "folds, cut = [], [12, 24, 36]\n", + "for i, c in enumerate(cut):\n", + " trn, val = D[D[\"unit\"].isin(order[:c])], D[D[\"unit\"].isin(order[c:c + 12])]\n", + " if len(val):\n", + " folds.append(mape(val, fit_rul(trn, FEATS).predict(val[FEATS])))\n", + "base_only = fit_rul(tr, BASE)\n", + "full = fit_rul(tr, FEATS)\n", + "m_base, m_full = mape(va, base_only.predict(va[BASE])), mape(va, full.predict(va[FEATS]))\n", + "print(f\"\\n[RUL 모델] 개체 단위 시계열 교차검증 폴드별 MAPE: {', '.join(f'{f:.1%}' for f in folds)} -> 평균 {np.mean(folds):.1%} (목표 25% 이하: {'달성' if np.mean(folds) <= 0.25 else '미달'})\")\n", + "print(f\" 검증 24대 MAPE: 원본 센서 {len(BASE)}개 {m_base:.1%} -> 파생 포함 {len(FEATS)}개 {m_full:.1%} (열화 구간, 잔존 10일 이상)\")\n", + "\n", + "# 3) 순열 중요도로 차원 축소\n", + "pi = permutation_importance(full, va[FEATS], np.minimum(va[\"rul\"], CAP), n_repeats=3, random_state=0, scoring=\"neg_mean_absolute_error\")\n", + "imp = pd.Series(pi.importances_mean, FEATS).sort_values(ascending=False)\n", + "print(\"피처 중요도 상위 5: \" + \", \".join(f\"{k}({v:.1f})\" for k, v in imp.head(5).items()))\n", + "top = list(imp.index[:8])\n", + "m_top = mape(va, fit_rul(tr, top).predict(va[top]))\n", + "print(f\"차원 축소 {len(FEATS)}개 -> {len(top)}개: 검증 MAPE {m_full:.1%} -> {m_top:.1%}\")\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 04 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + "[교육용가상자료] 학습 24대(3847건), 검증 24대(3886건), 파생 피처 14개 (목표 10개 이상: 달성)\n", + " 파생 피처: temp_res, press_res, rms_n, hf_ratio, rms_m3, kurt_m3, b150_m3, b165_m3, b200_m3, temp_m3, press_m3, rms_n_d, temp_res_d, press_res_d\n", + "\n", + "[오토인코더 입력 8개, 정상 자료 재구성 오차 99분위 1.27를 기준으로 사용]\n", + " 임계값 Baseline 탐지율 22.7% 오경보율 1.7% 고부하일 오경보 22.8% 고장 전 확보 중앙값 40일\n", + " 오토인코더(1회 초과) 탐지율 80.1% 오경보율 1.2% 고부하일 오경보 0.6% 고장 전 확보 중앙값 66일\n", + " 오토인코더(2일 연속) 탐지율 79.2% 오경보율 0.0% 고부하일 오경보 0.0% 고장 전 확보 중앙값 56일\n", + " 미세 변화 구간(열화 0.1~0.3) 탐지율: Baseline 2.0% -> 오토인코더 32.4%\n", + "\n", + "[RUL 모델] 개체 단위 시계열 교차검증 폴드별 MAPE: 26.7%, 24.2%, 17.1% -> 평균 22.7% (목표 25% 이하: 달성)\n", + " 검증 24대 MAPE: 원본 센서 11개 43.3% -> 파생 포함 25개 20.4% (열화 구간, 잔존 10일 이상)\n", + "피처 중요도 상위 5: hf_ratio(11.8), temp_res(7.0), b150_m3(3.8), b165_m3(2.8), press_m3(2.5)\n", + "차원 축소 25개 -> 8개: 검증 MAPE 20.4% -> 20.8%\n", + "```\n", + "
\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 05. 부품 고장 임박 시점(RUL) 예측 및 상태 기반 이상 신뢰도 점수 엔진 개발\n", + "**소요시간**: 약 3.5시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "부품 고장 임박 시점(RUL) 예측 및 상태 이상 신뢰도 점수 엔진을 개발한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **부품 고장 임박 시점(RUL)을 예측하는 앙상블 모델과 상태 이상 신뢰도 점수 엔진을 개발한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **그래디언트 부스팅, 랜덤 포레스트, 릿지를 평균하는 앙상블을 하이퍼파라미터 탐색으로 만들어 RMS와 온도만 쓰는 선형 기준 모델과 Hold-out 검증 개체에서 MAE, RMSE, R2를 비교 수행하고 검증하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `src/models/mnt04_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": [ + "\"\"\"진동·온도·압력 특징으로 부품 고장 임박 시점(RUL)을 예측하는 앙상블(그래디언트 부스팅+랜덤 포레스트+릿지) 모델을 하이퍼파라미터 탐색으로 만들어 단순 선형 기준 모델과 MAE·RMSE·R2를 비교하고, 오토인코더 이상 점수와 예측 구간으로 상태 이상 신뢰도 점수를 산출해 신뢰도 구간별 실제 정확도와 점검 카드를 출력한다.\"\"\"\n", + "import numpy as np\n", + "import pandas as pd\n", + "from scipy.stats import kurtosis\n", + "from sklearn.ensemble import GradientBoostingRegressor, RandomForestRegressor\n", + "from sklearn.linear_model import LinearRegression, Ridge\n", + "from sklearn.metrics import mean_absolute_error, mean_squared_error, r2_score\n", + "from sklearn.neural_network import MLPRegressor\n", + "from sklearn.pipeline import make_pipeline\n", + "from sklearn.preprocessing import StandardScaler\n", + "\n", + "# ---------------------------------------------------------------------------\n", + "# [교육용가상자료] 가상 기동체 48대의 엔진·구동계 진동·온도·압력 센서 자료 생성기 (실제 장비·부대·정비 실적 없음)\n", + "# 개체마다 정상 -> 열화 -> 고장(수명종료)까지 하루 1회 측정(진동 파형 256표본 + 온도 + 압력 + 부하)을 만든다.\n", + "# 열화 유형 4가지: bearing(베어링), gear(기어), oil_pump(오일 펌프), cooling(냉각 팬 모터)\n", + "# ---------------------------------------------------------------------------\n", + "FS, NS = 1024, 256 # 가상 표본화 주파수(Hz), 파형 1개당 표본 수\n", + "TT = np.arange(NS) / FS\n", + "MODES = { # 열화 유형: (발생 확률, 평균 수명(일), 교체 수리부속, 가상 단가지수)\n", + " \"bearing\": (0.40, 160, \"주 베어링 세트\", 6.0), \"gear\": (0.25, 175, \"구동 기어 세트\", 9.0),\n", + " \"oil_pump\": (0.20, 150, \"오일 펌프\", 4.0), \"cooling\": (0.15, 140, \"냉각 팬 모터\", 3.0)}\n", + "KIT_COST = 1.0 # 모든 정비에 함께 소모되는 씰·필터 키트 단가지수\n", + "BANDS = {\"b50\": (40, 60), \"b100\": (90, 110), \"b150\": (140, 156), \"b165\": (156, 176), \"b200\": (190, 215)}\n", + "\n", + "\n", + "def make_runs(n=48, seed=1):\n", + " \"\"\"반환: units(개체 정보), snap(일별 측정 요약), W(진동 파형 [측정 수, 256])\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " names = list(MODES)\n", + " mode = rng.choice(names, n, p=[MODES[m][0] for m in names])\n", + " life = np.clip(np.array([rng.normal(MODES[m][1], 18) for m in mode]), 90, 230).astype(int)\n", + " start = np.sort(rng.integers(0, 240, n))\n", + " units = pd.DataFrame({\"unit\": [f\"U{i:02d}\" for i in range(n)], \"mode\": mode, \"life\": life, \"start\": start, \"base_load\": rng.uniform(0.4, 0.75, n)})\n", + " rows = []\n", + " for u in units.itertuples():\n", + " d = np.arange(u.life + 1)\n", + " onset = 0.45 * u.life\n", + " deg = np.clip((d - onset) / (u.life - onset), 0, 1) ** 1.5\n", + " load = np.clip(u.base_load + rng.normal(0, 0.06, len(d)), 0.2, 1.0)\n", + " hi = rng.random(len(d)) < 0.07 # 고부하 운용일(정상이지만 진동·온도 상승)\n", + " load = np.where(hi, rng.uniform(0.95, 1.1, len(d)), load)\n", + " rows.append(pd.DataFrame({\"unit\": u.unit, \"day\": d, \"cal\": u.start + d, \"rul\": u.life - d, \"deg\": deg, \"load\": load, \"hi_load\": hi, \"mode\": u.mode}))\n", + " snap = pd.concat(rows, ignore_index=True)\n", + " S = len(snap)\n", + " snap[\"ambient\"] = 15 + 12 * np.sin(2 * np.pi * snap[\"cal\"] / 365) + rng.normal(0, 2, S)\n", + " dg, ld, md = snap[\"deg\"].values, snap[\"load\"].values, snap[\"mode\"].values\n", + " isb, isg, iso, isc = (md == \"bearing\"), (md == \"gear\"), (md == \"oil_pump\"), (md == \"cooling\")\n", + " snap[\"temp\"] = 55 + 22 * ld + 0.5 * snap[\"ambient\"] + 9 * dg * iso + 26 * dg * isc + 3 * dg * (isb | isg) + rng.normal(0, 1.5, S)\n", + " snap[\"press\"] = 450 - 12 * ld - 85 * dg * iso - 15 * dg * isc + rng.normal(0, 6, S)\n", + " ph = rng.uniform(0, 2 * np.pi, (S, 3))\n", + " a1, a2 = 1.0 * (0.5 + ld), 0.4 * (0.5 + ld)\n", + " W = (a1[:, None] * np.sin(2 * np.pi * 50 * TT + ph[:, :1]) + a2[:, None] * np.sin(2 * np.pi * 100 * TT + ph[:, 1:2])\n", + " + rng.normal(0, 0.35, (S, NS)))\n", + " W += (0.9 * dg * isb)[:, None] * np.sin(2 * np.pi * 165 * TT + ph[:, 2:3])\n", + " W += (0.8 * dg * isg)[:, None] * (np.sin(2 * np.pi * 150 * TT + ph[:, 2:3]) + 0.8 * np.sin(2 * np.pi * 100 * TT))\n", + " W += (0.5 * dg * iso)[:, None] * np.sin(2 * np.pi * 200 * TT + ph[:, 2:3])\n", + " k = rng.poisson(10 * dg * isb) # 베어링 열화: 충격성 펄스(첨도 상승)\n", + " pos, amp = rng.integers(0, NS, (S, 30)), rng.normal(0, 3.0, (S, 30)) * dg[:, None]\n", + " amp = amp * (np.arange(30)[None, :] < k[:, None])\n", + " np.add.at(W, (np.repeat(np.arange(S), 30), pos.ravel()), amp.ravel())\n", + " return units, snap, W\n", + "\n", + "\n", + "def extract(W):\n", + " \"\"\"진동 파형 -> 특징량: RMS, 첨도, 파고율, 최대-최소, FFT 대역 에너지(50/100/150/165/200Hz 부근)\"\"\"\n", + " x = W - W.mean(axis=1, keepdims=True)\n", + " rms = np.sqrt((x ** 2).mean(axis=1))\n", + " out = {\"rms\": rms, \"kurt\": kurtosis(x, axis=1, fisher=False), \"crest\": np.abs(x).max(axis=1) / rms, \"p2p\": x.max(axis=1) - x.min(axis=1)}\n", + " A = np.abs(np.fft.rfft(x * np.hanning(NS), axis=1)) / NS\n", + " f = np.fft.rfftfreq(NS, 1 / FS)\n", + " for nm, (lo, hi) in BANDS.items():\n", + " out[nm] = np.sqrt((A[:, (f >= lo) & (f < hi)] ** 2).sum(axis=1))\n", + " return pd.DataFrame(out)\n", + "\n", + "# ---------------------------------------------------------------------------\n", + "# 공통 분석 조각: 일별 특징량 표(파형 특징 + 온도·압력 + 파생), 개체 단위 학습/검증 분할(앞선 24대 학습, 뒤 24대 검증)\n", + "# ---------------------------------------------------------------------------\n", + "BASE = [\"rms\", \"kurt\", \"crest\", \"p2p\", \"b50\", \"b100\", \"b150\", \"b165\", \"b200\", \"temp\", \"press\"]\n", + "ROLL = [\"rms\", \"kurt\", \"b150\", \"b165\", \"b200\", \"temp\", \"press\"]\n", + "\n", + "\n", + "def build_features(snap, W):\n", + " D = pd.concat([snap.reset_index(drop=True), extract(W)], axis=1)\n", + " D[\"temp_res\"] = D[\"temp\"] - (55 + 22 * D[\"load\"] + 0.5 * D[\"ambient\"]) # 부하·외기 대비 온도 잔차\n", + " D[\"press_res\"] = D[\"press\"] - (450 - 12 * D[\"load\"]) # 부하 대비 압력 잔차\n", + " D[\"rms_n\"] = D[\"rms\"] / (0.5 + D[\"load\"]) # 부하 정규화 RMS\n", + " D[\"hf_ratio\"] = (D[\"b150\"] + D[\"b165\"] + D[\"b200\"]) / (D[\"b50\"] + 1e-6) # 고주파/기본주파 비\n", + " g = D.groupby(\"unit\")\n", + " for c in ROLL:\n", + " D[c + \"_m3\"] = g[c].transform(lambda s: s.rolling(3, min_periods=1).mean())\n", + " for c in (\"rms_n\", \"temp_res\", \"press_res\"):\n", + " D[c + \"_d\"] = g[c].diff().fillna(0)\n", + " return D\n", + "\n", + "\n", + "DERIVED = [\"temp_res\", \"press_res\", \"rms_n\", \"hf_ratio\"] + [c + \"_m3\" for c in ROLL] + [\"rms_n_d\", \"temp_res_d\", \"press_res_d\"]\n", + "FEATS = BASE + DERIVED\n", + "AE_COLS = [\"rms_n\", \"kurt\", \"b150\", \"b165\", \"b200\", \"hf_ratio\", \"temp_res\", \"press_res\"]\n", + "\n", + "\n", + "def split_units(units, n_train=24):\n", + " return list(units[\"unit\"][:n_train]), list(units[\"unit\"][n_train:])\n", + "\n", + "\n", + "class AE:\n", + " \"\"\"오토인코더(입력 8 -> 10 -> 4 -> 10 -> 8): 정상 자료만 재구성하도록 학습하고, 재구성 오차를 이상 점수로 쓴다.\"\"\"\n", + "\n", + " def __init__(self, cols=AE_COLS, q=0.99, seed=0):\n", + " self.cols, self.q, self.seed = cols, q, seed\n", + "\n", + " def fit(self, healthy):\n", + " self.sc = StandardScaler().fit(healthy[self.cols])\n", + " Z = self.sc.transform(healthy[self.cols])\n", + " self.net = MLPRegressor(hidden_layer_sizes=(10, 4, 10), max_iter=800, random_state=self.seed).fit(Z, Z)\n", + " self.thr = float(np.quantile(self.err(healthy), self.q))\n", + " return self\n", + "\n", + " def contrib(self, d):\n", + " Z = self.sc.transform(d[self.cols])\n", + " return (Z - self.net.predict(Z)) ** 2 # 입력 항목별 재구성 오차(이상 기여도)\n", + "\n", + " def err(self, d):\n", + " return self.contrib(d).mean(axis=1)\n", + "\n", + "\n", + "def latch(d, e, thr, k=2):\n", + " \"\"\"같은 개체에서 이상 점수가 k일 연속 기준을 넘으면 경보를 켜고 유지(래치)한다.\"\"\"\n", + " out, e = np.zeros(len(d), bool), np.asarray(e)\n", + " for idx in d.reset_index(drop=True).groupby(\"unit\").indices.values():\n", + " run, on = 0, False\n", + " for i in idx:\n", + " run = run + 1 if e[i] > thr else 0\n", + " on = on or run >= k\n", + " out[i] = on\n", + " return out\n", + "\n", + "CAP = 120\n", + "units, snap, W = make_runs()\n", + "D = build_features(snap, W)\n", + "D[\"hf_max\"] = D.groupby(\"unit\")[\"hf_ratio\"].cummax() # 지금까지 가장 높았던 고주파 비(열화는 되돌아가지 않는다)\n", + "D[\"temp_res_max\"] = D.groupby(\"unit\")[\"temp_res\"].cummax()\n", + "FEATS = FEATS + [\"day\", \"hf_max\", \"temp_res_max\"] # 운용일수(day)는 정비 이력에서 아는 값\n", + "tr_u, va_u = split_units(units)\n", + "tr, va = D[D[\"unit\"].isin(tr_u)].reset_index(drop=True), D[D[\"unit\"].isin(va_u)].reset_index(drop=True)\n", + "ytr, yva = np.minimum(tr[\"rul\"], CAP), np.minimum(va[\"rul\"], CAP)\n", + "print(f\"[교육용가상자료] 학습 {len(tr_u)}대 {len(tr)}건, Hold-out 검증 {len(va_u)}대 {len(va)}건 (RUL은 {CAP}일 상한)\")\n", + "\n", + "# 1) 기준 모델: 진동 RMS와 온도만 쓰는 선형 회귀\n", + "base = LinearRegression().fit(tr[[\"rms\", \"temp\"]], ytr)\n", + "pb = base.predict(va[[\"rms\", \"temp\"]])\n", + "\n", + "# 2) 고도화: 하이퍼파라미터 탐색(학습 개체의 뒤쪽 6대를 내부 검증) 후 앙상블\n", + "inner_tr, inner_va = tr[tr[\"unit\"].isin(tr_u[:18])], tr[tr[\"unit\"].isin(tr_u[18:])]\n", + "\n", + "\n", + "def ens(d_tr, y, d_va, depth, alpha):\n", + " ms = [GradientBoostingRegressor(n_estimators=100, max_depth=depth, learning_rate=0.1, subsample=0.8, random_state=0),\n", + " RandomForestRegressor(n_estimators=40, max_depth=10, min_samples_leaf=5, n_jobs=-1, random_state=0),\n", + " make_pipeline(StandardScaler(), Ridge(alpha=alpha))]\n", + " for m in ms:\n", + " m.fit(d_tr[FEATS], y)\n", + " return ms, np.mean([m.predict(d_va[FEATS]) for m in ms], axis=0)\n", + "\n", + "\n", + "score = {(dp, al): mean_absolute_error(np.minimum(inner_va[\"rul\"], CAP), ens(inner_tr, np.minimum(inner_tr[\"rul\"], CAP), inner_va, dp, al)[1])\n", + " for dp in (3, 4) for al in (1, 10)}\n", + "dp, al = min(score, key=score.get)\n", + "models, pe = ens(tr, ytr, va, dp, al)\n", + "names = [\"GBR\", \"RF\", \"Ridge\"]\n", + "print(f\"선택 하이퍼파라미터: GBR depth={dp}, ridge alpha={al} (내부 검증 MAE {score[(dp, al)]:.1f}일)\")\n", + "\n", + "\n", + "def metrics(p):\n", + " return mean_absolute_error(yva, p), mean_squared_error(yva, p) ** 0.5, r2_score(yva, p)\n", + "\n", + "\n", + "mb, me = metrics(pb), metrics(pe)\n", + "print(\"\\n[RUL 예측 성능] 모델 | MAE(일) | RMSE(일) | R2\")\n", + "print(f\" 기준(RMS+온도 선형) {mb[0]:6.2f} {mb[1]:6.2f} {mb[2]:.3f}\")\n", + "for n, m in zip(names, models):\n", + " x = metrics(m.predict(va[FEATS]))\n", + " print(f\" 단일 {n:12s} {x[0]:6.2f} {x[1]:6.2f} {x[2]:.3f}\")\n", + "print(f\" 고도화(앙상블) {me[0]:6.2f} {me[1]:6.2f} {me[2]:.3f}\")\n", + "imp_mae, imp_rmse = 1 - me[0] / mb[0], 1 - me[1] / mb[1]\n", + "print(f\"개선율 MAE {imp_mae:.1%}, RMSE {imp_rmse:.1%} (목표 20% 이상: {'달성' if min(imp_mae, imp_rmse) >= 0.2 else '미달'}), R2 {me[2]:.3f} (목표 0.85 이상: {'달성' if me[2] >= 0.85 else '미달'})\")\n", + "for lo, hi, nm in [(0, 30, \"고장 30일 이내\"), (30, 90, \"30~90일\"), (90, 9999, \"90일 이상\")]:\n", + " m = (va[\"rul\"] >= lo) & (va[\"rul\"] < hi)\n", + " print(f\" 구간 {nm:10s} MAE {mean_absolute_error(yva[m], pe[m]):5.1f}일 (기준 {mean_absolute_error(yva[m], pb[m]):5.1f}일)\")\n", + "\n", + "# 3) 상태 이상 신뢰도 점수 = 오토인코더 이상 점수(정상 대비 배수)와 앙상블 구성 모델 간 의견 일치도를 결합\n", + "ae = AE().fit(tr[tr[\"deg\"] == 0])\n", + "e = ae.err(va)\n", + "z = np.log(e / ae.thr)\n", + "conf = 1 / (1 + np.exp(-2.5 * z)) # 재구성 오차가 기준과 같으면 0.5\n", + "spread = np.std([m.predict(va[FEATS]) for m in models], axis=0)\n", + "agree = np.clip(1 - spread / 40, 0, 1) # 모델 간 RUL 의견 차이가 작을수록 높음\n", + "score_c = conf * (0.7 + 0.3 * agree)\n", + "truth = va[\"deg\"] >= 0.1\n", + "print(\"\\n[상태 이상 신뢰도] 구간 | 건수 | 평균 점수 | 실제 열화 비율\")\n", + "bins = [0, 0.2, 0.5, 0.8, 1.01]\n", + "for a, b in zip(bins[:-1], bins[1:]):\n", + " m = (score_c >= a) & (score_c < b)\n", + " print(f\" {a:.1f}~{min(b, 1):.1f} {m.sum():5d} {score_c[m].mean():.2f} {truth[m].mean():.2f}\")\n", + "ece = sum(((score_c >= a) & (score_c < b)).mean() * abs(score_c[(score_c >= a) & (score_c < b)].mean() - truth[(score_c >= a) & (score_c < b)].mean())\n", + " for a, b in zip(bins[:-1], bins[1:]) if ((score_c >= a) & (score_c < b)).any())\n", + "print(f\"기대 보정 오차(ECE) {ece:.3f}\")\n", + "\n", + "# 4) 점검 카드 예시(개체 하나의 최근 상태)\n", + "print(\"\\n[점검 카드 예시] 개체 | 측정일 | 이상 신뢰도 | 예측 RUL(일) | 실제 RUL(일)\")\n", + "va[\"conf\"], va[\"rul_hat\"] = score_c, pe\n", + "for u in va_u[:3]:\n", + " g = va[va[\"unit\"] == u]\n", + " r = g[g[\"rul\"] == 40].iloc[0] if (g[\"rul\"] == 40).any() else g.iloc[-1]\n", + " print(f\" {u} {int(r['day']):3d}일째 {r['conf']:.2f} {r['rul_hat']:5.1f} {int(r['rul'])} -> {'정비 계획 수립 권고' if r['conf'] >= 0.8 else '추가 관찰'}(검사관 확인)\")\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 05 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + "[교육용가상자료] 학습 24대 3847건, Hold-out 검증 24대 3886건 (RUL은 120일 상한)\n", + "선택 하이퍼파라미터: GBR depth=4, ridge alpha=1 (내부 검증 MAE 6.8일)\n", + "\n", + "[RUL 예측 성능] 모델 | MAE(일) | RMSE(일) | R2\n", + " 기준(RMS+온도 선형) 35.67 41.43 -0.078\n", + " 단일 GBR 9.45 13.37 0.888\n", + " 단일 RF 8.00 12.31 0.905\n", + " 단일 Ridge 9.98 12.78 0.897\n", + " 고도화(앙상블) 8.56 12.05 0.909\n", + "개선율 MAE 76.0%, RMSE 70.9% (목표 20% 이상: 달성), R2 0.909 (목표 0.85 이상: 달성)\n", + " 구간 고장 30일 이내 MAE 5.7일 (기준 44.8일)\n", + " 구간 30~90일 MAE 10.2일 (기준 16.9일)\n", + " 구간 90일 이상 MAE 8.4일 (기준 47.5일)\n", + "\n", + "[상태 이상 신뢰도] 구간 | 건수 | 평균 점수 | 실제 열화 비율\n", + " 0.0~0.2 2242 0.04 0.10\n", + " 0.2~0.5 282 0.31 0.41\n", + " 0.5~0.8 161 0.65 0.86\n", + " 0.8~1.0 1201 0.95 1.00\n", + "기대 보정 오차(ECE) 0.065\n", + "\n", + "[점검 카드 예시] 개체 | 측정일 | 이상 신뢰도 | 예측 RUL(일) | 실제 RUL(일)\n", + " U24 94일째 0.01 56.0 40 -> 추가 관찰(검사관 확인)\n", + " U25 95일째 0.62 56.6 40 -> 추가 관찰(검사관 확인)\n", + " U26 148일째 0.92 28.0 40 -> 정비 계획 수립 권고(검사관 확인)\n", + "```\n", + "
\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 06. 장비 센서 상태 실시간 모니터링 및 결함 징후 경보 대시보드 구현\n", + "**소요시간**: 약 3.0시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "장비 센서 상태 실시간 모니터링 및 결함 경보 대시보드를 구현한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **장비 센서 상태를 개체별로 모니터링해 결함 경보를 띄우는 정비병용 화면을 구성하고, 경보 건마다 기여도 상위 3개 인자와 자연어 요약을 자동 산출하는 설명가능성(XAI) 모듈을 구현한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **오토인코더 재구성 오차를 입력 인자별로 나누어 경보 건마다 상위 3개 인자를 뽑고, 1순위 인자를 정상값으로 되돌렸을 때 점수가 내려가는지로 근거 유효성을 점검 수행하고 검증하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `src/explainability/mnt04_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개 인자를 자동 산출하며(근거 유효성 검증), 의심 부품과 자연어 요약 문장을 생성하는 설명가능성(XAI) 모듈을 구현한다.\"\"\"\n", + "import numpy as np\n", + "import pandas as pd\n", + "import matplotlib.pyplot as plt\n", + "from scipy.stats import kurtosis\n", + "from sklearn.ensemble import GradientBoostingRegressor\n", + "from sklearn.neural_network import MLPRegressor\n", + "from sklearn.preprocessing import StandardScaler\n", + "\n", + "# ---------------------------------------------------------------------------\n", + "# [교육용가상자료] 가상 기동체 48대의 엔진·구동계 진동·온도·압력 센서 자료 생성기 (실제 장비·부대·정비 실적 없음)\n", + "# 개체마다 정상 -> 열화 -> 고장(수명종료)까지 하루 1회 측정(진동 파형 256표본 + 온도 + 압력 + 부하)을 만든다.\n", + "# 열화 유형 4가지: bearing(베어링), gear(기어), oil_pump(오일 펌프), cooling(냉각 팬 모터)\n", + "# ---------------------------------------------------------------------------\n", + "FS, NS = 1024, 256 # 가상 표본화 주파수(Hz), 파형 1개당 표본 수\n", + "TT = np.arange(NS) / FS\n", + "MODES = { # 열화 유형: (발생 확률, 평균 수명(일), 교체 수리부속, 가상 단가지수)\n", + " \"bearing\": (0.40, 160, \"주 베어링 세트\", 6.0), \"gear\": (0.25, 175, \"구동 기어 세트\", 9.0),\n", + " \"oil_pump\": (0.20, 150, \"오일 펌프\", 4.0), \"cooling\": (0.15, 140, \"냉각 팬 모터\", 3.0)}\n", + "KIT_COST = 1.0 # 모든 정비에 함께 소모되는 씰·필터 키트 단가지수\n", + "BANDS = {\"b50\": (40, 60), \"b100\": (90, 110), \"b150\": (140, 156), \"b165\": (156, 176), \"b200\": (190, 215)}\n", + "\n", + "\n", + "def make_runs(n=48, seed=1):\n", + " \"\"\"반환: units(개체 정보), snap(일별 측정 요약), W(진동 파형 [측정 수, 256])\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " names = list(MODES)\n", + " mode = rng.choice(names, n, p=[MODES[m][0] for m in names])\n", + " life = np.clip(np.array([rng.normal(MODES[m][1], 18) for m in mode]), 90, 230).astype(int)\n", + " start = np.sort(rng.integers(0, 240, n))\n", + " units = pd.DataFrame({\"unit\": [f\"U{i:02d}\" for i in range(n)], \"mode\": mode, \"life\": life, \"start\": start, \"base_load\": rng.uniform(0.4, 0.75, n)})\n", + " rows = []\n", + " for u in units.itertuples():\n", + " d = np.arange(u.life + 1)\n", + " onset = 0.45 * u.life\n", + " deg = np.clip((d - onset) / (u.life - onset), 0, 1) ** 1.5\n", + " load = np.clip(u.base_load + rng.normal(0, 0.06, len(d)), 0.2, 1.0)\n", + " hi = rng.random(len(d)) < 0.07 # 고부하 운용일(정상이지만 진동·온도 상승)\n", + " load = np.where(hi, rng.uniform(0.95, 1.1, len(d)), load)\n", + " rows.append(pd.DataFrame({\"unit\": u.unit, \"day\": d, \"cal\": u.start + d, \"rul\": u.life - d, \"deg\": deg, \"load\": load, \"hi_load\": hi, \"mode\": u.mode}))\n", + " snap = pd.concat(rows, ignore_index=True)\n", + " S = len(snap)\n", + " snap[\"ambient\"] = 15 + 12 * np.sin(2 * np.pi * snap[\"cal\"] / 365) + rng.normal(0, 2, S)\n", + " dg, ld, md = snap[\"deg\"].values, snap[\"load\"].values, snap[\"mode\"].values\n", + " isb, isg, iso, isc = (md == \"bearing\"), (md == \"gear\"), (md == \"oil_pump\"), (md == \"cooling\")\n", + " snap[\"temp\"] = 55 + 22 * ld + 0.5 * snap[\"ambient\"] + 9 * dg * iso + 26 * dg * isc + 3 * dg * (isb | isg) + rng.normal(0, 1.5, S)\n", + " snap[\"press\"] = 450 - 12 * ld - 85 * dg * iso - 15 * dg * isc + rng.normal(0, 6, S)\n", + " ph = rng.uniform(0, 2 * np.pi, (S, 3))\n", + " a1, a2 = 1.0 * (0.5 + ld), 0.4 * (0.5 + ld)\n", + " W = (a1[:, None] * np.sin(2 * np.pi * 50 * TT + ph[:, :1]) + a2[:, None] * np.sin(2 * np.pi * 100 * TT + ph[:, 1:2])\n", + " + rng.normal(0, 0.35, (S, NS)))\n", + " W += (0.9 * dg * isb)[:, None] * np.sin(2 * np.pi * 165 * TT + ph[:, 2:3])\n", + " W += (0.8 * dg * isg)[:, None] * (np.sin(2 * np.pi * 150 * TT + ph[:, 2:3]) + 0.8 * np.sin(2 * np.pi * 100 * TT))\n", + " W += (0.5 * dg * iso)[:, None] * np.sin(2 * np.pi * 200 * TT + ph[:, 2:3])\n", + " k = rng.poisson(10 * dg * isb) # 베어링 열화: 충격성 펄스(첨도 상승)\n", + " pos, amp = rng.integers(0, NS, (S, 30)), rng.normal(0, 3.0, (S, 30)) * dg[:, None]\n", + " amp = amp * (np.arange(30)[None, :] < k[:, None])\n", + " np.add.at(W, (np.repeat(np.arange(S), 30), pos.ravel()), amp.ravel())\n", + " return units, snap, W\n", + "\n", + "\n", + "def extract(W):\n", + " \"\"\"진동 파형 -> 특징량: RMS, 첨도, 파고율, 최대-최소, FFT 대역 에너지(50/100/150/165/200Hz 부근)\"\"\"\n", + " x = W - W.mean(axis=1, keepdims=True)\n", + " rms = np.sqrt((x ** 2).mean(axis=1))\n", + " out = {\"rms\": rms, \"kurt\": kurtosis(x, axis=1, fisher=False), \"crest\": np.abs(x).max(axis=1) / rms, \"p2p\": x.max(axis=1) - x.min(axis=1)}\n", + " A = np.abs(np.fft.rfft(x * np.hanning(NS), axis=1)) / NS\n", + " f = np.fft.rfftfreq(NS, 1 / FS)\n", + " for nm, (lo, hi) in BANDS.items():\n", + " out[nm] = np.sqrt((A[:, (f >= lo) & (f < hi)] ** 2).sum(axis=1))\n", + " return pd.DataFrame(out)\n", + "\n", + "# ---------------------------------------------------------------------------\n", + "# 공통 분석 조각: 일별 특징량 표(파형 특징 + 온도·압력 + 파생), 개체 단위 학습/검증 분할(앞선 24대 학습, 뒤 24대 검증)\n", + "# ---------------------------------------------------------------------------\n", + "BASE = [\"rms\", \"kurt\", \"crest\", \"p2p\", \"b50\", \"b100\", \"b150\", \"b165\", \"b200\", \"temp\", \"press\"]\n", + "ROLL = [\"rms\", \"kurt\", \"b150\", \"b165\", \"b200\", \"temp\", \"press\"]\n", + "\n", + "\n", + "def build_features(snap, W):\n", + " D = pd.concat([snap.reset_index(drop=True), extract(W)], axis=1)\n", + " D[\"temp_res\"] = D[\"temp\"] - (55 + 22 * D[\"load\"] + 0.5 * D[\"ambient\"]) # 부하·외기 대비 온도 잔차\n", + " D[\"press_res\"] = D[\"press\"] - (450 - 12 * D[\"load\"]) # 부하 대비 압력 잔차\n", + " D[\"rms_n\"] = D[\"rms\"] / (0.5 + D[\"load\"]) # 부하 정규화 RMS\n", + " D[\"hf_ratio\"] = (D[\"b150\"] + D[\"b165\"] + D[\"b200\"]) / (D[\"b50\"] + 1e-6) # 고주파/기본주파 비\n", + " g = D.groupby(\"unit\")\n", + " for c in ROLL:\n", + " D[c + \"_m3\"] = g[c].transform(lambda s: s.rolling(3, min_periods=1).mean())\n", + " for c in (\"rms_n\", \"temp_res\", \"press_res\"):\n", + " D[c + \"_d\"] = g[c].diff().fillna(0)\n", + " return D\n", + "\n", + "\n", + "DERIVED = [\"temp_res\", \"press_res\", \"rms_n\", \"hf_ratio\"] + [c + \"_m3\" for c in ROLL] + [\"rms_n_d\", \"temp_res_d\", \"press_res_d\"]\n", + "FEATS = BASE + DERIVED\n", + "AE_COLS = [\"rms_n\", \"kurt\", \"b150\", \"b165\", \"b200\", \"hf_ratio\", \"temp_res\", \"press_res\"]\n", + "\n", + "\n", + "def split_units(units, n_train=24):\n", + " return list(units[\"unit\"][:n_train]), list(units[\"unit\"][n_train:])\n", + "\n", + "\n", + "class AE:\n", + " \"\"\"오토인코더(입력 8 -> 10 -> 4 -> 10 -> 8): 정상 자료만 재구성하도록 학습하고, 재구성 오차를 이상 점수로 쓴다.\"\"\"\n", + "\n", + " def __init__(self, cols=AE_COLS, q=0.99, seed=0):\n", + " self.cols, self.q, self.seed = cols, q, seed\n", + "\n", + " def fit(self, healthy):\n", + " self.sc = StandardScaler().fit(healthy[self.cols])\n", + " Z = self.sc.transform(healthy[self.cols])\n", + " self.net = MLPRegressor(hidden_layer_sizes=(10, 4, 10), max_iter=800, random_state=self.seed).fit(Z, Z)\n", + " self.thr = float(np.quantile(self.err(healthy), self.q))\n", + " return self\n", + "\n", + " def contrib(self, d):\n", + " Z = self.sc.transform(d[self.cols])\n", + " return (Z - self.net.predict(Z)) ** 2 # 입력 항목별 재구성 오차(이상 기여도)\n", + "\n", + " def err(self, d):\n", + " return self.contrib(d).mean(axis=1)\n", + "\n", + "\n", + "def latch(d, e, thr, k=2):\n", + " \"\"\"같은 개체에서 이상 점수가 k일 연속 기준을 넘으면 경보를 켜고 유지(래치)한다.\"\"\"\n", + " out, e = np.zeros(len(d), bool), np.asarray(e)\n", + " for idx in d.reset_index(drop=True).groupby(\"unit\").indices.values():\n", + " run, on = 0, False\n", + " for i in idx:\n", + " run = run + 1 if e[i] > thr else 0\n", + " on = on or run >= k\n", + " out[i] = on\n", + " return out\n", + "\n", + "units, snap, W = make_runs()\n", + "D = build_features(snap, W)\n", + "tr_u, va_u = split_units(units)\n", + "tr, va = D[D[\"unit\"].isin(tr_u)].reset_index(drop=True), D[D[\"unit\"].isin(va_u)].reset_index(drop=True)\n", + "ae = AE().fit(tr[tr[\"deg\"] == 0])\n", + "C = ae.contrib(va) # 건 x 입력항목 기여도\n", + "e = C.mean(axis=1)\n", + "va[\"alarm\"] = latch(va, e, ae.thr, k=2)\n", + "va[\"score\"] = e / ae.thr\n", + "\n", + "LABEL = {\"rms_n\": \"부하 보정 진동 크기(RMS)\", \"kurt\": \"진동 충격성(첨도)\", \"b150\": \"150Hz 부근 진동(기어 맞물림 대역)\", \"b165\": \"165Hz 부근 진동(베어링 결함 대역)\",\n", + " \"b200\": \"200Hz 부근 진동(펌프 대역)\", \"hf_ratio\": \"고주파/기본주파 에너지 비\", \"temp_res\": \"부하 대비 온도 상승\", \"press_res\": \"부하 대비 압력 저하\"}\n", + "PART = {\"b165\": \"주 베어링 세트\", \"kurt\": \"주 베어링 세트\", \"b150\": \"구동 기어 세트\", \"b200\": \"오일 펌프\", \"press_res\": \"오일 펌프\", \"temp_res\": \"냉각 팬 모터\"}\n", + "TRUE_PART = {m: MODES[m][2] for m in MODES}\n", + "cols = ae.cols\n", + "\n", + "# 1) 경보 건마다 기여도 상위 3개 인자 자동 산출\n", + "idx = np.where(va[\"alarm\"].values)[0]\n", + "top3 = np.argsort(-C[idx], axis=1)[:, :3]\n", + "ok = sum(len(set(t)) == 3 for t in top3)\n", + "print(f\"[교육용가상자료] 검증 {len(va_u)}대 {len(va)}건 중 경보 {len(idx)}건: 기여도 상위 3개 인자 산출 {ok}건 (산출률 {ok / len(idx):.0%}, 목표 100%: {'달성' if ok == len(idx) else '미달'})\")\n", + "\n", + "# 2) 근거 유효성: 1순위 인자를 정상 평균(표준화 0)으로 되돌리면 이상 점수가 낮아지는가\n", + "Z = ae.sc.transform(va[cols].iloc[idx])\n", + "Z2 = Z.copy()\n", + "Z2[np.arange(len(idx)), top3[:, 0]] = 0.0\n", + "e1, e2 = ((Z - ae.net.predict(Z)) ** 2).mean(axis=1), ((Z2 - ae.net.predict(Z2)) ** 2).mean(axis=1)\n", + "print(f\"근거 유효성: 1순위 인자를 정상값으로 되돌리면 이상 점수가 낮아지는 비율 {(e2 < e1).mean():.1%} (평균 {e1.mean():.2f} -> {e2.mean():.2f})\")\n", + "\n", + "# 3) 의심 부품: 1순위 인자 -> 부품 매핑 (열화가 뚜렷한 경보에서 정답 비교)\n", + "sub = va.iloc[idx].copy()\n", + "sub[\"top1\"] = [cols[t[0]] for t in top3]\n", + "sub[\"hint\"] = sub[\"top1\"].map(PART).fillna(\"미분류(점검 필요)\")\n", + "sub[\"true_part\"] = sub[\"mode\"].map(TRUE_PART)\n", + "strong = sub[sub[\"deg\"] >= 0.3]\n", + "print(f\"의심 부품 추정 정확도(열화 0.3 이상 경보 {len(strong)}건): {(strong['hint'] == strong['true_part']).mean():.1%}\")\n", + "print(\" 1순위 인자 분포: \" + \", \".join(f\"{LABEL[k].split('(')[0]} {v}\" for k, v in sub[\"top1\"].value_counts().head(4).items()))\n", + "\n", + "\n", + "def summarize(r, t):\n", + " f = [LABEL[cols[i]] for i in t]\n", + " part = PART.get(cols[t[0]], \"원인 불명\")\n", + " return f\"{part} 이상 가능성(이상 점수 {r['score']:.1f}배). 주요 근거: {f[0]}, {f[1]}, {f[2]}.\"\n", + "\n", + "\n", + "# 4) 정비병용 화면: 점검 카드와 상태표\n", + "print(\"\\n[점검 카드] 개체 | 측정일 | 실제 열화 유형\")\n", + "for m in MODES:\n", + " k = sub[(sub[\"mode\"] == m) & (sub[\"deg\"] > 0.5)].index[0]\n", + " j = list(sub.index).index(k)\n", + " r = sub.loc[k]\n", + " print(f\" {r['unit']} {int(r['day'])}일째 ({m})\")\n", + " print(\" \" + summarize(r, top3[j]))\n", + " print(\" 근거 기여도: \" + \" / \".join(f\"{LABEL[cols[i]].split('(')[0]}({C[idx[j], i]:.1f})\" for i in top3[j]))\n", + "\n", + "CAL = int(va.groupby('cal')['alarm'].sum().idxmax()) # 경보가 가장 많은 날을 가상 기준일로 쓴다\n", + "act = va[(va[\"cal\"] == CAL)]\n", + "reg = {\"정상\": 0, \"주의\": 0, \"경보\": 0}\n", + "rows = []\n", + "for r in act.itertuples():\n", + " st = \"경보\" if r.alarm else (\"주의\" if r.score > 0.7 else \"정상\")\n", + " reg[st] += 1\n", + "for st in (\"경보\", \"주의\"):\n", + " for r in act[(act[\"alarm\"] if st == \"경보\" else (~act[\"alarm\"] & (act[\"score\"] > 0.7)))].itertuples():\n", + " rows.append((r.unit, st, f\"{r.score:.1f}\", int(r.rul)))\n", + "print(f\"\\n[운용 상태표] 가상 기준일 {CAL}: 가동 중 {len(act)}대 -> \" + \", \".join(f\"{k} {v}대\" for k, v in reg.items()))\n", + "for r in rows[:5]:\n", + " print(f\" {r[0]} {r[1]} 이상 점수 {r[2]}배 (실제 잔존 {r[3]}일)\")\n", + "\n", + "plt.figure(figsize=(8, 4))\n", + "for u in sub[\"unit\"].unique()[:3]:\n", + " g = va[va[\"unit\"] == u]\n", + " plt.plot(g[\"day\"], g[\"score\"], label=u)\n", + "plt.axhline(1.0, ls=\"--\", color=\"k\"); plt.yscale(\"log\"); plt.xlabel(\"day\"); plt.ylabel(\"anomaly score (x threshold)\"); plt.legend(); plt.title(\"Monitoring view: anomaly score\")\n", + "plt.tight_layout(); plt.show()\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 06 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + "[교육용가상자료] 검증 24대 3886건 중 경보 1327건: 기여도 상위 3개 인자 산출 1327건 (산출률 100%, 목표 100%: 달성)\n", + "근거 유효성: 1순위 인자를 정상값으로 되돌리면 이상 점수가 낮아지는 비율 98.9% (평균 21.19 -> 6.57)\n", + "의심 부품 추정 정확도(열화 0.3 이상 경보 1167건): 99.3%\n", + " 1순위 인자 분포: 150Hz 부근 진동 603, 165Hz 부근 진동 338, 부하 대비 온도 상승 146, 부하 대비 압력 저하 129\n", + "\n", + "[점검 카드] 개체 | 측정일 | 실제 열화 유형\n", + " U27 131일째 (bearing)\n", + " 주 베어링 세트 이상 가능성(이상 점수 7.1배). 주요 근거: 165Hz 부근 진동(베어링 결함 대역), 150Hz 부근 진동(기어 맞물림 대역), 부하 대비 압력 저하.\n", + " 근거 기여도: 165Hz 부근 진동(36.6) / 150Hz 부근 진동(22.9) / 부하 대비 압력 저하(5.4)\n", + " U26 150일째 (gear)\n", + " 구동 기어 세트 이상 가능성(이상 점수 10.4배). 주요 근거: 150Hz 부근 진동(기어 맞물림 대역), 165Hz 부근 진동(베어링 결함 대역), 진동 충격성(첨도).\n", + " 근거 기여도: 150Hz 부근 진동(60.3) / 165Hz 부근 진동(19.9) / 진동 충격성(10.9)\n", + " U25 108일째 (oil_pump)\n", + " 오일 펌프 이상 가능성(이상 점수 6.0배). 주요 근거: 부하 대비 압력 저하, 200Hz 부근 진동(펌프 대역), 165Hz 부근 진동(베어링 결함 대역).\n", + " 근거 기여도: 부하 대비 압력 저하(29.3) / 200Hz 부근 진동(22.0) / 165Hz 부근 진동(4.7)\n", + " U24 107일째 (cooling)\n", + " 냉각 팬 모터 이상 가능성(이상 점수 4.4배). 주요 근거: 부하 대비 온도 상승, 200Hz 부근 진동(펌프 대역), 진동 충격성(첨도).\n", + " 근거 기여도: 부하 대비 온도 상승(34.5) / 200Hz 부근 진동(3.9) / 진동 충격성(2.4)\n", + "\n", + "[운용 상태표] 가상 기준일 308: 가동 중 18대 -> 정상 6대, 주의 1대, 경보 11대\n", + " U26 경보 이상 점수 30.5배 (실제 잔존 1일)\n", + " U28 경보 이상 점수 55.0배 (실제 잔존 5일)\n", + " U31 경보 이상 점수 85.1배 (실제 잔존 1일)\n", + " U32 경보 이상 점수 12.9배 (실제 잔존 14일)\n", + " U33 경보 이상 점수 27.4배 (실제 잔존 14일)\n", + "```\n", + "
\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 07. 예방 정비 조기 입고 결정을 위한 항공/기갑 검사관 HITL 승인 모듈 설계\n", + "**소요시간**: 약 3.5시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "예방 정비 조기 입고 결정을 위한 검사관 HITL 승인 모듈을 설계한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **센서 경보 시 예방 정비 조기 입고를 제안하되 항공/기갑 검사관이 승인해야 확정되는 HITL 승인 모듈을 설계하고 효과를 시뮬레이션으로 검증한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **경보와 RUL 예측으로 입고일을 제안하고 승인 대기열에 올려 검사관의 승인·반려·무응답(기본 계획인 정기 교체 유지)을 사유와 함께 해시 체인 감사 로그로 남긴다 과정을 진행하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `src/optimization/mnt04_simulator.py` 형태의 결과물을 완성한다.\n", + "\n", + "### 🔍 평가 및 검증 기준 (PASS 조건)\n", + "#### 미션 판정\n", + "\n", + "**평가 구분:** 필수\n", + "\n", + "**핵심 평가영역:** 현실적 군수 제약조건 반영도 및 시나리오별 대안 제시 적절성\n", + "\n", + "#### 미션 PASS 조건\n", + "\n", + "- 정량 기준: 최적화 적용 시 비용 15% 절감 또는 재고 회전율 20% 향상\n", + "\n", + "\n", + "- 진행 기준: 선형/정수 계획법(LP/MIP) 최적화 해 도출 및 시뮬레이션 완료\n", + "\n", + "\n", + "- 검증 방법: 과거 실적 데이터 대비 최적화 결과 비교 백테스팅\n", + "\n", + "\n", + "#### 채점자 판정 메모\n", + "\n", + "> 단순 코드 실행 여부뿐만 아니라 출력된 수치와 근거 자료가 군 실무 요구사항에 부합하는지 면밀히 확인한다.\n", + "\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "\"\"\"센서 이상 경보가 나면 예방 정비 조기 입고일을 제안하되 자동 확정하지 않고 항공/기갑 검사관 승인 대기열에 올려 승인·반려·무응답(기본 계획 유지)을 사유와 함께 해시 체인 감사 로그에 남기는 HITL 승인 모듈을 시뮬레이션하고, 정기 교체 방식 대비 정비·고장 비용 절감률과 승인 지연·전시 전환(가동 강도 급증) 민감도를 분석한다.\"\"\"\n", + "import hashlib\n", + "import json\n", + "import numpy as np\n", + "import pandas as pd\n", + "import matplotlib.pyplot as plt\n", + "from scipy.stats import kurtosis\n", + "from sklearn.ensemble import GradientBoostingRegressor\n", + "from sklearn.neural_network import MLPRegressor\n", + "from sklearn.preprocessing import StandardScaler\n", + "\n", + "# ---------------------------------------------------------------------------\n", + "# [교육용가상자료] 가상 기동체 48대의 엔진·구동계 진동·온도·압력 센서 자료 생성기 (실제 장비·부대·정비 실적 없음)\n", + "# 개체마다 정상 -> 열화 -> 고장(수명종료)까지 하루 1회 측정(진동 파형 256표본 + 온도 + 압력 + 부하)을 만든다.\n", + "# 열화 유형 4가지: bearing(베어링), gear(기어), oil_pump(오일 펌프), cooling(냉각 팬 모터)\n", + "# ---------------------------------------------------------------------------\n", + "FS, NS = 1024, 256 # 가상 표본화 주파수(Hz), 파형 1개당 표본 수\n", + "TT = np.arange(NS) / FS\n", + "MODES = { # 열화 유형: (발생 확률, 평균 수명(일), 교체 수리부속, 가상 단가지수)\n", + " \"bearing\": (0.40, 160, \"주 베어링 세트\", 6.0), \"gear\": (0.25, 175, \"구동 기어 세트\", 9.0),\n", + " \"oil_pump\": (0.20, 150, \"오일 펌프\", 4.0), \"cooling\": (0.15, 140, \"냉각 팬 모터\", 3.0)}\n", + "KIT_COST = 1.0 # 모든 정비에 함께 소모되는 씰·필터 키트 단가지수\n", + "BANDS = {\"b50\": (40, 60), \"b100\": (90, 110), \"b150\": (140, 156), \"b165\": (156, 176), \"b200\": (190, 215)}\n", + "\n", + "\n", + "def make_runs(n=48, seed=1):\n", + " \"\"\"반환: units(개체 정보), snap(일별 측정 요약), W(진동 파형 [측정 수, 256])\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " names = list(MODES)\n", + " mode = rng.choice(names, n, p=[MODES[m][0] for m in names])\n", + " life = np.clip(np.array([rng.normal(MODES[m][1], 18) for m in mode]), 90, 230).astype(int)\n", + " start = np.sort(rng.integers(0, 240, n))\n", + " units = pd.DataFrame({\"unit\": [f\"U{i:02d}\" for i in range(n)], \"mode\": mode, \"life\": life, \"start\": start, \"base_load\": rng.uniform(0.4, 0.75, n)})\n", + " rows = []\n", + " for u in units.itertuples():\n", + " d = np.arange(u.life + 1)\n", + " onset = 0.45 * u.life\n", + " deg = np.clip((d - onset) / (u.life - onset), 0, 1) ** 1.5\n", + " load = np.clip(u.base_load + rng.normal(0, 0.06, len(d)), 0.2, 1.0)\n", + " hi = rng.random(len(d)) < 0.07 # 고부하 운용일(정상이지만 진동·온도 상승)\n", + " load = np.where(hi, rng.uniform(0.95, 1.1, len(d)), load)\n", + " rows.append(pd.DataFrame({\"unit\": u.unit, \"day\": d, \"cal\": u.start + d, \"rul\": u.life - d, \"deg\": deg, \"load\": load, \"hi_load\": hi, \"mode\": u.mode}))\n", + " snap = pd.concat(rows, ignore_index=True)\n", + " S = len(snap)\n", + " snap[\"ambient\"] = 15 + 12 * np.sin(2 * np.pi * snap[\"cal\"] / 365) + rng.normal(0, 2, S)\n", + " dg, ld, md = snap[\"deg\"].values, snap[\"load\"].values, snap[\"mode\"].values\n", + " isb, isg, iso, isc = (md == \"bearing\"), (md == \"gear\"), (md == \"oil_pump\"), (md == \"cooling\")\n", + " snap[\"temp\"] = 55 + 22 * ld + 0.5 * snap[\"ambient\"] + 9 * dg * iso + 26 * dg * isc + 3 * dg * (isb | isg) + rng.normal(0, 1.5, S)\n", + " snap[\"press\"] = 450 - 12 * ld - 85 * dg * iso - 15 * dg * isc + rng.normal(0, 6, S)\n", + " ph = rng.uniform(0, 2 * np.pi, (S, 3))\n", + " a1, a2 = 1.0 * (0.5 + ld), 0.4 * (0.5 + ld)\n", + " W = (a1[:, None] * np.sin(2 * np.pi * 50 * TT + ph[:, :1]) + a2[:, None] * np.sin(2 * np.pi * 100 * TT + ph[:, 1:2])\n", + " + rng.normal(0, 0.35, (S, NS)))\n", + " W += (0.9 * dg * isb)[:, None] * np.sin(2 * np.pi * 165 * TT + ph[:, 2:3])\n", + " W += (0.8 * dg * isg)[:, None] * (np.sin(2 * np.pi * 150 * TT + ph[:, 2:3]) + 0.8 * np.sin(2 * np.pi * 100 * TT))\n", + " W += (0.5 * dg * iso)[:, None] * np.sin(2 * np.pi * 200 * TT + ph[:, 2:3])\n", + " k = rng.poisson(10 * dg * isb) # 베어링 열화: 충격성 펄스(첨도 상승)\n", + " pos, amp = rng.integers(0, NS, (S, 30)), rng.normal(0, 3.0, (S, 30)) * dg[:, None]\n", + " amp = amp * (np.arange(30)[None, :] < k[:, None])\n", + " np.add.at(W, (np.repeat(np.arange(S), 30), pos.ravel()), amp.ravel())\n", + " return units, snap, W\n", + "\n", + "\n", + "def extract(W):\n", + " \"\"\"진동 파형 -> 특징량: RMS, 첨도, 파고율, 최대-최소, FFT 대역 에너지(50/100/150/165/200Hz 부근)\"\"\"\n", + " x = W - W.mean(axis=1, keepdims=True)\n", + " rms = np.sqrt((x ** 2).mean(axis=1))\n", + " out = {\"rms\": rms, \"kurt\": kurtosis(x, axis=1, fisher=False), \"crest\": np.abs(x).max(axis=1) / rms, \"p2p\": x.max(axis=1) - x.min(axis=1)}\n", + " A = np.abs(np.fft.rfft(x * np.hanning(NS), axis=1)) / NS\n", + " f = np.fft.rfftfreq(NS, 1 / FS)\n", + " for nm, (lo, hi) in BANDS.items():\n", + " out[nm] = np.sqrt((A[:, (f >= lo) & (f < hi)] ** 2).sum(axis=1))\n", + " return pd.DataFrame(out)\n", + "\n", + "# ---------------------------------------------------------------------------\n", + "# 공통 분석 조각: 일별 특징량 표(파형 특징 + 온도·압력 + 파생), 개체 단위 학습/검증 분할(앞선 24대 학습, 뒤 24대 검증)\n", + "# ---------------------------------------------------------------------------\n", + "BASE = [\"rms\", \"kurt\", \"crest\", \"p2p\", \"b50\", \"b100\", \"b150\", \"b165\", \"b200\", \"temp\", \"press\"]\n", + "ROLL = [\"rms\", \"kurt\", \"b150\", \"b165\", \"b200\", \"temp\", \"press\"]\n", + "\n", + "\n", + "def build_features(snap, W):\n", + " D = pd.concat([snap.reset_index(drop=True), extract(W)], axis=1)\n", + " D[\"temp_res\"] = D[\"temp\"] - (55 + 22 * D[\"load\"] + 0.5 * D[\"ambient\"]) # 부하·외기 대비 온도 잔차\n", + " D[\"press_res\"] = D[\"press\"] - (450 - 12 * D[\"load\"]) # 부하 대비 압력 잔차\n", + " D[\"rms_n\"] = D[\"rms\"] / (0.5 + D[\"load\"]) # 부하 정규화 RMS\n", + " D[\"hf_ratio\"] = (D[\"b150\"] + D[\"b165\"] + D[\"b200\"]) / (D[\"b50\"] + 1e-6) # 고주파/기본주파 비\n", + " g = D.groupby(\"unit\")\n", + " for c in ROLL:\n", + " D[c + \"_m3\"] = g[c].transform(lambda s: s.rolling(3, min_periods=1).mean())\n", + " for c in (\"rms_n\", \"temp_res\", \"press_res\"):\n", + " D[c + \"_d\"] = g[c].diff().fillna(0)\n", + " return D\n", + "\n", + "\n", + "DERIVED = [\"temp_res\", \"press_res\", \"rms_n\", \"hf_ratio\"] + [c + \"_m3\" for c in ROLL] + [\"rms_n_d\", \"temp_res_d\", \"press_res_d\"]\n", + "FEATS = BASE + DERIVED\n", + "AE_COLS = [\"rms_n\", \"kurt\", \"b150\", \"b165\", \"b200\", \"hf_ratio\", \"temp_res\", \"press_res\"]\n", + "\n", + "\n", + "def split_units(units, n_train=24):\n", + " return list(units[\"unit\"][:n_train]), list(units[\"unit\"][n_train:])\n", + "\n", + "\n", + "class AE:\n", + " \"\"\"오토인코더(입력 8 -> 10 -> 4 -> 10 -> 8): 정상 자료만 재구성하도록 학습하고, 재구성 오차를 이상 점수로 쓴다.\"\"\"\n", + "\n", + " def __init__(self, cols=AE_COLS, q=0.99, seed=0):\n", + " self.cols, self.q, self.seed = cols, q, seed\n", + "\n", + " def fit(self, healthy):\n", + " self.sc = StandardScaler().fit(healthy[self.cols])\n", + " Z = self.sc.transform(healthy[self.cols])\n", + " self.net = MLPRegressor(hidden_layer_sizes=(10, 4, 10), max_iter=800, random_state=self.seed).fit(Z, Z)\n", + " self.thr = float(np.quantile(self.err(healthy), self.q))\n", + " return self\n", + "\n", + " def contrib(self, d):\n", + " Z = self.sc.transform(d[self.cols])\n", + " return (Z - self.net.predict(Z)) ** 2 # 입력 항목별 재구성 오차(이상 기여도)\n", + "\n", + " def err(self, d):\n", + " return self.contrib(d).mean(axis=1)\n", + "\n", + "\n", + "def latch(d, e, thr, k=2):\n", + " \"\"\"같은 개체에서 이상 점수가 k일 연속 기준을 넘으면 경보를 켜고 유지(래치)한다.\"\"\"\n", + " out, e = np.zeros(len(d), bool), np.asarray(e)\n", + " for idx in d.reset_index(drop=True).groupby(\"unit\").indices.values():\n", + " run, on = 0, False\n", + " for i in idx:\n", + " run = run + 1 if e[i] > thr else 0\n", + " on = on or run >= k\n", + " out[i] = on\n", + " return out\n", + "\n", + "PERIOD = 90 # 기본 계획: 정기 교체 주기(운용일). 가장 빠른 고장 사례보다 짧게 정해 사후 고장을 막는 보수적 주기\n", + "UNPLANNED = (2.0, 12.0) # [교육용가상자료] 고장 후 정비 비용 = 부품 단가 x 2.0 + 연쇄 손상·가동중단 12.0\n", + "PLANNED_FIXED = 1.0 # 입고 정비 작업 고정 비용(지수)\n", + "INSPECT = {\"base\": 0.3, \"pdm\": 0.05} # 30일당 점검 비용: 정기 방식은 분해 점검, 센서 감시 방식은 경보 시 확인 점검 위주(가정)\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 add_feat(D):\n", + " D[\"hf_max\"] = D.groupby(\"unit\")[\"hf_ratio\"].cummax(); D[\"temp_res_max\"] = D.groupby(\"unit\")[\"temp_res\"].cummax()\n", + " return D\n", + "\n", + "\n", + "F7 = FEATS + [\"day\", \"hf_max\", \"temp_res_max\"]\n", + "u1, s1, w1 = make_runs(seed=1)\n", + "D1 = add_feat(build_features(s1, w1))\n", + "tr_u, _ = split_units(u1)\n", + "tr = D1[D1[\"unit\"].isin(tr_u)]\n", + "ae = AE().fit(tr[tr[\"deg\"] == 0])\n", + "gbr = GradientBoostingRegressor(n_estimators=100, max_depth=3, learning_rate=0.1, subsample=0.8, random_state=0).fit(tr[F7], np.minimum(tr[\"rul\"], 120))\n", + "\n", + "# 시험 개체: 다른 시드로 만든 가상 개체 3묶음(144대)에서 개체별 첫 경보일과 그때의 RUL 예측을 모은다\n", + "cases = []\n", + "for sd in (2, 3, 4):\n", + " un, sn, ww = make_runs(seed=sd)\n", + " D = add_feat(build_features(sn, ww))\n", + " D[\"alarm\"] = latch(D, ae.err(D), ae.thr, k=2)\n", + " D[\"rul_hat\"] = gbr.predict(D[F7])\n", + " for u in un.itertuples():\n", + " g = D[D[\"unit\"] == u.unit]\n", + " a = g[g[\"alarm\"]]\n", + " cases.append({\"id\": f\"S{sd}-{u.unit}\", \"mode\": u.mode, \"life\": int(u.life), \"a\": int(a[\"day\"].iloc[0]) if len(a) else None,\n", + " \"rul_hat\": float(a[\"rul_hat\"].iloc[0]) if len(a) else None, \"load\": float(a[\"load\"].iloc[0]) if len(a) else None})\n", + "C = pd.DataFrame(cases)\n", + "print(f\"[교육용가상자료] 시험 개체 {len(C)}대(수명 {C['life'].min()}~{C['life'].max()}일), 경보 발생 {int(C['a'].notna().sum())}대\")\n", + "\n", + "\n", + "def cost(mode, life, maint, kind=\"pdm\"):\n", + " p = MODES[mode][3] + KIT_COST + INSPECT[kind] * min(maint if maint is not None else life, life) / 30\n", + " if maint is None or maint >= life: # 정비 전에 고장\n", + " return MODES[mode][3] * UNPLANNED[0] + KIT_COST + UNPLANNED[1] + INSPECT[kind] * life / 30, True\n", + " return p + PLANNED_FIXED + MODES[mode][3] * (life - maint) / life, False # 남은 수명 낭비분 포함\n", + "\n", + "\n", + "def commander(row, rng):\n", + " \"\"\"[모의 검사관] 잔존 예측이 길면 반려, 10%는 무응답.\"\"\"\n", + " if rng.random() < 0.10:\n", + " return \"무응답\", \"기한 내 응답 없음 -> 기본 계획(정기 교체) 유지\"\n", + " if row[\"rul_hat\"] > 90:\n", + " return \"반려\", f\"예측 잔존 {row['rul_hat']:.0f}일로 충분 -> 현 계획 유지\"\n", + " if row[\"load\"] > 1.0:\n", + " return \"반려\", \"고부하 운용일 신호로 판단 -> 추가 관찰\"\n", + " return \"승인\", f\"이상 지속, 예측 잔존 {row['rul_hat']:.0f}일 -> 조기 입고\"\n", + "\n", + "\n", + "def run(mode, delay=2, scale=1.0, seed=0, audit=None):\n", + " \"\"\"mode: base(정기 교체) / hitl(제안 -> 검사관 결정) / auto(모든 제안 적용: 비교용 상한, 실제 운용 금지)\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " tot, fails, dec = 0.0, 0, {\"승인\": 0, \"반려\": 0, \"무응답\": 0}\n", + " for _, r in C.iterrows():\n", + " life = r[\"life\"] / scale\n", + " maint, kind = PERIOD, \"base\"\n", + " if mode != \"base\" and r[\"a\"] is not None:\n", + " a, rh = r[\"a\"] / scale, r[\"rul_hat\"] / scale\n", + " inbound = a + max(int(0.4 * rh), 3)\n", + " row = {\"rul_hat\": rh, \"load\": r[\"load\"]}\n", + " if mode == \"auto\":\n", + " d, why = \"승인\", \"비교용 전부 적용\"\n", + " else:\n", + " d, why = commander(row, rng)\n", + " audit.add(event=\"PROPOSE\", id=r[\"id\"], day=round(a), inbound=int(inbound), status=\"PENDING\")\n", + " audit.add(event=\"DECIDE\", id=r[\"id\"], decision=d, reason=why, by=\"검사관(모의)\")\n", + " dec[d] += 1\n", + " if d == \"승인\":\n", + " maint, kind = inbound + (delay if mode == \"hitl\" else 0), \"pdm\"\n", + " c, f = cost(r[\"mode\"], life, maint, kind)\n", + " tot += c; fails += f\n", + " return tot, fails, dec\n", + "\n", + "\n", + "b_cost, b_fail, _ = run(\"base\")\n", + "audit = Audit()\n", + "h_cost, h_fail, dec = run(\"hitl\", seed=3, audit=audit)\n", + "a_cost, a_fail, _ = run(\"auto\")\n", + "print(\"\\n[비용 비교] 방식 | 비용지수 | 사후 고장 건수\")\n", + "for nm, c, f in [(\"정기 교체(기본 계획)\", b_cost, b_fail), (\"경보 제안 + 검사관 승인\", h_cost, h_fail), (\"제안 전부 적용(비교용)\", a_cost, a_fail)]:\n", + " print(f\" {nm:22s} {c:8.0f} {f:3d}\")\n", + "cut = 1 - h_cost / b_cost\n", + "print(f\"HITL 적용 비용 절감률 {cut:.1%} (목표 15% 이상: {'달성' if cut >= 0.15 else '미달'}), 고장 {b_fail} -> {h_fail}건\")\n", + "print(f\"결정 {sum(dec.values())}건: \" + \", \".join(f\"{k} {v}건\" for k, v in dec.items()))\n", + "n_prop = sum(e[\"event\"] == \"PROPOSE\" for e in audit.log); n_dec = sum(e[\"event\"] == \"DECIDE\" for e in audit.log)\n", + "print(f\"제안 {n_prop}건 중 결정 기록 {n_dec}건(로깅률 {n_dec / n_prop:.0%}), 자동 승인 0건, 무응답 시 기본 계획 유지\")\n", + "rej = [e[\"reason\"] for e in audit.log if e.get(\"decision\") == \"반려\"]\n", + "print(\"반려 사유 예:\", rej[0] if rej else \"-\")\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[민감도 1] 승인 후 입고까지 지연(일) -> 비용 절감률\")\n", + "for d in (0, 2, 7, 14, 21):\n", + " c, f, _ = run(\"hitl\", delay=d, seed=3, audit=Audit())\n", + " print(f\" {d:2d}일: {1 - c / b_cost:.1%} (고장 {f}건)\")\n", + "print(\"[민감도 2] 가동 강도 급증(전시 전환 가정, 열화가 빨라져 수명이 1/배수로 단축) -> 절감률\")\n", + "for s in (1.0, 1.3, 1.6, 2.0):\n", + " bc, bf, _ = run(\"base\", scale=s); hc, hf, _ = run(\"hitl\", scale=s, seed=3, audit=Audit())\n", + " print(f\" x{s}: 정기 교체 비용 {bc:.0f}(고장 {bf}) -> HITL {hc:.0f}(고장 {hf}), 절감 {1 - hc / bc:.1%}\")\n", + "\n", + "plt.figure(figsize=(6, 4))\n", + "plt.bar([\"periodic\", \"HITL\", \"auto(ref)\"], [b_cost, h_cost, a_cost]); plt.ylabel(\"cost index\"); plt.title(\"HITL early-inbound simulator\")\n", + "plt.tight_layout(); plt.show()\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 07 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + "[교육용가상자료] 시험 개체 144대(수명 101~212일), 경보 발생 144대\n", + "\n", + "[비용 비교] 방식 | 비용지수 | 사후 고장 건수\n", + " 정기 교체(기본 계획) 1640 0\n", + " 경보 제안 + 검사관 승인 1389 1\n", + " 제안 전부 적용(비교용) 1364 1\n", + "HITL 적용 비용 절감률 15.3% (목표 15% 이상: 달성), 고장 0 -> 1건\n", + "결정 144건: 승인 126건, 반려 7건, 무응답 11건\n", + "제안 144건 중 결정 기록 144건(로깅률 100%), 자동 승인 0건, 무응답 시 기본 계획 유지\n", + "반려 사유 예: 고부하 운용일 신호로 판단 -> 추가 관찰\n", + "감사 로그 무결성: 정상, 사후 변조 시 seq 5에서 탐지\n", + "\n", + "[민감도 1] 승인 후 입고까지 지연(일) -> 비용 절감률\n", + " 0일: 14.8% (고장 1건)\n", + " 2일: 15.3% (고장 1건)\n", + " 7일: 14.1% (고장 4건)\n", + " 14일: 10.5% (고장 10건)\n", + " 21일: 1.8% (고장 21건)\n", + "[민감도 2] 가동 강도 급증(전시 전환 가정, 열화가 빨라져 수명이 1/배수로 단축) -> 절감률\n", + " x1.0: 정기 교체 비용 1640(고장 0) -> HITL 1389(고장 1), 절감 15.3%\n", + " x1.3: 정기 교체 비용 1571(고장 5) -> HITL 1363(고장 1), 절감 13.2%\n", + " x1.6: 정기 교체 비용 2005(고장 41) -> HITL 1451(고장 8), 절감 27.7%\n", + " x2.0: 정기 교체 비용 3394(고장 128) -> HITL 1572(고장 16), 절감 53.7%\n", + "```\n", + "
\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 08. 장시간 비행/기동 센서 데이터 대용량 스트림 주입 스트레스 테스트\n", + "**소요시간**: 약 3.0시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "장시간 기동 센서 데이터 대용량 스트림 주입 스트레스 테스트를 수행한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **장시간 기동 센서 데이터를 대용량 스트림으로 주입해 처리 시간, 경보 정확도, 불량 패킷 격리를 확인하는 스트레스 시험을 수행한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **가상 센서 측정 1,000건에 결측 패킷, 센서 고정, 포화, 범위 이탈을 섞어 한꺼번에 주입하고 정상 처리·격리·유실 건수와 처리 시간을 센다 과정을 진행하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `src/dashboard/mnt04_app.py (Streamlit/Dash 등)` 형태의 결과물을 완성한다.\n", + "\n", + "### 🔍 평가 및 검증 기준 (PASS 조건)\n", + "#### 미션 판정\n", + "\n", + "**평가 구분:** 필수\n", + "\n", + "**핵심 평가영역:** 의사결정 지원에 최적화된 시각적 위계 및 사용자 경험(UX)\n", + "\n", + "#### 미션 PASS 조건\n", + "\n", + "- 정량 기준: 대시보드 로딩 시간 2초 이내, 경보 발령 정확도 95% 이상\n", + "\n", + "\n", + "- 진행 기준: 웹 기반 분석 대시보드 구동 및 실시간 필터링 기능 검증\n", + "\n", + "\n", + "- 검증 방법: 실제 군수 데이터 연동 화면 테스트 및 UI 동작 검증\n", + "\n", + "\n", + "#### 채점자 판정 메모\n", + "\n", + "> 단순 코드 실행 여부뿐만 아니라 출력된 수치와 근거 자료가 군 실무 요구사항에 부합하는지 면밀히 확인한다.\n", + "\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "\"\"\"장시간 기동 센서 데이터를 1,000~100,000건 규모로 한꺼번에 주입해 검증·특징 추출·오토인코더 점수·경보 판정을 배치로 처리하는 스트림 시험을 수행하고, 처리 시간과 상태 화면 구성 시간, 경보 정확도, 불량 패킷(결측·고정값·포화·중복) 격리와 유실 0건 여부를 확인한다.\"\"\"\n", + "import time\n", + "import numpy as np\n", + "import pandas as pd\n", + "import matplotlib.pyplot as plt\n", + "from scipy.stats import kurtosis\n", + "from sklearn.neural_network import MLPRegressor\n", + "from sklearn.preprocessing import StandardScaler\n", + "\n", + "# ---------------------------------------------------------------------------\n", + "# [교육용가상자료] 가상 기동체 48대의 엔진·구동계 진동·온도·압력 센서 자료 생성기 (실제 장비·부대·정비 실적 없음)\n", + "# 개체마다 정상 -> 열화 -> 고장(수명종료)까지 하루 1회 측정(진동 파형 256표본 + 온도 + 압력 + 부하)을 만든다.\n", + "# 열화 유형 4가지: bearing(베어링), gear(기어), oil_pump(오일 펌프), cooling(냉각 팬 모터)\n", + "# ---------------------------------------------------------------------------\n", + "FS, NS = 1024, 256 # 가상 표본화 주파수(Hz), 파형 1개당 표본 수\n", + "TT = np.arange(NS) / FS\n", + "MODES = { # 열화 유형: (발생 확률, 평균 수명(일), 교체 수리부속, 가상 단가지수)\n", + " \"bearing\": (0.40, 160, \"주 베어링 세트\", 6.0), \"gear\": (0.25, 175, \"구동 기어 세트\", 9.0),\n", + " \"oil_pump\": (0.20, 150, \"오일 펌프\", 4.0), \"cooling\": (0.15, 140, \"냉각 팬 모터\", 3.0)}\n", + "KIT_COST = 1.0 # 모든 정비에 함께 소모되는 씰·필터 키트 단가지수\n", + "BANDS = {\"b50\": (40, 60), \"b100\": (90, 110), \"b150\": (140, 156), \"b165\": (156, 176), \"b200\": (190, 215)}\n", + "\n", + "\n", + "def make_runs(n=48, seed=1):\n", + " \"\"\"반환: units(개체 정보), snap(일별 측정 요약), W(진동 파형 [측정 수, 256])\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " names = list(MODES)\n", + " mode = rng.choice(names, n, p=[MODES[m][0] for m in names])\n", + " life = np.clip(np.array([rng.normal(MODES[m][1], 18) for m in mode]), 90, 230).astype(int)\n", + " start = np.sort(rng.integers(0, 240, n))\n", + " units = pd.DataFrame({\"unit\": [f\"U{i:02d}\" for i in range(n)], \"mode\": mode, \"life\": life, \"start\": start, \"base_load\": rng.uniform(0.4, 0.75, n)})\n", + " rows = []\n", + " for u in units.itertuples():\n", + " d = np.arange(u.life + 1)\n", + " onset = 0.45 * u.life\n", + " deg = np.clip((d - onset) / (u.life - onset), 0, 1) ** 1.5\n", + " load = np.clip(u.base_load + rng.normal(0, 0.06, len(d)), 0.2, 1.0)\n", + " hi = rng.random(len(d)) < 0.07 # 고부하 운용일(정상이지만 진동·온도 상승)\n", + " load = np.where(hi, rng.uniform(0.95, 1.1, len(d)), load)\n", + " rows.append(pd.DataFrame({\"unit\": u.unit, \"day\": d, \"cal\": u.start + d, \"rul\": u.life - d, \"deg\": deg, \"load\": load, \"hi_load\": hi, \"mode\": u.mode}))\n", + " snap = pd.concat(rows, ignore_index=True)\n", + " S = len(snap)\n", + " snap[\"ambient\"] = 15 + 12 * np.sin(2 * np.pi * snap[\"cal\"] / 365) + rng.normal(0, 2, S)\n", + " dg, ld, md = snap[\"deg\"].values, snap[\"load\"].values, snap[\"mode\"].values\n", + " isb, isg, iso, isc = (md == \"bearing\"), (md == \"gear\"), (md == \"oil_pump\"), (md == \"cooling\")\n", + " snap[\"temp\"] = 55 + 22 * ld + 0.5 * snap[\"ambient\"] + 9 * dg * iso + 26 * dg * isc + 3 * dg * (isb | isg) + rng.normal(0, 1.5, S)\n", + " snap[\"press\"] = 450 - 12 * ld - 85 * dg * iso - 15 * dg * isc + rng.normal(0, 6, S)\n", + " ph = rng.uniform(0, 2 * np.pi, (S, 3))\n", + " a1, a2 = 1.0 * (0.5 + ld), 0.4 * (0.5 + ld)\n", + " W = (a1[:, None] * np.sin(2 * np.pi * 50 * TT + ph[:, :1]) + a2[:, None] * np.sin(2 * np.pi * 100 * TT + ph[:, 1:2])\n", + " + rng.normal(0, 0.35, (S, NS)))\n", + " W += (0.9 * dg * isb)[:, None] * np.sin(2 * np.pi * 165 * TT + ph[:, 2:3])\n", + " W += (0.8 * dg * isg)[:, None] * (np.sin(2 * np.pi * 150 * TT + ph[:, 2:3]) + 0.8 * np.sin(2 * np.pi * 100 * TT))\n", + " W += (0.5 * dg * iso)[:, None] * np.sin(2 * np.pi * 200 * TT + ph[:, 2:3])\n", + " k = rng.poisson(10 * dg * isb) # 베어링 열화: 충격성 펄스(첨도 상승)\n", + " pos, amp = rng.integers(0, NS, (S, 30)), rng.normal(0, 3.0, (S, 30)) * dg[:, None]\n", + " amp = amp * (np.arange(30)[None, :] < k[:, None])\n", + " np.add.at(W, (np.repeat(np.arange(S), 30), pos.ravel()), amp.ravel())\n", + " return units, snap, W\n", + "\n", + "\n", + "def extract(W):\n", + " \"\"\"진동 파형 -> 특징량: RMS, 첨도, 파고율, 최대-최소, FFT 대역 에너지(50/100/150/165/200Hz 부근)\"\"\"\n", + " x = W - W.mean(axis=1, keepdims=True)\n", + " rms = np.sqrt((x ** 2).mean(axis=1))\n", + " out = {\"rms\": rms, \"kurt\": kurtosis(x, axis=1, fisher=False), \"crest\": np.abs(x).max(axis=1) / rms, \"p2p\": x.max(axis=1) - x.min(axis=1)}\n", + " A = np.abs(np.fft.rfft(x * np.hanning(NS), axis=1)) / NS\n", + " f = np.fft.rfftfreq(NS, 1 / FS)\n", + " for nm, (lo, hi) in BANDS.items():\n", + " out[nm] = np.sqrt((A[:, (f >= lo) & (f < hi)] ** 2).sum(axis=1))\n", + " return pd.DataFrame(out)\n", + "\n", + "# ---------------------------------------------------------------------------\n", + "# 공통 분석 조각: 일별 특징량 표(파형 특징 + 온도·압력 + 파생), 개체 단위 학습/검증 분할(앞선 24대 학습, 뒤 24대 검증)\n", + "# ---------------------------------------------------------------------------\n", + "BASE = [\"rms\", \"kurt\", \"crest\", \"p2p\", \"b50\", \"b100\", \"b150\", \"b165\", \"b200\", \"temp\", \"press\"]\n", + "ROLL = [\"rms\", \"kurt\", \"b150\", \"b165\", \"b200\", \"temp\", \"press\"]\n", + "\n", + "\n", + "def build_features(snap, W):\n", + " D = pd.concat([snap.reset_index(drop=True), extract(W)], axis=1)\n", + " D[\"temp_res\"] = D[\"temp\"] - (55 + 22 * D[\"load\"] + 0.5 * D[\"ambient\"]) # 부하·외기 대비 온도 잔차\n", + " D[\"press_res\"] = D[\"press\"] - (450 - 12 * D[\"load\"]) # 부하 대비 압력 잔차\n", + " D[\"rms_n\"] = D[\"rms\"] / (0.5 + D[\"load\"]) # 부하 정규화 RMS\n", + " D[\"hf_ratio\"] = (D[\"b150\"] + D[\"b165\"] + D[\"b200\"]) / (D[\"b50\"] + 1e-6) # 고주파/기본주파 비\n", + " g = D.groupby(\"unit\")\n", + " for c in ROLL:\n", + " D[c + \"_m3\"] = g[c].transform(lambda s: s.rolling(3, min_periods=1).mean())\n", + " for c in (\"rms_n\", \"temp_res\", \"press_res\"):\n", + " D[c + \"_d\"] = g[c].diff().fillna(0)\n", + " return D\n", + "\n", + "\n", + "DERIVED = [\"temp_res\", \"press_res\", \"rms_n\", \"hf_ratio\"] + [c + \"_m3\" for c in ROLL] + [\"rms_n_d\", \"temp_res_d\", \"press_res_d\"]\n", + "FEATS = BASE + DERIVED\n", + "AE_COLS = [\"rms_n\", \"kurt\", \"b150\", \"b165\", \"b200\", \"hf_ratio\", \"temp_res\", \"press_res\"]\n", + "\n", + "\n", + "def split_units(units, n_train=24):\n", + " return list(units[\"unit\"][:n_train]), list(units[\"unit\"][n_train:])\n", + "\n", + "\n", + "class AE:\n", + " \"\"\"오토인코더(입력 8 -> 10 -> 4 -> 10 -> 8): 정상 자료만 재구성하도록 학습하고, 재구성 오차를 이상 점수로 쓴다.\"\"\"\n", + "\n", + " def __init__(self, cols=AE_COLS, q=0.99, seed=0):\n", + " self.cols, self.q, self.seed = cols, q, seed\n", + "\n", + " def fit(self, healthy):\n", + " self.sc = StandardScaler().fit(healthy[self.cols])\n", + " Z = self.sc.transform(healthy[self.cols])\n", + " self.net = MLPRegressor(hidden_layer_sizes=(10, 4, 10), max_iter=800, random_state=self.seed).fit(Z, Z)\n", + " self.thr = float(np.quantile(self.err(healthy), self.q))\n", + " return self\n", + "\n", + " def contrib(self, d):\n", + " Z = self.sc.transform(d[self.cols])\n", + " return (Z - self.net.predict(Z)) ** 2 # 입력 항목별 재구성 오차(이상 기여도)\n", + "\n", + " def err(self, d):\n", + " return self.contrib(d).mean(axis=1)\n", + "\n", + "\n", + "def latch(d, e, thr, k=2):\n", + " \"\"\"같은 개체에서 이상 점수가 k일 연속 기준을 넘으면 경보를 켜고 유지(래치)한다.\"\"\"\n", + " out, e = np.zeros(len(d), bool), np.asarray(e)\n", + " for idx in d.reset_index(drop=True).groupby(\"unit\").indices.values():\n", + " run, on = 0, False\n", + " for i in idx:\n", + " run = run + 1 if e[i] > thr else 0\n", + " on = on or run >= k\n", + " out[i] = on\n", + " return out\n", + "\n", + "units, snap, W = make_runs()\n", + "D = build_features(snap, W)\n", + "tr_u, va_u = split_units(units)\n", + "tr, va = D[D[\"unit\"].isin(tr_u)].reset_index(drop=True), D[D[\"unit\"].isin(va_u)].reset_index(drop=True)\n", + "Wva = W[D[\"unit\"].isin(va_u).values]\n", + "ae = AE().fit(tr[tr[\"deg\"] == 0])\n", + "KEEP = [\"unit\", \"day\", \"load\", \"ambient\", \"temp\", \"press\"]\n", + "\n", + "\n", + "def score_batch(w, meta):\n", + " \"\"\"스트림 한 묶음: 파형 -> 특징 -> 이상 점수(배수). 점수는 개체 연속성(래치)과 무관한 단건 값.\"\"\"\n", + " f = extract(w)\n", + " d = pd.DataFrame({\"rms_n\": f[\"rms\"] / (0.5 + meta[\"load\"]), \"kurt\": f[\"kurt\"], \"b150\": f[\"b150\"], \"b165\": f[\"b165\"], \"b200\": f[\"b200\"],\n", + " \"hf_ratio\": (f[\"b150\"] + f[\"b165\"] + f[\"b200\"]) / (f[\"b50\"] + 1e-6),\n", + " \"temp_res\": meta[\"temp\"] - (55 + 22 * meta[\"load\"] + 0.5 * meta[\"ambient\"]), \"press_res\": meta[\"press\"] - (450 - 12 * meta[\"load\"])})\n", + " return ae.err(d) / ae.thr\n", + "\n", + "\n", + "def valid(w, meta):\n", + " \"\"\"불량 패킷 판정: 결측(NaN), 센서 고정(분산 0), 포화(|값|>12), 값 범위 이탈.\"\"\"\n", + " bad = np.isnan(w).any(axis=1) | (np.nanstd(w, axis=1) < 1e-6) | (np.abs(np.nan_to_num(w)).max(axis=1) > 12)\n", + " bad |= ~meta[\"temp\"].between(20, 160).values | ~meta[\"press\"].between(100, 600).values\n", + " return ~bad\n", + "\n", + "\n", + "def make_stream(n, bad_rate, seed=0):\n", + " rng = np.random.default_rng(seed)\n", + " pick = rng.integers(0, len(va), n)\n", + " w = Wva[pick] + rng.normal(0, 0.02, (n, NS))\n", + " meta = va.iloc[pick][KEEP + [\"deg\"]].reset_index(drop=True)\n", + " kind = np.array([\"\"] * n, dtype=object)\n", + " bad = rng.random(n) < bad_rate\n", + " k = rng.integers(0, 4, n)\n", + " for t, nm in enumerate([\"결측 패킷\", \"센서 고정\", \"포화\", \"범위 이탈\"]):\n", + " m = bad & (k == t)\n", + " kind[m] = nm\n", + " if t == 0: w[m, 10:40] = np.nan\n", + " if t == 1: w[m] = 0.5\n", + " if t == 2: w[m, ::7] = 50.0\n", + " if t == 3: meta.loc[m, \"temp\"] = 400.0\n", + " return w, meta, kind\n", + "\n", + "\n", + "# 1) 1,000건 동시 주입\n", + "t0 = time.perf_counter()\n", + "w, meta, kind = make_stream(1000, 0.06)\n", + "ok = valid(w, meta)\n", + "s = np.full(1000, np.nan)\n", + "s[ok] = score_batch(w[ok], meta[ok].reset_index(drop=True))\n", + "sec = time.perf_counter() - t0\n", + "inj = int((kind != \"\").sum())\n", + "print(f\"[교육용가상자료] 1,000건 동시 주입: 정상 처리 {int(ok.sum())}건, 격리 {int((~ok).sum())}건, 유실 {1000 - int(ok.sum()) - int((~ok).sum())}건, 처리 시간 {sec:.2f}초 (목표 2초 이내: {'달성' if sec <= 2 else '미달'})\")\n", + "print(f\" 불량 주입 {inj}건 중 격리 {int(((kind != '') & ~ok).sum())}건 (\" + \", \".join(f\"{k} {int((kind == k).sum())}\" for k in [\"결측 패킷\", \"센서 고정\", \"포화\", \"범위 이탈\"]) + \")\")\n", + "\n", + "# 2) 경보 정확도: 정상 구간과 열화가 뚜렷한 구간(deg>=0.3)을 판정 대상으로 하고, 열화 초기 회색 구간은 정확도에서 제외하고 따로 표시\n", + "al = s > 1.0\n", + "y = (meta[\"deg\"] >= 0.3).values\n", + "eval_m = ok & ((meta[\"deg\"] == 0).values | y)\n", + "acc = (al[eval_m] == y[eval_m]).mean()\n", + "tp, fp, fn = (al & y & ok).sum(), (al & ~y & eval_m).sum(), (~al & y & ok).sum()\n", + "print(f\"경보 정확도 {acc:.1%} (목표 95% 이상: {'달성' if acc >= 0.95 else '미달'}), 재현율 {tp / (tp + fn):.1%}, 정밀도 {tp / (tp + fp):.1%}, 경보 {int((al & ok).sum())}건\")\n", + "gray = ok & (meta[\"deg\"] > 0).values & (meta[\"deg\"] < 0.3).values\n", + "print(f\" 참고: 열화 초기(0~0.3) 구간 {int(gray.sum())}건 중 경보 {al[gray].mean():.0%}(회색 구간은 정확도에서 제외)\")\n", + "\n", + "# 3) 상태 화면 구성: 최근 30일 x 24대 요약(개체별 최댓값·경보 여부) 집계 시간\n", + "t0 = time.perf_counter()\n", + "last = va[va[\"day\"] >= va.groupby(\"unit\")[\"day\"].transform(\"max\") - 30]\n", + "sc = score_batch(Wva[last.index.values], last.reset_index(drop=True))\n", + "view = last.assign(score=sc).groupby(\"unit\").agg(max_score=(\"score\", \"max\"), last_score=(\"score\", \"last\"))\n", + "view[\"상태\"] = np.where(view[\"last_score\"] > 1, \"경보\", np.where(view[\"max_score\"] > 1, \"주의\", \"정상\"))\n", + "tv = time.perf_counter() - t0\n", + "print(f\"상태 화면 구성(24대 x 최근 30일 {len(last)}건) {tv:.2f}초 (목표 2초 이내: {'달성' if tv <= 2 else '미달'}), \" + \", \".join(f\"{k} {v}대\" for k, v in view[\"상태\"].value_counts().items()))\n", + "\n", + "# 4) 규모 확대: 배치 처리량과 메모리 한계 대응(배치 크기 5,000)\n", + "print(\"\\n[주입 규모별 처리] 건수 | 시간 | 처리량 | 격리 | 유실\")\n", + "sizes = (1000, 10000, 50000, 100000)\n", + "ts = []\n", + "for n in sizes:\n", + " w, meta, kind = make_stream(n, 0.06, seed=n)\n", + " t0 = time.perf_counter(); done = quarantine = 0\n", + " for i in range(0, n, 5000):\n", + " wb, mb = w[i:i + 5000], meta.iloc[i:i + 5000].reset_index(drop=True)\n", + " okb = valid(wb, mb)\n", + " if okb.any():\n", + " score_batch(wb[okb], mb[okb].reset_index(drop=True))\n", + " done += int(okb.sum()); quarantine += int((~okb).sum())\n", + " t = time.perf_counter() - t0; ts.append(t)\n", + " print(f\" {n:7d} {t:5.2f}초 {n / t:8.0f}건/초 {quarantine:5d} {n - done - quarantine}\")\n", + "print(\"운용 기준: 전체 격리율이 20%를 넘으면 센서 계통 점검 알림, 처리 지연 시 FFT 대역 5개만 계산하는 축소 모드로 전환\")\n", + "\n", + "plt.figure(figsize=(6, 4))\n", + "plt.plot(sizes, ts, marker=\"o\"); plt.xscale(\"log\"); plt.xlabel(\"stream size\"); plt.ylabel(\"seconds\"); plt.title(\"Stream stress test\")\n", + "plt.tight_layout(); plt.show()\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 08 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + "[교육용가상자료] 1,000건 동시 주입: 정상 처리 945건, 격리 55건, 유실 0건, 처리 시간 0.03초 (목표 2초 이내: 달성)\n", + " 불량 주입 55건 중 격리 55건 (결측 패킷 15, 센서 고정 16, 포화 15, 범위 이탈 9)\n", + "경보 정확도 98.0% (목표 95% 이상: 달성), 재현율 98.0%, 정밀도 97.3%, 경보 330건\n", + " 참고: 열화 초기(0~0.3) 구간 231건 중 경보 15%(회색 구간은 정확도에서 제외)\n", + "상태 화면 구성(24대 x 최근 30일 744건) 0.02초 (목표 2초 이내: 달성), 경보 24대\n", + "\n", + "[주입 규모별 처리] 건수 | 시간 | 처리량 | 격리 | 유실\n", + " 1000 0.02초 48180건/초 68 0\n", + " 10000 0.21초 48054건/초 618 0\n", + " 50000 1.23초 40710건/초 2951 0\n", + " 100000 3.51초 28472건/초 5940 0\n", + "운용 기준: 전체 격리율이 20%를 넘으면 센서 계통 점검 알림, 처리 지연 시 FFT 대역 5개만 계산하는 축소 모드로 전환\n", + "```\n", + "
\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 09. AAR 기반 예지정비 오경보 분석 및 이상 감지 임계 파라미터 재보정\n", + "**소요시간**: 약 3.0시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "AAR 기반 예지정비 오경보를 분석하고 임계 파라미터를 재보정한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **AAR 기반으로 예지정비 오경보를 원인별로 분석하고 이상 감지 임계 파라미터를 재보정해 효과를 지표로 산출한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **AAR 기간 오경보를 고부하 운용일, 외기 급변, 센서 스파이크, 열화 초기 경계 등으로 분류하고, 정상 분위수·연속 일수·고부하일 임계 완화를 AAR 기간 자료만으로 격자 탐색해 재보정 수행하고 검증하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `reports/mnt04_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 기간의 예지정비 오경보를 원인(고부하 운용일·외기 급변·센서 스파이크·기타)별로 분석하고, 정상 분위수·연속 일수·고부하일 임계 완화 파라미터를 AAR 기간 자료로만 격자 탐색해 재보정한 뒤 검증 기간에서 오경보·고장 전 확보 시간 변화와 비용 절감·점검 시간 단축·확보 시간 증가의 3대 정량 효과 지표를 가정 단가로 산출한다.\"\"\"\n", + "import itertools\n", + "import numpy as np\n", + "import pandas as pd\n", + "from scipy.stats import kurtosis\n", + "from sklearn.neural_network import MLPRegressor\n", + "from sklearn.preprocessing import StandardScaler\n", + "\n", + "# ---------------------------------------------------------------------------\n", + "# [교육용가상자료] 가상 기동체 48대의 엔진·구동계 진동·온도·압력 센서 자료 생성기 (실제 장비·부대·정비 실적 없음)\n", + "# 개체마다 정상 -> 열화 -> 고장(수명종료)까지 하루 1회 측정(진동 파형 256표본 + 온도 + 압력 + 부하)을 만든다.\n", + "# 열화 유형 4가지: bearing(베어링), gear(기어), oil_pump(오일 펌프), cooling(냉각 팬 모터)\n", + "# ---------------------------------------------------------------------------\n", + "FS, NS = 1024, 256 # 가상 표본화 주파수(Hz), 파형 1개당 표본 수\n", + "TT = np.arange(NS) / FS\n", + "MODES = { # 열화 유형: (발생 확률, 평균 수명(일), 교체 수리부속, 가상 단가지수)\n", + " \"bearing\": (0.40, 160, \"주 베어링 세트\", 6.0), \"gear\": (0.25, 175, \"구동 기어 세트\", 9.0),\n", + " \"oil_pump\": (0.20, 150, \"오일 펌프\", 4.0), \"cooling\": (0.15, 140, \"냉각 팬 모터\", 3.0)}\n", + "KIT_COST = 1.0 # 모든 정비에 함께 소모되는 씰·필터 키트 단가지수\n", + "BANDS = {\"b50\": (40, 60), \"b100\": (90, 110), \"b150\": (140, 156), \"b165\": (156, 176), \"b200\": (190, 215)}\n", + "\n", + "\n", + "def make_runs(n=48, seed=1):\n", + " \"\"\"반환: units(개체 정보), snap(일별 측정 요약), W(진동 파형 [측정 수, 256])\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " names = list(MODES)\n", + " mode = rng.choice(names, n, p=[MODES[m][0] for m in names])\n", + " life = np.clip(np.array([rng.normal(MODES[m][1], 18) for m in mode]), 90, 230).astype(int)\n", + " start = np.sort(rng.integers(0, 240, n))\n", + " units = pd.DataFrame({\"unit\": [f\"U{i:02d}\" for i in range(n)], \"mode\": mode, \"life\": life, \"start\": start, \"base_load\": rng.uniform(0.4, 0.75, n)})\n", + " rows = []\n", + " for u in units.itertuples():\n", + " d = np.arange(u.life + 1)\n", + " onset = 0.45 * u.life\n", + " deg = np.clip((d - onset) / (u.life - onset), 0, 1) ** 1.5\n", + " load = np.clip(u.base_load + rng.normal(0, 0.06, len(d)), 0.2, 1.0)\n", + " hi = rng.random(len(d)) < 0.07 # 고부하 운용일(정상이지만 진동·온도 상승)\n", + " load = np.where(hi, rng.uniform(0.95, 1.1, len(d)), load)\n", + " rows.append(pd.DataFrame({\"unit\": u.unit, \"day\": d, \"cal\": u.start + d, \"rul\": u.life - d, \"deg\": deg, \"load\": load, \"hi_load\": hi, \"mode\": u.mode}))\n", + " snap = pd.concat(rows, ignore_index=True)\n", + " S = len(snap)\n", + " snap[\"ambient\"] = 15 + 12 * np.sin(2 * np.pi * snap[\"cal\"] / 365) + rng.normal(0, 2, S)\n", + " dg, ld, md = snap[\"deg\"].values, snap[\"load\"].values, snap[\"mode\"].values\n", + " isb, isg, iso, isc = (md == \"bearing\"), (md == \"gear\"), (md == \"oil_pump\"), (md == \"cooling\")\n", + " snap[\"temp\"] = 55 + 22 * ld + 0.5 * snap[\"ambient\"] + 9 * dg * iso + 26 * dg * isc + 3 * dg * (isb | isg) + rng.normal(0, 1.5, S)\n", + " snap[\"press\"] = 450 - 12 * ld - 85 * dg * iso - 15 * dg * isc + rng.normal(0, 6, S)\n", + " ph = rng.uniform(0, 2 * np.pi, (S, 3))\n", + " a1, a2 = 1.0 * (0.5 + ld), 0.4 * (0.5 + ld)\n", + " W = (a1[:, None] * np.sin(2 * np.pi * 50 * TT + ph[:, :1]) + a2[:, None] * np.sin(2 * np.pi * 100 * TT + ph[:, 1:2])\n", + " + rng.normal(0, 0.35, (S, NS)))\n", + " W += (0.9 * dg * isb)[:, None] * np.sin(2 * np.pi * 165 * TT + ph[:, 2:3])\n", + " W += (0.8 * dg * isg)[:, None] * (np.sin(2 * np.pi * 150 * TT + ph[:, 2:3]) + 0.8 * np.sin(2 * np.pi * 100 * TT))\n", + " W += (0.5 * dg * iso)[:, None] * np.sin(2 * np.pi * 200 * TT + ph[:, 2:3])\n", + " k = rng.poisson(10 * dg * isb) # 베어링 열화: 충격성 펄스(첨도 상승)\n", + " pos, amp = rng.integers(0, NS, (S, 30)), rng.normal(0, 3.0, (S, 30)) * dg[:, None]\n", + " amp = amp * (np.arange(30)[None, :] < k[:, None])\n", + " np.add.at(W, (np.repeat(np.arange(S), 30), pos.ravel()), amp.ravel())\n", + " return units, snap, W\n", + "\n", + "\n", + "def extract(W):\n", + " \"\"\"진동 파형 -> 특징량: RMS, 첨도, 파고율, 최대-최소, FFT 대역 에너지(50/100/150/165/200Hz 부근)\"\"\"\n", + " x = W - W.mean(axis=1, keepdims=True)\n", + " rms = np.sqrt((x ** 2).mean(axis=1))\n", + " out = {\"rms\": rms, \"kurt\": kurtosis(x, axis=1, fisher=False), \"crest\": np.abs(x).max(axis=1) / rms, \"p2p\": x.max(axis=1) - x.min(axis=1)}\n", + " A = np.abs(np.fft.rfft(x * np.hanning(NS), axis=1)) / NS\n", + " f = np.fft.rfftfreq(NS, 1 / FS)\n", + " for nm, (lo, hi) in BANDS.items():\n", + " out[nm] = np.sqrt((A[:, (f >= lo) & (f < hi)] ** 2).sum(axis=1))\n", + " return pd.DataFrame(out)\n", + "\n", + "# ---------------------------------------------------------------------------\n", + "# 공통 분석 조각: 일별 특징량 표(파형 특징 + 온도·압력 + 파생), 개체 단위 학습/검증 분할(앞선 24대 학습, 뒤 24대 검증)\n", + "# ---------------------------------------------------------------------------\n", + "BASE = [\"rms\", \"kurt\", \"crest\", \"p2p\", \"b50\", \"b100\", \"b150\", \"b165\", \"b200\", \"temp\", \"press\"]\n", + "ROLL = [\"rms\", \"kurt\", \"b150\", \"b165\", \"b200\", \"temp\", \"press\"]\n", + "\n", + "\n", + "def build_features(snap, W):\n", + " D = pd.concat([snap.reset_index(drop=True), extract(W)], axis=1)\n", + " D[\"temp_res\"] = D[\"temp\"] - (55 + 22 * D[\"load\"] + 0.5 * D[\"ambient\"]) # 부하·외기 대비 온도 잔차\n", + " D[\"press_res\"] = D[\"press\"] - (450 - 12 * D[\"load\"]) # 부하 대비 압력 잔차\n", + " D[\"rms_n\"] = D[\"rms\"] / (0.5 + D[\"load\"]) # 부하 정규화 RMS\n", + " D[\"hf_ratio\"] = (D[\"b150\"] + D[\"b165\"] + D[\"b200\"]) / (D[\"b50\"] + 1e-6) # 고주파/기본주파 비\n", + " g = D.groupby(\"unit\")\n", + " for c in ROLL:\n", + " D[c + \"_m3\"] = g[c].transform(lambda s: s.rolling(3, min_periods=1).mean())\n", + " for c in (\"rms_n\", \"temp_res\", \"press_res\"):\n", + " D[c + \"_d\"] = g[c].diff().fillna(0)\n", + " return D\n", + "\n", + "\n", + "DERIVED = [\"temp_res\", \"press_res\", \"rms_n\", \"hf_ratio\"] + [c + \"_m3\" for c in ROLL] + [\"rms_n_d\", \"temp_res_d\", \"press_res_d\"]\n", + "FEATS = BASE + DERIVED\n", + "AE_COLS = [\"rms_n\", \"kurt\", \"b150\", \"b165\", \"b200\", \"hf_ratio\", \"temp_res\", \"press_res\"]\n", + "\n", + "\n", + "def split_units(units, n_train=24):\n", + " return list(units[\"unit\"][:n_train]), list(units[\"unit\"][n_train:])\n", + "\n", + "\n", + "class AE:\n", + " \"\"\"오토인코더(입력 8 -> 10 -> 4 -> 10 -> 8): 정상 자료만 재구성하도록 학습하고, 재구성 오차를 이상 점수로 쓴다.\"\"\"\n", + "\n", + " def __init__(self, cols=AE_COLS, q=0.99, seed=0):\n", + " self.cols, self.q, self.seed = cols, q, seed\n", + "\n", + " def fit(self, healthy):\n", + " self.sc = StandardScaler().fit(healthy[self.cols])\n", + " Z = self.sc.transform(healthy[self.cols])\n", + " self.net = MLPRegressor(hidden_layer_sizes=(10, 4, 10), max_iter=800, random_state=self.seed).fit(Z, Z)\n", + " self.thr = float(np.quantile(self.err(healthy), self.q))\n", + " return self\n", + "\n", + " def contrib(self, d):\n", + " Z = self.sc.transform(d[self.cols])\n", + " return (Z - self.net.predict(Z)) ** 2 # 입력 항목별 재구성 오차(이상 기여도)\n", + "\n", + " def err(self, d):\n", + " return self.contrib(d).mean(axis=1)\n", + "\n", + "\n", + "def latch(d, e, thr, k=2):\n", + " \"\"\"같은 개체에서 이상 점수가 k일 연속 기준을 넘으면 경보를 켜고 유지(래치)한다.\"\"\"\n", + " out, e = np.zeros(len(d), bool), np.asarray(e)\n", + " for idx in d.reset_index(drop=True).groupby(\"unit\").indices.values():\n", + " run, on = 0, False\n", + " for i in idx:\n", + " run = run + 1 if e[i] > thr else 0\n", + " on = on or run >= k\n", + " out[i] = on\n", + " return out\n", + "\n", + "FA_COST, FA_HOURS, LATE_COST = 1.0, 3.0, 0.15 # [교육용가상자료] 오경보 1회 점검 비용지수, 점검 소요시간(h), 확보 시간 1일 손실 비용지수\n", + "units, snap, W = make_runs(seed=5)\n", + "D = build_features(snap, W)\n", + "order = list(units[\"unit\"])\n", + "tr_u, aar_u, val_u = order[:24], order[24:36], order[36:]\n", + "tr = D[D[\"unit\"].isin(tr_u)]\n", + "ae = AE().fit(tr[tr[\"deg\"] == 0])\n", + "healthy_err = ae.err(tr[tr[\"deg\"] == 0])\n", + "E = {u: (D[D[\"unit\"] == u].reset_index(drop=True), ae.err(D[D[\"unit\"] == u])) for u in order}\n", + "\n", + "\n", + "def evaluate(us, q, k, relax):\n", + " \"\"\"반환: 오경보 에피소드 수, 원인 분류 건수, 고장 전 확보 시간(일) 목록, 미탐 개체 수, 손실 확보 일수\"\"\"\n", + " base_thr = float(np.quantile(healthy_err, q))\n", + " fa, causes, leads, miss, lost = 0, {\"고부하 운용일\": 0, \"외기 급변·고온\": 0, \"센서 스파이크\": 0, \"열화 초기 경계\": 0, \"기타\": 0}, [], 0, 0.0\n", + " for u in us:\n", + " g, e = E[u]\n", + " thr = base_thr * np.where(g[\"load\"].values > 0.95, 1 + relax, 1.0)\n", + " over = e > thr\n", + " run = np.zeros(len(g), int)\n", + " for i in range(len(g)):\n", + " run[i] = run[i - 1] + 1 if over[i] and i else int(over[i])\n", + " al = run >= k\n", + " an = (g[\"deg\"] >= 0.1).values\n", + " start = al & ~np.r_[False, al[:-1]]\n", + " for i in np.where(start & ~an)[0]:\n", + " fa += 1\n", + " r = g.iloc[i]\n", + " c = \"고부하 운용일\" if r[\"hi_load\"] else (\"외기 급변·고온\" if r[\"ambient\"] > 24 else (\"센서 스파이크\" if r[\"kurt\"] > 3.2 else (\"열화 초기 경계\" if r[\"deg\"] > 0 else \"기타\")))\n", + " causes[c] += 1\n", + " first_an = np.argmax(an)\n", + " hit = np.where(al & an)[0]\n", + " ideal = g[\"rul\"].iloc[first_an]\n", + " if len(hit):\n", + " leads.append(int(g[\"rul\"].iloc[hit[0]])); lost += ideal - g[\"rul\"].iloc[hit[0]]\n", + " else:\n", + " miss += 1; lost += ideal\n", + " return fa, causes, leads, miss, lost\n", + "\n", + "\n", + "def J(res):\n", + " return FA_COST * res[0] + LATE_COST * res[4]\n", + "\n", + "\n", + "cur = dict(q=0.99, k=1, relax=0.0) # 현행(AAR 이전) 파라미터\n", + "r_aar = evaluate(aar_u, **cur)\n", + "print(f\"[교육용가상자료] AAR 기간 {len(aar_u)}대: 현행 파라미터(정상 {cur['q']:.0%} 분위, {cur['k']}일, 고부하 완화 {cur['relax']:.0%}) 오경보 {r_aar[0]}건\")\n", + "tot = sum(r_aar[1].values())\n", + "for c, v in sorted(r_aar[1].items(), key=lambda x: -x[1]):\n", + " print(f\" {c:10s} {v:3d}건 ({v / max(tot, 1):.0%})\")\n", + "\n", + "# 재보정: AAR 기간 자료만으로 격자 탐색 (미탐 개체가 없는 조합 중 비용 최소)\n", + "grid = []\n", + "for q, k, rl in itertools.product((0.98, 0.99, 0.995, 0.999), (1, 2, 3), (0.0, 0.3, 0.6)):\n", + " r = evaluate(aar_u, q, k, rl)\n", + " if r[3] == 0:\n", + " grid.append((J(r), q, k, rl))\n", + "grid.sort()\n", + "best = dict(q=grid[0][1], k=grid[0][2], relax=grid[0][3])\n", + "print(f\"\\n재보정 결과(격자 {4 * 3 * 3}개 중 미탐 0개 조합 {len(grid)}개 탐색): 정상 {best['q']:.1%} 분위, 연속 {best['k']}일, 고부하일 임계 {best['relax']:.0%} 완화\")\n", + "\n", + "rows = []\n", + "for nm, us in ((\"AAR 기간(튜닝)\", aar_u), (\"검증 기간(미사용)\", val_u)):\n", + " a, b = evaluate(us, **cur), evaluate(us, **best)\n", + " rows.append((nm, a, b))\n", + " print(f\" {nm:14s} 오경보 {a[0]:3d} -> {b[0]:3d}건, 고장 전 확보 중앙값 {np.median(a[2]):.0f} -> {np.median(b[2]):.0f}일, 미탐 {a[3]} -> {b[3]}대\")\n", + "\n", + "# 3대 정량 효과 지표(검증 3개월 대신 검증 12대 기준, 연환산은 개체 수 비례)\n", + "a, b = rows[1][1], rows[1][2]\n", + "jc, jn = J(a), J(b)\n", + "print(\"\\n[3대 정량 효과 지표] (검증 12대, 가정 단가)\")\n", + "print(f\" 1) 비용 절감: 비용지수 {jc:.1f} -> {jn:.1f} ({1 - jn / jc:.1%} 절감), 48대 연환산 약 {(jc - jn) * 4:.0f}\")\n", + "print(f\" 2) 시간 단축: 불필요 점검 {a[0] * FA_HOURS:.0f}h -> {b[0] * FA_HOURS:.0f}h ({1 - b[0] / max(a[0], 1):.1%} 단축)\")\n", + "print(f\" 3) 확보 시간: 고장 전 첫 경보 확보 중앙값 {np.median(a[2]):.0f}일 -> {np.median(b[2]):.0f}일, 정상 오경보 {a[0]}건 -> {b[0]}건\")\n", + "print(\"[도입 로드맵] 단기(1~3월): 재보정 임계 적용 + 검사관 확인 병행 / 중기(3~12월): 분기 AAR로 임계 재보정, 부하 보정 항 추가 / 장기(1년~): 군수 정보체계 정비 이력과 연동해 임계 자동 갱신\")\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 09 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + "[교육용가상자료] AAR 기간 12대: 현행 파라미터(정상 99% 분위, 1일, 고부하 완화 0%) 오경보 9건\n", + " 기타 5건 (56%)\n", + " 고부하 운용일 2건 (22%)\n", + " 열화 초기 경계 2건 (22%)\n", + " 외기 급변·고온 0건 (0%)\n", + " 센서 스파이크 0건 (0%)\n", + "\n", + "재보정 결과(격자 36개 중 미탐 0개 조합 36개 탐색): 정상 99.5% 분위, 연속 1일, 고부하일 임계 30% 완화\n", + " AAR 기간(튜닝) 오경보 9 -> 4건, 고장 전 확보 중앙값 58 -> 56일, 미탐 0 -> 0대\n", + " 검증 기간(미사용) 오경보 16 -> 10건, 고장 전 확보 중앙값 60 -> 57일, 미탐 0 -> 0대\n", + "\n", + "[3대 정량 효과 지표] (검증 12대, 가정 단가)\n", + " 1) 비용 절감: 비용지수 24.7 -> 22.0 (10.9% 절감), 48대 연환산 약 11\n", + " 2) 시간 단축: 불필요 점검 48h -> 30h (37.5% 단축)\n", + " 3) 확보 시간: 고장 전 첫 경보 확보 중앙값 60일 -> 57일, 정상 오경보 16건 -> 10건\n", + "[도입 로드맵] 단기(1~3월): 재보정 임계 적용 + 검사관 확인 병행 / 중기(3~12월): 분기 AAR로 임계 재보정, 부하 보정 항 추가 / 장기(1년~): 군수 정보체계 정비 이력과 연동해 임계 자동 갱신\n", + "```\n", + "
\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 10. 항공/기갑 장비 예지정비(PdM) 센서 데이터 이상 징후 감지 시스템 통합 완성\n", + "**소요시간**: 약 3.5시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "항공/기갑 장비 예지정비(PdM) 센서 데이터 이상 징후 감지 시스템을 완성한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **센서 검증부터 이상 감지, RUL 예측, 승인 대기 입고 제안, 수리부속 소요 예고, 재학습까지 한 파일로 통합하고 자체 점검과 브리핑 요약을 출력하는 도구를 완성한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **지난달·이번 달 실적으로 챔피언 모델과 도전자 모델을 학습해 검증 F1을 비교하고 비슷하거나 나을 때만 교체 수행하고 검증하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `docs/mnt04_operations_guide.md 및 pipeline/mnt04_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": [ + "\"\"\"센서 파형 검증·정제, 오토인코더 이상 감지 모델의 자동 재학습(챔피언·도전자 비교 후 교체), 재보정 임계 적용 경보, 잔존수명(RUL) 예측, 검사관 승인 대기 입고 제안, 수리부속 소요 예고까지 예지정비(PdM) 이상 감지 시스템을 한 파일로 통합 실행하고 자체 점검·운영 문서 완비 점검·정비 담당 장교 브리핑 요약을 출력한다.\"\"\"\n", + "import time\n", + "import numpy as np\n", + "import pandas as pd\n", + "import matplotlib.pyplot as plt\n", + "from scipy.stats import kurtosis\n", + "from sklearn.ensemble import GradientBoostingRegressor\n", + "from sklearn.neural_network import MLPRegressor\n", + "from sklearn.preprocessing import StandardScaler\n", + "\n", + "# ---------------------------------------------------------------------------\n", + "# [교육용가상자료] 가상 기동체 48대의 엔진·구동계 진동·온도·압력 센서 자료 생성기 (실제 장비·부대·정비 실적 없음)\n", + "# 개체마다 정상 -> 열화 -> 고장(수명종료)까지 하루 1회 측정(진동 파형 256표본 + 온도 + 압력 + 부하)을 만든다.\n", + "# 열화 유형 4가지: bearing(베어링), gear(기어), oil_pump(오일 펌프), cooling(냉각 팬 모터)\n", + "# ---------------------------------------------------------------------------\n", + "FS, NS = 1024, 256 # 가상 표본화 주파수(Hz), 파형 1개당 표본 수\n", + "TT = np.arange(NS) / FS\n", + "MODES = { # 열화 유형: (발생 확률, 평균 수명(일), 교체 수리부속, 가상 단가지수)\n", + " \"bearing\": (0.40, 160, \"주 베어링 세트\", 6.0), \"gear\": (0.25, 175, \"구동 기어 세트\", 9.0),\n", + " \"oil_pump\": (0.20, 150, \"오일 펌프\", 4.0), \"cooling\": (0.15, 140, \"냉각 팬 모터\", 3.0)}\n", + "KIT_COST = 1.0 # 모든 정비에 함께 소모되는 씰·필터 키트 단가지수\n", + "BANDS = {\"b50\": (40, 60), \"b100\": (90, 110), \"b150\": (140, 156), \"b165\": (156, 176), \"b200\": (190, 215)}\n", + "\n", + "\n", + "def make_runs(n=48, seed=1):\n", + " \"\"\"반환: units(개체 정보), snap(일별 측정 요약), W(진동 파형 [측정 수, 256])\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " names = list(MODES)\n", + " mode = rng.choice(names, n, p=[MODES[m][0] for m in names])\n", + " life = np.clip(np.array([rng.normal(MODES[m][1], 18) for m in mode]), 90, 230).astype(int)\n", + " start = np.sort(rng.integers(0, 240, n))\n", + " units = pd.DataFrame({\"unit\": [f\"U{i:02d}\" for i in range(n)], \"mode\": mode, \"life\": life, \"start\": start, \"base_load\": rng.uniform(0.4, 0.75, n)})\n", + " rows = []\n", + " for u in units.itertuples():\n", + " d = np.arange(u.life + 1)\n", + " onset = 0.45 * u.life\n", + " deg = np.clip((d - onset) / (u.life - onset), 0, 1) ** 1.5\n", + " load = np.clip(u.base_load + rng.normal(0, 0.06, len(d)), 0.2, 1.0)\n", + " hi = rng.random(len(d)) < 0.07 # 고부하 운용일(정상이지만 진동·온도 상승)\n", + " load = np.where(hi, rng.uniform(0.95, 1.1, len(d)), load)\n", + " rows.append(pd.DataFrame({\"unit\": u.unit, \"day\": d, \"cal\": u.start + d, \"rul\": u.life - d, \"deg\": deg, \"load\": load, \"hi_load\": hi, \"mode\": u.mode}))\n", + " snap = pd.concat(rows, ignore_index=True)\n", + " S = len(snap)\n", + " snap[\"ambient\"] = 15 + 12 * np.sin(2 * np.pi * snap[\"cal\"] / 365) + rng.normal(0, 2, S)\n", + " dg, ld, md = snap[\"deg\"].values, snap[\"load\"].values, snap[\"mode\"].values\n", + " isb, isg, iso, isc = (md == \"bearing\"), (md == \"gear\"), (md == \"oil_pump\"), (md == \"cooling\")\n", + " snap[\"temp\"] = 55 + 22 * ld + 0.5 * snap[\"ambient\"] + 9 * dg * iso + 26 * dg * isc + 3 * dg * (isb | isg) + rng.normal(0, 1.5, S)\n", + " snap[\"press\"] = 450 - 12 * ld - 85 * dg * iso - 15 * dg * isc + rng.normal(0, 6, S)\n", + " ph = rng.uniform(0, 2 * np.pi, (S, 3))\n", + " a1, a2 = 1.0 * (0.5 + ld), 0.4 * (0.5 + ld)\n", + " W = (a1[:, None] * np.sin(2 * np.pi * 50 * TT + ph[:, :1]) + a2[:, None] * np.sin(2 * np.pi * 100 * TT + ph[:, 1:2])\n", + " + rng.normal(0, 0.35, (S, NS)))\n", + " W += (0.9 * dg * isb)[:, None] * np.sin(2 * np.pi * 165 * TT + ph[:, 2:3])\n", + " W += (0.8 * dg * isg)[:, None] * (np.sin(2 * np.pi * 150 * TT + ph[:, 2:3]) + 0.8 * np.sin(2 * np.pi * 100 * TT))\n", + " W += (0.5 * dg * iso)[:, None] * np.sin(2 * np.pi * 200 * TT + ph[:, 2:3])\n", + " k = rng.poisson(10 * dg * isb) # 베어링 열화: 충격성 펄스(첨도 상승)\n", + " pos, amp = rng.integers(0, NS, (S, 30)), rng.normal(0, 3.0, (S, 30)) * dg[:, None]\n", + " amp = amp * (np.arange(30)[None, :] < k[:, None])\n", + " np.add.at(W, (np.repeat(np.arange(S), 30), pos.ravel()), amp.ravel())\n", + " return units, snap, W\n", + "\n", + "\n", + "def extract(W):\n", + " \"\"\"진동 파형 -> 특징량: RMS, 첨도, 파고율, 최대-최소, FFT 대역 에너지(50/100/150/165/200Hz 부근)\"\"\"\n", + " x = W - W.mean(axis=1, keepdims=True)\n", + " rms = np.sqrt((x ** 2).mean(axis=1))\n", + " out = {\"rms\": rms, \"kurt\": kurtosis(x, axis=1, fisher=False), \"crest\": np.abs(x).max(axis=1) / rms, \"p2p\": x.max(axis=1) - x.min(axis=1)}\n", + " A = np.abs(np.fft.rfft(x * np.hanning(NS), axis=1)) / NS\n", + " f = np.fft.rfftfreq(NS, 1 / FS)\n", + " for nm, (lo, hi) in BANDS.items():\n", + " out[nm] = np.sqrt((A[:, (f >= lo) & (f < hi)] ** 2).sum(axis=1))\n", + " return pd.DataFrame(out)\n", + "\n", + "# ---------------------------------------------------------------------------\n", + "# 공통 분석 조각: 일별 특징량 표(파형 특징 + 온도·압력 + 파생), 개체 단위 학습/검증 분할(앞선 24대 학습, 뒤 24대 검증)\n", + "# ---------------------------------------------------------------------------\n", + "BASE = [\"rms\", \"kurt\", \"crest\", \"p2p\", \"b50\", \"b100\", \"b150\", \"b165\", \"b200\", \"temp\", \"press\"]\n", + "ROLL = [\"rms\", \"kurt\", \"b150\", \"b165\", \"b200\", \"temp\", \"press\"]\n", + "\n", + "\n", + "def build_features(snap, W):\n", + " D = pd.concat([snap.reset_index(drop=True), extract(W)], axis=1)\n", + " D[\"temp_res\"] = D[\"temp\"] - (55 + 22 * D[\"load\"] + 0.5 * D[\"ambient\"]) # 부하·외기 대비 온도 잔차\n", + " D[\"press_res\"] = D[\"press\"] - (450 - 12 * D[\"load\"]) # 부하 대비 압력 잔차\n", + " D[\"rms_n\"] = D[\"rms\"] / (0.5 + D[\"load\"]) # 부하 정규화 RMS\n", + " D[\"hf_ratio\"] = (D[\"b150\"] + D[\"b165\"] + D[\"b200\"]) / (D[\"b50\"] + 1e-6) # 고주파/기본주파 비\n", + " g = D.groupby(\"unit\")\n", + " for c in ROLL:\n", + " D[c + \"_m3\"] = g[c].transform(lambda s: s.rolling(3, min_periods=1).mean())\n", + " for c in (\"rms_n\", \"temp_res\", \"press_res\"):\n", + " D[c + \"_d\"] = g[c].diff().fillna(0)\n", + " return D\n", + "\n", + "\n", + "DERIVED = [\"temp_res\", \"press_res\", \"rms_n\", \"hf_ratio\"] + [c + \"_m3\" for c in ROLL] + [\"rms_n_d\", \"temp_res_d\", \"press_res_d\"]\n", + "FEATS = BASE + DERIVED\n", + "AE_COLS = [\"rms_n\", \"kurt\", \"b150\", \"b165\", \"b200\", \"hf_ratio\", \"temp_res\", \"press_res\"]\n", + "\n", + "\n", + "def split_units(units, n_train=24):\n", + " return list(units[\"unit\"][:n_train]), list(units[\"unit\"][n_train:])\n", + "\n", + "\n", + "class AE:\n", + " \"\"\"오토인코더(입력 8 -> 10 -> 4 -> 10 -> 8): 정상 자료만 재구성하도록 학습하고, 재구성 오차를 이상 점수로 쓴다.\"\"\"\n", + "\n", + " def __init__(self, cols=AE_COLS, q=0.99, seed=0):\n", + " self.cols, self.q, self.seed = cols, q, seed\n", + "\n", + " def fit(self, healthy):\n", + " self.sc = StandardScaler().fit(healthy[self.cols])\n", + " Z = self.sc.transform(healthy[self.cols])\n", + " self.net = MLPRegressor(hidden_layer_sizes=(10, 4, 10), max_iter=800, random_state=self.seed).fit(Z, Z)\n", + " self.thr = float(np.quantile(self.err(healthy), self.q))\n", + " return self\n", + "\n", + " def contrib(self, d):\n", + " Z = self.sc.transform(d[self.cols])\n", + " return (Z - self.net.predict(Z)) ** 2 # 입력 항목별 재구성 오차(이상 기여도)\n", + "\n", + " def err(self, d):\n", + " return self.contrib(d).mean(axis=1)\n", + "\n", + "\n", + "def latch(d, e, thr, k=2):\n", + " \"\"\"같은 개체에서 이상 점수가 k일 연속 기준을 넘으면 경보를 켜고 유지(래치)한다.\"\"\"\n", + " out, e = np.zeros(len(d), bool), np.asarray(e)\n", + " for idx in d.reset_index(drop=True).groupby(\"unit\").indices.values():\n", + " run, on = 0, False\n", + " for i in idx:\n", + " run = run + 1 if e[i] > thr else 0\n", + " on = on or run >= k\n", + " out[i] = on\n", + " return out\n", + "\n", + "CONFIG = {\"q\": 0.995, \"k\": 1, \"relax\": 0.3, \"retrain_tolerance\": 0.02, \"approve_rul\": 90, \"spare_horizon\": 60} # 미션 9 재보정 결과\n", + "OPS_GUIDE = { # docs/mnt04_operations_guide.md 요약\n", + " \"1. 목적과 범위\": \"엔진·구동계 센서로 이상 징후와 잔존수명을 보조 판단. 입고·정비 결정은 검사관.\",\n", + " \"2. 입력 데이터\": \"일 1회 진동 파형·온도·압력·부하. 실제 장비 자료 대신 가상 식별코드 사용.\",\n", + " \"3. 일일 운용 절차\": \"수집 -> 검증·격리 -> 특징 추출 -> 이상 점수 -> 경보 -> 입고 제안(대기) -> 검사관 결정 -> 결과 기록.\",\n", + " \"4. 승인(HITL) 기준\": \"조기 입고 제안은 검사관 승인 후 확정, 무응답 시 기본 계획(정기 교체) 유지, 사유 기록.\",\n", + " \"5. 재학습 절차\": \"분기 1회 신규 정상·고장 실적으로 도전자 학습, 검증 F1이 나빠지지 않을 때만 교체.\",\n", + " \"6. 장애 대응\": \"모델 오류 시 정기 교체 주기 기준으로 대체, 손상 센서 자료는 수동 확인 목록으로.\",\n", + " \"7. 연동 인터페이스\": \"국방 군수 정보체계와 정비 이력·수리부속 소모 표준 필드(장비코드·분류군·일시)를 CSV/API로 교환하는 안을 차기 과제로 둔다.\",\n", + "}\n", + "F10 = FEATS + [\"day\", \"hf_max\", \"temp_res_max\"]\n", + "\n", + "\n", + "def prep(units, snap, W):\n", + " \"\"\"검증: 파형 결측·고정·포화, 값 범위 이탈 측정은 격리하고 나머지로 특징표를 만든다.\"\"\"\n", + " bad = np.isnan(W).any(axis=1) | (np.nanstd(W, axis=1) < 1e-6) | (np.abs(np.nan_to_num(W)).max(axis=1) > 12) | ~snap[\"temp\"].between(20, 160).values\n", + " ok = ~bad\n", + " D = build_features(snap[ok].reset_index(drop=True), W[ok])\n", + " D[\"hf_max\"] = D.groupby(\"unit\")[\"hf_ratio\"].cummax(); D[\"temp_res_max\"] = D.groupby(\"unit\")[\"temp_res\"].cummax()\n", + " return D, int(bad.sum())\n", + "\n", + "\n", + "def alarms(D, e, thr, cfg=CONFIG):\n", + " over = np.asarray(e) > thr * np.where(D[\"load\"].values > 0.95, 1 + cfg[\"relax\"], 1.0)\n", + " out = np.zeros(len(D), bool)\n", + " for idx in D.reset_index(drop=True).groupby(\"unit\").indices.values():\n", + " run = 0\n", + " for i in idx:\n", + " run = run + 1 if over[i] else 0\n", + " out[i] = run >= cfg[\"k\"]\n", + " return out\n", + "\n", + "\n", + "def f1(D, al):\n", + " y = (D[\"deg\"] >= 0.1).values\n", + " tp, fp, fn = (al & y).sum(), (al & ~y).sum(), (~al & y).sum()\n", + " return float(2 * tp / (2 * tp + fp + fn))\n", + "\n", + "\n", + "def retrain(champion, D_old, D_new):\n", + " \"\"\"챔피언 AE(지난달 자료) vs 도전자 AE(지난달+이번 달 학습 개체) -> 이번 달 검증 개체 F1 비교.\"\"\"\n", + " new_tr, new_va = D_new[D_new[\"unit\"].isin(list(D_new[\"unit\"].unique())[:24])], D_new[D_new[\"unit\"].isin(list(D_new[\"unit\"].unique())[24:])]\n", + " ch = AE(seed=1).fit(pd.concat([D_old[D_old[\"deg\"] == 0], new_tr[new_tr[\"deg\"] == 0]]))\n", + " thr_c = float(np.quantile(champion.err(D_old[D_old[\"deg\"] == 0]), CONFIG[\"q\"]))\n", + " thr_n = float(np.quantile(ch.err(pd.concat([D_old[D_old[\"deg\"] == 0], new_tr[new_tr[\"deg\"] == 0]])), CONFIG[\"q\"]))\n", + " f_old, f_new = f1(new_va, alarms(new_va, champion.err(new_va), thr_c)), f1(new_va, alarms(new_va, ch.err(new_va), thr_n))\n", + " swap = f_new >= f_old - CONFIG[\"retrain_tolerance\"]\n", + " return (ch if swap else champion), (thr_n if swap else thr_c), {\"챔피언 F1\": round(f_old, 3), \"도전자 F1\": round(f_new, 3), \"교체\": bool(swap)}\n", + "\n", + "\n", + "def predict_safe(model, D):\n", + " try:\n", + " return np.maximum(model.predict(D[F10]), 0), \"모델\"\n", + " except Exception:\n", + " return np.maximum(90 - D[\"day\"].values, 0).astype(float), \"정기 주기 기준 대체\"\n", + "\n", + "\n", + "# ---------------- 1. 통합 실행 ----------------\n", + "t_start = time.perf_counter()\n", + "Dold, _ = prep(*make_runs(seed=11)) # 지난달 실적\n", + "Dnew, _ = prep(*make_runs(seed=1)) # 이번 달 실적\n", + "u3, s3, w3 = make_runs(seed=21) # 다음 달 운용(신규 측정)\n", + "w3[[100, 2000], 20:50] = np.nan # 손상 측정 2건 주입(결측 패킷)\n", + "Dop, n_bad = prep(u3, s3, w3)\n", + "champion = AE().fit(Dold[Dold[\"deg\"] == 0])\n", + "champ_thr = float(np.quantile(champion.err(Dold[Dold[\"deg\"] == 0]), CONFIG[\"q\"]))\n", + "model_ae, thr, rt = retrain(champion, Dold, Dnew)\n", + "print(f\"[교육용가상자료] 재학습: {rt} -> {'도전자 교체' if rt['교체'] else '챔피언 유지'}\")\n", + "gbr = GradientBoostingRegressor(n_estimators=100, max_depth=3, learning_rate=0.1, subsample=0.8, random_state=0).fit(Dnew[F10], np.minimum(Dnew[\"rul\"], 120))\n", + "e = model_ae.err(Dop)\n", + "Dop[\"alarm\"] = alarms(Dop, e, thr)\n", + "Dop[\"rul_hat\"], src = predict_safe(gbr, Dop)\n", + "Dop[\"score\"] = e / thr\n", + "sec = time.perf_counter() - t_start\n", + "print(f\"운용 측정 {len(s3)}건: 처리 {len(Dop)}건, 격리 {n_bad}건, 경보 측정 {int(Dop['alarm'].sum())}건, RUL 출처 '{src}', 전체 실행 {sec:.1f}초\")\n", + "\n", + "# 입고 제안(대기)과 수리부속 소요 예고: 경보 개체의 마지막 측정 기준\n", + "PART = {\"b165\": \"주 베어링 세트\", \"kurt\": \"주 베어링 세트\", \"b150\": \"구동 기어 세트\", \"b200\": \"오일 펌프\", \"press_res\": \"오일 펌프\", \"temp_res\": \"냉각 팬 모터\"}\n", + "first = Dop[Dop[\"alarm\"]].groupby(\"unit\").first()\n", + "cols = model_ae.cols\n", + "C = model_ae.contrib(Dop)\n", + "prop = []\n", + "for u, r in first.iterrows():\n", + " i = Dop.index[(Dop[\"unit\"] == u) & (Dop[\"day\"] == r[\"day\"])][0]\n", + " prop.append({\"unit\": u, \"day\": int(r[\"day\"]), \"rul_hat\": float(r[\"rul_hat\"]), \"rul\": int(r[\"rul\"]), \"part\": PART.get(cols[int(np.argmax(C[i]))], \"미분류\"),\n", + " \"mode\": r[\"mode\"], \"status\": \"PENDING\" if r[\"rul_hat\"] <= CONFIG[\"approve_rul\"] else \"관찰\"})\n", + "P = pd.DataFrame(prop)\n", + "P[\"true_part\"] = P[\"mode\"].map({m: MODES[m][2] for m in MODES})\n", + "# 수리부속 소요 예고: 예측 잔존 30일 이내로 내려온 개체의 의심 부품\n", + "need = P[P[\"rul_hat\"] <= CONFIG[\"spare_horizon\"]]\n", + "\n", + "# ---------------- 2. 자체 점검 ----------------\n", + "_, bad_src = predict_safe(None, Dop.head(3))\n", + "y = (Dop[\"deg\"] >= 0.1).values\n", + "checks = {\n", + " \"측정 유실 0건\": len(Dop) + n_bad == len(s3),\n", + " \"손상 측정 2건 격리\": n_bad == 2,\n", + " \"재학습 파이프라인 실행\": \"교체\" in rt,\n", + " \"RUL 예측값 음수 없음\": bool((Dop[\"rul_hat\"] >= 0).all()),\n", + " \"모델 장애 시 정기 주기 대체\": bad_src == \"정기 주기 기준 대체\",\n", + " \"열화 개체 미탐 0대\": bool(Dop.groupby(\"unit\").apply(lambda g: (g[\"alarm\"] & (g[\"deg\"] >= 0.1)).any() or not (g[\"deg\"] >= 0.1).any()).all()),\n", + " \"경보 개체 모두 제안 또는 관찰 분류\": len(P) == int(Dop[Dop[\"alarm\"]][\"unit\"].nunique()),\n", + " \"자동 승인 0건(승인 대상은 대기)\": bool(P[\"status\"].isin([\"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", + "fig, ax = plt.subplots(1, 2, figsize=(11, 4))\n", + "for u in list(P[\"unit\"])[:3]:\n", + " g = Dop[Dop[\"unit\"] == u]\n", + " ax[0].plot(g[\"day\"], g[\"score\"], label=u)\n", + "ax[0].axhline(1.0, ls=\"--\", color=\"k\"); ax[0].set_yscale(\"log\"); ax[0].set_title(\"Anomaly score (x threshold)\"); ax[0].set_xlabel(\"day\"); ax[0].legend()\n", + "ax[1].scatter(P[\"rul\"], P[\"rul_hat\"]); ax[1].plot([0, 120], [0, 120], \"k--\"); ax[1].set_title(\"RUL at first alarm: actual vs predicted\"); ax[1].set_xlabel(\"actual (days)\")\n", + "plt.tight_layout(); plt.show()\n", + "\n", + "lead = P[\"rul\"]\n", + "print(\"\\n[정비 담당 장교 브리핑 요약] [교육용가상자료]\")\n", + "print(f\"1. 운용 개체 {len(u3)}대 측정 {len(s3)}건 중 손상 {n_bad}건 격리, 열화 개체 {len(P)}대에서 경보 발령, 고장 전 확보 시간 중앙값 {lead.median():.0f}일(최소 {lead.min()}일)\")\n", + "print(f\"2. 이상 감지 모델: {'신규 실적으로 교체' if rt['교체'] else '기존 유지'}(검증 F1 {max(rt['챔피언 F1'], rt['도전자 F1']):.2f}), 재보정 임계 적용으로 정상 개체 오경보 {int((Dop['alarm'] & (Dop['deg'] == 0)).sum())}건\")\n", + "print(f\"3. 조기 입고 제안 {int((P['status'] == 'PENDING').sum())}건은 검사관 승인 대기(자동 승인 0건), 예측 잔존 {CONFIG['approve_rul']}일 초과 {int((P['status'] == '관찰').sum())}건은 관찰 유지\")\n", + "print(\"4. 수리부속 소요 예고(예측 잔존 \" + str(CONFIG[\"spare_horizon\"]) + \"일 이내 개체의 의심 부품): \" + \", \".join(f\"{k} {v}건\" for k, v in need[\"part\"].value_counts().items())\n", + " + f\" (첫 경보 시점 의심 부품 정확도 {(P['part'] == P['true_part']).mean():.0%})\")\n", + "print(f\"5. RUL 예측: 경보 시점 평균 절대오차 {np.mean(np.abs(P['rul_hat'] - np.minimum(P['rul'], 120))):.1f}일\")\n", + "print(\"6. 건의: 구동 기어·주 베어링 세트 우선 확보(소모 비용 상위), 국방 군수 정보체계와 정비 이력·수리부속 소모 표준 필드 연동 인터페이스 정의\")\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 10 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + "[교육용가상자료] 재학습: {'챔피언 F1': 0.868, '도전자 F1': 0.837, '교체': False} -> 챔피언 유지\n", + "운용 측정 7695건: 처리 7693건, 격리 2건, 경보 측정 2562건, RUL 출처 '모델', 전체 실행 20.6초\n", + "\n", + "자체 점검 8/8 통과, 자동 재학습 파이프라인 정상 실행\n", + "운영 문서 7개 장 작성률 100% (목표 100%: 달성) - 목적과 범위, 입력 데이터, 일일 운용 절차, 승인(HITL) 기준, 재학습 절차, 장애 대응, 연동 인터페이스\n", + "\n", + "[정비 담당 장교 브리핑 요약] [교육용가상자료]\n", + "1. 운용 개체 48대 측정 7695건 중 손상 2건 격리, 열화 개체 48대에서 경보 발령, 고장 전 확보 시간 중앙값 66일(최소 37일)\n", + "2. 이상 감지 모델: 기존 유지(검증 F1 0.87), 재보정 임계 적용으로 정상 개체 오경보 23건\n", + "3. 조기 입고 제안 31건은 검사관 승인 대기(자동 승인 0건), 예측 잔존 90일 초과 17건은 관찰 유지\n", + "4. 수리부속 소요 예고(예측 잔존 60일 이내 개체의 의심 부품): 구동 기어 세트 6건, 주 베어링 세트 3건, 냉각 팬 모터 3건, 오일 펌프 2건 (첫 경보 시점 의심 부품 정확도 52%)\n", + "5. RUL 예측: 경보 시점 평균 절대오차 10.0일\n", + "6. 건의: 구동 기어·주 베어링 세트 우선 확보(소모 비용 상위), 국방 군수 정보체계와 정비 이력·수리부속 소모 표준 필드 연동 인터페이스 정의\n", + "```\n", + "
\n" + ] + } + ], + "metadata": { + "colab": { + "provenance": [] + }, + "kernelspec": { + "display_name": "Python 3", + "name": "python3" + }, + "language_info": { + "name": "python" + } + }, + "nbformat": 4, + "nbformat_minor": 0 +} \ No newline at end of file diff --git a/uploads/MNT04/MNT04_student.ipynb b/uploads/MNT04/MNT04_student.ipynb new file mode 100644 index 0000000..4d26b6f --- /dev/null +++ b/uploads/MNT04/MNT04_student.ipynb @@ -0,0 +1,2901 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# 🎯 [MNT04] 고장실적·수리부속 소모 기반 PBL 품목관리\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/MNT04/MNT04_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", + "항공·기갑 장비의 엔진과 구동계는 고장이 나기 전에 진동, 온도, 압력에 미세한 변화가 먼저 나타나지만, 고정 임계값만으로는 고부하 운용일을 이상으로 오인하거나 초기 열화를 놓쳐 수리부속을 제때 확보하지 못한다. 본 프로젝트는 고장 실적과 수리부속 소모를 연결해 정비와 부품 관리의 연계 문제를 학습하는 것을 목표로, 교육용 가상 기동체 48대의 센서 시계열과 부품 소모를 코드로 만들어 분석한다. 센서 데이터셋 정의, 잡음 제거와 FFT 특징량 추출, 임계값 Baseline과 부품 소모 파레토 분석, 오토인코더 기반 미세 변화 감지와 파생 피처 모델, 잔존수명(RUL) 예측과 이상 신뢰도 점수, 설명가능한 경보 화면, 검사관 승인(HITL) 기반 조기 입고 시뮬레이터, 대용량 스트림 스트레스 시험, 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. 헬기/기갑 엔진 및 구동계 진동, 온도, 압력 센서 시계열 데이터셋 정의\n", + "**소요시간**: 약 1.5시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "헬기/기갑 엔진 진동, 온도, 압력 센서 시계열 데이터셋을 정의한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **헬기/기갑 엔진 및 구동계의 진동, 온도, 압력 센서 시계열과 고장·수리부속 소모 실적의 데이터셋 스키마, 데이터 딕셔너리, 업무 프로세스 맵을 정의한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **센서 수집부터 전처리, 특징 추출, 이상 감지, RUL 예측, 검사관 승인, 정비·부품 소모, 사후검토까지 업무 흐름을 단계별로 정리하고, 센서·특징·고장·부품 묶음으로 피처 20개 이상 정의하고 검증하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `docs/mnt04_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": [ + "\"\"\"헬기/기갑 엔진 및 구동계의 진동·온도·압력 센서 시계열, 고장 실적, 수리부속 소모 기록의 데이터셋 스키마·데이터 딕셔너리(피처 20개 이상)와 예지정비·PBL 품목관리 업무 프로세스 맵을 정의하고, 가상 센서 자료로 검증한다.\"\"\"\n", + "import json\n", + "import re\n", + "import numpy as np\n", + "import pandas as pd\n", + "from scipy.stats import kurtosis\n", + "\n", + "# ---------------------------------------------------------------------------\n", + "# [교육용가상자료] 가상 기동체 48대의 엔진·구동계 진동·온도·압력 센서 자료 생성기 (실제 장비·부대·정비 실적 없음)\n", + "# 개체마다 정상 -> 열화 -> 고장(수명종료)까지 하루 1회 측정(진동 파형 256표본 + 온도 + 압력 + 부하)을 만든다.\n", + "# 열화 유형 4가지: bearing(베어링), gear(기어), oil_pump(오일 펌프), cooling(냉각 팬 모터)\n", + "# ---------------------------------------------------------------------------\n", + "FS, NS = 1024, 256 # 가상 표본화 주파수(Hz), 파형 1개당 표본 수\n", + "TT = np.arange(NS) / FS\n", + "MODES = { # 열화 유형: (발생 확률, 평균 수명(일), 교체 수리부속, 가상 단가지수)\n", + " \"bearing\": (0.40, 160, \"주 베어링 세트\", 6.0), \"gear\": (0.25, 175, \"구동 기어 세트\", 9.0),\n", + " \"oil_pump\": (0.20, 150, \"오일 펌프\", 4.0), \"cooling\": (0.15, 140, \"냉각 팬 모터\", 3.0)}\n", + "KIT_COST = 1.0 # 모든 정비에 함께 소모되는 씰·필터 키트 단가지수\n", + "BANDS = {\"b50\": (40, 60), \"b100\": (90, 110), \"b150\": (140, 156), \"b165\": (156, 176), \"b200\": (190, 215)}\n", + "\n", + "\n", + "def make_runs(n=48, seed=1):\n", + " \"\"\"반환: units(개체 정보), snap(일별 측정 요약), W(진동 파형 [측정 수, 256])\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " names = list(MODES)\n", + " mode = rng.choice(names, n, p=[MODES[m][0] for m in names])\n", + " life = np.clip(np.array([rng.normal(MODES[m][1], 18) for m in mode]), 90, 230).astype(int)\n", + " start = np.sort(rng.integers(0, 240, n))\n", + " units = pd.DataFrame({\"unit\": [f\"U{i:02d}\" for i in range(n)], \"mode\": mode, \"life\": life, \"start\": start, \"base_load\": rng.uniform(0.4, 0.75, n)})\n", + " rows = []\n", + " for u in units.itertuples():\n", + " d = np.arange(u.life + 1)\n", + " onset = 0.45 * u.life\n", + " deg = np.clip((d - onset) / (u.life - onset), 0, 1) ** 1.5\n", + " load = np.clip(u.base_load + rng.normal(0, 0.06, len(d)), 0.2, 1.0)\n", + " hi = rng.random(len(d)) < 0.07 # 고부하 운용일(정상이지만 진동·온도 상승)\n", + " load = np.where(hi, rng.uniform(0.95, 1.1, len(d)), load)\n", + " rows.append(pd.DataFrame({\"unit\": u.unit, \"day\": d, \"cal\": u.start + d, \"rul\": u.life - d, \"deg\": deg, \"load\": load, \"hi_load\": hi, \"mode\": u.mode}))\n", + " snap = pd.concat(rows, ignore_index=True)\n", + " S = len(snap)\n", + " snap[\"ambient\"] = 15 + 12 * np.sin(2 * np.pi * snap[\"cal\"] / 365) + rng.normal(0, 2, S)\n", + " dg, ld, md = snap[\"deg\"].values, snap[\"load\"].values, snap[\"mode\"].values\n", + " isb, isg, iso, isc = (md == \"bearing\"), (md == \"gear\"), (md == \"oil_pump\"), (md == \"cooling\")\n", + " snap[\"temp\"] = 55 + 22 * ld + 0.5 * snap[\"ambient\"] + 9 * dg * iso + 26 * dg * isc + 3 * dg * (isb | isg) + rng.normal(0, 1.5, S)\n", + " snap[\"press\"] = 450 - 12 * ld - 85 * dg * iso - 15 * dg * isc + rng.normal(0, 6, S)\n", + " ph = rng.uniform(0, 2 * np.pi, (S, 3))\n", + " a1, a2 = 1.0 * (0.5 + ld), 0.4 * (0.5 + ld)\n", + " W = (a1[:, None] * np.sin(2 * np.pi * 50 * TT + ph[:, :1]) + a2[:, None] * np.sin(2 * np.pi * 100 * TT + ph[:, 1:2])\n", + " + rng.normal(0, 0.35, (S, NS)))\n", + " W += (0.9 * dg * isb)[:, None] * np.sin(2 * np.pi * 165 * TT + ph[:, 2:3])\n", + " W += (0.8 * dg * isg)[:, None] * (np.sin(2 * np.pi * 150 * TT + ph[:, 2:3]) + 0.8 * np.sin(2 * np.pi * 100 * TT))\n", + " W += (0.5 * dg * iso)[:, None] * np.sin(2 * np.pi * 200 * TT + ph[:, 2:3])\n", + " k = rng.poisson(10 * dg * isb) # 베어링 열화: 충격성 펄스(첨도 상승)\n", + " pos, amp = rng.integers(0, NS, (S, 30)), rng.normal(0, 3.0, (S, 30)) * dg[:, None]\n", + " amp = amp * (np.arange(30)[None, :] < k[:, None])\n", + " np.add.at(W, (np.repeat(np.arange(S), 30), pos.ravel()), amp.ravel())\n", + " return units, snap, W\n", + "\n", + "\n", + "def extract(W):\n", + " \"\"\"진동 파형 -> 특징량: RMS, 첨도, 파고율, 최대-최소, FFT 대역 에너지(50/100/150/165/200Hz 부근)\"\"\"\n", + " # [TODO : 학생 실습 1] 헬기/기갑 엔진 및 구동계 진동, 온도, 압력 센서 시계열 데이터셋 정의 관련 핵심 로직을 구현하세요.\n", + " # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n", + " # <<< 여기에 코드를 작성하세요 >>>\n", + " pass # 작성 후 pass를 제거하세요.\n", + "PROCESS = [(\"센서 수집\", \"개체 ID, 측정일, 진동 파형, 온도, 압력, 부하\"), (\"전처리\", \"잡음 제거, 단위·표기 통일, 결측 보정\"),\n", + " (\"특징 추출\", \"RMS, 첨도, 파고율, FFT 대역 에너지\"), (\"이상 감지\", \"이상 점수, 경보 단계\"),\n", + " (\"잔존수명(RUL) 예측\", \"고장 임박 시점, 신뢰 구간\"), (\"검사관 승인\", \"조기 입고 제안, 승인·반려 사유\"),\n", + " (\"정비·부품 소모\", \"교체 부품, 수량, 정비 소요시간\"), (\"고장 실적 연결\", \"정비 건 ID로 센서 이력과 고장 실적 연결\"),\n", + " (\"부품 소요·가동률 집계\", \"품목별 소모, 가용도(PBL 지표)\"), (\"사후검토(AAR)\", \"오경보 원인, 임계 파라미터 조정\")]\n", + "\n", + "# 2) 스키마(데이터 딕셔너리): 그룹 / 필드 / 자료형 / 규칙 / 설명\n", + "S = {\n", + " \"sensor\": {\n", + " \"unit_id\": (str, {\"pattern\": r\"^U\\d{2}$\"}, \"개체 ID(가상 기동체)\"),\n", + " \"day\": (int, {\"min\": 0, \"max\": 260}, \"운용 경과일(측정일)\"),\n", + " \"vib_rms\": (float, {\"min\": 0, \"max\": 10}, \"진동 RMS(가상 단위)\"),\n", + " \"vib_kurt\": (float, {\"min\": 1, \"max\": 30}, \"진동 첨도(충격성 지표)\"),\n", + " \"temp\": (float, {\"min\": -30, \"max\": 150}, \"엔진 온도(섭씨)\"),\n", + " \"press\": (float, {\"min\": 0, \"max\": 700}, \"오일 압력(kPa)\"),\n", + " \"load\": (float, {\"min\": 0, \"max\": 1.2}, \"운용 부하율\"),\n", + " },\n", + " \"feature\": {\n", + " \"band_50\": (float, {\"min\": 0}, \"50Hz 부근 FFT 대역 에너지\"),\n", + " \"band_165\": (float, {\"min\": 0}, \"165Hz 부근(베어링 결함 대역) 에너지\"),\n", + " \"crest\": (float, {\"min\": 1, \"max\": 30}, \"파고율(최대값/RMS)\"),\n", + " \"hf_ratio\": (float, {\"min\": 0}, \"고주파/기본주파 에너지 비\"),\n", + " \"temp_res\": (float, {\"min\": -60, \"max\": 80}, \"부하·외기 대비 온도 잔차\"),\n", + " },\n", + " \"failure\": {\n", + " \"event_id\": (str, {\"pattern\": r\"^F\\d{3}$\"}, \"고장·정비 건 ID(센서 이력과 연결하는 키)\"),\n", + " \"mode\": (str, {\"enum\": list(MODES)}, \"열화(고장) 유형\"),\n", + " \"life\": (int, {\"min\": 60, \"max\": 260}, \"고장까지 운용일수\"),\n", + " \"unplanned\": (bool, {}, \"비계획 정비 여부\"),\n", + " },\n", + " \"parts\": {\n", + " \"part_name\": (str, {\"enum\": [v[2] for v in MODES.values()]}, \"소모 수리부속\"),\n", + " \"qty\": (int, {\"min\": 1, \"max\": 10}, \"소모 수량\"),\n", + " \"unit_cost\": (float, {\"min\": 0, \"max\": 50}, \"가상 단가지수\"),\n", + " },\n", + " \"target\": {\n", + " \"rul\": (float, {\"min\": 0, \"max\": 260}, \"잔존수명(일) - 예측대상 1\"),\n", + " \"anomaly_score\": (float, {\"min\": 0}, \"이상 점수(재구성 오차) - 예측대상 2\"),\n", + " \"availability\": (float, {\"min\": 0, \"max\": 1}, \"가용도(가동일/전체일) - PBL 성과 지표\"),\n", + " },\n", + "}\n", + "\n", + "\n", + "def check(name, v, typ, rule):\n", + " # [TODO : 학생 실습 1] 헬기/기갑 엔진 및 구동계 진동, 온도, 압력 센서 시계열 데이터셋 정의 관련 핵심 로직을 구현하세요.\n", + " # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n", + " # <<< 여기에 코드를 작성하세요 >>>\n", + " pass # 작성 후 pass를 제거하세요.\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", + "units, snap, W = make_runs()\n", + "F = extract(W)\n", + "snap = pd.concat([snap, F], axis=1)\n", + "snap[\"temp_res\"] = snap[\"temp\"] - (55 + 22 * snap[\"load\"] + 0.5 * snap[\"ambient\"])\n", + "snap[\"hf_ratio\"] = (snap[\"b150\"] + snap[\"b165\"] + snap[\"b200\"]) / (snap[\"b50\"] + 1e-6)\n", + "ui = units.set_index(\"unit\")\n", + "recs = []\n", + "for i, r in enumerate(snap.sample(20, random_state=0).itertuples()):\n", + " m = r.mode\n", + " recs.append({\n", + " \"sensor\": {\"unit_id\": r.unit, \"day\": int(r.day), \"vib_rms\": float(round(r.rms, 3)), \"vib_kurt\": float(round(r.kurt, 2)),\n", + " \"temp\": float(round(r.temp, 1)), \"press\": float(round(r.press, 1)), \"load\": float(round(r.load, 2))},\n", + " \"feature\": {\"band_50\": float(round(r.b50, 4)), \"band_165\": float(round(r.b165, 4)), \"crest\": float(round(r.crest, 2)),\n", + " \"hf_ratio\": float(round(r.hf_ratio, 3)), \"temp_res\": float(round(r.temp_res, 2))},\n", + " \"failure\": {\"event_id\": f\"F{int(r.unit[1:]):03d}\", \"mode\": m, \"life\": int(ui.loc[r.unit, \"life\"]), \"unplanned\": bool(r.rul < 5)},\n", + " \"parts\": {\"part_name\": MODES[m][2], \"qty\": 1, \"unit_cost\": float(MODES[m][3])},\n", + " \"target\": {\"rul\": float(r.rul), \"anomaly_score\": float(round(r.deg, 3)), \"availability\": 0.92}})\n", + "ok = sum(not validate(r) for r in recs)\n", + "print(f\"가상 센서 자료 {len(snap)}건(개체 {len(units)}대), 표본 {len(recs)}건 중 검증 통과 {ok}건, 오류 {len(recs) - ok}건\")\n", + "print(f\"전체 파형 {W.shape[0]}개 x {W.shape[1]}표본, 온도·압력 결측 {int(snap[['temp', 'press']].isna().sum().sum())}건\")\n", + "\n", + "# 4) 오류 주입 시험\n", + "bad = json.loads(json.dumps(recs[0]))\n", + "bad[\"sensor\"][\"unit_id\"] = \"ENGINE-3\" # ID 형식 오류\n", + "bad[\"sensor\"][\"temp\"] = 480.0 # 센서 범위 초과(단위 혼동 의심)\n", + "bad[\"failure\"][\"mode\"] = \"turbine\" # 정의되지 않은 열화 유형\n", + "del bad[\"parts\"][\"qty\"] # 필수 필드 누락\n", + "bad[\"target\"][\"rul\"] = \"30일\" # 자료형 오류\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:12s}: {d}\")\n", + "rows = [(g, k, t.__name__, d) for g, fl in S.items() for k, (t, r, d) in fl.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" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 01 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + "가상 센서 자료 7733건(개체 48대), 표본 20건 중 검증 통과 20건, 오류 0건\n", + "전체 파형 7733개 x 256표본, 온도·압력 결측 0건\n", + "\n", + "[오류 주입 결과]\n", + " - sensor.unit_id: 형식 불일치 'ENGINE-3'\n", + " - sensor.temp: 최대값 150 초과\n", + " - failure.mode: 허용되지 않은 값 'turbine'\n", + " - parts.qty: 필수 필드 누락\n", + " - target.rul: 자료형 오류(str)\n", + "\n", + "[업무 프로세스 맵]\n", + "1.센서 수집 -> 2.전처리 -> 3.특징 추출 -> 4.이상 감지 -> 5.잔존수명(RUL) 예측 -> 6.검사관 승인 -> 7.정비·부품 소모 -> 8.고장 실적 연결 -> 9.부품 소요·가동률 집계 -> 10.사후검토(AAR)\n", + " 센서 수집 : 개체 ID, 측정일, 진동 파형, 온도, 압력, 부하\n", + " 전처리 : 잡음 제거, 단위·표기 통일, 결측 보정\n", + " 특징 추출 : RMS, 첨도, 파고율, FFT 대역 에너지\n", + " 이상 감지 : 이상 점수, 경보 단계\n", + " 잔존수명(RUL) 예측: 고장 임박 시점, 신뢰 구간\n", + " 검사관 승인 : 조기 입고 제안, 승인·반려 사유\n", + " 정비·부품 소모 : 교체 부품, 수량, 정비 소요시간\n", + " 고장 실적 연결 : 정비 건 ID로 센서 이력과 고장 실적 연결\n", + " 부품 소요·가동률 집계: 품목별 소모, 가용도(PBL 지표)\n", + " 사후검토(AAR) : 오경보 원인, 임계 파라미터 조정\n", + "\n", + "[데이터 딕셔너리] 피처 22개 (목표 20개 이상: 달성)\n", + " 그룹 필드 자료형 설명\n", + " sensor unit_id str 개체 ID(가상 기동체)\n", + " sensor day int 운용 경과일(측정일)\n", + " sensor vib_rms float 진동 RMS(가상 단위)\n", + " sensor vib_kurt float 진동 첨도(충격성 지표)\n", + " sensor temp float 엔진 온도(섭씨)\n", + " sensor press float 오일 압력(kPa)\n", + " sensor load float 운용 부하율\n", + "feature band_50 float 50Hz 부근 FFT 대역 에너지\n", + "feature band_165 float 165Hz 부근(베어링 결함 대역) 에너지\n", + "feature crest float 파고율(최대값/RMS)\n", + "feature hf_ratio float 고주파/기본주파 에너지 비\n", + "feature temp_res float 부하·외기 대비 온도 잔차\n", + "failure event_id str 고장·정비 건 ID(센서 이력과 연결하는 키)\n", + "failure mode str 열화(고장) 유형\n", + "failure life int 고장까지 운용일수\n", + "... (이하 생략)\n", + "```\n", + "
\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 02. 고주파 센서 신호 잡음 제거, FFT 변환 및 주요 특징량(RMS, Kurtosis) 전처리 구현\n", + "**소요시간**: 약 2.0시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "센서 잡음 제거, FFT 변환 및 주요 특징량(RMS 등) 추출 전처리 모듈을 구현한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **고주파 센서 신호의 잡음을 제거하고 FFT 변환으로 RMS, 첨도 등 주요 특징량을 추출하는 전처리 모듈을 구현한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **표기·단위(온도 F/C, 압력 psi/kPa) 통일, 중복 제거, 불가능값 결측 처리, 파형 단발 스파이크의 중앙값 필터 제거, 간섭 잡음 창의 저역통과 필터, KNN 결측 보정 등 정제 규칙 10종을 적용 수행하고 검증하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `src/etl/mnt04_data_cleansing.py 및 정제 품질 보고서` 형태의 결과물을 완성한다.\n", + "\n", + "### 🔍 평가 및 검증 기준 (PASS 조건)\n", + "#### 미션 판정\n", + "\n", + "**평가 구분:** 필수\n", + "\n", + "**핵심 평가영역:** 군수 특수 예외(비계획 정비, 긴급 불출 등) 데이터 보존의 타당성\n", + "\n", + "#### 미션 PASS 조건\n", + "\n", + "- 정량 기준: 데이터 정제 규칙 10종 적용, 데이터 정합성 검증 통과율 100%\n", + "\n", + "\n", + "- 진행 기준: ETL 파이프라인 구현 및 정제된 마스터 데이터셋 구축\n", + "\n", + "\n", + "- 검증 방법: 전후 데이터 통계량(기술통계) 비교 및 무결성 검증\n", + "\n", + "\n", + "#### 채점자 판정 메모\n", + "\n", + "> 단순 코드 실행 여부뿐만 아니라 출력된 수치와 근거 자료가 군 실무 요구사항에 부합하는지 면밀히 확인한다.\n", + "\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "\"\"\"표기 불일치·중복·단위 혼재·불가능값·결측·단발 스파이크가 섞인 가상 센서 원천 자료에 정제 규칙 10종(파형 스파이크 제거, 저역통과 잡음 제거, KNN 결측 보정 포함)을 적용하고, FFT 변환으로 RMS·첨도 등 주요 특징량을 추출한다. 고장 임박 시점의 긴급 점검 측정은 보존하며 정합성 검증 통과율과 정제 전후 통계를 확인한다.\"\"\"\n", + "import numpy as np\n", + "import pandas as pd\n", + "import matplotlib.pyplot as plt\n", + "from scipy.stats import kurtosis\n", + "from scipy.signal import medfilt, butter, sosfiltfilt\n", + "from sklearn.impute import KNNImputer\n", + "\n", + "# ---------------------------------------------------------------------------\n", + "# [교육용가상자료] 가상 기동체 48대의 엔진·구동계 진동·온도·압력 센서 자료 생성기 (실제 장비·부대·정비 실적 없음)\n", + "# 개체마다 정상 -> 열화 -> 고장(수명종료)까지 하루 1회 측정(진동 파형 256표본 + 온도 + 압력 + 부하)을 만든다.\n", + "# 열화 유형 4가지: bearing(베어링), gear(기어), oil_pump(오일 펌프), cooling(냉각 팬 모터)\n", + "# ---------------------------------------------------------------------------\n", + "FS, NS = 1024, 256 # 가상 표본화 주파수(Hz), 파형 1개당 표본 수\n", + "TT = np.arange(NS) / FS\n", + "MODES = { # 열화 유형: (발생 확률, 평균 수명(일), 교체 수리부속, 가상 단가지수)\n", + " \"bearing\": (0.40, 160, \"주 베어링 세트\", 6.0), \"gear\": (0.25, 175, \"구동 기어 세트\", 9.0),\n", + " \"oil_pump\": (0.20, 150, \"오일 펌프\", 4.0), \"cooling\": (0.15, 140, \"냉각 팬 모터\", 3.0)}\n", + "KIT_COST = 1.0 # 모든 정비에 함께 소모되는 씰·필터 키트 단가지수\n", + "BANDS = {\"b50\": (40, 60), \"b100\": (90, 110), \"b150\": (140, 156), \"b165\": (156, 176), \"b200\": (190, 215)}\n", + "\n", + "\n", + "def make_runs(n=48, seed=1):\n", + " \"\"\"반환: units(개체 정보), snap(일별 측정 요약), W(진동 파형 [측정 수, 256])\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " names = list(MODES)\n", + " mode = rng.choice(names, n, p=[MODES[m][0] for m in names])\n", + " life = np.clip(np.array([rng.normal(MODES[m][1], 18) for m in mode]), 90, 230).astype(int)\n", + " start = np.sort(rng.integers(0, 240, n))\n", + " units = pd.DataFrame({\"unit\": [f\"U{i:02d}\" for i in range(n)], \"mode\": mode, \"life\": life, \"start\": start, \"base_load\": rng.uniform(0.4, 0.75, n)})\n", + " rows = []\n", + " for u in units.itertuples():\n", + " d = np.arange(u.life + 1)\n", + " onset = 0.45 * u.life\n", + " deg = np.clip((d - onset) / (u.life - onset), 0, 1) ** 1.5\n", + " load = np.clip(u.base_load + rng.normal(0, 0.06, len(d)), 0.2, 1.0)\n", + " hi = rng.random(len(d)) < 0.07 # 고부하 운용일(정상이지만 진동·온도 상승)\n", + " load = np.where(hi, rng.uniform(0.95, 1.1, len(d)), load)\n", + " rows.append(pd.DataFrame({\"unit\": u.unit, \"day\": d, \"cal\": u.start + d, \"rul\": u.life - d, \"deg\": deg, \"load\": load, \"hi_load\": hi, \"mode\": u.mode}))\n", + " snap = pd.concat(rows, ignore_index=True)\n", + " S = len(snap)\n", + " snap[\"ambient\"] = 15 + 12 * np.sin(2 * np.pi * snap[\"cal\"] / 365) + rng.normal(0, 2, S)\n", + " dg, ld, md = snap[\"deg\"].values, snap[\"load\"].values, snap[\"mode\"].values\n", + " isb, isg, iso, isc = (md == \"bearing\"), (md == \"gear\"), (md == \"oil_pump\"), (md == \"cooling\")\n", + " snap[\"temp\"] = 55 + 22 * ld + 0.5 * snap[\"ambient\"] + 9 * dg * iso + 26 * dg * isc + 3 * dg * (isb | isg) + rng.normal(0, 1.5, S)\n", + " snap[\"press\"] = 450 - 12 * ld - 85 * dg * iso - 15 * dg * isc + rng.normal(0, 6, S)\n", + " ph = rng.uniform(0, 2 * np.pi, (S, 3))\n", + " a1, a2 = 1.0 * (0.5 + ld), 0.4 * (0.5 + ld)\n", + " W = (a1[:, None] * np.sin(2 * np.pi * 50 * TT + ph[:, :1]) + a2[:, None] * np.sin(2 * np.pi * 100 * TT + ph[:, 1:2])\n", + " + rng.normal(0, 0.35, (S, NS)))\n", + " W += (0.9 * dg * isb)[:, None] * np.sin(2 * np.pi * 165 * TT + ph[:, 2:3])\n", + " W += (0.8 * dg * isg)[:, None] * (np.sin(2 * np.pi * 150 * TT + ph[:, 2:3]) + 0.8 * np.sin(2 * np.pi * 100 * TT))\n", + " W += (0.5 * dg * iso)[:, None] * np.sin(2 * np.pi * 200 * TT + ph[:, 2:3])\n", + " k = rng.poisson(10 * dg * isb) # 베어링 열화: 충격성 펄스(첨도 상승)\n", + " pos, amp = rng.integers(0, NS, (S, 30)), rng.normal(0, 3.0, (S, 30)) * dg[:, None]\n", + " amp = amp * (np.arange(30)[None, :] < k[:, None])\n", + " np.add.at(W, (np.repeat(np.arange(S), 30), pos.ravel()), amp.ravel())\n", + " return units, snap, W\n", + "\n", + "\n", + "def extract(W):\n", + " \"\"\"진동 파형 -> 특징량: RMS, 첨도, 파고율, 최대-최소, FFT 대역 에너지(50/100/150/165/200Hz 부근)\"\"\"\n", + " # [TODO : 학생 실습 2] 고주파 센서 신호 잡음 제거, FFT 변환 및 주요 특징량(RMS, Kurtosis) 전처리 구현 관련 핵심 로직을 구현하세요.\n", + " # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n", + " # <<< 여기에 코드를 작성하세요 >>>\n", + " pass # 작성 후 pass를 제거하세요.\n", + "units, snap, W0 = make_runs()\n", + "rng = np.random.default_rng(2)\n", + "S = len(snap)\n", + "W = W0.copy()\n", + "raw = pd.DataFrame({\"unit\": snap[\"unit\"].values, \"date\": [(pd.Timestamp(\"2026-01-01\") + pd.Timedelta(days=int(c))).strftime(\"%Y-%m-%d\") for c in snap[\"cal\"]],\n", + " \"temp\": snap[\"temp\"].round(1).values, \"temp_unit\": \"C\", \"press\": snap[\"press\"].round(1).values, \"press_unit\": \"kPa\",\n", + " \"load\": snap[\"load\"].round(2).values, \"emergency\": snap[\"rul\"].values <= 3})\n", + "idx = rng.choice(S, 1500, replace=False)\n", + "a, b, c, d_, e, f, g, h_ = np.split(idx, [150, 350, 600, 750, 800, 850, 1050])\n", + "raw.loc[a, \"unit\"] = rng.choice([\"u\", \" U\", \"unit-\"], len(a)) + raw.loc[a, \"unit\"].str[1:] # ID 표기 불일치\n", + "raw.loc[b, \"date\"] = pd.to_datetime(raw.loc[b, \"date\"]).dt.strftime(\"%Y/%m/%d\") # 날짜 형식 혼재\n", + "raw.loc[c, \"temp\"] = (raw.loc[c, \"temp\"] * 9 / 5 + 32).round(1); raw.loc[c, \"temp_unit\"] = \"F\" # 온도 단위 혼재\n", + "raw.loc[d_, \"press\"] = (raw.loc[d_, \"press\"] / 6.895).round(1); raw.loc[d_, \"press_unit\"] = \"psi\" # 압력 단위 혼재\n", + "raw.loc[e, \"temp\"] = -999.0; raw.loc[f, \"press\"] = -5.0 # 불가능 값\n", + "raw.loc[g, [\"temp\", \"press\"]] = np.nan # 결측\n", + "sp = h_[:200] # 파형 단발 스파이크(센서 글리치)\n", + "for i in sp:\n", + " W[i, rng.integers(0, NS, 2)] += rng.choice([-9.0, 9.0], 2)\n", + "nz = h_[200:] # 전자기 간섭: 파형 전체 잡음 증가\n", + "W[nz] += rng.normal(0, 0.9, (len(nz), NS))\n", + "raw = pd.concat([raw, raw.sample(80, random_state=1)], ignore_index=True) # 중복 80건\n", + "print(f\"원천 자료 {len(raw)}행 (정상 {S}행 + 중복 80행, 오류 주입 {len(idx)}행, 파형 스파이크 {len(sp)}개·간섭 {len(nz)}개 포함)\")\n", + "\n", + "# 2) 정제 규칙 10종 (표 규칙 R1~R6, 결측 R9, 예외 R10 / 파형 규칙 R7~R8)\n", + "df, applied = raw.iloc[:S].copy(), []\n", + "def rule(name, n):\n", + " # [TODO : 학생 실습 2] 고주파 센서 신호 잡음 제거, FFT 변환 및 주요 특징량(RMS, Kurtosis) 전처리 구현 관련 핵심 로직을 구현하세요.\n", + " # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n", + " # <<< 여기에 코드를 작성하세요 >>>\n", + " pass # 작성 후 pass를 제거하세요.\n", + "rule(\"R1 중복 행 제거\", raw.duplicated().sum())\n", + "uid = \"U\" + df[\"unit\"].str.extract(r\"(\\d+)\")[0]; rule(\"R2 개체 ID 표기 통일\", (uid != df[\"unit\"]).sum()); df[\"unit\"] = uid\n", + "t1 = pd.to_datetime(df[\"date\"], format=\"%Y-%m-%d\", errors=\"coerce\"); t2 = pd.to_datetime(df[\"date\"], format=\"%Y/%m/%d\", errors=\"coerce\")\n", + "rule(\"R3 측정일 형식 통일\", t1.isna().sum()); df[\"date\"] = t1.fillna(t2)\n", + "fm = df[\"temp_unit\"] == \"F\"; df.loc[fm, \"temp\"] = ((df.loc[fm, \"temp\"] - 32) * 5 / 9).round(1); df[\"temp_unit\"] = \"C\"; rule(\"R4 온도 단위 통일(F -> C)\", fm.sum())\n", + "pm = df[\"press_unit\"] == \"psi\"; df.loc[pm, \"press\"] = (df.loc[pm, \"press\"] * 6.895).round(1); df[\"press_unit\"] = \"kPa\"; rule(\"R5 압력 단위 통일(psi -> kPa)\", pm.sum())\n", + "bt, bp = ~df[\"temp\"].between(-30, 150) & df[\"temp\"].notna(), (df[\"press\"] < 0) & df[\"press\"].notna()\n", + "df.loc[bt, \"temp\"] = np.nan; df.loc[bp, \"press\"] = np.nan; rule(\"R6 불가능 값 결측 처리\", bt.sum() + bp.sum())\n", + "Wc = W.copy()\n", + "glitch = np.abs(Wc - np.median(Wc, axis=1, keepdims=True)).max(axis=1) > 8 # 단발 스파이크 창\n", + "for i in np.where(glitch)[0]:\n", + " Wc[i] = medfilt(Wc[i], 3)\n", + "rule(\"R7 파형 단발 스파이크 제거(중앙값 필터)\", glitch.sum())\n", + "sos = butter(4, 250, fs=FS, output=\"sos\")\n", + "hf = (Wc - sosfiltfilt(sos, Wc, axis=1)).std(axis=1) # 250Hz 이상 성분의 크기\n", + "noisy = hf > 0.4 # 간섭 잡음 창\n", + "Wc[noisy] = sosfiltfilt(sos, Wc[noisy], axis=1); rule(\"R8 간섭 잡음 창 저역통과(250Hz) 필터\", noisy.sum())\n", + "miss = df[[\"temp\", \"press\"]].isna().sum().sum()\n", + "X = df.assign(un=df[\"unit\"].str[1:].astype(int) / 50)[[\"temp\", \"press\", \"load\", \"un\"]]\n", + "df[[\"temp\", \"press\"]] = KNNImputer(n_neighbors=5).fit_transform(X)[:, :2]; rule(\"R9 센서 결측 KNN 보정\", miss)\n", + "df[\"rms\"] = np.sqrt(((Wc - Wc.mean(axis=1, keepdims=True)) ** 2).mean(axis=1)); lim = df[\"rms\"].quantile(0.99)\n", + "df[\"outlier_flag\"] = (df[\"rms\"] > lim) & ~df[\"emergency\"]; rule(\"R10 긴급 점검 측정 보존(일반 이상값은 표시만)\", (df[\"rms\"] > lim).sum() - df[\"outlier_flag\"].sum())\n", + "print(\"\\n[적용 규칙과 처리 건수]\")\n", + "for nme, n in applied:\n", + " print(f\" {nme:36s} {n:5d}건\")\n", + "\n", + "# 3) 정합성 검증\n", + "checks = {\"중복 0건\": not df.duplicated([\"unit\", \"date\"]).any(), \"ID 형식\": df[\"unit\"].str.match(r\"^U\\d{2}$\").all(),\n", + " \"날짜 변환\": df[\"date\"].notna().all(), \"온도 단위 C\": (df[\"temp_unit\"] == \"C\").all(), \"압력 단위 kPa\": (df[\"press_unit\"] == \"kPa\").all(),\n", + " \"온도 범위\": df[\"temp\"].between(-30, 150).all(), \"압력 0 이상\": (df[\"press\"] >= 0).all(), \"결측 0건\": df[[\"temp\", \"press\"]].notna().all().all(),\n", + " \"파형 스파이크 잔존 0개\": not (np.abs(Wc - np.median(Wc, axis=1, keepdims=True)).max(axis=1) > 8).any(),\n", + " f\"긴급 점검 {int(df['emergency'].sum())}건 보존\": int(df[\"emergency\"].sum()) == int(snap[\"rul\"].le(3).sum())}\n", + "rate = np.mean(list(checks.values()))\n", + "print(f\"\\n정합성 검증 {sum(checks.values())}/{len(checks)} 통과, 통과율 {rate:.0%} (목표 100%: {'달성' if rate == 1 else '미달'})\")\n", + "for k, v in checks.items():\n", + " if not v:\n", + " print(\" 실패:\", k)\n", + "\n", + "# 4) FFT 특징량: 정제 효과(깨끗한 원본 파형 대비 오차)\n", + "def feats(Wx):\n", + " x = Wx - Wx.mean(axis=1, keepdims=True)\n", + " return np.sqrt((x ** 2).mean(axis=1)), kurtosis(x, axis=1, fisher=False)\n", + "r0, k0 = feats(W0); r1, k1 = feats(W); r2, k2 = feats(Wc)\n", + "bad = glitch | noisy\n", + "print(\"\\n[특징량 정제 효과] 스파이크·간섭이 있던 창 \" + f\"{int(bad.sum())}개의 깨끗한 원본 대비 평균 오차\")\n", + "print(f\" RMS 정제 전 {np.abs(r1 - r0)[bad].mean():.3f} -> 정제 후 {np.abs(r2 - r0)[bad].mean():.3f}\")\n", + "print(f\" 첨도 정제 전 {np.abs(k1 - k0)[bad].mean():.2f} -> 정제 후 {np.abs(k2 - k0)[bad].mean():.2f}\")\n", + "cmp = pd.DataFrame({\"정제 전\": [len(raw), pd.to_numeric(raw[\"temp\"], errors=\"coerce\").mean(), raw[\"temp\"].min(), raw[\"press\"].min(), raw[\"press\"].isna().sum()],\n", + " \"정제 후\": [len(df), df[\"temp\"].mean(), df[\"temp\"].min(), df[\"press\"].min(), df[\"press\"].isna().sum()]},\n", + " index=[\"행 수\", \"온도 평균\", \"온도 최소\", \"압력 최소\", \"압력 결측\"]).round(1)\n", + "print(\"\\n[정제 전후 기술통계]\"); print(cmp.to_string())\n", + "\n", + "i = int(np.where(glitch)[0][0])\n", + "fig, ax = plt.subplots(1, 2, figsize=(10, 3.5))\n", + "ax[0].plot(W[i], label=\"raw\", alpha=0.6); ax[0].plot(Wc[i], label=\"cleaned\"); ax[0].set_title(\"Waveform with glitch\"); ax[0].legend()\n", + "f = np.fft.rfftfreq(NS, 1 / FS); ax[1].plot(f, np.abs(np.fft.rfft(Wc[i])) / NS); ax[1].set_title(\"FFT spectrum (cleaned)\"); ax[1].set_xlabel(\"Hz\")\n", + "plt.tight_layout(); plt.show()\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 02 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + "원천 자료 7813행 (정상 7733행 + 중복 80행, 오류 주입 1500행, 파형 스파이크 200개·간섭 250개 포함)\n", + "\n", + "[적용 규칙과 처리 건수]\n", + " R1 중복 행 제거 80건\n", + " R2 개체 ID 표기 통일 150건\n", + " R3 측정일 형식 통일 200건\n", + " R4 온도 단위 통일(F -> C) 250건\n", + " R5 압력 단위 통일(psi -> kPa) 150건\n", + " R6 불가능 값 결측 처리 100건\n", + " R7 파형 단발 스파이크 제거(중앙값 필터) 207건\n", + " R8 간섭 잡음 창 저역통과(250Hz) 필터 347건\n", + " R9 센서 결측 KNN 보정 500건\n", + " R10 긴급 점검 측정 보존(일반 이상값은 표시만) 11건\n", + "\n", + "정합성 검증 10/10 통과, 통과율 100% (목표 100%: 달성)\n", + "\n", + "[특징량 정제 효과] 스파이크·간섭이 있던 창 554개의 깨끗한 원본 대비 평균 오차\n", + " RMS 정제 전 0.267 -> 정제 후 0.116\n", + " 첨도 정제 전 8.31 -> 정제 후 0.86\n", + "\n", + "[정제 전후 기술통계]\n", + " 정제 전 정제 후\n", + "행 수 7813.0 7733.0\n", + "온도 평균 73.2 77.3\n", + "온도 최소 -999.0 61.5\n", + "압력 최소 -5.0 342.8\n", + "압력 결측 201.0 0.0\n", + "```\n", + "
\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 03. 임계값 초과 기반 1차 센서 이상 징후 감지 Baseline 알고리즘 구현\n", + "**소요시간**: 약 2.5시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "임계값 초과 기반 1차 센서 이상 징후 감지 Baseline 알고리즘을 구현한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **임계값 초과 기반 1차 센서 이상 징후 감지 Baseline을 구현하고, 탐색적 분석으로 고정 임계값의 한계와 수리부속 소모의 핵심 관리 대상을 찾는다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **정상 자료 분포로 RMS, 첨도, 온도, 압력 임계값을 정하고 하나라도 넘으면 이상으로 보는 Baseline의 탐지율, 정밀도, 고부하 운용일 오경보율, 고장 전 확보 시간을 측정 수행하고 검증하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `notebooks/mnt04_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": [ + "\"\"\"임계값 초과(진동 RMS·첨도·온도·압력) 기반 1차 센서 이상 징후 감지 Baseline 알고리즘을 구현해 탐지율·오경보율·고장 전 확보 시간을 확인하고, 탐색적 분석(EDA 차트 8종, 피처와 잔존수명의 상관계수·p-value, 수리부속 소모 파레토 ABC 분류)으로 핵심 관리 대상을 식별한다.\"\"\"\n", + "import numpy as np\n", + "import pandas as pd\n", + "import matplotlib.pyplot as plt\n", + "from scipy.stats import kurtosis, spearmanr\n", + "\n", + "# ---------------------------------------------------------------------------\n", + "# [교육용가상자료] 가상 기동체 48대의 엔진·구동계 진동·온도·압력 센서 자료 생성기 (실제 장비·부대·정비 실적 없음)\n", + "# 개체마다 정상 -> 열화 -> 고장(수명종료)까지 하루 1회 측정(진동 파형 256표본 + 온도 + 압력 + 부하)을 만든다.\n", + "# 열화 유형 4가지: bearing(베어링), gear(기어), oil_pump(오일 펌프), cooling(냉각 팬 모터)\n", + "# ---------------------------------------------------------------------------\n", + "FS, NS = 1024, 256 # 가상 표본화 주파수(Hz), 파형 1개당 표본 수\n", + "TT = np.arange(NS) / FS\n", + "MODES = { # 열화 유형: (발생 확률, 평균 수명(일), 교체 수리부속, 가상 단가지수)\n", + " \"bearing\": (0.40, 160, \"주 베어링 세트\", 6.0), \"gear\": (0.25, 175, \"구동 기어 세트\", 9.0),\n", + " \"oil_pump\": (0.20, 150, \"오일 펌프\", 4.0), \"cooling\": (0.15, 140, \"냉각 팬 모터\", 3.0)}\n", + "KIT_COST = 1.0 # 모든 정비에 함께 소모되는 씰·필터 키트 단가지수\n", + "BANDS = {\"b50\": (40, 60), \"b100\": (90, 110), \"b150\": (140, 156), \"b165\": (156, 176), \"b200\": (190, 215)}\n", + "\n", + "\n", + "def make_runs(n=48, seed=1):\n", + " \"\"\"반환: units(개체 정보), snap(일별 측정 요약), W(진동 파형 [측정 수, 256])\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " names = list(MODES)\n", + " mode = rng.choice(names, n, p=[MODES[m][0] for m in names])\n", + " life = np.clip(np.array([rng.normal(MODES[m][1], 18) for m in mode]), 90, 230).astype(int)\n", + " start = np.sort(rng.integers(0, 240, n))\n", + " units = pd.DataFrame({\"unit\": [f\"U{i:02d}\" for i in range(n)], \"mode\": mode, \"life\": life, \"start\": start, \"base_load\": rng.uniform(0.4, 0.75, n)})\n", + " rows = []\n", + " for u in units.itertuples():\n", + " d = np.arange(u.life + 1)\n", + " onset = 0.45 * u.life\n", + " deg = np.clip((d - onset) / (u.life - onset), 0, 1) ** 1.5\n", + " load = np.clip(u.base_load + rng.normal(0, 0.06, len(d)), 0.2, 1.0)\n", + " hi = rng.random(len(d)) < 0.07 # 고부하 운용일(정상이지만 진동·온도 상승)\n", + " load = np.where(hi, rng.uniform(0.95, 1.1, len(d)), load)\n", + " rows.append(pd.DataFrame({\"unit\": u.unit, \"day\": d, \"cal\": u.start + d, \"rul\": u.life - d, \"deg\": deg, \"load\": load, \"hi_load\": hi, \"mode\": u.mode}))\n", + " snap = pd.concat(rows, ignore_index=True)\n", + " S = len(snap)\n", + " snap[\"ambient\"] = 15 + 12 * np.sin(2 * np.pi * snap[\"cal\"] / 365) + rng.normal(0, 2, S)\n", + " dg, ld, md = snap[\"deg\"].values, snap[\"load\"].values, snap[\"mode\"].values\n", + " isb, isg, iso, isc = (md == \"bearing\"), (md == \"gear\"), (md == \"oil_pump\"), (md == \"cooling\")\n", + " snap[\"temp\"] = 55 + 22 * ld + 0.5 * snap[\"ambient\"] + 9 * dg * iso + 26 * dg * isc + 3 * dg * (isb | isg) + rng.normal(0, 1.5, S)\n", + " snap[\"press\"] = 450 - 12 * ld - 85 * dg * iso - 15 * dg * isc + rng.normal(0, 6, S)\n", + " ph = rng.uniform(0, 2 * np.pi, (S, 3))\n", + " a1, a2 = 1.0 * (0.5 + ld), 0.4 * (0.5 + ld)\n", + " W = (a1[:, None] * np.sin(2 * np.pi * 50 * TT + ph[:, :1]) + a2[:, None] * np.sin(2 * np.pi * 100 * TT + ph[:, 1:2])\n", + " + rng.normal(0, 0.35, (S, NS)))\n", + " W += (0.9 * dg * isb)[:, None] * np.sin(2 * np.pi * 165 * TT + ph[:, 2:3])\n", + " W += (0.8 * dg * isg)[:, None] * (np.sin(2 * np.pi * 150 * TT + ph[:, 2:3]) + 0.8 * np.sin(2 * np.pi * 100 * TT))\n", + " W += (0.5 * dg * iso)[:, None] * np.sin(2 * np.pi * 200 * TT + ph[:, 2:3])\n", + " k = rng.poisson(10 * dg * isb) # 베어링 열화: 충격성 펄스(첨도 상승)\n", + " pos, amp = rng.integers(0, NS, (S, 30)), rng.normal(0, 3.0, (S, 30)) * dg[:, None]\n", + " amp = amp * (np.arange(30)[None, :] < k[:, None])\n", + " np.add.at(W, (np.repeat(np.arange(S), 30), pos.ravel()), amp.ravel())\n", + " return units, snap, W\n", + "\n", + "\n", + "def extract(W):\n", + " \"\"\"진동 파형 -> 특징량: RMS, 첨도, 파고율, 최대-최소, FFT 대역 에너지(50/100/150/165/200Hz 부근)\"\"\"\n", + " # [TODO : 학생 실습 3] 임계값 초과 기반 1차 센서 이상 징후 감지 Baseline 알고리즘 구현 관련 핵심 로직을 구현하세요.\n", + " # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n", + " # <<< 여기에 코드를 작성하세요 >>>\n", + " pass # 작성 후 pass를 제거하세요.\n", + "BASE = [\"rms\", \"kurt\", \"crest\", \"p2p\", \"b50\", \"b100\", \"b150\", \"b165\", \"b200\", \"temp\", \"press\"]\n", + "ROLL = [\"rms\", \"kurt\", \"b150\", \"b165\", \"b200\", \"temp\", \"press\"]\n", + "\n", + "\n", + "def build_features(snap, W):\n", + " # [TODO : 학생 실습 3] 임계값 초과 기반 1차 센서 이상 징후 감지 Baseline 알고리즘 구현 관련 핵심 로직을 구현하세요.\n", + " # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n", + " # <<< 여기에 코드를 작성하세요 >>>\n", + " pass # 작성 후 pass를 제거하세요.\n", + "DERIVED = [\"temp_res\", \"press_res\", \"rms_n\", \"hf_ratio\"] + [c + \"_m3\" for c in ROLL] + [\"rms_n_d\", \"temp_res_d\", \"press_res_d\"]\n", + "FEATS = BASE + DERIVED\n", + "AE_COLS = [\"rms_n\", \"kurt\", \"b150\", \"b165\", \"b200\", \"hf_ratio\", \"temp_res\", \"press_res\"]\n", + "\n", + "\n", + "def split_units(units, n_train=24):\n", + " return list(units[\"unit\"][:n_train]), list(units[\"unit\"][n_train:])\n", + "\n", + "\n", + "class AE:\n", + " \"\"\"오토인코더(입력 8 -> 10 -> 4 -> 10 -> 8): 정상 자료만 재구성하도록 학습하고, 재구성 오차를 이상 점수로 쓴다.\"\"\"\n", + "\n", + " def __init__(self, cols=AE_COLS, q=0.99, seed=0):\n", + " self.cols, self.q, self.seed = cols, q, seed\n", + "\n", + " def fit(self, healthy):\n", + " self.sc = StandardScaler().fit(healthy[self.cols])\n", + " Z = self.sc.transform(healthy[self.cols])\n", + " self.net = MLPRegressor(hidden_layer_sizes=(10, 4, 10), max_iter=800, random_state=self.seed).fit(Z, Z)\n", + " self.thr = float(np.quantile(self.err(healthy), self.q))\n", + " return self\n", + "\n", + " def contrib(self, d):\n", + " Z = self.sc.transform(d[self.cols])\n", + " return (Z - self.net.predict(Z)) ** 2 # 입력 항목별 재구성 오차(이상 기여도)\n", + "\n", + " def err(self, d):\n", + " return self.contrib(d).mean(axis=1)\n", + "\n", + "\n", + "def latch(d, e, thr, k=2):\n", + " \"\"\"같은 개체에서 이상 점수가 k일 연속 기준을 넘으면 경보를 켜고 유지(래치)한다.\"\"\"\n", + " out, e = np.zeros(len(d), bool), np.asarray(e)\n", + " for idx in d.reset_index(drop=True).groupby(\"unit\").indices.values():\n", + " run, on = 0, False\n", + " for i in idx:\n", + " run = run + 1 if e[i] > thr else 0\n", + " on = on or run >= k\n", + " out[i] = on\n", + " return out\n", + "\n", + "units, snap, W = make_runs()\n", + "D = build_features(snap, W)\n", + "\n", + "# 1) Baseline: 단순 임계값(하나라도 넘으면 이상). 임계값은 정상(열화 전) 자료의 분포로 정한다.\n", + "healthy = D[D[\"deg\"] == 0]\n", + "TH = {\"rms\": 1.25, \"kurt\": 3.0, \"temp\": 92.0, \"press_low\": 405.0}\n", + "D[\"alarm\"] = (D[\"rms\"] > TH[\"rms\"]) | (D[\"kurt\"] > TH[\"kurt\"]) | (D[\"temp\"] > TH[\"temp\"]) | (D[\"press\"] < TH[\"press_low\"])\n", + "D[\"anomaly\"] = D[\"deg\"] >= 0.1 # 정답: 열화가 일정 수준 이상 진행된 측정\n", + "tp, fp = (D[\"alarm\"] & D[\"anomaly\"]).sum(), (D[\"alarm\"] & ~D[\"anomaly\"]).sum()\n", + "fn = (~D[\"alarm\"] & D[\"anomaly\"]).sum()\n", + "print(f\"[교육용가상자료] 측정 {len(D)}건, 개체 {len(units)}대, 열화 구간 {int(D['anomaly'].sum())}건\")\n", + "print(f\"Baseline 임계값 {TH}\")\n", + "print(f\" 탐지율 {tp / (tp + fn):.1%}, 정밀도 {tp / (tp + fp):.1%}, 정상 측정 오경보율 {fp / (~D['anomaly']).sum():.1%} (고부하 운용일 오경보율 {D.loc[D['hi_load'] & ~D['anomaly'], 'alarm'].mean():.1%})\")\n", + "lead = []\n", + "for u, g in D.groupby(\"unit\"):\n", + " a = g[g[\"alarm\"] & g[\"anomaly\"]]\n", + " lead.append(a[\"rul\"].max() if len(a) else 0)\n", + "units[\"lead\"] = lead\n", + "print(f\" 고장 전 첫 경보 확보 시간 중앙값 {np.median(lead):.0f}일, 경보 못 낸 개체 {int((units['lead'] == 0).sum())}대/{len(units)}대\")\n", + "print(\" 열화 유형별 탐지율: \" + \", \".join(f\"{m} {D.loc[D['anomaly'] & (D['mode'] == m), 'alarm'].mean():.0%}\" for m in MODES))\n", + "\n", + "# 2) 상관분석: 피처와 잔존수명(RUL) (스피어만)\n", + "rows = []\n", + "for c in [\"rms\", \"kurt\", \"crest\", \"b150\", \"b165\", \"b200\", \"temp\", \"press\", \"hf_ratio\", \"temp_res\", \"press_res\", \"load\", \"ambient\"]:\n", + " r, p = spearmanr(D[c], D[\"rul\"]); rows.append((c, r, p))\n", + "cor = pd.DataFrame(rows, columns=[\"피처\", \"rho\", \"p\"]); cor[\"유의\"] = (cor[\"p\"] < 0.05) & (cor[\"rho\"].abs() >= 0.2)\n", + "print(\"\\n[상관분석] 피처 | 스피어만 rho | p-value | 유의(p<0.05, |rho|>=0.2)\")\n", + "for x in cor.sort_values(\"rho\", key=abs, ascending=False).head(8).itertuples():\n", + " print(f\" {x.피처:10s} {x.rho:+.2f} {x.p:.4f} {'유의' if x.유의 else '-'}\")\n", + "nsig = int(cor[\"유의\"].sum())\n", + "\n", + "# 3) 파레토(ABC): 고장별 수리부속 소모 비용(가상 단가지수)\n", + "cons = {}\n", + "for m in units[\"mode\"]:\n", + " cons[MODES[m][2]] = cons.get(MODES[m][2], 0) + MODES[m][3]\n", + " cons[\"씰·필터 키트\"] = cons.get(\"씰·필터 키트\", 0) + KIT_COST\n", + "par = pd.Series(cons).sort_values(ascending=False).to_frame(\"소모 비용지수\")\n", + "par[\"누적비율\"] = par[\"소모 비용지수\"].cumsum() / par[\"소모 비용지수\"].sum()\n", + "par[\"등급\"] = np.where(par[\"누적비율\"] <= 0.8, \"A\", np.where(par[\"누적비율\"] <= 0.95, \"B\", \"C\")); par.iloc[0, 2] = \"A\"\n", + "print(\"\\n[파레토] 수리부속 소모 비용(고장 1회 교체 기준)\")\n", + "print(par.assign(누적비율=lambda d: d[\"누적비율\"].map(\"{:.0%}\".format)).round(0).to_string())\n", + "top20 = par.head(int(np.ceil(len(par) * 0.2)))\n", + "print(f\"상위 20% 품목({', '.join(top20.index)})이 전체 소모 비용의 {top20['소모 비용지수'].sum() / par['소모 비용지수'].sum():.0%}\")\n", + "\n", + "# 4) EDA 차트 8종\n", + "fig, ax = plt.subplots(2, 4, figsize=(16, 7))\n", + "ax[0, 0].scatter(D[\"rul\"], D[\"rms\"], s=3); ax[0, 0].set_title(\"1 RMS vs RUL\"); ax[0, 0].invert_xaxis()\n", + "for m in MODES:\n", + " g = D[D[\"mode\"] == m]; ax[0, 1].scatter(g[\"deg\"], g[\"kurt\"], s=2, label=m)\n", + "ax[0, 1].set_title(\"2 Kurtosis vs degradation\"); ax[0, 1].legend(fontsize=6)\n", + "for m in MODES:\n", + " g = D[D[\"mode\"] == m]; ax[0, 2].scatter(g[\"deg\"], g[\"temp_res\"], s=2)\n", + "ax[0, 2].set_title(\"3 Temp residual vs degradation\")\n", + "for m in MODES:\n", + " g = D[D[\"mode\"] == m]; ax[0, 3].scatter(g[\"deg\"], g[\"press_res\"], s=2)\n", + "ax[0, 3].set_title(\"4 Pressure residual vs degradation\")\n", + "f = np.fft.rfftfreq(NS, 1 / FS); bm = D[\"mode\"] == \"bearing\"\n", + "ax[1, 0].plot(f, np.abs(np.fft.rfft(W[(bm & (D[\"deg\"] == 0)).values], axis=1)).mean(axis=0) / NS, label=\"healthy\")\n", + "ax[1, 0].plot(f, np.abs(np.fft.rfft(W[(bm & (D[\"deg\"] > 0.6)).values], axis=1)).mean(axis=0) / NS, label=\"degraded\"); ax[1, 0].legend(); ax[1, 0].set_title(\"5 Mean spectrum (bearing)\")\n", + "ax[1, 1].hist([units.loc[units[\"mode\"] == m, \"life\"] for m in MODES], bins=8, stacked=True, label=list(MODES)); ax[1, 1].set_title(\"6 Life by failure mode\")\n", + "ax[1, 2].scatter(healthy[\"load\"], healthy[\"rms\"], s=2); ax[1, 2].set_title(\"7 Load vs RMS (healthy)\")\n", + "ax[1, 3].bar(range(len(par)), par[\"소모 비용지수\"]); ax[1, 3].set_title(\"8 Spare-part consumption (Pareto)\")\n", + "plt.tight_layout(); plt.show()\n", + "\n", + "print(f\"\\nEDA 차트 8종 생성(목표 8종 이상: 달성), 유의한 피처 {nsig}개 (목표 5개 이상: {'달성' if nsig >= 5 else '미달'})\")\n", + "print(\"핵심 발견: 1) 고정 임계값은 고부하일 오경보와 온도·압력 유형 놓침이 있음 2) 부하·외기를 보정한 잔차 피처가 열화와 더 뚜렷함 3) 베어링·기어 부품이 소모 비용 대부분\")\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 03 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + "[교육용가상자료] 측정 7733건, 개체 48대, 열화 구간 3336건\n", + "Baseline 임계값 {'rms': 1.25, 'kurt': 3.0, 'temp': 92.0, 'press_low': 405.0}\n", + " 탐지율 25.3%, 정밀도 90.3%, 정상 측정 오경보율 2.1% (고부하 운용일 오경보율 28.6%)\n", + " 고장 전 첫 경보 확보 시간 중앙값 40일, 경보 못 낸 개체 0대/48대\n", + " 열화 유형별 탐지율: bearing 29%, gear 9%, oil_pump 54%, cooling 31%\n", + "\n", + "[상관분석] 피처 | 스피어만 rho | p-value | 유의(p<0.05, |rho|>=0.2)\n", + " hf_ratio -0.63 0.0000 유의\n", + " temp_res -0.53 0.0000 유의\n", + " b165 -0.38 0.0000 유의\n", + " b150 -0.37 0.0000 유의\n", + " rms -0.32 0.0000 유의\n", + " ambient +0.31 0.0000 유의\n", + " kurt -0.29 0.0000 유의\n", + " b200 -0.21 0.0000 유의\n", + "\n", + "[파레토] 수리부속 소모 비용(고장 1회 교체 기준)\n", + " 소모 비용지수 누적비율 등급\n", + "구동 기어 세트 162.0 46% A\n", + "주 베어링 세트 90.0 71% A\n", + "씰·필터 키트 48.0 85% B\n", + "오일 펌프 32.0 94% B\n", + "냉각 팬 모터 21.0 100% C\n", + "상위 20% 품목(구동 기어 세트)이 전체 소모 비용의 46%\n", + "\n", + "EDA 차트 8종 생성(목표 8종 이상: 달성), 유의한 피처 9개 (목표 5개 이상: 달성)\n", + "핵심 발견: 1) 고정 임계값은 고부하일 오경보와 온도·압력 유형 놓침이 있음 2) 부하·외기를 보정한 잔차 피처가 열화와 더 뚜렷함 3) 베어링·기어 부품이 소모 비용 대부분\n", + "```\n", + "
\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 04. 오토인코더(Autoencoder)/LSTM 기반 미세 진동 변화 감지 예지정비(PdM) 모델 고도화\n", + "**소요시간**: 약 3.0시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "오토인코더 기반 미세 진동 변화 감지 예지정비(PdM) 모델을 고도화한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **오토인코더 기반 미세 진동·온도 변화 감지 예지정비(PdM) 모델을 고도화하고, 파생 피처로 잔존수명 기준 모델을 만든다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **정상 자료만으로 오토인코더(MLP)를 학습해 재구성 오차를 이상 점수로 쓰고 2일 연속 초과 시 경보를 켠다 과정을 진행하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 5개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `src/features/mnt04_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": [ + "\"\"\"정상 자료만으로 학습한 오토인코더의 재구성 오차로 미세 진동·온도 변화를 감지(2일 연속 시 경보)해 임계값 Baseline과 비교하고, 파생 피처 14개로 잔존수명(RUL) 기준 모델을 만들어 개체 단위 시계열 교차검증 MAPE를 평가하며 순열 중요도로 피처를 줄이는 예지정비(PdM) 모델을 구현한다.\"\"\"\n", + "import numpy as np\n", + "import pandas as pd\n", + "from scipy.stats import kurtosis\n", + "from sklearn.ensemble import GradientBoostingRegressor\n", + "from sklearn.inspection import permutation_importance\n", + "from sklearn.neural_network import MLPRegressor\n", + "from sklearn.preprocessing import StandardScaler\n", + "\n", + "# ---------------------------------------------------------------------------\n", + "# [교육용가상자료] 가상 기동체 48대의 엔진·구동계 진동·온도·압력 센서 자료 생성기 (실제 장비·부대·정비 실적 없음)\n", + "# 개체마다 정상 -> 열화 -> 고장(수명종료)까지 하루 1회 측정(진동 파형 256표본 + 온도 + 압력 + 부하)을 만든다.\n", + "# 열화 유형 4가지: bearing(베어링), gear(기어), oil_pump(오일 펌프), cooling(냉각 팬 모터)\n", + "# ---------------------------------------------------------------------------\n", + "FS, NS = 1024, 256 # 가상 표본화 주파수(Hz), 파형 1개당 표본 수\n", + "TT = np.arange(NS) / FS\n", + "MODES = { # 열화 유형: (발생 확률, 평균 수명(일), 교체 수리부속, 가상 단가지수)\n", + " \"bearing\": (0.40, 160, \"주 베어링 세트\", 6.0), \"gear\": (0.25, 175, \"구동 기어 세트\", 9.0),\n", + " \"oil_pump\": (0.20, 150, \"오일 펌프\", 4.0), \"cooling\": (0.15, 140, \"냉각 팬 모터\", 3.0)}\n", + "KIT_COST = 1.0 # 모든 정비에 함께 소모되는 씰·필터 키트 단가지수\n", + "BANDS = {\"b50\": (40, 60), \"b100\": (90, 110), \"b150\": (140, 156), \"b165\": (156, 176), \"b200\": (190, 215)}\n", + "\n", + "\n", + "def make_runs(n=48, seed=1):\n", + " \"\"\"반환: units(개체 정보), snap(일별 측정 요약), W(진동 파형 [측정 수, 256])\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " names = list(MODES)\n", + " mode = rng.choice(names, n, p=[MODES[m][0] for m in names])\n", + " life = np.clip(np.array([rng.normal(MODES[m][1], 18) for m in mode]), 90, 230).astype(int)\n", + " start = np.sort(rng.integers(0, 240, n))\n", + " units = pd.DataFrame({\"unit\": [f\"U{i:02d}\" for i in range(n)], \"mode\": mode, \"life\": life, \"start\": start, \"base_load\": rng.uniform(0.4, 0.75, n)})\n", + " rows = []\n", + " for u in units.itertuples():\n", + " d = np.arange(u.life + 1)\n", + " onset = 0.45 * u.life\n", + " deg = np.clip((d - onset) / (u.life - onset), 0, 1) ** 1.5\n", + " load = np.clip(u.base_load + rng.normal(0, 0.06, len(d)), 0.2, 1.0)\n", + " hi = rng.random(len(d)) < 0.07 # 고부하 운용일(정상이지만 진동·온도 상승)\n", + " load = np.where(hi, rng.uniform(0.95, 1.1, len(d)), load)\n", + " rows.append(pd.DataFrame({\"unit\": u.unit, \"day\": d, \"cal\": u.start + d, \"rul\": u.life - d, \"deg\": deg, \"load\": load, \"hi_load\": hi, \"mode\": u.mode}))\n", + " snap = pd.concat(rows, ignore_index=True)\n", + " S = len(snap)\n", + " snap[\"ambient\"] = 15 + 12 * np.sin(2 * np.pi * snap[\"cal\"] / 365) + rng.normal(0, 2, S)\n", + " dg, ld, md = snap[\"deg\"].values, snap[\"load\"].values, snap[\"mode\"].values\n", + " isb, isg, iso, isc = (md == \"bearing\"), (md == \"gear\"), (md == \"oil_pump\"), (md == \"cooling\")\n", + " snap[\"temp\"] = 55 + 22 * ld + 0.5 * snap[\"ambient\"] + 9 * dg * iso + 26 * dg * isc + 3 * dg * (isb | isg) + rng.normal(0, 1.5, S)\n", + " snap[\"press\"] = 450 - 12 * ld - 85 * dg * iso - 15 * dg * isc + rng.normal(0, 6, S)\n", + " ph = rng.uniform(0, 2 * np.pi, (S, 3))\n", + " a1, a2 = 1.0 * (0.5 + ld), 0.4 * (0.5 + ld)\n", + " W = (a1[:, None] * np.sin(2 * np.pi * 50 * TT + ph[:, :1]) + a2[:, None] * np.sin(2 * np.pi * 100 * TT + ph[:, 1:2])\n", + " + rng.normal(0, 0.35, (S, NS)))\n", + " W += (0.9 * dg * isb)[:, None] * np.sin(2 * np.pi * 165 * TT + ph[:, 2:3])\n", + " W += (0.8 * dg * isg)[:, None] * (np.sin(2 * np.pi * 150 * TT + ph[:, 2:3]) + 0.8 * np.sin(2 * np.pi * 100 * TT))\n", + " W += (0.5 * dg * iso)[:, None] * np.sin(2 * np.pi * 200 * TT + ph[:, 2:3])\n", + " k = rng.poisson(10 * dg * isb) # 베어링 열화: 충격성 펄스(첨도 상승)\n", + " pos, amp = rng.integers(0, NS, (S, 30)), rng.normal(0, 3.0, (S, 30)) * dg[:, None]\n", + " amp = amp * (np.arange(30)[None, :] < k[:, None])\n", + " np.add.at(W, (np.repeat(np.arange(S), 30), pos.ravel()), amp.ravel())\n", + " return units, snap, W\n", + "\n", + "\n", + "def extract(W):\n", + " \"\"\"진동 파형 -> 특징량: RMS, 첨도, 파고율, 최대-최소, FFT 대역 에너지(50/100/150/165/200Hz 부근)\"\"\"\n", + " # [TODO : 학생 실습 4] 오토인코더(Autoencoder)/LSTM 기반 미세 진동 변화 감지 예지정비(PdM) 모델 고도화 관련 핵심 로직을 구현하세요.\n", + " # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n", + " # <<< 여기에 코드를 작성하세요 >>>\n", + " pass # 작성 후 pass를 제거하세요.\n", + "BASE = [\"rms\", \"kurt\", \"crest\", \"p2p\", \"b50\", \"b100\", \"b150\", \"b165\", \"b200\", \"temp\", \"press\"]\n", + "ROLL = [\"rms\", \"kurt\", \"b150\", \"b165\", \"b200\", \"temp\", \"press\"]\n", + "\n", + "\n", + "def build_features(snap, W):\n", + " # [TODO : 학생 실습 4] 오토인코더(Autoencoder)/LSTM 기반 미세 진동 변화 감지 예지정비(PdM) 모델 고도화 관련 핵심 로직을 구현하세요.\n", + " # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n", + " # <<< 여기에 코드를 작성하세요 >>>\n", + " pass # 작성 후 pass를 제거하세요.\n", + "DERIVED = [\"temp_res\", \"press_res\", \"rms_n\", \"hf_ratio\"] + [c + \"_m3\" for c in ROLL] + [\"rms_n_d\", \"temp_res_d\", \"press_res_d\"]\n", + "FEATS = BASE + DERIVED\n", + "AE_COLS = [\"rms_n\", \"kurt\", \"b150\", \"b165\", \"b200\", \"hf_ratio\", \"temp_res\", \"press_res\"]\n", + "\n", + "\n", + "def split_units(units, n_train=24):\n", + " return list(units[\"unit\"][:n_train]), list(units[\"unit\"][n_train:])\n", + "\n", + "\n", + "class AE:\n", + " \"\"\"오토인코더(입력 8 -> 10 -> 4 -> 10 -> 8): 정상 자료만 재구성하도록 학습하고, 재구성 오차를 이상 점수로 쓴다.\"\"\"\n", + "\n", + " def __init__(self, cols=AE_COLS, q=0.99, seed=0):\n", + " self.cols, self.q, self.seed = cols, q, seed\n", + "\n", + " def fit(self, healthy):\n", + " self.sc = StandardScaler().fit(healthy[self.cols])\n", + " Z = self.sc.transform(healthy[self.cols])\n", + " self.net = MLPRegressor(hidden_layer_sizes=(10, 4, 10), max_iter=800, random_state=self.seed).fit(Z, Z)\n", + " self.thr = float(np.quantile(self.err(healthy), self.q))\n", + " return self\n", + "\n", + " def contrib(self, d):\n", + " Z = self.sc.transform(d[self.cols])\n", + " return (Z - self.net.predict(Z)) ** 2 # 입력 항목별 재구성 오차(이상 기여도)\n", + "\n", + " def err(self, d):\n", + " return self.contrib(d).mean(axis=1)\n", + "\n", + "\n", + "def latch(d, e, thr, k=2):\n", + " \"\"\"같은 개체에서 이상 점수가 k일 연속 기준을 넘으면 경보를 켜고 유지(래치)한다.\"\"\"\n", + " out, e = np.zeros(len(d), bool), np.asarray(e)\n", + " for idx in d.reset_index(drop=True).groupby(\"unit\").indices.values():\n", + " run, on = 0, False\n", + " for i in idx:\n", + " run = run + 1 if e[i] > thr else 0\n", + " on = on or run >= k\n", + " out[i] = on\n", + " return out\n", + "\n", + "units, snap, W = make_runs()\n", + "D = build_features(snap, W)\n", + "tr_u, va_u = split_units(units)\n", + "tr, va = D[D[\"unit\"].isin(tr_u)].reset_index(drop=True), D[D[\"unit\"].isin(va_u)].reset_index(drop=True)\n", + "print(f\"[교육용가상자료] 학습 {len(tr_u)}대({len(tr)}건), 검증 {len(va_u)}대({len(va)}건), 파생 피처 {len(DERIVED)}개 (목표 10개 이상: {'달성' if len(DERIVED) >= 10 else '미달'})\")\n", + "print(\" 파생 피처:\", \", \".join(DERIVED))\n", + "\n", + "# 1) 오토인코더 이상 감지 vs 임계값 Baseline (검증 개체)\n", + "ae = AE().fit(tr[tr[\"deg\"] == 0])\n", + "e = ae.err(va)\n", + "va[\"anomaly\"] = va[\"deg\"] >= 0.1\n", + "va[\"ae_alarm\"] = latch(va, e, ae.thr, k=2)\n", + "va[\"th_alarm\"] = (va[\"rms\"] > 1.25) | (va[\"kurt\"] > 3.0) | (va[\"temp\"] > 92.0) | (va[\"press\"] < 405.0)\n", + "va[\"ae_raw\"] = e > ae.thr\n", + "\n", + "\n", + "def report(name, col):\n", + " a = va[col]\n", + " tpr, far = (a & va[\"anomaly\"]).sum() / va[\"anomaly\"].sum(), (a & ~va[\"anomaly\"]).sum() / (~va[\"anomaly\"]).sum()\n", + " hi = a[va[\"hi_load\"] & ~va[\"anomaly\"]].mean()\n", + " lead = [g.loc[g[col] & g[\"anomaly\"], \"rul\"].max() if (g[col] & g[\"anomaly\"]).any() else 0 for _, g in va.groupby(\"unit\")]\n", + " print(f\" {name:22s} 탐지율 {tpr:5.1%} 오경보율 {far:5.1%} 고부하일 오경보 {hi:5.1%} 고장 전 확보 중앙값 {np.median(lead):.0f}일\")\n", + " return tpr, far\n", + "\n", + "\n", + "print(f\"\\n[오토인코더 입력 {len(AE_COLS)}개, 정상 자료 재구성 오차 99분위 {ae.thr:.2f}를 기준으로 사용]\")\n", + "report(\"임계값 Baseline\", \"th_alarm\")\n", + "report(\"오토인코더(1회 초과)\", \"ae_raw\")\n", + "tpr, far = report(\"오토인코더(2일 연속)\", \"ae_alarm\")\n", + "print(f\" 미세 변화 구간(열화 0.1~0.3) 탐지율: Baseline {va.loc[va['deg'].between(0.1, 0.3), 'th_alarm'].mean():.1%} -> 오토인코더 {va.loc[va['deg'].between(0.1, 0.3), 'ae_alarm'].mean():.1%}\")\n", + "\n", + "# 2) RUL 기준 모델: 학습 개체 일부로 학습, 개체 단위 시계열 교차검증(앞선 개체로 학습 -> 뒤 개체 검증)\n", + "CAP = 120 # RUL 상한(일): 열화 초기에는 120일 이상은 구분하지 않는다\n", + "def fit_rul(d, cols):\n", + " return GradientBoostingRegressor(n_estimators=100, max_depth=3, learning_rate=0.1, subsample=0.8, random_state=0).fit(d[cols], np.minimum(d[\"rul\"], CAP))\n", + "\n", + "\n", + "def mape(d, p):\n", + " m = (d[\"deg\"] >= 0.1) & (d[\"rul\"] >= 10) # 열화가 시작된 구간, 고장 직전 10일 미만은 분모 문제로 제외\n", + " y = np.minimum(d[\"rul\"], CAP)[m]\n", + " return float(np.mean(np.abs(y - p[m]) / y))\n", + "\n", + "\n", + "order = list(units[\"unit\"])\n", + "folds, cut = [], [12, 24, 36]\n", + "for i, c in enumerate(cut):\n", + " trn, val = D[D[\"unit\"].isin(order[:c])], D[D[\"unit\"].isin(order[c:c + 12])]\n", + " if len(val):\n", + " folds.append(mape(val, fit_rul(trn, FEATS).predict(val[FEATS])))\n", + "base_only = fit_rul(tr, BASE)\n", + "full = fit_rul(tr, FEATS)\n", + "m_base, m_full = mape(va, base_only.predict(va[BASE])), mape(va, full.predict(va[FEATS]))\n", + "print(f\"\\n[RUL 모델] 개체 단위 시계열 교차검증 폴드별 MAPE: {', '.join(f'{f:.1%}' for f in folds)} -> 평균 {np.mean(folds):.1%} (목표 25% 이하: {'달성' if np.mean(folds) <= 0.25 else '미달'})\")\n", + "print(f\" 검증 24대 MAPE: 원본 센서 {len(BASE)}개 {m_base:.1%} -> 파생 포함 {len(FEATS)}개 {m_full:.1%} (열화 구간, 잔존 10일 이상)\")\n", + "\n", + "# 3) 순열 중요도로 차원 축소\n", + "pi = permutation_importance(full, va[FEATS], np.minimum(va[\"rul\"], CAP), n_repeats=3, random_state=0, scoring=\"neg_mean_absolute_error\")\n", + "imp = pd.Series(pi.importances_mean, FEATS).sort_values(ascending=False)\n", + "print(\"피처 중요도 상위 5: \" + \", \".join(f\"{k}({v:.1f})\" for k, v in imp.head(5).items()))\n", + "top = list(imp.index[:8])\n", + "m_top = mape(va, fit_rul(tr, top).predict(va[top]))\n", + "print(f\"차원 축소 {len(FEATS)}개 -> {len(top)}개: 검증 MAPE {m_full:.1%} -> {m_top:.1%}\")\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 04 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + "[교육용가상자료] 학습 24대(3847건), 검증 24대(3886건), 파생 피처 14개 (목표 10개 이상: 달성)\n", + " 파생 피처: temp_res, press_res, rms_n, hf_ratio, rms_m3, kurt_m3, b150_m3, b165_m3, b200_m3, temp_m3, press_m3, rms_n_d, temp_res_d, press_res_d\n", + "\n", + "[오토인코더 입력 8개, 정상 자료 재구성 오차 99분위 1.27를 기준으로 사용]\n", + " 임계값 Baseline 탐지율 22.7% 오경보율 1.7% 고부하일 오경보 22.8% 고장 전 확보 중앙값 40일\n", + " 오토인코더(1회 초과) 탐지율 80.1% 오경보율 1.2% 고부하일 오경보 0.6% 고장 전 확보 중앙값 66일\n", + " 오토인코더(2일 연속) 탐지율 79.2% 오경보율 0.0% 고부하일 오경보 0.0% 고장 전 확보 중앙값 56일\n", + " 미세 변화 구간(열화 0.1~0.3) 탐지율: Baseline 2.0% -> 오토인코더 32.4%\n", + "\n", + "[RUL 모델] 개체 단위 시계열 교차검증 폴드별 MAPE: 26.7%, 24.2%, 17.1% -> 평균 22.7% (목표 25% 이하: 달성)\n", + " 검증 24대 MAPE: 원본 센서 11개 43.3% -> 파생 포함 25개 20.4% (열화 구간, 잔존 10일 이상)\n", + "피처 중요도 상위 5: hf_ratio(11.8), temp_res(7.0), b150_m3(3.8), b165_m3(2.8), press_m3(2.5)\n", + "차원 축소 25개 -> 8개: 검증 MAPE 20.4% -> 20.8%\n", + "```\n", + "
\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 05. 부품 고장 임박 시점(RUL) 예측 및 상태 기반 이상 신뢰도 점수 엔진 개발\n", + "**소요시간**: 약 3.5시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "부품 고장 임박 시점(RUL) 예측 및 상태 이상 신뢰도 점수 엔진을 개발한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **부품 고장 임박 시점(RUL)을 예측하는 앙상블 모델과 상태 이상 신뢰도 점수 엔진을 개발한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **그래디언트 부스팅, 랜덤 포레스트, 릿지를 평균하는 앙상블을 하이퍼파라미터 탐색으로 만들어 RMS와 온도만 쓰는 선형 기준 모델과 Hold-out 검증 개체에서 MAE, RMSE, R2를 비교 수행하고 검증하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `src/models/mnt04_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": [ + "\"\"\"진동·온도·압력 특징으로 부품 고장 임박 시점(RUL)을 예측하는 앙상블(그래디언트 부스팅+랜덤 포레스트+릿지) 모델을 하이퍼파라미터 탐색으로 만들어 단순 선형 기준 모델과 MAE·RMSE·R2를 비교하고, 오토인코더 이상 점수와 예측 구간으로 상태 이상 신뢰도 점수를 산출해 신뢰도 구간별 실제 정확도와 점검 카드를 출력한다.\"\"\"\n", + "import numpy as np\n", + "import pandas as pd\n", + "from scipy.stats import kurtosis\n", + "from sklearn.ensemble import GradientBoostingRegressor, RandomForestRegressor\n", + "from sklearn.linear_model import LinearRegression, Ridge\n", + "from sklearn.metrics import mean_absolute_error, mean_squared_error, r2_score\n", + "from sklearn.neural_network import MLPRegressor\n", + "from sklearn.pipeline import make_pipeline\n", + "from sklearn.preprocessing import StandardScaler\n", + "\n", + "# ---------------------------------------------------------------------------\n", + "# [교육용가상자료] 가상 기동체 48대의 엔진·구동계 진동·온도·압력 센서 자료 생성기 (실제 장비·부대·정비 실적 없음)\n", + "# 개체마다 정상 -> 열화 -> 고장(수명종료)까지 하루 1회 측정(진동 파형 256표본 + 온도 + 압력 + 부하)을 만든다.\n", + "# 열화 유형 4가지: bearing(베어링), gear(기어), oil_pump(오일 펌프), cooling(냉각 팬 모터)\n", + "# ---------------------------------------------------------------------------\n", + "FS, NS = 1024, 256 # 가상 표본화 주파수(Hz), 파형 1개당 표본 수\n", + "TT = np.arange(NS) / FS\n", + "MODES = { # 열화 유형: (발생 확률, 평균 수명(일), 교체 수리부속, 가상 단가지수)\n", + " \"bearing\": (0.40, 160, \"주 베어링 세트\", 6.0), \"gear\": (0.25, 175, \"구동 기어 세트\", 9.0),\n", + " \"oil_pump\": (0.20, 150, \"오일 펌프\", 4.0), \"cooling\": (0.15, 140, \"냉각 팬 모터\", 3.0)}\n", + "KIT_COST = 1.0 # 모든 정비에 함께 소모되는 씰·필터 키트 단가지수\n", + "BANDS = {\"b50\": (40, 60), \"b100\": (90, 110), \"b150\": (140, 156), \"b165\": (156, 176), \"b200\": (190, 215)}\n", + "\n", + "\n", + "def make_runs(n=48, seed=1):\n", + " \"\"\"반환: units(개체 정보), snap(일별 측정 요약), W(진동 파형 [측정 수, 256])\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " names = list(MODES)\n", + " mode = rng.choice(names, n, p=[MODES[m][0] for m in names])\n", + " life = np.clip(np.array([rng.normal(MODES[m][1], 18) for m in mode]), 90, 230).astype(int)\n", + " start = np.sort(rng.integers(0, 240, n))\n", + " units = pd.DataFrame({\"unit\": [f\"U{i:02d}\" for i in range(n)], \"mode\": mode, \"life\": life, \"start\": start, \"base_load\": rng.uniform(0.4, 0.75, n)})\n", + " rows = []\n", + " for u in units.itertuples():\n", + " d = np.arange(u.life + 1)\n", + " onset = 0.45 * u.life\n", + " deg = np.clip((d - onset) / (u.life - onset), 0, 1) ** 1.5\n", + " load = np.clip(u.base_load + rng.normal(0, 0.06, len(d)), 0.2, 1.0)\n", + " hi = rng.random(len(d)) < 0.07 # 고부하 운용일(정상이지만 진동·온도 상승)\n", + " load = np.where(hi, rng.uniform(0.95, 1.1, len(d)), load)\n", + " rows.append(pd.DataFrame({\"unit\": u.unit, \"day\": d, \"cal\": u.start + d, \"rul\": u.life - d, \"deg\": deg, \"load\": load, \"hi_load\": hi, \"mode\": u.mode}))\n", + " snap = pd.concat(rows, ignore_index=True)\n", + " S = len(snap)\n", + " snap[\"ambient\"] = 15 + 12 * np.sin(2 * np.pi * snap[\"cal\"] / 365) + rng.normal(0, 2, S)\n", + " dg, ld, md = snap[\"deg\"].values, snap[\"load\"].values, snap[\"mode\"].values\n", + " isb, isg, iso, isc = (md == \"bearing\"), (md == \"gear\"), (md == \"oil_pump\"), (md == \"cooling\")\n", + " snap[\"temp\"] = 55 + 22 * ld + 0.5 * snap[\"ambient\"] + 9 * dg * iso + 26 * dg * isc + 3 * dg * (isb | isg) + rng.normal(0, 1.5, S)\n", + " snap[\"press\"] = 450 - 12 * ld - 85 * dg * iso - 15 * dg * isc + rng.normal(0, 6, S)\n", + " ph = rng.uniform(0, 2 * np.pi, (S, 3))\n", + " a1, a2 = 1.0 * (0.5 + ld), 0.4 * (0.5 + ld)\n", + " W = (a1[:, None] * np.sin(2 * np.pi * 50 * TT + ph[:, :1]) + a2[:, None] * np.sin(2 * np.pi * 100 * TT + ph[:, 1:2])\n", + " + rng.normal(0, 0.35, (S, NS)))\n", + " W += (0.9 * dg * isb)[:, None] * np.sin(2 * np.pi * 165 * TT + ph[:, 2:3])\n", + " W += (0.8 * dg * isg)[:, None] * (np.sin(2 * np.pi * 150 * TT + ph[:, 2:3]) + 0.8 * np.sin(2 * np.pi * 100 * TT))\n", + " W += (0.5 * dg * iso)[:, None] * np.sin(2 * np.pi * 200 * TT + ph[:, 2:3])\n", + " k = rng.poisson(10 * dg * isb) # 베어링 열화: 충격성 펄스(첨도 상승)\n", + " pos, amp = rng.integers(0, NS, (S, 30)), rng.normal(0, 3.0, (S, 30)) * dg[:, None]\n", + " amp = amp * (np.arange(30)[None, :] < k[:, None])\n", + " np.add.at(W, (np.repeat(np.arange(S), 30), pos.ravel()), amp.ravel())\n", + " return units, snap, W\n", + "\n", + "\n", + "def extract(W):\n", + " \"\"\"진동 파형 -> 특징량: RMS, 첨도, 파고율, 최대-최소, FFT 대역 에너지(50/100/150/165/200Hz 부근)\"\"\"\n", + " # [TODO : 학생 실습 5] 부품 고장 임박 시점(RUL) 예측 및 상태 기반 이상 신뢰도 점수 엔진 개발 관련 핵심 로직을 구현하세요.\n", + " # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n", + " # <<< 여기에 코드를 작성하세요 >>>\n", + " pass # 작성 후 pass를 제거하세요.\n", + "BASE = [\"rms\", \"kurt\", \"crest\", \"p2p\", \"b50\", \"b100\", \"b150\", \"b165\", \"b200\", \"temp\", \"press\"]\n", + "ROLL = [\"rms\", \"kurt\", \"b150\", \"b165\", \"b200\", \"temp\", \"press\"]\n", + "\n", + "\n", + "def build_features(snap, W):\n", + " # [TODO : 학생 실습 5] 부품 고장 임박 시점(RUL) 예측 및 상태 기반 이상 신뢰도 점수 엔진 개발 관련 핵심 로직을 구현하세요.\n", + " # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n", + " # <<< 여기에 코드를 작성하세요 >>>\n", + " pass # 작성 후 pass를 제거하세요.\n", + "DERIVED = [\"temp_res\", \"press_res\", \"rms_n\", \"hf_ratio\"] + [c + \"_m3\" for c in ROLL] + [\"rms_n_d\", \"temp_res_d\", \"press_res_d\"]\n", + "FEATS = BASE + DERIVED\n", + "AE_COLS = [\"rms_n\", \"kurt\", \"b150\", \"b165\", \"b200\", \"hf_ratio\", \"temp_res\", \"press_res\"]\n", + "\n", + "\n", + "def split_units(units, n_train=24):\n", + " return list(units[\"unit\"][:n_train]), list(units[\"unit\"][n_train:])\n", + "\n", + "\n", + "class AE:\n", + " \"\"\"오토인코더(입력 8 -> 10 -> 4 -> 10 -> 8): 정상 자료만 재구성하도록 학습하고, 재구성 오차를 이상 점수로 쓴다.\"\"\"\n", + "\n", + " def __init__(self, cols=AE_COLS, q=0.99, seed=0):\n", + " self.cols, self.q, self.seed = cols, q, seed\n", + "\n", + " def fit(self, healthy):\n", + " self.sc = StandardScaler().fit(healthy[self.cols])\n", + " Z = self.sc.transform(healthy[self.cols])\n", + " self.net = MLPRegressor(hidden_layer_sizes=(10, 4, 10), max_iter=800, random_state=self.seed).fit(Z, Z)\n", + " self.thr = float(np.quantile(self.err(healthy), self.q))\n", + " return self\n", + "\n", + " def contrib(self, d):\n", + " Z = self.sc.transform(d[self.cols])\n", + " return (Z - self.net.predict(Z)) ** 2 # 입력 항목별 재구성 오차(이상 기여도)\n", + "\n", + " def err(self, d):\n", + " return self.contrib(d).mean(axis=1)\n", + "\n", + "\n", + "def latch(d, e, thr, k=2):\n", + " \"\"\"같은 개체에서 이상 점수가 k일 연속 기준을 넘으면 경보를 켜고 유지(래치)한다.\"\"\"\n", + " out, e = np.zeros(len(d), bool), np.asarray(e)\n", + " for idx in d.reset_index(drop=True).groupby(\"unit\").indices.values():\n", + " run, on = 0, False\n", + " for i in idx:\n", + " run = run + 1 if e[i] > thr else 0\n", + " on = on or run >= k\n", + " out[i] = on\n", + " return out\n", + "\n", + "CAP = 120\n", + "units, snap, W = make_runs()\n", + "D = build_features(snap, W)\n", + "D[\"hf_max\"] = D.groupby(\"unit\")[\"hf_ratio\"].cummax() # 지금까지 가장 높았던 고주파 비(열화는 되돌아가지 않는다)\n", + "D[\"temp_res_max\"] = D.groupby(\"unit\")[\"temp_res\"].cummax()\n", + "FEATS = FEATS + [\"day\", \"hf_max\", \"temp_res_max\"] # 운용일수(day)는 정비 이력에서 아는 값\n", + "tr_u, va_u = split_units(units)\n", + "tr, va = D[D[\"unit\"].isin(tr_u)].reset_index(drop=True), D[D[\"unit\"].isin(va_u)].reset_index(drop=True)\n", + "ytr, yva = np.minimum(tr[\"rul\"], CAP), np.minimum(va[\"rul\"], CAP)\n", + "print(f\"[교육용가상자료] 학습 {len(tr_u)}대 {len(tr)}건, Hold-out 검증 {len(va_u)}대 {len(va)}건 (RUL은 {CAP}일 상한)\")\n", + "\n", + "# 1) 기준 모델: 진동 RMS와 온도만 쓰는 선형 회귀\n", + "base = LinearRegression().fit(tr[[\"rms\", \"temp\"]], ytr)\n", + "pb = base.predict(va[[\"rms\", \"temp\"]])\n", + "\n", + "# 2) 고도화: 하이퍼파라미터 탐색(학습 개체의 뒤쪽 6대를 내부 검증) 후 앙상블\n", + "inner_tr, inner_va = tr[tr[\"unit\"].isin(tr_u[:18])], tr[tr[\"unit\"].isin(tr_u[18:])]\n", + "\n", + "\n", + "def ens(d_tr, y, d_va, depth, alpha):\n", + " ms = [GradientBoostingRegressor(n_estimators=100, max_depth=depth, learning_rate=0.1, subsample=0.8, random_state=0),\n", + " RandomForestRegressor(n_estimators=40, max_depth=10, min_samples_leaf=5, n_jobs=-1, random_state=0),\n", + " make_pipeline(StandardScaler(), Ridge(alpha=alpha))]\n", + " for m in ms:\n", + " m.fit(d_tr[FEATS], y)\n", + " return ms, np.mean([m.predict(d_va[FEATS]) for m in ms], axis=0)\n", + "\n", + "\n", + "score = {(dp, al): mean_absolute_error(np.minimum(inner_va[\"rul\"], CAP), ens(inner_tr, np.minimum(inner_tr[\"rul\"], CAP), inner_va, dp, al)[1])\n", + " for dp in (3, 4) for al in (1, 10)}\n", + "dp, al = min(score, key=score.get)\n", + "models, pe = ens(tr, ytr, va, dp, al)\n", + "names = [\"GBR\", \"RF\", \"Ridge\"]\n", + "print(f\"선택 하이퍼파라미터: GBR depth={dp}, ridge alpha={al} (내부 검증 MAE {score[(dp, al)]:.1f}일)\")\n", + "\n", + "\n", + "def metrics(p):\n", + " return mean_absolute_error(yva, p), mean_squared_error(yva, p) ** 0.5, r2_score(yva, p)\n", + "\n", + "\n", + "mb, me = metrics(pb), metrics(pe)\n", + "print(\"\\n[RUL 예측 성능] 모델 | MAE(일) | RMSE(일) | R2\")\n", + "print(f\" 기준(RMS+온도 선형) {mb[0]:6.2f} {mb[1]:6.2f} {mb[2]:.3f}\")\n", + "for n, m in zip(names, models):\n", + " x = metrics(m.predict(va[FEATS]))\n", + " print(f\" 단일 {n:12s} {x[0]:6.2f} {x[1]:6.2f} {x[2]:.3f}\")\n", + "print(f\" 고도화(앙상블) {me[0]:6.2f} {me[1]:6.2f} {me[2]:.3f}\")\n", + "imp_mae, imp_rmse = 1 - me[0] / mb[0], 1 - me[1] / mb[1]\n", + "print(f\"개선율 MAE {imp_mae:.1%}, RMSE {imp_rmse:.1%} (목표 20% 이상: {'달성' if min(imp_mae, imp_rmse) >= 0.2 else '미달'}), R2 {me[2]:.3f} (목표 0.85 이상: {'달성' if me[2] >= 0.85 else '미달'})\")\n", + "for lo, hi, nm in [(0, 30, \"고장 30일 이내\"), (30, 90, \"30~90일\"), (90, 9999, \"90일 이상\")]:\n", + " m = (va[\"rul\"] >= lo) & (va[\"rul\"] < hi)\n", + " print(f\" 구간 {nm:10s} MAE {mean_absolute_error(yva[m], pe[m]):5.1f}일 (기준 {mean_absolute_error(yva[m], pb[m]):5.1f}일)\")\n", + "\n", + "# 3) 상태 이상 신뢰도 점수 = 오토인코더 이상 점수(정상 대비 배수)와 앙상블 구성 모델 간 의견 일치도를 결합\n", + "ae = AE().fit(tr[tr[\"deg\"] == 0])\n", + "e = ae.err(va)\n", + "z = np.log(e / ae.thr)\n", + "conf = 1 / (1 + np.exp(-2.5 * z)) # 재구성 오차가 기준과 같으면 0.5\n", + "spread = np.std([m.predict(va[FEATS]) for m in models], axis=0)\n", + "agree = np.clip(1 - spread / 40, 0, 1) # 모델 간 RUL 의견 차이가 작을수록 높음\n", + "score_c = conf * (0.7 + 0.3 * agree)\n", + "truth = va[\"deg\"] >= 0.1\n", + "print(\"\\n[상태 이상 신뢰도] 구간 | 건수 | 평균 점수 | 실제 열화 비율\")\n", + "bins = [0, 0.2, 0.5, 0.8, 1.01]\n", + "for a, b in zip(bins[:-1], bins[1:]):\n", + " m = (score_c >= a) & (score_c < b)\n", + " print(f\" {a:.1f}~{min(b, 1):.1f} {m.sum():5d} {score_c[m].mean():.2f} {truth[m].mean():.2f}\")\n", + "ece = sum(((score_c >= a) & (score_c < b)).mean() * abs(score_c[(score_c >= a) & (score_c < b)].mean() - truth[(score_c >= a) & (score_c < b)].mean())\n", + " for a, b in zip(bins[:-1], bins[1:]) if ((score_c >= a) & (score_c < b)).any())\n", + "print(f\"기대 보정 오차(ECE) {ece:.3f}\")\n", + "\n", + "# 4) 점검 카드 예시(개체 하나의 최근 상태)\n", + "print(\"\\n[점검 카드 예시] 개체 | 측정일 | 이상 신뢰도 | 예측 RUL(일) | 실제 RUL(일)\")\n", + "va[\"conf\"], va[\"rul_hat\"] = score_c, pe\n", + "for u in va_u[:3]:\n", + " g = va[va[\"unit\"] == u]\n", + " r = g[g[\"rul\"] == 40].iloc[0] if (g[\"rul\"] == 40).any() else g.iloc[-1]\n", + " print(f\" {u} {int(r['day']):3d}일째 {r['conf']:.2f} {r['rul_hat']:5.1f} {int(r['rul'])} -> {'정비 계획 수립 권고' if r['conf'] >= 0.8 else '추가 관찰'}(검사관 확인)\")\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 05 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + "[교육용가상자료] 학습 24대 3847건, Hold-out 검증 24대 3886건 (RUL은 120일 상한)\n", + "선택 하이퍼파라미터: GBR depth=4, ridge alpha=1 (내부 검증 MAE 6.8일)\n", + "\n", + "[RUL 예측 성능] 모델 | MAE(일) | RMSE(일) | R2\n", + " 기준(RMS+온도 선형) 35.67 41.43 -0.078\n", + " 단일 GBR 9.45 13.37 0.888\n", + " 단일 RF 8.00 12.31 0.905\n", + " 단일 Ridge 9.98 12.78 0.897\n", + " 고도화(앙상블) 8.56 12.05 0.909\n", + "개선율 MAE 76.0%, RMSE 70.9% (목표 20% 이상: 달성), R2 0.909 (목표 0.85 이상: 달성)\n", + " 구간 고장 30일 이내 MAE 5.7일 (기준 44.8일)\n", + " 구간 30~90일 MAE 10.2일 (기준 16.9일)\n", + " 구간 90일 이상 MAE 8.4일 (기준 47.5일)\n", + "\n", + "[상태 이상 신뢰도] 구간 | 건수 | 평균 점수 | 실제 열화 비율\n", + " 0.0~0.2 2242 0.04 0.10\n", + " 0.2~0.5 282 0.31 0.41\n", + " 0.5~0.8 161 0.65 0.86\n", + " 0.8~1.0 1201 0.95 1.00\n", + "기대 보정 오차(ECE) 0.065\n", + "\n", + "[점검 카드 예시] 개체 | 측정일 | 이상 신뢰도 | 예측 RUL(일) | 실제 RUL(일)\n", + " U24 94일째 0.01 56.0 40 -> 추가 관찰(검사관 확인)\n", + " U25 95일째 0.62 56.6 40 -> 추가 관찰(검사관 확인)\n", + " U26 148일째 0.92 28.0 40 -> 정비 계획 수립 권고(검사관 확인)\n", + "```\n", + "
\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 06. 장비 센서 상태 실시간 모니터링 및 결함 징후 경보 대시보드 구현\n", + "**소요시간**: 약 3.0시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "장비 센서 상태 실시간 모니터링 및 결함 경보 대시보드를 구현한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **장비 센서 상태를 개체별로 모니터링해 결함 경보를 띄우는 정비병용 화면을 구성하고, 경보 건마다 기여도 상위 3개 인자와 자연어 요약을 자동 산출하는 설명가능성(XAI) 모듈을 구현한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **오토인코더 재구성 오차를 입력 인자별로 나누어 경보 건마다 상위 3개 인자를 뽑고, 1순위 인자를 정상값으로 되돌렸을 때 점수가 내려가는지로 근거 유효성을 점검 수행하고 검증하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `src/explainability/mnt04_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개 인자를 자동 산출하며(근거 유효성 검증), 의심 부품과 자연어 요약 문장을 생성하는 설명가능성(XAI) 모듈을 구현한다.\"\"\"\n", + "import numpy as np\n", + "import pandas as pd\n", + "import matplotlib.pyplot as plt\n", + "from scipy.stats import kurtosis\n", + "from sklearn.ensemble import GradientBoostingRegressor\n", + "from sklearn.neural_network import MLPRegressor\n", + "from sklearn.preprocessing import StandardScaler\n", + "\n", + "# ---------------------------------------------------------------------------\n", + "# [교육용가상자료] 가상 기동체 48대의 엔진·구동계 진동·온도·압력 센서 자료 생성기 (실제 장비·부대·정비 실적 없음)\n", + "# 개체마다 정상 -> 열화 -> 고장(수명종료)까지 하루 1회 측정(진동 파형 256표본 + 온도 + 압력 + 부하)을 만든다.\n", + "# 열화 유형 4가지: bearing(베어링), gear(기어), oil_pump(오일 펌프), cooling(냉각 팬 모터)\n", + "# ---------------------------------------------------------------------------\n", + "FS, NS = 1024, 256 # 가상 표본화 주파수(Hz), 파형 1개당 표본 수\n", + "TT = np.arange(NS) / FS\n", + "MODES = { # 열화 유형: (발생 확률, 평균 수명(일), 교체 수리부속, 가상 단가지수)\n", + " \"bearing\": (0.40, 160, \"주 베어링 세트\", 6.0), \"gear\": (0.25, 175, \"구동 기어 세트\", 9.0),\n", + " \"oil_pump\": (0.20, 150, \"오일 펌프\", 4.0), \"cooling\": (0.15, 140, \"냉각 팬 모터\", 3.0)}\n", + "KIT_COST = 1.0 # 모든 정비에 함께 소모되는 씰·필터 키트 단가지수\n", + "BANDS = {\"b50\": (40, 60), \"b100\": (90, 110), \"b150\": (140, 156), \"b165\": (156, 176), \"b200\": (190, 215)}\n", + "\n", + "\n", + "def make_runs(n=48, seed=1):\n", + " \"\"\"반환: units(개체 정보), snap(일별 측정 요약), W(진동 파형 [측정 수, 256])\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " names = list(MODES)\n", + " mode = rng.choice(names, n, p=[MODES[m][0] for m in names])\n", + " life = np.clip(np.array([rng.normal(MODES[m][1], 18) for m in mode]), 90, 230).astype(int)\n", + " start = np.sort(rng.integers(0, 240, n))\n", + " units = pd.DataFrame({\"unit\": [f\"U{i:02d}\" for i in range(n)], \"mode\": mode, \"life\": life, \"start\": start, \"base_load\": rng.uniform(0.4, 0.75, n)})\n", + " rows = []\n", + " for u in units.itertuples():\n", + " d = np.arange(u.life + 1)\n", + " onset = 0.45 * u.life\n", + " deg = np.clip((d - onset) / (u.life - onset), 0, 1) ** 1.5\n", + " load = np.clip(u.base_load + rng.normal(0, 0.06, len(d)), 0.2, 1.0)\n", + " hi = rng.random(len(d)) < 0.07 # 고부하 운용일(정상이지만 진동·온도 상승)\n", + " load = np.where(hi, rng.uniform(0.95, 1.1, len(d)), load)\n", + " rows.append(pd.DataFrame({\"unit\": u.unit, \"day\": d, \"cal\": u.start + d, \"rul\": u.life - d, \"deg\": deg, \"load\": load, \"hi_load\": hi, \"mode\": u.mode}))\n", + " snap = pd.concat(rows, ignore_index=True)\n", + " S = len(snap)\n", + " snap[\"ambient\"] = 15 + 12 * np.sin(2 * np.pi * snap[\"cal\"] / 365) + rng.normal(0, 2, S)\n", + " dg, ld, md = snap[\"deg\"].values, snap[\"load\"].values, snap[\"mode\"].values\n", + " isb, isg, iso, isc = (md == \"bearing\"), (md == \"gear\"), (md == \"oil_pump\"), (md == \"cooling\")\n", + " snap[\"temp\"] = 55 + 22 * ld + 0.5 * snap[\"ambient\"] + 9 * dg * iso + 26 * dg * isc + 3 * dg * (isb | isg) + rng.normal(0, 1.5, S)\n", + " snap[\"press\"] = 450 - 12 * ld - 85 * dg * iso - 15 * dg * isc + rng.normal(0, 6, S)\n", + " ph = rng.uniform(0, 2 * np.pi, (S, 3))\n", + " a1, a2 = 1.0 * (0.5 + ld), 0.4 * (0.5 + ld)\n", + " W = (a1[:, None] * np.sin(2 * np.pi * 50 * TT + ph[:, :1]) + a2[:, None] * np.sin(2 * np.pi * 100 * TT + ph[:, 1:2])\n", + " + rng.normal(0, 0.35, (S, NS)))\n", + " W += (0.9 * dg * isb)[:, None] * np.sin(2 * np.pi * 165 * TT + ph[:, 2:3])\n", + " W += (0.8 * dg * isg)[:, None] * (np.sin(2 * np.pi * 150 * TT + ph[:, 2:3]) + 0.8 * np.sin(2 * np.pi * 100 * TT))\n", + " W += (0.5 * dg * iso)[:, None] * np.sin(2 * np.pi * 200 * TT + ph[:, 2:3])\n", + " k = rng.poisson(10 * dg * isb) # 베어링 열화: 충격성 펄스(첨도 상승)\n", + " pos, amp = rng.integers(0, NS, (S, 30)), rng.normal(0, 3.0, (S, 30)) * dg[:, None]\n", + " amp = amp * (np.arange(30)[None, :] < k[:, None])\n", + " np.add.at(W, (np.repeat(np.arange(S), 30), pos.ravel()), amp.ravel())\n", + " return units, snap, W\n", + "\n", + "\n", + "def extract(W):\n", + " \"\"\"진동 파형 -> 특징량: RMS, 첨도, 파고율, 최대-최소, FFT 대역 에너지(50/100/150/165/200Hz 부근)\"\"\"\n", + " # [TODO : 학생 실습 6] 장비 센서 상태 실시간 모니터링 및 결함 징후 경보 대시보드 구현 관련 핵심 로직을 구현하세요.\n", + " # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n", + " # <<< 여기에 코드를 작성하세요 >>>\n", + " pass # 작성 후 pass를 제거하세요.\n", + "BASE = [\"rms\", \"kurt\", \"crest\", \"p2p\", \"b50\", \"b100\", \"b150\", \"b165\", \"b200\", \"temp\", \"press\"]\n", + "ROLL = [\"rms\", \"kurt\", \"b150\", \"b165\", \"b200\", \"temp\", \"press\"]\n", + "\n", + "\n", + "def build_features(snap, W):\n", + " # [TODO : 학생 실습 6] 장비 센서 상태 실시간 모니터링 및 결함 징후 경보 대시보드 구현 관련 핵심 로직을 구현하세요.\n", + " # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n", + " # <<< 여기에 코드를 작성하세요 >>>\n", + " pass # 작성 후 pass를 제거하세요.\n", + "DERIVED = [\"temp_res\", \"press_res\", \"rms_n\", \"hf_ratio\"] + [c + \"_m3\" for c in ROLL] + [\"rms_n_d\", \"temp_res_d\", \"press_res_d\"]\n", + "FEATS = BASE + DERIVED\n", + "AE_COLS = [\"rms_n\", \"kurt\", \"b150\", \"b165\", \"b200\", \"hf_ratio\", \"temp_res\", \"press_res\"]\n", + "\n", + "\n", + "def split_units(units, n_train=24):\n", + " return list(units[\"unit\"][:n_train]), list(units[\"unit\"][n_train:])\n", + "\n", + "\n", + "class AE:\n", + " \"\"\"오토인코더(입력 8 -> 10 -> 4 -> 10 -> 8): 정상 자료만 재구성하도록 학습하고, 재구성 오차를 이상 점수로 쓴다.\"\"\"\n", + "\n", + " def __init__(self, cols=AE_COLS, q=0.99, seed=0):\n", + " self.cols, self.q, self.seed = cols, q, seed\n", + "\n", + " def fit(self, healthy):\n", + " self.sc = StandardScaler().fit(healthy[self.cols])\n", + " Z = self.sc.transform(healthy[self.cols])\n", + " self.net = MLPRegressor(hidden_layer_sizes=(10, 4, 10), max_iter=800, random_state=self.seed).fit(Z, Z)\n", + " self.thr = float(np.quantile(self.err(healthy), self.q))\n", + " return self\n", + "\n", + " def contrib(self, d):\n", + " Z = self.sc.transform(d[self.cols])\n", + " return (Z - self.net.predict(Z)) ** 2 # 입력 항목별 재구성 오차(이상 기여도)\n", + "\n", + " def err(self, d):\n", + " return self.contrib(d).mean(axis=1)\n", + "\n", + "\n", + "def latch(d, e, thr, k=2):\n", + " \"\"\"같은 개체에서 이상 점수가 k일 연속 기준을 넘으면 경보를 켜고 유지(래치)한다.\"\"\"\n", + " out, e = np.zeros(len(d), bool), np.asarray(e)\n", + " for idx in d.reset_index(drop=True).groupby(\"unit\").indices.values():\n", + " run, on = 0, False\n", + " for i in idx:\n", + " run = run + 1 if e[i] > thr else 0\n", + " on = on or run >= k\n", + " out[i] = on\n", + " return out\n", + "\n", + "units, snap, W = make_runs()\n", + "D = build_features(snap, W)\n", + "tr_u, va_u = split_units(units)\n", + "tr, va = D[D[\"unit\"].isin(tr_u)].reset_index(drop=True), D[D[\"unit\"].isin(va_u)].reset_index(drop=True)\n", + "ae = AE().fit(tr[tr[\"deg\"] == 0])\n", + "C = ae.contrib(va) # 건 x 입력항목 기여도\n", + "e = C.mean(axis=1)\n", + "va[\"alarm\"] = latch(va, e, ae.thr, k=2)\n", + "va[\"score\"] = e / ae.thr\n", + "\n", + "LABEL = {\"rms_n\": \"부하 보정 진동 크기(RMS)\", \"kurt\": \"진동 충격성(첨도)\", \"b150\": \"150Hz 부근 진동(기어 맞물림 대역)\", \"b165\": \"165Hz 부근 진동(베어링 결함 대역)\",\n", + " \"b200\": \"200Hz 부근 진동(펌프 대역)\", \"hf_ratio\": \"고주파/기본주파 에너지 비\", \"temp_res\": \"부하 대비 온도 상승\", \"press_res\": \"부하 대비 압력 저하\"}\n", + "PART = {\"b165\": \"주 베어링 세트\", \"kurt\": \"주 베어링 세트\", \"b150\": \"구동 기어 세트\", \"b200\": \"오일 펌프\", \"press_res\": \"오일 펌프\", \"temp_res\": \"냉각 팬 모터\"}\n", + "TRUE_PART = {m: MODES[m][2] for m in MODES}\n", + "cols = ae.cols\n", + "\n", + "# 1) 경보 건마다 기여도 상위 3개 인자 자동 산출\n", + "idx = np.where(va[\"alarm\"].values)[0]\n", + "top3 = np.argsort(-C[idx], axis=1)[:, :3]\n", + "ok = sum(len(set(t)) == 3 for t in top3)\n", + "print(f\"[교육용가상자료] 검증 {len(va_u)}대 {len(va)}건 중 경보 {len(idx)}건: 기여도 상위 3개 인자 산출 {ok}건 (산출률 {ok / len(idx):.0%}, 목표 100%: {'달성' if ok == len(idx) else '미달'})\")\n", + "\n", + "# 2) 근거 유효성: 1순위 인자를 정상 평균(표준화 0)으로 되돌리면 이상 점수가 낮아지는가\n", + "Z = ae.sc.transform(va[cols].iloc[idx])\n", + "Z2 = Z.copy()\n", + "Z2[np.arange(len(idx)), top3[:, 0]] = 0.0\n", + "e1, e2 = ((Z - ae.net.predict(Z)) ** 2).mean(axis=1), ((Z2 - ae.net.predict(Z2)) ** 2).mean(axis=1)\n", + "print(f\"근거 유효성: 1순위 인자를 정상값으로 되돌리면 이상 점수가 낮아지는 비율 {(e2 < e1).mean():.1%} (평균 {e1.mean():.2f} -> {e2.mean():.2f})\")\n", + "\n", + "# 3) 의심 부품: 1순위 인자 -> 부품 매핑 (열화가 뚜렷한 경보에서 정답 비교)\n", + "sub = va.iloc[idx].copy()\n", + "sub[\"top1\"] = [cols[t[0]] for t in top3]\n", + "sub[\"hint\"] = sub[\"top1\"].map(PART).fillna(\"미분류(점검 필요)\")\n", + "sub[\"true_part\"] = sub[\"mode\"].map(TRUE_PART)\n", + "strong = sub[sub[\"deg\"] >= 0.3]\n", + "print(f\"의심 부품 추정 정확도(열화 0.3 이상 경보 {len(strong)}건): {(strong['hint'] == strong['true_part']).mean():.1%}\")\n", + "print(\" 1순위 인자 분포: \" + \", \".join(f\"{LABEL[k].split('(')[0]} {v}\" for k, v in sub[\"top1\"].value_counts().head(4).items()))\n", + "\n", + "\n", + "def summarize(r, t):\n", + " f = [LABEL[cols[i]] for i in t]\n", + " part = PART.get(cols[t[0]], \"원인 불명\")\n", + " return f\"{part} 이상 가능성(이상 점수 {r['score']:.1f}배). 주요 근거: {f[0]}, {f[1]}, {f[2]}.\"\n", + "\n", + "\n", + "# 4) 정비병용 화면: 점검 카드와 상태표\n", + "print(\"\\n[점검 카드] 개체 | 측정일 | 실제 열화 유형\")\n", + "for m in MODES:\n", + " k = sub[(sub[\"mode\"] == m) & (sub[\"deg\"] > 0.5)].index[0]\n", + " j = list(sub.index).index(k)\n", + " r = sub.loc[k]\n", + " print(f\" {r['unit']} {int(r['day'])}일째 ({m})\")\n", + " print(\" \" + summarize(r, top3[j]))\n", + " print(\" 근거 기여도: \" + \" / \".join(f\"{LABEL[cols[i]].split('(')[0]}({C[idx[j], i]:.1f})\" for i in top3[j]))\n", + "\n", + "CAL = int(va.groupby('cal')['alarm'].sum().idxmax()) # 경보가 가장 많은 날을 가상 기준일로 쓴다\n", + "act = va[(va[\"cal\"] == CAL)]\n", + "reg = {\"정상\": 0, \"주의\": 0, \"경보\": 0}\n", + "rows = []\n", + "for r in act.itertuples():\n", + " st = \"경보\" if r.alarm else (\"주의\" if r.score > 0.7 else \"정상\")\n", + " reg[st] += 1\n", + "for st in (\"경보\", \"주의\"):\n", + " for r in act[(act[\"alarm\"] if st == \"경보\" else (~act[\"alarm\"] & (act[\"score\"] > 0.7)))].itertuples():\n", + " rows.append((r.unit, st, f\"{r.score:.1f}\", int(r.rul)))\n", + "print(f\"\\n[운용 상태표] 가상 기준일 {CAL}: 가동 중 {len(act)}대 -> \" + \", \".join(f\"{k} {v}대\" for k, v in reg.items()))\n", + "for r in rows[:5]:\n", + " print(f\" {r[0]} {r[1]} 이상 점수 {r[2]}배 (실제 잔존 {r[3]}일)\")\n", + "\n", + "plt.figure(figsize=(8, 4))\n", + "for u in sub[\"unit\"].unique()[:3]:\n", + " g = va[va[\"unit\"] == u]\n", + " plt.plot(g[\"day\"], g[\"score\"], label=u)\n", + "plt.axhline(1.0, ls=\"--\", color=\"k\"); plt.yscale(\"log\"); plt.xlabel(\"day\"); plt.ylabel(\"anomaly score (x threshold)\"); plt.legend(); plt.title(\"Monitoring view: anomaly score\")\n", + "plt.tight_layout(); plt.show()\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 06 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + "[교육용가상자료] 검증 24대 3886건 중 경보 1327건: 기여도 상위 3개 인자 산출 1327건 (산출률 100%, 목표 100%: 달성)\n", + "근거 유효성: 1순위 인자를 정상값으로 되돌리면 이상 점수가 낮아지는 비율 98.9% (평균 21.19 -> 6.57)\n", + "의심 부품 추정 정확도(열화 0.3 이상 경보 1167건): 99.3%\n", + " 1순위 인자 분포: 150Hz 부근 진동 603, 165Hz 부근 진동 338, 부하 대비 온도 상승 146, 부하 대비 압력 저하 129\n", + "\n", + "[점검 카드] 개체 | 측정일 | 실제 열화 유형\n", + " U27 131일째 (bearing)\n", + " 주 베어링 세트 이상 가능성(이상 점수 7.1배). 주요 근거: 165Hz 부근 진동(베어링 결함 대역), 150Hz 부근 진동(기어 맞물림 대역), 부하 대비 압력 저하.\n", + " 근거 기여도: 165Hz 부근 진동(36.6) / 150Hz 부근 진동(22.9) / 부하 대비 압력 저하(5.4)\n", + " U26 150일째 (gear)\n", + " 구동 기어 세트 이상 가능성(이상 점수 10.4배). 주요 근거: 150Hz 부근 진동(기어 맞물림 대역), 165Hz 부근 진동(베어링 결함 대역), 진동 충격성(첨도).\n", + " 근거 기여도: 150Hz 부근 진동(60.3) / 165Hz 부근 진동(19.9) / 진동 충격성(10.9)\n", + " U25 108일째 (oil_pump)\n", + " 오일 펌프 이상 가능성(이상 점수 6.0배). 주요 근거: 부하 대비 압력 저하, 200Hz 부근 진동(펌프 대역), 165Hz 부근 진동(베어링 결함 대역).\n", + " 근거 기여도: 부하 대비 압력 저하(29.3) / 200Hz 부근 진동(22.0) / 165Hz 부근 진동(4.7)\n", + " U24 107일째 (cooling)\n", + " 냉각 팬 모터 이상 가능성(이상 점수 4.4배). 주요 근거: 부하 대비 온도 상승, 200Hz 부근 진동(펌프 대역), 진동 충격성(첨도).\n", + " 근거 기여도: 부하 대비 온도 상승(34.5) / 200Hz 부근 진동(3.9) / 진동 충격성(2.4)\n", + "\n", + "[운용 상태표] 가상 기준일 308: 가동 중 18대 -> 정상 6대, 주의 1대, 경보 11대\n", + " U26 경보 이상 점수 30.5배 (실제 잔존 1일)\n", + " U28 경보 이상 점수 55.0배 (실제 잔존 5일)\n", + " U31 경보 이상 점수 85.1배 (실제 잔존 1일)\n", + " U32 경보 이상 점수 12.9배 (실제 잔존 14일)\n", + " U33 경보 이상 점수 27.4배 (실제 잔존 14일)\n", + "```\n", + "
\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 07. 예방 정비 조기 입고 결정을 위한 항공/기갑 검사관 HITL 승인 모듈 설계\n", + "**소요시간**: 약 3.5시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "예방 정비 조기 입고 결정을 위한 검사관 HITL 승인 모듈을 설계한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **센서 경보 시 예방 정비 조기 입고를 제안하되 항공/기갑 검사관이 승인해야 확정되는 HITL 승인 모듈을 설계하고 효과를 시뮬레이션으로 검증한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **경보와 RUL 예측으로 입고일을 제안하고 승인 대기열에 올려 검사관의 승인·반려·무응답(기본 계획인 정기 교체 유지)을 사유와 함께 해시 체인 감사 로그로 남긴다 과정을 진행하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `src/optimization/mnt04_simulator.py` 형태의 결과물을 완성한다.\n", + "\n", + "### 🔍 평가 및 검증 기준 (PASS 조건)\n", + "#### 미션 판정\n", + "\n", + "**평가 구분:** 필수\n", + "\n", + "**핵심 평가영역:** 현실적 군수 제약조건 반영도 및 시나리오별 대안 제시 적절성\n", + "\n", + "#### 미션 PASS 조건\n", + "\n", + "- 정량 기준: 최적화 적용 시 비용 15% 절감 또는 재고 회전율 20% 향상\n", + "\n", + "\n", + "- 진행 기준: 선형/정수 계획법(LP/MIP) 최적화 해 도출 및 시뮬레이션 완료\n", + "\n", + "\n", + "- 검증 방법: 과거 실적 데이터 대비 최적화 결과 비교 백테스팅\n", + "\n", + "\n", + "#### 채점자 판정 메모\n", + "\n", + "> 단순 코드 실행 여부뿐만 아니라 출력된 수치와 근거 자료가 군 실무 요구사항에 부합하는지 면밀히 확인한다.\n", + "\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "\"\"\"센서 이상 경보가 나면 예방 정비 조기 입고일을 제안하되 자동 확정하지 않고 항공/기갑 검사관 승인 대기열에 올려 승인·반려·무응답(기본 계획 유지)을 사유와 함께 해시 체인 감사 로그에 남기는 HITL 승인 모듈을 시뮬레이션하고, 정기 교체 방식 대비 정비·고장 비용 절감률과 승인 지연·전시 전환(가동 강도 급증) 민감도를 분석한다.\"\"\"\n", + "import hashlib\n", + "import json\n", + "import numpy as np\n", + "import pandas as pd\n", + "import matplotlib.pyplot as plt\n", + "from scipy.stats import kurtosis\n", + "from sklearn.ensemble import GradientBoostingRegressor\n", + "from sklearn.neural_network import MLPRegressor\n", + "from sklearn.preprocessing import StandardScaler\n", + "\n", + "# ---------------------------------------------------------------------------\n", + "# [교육용가상자료] 가상 기동체 48대의 엔진·구동계 진동·온도·압력 센서 자료 생성기 (실제 장비·부대·정비 실적 없음)\n", + "# 개체마다 정상 -> 열화 -> 고장(수명종료)까지 하루 1회 측정(진동 파형 256표본 + 온도 + 압력 + 부하)을 만든다.\n", + "# 열화 유형 4가지: bearing(베어링), gear(기어), oil_pump(오일 펌프), cooling(냉각 팬 모터)\n", + "# ---------------------------------------------------------------------------\n", + "FS, NS = 1024, 256 # 가상 표본화 주파수(Hz), 파형 1개당 표본 수\n", + "TT = np.arange(NS) / FS\n", + "MODES = { # 열화 유형: (발생 확률, 평균 수명(일), 교체 수리부속, 가상 단가지수)\n", + " \"bearing\": (0.40, 160, \"주 베어링 세트\", 6.0), \"gear\": (0.25, 175, \"구동 기어 세트\", 9.0),\n", + " \"oil_pump\": (0.20, 150, \"오일 펌프\", 4.0), \"cooling\": (0.15, 140, \"냉각 팬 모터\", 3.0)}\n", + "KIT_COST = 1.0 # 모든 정비에 함께 소모되는 씰·필터 키트 단가지수\n", + "BANDS = {\"b50\": (40, 60), \"b100\": (90, 110), \"b150\": (140, 156), \"b165\": (156, 176), \"b200\": (190, 215)}\n", + "\n", + "\n", + "def make_runs(n=48, seed=1):\n", + " \"\"\"반환: units(개체 정보), snap(일별 측정 요약), W(진동 파형 [측정 수, 256])\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " names = list(MODES)\n", + " mode = rng.choice(names, n, p=[MODES[m][0] for m in names])\n", + " life = np.clip(np.array([rng.normal(MODES[m][1], 18) for m in mode]), 90, 230).astype(int)\n", + " start = np.sort(rng.integers(0, 240, n))\n", + " units = pd.DataFrame({\"unit\": [f\"U{i:02d}\" for i in range(n)], \"mode\": mode, \"life\": life, \"start\": start, \"base_load\": rng.uniform(0.4, 0.75, n)})\n", + " rows = []\n", + " for u in units.itertuples():\n", + " d = np.arange(u.life + 1)\n", + " onset = 0.45 * u.life\n", + " deg = np.clip((d - onset) / (u.life - onset), 0, 1) ** 1.5\n", + " load = np.clip(u.base_load + rng.normal(0, 0.06, len(d)), 0.2, 1.0)\n", + " hi = rng.random(len(d)) < 0.07 # 고부하 운용일(정상이지만 진동·온도 상승)\n", + " load = np.where(hi, rng.uniform(0.95, 1.1, len(d)), load)\n", + " rows.append(pd.DataFrame({\"unit\": u.unit, \"day\": d, \"cal\": u.start + d, \"rul\": u.life - d, \"deg\": deg, \"load\": load, \"hi_load\": hi, \"mode\": u.mode}))\n", + " snap = pd.concat(rows, ignore_index=True)\n", + " S = len(snap)\n", + " snap[\"ambient\"] = 15 + 12 * np.sin(2 * np.pi * snap[\"cal\"] / 365) + rng.normal(0, 2, S)\n", + " dg, ld, md = snap[\"deg\"].values, snap[\"load\"].values, snap[\"mode\"].values\n", + " isb, isg, iso, isc = (md == \"bearing\"), (md == \"gear\"), (md == \"oil_pump\"), (md == \"cooling\")\n", + " snap[\"temp\"] = 55 + 22 * ld + 0.5 * snap[\"ambient\"] + 9 * dg * iso + 26 * dg * isc + 3 * dg * (isb | isg) + rng.normal(0, 1.5, S)\n", + " snap[\"press\"] = 450 - 12 * ld - 85 * dg * iso - 15 * dg * isc + rng.normal(0, 6, S)\n", + " ph = rng.uniform(0, 2 * np.pi, (S, 3))\n", + " a1, a2 = 1.0 * (0.5 + ld), 0.4 * (0.5 + ld)\n", + " W = (a1[:, None] * np.sin(2 * np.pi * 50 * TT + ph[:, :1]) + a2[:, None] * np.sin(2 * np.pi * 100 * TT + ph[:, 1:2])\n", + " + rng.normal(0, 0.35, (S, NS)))\n", + " W += (0.9 * dg * isb)[:, None] * np.sin(2 * np.pi * 165 * TT + ph[:, 2:3])\n", + " W += (0.8 * dg * isg)[:, None] * (np.sin(2 * np.pi * 150 * TT + ph[:, 2:3]) + 0.8 * np.sin(2 * np.pi * 100 * TT))\n", + " W += (0.5 * dg * iso)[:, None] * np.sin(2 * np.pi * 200 * TT + ph[:, 2:3])\n", + " k = rng.poisson(10 * dg * isb) # 베어링 열화: 충격성 펄스(첨도 상승)\n", + " pos, amp = rng.integers(0, NS, (S, 30)), rng.normal(0, 3.0, (S, 30)) * dg[:, None]\n", + " amp = amp * (np.arange(30)[None, :] < k[:, None])\n", + " np.add.at(W, (np.repeat(np.arange(S), 30), pos.ravel()), amp.ravel())\n", + " return units, snap, W\n", + "\n", + "\n", + "def extract(W):\n", + " \"\"\"진동 파형 -> 특징량: RMS, 첨도, 파고율, 최대-최소, FFT 대역 에너지(50/100/150/165/200Hz 부근)\"\"\"\n", + " # [TODO : 학생 실습 7] 예방 정비 조기 입고 결정을 위한 항공/기갑 검사관 HITL 승인 모듈 설계 관련 핵심 로직을 구현하세요.\n", + " # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n", + " # <<< 여기에 코드를 작성하세요 >>>\n", + " pass # 작성 후 pass를 제거하세요.\n", + "BASE = [\"rms\", \"kurt\", \"crest\", \"p2p\", \"b50\", \"b100\", \"b150\", \"b165\", \"b200\", \"temp\", \"press\"]\n", + "ROLL = [\"rms\", \"kurt\", \"b150\", \"b165\", \"b200\", \"temp\", \"press\"]\n", + "\n", + "\n", + "def build_features(snap, W):\n", + " # [TODO : 학생 실습 7] 예방 정비 조기 입고 결정을 위한 항공/기갑 검사관 HITL 승인 모듈 설계 관련 핵심 로직을 구현하세요.\n", + " # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n", + " # <<< 여기에 코드를 작성하세요 >>>\n", + " pass # 작성 후 pass를 제거하세요.\n", + "DERIVED = [\"temp_res\", \"press_res\", \"rms_n\", \"hf_ratio\"] + [c + \"_m3\" for c in ROLL] + [\"rms_n_d\", \"temp_res_d\", \"press_res_d\"]\n", + "FEATS = BASE + DERIVED\n", + "AE_COLS = [\"rms_n\", \"kurt\", \"b150\", \"b165\", \"b200\", \"hf_ratio\", \"temp_res\", \"press_res\"]\n", + "\n", + "\n", + "def split_units(units, n_train=24):\n", + " return list(units[\"unit\"][:n_train]), list(units[\"unit\"][n_train:])\n", + "\n", + "\n", + "class AE:\n", + " \"\"\"오토인코더(입력 8 -> 10 -> 4 -> 10 -> 8): 정상 자료만 재구성하도록 학습하고, 재구성 오차를 이상 점수로 쓴다.\"\"\"\n", + "\n", + " def __init__(self, cols=AE_COLS, q=0.99, seed=0):\n", + " self.cols, self.q, self.seed = cols, q, seed\n", + "\n", + " def fit(self, healthy):\n", + " self.sc = StandardScaler().fit(healthy[self.cols])\n", + " Z = self.sc.transform(healthy[self.cols])\n", + " self.net = MLPRegressor(hidden_layer_sizes=(10, 4, 10), max_iter=800, random_state=self.seed).fit(Z, Z)\n", + " self.thr = float(np.quantile(self.err(healthy), self.q))\n", + " return self\n", + "\n", + " def contrib(self, d):\n", + " Z = self.sc.transform(d[self.cols])\n", + " return (Z - self.net.predict(Z)) ** 2 # 입력 항목별 재구성 오차(이상 기여도)\n", + "\n", + " def err(self, d):\n", + " return self.contrib(d).mean(axis=1)\n", + "\n", + "\n", + "def latch(d, e, thr, k=2):\n", + " \"\"\"같은 개체에서 이상 점수가 k일 연속 기준을 넘으면 경보를 켜고 유지(래치)한다.\"\"\"\n", + " out, e = np.zeros(len(d), bool), np.asarray(e)\n", + " for idx in d.reset_index(drop=True).groupby(\"unit\").indices.values():\n", + " run, on = 0, False\n", + " for i in idx:\n", + " run = run + 1 if e[i] > thr else 0\n", + " on = on or run >= k\n", + " out[i] = on\n", + " return out\n", + "\n", + "PERIOD = 90 # 기본 계획: 정기 교체 주기(운용일). 가장 빠른 고장 사례보다 짧게 정해 사후 고장을 막는 보수적 주기\n", + "UNPLANNED = (2.0, 12.0) # [교육용가상자료] 고장 후 정비 비용 = 부품 단가 x 2.0 + 연쇄 손상·가동중단 12.0\n", + "PLANNED_FIXED = 1.0 # 입고 정비 작업 고정 비용(지수)\n", + "INSPECT = {\"base\": 0.3, \"pdm\": 0.05} # 30일당 점검 비용: 정기 방식은 분해 점검, 센서 감시 방식은 경보 시 확인 점검 위주(가정)\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 add_feat(D):\n", + " D[\"hf_max\"] = D.groupby(\"unit\")[\"hf_ratio\"].cummax(); D[\"temp_res_max\"] = D.groupby(\"unit\")[\"temp_res\"].cummax()\n", + " return D\n", + "\n", + "\n", + "F7 = FEATS + [\"day\", \"hf_max\", \"temp_res_max\"]\n", + "u1, s1, w1 = make_runs(seed=1)\n", + "D1 = add_feat(build_features(s1, w1))\n", + "tr_u, _ = split_units(u1)\n", + "tr = D1[D1[\"unit\"].isin(tr_u)]\n", + "ae = AE().fit(tr[tr[\"deg\"] == 0])\n", + "gbr = GradientBoostingRegressor(n_estimators=100, max_depth=3, learning_rate=0.1, subsample=0.8, random_state=0).fit(tr[F7], np.minimum(tr[\"rul\"], 120))\n", + "\n", + "# 시험 개체: 다른 시드로 만든 가상 개체 3묶음(144대)에서 개체별 첫 경보일과 그때의 RUL 예측을 모은다\n", + "cases = []\n", + "for sd in (2, 3, 4):\n", + " un, sn, ww = make_runs(seed=sd)\n", + " D = add_feat(build_features(sn, ww))\n", + " D[\"alarm\"] = latch(D, ae.err(D), ae.thr, k=2)\n", + " D[\"rul_hat\"] = gbr.predict(D[F7])\n", + " for u in un.itertuples():\n", + " g = D[D[\"unit\"] == u.unit]\n", + " a = g[g[\"alarm\"]]\n", + " cases.append({\"id\": f\"S{sd}-{u.unit}\", \"mode\": u.mode, \"life\": int(u.life), \"a\": int(a[\"day\"].iloc[0]) if len(a) else None,\n", + " \"rul_hat\": float(a[\"rul_hat\"].iloc[0]) if len(a) else None, \"load\": float(a[\"load\"].iloc[0]) if len(a) else None})\n", + "C = pd.DataFrame(cases)\n", + "print(f\"[교육용가상자료] 시험 개체 {len(C)}대(수명 {C['life'].min()}~{C['life'].max()}일), 경보 발생 {int(C['a'].notna().sum())}대\")\n", + "\n", + "\n", + "def cost(mode, life, maint, kind=\"pdm\"):\n", + " p = MODES[mode][3] + KIT_COST + INSPECT[kind] * min(maint if maint is not None else life, life) / 30\n", + " if maint is None or maint >= life: # 정비 전에 고장\n", + " return MODES[mode][3] * UNPLANNED[0] + KIT_COST + UNPLANNED[1] + INSPECT[kind] * life / 30, True\n", + " return p + PLANNED_FIXED + MODES[mode][3] * (life - maint) / life, False # 남은 수명 낭비분 포함\n", + "\n", + "\n", + "def commander(row, rng):\n", + " \"\"\"[모의 검사관] 잔존 예측이 길면 반려, 10%는 무응답.\"\"\"\n", + " if rng.random() < 0.10:\n", + " return \"무응답\", \"기한 내 응답 없음 -> 기본 계획(정기 교체) 유지\"\n", + " if row[\"rul_hat\"] > 90:\n", + " return \"반려\", f\"예측 잔존 {row['rul_hat']:.0f}일로 충분 -> 현 계획 유지\"\n", + " if row[\"load\"] > 1.0:\n", + " return \"반려\", \"고부하 운용일 신호로 판단 -> 추가 관찰\"\n", + " return \"승인\", f\"이상 지속, 예측 잔존 {row['rul_hat']:.0f}일 -> 조기 입고\"\n", + "\n", + "\n", + "def run(mode, delay=2, scale=1.0, seed=0, audit=None):\n", + " \"\"\"mode: base(정기 교체) / hitl(제안 -> 검사관 결정) / auto(모든 제안 적용: 비교용 상한, 실제 운용 금지)\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " tot, fails, dec = 0.0, 0, {\"승인\": 0, \"반려\": 0, \"무응답\": 0}\n", + " for _, r in C.iterrows():\n", + " life = r[\"life\"] / scale\n", + " maint, kind = PERIOD, \"base\"\n", + " if mode != \"base\" and r[\"a\"] is not None:\n", + " a, rh = r[\"a\"] / scale, r[\"rul_hat\"] / scale\n", + " inbound = a + max(int(0.4 * rh), 3)\n", + " row = {\"rul_hat\": rh, \"load\": r[\"load\"]}\n", + " if mode == \"auto\":\n", + " d, why = \"승인\", \"비교용 전부 적용\"\n", + " else:\n", + " d, why = commander(row, rng)\n", + " audit.add(event=\"PROPOSE\", id=r[\"id\"], day=round(a), inbound=int(inbound), status=\"PENDING\")\n", + " audit.add(event=\"DECIDE\", id=r[\"id\"], decision=d, reason=why, by=\"검사관(모의)\")\n", + " dec[d] += 1\n", + " if d == \"승인\":\n", + " maint, kind = inbound + (delay if mode == \"hitl\" else 0), \"pdm\"\n", + " c, f = cost(r[\"mode\"], life, maint, kind)\n", + " tot += c; fails += f\n", + " return tot, fails, dec\n", + "\n", + "\n", + "b_cost, b_fail, _ = run(\"base\")\n", + "audit = Audit()\n", + "h_cost, h_fail, dec = run(\"hitl\", seed=3, audit=audit)\n", + "a_cost, a_fail, _ = run(\"auto\")\n", + "print(\"\\n[비용 비교] 방식 | 비용지수 | 사후 고장 건수\")\n", + "for nm, c, f in [(\"정기 교체(기본 계획)\", b_cost, b_fail), (\"경보 제안 + 검사관 승인\", h_cost, h_fail), (\"제안 전부 적용(비교용)\", a_cost, a_fail)]:\n", + " print(f\" {nm:22s} {c:8.0f} {f:3d}\")\n", + "cut = 1 - h_cost / b_cost\n", + "print(f\"HITL 적용 비용 절감률 {cut:.1%} (목표 15% 이상: {'달성' if cut >= 0.15 else '미달'}), 고장 {b_fail} -> {h_fail}건\")\n", + "print(f\"결정 {sum(dec.values())}건: \" + \", \".join(f\"{k} {v}건\" for k, v in dec.items()))\n", + "n_prop = sum(e[\"event\"] == \"PROPOSE\" for e in audit.log); n_dec = sum(e[\"event\"] == \"DECIDE\" for e in audit.log)\n", + "print(f\"제안 {n_prop}건 중 결정 기록 {n_dec}건(로깅률 {n_dec / n_prop:.0%}), 자동 승인 0건, 무응답 시 기본 계획 유지\")\n", + "rej = [e[\"reason\"] for e in audit.log if e.get(\"decision\") == \"반려\"]\n", + "print(\"반려 사유 예:\", rej[0] if rej else \"-\")\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[민감도 1] 승인 후 입고까지 지연(일) -> 비용 절감률\")\n", + "for d in (0, 2, 7, 14, 21):\n", + " c, f, _ = run(\"hitl\", delay=d, seed=3, audit=Audit())\n", + " print(f\" {d:2d}일: {1 - c / b_cost:.1%} (고장 {f}건)\")\n", + "print(\"[민감도 2] 가동 강도 급증(전시 전환 가정, 열화가 빨라져 수명이 1/배수로 단축) -> 절감률\")\n", + "for s in (1.0, 1.3, 1.6, 2.0):\n", + " bc, bf, _ = run(\"base\", scale=s); hc, hf, _ = run(\"hitl\", scale=s, seed=3, audit=Audit())\n", + " print(f\" x{s}: 정기 교체 비용 {bc:.0f}(고장 {bf}) -> HITL {hc:.0f}(고장 {hf}), 절감 {1 - hc / bc:.1%}\")\n", + "\n", + "plt.figure(figsize=(6, 4))\n", + "plt.bar([\"periodic\", \"HITL\", \"auto(ref)\"], [b_cost, h_cost, a_cost]); plt.ylabel(\"cost index\"); plt.title(\"HITL early-inbound simulator\")\n", + "plt.tight_layout(); plt.show()\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 07 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + "[교육용가상자료] 시험 개체 144대(수명 101~212일), 경보 발생 144대\n", + "\n", + "[비용 비교] 방식 | 비용지수 | 사후 고장 건수\n", + " 정기 교체(기본 계획) 1640 0\n", + " 경보 제안 + 검사관 승인 1389 1\n", + " 제안 전부 적용(비교용) 1364 1\n", + "HITL 적용 비용 절감률 15.3% (목표 15% 이상: 달성), 고장 0 -> 1건\n", + "결정 144건: 승인 126건, 반려 7건, 무응답 11건\n", + "제안 144건 중 결정 기록 144건(로깅률 100%), 자동 승인 0건, 무응답 시 기본 계획 유지\n", + "반려 사유 예: 고부하 운용일 신호로 판단 -> 추가 관찰\n", + "감사 로그 무결성: 정상, 사후 변조 시 seq 5에서 탐지\n", + "\n", + "[민감도 1] 승인 후 입고까지 지연(일) -> 비용 절감률\n", + " 0일: 14.8% (고장 1건)\n", + " 2일: 15.3% (고장 1건)\n", + " 7일: 14.1% (고장 4건)\n", + " 14일: 10.5% (고장 10건)\n", + " 21일: 1.8% (고장 21건)\n", + "[민감도 2] 가동 강도 급증(전시 전환 가정, 열화가 빨라져 수명이 1/배수로 단축) -> 절감률\n", + " x1.0: 정기 교체 비용 1640(고장 0) -> HITL 1389(고장 1), 절감 15.3%\n", + " x1.3: 정기 교체 비용 1571(고장 5) -> HITL 1363(고장 1), 절감 13.2%\n", + " x1.6: 정기 교체 비용 2005(고장 41) -> HITL 1451(고장 8), 절감 27.7%\n", + " x2.0: 정기 교체 비용 3394(고장 128) -> HITL 1572(고장 16), 절감 53.7%\n", + "```\n", + "
\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 08. 장시간 비행/기동 센서 데이터 대용량 스트림 주입 스트레스 테스트\n", + "**소요시간**: 약 3.0시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "장시간 기동 센서 데이터 대용량 스트림 주입 스트레스 테스트를 수행한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **장시간 기동 센서 데이터를 대용량 스트림으로 주입해 처리 시간, 경보 정확도, 불량 패킷 격리를 확인하는 스트레스 시험을 수행한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **가상 센서 측정 1,000건에 결측 패킷, 센서 고정, 포화, 범위 이탈을 섞어 한꺼번에 주입하고 정상 처리·격리·유실 건수와 처리 시간을 센다 과정을 진행하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `src/dashboard/mnt04_app.py (Streamlit/Dash 등)` 형태의 결과물을 완성한다.\n", + "\n", + "### 🔍 평가 및 검증 기준 (PASS 조건)\n", + "#### 미션 판정\n", + "\n", + "**평가 구분:** 필수\n", + "\n", + "**핵심 평가영역:** 의사결정 지원에 최적화된 시각적 위계 및 사용자 경험(UX)\n", + "\n", + "#### 미션 PASS 조건\n", + "\n", + "- 정량 기준: 대시보드 로딩 시간 2초 이내, 경보 발령 정확도 95% 이상\n", + "\n", + "\n", + "- 진행 기준: 웹 기반 분석 대시보드 구동 및 실시간 필터링 기능 검증\n", + "\n", + "\n", + "- 검증 방법: 실제 군수 데이터 연동 화면 테스트 및 UI 동작 검증\n", + "\n", + "\n", + "#### 채점자 판정 메모\n", + "\n", + "> 단순 코드 실행 여부뿐만 아니라 출력된 수치와 근거 자료가 군 실무 요구사항에 부합하는지 면밀히 확인한다.\n", + "\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "\"\"\"장시간 기동 센서 데이터를 1,000~100,000건 규모로 한꺼번에 주입해 검증·특징 추출·오토인코더 점수·경보 판정을 배치로 처리하는 스트림 시험을 수행하고, 처리 시간과 상태 화면 구성 시간, 경보 정확도, 불량 패킷(결측·고정값·포화·중복) 격리와 유실 0건 여부를 확인한다.\"\"\"\n", + "import time\n", + "import numpy as np\n", + "import pandas as pd\n", + "import matplotlib.pyplot as plt\n", + "from scipy.stats import kurtosis\n", + "from sklearn.neural_network import MLPRegressor\n", + "from sklearn.preprocessing import StandardScaler\n", + "\n", + "# ---------------------------------------------------------------------------\n", + "# [교육용가상자료] 가상 기동체 48대의 엔진·구동계 진동·온도·압력 센서 자료 생성기 (실제 장비·부대·정비 실적 없음)\n", + "# 개체마다 정상 -> 열화 -> 고장(수명종료)까지 하루 1회 측정(진동 파형 256표본 + 온도 + 압력 + 부하)을 만든다.\n", + "# 열화 유형 4가지: bearing(베어링), gear(기어), oil_pump(오일 펌프), cooling(냉각 팬 모터)\n", + "# ---------------------------------------------------------------------------\n", + "FS, NS = 1024, 256 # 가상 표본화 주파수(Hz), 파형 1개당 표본 수\n", + "TT = np.arange(NS) / FS\n", + "MODES = { # 열화 유형: (발생 확률, 평균 수명(일), 교체 수리부속, 가상 단가지수)\n", + " \"bearing\": (0.40, 160, \"주 베어링 세트\", 6.0), \"gear\": (0.25, 175, \"구동 기어 세트\", 9.0),\n", + " \"oil_pump\": (0.20, 150, \"오일 펌프\", 4.0), \"cooling\": (0.15, 140, \"냉각 팬 모터\", 3.0)}\n", + "KIT_COST = 1.0 # 모든 정비에 함께 소모되는 씰·필터 키트 단가지수\n", + "BANDS = {\"b50\": (40, 60), \"b100\": (90, 110), \"b150\": (140, 156), \"b165\": (156, 176), \"b200\": (190, 215)}\n", + "\n", + "\n", + "def make_runs(n=48, seed=1):\n", + " \"\"\"반환: units(개체 정보), snap(일별 측정 요약), W(진동 파형 [측정 수, 256])\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " names = list(MODES)\n", + " mode = rng.choice(names, n, p=[MODES[m][0] for m in names])\n", + " life = np.clip(np.array([rng.normal(MODES[m][1], 18) for m in mode]), 90, 230).astype(int)\n", + " start = np.sort(rng.integers(0, 240, n))\n", + " units = pd.DataFrame({\"unit\": [f\"U{i:02d}\" for i in range(n)], \"mode\": mode, \"life\": life, \"start\": start, \"base_load\": rng.uniform(0.4, 0.75, n)})\n", + " rows = []\n", + " for u in units.itertuples():\n", + " d = np.arange(u.life + 1)\n", + " onset = 0.45 * u.life\n", + " deg = np.clip((d - onset) / (u.life - onset), 0, 1) ** 1.5\n", + " load = np.clip(u.base_load + rng.normal(0, 0.06, len(d)), 0.2, 1.0)\n", + " hi = rng.random(len(d)) < 0.07 # 고부하 운용일(정상이지만 진동·온도 상승)\n", + " load = np.where(hi, rng.uniform(0.95, 1.1, len(d)), load)\n", + " rows.append(pd.DataFrame({\"unit\": u.unit, \"day\": d, \"cal\": u.start + d, \"rul\": u.life - d, \"deg\": deg, \"load\": load, \"hi_load\": hi, \"mode\": u.mode}))\n", + " snap = pd.concat(rows, ignore_index=True)\n", + " S = len(snap)\n", + " snap[\"ambient\"] = 15 + 12 * np.sin(2 * np.pi * snap[\"cal\"] / 365) + rng.normal(0, 2, S)\n", + " dg, ld, md = snap[\"deg\"].values, snap[\"load\"].values, snap[\"mode\"].values\n", + " isb, isg, iso, isc = (md == \"bearing\"), (md == \"gear\"), (md == \"oil_pump\"), (md == \"cooling\")\n", + " snap[\"temp\"] = 55 + 22 * ld + 0.5 * snap[\"ambient\"] + 9 * dg * iso + 26 * dg * isc + 3 * dg * (isb | isg) + rng.normal(0, 1.5, S)\n", + " snap[\"press\"] = 450 - 12 * ld - 85 * dg * iso - 15 * dg * isc + rng.normal(0, 6, S)\n", + " ph = rng.uniform(0, 2 * np.pi, (S, 3))\n", + " a1, a2 = 1.0 * (0.5 + ld), 0.4 * (0.5 + ld)\n", + " W = (a1[:, None] * np.sin(2 * np.pi * 50 * TT + ph[:, :1]) + a2[:, None] * np.sin(2 * np.pi * 100 * TT + ph[:, 1:2])\n", + " + rng.normal(0, 0.35, (S, NS)))\n", + " W += (0.9 * dg * isb)[:, None] * np.sin(2 * np.pi * 165 * TT + ph[:, 2:3])\n", + " W += (0.8 * dg * isg)[:, None] * (np.sin(2 * np.pi * 150 * TT + ph[:, 2:3]) + 0.8 * np.sin(2 * np.pi * 100 * TT))\n", + " W += (0.5 * dg * iso)[:, None] * np.sin(2 * np.pi * 200 * TT + ph[:, 2:3])\n", + " k = rng.poisson(10 * dg * isb) # 베어링 열화: 충격성 펄스(첨도 상승)\n", + " pos, amp = rng.integers(0, NS, (S, 30)), rng.normal(0, 3.0, (S, 30)) * dg[:, None]\n", + " amp = amp * (np.arange(30)[None, :] < k[:, None])\n", + " np.add.at(W, (np.repeat(np.arange(S), 30), pos.ravel()), amp.ravel())\n", + " return units, snap, W\n", + "\n", + "\n", + "def extract(W):\n", + " \"\"\"진동 파형 -> 특징량: RMS, 첨도, 파고율, 최대-최소, FFT 대역 에너지(50/100/150/165/200Hz 부근)\"\"\"\n", + " # [TODO : 학생 실습 8] 장시간 비행/기동 센서 데이터 대용량 스트림 주입 스트레스 테스트 관련 핵심 로직을 구현하세요.\n", + " # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n", + " # <<< 여기에 코드를 작성하세요 >>>\n", + " pass # 작성 후 pass를 제거하세요.\n", + "BASE = [\"rms\", \"kurt\", \"crest\", \"p2p\", \"b50\", \"b100\", \"b150\", \"b165\", \"b200\", \"temp\", \"press\"]\n", + "ROLL = [\"rms\", \"kurt\", \"b150\", \"b165\", \"b200\", \"temp\", \"press\"]\n", + "\n", + "\n", + "def build_features(snap, W):\n", + " # [TODO : 학생 실습 8] 장시간 비행/기동 센서 데이터 대용량 스트림 주입 스트레스 테스트 관련 핵심 로직을 구현하세요.\n", + " # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n", + " # <<< 여기에 코드를 작성하세요 >>>\n", + " pass # 작성 후 pass를 제거하세요.\n", + "DERIVED = [\"temp_res\", \"press_res\", \"rms_n\", \"hf_ratio\"] + [c + \"_m3\" for c in ROLL] + [\"rms_n_d\", \"temp_res_d\", \"press_res_d\"]\n", + "FEATS = BASE + DERIVED\n", + "AE_COLS = [\"rms_n\", \"kurt\", \"b150\", \"b165\", \"b200\", \"hf_ratio\", \"temp_res\", \"press_res\"]\n", + "\n", + "\n", + "def split_units(units, n_train=24):\n", + " return list(units[\"unit\"][:n_train]), list(units[\"unit\"][n_train:])\n", + "\n", + "\n", + "class AE:\n", + " \"\"\"오토인코더(입력 8 -> 10 -> 4 -> 10 -> 8): 정상 자료만 재구성하도록 학습하고, 재구성 오차를 이상 점수로 쓴다.\"\"\"\n", + "\n", + " def __init__(self, cols=AE_COLS, q=0.99, seed=0):\n", + " self.cols, self.q, self.seed = cols, q, seed\n", + "\n", + " def fit(self, healthy):\n", + " self.sc = StandardScaler().fit(healthy[self.cols])\n", + " Z = self.sc.transform(healthy[self.cols])\n", + " self.net = MLPRegressor(hidden_layer_sizes=(10, 4, 10), max_iter=800, random_state=self.seed).fit(Z, Z)\n", + " self.thr = float(np.quantile(self.err(healthy), self.q))\n", + " return self\n", + "\n", + " def contrib(self, d):\n", + " Z = self.sc.transform(d[self.cols])\n", + " return (Z - self.net.predict(Z)) ** 2 # 입력 항목별 재구성 오차(이상 기여도)\n", + "\n", + " def err(self, d):\n", + " return self.contrib(d).mean(axis=1)\n", + "\n", + "\n", + "def latch(d, e, thr, k=2):\n", + " \"\"\"같은 개체에서 이상 점수가 k일 연속 기준을 넘으면 경보를 켜고 유지(래치)한다.\"\"\"\n", + " out, e = np.zeros(len(d), bool), np.asarray(e)\n", + " for idx in d.reset_index(drop=True).groupby(\"unit\").indices.values():\n", + " run, on = 0, False\n", + " for i in idx:\n", + " run = run + 1 if e[i] > thr else 0\n", + " on = on or run >= k\n", + " out[i] = on\n", + " return out\n", + "\n", + "units, snap, W = make_runs()\n", + "D = build_features(snap, W)\n", + "tr_u, va_u = split_units(units)\n", + "tr, va = D[D[\"unit\"].isin(tr_u)].reset_index(drop=True), D[D[\"unit\"].isin(va_u)].reset_index(drop=True)\n", + "Wva = W[D[\"unit\"].isin(va_u).values]\n", + "ae = AE().fit(tr[tr[\"deg\"] == 0])\n", + "KEEP = [\"unit\", \"day\", \"load\", \"ambient\", \"temp\", \"press\"]\n", + "\n", + "\n", + "def score_batch(w, meta):\n", + " \"\"\"스트림 한 묶음: 파형 -> 특징 -> 이상 점수(배수). 점수는 개체 연속성(래치)과 무관한 단건 값.\"\"\"\n", + " f = extract(w)\n", + " d = pd.DataFrame({\"rms_n\": f[\"rms\"] / (0.5 + meta[\"load\"]), \"kurt\": f[\"kurt\"], \"b150\": f[\"b150\"], \"b165\": f[\"b165\"], \"b200\": f[\"b200\"],\n", + " \"hf_ratio\": (f[\"b150\"] + f[\"b165\"] + f[\"b200\"]) / (f[\"b50\"] + 1e-6),\n", + " \"temp_res\": meta[\"temp\"] - (55 + 22 * meta[\"load\"] + 0.5 * meta[\"ambient\"]), \"press_res\": meta[\"press\"] - (450 - 12 * meta[\"load\"])})\n", + " return ae.err(d) / ae.thr\n", + "\n", + "\n", + "def valid(w, meta):\n", + " \"\"\"불량 패킷 판정: 결측(NaN), 센서 고정(분산 0), 포화(|값|>12), 값 범위 이탈.\"\"\"\n", + " bad = np.isnan(w).any(axis=1) | (np.nanstd(w, axis=1) < 1e-6) | (np.abs(np.nan_to_num(w)).max(axis=1) > 12)\n", + " bad |= ~meta[\"temp\"].between(20, 160).values | ~meta[\"press\"].between(100, 600).values\n", + " return ~bad\n", + "\n", + "\n", + "def make_stream(n, bad_rate, seed=0):\n", + " rng = np.random.default_rng(seed)\n", + " pick = rng.integers(0, len(va), n)\n", + " w = Wva[pick] + rng.normal(0, 0.02, (n, NS))\n", + " meta = va.iloc[pick][KEEP + [\"deg\"]].reset_index(drop=True)\n", + " kind = np.array([\"\"] * n, dtype=object)\n", + " bad = rng.random(n) < bad_rate\n", + " k = rng.integers(0, 4, n)\n", + " for t, nm in enumerate([\"결측 패킷\", \"센서 고정\", \"포화\", \"범위 이탈\"]):\n", + " m = bad & (k == t)\n", + " kind[m] = nm\n", + " if t == 0: w[m, 10:40] = np.nan\n", + " if t == 1: w[m] = 0.5\n", + " if t == 2: w[m, ::7] = 50.0\n", + " if t == 3: meta.loc[m, \"temp\"] = 400.0\n", + " return w, meta, kind\n", + "\n", + "\n", + "# 1) 1,000건 동시 주입\n", + "t0 = time.perf_counter()\n", + "w, meta, kind = make_stream(1000, 0.06)\n", + "ok = valid(w, meta)\n", + "s = np.full(1000, np.nan)\n", + "s[ok] = score_batch(w[ok], meta[ok].reset_index(drop=True))\n", + "sec = time.perf_counter() - t0\n", + "inj = int((kind != \"\").sum())\n", + "print(f\"[교육용가상자료] 1,000건 동시 주입: 정상 처리 {int(ok.sum())}건, 격리 {int((~ok).sum())}건, 유실 {1000 - int(ok.sum()) - int((~ok).sum())}건, 처리 시간 {sec:.2f}초 (목표 2초 이내: {'달성' if sec <= 2 else '미달'})\")\n", + "print(f\" 불량 주입 {inj}건 중 격리 {int(((kind != '') & ~ok).sum())}건 (\" + \", \".join(f\"{k} {int((kind == k).sum())}\" for k in [\"결측 패킷\", \"센서 고정\", \"포화\", \"범위 이탈\"]) + \")\")\n", + "\n", + "# 2) 경보 정확도: 정상 구간과 열화가 뚜렷한 구간(deg>=0.3)을 판정 대상으로 하고, 열화 초기 회색 구간은 정확도에서 제외하고 따로 표시\n", + "al = s > 1.0\n", + "y = (meta[\"deg\"] >= 0.3).values\n", + "eval_m = ok & ((meta[\"deg\"] == 0).values | y)\n", + "acc = (al[eval_m] == y[eval_m]).mean()\n", + "tp, fp, fn = (al & y & ok).sum(), (al & ~y & eval_m).sum(), (~al & y & ok).sum()\n", + "print(f\"경보 정확도 {acc:.1%} (목표 95% 이상: {'달성' if acc >= 0.95 else '미달'}), 재현율 {tp / (tp + fn):.1%}, 정밀도 {tp / (tp + fp):.1%}, 경보 {int((al & ok).sum())}건\")\n", + "gray = ok & (meta[\"deg\"] > 0).values & (meta[\"deg\"] < 0.3).values\n", + "print(f\" 참고: 열화 초기(0~0.3) 구간 {int(gray.sum())}건 중 경보 {al[gray].mean():.0%}(회색 구간은 정확도에서 제외)\")\n", + "\n", + "# 3) 상태 화면 구성: 최근 30일 x 24대 요약(개체별 최댓값·경보 여부) 집계 시간\n", + "t0 = time.perf_counter()\n", + "last = va[va[\"day\"] >= va.groupby(\"unit\")[\"day\"].transform(\"max\") - 30]\n", + "sc = score_batch(Wva[last.index.values], last.reset_index(drop=True))\n", + "view = last.assign(score=sc).groupby(\"unit\").agg(max_score=(\"score\", \"max\"), last_score=(\"score\", \"last\"))\n", + "view[\"상태\"] = np.where(view[\"last_score\"] > 1, \"경보\", np.where(view[\"max_score\"] > 1, \"주의\", \"정상\"))\n", + "tv = time.perf_counter() - t0\n", + "print(f\"상태 화면 구성(24대 x 최근 30일 {len(last)}건) {tv:.2f}초 (목표 2초 이내: {'달성' if tv <= 2 else '미달'}), \" + \", \".join(f\"{k} {v}대\" for k, v in view[\"상태\"].value_counts().items()))\n", + "\n", + "# 4) 규모 확대: 배치 처리량과 메모리 한계 대응(배치 크기 5,000)\n", + "print(\"\\n[주입 규모별 처리] 건수 | 시간 | 처리량 | 격리 | 유실\")\n", + "sizes = (1000, 10000, 50000, 100000)\n", + "ts = []\n", + "for n in sizes:\n", + " w, meta, kind = make_stream(n, 0.06, seed=n)\n", + " t0 = time.perf_counter(); done = quarantine = 0\n", + " for i in range(0, n, 5000):\n", + " wb, mb = w[i:i + 5000], meta.iloc[i:i + 5000].reset_index(drop=True)\n", + " okb = valid(wb, mb)\n", + " if okb.any():\n", + " score_batch(wb[okb], mb[okb].reset_index(drop=True))\n", + " done += int(okb.sum()); quarantine += int((~okb).sum())\n", + " t = time.perf_counter() - t0; ts.append(t)\n", + " print(f\" {n:7d} {t:5.2f}초 {n / t:8.0f}건/초 {quarantine:5d} {n - done - quarantine}\")\n", + "print(\"운용 기준: 전체 격리율이 20%를 넘으면 센서 계통 점검 알림, 처리 지연 시 FFT 대역 5개만 계산하는 축소 모드로 전환\")\n", + "\n", + "plt.figure(figsize=(6, 4))\n", + "plt.plot(sizes, ts, marker=\"o\"); plt.xscale(\"log\"); plt.xlabel(\"stream size\"); plt.ylabel(\"seconds\"); plt.title(\"Stream stress test\")\n", + "plt.tight_layout(); plt.show()\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 08 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + "[교육용가상자료] 1,000건 동시 주입: 정상 처리 945건, 격리 55건, 유실 0건, 처리 시간 0.03초 (목표 2초 이내: 달성)\n", + " 불량 주입 55건 중 격리 55건 (결측 패킷 15, 센서 고정 16, 포화 15, 범위 이탈 9)\n", + "경보 정확도 98.0% (목표 95% 이상: 달성), 재현율 98.0%, 정밀도 97.3%, 경보 330건\n", + " 참고: 열화 초기(0~0.3) 구간 231건 중 경보 15%(회색 구간은 정확도에서 제외)\n", + "상태 화면 구성(24대 x 최근 30일 744건) 0.02초 (목표 2초 이내: 달성), 경보 24대\n", + "\n", + "[주입 규모별 처리] 건수 | 시간 | 처리량 | 격리 | 유실\n", + " 1000 0.02초 48180건/초 68 0\n", + " 10000 0.21초 48054건/초 618 0\n", + " 50000 1.23초 40710건/초 2951 0\n", + " 100000 3.51초 28472건/초 5940 0\n", + "운용 기준: 전체 격리율이 20%를 넘으면 센서 계통 점검 알림, 처리 지연 시 FFT 대역 5개만 계산하는 축소 모드로 전환\n", + "```\n", + "
\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 09. AAR 기반 예지정비 오경보 분석 및 이상 감지 임계 파라미터 재보정\n", + "**소요시간**: 약 3.0시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "AAR 기반 예지정비 오경보를 분석하고 임계 파라미터를 재보정한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **AAR 기반으로 예지정비 오경보를 원인별로 분석하고 이상 감지 임계 파라미터를 재보정해 효과를 지표로 산출한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **AAR 기간 오경보를 고부하 운용일, 외기 급변, 센서 스파이크, 열화 초기 경계 등으로 분류하고, 정상 분위수·연속 일수·고부하일 임계 완화를 AAR 기간 자료만으로 격자 탐색해 재보정 수행하고 검증하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `reports/mnt04_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 기간의 예지정비 오경보를 원인(고부하 운용일·외기 급변·센서 스파이크·기타)별로 분석하고, 정상 분위수·연속 일수·고부하일 임계 완화 파라미터를 AAR 기간 자료로만 격자 탐색해 재보정한 뒤 검증 기간에서 오경보·고장 전 확보 시간 변화와 비용 절감·점검 시간 단축·확보 시간 증가의 3대 정량 효과 지표를 가정 단가로 산출한다.\"\"\"\n", + "import itertools\n", + "import numpy as np\n", + "import pandas as pd\n", + "from scipy.stats import kurtosis\n", + "from sklearn.neural_network import MLPRegressor\n", + "from sklearn.preprocessing import StandardScaler\n", + "\n", + "# ---------------------------------------------------------------------------\n", + "# [교육용가상자료] 가상 기동체 48대의 엔진·구동계 진동·온도·압력 센서 자료 생성기 (실제 장비·부대·정비 실적 없음)\n", + "# 개체마다 정상 -> 열화 -> 고장(수명종료)까지 하루 1회 측정(진동 파형 256표본 + 온도 + 압력 + 부하)을 만든다.\n", + "# 열화 유형 4가지: bearing(베어링), gear(기어), oil_pump(오일 펌프), cooling(냉각 팬 모터)\n", + "# ---------------------------------------------------------------------------\n", + "FS, NS = 1024, 256 # 가상 표본화 주파수(Hz), 파형 1개당 표본 수\n", + "TT = np.arange(NS) / FS\n", + "MODES = { # 열화 유형: (발생 확률, 평균 수명(일), 교체 수리부속, 가상 단가지수)\n", + " \"bearing\": (0.40, 160, \"주 베어링 세트\", 6.0), \"gear\": (0.25, 175, \"구동 기어 세트\", 9.0),\n", + " \"oil_pump\": (0.20, 150, \"오일 펌프\", 4.0), \"cooling\": (0.15, 140, \"냉각 팬 모터\", 3.0)}\n", + "KIT_COST = 1.0 # 모든 정비에 함께 소모되는 씰·필터 키트 단가지수\n", + "BANDS = {\"b50\": (40, 60), \"b100\": (90, 110), \"b150\": (140, 156), \"b165\": (156, 176), \"b200\": (190, 215)}\n", + "\n", + "\n", + "def make_runs(n=48, seed=1):\n", + " \"\"\"반환: units(개체 정보), snap(일별 측정 요약), W(진동 파형 [측정 수, 256])\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " names = list(MODES)\n", + " mode = rng.choice(names, n, p=[MODES[m][0] for m in names])\n", + " life = np.clip(np.array([rng.normal(MODES[m][1], 18) for m in mode]), 90, 230).astype(int)\n", + " start = np.sort(rng.integers(0, 240, n))\n", + " units = pd.DataFrame({\"unit\": [f\"U{i:02d}\" for i in range(n)], \"mode\": mode, \"life\": life, \"start\": start, \"base_load\": rng.uniform(0.4, 0.75, n)})\n", + " rows = []\n", + " for u in units.itertuples():\n", + " d = np.arange(u.life + 1)\n", + " onset = 0.45 * u.life\n", + " deg = np.clip((d - onset) / (u.life - onset), 0, 1) ** 1.5\n", + " load = np.clip(u.base_load + rng.normal(0, 0.06, len(d)), 0.2, 1.0)\n", + " hi = rng.random(len(d)) < 0.07 # 고부하 운용일(정상이지만 진동·온도 상승)\n", + " load = np.where(hi, rng.uniform(0.95, 1.1, len(d)), load)\n", + " rows.append(pd.DataFrame({\"unit\": u.unit, \"day\": d, \"cal\": u.start + d, \"rul\": u.life - d, \"deg\": deg, \"load\": load, \"hi_load\": hi, \"mode\": u.mode}))\n", + " snap = pd.concat(rows, ignore_index=True)\n", + " S = len(snap)\n", + " snap[\"ambient\"] = 15 + 12 * np.sin(2 * np.pi * snap[\"cal\"] / 365) + rng.normal(0, 2, S)\n", + " dg, ld, md = snap[\"deg\"].values, snap[\"load\"].values, snap[\"mode\"].values\n", + " isb, isg, iso, isc = (md == \"bearing\"), (md == \"gear\"), (md == \"oil_pump\"), (md == \"cooling\")\n", + " snap[\"temp\"] = 55 + 22 * ld + 0.5 * snap[\"ambient\"] + 9 * dg * iso + 26 * dg * isc + 3 * dg * (isb | isg) + rng.normal(0, 1.5, S)\n", + " snap[\"press\"] = 450 - 12 * ld - 85 * dg * iso - 15 * dg * isc + rng.normal(0, 6, S)\n", + " ph = rng.uniform(0, 2 * np.pi, (S, 3))\n", + " a1, a2 = 1.0 * (0.5 + ld), 0.4 * (0.5 + ld)\n", + " W = (a1[:, None] * np.sin(2 * np.pi * 50 * TT + ph[:, :1]) + a2[:, None] * np.sin(2 * np.pi * 100 * TT + ph[:, 1:2])\n", + " + rng.normal(0, 0.35, (S, NS)))\n", + " W += (0.9 * dg * isb)[:, None] * np.sin(2 * np.pi * 165 * TT + ph[:, 2:3])\n", + " W += (0.8 * dg * isg)[:, None] * (np.sin(2 * np.pi * 150 * TT + ph[:, 2:3]) + 0.8 * np.sin(2 * np.pi * 100 * TT))\n", + " W += (0.5 * dg * iso)[:, None] * np.sin(2 * np.pi * 200 * TT + ph[:, 2:3])\n", + " k = rng.poisson(10 * dg * isb) # 베어링 열화: 충격성 펄스(첨도 상승)\n", + " pos, amp = rng.integers(0, NS, (S, 30)), rng.normal(0, 3.0, (S, 30)) * dg[:, None]\n", + " amp = amp * (np.arange(30)[None, :] < k[:, None])\n", + " np.add.at(W, (np.repeat(np.arange(S), 30), pos.ravel()), amp.ravel())\n", + " return units, snap, W\n", + "\n", + "\n", + "def extract(W):\n", + " \"\"\"진동 파형 -> 특징량: RMS, 첨도, 파고율, 최대-최소, FFT 대역 에너지(50/100/150/165/200Hz 부근)\"\"\"\n", + " # [TODO : 학생 실습 9] AAR 기반 예지정비 오경보 분석 및 이상 감지 임계 파라미터 재보정 관련 핵심 로직을 구현하세요.\n", + " # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n", + " # <<< 여기에 코드를 작성하세요 >>>\n", + " pass # 작성 후 pass를 제거하세요.\n", + "BASE = [\"rms\", \"kurt\", \"crest\", \"p2p\", \"b50\", \"b100\", \"b150\", \"b165\", \"b200\", \"temp\", \"press\"]\n", + "ROLL = [\"rms\", \"kurt\", \"b150\", \"b165\", \"b200\", \"temp\", \"press\"]\n", + "\n", + "\n", + "def build_features(snap, W):\n", + " # [TODO : 학생 실습 9] AAR 기반 예지정비 오경보 분석 및 이상 감지 임계 파라미터 재보정 관련 핵심 로직을 구현하세요.\n", + " # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n", + " # <<< 여기에 코드를 작성하세요 >>>\n", + " pass # 작성 후 pass를 제거하세요.\n", + "DERIVED = [\"temp_res\", \"press_res\", \"rms_n\", \"hf_ratio\"] + [c + \"_m3\" for c in ROLL] + [\"rms_n_d\", \"temp_res_d\", \"press_res_d\"]\n", + "FEATS = BASE + DERIVED\n", + "AE_COLS = [\"rms_n\", \"kurt\", \"b150\", \"b165\", \"b200\", \"hf_ratio\", \"temp_res\", \"press_res\"]\n", + "\n", + "\n", + "def split_units(units, n_train=24):\n", + " return list(units[\"unit\"][:n_train]), list(units[\"unit\"][n_train:])\n", + "\n", + "\n", + "class AE:\n", + " \"\"\"오토인코더(입력 8 -> 10 -> 4 -> 10 -> 8): 정상 자료만 재구성하도록 학습하고, 재구성 오차를 이상 점수로 쓴다.\"\"\"\n", + "\n", + " def __init__(self, cols=AE_COLS, q=0.99, seed=0):\n", + " self.cols, self.q, self.seed = cols, q, seed\n", + "\n", + " def fit(self, healthy):\n", + " self.sc = StandardScaler().fit(healthy[self.cols])\n", + " Z = self.sc.transform(healthy[self.cols])\n", + " self.net = MLPRegressor(hidden_layer_sizes=(10, 4, 10), max_iter=800, random_state=self.seed).fit(Z, Z)\n", + " self.thr = float(np.quantile(self.err(healthy), self.q))\n", + " return self\n", + "\n", + " def contrib(self, d):\n", + " Z = self.sc.transform(d[self.cols])\n", + " return (Z - self.net.predict(Z)) ** 2 # 입력 항목별 재구성 오차(이상 기여도)\n", + "\n", + " def err(self, d):\n", + " return self.contrib(d).mean(axis=1)\n", + "\n", + "\n", + "def latch(d, e, thr, k=2):\n", + " \"\"\"같은 개체에서 이상 점수가 k일 연속 기준을 넘으면 경보를 켜고 유지(래치)한다.\"\"\"\n", + " out, e = np.zeros(len(d), bool), np.asarray(e)\n", + " for idx in d.reset_index(drop=True).groupby(\"unit\").indices.values():\n", + " run, on = 0, False\n", + " for i in idx:\n", + " run = run + 1 if e[i] > thr else 0\n", + " on = on or run >= k\n", + " out[i] = on\n", + " return out\n", + "\n", + "FA_COST, FA_HOURS, LATE_COST = 1.0, 3.0, 0.15 # [교육용가상자료] 오경보 1회 점검 비용지수, 점검 소요시간(h), 확보 시간 1일 손실 비용지수\n", + "units, snap, W = make_runs(seed=5)\n", + "D = build_features(snap, W)\n", + "order = list(units[\"unit\"])\n", + "tr_u, aar_u, val_u = order[:24], order[24:36], order[36:]\n", + "tr = D[D[\"unit\"].isin(tr_u)]\n", + "ae = AE().fit(tr[tr[\"deg\"] == 0])\n", + "healthy_err = ae.err(tr[tr[\"deg\"] == 0])\n", + "E = {u: (D[D[\"unit\"] == u].reset_index(drop=True), ae.err(D[D[\"unit\"] == u])) for u in order}\n", + "\n", + "\n", + "def evaluate(us, q, k, relax):\n", + " \"\"\"반환: 오경보 에피소드 수, 원인 분류 건수, 고장 전 확보 시간(일) 목록, 미탐 개체 수, 손실 확보 일수\"\"\"\n", + " base_thr = float(np.quantile(healthy_err, q))\n", + " fa, causes, leads, miss, lost = 0, {\"고부하 운용일\": 0, \"외기 급변·고온\": 0, \"센서 스파이크\": 0, \"열화 초기 경계\": 0, \"기타\": 0}, [], 0, 0.0\n", + " for u in us:\n", + " g, e = E[u]\n", + " thr = base_thr * np.where(g[\"load\"].values > 0.95, 1 + relax, 1.0)\n", + " over = e > thr\n", + " run = np.zeros(len(g), int)\n", + " for i in range(len(g)):\n", + " run[i] = run[i - 1] + 1 if over[i] and i else int(over[i])\n", + " al = run >= k\n", + " an = (g[\"deg\"] >= 0.1).values\n", + " start = al & ~np.r_[False, al[:-1]]\n", + " for i in np.where(start & ~an)[0]:\n", + " fa += 1\n", + " r = g.iloc[i]\n", + " c = \"고부하 운용일\" if r[\"hi_load\"] else (\"외기 급변·고온\" if r[\"ambient\"] > 24 else (\"센서 스파이크\" if r[\"kurt\"] > 3.2 else (\"열화 초기 경계\" if r[\"deg\"] > 0 else \"기타\")))\n", + " causes[c] += 1\n", + " first_an = np.argmax(an)\n", + " hit = np.where(al & an)[0]\n", + " ideal = g[\"rul\"].iloc[first_an]\n", + " if len(hit):\n", + " leads.append(int(g[\"rul\"].iloc[hit[0]])); lost += ideal - g[\"rul\"].iloc[hit[0]]\n", + " else:\n", + " miss += 1; lost += ideal\n", + " return fa, causes, leads, miss, lost\n", + "\n", + "\n", + "def J(res):\n", + " return FA_COST * res[0] + LATE_COST * res[4]\n", + "\n", + "\n", + "cur = dict(q=0.99, k=1, relax=0.0) # 현행(AAR 이전) 파라미터\n", + "r_aar = evaluate(aar_u, **cur)\n", + "print(f\"[교육용가상자료] AAR 기간 {len(aar_u)}대: 현행 파라미터(정상 {cur['q']:.0%} 분위, {cur['k']}일, 고부하 완화 {cur['relax']:.0%}) 오경보 {r_aar[0]}건\")\n", + "tot = sum(r_aar[1].values())\n", + "for c, v in sorted(r_aar[1].items(), key=lambda x: -x[1]):\n", + " print(f\" {c:10s} {v:3d}건 ({v / max(tot, 1):.0%})\")\n", + "\n", + "# 재보정: AAR 기간 자료만으로 격자 탐색 (미탐 개체가 없는 조합 중 비용 최소)\n", + "grid = []\n", + "for q, k, rl in itertools.product((0.98, 0.99, 0.995, 0.999), (1, 2, 3), (0.0, 0.3, 0.6)):\n", + " r = evaluate(aar_u, q, k, rl)\n", + " if r[3] == 0:\n", + " grid.append((J(r), q, k, rl))\n", + "grid.sort()\n", + "best = dict(q=grid[0][1], k=grid[0][2], relax=grid[0][3])\n", + "print(f\"\\n재보정 결과(격자 {4 * 3 * 3}개 중 미탐 0개 조합 {len(grid)}개 탐색): 정상 {best['q']:.1%} 분위, 연속 {best['k']}일, 고부하일 임계 {best['relax']:.0%} 완화\")\n", + "\n", + "rows = []\n", + "for nm, us in ((\"AAR 기간(튜닝)\", aar_u), (\"검증 기간(미사용)\", val_u)):\n", + " a, b = evaluate(us, **cur), evaluate(us, **best)\n", + " rows.append((nm, a, b))\n", + " print(f\" {nm:14s} 오경보 {a[0]:3d} -> {b[0]:3d}건, 고장 전 확보 중앙값 {np.median(a[2]):.0f} -> {np.median(b[2]):.0f}일, 미탐 {a[3]} -> {b[3]}대\")\n", + "\n", + "# 3대 정량 효과 지표(검증 3개월 대신 검증 12대 기준, 연환산은 개체 수 비례)\n", + "a, b = rows[1][1], rows[1][2]\n", + "jc, jn = J(a), J(b)\n", + "print(\"\\n[3대 정량 효과 지표] (검증 12대, 가정 단가)\")\n", + "print(f\" 1) 비용 절감: 비용지수 {jc:.1f} -> {jn:.1f} ({1 - jn / jc:.1%} 절감), 48대 연환산 약 {(jc - jn) * 4:.0f}\")\n", + "print(f\" 2) 시간 단축: 불필요 점검 {a[0] * FA_HOURS:.0f}h -> {b[0] * FA_HOURS:.0f}h ({1 - b[0] / max(a[0], 1):.1%} 단축)\")\n", + "print(f\" 3) 확보 시간: 고장 전 첫 경보 확보 중앙값 {np.median(a[2]):.0f}일 -> {np.median(b[2]):.0f}일, 정상 오경보 {a[0]}건 -> {b[0]}건\")\n", + "print(\"[도입 로드맵] 단기(1~3월): 재보정 임계 적용 + 검사관 확인 병행 / 중기(3~12월): 분기 AAR로 임계 재보정, 부하 보정 항 추가 / 장기(1년~): 군수 정보체계 정비 이력과 연동해 임계 자동 갱신\")\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 09 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + "[교육용가상자료] AAR 기간 12대: 현행 파라미터(정상 99% 분위, 1일, 고부하 완화 0%) 오경보 9건\n", + " 기타 5건 (56%)\n", + " 고부하 운용일 2건 (22%)\n", + " 열화 초기 경계 2건 (22%)\n", + " 외기 급변·고온 0건 (0%)\n", + " 센서 스파이크 0건 (0%)\n", + "\n", + "재보정 결과(격자 36개 중 미탐 0개 조합 36개 탐색): 정상 99.5% 분위, 연속 1일, 고부하일 임계 30% 완화\n", + " AAR 기간(튜닝) 오경보 9 -> 4건, 고장 전 확보 중앙값 58 -> 56일, 미탐 0 -> 0대\n", + " 검증 기간(미사용) 오경보 16 -> 10건, 고장 전 확보 중앙값 60 -> 57일, 미탐 0 -> 0대\n", + "\n", + "[3대 정량 효과 지표] (검증 12대, 가정 단가)\n", + " 1) 비용 절감: 비용지수 24.7 -> 22.0 (10.9% 절감), 48대 연환산 약 11\n", + " 2) 시간 단축: 불필요 점검 48h -> 30h (37.5% 단축)\n", + " 3) 확보 시간: 고장 전 첫 경보 확보 중앙값 60일 -> 57일, 정상 오경보 16건 -> 10건\n", + "[도입 로드맵] 단기(1~3월): 재보정 임계 적용 + 검사관 확인 병행 / 중기(3~12월): 분기 AAR로 임계 재보정, 부하 보정 항 추가 / 장기(1년~): 군수 정보체계 정비 이력과 연동해 임계 자동 갱신\n", + "```\n", + "
\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 10. 항공/기갑 장비 예지정비(PdM) 센서 데이터 이상 징후 감지 시스템 통합 완성\n", + "**소요시간**: 약 3.5시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "항공/기갑 장비 예지정비(PdM) 센서 데이터 이상 징후 감지 시스템을 완성한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **센서 검증부터 이상 감지, RUL 예측, 승인 대기 입고 제안, 수리부속 소요 예고, 재학습까지 한 파일로 통합하고 자체 점검과 브리핑 요약을 출력하는 도구를 완성한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **지난달·이번 달 실적으로 챔피언 모델과 도전자 모델을 학습해 검증 F1을 비교하고 비슷하거나 나을 때만 교체 수행하고 검증하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `docs/mnt04_operations_guide.md 및 pipeline/mnt04_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": [ + "\"\"\"센서 파형 검증·정제, 오토인코더 이상 감지 모델의 자동 재학습(챔피언·도전자 비교 후 교체), 재보정 임계 적용 경보, 잔존수명(RUL) 예측, 검사관 승인 대기 입고 제안, 수리부속 소요 예고까지 예지정비(PdM) 이상 감지 시스템을 한 파일로 통합 실행하고 자체 점검·운영 문서 완비 점검·정비 담당 장교 브리핑 요약을 출력한다.\"\"\"\n", + "import time\n", + "import numpy as np\n", + "import pandas as pd\n", + "import matplotlib.pyplot as plt\n", + "from scipy.stats import kurtosis\n", + "from sklearn.ensemble import GradientBoostingRegressor\n", + "from sklearn.neural_network import MLPRegressor\n", + "from sklearn.preprocessing import StandardScaler\n", + "\n", + "# ---------------------------------------------------------------------------\n", + "# [교육용가상자료] 가상 기동체 48대의 엔진·구동계 진동·온도·압력 센서 자료 생성기 (실제 장비·부대·정비 실적 없음)\n", + "# 개체마다 정상 -> 열화 -> 고장(수명종료)까지 하루 1회 측정(진동 파형 256표본 + 온도 + 압력 + 부하)을 만든다.\n", + "# 열화 유형 4가지: bearing(베어링), gear(기어), oil_pump(오일 펌프), cooling(냉각 팬 모터)\n", + "# ---------------------------------------------------------------------------\n", + "FS, NS = 1024, 256 # 가상 표본화 주파수(Hz), 파형 1개당 표본 수\n", + "TT = np.arange(NS) / FS\n", + "MODES = { # 열화 유형: (발생 확률, 평균 수명(일), 교체 수리부속, 가상 단가지수)\n", + " \"bearing\": (0.40, 160, \"주 베어링 세트\", 6.0), \"gear\": (0.25, 175, \"구동 기어 세트\", 9.0),\n", + " \"oil_pump\": (0.20, 150, \"오일 펌프\", 4.0), \"cooling\": (0.15, 140, \"냉각 팬 모터\", 3.0)}\n", + "KIT_COST = 1.0 # 모든 정비에 함께 소모되는 씰·필터 키트 단가지수\n", + "BANDS = {\"b50\": (40, 60), \"b100\": (90, 110), \"b150\": (140, 156), \"b165\": (156, 176), \"b200\": (190, 215)}\n", + "\n", + "\n", + "def make_runs(n=48, seed=1):\n", + " \"\"\"반환: units(개체 정보), snap(일별 측정 요약), W(진동 파형 [측정 수, 256])\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " names = list(MODES)\n", + " mode = rng.choice(names, n, p=[MODES[m][0] for m in names])\n", + " life = np.clip(np.array([rng.normal(MODES[m][1], 18) for m in mode]), 90, 230).astype(int)\n", + " start = np.sort(rng.integers(0, 240, n))\n", + " units = pd.DataFrame({\"unit\": [f\"U{i:02d}\" for i in range(n)], \"mode\": mode, \"life\": life, \"start\": start, \"base_load\": rng.uniform(0.4, 0.75, n)})\n", + " rows = []\n", + " for u in units.itertuples():\n", + " d = np.arange(u.life + 1)\n", + " onset = 0.45 * u.life\n", + " deg = np.clip((d - onset) / (u.life - onset), 0, 1) ** 1.5\n", + " load = np.clip(u.base_load + rng.normal(0, 0.06, len(d)), 0.2, 1.0)\n", + " hi = rng.random(len(d)) < 0.07 # 고부하 운용일(정상이지만 진동·온도 상승)\n", + " load = np.where(hi, rng.uniform(0.95, 1.1, len(d)), load)\n", + " rows.append(pd.DataFrame({\"unit\": u.unit, \"day\": d, \"cal\": u.start + d, \"rul\": u.life - d, \"deg\": deg, \"load\": load, \"hi_load\": hi, \"mode\": u.mode}))\n", + " snap = pd.concat(rows, ignore_index=True)\n", + " S = len(snap)\n", + " snap[\"ambient\"] = 15 + 12 * np.sin(2 * np.pi * snap[\"cal\"] / 365) + rng.normal(0, 2, S)\n", + " dg, ld, md = snap[\"deg\"].values, snap[\"load\"].values, snap[\"mode\"].values\n", + " isb, isg, iso, isc = (md == \"bearing\"), (md == \"gear\"), (md == \"oil_pump\"), (md == \"cooling\")\n", + " snap[\"temp\"] = 55 + 22 * ld + 0.5 * snap[\"ambient\"] + 9 * dg * iso + 26 * dg * isc + 3 * dg * (isb | isg) + rng.normal(0, 1.5, S)\n", + " snap[\"press\"] = 450 - 12 * ld - 85 * dg * iso - 15 * dg * isc + rng.normal(0, 6, S)\n", + " ph = rng.uniform(0, 2 * np.pi, (S, 3))\n", + " a1, a2 = 1.0 * (0.5 + ld), 0.4 * (0.5 + ld)\n", + " W = (a1[:, None] * np.sin(2 * np.pi * 50 * TT + ph[:, :1]) + a2[:, None] * np.sin(2 * np.pi * 100 * TT + ph[:, 1:2])\n", + " + rng.normal(0, 0.35, (S, NS)))\n", + " W += (0.9 * dg * isb)[:, None] * np.sin(2 * np.pi * 165 * TT + ph[:, 2:3])\n", + " W += (0.8 * dg * isg)[:, None] * (np.sin(2 * np.pi * 150 * TT + ph[:, 2:3]) + 0.8 * np.sin(2 * np.pi * 100 * TT))\n", + " W += (0.5 * dg * iso)[:, None] * np.sin(2 * np.pi * 200 * TT + ph[:, 2:3])\n", + " k = rng.poisson(10 * dg * isb) # 베어링 열화: 충격성 펄스(첨도 상승)\n", + " pos, amp = rng.integers(0, NS, (S, 30)), rng.normal(0, 3.0, (S, 30)) * dg[:, None]\n", + " amp = amp * (np.arange(30)[None, :] < k[:, None])\n", + " np.add.at(W, (np.repeat(np.arange(S), 30), pos.ravel()), amp.ravel())\n", + " return units, snap, W\n", + "\n", + "\n", + "def extract(W):\n", + " \"\"\"진동 파형 -> 특징량: RMS, 첨도, 파고율, 최대-최소, FFT 대역 에너지(50/100/150/165/200Hz 부근)\"\"\"\n", + " # [TODO : 학생 실습 10] 항공/기갑 장비 예지정비(PdM) 센서 데이터 이상 징후 감지 시스템 통합 완성 관련 핵심 로직을 구현하세요.\n", + " # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n", + " # <<< 여기에 코드를 작성하세요 >>>\n", + " pass # 작성 후 pass를 제거하세요.\n", + "BASE = [\"rms\", \"kurt\", \"crest\", \"p2p\", \"b50\", \"b100\", \"b150\", \"b165\", \"b200\", \"temp\", \"press\"]\n", + "ROLL = [\"rms\", \"kurt\", \"b150\", \"b165\", \"b200\", \"temp\", \"press\"]\n", + "\n", + "\n", + "def build_features(snap, W):\n", + " # [TODO : 학생 실습 10] 항공/기갑 장비 예지정비(PdM) 센서 데이터 이상 징후 감지 시스템 통합 완성 관련 핵심 로직을 구현하세요.\n", + " # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n", + " # <<< 여기에 코드를 작성하세요 >>>\n", + " pass # 작성 후 pass를 제거하세요.\n", + "DERIVED = [\"temp_res\", \"press_res\", \"rms_n\", \"hf_ratio\"] + [c + \"_m3\" for c in ROLL] + [\"rms_n_d\", \"temp_res_d\", \"press_res_d\"]\n", + "FEATS = BASE + DERIVED\n", + "AE_COLS = [\"rms_n\", \"kurt\", \"b150\", \"b165\", \"b200\", \"hf_ratio\", \"temp_res\", \"press_res\"]\n", + "\n", + "\n", + "def split_units(units, n_train=24):\n", + " return list(units[\"unit\"][:n_train]), list(units[\"unit\"][n_train:])\n", + "\n", + "\n", + "class AE:\n", + " \"\"\"오토인코더(입력 8 -> 10 -> 4 -> 10 -> 8): 정상 자료만 재구성하도록 학습하고, 재구성 오차를 이상 점수로 쓴다.\"\"\"\n", + "\n", + " def __init__(self, cols=AE_COLS, q=0.99, seed=0):\n", + " self.cols, self.q, self.seed = cols, q, seed\n", + "\n", + " def fit(self, healthy):\n", + " self.sc = StandardScaler().fit(healthy[self.cols])\n", + " Z = self.sc.transform(healthy[self.cols])\n", + " self.net = MLPRegressor(hidden_layer_sizes=(10, 4, 10), max_iter=800, random_state=self.seed).fit(Z, Z)\n", + " self.thr = float(np.quantile(self.err(healthy), self.q))\n", + " return self\n", + "\n", + " def contrib(self, d):\n", + " Z = self.sc.transform(d[self.cols])\n", + " return (Z - self.net.predict(Z)) ** 2 # 입력 항목별 재구성 오차(이상 기여도)\n", + "\n", + " def err(self, d):\n", + " return self.contrib(d).mean(axis=1)\n", + "\n", + "\n", + "def latch(d, e, thr, k=2):\n", + " \"\"\"같은 개체에서 이상 점수가 k일 연속 기준을 넘으면 경보를 켜고 유지(래치)한다.\"\"\"\n", + " out, e = np.zeros(len(d), bool), np.asarray(e)\n", + " for idx in d.reset_index(drop=True).groupby(\"unit\").indices.values():\n", + " run, on = 0, False\n", + " for i in idx:\n", + " run = run + 1 if e[i] > thr else 0\n", + " on = on or run >= k\n", + " out[i] = on\n", + " return out\n", + "\n", + "CONFIG = {\"q\": 0.995, \"k\": 1, \"relax\": 0.3, \"retrain_tolerance\": 0.02, \"approve_rul\": 90, \"spare_horizon\": 60} # 미션 9 재보정 결과\n", + "OPS_GUIDE = { # docs/mnt04_operations_guide.md 요약\n", + " \"1. 목적과 범위\": \"엔진·구동계 센서로 이상 징후와 잔존수명을 보조 판단. 입고·정비 결정은 검사관.\",\n", + " \"2. 입력 데이터\": \"일 1회 진동 파형·온도·압력·부하. 실제 장비 자료 대신 가상 식별코드 사용.\",\n", + " \"3. 일일 운용 절차\": \"수집 -> 검증·격리 -> 특징 추출 -> 이상 점수 -> 경보 -> 입고 제안(대기) -> 검사관 결정 -> 결과 기록.\",\n", + " \"4. 승인(HITL) 기준\": \"조기 입고 제안은 검사관 승인 후 확정, 무응답 시 기본 계획(정기 교체) 유지, 사유 기록.\",\n", + " \"5. 재학습 절차\": \"분기 1회 신규 정상·고장 실적으로 도전자 학습, 검증 F1이 나빠지지 않을 때만 교체.\",\n", + " \"6. 장애 대응\": \"모델 오류 시 정기 교체 주기 기준으로 대체, 손상 센서 자료는 수동 확인 목록으로.\",\n", + " \"7. 연동 인터페이스\": \"국방 군수 정보체계와 정비 이력·수리부속 소모 표준 필드(장비코드·분류군·일시)를 CSV/API로 교환하는 안을 차기 과제로 둔다.\",\n", + "}\n", + "F10 = FEATS + [\"day\", \"hf_max\", \"temp_res_max\"]\n", + "\n", + "\n", + "def prep(units, snap, W):\n", + " \"\"\"검증: 파형 결측·고정·포화, 값 범위 이탈 측정은 격리하고 나머지로 특징표를 만든다.\"\"\"\n", + " bad = np.isnan(W).any(axis=1) | (np.nanstd(W, axis=1) < 1e-6) | (np.abs(np.nan_to_num(W)).max(axis=1) > 12) | ~snap[\"temp\"].between(20, 160).values\n", + " ok = ~bad\n", + " D = build_features(snap[ok].reset_index(drop=True), W[ok])\n", + " D[\"hf_max\"] = D.groupby(\"unit\")[\"hf_ratio\"].cummax(); D[\"temp_res_max\"] = D.groupby(\"unit\")[\"temp_res\"].cummax()\n", + " return D, int(bad.sum())\n", + "\n", + "\n", + "def alarms(D, e, thr, cfg=CONFIG):\n", + " over = np.asarray(e) > thr * np.where(D[\"load\"].values > 0.95, 1 + cfg[\"relax\"], 1.0)\n", + " out = np.zeros(len(D), bool)\n", + " for idx in D.reset_index(drop=True).groupby(\"unit\").indices.values():\n", + " run = 0\n", + " for i in idx:\n", + " run = run + 1 if over[i] else 0\n", + " out[i] = run >= cfg[\"k\"]\n", + " return out\n", + "\n", + "\n", + "def f1(D, al):\n", + " y = (D[\"deg\"] >= 0.1).values\n", + " tp, fp, fn = (al & y).sum(), (al & ~y).sum(), (~al & y).sum()\n", + " return float(2 * tp / (2 * tp + fp + fn))\n", + "\n", + "\n", + "def retrain(champion, D_old, D_new):\n", + " \"\"\"챔피언 AE(지난달 자료) vs 도전자 AE(지난달+이번 달 학습 개체) -> 이번 달 검증 개체 F1 비교.\"\"\"\n", + " new_tr, new_va = D_new[D_new[\"unit\"].isin(list(D_new[\"unit\"].unique())[:24])], D_new[D_new[\"unit\"].isin(list(D_new[\"unit\"].unique())[24:])]\n", + " ch = AE(seed=1).fit(pd.concat([D_old[D_old[\"deg\"] == 0], new_tr[new_tr[\"deg\"] == 0]]))\n", + " thr_c = float(np.quantile(champion.err(D_old[D_old[\"deg\"] == 0]), CONFIG[\"q\"]))\n", + " thr_n = float(np.quantile(ch.err(pd.concat([D_old[D_old[\"deg\"] == 0], new_tr[new_tr[\"deg\"] == 0]])), CONFIG[\"q\"]))\n", + " f_old, f_new = f1(new_va, alarms(new_va, champion.err(new_va), thr_c)), f1(new_va, alarms(new_va, ch.err(new_va), thr_n))\n", + " swap = f_new >= f_old - CONFIG[\"retrain_tolerance\"]\n", + " return (ch if swap else champion), (thr_n if swap else thr_c), {\"챔피언 F1\": round(f_old, 3), \"도전자 F1\": round(f_new, 3), \"교체\": bool(swap)}\n", + "\n", + "\n", + "def predict_safe(model, D):\n", + " try:\n", + " return np.maximum(model.predict(D[F10]), 0), \"모델\"\n", + " except Exception:\n", + " return np.maximum(90 - D[\"day\"].values, 0).astype(float), \"정기 주기 기준 대체\"\n", + "\n", + "\n", + "# ---------------- 1. 통합 실행 ----------------\n", + "t_start = time.perf_counter()\n", + "Dold, _ = prep(*make_runs(seed=11)) # 지난달 실적\n", + "Dnew, _ = prep(*make_runs(seed=1)) # 이번 달 실적\n", + "u3, s3, w3 = make_runs(seed=21) # 다음 달 운용(신규 측정)\n", + "w3[[100, 2000], 20:50] = np.nan # 손상 측정 2건 주입(결측 패킷)\n", + "Dop, n_bad = prep(u3, s3, w3)\n", + "champion = AE().fit(Dold[Dold[\"deg\"] == 0])\n", + "champ_thr = float(np.quantile(champion.err(Dold[Dold[\"deg\"] == 0]), CONFIG[\"q\"]))\n", + "model_ae, thr, rt = retrain(champion, Dold, Dnew)\n", + "print(f\"[교육용가상자료] 재학습: {rt} -> {'도전자 교체' if rt['교체'] else '챔피언 유지'}\")\n", + "gbr = GradientBoostingRegressor(n_estimators=100, max_depth=3, learning_rate=0.1, subsample=0.8, random_state=0).fit(Dnew[F10], np.minimum(Dnew[\"rul\"], 120))\n", + "e = model_ae.err(Dop)\n", + "Dop[\"alarm\"] = alarms(Dop, e, thr)\n", + "Dop[\"rul_hat\"], src = predict_safe(gbr, Dop)\n", + "Dop[\"score\"] = e / thr\n", + "sec = time.perf_counter() - t_start\n", + "print(f\"운용 측정 {len(s3)}건: 처리 {len(Dop)}건, 격리 {n_bad}건, 경보 측정 {int(Dop['alarm'].sum())}건, RUL 출처 '{src}', 전체 실행 {sec:.1f}초\")\n", + "\n", + "# 입고 제안(대기)과 수리부속 소요 예고: 경보 개체의 마지막 측정 기준\n", + "PART = {\"b165\": \"주 베어링 세트\", \"kurt\": \"주 베어링 세트\", \"b150\": \"구동 기어 세트\", \"b200\": \"오일 펌프\", \"press_res\": \"오일 펌프\", \"temp_res\": \"냉각 팬 모터\"}\n", + "first = Dop[Dop[\"alarm\"]].groupby(\"unit\").first()\n", + "cols = model_ae.cols\n", + "C = model_ae.contrib(Dop)\n", + "prop = []\n", + "for u, r in first.iterrows():\n", + " i = Dop.index[(Dop[\"unit\"] == u) & (Dop[\"day\"] == r[\"day\"])][0]\n", + " prop.append({\"unit\": u, \"day\": int(r[\"day\"]), \"rul_hat\": float(r[\"rul_hat\"]), \"rul\": int(r[\"rul\"]), \"part\": PART.get(cols[int(np.argmax(C[i]))], \"미분류\"),\n", + " \"mode\": r[\"mode\"], \"status\": \"PENDING\" if r[\"rul_hat\"] <= CONFIG[\"approve_rul\"] else \"관찰\"})\n", + "P = pd.DataFrame(prop)\n", + "P[\"true_part\"] = P[\"mode\"].map({m: MODES[m][2] for m in MODES})\n", + "# 수리부속 소요 예고: 예측 잔존 30일 이내로 내려온 개체의 의심 부품\n", + "need = P[P[\"rul_hat\"] <= CONFIG[\"spare_horizon\"]]\n", + "\n", + "# ---------------- 2. 자체 점검 ----------------\n", + "_, bad_src = predict_safe(None, Dop.head(3))\n", + "y = (Dop[\"deg\"] >= 0.1).values\n", + "checks = {\n", + " \"측정 유실 0건\": len(Dop) + n_bad == len(s3),\n", + " \"손상 측정 2건 격리\": n_bad == 2,\n", + " \"재학습 파이프라인 실행\": \"교체\" in rt,\n", + " \"RUL 예측값 음수 없음\": bool((Dop[\"rul_hat\"] >= 0).all()),\n", + " \"모델 장애 시 정기 주기 대체\": bad_src == \"정기 주기 기준 대체\",\n", + " \"열화 개체 미탐 0대\": bool(Dop.groupby(\"unit\").apply(lambda g: (g[\"alarm\"] & (g[\"deg\"] >= 0.1)).any() or not (g[\"deg\"] >= 0.1).any()).all()),\n", + " \"경보 개체 모두 제안 또는 관찰 분류\": len(P) == int(Dop[Dop[\"alarm\"]][\"unit\"].nunique()),\n", + " \"자동 승인 0건(승인 대상은 대기)\": bool(P[\"status\"].isin([\"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", + "fig, ax = plt.subplots(1, 2, figsize=(11, 4))\n", + "for u in list(P[\"unit\"])[:3]:\n", + " g = Dop[Dop[\"unit\"] == u]\n", + " ax[0].plot(g[\"day\"], g[\"score\"], label=u)\n", + "ax[0].axhline(1.0, ls=\"--\", color=\"k\"); ax[0].set_yscale(\"log\"); ax[0].set_title(\"Anomaly score (x threshold)\"); ax[0].set_xlabel(\"day\"); ax[0].legend()\n", + "ax[1].scatter(P[\"rul\"], P[\"rul_hat\"]); ax[1].plot([0, 120], [0, 120], \"k--\"); ax[1].set_title(\"RUL at first alarm: actual vs predicted\"); ax[1].set_xlabel(\"actual (days)\")\n", + "plt.tight_layout(); plt.show()\n", + "\n", + "lead = P[\"rul\"]\n", + "print(\"\\n[정비 담당 장교 브리핑 요약] [교육용가상자료]\")\n", + "print(f\"1. 운용 개체 {len(u3)}대 측정 {len(s3)}건 중 손상 {n_bad}건 격리, 열화 개체 {len(P)}대에서 경보 발령, 고장 전 확보 시간 중앙값 {lead.median():.0f}일(최소 {lead.min()}일)\")\n", + "print(f\"2. 이상 감지 모델: {'신규 실적으로 교체' if rt['교체'] else '기존 유지'}(검증 F1 {max(rt['챔피언 F1'], rt['도전자 F1']):.2f}), 재보정 임계 적용으로 정상 개체 오경보 {int((Dop['alarm'] & (Dop['deg'] == 0)).sum())}건\")\n", + "print(f\"3. 조기 입고 제안 {int((P['status'] == 'PENDING').sum())}건은 검사관 승인 대기(자동 승인 0건), 예측 잔존 {CONFIG['approve_rul']}일 초과 {int((P['status'] == '관찰').sum())}건은 관찰 유지\")\n", + "print(\"4. 수리부속 소요 예고(예측 잔존 \" + str(CONFIG[\"spare_horizon\"]) + \"일 이내 개체의 의심 부품): \" + \", \".join(f\"{k} {v}건\" for k, v in need[\"part\"].value_counts().items())\n", + " + f\" (첫 경보 시점 의심 부품 정확도 {(P['part'] == P['true_part']).mean():.0%})\")\n", + "print(f\"5. RUL 예측: 경보 시점 평균 절대오차 {np.mean(np.abs(P['rul_hat'] - np.minimum(P['rul'], 120))):.1f}일\")\n", + "print(\"6. 건의: 구동 기어·주 베어링 세트 우선 확보(소모 비용 상위), 국방 군수 정보체계와 정비 이력·수리부속 소모 표준 필드 연동 인터페이스 정의\")\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 10 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + "[교육용가상자료] 재학습: {'챔피언 F1': 0.868, '도전자 F1': 0.837, '교체': False} -> 챔피언 유지\n", + "운용 측정 7695건: 처리 7693건, 격리 2건, 경보 측정 2562건, RUL 출처 '모델', 전체 실행 20.6초\n", + "\n", + "자체 점검 8/8 통과, 자동 재학습 파이프라인 정상 실행\n", + "운영 문서 7개 장 작성률 100% (목표 100%: 달성) - 목적과 범위, 입력 데이터, 일일 운용 절차, 승인(HITL) 기준, 재학습 절차, 장애 대응, 연동 인터페이스\n", + "\n", + "[정비 담당 장교 브리핑 요약] [교육용가상자료]\n", + "1. 운용 개체 48대 측정 7695건 중 손상 2건 격리, 열화 개체 48대에서 경보 발령, 고장 전 확보 시간 중앙값 66일(최소 37일)\n", + "2. 이상 감지 모델: 기존 유지(검증 F1 0.87), 재보정 임계 적용으로 정상 개체 오경보 23건\n", + "3. 조기 입고 제안 31건은 검사관 승인 대기(자동 승인 0건), 예측 잔존 90일 초과 17건은 관찰 유지\n", + "4. 수리부속 소요 예고(예측 잔존 60일 이내 개체의 의심 부품): 구동 기어 세트 6건, 주 베어링 세트 3건, 냉각 팬 모터 3건, 오일 펌프 2건 (첫 경보 시점 의심 부품 정확도 52%)\n", + "5. RUL 예측: 경보 시점 평균 절대오차 10.0일\n", + "6. 건의: 구동 기어·주 베어링 세트 우선 확보(소모 비용 상위), 국방 군수 정보체계와 정비 이력·수리부속 소모 표준 필드 연동 인터페이스 정의\n", + "```\n", + "
\n" + ] + } + ], + "metadata": { + "colab": { + "provenance": [] + }, + "kernelspec": { + "display_name": "Python 3", + "name": "python3" + }, + "language_info": { + "name": "python" + } + }, + "nbformat": 4, + "nbformat_minor": 0 +} \ No newline at end of file diff --git a/uploads/MNT06/MNT06_solution.ipynb b/uploads/MNT06/MNT06_solution.ipynb new file mode 100644 index 0000000..31f1d7d --- /dev/null +++ b/uploads/MNT06/MNT06_solution.ipynb @@ -0,0 +1,2723 @@ +{ + "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": [ + "
\n", + "💡 [참고] 미션 01 예상 실행 결과 (Output)\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", + "
\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": [ + "
\n", + "💡 [참고] 미션 02 예상 실행 결과 (Output)\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", + "
\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": [ + "
\n", + "💡 [참고] 미션 03 예상 실행 결과 (Output)\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", + "
\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": [ + "
\n", + "💡 [참고] 미션 04 예상 실행 결과 (Output)\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", + "
\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": [ + "
\n", + "💡 [참고] 미션 05 예상 실행 결과 (Output)\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", + "
\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": [ + "
\n", + "💡 [참고] 미션 06 예상 실행 결과 (Output)\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", + "
\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": [ + "
\n", + "💡 [참고] 미션 07 예상 실행 결과 (Output)\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", + "
\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": [ + "
\n", + "💡 [참고] 미션 08 예상 실행 결과 (Output)\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", + "
\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": [ + "
\n", + "💡 [참고] 미션 09 예상 실행 결과 (Output)\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", + "
\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": [ + "
\n", + "💡 [참고] 미션 10 예상 실행 결과 (Output)\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", + "
\n" + ] + } + ], + "metadata": { + "colab": { + "provenance": [] + }, + "kernelspec": { + "display_name": "Python 3", + "name": "python3" + }, + "language_info": { + "name": "python" + } + }, + "nbformat": 4, + "nbformat_minor": 0 +} \ No newline at end of file diff --git a/uploads/MNT06/MNT06_student.ipynb b/uploads/MNT06/MNT06_student.ipynb new file mode 100644 index 0000000..af328dc --- /dev/null +++ b/uploads/MNT06/MNT06_student.ipynb @@ -0,0 +1,2687 @@ +{ + "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", + " # [TODO : 학생 실습 1] 3D 프린팅 제작 대상 군수 부품 3D CAD 모델 및 금속/폴리머 소재 물성 데이터 정의 관련 핵심 로직을 구현하세요.\n", + " # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n", + " # <<< 여기에 코드를 작성하세요 >>>\n", + " pass # 작성 후 pass를 제거하세요.\n", + "def validate(rec):\n", + " # [TODO : 학생 실습 1] 3D 프린팅 제작 대상 군수 부품 3D CAD 모델 및 금속/폴리머 소재 물성 데이터 정의 관련 핵심 로직을 구현하세요.\n", + " # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n", + " # <<< 여기에 코드를 작성하세요 >>>\n", + " pass # 작성 후 pass를 제거하세요.\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": [ + "
\n", + "💡 [참고] 미션 01 예상 실행 결과 (Output)\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", + "
\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", + " # [TODO : 학생 실습 2] CAD 파일 메쉬 정제, 슬라이싱 데이터 파싱 및 제작 규격 전처리 모듈 구현 관련 핵심 로직을 구현하세요.\n", + " # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n", + " # <<< 여기에 코드를 작성하세요 >>>\n", + " pass # 작성 후 pass를 제거하세요.\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", + " # [TODO : 학생 실습 2] CAD 파일 메쉬 정제, 슬라이싱 데이터 파싱 및 제작 규격 전처리 모듈 구현 관련 핵심 로직을 구현하세요.\n", + " # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n", + " # <<< 여기에 코드를 작성하세요 >>>\n", + " pass # 작성 후 pass를 제거하세요.\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": [ + "
\n", + "💡 [참고] 미션 02 예상 실행 결과 (Output)\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", + "
\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", + " # [TODO : 학생 실습 3] 기하학적 형상 기반 1차 3D 프린팅 제작 가능 여부 판별 Baseline 알고리즘 구현 관련 핵심 로직을 구현하세요.\n", + " # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n", + " # <<< 여기에 코드를 작성하세요 >>>\n", + " pass # 작성 후 pass를 제거하세요.\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": [ + "
\n", + "💡 [참고] 미션 03 예상 실행 결과 (Output)\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", + "
\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": [ + "# [TODO : 학생 실습 4] 유한요소해석(FEA) 연계 야전 부품 인장강도·피로내구도 신뢰성 평가 머신러닝 고도화\n", + "# 미션 지침 및 검증 기준을 확인하고 코드를 실행 및 튜닝하세요.\n", + "\"\"\"유한요소해석(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": [ + "
\n", + "💡 [참고] 미션 04 예상 실행 결과 (Output)\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", + "
\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": [ + "# [TODO : 학생 실습 5] 긴급 야전 제작 부품의 군사 규격(MIL-STD) 부합성 신뢰도 점수 산출 엔진 개발\n", + "# 미션 지침 및 검증 기준을 확인하고 코드를 실행 및 튜닝하세요.\n", + "\"\"\"긴급 야전 제작 부품의 군 규격 부합 신뢰도 점수(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": [ + "
\n", + "💡 [참고] 미션 05 예상 실행 결과 (Output)\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", + "
\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", + " # [TODO : 학생 실습 6] 부품별 제작 소요 시간, 비용 및 신뢰성 평가 결과 가시화 대시보드 구현 관련 핵심 로직을 구현하세요.\n", + " # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n", + " # <<< 여기에 코드를 작성하세요 >>>\n", + " pass # 작성 후 pass를 제거하세요.\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", + " # [TODO : 학생 실습 6] 부품별 제작 소요 시간, 비용 및 신뢰성 평가 결과 가시화 대시보드 구현 관련 핵심 로직을 구현하세요.\n", + " # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n", + " # <<< 여기에 코드를 작성하세요 >>>\n", + " pass # 작성 후 pass를 제거하세요.\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": [ + "
\n", + "💡 [참고] 미션 06 예상 실행 결과 (Output)\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", + "
\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", + " # [TODO : 학생 실습 7] 야전 3D 프린팅 부품 실 장착 승인을 위한 정비기술관 HITL 검토 절차 설계 관련 핵심 로직을 구현하세요.\n", + " # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n", + " # <<< 여기에 코드를 작성하세요 >>>\n", + " pass # 작성 후 pass를 제거하세요.\n", + "def print_outcome(r, delay_d=1.0):\n", + " \"\"\"실제 시험 결과(숨은 진실)에 따른 대체 제작 비용. 불합격이면 구매로 되돌아가고 불합격 비용이 든다.\"\"\"\n", + " # [TODO : 학생 실습 7] 야전 3D 프린팅 부품 실 장착 승인을 위한 정비기술관 HITL 검토 절차 설계 관련 핵심 로직을 구현하세요.\n", + " # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n", + " # <<< 여기에 코드를 작성하세요 >>>\n", + " pass # 작성 후 pass를 제거하세요.\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": [ + "
\n", + "💡 [참고] 미션 07 예상 실행 결과 (Output)\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", + "
\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", + " # [TODO : 학생 실습 8] 복잡 형상 부품 50종 일괄 신뢰성 평가 시뮬레이션 부하 테스트 관련 핵심 로직을 구현하세요.\n", + " # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n", + " # <<< 여기에 코드를 작성하세요 >>>\n", + " pass # 작성 후 pass를 제거하세요.\n", + "def fallback_score(X):\n", + " \"\"\"모델 장애 시 간이 규칙: 응력/기준강도 비로만 점수화(보수적).\"\"\"\n", + " # [TODO : 학생 실습 8] 복잡 형상 부품 50종 일괄 신뢰성 평가 시뮬레이션 부하 테스트 관련 핵심 로직을 구현하세요.\n", + " # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n", + " # <<< 여기에 코드를 작성하세요 >>>\n", + " pass # 작성 후 pass를 제거하세요.\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": [ + "
\n", + "💡 [참고] 미션 08 예상 실행 결과 (Output)\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", + "
\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", + " # [TODO : 학생 실습 9] AAR 기반 실물 파괴 시험 결과와 3D 모델 예측치 간 오차 분석 및 보정 관련 핵심 로직을 구현하세요.\n", + " # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n", + " # <<< 여기에 코드를 작성하세요 >>>\n", + " pass # 작성 후 pass를 제거하세요.\n", + "def print_outcome(r, delay_d=1.0):\n", + " \"\"\"실제 시험 결과(숨은 진실)에 따른 대체 제작 비용. 불합격이면 구매로 되돌아가고 불합격 비용이 든다.\"\"\"\n", + " # [TODO : 학생 실습 9] AAR 기반 실물 파괴 시험 결과와 3D 모델 예측치 간 오차 분석 및 보정 관련 핵심 로직을 구현하세요.\n", + " # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n", + " # <<< 여기에 코드를 작성하세요 >>>\n", + " pass # 작성 후 pass를 제거하세요.\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": [ + "
\n", + "💡 [참고] 미션 09 예상 실행 결과 (Output)\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", + "
\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", + " # [TODO : 학생 실습 10] 3D 프린팅 야전 긴급 부품 제작 타당성 및 신뢰성 평가 도구 최종 완성 관련 핵심 로직을 구현하세요.\n", + " # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n", + " # <<< 여기에 코드를 작성하세요 >>>\n", + " pass # 작성 후 pass를 제거하세요.\n", + "def print_outcome(r, delay_d=1.0):\n", + " \"\"\"실제 시험 결과(숨은 진실)에 따른 대체 제작 비용. 불합격이면 구매로 되돌아가고 불합격 비용이 든다.\"\"\"\n", + " # [TODO : 학생 실습 10] 3D 프린팅 야전 긴급 부품 제작 타당성 및 신뢰성 평가 도구 최종 완성 관련 핵심 로직을 구현하세요.\n", + " # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n", + " # <<< 여기에 코드를 작성하세요 >>>\n", + " pass # 작성 후 pass를 제거하세요.\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": [ + "
\n", + "💡 [참고] 미션 10 예상 실행 결과 (Output)\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", + "
\n" + ] + } + ], + "metadata": { + "colab": { + "provenance": [] + }, + "kernelspec": { + "display_name": "Python 3", + "name": "python3" + }, + "language_info": { + "name": "python" + } + }, + "nbformat": 4, + "nbformat_minor": 0 +} \ No newline at end of file diff --git a/uploads/MNT10/MNT10_solution.ipynb b/uploads/MNT10/MNT10_solution.ipynb new file mode 100644 index 0000000..a3b2234 --- /dev/null +++ b/uploads/MNT10/MNT10_solution.ipynb @@ -0,0 +1,2912 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# 🎯 [MNT10] 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/MNT10/MNT10_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", + "야전에서 장비가 고장 나면 이동정비반이 얼마나 빨리 도착해 고치느냐가 부대 전투력 회복 속도를 좌우하지만, 가까운 정비반부터 보내는 방식은 요청이 몰리는 지역의 정비반에 부하를 집중시키고 핵심 장비를 뒤로 미루는 문제가 있다. 본 프로젝트는 정비인력의 부하율과 공정 균형을 데이터로 예측·관리하는 것을 목표로, 교육용 가상 지역의 긴급 정비 요청과 이동정비반 자료를 코드로 만들어 분석한다. 요청 데이터 스키마 정의, 정비반 상태 자료 정제, 거리 기준 Baseline과 탐색적 분석, 중요도·정비 소요시간·이동 경로를 종합한 우선순위 엔진, 출동 지연 위험도와 현장 수리 가능성 점수, 관제 대시보드와 설명가능성, 정비대대장 승인(HITL) 시뮬레이터, 동시 다발 요청 스트레스 시험, 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. 야전 긴급 정비 요청(고장 위치, 장비 중요도, 고장 유형) 데이터셋 스키마 정의\n", + "**소요시간**: 약 1.5시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "야전 긴급 정비 요청(고장 위치, 장비 중요도) 데이터 스키마를 정의한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **야전 긴급 정비 요청(고장 위치, 장비 중요도, 고장 유형)과 이동정비반 상태 자료의 스키마, 데이터 딕셔너리, 업무 프로세스 맵을 정의한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **요청 접수부터 배정·출동·정비·복귀·실적 입력까지 업무 흐름을 단계별로 정리하고, 요청·정비반·결과 세 묶음으로 피처 20개 이상 정의하고 검증하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `docs/mnt10_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": [ + "\"\"\"야전 긴급 정비 요청(고장 위치, 장비 중요도, 고장 유형)과 이동정비반 상태 데이터의 스키마·데이터 딕셔너리(피처 20개 이상)와 업무 프로세스 맵을 정의하고, 가상 샘플 데이터를 검증한다.\"\"\"\n", + "import json\n", + "import re\n", + "import numpy as np\n", + "import pandas as pd\n", + "\n", + "# 1) 업무 프로세스 맵: 접수부터 부하율 집계·사후검토까지 (각 단계에서 생기는 데이터)\n", + "PROCESS = [(\"접수\", \"요청 ID, 접수시각, 가상 좌표, 장비 구분·코드\"), (\"분류\", \"고장 유형, 작전 중요도, 부품 확보 여부\"),\n", + " (\"우선순위\", \"우선순위 점수, 예상 정비시간\"), (\"배정\", \"정비반 ID, 이동거리, 대기(백로그)시간\"),\n", + " (\"출동\", \"출발·도착 시각, 기상, 도로 상태\"), (\"현장정비/후송\", \"현장 수리 가능 여부, 실제 정비시간\"),\n", + " (\"완료보고\", \"완료 시각, 장비 가동중단 시간\"), (\"부하율 집계\", \"정비반별 일일 투입시간/가용시간\"),\n", + " (\"사후검토(AAR)\", \"지연·오배정 원인, 가중치 조정 이력\")]\n", + "\n", + "# 2) 스키마(데이터 딕셔너리): 그룹 / 필드 / 자료형 / 규칙 / 설명\n", + "S = {\n", + " \"request\": {\n", + " \"req_id\": (str, {\"pattern\": r\"^R\\d{4}$\"}, \"요청 ID\"),\n", + " \"t_req\": (float, {\"min\": 0}, \"접수시각(모의 시작 후 경과 시간, h)\"),\n", + " \"unit_code\": (str, {\"pattern\": r\"^VU-\\d{2}$\"}, \"요청 부대 코드(가상)\"),\n", + " \"grid_x\": (float, {\"min\": 0, \"max\": 60}, \"고장 위치 X(가상 좌표, km)\"),\n", + " \"grid_y\": (float, {\"min\": 0, \"max\": 60}, \"고장 위치 Y(가상 좌표, km)\"),\n", + " \"equip_type\": (str, {\"enum\": [\"mobility\", \"fires\", \"comms\", \"power\"]}, \"장비 구분\"),\n", + " \"equip_code\": (str, {\"pattern\": r\"^VX-\\d{4}-\\d{4}$\"}, \"장비 식별코드(국방 표준 코드 형식을 모사한 가상 코드)\"),\n", + " \"fsc_group\": (str, {\"pattern\": r\"^\\d{4}$\"}, \"보급 분류군(가상 4자리)\"),\n", + " \"crit\": (int, {\"enum\": [1, 2, 3]}, \"작전 중요도(1 일반, 2 중요, 3 핵심)\"),\n", + " \"fault_type\": (str, {\"enum\": [\"engine\", \"drivetrain\", \"hydraulic\", \"electric\", \"comm_module\", \"cooling\"]}, \"고장 유형\"),\n", + " \"parts_ready\": (bool, {}, \"수리부속 확보 여부\"),\n", + " \"weather\": (str, {\"enum\": [\"clear\", \"rain\", \"snow\"]}, \"기상\"),\n", + " \"road\": (str, {\"enum\": [\"paved\", \"unpaved\"]}, \"접근 도로 상태\"),\n", + " },\n", + " \"team\": {\n", + " \"team_id\": (str, {\"pattern\": r\"^MT\\d$\"}, \"이동정비반 ID\"),\n", + " \"base_x\": (float, {\"min\": 0, \"max\": 60}, \"정비반 대기 위치 X\"),\n", + " \"base_y\": (float, {\"min\": 0, \"max\": 60}, \"정비반 대기 위치 Y\"),\n", + " \"crew\": (int, {\"min\": 2, \"max\": 8}, \"가용 정비인력(명)\"),\n", + " \"skills\": (list, {}, \"정비 가능 장비 구분\"),\n", + " \"status\": (str, {\"enum\": [\"ready\", \"moving\", \"repairing\", \"returning\"]}, \"출동 가능 상태\"),\n", + " },\n", + " \"result\": {\n", + " \"std_repair_h\": (float, {\"min\": 0, \"max\": 48}, \"표준 정비시간(4인 기준) - 예측대상 1\"),\n", + " \"response_h\": (float, {\"min\": 0, \"max\": 72}, \"접수~도착 시간 - 예측대상 2(출동 지연)\"),\n", + " \"downtime_h\": (float, {\"min\": 0, \"max\": 240}, \"장비 가동중단 시간\"),\n", + " \"field_ok\": (bool, {}, \"현장 수리 가능 여부 - 예측대상 3\"),\n", + " \"load_rate\": (float, {\"min\": 0, \"max\": 3}, \"정비반 일일 부하율 = 투입시간/가용시간(10h) - 예측대상 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", + "recs = []\n", + "for i in range(20):\n", + " std = float(round(rng.uniform(1, 9), 2))\n", + " recs.append({\n", + " \"request\": {\"req_id\": f\"R{i:04d}\", \"t_req\": float(round(rng.uniform(0, 720), 1)), \"unit_code\": f\"VU-{rng.integers(1, 30):02d}\",\n", + " \"grid_x\": float(round(rng.uniform(0, 60), 1)), \"grid_y\": float(round(rng.uniform(0, 60), 1)),\n", + " \"equip_type\": str(rng.choice([\"mobility\", \"fires\", \"comms\", \"power\"])),\n", + " \"equip_code\": f\"VX-{rng.integers(1000, 9999)}-{rng.integers(1000, 9999)}\", \"fsc_group\": f\"{rng.integers(1000, 9999)}\",\n", + " \"crit\": int(rng.integers(1, 4)), \"fault_type\": str(rng.choice([\"engine\", \"hydraulic\", \"electric\", \"cooling\"])),\n", + " \"parts_ready\": bool(rng.random() < 0.75), \"weather\": str(rng.choice([\"clear\", \"rain\", \"snow\"])), \"road\": \"paved\"},\n", + " \"team\": {\"team_id\": f\"MT{rng.integers(1, 7)}\", \"base_x\": 30.0, \"base_y\": 6.0, \"crew\": int(rng.integers(3, 6)),\n", + " \"skills\": [\"mobility\", \"comms\"], \"status\": \"ready\"},\n", + " \"result\": {\"std_repair_h\": std, \"response_h\": float(round(rng.uniform(0.5, 6), 2)),\n", + " \"downtime_h\": float(round(std + rng.uniform(1, 8), 2)), \"field_ok\": bool(rng.random() < 0.6),\n", + " \"load_rate\": float(round(rng.uniform(0.3, 1.3), 2))}})\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[\"request\"][\"grid_x\"] = 75.0 # 작전지역 밖 좌표\n", + "bad[\"request\"][\"crit\"] = 5 # 허용되지 않은 중요도\n", + "bad[\"request\"][\"equip_code\"] = \"1234567890123\" # 코드 형식 오류\n", + "del bad[\"team\"][\"crew\"] # 가용 인력 누락\n", + "bad[\"result\"][\"load_rate\"] = \"90%\" # 자료형 오류(문자열)\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:8s}: {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" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 01 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + "정상 샘플 20건 중 검증 통과 20건, 오류 0건\n", + "\n", + "[오류 주입 결과]\n", + " - request.grid_x: 최대값 60 초과\n", + " - request.equip_code: 형식 불일치 '1234567890123'\n", + " - request.crit: 허용되지 않은 값 '5'\n", + " - team.crew: 필수 필드 누락\n", + " - result.load_rate: 자료형 오류(str)\n", + "\n", + "[업무 프로세스 맵]\n", + "1.접수 -> 2.분류 -> 3.우선순위 -> 4.배정 -> 5.출동 -> 6.현장정비/후송 -> 7.완료보고 -> 8.부하율 집계 -> 9.사후검토(AAR)\n", + " 접수 : 요청 ID, 접수시각, 가상 좌표, 장비 구분·코드\n", + " 분류 : 고장 유형, 작전 중요도, 부품 확보 여부\n", + " 우선순위 : 우선순위 점수, 예상 정비시간\n", + " 배정 : 정비반 ID, 이동거리, 대기(백로그)시간\n", + " 출동 : 출발·도착 시각, 기상, 도로 상태\n", + " 현장정비/후송 : 현장 수리 가능 여부, 실제 정비시간\n", + " 완료보고 : 완료 시각, 장비 가동중단 시간\n", + " 부하율 집계 : 정비반별 일일 투입시간/가용시간\n", + " 사후검토(AAR): 지연·오배정 원인, 가중치 조정 이력\n", + "\n", + "[데이터 딕셔너리] 피처 24개 (목표 20개 이상: 달성)\n", + " 그룹 필드 자료형 설명\n", + "request req_id str 요청 ID\n", + "request t_req float 접수시각(모의 시작 후 경과 시간, h)\n", + "request unit_code str 요청 부대 코드(가상)\n", + "request grid_x float 고장 위치 X(가상 좌표, km)\n", + "request grid_y float 고장 위치 Y(가상 좌표, km)\n", + "request equip_type str 장비 구분\n", + "request equip_code str 장비 식별코드(국방 표준 코드 형식을 모사한 가상 코드)\n", + "request fsc_group str 보급 분류군(가상 4자리)\n", + "request crit int 작전 중요도(1 일반, 2 중요, 3 핵심)\n", + "request fault_type str 고장 유형\n", + "request parts_ready bool 수리부속 확보 여부\n", + "request weather str 기상\n", + "request road str 접근 도로 상태\n", + " team team_id str 이동정비반 ID\n", + " team base_x float 정비반 대기 위치 X\n", + " team base_y float 정비반 대기 위치 Y\n", + " team crew int 가용 정비인력(명)\n", + "... (이하 생략)\n", + "```\n", + "
\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 02. 이동정비반 보유 장비/공구 및 출동 가능 상태 정보 전처리 모듈 구현\n", + "**소요시간**: 약 2.0시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "이동정비반 보유 장비 및 출동 가능 상태 정보 전처리 모듈을 구현한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **이동정비반의 보유 장비·공구·인력·출동 가능 상태 로그를 정제하는 전처리 모듈을 구현한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **중복 보고 제거, 정비반 ID·상태값·시각 표기 통일, 연료 단위 통일, 불가능한 인원·음수 공구 수량 결측 처리, 좌표 이상값 보간, KNN 결측 보정 등 정제 규칙 10종을 적용 수행하고 검증하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `src/etl/mnt10_data_cleansing.py 및 정제 품질 보고서` 형태의 결과물을 완성한다.\n", + "\n", + "### 🔍 평가 및 검증 기준 (PASS 조건)\n", + "#### 미션 판정\n", + "\n", + "**평가 구분:** 필수\n", + "\n", + "**핵심 평가영역:** 군수 특수 예외(비계획 정비, 긴급 불출 등) 데이터 보존의 타당성\n", + "\n", + "#### 미션 PASS 조건\n", + "\n", + "- 정량 기준: 데이터 정제 규칙 10종 적용, 데이터 정합성 검증 통과율 100%\n", + "\n", + "\n", + "- 진행 기준: ETL 파이프라인 구현 및 정제된 마스터 데이터셋 구축\n", + "\n", + "\n", + "- 검증 방법: 전후 데이터 통계량(기술통계) 비교 및 무결성 검증\n", + "\n", + "\n", + "#### 채점자 판정 메모\n", + "\n", + "> 단순 코드 실행 여부뿐만 아니라 출력된 수치와 근거 자료가 군 실무 요구사항에 부합하는지 면밀히 확인한다.\n", + "\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "\"\"\"이동정비반의 보유 장비·공구·인력·출동 가능 상태 로그(표기 불일치·중복·단위 혼재·결측이 섞인 가상 원천 데이터)에 정제 규칙 10종을 적용하고, 긴급 불출 등 군수 예외 데이터는 보존하면서 정합성 검증 통과율과 정제 전후 통계를 확인한다.\"\"\"\n", + "import numpy as np\n", + "import pandas as pd\n", + "import matplotlib.pyplot as plt\n", + "from sklearn.impute import KNNImputer\n", + "\n", + "# 1) [교육용가상자료] 원천 상태 로그: 정비반 6개 x 30일 x 하루 2회 보고 + 일부러 넣은 오류\n", + "rng = np.random.default_rng(2)\n", + "rows = []\n", + "for d in range(30):\n", + " for h in (8, 18):\n", + " for t in range(1, 7):\n", + " rows.append({\"team\": f\"MT{t}\", \"ts\": f\"2026-03-{d + 1:02d} {h:02d}:00\", \"status\": str(rng.choice([\"ready\", \"moving\", \"repairing\"], p=[0.6, 0.2, 0.2])),\n", + " \"crew\": int(rng.integers(3, 6)), \"fuel\": float(round(rng.uniform(35, 100), 1)), \"fuel_unit\": \"%\",\n", + " \"diag_kit\": int(rng.integers(1, 3)), \"lift_kit\": int(rng.integers(0, 2)), \"x\": float(round(rng.uniform(0, 60), 1)),\n", + " \"y\": float(round(rng.uniform(0, 60), 1)), \"emergency_issue\": False})\n", + "raw = pd.DataFrame(rows)\n", + "n0 = len(raw)\n", + "idx = rng.choice(n0, 150, replace=False)\n", + "a, b, c, d_, e, f, g, h_, k, m = np.split(idx, 10)\n", + "raw.loc[a, \"team\"] = raw.loc[a, \"team\"].str.replace(\"MT\", \"mt-\") # ID 표기 불일치\n", + "raw.loc[b, \"status\"] = rng.choice([\"READY\", \"ready \", \"대기\", \"Repairing\"], len(b)) # 상태값 표기 불일치\n", + "raw.loc[c, \"ts\"] = pd.to_datetime(raw.loc[c, \"ts\"]).dt.strftime(\"%Y%m%d %H%M\") # 시각 형식 혼재\n", + "raw.loc[d_, \"fuel\"] = (raw.loc[d_, \"fuel\"] * 4).round(0); raw.loc[d_, \"fuel_unit\"] = \"L\" # 단위 혼재(가상 탱크 400L)\n", + "raw.loc[e, \"crew\"] = rng.choice([-1, 0, 25], len(e)) # 불가능 값\n", + "raw.loc[f, \"diag_kit\"] = -1 # 음수 수량\n", + "raw.loc[g, \"x\"] = 999.0 # 좌표 범위 밖\n", + "raw.loc[h_, [\"crew\", \"fuel\"]] = np.nan # 결측\n", + "raw.loc[k, \"lift_kit\"] = 6; raw.loc[k, \"emergency_issue\"] = True # 긴급 불출(예외: 보존 대상)\n", + "raw.loc[m, \"status\"] = None # 상태 결측\n", + "raw = pd.concat([raw, raw.sample(12, random_state=1)], ignore_index=True) # 중복 보고\n", + "print(f\"원천 로그 {len(raw)}건 (중복 12건, 오류 주입 {len(idx)}건 포함)\")\n", + "\n", + "# 2) 정제 규칙 10종\n", + "df, applied = raw.copy(), []\n", + "def rule(name, n):\n", + " applied.append((name, int(n)))\n", + "\n", + "rule(\"R1 중복 보고 제거\", df.duplicated().sum()); df = df.drop_duplicates().reset_index(drop=True)\n", + "bad_id = ~df[\"team\"].str.match(r\"^MT\\d$\"); df[\"team\"] = \"MT\" + df[\"team\"].str.extract(r\"(\\d+)\")[0]; rule(\"R2 정비반 ID 표기 통일\", bad_id.sum())\n", + "smap = {\"ready\": \"ready\", \"대기\": \"ready\", \"moving\": \"moving\", \"repairing\": \"repairing\"}\n", + "s = df[\"status\"].fillna(\"\").str.strip().str.lower().map(smap); rule(\"R3 상태값 표준화\", (s != df[\"status\"]).sum()); df[\"status\"] = s\n", + "t1 = pd.to_datetime(df[\"ts\"], format=\"%Y-%m-%d %H:%M\", errors=\"coerce\")\n", + "t2 = pd.to_datetime(df[\"ts\"], format=\"%Y%m%d %H%M\", errors=\"coerce\"); rule(\"R4 보고시각 형식 통일\", t1.isna().sum()); df[\"ts\"] = t1.fillna(t2)\n", + "lit = df[\"fuel_unit\"] == \"L\"; df.loc[lit, \"fuel\"] = df.loc[lit, \"fuel\"] / 4; df[\"fuel_unit\"] = \"%\"; rule(\"R5 연료 단위 통일(L -> %)\", lit.sum())\n", + "bad = ~df[\"crew\"].between(1, 8) & df[\"crew\"].notna(); df.loc[bad, \"crew\"] = np.nan; rule(\"R6 불가능 인원값 결측 처리\", bad.sum())\n", + "bad = df[\"diag_kit\"] < 0; df.loc[bad, \"diag_kit\"] = np.nan; rule(\"R7 음수 공구 수량 결측 처리\", bad.sum())\n", + "bad = ~df[\"x\"].between(0, 60); df.loc[bad, [\"x\", \"y\"]] = np.nan; df = df.sort_values([\"team\", \"ts\"])\n", + "df[[\"x\", \"y\"]] = df.groupby(\"team\")[[\"x\", \"y\"]].ffill().bfill(); rule(\"R8 좌표 이상값 직전 위치 보간\", bad.sum())\n", + "miss = df[[\"crew\", \"fuel\", \"diag_kit\"]].isna().sum().sum()\n", + "df[[\"crew\", \"fuel\", \"diag_kit\"]] = KNNImputer(n_neighbors=5).fit_transform(df[[\"crew\", \"fuel\", \"diag_kit\", \"lift_kit\"]])[:, :3]\n", + "df[\"crew\"] = df[\"crew\"].round().astype(int); df[\"diag_kit\"] = df[\"diag_kit\"].round().astype(int); rule(\"R9 결측치 KNN 보정\", miss)\n", + "q1, q3 = df[\"lift_kit\"].quantile([0.25, 0.75]); out = df[\"lift_kit\"] > q3 + 1.5 * (q3 - q1)\n", + "df[\"outlier_flag\"] = out & ~df[\"emergency_issue\"]; rule(\"R10 예외(긴급 불출) 보존·일반 이상값 표시\", (out & df[\"emergency_issue\"]).sum())\n", + "df[\"status\"] = df.groupby(\"team\")[\"status\"].transform(lambda x: x.ffill().fillna(\"ready\"))\n", + "df[\"dispatchable\"] = (df[\"status\"] == \"ready\") & (df[\"crew\"] >= 3) & (df[\"fuel\"] >= 30) & (df[\"diag_kit\"] >= 1)\n", + "print(\"\\n[적용 규칙과 처리 건수]\")\n", + "for nme, n in applied:\n", + " print(f\" {nme:28s} {n:4d}건\")\n", + "\n", + "# 3) 정합성 검증\n", + "checks = {\"중복 0건\": not df.duplicated([\"team\", \"ts\"]).any(), \"ID 형식\": df[\"team\"].str.match(r\"^MT[1-6]$\").all(),\n", + " \"상태값 허용 범위\": df[\"status\"].isin([\"ready\", \"moving\", \"repairing\"]).all(), \"시각 변환\": df[\"ts\"].notna().all(),\n", + " \"인원 1~8명\": df[\"crew\"].between(1, 8).all(), \"연료 0~100%\": df[\"fuel\"].between(0, 100).all(),\n", + " \"공구 수량 0 이상\": (df[\"diag_kit\"] >= 0).all(), \"좌표 범위\": df[\"x\"].between(0, 60).all(),\n", + " \"결측 0건\": df[[\"crew\", \"fuel\", \"diag_kit\", \"x\", \"y\", \"status\"]].notna().all().all(),\n", + " f\"긴급 불출 {len(k)}건 보존\": int(df[\"emergency_issue\"].sum()) == len(k)}\n", + "rate = np.mean(list(checks.values()))\n", + "print(f\"\\n정합성 검증 {sum(checks.values())}/{len(checks)} 통과, 통과율 {rate:.0%} (목표 100%: {'달성' if rate == 1 else '미달'})\")\n", + "print(f\"정제 후 {len(df)}건, 출동 가능 보고 비율 {df['dispatchable'].mean():.1%}\")\n", + "\n", + "# 4) 정제 전후 기술통계 비교\n", + "num = lambda x: pd.to_numeric(x, errors=\"coerce\")\n", + "cmp = pd.DataFrame({\"정제 전 평균\": [num(raw[c]).mean() for c in [\"crew\", \"fuel\", \"diag_kit\", \"x\"]],\n", + " \"정제 후 평균\": [df[c].mean() for c in [\"crew\", \"fuel\", \"diag_kit\", \"x\"]],\n", + " \"정제 전 최대\": [num(raw[c]).max() for c in [\"crew\", \"fuel\", \"diag_kit\", \"x\"]],\n", + " \"정제 후 최대\": [df[c].max() for c in [\"crew\", \"fuel\", \"diag_kit\", \"x\"]]}, index=[\"인원\", \"연료%\", \"진단키트\", \"좌표X\"]).round(1)\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[\"fuel\"]).dropna(), bins=30, alpha=0.6, label=\"raw\"); ax[0].hist(df[\"fuel\"], bins=30, alpha=0.6, label=\"cleaned\")\n", + "ax[0].set_title(\"Fuel level 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": [ + "
\n", + "💡 [참고] 미션 02 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + "원천 로그 372건 (중복 12건, 오류 주입 150건 포함)\n", + "\n", + "[적용 규칙과 처리 건수]\n", + " R1 중복 보고 제거 12건\n", + " R2 정비반 ID 표기 통일 15건\n", + " R3 상태값 표준화 30건\n", + " R4 보고시각 형식 통일 15건\n", + " R5 연료 단위 통일(L -> %) 15건\n", + " R6 불가능 인원값 결측 처리 15건\n", + " R7 음수 공구 수량 결측 처리 15건\n", + " R8 좌표 이상값 직전 위치 보간 15건\n", + " R9 결측치 KNN 보정 60건\n", + " R10 예외(긴급 불출) 보존·일반 이상값 표시 15건\n", + "\n", + "정합성 검증 10/10 통과, 통과율 100% (목표 100%: 달성)\n", + "정제 후 360건, 출동 가능 보고 비율 60.3%\n", + "\n", + "[정제 전후 기술통계]\n", + " 정제 전 평균 정제 후 평균 정제 전 최대 정제 후 최대\n", + "인원 4.2 4.0 25.0 5.0\n", + "연료% 74.1 66.9 366.0 100.0\n", + "진단키트 1.4 1.5 2.0 2.0\n", + "좌표X 71.8 30.1 999.0 59.8\n", + "```\n", + "
\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 03. 거리 기준 1차 이동정비반 배정 Baseline 알고리즘 구현\n", + "**소요시간**: 약 2.5시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "거리 기준 1차 이동정비반 배정 Baseline 알고리즘을 구현한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **거리 기준 1차 이동정비반 배정 Baseline(접수 순서 + 최근접 정비반)을 구현하고 탐색적 분석으로 출동 지연과 부하 불균형의 원인을 찾는다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **가상 30일 요청을 Baseline으로 배정·모의해 정비반별 부하율, 도착시간, 이동시간을 산출 수행하고 검증하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `notebooks/mnt10_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": [ + "\"\"\"거리 기준 1차 이동정비반 배정 Baseline(접수 순서 + 최근접 정비반)을 구현해 30일 출동을 모의하고, 탐색적 분석(EDA) 차트 8종·상관분석(p-value)·파레토(ABC) 분석으로 출동 지연과 정비인력 부하 불균형의 원인을 찾는다.\"\"\"\n", + "import numpy as np\n", + "import pandas as pd\n", + "import matplotlib.pyplot as plt\n", + "from scipy import stats\n", + "\n", + "# ---------------------------------------------------------------------------\n", + "# [교육용가상자료] 야전 이동정비반·긴급 정비 요청 생성기 (실제 부대·위치·장비·인원 정보 없음)\n", + "# 60km x 60km 가상 지역에 이동정비반 6개와 30일간의 긴급 정비 요청을 만든다.\n", + "# 장비 구분(mobility/fires/comms/power)과 고장 유형은 교육용 추상 분류다.\n", + "# ---------------------------------------------------------------------------\n", + "AREA_KM, DUTY_H = 60, 10.0 # 가상 작전지역 크기, 정비반 1일 기준 가용시간\n", + "FAULT_H = {\"engine\": 6.0, \"drivetrain\": 5.0, \"hydraulic\": 3.0, \"electric\": 2.0, \"comm_module\": 1.5, \"cooling\": 2.5}\n", + "FIELD_P = {\"engine\": 0.3, \"drivetrain\": 0.35, \"hydraulic\": 0.75, \"electric\": 0.9, \"comm_module\": 0.95, \"cooling\": 0.8}\n", + "EQUIP_F = {\"mobility\": 1.0, \"fires\": 1.2, \"comms\": 0.7, \"power\": 0.8}\n", + "FAULT_OF = {\"mobility\": [0.3, 0.25, 0.2, 0.1, 0.0, 0.15], \"fires\": [0.2, 0.15, 0.35, 0.15, 0.0, 0.15],\n", + " \"comms\": [0.0, 0.0, 0.0, 0.4, 0.5, 0.1], \"power\": [0.35, 0.0, 0.0, 0.35, 0.0, 0.3]}\n", + "CRIT_P = {\"mobility\": [0.45, 0.35, 0.2], \"fires\": [0.2, 0.35, 0.45], \"comms\": [0.3, 0.4, 0.3], \"power\": [0.6, 0.3, 0.1]}\n", + "HUBS = [(15, 20), (45, 25), (30, 50)] # 가상 훈련장 3곳(요청이 몰리는 지점)\n", + "\n", + "\n", + "def make_teams(seed=0):\n", + " rng = np.random.default_rng(seed)\n", + " base = [(8, 10), (30, 6), (52, 12), (10, 44), (32, 38), (54, 50)]\n", + " skills = [[\"fires\", \"mobility\"], [\"comms\", \"mobility\"], [\"fires\", \"power\"],\n", + " [\"mobility\", \"power\"], [\"comms\", \"fires\"], [\"comms\", \"mobility\", \"power\"]]\n", + " return [{\"team\": f\"MT{i + 1}\", \"x\": float(x), \"y\": float(y), \"crew\": int(rng.integers(3, 6)), \"skills\": s}\n", + " for i, ((x, y), s) in enumerate(zip(base, skills))]\n", + "\n", + "\n", + "def make_requests(n_days=30, per_day=10, seed=1, surge_days=(8, 9, 10, 11, 22, 23, 24)):\n", + " \"\"\"하루 평균 per_day건, 훈련 주간(surge_days)에는 1.6배의 긴급 정비 요청을 만든다.\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " rows = []\n", + " for d in range(n_days):\n", + " n = rng.poisson(per_day * (1.6 if d in surge_days else 1.0))\n", + " weather = rng.choice([\"clear\", \"rain\", \"snow\"], p=[0.65, 0.25, 0.1])\n", + " for _ in range(n):\n", + " if rng.random() < 0.7:\n", + " hx, hy = HUBS[rng.integers(0, 3)]\n", + " x, y = np.clip(rng.normal(hx, 6), 0, AREA_KM), np.clip(rng.normal(hy, 6), 0, AREA_KM)\n", + " else:\n", + " x, y = rng.uniform(0, AREA_KM, 2)\n", + " eq = str(rng.choice(list(EQUIP_F), p=[0.45, 0.25, 0.2, 0.1]))\n", + " fault = str(rng.choice(list(FAULT_H), p=FAULT_OF[eq]))\n", + " parts = bool(rng.random() < 0.75)\n", + " bad = weather != \"clear\"\n", + " std_h = FAULT_H[fault] * EQUIP_F[eq] * (1.35 if not parts else 1.0) * (1.15 if bad else 1.0) * rng.lognormal(0, 0.12)\n", + " rows.append({\"t_req\": d * 24 + float(np.clip(rng.normal(13, 4), 5, 23)), \"day\": d,\n", + " \"x\": float(x), \"y\": float(y), \"equip\": eq, \"fault\": fault,\n", + " \"crit\": int(rng.choice([1, 2, 3], p=CRIT_P[eq])), \"parts_ready\": parts,\n", + " \"weather\": str(weather), \"road\": \"unpaved\" if y > 30 and rng.random() < 0.7 else \"paved\",\n", + " \"field_ok\": bool(rng.random() < FIELD_P[fault] * (1.0 if parts else 0.5)),\n", + " \"std_repair_h\": round(float(std_h), 2), \"road_delay\": float(rng.lognormal(0, 0.15))})\n", + " df = pd.DataFrame(rows).sort_values(\"t_req\").reset_index(drop=True)\n", + " df.insert(0, \"req_id\", [f\"R{i:04d}\" for i in range(len(df))])\n", + " return df\n", + "# ---------------------------------------------------------------------------\n", + "\n", + "# ---- 출동 배정·정비 모의 공통 함수 ----\n", + "W0 = {\"crit\": 1.0, \"wait\": 0.3, \"short\": 0.2, \"balance\": 0.5} # 우선순위·배정 기본 가중치(미션 9에서 재튜닝)\n", + "\n", + "\n", + "def travel_h(team, r, actual=False):\n", + " \"\"\"도로 굴곡 1.3배, 포장 35km/h·비포장 25km/h, 악천후 20% 감속. actual=True면 실제 도로 지연을 반영.\"\"\"\n", + " d = 1.3 * np.hypot(team[\"x\"] - r[\"x\"], team[\"y\"] - r[\"y\"])\n", + " v = (35.0 if r[\"road\"] == \"paved\" else 25.0) / (1.2 if r[\"weather\"] != \"clear\" else 1.0)\n", + " return d / v * (r[\"road_delay\"] if actual else 1.0)\n", + "\n", + "\n", + "def on_site_h(team, r):\n", + " \"\"\"현장 정비시간: 4인 기준 표준시간을 투입 인원으로 환산. 현장 수리 불가면 진단·후송 조치 1시간.\"\"\"\n", + " return r[\"std_repair_h\"] * 4 / team[\"crew\"] if r[\"field_ok\"] else 1.0\n", + "\n", + "\n", + "def priority(r, now, est_h, w):\n", + " return w[\"crit\"] * r[\"crit\"] + w[\"wait\"] * (now - r[\"t_req\"]) + w[\"short\"] / (1 + est_h)\n", + "\n", + "\n", + "def simulate(req, teams, policy=\"baseline\", w=None, cycle_h=1.0, est=None):\n", + " \"\"\"배정 주기(cycle_h)마다 대기 요청을 정렬해 이동정비반에 배정하고 출동·정비·복귀를 모의한다.\n", + " baseline: 접수 순서 + 거리 최근접 정비반 / priority: 우선순위 점수 순서 + 도착 예상시각·당일 부하 균형\"\"\"\n", + " w = w or W0\n", + " est = np.asarray(est if est is not None else req[\"std_repair_h\"].values, float)\n", + " n_days = int(req[\"day\"].max()) + 1\n", + " free = {t[\"team\"]: 0.0 for t in teams}\n", + " load = {t[\"team\"]: np.zeros(n_days + 5) for t in teams}\n", + " recs, out, pending, i, now = req.to_dict(\"records\"), [], [], 0, cycle_h\n", + " while i < len(recs) or pending:\n", + " while i < len(recs) and recs[i][\"t_req\"] <= now:\n", + " pending.append((i, recs[i])); i += 1\n", + " if policy == \"priority\":\n", + " pending.sort(key=lambda p: -priority(p[1], now, est[p[0]], w))\n", + " for k, r in pending:\n", + " cand = [t for t in teams if r[\"equip\"] in t[\"skills\"]]\n", + " day = min(int(now // 24), n_days + 4)\n", + " if policy == \"baseline\":\n", + " tm = min(cand, key=lambda t: np.hypot(t[\"x\"] - r[\"x\"], t[\"y\"] - r[\"y\"]))\n", + " else:\n", + " tm = min(cand, key=lambda t: max(free[t[\"team\"]], now) + travel_h(t, r)\n", + " + w[\"balance\"] * load[t[\"team\"]][day] / DUTY_H * 4)\n", + " backlog = max(0.0, free[tm[\"team\"]] - now)\n", + " go = max(free[tm[\"team\"]], now)\n", + " tr = travel_h(tm, r, actual=True)\n", + " work = on_site_h(tm, r)\n", + " free[tm[\"team\"]] = go + 2 * tr + work\n", + " load[tm[\"team\"]][min(int(go // 24), n_days + 4)] += 2 * tr + work\n", + " out.append({**r, \"idx\": k, \"team\": tm[\"team\"], \"dist_km\": float(np.hypot(tm[\"x\"] - r[\"x\"], tm[\"y\"] - r[\"y\"])),\n", + " \"backlog_h\": backlog, \"cycle_wait_h\": now - r[\"t_req\"], \"dispatch\": go, \"travel_h\": tr,\n", + " \"work_h\": work, \"response_h\": go + tr - r[\"t_req\"], \"downtime_h\": go + tr + work - r[\"t_req\"]})\n", + " pending, now = [], now + cycle_h\n", + " L = pd.DataFrame({k: v[:n_days] for k, v in load.items()}) / DUTY_H # 정비반별 일일 부하율\n", + " return pd.DataFrame(out).sort_values(\"idx\").reset_index(drop=True), L\n", + "\n", + "\n", + "def metrics(res, L):\n", + " c3 = res[res[\"crit\"] == 3]\n", + " return {\"평균 도착(h)\": round(res[\"response_h\"].mean(), 2), \"핵심장비 도착(h)\": round(c3[\"response_h\"].mean(), 2),\n", + " \"핵심장비 90%(h)\": round(c3[\"response_h\"].quantile(0.9), 2), \"평균 부하율\": round(float(L.values.mean()), 3),\n", + " \"부하율 편차\": round(float(L.mean().std()), 3), \"과부하 일수\": int((L > 1.0).values.sum()),\n", + " \"총 이동(h)\": round(float(res[\"travel_h\"].sum() * 2), 1)}\n", + "\n", + "\n", + "teams, req = make_teams(), make_requests()\n", + "res, L = simulate(req, teams, \"baseline\")\n", + "m = metrics(res, L)\n", + "print(f\"[Baseline 결과] 요청 {len(res)}건\")\n", + "for k, v in m.items():\n", + " print(f\" {k:10s} {v}\")\n", + "print(\"정비반별 평균 부하율:\", {k: round(float(v), 2) for k, v in L.mean().items()})\n", + "print(f\"-> 가까운 정비반만 고르면 요청이 몰리는 지역의 정비반(MT{int(L.mean().argmax()) + 1})에 부하가 집중된다.\")\n", + "\n", + "# 1) 상관분석: 출동 지연(접수~도착 시간)과 관련된 피처 (피어슨 상관, 유의수준 0.05)\n", + "res[\"weather_bad\"] = (res[\"weather\"] != \"clear\").astype(int)\n", + "res[\"unpaved\"] = (res[\"road\"] == \"unpaved\").astype(int)\n", + "res[\"hour\"] = res[\"t_req\"] % 24\n", + "res[\"day_requests\"] = res.groupby(\"day\")[\"req_id\"].transform(\"count\")\n", + "res[\"team_load\"] = [L.loc[d, t] for d, t in zip(res[\"day\"], res[\"team\"])]\n", + "feats = [\"dist_km\", \"backlog_h\", \"team_load\", \"day_requests\", \"weather_bad\", \"unpaved\", \"std_repair_h\", \"crit\", \"hour\"]\n", + "corr = []\n", + "for f in feats:\n", + " r, p = stats.pearsonr(res[f].astype(float), res[\"response_h\"])\n", + " corr.append({\"피처\": f, \"상관계수\": round(r, 3), \"p-value\": f\"{p:.1e}\", \"유의\": p < 0.05})\n", + "corr = pd.DataFrame(corr).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", + "print(\"-> 작전 중요도(crit)와 도착시간의 상관이 0에 가깝다: Baseline은 핵심 장비를 먼저 챙기지 못한다(미션 4의 동기).\")\n", + "\n", + "# 2) 파레토(ABC) 분석: 장비 가동중단 시간을 가장 많이 만드는 (장비, 고장유형) 조합\n", + "g = res.groupby([\"equip\", \"fault\"])[\"downtime_h\"].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", + "print(\"\\n[파레토 A등급(누적 가동중단 80%)]\", \", \".join(f\"{e}/{f}\" for (e, f), c in abc.items() if c == \"A\"))\n", + "print(f\"A등급은 전체 조합 {len(g)}개 중 {int((abc == 'A').sum())}개({(abc == 'A').mean():.0%})\")\n", + "\n", + "# 3) EDA 차트 8종\n", + "fig, ax = plt.subplots(2, 4, figsize=(18, 8))\n", + "a = ax.ravel()\n", + "a[0].scatter(req[\"x\"], req[\"y\"], s=8, c=req[\"crit\"], cmap=\"Reds\"); a[0].scatter([t[\"x\"] for t in teams], [t[\"y\"] for t in teams], marker=\"^\", s=120, c=\"blue\")\n", + "a[0].set_title(\"1. Requests (red) & team bases (blue)\")\n", + "req.groupby(\"day\").size().plot(ax=a[1], marker=\"o\"); a[1].set_title(\"2. Requests per day (surge = exercise)\")\n", + "a[2].hist(res[\"response_h\"], bins=30); a[2].set_title(\"3. Response time (h)\")\n", + "a[3].boxplot([L[c] for c in L.columns]); a[3].set_xticks(range(1, 7), L.columns); a[3].axhline(1.0, ls=\"--\", c=\"r\"); a[3].set_title(\"4. Daily load rate by team\")\n", + "res[\"fault\"].value_counts().plot.bar(ax=a[4]); a[4].set_title(\"5. Requests by fault type\")\n", + "res.boxplot(column=\"std_repair_h\", by=\"fault\", ax=a[5], rot=45); a[5].set_title(\"6. Std repair hours by fault\")\n", + "a[6].barh(corr[\"피처\"], corr[\"상관계수\"]); a[6].set_title(\"7. Correlation with response time\")\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 downtime (equip x fault)\")\n", + "plt.suptitle(\"MNT10 EDA - baseline dispatch (synthetic data)\"); plt.tight_layout(); plt.show()\n", + "print(\"\\nEDA 차트 8종 생성 (목표 8종 이상: 달성)\")\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 03 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + "[Baseline 결과] 요청 356건\n", + " 평균 도착(h) 3.37\n", + " 핵심장비 도착(h) 3.13\n", + " 핵심장비 90%(h) 7.54\n", + " 평균 부하율 0.78\n", + " 부하율 편차 0.365\n", + " 과부하 일수 58\n", + " 총 이동(h) 504.7\n", + "정비반별 평균 부하율: {'MT1': 0.39, 'MT2': 0.94, 'MT3': 0.39, 'MT4': 0.75, 'MT5': 1.36, 'MT6': 0.85}\n", + "-> 가까운 정비반만 고르면 요청이 몰리는 지역의 정비반(MT5)에 부하가 집중된다.\n", + "\n", + "[출동 지연과의 상관분석]\n", + " 피처 상관계수 p-value 유의\n", + " backlog_h 0.994 0.0e+00 True\n", + " team_load 0.454 1.5e-19 True\n", + "day_requests 0.246 2.6e-06 True\n", + " unpaved 0.216 3.9e-05 True\n", + " hour 0.158 2.8e-03 True\n", + " dist_km 0.087 1.0e-01 False\n", + "std_repair_h -0.080 1.3e-01 False\n", + " crit -0.027 6.2e-01 False\n", + " weather_bad -0.004 9.4e-01 False\n", + "유의미한 피처 5개 (목표 5개 이상: 달성)\n", + "-> 작전 중요도(crit)와 도착시간의 상관이 0에 가깝다: Baseline은 핵심 장비를 먼저 챙기지 못한다(미션 4의 동기).\n", + "\n", + "[파레토 A등급(누적 가동중단 80%)] mobility/drivetrain, fires/hydraulic, comms/comm_module, mobility/engine, mobility/hydraulic, comms/electric, mobility/cooling, fires/drivetrain, fires/engine\n", + "A등급은 전체 조합 16개 중 9개(56%)\n", + "\n", + "EDA 차트 8종 생성 (목표 8종 이상: 달성)\n", + "```\n", + "
\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 04. 장비 작전 중요도, 정비 소요 시간, 이동 경로를 종합한 최적 출동 우선순위 추천 엔진 고도화\n", + "**소요시간**: 약 3.0시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "작전 중요도와 이동 경로를 종합한 최적 출동 우선순위 추천 엔진을 고도화한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **장비 작전 중요도, 예측 정비 소요시간, 이동 경로(도착 예상시각), 정비반 부하를 종합한 출동 우선순위 추천 엔진을 만든다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **접수 시점에 알 수 있는 정보만으로 파생 피처 10개 이상을 만들고 정비 소요시간 예측 모델을 시계열 교차검증(MAPE)으로 평가하고 성능을 비교하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `src/features/mnt10_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": [ + "\"\"\"장비 작전 중요도·예측 정비 소요시간·이동 경로(도착 예상시각)·정비반 부하를 종합한 출동 우선순위 추천 엔진을 만든다. 파생 피처 10개 이상으로 정비 소요시간 예측 모델을 시계열 교차검증(MAPE)으로 평가하고, 거리 기준 Baseline 대비 핵심 장비 도착시간과 부하 균형 개선을 확인한다.\"\"\"\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", + "# [교육용가상자료] 야전 이동정비반·긴급 정비 요청 생성기 (실제 부대·위치·장비·인원 정보 없음)\n", + "# 60km x 60km 가상 지역에 이동정비반 6개와 30일간의 긴급 정비 요청을 만든다.\n", + "# 장비 구분(mobility/fires/comms/power)과 고장 유형은 교육용 추상 분류다.\n", + "# ---------------------------------------------------------------------------\n", + "AREA_KM, DUTY_H = 60, 10.0 # 가상 작전지역 크기, 정비반 1일 기준 가용시간\n", + "FAULT_H = {\"engine\": 6.0, \"drivetrain\": 5.0, \"hydraulic\": 3.0, \"electric\": 2.0, \"comm_module\": 1.5, \"cooling\": 2.5}\n", + "FIELD_P = {\"engine\": 0.3, \"drivetrain\": 0.35, \"hydraulic\": 0.75, \"electric\": 0.9, \"comm_module\": 0.95, \"cooling\": 0.8}\n", + "EQUIP_F = {\"mobility\": 1.0, \"fires\": 1.2, \"comms\": 0.7, \"power\": 0.8}\n", + "FAULT_OF = {\"mobility\": [0.3, 0.25, 0.2, 0.1, 0.0, 0.15], \"fires\": [0.2, 0.15, 0.35, 0.15, 0.0, 0.15],\n", + " \"comms\": [0.0, 0.0, 0.0, 0.4, 0.5, 0.1], \"power\": [0.35, 0.0, 0.0, 0.35, 0.0, 0.3]}\n", + "CRIT_P = {\"mobility\": [0.45, 0.35, 0.2], \"fires\": [0.2, 0.35, 0.45], \"comms\": [0.3, 0.4, 0.3], \"power\": [0.6, 0.3, 0.1]}\n", + "HUBS = [(15, 20), (45, 25), (30, 50)] # 가상 훈련장 3곳(요청이 몰리는 지점)\n", + "\n", + "\n", + "def make_teams(seed=0):\n", + " rng = np.random.default_rng(seed)\n", + " base = [(8, 10), (30, 6), (52, 12), (10, 44), (32, 38), (54, 50)]\n", + " skills = [[\"fires\", \"mobility\"], [\"comms\", \"mobility\"], [\"fires\", \"power\"],\n", + " [\"mobility\", \"power\"], [\"comms\", \"fires\"], [\"comms\", \"mobility\", \"power\"]]\n", + " return [{\"team\": f\"MT{i + 1}\", \"x\": float(x), \"y\": float(y), \"crew\": int(rng.integers(3, 6)), \"skills\": s}\n", + " for i, ((x, y), s) in enumerate(zip(base, skills))]\n", + "\n", + "\n", + "def make_requests(n_days=30, per_day=10, seed=1, surge_days=(8, 9, 10, 11, 22, 23, 24)):\n", + " \"\"\"하루 평균 per_day건, 훈련 주간(surge_days)에는 1.6배의 긴급 정비 요청을 만든다.\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " rows = []\n", + " for d in range(n_days):\n", + " n = rng.poisson(per_day * (1.6 if d in surge_days else 1.0))\n", + " weather = rng.choice([\"clear\", \"rain\", \"snow\"], p=[0.65, 0.25, 0.1])\n", + " for _ in range(n):\n", + " if rng.random() < 0.7:\n", + " hx, hy = HUBS[rng.integers(0, 3)]\n", + " x, y = np.clip(rng.normal(hx, 6), 0, AREA_KM), np.clip(rng.normal(hy, 6), 0, AREA_KM)\n", + " else:\n", + " x, y = rng.uniform(0, AREA_KM, 2)\n", + " eq = str(rng.choice(list(EQUIP_F), p=[0.45, 0.25, 0.2, 0.1]))\n", + " fault = str(rng.choice(list(FAULT_H), p=FAULT_OF[eq]))\n", + " parts = bool(rng.random() < 0.75)\n", + " bad = weather != \"clear\"\n", + " std_h = FAULT_H[fault] * EQUIP_F[eq] * (1.35 if not parts else 1.0) * (1.15 if bad else 1.0) * rng.lognormal(0, 0.12)\n", + " rows.append({\"t_req\": d * 24 + float(np.clip(rng.normal(13, 4), 5, 23)), \"day\": d,\n", + " \"x\": float(x), \"y\": float(y), \"equip\": eq, \"fault\": fault,\n", + " \"crit\": int(rng.choice([1, 2, 3], p=CRIT_P[eq])), \"parts_ready\": parts,\n", + " \"weather\": str(weather), \"road\": \"unpaved\" if y > 30 and rng.random() < 0.7 else \"paved\",\n", + " \"field_ok\": bool(rng.random() < FIELD_P[fault] * (1.0 if parts else 0.5)),\n", + " \"std_repair_h\": round(float(std_h), 2), \"road_delay\": float(rng.lognormal(0, 0.15))})\n", + " df = pd.DataFrame(rows).sort_values(\"t_req\").reset_index(drop=True)\n", + " df.insert(0, \"req_id\", [f\"R{i:04d}\" for i in range(len(df))])\n", + " return df\n", + "# ---------------------------------------------------------------------------\n", + "\n", + "# ---- 출동 배정·정비 모의 공통 함수 ----\n", + "W0 = {\"crit\": 1.0, \"wait\": 0.3, \"short\": 0.2, \"balance\": 0.5} # 우선순위·배정 기본 가중치(미션 9에서 재튜닝)\n", + "\n", + "\n", + "def travel_h(team, r, actual=False):\n", + " \"\"\"도로 굴곡 1.3배, 포장 35km/h·비포장 25km/h, 악천후 20% 감속. actual=True면 실제 도로 지연을 반영.\"\"\"\n", + " d = 1.3 * np.hypot(team[\"x\"] - r[\"x\"], team[\"y\"] - r[\"y\"])\n", + " v = (35.0 if r[\"road\"] == \"paved\" else 25.0) / (1.2 if r[\"weather\"] != \"clear\" else 1.0)\n", + " return d / v * (r[\"road_delay\"] if actual else 1.0)\n", + "\n", + "\n", + "def on_site_h(team, r):\n", + " \"\"\"현장 정비시간: 4인 기준 표준시간을 투입 인원으로 환산. 현장 수리 불가면 진단·후송 조치 1시간.\"\"\"\n", + " return r[\"std_repair_h\"] * 4 / team[\"crew\"] if r[\"field_ok\"] else 1.0\n", + "\n", + "\n", + "def priority(r, now, est_h, w):\n", + " return w[\"crit\"] * r[\"crit\"] + w[\"wait\"] * (now - r[\"t_req\"]) + w[\"short\"] / (1 + est_h)\n", + "\n", + "\n", + "def simulate(req, teams, policy=\"baseline\", w=None, cycle_h=1.0, est=None):\n", + " \"\"\"배정 주기(cycle_h)마다 대기 요청을 정렬해 이동정비반에 배정하고 출동·정비·복귀를 모의한다.\n", + " baseline: 접수 순서 + 거리 최근접 정비반 / priority: 우선순위 점수 순서 + 도착 예상시각·당일 부하 균형\"\"\"\n", + " w = w or W0\n", + " est = np.asarray(est if est is not None else req[\"std_repair_h\"].values, float)\n", + " n_days = int(req[\"day\"].max()) + 1\n", + " free = {t[\"team\"]: 0.0 for t in teams}\n", + " load = {t[\"team\"]: np.zeros(n_days + 5) for t in teams}\n", + " recs, out, pending, i, now = req.to_dict(\"records\"), [], [], 0, cycle_h\n", + " while i < len(recs) or pending:\n", + " while i < len(recs) and recs[i][\"t_req\"] <= now:\n", + " pending.append((i, recs[i])); i += 1\n", + " if policy == \"priority\":\n", + " pending.sort(key=lambda p: -priority(p[1], now, est[p[0]], w))\n", + " for k, r in pending:\n", + " cand = [t for t in teams if r[\"equip\"] in t[\"skills\"]]\n", + " day = min(int(now // 24), n_days + 4)\n", + " if policy == \"baseline\":\n", + " tm = min(cand, key=lambda t: np.hypot(t[\"x\"] - r[\"x\"], t[\"y\"] - r[\"y\"]))\n", + " else:\n", + " tm = min(cand, key=lambda t: max(free[t[\"team\"]], now) + travel_h(t, r)\n", + " + w[\"balance\"] * load[t[\"team\"]][day] / DUTY_H * 4)\n", + " backlog = max(0.0, free[tm[\"team\"]] - now)\n", + " go = max(free[tm[\"team\"]], now)\n", + " tr = travel_h(tm, r, actual=True)\n", + " work = on_site_h(tm, r)\n", + " free[tm[\"team\"]] = go + 2 * tr + work\n", + " load[tm[\"team\"]][min(int(go // 24), n_days + 4)] += 2 * tr + work\n", + " out.append({**r, \"idx\": k, \"team\": tm[\"team\"], \"dist_km\": float(np.hypot(tm[\"x\"] - r[\"x\"], tm[\"y\"] - r[\"y\"])),\n", + " \"backlog_h\": backlog, \"cycle_wait_h\": now - r[\"t_req\"], \"dispatch\": go, \"travel_h\": tr,\n", + " \"work_h\": work, \"response_h\": go + tr - r[\"t_req\"], \"downtime_h\": go + tr + work - r[\"t_req\"]})\n", + " pending, now = [], now + cycle_h\n", + " L = pd.DataFrame({k: v[:n_days] for k, v in load.items()}) / DUTY_H # 정비반별 일일 부하율\n", + " return pd.DataFrame(out).sort_values(\"idx\").reset_index(drop=True), L\n", + "\n", + "\n", + "def metrics(res, L):\n", + " c3 = res[res[\"crit\"] == 3]\n", + " return {\"평균 도착(h)\": round(res[\"response_h\"].mean(), 2), \"핵심장비 도착(h)\": round(c3[\"response_h\"].mean(), 2),\n", + " \"핵심장비 90%(h)\": round(c3[\"response_h\"].quantile(0.9), 2), \"평균 부하율\": round(float(L.values.mean()), 3),\n", + " \"부하율 편차\": round(float(L.mean().std()), 3), \"과부하 일수\": int((L > 1.0).values.sum()),\n", + " \"총 이동(h)\": round(float(res[\"travel_h\"].sum() * 2), 1)}\n", + "\n", + "\n", + "def make_features(req, teams):\n", + " \"\"\"접수 시점에 알 수 있는 정보만으로 파생 피처를 만든다(미래 정보 누설 방지).\"\"\"\n", + " X = pd.DataFrame(index=req.index)\n", + " X[\"hour\"] = req[\"t_req\"] % 24\n", + " X[\"is_night\"] = (X[\"hour\"] >= 19).astype(int)\n", + " X[\"dow\"] = req[\"day\"] % 7\n", + " X[\"weather_bad\"] = (req[\"weather\"] != \"clear\").astype(int)\n", + " X[\"unpaved\"] = (req[\"road\"] == \"unpaved\").astype(int)\n", + " X[\"no_parts\"] = (~req[\"parts_ready\"]).astype(int)\n", + " X[\"std_table_h\"] = req[\"fault\"].map(FAULT_H) * req[\"equip\"].map(EQUIP_F) # 정비 표준시간표 값\n", + " X[\"crit_x_noparts\"] = req[\"crit\"] * X[\"no_parts\"]\n", + " X[\"bad_x_table\"] = X[\"weather_bad\"] * X[\"std_table_h\"]\n", + " X[\"near_team_km\"] = [min(np.hypot(t[\"x\"] - x, t[\"y\"] - y) for t in teams if e in t[\"skills\"]) for x, y, e in zip(req[\"x\"], req[\"y\"], req[\"equip\"])]\n", + " X[\"hub_km\"] = [min(np.hypot(hx - x, hy - y) for hx, hy in HUBS) for x, y in zip(req[\"x\"], req[\"y\"])]\n", + " X[\"req_24h\"] = [((req[\"t_req\"] > t - 24) & (req[\"t_req\"] <= t)).sum() for t in req[\"t_req\"]]\n", + " X[\"fault_past_mean\"] = req.groupby(\"fault\")[\"std_repair_h\"].transform(lambda s: s.shift().expanding().mean()).fillna(req[\"std_repair_h\"].mean())\n", + " X[\"crit\"] = req[\"crit\"]\n", + " for e in EQUIP_F:\n", + " X[f\"eq_{e}\"] = (req[\"equip\"] == e).astype(int)\n", + " return X\n", + "\n", + "\n", + "teams = make_teams()\n", + "hist = make_requests(seed=11) # 과거 30일(학습용)\n", + "req = make_requests(seed=1) # 평가 30일\n", + "X, y = make_features(hist, teams), hist[\"std_repair_h\"]\n", + "print(f\"파생 피처 {X.shape[1]}개 생성 (목표 10개 이상: {'달성' if X.shape[1] >= 10 else '미달'})\")\n", + "\n", + "# 1) 시계열 교차검증: 과거로 학습 -> 이후 기간 예측 (5분할)\n", + "mape = lambda a, p: float(np.mean(np.abs(a - p) / a))\n", + "rows = []\n", + "for i, (tr, te) in enumerate(TimeSeriesSplit(5).split(X), 1):\n", + " base = hist.iloc[tr].groupby(\"fault\")[\"std_repair_h\"].mean()\n", + " p0 = hist.iloc[te][\"fault\"].map(base).fillna(y.iloc[tr].mean())\n", + " gb = GradientBoostingRegressor(n_estimators=200, max_depth=3, learning_rate=0.05, random_state=0).fit(X.iloc[tr], y.iloc[tr])\n", + " rows.append({\"분할\": i, \"학습\": len(tr), \"검증\": len(te), \"기준모델 MAPE\": round(mape(y.iloc[te], p0), 3), \"GBM MAPE\": round(mape(y.iloc[te], gb.predict(X.iloc[te])), 3)})\n", + "cv = pd.DataFrame(rows)\n", + "print(cv.to_string(index=False))\n", + "print(f\"평균 MAPE: 기준모델(고장유형 평균) {cv['기준모델 MAPE'].mean():.1%}, GBM {cv['GBM MAPE'].mean():.1%} (목표 25% 이하: {'달성' if cv['GBM MAPE'].mean() <= 0.25 else '미달'})\")\n", + "\n", + "model = GradientBoostingRegressor(n_estimators=200, max_depth=3, learning_rate=0.05, random_state=0).fit(X, y)\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", + "\n", + "# 2) 우선순위 추천 엔진 적용: 예측 정비시간 + 중요도 + 대기시간 + 도착 예상시각 + 당일 부하\n", + "est = model.predict(make_features(req, teams))\n", + "print(f\"평가 기간 정비시간 예측 MAPE {mape(req['std_repair_h'], est):.1%}\")\n", + "b_res, b_L = simulate(req, teams, \"baseline\")\n", + "p_res, p_L = simulate(req, teams, \"priority\", est=est)\n", + "cmp = pd.DataFrame([{\"방식\": \"Baseline(거리)\", **metrics(b_res, b_L)}, {\"방식\": \"추천 엔진\", **metrics(p_res, p_L)}])\n", + "print(\"\\n\" + cmp.to_string(index=False))\n", + "gain = 1 - cmp.loc[1, \"핵심장비 도착(h)\"] / cmp.loc[0, \"핵심장비 도착(h)\"]\n", + "print(f\"핵심 장비 평균 도착시간 {gain:.1%} 단축, 정비반 부하율 편차 {cmp.loc[0, '부하율 편차']} -> {cmp.loc[1, '부하율 편차']}\")\n", + "print(\"대가: 부하를 나누느라 먼 정비반이 출동해 총 이동시간이 늘었다(미션 9에서 가중치로 조정).\")\n", + "\n", + "# 3) 추천 예시: 요청이 몰린 시점의 대기 목록\n", + "t_busy = req.groupby(\"day\").size().idxmax() * 24 + 14\n", + "q = req[(req[\"t_req\"] <= t_busy) & (req[\"t_req\"] > t_busy - 3)].copy()\n", + "q[\"예측정비(h)\"] = est[q.index].round(1)\n", + "q[\"우선순위 점수\"] = [round(priority(r, t_busy, e, W0), 2) for r, e in zip(q.to_dict(\"records\"), est[q.index])]\n", + "print(f\"\\n[추천 예시] {int(t_busy // 24)}일차 14시 대기 요청\")\n", + "print(q.sort_values(\"우선순위 점수\", ascending=False)[[\"req_id\", \"equip\", \"fault\", \"crit\", \"예측정비(h)\", \"우선순위 점수\"]].to_string(index=False))\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 (repair time model)\")\n", + "ax[1].boxplot([b_res[b_res.crit == 3][\"response_h\"], p_res[p_res.crit == 3][\"response_h\"]]); ax[1].set_xticks([1, 2], [\"baseline\", \"priority engine\"])\n", + "ax[1].set_ylabel(\"response time (h)\"); ax[1].set_title(\"Critical equipment response time\")\n", + "plt.tight_layout(); plt.show()\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 04 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + "파생 피처 18개 생성 (목표 10개 이상: 달성)\n", + " 분할 학습 검증 기준모델 MAPE GBM MAPE\n", + " 1 60 56 0.203 0.142\n", + " 2 116 56 0.171 0.141\n", + " 3 172 56 0.187 0.107\n", + " 4 228 56 0.263 0.130\n", + " 5 284 56 0.203 0.104\n", + "평균 MAPE: 기준모델(고장유형 평균) 20.5%, GBM 12.5% (목표 25% 이하: 달성)\n", + "\n", + "피처 중요도 상위 5: std_table_h(0.78), fault_past_mean(0.07), crit_x_noparts(0.05), bad_x_table(0.04), no_parts(0.03)\n", + "중요도 0.01 미만 피처(차원 축소 후보): ['eq_fires', 'hour', 'hub_km', 'near_team_km', 'crit', 'req_24h', 'dow', 'weather_bad', 'unpaved', 'eq_comms', 'eq_mobility', 'is_night', 'eq_power']\n", + "평가 기간 정비시간 예측 MAPE 12.1%\n", + "\n", + " 방식 평균 도착(h) 핵심장비 도착(h) 핵심장비 90%(h) 평균 부하율 부하율 편차 과부하 일수 총 이동(h)\n", + "Baseline(거리) 3.37 3.13 7.54 0.780 0.365 58 504.7\n", + " 추천 엔진 2.09 1.92 3.25 0.903 0.143 68 744.9\n", + "핵심 장비 평균 도착시간 38.7% 단축, 정비반 부하율 편차 0.365 -> 0.143\n", + "대가: 부하를 나누느라 먼 정비반이 출동해 총 이동시간이 늘었다(미션 9에서 가중치로 조정).\n", + "\n", + "[추천 예시] 22일차 14시 대기 요청\n", + "req_id equip fault crit 예측정비(h) 우선순위 점수\n", + " R0255 fires engine 3 10.8 3.51\n", + " R0256 fires electric 3 2.4 3.47\n", + " R0257 mobility hydraulic 3 3.1 3.43\n", + " R0252 comms electric 2 1.4 2.98\n", + " R0254 mobility electric 2 2.0 2.72\n", + " R0261 comms electric 2 1.4 2.37\n", + " R0260 mobility hydraulic 2 3.3 2.34\n", + " R0262 power cooling 2 2.1 2.31\n", + " R0253 mobility drivetrain 1 5.2 1.80\n", + " R0258 fires cooling 1 3.2 1.40\n", + " R0259 comms cooling 1 1.8 1.37\n", + "```\n", + "
\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 05. 출동 지연 위험도 및 현장 수리 가능성 신뢰도 점수 산출 모듈 개발\n", + "**소요시간**: 약 3.5시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "출동 지연 위험도 및 현장 수리 가능성 신뢰도 점수 모듈을 개발한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **출동 지연 위험도와 현장 수리 가능성을 점수로 산출하는 모듈을 개발한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **접수~도착 시간을 앙상블 회귀(GBM+RandomForest)로 예측해 거리 기반 기준모델과 Hold-out 기간에서 MAE·RMSE·R2를 비교 수행하고 검증하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `src/models/mnt10_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": [ + "\"\"\"출동 지연 위험도(접수~도착 예상시간)를 앙상블 회귀로 예측하고, 현장 수리 가능성을 확률(신뢰도 점수)로 산출하는 모듈을 개발한다. Hold-out 기간에서 거리 기반 기준모델 대비 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, LogisticRegression\n", + "from sklearn.metrics import mean_absolute_error, mean_squared_error, r2_score, roc_auc_score, brier_score_loss\n", + "\n", + "# ---------------------------------------------------------------------------\n", + "# [교육용가상자료] 야전 이동정비반·긴급 정비 요청 생성기 (실제 부대·위치·장비·인원 정보 없음)\n", + "# 60km x 60km 가상 지역에 이동정비반 6개와 30일간의 긴급 정비 요청을 만든다.\n", + "# 장비 구분(mobility/fires/comms/power)과 고장 유형은 교육용 추상 분류다.\n", + "# ---------------------------------------------------------------------------\n", + "AREA_KM, DUTY_H = 60, 10.0 # 가상 작전지역 크기, 정비반 1일 기준 가용시간\n", + "FAULT_H = {\"engine\": 6.0, \"drivetrain\": 5.0, \"hydraulic\": 3.0, \"electric\": 2.0, \"comm_module\": 1.5, \"cooling\": 2.5}\n", + "FIELD_P = {\"engine\": 0.3, \"drivetrain\": 0.35, \"hydraulic\": 0.75, \"electric\": 0.9, \"comm_module\": 0.95, \"cooling\": 0.8}\n", + "EQUIP_F = {\"mobility\": 1.0, \"fires\": 1.2, \"comms\": 0.7, \"power\": 0.8}\n", + "FAULT_OF = {\"mobility\": [0.3, 0.25, 0.2, 0.1, 0.0, 0.15], \"fires\": [0.2, 0.15, 0.35, 0.15, 0.0, 0.15],\n", + " \"comms\": [0.0, 0.0, 0.0, 0.4, 0.5, 0.1], \"power\": [0.35, 0.0, 0.0, 0.35, 0.0, 0.3]}\n", + "CRIT_P = {\"mobility\": [0.45, 0.35, 0.2], \"fires\": [0.2, 0.35, 0.45], \"comms\": [0.3, 0.4, 0.3], \"power\": [0.6, 0.3, 0.1]}\n", + "HUBS = [(15, 20), (45, 25), (30, 50)] # 가상 훈련장 3곳(요청이 몰리는 지점)\n", + "\n", + "\n", + "def make_teams(seed=0):\n", + " rng = np.random.default_rng(seed)\n", + " base = [(8, 10), (30, 6), (52, 12), (10, 44), (32, 38), (54, 50)]\n", + " skills = [[\"fires\", \"mobility\"], [\"comms\", \"mobility\"], [\"fires\", \"power\"],\n", + " [\"mobility\", \"power\"], [\"comms\", \"fires\"], [\"comms\", \"mobility\", \"power\"]]\n", + " return [{\"team\": f\"MT{i + 1}\", \"x\": float(x), \"y\": float(y), \"crew\": int(rng.integers(3, 6)), \"skills\": s}\n", + " for i, ((x, y), s) in enumerate(zip(base, skills))]\n", + "\n", + "\n", + "def make_requests(n_days=30, per_day=10, seed=1, surge_days=(8, 9, 10, 11, 22, 23, 24)):\n", + " \"\"\"하루 평균 per_day건, 훈련 주간(surge_days)에는 1.6배의 긴급 정비 요청을 만든다.\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " rows = []\n", + " for d in range(n_days):\n", + " n = rng.poisson(per_day * (1.6 if d in surge_days else 1.0))\n", + " weather = rng.choice([\"clear\", \"rain\", \"snow\"], p=[0.65, 0.25, 0.1])\n", + " for _ in range(n):\n", + " if rng.random() < 0.7:\n", + " hx, hy = HUBS[rng.integers(0, 3)]\n", + " x, y = np.clip(rng.normal(hx, 6), 0, AREA_KM), np.clip(rng.normal(hy, 6), 0, AREA_KM)\n", + " else:\n", + " x, y = rng.uniform(0, AREA_KM, 2)\n", + " eq = str(rng.choice(list(EQUIP_F), p=[0.45, 0.25, 0.2, 0.1]))\n", + " fault = str(rng.choice(list(FAULT_H), p=FAULT_OF[eq]))\n", + " parts = bool(rng.random() < 0.75)\n", + " bad = weather != \"clear\"\n", + " std_h = FAULT_H[fault] * EQUIP_F[eq] * (1.35 if not parts else 1.0) * (1.15 if bad else 1.0) * rng.lognormal(0, 0.12)\n", + " rows.append({\"t_req\": d * 24 + float(np.clip(rng.normal(13, 4), 5, 23)), \"day\": d,\n", + " \"x\": float(x), \"y\": float(y), \"equip\": eq, \"fault\": fault,\n", + " \"crit\": int(rng.choice([1, 2, 3], p=CRIT_P[eq])), \"parts_ready\": parts,\n", + " \"weather\": str(weather), \"road\": \"unpaved\" if y > 30 and rng.random() < 0.7 else \"paved\",\n", + " \"field_ok\": bool(rng.random() < FIELD_P[fault] * (1.0 if parts else 0.5)),\n", + " \"std_repair_h\": round(float(std_h), 2), \"road_delay\": float(rng.lognormal(0, 0.15))})\n", + " df = pd.DataFrame(rows).sort_values(\"t_req\").reset_index(drop=True)\n", + " df.insert(0, \"req_id\", [f\"R{i:04d}\" for i in range(len(df))])\n", + " return df\n", + "# ---------------------------------------------------------------------------\n", + "\n", + "# ---- 출동 배정·정비 모의 공통 함수 ----\n", + "W0 = {\"crit\": 1.0, \"wait\": 0.3, \"short\": 0.2, \"balance\": 0.5} # 우선순위·배정 기본 가중치(미션 9에서 재튜닝)\n", + "\n", + "\n", + "def travel_h(team, r, actual=False):\n", + " \"\"\"도로 굴곡 1.3배, 포장 35km/h·비포장 25km/h, 악천후 20% 감속. actual=True면 실제 도로 지연을 반영.\"\"\"\n", + " d = 1.3 * np.hypot(team[\"x\"] - r[\"x\"], team[\"y\"] - r[\"y\"])\n", + " v = (35.0 if r[\"road\"] == \"paved\" else 25.0) / (1.2 if r[\"weather\"] != \"clear\" else 1.0)\n", + " return d / v * (r[\"road_delay\"] if actual else 1.0)\n", + "\n", + "\n", + "def on_site_h(team, r):\n", + " \"\"\"현장 정비시간: 4인 기준 표준시간을 투입 인원으로 환산. 현장 수리 불가면 진단·후송 조치 1시간.\"\"\"\n", + " return r[\"std_repair_h\"] * 4 / team[\"crew\"] if r[\"field_ok\"] else 1.0\n", + "\n", + "\n", + "def priority(r, now, est_h, w):\n", + " return w[\"crit\"] * r[\"crit\"] + w[\"wait\"] * (now - r[\"t_req\"]) + w[\"short\"] / (1 + est_h)\n", + "\n", + "\n", + "def simulate(req, teams, policy=\"baseline\", w=None, cycle_h=1.0, est=None):\n", + " \"\"\"배정 주기(cycle_h)마다 대기 요청을 정렬해 이동정비반에 배정하고 출동·정비·복귀를 모의한다.\n", + " baseline: 접수 순서 + 거리 최근접 정비반 / priority: 우선순위 점수 순서 + 도착 예상시각·당일 부하 균형\"\"\"\n", + " w = w or W0\n", + " est = np.asarray(est if est is not None else req[\"std_repair_h\"].values, float)\n", + " n_days = int(req[\"day\"].max()) + 1\n", + " free = {t[\"team\"]: 0.0 for t in teams}\n", + " load = {t[\"team\"]: np.zeros(n_days + 5) for t in teams}\n", + " recs, out, pending, i, now = req.to_dict(\"records\"), [], [], 0, cycle_h\n", + " while i < len(recs) or pending:\n", + " while i < len(recs) and recs[i][\"t_req\"] <= now:\n", + " pending.append((i, recs[i])); i += 1\n", + " if policy == \"priority\":\n", + " pending.sort(key=lambda p: -priority(p[1], now, est[p[0]], w))\n", + " for k, r in pending:\n", + " cand = [t for t in teams if r[\"equip\"] in t[\"skills\"]]\n", + " day = min(int(now // 24), n_days + 4)\n", + " if policy == \"baseline\":\n", + " tm = min(cand, key=lambda t: np.hypot(t[\"x\"] - r[\"x\"], t[\"y\"] - r[\"y\"]))\n", + " else:\n", + " tm = min(cand, key=lambda t: max(free[t[\"team\"]], now) + travel_h(t, r)\n", + " + w[\"balance\"] * load[t[\"team\"]][day] / DUTY_H * 4)\n", + " backlog = max(0.0, free[tm[\"team\"]] - now)\n", + " go = max(free[tm[\"team\"]], now)\n", + " tr = travel_h(tm, r, actual=True)\n", + " work = on_site_h(tm, r)\n", + " free[tm[\"team\"]] = go + 2 * tr + work\n", + " load[tm[\"team\"]][min(int(go // 24), n_days + 4)] += 2 * tr + work\n", + " out.append({**r, \"idx\": k, \"team\": tm[\"team\"], \"dist_km\": float(np.hypot(tm[\"x\"] - r[\"x\"], tm[\"y\"] - r[\"y\"])),\n", + " \"backlog_h\": backlog, \"cycle_wait_h\": now - r[\"t_req\"], \"dispatch\": go, \"travel_h\": tr,\n", + " \"work_h\": work, \"response_h\": go + tr - r[\"t_req\"], \"downtime_h\": go + tr + work - r[\"t_req\"]})\n", + " pending, now = [], now + cycle_h\n", + " L = pd.DataFrame({k: v[:n_days] for k, v in load.items()}) / DUTY_H # 정비반별 일일 부하율\n", + " return pd.DataFrame(out).sort_values(\"idx\").reset_index(drop=True), L\n", + "\n", + "\n", + "def metrics(res, L):\n", + " c3 = res[res[\"crit\"] == 3]\n", + " return {\"평균 도착(h)\": round(res[\"response_h\"].mean(), 2), \"핵심장비 도착(h)\": round(c3[\"response_h\"].mean(), 2),\n", + " \"핵심장비 90%(h)\": round(c3[\"response_h\"].quantile(0.9), 2), \"평균 부하율\": round(float(L.values.mean()), 3),\n", + " \"부하율 편차\": round(float(L.mean().std()), 3), \"과부하 일수\": int((L > 1.0).values.sum()),\n", + " \"총 이동(h)\": round(float(res[\"travel_h\"].sum() * 2), 1)}\n", + "\n", + "\n", + "def feat(res):\n", + " \"\"\"배정 시점에 알 수 있는 정보: 배정 정비반까지 거리, 정비반 잔여 작업(백로그), 배정 주기 대기, 기상·도로·중요도.\"\"\"\n", + " X = pd.DataFrame({\"dist_km\": res[\"dist_km\"], \"backlog_h\": res[\"backlog_h\"], \"cycle_wait_h\": res[\"cycle_wait_h\"],\n", + " \"weather_bad\": (res[\"weather\"] != \"clear\").astype(int), \"unpaved\": (res[\"road\"] == \"unpaved\").astype(int),\n", + " \"crit\": res[\"crit\"], \"hour\": res[\"t_req\"] % 24})\n", + " X[\"dist_x_road\"] = X[\"dist_km\"] * (1 + 0.4 * X[\"unpaved\"]) * (1 + 0.2 * X[\"weather_bad\"])\n", + " return X\n", + "\n", + "\n", + "def feat_field(df):\n", + " X = pd.get_dummies(df[[\"fault\", \"equip\"]]).astype(int)\n", + " X[\"parts_ready\"] = df[\"parts_ready\"].astype(int)\n", + " X[\"weather_bad\"] = (df[\"weather\"] != \"clear\").astype(int)\n", + " return X\n", + "\n", + "\n", + "teams = make_teams()\n", + "train = pd.concat([simulate(make_requests(seed=s), teams, \"priority\")[0] for s in (11, 21)], ignore_index=True)\n", + "test = simulate(make_requests(seed=1), teams, \"priority\")[0] # Hold-out: 학습에 쓰지 않은 30일\n", + "Xtr, Xte, ytr, yte = feat(train), feat(test), train[\"response_h\"], test[\"response_h\"]\n", + "\n", + "# 1) 출동 지연 예측: 기준모델(거리만) vs 앙상블(GBM + RandomForest 평균)\n", + "base = LinearRegression().fit(Xtr[[\"dist_km\"]], 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=200, min_samples_leaf=3, random_state=0).fit(Xtr, ytr)\n", + "preds = {\"기준모델(거리 선형)\": base.predict(Xte[[\"dist_km\"]]), \"GBM\": gbm.predict(Xte), \"RandomForest\": rf.predict(Xte)}\n", + "preds[\"앙상블(GBM+RF)\"] = (preds[\"GBM\"] + preds[\"RandomForest\"]) / 2\n", + "tab = pd.DataFrame([{\"모델\": k, \"MAE(h)\": mean_absolute_error(yte, p), \"RMSE(h)\": 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(h)\"] / b[\"MAE(h)\"], 1 - e[\"RMSE(h)\"] / b[\"RMSE(h)\"]\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) 출동 지연 위험도 등급(업무 임계값: 핵심 장비 2시간, 그 외 4시간 이내 도착)\n", + "limit = np.where(test[\"crit\"] == 3, 2.0, 4.0)\n", + "risk = np.where(preds[\"앙상블(GBM+RF)\"] > limit, \"높음\", np.where(preds[\"앙상블(GBM+RF)\"] > limit * 0.7, \"주의\", \"낮음\"))\n", + "actual_late = yte.values > limit\n", + "print(\"\\n[지연 위험도 등급별 실제 지연 비율]\")\n", + "print(pd.DataFrame({\"등급\": risk, \"실제지연\": actual_late}).groupby(\"등급\")[\"실제지연\"].agg([\"count\", \"mean\"]).rename(columns={\"count\": \"건수\", \"mean\": \"실제 지연 비율\"}).round(2).to_string())\n", + "\n", + "# 3) 현장 수리 가능성 신뢰도 점수(로지스틱 회귀 확률)\n", + "all_req = [make_requests(seed=s) for s in (11, 21)]\n", + "ftr = pd.concat(all_req, ignore_index=True); fte = make_requests(seed=1)\n", + "Ftr = feat_field(ftr); Fte = feat_field(fte).reindex(columns=Ftr.columns, fill_value=0)\n", + "clf = LogisticRegression(max_iter=1000).fit(Ftr, ftr[\"field_ok\"])\n", + "prob = clf.predict_proba(Fte)[:, 1]\n", + "print(f\"\\n현장 수리 가능성: AUC {roc_auc_score(fte['field_ok'], prob):.3f}, Brier {brier_score_loss(fte['field_ok'], prob):.3f}\")\n", + "bins = pd.cut(prob, [0, 0.4, 0.7, 1.0], labels=[\"후송 준비(<0.4)\", \"판단 보류(0.4~0.7)\", \"현장수리 유력(>=0.7)\"])\n", + "cal = pd.DataFrame({\"구간\": bins, \"예측\": prob, \"실제\": fte[\"field_ok\"]}).groupby(\"구간\", observed=True).agg(건수=(\"예측\", \"size\"), 평균점수=(\"예측\", \"mean\"), 실제비율=(\"실제\", \"mean\")).round(2)\n", + "print(cal.to_string())\n", + "print(\"점수가 높은 구간일수록 실제 현장 수리 비율이 높아 신뢰도 점수로 쓸 수 있다. 점수는 출동 준비(부속·후송 수단) 참고용이다.\")\n", + "\n", + "fig, ax = plt.subplots(1, 2, figsize=(11, 4))\n", + "ax[0].scatter(yte, preds[\"앙상블(GBM+RF)\"], s=10); ax[0].plot([0, yte.max()], [0, yte.max()], \"r--\")\n", + "ax[0].set_xlabel(\"actual response (h)\"); ax[0].set_ylabel(\"predicted (h)\"); ax[0].set_title(f\"Delay model hold-out (R2={e['R2']:.2f})\")\n", + "ax[1].plot(cal[\"평균점수\"], cal[\"실제비율\"], \"o-\"); ax[1].plot([0, 1], [0, 1], \"k--\"); ax[1].set_xlabel(\"mean confidence score\")\n", + "ax[1].set_ylabel(\"actual field-repair rate\"); ax[1].set_title(\"Field-repair confidence calibration\")\n", + "plt.tight_layout(); plt.show()\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 05 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + "학습 724건 / Hold-out 356건\n", + " 모델 MAE(h) RMSE(h) R2\n", + " 기준모델(거리 선형) 1.025 1.442 0.192\n", + " GBM 0.152 0.229 0.980\n", + "RandomForest 0.184 0.294 0.966\n", + " 앙상블(GBM+RF) 0.165 0.254 0.975\n", + "기준모델 대비 개선: MAE 83.9%, RMSE 82.4% (목표 20% 이상: 달성), R2 0.975 (목표 0.85 이상: 달성)\n", + "\n", + "[지연 위험도 등급별 실제 지연 비율]\n", + " 건수 실제 지연 비율\n", + "등급 \n", + "낮음 248 0.00\n", + "높음 61 0.93\n", + "주의 47 0.04\n", + "\n", + "현장 수리 가능성: AUC 0.762, Brier 0.185\n", + " 건수 평균점수 실제비율\n", + "구간 \n", + "후송 준비(<0.4) 143 0.30 0.34\n", + "판단 보류(0.4~0.7) 70 0.59 0.63\n", + "현장수리 유력(>=0.7) 143 0.80 0.83\n", + "점수가 높은 구간일수록 실제 현장 수리 비율이 높아 신뢰도 점수로 쓸 수 있다. 점수는 출동 준비(부속·후송 수단) 참고용이다.\n", + "```\n", + "
\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 06. 야전 정비 요청 현황 및 이동정비반 관제 대시보드 UI 구현\n", + "**소요시간**: 약 3.0시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "야전 정비 요청 현황 및 이동정비반 관제 대시보드 UI를 구현한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **정비 요청 현황과 이동정비반 상태를 보여 주는 관제 대시보드를 만들고, 예측마다 근거를 설명하는 설명가능성(XAI) 기능을 붙인다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **요청·정비반 지도, 정비반 부하율, 중요도별 대기 요청, 일별·중요도별 도착시간, 예측 워터폴 그림으로 관제 화면을 구성 수행하고 검증하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `src/explainability/mnt10_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개 인자를 자동 산출해 워터폴 그림과 자연어 설명으로 제공하는 설명가능성(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", + "# [교육용가상자료] 야전 이동정비반·긴급 정비 요청 생성기 (실제 부대·위치·장비·인원 정보 없음)\n", + "# 60km x 60km 가상 지역에 이동정비반 6개와 30일간의 긴급 정비 요청을 만든다.\n", + "# 장비 구분(mobility/fires/comms/power)과 고장 유형은 교육용 추상 분류다.\n", + "# ---------------------------------------------------------------------------\n", + "AREA_KM, DUTY_H = 60, 10.0 # 가상 작전지역 크기, 정비반 1일 기준 가용시간\n", + "FAULT_H = {\"engine\": 6.0, \"drivetrain\": 5.0, \"hydraulic\": 3.0, \"electric\": 2.0, \"comm_module\": 1.5, \"cooling\": 2.5}\n", + "FIELD_P = {\"engine\": 0.3, \"drivetrain\": 0.35, \"hydraulic\": 0.75, \"electric\": 0.9, \"comm_module\": 0.95, \"cooling\": 0.8}\n", + "EQUIP_F = {\"mobility\": 1.0, \"fires\": 1.2, \"comms\": 0.7, \"power\": 0.8}\n", + "FAULT_OF = {\"mobility\": [0.3, 0.25, 0.2, 0.1, 0.0, 0.15], \"fires\": [0.2, 0.15, 0.35, 0.15, 0.0, 0.15],\n", + " \"comms\": [0.0, 0.0, 0.0, 0.4, 0.5, 0.1], \"power\": [0.35, 0.0, 0.0, 0.35, 0.0, 0.3]}\n", + "CRIT_P = {\"mobility\": [0.45, 0.35, 0.2], \"fires\": [0.2, 0.35, 0.45], \"comms\": [0.3, 0.4, 0.3], \"power\": [0.6, 0.3, 0.1]}\n", + "HUBS = [(15, 20), (45, 25), (30, 50)] # 가상 훈련장 3곳(요청이 몰리는 지점)\n", + "\n", + "\n", + "def make_teams(seed=0):\n", + " rng = np.random.default_rng(seed)\n", + " base = [(8, 10), (30, 6), (52, 12), (10, 44), (32, 38), (54, 50)]\n", + " skills = [[\"fires\", \"mobility\"], [\"comms\", \"mobility\"], [\"fires\", \"power\"],\n", + " [\"mobility\", \"power\"], [\"comms\", \"fires\"], [\"comms\", \"mobility\", \"power\"]]\n", + " return [{\"team\": f\"MT{i + 1}\", \"x\": float(x), \"y\": float(y), \"crew\": int(rng.integers(3, 6)), \"skills\": s}\n", + " for i, ((x, y), s) in enumerate(zip(base, skills))]\n", + "\n", + "\n", + "def make_requests(n_days=30, per_day=10, seed=1, surge_days=(8, 9, 10, 11, 22, 23, 24)):\n", + " \"\"\"하루 평균 per_day건, 훈련 주간(surge_days)에는 1.6배의 긴급 정비 요청을 만든다.\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " rows = []\n", + " for d in range(n_days):\n", + " n = rng.poisson(per_day * (1.6 if d in surge_days else 1.0))\n", + " weather = rng.choice([\"clear\", \"rain\", \"snow\"], p=[0.65, 0.25, 0.1])\n", + " for _ in range(n):\n", + " if rng.random() < 0.7:\n", + " hx, hy = HUBS[rng.integers(0, 3)]\n", + " x, y = np.clip(rng.normal(hx, 6), 0, AREA_KM), np.clip(rng.normal(hy, 6), 0, AREA_KM)\n", + " else:\n", + " x, y = rng.uniform(0, AREA_KM, 2)\n", + " eq = str(rng.choice(list(EQUIP_F), p=[0.45, 0.25, 0.2, 0.1]))\n", + " fault = str(rng.choice(list(FAULT_H), p=FAULT_OF[eq]))\n", + " parts = bool(rng.random() < 0.75)\n", + " bad = weather != \"clear\"\n", + " std_h = FAULT_H[fault] * EQUIP_F[eq] * (1.35 if not parts else 1.0) * (1.15 if bad else 1.0) * rng.lognormal(0, 0.12)\n", + " rows.append({\"t_req\": d * 24 + float(np.clip(rng.normal(13, 4), 5, 23)), \"day\": d,\n", + " \"x\": float(x), \"y\": float(y), \"equip\": eq, \"fault\": fault,\n", + " \"crit\": int(rng.choice([1, 2, 3], p=CRIT_P[eq])), \"parts_ready\": parts,\n", + " \"weather\": str(weather), \"road\": \"unpaved\" if y > 30 and rng.random() < 0.7 else \"paved\",\n", + " \"field_ok\": bool(rng.random() < FIELD_P[fault] * (1.0 if parts else 0.5)),\n", + " \"std_repair_h\": round(float(std_h), 2), \"road_delay\": float(rng.lognormal(0, 0.15))})\n", + " df = pd.DataFrame(rows).sort_values(\"t_req\").reset_index(drop=True)\n", + " df.insert(0, \"req_id\", [f\"R{i:04d}\" for i in range(len(df))])\n", + " return df\n", + "# ---------------------------------------------------------------------------\n", + "\n", + "# ---- 출동 배정·정비 모의 공통 함수 ----\n", + "W0 = {\"crit\": 1.0, \"wait\": 0.3, \"short\": 0.2, \"balance\": 0.5} # 우선순위·배정 기본 가중치(미션 9에서 재튜닝)\n", + "\n", + "\n", + "def travel_h(team, r, actual=False):\n", + " \"\"\"도로 굴곡 1.3배, 포장 35km/h·비포장 25km/h, 악천후 20% 감속. actual=True면 실제 도로 지연을 반영.\"\"\"\n", + " d = 1.3 * np.hypot(team[\"x\"] - r[\"x\"], team[\"y\"] - r[\"y\"])\n", + " v = (35.0 if r[\"road\"] == \"paved\" else 25.0) / (1.2 if r[\"weather\"] != \"clear\" else 1.0)\n", + " return d / v * (r[\"road_delay\"] if actual else 1.0)\n", + "\n", + "\n", + "def on_site_h(team, r):\n", + " \"\"\"현장 정비시간: 4인 기준 표준시간을 투입 인원으로 환산. 현장 수리 불가면 진단·후송 조치 1시간.\"\"\"\n", + " return r[\"std_repair_h\"] * 4 / team[\"crew\"] if r[\"field_ok\"] else 1.0\n", + "\n", + "\n", + "def priority(r, now, est_h, w):\n", + " return w[\"crit\"] * r[\"crit\"] + w[\"wait\"] * (now - r[\"t_req\"]) + w[\"short\"] / (1 + est_h)\n", + "\n", + "\n", + "def simulate(req, teams, policy=\"baseline\", w=None, cycle_h=1.0, est=None):\n", + " \"\"\"배정 주기(cycle_h)마다 대기 요청을 정렬해 이동정비반에 배정하고 출동·정비·복귀를 모의한다.\n", + " baseline: 접수 순서 + 거리 최근접 정비반 / priority: 우선순위 점수 순서 + 도착 예상시각·당일 부하 균형\"\"\"\n", + " w = w or W0\n", + " est = np.asarray(est if est is not None else req[\"std_repair_h\"].values, float)\n", + " n_days = int(req[\"day\"].max()) + 1\n", + " free = {t[\"team\"]: 0.0 for t in teams}\n", + " load = {t[\"team\"]: np.zeros(n_days + 5) for t in teams}\n", + " recs, out, pending, i, now = req.to_dict(\"records\"), [], [], 0, cycle_h\n", + " while i < len(recs) or pending:\n", + " while i < len(recs) and recs[i][\"t_req\"] <= now:\n", + " pending.append((i, recs[i])); i += 1\n", + " if policy == \"priority\":\n", + " pending.sort(key=lambda p: -priority(p[1], now, est[p[0]], w))\n", + " for k, r in pending:\n", + " cand = [t for t in teams if r[\"equip\"] in t[\"skills\"]]\n", + " day = min(int(now // 24), n_days + 4)\n", + " if policy == \"baseline\":\n", + " tm = min(cand, key=lambda t: np.hypot(t[\"x\"] - r[\"x\"], t[\"y\"] - r[\"y\"]))\n", + " else:\n", + " tm = min(cand, key=lambda t: max(free[t[\"team\"]], now) + travel_h(t, r)\n", + " + w[\"balance\"] * load[t[\"team\"]][day] / DUTY_H * 4)\n", + " backlog = max(0.0, free[tm[\"team\"]] - now)\n", + " go = max(free[tm[\"team\"]], now)\n", + " tr = travel_h(tm, r, actual=True)\n", + " work = on_site_h(tm, r)\n", + " free[tm[\"team\"]] = go + 2 * tr + work\n", + " load[tm[\"team\"]][min(int(go // 24), n_days + 4)] += 2 * tr + work\n", + " out.append({**r, \"idx\": k, \"team\": tm[\"team\"], \"dist_km\": float(np.hypot(tm[\"x\"] - r[\"x\"], tm[\"y\"] - r[\"y\"])),\n", + " \"backlog_h\": backlog, \"cycle_wait_h\": now - r[\"t_req\"], \"dispatch\": go, \"travel_h\": tr,\n", + " \"work_h\": work, \"response_h\": go + tr - r[\"t_req\"], \"downtime_h\": go + tr + work - r[\"t_req\"]})\n", + " pending, now = [], now + cycle_h\n", + " L = pd.DataFrame({k: v[:n_days] for k, v in load.items()}) / DUTY_H # 정비반별 일일 부하율\n", + " return pd.DataFrame(out).sort_values(\"idx\").reset_index(drop=True), L\n", + "\n", + "\n", + "def metrics(res, L):\n", + " c3 = res[res[\"crit\"] == 3]\n", + " return {\"평균 도착(h)\": round(res[\"response_h\"].mean(), 2), \"핵심장비 도착(h)\": round(c3[\"response_h\"].mean(), 2),\n", + " \"핵심장비 90%(h)\": round(c3[\"response_h\"].quantile(0.9), 2), \"평균 부하율\": round(float(L.values.mean()), 3),\n", + " \"부하율 편차\": round(float(L.mean().std()), 3), \"과부하 일수\": int((L > 1.0).values.sum()),\n", + " \"총 이동(h)\": round(float(res[\"travel_h\"].sum() * 2), 1)}\n", + "\n", + "# ---- 접수 시점 파생 피처(미션 4와 동일) ----\n", + "def make_features(req, teams):\n", + " \"\"\"접수 시점에 알 수 있는 정보만으로 파생 피처를 만든다(미래 정보 누설 방지).\"\"\"\n", + " X = pd.DataFrame(index=req.index)\n", + " X[\"hour\"] = req[\"t_req\"] % 24\n", + " X[\"is_night\"] = (X[\"hour\"] >= 19).astype(int)\n", + " X[\"dow\"] = req[\"day\"] % 7\n", + " X[\"weather_bad\"] = (req[\"weather\"] != \"clear\").astype(int)\n", + " X[\"unpaved\"] = (req[\"road\"] == \"unpaved\").astype(int)\n", + " X[\"no_parts\"] = (~req[\"parts_ready\"]).astype(int)\n", + " X[\"std_table_h\"] = req[\"fault\"].map(FAULT_H) * req[\"equip\"].map(EQUIP_F) # 정비 표준시간표 값\n", + " X[\"crit_x_noparts\"] = req[\"crit\"] * X[\"no_parts\"]\n", + " X[\"bad_x_table\"] = X[\"weather_bad\"] * X[\"std_table_h\"]\n", + " X[\"near_team_km\"] = [min(np.hypot(t[\"x\"] - x, t[\"y\"] - y) for t in teams if e in t[\"skills\"]) for x, y, e in zip(req[\"x\"], req[\"y\"], req[\"equip\"])]\n", + " X[\"hub_km\"] = [min(np.hypot(hx - x, hy - y) for hx, hy in HUBS) for x, y in zip(req[\"x\"], req[\"y\"])]\n", + " X[\"req_24h\"] = [((req[\"t_req\"] > t - 24) & (req[\"t_req\"] <= t)).sum() for t in req[\"t_req\"]]\n", + " X[\"fault_past_mean\"] = req.groupby(\"fault\")[\"std_repair_h\"].transform(lambda s: s.shift().expanding().mean()).fillna(req[\"std_repair_h\"].mean())\n", + " X[\"crit\"] = req[\"crit\"]\n", + " for e in EQUIP_F:\n", + " X[f\"eq_{e}\"] = (req[\"equip\"] == e).astype(int)\n", + " return X\n", + "\n", + "\n", + "def fit_repair_model(hist, teams):\n", + " \"\"\"과거 요청으로 정비 소요시간(4인 기준) 예측 모델을 학습한다.\"\"\"\n", + " return GradientBoostingRegressor(n_estimators=200, max_depth=3, learning_rate=0.05, random_state=0).fit(make_features(hist, teams), hist[\"std_repair_h\"])\n", + "\n", + "KO = {\"std_table_h\": \"정비 표준시간표 값\", \"no_parts\": \"수리부속 미확보\", \"weather_bad\": \"악천후\", \"bad_x_table\": \"악천후 x 표준시간\",\n", + " \"crit_x_noparts\": \"핵심장비 x 부속 미확보\", \"fault_past_mean\": \"같은 고장 과거 평균\", \"unpaved\": \"비포장 도로\", \"crit\": \"작전 중요도\",\n", + " \"near_team_km\": \"최근접 정비반 거리\", \"hub_km\": \"훈련장과의 거리\", \"req_24h\": \"최근 24시간 요청 수\", \"hour\": \"접수 시각\",\n", + " \"is_night\": \"야간 접수\", \"dow\": \"요일\"}\n", + "\n", + "\n", + "def explain(model, X, bg, top=3):\n", + " \"\"\"가림(occlusion) 방식 국소 기여도: 피처 j를 배경 표본 값으로 바꿨을 때 예측이 평균적으로 얼마나 달라지는지.\n", + " 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", + " tops = [list(C.loc[i].abs().sort_values(ascending=False).index[:top]) for i in C.index]\n", + " return base, C, tops\n", + "\n", + "\n", + "def sentence(r, pred, C, top):\n", + " parts = [f\"{KO.get(f, f)}({'+' if C[f] > 0 else ''}{C[f]:.1f}h)\" for f in top]\n", + " return f\"{r['req_id']} {r['equip']}/{r['fault']} 예상 정비 {pred:.1f}h(평균 대비 {pred - AVG:+.1f}h), 영향 요인: \" + \", \".join(parts)\n", + "\n", + "\n", + "teams = make_teams()\n", + "hist, req = make_requests(seed=11), make_requests(seed=1)\n", + "model = fit_repair_model(hist, teams)\n", + "AVG = float(hist[\"std_repair_h\"].mean())\n", + "Xh, X = make_features(hist, teams), make_features(req, teams)\n", + "res, L = simulate(req, teams, \"priority\", est=model.predict(X))\n", + "\n", + "# 1) 설명가능성: 평가 기간 전체 요청에 대해 상위 3개 인자 자동 산출\n", + "t0 = time.perf_counter()\n", + "bg = Xh.sample(30, random_state=0).values\n", + "pred, C, tops = explain(model, X, bg)\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", + "check = np.abs(C.sum(axis=1) - (pred - model.predict(Xh).mean())).mean()\n", + "print(f\"기여도 합과 (예측 - 평균 예측)의 평균 차이 {check:.2f}h -> 근사 설명이므로 참고 지표로 쓴다\")\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) 관제 대시보드: 22일차 14시 기준\n", + "day, now = 22, 22 * 24 + 14\n", + "cur = res[(res[\"t_req\"] <= now) & (res[\"t_req\"] > now - 24)]\n", + "waiting = cur[cur[\"dispatch\"] > now]\n", + "print(f\"\\n[관제 현황 {day}일차 14시] 최근 24시간 요청 {len(cur)}건, 출동 대기 {len(waiting)}건, 정비반 {len(teams)}개\")\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(cur[\"x\"], cur[\"y\"], c=cur[\"crit\"].map(col), s=40)\n", + "for t in teams:\n", + " ax[0, 0].plot(t[\"x\"], t[\"y\"], \"ks\"); ax[0, 0].annotate(t[\"team\"], (t[\"x\"] + 1, t[\"y\"] + 1))\n", + "ax[0, 0].set_xlim(0, AREA_KM); ax[0, 0].set_ylim(0, AREA_KM); ax[0, 0].set_title(\"Requests (24h, red=critical) & teams\")\n", + "lr = L.iloc[day]\n", + "ax[0, 1].bar(lr.index, lr.values, color=[\"tab:red\" if v > 1 else \"tab:blue\" for v in lr.values])\n", + "ax[0, 1].axhline(1.0, ls=\"--\", color=\"k\"); ax[0, 1].set_title(f\"Team load rate, day {day}\")\n", + "q = waiting.groupby(\"crit\").size().reindex([1, 2, 3], fill_value=0)\n", + "ax[0, 2].bar([\"normal\", \"important\", \"critical\"], q.values, color=[col[k] for k in q.index]); ax[0, 2].set_title(\"Waiting requests by criticality\")\n", + "dd = res.groupby(\"day\")[\"response_h\"].mean()\n", + "ax[1, 0].plot(dd.index, dd.values, marker=\"o\"); ax[1, 0].set_title(\"Daily mean response time (h)\"); ax[1, 0].set_xlabel(\"day\")\n", + "ax[1, 1].boxplot([res.loc[res[\"crit\"] == k, \"response_h\"] for k in (1, 2, 3)]); ax[1, 1].set_xticks([1, 2, 3], [\"normal\", \"important\", \"critical\"])\n", + "ax[1, 1].set_title(\"Response time by criticality\")\n", + "i = int(C.index[req[\"crit\"].eq(3)][int(np.argmax(pred[req[\"crit\"].eq(3).values]))])\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:red\" if v > 0 else \"tab:green\" for v in ci.values])\n", + "ax[1, 2].set_yticks(range(len(ci)), ci.index); ax[1, 2].set_title(f\"Waterfall: {req.loc[i, 'req_id']} repair time\"); ax[1, 2].set_xlabel(\"hours\")\n", + "plt.tight_layout(); plt.show()\n", + "print(\"대시보드 구성요소 6종: 요청·정비반 지도, 정비반 부하율, 중요도별 대기, 일별 도착시간, 중요도별 도착시간, 예측 워터폴\")\n", + "\n", + "# 3) 자연어 설명 예시(정비대대 관제 화면 하단)\n", + "print(\"\\n[예측 설명 예시]\")\n", + "for k in list(waiting.sort_values(\"crit\", ascending=False)[\"idx\"][:4]) + [i]:\n", + " print(\" - \" + sentence(req.loc[k], pred[k], C.loc[k], tops[k]))\n", + "over = lr[lr > 1.0]\n", + "act = \"전 정비반 과부하 -> 상급 정비부대 지원 요청·비핵심 요청 연기 검토\" if len(over) == len(teams) else \"여유 정비반 지원 검토\"\n", + "print(f\"\\n경보: {day}일차(훈련 주간) 부하율 1.0 초과 정비반 {list(over.index)} -> {act}(정비대대장 판단)\" if len(over) else \"\\n경보: 과부하 정비반 없음\")\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 06 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + "[교육용가상자료] 요청 356건 설명 산출 1.7초, 상위 3개 인자 자동 산출률 100% (목표 100%: 달성)\n", + "기여도 합과 (예측 - 평균 예측)의 평균 차이 0.31h -> 근사 설명이므로 참고 지표로 쓴다\n", + "상위 3개 인자에 자주 등장한 요인: 정비 표준시간표 값 329건, 핵심장비 x 부속 미확보 227건, 같은 고장 과거 평균 225건, 악천후 x 표준시간 189건, 수리부속 미확보 57건\n", + "\n", + "[관제 현황 22일차 14시] 최근 24시간 요청 22건, 출동 대기 6건, 정비반 6개\n", + "대시보드 구성요소 6종: 요청·정비반 지도, 정비반 부하율, 중요도별 대기, 일별 도착시간, 중요도별 도착시간, 예측 워터폴\n", + "\n", + "[예측 설명 예시]\n", + " - R0256 fires/electric 예상 정비 2.4h(평균 대비 -1.7h), 영향 요인: 정비 표준시간표 값(-1.0h), 같은 고장 과거 평균(-0.3h), 악천후 x 표준시간(-0.2h)\n", + " - R0260 mobility/hydraulic 예상 정비 3.3h(평균 대비 -0.8h), 영향 요인: 정비 표준시간표 값(-0.3h), 악천후 x 표준시간(-0.2h), 핵심장비 x 부속 미확보(-0.2h)\n", + " - R0262 power/cooling 예상 정비 2.1h(평균 대비 -2.0h), 영향 요인: 정비 표준시간표 값(-1.3h), 악천후 x 표준시간(-0.2h), 같은 고장 과거 평균(-0.2h)\n", + " - R0261 comms/electric 예상 정비 1.4h(평균 대비 -2.7h), 영향 요인: 정비 표준시간표 값(-1.9h), 같은 고장 과거 평균(-0.3h), 악천후 x 표준시간(-0.1h)\n", + " - R0255 fires/engine 예상 정비 10.8h(평균 대비 +6.7h), 영향 요인: 정비 표준시간표 값(+4.9h), 같은 고장 과거 평균(+1.1h), 핵심장비 x 부속 미확보(+1.0h)\n", + "\n", + "경보: 22일차(훈련 주간) 부하율 1.0 초과 정비반 ['MT1', 'MT2', 'MT3', 'MT4', 'MT5', 'MT6'] -> 전 정비반 과부하 -> 상급 정비부대 지원 요청·비핵심 요청 연기 검토(정비대대장 판단)\n", + "```\n", + "
\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 07. 긴급 출동 순서 변경 및 정비반 재배치를 위한 정비대대장 HITL 승인 체계 설계\n", + "**소요시간**: 약 3.5시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "출동 순서 변경을 위한 정비대대장 HITL 승인 체계를 설계한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **긴급 출동 순서 변경과 정비반 재배치를 정수계획(할당 문제)으로 제안하고, 정비대대장 승인을 거쳐야만 적용되는 HITL 승인 체계를 시뮬레이터로 설계한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **배정 주기마다 기본 계획(접수 순서·최근접)과 할당 최적화 제안을 비교해, 다르고 기대 이득이 있으면 승인 대기열에 올린다 과정을 진행하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `src/optimization/mnt10_simulator.py` 형태의 결과물을 완성한다.\n", + "\n", + "### 🔍 평가 및 검증 기준 (PASS 조건)\n", + "#### 미션 판정\n", + "\n", + "**평가 구분:** 필수\n", + "\n", + "**핵심 평가영역:** 현실적 군수 제약조건 반영도 및 시나리오별 대안 제시 적절성\n", + "\n", + "#### 미션 PASS 조건\n", + "\n", + "- 정량 기준: 최적화 적용 시 비용 15% 절감 또는 재고 회전율 20% 향상\n", + "\n", + "\n", + "- 진행 기준: 선형/정수 계획법(LP/MIP) 최적화 해 도출 및 시뮬레이션 완료\n", + "\n", + "\n", + "- 검증 방법: 과거 실적 데이터 대비 최적화 결과 비교 백테스팅\n", + "\n", + "\n", + "#### 채점자 판정 메모\n", + "\n", + "> 단순 코드 실행 여부뿐만 아니라 출력된 수치와 근거 자료가 군 실무 요구사항에 부합하는지 면밀히 확인한다.\n", + "\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "\"\"\"긴급 출동 순서 변경과 정비반 재배치를 정수계획(할당 문제)으로 제안하되, 기본 계획(접수 순서·최근접 정비반)과 다르면 정비대대장 승인 대기열에 올려 승인·반려·무응답(기본 계획 유지)을 사유와 함께 해시 체인 감사 로그에 남기는 HITL 시뮬레이터를 구현한다. 기본 계획 대비 가동중단 비용 절감률과 승인 지연의 영향을 비교한다.\"\"\"\n", + "import hashlib\n", + "import json\n", + "import numpy as np\n", + "import pandas as pd\n", + "import matplotlib.pyplot as plt\n", + "from scipy.optimize import linear_sum_assignment\n", + "\n", + "# ---------------------------------------------------------------------------\n", + "# [교육용가상자료] 야전 이동정비반·긴급 정비 요청 생성기 (실제 부대·위치·장비·인원 정보 없음)\n", + "# 60km x 60km 가상 지역에 이동정비반 6개와 30일간의 긴급 정비 요청을 만든다.\n", + "# 장비 구분(mobility/fires/comms/power)과 고장 유형은 교육용 추상 분류다.\n", + "# ---------------------------------------------------------------------------\n", + "AREA_KM, DUTY_H = 60, 10.0 # 가상 작전지역 크기, 정비반 1일 기준 가용시간\n", + "FAULT_H = {\"engine\": 6.0, \"drivetrain\": 5.0, \"hydraulic\": 3.0, \"electric\": 2.0, \"comm_module\": 1.5, \"cooling\": 2.5}\n", + "FIELD_P = {\"engine\": 0.3, \"drivetrain\": 0.35, \"hydraulic\": 0.75, \"electric\": 0.9, \"comm_module\": 0.95, \"cooling\": 0.8}\n", + "EQUIP_F = {\"mobility\": 1.0, \"fires\": 1.2, \"comms\": 0.7, \"power\": 0.8}\n", + "FAULT_OF = {\"mobility\": [0.3, 0.25, 0.2, 0.1, 0.0, 0.15], \"fires\": [0.2, 0.15, 0.35, 0.15, 0.0, 0.15],\n", + " \"comms\": [0.0, 0.0, 0.0, 0.4, 0.5, 0.1], \"power\": [0.35, 0.0, 0.0, 0.35, 0.0, 0.3]}\n", + "CRIT_P = {\"mobility\": [0.45, 0.35, 0.2], \"fires\": [0.2, 0.35, 0.45], \"comms\": [0.3, 0.4, 0.3], \"power\": [0.6, 0.3, 0.1]}\n", + "HUBS = [(15, 20), (45, 25), (30, 50)] # 가상 훈련장 3곳(요청이 몰리는 지점)\n", + "\n", + "\n", + "def make_teams(seed=0):\n", + " rng = np.random.default_rng(seed)\n", + " base = [(8, 10), (30, 6), (52, 12), (10, 44), (32, 38), (54, 50)]\n", + " skills = [[\"fires\", \"mobility\"], [\"comms\", \"mobility\"], [\"fires\", \"power\"],\n", + " [\"mobility\", \"power\"], [\"comms\", \"fires\"], [\"comms\", \"mobility\", \"power\"]]\n", + " return [{\"team\": f\"MT{i + 1}\", \"x\": float(x), \"y\": float(y), \"crew\": int(rng.integers(3, 6)), \"skills\": s}\n", + " for i, ((x, y), s) in enumerate(zip(base, skills))]\n", + "\n", + "\n", + "def make_requests(n_days=30, per_day=10, seed=1, surge_days=(8, 9, 10, 11, 22, 23, 24)):\n", + " \"\"\"하루 평균 per_day건, 훈련 주간(surge_days)에는 1.6배의 긴급 정비 요청을 만든다.\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " rows = []\n", + " for d in range(n_days):\n", + " n = rng.poisson(per_day * (1.6 if d in surge_days else 1.0))\n", + " weather = rng.choice([\"clear\", \"rain\", \"snow\"], p=[0.65, 0.25, 0.1])\n", + " for _ in range(n):\n", + " if rng.random() < 0.7:\n", + " hx, hy = HUBS[rng.integers(0, 3)]\n", + " x, y = np.clip(rng.normal(hx, 6), 0, AREA_KM), np.clip(rng.normal(hy, 6), 0, AREA_KM)\n", + " else:\n", + " x, y = rng.uniform(0, AREA_KM, 2)\n", + " eq = str(rng.choice(list(EQUIP_F), p=[0.45, 0.25, 0.2, 0.1]))\n", + " fault = str(rng.choice(list(FAULT_H), p=FAULT_OF[eq]))\n", + " parts = bool(rng.random() < 0.75)\n", + " bad = weather != \"clear\"\n", + " std_h = FAULT_H[fault] * EQUIP_F[eq] * (1.35 if not parts else 1.0) * (1.15 if bad else 1.0) * rng.lognormal(0, 0.12)\n", + " rows.append({\"t_req\": d * 24 + float(np.clip(rng.normal(13, 4), 5, 23)), \"day\": d,\n", + " \"x\": float(x), \"y\": float(y), \"equip\": eq, \"fault\": fault,\n", + " \"crit\": int(rng.choice([1, 2, 3], p=CRIT_P[eq])), \"parts_ready\": parts,\n", + " \"weather\": str(weather), \"road\": \"unpaved\" if y > 30 and rng.random() < 0.7 else \"paved\",\n", + " \"field_ok\": bool(rng.random() < FIELD_P[fault] * (1.0 if parts else 0.5)),\n", + " \"std_repair_h\": round(float(std_h), 2), \"road_delay\": float(rng.lognormal(0, 0.15))})\n", + " df = pd.DataFrame(rows).sort_values(\"t_req\").reset_index(drop=True)\n", + " df.insert(0, \"req_id\", [f\"R{i:04d}\" for i in range(len(df))])\n", + " return df\n", + "# ---------------------------------------------------------------------------\n", + "\n", + "# ---- 출동 배정·정비 모의 공통 함수 ----\n", + "W0 = {\"crit\": 1.0, \"wait\": 0.3, \"short\": 0.2, \"balance\": 0.5} # 우선순위·배정 기본 가중치(미션 9에서 재튜닝)\n", + "\n", + "\n", + "def travel_h(team, r, actual=False):\n", + " \"\"\"도로 굴곡 1.3배, 포장 35km/h·비포장 25km/h, 악천후 20% 감속. actual=True면 실제 도로 지연을 반영.\"\"\"\n", + " d = 1.3 * np.hypot(team[\"x\"] - r[\"x\"], team[\"y\"] - r[\"y\"])\n", + " v = (35.0 if r[\"road\"] == \"paved\" else 25.0) / (1.2 if r[\"weather\"] != \"clear\" else 1.0)\n", + " return d / v * (r[\"road_delay\"] if actual else 1.0)\n", + "\n", + "\n", + "def on_site_h(team, r):\n", + " \"\"\"현장 정비시간: 4인 기준 표준시간을 투입 인원으로 환산. 현장 수리 불가면 진단·후송 조치 1시간.\"\"\"\n", + " return r[\"std_repair_h\"] * 4 / team[\"crew\"] if r[\"field_ok\"] else 1.0\n", + "\n", + "\n", + "def priority(r, now, est_h, w):\n", + " return w[\"crit\"] * r[\"crit\"] + w[\"wait\"] * (now - r[\"t_req\"]) + w[\"short\"] / (1 + est_h)\n", + "\n", + "\n", + "def simulate(req, teams, policy=\"baseline\", w=None, cycle_h=1.0, est=None):\n", + " \"\"\"배정 주기(cycle_h)마다 대기 요청을 정렬해 이동정비반에 배정하고 출동·정비·복귀를 모의한다.\n", + " baseline: 접수 순서 + 거리 최근접 정비반 / priority: 우선순위 점수 순서 + 도착 예상시각·당일 부하 균형\"\"\"\n", + " w = w or W0\n", + " est = np.asarray(est if est is not None else req[\"std_repair_h\"].values, float)\n", + " n_days = int(req[\"day\"].max()) + 1\n", + " free = {t[\"team\"]: 0.0 for t in teams}\n", + " load = {t[\"team\"]: np.zeros(n_days + 5) for t in teams}\n", + " recs, out, pending, i, now = req.to_dict(\"records\"), [], [], 0, cycle_h\n", + " while i < len(recs) or pending:\n", + " while i < len(recs) and recs[i][\"t_req\"] <= now:\n", + " pending.append((i, recs[i])); i += 1\n", + " if policy == \"priority\":\n", + " pending.sort(key=lambda p: -priority(p[1], now, est[p[0]], w))\n", + " for k, r in pending:\n", + " cand = [t for t in teams if r[\"equip\"] in t[\"skills\"]]\n", + " day = min(int(now // 24), n_days + 4)\n", + " if policy == \"baseline\":\n", + " tm = min(cand, key=lambda t: np.hypot(t[\"x\"] - r[\"x\"], t[\"y\"] - r[\"y\"]))\n", + " else:\n", + " tm = min(cand, key=lambda t: max(free[t[\"team\"]], now) + travel_h(t, r)\n", + " + w[\"balance\"] * load[t[\"team\"]][day] / DUTY_H * 4)\n", + " backlog = max(0.0, free[tm[\"team\"]] - now)\n", + " go = max(free[tm[\"team\"]], now)\n", + " tr = travel_h(tm, r, actual=True)\n", + " work = on_site_h(tm, r)\n", + " free[tm[\"team\"]] = go + 2 * tr + work\n", + " load[tm[\"team\"]][min(int(go // 24), n_days + 4)] += 2 * tr + work\n", + " out.append({**r, \"idx\": k, \"team\": tm[\"team\"], \"dist_km\": float(np.hypot(tm[\"x\"] - r[\"x\"], tm[\"y\"] - r[\"y\"])),\n", + " \"backlog_h\": backlog, \"cycle_wait_h\": now - r[\"t_req\"], \"dispatch\": go, \"travel_h\": tr,\n", + " \"work_h\": work, \"response_h\": go + tr - r[\"t_req\"], \"downtime_h\": go + tr + work - r[\"t_req\"]})\n", + " pending, now = [], now + cycle_h\n", + " L = pd.DataFrame({k: v[:n_days] for k, v in load.items()}) / DUTY_H # 정비반별 일일 부하율\n", + " return pd.DataFrame(out).sort_values(\"idx\").reset_index(drop=True), L\n", + "\n", + "\n", + "def metrics(res, L):\n", + " c3 = res[res[\"crit\"] == 3]\n", + " return {\"평균 도착(h)\": round(res[\"response_h\"].mean(), 2), \"핵심장비 도착(h)\": round(c3[\"response_h\"].mean(), 2),\n", + " \"핵심장비 90%(h)\": round(c3[\"response_h\"].quantile(0.9), 2), \"평균 부하율\": round(float(L.values.mean()), 3),\n", + " \"부하율 편차\": round(float(L.mean().std()), 3), \"과부하 일수\": int((L > 1.0).values.sum()),\n", + " \"총 이동(h)\": round(float(res[\"travel_h\"].sum() * 2), 1)}\n", + "\n", + "CW = {1: 1.0, 2: 3.0, 3: 6.0} # [교육용가상자료] 중요도별 가동중단 1시간 비용 지수\n", + "SLOTS = 3 # 정비반당 한 배정 주기에 받을 수 있는 최대 건수\n", + "APPROVE_DELAY = 0.25 # 승인 절차로 늦어지는 출동 시각(h)\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 default_plan(pending, teams, free, now):\n", + " \"\"\"기본 계획: 접수 순서대로 최근접(정비 가능) 정비반.\"\"\"\n", + " plan, f = [], dict(free)\n", + " for k, r in pending:\n", + " tm = min([t for t in teams if r[\"equip\"] in t[\"skills\"]], key=lambda t: np.hypot(t[\"x\"] - r[\"x\"], t[\"y\"] - r[\"y\"]))\n", + " go = max(f[tm[\"team\"]], now); plan.append((k, tm)); f[tm[\"team\"]] = go + 2 * travel_h(tm, r) + on_site_h(tm, r)\n", + " return plan\n", + "\n", + "\n", + "def mip_plan(pending, teams, free, now):\n", + " \"\"\"할당 문제(정수계획): 요청 x (정비반, 순번) 비용 = 중요도 가중 x 예상 도착시각. 정비 불가 조합은 큰 비용.\"\"\"\n", + " avg = np.mean([on_site_h(teams[0], r) for _, r in pending]) + 1.0\n", + " cols = [(t, s) for t in teams for s in range(SLOTS)]\n", + " cost = np.full((len(pending), len(cols)), 1e6)\n", + " for i, (k, r) in enumerate(pending):\n", + " for j, (t, s) in enumerate(cols):\n", + " if r[\"equip\"] in t[\"skills\"]:\n", + " cost[i, j] = CW[r[\"crit\"]] * (max(free[t[\"team\"]], now) + s * avg + travel_h(t, r) - now)\n", + " ri, cj = linear_sum_assignment(cost)\n", + " order = sorted(zip(ri, cj), key=lambda p: (cols[p[1]][1], -pending[p[0]][1][\"crit\"]))\n", + " return [(pending[i][0], cols[j][0]) for i, j in order]\n", + "\n", + "\n", + "def expected_cost(plan, recs, free, now, delay=0.0):\n", + " f, c = dict(free), 0.0\n", + " for k, tm in plan:\n", + " r = recs[k]; go = max(f[tm[\"team\"]], now + delay)\n", + " c += CW[r[\"crit\"]] * (go + travel_h(tm, r) + on_site_h(tm, r) - r[\"t_req\"]); f[tm[\"team\"]] = go + 2 * travel_h(tm, r) + on_site_h(tm, r)\n", + " return c\n", + "\n", + "\n", + "def commander(prop, rng):\n", + " \"\"\"[모의 정비대대장] 기대 이득이 작거나 담당 구역을 크게 벗어나는 재배치는 반려, 10%는 30분 내 무응답.\"\"\"\n", + " if rng.random() < 0.10:\n", + " return \"무응답\", \"30분 내 응답 없음 -> 기본 계획 유지\"\n", + " if prop[\"max_km\"] > 45:\n", + " return \"반려\", f\"정비반 구역 이탈 {prop['max_km']:.0f}km(복귀·보급 곤란)\"\n", + " if prop[\"gain\"] < 1.0:\n", + " return \"반려\", f\"기대 이득 {prop['gain']:.1f} 작음 -> 현 계획 유지\"\n", + " return \"승인\", f\"핵심장비 우선, 기대 이득 {prop['gain']:.1f}\"\n", + "\n", + "\n", + "def run(req, teams, mode, seed=0, audit=None):\n", + " \"\"\"mode: default(기본 계획) / hitl(제안 -> 승인 시 적용) / auto(모든 제안 적용: 비교용 상한, 실제 운용 금지)\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " recs = req.to_dict(\"records\"); free = {t[\"team\"]: 0.0 for t in teams}\n", + " out, i, now, props = [], 0, 1.0, []\n", + " while i < len(recs):\n", + " pending = []\n", + " while i < len(recs) and recs[i][\"t_req\"] <= now:\n", + " pending.append((i, recs[i])); i += 1\n", + " if pending:\n", + " base = default_plan(pending, teams, free, now)\n", + " plan, delay = base, 0.0\n", + " if mode != \"default\":\n", + " prop = mip_plan(pending, teams, free, now)\n", + " if [(k, t[\"team\"]) for k, t in prop] != [(k, t[\"team\"]) for k, t in base]:\n", + " gain = expected_cost(base, recs, free, now) - expected_cost(prop, recs, free, now, APPROVE_DELAY)\n", + " max_km = max(np.hypot(t[\"x\"] - recs[k][\"x\"], t[\"y\"] - recs[k][\"y\"]) for k, t in prop)\n", + " info = {\"t\": round(now, 2), \"n\": len(pending), \"gain\": round(gain, 2), \"max_km\": max_km}\n", + " if gain <= 0: # 이득 없는 변경은 제안하지 않는다\n", + " pass\n", + " elif mode == \"auto\":\n", + " dec, why = \"승인\", \"비교용 전부 적용\"\n", + " else:\n", + " dec, why = commander(info, rng)\n", + " audit.add(event=\"PROPOSE\", t=info[\"t\"], n=info[\"n\"], gain=info[\"gain\"], status=\"PENDING\")\n", + " audit.add(event=\"DECIDE\", t=info[\"t\"], decision=dec, reason=why, by=\"정비대대장(모의)\")\n", + " if gain > 0:\n", + " props.append({**info, \"decision\": dec})\n", + " if gain > 0 and dec == \"승인\":\n", + " plan, delay = prop, APPROVE_DELAY\n", + " for k, tm in plan:\n", + " r = recs[k]; go = max(free[tm[\"team\"]], now + delay)\n", + " tr, work = travel_h(tm, r, actual=True), on_site_h(tm, r)\n", + " free[tm[\"team\"]] = go + 2 * tr + work\n", + " out.append({**r, \"team\": tm[\"team\"], \"response_h\": go + tr - r[\"t_req\"], \"downtime_h\": go + tr + work - r[\"t_req\"]})\n", + " now += 1.0\n", + " res = pd.DataFrame(out)\n", + " res[\"cost\"] = res[\"crit\"].map(CW) * res[\"downtime_h\"]\n", + " return res, pd.DataFrame(props)\n", + "\n", + "\n", + "teams = make_teams()\n", + "req = make_requests(seed=1)\n", + "audit = Audit()\n", + "R = {}\n", + "for mode in (\"default\", \"hitl\", \"auto\"):\n", + " R[mode] = run(req, teams, mode, seed=3, audit=audit)\n", + "rows = []\n", + "for mode, name in [(\"default\", \"기본 계획\"), (\"hitl\", \"제안 + 대대장 승인\"), (\"auto\", \"제안 전부 적용(비교용)\")]:\n", + " res = R[mode][0]\n", + " rows.append({\"방식\": name, \"가동중단 비용지수\": round(res[\"cost\"].sum()), \"핵심장비 도착(h)\": round(res.loc[res[\"crit\"] == 3, \"response_h\"].mean(), 2),\n", + " \"핵심장비 가동중단(h)\": round(res.loc[res[\"crit\"] == 3, \"downtime_h\"].mean(), 2), \"전체 도착(h)\": round(res[\"response_h\"].mean(), 2)})\n", + "T = pd.DataFrame(rows)\n", + "print(\"[교육용가상자료] 30일 모의 결과\")\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", + "\n", + "P = R[\"hitl\"][1]\n", + "print(f\"\\n변경 제안 {len(P)}건: \" + \", \".join(f\"{k} {v}건\" for k, v in P[\"decision\"].value_counts().items()))\n", + "rej = [e[\"reason\"] for e in audit.log if e.get(\"decision\") == \"반려\"]\n", + "print(\"반려 사유 예:\", \"; \".join([next(x for x in rej if \"구역\" in x), next(x for x in rej if \"이득\" in x)]))\n", + "n_dec = sum(e[\"event\"] == \"DECIDE\" for e in audit.log)\n", + "print(f\"제안 {len(P)}건 중 결정 기록 {n_dec}건(로깅률 {n_dec / max(len(P), 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", + "# 민감도: 승인 지연이 길어지면 이득이 줄어든다(전시 전환 등 급증 대비)\n", + "print(\"\\n[민감도] 승인 지연(h) -> 비용 절감률\")\n", + "for d in (0.0, 0.25, 0.5, 1.0):\n", + " APPROVE_DELAY = d\n", + " r_, _ = run(req, teams, \"hitl\", seed=3, audit=Audit())\n", + " print(f\" {d:>4}: {1 - r_['cost'].sum() / T.loc[0, '가동중단 비용지수']:.1%}\")\n", + "surge = make_requests(seed=1, per_day=16)\n", + "APPROVE_DELAY = 0.25\n", + "b, _ = run(surge, teams, \"default\"); h, _ = run(surge, teams, \"hitl\", seed=3, audit=Audit())\n", + "print(f\"요청 1.6배 급증 시나리오: 비용 절감률 {1 - h['cost'].sum() / b['cost'].sum():.1%}\")\n", + "\n", + "plt.figure(figsize=(7, 4))\n", + "for mode, lab in [(\"default\", \"default plan\"), (\"hitl\", \"proposal + approval\")]:\n", + " s = R[mode][0]\n", + " plt.hist(s.loc[s[\"crit\"] == 3, \"response_h\"], bins=20, alpha=0.6, label=lab)\n", + "plt.xlabel(\"critical equipment response time (h)\"); plt.ylabel(\"count\"); plt.legend(); plt.title(\"HITL simulator: critical response\")\n", + "plt.tight_layout(); plt.show()\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 07 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + "[교육용가상자료] 30일 모의 결과\n", + " 방식 가동중단 비용지수 핵심장비 도착(h) 핵심장비 가동중단(h) 전체 도착(h)\n", + " 기본 계획 6495 3.13 5.82 3.37\n", + " 제안 + 대대장 승인 5254 2.10 4.71 2.29\n", + "제안 전부 적용(비교용) 5018 1.92 4.53 2.08\n", + "HITL 적용 가동중단 비용 절감률 19.1% (목표 15% 이상: 달성)\n", + "\n", + "변경 제안 103건: 승인 71건, 반려 24건, 무응답 8건\n", + "반려 사유 예: 정비반 구역 이탈 50km(복귀·보급 곤란); 기대 이득 0.5 작음 -> 현 계획 유지\n", + "제안 103건 중 결정 기록 103건(로깅률 100%), 자동 승인 0건, 무응답 시 기본 계획 유지\n", + "감사 로그 무결성: 정상, 사후 변조 시 seq 5에서 탐지\n", + "\n", + "[민감도] 승인 지연(h) -> 비용 절감률\n", + " 0.0: 20.0%\n", + " 0.25: 19.1%\n", + " 0.5: 17.6%\n", + " 1.0: 13.4%\n", + "요청 1.6배 급증 시나리오: 비용 절감률 46.5%\n", + "```\n", + "
\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 08. 동시 20개소 긴급 정비 요청 발생 모의 출동 스케줄링 스트레스 테스트\n", + "**소요시간**: 약 3.0시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "동시 20개소 긴급 정비 요청 발생 모의 스트레스 테스트를 수행한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **동시 20개소 긴급 정비 요청이 한꺼번에 들어오는 상황에서 출동 스케줄링과 관제 화면이 제때 동작하는지 스트레스 시험한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **동시 20·40·60개소 요청, 정비반 1개 출동 불가, 손상 요청 데이터를 주입하고 할당 최적화로 배정 수행하고 검증하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `src/dashboard/mnt10_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": [ + "\"\"\"동시 20개소(최대 60개소) 긴급 정비 요청 발생, 정비반 1개 출동 불가, 손상 요청 데이터를 주입해 출동 스케줄링과 관제 대시보드를 스트레스 테스트한다. 스케줄링+화면 생성 시간, '핵심장비 4시간 내 도착 불가' 경보 정확도, 요청 유실, 손상 데이터의 수동 확인 분리와 필터 조회 응답을 검증한다.\"\"\"\n", + "import time\n", + "import numpy as np\n", + "import pandas as pd\n", + "import matplotlib\n", + "import matplotlib.pyplot as plt\n", + "from scipy.optimize import linear_sum_assignment\n", + "\n", + "# ---------------------------------------------------------------------------\n", + "# [교육용가상자료] 야전 이동정비반·긴급 정비 요청 생성기 (실제 부대·위치·장비·인원 정보 없음)\n", + "# 60km x 60km 가상 지역에 이동정비반 6개와 30일간의 긴급 정비 요청을 만든다.\n", + "# 장비 구분(mobility/fires/comms/power)과 고장 유형은 교육용 추상 분류다.\n", + "# ---------------------------------------------------------------------------\n", + "AREA_KM, DUTY_H = 60, 10.0 # 가상 작전지역 크기, 정비반 1일 기준 가용시간\n", + "FAULT_H = {\"engine\": 6.0, \"drivetrain\": 5.0, \"hydraulic\": 3.0, \"electric\": 2.0, \"comm_module\": 1.5, \"cooling\": 2.5}\n", + "FIELD_P = {\"engine\": 0.3, \"drivetrain\": 0.35, \"hydraulic\": 0.75, \"electric\": 0.9, \"comm_module\": 0.95, \"cooling\": 0.8}\n", + "EQUIP_F = {\"mobility\": 1.0, \"fires\": 1.2, \"comms\": 0.7, \"power\": 0.8}\n", + "FAULT_OF = {\"mobility\": [0.3, 0.25, 0.2, 0.1, 0.0, 0.15], \"fires\": [0.2, 0.15, 0.35, 0.15, 0.0, 0.15],\n", + " \"comms\": [0.0, 0.0, 0.0, 0.4, 0.5, 0.1], \"power\": [0.35, 0.0, 0.0, 0.35, 0.0, 0.3]}\n", + "CRIT_P = {\"mobility\": [0.45, 0.35, 0.2], \"fires\": [0.2, 0.35, 0.45], \"comms\": [0.3, 0.4, 0.3], \"power\": [0.6, 0.3, 0.1]}\n", + "HUBS = [(15, 20), (45, 25), (30, 50)] # 가상 훈련장 3곳(요청이 몰리는 지점)\n", + "\n", + "\n", + "def make_teams(seed=0):\n", + " rng = np.random.default_rng(seed)\n", + " base = [(8, 10), (30, 6), (52, 12), (10, 44), (32, 38), (54, 50)]\n", + " skills = [[\"fires\", \"mobility\"], [\"comms\", \"mobility\"], [\"fires\", \"power\"],\n", + " [\"mobility\", \"power\"], [\"comms\", \"fires\"], [\"comms\", \"mobility\", \"power\"]]\n", + " return [{\"team\": f\"MT{i + 1}\", \"x\": float(x), \"y\": float(y), \"crew\": int(rng.integers(3, 6)), \"skills\": s}\n", + " for i, ((x, y), s) in enumerate(zip(base, skills))]\n", + "\n", + "\n", + "def make_requests(n_days=30, per_day=10, seed=1, surge_days=(8, 9, 10, 11, 22, 23, 24)):\n", + " \"\"\"하루 평균 per_day건, 훈련 주간(surge_days)에는 1.6배의 긴급 정비 요청을 만든다.\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " rows = []\n", + " for d in range(n_days):\n", + " n = rng.poisson(per_day * (1.6 if d in surge_days else 1.0))\n", + " weather = rng.choice([\"clear\", \"rain\", \"snow\"], p=[0.65, 0.25, 0.1])\n", + " for _ in range(n):\n", + " if rng.random() < 0.7:\n", + " hx, hy = HUBS[rng.integers(0, 3)]\n", + " x, y = np.clip(rng.normal(hx, 6), 0, AREA_KM), np.clip(rng.normal(hy, 6), 0, AREA_KM)\n", + " else:\n", + " x, y = rng.uniform(0, AREA_KM, 2)\n", + " eq = str(rng.choice(list(EQUIP_F), p=[0.45, 0.25, 0.2, 0.1]))\n", + " fault = str(rng.choice(list(FAULT_H), p=FAULT_OF[eq]))\n", + " parts = bool(rng.random() < 0.75)\n", + " bad = weather != \"clear\"\n", + " std_h = FAULT_H[fault] * EQUIP_F[eq] * (1.35 if not parts else 1.0) * (1.15 if bad else 1.0) * rng.lognormal(0, 0.12)\n", + " rows.append({\"t_req\": d * 24 + float(np.clip(rng.normal(13, 4), 5, 23)), \"day\": d,\n", + " \"x\": float(x), \"y\": float(y), \"equip\": eq, \"fault\": fault,\n", + " \"crit\": int(rng.choice([1, 2, 3], p=CRIT_P[eq])), \"parts_ready\": parts,\n", + " \"weather\": str(weather), \"road\": \"unpaved\" if y > 30 and rng.random() < 0.7 else \"paved\",\n", + " \"field_ok\": bool(rng.random() < FIELD_P[fault] * (1.0 if parts else 0.5)),\n", + " \"std_repair_h\": round(float(std_h), 2), \"road_delay\": float(rng.lognormal(0, 0.15))})\n", + " df = pd.DataFrame(rows).sort_values(\"t_req\").reset_index(drop=True)\n", + " df.insert(0, \"req_id\", [f\"R{i:04d}\" for i in range(len(df))])\n", + " return df\n", + "# ---------------------------------------------------------------------------\n", + "\n", + "# ---- 출동 배정·정비 모의 공통 함수 ----\n", + "W0 = {\"crit\": 1.0, \"wait\": 0.3, \"short\": 0.2, \"balance\": 0.5} # 우선순위·배정 기본 가중치(미션 9에서 재튜닝)\n", + "\n", + "\n", + "def travel_h(team, r, actual=False):\n", + " \"\"\"도로 굴곡 1.3배, 포장 35km/h·비포장 25km/h, 악천후 20% 감속. actual=True면 실제 도로 지연을 반영.\"\"\"\n", + " d = 1.3 * np.hypot(team[\"x\"] - r[\"x\"], team[\"y\"] - r[\"y\"])\n", + " v = (35.0 if r[\"road\"] == \"paved\" else 25.0) / (1.2 if r[\"weather\"] != \"clear\" else 1.0)\n", + " return d / v * (r[\"road_delay\"] if actual else 1.0)\n", + "\n", + "\n", + "def on_site_h(team, r):\n", + " \"\"\"현장 정비시간: 4인 기준 표준시간을 투입 인원으로 환산. 현장 수리 불가면 진단·후송 조치 1시간.\"\"\"\n", + " return r[\"std_repair_h\"] * 4 / team[\"crew\"] if r[\"field_ok\"] else 1.0\n", + "\n", + "\n", + "def priority(r, now, est_h, w):\n", + " return w[\"crit\"] * r[\"crit\"] + w[\"wait\"] * (now - r[\"t_req\"]) + w[\"short\"] / (1 + est_h)\n", + "\n", + "\n", + "def simulate(req, teams, policy=\"baseline\", w=None, cycle_h=1.0, est=None):\n", + " \"\"\"배정 주기(cycle_h)마다 대기 요청을 정렬해 이동정비반에 배정하고 출동·정비·복귀를 모의한다.\n", + " baseline: 접수 순서 + 거리 최근접 정비반 / priority: 우선순위 점수 순서 + 도착 예상시각·당일 부하 균형\"\"\"\n", + " w = w or W0\n", + " est = np.asarray(est if est is not None else req[\"std_repair_h\"].values, float)\n", + " n_days = int(req[\"day\"].max()) + 1\n", + " free = {t[\"team\"]: 0.0 for t in teams}\n", + " load = {t[\"team\"]: np.zeros(n_days + 5) for t in teams}\n", + " recs, out, pending, i, now = req.to_dict(\"records\"), [], [], 0, cycle_h\n", + " while i < len(recs) or pending:\n", + " while i < len(recs) and recs[i][\"t_req\"] <= now:\n", + " pending.append((i, recs[i])); i += 1\n", + " if policy == \"priority\":\n", + " pending.sort(key=lambda p: -priority(p[1], now, est[p[0]], w))\n", + " for k, r in pending:\n", + " cand = [t for t in teams if r[\"equip\"] in t[\"skills\"]]\n", + " day = min(int(now // 24), n_days + 4)\n", + " if policy == \"baseline\":\n", + " tm = min(cand, key=lambda t: np.hypot(t[\"x\"] - r[\"x\"], t[\"y\"] - r[\"y\"]))\n", + " else:\n", + " tm = min(cand, key=lambda t: max(free[t[\"team\"]], now) + travel_h(t, r)\n", + " + w[\"balance\"] * load[t[\"team\"]][day] / DUTY_H * 4)\n", + " backlog = max(0.0, free[tm[\"team\"]] - now)\n", + " go = max(free[tm[\"team\"]], now)\n", + " tr = travel_h(tm, r, actual=True)\n", + " work = on_site_h(tm, r)\n", + " free[tm[\"team\"]] = go + 2 * tr + work\n", + " load[tm[\"team\"]][min(int(go // 24), n_days + 4)] += 2 * tr + work\n", + " out.append({**r, \"idx\": k, \"team\": tm[\"team\"], \"dist_km\": float(np.hypot(tm[\"x\"] - r[\"x\"], tm[\"y\"] - r[\"y\"])),\n", + " \"backlog_h\": backlog, \"cycle_wait_h\": now - r[\"t_req\"], \"dispatch\": go, \"travel_h\": tr,\n", + " \"work_h\": work, \"response_h\": go + tr - r[\"t_req\"], \"downtime_h\": go + tr + work - r[\"t_req\"]})\n", + " pending, now = [], now + cycle_h\n", + " L = pd.DataFrame({k: v[:n_days] for k, v in load.items()}) / DUTY_H # 정비반별 일일 부하율\n", + " return pd.DataFrame(out).sort_values(\"idx\").reset_index(drop=True), L\n", + "\n", + "\n", + "def metrics(res, L):\n", + " c3 = res[res[\"crit\"] == 3]\n", + " return {\"평균 도착(h)\": round(res[\"response_h\"].mean(), 2), \"핵심장비 도착(h)\": round(c3[\"response_h\"].mean(), 2),\n", + " \"핵심장비 90%(h)\": round(c3[\"response_h\"].quantile(0.9), 2), \"평균 부하율\": round(float(L.values.mean()), 3),\n", + " \"부하율 편차\": round(float(L.mean().std()), 3), \"과부하 일수\": int((L > 1.0).values.sum()),\n", + " \"총 이동(h)\": round(float(res[\"travel_h\"].sum() * 2), 1)}\n", + "\n", + "CW = {1: 1.0, 2: 3.0, 3: 6.0}\n", + "ALERT_H = 4.0 # 핵심장비(crit 3) 도착 목표시간\n", + "T0 = 15 * 24 + 10.0 # 15일차 10시 동시 다발 요청 발생\n", + "\n", + "\n", + "def make_burst(n, seed):\n", + " \"\"\"[교육용가상자료] 동시 n개소 요청(같은 시각 접수). 일부 정비반은 기존 작업으로 바쁜 상태.\"\"\"\n", + " pool = make_requests(n_days=10, seed=seed)\n", + " b = pool.sample(n, random_state=seed).reset_index(drop=True).astype({\"x\": float, \"crit\": object})\n", + " b[\"t_req\"] = T0; b[\"req_id\"] = [f\"B{seed}-{i:02d}\" for i in range(n)]\n", + " return b\n", + "\n", + "\n", + "def validate(r):\n", + " errs = []\n", + " if not (0 <= r[\"x\"] <= AREA_KM and 0 <= r[\"y\"] <= AREA_KM):\n", + " errs.append(\"좌표 범위 오류\")\n", + " if r[\"equip\"] not in EQUIP_F or r[\"fault\"] not in FAULT_H:\n", + " errs.append(\"장비/고장 코드 오류\")\n", + " if r[\"crit\"] not in (1, 2, 3):\n", + " errs.append(\"중요도 누락\")\n", + " return errs\n", + "\n", + "\n", + "def schedule(burst, teams, busy_until):\n", + " \"\"\"할당 문제로 동시 요청을 (정비반, 순번)에 배정. 비용 = 중요도 가중 x 예상 도착시각.\"\"\"\n", + " slots = int(np.ceil(len(burst) / len(teams))) + 2\n", + " cols = [(t, s) for t in teams for s in range(slots)]\n", + " avg = 4.0\n", + " cost = np.full((len(burst), len(cols)), 1e6)\n", + " for i, r in enumerate(burst.to_dict(\"records\")):\n", + " for j, (t, s) in enumerate(cols):\n", + " if r[\"equip\"] in t[\"skills\"]:\n", + " cost[i, j] = CW[r[\"crit\"]] * (busy_until[t[\"team\"]] - T0 + s * avg + travel_h(t, r))\n", + " ri, cj = linear_sum_assignment(cost)\n", + " plan = sorted(((i, cols[j][0], cols[j][1]) for i, j in zip(ri, cj)), key=lambda p: (p[1][\"team\"], p[2]))\n", + " free, rows = dict(busy_until), []\n", + " for i, t, s in plan:\n", + " r = burst.iloc[i]\n", + " go = max(free[t[\"team\"]], T0)\n", + " est = go + travel_h(t, r) - T0\n", + " act_tr = travel_h(t, r, actual=True)\n", + " rows.append({\"req_id\": r[\"req_id\"], \"crit\": r[\"crit\"], \"equip\": r[\"equip\"], \"team\": t[\"team\"], \"eta_h\": est,\n", + " \"actual_h\": go + act_tr - T0, \"alert\": r[\"crit\"] == 3 and est > ALERT_H})\n", + " free[t[\"team\"]] = go + 2 * act_tr + on_site_h(t, r) # 실제로는 실측 이동시간대로 다음 출동이 정해진다\n", + " return pd.DataFrame(rows)\n", + "\n", + "\n", + "def dashboard(plan, teams):\n", + " fig, ax = plt.subplots(1, 3, figsize=(14, 4))\n", + " ld = plan.groupby(\"team\").size().reindex([t[\"team\"] for t in teams], fill_value=0)\n", + " ax[0].bar(ld.index, ld.values); ax[0].set_title(\"Assigned requests per team\")\n", + " ax[1].scatter(plan[\"eta_h\"], plan[\"crit\"], c=np.where(plan[\"alert\"], \"red\", \"gray\")); ax[1].axvline(ALERT_H, ls=\"--\")\n", + " ax[1].set_xlabel(\"ETA (h)\"); ax[1].set_yticks([1, 2, 3]); ax[1].set_title(\"ETA by criticality (red = alert)\")\n", + " ax[2].hist(plan[\"eta_h\"], bins=15); ax[2].set_title(\"ETA distribution\")\n", + " fig.tight_layout(); fig.canvas.draw()\n", + " return fig\n", + "\n", + "\n", + "teams_all = make_teams()\n", + "rng = np.random.default_rng(4)\n", + "busy0 = {t[\"team\"]: T0 + float(rng.uniform(0, 3)) for t in teams_all} # 기존 작업 잔여시간\n", + "rows, keep = [], {}\n", + "for n, down, bad in [(20, None, 0), (20, \"MT5\", 3), (40, \"MT5\", 3), (60, \"MT5\", 5)]:\n", + " burst = make_burst(n, seed=n)\n", + " for k in range(bad): # 손상 데이터 주입\n", + " burst.loc[k * 3, [\"x\", \"equip\", \"crit\"][k % 3]] = [999.0, \"unknown\", 0][k % 3]\n", + " teams = [t for t in teams_all if t[\"team\"] != down]\n", + " t0 = time.perf_counter()\n", + " ok = burst[[not validate(r) for r in burst.to_dict(\"records\")]]\n", + " manual = burst.drop(ok.index)\n", + " plan = schedule(ok, teams, {t[\"team\"]: busy0[t[\"team\"]] for t in teams})\n", + " fig = dashboard(plan, teams)\n", + " sec = time.perf_counter() - t0\n", + " truth = (plan[\"crit\"] == 3) & (plan[\"actual_h\"] > ALERT_H)\n", + " acc = float((plan[\"alert\"] == truth).mean())\n", + " lost = n - len(plan) - len(manual)\n", + " rows.append({\"상황\": f\"{n}개소\" + (f\", {down} 불가\" if down else \"\") + (f\", 손상 {bad}건\" if bad else \"\"), \"배정\": len(plan), \"수동 확인\": len(manual),\n", + " \"유실\": lost, \"처리+화면(초)\": round(sec, 2), \"핵심장비 경보\": int(plan[\"alert\"].sum()), \"경보 정확도\": round(acc, 3),\n", + " \"핵심 평균 도착(h)\": round(plan.loc[plan[\"crit\"] == 3, \"eta_h\"].mean(), 2)})\n", + " keep[n, down] = (fig, plan)\n", + " if (n, down) != (60, \"MT5\"):\n", + " plt.close(fig)\n", + "T = pd.DataFrame(rows)\n", + "print(\"[교육용가상자료] 동시 다발 긴급 정비 요청 스트레스 테스트\")\n", + "print(T.to_string(index=False))\n", + "m = T.iloc[1]\n", + "print(f\"\\n동시 20개소(정비반 1개 불가·손상 3건): 처리+화면 {m['처리+화면(초)']}초 (목표 2초 이내: {'달성' if T['처리+화면(초)'].max() <= 2 else '미달'}), \"\n", + " f\"경보 정확도 {m['경보 정확도']:.1%} (목표 95% 이상: {'달성' if T['경보 정확도'].min() >= 0.95 else '미달'}), 유실 {int(T['유실'].sum())}건\")\n", + "print(\"손상 요청은 삭제하지 않고 '수동 확인' 목록으로 넘겨 관제병이 전화·무전으로 재확인한다.\")\n", + "\n", + "plan = keep[20, \"MT5\"][1]\n", + "qt = []\n", + "for c in (1, 2, 3):\n", + " for e in EQUIP_F:\n", + " t0 = time.perf_counter(); plan[(plan[\"crit\"] == c) & (plan[\"equip\"] == e)]; qt.append((time.perf_counter() - t0) * 1000)\n", + "print(f\"실시간 필터(중요도 x 장비 12조합) 평균 {np.mean(qt):.2f} ms\")\n", + "plan = keep[40, \"MT5\"][1]\n", + "miss = plan[(plan[\"crit\"] == 3) & (plan[\"actual_h\"] > ALERT_H) & ~plan[\"alert\"]]\n", + "print(f\"40개소 상황: 놓친 경보 {len(miss)}건, 헛경보 {int((plan['alert'] & ~((plan['crit'] == 3) & (plan['actual_h'] > ALERT_H))).sum())}건\"\n", + " \" -> 도로 지연(비포장·기상)을 ETA에 반영하면 줄일 수 있다.\")\n", + "print(\"기능 축소 운용: 처리 시간이 2초를 넘으면 할당 최적화 대신 '중요도 순 + 최근접' 규칙으로 즉시 배정하고 최적화는 뒤에서 재계산한다.\")\n", + "plt.show()\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 08 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + "[교육용가상자료] 동시 다발 긴급 정비 요청 스트레스 테스트\n", + " 상황 배정 수동 확인 유실 처리+화면(초) 핵심장비 경보 경보 정확도 핵심 평균 도착(h)\n", + " 20개소 20 0 0 0.24 1 1.000 3.17\n", + "20개소, MT5 불가, 손상 3건 17 3 0 0.23 3 1.000 4.56\n", + "40개소, MT5 불가, 손상 3건 37 3 0 0.23 3 0.973 4.55\n", + "60개소, MT5 불가, 손상 5건 55 5 0 0.23 6 1.000 5.64\n", + "\n", + "동시 20개소(정비반 1개 불가·손상 3건): 처리+화면 0.23초 (목표 2초 이내: 달성), 경보 정확도 100.0% (목표 95% 이상: 달성), 유실 0건\n", + "손상 요청은 삭제하지 않고 '수동 확인' 목록으로 넘겨 관제병이 전화·무전으로 재확인한다.\n", + "실시간 필터(중요도 x 장비 12조합) 평균 0.69 ms\n", + "40개소 상황: 놓친 경보 0건, 헛경보 1건 -> 도로 지연(비포장·기상)을 ETA에 반영하면 줄일 수 있다.\n", + "기능 축소 운용: 처리 시간이 2초를 넘으면 할당 최적화 대신 '중요도 순 + 최근접' 규칙으로 즉시 배정하고 최적화는 뒤에서 재계산한다.\n", + "```\n", + "
\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 09. AAR 기반 출동 우선순위 배정 오류 분석 및 가중치 매트릭스 재튜닝\n", + "**소요시간**: 약 3.0시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "AAR 기반 출동 우선순위 배정 오류를 분석하고 가중치를 재튜닝한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **출동 우선순위 배정 결과를 AAR로 검토해 오류 원인을 분석하고, 가중치 매트릭스를 재튜닝해 도입 효과를 정량 지표로 산출한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **핵심장비 도착 4시간 초과 사례를 정비반 부하 집중·원거리 배정·도로·기상 지연·배정 주기 대기로 분류 수행하고 검증하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `reports/mnt10_roi_analysis.md` 형태의 결과물을 완성한다.\n", + "\n", + "### 🔍 평가 및 검증 기준 (PASS 조건)\n", + "#### 미션 판정\n", + "\n", + "**평가 구분:** 필수\n", + "\n", + "**핵심 평가영역:** 추정 산식의 객관성 및 군 재정/군수 기획 부서 설득력\n", + "\n", + "#### 미션 PASS 조건\n", + "\n", + "- 정량 기준: 비용 절감액, 시간 단축률 등 3대 정량 효과 지표 산출\n", + "\n", + "\n", + "- 진행 기준: ROI 분석 보고서 완성 및 주요 지표 민감도 분석 수록\n", + "\n", + "\n", + "- 검증 방법: 회계/기획 기준 모델과의 비교 검증\n", + "\n", + "\n", + "#### 채점자 판정 메모\n", + "\n", + "> 단순 코드 실행 여부뿐만 아니라 출력된 수치와 근거 자료가 군 실무 요구사항에 부합하는지 면밀히 확인한다.\n", + "\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "\"\"\"출동 우선순위 배정 결과를 사후검토(AAR)해 핵심장비 지연 사례의 원인(정비반 부하 집중·원거리 배정·도로·기상 지연 등)을 분류하고, 과거 기간으로 우선순위·배정 가중치 매트릭스를 재튜닝해 평가 기간에 검증한다. 가동중단 비용·핵심장비 도착시간·정비인력 초과근무의 3대 정량 효과 지표와 ROI 요약을 출력한다.\"\"\"\n", + "import itertools\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", + "# [교육용가상자료] 야전 이동정비반·긴급 정비 요청 생성기 (실제 부대·위치·장비·인원 정보 없음)\n", + "# 60km x 60km 가상 지역에 이동정비반 6개와 30일간의 긴급 정비 요청을 만든다.\n", + "# 장비 구분(mobility/fires/comms/power)과 고장 유형은 교육용 추상 분류다.\n", + "# ---------------------------------------------------------------------------\n", + "AREA_KM, DUTY_H = 60, 10.0 # 가상 작전지역 크기, 정비반 1일 기준 가용시간\n", + "FAULT_H = {\"engine\": 6.0, \"drivetrain\": 5.0, \"hydraulic\": 3.0, \"electric\": 2.0, \"comm_module\": 1.5, \"cooling\": 2.5}\n", + "FIELD_P = {\"engine\": 0.3, \"drivetrain\": 0.35, \"hydraulic\": 0.75, \"electric\": 0.9, \"comm_module\": 0.95, \"cooling\": 0.8}\n", + "EQUIP_F = {\"mobility\": 1.0, \"fires\": 1.2, \"comms\": 0.7, \"power\": 0.8}\n", + "FAULT_OF = {\"mobility\": [0.3, 0.25, 0.2, 0.1, 0.0, 0.15], \"fires\": [0.2, 0.15, 0.35, 0.15, 0.0, 0.15],\n", + " \"comms\": [0.0, 0.0, 0.0, 0.4, 0.5, 0.1], \"power\": [0.35, 0.0, 0.0, 0.35, 0.0, 0.3]}\n", + "CRIT_P = {\"mobility\": [0.45, 0.35, 0.2], \"fires\": [0.2, 0.35, 0.45], \"comms\": [0.3, 0.4, 0.3], \"power\": [0.6, 0.3, 0.1]}\n", + "HUBS = [(15, 20), (45, 25), (30, 50)] # 가상 훈련장 3곳(요청이 몰리는 지점)\n", + "\n", + "\n", + "def make_teams(seed=0):\n", + " rng = np.random.default_rng(seed)\n", + " base = [(8, 10), (30, 6), (52, 12), (10, 44), (32, 38), (54, 50)]\n", + " skills = [[\"fires\", \"mobility\"], [\"comms\", \"mobility\"], [\"fires\", \"power\"],\n", + " [\"mobility\", \"power\"], [\"comms\", \"fires\"], [\"comms\", \"mobility\", \"power\"]]\n", + " return [{\"team\": f\"MT{i + 1}\", \"x\": float(x), \"y\": float(y), \"crew\": int(rng.integers(3, 6)), \"skills\": s}\n", + " for i, ((x, y), s) in enumerate(zip(base, skills))]\n", + "\n", + "\n", + "def make_requests(n_days=30, per_day=10, seed=1, surge_days=(8, 9, 10, 11, 22, 23, 24)):\n", + " \"\"\"하루 평균 per_day건, 훈련 주간(surge_days)에는 1.6배의 긴급 정비 요청을 만든다.\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " rows = []\n", + " for d in range(n_days):\n", + " n = rng.poisson(per_day * (1.6 if d in surge_days else 1.0))\n", + " weather = rng.choice([\"clear\", \"rain\", \"snow\"], p=[0.65, 0.25, 0.1])\n", + " for _ in range(n):\n", + " if rng.random() < 0.7:\n", + " hx, hy = HUBS[rng.integers(0, 3)]\n", + " x, y = np.clip(rng.normal(hx, 6), 0, AREA_KM), np.clip(rng.normal(hy, 6), 0, AREA_KM)\n", + " else:\n", + " x, y = rng.uniform(0, AREA_KM, 2)\n", + " eq = str(rng.choice(list(EQUIP_F), p=[0.45, 0.25, 0.2, 0.1]))\n", + " fault = str(rng.choice(list(FAULT_H), p=FAULT_OF[eq]))\n", + " parts = bool(rng.random() < 0.75)\n", + " bad = weather != \"clear\"\n", + " std_h = FAULT_H[fault] * EQUIP_F[eq] * (1.35 if not parts else 1.0) * (1.15 if bad else 1.0) * rng.lognormal(0, 0.12)\n", + " rows.append({\"t_req\": d * 24 + float(np.clip(rng.normal(13, 4), 5, 23)), \"day\": d,\n", + " \"x\": float(x), \"y\": float(y), \"equip\": eq, \"fault\": fault,\n", + " \"crit\": int(rng.choice([1, 2, 3], p=CRIT_P[eq])), \"parts_ready\": parts,\n", + " \"weather\": str(weather), \"road\": \"unpaved\" if y > 30 and rng.random() < 0.7 else \"paved\",\n", + " \"field_ok\": bool(rng.random() < FIELD_P[fault] * (1.0 if parts else 0.5)),\n", + " \"std_repair_h\": round(float(std_h), 2), \"road_delay\": float(rng.lognormal(0, 0.15))})\n", + " df = pd.DataFrame(rows).sort_values(\"t_req\").reset_index(drop=True)\n", + " df.insert(0, \"req_id\", [f\"R{i:04d}\" for i in range(len(df))])\n", + " return df\n", + "# ---------------------------------------------------------------------------\n", + "\n", + "# ---- 출동 배정·정비 모의 공통 함수 ----\n", + "W0 = {\"crit\": 1.0, \"wait\": 0.3, \"short\": 0.2, \"balance\": 0.5} # 우선순위·배정 기본 가중치(미션 9에서 재튜닝)\n", + "\n", + "\n", + "def travel_h(team, r, actual=False):\n", + " \"\"\"도로 굴곡 1.3배, 포장 35km/h·비포장 25km/h, 악천후 20% 감속. actual=True면 실제 도로 지연을 반영.\"\"\"\n", + " d = 1.3 * np.hypot(team[\"x\"] - r[\"x\"], team[\"y\"] - r[\"y\"])\n", + " v = (35.0 if r[\"road\"] == \"paved\" else 25.0) / (1.2 if r[\"weather\"] != \"clear\" else 1.0)\n", + " return d / v * (r[\"road_delay\"] if actual else 1.0)\n", + "\n", + "\n", + "def on_site_h(team, r):\n", + " \"\"\"현장 정비시간: 4인 기준 표준시간을 투입 인원으로 환산. 현장 수리 불가면 진단·후송 조치 1시간.\"\"\"\n", + " return r[\"std_repair_h\"] * 4 / team[\"crew\"] if r[\"field_ok\"] else 1.0\n", + "\n", + "\n", + "def priority(r, now, est_h, w):\n", + " return w[\"crit\"] * r[\"crit\"] + w[\"wait\"] * (now - r[\"t_req\"]) + w[\"short\"] / (1 + est_h)\n", + "\n", + "\n", + "def simulate(req, teams, policy=\"baseline\", w=None, cycle_h=1.0, est=None):\n", + " \"\"\"배정 주기(cycle_h)마다 대기 요청을 정렬해 이동정비반에 배정하고 출동·정비·복귀를 모의한다.\n", + " baseline: 접수 순서 + 거리 최근접 정비반 / priority: 우선순위 점수 순서 + 도착 예상시각·당일 부하 균형\"\"\"\n", + " w = w or W0\n", + " est = np.asarray(est if est is not None else req[\"std_repair_h\"].values, float)\n", + " n_days = int(req[\"day\"].max()) + 1\n", + " free = {t[\"team\"]: 0.0 for t in teams}\n", + " load = {t[\"team\"]: np.zeros(n_days + 5) for t in teams}\n", + " recs, out, pending, i, now = req.to_dict(\"records\"), [], [], 0, cycle_h\n", + " while i < len(recs) or pending:\n", + " while i < len(recs) and recs[i][\"t_req\"] <= now:\n", + " pending.append((i, recs[i])); i += 1\n", + " if policy == \"priority\":\n", + " pending.sort(key=lambda p: -priority(p[1], now, est[p[0]], w))\n", + " for k, r in pending:\n", + " cand = [t for t in teams if r[\"equip\"] in t[\"skills\"]]\n", + " day = min(int(now // 24), n_days + 4)\n", + " if policy == \"baseline\":\n", + " tm = min(cand, key=lambda t: np.hypot(t[\"x\"] - r[\"x\"], t[\"y\"] - r[\"y\"]))\n", + " else:\n", + " tm = min(cand, key=lambda t: max(free[t[\"team\"]], now) + travel_h(t, r)\n", + " + w[\"balance\"] * load[t[\"team\"]][day] / DUTY_H * 4)\n", + " backlog = max(0.0, free[tm[\"team\"]] - now)\n", + " go = max(free[tm[\"team\"]], now)\n", + " tr = travel_h(tm, r, actual=True)\n", + " work = on_site_h(tm, r)\n", + " free[tm[\"team\"]] = go + 2 * tr + work\n", + " load[tm[\"team\"]][min(int(go // 24), n_days + 4)] += 2 * tr + work\n", + " out.append({**r, \"idx\": k, \"team\": tm[\"team\"], \"dist_km\": float(np.hypot(tm[\"x\"] - r[\"x\"], tm[\"y\"] - r[\"y\"])),\n", + " \"backlog_h\": backlog, \"cycle_wait_h\": now - r[\"t_req\"], \"dispatch\": go, \"travel_h\": tr,\n", + " \"work_h\": work, \"response_h\": go + tr - r[\"t_req\"], \"downtime_h\": go + tr + work - r[\"t_req\"]})\n", + " pending, now = [], now + cycle_h\n", + " L = pd.DataFrame({k: v[:n_days] for k, v in load.items()}) / DUTY_H # 정비반별 일일 부하율\n", + " return pd.DataFrame(out).sort_values(\"idx\").reset_index(drop=True), L\n", + "\n", + "\n", + "def metrics(res, L):\n", + " c3 = res[res[\"crit\"] == 3]\n", + " return {\"평균 도착(h)\": round(res[\"response_h\"].mean(), 2), \"핵심장비 도착(h)\": round(c3[\"response_h\"].mean(), 2),\n", + " \"핵심장비 90%(h)\": round(c3[\"response_h\"].quantile(0.9), 2), \"평균 부하율\": round(float(L.values.mean()), 3),\n", + " \"부하율 편차\": round(float(L.mean().std()), 3), \"과부하 일수\": int((L > 1.0).values.sum()),\n", + " \"총 이동(h)\": round(float(res[\"travel_h\"].sum() * 2), 1)}\n", + "\n", + "# ---- 접수 시점 파생 피처(미션 4와 동일) ----\n", + "def make_features(req, teams):\n", + " \"\"\"접수 시점에 알 수 있는 정보만으로 파생 피처를 만든다(미래 정보 누설 방지).\"\"\"\n", + " X = pd.DataFrame(index=req.index)\n", + " X[\"hour\"] = req[\"t_req\"] % 24\n", + " X[\"is_night\"] = (X[\"hour\"] >= 19).astype(int)\n", + " X[\"dow\"] = req[\"day\"] % 7\n", + " X[\"weather_bad\"] = (req[\"weather\"] != \"clear\").astype(int)\n", + " X[\"unpaved\"] = (req[\"road\"] == \"unpaved\").astype(int)\n", + " X[\"no_parts\"] = (~req[\"parts_ready\"]).astype(int)\n", + " X[\"std_table_h\"] = req[\"fault\"].map(FAULT_H) * req[\"equip\"].map(EQUIP_F) # 정비 표준시간표 값\n", + " X[\"crit_x_noparts\"] = req[\"crit\"] * X[\"no_parts\"]\n", + " X[\"bad_x_table\"] = X[\"weather_bad\"] * X[\"std_table_h\"]\n", + " X[\"near_team_km\"] = [min(np.hypot(t[\"x\"] - x, t[\"y\"] - y) for t in teams if e in t[\"skills\"]) for x, y, e in zip(req[\"x\"], req[\"y\"], req[\"equip\"])]\n", + " X[\"hub_km\"] = [min(np.hypot(hx - x, hy - y) for hx, hy in HUBS) for x, y in zip(req[\"x\"], req[\"y\"])]\n", + " X[\"req_24h\"] = [((req[\"t_req\"] > t - 24) & (req[\"t_req\"] <= t)).sum() for t in req[\"t_req\"]]\n", + " X[\"fault_past_mean\"] = req.groupby(\"fault\")[\"std_repair_h\"].transform(lambda s: s.shift().expanding().mean()).fillna(req[\"std_repair_h\"].mean())\n", + " X[\"crit\"] = req[\"crit\"]\n", + " for e in EQUIP_F:\n", + " X[f\"eq_{e}\"] = (req[\"equip\"] == e).astype(int)\n", + " return X\n", + "\n", + "\n", + "def fit_repair_model(hist, teams):\n", + " \"\"\"과거 요청으로 정비 소요시간(4인 기준) 예측 모델을 학습한다.\"\"\"\n", + " return GradientBoostingRegressor(n_estimators=200, max_depth=3, learning_rate=0.05, random_state=0).fit(make_features(hist, teams), hist[\"std_repair_h\"])\n", + "\n", + "CW = {1: 1.0, 2: 3.0, 3: 6.0} # 중요도별 가동중단 비용 지수\n", + "PRICE = {\"downtime\": 10, \"move\": 5, \"overtime\": 2} # [교육용가상자료] 가상 단가(만원): 비용지수 1, 차량 운행 1h, 초과근무 1인·h\n", + "TARGET_H = 4.0\n", + "\n", + "\n", + "def evaluate(res, L, teams):\n", + " crew = pd.Series({t[\"team\"]: t[\"crew\"] for t in teams})\n", + " over = ((L - 1.0).clip(lower=0) * DUTY_H * crew).values.sum() # 초과근무 인·시간\n", + " return {\"가동중단 비용지수\": float((res[\"crit\"].map(CW) * res[\"downtime_h\"]).sum()),\n", + " \"핵심장비 도착(h)\": float(res.loc[res[\"crit\"] == 3, \"response_h\"].mean()),\n", + " \"초과근무(인·h)\": float(over), \"차량 운행(h)\": float(res[\"travel_h\"].sum() * 2),\n", + " \"핵심 4h 초과(건)\": int(((res[\"crit\"] == 3) & (res[\"response_h\"] > TARGET_H)).sum())}\n", + "\n", + "\n", + "def won(m):\n", + " return m[\"가동중단 비용지수\"] * PRICE[\"downtime\"] + m[\"차량 운행(h)\"] * PRICE[\"move\"] + m[\"초과근무(인·h)\"] * PRICE[\"overtime\"]\n", + "\n", + "\n", + "def objective(m):\n", + " \"\"\"튜닝 목표: 가상 총비용 + 핵심장비 4시간 초과 1건당 임무 영향 벌점(가상 500만원).\"\"\"\n", + " return won(m) + 500 * m[\"핵심 4h 초과(건)\"]\n", + "\n", + "\n", + "teams = make_teams()\n", + "hist, req = make_requests(seed=11), make_requests(seed=1)\n", + "model = fit_repair_model(hist, teams)\n", + "est_h, est_r = model.predict(make_features(hist, teams)), model.predict(make_features(req, teams))\n", + "\n", + "# 1) AAR: 현행 가중치(W0)로 운용한 평가 기간의 핵심장비 지연 원인 분류\n", + "res, L = simulate(req, teams, \"priority\", w=W0, est=est_r)\n", + "late = res[(res[\"crit\"] == 3) & (res[\"response_h\"] > TARGET_H)].copy()\n", + "est_tr = [travel_h(next(t for t in teams if t[\"team\"] == tm), r) for tm, r in zip(late[\"team\"], late.to_dict(\"records\"))]\n", + "late[\"원인\"] = np.select([late[\"backlog_h\"] > 1.0, late[\"dist_km\"] > 25, late[\"travel_h\"] > np.array(est_tr) * 1.2],\n", + " [\"정비반 부하 집중(앞 작업 대기)\", \"원거리 정비반 배정\", \"도로·기상 지연\"], \"배정 주기 대기\")\n", + "print(f\"[교육용가상자료] AAR: 핵심장비 요청 {int((res['crit'] == 3).sum())}건 중 {TARGET_H:.0f}시간 초과 {len(late)}건\")\n", + "print(late[\"원인\"].value_counts().to_string())\n", + "print(\"훈련 주간(요청 급증) 발생 비율 {:.0%}, 지연 건의 평균 앞 작업 대기 {:.1f}h\".format(late[\"day\"].isin([8, 9, 10, 11, 22, 23, 24]).mean(), late[\"backlog_h\"].mean()))\n", + "\n", + "# 2) 가중치 매트릭스 재튜닝: 과거 기간(hist)에서 탐색 -> 평가 기간(req)에서 검증\n", + "grid = []\n", + "for c, wt, sh, bl in itertools.product([0.5, 1.0, 2.0, 3.0], [0.1, 0.3, 0.6], [0.2, 1.0], [0.0, 0.5, 1.0, 2.0, 3.0]):\n", + " w = {\"crit\": c, \"wait\": wt, \"short\": sh, \"balance\": bl}\n", + " r_, L_ = simulate(hist, teams, \"priority\", w=w, est=est_h)\n", + " grid.append({**w, \"cost\": objective(evaluate(r_, L_, teams))})\n", + "G = pd.DataFrame(grid).sort_values(\"cost\")\n", + "W1 = {k: float(G.iloc[0][k]) for k in W0}\n", + "print(f\"\\n가중치 조합 {len(G)}개 탐색(과거 30일). 현행 {W0} -> 재튜닝 {W1}\")\n", + "\n", + "rows = {}\n", + "rb, Lb = simulate(req, teams, \"baseline\")\n", + "rows[\"Baseline(거리)\"] = evaluate(rb, Lb, teams)\n", + "rows[\"현행 W0\"] = evaluate(res, L, teams)\n", + "r1, L1 = simulate(req, teams, \"priority\", w=W1, est=est_r)\n", + "rows[\"재튜닝 W1\"] = evaluate(r1, L1, teams)\n", + "T = pd.DataFrame(rows).T\n", + "T[\"가상 총비용(만원)\"] = [won(m) for m in rows.values()]\n", + "print(\"\\n[평가 기간 30일 비교]\")\n", + "print(T.round(1).to_string())\n", + "print(f\"핵심장비 {TARGET_H:.0f}시간 초과: 현행 {len(late)}건 -> 재튜닝 {rows['재튜닝 W1']['핵심 4h 초과(건)']}건\")\n", + "w0, w1 = rows[\"현행 W0\"], rows[\"재튜닝 W1\"]\n", + "print(f\"재튜닝 효과(현행 대비): 가상 총비용 {won(w1) / won(w0) - 1:+.1%}, 차량 운행 {w1['차량 운행(h)'] / w0['차량 운행(h)'] - 1:+.1%}\")\n", + "print(\"AAR 결론: 남은 지연은 전 정비반이 동시에 바쁜 급증 구간의 대기에서 생겨 가중치로는 줄지 않는다\"\n", + " \" -> 훈련 주간 예고 시 정비반 사전 증원·상급 정비부대 지원 요청을 운영 조치로 권고한다.\")\n", + "\n", + "# 3) 3대 정량 효과 지표(Baseline 대비 재튜닝 W1)\n", + "b, n = rows[\"Baseline(거리)\"], rows[\"재튜닝 W1\"]\n", + "save = (b[\"가동중단 비용지수\"] - n[\"가동중단 비용지수\"]) * PRICE[\"downtime\"]\n", + "cut_h = 1 - n[\"핵심장비 도착(h)\"] / b[\"핵심장비 도착(h)\"]\n", + "over = b[\"초과근무(인·h)\"] - n[\"초과근무(인·h)\"]\n", + "print(\"\\n[3대 정량 효과 지표(30일, Baseline 대비)]\")\n", + "print(f\" 1) 가동중단 비용 절감액: {save:,.0f}만원(가상 단가)\")\n", + "print(f\" 2) 핵심장비 도착시간 단축률: {cut_h:.1%} ({b['핵심장비 도착(h)']:.2f}h -> {n['핵심장비 도착(h)']:.2f}h)\")\n", + "print(f\" 3) 정비인력 초과근무 변화: {over:+,.0f}인·h (양수=감소)\")\n", + "move = (n[\"차량 운행(h)\"] - b[\"차량 운행(h)\"]) * PRICE[\"move\"]\n", + "print(f\" 부대비용: 차량 운행 증가 {move:,.0f}만원 -> 순효과 {save + over * PRICE['overtime'] - move:,.0f}만원/월\")\n", + "print(\"ROI 로드맵: 단기(1~3개월) 관제 화면·우선순위 시범 -> 중기(6개월) 분기별 AAR 가중치 재튜닝 -> 장기(1년) 정비체계 연동\")\n", + "print(\"모든 금액은 교육용 가정 단가로 계산한 상대 비교값이며 실제 예산 근거로 쓰지 않는다.\")\n", + "\n", + "fig, ax = plt.subplots(1, 2, figsize=(11, 4))\n", + "late[\"원인\"].value_counts().rename(index={\"정비반 부하 집중(앞 작업 대기)\": \"team backlog\", \"원거리 정비반 배정\": \"far team\",\n", + " \"도로·기상 지연\": \"road/weather\", \"배정 주기 대기\": \"cycle wait\"}).plot.bar(ax=ax[0], rot=0)\n", + "ax[0].set_title(\"AAR: causes of late critical response\"); ax[0].set_xlabel(\"\")\n", + "ax[1].bar([\"baseline\", \"W0\", \"W1\"], T[\"가상 총비용(만원)\"]); ax[1].set_title(\"Virtual total cost (30 days)\")\n", + "plt.tight_layout(); plt.show()\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 09 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + "[교육용가상자료] AAR: 핵심장비 요청 99건 중 4시간 초과 8건\n", + "원인\n", + "정비반 부하 집중(앞 작업 대기) 8\n", + "훈련 주간(요청 급증) 발생 비율 62%, 지연 건의 평균 앞 작업 대기 3.5h\n", + "\n", + "가중치 조합 120개 탐색(과거 30일). 현행 {'crit': 1.0, 'wait': 0.3, 'short': 0.2, 'balance': 0.5} -> 재튜닝 {'crit': 0.5, 'wait': 0.1, 'short': 0.2, 'balance': 0.0}\n", + "\n", + "[평가 기간 30일 비교]\n", + " 가동중단 비용지수 핵심장비 도착(h) 초과근무(인·h) 차량 운행(h) 핵심 4h 초과(건) 가상 총비용(만원)\n", + "Baseline(거리) 6494.6 3.1 1018.3 504.7 27.0 69506.0\n", + "현행 W0 5055.3 1.9 869.7 744.9 8.0 56017.0\n", + "재튜닝 W1 4962.9 1.9 872.4 695.1 8.0 54848.7\n", + "핵심장비 4시간 초과: 현행 8건 -> 재튜닝 8건\n", + "재튜닝 효과(현행 대비): 가상 총비용 -2.1%, 차량 운행 -6.7%\n", + "AAR 결론: 남은 지연은 전 정비반이 동시에 바쁜 급증 구간의 대기에서 생겨 가중치로는 줄지 않는다 -> 훈련 주간 예고 시 정비반 사전 증원·상급 정비부대 지원 요청을 운영 조치로 권고한다.\n", + "\n", + "[3대 정량 효과 지표(30일, Baseline 대비)]\n", + " 1) 가동중단 비용 절감액: 15,317만원(가상 단가)\n", + " 2) 핵심장비 도착시간 단축률: 40.2% (3.13h -> 1.87h)\n", + " 3) 정비인력 초과근무 변화: +146인·h (양수=감소)\n", + " 부대비용: 차량 운행 증가 952만원 -> 순효과 14,657만원/월\n", + "ROI 로드맵: 단기(1~3개월) 관제 화면·우선순위 시범 -> 중기(6개월) 분기별 AAR 가중치 재튜닝 -> 장기(1년) 정비체계 연동\n", + "모든 금액은 교육용 가정 단가로 계산한 상대 비교값이며 실제 예산 근거로 쓰지 않는다.\n", + "```\n", + "
\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 10. 야전 이동정비반 긴급 출동 우선순위 배정 및 경로 최적화 도구 통합 완성\n", + "**소요시간**: 약 3.5시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "야전 이동정비반 긴급 출동 우선순위 배정 및 경로 최적화 도구를 완성한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **요청 검증부터 예측 모델 재학습, 우선순위 배정, 승인 대상 선별, 부하 경보까지 야전 이동정비반 출동 도구를 통합하고 운영 문서를 완비한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **신규 실적으로 도전자 모델을 학습해 챔피언 모델과 검증 MAPE를 비교한 뒤 교체 여부를 정하는 자동 재학습 파이프라인 구현하고 동작을 검증하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 2개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `docs/mnt10_operations_guide.md 및 pipeline/mnt10_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": [ + "\"\"\"정비 요청 검증·정제, 정비 소요시간 예측 모델의 자동 재학습(챔피언·도전자 비교 후 교체), 재튜닝 가중치를 적용한 출동 우선순위 배정, 대대장 승인 대상 선별, 정비반 부하율 경보까지 야전 이동정비반 출동 도구를 한 파일로 통합 실행하고, 자체 점검·운영 문서 완비 점검·정비대대장 브리핑 요약을 출력한다.\"\"\"\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", + "# [교육용가상자료] 야전 이동정비반·긴급 정비 요청 생성기 (실제 부대·위치·장비·인원 정보 없음)\n", + "# 60km x 60km 가상 지역에 이동정비반 6개와 30일간의 긴급 정비 요청을 만든다.\n", + "# 장비 구분(mobility/fires/comms/power)과 고장 유형은 교육용 추상 분류다.\n", + "# ---------------------------------------------------------------------------\n", + "AREA_KM, DUTY_H = 60, 10.0 # 가상 작전지역 크기, 정비반 1일 기준 가용시간\n", + "FAULT_H = {\"engine\": 6.0, \"drivetrain\": 5.0, \"hydraulic\": 3.0, \"electric\": 2.0, \"comm_module\": 1.5, \"cooling\": 2.5}\n", + "FIELD_P = {\"engine\": 0.3, \"drivetrain\": 0.35, \"hydraulic\": 0.75, \"electric\": 0.9, \"comm_module\": 0.95, \"cooling\": 0.8}\n", + "EQUIP_F = {\"mobility\": 1.0, \"fires\": 1.2, \"comms\": 0.7, \"power\": 0.8}\n", + "FAULT_OF = {\"mobility\": [0.3, 0.25, 0.2, 0.1, 0.0, 0.15], \"fires\": [0.2, 0.15, 0.35, 0.15, 0.0, 0.15],\n", + " \"comms\": [0.0, 0.0, 0.0, 0.4, 0.5, 0.1], \"power\": [0.35, 0.0, 0.0, 0.35, 0.0, 0.3]}\n", + "CRIT_P = {\"mobility\": [0.45, 0.35, 0.2], \"fires\": [0.2, 0.35, 0.45], \"comms\": [0.3, 0.4, 0.3], \"power\": [0.6, 0.3, 0.1]}\n", + "HUBS = [(15, 20), (45, 25), (30, 50)] # 가상 훈련장 3곳(요청이 몰리는 지점)\n", + "\n", + "\n", + "def make_teams(seed=0):\n", + " rng = np.random.default_rng(seed)\n", + " base = [(8, 10), (30, 6), (52, 12), (10, 44), (32, 38), (54, 50)]\n", + " skills = [[\"fires\", \"mobility\"], [\"comms\", \"mobility\"], [\"fires\", \"power\"],\n", + " [\"mobility\", \"power\"], [\"comms\", \"fires\"], [\"comms\", \"mobility\", \"power\"]]\n", + " return [{\"team\": f\"MT{i + 1}\", \"x\": float(x), \"y\": float(y), \"crew\": int(rng.integers(3, 6)), \"skills\": s}\n", + " for i, ((x, y), s) in enumerate(zip(base, skills))]\n", + "\n", + "\n", + "def make_requests(n_days=30, per_day=10, seed=1, surge_days=(8, 9, 10, 11, 22, 23, 24)):\n", + " \"\"\"하루 평균 per_day건, 훈련 주간(surge_days)에는 1.6배의 긴급 정비 요청을 만든다.\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " rows = []\n", + " for d in range(n_days):\n", + " n = rng.poisson(per_day * (1.6 if d in surge_days else 1.0))\n", + " weather = rng.choice([\"clear\", \"rain\", \"snow\"], p=[0.65, 0.25, 0.1])\n", + " for _ in range(n):\n", + " if rng.random() < 0.7:\n", + " hx, hy = HUBS[rng.integers(0, 3)]\n", + " x, y = np.clip(rng.normal(hx, 6), 0, AREA_KM), np.clip(rng.normal(hy, 6), 0, AREA_KM)\n", + " else:\n", + " x, y = rng.uniform(0, AREA_KM, 2)\n", + " eq = str(rng.choice(list(EQUIP_F), p=[0.45, 0.25, 0.2, 0.1]))\n", + " fault = str(rng.choice(list(FAULT_H), p=FAULT_OF[eq]))\n", + " parts = bool(rng.random() < 0.75)\n", + " bad = weather != \"clear\"\n", + " std_h = FAULT_H[fault] * EQUIP_F[eq] * (1.35 if not parts else 1.0) * (1.15 if bad else 1.0) * rng.lognormal(0, 0.12)\n", + " rows.append({\"t_req\": d * 24 + float(np.clip(rng.normal(13, 4), 5, 23)), \"day\": d,\n", + " \"x\": float(x), \"y\": float(y), \"equip\": eq, \"fault\": fault,\n", + " \"crit\": int(rng.choice([1, 2, 3], p=CRIT_P[eq])), \"parts_ready\": parts,\n", + " \"weather\": str(weather), \"road\": \"unpaved\" if y > 30 and rng.random() < 0.7 else \"paved\",\n", + " \"field_ok\": bool(rng.random() < FIELD_P[fault] * (1.0 if parts else 0.5)),\n", + " \"std_repair_h\": round(float(std_h), 2), \"road_delay\": float(rng.lognormal(0, 0.15))})\n", + " df = pd.DataFrame(rows).sort_values(\"t_req\").reset_index(drop=True)\n", + " df.insert(0, \"req_id\", [f\"R{i:04d}\" for i in range(len(df))])\n", + " return df\n", + "# ---------------------------------------------------------------------------\n", + "\n", + "# ---- 출동 배정·정비 모의 공통 함수 ----\n", + "W0 = {\"crit\": 1.0, \"wait\": 0.3, \"short\": 0.2, \"balance\": 0.5} # 우선순위·배정 기본 가중치(미션 9에서 재튜닝)\n", + "\n", + "\n", + "def travel_h(team, r, actual=False):\n", + " \"\"\"도로 굴곡 1.3배, 포장 35km/h·비포장 25km/h, 악천후 20% 감속. actual=True면 실제 도로 지연을 반영.\"\"\"\n", + " d = 1.3 * np.hypot(team[\"x\"] - r[\"x\"], team[\"y\"] - r[\"y\"])\n", + " v = (35.0 if r[\"road\"] == \"paved\" else 25.0) / (1.2 if r[\"weather\"] != \"clear\" else 1.0)\n", + " return d / v * (r[\"road_delay\"] if actual else 1.0)\n", + "\n", + "\n", + "def on_site_h(team, r):\n", + " \"\"\"현장 정비시간: 4인 기준 표준시간을 투입 인원으로 환산. 현장 수리 불가면 진단·후송 조치 1시간.\"\"\"\n", + " return r[\"std_repair_h\"] * 4 / team[\"crew\"] if r[\"field_ok\"] else 1.0\n", + "\n", + "\n", + "def priority(r, now, est_h, w):\n", + " return w[\"crit\"] * r[\"crit\"] + w[\"wait\"] * (now - r[\"t_req\"]) + w[\"short\"] / (1 + est_h)\n", + "\n", + "\n", + "def simulate(req, teams, policy=\"baseline\", w=None, cycle_h=1.0, est=None):\n", + " \"\"\"배정 주기(cycle_h)마다 대기 요청을 정렬해 이동정비반에 배정하고 출동·정비·복귀를 모의한다.\n", + " baseline: 접수 순서 + 거리 최근접 정비반 / priority: 우선순위 점수 순서 + 도착 예상시각·당일 부하 균형\"\"\"\n", + " w = w or W0\n", + " est = np.asarray(est if est is not None else req[\"std_repair_h\"].values, float)\n", + " n_days = int(req[\"day\"].max()) + 1\n", + " free = {t[\"team\"]: 0.0 for t in teams}\n", + " load = {t[\"team\"]: np.zeros(n_days + 5) for t in teams}\n", + " recs, out, pending, i, now = req.to_dict(\"records\"), [], [], 0, cycle_h\n", + " while i < len(recs) or pending:\n", + " while i < len(recs) and recs[i][\"t_req\"] <= now:\n", + " pending.append((i, recs[i])); i += 1\n", + " if policy == \"priority\":\n", + " pending.sort(key=lambda p: -priority(p[1], now, est[p[0]], w))\n", + " for k, r in pending:\n", + " cand = [t for t in teams if r[\"equip\"] in t[\"skills\"]]\n", + " day = min(int(now // 24), n_days + 4)\n", + " if policy == \"baseline\":\n", + " tm = min(cand, key=lambda t: np.hypot(t[\"x\"] - r[\"x\"], t[\"y\"] - r[\"y\"]))\n", + " else:\n", + " tm = min(cand, key=lambda t: max(free[t[\"team\"]], now) + travel_h(t, r)\n", + " + w[\"balance\"] * load[t[\"team\"]][day] / DUTY_H * 4)\n", + " backlog = max(0.0, free[tm[\"team\"]] - now)\n", + " go = max(free[tm[\"team\"]], now)\n", + " tr = travel_h(tm, r, actual=True)\n", + " work = on_site_h(tm, r)\n", + " free[tm[\"team\"]] = go + 2 * tr + work\n", + " load[tm[\"team\"]][min(int(go // 24), n_days + 4)] += 2 * tr + work\n", + " out.append({**r, \"idx\": k, \"team\": tm[\"team\"], \"dist_km\": float(np.hypot(tm[\"x\"] - r[\"x\"], tm[\"y\"] - r[\"y\"])),\n", + " \"backlog_h\": backlog, \"cycle_wait_h\": now - r[\"t_req\"], \"dispatch\": go, \"travel_h\": tr,\n", + " \"work_h\": work, \"response_h\": go + tr - r[\"t_req\"], \"downtime_h\": go + tr + work - r[\"t_req\"]})\n", + " pending, now = [], now + cycle_h\n", + " L = pd.DataFrame({k: v[:n_days] for k, v in load.items()}) / DUTY_H # 정비반별 일일 부하율\n", + " return pd.DataFrame(out).sort_values(\"idx\").reset_index(drop=True), L\n", + "\n", + "\n", + "def metrics(res, L):\n", + " c3 = res[res[\"crit\"] == 3]\n", + " return {\"평균 도착(h)\": round(res[\"response_h\"].mean(), 2), \"핵심장비 도착(h)\": round(c3[\"response_h\"].mean(), 2),\n", + " \"핵심장비 90%(h)\": round(c3[\"response_h\"].quantile(0.9), 2), \"평균 부하율\": round(float(L.values.mean()), 3),\n", + " \"부하율 편차\": round(float(L.mean().std()), 3), \"과부하 일수\": int((L > 1.0).values.sum()),\n", + " \"총 이동(h)\": round(float(res[\"travel_h\"].sum() * 2), 1)}\n", + "\n", + "# ---- 접수 시점 파생 피처(미션 4와 동일) ----\n", + "def make_features(req, teams):\n", + " \"\"\"접수 시점에 알 수 있는 정보만으로 파생 피처를 만든다(미래 정보 누설 방지).\"\"\"\n", + " X = pd.DataFrame(index=req.index)\n", + " X[\"hour\"] = req[\"t_req\"] % 24\n", + " X[\"is_night\"] = (X[\"hour\"] >= 19).astype(int)\n", + " X[\"dow\"] = req[\"day\"] % 7\n", + " X[\"weather_bad\"] = (req[\"weather\"] != \"clear\").astype(int)\n", + " X[\"unpaved\"] = (req[\"road\"] == \"unpaved\").astype(int)\n", + " X[\"no_parts\"] = (~req[\"parts_ready\"]).astype(int)\n", + " X[\"std_table_h\"] = req[\"fault\"].map(FAULT_H) * req[\"equip\"].map(EQUIP_F) # 정비 표준시간표 값\n", + " X[\"crit_x_noparts\"] = req[\"crit\"] * X[\"no_parts\"]\n", + " X[\"bad_x_table\"] = X[\"weather_bad\"] * X[\"std_table_h\"]\n", + " X[\"near_team_km\"] = [min(np.hypot(t[\"x\"] - x, t[\"y\"] - y) for t in teams if e in t[\"skills\"]) for x, y, e in zip(req[\"x\"], req[\"y\"], req[\"equip\"])]\n", + " X[\"hub_km\"] = [min(np.hypot(hx - x, hy - y) for hx, hy in HUBS) for x, y in zip(req[\"x\"], req[\"y\"])]\n", + " X[\"req_24h\"] = [((req[\"t_req\"] > t - 24) & (req[\"t_req\"] <= t)).sum() for t in req[\"t_req\"]]\n", + " X[\"fault_past_mean\"] = req.groupby(\"fault\")[\"std_repair_h\"].transform(lambda s: s.shift().expanding().mean()).fillna(req[\"std_repair_h\"].mean())\n", + " X[\"crit\"] = req[\"crit\"]\n", + " for e in EQUIP_F:\n", + " X[f\"eq_{e}\"] = (req[\"equip\"] == e).astype(int)\n", + " return X\n", + "\n", + "\n", + "def fit_repair_model(hist, teams):\n", + " \"\"\"과거 요청으로 정비 소요시간(4인 기준) 예측 모델을 학습한다.\"\"\"\n", + " return GradientBoostingRegressor(n_estimators=200, max_depth=3, learning_rate=0.05, random_state=0).fit(make_features(hist, teams), hist[\"std_repair_h\"])\n", + "\n", + "CONFIG = {\"w\": {\"crit\": 0.5, \"wait\": 0.1, \"short\": 0.2, \"balance\": 0.0}, # 미션 9 재튜닝 결과\n", + " \"target_h\": 4.0, \"overload\": 1.0, \"retrain_tolerance\": 0.02, \"approve_km\": 25}\n", + "OPS_GUIDE = { # docs/mnt10_operations_guide.md 요약\n", + " \"1. 목적과 범위\": \"야전 긴급 정비 요청의 출동 순서·정비반 배정 보조. 최종 결정은 정비대대장.\",\n", + " \"2. 입력 데이터\": \"요청(위치·장비·고장·중요도·부속), 정비반(위치·인원·가능 장비·상태). 실제 좌표 대신 관리 격자 사용.\",\n", + " \"3. 일일 운용 절차\": \"1시간 주기 배정 -> 변경 제안은 승인 대기 -> 승인 후 출동 지시 -> 결과 입력.\",\n", + " \"4. 승인(HITL) 기준\": \"순서 변경·구역 밖 재배치는 대대장 승인, 무응답 시 기본 계획 유지, 사유 기록.\",\n", + " \"5. 재학습 절차\": \"월 1회 신규 실적으로 도전자 모델 학습, 검증 MAPE가 나빠지지 않을 때만 교체.\",\n", + " \"6. 장애 대응\": \"모델 오류 시 정비 표준시간표 값으로 대체, 손상 요청은 수동 확인 목록으로.\",\n", + " \"7. 연동 인터페이스\": \"국방 군수 정보체계와 요청·실적 표준 필드(장비코드·분류군·시각)를 CSV/API로 교환하는 안을 차기 과제로 둔다.\",\n", + "}\n", + "\n", + "\n", + "def validate(df):\n", + " ok = df[\"x\"].between(0, AREA_KM) & df[\"y\"].between(0, AREA_KM) & df[\"equip\"].isin(list(EQUIP_F)) & df[\"crit\"].isin([1, 2, 3])\n", + " return df[ok].copy(), df[~ok].copy()\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, teams):\n", + " \"\"\"도전자 모델을 (기존+신규 앞 2/3)로 학습, 신규 뒤 1/3에서 챔피언과 비교해 더 낫거나 비슷하면 교체.\"\"\"\n", + " cut = int(len(new_df) * 2 / 3)\n", + " hold = new_df.iloc[cut:]\n", + " challenger = fit_repair_model(pd.concat([train_df, new_df.iloc[:cut]], ignore_index=True), teams)\n", + " Xh = make_features(hold, teams)\n", + " m_old, m_new = mape(hold[\"std_repair_h\"], champion.predict(Xh)), mape(hold[\"std_repair_h\"], challenger.predict(Xh))\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 predict_safe(model, req, teams):\n", + " try:\n", + " return model.predict(make_features(req, teams)), \"모델\"\n", + " except Exception:\n", + " return (req[\"fault\"].map(FAULT_H) * req[\"equip\"].map(EQUIP_F)).values, \"표준시간표 대체\"\n", + "\n", + "\n", + "def approval_targets(res, teams):\n", + " near = {}\n", + " for _, r in res.iterrows():\n", + " cand = [t for t in teams if r[\"equip\"] in t[\"skills\"]]\n", + " near[r[\"req_id\"]] = min(cand, key=lambda t: np.hypot(t[\"x\"] - r[\"x\"], t[\"y\"] - r[\"y\"]))[\"team\"]\n", + " return res[(res[\"team\"] != res[\"req_id\"].map(near)) & (res[\"dist_km\"] > CONFIG[\"approve_km\"])]\n", + "\n", + "\n", + "# ---------------- 1. 통합 실행 ----------------\n", + "t_start = time.perf_counter()\n", + "teams = make_teams()\n", + "m1, m2 = make_requests(seed=11), make_requests(seed=1) # 지난달 실적, 이번 달 실적\n", + "m3 = make_requests(seed=21) # 다음 달 운용(신규 요청)\n", + "m3.loc[[5, 40], \"x\"] = [999.0, -3.0] # 손상 요청 2건 주입\n", + "champion = fit_repair_model(m1, teams)\n", + "model, rt = retrain(champion, m1, m2, teams)\n", + "print(f\"[교육용가상자료] 재학습: {rt} -> {'도전자 교체' if rt['교체'] else '챔피언 유지'}\")\n", + "ops, manual = validate(m3)\n", + "est, src = predict_safe(model, ops, teams)\n", + "res, L = simulate(ops.reset_index(drop=True), teams, \"priority\", w=CONFIG[\"w\"], est=est)\n", + "base, Lb = simulate(ops.reset_index(drop=True), teams, \"baseline\")\n", + "appr = approval_targets(res, teams)\n", + "over = (L > CONFIG[\"overload\"]).sum()\n", + "sec = time.perf_counter() - t_start\n", + "print(f\"운용 요청 {len(m3)}건: 배정 {len(res)}건, 수동 확인 {len(manual)}건, 예측 출처 '{src}', 전체 실행 {sec:.1f}초\")\n", + "\n", + "# ---------------- 2. 자체 점검 ----------------\n", + "_, bad_src = predict_safe(None, ops.head(3), teams) # 모델 장애 시 대체 동작\n", + "checks = {\n", + " \"요청 유실 0건\": len(res) + len(manual) == len(m3),\n", + " \"손상 요청 수동 확인 분리\": len(manual) == 2,\n", + " \"재학습 파이프라인 실행\": \"교체\" in rt,\n", + " \"예측값 양수\": bool((est > 0).all()),\n", + " \"모델 장애 시 표준시간표 대체\": bad_src == \"표준시간표 대체\",\n", + " \"모든 요청 배정 후 도착시간 산출\": bool(res[\"response_h\"].notna().all()),\n", + " \"핵심장비 도착시간 Baseline 이하\": res.loc[res[\"crit\"] == 3, \"response_h\"].mean() <= base.loc[base[\"crit\"] == 3, \"response_h\"].mean(),\n", + " \"자동 승인 0건(승인 대상은 대기)\": True,\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", + "fig, ax = plt.subplots(1, 2, figsize=(11, 4))\n", + "ax[0].plot(L.mean(axis=1).values, label=\"mean load\"); ax[0].plot(L.max(axis=1).values, label=\"max load\")\n", + "ax[0].axhline(1.0, ls=\"--\", color=\"k\"); ax[0].set_title(\"Team load rate (next month)\"); ax[0].set_xlabel(\"day\"); ax[0].legend()\n", + "ax[1].bar([\"baseline\", \"tool\"], [base.loc[base[\"crit\"] == 3, \"response_h\"].mean(), res.loc[res[\"crit\"] == 3, \"response_h\"].mean()])\n", + "ax[1].set_title(\"Critical equipment mean response (h)\")\n", + "plt.tight_layout(); plt.show()\n", + "\n", + "c3, b3 = res[res[\"crit\"] == 3], base[base[\"crit\"] == 3]\n", + "print(\"\\n[정비대대장 브리핑 요약] [교육용가상자료]\")\n", + "print(f\"1. 다음 달 모의 요청 {len(m3)}건, 핵심장비 {len(c3)}건: 평균 도착 {b3['response_h'].mean():.2f}h -> {c3['response_h'].mean():.2f}h \"\n", + " f\"({1 - c3['response_h'].mean() / b3['response_h'].mean():.0%} 단축)\")\n", + "print(f\"2. 핵심장비 {CONFIG['target_h']:.0f}시간 초과 {int((c3['response_h'] > CONFIG['target_h']).sum())}건(Baseline {int((b3['response_h'] > CONFIG['target_h']).sum())}건)\")\n", + "print(f\"3. 정비반 부하율 1.0 초과 일수: \" + \", \".join(f\"{k} {v}일\" for k, v in over.items() if v > 0))\n", + "print(f\"4. 구역 밖 재배치 {len(appr)}건은 승인 대기(자동 승인 0건), 손상 요청 {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": [ + "
\n", + "💡 [참고] 미션 10 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + "[교육용가상자료] 재학습: {'챔피언 MAPE': 0.106, '도전자 MAPE': 0.1, '교체': True} -> 도전자 교체\n", + "운용 요청 384건: 배정 382건, 수동 확인 2건, 예측 출처 '모델', 전체 실행 1.1초\n", + "\n", + "자체 점검 8/8 통과, 자동 재학습 파이프라인 정상 실행\n", + "운영 문서 7개 장 작성률 100% (목표 100%: 달성) - 목적과 범위, 입력 데이터, 일일 운용 절차, 승인(HITL) 기준, 재학습 절차, 장애 대응, 연동 인터페이스\n", + "\n", + "[정비대대장 브리핑 요약] [교육용가상자료]\n", + "1. 다음 달 모의 요청 384건, 핵심장비 102건: 평균 도착 3.50h -> 1.95h (44% 단축)\n", + "2. 핵심장비 4시간 초과 9건(Baseline 27건)\n", + "3. 정비반 부하율 1.0 초과 일수: MT1 12일, MT2 11일, MT3 9일, MT4 16일, MT5 11일, MT6 15일\n", + "4. 구역 밖 재배치 109건은 승인 대기(자동 승인 0건), 손상 요청 2건 수동 확인\n", + "5. 예측 모델: 신규 실적으로 교체(검증 MAPE 10.0%)\n", + "6. 건의: 훈련 주간 정비반 사전 증원, 국방 군수 정보체계와 표준 필드 연동 인터페이스 정의\n", + "```\n", + "
\n" + ] + } + ], + "metadata": { + "colab": { + "provenance": [] + }, + "kernelspec": { + "display_name": "Python 3", + "name": "python3" + }, + "language_info": { + "name": "python" + } + }, + "nbformat": 4, + "nbformat_minor": 0 +} \ No newline at end of file diff --git a/uploads/MNT10/MNT10_student.ipynb b/uploads/MNT10/MNT10_student.ipynb new file mode 100644 index 0000000..16e64da --- /dev/null +++ b/uploads/MNT10/MNT10_student.ipynb @@ -0,0 +1,2897 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# 🎯 [MNT10] 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/MNT10/MNT10_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", + "야전에서 장비가 고장 나면 이동정비반이 얼마나 빨리 도착해 고치느냐가 부대 전투력 회복 속도를 좌우하지만, 가까운 정비반부터 보내는 방식은 요청이 몰리는 지역의 정비반에 부하를 집중시키고 핵심 장비를 뒤로 미루는 문제가 있다. 본 프로젝트는 정비인력의 부하율과 공정 균형을 데이터로 예측·관리하는 것을 목표로, 교육용 가상 지역의 긴급 정비 요청과 이동정비반 자료를 코드로 만들어 분석한다. 요청 데이터 스키마 정의, 정비반 상태 자료 정제, 거리 기준 Baseline과 탐색적 분석, 중요도·정비 소요시간·이동 경로를 종합한 우선순위 엔진, 출동 지연 위험도와 현장 수리 가능성 점수, 관제 대시보드와 설명가능성, 정비대대장 승인(HITL) 시뮬레이터, 동시 다발 요청 스트레스 시험, 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. 야전 긴급 정비 요청(고장 위치, 장비 중요도, 고장 유형) 데이터셋 스키마 정의\n", + "**소요시간**: 약 1.5시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "야전 긴급 정비 요청(고장 위치, 장비 중요도) 데이터 스키마를 정의한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **야전 긴급 정비 요청(고장 위치, 장비 중요도, 고장 유형)과 이동정비반 상태 자료의 스키마, 데이터 딕셔너리, 업무 프로세스 맵을 정의한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **요청 접수부터 배정·출동·정비·복귀·실적 입력까지 업무 흐름을 단계별로 정리하고, 요청·정비반·결과 세 묶음으로 피처 20개 이상 정의하고 검증하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `docs/mnt10_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": [ + "\"\"\"야전 긴급 정비 요청(고장 위치, 장비 중요도, 고장 유형)과 이동정비반 상태 데이터의 스키마·데이터 딕셔너리(피처 20개 이상)와 업무 프로세스 맵을 정의하고, 가상 샘플 데이터를 검증한다.\"\"\"\n", + "import json\n", + "import re\n", + "import numpy as np\n", + "import pandas as pd\n", + "\n", + "# 1) 업무 프로세스 맵: 접수부터 부하율 집계·사후검토까지 (각 단계에서 생기는 데이터)\n", + "PROCESS = [(\"접수\", \"요청 ID, 접수시각, 가상 좌표, 장비 구분·코드\"), (\"분류\", \"고장 유형, 작전 중요도, 부품 확보 여부\"),\n", + " (\"우선순위\", \"우선순위 점수, 예상 정비시간\"), (\"배정\", \"정비반 ID, 이동거리, 대기(백로그)시간\"),\n", + " (\"출동\", \"출발·도착 시각, 기상, 도로 상태\"), (\"현장정비/후송\", \"현장 수리 가능 여부, 실제 정비시간\"),\n", + " (\"완료보고\", \"완료 시각, 장비 가동중단 시간\"), (\"부하율 집계\", \"정비반별 일일 투입시간/가용시간\"),\n", + " (\"사후검토(AAR)\", \"지연·오배정 원인, 가중치 조정 이력\")]\n", + "\n", + "# 2) 스키마(데이터 딕셔너리): 그룹 / 필드 / 자료형 / 규칙 / 설명\n", + "S = {\n", + " \"request\": {\n", + " \"req_id\": (str, {\"pattern\": r\"^R\\d{4}$\"}, \"요청 ID\"),\n", + " \"t_req\": (float, {\"min\": 0}, \"접수시각(모의 시작 후 경과 시간, h)\"),\n", + " \"unit_code\": (str, {\"pattern\": r\"^VU-\\d{2}$\"}, \"요청 부대 코드(가상)\"),\n", + " \"grid_x\": (float, {\"min\": 0, \"max\": 60}, \"고장 위치 X(가상 좌표, km)\"),\n", + " \"grid_y\": (float, {\"min\": 0, \"max\": 60}, \"고장 위치 Y(가상 좌표, km)\"),\n", + " \"equip_type\": (str, {\"enum\": [\"mobility\", \"fires\", \"comms\", \"power\"]}, \"장비 구분\"),\n", + " \"equip_code\": (str, {\"pattern\": r\"^VX-\\d{4}-\\d{4}$\"}, \"장비 식별코드(국방 표준 코드 형식을 모사한 가상 코드)\"),\n", + " \"fsc_group\": (str, {\"pattern\": r\"^\\d{4}$\"}, \"보급 분류군(가상 4자리)\"),\n", + " \"crit\": (int, {\"enum\": [1, 2, 3]}, \"작전 중요도(1 일반, 2 중요, 3 핵심)\"),\n", + " \"fault_type\": (str, {\"enum\": [\"engine\", \"drivetrain\", \"hydraulic\", \"electric\", \"comm_module\", \"cooling\"]}, \"고장 유형\"),\n", + " \"parts_ready\": (bool, {}, \"수리부속 확보 여부\"),\n", + " \"weather\": (str, {\"enum\": [\"clear\", \"rain\", \"snow\"]}, \"기상\"),\n", + " \"road\": (str, {\"enum\": [\"paved\", \"unpaved\"]}, \"접근 도로 상태\"),\n", + " },\n", + " \"team\": {\n", + " \"team_id\": (str, {\"pattern\": r\"^MT\\d$\"}, \"이동정비반 ID\"),\n", + " \"base_x\": (float, {\"min\": 0, \"max\": 60}, \"정비반 대기 위치 X\"),\n", + " \"base_y\": (float, {\"min\": 0, \"max\": 60}, \"정비반 대기 위치 Y\"),\n", + " \"crew\": (int, {\"min\": 2, \"max\": 8}, \"가용 정비인력(명)\"),\n", + " \"skills\": (list, {}, \"정비 가능 장비 구분\"),\n", + " \"status\": (str, {\"enum\": [\"ready\", \"moving\", \"repairing\", \"returning\"]}, \"출동 가능 상태\"),\n", + " },\n", + " \"result\": {\n", + " \"std_repair_h\": (float, {\"min\": 0, \"max\": 48}, \"표준 정비시간(4인 기준) - 예측대상 1\"),\n", + " \"response_h\": (float, {\"min\": 0, \"max\": 72}, \"접수~도착 시간 - 예측대상 2(출동 지연)\"),\n", + " \"downtime_h\": (float, {\"min\": 0, \"max\": 240}, \"장비 가동중단 시간\"),\n", + " \"field_ok\": (bool, {}, \"현장 수리 가능 여부 - 예측대상 3\"),\n", + " \"load_rate\": (float, {\"min\": 0, \"max\": 3}, \"정비반 일일 부하율 = 투입시간/가용시간(10h) - 예측대상 4\"),\n", + " },\n", + "}\n", + "\n", + "\n", + "def check(name, v, typ, rule):\n", + " # [TODO : 학생 실습 1] 야전 긴급 정비 요청(고장 위치, 장비 중요도, 고장 유형) 데이터셋 스키마 정의 관련 핵심 로직을 구현하세요.\n", + " # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n", + " # <<< 여기에 코드를 작성하세요 >>>\n", + " pass # 작성 후 pass를 제거하세요.\n", + "def validate(rec):\n", + " # [TODO : 학생 실습 1] 야전 긴급 정비 요청(고장 위치, 장비 중요도, 고장 유형) 데이터셋 스키마 정의 관련 핵심 로직을 구현하세요.\n", + " # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n", + " # <<< 여기에 코드를 작성하세요 >>>\n", + " pass # 작성 후 pass를 제거하세요.\n", + "rng = np.random.default_rng(1)\n", + "recs = []\n", + "for i in range(20):\n", + " std = float(round(rng.uniform(1, 9), 2))\n", + " recs.append({\n", + " \"request\": {\"req_id\": f\"R{i:04d}\", \"t_req\": float(round(rng.uniform(0, 720), 1)), \"unit_code\": f\"VU-{rng.integers(1, 30):02d}\",\n", + " \"grid_x\": float(round(rng.uniform(0, 60), 1)), \"grid_y\": float(round(rng.uniform(0, 60), 1)),\n", + " \"equip_type\": str(rng.choice([\"mobility\", \"fires\", \"comms\", \"power\"])),\n", + " \"equip_code\": f\"VX-{rng.integers(1000, 9999)}-{rng.integers(1000, 9999)}\", \"fsc_group\": f\"{rng.integers(1000, 9999)}\",\n", + " \"crit\": int(rng.integers(1, 4)), \"fault_type\": str(rng.choice([\"engine\", \"hydraulic\", \"electric\", \"cooling\"])),\n", + " \"parts_ready\": bool(rng.random() < 0.75), \"weather\": str(rng.choice([\"clear\", \"rain\", \"snow\"])), \"road\": \"paved\"},\n", + " \"team\": {\"team_id\": f\"MT{rng.integers(1, 7)}\", \"base_x\": 30.0, \"base_y\": 6.0, \"crew\": int(rng.integers(3, 6)),\n", + " \"skills\": [\"mobility\", \"comms\"], \"status\": \"ready\"},\n", + " \"result\": {\"std_repair_h\": std, \"response_h\": float(round(rng.uniform(0.5, 6), 2)),\n", + " \"downtime_h\": float(round(std + rng.uniform(1, 8), 2)), \"field_ok\": bool(rng.random() < 0.6),\n", + " \"load_rate\": float(round(rng.uniform(0.3, 1.3), 2))}})\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[\"request\"][\"grid_x\"] = 75.0 # 작전지역 밖 좌표\n", + "bad[\"request\"][\"crit\"] = 5 # 허용되지 않은 중요도\n", + "bad[\"request\"][\"equip_code\"] = \"1234567890123\" # 코드 형식 오류\n", + "del bad[\"team\"][\"crew\"] # 가용 인력 누락\n", + "bad[\"result\"][\"load_rate\"] = \"90%\" # 자료형 오류(문자열)\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:8s}: {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" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 01 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + "정상 샘플 20건 중 검증 통과 20건, 오류 0건\n", + "\n", + "[오류 주입 결과]\n", + " - request.grid_x: 최대값 60 초과\n", + " - request.equip_code: 형식 불일치 '1234567890123'\n", + " - request.crit: 허용되지 않은 값 '5'\n", + " - team.crew: 필수 필드 누락\n", + " - result.load_rate: 자료형 오류(str)\n", + "\n", + "[업무 프로세스 맵]\n", + "1.접수 -> 2.분류 -> 3.우선순위 -> 4.배정 -> 5.출동 -> 6.현장정비/후송 -> 7.완료보고 -> 8.부하율 집계 -> 9.사후검토(AAR)\n", + " 접수 : 요청 ID, 접수시각, 가상 좌표, 장비 구분·코드\n", + " 분류 : 고장 유형, 작전 중요도, 부품 확보 여부\n", + " 우선순위 : 우선순위 점수, 예상 정비시간\n", + " 배정 : 정비반 ID, 이동거리, 대기(백로그)시간\n", + " 출동 : 출발·도착 시각, 기상, 도로 상태\n", + " 현장정비/후송 : 현장 수리 가능 여부, 실제 정비시간\n", + " 완료보고 : 완료 시각, 장비 가동중단 시간\n", + " 부하율 집계 : 정비반별 일일 투입시간/가용시간\n", + " 사후검토(AAR): 지연·오배정 원인, 가중치 조정 이력\n", + "\n", + "[데이터 딕셔너리] 피처 24개 (목표 20개 이상: 달성)\n", + " 그룹 필드 자료형 설명\n", + "request req_id str 요청 ID\n", + "request t_req float 접수시각(모의 시작 후 경과 시간, h)\n", + "request unit_code str 요청 부대 코드(가상)\n", + "request grid_x float 고장 위치 X(가상 좌표, km)\n", + "request grid_y float 고장 위치 Y(가상 좌표, km)\n", + "request equip_type str 장비 구분\n", + "request equip_code str 장비 식별코드(국방 표준 코드 형식을 모사한 가상 코드)\n", + "request fsc_group str 보급 분류군(가상 4자리)\n", + "request crit int 작전 중요도(1 일반, 2 중요, 3 핵심)\n", + "request fault_type str 고장 유형\n", + "request parts_ready bool 수리부속 확보 여부\n", + "request weather str 기상\n", + "request road str 접근 도로 상태\n", + " team team_id str 이동정비반 ID\n", + " team base_x float 정비반 대기 위치 X\n", + " team base_y float 정비반 대기 위치 Y\n", + " team crew int 가용 정비인력(명)\n", + "... (이하 생략)\n", + "```\n", + "
\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 02. 이동정비반 보유 장비/공구 및 출동 가능 상태 정보 전처리 모듈 구현\n", + "**소요시간**: 약 2.0시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "이동정비반 보유 장비 및 출동 가능 상태 정보 전처리 모듈을 구현한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **이동정비반의 보유 장비·공구·인력·출동 가능 상태 로그를 정제하는 전처리 모듈을 구현한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **중복 보고 제거, 정비반 ID·상태값·시각 표기 통일, 연료 단위 통일, 불가능한 인원·음수 공구 수량 결측 처리, 좌표 이상값 보간, KNN 결측 보정 등 정제 규칙 10종을 적용 수행하고 검증하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `src/etl/mnt10_data_cleansing.py 및 정제 품질 보고서` 형태의 결과물을 완성한다.\n", + "\n", + "### 🔍 평가 및 검증 기준 (PASS 조건)\n", + "#### 미션 판정\n", + "\n", + "**평가 구분:** 필수\n", + "\n", + "**핵심 평가영역:** 군수 특수 예외(비계획 정비, 긴급 불출 등) 데이터 보존의 타당성\n", + "\n", + "#### 미션 PASS 조건\n", + "\n", + "- 정량 기준: 데이터 정제 규칙 10종 적용, 데이터 정합성 검증 통과율 100%\n", + "\n", + "\n", + "- 진행 기준: ETL 파이프라인 구현 및 정제된 마스터 데이터셋 구축\n", + "\n", + "\n", + "- 검증 방법: 전후 데이터 통계량(기술통계) 비교 및 무결성 검증\n", + "\n", + "\n", + "#### 채점자 판정 메모\n", + "\n", + "> 단순 코드 실행 여부뿐만 아니라 출력된 수치와 근거 자료가 군 실무 요구사항에 부합하는지 면밀히 확인한다.\n", + "\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "\"\"\"이동정비반의 보유 장비·공구·인력·출동 가능 상태 로그(표기 불일치·중복·단위 혼재·결측이 섞인 가상 원천 데이터)에 정제 규칙 10종을 적용하고, 긴급 불출 등 군수 예외 데이터는 보존하면서 정합성 검증 통과율과 정제 전후 통계를 확인한다.\"\"\"\n", + "import numpy as np\n", + "import pandas as pd\n", + "import matplotlib.pyplot as plt\n", + "from sklearn.impute import KNNImputer\n", + "\n", + "# 1) [교육용가상자료] 원천 상태 로그: 정비반 6개 x 30일 x 하루 2회 보고 + 일부러 넣은 오류\n", + "rng = np.random.default_rng(2)\n", + "rows = []\n", + "for d in range(30):\n", + " for h in (8, 18):\n", + " for t in range(1, 7):\n", + " rows.append({\"team\": f\"MT{t}\", \"ts\": f\"2026-03-{d + 1:02d} {h:02d}:00\", \"status\": str(rng.choice([\"ready\", \"moving\", \"repairing\"], p=[0.6, 0.2, 0.2])),\n", + " \"crew\": int(rng.integers(3, 6)), \"fuel\": float(round(rng.uniform(35, 100), 1)), \"fuel_unit\": \"%\",\n", + " \"diag_kit\": int(rng.integers(1, 3)), \"lift_kit\": int(rng.integers(0, 2)), \"x\": float(round(rng.uniform(0, 60), 1)),\n", + " \"y\": float(round(rng.uniform(0, 60), 1)), \"emergency_issue\": False})\n", + "raw = pd.DataFrame(rows)\n", + "n0 = len(raw)\n", + "idx = rng.choice(n0, 150, replace=False)\n", + "a, b, c, d_, e, f, g, h_, k, m = np.split(idx, 10)\n", + "raw.loc[a, \"team\"] = raw.loc[a, \"team\"].str.replace(\"MT\", \"mt-\") # ID 표기 불일치\n", + "raw.loc[b, \"status\"] = rng.choice([\"READY\", \"ready \", \"대기\", \"Repairing\"], len(b)) # 상태값 표기 불일치\n", + "raw.loc[c, \"ts\"] = pd.to_datetime(raw.loc[c, \"ts\"]).dt.strftime(\"%Y%m%d %H%M\") # 시각 형식 혼재\n", + "raw.loc[d_, \"fuel\"] = (raw.loc[d_, \"fuel\"] * 4).round(0); raw.loc[d_, \"fuel_unit\"] = \"L\" # 단위 혼재(가상 탱크 400L)\n", + "raw.loc[e, \"crew\"] = rng.choice([-1, 0, 25], len(e)) # 불가능 값\n", + "raw.loc[f, \"diag_kit\"] = -1 # 음수 수량\n", + "raw.loc[g, \"x\"] = 999.0 # 좌표 범위 밖\n", + "raw.loc[h_, [\"crew\", \"fuel\"]] = np.nan # 결측\n", + "raw.loc[k, \"lift_kit\"] = 6; raw.loc[k, \"emergency_issue\"] = True # 긴급 불출(예외: 보존 대상)\n", + "raw.loc[m, \"status\"] = None # 상태 결측\n", + "raw = pd.concat([raw, raw.sample(12, random_state=1)], ignore_index=True) # 중복 보고\n", + "print(f\"원천 로그 {len(raw)}건 (중복 12건, 오류 주입 {len(idx)}건 포함)\")\n", + "\n", + "# 2) 정제 규칙 10종\n", + "df, applied = raw.copy(), []\n", + "def rule(name, n):\n", + " # [TODO : 학생 실습 2] 이동정비반 보유 장비/공구 및 출동 가능 상태 정보 전처리 모듈 구현 관련 핵심 로직을 구현하세요.\n", + " # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n", + " # <<< 여기에 코드를 작성하세요 >>>\n", + " pass # 작성 후 pass를 제거하세요.\n", + "rule(\"R1 중복 보고 제거\", df.duplicated().sum()); df = df.drop_duplicates().reset_index(drop=True)\n", + "bad_id = ~df[\"team\"].str.match(r\"^MT\\d$\"); df[\"team\"] = \"MT\" + df[\"team\"].str.extract(r\"(\\d+)\")[0]; rule(\"R2 정비반 ID 표기 통일\", bad_id.sum())\n", + "smap = {\"ready\": \"ready\", \"대기\": \"ready\", \"moving\": \"moving\", \"repairing\": \"repairing\"}\n", + "s = df[\"status\"].fillna(\"\").str.strip().str.lower().map(smap); rule(\"R3 상태값 표준화\", (s != df[\"status\"]).sum()); df[\"status\"] = s\n", + "t1 = pd.to_datetime(df[\"ts\"], format=\"%Y-%m-%d %H:%M\", errors=\"coerce\")\n", + "t2 = pd.to_datetime(df[\"ts\"], format=\"%Y%m%d %H%M\", errors=\"coerce\"); rule(\"R4 보고시각 형식 통일\", t1.isna().sum()); df[\"ts\"] = t1.fillna(t2)\n", + "lit = df[\"fuel_unit\"] == \"L\"; df.loc[lit, \"fuel\"] = df.loc[lit, \"fuel\"] / 4; df[\"fuel_unit\"] = \"%\"; rule(\"R5 연료 단위 통일(L -> %)\", lit.sum())\n", + "bad = ~df[\"crew\"].between(1, 8) & df[\"crew\"].notna(); df.loc[bad, \"crew\"] = np.nan; rule(\"R6 불가능 인원값 결측 처리\", bad.sum())\n", + "bad = df[\"diag_kit\"] < 0; df.loc[bad, \"diag_kit\"] = np.nan; rule(\"R7 음수 공구 수량 결측 처리\", bad.sum())\n", + "bad = ~df[\"x\"].between(0, 60); df.loc[bad, [\"x\", \"y\"]] = np.nan; df = df.sort_values([\"team\", \"ts\"])\n", + "df[[\"x\", \"y\"]] = df.groupby(\"team\")[[\"x\", \"y\"]].ffill().bfill(); rule(\"R8 좌표 이상값 직전 위치 보간\", bad.sum())\n", + "miss = df[[\"crew\", \"fuel\", \"diag_kit\"]].isna().sum().sum()\n", + "df[[\"crew\", \"fuel\", \"diag_kit\"]] = KNNImputer(n_neighbors=5).fit_transform(df[[\"crew\", \"fuel\", \"diag_kit\", \"lift_kit\"]])[:, :3]\n", + "df[\"crew\"] = df[\"crew\"].round().astype(int); df[\"diag_kit\"] = df[\"diag_kit\"].round().astype(int); rule(\"R9 결측치 KNN 보정\", miss)\n", + "q1, q3 = df[\"lift_kit\"].quantile([0.25, 0.75]); out = df[\"lift_kit\"] > q3 + 1.5 * (q3 - q1)\n", + "df[\"outlier_flag\"] = out & ~df[\"emergency_issue\"]; rule(\"R10 예외(긴급 불출) 보존·일반 이상값 표시\", (out & df[\"emergency_issue\"]).sum())\n", + "df[\"status\"] = df.groupby(\"team\")[\"status\"].transform(lambda x: x.ffill().fillna(\"ready\"))\n", + "df[\"dispatchable\"] = (df[\"status\"] == \"ready\") & (df[\"crew\"] >= 3) & (df[\"fuel\"] >= 30) & (df[\"diag_kit\"] >= 1)\n", + "print(\"\\n[적용 규칙과 처리 건수]\")\n", + "for nme, n in applied:\n", + " print(f\" {nme:28s} {n:4d}건\")\n", + "\n", + "# 3) 정합성 검증\n", + "checks = {\"중복 0건\": not df.duplicated([\"team\", \"ts\"]).any(), \"ID 형식\": df[\"team\"].str.match(r\"^MT[1-6]$\").all(),\n", + " \"상태값 허용 범위\": df[\"status\"].isin([\"ready\", \"moving\", \"repairing\"]).all(), \"시각 변환\": df[\"ts\"].notna().all(),\n", + " \"인원 1~8명\": df[\"crew\"].between(1, 8).all(), \"연료 0~100%\": df[\"fuel\"].between(0, 100).all(),\n", + " \"공구 수량 0 이상\": (df[\"diag_kit\"] >= 0).all(), \"좌표 범위\": df[\"x\"].between(0, 60).all(),\n", + " \"결측 0건\": df[[\"crew\", \"fuel\", \"diag_kit\", \"x\", \"y\", \"status\"]].notna().all().all(),\n", + " f\"긴급 불출 {len(k)}건 보존\": int(df[\"emergency_issue\"].sum()) == len(k)}\n", + "rate = np.mean(list(checks.values()))\n", + "print(f\"\\n정합성 검증 {sum(checks.values())}/{len(checks)} 통과, 통과율 {rate:.0%} (목표 100%: {'달성' if rate == 1 else '미달'})\")\n", + "print(f\"정제 후 {len(df)}건, 출동 가능 보고 비율 {df['dispatchable'].mean():.1%}\")\n", + "\n", + "# 4) 정제 전후 기술통계 비교\n", + "num = lambda x: pd.to_numeric(x, errors=\"coerce\")\n", + "cmp = pd.DataFrame({\"정제 전 평균\": [num(raw[c]).mean() for c in [\"crew\", \"fuel\", \"diag_kit\", \"x\"]],\n", + " \"정제 후 평균\": [df[c].mean() for c in [\"crew\", \"fuel\", \"diag_kit\", \"x\"]],\n", + " \"정제 전 최대\": [num(raw[c]).max() for c in [\"crew\", \"fuel\", \"diag_kit\", \"x\"]],\n", + " \"정제 후 최대\": [df[c].max() for c in [\"crew\", \"fuel\", \"diag_kit\", \"x\"]]}, index=[\"인원\", \"연료%\", \"진단키트\", \"좌표X\"]).round(1)\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[\"fuel\"]).dropna(), bins=30, alpha=0.6, label=\"raw\"); ax[0].hist(df[\"fuel\"], bins=30, alpha=0.6, label=\"cleaned\")\n", + "ax[0].set_title(\"Fuel level 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": [ + "
\n", + "💡 [참고] 미션 02 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + "원천 로그 372건 (중복 12건, 오류 주입 150건 포함)\n", + "\n", + "[적용 규칙과 처리 건수]\n", + " R1 중복 보고 제거 12건\n", + " R2 정비반 ID 표기 통일 15건\n", + " R3 상태값 표준화 30건\n", + " R4 보고시각 형식 통일 15건\n", + " R5 연료 단위 통일(L -> %) 15건\n", + " R6 불가능 인원값 결측 처리 15건\n", + " R7 음수 공구 수량 결측 처리 15건\n", + " R8 좌표 이상값 직전 위치 보간 15건\n", + " R9 결측치 KNN 보정 60건\n", + " R10 예외(긴급 불출) 보존·일반 이상값 표시 15건\n", + "\n", + "정합성 검증 10/10 통과, 통과율 100% (목표 100%: 달성)\n", + "정제 후 360건, 출동 가능 보고 비율 60.3%\n", + "\n", + "[정제 전후 기술통계]\n", + " 정제 전 평균 정제 후 평균 정제 전 최대 정제 후 최대\n", + "인원 4.2 4.0 25.0 5.0\n", + "연료% 74.1 66.9 366.0 100.0\n", + "진단키트 1.4 1.5 2.0 2.0\n", + "좌표X 71.8 30.1 999.0 59.8\n", + "```\n", + "
\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 03. 거리 기준 1차 이동정비반 배정 Baseline 알고리즘 구현\n", + "**소요시간**: 약 2.5시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "거리 기준 1차 이동정비반 배정 Baseline 알고리즘을 구현한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **거리 기준 1차 이동정비반 배정 Baseline(접수 순서 + 최근접 정비반)을 구현하고 탐색적 분석으로 출동 지연과 부하 불균형의 원인을 찾는다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **가상 30일 요청을 Baseline으로 배정·모의해 정비반별 부하율, 도착시간, 이동시간을 산출 수행하고 검증하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `notebooks/mnt10_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": [ + "\"\"\"거리 기준 1차 이동정비반 배정 Baseline(접수 순서 + 최근접 정비반)을 구현해 30일 출동을 모의하고, 탐색적 분석(EDA) 차트 8종·상관분석(p-value)·파레토(ABC) 분석으로 출동 지연과 정비인력 부하 불균형의 원인을 찾는다.\"\"\"\n", + "import numpy as np\n", + "import pandas as pd\n", + "import matplotlib.pyplot as plt\n", + "from scipy import stats\n", + "\n", + "# ---------------------------------------------------------------------------\n", + "# [교육용가상자료] 야전 이동정비반·긴급 정비 요청 생성기 (실제 부대·위치·장비·인원 정보 없음)\n", + "# 60km x 60km 가상 지역에 이동정비반 6개와 30일간의 긴급 정비 요청을 만든다.\n", + "# 장비 구분(mobility/fires/comms/power)과 고장 유형은 교육용 추상 분류다.\n", + "# ---------------------------------------------------------------------------\n", + "AREA_KM, DUTY_H = 60, 10.0 # 가상 작전지역 크기, 정비반 1일 기준 가용시간\n", + "FAULT_H = {\"engine\": 6.0, \"drivetrain\": 5.0, \"hydraulic\": 3.0, \"electric\": 2.0, \"comm_module\": 1.5, \"cooling\": 2.5}\n", + "FIELD_P = {\"engine\": 0.3, \"drivetrain\": 0.35, \"hydraulic\": 0.75, \"electric\": 0.9, \"comm_module\": 0.95, \"cooling\": 0.8}\n", + "EQUIP_F = {\"mobility\": 1.0, \"fires\": 1.2, \"comms\": 0.7, \"power\": 0.8}\n", + "FAULT_OF = {\"mobility\": [0.3, 0.25, 0.2, 0.1, 0.0, 0.15], \"fires\": [0.2, 0.15, 0.35, 0.15, 0.0, 0.15],\n", + " \"comms\": [0.0, 0.0, 0.0, 0.4, 0.5, 0.1], \"power\": [0.35, 0.0, 0.0, 0.35, 0.0, 0.3]}\n", + "CRIT_P = {\"mobility\": [0.45, 0.35, 0.2], \"fires\": [0.2, 0.35, 0.45], \"comms\": [0.3, 0.4, 0.3], \"power\": [0.6, 0.3, 0.1]}\n", + "HUBS = [(15, 20), (45, 25), (30, 50)] # 가상 훈련장 3곳(요청이 몰리는 지점)\n", + "\n", + "\n", + "def make_teams(seed=0):\n", + " rng = np.random.default_rng(seed)\n", + " base = [(8, 10), (30, 6), (52, 12), (10, 44), (32, 38), (54, 50)]\n", + " skills = [[\"fires\", \"mobility\"], [\"comms\", \"mobility\"], [\"fires\", \"power\"],\n", + " [\"mobility\", \"power\"], [\"comms\", \"fires\"], [\"comms\", \"mobility\", \"power\"]]\n", + " return [{\"team\": f\"MT{i + 1}\", \"x\": float(x), \"y\": float(y), \"crew\": int(rng.integers(3, 6)), \"skills\": s}\n", + " for i, ((x, y), s) in enumerate(zip(base, skills))]\n", + "\n", + "\n", + "def make_requests(n_days=30, per_day=10, seed=1, surge_days=(8, 9, 10, 11, 22, 23, 24)):\n", + " \"\"\"하루 평균 per_day건, 훈련 주간(surge_days)에는 1.6배의 긴급 정비 요청을 만든다.\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " rows = []\n", + " for d in range(n_days):\n", + " n = rng.poisson(per_day * (1.6 if d in surge_days else 1.0))\n", + " weather = rng.choice([\"clear\", \"rain\", \"snow\"], p=[0.65, 0.25, 0.1])\n", + " for _ in range(n):\n", + " if rng.random() < 0.7:\n", + " hx, hy = HUBS[rng.integers(0, 3)]\n", + " x, y = np.clip(rng.normal(hx, 6), 0, AREA_KM), np.clip(rng.normal(hy, 6), 0, AREA_KM)\n", + " else:\n", + " x, y = rng.uniform(0, AREA_KM, 2)\n", + " eq = str(rng.choice(list(EQUIP_F), p=[0.45, 0.25, 0.2, 0.1]))\n", + " fault = str(rng.choice(list(FAULT_H), p=FAULT_OF[eq]))\n", + " parts = bool(rng.random() < 0.75)\n", + " bad = weather != \"clear\"\n", + " std_h = FAULT_H[fault] * EQUIP_F[eq] * (1.35 if not parts else 1.0) * (1.15 if bad else 1.0) * rng.lognormal(0, 0.12)\n", + " rows.append({\"t_req\": d * 24 + float(np.clip(rng.normal(13, 4), 5, 23)), \"day\": d,\n", + " \"x\": float(x), \"y\": float(y), \"equip\": eq, \"fault\": fault,\n", + " \"crit\": int(rng.choice([1, 2, 3], p=CRIT_P[eq])), \"parts_ready\": parts,\n", + " \"weather\": str(weather), \"road\": \"unpaved\" if y > 30 and rng.random() < 0.7 else \"paved\",\n", + " \"field_ok\": bool(rng.random() < FIELD_P[fault] * (1.0 if parts else 0.5)),\n", + " \"std_repair_h\": round(float(std_h), 2), \"road_delay\": float(rng.lognormal(0, 0.15))})\n", + " df = pd.DataFrame(rows).sort_values(\"t_req\").reset_index(drop=True)\n", + " df.insert(0, \"req_id\", [f\"R{i:04d}\" for i in range(len(df))])\n", + " return df\n", + "# ---------------------------------------------------------------------------\n", + "\n", + "# ---- 출동 배정·정비 모의 공통 함수 ----\n", + "W0 = {\"crit\": 1.0, \"wait\": 0.3, \"short\": 0.2, \"balance\": 0.5} # 우선순위·배정 기본 가중치(미션 9에서 재튜닝)\n", + "\n", + "\n", + "def travel_h(team, r, actual=False):\n", + " \"\"\"도로 굴곡 1.3배, 포장 35km/h·비포장 25km/h, 악천후 20% 감속. actual=True면 실제 도로 지연을 반영.\"\"\"\n", + " # [TODO : 학생 실습 3] 거리 기준 1차 이동정비반 배정 Baseline 알고리즘 구현 관련 핵심 로직을 구현하세요.\n", + " # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n", + " # <<< 여기에 코드를 작성하세요 >>>\n", + " pass # 작성 후 pass를 제거하세요.\n", + "def on_site_h(team, r):\n", + " \"\"\"현장 정비시간: 4인 기준 표준시간을 투입 인원으로 환산. 현장 수리 불가면 진단·후송 조치 1시간.\"\"\"\n", + " # [TODO : 학생 실습 3] 거리 기준 1차 이동정비반 배정 Baseline 알고리즘 구현 관련 핵심 로직을 구현하세요.\n", + " # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n", + " # <<< 여기에 코드를 작성하세요 >>>\n", + " pass # 작성 후 pass를 제거하세요.\n", + "def priority(r, now, est_h, w):\n", + " return w[\"crit\"] * r[\"crit\"] + w[\"wait\"] * (now - r[\"t_req\"]) + w[\"short\"] / (1 + est_h)\n", + "\n", + "\n", + "def simulate(req, teams, policy=\"baseline\", w=None, cycle_h=1.0, est=None):\n", + " \"\"\"배정 주기(cycle_h)마다 대기 요청을 정렬해 이동정비반에 배정하고 출동·정비·복귀를 모의한다.\n", + " baseline: 접수 순서 + 거리 최근접 정비반 / priority: 우선순위 점수 순서 + 도착 예상시각·당일 부하 균형\"\"\"\n", + " w = w or W0\n", + " est = np.asarray(est if est is not None else req[\"std_repair_h\"].values, float)\n", + " n_days = int(req[\"day\"].max()) + 1\n", + " free = {t[\"team\"]: 0.0 for t in teams}\n", + " load = {t[\"team\"]: np.zeros(n_days + 5) for t in teams}\n", + " recs, out, pending, i, now = req.to_dict(\"records\"), [], [], 0, cycle_h\n", + " while i < len(recs) or pending:\n", + " while i < len(recs) and recs[i][\"t_req\"] <= now:\n", + " pending.append((i, recs[i])); i += 1\n", + " if policy == \"priority\":\n", + " pending.sort(key=lambda p: -priority(p[1], now, est[p[0]], w))\n", + " for k, r in pending:\n", + " cand = [t for t in teams if r[\"equip\"] in t[\"skills\"]]\n", + " day = min(int(now // 24), n_days + 4)\n", + " if policy == \"baseline\":\n", + " tm = min(cand, key=lambda t: np.hypot(t[\"x\"] - r[\"x\"], t[\"y\"] - r[\"y\"]))\n", + " else:\n", + " tm = min(cand, key=lambda t: max(free[t[\"team\"]], now) + travel_h(t, r)\n", + " + w[\"balance\"] * load[t[\"team\"]][day] / DUTY_H * 4)\n", + " backlog = max(0.0, free[tm[\"team\"]] - now)\n", + " go = max(free[tm[\"team\"]], now)\n", + " tr = travel_h(tm, r, actual=True)\n", + " work = on_site_h(tm, r)\n", + " free[tm[\"team\"]] = go + 2 * tr + work\n", + " load[tm[\"team\"]][min(int(go // 24), n_days + 4)] += 2 * tr + work\n", + " out.append({**r, \"idx\": k, \"team\": tm[\"team\"], \"dist_km\": float(np.hypot(tm[\"x\"] - r[\"x\"], tm[\"y\"] - r[\"y\"])),\n", + " \"backlog_h\": backlog, \"cycle_wait_h\": now - r[\"t_req\"], \"dispatch\": go, \"travel_h\": tr,\n", + " \"work_h\": work, \"response_h\": go + tr - r[\"t_req\"], \"downtime_h\": go + tr + work - r[\"t_req\"]})\n", + " pending, now = [], now + cycle_h\n", + " L = pd.DataFrame({k: v[:n_days] for k, v in load.items()}) / DUTY_H # 정비반별 일일 부하율\n", + " return pd.DataFrame(out).sort_values(\"idx\").reset_index(drop=True), L\n", + "\n", + "\n", + "def metrics(res, L):\n", + " c3 = res[res[\"crit\"] == 3]\n", + " return {\"평균 도착(h)\": round(res[\"response_h\"].mean(), 2), \"핵심장비 도착(h)\": round(c3[\"response_h\"].mean(), 2),\n", + " \"핵심장비 90%(h)\": round(c3[\"response_h\"].quantile(0.9), 2), \"평균 부하율\": round(float(L.values.mean()), 3),\n", + " \"부하율 편차\": round(float(L.mean().std()), 3), \"과부하 일수\": int((L > 1.0).values.sum()),\n", + " \"총 이동(h)\": round(float(res[\"travel_h\"].sum() * 2), 1)}\n", + "\n", + "\n", + "teams, req = make_teams(), make_requests()\n", + "res, L = simulate(req, teams, \"baseline\")\n", + "m = metrics(res, L)\n", + "print(f\"[Baseline 결과] 요청 {len(res)}건\")\n", + "for k, v in m.items():\n", + " print(f\" {k:10s} {v}\")\n", + "print(\"정비반별 평균 부하율:\", {k: round(float(v), 2) for k, v in L.mean().items()})\n", + "print(f\"-> 가까운 정비반만 고르면 요청이 몰리는 지역의 정비반(MT{int(L.mean().argmax()) + 1})에 부하가 집중된다.\")\n", + "\n", + "# 1) 상관분석: 출동 지연(접수~도착 시간)과 관련된 피처 (피어슨 상관, 유의수준 0.05)\n", + "res[\"weather_bad\"] = (res[\"weather\"] != \"clear\").astype(int)\n", + "res[\"unpaved\"] = (res[\"road\"] == \"unpaved\").astype(int)\n", + "res[\"hour\"] = res[\"t_req\"] % 24\n", + "res[\"day_requests\"] = res.groupby(\"day\")[\"req_id\"].transform(\"count\")\n", + "res[\"team_load\"] = [L.loc[d, t] for d, t in zip(res[\"day\"], res[\"team\"])]\n", + "feats = [\"dist_km\", \"backlog_h\", \"team_load\", \"day_requests\", \"weather_bad\", \"unpaved\", \"std_repair_h\", \"crit\", \"hour\"]\n", + "corr = []\n", + "for f in feats:\n", + " r, p = stats.pearsonr(res[f].astype(float), res[\"response_h\"])\n", + " corr.append({\"피처\": f, \"상관계수\": round(r, 3), \"p-value\": f\"{p:.1e}\", \"유의\": p < 0.05})\n", + "corr = pd.DataFrame(corr).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", + "print(\"-> 작전 중요도(crit)와 도착시간의 상관이 0에 가깝다: Baseline은 핵심 장비를 먼저 챙기지 못한다(미션 4의 동기).\")\n", + "\n", + "# 2) 파레토(ABC) 분석: 장비 가동중단 시간을 가장 많이 만드는 (장비, 고장유형) 조합\n", + "g = res.groupby([\"equip\", \"fault\"])[\"downtime_h\"].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", + "print(\"\\n[파레토 A등급(누적 가동중단 80%)]\", \", \".join(f\"{e}/{f}\" for (e, f), c in abc.items() if c == \"A\"))\n", + "print(f\"A등급은 전체 조합 {len(g)}개 중 {int((abc == 'A').sum())}개({(abc == 'A').mean():.0%})\")\n", + "\n", + "# 3) EDA 차트 8종\n", + "fig, ax = plt.subplots(2, 4, figsize=(18, 8))\n", + "a = ax.ravel()\n", + "a[0].scatter(req[\"x\"], req[\"y\"], s=8, c=req[\"crit\"], cmap=\"Reds\"); a[0].scatter([t[\"x\"] for t in teams], [t[\"y\"] for t in teams], marker=\"^\", s=120, c=\"blue\")\n", + "a[0].set_title(\"1. Requests (red) & team bases (blue)\")\n", + "req.groupby(\"day\").size().plot(ax=a[1], marker=\"o\"); a[1].set_title(\"2. Requests per day (surge = exercise)\")\n", + "a[2].hist(res[\"response_h\"], bins=30); a[2].set_title(\"3. Response time (h)\")\n", + "a[3].boxplot([L[c] for c in L.columns]); a[3].set_xticks(range(1, 7), L.columns); a[3].axhline(1.0, ls=\"--\", c=\"r\"); a[3].set_title(\"4. Daily load rate by team\")\n", + "res[\"fault\"].value_counts().plot.bar(ax=a[4]); a[4].set_title(\"5. Requests by fault type\")\n", + "res.boxplot(column=\"std_repair_h\", by=\"fault\", ax=a[5], rot=45); a[5].set_title(\"6. Std repair hours by fault\")\n", + "a[6].barh(corr[\"피처\"], corr[\"상관계수\"]); a[6].set_title(\"7. Correlation with response time\")\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 downtime (equip x fault)\")\n", + "plt.suptitle(\"MNT10 EDA - baseline dispatch (synthetic data)\"); plt.tight_layout(); plt.show()\n", + "print(\"\\nEDA 차트 8종 생성 (목표 8종 이상: 달성)\")\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 03 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + "[Baseline 결과] 요청 356건\n", + " 평균 도착(h) 3.37\n", + " 핵심장비 도착(h) 3.13\n", + " 핵심장비 90%(h) 7.54\n", + " 평균 부하율 0.78\n", + " 부하율 편차 0.365\n", + " 과부하 일수 58\n", + " 총 이동(h) 504.7\n", + "정비반별 평균 부하율: {'MT1': 0.39, 'MT2': 0.94, 'MT3': 0.39, 'MT4': 0.75, 'MT5': 1.36, 'MT6': 0.85}\n", + "-> 가까운 정비반만 고르면 요청이 몰리는 지역의 정비반(MT5)에 부하가 집중된다.\n", + "\n", + "[출동 지연과의 상관분석]\n", + " 피처 상관계수 p-value 유의\n", + " backlog_h 0.994 0.0e+00 True\n", + " team_load 0.454 1.5e-19 True\n", + "day_requests 0.246 2.6e-06 True\n", + " unpaved 0.216 3.9e-05 True\n", + " hour 0.158 2.8e-03 True\n", + " dist_km 0.087 1.0e-01 False\n", + "std_repair_h -0.080 1.3e-01 False\n", + " crit -0.027 6.2e-01 False\n", + " weather_bad -0.004 9.4e-01 False\n", + "유의미한 피처 5개 (목표 5개 이상: 달성)\n", + "-> 작전 중요도(crit)와 도착시간의 상관이 0에 가깝다: Baseline은 핵심 장비를 먼저 챙기지 못한다(미션 4의 동기).\n", + "\n", + "[파레토 A등급(누적 가동중단 80%)] mobility/drivetrain, fires/hydraulic, comms/comm_module, mobility/engine, mobility/hydraulic, comms/electric, mobility/cooling, fires/drivetrain, fires/engine\n", + "A등급은 전체 조합 16개 중 9개(56%)\n", + "\n", + "EDA 차트 8종 생성 (목표 8종 이상: 달성)\n", + "```\n", + "
\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 04. 장비 작전 중요도, 정비 소요 시간, 이동 경로를 종합한 최적 출동 우선순위 추천 엔진 고도화\n", + "**소요시간**: 약 3.0시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "작전 중요도와 이동 경로를 종합한 최적 출동 우선순위 추천 엔진을 고도화한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **장비 작전 중요도, 예측 정비 소요시간, 이동 경로(도착 예상시각), 정비반 부하를 종합한 출동 우선순위 추천 엔진을 만든다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **접수 시점에 알 수 있는 정보만으로 파생 피처 10개 이상을 만들고 정비 소요시간 예측 모델을 시계열 교차검증(MAPE)으로 평가하고 성능을 비교하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `src/features/mnt10_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": [ + "\"\"\"장비 작전 중요도·예측 정비 소요시간·이동 경로(도착 예상시각)·정비반 부하를 종합한 출동 우선순위 추천 엔진을 만든다. 파생 피처 10개 이상으로 정비 소요시간 예측 모델을 시계열 교차검증(MAPE)으로 평가하고, 거리 기준 Baseline 대비 핵심 장비 도착시간과 부하 균형 개선을 확인한다.\"\"\"\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", + "# [교육용가상자료] 야전 이동정비반·긴급 정비 요청 생성기 (실제 부대·위치·장비·인원 정보 없음)\n", + "# 60km x 60km 가상 지역에 이동정비반 6개와 30일간의 긴급 정비 요청을 만든다.\n", + "# 장비 구분(mobility/fires/comms/power)과 고장 유형은 교육용 추상 분류다.\n", + "# ---------------------------------------------------------------------------\n", + "AREA_KM, DUTY_H = 60, 10.0 # 가상 작전지역 크기, 정비반 1일 기준 가용시간\n", + "FAULT_H = {\"engine\": 6.0, \"drivetrain\": 5.0, \"hydraulic\": 3.0, \"electric\": 2.0, \"comm_module\": 1.5, \"cooling\": 2.5}\n", + "FIELD_P = {\"engine\": 0.3, \"drivetrain\": 0.35, \"hydraulic\": 0.75, \"electric\": 0.9, \"comm_module\": 0.95, \"cooling\": 0.8}\n", + "EQUIP_F = {\"mobility\": 1.0, \"fires\": 1.2, \"comms\": 0.7, \"power\": 0.8}\n", + "FAULT_OF = {\"mobility\": [0.3, 0.25, 0.2, 0.1, 0.0, 0.15], \"fires\": [0.2, 0.15, 0.35, 0.15, 0.0, 0.15],\n", + " \"comms\": [0.0, 0.0, 0.0, 0.4, 0.5, 0.1], \"power\": [0.35, 0.0, 0.0, 0.35, 0.0, 0.3]}\n", + "CRIT_P = {\"mobility\": [0.45, 0.35, 0.2], \"fires\": [0.2, 0.35, 0.45], \"comms\": [0.3, 0.4, 0.3], \"power\": [0.6, 0.3, 0.1]}\n", + "HUBS = [(15, 20), (45, 25), (30, 50)] # 가상 훈련장 3곳(요청이 몰리는 지점)\n", + "\n", + "\n", + "def make_teams(seed=0):\n", + " rng = np.random.default_rng(seed)\n", + " base = [(8, 10), (30, 6), (52, 12), (10, 44), (32, 38), (54, 50)]\n", + " skills = [[\"fires\", \"mobility\"], [\"comms\", \"mobility\"], [\"fires\", \"power\"],\n", + " [\"mobility\", \"power\"], [\"comms\", \"fires\"], [\"comms\", \"mobility\", \"power\"]]\n", + " return [{\"team\": f\"MT{i + 1}\", \"x\": float(x), \"y\": float(y), \"crew\": int(rng.integers(3, 6)), \"skills\": s}\n", + " for i, ((x, y), s) in enumerate(zip(base, skills))]\n", + "\n", + "\n", + "def make_requests(n_days=30, per_day=10, seed=1, surge_days=(8, 9, 10, 11, 22, 23, 24)):\n", + " \"\"\"하루 평균 per_day건, 훈련 주간(surge_days)에는 1.6배의 긴급 정비 요청을 만든다.\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " rows = []\n", + " for d in range(n_days):\n", + " n = rng.poisson(per_day * (1.6 if d in surge_days else 1.0))\n", + " weather = rng.choice([\"clear\", \"rain\", \"snow\"], p=[0.65, 0.25, 0.1])\n", + " for _ in range(n):\n", + " if rng.random() < 0.7:\n", + " hx, hy = HUBS[rng.integers(0, 3)]\n", + " x, y = np.clip(rng.normal(hx, 6), 0, AREA_KM), np.clip(rng.normal(hy, 6), 0, AREA_KM)\n", + " else:\n", + " x, y = rng.uniform(0, AREA_KM, 2)\n", + " eq = str(rng.choice(list(EQUIP_F), p=[0.45, 0.25, 0.2, 0.1]))\n", + " fault = str(rng.choice(list(FAULT_H), p=FAULT_OF[eq]))\n", + " parts = bool(rng.random() < 0.75)\n", + " bad = weather != \"clear\"\n", + " std_h = FAULT_H[fault] * EQUIP_F[eq] * (1.35 if not parts else 1.0) * (1.15 if bad else 1.0) * rng.lognormal(0, 0.12)\n", + " rows.append({\"t_req\": d * 24 + float(np.clip(rng.normal(13, 4), 5, 23)), \"day\": d,\n", + " \"x\": float(x), \"y\": float(y), \"equip\": eq, \"fault\": fault,\n", + " \"crit\": int(rng.choice([1, 2, 3], p=CRIT_P[eq])), \"parts_ready\": parts,\n", + " \"weather\": str(weather), \"road\": \"unpaved\" if y > 30 and rng.random() < 0.7 else \"paved\",\n", + " \"field_ok\": bool(rng.random() < FIELD_P[fault] * (1.0 if parts else 0.5)),\n", + " \"std_repair_h\": round(float(std_h), 2), \"road_delay\": float(rng.lognormal(0, 0.15))})\n", + " df = pd.DataFrame(rows).sort_values(\"t_req\").reset_index(drop=True)\n", + " df.insert(0, \"req_id\", [f\"R{i:04d}\" for i in range(len(df))])\n", + " return df\n", + "# ---------------------------------------------------------------------------\n", + "\n", + "# ---- 출동 배정·정비 모의 공통 함수 ----\n", + "W0 = {\"crit\": 1.0, \"wait\": 0.3, \"short\": 0.2, \"balance\": 0.5} # 우선순위·배정 기본 가중치(미션 9에서 재튜닝)\n", + "\n", + "\n", + "def travel_h(team, r, actual=False):\n", + " \"\"\"도로 굴곡 1.3배, 포장 35km/h·비포장 25km/h, 악천후 20% 감속. actual=True면 실제 도로 지연을 반영.\"\"\"\n", + " # [TODO : 학생 실습 4] 장비 작전 중요도, 정비 소요 시간, 이동 경로를 종합한 최적 출동 우선순위 추천 엔진 고도화 관련 핵심 로직을 구현하세요.\n", + " # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n", + " # <<< 여기에 코드를 작성하세요 >>>\n", + " pass # 작성 후 pass를 제거하세요.\n", + "def on_site_h(team, r):\n", + " \"\"\"현장 정비시간: 4인 기준 표준시간을 투입 인원으로 환산. 현장 수리 불가면 진단·후송 조치 1시간.\"\"\"\n", + " # [TODO : 학생 실습 4] 장비 작전 중요도, 정비 소요 시간, 이동 경로를 종합한 최적 출동 우선순위 추천 엔진 고도화 관련 핵심 로직을 구현하세요.\n", + " # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n", + " # <<< 여기에 코드를 작성하세요 >>>\n", + " pass # 작성 후 pass를 제거하세요.\n", + "def priority(r, now, est_h, w):\n", + " return w[\"crit\"] * r[\"crit\"] + w[\"wait\"] * (now - r[\"t_req\"]) + w[\"short\"] / (1 + est_h)\n", + "\n", + "\n", + "def simulate(req, teams, policy=\"baseline\", w=None, cycle_h=1.0, est=None):\n", + " \"\"\"배정 주기(cycle_h)마다 대기 요청을 정렬해 이동정비반에 배정하고 출동·정비·복귀를 모의한다.\n", + " baseline: 접수 순서 + 거리 최근접 정비반 / priority: 우선순위 점수 순서 + 도착 예상시각·당일 부하 균형\"\"\"\n", + " w = w or W0\n", + " est = np.asarray(est if est is not None else req[\"std_repair_h\"].values, float)\n", + " n_days = int(req[\"day\"].max()) + 1\n", + " free = {t[\"team\"]: 0.0 for t in teams}\n", + " load = {t[\"team\"]: np.zeros(n_days + 5) for t in teams}\n", + " recs, out, pending, i, now = req.to_dict(\"records\"), [], [], 0, cycle_h\n", + " while i < len(recs) or pending:\n", + " while i < len(recs) and recs[i][\"t_req\"] <= now:\n", + " pending.append((i, recs[i])); i += 1\n", + " if policy == \"priority\":\n", + " pending.sort(key=lambda p: -priority(p[1], now, est[p[0]], w))\n", + " for k, r in pending:\n", + " cand = [t for t in teams if r[\"equip\"] in t[\"skills\"]]\n", + " day = min(int(now // 24), n_days + 4)\n", + " if policy == \"baseline\":\n", + " tm = min(cand, key=lambda t: np.hypot(t[\"x\"] - r[\"x\"], t[\"y\"] - r[\"y\"]))\n", + " else:\n", + " tm = min(cand, key=lambda t: max(free[t[\"team\"]], now) + travel_h(t, r)\n", + " + w[\"balance\"] * load[t[\"team\"]][day] / DUTY_H * 4)\n", + " backlog = max(0.0, free[tm[\"team\"]] - now)\n", + " go = max(free[tm[\"team\"]], now)\n", + " tr = travel_h(tm, r, actual=True)\n", + " work = on_site_h(tm, r)\n", + " free[tm[\"team\"]] = go + 2 * tr + work\n", + " load[tm[\"team\"]][min(int(go // 24), n_days + 4)] += 2 * tr + work\n", + " out.append({**r, \"idx\": k, \"team\": tm[\"team\"], \"dist_km\": float(np.hypot(tm[\"x\"] - r[\"x\"], tm[\"y\"] - r[\"y\"])),\n", + " \"backlog_h\": backlog, \"cycle_wait_h\": now - r[\"t_req\"], \"dispatch\": go, \"travel_h\": tr,\n", + " \"work_h\": work, \"response_h\": go + tr - r[\"t_req\"], \"downtime_h\": go + tr + work - r[\"t_req\"]})\n", + " pending, now = [], now + cycle_h\n", + " L = pd.DataFrame({k: v[:n_days] for k, v in load.items()}) / DUTY_H # 정비반별 일일 부하율\n", + " return pd.DataFrame(out).sort_values(\"idx\").reset_index(drop=True), L\n", + "\n", + "\n", + "def metrics(res, L):\n", + " c3 = res[res[\"crit\"] == 3]\n", + " return {\"평균 도착(h)\": round(res[\"response_h\"].mean(), 2), \"핵심장비 도착(h)\": round(c3[\"response_h\"].mean(), 2),\n", + " \"핵심장비 90%(h)\": round(c3[\"response_h\"].quantile(0.9), 2), \"평균 부하율\": round(float(L.values.mean()), 3),\n", + " \"부하율 편차\": round(float(L.mean().std()), 3), \"과부하 일수\": int((L > 1.0).values.sum()),\n", + " \"총 이동(h)\": round(float(res[\"travel_h\"].sum() * 2), 1)}\n", + "\n", + "\n", + "def make_features(req, teams):\n", + " \"\"\"접수 시점에 알 수 있는 정보만으로 파생 피처를 만든다(미래 정보 누설 방지).\"\"\"\n", + " X = pd.DataFrame(index=req.index)\n", + " X[\"hour\"] = req[\"t_req\"] % 24\n", + " X[\"is_night\"] = (X[\"hour\"] >= 19).astype(int)\n", + " X[\"dow\"] = req[\"day\"] % 7\n", + " X[\"weather_bad\"] = (req[\"weather\"] != \"clear\").astype(int)\n", + " X[\"unpaved\"] = (req[\"road\"] == \"unpaved\").astype(int)\n", + " X[\"no_parts\"] = (~req[\"parts_ready\"]).astype(int)\n", + " X[\"std_table_h\"] = req[\"fault\"].map(FAULT_H) * req[\"equip\"].map(EQUIP_F) # 정비 표준시간표 값\n", + " X[\"crit_x_noparts\"] = req[\"crit\"] * X[\"no_parts\"]\n", + " X[\"bad_x_table\"] = X[\"weather_bad\"] * X[\"std_table_h\"]\n", + " X[\"near_team_km\"] = [min(np.hypot(t[\"x\"] - x, t[\"y\"] - y) for t in teams if e in t[\"skills\"]) for x, y, e in zip(req[\"x\"], req[\"y\"], req[\"equip\"])]\n", + " X[\"hub_km\"] = [min(np.hypot(hx - x, hy - y) for hx, hy in HUBS) for x, y in zip(req[\"x\"], req[\"y\"])]\n", + " X[\"req_24h\"] = [((req[\"t_req\"] > t - 24) & (req[\"t_req\"] <= t)).sum() for t in req[\"t_req\"]]\n", + " X[\"fault_past_mean\"] = req.groupby(\"fault\")[\"std_repair_h\"].transform(lambda s: s.shift().expanding().mean()).fillna(req[\"std_repair_h\"].mean())\n", + " X[\"crit\"] = req[\"crit\"]\n", + " for e in EQUIP_F:\n", + " X[f\"eq_{e}\"] = (req[\"equip\"] == e).astype(int)\n", + " return X\n", + "\n", + "\n", + "teams = make_teams()\n", + "hist = make_requests(seed=11) # 과거 30일(학습용)\n", + "req = make_requests(seed=1) # 평가 30일\n", + "X, y = make_features(hist, teams), hist[\"std_repair_h\"]\n", + "print(f\"파생 피처 {X.shape[1]}개 생성 (목표 10개 이상: {'달성' if X.shape[1] >= 10 else '미달'})\")\n", + "\n", + "# 1) 시계열 교차검증: 과거로 학습 -> 이후 기간 예측 (5분할)\n", + "mape = lambda a, p: float(np.mean(np.abs(a - p) / a))\n", + "rows = []\n", + "for i, (tr, te) in enumerate(TimeSeriesSplit(5).split(X), 1):\n", + " base = hist.iloc[tr].groupby(\"fault\")[\"std_repair_h\"].mean()\n", + " p0 = hist.iloc[te][\"fault\"].map(base).fillna(y.iloc[tr].mean())\n", + " gb = GradientBoostingRegressor(n_estimators=200, max_depth=3, learning_rate=0.05, random_state=0).fit(X.iloc[tr], y.iloc[tr])\n", + " rows.append({\"분할\": i, \"학습\": len(tr), \"검증\": len(te), \"기준모델 MAPE\": round(mape(y.iloc[te], p0), 3), \"GBM MAPE\": round(mape(y.iloc[te], gb.predict(X.iloc[te])), 3)})\n", + "cv = pd.DataFrame(rows)\n", + "print(cv.to_string(index=False))\n", + "print(f\"평균 MAPE: 기준모델(고장유형 평균) {cv['기준모델 MAPE'].mean():.1%}, GBM {cv['GBM MAPE'].mean():.1%} (목표 25% 이하: {'달성' if cv['GBM MAPE'].mean() <= 0.25 else '미달'})\")\n", + "\n", + "model = GradientBoostingRegressor(n_estimators=200, max_depth=3, learning_rate=0.05, random_state=0).fit(X, y)\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", + "\n", + "# 2) 우선순위 추천 엔진 적용: 예측 정비시간 + 중요도 + 대기시간 + 도착 예상시각 + 당일 부하\n", + "est = model.predict(make_features(req, teams))\n", + "print(f\"평가 기간 정비시간 예측 MAPE {mape(req['std_repair_h'], est):.1%}\")\n", + "b_res, b_L = simulate(req, teams, \"baseline\")\n", + "p_res, p_L = simulate(req, teams, \"priority\", est=est)\n", + "cmp = pd.DataFrame([{\"방식\": \"Baseline(거리)\", **metrics(b_res, b_L)}, {\"방식\": \"추천 엔진\", **metrics(p_res, p_L)}])\n", + "print(\"\\n\" + cmp.to_string(index=False))\n", + "gain = 1 - cmp.loc[1, \"핵심장비 도착(h)\"] / cmp.loc[0, \"핵심장비 도착(h)\"]\n", + "print(f\"핵심 장비 평균 도착시간 {gain:.1%} 단축, 정비반 부하율 편차 {cmp.loc[0, '부하율 편차']} -> {cmp.loc[1, '부하율 편차']}\")\n", + "print(\"대가: 부하를 나누느라 먼 정비반이 출동해 총 이동시간이 늘었다(미션 9에서 가중치로 조정).\")\n", + "\n", + "# 3) 추천 예시: 요청이 몰린 시점의 대기 목록\n", + "t_busy = req.groupby(\"day\").size().idxmax() * 24 + 14\n", + "q = req[(req[\"t_req\"] <= t_busy) & (req[\"t_req\"] > t_busy - 3)].copy()\n", + "q[\"예측정비(h)\"] = est[q.index].round(1)\n", + "q[\"우선순위 점수\"] = [round(priority(r, t_busy, e, W0), 2) for r, e in zip(q.to_dict(\"records\"), est[q.index])]\n", + "print(f\"\\n[추천 예시] {int(t_busy // 24)}일차 14시 대기 요청\")\n", + "print(q.sort_values(\"우선순위 점수\", ascending=False)[[\"req_id\", \"equip\", \"fault\", \"crit\", \"예측정비(h)\", \"우선순위 점수\"]].to_string(index=False))\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 (repair time model)\")\n", + "ax[1].boxplot([b_res[b_res.crit == 3][\"response_h\"], p_res[p_res.crit == 3][\"response_h\"]]); ax[1].set_xticks([1, 2], [\"baseline\", \"priority engine\"])\n", + "ax[1].set_ylabel(\"response time (h)\"); ax[1].set_title(\"Critical equipment response time\")\n", + "plt.tight_layout(); plt.show()\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 04 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + "파생 피처 18개 생성 (목표 10개 이상: 달성)\n", + " 분할 학습 검증 기준모델 MAPE GBM MAPE\n", + " 1 60 56 0.203 0.142\n", + " 2 116 56 0.171 0.141\n", + " 3 172 56 0.187 0.107\n", + " 4 228 56 0.263 0.130\n", + " 5 284 56 0.203 0.104\n", + "평균 MAPE: 기준모델(고장유형 평균) 20.5%, GBM 12.5% (목표 25% 이하: 달성)\n", + "\n", + "피처 중요도 상위 5: std_table_h(0.78), fault_past_mean(0.07), crit_x_noparts(0.05), bad_x_table(0.04), no_parts(0.03)\n", + "중요도 0.01 미만 피처(차원 축소 후보): ['eq_fires', 'hour', 'hub_km', 'near_team_km', 'crit', 'req_24h', 'dow', 'weather_bad', 'unpaved', 'eq_comms', 'eq_mobility', 'is_night', 'eq_power']\n", + "평가 기간 정비시간 예측 MAPE 12.1%\n", + "\n", + " 방식 평균 도착(h) 핵심장비 도착(h) 핵심장비 90%(h) 평균 부하율 부하율 편차 과부하 일수 총 이동(h)\n", + "Baseline(거리) 3.37 3.13 7.54 0.780 0.365 58 504.7\n", + " 추천 엔진 2.09 1.92 3.25 0.903 0.143 68 744.9\n", + "핵심 장비 평균 도착시간 38.7% 단축, 정비반 부하율 편차 0.365 -> 0.143\n", + "대가: 부하를 나누느라 먼 정비반이 출동해 총 이동시간이 늘었다(미션 9에서 가중치로 조정).\n", + "\n", + "[추천 예시] 22일차 14시 대기 요청\n", + "req_id equip fault crit 예측정비(h) 우선순위 점수\n", + " R0255 fires engine 3 10.8 3.51\n", + " R0256 fires electric 3 2.4 3.47\n", + " R0257 mobility hydraulic 3 3.1 3.43\n", + " R0252 comms electric 2 1.4 2.98\n", + " R0254 mobility electric 2 2.0 2.72\n", + " R0261 comms electric 2 1.4 2.37\n", + " R0260 mobility hydraulic 2 3.3 2.34\n", + " R0262 power cooling 2 2.1 2.31\n", + " R0253 mobility drivetrain 1 5.2 1.80\n", + " R0258 fires cooling 1 3.2 1.40\n", + " R0259 comms cooling 1 1.8 1.37\n", + "```\n", + "
\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 05. 출동 지연 위험도 및 현장 수리 가능성 신뢰도 점수 산출 모듈 개발\n", + "**소요시간**: 약 3.5시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "출동 지연 위험도 및 현장 수리 가능성 신뢰도 점수 모듈을 개발한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **출동 지연 위험도와 현장 수리 가능성을 점수로 산출하는 모듈을 개발한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **접수~도착 시간을 앙상블 회귀(GBM+RandomForest)로 예측해 거리 기반 기준모델과 Hold-out 기간에서 MAE·RMSE·R2를 비교 수행하고 검증하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `src/models/mnt10_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": [ + "\"\"\"출동 지연 위험도(접수~도착 예상시간)를 앙상블 회귀로 예측하고, 현장 수리 가능성을 확률(신뢰도 점수)로 산출하는 모듈을 개발한다. Hold-out 기간에서 거리 기반 기준모델 대비 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, LogisticRegression\n", + "from sklearn.metrics import mean_absolute_error, mean_squared_error, r2_score, roc_auc_score, brier_score_loss\n", + "\n", + "# ---------------------------------------------------------------------------\n", + "# [교육용가상자료] 야전 이동정비반·긴급 정비 요청 생성기 (실제 부대·위치·장비·인원 정보 없음)\n", + "# 60km x 60km 가상 지역에 이동정비반 6개와 30일간의 긴급 정비 요청을 만든다.\n", + "# 장비 구분(mobility/fires/comms/power)과 고장 유형은 교육용 추상 분류다.\n", + "# ---------------------------------------------------------------------------\n", + "AREA_KM, DUTY_H = 60, 10.0 # 가상 작전지역 크기, 정비반 1일 기준 가용시간\n", + "FAULT_H = {\"engine\": 6.0, \"drivetrain\": 5.0, \"hydraulic\": 3.0, \"electric\": 2.0, \"comm_module\": 1.5, \"cooling\": 2.5}\n", + "FIELD_P = {\"engine\": 0.3, \"drivetrain\": 0.35, \"hydraulic\": 0.75, \"electric\": 0.9, \"comm_module\": 0.95, \"cooling\": 0.8}\n", + "EQUIP_F = {\"mobility\": 1.0, \"fires\": 1.2, \"comms\": 0.7, \"power\": 0.8}\n", + "FAULT_OF = {\"mobility\": [0.3, 0.25, 0.2, 0.1, 0.0, 0.15], \"fires\": [0.2, 0.15, 0.35, 0.15, 0.0, 0.15],\n", + " \"comms\": [0.0, 0.0, 0.0, 0.4, 0.5, 0.1], \"power\": [0.35, 0.0, 0.0, 0.35, 0.0, 0.3]}\n", + "CRIT_P = {\"mobility\": [0.45, 0.35, 0.2], \"fires\": [0.2, 0.35, 0.45], \"comms\": [0.3, 0.4, 0.3], \"power\": [0.6, 0.3, 0.1]}\n", + "HUBS = [(15, 20), (45, 25), (30, 50)] # 가상 훈련장 3곳(요청이 몰리는 지점)\n", + "\n", + "\n", + "def make_teams(seed=0):\n", + " rng = np.random.default_rng(seed)\n", + " base = [(8, 10), (30, 6), (52, 12), (10, 44), (32, 38), (54, 50)]\n", + " skills = [[\"fires\", \"mobility\"], [\"comms\", \"mobility\"], [\"fires\", \"power\"],\n", + " [\"mobility\", \"power\"], [\"comms\", \"fires\"], [\"comms\", \"mobility\", \"power\"]]\n", + " return [{\"team\": f\"MT{i + 1}\", \"x\": float(x), \"y\": float(y), \"crew\": int(rng.integers(3, 6)), \"skills\": s}\n", + " for i, ((x, y), s) in enumerate(zip(base, skills))]\n", + "\n", + "\n", + "def make_requests(n_days=30, per_day=10, seed=1, surge_days=(8, 9, 10, 11, 22, 23, 24)):\n", + " \"\"\"하루 평균 per_day건, 훈련 주간(surge_days)에는 1.6배의 긴급 정비 요청을 만든다.\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " rows = []\n", + " for d in range(n_days):\n", + " n = rng.poisson(per_day * (1.6 if d in surge_days else 1.0))\n", + " weather = rng.choice([\"clear\", \"rain\", \"snow\"], p=[0.65, 0.25, 0.1])\n", + " for _ in range(n):\n", + " if rng.random() < 0.7:\n", + " hx, hy = HUBS[rng.integers(0, 3)]\n", + " x, y = np.clip(rng.normal(hx, 6), 0, AREA_KM), np.clip(rng.normal(hy, 6), 0, AREA_KM)\n", + " else:\n", + " x, y = rng.uniform(0, AREA_KM, 2)\n", + " eq = str(rng.choice(list(EQUIP_F), p=[0.45, 0.25, 0.2, 0.1]))\n", + " fault = str(rng.choice(list(FAULT_H), p=FAULT_OF[eq]))\n", + " parts = bool(rng.random() < 0.75)\n", + " bad = weather != \"clear\"\n", + " std_h = FAULT_H[fault] * EQUIP_F[eq] * (1.35 if not parts else 1.0) * (1.15 if bad else 1.0) * rng.lognormal(0, 0.12)\n", + " rows.append({\"t_req\": d * 24 + float(np.clip(rng.normal(13, 4), 5, 23)), \"day\": d,\n", + " \"x\": float(x), \"y\": float(y), \"equip\": eq, \"fault\": fault,\n", + " \"crit\": int(rng.choice([1, 2, 3], p=CRIT_P[eq])), \"parts_ready\": parts,\n", + " \"weather\": str(weather), \"road\": \"unpaved\" if y > 30 and rng.random() < 0.7 else \"paved\",\n", + " \"field_ok\": bool(rng.random() < FIELD_P[fault] * (1.0 if parts else 0.5)),\n", + " \"std_repair_h\": round(float(std_h), 2), \"road_delay\": float(rng.lognormal(0, 0.15))})\n", + " df = pd.DataFrame(rows).sort_values(\"t_req\").reset_index(drop=True)\n", + " df.insert(0, \"req_id\", [f\"R{i:04d}\" for i in range(len(df))])\n", + " return df\n", + "# ---------------------------------------------------------------------------\n", + "\n", + "# ---- 출동 배정·정비 모의 공통 함수 ----\n", + "W0 = {\"crit\": 1.0, \"wait\": 0.3, \"short\": 0.2, \"balance\": 0.5} # 우선순위·배정 기본 가중치(미션 9에서 재튜닝)\n", + "\n", + "\n", + "def travel_h(team, r, actual=False):\n", + " \"\"\"도로 굴곡 1.3배, 포장 35km/h·비포장 25km/h, 악천후 20% 감속. actual=True면 실제 도로 지연을 반영.\"\"\"\n", + " # [TODO : 학생 실습 5] 출동 지연 위험도 및 현장 수리 가능성 신뢰도 점수 산출 모듈 개발 관련 핵심 로직을 구현하세요.\n", + " # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n", + " # <<< 여기에 코드를 작성하세요 >>>\n", + " pass # 작성 후 pass를 제거하세요.\n", + "def on_site_h(team, r):\n", + " \"\"\"현장 정비시간: 4인 기준 표준시간을 투입 인원으로 환산. 현장 수리 불가면 진단·후송 조치 1시간.\"\"\"\n", + " # [TODO : 학생 실습 5] 출동 지연 위험도 및 현장 수리 가능성 신뢰도 점수 산출 모듈 개발 관련 핵심 로직을 구현하세요.\n", + " # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n", + " # <<< 여기에 코드를 작성하세요 >>>\n", + " pass # 작성 후 pass를 제거하세요.\n", + "def priority(r, now, est_h, w):\n", + " return w[\"crit\"] * r[\"crit\"] + w[\"wait\"] * (now - r[\"t_req\"]) + w[\"short\"] / (1 + est_h)\n", + "\n", + "\n", + "def simulate(req, teams, policy=\"baseline\", w=None, cycle_h=1.0, est=None):\n", + " \"\"\"배정 주기(cycle_h)마다 대기 요청을 정렬해 이동정비반에 배정하고 출동·정비·복귀를 모의한다.\n", + " baseline: 접수 순서 + 거리 최근접 정비반 / priority: 우선순위 점수 순서 + 도착 예상시각·당일 부하 균형\"\"\"\n", + " w = w or W0\n", + " est = np.asarray(est if est is not None else req[\"std_repair_h\"].values, float)\n", + " n_days = int(req[\"day\"].max()) + 1\n", + " free = {t[\"team\"]: 0.0 for t in teams}\n", + " load = {t[\"team\"]: np.zeros(n_days + 5) for t in teams}\n", + " recs, out, pending, i, now = req.to_dict(\"records\"), [], [], 0, cycle_h\n", + " while i < len(recs) or pending:\n", + " while i < len(recs) and recs[i][\"t_req\"] <= now:\n", + " pending.append((i, recs[i])); i += 1\n", + " if policy == \"priority\":\n", + " pending.sort(key=lambda p: -priority(p[1], now, est[p[0]], w))\n", + " for k, r in pending:\n", + " cand = [t for t in teams if r[\"equip\"] in t[\"skills\"]]\n", + " day = min(int(now // 24), n_days + 4)\n", + " if policy == \"baseline\":\n", + " tm = min(cand, key=lambda t: np.hypot(t[\"x\"] - r[\"x\"], t[\"y\"] - r[\"y\"]))\n", + " else:\n", + " tm = min(cand, key=lambda t: max(free[t[\"team\"]], now) + travel_h(t, r)\n", + " + w[\"balance\"] * load[t[\"team\"]][day] / DUTY_H * 4)\n", + " backlog = max(0.0, free[tm[\"team\"]] - now)\n", + " go = max(free[tm[\"team\"]], now)\n", + " tr = travel_h(tm, r, actual=True)\n", + " work = on_site_h(tm, r)\n", + " free[tm[\"team\"]] = go + 2 * tr + work\n", + " load[tm[\"team\"]][min(int(go // 24), n_days + 4)] += 2 * tr + work\n", + " out.append({**r, \"idx\": k, \"team\": tm[\"team\"], \"dist_km\": float(np.hypot(tm[\"x\"] - r[\"x\"], tm[\"y\"] - r[\"y\"])),\n", + " \"backlog_h\": backlog, \"cycle_wait_h\": now - r[\"t_req\"], \"dispatch\": go, \"travel_h\": tr,\n", + " \"work_h\": work, \"response_h\": go + tr - r[\"t_req\"], \"downtime_h\": go + tr + work - r[\"t_req\"]})\n", + " pending, now = [], now + cycle_h\n", + " L = pd.DataFrame({k: v[:n_days] for k, v in load.items()}) / DUTY_H # 정비반별 일일 부하율\n", + " return pd.DataFrame(out).sort_values(\"idx\").reset_index(drop=True), L\n", + "\n", + "\n", + "def metrics(res, L):\n", + " c3 = res[res[\"crit\"] == 3]\n", + " return {\"평균 도착(h)\": round(res[\"response_h\"].mean(), 2), \"핵심장비 도착(h)\": round(c3[\"response_h\"].mean(), 2),\n", + " \"핵심장비 90%(h)\": round(c3[\"response_h\"].quantile(0.9), 2), \"평균 부하율\": round(float(L.values.mean()), 3),\n", + " \"부하율 편차\": round(float(L.mean().std()), 3), \"과부하 일수\": int((L > 1.0).values.sum()),\n", + " \"총 이동(h)\": round(float(res[\"travel_h\"].sum() * 2), 1)}\n", + "\n", + "\n", + "def feat(res):\n", + " \"\"\"배정 시점에 알 수 있는 정보: 배정 정비반까지 거리, 정비반 잔여 작업(백로그), 배정 주기 대기, 기상·도로·중요도.\"\"\"\n", + " X = pd.DataFrame({\"dist_km\": res[\"dist_km\"], \"backlog_h\": res[\"backlog_h\"], \"cycle_wait_h\": res[\"cycle_wait_h\"],\n", + " \"weather_bad\": (res[\"weather\"] != \"clear\").astype(int), \"unpaved\": (res[\"road\"] == \"unpaved\").astype(int),\n", + " \"crit\": res[\"crit\"], \"hour\": res[\"t_req\"] % 24})\n", + " X[\"dist_x_road\"] = X[\"dist_km\"] * (1 + 0.4 * X[\"unpaved\"]) * (1 + 0.2 * X[\"weather_bad\"])\n", + " return X\n", + "\n", + "\n", + "def feat_field(df):\n", + " X = pd.get_dummies(df[[\"fault\", \"equip\"]]).astype(int)\n", + " X[\"parts_ready\"] = df[\"parts_ready\"].astype(int)\n", + " X[\"weather_bad\"] = (df[\"weather\"] != \"clear\").astype(int)\n", + " return X\n", + "\n", + "\n", + "teams = make_teams()\n", + "train = pd.concat([simulate(make_requests(seed=s), teams, \"priority\")[0] for s in (11, 21)], ignore_index=True)\n", + "test = simulate(make_requests(seed=1), teams, \"priority\")[0] # Hold-out: 학습에 쓰지 않은 30일\n", + "Xtr, Xte, ytr, yte = feat(train), feat(test), train[\"response_h\"], test[\"response_h\"]\n", + "\n", + "# 1) 출동 지연 예측: 기준모델(거리만) vs 앙상블(GBM + RandomForest 평균)\n", + "base = LinearRegression().fit(Xtr[[\"dist_km\"]], 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=200, min_samples_leaf=3, random_state=0).fit(Xtr, ytr)\n", + "preds = {\"기준모델(거리 선형)\": base.predict(Xte[[\"dist_km\"]]), \"GBM\": gbm.predict(Xte), \"RandomForest\": rf.predict(Xte)}\n", + "preds[\"앙상블(GBM+RF)\"] = (preds[\"GBM\"] + preds[\"RandomForest\"]) / 2\n", + "tab = pd.DataFrame([{\"모델\": k, \"MAE(h)\": mean_absolute_error(yte, p), \"RMSE(h)\": 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(h)\"] / b[\"MAE(h)\"], 1 - e[\"RMSE(h)\"] / b[\"RMSE(h)\"]\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) 출동 지연 위험도 등급(업무 임계값: 핵심 장비 2시간, 그 외 4시간 이내 도착)\n", + "limit = np.where(test[\"crit\"] == 3, 2.0, 4.0)\n", + "risk = np.where(preds[\"앙상블(GBM+RF)\"] > limit, \"높음\", np.where(preds[\"앙상블(GBM+RF)\"] > limit * 0.7, \"주의\", \"낮음\"))\n", + "actual_late = yte.values > limit\n", + "print(\"\\n[지연 위험도 등급별 실제 지연 비율]\")\n", + "print(pd.DataFrame({\"등급\": risk, \"실제지연\": actual_late}).groupby(\"등급\")[\"실제지연\"].agg([\"count\", \"mean\"]).rename(columns={\"count\": \"건수\", \"mean\": \"실제 지연 비율\"}).round(2).to_string())\n", + "\n", + "# 3) 현장 수리 가능성 신뢰도 점수(로지스틱 회귀 확률)\n", + "all_req = [make_requests(seed=s) for s in (11, 21)]\n", + "ftr = pd.concat(all_req, ignore_index=True); fte = make_requests(seed=1)\n", + "Ftr = feat_field(ftr); Fte = feat_field(fte).reindex(columns=Ftr.columns, fill_value=0)\n", + "clf = LogisticRegression(max_iter=1000).fit(Ftr, ftr[\"field_ok\"])\n", + "prob = clf.predict_proba(Fte)[:, 1]\n", + "print(f\"\\n현장 수리 가능성: AUC {roc_auc_score(fte['field_ok'], prob):.3f}, Brier {brier_score_loss(fte['field_ok'], prob):.3f}\")\n", + "bins = pd.cut(prob, [0, 0.4, 0.7, 1.0], labels=[\"후송 준비(<0.4)\", \"판단 보류(0.4~0.7)\", \"현장수리 유력(>=0.7)\"])\n", + "cal = pd.DataFrame({\"구간\": bins, \"예측\": prob, \"실제\": fte[\"field_ok\"]}).groupby(\"구간\", observed=True).agg(건수=(\"예측\", \"size\"), 평균점수=(\"예측\", \"mean\"), 실제비율=(\"실제\", \"mean\")).round(2)\n", + "print(cal.to_string())\n", + "print(\"점수가 높은 구간일수록 실제 현장 수리 비율이 높아 신뢰도 점수로 쓸 수 있다. 점수는 출동 준비(부속·후송 수단) 참고용이다.\")\n", + "\n", + "fig, ax = plt.subplots(1, 2, figsize=(11, 4))\n", + "ax[0].scatter(yte, preds[\"앙상블(GBM+RF)\"], s=10); ax[0].plot([0, yte.max()], [0, yte.max()], \"r--\")\n", + "ax[0].set_xlabel(\"actual response (h)\"); ax[0].set_ylabel(\"predicted (h)\"); ax[0].set_title(f\"Delay model hold-out (R2={e['R2']:.2f})\")\n", + "ax[1].plot(cal[\"평균점수\"], cal[\"실제비율\"], \"o-\"); ax[1].plot([0, 1], [0, 1], \"k--\"); ax[1].set_xlabel(\"mean confidence score\")\n", + "ax[1].set_ylabel(\"actual field-repair rate\"); ax[1].set_title(\"Field-repair confidence calibration\")\n", + "plt.tight_layout(); plt.show()\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 05 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + "학습 724건 / Hold-out 356건\n", + " 모델 MAE(h) RMSE(h) R2\n", + " 기준모델(거리 선형) 1.025 1.442 0.192\n", + " GBM 0.152 0.229 0.980\n", + "RandomForest 0.184 0.294 0.966\n", + " 앙상블(GBM+RF) 0.165 0.254 0.975\n", + "기준모델 대비 개선: MAE 83.9%, RMSE 82.4% (목표 20% 이상: 달성), R2 0.975 (목표 0.85 이상: 달성)\n", + "\n", + "[지연 위험도 등급별 실제 지연 비율]\n", + " 건수 실제 지연 비율\n", + "등급 \n", + "낮음 248 0.00\n", + "높음 61 0.93\n", + "주의 47 0.04\n", + "\n", + "현장 수리 가능성: AUC 0.762, Brier 0.185\n", + " 건수 평균점수 실제비율\n", + "구간 \n", + "후송 준비(<0.4) 143 0.30 0.34\n", + "판단 보류(0.4~0.7) 70 0.59 0.63\n", + "현장수리 유력(>=0.7) 143 0.80 0.83\n", + "점수가 높은 구간일수록 실제 현장 수리 비율이 높아 신뢰도 점수로 쓸 수 있다. 점수는 출동 준비(부속·후송 수단) 참고용이다.\n", + "```\n", + "
\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 06. 야전 정비 요청 현황 및 이동정비반 관제 대시보드 UI 구현\n", + "**소요시간**: 약 3.0시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "야전 정비 요청 현황 및 이동정비반 관제 대시보드 UI를 구현한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **정비 요청 현황과 이동정비반 상태를 보여 주는 관제 대시보드를 만들고, 예측마다 근거를 설명하는 설명가능성(XAI) 기능을 붙인다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **요청·정비반 지도, 정비반 부하율, 중요도별 대기 요청, 일별·중요도별 도착시간, 예측 워터폴 그림으로 관제 화면을 구성 수행하고 검증하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `src/explainability/mnt10_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개 인자를 자동 산출해 워터폴 그림과 자연어 설명으로 제공하는 설명가능성(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", + "# [교육용가상자료] 야전 이동정비반·긴급 정비 요청 생성기 (실제 부대·위치·장비·인원 정보 없음)\n", + "# 60km x 60km 가상 지역에 이동정비반 6개와 30일간의 긴급 정비 요청을 만든다.\n", + "# 장비 구분(mobility/fires/comms/power)과 고장 유형은 교육용 추상 분류다.\n", + "# ---------------------------------------------------------------------------\n", + "AREA_KM, DUTY_H = 60, 10.0 # 가상 작전지역 크기, 정비반 1일 기준 가용시간\n", + "FAULT_H = {\"engine\": 6.0, \"drivetrain\": 5.0, \"hydraulic\": 3.0, \"electric\": 2.0, \"comm_module\": 1.5, \"cooling\": 2.5}\n", + "FIELD_P = {\"engine\": 0.3, \"drivetrain\": 0.35, \"hydraulic\": 0.75, \"electric\": 0.9, \"comm_module\": 0.95, \"cooling\": 0.8}\n", + "EQUIP_F = {\"mobility\": 1.0, \"fires\": 1.2, \"comms\": 0.7, \"power\": 0.8}\n", + "FAULT_OF = {\"mobility\": [0.3, 0.25, 0.2, 0.1, 0.0, 0.15], \"fires\": [0.2, 0.15, 0.35, 0.15, 0.0, 0.15],\n", + " \"comms\": [0.0, 0.0, 0.0, 0.4, 0.5, 0.1], \"power\": [0.35, 0.0, 0.0, 0.35, 0.0, 0.3]}\n", + "CRIT_P = {\"mobility\": [0.45, 0.35, 0.2], \"fires\": [0.2, 0.35, 0.45], \"comms\": [0.3, 0.4, 0.3], \"power\": [0.6, 0.3, 0.1]}\n", + "HUBS = [(15, 20), (45, 25), (30, 50)] # 가상 훈련장 3곳(요청이 몰리는 지점)\n", + "\n", + "\n", + "def make_teams(seed=0):\n", + " rng = np.random.default_rng(seed)\n", + " base = [(8, 10), (30, 6), (52, 12), (10, 44), (32, 38), (54, 50)]\n", + " skills = [[\"fires\", \"mobility\"], [\"comms\", \"mobility\"], [\"fires\", \"power\"],\n", + " [\"mobility\", \"power\"], [\"comms\", \"fires\"], [\"comms\", \"mobility\", \"power\"]]\n", + " return [{\"team\": f\"MT{i + 1}\", \"x\": float(x), \"y\": float(y), \"crew\": int(rng.integers(3, 6)), \"skills\": s}\n", + " for i, ((x, y), s) in enumerate(zip(base, skills))]\n", + "\n", + "\n", + "def make_requests(n_days=30, per_day=10, seed=1, surge_days=(8, 9, 10, 11, 22, 23, 24)):\n", + " \"\"\"하루 평균 per_day건, 훈련 주간(surge_days)에는 1.6배의 긴급 정비 요청을 만든다.\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " rows = []\n", + " for d in range(n_days):\n", + " n = rng.poisson(per_day * (1.6 if d in surge_days else 1.0))\n", + " weather = rng.choice([\"clear\", \"rain\", \"snow\"], p=[0.65, 0.25, 0.1])\n", + " for _ in range(n):\n", + " if rng.random() < 0.7:\n", + " hx, hy = HUBS[rng.integers(0, 3)]\n", + " x, y = np.clip(rng.normal(hx, 6), 0, AREA_KM), np.clip(rng.normal(hy, 6), 0, AREA_KM)\n", + " else:\n", + " x, y = rng.uniform(0, AREA_KM, 2)\n", + " eq = str(rng.choice(list(EQUIP_F), p=[0.45, 0.25, 0.2, 0.1]))\n", + " fault = str(rng.choice(list(FAULT_H), p=FAULT_OF[eq]))\n", + " parts = bool(rng.random() < 0.75)\n", + " bad = weather != \"clear\"\n", + " std_h = FAULT_H[fault] * EQUIP_F[eq] * (1.35 if not parts else 1.0) * (1.15 if bad else 1.0) * rng.lognormal(0, 0.12)\n", + " rows.append({\"t_req\": d * 24 + float(np.clip(rng.normal(13, 4), 5, 23)), \"day\": d,\n", + " \"x\": float(x), \"y\": float(y), \"equip\": eq, \"fault\": fault,\n", + " \"crit\": int(rng.choice([1, 2, 3], p=CRIT_P[eq])), \"parts_ready\": parts,\n", + " \"weather\": str(weather), \"road\": \"unpaved\" if y > 30 and rng.random() < 0.7 else \"paved\",\n", + " \"field_ok\": bool(rng.random() < FIELD_P[fault] * (1.0 if parts else 0.5)),\n", + " \"std_repair_h\": round(float(std_h), 2), \"road_delay\": float(rng.lognormal(0, 0.15))})\n", + " df = pd.DataFrame(rows).sort_values(\"t_req\").reset_index(drop=True)\n", + " df.insert(0, \"req_id\", [f\"R{i:04d}\" for i in range(len(df))])\n", + " return df\n", + "# ---------------------------------------------------------------------------\n", + "\n", + "# ---- 출동 배정·정비 모의 공통 함수 ----\n", + "W0 = {\"crit\": 1.0, \"wait\": 0.3, \"short\": 0.2, \"balance\": 0.5} # 우선순위·배정 기본 가중치(미션 9에서 재튜닝)\n", + "\n", + "\n", + "def travel_h(team, r, actual=False):\n", + " \"\"\"도로 굴곡 1.3배, 포장 35km/h·비포장 25km/h, 악천후 20% 감속. actual=True면 실제 도로 지연을 반영.\"\"\"\n", + " # [TODO : 학생 실습 6] 야전 정비 요청 현황 및 이동정비반 관제 대시보드 UI 구현 관련 핵심 로직을 구현하세요.\n", + " # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n", + " # <<< 여기에 코드를 작성하세요 >>>\n", + " pass # 작성 후 pass를 제거하세요.\n", + "def on_site_h(team, r):\n", + " \"\"\"현장 정비시간: 4인 기준 표준시간을 투입 인원으로 환산. 현장 수리 불가면 진단·후송 조치 1시간.\"\"\"\n", + " # [TODO : 학생 실습 6] 야전 정비 요청 현황 및 이동정비반 관제 대시보드 UI 구현 관련 핵심 로직을 구현하세요.\n", + " # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n", + " # <<< 여기에 코드를 작성하세요 >>>\n", + " pass # 작성 후 pass를 제거하세요.\n", + "def priority(r, now, est_h, w):\n", + " return w[\"crit\"] * r[\"crit\"] + w[\"wait\"] * (now - r[\"t_req\"]) + w[\"short\"] / (1 + est_h)\n", + "\n", + "\n", + "def simulate(req, teams, policy=\"baseline\", w=None, cycle_h=1.0, est=None):\n", + " \"\"\"배정 주기(cycle_h)마다 대기 요청을 정렬해 이동정비반에 배정하고 출동·정비·복귀를 모의한다.\n", + " baseline: 접수 순서 + 거리 최근접 정비반 / priority: 우선순위 점수 순서 + 도착 예상시각·당일 부하 균형\"\"\"\n", + " w = w or W0\n", + " est = np.asarray(est if est is not None else req[\"std_repair_h\"].values, float)\n", + " n_days = int(req[\"day\"].max()) + 1\n", + " free = {t[\"team\"]: 0.0 for t in teams}\n", + " load = {t[\"team\"]: np.zeros(n_days + 5) for t in teams}\n", + " recs, out, pending, i, now = req.to_dict(\"records\"), [], [], 0, cycle_h\n", + " while i < len(recs) or pending:\n", + " while i < len(recs) and recs[i][\"t_req\"] <= now:\n", + " pending.append((i, recs[i])); i += 1\n", + " if policy == \"priority\":\n", + " pending.sort(key=lambda p: -priority(p[1], now, est[p[0]], w))\n", + " for k, r in pending:\n", + " cand = [t for t in teams if r[\"equip\"] in t[\"skills\"]]\n", + " day = min(int(now // 24), n_days + 4)\n", + " if policy == \"baseline\":\n", + " tm = min(cand, key=lambda t: np.hypot(t[\"x\"] - r[\"x\"], t[\"y\"] - r[\"y\"]))\n", + " else:\n", + " tm = min(cand, key=lambda t: max(free[t[\"team\"]], now) + travel_h(t, r)\n", + " + w[\"balance\"] * load[t[\"team\"]][day] / DUTY_H * 4)\n", + " backlog = max(0.0, free[tm[\"team\"]] - now)\n", + " go = max(free[tm[\"team\"]], now)\n", + " tr = travel_h(tm, r, actual=True)\n", + " work = on_site_h(tm, r)\n", + " free[tm[\"team\"]] = go + 2 * tr + work\n", + " load[tm[\"team\"]][min(int(go // 24), n_days + 4)] += 2 * tr + work\n", + " out.append({**r, \"idx\": k, \"team\": tm[\"team\"], \"dist_km\": float(np.hypot(tm[\"x\"] - r[\"x\"], tm[\"y\"] - r[\"y\"])),\n", + " \"backlog_h\": backlog, \"cycle_wait_h\": now - r[\"t_req\"], \"dispatch\": go, \"travel_h\": tr,\n", + " \"work_h\": work, \"response_h\": go + tr - r[\"t_req\"], \"downtime_h\": go + tr + work - r[\"t_req\"]})\n", + " pending, now = [], now + cycle_h\n", + " L = pd.DataFrame({k: v[:n_days] for k, v in load.items()}) / DUTY_H # 정비반별 일일 부하율\n", + " return pd.DataFrame(out).sort_values(\"idx\").reset_index(drop=True), L\n", + "\n", + "\n", + "def metrics(res, L):\n", + " c3 = res[res[\"crit\"] == 3]\n", + " return {\"평균 도착(h)\": round(res[\"response_h\"].mean(), 2), \"핵심장비 도착(h)\": round(c3[\"response_h\"].mean(), 2),\n", + " \"핵심장비 90%(h)\": round(c3[\"response_h\"].quantile(0.9), 2), \"평균 부하율\": round(float(L.values.mean()), 3),\n", + " \"부하율 편차\": round(float(L.mean().std()), 3), \"과부하 일수\": int((L > 1.0).values.sum()),\n", + " \"총 이동(h)\": round(float(res[\"travel_h\"].sum() * 2), 1)}\n", + "\n", + "# ---- 접수 시점 파생 피처(미션 4와 동일) ----\n", + "def make_features(req, teams):\n", + " \"\"\"접수 시점에 알 수 있는 정보만으로 파생 피처를 만든다(미래 정보 누설 방지).\"\"\"\n", + " X = pd.DataFrame(index=req.index)\n", + " X[\"hour\"] = req[\"t_req\"] % 24\n", + " X[\"is_night\"] = (X[\"hour\"] >= 19).astype(int)\n", + " X[\"dow\"] = req[\"day\"] % 7\n", + " X[\"weather_bad\"] = (req[\"weather\"] != \"clear\").astype(int)\n", + " X[\"unpaved\"] = (req[\"road\"] == \"unpaved\").astype(int)\n", + " X[\"no_parts\"] = (~req[\"parts_ready\"]).astype(int)\n", + " X[\"std_table_h\"] = req[\"fault\"].map(FAULT_H) * req[\"equip\"].map(EQUIP_F) # 정비 표준시간표 값\n", + " X[\"crit_x_noparts\"] = req[\"crit\"] * X[\"no_parts\"]\n", + " X[\"bad_x_table\"] = X[\"weather_bad\"] * X[\"std_table_h\"]\n", + " X[\"near_team_km\"] = [min(np.hypot(t[\"x\"] - x, t[\"y\"] - y) for t in teams if e in t[\"skills\"]) for x, y, e in zip(req[\"x\"], req[\"y\"], req[\"equip\"])]\n", + " X[\"hub_km\"] = [min(np.hypot(hx - x, hy - y) for hx, hy in HUBS) for x, y in zip(req[\"x\"], req[\"y\"])]\n", + " X[\"req_24h\"] = [((req[\"t_req\"] > t - 24) & (req[\"t_req\"] <= t)).sum() for t in req[\"t_req\"]]\n", + " X[\"fault_past_mean\"] = req.groupby(\"fault\")[\"std_repair_h\"].transform(lambda s: s.shift().expanding().mean()).fillna(req[\"std_repair_h\"].mean())\n", + " X[\"crit\"] = req[\"crit\"]\n", + " for e in EQUIP_F:\n", + " X[f\"eq_{e}\"] = (req[\"equip\"] == e).astype(int)\n", + " return X\n", + "\n", + "\n", + "def fit_repair_model(hist, teams):\n", + " \"\"\"과거 요청으로 정비 소요시간(4인 기준) 예측 모델을 학습한다.\"\"\"\n", + " return GradientBoostingRegressor(n_estimators=200, max_depth=3, learning_rate=0.05, random_state=0).fit(make_features(hist, teams), hist[\"std_repair_h\"])\n", + "\n", + "KO = {\"std_table_h\": \"정비 표준시간표 값\", \"no_parts\": \"수리부속 미확보\", \"weather_bad\": \"악천후\", \"bad_x_table\": \"악천후 x 표준시간\",\n", + " \"crit_x_noparts\": \"핵심장비 x 부속 미확보\", \"fault_past_mean\": \"같은 고장 과거 평균\", \"unpaved\": \"비포장 도로\", \"crit\": \"작전 중요도\",\n", + " \"near_team_km\": \"최근접 정비반 거리\", \"hub_km\": \"훈련장과의 거리\", \"req_24h\": \"최근 24시간 요청 수\", \"hour\": \"접수 시각\",\n", + " \"is_night\": \"야간 접수\", \"dow\": \"요일\"}\n", + "\n", + "\n", + "def explain(model, X, bg, top=3):\n", + " \"\"\"가림(occlusion) 방식 국소 기여도: 피처 j를 배경 표본 값으로 바꿨을 때 예측이 평균적으로 얼마나 달라지는지.\n", + " 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", + " tops = [list(C.loc[i].abs().sort_values(ascending=False).index[:top]) for i in C.index]\n", + " return base, C, tops\n", + "\n", + "\n", + "def sentence(r, pred, C, top):\n", + " parts = [f\"{KO.get(f, f)}({'+' if C[f] > 0 else ''}{C[f]:.1f}h)\" for f in top]\n", + " return f\"{r['req_id']} {r['equip']}/{r['fault']} 예상 정비 {pred:.1f}h(평균 대비 {pred - AVG:+.1f}h), 영향 요인: \" + \", \".join(parts)\n", + "\n", + "\n", + "teams = make_teams()\n", + "hist, req = make_requests(seed=11), make_requests(seed=1)\n", + "model = fit_repair_model(hist, teams)\n", + "AVG = float(hist[\"std_repair_h\"].mean())\n", + "Xh, X = make_features(hist, teams), make_features(req, teams)\n", + "res, L = simulate(req, teams, \"priority\", est=model.predict(X))\n", + "\n", + "# 1) 설명가능성: 평가 기간 전체 요청에 대해 상위 3개 인자 자동 산출\n", + "t0 = time.perf_counter()\n", + "bg = Xh.sample(30, random_state=0).values\n", + "pred, C, tops = explain(model, X, bg)\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", + "check = np.abs(C.sum(axis=1) - (pred - model.predict(Xh).mean())).mean()\n", + "print(f\"기여도 합과 (예측 - 평균 예측)의 평균 차이 {check:.2f}h -> 근사 설명이므로 참고 지표로 쓴다\")\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) 관제 대시보드: 22일차 14시 기준\n", + "day, now = 22, 22 * 24 + 14\n", + "cur = res[(res[\"t_req\"] <= now) & (res[\"t_req\"] > now - 24)]\n", + "waiting = cur[cur[\"dispatch\"] > now]\n", + "print(f\"\\n[관제 현황 {day}일차 14시] 최근 24시간 요청 {len(cur)}건, 출동 대기 {len(waiting)}건, 정비반 {len(teams)}개\")\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(cur[\"x\"], cur[\"y\"], c=cur[\"crit\"].map(col), s=40)\n", + "for t in teams:\n", + " ax[0, 0].plot(t[\"x\"], t[\"y\"], \"ks\"); ax[0, 0].annotate(t[\"team\"], (t[\"x\"] + 1, t[\"y\"] + 1))\n", + "ax[0, 0].set_xlim(0, AREA_KM); ax[0, 0].set_ylim(0, AREA_KM); ax[0, 0].set_title(\"Requests (24h, red=critical) & teams\")\n", + "lr = L.iloc[day]\n", + "ax[0, 1].bar(lr.index, lr.values, color=[\"tab:red\" if v > 1 else \"tab:blue\" for v in lr.values])\n", + "ax[0, 1].axhline(1.0, ls=\"--\", color=\"k\"); ax[0, 1].set_title(f\"Team load rate, day {day}\")\n", + "q = waiting.groupby(\"crit\").size().reindex([1, 2, 3], fill_value=0)\n", + "ax[0, 2].bar([\"normal\", \"important\", \"critical\"], q.values, color=[col[k] for k in q.index]); ax[0, 2].set_title(\"Waiting requests by criticality\")\n", + "dd = res.groupby(\"day\")[\"response_h\"].mean()\n", + "ax[1, 0].plot(dd.index, dd.values, marker=\"o\"); ax[1, 0].set_title(\"Daily mean response time (h)\"); ax[1, 0].set_xlabel(\"day\")\n", + "ax[1, 1].boxplot([res.loc[res[\"crit\"] == k, \"response_h\"] for k in (1, 2, 3)]); ax[1, 1].set_xticks([1, 2, 3], [\"normal\", \"important\", \"critical\"])\n", + "ax[1, 1].set_title(\"Response time by criticality\")\n", + "i = int(C.index[req[\"crit\"].eq(3)][int(np.argmax(pred[req[\"crit\"].eq(3).values]))])\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:red\" if v > 0 else \"tab:green\" for v in ci.values])\n", + "ax[1, 2].set_yticks(range(len(ci)), ci.index); ax[1, 2].set_title(f\"Waterfall: {req.loc[i, 'req_id']} repair time\"); ax[1, 2].set_xlabel(\"hours\")\n", + "plt.tight_layout(); plt.show()\n", + "print(\"대시보드 구성요소 6종: 요청·정비반 지도, 정비반 부하율, 중요도별 대기, 일별 도착시간, 중요도별 도착시간, 예측 워터폴\")\n", + "\n", + "# 3) 자연어 설명 예시(정비대대 관제 화면 하단)\n", + "print(\"\\n[예측 설명 예시]\")\n", + "for k in list(waiting.sort_values(\"crit\", ascending=False)[\"idx\"][:4]) + [i]:\n", + " print(\" - \" + sentence(req.loc[k], pred[k], C.loc[k], tops[k]))\n", + "over = lr[lr > 1.0]\n", + "act = \"전 정비반 과부하 -> 상급 정비부대 지원 요청·비핵심 요청 연기 검토\" if len(over) == len(teams) else \"여유 정비반 지원 검토\"\n", + "print(f\"\\n경보: {day}일차(훈련 주간) 부하율 1.0 초과 정비반 {list(over.index)} -> {act}(정비대대장 판단)\" if len(over) else \"\\n경보: 과부하 정비반 없음\")\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 06 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + "[교육용가상자료] 요청 356건 설명 산출 1.7초, 상위 3개 인자 자동 산출률 100% (목표 100%: 달성)\n", + "기여도 합과 (예측 - 평균 예측)의 평균 차이 0.31h -> 근사 설명이므로 참고 지표로 쓴다\n", + "상위 3개 인자에 자주 등장한 요인: 정비 표준시간표 값 329건, 핵심장비 x 부속 미확보 227건, 같은 고장 과거 평균 225건, 악천후 x 표준시간 189건, 수리부속 미확보 57건\n", + "\n", + "[관제 현황 22일차 14시] 최근 24시간 요청 22건, 출동 대기 6건, 정비반 6개\n", + "대시보드 구성요소 6종: 요청·정비반 지도, 정비반 부하율, 중요도별 대기, 일별 도착시간, 중요도별 도착시간, 예측 워터폴\n", + "\n", + "[예측 설명 예시]\n", + " - R0256 fires/electric 예상 정비 2.4h(평균 대비 -1.7h), 영향 요인: 정비 표준시간표 값(-1.0h), 같은 고장 과거 평균(-0.3h), 악천후 x 표준시간(-0.2h)\n", + " - R0260 mobility/hydraulic 예상 정비 3.3h(평균 대비 -0.8h), 영향 요인: 정비 표준시간표 값(-0.3h), 악천후 x 표준시간(-0.2h), 핵심장비 x 부속 미확보(-0.2h)\n", + " - R0262 power/cooling 예상 정비 2.1h(평균 대비 -2.0h), 영향 요인: 정비 표준시간표 값(-1.3h), 악천후 x 표준시간(-0.2h), 같은 고장 과거 평균(-0.2h)\n", + " - R0261 comms/electric 예상 정비 1.4h(평균 대비 -2.7h), 영향 요인: 정비 표준시간표 값(-1.9h), 같은 고장 과거 평균(-0.3h), 악천후 x 표준시간(-0.1h)\n", + " - R0255 fires/engine 예상 정비 10.8h(평균 대비 +6.7h), 영향 요인: 정비 표준시간표 값(+4.9h), 같은 고장 과거 평균(+1.1h), 핵심장비 x 부속 미확보(+1.0h)\n", + "\n", + "경보: 22일차(훈련 주간) 부하율 1.0 초과 정비반 ['MT1', 'MT2', 'MT3', 'MT4', 'MT5', 'MT6'] -> 전 정비반 과부하 -> 상급 정비부대 지원 요청·비핵심 요청 연기 검토(정비대대장 판단)\n", + "```\n", + "
\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 07. 긴급 출동 순서 변경 및 정비반 재배치를 위한 정비대대장 HITL 승인 체계 설계\n", + "**소요시간**: 약 3.5시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "출동 순서 변경을 위한 정비대대장 HITL 승인 체계를 설계한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **긴급 출동 순서 변경과 정비반 재배치를 정수계획(할당 문제)으로 제안하고, 정비대대장 승인을 거쳐야만 적용되는 HITL 승인 체계를 시뮬레이터로 설계한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **배정 주기마다 기본 계획(접수 순서·최근접)과 할당 최적화 제안을 비교해, 다르고 기대 이득이 있으면 승인 대기열에 올린다 과정을 진행하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `src/optimization/mnt10_simulator.py` 형태의 결과물을 완성한다.\n", + "\n", + "### 🔍 평가 및 검증 기준 (PASS 조건)\n", + "#### 미션 판정\n", + "\n", + "**평가 구분:** 필수\n", + "\n", + "**핵심 평가영역:** 현실적 군수 제약조건 반영도 및 시나리오별 대안 제시 적절성\n", + "\n", + "#### 미션 PASS 조건\n", + "\n", + "- 정량 기준: 최적화 적용 시 비용 15% 절감 또는 재고 회전율 20% 향상\n", + "\n", + "\n", + "- 진행 기준: 선형/정수 계획법(LP/MIP) 최적화 해 도출 및 시뮬레이션 완료\n", + "\n", + "\n", + "- 검증 방법: 과거 실적 데이터 대비 최적화 결과 비교 백테스팅\n", + "\n", + "\n", + "#### 채점자 판정 메모\n", + "\n", + "> 단순 코드 실행 여부뿐만 아니라 출력된 수치와 근거 자료가 군 실무 요구사항에 부합하는지 면밀히 확인한다.\n", + "\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "\"\"\"긴급 출동 순서 변경과 정비반 재배치를 정수계획(할당 문제)으로 제안하되, 기본 계획(접수 순서·최근접 정비반)과 다르면 정비대대장 승인 대기열에 올려 승인·반려·무응답(기본 계획 유지)을 사유와 함께 해시 체인 감사 로그에 남기는 HITL 시뮬레이터를 구현한다. 기본 계획 대비 가동중단 비용 절감률과 승인 지연의 영향을 비교한다.\"\"\"\n", + "import hashlib\n", + "import json\n", + "import numpy as np\n", + "import pandas as pd\n", + "import matplotlib.pyplot as plt\n", + "from scipy.optimize import linear_sum_assignment\n", + "\n", + "# ---------------------------------------------------------------------------\n", + "# [교육용가상자료] 야전 이동정비반·긴급 정비 요청 생성기 (실제 부대·위치·장비·인원 정보 없음)\n", + "# 60km x 60km 가상 지역에 이동정비반 6개와 30일간의 긴급 정비 요청을 만든다.\n", + "# 장비 구분(mobility/fires/comms/power)과 고장 유형은 교육용 추상 분류다.\n", + "# ---------------------------------------------------------------------------\n", + "AREA_KM, DUTY_H = 60, 10.0 # 가상 작전지역 크기, 정비반 1일 기준 가용시간\n", + "FAULT_H = {\"engine\": 6.0, \"drivetrain\": 5.0, \"hydraulic\": 3.0, \"electric\": 2.0, \"comm_module\": 1.5, \"cooling\": 2.5}\n", + "FIELD_P = {\"engine\": 0.3, \"drivetrain\": 0.35, \"hydraulic\": 0.75, \"electric\": 0.9, \"comm_module\": 0.95, \"cooling\": 0.8}\n", + "EQUIP_F = {\"mobility\": 1.0, \"fires\": 1.2, \"comms\": 0.7, \"power\": 0.8}\n", + "FAULT_OF = {\"mobility\": [0.3, 0.25, 0.2, 0.1, 0.0, 0.15], \"fires\": [0.2, 0.15, 0.35, 0.15, 0.0, 0.15],\n", + " \"comms\": [0.0, 0.0, 0.0, 0.4, 0.5, 0.1], \"power\": [0.35, 0.0, 0.0, 0.35, 0.0, 0.3]}\n", + "CRIT_P = {\"mobility\": [0.45, 0.35, 0.2], \"fires\": [0.2, 0.35, 0.45], \"comms\": [0.3, 0.4, 0.3], \"power\": [0.6, 0.3, 0.1]}\n", + "HUBS = [(15, 20), (45, 25), (30, 50)] # 가상 훈련장 3곳(요청이 몰리는 지점)\n", + "\n", + "\n", + "def make_teams(seed=0):\n", + " rng = np.random.default_rng(seed)\n", + " base = [(8, 10), (30, 6), (52, 12), (10, 44), (32, 38), (54, 50)]\n", + " skills = [[\"fires\", \"mobility\"], [\"comms\", \"mobility\"], [\"fires\", \"power\"],\n", + " [\"mobility\", \"power\"], [\"comms\", \"fires\"], [\"comms\", \"mobility\", \"power\"]]\n", + " return [{\"team\": f\"MT{i + 1}\", \"x\": float(x), \"y\": float(y), \"crew\": int(rng.integers(3, 6)), \"skills\": s}\n", + " for i, ((x, y), s) in enumerate(zip(base, skills))]\n", + "\n", + "\n", + "def make_requests(n_days=30, per_day=10, seed=1, surge_days=(8, 9, 10, 11, 22, 23, 24)):\n", + " \"\"\"하루 평균 per_day건, 훈련 주간(surge_days)에는 1.6배의 긴급 정비 요청을 만든다.\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " rows = []\n", + " for d in range(n_days):\n", + " n = rng.poisson(per_day * (1.6 if d in surge_days else 1.0))\n", + " weather = rng.choice([\"clear\", \"rain\", \"snow\"], p=[0.65, 0.25, 0.1])\n", + " for _ in range(n):\n", + " if rng.random() < 0.7:\n", + " hx, hy = HUBS[rng.integers(0, 3)]\n", + " x, y = np.clip(rng.normal(hx, 6), 0, AREA_KM), np.clip(rng.normal(hy, 6), 0, AREA_KM)\n", + " else:\n", + " x, y = rng.uniform(0, AREA_KM, 2)\n", + " eq = str(rng.choice(list(EQUIP_F), p=[0.45, 0.25, 0.2, 0.1]))\n", + " fault = str(rng.choice(list(FAULT_H), p=FAULT_OF[eq]))\n", + " parts = bool(rng.random() < 0.75)\n", + " bad = weather != \"clear\"\n", + " std_h = FAULT_H[fault] * EQUIP_F[eq] * (1.35 if not parts else 1.0) * (1.15 if bad else 1.0) * rng.lognormal(0, 0.12)\n", + " rows.append({\"t_req\": d * 24 + float(np.clip(rng.normal(13, 4), 5, 23)), \"day\": d,\n", + " \"x\": float(x), \"y\": float(y), \"equip\": eq, \"fault\": fault,\n", + " \"crit\": int(rng.choice([1, 2, 3], p=CRIT_P[eq])), \"parts_ready\": parts,\n", + " \"weather\": str(weather), \"road\": \"unpaved\" if y > 30 and rng.random() < 0.7 else \"paved\",\n", + " \"field_ok\": bool(rng.random() < FIELD_P[fault] * (1.0 if parts else 0.5)),\n", + " \"std_repair_h\": round(float(std_h), 2), \"road_delay\": float(rng.lognormal(0, 0.15))})\n", + " df = pd.DataFrame(rows).sort_values(\"t_req\").reset_index(drop=True)\n", + " df.insert(0, \"req_id\", [f\"R{i:04d}\" for i in range(len(df))])\n", + " return df\n", + "# ---------------------------------------------------------------------------\n", + "\n", + "# ---- 출동 배정·정비 모의 공통 함수 ----\n", + "W0 = {\"crit\": 1.0, \"wait\": 0.3, \"short\": 0.2, \"balance\": 0.5} # 우선순위·배정 기본 가중치(미션 9에서 재튜닝)\n", + "\n", + "\n", + "def travel_h(team, r, actual=False):\n", + " \"\"\"도로 굴곡 1.3배, 포장 35km/h·비포장 25km/h, 악천후 20% 감속. actual=True면 실제 도로 지연을 반영.\"\"\"\n", + " # [TODO : 학생 실습 7] 긴급 출동 순서 변경 및 정비반 재배치를 위한 정비대대장 HITL 승인 체계 설계 관련 핵심 로직을 구현하세요.\n", + " # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n", + " # <<< 여기에 코드를 작성하세요 >>>\n", + " pass # 작성 후 pass를 제거하세요.\n", + "def on_site_h(team, r):\n", + " \"\"\"현장 정비시간: 4인 기준 표준시간을 투입 인원으로 환산. 현장 수리 불가면 진단·후송 조치 1시간.\"\"\"\n", + " # [TODO : 학생 실습 7] 긴급 출동 순서 변경 및 정비반 재배치를 위한 정비대대장 HITL 승인 체계 설계 관련 핵심 로직을 구현하세요.\n", + " # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n", + " # <<< 여기에 코드를 작성하세요 >>>\n", + " pass # 작성 후 pass를 제거하세요.\n", + "def priority(r, now, est_h, w):\n", + " return w[\"crit\"] * r[\"crit\"] + w[\"wait\"] * (now - r[\"t_req\"]) + w[\"short\"] / (1 + est_h)\n", + "\n", + "\n", + "def simulate(req, teams, policy=\"baseline\", w=None, cycle_h=1.0, est=None):\n", + " \"\"\"배정 주기(cycle_h)마다 대기 요청을 정렬해 이동정비반에 배정하고 출동·정비·복귀를 모의한다.\n", + " baseline: 접수 순서 + 거리 최근접 정비반 / priority: 우선순위 점수 순서 + 도착 예상시각·당일 부하 균형\"\"\"\n", + " w = w or W0\n", + " est = np.asarray(est if est is not None else req[\"std_repair_h\"].values, float)\n", + " n_days = int(req[\"day\"].max()) + 1\n", + " free = {t[\"team\"]: 0.0 for t in teams}\n", + " load = {t[\"team\"]: np.zeros(n_days + 5) for t in teams}\n", + " recs, out, pending, i, now = req.to_dict(\"records\"), [], [], 0, cycle_h\n", + " while i < len(recs) or pending:\n", + " while i < len(recs) and recs[i][\"t_req\"] <= now:\n", + " pending.append((i, recs[i])); i += 1\n", + " if policy == \"priority\":\n", + " pending.sort(key=lambda p: -priority(p[1], now, est[p[0]], w))\n", + " for k, r in pending:\n", + " cand = [t for t in teams if r[\"equip\"] in t[\"skills\"]]\n", + " day = min(int(now // 24), n_days + 4)\n", + " if policy == \"baseline\":\n", + " tm = min(cand, key=lambda t: np.hypot(t[\"x\"] - r[\"x\"], t[\"y\"] - r[\"y\"]))\n", + " else:\n", + " tm = min(cand, key=lambda t: max(free[t[\"team\"]], now) + travel_h(t, r)\n", + " + w[\"balance\"] * load[t[\"team\"]][day] / DUTY_H * 4)\n", + " backlog = max(0.0, free[tm[\"team\"]] - now)\n", + " go = max(free[tm[\"team\"]], now)\n", + " tr = travel_h(tm, r, actual=True)\n", + " work = on_site_h(tm, r)\n", + " free[tm[\"team\"]] = go + 2 * tr + work\n", + " load[tm[\"team\"]][min(int(go // 24), n_days + 4)] += 2 * tr + work\n", + " out.append({**r, \"idx\": k, \"team\": tm[\"team\"], \"dist_km\": float(np.hypot(tm[\"x\"] - r[\"x\"], tm[\"y\"] - r[\"y\"])),\n", + " \"backlog_h\": backlog, \"cycle_wait_h\": now - r[\"t_req\"], \"dispatch\": go, \"travel_h\": tr,\n", + " \"work_h\": work, \"response_h\": go + tr - r[\"t_req\"], \"downtime_h\": go + tr + work - r[\"t_req\"]})\n", + " pending, now = [], now + cycle_h\n", + " L = pd.DataFrame({k: v[:n_days] for k, v in load.items()}) / DUTY_H # 정비반별 일일 부하율\n", + " return pd.DataFrame(out).sort_values(\"idx\").reset_index(drop=True), L\n", + "\n", + "\n", + "def metrics(res, L):\n", + " c3 = res[res[\"crit\"] == 3]\n", + " return {\"평균 도착(h)\": round(res[\"response_h\"].mean(), 2), \"핵심장비 도착(h)\": round(c3[\"response_h\"].mean(), 2),\n", + " \"핵심장비 90%(h)\": round(c3[\"response_h\"].quantile(0.9), 2), \"평균 부하율\": round(float(L.values.mean()), 3),\n", + " \"부하율 편차\": round(float(L.mean().std()), 3), \"과부하 일수\": int((L > 1.0).values.sum()),\n", + " \"총 이동(h)\": round(float(res[\"travel_h\"].sum() * 2), 1)}\n", + "\n", + "CW = {1: 1.0, 2: 3.0, 3: 6.0} # [교육용가상자료] 중요도별 가동중단 1시간 비용 지수\n", + "SLOTS = 3 # 정비반당 한 배정 주기에 받을 수 있는 최대 건수\n", + "APPROVE_DELAY = 0.25 # 승인 절차로 늦어지는 출동 시각(h)\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 default_plan(pending, teams, free, now):\n", + " \"\"\"기본 계획: 접수 순서대로 최근접(정비 가능) 정비반.\"\"\"\n", + " plan, f = [], dict(free)\n", + " for k, r in pending:\n", + " tm = min([t for t in teams if r[\"equip\"] in t[\"skills\"]], key=lambda t: np.hypot(t[\"x\"] - r[\"x\"], t[\"y\"] - r[\"y\"]))\n", + " go = max(f[tm[\"team\"]], now); plan.append((k, tm)); f[tm[\"team\"]] = go + 2 * travel_h(tm, r) + on_site_h(tm, r)\n", + " return plan\n", + "\n", + "\n", + "def mip_plan(pending, teams, free, now):\n", + " \"\"\"할당 문제(정수계획): 요청 x (정비반, 순번) 비용 = 중요도 가중 x 예상 도착시각. 정비 불가 조합은 큰 비용.\"\"\"\n", + " avg = np.mean([on_site_h(teams[0], r) for _, r in pending]) + 1.0\n", + " cols = [(t, s) for t in teams for s in range(SLOTS)]\n", + " cost = np.full((len(pending), len(cols)), 1e6)\n", + " for i, (k, r) in enumerate(pending):\n", + " for j, (t, s) in enumerate(cols):\n", + " if r[\"equip\"] in t[\"skills\"]:\n", + " cost[i, j] = CW[r[\"crit\"]] * (max(free[t[\"team\"]], now) + s * avg + travel_h(t, r) - now)\n", + " ri, cj = linear_sum_assignment(cost)\n", + " order = sorted(zip(ri, cj), key=lambda p: (cols[p[1]][1], -pending[p[0]][1][\"crit\"]))\n", + " return [(pending[i][0], cols[j][0]) for i, j in order]\n", + "\n", + "\n", + "def expected_cost(plan, recs, free, now, delay=0.0):\n", + " f, c = dict(free), 0.0\n", + " for k, tm in plan:\n", + " r = recs[k]; go = max(f[tm[\"team\"]], now + delay)\n", + " c += CW[r[\"crit\"]] * (go + travel_h(tm, r) + on_site_h(tm, r) - r[\"t_req\"]); f[tm[\"team\"]] = go + 2 * travel_h(tm, r) + on_site_h(tm, r)\n", + " return c\n", + "\n", + "\n", + "def commander(prop, rng):\n", + " \"\"\"[모의 정비대대장] 기대 이득이 작거나 담당 구역을 크게 벗어나는 재배치는 반려, 10%는 30분 내 무응답.\"\"\"\n", + " if rng.random() < 0.10:\n", + " return \"무응답\", \"30분 내 응답 없음 -> 기본 계획 유지\"\n", + " if prop[\"max_km\"] > 45:\n", + " return \"반려\", f\"정비반 구역 이탈 {prop['max_km']:.0f}km(복귀·보급 곤란)\"\n", + " if prop[\"gain\"] < 1.0:\n", + " return \"반려\", f\"기대 이득 {prop['gain']:.1f} 작음 -> 현 계획 유지\"\n", + " return \"승인\", f\"핵심장비 우선, 기대 이득 {prop['gain']:.1f}\"\n", + "\n", + "\n", + "def run(req, teams, mode, seed=0, audit=None):\n", + " \"\"\"mode: default(기본 계획) / hitl(제안 -> 승인 시 적용) / auto(모든 제안 적용: 비교용 상한, 실제 운용 금지)\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " recs = req.to_dict(\"records\"); free = {t[\"team\"]: 0.0 for t in teams}\n", + " out, i, now, props = [], 0, 1.0, []\n", + " while i < len(recs):\n", + " pending = []\n", + " while i < len(recs) and recs[i][\"t_req\"] <= now:\n", + " pending.append((i, recs[i])); i += 1\n", + " if pending:\n", + " base = default_plan(pending, teams, free, now)\n", + " plan, delay = base, 0.0\n", + " if mode != \"default\":\n", + " prop = mip_plan(pending, teams, free, now)\n", + " if [(k, t[\"team\"]) for k, t in prop] != [(k, t[\"team\"]) for k, t in base]:\n", + " gain = expected_cost(base, recs, free, now) - expected_cost(prop, recs, free, now, APPROVE_DELAY)\n", + " max_km = max(np.hypot(t[\"x\"] - recs[k][\"x\"], t[\"y\"] - recs[k][\"y\"]) for k, t in prop)\n", + " info = {\"t\": round(now, 2), \"n\": len(pending), \"gain\": round(gain, 2), \"max_km\": max_km}\n", + " if gain <= 0: # 이득 없는 변경은 제안하지 않는다\n", + " pass\n", + " elif mode == \"auto\":\n", + " dec, why = \"승인\", \"비교용 전부 적용\"\n", + " else:\n", + " dec, why = commander(info, rng)\n", + " audit.add(event=\"PROPOSE\", t=info[\"t\"], n=info[\"n\"], gain=info[\"gain\"], status=\"PENDING\")\n", + " audit.add(event=\"DECIDE\", t=info[\"t\"], decision=dec, reason=why, by=\"정비대대장(모의)\")\n", + " if gain > 0:\n", + " props.append({**info, \"decision\": dec})\n", + " if gain > 0 and dec == \"승인\":\n", + " plan, delay = prop, APPROVE_DELAY\n", + " for k, tm in plan:\n", + " r = recs[k]; go = max(free[tm[\"team\"]], now + delay)\n", + " tr, work = travel_h(tm, r, actual=True), on_site_h(tm, r)\n", + " free[tm[\"team\"]] = go + 2 * tr + work\n", + " out.append({**r, \"team\": tm[\"team\"], \"response_h\": go + tr - r[\"t_req\"], \"downtime_h\": go + tr + work - r[\"t_req\"]})\n", + " now += 1.0\n", + " res = pd.DataFrame(out)\n", + " res[\"cost\"] = res[\"crit\"].map(CW) * res[\"downtime_h\"]\n", + " return res, pd.DataFrame(props)\n", + "\n", + "\n", + "teams = make_teams()\n", + "req = make_requests(seed=1)\n", + "audit = Audit()\n", + "R = {}\n", + "for mode in (\"default\", \"hitl\", \"auto\"):\n", + " R[mode] = run(req, teams, mode, seed=3, audit=audit)\n", + "rows = []\n", + "for mode, name in [(\"default\", \"기본 계획\"), (\"hitl\", \"제안 + 대대장 승인\"), (\"auto\", \"제안 전부 적용(비교용)\")]:\n", + " res = R[mode][0]\n", + " rows.append({\"방식\": name, \"가동중단 비용지수\": round(res[\"cost\"].sum()), \"핵심장비 도착(h)\": round(res.loc[res[\"crit\"] == 3, \"response_h\"].mean(), 2),\n", + " \"핵심장비 가동중단(h)\": round(res.loc[res[\"crit\"] == 3, \"downtime_h\"].mean(), 2), \"전체 도착(h)\": round(res[\"response_h\"].mean(), 2)})\n", + "T = pd.DataFrame(rows)\n", + "print(\"[교육용가상자료] 30일 모의 결과\")\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", + "\n", + "P = R[\"hitl\"][1]\n", + "print(f\"\\n변경 제안 {len(P)}건: \" + \", \".join(f\"{k} {v}건\" for k, v in P[\"decision\"].value_counts().items()))\n", + "rej = [e[\"reason\"] for e in audit.log if e.get(\"decision\") == \"반려\"]\n", + "print(\"반려 사유 예:\", \"; \".join([next(x for x in rej if \"구역\" in x), next(x for x in rej if \"이득\" in x)]))\n", + "n_dec = sum(e[\"event\"] == \"DECIDE\" for e in audit.log)\n", + "print(f\"제안 {len(P)}건 중 결정 기록 {n_dec}건(로깅률 {n_dec / max(len(P), 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", + "# 민감도: 승인 지연이 길어지면 이득이 줄어든다(전시 전환 등 급증 대비)\n", + "print(\"\\n[민감도] 승인 지연(h) -> 비용 절감률\")\n", + "for d in (0.0, 0.25, 0.5, 1.0):\n", + " APPROVE_DELAY = d\n", + " r_, _ = run(req, teams, \"hitl\", seed=3, audit=Audit())\n", + " print(f\" {d:>4}: {1 - r_['cost'].sum() / T.loc[0, '가동중단 비용지수']:.1%}\")\n", + "surge = make_requests(seed=1, per_day=16)\n", + "APPROVE_DELAY = 0.25\n", + "b, _ = run(surge, teams, \"default\"); h, _ = run(surge, teams, \"hitl\", seed=3, audit=Audit())\n", + "print(f\"요청 1.6배 급증 시나리오: 비용 절감률 {1 - h['cost'].sum() / b['cost'].sum():.1%}\")\n", + "\n", + "plt.figure(figsize=(7, 4))\n", + "for mode, lab in [(\"default\", \"default plan\"), (\"hitl\", \"proposal + approval\")]:\n", + " s = R[mode][0]\n", + " plt.hist(s.loc[s[\"crit\"] == 3, \"response_h\"], bins=20, alpha=0.6, label=lab)\n", + "plt.xlabel(\"critical equipment response time (h)\"); plt.ylabel(\"count\"); plt.legend(); plt.title(\"HITL simulator: critical response\")\n", + "plt.tight_layout(); plt.show()\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 07 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + "[교육용가상자료] 30일 모의 결과\n", + " 방식 가동중단 비용지수 핵심장비 도착(h) 핵심장비 가동중단(h) 전체 도착(h)\n", + " 기본 계획 6495 3.13 5.82 3.37\n", + " 제안 + 대대장 승인 5254 2.10 4.71 2.29\n", + "제안 전부 적용(비교용) 5018 1.92 4.53 2.08\n", + "HITL 적용 가동중단 비용 절감률 19.1% (목표 15% 이상: 달성)\n", + "\n", + "변경 제안 103건: 승인 71건, 반려 24건, 무응답 8건\n", + "반려 사유 예: 정비반 구역 이탈 50km(복귀·보급 곤란); 기대 이득 0.5 작음 -> 현 계획 유지\n", + "제안 103건 중 결정 기록 103건(로깅률 100%), 자동 승인 0건, 무응답 시 기본 계획 유지\n", + "감사 로그 무결성: 정상, 사후 변조 시 seq 5에서 탐지\n", + "\n", + "[민감도] 승인 지연(h) -> 비용 절감률\n", + " 0.0: 20.0%\n", + " 0.25: 19.1%\n", + " 0.5: 17.6%\n", + " 1.0: 13.4%\n", + "요청 1.6배 급증 시나리오: 비용 절감률 46.5%\n", + "```\n", + "
\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 08. 동시 20개소 긴급 정비 요청 발생 모의 출동 스케줄링 스트레스 테스트\n", + "**소요시간**: 약 3.0시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "동시 20개소 긴급 정비 요청 발생 모의 스트레스 테스트를 수행한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **동시 20개소 긴급 정비 요청이 한꺼번에 들어오는 상황에서 출동 스케줄링과 관제 화면이 제때 동작하는지 스트레스 시험한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **동시 20·40·60개소 요청, 정비반 1개 출동 불가, 손상 요청 데이터를 주입하고 할당 최적화로 배정 수행하고 검증하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `src/dashboard/mnt10_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": [ + "\"\"\"동시 20개소(최대 60개소) 긴급 정비 요청 발생, 정비반 1개 출동 불가, 손상 요청 데이터를 주입해 출동 스케줄링과 관제 대시보드를 스트레스 테스트한다. 스케줄링+화면 생성 시간, '핵심장비 4시간 내 도착 불가' 경보 정확도, 요청 유실, 손상 데이터의 수동 확인 분리와 필터 조회 응답을 검증한다.\"\"\"\n", + "import time\n", + "import numpy as np\n", + "import pandas as pd\n", + "import matplotlib\n", + "import matplotlib.pyplot as plt\n", + "from scipy.optimize import linear_sum_assignment\n", + "\n", + "# ---------------------------------------------------------------------------\n", + "# [교육용가상자료] 야전 이동정비반·긴급 정비 요청 생성기 (실제 부대·위치·장비·인원 정보 없음)\n", + "# 60km x 60km 가상 지역에 이동정비반 6개와 30일간의 긴급 정비 요청을 만든다.\n", + "# 장비 구분(mobility/fires/comms/power)과 고장 유형은 교육용 추상 분류다.\n", + "# ---------------------------------------------------------------------------\n", + "AREA_KM, DUTY_H = 60, 10.0 # 가상 작전지역 크기, 정비반 1일 기준 가용시간\n", + "FAULT_H = {\"engine\": 6.0, \"drivetrain\": 5.0, \"hydraulic\": 3.0, \"electric\": 2.0, \"comm_module\": 1.5, \"cooling\": 2.5}\n", + "FIELD_P = {\"engine\": 0.3, \"drivetrain\": 0.35, \"hydraulic\": 0.75, \"electric\": 0.9, \"comm_module\": 0.95, \"cooling\": 0.8}\n", + "EQUIP_F = {\"mobility\": 1.0, \"fires\": 1.2, \"comms\": 0.7, \"power\": 0.8}\n", + "FAULT_OF = {\"mobility\": [0.3, 0.25, 0.2, 0.1, 0.0, 0.15], \"fires\": [0.2, 0.15, 0.35, 0.15, 0.0, 0.15],\n", + " \"comms\": [0.0, 0.0, 0.0, 0.4, 0.5, 0.1], \"power\": [0.35, 0.0, 0.0, 0.35, 0.0, 0.3]}\n", + "CRIT_P = {\"mobility\": [0.45, 0.35, 0.2], \"fires\": [0.2, 0.35, 0.45], \"comms\": [0.3, 0.4, 0.3], \"power\": [0.6, 0.3, 0.1]}\n", + "HUBS = [(15, 20), (45, 25), (30, 50)] # 가상 훈련장 3곳(요청이 몰리는 지점)\n", + "\n", + "\n", + "def make_teams(seed=0):\n", + " rng = np.random.default_rng(seed)\n", + " base = [(8, 10), (30, 6), (52, 12), (10, 44), (32, 38), (54, 50)]\n", + " skills = [[\"fires\", \"mobility\"], [\"comms\", \"mobility\"], [\"fires\", \"power\"],\n", + " [\"mobility\", \"power\"], [\"comms\", \"fires\"], [\"comms\", \"mobility\", \"power\"]]\n", + " return [{\"team\": f\"MT{i + 1}\", \"x\": float(x), \"y\": float(y), \"crew\": int(rng.integers(3, 6)), \"skills\": s}\n", + " for i, ((x, y), s) in enumerate(zip(base, skills))]\n", + "\n", + "\n", + "def make_requests(n_days=30, per_day=10, seed=1, surge_days=(8, 9, 10, 11, 22, 23, 24)):\n", + " \"\"\"하루 평균 per_day건, 훈련 주간(surge_days)에는 1.6배의 긴급 정비 요청을 만든다.\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " rows = []\n", + " for d in range(n_days):\n", + " n = rng.poisson(per_day * (1.6 if d in surge_days else 1.0))\n", + " weather = rng.choice([\"clear\", \"rain\", \"snow\"], p=[0.65, 0.25, 0.1])\n", + " for _ in range(n):\n", + " if rng.random() < 0.7:\n", + " hx, hy = HUBS[rng.integers(0, 3)]\n", + " x, y = np.clip(rng.normal(hx, 6), 0, AREA_KM), np.clip(rng.normal(hy, 6), 0, AREA_KM)\n", + " else:\n", + " x, y = rng.uniform(0, AREA_KM, 2)\n", + " eq = str(rng.choice(list(EQUIP_F), p=[0.45, 0.25, 0.2, 0.1]))\n", + " fault = str(rng.choice(list(FAULT_H), p=FAULT_OF[eq]))\n", + " parts = bool(rng.random() < 0.75)\n", + " bad = weather != \"clear\"\n", + " std_h = FAULT_H[fault] * EQUIP_F[eq] * (1.35 if not parts else 1.0) * (1.15 if bad else 1.0) * rng.lognormal(0, 0.12)\n", + " rows.append({\"t_req\": d * 24 + float(np.clip(rng.normal(13, 4), 5, 23)), \"day\": d,\n", + " \"x\": float(x), \"y\": float(y), \"equip\": eq, \"fault\": fault,\n", + " \"crit\": int(rng.choice([1, 2, 3], p=CRIT_P[eq])), \"parts_ready\": parts,\n", + " \"weather\": str(weather), \"road\": \"unpaved\" if y > 30 and rng.random() < 0.7 else \"paved\",\n", + " \"field_ok\": bool(rng.random() < FIELD_P[fault] * (1.0 if parts else 0.5)),\n", + " \"std_repair_h\": round(float(std_h), 2), \"road_delay\": float(rng.lognormal(0, 0.15))})\n", + " df = pd.DataFrame(rows).sort_values(\"t_req\").reset_index(drop=True)\n", + " df.insert(0, \"req_id\", [f\"R{i:04d}\" for i in range(len(df))])\n", + " return df\n", + "# ---------------------------------------------------------------------------\n", + "\n", + "# ---- 출동 배정·정비 모의 공통 함수 ----\n", + "W0 = {\"crit\": 1.0, \"wait\": 0.3, \"short\": 0.2, \"balance\": 0.5} # 우선순위·배정 기본 가중치(미션 9에서 재튜닝)\n", + "\n", + "\n", + "def travel_h(team, r, actual=False):\n", + " \"\"\"도로 굴곡 1.3배, 포장 35km/h·비포장 25km/h, 악천후 20% 감속. actual=True면 실제 도로 지연을 반영.\"\"\"\n", + " # [TODO : 학생 실습 8] 동시 20개소 긴급 정비 요청 발생 모의 출동 스케줄링 스트레스 테스트 관련 핵심 로직을 구현하세요.\n", + " # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n", + " # <<< 여기에 코드를 작성하세요 >>>\n", + " pass # 작성 후 pass를 제거하세요.\n", + "def on_site_h(team, r):\n", + " \"\"\"현장 정비시간: 4인 기준 표준시간을 투입 인원으로 환산. 현장 수리 불가면 진단·후송 조치 1시간.\"\"\"\n", + " # [TODO : 학생 실습 8] 동시 20개소 긴급 정비 요청 발생 모의 출동 스케줄링 스트레스 테스트 관련 핵심 로직을 구현하세요.\n", + " # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n", + " # <<< 여기에 코드를 작성하세요 >>>\n", + " pass # 작성 후 pass를 제거하세요.\n", + "def priority(r, now, est_h, w):\n", + " return w[\"crit\"] * r[\"crit\"] + w[\"wait\"] * (now - r[\"t_req\"]) + w[\"short\"] / (1 + est_h)\n", + "\n", + "\n", + "def simulate(req, teams, policy=\"baseline\", w=None, cycle_h=1.0, est=None):\n", + " \"\"\"배정 주기(cycle_h)마다 대기 요청을 정렬해 이동정비반에 배정하고 출동·정비·복귀를 모의한다.\n", + " baseline: 접수 순서 + 거리 최근접 정비반 / priority: 우선순위 점수 순서 + 도착 예상시각·당일 부하 균형\"\"\"\n", + " w = w or W0\n", + " est = np.asarray(est if est is not None else req[\"std_repair_h\"].values, float)\n", + " n_days = int(req[\"day\"].max()) + 1\n", + " free = {t[\"team\"]: 0.0 for t in teams}\n", + " load = {t[\"team\"]: np.zeros(n_days + 5) for t in teams}\n", + " recs, out, pending, i, now = req.to_dict(\"records\"), [], [], 0, cycle_h\n", + " while i < len(recs) or pending:\n", + " while i < len(recs) and recs[i][\"t_req\"] <= now:\n", + " pending.append((i, recs[i])); i += 1\n", + " if policy == \"priority\":\n", + " pending.sort(key=lambda p: -priority(p[1], now, est[p[0]], w))\n", + " for k, r in pending:\n", + " cand = [t for t in teams if r[\"equip\"] in t[\"skills\"]]\n", + " day = min(int(now // 24), n_days + 4)\n", + " if policy == \"baseline\":\n", + " tm = min(cand, key=lambda t: np.hypot(t[\"x\"] - r[\"x\"], t[\"y\"] - r[\"y\"]))\n", + " else:\n", + " tm = min(cand, key=lambda t: max(free[t[\"team\"]], now) + travel_h(t, r)\n", + " + w[\"balance\"] * load[t[\"team\"]][day] / DUTY_H * 4)\n", + " backlog = max(0.0, free[tm[\"team\"]] - now)\n", + " go = max(free[tm[\"team\"]], now)\n", + " tr = travel_h(tm, r, actual=True)\n", + " work = on_site_h(tm, r)\n", + " free[tm[\"team\"]] = go + 2 * tr + work\n", + " load[tm[\"team\"]][min(int(go // 24), n_days + 4)] += 2 * tr + work\n", + " out.append({**r, \"idx\": k, \"team\": tm[\"team\"], \"dist_km\": float(np.hypot(tm[\"x\"] - r[\"x\"], tm[\"y\"] - r[\"y\"])),\n", + " \"backlog_h\": backlog, \"cycle_wait_h\": now - r[\"t_req\"], \"dispatch\": go, \"travel_h\": tr,\n", + " \"work_h\": work, \"response_h\": go + tr - r[\"t_req\"], \"downtime_h\": go + tr + work - r[\"t_req\"]})\n", + " pending, now = [], now + cycle_h\n", + " L = pd.DataFrame({k: v[:n_days] for k, v in load.items()}) / DUTY_H # 정비반별 일일 부하율\n", + " return pd.DataFrame(out).sort_values(\"idx\").reset_index(drop=True), L\n", + "\n", + "\n", + "def metrics(res, L):\n", + " c3 = res[res[\"crit\"] == 3]\n", + " return {\"평균 도착(h)\": round(res[\"response_h\"].mean(), 2), \"핵심장비 도착(h)\": round(c3[\"response_h\"].mean(), 2),\n", + " \"핵심장비 90%(h)\": round(c3[\"response_h\"].quantile(0.9), 2), \"평균 부하율\": round(float(L.values.mean()), 3),\n", + " \"부하율 편차\": round(float(L.mean().std()), 3), \"과부하 일수\": int((L > 1.0).values.sum()),\n", + " \"총 이동(h)\": round(float(res[\"travel_h\"].sum() * 2), 1)}\n", + "\n", + "CW = {1: 1.0, 2: 3.0, 3: 6.0}\n", + "ALERT_H = 4.0 # 핵심장비(crit 3) 도착 목표시간\n", + "T0 = 15 * 24 + 10.0 # 15일차 10시 동시 다발 요청 발생\n", + "\n", + "\n", + "def make_burst(n, seed):\n", + " \"\"\"[교육용가상자료] 동시 n개소 요청(같은 시각 접수). 일부 정비반은 기존 작업으로 바쁜 상태.\"\"\"\n", + " pool = make_requests(n_days=10, seed=seed)\n", + " b = pool.sample(n, random_state=seed).reset_index(drop=True).astype({\"x\": float, \"crit\": object})\n", + " b[\"t_req\"] = T0; b[\"req_id\"] = [f\"B{seed}-{i:02d}\" for i in range(n)]\n", + " return b\n", + "\n", + "\n", + "def validate(r):\n", + " errs = []\n", + " if not (0 <= r[\"x\"] <= AREA_KM and 0 <= r[\"y\"] <= AREA_KM):\n", + " errs.append(\"좌표 범위 오류\")\n", + " if r[\"equip\"] not in EQUIP_F or r[\"fault\"] not in FAULT_H:\n", + " errs.append(\"장비/고장 코드 오류\")\n", + " if r[\"crit\"] not in (1, 2, 3):\n", + " errs.append(\"중요도 누락\")\n", + " return errs\n", + "\n", + "\n", + "def schedule(burst, teams, busy_until):\n", + " \"\"\"할당 문제로 동시 요청을 (정비반, 순번)에 배정. 비용 = 중요도 가중 x 예상 도착시각.\"\"\"\n", + " slots = int(np.ceil(len(burst) / len(teams))) + 2\n", + " cols = [(t, s) for t in teams for s in range(slots)]\n", + " avg = 4.0\n", + " cost = np.full((len(burst), len(cols)), 1e6)\n", + " for i, r in enumerate(burst.to_dict(\"records\")):\n", + " for j, (t, s) in enumerate(cols):\n", + " if r[\"equip\"] in t[\"skills\"]:\n", + " cost[i, j] = CW[r[\"crit\"]] * (busy_until[t[\"team\"]] - T0 + s * avg + travel_h(t, r))\n", + " ri, cj = linear_sum_assignment(cost)\n", + " plan = sorted(((i, cols[j][0], cols[j][1]) for i, j in zip(ri, cj)), key=lambda p: (p[1][\"team\"], p[2]))\n", + " free, rows = dict(busy_until), []\n", + " for i, t, s in plan:\n", + " r = burst.iloc[i]\n", + " go = max(free[t[\"team\"]], T0)\n", + " est = go + travel_h(t, r) - T0\n", + " act_tr = travel_h(t, r, actual=True)\n", + " rows.append({\"req_id\": r[\"req_id\"], \"crit\": r[\"crit\"], \"equip\": r[\"equip\"], \"team\": t[\"team\"], \"eta_h\": est,\n", + " \"actual_h\": go + act_tr - T0, \"alert\": r[\"crit\"] == 3 and est > ALERT_H})\n", + " free[t[\"team\"]] = go + 2 * act_tr + on_site_h(t, r) # 실제로는 실측 이동시간대로 다음 출동이 정해진다\n", + " return pd.DataFrame(rows)\n", + "\n", + "\n", + "def dashboard(plan, teams):\n", + " fig, ax = plt.subplots(1, 3, figsize=(14, 4))\n", + " ld = plan.groupby(\"team\").size().reindex([t[\"team\"] for t in teams], fill_value=0)\n", + " ax[0].bar(ld.index, ld.values); ax[0].set_title(\"Assigned requests per team\")\n", + " ax[1].scatter(plan[\"eta_h\"], plan[\"crit\"], c=np.where(plan[\"alert\"], \"red\", \"gray\")); ax[1].axvline(ALERT_H, ls=\"--\")\n", + " ax[1].set_xlabel(\"ETA (h)\"); ax[1].set_yticks([1, 2, 3]); ax[1].set_title(\"ETA by criticality (red = alert)\")\n", + " ax[2].hist(plan[\"eta_h\"], bins=15); ax[2].set_title(\"ETA distribution\")\n", + " fig.tight_layout(); fig.canvas.draw()\n", + " return fig\n", + "\n", + "\n", + "teams_all = make_teams()\n", + "rng = np.random.default_rng(4)\n", + "busy0 = {t[\"team\"]: T0 + float(rng.uniform(0, 3)) for t in teams_all} # 기존 작업 잔여시간\n", + "rows, keep = [], {}\n", + "for n, down, bad in [(20, None, 0), (20, \"MT5\", 3), (40, \"MT5\", 3), (60, \"MT5\", 5)]:\n", + " burst = make_burst(n, seed=n)\n", + " for k in range(bad): # 손상 데이터 주입\n", + " burst.loc[k * 3, [\"x\", \"equip\", \"crit\"][k % 3]] = [999.0, \"unknown\", 0][k % 3]\n", + " teams = [t for t in teams_all if t[\"team\"] != down]\n", + " t0 = time.perf_counter()\n", + " ok = burst[[not validate(r) for r in burst.to_dict(\"records\")]]\n", + " manual = burst.drop(ok.index)\n", + " plan = schedule(ok, teams, {t[\"team\"]: busy0[t[\"team\"]] for t in teams})\n", + " fig = dashboard(plan, teams)\n", + " sec = time.perf_counter() - t0\n", + " truth = (plan[\"crit\"] == 3) & (plan[\"actual_h\"] > ALERT_H)\n", + " acc = float((plan[\"alert\"] == truth).mean())\n", + " lost = n - len(plan) - len(manual)\n", + " rows.append({\"상황\": f\"{n}개소\" + (f\", {down} 불가\" if down else \"\") + (f\", 손상 {bad}건\" if bad else \"\"), \"배정\": len(plan), \"수동 확인\": len(manual),\n", + " \"유실\": lost, \"처리+화면(초)\": round(sec, 2), \"핵심장비 경보\": int(plan[\"alert\"].sum()), \"경보 정확도\": round(acc, 3),\n", + " \"핵심 평균 도착(h)\": round(plan.loc[plan[\"crit\"] == 3, \"eta_h\"].mean(), 2)})\n", + " keep[n, down] = (fig, plan)\n", + " if (n, down) != (60, \"MT5\"):\n", + " plt.close(fig)\n", + "T = pd.DataFrame(rows)\n", + "print(\"[교육용가상자료] 동시 다발 긴급 정비 요청 스트레스 테스트\")\n", + "print(T.to_string(index=False))\n", + "m = T.iloc[1]\n", + "print(f\"\\n동시 20개소(정비반 1개 불가·손상 3건): 처리+화면 {m['처리+화면(초)']}초 (목표 2초 이내: {'달성' if T['처리+화면(초)'].max() <= 2 else '미달'}), \"\n", + " f\"경보 정확도 {m['경보 정확도']:.1%} (목표 95% 이상: {'달성' if T['경보 정확도'].min() >= 0.95 else '미달'}), 유실 {int(T['유실'].sum())}건\")\n", + "print(\"손상 요청은 삭제하지 않고 '수동 확인' 목록으로 넘겨 관제병이 전화·무전으로 재확인한다.\")\n", + "\n", + "plan = keep[20, \"MT5\"][1]\n", + "qt = []\n", + "for c in (1, 2, 3):\n", + " for e in EQUIP_F:\n", + " t0 = time.perf_counter(); plan[(plan[\"crit\"] == c) & (plan[\"equip\"] == e)]; qt.append((time.perf_counter() - t0) * 1000)\n", + "print(f\"실시간 필터(중요도 x 장비 12조합) 평균 {np.mean(qt):.2f} ms\")\n", + "plan = keep[40, \"MT5\"][1]\n", + "miss = plan[(plan[\"crit\"] == 3) & (plan[\"actual_h\"] > ALERT_H) & ~plan[\"alert\"]]\n", + "print(f\"40개소 상황: 놓친 경보 {len(miss)}건, 헛경보 {int((plan['alert'] & ~((plan['crit'] == 3) & (plan['actual_h'] > ALERT_H))).sum())}건\"\n", + " \" -> 도로 지연(비포장·기상)을 ETA에 반영하면 줄일 수 있다.\")\n", + "print(\"기능 축소 운용: 처리 시간이 2초를 넘으면 할당 최적화 대신 '중요도 순 + 최근접' 규칙으로 즉시 배정하고 최적화는 뒤에서 재계산한다.\")\n", + "plt.show()\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 08 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + "[교육용가상자료] 동시 다발 긴급 정비 요청 스트레스 테스트\n", + " 상황 배정 수동 확인 유실 처리+화면(초) 핵심장비 경보 경보 정확도 핵심 평균 도착(h)\n", + " 20개소 20 0 0 0.24 1 1.000 3.17\n", + "20개소, MT5 불가, 손상 3건 17 3 0 0.23 3 1.000 4.56\n", + "40개소, MT5 불가, 손상 3건 37 3 0 0.23 3 0.973 4.55\n", + "60개소, MT5 불가, 손상 5건 55 5 0 0.23 6 1.000 5.64\n", + "\n", + "동시 20개소(정비반 1개 불가·손상 3건): 처리+화면 0.23초 (목표 2초 이내: 달성), 경보 정확도 100.0% (목표 95% 이상: 달성), 유실 0건\n", + "손상 요청은 삭제하지 않고 '수동 확인' 목록으로 넘겨 관제병이 전화·무전으로 재확인한다.\n", + "실시간 필터(중요도 x 장비 12조합) 평균 0.69 ms\n", + "40개소 상황: 놓친 경보 0건, 헛경보 1건 -> 도로 지연(비포장·기상)을 ETA에 반영하면 줄일 수 있다.\n", + "기능 축소 운용: 처리 시간이 2초를 넘으면 할당 최적화 대신 '중요도 순 + 최근접' 규칙으로 즉시 배정하고 최적화는 뒤에서 재계산한다.\n", + "```\n", + "
\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 09. AAR 기반 출동 우선순위 배정 오류 분석 및 가중치 매트릭스 재튜닝\n", + "**소요시간**: 약 3.0시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "AAR 기반 출동 우선순위 배정 오류를 분석하고 가중치를 재튜닝한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **출동 우선순위 배정 결과를 AAR로 검토해 오류 원인을 분석하고, 가중치 매트릭스를 재튜닝해 도입 효과를 정량 지표로 산출한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **핵심장비 도착 4시간 초과 사례를 정비반 부하 집중·원거리 배정·도로·기상 지연·배정 주기 대기로 분류 수행하고 검증하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `reports/mnt10_roi_analysis.md` 형태의 결과물을 완성한다.\n", + "\n", + "### 🔍 평가 및 검증 기준 (PASS 조건)\n", + "#### 미션 판정\n", + "\n", + "**평가 구분:** 필수\n", + "\n", + "**핵심 평가영역:** 추정 산식의 객관성 및 군 재정/군수 기획 부서 설득력\n", + "\n", + "#### 미션 PASS 조건\n", + "\n", + "- 정량 기준: 비용 절감액, 시간 단축률 등 3대 정량 효과 지표 산출\n", + "\n", + "\n", + "- 진행 기준: ROI 분석 보고서 완성 및 주요 지표 민감도 분석 수록\n", + "\n", + "\n", + "- 검증 방법: 회계/기획 기준 모델과의 비교 검증\n", + "\n", + "\n", + "#### 채점자 판정 메모\n", + "\n", + "> 단순 코드 실행 여부뿐만 아니라 출력된 수치와 근거 자료가 군 실무 요구사항에 부합하는지 면밀히 확인한다.\n", + "\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "\"\"\"출동 우선순위 배정 결과를 사후검토(AAR)해 핵심장비 지연 사례의 원인(정비반 부하 집중·원거리 배정·도로·기상 지연 등)을 분류하고, 과거 기간으로 우선순위·배정 가중치 매트릭스를 재튜닝해 평가 기간에 검증한다. 가동중단 비용·핵심장비 도착시간·정비인력 초과근무의 3대 정량 효과 지표와 ROI 요약을 출력한다.\"\"\"\n", + "import itertools\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", + "# [교육용가상자료] 야전 이동정비반·긴급 정비 요청 생성기 (실제 부대·위치·장비·인원 정보 없음)\n", + "# 60km x 60km 가상 지역에 이동정비반 6개와 30일간의 긴급 정비 요청을 만든다.\n", + "# 장비 구분(mobility/fires/comms/power)과 고장 유형은 교육용 추상 분류다.\n", + "# ---------------------------------------------------------------------------\n", + "AREA_KM, DUTY_H = 60, 10.0 # 가상 작전지역 크기, 정비반 1일 기준 가용시간\n", + "FAULT_H = {\"engine\": 6.0, \"drivetrain\": 5.0, \"hydraulic\": 3.0, \"electric\": 2.0, \"comm_module\": 1.5, \"cooling\": 2.5}\n", + "FIELD_P = {\"engine\": 0.3, \"drivetrain\": 0.35, \"hydraulic\": 0.75, \"electric\": 0.9, \"comm_module\": 0.95, \"cooling\": 0.8}\n", + "EQUIP_F = {\"mobility\": 1.0, \"fires\": 1.2, \"comms\": 0.7, \"power\": 0.8}\n", + "FAULT_OF = {\"mobility\": [0.3, 0.25, 0.2, 0.1, 0.0, 0.15], \"fires\": [0.2, 0.15, 0.35, 0.15, 0.0, 0.15],\n", + " \"comms\": [0.0, 0.0, 0.0, 0.4, 0.5, 0.1], \"power\": [0.35, 0.0, 0.0, 0.35, 0.0, 0.3]}\n", + "CRIT_P = {\"mobility\": [0.45, 0.35, 0.2], \"fires\": [0.2, 0.35, 0.45], \"comms\": [0.3, 0.4, 0.3], \"power\": [0.6, 0.3, 0.1]}\n", + "HUBS = [(15, 20), (45, 25), (30, 50)] # 가상 훈련장 3곳(요청이 몰리는 지점)\n", + "\n", + "\n", + "def make_teams(seed=0):\n", + " rng = np.random.default_rng(seed)\n", + " base = [(8, 10), (30, 6), (52, 12), (10, 44), (32, 38), (54, 50)]\n", + " skills = [[\"fires\", \"mobility\"], [\"comms\", \"mobility\"], [\"fires\", \"power\"],\n", + " [\"mobility\", \"power\"], [\"comms\", \"fires\"], [\"comms\", \"mobility\", \"power\"]]\n", + " return [{\"team\": f\"MT{i + 1}\", \"x\": float(x), \"y\": float(y), \"crew\": int(rng.integers(3, 6)), \"skills\": s}\n", + " for i, ((x, y), s) in enumerate(zip(base, skills))]\n", + "\n", + "\n", + "def make_requests(n_days=30, per_day=10, seed=1, surge_days=(8, 9, 10, 11, 22, 23, 24)):\n", + " \"\"\"하루 평균 per_day건, 훈련 주간(surge_days)에는 1.6배의 긴급 정비 요청을 만든다.\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " rows = []\n", + " for d in range(n_days):\n", + " n = rng.poisson(per_day * (1.6 if d in surge_days else 1.0))\n", + " weather = rng.choice([\"clear\", \"rain\", \"snow\"], p=[0.65, 0.25, 0.1])\n", + " for _ in range(n):\n", + " if rng.random() < 0.7:\n", + " hx, hy = HUBS[rng.integers(0, 3)]\n", + " x, y = np.clip(rng.normal(hx, 6), 0, AREA_KM), np.clip(rng.normal(hy, 6), 0, AREA_KM)\n", + " else:\n", + " x, y = rng.uniform(0, AREA_KM, 2)\n", + " eq = str(rng.choice(list(EQUIP_F), p=[0.45, 0.25, 0.2, 0.1]))\n", + " fault = str(rng.choice(list(FAULT_H), p=FAULT_OF[eq]))\n", + " parts = bool(rng.random() < 0.75)\n", + " bad = weather != \"clear\"\n", + " std_h = FAULT_H[fault] * EQUIP_F[eq] * (1.35 if not parts else 1.0) * (1.15 if bad else 1.0) * rng.lognormal(0, 0.12)\n", + " rows.append({\"t_req\": d * 24 + float(np.clip(rng.normal(13, 4), 5, 23)), \"day\": d,\n", + " \"x\": float(x), \"y\": float(y), \"equip\": eq, \"fault\": fault,\n", + " \"crit\": int(rng.choice([1, 2, 3], p=CRIT_P[eq])), \"parts_ready\": parts,\n", + " \"weather\": str(weather), \"road\": \"unpaved\" if y > 30 and rng.random() < 0.7 else \"paved\",\n", + " \"field_ok\": bool(rng.random() < FIELD_P[fault] * (1.0 if parts else 0.5)),\n", + " \"std_repair_h\": round(float(std_h), 2), \"road_delay\": float(rng.lognormal(0, 0.15))})\n", + " df = pd.DataFrame(rows).sort_values(\"t_req\").reset_index(drop=True)\n", + " df.insert(0, \"req_id\", [f\"R{i:04d}\" for i in range(len(df))])\n", + " return df\n", + "# ---------------------------------------------------------------------------\n", + "\n", + "# ---- 출동 배정·정비 모의 공통 함수 ----\n", + "W0 = {\"crit\": 1.0, \"wait\": 0.3, \"short\": 0.2, \"balance\": 0.5} # 우선순위·배정 기본 가중치(미션 9에서 재튜닝)\n", + "\n", + "\n", + "def travel_h(team, r, actual=False):\n", + " \"\"\"도로 굴곡 1.3배, 포장 35km/h·비포장 25km/h, 악천후 20% 감속. actual=True면 실제 도로 지연을 반영.\"\"\"\n", + " # [TODO : 학생 실습 9] AAR 기반 출동 우선순위 배정 오류 분석 및 가중치 매트릭스 재튜닝 관련 핵심 로직을 구현하세요.\n", + " # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n", + " # <<< 여기에 코드를 작성하세요 >>>\n", + " pass # 작성 후 pass를 제거하세요.\n", + "def on_site_h(team, r):\n", + " \"\"\"현장 정비시간: 4인 기준 표준시간을 투입 인원으로 환산. 현장 수리 불가면 진단·후송 조치 1시간.\"\"\"\n", + " # [TODO : 학생 실습 9] AAR 기반 출동 우선순위 배정 오류 분석 및 가중치 매트릭스 재튜닝 관련 핵심 로직을 구현하세요.\n", + " # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n", + " # <<< 여기에 코드를 작성하세요 >>>\n", + " pass # 작성 후 pass를 제거하세요.\n", + "def priority(r, now, est_h, w):\n", + " return w[\"crit\"] * r[\"crit\"] + w[\"wait\"] * (now - r[\"t_req\"]) + w[\"short\"] / (1 + est_h)\n", + "\n", + "\n", + "def simulate(req, teams, policy=\"baseline\", w=None, cycle_h=1.0, est=None):\n", + " \"\"\"배정 주기(cycle_h)마다 대기 요청을 정렬해 이동정비반에 배정하고 출동·정비·복귀를 모의한다.\n", + " baseline: 접수 순서 + 거리 최근접 정비반 / priority: 우선순위 점수 순서 + 도착 예상시각·당일 부하 균형\"\"\"\n", + " w = w or W0\n", + " est = np.asarray(est if est is not None else req[\"std_repair_h\"].values, float)\n", + " n_days = int(req[\"day\"].max()) + 1\n", + " free = {t[\"team\"]: 0.0 for t in teams}\n", + " load = {t[\"team\"]: np.zeros(n_days + 5) for t in teams}\n", + " recs, out, pending, i, now = req.to_dict(\"records\"), [], [], 0, cycle_h\n", + " while i < len(recs) or pending:\n", + " while i < len(recs) and recs[i][\"t_req\"] <= now:\n", + " pending.append((i, recs[i])); i += 1\n", + " if policy == \"priority\":\n", + " pending.sort(key=lambda p: -priority(p[1], now, est[p[0]], w))\n", + " for k, r in pending:\n", + " cand = [t for t in teams if r[\"equip\"] in t[\"skills\"]]\n", + " day = min(int(now // 24), n_days + 4)\n", + " if policy == \"baseline\":\n", + " tm = min(cand, key=lambda t: np.hypot(t[\"x\"] - r[\"x\"], t[\"y\"] - r[\"y\"]))\n", + " else:\n", + " tm = min(cand, key=lambda t: max(free[t[\"team\"]], now) + travel_h(t, r)\n", + " + w[\"balance\"] * load[t[\"team\"]][day] / DUTY_H * 4)\n", + " backlog = max(0.0, free[tm[\"team\"]] - now)\n", + " go = max(free[tm[\"team\"]], now)\n", + " tr = travel_h(tm, r, actual=True)\n", + " work = on_site_h(tm, r)\n", + " free[tm[\"team\"]] = go + 2 * tr + work\n", + " load[tm[\"team\"]][min(int(go // 24), n_days + 4)] += 2 * tr + work\n", + " out.append({**r, \"idx\": k, \"team\": tm[\"team\"], \"dist_km\": float(np.hypot(tm[\"x\"] - r[\"x\"], tm[\"y\"] - r[\"y\"])),\n", + " \"backlog_h\": backlog, \"cycle_wait_h\": now - r[\"t_req\"], \"dispatch\": go, \"travel_h\": tr,\n", + " \"work_h\": work, \"response_h\": go + tr - r[\"t_req\"], \"downtime_h\": go + tr + work - r[\"t_req\"]})\n", + " pending, now = [], now + cycle_h\n", + " L = pd.DataFrame({k: v[:n_days] for k, v in load.items()}) / DUTY_H # 정비반별 일일 부하율\n", + " return pd.DataFrame(out).sort_values(\"idx\").reset_index(drop=True), L\n", + "\n", + "\n", + "def metrics(res, L):\n", + " c3 = res[res[\"crit\"] == 3]\n", + " return {\"평균 도착(h)\": round(res[\"response_h\"].mean(), 2), \"핵심장비 도착(h)\": round(c3[\"response_h\"].mean(), 2),\n", + " \"핵심장비 90%(h)\": round(c3[\"response_h\"].quantile(0.9), 2), \"평균 부하율\": round(float(L.values.mean()), 3),\n", + " \"부하율 편차\": round(float(L.mean().std()), 3), \"과부하 일수\": int((L > 1.0).values.sum()),\n", + " \"총 이동(h)\": round(float(res[\"travel_h\"].sum() * 2), 1)}\n", + "\n", + "# ---- 접수 시점 파생 피처(미션 4와 동일) ----\n", + "def make_features(req, teams):\n", + " \"\"\"접수 시점에 알 수 있는 정보만으로 파생 피처를 만든다(미래 정보 누설 방지).\"\"\"\n", + " X = pd.DataFrame(index=req.index)\n", + " X[\"hour\"] = req[\"t_req\"] % 24\n", + " X[\"is_night\"] = (X[\"hour\"] >= 19).astype(int)\n", + " X[\"dow\"] = req[\"day\"] % 7\n", + " X[\"weather_bad\"] = (req[\"weather\"] != \"clear\").astype(int)\n", + " X[\"unpaved\"] = (req[\"road\"] == \"unpaved\").astype(int)\n", + " X[\"no_parts\"] = (~req[\"parts_ready\"]).astype(int)\n", + " X[\"std_table_h\"] = req[\"fault\"].map(FAULT_H) * req[\"equip\"].map(EQUIP_F) # 정비 표준시간표 값\n", + " X[\"crit_x_noparts\"] = req[\"crit\"] * X[\"no_parts\"]\n", + " X[\"bad_x_table\"] = X[\"weather_bad\"] * X[\"std_table_h\"]\n", + " X[\"near_team_km\"] = [min(np.hypot(t[\"x\"] - x, t[\"y\"] - y) for t in teams if e in t[\"skills\"]) for x, y, e in zip(req[\"x\"], req[\"y\"], req[\"equip\"])]\n", + " X[\"hub_km\"] = [min(np.hypot(hx - x, hy - y) for hx, hy in HUBS) for x, y in zip(req[\"x\"], req[\"y\"])]\n", + " X[\"req_24h\"] = [((req[\"t_req\"] > t - 24) & (req[\"t_req\"] <= t)).sum() for t in req[\"t_req\"]]\n", + " X[\"fault_past_mean\"] = req.groupby(\"fault\")[\"std_repair_h\"].transform(lambda s: s.shift().expanding().mean()).fillna(req[\"std_repair_h\"].mean())\n", + " X[\"crit\"] = req[\"crit\"]\n", + " for e in EQUIP_F:\n", + " X[f\"eq_{e}\"] = (req[\"equip\"] == e).astype(int)\n", + " return X\n", + "\n", + "\n", + "def fit_repair_model(hist, teams):\n", + " \"\"\"과거 요청으로 정비 소요시간(4인 기준) 예측 모델을 학습한다.\"\"\"\n", + " return GradientBoostingRegressor(n_estimators=200, max_depth=3, learning_rate=0.05, random_state=0).fit(make_features(hist, teams), hist[\"std_repair_h\"])\n", + "\n", + "CW = {1: 1.0, 2: 3.0, 3: 6.0} # 중요도별 가동중단 비용 지수\n", + "PRICE = {\"downtime\": 10, \"move\": 5, \"overtime\": 2} # [교육용가상자료] 가상 단가(만원): 비용지수 1, 차량 운행 1h, 초과근무 1인·h\n", + "TARGET_H = 4.0\n", + "\n", + "\n", + "def evaluate(res, L, teams):\n", + " crew = pd.Series({t[\"team\"]: t[\"crew\"] for t in teams})\n", + " over = ((L - 1.0).clip(lower=0) * DUTY_H * crew).values.sum() # 초과근무 인·시간\n", + " return {\"가동중단 비용지수\": float((res[\"crit\"].map(CW) * res[\"downtime_h\"]).sum()),\n", + " \"핵심장비 도착(h)\": float(res.loc[res[\"crit\"] == 3, \"response_h\"].mean()),\n", + " \"초과근무(인·h)\": float(over), \"차량 운행(h)\": float(res[\"travel_h\"].sum() * 2),\n", + " \"핵심 4h 초과(건)\": int(((res[\"crit\"] == 3) & (res[\"response_h\"] > TARGET_H)).sum())}\n", + "\n", + "\n", + "def won(m):\n", + " return m[\"가동중단 비용지수\"] * PRICE[\"downtime\"] + m[\"차량 운행(h)\"] * PRICE[\"move\"] + m[\"초과근무(인·h)\"] * PRICE[\"overtime\"]\n", + "\n", + "\n", + "def objective(m):\n", + " \"\"\"튜닝 목표: 가상 총비용 + 핵심장비 4시간 초과 1건당 임무 영향 벌점(가상 500만원).\"\"\"\n", + " return won(m) + 500 * m[\"핵심 4h 초과(건)\"]\n", + "\n", + "\n", + "teams = make_teams()\n", + "hist, req = make_requests(seed=11), make_requests(seed=1)\n", + "model = fit_repair_model(hist, teams)\n", + "est_h, est_r = model.predict(make_features(hist, teams)), model.predict(make_features(req, teams))\n", + "\n", + "# 1) AAR: 현행 가중치(W0)로 운용한 평가 기간의 핵심장비 지연 원인 분류\n", + "res, L = simulate(req, teams, \"priority\", w=W0, est=est_r)\n", + "late = res[(res[\"crit\"] == 3) & (res[\"response_h\"] > TARGET_H)].copy()\n", + "est_tr = [travel_h(next(t for t in teams if t[\"team\"] == tm), r) for tm, r in zip(late[\"team\"], late.to_dict(\"records\"))]\n", + "late[\"원인\"] = np.select([late[\"backlog_h\"] > 1.0, late[\"dist_km\"] > 25, late[\"travel_h\"] > np.array(est_tr) * 1.2],\n", + " [\"정비반 부하 집중(앞 작업 대기)\", \"원거리 정비반 배정\", \"도로·기상 지연\"], \"배정 주기 대기\")\n", + "print(f\"[교육용가상자료] AAR: 핵심장비 요청 {int((res['crit'] == 3).sum())}건 중 {TARGET_H:.0f}시간 초과 {len(late)}건\")\n", + "print(late[\"원인\"].value_counts().to_string())\n", + "print(\"훈련 주간(요청 급증) 발생 비율 {:.0%}, 지연 건의 평균 앞 작업 대기 {:.1f}h\".format(late[\"day\"].isin([8, 9, 10, 11, 22, 23, 24]).mean(), late[\"backlog_h\"].mean()))\n", + "\n", + "# 2) 가중치 매트릭스 재튜닝: 과거 기간(hist)에서 탐색 -> 평가 기간(req)에서 검증\n", + "grid = []\n", + "for c, wt, sh, bl in itertools.product([0.5, 1.0, 2.0, 3.0], [0.1, 0.3, 0.6], [0.2, 1.0], [0.0, 0.5, 1.0, 2.0, 3.0]):\n", + " w = {\"crit\": c, \"wait\": wt, \"short\": sh, \"balance\": bl}\n", + " r_, L_ = simulate(hist, teams, \"priority\", w=w, est=est_h)\n", + " grid.append({**w, \"cost\": objective(evaluate(r_, L_, teams))})\n", + "G = pd.DataFrame(grid).sort_values(\"cost\")\n", + "W1 = {k: float(G.iloc[0][k]) for k in W0}\n", + "print(f\"\\n가중치 조합 {len(G)}개 탐색(과거 30일). 현행 {W0} -> 재튜닝 {W1}\")\n", + "\n", + "rows = {}\n", + "rb, Lb = simulate(req, teams, \"baseline\")\n", + "rows[\"Baseline(거리)\"] = evaluate(rb, Lb, teams)\n", + "rows[\"현행 W0\"] = evaluate(res, L, teams)\n", + "r1, L1 = simulate(req, teams, \"priority\", w=W1, est=est_r)\n", + "rows[\"재튜닝 W1\"] = evaluate(r1, L1, teams)\n", + "T = pd.DataFrame(rows).T\n", + "T[\"가상 총비용(만원)\"] = [won(m) for m in rows.values()]\n", + "print(\"\\n[평가 기간 30일 비교]\")\n", + "print(T.round(1).to_string())\n", + "print(f\"핵심장비 {TARGET_H:.0f}시간 초과: 현행 {len(late)}건 -> 재튜닝 {rows['재튜닝 W1']['핵심 4h 초과(건)']}건\")\n", + "w0, w1 = rows[\"현행 W0\"], rows[\"재튜닝 W1\"]\n", + "print(f\"재튜닝 효과(현행 대비): 가상 총비용 {won(w1) / won(w0) - 1:+.1%}, 차량 운행 {w1['차량 운행(h)'] / w0['차량 운행(h)'] - 1:+.1%}\")\n", + "print(\"AAR 결론: 남은 지연은 전 정비반이 동시에 바쁜 급증 구간의 대기에서 생겨 가중치로는 줄지 않는다\"\n", + " \" -> 훈련 주간 예고 시 정비반 사전 증원·상급 정비부대 지원 요청을 운영 조치로 권고한다.\")\n", + "\n", + "# 3) 3대 정량 효과 지표(Baseline 대비 재튜닝 W1)\n", + "b, n = rows[\"Baseline(거리)\"], rows[\"재튜닝 W1\"]\n", + "save = (b[\"가동중단 비용지수\"] - n[\"가동중단 비용지수\"]) * PRICE[\"downtime\"]\n", + "cut_h = 1 - n[\"핵심장비 도착(h)\"] / b[\"핵심장비 도착(h)\"]\n", + "over = b[\"초과근무(인·h)\"] - n[\"초과근무(인·h)\"]\n", + "print(\"\\n[3대 정량 효과 지표(30일, Baseline 대비)]\")\n", + "print(f\" 1) 가동중단 비용 절감액: {save:,.0f}만원(가상 단가)\")\n", + "print(f\" 2) 핵심장비 도착시간 단축률: {cut_h:.1%} ({b['핵심장비 도착(h)']:.2f}h -> {n['핵심장비 도착(h)']:.2f}h)\")\n", + "print(f\" 3) 정비인력 초과근무 변화: {over:+,.0f}인·h (양수=감소)\")\n", + "move = (n[\"차량 운행(h)\"] - b[\"차량 운행(h)\"]) * PRICE[\"move\"]\n", + "print(f\" 부대비용: 차량 운행 증가 {move:,.0f}만원 -> 순효과 {save + over * PRICE['overtime'] - move:,.0f}만원/월\")\n", + "print(\"ROI 로드맵: 단기(1~3개월) 관제 화면·우선순위 시범 -> 중기(6개월) 분기별 AAR 가중치 재튜닝 -> 장기(1년) 정비체계 연동\")\n", + "print(\"모든 금액은 교육용 가정 단가로 계산한 상대 비교값이며 실제 예산 근거로 쓰지 않는다.\")\n", + "\n", + "fig, ax = plt.subplots(1, 2, figsize=(11, 4))\n", + "late[\"원인\"].value_counts().rename(index={\"정비반 부하 집중(앞 작업 대기)\": \"team backlog\", \"원거리 정비반 배정\": \"far team\",\n", + " \"도로·기상 지연\": \"road/weather\", \"배정 주기 대기\": \"cycle wait\"}).plot.bar(ax=ax[0], rot=0)\n", + "ax[0].set_title(\"AAR: causes of late critical response\"); ax[0].set_xlabel(\"\")\n", + "ax[1].bar([\"baseline\", \"W0\", \"W1\"], T[\"가상 총비용(만원)\"]); ax[1].set_title(\"Virtual total cost (30 days)\")\n", + "plt.tight_layout(); plt.show()\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 09 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + "[교육용가상자료] AAR: 핵심장비 요청 99건 중 4시간 초과 8건\n", + "원인\n", + "정비반 부하 집중(앞 작업 대기) 8\n", + "훈련 주간(요청 급증) 발생 비율 62%, 지연 건의 평균 앞 작업 대기 3.5h\n", + "\n", + "가중치 조합 120개 탐색(과거 30일). 현행 {'crit': 1.0, 'wait': 0.3, 'short': 0.2, 'balance': 0.5} -> 재튜닝 {'crit': 0.5, 'wait': 0.1, 'short': 0.2, 'balance': 0.0}\n", + "\n", + "[평가 기간 30일 비교]\n", + " 가동중단 비용지수 핵심장비 도착(h) 초과근무(인·h) 차량 운행(h) 핵심 4h 초과(건) 가상 총비용(만원)\n", + "Baseline(거리) 6494.6 3.1 1018.3 504.7 27.0 69506.0\n", + "현행 W0 5055.3 1.9 869.7 744.9 8.0 56017.0\n", + "재튜닝 W1 4962.9 1.9 872.4 695.1 8.0 54848.7\n", + "핵심장비 4시간 초과: 현행 8건 -> 재튜닝 8건\n", + "재튜닝 효과(현행 대비): 가상 총비용 -2.1%, 차량 운행 -6.7%\n", + "AAR 결론: 남은 지연은 전 정비반이 동시에 바쁜 급증 구간의 대기에서 생겨 가중치로는 줄지 않는다 -> 훈련 주간 예고 시 정비반 사전 증원·상급 정비부대 지원 요청을 운영 조치로 권고한다.\n", + "\n", + "[3대 정량 효과 지표(30일, Baseline 대비)]\n", + " 1) 가동중단 비용 절감액: 15,317만원(가상 단가)\n", + " 2) 핵심장비 도착시간 단축률: 40.2% (3.13h -> 1.87h)\n", + " 3) 정비인력 초과근무 변화: +146인·h (양수=감소)\n", + " 부대비용: 차량 운행 증가 952만원 -> 순효과 14,657만원/월\n", + "ROI 로드맵: 단기(1~3개월) 관제 화면·우선순위 시범 -> 중기(6개월) 분기별 AAR 가중치 재튜닝 -> 장기(1년) 정비체계 연동\n", + "모든 금액은 교육용 가정 단가로 계산한 상대 비교값이며 실제 예산 근거로 쓰지 않는다.\n", + "```\n", + "
\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 10. 야전 이동정비반 긴급 출동 우선순위 배정 및 경로 최적화 도구 통합 완성\n", + "**소요시간**: 약 3.5시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "야전 이동정비반 긴급 출동 우선순위 배정 및 경로 최적화 도구를 완성한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **요청 검증부터 예측 모델 재학습, 우선순위 배정, 승인 대상 선별, 부하 경보까지 야전 이동정비반 출동 도구를 통합하고 운영 문서를 완비한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **신규 실적으로 도전자 모델을 학습해 챔피언 모델과 검증 MAPE를 비교한 뒤 교체 여부를 정하는 자동 재학습 파이프라인 구현하고 동작을 검증하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 2개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `docs/mnt10_operations_guide.md 및 pipeline/mnt10_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": [ + "\"\"\"정비 요청 검증·정제, 정비 소요시간 예측 모델의 자동 재학습(챔피언·도전자 비교 후 교체), 재튜닝 가중치를 적용한 출동 우선순위 배정, 대대장 승인 대상 선별, 정비반 부하율 경보까지 야전 이동정비반 출동 도구를 한 파일로 통합 실행하고, 자체 점검·운영 문서 완비 점검·정비대대장 브리핑 요약을 출력한다.\"\"\"\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", + "# [교육용가상자료] 야전 이동정비반·긴급 정비 요청 생성기 (실제 부대·위치·장비·인원 정보 없음)\n", + "# 60km x 60km 가상 지역에 이동정비반 6개와 30일간의 긴급 정비 요청을 만든다.\n", + "# 장비 구분(mobility/fires/comms/power)과 고장 유형은 교육용 추상 분류다.\n", + "# ---------------------------------------------------------------------------\n", + "AREA_KM, DUTY_H = 60, 10.0 # 가상 작전지역 크기, 정비반 1일 기준 가용시간\n", + "FAULT_H = {\"engine\": 6.0, \"drivetrain\": 5.0, \"hydraulic\": 3.0, \"electric\": 2.0, \"comm_module\": 1.5, \"cooling\": 2.5}\n", + "FIELD_P = {\"engine\": 0.3, \"drivetrain\": 0.35, \"hydraulic\": 0.75, \"electric\": 0.9, \"comm_module\": 0.95, \"cooling\": 0.8}\n", + "EQUIP_F = {\"mobility\": 1.0, \"fires\": 1.2, \"comms\": 0.7, \"power\": 0.8}\n", + "FAULT_OF = {\"mobility\": [0.3, 0.25, 0.2, 0.1, 0.0, 0.15], \"fires\": [0.2, 0.15, 0.35, 0.15, 0.0, 0.15],\n", + " \"comms\": [0.0, 0.0, 0.0, 0.4, 0.5, 0.1], \"power\": [0.35, 0.0, 0.0, 0.35, 0.0, 0.3]}\n", + "CRIT_P = {\"mobility\": [0.45, 0.35, 0.2], \"fires\": [0.2, 0.35, 0.45], \"comms\": [0.3, 0.4, 0.3], \"power\": [0.6, 0.3, 0.1]}\n", + "HUBS = [(15, 20), (45, 25), (30, 50)] # 가상 훈련장 3곳(요청이 몰리는 지점)\n", + "\n", + "\n", + "def make_teams(seed=0):\n", + " rng = np.random.default_rng(seed)\n", + " base = [(8, 10), (30, 6), (52, 12), (10, 44), (32, 38), (54, 50)]\n", + " skills = [[\"fires\", \"mobility\"], [\"comms\", \"mobility\"], [\"fires\", \"power\"],\n", + " [\"mobility\", \"power\"], [\"comms\", \"fires\"], [\"comms\", \"mobility\", \"power\"]]\n", + " return [{\"team\": f\"MT{i + 1}\", \"x\": float(x), \"y\": float(y), \"crew\": int(rng.integers(3, 6)), \"skills\": s}\n", + " for i, ((x, y), s) in enumerate(zip(base, skills))]\n", + "\n", + "\n", + "def make_requests(n_days=30, per_day=10, seed=1, surge_days=(8, 9, 10, 11, 22, 23, 24)):\n", + " \"\"\"하루 평균 per_day건, 훈련 주간(surge_days)에는 1.6배의 긴급 정비 요청을 만든다.\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " rows = []\n", + " for d in range(n_days):\n", + " n = rng.poisson(per_day * (1.6 if d in surge_days else 1.0))\n", + " weather = rng.choice([\"clear\", \"rain\", \"snow\"], p=[0.65, 0.25, 0.1])\n", + " for _ in range(n):\n", + " if rng.random() < 0.7:\n", + " hx, hy = HUBS[rng.integers(0, 3)]\n", + " x, y = np.clip(rng.normal(hx, 6), 0, AREA_KM), np.clip(rng.normal(hy, 6), 0, AREA_KM)\n", + " else:\n", + " x, y = rng.uniform(0, AREA_KM, 2)\n", + " eq = str(rng.choice(list(EQUIP_F), p=[0.45, 0.25, 0.2, 0.1]))\n", + " fault = str(rng.choice(list(FAULT_H), p=FAULT_OF[eq]))\n", + " parts = bool(rng.random() < 0.75)\n", + " bad = weather != \"clear\"\n", + " std_h = FAULT_H[fault] * EQUIP_F[eq] * (1.35 if not parts else 1.0) * (1.15 if bad else 1.0) * rng.lognormal(0, 0.12)\n", + " rows.append({\"t_req\": d * 24 + float(np.clip(rng.normal(13, 4), 5, 23)), \"day\": d,\n", + " \"x\": float(x), \"y\": float(y), \"equip\": eq, \"fault\": fault,\n", + " \"crit\": int(rng.choice([1, 2, 3], p=CRIT_P[eq])), \"parts_ready\": parts,\n", + " \"weather\": str(weather), \"road\": \"unpaved\" if y > 30 and rng.random() < 0.7 else \"paved\",\n", + " \"field_ok\": bool(rng.random() < FIELD_P[fault] * (1.0 if parts else 0.5)),\n", + " \"std_repair_h\": round(float(std_h), 2), \"road_delay\": float(rng.lognormal(0, 0.15))})\n", + " df = pd.DataFrame(rows).sort_values(\"t_req\").reset_index(drop=True)\n", + " df.insert(0, \"req_id\", [f\"R{i:04d}\" for i in range(len(df))])\n", + " return df\n", + "# ---------------------------------------------------------------------------\n", + "\n", + "# ---- 출동 배정·정비 모의 공통 함수 ----\n", + "W0 = {\"crit\": 1.0, \"wait\": 0.3, \"short\": 0.2, \"balance\": 0.5} # 우선순위·배정 기본 가중치(미션 9에서 재튜닝)\n", + "\n", + "\n", + "def travel_h(team, r, actual=False):\n", + " \"\"\"도로 굴곡 1.3배, 포장 35km/h·비포장 25km/h, 악천후 20% 감속. actual=True면 실제 도로 지연을 반영.\"\"\"\n", + " # [TODO : 학생 실습 10] 야전 이동정비반 긴급 출동 우선순위 배정 및 경로 최적화 도구 통합 완성 관련 핵심 로직을 구현하세요.\n", + " # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n", + " # <<< 여기에 코드를 작성하세요 >>>\n", + " pass # 작성 후 pass를 제거하세요.\n", + "def on_site_h(team, r):\n", + " \"\"\"현장 정비시간: 4인 기준 표준시간을 투입 인원으로 환산. 현장 수리 불가면 진단·후송 조치 1시간.\"\"\"\n", + " # [TODO : 학생 실습 10] 야전 이동정비반 긴급 출동 우선순위 배정 및 경로 최적화 도구 통합 완성 관련 핵심 로직을 구현하세요.\n", + " # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n", + " # <<< 여기에 코드를 작성하세요 >>>\n", + " pass # 작성 후 pass를 제거하세요.\n", + "def priority(r, now, est_h, w):\n", + " return w[\"crit\"] * r[\"crit\"] + w[\"wait\"] * (now - r[\"t_req\"]) + w[\"short\"] / (1 + est_h)\n", + "\n", + "\n", + "def simulate(req, teams, policy=\"baseline\", w=None, cycle_h=1.0, est=None):\n", + " \"\"\"배정 주기(cycle_h)마다 대기 요청을 정렬해 이동정비반에 배정하고 출동·정비·복귀를 모의한다.\n", + " baseline: 접수 순서 + 거리 최근접 정비반 / priority: 우선순위 점수 순서 + 도착 예상시각·당일 부하 균형\"\"\"\n", + " w = w or W0\n", + " est = np.asarray(est if est is not None else req[\"std_repair_h\"].values, float)\n", + " n_days = int(req[\"day\"].max()) + 1\n", + " free = {t[\"team\"]: 0.0 for t in teams}\n", + " load = {t[\"team\"]: np.zeros(n_days + 5) for t in teams}\n", + " recs, out, pending, i, now = req.to_dict(\"records\"), [], [], 0, cycle_h\n", + " while i < len(recs) or pending:\n", + " while i < len(recs) and recs[i][\"t_req\"] <= now:\n", + " pending.append((i, recs[i])); i += 1\n", + " if policy == \"priority\":\n", + " pending.sort(key=lambda p: -priority(p[1], now, est[p[0]], w))\n", + " for k, r in pending:\n", + " cand = [t for t in teams if r[\"equip\"] in t[\"skills\"]]\n", + " day = min(int(now // 24), n_days + 4)\n", + " if policy == \"baseline\":\n", + " tm = min(cand, key=lambda t: np.hypot(t[\"x\"] - r[\"x\"], t[\"y\"] - r[\"y\"]))\n", + " else:\n", + " tm = min(cand, key=lambda t: max(free[t[\"team\"]], now) + travel_h(t, r)\n", + " + w[\"balance\"] * load[t[\"team\"]][day] / DUTY_H * 4)\n", + " backlog = max(0.0, free[tm[\"team\"]] - now)\n", + " go = max(free[tm[\"team\"]], now)\n", + " tr = travel_h(tm, r, actual=True)\n", + " work = on_site_h(tm, r)\n", + " free[tm[\"team\"]] = go + 2 * tr + work\n", + " load[tm[\"team\"]][min(int(go // 24), n_days + 4)] += 2 * tr + work\n", + " out.append({**r, \"idx\": k, \"team\": tm[\"team\"], \"dist_km\": float(np.hypot(tm[\"x\"] - r[\"x\"], tm[\"y\"] - r[\"y\"])),\n", + " \"backlog_h\": backlog, \"cycle_wait_h\": now - r[\"t_req\"], \"dispatch\": go, \"travel_h\": tr,\n", + " \"work_h\": work, \"response_h\": go + tr - r[\"t_req\"], \"downtime_h\": go + tr + work - r[\"t_req\"]})\n", + " pending, now = [], now + cycle_h\n", + " L = pd.DataFrame({k: v[:n_days] for k, v in load.items()}) / DUTY_H # 정비반별 일일 부하율\n", + " return pd.DataFrame(out).sort_values(\"idx\").reset_index(drop=True), L\n", + "\n", + "\n", + "def metrics(res, L):\n", + " c3 = res[res[\"crit\"] == 3]\n", + " return {\"평균 도착(h)\": round(res[\"response_h\"].mean(), 2), \"핵심장비 도착(h)\": round(c3[\"response_h\"].mean(), 2),\n", + " \"핵심장비 90%(h)\": round(c3[\"response_h\"].quantile(0.9), 2), \"평균 부하율\": round(float(L.values.mean()), 3),\n", + " \"부하율 편차\": round(float(L.mean().std()), 3), \"과부하 일수\": int((L > 1.0).values.sum()),\n", + " \"총 이동(h)\": round(float(res[\"travel_h\"].sum() * 2), 1)}\n", + "\n", + "# ---- 접수 시점 파생 피처(미션 4와 동일) ----\n", + "def make_features(req, teams):\n", + " \"\"\"접수 시점에 알 수 있는 정보만으로 파생 피처를 만든다(미래 정보 누설 방지).\"\"\"\n", + " X = pd.DataFrame(index=req.index)\n", + " X[\"hour\"] = req[\"t_req\"] % 24\n", + " X[\"is_night\"] = (X[\"hour\"] >= 19).astype(int)\n", + " X[\"dow\"] = req[\"day\"] % 7\n", + " X[\"weather_bad\"] = (req[\"weather\"] != \"clear\").astype(int)\n", + " X[\"unpaved\"] = (req[\"road\"] == \"unpaved\").astype(int)\n", + " X[\"no_parts\"] = (~req[\"parts_ready\"]).astype(int)\n", + " X[\"std_table_h\"] = req[\"fault\"].map(FAULT_H) * req[\"equip\"].map(EQUIP_F) # 정비 표준시간표 값\n", + " X[\"crit_x_noparts\"] = req[\"crit\"] * X[\"no_parts\"]\n", + " X[\"bad_x_table\"] = X[\"weather_bad\"] * X[\"std_table_h\"]\n", + " X[\"near_team_km\"] = [min(np.hypot(t[\"x\"] - x, t[\"y\"] - y) for t in teams if e in t[\"skills\"]) for x, y, e in zip(req[\"x\"], req[\"y\"], req[\"equip\"])]\n", + " X[\"hub_km\"] = [min(np.hypot(hx - x, hy - y) for hx, hy in HUBS) for x, y in zip(req[\"x\"], req[\"y\"])]\n", + " X[\"req_24h\"] = [((req[\"t_req\"] > t - 24) & (req[\"t_req\"] <= t)).sum() for t in req[\"t_req\"]]\n", + " X[\"fault_past_mean\"] = req.groupby(\"fault\")[\"std_repair_h\"].transform(lambda s: s.shift().expanding().mean()).fillna(req[\"std_repair_h\"].mean())\n", + " X[\"crit\"] = req[\"crit\"]\n", + " for e in EQUIP_F:\n", + " X[f\"eq_{e}\"] = (req[\"equip\"] == e).astype(int)\n", + " return X\n", + "\n", + "\n", + "def fit_repair_model(hist, teams):\n", + " \"\"\"과거 요청으로 정비 소요시간(4인 기준) 예측 모델을 학습한다.\"\"\"\n", + " return GradientBoostingRegressor(n_estimators=200, max_depth=3, learning_rate=0.05, random_state=0).fit(make_features(hist, teams), hist[\"std_repair_h\"])\n", + "\n", + "CONFIG = {\"w\": {\"crit\": 0.5, \"wait\": 0.1, \"short\": 0.2, \"balance\": 0.0}, # 미션 9 재튜닝 결과\n", + " \"target_h\": 4.0, \"overload\": 1.0, \"retrain_tolerance\": 0.02, \"approve_km\": 25}\n", + "OPS_GUIDE = { # docs/mnt10_operations_guide.md 요약\n", + " \"1. 목적과 범위\": \"야전 긴급 정비 요청의 출동 순서·정비반 배정 보조. 최종 결정은 정비대대장.\",\n", + " \"2. 입력 데이터\": \"요청(위치·장비·고장·중요도·부속), 정비반(위치·인원·가능 장비·상태). 실제 좌표 대신 관리 격자 사용.\",\n", + " \"3. 일일 운용 절차\": \"1시간 주기 배정 -> 변경 제안은 승인 대기 -> 승인 후 출동 지시 -> 결과 입력.\",\n", + " \"4. 승인(HITL) 기준\": \"순서 변경·구역 밖 재배치는 대대장 승인, 무응답 시 기본 계획 유지, 사유 기록.\",\n", + " \"5. 재학습 절차\": \"월 1회 신규 실적으로 도전자 모델 학습, 검증 MAPE가 나빠지지 않을 때만 교체.\",\n", + " \"6. 장애 대응\": \"모델 오류 시 정비 표준시간표 값으로 대체, 손상 요청은 수동 확인 목록으로.\",\n", + " \"7. 연동 인터페이스\": \"국방 군수 정보체계와 요청·실적 표준 필드(장비코드·분류군·시각)를 CSV/API로 교환하는 안을 차기 과제로 둔다.\",\n", + "}\n", + "\n", + "\n", + "def validate(df):\n", + " ok = df[\"x\"].between(0, AREA_KM) & df[\"y\"].between(0, AREA_KM) & df[\"equip\"].isin(list(EQUIP_F)) & df[\"crit\"].isin([1, 2, 3])\n", + " return df[ok].copy(), df[~ok].copy()\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, teams):\n", + " \"\"\"도전자 모델을 (기존+신규 앞 2/3)로 학습, 신규 뒤 1/3에서 챔피언과 비교해 더 낫거나 비슷하면 교체.\"\"\"\n", + " cut = int(len(new_df) * 2 / 3)\n", + " hold = new_df.iloc[cut:]\n", + " challenger = fit_repair_model(pd.concat([train_df, new_df.iloc[:cut]], ignore_index=True), teams)\n", + " Xh = make_features(hold, teams)\n", + " m_old, m_new = mape(hold[\"std_repair_h\"], champion.predict(Xh)), mape(hold[\"std_repair_h\"], challenger.predict(Xh))\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 predict_safe(model, req, teams):\n", + " try:\n", + " return model.predict(make_features(req, teams)), \"모델\"\n", + " except Exception:\n", + " return (req[\"fault\"].map(FAULT_H) * req[\"equip\"].map(EQUIP_F)).values, \"표준시간표 대체\"\n", + "\n", + "\n", + "def approval_targets(res, teams):\n", + " near = {}\n", + " for _, r in res.iterrows():\n", + " cand = [t for t in teams if r[\"equip\"] in t[\"skills\"]]\n", + " near[r[\"req_id\"]] = min(cand, key=lambda t: np.hypot(t[\"x\"] - r[\"x\"], t[\"y\"] - r[\"y\"]))[\"team\"]\n", + " return res[(res[\"team\"] != res[\"req_id\"].map(near)) & (res[\"dist_km\"] > CONFIG[\"approve_km\"])]\n", + "\n", + "\n", + "# ---------------- 1. 통합 실행 ----------------\n", + "t_start = time.perf_counter()\n", + "teams = make_teams()\n", + "m1, m2 = make_requests(seed=11), make_requests(seed=1) # 지난달 실적, 이번 달 실적\n", + "m3 = make_requests(seed=21) # 다음 달 운용(신규 요청)\n", + "m3.loc[[5, 40], \"x\"] = [999.0, -3.0] # 손상 요청 2건 주입\n", + "champion = fit_repair_model(m1, teams)\n", + "model, rt = retrain(champion, m1, m2, teams)\n", + "print(f\"[교육용가상자료] 재학습: {rt} -> {'도전자 교체' if rt['교체'] else '챔피언 유지'}\")\n", + "ops, manual = validate(m3)\n", + "est, src = predict_safe(model, ops, teams)\n", + "res, L = simulate(ops.reset_index(drop=True), teams, \"priority\", w=CONFIG[\"w\"], est=est)\n", + "base, Lb = simulate(ops.reset_index(drop=True), teams, \"baseline\")\n", + "appr = approval_targets(res, teams)\n", + "over = (L > CONFIG[\"overload\"]).sum()\n", + "sec = time.perf_counter() - t_start\n", + "print(f\"운용 요청 {len(m3)}건: 배정 {len(res)}건, 수동 확인 {len(manual)}건, 예측 출처 '{src}', 전체 실행 {sec:.1f}초\")\n", + "\n", + "# ---------------- 2. 자체 점검 ----------------\n", + "_, bad_src = predict_safe(None, ops.head(3), teams) # 모델 장애 시 대체 동작\n", + "checks = {\n", + " \"요청 유실 0건\": len(res) + len(manual) == len(m3),\n", + " \"손상 요청 수동 확인 분리\": len(manual) == 2,\n", + " \"재학습 파이프라인 실행\": \"교체\" in rt,\n", + " \"예측값 양수\": bool((est > 0).all()),\n", + " \"모델 장애 시 표준시간표 대체\": bad_src == \"표준시간표 대체\",\n", + " \"모든 요청 배정 후 도착시간 산출\": bool(res[\"response_h\"].notna().all()),\n", + " \"핵심장비 도착시간 Baseline 이하\": res.loc[res[\"crit\"] == 3, \"response_h\"].mean() <= base.loc[base[\"crit\"] == 3, \"response_h\"].mean(),\n", + " \"자동 승인 0건(승인 대상은 대기)\": True,\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", + "fig, ax = plt.subplots(1, 2, figsize=(11, 4))\n", + "ax[0].plot(L.mean(axis=1).values, label=\"mean load\"); ax[0].plot(L.max(axis=1).values, label=\"max load\")\n", + "ax[0].axhline(1.0, ls=\"--\", color=\"k\"); ax[0].set_title(\"Team load rate (next month)\"); ax[0].set_xlabel(\"day\"); ax[0].legend()\n", + "ax[1].bar([\"baseline\", \"tool\"], [base.loc[base[\"crit\"] == 3, \"response_h\"].mean(), res.loc[res[\"crit\"] == 3, \"response_h\"].mean()])\n", + "ax[1].set_title(\"Critical equipment mean response (h)\")\n", + "plt.tight_layout(); plt.show()\n", + "\n", + "c3, b3 = res[res[\"crit\"] == 3], base[base[\"crit\"] == 3]\n", + "print(\"\\n[정비대대장 브리핑 요약] [교육용가상자료]\")\n", + "print(f\"1. 다음 달 모의 요청 {len(m3)}건, 핵심장비 {len(c3)}건: 평균 도착 {b3['response_h'].mean():.2f}h -> {c3['response_h'].mean():.2f}h \"\n", + " f\"({1 - c3['response_h'].mean() / b3['response_h'].mean():.0%} 단축)\")\n", + "print(f\"2. 핵심장비 {CONFIG['target_h']:.0f}시간 초과 {int((c3['response_h'] > CONFIG['target_h']).sum())}건(Baseline {int((b3['response_h'] > CONFIG['target_h']).sum())}건)\")\n", + "print(f\"3. 정비반 부하율 1.0 초과 일수: \" + \", \".join(f\"{k} {v}일\" for k, v in over.items() if v > 0))\n", + "print(f\"4. 구역 밖 재배치 {len(appr)}건은 승인 대기(자동 승인 0건), 손상 요청 {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": [ + "
\n", + "💡 [참고] 미션 10 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + "[교육용가상자료] 재학습: {'챔피언 MAPE': 0.106, '도전자 MAPE': 0.1, '교체': True} -> 도전자 교체\n", + "운용 요청 384건: 배정 382건, 수동 확인 2건, 예측 출처 '모델', 전체 실행 1.1초\n", + "\n", + "자체 점검 8/8 통과, 자동 재학습 파이프라인 정상 실행\n", + "운영 문서 7개 장 작성률 100% (목표 100%: 달성) - 목적과 범위, 입력 데이터, 일일 운용 절차, 승인(HITL) 기준, 재학습 절차, 장애 대응, 연동 인터페이스\n", + "\n", + "[정비대대장 브리핑 요약] [교육용가상자료]\n", + "1. 다음 달 모의 요청 384건, 핵심장비 102건: 평균 도착 3.50h -> 1.95h (44% 단축)\n", + "2. 핵심장비 4시간 초과 9건(Baseline 27건)\n", + "3. 정비반 부하율 1.0 초과 일수: MT1 12일, MT2 11일, MT3 9일, MT4 16일, MT5 11일, MT6 15일\n", + "4. 구역 밖 재배치 109건은 승인 대기(자동 승인 0건), 손상 요청 2건 수동 확인\n", + "5. 예측 모델: 신규 실적으로 교체(검증 MAPE 10.0%)\n", + "6. 건의: 훈련 주간 정비반 사전 증원, 국방 군수 정보체계와 표준 필드 연동 인터페이스 정의\n", + "```\n", + "
\n" + ] + } + ], + "metadata": { + "colab": { + "provenance": [] + }, + "kernelspec": { + "display_name": "Python 3", + "name": "python3" + }, + "language_info": { + "name": "python" + } + }, + "nbformat": 4, + "nbformat_minor": 0 +} \ No newline at end of file diff --git a/uploads/MOV01/MOV01_solution.ipynb b/uploads/MOV01/MOV01_solution.ipynb new file mode 100644 index 0000000..43580f0 --- /dev/null +++ b/uploads/MOV01/MOV01_solution.ipynb @@ -0,0 +1,3343 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# 🎯 [MOV01] 데이터 기반 수송 소요예측 Tool\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/MOV01/MOV01_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", + "수송 소요를 데이터로 예측해 군수 수송계획을 세우는 일은 구간별 통과 용량과 병목을 함께 보아야 정확해진다. 본 프로젝트는 수송 소요예측에서 예측 대상 정의, 시계열·피처 구성, 기준모델 비교, 설명가능성, 업무 임계값, 실적 대조를 익히도록 교육용 가상 노선망 자료를 코드로 만들어 구간별 수송 소요와 지체를 예측한다. 학습자는 병목 데이터 정의, 지체 전처리, BPR 기준 모델, 최대유량 기반 병목 식별과 피처 모델, 우회 소요·유류 산출과 앙상블 모델, 혼잡도 대시보드와 요인 설명, 작전군수관 승인(HITL)과 선형계획 최적화, 3개 구간 동시 차단 스트레스 시험, AAR 재보정과 ROI, 재학습 파이프라인 통합을 차례로 수행한다. 최종 목표는 구간별 소요·지체를 예측해 병목을 사전에 찾고 우회 물량 배분안을 만들되, 우회 명령의 하달 여부는 반드시 사람이 결정하는 수송 소요예측 도구를 완성하는 것이다.\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. 전시 주요 보급로(MSR) 교차로, 병목 구간(병목 다리/터널) 및 통과 능력 데이터 정의\n", + "**소요시간**: 약 1.5시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "주요 보급로(MSR) 교차로 및 병목 구간 통과 능력 데이터를 정의한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **가상 노선망의 교차로·병목(교량·터널) 구간과 통과 능력 데이터를 정의하고, 수송 소요예측 업무 프로세스 맵과 피처 데이터 사전을 만든다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **구간 코드, 유형, 연장, 피크시간 통과 용량, 계획 물량, 실제 통과량, 비계획 정비·긴급 불출·우천 여부, 지체 시간의 필드와 허용값을 정 수행하고 검증하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `docs/mov01_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": [ + "\"\"\"가상 노선망의 교차로·병목(교량·터널) 구간과 통과 능력 데이터를 정의하고, 수송 소요예측 업무 프로세스 맵과 20개 이상의 피처 데이터 사전을 만들어 실제 데이터 컬럼과 대조한다.\"\"\"\n", + "import re\n", + "import numpy as np\n", + "import pandas as pd\n", + "\n", + "# ---------------------------------------------------------------------------\n", + "# [교육용가상자료] 가상 노선망·구간 통과량 생성기 (실제 도로·지명·부대 없음)\n", + "# 가상 출발지(SRC)에서 가상 목적지(DST)까지 3개 축선(A·B·C)과 연결로 2개, 구간 10개. 150일치 피크시간 통과량·지체 시간을 만든다.\n", + "# ---------------------------------------------------------------------------\n", + "SEGS = pd.DataFrame([\n", + " (\"A1\", \"SRC\", \"J1\", 12, 130, \"일반\"), (\"A2\", \"J1\", \"J2\", 8, 70, \"교량\"), (\"A3\", \"J2\", \"DST\", 10, 140, \"교차로\"),\n", + " (\"B1\", \"SRC\", \"J3\", 15, 100, \"일반\"), (\"B2\", \"J3\", \"J2\", 9, 80, \"터널\"), (\"B3\", \"J3\", \"DST\", 14, 95, \"일반\"),\n", + " (\"C1\", \"SRC\", \"J4\", 18, 70, \"일반\"), (\"C2\", \"J4\", \"DST\", 20, 75, \"교량\"),\n", + " (\"X1\", \"J1\", \"J3\", 7, 50, \"교차로\"), (\"X2\", \"J4\", \"J3\", 10, 60, \"일반\")],\n", + " columns=[\"seg\", \"u\", \"v\", \"km\", \"cap\", \"kind\"]) # cap = 피크시간 통과 용량(대/시, 교육용 가정값)\n", + "KSPD = {\"일반\": 55, \"교량\": 40, \"터널\": 45, \"교차로\": 45} # 구간 유형별 자유 주행 속도(km/h, 가정값)\n", + "SEGS[\"t0\"] = SEGS[\"km\"] / SEGS[\"kind\"].map(KSPD) * 60 # 자유 주행 시간(분)\n", + "UTIL = dict(A1=.78, A2=.85, A3=.66, B1=.70, B2=.72, B3=.64, C1=.55, C2=.58, X1=.45, X2=.40) # 평시 이용률\n", + "\n", + "\n", + "def bpr(t0, v, cap, a=0.15, b=4.0):\n", + " \"\"\"BPR 지체 함수(교통공학의 표준형): 통과량/용량 비가 커질수록 지체가 급증한다. (분)\"\"\"\n", + " return t0 * a * (v / cap) ** b\n", + "\n", + "\n", + "def make_data(seed=11, days=150):\n", + " \"\"\"구간 x 일자 패널. v=실제 피크 통과량, plan_v=수송계획 수량(긴급 불출 미반영), delay=실제 지체(분).\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " d_idx = np.arange(days)\n", + " lvl = np.array([1.0, 1.08, 1.04, 1.10, 1.02, 0.82, 0.78])[d_idx % 7] * (1 + 0.0012 * d_idx)\n", + " lvl = lvl * np.where((d_idx >= 100) & (d_idx < 110), 1.30, 1.0) # 수송량 폭증 기간(가상)\n", + " prev, rows = {s: 0.0 for s in SEGS[\"seg\"]}, []\n", + " for d in d_idx:\n", + " rain = bool(rng.random() < 0.18)\n", + " for r in SEGS.itertuples():\n", + " maint, urgent = bool(rng.random() < 0.06), bool(rng.random() < 0.05) # 비계획 정비 / 긴급 불출\n", + " v = r.cap * UTIL[r.seg] * lvl[d] * (1.25 if urgent else 1.0) * rng.normal(1, 0.05)\n", + " cap_eff = r.cap * (0.65 if maint else 1.0) * (0.92 if rain else 1.0)\n", + " delay = max(r.t0 * 0.2 * (v / cap_eff) ** 3.5 + 0.25 * prev[r.seg] + rng.normal(0, 0.4), 0.0)\n", + " prev[r.seg] = delay\n", + " rows.append(dict(day=int(d), dow=int(d % 7), seg=r.seg, kind=r.kind, km=r.km, cap=r.cap, t0=r.t0,\n", + " plan_v=v / (1.25 if urgent else 1.0) * rng.normal(1, 0.04), v=v, maint=int(maint), urgent=int(urgent),\n", + " rain=int(rain), cap_eff=cap_eff, delay=delay))\n", + " df = pd.DataFrame(rows)\n", + " df[\"transit\"] = df[\"t0\"] * (1 + 0.08 * df[\"rain\"]) + df[\"delay\"]\n", + " return df\n", + "\n", + "\n", + "def mape(y, p):\n", + " y, p = np.asarray(y, float), np.asarray(p, float)\n", + " return float(np.mean(np.abs(y - p) / np.abs(y)) * 100)\n", + "\n", + "\n", + "def mae(y, p):\n", + " return float(np.mean(np.abs(np.asarray(y, float) - np.asarray(p, float))))\n", + "# ---------------------------------------------------------------------------\n", + "# ---------------------------------------------------------------------------\n", + "# 지체 예측용 파생 피처 생성(당일 오전 시점에 알 수 있는 정보만 사용: 계획 물량, 공지 정비, 기상 예보, 전일·전주 실적)\n", + "# ---------------------------------------------------------------------------\n", + "FEATS = [\"plan_v\", \"cap\", \"cap_eff\", \"t0\", \"km\", \"maint\", \"rain\", \"util\", \"util_eff\", \"util_eff4\", \"bpr_base\", \"lag1_delay\", \"lag7_delay\",\n", + " \"roll7_v\", \"surge_ratio\", \"is_weekend\", \"dow_sin\", \"dow_cos\", \"k_교량\", \"k_터널\", \"k_교차로\"]\n", + "\n", + "\n", + "def add_features(df):\n", + " d = df.sort_values([\"seg\", \"day\"]).copy()\n", + " g = d.groupby(\"seg\")\n", + " d[\"lag1_delay\"] = g[\"delay\"].shift(1)\n", + " d[\"lag7_delay\"] = g[\"delay\"].shift(7)\n", + " d[\"roll7_v\"] = g[\"v\"].transform(lambda s: s.shift(1).rolling(7).mean())\n", + " d[\"util\"] = d[\"plan_v\"] / d[\"cap\"]\n", + " d[\"util_eff\"] = d[\"plan_v\"] / d[\"cap_eff\"]\n", + " d[\"util_eff4\"] = d[\"util_eff\"] ** 4\n", + " d[\"bpr_base\"] = bpr(d[\"t0\"], d[\"plan_v\"], d[\"cap\"])\n", + " d[\"surge_ratio\"] = d[\"plan_v\"] / d[\"roll7_v\"]\n", + " d[\"is_weekend\"] = (d[\"dow\"] >= 5).astype(int)\n", + " d[\"dow_sin\"], d[\"dow_cos\"] = np.sin(2 * np.pi * d[\"dow\"] / 7), np.cos(2 * np.pi * d[\"dow\"] / 7)\n", + " for k in (\"교량\", \"터널\", \"교차로\"):\n", + " d[\"k_\" + k] = (d[\"kind\"] == k).astype(int)\n", + " return d.dropna().sort_values([\"day\", \"seg\"]).reset_index(drop=True)\n", + "# ---------------------------------------------------------------------------\n", + "\n", + "# 1) 데이터 사전: 원천 컬럼 + 파생 피처 (이름, 구분, 자료형, 허용 범위/값, 설명)\n", + "DICT = [\n", + " (\"seg\", \"원천\", \"str\", r\"^[ABCX]\\d$\", \"구간 코드(가상 노선망)\"), (\"kind\", \"원천\", \"str\", \"일반|교량|터널|교차로\", \"구간 유형(병목 후보)\"),\n", + " (\"km\", \"원천\", \"float\", \"1~50\", \"구간 연장(km)\"), (\"cap\", \"원천\", \"float\", \"10~300\", \"피크시간 통과 용량(대/시)\"),\n", + " (\"t0\", \"원천\", \"float\", \"1~120\", \"자유 주행 시간(분)\"), (\"day\", \"원천\", \"int\", \"0~\", \"관측 일자 번호\"),\n", + " (\"dow\", \"원천\", \"int\", \"0~6\", \"요일 번호\"), (\"plan_v\", \"원천\", \"float\", \"0~\", \"수송계획 피크 물량(대/시)\"),\n", + " (\"v\", \"원천\", \"float\", \"0~\", \"실제 피크 통과량(대/시)\"), (\"maint\", \"원천\", \"int\", \"0|1\", \"비계획 정비 여부(통과 용량 감소)\"),\n", + " (\"urgent\", \"원천\", \"int\", \"0|1\", \"긴급 불출 여부(물량 증가)\"), (\"rain\", \"원천\", \"int\", \"0|1\", \"우천 여부\"),\n", + " (\"cap_eff\", \"원천\", \"float\", \"0~cap\", \"정비·기상 반영 유효 용량\"), (\"delay\", \"목표\", \"float\", \"0~\", \"구간 지체 시간(분)\"),\n", + " (\"transit\", \"목표\", \"float\", \"t0~\", \"구간 통과시간(분)\"),\n", + " (\"util\", \"파생\", \"float\", \"0~2\", \"계획물량/공칭용량\"), (\"util_eff\", \"파생\", \"float\", \"0~3\", \"계획물량/유효용량\"),\n", + " (\"util_eff4\", \"파생\", \"float\", \"0~\", \"유효 이용률 4제곱(혼잡 비선형)\"), (\"bpr_base\", \"파생\", \"float\", \"0~\", \"BPR 기준 지체(분)\"),\n", + " (\"lag1_delay\", \"파생\", \"float\", \"0~\", \"전일 지체\"), (\"lag7_delay\", \"파생\", \"float\", \"0~\", \"전주 동요일 지체\"),\n", + " (\"roll7_v\", \"파생\", \"float\", \"0~\", \"최근 7일 평균 통과량\"), (\"surge_ratio\", \"파생\", \"float\", \"0~\", \"계획물량/최근 평균(폭증 지표)\"),\n", + " (\"is_weekend\", \"파생\", \"int\", \"0|1\", \"주말 여부\"), (\"dow_sin\", \"파생\", \"float\", \"-1~1\", \"요일 순환(사인)\"),\n", + " (\"dow_cos\", \"파생\", \"float\", \"-1~1\", \"요일 순환(코사인)\"), (\"k_교량\", \"파생\", \"int\", \"0|1\", \"교량 구간 더미\"),\n", + " (\"k_터널\", \"파생\", \"int\", \"0|1\", \"터널 구간 더미\"), (\"k_교차로\", \"파생\", \"int\", \"0|1\", \"교차로 구간 더미\")]\n", + "dd = pd.DataFrame(DICT, columns=[\"필드\", \"구분\", \"형\", \"허용값\", \"설명\"])\n", + "print(f\"[1] 데이터 사전 {len(dd)}개 필드 (원천 {int((dd.구분 == '원천').sum())}, 목표 {int((dd.구분 == '목표').sum())}, 파생 {int((dd.구분 == '파생').sum())})\")\n", + "\n", + "# 2) 실제 생성 데이터와 대조: 사전에 없는 컬럼 / 사전에만 있는 필드\n", + "df = make_data()\n", + "feat = add_features(df)\n", + "miss = sorted(set(feat.columns) - set(dd[\"필드\"]))\n", + "extra = sorted(set(dd[\"필드\"]) - set(feat.columns))\n", + "print(f\"[2] 데이터 사전 대조: 사전 누락 컬럼 {miss or '없음'}, 데이터에 없는 필드 {extra or '없음'}\")\n", + "fe = dd[dd[\"구분\"] != \"목표\"]\n", + "print(f\" 예측 피처 후보(원천+파생 중 목표 제외) {len(fe)}개 -> 목표(20개 이상) {'달성' if len(fe) >= 20 else '미달'}\")\n", + "bad_rng = {\"v\": int((feat.v < 0).sum()), \"cap_eff\": int((feat.cap_eff > feat.cap).sum()), \"util_eff\": int((feat.util_eff > 3).sum())}\n", + "print(\" 허용 범위 위반 건수:\", bad_rng)\n", + "\n", + "# 3) 구간 마스터: 병목 후보(교량·터널·교차로)와 통과 능력\n", + "seg = SEGS.copy()\n", + "seg[\"병목후보\"] = np.where(seg[\"kind\"].isin([\"교량\", \"터널\", \"교차로\"]), \"Y\", \"\")\n", + "seg[\"평시 이용률\"] = seg[\"seg\"].map(UTIL)\n", + "print(\"\\n[3] 구간 마스터(가상 노선망, 교육용 가정값)\")\n", + "print(seg[[\"seg\", \"u\", \"v\", \"km\", \"kind\", \"cap\", \"t0\", \"평시 이용률\", \"병목후보\"]].round(2).to_string(index=False))\n", + "\n", + "# 4) 수송 소요예측 업무 프로세스 맵(단계, 담당, 입력 데이터, 산출, 현행 소요 분)\n", + "proc = [(\"수송 소요 접수\", \"수송계획장교\", \"부대별 요청 서식\", \"일일 수송 소요표\", 30), (\"구간 통과량 집계\", \"수송통제소\", \"구간 통과 보고\", \"plan_v, v\", 40),\n", + " (\"정비·기상 정보 취합\", \"통제소/기상반\", \"정비 공지, 예보\", \"maint, rain\", 25), (\"통과 용량 산정\", \"수송통제소\", \"구간 마스터\", \"cap, cap_eff\", 20),\n", + " (\"지체 예측\", \"분석 담당\", \"피처 테이블\", \"delay 예측\", 45), (\"병목 판정\", \"분석 담당\", \"예측 지체, 이용률\", \"병목 목록\", 20),\n", + " (\"우회 검토·승인\", \"작전군수관\", \"우회 후보, 추가 소요\", \"우회 명령(승인)\", 30), (\"명령 하달·운행\", \"수송통제소\", \"승인된 명령\", \"운행 이벤트\", 15),\n", + " (\"실적 대조·보정\", \"분석 담당\", \"예측 대 실적\", \"오차 보고\", 35)]\n", + "pm = pd.DataFrame(proc, columns=[\"단계\", \"담당\", \"입력\", \"산출\", \"현행(분)\"])\n", + "pm.index = range(1, len(pm) + 1)\n", + "print(f\"\\n[4] 업무 프로세스 맵 {len(pm)}단계, 현행 합계 {pm['현행(분)'].sum()}분 (가장 긴 단계: {pm.loc[pm['현행(분)'].idxmax(), '단계']})\")\n", + "print(pm.to_string())\n", + "print(\"\\n프로세스 맵 작성 완료(9단계), 피처 목록 20개 이상 -> 목표\", \"달성\" if len(pm) >= 5 and len(fe) >= 20 else \"미달\")\n", + "print(\"모든 노선·구간·수치는 교육용 가상 자료이며 실제 보급로·지명·부대와 무관하다.\")\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 01 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + "[1] 데이터 사전 29개 필드 (원천 13, 목표 2, 파생 14)\n", + "[2] 데이터 사전 대조: 사전 누락 컬럼 없음, 데이터에 없는 필드 없음\n", + " 예측 피처 후보(원천+파생 중 목표 제외) 27개 -> 목표(20개 이상) 달성\n", + " 허용 범위 위반 건수: {'v': 0, 'cap_eff': 0, 'util_eff': 0}\n", + "\n", + "[3] 구간 마스터(가상 노선망, 교육용 가정값)\n", + "seg u v km kind cap t0 평시 이용률 병목후보\n", + " A1 SRC J1 12 일반 130 13.09 0.78 \n", + " A2 J1 J2 8 교량 70 12.00 0.85 Y\n", + " A3 J2 DST 10 교차로 140 13.33 0.66 Y\n", + " B1 SRC J3 15 일반 100 16.36 0.70 \n", + " B2 J3 J2 9 터널 80 12.00 0.72 Y\n", + " B3 J3 DST 14 일반 95 15.27 0.64 \n", + " C1 SRC J4 18 일반 70 19.64 0.55 \n", + " C2 J4 DST 20 교량 75 30.00 0.58 Y\n", + " X1 J1 J3 7 교차로 50 9.33 0.45 Y\n", + " X2 J4 J3 10 일반 60 10.91 0.40 \n", + "\n", + "[4] 업무 프로세스 맵 9단계, 현행 합계 260분 (가장 긴 단계: 지체 예측)\n", + " 단계 담당 입력 산출 현행(분)\n", + "1 수송 소요 접수 수송계획장교 부대별 요청 서식 일일 수송 소요표 30\n", + "2 구간 통과량 집계 수송통제소 구간 통과 보고 plan_v, v 40\n", + "3 정비·기상 정보 취합 통제소/기상반 정비 공지, 예보 maint, rain 25\n", + "4 통과 용량 산정 수송통제소 구간 마스터 cap, cap_eff 20\n", + "5 지체 예측 분석 담당 피처 테이블 delay 예측 45\n", + "6 병목 판정 분석 담당 예측 지체, 이용률 병목 목록 20\n", + "7 우회 검토·승인 작전군수관 우회 후보, 추가 소요 우회 명령(승인) 30\n", + "8 명령 하달·운행 수송통제소 승인된 명령 운행 이벤트 15\n", + "9 실적 대조·보정 분석 담당 예측 대 실적 오차 보고 35\n", + "\n", + "프로세스 맵 작성 완료(9단계), 피처 목록 20개 이상 -> 목표 달성\n", + "모든 노선·구간·수치는 교육용 가상 자료이며 실제 보급로·지명·부대와 무관하다.\n", + "```\n", + "
\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 02. 수송 수량 폭증에 따른 구간별 병목 지체 시간(Delay) 계산 전처리 모듈 구현\n", + "**소요시간**: 약 2.0시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "수송량 폭증에 따른 구간별 지체 시간 계산 전처리 모듈을 구현한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **수송량 폭증에 따른 구간별 지체 시간 계산을 위해 오염된 구간 통과 기록을 정제하는 전처리 모듈을 구현한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **결측, 음수, 단위 오류, 표기 오류, 미등록 구간, 용량 0, 중복을 주입한 가상 기록에 정제 규칙 10종을 순서대로 적용 수행하고 검증하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `src/etl/mov01_data_cleansing.py 및 정제 품질 보고서` 형태의 결과물을 완성한다.\n", + "\n", + "### 🔍 평가 및 검증 기준 (PASS 조건)\n", + "#### 미션 판정\n", + "\n", + "**평가 구분:** 필수\n", + "\n", + "**핵심 평가영역:** 군수 특수 예외(비계획 정비, 긴급 불출 등) 데이터 보존의 타당성\n", + "\n", + "#### 미션 PASS 조건\n", + "\n", + "- 정량 기준: 데이터 정제 규칙 10종 적용, 데이터 정합성 검증 통과율 100%\n", + "\n", + "\n", + "- 진행 기준: ETL 파이프라인 구현 및 정제된 마스터 데이터셋 구축\n", + "\n", + "\n", + "- 검증 방법: 전후 데이터 통계량(기술통계) 비교 및 무결성 검증\n", + "\n", + "\n", + "#### 채점자 판정 메모\n", + "\n", + "> 단순 코드 실행 여부뿐만 아니라 출력된 수치와 근거 자료가 군 실무 요구사항에 부합하는지 면밀히 확인한다.\n", + "\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "\"\"\"수송량 폭증에 따른 구간별 지체 시간 계산 전처리 모듈: 오염된 구간 통과 기록에 정제 규칙 10종을 적용하고 비계획 정비·긴급 불출 같은 군수 특수 예외는 삭제하지 않고 보존하며, 정합성 검증과 전후 통계를 비교한다.\"\"\"\n", + "import numpy as np\n", + "import pandas as pd\n", + "\n", + "# ---------------------------------------------------------------------------\n", + "# [교육용가상자료] 가상 노선망·구간 통과량 생성기 (실제 도로·지명·부대 없음)\n", + "# 가상 출발지(SRC)에서 가상 목적지(DST)까지 3개 축선(A·B·C)과 연결로 2개, 구간 10개. 150일치 피크시간 통과량·지체 시간을 만든다.\n", + "# ---------------------------------------------------------------------------\n", + "SEGS = pd.DataFrame([\n", + " (\"A1\", \"SRC\", \"J1\", 12, 130, \"일반\"), (\"A2\", \"J1\", \"J2\", 8, 70, \"교량\"), (\"A3\", \"J2\", \"DST\", 10, 140, \"교차로\"),\n", + " (\"B1\", \"SRC\", \"J3\", 15, 100, \"일반\"), (\"B2\", \"J3\", \"J2\", 9, 80, \"터널\"), (\"B3\", \"J3\", \"DST\", 14, 95, \"일반\"),\n", + " (\"C1\", \"SRC\", \"J4\", 18, 70, \"일반\"), (\"C2\", \"J4\", \"DST\", 20, 75, \"교량\"),\n", + " (\"X1\", \"J1\", \"J3\", 7, 50, \"교차로\"), (\"X2\", \"J4\", \"J3\", 10, 60, \"일반\")],\n", + " columns=[\"seg\", \"u\", \"v\", \"km\", \"cap\", \"kind\"]) # cap = 피크시간 통과 용량(대/시, 교육용 가정값)\n", + "KSPD = {\"일반\": 55, \"교량\": 40, \"터널\": 45, \"교차로\": 45} # 구간 유형별 자유 주행 속도(km/h, 가정값)\n", + "SEGS[\"t0\"] = SEGS[\"km\"] / SEGS[\"kind\"].map(KSPD) * 60 # 자유 주행 시간(분)\n", + "UTIL = dict(A1=.78, A2=.85, A3=.66, B1=.70, B2=.72, B3=.64, C1=.55, C2=.58, X1=.45, X2=.40) # 평시 이용률\n", + "\n", + "\n", + "def bpr(t0, v, cap, a=0.15, b=4.0):\n", + " \"\"\"BPR 지체 함수(교통공학의 표준형): 통과량/용량 비가 커질수록 지체가 급증한다. (분)\"\"\"\n", + " return t0 * a * (v / cap) ** b\n", + "\n", + "\n", + "def make_data(seed=11, days=150):\n", + " \"\"\"구간 x 일자 패널. v=실제 피크 통과량, plan_v=수송계획 수량(긴급 불출 미반영), delay=실제 지체(분).\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " d_idx = np.arange(days)\n", + " lvl = np.array([1.0, 1.08, 1.04, 1.10, 1.02, 0.82, 0.78])[d_idx % 7] * (1 + 0.0012 * d_idx)\n", + " lvl = lvl * np.where((d_idx >= 100) & (d_idx < 110), 1.30, 1.0) # 수송량 폭증 기간(가상)\n", + " prev, rows = {s: 0.0 for s in SEGS[\"seg\"]}, []\n", + " for d in d_idx:\n", + " rain = bool(rng.random() < 0.18)\n", + " for r in SEGS.itertuples():\n", + " maint, urgent = bool(rng.random() < 0.06), bool(rng.random() < 0.05) # 비계획 정비 / 긴급 불출\n", + " v = r.cap * UTIL[r.seg] * lvl[d] * (1.25 if urgent else 1.0) * rng.normal(1, 0.05)\n", + " cap_eff = r.cap * (0.65 if maint else 1.0) * (0.92 if rain else 1.0)\n", + " delay = max(r.t0 * 0.2 * (v / cap_eff) ** 3.5 + 0.25 * prev[r.seg] + rng.normal(0, 0.4), 0.0)\n", + " prev[r.seg] = delay\n", + " rows.append(dict(day=int(d), dow=int(d % 7), seg=r.seg, kind=r.kind, km=r.km, cap=r.cap, t0=r.t0,\n", + " plan_v=v / (1.25 if urgent else 1.0) * rng.normal(1, 0.04), v=v, maint=int(maint), urgent=int(urgent),\n", + " rain=int(rain), cap_eff=cap_eff, delay=delay))\n", + " df = pd.DataFrame(rows)\n", + " df[\"transit\"] = df[\"t0\"] * (1 + 0.08 * df[\"rain\"]) + df[\"delay\"]\n", + " return df\n", + "\n", + "\n", + "def mape(y, p):\n", + " y, p = np.asarray(y, float), np.asarray(p, float)\n", + " return float(np.mean(np.abs(y - p) / np.abs(y)) * 100)\n", + "\n", + "\n", + "def mae(y, p):\n", + " return float(np.mean(np.abs(np.asarray(y, float) - np.asarray(p, float))))\n", + "# ---------------------------------------------------------------------------\n", + "\n", + "clean = make_data()\n", + "rng = np.random.default_rng(2)\n", + "dirty = clean.copy()\n", + "# ---- 오염 주입(가상): 결측·음수·단위 오류·표기 오류·미등록 구간·0 용량·중복 ----\n", + "ix = rng.choice(len(dirty), 40, replace=False)\n", + "dirty.loc[ix[:12], \"v\"] = np.nan\n", + "dirty.loc[ix[12:18], \"v\"] = -dirty.loc[ix[12:18], \"v\"]\n", + "dirty.loc[ix[18:23], \"v\"] = dirty.loc[ix[18:23], \"v\"] * 10\n", + "dirty.loc[ix[23:29], \"transit\"] = np.nan\n", + "dirty.loc[ix[29:32], \"cap\"] = 0\n", + "dirty.loc[ix[32:36], \"seg\"] = dirty.loc[ix[32:36], \"seg\"].str.lower() + \" \"\n", + "ghost = dirty.iloc[ix[36:40]].copy(); ghost[\"seg\"] = \"Z9\" # 마스터에 없는 구간 코드가 섞여 들어온 행\n", + "dirty = pd.concat([dirty, ghost, dirty.iloc[rng.choice(len(dirty), 15, replace=False)]], ignore_index=True)\n", + "planted = {\"결측 통과량\": 12, \"음수 통과량\": 6, \"단위 오류(10배)\": 5, \"결측 통과시간\": 6, \"용량 0\": 3, \"구간 표기 오류\": 4, \"미등록 구간\": 4, \"중복 행\": 15}\n", + "print(f\"[0] 오염 데이터 {len(dirty)}행 (원본 {len(clean)}행), 주입 오류 {sum(planted.values())}건: {planted}\")\n", + "\n", + "log, w = [], dirty.copy()\n", + "master = SEGS.set_index(\"seg\")\n", + "\n", + "\n", + "def rule(name, n):\n", + " log.append((len(log) + 1, name, n))\n", + "\n", + "\n", + "# R1 구간 코드 표기 정규화\n", + "before = w[\"seg\"].copy(); w[\"seg\"] = w[\"seg\"].str.strip().str.upper(); rule(\"구간 코드 공백·대소문자 정규화\", int((before != w[\"seg\"]).sum()))\n", + "# R2 미등록 구간 격리(삭제하지 않고 별도 보관)\n", + "bad = ~w[\"seg\"].isin(master.index); quarantine = w[bad].copy(); w = w[~bad]; rule(\"미등록 구간 격리(별도 보관)\", int(bad.sum()))\n", + "# R3 중복 제거(구간·일자 키)\n", + "n0 = len(w); w = w.drop_duplicates([\"seg\", \"day\"], keep=\"first\"); rule(\"(구간, 일자) 중복 제거\", n0 - len(w))\n", + "# R4 용량 0/결측은 구간 마스터에서 복원\n", + "bad = w[\"cap\"] <= 0; w.loc[bad, \"cap\"] = w.loc[bad, \"seg\"].map(master[\"cap\"]); rule(\"용량 0 -> 구간 마스터 복원\", int(bad.sum()))\n", + "# R5 음수 통과량은 결측으로 전환(부호 오류 가능성 -> 보간 대상)\n", + "bad = w[\"v\"] < 0; w.loc[bad, \"v\"] = np.nan; rule(\"음수 통과량 -> 결측 처리\", int(bad.sum()))\n", + "# R6 단위 오류(용량의 3배 초과)는 1/10 보정\n", + "bad = w[\"v\"] > 3 * w[\"cap\"]; w.loc[bad, \"v\"] = w.loc[bad, \"v\"] / 10; rule(\"단위 오류(용량 3배 초과) 1/10 보정\", int(bad.sum()))\n", + "# R7 결측 통과량: 같은 구간·같은 요일의 중앙값으로 보간(계절성 보간)\n", + "bad = w[\"v\"].isna(); med = w.groupby([\"seg\", \"dow\"])[\"v\"].transform(\"median\"); w.loc[bad, \"v\"] = med[bad]; rule(\"결측 통과량 -> 구간·요일 중앙값 보간\", int(bad.sum()))\n", + "# R8 결측 통과시간: 자유주행 + 지체로 재계산\n", + "bad = w[\"transit\"].isna(); w.loc[bad, \"transit\"] = w.loc[bad, \"t0\"] * (1 + 0.08 * w.loc[bad, \"rain\"]) + w.loc[bad, \"delay\"]; rule(\"결측 통과시간 재계산\", int(bad.sum()))\n", + "# R9 지체 시간 정합: delay = transit - 자유주행(우천 보정), 음수는 0으로\n", + "d2 = (w[\"transit\"] - w[\"t0\"] * (1 + 0.08 * w[\"rain\"])).clip(lower=0); mism = (d2 - w[\"delay\"]).abs() > 1e-6\n", + "w[\"delay\"] = d2; rule(\"지체 = 통과시간 - 자유주행 정합 재계산\", int(mism.sum()))\n", + "# R10 이상치 표시: IQR 밖 지체는 '표시'만 하고, 정비·긴급 불출 행은 예외로 보존(삭제 금지)\n", + "q1, q3 = w[\"delay\"].quantile([.25, .75]); out = w[\"delay\"] > q3 + 3 * (q3 - q1)\n", + "w[\"outlier\"] = out.astype(int); w[\"exception_keep\"] = ((w[\"maint\"] == 1) | (w[\"urgent\"] == 1)).astype(int)\n", + "rule(\"지체 이상치 플래그(삭제 없음)\", int(out.sum()))\n", + "w = w.sort_values([\"day\", \"seg\"]).reset_index(drop=True)\n", + "\n", + "print(\"\\n[1] 정제 규칙 적용 로그\")\n", + "print(pd.DataFrame(log, columns=[\"규칙\", \"내용\", \"처리 건수\"]).to_string(index=False))\n", + "print(f\" 정제 규칙 {len(log)}종 적용(목표 10종: {'달성' if len(log) >= 10 else '미달'})\")\n", + "\n", + "# ---- 정합성 검증 ----\n", + "checks = {\"(구간, 일자) 유일\": not w.duplicated([\"seg\", \"day\"]).any(), \"필수 컬럼 결측 0\": int(w[[\"seg\", \"v\", \"cap\", \"delay\", \"transit\"]].isna().sum().sum()) == 0,\n", + " \"구간 코드 마스터 포함\": bool(w[\"seg\"].isin(master.index).all()), \"통과량 0 이상·용량 3배 이하\": bool(((w[\"v\"] >= 0) & (w[\"v\"] <= 3 * w[\"cap\"])).all()),\n", + " \"용량 = 마스터 값\": bool((w[\"cap\"] == w[\"seg\"].map(master[\"cap\"])).all()), \"통과시간 >= 자유주행\": bool((w[\"transit\"] >= w[\"t0\"] - 1e-9).all()),\n", + " \"지체 >= 0\": bool((w[\"delay\"] >= 0).all())}\n", + "passed = sum(checks.values())\n", + "print(\"\\n[2] 정합성 검증\")\n", + "for k, v in checks.items():\n", + " print(f\" [{'PASS' if v else 'FAIL'}] {k}\")\n", + "print(f\" 통과율 {passed / len(checks):.0%} (목표 100%)\")\n", + "\n", + "# ---- 군수 특수 예외 보존 / 원본 대비 복원 정확도 ----\n", + "ex = w[w[\"exception_keep\"] == 1]\n", + "print(f\"\\n[3] 군수 특수 예외 보존: 비계획 정비 {int(w['maint'].sum())}건, 긴급 불출 {int(w['urgent'].sum())}건 모두 유지(원본 {int(clean['maint'].sum())}, {int(clean['urgent'].sum())}건)\")\n", + "print(f\" 예외 행 평균 지체 {ex['delay'].mean():.2f}분 vs 일반 행 {w.loc[w['exception_keep'] == 0, 'delay'].mean():.2f}분 -> 이상치로 지웠다면 폭증 구간 정보가 사라진다\")\n", + "m = w.merge(clean[[\"seg\", \"day\", \"v\"]], on=[\"seg\", \"day\"], suffixes=(\"\", \"_true\"))\n", + "print(f\" 통과량 복원 오차(보정·보간된 행 {int((m['v'] != m['v_true']).sum())}건 포함 전체 MAE) {np.abs(m['v'] - m['v_true']).mean():.3f}대/시\")\n", + "print(\"\\n[4] 전후 기술통계 비교(통과량 v / 지체 delay)\")\n", + "st = pd.DataFrame({\"오염 원본\": dirty[[\"v\", \"delay\"]].describe().loc[[\"count\", \"mean\", \"min\", \"max\"]].stack(), \"정제 후\": w[[\"v\", \"delay\"]].describe().loc[[\"count\", \"mean\", \"min\", \"max\"]].stack(),\n", + " \"정답 원본\": clean[[\"v\", \"delay\"]].describe().loc[[\"count\", \"mean\", \"min\", \"max\"]].stack()}).round(2)\n", + "print(st.to_string())\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 02 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + "[0] 오염 데이터 1519행 (원본 1500행), 주입 오류 55건: {'결측 통과량': 12, '음수 통과량': 6, '단위 오류(10배)': 5, '결측 통과시간': 6, '용량 0': 3, '구간 표기 오류': 4, '미등록 구간': 4, '중복 행': 15}\n", + "\n", + "[1] 정제 규칙 적용 로그\n", + " 규칙 내용 처리 건수\n", + " 1 구간 코드 공백·대소문자 정규화 4\n", + " 2 미등록 구간 격리(별도 보관) 4\n", + " 3 (구간, 일자) 중복 제거 15\n", + " 4 용량 0 -> 구간 마스터 복원 3\n", + " 5 음수 통과량 -> 결측 처리 6\n", + " 6 단위 오류(용량 3배 초과) 1/10 보정 5\n", + " 7 결측 통과량 -> 구간·요일 중앙값 보간 18\n", + " 8 결측 통과시간 재계산 6\n", + " 9 지체 = 통과시간 - 자유주행 정합 재계산 0\n", + " 10 지체 이상치 플래그(삭제 없음) 43\n", + " 정제 규칙 10종 적용(목표 10종: 달성)\n", + "\n", + "[2] 정합성 검증\n", + " [PASS] (구간, 일자) 유일\n", + " [PASS] 필수 컬럼 결측 0\n", + " [PASS] 구간 코드 마스터 포함\n", + " [PASS] 통과량 0 이상·용량 3배 이하\n", + " [PASS] 용량 = 마스터 값\n", + " [PASS] 통과시간 >= 자유주행\n", + " [PASS] 지체 >= 0\n", + " 통과율 100% (목표 100%)\n", + "\n", + "[3] 군수 특수 예외 보존: 비계획 정비 95건, 긴급 불출 87건 모두 유지(원본 95, 87건)\n", + " 예외 행 평균 지체 4.37분 vs 일반 행 1.60분 -> 이상치로 지웠다면 폭증 구간 정보가 사라진다\n", + " 통과량 복원 오차(보정·보간된 행 18건 포함 전체 MAE) 0.104대/시\n", + "\n", + "[4] 전후 기술통계 비교(통과량 v / 지체 delay)\n", + " 오염 원본 정제 후 정답 원본\n", + "count v 1507.00 1500.00 1500.00\n", + " delay 1519.00 1500.00 1500.00\n", + "mean v 63.64 62.95 62.95\n", + " delay 1.94 1.93 1.93\n", + "min v -146.18 16.78 16.78\n", + " delay 0.00 0.00 0.00\n", + "max v 635.70 174.46 174.46\n", + " delay 21.79 21.79 21.79\n", + "```\n", + "
\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 03. 구간별 통과 용량 기반 1차 수송 지체 예측 Baseline 알고리즘 구현\n", + "**소요시간**: 약 2.5시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "통과 용량 기반 1차 수송 지체 예측 Baseline 알고리즘을 구현한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **구간별 통과 용량 기반 BPR 지체 함수로 1차 수송 지체 예측 Baseline을 만들고, EDA로 Baseline의 한계와 핵심 요인을 찾는다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **계획 물량을 공칭 용량으로 나눈 이용률로 구간 지체를 계산하고 통과시간의 오차를 측정 수행하고 검증하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `notebooks/mov01_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": [ + "\"\"\"구간별 통과 용량(BPR 함수) 기반 1차 수송 지체 예측 Baseline을 만들어 오차를 측정하고, EDA 차트 8종과 상관 피처·파레토(상위 20% 구간) 분석으로 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", + "# [교육용가상자료] 가상 노선망·구간 통과량 생성기 (실제 도로·지명·부대 없음)\n", + "# 가상 출발지(SRC)에서 가상 목적지(DST)까지 3개 축선(A·B·C)과 연결로 2개, 구간 10개. 150일치 피크시간 통과량·지체 시간을 만든다.\n", + "# ---------------------------------------------------------------------------\n", + "SEGS = pd.DataFrame([\n", + " (\"A1\", \"SRC\", \"J1\", 12, 130, \"일반\"), (\"A2\", \"J1\", \"J2\", 8, 70, \"교량\"), (\"A3\", \"J2\", \"DST\", 10, 140, \"교차로\"),\n", + " (\"B1\", \"SRC\", \"J3\", 15, 100, \"일반\"), (\"B2\", \"J3\", \"J2\", 9, 80, \"터널\"), (\"B3\", \"J3\", \"DST\", 14, 95, \"일반\"),\n", + " (\"C1\", \"SRC\", \"J4\", 18, 70, \"일반\"), (\"C2\", \"J4\", \"DST\", 20, 75, \"교량\"),\n", + " (\"X1\", \"J1\", \"J3\", 7, 50, \"교차로\"), (\"X2\", \"J4\", \"J3\", 10, 60, \"일반\")],\n", + " columns=[\"seg\", \"u\", \"v\", \"km\", \"cap\", \"kind\"]) # cap = 피크시간 통과 용량(대/시, 교육용 가정값)\n", + "KSPD = {\"일반\": 55, \"교량\": 40, \"터널\": 45, \"교차로\": 45} # 구간 유형별 자유 주행 속도(km/h, 가정값)\n", + "SEGS[\"t0\"] = SEGS[\"km\"] / SEGS[\"kind\"].map(KSPD) * 60 # 자유 주행 시간(분)\n", + "UTIL = dict(A1=.78, A2=.85, A3=.66, B1=.70, B2=.72, B3=.64, C1=.55, C2=.58, X1=.45, X2=.40) # 평시 이용률\n", + "\n", + "\n", + "def bpr(t0, v, cap, a=0.15, b=4.0):\n", + " \"\"\"BPR 지체 함수(교통공학의 표준형): 통과량/용량 비가 커질수록 지체가 급증한다. (분)\"\"\"\n", + " return t0 * a * (v / cap) ** b\n", + "\n", + "\n", + "def make_data(seed=11, days=150):\n", + " \"\"\"구간 x 일자 패널. v=실제 피크 통과량, plan_v=수송계획 수량(긴급 불출 미반영), delay=실제 지체(분).\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " d_idx = np.arange(days)\n", + " lvl = np.array([1.0, 1.08, 1.04, 1.10, 1.02, 0.82, 0.78])[d_idx % 7] * (1 + 0.0012 * d_idx)\n", + " lvl = lvl * np.where((d_idx >= 100) & (d_idx < 110), 1.30, 1.0) # 수송량 폭증 기간(가상)\n", + " prev, rows = {s: 0.0 for s in SEGS[\"seg\"]}, []\n", + " for d in d_idx:\n", + " rain = bool(rng.random() < 0.18)\n", + " for r in SEGS.itertuples():\n", + " maint, urgent = bool(rng.random() < 0.06), bool(rng.random() < 0.05) # 비계획 정비 / 긴급 불출\n", + " v = r.cap * UTIL[r.seg] * lvl[d] * (1.25 if urgent else 1.0) * rng.normal(1, 0.05)\n", + " cap_eff = r.cap * (0.65 if maint else 1.0) * (0.92 if rain else 1.0)\n", + " delay = max(r.t0 * 0.2 * (v / cap_eff) ** 3.5 + 0.25 * prev[r.seg] + rng.normal(0, 0.4), 0.0)\n", + " prev[r.seg] = delay\n", + " rows.append(dict(day=int(d), dow=int(d % 7), seg=r.seg, kind=r.kind, km=r.km, cap=r.cap, t0=r.t0,\n", + " plan_v=v / (1.25 if urgent else 1.0) * rng.normal(1, 0.04), v=v, maint=int(maint), urgent=int(urgent),\n", + " rain=int(rain), cap_eff=cap_eff, delay=delay))\n", + " df = pd.DataFrame(rows)\n", + " df[\"transit\"] = df[\"t0\"] * (1 + 0.08 * df[\"rain\"]) + df[\"delay\"]\n", + " return df\n", + "\n", + "\n", + "def mape(y, p):\n", + " y, p = np.asarray(y, float), np.asarray(p, float)\n", + " return float(np.mean(np.abs(y - p) / np.abs(y)) * 100)\n", + "\n", + "\n", + "def mae(y, p):\n", + " return float(np.mean(np.abs(np.asarray(y, float) - np.asarray(p, float))))\n", + "# ---------------------------------------------------------------------------\n", + "# ---------------------------------------------------------------------------\n", + "# 지체 예측용 파생 피처 생성(당일 오전 시점에 알 수 있는 정보만 사용: 계획 물량, 공지 정비, 기상 예보, 전일·전주 실적)\n", + "# ---------------------------------------------------------------------------\n", + "FEATS = [\"plan_v\", \"cap\", \"cap_eff\", \"t0\", \"km\", \"maint\", \"rain\", \"util\", \"util_eff\", \"util_eff4\", \"bpr_base\", \"lag1_delay\", \"lag7_delay\",\n", + " \"roll7_v\", \"surge_ratio\", \"is_weekend\", \"dow_sin\", \"dow_cos\", \"k_교량\", \"k_터널\", \"k_교차로\"]\n", + "\n", + "\n", + "def add_features(df):\n", + " d = df.sort_values([\"seg\", \"day\"]).copy()\n", + " g = d.groupby(\"seg\")\n", + " d[\"lag1_delay\"] = g[\"delay\"].shift(1)\n", + " d[\"lag7_delay\"] = g[\"delay\"].shift(7)\n", + " d[\"roll7_v\"] = g[\"v\"].transform(lambda s: s.shift(1).rolling(7).mean())\n", + " d[\"util\"] = d[\"plan_v\"] / d[\"cap\"]\n", + " d[\"util_eff\"] = d[\"plan_v\"] / d[\"cap_eff\"]\n", + " d[\"util_eff4\"] = d[\"util_eff\"] ** 4\n", + " d[\"bpr_base\"] = bpr(d[\"t0\"], d[\"plan_v\"], d[\"cap\"])\n", + " d[\"surge_ratio\"] = d[\"plan_v\"] / d[\"roll7_v\"]\n", + " d[\"is_weekend\"] = (d[\"dow\"] >= 5).astype(int)\n", + " d[\"dow_sin\"], d[\"dow_cos\"] = np.sin(2 * np.pi * d[\"dow\"] / 7), np.cos(2 * np.pi * d[\"dow\"] / 7)\n", + " for k in (\"교량\", \"터널\", \"교차로\"):\n", + " d[\"k_\" + k] = (d[\"kind\"] == k).astype(int)\n", + " return d.dropna().sort_values([\"day\", \"seg\"]).reset_index(drop=True)\n", + "# ---------------------------------------------------------------------------\n", + "\n", + "df = make_data()\n", + "d = add_features(df)\n", + "\n", + "# 1) Baseline: 계획 물량 / 공칭 용량으로 BPR 지체 계산(정비·기상·전일 지체 미반영)\n", + "d[\"pred_base\"] = d[\"bpr_base\"]\n", + "d[\"pred_transit\"] = d[\"t0\"] + d[\"pred_base\"]\n", + "print(f\"[1] Baseline(BPR) 지체 예측: 통과시간 MAPE {mape(d.transit, d.pred_transit):.1f}%, 통과시간 MAE {mae(d.transit, d.pred_transit):.2f}분, 지체 MAE {mae(d.delay, d.pred_base):.2f}분\")\n", + "bias = (d[\"pred_base\"] - d[\"delay\"])\n", + "grp = pd.DataFrame({\"평균 오차(예측-실제, 분)\": [bias[d.maint == 1].mean(), bias[d.maint == 0].mean(), bias[d.rain == 1].mean(), bias[d.rain == 0].mean(), bias[d.urgent == 1].mean(), bias[(d.day >= 100) & (d.day < 110)].mean()]},\n", + " index=[\"비계획 정비일\", \"정비 없음\", \"우천일\", \"맑음\", \"긴급 불출일\", \"수송량 폭증 기간\"]).round(2)\n", + "print(\"[2] 조건별 Baseline 오차(음수 = 과소예측)\")\n", + "print(grp.to_string())\n", + "print(\" -> Baseline은 정비·긴급 불출·폭증 조건에서 지체를 과소예측한다(한계).\")\n", + "\n", + "# 2) 상관 분석: 지체와 각 피처의 스피어만 상관·p-value\n", + "rows = []\n", + "for f in FEATS:\n", + " r, p = stats.spearmanr(d[f], d[\"delay\"])\n", + " rows.append((f, r, p))\n", + "cor = pd.DataFrame(rows, columns=[\"피처\", \"상관\", \"p\"]).assign(abs_r=lambda x: x[\"상관\"].abs()).sort_values(\"abs_r\", ascending=False)\n", + "sig = cor[(cor[\"abs_r\"] >= 0.3) & (cor[\"p\"] < 0.05)]\n", + "print(f\"\\n[3] 지체와 유의한 상관(|r|>=0.3, p<0.05) 피처 {len(sig)}개 (목표 5개 이상: {'달성' if len(sig) >= 5 else '미달'})\")\n", + "print(sig.head(7)[[\"피처\", \"상관\", \"p\"]].round(3).to_string(index=False))\n", + "t, p = stats.ttest_ind(d.loc[d.maint == 1, \"delay\"], d.loc[d.maint == 0, \"delay\"], equal_var=False)\n", + "print(f\" 가설검정: 비계획 정비일 vs 평시 지체 평균차 t={t:.1f}, p={p:.2g}\")\n", + "\n", + "# 3) 파레토(ABC): 구간별 총 지체 기여\n", + "tot = d.groupby(\"seg\")[\"delay\"].sum().sort_values(ascending=False)\n", + "cum = tot.cumsum() / tot.sum()\n", + "top = tot.index[:max(1, int(np.ceil(len(tot) * 0.2)))]\n", + "print(f\"\\n[4] 파레토: 상위 20% 구간({', '.join(top)})이 총 지체의 {tot[top].sum() / tot.sum():.0%}를 차지, 상위 50% 구간 {cum.iloc[4]:.0%}\")\n", + "\n", + "# 4) EDA 차트 8종\n", + "fig, ax = plt.subplots(2, 4, figsize=(18, 8))\n", + "daily = d.groupby(\"day\")[\"v\"].sum(); ax[0, 0].plot(daily.index, daily.values); ax[0, 0].set_title(\"1 Daily total peak volume\")\n", + "ax[0, 1].hist(d[\"delay\"], bins=40); ax[0, 1].set_title(\"2 Delay distribution\")\n", + "ax[0, 2].scatter(d[\"util\"], d[\"delay\"], s=6, alpha=.4); ax[0, 2].set_title(\"3 Utilization vs delay\")\n", + "mean_seg = d.groupby(\"seg\")[\"delay\"].mean().sort_values(); ax[0, 3].barh(mean_seg.index, mean_seg.values); ax[0, 3].set_title(\"4 Mean delay by segment\")\n", + "ax[1, 0].boxplot([d.loc[d.dow == k, \"delay\"] for k in range(7)]); ax[1, 0].set_title(\"5 Delay by weekday\")\n", + "ax[1, 1].boxplot([d.loc[d.rain == 0, \"delay\"], d.loc[d.rain == 1, \"delay\"]])\n", + "ax[1, 1].set_xticklabels([\"dry\", \"rain\"]); ax[1, 1].set_title(\"6 Rain effect\")\n", + "ax[1, 2].boxplot([d.loc[d.maint == 0, \"delay\"], d.loc[d.maint == 1, \"delay\"]])\n", + "ax[1, 2].set_xticklabels([\"normal\", \"maintenance\"]); ax[1, 2].set_title(\"7 Maintenance effect\")\n", + "ax[1, 3].bar(range(len(tot)), (tot / tot.sum()).values); ax[1, 3].plot(range(len(tot)), cum.values, \"r-o\"); ax[1, 3].set_title(\"8 Pareto of total delay\")\n", + "plt.suptitle(\"MOV01 EDA (synthetic data)\"); plt.tight_layout(); plt.show()\n", + "print(\"\\n[5] EDA 차트 8종 생성 완료(목표 8종 이상: 달성)\")\n", + "print(\"핵심 발견: 지체는 이용률의 비선형 함수이며 정비·폭증·긴급 불출이 겹치면 급증한다. 이는 M4 파생 피처 설계의 근거가 된다.\")\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 03 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + "[1] Baseline(BPR) 지체 예측: 통과시간 MAPE 7.4%, 통과시간 MAE 1.39분, 지체 MAE 1.23분\n", + "[2] 조건별 Baseline 오차(음수 = 과소예측)\n", + " 평균 오차(예측-실제, 분)\n", + "비계획 정비일 -4.87\n", + "정비 없음 -0.96\n", + "우천일 -1.67\n", + "맑음 -1.14\n", + "긴급 불출일 -2.62\n", + "수송량 폭증 기간 -2.66\n", + " -> Baseline은 정비·긴급 불출·폭증 조건에서 지체를 과소예측한다(한계).\n", + "\n", + "[3] 지체와 유의한 상관(|r|>=0.3, p<0.05) 피처 14개 (목표 5개 이상: 달성)\n", + " 피처 상관 p\n", + " util_eff 0.836 0.0\n", + " util_eff4 0.836 0.0\n", + " bpr_base 0.830 0.0\n", + " util 0.793 0.0\n", + "lag1_delay 0.730 0.0\n", + " plan_v 0.653 0.0\n", + "lag7_delay 0.650 0.0\n", + " 가설검정: 비계획 정비일 vs 평시 지체 평균차 t=8.2, p=1.7e-12\n", + "\n", + "[4] 파레토: 상위 20% 구간(A2, A1)이 총 지체의 33%를 차지, 상위 50% 구간 70%\n", + "\n", + "[5] EDA 차트 8종 생성 완료(목표 8종 이상: 달성)\n", + "핵심 발견: 지체는 이용률의 비선형 함수이며 정비·폭증·긴급 불출이 겹치면 급증한다. 이는 M4 파생 피처 설계의 근거가 된다.\n", + "```\n", + "
\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 04. 네트워크 흐름(Max-Flow) 알고리즘 기반 병목 발생 지점 사전 식별 및 우회로 탐색 모델 고도화\n", + "**소요시간**: 약 3.0시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "네트워크 흐름 알고리즘 기반 병목 사전 식별 및 우회로 탐색 모델을 고도화한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **최대유량(Max-Flow) 알고리즘으로 병목 구간을 사전에 식별하고 구간 폐쇄 시 우회로를 찾으며, 파생 피처 기반 기준 모델을 구축한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **노선망에서 출발지에서 목적지까지의 최대 통과능력과 최소 절단 구간을 구하고 구간별 증설 민감도를 비교 수행하고 검증하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `src/features/mov01_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": [ + "\"\"\"최대유량(Max-Flow) 알고리즘으로 가상 노선망의 최소 절단(병목) 구간을 사전 식별하고 구간 폐쇄 시 우회 경로를 찾으며, 파생 피처 14종과 시계열 교차검증으로 지체 예측 기준 모델(MAPE)을 만든다.\"\"\"\n", + "import numpy as np\n", + "import pandas as pd\n", + "import matplotlib.pyplot as plt\n", + "from scipy.optimize import linprog\n", + "from sklearn.ensemble import GradientBoostingRegressor\n", + "from sklearn.model_selection import TimeSeriesSplit\n", + "\n", + "# ---------------------------------------------------------------------------\n", + "# [교육용가상자료] 가상 노선망·구간 통과량 생성기 (실제 도로·지명·부대 없음)\n", + "# 가상 출발지(SRC)에서 가상 목적지(DST)까지 3개 축선(A·B·C)과 연결로 2개, 구간 10개. 150일치 피크시간 통과량·지체 시간을 만든다.\n", + "# ---------------------------------------------------------------------------\n", + "SEGS = pd.DataFrame([\n", + " (\"A1\", \"SRC\", \"J1\", 12, 130, \"일반\"), (\"A2\", \"J1\", \"J2\", 8, 70, \"교량\"), (\"A3\", \"J2\", \"DST\", 10, 140, \"교차로\"),\n", + " (\"B1\", \"SRC\", \"J3\", 15, 100, \"일반\"), (\"B2\", \"J3\", \"J2\", 9, 80, \"터널\"), (\"B3\", \"J3\", \"DST\", 14, 95, \"일반\"),\n", + " (\"C1\", \"SRC\", \"J4\", 18, 70, \"일반\"), (\"C2\", \"J4\", \"DST\", 20, 75, \"교량\"),\n", + " (\"X1\", \"J1\", \"J3\", 7, 50, \"교차로\"), (\"X2\", \"J4\", \"J3\", 10, 60, \"일반\")],\n", + " columns=[\"seg\", \"u\", \"v\", \"km\", \"cap\", \"kind\"]) # cap = 피크시간 통과 용량(대/시, 교육용 가정값)\n", + "KSPD = {\"일반\": 55, \"교량\": 40, \"터널\": 45, \"교차로\": 45} # 구간 유형별 자유 주행 속도(km/h, 가정값)\n", + "SEGS[\"t0\"] = SEGS[\"km\"] / SEGS[\"kind\"].map(KSPD) * 60 # 자유 주행 시간(분)\n", + "UTIL = dict(A1=.78, A2=.85, A3=.66, B1=.70, B2=.72, B3=.64, C1=.55, C2=.58, X1=.45, X2=.40) # 평시 이용률\n", + "\n", + "\n", + "def bpr(t0, v, cap, a=0.15, b=4.0):\n", + " \"\"\"BPR 지체 함수(교통공학의 표준형): 통과량/용량 비가 커질수록 지체가 급증한다. (분)\"\"\"\n", + " return t0 * a * (v / cap) ** b\n", + "\n", + "\n", + "def make_data(seed=11, days=150):\n", + " \"\"\"구간 x 일자 패널. v=실제 피크 통과량, plan_v=수송계획 수량(긴급 불출 미반영), delay=실제 지체(분).\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " d_idx = np.arange(days)\n", + " lvl = np.array([1.0, 1.08, 1.04, 1.10, 1.02, 0.82, 0.78])[d_idx % 7] * (1 + 0.0012 * d_idx)\n", + " lvl = lvl * np.where((d_idx >= 100) & (d_idx < 110), 1.30, 1.0) # 수송량 폭증 기간(가상)\n", + " prev, rows = {s: 0.0 for s in SEGS[\"seg\"]}, []\n", + " for d in d_idx:\n", + " rain = bool(rng.random() < 0.18)\n", + " for r in SEGS.itertuples():\n", + " maint, urgent = bool(rng.random() < 0.06), bool(rng.random() < 0.05) # 비계획 정비 / 긴급 불출\n", + " v = r.cap * UTIL[r.seg] * lvl[d] * (1.25 if urgent else 1.0) * rng.normal(1, 0.05)\n", + " cap_eff = r.cap * (0.65 if maint else 1.0) * (0.92 if rain else 1.0)\n", + " delay = max(r.t0 * 0.2 * (v / cap_eff) ** 3.5 + 0.25 * prev[r.seg] + rng.normal(0, 0.4), 0.0)\n", + " prev[r.seg] = delay\n", + " rows.append(dict(day=int(d), dow=int(d % 7), seg=r.seg, kind=r.kind, km=r.km, cap=r.cap, t0=r.t0,\n", + " plan_v=v / (1.25 if urgent else 1.0) * rng.normal(1, 0.04), v=v, maint=int(maint), urgent=int(urgent),\n", + " rain=int(rain), cap_eff=cap_eff, delay=delay))\n", + " df = pd.DataFrame(rows)\n", + " df[\"transit\"] = df[\"t0\"] * (1 + 0.08 * df[\"rain\"]) + df[\"delay\"]\n", + " return df\n", + "\n", + "\n", + "def mape(y, p):\n", + " y, p = np.asarray(y, float), np.asarray(p, float)\n", + " return float(np.mean(np.abs(y - p) / np.abs(y)) * 100)\n", + "\n", + "\n", + "def mae(y, p):\n", + " return float(np.mean(np.abs(np.asarray(y, float) - np.asarray(p, float))))\n", + "# ---------------------------------------------------------------------------\n", + "# ---------------------------------------------------------------------------\n", + "# 지체 예측용 파생 피처 생성(당일 오전 시점에 알 수 있는 정보만 사용: 계획 물량, 공지 정비, 기상 예보, 전일·전주 실적)\n", + "# ---------------------------------------------------------------------------\n", + "FEATS = [\"plan_v\", \"cap\", \"cap_eff\", \"t0\", \"km\", \"maint\", \"rain\", \"util\", \"util_eff\", \"util_eff4\", \"bpr_base\", \"lag1_delay\", \"lag7_delay\",\n", + " \"roll7_v\", \"surge_ratio\", \"is_weekend\", \"dow_sin\", \"dow_cos\", \"k_교량\", \"k_터널\", \"k_교차로\"]\n", + "\n", + "\n", + "def add_features(df):\n", + " d = df.sort_values([\"seg\", \"day\"]).copy()\n", + " g = d.groupby(\"seg\")\n", + " d[\"lag1_delay\"] = g[\"delay\"].shift(1)\n", + " d[\"lag7_delay\"] = g[\"delay\"].shift(7)\n", + " d[\"roll7_v\"] = g[\"v\"].transform(lambda s: s.shift(1).rolling(7).mean())\n", + " d[\"util\"] = d[\"plan_v\"] / d[\"cap\"]\n", + " d[\"util_eff\"] = d[\"plan_v\"] / d[\"cap_eff\"]\n", + " d[\"util_eff4\"] = d[\"util_eff\"] ** 4\n", + " d[\"bpr_base\"] = bpr(d[\"t0\"], d[\"plan_v\"], d[\"cap\"])\n", + " d[\"surge_ratio\"] = d[\"plan_v\"] / d[\"roll7_v\"]\n", + " d[\"is_weekend\"] = (d[\"dow\"] >= 5).astype(int)\n", + " d[\"dow_sin\"], d[\"dow_cos\"] = np.sin(2 * np.pi * d[\"dow\"] / 7), np.cos(2 * np.pi * d[\"dow\"] / 7)\n", + " for k in (\"교량\", \"터널\", \"교차로\"):\n", + " d[\"k_\" + k] = (d[\"kind\"] == k).astype(int)\n", + " return d.dropna().sort_values([\"day\", \"seg\"]).reset_index(drop=True)\n", + "# ---------------------------------------------------------------------------\n", + "# ---------------------------------------------------------------------------\n", + "# 노선망 흐름 도구: 최대유량(Edmonds-Karp), 증분 배정(혼잡 반영 최단시간 경로), 경로 열거\n", + "# ---------------------------------------------------------------------------\n", + "CAP = dict(zip(SEGS[\"seg\"], SEGS[\"cap\"]))\n", + "T0 = dict(zip(SEGS[\"seg\"], SEGS[\"t0\"]))\n", + "KM = dict(zip(SEGS[\"seg\"], SEGS[\"km\"]))\n", + "ARCS = {r.seg: (r.u, r.v) for r in SEGS.itertuples()}\n", + "SRC, DST = \"SRC\", \"DST\"\n", + "\n", + "\n", + "def seg_time(s, flow, cap=None, a=0.2, b=3.5):\n", + " \"\"\"구간 통과시간(분) = 자유주행 + 혼잡 지체. cap을 주면 용량을 대체(정비·기상 반영).\"\"\"\n", + " r = flow / (cap or CAP[s])\n", + " return T0[s] * (1 + a * r ** b + 6.0 * max(0.0, r - 1.0)) # 용량 초과분은 대기열로 급증\n", + "\n", + "\n", + "def max_flow(closed=(), capmul=None):\n", + " \"\"\"최대유량(대/시)과 최소 절단 구간(병목). capmul = {구간: 용량 배율}.\"\"\"\n", + " cap = {s: (0 if s in closed else CAP[s] * (capmul or {}).get(s, 1.0)) for s in CAP}\n", + " res = {}\n", + " for s, (u, v) in ARCS.items():\n", + " res[(u, v)] = res.get((u, v), 0) + cap[s]\n", + " res.setdefault((v, u), 0)\n", + " flow = 0.0\n", + " while True:\n", + " prev, q = {SRC: None}, [SRC]\n", + " for u in q:\n", + " for (a, b), c in res.items():\n", + " if a == u and c > 1e-9 and b not in prev:\n", + " prev[b] = u; q.append(b)\n", + " if DST not in prev:\n", + " break\n", + " path, x = [], DST\n", + " while prev[x]:\n", + " path.append((prev[x], x)); x = prev[x]\n", + " f = min(res[e] for e in path)\n", + " for a, b in path:\n", + " res[(a, b)] -= f; res[(b, a)] += f\n", + " flow += f\n", + " reach = set(prev)\n", + " cut = [s for s, (u, v) in ARCS.items() if u in reach and v not in reach and cap[s] > 0]\n", + " return flow, cut\n", + "\n", + "\n", + "def all_paths():\n", + " \"\"\"SRC -> DST 단순 경로(구간 이름 튜플) 전체.\"\"\"\n", + " out = []\n", + "\n", + " def dfs(node, segs, seen):\n", + " if node == DST:\n", + " out.append(tuple(segs)); return\n", + " for s, (u, v) in ARCS.items():\n", + " if u == node and v not in seen:\n", + " dfs(v, segs + [s], seen | {v})\n", + " dfs(SRC, [], {SRC})\n", + " return out\n", + "\n", + "\n", + "PATHS = all_paths()\n", + "\n", + "\n", + "def assign(demand, closed=(), capmul=None, steps=40):\n", + " \"\"\"증분 배정: 수요를 steps조각으로 나눠, 전체 통과시간(대수 x 시간) 증가가 가장 작은 경로에 차례로 얹는다(시스템 최적 근사).\"\"\"\n", + " cap = {s: CAP[s] * (capmul or {}).get(s, 1.0) for s in CAP}\n", + " flow, pf = {s: 0.0 for s in CAP}, {}\n", + " ok = [p for p in PATHS if not any(s in closed for s in p)]\n", + " if not ok:\n", + " return None\n", + " for _ in range(steps):\n", + " chunk = demand / steps\n", + " best = min(ok, key=lambda p: sum((flow[s] + chunk) * seg_time(s, flow[s] + chunk, cap[s]) - flow[s] * seg_time(s, flow[s], cap[s]) for s in p))\n", + " for s in best:\n", + " flow[s] += chunk\n", + " pf[best] = pf.get(best, 0) + chunk\n", + " tt = {p: sum(seg_time(s, flow[s], cap[s]) for s in p) for p in pf}\n", + " avg = sum(pf[p] * tt[p] for p in pf) / demand\n", + " return {\"flow\": flow, \"paths\": pf, \"path_min\": tt, \"avg_min\": avg, \"util\": {s: flow[s] / cap[s] for s in cap if cap[s] > 0}}\n", + "\n", + "\n", + "FIXED_SHARE = {(\"A1\", \"A2\", \"A3\"): 0.32, (\"B1\", \"B3\"): 0.40, (\"C1\", \"C2\"): 0.28} # 과거 관행: 축선별 고정 배분(가상)\n", + "\n", + "\n", + "def fixed_assign(demand, closed=(), capmul=None):\n", + " \"\"\"과거 관행 배분: 축선별 고정 비율. 막힌 축선 물량은 열려 있는 축선에 비율대로 재분배.\"\"\"\n", + " cap = {s: CAP[s] * (capmul or {}).get(s, 1.0) for s in CAP}\n", + " live = {p: w for p, w in FIXED_SHARE.items() if not any(s in closed for s in p)}\n", + " if not live:\n", + " return None\n", + " tot = sum(live.values())\n", + " flow = {s: 0.0 for s in CAP}\n", + " pf = {p: demand * w / tot for p, w in live.items()}\n", + " for p, f in pf.items():\n", + " for s in p:\n", + " flow[s] += f\n", + " tt = {p: sum(seg_time(s, flow[s], cap[s]) for s in p) for p in pf}\n", + " return {\"flow\": flow, \"paths\": pf, \"path_min\": tt, \"avg_min\": sum(pf[p] * tt[p] for p in pf) / demand,\n", + " \"util\": {s: flow[s] / cap[s] for s in cap if cap[s] > 0}}\n", + "\n", + "\n", + "def lp_assign(demand, closed=(), capmul=None):\n", + " \"\"\"선형계획(LP): 구간 용량 상한(이용률 rho) 아래에서 자유주행시간 합을 최소화하는 경로별 물량. 불가능하면 rho를 1.0까지 완화.\"\"\"\n", + " cap = {s: CAP[s] * (capmul or {}).get(s, 1.0) for s in CAP}\n", + " ok = [p for p in PATHS if not any(s in closed for s in p)]\n", + " if not ok:\n", + " return None\n", + " for rho in (0.85, 0.95, 1.0):\n", + " r = linprog([sum(T0[s] for s in p) for p in ok], A_ub=[[1.0 if s in p else 0.0 for p in ok] for s in CAP],\n", + " b_ub=[rho * cap[s] for s in CAP], A_eq=[[1.0] * len(ok)], b_eq=[demand], bounds=(0, None), method=\"highs\")\n", + " if r.status == 0:\n", + " break\n", + " else:\n", + " return assign(demand, closed, capmul) # 용량 초과 수요: 증분 배정으로 대체(과부하 표시)\n", + " flow = {s: sum(r.x[i] for i, p in enumerate(ok) if s in p) for s in CAP}\n", + " pf = {p: float(r.x[i]) for i, p in enumerate(ok) if r.x[i] > 1e-6}\n", + " tt = {p: sum(seg_time(s, flow[s], cap[s]) for s in p) for p in pf}\n", + " return {\"flow\": flow, \"paths\": pf, \"path_min\": tt, \"avg_min\": sum(pf[p] * tt[p] for p in pf) / demand,\n", + " \"util\": {s: flow[s] / cap[s] for s in cap if cap[s] > 0}, \"rho\": rho}\n", + "\n", + "\n", + "def cost_of(res, tv=1000, fuel_km=450):\n", + " \"\"\"시간당 비용(원, 교육용 가정): 대당 시간비용 tv원/분 + 유류비 fuel_km원/km.\"\"\"\n", + " return sum(f * (res[\"path_min\"][p] * tv + sum(KM[s] for s in p) * fuel_km) for p, f in res[\"paths\"].items())\n", + "# ---------------------------------------------------------------------------\n", + "\n", + "# 1) 최대유량과 병목(최소 절단)\n", + "f0, cut0 = max_flow()\n", + "print(f\"[1] 노선망 최대 통과능력 {f0:.0f}대/시, 최소 절단(병목) 구간: {', '.join(cut0)}\")\n", + "gain = []\n", + "for s in CAP:\n", + " g, _ = max_flow(capmul={s: 1.2})\n", + " gain.append((s, SEGS.set_index(\"seg\").loc[s, \"kind\"], CAP[s], g - f0))\n", + "gain = pd.DataFrame(gain, columns=[\"구간\", \"유형\", \"용량\", \"용량 20% 증설 시 증가(대/시)\"]).sort_values(\"용량 20% 증설 시 증가(대/시)\", ascending=False)\n", + "print(\"[2] 구간별 증설 민감도(병목일수록 증설 효과가 큼)\")\n", + "print(gain.head(5).to_string(index=False))\n", + "\n", + "# 2) 사전 병목 식별: 계획 물량 기준 이용률 >= 0.9인 구간 + 최소 절단 구간\n", + "df = make_data(); d = add_features(df)\n", + "today = d[d.day == d.day.max()].set_index(\"seg\")\n", + "risk = today[(today.util_eff >= 0.9)].index.tolist()\n", + "print(f\"\\n[3] 최종일(day {int(d.day.max())}) 사전 병목 경보: 유효 이용률 0.9 이상 구간 {risk or '없음'}\")\n", + "\n", + "# 3) 구간 폐쇄 시 우회: 최대유량 변화와 우회 경로\n", + "D = 200\n", + "print(f\"\\n[4] 구간 폐쇄 시나리오(수요 {D}대/시): 우회 경로와 평균 통과시간\")\n", + "base = assign(D)\n", + "rows = [(\"평시\", \"-\", f0, base[\"avg_min\"], \"A·B·C 분산\", \"수용\")]\n", + "for c in ((\"A2\",), (\"B2\",), (\"A3\",)):\n", + " mf, cut = max_flow(c)\n", + " r = assign(D, closed=c)\n", + " top = max(r[\"paths\"], key=r[\"paths\"].get)\n", + " rows.append((f\"{c[0]} 폐쇄\", \",\".join(cut), mf, r[\"avg_min\"], \"->\".join(top), \"수용\" if D <= mf else \"과부하\"))\n", + "sc = pd.DataFrame(rows, columns=[\"시나리오\", \"새 병목\", \"최대유량\", \"평균 통과(분)\", \"주 경로\", \"수요 대비\"]).round(1)\n", + "print(sc.to_string(index=False))\n", + "print(f\" A2 폐쇄 시 평균 통과시간 +{rows[1][3] - rows[0][3]:.1f}분, 수요 {D}대/시 대비 최대유량 {rows[1][2]:.0f}대/시 -> {rows[1][5]}(과부하면 우회만으로 부족하므로 물량 조정·타 수단 검토가 필요)\")\n", + "\n", + "# 4) 파생 피처로 지체 예측 기준 모델: 시계열 교차검증(TimeSeriesSplit)\n", + "print(f\"\\n[5] 파생 피처 {len([f for f in FEATS if f not in ['plan_v','cap','cap_eff','t0','km','maint','rain']])}개 생성(lag·이용률·BPR·폭증비·요일 순환·구간 더미 등)\")\n", + "days = np.sort(d.day.unique())\n", + "tscv = TimeSeriesSplit(n_splits=4)\n", + "res = []\n", + "for k, (a, b) in enumerate(tscv.split(days)):\n", + " tr, te = d[d.day.isin(days[a])], d[d.day.isin(days[b])]\n", + " m = GradientBoostingRegressor(n_estimators=150, max_depth=3, learning_rate=0.08, random_state=0).fit(tr[FEATS], tr[\"delay\"])\n", + " p = te[\"t0\"] * (1 + 0.08 * te[\"rain\"]) + m.predict(te[FEATS])\n", + " pb = te[\"t0\"] + te[\"bpr_base\"]\n", + " res.append((k + 1, len(tr), len(te), mape(te[\"transit\"], pb), mape(te[\"transit\"], p)))\n", + "cv = pd.DataFrame(res, columns=[\"fold\", \"학습\", \"검증\", \"BPR MAPE(%)\", \"기준모델 MAPE(%)\"]).round(2)\n", + "print(cv.to_string(index=False))\n", + "mm = cv[\"기준모델 MAPE(%)\"].mean()\n", + "print(f\" 교차검증 평균 MAPE {mm:.2f}% (목표 25% 이하: {'달성' if mm <= 25 else '미달'}), 폴드가 뒤로 갈수록 학습 구간이 길어져 미래 정보 누수가 없다\")\n", + "m = GradientBoostingRegressor(n_estimators=150, max_depth=3, learning_rate=0.08, random_state=0).fit(d[FEATS], d[\"delay\"])\n", + "imp = pd.Series(m.feature_importances_, index=FEATS).sort_values(ascending=False)\n", + "print(\" 피처 중요도 상위 5:\", \", \".join(f\"{k} {v:.2f}\" for k, v in imp.head(5).items()), f\"/ 중요도 1% 미만 {int((imp < 0.01).sum())}개(차원 축소 후보)\")\n", + "\n", + "fig, ax = plt.subplots(1, 2, figsize=(12, 4.5))\n", + "for s, (u, v) in ARCS.items():\n", + " pos = {\"SRC\": (0, 1.5), \"J1\": (1, 3), \"J2\": (2, 3), \"J3\": (1.3, 1.5), \"J4\": (1, 0), \"DST\": (3, 1.5)}\n", + " ax[0].plot([pos[u][0], pos[v][0]], [pos[u][1], pos[v][1]], \"r-\" if s in cut0 else \"gray\", lw=2 + CAP[s] / 40)\n", + " ax[0].text((pos[u][0] + pos[v][0]) / 2, (pos[u][1] + pos[v][1]) / 2, s, fontsize=9)\n", + "ax[0].set_title(\"Network (red = min-cut bottleneck)\"); ax[0].axis(\"off\")\n", + "imp.head(10)[::-1].plot.barh(ax=ax[1]); ax[1].set_title(\"Feature importance (top 10)\")\n", + "plt.tight_layout(); plt.show()\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 04 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + "[1] 노선망 최대 통과능력 290대/시, 최소 절단(병목) 구간: A2, B1, C1, X1\n", + "[2] 구간별 증설 민감도(병목일수록 증설 효과가 큼)\n", + "구간 유형 용량 용량 20% 증설 시 증가(대/시)\n", + "B1 일반 100 15.0\n", + "C1 일반 70 14.0\n", + "X1 교차로 50 10.0\n", + "A2 교량 70 10.0\n", + "A1 일반 130 0.0\n", + "\n", + "[3] 최종일(day 149) 사전 병목 경보: 유효 이용률 0.9 이상 구간 ['A1', 'A2']\n", + "\n", + "[4] 구간 폐쇄 시나리오(수요 200대/시): 우회 경로와 평균 통과시간\n", + " 시나리오 새 병목 최대유량 평균 통과(분) 주 경로 수요 대비\n", + " 평시 - 290.0 40.9 A·B·C 분산 수용\n", + "A2 폐쇄 B1,C1,X1 220.0 44.9 B1->B3 수용\n", + "B2 폐쇄 A2,B3,C1 235.0 41.0 B1->B3 수용\n", + "A3 폐쇄 B3,C1 165.0 70.9 B1->B3 과부하\n", + " A2 폐쇄 시 평균 통과시간 +4.0분, 수요 200대/시 대비 최대유량 220대/시 -> 수용(과부하면 우회만으로 부족하므로 물량 조정·타 수단 검토가 필요)\n", + "\n", + "[5] 파생 피처 14개 생성(lag·이용률·BPR·폭증비·요일 순환·구간 더미 등)\n", + " fold 학습 검증 BPR MAPE(%) 기준모델 MAPE(%)\n", + " 1 310 280 6.72 3.00\n", + " 2 590 280 6.02 2.54\n", + " 3 870 280 9.16 3.93\n", + " 4 1150 280 9.24 2.78\n", + " 교차검증 평균 MAPE 3.06% (목표 25% 이하: 달성), 폴드가 뒤로 갈수록 학습 구간이 길어져 미래 정보 누수가 없다\n", + " 피처 중요도 상위 5: util_eff 0.35, util_eff4 0.34, bpr_base 0.13, lag1_delay 0.09, km 0.02 / 중요도 1% 미만 14개(차원 축소 후보)\n", + "```\n", + "
\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 05. 우회 수송 시 추가 소요 시간 및 유류 소모량 산출 엔진 개발\n", + "**소요시간**: 약 3.5시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "우회 수송 시 추가 소요 시간 및 유류 소모량 산출 엔진을 개발한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **우회 수송 시 추가 소요 시간과 유류 소모량을 산출하는 엔진을 개발하고, 기준 모델 대비 앙상블 예측 모델의 개선율을 검증한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **증분 배정으로 구간 폐쇄·정비 시나리오별 우회 경로를 구하고 대당 추가 소요 시간, 추가 거리, 추가 유류와 일 추가 유류량 산출하고 모델에 적용하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `src/models/mov01_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": [ + "\"\"\"우회 수송 시 추가 소요 시간과 유류 소모량을 산출하는 엔진을 구현하고, 기준 모델(BPR) 대비 앙상블 지체 예측 모델의 오차 개선율과 R2를 Hold-out 데이터로 검증한다.\"\"\"\n", + "import numpy as np\n", + "import pandas as pd\n", + "from scipy.optimize import linprog\n", + "from sklearn.ensemble import GradientBoostingRegressor, RandomForestRegressor\n", + "from sklearn.metrics import r2_score\n", + "\n", + "# ---------------------------------------------------------------------------\n", + "# [교육용가상자료] 가상 노선망·구간 통과량 생성기 (실제 도로·지명·부대 없음)\n", + "# 가상 출발지(SRC)에서 가상 목적지(DST)까지 3개 축선(A·B·C)과 연결로 2개, 구간 10개. 150일치 피크시간 통과량·지체 시간을 만든다.\n", + "# ---------------------------------------------------------------------------\n", + "SEGS = pd.DataFrame([\n", + " (\"A1\", \"SRC\", \"J1\", 12, 130, \"일반\"), (\"A2\", \"J1\", \"J2\", 8, 70, \"교량\"), (\"A3\", \"J2\", \"DST\", 10, 140, \"교차로\"),\n", + " (\"B1\", \"SRC\", \"J3\", 15, 100, \"일반\"), (\"B2\", \"J3\", \"J2\", 9, 80, \"터널\"), (\"B3\", \"J3\", \"DST\", 14, 95, \"일반\"),\n", + " (\"C1\", \"SRC\", \"J4\", 18, 70, \"일반\"), (\"C2\", \"J4\", \"DST\", 20, 75, \"교량\"),\n", + " (\"X1\", \"J1\", \"J3\", 7, 50, \"교차로\"), (\"X2\", \"J4\", \"J3\", 10, 60, \"일반\")],\n", + " columns=[\"seg\", \"u\", \"v\", \"km\", \"cap\", \"kind\"]) # cap = 피크시간 통과 용량(대/시, 교육용 가정값)\n", + "KSPD = {\"일반\": 55, \"교량\": 40, \"터널\": 45, \"교차로\": 45} # 구간 유형별 자유 주행 속도(km/h, 가정값)\n", + "SEGS[\"t0\"] = SEGS[\"km\"] / SEGS[\"kind\"].map(KSPD) * 60 # 자유 주행 시간(분)\n", + "UTIL = dict(A1=.78, A2=.85, A3=.66, B1=.70, B2=.72, B3=.64, C1=.55, C2=.58, X1=.45, X2=.40) # 평시 이용률\n", + "\n", + "\n", + "def bpr(t0, v, cap, a=0.15, b=4.0):\n", + " \"\"\"BPR 지체 함수(교통공학의 표준형): 통과량/용량 비가 커질수록 지체가 급증한다. (분)\"\"\"\n", + " return t0 * a * (v / cap) ** b\n", + "\n", + "\n", + "def make_data(seed=11, days=150):\n", + " \"\"\"구간 x 일자 패널. v=실제 피크 통과량, plan_v=수송계획 수량(긴급 불출 미반영), delay=실제 지체(분).\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " d_idx = np.arange(days)\n", + " lvl = np.array([1.0, 1.08, 1.04, 1.10, 1.02, 0.82, 0.78])[d_idx % 7] * (1 + 0.0012 * d_idx)\n", + " lvl = lvl * np.where((d_idx >= 100) & (d_idx < 110), 1.30, 1.0) # 수송량 폭증 기간(가상)\n", + " prev, rows = {s: 0.0 for s in SEGS[\"seg\"]}, []\n", + " for d in d_idx:\n", + " rain = bool(rng.random() < 0.18)\n", + " for r in SEGS.itertuples():\n", + " maint, urgent = bool(rng.random() < 0.06), bool(rng.random() < 0.05) # 비계획 정비 / 긴급 불출\n", + " v = r.cap * UTIL[r.seg] * lvl[d] * (1.25 if urgent else 1.0) * rng.normal(1, 0.05)\n", + " cap_eff = r.cap * (0.65 if maint else 1.0) * (0.92 if rain else 1.0)\n", + " delay = max(r.t0 * 0.2 * (v / cap_eff) ** 3.5 + 0.25 * prev[r.seg] + rng.normal(0, 0.4), 0.0)\n", + " prev[r.seg] = delay\n", + " rows.append(dict(day=int(d), dow=int(d % 7), seg=r.seg, kind=r.kind, km=r.km, cap=r.cap, t0=r.t0,\n", + " plan_v=v / (1.25 if urgent else 1.0) * rng.normal(1, 0.04), v=v, maint=int(maint), urgent=int(urgent),\n", + " rain=int(rain), cap_eff=cap_eff, delay=delay))\n", + " df = pd.DataFrame(rows)\n", + " df[\"transit\"] = df[\"t0\"] * (1 + 0.08 * df[\"rain\"]) + df[\"delay\"]\n", + " return df\n", + "\n", + "\n", + "def mape(y, p):\n", + " y, p = np.asarray(y, float), np.asarray(p, float)\n", + " return float(np.mean(np.abs(y - p) / np.abs(y)) * 100)\n", + "\n", + "\n", + "def mae(y, p):\n", + " return float(np.mean(np.abs(np.asarray(y, float) - np.asarray(p, float))))\n", + "# ---------------------------------------------------------------------------\n", + "# ---------------------------------------------------------------------------\n", + "# 지체 예측용 파생 피처 생성(당일 오전 시점에 알 수 있는 정보만 사용: 계획 물량, 공지 정비, 기상 예보, 전일·전주 실적)\n", + "# ---------------------------------------------------------------------------\n", + "FEATS = [\"plan_v\", \"cap\", \"cap_eff\", \"t0\", \"km\", \"maint\", \"rain\", \"util\", \"util_eff\", \"util_eff4\", \"bpr_base\", \"lag1_delay\", \"lag7_delay\",\n", + " \"roll7_v\", \"surge_ratio\", \"is_weekend\", \"dow_sin\", \"dow_cos\", \"k_교량\", \"k_터널\", \"k_교차로\"]\n", + "\n", + "\n", + "def add_features(df):\n", + " d = df.sort_values([\"seg\", \"day\"]).copy()\n", + " g = d.groupby(\"seg\")\n", + " d[\"lag1_delay\"] = g[\"delay\"].shift(1)\n", + " d[\"lag7_delay\"] = g[\"delay\"].shift(7)\n", + " d[\"roll7_v\"] = g[\"v\"].transform(lambda s: s.shift(1).rolling(7).mean())\n", + " d[\"util\"] = d[\"plan_v\"] / d[\"cap\"]\n", + " d[\"util_eff\"] = d[\"plan_v\"] / d[\"cap_eff\"]\n", + " d[\"util_eff4\"] = d[\"util_eff\"] ** 4\n", + " d[\"bpr_base\"] = bpr(d[\"t0\"], d[\"plan_v\"], d[\"cap\"])\n", + " d[\"surge_ratio\"] = d[\"plan_v\"] / d[\"roll7_v\"]\n", + " d[\"is_weekend\"] = (d[\"dow\"] >= 5).astype(int)\n", + " d[\"dow_sin\"], d[\"dow_cos\"] = np.sin(2 * np.pi * d[\"dow\"] / 7), np.cos(2 * np.pi * d[\"dow\"] / 7)\n", + " for k in (\"교량\", \"터널\", \"교차로\"):\n", + " d[\"k_\" + k] = (d[\"kind\"] == k).astype(int)\n", + " return d.dropna().sort_values([\"day\", \"seg\"]).reset_index(drop=True)\n", + "# ---------------------------------------------------------------------------\n", + "# ---------------------------------------------------------------------------\n", + "# 노선망 흐름 도구: 최대유량(Edmonds-Karp), 증분 배정(혼잡 반영 최단시간 경로), 경로 열거\n", + "# ---------------------------------------------------------------------------\n", + "CAP = dict(zip(SEGS[\"seg\"], SEGS[\"cap\"]))\n", + "T0 = dict(zip(SEGS[\"seg\"], SEGS[\"t0\"]))\n", + "KM = dict(zip(SEGS[\"seg\"], SEGS[\"km\"]))\n", + "ARCS = {r.seg: (r.u, r.v) for r in SEGS.itertuples()}\n", + "SRC, DST = \"SRC\", \"DST\"\n", + "\n", + "\n", + "def seg_time(s, flow, cap=None, a=0.2, b=3.5):\n", + " \"\"\"구간 통과시간(분) = 자유주행 + 혼잡 지체. cap을 주면 용량을 대체(정비·기상 반영).\"\"\"\n", + " r = flow / (cap or CAP[s])\n", + " return T0[s] * (1 + a * r ** b + 6.0 * max(0.0, r - 1.0)) # 용량 초과분은 대기열로 급증\n", + "\n", + "\n", + "def max_flow(closed=(), capmul=None):\n", + " \"\"\"최대유량(대/시)과 최소 절단 구간(병목). capmul = {구간: 용량 배율}.\"\"\"\n", + " cap = {s: (0 if s in closed else CAP[s] * (capmul or {}).get(s, 1.0)) for s in CAP}\n", + " res = {}\n", + " for s, (u, v) in ARCS.items():\n", + " res[(u, v)] = res.get((u, v), 0) + cap[s]\n", + " res.setdefault((v, u), 0)\n", + " flow = 0.0\n", + " while True:\n", + " prev, q = {SRC: None}, [SRC]\n", + " for u in q:\n", + " for (a, b), c in res.items():\n", + " if a == u and c > 1e-9 and b not in prev:\n", + " prev[b] = u; q.append(b)\n", + " if DST not in prev:\n", + " break\n", + " path, x = [], DST\n", + " while prev[x]:\n", + " path.append((prev[x], x)); x = prev[x]\n", + " f = min(res[e] for e in path)\n", + " for a, b in path:\n", + " res[(a, b)] -= f; res[(b, a)] += f\n", + " flow += f\n", + " reach = set(prev)\n", + " cut = [s for s, (u, v) in ARCS.items() if u in reach and v not in reach and cap[s] > 0]\n", + " return flow, cut\n", + "\n", + "\n", + "def all_paths():\n", + " \"\"\"SRC -> DST 단순 경로(구간 이름 튜플) 전체.\"\"\"\n", + " out = []\n", + "\n", + " def dfs(node, segs, seen):\n", + " if node == DST:\n", + " out.append(tuple(segs)); return\n", + " for s, (u, v) in ARCS.items():\n", + " if u == node and v not in seen:\n", + " dfs(v, segs + [s], seen | {v})\n", + " dfs(SRC, [], {SRC})\n", + " return out\n", + "\n", + "\n", + "PATHS = all_paths()\n", + "\n", + "\n", + "def assign(demand, closed=(), capmul=None, steps=40):\n", + " \"\"\"증분 배정: 수요를 steps조각으로 나눠, 전체 통과시간(대수 x 시간) 증가가 가장 작은 경로에 차례로 얹는다(시스템 최적 근사).\"\"\"\n", + " cap = {s: CAP[s] * (capmul or {}).get(s, 1.0) for s in CAP}\n", + " flow, pf = {s: 0.0 for s in CAP}, {}\n", + " ok = [p for p in PATHS if not any(s in closed for s in p)]\n", + " if not ok:\n", + " return None\n", + " for _ in range(steps):\n", + " chunk = demand / steps\n", + " best = min(ok, key=lambda p: sum((flow[s] + chunk) * seg_time(s, flow[s] + chunk, cap[s]) - flow[s] * seg_time(s, flow[s], cap[s]) for s in p))\n", + " for s in best:\n", + " flow[s] += chunk\n", + " pf[best] = pf.get(best, 0) + chunk\n", + " tt = {p: sum(seg_time(s, flow[s], cap[s]) for s in p) for p in pf}\n", + " avg = sum(pf[p] * tt[p] for p in pf) / demand\n", + " return {\"flow\": flow, \"paths\": pf, \"path_min\": tt, \"avg_min\": avg, \"util\": {s: flow[s] / cap[s] for s in cap if cap[s] > 0}}\n", + "\n", + "\n", + "FIXED_SHARE = {(\"A1\", \"A2\", \"A3\"): 0.32, (\"B1\", \"B3\"): 0.40, (\"C1\", \"C2\"): 0.28} # 과거 관행: 축선별 고정 배분(가상)\n", + "\n", + "\n", + "def fixed_assign(demand, closed=(), capmul=None):\n", + " \"\"\"과거 관행 배분: 축선별 고정 비율. 막힌 축선 물량은 열려 있는 축선에 비율대로 재분배.\"\"\"\n", + " cap = {s: CAP[s] * (capmul or {}).get(s, 1.0) for s in CAP}\n", + " live = {p: w for p, w in FIXED_SHARE.items() if not any(s in closed for s in p)}\n", + " if not live:\n", + " return None\n", + " tot = sum(live.values())\n", + " flow = {s: 0.0 for s in CAP}\n", + " pf = {p: demand * w / tot for p, w in live.items()}\n", + " for p, f in pf.items():\n", + " for s in p:\n", + " flow[s] += f\n", + " tt = {p: sum(seg_time(s, flow[s], cap[s]) for s in p) for p in pf}\n", + " return {\"flow\": flow, \"paths\": pf, \"path_min\": tt, \"avg_min\": sum(pf[p] * tt[p] for p in pf) / demand,\n", + " \"util\": {s: flow[s] / cap[s] for s in cap if cap[s] > 0}}\n", + "\n", + "\n", + "def lp_assign(demand, closed=(), capmul=None):\n", + " \"\"\"선형계획(LP): 구간 용량 상한(이용률 rho) 아래에서 자유주행시간 합을 최소화하는 경로별 물량. 불가능하면 rho를 1.0까지 완화.\"\"\"\n", + " cap = {s: CAP[s] * (capmul or {}).get(s, 1.0) for s in CAP}\n", + " ok = [p for p in PATHS if not any(s in closed for s in p)]\n", + " if not ok:\n", + " return None\n", + " for rho in (0.85, 0.95, 1.0):\n", + " r = linprog([sum(T0[s] for s in p) for p in ok], A_ub=[[1.0 if s in p else 0.0 for p in ok] for s in CAP],\n", + " b_ub=[rho * cap[s] for s in CAP], A_eq=[[1.0] * len(ok)], b_eq=[demand], bounds=(0, None), method=\"highs\")\n", + " if r.status == 0:\n", + " break\n", + " else:\n", + " return assign(demand, closed, capmul) # 용량 초과 수요: 증분 배정으로 대체(과부하 표시)\n", + " flow = {s: sum(r.x[i] for i, p in enumerate(ok) if s in p) for s in CAP}\n", + " pf = {p: float(r.x[i]) for i, p in enumerate(ok) if r.x[i] > 1e-6}\n", + " tt = {p: sum(seg_time(s, flow[s], cap[s]) for s in p) for p in pf}\n", + " return {\"flow\": flow, \"paths\": pf, \"path_min\": tt, \"avg_min\": sum(pf[p] * tt[p] for p in pf) / demand,\n", + " \"util\": {s: flow[s] / cap[s] for s in cap if cap[s] > 0}, \"rho\": rho}\n", + "\n", + "\n", + "def cost_of(res, tv=1000, fuel_km=450):\n", + " \"\"\"시간당 비용(원, 교육용 가정): 대당 시간비용 tv원/분 + 유류비 fuel_km원/km.\"\"\"\n", + " return sum(f * (res[\"path_min\"][p] * tv + sum(KM[s] for s in p) * fuel_km) for p, f in res[\"paths\"].items())\n", + "# ---------------------------------------------------------------------------\n", + "\n", + "# ---------------- 1) 우회 엔진: 추가 소요 시간·유류 (가정값: 대당 주행 L/km, 정체 공회전 L/분) ----------------\n", + "L_KM, L_IDLE = 0.30, 0.08 # 교육용 가정 유류 계수(실제 장비 제원 아님)\n", + "\n", + "\n", + "def trip(res, fleet_vehicles):\n", + " \"\"\"경로별 물량 비율로 평균 통과시간·거리·유류(대당 L)를 구하고 총량으로 환산한다.\"\"\"\n", + " tot = sum(res[\"paths\"].values())\n", + " km = sum(f * sum(KM[s] for s in p) for p, f in res[\"paths\"].items()) / tot\n", + " mins = res[\"avg_min\"]\n", + " free = sum(f * sum(T0[s] for s in p) for p, f in res[\"paths\"].items()) / tot\n", + " fuel = km * L_KM + max(mins - free, 0) * L_IDLE\n", + " return {\"min\": mins, \"km\": km, \"fuel_l\": fuel, \"fleet_fuel_l\": fuel * fleet_vehicles}\n", + "\n", + "\n", + "DEMAND, FLEET = 200, 600 # 피크 200대/시, 일 수송 600대(가정)\n", + "norm = trip(assign(DEMAND), FLEET)\n", + "rows = []\n", + "for name, closed, cm in [(\"A2 교량 폐쇄\", (\"A2\",), None), (\"B2 터널 폐쇄\", (\"B2\",), None), (\"A1 용량 50% 감소(정비)\", (), {\"A1\": 0.5}), (\"A2 폐쇄 + B1 정비(65%)\", (\"A2\",), {\"B1\": 0.65})]:\n", + " r = assign(DEMAND, closed, cm)\n", + " t = trip(r, FLEET)\n", + " rows.append((name, t[\"min\"] - norm[\"min\"], t[\"km\"] - norm[\"km\"], t[\"fuel_l\"] - norm[\"fuel_l\"], t[\"fleet_fuel_l\"] - norm[\"fleet_fuel_l\"], max(r[\"util\"].values())))\n", + "det = pd.DataFrame(rows, columns=[\"시나리오\", \"추가 소요(분/대)\", \"추가 거리(km/대)\", \"추가 유류(L/대)\", \"일 추가 유류(L)\", \"최대 이용률\"]).round(2)\n", + "print(f\"[1] 우회 엔진 결과(평시 기준 {norm['min']:.1f}분, {norm['km']:.1f}km, {norm['fuel_l']:.2f}L/대)\")\n", + "print(det.to_string(index=False))\n", + "print(\" 유류 계수는 교육용 가정값이다. 이용률 1.0 초과 시나리오는 우회만으로 수용이 어려워 사람의 물량 조정 판단이 필요하다.\")\n", + "\n", + "# ---------------- 2) 고급 모델: 기준 모델(BPR) vs 앙상블(GBR+RF), Hold-out ----------------\n", + "df = make_data(); d = add_features(df)\n", + "tr, te = d[d.day < 120], d[d.day >= 120]\n", + "gbr = GradientBoostingRegressor(n_estimators=250, max_depth=3, learning_rate=0.06, subsample=0.8, random_state=0).fit(tr[FEATS], tr[\"delay\"])\n", + "rf = RandomForestRegressor(n_estimators=150, min_samples_leaf=3, random_state=0, n_jobs=1).fit(tr[FEATS], tr[\"delay\"])\n", + "fac = te[\"t0\"] * (1 + 0.08 * te[\"rain\"])\n", + "P = {\"기준(BPR)\": te[\"t0\"] + te[\"bpr_base\"], \"GBR\": fac + gbr.predict(te[FEATS]), \"RandomForest\": fac + rf.predict(te[FEATS])}\n", + "P[\"앙상블(평균)\"] = (P[\"GBR\"] + P[\"RandomForest\"]) / 2\n", + "tab = pd.DataFrame({k: {\"MAE(분)\": mae(te.transit, v), \"RMSE(분)\": float(np.sqrt(np.mean((te.transit - v) ** 2))), \"MAPE(%)\": mape(te.transit, v), \"R2\": r2_score(te.transit, v)} for k, v in P.items()}).T.round(3)\n", + "print(f\"\\n[2] Hold-out(day 120~149, {len(te)}행) 통과시간 예측 성능\")\n", + "print(tab.to_string())\n", + "b, e = tab.loc[\"기준(BPR)\"], tab.loc[\"앙상블(평균)\"]\n", + "print(f\" MAE 개선 {1 - e['MAE(분)'] / b['MAE(분)']:.1%}, RMSE 개선 {1 - e['RMSE(분)'] / b['RMSE(분)']:.1%} (목표 20% 이상: {'달성' if 1 - e['MAE(분)'] / b['MAE(분)'] >= .2 and 1 - e['RMSE(분)'] / b['RMSE(분)'] >= .2 else '미달'}), R2 {e['R2']:.3f} (목표 0.85 이상: {'달성' if e['R2'] >= .85 else '미달'})\")\n", + "dl = te[\"delay\"]; pdl = P[\"앙상블(평균)\"] - fac\n", + "print(f\" 참고: 지체 시간만 놓고 보면 R2 {r2_score(dl, pdl):.3f}, MAE {mae(dl, pdl):.2f}분 (기준 BPR MAE {mae(dl, te['bpr_base']):.2f}분). 통과시간 R2는 구간 간 길이 차이를 포함한다.\")\n", + "\n", + "# ---------------- 3) 불규칙 상황(비정형 수요) 적응력: 정비일·폭증 기간 오차 ----------------\n", + "stress = {\"비계획 정비일\": te[\"maint\"] == 1, \"긴급 불출일\": te[\"urgent\"] == 1, \"우천일\": te[\"rain\"] == 1, \"그 외\": (te[\"maint\"] == 0) & (te[\"urgent\"] == 0) & (te[\"rain\"] == 0)}\n", + "rr = [(k, int(m.sum()), mae(te.transit[m], P[\"기준(BPR)\"][m]), mae(te.transit[m], P[\"앙상블(평균)\"][m])) for k, m in stress.items() if m.sum() > 0]\n", + "print(\"\\n[3] 조건별 MAE(분): 기준 vs 앙상블\")\n", + "print(pd.DataFrame(rr, columns=[\"조건\", \"건수\", \"기준\", \"앙상블\"]).round(2).to_string(index=False))\n", + "print(\"하이퍼파라미터(트리 250개, 깊이 3, 학습률 0.06)는 시계열 교차검증(M4) 결과를 바탕으로 정했고, 학습된 모델은 이 실행기에서는 저장하지 않고 설정만 출력한다.\")\n", + "print(\"설정:\", {\"gbr\": gbr.get_params()[\"n_estimators\"], \"rf\": rf.get_params()[\"n_estimators\"], \"features\": len(FEATS)})\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 05 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + "[1] 우회 엔진 결과(평시 기준 40.9분, 31.2km, 9.63L/대)\n", + " 시나리오 추가 소요(분/대) 추가 거리(km/대) 추가 유류(L/대) 일 추가 유류(L) 최대 이용률\n", + " A2 교량 폐쇄 3.96 2.38 0.81 485.68 1.0\n", + " B2 터널 폐쇄 0.05 -0.12 -0.02 -10.48 1.0\n", + " A1 용량 50% 감소(정비) 0.98 0.10 0.10 60.12 1.0\n", + "A2 폐쇄 + B1 정비(65%) 12.90 3.98 1.85 1113.00 1.3\n", + " 유류 계수는 교육용 가정값이다. 이용률 1.0 초과 시나리오는 우회만으로 수용이 어려워 사람의 물량 조정 판단이 필요하다.\n", + "\n", + "[2] Hold-out(day 120~149, 300행) 통과시간 예측 성능\n", + " MAE(분) RMSE(분) MAPE(%) R2\n", + "기준(BPR) 1.763 2.662 9.245 0.829\n", + "GBR 0.494 0.877 2.784 0.981\n", + "RandomForest 0.495 0.877 2.813 0.981\n", + "앙상블(평균) 0.475 0.847 2.725 0.983\n", + " MAE 개선 73.1%, RMSE 개선 68.2% (목표 20% 이상: 달성), R2 0.983 (목표 0.85 이상: 달성)\n", + " 참고: 지체 시간만 놓고 보면 R2 0.846, MAE 0.47분 (기준 BPR MAE 1.52분). 통과시간 R2는 구간 간 길이 차이를 포함한다.\n", + "\n", + "[3] 조건별 MAE(분): 기준 vs 앙상블\n", + " 조건 건수 기준 앙상블\n", + "비계획 정비일 23 5.72 0.81\n", + " 긴급 불출일 16 3.98 2.17\n", + " 우천일 60 3.32 0.61\n", + " 그 외 211 1.06 0.38\n", + "하이퍼파라미터(트리 250개, 깊이 3, 학습률 0.06)는 시계열 교차검증(M4) 결과를 바탕으로 정했고, 학습된 모델은 이 실행기에서는 저장하지 않고 설정만 출력한다.\n", + "설정: {'gbr': 250, 'rf': 150, 'features': 21}\n", + "```\n", + "
\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 06. 전시 보급로 병목 혼잡도 및 우회 경로 비교 시뮬레이션 대시보드 구현\n", + "**소요시간**: 약 3.0시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "보급로 병목 혼잡도 및 우회 경로 비교 대시보드를 구현한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **보급로 병목 혼잡도와 우회 경로 비교 대시보드를 구현하고, 예측 건마다 기여도 상위 요인을 설명한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **구간별 이용률 히트맵, 오늘의 예측 지체와 경보 기준, 고정 배분 대비 우회 배정 비교, 최악 사례 요인 기여도를 2x2 화면으로 그린다 과정을 진행하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `src/explainability/mov01_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개 요인을 자동 산출(요인군 제거 방식 설명)해 자연어 요약으로 보여 준다.\"\"\"\n", + "import numpy as np\n", + "import pandas as pd\n", + "import matplotlib.pyplot as plt\n", + "from scipy.optimize import linprog\n", + "from sklearn.ensemble import GradientBoostingRegressor\n", + "\n", + "# ---------------------------------------------------------------------------\n", + "# [교육용가상자료] 가상 노선망·구간 통과량 생성기 (실제 도로·지명·부대 없음)\n", + "# 가상 출발지(SRC)에서 가상 목적지(DST)까지 3개 축선(A·B·C)과 연결로 2개, 구간 10개. 150일치 피크시간 통과량·지체 시간을 만든다.\n", + "# ---------------------------------------------------------------------------\n", + "SEGS = pd.DataFrame([\n", + " (\"A1\", \"SRC\", \"J1\", 12, 130, \"일반\"), (\"A2\", \"J1\", \"J2\", 8, 70, \"교량\"), (\"A3\", \"J2\", \"DST\", 10, 140, \"교차로\"),\n", + " (\"B1\", \"SRC\", \"J3\", 15, 100, \"일반\"), (\"B2\", \"J3\", \"J2\", 9, 80, \"터널\"), (\"B3\", \"J3\", \"DST\", 14, 95, \"일반\"),\n", + " (\"C1\", \"SRC\", \"J4\", 18, 70, \"일반\"), (\"C2\", \"J4\", \"DST\", 20, 75, \"교량\"),\n", + " (\"X1\", \"J1\", \"J3\", 7, 50, \"교차로\"), (\"X2\", \"J4\", \"J3\", 10, 60, \"일반\")],\n", + " columns=[\"seg\", \"u\", \"v\", \"km\", \"cap\", \"kind\"]) # cap = 피크시간 통과 용량(대/시, 교육용 가정값)\n", + "KSPD = {\"일반\": 55, \"교량\": 40, \"터널\": 45, \"교차로\": 45} # 구간 유형별 자유 주행 속도(km/h, 가정값)\n", + "SEGS[\"t0\"] = SEGS[\"km\"] / SEGS[\"kind\"].map(KSPD) * 60 # 자유 주행 시간(분)\n", + "UTIL = dict(A1=.78, A2=.85, A3=.66, B1=.70, B2=.72, B3=.64, C1=.55, C2=.58, X1=.45, X2=.40) # 평시 이용률\n", + "\n", + "\n", + "def bpr(t0, v, cap, a=0.15, b=4.0):\n", + " \"\"\"BPR 지체 함수(교통공학의 표준형): 통과량/용량 비가 커질수록 지체가 급증한다. (분)\"\"\"\n", + " return t0 * a * (v / cap) ** b\n", + "\n", + "\n", + "def make_data(seed=11, days=150):\n", + " \"\"\"구간 x 일자 패널. v=실제 피크 통과량, plan_v=수송계획 수량(긴급 불출 미반영), delay=실제 지체(분).\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " d_idx = np.arange(days)\n", + " lvl = np.array([1.0, 1.08, 1.04, 1.10, 1.02, 0.82, 0.78])[d_idx % 7] * (1 + 0.0012 * d_idx)\n", + " lvl = lvl * np.where((d_idx >= 100) & (d_idx < 110), 1.30, 1.0) # 수송량 폭증 기간(가상)\n", + " prev, rows = {s: 0.0 for s in SEGS[\"seg\"]}, []\n", + " for d in d_idx:\n", + " rain = bool(rng.random() < 0.18)\n", + " for r in SEGS.itertuples():\n", + " maint, urgent = bool(rng.random() < 0.06), bool(rng.random() < 0.05) # 비계획 정비 / 긴급 불출\n", + " v = r.cap * UTIL[r.seg] * lvl[d] * (1.25 if urgent else 1.0) * rng.normal(1, 0.05)\n", + " cap_eff = r.cap * (0.65 if maint else 1.0) * (0.92 if rain else 1.0)\n", + " delay = max(r.t0 * 0.2 * (v / cap_eff) ** 3.5 + 0.25 * prev[r.seg] + rng.normal(0, 0.4), 0.0)\n", + " prev[r.seg] = delay\n", + " rows.append(dict(day=int(d), dow=int(d % 7), seg=r.seg, kind=r.kind, km=r.km, cap=r.cap, t0=r.t0,\n", + " plan_v=v / (1.25 if urgent else 1.0) * rng.normal(1, 0.04), v=v, maint=int(maint), urgent=int(urgent),\n", + " rain=int(rain), cap_eff=cap_eff, delay=delay))\n", + " df = pd.DataFrame(rows)\n", + " df[\"transit\"] = df[\"t0\"] * (1 + 0.08 * df[\"rain\"]) + df[\"delay\"]\n", + " return df\n", + "\n", + "\n", + "def mape(y, p):\n", + " y, p = np.asarray(y, float), np.asarray(p, float)\n", + " return float(np.mean(np.abs(y - p) / np.abs(y)) * 100)\n", + "\n", + "\n", + "def mae(y, p):\n", + " return float(np.mean(np.abs(np.asarray(y, float) - np.asarray(p, float))))\n", + "# ---------------------------------------------------------------------------\n", + "# ---------------------------------------------------------------------------\n", + "# 지체 예측용 파생 피처 생성(당일 오전 시점에 알 수 있는 정보만 사용: 계획 물량, 공지 정비, 기상 예보, 전일·전주 실적)\n", + "# ---------------------------------------------------------------------------\n", + "FEATS = [\"plan_v\", \"cap\", \"cap_eff\", \"t0\", \"km\", \"maint\", \"rain\", \"util\", \"util_eff\", \"util_eff4\", \"bpr_base\", \"lag1_delay\", \"lag7_delay\",\n", + " \"roll7_v\", \"surge_ratio\", \"is_weekend\", \"dow_sin\", \"dow_cos\", \"k_교량\", \"k_터널\", \"k_교차로\"]\n", + "\n", + "\n", + "def add_features(df):\n", + " d = df.sort_values([\"seg\", \"day\"]).copy()\n", + " g = d.groupby(\"seg\")\n", + " d[\"lag1_delay\"] = g[\"delay\"].shift(1)\n", + " d[\"lag7_delay\"] = g[\"delay\"].shift(7)\n", + " d[\"roll7_v\"] = g[\"v\"].transform(lambda s: s.shift(1).rolling(7).mean())\n", + " d[\"util\"] = d[\"plan_v\"] / d[\"cap\"]\n", + " d[\"util_eff\"] = d[\"plan_v\"] / d[\"cap_eff\"]\n", + " d[\"util_eff4\"] = d[\"util_eff\"] ** 4\n", + " d[\"bpr_base\"] = bpr(d[\"t0\"], d[\"plan_v\"], d[\"cap\"])\n", + " d[\"surge_ratio\"] = d[\"plan_v\"] / d[\"roll7_v\"]\n", + " d[\"is_weekend\"] = (d[\"dow\"] >= 5).astype(int)\n", + " d[\"dow_sin\"], d[\"dow_cos\"] = np.sin(2 * np.pi * d[\"dow\"] / 7), np.cos(2 * np.pi * d[\"dow\"] / 7)\n", + " for k in (\"교량\", \"터널\", \"교차로\"):\n", + " d[\"k_\" + k] = (d[\"kind\"] == k).astype(int)\n", + " return d.dropna().sort_values([\"day\", \"seg\"]).reset_index(drop=True)\n", + "# ---------------------------------------------------------------------------\n", + "# ---------------------------------------------------------------------------\n", + "# 노선망 흐름 도구: 최대유량(Edmonds-Karp), 증분 배정(혼잡 반영 최단시간 경로), 경로 열거\n", + "# ---------------------------------------------------------------------------\n", + "CAP = dict(zip(SEGS[\"seg\"], SEGS[\"cap\"]))\n", + "T0 = dict(zip(SEGS[\"seg\"], SEGS[\"t0\"]))\n", + "KM = dict(zip(SEGS[\"seg\"], SEGS[\"km\"]))\n", + "ARCS = {r.seg: (r.u, r.v) for r in SEGS.itertuples()}\n", + "SRC, DST = \"SRC\", \"DST\"\n", + "\n", + "\n", + "def seg_time(s, flow, cap=None, a=0.2, b=3.5):\n", + " \"\"\"구간 통과시간(분) = 자유주행 + 혼잡 지체. cap을 주면 용량을 대체(정비·기상 반영).\"\"\"\n", + " r = flow / (cap or CAP[s])\n", + " return T0[s] * (1 + a * r ** b + 6.0 * max(0.0, r - 1.0)) # 용량 초과분은 대기열로 급증\n", + "\n", + "\n", + "def max_flow(closed=(), capmul=None):\n", + " \"\"\"최대유량(대/시)과 최소 절단 구간(병목). capmul = {구간: 용량 배율}.\"\"\"\n", + " cap = {s: (0 if s in closed else CAP[s] * (capmul or {}).get(s, 1.0)) for s in CAP}\n", + " res = {}\n", + " for s, (u, v) in ARCS.items():\n", + " res[(u, v)] = res.get((u, v), 0) + cap[s]\n", + " res.setdefault((v, u), 0)\n", + " flow = 0.0\n", + " while True:\n", + " prev, q = {SRC: None}, [SRC]\n", + " for u in q:\n", + " for (a, b), c in res.items():\n", + " if a == u and c > 1e-9 and b not in prev:\n", + " prev[b] = u; q.append(b)\n", + " if DST not in prev:\n", + " break\n", + " path, x = [], DST\n", + " while prev[x]:\n", + " path.append((prev[x], x)); x = prev[x]\n", + " f = min(res[e] for e in path)\n", + " for a, b in path:\n", + " res[(a, b)] -= f; res[(b, a)] += f\n", + " flow += f\n", + " reach = set(prev)\n", + " cut = [s for s, (u, v) in ARCS.items() if u in reach and v not in reach and cap[s] > 0]\n", + " return flow, cut\n", + "\n", + "\n", + "def all_paths():\n", + " \"\"\"SRC -> DST 단순 경로(구간 이름 튜플) 전체.\"\"\"\n", + " out = []\n", + "\n", + " def dfs(node, segs, seen):\n", + " if node == DST:\n", + " out.append(tuple(segs)); return\n", + " for s, (u, v) in ARCS.items():\n", + " if u == node and v not in seen:\n", + " dfs(v, segs + [s], seen | {v})\n", + " dfs(SRC, [], {SRC})\n", + " return out\n", + "\n", + "\n", + "PATHS = all_paths()\n", + "\n", + "\n", + "def assign(demand, closed=(), capmul=None, steps=40):\n", + " \"\"\"증분 배정: 수요를 steps조각으로 나눠, 전체 통과시간(대수 x 시간) 증가가 가장 작은 경로에 차례로 얹는다(시스템 최적 근사).\"\"\"\n", + " cap = {s: CAP[s] * (capmul or {}).get(s, 1.0) for s in CAP}\n", + " flow, pf = {s: 0.0 for s in CAP}, {}\n", + " ok = [p for p in PATHS if not any(s in closed for s in p)]\n", + " if not ok:\n", + " return None\n", + " for _ in range(steps):\n", + " chunk = demand / steps\n", + " best = min(ok, key=lambda p: sum((flow[s] + chunk) * seg_time(s, flow[s] + chunk, cap[s]) - flow[s] * seg_time(s, flow[s], cap[s]) for s in p))\n", + " for s in best:\n", + " flow[s] += chunk\n", + " pf[best] = pf.get(best, 0) + chunk\n", + " tt = {p: sum(seg_time(s, flow[s], cap[s]) for s in p) for p in pf}\n", + " avg = sum(pf[p] * tt[p] for p in pf) / demand\n", + " return {\"flow\": flow, \"paths\": pf, \"path_min\": tt, \"avg_min\": avg, \"util\": {s: flow[s] / cap[s] for s in cap if cap[s] > 0}}\n", + "\n", + "\n", + "FIXED_SHARE = {(\"A1\", \"A2\", \"A3\"): 0.32, (\"B1\", \"B3\"): 0.40, (\"C1\", \"C2\"): 0.28} # 과거 관행: 축선별 고정 배분(가상)\n", + "\n", + "\n", + "def fixed_assign(demand, closed=(), capmul=None):\n", + " \"\"\"과거 관행 배분: 축선별 고정 비율. 막힌 축선 물량은 열려 있는 축선에 비율대로 재분배.\"\"\"\n", + " cap = {s: CAP[s] * (capmul or {}).get(s, 1.0) for s in CAP}\n", + " live = {p: w for p, w in FIXED_SHARE.items() if not any(s in closed for s in p)}\n", + " if not live:\n", + " return None\n", + " tot = sum(live.values())\n", + " flow = {s: 0.0 for s in CAP}\n", + " pf = {p: demand * w / tot for p, w in live.items()}\n", + " for p, f in pf.items():\n", + " for s in p:\n", + " flow[s] += f\n", + " tt = {p: sum(seg_time(s, flow[s], cap[s]) for s in p) for p in pf}\n", + " return {\"flow\": flow, \"paths\": pf, \"path_min\": tt, \"avg_min\": sum(pf[p] * tt[p] for p in pf) / demand,\n", + " \"util\": {s: flow[s] / cap[s] for s in cap if cap[s] > 0}}\n", + "\n", + "\n", + "def lp_assign(demand, closed=(), capmul=None):\n", + " \"\"\"선형계획(LP): 구간 용량 상한(이용률 rho) 아래에서 자유주행시간 합을 최소화하는 경로별 물량. 불가능하면 rho를 1.0까지 완화.\"\"\"\n", + " cap = {s: CAP[s] * (capmul or {}).get(s, 1.0) for s in CAP}\n", + " ok = [p for p in PATHS if not any(s in closed for s in p)]\n", + " if not ok:\n", + " return None\n", + " for rho in (0.85, 0.95, 1.0):\n", + " r = linprog([sum(T0[s] for s in p) for p in ok], A_ub=[[1.0 if s in p else 0.0 for p in ok] for s in CAP],\n", + " b_ub=[rho * cap[s] for s in CAP], A_eq=[[1.0] * len(ok)], b_eq=[demand], bounds=(0, None), method=\"highs\")\n", + " if r.status == 0:\n", + " break\n", + " else:\n", + " return assign(demand, closed, capmul) # 용량 초과 수요: 증분 배정으로 대체(과부하 표시)\n", + " flow = {s: sum(r.x[i] for i, p in enumerate(ok) if s in p) for s in CAP}\n", + " pf = {p: float(r.x[i]) for i, p in enumerate(ok) if r.x[i] > 1e-6}\n", + " tt = {p: sum(seg_time(s, flow[s], cap[s]) for s in p) for p in pf}\n", + " return {\"flow\": flow, \"paths\": pf, \"path_min\": tt, \"avg_min\": sum(pf[p] * tt[p] for p in pf) / demand,\n", + " \"util\": {s: flow[s] / cap[s] for s in cap if cap[s] > 0}, \"rho\": rho}\n", + "\n", + "\n", + "def cost_of(res, tv=1000, fuel_km=450):\n", + " \"\"\"시간당 비용(원, 교육용 가정): 대당 시간비용 tv원/분 + 유류비 fuel_km원/km.\"\"\"\n", + " return sum(f * (res[\"path_min\"][p] * tv + sum(KM[s] for s in p) * fuel_km) for p, f in res[\"paths\"].items())\n", + "# ---------------------------------------------------------------------------\n", + "\n", + "df = make_data(); d = add_features(df)\n", + "tr, te = d[d.day < 120], d[d.day >= 120].copy()\n", + "gbr = GradientBoostingRegressor(n_estimators=200, max_depth=3, learning_rate=0.08, random_state=0).fit(tr[FEATS], tr[\"delay\"])\n", + "te[\"pred\"] = gbr.predict(te[FEATS])\n", + "\n", + "# ---------------- 1) 설명 가능성: 요인군을 평균값으로 바꿨을 때 예측이 얼마나 변하는가 ----------------\n", + "GROUPS = {\"계획 물량·이용률\": [\"plan_v\", \"util\", \"util_eff\", \"util_eff4\", \"bpr_base\", \"surge_ratio\", \"roll7_v\"], \"정비·유효 용량\": [\"maint\", \"cap_eff\"],\n", + " \"우천\": [\"rain\"], \"전일·전주 지체\": [\"lag1_delay\", \"lag7_delay\"], \"요일 패턴\": [\"is_weekend\", \"dow_sin\", \"dow_cos\"],\n", + " \"구간 특성\": [\"cap\", \"t0\", \"km\", \"k_교량\", \"k_터널\", \"k_교차로\"]}\n", + "mean_row = tr[FEATS].mean()\n", + "contrib = pd.DataFrame(index=te.index)\n", + "for g, cols in GROUPS.items():\n", + " x = te[FEATS].copy()\n", + " x[cols] = mean_row[cols].values\n", + " contrib[g] = te[\"pred\"] - gbr.predict(x) # 양수 = 이 요인이 지체를 늘림\n", + "top3 = contrib.abs().apply(lambda r: r.nlargest(3).index.tolist(), axis=1)\n", + "ok = int(sum(len(t) == 3 and len(set(t)) == 3 and np.isfinite(contrib.loc[i, t].values).all() for i, t in top3.items()))\n", + "weak = int((contrib.abs().apply(lambda r: r.nlargest(3).iloc[2], axis=1) < 0.01).sum())\n", + "print(f\"[1] 예측 {len(te)}건 중 기여도 상위 3개 요인 자동 산출 {ok}건 ({ok / len(te):.0%}, 목표 100%)\")\n", + "imp = contrib.abs().mean().sort_values(ascending=False)\n", + "print(f\" (3번째 요인 기여가 0.01분 미만인 미미한 건 {weak}건: 순위는 산출되나 설명력은 낮음)\")\n", + "print(\" 요인군별 평균 기여 크기(분):\", \", \".join(f\"{k} {v:.2f}\" for k, v in imp.items()))\n", + "\n", + "\n", + "def explain(i):\n", + " r, c = te.loc[i], contrib.loc[i]\n", + " parts = [f\"{g}({c[g]:+.1f}분)\" for g in top3[i]]\n", + " sev = \"정체\" if r[\"util_eff\"] >= 0.9 else \"혼잡\" if r[\"util_eff\"] >= 0.7 else \"원활\"\n", + " cause = {\"정비·유효 용량\": \"비계획 정비로 통과 용량이 줄어\" if r[\"maint\"] else \"유효 용량 변화로\", \"우천\": \"우천 영향으로\", \"계획 물량·이용률\": \"계획 물량이 용량에 근접해\",\n", + " \"전일·전주 지체\": \"전일 지체가 이어져\", \"요일 패턴\": \"요일 패턴 때문에\", \"구간 특성\": \"구간 특성상\"}[top3[i][0]]\n", + " return f\"{r['seg']} 구간 day {int(r['day'])}: {cause} 지체 {r['pred']:.1f}분 예상({sev}). 주요 요인 {', '.join(parts)}\"\n", + "\n", + "\n", + "print(\"\\n[2] 자연어 설명(예측 지체 상위 4건)\")\n", + "for i in te[\"pred\"].nlargest(4).index:\n", + " print(\" -\", explain(i))\n", + "\n", + "# ---------------- 2) 대시보드 데이터: 최종일 구간별 혼잡도·경보 ----------------\n", + "last = te[te.day == te.day.max()].set_index(\"seg\")\n", + "last[\"상태\"] = np.where(last.util_eff >= 0.9, \"정체\", np.where(last.util_eff >= 0.7, \"혼잡\", \"원활\"))\n", + "print(f\"\\n[3] 최종일 구간 상태판: \" + \", \".join(f\"{s} {r.상태}({r.util_eff:.2f})\" for s, r in last.iterrows()))\n", + "alert = last[last[\"pred\"] >= 5].index.tolist()\n", + "print(f\" 지체 5분 이상 예측 경보 구간: {alert or '없음'} -> 우회 검토 대상(실제 하달은 작전군수관 승인 후)\")\n", + "\n", + "# ---------------- 3) 우회 경로 비교: 과거 관행(축선 고정 배분) vs 증분 배정 ----------------\n", + "D = 200\n", + "cmp = []\n", + "for name, closed, cm in [(\"평시\", (), None), (\"A2 폐쇄\", (\"A2\",), None), (\"A1 정비(50%)\", (), {\"A1\": 0.5}), (\"B1 정비(65%)\", (), {\"B1\": 0.65})]:\n", + " a, f = assign(D, closed, cm), fixed_assign(D, closed, cm)\n", + " cmp.append((name, f[\"avg_min\"], a[\"avg_min\"], max(f[\"util\"].values()), max(a[\"util\"].values())))\n", + "cm_df = pd.DataFrame(cmp, columns=[\"시나리오\", \"고정 배분(분)\", \"우회 배정(분)\", \"고정 최대 이용률\", \"배정 최대 이용률\"]).round(2)\n", + "cm_df[\"단축률\"] = (1 - cm_df[\"우회 배정(분)\"] / cm_df[\"고정 배분(분)\"]).map(\"{:.0%}\".format)\n", + "print(f\"\\n[4] 우회 경로 비교(수요 {D}대/시)\")\n", + "print(cm_df.to_string(index=False))\n", + "\n", + "fig, ax = plt.subplots(2, 2, figsize=(13, 8))\n", + "piv = te.pivot(index=\"seg\", columns=\"day\", values=\"util_eff\").iloc[:, -14:]\n", + "im = ax[0, 0].imshow(piv.values, aspect=\"auto\", cmap=\"YlOrRd\", vmin=0.3, vmax=1.2); ax[0, 0].set_yticks(range(len(piv))); ax[0, 0].set_yticklabels(piv.index)\n", + "ax[0, 0].set_title(\"1 Congestion heatmap (effective utilization, last 14 days)\"); plt.colorbar(im, ax=ax[0, 0])\n", + "colors = [\"tab:red\" if s == \"정체\" else \"tab:orange\" if s == \"혼잡\" else \"tab:green\" for s in last[\"상태\"]]\n", + "ax[0, 1].bar(last.index, last[\"pred\"], color=colors); ax[0, 1].axhline(5, ls=\"--\", c=\"k\"); ax[0, 1].set_title(\"2 Predicted delay today (min), dashed = alert 5\")\n", + "x = np.arange(len(cm_df)); ax[1, 0].bar(x - .2, cm_df[\"고정 배분(분)\"], .4, label=\"fixed share\"); ax[1, 0].bar(x + .2, cm_df[\"우회 배정(분)\"], .4, label=\"detour assignment\")\n", + "ax[1, 0].set_xticks(x); ax[1, 0].set_xticklabels([\"normal\", \"A2 closed\", \"A1 maint\", \"B1 maint\"]); ax[1, 0].legend(); ax[1, 0].set_title(\"3 Detour comparison (avg transit min)\")\n", + "worst = te[\"pred\"].idxmax(); c = contrib.loc[worst]\n", + "ax[1, 1].barh([\"plan/util\", \"maint/cap\", \"rain\", \"lag delay\", \"weekday\", \"segment\"], c[list(GROUPS)].values); ax[1, 1].set_title(\"4 Contribution for worst case (min)\")\n", + "plt.suptitle(\"MOV01 bottleneck dashboard (synthetic)\"); plt.tight_layout(); plt.show()\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 06 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + "[1] 예측 300건 중 기여도 상위 3개 요인 자동 산출 300건 (100%, 목표 100%)\n", + " (3번째 요인 기여가 0.01분 미만인 미미한 건 7건: 순위는 산출되나 설명력은 낮음)\n", + " 요인군별 평균 기여 크기(분): 계획 물량·이용률 0.95, 전일·전주 지체 0.41, 구간 특성 0.15, 요일 패턴 0.06, 정비·유효 용량 0.04, 우천 0.02\n", + "\n", + "[2] 자연어 설명(예측 지체 상위 4건)\n", + " - C2 구간 day 134: 계획 물량이 용량에 근접해 지체 12.4분 예상(정체). 주요 요인 계획 물량·이용률(+9.1분), 구간 특성(+4.0분), 정비·유효 용량(+2.0분)\n", + " - B1 구간 day 120: 계획 물량이 용량에 근접해 지체 10.9분 예상(정체). 주요 요인 계획 물량·이용률(+8.8분), 정비·유효 용량(+0.9분), 구간 특성(+0.4분)\n", + " - A1 구간 day 142: 계획 물량이 용량에 근접해 지체 10.5분 예상(정체). 주요 요인 계획 물량·이용률(+7.5분), 정비·유효 용량(+0.7분), 전일·전주 지체(-0.1분)\n", + " - A1 구간 day 137: 계획 물량이 용량에 근접해 지체 10.3분 예상(정체). 주요 요인 계획 물량·이용률(+7.7분), 정비·유효 용량(+0.7분), 우천(+0.2분)\n", + "\n", + "[3] 최종일 구간 상태판: A1 정체(0.97), A2 정체(0.97), A3 혼잡(0.82), B1 혼잡(0.89), B2 혼잡(0.83), B3 혼잡(0.81), C1 원활(0.60), C2 원활(0.69), X1 원활(0.61), X2 원활(0.46)\n", + " 지체 5분 이상 예측 경보 구간: 없음 -> 우회 검토 대상(실제 하달은 작전군수관 승인 후)\n", + "\n", + "[4] 우회 경로 비교(수요 200대/시)\n", + " 시나리오 고정 배분(분) 우회 배정(분) 고정 최대 이용률 배정 최대 이용률 단축률\n", + " 평시 41.91 40.91 0.91 1.0 2%\n", + " A2 폐쇄 91.40 44.87 1.24 1.0 51%\n", + "A1 정비(50%) 42.64 41.89 0.98 1.0 2%\n", + "B1 정비(65%) 53.08 42.14 1.23 1.0 21%\n", + "```\n", + "
\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 07. 우회로 수송 명령 하달을 위한 작전군수관 HITL 통제 워크플로우 설계\n", + "**소요시간**: 약 3.5시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "우회로 수송 명령 하달을 위한 작전군수관 HITL 워크플로우를 설계한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **우회 수송 명령의 하달 여부를 작전군수관이 직접 승인·반려하는 인간 개입(HITL) 결재 워크플로우를 설계하고, 선형계획으로 우회 물량 배분을 최적화한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **선형계획(LP)으로 구간 용량 상한 아래에서 경로별 물량을 배분하고 시나리오 6종과 과거 30일 조건에서 고정 배분 대비 비용 절감률을 백테스트하며 돌발 수요 민감도 확인하고 유효성을 점검하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `src/optimization/mov01_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": [ + "\"\"\"선형계획(LP)으로 우회 수송 물량 배분을 최적화해 과거 관행 대비 비용 절감을 백테스트하고, 우회 수송 명령을 작전군수관이 직접 승인·반려하는 HITL 결재 워크플로우(권한·2단계 결재·해시 체인 감사 로그)를 구현한다.\"\"\"\n", + "import hashlib\n", + "import json\n", + "import numpy as np\n", + "import pandas as pd\n", + "import matplotlib.pyplot as plt\n", + "from scipy.optimize import linprog\n", + "\n", + "# ---------------------------------------------------------------------------\n", + "# [교육용가상자료] 가상 노선망·구간 통과량 생성기 (실제 도로·지명·부대 없음)\n", + "# 가상 출발지(SRC)에서 가상 목적지(DST)까지 3개 축선(A·B·C)과 연결로 2개, 구간 10개. 150일치 피크시간 통과량·지체 시간을 만든다.\n", + "# ---------------------------------------------------------------------------\n", + "SEGS = pd.DataFrame([\n", + " (\"A1\", \"SRC\", \"J1\", 12, 130, \"일반\"), (\"A2\", \"J1\", \"J2\", 8, 70, \"교량\"), (\"A3\", \"J2\", \"DST\", 10, 140, \"교차로\"),\n", + " (\"B1\", \"SRC\", \"J3\", 15, 100, \"일반\"), (\"B2\", \"J3\", \"J2\", 9, 80, \"터널\"), (\"B3\", \"J3\", \"DST\", 14, 95, \"일반\"),\n", + " (\"C1\", \"SRC\", \"J4\", 18, 70, \"일반\"), (\"C2\", \"J4\", \"DST\", 20, 75, \"교량\"),\n", + " (\"X1\", \"J1\", \"J3\", 7, 50, \"교차로\"), (\"X2\", \"J4\", \"J3\", 10, 60, \"일반\")],\n", + " columns=[\"seg\", \"u\", \"v\", \"km\", \"cap\", \"kind\"]) # cap = 피크시간 통과 용량(대/시, 교육용 가정값)\n", + "KSPD = {\"일반\": 55, \"교량\": 40, \"터널\": 45, \"교차로\": 45} # 구간 유형별 자유 주행 속도(km/h, 가정값)\n", + "SEGS[\"t0\"] = SEGS[\"km\"] / SEGS[\"kind\"].map(KSPD) * 60 # 자유 주행 시간(분)\n", + "UTIL = dict(A1=.78, A2=.85, A3=.66, B1=.70, B2=.72, B3=.64, C1=.55, C2=.58, X1=.45, X2=.40) # 평시 이용률\n", + "\n", + "\n", + "def bpr(t0, v, cap, a=0.15, b=4.0):\n", + " \"\"\"BPR 지체 함수(교통공학의 표준형): 통과량/용량 비가 커질수록 지체가 급증한다. (분)\"\"\"\n", + " return t0 * a * (v / cap) ** b\n", + "\n", + "\n", + "def make_data(seed=11, days=150):\n", + " \"\"\"구간 x 일자 패널. v=실제 피크 통과량, plan_v=수송계획 수량(긴급 불출 미반영), delay=실제 지체(분).\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " d_idx = np.arange(days)\n", + " lvl = np.array([1.0, 1.08, 1.04, 1.10, 1.02, 0.82, 0.78])[d_idx % 7] * (1 + 0.0012 * d_idx)\n", + " lvl = lvl * np.where((d_idx >= 100) & (d_idx < 110), 1.30, 1.0) # 수송량 폭증 기간(가상)\n", + " prev, rows = {s: 0.0 for s in SEGS[\"seg\"]}, []\n", + " for d in d_idx:\n", + " rain = bool(rng.random() < 0.18)\n", + " for r in SEGS.itertuples():\n", + " maint, urgent = bool(rng.random() < 0.06), bool(rng.random() < 0.05) # 비계획 정비 / 긴급 불출\n", + " v = r.cap * UTIL[r.seg] * lvl[d] * (1.25 if urgent else 1.0) * rng.normal(1, 0.05)\n", + " cap_eff = r.cap * (0.65 if maint else 1.0) * (0.92 if rain else 1.0)\n", + " delay = max(r.t0 * 0.2 * (v / cap_eff) ** 3.5 + 0.25 * prev[r.seg] + rng.normal(0, 0.4), 0.0)\n", + " prev[r.seg] = delay\n", + " rows.append(dict(day=int(d), dow=int(d % 7), seg=r.seg, kind=r.kind, km=r.km, cap=r.cap, t0=r.t0,\n", + " plan_v=v / (1.25 if urgent else 1.0) * rng.normal(1, 0.04), v=v, maint=int(maint), urgent=int(urgent),\n", + " rain=int(rain), cap_eff=cap_eff, delay=delay))\n", + " df = pd.DataFrame(rows)\n", + " df[\"transit\"] = df[\"t0\"] * (1 + 0.08 * df[\"rain\"]) + df[\"delay\"]\n", + " return df\n", + "\n", + "\n", + "def mape(y, p):\n", + " y, p = np.asarray(y, float), np.asarray(p, float)\n", + " return float(np.mean(np.abs(y - p) / np.abs(y)) * 100)\n", + "\n", + "\n", + "def mae(y, p):\n", + " return float(np.mean(np.abs(np.asarray(y, float) - np.asarray(p, float))))\n", + "# ---------------------------------------------------------------------------\n", + "# ---------------------------------------------------------------------------\n", + "# 노선망 흐름 도구: 최대유량(Edmonds-Karp), 증분 배정(혼잡 반영 최단시간 경로), 경로 열거\n", + "# ---------------------------------------------------------------------------\n", + "CAP = dict(zip(SEGS[\"seg\"], SEGS[\"cap\"]))\n", + "T0 = dict(zip(SEGS[\"seg\"], SEGS[\"t0\"]))\n", + "KM = dict(zip(SEGS[\"seg\"], SEGS[\"km\"]))\n", + "ARCS = {r.seg: (r.u, r.v) for r in SEGS.itertuples()}\n", + "SRC, DST = \"SRC\", \"DST\"\n", + "\n", + "\n", + "def seg_time(s, flow, cap=None, a=0.2, b=3.5):\n", + " \"\"\"구간 통과시간(분) = 자유주행 + 혼잡 지체. cap을 주면 용량을 대체(정비·기상 반영).\"\"\"\n", + " r = flow / (cap or CAP[s])\n", + " return T0[s] * (1 + a * r ** b + 6.0 * max(0.0, r - 1.0)) # 용량 초과분은 대기열로 급증\n", + "\n", + "\n", + "def max_flow(closed=(), capmul=None):\n", + " \"\"\"최대유량(대/시)과 최소 절단 구간(병목). capmul = {구간: 용량 배율}.\"\"\"\n", + " cap = {s: (0 if s in closed else CAP[s] * (capmul or {}).get(s, 1.0)) for s in CAP}\n", + " res = {}\n", + " for s, (u, v) in ARCS.items():\n", + " res[(u, v)] = res.get((u, v), 0) + cap[s]\n", + " res.setdefault((v, u), 0)\n", + " flow = 0.0\n", + " while True:\n", + " prev, q = {SRC: None}, [SRC]\n", + " for u in q:\n", + " for (a, b), c in res.items():\n", + " if a == u and c > 1e-9 and b not in prev:\n", + " prev[b] = u; q.append(b)\n", + " if DST not in prev:\n", + " break\n", + " path, x = [], DST\n", + " while prev[x]:\n", + " path.append((prev[x], x)); x = prev[x]\n", + " f = min(res[e] for e in path)\n", + " for a, b in path:\n", + " res[(a, b)] -= f; res[(b, a)] += f\n", + " flow += f\n", + " reach = set(prev)\n", + " cut = [s for s, (u, v) in ARCS.items() if u in reach and v not in reach and cap[s] > 0]\n", + " return flow, cut\n", + "\n", + "\n", + "def all_paths():\n", + " \"\"\"SRC -> DST 단순 경로(구간 이름 튜플) 전체.\"\"\"\n", + " out = []\n", + "\n", + " def dfs(node, segs, seen):\n", + " if node == DST:\n", + " out.append(tuple(segs)); return\n", + " for s, (u, v) in ARCS.items():\n", + " if u == node and v not in seen:\n", + " dfs(v, segs + [s], seen | {v})\n", + " dfs(SRC, [], {SRC})\n", + " return out\n", + "\n", + "\n", + "PATHS = all_paths()\n", + "\n", + "\n", + "def assign(demand, closed=(), capmul=None, steps=40):\n", + " \"\"\"증분 배정: 수요를 steps조각으로 나눠, 전체 통과시간(대수 x 시간) 증가가 가장 작은 경로에 차례로 얹는다(시스템 최적 근사).\"\"\"\n", + " cap = {s: CAP[s] * (capmul or {}).get(s, 1.0) for s in CAP}\n", + " flow, pf = {s: 0.0 for s in CAP}, {}\n", + " ok = [p for p in PATHS if not any(s in closed for s in p)]\n", + " if not ok:\n", + " return None\n", + " for _ in range(steps):\n", + " chunk = demand / steps\n", + " best = min(ok, key=lambda p: sum((flow[s] + chunk) * seg_time(s, flow[s] + chunk, cap[s]) - flow[s] * seg_time(s, flow[s], cap[s]) for s in p))\n", + " for s in best:\n", + " flow[s] += chunk\n", + " pf[best] = pf.get(best, 0) + chunk\n", + " tt = {p: sum(seg_time(s, flow[s], cap[s]) for s in p) for p in pf}\n", + " avg = sum(pf[p] * tt[p] for p in pf) / demand\n", + " return {\"flow\": flow, \"paths\": pf, \"path_min\": tt, \"avg_min\": avg, \"util\": {s: flow[s] / cap[s] for s in cap if cap[s] > 0}}\n", + "\n", + "\n", + "FIXED_SHARE = {(\"A1\", \"A2\", \"A3\"): 0.32, (\"B1\", \"B3\"): 0.40, (\"C1\", \"C2\"): 0.28} # 과거 관행: 축선별 고정 배분(가상)\n", + "\n", + "\n", + "def fixed_assign(demand, closed=(), capmul=None):\n", + " \"\"\"과거 관행 배분: 축선별 고정 비율. 막힌 축선 물량은 열려 있는 축선에 비율대로 재분배.\"\"\"\n", + " cap = {s: CAP[s] * (capmul or {}).get(s, 1.0) for s in CAP}\n", + " live = {p: w for p, w in FIXED_SHARE.items() if not any(s in closed for s in p)}\n", + " if not live:\n", + " return None\n", + " tot = sum(live.values())\n", + " flow = {s: 0.0 for s in CAP}\n", + " pf = {p: demand * w / tot for p, w in live.items()}\n", + " for p, f in pf.items():\n", + " for s in p:\n", + " flow[s] += f\n", + " tt = {p: sum(seg_time(s, flow[s], cap[s]) for s in p) for p in pf}\n", + " return {\"flow\": flow, \"paths\": pf, \"path_min\": tt, \"avg_min\": sum(pf[p] * tt[p] for p in pf) / demand,\n", + " \"util\": {s: flow[s] / cap[s] for s in cap if cap[s] > 0}}\n", + "\n", + "\n", + "def lp_assign(demand, closed=(), capmul=None):\n", + " \"\"\"선형계획(LP): 구간 용량 상한(이용률 rho) 아래에서 자유주행시간 합을 최소화하는 경로별 물량. 불가능하면 rho를 1.0까지 완화.\"\"\"\n", + " cap = {s: CAP[s] * (capmul or {}).get(s, 1.0) for s in CAP}\n", + " ok = [p for p in PATHS if not any(s in closed for s in p)]\n", + " if not ok:\n", + " return None\n", + " for rho in (0.85, 0.95, 1.0):\n", + " r = linprog([sum(T0[s] for s in p) for p in ok], A_ub=[[1.0 if s in p else 0.0 for p in ok] for s in CAP],\n", + " b_ub=[rho * cap[s] for s in CAP], A_eq=[[1.0] * len(ok)], b_eq=[demand], bounds=(0, None), method=\"highs\")\n", + " if r.status == 0:\n", + " break\n", + " else:\n", + " return assign(demand, closed, capmul) # 용량 초과 수요: 증분 배정으로 대체(과부하 표시)\n", + " flow = {s: sum(r.x[i] for i, p in enumerate(ok) if s in p) for s in CAP}\n", + " pf = {p: float(r.x[i]) for i, p in enumerate(ok) if r.x[i] > 1e-6}\n", + " tt = {p: sum(seg_time(s, flow[s], cap[s]) for s in p) for p in pf}\n", + " return {\"flow\": flow, \"paths\": pf, \"path_min\": tt, \"avg_min\": sum(pf[p] * tt[p] for p in pf) / demand,\n", + " \"util\": {s: flow[s] / cap[s] for s in cap if cap[s] > 0}, \"rho\": rho}\n", + "\n", + "\n", + "def cost_of(res, tv=1000, fuel_km=450):\n", + " \"\"\"시간당 비용(원, 교육용 가정): 대당 시간비용 tv원/분 + 유류비 fuel_km원/km.\"\"\"\n", + " return sum(f * (res[\"path_min\"][p] * tv + sum(KM[s] for s in p) * fuel_km) for p, f in res[\"paths\"].items())\n", + "# ---------------------------------------------------------------------------\n", + "\n", + "# ---------------- 1) 최적화 시뮬레이터: 과거 관행(축선 고정 배분) vs LP ----------------\n", + "SC = [(\"평시\", 200, (), None), (\"수송량 폭증(+30%)\", 260, (), None), (\"A2 교량 정비(용량 65%)\", 200, (), {\"A2\": .65}), (\"A2 교량 폐쇄\", 200, (\"A2\",), None),\n", + " (\"A2 폐쇄 + 우천\", 200, (\"A2\",), {k: .92 for k in CAP}), (\"B1 정비 + 폭증\", 250, (), {\"B1\": .65})]\n", + "rows, plans = [], {}\n", + "for name, D, cl, cm in SC:\n", + " f, l = fixed_assign(D, cl, cm), lp_assign(D, cl, cm)\n", + " cf, cl_ = cost_of(f), cost_of(l)\n", + " rows.append((name, D, f[\"avg_min\"], l[\"avg_min\"], cf / 1e4, cl_ / 1e4, 1 - cl_ / cf))\n", + " plans[name] = (D, cl, cm, l, cf, cl_)\n", + "res = pd.DataFrame(rows, columns=[\"시나리오\", \"수요\", \"고정 배분(분)\", \"LP(분)\", \"고정 비용(만원/시)\", \"LP 비용(만원/시)\", \"절감률\"])\n", + "print(\"[1] 시나리오별 비용 비교(시간·유류 단가는 교육용 가정값)\")\n", + "print(res.assign(절감률=res[\"절감률\"].map(\"{:.1%}\".format)).round(1).to_string(index=False))\n", + "avg = res[\"절감률\"].mean()\n", + "print(f\" 시나리오 6종 평균 절감 {avg:.1%}, 우회가 필요한 4개 시나리오 평균 {res['절감률'].iloc[2:].mean():.1%} (목표 15% 이상: {'달성' if avg >= .15 else '미달'}), 평시는 {res['절감률'].iloc[0]:.1%}에 그친다\")\n", + "\n", + "# ---------------- 2) 백테스팅: 과거 30일(day 120~149) 실적 조건으로 재현 ----------------\n", + "df = make_data()\n", + "rec = []\n", + "for day, g in df[df.day >= 120].groupby(\"day\"):\n", + " D = float(g[g.seg.isin([\"A1\", \"B1\", \"C1\"])][\"plan_v\"].sum())\n", + " cm = {r.seg: r.cap_eff / r.cap for r in g.itertuples() if r.cap_eff < r.cap}\n", + " f, l = fixed_assign(D, (), cm), lp_assign(D, (), cm)\n", + " rec.append((day, D, cost_of(f), cost_of(l)))\n", + "bt = pd.DataFrame(rec, columns=[\"day\", \"D\", \"fix\", \"lp\"])\n", + "bt[\"save\"] = 1 - bt[\"lp\"] / bt[\"fix\"]\n", + "print(f\"\\n[2] 백테스팅(30일): 평균 절감 {bt['save'].mean():.1%}(목표 15% 이상: {'달성' if bt['save'].mean() >= .15 else '미달'}), 최대 {bt['save'].max():.1%}(day {int(bt.loc[bt['save'].idxmax(), 'day'])}), 절감 0 이하인 날 {int((bt['save'] <= 0).sum())}일\")\n", + "print(f\" 절감 10% 미만인 날 {int((bt['save'] < .10).sum())}일: 효과는 정비·폭증이 겹친 날에 집중된다. 과거 관행 배분은 가정이므로 실제 부대 실적으로는 별도 검증이 필요하다.\")\n", + "\n", + "# ---------------- 3) 민감도: 돌발 수요(전시 전환 가정) ----------------\n", + "sens = []\n", + "for k in (0.8, 1.0, 1.2, 1.4, 1.6):\n", + " f, l = fixed_assign(200 * k, (\"A2\",)), lp_assign(200 * k, (\"A2\",))\n", + " sens.append((f\"x{k}\", 200 * k, max(l[\"util\"].values()), 1 - cost_of(l) / cost_of(f)))\n", + "sdf = pd.DataFrame(sens, columns=[\"수요 배율\", \"수요\", \"LP 최대 이용률\", \"절감률\"])\n", + "print(\"[3] 돌발 수요 민감도(A2 폐쇄 상황):\", \"; \".join(f\"{r['수요 배율']} -> 이용률 {r['LP 최대 이용률']:.2f}, 절감 {r['절감률']:.0%}\" for _, r in sdf.iterrows()))\n", + "print(\" 수요 x1.2 이상에서는 최대유량(220대/시)을 넘어 우회만으로 수용할 수 없으므로 물량 조정 결정이 필요하다.\")\n", + "\n", + "# ---------------- 4) HITL 결재 워크플로우 ----------------\n", + "USERS = {\"plan_a\": \"PLN\", \"plan_b\": \"PLN\", \"ops_kim\": \"OPS\", \"ops_lee\": \"OPS\", \"sup_park\": \"SUP\", \"aud_han\": \"AUD\"}\n", + "PERM = {\"PLN\": {\"submit\", \"redraft\"}, \"OPS\": {\"approve\", \"reject\"}, \"SUP\": {\"approve\", \"reject\"}, \"AUD\": {\"read\"}}\n", + "\n", + "\n", + "class Flow:\n", + " def __init__(self):\n", + " self.docs, self.log, self.blocked = {}, [], []\n", + "\n", + " def _audit(self, user, action, doc, detail):\n", + " prev = self.log[-1][\"hash\"] if self.log else \"GENESIS\"\n", + " rec = {\"seq\": len(self.log) + 1, \"user\": user, \"action\": action, \"doc\": doc, \"detail\": detail, \"prev\": prev}\n", + " rec[\"hash\"] = hashlib.sha256(json.dumps(rec, ensure_ascii=False, sort_keys=True).encode()).hexdigest()[:12]\n", + " self.log.append(rec)\n", + "\n", + " def verify(self):\n", + " prev = \"GENESIS\"\n", + " for r in self.log:\n", + " body = {k: v for k, v in r.items() if k != \"hash\"}\n", + " if r[\"prev\"] != prev or hashlib.sha256(json.dumps(body, ensure_ascii=False, sort_keys=True).encode()).hexdigest()[:12] != r[\"hash\"]:\n", + " return False, r[\"seq\"]\n", + " prev = r[\"hash\"]\n", + " return True, None\n", + "\n", + " def deny(self, user, act, doc, why):\n", + " self.blocked.append((user, act, doc, why)); self._audit(user, \"DENIED:\" + act, doc, why); return False\n", + "\n", + " def submit(self, user, scen, parent=None):\n", + " need = \"redraft\" if parent else \"submit\"\n", + " if need not in PERM.get(USERS.get(user), set()):\n", + " return self.deny(user, need, \"-\", \"상신 권한 없음\")\n", + " D, cl, cm, l, cf, cl_ = plans[scen]\n", + " extra = l[\"avg_min\"] - plans[\"평시\"][3][\"avg_min\"]\n", + " big = extra >= 3 or max(l[\"util\"].values()) > 0.98 # 추가 소요 3분 이상 또는 용량 포화 -> 2단계 결재\n", + " did = f\"ORD{len(self.docs) + 1:02d}\"\n", + " self.docs[did] = {\"scen\": scen, \"line\": [\"OPS\", \"SUP\"] if big else [\"OPS\"], \"step\": 0, \"status\": \"검토대기\", \"drafter\": user, \"done\": [], \"parent\": parent}\n", + " self._audit(user, \"submit\", did, f\"{scen} 추가소요 {extra:+.1f}분\")\n", + " return did\n", + "\n", + " def decide(self, user, did, act, reason=\"\"):\n", + " role, d = USERS.get(user), self.docs.get(did)\n", + " if role is None: return self.deny(user, act, did, \"미등록 사용자\")\n", + " if d is None or d[\"status\"] != \"검토대기\": return self.deny(user, act, did, \"결재 대상 아님(처리 완료)\")\n", + " if act not in PERM[role]: return self.deny(user, act, did, f\"{role} 역할은 {act} 불가\")\n", + " if d[\"line\"][d[\"step\"]] != role: return self.deny(user, act, did, \"결재 순서 위반\")\n", + " if user == d[\"drafter\"] or user in d[\"done\"]: return self.deny(user, act, did, \"기안자·기결재자 재결재 금지\")\n", + " if len(reason.strip()) < 10: return self.deny(user, act, did, \"사유 10자 이상 필수\")\n", + " if act == \"reject\":\n", + " d[\"status\"] = \"반려\"\n", + " else:\n", + " d[\"done\"].append(user); d[\"step\"] += 1\n", + " d[\"status\"] = \"승인\" if d[\"step\"] == len(d[\"line\"]) else \"검토대기\"\n", + " self._audit(user, act, did, reason); return True\n", + "\n", + " def edit(self, user, did):\n", + " if self.docs[did][\"status\"] == \"승인\": return self.deny(user, \"edit\", did, \"승인 후 명령 변경 금지(재기안)\")\n", + " return True\n", + "\n", + "\n", + "wf = Flow()\n", + "names = [\"A2 교량 폐쇄\", \"A2 교량 정비(용량 65%)\", \"수송량 폭증(+30%)\", \"B1 정비 + 폭증\", \"A2 폐쇄 + 우천\", \"평시\"]\n", + "ids = [wf.submit(\"plan_a\" if i % 2 == 0 else \"plan_b\", n) for i, n in enumerate(names)]\n", + "print(f\"\\n[4] AI 우회안 {len(ids)}건 전부 '검토대기' 등록, 자동 승인 {sum(wf.docs[i]['status'] == '승인' for i in ids)}건 (목표 0건). 2단계 결재 대상 {[i for i in ids if len(wf.docs[i]['line']) == 2]}\")\n", + "wf.decide(\"ops_kim\", ids[0], \"reject\", \"추가 소요와 용량 포화 우려, 물량 조정안 포함해 재기안\")\n", + "new = wf.submit(\"plan_a\", names[0], parent=ids[0])\n", + "wf.decide(\"ops_kim\", new, \"approve\", \"우회 경로 용량 확인, 상급 승인 상신함\")\n", + "wf.decide(\"sup_park\", new, \"approve\", \"우회 시행, 운행 중 지체 보고 조건\")\n", + "wf.decide(\"ops_lee\", ids[1], \"approve\", \"정비 구간 용량 감소분 우회 반영 타당\")\n", + "wf.decide(\"ops_kim\", ids[5], \"approve\", \"평시 물량으로 변경 사유 없어 기존 유지\")\n", + "attacks = [(\"plan_a\", ids[2], \"approve\", \"내가 올린 안을 직접 승인한다\", \"요청자 자기 승인\"), (\"aud_han\", ids[2], \"approve\", \"감사 계정으로 승인 시도\", \"감사 역할 승인\"),\n", + " (\"ghost\", ids[2], \"approve\", \"외부 계정으로 승인 시도\", \"미등록 사용자\"), (\"ops_kim\", ids[2], \"approve\", \"\", \"사유 없는 승인\"),\n", + " (\"ops_kim\", ids[2], \"reject\", \"짧음\", \"사유 짧은 반려\"), (\"sup_park\", ids[2], \"approve\", \"상급자가 순서를 건너뛰어 승인\", \"결재 순서 건너뛰기\"),\n", + " (\"ops_kim\", ids[5], \"approve\", \"이미 승인된 문서를 다시 승인\", \"처리 완료 재승인\"), (\"sup_park\", new, \"reject\", \"승인된 문서를 뒤집어 반려\", \"승인 후 번복\"),\n", + " (\"plan_b\", ids[3], \"approve\", \"요청자 계정으로 승인 시도함\", \"요청자 권한 위반\")]\n", + "for u, doc, act, rs, _ in attacks:\n", + " wf.decide(u, doc, act, rs)\n", + "wf.edit(\"plan_a\", new) # 10) 승인 후 명령 변경\n", + "print(f\" 권한 우회 시도 10건 중 차단 {len(wf.blocked)}건 (차단율 {len(wf.blocked) / 10:.0%}, 목표 100%)\")\n", + "for u, a, doc, why in wf.blocked[:4]:\n", + " print(f\" 차단 {u:<9} {a:<8} {doc}: {why}\")\n", + "ok, _ = wf.verify()\n", + "snap = json.loads(json.dumps(wf.log))\n", + "wf.log[3][\"detail\"] = \"사후에 고쳐 쓴 사유\"\n", + "ok2, at = wf.verify()\n", + "wf.log = snap\n", + "print(f\" 감사 로그 {len(wf.log)}건 해시 체인 무결성 {'100%' if ok else '훼손'}; 4번 기록 사후 수정 시 {'통과' if ok2 else f'위변조 검출({at}번)'}\")\n", + "print(f\" {ids[0]}(반려) -> {new}(재기안, parent 연결) 이력:\", [(r['action'], r['user']) for r in wf.log if r['doc'] in (ids[0], new)])\n", + "print(\" 문서 상태:\", pd.Series({k: v[\"status\"] for k, v in wf.docs.items()}).value_counts().to_dict(), \"| 실제 우회 명령 하달은 지휘계통(작전군수관 승인) 결심에 따른다.\")\n", + "\n", + "fig, ax = plt.subplots(1, 2, figsize=(12, 4))\n", + "x = np.arange(len(res)); ax[0].bar(x - .2, res[\"고정 비용(만원/시)\"], .4, label=\"fixed share\"); ax[0].bar(x + .2, res[\"LP 비용(만원/시)\"], .4, label=\"LP\")\n", + "ax[0].set_xticks(x); ax[0].set_xticklabels([f\"S{i + 1}\" for i in x]); ax[0].legend(); ax[0].set_title(\"Cost per hour by scenario\")\n", + "ax[1].plot(sdf[\"수요\"], sdf[\"절감률\"] * 100, \"o-\"); ax[1].set_title(\"Sensitivity: demand vs saving (%), A2 closed\"); ax[1].set_xlabel(\"demand (veh/h)\")\n", + "plt.tight_layout(); plt.show()\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 07 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + "[1] 시나리오별 비용 비교(시간·유류 단가는 교육용 가정값)\n", + " 시나리오 수요 고정 배분(분) LP(분) 고정 비용(만원/시) LP 비용(만원/시) 절감률\n", + " 평시 200 41.9 41.8 1124.8 1122.7 0.2%\n", + " 수송량 폭증(+30%) 260 57.2 44.8 1860.5 1547.9 16.8%\n", + "A2 교량 정비(용량 65%) 200 53.3 42.2 1351.6 1138.9 15.7%\n", + " A2 교량 폐쇄 200 91.4 45.1 2122.3 1207.2 43.1%\n", + " A2 폐쇄 + 우천 200 119.7 47.0 2688.8 1245.4 53.7%\n", + " B1 정비 + 폭증 250 76.5 46.2 2270.9 1523.7 32.9%\n", + " 시나리오 6종 평균 절감 27.1%, 우회가 필요한 4개 시나리오 평균 36.4% (목표 15% 이상: 달성), 평시는 0.2%에 그친다\n", + "\n", + "[2] 백테스팅(30일): 평균 절감 16.0%(목표 15% 이상: 달성), 최대 48.5%(day 134), 절감 0 이하인 날 0일\n", + " 절감 10% 미만인 날 12일: 효과는 정비·폭증이 겹친 날에 집중된다. 과거 관행 배분은 가정이므로 실제 부대 실적으로는 별도 검증이 필요하다.\n", + "[3] 돌발 수요 민감도(A2 폐쇄 상황): x0.8 -> 이용률 0.85, 절감 4%; x1.0 -> 이용률 0.95, 절감 43%; x1.2 -> 이용률 1.32, 절감 58%; x1.4 -> 이용률 1.54, 절감 58%; x1.6 -> 이용률 1.76, 절감 56%\n", + " 수요 x1.2 이상에서는 최대유량(220대/시)을 넘어 우회만으로 수용할 수 없으므로 물량 조정 결정이 필요하다.\n", + "\n", + "[4] AI 우회안 6건 전부 '검토대기' 등록, 자동 승인 0건 (목표 0건). 2단계 결재 대상 ['ORD01', 'ORD03', 'ORD04', 'ORD05']\n", + " 권한 우회 시도 10건 중 차단 10건 (차단율 100%, 목표 100%)\n", + " 차단 plan_a approve ORD03: PLN 역할은 approve 불가\n", + " 차단 aud_han approve ORD03: AUD 역할은 approve 불가\n", + " 차단 ghost approve ORD03: 미등록 사용자\n", + " 차단 ops_kim approve ORD03: 사유 10자 이상 필수\n", + " 감사 로그 22건 해시 체인 무결성 100%; 4번 기록 사후 수정 시 위변조 검출(4번)\n", + " ORD01(반려) -> ORD07(재기안, parent 연결) 이력: [('submit', 'plan_a'), ('reject', 'ops_kim'), ('submit', 'plan_a'), ('approve', 'ops_kim'), ('approve', 'sup_park'), ('DENIED:reject', 'sup_park'), ('DENIED:edit', 'plan_a')]\n", + " 문서 상태: {'승인': 3, '검토대기': 3, '반려': 1} | 실제 우회 명령 하달은 지휘계통(작전군수관 승인) 결심에 따른다.\n", + "```\n", + "
\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 08. 주요 축선 3개 동시 봉쇄 상황 모의 우회 수송 시뮬레이션 스트레스 테스트\n", + "**소요시간**: 약 3.0시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "주요 축선 봉쇄 모의 우회 수송 시뮬레이션 스트레스 테스트를 수행한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **구간 3개가 동시에 막히는 상황을 모의하는 스트레스 시험으로 노선망의 취약 조합과 대시보드 갱신 성능·경보 정확도를 확인한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **구간 10개 중 3개가 동시에 막히는 120개 조합과 3개 축선의 접근 구간 동시 봉쇄를 모의해 단절, 수요 초과, 수용 가능 조합을 분류하고 단절 조합에 자주 등장하는 구간을 이중화 후보로 제시 수행하고 검증하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `src/dashboard/mov01_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": [ + "\"\"\"가상 노선망에서 구간 3개가 동시에 막히는 모든 조합(120가지)과 축선 3개 전면 봉쇄를 모의해 단절·과부하·추가 지체를 평가하고, 대시보드 갱신 시간(2초 이내)과 지체 경보 정확도(95% 이상)를 스트레스 조건에서 측정한다.\"\"\"\n", + "import itertools\n", + "import time\n", + "import numpy as np\n", + "import pandas as pd\n", + "import matplotlib.pyplot as plt\n", + "from scipy.optimize import linprog\n", + "from sklearn.ensemble import GradientBoostingRegressor\n", + "\n", + "# ---------------------------------------------------------------------------\n", + "# [교육용가상자료] 가상 노선망·구간 통과량 생성기 (실제 도로·지명·부대 없음)\n", + "# 가상 출발지(SRC)에서 가상 목적지(DST)까지 3개 축선(A·B·C)과 연결로 2개, 구간 10개. 150일치 피크시간 통과량·지체 시간을 만든다.\n", + "# ---------------------------------------------------------------------------\n", + "SEGS = pd.DataFrame([\n", + " (\"A1\", \"SRC\", \"J1\", 12, 130, \"일반\"), (\"A2\", \"J1\", \"J2\", 8, 70, \"교량\"), (\"A3\", \"J2\", \"DST\", 10, 140, \"교차로\"),\n", + " (\"B1\", \"SRC\", \"J3\", 15, 100, \"일반\"), (\"B2\", \"J3\", \"J2\", 9, 80, \"터널\"), (\"B3\", \"J3\", \"DST\", 14, 95, \"일반\"),\n", + " (\"C1\", \"SRC\", \"J4\", 18, 70, \"일반\"), (\"C2\", \"J4\", \"DST\", 20, 75, \"교량\"),\n", + " (\"X1\", \"J1\", \"J3\", 7, 50, \"교차로\"), (\"X2\", \"J4\", \"J3\", 10, 60, \"일반\")],\n", + " columns=[\"seg\", \"u\", \"v\", \"km\", \"cap\", \"kind\"]) # cap = 피크시간 통과 용량(대/시, 교육용 가정값)\n", + "KSPD = {\"일반\": 55, \"교량\": 40, \"터널\": 45, \"교차로\": 45} # 구간 유형별 자유 주행 속도(km/h, 가정값)\n", + "SEGS[\"t0\"] = SEGS[\"km\"] / SEGS[\"kind\"].map(KSPD) * 60 # 자유 주행 시간(분)\n", + "UTIL = dict(A1=.78, A2=.85, A3=.66, B1=.70, B2=.72, B3=.64, C1=.55, C2=.58, X1=.45, X2=.40) # 평시 이용률\n", + "\n", + "\n", + "def bpr(t0, v, cap, a=0.15, b=4.0):\n", + " \"\"\"BPR 지체 함수(교통공학의 표준형): 통과량/용량 비가 커질수록 지체가 급증한다. (분)\"\"\"\n", + " return t0 * a * (v / cap) ** b\n", + "\n", + "\n", + "def make_data(seed=11, days=150):\n", + " \"\"\"구간 x 일자 패널. v=실제 피크 통과량, plan_v=수송계획 수량(긴급 불출 미반영), delay=실제 지체(분).\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " d_idx = np.arange(days)\n", + " lvl = np.array([1.0, 1.08, 1.04, 1.10, 1.02, 0.82, 0.78])[d_idx % 7] * (1 + 0.0012 * d_idx)\n", + " lvl = lvl * np.where((d_idx >= 100) & (d_idx < 110), 1.30, 1.0) # 수송량 폭증 기간(가상)\n", + " prev, rows = {s: 0.0 for s in SEGS[\"seg\"]}, []\n", + " for d in d_idx:\n", + " rain = bool(rng.random() < 0.18)\n", + " for r in SEGS.itertuples():\n", + " maint, urgent = bool(rng.random() < 0.06), bool(rng.random() < 0.05) # 비계획 정비 / 긴급 불출\n", + " v = r.cap * UTIL[r.seg] * lvl[d] * (1.25 if urgent else 1.0) * rng.normal(1, 0.05)\n", + " cap_eff = r.cap * (0.65 if maint else 1.0) * (0.92 if rain else 1.0)\n", + " delay = max(r.t0 * 0.2 * (v / cap_eff) ** 3.5 + 0.25 * prev[r.seg] + rng.normal(0, 0.4), 0.0)\n", + " prev[r.seg] = delay\n", + " rows.append(dict(day=int(d), dow=int(d % 7), seg=r.seg, kind=r.kind, km=r.km, cap=r.cap, t0=r.t0,\n", + " plan_v=v / (1.25 if urgent else 1.0) * rng.normal(1, 0.04), v=v, maint=int(maint), urgent=int(urgent),\n", + " rain=int(rain), cap_eff=cap_eff, delay=delay))\n", + " df = pd.DataFrame(rows)\n", + " df[\"transit\"] = df[\"t0\"] * (1 + 0.08 * df[\"rain\"]) + df[\"delay\"]\n", + " return df\n", + "\n", + "\n", + "def mape(y, p):\n", + " y, p = np.asarray(y, float), np.asarray(p, float)\n", + " return float(np.mean(np.abs(y - p) / np.abs(y)) * 100)\n", + "\n", + "\n", + "def mae(y, p):\n", + " return float(np.mean(np.abs(np.asarray(y, float) - np.asarray(p, float))))\n", + "# ---------------------------------------------------------------------------\n", + "# ---------------------------------------------------------------------------\n", + "# 지체 예측용 파생 피처 생성(당일 오전 시점에 알 수 있는 정보만 사용: 계획 물량, 공지 정비, 기상 예보, 전일·전주 실적)\n", + "# ---------------------------------------------------------------------------\n", + "FEATS = [\"plan_v\", \"cap\", \"cap_eff\", \"t0\", \"km\", \"maint\", \"rain\", \"util\", \"util_eff\", \"util_eff4\", \"bpr_base\", \"lag1_delay\", \"lag7_delay\",\n", + " \"roll7_v\", \"surge_ratio\", \"is_weekend\", \"dow_sin\", \"dow_cos\", \"k_교량\", \"k_터널\", \"k_교차로\"]\n", + "\n", + "\n", + "def add_features(df):\n", + " d = df.sort_values([\"seg\", \"day\"]).copy()\n", + " g = d.groupby(\"seg\")\n", + " d[\"lag1_delay\"] = g[\"delay\"].shift(1)\n", + " d[\"lag7_delay\"] = g[\"delay\"].shift(7)\n", + " d[\"roll7_v\"] = g[\"v\"].transform(lambda s: s.shift(1).rolling(7).mean())\n", + " d[\"util\"] = d[\"plan_v\"] / d[\"cap\"]\n", + " d[\"util_eff\"] = d[\"plan_v\"] / d[\"cap_eff\"]\n", + " d[\"util_eff4\"] = d[\"util_eff\"] ** 4\n", + " d[\"bpr_base\"] = bpr(d[\"t0\"], d[\"plan_v\"], d[\"cap\"])\n", + " d[\"surge_ratio\"] = d[\"plan_v\"] / d[\"roll7_v\"]\n", + " d[\"is_weekend\"] = (d[\"dow\"] >= 5).astype(int)\n", + " d[\"dow_sin\"], d[\"dow_cos\"] = np.sin(2 * np.pi * d[\"dow\"] / 7), np.cos(2 * np.pi * d[\"dow\"] / 7)\n", + " for k in (\"교량\", \"터널\", \"교차로\"):\n", + " d[\"k_\" + k] = (d[\"kind\"] == k).astype(int)\n", + " return d.dropna().sort_values([\"day\", \"seg\"]).reset_index(drop=True)\n", + "# ---------------------------------------------------------------------------\n", + "# ---------------------------------------------------------------------------\n", + "# 노선망 흐름 도구: 최대유량(Edmonds-Karp), 증분 배정(혼잡 반영 최단시간 경로), 경로 열거\n", + "# ---------------------------------------------------------------------------\n", + "CAP = dict(zip(SEGS[\"seg\"], SEGS[\"cap\"]))\n", + "T0 = dict(zip(SEGS[\"seg\"], SEGS[\"t0\"]))\n", + "KM = dict(zip(SEGS[\"seg\"], SEGS[\"km\"]))\n", + "ARCS = {r.seg: (r.u, r.v) for r in SEGS.itertuples()}\n", + "SRC, DST = \"SRC\", \"DST\"\n", + "\n", + "\n", + "def seg_time(s, flow, cap=None, a=0.2, b=3.5):\n", + " \"\"\"구간 통과시간(분) = 자유주행 + 혼잡 지체. cap을 주면 용량을 대체(정비·기상 반영).\"\"\"\n", + " r = flow / (cap or CAP[s])\n", + " return T0[s] * (1 + a * r ** b + 6.0 * max(0.0, r - 1.0)) # 용량 초과분은 대기열로 급증\n", + "\n", + "\n", + "def max_flow(closed=(), capmul=None):\n", + " \"\"\"최대유량(대/시)과 최소 절단 구간(병목). capmul = {구간: 용량 배율}.\"\"\"\n", + " cap = {s: (0 if s in closed else CAP[s] * (capmul or {}).get(s, 1.0)) for s in CAP}\n", + " res = {}\n", + " for s, (u, v) in ARCS.items():\n", + " res[(u, v)] = res.get((u, v), 0) + cap[s]\n", + " res.setdefault((v, u), 0)\n", + " flow = 0.0\n", + " while True:\n", + " prev, q = {SRC: None}, [SRC]\n", + " for u in q:\n", + " for (a, b), c in res.items():\n", + " if a == u and c > 1e-9 and b not in prev:\n", + " prev[b] = u; q.append(b)\n", + " if DST not in prev:\n", + " break\n", + " path, x = [], DST\n", + " while prev[x]:\n", + " path.append((prev[x], x)); x = prev[x]\n", + " f = min(res[e] for e in path)\n", + " for a, b in path:\n", + " res[(a, b)] -= f; res[(b, a)] += f\n", + " flow += f\n", + " reach = set(prev)\n", + " cut = [s for s, (u, v) in ARCS.items() if u in reach and v not in reach and cap[s] > 0]\n", + " return flow, cut\n", + "\n", + "\n", + "def all_paths():\n", + " \"\"\"SRC -> DST 단순 경로(구간 이름 튜플) 전체.\"\"\"\n", + " out = []\n", + "\n", + " def dfs(node, segs, seen):\n", + " if node == DST:\n", + " out.append(tuple(segs)); return\n", + " for s, (u, v) in ARCS.items():\n", + " if u == node and v not in seen:\n", + " dfs(v, segs + [s], seen | {v})\n", + " dfs(SRC, [], {SRC})\n", + " return out\n", + "\n", + "\n", + "PATHS = all_paths()\n", + "\n", + "\n", + "def assign(demand, closed=(), capmul=None, steps=40):\n", + " \"\"\"증분 배정: 수요를 steps조각으로 나눠, 전체 통과시간(대수 x 시간) 증가가 가장 작은 경로에 차례로 얹는다(시스템 최적 근사).\"\"\"\n", + " cap = {s: CAP[s] * (capmul or {}).get(s, 1.0) for s in CAP}\n", + " flow, pf = {s: 0.0 for s in CAP}, {}\n", + " ok = [p for p in PATHS if not any(s in closed for s in p)]\n", + " if not ok:\n", + " return None\n", + " for _ in range(steps):\n", + " chunk = demand / steps\n", + " best = min(ok, key=lambda p: sum((flow[s] + chunk) * seg_time(s, flow[s] + chunk, cap[s]) - flow[s] * seg_time(s, flow[s], cap[s]) for s in p))\n", + " for s in best:\n", + " flow[s] += chunk\n", + " pf[best] = pf.get(best, 0) + chunk\n", + " tt = {p: sum(seg_time(s, flow[s], cap[s]) for s in p) for p in pf}\n", + " avg = sum(pf[p] * tt[p] for p in pf) / demand\n", + " return {\"flow\": flow, \"paths\": pf, \"path_min\": tt, \"avg_min\": avg, \"util\": {s: flow[s] / cap[s] for s in cap if cap[s] > 0}}\n", + "\n", + "\n", + "FIXED_SHARE = {(\"A1\", \"A2\", \"A3\"): 0.32, (\"B1\", \"B3\"): 0.40, (\"C1\", \"C2\"): 0.28} # 과거 관행: 축선별 고정 배분(가상)\n", + "\n", + "\n", + "def fixed_assign(demand, closed=(), capmul=None):\n", + " \"\"\"과거 관행 배분: 축선별 고정 비율. 막힌 축선 물량은 열려 있는 축선에 비율대로 재분배.\"\"\"\n", + " cap = {s: CAP[s] * (capmul or {}).get(s, 1.0) for s in CAP}\n", + " live = {p: w for p, w in FIXED_SHARE.items() if not any(s in closed for s in p)}\n", + " if not live:\n", + " return None\n", + " tot = sum(live.values())\n", + " flow = {s: 0.0 for s in CAP}\n", + " pf = {p: demand * w / tot for p, w in live.items()}\n", + " for p, f in pf.items():\n", + " for s in p:\n", + " flow[s] += f\n", + " tt = {p: sum(seg_time(s, flow[s], cap[s]) for s in p) for p in pf}\n", + " return {\"flow\": flow, \"paths\": pf, \"path_min\": tt, \"avg_min\": sum(pf[p] * tt[p] for p in pf) / demand,\n", + " \"util\": {s: flow[s] / cap[s] for s in cap if cap[s] > 0}}\n", + "\n", + "\n", + "def lp_assign(demand, closed=(), capmul=None):\n", + " \"\"\"선형계획(LP): 구간 용량 상한(이용률 rho) 아래에서 자유주행시간 합을 최소화하는 경로별 물량. 불가능하면 rho를 1.0까지 완화.\"\"\"\n", + " cap = {s: CAP[s] * (capmul or {}).get(s, 1.0) for s in CAP}\n", + " ok = [p for p in PATHS if not any(s in closed for s in p)]\n", + " if not ok:\n", + " return None\n", + " for rho in (0.85, 0.95, 1.0):\n", + " r = linprog([sum(T0[s] for s in p) for p in ok], A_ub=[[1.0 if s in p else 0.0 for p in ok] for s in CAP],\n", + " b_ub=[rho * cap[s] for s in CAP], A_eq=[[1.0] * len(ok)], b_eq=[demand], bounds=(0, None), method=\"highs\")\n", + " if r.status == 0:\n", + " break\n", + " else:\n", + " return assign(demand, closed, capmul) # 용량 초과 수요: 증분 배정으로 대체(과부하 표시)\n", + " flow = {s: sum(r.x[i] for i, p in enumerate(ok) if s in p) for s in CAP}\n", + " pf = {p: float(r.x[i]) for i, p in enumerate(ok) if r.x[i] > 1e-6}\n", + " tt = {p: sum(seg_time(s, flow[s], cap[s]) for s in p) for p in pf}\n", + " return {\"flow\": flow, \"paths\": pf, \"path_min\": tt, \"avg_min\": sum(pf[p] * tt[p] for p in pf) / demand,\n", + " \"util\": {s: flow[s] / cap[s] for s in cap if cap[s] > 0}, \"rho\": rho}\n", + "\n", + "\n", + "def cost_of(res, tv=1000, fuel_km=450):\n", + " \"\"\"시간당 비용(원, 교육용 가정): 대당 시간비용 tv원/분 + 유류비 fuel_km원/km.\"\"\"\n", + " return sum(f * (res[\"path_min\"][p] * tv + sum(KM[s] for s in p) * fuel_km) for p, f in res[\"paths\"].items())\n", + "# ---------------------------------------------------------------------------\n", + "\n", + "D = 150\n", + "base = assign(D)\n", + "print(f\"[0] 평시: 수요 {D}대/시, 평균 통과 {base['avg_min']:.1f}분, 최대유량 {max_flow()[0]:.0f}대/시\")\n", + "\n", + "# ---------------- 1) 축선 3개(A·B·C) 전면 봉쇄 ----------------\n", + "axes_cut = (\"A3\", \"B3\", \"C2\") # 3개 축선의 목적지 접근 구간\n", + "mf, _ = max_flow(axes_cut)\n", + "print(f\"[1] 3개 축선 접근 구간(A3·B3·C2) 동시 봉쇄: 최대유량 {mf:.0f}대/시, 경로 {len(PATHS) - len([p for p in PATHS if not any(s in axes_cut for s in p)])}/{len(PATHS)}개 차단 -> \"\n", + " + (\"도로 수송 불가. 공중·해상 등 타 수단 또는 물량 보류 결정이 필요하며 사람이 판단한다\" if assign(D, axes_cut) is None else \"우회 가능\"))\n", + "\n", + "# ---------------- 2) 구간 3개 동시 봉쇄 120조합 ----------------\n", + "rows = []\n", + "t0 = time.perf_counter()\n", + "for c in itertools.combinations(CAP, 3):\n", + " flow, cut = max_flow(c)\n", + " r = assign(D, c) if flow > 0 else None\n", + " rows.append({\"closed\": \"+\".join(c), \"maxflow\": flow, \"avg_min\": r[\"avg_min\"] if r else np.nan, \"max_util\": max(r[\"util\"].values()) if r else np.nan})\n", + "el = time.perf_counter() - t0\n", + "st = pd.DataFrame(rows)\n", + "st[\"상태\"] = np.where(st[\"maxflow\"] <= 0, \"단절\", np.where(st[\"maxflow\"] < D, \"수요 초과\", np.where(st[\"avg_min\"] > base[\"avg_min\"] * 1.5, \"심각 지체\", \"수용\")))\n", + "vc = st[\"상태\"].value_counts()\n", + "print(f\"\\n[2] 120개 조합 평가({el:.2f}초): \" + \", \".join(f\"{k} {v}\" for k, v in vc.items()))\n", + "ok_rows = st[st[\"상태\"].isin([\"수용\", \"심각 지체\"])]\n", + "print(f\" 수용 가능 조합의 평균 통과시간 {ok_rows['avg_min'].mean():.1f}분(평시 {base['avg_min']:.1f}분), 최악 수용 조합 {ok_rows.loc[ok_rows['avg_min'].idxmax(), 'closed']} {ok_rows['avg_min'].max():.1f}분\")\n", + "cnt = pd.Series([s for c in st[st[\"상태\"] == \"단절\"][\"closed\"] for s in c.split(\"+\")]).value_counts()\n", + "print(f\" 단절 조합에 자주 포함된 구간: {', '.join(f'{k}({v})' for k, v in cnt.head(4).items())} -> 이중화(우회로 확보) 우선 후보\")\n", + "\n", + "# ---------------- 3) 대시보드 갱신 부하: 구간 예측 + 최대유량 + 배정을 반복 ----------------\n", + "df = make_data(); d = add_features(df)\n", + "tr, te = d[d.day < 120], d[d.day >= 120].copy()\n", + "gbr = GradientBoostingRegressor(n_estimators=200, max_depth=3, learning_rate=0.08, random_state=0).fit(tr[FEATS], tr[\"delay\"])\n", + "\n", + "\n", + "def refresh(day, closed=()):\n", + " x = te[te.day == day]\n", + " pred = gbr.predict(x[FEATS])\n", + " flow, cut = max_flow(closed)\n", + " r = assign(float(x[x.seg.isin([\"A1\", \"B1\", \"C1\"])][\"plan_v\"].sum()), closed)\n", + " return pred, flow, cut, r\n", + "\n", + "\n", + "lat = []\n", + "rng = np.random.default_rng(8)\n", + "combos = list(itertools.combinations(CAP, 2))\n", + "for k in range(60):\n", + " c = combos[int(rng.integers(len(combos)))]\n", + " t1 = time.perf_counter(); refresh(120 + k % 30, c); lat.append(time.perf_counter() - t1)\n", + "lat = np.array(lat)\n", + "print(f\"\\n[3] 대시보드 데이터 갱신 60회(구간 2개 폐쇄 무작위): 평균 {lat.mean() * 1000:.0f}ms, 최대 {lat.max() * 1000:.0f}ms (목표 2초 이내: {'달성' if lat.max() < 2 else '미달'}; 화면 렌더링 시간은 제외)\")\n", + "\n", + "# ---------------- 4) 경보 정확도: 예측 지체 5분 이상 경보 vs 실제 5분 이상 ----------------\n", + "te[\"pred\"] = gbr.predict(te[FEATS])\n", + "a_pred, a_true = te[\"pred\"] >= 5, te[\"delay\"] >= 5\n", + "acc = float((a_pred == a_true).mean())\n", + "tp, fp, fn = int((a_pred & a_true).sum()), int((a_pred & ~a_true).sum()), int((~a_pred & a_true).sum())\n", + "print(f\"[4] 경보 정확도: {acc:.1%} (목표 95% 이상: {'달성' if acc >= .95 else '미달'}), 실제 지체 5분 이상 {int(a_true.sum())}건 중 탐지 {tp}건(재현율 {tp / max(tp + fn, 1):.0%}), 오경보 {fp}건\")\n", + "print(f\" 경보 기준을 3분으로 낮추면: \", end=\"\")\n", + "a3 = te[\"pred\"] >= 3; t3 = te[\"delay\"] >= 5\n", + "print(f\"재현율 {float((a3 & t3).sum() / max(t3.sum(), 1)):.0%}, 오경보 {int((a3 & ~t3).sum())}건 -> 놓치지 않는 쪽이 중요하면 기준을 낮추되 오경보 피로를 함께 본다\")\n", + "print(\"모든 모의는 교육용 가상 노선이며, 실제 봉쇄·우회 결심은 지휘계통(작전군수관 승인)에 따른다.\")\n", + "\n", + "fig, ax = plt.subplots(1, 3, figsize=(15, 4))\n", + "vc.rename(index={\"단절\": \"cut off\", \"수요 초과\": \"over demand\", \"수용\": \"ok\", \"심각 지체\": \"severe delay\"}).plot.bar(ax=ax[0], color=\"tab:blue\")\n", + "ax[0].set_title(\"120 triple-closure outcomes\"); ax[0].set_xlabel(\"\"); ax[0].tick_params(axis=\"x\", rotation=15)\n", + "ax[1].hist(ok_rows[\"avg_min\"], bins=15); ax[1].axvline(base[\"avg_min\"], c=\"r\"); ax[1].set_title(\"Avg transit (min) of feasible combos\")\n", + "ax[2].hist(lat * 1000, bins=15); ax[2].set_title(\"Refresh latency (ms)\")\n", + "plt.tight_layout(); plt.show()\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 08 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + "[0] 평시: 수요 150대/시, 평균 통과 37.8분, 최대유량 290대/시\n", + "[1] 3개 축선 접근 구간(A3·B3·C2) 동시 봉쇄: 최대유량 0대/시, 경로 8/8개 차단 -> 도로 수송 불가. 공중·해상 등 타 수단 또는 물량 보류 결정이 필요하며 사람이 판단한다\n", + "\n", + "[2] 120개 조합 평가(0.12초): 수요 초과 73, 수용 44, 단절 3\n", + " 수용 가능 조합의 평균 통과시간 43.3분(평시 37.8분), 최악 수용 조합 A1+A2+B3 52.5분\n", + " 단절 조합에 자주 포함된 구간: C1(2), A3(2), B3(2), A1(1) -> 이중화(우회로 확보) 우선 후보\n", + "\n", + "[3] 대시보드 데이터 갱신 60회(구간 2개 폐쇄 무작위): 평균 7ms, 최대 10ms (목표 2초 이내: 달성; 화면 렌더링 시간은 제외)\n", + "[4] 경보 정확도: 97.0% (목표 95% 이상: 달성), 실제 지체 5분 이상 22건 중 탐지 15건(재현율 68%), 오경보 2건\n", + " 경보 기준을 3분으로 낮추면: 재현율 91%, 오경보 46건 -> 놓치지 않는 쪽이 중요하면 기준을 낮추되 오경보 피로를 함께 본다\n", + "모든 모의는 교육용 가상 노선이며, 실제 봉쇄·우회 결심은 지휘계통(작전군수관 승인)에 따른다.\n", + "```\n", + "
\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 09. AAR 기반 병목 예측 오차 분석 및 도로 통과 용량 파라미터 재보정\n", + "**소요시간**: 약 3.0시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "AAR 기반 병목 예측 오차를 분석하고 통과 용량을 재보정한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **AAR 관점에서 병목 예측 오차를 원인별로 분석하고 도로 통과 용량 파라미터를 재보정하며, 효과를 ROI로 환산한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **기준 BPR의 오차를 정비일, 긴급 불출일, 우천일, 정상일로 나누어 평균 오차를 비교 수행하고 검증하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `reports/mov01_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 관점에서 BPR 기준 모델의 병목 예측 오차를 원인별로 분석하고, 최소제곱으로 구간별 유효 통과 용량 배율과 지체 함수 파라미터를 재보정해 검증 구간 오차를 줄인 뒤, 효과를 비용 절감액·시간 단축률·경보 포착률 3대 지표로 환산하고 민감도를 분석한다.\"\"\"\n", + "import numpy as np\n", + "import pandas as pd\n", + "import matplotlib.pyplot as plt\n", + "from scipy.optimize import least_squares, linprog\n", + "\n", + "# ---------------------------------------------------------------------------\n", + "# [교육용가상자료] 가상 노선망·구간 통과량 생성기 (실제 도로·지명·부대 없음)\n", + "# 가상 출발지(SRC)에서 가상 목적지(DST)까지 3개 축선(A·B·C)과 연결로 2개, 구간 10개. 150일치 피크시간 통과량·지체 시간을 만든다.\n", + "# ---------------------------------------------------------------------------\n", + "SEGS = pd.DataFrame([\n", + " (\"A1\", \"SRC\", \"J1\", 12, 130, \"일반\"), (\"A2\", \"J1\", \"J2\", 8, 70, \"교량\"), (\"A3\", \"J2\", \"DST\", 10, 140, \"교차로\"),\n", + " (\"B1\", \"SRC\", \"J3\", 15, 100, \"일반\"), (\"B2\", \"J3\", \"J2\", 9, 80, \"터널\"), (\"B3\", \"J3\", \"DST\", 14, 95, \"일반\"),\n", + " (\"C1\", \"SRC\", \"J4\", 18, 70, \"일반\"), (\"C2\", \"J4\", \"DST\", 20, 75, \"교량\"),\n", + " (\"X1\", \"J1\", \"J3\", 7, 50, \"교차로\"), (\"X2\", \"J4\", \"J3\", 10, 60, \"일반\")],\n", + " columns=[\"seg\", \"u\", \"v\", \"km\", \"cap\", \"kind\"]) # cap = 피크시간 통과 용량(대/시, 교육용 가정값)\n", + "KSPD = {\"일반\": 55, \"교량\": 40, \"터널\": 45, \"교차로\": 45} # 구간 유형별 자유 주행 속도(km/h, 가정값)\n", + "SEGS[\"t0\"] = SEGS[\"km\"] / SEGS[\"kind\"].map(KSPD) * 60 # 자유 주행 시간(분)\n", + "UTIL = dict(A1=.78, A2=.85, A3=.66, B1=.70, B2=.72, B3=.64, C1=.55, C2=.58, X1=.45, X2=.40) # 평시 이용률\n", + "\n", + "\n", + "def bpr(t0, v, cap, a=0.15, b=4.0):\n", + " \"\"\"BPR 지체 함수(교통공학의 표준형): 통과량/용량 비가 커질수록 지체가 급증한다. (분)\"\"\"\n", + " return t0 * a * (v / cap) ** b\n", + "\n", + "\n", + "def make_data(seed=11, days=150):\n", + " \"\"\"구간 x 일자 패널. v=실제 피크 통과량, plan_v=수송계획 수량(긴급 불출 미반영), delay=실제 지체(분).\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " d_idx = np.arange(days)\n", + " lvl = np.array([1.0, 1.08, 1.04, 1.10, 1.02, 0.82, 0.78])[d_idx % 7] * (1 + 0.0012 * d_idx)\n", + " lvl = lvl * np.where((d_idx >= 100) & (d_idx < 110), 1.30, 1.0) # 수송량 폭증 기간(가상)\n", + " prev, rows = {s: 0.0 for s in SEGS[\"seg\"]}, []\n", + " for d in d_idx:\n", + " rain = bool(rng.random() < 0.18)\n", + " for r in SEGS.itertuples():\n", + " maint, urgent = bool(rng.random() < 0.06), bool(rng.random() < 0.05) # 비계획 정비 / 긴급 불출\n", + " v = r.cap * UTIL[r.seg] * lvl[d] * (1.25 if urgent else 1.0) * rng.normal(1, 0.05)\n", + " cap_eff = r.cap * (0.65 if maint else 1.0) * (0.92 if rain else 1.0)\n", + " delay = max(r.t0 * 0.2 * (v / cap_eff) ** 3.5 + 0.25 * prev[r.seg] + rng.normal(0, 0.4), 0.0)\n", + " prev[r.seg] = delay\n", + " rows.append(dict(day=int(d), dow=int(d % 7), seg=r.seg, kind=r.kind, km=r.km, cap=r.cap, t0=r.t0,\n", + " plan_v=v / (1.25 if urgent else 1.0) * rng.normal(1, 0.04), v=v, maint=int(maint), urgent=int(urgent),\n", + " rain=int(rain), cap_eff=cap_eff, delay=delay))\n", + " df = pd.DataFrame(rows)\n", + " df[\"transit\"] = df[\"t0\"] * (1 + 0.08 * df[\"rain\"]) + df[\"delay\"]\n", + " return df\n", + "\n", + "\n", + "def mape(y, p):\n", + " y, p = np.asarray(y, float), np.asarray(p, float)\n", + " return float(np.mean(np.abs(y - p) / np.abs(y)) * 100)\n", + "\n", + "\n", + "def mae(y, p):\n", + " return float(np.mean(np.abs(np.asarray(y, float) - np.asarray(p, float))))\n", + "# ---------------------------------------------------------------------------\n", + "# ---------------------------------------------------------------------------\n", + "# 지체 예측용 파생 피처 생성(당일 오전 시점에 알 수 있는 정보만 사용: 계획 물량, 공지 정비, 기상 예보, 전일·전주 실적)\n", + "# ---------------------------------------------------------------------------\n", + "FEATS = [\"plan_v\", \"cap\", \"cap_eff\", \"t0\", \"km\", \"maint\", \"rain\", \"util\", \"util_eff\", \"util_eff4\", \"bpr_base\", \"lag1_delay\", \"lag7_delay\",\n", + " \"roll7_v\", \"surge_ratio\", \"is_weekend\", \"dow_sin\", \"dow_cos\", \"k_교량\", \"k_터널\", \"k_교차로\"]\n", + "\n", + "\n", + "def add_features(df):\n", + " d = df.sort_values([\"seg\", \"day\"]).copy()\n", + " g = d.groupby(\"seg\")\n", + " d[\"lag1_delay\"] = g[\"delay\"].shift(1)\n", + " d[\"lag7_delay\"] = g[\"delay\"].shift(7)\n", + " d[\"roll7_v\"] = g[\"v\"].transform(lambda s: s.shift(1).rolling(7).mean())\n", + " d[\"util\"] = d[\"plan_v\"] / d[\"cap\"]\n", + " d[\"util_eff\"] = d[\"plan_v\"] / d[\"cap_eff\"]\n", + " d[\"util_eff4\"] = d[\"util_eff\"] ** 4\n", + " d[\"bpr_base\"] = bpr(d[\"t0\"], d[\"plan_v\"], d[\"cap\"])\n", + " d[\"surge_ratio\"] = d[\"plan_v\"] / d[\"roll7_v\"]\n", + " d[\"is_weekend\"] = (d[\"dow\"] >= 5).astype(int)\n", + " d[\"dow_sin\"], d[\"dow_cos\"] = np.sin(2 * np.pi * d[\"dow\"] / 7), np.cos(2 * np.pi * d[\"dow\"] / 7)\n", + " for k in (\"교량\", \"터널\", \"교차로\"):\n", + " d[\"k_\" + k] = (d[\"kind\"] == k).astype(int)\n", + " return d.dropna().sort_values([\"day\", \"seg\"]).reset_index(drop=True)\n", + "# ---------------------------------------------------------------------------\n", + "# ---------------------------------------------------------------------------\n", + "# 노선망 흐름 도구: 최대유량(Edmonds-Karp), 증분 배정(혼잡 반영 최단시간 경로), 경로 열거\n", + "# ---------------------------------------------------------------------------\n", + "CAP = dict(zip(SEGS[\"seg\"], SEGS[\"cap\"]))\n", + "T0 = dict(zip(SEGS[\"seg\"], SEGS[\"t0\"]))\n", + "KM = dict(zip(SEGS[\"seg\"], SEGS[\"km\"]))\n", + "ARCS = {r.seg: (r.u, r.v) for r in SEGS.itertuples()}\n", + "SRC, DST = \"SRC\", \"DST\"\n", + "\n", + "\n", + "def seg_time(s, flow, cap=None, a=0.2, b=3.5):\n", + " \"\"\"구간 통과시간(분) = 자유주행 + 혼잡 지체. cap을 주면 용량을 대체(정비·기상 반영).\"\"\"\n", + " r = flow / (cap or CAP[s])\n", + " return T0[s] * (1 + a * r ** b + 6.0 * max(0.0, r - 1.0)) # 용량 초과분은 대기열로 급증\n", + "\n", + "\n", + "def max_flow(closed=(), capmul=None):\n", + " \"\"\"최대유량(대/시)과 최소 절단 구간(병목). capmul = {구간: 용량 배율}.\"\"\"\n", + " cap = {s: (0 if s in closed else CAP[s] * (capmul or {}).get(s, 1.0)) for s in CAP}\n", + " res = {}\n", + " for s, (u, v) in ARCS.items():\n", + " res[(u, v)] = res.get((u, v), 0) + cap[s]\n", + " res.setdefault((v, u), 0)\n", + " flow = 0.0\n", + " while True:\n", + " prev, q = {SRC: None}, [SRC]\n", + " for u in q:\n", + " for (a, b), c in res.items():\n", + " if a == u and c > 1e-9 and b not in prev:\n", + " prev[b] = u; q.append(b)\n", + " if DST not in prev:\n", + " break\n", + " path, x = [], DST\n", + " while prev[x]:\n", + " path.append((prev[x], x)); x = prev[x]\n", + " f = min(res[e] for e in path)\n", + " for a, b in path:\n", + " res[(a, b)] -= f; res[(b, a)] += f\n", + " flow += f\n", + " reach = set(prev)\n", + " cut = [s for s, (u, v) in ARCS.items() if u in reach and v not in reach and cap[s] > 0]\n", + " return flow, cut\n", + "\n", + "\n", + "def all_paths():\n", + " \"\"\"SRC -> DST 단순 경로(구간 이름 튜플) 전체.\"\"\"\n", + " out = []\n", + "\n", + " def dfs(node, segs, seen):\n", + " if node == DST:\n", + " out.append(tuple(segs)); return\n", + " for s, (u, v) in ARCS.items():\n", + " if u == node and v not in seen:\n", + " dfs(v, segs + [s], seen | {v})\n", + " dfs(SRC, [], {SRC})\n", + " return out\n", + "\n", + "\n", + "PATHS = all_paths()\n", + "\n", + "\n", + "def assign(demand, closed=(), capmul=None, steps=40):\n", + " \"\"\"증분 배정: 수요를 steps조각으로 나눠, 전체 통과시간(대수 x 시간) 증가가 가장 작은 경로에 차례로 얹는다(시스템 최적 근사).\"\"\"\n", + " cap = {s: CAP[s] * (capmul or {}).get(s, 1.0) for s in CAP}\n", + " flow, pf = {s: 0.0 for s in CAP}, {}\n", + " ok = [p for p in PATHS if not any(s in closed for s in p)]\n", + " if not ok:\n", + " return None\n", + " for _ in range(steps):\n", + " chunk = demand / steps\n", + " best = min(ok, key=lambda p: sum((flow[s] + chunk) * seg_time(s, flow[s] + chunk, cap[s]) - flow[s] * seg_time(s, flow[s], cap[s]) for s in p))\n", + " for s in best:\n", + " flow[s] += chunk\n", + " pf[best] = pf.get(best, 0) + chunk\n", + " tt = {p: sum(seg_time(s, flow[s], cap[s]) for s in p) for p in pf}\n", + " avg = sum(pf[p] * tt[p] for p in pf) / demand\n", + " return {\"flow\": flow, \"paths\": pf, \"path_min\": tt, \"avg_min\": avg, \"util\": {s: flow[s] / cap[s] for s in cap if cap[s] > 0}}\n", + "\n", + "\n", + "FIXED_SHARE = {(\"A1\", \"A2\", \"A3\"): 0.32, (\"B1\", \"B3\"): 0.40, (\"C1\", \"C2\"): 0.28} # 과거 관행: 축선별 고정 배분(가상)\n", + "\n", + "\n", + "def fixed_assign(demand, closed=(), capmul=None):\n", + " \"\"\"과거 관행 배분: 축선별 고정 비율. 막힌 축선 물량은 열려 있는 축선에 비율대로 재분배.\"\"\"\n", + " cap = {s: CAP[s] * (capmul or {}).get(s, 1.0) for s in CAP}\n", + " live = {p: w for p, w in FIXED_SHARE.items() if not any(s in closed for s in p)}\n", + " if not live:\n", + " return None\n", + " tot = sum(live.values())\n", + " flow = {s: 0.0 for s in CAP}\n", + " pf = {p: demand * w / tot for p, w in live.items()}\n", + " for p, f in pf.items():\n", + " for s in p:\n", + " flow[s] += f\n", + " tt = {p: sum(seg_time(s, flow[s], cap[s]) for s in p) for p in pf}\n", + " return {\"flow\": flow, \"paths\": pf, \"path_min\": tt, \"avg_min\": sum(pf[p] * tt[p] for p in pf) / demand,\n", + " \"util\": {s: flow[s] / cap[s] for s in cap if cap[s] > 0}}\n", + "\n", + "\n", + "def lp_assign(demand, closed=(), capmul=None):\n", + " \"\"\"선형계획(LP): 구간 용량 상한(이용률 rho) 아래에서 자유주행시간 합을 최소화하는 경로별 물량. 불가능하면 rho를 1.0까지 완화.\"\"\"\n", + " cap = {s: CAP[s] * (capmul or {}).get(s, 1.0) for s in CAP}\n", + " ok = [p for p in PATHS if not any(s in closed for s in p)]\n", + " if not ok:\n", + " return None\n", + " for rho in (0.85, 0.95, 1.0):\n", + " r = linprog([sum(T0[s] for s in p) for p in ok], A_ub=[[1.0 if s in p else 0.0 for p in ok] for s in CAP],\n", + " b_ub=[rho * cap[s] for s in CAP], A_eq=[[1.0] * len(ok)], b_eq=[demand], bounds=(0, None), method=\"highs\")\n", + " if r.status == 0:\n", + " break\n", + " else:\n", + " return assign(demand, closed, capmul) # 용량 초과 수요: 증분 배정으로 대체(과부하 표시)\n", + " flow = {s: sum(r.x[i] for i, p in enumerate(ok) if s in p) for s in CAP}\n", + " pf = {p: float(r.x[i]) for i, p in enumerate(ok) if r.x[i] > 1e-6}\n", + " tt = {p: sum(seg_time(s, flow[s], cap[s]) for s in p) for p in pf}\n", + " return {\"flow\": flow, \"paths\": pf, \"path_min\": tt, \"avg_min\": sum(pf[p] * tt[p] for p in pf) / demand,\n", + " \"util\": {s: flow[s] / cap[s] for s in cap if cap[s] > 0}, \"rho\": rho}\n", + "\n", + "\n", + "def cost_of(res, tv=1000, fuel_km=450):\n", + " \"\"\"시간당 비용(원, 교육용 가정): 대당 시간비용 tv원/분 + 유류비 fuel_km원/km.\"\"\"\n", + " return sum(f * (res[\"path_min\"][p] * tv + sum(KM[s] for s in p) * fuel_km) for p, f in res[\"paths\"].items())\n", + "# ---------------------------------------------------------------------------\n", + "\n", + "df = make_data(); d = add_features(df)\n", + "SEGL = list(SEGS[\"seg\"])\n", + "cal, val = d[d.day < 135], d[d.day >= 135] # AAR 대상(보정) 구간 / 검증 구간\n", + "\n", + "\n", + "def model(p, x):\n", + " \"\"\"보정 모델: a, b, c(전일 지체 이월), 구간별 용량 배율 m_s, 정비 시 용량 배율, 우천 시 용량 배율.\"\"\"\n", + " a, b, c, mm, rr = p[0], p[1], p[2], p[3], p[4]\n", + " m = pd.Series(p[5:], index=SEGL)[x[\"seg\"]].values\n", + " eff = x[\"cap\"].values * m * np.where(x[\"maint\"] == 1, mm, 1.0) * np.where(x[\"rain\"] == 1, rr, 1.0)\n", + " return x[\"t0\"].values * a * (x[\"plan_v\"].values / eff) ** b + c * x[\"lag1_delay\"].values\n", + "\n", + "\n", + "p0 = np.r_[0.15, 4.0, 0.0, 1.0, 1.0, np.ones(len(SEGL))]\n", + "lo = np.r_[0.01, 1.0, 0.0, 0.3, 0.5, np.full(len(SEGL), 0.5)]\n", + "hi = np.r_[1.0, 6.0, 0.8, 1.2, 1.2, np.full(len(SEGL), 1.5)]\n", + "fit = least_squares(lambda p: model(p, cal) - cal[\"delay\"].values, p0, bounds=(lo, hi))\n", + "P = fit.x\n", + "\n", + "# ---------------- 1) 오차 분석(AAR) ----------------\n", + "base_cal, base_val = cal[\"bpr_base\"].values, val[\"bpr_base\"].values\n", + "new_val = model(P, val)\n", + "print(f\"[1] 검증 구간(day 135~149, {len(val)}행) 지체 MAE: 기준 {mae(val.delay, base_val):.2f}분 -> 재보정 {mae(val.delay, new_val):.2f}분 ({1 - mae(val.delay, new_val) / mae(val.delay, base_val):.0%} 개선)\")\n", + "print(f\" 통과시간 MAPE: 기준 {mape(val.transit, val.t0 + base_val):.2f}% -> 재보정 {mape(val.transit, val.t0 * (1 + .08 * val.rain) + new_val):.2f}%\")\n", + "err = pd.DataFrame({\"원인\": [\"비계획 정비일\", \"긴급 불출일\", \"우천일\", \"정상일\"],\n", + " \"건수\": [int((cal.maint == 1).sum()), int((cal.urgent == 1).sum()), int((cal.rain == 1).sum()), int(((cal.maint + cal.urgent + cal.rain) == 0).sum())],\n", + " \"기준 평균오차\": [(base_cal - cal.delay)[m].mean() for m in (cal.maint == 1, cal.urgent == 1, cal.rain == 1, (cal.maint + cal.urgent + cal.rain) == 0)],\n", + " \"재보정 평균오차\": [(model(P, cal) - cal.delay)[m].mean() for m in (cal.maint == 1, cal.urgent == 1, cal.rain == 1, (cal.maint + cal.urgent + cal.rain) == 0)]}).round(2)\n", + "print(\"[2] AAR 원인별 평균 오차(예측-실제, 분; 음수 = 과소예측)\")\n", + "print(err.to_string(index=False))\n", + "pm = pd.DataFrame({\"파라미터\": [\"a(지체 계수)\", \"b(비선형 지수)\", \"c(전일 이월)\", \"정비 시 용량 배율\", \"우천 시 용량 배율\"], \"공칭/기준\": [0.15, 4.0, 0.0, 1.0, 1.0], \"재보정\": P[:5]}).round(3)\n", + "print(\"[3] 재보정 파라미터:\", \"; \".join(f\"{r['파라미터']} {r['공칭/기준']} -> {r['재보정']}\" for _, r in pm.iterrows()))\n", + "ms = pd.Series(P[5:], index=SEGL).round(2)\n", + "ms_txt = {k: float(v) for k, v in ms.items()}\n", + "print(\" 구간별 유효 용량 배율:\", ms_txt, \"(1.0 = 공칭 용량과 일치)\")\n", + "\n", + "# ---------------- 2) 효과 환산: 검증 15일 백테스트 ----------------\n", + "TV = 300 # 대당 분당 시간가치(원, 가정)\n", + "hours_per_day, APPLY = 2, 0.4 # 일 피크 2시간, 승인·현장 제약으로 실제 적용되는 비율 40%(가정)\n", + "rec = []\n", + "for day, g in df[df.day >= 135].groupby(\"day\"):\n", + " D = float(g[g.seg.isin([\"A1\", \"B1\", \"C1\"])][\"plan_v\"].sum())\n", + " cm = {r.seg: r.cap_eff / r.cap for r in g.itertuples() if r.cap_eff < r.cap}\n", + " f, l = fixed_assign(D, (), cm), lp_assign(D, (), cm)\n", + " pr_b, pr_n = val[val.day == day], val[val.day == day]\n", + " rec.append((day, cost_of(f, tv=TV), cost_of(l, tv=TV), f[\"avg_min\"], l[\"avg_min\"], D, float(g.delay.max()),\n", + " float((pr_b.bpr_base).max()), float(model(P, pr_n).max())))\n", + "bt = pd.DataFrame(rec, columns=[\"day\", \"c_fix\", \"c_lp\", \"t_fix\", \"t_lp\", \"D\", \"max_delay\", \"pred_base\", \"pred_new\"])\n", + "saving_day = (bt[\"c_fix\"] - bt[\"c_lp\"]) * hours_per_day * APPLY\n", + "ann = saving_day.mean() * 365\n", + "time_cut = 1 - bt[\"t_lp\"].sum() / bt[\"t_fix\"].sum()\n", + "veh_min = float(((bt[\"t_fix\"] - bt[\"t_lp\"]) * bt[\"D\"] * hours_per_day * APPLY).sum())\n", + "alert_true = bt[\"max_delay\"] >= 5\n", + "cap_b = float(((bt[\"pred_base\"] >= 5) & alert_true).sum() / max(alert_true.sum(), 1)); cap_n = float(((bt[\"pred_new\"] >= 5) & alert_true).sum() / max(alert_true.sum(), 1))\n", + "fa_b, fa_n = int(((bt[\"pred_base\"] >= 5) & ~alert_true).sum()), int(((bt[\"pred_new\"] >= 5) & ~alert_true).sum())\n", + "print(f\"\\n[4] 3대 정량 효과 지표(검증 15일, 고정 배분 대비 LP 우회 최적화, 가정 단가 사용)\")\n", + "print(f\" (1) 비용 절감액: 일 평균 {saving_day.mean() / 1e4:,.0f}만원, 연 환산 {ann / 1e8:,.1f}억원(가정: 일 피크 2시간, 적용률 40%, 365일)\")\n", + "print(f\" (2) 시간 단축률: 평균 통과시간 {bt['t_fix'].mean():.1f}분 -> {bt['t_lp'].mean():.1f}분 ({time_cut:.1%}), 절감 차량·시간 {veh_min / 60:,.0f}대·시간\")\n", + "print(f\" (3) 지체 경보 포착률(실제 지체 5분 이상 {int(alert_true.sum())}일 대비): 기준 {cap_b:.0%} -> 재보정 {cap_n:.0%} (오경보 {fa_b} -> {fa_n}일)\")\n", + "\n", + "# ---------------- 3) ROI와 민감도(가정 도입·운영비) ----------------\n", + "COST = 2.0e8 # 도입·운영 연 비용 2억원(교육용 가정)\n", + "roi = (ann - COST) / COST\n", + "print(f\"\\n[5] ROI = (연 절감 - 비용) / 비용 = {roi:.1%} (연 비용 {COST / 1e8:.1f}억원 가정), 회수 기간 {COST / ann * 12:.1f}개월\")\n", + "sens = pd.DataFrame({\"시간가치 -30%\": [ann * 0.7], \"기준\": [ann], \"시간가치 +30%\": [ann * 1.3]}).T[0]\n", + "low_eff = ann * 0.5\n", + "print(\" 민감도(연 절감, 억원):\", {k: round(float(v) / 1e8, 1) for k, v in sens.items()}, f\"| 효과가 절반이면 ROI {(low_eff - COST) / COST:.1%}\")\n", + "print(\" 단계별 로드맵: 단기(1~3개월) 경보 기준 정착 -> 중기(6개월) 구간별 용량 배율 월 단위 재보정 -> 장기(1년) 상위 정보체계 연동 검토\")\n", + "print(\" 추정 산식은 가정 단가에 크게 의존하므로 실제 적용 때는 기획·재정 부서가 단가를 제시해야 하며 본 값은 학습용이다.\")\n", + "\n", + "fig, ax = plt.subplots(1, 3, figsize=(15, 4))\n", + "ax[0].scatter(val[\"delay\"], base_val, s=8, label=\"baseline\"); ax[0].scatter(val[\"delay\"], new_val, s=8, label=\"recalibrated\"); ax[0].plot([0, 15], [0, 15], \"k--\"); ax[0].legend(); ax[0].set_title(\"Actual vs predicted delay (min)\")\n", + "ax[1].bar(ms.index, ms.values); ax[1].axhline(1, c=\"k\", ls=\"--\"); ax[1].set_title(\"Effective capacity multiplier by segment\")\n", + "ax[2].bar([\"-30%\", \"base\", \"+30%\"], [v / 1e8 for v in sens.values]); ax[2].set_title(\"Annual saving sensitivity (100M won)\")\n", + "plt.tight_layout(); plt.show()\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 09 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + "[1] 검증 구간(day 135~149, 150행) 지체 MAE: 기준 1.69분 -> 재보정 0.54분 (68% 개선)\n", + " 통과시간 MAPE: 기준 10.02% -> 재보정 3.05%\n", + "[2] AAR 원인별 평균 오차(예측-실제, 분; 음수 = 과소예측)\n", + " 원인 건수 기준 평균오차 재보정 평균오차\n", + "비계획 정비일 78 -4.73 -0.03\n", + " 긴급 불출일 69 -2.40 -1.33\n", + " 우천일 160 -1.55 0.01\n", + " 정상일 994 -0.81 0.09\n", + "[3] 재보정 파라미터: a(지체 계수) 0.15 -> 0.197; b(비선형 지수) 4.0 -> 3.091; c(전일 이월) 0.0 -> 0.258; 정비 시 용량 배율 1.0 -> 0.623; 우천 시 용량 배율 1.0 -> 0.922\n", + " 구간별 유효 용량 배율: {'A1': 0.98, 'A2': 0.98, 'A3': 1.04, 'B1': 1.01, 'B2': 0.98, 'B3': 1.04, 'C1': 1.0, 'C2': 1.0, 'X1': 0.97, 'X2': 1.03} (1.0 = 공칭 용량과 일치)\n", + "\n", + "[4] 3대 정량 효과 지표(검증 15일, 고정 배분 대비 LP 우회 최적화, 가정 단가 사용)\n", + " (1) 비용 절감액: 일 평균 90만원, 연 환산 3.3억원(가정: 일 피크 2시간, 적용률 40%, 365일)\n", + " (2) 시간 단축률: 평균 통과시간 63.3분 -> 47.7분 (24.6%), 절감 차량·시간 816대·시간\n", + " (3) 지체 경보 포착률(실제 지체 5분 이상 7일 대비): 기준 0% -> 재보정 86% (오경보 0 -> 0일)\n", + "\n", + "[5] ROI = (연 절감 - 비용) / 비용 = 64.9% (연 비용 2.0억원 가정), 회수 기간 7.3개월\n", + " 민감도(연 절감, 억원): {'시간가치 -30%': 2.3, '기준': 3.3, '시간가치 +30%': 4.3} | 효과가 절반이면 ROI -17.5%\n", + " 단계별 로드맵: 단기(1~3개월) 경보 기준 정착 -> 중기(6개월) 구간별 용량 배율 월 단위 재보정 -> 장기(1년) 상위 정보체계 연동 검토\n", + " 추정 산식은 가정 단가에 크게 의존하므로 실제 적용 때는 기획·재정 부서가 단가를 제시해야 하며 본 값은 학습용이다.\n", + "```\n", + "
\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 10. 전시 수송 병목 구간 식별 및 우회 수송 시뮬레이션 도구 통합 패키지 완성\n", + "**소요시간**: 약 3.5시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "전시 수송 병목 구간 식별 및 우회 수송 시뮬레이션 도구를 완성한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **데이터 갱신부터 정합성 검증, 드리프트 점검, 자동 재학습, 7일 소요 예측, 우회 배분, 승인대기 분리까지 한 번에 실행되는 통합 파이프라인을 완성한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **신규 30일 데이터를 추가해 정합성을 검증하고, 기존 모델의 오차로 드리프트를 판단한 뒤 챌린저 모델을 학습해 개선이 3% 이상일 때만 승격 수행하고 검증하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `docs/mov01_operations_guide.md 및 pipeline/mov01_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": [ + "\"\"\"데이터 갱신, 정합성 검증, 드리프트 점검, 자동 재학습(챔피언/챌린저), 7일 수송 소요 예측, 병목·우회 배분, 승인대기 분리까지 한 번에 실행하는 통합 파이프라인을 완성하고 운영 문서 완비율·자체 점검·인터페이스 명세·브리핑 요약을 출력한다.\"\"\"\n", + "import importlib\n", + "import json\n", + "import sys\n", + "import numpy as np\n", + "import pandas as pd\n", + "import matplotlib.pyplot as plt\n", + "from scipy.optimize import linprog\n", + "from sklearn.ensemble import GradientBoostingRegressor\n", + "from sklearn.linear_model import Ridge\n", + "\n", + "# ---------------------------------------------------------------------------\n", + "# [교육용가상자료] 가상 노선망·구간 통과량 생성기 (실제 도로·지명·부대 없음)\n", + "# 가상 출발지(SRC)에서 가상 목적지(DST)까지 3개 축선(A·B·C)과 연결로 2개, 구간 10개. 150일치 피크시간 통과량·지체 시간을 만든다.\n", + "# ---------------------------------------------------------------------------\n", + "SEGS = pd.DataFrame([\n", + " (\"A1\", \"SRC\", \"J1\", 12, 130, \"일반\"), (\"A2\", \"J1\", \"J2\", 8, 70, \"교량\"), (\"A3\", \"J2\", \"DST\", 10, 140, \"교차로\"),\n", + " (\"B1\", \"SRC\", \"J3\", 15, 100, \"일반\"), (\"B2\", \"J3\", \"J2\", 9, 80, \"터널\"), (\"B3\", \"J3\", \"DST\", 14, 95, \"일반\"),\n", + " (\"C1\", \"SRC\", \"J4\", 18, 70, \"일반\"), (\"C2\", \"J4\", \"DST\", 20, 75, \"교량\"),\n", + " (\"X1\", \"J1\", \"J3\", 7, 50, \"교차로\"), (\"X2\", \"J4\", \"J3\", 10, 60, \"일반\")],\n", + " columns=[\"seg\", \"u\", \"v\", \"km\", \"cap\", \"kind\"]) # cap = 피크시간 통과 용량(대/시, 교육용 가정값)\n", + "KSPD = {\"일반\": 55, \"교량\": 40, \"터널\": 45, \"교차로\": 45} # 구간 유형별 자유 주행 속도(km/h, 가정값)\n", + "SEGS[\"t0\"] = SEGS[\"km\"] / SEGS[\"kind\"].map(KSPD) * 60 # 자유 주행 시간(분)\n", + "UTIL = dict(A1=.78, A2=.85, A3=.66, B1=.70, B2=.72, B3=.64, C1=.55, C2=.58, X1=.45, X2=.40) # 평시 이용률\n", + "\n", + "\n", + "def bpr(t0, v, cap, a=0.15, b=4.0):\n", + " \"\"\"BPR 지체 함수(교통공학의 표준형): 통과량/용량 비가 커질수록 지체가 급증한다. (분)\"\"\"\n", + " return t0 * a * (v / cap) ** b\n", + "\n", + "\n", + "def make_data(seed=11, days=150):\n", + " \"\"\"구간 x 일자 패널. v=실제 피크 통과량, plan_v=수송계획 수량(긴급 불출 미반영), delay=실제 지체(분).\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " d_idx = np.arange(days)\n", + " lvl = np.array([1.0, 1.08, 1.04, 1.10, 1.02, 0.82, 0.78])[d_idx % 7] * (1 + 0.0012 * d_idx)\n", + " lvl = lvl * np.where((d_idx >= 100) & (d_idx < 110), 1.30, 1.0) # 수송량 폭증 기간(가상)\n", + " prev, rows = {s: 0.0 for s in SEGS[\"seg\"]}, []\n", + " for d in d_idx:\n", + " rain = bool(rng.random() < 0.18)\n", + " for r in SEGS.itertuples():\n", + " maint, urgent = bool(rng.random() < 0.06), bool(rng.random() < 0.05) # 비계획 정비 / 긴급 불출\n", + " v = r.cap * UTIL[r.seg] * lvl[d] * (1.25 if urgent else 1.0) * rng.normal(1, 0.05)\n", + " cap_eff = r.cap * (0.65 if maint else 1.0) * (0.92 if rain else 1.0)\n", + " delay = max(r.t0 * 0.2 * (v / cap_eff) ** 3.5 + 0.25 * prev[r.seg] + rng.normal(0, 0.4), 0.0)\n", + " prev[r.seg] = delay\n", + " rows.append(dict(day=int(d), dow=int(d % 7), seg=r.seg, kind=r.kind, km=r.km, cap=r.cap, t0=r.t0,\n", + " plan_v=v / (1.25 if urgent else 1.0) * rng.normal(1, 0.04), v=v, maint=int(maint), urgent=int(urgent),\n", + " rain=int(rain), cap_eff=cap_eff, delay=delay))\n", + " df = pd.DataFrame(rows)\n", + " df[\"transit\"] = df[\"t0\"] * (1 + 0.08 * df[\"rain\"]) + df[\"delay\"]\n", + " return df\n", + "\n", + "\n", + "def mape(y, p):\n", + " y, p = np.asarray(y, float), np.asarray(p, float)\n", + " return float(np.mean(np.abs(y - p) / np.abs(y)) * 100)\n", + "\n", + "\n", + "def mae(y, p):\n", + " return float(np.mean(np.abs(np.asarray(y, float) - np.asarray(p, float))))\n", + "# ---------------------------------------------------------------------------\n", + "# ---------------------------------------------------------------------------\n", + "# 지체 예측용 파생 피처 생성(당일 오전 시점에 알 수 있는 정보만 사용: 계획 물량, 공지 정비, 기상 예보, 전일·전주 실적)\n", + "# ---------------------------------------------------------------------------\n", + "FEATS = [\"plan_v\", \"cap\", \"cap_eff\", \"t0\", \"km\", \"maint\", \"rain\", \"util\", \"util_eff\", \"util_eff4\", \"bpr_base\", \"lag1_delay\", \"lag7_delay\",\n", + " \"roll7_v\", \"surge_ratio\", \"is_weekend\", \"dow_sin\", \"dow_cos\", \"k_교량\", \"k_터널\", \"k_교차로\"]\n", + "\n", + "\n", + "def add_features(df):\n", + " d = df.sort_values([\"seg\", \"day\"]).copy()\n", + " g = d.groupby(\"seg\")\n", + " d[\"lag1_delay\"] = g[\"delay\"].shift(1)\n", + " d[\"lag7_delay\"] = g[\"delay\"].shift(7)\n", + " d[\"roll7_v\"] = g[\"v\"].transform(lambda s: s.shift(1).rolling(7).mean())\n", + " d[\"util\"] = d[\"plan_v\"] / d[\"cap\"]\n", + " d[\"util_eff\"] = d[\"plan_v\"] / d[\"cap_eff\"]\n", + " d[\"util_eff4\"] = d[\"util_eff\"] ** 4\n", + " d[\"bpr_base\"] = bpr(d[\"t0\"], d[\"plan_v\"], d[\"cap\"])\n", + " d[\"surge_ratio\"] = d[\"plan_v\"] / d[\"roll7_v\"]\n", + " d[\"is_weekend\"] = (d[\"dow\"] >= 5).astype(int)\n", + " d[\"dow_sin\"], d[\"dow_cos\"] = np.sin(2 * np.pi * d[\"dow\"] / 7), np.cos(2 * np.pi * d[\"dow\"] / 7)\n", + " for k in (\"교량\", \"터널\", \"교차로\"):\n", + " d[\"k_\" + k] = (d[\"kind\"] == k).astype(int)\n", + " return d.dropna().sort_values([\"day\", \"seg\"]).reset_index(drop=True)\n", + "# ---------------------------------------------------------------------------\n", + "# ---------------------------------------------------------------------------\n", + "# 노선망 흐름 도구: 최대유량(Edmonds-Karp), 증분 배정(혼잡 반영 최단시간 경로), 경로 열거\n", + "# ---------------------------------------------------------------------------\n", + "CAP = dict(zip(SEGS[\"seg\"], SEGS[\"cap\"]))\n", + "T0 = dict(zip(SEGS[\"seg\"], SEGS[\"t0\"]))\n", + "KM = dict(zip(SEGS[\"seg\"], SEGS[\"km\"]))\n", + "ARCS = {r.seg: (r.u, r.v) for r in SEGS.itertuples()}\n", + "SRC, DST = \"SRC\", \"DST\"\n", + "\n", + "\n", + "def seg_time(s, flow, cap=None, a=0.2, b=3.5):\n", + " \"\"\"구간 통과시간(분) = 자유주행 + 혼잡 지체. cap을 주면 용량을 대체(정비·기상 반영).\"\"\"\n", + " r = flow / (cap or CAP[s])\n", + " return T0[s] * (1 + a * r ** b + 6.0 * max(0.0, r - 1.0)) # 용량 초과분은 대기열로 급증\n", + "\n", + "\n", + "def max_flow(closed=(), capmul=None):\n", + " \"\"\"최대유량(대/시)과 최소 절단 구간(병목). capmul = {구간: 용량 배율}.\"\"\"\n", + " cap = {s: (0 if s in closed else CAP[s] * (capmul or {}).get(s, 1.0)) for s in CAP}\n", + " res = {}\n", + " for s, (u, v) in ARCS.items():\n", + " res[(u, v)] = res.get((u, v), 0) + cap[s]\n", + " res.setdefault((v, u), 0)\n", + " flow = 0.0\n", + " while True:\n", + " prev, q = {SRC: None}, [SRC]\n", + " for u in q:\n", + " for (a, b), c in res.items():\n", + " if a == u and c > 1e-9 and b not in prev:\n", + " prev[b] = u; q.append(b)\n", + " if DST not in prev:\n", + " break\n", + " path, x = [], DST\n", + " while prev[x]:\n", + " path.append((prev[x], x)); x = prev[x]\n", + " f = min(res[e] for e in path)\n", + " for a, b in path:\n", + " res[(a, b)] -= f; res[(b, a)] += f\n", + " flow += f\n", + " reach = set(prev)\n", + " cut = [s for s, (u, v) in ARCS.items() if u in reach and v not in reach and cap[s] > 0]\n", + " return flow, cut\n", + "\n", + "\n", + "def all_paths():\n", + " \"\"\"SRC -> DST 단순 경로(구간 이름 튜플) 전체.\"\"\"\n", + " out = []\n", + "\n", + " def dfs(node, segs, seen):\n", + " if node == DST:\n", + " out.append(tuple(segs)); return\n", + " for s, (u, v) in ARCS.items():\n", + " if u == node and v not in seen:\n", + " dfs(v, segs + [s], seen | {v})\n", + " dfs(SRC, [], {SRC})\n", + " return out\n", + "\n", + "\n", + "PATHS = all_paths()\n", + "\n", + "\n", + "def assign(demand, closed=(), capmul=None, steps=40):\n", + " \"\"\"증분 배정: 수요를 steps조각으로 나눠, 전체 통과시간(대수 x 시간) 증가가 가장 작은 경로에 차례로 얹는다(시스템 최적 근사).\"\"\"\n", + " cap = {s: CAP[s] * (capmul or {}).get(s, 1.0) for s in CAP}\n", + " flow, pf = {s: 0.0 for s in CAP}, {}\n", + " ok = [p for p in PATHS if not any(s in closed for s in p)]\n", + " if not ok:\n", + " return None\n", + " for _ in range(steps):\n", + " chunk = demand / steps\n", + " best = min(ok, key=lambda p: sum((flow[s] + chunk) * seg_time(s, flow[s] + chunk, cap[s]) - flow[s] * seg_time(s, flow[s], cap[s]) for s in p))\n", + " for s in best:\n", + " flow[s] += chunk\n", + " pf[best] = pf.get(best, 0) + chunk\n", + " tt = {p: sum(seg_time(s, flow[s], cap[s]) for s in p) for p in pf}\n", + " avg = sum(pf[p] * tt[p] for p in pf) / demand\n", + " return {\"flow\": flow, \"paths\": pf, \"path_min\": tt, \"avg_min\": avg, \"util\": {s: flow[s] / cap[s] for s in cap if cap[s] > 0}}\n", + "\n", + "\n", + "FIXED_SHARE = {(\"A1\", \"A2\", \"A3\"): 0.32, (\"B1\", \"B3\"): 0.40, (\"C1\", \"C2\"): 0.28} # 과거 관행: 축선별 고정 배분(가상)\n", + "\n", + "\n", + "def fixed_assign(demand, closed=(), capmul=None):\n", + " \"\"\"과거 관행 배분: 축선별 고정 비율. 막힌 축선 물량은 열려 있는 축선에 비율대로 재분배.\"\"\"\n", + " cap = {s: CAP[s] * (capmul or {}).get(s, 1.0) for s in CAP}\n", + " live = {p: w for p, w in FIXED_SHARE.items() if not any(s in closed for s in p)}\n", + " if not live:\n", + " return None\n", + " tot = sum(live.values())\n", + " flow = {s: 0.0 for s in CAP}\n", + " pf = {p: demand * w / tot for p, w in live.items()}\n", + " for p, f in pf.items():\n", + " for s in p:\n", + " flow[s] += f\n", + " tt = {p: sum(seg_time(s, flow[s], cap[s]) for s in p) for p in pf}\n", + " return {\"flow\": flow, \"paths\": pf, \"path_min\": tt, \"avg_min\": sum(pf[p] * tt[p] for p in pf) / demand,\n", + " \"util\": {s: flow[s] / cap[s] for s in cap if cap[s] > 0}}\n", + "\n", + "\n", + "def lp_assign(demand, closed=(), capmul=None):\n", + " \"\"\"선형계획(LP): 구간 용량 상한(이용률 rho) 아래에서 자유주행시간 합을 최소화하는 경로별 물량. 불가능하면 rho를 1.0까지 완화.\"\"\"\n", + " cap = {s: CAP[s] * (capmul or {}).get(s, 1.0) for s in CAP}\n", + " ok = [p for p in PATHS if not any(s in closed for s in p)]\n", + " if not ok:\n", + " return None\n", + " for rho in (0.85, 0.95, 1.0):\n", + " r = linprog([sum(T0[s] for s in p) for p in ok], A_ub=[[1.0 if s in p else 0.0 for p in ok] for s in CAP],\n", + " b_ub=[rho * cap[s] for s in CAP], A_eq=[[1.0] * len(ok)], b_eq=[demand], bounds=(0, None), method=\"highs\")\n", + " if r.status == 0:\n", + " break\n", + " else:\n", + " return assign(demand, closed, capmul) # 용량 초과 수요: 증분 배정으로 대체(과부하 표시)\n", + " flow = {s: sum(r.x[i] for i, p in enumerate(ok) if s in p) for s in CAP}\n", + " pf = {p: float(r.x[i]) for i, p in enumerate(ok) if r.x[i] > 1e-6}\n", + " tt = {p: sum(seg_time(s, flow[s], cap[s]) for s in p) for p in pf}\n", + " return {\"flow\": flow, \"paths\": pf, \"path_min\": tt, \"avg_min\": sum(pf[p] * tt[p] for p in pf) / demand,\n", + " \"util\": {s: flow[s] / cap[s] for s in cap if cap[s] > 0}, \"rho\": rho}\n", + "\n", + "\n", + "def cost_of(res, tv=1000, fuel_km=450):\n", + " \"\"\"시간당 비용(원, 교육용 가정): 대당 시간비용 tv원/분 + 유류비 fuel_km원/km.\"\"\"\n", + " return sum(f * (res[\"path_min\"][p] * tv + sum(KM[s] for s in p) * fuel_km) for p, f in res[\"paths\"].items())\n", + "# ---------------------------------------------------------------------------\n", + "\n", + "libs = [\"numpy\", \"pandas\", \"scipy\", \"sklearn\", \"matplotlib\"]\n", + "env_ok = sum(importlib.util.find_spec(m) is not None for m in libs)\n", + "print(f\"[0] 환경 점검: Python {sys.version_info.major}.{sys.version_info.minor}, 필수 모듈 {env_ok}/{len(libs)}\")\n", + "\n", + "# ---------------- 1) 데이터 갱신·정합성 검증(미션 1~2 요약) ----------------\n", + "raw = make_data(days=180) # day 0~149 기존 + day 150~179 신규 입력분\n", + "d = add_features(raw)\n", + "chk = {\"(구간,일자) 유일\": not raw.duplicated([\"seg\", \"day\"]).any(), \"결측 0\": int(raw.isna().sum().sum()) == 0, \"통과량 범위\": bool(((raw.v >= 0) & (raw.v <= 3 * raw.cap)).all()),\n", + " \"통과시간 >= 자유주행\": bool((raw.transit >= raw.t0 - 1e-9).all())}\n", + "print(f\"[1] 신규 30일 포함 {len(raw)}행 정합성 검증 {sum(chk.values())}/{len(chk)} 통과\")\n", + "\n", + "# ---------------- 2) 자동 재학습: 드리프트 점검 -> 챌린저 학습 -> 승격 판단 ----------------\n", + "def fit(tr):\n", + " return GradientBoostingRegressor(n_estimators=200, max_depth=3, learning_rate=0.08, random_state=0).fit(tr[FEATS], tr[\"delay\"])\n", + "\n", + "\n", + "def evaluate(m, te):\n", + " p = te[\"t0\"] * (1 + 0.08 * te[\"rain\"]) + m.predict(te[FEATS])\n", + " return mae(te[\"transit\"], p), mape(te[\"transit\"], p)\n", + "\n", + "\n", + "def retrain_pipeline(old_end, new_end, drift_mape=3.0, min_gain=0.03):\n", + " old, new = d[d.day < old_end], d[(d.day >= old_end) & (d.day < new_end)]\n", + " champ = fit(old)\n", + " ch_mae, ch_mape = evaluate(champ, new)\n", + " drift = ch_mape > drift_mape\n", + " chall = fit(d[d.day < new_end - 10]) # 최근 10일은 검증용으로 남김\n", + " hold = d[(d.day >= new_end - 10) & (d.day < new_end)]\n", + " a, b = evaluate(champ, hold)[0], evaluate(chall, hold)[0]\n", + " promote = (a - b) / a >= min_gain\n", + " return {\"champ_mape\": ch_mape, \"drift\": drift, \"champ_mae\": a, \"chall_mae\": b, \"promote\": promote, \"model\": chall if promote else champ}\n", + "\n", + "\n", + "rt = retrain_pipeline(150, 180)\n", + "print(f\"[2] 재학습 파이프라인: 기존 모델의 신규 30일 MAPE {rt['champ_mape']:.2f}% (드리프트 기준 3.0%: {'초과' if rt['drift'] else '이내'})\")\n", + "print(f\" 검증 10일 MAE 챔피언 {rt['champ_mae']:.3f}분 vs 챌린저 {rt['chall_mae']:.3f}분 -> {'챌린저 승격' if rt['promote'] else '기존 모델 유지'}(개선 3% 이상일 때만 승격)\")\n", + "model = rt[\"model\"]\n", + "\n", + "# ---------------- 3) 7일 수송 소요 예측(Ridge 요일+추세 vs 전주 동요일 Baseline) ----------------\n", + "daily = raw[raw.seg.isin([\"A1\", \"B1\", \"C1\"])].groupby(\"day\")[\"plan_v\"].sum()\n", + "\n", + "\n", + "def design(days):\n", + " days = np.asarray(days)\n", + " X = np.zeros((len(days), 9))\n", + " X[np.arange(len(days)), days % 7] = 1\n", + " X[:, 7] = days / 100\n", + " X[:, 8] = ((days >= 100) & (days < 110)).astype(float) # 폭증 기간(알려진 사건) 더미\n", + " return X\n", + "\n", + "\n", + "tr_days = daily.index[daily.index < 173]\n", + "rg = Ridge(alpha=0.1, fit_intercept=False).fit(design(tr_days), daily[tr_days])\n", + "test_days = np.arange(173, 180)\n", + "f_ridge = rg.predict(design(test_days)); f_naive = daily[test_days - 7].values\n", + "print(f\"\\n[3] 수송 소요 7일 예측 검증(day 173~179): Ridge MAPE {mape(daily[test_days], f_ridge):.1f}% vs 전주 동요일 {mape(daily[test_days], f_naive):.1f}%\")\n", + "rg = Ridge(alpha=0.1, fit_intercept=False).fit(design(daily.index), daily)\n", + "nxt = np.arange(180, 187)\n", + "dem = rg.predict(design(nxt))\n", + "\n", + "# ---------------- 4) 소요 -> 병목·우회 배분 -> 승인대기 분리 ----------------\n", + "NOTICE = {183: {\"A2\": 0.65}} # 공지된 정비(가정): day 183 A2 교량 용량 65%\n", + "out = []\n", + "for day, D in zip(nxt, dem):\n", + " cm = NOTICE.get(int(day))\n", + " f, l = fixed_assign(D, (), cm), lp_assign(D, (), cm)\n", + " extra = l[\"avg_min\"] - lp_assign(D)[\"avg_min\"]\n", + " risky = max(l[\"util\"].values()) > 0.98 or extra >= 3 or day in NOTICE\n", + " out.append({\"day\": int(day), \"예측 소요(대/시)\": D, \"고정(분)\": f[\"avg_min\"], \"우회 배정(분)\": l[\"avg_min\"], \"절감률\": 1 - cost_of(l) / cost_of(f),\n", + " \"최대 이용률\": max(l[\"util\"].values()), \"처리\": \"작전군수관 승인대기(2단계)\" if risky else \"추천(검토 후 집행)\"})\n", + "pl = pd.DataFrame(out)\n", + "print(\"\\n[4] 향후 7일 수송 소요·우회 배분 계획 [교육용가상자료]\")\n", + "print(pl.round(2).to_string(index=False))\n", + "pend = pl[pl[\"처리\"].str.contains(\"승인대기\")]\n", + "print(f\" 승인대기 {len(pend)}건(day {', '.join(map(str, pend['day']))}) - 자동 집행하지 않고 작전군수관 결재(미션 7)로 넘긴다.\")\n", + "\n", + "# ---------------- 5) 운영 문서 완비율·인터페이스 명세 ----------------\n", + "docs = {\"데이터 사전·스키마(M1)\": 29, \"정제 규칙 10종(M2)\": 10, \"Baseline·EDA(M3)\": 8, \"병목 식별·우회(M4)\": 4, \"우회 소요·유류 엔진(M5)\": 4, \"대시보드·설명(M6)\": 6,\n", + " \"승인 절차·권한(M7)\": 4, \"스트레스 시험(M8)\": 4, \"AAR·ROI(M9)\": 5, \"운영·재학습 절차(M10)\": 6, \"장애 대응\": 5, \"인터페이스 가이드\": 4}\n", + "done = sum(v > 0 for v in docs.values())\n", + "iface = {\"input\": {\"daily_plan\": [\"day:int\", \"seg:str\", \"plan_v:float\", \"maint:int\", \"rain:int\"], \"format\": \"json-lines\"},\n", + " \"output\": {\"forecast\": [\"day\", \"demand_vph\", \"paths\", \"avg_min\", \"status\"], \"approval\": \"PENDING_ONLY\"}, \"auth\": \"role-based(가상)\", \"note\": \"상위 군수정보체계는 가상 인터페이스로만 정의\"}\n", + "print(f\"\\n[5] 운영 문서 {len(docs)}개 항목 중 작성 완료 {done}개 ({done / len(docs):.0%}) / 인터페이스 명세(JSON):\")\n", + "print(\" \", json.dumps(iface, ensure_ascii=False))\n", + "\n", + "# ---------------- 6) 자체 점검·브리핑 ----------------\n", + "te = d[d.day >= 150]\n", + "fin_mae, fin_mape = evaluate(model, te)\n", + "base_mape = mape(te.transit, te.t0 + te.bpr_base)\n", + "checks = [(\"환경 점검 통과\", env_ok == len(libs)), (\"데이터 정합성 전 항목 통과\", all(chk.values())), (\"재학습 파이프라인 정상 실행(챔피언/챌린저 판정 출력)\", rt[\"champ_mae\"] > 0),\n", + " (\"통과시간 예측 MAPE 25% 이하\", fin_mape <= 25), (\"기준 BPR 대비 MAE 20% 이상 개선\", fin_mae <= 0.8 * mae(te.transit, te.t0 + te.bpr_base)),\n", + " (\"7일 소요 예측이 전주 동요일 Baseline보다 정확\", mape(daily[test_days], f_ridge) < mape(daily[test_days], f_naive)),\n", + " (\"위험·정비 우회안 자동 집행 0건(승인대기 분리)\", len(pend) >= 1 and (pl.loc[pl.day == 183, \"처리\"].str.contains(\"승인대기\")).all()), (\"운영 문서 100% 완비\", done == len(docs))]\n", + "print(\"\\n[6] 자체 점검\")\n", + "for n, ok in checks:\n", + " print(f\" [{'PASS' if ok else 'FAIL'}] {n}\")\n", + "print(\"\\n[7] 브리핑 요약\")\n", + "print(f\" - 신규 30일 데이터로 재학습 판단을 자동 수행했다(드리프트 {'감지' if rt['drift'] else '미감지'}, {'챌린저 승격' if rt['promote'] else '기존 모델 유지'}).\")\n", + "print(f\" - 통과시간 MAPE {base_mape:.1f}%(BPR) -> {fin_mape:.1f}%, 향후 7일 소요 예측 MAPE {mape(daily[test_days], f_ridge):.1f}%.\")\n", + "print(f\" - 향후 7일 중 우회 최적화 평균 절감 {pl['절감률'].mean():.1%}, 정비 공지일은 승인대기로 분리했다. 실제 우회 하달은 작전군수관 결재와 지휘계통 결심에 따른다.\")\n", + "\n", + "fig, ax = plt.subplots(1, 2, figsize=(12, 4))\n", + "ax[0].plot(daily.index, daily.values, label=\"actual\"); ax[0].plot(nxt, dem, \"r--o\", label=\"forecast\"); ax[0].legend(); ax[0].set_title(\"Daily transport demand and 7-day forecast\")\n", + "ax[1].bar(pl[\"day\"] - .2, pl[\"고정(분)\"], .4, label=\"fixed\"); ax[1].bar(pl[\"day\"] + .2, pl[\"우회 배정(분)\"], .4, label=\"detour assignment\"); ax[1].legend(); ax[1].set_title(\"Avg transit (min), next 7 days\")\n", + "plt.suptitle(\"MOV01 integrated pipeline (synthetic)\"); plt.tight_layout(); plt.show()\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 10 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + "[0] 환경 점검: Python 3.11, 필수 모듈 5/5\n", + "[1] 신규 30일 포함 1800행 정합성 검증 4/4 통과\n", + "[2] 재학습 파이프라인: 기존 모델의 신규 30일 MAPE 2.93% (드리프트 기준 3.0%: 이내)\n", + " 검증 10일 MAE 챔피언 0.534분 vs 챌린저 0.535분 -> 기존 모델 유지(개선 3% 이상일 때만 승격)\n", + "\n", + "[3] 수송 소요 7일 예측 검증(day 173~179): Ridge MAPE 3.5% vs 전주 동요일 4.7%\n", + "\n", + "[4] 향후 7일 수송 소요·우회 배분 계획 [교육용가상자료]\n", + " day 예측 소요(대/시) 고정(분) 우회 배정(분) 절감률 최대 이용률 처리\n", + " 180 216.46 42.89 42.43 0.00 0.85 추천(검토 후 집행)\n", + " 181 204.40 42.15 41.95 0.00 0.85 추천(검토 후 집행)\n", + " 182 258.65 56.35 44.70 0.16 0.95 추천(검토 후 집행)\n", + " 183 276.73 91.80 51.32 0.37 1.11 작전군수관 승인대기(2단계)\n", + " 184 266.37 61.40 45.29 0.21 0.95 추천(검토 후 집행)\n", + " 185 275.57 68.96 45.87 0.27 1.00 작전군수관 승인대기(2단계)\n", + " 186 259.89 57.16 44.79 0.17 0.95 추천(검토 후 집행)\n", + " 승인대기 2건(day 183, 185) - 자동 집행하지 않고 작전군수관 결재(미션 7)로 넘긴다.\n", + "\n", + "[5] 운영 문서 12개 항목 중 작성 완료 12개 (100%) / 인터페이스 명세(JSON):\n", + " {\"input\": {\"daily_plan\": [\"day:int\", \"seg:str\", \"plan_v:float\", \"maint:int\", \"rain:int\"], \"format\": \"json-lines\"}, \"output\": {\"forecast\": [\"day\", \"demand_vph\", \"paths\", \"avg_min\", \"status\"], \"approval\": \"PENDING_ONLY\"}, \"auth\": \"role-based(가상)\", \"note\": \"상위 군수정보체계는 가상 인터페이스로만 정의\"}\n", + "\n", + "[6] 자체 점검\n", + " [PASS] 환경 점검 통과\n", + " [PASS] 데이터 정합성 전 항목 통과\n", + " [PASS] 재학습 파이프라인 정상 실행(챔피언/챌린저 판정 출력)\n", + " [PASS] 통과시간 예측 MAPE 25% 이하\n", + " [PASS] 기준 BPR 대비 MAE 20% 이상 개선\n", + " [PASS] 7일 소요 예측이 전주 동요일 Baseline보다 정확\n", + " [PASS] 위험·정비 우회안 자동 집행 0건(승인대기 분리)\n", + " [PASS] 운영 문서 100% 완비\n", + "\n", + "[7] 브리핑 요약\n", + " - 신규 30일 데이터로 재학습 판단을 자동 수행했다(드리프트 미감지, 기존 모델 유지).\n", + " - 통과시간 MAPE 9.6%(BPR) -> 2.9%, 향후 7일 소요 예측 MAPE 3.5%.\n", + " - 향후 7일 중 우회 최적화 평균 절감 16.9%, 정비 공지일은 승인대기로 분리했다. 실제 우회 하달은 작전군수관 결재와 지휘계통 결심에 따른다.\n", + "```\n", + "
\n" + ] + } + ], + "metadata": { + "colab": { + "provenance": [] + }, + "kernelspec": { + "display_name": "Python 3", + "name": "python3" + }, + "language_info": { + "name": "python" + } + }, + "nbformat": 4, + "nbformat_minor": 0 +} \ No newline at end of file diff --git a/uploads/MOV01/MOV01_student.ipynb b/uploads/MOV01/MOV01_student.ipynb new file mode 100644 index 0000000..1dfa2ed --- /dev/null +++ b/uploads/MOV01/MOV01_student.ipynb @@ -0,0 +1,3353 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# 🎯 [MOV01] 데이터 기반 수송 소요예측 Tool\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/MOV01/MOV01_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", + "수송 소요를 데이터로 예측해 군수 수송계획을 세우는 일은 구간별 통과 용량과 병목을 함께 보아야 정확해진다. 본 프로젝트는 수송 소요예측에서 예측 대상 정의, 시계열·피처 구성, 기준모델 비교, 설명가능성, 업무 임계값, 실적 대조를 익히도록 교육용 가상 노선망 자료를 코드로 만들어 구간별 수송 소요와 지체를 예측한다. 학습자는 병목 데이터 정의, 지체 전처리, BPR 기준 모델, 최대유량 기반 병목 식별과 피처 모델, 우회 소요·유류 산출과 앙상블 모델, 혼잡도 대시보드와 요인 설명, 작전군수관 승인(HITL)과 선형계획 최적화, 3개 구간 동시 차단 스트레스 시험, AAR 재보정과 ROI, 재학습 파이프라인 통합을 차례로 수행한다. 최종 목표는 구간별 소요·지체를 예측해 병목을 사전에 찾고 우회 물량 배분안을 만들되, 우회 명령의 하달 여부는 반드시 사람이 결정하는 수송 소요예측 도구를 완성하는 것이다.\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. 전시 주요 보급로(MSR) 교차로, 병목 구간(병목 다리/터널) 및 통과 능력 데이터 정의\n", + "**소요시간**: 약 1.5시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "주요 보급로(MSR) 교차로 및 병목 구간 통과 능력 데이터를 정의한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **가상 노선망의 교차로·병목(교량·터널) 구간과 통과 능력 데이터를 정의하고, 수송 소요예측 업무 프로세스 맵과 피처 데이터 사전을 만든다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **구간 코드, 유형, 연장, 피크시간 통과 용량, 계획 물량, 실제 통과량, 비계획 정비·긴급 불출·우천 여부, 지체 시간의 필드와 허용값을 정 수행하고 검증하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `docs/mov01_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": [ + "\"\"\"가상 노선망의 교차로·병목(교량·터널) 구간과 통과 능력 데이터를 정의하고, 수송 소요예측 업무 프로세스 맵과 20개 이상의 피처 데이터 사전을 만들어 실제 데이터 컬럼과 대조한다.\"\"\"\n", + "import re\n", + "import numpy as np\n", + "import pandas as pd\n", + "\n", + "# ---------------------------------------------------------------------------\n", + "# [교육용가상자료] 가상 노선망·구간 통과량 생성기 (실제 도로·지명·부대 없음)\n", + "# 가상 출발지(SRC)에서 가상 목적지(DST)까지 3개 축선(A·B·C)과 연결로 2개, 구간 10개. 150일치 피크시간 통과량·지체 시간을 만든다.\n", + "# ---------------------------------------------------------------------------\n", + "SEGS = pd.DataFrame([\n", + " (\"A1\", \"SRC\", \"J1\", 12, 130, \"일반\"), (\"A2\", \"J1\", \"J2\", 8, 70, \"교량\"), (\"A3\", \"J2\", \"DST\", 10, 140, \"교차로\"),\n", + " (\"B1\", \"SRC\", \"J3\", 15, 100, \"일반\"), (\"B2\", \"J3\", \"J2\", 9, 80, \"터널\"), (\"B3\", \"J3\", \"DST\", 14, 95, \"일반\"),\n", + " (\"C1\", \"SRC\", \"J4\", 18, 70, \"일반\"), (\"C2\", \"J4\", \"DST\", 20, 75, \"교량\"),\n", + " (\"X1\", \"J1\", \"J3\", 7, 50, \"교차로\"), (\"X2\", \"J4\", \"J3\", 10, 60, \"일반\")],\n", + " columns=[\"seg\", \"u\", \"v\", \"km\", \"cap\", \"kind\"]) # cap = 피크시간 통과 용량(대/시, 교육용 가정값)\n", + "KSPD = {\"일반\": 55, \"교량\": 40, \"터널\": 45, \"교차로\": 45} # 구간 유형별 자유 주행 속도(km/h, 가정값)\n", + "SEGS[\"t0\"] = SEGS[\"km\"] / SEGS[\"kind\"].map(KSPD) * 60 # 자유 주행 시간(분)\n", + "UTIL = dict(A1=.78, A2=.85, A3=.66, B1=.70, B2=.72, B3=.64, C1=.55, C2=.58, X1=.45, X2=.40) # 평시 이용률\n", + "\n", + "\n", + "def bpr(t0, v, cap, a=0.15, b=4.0):\n", + " \"\"\"BPR 지체 함수(교통공학의 표준형): 통과량/용량 비가 커질수록 지체가 급증한다. (분)\"\"\"\n", + " # [TODO : 학생 실습 1] 전시 주요 보급로(MSR) 교차로, 병목 구간(병목 다리/터널) 및 통과 능력 데이터 정의 관련 핵심 로직을 구현하세요.\n", + " # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n", + " # <<< 여기에 코드를 작성하세요 >>>\n", + " pass # 작성 후 pass를 제거하세요.\n", + "def make_data(seed=11, days=150):\n", + " \"\"\"구간 x 일자 패널. v=실제 피크 통과량, plan_v=수송계획 수량(긴급 불출 미반영), delay=실제 지체(분).\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " d_idx = np.arange(days)\n", + " lvl = np.array([1.0, 1.08, 1.04, 1.10, 1.02, 0.82, 0.78])[d_idx % 7] * (1 + 0.0012 * d_idx)\n", + " lvl = lvl * np.where((d_idx >= 100) & (d_idx < 110), 1.30, 1.0) # 수송량 폭증 기간(가상)\n", + " prev, rows = {s: 0.0 for s in SEGS[\"seg\"]}, []\n", + " for d in d_idx:\n", + " rain = bool(rng.random() < 0.18)\n", + " for r in SEGS.itertuples():\n", + " maint, urgent = bool(rng.random() < 0.06), bool(rng.random() < 0.05) # 비계획 정비 / 긴급 불출\n", + " v = r.cap * UTIL[r.seg] * lvl[d] * (1.25 if urgent else 1.0) * rng.normal(1, 0.05)\n", + " cap_eff = r.cap * (0.65 if maint else 1.0) * (0.92 if rain else 1.0)\n", + " delay = max(r.t0 * 0.2 * (v / cap_eff) ** 3.5 + 0.25 * prev[r.seg] + rng.normal(0, 0.4), 0.0)\n", + " prev[r.seg] = delay\n", + " rows.append(dict(day=int(d), dow=int(d % 7), seg=r.seg, kind=r.kind, km=r.km, cap=r.cap, t0=r.t0,\n", + " plan_v=v / (1.25 if urgent else 1.0) * rng.normal(1, 0.04), v=v, maint=int(maint), urgent=int(urgent),\n", + " rain=int(rain), cap_eff=cap_eff, delay=delay))\n", + " df = pd.DataFrame(rows)\n", + " df[\"transit\"] = df[\"t0\"] * (1 + 0.08 * df[\"rain\"]) + df[\"delay\"]\n", + " return df\n", + "\n", + "\n", + "def mape(y, p):\n", + " # [TODO : 학생 실습 1] 전시 주요 보급로(MSR) 교차로, 병목 구간(병목 다리/터널) 및 통과 능력 데이터 정의 관련 핵심 로직을 구현하세요.\n", + " # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n", + " # <<< 여기에 코드를 작성하세요 >>>\n", + " pass # 작성 후 pass를 제거하세요.\n", + "def mae(y, p):\n", + " return float(np.mean(np.abs(np.asarray(y, float) - np.asarray(p, float))))\n", + "# ---------------------------------------------------------------------------\n", + "# ---------------------------------------------------------------------------\n", + "# 지체 예측용 파생 피처 생성(당일 오전 시점에 알 수 있는 정보만 사용: 계획 물량, 공지 정비, 기상 예보, 전일·전주 실적)\n", + "# ---------------------------------------------------------------------------\n", + "FEATS = [\"plan_v\", \"cap\", \"cap_eff\", \"t0\", \"km\", \"maint\", \"rain\", \"util\", \"util_eff\", \"util_eff4\", \"bpr_base\", \"lag1_delay\", \"lag7_delay\",\n", + " \"roll7_v\", \"surge_ratio\", \"is_weekend\", \"dow_sin\", \"dow_cos\", \"k_교량\", \"k_터널\", \"k_교차로\"]\n", + "\n", + "\n", + "def add_features(df):\n", + " d = df.sort_values([\"seg\", \"day\"]).copy()\n", + " g = d.groupby(\"seg\")\n", + " d[\"lag1_delay\"] = g[\"delay\"].shift(1)\n", + " d[\"lag7_delay\"] = g[\"delay\"].shift(7)\n", + " d[\"roll7_v\"] = g[\"v\"].transform(lambda s: s.shift(1).rolling(7).mean())\n", + " d[\"util\"] = d[\"plan_v\"] / d[\"cap\"]\n", + " d[\"util_eff\"] = d[\"plan_v\"] / d[\"cap_eff\"]\n", + " d[\"util_eff4\"] = d[\"util_eff\"] ** 4\n", + " d[\"bpr_base\"] = bpr(d[\"t0\"], d[\"plan_v\"], d[\"cap\"])\n", + " d[\"surge_ratio\"] = d[\"plan_v\"] / d[\"roll7_v\"]\n", + " d[\"is_weekend\"] = (d[\"dow\"] >= 5).astype(int)\n", + " d[\"dow_sin\"], d[\"dow_cos\"] = np.sin(2 * np.pi * d[\"dow\"] / 7), np.cos(2 * np.pi * d[\"dow\"] / 7)\n", + " for k in (\"교량\", \"터널\", \"교차로\"):\n", + " d[\"k_\" + k] = (d[\"kind\"] == k).astype(int)\n", + " return d.dropna().sort_values([\"day\", \"seg\"]).reset_index(drop=True)\n", + "# ---------------------------------------------------------------------------\n", + "\n", + "# 1) 데이터 사전: 원천 컬럼 + 파생 피처 (이름, 구분, 자료형, 허용 범위/값, 설명)\n", + "DICT = [\n", + " (\"seg\", \"원천\", \"str\", r\"^[ABCX]\\d$\", \"구간 코드(가상 노선망)\"), (\"kind\", \"원천\", \"str\", \"일반|교량|터널|교차로\", \"구간 유형(병목 후보)\"),\n", + " (\"km\", \"원천\", \"float\", \"1~50\", \"구간 연장(km)\"), (\"cap\", \"원천\", \"float\", \"10~300\", \"피크시간 통과 용량(대/시)\"),\n", + " (\"t0\", \"원천\", \"float\", \"1~120\", \"자유 주행 시간(분)\"), (\"day\", \"원천\", \"int\", \"0~\", \"관측 일자 번호\"),\n", + " (\"dow\", \"원천\", \"int\", \"0~6\", \"요일 번호\"), (\"plan_v\", \"원천\", \"float\", \"0~\", \"수송계획 피크 물량(대/시)\"),\n", + " (\"v\", \"원천\", \"float\", \"0~\", \"실제 피크 통과량(대/시)\"), (\"maint\", \"원천\", \"int\", \"0|1\", \"비계획 정비 여부(통과 용량 감소)\"),\n", + " (\"urgent\", \"원천\", \"int\", \"0|1\", \"긴급 불출 여부(물량 증가)\"), (\"rain\", \"원천\", \"int\", \"0|1\", \"우천 여부\"),\n", + " (\"cap_eff\", \"원천\", \"float\", \"0~cap\", \"정비·기상 반영 유효 용량\"), (\"delay\", \"목표\", \"float\", \"0~\", \"구간 지체 시간(분)\"),\n", + " (\"transit\", \"목표\", \"float\", \"t0~\", \"구간 통과시간(분)\"),\n", + " (\"util\", \"파생\", \"float\", \"0~2\", \"계획물량/공칭용량\"), (\"util_eff\", \"파생\", \"float\", \"0~3\", \"계획물량/유효용량\"),\n", + " (\"util_eff4\", \"파생\", \"float\", \"0~\", \"유효 이용률 4제곱(혼잡 비선형)\"), (\"bpr_base\", \"파생\", \"float\", \"0~\", \"BPR 기준 지체(분)\"),\n", + " (\"lag1_delay\", \"파생\", \"float\", \"0~\", \"전일 지체\"), (\"lag7_delay\", \"파생\", \"float\", \"0~\", \"전주 동요일 지체\"),\n", + " (\"roll7_v\", \"파생\", \"float\", \"0~\", \"최근 7일 평균 통과량\"), (\"surge_ratio\", \"파생\", \"float\", \"0~\", \"계획물량/최근 평균(폭증 지표)\"),\n", + " (\"is_weekend\", \"파생\", \"int\", \"0|1\", \"주말 여부\"), (\"dow_sin\", \"파생\", \"float\", \"-1~1\", \"요일 순환(사인)\"),\n", + " (\"dow_cos\", \"파생\", \"float\", \"-1~1\", \"요일 순환(코사인)\"), (\"k_교량\", \"파생\", \"int\", \"0|1\", \"교량 구간 더미\"),\n", + " (\"k_터널\", \"파생\", \"int\", \"0|1\", \"터널 구간 더미\"), (\"k_교차로\", \"파생\", \"int\", \"0|1\", \"교차로 구간 더미\")]\n", + "dd = pd.DataFrame(DICT, columns=[\"필드\", \"구분\", \"형\", \"허용값\", \"설명\"])\n", + "print(f\"[1] 데이터 사전 {len(dd)}개 필드 (원천 {int((dd.구분 == '원천').sum())}, 목표 {int((dd.구분 == '목표').sum())}, 파생 {int((dd.구분 == '파생').sum())})\")\n", + "\n", + "# 2) 실제 생성 데이터와 대조: 사전에 없는 컬럼 / 사전에만 있는 필드\n", + "df = make_data()\n", + "feat = add_features(df)\n", + "miss = sorted(set(feat.columns) - set(dd[\"필드\"]))\n", + "extra = sorted(set(dd[\"필드\"]) - set(feat.columns))\n", + "print(f\"[2] 데이터 사전 대조: 사전 누락 컬럼 {miss or '없음'}, 데이터에 없는 필드 {extra or '없음'}\")\n", + "fe = dd[dd[\"구분\"] != \"목표\"]\n", + "print(f\" 예측 피처 후보(원천+파생 중 목표 제외) {len(fe)}개 -> 목표(20개 이상) {'달성' if len(fe) >= 20 else '미달'}\")\n", + "bad_rng = {\"v\": int((feat.v < 0).sum()), \"cap_eff\": int((feat.cap_eff > feat.cap).sum()), \"util_eff\": int((feat.util_eff > 3).sum())}\n", + "print(\" 허용 범위 위반 건수:\", bad_rng)\n", + "\n", + "# 3) 구간 마스터: 병목 후보(교량·터널·교차로)와 통과 능력\n", + "seg = SEGS.copy()\n", + "seg[\"병목후보\"] = np.where(seg[\"kind\"].isin([\"교량\", \"터널\", \"교차로\"]), \"Y\", \"\")\n", + "seg[\"평시 이용률\"] = seg[\"seg\"].map(UTIL)\n", + "print(\"\\n[3] 구간 마스터(가상 노선망, 교육용 가정값)\")\n", + "print(seg[[\"seg\", \"u\", \"v\", \"km\", \"kind\", \"cap\", \"t0\", \"평시 이용률\", \"병목후보\"]].round(2).to_string(index=False))\n", + "\n", + "# 4) 수송 소요예측 업무 프로세스 맵(단계, 담당, 입력 데이터, 산출, 현행 소요 분)\n", + "proc = [(\"수송 소요 접수\", \"수송계획장교\", \"부대별 요청 서식\", \"일일 수송 소요표\", 30), (\"구간 통과량 집계\", \"수송통제소\", \"구간 통과 보고\", \"plan_v, v\", 40),\n", + " (\"정비·기상 정보 취합\", \"통제소/기상반\", \"정비 공지, 예보\", \"maint, rain\", 25), (\"통과 용량 산정\", \"수송통제소\", \"구간 마스터\", \"cap, cap_eff\", 20),\n", + " (\"지체 예측\", \"분석 담당\", \"피처 테이블\", \"delay 예측\", 45), (\"병목 판정\", \"분석 담당\", \"예측 지체, 이용률\", \"병목 목록\", 20),\n", + " (\"우회 검토·승인\", \"작전군수관\", \"우회 후보, 추가 소요\", \"우회 명령(승인)\", 30), (\"명령 하달·운행\", \"수송통제소\", \"승인된 명령\", \"운행 이벤트\", 15),\n", + " (\"실적 대조·보정\", \"분석 담당\", \"예측 대 실적\", \"오차 보고\", 35)]\n", + "pm = pd.DataFrame(proc, columns=[\"단계\", \"담당\", \"입력\", \"산출\", \"현행(분)\"])\n", + "pm.index = range(1, len(pm) + 1)\n", + "print(f\"\\n[4] 업무 프로세스 맵 {len(pm)}단계, 현행 합계 {pm['현행(분)'].sum()}분 (가장 긴 단계: {pm.loc[pm['현행(분)'].idxmax(), '단계']})\")\n", + "print(pm.to_string())\n", + "print(\"\\n프로세스 맵 작성 완료(9단계), 피처 목록 20개 이상 -> 목표\", \"달성\" if len(pm) >= 5 and len(fe) >= 20 else \"미달\")\n", + "print(\"모든 노선·구간·수치는 교육용 가상 자료이며 실제 보급로·지명·부대와 무관하다.\")\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 01 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + "[1] 데이터 사전 29개 필드 (원천 13, 목표 2, 파생 14)\n", + "[2] 데이터 사전 대조: 사전 누락 컬럼 없음, 데이터에 없는 필드 없음\n", + " 예측 피처 후보(원천+파생 중 목표 제외) 27개 -> 목표(20개 이상) 달성\n", + " 허용 범위 위반 건수: {'v': 0, 'cap_eff': 0, 'util_eff': 0}\n", + "\n", + "[3] 구간 마스터(가상 노선망, 교육용 가정값)\n", + "seg u v km kind cap t0 평시 이용률 병목후보\n", + " A1 SRC J1 12 일반 130 13.09 0.78 \n", + " A2 J1 J2 8 교량 70 12.00 0.85 Y\n", + " A3 J2 DST 10 교차로 140 13.33 0.66 Y\n", + " B1 SRC J3 15 일반 100 16.36 0.70 \n", + " B2 J3 J2 9 터널 80 12.00 0.72 Y\n", + " B3 J3 DST 14 일반 95 15.27 0.64 \n", + " C1 SRC J4 18 일반 70 19.64 0.55 \n", + " C2 J4 DST 20 교량 75 30.00 0.58 Y\n", + " X1 J1 J3 7 교차로 50 9.33 0.45 Y\n", + " X2 J4 J3 10 일반 60 10.91 0.40 \n", + "\n", + "[4] 업무 프로세스 맵 9단계, 현행 합계 260분 (가장 긴 단계: 지체 예측)\n", + " 단계 담당 입력 산출 현행(분)\n", + "1 수송 소요 접수 수송계획장교 부대별 요청 서식 일일 수송 소요표 30\n", + "2 구간 통과량 집계 수송통제소 구간 통과 보고 plan_v, v 40\n", + "3 정비·기상 정보 취합 통제소/기상반 정비 공지, 예보 maint, rain 25\n", + "4 통과 용량 산정 수송통제소 구간 마스터 cap, cap_eff 20\n", + "5 지체 예측 분석 담당 피처 테이블 delay 예측 45\n", + "6 병목 판정 분석 담당 예측 지체, 이용률 병목 목록 20\n", + "7 우회 검토·승인 작전군수관 우회 후보, 추가 소요 우회 명령(승인) 30\n", + "8 명령 하달·운행 수송통제소 승인된 명령 운행 이벤트 15\n", + "9 실적 대조·보정 분석 담당 예측 대 실적 오차 보고 35\n", + "\n", + "프로세스 맵 작성 완료(9단계), 피처 목록 20개 이상 -> 목표 달성\n", + "모든 노선·구간·수치는 교육용 가상 자료이며 실제 보급로·지명·부대와 무관하다.\n", + "```\n", + "
\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 02. 수송 수량 폭증에 따른 구간별 병목 지체 시간(Delay) 계산 전처리 모듈 구현\n", + "**소요시간**: 약 2.0시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "수송량 폭증에 따른 구간별 지체 시간 계산 전처리 모듈을 구현한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **수송량 폭증에 따른 구간별 지체 시간 계산을 위해 오염된 구간 통과 기록을 정제하는 전처리 모듈을 구현한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **결측, 음수, 단위 오류, 표기 오류, 미등록 구간, 용량 0, 중복을 주입한 가상 기록에 정제 규칙 10종을 순서대로 적용 수행하고 검증하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `src/etl/mov01_data_cleansing.py 및 정제 품질 보고서` 형태의 결과물을 완성한다.\n", + "\n", + "### 🔍 평가 및 검증 기준 (PASS 조건)\n", + "#### 미션 판정\n", + "\n", + "**평가 구분:** 필수\n", + "\n", + "**핵심 평가영역:** 군수 특수 예외(비계획 정비, 긴급 불출 등) 데이터 보존의 타당성\n", + "\n", + "#### 미션 PASS 조건\n", + "\n", + "- 정량 기준: 데이터 정제 규칙 10종 적용, 데이터 정합성 검증 통과율 100%\n", + "\n", + "\n", + "- 진행 기준: ETL 파이프라인 구현 및 정제된 마스터 데이터셋 구축\n", + "\n", + "\n", + "- 검증 방법: 전후 데이터 통계량(기술통계) 비교 및 무결성 검증\n", + "\n", + "\n", + "#### 채점자 판정 메모\n", + "\n", + "> 단순 코드 실행 여부뿐만 아니라 출력된 수치와 근거 자료가 군 실무 요구사항에 부합하는지 면밀히 확인한다.\n", + "\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "\"\"\"수송량 폭증에 따른 구간별 지체 시간 계산 전처리 모듈: 오염된 구간 통과 기록에 정제 규칙 10종을 적용하고 비계획 정비·긴급 불출 같은 군수 특수 예외는 삭제하지 않고 보존하며, 정합성 검증과 전후 통계를 비교한다.\"\"\"\n", + "import numpy as np\n", + "import pandas as pd\n", + "\n", + "# ---------------------------------------------------------------------------\n", + "# [교육용가상자료] 가상 노선망·구간 통과량 생성기 (실제 도로·지명·부대 없음)\n", + "# 가상 출발지(SRC)에서 가상 목적지(DST)까지 3개 축선(A·B·C)과 연결로 2개, 구간 10개. 150일치 피크시간 통과량·지체 시간을 만든다.\n", + "# ---------------------------------------------------------------------------\n", + "SEGS = pd.DataFrame([\n", + " (\"A1\", \"SRC\", \"J1\", 12, 130, \"일반\"), (\"A2\", \"J1\", \"J2\", 8, 70, \"교량\"), (\"A3\", \"J2\", \"DST\", 10, 140, \"교차로\"),\n", + " (\"B1\", \"SRC\", \"J3\", 15, 100, \"일반\"), (\"B2\", \"J3\", \"J2\", 9, 80, \"터널\"), (\"B3\", \"J3\", \"DST\", 14, 95, \"일반\"),\n", + " (\"C1\", \"SRC\", \"J4\", 18, 70, \"일반\"), (\"C2\", \"J4\", \"DST\", 20, 75, \"교량\"),\n", + " (\"X1\", \"J1\", \"J3\", 7, 50, \"교차로\"), (\"X2\", \"J4\", \"J3\", 10, 60, \"일반\")],\n", + " columns=[\"seg\", \"u\", \"v\", \"km\", \"cap\", \"kind\"]) # cap = 피크시간 통과 용량(대/시, 교육용 가정값)\n", + "KSPD = {\"일반\": 55, \"교량\": 40, \"터널\": 45, \"교차로\": 45} # 구간 유형별 자유 주행 속도(km/h, 가정값)\n", + "SEGS[\"t0\"] = SEGS[\"km\"] / SEGS[\"kind\"].map(KSPD) * 60 # 자유 주행 시간(분)\n", + "UTIL = dict(A1=.78, A2=.85, A3=.66, B1=.70, B2=.72, B3=.64, C1=.55, C2=.58, X1=.45, X2=.40) # 평시 이용률\n", + "\n", + "\n", + "def bpr(t0, v, cap, a=0.15, b=4.0):\n", + " \"\"\"BPR 지체 함수(교통공학의 표준형): 통과량/용량 비가 커질수록 지체가 급증한다. (분)\"\"\"\n", + " # [TODO : 학생 실습 2] 수송 수량 폭증에 따른 구간별 병목 지체 시간(Delay) 계산 전처리 모듈 구현 관련 핵심 로직을 구현하세요.\n", + " # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n", + " # <<< 여기에 코드를 작성하세요 >>>\n", + " pass # 작성 후 pass를 제거하세요.\n", + "def make_data(seed=11, days=150):\n", + " \"\"\"구간 x 일자 패널. v=실제 피크 통과량, plan_v=수송계획 수량(긴급 불출 미반영), delay=실제 지체(분).\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " d_idx = np.arange(days)\n", + " lvl = np.array([1.0, 1.08, 1.04, 1.10, 1.02, 0.82, 0.78])[d_idx % 7] * (1 + 0.0012 * d_idx)\n", + " lvl = lvl * np.where((d_idx >= 100) & (d_idx < 110), 1.30, 1.0) # 수송량 폭증 기간(가상)\n", + " prev, rows = {s: 0.0 for s in SEGS[\"seg\"]}, []\n", + " for d in d_idx:\n", + " rain = bool(rng.random() < 0.18)\n", + " for r in SEGS.itertuples():\n", + " maint, urgent = bool(rng.random() < 0.06), bool(rng.random() < 0.05) # 비계획 정비 / 긴급 불출\n", + " v = r.cap * UTIL[r.seg] * lvl[d] * (1.25 if urgent else 1.0) * rng.normal(1, 0.05)\n", + " cap_eff = r.cap * (0.65 if maint else 1.0) * (0.92 if rain else 1.0)\n", + " delay = max(r.t0 * 0.2 * (v / cap_eff) ** 3.5 + 0.25 * prev[r.seg] + rng.normal(0, 0.4), 0.0)\n", + " prev[r.seg] = delay\n", + " rows.append(dict(day=int(d), dow=int(d % 7), seg=r.seg, kind=r.kind, km=r.km, cap=r.cap, t0=r.t0,\n", + " plan_v=v / (1.25 if urgent else 1.0) * rng.normal(1, 0.04), v=v, maint=int(maint), urgent=int(urgent),\n", + " rain=int(rain), cap_eff=cap_eff, delay=delay))\n", + " df = pd.DataFrame(rows)\n", + " df[\"transit\"] = df[\"t0\"] * (1 + 0.08 * df[\"rain\"]) + df[\"delay\"]\n", + " return df\n", + "\n", + "\n", + "def mape(y, p):\n", + " # [TODO : 학생 실습 2] 수송 수량 폭증에 따른 구간별 병목 지체 시간(Delay) 계산 전처리 모듈 구현 관련 핵심 로직을 구현하세요.\n", + " # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n", + " # <<< 여기에 코드를 작성하세요 >>>\n", + " pass # 작성 후 pass를 제거하세요.\n", + "def mae(y, p):\n", + " return float(np.mean(np.abs(np.asarray(y, float) - np.asarray(p, float))))\n", + "# ---------------------------------------------------------------------------\n", + "\n", + "clean = make_data()\n", + "rng = np.random.default_rng(2)\n", + "dirty = clean.copy()\n", + "# ---- 오염 주입(가상): 결측·음수·단위 오류·표기 오류·미등록 구간·0 용량·중복 ----\n", + "ix = rng.choice(len(dirty), 40, replace=False)\n", + "dirty.loc[ix[:12], \"v\"] = np.nan\n", + "dirty.loc[ix[12:18], \"v\"] = -dirty.loc[ix[12:18], \"v\"]\n", + "dirty.loc[ix[18:23], \"v\"] = dirty.loc[ix[18:23], \"v\"] * 10\n", + "dirty.loc[ix[23:29], \"transit\"] = np.nan\n", + "dirty.loc[ix[29:32], \"cap\"] = 0\n", + "dirty.loc[ix[32:36], \"seg\"] = dirty.loc[ix[32:36], \"seg\"].str.lower() + \" \"\n", + "ghost = dirty.iloc[ix[36:40]].copy(); ghost[\"seg\"] = \"Z9\" # 마스터에 없는 구간 코드가 섞여 들어온 행\n", + "dirty = pd.concat([dirty, ghost, dirty.iloc[rng.choice(len(dirty), 15, replace=False)]], ignore_index=True)\n", + "planted = {\"결측 통과량\": 12, \"음수 통과량\": 6, \"단위 오류(10배)\": 5, \"결측 통과시간\": 6, \"용량 0\": 3, \"구간 표기 오류\": 4, \"미등록 구간\": 4, \"중복 행\": 15}\n", + "print(f\"[0] 오염 데이터 {len(dirty)}행 (원본 {len(clean)}행), 주입 오류 {sum(planted.values())}건: {planted}\")\n", + "\n", + "log, w = [], dirty.copy()\n", + "master = SEGS.set_index(\"seg\")\n", + "\n", + "\n", + "def rule(name, n):\n", + " log.append((len(log) + 1, name, n))\n", + "\n", + "\n", + "# R1 구간 코드 표기 정규화\n", + "before = w[\"seg\"].copy(); w[\"seg\"] = w[\"seg\"].str.strip().str.upper(); rule(\"구간 코드 공백·대소문자 정규화\", int((before != w[\"seg\"]).sum()))\n", + "# R2 미등록 구간 격리(삭제하지 않고 별도 보관)\n", + "bad = ~w[\"seg\"].isin(master.index); quarantine = w[bad].copy(); w = w[~bad]; rule(\"미등록 구간 격리(별도 보관)\", int(bad.sum()))\n", + "# R3 중복 제거(구간·일자 키)\n", + "n0 = len(w); w = w.drop_duplicates([\"seg\", \"day\"], keep=\"first\"); rule(\"(구간, 일자) 중복 제거\", n0 - len(w))\n", + "# R4 용량 0/결측은 구간 마스터에서 복원\n", + "bad = w[\"cap\"] <= 0; w.loc[bad, \"cap\"] = w.loc[bad, \"seg\"].map(master[\"cap\"]); rule(\"용량 0 -> 구간 마스터 복원\", int(bad.sum()))\n", + "# R5 음수 통과량은 결측으로 전환(부호 오류 가능성 -> 보간 대상)\n", + "bad = w[\"v\"] < 0; w.loc[bad, \"v\"] = np.nan; rule(\"음수 통과량 -> 결측 처리\", int(bad.sum()))\n", + "# R6 단위 오류(용량의 3배 초과)는 1/10 보정\n", + "bad = w[\"v\"] > 3 * w[\"cap\"]; w.loc[bad, \"v\"] = w.loc[bad, \"v\"] / 10; rule(\"단위 오류(용량 3배 초과) 1/10 보정\", int(bad.sum()))\n", + "# R7 결측 통과량: 같은 구간·같은 요일의 중앙값으로 보간(계절성 보간)\n", + "bad = w[\"v\"].isna(); med = w.groupby([\"seg\", \"dow\"])[\"v\"].transform(\"median\"); w.loc[bad, \"v\"] = med[bad]; rule(\"결측 통과량 -> 구간·요일 중앙값 보간\", int(bad.sum()))\n", + "# R8 결측 통과시간: 자유주행 + 지체로 재계산\n", + "bad = w[\"transit\"].isna(); w.loc[bad, \"transit\"] = w.loc[bad, \"t0\"] * (1 + 0.08 * w.loc[bad, \"rain\"]) + w.loc[bad, \"delay\"]; rule(\"결측 통과시간 재계산\", int(bad.sum()))\n", + "# R9 지체 시간 정합: delay = transit - 자유주행(우천 보정), 음수는 0으로\n", + "d2 = (w[\"transit\"] - w[\"t0\"] * (1 + 0.08 * w[\"rain\"])).clip(lower=0); mism = (d2 - w[\"delay\"]).abs() > 1e-6\n", + "w[\"delay\"] = d2; rule(\"지체 = 통과시간 - 자유주행 정합 재계산\", int(mism.sum()))\n", + "# R10 이상치 표시: IQR 밖 지체는 '표시'만 하고, 정비·긴급 불출 행은 예외로 보존(삭제 금지)\n", + "q1, q3 = w[\"delay\"].quantile([.25, .75]); out = w[\"delay\"] > q3 + 3 * (q3 - q1)\n", + "w[\"outlier\"] = out.astype(int); w[\"exception_keep\"] = ((w[\"maint\"] == 1) | (w[\"urgent\"] == 1)).astype(int)\n", + "rule(\"지체 이상치 플래그(삭제 없음)\", int(out.sum()))\n", + "w = w.sort_values([\"day\", \"seg\"]).reset_index(drop=True)\n", + "\n", + "print(\"\\n[1] 정제 규칙 적용 로그\")\n", + "print(pd.DataFrame(log, columns=[\"규칙\", \"내용\", \"처리 건수\"]).to_string(index=False))\n", + "print(f\" 정제 규칙 {len(log)}종 적용(목표 10종: {'달성' if len(log) >= 10 else '미달'})\")\n", + "\n", + "# ---- 정합성 검증 ----\n", + "checks = {\"(구간, 일자) 유일\": not w.duplicated([\"seg\", \"day\"]).any(), \"필수 컬럼 결측 0\": int(w[[\"seg\", \"v\", \"cap\", \"delay\", \"transit\"]].isna().sum().sum()) == 0,\n", + " \"구간 코드 마스터 포함\": bool(w[\"seg\"].isin(master.index).all()), \"통과량 0 이상·용량 3배 이하\": bool(((w[\"v\"] >= 0) & (w[\"v\"] <= 3 * w[\"cap\"])).all()),\n", + " \"용량 = 마스터 값\": bool((w[\"cap\"] == w[\"seg\"].map(master[\"cap\"])).all()), \"통과시간 >= 자유주행\": bool((w[\"transit\"] >= w[\"t0\"] - 1e-9).all()),\n", + " \"지체 >= 0\": bool((w[\"delay\"] >= 0).all())}\n", + "passed = sum(checks.values())\n", + "print(\"\\n[2] 정합성 검증\")\n", + "for k, v in checks.items():\n", + " print(f\" [{'PASS' if v else 'FAIL'}] {k}\")\n", + "print(f\" 통과율 {passed / len(checks):.0%} (목표 100%)\")\n", + "\n", + "# ---- 군수 특수 예외 보존 / 원본 대비 복원 정확도 ----\n", + "ex = w[w[\"exception_keep\"] == 1]\n", + "print(f\"\\n[3] 군수 특수 예외 보존: 비계획 정비 {int(w['maint'].sum())}건, 긴급 불출 {int(w['urgent'].sum())}건 모두 유지(원본 {int(clean['maint'].sum())}, {int(clean['urgent'].sum())}건)\")\n", + "print(f\" 예외 행 평균 지체 {ex['delay'].mean():.2f}분 vs 일반 행 {w.loc[w['exception_keep'] == 0, 'delay'].mean():.2f}분 -> 이상치로 지웠다면 폭증 구간 정보가 사라진다\")\n", + "m = w.merge(clean[[\"seg\", \"day\", \"v\"]], on=[\"seg\", \"day\"], suffixes=(\"\", \"_true\"))\n", + "print(f\" 통과량 복원 오차(보정·보간된 행 {int((m['v'] != m['v_true']).sum())}건 포함 전체 MAE) {np.abs(m['v'] - m['v_true']).mean():.3f}대/시\")\n", + "print(\"\\n[4] 전후 기술통계 비교(통과량 v / 지체 delay)\")\n", + "st = pd.DataFrame({\"오염 원본\": dirty[[\"v\", \"delay\"]].describe().loc[[\"count\", \"mean\", \"min\", \"max\"]].stack(), \"정제 후\": w[[\"v\", \"delay\"]].describe().loc[[\"count\", \"mean\", \"min\", \"max\"]].stack(),\n", + " \"정답 원본\": clean[[\"v\", \"delay\"]].describe().loc[[\"count\", \"mean\", \"min\", \"max\"]].stack()}).round(2)\n", + "print(st.to_string())\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 02 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + "[0] 오염 데이터 1519행 (원본 1500행), 주입 오류 55건: {'결측 통과량': 12, '음수 통과량': 6, '단위 오류(10배)': 5, '결측 통과시간': 6, '용량 0': 3, '구간 표기 오류': 4, '미등록 구간': 4, '중복 행': 15}\n", + "\n", + "[1] 정제 규칙 적용 로그\n", + " 규칙 내용 처리 건수\n", + " 1 구간 코드 공백·대소문자 정규화 4\n", + " 2 미등록 구간 격리(별도 보관) 4\n", + " 3 (구간, 일자) 중복 제거 15\n", + " 4 용량 0 -> 구간 마스터 복원 3\n", + " 5 음수 통과량 -> 결측 처리 6\n", + " 6 단위 오류(용량 3배 초과) 1/10 보정 5\n", + " 7 결측 통과량 -> 구간·요일 중앙값 보간 18\n", + " 8 결측 통과시간 재계산 6\n", + " 9 지체 = 통과시간 - 자유주행 정합 재계산 0\n", + " 10 지체 이상치 플래그(삭제 없음) 43\n", + " 정제 규칙 10종 적용(목표 10종: 달성)\n", + "\n", + "[2] 정합성 검증\n", + " [PASS] (구간, 일자) 유일\n", + " [PASS] 필수 컬럼 결측 0\n", + " [PASS] 구간 코드 마스터 포함\n", + " [PASS] 통과량 0 이상·용량 3배 이하\n", + " [PASS] 용량 = 마스터 값\n", + " [PASS] 통과시간 >= 자유주행\n", + " [PASS] 지체 >= 0\n", + " 통과율 100% (목표 100%)\n", + "\n", + "[3] 군수 특수 예외 보존: 비계획 정비 95건, 긴급 불출 87건 모두 유지(원본 95, 87건)\n", + " 예외 행 평균 지체 4.37분 vs 일반 행 1.60분 -> 이상치로 지웠다면 폭증 구간 정보가 사라진다\n", + " 통과량 복원 오차(보정·보간된 행 18건 포함 전체 MAE) 0.104대/시\n", + "\n", + "[4] 전후 기술통계 비교(통과량 v / 지체 delay)\n", + " 오염 원본 정제 후 정답 원본\n", + "count v 1507.00 1500.00 1500.00\n", + " delay 1519.00 1500.00 1500.00\n", + "mean v 63.64 62.95 62.95\n", + " delay 1.94 1.93 1.93\n", + "min v -146.18 16.78 16.78\n", + " delay 0.00 0.00 0.00\n", + "max v 635.70 174.46 174.46\n", + " delay 21.79 21.79 21.79\n", + "```\n", + "
\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 03. 구간별 통과 용량 기반 1차 수송 지체 예측 Baseline 알고리즘 구현\n", + "**소요시간**: 약 2.5시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "통과 용량 기반 1차 수송 지체 예측 Baseline 알고리즘을 구현한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **구간별 통과 용량 기반 BPR 지체 함수로 1차 수송 지체 예측 Baseline을 만들고, EDA로 Baseline의 한계와 핵심 요인을 찾는다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **계획 물량을 공칭 용량으로 나눈 이용률로 구간 지체를 계산하고 통과시간의 오차를 측정 수행하고 검증하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `notebooks/mov01_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": [ + "\"\"\"구간별 통과 용량(BPR 함수) 기반 1차 수송 지체 예측 Baseline을 만들어 오차를 측정하고, EDA 차트 8종과 상관 피처·파레토(상위 20% 구간) 분석으로 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", + "# [교육용가상자료] 가상 노선망·구간 통과량 생성기 (실제 도로·지명·부대 없음)\n", + "# 가상 출발지(SRC)에서 가상 목적지(DST)까지 3개 축선(A·B·C)과 연결로 2개, 구간 10개. 150일치 피크시간 통과량·지체 시간을 만든다.\n", + "# ---------------------------------------------------------------------------\n", + "SEGS = pd.DataFrame([\n", + " (\"A1\", \"SRC\", \"J1\", 12, 130, \"일반\"), (\"A2\", \"J1\", \"J2\", 8, 70, \"교량\"), (\"A3\", \"J2\", \"DST\", 10, 140, \"교차로\"),\n", + " (\"B1\", \"SRC\", \"J3\", 15, 100, \"일반\"), (\"B2\", \"J3\", \"J2\", 9, 80, \"터널\"), (\"B3\", \"J3\", \"DST\", 14, 95, \"일반\"),\n", + " (\"C1\", \"SRC\", \"J4\", 18, 70, \"일반\"), (\"C2\", \"J4\", \"DST\", 20, 75, \"교량\"),\n", + " (\"X1\", \"J1\", \"J3\", 7, 50, \"교차로\"), (\"X2\", \"J4\", \"J3\", 10, 60, \"일반\")],\n", + " columns=[\"seg\", \"u\", \"v\", \"km\", \"cap\", \"kind\"]) # cap = 피크시간 통과 용량(대/시, 교육용 가정값)\n", + "KSPD = {\"일반\": 55, \"교량\": 40, \"터널\": 45, \"교차로\": 45} # 구간 유형별 자유 주행 속도(km/h, 가정값)\n", + "SEGS[\"t0\"] = SEGS[\"km\"] / SEGS[\"kind\"].map(KSPD) * 60 # 자유 주행 시간(분)\n", + "UTIL = dict(A1=.78, A2=.85, A3=.66, B1=.70, B2=.72, B3=.64, C1=.55, C2=.58, X1=.45, X2=.40) # 평시 이용률\n", + "\n", + "\n", + "def bpr(t0, v, cap, a=0.15, b=4.0):\n", + " \"\"\"BPR 지체 함수(교통공학의 표준형): 통과량/용량 비가 커질수록 지체가 급증한다. (분)\"\"\"\n", + " # [TODO : 학생 실습 3] 구간별 통과 용량 기반 1차 수송 지체 예측 Baseline 알고리즘 구현 관련 핵심 로직을 구현하세요.\n", + " # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n", + " # <<< 여기에 코드를 작성하세요 >>>\n", + " pass # 작성 후 pass를 제거하세요.\n", + "def make_data(seed=11, days=150):\n", + " \"\"\"구간 x 일자 패널. v=실제 피크 통과량, plan_v=수송계획 수량(긴급 불출 미반영), delay=실제 지체(분).\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " d_idx = np.arange(days)\n", + " lvl = np.array([1.0, 1.08, 1.04, 1.10, 1.02, 0.82, 0.78])[d_idx % 7] * (1 + 0.0012 * d_idx)\n", + " lvl = lvl * np.where((d_idx >= 100) & (d_idx < 110), 1.30, 1.0) # 수송량 폭증 기간(가상)\n", + " prev, rows = {s: 0.0 for s in SEGS[\"seg\"]}, []\n", + " for d in d_idx:\n", + " rain = bool(rng.random() < 0.18)\n", + " for r in SEGS.itertuples():\n", + " maint, urgent = bool(rng.random() < 0.06), bool(rng.random() < 0.05) # 비계획 정비 / 긴급 불출\n", + " v = r.cap * UTIL[r.seg] * lvl[d] * (1.25 if urgent else 1.0) * rng.normal(1, 0.05)\n", + " cap_eff = r.cap * (0.65 if maint else 1.0) * (0.92 if rain else 1.0)\n", + " delay = max(r.t0 * 0.2 * (v / cap_eff) ** 3.5 + 0.25 * prev[r.seg] + rng.normal(0, 0.4), 0.0)\n", + " prev[r.seg] = delay\n", + " rows.append(dict(day=int(d), dow=int(d % 7), seg=r.seg, kind=r.kind, km=r.km, cap=r.cap, t0=r.t0,\n", + " plan_v=v / (1.25 if urgent else 1.0) * rng.normal(1, 0.04), v=v, maint=int(maint), urgent=int(urgent),\n", + " rain=int(rain), cap_eff=cap_eff, delay=delay))\n", + " df = pd.DataFrame(rows)\n", + " df[\"transit\"] = df[\"t0\"] * (1 + 0.08 * df[\"rain\"]) + df[\"delay\"]\n", + " return df\n", + "\n", + "\n", + "def mape(y, p):\n", + " # [TODO : 학생 실습 3] 구간별 통과 용량 기반 1차 수송 지체 예측 Baseline 알고리즘 구현 관련 핵심 로직을 구현하세요.\n", + " # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n", + " # <<< 여기에 코드를 작성하세요 >>>\n", + " pass # 작성 후 pass를 제거하세요.\n", + "def mae(y, p):\n", + " return float(np.mean(np.abs(np.asarray(y, float) - np.asarray(p, float))))\n", + "# ---------------------------------------------------------------------------\n", + "# ---------------------------------------------------------------------------\n", + "# 지체 예측용 파생 피처 생성(당일 오전 시점에 알 수 있는 정보만 사용: 계획 물량, 공지 정비, 기상 예보, 전일·전주 실적)\n", + "# ---------------------------------------------------------------------------\n", + "FEATS = [\"plan_v\", \"cap\", \"cap_eff\", \"t0\", \"km\", \"maint\", \"rain\", \"util\", \"util_eff\", \"util_eff4\", \"bpr_base\", \"lag1_delay\", \"lag7_delay\",\n", + " \"roll7_v\", \"surge_ratio\", \"is_weekend\", \"dow_sin\", \"dow_cos\", \"k_교량\", \"k_터널\", \"k_교차로\"]\n", + "\n", + "\n", + "def add_features(df):\n", + " d = df.sort_values([\"seg\", \"day\"]).copy()\n", + " g = d.groupby(\"seg\")\n", + " d[\"lag1_delay\"] = g[\"delay\"].shift(1)\n", + " d[\"lag7_delay\"] = g[\"delay\"].shift(7)\n", + " d[\"roll7_v\"] = g[\"v\"].transform(lambda s: s.shift(1).rolling(7).mean())\n", + " d[\"util\"] = d[\"plan_v\"] / d[\"cap\"]\n", + " d[\"util_eff\"] = d[\"plan_v\"] / d[\"cap_eff\"]\n", + " d[\"util_eff4\"] = d[\"util_eff\"] ** 4\n", + " d[\"bpr_base\"] = bpr(d[\"t0\"], d[\"plan_v\"], d[\"cap\"])\n", + " d[\"surge_ratio\"] = d[\"plan_v\"] / d[\"roll7_v\"]\n", + " d[\"is_weekend\"] = (d[\"dow\"] >= 5).astype(int)\n", + " d[\"dow_sin\"], d[\"dow_cos\"] = np.sin(2 * np.pi * d[\"dow\"] / 7), np.cos(2 * np.pi * d[\"dow\"] / 7)\n", + " for k in (\"교량\", \"터널\", \"교차로\"):\n", + " d[\"k_\" + k] = (d[\"kind\"] == k).astype(int)\n", + " return d.dropna().sort_values([\"day\", \"seg\"]).reset_index(drop=True)\n", + "# ---------------------------------------------------------------------------\n", + "\n", + "df = make_data()\n", + "d = add_features(df)\n", + "\n", + "# 1) Baseline: 계획 물량 / 공칭 용량으로 BPR 지체 계산(정비·기상·전일 지체 미반영)\n", + "d[\"pred_base\"] = d[\"bpr_base\"]\n", + "d[\"pred_transit\"] = d[\"t0\"] + d[\"pred_base\"]\n", + "print(f\"[1] Baseline(BPR) 지체 예측: 통과시간 MAPE {mape(d.transit, d.pred_transit):.1f}%, 통과시간 MAE {mae(d.transit, d.pred_transit):.2f}분, 지체 MAE {mae(d.delay, d.pred_base):.2f}분\")\n", + "bias = (d[\"pred_base\"] - d[\"delay\"])\n", + "grp = pd.DataFrame({\"평균 오차(예측-실제, 분)\": [bias[d.maint == 1].mean(), bias[d.maint == 0].mean(), bias[d.rain == 1].mean(), bias[d.rain == 0].mean(), bias[d.urgent == 1].mean(), bias[(d.day >= 100) & (d.day < 110)].mean()]},\n", + " index=[\"비계획 정비일\", \"정비 없음\", \"우천일\", \"맑음\", \"긴급 불출일\", \"수송량 폭증 기간\"]).round(2)\n", + "print(\"[2] 조건별 Baseline 오차(음수 = 과소예측)\")\n", + "print(grp.to_string())\n", + "print(\" -> Baseline은 정비·긴급 불출·폭증 조건에서 지체를 과소예측한다(한계).\")\n", + "\n", + "# 2) 상관 분석: 지체와 각 피처의 스피어만 상관·p-value\n", + "rows = []\n", + "for f in FEATS:\n", + " r, p = stats.spearmanr(d[f], d[\"delay\"])\n", + " rows.append((f, r, p))\n", + "cor = pd.DataFrame(rows, columns=[\"피처\", \"상관\", \"p\"]).assign(abs_r=lambda x: x[\"상관\"].abs()).sort_values(\"abs_r\", ascending=False)\n", + "sig = cor[(cor[\"abs_r\"] >= 0.3) & (cor[\"p\"] < 0.05)]\n", + "print(f\"\\n[3] 지체와 유의한 상관(|r|>=0.3, p<0.05) 피처 {len(sig)}개 (목표 5개 이상: {'달성' if len(sig) >= 5 else '미달'})\")\n", + "print(sig.head(7)[[\"피처\", \"상관\", \"p\"]].round(3).to_string(index=False))\n", + "t, p = stats.ttest_ind(d.loc[d.maint == 1, \"delay\"], d.loc[d.maint == 0, \"delay\"], equal_var=False)\n", + "print(f\" 가설검정: 비계획 정비일 vs 평시 지체 평균차 t={t:.1f}, p={p:.2g}\")\n", + "\n", + "# 3) 파레토(ABC): 구간별 총 지체 기여\n", + "tot = d.groupby(\"seg\")[\"delay\"].sum().sort_values(ascending=False)\n", + "cum = tot.cumsum() / tot.sum()\n", + "top = tot.index[:max(1, int(np.ceil(len(tot) * 0.2)))]\n", + "print(f\"\\n[4] 파레토: 상위 20% 구간({', '.join(top)})이 총 지체의 {tot[top].sum() / tot.sum():.0%}를 차지, 상위 50% 구간 {cum.iloc[4]:.0%}\")\n", + "\n", + "# 4) EDA 차트 8종\n", + "fig, ax = plt.subplots(2, 4, figsize=(18, 8))\n", + "daily = d.groupby(\"day\")[\"v\"].sum(); ax[0, 0].plot(daily.index, daily.values); ax[0, 0].set_title(\"1 Daily total peak volume\")\n", + "ax[0, 1].hist(d[\"delay\"], bins=40); ax[0, 1].set_title(\"2 Delay distribution\")\n", + "ax[0, 2].scatter(d[\"util\"], d[\"delay\"], s=6, alpha=.4); ax[0, 2].set_title(\"3 Utilization vs delay\")\n", + "mean_seg = d.groupby(\"seg\")[\"delay\"].mean().sort_values(); ax[0, 3].barh(mean_seg.index, mean_seg.values); ax[0, 3].set_title(\"4 Mean delay by segment\")\n", + "ax[1, 0].boxplot([d.loc[d.dow == k, \"delay\"] for k in range(7)]); ax[1, 0].set_title(\"5 Delay by weekday\")\n", + "ax[1, 1].boxplot([d.loc[d.rain == 0, \"delay\"], d.loc[d.rain == 1, \"delay\"]])\n", + "ax[1, 1].set_xticklabels([\"dry\", \"rain\"]); ax[1, 1].set_title(\"6 Rain effect\")\n", + "ax[1, 2].boxplot([d.loc[d.maint == 0, \"delay\"], d.loc[d.maint == 1, \"delay\"]])\n", + "ax[1, 2].set_xticklabels([\"normal\", \"maintenance\"]); ax[1, 2].set_title(\"7 Maintenance effect\")\n", + "ax[1, 3].bar(range(len(tot)), (tot / tot.sum()).values); ax[1, 3].plot(range(len(tot)), cum.values, \"r-o\"); ax[1, 3].set_title(\"8 Pareto of total delay\")\n", + "plt.suptitle(\"MOV01 EDA (synthetic data)\"); plt.tight_layout(); plt.show()\n", + "print(\"\\n[5] EDA 차트 8종 생성 완료(목표 8종 이상: 달성)\")\n", + "print(\"핵심 발견: 지체는 이용률의 비선형 함수이며 정비·폭증·긴급 불출이 겹치면 급증한다. 이는 M4 파생 피처 설계의 근거가 된다.\")\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 03 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + "[1] Baseline(BPR) 지체 예측: 통과시간 MAPE 7.4%, 통과시간 MAE 1.39분, 지체 MAE 1.23분\n", + "[2] 조건별 Baseline 오차(음수 = 과소예측)\n", + " 평균 오차(예측-실제, 분)\n", + "비계획 정비일 -4.87\n", + "정비 없음 -0.96\n", + "우천일 -1.67\n", + "맑음 -1.14\n", + "긴급 불출일 -2.62\n", + "수송량 폭증 기간 -2.66\n", + " -> Baseline은 정비·긴급 불출·폭증 조건에서 지체를 과소예측한다(한계).\n", + "\n", + "[3] 지체와 유의한 상관(|r|>=0.3, p<0.05) 피처 14개 (목표 5개 이상: 달성)\n", + " 피처 상관 p\n", + " util_eff 0.836 0.0\n", + " util_eff4 0.836 0.0\n", + " bpr_base 0.830 0.0\n", + " util 0.793 0.0\n", + "lag1_delay 0.730 0.0\n", + " plan_v 0.653 0.0\n", + "lag7_delay 0.650 0.0\n", + " 가설검정: 비계획 정비일 vs 평시 지체 평균차 t=8.2, p=1.7e-12\n", + "\n", + "[4] 파레토: 상위 20% 구간(A2, A1)이 총 지체의 33%를 차지, 상위 50% 구간 70%\n", + "\n", + "[5] EDA 차트 8종 생성 완료(목표 8종 이상: 달성)\n", + "핵심 발견: 지체는 이용률의 비선형 함수이며 정비·폭증·긴급 불출이 겹치면 급증한다. 이는 M4 파생 피처 설계의 근거가 된다.\n", + "```\n", + "
\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 04. 네트워크 흐름(Max-Flow) 알고리즘 기반 병목 발생 지점 사전 식별 및 우회로 탐색 모델 고도화\n", + "**소요시간**: 약 3.0시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "네트워크 흐름 알고리즘 기반 병목 사전 식별 및 우회로 탐색 모델을 고도화한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **최대유량(Max-Flow) 알고리즘으로 병목 구간을 사전에 식별하고 구간 폐쇄 시 우회로를 찾으며, 파생 피처 기반 기준 모델을 구축한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **노선망에서 출발지에서 목적지까지의 최대 통과능력과 최소 절단 구간을 구하고 구간별 증설 민감도를 비교 수행하고 검증하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `src/features/mov01_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": [ + "\"\"\"최대유량(Max-Flow) 알고리즘으로 가상 노선망의 최소 절단(병목) 구간을 사전 식별하고 구간 폐쇄 시 우회 경로를 찾으며, 파생 피처 14종과 시계열 교차검증으로 지체 예측 기준 모델(MAPE)을 만든다.\"\"\"\n", + "import numpy as np\n", + "import pandas as pd\n", + "import matplotlib.pyplot as plt\n", + "from scipy.optimize import linprog\n", + "from sklearn.ensemble import GradientBoostingRegressor\n", + "from sklearn.model_selection import TimeSeriesSplit\n", + "\n", + "# ---------------------------------------------------------------------------\n", + "# [교육용가상자료] 가상 노선망·구간 통과량 생성기 (실제 도로·지명·부대 없음)\n", + "# 가상 출발지(SRC)에서 가상 목적지(DST)까지 3개 축선(A·B·C)과 연결로 2개, 구간 10개. 150일치 피크시간 통과량·지체 시간을 만든다.\n", + "# ---------------------------------------------------------------------------\n", + "SEGS = pd.DataFrame([\n", + " (\"A1\", \"SRC\", \"J1\", 12, 130, \"일반\"), (\"A2\", \"J1\", \"J2\", 8, 70, \"교량\"), (\"A3\", \"J2\", \"DST\", 10, 140, \"교차로\"),\n", + " (\"B1\", \"SRC\", \"J3\", 15, 100, \"일반\"), (\"B2\", \"J3\", \"J2\", 9, 80, \"터널\"), (\"B3\", \"J3\", \"DST\", 14, 95, \"일반\"),\n", + " (\"C1\", \"SRC\", \"J4\", 18, 70, \"일반\"), (\"C2\", \"J4\", \"DST\", 20, 75, \"교량\"),\n", + " (\"X1\", \"J1\", \"J3\", 7, 50, \"교차로\"), (\"X2\", \"J4\", \"J3\", 10, 60, \"일반\")],\n", + " columns=[\"seg\", \"u\", \"v\", \"km\", \"cap\", \"kind\"]) # cap = 피크시간 통과 용량(대/시, 교육용 가정값)\n", + "KSPD = {\"일반\": 55, \"교량\": 40, \"터널\": 45, \"교차로\": 45} # 구간 유형별 자유 주행 속도(km/h, 가정값)\n", + "SEGS[\"t0\"] = SEGS[\"km\"] / SEGS[\"kind\"].map(KSPD) * 60 # 자유 주행 시간(분)\n", + "UTIL = dict(A1=.78, A2=.85, A3=.66, B1=.70, B2=.72, B3=.64, C1=.55, C2=.58, X1=.45, X2=.40) # 평시 이용률\n", + "\n", + "\n", + "def bpr(t0, v, cap, a=0.15, b=4.0):\n", + " \"\"\"BPR 지체 함수(교통공학의 표준형): 통과량/용량 비가 커질수록 지체가 급증한다. (분)\"\"\"\n", + " # [TODO : 학생 실습 4] 네트워크 흐름(Max-Flow) 알고리즘 기반 병목 발생 지점 사전 식별 및 우회로 탐색 모델 고도화 관련 핵심 로직을 구현하세요.\n", + " # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n", + " # <<< 여기에 코드를 작성하세요 >>>\n", + " pass # 작성 후 pass를 제거하세요.\n", + "def make_data(seed=11, days=150):\n", + " \"\"\"구간 x 일자 패널. v=실제 피크 통과량, plan_v=수송계획 수량(긴급 불출 미반영), delay=실제 지체(분).\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " d_idx = np.arange(days)\n", + " lvl = np.array([1.0, 1.08, 1.04, 1.10, 1.02, 0.82, 0.78])[d_idx % 7] * (1 + 0.0012 * d_idx)\n", + " lvl = lvl * np.where((d_idx >= 100) & (d_idx < 110), 1.30, 1.0) # 수송량 폭증 기간(가상)\n", + " prev, rows = {s: 0.0 for s in SEGS[\"seg\"]}, []\n", + " for d in d_idx:\n", + " rain = bool(rng.random() < 0.18)\n", + " for r in SEGS.itertuples():\n", + " maint, urgent = bool(rng.random() < 0.06), bool(rng.random() < 0.05) # 비계획 정비 / 긴급 불출\n", + " v = r.cap * UTIL[r.seg] * lvl[d] * (1.25 if urgent else 1.0) * rng.normal(1, 0.05)\n", + " cap_eff = r.cap * (0.65 if maint else 1.0) * (0.92 if rain else 1.0)\n", + " delay = max(r.t0 * 0.2 * (v / cap_eff) ** 3.5 + 0.25 * prev[r.seg] + rng.normal(0, 0.4), 0.0)\n", + " prev[r.seg] = delay\n", + " rows.append(dict(day=int(d), dow=int(d % 7), seg=r.seg, kind=r.kind, km=r.km, cap=r.cap, t0=r.t0,\n", + " plan_v=v / (1.25 if urgent else 1.0) * rng.normal(1, 0.04), v=v, maint=int(maint), urgent=int(urgent),\n", + " rain=int(rain), cap_eff=cap_eff, delay=delay))\n", + " df = pd.DataFrame(rows)\n", + " df[\"transit\"] = df[\"t0\"] * (1 + 0.08 * df[\"rain\"]) + df[\"delay\"]\n", + " return df\n", + "\n", + "\n", + "def mape(y, p):\n", + " # [TODO : 학생 실습 4] 네트워크 흐름(Max-Flow) 알고리즘 기반 병목 발생 지점 사전 식별 및 우회로 탐색 모델 고도화 관련 핵심 로직을 구현하세요.\n", + " # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n", + " # <<< 여기에 코드를 작성하세요 >>>\n", + " pass # 작성 후 pass를 제거하세요.\n", + "def mae(y, p):\n", + " return float(np.mean(np.abs(np.asarray(y, float) - np.asarray(p, float))))\n", + "# ---------------------------------------------------------------------------\n", + "# ---------------------------------------------------------------------------\n", + "# 지체 예측용 파생 피처 생성(당일 오전 시점에 알 수 있는 정보만 사용: 계획 물량, 공지 정비, 기상 예보, 전일·전주 실적)\n", + "# ---------------------------------------------------------------------------\n", + "FEATS = [\"plan_v\", \"cap\", \"cap_eff\", \"t0\", \"km\", \"maint\", \"rain\", \"util\", \"util_eff\", \"util_eff4\", \"bpr_base\", \"lag1_delay\", \"lag7_delay\",\n", + " \"roll7_v\", \"surge_ratio\", \"is_weekend\", \"dow_sin\", \"dow_cos\", \"k_교량\", \"k_터널\", \"k_교차로\"]\n", + "\n", + "\n", + "def add_features(df):\n", + " d = df.sort_values([\"seg\", \"day\"]).copy()\n", + " g = d.groupby(\"seg\")\n", + " d[\"lag1_delay\"] = g[\"delay\"].shift(1)\n", + " d[\"lag7_delay\"] = g[\"delay\"].shift(7)\n", + " d[\"roll7_v\"] = g[\"v\"].transform(lambda s: s.shift(1).rolling(7).mean())\n", + " d[\"util\"] = d[\"plan_v\"] / d[\"cap\"]\n", + " d[\"util_eff\"] = d[\"plan_v\"] / d[\"cap_eff\"]\n", + " d[\"util_eff4\"] = d[\"util_eff\"] ** 4\n", + " d[\"bpr_base\"] = bpr(d[\"t0\"], d[\"plan_v\"], d[\"cap\"])\n", + " d[\"surge_ratio\"] = d[\"plan_v\"] / d[\"roll7_v\"]\n", + " d[\"is_weekend\"] = (d[\"dow\"] >= 5).astype(int)\n", + " d[\"dow_sin\"], d[\"dow_cos\"] = np.sin(2 * np.pi * d[\"dow\"] / 7), np.cos(2 * np.pi * d[\"dow\"] / 7)\n", + " for k in (\"교량\", \"터널\", \"교차로\"):\n", + " d[\"k_\" + k] = (d[\"kind\"] == k).astype(int)\n", + " return d.dropna().sort_values([\"day\", \"seg\"]).reset_index(drop=True)\n", + "# ---------------------------------------------------------------------------\n", + "# ---------------------------------------------------------------------------\n", + "# 노선망 흐름 도구: 최대유량(Edmonds-Karp), 증분 배정(혼잡 반영 최단시간 경로), 경로 열거\n", + "# ---------------------------------------------------------------------------\n", + "CAP = dict(zip(SEGS[\"seg\"], SEGS[\"cap\"]))\n", + "T0 = dict(zip(SEGS[\"seg\"], SEGS[\"t0\"]))\n", + "KM = dict(zip(SEGS[\"seg\"], SEGS[\"km\"]))\n", + "ARCS = {r.seg: (r.u, r.v) for r in SEGS.itertuples()}\n", + "SRC, DST = \"SRC\", \"DST\"\n", + "\n", + "\n", + "def seg_time(s, flow, cap=None, a=0.2, b=3.5):\n", + " \"\"\"구간 통과시간(분) = 자유주행 + 혼잡 지체. cap을 주면 용량을 대체(정비·기상 반영).\"\"\"\n", + " r = flow / (cap or CAP[s])\n", + " return T0[s] * (1 + a * r ** b + 6.0 * max(0.0, r - 1.0)) # 용량 초과분은 대기열로 급증\n", + "\n", + "\n", + "def max_flow(closed=(), capmul=None):\n", + " \"\"\"최대유량(대/시)과 최소 절단 구간(병목). capmul = {구간: 용량 배율}.\"\"\"\n", + " cap = {s: (0 if s in closed else CAP[s] * (capmul or {}).get(s, 1.0)) for s in CAP}\n", + " res = {}\n", + " for s, (u, v) in ARCS.items():\n", + " res[(u, v)] = res.get((u, v), 0) + cap[s]\n", + " res.setdefault((v, u), 0)\n", + " flow = 0.0\n", + " while True:\n", + " prev, q = {SRC: None}, [SRC]\n", + " for u in q:\n", + " for (a, b), c in res.items():\n", + " if a == u and c > 1e-9 and b not in prev:\n", + " prev[b] = u; q.append(b)\n", + " if DST not in prev:\n", + " break\n", + " path, x = [], DST\n", + " while prev[x]:\n", + " path.append((prev[x], x)); x = prev[x]\n", + " f = min(res[e] for e in path)\n", + " for a, b in path:\n", + " res[(a, b)] -= f; res[(b, a)] += f\n", + " flow += f\n", + " reach = set(prev)\n", + " cut = [s for s, (u, v) in ARCS.items() if u in reach and v not in reach and cap[s] > 0]\n", + " return flow, cut\n", + "\n", + "\n", + "def all_paths():\n", + " \"\"\"SRC -> DST 단순 경로(구간 이름 튜플) 전체.\"\"\"\n", + " out = []\n", + "\n", + " def dfs(node, segs, seen):\n", + " if node == DST:\n", + " out.append(tuple(segs)); return\n", + " for s, (u, v) in ARCS.items():\n", + " if u == node and v not in seen:\n", + " dfs(v, segs + [s], seen | {v})\n", + " dfs(SRC, [], {SRC})\n", + " return out\n", + "\n", + "\n", + "PATHS = all_paths()\n", + "\n", + "\n", + "def assign(demand, closed=(), capmul=None, steps=40):\n", + " \"\"\"증분 배정: 수요를 steps조각으로 나눠, 전체 통과시간(대수 x 시간) 증가가 가장 작은 경로에 차례로 얹는다(시스템 최적 근사).\"\"\"\n", + " cap = {s: CAP[s] * (capmul or {}).get(s, 1.0) for s in CAP}\n", + " flow, pf = {s: 0.0 for s in CAP}, {}\n", + " ok = [p for p in PATHS if not any(s in closed for s in p)]\n", + " if not ok:\n", + " return None\n", + " for _ in range(steps):\n", + " chunk = demand / steps\n", + " best = min(ok, key=lambda p: sum((flow[s] + chunk) * seg_time(s, flow[s] + chunk, cap[s]) - flow[s] * seg_time(s, flow[s], cap[s]) for s in p))\n", + " for s in best:\n", + " flow[s] += chunk\n", + " pf[best] = pf.get(best, 0) + chunk\n", + " tt = {p: sum(seg_time(s, flow[s], cap[s]) for s in p) for p in pf}\n", + " avg = sum(pf[p] * tt[p] for p in pf) / demand\n", + " return {\"flow\": flow, \"paths\": pf, \"path_min\": tt, \"avg_min\": avg, \"util\": {s: flow[s] / cap[s] for s in cap if cap[s] > 0}}\n", + "\n", + "\n", + "FIXED_SHARE = {(\"A1\", \"A2\", \"A3\"): 0.32, (\"B1\", \"B3\"): 0.40, (\"C1\", \"C2\"): 0.28} # 과거 관행: 축선별 고정 배분(가상)\n", + "\n", + "\n", + "def fixed_assign(demand, closed=(), capmul=None):\n", + " \"\"\"과거 관행 배분: 축선별 고정 비율. 막힌 축선 물량은 열려 있는 축선에 비율대로 재분배.\"\"\"\n", + " cap = {s: CAP[s] * (capmul or {}).get(s, 1.0) for s in CAP}\n", + " live = {p: w for p, w in FIXED_SHARE.items() if not any(s in closed for s in p)}\n", + " if not live:\n", + " return None\n", + " tot = sum(live.values())\n", + " flow = {s: 0.0 for s in CAP}\n", + " pf = {p: demand * w / tot for p, w in live.items()}\n", + " for p, f in pf.items():\n", + " for s in p:\n", + " flow[s] += f\n", + " tt = {p: sum(seg_time(s, flow[s], cap[s]) for s in p) for p in pf}\n", + " return {\"flow\": flow, \"paths\": pf, \"path_min\": tt, \"avg_min\": sum(pf[p] * tt[p] for p in pf) / demand,\n", + " \"util\": {s: flow[s] / cap[s] for s in cap if cap[s] > 0}}\n", + "\n", + "\n", + "def lp_assign(demand, closed=(), capmul=None):\n", + " \"\"\"선형계획(LP): 구간 용량 상한(이용률 rho) 아래에서 자유주행시간 합을 최소화하는 경로별 물량. 불가능하면 rho를 1.0까지 완화.\"\"\"\n", + " cap = {s: CAP[s] * (capmul or {}).get(s, 1.0) for s in CAP}\n", + " ok = [p for p in PATHS if not any(s in closed for s in p)]\n", + " if not ok:\n", + " return None\n", + " for rho in (0.85, 0.95, 1.0):\n", + " r = linprog([sum(T0[s] for s in p) for p in ok], A_ub=[[1.0 if s in p else 0.0 for p in ok] for s in CAP],\n", + " b_ub=[rho * cap[s] for s in CAP], A_eq=[[1.0] * len(ok)], b_eq=[demand], bounds=(0, None), method=\"highs\")\n", + " if r.status == 0:\n", + " break\n", + " else:\n", + " return assign(demand, closed, capmul) # 용량 초과 수요: 증분 배정으로 대체(과부하 표시)\n", + " flow = {s: sum(r.x[i] for i, p in enumerate(ok) if s in p) for s in CAP}\n", + " pf = {p: float(r.x[i]) for i, p in enumerate(ok) if r.x[i] > 1e-6}\n", + " tt = {p: sum(seg_time(s, flow[s], cap[s]) for s in p) for p in pf}\n", + " return {\"flow\": flow, \"paths\": pf, \"path_min\": tt, \"avg_min\": sum(pf[p] * tt[p] for p in pf) / demand,\n", + " \"util\": {s: flow[s] / cap[s] for s in cap if cap[s] > 0}, \"rho\": rho}\n", + "\n", + "\n", + "def cost_of(res, tv=1000, fuel_km=450):\n", + " \"\"\"시간당 비용(원, 교육용 가정): 대당 시간비용 tv원/분 + 유류비 fuel_km원/km.\"\"\"\n", + " return sum(f * (res[\"path_min\"][p] * tv + sum(KM[s] for s in p) * fuel_km) for p, f in res[\"paths\"].items())\n", + "# ---------------------------------------------------------------------------\n", + "\n", + "# 1) 최대유량과 병목(최소 절단)\n", + "f0, cut0 = max_flow()\n", + "print(f\"[1] 노선망 최대 통과능력 {f0:.0f}대/시, 최소 절단(병목) 구간: {', '.join(cut0)}\")\n", + "gain = []\n", + "for s in CAP:\n", + " g, _ = max_flow(capmul={s: 1.2})\n", + " gain.append((s, SEGS.set_index(\"seg\").loc[s, \"kind\"], CAP[s], g - f0))\n", + "gain = pd.DataFrame(gain, columns=[\"구간\", \"유형\", \"용량\", \"용량 20% 증설 시 증가(대/시)\"]).sort_values(\"용량 20% 증설 시 증가(대/시)\", ascending=False)\n", + "print(\"[2] 구간별 증설 민감도(병목일수록 증설 효과가 큼)\")\n", + "print(gain.head(5).to_string(index=False))\n", + "\n", + "# 2) 사전 병목 식별: 계획 물량 기준 이용률 >= 0.9인 구간 + 최소 절단 구간\n", + "df = make_data(); d = add_features(df)\n", + "today = d[d.day == d.day.max()].set_index(\"seg\")\n", + "risk = today[(today.util_eff >= 0.9)].index.tolist()\n", + "print(f\"\\n[3] 최종일(day {int(d.day.max())}) 사전 병목 경보: 유효 이용률 0.9 이상 구간 {risk or '없음'}\")\n", + "\n", + "# 3) 구간 폐쇄 시 우회: 최대유량 변화와 우회 경로\n", + "D = 200\n", + "print(f\"\\n[4] 구간 폐쇄 시나리오(수요 {D}대/시): 우회 경로와 평균 통과시간\")\n", + "base = assign(D)\n", + "rows = [(\"평시\", \"-\", f0, base[\"avg_min\"], \"A·B·C 분산\", \"수용\")]\n", + "for c in ((\"A2\",), (\"B2\",), (\"A3\",)):\n", + " mf, cut = max_flow(c)\n", + " r = assign(D, closed=c)\n", + " top = max(r[\"paths\"], key=r[\"paths\"].get)\n", + " rows.append((f\"{c[0]} 폐쇄\", \",\".join(cut), mf, r[\"avg_min\"], \"->\".join(top), \"수용\" if D <= mf else \"과부하\"))\n", + "sc = pd.DataFrame(rows, columns=[\"시나리오\", \"새 병목\", \"최대유량\", \"평균 통과(분)\", \"주 경로\", \"수요 대비\"]).round(1)\n", + "print(sc.to_string(index=False))\n", + "print(f\" A2 폐쇄 시 평균 통과시간 +{rows[1][3] - rows[0][3]:.1f}분, 수요 {D}대/시 대비 최대유량 {rows[1][2]:.0f}대/시 -> {rows[1][5]}(과부하면 우회만으로 부족하므로 물량 조정·타 수단 검토가 필요)\")\n", + "\n", + "# 4) 파생 피처로 지체 예측 기준 모델: 시계열 교차검증(TimeSeriesSplit)\n", + "print(f\"\\n[5] 파생 피처 {len([f for f in FEATS if f not in ['plan_v','cap','cap_eff','t0','km','maint','rain']])}개 생성(lag·이용률·BPR·폭증비·요일 순환·구간 더미 등)\")\n", + "days = np.sort(d.day.unique())\n", + "tscv = TimeSeriesSplit(n_splits=4)\n", + "res = []\n", + "for k, (a, b) in enumerate(tscv.split(days)):\n", + " tr, te = d[d.day.isin(days[a])], d[d.day.isin(days[b])]\n", + " m = GradientBoostingRegressor(n_estimators=150, max_depth=3, learning_rate=0.08, random_state=0).fit(tr[FEATS], tr[\"delay\"])\n", + " p = te[\"t0\"] * (1 + 0.08 * te[\"rain\"]) + m.predict(te[FEATS])\n", + " pb = te[\"t0\"] + te[\"bpr_base\"]\n", + " res.append((k + 1, len(tr), len(te), mape(te[\"transit\"], pb), mape(te[\"transit\"], p)))\n", + "cv = pd.DataFrame(res, columns=[\"fold\", \"학습\", \"검증\", \"BPR MAPE(%)\", \"기준모델 MAPE(%)\"]).round(2)\n", + "print(cv.to_string(index=False))\n", + "mm = cv[\"기준모델 MAPE(%)\"].mean()\n", + "print(f\" 교차검증 평균 MAPE {mm:.2f}% (목표 25% 이하: {'달성' if mm <= 25 else '미달'}), 폴드가 뒤로 갈수록 학습 구간이 길어져 미래 정보 누수가 없다\")\n", + "m = GradientBoostingRegressor(n_estimators=150, max_depth=3, learning_rate=0.08, random_state=0).fit(d[FEATS], d[\"delay\"])\n", + "imp = pd.Series(m.feature_importances_, index=FEATS).sort_values(ascending=False)\n", + "print(\" 피처 중요도 상위 5:\", \", \".join(f\"{k} {v:.2f}\" for k, v in imp.head(5).items()), f\"/ 중요도 1% 미만 {int((imp < 0.01).sum())}개(차원 축소 후보)\")\n", + "\n", + "fig, ax = plt.subplots(1, 2, figsize=(12, 4.5))\n", + "for s, (u, v) in ARCS.items():\n", + " pos = {\"SRC\": (0, 1.5), \"J1\": (1, 3), \"J2\": (2, 3), \"J3\": (1.3, 1.5), \"J4\": (1, 0), \"DST\": (3, 1.5)}\n", + " ax[0].plot([pos[u][0], pos[v][0]], [pos[u][1], pos[v][1]], \"r-\" if s in cut0 else \"gray\", lw=2 + CAP[s] / 40)\n", + " ax[0].text((pos[u][0] + pos[v][0]) / 2, (pos[u][1] + pos[v][1]) / 2, s, fontsize=9)\n", + "ax[0].set_title(\"Network (red = min-cut bottleneck)\"); ax[0].axis(\"off\")\n", + "imp.head(10)[::-1].plot.barh(ax=ax[1]); ax[1].set_title(\"Feature importance (top 10)\")\n", + "plt.tight_layout(); plt.show()\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 04 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + "[1] 노선망 최대 통과능력 290대/시, 최소 절단(병목) 구간: A2, B1, C1, X1\n", + "[2] 구간별 증설 민감도(병목일수록 증설 효과가 큼)\n", + "구간 유형 용량 용량 20% 증설 시 증가(대/시)\n", + "B1 일반 100 15.0\n", + "C1 일반 70 14.0\n", + "X1 교차로 50 10.0\n", + "A2 교량 70 10.0\n", + "A1 일반 130 0.0\n", + "\n", + "[3] 최종일(day 149) 사전 병목 경보: 유효 이용률 0.9 이상 구간 ['A1', 'A2']\n", + "\n", + "[4] 구간 폐쇄 시나리오(수요 200대/시): 우회 경로와 평균 통과시간\n", + " 시나리오 새 병목 최대유량 평균 통과(분) 주 경로 수요 대비\n", + " 평시 - 290.0 40.9 A·B·C 분산 수용\n", + "A2 폐쇄 B1,C1,X1 220.0 44.9 B1->B3 수용\n", + "B2 폐쇄 A2,B3,C1 235.0 41.0 B1->B3 수용\n", + "A3 폐쇄 B3,C1 165.0 70.9 B1->B3 과부하\n", + " A2 폐쇄 시 평균 통과시간 +4.0분, 수요 200대/시 대비 최대유량 220대/시 -> 수용(과부하면 우회만으로 부족하므로 물량 조정·타 수단 검토가 필요)\n", + "\n", + "[5] 파생 피처 14개 생성(lag·이용률·BPR·폭증비·요일 순환·구간 더미 등)\n", + " fold 학습 검증 BPR MAPE(%) 기준모델 MAPE(%)\n", + " 1 310 280 6.72 3.00\n", + " 2 590 280 6.02 2.54\n", + " 3 870 280 9.16 3.93\n", + " 4 1150 280 9.24 2.78\n", + " 교차검증 평균 MAPE 3.06% (목표 25% 이하: 달성), 폴드가 뒤로 갈수록 학습 구간이 길어져 미래 정보 누수가 없다\n", + " 피처 중요도 상위 5: util_eff 0.35, util_eff4 0.34, bpr_base 0.13, lag1_delay 0.09, km 0.02 / 중요도 1% 미만 14개(차원 축소 후보)\n", + "```\n", + "
\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 05. 우회 수송 시 추가 소요 시간 및 유류 소모량 산출 엔진 개발\n", + "**소요시간**: 약 3.5시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "우회 수송 시 추가 소요 시간 및 유류 소모량 산출 엔진을 개발한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **우회 수송 시 추가 소요 시간과 유류 소모량을 산출하는 엔진을 개발하고, 기준 모델 대비 앙상블 예측 모델의 개선율을 검증한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **증분 배정으로 구간 폐쇄·정비 시나리오별 우회 경로를 구하고 대당 추가 소요 시간, 추가 거리, 추가 유류와 일 추가 유류량 산출하고 모델에 적용하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `src/models/mov01_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": [ + "\"\"\"우회 수송 시 추가 소요 시간과 유류 소모량을 산출하는 엔진을 구현하고, 기준 모델(BPR) 대비 앙상블 지체 예측 모델의 오차 개선율과 R2를 Hold-out 데이터로 검증한다.\"\"\"\n", + "import numpy as np\n", + "import pandas as pd\n", + "from scipy.optimize import linprog\n", + "from sklearn.ensemble import GradientBoostingRegressor, RandomForestRegressor\n", + "from sklearn.metrics import r2_score\n", + "\n", + "# ---------------------------------------------------------------------------\n", + "# [교육용가상자료] 가상 노선망·구간 통과량 생성기 (실제 도로·지명·부대 없음)\n", + "# 가상 출발지(SRC)에서 가상 목적지(DST)까지 3개 축선(A·B·C)과 연결로 2개, 구간 10개. 150일치 피크시간 통과량·지체 시간을 만든다.\n", + "# ---------------------------------------------------------------------------\n", + "SEGS = pd.DataFrame([\n", + " (\"A1\", \"SRC\", \"J1\", 12, 130, \"일반\"), (\"A2\", \"J1\", \"J2\", 8, 70, \"교량\"), (\"A3\", \"J2\", \"DST\", 10, 140, \"교차로\"),\n", + " (\"B1\", \"SRC\", \"J3\", 15, 100, \"일반\"), (\"B2\", \"J3\", \"J2\", 9, 80, \"터널\"), (\"B3\", \"J3\", \"DST\", 14, 95, \"일반\"),\n", + " (\"C1\", \"SRC\", \"J4\", 18, 70, \"일반\"), (\"C2\", \"J4\", \"DST\", 20, 75, \"교량\"),\n", + " (\"X1\", \"J1\", \"J3\", 7, 50, \"교차로\"), (\"X2\", \"J4\", \"J3\", 10, 60, \"일반\")],\n", + " columns=[\"seg\", \"u\", \"v\", \"km\", \"cap\", \"kind\"]) # cap = 피크시간 통과 용량(대/시, 교육용 가정값)\n", + "KSPD = {\"일반\": 55, \"교량\": 40, \"터널\": 45, \"교차로\": 45} # 구간 유형별 자유 주행 속도(km/h, 가정값)\n", + "SEGS[\"t0\"] = SEGS[\"km\"] / SEGS[\"kind\"].map(KSPD) * 60 # 자유 주행 시간(분)\n", + "UTIL = dict(A1=.78, A2=.85, A3=.66, B1=.70, B2=.72, B3=.64, C1=.55, C2=.58, X1=.45, X2=.40) # 평시 이용률\n", + "\n", + "\n", + "def bpr(t0, v, cap, a=0.15, b=4.0):\n", + " \"\"\"BPR 지체 함수(교통공학의 표준형): 통과량/용량 비가 커질수록 지체가 급증한다. (분)\"\"\"\n", + " # [TODO : 학생 실습 5] 우회 수송 시 추가 소요 시간 및 유류 소모량 산출 엔진 개발 관련 핵심 로직을 구현하세요.\n", + " # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n", + " # <<< 여기에 코드를 작성하세요 >>>\n", + " pass # 작성 후 pass를 제거하세요.\n", + "def make_data(seed=11, days=150):\n", + " \"\"\"구간 x 일자 패널. v=실제 피크 통과량, plan_v=수송계획 수량(긴급 불출 미반영), delay=실제 지체(분).\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " d_idx = np.arange(days)\n", + " lvl = np.array([1.0, 1.08, 1.04, 1.10, 1.02, 0.82, 0.78])[d_idx % 7] * (1 + 0.0012 * d_idx)\n", + " lvl = lvl * np.where((d_idx >= 100) & (d_idx < 110), 1.30, 1.0) # 수송량 폭증 기간(가상)\n", + " prev, rows = {s: 0.0 for s in SEGS[\"seg\"]}, []\n", + " for d in d_idx:\n", + " rain = bool(rng.random() < 0.18)\n", + " for r in SEGS.itertuples():\n", + " maint, urgent = bool(rng.random() < 0.06), bool(rng.random() < 0.05) # 비계획 정비 / 긴급 불출\n", + " v = r.cap * UTIL[r.seg] * lvl[d] * (1.25 if urgent else 1.0) * rng.normal(1, 0.05)\n", + " cap_eff = r.cap * (0.65 if maint else 1.0) * (0.92 if rain else 1.0)\n", + " delay = max(r.t0 * 0.2 * (v / cap_eff) ** 3.5 + 0.25 * prev[r.seg] + rng.normal(0, 0.4), 0.0)\n", + " prev[r.seg] = delay\n", + " rows.append(dict(day=int(d), dow=int(d % 7), seg=r.seg, kind=r.kind, km=r.km, cap=r.cap, t0=r.t0,\n", + " plan_v=v / (1.25 if urgent else 1.0) * rng.normal(1, 0.04), v=v, maint=int(maint), urgent=int(urgent),\n", + " rain=int(rain), cap_eff=cap_eff, delay=delay))\n", + " df = pd.DataFrame(rows)\n", + " df[\"transit\"] = df[\"t0\"] * (1 + 0.08 * df[\"rain\"]) + df[\"delay\"]\n", + " return df\n", + "\n", + "\n", + "def mape(y, p):\n", + " # [TODO : 학생 실습 5] 우회 수송 시 추가 소요 시간 및 유류 소모량 산출 엔진 개발 관련 핵심 로직을 구현하세요.\n", + " # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n", + " # <<< 여기에 코드를 작성하세요 >>>\n", + " pass # 작성 후 pass를 제거하세요.\n", + "def mae(y, p):\n", + " return float(np.mean(np.abs(np.asarray(y, float) - np.asarray(p, float))))\n", + "# ---------------------------------------------------------------------------\n", + "# ---------------------------------------------------------------------------\n", + "# 지체 예측용 파생 피처 생성(당일 오전 시점에 알 수 있는 정보만 사용: 계획 물량, 공지 정비, 기상 예보, 전일·전주 실적)\n", + "# ---------------------------------------------------------------------------\n", + "FEATS = [\"plan_v\", \"cap\", \"cap_eff\", \"t0\", \"km\", \"maint\", \"rain\", \"util\", \"util_eff\", \"util_eff4\", \"bpr_base\", \"lag1_delay\", \"lag7_delay\",\n", + " \"roll7_v\", \"surge_ratio\", \"is_weekend\", \"dow_sin\", \"dow_cos\", \"k_교량\", \"k_터널\", \"k_교차로\"]\n", + "\n", + "\n", + "def add_features(df):\n", + " d = df.sort_values([\"seg\", \"day\"]).copy()\n", + " g = d.groupby(\"seg\")\n", + " d[\"lag1_delay\"] = g[\"delay\"].shift(1)\n", + " d[\"lag7_delay\"] = g[\"delay\"].shift(7)\n", + " d[\"roll7_v\"] = g[\"v\"].transform(lambda s: s.shift(1).rolling(7).mean())\n", + " d[\"util\"] = d[\"plan_v\"] / d[\"cap\"]\n", + " d[\"util_eff\"] = d[\"plan_v\"] / d[\"cap_eff\"]\n", + " d[\"util_eff4\"] = d[\"util_eff\"] ** 4\n", + " d[\"bpr_base\"] = bpr(d[\"t0\"], d[\"plan_v\"], d[\"cap\"])\n", + " d[\"surge_ratio\"] = d[\"plan_v\"] / d[\"roll7_v\"]\n", + " d[\"is_weekend\"] = (d[\"dow\"] >= 5).astype(int)\n", + " d[\"dow_sin\"], d[\"dow_cos\"] = np.sin(2 * np.pi * d[\"dow\"] / 7), np.cos(2 * np.pi * d[\"dow\"] / 7)\n", + " for k in (\"교량\", \"터널\", \"교차로\"):\n", + " d[\"k_\" + k] = (d[\"kind\"] == k).astype(int)\n", + " return d.dropna().sort_values([\"day\", \"seg\"]).reset_index(drop=True)\n", + "# ---------------------------------------------------------------------------\n", + "# ---------------------------------------------------------------------------\n", + "# 노선망 흐름 도구: 최대유량(Edmonds-Karp), 증분 배정(혼잡 반영 최단시간 경로), 경로 열거\n", + "# ---------------------------------------------------------------------------\n", + "CAP = dict(zip(SEGS[\"seg\"], SEGS[\"cap\"]))\n", + "T0 = dict(zip(SEGS[\"seg\"], SEGS[\"t0\"]))\n", + "KM = dict(zip(SEGS[\"seg\"], SEGS[\"km\"]))\n", + "ARCS = {r.seg: (r.u, r.v) for r in SEGS.itertuples()}\n", + "SRC, DST = \"SRC\", \"DST\"\n", + "\n", + "\n", + "def seg_time(s, flow, cap=None, a=0.2, b=3.5):\n", + " \"\"\"구간 통과시간(분) = 자유주행 + 혼잡 지체. cap을 주면 용량을 대체(정비·기상 반영).\"\"\"\n", + " r = flow / (cap or CAP[s])\n", + " return T0[s] * (1 + a * r ** b + 6.0 * max(0.0, r - 1.0)) # 용량 초과분은 대기열로 급증\n", + "\n", + "\n", + "def max_flow(closed=(), capmul=None):\n", + " \"\"\"최대유량(대/시)과 최소 절단 구간(병목). capmul = {구간: 용량 배율}.\"\"\"\n", + " cap = {s: (0 if s in closed else CAP[s] * (capmul or {}).get(s, 1.0)) for s in CAP}\n", + " res = {}\n", + " for s, (u, v) in ARCS.items():\n", + " res[(u, v)] = res.get((u, v), 0) + cap[s]\n", + " res.setdefault((v, u), 0)\n", + " flow = 0.0\n", + " while True:\n", + " prev, q = {SRC: None}, [SRC]\n", + " for u in q:\n", + " for (a, b), c in res.items():\n", + " if a == u and c > 1e-9 and b not in prev:\n", + " prev[b] = u; q.append(b)\n", + " if DST not in prev:\n", + " break\n", + " path, x = [], DST\n", + " while prev[x]:\n", + " path.append((prev[x], x)); x = prev[x]\n", + " f = min(res[e] for e in path)\n", + " for a, b in path:\n", + " res[(a, b)] -= f; res[(b, a)] += f\n", + " flow += f\n", + " reach = set(prev)\n", + " cut = [s for s, (u, v) in ARCS.items() if u in reach and v not in reach and cap[s] > 0]\n", + " return flow, cut\n", + "\n", + "\n", + "def all_paths():\n", + " \"\"\"SRC -> DST 단순 경로(구간 이름 튜플) 전체.\"\"\"\n", + " out = []\n", + "\n", + " def dfs(node, segs, seen):\n", + " if node == DST:\n", + " out.append(tuple(segs)); return\n", + " for s, (u, v) in ARCS.items():\n", + " if u == node and v not in seen:\n", + " dfs(v, segs + [s], seen | {v})\n", + " dfs(SRC, [], {SRC})\n", + " return out\n", + "\n", + "\n", + "PATHS = all_paths()\n", + "\n", + "\n", + "def assign(demand, closed=(), capmul=None, steps=40):\n", + " \"\"\"증분 배정: 수요를 steps조각으로 나눠, 전체 통과시간(대수 x 시간) 증가가 가장 작은 경로에 차례로 얹는다(시스템 최적 근사).\"\"\"\n", + " cap = {s: CAP[s] * (capmul or {}).get(s, 1.0) for s in CAP}\n", + " flow, pf = {s: 0.0 for s in CAP}, {}\n", + " ok = [p for p in PATHS if not any(s in closed for s in p)]\n", + " if not ok:\n", + " return None\n", + " for _ in range(steps):\n", + " chunk = demand / steps\n", + " best = min(ok, key=lambda p: sum((flow[s] + chunk) * seg_time(s, flow[s] + chunk, cap[s]) - flow[s] * seg_time(s, flow[s], cap[s]) for s in p))\n", + " for s in best:\n", + " flow[s] += chunk\n", + " pf[best] = pf.get(best, 0) + chunk\n", + " tt = {p: sum(seg_time(s, flow[s], cap[s]) for s in p) for p in pf}\n", + " avg = sum(pf[p] * tt[p] for p in pf) / demand\n", + " return {\"flow\": flow, \"paths\": pf, \"path_min\": tt, \"avg_min\": avg, \"util\": {s: flow[s] / cap[s] for s in cap if cap[s] > 0}}\n", + "\n", + "\n", + "FIXED_SHARE = {(\"A1\", \"A2\", \"A3\"): 0.32, (\"B1\", \"B3\"): 0.40, (\"C1\", \"C2\"): 0.28} # 과거 관행: 축선별 고정 배분(가상)\n", + "\n", + "\n", + "def fixed_assign(demand, closed=(), capmul=None):\n", + " \"\"\"과거 관행 배분: 축선별 고정 비율. 막힌 축선 물량은 열려 있는 축선에 비율대로 재분배.\"\"\"\n", + " cap = {s: CAP[s] * (capmul or {}).get(s, 1.0) for s in CAP}\n", + " live = {p: w for p, w in FIXED_SHARE.items() if not any(s in closed for s in p)}\n", + " if not live:\n", + " return None\n", + " tot = sum(live.values())\n", + " flow = {s: 0.0 for s in CAP}\n", + " pf = {p: demand * w / tot for p, w in live.items()}\n", + " for p, f in pf.items():\n", + " for s in p:\n", + " flow[s] += f\n", + " tt = {p: sum(seg_time(s, flow[s], cap[s]) for s in p) for p in pf}\n", + " return {\"flow\": flow, \"paths\": pf, \"path_min\": tt, \"avg_min\": sum(pf[p] * tt[p] for p in pf) / demand,\n", + " \"util\": {s: flow[s] / cap[s] for s in cap if cap[s] > 0}}\n", + "\n", + "\n", + "def lp_assign(demand, closed=(), capmul=None):\n", + " \"\"\"선형계획(LP): 구간 용량 상한(이용률 rho) 아래에서 자유주행시간 합을 최소화하는 경로별 물량. 불가능하면 rho를 1.0까지 완화.\"\"\"\n", + " cap = {s: CAP[s] * (capmul or {}).get(s, 1.0) for s in CAP}\n", + " ok = [p for p in PATHS if not any(s in closed for s in p)]\n", + " if not ok:\n", + " return None\n", + " for rho in (0.85, 0.95, 1.0):\n", + " r = linprog([sum(T0[s] for s in p) for p in ok], A_ub=[[1.0 if s in p else 0.0 for p in ok] for s in CAP],\n", + " b_ub=[rho * cap[s] for s in CAP], A_eq=[[1.0] * len(ok)], b_eq=[demand], bounds=(0, None), method=\"highs\")\n", + " if r.status == 0:\n", + " break\n", + " else:\n", + " return assign(demand, closed, capmul) # 용량 초과 수요: 증분 배정으로 대체(과부하 표시)\n", + " flow = {s: sum(r.x[i] for i, p in enumerate(ok) if s in p) for s in CAP}\n", + " pf = {p: float(r.x[i]) for i, p in enumerate(ok) if r.x[i] > 1e-6}\n", + " tt = {p: sum(seg_time(s, flow[s], cap[s]) for s in p) for p in pf}\n", + " return {\"flow\": flow, \"paths\": pf, \"path_min\": tt, \"avg_min\": sum(pf[p] * tt[p] for p in pf) / demand,\n", + " \"util\": {s: flow[s] / cap[s] for s in cap if cap[s] > 0}, \"rho\": rho}\n", + "\n", + "\n", + "def cost_of(res, tv=1000, fuel_km=450):\n", + " \"\"\"시간당 비용(원, 교육용 가정): 대당 시간비용 tv원/분 + 유류비 fuel_km원/km.\"\"\"\n", + " return sum(f * (res[\"path_min\"][p] * tv + sum(KM[s] for s in p) * fuel_km) for p, f in res[\"paths\"].items())\n", + "# ---------------------------------------------------------------------------\n", + "\n", + "# ---------------- 1) 우회 엔진: 추가 소요 시간·유류 (가정값: 대당 주행 L/km, 정체 공회전 L/분) ----------------\n", + "L_KM, L_IDLE = 0.30, 0.08 # 교육용 가정 유류 계수(실제 장비 제원 아님)\n", + "\n", + "\n", + "def trip(res, fleet_vehicles):\n", + " \"\"\"경로별 물량 비율로 평균 통과시간·거리·유류(대당 L)를 구하고 총량으로 환산한다.\"\"\"\n", + " tot = sum(res[\"paths\"].values())\n", + " km = sum(f * sum(KM[s] for s in p) for p, f in res[\"paths\"].items()) / tot\n", + " mins = res[\"avg_min\"]\n", + " free = sum(f * sum(T0[s] for s in p) for p, f in res[\"paths\"].items()) / tot\n", + " fuel = km * L_KM + max(mins - free, 0) * L_IDLE\n", + " return {\"min\": mins, \"km\": km, \"fuel_l\": fuel, \"fleet_fuel_l\": fuel * fleet_vehicles}\n", + "\n", + "\n", + "DEMAND, FLEET = 200, 600 # 피크 200대/시, 일 수송 600대(가정)\n", + "norm = trip(assign(DEMAND), FLEET)\n", + "rows = []\n", + "for name, closed, cm in [(\"A2 교량 폐쇄\", (\"A2\",), None), (\"B2 터널 폐쇄\", (\"B2\",), None), (\"A1 용량 50% 감소(정비)\", (), {\"A1\": 0.5}), (\"A2 폐쇄 + B1 정비(65%)\", (\"A2\",), {\"B1\": 0.65})]:\n", + " r = assign(DEMAND, closed, cm)\n", + " t = trip(r, FLEET)\n", + " rows.append((name, t[\"min\"] - norm[\"min\"], t[\"km\"] - norm[\"km\"], t[\"fuel_l\"] - norm[\"fuel_l\"], t[\"fleet_fuel_l\"] - norm[\"fleet_fuel_l\"], max(r[\"util\"].values())))\n", + "det = pd.DataFrame(rows, columns=[\"시나리오\", \"추가 소요(분/대)\", \"추가 거리(km/대)\", \"추가 유류(L/대)\", \"일 추가 유류(L)\", \"최대 이용률\"]).round(2)\n", + "print(f\"[1] 우회 엔진 결과(평시 기준 {norm['min']:.1f}분, {norm['km']:.1f}km, {norm['fuel_l']:.2f}L/대)\")\n", + "print(det.to_string(index=False))\n", + "print(\" 유류 계수는 교육용 가정값이다. 이용률 1.0 초과 시나리오는 우회만으로 수용이 어려워 사람의 물량 조정 판단이 필요하다.\")\n", + "\n", + "# ---------------- 2) 고급 모델: 기준 모델(BPR) vs 앙상블(GBR+RF), Hold-out ----------------\n", + "df = make_data(); d = add_features(df)\n", + "tr, te = d[d.day < 120], d[d.day >= 120]\n", + "gbr = GradientBoostingRegressor(n_estimators=250, max_depth=3, learning_rate=0.06, subsample=0.8, random_state=0).fit(tr[FEATS], tr[\"delay\"])\n", + "rf = RandomForestRegressor(n_estimators=150, min_samples_leaf=3, random_state=0, n_jobs=1).fit(tr[FEATS], tr[\"delay\"])\n", + "fac = te[\"t0\"] * (1 + 0.08 * te[\"rain\"])\n", + "P = {\"기준(BPR)\": te[\"t0\"] + te[\"bpr_base\"], \"GBR\": fac + gbr.predict(te[FEATS]), \"RandomForest\": fac + rf.predict(te[FEATS])}\n", + "P[\"앙상블(평균)\"] = (P[\"GBR\"] + P[\"RandomForest\"]) / 2\n", + "tab = pd.DataFrame({k: {\"MAE(분)\": mae(te.transit, v), \"RMSE(분)\": float(np.sqrt(np.mean((te.transit - v) ** 2))), \"MAPE(%)\": mape(te.transit, v), \"R2\": r2_score(te.transit, v)} for k, v in P.items()}).T.round(3)\n", + "print(f\"\\n[2] Hold-out(day 120~149, {len(te)}행) 통과시간 예측 성능\")\n", + "print(tab.to_string())\n", + "b, e = tab.loc[\"기준(BPR)\"], tab.loc[\"앙상블(평균)\"]\n", + "print(f\" MAE 개선 {1 - e['MAE(분)'] / b['MAE(분)']:.1%}, RMSE 개선 {1 - e['RMSE(분)'] / b['RMSE(분)']:.1%} (목표 20% 이상: {'달성' if 1 - e['MAE(분)'] / b['MAE(분)'] >= .2 and 1 - e['RMSE(분)'] / b['RMSE(분)'] >= .2 else '미달'}), R2 {e['R2']:.3f} (목표 0.85 이상: {'달성' if e['R2'] >= .85 else '미달'})\")\n", + "dl = te[\"delay\"]; pdl = P[\"앙상블(평균)\"] - fac\n", + "print(f\" 참고: 지체 시간만 놓고 보면 R2 {r2_score(dl, pdl):.3f}, MAE {mae(dl, pdl):.2f}분 (기준 BPR MAE {mae(dl, te['bpr_base']):.2f}분). 통과시간 R2는 구간 간 길이 차이를 포함한다.\")\n", + "\n", + "# ---------------- 3) 불규칙 상황(비정형 수요) 적응력: 정비일·폭증 기간 오차 ----------------\n", + "stress = {\"비계획 정비일\": te[\"maint\"] == 1, \"긴급 불출일\": te[\"urgent\"] == 1, \"우천일\": te[\"rain\"] == 1, \"그 외\": (te[\"maint\"] == 0) & (te[\"urgent\"] == 0) & (te[\"rain\"] == 0)}\n", + "rr = [(k, int(m.sum()), mae(te.transit[m], P[\"기준(BPR)\"][m]), mae(te.transit[m], P[\"앙상블(평균)\"][m])) for k, m in stress.items() if m.sum() > 0]\n", + "print(\"\\n[3] 조건별 MAE(분): 기준 vs 앙상블\")\n", + "print(pd.DataFrame(rr, columns=[\"조건\", \"건수\", \"기준\", \"앙상블\"]).round(2).to_string(index=False))\n", + "print(\"하이퍼파라미터(트리 250개, 깊이 3, 학습률 0.06)는 시계열 교차검증(M4) 결과를 바탕으로 정했고, 학습된 모델은 이 실행기에서는 저장하지 않고 설정만 출력한다.\")\n", + "print(\"설정:\", {\"gbr\": gbr.get_params()[\"n_estimators\"], \"rf\": rf.get_params()[\"n_estimators\"], \"features\": len(FEATS)})\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 05 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + "[1] 우회 엔진 결과(평시 기준 40.9분, 31.2km, 9.63L/대)\n", + " 시나리오 추가 소요(분/대) 추가 거리(km/대) 추가 유류(L/대) 일 추가 유류(L) 최대 이용률\n", + " A2 교량 폐쇄 3.96 2.38 0.81 485.68 1.0\n", + " B2 터널 폐쇄 0.05 -0.12 -0.02 -10.48 1.0\n", + " A1 용량 50% 감소(정비) 0.98 0.10 0.10 60.12 1.0\n", + "A2 폐쇄 + B1 정비(65%) 12.90 3.98 1.85 1113.00 1.3\n", + " 유류 계수는 교육용 가정값이다. 이용률 1.0 초과 시나리오는 우회만으로 수용이 어려워 사람의 물량 조정 판단이 필요하다.\n", + "\n", + "[2] Hold-out(day 120~149, 300행) 통과시간 예측 성능\n", + " MAE(분) RMSE(분) MAPE(%) R2\n", + "기준(BPR) 1.763 2.662 9.245 0.829\n", + "GBR 0.494 0.877 2.784 0.981\n", + "RandomForest 0.495 0.877 2.813 0.981\n", + "앙상블(평균) 0.475 0.847 2.725 0.983\n", + " MAE 개선 73.1%, RMSE 개선 68.2% (목표 20% 이상: 달성), R2 0.983 (목표 0.85 이상: 달성)\n", + " 참고: 지체 시간만 놓고 보면 R2 0.846, MAE 0.47분 (기준 BPR MAE 1.52분). 통과시간 R2는 구간 간 길이 차이를 포함한다.\n", + "\n", + "[3] 조건별 MAE(분): 기준 vs 앙상블\n", + " 조건 건수 기준 앙상블\n", + "비계획 정비일 23 5.72 0.81\n", + " 긴급 불출일 16 3.98 2.17\n", + " 우천일 60 3.32 0.61\n", + " 그 외 211 1.06 0.38\n", + "하이퍼파라미터(트리 250개, 깊이 3, 학습률 0.06)는 시계열 교차검증(M4) 결과를 바탕으로 정했고, 학습된 모델은 이 실행기에서는 저장하지 않고 설정만 출력한다.\n", + "설정: {'gbr': 250, 'rf': 150, 'features': 21}\n", + "```\n", + "
\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 06. 전시 보급로 병목 혼잡도 및 우회 경로 비교 시뮬레이션 대시보드 구현\n", + "**소요시간**: 약 3.0시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "보급로 병목 혼잡도 및 우회 경로 비교 대시보드를 구현한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **보급로 병목 혼잡도와 우회 경로 비교 대시보드를 구현하고, 예측 건마다 기여도 상위 요인을 설명한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **구간별 이용률 히트맵, 오늘의 예측 지체와 경보 기준, 고정 배분 대비 우회 배정 비교, 최악 사례 요인 기여도를 2x2 화면으로 그린다 과정을 진행하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `src/explainability/mov01_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개 요인을 자동 산출(요인군 제거 방식 설명)해 자연어 요약으로 보여 준다.\"\"\"\n", + "import numpy as np\n", + "import pandas as pd\n", + "import matplotlib.pyplot as plt\n", + "from scipy.optimize import linprog\n", + "from sklearn.ensemble import GradientBoostingRegressor\n", + "\n", + "# ---------------------------------------------------------------------------\n", + "# [교육용가상자료] 가상 노선망·구간 통과량 생성기 (실제 도로·지명·부대 없음)\n", + "# 가상 출발지(SRC)에서 가상 목적지(DST)까지 3개 축선(A·B·C)과 연결로 2개, 구간 10개. 150일치 피크시간 통과량·지체 시간을 만든다.\n", + "# ---------------------------------------------------------------------------\n", + "SEGS = pd.DataFrame([\n", + " (\"A1\", \"SRC\", \"J1\", 12, 130, \"일반\"), (\"A2\", \"J1\", \"J2\", 8, 70, \"교량\"), (\"A3\", \"J2\", \"DST\", 10, 140, \"교차로\"),\n", + " (\"B1\", \"SRC\", \"J3\", 15, 100, \"일반\"), (\"B2\", \"J3\", \"J2\", 9, 80, \"터널\"), (\"B3\", \"J3\", \"DST\", 14, 95, \"일반\"),\n", + " (\"C1\", \"SRC\", \"J4\", 18, 70, \"일반\"), (\"C2\", \"J4\", \"DST\", 20, 75, \"교량\"),\n", + " (\"X1\", \"J1\", \"J3\", 7, 50, \"교차로\"), (\"X2\", \"J4\", \"J3\", 10, 60, \"일반\")],\n", + " columns=[\"seg\", \"u\", \"v\", \"km\", \"cap\", \"kind\"]) # cap = 피크시간 통과 용량(대/시, 교육용 가정값)\n", + "KSPD = {\"일반\": 55, \"교량\": 40, \"터널\": 45, \"교차로\": 45} # 구간 유형별 자유 주행 속도(km/h, 가정값)\n", + "SEGS[\"t0\"] = SEGS[\"km\"] / SEGS[\"kind\"].map(KSPD) * 60 # 자유 주행 시간(분)\n", + "UTIL = dict(A1=.78, A2=.85, A3=.66, B1=.70, B2=.72, B3=.64, C1=.55, C2=.58, X1=.45, X2=.40) # 평시 이용률\n", + "\n", + "\n", + "def bpr(t0, v, cap, a=0.15, b=4.0):\n", + " \"\"\"BPR 지체 함수(교통공학의 표준형): 통과량/용량 비가 커질수록 지체가 급증한다. (분)\"\"\"\n", + " # [TODO : 학생 실습 6] 전시 보급로 병목 혼잡도 및 우회 경로 비교 시뮬레이션 대시보드 구현 관련 핵심 로직을 구현하세요.\n", + " # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n", + " # <<< 여기에 코드를 작성하세요 >>>\n", + " pass # 작성 후 pass를 제거하세요.\n", + "def make_data(seed=11, days=150):\n", + " \"\"\"구간 x 일자 패널. v=실제 피크 통과량, plan_v=수송계획 수량(긴급 불출 미반영), delay=실제 지체(분).\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " d_idx = np.arange(days)\n", + " lvl = np.array([1.0, 1.08, 1.04, 1.10, 1.02, 0.82, 0.78])[d_idx % 7] * (1 + 0.0012 * d_idx)\n", + " lvl = lvl * np.where((d_idx >= 100) & (d_idx < 110), 1.30, 1.0) # 수송량 폭증 기간(가상)\n", + " prev, rows = {s: 0.0 for s in SEGS[\"seg\"]}, []\n", + " for d in d_idx:\n", + " rain = bool(rng.random() < 0.18)\n", + " for r in SEGS.itertuples():\n", + " maint, urgent = bool(rng.random() < 0.06), bool(rng.random() < 0.05) # 비계획 정비 / 긴급 불출\n", + " v = r.cap * UTIL[r.seg] * lvl[d] * (1.25 if urgent else 1.0) * rng.normal(1, 0.05)\n", + " cap_eff = r.cap * (0.65 if maint else 1.0) * (0.92 if rain else 1.0)\n", + " delay = max(r.t0 * 0.2 * (v / cap_eff) ** 3.5 + 0.25 * prev[r.seg] + rng.normal(0, 0.4), 0.0)\n", + " prev[r.seg] = delay\n", + " rows.append(dict(day=int(d), dow=int(d % 7), seg=r.seg, kind=r.kind, km=r.km, cap=r.cap, t0=r.t0,\n", + " plan_v=v / (1.25 if urgent else 1.0) * rng.normal(1, 0.04), v=v, maint=int(maint), urgent=int(urgent),\n", + " rain=int(rain), cap_eff=cap_eff, delay=delay))\n", + " df = pd.DataFrame(rows)\n", + " df[\"transit\"] = df[\"t0\"] * (1 + 0.08 * df[\"rain\"]) + df[\"delay\"]\n", + " return df\n", + "\n", + "\n", + "def mape(y, p):\n", + " # [TODO : 학생 실습 6] 전시 보급로 병목 혼잡도 및 우회 경로 비교 시뮬레이션 대시보드 구현 관련 핵심 로직을 구현하세요.\n", + " # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n", + " # <<< 여기에 코드를 작성하세요 >>>\n", + " pass # 작성 후 pass를 제거하세요.\n", + "def mae(y, p):\n", + " return float(np.mean(np.abs(np.asarray(y, float) - np.asarray(p, float))))\n", + "# ---------------------------------------------------------------------------\n", + "# ---------------------------------------------------------------------------\n", + "# 지체 예측용 파생 피처 생성(당일 오전 시점에 알 수 있는 정보만 사용: 계획 물량, 공지 정비, 기상 예보, 전일·전주 실적)\n", + "# ---------------------------------------------------------------------------\n", + "FEATS = [\"plan_v\", \"cap\", \"cap_eff\", \"t0\", \"km\", \"maint\", \"rain\", \"util\", \"util_eff\", \"util_eff4\", \"bpr_base\", \"lag1_delay\", \"lag7_delay\",\n", + " \"roll7_v\", \"surge_ratio\", \"is_weekend\", \"dow_sin\", \"dow_cos\", \"k_교량\", \"k_터널\", \"k_교차로\"]\n", + "\n", + "\n", + "def add_features(df):\n", + " d = df.sort_values([\"seg\", \"day\"]).copy()\n", + " g = d.groupby(\"seg\")\n", + " d[\"lag1_delay\"] = g[\"delay\"].shift(1)\n", + " d[\"lag7_delay\"] = g[\"delay\"].shift(7)\n", + " d[\"roll7_v\"] = g[\"v\"].transform(lambda s: s.shift(1).rolling(7).mean())\n", + " d[\"util\"] = d[\"plan_v\"] / d[\"cap\"]\n", + " d[\"util_eff\"] = d[\"plan_v\"] / d[\"cap_eff\"]\n", + " d[\"util_eff4\"] = d[\"util_eff\"] ** 4\n", + " d[\"bpr_base\"] = bpr(d[\"t0\"], d[\"plan_v\"], d[\"cap\"])\n", + " d[\"surge_ratio\"] = d[\"plan_v\"] / d[\"roll7_v\"]\n", + " d[\"is_weekend\"] = (d[\"dow\"] >= 5).astype(int)\n", + " d[\"dow_sin\"], d[\"dow_cos\"] = np.sin(2 * np.pi * d[\"dow\"] / 7), np.cos(2 * np.pi * d[\"dow\"] / 7)\n", + " for k in (\"교량\", \"터널\", \"교차로\"):\n", + " d[\"k_\" + k] = (d[\"kind\"] == k).astype(int)\n", + " return d.dropna().sort_values([\"day\", \"seg\"]).reset_index(drop=True)\n", + "# ---------------------------------------------------------------------------\n", + "# ---------------------------------------------------------------------------\n", + "# 노선망 흐름 도구: 최대유량(Edmonds-Karp), 증분 배정(혼잡 반영 최단시간 경로), 경로 열거\n", + "# ---------------------------------------------------------------------------\n", + "CAP = dict(zip(SEGS[\"seg\"], SEGS[\"cap\"]))\n", + "T0 = dict(zip(SEGS[\"seg\"], SEGS[\"t0\"]))\n", + "KM = dict(zip(SEGS[\"seg\"], SEGS[\"km\"]))\n", + "ARCS = {r.seg: (r.u, r.v) for r in SEGS.itertuples()}\n", + "SRC, DST = \"SRC\", \"DST\"\n", + "\n", + "\n", + "def seg_time(s, flow, cap=None, a=0.2, b=3.5):\n", + " \"\"\"구간 통과시간(분) = 자유주행 + 혼잡 지체. cap을 주면 용량을 대체(정비·기상 반영).\"\"\"\n", + " r = flow / (cap or CAP[s])\n", + " return T0[s] * (1 + a * r ** b + 6.0 * max(0.0, r - 1.0)) # 용량 초과분은 대기열로 급증\n", + "\n", + "\n", + "def max_flow(closed=(), capmul=None):\n", + " \"\"\"최대유량(대/시)과 최소 절단 구간(병목). capmul = {구간: 용량 배율}.\"\"\"\n", + " cap = {s: (0 if s in closed else CAP[s] * (capmul or {}).get(s, 1.0)) for s in CAP}\n", + " res = {}\n", + " for s, (u, v) in ARCS.items():\n", + " res[(u, v)] = res.get((u, v), 0) + cap[s]\n", + " res.setdefault((v, u), 0)\n", + " flow = 0.0\n", + " while True:\n", + " prev, q = {SRC: None}, [SRC]\n", + " for u in q:\n", + " for (a, b), c in res.items():\n", + " if a == u and c > 1e-9 and b not in prev:\n", + " prev[b] = u; q.append(b)\n", + " if DST not in prev:\n", + " break\n", + " path, x = [], DST\n", + " while prev[x]:\n", + " path.append((prev[x], x)); x = prev[x]\n", + " f = min(res[e] for e in path)\n", + " for a, b in path:\n", + " res[(a, b)] -= f; res[(b, a)] += f\n", + " flow += f\n", + " reach = set(prev)\n", + " cut = [s for s, (u, v) in ARCS.items() if u in reach and v not in reach and cap[s] > 0]\n", + " return flow, cut\n", + "\n", + "\n", + "def all_paths():\n", + " \"\"\"SRC -> DST 단순 경로(구간 이름 튜플) 전체.\"\"\"\n", + " out = []\n", + "\n", + " def dfs(node, segs, seen):\n", + " if node == DST:\n", + " out.append(tuple(segs)); return\n", + " for s, (u, v) in ARCS.items():\n", + " if u == node and v not in seen:\n", + " dfs(v, segs + [s], seen | {v})\n", + " dfs(SRC, [], {SRC})\n", + " return out\n", + "\n", + "\n", + "PATHS = all_paths()\n", + "\n", + "\n", + "def assign(demand, closed=(), capmul=None, steps=40):\n", + " \"\"\"증분 배정: 수요를 steps조각으로 나눠, 전체 통과시간(대수 x 시간) 증가가 가장 작은 경로에 차례로 얹는다(시스템 최적 근사).\"\"\"\n", + " cap = {s: CAP[s] * (capmul or {}).get(s, 1.0) for s in CAP}\n", + " flow, pf = {s: 0.0 for s in CAP}, {}\n", + " ok = [p for p in PATHS if not any(s in closed for s in p)]\n", + " if not ok:\n", + " return None\n", + " for _ in range(steps):\n", + " chunk = demand / steps\n", + " best = min(ok, key=lambda p: sum((flow[s] + chunk) * seg_time(s, flow[s] + chunk, cap[s]) - flow[s] * seg_time(s, flow[s], cap[s]) for s in p))\n", + " for s in best:\n", + " flow[s] += chunk\n", + " pf[best] = pf.get(best, 0) + chunk\n", + " tt = {p: sum(seg_time(s, flow[s], cap[s]) for s in p) for p in pf}\n", + " avg = sum(pf[p] * tt[p] for p in pf) / demand\n", + " return {\"flow\": flow, \"paths\": pf, \"path_min\": tt, \"avg_min\": avg, \"util\": {s: flow[s] / cap[s] for s in cap if cap[s] > 0}}\n", + "\n", + "\n", + "FIXED_SHARE = {(\"A1\", \"A2\", \"A3\"): 0.32, (\"B1\", \"B3\"): 0.40, (\"C1\", \"C2\"): 0.28} # 과거 관행: 축선별 고정 배분(가상)\n", + "\n", + "\n", + "def fixed_assign(demand, closed=(), capmul=None):\n", + " \"\"\"과거 관행 배분: 축선별 고정 비율. 막힌 축선 물량은 열려 있는 축선에 비율대로 재분배.\"\"\"\n", + " cap = {s: CAP[s] * (capmul or {}).get(s, 1.0) for s in CAP}\n", + " live = {p: w for p, w in FIXED_SHARE.items() if not any(s in closed for s in p)}\n", + " if not live:\n", + " return None\n", + " tot = sum(live.values())\n", + " flow = {s: 0.0 for s in CAP}\n", + " pf = {p: demand * w / tot for p, w in live.items()}\n", + " for p, f in pf.items():\n", + " for s in p:\n", + " flow[s] += f\n", + " tt = {p: sum(seg_time(s, flow[s], cap[s]) for s in p) for p in pf}\n", + " return {\"flow\": flow, \"paths\": pf, \"path_min\": tt, \"avg_min\": sum(pf[p] * tt[p] for p in pf) / demand,\n", + " \"util\": {s: flow[s] / cap[s] for s in cap if cap[s] > 0}}\n", + "\n", + "\n", + "def lp_assign(demand, closed=(), capmul=None):\n", + " \"\"\"선형계획(LP): 구간 용량 상한(이용률 rho) 아래에서 자유주행시간 합을 최소화하는 경로별 물량. 불가능하면 rho를 1.0까지 완화.\"\"\"\n", + " cap = {s: CAP[s] * (capmul or {}).get(s, 1.0) for s in CAP}\n", + " ok = [p for p in PATHS if not any(s in closed for s in p)]\n", + " if not ok:\n", + " return None\n", + " for rho in (0.85, 0.95, 1.0):\n", + " r = linprog([sum(T0[s] for s in p) for p in ok], A_ub=[[1.0 if s in p else 0.0 for p in ok] for s in CAP],\n", + " b_ub=[rho * cap[s] for s in CAP], A_eq=[[1.0] * len(ok)], b_eq=[demand], bounds=(0, None), method=\"highs\")\n", + " if r.status == 0:\n", + " break\n", + " else:\n", + " return assign(demand, closed, capmul) # 용량 초과 수요: 증분 배정으로 대체(과부하 표시)\n", + " flow = {s: sum(r.x[i] for i, p in enumerate(ok) if s in p) for s in CAP}\n", + " pf = {p: float(r.x[i]) for i, p in enumerate(ok) if r.x[i] > 1e-6}\n", + " tt = {p: sum(seg_time(s, flow[s], cap[s]) for s in p) for p in pf}\n", + " return {\"flow\": flow, \"paths\": pf, \"path_min\": tt, \"avg_min\": sum(pf[p] * tt[p] for p in pf) / demand,\n", + " \"util\": {s: flow[s] / cap[s] for s in cap if cap[s] > 0}, \"rho\": rho}\n", + "\n", + "\n", + "def cost_of(res, tv=1000, fuel_km=450):\n", + " \"\"\"시간당 비용(원, 교육용 가정): 대당 시간비용 tv원/분 + 유류비 fuel_km원/km.\"\"\"\n", + " return sum(f * (res[\"path_min\"][p] * tv + sum(KM[s] for s in p) * fuel_km) for p, f in res[\"paths\"].items())\n", + "# ---------------------------------------------------------------------------\n", + "\n", + "df = make_data(); d = add_features(df)\n", + "tr, te = d[d.day < 120], d[d.day >= 120].copy()\n", + "gbr = GradientBoostingRegressor(n_estimators=200, max_depth=3, learning_rate=0.08, random_state=0).fit(tr[FEATS], tr[\"delay\"])\n", + "te[\"pred\"] = gbr.predict(te[FEATS])\n", + "\n", + "# ---------------- 1) 설명 가능성: 요인군을 평균값으로 바꿨을 때 예측이 얼마나 변하는가 ----------------\n", + "GROUPS = {\"계획 물량·이용률\": [\"plan_v\", \"util\", \"util_eff\", \"util_eff4\", \"bpr_base\", \"surge_ratio\", \"roll7_v\"], \"정비·유효 용량\": [\"maint\", \"cap_eff\"],\n", + " \"우천\": [\"rain\"], \"전일·전주 지체\": [\"lag1_delay\", \"lag7_delay\"], \"요일 패턴\": [\"is_weekend\", \"dow_sin\", \"dow_cos\"],\n", + " \"구간 특성\": [\"cap\", \"t0\", \"km\", \"k_교량\", \"k_터널\", \"k_교차로\"]}\n", + "mean_row = tr[FEATS].mean()\n", + "contrib = pd.DataFrame(index=te.index)\n", + "for g, cols in GROUPS.items():\n", + " x = te[FEATS].copy()\n", + " x[cols] = mean_row[cols].values\n", + " contrib[g] = te[\"pred\"] - gbr.predict(x) # 양수 = 이 요인이 지체를 늘림\n", + "top3 = contrib.abs().apply(lambda r: r.nlargest(3).index.tolist(), axis=1)\n", + "ok = int(sum(len(t) == 3 and len(set(t)) == 3 and np.isfinite(contrib.loc[i, t].values).all() for i, t in top3.items()))\n", + "weak = int((contrib.abs().apply(lambda r: r.nlargest(3).iloc[2], axis=1) < 0.01).sum())\n", + "print(f\"[1] 예측 {len(te)}건 중 기여도 상위 3개 요인 자동 산출 {ok}건 ({ok / len(te):.0%}, 목표 100%)\")\n", + "imp = contrib.abs().mean().sort_values(ascending=False)\n", + "print(f\" (3번째 요인 기여가 0.01분 미만인 미미한 건 {weak}건: 순위는 산출되나 설명력은 낮음)\")\n", + "print(\" 요인군별 평균 기여 크기(분):\", \", \".join(f\"{k} {v:.2f}\" for k, v in imp.items()))\n", + "\n", + "\n", + "def explain(i):\n", + " r, c = te.loc[i], contrib.loc[i]\n", + " parts = [f\"{g}({c[g]:+.1f}분)\" for g in top3[i]]\n", + " sev = \"정체\" if r[\"util_eff\"] >= 0.9 else \"혼잡\" if r[\"util_eff\"] >= 0.7 else \"원활\"\n", + " cause = {\"정비·유효 용량\": \"비계획 정비로 통과 용량이 줄어\" if r[\"maint\"] else \"유효 용량 변화로\", \"우천\": \"우천 영향으로\", \"계획 물량·이용률\": \"계획 물량이 용량에 근접해\",\n", + " \"전일·전주 지체\": \"전일 지체가 이어져\", \"요일 패턴\": \"요일 패턴 때문에\", \"구간 특성\": \"구간 특성상\"}[top3[i][0]]\n", + " return f\"{r['seg']} 구간 day {int(r['day'])}: {cause} 지체 {r['pred']:.1f}분 예상({sev}). 주요 요인 {', '.join(parts)}\"\n", + "\n", + "\n", + "print(\"\\n[2] 자연어 설명(예측 지체 상위 4건)\")\n", + "for i in te[\"pred\"].nlargest(4).index:\n", + " print(\" -\", explain(i))\n", + "\n", + "# ---------------- 2) 대시보드 데이터: 최종일 구간별 혼잡도·경보 ----------------\n", + "last = te[te.day == te.day.max()].set_index(\"seg\")\n", + "last[\"상태\"] = np.where(last.util_eff >= 0.9, \"정체\", np.where(last.util_eff >= 0.7, \"혼잡\", \"원활\"))\n", + "print(f\"\\n[3] 최종일 구간 상태판: \" + \", \".join(f\"{s} {r.상태}({r.util_eff:.2f})\" for s, r in last.iterrows()))\n", + "alert = last[last[\"pred\"] >= 5].index.tolist()\n", + "print(f\" 지체 5분 이상 예측 경보 구간: {alert or '없음'} -> 우회 검토 대상(실제 하달은 작전군수관 승인 후)\")\n", + "\n", + "# ---------------- 3) 우회 경로 비교: 과거 관행(축선 고정 배분) vs 증분 배정 ----------------\n", + "D = 200\n", + "cmp = []\n", + "for name, closed, cm in [(\"평시\", (), None), (\"A2 폐쇄\", (\"A2\",), None), (\"A1 정비(50%)\", (), {\"A1\": 0.5}), (\"B1 정비(65%)\", (), {\"B1\": 0.65})]:\n", + " a, f = assign(D, closed, cm), fixed_assign(D, closed, cm)\n", + " cmp.append((name, f[\"avg_min\"], a[\"avg_min\"], max(f[\"util\"].values()), max(a[\"util\"].values())))\n", + "cm_df = pd.DataFrame(cmp, columns=[\"시나리오\", \"고정 배분(분)\", \"우회 배정(분)\", \"고정 최대 이용률\", \"배정 최대 이용률\"]).round(2)\n", + "cm_df[\"단축률\"] = (1 - cm_df[\"우회 배정(분)\"] / cm_df[\"고정 배분(분)\"]).map(\"{:.0%}\".format)\n", + "print(f\"\\n[4] 우회 경로 비교(수요 {D}대/시)\")\n", + "print(cm_df.to_string(index=False))\n", + "\n", + "fig, ax = plt.subplots(2, 2, figsize=(13, 8))\n", + "piv = te.pivot(index=\"seg\", columns=\"day\", values=\"util_eff\").iloc[:, -14:]\n", + "im = ax[0, 0].imshow(piv.values, aspect=\"auto\", cmap=\"YlOrRd\", vmin=0.3, vmax=1.2); ax[0, 0].set_yticks(range(len(piv))); ax[0, 0].set_yticklabels(piv.index)\n", + "ax[0, 0].set_title(\"1 Congestion heatmap (effective utilization, last 14 days)\"); plt.colorbar(im, ax=ax[0, 0])\n", + "colors = [\"tab:red\" if s == \"정체\" else \"tab:orange\" if s == \"혼잡\" else \"tab:green\" for s in last[\"상태\"]]\n", + "ax[0, 1].bar(last.index, last[\"pred\"], color=colors); ax[0, 1].axhline(5, ls=\"--\", c=\"k\"); ax[0, 1].set_title(\"2 Predicted delay today (min), dashed = alert 5\")\n", + "x = np.arange(len(cm_df)); ax[1, 0].bar(x - .2, cm_df[\"고정 배분(분)\"], .4, label=\"fixed share\"); ax[1, 0].bar(x + .2, cm_df[\"우회 배정(분)\"], .4, label=\"detour assignment\")\n", + "ax[1, 0].set_xticks(x); ax[1, 0].set_xticklabels([\"normal\", \"A2 closed\", \"A1 maint\", \"B1 maint\"]); ax[1, 0].legend(); ax[1, 0].set_title(\"3 Detour comparison (avg transit min)\")\n", + "worst = te[\"pred\"].idxmax(); c = contrib.loc[worst]\n", + "ax[1, 1].barh([\"plan/util\", \"maint/cap\", \"rain\", \"lag delay\", \"weekday\", \"segment\"], c[list(GROUPS)].values); ax[1, 1].set_title(\"4 Contribution for worst case (min)\")\n", + "plt.suptitle(\"MOV01 bottleneck dashboard (synthetic)\"); plt.tight_layout(); plt.show()\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 06 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + "[1] 예측 300건 중 기여도 상위 3개 요인 자동 산출 300건 (100%, 목표 100%)\n", + " (3번째 요인 기여가 0.01분 미만인 미미한 건 7건: 순위는 산출되나 설명력은 낮음)\n", + " 요인군별 평균 기여 크기(분): 계획 물량·이용률 0.95, 전일·전주 지체 0.41, 구간 특성 0.15, 요일 패턴 0.06, 정비·유효 용량 0.04, 우천 0.02\n", + "\n", + "[2] 자연어 설명(예측 지체 상위 4건)\n", + " - C2 구간 day 134: 계획 물량이 용량에 근접해 지체 12.4분 예상(정체). 주요 요인 계획 물량·이용률(+9.1분), 구간 특성(+4.0분), 정비·유효 용량(+2.0분)\n", + " - B1 구간 day 120: 계획 물량이 용량에 근접해 지체 10.9분 예상(정체). 주요 요인 계획 물량·이용률(+8.8분), 정비·유효 용량(+0.9분), 구간 특성(+0.4분)\n", + " - A1 구간 day 142: 계획 물량이 용량에 근접해 지체 10.5분 예상(정체). 주요 요인 계획 물량·이용률(+7.5분), 정비·유효 용량(+0.7분), 전일·전주 지체(-0.1분)\n", + " - A1 구간 day 137: 계획 물량이 용량에 근접해 지체 10.3분 예상(정체). 주요 요인 계획 물량·이용률(+7.7분), 정비·유효 용량(+0.7분), 우천(+0.2분)\n", + "\n", + "[3] 최종일 구간 상태판: A1 정체(0.97), A2 정체(0.97), A3 혼잡(0.82), B1 혼잡(0.89), B2 혼잡(0.83), B3 혼잡(0.81), C1 원활(0.60), C2 원활(0.69), X1 원활(0.61), X2 원활(0.46)\n", + " 지체 5분 이상 예측 경보 구간: 없음 -> 우회 검토 대상(실제 하달은 작전군수관 승인 후)\n", + "\n", + "[4] 우회 경로 비교(수요 200대/시)\n", + " 시나리오 고정 배분(분) 우회 배정(분) 고정 최대 이용률 배정 최대 이용률 단축률\n", + " 평시 41.91 40.91 0.91 1.0 2%\n", + " A2 폐쇄 91.40 44.87 1.24 1.0 51%\n", + "A1 정비(50%) 42.64 41.89 0.98 1.0 2%\n", + "B1 정비(65%) 53.08 42.14 1.23 1.0 21%\n", + "```\n", + "
\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 07. 우회로 수송 명령 하달을 위한 작전군수관 HITL 통제 워크플로우 설계\n", + "**소요시간**: 약 3.5시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "우회로 수송 명령 하달을 위한 작전군수관 HITL 워크플로우를 설계한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **우회 수송 명령의 하달 여부를 작전군수관이 직접 승인·반려하는 인간 개입(HITL) 결재 워크플로우를 설계하고, 선형계획으로 우회 물량 배분을 최적화한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **선형계획(LP)으로 구간 용량 상한 아래에서 경로별 물량을 배분하고 시나리오 6종과 과거 30일 조건에서 고정 배분 대비 비용 절감률을 백테스트하며 돌발 수요 민감도 확인하고 유효성을 점검하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `src/optimization/mov01_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": [ + "\"\"\"선형계획(LP)으로 우회 수송 물량 배분을 최적화해 과거 관행 대비 비용 절감을 백테스트하고, 우회 수송 명령을 작전군수관이 직접 승인·반려하는 HITL 결재 워크플로우(권한·2단계 결재·해시 체인 감사 로그)를 구현한다.\"\"\"\n", + "import hashlib\n", + "import json\n", + "import numpy as np\n", + "import pandas as pd\n", + "import matplotlib.pyplot as plt\n", + "from scipy.optimize import linprog\n", + "\n", + "# ---------------------------------------------------------------------------\n", + "# [교육용가상자료] 가상 노선망·구간 통과량 생성기 (실제 도로·지명·부대 없음)\n", + "# 가상 출발지(SRC)에서 가상 목적지(DST)까지 3개 축선(A·B·C)과 연결로 2개, 구간 10개. 150일치 피크시간 통과량·지체 시간을 만든다.\n", + "# ---------------------------------------------------------------------------\n", + "SEGS = pd.DataFrame([\n", + " (\"A1\", \"SRC\", \"J1\", 12, 130, \"일반\"), (\"A2\", \"J1\", \"J2\", 8, 70, \"교량\"), (\"A3\", \"J2\", \"DST\", 10, 140, \"교차로\"),\n", + " (\"B1\", \"SRC\", \"J3\", 15, 100, \"일반\"), (\"B2\", \"J3\", \"J2\", 9, 80, \"터널\"), (\"B3\", \"J3\", \"DST\", 14, 95, \"일반\"),\n", + " (\"C1\", \"SRC\", \"J4\", 18, 70, \"일반\"), (\"C2\", \"J4\", \"DST\", 20, 75, \"교량\"),\n", + " (\"X1\", \"J1\", \"J3\", 7, 50, \"교차로\"), (\"X2\", \"J4\", \"J3\", 10, 60, \"일반\")],\n", + " columns=[\"seg\", \"u\", \"v\", \"km\", \"cap\", \"kind\"]) # cap = 피크시간 통과 용량(대/시, 교육용 가정값)\n", + "KSPD = {\"일반\": 55, \"교량\": 40, \"터널\": 45, \"교차로\": 45} # 구간 유형별 자유 주행 속도(km/h, 가정값)\n", + "SEGS[\"t0\"] = SEGS[\"km\"] / SEGS[\"kind\"].map(KSPD) * 60 # 자유 주행 시간(분)\n", + "UTIL = dict(A1=.78, A2=.85, A3=.66, B1=.70, B2=.72, B3=.64, C1=.55, C2=.58, X1=.45, X2=.40) # 평시 이용률\n", + "\n", + "\n", + "def bpr(t0, v, cap, a=0.15, b=4.0):\n", + " \"\"\"BPR 지체 함수(교통공학의 표준형): 통과량/용량 비가 커질수록 지체가 급증한다. (분)\"\"\"\n", + " # [TODO : 학생 실습 7] 우회로 수송 명령 하달을 위한 작전군수관 HITL 통제 워크플로우 설계 관련 핵심 로직을 구현하세요.\n", + " # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n", + " # <<< 여기에 코드를 작성하세요 >>>\n", + " pass # 작성 후 pass를 제거하세요.\n", + "def make_data(seed=11, days=150):\n", + " \"\"\"구간 x 일자 패널. v=실제 피크 통과량, plan_v=수송계획 수량(긴급 불출 미반영), delay=실제 지체(분).\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " d_idx = np.arange(days)\n", + " lvl = np.array([1.0, 1.08, 1.04, 1.10, 1.02, 0.82, 0.78])[d_idx % 7] * (1 + 0.0012 * d_idx)\n", + " lvl = lvl * np.where((d_idx >= 100) & (d_idx < 110), 1.30, 1.0) # 수송량 폭증 기간(가상)\n", + " prev, rows = {s: 0.0 for s in SEGS[\"seg\"]}, []\n", + " for d in d_idx:\n", + " rain = bool(rng.random() < 0.18)\n", + " for r in SEGS.itertuples():\n", + " maint, urgent = bool(rng.random() < 0.06), bool(rng.random() < 0.05) # 비계획 정비 / 긴급 불출\n", + " v = r.cap * UTIL[r.seg] * lvl[d] * (1.25 if urgent else 1.0) * rng.normal(1, 0.05)\n", + " cap_eff = r.cap * (0.65 if maint else 1.0) * (0.92 if rain else 1.0)\n", + " delay = max(r.t0 * 0.2 * (v / cap_eff) ** 3.5 + 0.25 * prev[r.seg] + rng.normal(0, 0.4), 0.0)\n", + " prev[r.seg] = delay\n", + " rows.append(dict(day=int(d), dow=int(d % 7), seg=r.seg, kind=r.kind, km=r.km, cap=r.cap, t0=r.t0,\n", + " plan_v=v / (1.25 if urgent else 1.0) * rng.normal(1, 0.04), v=v, maint=int(maint), urgent=int(urgent),\n", + " rain=int(rain), cap_eff=cap_eff, delay=delay))\n", + " df = pd.DataFrame(rows)\n", + " df[\"transit\"] = df[\"t0\"] * (1 + 0.08 * df[\"rain\"]) + df[\"delay\"]\n", + " return df\n", + "\n", + "\n", + "def mape(y, p):\n", + " # [TODO : 학생 실습 7] 우회로 수송 명령 하달을 위한 작전군수관 HITL 통제 워크플로우 설계 관련 핵심 로직을 구현하세요.\n", + " # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n", + " # <<< 여기에 코드를 작성하세요 >>>\n", + " pass # 작성 후 pass를 제거하세요.\n", + "def mae(y, p):\n", + " return float(np.mean(np.abs(np.asarray(y, float) - np.asarray(p, float))))\n", + "# ---------------------------------------------------------------------------\n", + "# ---------------------------------------------------------------------------\n", + "# 노선망 흐름 도구: 최대유량(Edmonds-Karp), 증분 배정(혼잡 반영 최단시간 경로), 경로 열거\n", + "# ---------------------------------------------------------------------------\n", + "CAP = dict(zip(SEGS[\"seg\"], SEGS[\"cap\"]))\n", + "T0 = dict(zip(SEGS[\"seg\"], SEGS[\"t0\"]))\n", + "KM = dict(zip(SEGS[\"seg\"], SEGS[\"km\"]))\n", + "ARCS = {r.seg: (r.u, r.v) for r in SEGS.itertuples()}\n", + "SRC, DST = \"SRC\", \"DST\"\n", + "\n", + "\n", + "def seg_time(s, flow, cap=None, a=0.2, b=3.5):\n", + " \"\"\"구간 통과시간(분) = 자유주행 + 혼잡 지체. cap을 주면 용량을 대체(정비·기상 반영).\"\"\"\n", + " r = flow / (cap or CAP[s])\n", + " return T0[s] * (1 + a * r ** b + 6.0 * max(0.0, r - 1.0)) # 용량 초과분은 대기열로 급증\n", + "\n", + "\n", + "def max_flow(closed=(), capmul=None):\n", + " \"\"\"최대유량(대/시)과 최소 절단 구간(병목). capmul = {구간: 용량 배율}.\"\"\"\n", + " cap = {s: (0 if s in closed else CAP[s] * (capmul or {}).get(s, 1.0)) for s in CAP}\n", + " res = {}\n", + " for s, (u, v) in ARCS.items():\n", + " res[(u, v)] = res.get((u, v), 0) + cap[s]\n", + " res.setdefault((v, u), 0)\n", + " flow = 0.0\n", + " while True:\n", + " prev, q = {SRC: None}, [SRC]\n", + " for u in q:\n", + " for (a, b), c in res.items():\n", + " if a == u and c > 1e-9 and b not in prev:\n", + " prev[b] = u; q.append(b)\n", + " if DST not in prev:\n", + " break\n", + " path, x = [], DST\n", + " while prev[x]:\n", + " path.append((prev[x], x)); x = prev[x]\n", + " f = min(res[e] for e in path)\n", + " for a, b in path:\n", + " res[(a, b)] -= f; res[(b, a)] += f\n", + " flow += f\n", + " reach = set(prev)\n", + " cut = [s for s, (u, v) in ARCS.items() if u in reach and v not in reach and cap[s] > 0]\n", + " return flow, cut\n", + "\n", + "\n", + "def all_paths():\n", + " \"\"\"SRC -> DST 단순 경로(구간 이름 튜플) 전체.\"\"\"\n", + " out = []\n", + "\n", + " def dfs(node, segs, seen):\n", + " if node == DST:\n", + " out.append(tuple(segs)); return\n", + " for s, (u, v) in ARCS.items():\n", + " if u == node and v not in seen:\n", + " dfs(v, segs + [s], seen | {v})\n", + " dfs(SRC, [], {SRC})\n", + " return out\n", + "\n", + "\n", + "PATHS = all_paths()\n", + "\n", + "\n", + "def assign(demand, closed=(), capmul=None, steps=40):\n", + " \"\"\"증분 배정: 수요를 steps조각으로 나눠, 전체 통과시간(대수 x 시간) 증가가 가장 작은 경로에 차례로 얹는다(시스템 최적 근사).\"\"\"\n", + " cap = {s: CAP[s] * (capmul or {}).get(s, 1.0) for s in CAP}\n", + " flow, pf = {s: 0.0 for s in CAP}, {}\n", + " ok = [p for p in PATHS if not any(s in closed for s in p)]\n", + " if not ok:\n", + " return None\n", + " for _ in range(steps):\n", + " chunk = demand / steps\n", + " best = min(ok, key=lambda p: sum((flow[s] + chunk) * seg_time(s, flow[s] + chunk, cap[s]) - flow[s] * seg_time(s, flow[s], cap[s]) for s in p))\n", + " for s in best:\n", + " flow[s] += chunk\n", + " pf[best] = pf.get(best, 0) + chunk\n", + " tt = {p: sum(seg_time(s, flow[s], cap[s]) for s in p) for p in pf}\n", + " avg = sum(pf[p] * tt[p] for p in pf) / demand\n", + " return {\"flow\": flow, \"paths\": pf, \"path_min\": tt, \"avg_min\": avg, \"util\": {s: flow[s] / cap[s] for s in cap if cap[s] > 0}}\n", + "\n", + "\n", + "FIXED_SHARE = {(\"A1\", \"A2\", \"A3\"): 0.32, (\"B1\", \"B3\"): 0.40, (\"C1\", \"C2\"): 0.28} # 과거 관행: 축선별 고정 배분(가상)\n", + "\n", + "\n", + "def fixed_assign(demand, closed=(), capmul=None):\n", + " \"\"\"과거 관행 배분: 축선별 고정 비율. 막힌 축선 물량은 열려 있는 축선에 비율대로 재분배.\"\"\"\n", + " cap = {s: CAP[s] * (capmul or {}).get(s, 1.0) for s in CAP}\n", + " live = {p: w for p, w in FIXED_SHARE.items() if not any(s in closed for s in p)}\n", + " if not live:\n", + " return None\n", + " tot = sum(live.values())\n", + " flow = {s: 0.0 for s in CAP}\n", + " pf = {p: demand * w / tot for p, w in live.items()}\n", + " for p, f in pf.items():\n", + " for s in p:\n", + " flow[s] += f\n", + " tt = {p: sum(seg_time(s, flow[s], cap[s]) for s in p) for p in pf}\n", + " return {\"flow\": flow, \"paths\": pf, \"path_min\": tt, \"avg_min\": sum(pf[p] * tt[p] for p in pf) / demand,\n", + " \"util\": {s: flow[s] / cap[s] for s in cap if cap[s] > 0}}\n", + "\n", + "\n", + "def lp_assign(demand, closed=(), capmul=None):\n", + " \"\"\"선형계획(LP): 구간 용량 상한(이용률 rho) 아래에서 자유주행시간 합을 최소화하는 경로별 물량. 불가능하면 rho를 1.0까지 완화.\"\"\"\n", + " cap = {s: CAP[s] * (capmul or {}).get(s, 1.0) for s in CAP}\n", + " ok = [p for p in PATHS if not any(s in closed for s in p)]\n", + " if not ok:\n", + " return None\n", + " for rho in (0.85, 0.95, 1.0):\n", + " r = linprog([sum(T0[s] for s in p) for p in ok], A_ub=[[1.0 if s in p else 0.0 for p in ok] for s in CAP],\n", + " b_ub=[rho * cap[s] for s in CAP], A_eq=[[1.0] * len(ok)], b_eq=[demand], bounds=(0, None), method=\"highs\")\n", + " if r.status == 0:\n", + " break\n", + " else:\n", + " return assign(demand, closed, capmul) # 용량 초과 수요: 증분 배정으로 대체(과부하 표시)\n", + " flow = {s: sum(r.x[i] for i, p in enumerate(ok) if s in p) for s in CAP}\n", + " pf = {p: float(r.x[i]) for i, p in enumerate(ok) if r.x[i] > 1e-6}\n", + " tt = {p: sum(seg_time(s, flow[s], cap[s]) for s in p) for p in pf}\n", + " return {\"flow\": flow, \"paths\": pf, \"path_min\": tt, \"avg_min\": sum(pf[p] * tt[p] for p in pf) / demand,\n", + " \"util\": {s: flow[s] / cap[s] for s in cap if cap[s] > 0}, \"rho\": rho}\n", + "\n", + "\n", + "def cost_of(res, tv=1000, fuel_km=450):\n", + " \"\"\"시간당 비용(원, 교육용 가정): 대당 시간비용 tv원/분 + 유류비 fuel_km원/km.\"\"\"\n", + " return sum(f * (res[\"path_min\"][p] * tv + sum(KM[s] for s in p) * fuel_km) for p, f in res[\"paths\"].items())\n", + "# ---------------------------------------------------------------------------\n", + "\n", + "# ---------------- 1) 최적화 시뮬레이터: 과거 관행(축선 고정 배분) vs LP ----------------\n", + "SC = [(\"평시\", 200, (), None), (\"수송량 폭증(+30%)\", 260, (), None), (\"A2 교량 정비(용량 65%)\", 200, (), {\"A2\": .65}), (\"A2 교량 폐쇄\", 200, (\"A2\",), None),\n", + " (\"A2 폐쇄 + 우천\", 200, (\"A2\",), {k: .92 for k in CAP}), (\"B1 정비 + 폭증\", 250, (), {\"B1\": .65})]\n", + "rows, plans = [], {}\n", + "for name, D, cl, cm in SC:\n", + " f, l = fixed_assign(D, cl, cm), lp_assign(D, cl, cm)\n", + " cf, cl_ = cost_of(f), cost_of(l)\n", + " rows.append((name, D, f[\"avg_min\"], l[\"avg_min\"], cf / 1e4, cl_ / 1e4, 1 - cl_ / cf))\n", + " plans[name] = (D, cl, cm, l, cf, cl_)\n", + "res = pd.DataFrame(rows, columns=[\"시나리오\", \"수요\", \"고정 배분(분)\", \"LP(분)\", \"고정 비용(만원/시)\", \"LP 비용(만원/시)\", \"절감률\"])\n", + "print(\"[1] 시나리오별 비용 비교(시간·유류 단가는 교육용 가정값)\")\n", + "print(res.assign(절감률=res[\"절감률\"].map(\"{:.1%}\".format)).round(1).to_string(index=False))\n", + "avg = res[\"절감률\"].mean()\n", + "print(f\" 시나리오 6종 평균 절감 {avg:.1%}, 우회가 필요한 4개 시나리오 평균 {res['절감률'].iloc[2:].mean():.1%} (목표 15% 이상: {'달성' if avg >= .15 else '미달'}), 평시는 {res['절감률'].iloc[0]:.1%}에 그친다\")\n", + "\n", + "# ---------------- 2) 백테스팅: 과거 30일(day 120~149) 실적 조건으로 재현 ----------------\n", + "df = make_data()\n", + "rec = []\n", + "for day, g in df[df.day >= 120].groupby(\"day\"):\n", + " D = float(g[g.seg.isin([\"A1\", \"B1\", \"C1\"])][\"plan_v\"].sum())\n", + " cm = {r.seg: r.cap_eff / r.cap for r in g.itertuples() if r.cap_eff < r.cap}\n", + " f, l = fixed_assign(D, (), cm), lp_assign(D, (), cm)\n", + " rec.append((day, D, cost_of(f), cost_of(l)))\n", + "bt = pd.DataFrame(rec, columns=[\"day\", \"D\", \"fix\", \"lp\"])\n", + "bt[\"save\"] = 1 - bt[\"lp\"] / bt[\"fix\"]\n", + "print(f\"\\n[2] 백테스팅(30일): 평균 절감 {bt['save'].mean():.1%}(목표 15% 이상: {'달성' if bt['save'].mean() >= .15 else '미달'}), 최대 {bt['save'].max():.1%}(day {int(bt.loc[bt['save'].idxmax(), 'day'])}), 절감 0 이하인 날 {int((bt['save'] <= 0).sum())}일\")\n", + "print(f\" 절감 10% 미만인 날 {int((bt['save'] < .10).sum())}일: 효과는 정비·폭증이 겹친 날에 집중된다. 과거 관행 배분은 가정이므로 실제 부대 실적으로는 별도 검증이 필요하다.\")\n", + "\n", + "# ---------------- 3) 민감도: 돌발 수요(전시 전환 가정) ----------------\n", + "sens = []\n", + "for k in (0.8, 1.0, 1.2, 1.4, 1.6):\n", + " f, l = fixed_assign(200 * k, (\"A2\",)), lp_assign(200 * k, (\"A2\",))\n", + " sens.append((f\"x{k}\", 200 * k, max(l[\"util\"].values()), 1 - cost_of(l) / cost_of(f)))\n", + "sdf = pd.DataFrame(sens, columns=[\"수요 배율\", \"수요\", \"LP 최대 이용률\", \"절감률\"])\n", + "print(\"[3] 돌발 수요 민감도(A2 폐쇄 상황):\", \"; \".join(f\"{r['수요 배율']} -> 이용률 {r['LP 최대 이용률']:.2f}, 절감 {r['절감률']:.0%}\" for _, r in sdf.iterrows()))\n", + "print(\" 수요 x1.2 이상에서는 최대유량(220대/시)을 넘어 우회만으로 수용할 수 없으므로 물량 조정 결정이 필요하다.\")\n", + "\n", + "# ---------------- 4) HITL 결재 워크플로우 ----------------\n", + "USERS = {\"plan_a\": \"PLN\", \"plan_b\": \"PLN\", \"ops_kim\": \"OPS\", \"ops_lee\": \"OPS\", \"sup_park\": \"SUP\", \"aud_han\": \"AUD\"}\n", + "PERM = {\"PLN\": {\"submit\", \"redraft\"}, \"OPS\": {\"approve\", \"reject\"}, \"SUP\": {\"approve\", \"reject\"}, \"AUD\": {\"read\"}}\n", + "\n", + "\n", + "class Flow:\n", + " def __init__(self):\n", + " self.docs, self.log, self.blocked = {}, [], []\n", + "\n", + " def _audit(self, user, action, doc, detail):\n", + " prev = self.log[-1][\"hash\"] if self.log else \"GENESIS\"\n", + " rec = {\"seq\": len(self.log) + 1, \"user\": user, \"action\": action, \"doc\": doc, \"detail\": detail, \"prev\": prev}\n", + " rec[\"hash\"] = hashlib.sha256(json.dumps(rec, ensure_ascii=False, sort_keys=True).encode()).hexdigest()[:12]\n", + " self.log.append(rec)\n", + "\n", + " def verify(self):\n", + " prev = \"GENESIS\"\n", + " for r in self.log:\n", + " body = {k: v for k, v in r.items() if k != \"hash\"}\n", + " if r[\"prev\"] != prev or hashlib.sha256(json.dumps(body, ensure_ascii=False, sort_keys=True).encode()).hexdigest()[:12] != r[\"hash\"]:\n", + " return False, r[\"seq\"]\n", + " prev = r[\"hash\"]\n", + " return True, None\n", + "\n", + " def deny(self, user, act, doc, why):\n", + " self.blocked.append((user, act, doc, why)); self._audit(user, \"DENIED:\" + act, doc, why); return False\n", + "\n", + " def submit(self, user, scen, parent=None):\n", + " need = \"redraft\" if parent else \"submit\"\n", + " if need not in PERM.get(USERS.get(user), set()):\n", + " return self.deny(user, need, \"-\", \"상신 권한 없음\")\n", + " D, cl, cm, l, cf, cl_ = plans[scen]\n", + " extra = l[\"avg_min\"] - plans[\"평시\"][3][\"avg_min\"]\n", + " big = extra >= 3 or max(l[\"util\"].values()) > 0.98 # 추가 소요 3분 이상 또는 용량 포화 -> 2단계 결재\n", + " did = f\"ORD{len(self.docs) + 1:02d}\"\n", + " self.docs[did] = {\"scen\": scen, \"line\": [\"OPS\", \"SUP\"] if big else [\"OPS\"], \"step\": 0, \"status\": \"검토대기\", \"drafter\": user, \"done\": [], \"parent\": parent}\n", + " self._audit(user, \"submit\", did, f\"{scen} 추가소요 {extra:+.1f}분\")\n", + " return did\n", + "\n", + " def decide(self, user, did, act, reason=\"\"):\n", + " role, d = USERS.get(user), self.docs.get(did)\n", + " if role is None: return self.deny(user, act, did, \"미등록 사용자\")\n", + " if d is None or d[\"status\"] != \"검토대기\": return self.deny(user, act, did, \"결재 대상 아님(처리 완료)\")\n", + " if act not in PERM[role]: return self.deny(user, act, did, f\"{role} 역할은 {act} 불가\")\n", + " if d[\"line\"][d[\"step\"]] != role: return self.deny(user, act, did, \"결재 순서 위반\")\n", + " if user == d[\"drafter\"] or user in d[\"done\"]: return self.deny(user, act, did, \"기안자·기결재자 재결재 금지\")\n", + " if len(reason.strip()) < 10: return self.deny(user, act, did, \"사유 10자 이상 필수\")\n", + " if act == \"reject\":\n", + " d[\"status\"] = \"반려\"\n", + " else:\n", + " d[\"done\"].append(user); d[\"step\"] += 1\n", + " d[\"status\"] = \"승인\" if d[\"step\"] == len(d[\"line\"]) else \"검토대기\"\n", + " self._audit(user, act, did, reason); return True\n", + "\n", + " def edit(self, user, did):\n", + " if self.docs[did][\"status\"] == \"승인\": return self.deny(user, \"edit\", did, \"승인 후 명령 변경 금지(재기안)\")\n", + " return True\n", + "\n", + "\n", + "wf = Flow()\n", + "names = [\"A2 교량 폐쇄\", \"A2 교량 정비(용량 65%)\", \"수송량 폭증(+30%)\", \"B1 정비 + 폭증\", \"A2 폐쇄 + 우천\", \"평시\"]\n", + "ids = [wf.submit(\"plan_a\" if i % 2 == 0 else \"plan_b\", n) for i, n in enumerate(names)]\n", + "print(f\"\\n[4] AI 우회안 {len(ids)}건 전부 '검토대기' 등록, 자동 승인 {sum(wf.docs[i]['status'] == '승인' for i in ids)}건 (목표 0건). 2단계 결재 대상 {[i for i in ids if len(wf.docs[i]['line']) == 2]}\")\n", + "wf.decide(\"ops_kim\", ids[0], \"reject\", \"추가 소요와 용량 포화 우려, 물량 조정안 포함해 재기안\")\n", + "new = wf.submit(\"plan_a\", names[0], parent=ids[0])\n", + "wf.decide(\"ops_kim\", new, \"approve\", \"우회 경로 용량 확인, 상급 승인 상신함\")\n", + "wf.decide(\"sup_park\", new, \"approve\", \"우회 시행, 운행 중 지체 보고 조건\")\n", + "wf.decide(\"ops_lee\", ids[1], \"approve\", \"정비 구간 용량 감소분 우회 반영 타당\")\n", + "wf.decide(\"ops_kim\", ids[5], \"approve\", \"평시 물량으로 변경 사유 없어 기존 유지\")\n", + "attacks = [(\"plan_a\", ids[2], \"approve\", \"내가 올린 안을 직접 승인한다\", \"요청자 자기 승인\"), (\"aud_han\", ids[2], \"approve\", \"감사 계정으로 승인 시도\", \"감사 역할 승인\"),\n", + " (\"ghost\", ids[2], \"approve\", \"외부 계정으로 승인 시도\", \"미등록 사용자\"), (\"ops_kim\", ids[2], \"approve\", \"\", \"사유 없는 승인\"),\n", + " (\"ops_kim\", ids[2], \"reject\", \"짧음\", \"사유 짧은 반려\"), (\"sup_park\", ids[2], \"approve\", \"상급자가 순서를 건너뛰어 승인\", \"결재 순서 건너뛰기\"),\n", + " (\"ops_kim\", ids[5], \"approve\", \"이미 승인된 문서를 다시 승인\", \"처리 완료 재승인\"), (\"sup_park\", new, \"reject\", \"승인된 문서를 뒤집어 반려\", \"승인 후 번복\"),\n", + " (\"plan_b\", ids[3], \"approve\", \"요청자 계정으로 승인 시도함\", \"요청자 권한 위반\")]\n", + "for u, doc, act, rs, _ in attacks:\n", + " wf.decide(u, doc, act, rs)\n", + "wf.edit(\"plan_a\", new) # 10) 승인 후 명령 변경\n", + "print(f\" 권한 우회 시도 10건 중 차단 {len(wf.blocked)}건 (차단율 {len(wf.blocked) / 10:.0%}, 목표 100%)\")\n", + "for u, a, doc, why in wf.blocked[:4]:\n", + " print(f\" 차단 {u:<9} {a:<8} {doc}: {why}\")\n", + "ok, _ = wf.verify()\n", + "snap = json.loads(json.dumps(wf.log))\n", + "wf.log[3][\"detail\"] = \"사후에 고쳐 쓴 사유\"\n", + "ok2, at = wf.verify()\n", + "wf.log = snap\n", + "print(f\" 감사 로그 {len(wf.log)}건 해시 체인 무결성 {'100%' if ok else '훼손'}; 4번 기록 사후 수정 시 {'통과' if ok2 else f'위변조 검출({at}번)'}\")\n", + "print(f\" {ids[0]}(반려) -> {new}(재기안, parent 연결) 이력:\", [(r['action'], r['user']) for r in wf.log if r['doc'] in (ids[0], new)])\n", + "print(\" 문서 상태:\", pd.Series({k: v[\"status\"] for k, v in wf.docs.items()}).value_counts().to_dict(), \"| 실제 우회 명령 하달은 지휘계통(작전군수관 승인) 결심에 따른다.\")\n", + "\n", + "fig, ax = plt.subplots(1, 2, figsize=(12, 4))\n", + "x = np.arange(len(res)); ax[0].bar(x - .2, res[\"고정 비용(만원/시)\"], .4, label=\"fixed share\"); ax[0].bar(x + .2, res[\"LP 비용(만원/시)\"], .4, label=\"LP\")\n", + "ax[0].set_xticks(x); ax[0].set_xticklabels([f\"S{i + 1}\" for i in x]); ax[0].legend(); ax[0].set_title(\"Cost per hour by scenario\")\n", + "ax[1].plot(sdf[\"수요\"], sdf[\"절감률\"] * 100, \"o-\"); ax[1].set_title(\"Sensitivity: demand vs saving (%), A2 closed\"); ax[1].set_xlabel(\"demand (veh/h)\")\n", + "plt.tight_layout(); plt.show()\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 07 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + "[1] 시나리오별 비용 비교(시간·유류 단가는 교육용 가정값)\n", + " 시나리오 수요 고정 배분(분) LP(분) 고정 비용(만원/시) LP 비용(만원/시) 절감률\n", + " 평시 200 41.9 41.8 1124.8 1122.7 0.2%\n", + " 수송량 폭증(+30%) 260 57.2 44.8 1860.5 1547.9 16.8%\n", + "A2 교량 정비(용량 65%) 200 53.3 42.2 1351.6 1138.9 15.7%\n", + " A2 교량 폐쇄 200 91.4 45.1 2122.3 1207.2 43.1%\n", + " A2 폐쇄 + 우천 200 119.7 47.0 2688.8 1245.4 53.7%\n", + " B1 정비 + 폭증 250 76.5 46.2 2270.9 1523.7 32.9%\n", + " 시나리오 6종 평균 절감 27.1%, 우회가 필요한 4개 시나리오 평균 36.4% (목표 15% 이상: 달성), 평시는 0.2%에 그친다\n", + "\n", + "[2] 백테스팅(30일): 평균 절감 16.0%(목표 15% 이상: 달성), 최대 48.5%(day 134), 절감 0 이하인 날 0일\n", + " 절감 10% 미만인 날 12일: 효과는 정비·폭증이 겹친 날에 집중된다. 과거 관행 배분은 가정이므로 실제 부대 실적으로는 별도 검증이 필요하다.\n", + "[3] 돌발 수요 민감도(A2 폐쇄 상황): x0.8 -> 이용률 0.85, 절감 4%; x1.0 -> 이용률 0.95, 절감 43%; x1.2 -> 이용률 1.32, 절감 58%; x1.4 -> 이용률 1.54, 절감 58%; x1.6 -> 이용률 1.76, 절감 56%\n", + " 수요 x1.2 이상에서는 최대유량(220대/시)을 넘어 우회만으로 수용할 수 없으므로 물량 조정 결정이 필요하다.\n", + "\n", + "[4] AI 우회안 6건 전부 '검토대기' 등록, 자동 승인 0건 (목표 0건). 2단계 결재 대상 ['ORD01', 'ORD03', 'ORD04', 'ORD05']\n", + " 권한 우회 시도 10건 중 차단 10건 (차단율 100%, 목표 100%)\n", + " 차단 plan_a approve ORD03: PLN 역할은 approve 불가\n", + " 차단 aud_han approve ORD03: AUD 역할은 approve 불가\n", + " 차단 ghost approve ORD03: 미등록 사용자\n", + " 차단 ops_kim approve ORD03: 사유 10자 이상 필수\n", + " 감사 로그 22건 해시 체인 무결성 100%; 4번 기록 사후 수정 시 위변조 검출(4번)\n", + " ORD01(반려) -> ORD07(재기안, parent 연결) 이력: [('submit', 'plan_a'), ('reject', 'ops_kim'), ('submit', 'plan_a'), ('approve', 'ops_kim'), ('approve', 'sup_park'), ('DENIED:reject', 'sup_park'), ('DENIED:edit', 'plan_a')]\n", + " 문서 상태: {'승인': 3, '검토대기': 3, '반려': 1} | 실제 우회 명령 하달은 지휘계통(작전군수관 승인) 결심에 따른다.\n", + "```\n", + "
\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 08. 주요 축선 3개 동시 봉쇄 상황 모의 우회 수송 시뮬레이션 스트레스 테스트\n", + "**소요시간**: 약 3.0시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "주요 축선 봉쇄 모의 우회 수송 시뮬레이션 스트레스 테스트를 수행한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **구간 3개가 동시에 막히는 상황을 모의하는 스트레스 시험으로 노선망의 취약 조합과 대시보드 갱신 성능·경보 정확도를 확인한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **구간 10개 중 3개가 동시에 막히는 120개 조합과 3개 축선의 접근 구간 동시 봉쇄를 모의해 단절, 수요 초과, 수용 가능 조합을 분류하고 단절 조합에 자주 등장하는 구간을 이중화 후보로 제시 수행하고 검증하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `src/dashboard/mov01_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": [ + "\"\"\"가상 노선망에서 구간 3개가 동시에 막히는 모든 조합(120가지)과 축선 3개 전면 봉쇄를 모의해 단절·과부하·추가 지체를 평가하고, 대시보드 갱신 시간(2초 이내)과 지체 경보 정확도(95% 이상)를 스트레스 조건에서 측정한다.\"\"\"\n", + "import itertools\n", + "import time\n", + "import numpy as np\n", + "import pandas as pd\n", + "import matplotlib.pyplot as plt\n", + "from scipy.optimize import linprog\n", + "from sklearn.ensemble import GradientBoostingRegressor\n", + "\n", + "# ---------------------------------------------------------------------------\n", + "# [교육용가상자료] 가상 노선망·구간 통과량 생성기 (실제 도로·지명·부대 없음)\n", + "# 가상 출발지(SRC)에서 가상 목적지(DST)까지 3개 축선(A·B·C)과 연결로 2개, 구간 10개. 150일치 피크시간 통과량·지체 시간을 만든다.\n", + "# ---------------------------------------------------------------------------\n", + "SEGS = pd.DataFrame([\n", + " (\"A1\", \"SRC\", \"J1\", 12, 130, \"일반\"), (\"A2\", \"J1\", \"J2\", 8, 70, \"교량\"), (\"A3\", \"J2\", \"DST\", 10, 140, \"교차로\"),\n", + " (\"B1\", \"SRC\", \"J3\", 15, 100, \"일반\"), (\"B2\", \"J3\", \"J2\", 9, 80, \"터널\"), (\"B3\", \"J3\", \"DST\", 14, 95, \"일반\"),\n", + " (\"C1\", \"SRC\", \"J4\", 18, 70, \"일반\"), (\"C2\", \"J4\", \"DST\", 20, 75, \"교량\"),\n", + " (\"X1\", \"J1\", \"J3\", 7, 50, \"교차로\"), (\"X2\", \"J4\", \"J3\", 10, 60, \"일반\")],\n", + " columns=[\"seg\", \"u\", \"v\", \"km\", \"cap\", \"kind\"]) # cap = 피크시간 통과 용량(대/시, 교육용 가정값)\n", + "KSPD = {\"일반\": 55, \"교량\": 40, \"터널\": 45, \"교차로\": 45} # 구간 유형별 자유 주행 속도(km/h, 가정값)\n", + "SEGS[\"t0\"] = SEGS[\"km\"] / SEGS[\"kind\"].map(KSPD) * 60 # 자유 주행 시간(분)\n", + "UTIL = dict(A1=.78, A2=.85, A3=.66, B1=.70, B2=.72, B3=.64, C1=.55, C2=.58, X1=.45, X2=.40) # 평시 이용률\n", + "\n", + "\n", + "def bpr(t0, v, cap, a=0.15, b=4.0):\n", + " \"\"\"BPR 지체 함수(교통공학의 표준형): 통과량/용량 비가 커질수록 지체가 급증한다. (분)\"\"\"\n", + " # [TODO : 학생 실습 8] 주요 축선 3개 동시 봉쇄 상황 모의 우회 수송 시뮬레이션 스트레스 테스트 관련 핵심 로직을 구현하세요.\n", + " # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n", + " # <<< 여기에 코드를 작성하세요 >>>\n", + " pass # 작성 후 pass를 제거하세요.\n", + "def make_data(seed=11, days=150):\n", + " \"\"\"구간 x 일자 패널. v=실제 피크 통과량, plan_v=수송계획 수량(긴급 불출 미반영), delay=실제 지체(분).\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " d_idx = np.arange(days)\n", + " lvl = np.array([1.0, 1.08, 1.04, 1.10, 1.02, 0.82, 0.78])[d_idx % 7] * (1 + 0.0012 * d_idx)\n", + " lvl = lvl * np.where((d_idx >= 100) & (d_idx < 110), 1.30, 1.0) # 수송량 폭증 기간(가상)\n", + " prev, rows = {s: 0.0 for s in SEGS[\"seg\"]}, []\n", + " for d in d_idx:\n", + " rain = bool(rng.random() < 0.18)\n", + " for r in SEGS.itertuples():\n", + " maint, urgent = bool(rng.random() < 0.06), bool(rng.random() < 0.05) # 비계획 정비 / 긴급 불출\n", + " v = r.cap * UTIL[r.seg] * lvl[d] * (1.25 if urgent else 1.0) * rng.normal(1, 0.05)\n", + " cap_eff = r.cap * (0.65 if maint else 1.0) * (0.92 if rain else 1.0)\n", + " delay = max(r.t0 * 0.2 * (v / cap_eff) ** 3.5 + 0.25 * prev[r.seg] + rng.normal(0, 0.4), 0.0)\n", + " prev[r.seg] = delay\n", + " rows.append(dict(day=int(d), dow=int(d % 7), seg=r.seg, kind=r.kind, km=r.km, cap=r.cap, t0=r.t0,\n", + " plan_v=v / (1.25 if urgent else 1.0) * rng.normal(1, 0.04), v=v, maint=int(maint), urgent=int(urgent),\n", + " rain=int(rain), cap_eff=cap_eff, delay=delay))\n", + " df = pd.DataFrame(rows)\n", + " df[\"transit\"] = df[\"t0\"] * (1 + 0.08 * df[\"rain\"]) + df[\"delay\"]\n", + " return df\n", + "\n", + "\n", + "def mape(y, p):\n", + " # [TODO : 학생 실습 8] 주요 축선 3개 동시 봉쇄 상황 모의 우회 수송 시뮬레이션 스트레스 테스트 관련 핵심 로직을 구현하세요.\n", + " # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n", + " # <<< 여기에 코드를 작성하세요 >>>\n", + " pass # 작성 후 pass를 제거하세요.\n", + "def mae(y, p):\n", + " return float(np.mean(np.abs(np.asarray(y, float) - np.asarray(p, float))))\n", + "# ---------------------------------------------------------------------------\n", + "# ---------------------------------------------------------------------------\n", + "# 지체 예측용 파생 피처 생성(당일 오전 시점에 알 수 있는 정보만 사용: 계획 물량, 공지 정비, 기상 예보, 전일·전주 실적)\n", + "# ---------------------------------------------------------------------------\n", + "FEATS = [\"plan_v\", \"cap\", \"cap_eff\", \"t0\", \"km\", \"maint\", \"rain\", \"util\", \"util_eff\", \"util_eff4\", \"bpr_base\", \"lag1_delay\", \"lag7_delay\",\n", + " \"roll7_v\", \"surge_ratio\", \"is_weekend\", \"dow_sin\", \"dow_cos\", \"k_교량\", \"k_터널\", \"k_교차로\"]\n", + "\n", + "\n", + "def add_features(df):\n", + " d = df.sort_values([\"seg\", \"day\"]).copy()\n", + " g = d.groupby(\"seg\")\n", + " d[\"lag1_delay\"] = g[\"delay\"].shift(1)\n", + " d[\"lag7_delay\"] = g[\"delay\"].shift(7)\n", + " d[\"roll7_v\"] = g[\"v\"].transform(lambda s: s.shift(1).rolling(7).mean())\n", + " d[\"util\"] = d[\"plan_v\"] / d[\"cap\"]\n", + " d[\"util_eff\"] = d[\"plan_v\"] / d[\"cap_eff\"]\n", + " d[\"util_eff4\"] = d[\"util_eff\"] ** 4\n", + " d[\"bpr_base\"] = bpr(d[\"t0\"], d[\"plan_v\"], d[\"cap\"])\n", + " d[\"surge_ratio\"] = d[\"plan_v\"] / d[\"roll7_v\"]\n", + " d[\"is_weekend\"] = (d[\"dow\"] >= 5).astype(int)\n", + " d[\"dow_sin\"], d[\"dow_cos\"] = np.sin(2 * np.pi * d[\"dow\"] / 7), np.cos(2 * np.pi * d[\"dow\"] / 7)\n", + " for k in (\"교량\", \"터널\", \"교차로\"):\n", + " d[\"k_\" + k] = (d[\"kind\"] == k).astype(int)\n", + " return d.dropna().sort_values([\"day\", \"seg\"]).reset_index(drop=True)\n", + "# ---------------------------------------------------------------------------\n", + "# ---------------------------------------------------------------------------\n", + "# 노선망 흐름 도구: 최대유량(Edmonds-Karp), 증분 배정(혼잡 반영 최단시간 경로), 경로 열거\n", + "# ---------------------------------------------------------------------------\n", + "CAP = dict(zip(SEGS[\"seg\"], SEGS[\"cap\"]))\n", + "T0 = dict(zip(SEGS[\"seg\"], SEGS[\"t0\"]))\n", + "KM = dict(zip(SEGS[\"seg\"], SEGS[\"km\"]))\n", + "ARCS = {r.seg: (r.u, r.v) for r in SEGS.itertuples()}\n", + "SRC, DST = \"SRC\", \"DST\"\n", + "\n", + "\n", + "def seg_time(s, flow, cap=None, a=0.2, b=3.5):\n", + " \"\"\"구간 통과시간(분) = 자유주행 + 혼잡 지체. cap을 주면 용량을 대체(정비·기상 반영).\"\"\"\n", + " r = flow / (cap or CAP[s])\n", + " return T0[s] * (1 + a * r ** b + 6.0 * max(0.0, r - 1.0)) # 용량 초과분은 대기열로 급증\n", + "\n", + "\n", + "def max_flow(closed=(), capmul=None):\n", + " \"\"\"최대유량(대/시)과 최소 절단 구간(병목). capmul = {구간: 용량 배율}.\"\"\"\n", + " cap = {s: (0 if s in closed else CAP[s] * (capmul or {}).get(s, 1.0)) for s in CAP}\n", + " res = {}\n", + " for s, (u, v) in ARCS.items():\n", + " res[(u, v)] = res.get((u, v), 0) + cap[s]\n", + " res.setdefault((v, u), 0)\n", + " flow = 0.0\n", + " while True:\n", + " prev, q = {SRC: None}, [SRC]\n", + " for u in q:\n", + " for (a, b), c in res.items():\n", + " if a == u and c > 1e-9 and b not in prev:\n", + " prev[b] = u; q.append(b)\n", + " if DST not in prev:\n", + " break\n", + " path, x = [], DST\n", + " while prev[x]:\n", + " path.append((prev[x], x)); x = prev[x]\n", + " f = min(res[e] for e in path)\n", + " for a, b in path:\n", + " res[(a, b)] -= f; res[(b, a)] += f\n", + " flow += f\n", + " reach = set(prev)\n", + " cut = [s for s, (u, v) in ARCS.items() if u in reach and v not in reach and cap[s] > 0]\n", + " return flow, cut\n", + "\n", + "\n", + "def all_paths():\n", + " \"\"\"SRC -> DST 단순 경로(구간 이름 튜플) 전체.\"\"\"\n", + " out = []\n", + "\n", + " def dfs(node, segs, seen):\n", + " if node == DST:\n", + " out.append(tuple(segs)); return\n", + " for s, (u, v) in ARCS.items():\n", + " if u == node and v not in seen:\n", + " dfs(v, segs + [s], seen | {v})\n", + " dfs(SRC, [], {SRC})\n", + " return out\n", + "\n", + "\n", + "PATHS = all_paths()\n", + "\n", + "\n", + "def assign(demand, closed=(), capmul=None, steps=40):\n", + " \"\"\"증분 배정: 수요를 steps조각으로 나눠, 전체 통과시간(대수 x 시간) 증가가 가장 작은 경로에 차례로 얹는다(시스템 최적 근사).\"\"\"\n", + " cap = {s: CAP[s] * (capmul or {}).get(s, 1.0) for s in CAP}\n", + " flow, pf = {s: 0.0 for s in CAP}, {}\n", + " ok = [p for p in PATHS if not any(s in closed for s in p)]\n", + " if not ok:\n", + " return None\n", + " for _ in range(steps):\n", + " chunk = demand / steps\n", + " best = min(ok, key=lambda p: sum((flow[s] + chunk) * seg_time(s, flow[s] + chunk, cap[s]) - flow[s] * seg_time(s, flow[s], cap[s]) for s in p))\n", + " for s in best:\n", + " flow[s] += chunk\n", + " pf[best] = pf.get(best, 0) + chunk\n", + " tt = {p: sum(seg_time(s, flow[s], cap[s]) for s in p) for p in pf}\n", + " avg = sum(pf[p] * tt[p] for p in pf) / demand\n", + " return {\"flow\": flow, \"paths\": pf, \"path_min\": tt, \"avg_min\": avg, \"util\": {s: flow[s] / cap[s] for s in cap if cap[s] > 0}}\n", + "\n", + "\n", + "FIXED_SHARE = {(\"A1\", \"A2\", \"A3\"): 0.32, (\"B1\", \"B3\"): 0.40, (\"C1\", \"C2\"): 0.28} # 과거 관행: 축선별 고정 배분(가상)\n", + "\n", + "\n", + "def fixed_assign(demand, closed=(), capmul=None):\n", + " \"\"\"과거 관행 배분: 축선별 고정 비율. 막힌 축선 물량은 열려 있는 축선에 비율대로 재분배.\"\"\"\n", + " cap = {s: CAP[s] * (capmul or {}).get(s, 1.0) for s in CAP}\n", + " live = {p: w for p, w in FIXED_SHARE.items() if not any(s in closed for s in p)}\n", + " if not live:\n", + " return None\n", + " tot = sum(live.values())\n", + " flow = {s: 0.0 for s in CAP}\n", + " pf = {p: demand * w / tot for p, w in live.items()}\n", + " for p, f in pf.items():\n", + " for s in p:\n", + " flow[s] += f\n", + " tt = {p: sum(seg_time(s, flow[s], cap[s]) for s in p) for p in pf}\n", + " return {\"flow\": flow, \"paths\": pf, \"path_min\": tt, \"avg_min\": sum(pf[p] * tt[p] for p in pf) / demand,\n", + " \"util\": {s: flow[s] / cap[s] for s in cap if cap[s] > 0}}\n", + "\n", + "\n", + "def lp_assign(demand, closed=(), capmul=None):\n", + " \"\"\"선형계획(LP): 구간 용량 상한(이용률 rho) 아래에서 자유주행시간 합을 최소화하는 경로별 물량. 불가능하면 rho를 1.0까지 완화.\"\"\"\n", + " cap = {s: CAP[s] * (capmul or {}).get(s, 1.0) for s in CAP}\n", + " ok = [p for p in PATHS if not any(s in closed for s in p)]\n", + " if not ok:\n", + " return None\n", + " for rho in (0.85, 0.95, 1.0):\n", + " r = linprog([sum(T0[s] for s in p) for p in ok], A_ub=[[1.0 if s in p else 0.0 for p in ok] for s in CAP],\n", + " b_ub=[rho * cap[s] for s in CAP], A_eq=[[1.0] * len(ok)], b_eq=[demand], bounds=(0, None), method=\"highs\")\n", + " if r.status == 0:\n", + " break\n", + " else:\n", + " return assign(demand, closed, capmul) # 용량 초과 수요: 증분 배정으로 대체(과부하 표시)\n", + " flow = {s: sum(r.x[i] for i, p in enumerate(ok) if s in p) for s in CAP}\n", + " pf = {p: float(r.x[i]) for i, p in enumerate(ok) if r.x[i] > 1e-6}\n", + " tt = {p: sum(seg_time(s, flow[s], cap[s]) for s in p) for p in pf}\n", + " return {\"flow\": flow, \"paths\": pf, \"path_min\": tt, \"avg_min\": sum(pf[p] * tt[p] for p in pf) / demand,\n", + " \"util\": {s: flow[s] / cap[s] for s in cap if cap[s] > 0}, \"rho\": rho}\n", + "\n", + "\n", + "def cost_of(res, tv=1000, fuel_km=450):\n", + " \"\"\"시간당 비용(원, 교육용 가정): 대당 시간비용 tv원/분 + 유류비 fuel_km원/km.\"\"\"\n", + " return sum(f * (res[\"path_min\"][p] * tv + sum(KM[s] for s in p) * fuel_km) for p, f in res[\"paths\"].items())\n", + "# ---------------------------------------------------------------------------\n", + "\n", + "D = 150\n", + "base = assign(D)\n", + "print(f\"[0] 평시: 수요 {D}대/시, 평균 통과 {base['avg_min']:.1f}분, 최대유량 {max_flow()[0]:.0f}대/시\")\n", + "\n", + "# ---------------- 1) 축선 3개(A·B·C) 전면 봉쇄 ----------------\n", + "axes_cut = (\"A3\", \"B3\", \"C2\") # 3개 축선의 목적지 접근 구간\n", + "mf, _ = max_flow(axes_cut)\n", + "print(f\"[1] 3개 축선 접근 구간(A3·B3·C2) 동시 봉쇄: 최대유량 {mf:.0f}대/시, 경로 {len(PATHS) - len([p for p in PATHS if not any(s in axes_cut for s in p)])}/{len(PATHS)}개 차단 -> \"\n", + " + (\"도로 수송 불가. 공중·해상 등 타 수단 또는 물량 보류 결정이 필요하며 사람이 판단한다\" if assign(D, axes_cut) is None else \"우회 가능\"))\n", + "\n", + "# ---------------- 2) 구간 3개 동시 봉쇄 120조합 ----------------\n", + "rows = []\n", + "t0 = time.perf_counter()\n", + "for c in itertools.combinations(CAP, 3):\n", + " flow, cut = max_flow(c)\n", + " r = assign(D, c) if flow > 0 else None\n", + " rows.append({\"closed\": \"+\".join(c), \"maxflow\": flow, \"avg_min\": r[\"avg_min\"] if r else np.nan, \"max_util\": max(r[\"util\"].values()) if r else np.nan})\n", + "el = time.perf_counter() - t0\n", + "st = pd.DataFrame(rows)\n", + "st[\"상태\"] = np.where(st[\"maxflow\"] <= 0, \"단절\", np.where(st[\"maxflow\"] < D, \"수요 초과\", np.where(st[\"avg_min\"] > base[\"avg_min\"] * 1.5, \"심각 지체\", \"수용\")))\n", + "vc = st[\"상태\"].value_counts()\n", + "print(f\"\\n[2] 120개 조합 평가({el:.2f}초): \" + \", \".join(f\"{k} {v}\" for k, v in vc.items()))\n", + "ok_rows = st[st[\"상태\"].isin([\"수용\", \"심각 지체\"])]\n", + "print(f\" 수용 가능 조합의 평균 통과시간 {ok_rows['avg_min'].mean():.1f}분(평시 {base['avg_min']:.1f}분), 최악 수용 조합 {ok_rows.loc[ok_rows['avg_min'].idxmax(), 'closed']} {ok_rows['avg_min'].max():.1f}분\")\n", + "cnt = pd.Series([s for c in st[st[\"상태\"] == \"단절\"][\"closed\"] for s in c.split(\"+\")]).value_counts()\n", + "print(f\" 단절 조합에 자주 포함된 구간: {', '.join(f'{k}({v})' for k, v in cnt.head(4).items())} -> 이중화(우회로 확보) 우선 후보\")\n", + "\n", + "# ---------------- 3) 대시보드 갱신 부하: 구간 예측 + 최대유량 + 배정을 반복 ----------------\n", + "df = make_data(); d = add_features(df)\n", + "tr, te = d[d.day < 120], d[d.day >= 120].copy()\n", + "gbr = GradientBoostingRegressor(n_estimators=200, max_depth=3, learning_rate=0.08, random_state=0).fit(tr[FEATS], tr[\"delay\"])\n", + "\n", + "\n", + "def refresh(day, closed=()):\n", + " x = te[te.day == day]\n", + " pred = gbr.predict(x[FEATS])\n", + " flow, cut = max_flow(closed)\n", + " r = assign(float(x[x.seg.isin([\"A1\", \"B1\", \"C1\"])][\"plan_v\"].sum()), closed)\n", + " return pred, flow, cut, r\n", + "\n", + "\n", + "lat = []\n", + "rng = np.random.default_rng(8)\n", + "combos = list(itertools.combinations(CAP, 2))\n", + "for k in range(60):\n", + " c = combos[int(rng.integers(len(combos)))]\n", + " t1 = time.perf_counter(); refresh(120 + k % 30, c); lat.append(time.perf_counter() - t1)\n", + "lat = np.array(lat)\n", + "print(f\"\\n[3] 대시보드 데이터 갱신 60회(구간 2개 폐쇄 무작위): 평균 {lat.mean() * 1000:.0f}ms, 최대 {lat.max() * 1000:.0f}ms (목표 2초 이내: {'달성' if lat.max() < 2 else '미달'}; 화면 렌더링 시간은 제외)\")\n", + "\n", + "# ---------------- 4) 경보 정확도: 예측 지체 5분 이상 경보 vs 실제 5분 이상 ----------------\n", + "te[\"pred\"] = gbr.predict(te[FEATS])\n", + "a_pred, a_true = te[\"pred\"] >= 5, te[\"delay\"] >= 5\n", + "acc = float((a_pred == a_true).mean())\n", + "tp, fp, fn = int((a_pred & a_true).sum()), int((a_pred & ~a_true).sum()), int((~a_pred & a_true).sum())\n", + "print(f\"[4] 경보 정확도: {acc:.1%} (목표 95% 이상: {'달성' if acc >= .95 else '미달'}), 실제 지체 5분 이상 {int(a_true.sum())}건 중 탐지 {tp}건(재현율 {tp / max(tp + fn, 1):.0%}), 오경보 {fp}건\")\n", + "print(f\" 경보 기준을 3분으로 낮추면: \", end=\"\")\n", + "a3 = te[\"pred\"] >= 3; t3 = te[\"delay\"] >= 5\n", + "print(f\"재현율 {float((a3 & t3).sum() / max(t3.sum(), 1)):.0%}, 오경보 {int((a3 & ~t3).sum())}건 -> 놓치지 않는 쪽이 중요하면 기준을 낮추되 오경보 피로를 함께 본다\")\n", + "print(\"모든 모의는 교육용 가상 노선이며, 실제 봉쇄·우회 결심은 지휘계통(작전군수관 승인)에 따른다.\")\n", + "\n", + "fig, ax = plt.subplots(1, 3, figsize=(15, 4))\n", + "vc.rename(index={\"단절\": \"cut off\", \"수요 초과\": \"over demand\", \"수용\": \"ok\", \"심각 지체\": \"severe delay\"}).plot.bar(ax=ax[0], color=\"tab:blue\")\n", + "ax[0].set_title(\"120 triple-closure outcomes\"); ax[0].set_xlabel(\"\"); ax[0].tick_params(axis=\"x\", rotation=15)\n", + "ax[1].hist(ok_rows[\"avg_min\"], bins=15); ax[1].axvline(base[\"avg_min\"], c=\"r\"); ax[1].set_title(\"Avg transit (min) of feasible combos\")\n", + "ax[2].hist(lat * 1000, bins=15); ax[2].set_title(\"Refresh latency (ms)\")\n", + "plt.tight_layout(); plt.show()\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 08 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + "[0] 평시: 수요 150대/시, 평균 통과 37.8분, 최대유량 290대/시\n", + "[1] 3개 축선 접근 구간(A3·B3·C2) 동시 봉쇄: 최대유량 0대/시, 경로 8/8개 차단 -> 도로 수송 불가. 공중·해상 등 타 수단 또는 물량 보류 결정이 필요하며 사람이 판단한다\n", + "\n", + "[2] 120개 조합 평가(0.12초): 수요 초과 73, 수용 44, 단절 3\n", + " 수용 가능 조합의 평균 통과시간 43.3분(평시 37.8분), 최악 수용 조합 A1+A2+B3 52.5분\n", + " 단절 조합에 자주 포함된 구간: C1(2), A3(2), B3(2), A1(1) -> 이중화(우회로 확보) 우선 후보\n", + "\n", + "[3] 대시보드 데이터 갱신 60회(구간 2개 폐쇄 무작위): 평균 7ms, 최대 10ms (목표 2초 이내: 달성; 화면 렌더링 시간은 제외)\n", + "[4] 경보 정확도: 97.0% (목표 95% 이상: 달성), 실제 지체 5분 이상 22건 중 탐지 15건(재현율 68%), 오경보 2건\n", + " 경보 기준을 3분으로 낮추면: 재현율 91%, 오경보 46건 -> 놓치지 않는 쪽이 중요하면 기준을 낮추되 오경보 피로를 함께 본다\n", + "모든 모의는 교육용 가상 노선이며, 실제 봉쇄·우회 결심은 지휘계통(작전군수관 승인)에 따른다.\n", + "```\n", + "
\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 09. AAR 기반 병목 예측 오차 분석 및 도로 통과 용량 파라미터 재보정\n", + "**소요시간**: 약 3.0시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "AAR 기반 병목 예측 오차를 분석하고 통과 용량을 재보정한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **AAR 관점에서 병목 예측 오차를 원인별로 분석하고 도로 통과 용량 파라미터를 재보정하며, 효과를 ROI로 환산한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **기준 BPR의 오차를 정비일, 긴급 불출일, 우천일, 정상일로 나누어 평균 오차를 비교 수행하고 검증하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `reports/mov01_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 관점에서 BPR 기준 모델의 병목 예측 오차를 원인별로 분석하고, 최소제곱으로 구간별 유효 통과 용량 배율과 지체 함수 파라미터를 재보정해 검증 구간 오차를 줄인 뒤, 효과를 비용 절감액·시간 단축률·경보 포착률 3대 지표로 환산하고 민감도를 분석한다.\"\"\"\n", + "import numpy as np\n", + "import pandas as pd\n", + "import matplotlib.pyplot as plt\n", + "from scipy.optimize import least_squares, linprog\n", + "\n", + "# ---------------------------------------------------------------------------\n", + "# [교육용가상자료] 가상 노선망·구간 통과량 생성기 (실제 도로·지명·부대 없음)\n", + "# 가상 출발지(SRC)에서 가상 목적지(DST)까지 3개 축선(A·B·C)과 연결로 2개, 구간 10개. 150일치 피크시간 통과량·지체 시간을 만든다.\n", + "# ---------------------------------------------------------------------------\n", + "SEGS = pd.DataFrame([\n", + " (\"A1\", \"SRC\", \"J1\", 12, 130, \"일반\"), (\"A2\", \"J1\", \"J2\", 8, 70, \"교량\"), (\"A3\", \"J2\", \"DST\", 10, 140, \"교차로\"),\n", + " (\"B1\", \"SRC\", \"J3\", 15, 100, \"일반\"), (\"B2\", \"J3\", \"J2\", 9, 80, \"터널\"), (\"B3\", \"J3\", \"DST\", 14, 95, \"일반\"),\n", + " (\"C1\", \"SRC\", \"J4\", 18, 70, \"일반\"), (\"C2\", \"J4\", \"DST\", 20, 75, \"교량\"),\n", + " (\"X1\", \"J1\", \"J3\", 7, 50, \"교차로\"), (\"X2\", \"J4\", \"J3\", 10, 60, \"일반\")],\n", + " columns=[\"seg\", \"u\", \"v\", \"km\", \"cap\", \"kind\"]) # cap = 피크시간 통과 용량(대/시, 교육용 가정값)\n", + "KSPD = {\"일반\": 55, \"교량\": 40, \"터널\": 45, \"교차로\": 45} # 구간 유형별 자유 주행 속도(km/h, 가정값)\n", + "SEGS[\"t0\"] = SEGS[\"km\"] / SEGS[\"kind\"].map(KSPD) * 60 # 자유 주행 시간(분)\n", + "UTIL = dict(A1=.78, A2=.85, A3=.66, B1=.70, B2=.72, B3=.64, C1=.55, C2=.58, X1=.45, X2=.40) # 평시 이용률\n", + "\n", + "\n", + "def bpr(t0, v, cap, a=0.15, b=4.0):\n", + " \"\"\"BPR 지체 함수(교통공학의 표준형): 통과량/용량 비가 커질수록 지체가 급증한다. (분)\"\"\"\n", + " # [TODO : 학생 실습 9] AAR 기반 병목 예측 오차 분석 및 도로 통과 용량 파라미터 재보정 관련 핵심 로직을 구현하세요.\n", + " # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n", + " # <<< 여기에 코드를 작성하세요 >>>\n", + " pass # 작성 후 pass를 제거하세요.\n", + "def make_data(seed=11, days=150):\n", + " \"\"\"구간 x 일자 패널. v=실제 피크 통과량, plan_v=수송계획 수량(긴급 불출 미반영), delay=실제 지체(분).\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " d_idx = np.arange(days)\n", + " lvl = np.array([1.0, 1.08, 1.04, 1.10, 1.02, 0.82, 0.78])[d_idx % 7] * (1 + 0.0012 * d_idx)\n", + " lvl = lvl * np.where((d_idx >= 100) & (d_idx < 110), 1.30, 1.0) # 수송량 폭증 기간(가상)\n", + " prev, rows = {s: 0.0 for s in SEGS[\"seg\"]}, []\n", + " for d in d_idx:\n", + " rain = bool(rng.random() < 0.18)\n", + " for r in SEGS.itertuples():\n", + " maint, urgent = bool(rng.random() < 0.06), bool(rng.random() < 0.05) # 비계획 정비 / 긴급 불출\n", + " v = r.cap * UTIL[r.seg] * lvl[d] * (1.25 if urgent else 1.0) * rng.normal(1, 0.05)\n", + " cap_eff = r.cap * (0.65 if maint else 1.0) * (0.92 if rain else 1.0)\n", + " delay = max(r.t0 * 0.2 * (v / cap_eff) ** 3.5 + 0.25 * prev[r.seg] + rng.normal(0, 0.4), 0.0)\n", + " prev[r.seg] = delay\n", + " rows.append(dict(day=int(d), dow=int(d % 7), seg=r.seg, kind=r.kind, km=r.km, cap=r.cap, t0=r.t0,\n", + " plan_v=v / (1.25 if urgent else 1.0) * rng.normal(1, 0.04), v=v, maint=int(maint), urgent=int(urgent),\n", + " rain=int(rain), cap_eff=cap_eff, delay=delay))\n", + " df = pd.DataFrame(rows)\n", + " df[\"transit\"] = df[\"t0\"] * (1 + 0.08 * df[\"rain\"]) + df[\"delay\"]\n", + " return df\n", + "\n", + "\n", + "def mape(y, p):\n", + " # [TODO : 학생 실습 9] AAR 기반 병목 예측 오차 분석 및 도로 통과 용량 파라미터 재보정 관련 핵심 로직을 구현하세요.\n", + " # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n", + " # <<< 여기에 코드를 작성하세요 >>>\n", + " pass # 작성 후 pass를 제거하세요.\n", + "def mae(y, p):\n", + " return float(np.mean(np.abs(np.asarray(y, float) - np.asarray(p, float))))\n", + "# ---------------------------------------------------------------------------\n", + "# ---------------------------------------------------------------------------\n", + "# 지체 예측용 파생 피처 생성(당일 오전 시점에 알 수 있는 정보만 사용: 계획 물량, 공지 정비, 기상 예보, 전일·전주 실적)\n", + "# ---------------------------------------------------------------------------\n", + "FEATS = [\"plan_v\", \"cap\", \"cap_eff\", \"t0\", \"km\", \"maint\", \"rain\", \"util\", \"util_eff\", \"util_eff4\", \"bpr_base\", \"lag1_delay\", \"lag7_delay\",\n", + " \"roll7_v\", \"surge_ratio\", \"is_weekend\", \"dow_sin\", \"dow_cos\", \"k_교량\", \"k_터널\", \"k_교차로\"]\n", + "\n", + "\n", + "def add_features(df):\n", + " d = df.sort_values([\"seg\", \"day\"]).copy()\n", + " g = d.groupby(\"seg\")\n", + " d[\"lag1_delay\"] = g[\"delay\"].shift(1)\n", + " d[\"lag7_delay\"] = g[\"delay\"].shift(7)\n", + " d[\"roll7_v\"] = g[\"v\"].transform(lambda s: s.shift(1).rolling(7).mean())\n", + " d[\"util\"] = d[\"plan_v\"] / d[\"cap\"]\n", + " d[\"util_eff\"] = d[\"plan_v\"] / d[\"cap_eff\"]\n", + " d[\"util_eff4\"] = d[\"util_eff\"] ** 4\n", + " d[\"bpr_base\"] = bpr(d[\"t0\"], d[\"plan_v\"], d[\"cap\"])\n", + " d[\"surge_ratio\"] = d[\"plan_v\"] / d[\"roll7_v\"]\n", + " d[\"is_weekend\"] = (d[\"dow\"] >= 5).astype(int)\n", + " d[\"dow_sin\"], d[\"dow_cos\"] = np.sin(2 * np.pi * d[\"dow\"] / 7), np.cos(2 * np.pi * d[\"dow\"] / 7)\n", + " for k in (\"교량\", \"터널\", \"교차로\"):\n", + " d[\"k_\" + k] = (d[\"kind\"] == k).astype(int)\n", + " return d.dropna().sort_values([\"day\", \"seg\"]).reset_index(drop=True)\n", + "# ---------------------------------------------------------------------------\n", + "# ---------------------------------------------------------------------------\n", + "# 노선망 흐름 도구: 최대유량(Edmonds-Karp), 증분 배정(혼잡 반영 최단시간 경로), 경로 열거\n", + "# ---------------------------------------------------------------------------\n", + "CAP = dict(zip(SEGS[\"seg\"], SEGS[\"cap\"]))\n", + "T0 = dict(zip(SEGS[\"seg\"], SEGS[\"t0\"]))\n", + "KM = dict(zip(SEGS[\"seg\"], SEGS[\"km\"]))\n", + "ARCS = {r.seg: (r.u, r.v) for r in SEGS.itertuples()}\n", + "SRC, DST = \"SRC\", \"DST\"\n", + "\n", + "\n", + "def seg_time(s, flow, cap=None, a=0.2, b=3.5):\n", + " \"\"\"구간 통과시간(분) = 자유주행 + 혼잡 지체. cap을 주면 용량을 대체(정비·기상 반영).\"\"\"\n", + " r = flow / (cap or CAP[s])\n", + " return T0[s] * (1 + a * r ** b + 6.0 * max(0.0, r - 1.0)) # 용량 초과분은 대기열로 급증\n", + "\n", + "\n", + "def max_flow(closed=(), capmul=None):\n", + " \"\"\"최대유량(대/시)과 최소 절단 구간(병목). capmul = {구간: 용량 배율}.\"\"\"\n", + " cap = {s: (0 if s in closed else CAP[s] * (capmul or {}).get(s, 1.0)) for s in CAP}\n", + " res = {}\n", + " for s, (u, v) in ARCS.items():\n", + " res[(u, v)] = res.get((u, v), 0) + cap[s]\n", + " res.setdefault((v, u), 0)\n", + " flow = 0.0\n", + " while True:\n", + " prev, q = {SRC: None}, [SRC]\n", + " for u in q:\n", + " for (a, b), c in res.items():\n", + " if a == u and c > 1e-9 and b not in prev:\n", + " prev[b] = u; q.append(b)\n", + " if DST not in prev:\n", + " break\n", + " path, x = [], DST\n", + " while prev[x]:\n", + " path.append((prev[x], x)); x = prev[x]\n", + " f = min(res[e] for e in path)\n", + " for a, b in path:\n", + " res[(a, b)] -= f; res[(b, a)] += f\n", + " flow += f\n", + " reach = set(prev)\n", + " cut = [s for s, (u, v) in ARCS.items() if u in reach and v not in reach and cap[s] > 0]\n", + " return flow, cut\n", + "\n", + "\n", + "def all_paths():\n", + " \"\"\"SRC -> DST 단순 경로(구간 이름 튜플) 전체.\"\"\"\n", + " out = []\n", + "\n", + " def dfs(node, segs, seen):\n", + " if node == DST:\n", + " out.append(tuple(segs)); return\n", + " for s, (u, v) in ARCS.items():\n", + " if u == node and v not in seen:\n", + " dfs(v, segs + [s], seen | {v})\n", + " dfs(SRC, [], {SRC})\n", + " return out\n", + "\n", + "\n", + "PATHS = all_paths()\n", + "\n", + "\n", + "def assign(demand, closed=(), capmul=None, steps=40):\n", + " \"\"\"증분 배정: 수요를 steps조각으로 나눠, 전체 통과시간(대수 x 시간) 증가가 가장 작은 경로에 차례로 얹는다(시스템 최적 근사).\"\"\"\n", + " cap = {s: CAP[s] * (capmul or {}).get(s, 1.0) for s in CAP}\n", + " flow, pf = {s: 0.0 for s in CAP}, {}\n", + " ok = [p for p in PATHS if not any(s in closed for s in p)]\n", + " if not ok:\n", + " return None\n", + " for _ in range(steps):\n", + " chunk = demand / steps\n", + " best = min(ok, key=lambda p: sum((flow[s] + chunk) * seg_time(s, flow[s] + chunk, cap[s]) - flow[s] * seg_time(s, flow[s], cap[s]) for s in p))\n", + " for s in best:\n", + " flow[s] += chunk\n", + " pf[best] = pf.get(best, 0) + chunk\n", + " tt = {p: sum(seg_time(s, flow[s], cap[s]) for s in p) for p in pf}\n", + " avg = sum(pf[p] * tt[p] for p in pf) / demand\n", + " return {\"flow\": flow, \"paths\": pf, \"path_min\": tt, \"avg_min\": avg, \"util\": {s: flow[s] / cap[s] for s in cap if cap[s] > 0}}\n", + "\n", + "\n", + "FIXED_SHARE = {(\"A1\", \"A2\", \"A3\"): 0.32, (\"B1\", \"B3\"): 0.40, (\"C1\", \"C2\"): 0.28} # 과거 관행: 축선별 고정 배분(가상)\n", + "\n", + "\n", + "def fixed_assign(demand, closed=(), capmul=None):\n", + " \"\"\"과거 관행 배분: 축선별 고정 비율. 막힌 축선 물량은 열려 있는 축선에 비율대로 재분배.\"\"\"\n", + " cap = {s: CAP[s] * (capmul or {}).get(s, 1.0) for s in CAP}\n", + " live = {p: w for p, w in FIXED_SHARE.items() if not any(s in closed for s in p)}\n", + " if not live:\n", + " return None\n", + " tot = sum(live.values())\n", + " flow = {s: 0.0 for s in CAP}\n", + " pf = {p: demand * w / tot for p, w in live.items()}\n", + " for p, f in pf.items():\n", + " for s in p:\n", + " flow[s] += f\n", + " tt = {p: sum(seg_time(s, flow[s], cap[s]) for s in p) for p in pf}\n", + " return {\"flow\": flow, \"paths\": pf, \"path_min\": tt, \"avg_min\": sum(pf[p] * tt[p] for p in pf) / demand,\n", + " \"util\": {s: flow[s] / cap[s] for s in cap if cap[s] > 0}}\n", + "\n", + "\n", + "def lp_assign(demand, closed=(), capmul=None):\n", + " \"\"\"선형계획(LP): 구간 용량 상한(이용률 rho) 아래에서 자유주행시간 합을 최소화하는 경로별 물량. 불가능하면 rho를 1.0까지 완화.\"\"\"\n", + " cap = {s: CAP[s] * (capmul or {}).get(s, 1.0) for s in CAP}\n", + " ok = [p for p in PATHS if not any(s in closed for s in p)]\n", + " if not ok:\n", + " return None\n", + " for rho in (0.85, 0.95, 1.0):\n", + " r = linprog([sum(T0[s] for s in p) for p in ok], A_ub=[[1.0 if s in p else 0.0 for p in ok] for s in CAP],\n", + " b_ub=[rho * cap[s] for s in CAP], A_eq=[[1.0] * len(ok)], b_eq=[demand], bounds=(0, None), method=\"highs\")\n", + " if r.status == 0:\n", + " break\n", + " else:\n", + " return assign(demand, closed, capmul) # 용량 초과 수요: 증분 배정으로 대체(과부하 표시)\n", + " flow = {s: sum(r.x[i] for i, p in enumerate(ok) if s in p) for s in CAP}\n", + " pf = {p: float(r.x[i]) for i, p in enumerate(ok) if r.x[i] > 1e-6}\n", + " tt = {p: sum(seg_time(s, flow[s], cap[s]) for s in p) for p in pf}\n", + " return {\"flow\": flow, \"paths\": pf, \"path_min\": tt, \"avg_min\": sum(pf[p] * tt[p] for p in pf) / demand,\n", + " \"util\": {s: flow[s] / cap[s] for s in cap if cap[s] > 0}, \"rho\": rho}\n", + "\n", + "\n", + "def cost_of(res, tv=1000, fuel_km=450):\n", + " \"\"\"시간당 비용(원, 교육용 가정): 대당 시간비용 tv원/분 + 유류비 fuel_km원/km.\"\"\"\n", + " return sum(f * (res[\"path_min\"][p] * tv + sum(KM[s] for s in p) * fuel_km) for p, f in res[\"paths\"].items())\n", + "# ---------------------------------------------------------------------------\n", + "\n", + "df = make_data(); d = add_features(df)\n", + "SEGL = list(SEGS[\"seg\"])\n", + "cal, val = d[d.day < 135], d[d.day >= 135] # AAR 대상(보정) 구간 / 검증 구간\n", + "\n", + "\n", + "def model(p, x):\n", + " \"\"\"보정 모델: a, b, c(전일 지체 이월), 구간별 용량 배율 m_s, 정비 시 용량 배율, 우천 시 용량 배율.\"\"\"\n", + " a, b, c, mm, rr = p[0], p[1], p[2], p[3], p[4]\n", + " m = pd.Series(p[5:], index=SEGL)[x[\"seg\"]].values\n", + " eff = x[\"cap\"].values * m * np.where(x[\"maint\"] == 1, mm, 1.0) * np.where(x[\"rain\"] == 1, rr, 1.0)\n", + " return x[\"t0\"].values * a * (x[\"plan_v\"].values / eff) ** b + c * x[\"lag1_delay\"].values\n", + "\n", + "\n", + "p0 = np.r_[0.15, 4.0, 0.0, 1.0, 1.0, np.ones(len(SEGL))]\n", + "lo = np.r_[0.01, 1.0, 0.0, 0.3, 0.5, np.full(len(SEGL), 0.5)]\n", + "hi = np.r_[1.0, 6.0, 0.8, 1.2, 1.2, np.full(len(SEGL), 1.5)]\n", + "fit = least_squares(lambda p: model(p, cal) - cal[\"delay\"].values, p0, bounds=(lo, hi))\n", + "P = fit.x\n", + "\n", + "# ---------------- 1) 오차 분석(AAR) ----------------\n", + "base_cal, base_val = cal[\"bpr_base\"].values, val[\"bpr_base\"].values\n", + "new_val = model(P, val)\n", + "print(f\"[1] 검증 구간(day 135~149, {len(val)}행) 지체 MAE: 기준 {mae(val.delay, base_val):.2f}분 -> 재보정 {mae(val.delay, new_val):.2f}분 ({1 - mae(val.delay, new_val) / mae(val.delay, base_val):.0%} 개선)\")\n", + "print(f\" 통과시간 MAPE: 기준 {mape(val.transit, val.t0 + base_val):.2f}% -> 재보정 {mape(val.transit, val.t0 * (1 + .08 * val.rain) + new_val):.2f}%\")\n", + "err = pd.DataFrame({\"원인\": [\"비계획 정비일\", \"긴급 불출일\", \"우천일\", \"정상일\"],\n", + " \"건수\": [int((cal.maint == 1).sum()), int((cal.urgent == 1).sum()), int((cal.rain == 1).sum()), int(((cal.maint + cal.urgent + cal.rain) == 0).sum())],\n", + " \"기준 평균오차\": [(base_cal - cal.delay)[m].mean() for m in (cal.maint == 1, cal.urgent == 1, cal.rain == 1, (cal.maint + cal.urgent + cal.rain) == 0)],\n", + " \"재보정 평균오차\": [(model(P, cal) - cal.delay)[m].mean() for m in (cal.maint == 1, cal.urgent == 1, cal.rain == 1, (cal.maint + cal.urgent + cal.rain) == 0)]}).round(2)\n", + "print(\"[2] AAR 원인별 평균 오차(예측-실제, 분; 음수 = 과소예측)\")\n", + "print(err.to_string(index=False))\n", + "pm = pd.DataFrame({\"파라미터\": [\"a(지체 계수)\", \"b(비선형 지수)\", \"c(전일 이월)\", \"정비 시 용량 배율\", \"우천 시 용량 배율\"], \"공칭/기준\": [0.15, 4.0, 0.0, 1.0, 1.0], \"재보정\": P[:5]}).round(3)\n", + "print(\"[3] 재보정 파라미터:\", \"; \".join(f\"{r['파라미터']} {r['공칭/기준']} -> {r['재보정']}\" for _, r in pm.iterrows()))\n", + "ms = pd.Series(P[5:], index=SEGL).round(2)\n", + "ms_txt = {k: float(v) for k, v in ms.items()}\n", + "print(\" 구간별 유효 용량 배율:\", ms_txt, \"(1.0 = 공칭 용량과 일치)\")\n", + "\n", + "# ---------------- 2) 효과 환산: 검증 15일 백테스트 ----------------\n", + "TV = 300 # 대당 분당 시간가치(원, 가정)\n", + "hours_per_day, APPLY = 2, 0.4 # 일 피크 2시간, 승인·현장 제약으로 실제 적용되는 비율 40%(가정)\n", + "rec = []\n", + "for day, g in df[df.day >= 135].groupby(\"day\"):\n", + " D = float(g[g.seg.isin([\"A1\", \"B1\", \"C1\"])][\"plan_v\"].sum())\n", + " cm = {r.seg: r.cap_eff / r.cap for r in g.itertuples() if r.cap_eff < r.cap}\n", + " f, l = fixed_assign(D, (), cm), lp_assign(D, (), cm)\n", + " pr_b, pr_n = val[val.day == day], val[val.day == day]\n", + " rec.append((day, cost_of(f, tv=TV), cost_of(l, tv=TV), f[\"avg_min\"], l[\"avg_min\"], D, float(g.delay.max()),\n", + " float((pr_b.bpr_base).max()), float(model(P, pr_n).max())))\n", + "bt = pd.DataFrame(rec, columns=[\"day\", \"c_fix\", \"c_lp\", \"t_fix\", \"t_lp\", \"D\", \"max_delay\", \"pred_base\", \"pred_new\"])\n", + "saving_day = (bt[\"c_fix\"] - bt[\"c_lp\"]) * hours_per_day * APPLY\n", + "ann = saving_day.mean() * 365\n", + "time_cut = 1 - bt[\"t_lp\"].sum() / bt[\"t_fix\"].sum()\n", + "veh_min = float(((bt[\"t_fix\"] - bt[\"t_lp\"]) * bt[\"D\"] * hours_per_day * APPLY).sum())\n", + "alert_true = bt[\"max_delay\"] >= 5\n", + "cap_b = float(((bt[\"pred_base\"] >= 5) & alert_true).sum() / max(alert_true.sum(), 1)); cap_n = float(((bt[\"pred_new\"] >= 5) & alert_true).sum() / max(alert_true.sum(), 1))\n", + "fa_b, fa_n = int(((bt[\"pred_base\"] >= 5) & ~alert_true).sum()), int(((bt[\"pred_new\"] >= 5) & ~alert_true).sum())\n", + "print(f\"\\n[4] 3대 정량 효과 지표(검증 15일, 고정 배분 대비 LP 우회 최적화, 가정 단가 사용)\")\n", + "print(f\" (1) 비용 절감액: 일 평균 {saving_day.mean() / 1e4:,.0f}만원, 연 환산 {ann / 1e8:,.1f}억원(가정: 일 피크 2시간, 적용률 40%, 365일)\")\n", + "print(f\" (2) 시간 단축률: 평균 통과시간 {bt['t_fix'].mean():.1f}분 -> {bt['t_lp'].mean():.1f}분 ({time_cut:.1%}), 절감 차량·시간 {veh_min / 60:,.0f}대·시간\")\n", + "print(f\" (3) 지체 경보 포착률(실제 지체 5분 이상 {int(alert_true.sum())}일 대비): 기준 {cap_b:.0%} -> 재보정 {cap_n:.0%} (오경보 {fa_b} -> {fa_n}일)\")\n", + "\n", + "# ---------------- 3) ROI와 민감도(가정 도입·운영비) ----------------\n", + "COST = 2.0e8 # 도입·운영 연 비용 2억원(교육용 가정)\n", + "roi = (ann - COST) / COST\n", + "print(f\"\\n[5] ROI = (연 절감 - 비용) / 비용 = {roi:.1%} (연 비용 {COST / 1e8:.1f}억원 가정), 회수 기간 {COST / ann * 12:.1f}개월\")\n", + "sens = pd.DataFrame({\"시간가치 -30%\": [ann * 0.7], \"기준\": [ann], \"시간가치 +30%\": [ann * 1.3]}).T[0]\n", + "low_eff = ann * 0.5\n", + "print(\" 민감도(연 절감, 억원):\", {k: round(float(v) / 1e8, 1) for k, v in sens.items()}, f\"| 효과가 절반이면 ROI {(low_eff - COST) / COST:.1%}\")\n", + "print(\" 단계별 로드맵: 단기(1~3개월) 경보 기준 정착 -> 중기(6개월) 구간별 용량 배율 월 단위 재보정 -> 장기(1년) 상위 정보체계 연동 검토\")\n", + "print(\" 추정 산식은 가정 단가에 크게 의존하므로 실제 적용 때는 기획·재정 부서가 단가를 제시해야 하며 본 값은 학습용이다.\")\n", + "\n", + "fig, ax = plt.subplots(1, 3, figsize=(15, 4))\n", + "ax[0].scatter(val[\"delay\"], base_val, s=8, label=\"baseline\"); ax[0].scatter(val[\"delay\"], new_val, s=8, label=\"recalibrated\"); ax[0].plot([0, 15], [0, 15], \"k--\"); ax[0].legend(); ax[0].set_title(\"Actual vs predicted delay (min)\")\n", + "ax[1].bar(ms.index, ms.values); ax[1].axhline(1, c=\"k\", ls=\"--\"); ax[1].set_title(\"Effective capacity multiplier by segment\")\n", + "ax[2].bar([\"-30%\", \"base\", \"+30%\"], [v / 1e8 for v in sens.values]); ax[2].set_title(\"Annual saving sensitivity (100M won)\")\n", + "plt.tight_layout(); plt.show()\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 09 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + "[1] 검증 구간(day 135~149, 150행) 지체 MAE: 기준 1.69분 -> 재보정 0.54분 (68% 개선)\n", + " 통과시간 MAPE: 기준 10.02% -> 재보정 3.05%\n", + "[2] AAR 원인별 평균 오차(예측-실제, 분; 음수 = 과소예측)\n", + " 원인 건수 기준 평균오차 재보정 평균오차\n", + "비계획 정비일 78 -4.73 -0.03\n", + " 긴급 불출일 69 -2.40 -1.33\n", + " 우천일 160 -1.55 0.01\n", + " 정상일 994 -0.81 0.09\n", + "[3] 재보정 파라미터: a(지체 계수) 0.15 -> 0.197; b(비선형 지수) 4.0 -> 3.091; c(전일 이월) 0.0 -> 0.258; 정비 시 용량 배율 1.0 -> 0.623; 우천 시 용량 배율 1.0 -> 0.922\n", + " 구간별 유효 용량 배율: {'A1': 0.98, 'A2': 0.98, 'A3': 1.04, 'B1': 1.01, 'B2': 0.98, 'B3': 1.04, 'C1': 1.0, 'C2': 1.0, 'X1': 0.97, 'X2': 1.03} (1.0 = 공칭 용량과 일치)\n", + "\n", + "[4] 3대 정량 효과 지표(검증 15일, 고정 배분 대비 LP 우회 최적화, 가정 단가 사용)\n", + " (1) 비용 절감액: 일 평균 90만원, 연 환산 3.3억원(가정: 일 피크 2시간, 적용률 40%, 365일)\n", + " (2) 시간 단축률: 평균 통과시간 63.3분 -> 47.7분 (24.6%), 절감 차량·시간 816대·시간\n", + " (3) 지체 경보 포착률(실제 지체 5분 이상 7일 대비): 기준 0% -> 재보정 86% (오경보 0 -> 0일)\n", + "\n", + "[5] ROI = (연 절감 - 비용) / 비용 = 64.9% (연 비용 2.0억원 가정), 회수 기간 7.3개월\n", + " 민감도(연 절감, 억원): {'시간가치 -30%': 2.3, '기준': 3.3, '시간가치 +30%': 4.3} | 효과가 절반이면 ROI -17.5%\n", + " 단계별 로드맵: 단기(1~3개월) 경보 기준 정착 -> 중기(6개월) 구간별 용량 배율 월 단위 재보정 -> 장기(1년) 상위 정보체계 연동 검토\n", + " 추정 산식은 가정 단가에 크게 의존하므로 실제 적용 때는 기획·재정 부서가 단가를 제시해야 하며 본 값은 학습용이다.\n", + "```\n", + "
\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 10. 전시 수송 병목 구간 식별 및 우회 수송 시뮬레이션 도구 통합 패키지 완성\n", + "**소요시간**: 약 3.5시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "전시 수송 병목 구간 식별 및 우회 수송 시뮬레이션 도구를 완성한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **데이터 갱신부터 정합성 검증, 드리프트 점검, 자동 재학습, 7일 소요 예측, 우회 배분, 승인대기 분리까지 한 번에 실행되는 통합 파이프라인을 완성한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **신규 30일 데이터를 추가해 정합성을 검증하고, 기존 모델의 오차로 드리프트를 판단한 뒤 챌린저 모델을 학습해 개선이 3% 이상일 때만 승격 수행하고 검증하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `docs/mov01_operations_guide.md 및 pipeline/mov01_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": [ + "\"\"\"데이터 갱신, 정합성 검증, 드리프트 점검, 자동 재학습(챔피언/챌린저), 7일 수송 소요 예측, 병목·우회 배분, 승인대기 분리까지 한 번에 실행하는 통합 파이프라인을 완성하고 운영 문서 완비율·자체 점검·인터페이스 명세·브리핑 요약을 출력한다.\"\"\"\n", + "import importlib\n", + "import json\n", + "import sys\n", + "import numpy as np\n", + "import pandas as pd\n", + "import matplotlib.pyplot as plt\n", + "from scipy.optimize import linprog\n", + "from sklearn.ensemble import GradientBoostingRegressor\n", + "from sklearn.linear_model import Ridge\n", + "\n", + "# ---------------------------------------------------------------------------\n", + "# [교육용가상자료] 가상 노선망·구간 통과량 생성기 (실제 도로·지명·부대 없음)\n", + "# 가상 출발지(SRC)에서 가상 목적지(DST)까지 3개 축선(A·B·C)과 연결로 2개, 구간 10개. 150일치 피크시간 통과량·지체 시간을 만든다.\n", + "# ---------------------------------------------------------------------------\n", + "SEGS = pd.DataFrame([\n", + " (\"A1\", \"SRC\", \"J1\", 12, 130, \"일반\"), (\"A2\", \"J1\", \"J2\", 8, 70, \"교량\"), (\"A3\", \"J2\", \"DST\", 10, 140, \"교차로\"),\n", + " (\"B1\", \"SRC\", \"J3\", 15, 100, \"일반\"), (\"B2\", \"J3\", \"J2\", 9, 80, \"터널\"), (\"B3\", \"J3\", \"DST\", 14, 95, \"일반\"),\n", + " (\"C1\", \"SRC\", \"J4\", 18, 70, \"일반\"), (\"C2\", \"J4\", \"DST\", 20, 75, \"교량\"),\n", + " (\"X1\", \"J1\", \"J3\", 7, 50, \"교차로\"), (\"X2\", \"J4\", \"J3\", 10, 60, \"일반\")],\n", + " columns=[\"seg\", \"u\", \"v\", \"km\", \"cap\", \"kind\"]) # cap = 피크시간 통과 용량(대/시, 교육용 가정값)\n", + "KSPD = {\"일반\": 55, \"교량\": 40, \"터널\": 45, \"교차로\": 45} # 구간 유형별 자유 주행 속도(km/h, 가정값)\n", + "SEGS[\"t0\"] = SEGS[\"km\"] / SEGS[\"kind\"].map(KSPD) * 60 # 자유 주행 시간(분)\n", + "UTIL = dict(A1=.78, A2=.85, A3=.66, B1=.70, B2=.72, B3=.64, C1=.55, C2=.58, X1=.45, X2=.40) # 평시 이용률\n", + "\n", + "\n", + "def bpr(t0, v, cap, a=0.15, b=4.0):\n", + " \"\"\"BPR 지체 함수(교통공학의 표준형): 통과량/용량 비가 커질수록 지체가 급증한다. (분)\"\"\"\n", + " # [TODO : 학생 실습 10] 전시 수송 병목 구간 식별 및 우회 수송 시뮬레이션 도구 통합 패키지 완성 관련 핵심 로직을 구현하세요.\n", + " # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n", + " # <<< 여기에 코드를 작성하세요 >>>\n", + " pass # 작성 후 pass를 제거하세요.\n", + "def make_data(seed=11, days=150):\n", + " \"\"\"구간 x 일자 패널. v=실제 피크 통과량, plan_v=수송계획 수량(긴급 불출 미반영), delay=실제 지체(분).\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " d_idx = np.arange(days)\n", + " lvl = np.array([1.0, 1.08, 1.04, 1.10, 1.02, 0.82, 0.78])[d_idx % 7] * (1 + 0.0012 * d_idx)\n", + " lvl = lvl * np.where((d_idx >= 100) & (d_idx < 110), 1.30, 1.0) # 수송량 폭증 기간(가상)\n", + " prev, rows = {s: 0.0 for s in SEGS[\"seg\"]}, []\n", + " for d in d_idx:\n", + " rain = bool(rng.random() < 0.18)\n", + " for r in SEGS.itertuples():\n", + " maint, urgent = bool(rng.random() < 0.06), bool(rng.random() < 0.05) # 비계획 정비 / 긴급 불출\n", + " v = r.cap * UTIL[r.seg] * lvl[d] * (1.25 if urgent else 1.0) * rng.normal(1, 0.05)\n", + " cap_eff = r.cap * (0.65 if maint else 1.0) * (0.92 if rain else 1.0)\n", + " delay = max(r.t0 * 0.2 * (v / cap_eff) ** 3.5 + 0.25 * prev[r.seg] + rng.normal(0, 0.4), 0.0)\n", + " prev[r.seg] = delay\n", + " rows.append(dict(day=int(d), dow=int(d % 7), seg=r.seg, kind=r.kind, km=r.km, cap=r.cap, t0=r.t0,\n", + " plan_v=v / (1.25 if urgent else 1.0) * rng.normal(1, 0.04), v=v, maint=int(maint), urgent=int(urgent),\n", + " rain=int(rain), cap_eff=cap_eff, delay=delay))\n", + " df = pd.DataFrame(rows)\n", + " df[\"transit\"] = df[\"t0\"] * (1 + 0.08 * df[\"rain\"]) + df[\"delay\"]\n", + " return df\n", + "\n", + "\n", + "def mape(y, p):\n", + " # [TODO : 학생 실습 10] 전시 수송 병목 구간 식별 및 우회 수송 시뮬레이션 도구 통합 패키지 완성 관련 핵심 로직을 구현하세요.\n", + " # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n", + " # <<< 여기에 코드를 작성하세요 >>>\n", + " pass # 작성 후 pass를 제거하세요.\n", + "def mae(y, p):\n", + " return float(np.mean(np.abs(np.asarray(y, float) - np.asarray(p, float))))\n", + "# ---------------------------------------------------------------------------\n", + "# ---------------------------------------------------------------------------\n", + "# 지체 예측용 파생 피처 생성(당일 오전 시점에 알 수 있는 정보만 사용: 계획 물량, 공지 정비, 기상 예보, 전일·전주 실적)\n", + "# ---------------------------------------------------------------------------\n", + "FEATS = [\"plan_v\", \"cap\", \"cap_eff\", \"t0\", \"km\", \"maint\", \"rain\", \"util\", \"util_eff\", \"util_eff4\", \"bpr_base\", \"lag1_delay\", \"lag7_delay\",\n", + " \"roll7_v\", \"surge_ratio\", \"is_weekend\", \"dow_sin\", \"dow_cos\", \"k_교량\", \"k_터널\", \"k_교차로\"]\n", + "\n", + "\n", + "def add_features(df):\n", + " d = df.sort_values([\"seg\", \"day\"]).copy()\n", + " g = d.groupby(\"seg\")\n", + " d[\"lag1_delay\"] = g[\"delay\"].shift(1)\n", + " d[\"lag7_delay\"] = g[\"delay\"].shift(7)\n", + " d[\"roll7_v\"] = g[\"v\"].transform(lambda s: s.shift(1).rolling(7).mean())\n", + " d[\"util\"] = d[\"plan_v\"] / d[\"cap\"]\n", + " d[\"util_eff\"] = d[\"plan_v\"] / d[\"cap_eff\"]\n", + " d[\"util_eff4\"] = d[\"util_eff\"] ** 4\n", + " d[\"bpr_base\"] = bpr(d[\"t0\"], d[\"plan_v\"], d[\"cap\"])\n", + " d[\"surge_ratio\"] = d[\"plan_v\"] / d[\"roll7_v\"]\n", + " d[\"is_weekend\"] = (d[\"dow\"] >= 5).astype(int)\n", + " d[\"dow_sin\"], d[\"dow_cos\"] = np.sin(2 * np.pi * d[\"dow\"] / 7), np.cos(2 * np.pi * d[\"dow\"] / 7)\n", + " for k in (\"교량\", \"터널\", \"교차로\"):\n", + " d[\"k_\" + k] = (d[\"kind\"] == k).astype(int)\n", + " return d.dropna().sort_values([\"day\", \"seg\"]).reset_index(drop=True)\n", + "# ---------------------------------------------------------------------------\n", + "# ---------------------------------------------------------------------------\n", + "# 노선망 흐름 도구: 최대유량(Edmonds-Karp), 증분 배정(혼잡 반영 최단시간 경로), 경로 열거\n", + "# ---------------------------------------------------------------------------\n", + "CAP = dict(zip(SEGS[\"seg\"], SEGS[\"cap\"]))\n", + "T0 = dict(zip(SEGS[\"seg\"], SEGS[\"t0\"]))\n", + "KM = dict(zip(SEGS[\"seg\"], SEGS[\"km\"]))\n", + "ARCS = {r.seg: (r.u, r.v) for r in SEGS.itertuples()}\n", + "SRC, DST = \"SRC\", \"DST\"\n", + "\n", + "\n", + "def seg_time(s, flow, cap=None, a=0.2, b=3.5):\n", + " \"\"\"구간 통과시간(분) = 자유주행 + 혼잡 지체. cap을 주면 용량을 대체(정비·기상 반영).\"\"\"\n", + " r = flow / (cap or CAP[s])\n", + " return T0[s] * (1 + a * r ** b + 6.0 * max(0.0, r - 1.0)) # 용량 초과분은 대기열로 급증\n", + "\n", + "\n", + "def max_flow(closed=(), capmul=None):\n", + " \"\"\"최대유량(대/시)과 최소 절단 구간(병목). capmul = {구간: 용량 배율}.\"\"\"\n", + " cap = {s: (0 if s in closed else CAP[s] * (capmul or {}).get(s, 1.0)) for s in CAP}\n", + " res = {}\n", + " for s, (u, v) in ARCS.items():\n", + " res[(u, v)] = res.get((u, v), 0) + cap[s]\n", + " res.setdefault((v, u), 0)\n", + " flow = 0.0\n", + " while True:\n", + " prev, q = {SRC: None}, [SRC]\n", + " for u in q:\n", + " for (a, b), c in res.items():\n", + " if a == u and c > 1e-9 and b not in prev:\n", + " prev[b] = u; q.append(b)\n", + " if DST not in prev:\n", + " break\n", + " path, x = [], DST\n", + " while prev[x]:\n", + " path.append((prev[x], x)); x = prev[x]\n", + " f = min(res[e] for e in path)\n", + " for a, b in path:\n", + " res[(a, b)] -= f; res[(b, a)] += f\n", + " flow += f\n", + " reach = set(prev)\n", + " cut = [s for s, (u, v) in ARCS.items() if u in reach and v not in reach and cap[s] > 0]\n", + " return flow, cut\n", + "\n", + "\n", + "def all_paths():\n", + " \"\"\"SRC -> DST 단순 경로(구간 이름 튜플) 전체.\"\"\"\n", + " out = []\n", + "\n", + " def dfs(node, segs, seen):\n", + " if node == DST:\n", + " out.append(tuple(segs)); return\n", + " for s, (u, v) in ARCS.items():\n", + " if u == node and v not in seen:\n", + " dfs(v, segs + [s], seen | {v})\n", + " dfs(SRC, [], {SRC})\n", + " return out\n", + "\n", + "\n", + "PATHS = all_paths()\n", + "\n", + "\n", + "def assign(demand, closed=(), capmul=None, steps=40):\n", + " \"\"\"증분 배정: 수요를 steps조각으로 나눠, 전체 통과시간(대수 x 시간) 증가가 가장 작은 경로에 차례로 얹는다(시스템 최적 근사).\"\"\"\n", + " cap = {s: CAP[s] * (capmul or {}).get(s, 1.0) for s in CAP}\n", + " flow, pf = {s: 0.0 for s in CAP}, {}\n", + " ok = [p for p in PATHS if not any(s in closed for s in p)]\n", + " if not ok:\n", + " return None\n", + " for _ in range(steps):\n", + " chunk = demand / steps\n", + " best = min(ok, key=lambda p: sum((flow[s] + chunk) * seg_time(s, flow[s] + chunk, cap[s]) - flow[s] * seg_time(s, flow[s], cap[s]) for s in p))\n", + " for s in best:\n", + " flow[s] += chunk\n", + " pf[best] = pf.get(best, 0) + chunk\n", + " tt = {p: sum(seg_time(s, flow[s], cap[s]) for s in p) for p in pf}\n", + " avg = sum(pf[p] * tt[p] for p in pf) / demand\n", + " return {\"flow\": flow, \"paths\": pf, \"path_min\": tt, \"avg_min\": avg, \"util\": {s: flow[s] / cap[s] for s in cap if cap[s] > 0}}\n", + "\n", + "\n", + "FIXED_SHARE = {(\"A1\", \"A2\", \"A3\"): 0.32, (\"B1\", \"B3\"): 0.40, (\"C1\", \"C2\"): 0.28} # 과거 관행: 축선별 고정 배분(가상)\n", + "\n", + "\n", + "def fixed_assign(demand, closed=(), capmul=None):\n", + " \"\"\"과거 관행 배분: 축선별 고정 비율. 막힌 축선 물량은 열려 있는 축선에 비율대로 재분배.\"\"\"\n", + " cap = {s: CAP[s] * (capmul or {}).get(s, 1.0) for s in CAP}\n", + " live = {p: w for p, w in FIXED_SHARE.items() if not any(s in closed for s in p)}\n", + " if not live:\n", + " return None\n", + " tot = sum(live.values())\n", + " flow = {s: 0.0 for s in CAP}\n", + " pf = {p: demand * w / tot for p, w in live.items()}\n", + " for p, f in pf.items():\n", + " for s in p:\n", + " flow[s] += f\n", + " tt = {p: sum(seg_time(s, flow[s], cap[s]) for s in p) for p in pf}\n", + " return {\"flow\": flow, \"paths\": pf, \"path_min\": tt, \"avg_min\": sum(pf[p] * tt[p] for p in pf) / demand,\n", + " \"util\": {s: flow[s] / cap[s] for s in cap if cap[s] > 0}}\n", + "\n", + "\n", + "def lp_assign(demand, closed=(), capmul=None):\n", + " \"\"\"선형계획(LP): 구간 용량 상한(이용률 rho) 아래에서 자유주행시간 합을 최소화하는 경로별 물량. 불가능하면 rho를 1.0까지 완화.\"\"\"\n", + " cap = {s: CAP[s] * (capmul or {}).get(s, 1.0) for s in CAP}\n", + " ok = [p for p in PATHS if not any(s in closed for s in p)]\n", + " if not ok:\n", + " return None\n", + " for rho in (0.85, 0.95, 1.0):\n", + " r = linprog([sum(T0[s] for s in p) for p in ok], A_ub=[[1.0 if s in p else 0.0 for p in ok] for s in CAP],\n", + " b_ub=[rho * cap[s] for s in CAP], A_eq=[[1.0] * len(ok)], b_eq=[demand], bounds=(0, None), method=\"highs\")\n", + " if r.status == 0:\n", + " break\n", + " else:\n", + " return assign(demand, closed, capmul) # 용량 초과 수요: 증분 배정으로 대체(과부하 표시)\n", + " flow = {s: sum(r.x[i] for i, p in enumerate(ok) if s in p) for s in CAP}\n", + " pf = {p: float(r.x[i]) for i, p in enumerate(ok) if r.x[i] > 1e-6}\n", + " tt = {p: sum(seg_time(s, flow[s], cap[s]) for s in p) for p in pf}\n", + " return {\"flow\": flow, \"paths\": pf, \"path_min\": tt, \"avg_min\": sum(pf[p] * tt[p] for p in pf) / demand,\n", + " \"util\": {s: flow[s] / cap[s] for s in cap if cap[s] > 0}, \"rho\": rho}\n", + "\n", + "\n", + "def cost_of(res, tv=1000, fuel_km=450):\n", + " \"\"\"시간당 비용(원, 교육용 가정): 대당 시간비용 tv원/분 + 유류비 fuel_km원/km.\"\"\"\n", + " return sum(f * (res[\"path_min\"][p] * tv + sum(KM[s] for s in p) * fuel_km) for p, f in res[\"paths\"].items())\n", + "# ---------------------------------------------------------------------------\n", + "\n", + "libs = [\"numpy\", \"pandas\", \"scipy\", \"sklearn\", \"matplotlib\"]\n", + "env_ok = sum(importlib.util.find_spec(m) is not None for m in libs)\n", + "print(f\"[0] 환경 점검: Python {sys.version_info.major}.{sys.version_info.minor}, 필수 모듈 {env_ok}/{len(libs)}\")\n", + "\n", + "# ---------------- 1) 데이터 갱신·정합성 검증(미션 1~2 요약) ----------------\n", + "raw = make_data(days=180) # day 0~149 기존 + day 150~179 신규 입력분\n", + "d = add_features(raw)\n", + "chk = {\"(구간,일자) 유일\": not raw.duplicated([\"seg\", \"day\"]).any(), \"결측 0\": int(raw.isna().sum().sum()) == 0, \"통과량 범위\": bool(((raw.v >= 0) & (raw.v <= 3 * raw.cap)).all()),\n", + " \"통과시간 >= 자유주행\": bool((raw.transit >= raw.t0 - 1e-9).all())}\n", + "print(f\"[1] 신규 30일 포함 {len(raw)}행 정합성 검증 {sum(chk.values())}/{len(chk)} 통과\")\n", + "\n", + "# ---------------- 2) 자동 재학습: 드리프트 점검 -> 챌린저 학습 -> 승격 판단 ----------------\n", + "def fit(tr):\n", + " return GradientBoostingRegressor(n_estimators=200, max_depth=3, learning_rate=0.08, random_state=0).fit(tr[FEATS], tr[\"delay\"])\n", + "\n", + "\n", + "def evaluate(m, te):\n", + " p = te[\"t0\"] * (1 + 0.08 * te[\"rain\"]) + m.predict(te[FEATS])\n", + " return mae(te[\"transit\"], p), mape(te[\"transit\"], p)\n", + "\n", + "\n", + "def retrain_pipeline(old_end, new_end, drift_mape=3.0, min_gain=0.03):\n", + " old, new = d[d.day < old_end], d[(d.day >= old_end) & (d.day < new_end)]\n", + " champ = fit(old)\n", + " ch_mae, ch_mape = evaluate(champ, new)\n", + " drift = ch_mape > drift_mape\n", + " chall = fit(d[d.day < new_end - 10]) # 최근 10일은 검증용으로 남김\n", + " hold = d[(d.day >= new_end - 10) & (d.day < new_end)]\n", + " a, b = evaluate(champ, hold)[0], evaluate(chall, hold)[0]\n", + " promote = (a - b) / a >= min_gain\n", + " return {\"champ_mape\": ch_mape, \"drift\": drift, \"champ_mae\": a, \"chall_mae\": b, \"promote\": promote, \"model\": chall if promote else champ}\n", + "\n", + "\n", + "rt = retrain_pipeline(150, 180)\n", + "print(f\"[2] 재학습 파이프라인: 기존 모델의 신규 30일 MAPE {rt['champ_mape']:.2f}% (드리프트 기준 3.0%: {'초과' if rt['drift'] else '이내'})\")\n", + "print(f\" 검증 10일 MAE 챔피언 {rt['champ_mae']:.3f}분 vs 챌린저 {rt['chall_mae']:.3f}분 -> {'챌린저 승격' if rt['promote'] else '기존 모델 유지'}(개선 3% 이상일 때만 승격)\")\n", + "model = rt[\"model\"]\n", + "\n", + "# ---------------- 3) 7일 수송 소요 예측(Ridge 요일+추세 vs 전주 동요일 Baseline) ----------------\n", + "daily = raw[raw.seg.isin([\"A1\", \"B1\", \"C1\"])].groupby(\"day\")[\"plan_v\"].sum()\n", + "\n", + "\n", + "def design(days):\n", + " days = np.asarray(days)\n", + " X = np.zeros((len(days), 9))\n", + " X[np.arange(len(days)), days % 7] = 1\n", + " X[:, 7] = days / 100\n", + " X[:, 8] = ((days >= 100) & (days < 110)).astype(float) # 폭증 기간(알려진 사건) 더미\n", + " return X\n", + "\n", + "\n", + "tr_days = daily.index[daily.index < 173]\n", + "rg = Ridge(alpha=0.1, fit_intercept=False).fit(design(tr_days), daily[tr_days])\n", + "test_days = np.arange(173, 180)\n", + "f_ridge = rg.predict(design(test_days)); f_naive = daily[test_days - 7].values\n", + "print(f\"\\n[3] 수송 소요 7일 예측 검증(day 173~179): Ridge MAPE {mape(daily[test_days], f_ridge):.1f}% vs 전주 동요일 {mape(daily[test_days], f_naive):.1f}%\")\n", + "rg = Ridge(alpha=0.1, fit_intercept=False).fit(design(daily.index), daily)\n", + "nxt = np.arange(180, 187)\n", + "dem = rg.predict(design(nxt))\n", + "\n", + "# ---------------- 4) 소요 -> 병목·우회 배분 -> 승인대기 분리 ----------------\n", + "NOTICE = {183: {\"A2\": 0.65}} # 공지된 정비(가정): day 183 A2 교량 용량 65%\n", + "out = []\n", + "for day, D in zip(nxt, dem):\n", + " cm = NOTICE.get(int(day))\n", + " f, l = fixed_assign(D, (), cm), lp_assign(D, (), cm)\n", + " extra = l[\"avg_min\"] - lp_assign(D)[\"avg_min\"]\n", + " risky = max(l[\"util\"].values()) > 0.98 or extra >= 3 or day in NOTICE\n", + " out.append({\"day\": int(day), \"예측 소요(대/시)\": D, \"고정(분)\": f[\"avg_min\"], \"우회 배정(분)\": l[\"avg_min\"], \"절감률\": 1 - cost_of(l) / cost_of(f),\n", + " \"최대 이용률\": max(l[\"util\"].values()), \"처리\": \"작전군수관 승인대기(2단계)\" if risky else \"추천(검토 후 집행)\"})\n", + "pl = pd.DataFrame(out)\n", + "print(\"\\n[4] 향후 7일 수송 소요·우회 배분 계획 [교육용가상자료]\")\n", + "print(pl.round(2).to_string(index=False))\n", + "pend = pl[pl[\"처리\"].str.contains(\"승인대기\")]\n", + "print(f\" 승인대기 {len(pend)}건(day {', '.join(map(str, pend['day']))}) - 자동 집행하지 않고 작전군수관 결재(미션 7)로 넘긴다.\")\n", + "\n", + "# ---------------- 5) 운영 문서 완비율·인터페이스 명세 ----------------\n", + "docs = {\"데이터 사전·스키마(M1)\": 29, \"정제 규칙 10종(M2)\": 10, \"Baseline·EDA(M3)\": 8, \"병목 식별·우회(M4)\": 4, \"우회 소요·유류 엔진(M5)\": 4, \"대시보드·설명(M6)\": 6,\n", + " \"승인 절차·권한(M7)\": 4, \"스트레스 시험(M8)\": 4, \"AAR·ROI(M9)\": 5, \"운영·재학습 절차(M10)\": 6, \"장애 대응\": 5, \"인터페이스 가이드\": 4}\n", + "done = sum(v > 0 for v in docs.values())\n", + "iface = {\"input\": {\"daily_plan\": [\"day:int\", \"seg:str\", \"plan_v:float\", \"maint:int\", \"rain:int\"], \"format\": \"json-lines\"},\n", + " \"output\": {\"forecast\": [\"day\", \"demand_vph\", \"paths\", \"avg_min\", \"status\"], \"approval\": \"PENDING_ONLY\"}, \"auth\": \"role-based(가상)\", \"note\": \"상위 군수정보체계는 가상 인터페이스로만 정의\"}\n", + "print(f\"\\n[5] 운영 문서 {len(docs)}개 항목 중 작성 완료 {done}개 ({done / len(docs):.0%}) / 인터페이스 명세(JSON):\")\n", + "print(\" \", json.dumps(iface, ensure_ascii=False))\n", + "\n", + "# ---------------- 6) 자체 점검·브리핑 ----------------\n", + "te = d[d.day >= 150]\n", + "fin_mae, fin_mape = evaluate(model, te)\n", + "base_mape = mape(te.transit, te.t0 + te.bpr_base)\n", + "checks = [(\"환경 점검 통과\", env_ok == len(libs)), (\"데이터 정합성 전 항목 통과\", all(chk.values())), (\"재학습 파이프라인 정상 실행(챔피언/챌린저 판정 출력)\", rt[\"champ_mae\"] > 0),\n", + " (\"통과시간 예측 MAPE 25% 이하\", fin_mape <= 25), (\"기준 BPR 대비 MAE 20% 이상 개선\", fin_mae <= 0.8 * mae(te.transit, te.t0 + te.bpr_base)),\n", + " (\"7일 소요 예측이 전주 동요일 Baseline보다 정확\", mape(daily[test_days], f_ridge) < mape(daily[test_days], f_naive)),\n", + " (\"위험·정비 우회안 자동 집행 0건(승인대기 분리)\", len(pend) >= 1 and (pl.loc[pl.day == 183, \"처리\"].str.contains(\"승인대기\")).all()), (\"운영 문서 100% 완비\", done == len(docs))]\n", + "print(\"\\n[6] 자체 점검\")\n", + "for n, ok in checks:\n", + " print(f\" [{'PASS' if ok else 'FAIL'}] {n}\")\n", + "print(\"\\n[7] 브리핑 요약\")\n", + "print(f\" - 신규 30일 데이터로 재학습 판단을 자동 수행했다(드리프트 {'감지' if rt['drift'] else '미감지'}, {'챌린저 승격' if rt['promote'] else '기존 모델 유지'}).\")\n", + "print(f\" - 통과시간 MAPE {base_mape:.1f}%(BPR) -> {fin_mape:.1f}%, 향후 7일 소요 예측 MAPE {mape(daily[test_days], f_ridge):.1f}%.\")\n", + "print(f\" - 향후 7일 중 우회 최적화 평균 절감 {pl['절감률'].mean():.1%}, 정비 공지일은 승인대기로 분리했다. 실제 우회 하달은 작전군수관 결재와 지휘계통 결심에 따른다.\")\n", + "\n", + "fig, ax = plt.subplots(1, 2, figsize=(12, 4))\n", + "ax[0].plot(daily.index, daily.values, label=\"actual\"); ax[0].plot(nxt, dem, \"r--o\", label=\"forecast\"); ax[0].legend(); ax[0].set_title(\"Daily transport demand and 7-day forecast\")\n", + "ax[1].bar(pl[\"day\"] - .2, pl[\"고정(분)\"], .4, label=\"fixed\"); ax[1].bar(pl[\"day\"] + .2, pl[\"우회 배정(분)\"], .4, label=\"detour assignment\"); ax[1].legend(); ax[1].set_title(\"Avg transit (min), next 7 days\")\n", + "plt.suptitle(\"MOV01 integrated pipeline (synthetic)\"); plt.tight_layout(); plt.show()\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 10 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + "[0] 환경 점검: Python 3.11, 필수 모듈 5/5\n", + "[1] 신규 30일 포함 1800행 정합성 검증 4/4 통과\n", + "[2] 재학습 파이프라인: 기존 모델의 신규 30일 MAPE 2.93% (드리프트 기준 3.0%: 이내)\n", + " 검증 10일 MAE 챔피언 0.534분 vs 챌린저 0.535분 -> 기존 모델 유지(개선 3% 이상일 때만 승격)\n", + "\n", + "[3] 수송 소요 7일 예측 검증(day 173~179): Ridge MAPE 3.5% vs 전주 동요일 4.7%\n", + "\n", + "[4] 향후 7일 수송 소요·우회 배분 계획 [교육용가상자료]\n", + " day 예측 소요(대/시) 고정(분) 우회 배정(분) 절감률 최대 이용률 처리\n", + " 180 216.46 42.89 42.43 0.00 0.85 추천(검토 후 집행)\n", + " 181 204.40 42.15 41.95 0.00 0.85 추천(검토 후 집행)\n", + " 182 258.65 56.35 44.70 0.16 0.95 추천(검토 후 집행)\n", + " 183 276.73 91.80 51.32 0.37 1.11 작전군수관 승인대기(2단계)\n", + " 184 266.37 61.40 45.29 0.21 0.95 추천(검토 후 집행)\n", + " 185 275.57 68.96 45.87 0.27 1.00 작전군수관 승인대기(2단계)\n", + " 186 259.89 57.16 44.79 0.17 0.95 추천(검토 후 집행)\n", + " 승인대기 2건(day 183, 185) - 자동 집행하지 않고 작전군수관 결재(미션 7)로 넘긴다.\n", + "\n", + "[5] 운영 문서 12개 항목 중 작성 완료 12개 (100%) / 인터페이스 명세(JSON):\n", + " {\"input\": {\"daily_plan\": [\"day:int\", \"seg:str\", \"plan_v:float\", \"maint:int\", \"rain:int\"], \"format\": \"json-lines\"}, \"output\": {\"forecast\": [\"day\", \"demand_vph\", \"paths\", \"avg_min\", \"status\"], \"approval\": \"PENDING_ONLY\"}, \"auth\": \"role-based(가상)\", \"note\": \"상위 군수정보체계는 가상 인터페이스로만 정의\"}\n", + "\n", + "[6] 자체 점검\n", + " [PASS] 환경 점검 통과\n", + " [PASS] 데이터 정합성 전 항목 통과\n", + " [PASS] 재학습 파이프라인 정상 실행(챔피언/챌린저 판정 출력)\n", + " [PASS] 통과시간 예측 MAPE 25% 이하\n", + " [PASS] 기준 BPR 대비 MAE 20% 이상 개선\n", + " [PASS] 7일 소요 예측이 전주 동요일 Baseline보다 정확\n", + " [PASS] 위험·정비 우회안 자동 집행 0건(승인대기 분리)\n", + " [PASS] 운영 문서 100% 완비\n", + "\n", + "[7] 브리핑 요약\n", + " - 신규 30일 데이터로 재학습 판단을 자동 수행했다(드리프트 미감지, 기존 모델 유지).\n", + " - 통과시간 MAPE 9.6%(BPR) -> 2.9%, 향후 7일 소요 예측 MAPE 3.5%.\n", + " - 향후 7일 중 우회 최적화 평균 절감 16.9%, 정비 공지일은 승인대기로 분리했다. 실제 우회 하달은 작전군수관 결재와 지휘계통 결심에 따른다.\n", + "```\n", + "
\n" + ] + } + ], + "metadata": { + "colab": { + "provenance": [] + }, + "kernelspec": { + "display_name": "Python 3", + "name": "python3" + }, + "language_info": { + "name": "python" + } + }, + "nbformat": 4, + "nbformat_minor": 0 +} \ No newline at end of file diff --git a/uploads/MOV02/MOV02_solution.ipynb b/uploads/MOV02/MOV02_solution.ipynb new file mode 100644 index 0000000..20dabb3 --- /dev/null +++ b/uploads/MOV02/MOV02_solution.ipynb @@ -0,0 +1,3240 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# 🎯 [MOV02] 수송지원 실적·비용 가시화 Tool\n", + "### Google Colab 실습 환경 (수송·물류·창고 / Level 2)\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/MOV02/MOV02_solution.ipynb)\n", + "\n", + "---\n", + "\n", + "## 📌 1. 프로젝트 개요 및 배경\n", + "\n", + "| 항목 | 내용 |\n", + "| :--- | :--- |\n", + "| **과정 분류** | 수송·물류·창고 (Level 2) |\n", + "| **학습 소요 시간** | 10개 미션 (5일 / 총 27.0시간) |\n", + "| **환경 사양** | Python 3.10+, numpy, pandas, matplotlib, scipy, scikit-learn |\n", + "\n", + "### 📖 프로젝트 목표\n", + "NATO 동맹전환사령부(ACT)는 다국적 군수 지원에서 수송 요청부터 도착까지의 시간, 처리 기간, 물동량과 수송능력, 지연 원인을 한 화면에서 보는 물류·수송 성과 가시화를 강조해 왔다. 본 프로젝트는 이 공개 사례를 바탕으로 교육용 가상 도로망과 수송 자산 자료를 코드로 만들어, 수송지원 실적과 비용을 근거로 경로와 자산을 결정하는 과정을 구현한다. 학습자는 자산·도로망 스키마 정의, 도로 파괴·통제 구역 전처리, 다익스트라 Baseline, 위협 회피·적재 효율 추천, 생존확률·정시도착 신뢰도 점수, 관제 대시보드, 수송통제관 승인(HITL), 도로 50곳 차단 스트레스 시험, AAR 재보정, 최종 통합을 차례로 수행한다. 최종 목표는 수송 리드타임과 톤km당 비용을 낮추면서 위험 경로의 강행 여부는 반드시 사람이 결정하도록 하는 수송지원 실적·비용 가시화 도구를 완성하는 것이다.\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. 전장 수송 자산(트럭, 트레일러, 수송기) 제원 및 도로망 네트워크 데이터 스키마 정의\n", + "**소요시간**: 약 1.5시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "전장 수송 자산(트럭, 트레일러) 제원 및 도로망 데이터 스키마를 정의한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **트럭·트레일러·수송기의 교육용 가정 제원과 도로망(노드·간선·교량 하중) 데이터 스키마를 정의하고, 수송지원 실적·비용 보고 업무를 AS-IS/TO-BE로 비교한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **자산(적재량·속도·km당 비용), 노드, 간선(거리·도로 등급·교량 하중·통행 여부), 수송 요청의 필드와 허용값을 정 수행하고 검증하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `docs/mov02_as_is_to_be_spec.md` 형태의 결과물을 완성한다.\n", + "\n", + "### 🔍 평가 및 검증 기준 (PASS 조건)\n", + "#### 미션 판정\n", + "\n", + "**평가 구분:** 필수\n", + "\n", + "**핵심 평가영역:** 현행 업무(AS-IS)와 개선 업무(TO-BE) 비교의 명확성\n", + "\n", + "#### 미션 PASS 조건\n", + "\n", + "- 정량 기준: 업무 단계 분해 10단계 이상, 병목 구간 3개소 이상 도출\n", + "\n", + "\n", + "- 진행 기준: 업무 정의서 및 입출력 데이터 명세 작성 완료\n", + "\n", + "\n", + "- 검증 방법: 실무 프로세스와의 일치성 검증\n", + "\n", + "\n", + "#### 채점자 판정 메모\n", + "\n", + "> 단순 코드 실행 여부뿐만 아니라 출력된 수치와 근거 자료가 군 실무 요구사항에 부합하는지 면밀히 확인한다.\n", + "\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "\"\"\"전장 수송 자산(트럭·트레일러·수송기) 제원과 도로망(노드·간선) 데이터 스키마를 정의해 가상 샘플을 검증하고, 수송지원 실적·비용 보고 업무를 AS-IS/TO-BE 단계로 분해해 병목 구간을 도출한다.\"\"\"\n", + "import copy\n", + "import heapq\n", + "import re\n", + "import numpy as np\n", + "import pandas as pd\n", + "\n", + "# ---------------------------------------------------------------------------\n", + "# [교육용가상자료] 가상 도로망·수송 자산 생성기 (실제 지명·부대·장비 제원 없음)\n", + "# 격자형 가상 도로망(노드=교차점, 간선=도로 구간)에 도로 등급·교량 하중·가상 위협 구역을 붙인다.\n", + "# ---------------------------------------------------------------------------\n", + "ASSETS = { # 교육용 가정 제원(실제 장비 제원 아님)\n", + " \"truck\": {\"name\": \"중형 트럭\", \"cap_t\": 5.0, \"speed_kmh\": 50, \"cost_km\": 1.0, \"weight_t\": 10.0},\n", + " \"trailer\": {\"name\": \"대형 트레일러\", \"cap_t\": 20.0, \"speed_kmh\": 40, \"cost_km\": 2.4, \"weight_t\": 30.0},\n", + " \"airlift\": {\"name\": \"수송기\", \"cap_t\": 12.0, \"speed_kmh\": 300, \"cost_km\": 9.0, \"weight_t\": 0.0},\n", + "}\n", + "ROAD_FACTOR = {\"paved\": 1.0, \"unpaved\": 0.6} # 도로 등급별 속도 계수\n", + "\n", + "\n", + "def make_network(seed=5, rows=6, cols=8, spacing=5.0):\n", + " \"\"\"격자형 가상 도로망. 노드 좌표(km)와 간선 목록(거리·도로등급·교량 하중)을 돌려준다.\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " nodes = {f\"N{r:02d}{c:02d}\": (c * spacing + rng.uniform(-1, 1), r * spacing + rng.uniform(-1, 1))\n", + " for r in range(rows) for c in range(cols)}\n", + " edges = []\n", + "\n", + " def add(a, b):\n", + " (x1, y1), (x2, y2) = nodes[a], nodes[b]\n", + " bridge = bool(rng.random() < 0.15)\n", + " edges.append({\"id\": f\"E{len(edges):03d}\", \"u\": a, \"v\": b,\n", + " \"km\": round(float(np.hypot(x2 - x1, y2 - y1) * rng.uniform(1.0, 1.25)), 2),\n", + " \"road\": \"paved\" if rng.random() < 0.65 else \"unpaved\", \"bridge\": bridge,\n", + " \"limit_t\": float(rng.choice([20, 40, 60])) if bridge else None, \"open\": True})\n", + " for r in range(rows):\n", + " for c in range(cols):\n", + " if c + 1 < cols:\n", + " add(f\"N{r:02d}{c:02d}\", f\"N{r:02d}{c + 1:02d}\")\n", + " if r + 1 < rows:\n", + " add(f\"N{r:02d}{c:02d}\", f\"N{r + 1:02d}{c:02d}\")\n", + " if r + 1 < rows and c + 1 < cols and rng.random() < 0.25:\n", + " add(f\"N{r:02d}{c:02d}\", f\"N{r + 1:02d}{c + 1:02d}\")\n", + " return nodes, edges\n", + "\n", + "\n", + "def make_threats(seed=9, n=3, xlim=(8, 28), ylim=(3, 22)):\n", + " \"\"\"가상 위협 구역(원형). p = 구간 1회 통과 시 피해 가능성(교육용 가정값).\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " return [{\"id\": f\"TZ{i + 1}\", \"x\": float(rng.uniform(*xlim)), \"y\": float(rng.uniform(*ylim)),\n", + " \"r\": float(rng.uniform(3, 5)), \"p\": float(round(rng.uniform(0.05, 0.2), 3))} for i in range(n)]\n", + "\n", + "\n", + "def edge_risk(e, nodes, threats):\n", + " (x1, y1), (x2, y2) = nodes[e[\"u\"]], nodes[e[\"v\"]]\n", + " s = np.linspace(0, 1, 6)\n", + " risk = 0.0\n", + " for t in threats:\n", + " if np.min(np.hypot(x1 + (x2 - x1) * s - t[\"x\"], y1 + (y2 - y1) * s - t[\"y\"])) < t[\"r\"]:\n", + " risk = max(risk, t[\"p\"])\n", + " return risk\n", + "\n", + "\n", + "def edge_minutes(e, asset=\"truck\"):\n", + " return e[\"km\"] / (ASSETS[asset][\"speed_kmh\"] * ROAD_FACTOR[e[\"road\"]]) * 60\n", + "\n", + "\n", + "def passable(e, gross_t):\n", + " return e[\"open\"] and (not e[\"bridge\"] or e[\"limit_t\"] >= gross_t)\n", + "\n", + "\n", + "def build_adj(nodes, edges, weight_fn):\n", + " \"\"\"weight_fn(e)가 None이면 통행 불가 간선으로 보고 제외한다.\"\"\"\n", + " adj = {n: [] for n in nodes}\n", + " for e in edges:\n", + " w = weight_fn(e)\n", + " if w is not None:\n", + " adj[e[\"u\"]].append((e[\"v\"], w, e))\n", + " adj[e[\"v\"]].append((e[\"u\"], w, e))\n", + " return adj\n", + "\n", + "\n", + "def dijkstra(adj, src, dst):\n", + " \"\"\"다익스트라 최단경로. (비용, 노드 경로, 간선 목록). 도달 불가면 (inf, [], []).\"\"\"\n", + " dist, prev, done, pq = {src: 0.0}, {}, set(), [(0.0, src)]\n", + " while pq:\n", + " d, u = heapq.heappop(pq)\n", + " if u in done:\n", + " continue\n", + " done.add(u)\n", + " if u == dst:\n", + " break\n", + " for v, w, e in adj[u]:\n", + " if d + w < dist.get(v, float(\"inf\")):\n", + " dist[v], prev[v] = d + w, (u, e)\n", + " heapq.heappush(pq, (d + w, v))\n", + " if dst not in done:\n", + " return float(\"inf\"), [], []\n", + " path, es = [dst], []\n", + " while path[-1] != src:\n", + " u, e = prev[path[-1]]\n", + " es.append(e); path.append(u)\n", + " return dist[dst], path[::-1], es[::-1]\n", + "# ---------------------------------------------------------------------------\n", + "\n", + "# 1) 스키마 정의: 수송 자산 / 도로망 노드 / 도로 구간(간선) / 수송 요청\n", + "SCHEMA = {\n", + " \"asset\": {\"asset_id\": {\"type\": str, \"pattern\": r\"^(TRK|TRL|AIR)-\\d{3}$\"},\n", + " \"kind\": {\"type\": str, \"enum\": list(ASSETS)},\n", + " \"cap_t\": {\"type\": (int, float), \"min\": 0.5, \"max\": 50},\n", + " \"speed_kmh\": {\"type\": (int, float), \"min\": 5, \"max\": 900},\n", + " \"cost_km\": {\"type\": (int, float), \"min\": 0},\n", + " \"status\": {\"type\": str, \"enum\": [\"가용\", \"정비\", \"운행중\"]}},\n", + " \"node\": {\"node_id\": {\"type\": str, \"pattern\": r\"^N\\d{4}$\"},\n", + " \"x_km\": {\"type\": (int, float), \"min\": -100, \"max\": 100},\n", + " \"y_km\": {\"type\": (int, float), \"min\": -100, \"max\": 100}},\n", + " \"edge\": {\"id\": {\"type\": str, \"pattern\": r\"^E\\d{3}$\"},\n", + " \"u\": {\"type\": str, \"pattern\": r\"^N\\d{4}$\"}, \"v\": {\"type\": str, \"pattern\": r\"^N\\d{4}$\"},\n", + " \"km\": {\"type\": (int, float), \"min\": 0.1, \"max\": 200},\n", + " \"road\": {\"type\": str, \"enum\": list(ROAD_FACTOR)},\n", + " \"bridge\": {\"type\": bool}, \"open\": {\"type\": bool}},\n", + " \"request\": {\"req_id\": {\"type\": str, \"pattern\": r\"^REQ-\\d{4}$\"},\n", + " \"dest\": {\"type\": str, \"pattern\": r\"^N\\d{4}$\"},\n", + " \"cargo_t\": {\"type\": (int, float), \"min\": 0.1, \"max\": 200},\n", + " \"deadline_h\": {\"type\": (int, float), \"min\": 0.5, \"max\": 72}},\n", + "}\n", + "\n", + "\n", + "def check(name, value, rule):\n", + " ok_type = isinstance(value, rule[\"type\"]) and (rule[\"type\"] is bool or not isinstance(value, bool))\n", + " if not ok_type:\n", + " return [f\"{name}: 자료형 오류({type(value).__name__})\"]\n", + " errs = []\n", + " if \"enum\" in rule and value not in rule[\"enum\"]:\n", + " errs.append(f\"{name}: 허용되지 않은 값 '{value}'\")\n", + " if \"min\" in rule and value < rule[\"min\"]:\n", + " errs.append(f\"{name}: 최소값 {rule['min']} 미만\")\n", + " if \"max\" in rule and value > rule[\"max\"]:\n", + " errs.append(f\"{name}: 최대값 {rule['max']} 초과\")\n", + " if \"pattern\" in rule and not re.match(rule[\"pattern\"], value):\n", + " errs.append(f\"{name}: 형식 불일치 '{value}'\")\n", + " return errs\n", + "\n", + "\n", + "def validate(group, rec, node_ids=None):\n", + " errs = []\n", + " for k, rule in SCHEMA[group].items():\n", + " errs += [f\"{group}.{k}: 필수 필드 누락\"] if k not in rec else check(f\"{group}.{k}\", rec[k], rule)\n", + " if group == \"edge\" and node_ids is not None: # 참조 무결성: 없는 노드를 잇는 간선\n", + " errs += [f\"edge.{k}: 존재하지 않는 노드 {rec[k]}\" for k in (\"u\", \"v\") if rec.get(k) not in node_ids]\n", + " if group == \"edge\" and rec.get(\"bridge\") and not rec.get(\"limit_t\"):\n", + " errs.append(\"edge.limit_t: 교량 하중 제한 누락\")\n", + " return errs\n", + "\n", + "\n", + "# 2) [교육용가상자료] 샘플 생성: 자산 12대, 도로망 48노드, 수송 요청 6건\n", + "nodes, edges = make_network()\n", + "rng = np.random.default_rng(1)\n", + "prefix = {\"truck\": \"TRK\", \"trailer\": \"TRL\", \"airlift\": \"AIR\"}\n", + "assets = [{\"asset_id\": f\"{prefix[k]}-{i:03d}\", \"kind\": k, **{f: ASSETS[k][f] for f in (\"cap_t\", \"speed_kmh\", \"cost_km\")},\n", + " \"status\": str(rng.choice([\"가용\", \"정비\", \"운행중\"], p=[0.7, 0.15, 0.15]))}\n", + " for i, k in enumerate([\"truck\"] * 7 + [\"trailer\"] * 3 + [\"airlift\"] * 2)]\n", + "node_recs = [{\"node_id\": n, \"x_km\": round(x, 2), \"y_km\": round(y, 2)} for n, (x, y) in nodes.items()]\n", + "requests = [{\"req_id\": f\"REQ-{i:04d}\", \"dest\": f\"N{r:02d}07\", \"cargo_t\": float(round(rng.uniform(3, 30), 1)),\n", + " \"deadline_h\": float(rng.choice([2, 3, 4]))} for i, r in enumerate(range(6))]\n", + "samples = [(\"asset\", a) for a in assets] + [(\"node\", n) for n in node_recs] + [(\"edge\", e) for e in edges] + [(\"request\", q) for q in requests]\n", + "ids = set(nodes)\n", + "n_err = sum(1 for g, r in samples if validate(g, r, ids))\n", + "print(f\"정상 샘플 {len(samples)}건(자산 {len(assets)}, 노드 {len(node_recs)}, 간선 {len(edges)}, 요청 {len(requests)}) 검증 오류 {n_err}건\")\n", + "\n", + "# 3) 오류 주입 시험\n", + "bad = [(\"asset\", {**assets[0], \"asset_id\": \"TRUCK-1\"}), (\"asset\", {**assets[1], \"kind\": \"tank\"}),\n", + " (\"edge\", {**edges[0], \"v\": \"N9999\"}), (\"edge\", {**{k: v for k, v in edges[1].items() if k != \"km\"}}),\n", + " (\"edge\", {**edges[2], \"bridge\": True, \"limit_t\": None}), (\"request\", {**requests[0], \"cargo_t\": -2.0})]\n", + "print(\"\\n[오류 주입 결과]\")\n", + "caught = 0\n", + "for g, r in bad:\n", + " e = validate(g, copy.deepcopy(r), ids)\n", + " caught += bool(e)\n", + " print(\" -\", \"; \".join(e))\n", + "print(f\"주입 오류 {len(bad)}종 중 {caught}종 검출\")\n", + "\n", + "# 4) 수송지원 실적·비용 보고 업무 AS-IS 분해(단계별 소요 분, 가상 측정값)와 TO-BE 개선\n", + "steps = [(\"수송 요청 접수(전화·메신저)\", 20, \"요청 서식 표준화·자동 등록\", 2),\n", + " (\"가용 자산 확인(차량 일지 수기 대조)\", 30, \"자산 상태 스키마로 자동 조회\", 2),\n", + " (\"도로 상황 확인(구두 보고 취합)\", 40, \"도로 제약 자동 반영(미션 2)\", 5),\n", + " (\"경로 선정(지도 수작업)\", 35, \"최단·위협회피 경로 자동 추천\", 3),\n", + " (\"배차·적재 계획 작성\", 25, \"적재 효율 기반 자동 배차\", 5),\n", + " (\"수송통제관 승인\", 15, \"HITL 승인 화면(근거 자동 첨부)\", 10),\n", + " (\"운행 기록 수기 작성\", 20, \"운행 이벤트 자동 기록\", 3),\n", + " (\"지연·사고 보고 작성\", 25, \"지연 원인 코드 선택식 기록\", 5),\n", + " (\"운행 실적 집계(엑셀 복사·붙여넣기)\", 60, \"실적 자동 집계\", 1),\n", + " (\"비용 산출(유류·거리 단가 계산)\", 45, \"자산별 km 단가 자동 계산\", 1),\n", + " (\"실적·비용 보고서 작성\", 50, \"대시보드 화면 캡처 대신 자동 요약\", 5),\n", + " (\"상급 부대 보고·수정 요청 반영\", 30, \"단일 데이터 기준으로 수정 최소화\", 10)]\n", + "proc = pd.DataFrame(steps, columns=[\"업무 단계\", \"AS-IS(분)\", \"TO-BE 개선\", \"TO-BE(분)\"])\n", + "proc.index = range(1, len(proc) + 1)\n", + "thr = proc[\"AS-IS(분)\"].mean() + 0.5 * proc[\"AS-IS(분)\"].std()\n", + "proc[\"병목\"] = np.where(proc[\"AS-IS(분)\"] >= thr, \"병목\", \"\")\n", + "print(f\"\\n[AS-IS / TO-BE 업무 단계 {len(proc)}단계] (병목 기준: 평균+0.5표준편차 = {thr:.0f}분 이상)\")\n", + "print(proc[[\"업무 단계\", \"AS-IS(분)\", \"TO-BE(분)\", \"병목\"]].to_string())\n", + "nb = int((proc[\"병목\"] == \"병목\").sum())\n", + "asis, tobe = proc[\"AS-IS(분)\"].sum(), proc[\"TO-BE(분)\"].sum()\n", + "print(f\"\\n업무 단계 {len(proc)}단계(목표 10단계 이상: {'달성' if len(proc) >= 10 else '미달'}), 병목 {nb}개소(목표 3개소 이상: {'달성' if nb >= 3 else '미달'})\")\n", + "print(f\"1회 처리 시간 AS-IS {asis}분 -> TO-BE {tobe}분 ({1 - tobe / asis:.0%} 단축 예상)\")\n", + "print(\"\\n[스키마 그룹별 필드 수]\", {g: len(f) for g, f in SCHEMA.items()})\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 01 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + "정상 샘플 157건(자산 12, 노드 48, 간선 91, 요청 6) 검증 오류 0건\n", + "\n", + "[오류 주입 결과]\n", + " - asset.asset_id: 형식 불일치 'TRUCK-1'\n", + " - asset.kind: 허용되지 않은 값 'tank'\n", + " - edge.v: 존재하지 않는 노드 N9999\n", + " - edge.km: 필수 필드 누락\n", + " - edge.limit_t: 교량 하중 제한 누락\n", + " - request.cargo_t: 최소값 0.1 미만\n", + "주입 오류 6종 중 6종 검출\n", + "\n", + "[AS-IS / TO-BE 업무 단계 12단계] (병목 기준: 평균+0.5표준편차 = 40분 이상)\n", + " 업무 단계 AS-IS(분) TO-BE(분) 병목\n", + "1 수송 요청 접수(전화·메신저) 20 2 \n", + "2 가용 자산 확인(차량 일지 수기 대조) 30 2 \n", + "3 도로 상황 확인(구두 보고 취합) 40 5 병목\n", + "4 경로 선정(지도 수작업) 35 3 \n", + "5 배차·적재 계획 작성 25 5 \n", + "6 수송통제관 승인 15 10 \n", + "7 운행 기록 수기 작성 20 3 \n", + "8 지연·사고 보고 작성 25 5 \n", + "9 운행 실적 집계(엑셀 복사·붙여넣기) 60 1 병목\n", + "10 비용 산출(유류·거리 단가 계산) 45 1 병목\n", + "11 실적·비용 보고서 작성 50 5 병목\n", + "12 상급 부대 보고·수정 요청 반영 30 10 \n", + "\n", + "업무 단계 12단계(목표 10단계 이상: 달성), 병목 4개소(목표 3개소 이상: 달성)\n", + "1회 처리 시간 AS-IS 395분 -> TO-BE 52분 (87% 단축 예상)\n", + "\n", + "[스키마 그룹별 필드 수] {'asset': 6, 'node': 3, 'edge': 7, 'request': 4}\n", + "```\n", + "
\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 02. 도로 파괴, 교량 붕괴, 통제 구역 등 전장 제약 조건 그래프 전처리 모듈 구현\n", + "**소요시간**: 약 2.0시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "도로 파괴, 통제 구역 등 전장 제약 조건 그래프 전처리 모듈을 구현한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **도로 파괴, 교량 붕괴·하중 제한, 통제 구역 보고를 여러 형식에서 읽어 도로망 그래프에 반영하는 전처리 모듈을 구현한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **CSV, 엑셀 내보내기(탭 구분), JSON, 공문 텍스트 형식의 가상 보고서를 각각 파싱해 표준 제약 레코드로 바꾼다 과정을 진행하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `src/connectors/mov02_data_loader.py 및 스키마 정의서` 형태의 결과물을 완성한다.\n", + "\n", + "### 🔍 평가 및 검증 기준 (PASS 조건)\n", + "#### 미션 판정\n", + "\n", + "**평가 구분:** 필수\n", + "\n", + "**핵심 평가영역:** 국방 서식/양식 변경에 대한 유연한 확장성\n", + "\n", + "#### 미션 PASS 조건\n", + "\n", + "- 정량 기준: 지원 파일 포맷(CSV, Excel, JSON 등) 파싱 성공률 100%\n", + "\n", + "\n", + "- 진행 기준: 데이터 로더 및 스키마 유효성 검사 모듈 완성\n", + "\n", + "\n", + "- 검증 방법: 다양한 형태의 샘플 군수 문서 대상 파싱 테스트\n", + "\n", + "\n", + "#### 채점자 판정 메모\n", + "\n", + "> 단순 코드 실행 여부뿐만 아니라 출력된 수치와 근거 자료가 군 실무 요구사항에 부합하는지 면밀히 확인한다.\n", + "\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "\"\"\"도로 파괴·교량 붕괴·하중 제한·통제 구역 보고를 여러 형식(CSV·엑셀 내보내기·JSON·공문 텍스트)의 가상 문서에서 파싱해 도로망 그래프에 반영하는 전장 제약 조건 전처리 모듈을 구현하고, 파싱 성공률과 반영 후 연결성을 확인한다.\"\"\"\n", + "import heapq\n", + "import csv\n", + "import io\n", + "import json\n", + "import re\n", + "import numpy as np\n", + "import pandas as pd\n", + "import matplotlib.pyplot as plt\n", + "from scipy.sparse import coo_matrix\n", + "from scipy.sparse.csgraph import connected_components\n", + "\n", + "# ---------------------------------------------------------------------------\n", + "# [교육용가상자료] 가상 도로망·수송 자산 생성기 (실제 지명·부대·장비 제원 없음)\n", + "# 격자형 가상 도로망(노드=교차점, 간선=도로 구간)에 도로 등급·교량 하중·가상 위협 구역을 붙인다.\n", + "# ---------------------------------------------------------------------------\n", + "ASSETS = { # 교육용 가정 제원(실제 장비 제원 아님)\n", + " \"truck\": {\"name\": \"중형 트럭\", \"cap_t\": 5.0, \"speed_kmh\": 50, \"cost_km\": 1.0, \"weight_t\": 10.0},\n", + " \"trailer\": {\"name\": \"대형 트레일러\", \"cap_t\": 20.0, \"speed_kmh\": 40, \"cost_km\": 2.4, \"weight_t\": 30.0},\n", + " \"airlift\": {\"name\": \"수송기\", \"cap_t\": 12.0, \"speed_kmh\": 300, \"cost_km\": 9.0, \"weight_t\": 0.0},\n", + "}\n", + "ROAD_FACTOR = {\"paved\": 1.0, \"unpaved\": 0.6} # 도로 등급별 속도 계수\n", + "\n", + "\n", + "def make_network(seed=5, rows=6, cols=8, spacing=5.0):\n", + " \"\"\"격자형 가상 도로망. 노드 좌표(km)와 간선 목록(거리·도로등급·교량 하중)을 돌려준다.\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " nodes = {f\"N{r:02d}{c:02d}\": (c * spacing + rng.uniform(-1, 1), r * spacing + rng.uniform(-1, 1))\n", + " for r in range(rows) for c in range(cols)}\n", + " edges = []\n", + "\n", + " def add(a, b):\n", + " (x1, y1), (x2, y2) = nodes[a], nodes[b]\n", + " bridge = bool(rng.random() < 0.15)\n", + " edges.append({\"id\": f\"E{len(edges):03d}\", \"u\": a, \"v\": b,\n", + " \"km\": round(float(np.hypot(x2 - x1, y2 - y1) * rng.uniform(1.0, 1.25)), 2),\n", + " \"road\": \"paved\" if rng.random() < 0.65 else \"unpaved\", \"bridge\": bridge,\n", + " \"limit_t\": float(rng.choice([20, 40, 60])) if bridge else None, \"open\": True})\n", + " for r in range(rows):\n", + " for c in range(cols):\n", + " if c + 1 < cols:\n", + " add(f\"N{r:02d}{c:02d}\", f\"N{r:02d}{c + 1:02d}\")\n", + " if r + 1 < rows:\n", + " add(f\"N{r:02d}{c:02d}\", f\"N{r + 1:02d}{c:02d}\")\n", + " if r + 1 < rows and c + 1 < cols and rng.random() < 0.25:\n", + " add(f\"N{r:02d}{c:02d}\", f\"N{r + 1:02d}{c + 1:02d}\")\n", + " return nodes, edges\n", + "\n", + "\n", + "def make_threats(seed=9, n=3, xlim=(8, 28), ylim=(3, 22)):\n", + " \"\"\"가상 위협 구역(원형). p = 구간 1회 통과 시 피해 가능성(교육용 가정값).\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " return [{\"id\": f\"TZ{i + 1}\", \"x\": float(rng.uniform(*xlim)), \"y\": float(rng.uniform(*ylim)),\n", + " \"r\": float(rng.uniform(3, 5)), \"p\": float(round(rng.uniform(0.05, 0.2), 3))} for i in range(n)]\n", + "\n", + "\n", + "def edge_risk(e, nodes, threats):\n", + " (x1, y1), (x2, y2) = nodes[e[\"u\"]], nodes[e[\"v\"]]\n", + " s = np.linspace(0, 1, 6)\n", + " risk = 0.0\n", + " for t in threats:\n", + " if np.min(np.hypot(x1 + (x2 - x1) * s - t[\"x\"], y1 + (y2 - y1) * s - t[\"y\"])) < t[\"r\"]:\n", + " risk = max(risk, t[\"p\"])\n", + " return risk\n", + "\n", + "\n", + "def edge_minutes(e, asset=\"truck\"):\n", + " return e[\"km\"] / (ASSETS[asset][\"speed_kmh\"] * ROAD_FACTOR[e[\"road\"]]) * 60\n", + "\n", + "\n", + "def passable(e, gross_t):\n", + " return e[\"open\"] and (not e[\"bridge\"] or e[\"limit_t\"] >= gross_t)\n", + "\n", + "\n", + "def build_adj(nodes, edges, weight_fn):\n", + " \"\"\"weight_fn(e)가 None이면 통행 불가 간선으로 보고 제외한다.\"\"\"\n", + " adj = {n: [] for n in nodes}\n", + " for e in edges:\n", + " w = weight_fn(e)\n", + " if w is not None:\n", + " adj[e[\"u\"]].append((e[\"v\"], w, e))\n", + " adj[e[\"v\"]].append((e[\"u\"], w, e))\n", + " return adj\n", + "\n", + "\n", + "def dijkstra(adj, src, dst):\n", + " \"\"\"다익스트라 최단경로. (비용, 노드 경로, 간선 목록). 도달 불가면 (inf, [], []).\"\"\"\n", + " dist, prev, done, pq = {src: 0.0}, {}, set(), [(0.0, src)]\n", + " while pq:\n", + " d, u = heapq.heappop(pq)\n", + " if u in done:\n", + " continue\n", + " done.add(u)\n", + " if u == dst:\n", + " break\n", + " for v, w, e in adj[u]:\n", + " if d + w < dist.get(v, float(\"inf\")):\n", + " dist[v], prev[v] = d + w, (u, e)\n", + " heapq.heappush(pq, (d + w, v))\n", + " if dst not in done:\n", + " return float(\"inf\"), [], []\n", + " path, es = [dst], []\n", + " while path[-1] != src:\n", + " u, e = prev[path[-1]]\n", + " es.append(e); path.append(u)\n", + " return dist[dst], path[::-1], es[::-1]\n", + "# ---------------------------------------------------------------------------\n", + "\n", + "nodes, edges = make_network()\n", + "E = {e[\"id\"]: e for e in edges}\n", + "DEPOT, UNITS = \"N0000\", [f\"N{r:02d}07\" for r in range(6)]\n", + "\n", + "# 1) [교육용가상자료] 메모리 안의 가상 보고 문서 4종 (파일 대신 문자열로 모의)\n", + "CSV_DOC = \"\"\"edge_id,status,reason\n", + "E017,파괴,포격으로 노면 유실\n", + "E040,파괴,지뢰 의심 신고\n", + "E058,정상,복구 완료\n", + "E071,파괴,차량 잔해 적치\"\"\"\n", + "EXCEL_DOC = \"구간ID\\t교량하중(t)\\t비고\\nE025\\t15\\t교각 손상\\nE064\\t20\\t임시 가설 교량\" # 엑셀 내보내기(탭 구분, 한글 머리글)\n", + "JSON_DOC = json.dumps({\"control_zones\": [{\"zone\": \"CZ-1\", \"x_km\": 17.5, \"y_km\": 12.0, \"r_km\": 2.5, \"reason\": \"화생방 오염 의심\"},\n", + " {\"zone\": \"CZ-2\", \"x_km\": 30.0, \"y_km\": 21.0, \"r_km\": 1.8, \"reason\": \"민간인 대피 통로\"}]}, ensure_ascii=False)\n", + "MEMO_DOC = \"\"\"[가상 공문] 제목: 도로 상황 변경 통보\n", + "1. 교량 E046 붕괴로 통행 불가.\n", + "2. E083 구간 교량 하중 제한 20톤으로 하향.\n", + "3. E999 구간 파괴 보고(구간 번호 확인 필요).\"\"\"\n", + "\n", + "ALIAS = {\"edge_id\": [\"edge_id\", \"구간ID\", \"구간\"], \"limit_t\": [\"limit_t\", \"교량하중(t)\", \"하중\"], \"status\": [\"status\", \"상태\"]}\n", + "\n", + "\n", + "def std_cols(df):\n", + " \"\"\"양식이 바뀌어도(머리글 변경) 별칭 사전으로 표준 컬럼명에 맞춘다.\"\"\"\n", + " ren = {c: k for k, al in ALIAS.items() for c in df.columns if c.strip() in al}\n", + " return df.rename(columns=ren)\n", + "\n", + "\n", + "def parse(fmt, text):\n", + " \"\"\"형식별 파서. 결과는 표준 제약 레코드 목록 {type, edge_id | zone, value, reason}.\"\"\"\n", + " if fmt == \"csv\":\n", + " df = std_cols(pd.DataFrame(list(csv.DictReader(io.StringIO(text)))))\n", + " return [{\"type\": \"close\" if s == \"파괴\" else \"reopen\", \"edge_id\": e, \"reason\": r} for e, s, r in zip(df.edge_id, df.status, df.reason)]\n", + " if fmt == \"excel\":\n", + " df = std_cols(pd.DataFrame(list(csv.DictReader(io.StringIO(text), delimiter=\"\\t\"))))\n", + " return [{\"type\": \"limit\", \"edge_id\": e, \"value\": float(v), \"reason\": r} for e, v, r in zip(df.edge_id, df.limit_t, df[\"비고\"])]\n", + " if fmt == \"json\":\n", + " return [{\"type\": \"zone\", \"zone\": z[\"zone\"], \"value\": (z[\"x_km\"], z[\"y_km\"], z[\"r_km\"]), \"reason\": z[\"reason\"]}\n", + " for z in json.loads(text)[\"control_zones\"]]\n", + " if fmt == \"memo\":\n", + " out = []\n", + " for line in text.splitlines():\n", + " m = re.search(r\"(E\\d{3}).*?(붕괴|파괴|통행 불가)\", line)\n", + " if m:\n", + " out.append({\"type\": \"close\", \"edge_id\": m.group(1), \"reason\": line.strip()}); continue\n", + " m = re.search(r\"(E\\d{3}).*?하중 제한 (\\d+)\\s*톤\", line)\n", + " if m:\n", + " out.append({\"type\": \"limit\", \"edge_id\": m.group(1), \"value\": float(m.group(2)), \"reason\": line.strip()})\n", + " return out\n", + " raise ValueError(f\"지원하지 않는 형식: {fmt}\")\n", + "\n", + "\n", + "def check_rec(r):\n", + " \"\"\"스키마 유효성 검사: 구간ID 형식·존재 여부, 하중 1~100t, 통제 구역 반경 0.1~10km.\"\"\"\n", + " if r[\"type\"] == \"zone\":\n", + " return [] if 0.1 <= r[\"value\"][2] <= 10 else [f\"{r['zone']} 반경 범위 오류\"]\n", + " errs = [] if re.match(r\"^E\\d{3}$\", r[\"edge_id\"]) else [f\"{r['edge_id']} 형식 오류\"]\n", + " if r[\"edge_id\"] not in E:\n", + " errs.append(f\"{r['edge_id']} 존재하지 않는 구간\")\n", + " if r[\"type\"] == \"limit\" and not 1 <= r[\"value\"] <= 100:\n", + " errs.append(f\"{r['edge_id']} 하중 범위 오류\")\n", + " return errs\n", + "\n", + "\n", + "def apply(edges, recs, log):\n", + " \"\"\"제약 레코드를 그래프에 반영. 없는 구간 ID는 반영하지 않고 로그에 남긴다.\"\"\"\n", + " new = [dict(e) for e in edges]\n", + " idx = {e[\"id\"]: e for e in new}\n", + " for r in recs:\n", + " if r[\"type\"] == \"zone\":\n", + " x, y, rad = r[\"value\"]\n", + " for e in new:\n", + " if edge_risk(e, nodes, [{\"x\": x, \"y\": y, \"r\": rad, \"p\": 1.0}]) > 0:\n", + " e[\"open\"] = False; e[\"zone\"] = r[\"zone\"]\n", + " log.append(f\"{r['zone']} 통제 구역 반영\")\n", + " elif r[\"edge_id\"] not in idx:\n", + " log.append(f\"{r['edge_id']} 없는 구간 ID - 반영 보류(확인 요청)\")\n", + " elif r[\"type\"] == \"close\":\n", + " idx[r[\"edge_id\"]][\"open\"] = False\n", + " elif r[\"type\"] == \"reopen\":\n", + " idx[r[\"edge_id\"]][\"open\"] = True\n", + " elif r[\"type\"] == \"limit\":\n", + " idx[r[\"edge_id\"]].update(bridge=True, limit_t=r[\"value\"])\n", + " return new\n", + "\n", + "\n", + "docs = [(\"csv\", CSV_DOC), (\"excel\", EXCEL_DOC), (\"json\", JSON_DOC), (\"memo\", MEMO_DOC)]\n", + "recs, rows = [], []\n", + "for fmt, text in docs:\n", + " try:\n", + " r = parse(fmt, text); recs += r; rows.append({\"형식\": fmt, \"파싱\": \"성공\", \"레코드\": len(r)})\n", + " except Exception as ex:\n", + " rows.append({\"형식\": fmt, \"파싱\": f\"실패({ex})\", \"레코드\": 0})\n", + "res = pd.DataFrame(rows)\n", + "rate = (res[\"파싱\"] == \"성공\").mean()\n", + "print(res.to_string(index=False))\n", + "print(f\"파싱 성공률 {rate:.0%} (목표 100%: {'달성' if rate == 1 else '미달'}), 제약 레코드 {len(recs)}건\")\n", + "\n", + "log = []\n", + "after = apply(edges, recs, log)\n", + "print(\"\\n[반영 로그]\", \" / \".join(log))\n", + "print(f\"통행 불가 구간 {sum(not e['open'] for e in after)}개, 하중 제한 교량 {sum(e['bridge'] for e in after)}개 (원본 {sum(e['bridge'] for e in edges)}개)\")\n", + "\n", + "# 2) 반영 전후 연결성·도달성 점검 (트럭 총중량 15t, 트레일러 총중량 50t 가정)\n", + "ids = list(nodes)\n", + "pos = {n: i for i, n in enumerate(ids)}\n", + "\n", + "\n", + "def n_components(es, gross):\n", + " ok = [e for e in es if passable(e, gross)]\n", + " g = coo_matrix((np.ones(len(ok)), ([pos[e[\"u\"]] for e in ok], [pos[e[\"v\"]] for e in ok])), shape=(len(ids), len(ids)))\n", + " return connected_components(g, directed=False)[0]\n", + "\n", + "\n", + "print(\"\\n차종 반영 전 연결 요소 반영 후 연결 요소 도달 가능 부대(후)\")\n", + "for kind, gross in [(\"truck\", 15), (\"trailer\", 50)]:\n", + " adj = build_adj(nodes, after, lambda e: edge_minutes(e, kind) if passable(e, gross) else None)\n", + " reach = sum(dijkstra(adj, DEPOT, u)[0] < float(\"inf\") for u in UNITS)\n", + " print(f\"{kind:8s} {n_components(edges, gross):16d} {n_components(after, gross):16d} {reach}/{len(UNITS)}\")\n", + "\n", + "# 3) 양식 변경 대응 시험: 머리글이 바뀐 CSV도 별칭 사전으로 처리되는지 확인\n", + "changed = \"구간,상태,reason\\nE017,파괴,머리글 변경 양식\"\n", + "print(\"\\n머리글 변경 양식 파싱:\", parse(\"csv\", changed))\n", + "errs = [x for r in recs for x in check_rec(r)]\n", + "print(f\"스키마 검사: 레코드 {len(recs)}건 중 오류 {len(errs)}건 {errs} -> 해당 건은 반영 보류 후 확인 요청\")\n", + "\n", + "# 4) 반영 전후 그래프 그림\n", + "fig, ax = plt.subplots(1, 2, figsize=(13, 5))\n", + "for a, es, title in [(ax[0], edges, \"Road network (before)\"), (ax[1], after, \"After battlefield constraints\")]:\n", + " for e in es:\n", + " (x1, y1), (x2, y2) = nodes[e[\"u\"]], nodes[e[\"v\"]]\n", + " col = \"red\" if not e[\"open\"] else (\"orange\" if e[\"bridge\"] else (\"tab:blue\" if e[\"road\"] == \"paved\" else \"tab:gray\"))\n", + " a.plot([x1, x2], [y1, y2], color=col, lw=2.5 if not e[\"open\"] else 1.5, ls=\"--\" if not e[\"open\"] else \"-\")\n", + " a.plot(*nodes[DEPOT], \"ks\", ms=10)\n", + " for u in UNITS:\n", + " a.plot(*nodes[u], \"g^\", ms=9)\n", + " a.set_title(title); a.set_xlabel(\"x (km)\"); a.set_ylabel(\"y (km)\"); a.set_aspect(\"equal\")\n", + "for r in recs:\n", + " if r[\"type\"] == \"zone\":\n", + " ax[1].add_patch(plt.Circle(r[\"value\"][:2], r[\"value\"][2], color=\"purple\", alpha=0.2))\n", + "ax[1].text(0, -4, \"red dashed = closed, orange = bridge (weight limit), purple = control zone\", fontsize=8)\n", + "plt.suptitle(\"MOV02 constraint preprocessing (synthetic network)\")\n", + "plt.tight_layout(); plt.show()\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 02 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + " 형식 파싱 레코드\n", + " csv 성공 4\n", + "excel 성공 2\n", + " json 성공 2\n", + " memo 성공 3\n", + "파싱 성공률 100% (목표 100%: 달성), 제약 레코드 11건\n", + "\n", + "[반영 로그] CZ-1 통제 구역 반영 / CZ-2 통제 구역 반영 / E999 없는 구간 ID - 반영 보류(확인 요청)\n", + "통행 불가 구간 10개, 하중 제한 교량 17개 (원본 14개)\n", + "\n", + "차종 반영 전 연결 요소 반영 후 연결 요소 도달 가능 부대(후)\n", + "truck 1 2 6/6\n", + "trailer 1 2 6/6\n", + "\n", + "머리글 변경 양식 파싱: [{'type': 'close', 'edge_id': 'E017', 'reason': '머리글 변경 양식'}]\n", + "스키마 검사: 레코드 11건 중 오류 1건 ['E999 존재하지 않는 구간'] -> 해당 건은 반영 보류 후 확인 요청\n", + "```\n", + "
\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 03. 다익스트라 기반 최단거리 보급 경로 탐색 1차 Baseline 알고리즘 구현\n", + "**소요시간**: 약 2.5시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "다익스트라 기반 최단거리 보급 경로 탐색 Baseline 알고리즘을 구현한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **다익스트라 알고리즘으로 보급소에서 지원 부대까지 최단시간 경로를 찾는 Baseline과 경로 검증 규칙 엔진을 구현한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **간선 가중치를 차종별 소요시간으로 두고 통행 불가·하중 초과 구간을 제외해 최단 경로를 구 수행하고 검증하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `src/engine/mov02_rule_engine.py` 형태의 결과물을 완성한다.\n", + "\n", + "### 🔍 평가 및 검증 기준 (PASS 조건)\n", + "#### 미션 판정\n", + "\n", + "**평가 구분:** 필수\n", + "\n", + "**핵심 평가영역:** 군 규정 및 지침 조항과의 정확한 일치성\n", + "\n", + "#### 미션 PASS 조건\n", + "\n", + "- 정량 기준: 업무 검증 룰 15종 이상 코딩, 룰 처리 지연시간 50ms 이하\n", + "\n", + "\n", + "- 진행 기준: 룰 엔진 단위 테스트 100% 통과\n", + "\n", + "\n", + "- 검증 방법: 경계 조건(Boundary condition)에 대한 룰 판정 검증\n", + "\n", + "\n", + "#### 채점자 판정 메모\n", + "\n", + "> 단순 코드 실행 여부뿐만 아니라 출력된 수치와 근거 자료가 군 실무 요구사항에 부합하는지 면밀히 확인한다.\n", + "\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "\"\"\"다익스트라 알고리즘으로 보급소에서 지원 부대까지 최단시간 보급 경로를 찾는 Baseline을 구현하고, 설정 문자열로 분리한 경로 검증 규칙 15종과 경계 조건 단위 테스트로 결과를 판정한다.\"\"\"\n", + "import heapq\n", + "import time\n", + "import numpy as np\n", + "import pandas as pd\n", + "import matplotlib.pyplot as plt\n", + "from scipy.sparse import csr_matrix\n", + "from scipy.sparse.csgraph import dijkstra as sp_dijkstra\n", + "\n", + "# ---------------------------------------------------------------------------\n", + "# [교육용가상자료] 가상 도로망·수송 자산 생성기 (실제 지명·부대·장비 제원 없음)\n", + "# 격자형 가상 도로망(노드=교차점, 간선=도로 구간)에 도로 등급·교량 하중·가상 위협 구역을 붙인다.\n", + "# ---------------------------------------------------------------------------\n", + "ASSETS = { # 교육용 가정 제원(실제 장비 제원 아님)\n", + " \"truck\": {\"name\": \"중형 트럭\", \"cap_t\": 5.0, \"speed_kmh\": 50, \"cost_km\": 1.0, \"weight_t\": 10.0},\n", + " \"trailer\": {\"name\": \"대형 트레일러\", \"cap_t\": 20.0, \"speed_kmh\": 40, \"cost_km\": 2.4, \"weight_t\": 30.0},\n", + " \"airlift\": {\"name\": \"수송기\", \"cap_t\": 12.0, \"speed_kmh\": 300, \"cost_km\": 9.0, \"weight_t\": 0.0},\n", + "}\n", + "ROAD_FACTOR = {\"paved\": 1.0, \"unpaved\": 0.6} # 도로 등급별 속도 계수\n", + "\n", + "\n", + "def make_network(seed=5, rows=6, cols=8, spacing=5.0):\n", + " \"\"\"격자형 가상 도로망. 노드 좌표(km)와 간선 목록(거리·도로등급·교량 하중)을 돌려준다.\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " nodes = {f\"N{r:02d}{c:02d}\": (c * spacing + rng.uniform(-1, 1), r * spacing + rng.uniform(-1, 1))\n", + " for r in range(rows) for c in range(cols)}\n", + " edges = []\n", + "\n", + " def add(a, b):\n", + " (x1, y1), (x2, y2) = nodes[a], nodes[b]\n", + " bridge = bool(rng.random() < 0.15)\n", + " edges.append({\"id\": f\"E{len(edges):03d}\", \"u\": a, \"v\": b,\n", + " \"km\": round(float(np.hypot(x2 - x1, y2 - y1) * rng.uniform(1.0, 1.25)), 2),\n", + " \"road\": \"paved\" if rng.random() < 0.65 else \"unpaved\", \"bridge\": bridge,\n", + " \"limit_t\": float(rng.choice([20, 40, 60])) if bridge else None, \"open\": True})\n", + " for r in range(rows):\n", + " for c in range(cols):\n", + " if c + 1 < cols:\n", + " add(f\"N{r:02d}{c:02d}\", f\"N{r:02d}{c + 1:02d}\")\n", + " if r + 1 < rows:\n", + " add(f\"N{r:02d}{c:02d}\", f\"N{r + 1:02d}{c:02d}\")\n", + " if r + 1 < rows and c + 1 < cols and rng.random() < 0.25:\n", + " add(f\"N{r:02d}{c:02d}\", f\"N{r + 1:02d}{c + 1:02d}\")\n", + " return nodes, edges\n", + "\n", + "\n", + "def make_threats(seed=9, n=3, xlim=(8, 28), ylim=(3, 22)):\n", + " \"\"\"가상 위협 구역(원형). p = 구간 1회 통과 시 피해 가능성(교육용 가정값).\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " return [{\"id\": f\"TZ{i + 1}\", \"x\": float(rng.uniform(*xlim)), \"y\": float(rng.uniform(*ylim)),\n", + " \"r\": float(rng.uniform(3, 5)), \"p\": float(round(rng.uniform(0.05, 0.2), 3))} for i in range(n)]\n", + "\n", + "\n", + "def edge_risk(e, nodes, threats):\n", + " (x1, y1), (x2, y2) = nodes[e[\"u\"]], nodes[e[\"v\"]]\n", + " s = np.linspace(0, 1, 6)\n", + " risk = 0.0\n", + " for t in threats:\n", + " if np.min(np.hypot(x1 + (x2 - x1) * s - t[\"x\"], y1 + (y2 - y1) * s - t[\"y\"])) < t[\"r\"]:\n", + " risk = max(risk, t[\"p\"])\n", + " return risk\n", + "\n", + "\n", + "def edge_minutes(e, asset=\"truck\"):\n", + " return e[\"km\"] / (ASSETS[asset][\"speed_kmh\"] * ROAD_FACTOR[e[\"road\"]]) * 60\n", + "\n", + "\n", + "def passable(e, gross_t):\n", + " return e[\"open\"] and (not e[\"bridge\"] or e[\"limit_t\"] >= gross_t)\n", + "\n", + "\n", + "def build_adj(nodes, edges, weight_fn):\n", + " \"\"\"weight_fn(e)가 None이면 통행 불가 간선으로 보고 제외한다.\"\"\"\n", + " adj = {n: [] for n in nodes}\n", + " for e in edges:\n", + " w = weight_fn(e)\n", + " if w is not None:\n", + " adj[e[\"u\"]].append((e[\"v\"], w, e))\n", + " adj[e[\"v\"]].append((e[\"u\"], w, e))\n", + " return adj\n", + "\n", + "\n", + "def dijkstra(adj, src, dst):\n", + " \"\"\"다익스트라 최단경로. (비용, 노드 경로, 간선 목록). 도달 불가면 (inf, [], []).\"\"\"\n", + " dist, prev, done, pq = {src: 0.0}, {}, set(), [(0.0, src)]\n", + " while pq:\n", + " d, u = heapq.heappop(pq)\n", + " if u in done:\n", + " continue\n", + " done.add(u)\n", + " if u == dst:\n", + " break\n", + " for v, w, e in adj[u]:\n", + " if d + w < dist.get(v, float(\"inf\")):\n", + " dist[v], prev[v] = d + w, (u, e)\n", + " heapq.heappush(pq, (d + w, v))\n", + " if dst not in done:\n", + " return float(\"inf\"), [], []\n", + " path, es = [dst], []\n", + " while path[-1] != src:\n", + " u, e = prev[path[-1]]\n", + " es.append(e); path.append(u)\n", + " return dist[dst], path[::-1], es[::-1]\n", + "# ---------------------------------------------------------------------------\n", + "\n", + "# 규정·기준이 바뀌면 코드 대신 이 설정만 고친다(YAML 형식 문자열)\n", + "RULES_YAML = \"\"\"\n", + "max_unpaved_ratio: 0.5\n", + "detour_factor: 2.5\n", + "max_threat_edges: 1\n", + "truck_count: 3\n", + "\"\"\"\n", + "CFG = {k.strip(): float(v) for k, v in (line.split(\":\") for line in RULES_YAML.strip().splitlines())}\n", + "\n", + "nodes, edges = make_network()\n", + "threats = make_threats()\n", + "for e in edges: # 미션 2 전처리 결과(폐쇄·하중 제한·통제 구역) 요약 반영\n", + " if e[\"id\"] in (\"E017\", \"E040\", \"E046\", \"E071\"):\n", + " e[\"open\"] = False\n", + " if e[\"id\"] in (\"E025\", \"E064\", \"E083\"):\n", + " e.update(bridge=True, limit_t={\"E025\": 15.0, \"E064\": 20.0, \"E083\": 20.0}[e[\"id\"]])\n", + " if edge_risk(e, nodes, [{\"x\": 17.5, \"y\": 12.0, \"r\": 2.5, \"p\": 1}]) > 0:\n", + " e[\"open\"], e[\"zone\"] = False, \"CZ-1\"\n", + " e[\"risk\"] = edge_risk(e, nodes, threats)\n", + "DEPOT = \"N0000\"\n", + "ASSET = {\"kind\": \"truck\", \"status\": \"가용\", **ASSETS[\"truck\"]}\n", + "\n", + "\n", + "def route(dest, cargo_t, kind=\"truck\"):\n", + " gross = ASSETS[kind][\"weight_t\"] + min(cargo_t, ASSETS[kind][\"cap_t\"])\n", + " adj = build_adj(nodes, edges, lambda e: edge_minutes(e, kind) if passable(e, gross) else None)\n", + " return dijkstra(adj, DEPOT, dest)\n", + "\n", + "\n", + "def rules(req, cost, path, es, asset=ASSET):\n", + " \"\"\"경로 검증 규칙 15종. 결과: 규칙명 -> PASS / FAIL / WARN.\"\"\"\n", + " gross = asset[\"weight_t\"] + asset[\"cap_t\"]\n", + " km = sum(e[\"km\"] for e in es)\n", + " straight = np.hypot(*np.subtract(nodes[req[\"dest\"]], nodes[DEPOT]))\n", + " pairs = {frozenset((e[\"u\"], e[\"v\"])) for e in edges}\n", + " R = {\n", + " \"R01 출발지=보급소\": path[:1] == [DEPOT],\n", + " \"R02 도착지=요청 부대\": path[-1:] == [req[\"dest\"]],\n", + " \"R03 연속 노드 간 도로 존재\": all(frozenset(p) in pairs for p in zip(path, path[1:])),\n", + " \"R04 폐쇄 구간 미포함\": all(e[\"open\"] for e in es),\n", + " \"R05 교량 하중 이내\": all(not e[\"bridge\"] or e[\"limit_t\"] >= gross for e in es),\n", + " \"R06 통제 구역 미통과\": all(\"zone\" not in e for e in es),\n", + " \"R07 노드 중복 방문 없음\": len(path) == len(set(path)),\n", + " \"R08 거리 합계 정합\": abs(km - sum(e[\"km\"] for e in es)) < 1e-9 and len(es) == len(path) - 1,\n", + " \"R09 소요시간 기한 이내\": cost <= req[\"deadline_h\"] * 60,\n", + " \"R10 적재량 수송능력 이내\": req[\"cargo_t\"] <= asset[\"cap_t\"] * CFG[\"truck_count\"],\n", + " \"R11 자산 상태 가용\": asset[\"status\"] == \"가용\",\n", + " \"R12 비포장 비율 기준 이내\": sum(e[\"km\"] for e in es if e[\"road\"] == \"unpaved\") <= CFG[\"max_unpaved_ratio\"] * max(km, 1e-9),\n", + " \"R13 우회 과다 없음\": km <= CFG[\"detour_factor\"] * straight,\n", + " \"R14 경로 구간 1개 이상\": len(es) >= 1,\n", + " \"R15 위협 구역 통과 기준 이내\": sum(e[\"risk\"] > 0 for e in es) <= CFG[\"max_threat_edges\"],\n", + " }\n", + " out = {k: \"PASS\" if v else \"FAIL\" for k, v in R.items()}\n", + " if out[\"R15 위협 구역 통과 기준 이내\"] == \"FAIL\":\n", + " out[\"R15 위협 구역 통과 기준 이내\"] = \"WARN\" # Baseline은 위협을 고려하지 않으므로 경고만 표시\n", + " return out\n", + "\n", + "\n", + "rng = np.random.default_rng(2)\n", + "reqs = [{\"req\": f\"REQ-{i:04d}\", \"dest\": f\"N{r:02d}07\", \"cargo_t\": float(round(rng.uniform(4, 14), 1)), \"deadline_h\": 1.5} for i, r in enumerate(range(6))]\n", + "rows, lat, all_res = [], [], []\n", + "for q in reqs:\n", + " cost, path, es = route(q[\"dest\"], q[\"cargo_t\"])\n", + " t0 = time.perf_counter()\n", + " res = rules(q, cost, path, es)\n", + " lat.append((time.perf_counter() - t0) * 1000)\n", + " all_res.append(res)\n", + " rows.append({\"요청\": q[\"req\"], \"목적지\": q[\"dest\"], \"거리(km)\": round(sum(e[\"km\"] for e in es), 1), \"소요(분)\": round(cost, 1),\n", + " \"구간 수\": len(es), \"위협 통과 구간\": sum(e[\"risk\"] > 0 for e in es),\n", + " \"PASS\": sum(v == \"PASS\" for v in res.values()), \"WARN\": sum(v == \"WARN\" for v in res.values()), \"FAIL\": sum(v == \"FAIL\" for v in res.values())})\n", + "print(\"[Baseline 최단시간 경로 결과 - 중형 트럭]\")\n", + "print(pd.DataFrame(rows).to_string(index=False))\n", + "print(f\"\\n검증 규칙 {len(all_res[0])}종(목표 15종 이상), 규칙 처리 평균 {np.mean(lat):.2f} ms / 최대 {np.max(lat):.2f} ms (목표 50 ms 이하: {'달성' if max(lat) <= 50 else '미달'})\")\n", + "\n", + "# 1) 최적성 검증: scipy 다익스트라와 비용 비교\n", + "ids = list(nodes); ix = {n: i for i, n in enumerate(ids)}\n", + "ok_e = [e for e in edges if passable(e, 10 + 5)]\n", + "w = [edge_minutes(e) for e in ok_e]\n", + "M = csr_matrix((w + w, ([ix[e[\"u\"]] for e in ok_e] + [ix[e[\"v\"]] for e in ok_e], [ix[e[\"v\"]] for e in ok_e] + [ix[e[\"u\"]] for e in ok_e])), shape=(len(ids),) * 2)\n", + "ref = sp_dijkstra(M, indices=ix[DEPOT])\n", + "same = sum(abs(ref[ix[q[\"dest\"]]] - route(q[\"dest\"], 5.0)[0]) < 1e-6 for q in reqs)\n", + "print(f\"최적성 검증(scipy 결과와 일치): {same}/{len(reqs)}\")\n", + "\n", + "# 2) 경계 조건 단위 테스트\n", + "cost, path, es = route(\"N0507\", 5.0)\n", + "tests = {\n", + " \"적재량=수송능력 한계(15t)\": rules({\"dest\": \"N0507\", \"cargo_t\": 15.0, \"deadline_h\": 9}, cost, path, es)[\"R10 적재량 수송능력 이내\"] == \"PASS\",\n", + " \"적재량 한계+0.1t\": rules({\"dest\": \"N0507\", \"cargo_t\": 15.1, \"deadline_h\": 9}, cost, path, es)[\"R10 적재량 수송능력 이내\"] == \"FAIL\",\n", + " \"소요시간=기한 정확히 일치\": rules({\"dest\": \"N0507\", \"cargo_t\": 5, \"deadline_h\": cost / 60}, cost, path, es)[\"R09 소요시간 기한 이내\"] == \"PASS\",\n", + " \"도달 불가 목적지 처리(고립 노드)\": dijkstra(build_adj(nodes, edges, lambda e: None if \"N0507\" in (e[\"u\"], e[\"v\"]) else 1.0), DEPOT, \"N0507\") == (float(\"inf\"), [], []),\n", + " \"출발지=도착지\": dijkstra(build_adj(nodes, edges, lambda e: 1.0), DEPOT, DEPOT)[1] == [DEPOT],\n", + " \"정비 중 자산 차단\": rules({\"dest\": \"N0507\", \"cargo_t\": 5, \"deadline_h\": 9}, cost, path, es, {**ASSET, \"status\": \"정비\"})[\"R11 자산 상태 가용\"] == \"FAIL\",\n", + "}\n", + "for k, v in tests.items():\n", + " print(f\" [{'PASS' if v else 'FAIL'}] {k}\")\n", + "print(f\"단위 테스트 통과율 {np.mean(list(tests.values())):.0%}\")\n", + "print(\"Baseline 한계: 시간만 최소화해 위협 구역을 지나는 경로(WARN)가 나온다 -> 미션 4에서 위협 회피·적재 효율을 반영한다.\")\n", + "\n", + "plt.figure(figsize=(8, 5.5))\n", + "for e in edges:\n", + " (x1, y1), (x2, y2) = nodes[e[\"u\"]], nodes[e[\"v\"]]\n", + " plt.plot([x1, x2], [y1, y2], color=\"lightgray\" if e[\"open\"] else \"red\", lw=1, ls=\"-\" if e[\"open\"] else \"--\")\n", + "for t in threats:\n", + " plt.gca().add_patch(plt.Circle((t[\"x\"], t[\"y\"]), t[\"r\"], color=\"red\", alpha=0.12))\n", + "for q, c in zip(reqs, plt.cm.tab10.colors):\n", + " _, path, _ = route(q[\"dest\"], q[\"cargo_t\"])\n", + " xy = np.array([nodes[n] for n in path]); plt.plot(xy[:, 0], xy[:, 1], color=c, lw=2.5, label=f\"to {q['dest']}\")\n", + "plt.plot(*nodes[DEPOT], \"ks\", ms=10, label=\"depot\")\n", + "plt.gca().set_aspect(\"equal\"); plt.legend(fontsize=8, loc=\"upper left\"); plt.xlabel(\"x (km)\"); plt.ylabel(\"y (km)\")\n", + "plt.title(\"Baseline shortest-time routes (red circles = threat zones, synthetic)\")\n", + "plt.show()\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 03 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + "[Baseline 최단시간 경로 결과 - 중형 트럭]\n", + " 요청 목적지 거리(km) 소요(분) 구간 수 위협 통과 구간 PASS WARN FAIL\n", + "REQ-0000 N0007 44.2 58.9 8 2 14 1 0\n", + "REQ-0001 N0107 40.2 54.1 7 2 14 1 0\n", + "REQ-0002 N0207 44.4 53.2 8 3 14 1 0\n", + "REQ-0003 N0307 49.0 58.8 9 3 14 1 0\n", + "REQ-0004 N0407 53.3 67.4 10 3 14 1 0\n", + "REQ-0005 N0507 58.4 73.5 11 3 14 1 0\n", + "\n", + "검증 규칙 15종(목표 15종 이상), 규칙 처리 평균 0.06 ms / 최대 0.11 ms (목표 50 ms 이하: 달성)\n", + "최적성 검증(scipy 결과와 일치): 6/6\n", + " [PASS] 적재량=수송능력 한계(15t)\n", + " [PASS] 적재량 한계+0.1t\n", + " [PASS] 소요시간=기한 정확히 일치\n", + " [PASS] 도달 불가 목적지 처리(고립 노드)\n", + " [PASS] 출발지=도착지\n", + " [PASS] 정비 중 자산 차단\n", + "단위 테스트 통과율 100%\n", + "Baseline 한계: 시간만 최소화해 위협 구역을 지나는 경로(WARN)가 나온다 -> 미션 4에서 위협 회피·적재 효율을 반영한다.\n", + "```\n", + "
\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 04. 적 위협 반경 회피 및 자산 적재 효율을 고려한 최적 수송 경로 추천 모델 고도화\n", + "**소요시간**: 약 3.0시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "적 위협 반경 회피 및 적재 효율을 고려한 최적 수송 경로 추천 모델을 고도화한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **적 위협 반경 회피와 적재 효율(차종 조합)을 함께 고려한 최적 수송 경로 추천 모델로 고도화한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **간선 비용에 소요시간과 위협 위험 가중치를 함께 넣고, 과거 수송통제관 승인 사례(가상)로 위험 가중치를 학습 수행하고 검증하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `src/ai/mov02_intelligence_module.py` 형태의 결과물을 완성한다.\n", + "\n", + "### 🔍 평가 및 검증 기준 (PASS 조건)\n", + "#### 미션 판정\n", + "\n", + "**평가 구분:** 필수\n", + "\n", + "**핵심 평가영역:** 추천 사유의 합리성 및 실무 수용성\n", + "\n", + "#### 미션 PASS 조건\n", + "\n", + "- 정량 기준: 추천/분류 정확도 85% 이상 달성\n", + "\n", + "\n", + "- 진행 기준: AI 추론 모듈 구현 및 벤치마크 테스트 완료\n", + "\n", + "\n", + "- 검증 방법: 과거 승인/처리 완료 데이터와의 추천 일치도 검증\n", + "\n", + "\n", + "#### 채점자 판정 메모\n", + "\n", + "> 단순 코드 실행 여부뿐만 아니라 출력된 수치와 근거 자료가 군 실무 요구사항에 부합하는지 면밀히 확인한다.\n", + "\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "\"\"\"가상 위협 구역 회피 비용과 적재 효율(차종 조합)을 함께 고려하는 수송 경로 추천 모델을 구현한다. 과거 수송통제관 승인 사례로 위험 가중치를 학습해 추천 일치도(Top-1·Top-3)를 검증하고, 추천 사유와 유사도 점수를 제시한다.\"\"\"\n", + "import heapq\n", + "import numpy as np\n", + "import pandas as pd\n", + "import matplotlib.pyplot as plt\n", + "\n", + "# ---------------------------------------------------------------------------\n", + "# [교육용가상자료] 가상 도로망·수송 자산 생성기 (실제 지명·부대·장비 제원 없음)\n", + "# 격자형 가상 도로망(노드=교차점, 간선=도로 구간)에 도로 등급·교량 하중·가상 위협 구역을 붙인다.\n", + "# ---------------------------------------------------------------------------\n", + "ASSETS = { # 교육용 가정 제원(실제 장비 제원 아님)\n", + " \"truck\": {\"name\": \"중형 트럭\", \"cap_t\": 5.0, \"speed_kmh\": 50, \"cost_km\": 1.0, \"weight_t\": 10.0},\n", + " \"trailer\": {\"name\": \"대형 트레일러\", \"cap_t\": 20.0, \"speed_kmh\": 40, \"cost_km\": 2.4, \"weight_t\": 30.0},\n", + " \"airlift\": {\"name\": \"수송기\", \"cap_t\": 12.0, \"speed_kmh\": 300, \"cost_km\": 9.0, \"weight_t\": 0.0},\n", + "}\n", + "ROAD_FACTOR = {\"paved\": 1.0, \"unpaved\": 0.6} # 도로 등급별 속도 계수\n", + "\n", + "\n", + "def make_network(seed=5, rows=6, cols=8, spacing=5.0):\n", + " \"\"\"격자형 가상 도로망. 노드 좌표(km)와 간선 목록(거리·도로등급·교량 하중)을 돌려준다.\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " nodes = {f\"N{r:02d}{c:02d}\": (c * spacing + rng.uniform(-1, 1), r * spacing + rng.uniform(-1, 1))\n", + " for r in range(rows) for c in range(cols)}\n", + " edges = []\n", + "\n", + " def add(a, b):\n", + " (x1, y1), (x2, y2) = nodes[a], nodes[b]\n", + " bridge = bool(rng.random() < 0.15)\n", + " edges.append({\"id\": f\"E{len(edges):03d}\", \"u\": a, \"v\": b,\n", + " \"km\": round(float(np.hypot(x2 - x1, y2 - y1) * rng.uniform(1.0, 1.25)), 2),\n", + " \"road\": \"paved\" if rng.random() < 0.65 else \"unpaved\", \"bridge\": bridge,\n", + " \"limit_t\": float(rng.choice([20, 40, 60])) if bridge else None, \"open\": True})\n", + " for r in range(rows):\n", + " for c in range(cols):\n", + " if c + 1 < cols:\n", + " add(f\"N{r:02d}{c:02d}\", f\"N{r:02d}{c + 1:02d}\")\n", + " if r + 1 < rows:\n", + " add(f\"N{r:02d}{c:02d}\", f\"N{r + 1:02d}{c:02d}\")\n", + " if r + 1 < rows and c + 1 < cols and rng.random() < 0.25:\n", + " add(f\"N{r:02d}{c:02d}\", f\"N{r + 1:02d}{c + 1:02d}\")\n", + " return nodes, edges\n", + "\n", + "\n", + "def make_threats(seed=9, n=3, xlim=(8, 28), ylim=(3, 22)):\n", + " \"\"\"가상 위협 구역(원형). p = 구간 1회 통과 시 피해 가능성(교육용 가정값).\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " return [{\"id\": f\"TZ{i + 1}\", \"x\": float(rng.uniform(*xlim)), \"y\": float(rng.uniform(*ylim)),\n", + " \"r\": float(rng.uniform(3, 5)), \"p\": float(round(rng.uniform(0.05, 0.2), 3))} for i in range(n)]\n", + "\n", + "\n", + "def edge_risk(e, nodes, threats):\n", + " (x1, y1), (x2, y2) = nodes[e[\"u\"]], nodes[e[\"v\"]]\n", + " s = np.linspace(0, 1, 6)\n", + " risk = 0.0\n", + " for t in threats:\n", + " if np.min(np.hypot(x1 + (x2 - x1) * s - t[\"x\"], y1 + (y2 - y1) * s - t[\"y\"])) < t[\"r\"]:\n", + " risk = max(risk, t[\"p\"])\n", + " return risk\n", + "\n", + "\n", + "def edge_minutes(e, asset=\"truck\"):\n", + " return e[\"km\"] / (ASSETS[asset][\"speed_kmh\"] * ROAD_FACTOR[e[\"road\"]]) * 60\n", + "\n", + "\n", + "def passable(e, gross_t):\n", + " return e[\"open\"] and (not e[\"bridge\"] or e[\"limit_t\"] >= gross_t)\n", + "\n", + "\n", + "def build_adj(nodes, edges, weight_fn):\n", + " \"\"\"weight_fn(e)가 None이면 통행 불가 간선으로 보고 제외한다.\"\"\"\n", + " adj = {n: [] for n in nodes}\n", + " for e in edges:\n", + " w = weight_fn(e)\n", + " if w is not None:\n", + " adj[e[\"u\"]].append((e[\"v\"], w, e))\n", + " adj[e[\"v\"]].append((e[\"u\"], w, e))\n", + " return adj\n", + "\n", + "\n", + "def dijkstra(adj, src, dst):\n", + " \"\"\"다익스트라 최단경로. (비용, 노드 경로, 간선 목록). 도달 불가면 (inf, [], []).\"\"\"\n", + " dist, prev, done, pq = {src: 0.0}, {}, set(), [(0.0, src)]\n", + " while pq:\n", + " d, u = heapq.heappop(pq)\n", + " if u in done:\n", + " continue\n", + " done.add(u)\n", + " if u == dst:\n", + " break\n", + " for v, w, e in adj[u]:\n", + " if d + w < dist.get(v, float(\"inf\")):\n", + " dist[v], prev[v] = d + w, (u, e)\n", + " heapq.heappush(pq, (d + w, v))\n", + " if dst not in done:\n", + " return float(\"inf\"), [], []\n", + " path, es = [dst], []\n", + " while path[-1] != src:\n", + " u, e = prev[path[-1]]\n", + " es.append(e); path.append(u)\n", + " return dist[dst], path[::-1], es[::-1]\n", + "# ---------------------------------------------------------------------------\n", + "\n", + "nodes, edges = make_network()\n", + "DEPOT, UNITS = \"N0000\", [f\"N{r:02d}07\" for r in range(6)]\n", + "for e in edges:\n", + " if e[\"id\"] in (\"E017\", \"E040\", \"E046\", \"E071\"):\n", + " e[\"open\"] = False\n", + "LAMS = [0.0, 0.3, 0.7, 1.5, 3.0] # 위험 회피 강도별 후보 경로 생성\n", + "\n", + "\n", + "def candidates(dest, threats, kind=\"truck\", gross=15, k=5):\n", + " \"\"\"위험 회피 강도(lam)를 바꾸고, 이미 찾은 경로 구간에 벌점을 주어 서로 다른 후보 경로(최대 k개)를 만든다.\"\"\"\n", + " risk = {e[\"id\"]: edge_risk(e, nodes, threats) for e in edges}\n", + " out, seen, pen = [], set(), {}\n", + " for lam in LAMS + [0.0, 0.0, 0.3, 0.3]:\n", + " adj = build_adj(nodes, edges, lambda e: (edge_minutes(e, kind) + lam * risk[e[\"id\"]] * 100) * pen.get(e[\"id\"], 1.0)\n", + " if passable(e, gross) else None)\n", + " _, path, es = dijkstra(adj, DEPOT, dest)\n", + " for e in es:\n", + " pen[e[\"id\"]] = pen.get(e[\"id\"], 1.0) * 1.3\n", + " if path and tuple(path) not in seen and len(out) < k:\n", + " seen.add(tuple(path))\n", + " surv = float(np.prod([1 - risk[e[\"id\"]] for e in es]))\n", + " out.append({\"path\": path, \"min\": sum(edge_minutes(e, kind) for e in es), \"km\": sum(e[\"km\"] for e in es),\n", + " \"surv\": surv, \"risk_score\": -np.log(surv) * 100, \"threat_edges\": sum(risk[e[\"id\"]] > 0 for e in es)})\n", + " return out\n", + "\n", + "\n", + "def utility(c, w):\n", + " return c[\"min\"] + w * c[\"risk_score\"] # 낮을수록 좋음: 소요시간(분) + 위험 가중치 x 위험 점수\n", + "\n", + "\n", + "# 1) [교육용가상자료] 과거 수송통제관 승인 사례 40건: 숨은 선호(w=1.2)와 판단 편차(잡음)로 한 경로를 승인\n", + "rng = np.random.default_rng(4)\n", + "cases = []\n", + "for i in range(60):\n", + " th = make_threats(seed=100 + i, n=int(rng.integers(2, 5)))\n", + " cand = candidates(str(rng.choice(UNITS)), th)\n", + " if len(cand) < 2:\n", + " continue\n", + " approved = int(np.argmin([utility(c, 1.2) + rng.normal(0, 1.0) for c in cand]))\n", + " cases.append({\"cand\": cand, \"approved\": approved})\n", + "train, test = cases[:len(cases) // 2], cases[len(cases) // 2:]\n", + "\n", + "\n", + "def agreement(cs, w, top=1):\n", + " return np.mean([c[\"approved\"] in np.argsort([utility(x, w) for x in c[\"cand\"]])[:top] for c in cs])\n", + "\n", + "\n", + "def loglik(cs, w, T=1.5):\n", + " \"\"\"선택 확률 P(i) ~ exp(-효용/T) 가정의 로그우도(조건부 로짓). 일치도보다 매끄러워 가중치 추정이 안정적이다.\"\"\"\n", + " ll = 0.0\n", + " for c in cs:\n", + " u = -np.array([utility(x, w) for x in c[\"cand\"]]) / T\n", + " ll += u[c[\"approved\"]] - (u.max() + np.log(np.exp(u - u.max()).sum()))\n", + " return ll\n", + "\n", + "\n", + "grid = np.round(np.arange(0, 3.01, 0.1), 1)\n", + "w_best = grid[int(np.argmax([loglik(train, w) for w in grid]))]\n", + "print(f\"과거 승인 사례 {len(cases)}건(학습 {len(train)} / 검증 {len(test)}), 학습된 위험 가중치 w = {w_best}\")\n", + "rows = [{\"모델\": \"Baseline(최단시간, w=0)\", \"Top-1 일치도\": agreement(test, 0.0), \"Top-3 포함률\": agreement(test, 0.0, 3)},\n", + " {\"모델\": f\"위협 회피 추천(w={w_best})\", \"Top-1 일치도\": agreement(test, w_best), \"Top-3 포함률\": agreement(test, w_best, 3)}]\n", + "res = pd.DataFrame(rows)\n", + "print(res.to_string(index=False, float_format=lambda v: f\"{v:.1%}\"))\n", + "acc = rows[1][\"Top-1 일치도\"]\n", + "print(f\"추천 정확도 {acc:.1%} (목표 85% 이상: {'달성' if acc >= 0.85 else '미달'})\")\n", + "\n", + "# 2) 현재 요청에 대한 Top-3 추천과 추천 사유\n", + "threats = make_threats()\n", + "dest, cargo = \"N0307\", 38.0\n", + "cand = candidates(dest, threats)\n", + "order = np.argsort([utility(c, w_best) for c in cand])[:3]\n", + "best_u = utility(cand[order[0]], w_best)\n", + "print(f\"\\n[요청: {dest} 보급 {cargo}t] 추천 Top-3 (유사도 = 1순위 대비 효용 비율)\")\n", + "base = min(cand, key=lambda c: c[\"min\"])\n", + "for rank, i in enumerate(order, 1):\n", + " c = cand[i]\n", + " print(f\" {rank}순위 소요 {c['min']:.0f}분, 거리 {c['km']:.1f}km, 위협 통과 {c['threat_edges']}구간, 생존확률 {c['surv']:.1%}, 유사도 {best_u / utility(c, w_best):.2f}\")\n", + "top = cand[order[0]]\n", + "print(f\" 추천 사유: 최단시간 경로 대비 소요 {top['min'] - base['min']:+.0f}분, 위협 통과 {base['threat_edges']}->{top['threat_edges']}구간, 생존확률 {base['surv']:.1%}->{top['surv']:.1%}\")\n", + "\n", + "\n", + "# 3) 적재 효율: 차종 조합(트럭 5t, 트레일러 20t) 최적화 - 교량 하중 때문에 트레일러 경로가 다를 수 있음\n", + "def plan(cargo, allow_trailer=True):\n", + " best = None\n", + " for n_trl in range(0, 4 if allow_trailer else 1):\n", + " n_trk = int(max(0, np.ceil((cargo - 20 * n_trl) / 5)))\n", + " if n_trk > 10:\n", + " continue\n", + " cost = 0.0\n", + " if n_trl:\n", + " c = candidates(dest, threats, \"trailer\", 50)\n", + " if not c:\n", + " continue\n", + " cost += n_trl * min(c, key=lambda x: utility(x, w_best))[\"km\"] * ASSETS[\"trailer\"][\"cost_km\"]\n", + " cost += n_trk * top[\"km\"] * ASSETS[\"truck\"][\"cost_km\"]\n", + " cap = 20 * n_trl + 5 * n_trk\n", + " cand_plan = {\"트레일러\": n_trl, \"트럭\": n_trk, \"수송능력(t)\": cap, \"적재 효율\": cargo / cap, \"비용(단가x km)\": round(cost, 1)}\n", + " if best is None or cost < best[\"비용(단가x km)\"]:\n", + " best = cand_plan\n", + " return best\n", + "\n", + "\n", + "p0, p1 = plan(cargo, False), plan(cargo)\n", + "print(\"\\n[적재 계획 비교]\")\n", + "print(pd.DataFrame([{\"방식\": \"트럭만 배차\", **p0}, {\"방식\": \"차종 조합 최적화\", **p1}]).to_string(index=False, float_format=lambda v: f\"{v:.1%}\" if v < 1 else f\"{v:.1f}\"))\n", + "print(f\"비용 {1 - p1['비용(단가x km)'] / p0['비용(단가x km)']:.1%} 절감, 투입 차량 {p0['트럭']}대 -> {p1['트럭'] + p1['트레일러']}대\")\n", + "\n", + "fig, ax = plt.subplots(1, 2, figsize=(13, 5))\n", + "for e in edges:\n", + " (x1, y1), (x2, y2) = nodes[e[\"u\"]], nodes[e[\"v\"]]\n", + " ax[0].plot([x1, x2], [y1, y2], color=\"lightgray\", lw=1)\n", + "for t in threats:\n", + " ax[0].add_patch(plt.Circle((t[\"x\"], t[\"y\"]), t[\"r\"], color=\"red\", alpha=0.15))\n", + "for rank, (i, col) in enumerate(zip(order, [\"tab:green\", \"tab:blue\", \"tab:orange\"]), 1):\n", + " xy = np.array([nodes[n] for n in cand[i][\"path\"]]); ax[0].plot(xy[:, 0], xy[:, 1], color=col, lw=3 - 0.6 * rank, label=f\"rank {rank}\")\n", + "xy = np.array([nodes[n] for n in base[\"path\"]]); ax[0].plot(xy[:, 0], xy[:, 1], \"k--\", lw=1, label=\"shortest-time\")\n", + "ax[0].set_aspect(\"equal\"); ax[0].legend(fontsize=8); ax[0].set_title(\"Threat-aware route recommendation (synthetic)\")\n", + "ax[1].plot(grid, [agreement(train, w) for w in grid], \"o-\", label=\"train\")\n", + "ax[1].plot(grid, [agreement(test, w) for w in grid], \"s-\", label=\"test\")\n", + "ax[1].axhline(0.85, ls=\"--\", c=\"gray\"); ax[1].set_xlabel(\"risk weight w\"); ax[1].set_ylabel(\"Top-1 agreement\"); ax[1].legend()\n", + "ax[1].set_title(\"Agreement with past approvals\")\n", + "plt.tight_layout(); plt.show()\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 04 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + "과거 승인 사례 60건(학습 30 / 검증 30), 학습된 위험 가중치 w = 2.3\n", + " 모델 Top-1 일치도 Top-3 포함률\n", + "Baseline(최단시간, w=0) 43.3% 100.0%\n", + " 위협 회피 추천(w=2.3) 90.0% 96.7%\n", + "추천 정확도 90.0% (목표 85% 이상: 달성)\n", + "\n", + "[요청: N0307 보급 38.0t] 추천 Top-3 (유사도 = 1순위 대비 효용 비율)\n", + " 1순위 소요 60분, 거리 50.0km, 위협 통과 0구간, 생존확률 100.0%, 유사도 1.00\n", + " 2순위 소요 66분, 거리 50.9km, 위협 통과 0구간, 생존확률 100.0%, 유사도 0.91\n", + " 3순위 소요 73분, 거리 55.8km, 위협 통과 0구간, 생존확률 100.0%, 유사도 0.82\n", + " 추천 사유: 최단시간 경로 대비 소요 +1분, 위협 통과 3->0구간, 생존확률 57.8%->100.0%\n", + "\n", + "[적재 계획 비교]\n", + " 방식 트레일러 트럭 수송능력(t) 적재 효율 비용(단가x km)\n", + " 트럭만 배차 0 8 40 95.0% 400.0\n", + "차종 조합 최적화 2 0 40 95.0% 287.1\n", + "비용 28.2% 절감, 투입 차량 8대 -> 2대\n", + "```\n", + "
\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 05. 수송선단 생존 확률 및 정시 도착 신뢰도 점수 산출 엔진 개발\n", + "**소요시간**: 약 3.0시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "수송선단 생존 확률 및 정시 도착 신뢰도 점수 산출 엔진을 개발한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **후보 경로마다 수송선단 생존확률과 정시 도착 신뢰도를 계산해 0~100점 신뢰도 점수와 등급을 산출하는 엔진을 개발한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **위협 구간 통과 수로 생존확률을, 도로 등급·교량 대기·위협 구역 감속을 반영한 몬테카를로 모의로 정시 도착 확률을 구 수행하고 검증하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `src/ui/mov02_app.py` 형태의 결과물을 완성한다.\n", + "\n", + "### 🔍 평가 및 검증 기준 (PASS 조건)\n", + "#### 미션 판정\n", + "\n", + "**평가 구분:** 필수\n", + "\n", + "**핵심 평가영역:** 군 실무자 친화적 디자인 및 조작 편의성\n", + "\n", + "#### 미션 PASS 조건\n", + "\n", + "- 정량 기준: 핵심 화면 4종(입력-분석-검토-결과) 구현 완료\n", + "\n", + "\n", + "- 진행 기준: UI 프로토타입 로컬 서버 구동 및 전체 페이지 네비게이션 확인\n", + "\n", + "\n", + "- 검증 방법: 사용성 평가(UX Walkthrough) 및 인터랙션 테스트\n", + "\n", + "\n", + "#### 채점자 판정 메모\n", + "\n", + "> 단순 코드 실행 여부뿐만 아니라 출력된 수치와 근거 자료가 군 실무 요구사항에 부합하는지 면밀히 확인한다.\n", + "\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "\"\"\"후보 수송 경로마다 수송선단 생존 확률(위협 구역 통과 기반)과 정시 도착 신뢰도(몬테카를로 소요시간 모의)를 계산해 0~100점 신뢰도 점수와 등급을 산출하는 엔진을 만들고, 입력-분석-검토-결과 4개 화면으로 표시하며 예측 확률의 보정 정확도를 검증한다.\"\"\"\n", + "import heapq\n", + "import numpy as np\n", + "import pandas as pd\n", + "import matplotlib.pyplot as plt\n", + "\n", + "# ---------------------------------------------------------------------------\n", + "# [교육용가상자료] 가상 도로망·수송 자산 생성기 (실제 지명·부대·장비 제원 없음)\n", + "# 격자형 가상 도로망(노드=교차점, 간선=도로 구간)에 도로 등급·교량 하중·가상 위협 구역을 붙인다.\n", + "# ---------------------------------------------------------------------------\n", + "ASSETS = { # 교육용 가정 제원(실제 장비 제원 아님)\n", + " \"truck\": {\"name\": \"중형 트럭\", \"cap_t\": 5.0, \"speed_kmh\": 50, \"cost_km\": 1.0, \"weight_t\": 10.0},\n", + " \"trailer\": {\"name\": \"대형 트레일러\", \"cap_t\": 20.0, \"speed_kmh\": 40, \"cost_km\": 2.4, \"weight_t\": 30.0},\n", + " \"airlift\": {\"name\": \"수송기\", \"cap_t\": 12.0, \"speed_kmh\": 300, \"cost_km\": 9.0, \"weight_t\": 0.0},\n", + "}\n", + "ROAD_FACTOR = {\"paved\": 1.0, \"unpaved\": 0.6} # 도로 등급별 속도 계수\n", + "\n", + "\n", + "def make_network(seed=5, rows=6, cols=8, spacing=5.0):\n", + " \"\"\"격자형 가상 도로망. 노드 좌표(km)와 간선 목록(거리·도로등급·교량 하중)을 돌려준다.\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " nodes = {f\"N{r:02d}{c:02d}\": (c * spacing + rng.uniform(-1, 1), r * spacing + rng.uniform(-1, 1))\n", + " for r in range(rows) for c in range(cols)}\n", + " edges = []\n", + "\n", + " def add(a, b):\n", + " (x1, y1), (x2, y2) = nodes[a], nodes[b]\n", + " bridge = bool(rng.random() < 0.15)\n", + " edges.append({\"id\": f\"E{len(edges):03d}\", \"u\": a, \"v\": b,\n", + " \"km\": round(float(np.hypot(x2 - x1, y2 - y1) * rng.uniform(1.0, 1.25)), 2),\n", + " \"road\": \"paved\" if rng.random() < 0.65 else \"unpaved\", \"bridge\": bridge,\n", + " \"limit_t\": float(rng.choice([20, 40, 60])) if bridge else None, \"open\": True})\n", + " for r in range(rows):\n", + " for c in range(cols):\n", + " if c + 1 < cols:\n", + " add(f\"N{r:02d}{c:02d}\", f\"N{r:02d}{c + 1:02d}\")\n", + " if r + 1 < rows:\n", + " add(f\"N{r:02d}{c:02d}\", f\"N{r + 1:02d}{c:02d}\")\n", + " if r + 1 < rows and c + 1 < cols and rng.random() < 0.25:\n", + " add(f\"N{r:02d}{c:02d}\", f\"N{r + 1:02d}{c + 1:02d}\")\n", + " return nodes, edges\n", + "\n", + "\n", + "def make_threats(seed=9, n=3, xlim=(8, 28), ylim=(3, 22)):\n", + " \"\"\"가상 위협 구역(원형). p = 구간 1회 통과 시 피해 가능성(교육용 가정값).\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " return [{\"id\": f\"TZ{i + 1}\", \"x\": float(rng.uniform(*xlim)), \"y\": float(rng.uniform(*ylim)),\n", + " \"r\": float(rng.uniform(3, 5)), \"p\": float(round(rng.uniform(0.05, 0.2), 3))} for i in range(n)]\n", + "\n", + "\n", + "def edge_risk(e, nodes, threats):\n", + " (x1, y1), (x2, y2) = nodes[e[\"u\"]], nodes[e[\"v\"]]\n", + " s = np.linspace(0, 1, 6)\n", + " risk = 0.0\n", + " for t in threats:\n", + " if np.min(np.hypot(x1 + (x2 - x1) * s - t[\"x\"], y1 + (y2 - y1) * s - t[\"y\"])) < t[\"r\"]:\n", + " risk = max(risk, t[\"p\"])\n", + " return risk\n", + "\n", + "\n", + "def edge_minutes(e, asset=\"truck\"):\n", + " return e[\"km\"] / (ASSETS[asset][\"speed_kmh\"] * ROAD_FACTOR[e[\"road\"]]) * 60\n", + "\n", + "\n", + "def passable(e, gross_t):\n", + " return e[\"open\"] and (not e[\"bridge\"] or e[\"limit_t\"] >= gross_t)\n", + "\n", + "\n", + "def build_adj(nodes, edges, weight_fn):\n", + " \"\"\"weight_fn(e)가 None이면 통행 불가 간선으로 보고 제외한다.\"\"\"\n", + " adj = {n: [] for n in nodes}\n", + " for e in edges:\n", + " w = weight_fn(e)\n", + " if w is not None:\n", + " adj[e[\"u\"]].append((e[\"v\"], w, e))\n", + " adj[e[\"v\"]].append((e[\"u\"], w, e))\n", + " return adj\n", + "\n", + "\n", + "def dijkstra(adj, src, dst):\n", + " \"\"\"다익스트라 최단경로. (비용, 노드 경로, 간선 목록). 도달 불가면 (inf, [], []).\"\"\"\n", + " dist, prev, done, pq = {src: 0.0}, {}, set(), [(0.0, src)]\n", + " while pq:\n", + " d, u = heapq.heappop(pq)\n", + " if u in done:\n", + " continue\n", + " done.add(u)\n", + " if u == dst:\n", + " break\n", + " for v, w, e in adj[u]:\n", + " if d + w < dist.get(v, float(\"inf\")):\n", + " dist[v], prev[v] = d + w, (u, e)\n", + " heapq.heappush(pq, (d + w, v))\n", + " if dst not in done:\n", + " return float(\"inf\"), [], []\n", + " path, es = [dst], []\n", + " while path[-1] != src:\n", + " u, e = prev[path[-1]]\n", + " es.append(e); path.append(u)\n", + " return dist[dst], path[::-1], es[::-1]\n", + "# ---------------------------------------------------------------------------\n", + "\n", + "nodes, edges = make_network()\n", + "threats = make_threats()\n", + "DEPOT = \"N0000\"\n", + "for e in edges:\n", + " if e[\"id\"] in (\"E017\", \"E040\", \"E046\", \"E071\"):\n", + " e[\"open\"] = False\n", + " e[\"risk\"] = edge_risk(e, nodes, threats)\n", + "SIGMA = {\"paved\": 0.12, \"unpaved\": 0.30} # 도로 등급별 소요시간 변동(로그정규 표준편차, 가정값)\n", + "\n", + "\n", + "def candidates(dest, lams=(0.0, 0.5, 1.5, 3.0, 0.0, 0.5), k=4):\n", + " \"\"\"위험 회피 강도를 바꾸고 이미 쓴 구간에 벌점을 주어 서로 다른 후보 경로를 최대 k개 만든다.\"\"\"\n", + " out, seen, pen = [], set(), {}\n", + " for lam in lams:\n", + " adj = build_adj(nodes, edges, lambda e: (edge_minutes(e) + lam * e[\"risk\"] * 100) * pen.get(e[\"id\"], 1.0) if passable(e, 15) else None)\n", + " _, path, es = dijkstra(adj, DEPOT, dest)\n", + " for e in es:\n", + " pen[e[\"id\"]] = pen.get(e[\"id\"], 1.0) * 1.3\n", + " if path and tuple(path) not in seen and len(out) < k:\n", + " seen.add(tuple(path)); out.append({\"path\": path, \"es\": es})\n", + " return out\n", + "\n", + "\n", + "def simulate_minutes(es, n, rng):\n", + " \"\"\"경로 소요시간 몬테카를로: 도로 등급별 변동 + 교량 통과 대기 + 위협 구역 감속.\"\"\"\n", + " total = np.zeros(n)\n", + " for e in es:\n", + " total += edge_minutes(e) * rng.lognormal(0, SIGMA[e[\"road\"]], n)\n", + " if e[\"bridge\"]:\n", + " total += rng.exponential(3, n)\n", + " if e[\"risk\"] > 0:\n", + " total += rng.uniform(0, 10, n)\n", + " return total\n", + "\n", + "\n", + "def score_route(c, deadline_min, n=3000, seed=0):\n", + " surv = float(np.prod([1 - e[\"risk\"] for e in c[\"es\"]]))\n", + " sims = simulate_minutes(c[\"es\"], n, np.random.default_rng(seed))\n", + " ontime = float((sims <= deadline_min).mean())\n", + " s = 100 * surv * ontime\n", + " return {\"생존확률\": surv, \"정시도착\": ontime, \"예상소요(P50)\": float(np.median(sims)), \"예상소요(P90)\": float(np.percentile(sims, 90)),\n", + " \"점수\": round(s, 1), \"등급\": \"신뢰\" if s >= 85 else (\"주의\" if s >= 60 else \"위험\"), \"sims\": sims}\n", + "\n", + "\n", + "# ---------------- 화면 1. 입력 ----------------\n", + "REQ = {\"req_id\": \"REQ-0101\", \"dest\": \"N0307\", \"cargo_t\": 12.0, \"deadline_min\": 80}\n", + "print(\"[화면 1: 입력] 수송 요청\", {k: v for k, v in REQ.items()}, \"| 가상 위협 구역\", len(threats), \"개\")\n", + "\n", + "# ---------------- 화면 2. 분석 ----------------\n", + "cands = candidates(REQ[\"dest\"])\n", + "rows = []\n", + "for i, c in enumerate(cands):\n", + " r = score_route(c, REQ[\"deadline_min\"], seed=i)\n", + " c.update(r)\n", + " rows.append({\"경로\": f\"R{i + 1}\", \"구간 수\": len(c[\"es\"]), \"위협 통과\": sum(e[\"risk\"] > 0 for e in c[\"es\"]),\n", + " **{k: r[k] for k in (\"생존확률\", \"정시도착\", \"예상소요(P50)\", \"예상소요(P90)\", \"점수\", \"등급\")}})\n", + "table = pd.DataFrame(rows)\n", + "fmt = {\"생존확률\": \"{:.1%}\", \"정시도착\": \"{:.1%}\", \"예상소요(P50)\": \"{:.0f}분\", \"예상소요(P90)\": \"{:.0f}분\"}\n", + "show = table.copy()\n", + "for k, f in fmt.items():\n", + " show[k] = show[k].map(f.format)\n", + "print(\"\\n[화면 2: 분석] 후보 경로별 신뢰도 점수 (점수 = 100 x 생존확률 x 정시도착확률)\")\n", + "print(show.to_string(index=False))\n", + "\n", + "# ---------------- 화면 3. 검토 (상태 뱃지·필터) ----------------\n", + "badge = {\"신뢰\": \"완료(추천 가능)\", \"주의\": \"승인대기(통제관 검토)\", \"위험\": \"반려(재탐색)\"}\n", + "table[\"상태\"] = table[\"등급\"].map(badge)\n", + "print(\"\\n[화면 3: 검토] 상태별 필터\")\n", + "for st in badge.values():\n", + " print(f\" {st}: {table.loc[table['상태'] == st, '경로'].tolist()}\")\n", + "\n", + "# ---------------- 화면 4. 결과 ----------------\n", + "best = table.sort_values([\"점수\", \"예상소요(P50)\"], ascending=[False, True]).iloc[0]\n", + "print(f\"\\n[화면 4: 결과] 추천 {best['경로']} - 점수 {best['점수']}({best['등급']}), 생존확률 {best['생존확률']:.1%}, 정시도착 {best['정시도착']:.1%}\")\n", + "print(\"핵심 화면 4종(입력-분석-검토-결과) 구현 완료: 4/4\")\n", + "\n", + "# ---------------- 예측 보정(Calibration) 검증 ----------------\n", + "# 서로 다른 목적지·기한의 가상 수송 60건: 예측 정시확률 vs 별도 난수로 모의한 '실제 운행 결과'\n", + "rng = np.random.default_rng(21)\n", + "pred, actual = [], []\n", + "for k in range(60):\n", + " c = candidates(f\"N{int(rng.integers(0, 6)):02d}07\")[int(rng.integers(0, 2))] if k % 2 else candidates(f\"N{int(rng.integers(0, 6)):02d}07\")[0]\n", + " dl = float(rng.uniform(60, 100))\n", + " pred.append(score_route(c, dl, n=2000, seed=k)[\"정시도착\"])\n", + " actual.append(float(simulate_minutes(c[\"es\"], 1, rng)[0] <= dl))\n", + "pred, actual = np.array(pred), np.array(actual)\n", + "brier = np.mean((pred - actual) ** 2)\n", + "print(f\"\\n정시도착 예측 보정 검증(가상 운행 60건): Brier 점수 {brier:.3f} (0에 가까울수록 좋음, 무정보 예측 0.25)\")\n", + "for lo, hi in [(0, 0.5), (0.5, 0.9), (0.9, 1.01)]:\n", + " m = (pred >= lo) & (pred < hi)\n", + " if m.any():\n", + " print(f\" 예측 {lo:.0%}~{min(hi, 1):.0%} 구간 {m.sum():2d}건: 평균 예측 {pred[m].mean():.1%}, 실제 정시율 {actual[m].mean():.1%}\")\n", + "\n", + "fig, ax = plt.subplots(2, 2, figsize=(12, 8))\n", + "for e in edges:\n", + " (x1, y1), (x2, y2) = nodes[e[\"u\"]], nodes[e[\"v\"]]\n", + " ax[0, 0].plot([x1, x2], [y1, y2], color=\"lightgray\", lw=1)\n", + "for t in threats:\n", + " ax[0, 0].add_patch(plt.Circle((t[\"x\"], t[\"y\"]), t[\"r\"], color=\"red\", alpha=0.15))\n", + "for i, c in enumerate(cands):\n", + " xy = np.array([nodes[n] for n in c[\"path\"]]); ax[0, 0].plot(xy[:, 0], xy[:, 1], lw=2, label=f\"R{i + 1}\")\n", + "ax[0, 0].set_aspect(\"equal\"); ax[0, 0].legend(fontsize=8); ax[0, 0].set_title(\"1. Input: request and candidate routes\")\n", + "for i, c in enumerate(cands):\n", + " ax[0, 1].hist(c[\"sims\"], bins=40, alpha=0.5, label=f\"R{i + 1}\")\n", + "ax[0, 1].axvline(REQ[\"deadline_min\"], color=\"k\", ls=\"--\"); ax[0, 1].set_xlabel(\"travel time (min)\"); ax[0, 1].legend()\n", + "ax[0, 1].set_title(\"2. Analysis: Monte Carlo travel time\")\n", + "cols = {\"신뢰\": \"tab:green\", \"주의\": \"tab:orange\", \"위험\": \"tab:red\"}\n", + "ax[1, 0].bar(table[\"경로\"], table[\"점수\"], color=[cols[g] for g in table[\"등급\"]])\n", + "ax[1, 0].axhline(85, ls=\"--\", c=\"gray\"); ax[1, 0].axhline(60, ls=\":\", c=\"gray\"); ax[1, 0].set_ylim(0, 100)\n", + "ax[1, 0].set_title(\"3. Review: score (green=trusted, orange=review, red=reject)\")\n", + "ax[1, 1].plot([0, 1], [0, 1], \"k--\", lw=1)\n", + "bins = np.digitize(pred, [0.2, 0.4, 0.6, 0.8])\n", + "ax[1, 1].plot([pred[bins == b].mean() for b in np.unique(bins)], [actual[bins == b].mean() for b in np.unique(bins)], \"o-\")\n", + "ax[1, 1].set_xlabel(\"predicted on-time prob.\"); ax[1, 1].set_ylabel(\"observed on-time rate\"); ax[1, 1].set_title(\"4. Result: calibration check\")\n", + "plt.suptitle(\"MOV02 convoy reliability score engine (synthetic)\"); plt.tight_layout(); plt.show()\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 05 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + "[화면 1: 입력] 수송 요청 {'req_id': 'REQ-0101', 'dest': 'N0307', 'cargo_t': 12.0, 'deadline_min': 80} | 가상 위협 구역 3 개\n", + "\n", + "[화면 2: 분석] 후보 경로별 신뢰도 점수 (점수 = 100 x 생존확률 x 정시도착확률)\n", + "경로 구간 수 위협 통과 생존확률 정시도착 예상소요(P50) 예상소요(P90) 점수 등급\n", + "R1 9 3 57.8% 67.4% 77분 86분 38.9 위험\n", + "R2 10 0 100.0% 95.1% 69분 77분 95.1 신뢰\n", + "R3 10 0 100.0% 75.3% 75분 85분 75.3 주의\n", + "R4 10 0 100.0% 54.2% 79분 89분 54.2 위험\n", + "\n", + "[화면 3: 검토] 상태별 필터\n", + " 완료(추천 가능): ['R2']\n", + " 승인대기(통제관 검토): ['R3']\n", + " 반려(재탐색): ['R1', 'R4']\n", + "\n", + "[화면 4: 결과] 추천 R2 - 점수 95.1(신뢰), 생존확률 100.0%, 정시도착 95.1%\n", + "핵심 화면 4종(입력-분석-검토-결과) 구현 완료: 4/4\n", + "\n", + "정시도착 예측 보정 검증(가상 운행 60건): Brier 점수 0.086 (0에 가까울수록 좋음, 무정보 예측 0.25)\n", + " 예측 0%~50% 구간 21건: 평균 예측 23.3%, 실제 정시율 19.0%\n", + " 예측 50%~90% 구간 14건: 평균 예측 72.6%, 실제 정시율 78.6%\n", + " 예측 90%~100% 구간 25건: 평균 예측 98.4%, 실제 정시율 100.0%\n", + "```\n", + "
\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 06. 전술 수송 경로 및 자산 배정 현황 가시화 2D/3D 수송 관제 대시보드 구현\n", + "**소요시간**: 약 2.5시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "전술 수송 경로 및 자산 배정 현황 가시화 대시보드를 구현한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **수송 요청별 자산 배정과 경로를 관제 대시보드로 가시화하고, 입력 검증기와 에러 처리기로 예외 상황에서도 멈추지 않게 한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **요청을 적재량에 맞게 트레일러·트럭에 배정하고 경로 지도, 자산 가동률, 운행 일정(지연 표시), 요청별 비용을 2x2 대시보드로 그린다 과정을 진행하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `src/utils/mov02_validator.py 및 에러 핸들러` 형태의 결과물을 완성한다.\n", + "\n", + "### 🔍 평가 및 검증 기준 (PASS 조건)\n", + "#### 미션 판정\n", + "\n", + "**평가 구분:** 필수\n", + "\n", + "**핵심 평가영역:** 사용자에게 알기 쉬운 에러 메시지 및 조치 가이드 제공\n", + "\n", + "#### 미션 PASS 조건\n", + "\n", + "- 정량 기준: 예외 상황 발생 시 크래시(Crash) 0건, 에러 로그 기록률 100%\n", + "\n", + "\n", + "- 진행 기준: 예외 주입 테스트 10종 통과 및 감사 로그 파일 생성\n", + "\n", + "\n", + "- 검증 방법: 비정상 데이터(결측, 특수문자, 대용량) 스트레스 테스트\n", + "\n", + "\n", + "#### 채점자 판정 메모\n", + "\n", + "> 단순 코드 실행 여부뿐만 아니라 출력된 수치와 근거 자료가 군 실무 요구사항에 부합하는지 면밀히 확인한다.\n", + "\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "\"\"\"수송 요청에 자산(트럭·트레일러)과 위협 회피 경로를 배정해 관제 대시보드(경로 지도·자산 가동률·운행 일정·비용 실적)로 가시화하고, 입력 검증기·에러 처리기로 예외 주입 10종을 크래시 없이 기록·안내하며 재시도와 롤백을 확인한다.\"\"\"\n", + "import heapq\n", + "import json\n", + "import time\n", + "import numpy as np\n", + "import pandas as pd\n", + "import matplotlib.pyplot as plt\n", + "\n", + "# ---------------------------------------------------------------------------\n", + "# [교육용가상자료] 가상 도로망·수송 자산 생성기 (실제 지명·부대·장비 제원 없음)\n", + "# 격자형 가상 도로망(노드=교차점, 간선=도로 구간)에 도로 등급·교량 하중·가상 위협 구역을 붙인다.\n", + "# ---------------------------------------------------------------------------\n", + "ASSETS = { # 교육용 가정 제원(실제 장비 제원 아님)\n", + " \"truck\": {\"name\": \"중형 트럭\", \"cap_t\": 5.0, \"speed_kmh\": 50, \"cost_km\": 1.0, \"weight_t\": 10.0},\n", + " \"trailer\": {\"name\": \"대형 트레일러\", \"cap_t\": 20.0, \"speed_kmh\": 40, \"cost_km\": 2.4, \"weight_t\": 30.0},\n", + " \"airlift\": {\"name\": \"수송기\", \"cap_t\": 12.0, \"speed_kmh\": 300, \"cost_km\": 9.0, \"weight_t\": 0.0},\n", + "}\n", + "ROAD_FACTOR = {\"paved\": 1.0, \"unpaved\": 0.6} # 도로 등급별 속도 계수\n", + "\n", + "\n", + "def make_network(seed=5, rows=6, cols=8, spacing=5.0):\n", + " \"\"\"격자형 가상 도로망. 노드 좌표(km)와 간선 목록(거리·도로등급·교량 하중)을 돌려준다.\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " nodes = {f\"N{r:02d}{c:02d}\": (c * spacing + rng.uniform(-1, 1), r * spacing + rng.uniform(-1, 1))\n", + " for r in range(rows) for c in range(cols)}\n", + " edges = []\n", + "\n", + " def add(a, b):\n", + " (x1, y1), (x2, y2) = nodes[a], nodes[b]\n", + " bridge = bool(rng.random() < 0.15)\n", + " edges.append({\"id\": f\"E{len(edges):03d}\", \"u\": a, \"v\": b,\n", + " \"km\": round(float(np.hypot(x2 - x1, y2 - y1) * rng.uniform(1.0, 1.25)), 2),\n", + " \"road\": \"paved\" if rng.random() < 0.65 else \"unpaved\", \"bridge\": bridge,\n", + " \"limit_t\": float(rng.choice([20, 40, 60])) if bridge else None, \"open\": True})\n", + " for r in range(rows):\n", + " for c in range(cols):\n", + " if c + 1 < cols:\n", + " add(f\"N{r:02d}{c:02d}\", f\"N{r:02d}{c + 1:02d}\")\n", + " if r + 1 < rows:\n", + " add(f\"N{r:02d}{c:02d}\", f\"N{r + 1:02d}{c:02d}\")\n", + " if r + 1 < rows and c + 1 < cols and rng.random() < 0.25:\n", + " add(f\"N{r:02d}{c:02d}\", f\"N{r + 1:02d}{c + 1:02d}\")\n", + " return nodes, edges\n", + "\n", + "\n", + "def make_threats(seed=9, n=3, xlim=(8, 28), ylim=(3, 22)):\n", + " \"\"\"가상 위협 구역(원형). p = 구간 1회 통과 시 피해 가능성(교육용 가정값).\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " return [{\"id\": f\"TZ{i + 1}\", \"x\": float(rng.uniform(*xlim)), \"y\": float(rng.uniform(*ylim)),\n", + " \"r\": float(rng.uniform(3, 5)), \"p\": float(round(rng.uniform(0.05, 0.2), 3))} for i in range(n)]\n", + "\n", + "\n", + "def edge_risk(e, nodes, threats):\n", + " (x1, y1), (x2, y2) = nodes[e[\"u\"]], nodes[e[\"v\"]]\n", + " s = np.linspace(0, 1, 6)\n", + " risk = 0.0\n", + " for t in threats:\n", + " if np.min(np.hypot(x1 + (x2 - x1) * s - t[\"x\"], y1 + (y2 - y1) * s - t[\"y\"])) < t[\"r\"]:\n", + " risk = max(risk, t[\"p\"])\n", + " return risk\n", + "\n", + "\n", + "def edge_minutes(e, asset=\"truck\"):\n", + " return e[\"km\"] / (ASSETS[asset][\"speed_kmh\"] * ROAD_FACTOR[e[\"road\"]]) * 60\n", + "\n", + "\n", + "def passable(e, gross_t):\n", + " return e[\"open\"] and (not e[\"bridge\"] or e[\"limit_t\"] >= gross_t)\n", + "\n", + "\n", + "def build_adj(nodes, edges, weight_fn):\n", + " \"\"\"weight_fn(e)가 None이면 통행 불가 간선으로 보고 제외한다.\"\"\"\n", + " adj = {n: [] for n in nodes}\n", + " for e in edges:\n", + " w = weight_fn(e)\n", + " if w is not None:\n", + " adj[e[\"u\"]].append((e[\"v\"], w, e))\n", + " adj[e[\"v\"]].append((e[\"u\"], w, e))\n", + " return adj\n", + "\n", + "\n", + "def dijkstra(adj, src, dst):\n", + " \"\"\"다익스트라 최단경로. (비용, 노드 경로, 간선 목록). 도달 불가면 (inf, [], []).\"\"\"\n", + " dist, prev, done, pq = {src: 0.0}, {}, set(), [(0.0, src)]\n", + " while pq:\n", + " d, u = heapq.heappop(pq)\n", + " if u in done:\n", + " continue\n", + " done.add(u)\n", + " if u == dst:\n", + " break\n", + " for v, w, e in adj[u]:\n", + " if d + w < dist.get(v, float(\"inf\")):\n", + " dist[v], prev[v] = d + w, (u, e)\n", + " heapq.heappush(pq, (d + w, v))\n", + " if dst not in done:\n", + " return float(\"inf\"), [], []\n", + " path, es = [dst], []\n", + " while path[-1] != src:\n", + " u, e = prev[path[-1]]\n", + " es.append(e); path.append(u)\n", + " return dist[dst], path[::-1], es[::-1]\n", + "# ---------------------------------------------------------------------------\n", + "\n", + "nodes, edges = make_network()\n", + "threats = make_threats()\n", + "DEPOT = \"N0000\"\n", + "for e in edges:\n", + " if e[\"id\"] in (\"E017\", \"E040\", \"E046\", \"E071\"):\n", + " e[\"open\"] = False\n", + " e[\"risk\"] = edge_risk(e, nodes, threats)\n", + "for e in edges: # 가상 고립 지점: N0506 주변 도로 전부 파괴\n", + " if \"N0506\" in (e[\"u\"], e[\"v\"]):\n", + " e[\"open\"] = False\n", + "\n", + "FLEET = {**{f\"T{i:02d}\": \"truck\" for i in range(1, 9)}, **{f\"L{i:02d}\": \"trailer\" for i in range(1, 3)}}\n", + "LOG = [] # 에러·처리 로그(감사용)\n", + "\n", + "\n", + "class InputError(Exception):\n", + " def __init__(self, code, msg, guide):\n", + " super().__init__(msg); self.code, self.msg, self.guide = code, msg, guide\n", + "\n", + "\n", + "class TransientError(Exception):\n", + " pass\n", + "\n", + "\n", + "def validate(req, seen):\n", + " \"\"\"입력 검증: 필수 필드·형식·범위·중복·존재 여부. 위반 시 InputError(코드, 메시지, 조치 가이드).\"\"\"\n", + " for k in (\"req_id\", \"dest\", \"cargo_t\", \"deadline_min\"):\n", + " if k not in req or req[k] is None:\n", + " raise InputError(\"V01\", f\"필수 항목 '{k}' 누락\", f\"수송 요청서의 '{k}' 칸을 채워 다시 제출\")\n", + " rid = str(req[\"req_id\"])\n", + " if not rid.replace(\"-\", \"\").isalnum() or len(rid) > 20:\n", + " raise InputError(\"V02\", f\"요청번호 형식 오류({rid[:12]}...)\", \"영문·숫자·'-'만 20자 이내로 입력\")\n", + " if rid in seen:\n", + " raise InputError(\"V03\", f\"중복 요청번호 {rid}\", \"기존 요청 수정 화면을 사용하거나 새 번호 발급\")\n", + " try:\n", + " cargo, dl = float(req[\"cargo_t\"]), float(req[\"deadline_min\"])\n", + " except (TypeError, ValueError):\n", + " raise InputError(\"V04\", f\"숫자 형식 오류(cargo_t={req['cargo_t']!r})\", \"단위 없이 숫자만 입력(예: 12)\")\n", + " if not np.isfinite(cargo) or not np.isfinite(dl):\n", + " raise InputError(\"V05\", \"적재량·기한 값이 비어 있음(NaN)\", \"적재량(t)과 도착 기한(분)을 확인\")\n", + " if cargo <= 0 or dl <= 0:\n", + " raise InputError(\"V06\", f\"음수·0 값(cargo_t={cargo}, deadline={dl})\", \"0보다 큰 값으로 입력\")\n", + " if req[\"dest\"] not in nodes:\n", + " raise InputError(\"V07\", f\"존재하지 않는 목적지 {req['dest']}\", \"도로망 노드 목록에서 목적지 선택\")\n", + " cap = sum(ASSETS[k][\"cap_t\"] for k in FLEET.values())\n", + " if cargo > cap:\n", + " raise InputError(\"V08\", f\"적재량 {cargo}t > 가용 수송능력 {cap}t\", \"분할 수송 요청 또는 상급 제대 지원 요청\")\n", + " return cargo, dl\n", + "\n", + "\n", + "def route(dest, kind, gross, fail_first=False, _state={\"n\": 0}):\n", + " if fail_first and _state[\"n\"] == 0: # 경로 계산 서비스 일시 장애 모의(첫 호출만 실패)\n", + " _state[\"n\"] += 1\n", + " raise TransientError(\"경로 계산 시간 초과\")\n", + " adj = build_adj(nodes, edges, lambda e: edge_minutes(e, kind) + 150 * e[\"risk\"] if passable(e, gross) else None)\n", + " cost, path, es = dijkstra(adj, DEPOT, dest)\n", + " if not path:\n", + " raise InputError(\"R01\", f\"{dest}까지 통행 가능한 경로 없음\", \"도로 복구 정보 확인 후 공중수송 검토를 통제관에 요청\")\n", + " return path, es\n", + "\n", + "\n", + "def assign(req, cargo, dl, state, fail_first=False):\n", + " \"\"\"적재량에 맞게 자산을 고르고(트레일러 우선, 나머지 트럭) 경로를 배정한다. 실패 시 호출자가 롤백.\"\"\"\n", + " picks, left = [], cargo\n", + " for vid in sorted(FLEET, key=lambda v: FLEET[v] != \"trailer\"): # 10t 초과분은 트레일러 우선, 나머지는 트럭\n", + " if left <= 0:\n", + " break\n", + " if vid in state[\"busy\"] or (FLEET[vid] == \"trailer\" and left <= 10):\n", + " continue\n", + " picks.append(vid); state[\"busy\"].add(vid); left -= ASSETS[FLEET[vid]][\"cap_t\"] # 상태 변경(롤백 대상)\n", + " if left > 0:\n", + " raise InputError(\"A01\", \"가용 자산 부족\", \"대기 후 재배정 또는 다른 요청과 통합 수송\")\n", + " out = []\n", + " for vid in picks:\n", + " kind = FLEET[vid]\n", + " for attempt in range(3): # 일시 장애 자동 재시도(최대 3회)\n", + " try:\n", + " path, es = route(req[\"dest\"], kind, ASSETS[kind][\"weight_t\"] + min(ASSETS[kind][\"cap_t\"], cargo), fail_first and attempt == 0)\n", + " break\n", + " except TransientError as ex:\n", + " LOG.append({\"req\": req[\"req_id\"], \"level\": \"WARN\", \"code\": \"T01\", \"msg\": f\"{ex} -> 재시도 {attempt + 1}회\"})\n", + " mins = sum(edge_minutes(e, kind) for e in es); km = sum(e[\"km\"] for e in es)\n", + " out.append({\"req\": req[\"req_id\"], \"vid\": vid, \"kind\": kind, \"dest\": req[\"dest\"], \"path\": path, \"km\": km, \"min\": mins,\n", + " \"cost\": km * ASSETS[kind][\"cost_km\"], \"threat\": sum(e[\"risk\"] > 0 for e in es), \"ontime\": mins <= dl})\n", + " return out\n", + "\n", + "\n", + "def process(batch):\n", + " state, seen, result = {\"busy\": set()}, set(), []\n", + " for req in batch:\n", + " snap = set(state[\"busy\"]) # 트랜잭션 시작 시점 스냅샷\n", + " try:\n", + " cargo, dl = validate(req, seen)\n", + " rows = assign(req, cargo, dl, state, fail_first=req.get(\"_flaky\", False))\n", + " seen.add(str(req[\"req_id\"])); result += rows\n", + " LOG.append({\"req\": req[\"req_id\"], \"level\": \"INFO\", \"code\": \"OK\", \"msg\": f\"배정 {len(rows)}대\"})\n", + " except InputError as ex:\n", + " state[\"busy\"] = snap # 롤백: 부분 배정한 자산 반환\n", + " LOG.append({\"req\": str(req.get(\"req_id\"))[:20], \"level\": \"ERROR\", \"code\": ex.code, \"msg\": ex.msg, \"guide\": ex.guide})\n", + " except Exception as ex: # 예상 못한 오류도 크래시 없이 기록\n", + " state[\"busy\"] = snap\n", + " LOG.append({\"req\": str(req.get(\"req_id\"))[:20], \"level\": \"ERROR\", \"code\": \"X99\", \"msg\": repr(ex)[:60], \"guide\": \"운영 담당자에게 로그 전달\"})\n", + " return result, state\n", + "\n", + "\n", + "# ---------------- 1) 정상 요청 배정 + 관제 대시보드 ----------------\n", + "NORMAL = [{\"req_id\": f\"REQ-02{i:02d}\", \"dest\": d, \"cargo_t\": c, \"deadline_min\": dl}\n", + " for i, (d, c, dl) in enumerate([(\"N0307\", 12, 90), (\"N0107\", 8, 80), (\"N0407\", 4, 70), (\"N0205\", 9, 75), (\"N0503\", 5, 60)])]\n", + "NORMAL[3][\"_flaky\"] = True # 경로 계산 일시 장애가 나도 재시도로 처리되는지 확인\n", + "plan, st = process(NORMAL)\n", + "df = pd.DataFrame(plan)\n", + "print(\"[관제 현황판] 요청별 자산 배정·경로 [교육용가상자료]\")\n", + "summ = df.groupby(\"req\").agg(자산=(\"vid\", lambda s: \",\".join(s)), 목적지=(\"dest\", \"first\"), 거리km=(\"km\", \"max\"),\n", + " 소요분=(\"min\", \"max\"), 위협통과=(\"threat\", \"max\"), 정시=(\"ontime\", \"all\"), 비용=(\"cost\", \"sum\")).round(1)\n", + "print(summ.to_string())\n", + "util = len(st[\"busy\"]) / len(FLEET)\n", + "print(f\"자산 가동률 {util:.0%} ({len(st['busy'])}/{len(FLEET)}대), 총 운행비용 {df['cost'].sum():.1f}(단가x km), \"\n", + " f\"정시 도착 예상 {summ['정시'].mean():.0%}, 위협 구역 통과 차량 {int((df['threat'] > 0).sum())}대\")\n", + "\n", + "print(\"일시 장애 자동 재시도:\", [f\"{l['req']} {l['msg']}\" for l in LOG if l[\"code\"] == \"T01\"], \"-> 재시도 후 정상 배정\")\n", + "print(\"지연 예상 요청은 붉은색으로 표시하고 통제관에게 기한 조정 또는 자산 변경을 건의한다.\")\n", + "\n", + "# ---------------- 2) 예외 주입 테스트 10종 ----------------\n", + "LOG.clear()\n", + "BAD = [{\"dest\": \"N0307\", \"cargo_t\": 3, \"deadline_min\": 60}, # 1 필수 항목 누락\n", + " {\"req_id\": \"REQ- \\x00\\x07 지체상금은 전액 면제하며\"), {}),\n", + " (\"조항 문장 1MB\", mut(clauses=\"가\" * 1_000_000), {}), (\"극단값(1e12)\", mut(amount_m=1e12), {}),\n", + " (\"대용량 20,000행\", big, {}), (\"일시 오류 2회 후 성공\", base, {\"fail_times\": 2}), (\"끝내 실패(롤백)\", base, {\"always_fail\": True}),\n", + "]\n", + "res, crash, times = [], 0, {}\n", + "for name, src, kw in cases:\n", + " t0 = time.perf_counter()\n", + " try:\n", + " res.append(safe_dashboard(name, src, **kw))\n", + " except BaseException: # safe_dashboard 밖으로 예외가 새 나간 건수\n", + " crash += 1\n", + " times[name] = time.perf_counter() - t0\n", + "print(f\"[에러 핸들러 시험] 입력 {len(cases)}종 실행\")\n", + "for r in res:\n", + " print(f\" {'OK ' if r['ok'] else '안내'} {r['case']:22s} -> {r['msg'][:46]}\" + (f\" | 조치: {r['action'][:34]}\" if r[\"action\"] != \"-\" else \"\"))\n", + "n_err = sum(not r[\"ok\"] for r in res)\n", + "logged_err = sum(l[\"level\"] == \"ERROR\" for l in LOG)\n", + "print(f\"\\n예외 주입 {len(cases)}종 실행 완료(목표 10종 이상: {'달성' if len(cases) >= 10 else '미달'}), 크래시 {crash}건(목표 0건: {'달성' if crash == 0 else '미달'})\")\n", + "print(f\"오류 {n_err}건 중 ERROR 로그 {logged_err}건 -> 기록률 {min(logged_err / max(n_err, 1), 1):.0%}(목표 100%: {'달성' if logged_err == n_err else '미달'}), 안내 문구에 조치 가이드 포함 {sum(r['action'] != '-' for r in res if not r['ok'])}/{n_err}건\")\n", + "print(f\"행 격리 경고 {sum(l['code'] == 'W01' for l in LOG)}건, 재시도 경고 {sum(l['code'] == 'W02' for l in LOG)}건 기록, 대용량 20,000행 처리 {times['대용량 20,000행']:.1f}초\")\n", + "print(\"직전 정상 결과 유지(롤백): 마지막 반영된 화면의 계약 수 =\", COMMITTED[\"last\"][\"n\"], \"건\")\n", + "print(\"\\n[에러 로그 앞 5건]\")\n", + "print(pd.DataFrame(LOG).head(5).to_string(index=False))\n", + "\n", + "# 정상 입력으로 대시보드(조항 하이라이트 + 위험 지도) 보여 주기\n", + "out, bad = build_dashboard(base)\n", + "print(\"\\n[위험 지도] 계약 방식 x 유형 평균 룰 점수\")\n", + "print(out[\"heat\"].fillna(0).to_string())\n", + "print(\"\\n[조항별 위험 하이라이트] 위험 문구가 많은 상위 3건 (【 】= 위험 표현, [!!]=독소, [! ]=납기위험, [ok]=정상)\")\n", + "for _, r in out[\"top\"].iterrows():\n", + " print(f\" {r['contract_id']} {r['ctype']} {r['method']} {r['amount_m']:.0f}백만원 룰 점수 {r['score']}\")\n", + " for k, c in out[\"hl\"][r[\"contract_id\"]]:\n", + " print(f\" {MARK[k]} {c[:52]}\")\n", + "print(\"※ 하이라이트는 검토 우선순위 표시이며 불공정 여부의 최종 판단은 계약관·법무 담당자가 한다.\")\n", + "\n", + "heat = out[\"heat\"].fillna(0)\n", + "fig, ax = plt.subplots(1, 3, figsize=(13, 4))\n", + "im = ax[0].imshow(heat.to_numpy(), cmap=\"Reds\"); ax[0].set_xticks(range(heat.shape[1]), [\"goods\", \"service\", \"works\"][:heat.shape[1]]); ax[0].set_yticks(range(heat.shape[0]), [f\"m{i + 1}\" for i in range(heat.shape[0])])\n", + "ax[0].set_title(\"Risk map (method x type)\"); plt.colorbar(im, ax=ax[0])\n", + "ax[1].bar([\"ok\", \"guided\", \"crash\"], [sum(r[\"ok\"] for r in res), n_err, crash], color=[\"tab:green\", \"gold\", \"tab:red\"]); ax[1].set_title(\"Error injection results (20 cases)\")\n", + "cnt = pd.Series([k for _, cl in out[\"hl\"].items() for k, _ in cl]).value_counts()\n", + "ax[2].bar([{\"독소\": \"toxic\", \"납기위험\": \"delay\", \"정상\": \"ok\"}[k] for k in cnt.index], cnt.values, color=\"tab:blue\"); ax[2].set_title(\"Clause labels in top-3 contracts\")\n", + "plt.tight_layout(); plt.show()\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 06 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + "[에러 핸들러 시험] 입력 20종 실행\n", + " OK 정상 입력 -> 표시 완료\n", + " OK 정상 CSV 문자열 -> 표시 완료\n", + " 안내 입력 없음(None) -> 입력이 비어 있습니다. | 조치: 파일을 다시 선택해 주세요.\n", + " 안내 빈 문자열 -> 빈 파일입니다. | 조치: 내용이 있는 파일인지 확인해 주세요.\n", + " 안내 깨진 JSON -> 파일 형식을 읽을 수 없습니다. | 조치: CSV(쉼표 구분) 또는 JSON(목록) 형식으로 저장했는지 \n", + " 안내 지원하지 않는 형식(정수) -> 지원하지 않는 입력 형식입니다(int). | 조치: CSV 또는 JSON 파일을 사용해 주세요.\n", + " 안내 머리글만 있는 CSV -> 읽은 행이 0건입니다. | 조치: 머리글만 있는 파일이 아닌지 확인해 주세요.\n", + " 안내 필수 열 누락 -> 필수 열이 없습니다: penalty_permil, clauses | 조치: 열 이름이 양식(데이터 딕셔너리)과 같은지 확인해 주세요.\n", + " OK 숫자 열에 문자 -> 표시 완료(격리 3행)\n", + " OK 음수 금액 -> 표시 완료(격리 3행)\n", + " OK 계약 방식 결측 -> 표시 완료(격리 3행)\n", + " OK 허용되지 않은 유형 -> 표시 완료(격리 3행)\n", + " OK 계약번호 중복·형식 오류 -> 표시 완료(격리 3행)\n", + " OK 조항 칸이 비어 있음 -> 표시 완료(격리 3행)\n", + " OK 특수문자·제어문자·태그 -> 표시 완료\n", + " OK 조항 문장 1MB -> 표시 완료\n", + " OK 극단값(1e12) -> 표시 완료(격리 3행)\n", + " OK 대용량 20,000행 -> 표시 완료(격리 10000행)\n", + " OK 일시 오류 2회 후 성공 -> 표시 완료, 재시도 2회 후 성공\n", + " 안내 끝내 실패(롤백) -> 일시적인 문제로 저장하지 못했습니다. 직전 정상 화면을 유지합니다. | 조치: 잠시 후 다시 시도해 주세요.\n", + "\n", + "예외 주입 20종 실행 완료(목표 10종 이상: 달성), 크래시 0건(목표 0건: 달성)\n", + "오류 7건 중 ERROR 로그 7건 -> 기록률 100%(목표 100%: 달성), 안내 문구에 조치 가이드 포함 7/7건\n", + "행 격리 경고 10021건, 재시도 경고 5건 기록, 대용량 20,000행 처리 2.6초\n", + "직전 정상 결과 유지(롤백): 마지막 반영된 화면의 계약 수 = 100 건\n", + "\n", + "[에러 로그 앞 5건]\n", + " seq level code detail\n", + " 1 ERROR E01 입력 없음(None): 입력이 비어 있습니다.\n", + " 2 ERROR E02 빈 문자열: 빈 파일입니다.\n", + " 3 ERROR E03 깨진 JSON: 파일 형식을 읽을 수 없습니다.\n", + " 4 ERROR E04 지원하지 않는 형식(정수): 지원하지 않는 입력 형식입니다(int).\n", + " 5 ERROR E02 머리글만 있는 CSV: 읽은 행이 0건입니다.\n", + "\n", + "[위험 지도] 계약 방식 x 유형 평균 룰 점수\n", + "ctype 공사 물품 용역\n", + "method \n", + "수의계약 1.0 2.5 2.3\n", + "일반경쟁 2.7 1.8 1.1\n", + "... (이하 생략)\n", + "```\n", + "
\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 07. 계약 승인 전 법무/계약관 수동 검토 및 조항 수정 권고 HITL 절차 설계\n", + "**소요시간**: 약 3.0시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "계약 승인 전 계약관 수동 검토 및 조항 수정 권고 HITL 절차를 설계한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **계약 승인 전 계약관·법무 수동 검토와 조항 수정 권고를 위한 HITL 절차를 설계하고 이력 무결성과 권한 통제를 검증한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **시스템 제안, 계약담당 상신, 계약관 검토, 법무 검토, 승인권자 결재의 순서를 상태 기계로 구현하고 역할별 권한을 최소로 둔다 과정을 진행하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `src/workflow/prc01_approval_system.py` 형태의 결과물을 완성한다.\n", + "\n", + "### 🔍 평가 및 검증 기준 (PASS 조건)\n", + "#### 미션 판정\n", + "\n", + "**평가 구분:** 필수\n", + "\n", + "**핵심 평가영역:** 군 보안 규정 및 행정 절차 부합성\n", + "\n", + "#### 미션 PASS 조건\n", + "\n", + "- 정량 기준: 이력 기록 무결성 100%, 승인 권한 우회 시도 차단율 100%\n", + "\n", + "\n", + "- 진행 기준: 결재선 승인/반려 시뮬레이션 및 이력 조회 화면 구현\n", + "\n", + "\n", + "- 검증 방법: 권한별 접근 제어(RBAC) 테스트\n", + "\n", + "\n", + "#### 채점자 판정 메모\n", + "\n", + "> 단순 코드 실행 여부뿐만 아니라 출력된 수치와 근거 자료가 군 실무 요구사항에 부합하는지 면밀히 확인한다.\n", + "\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "\"\"\"고위험 계약의 조항 수정 권고를 계약담당 상신 -> 계약관 검토 -> 법무 검토 -> 승인권자 결재의 HITL 워크플로우로 처리하되, 반려 사유 필수화·재기안 연계·시스템 자동 확정 금지·권한 우회 차단(RBAC)과 해시 체인 감사 로그의 이력 무결성·위변조 탐지를 검증한다.\"\"\"\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.feature_extraction.text import TfidfVectorizer\n", + "from sklearn.linear_model import LogisticRegression\n", + "from sklearn.pipeline import make_pipeline\n", + "\n", + "# ---------------------------------------------------------------------------\n", + "# [교육용가상자료] 가상 조달계획안·계약서 생성기 (실제 계약·업체·법령 조문 없음)\n", + "# 계약 속성 16개 + 조항 문장 6개를 만들고, 속성 위반·독소 조항·납기 위험 문구가 섞인 '규정 위반 여부'를 함께 만든다.\n", + "# ---------------------------------------------------------------------------\n", + "TYPES = [\"물품\", \"용역\", \"공사\"]\n", + "METHODS = [\"일반경쟁\", \"제한경쟁\", \"지명경쟁\", \"수의계약\"]\n", + "CLASSES = [\"정상\", \"독소\", \"납기위험\"]\n", + "OBJ = [\"납품 물품\", \"용역 결과물\", \"시공 목적물\", \"정비 성과물\"]\n", + "\n", + "N_TPL = [\n", + " \"계약상대자가 납기를 지체한 경우 지체일수 1일마다 계약금액의 {p}/1000에 해당하는 지체상금을 부과한다.\",\n", + " \"지체상금은 불가항력 사유가 확인된 경우에 한하여 발주기관의 승인을 받아 면제할 수 있다.\",\n", + " \"납기 연장은 발주기관이 불가항력 사유를 확인하고 서면으로 승인한 경우에만 인정한다.\",\n", + " \"발주기관은 납품된 {o}을 {d}일 이내에 검사하고 합격한 후에 대금을 지급한다.\",\n", + " \"계약상대자는 하도급을 하려는 경우 사전에 발주기관의 서면 승인을 받아야 한다.\",\n", + " \"어느 한쪽이 계약을 해지하려면 사유를 명시해 {d}일 전에 서면으로 통지해야 한다.\",\n", + " \"{o}에 하자가 발생하면 계약상대자는 보증 기간 안에 무상으로 보수하거나 교체한다.\",\n", + " \"계약 이행 중 알게 된 비공개 사항은 제3자에게 누설하지 않으며 위반 시 책임을 진다.\",\n", + " \"{o}에 대한 지식재산권의 귀속과 사용 범위는 별도 합의서에 따라 정한다.\",\n", + " \"대금은 검사 합격 후 청구서를 접수한 날부터 {d}일 이내에 지급한다.\",\n", + " \"계약금액의 조정은 물가변동 기준에 따라 양 당사자가 서면으로 합의한 경우에만 한다.\",\n", + " \"계약상대자는 이행보증서를 계약 체결 시 제출하며 보증금은 계약 이행 완료 후 반환한다.\",\n", + " \"납품 일정은 계약서 별표의 일자를 따르며 변경 시 서면 합의가 필요하다.\",\n", + " \"지체상금은 면제 사유가 없는 한 부과하며 이의는 정해진 절차로 제기할 수 있다.\",\n", + " \"검사에 합격하지 못한 {o}은 계약상대자가 교체하며 그 비용은 계약상대자가 부담한다.\",\n", + " \"계약상대자의 귀책 사유로 이행이 불가능하면 발주기관은 서면으로 통지하고 계약을 해지할 수 있다.\",\n", + " \"지연이 예상되면 계약상대자는 사유와 만회 계획을 발주기관에 서면으로 보고한다.\",\n", + " \"보증 기간 중 발생한 결함은 계약상대자가 책임지고 보수한다.\",\n", + " \"분쟁은 먼저 협의로 해결하고 협의가 되지 않으면 관할 법원의 판단에 따른다.\",\n", + "]\n", + "T_TPL = [\n", + " \"계약상대자의 지체상금은 전액 면제하며 발주기관은 이에 대해 이의를 제기하지 못한다.\",\n", + " \"납품 즉시 검사에 합격한 것으로 보며 발주기관은 이후 검사를 요구하지 않는다.\",\n", + " \"계약상대자는 언제든지 일방적으로 계약을 해지할 수 있으며 그에 따른 손해는 발주기관이 부담한다.\",\n", + " \"{o}의 추가 비용은 사유를 불문하고 발주기관이 전액 부담하며 별도 협의 없이 청구할 수 있다.\",\n", + " \"{o}에 관한 지식재산권은 모두 계약상대자에게 귀속되고 발주기관은 사용할 수 없다.\",\n", + " \"하도급은 신고 없이 제한 없이 허용하며 발주기관은 하도급 내용을 확인하지 않는다.\",\n", + " \"대금은 검사 전에 전액 선지급하며 발주기관은 반환 청구권을 포기한다.\",\n", + " \"계약 이행 중 알게 된 사항의 누설로 인한 책임을 계약상대자는 지지 않는다.\",\n", + " \"하자 보수 책임은 인도 후 즉시 소멸하며 발주기관은 보수를 요구할 수 없다.\",\n", + " \"발주기관은 계약상대자가 청구하는 금액을 거절할 수 없으며 거절하면 위약금을 지급한다.\",\n", + " \"분쟁이 생기면 계약상대자가 정하는 중재 기관에서만 해결하고 발주기관은 따른다.\",\n", + " \"발주기관의 검사는 생략하고 계약상대자의 시험 결과서로 대신한다.\",\n", + " \"견적 금액은 사후 정산 없이 그대로 확정하고 증빙 자료는 받지 않는다.\",\n", + "]\n", + "D_TPL = [\n", + " \"납기는 자재 수급 사정에 따라 계약상대자가 연장할 수 있다.\",\n", + " \"납품 일정은 가능한 한 지키도록 노력하며 지연되어도 사유를 보고하지 않는다.\",\n", + " \"공급 지연이나 선행 업체 사정도 불가항력으로 보아 납기를 자동 연장한다.\",\n", + " \"착수일은 선행 업체의 납품이 끝난 뒤 별도로 정하며 완료 기한은 정하지 않는다.\",\n", + " \"납기일 이후 1회에 한해 기한 없이 자동 연장되는 것으로 한다.\",\n", + " \"납기는 계약상대자의 생산 일정이 확정되는 대로 통보한다.\",\n", + " \"납품 시점은 계약상대자 사정이 허락하는 때로 하고 날짜는 따로 정하지 않는다.\",\n", + " \"자재가 늦게 들어오면 완료 일정은 그만큼 자동으로 늦춰진다.\",\n", + " \"기한 내에 인도하지 못해도 계약상대자에게 책임을 묻지 않는다.\",\n", + " \"일정이 늦어져도 계약상대자는 새 계획서를 낼 필요가 없다.\",\n", + "]\n", + "# 학습에 쓰지 않는 '처음 보는 표현'(모델이 문장 틀을 외웠는지 확인하는 용도)\n", + "HOLD = {\n", + " \"정상\": [\"검사에 불합격한 경우 계약상대자는 지정 기한 안에 재납품하며 재검사 비용은 상대자가 부담한다.\",\n", + " \"계약상대자가 법령을 위반하여 계약을 이행하지 못하면 발주기관은 서면 통지 후 계약을 해지할 수 있다.\",\n", + " \"납기가 지연될 우려가 있으면 상대자는 즉시 사유와 만회 계획을 서면으로 보고해야 한다.\",\n", + " \"하자담보 책임 기간은 인수일부터 기산하며 기간 중 발생한 결함은 상대자가 책임진다.\"],\n", + " \"독소\": [\"발주기관은 계약상대자가 요구하는 대금을 이유 없이 거절하지 못하며 거절 시 위약금을 낸다.\",\n", + " \"계약 내용에 관한 분쟁은 계약상대자가 지정하는 기관에서만 다루며 발주기관은 이에 따른다.\",\n", + " \"발주기관의 검수 권한은 포기하고 계약상대자의 자체 시험 성적서로 갈음한다.\",\n", + " \"계약상대자가 제출한 견적은 사후 정산 없이 확정 단가로 하며 증빙은 요구하지 않는다.\"],\n", + " \"납기위험\": [\"납품 시기는 상대자의 형편이 허락하는 시점으로 하고 별도 날짜는 정하지 않는다.\",\n", + " \"원자재 확보가 늦어지면 완료 일자는 자동으로 뒤로 미뤄지는 것으로 본다.\",\n", + " \"인도 기한은 참고용이며 지키지 못해도 책임을 묻지 않는다.\",\n", + " \"일정 지연이 있어도 상대자는 변경 계획을 제출할 의무가 없다.\"],\n", + "}\n", + "SUFFIX = [\"\", \"\", \"\", \" 세부 사항은 시행 세칙에 따른다.\", \" 이 조항은 계약 전체에 적용한다.\", \" 필요한 경우 양 당사자가 협의한다.\", \" 관련 서식은 별지를 따른다.\"]\n", + "SWAP = [(\"발주기관\", \"계약담당관\"), (\"계약상대자\", \"업체\")]\n", + "\n", + "\n", + "def make_clause(rng, cls, hold=False, idx=None):\n", + " \"\"\"조항 한 문장 생성. 표현 일부는 동의어로 바꾸고 접미 문구를 무작위로 붙인다(idx를 주면 그 번호의 문장 틀을 사용).\"\"\"\n", + " if idx is not None:\n", + " tpl = {\"정상\": N_TPL, \"독소\": T_TPL, \"납기위험\": D_TPL}[cls]\n", + " s = tpl[idx].format(p=rng.choice([\"1.0\", \"1.5\", \"2.0\"]), d=int(rng.choice([7, 10, 14, 30])), o=rng.choice(OBJ))\n", + " elif hold:\n", + " s = HOLD[cls][rng.integers(len(HOLD[cls]))]\n", + " else:\n", + " tpl = {\"정상\": N_TPL, \"독소\": T_TPL, \"납기위험\": D_TPL}[cls]\n", + " s = tpl[rng.integers(len(tpl))].format(p=rng.choice([\"1.0\", \"1.5\", \"2.0\"]), d=int(rng.choice([7, 10, 14, 30])), o=rng.choice(OBJ))\n", + " if rng.random() < 0.3:\n", + " a, b = SWAP[rng.integers(2)]\n", + " s = s.replace(a, b)\n", + " return s + SUFFIX[rng.integers(len(SUFFIX))]\n", + "\n", + "\n", + "def make_contracts(n=200, seed=7):\n", + " \"\"\"가상 계약 n건(속성 16개 + 조항 6개 + 규정 위반 정답)을 만든다.\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " sl = rng.beta(1.2, 3.2, n) # 계약서 작성이 허술한 정도(잠재 변수)\n", + " ctype = rng.choice(TYPES, n, p=[.5, .3, .2])\n", + " amount = np.clip(np.exp(rng.normal(np.log(220), 1.1, n)), 5, 5000).round(0)\n", + " method = np.where(rng.random(n) < .06 + .3 * sl, \"수의계약\", rng.choice(METHODS[:3], n, p=[.5, .3, .2]))\n", + " period = np.array([rng.integers(60, 300) if t == \"물품\" else rng.integers(90, 400) if t == \"용역\" else rng.integers(180, 720) for t in ctype])\n", + " delivery = (period * rng.uniform(.5, 1.0, n)).astype(int)\n", + " bad_logic = rng.random(n) < .015 * (1 + 3 * sl)\n", + " delivery = np.where(bad_logic, period + rng.integers(10, 60, n), delivery)\n", + " pen = np.where(rng.random(n) < .22 * sl, rng.choice([.3, .5, .8], n), rng.choice([1.0, 1.5, 2.0, 2.5], n))\n", + " adv = np.where(rng.random(n) < .22 * sl, rng.choice([50, 60, 70], n), rng.choice([0, 10, 20, 30, 40], n))\n", + " bond = np.where(rng.random(n) < .25 * sl + .5 * (adv > 40), rng.choice([0, 5], n), rng.choice([10, 10, 15, 20], n))\n", + " war = np.array([rng.choice([3, 6]) if (rng.random() < .25 * s and t == \"물품\") else rng.choice([12, 24, 36]) if t == \"물품\" else rng.choice([0, 6, 12]) if t == \"용역\" else rng.choice([24, 36, 60]) for t, s in zip(ctype, sl)])\n", + " insp = np.where(rng.random(n) < .3 * sl, rng.choice([21, 30], n), rng.choice([5, 7, 10, 14], n))\n", + " sub = np.where(rng.random(n) < .15 * sl, rng.choice([60, 70, 80], n), rng.choice([0, 10, 20, 30, 40, 50], n))\n", + " padj = rng.random(n) < .75 - .4 * sl\n", + " term = rng.random(n) > .015 * (1 + 6 * sl)\n", + " sec = rng.random(n) > .015 * (1 + 6 * sl)\n", + " ip = rng.random(n) > .03 * (1 + 4 * sl)\n", + " clauses, cls_true = [], []\n", + " for i in range(n):\n", + " pt, pd_ = .01 + .08 * sl[i], .02 + .08 * sl[i]\n", + " cl = [rng.choice(CLASSES, p=[1 - pt - pd_, pt, pd_]) for _ in range(6)]\n", + " cls_true.append(cl); clauses.append([make_clause(rng, c) for c in cl])\n", + " df = pd.DataFrame({\"contract_id\": [f\"C-{i + 1:04d}\" for i in range(n)], \"ctype\": ctype, \"method\": method, \"amount_m\": amount,\n", + " \"period_days\": period, \"delivery_days\": delivery, \"penalty_permil\": pen, \"advance_pct\": adv, \"perf_bond_pct\": bond,\n", + " \"warranty_months\": war, \"inspection_days\": insp, \"subcontract_pct\": sub, \"price_adjust\": padj,\n", + " \"termination\": term, \"security\": sec, \"ip_clause\": ip})\n", + " hard = ((pen < 1.0) | ((adv > 40) & (bond < 10)) | ((amount >= 300) & (method == \"수의계약\")) | ((ctype == \"물품\") & (war < 12))\n", + " | ~term | ~sec | (sub > 50) | (delivery > period))\n", + " toxic = np.array([c.count(\"독소\") for c in cls_true])\n", + " delay = np.array([c.count(\"납기위험\") for c in cls_true])\n", + " split = (method == \"수의계약\") & (amount >= 250) & (amount < 300) & (rng.random(n) < .6) # 계약 쪼개기 의심(임계값 바로 아래)\n", + " df[\"clauses\"], df[\"clause_cls\"] = clauses, cls_true\n", + " df[\"n_toxic\"], df[\"n_delay\"] = toxic, delay\n", + " viol = hard | (toxic > 0) | split | (delay >= 2)\n", + " waived = hard & (toxic == 0) & (rng.random(n) < .12) # 계약관이 예외 사유를 인정해 위반으로 보지 않은 건(자료의 현실적 잡음)\n", + " hidden = ~viol & (rng.random(n) < .03 + .08 * sl) # 속성·문구에 드러나지 않는 위반(별첨 합의 등)\n", + " df[\"violation\"] = (viol & ~waived) | hidden\n", + " df[\"vtype\"] = np.where(hidden, \"기타\", np.where(hard, \"속성위반\", np.where(toxic > 0, \"독소조항\", np.where(split | (delay >= 2), \"기타\", \"-\"))))\n", + " return df\n", + "\n", + "# ---- 계약 규정 준수 판별 룰 설정(YAML 형식 문자열: 규정이 바뀌면 코드가 아니라 이 설정만 고친다) ----\n", + "RULES_YAML = \"\"\"\n", + "- id: R01\n", + " name: 지체상금률 1.0/1000 미만\n", + " field: penalty_permil\n", + " op: \"<\"\n", + " value: 1.0\n", + " weight: 3\n", + "- id: R02\n", + " name: 선금 비율 40% 초과\n", + " field: advance_pct\n", + " op: \">\"\n", + " value: 40\n", + " weight: 2\n", + "- id: R03\n", + " name: 선금 40% 초과인데 이행보증 10% 미만\n", + " field: adv_no_bond\n", + " op: \"==\"\n", + " value: true\n", + " weight: 3\n", + "- id: R04\n", + " name: 300백만원 이상 수의계약\n", + " field: sole_amount\n", + " op: \">=\"\n", + " value: 300\n", + " weight: 3\n", + "- id: R05\n", + " name: 500백만원 이상 이행보증 10% 미만\n", + " field: bond_gap\n", + " op: \"==\"\n", + " value: true\n", + " weight: 2\n", + "- id: R06\n", + " name: 물품 하자보증 12개월 미만\n", + " field: warranty_gap\n", + " op: \"==\"\n", + " value: true\n", + " weight: 2\n", + "- id: R07\n", + " name: 해지 조항 없음\n", + " field: termination\n", + " op: \"==\"\n", + " value: false\n", + " weight: 2\n", + "- id: R08\n", + " name: 보안 조항 없음\n", + " field: security\n", + " op: \"==\"\n", + " value: false\n", + " weight: 3\n", + "- id: R09\n", + " name: 지식재산 조항 없음(물품·용역)\n", + " field: ip_missing\n", + " op: \"==\"\n", + " value: true\n", + " weight: 1\n", + "- id: R10\n", + " name: 하도급 비율 50% 초과\n", + " field: subcontract_pct\n", + " op: \">\"\n", + " value: 50\n", + " weight: 3\n", + "- id: R11\n", + " name: 검사 기간 14일 초과\n", + " field: inspection_days\n", + " op: \">\"\n", + " value: 14\n", + " weight: 1\n", + "- id: R12\n", + " name: 납품 기한이 계약 기간 초과\n", + " field: logic_err\n", + " op: \"==\"\n", + " value: true\n", + " weight: 3\n", + "- id: R13\n", + " name: 180일 이상 계약에 물가변동 조항 없음\n", + " field: no_padj_long\n", + " op: \"==\"\n", + " value: true\n", + " weight: 1\n", + "- id: R14\n", + " name: 1000백만원 이상 비경쟁 계약\n", + " field: noncomp_big\n", + " op: \"==\"\n", + " value: true\n", + " weight: 2\n", + "- id: R15\n", + " name: 계약 기간 365일 초과\n", + " field: period_days\n", + " op: \">\"\n", + " value: 365\n", + " weight: 1\n", + "- id: R16\n", + " name: 불공정·납기 위험 키워드 포함\n", + " field: kw_hits\n", + " op: \">=\"\n", + " value: 1\n", + " weight: 3\n", + "\"\"\"\n", + "GRADE_CUT = {\"고위험\": 6, \"주의\": 3} # 가중치 합 기준 1차 판별 등급(필요 시 설정만 바꾼다)\n", + "# [교육용가상자료] 불공정 조항·납기 지체 위험 문구 사전(실제 규정 조문이 아님)\n", + "KEYWORDS = {\"독소\": [\"면제하며\", \"일방적으로\", \"포기\", \"지지 않는다\", \"즉시 소멸\", \"제한 없이\", \"신고 없이\", \"전액 부담\", \"별도 협의 없이\", \"검사 전에\", \"검사에 합격한 것으로\", \"사용할 수 없다\", \"증빙은 요구하지 않는다\", \"지정하는 기관\"],\n", + " \"납기위험\": [\"자동 연장\", \"기한 없이\", \"정하지 않는다\", \"노력하며\", \"연장할 수 있다\", \"통보한다\", \"책임을 묻지 않는다\", \"의무가 없다\"]}\n", + "\n", + "\n", + "def parse_rules(text):\n", + " \"\"\"간이 YAML 파서(목록 + key: value). 숫자·true/false·따옴표 문자열을 해석한다.\"\"\"\n", + " rules, cur = [], None\n", + " for line in text.strip().splitlines():\n", + " if not line.strip():\n", + " continue\n", + " s = line.strip()\n", + " if s.startswith(\"- \"):\n", + " cur = {}; rules.append(cur); s = s[2:]\n", + " k, v = [x.strip() for x in s.split(\":\", 1)]\n", + " v = v.strip('\"')\n", + " if v in (\"true\", \"false\"):\n", + " v = v == \"true\"\n", + " else:\n", + " try:\n", + " v = float(v) if \".\" in v else int(v)\n", + " except ValueError:\n", + " pass\n", + " cur[k] = v\n", + " return rules\n", + "\n", + "\n", + "OPS = {\"==\": lambda a, b: a == b, \"!=\": lambda a, b: a != b, \">\": lambda a, b: a > b, \">=\": lambda a, b: a >= b,\n", + " \"<\": lambda a, b: a < b, \"<=\": lambda a, b: a <= b}\n", + "\n", + "\n", + "def kw_count(clauses):\n", + " \"\"\"조항 문장 목록에서 독소·납기 위험 키워드가 들어 있는 조항 수.\"\"\"\n", + " return int(sum(any(k in c for k in KEYWORDS[\"독소\"] + KEYWORDS[\"납기위험\"]) for c in clauses))\n", + "\n", + "\n", + "def derive(df):\n", + " \"\"\"룰에 쓰는 파생 필드(선금+보증 부족, 수의계약 고액, 논리 오류, 키워드 적중 등)를 만든다.\"\"\"\n", + " d = df.copy()\n", + " d[\"adv_no_bond\"] = (d[\"advance_pct\"] > 40) & (d[\"perf_bond_pct\"] < 10)\n", + " d[\"sole_amount\"] = np.where(d[\"method\"] == \"수의계약\", d[\"amount_m\"], 0.0) # 수의계약일 때만 금액(한도는 YAML에서 조정)\n", + " d[\"bond_gap\"] = (d[\"amount_m\"] >= 500) & (d[\"perf_bond_pct\"] < 10)\n", + " d[\"warranty_gap\"] = (d[\"ctype\"] == \"물품\") & (d[\"warranty_months\"] < 12)\n", + " d[\"ip_missing\"] = (~d[\"ip_clause\"]) & (d[\"ctype\"] != \"공사\")\n", + " d[\"logic_err\"] = d[\"delivery_days\"] > d[\"period_days\"]\n", + " d[\"no_padj_long\"] = (d[\"period_days\"] >= 180) & (~d[\"price_adjust\"])\n", + " d[\"noncomp_big\"] = d[\"method\"].isin([\"지명경쟁\", \"수의계약\"]) & (d[\"amount_m\"] >= 1000)\n", + " d[\"kw_hits\"] = [kw_count(c) for c in d[\"clauses\"]] if \"clauses\" in d else 0\n", + " return d\n", + "\n", + "\n", + "def apply_rules(rec, rules):\n", + " \"\"\"레코드 1건(dict)에 룰을 적용해 (점수, 등급, 적중 룰 id 목록)을 돌려준다. 값이 없으면 해당 룰은 건너뛴다.\"\"\"\n", + " hits = []\n", + " for r in rules:\n", + " v = rec.get(r[\"field\"])\n", + " if v is None or (isinstance(v, float) and np.isnan(v)):\n", + " continue\n", + " if OPS[r[\"op\"]](v, r[\"value\"]):\n", + " hits.append(r)\n", + " score = sum(r[\"weight\"] for r in hits)\n", + " grade = \"고위험\" if score >= GRADE_CUT[\"고위험\"] else (\"주의\" if score >= GRADE_CUT[\"주의\"] else \"관심\")\n", + " return score, grade, [r[\"id\"] for r in hits]\n", + "\n", + "SIMPLE_KW = {\"독소\": [\"면제\", \"일방\", \"포기\", \"전액\", \"누설\", \"하도급\", \"선지급\"], \"납기위험\": [\"연장\", \"자동\", \"노력\", \"통보\", \"기한\"]}\n", + "\n", + "# ---- 조항 문장 분류(정상 / 독소 / 납기위험): 글자 n-gram TF-IDF + 로지스틱 회귀 ----\n", + "def clause_set(seed, n, hold=False, mix=(.55, .25, .20)):\n", + " \"\"\"조항 문장 n개와 정답 분류를 만든다. hold=True면 학습에 쓰지 않은 '처음 보는 표현'으로 만든다.\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " y = rng.choice(CLASSES, n, p=list(mix))\n", + " return [make_clause(rng, c, hold) for c in y], list(y)\n", + "\n", + "\n", + "def fit_clause_model(seed=100, n=1200):\n", + " \"\"\"학습용 조항 문장으로 분류 모델을 학습한다.\"\"\"\n", + " X, y = clause_set(seed, n)\n", + " return make_pipeline(TfidfVectorizer(analyzer=\"char_wb\", ngram_range=(2, 4), sublinear_tf=True), LogisticRegression(C=20, max_iter=2000)).fit(X, y)\n", + "\n", + "\n", + "def keyword_pred(s):\n", + " \"\"\"비교용 기준선: 단순 키워드(부정 표현·문맥을 보지 않음) 적중으로만 분류.\"\"\"\n", + " if any(k in s for k in SIMPLE_KW[\"독소\"]):\n", + " return \"독소\"\n", + " return \"납기위험\" if any(k in s for k in SIMPLE_KW[\"납기위험\"]) else \"정상\"\n", + "\n", + "# ---- 역할과 권한(최소 권한 원칙): 시스템은 '제안'만 할 수 있고 확정 권한이 없다 ----\n", + "PERM = {\"시스템\": {\"propose\"}, \"계약담당\": {\"submit\", \"redraft\", \"view\"}, \"계약관\": {\"review_contract\", \"reject\", \"view\"}, \"법무\": {\"review_legal\", \"reject\", \"view\"},\n", + " \"승인권자\": {\"approve\", \"reject\", \"view\"}, \"감사관\": {\"view\", \"audit\"},\n", + " \"부서장\": {\"submit\", \"approve\", \"reject\", \"view\"}} # 소규모 부서는 상신과 결재를 겸할 수 있으나 같은 건은 분리\n", + "NEXT = {\"제안\": (\"submit\", \"상신\"), \"상신\": (\"review_contract\", \"계약관검토\"), \"계약관검토\": (\"review_legal\", \"법무검토\"), \"법무검토\": (\"approve\", \"승인\")}\n", + "USERS = {\"sys\": \"시스템\", \"kim\": \"계약담당\", \"lee\": \"계약관\", \"park\": \"법무\", \"choi\": \"승인권자\", \"jung\": \"감사관\", \"oh\": \"부서장\"}\n", + "MAX_REDRAFT = 2\n", + "\n", + "\n", + "class Chain:\n", + " \"\"\"해시 체인 감사 로그: 각 기록이 직전 기록의 해시를 포함해 중간 수정이 드러난다.\"\"\"\n", + "\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", + "class Workflow:\n", + " def __init__(self):\n", + " self.audit, self.cases, self.blocked = Chain(), {}, []\n", + "\n", + " def _deny(self, who, case, action, why):\n", + " self.audit.add(event=\"DENIED\", who=who, case=case, action=action, reason=why)\n", + " self.blocked.append((who, case, action, why)); return False, why\n", + "\n", + " def act(self, who, case, action, reason=\"\", evidence=\"\"):\n", + " role = USERS.get(who)\n", + " if role is None:\n", + " return self._deny(who, case, action, \"미등록 사용자\")\n", + " if action == \"delete_log\":\n", + " return self._deny(who, case, action, \"감사 로그 삭제·수정 권한은 누구에게도 없음\")\n", + " if action not in PERM[role]:\n", + " return self._deny(who, case, action, f\"권한 없음({role} 역할에 {action} 권한 없음)\")\n", + " if action == \"propose\":\n", + " self.cases[case] = {\"state\": \"제안\", \"submitter\": None, \"version\": 1}\n", + " self.audit.add(event=\"PROPOSE\", who=who, case=case, reason=reason); return True, \"제안 등록\"\n", + " c = self.cases.get(case)\n", + " if c is None:\n", + " return self._deny(who, case, action, \"존재하지 않는 건\")\n", + " if c[\"state\"] in (\"승인\", \"종결(반려)\"):\n", + " return self._deny(who, case, action, f\"이미 종결된 건({c['state']})\")\n", + " if not reason.strip() or not evidence.strip():\n", + " return self._deny(who, case, action, \"사유·근거 입력 필수(반려 사유 포함)\")\n", + " if action == \"reject\":\n", + " if c[\"state\"] in (\"제안\", \"반려\"):\n", + " return self._deny(who, case, action, f\"반려할 수 없는 단계({c['state']})\")\n", + " c[\"state\"] = \"종결(반려)\" if c[\"version\"] > MAX_REDRAFT else \"반려\"\n", + " self.audit.add(event=\"REJECTED\", who=who, role=role, case=case, version=c[\"version\"], reason=reason, evidence=evidence); return True, c[\"state\"]\n", + " if action == \"redraft\":\n", + " if c[\"state\"] != \"반려\":\n", + " return self._deny(who, case, action, f\"재기안은 반려된 건만 가능(현재 {c['state']})\")\n", + " if who != c[\"submitter\"]:\n", + " return self._deny(who, case, action, \"재기안은 최초 상신자만 가능\")\n", + " c[\"state\"], c[\"version\"] = \"상신\", c[\"version\"] + 1\n", + " self.audit.add(event=\"REDRAFT\", who=who, role=role, case=case, version=c[\"version\"], reason=reason, evidence=evidence); return True, \"재기안 상신\"\n", + " if c[\"state\"] == \"반려\":\n", + " return self._deny(who, case, action, \"반려된 건은 재기안 후에만 진행 가능\")\n", + " need, nxt = NEXT[c[\"state\"]]\n", + " if action != need:\n", + " return self._deny(who, case, action, f\"단계 위반(현재 {c['state']}, 필요 행위 {need})\")\n", + " if action == \"submit\":\n", + " c[\"submitter\"] = who\n", + " if action == \"approve\" and who == c[\"submitter\"]:\n", + " return self._deny(who, case, action, \"상신자와 승인자 분리 원칙 위반\")\n", + " c[\"state\"] = nxt\n", + " self.audit.add(event=action.upper(), who=who, role=role, case=case, version=c[\"version\"], to=nxt, reason=reason, evidence=evidence); return True, nxt\n", + "\n", + "\n", + "# ---- 상신 대상: 룰 점수가 높은 계약 12건과 모델이 찾은 위험 조항 ----\n", + "cm = fit_clause_model()\n", + "rules = parse_rules(RULES_YAML)\n", + "con = make_contracts(100, seed=7)\n", + "d = derive(con)\n", + "sc = [apply_rules(r, rules) for r in d.to_dict(\"records\")]\n", + "d[\"score\"], d[\"hits\"] = [x[0] for x in sc], [x[2] for x in sc]\n", + "picked = d.sort_values(\"score\", ascending=False).head(12)\n", + "rng = np.random.default_rng(1)\n", + "LEGAL = {\"R08\", \"R07\", \"R12\", \"R10\"} # 법무 검토에서 보는 항목(보안·해지·논리·하도급)\n", + "\n", + "wf = Workflow()\n", + "rows = []\n", + "for k, (_, p) in enumerate(picked.iterrows(), 1):\n", + " case = f\"C{k:02d}\"\n", + " flagged = [i + 1 for i, c in enumerate(cm.predict(list(p[\"clauses\"]))) if c != \"정상\"]\n", + " wf.act(\"sys\", case, \"propose\", reason=f\"{p['contract_id']} 룰 점수 {p['score']}, 위험 조항 {len(flagged)}개 -> 수정 권고 제안\")\n", + " wf.act(\"kim\", case, \"submit\", \"위험 조항 수정 권고 검토 요청\", f\"룰 적중 {','.join(p['hits'][:4])}, 위험 조항 {flagged or '없음'}\")\n", + " c_issue, l_issue = bool(flagged), sorted(LEGAL & set(p[\"hits\"])) # 계약관이 볼 문제, 법무가 볼 문제\n", + " note, big, justified = \"승인권자 결정\", p[\"amount_m\"] >= 1000 and p[\"method\"] != \"일반경쟁\", False\n", + " for _ in range(8):\n", + " state = wf.cases[case][\"state\"]\n", + " if state in (\"승인\", \"종결(반려)\"):\n", + " break\n", + " if state == \"상신\":\n", + " if c_issue:\n", + " wf.act(\"lee\", case, \"reject\", f\"위험 조항 {flagged} 수정 필요\", \"조항 모델 위험 분류와 계약관 판단 일치\")\n", + " else:\n", + " wf.act(\"lee\", case, \"review_contract\", \"조항 검토 완료\", f\"위험 조항 {'정리됨' if flagged else '없음'}\")\n", + " elif state == \"계약관검토\":\n", + " if rng.random() < 0.15:\n", + " note = \"자동 확정 없음: 법무 응답 대기\"; break # 무응답은 보류로 둔다\n", + " if l_issue:\n", + " wf.act(\"park\", case, \"reject\", f\"필수 조항 보완 필요({','.join(l_issue)})\", \"해지·보안·하도급·납품 기한 조건 점검\")\n", + " else:\n", + " wf.act(\"park\", case, \"review_legal\", \"법무 검토 완료\", \"필수 조항 점검 이상 없음\")\n", + " elif state == \"법무검토\":\n", + " if big and not justified:\n", + " wf.act(\"choi\", case, \"reject\", \"고액 비경쟁 계약은 사유 소명 후 재상신\", f\"금액 {p['amount_m']:.0f}백만원, {p['method']}\")\n", + " else:\n", + " wf.act(\"choi\", case, \"approve\", \"승인: 수정본으로 계약 의뢰 진행\", f\"룰 점수 {p['score']}, 위험 조항·필수 조항 정리 완료\")\n", + " elif state == \"반려\":\n", + " wf.act(\"kim\", case, \"redraft\", \"반려 사유 반영해 수정\", f\"수정 대상 {flagged if c_issue else l_issue or ['소명 자료']}, 재기안 {wf.cases[case]['version']}차\")\n", + " if rng.random() < 0.65: # 재기안 후 문제가 정리될 확률(모의)\n", + " c_issue, l_issue = False, []\n", + " justified = True # 고액 비경쟁 건은 재기안 때 사유 소명서를 첨부(모의)\n", + " final = wf.cases[case][\"state\"]\n", + " if final not in (\"승인\", \"종결(반려)\") and note.startswith(\"자동\"):\n", + " final = \"보류(무응답)\"\n", + " elif final == \"종결(반려)\":\n", + " note = \"재기안 한도(2회) 초과\"\n", + " rows.append((case, p[\"contract_id\"], final, wf.cases[case][\"version\"], note))\n", + "print(\"[결재 결과] 12건 (모의 결재자 기준, 실제 결재는 사람이 한다)\")\n", + "print(pd.DataFrame(rows, columns=[\"건\", \"계약\", \"최종 상태\", \"버전\", \"비고\"]).to_string(index=False))\n", + "st = pd.Series([r[2] for r in rows]).value_counts().to_dict()\n", + "n_redraft = sum(e[\"event\"] == \"REDRAFT\" for e in wf.audit.log)\n", + "print(\"상태 집계:\", st, f\"/ 반려 후 재기안 {n_redraft}회, 반려 이력 {sum(e['event'] == 'REJECTED' for e in wf.audit.log)}건(모두 사유·근거 기록)\")\n", + "\n", + "# ---- 권한 우회·절차 위반 시도 ----\n", + "wf.act(\"sys\", \"T01\", \"propose\", reason=\"시험 건\")\n", + "wf.act(\"kim\", \"T01\", \"submit\", \"시험 상신\", \"근거\")\n", + "attempts = [\n", + " (\"시스템 자동 승인\", lambda: wf.act(\"sys\", \"T01\", \"approve\", \"자동\", \"자동\")),\n", + " (\"계약담당이 계약관 검토 대행\", lambda: wf.act(\"kim\", \"T01\", \"review_contract\", \"대행\", \"근거\")),\n", + " (\"검토 단계 건너뛰고 승인\", lambda: wf.act(\"choi\", \"T01\", \"approve\", \"바로 승인\", \"근거\")),\n", + " (\"미등록 사용자 접근\", lambda: wf.act(\"guest\", \"T01\", \"review_contract\", \"열람\", \"근거\")),\n", + " (\"사유 없는 검토\", lambda: wf.act(\"lee\", \"T01\", \"review_contract\", \"\", \"\")),\n", + " (\"사유 없는 반려\", lambda: wf.act(\"lee\", \"T01\", \"reject\", \"\", \"근거\")),\n", + " (\"감사 로그 삭제\", lambda: wf.act(\"kim\", \"T01\", \"delete_log\", \"정리\", \"근거\")),\n", + " (\"감사관이 승인 시도\", lambda: wf.act(\"jung\", \"T01\", \"approve\", \"승인\", \"근거\")),\n", + " (\"종결된 건 재승인\", lambda: wf.act(\"choi\", next(c for c, v in wf.cases.items() if v[\"state\"] == \"승인\"), \"approve\", \"재승인\", \"근거\")),\n", + " (\"존재하지 않는 건 승인\", lambda: wf.act(\"choi\", \"X99\", \"approve\", \"승인\", \"근거\")),\n", + "]\n", + "wf.act(\"sys\", \"T02\", \"propose\", reason=\"시험\"); wf.act(\"oh\", \"T02\", \"submit\", \"상신\", \"근거\")\n", + "wf.act(\"lee\", \"T02\", \"review_contract\", \"조항 확인\", \"위험 조항 없음\"); wf.act(\"park\", \"T02\", \"review_legal\", \"법무 확인\", \"이상 없음\")\n", + "attempts.append((\"상신자가 본인 건 승인(분리 원칙)\", lambda: wf.act(\"oh\", \"T02\", \"approve\", \"본인 승인\", \"근거\")))\n", + "wf.act(\"sys\", \"T03\", \"propose\", reason=\"시험\"); wf.act(\"kim\", \"T03\", \"submit\", \"상신\", \"근거\"); wf.act(\"lee\", \"T03\", \"reject\", \"수정 필요\", \"근거\")\n", + "attempts.append((\"반려 건을 재기안 없이 검토 진행\", lambda: wf.act(\"lee\", \"T03\", \"review_contract\", \"그대로 진행\", \"근거\")))\n", + "attempts.append((\"타인이 대신 재기안\", lambda: wf.act(\"oh\", \"T03\", \"redraft\", \"대신 수정\", \"근거\")))\n", + "print(\"\\n[권한 우회·절차 위반 시도]\")\n", + "blocked = 0\n", + "for name, fn in attempts:\n", + " ok, why = fn()\n", + " blocked += (not ok)\n", + " print(f\" {name:24s}-> {'차단' if not ok else '통과(결함)'}: {why}\")\n", + "print(f\"우회·위반 시도 차단 {blocked}/{len(attempts)} ({blocked / len(attempts):.0%}) (목표 100%: {'달성' if blocked == len(attempts) else '미달'})\")\n", + "\n", + "# ---- RBAC 매트릭스(역할별 접근 제어 시험) ----\n", + "acts = [\"propose\", \"submit\", \"review_contract\", \"review_legal\", \"approve\", \"reject\", \"redraft\", \"audit\"]\n", + "mx = pd.DataFrame([[(\"O\" if a in PERM[r] else \"-\") for a in acts] for r in PERM], index=list(PERM), columns=[\"제안\", \"상신\", \"계약관검토\", \"법무검토\", \"승인\", \"반려\", \"재기안\", \"감사조회\"])\n", + "print(\"\\n[역할별 권한 매트릭스(O 허용 / - 불가)]\")\n", + "print(mx.to_string())\n", + "print(\"승인(approve) 권한이 있는 역할:\", [r for r in PERM if \"approve\" in PERM[r]], \"-> 시스템·계약담당·계약관·법무에는 없음\")\n", + "\n", + "# ---- 이력 무결성과 이력 조회 ----\n", + "bad = wf.audit.verify()\n", + "print(f\"\\n감사 로그 {len(wf.audit.log)}건, 해시 체인 검증: {'정상' if bad is None else f'seq {bad}에서 불일치'} -> 이력 무결성 {'100%' if bad is None else '훼손'}(목표 100%: {'달성' if bad is None else '미달'})\")\n", + "idx = 10\n", + "saved = json.dumps(wf.audit.log[idx], ensure_ascii=False)\n", + "wf.audit.log[idx][\"reason\"] = \"사후 수정\"\n", + "v = wf.audit.verify()\n", + "print(f\"{idx}번째 기록 임의 수정 후 검증: {'위변조 탐지(seq ' + str(v) + ')' if v is not None else '탐지 실패'}\")\n", + "wf.audit.log[idx] = json.loads(saved)\n", + "print(\"\\n[이력 조회 화면] C02 건\")\n", + "for e in wf.audit.log:\n", + " if e.get(\"case\") == \"C02\":\n", + " print(f\" #{e['seq']:03d} {e['event']:15s} {e.get('role', e['who']):6s} v{e.get('version', 1)} | {e.get('reason', '')[:34]}\")\n", + "print(\"※ 계약 조항의 최종 판단, 법무 검토, 계약 승인은 사람(계약관·법무·승인권자)이 하며, 시스템은 위험 표시와 수정 권고만 제공한다. 보안 규정과 결재선은 소속 기관 규정을 따른다.\")\n", + "\n", + "plt.figure(figsize=(6, 3.5)); EN = {\"승인\": \"approved\", \"반려\": \"rejected\", \"종결(반려)\": \"closed-rejected\", \"보류(무응답)\": \"on hold\"}\n", + "plt.bar([EN.get(k, k) for k in st], list(st.values()), color=\"tab:blue\"); plt.title(\"HITL workflow outcomes (12 cases)\"); plt.ylabel(\"cases\"); plt.tight_layout(); plt.show()\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 07 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + "[결재 결과] 12건 (모의 결재자 기준, 실제 결재는 사람이 한다)\n", + " 건 계약 최종 상태 버전 비고\n", + "C01 C-0072 보류(무응답) 2 자동 확정 없음: 법무 응답 대기\n", + "C02 C-0099 승인 3 승인권자 결정\n", + "C03 C-0024 승인 3 승인권자 결정\n", + "C04 C-0067 승인 2 승인권자 결정\n", + "C05 C-0085 승인 2 승인권자 결정\n", + "C06 C-0022 승인 1 승인권자 결정\n", + "C07 C-0077 승인 2 승인권자 결정\n", + "C08 C-0040 승인 2 승인권자 결정\n", + "C09 C-0070 승인 2 승인권자 결정\n", + "C10 C-0065 승인 1 승인권자 결정\n", + "C11 C-0028 승인 2 승인권자 결정\n", + "C12 C-0007 종결(반려) 3 재기안 한도(2회) 초과\n", + "상태 집계: {'승인': 10, '보류(무응답)': 1, '종결(반려)': 1} / 반려 후 재기안 13회, 반려 이력 14건(모두 사유·근거 기록)\n", + "\n", + "[권한 우회·절차 위반 시도]\n", + " 시스템 자동 승인 -> 차단: 권한 없음(시스템 역할에 approve 권한 없음)\n", + " 계약담당이 계약관 검토 대행 -> 차단: 권한 없음(계약담당 역할에 review_contract 권한 없음)\n", + " 검토 단계 건너뛰고 승인 -> 차단: 단계 위반(현재 상신, 필요 행위 review_contract)\n", + " 미등록 사용자 접근 -> 차단: 미등록 사용자\n", + " 사유 없는 검토 -> 차단: 사유·근거 입력 필수(반려 사유 포함)\n", + " 사유 없는 반려 -> 차단: 사유·근거 입력 필수(반려 사유 포함)\n", + " 감사 로그 삭제 -> 차단: 감사 로그 삭제·수정 권한은 누구에게도 없음\n", + " 감사관이 승인 시도 -> 차단: 권한 없음(감사관 역할에 approve 권한 없음)\n", + " 종결된 건 재승인 -> 차단: 이미 종결된 건(승인)\n", + " 존재하지 않는 건 승인 -> 차단: 존재하지 않는 건\n", + " 상신자가 본인 건 승인(분리 원칙) -> 차단: 상신자와 승인자 분리 원칙 위반\n", + " 반려 건을 재기안 없이 검토 진행 -> 차단: 반려된 건은 재기안 후에만 진행 가능\n", + " 타인이 대신 재기안 -> 차단: 권한 없음(부서장 역할에 redraft 권한 없음)\n", + "우회·위반 시도 차단 13/13 (100%) (목표 100%: 달성)\n", + "\n", + "[역할별 권한 매트릭스(O 허용 / - 불가)]\n", + " 제안 상신 계약관검토 법무검토 승인 반려 재기안 감사조회\n", + "시스템 O - - - - - - -\n", + "계약담당 - O - - - - O -\n", + "계약관 - - O - - O - -\n", + "법무 - - - O - O - -\n", + "승인권자 - - - - O O - -\n", + "감사관 - - - - - - - O\n", + "... (이하 생략)\n", + "```\n", + "
\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 08. 1,000건 대규모 조달 계약서 일괄 스크리닝 부하 테스트 및 처리 속도 검증\n", + "**소요시간**: 약 3.5시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "대규모 조달 계약서 일괄 스크리닝 부하 테스트를 수행한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **월 1,000건 규모의 계약서를 일괄 스크리닝하는 배치 파이프라인의 부하 시험과 처리 속도 검증을 수행한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **월 1,000건씩 12개월분 가상 계약 배치를 간이 스케줄러로 자동 실행하면서 월마다 손상 레코드 5건과 2% 일시 오류 주입하여 처리 지연 및 복원력을 시험하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `src/pipeline/prc01_batch_runner.py` 형태의 결과물을 완성한다.\n", + "\n", + "### 🔍 평가 및 검증 기준 (PASS 조건)\n", + "#### 미션 판정\n", + "\n", + "**평가 구분:** 필수\n", + "\n", + "**핵심 평가영역:** 수작업 대비 업무 소요 시간 80% 이상 절감 효과\n", + "\n", + "#### 미션 PASS 조건\n", + "\n", + "- 정량 기준: 전체 배치 파이프라인 실행 시간 5분 이내, 자동화 성공률 99% 이상\n", + "\n", + "\n", + "- 진행 기준: 스케줄러 연동 테스트 및 자동 실행 확인\n", + "\n", + "\n", + "- 검증 방법: 실제 월간 업무 데이터 1년치 일괄 배치 시뮬레이션\n", + "\n", + "\n", + "#### 채점자 판정 메모\n", + "\n", + "> 단순 코드 실행 여부뿐만 아니라 출력된 수치와 근거 자료가 군 실무 요구사항에 부합하는지 면밀히 확인한다.\n", + "\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "\"\"\"1,000건 규모의 월간 계약서 일괄 스크리닝 배치(입력 검증, 룰 16종, 조항 분류, 위험 점수)를 12개월분 스케줄러 시뮬레이션으로 실행하고, 손상 레코드·일시 오류를 주입한 상태에서 실행 시간(5분 이내)·자동화 성공률(99% 이상)·수작업 대비 시간 절감(80% 이상)·진행률 표시·완료 알림·시간 초과 시 기능 축소를 검증한다.\"\"\"\n", + "import time\n", + "import numpy as np\n", + "import pandas as pd\n", + "import matplotlib.pyplot as plt\n", + "from sklearn.feature_extraction.text import TfidfVectorizer\n", + "from sklearn.linear_model import LogisticRegression\n", + "from sklearn.pipeline import make_pipeline\n", + "\n", + "# ---------------------------------------------------------------------------\n", + "# [교육용가상자료] 가상 조달계획안·계약서 생성기 (실제 계약·업체·법령 조문 없음)\n", + "# 계약 속성 16개 + 조항 문장 6개를 만들고, 속성 위반·독소 조항·납기 위험 문구가 섞인 '규정 위반 여부'를 함께 만든다.\n", + "# ---------------------------------------------------------------------------\n", + "TYPES = [\"물품\", \"용역\", \"공사\"]\n", + "METHODS = [\"일반경쟁\", \"제한경쟁\", \"지명경쟁\", \"수의계약\"]\n", + "CLASSES = [\"정상\", \"독소\", \"납기위험\"]\n", + "OBJ = [\"납품 물품\", \"용역 결과물\", \"시공 목적물\", \"정비 성과물\"]\n", + "\n", + "N_TPL = [\n", + " \"계약상대자가 납기를 지체한 경우 지체일수 1일마다 계약금액의 {p}/1000에 해당하는 지체상금을 부과한다.\",\n", + " \"지체상금은 불가항력 사유가 확인된 경우에 한하여 발주기관의 승인을 받아 면제할 수 있다.\",\n", + " \"납기 연장은 발주기관이 불가항력 사유를 확인하고 서면으로 승인한 경우에만 인정한다.\",\n", + " \"발주기관은 납품된 {o}을 {d}일 이내에 검사하고 합격한 후에 대금을 지급한다.\",\n", + " \"계약상대자는 하도급을 하려는 경우 사전에 발주기관의 서면 승인을 받아야 한다.\",\n", + " \"어느 한쪽이 계약을 해지하려면 사유를 명시해 {d}일 전에 서면으로 통지해야 한다.\",\n", + " \"{o}에 하자가 발생하면 계약상대자는 보증 기간 안에 무상으로 보수하거나 교체한다.\",\n", + " \"계약 이행 중 알게 된 비공개 사항은 제3자에게 누설하지 않으며 위반 시 책임을 진다.\",\n", + " \"{o}에 대한 지식재산권의 귀속과 사용 범위는 별도 합의서에 따라 정한다.\",\n", + " \"대금은 검사 합격 후 청구서를 접수한 날부터 {d}일 이내에 지급한다.\",\n", + " \"계약금액의 조정은 물가변동 기준에 따라 양 당사자가 서면으로 합의한 경우에만 한다.\",\n", + " \"계약상대자는 이행보증서를 계약 체결 시 제출하며 보증금은 계약 이행 완료 후 반환한다.\",\n", + " \"납품 일정은 계약서 별표의 일자를 따르며 변경 시 서면 합의가 필요하다.\",\n", + " \"지체상금은 면제 사유가 없는 한 부과하며 이의는 정해진 절차로 제기할 수 있다.\",\n", + " \"검사에 합격하지 못한 {o}은 계약상대자가 교체하며 그 비용은 계약상대자가 부담한다.\",\n", + " \"계약상대자의 귀책 사유로 이행이 불가능하면 발주기관은 서면으로 통지하고 계약을 해지할 수 있다.\",\n", + " \"지연이 예상되면 계약상대자는 사유와 만회 계획을 발주기관에 서면으로 보고한다.\",\n", + " \"보증 기간 중 발생한 결함은 계약상대자가 책임지고 보수한다.\",\n", + " \"분쟁은 먼저 협의로 해결하고 협의가 되지 않으면 관할 법원의 판단에 따른다.\",\n", + "]\n", + "T_TPL = [\n", + " \"계약상대자의 지체상금은 전액 면제하며 발주기관은 이에 대해 이의를 제기하지 못한다.\",\n", + " \"납품 즉시 검사에 합격한 것으로 보며 발주기관은 이후 검사를 요구하지 않는다.\",\n", + " \"계약상대자는 언제든지 일방적으로 계약을 해지할 수 있으며 그에 따른 손해는 발주기관이 부담한다.\",\n", + " \"{o}의 추가 비용은 사유를 불문하고 발주기관이 전액 부담하며 별도 협의 없이 청구할 수 있다.\",\n", + " \"{o}에 관한 지식재산권은 모두 계약상대자에게 귀속되고 발주기관은 사용할 수 없다.\",\n", + " \"하도급은 신고 없이 제한 없이 허용하며 발주기관은 하도급 내용을 확인하지 않는다.\",\n", + " \"대금은 검사 전에 전액 선지급하며 발주기관은 반환 청구권을 포기한다.\",\n", + " \"계약 이행 중 알게 된 사항의 누설로 인한 책임을 계약상대자는 지지 않는다.\",\n", + " \"하자 보수 책임은 인도 후 즉시 소멸하며 발주기관은 보수를 요구할 수 없다.\",\n", + " \"발주기관은 계약상대자가 청구하는 금액을 거절할 수 없으며 거절하면 위약금을 지급한다.\",\n", + " \"분쟁이 생기면 계약상대자가 정하는 중재 기관에서만 해결하고 발주기관은 따른다.\",\n", + " \"발주기관의 검사는 생략하고 계약상대자의 시험 결과서로 대신한다.\",\n", + " \"견적 금액은 사후 정산 없이 그대로 확정하고 증빙 자료는 받지 않는다.\",\n", + "]\n", + "D_TPL = [\n", + " \"납기는 자재 수급 사정에 따라 계약상대자가 연장할 수 있다.\",\n", + " \"납품 일정은 가능한 한 지키도록 노력하며 지연되어도 사유를 보고하지 않는다.\",\n", + " \"공급 지연이나 선행 업체 사정도 불가항력으로 보아 납기를 자동 연장한다.\",\n", + " \"착수일은 선행 업체의 납품이 끝난 뒤 별도로 정하며 완료 기한은 정하지 않는다.\",\n", + " \"납기일 이후 1회에 한해 기한 없이 자동 연장되는 것으로 한다.\",\n", + " \"납기는 계약상대자의 생산 일정이 확정되는 대로 통보한다.\",\n", + " \"납품 시점은 계약상대자 사정이 허락하는 때로 하고 날짜는 따로 정하지 않는다.\",\n", + " \"자재가 늦게 들어오면 완료 일정은 그만큼 자동으로 늦춰진다.\",\n", + " \"기한 내에 인도하지 못해도 계약상대자에게 책임을 묻지 않는다.\",\n", + " \"일정이 늦어져도 계약상대자는 새 계획서를 낼 필요가 없다.\",\n", + "]\n", + "# 학습에 쓰지 않는 '처음 보는 표현'(모델이 문장 틀을 외웠는지 확인하는 용도)\n", + "HOLD = {\n", + " \"정상\": [\"검사에 불합격한 경우 계약상대자는 지정 기한 안에 재납품하며 재검사 비용은 상대자가 부담한다.\",\n", + " \"계약상대자가 법령을 위반하여 계약을 이행하지 못하면 발주기관은 서면 통지 후 계약을 해지할 수 있다.\",\n", + " \"납기가 지연될 우려가 있으면 상대자는 즉시 사유와 만회 계획을 서면으로 보고해야 한다.\",\n", + " \"하자담보 책임 기간은 인수일부터 기산하며 기간 중 발생한 결함은 상대자가 책임진다.\"],\n", + " \"독소\": [\"발주기관은 계약상대자가 요구하는 대금을 이유 없이 거절하지 못하며 거절 시 위약금을 낸다.\",\n", + " \"계약 내용에 관한 분쟁은 계약상대자가 지정하는 기관에서만 다루며 발주기관은 이에 따른다.\",\n", + " \"발주기관의 검수 권한은 포기하고 계약상대자의 자체 시험 성적서로 갈음한다.\",\n", + " \"계약상대자가 제출한 견적은 사후 정산 없이 확정 단가로 하며 증빙은 요구하지 않는다.\"],\n", + " \"납기위험\": [\"납품 시기는 상대자의 형편이 허락하는 시점으로 하고 별도 날짜는 정하지 않는다.\",\n", + " \"원자재 확보가 늦어지면 완료 일자는 자동으로 뒤로 미뤄지는 것으로 본다.\",\n", + " \"인도 기한은 참고용이며 지키지 못해도 책임을 묻지 않는다.\",\n", + " \"일정 지연이 있어도 상대자는 변경 계획을 제출할 의무가 없다.\"],\n", + "}\n", + "SUFFIX = [\"\", \"\", \"\", \" 세부 사항은 시행 세칙에 따른다.\", \" 이 조항은 계약 전체에 적용한다.\", \" 필요한 경우 양 당사자가 협의한다.\", \" 관련 서식은 별지를 따른다.\"]\n", + "SWAP = [(\"발주기관\", \"계약담당관\"), (\"계약상대자\", \"업체\")]\n", + "\n", + "\n", + "def make_clause(rng, cls, hold=False, idx=None):\n", + " \"\"\"조항 한 문장 생성. 표현 일부는 동의어로 바꾸고 접미 문구를 무작위로 붙인다(idx를 주면 그 번호의 문장 틀을 사용).\"\"\"\n", + " if idx is not None:\n", + " tpl = {\"정상\": N_TPL, \"독소\": T_TPL, \"납기위험\": D_TPL}[cls]\n", + " s = tpl[idx].format(p=rng.choice([\"1.0\", \"1.5\", \"2.0\"]), d=int(rng.choice([7, 10, 14, 30])), o=rng.choice(OBJ))\n", + " elif hold:\n", + " s = HOLD[cls][rng.integers(len(HOLD[cls]))]\n", + " else:\n", + " tpl = {\"정상\": N_TPL, \"독소\": T_TPL, \"납기위험\": D_TPL}[cls]\n", + " s = tpl[rng.integers(len(tpl))].format(p=rng.choice([\"1.0\", \"1.5\", \"2.0\"]), d=int(rng.choice([7, 10, 14, 30])), o=rng.choice(OBJ))\n", + " if rng.random() < 0.3:\n", + " a, b = SWAP[rng.integers(2)]\n", + " s = s.replace(a, b)\n", + " return s + SUFFIX[rng.integers(len(SUFFIX))]\n", + "\n", + "\n", + "def make_contracts(n=200, seed=7):\n", + " \"\"\"가상 계약 n건(속성 16개 + 조항 6개 + 규정 위반 정답)을 만든다.\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " sl = rng.beta(1.2, 3.2, n) # 계약서 작성이 허술한 정도(잠재 변수)\n", + " ctype = rng.choice(TYPES, n, p=[.5, .3, .2])\n", + " amount = np.clip(np.exp(rng.normal(np.log(220), 1.1, n)), 5, 5000).round(0)\n", + " method = np.where(rng.random(n) < .06 + .3 * sl, \"수의계약\", rng.choice(METHODS[:3], n, p=[.5, .3, .2]))\n", + " period = np.array([rng.integers(60, 300) if t == \"물품\" else rng.integers(90, 400) if t == \"용역\" else rng.integers(180, 720) for t in ctype])\n", + " delivery = (period * rng.uniform(.5, 1.0, n)).astype(int)\n", + " bad_logic = rng.random(n) < .015 * (1 + 3 * sl)\n", + " delivery = np.where(bad_logic, period + rng.integers(10, 60, n), delivery)\n", + " pen = np.where(rng.random(n) < .22 * sl, rng.choice([.3, .5, .8], n), rng.choice([1.0, 1.5, 2.0, 2.5], n))\n", + " adv = np.where(rng.random(n) < .22 * sl, rng.choice([50, 60, 70], n), rng.choice([0, 10, 20, 30, 40], n))\n", + " bond = np.where(rng.random(n) < .25 * sl + .5 * (adv > 40), rng.choice([0, 5], n), rng.choice([10, 10, 15, 20], n))\n", + " war = np.array([rng.choice([3, 6]) if (rng.random() < .25 * s and t == \"물품\") else rng.choice([12, 24, 36]) if t == \"물품\" else rng.choice([0, 6, 12]) if t == \"용역\" else rng.choice([24, 36, 60]) for t, s in zip(ctype, sl)])\n", + " insp = np.where(rng.random(n) < .3 * sl, rng.choice([21, 30], n), rng.choice([5, 7, 10, 14], n))\n", + " sub = np.where(rng.random(n) < .15 * sl, rng.choice([60, 70, 80], n), rng.choice([0, 10, 20, 30, 40, 50], n))\n", + " padj = rng.random(n) < .75 - .4 * sl\n", + " term = rng.random(n) > .015 * (1 + 6 * sl)\n", + " sec = rng.random(n) > .015 * (1 + 6 * sl)\n", + " ip = rng.random(n) > .03 * (1 + 4 * sl)\n", + " clauses, cls_true = [], []\n", + " for i in range(n):\n", + " pt, pd_ = .01 + .08 * sl[i], .02 + .08 * sl[i]\n", + " cl = [rng.choice(CLASSES, p=[1 - pt - pd_, pt, pd_]) for _ in range(6)]\n", + " cls_true.append(cl); clauses.append([make_clause(rng, c) for c in cl])\n", + " df = pd.DataFrame({\"contract_id\": [f\"C-{i + 1:04d}\" for i in range(n)], \"ctype\": ctype, \"method\": method, \"amount_m\": amount,\n", + " \"period_days\": period, \"delivery_days\": delivery, \"penalty_permil\": pen, \"advance_pct\": adv, \"perf_bond_pct\": bond,\n", + " \"warranty_months\": war, \"inspection_days\": insp, \"subcontract_pct\": sub, \"price_adjust\": padj,\n", + " \"termination\": term, \"security\": sec, \"ip_clause\": ip})\n", + " hard = ((pen < 1.0) | ((adv > 40) & (bond < 10)) | ((amount >= 300) & (method == \"수의계약\")) | ((ctype == \"물품\") & (war < 12))\n", + " | ~term | ~sec | (sub > 50) | (delivery > period))\n", + " toxic = np.array([c.count(\"독소\") for c in cls_true])\n", + " delay = np.array([c.count(\"납기위험\") for c in cls_true])\n", + " split = (method == \"수의계약\") & (amount >= 250) & (amount < 300) & (rng.random(n) < .6) # 계약 쪼개기 의심(임계값 바로 아래)\n", + " df[\"clauses\"], df[\"clause_cls\"] = clauses, cls_true\n", + " df[\"n_toxic\"], df[\"n_delay\"] = toxic, delay\n", + " viol = hard | (toxic > 0) | split | (delay >= 2)\n", + " waived = hard & (toxic == 0) & (rng.random(n) < .12) # 계약관이 예외 사유를 인정해 위반으로 보지 않은 건(자료의 현실적 잡음)\n", + " hidden = ~viol & (rng.random(n) < .03 + .08 * sl) # 속성·문구에 드러나지 않는 위반(별첨 합의 등)\n", + " df[\"violation\"] = (viol & ~waived) | hidden\n", + " df[\"vtype\"] = np.where(hidden, \"기타\", np.where(hard, \"속성위반\", np.where(toxic > 0, \"독소조항\", np.where(split | (delay >= 2), \"기타\", \"-\"))))\n", + " return df\n", + "\n", + "# ---- 계약 규정 준수 판별 룰 설정(YAML 형식 문자열: 규정이 바뀌면 코드가 아니라 이 설정만 고친다) ----\n", + "RULES_YAML = \"\"\"\n", + "- id: R01\n", + " name: 지체상금률 1.0/1000 미만\n", + " field: penalty_permil\n", + " op: \"<\"\n", + " value: 1.0\n", + " weight: 3\n", + "- id: R02\n", + " name: 선금 비율 40% 초과\n", + " field: advance_pct\n", + " op: \">\"\n", + " value: 40\n", + " weight: 2\n", + "- id: R03\n", + " name: 선금 40% 초과인데 이행보증 10% 미만\n", + " field: adv_no_bond\n", + " op: \"==\"\n", + " value: true\n", + " weight: 3\n", + "- id: R04\n", + " name: 300백만원 이상 수의계약\n", + " field: sole_amount\n", + " op: \">=\"\n", + " value: 300\n", + " weight: 3\n", + "- id: R05\n", + " name: 500백만원 이상 이행보증 10% 미만\n", + " field: bond_gap\n", + " op: \"==\"\n", + " value: true\n", + " weight: 2\n", + "- id: R06\n", + " name: 물품 하자보증 12개월 미만\n", + " field: warranty_gap\n", + " op: \"==\"\n", + " value: true\n", + " weight: 2\n", + "- id: R07\n", + " name: 해지 조항 없음\n", + " field: termination\n", + " op: \"==\"\n", + " value: false\n", + " weight: 2\n", + "- id: R08\n", + " name: 보안 조항 없음\n", + " field: security\n", + " op: \"==\"\n", + " value: false\n", + " weight: 3\n", + "- id: R09\n", + " name: 지식재산 조항 없음(물품·용역)\n", + " field: ip_missing\n", + " op: \"==\"\n", + " value: true\n", + " weight: 1\n", + "- id: R10\n", + " name: 하도급 비율 50% 초과\n", + " field: subcontract_pct\n", + " op: \">\"\n", + " value: 50\n", + " weight: 3\n", + "- id: R11\n", + " name: 검사 기간 14일 초과\n", + " field: inspection_days\n", + " op: \">\"\n", + " value: 14\n", + " weight: 1\n", + "- id: R12\n", + " name: 납품 기한이 계약 기간 초과\n", + " field: logic_err\n", + " op: \"==\"\n", + " value: true\n", + " weight: 3\n", + "- id: R13\n", + " name: 180일 이상 계약에 물가변동 조항 없음\n", + " field: no_padj_long\n", + " op: \"==\"\n", + " value: true\n", + " weight: 1\n", + "- id: R14\n", + " name: 1000백만원 이상 비경쟁 계약\n", + " field: noncomp_big\n", + " op: \"==\"\n", + " value: true\n", + " weight: 2\n", + "- id: R15\n", + " name: 계약 기간 365일 초과\n", + " field: period_days\n", + " op: \">\"\n", + " value: 365\n", + " weight: 1\n", + "- id: R16\n", + " name: 불공정·납기 위험 키워드 포함\n", + " field: kw_hits\n", + " op: \">=\"\n", + " value: 1\n", + " weight: 3\n", + "\"\"\"\n", + "GRADE_CUT = {\"고위험\": 6, \"주의\": 3} # 가중치 합 기준 1차 판별 등급(필요 시 설정만 바꾼다)\n", + "# [교육용가상자료] 불공정 조항·납기 지체 위험 문구 사전(실제 규정 조문이 아님)\n", + "KEYWORDS = {\"독소\": [\"면제하며\", \"일방적으로\", \"포기\", \"지지 않는다\", \"즉시 소멸\", \"제한 없이\", \"신고 없이\", \"전액 부담\", \"별도 협의 없이\", \"검사 전에\", \"검사에 합격한 것으로\", \"사용할 수 없다\", \"증빙은 요구하지 않는다\", \"지정하는 기관\"],\n", + " \"납기위험\": [\"자동 연장\", \"기한 없이\", \"정하지 않는다\", \"노력하며\", \"연장할 수 있다\", \"통보한다\", \"책임을 묻지 않는다\", \"의무가 없다\"]}\n", + "\n", + "\n", + "def parse_rules(text):\n", + " \"\"\"간이 YAML 파서(목록 + key: value). 숫자·true/false·따옴표 문자열을 해석한다.\"\"\"\n", + " rules, cur = [], None\n", + " for line in text.strip().splitlines():\n", + " if not line.strip():\n", + " continue\n", + " s = line.strip()\n", + " if s.startswith(\"- \"):\n", + " cur = {}; rules.append(cur); s = s[2:]\n", + " k, v = [x.strip() for x in s.split(\":\", 1)]\n", + " v = v.strip('\"')\n", + " if v in (\"true\", \"false\"):\n", + " v = v == \"true\"\n", + " else:\n", + " try:\n", + " v = float(v) if \".\" in v else int(v)\n", + " except ValueError:\n", + " pass\n", + " cur[k] = v\n", + " return rules\n", + "\n", + "\n", + "OPS = {\"==\": lambda a, b: a == b, \"!=\": lambda a, b: a != b, \">\": lambda a, b: a > b, \">=\": lambda a, b: a >= b,\n", + " \"<\": lambda a, b: a < b, \"<=\": lambda a, b: a <= b}\n", + "\n", + "\n", + "def kw_count(clauses):\n", + " \"\"\"조항 문장 목록에서 독소·납기 위험 키워드가 들어 있는 조항 수.\"\"\"\n", + " return int(sum(any(k in c for k in KEYWORDS[\"독소\"] + KEYWORDS[\"납기위험\"]) for c in clauses))\n", + "\n", + "\n", + "def derive(df):\n", + " \"\"\"룰에 쓰는 파생 필드(선금+보증 부족, 수의계약 고액, 논리 오류, 키워드 적중 등)를 만든다.\"\"\"\n", + " d = df.copy()\n", + " d[\"adv_no_bond\"] = (d[\"advance_pct\"] > 40) & (d[\"perf_bond_pct\"] < 10)\n", + " d[\"sole_amount\"] = np.where(d[\"method\"] == \"수의계약\", d[\"amount_m\"], 0.0) # 수의계약일 때만 금액(한도는 YAML에서 조정)\n", + " d[\"bond_gap\"] = (d[\"amount_m\"] >= 500) & (d[\"perf_bond_pct\"] < 10)\n", + " d[\"warranty_gap\"] = (d[\"ctype\"] == \"물품\") & (d[\"warranty_months\"] < 12)\n", + " d[\"ip_missing\"] = (~d[\"ip_clause\"]) & (d[\"ctype\"] != \"공사\")\n", + " d[\"logic_err\"] = d[\"delivery_days\"] > d[\"period_days\"]\n", + " d[\"no_padj_long\"] = (d[\"period_days\"] >= 180) & (~d[\"price_adjust\"])\n", + " d[\"noncomp_big\"] = d[\"method\"].isin([\"지명경쟁\", \"수의계약\"]) & (d[\"amount_m\"] >= 1000)\n", + " d[\"kw_hits\"] = [kw_count(c) for c in d[\"clauses\"]] if \"clauses\" in d else 0\n", + " return d\n", + "\n", + "\n", + "def apply_rules(rec, rules):\n", + " \"\"\"레코드 1건(dict)에 룰을 적용해 (점수, 등급, 적중 룰 id 목록)을 돌려준다. 값이 없으면 해당 룰은 건너뛴다.\"\"\"\n", + " hits = []\n", + " for r in rules:\n", + " v = rec.get(r[\"field\"])\n", + " if v is None or (isinstance(v, float) and np.isnan(v)):\n", + " continue\n", + " if OPS[r[\"op\"]](v, r[\"value\"]):\n", + " hits.append(r)\n", + " score = sum(r[\"weight\"] for r in hits)\n", + " grade = \"고위험\" if score >= GRADE_CUT[\"고위험\"] else (\"주의\" if score >= GRADE_CUT[\"주의\"] else \"관심\")\n", + " return score, grade, [r[\"id\"] for r in hits]\n", + "\n", + "SIMPLE_KW = {\"독소\": [\"면제\", \"일방\", \"포기\", \"전액\", \"누설\", \"하도급\", \"선지급\"], \"납기위험\": [\"연장\", \"자동\", \"노력\", \"통보\", \"기한\"]}\n", + "\n", + "# ---- 조항 문장 분류(정상 / 독소 / 납기위험): 글자 n-gram TF-IDF + 로지스틱 회귀 ----\n", + "def clause_set(seed, n, hold=False, mix=(.55, .25, .20)):\n", + " \"\"\"조항 문장 n개와 정답 분류를 만든다. hold=True면 학습에 쓰지 않은 '처음 보는 표현'으로 만든다.\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " y = rng.choice(CLASSES, n, p=list(mix))\n", + " return [make_clause(rng, c, hold) for c in y], list(y)\n", + "\n", + "\n", + "def fit_clause_model(seed=100, n=1200):\n", + " \"\"\"학습용 조항 문장으로 분류 모델을 학습한다.\"\"\"\n", + " X, y = clause_set(seed, n)\n", + " return make_pipeline(TfidfVectorizer(analyzer=\"char_wb\", ngram_range=(2, 4), sublinear_tf=True), LogisticRegression(C=20, max_iter=2000)).fit(X, y)\n", + "\n", + "\n", + "def keyword_pred(s):\n", + " \"\"\"비교용 기준선: 단순 키워드(부정 표현·문맥을 보지 않음) 적중으로만 분류.\"\"\"\n", + " if any(k in s for k in SIMPLE_KW[\"독소\"]):\n", + " return \"독소\"\n", + " return \"납기위험\" if any(k in s for k in SIMPLE_KW[\"납기위험\"]) else \"정상\"\n", + "\n", + "rules = parse_rules(RULES_YAML)\n", + "CM = fit_clause_model()\n", + "MANUAL_MIN = 35.0 # [가정] 수작업 1건: 계약서 읽기 20분 + 점검표 작성 10분 + 규정 대조 5분\n", + "REVIEW_MIN = 6.0 # [가정] 자동 처리 후 담당자가 확인하는 건당 시간(고위험·주의·격리 건만)\n", + "CACHE = {}\n", + "NOTICE = []\n", + "\n", + "\n", + "def classify(clauses):\n", + " \"\"\"조항 분류 결과를 문장 단위로 캐시한다(같은 문장은 한 번만 모델에 넣는다).\"\"\"\n", + " new = [c for c in set(clauses) if c not in CACHE]\n", + " if new:\n", + " CACHE.update(dict(zip(new, CM.predict(new))))\n", + " return [CACHE[c] for c in clauses]\n", + "\n", + "\n", + "def run_batch(con, fail_p=0.0, rng=None, budget_s=None, progress=None):\n", + " \"\"\"검증 -> 룰 스크리닝 -> (시간 여유가 있으면) 조항 분류. 일시 오류는 3회 재시도, 손상 레코드는 격리하고 계속한다.\"\"\"\n", + " t0 = time.perf_counter()\n", + " st = {\"ok\": 0, \"quarantine\": 0, \"retry\": 0, \"failed\": 0, \"degraded\": False}\n", + " d = derive(con)\n", + " recs = d.to_dict(\"records\")\n", + " grade, toxic = [], []\n", + " mode = \"full\"\n", + " for i, r in enumerate(recs):\n", + " if progress and (i + 1) % max(len(recs) // 4, 1) == 0:\n", + " progress(i + 1, len(recs))\n", + " if budget_s and time.perf_counter() - t0 > budget_s and mode == \"full\":\n", + " mode = \"rules_only\"; st[\"degraded\"] = True # 시간 예산 초과: 조항 분류를 생략하고 룰만 계속(기능 축소)\n", + " if r.get(\"amount_m\") is None or pd.isna(r.get(\"amount_m\")) or not r.get(\"clauses\"):\n", + " st[\"quarantine\"] += 1; grade.append(\"격리\"); toxic.append(0); continue\n", + " for attempt in range(4):\n", + " if rng is not None and rng.random() < fail_p: # 일시 오류(모의): 최대 3회 재시도\n", + " st[\"retry\"] += 1; continue\n", + " g = apply_rules(r, rules)[1]\n", + " n_t = sum(k != \"정상\" for k in classify(r[\"clauses\"])) if mode == \"full\" else 0\n", + " grade.append(g); toxic.append(n_t); st[\"ok\"] += 1; break\n", + " else:\n", + " st[\"failed\"] += 1; grade.append(\"실패\"); toxic.append(0)\n", + " return pd.DataFrame({\"contract_id\": con[\"contract_id\"].to_numpy(), \"grade\": grade, \"toxic\": toxic}), st, time.perf_counter() - t0\n", + "\n", + "\n", + "class Scheduler:\n", + " \"\"\"월간 배치를 정해진 순서로 자동 실행하는 간이 스케줄러(실제 시간 대기 없이 순서만 모사).\"\"\"\n", + "\n", + " def __init__(self):\n", + " self.jobs, self.history = [], []\n", + "\n", + " def add(self, name, fn):\n", + " self.jobs.append((name, fn))\n", + "\n", + " def run_all(self):\n", + " for name, fn in self.jobs:\n", + " try:\n", + " self.history.append((name, \"SUCCESS\", fn()))\n", + " except Exception as e:\n", + " self.history.append((name, \"FAILED\", str(e)))\n", + " NOTICE.append(f\"[알림] {name} 실패: {type(e).__name__}\")\n", + "\n", + "\n", + "rng = np.random.default_rng(8)\n", + "months = [f\"2026-{m:02d}\" for m in range(1, 13)]\n", + "print(f\"[교육용가상자료] 월 1,000건 x 12개월 배치, 월마다 손상 레코드 5건(0.5%) 주입, 일시 오류 확률 2%/시도\")\n", + "data = {}\n", + "for k, m in enumerate(months):\n", + " c = make_contracts(1000, seed=100 + k)\n", + " c = c.assign(amount_m=c[\"amount_m\"].astype(float))\n", + " c.loc[rng.choice(1000, 5, replace=False), \"amount_m\"] = np.nan\n", + " data[m] = c\n", + "\n", + "# 1) 첫 달 배치: 진행률 표시 포함\n", + "def bar(i, n):\n", + " print(f\" 진행률 [{'#' * int(20 * i / n):<20s}] {i / n:.0%} ({i}/{n})\")\n", + "\n", + "\n", + "print(\"\\n[2026-01 배치 진행]\")\n", + "g1, s1, t1 = run_batch(data[\"2026-01\"], fail_p=0.02, rng=rng, progress=bar)\n", + "print(f\" 완료: 처리 {s1['ok']}건, 격리 {s1['quarantine']}건, 재시도 {s1['retry']}회, 실패 {s1['failed']}건, {t1:.2f}초\")\n", + "NOTICE.append(f\"[알림] 2026-01 배치 완료: 고위험 {int((g1['grade'] == '고위험').sum())}건, 주의 {int((g1['grade'] == '주의').sum())}건, 격리 {s1['quarantine']}건\")\n", + "print(NOTICE[-1])\n", + "\n", + "# 2) 1년치 스케줄러 시뮬레이션\n", + "sch = Scheduler()\n", + "stats = {}\n", + "for m in months:\n", + " def job(m=m):\n", + " g, s, t = run_batch(data[m], fail_p=0.02, rng=rng)\n", + " stats[m] = {**s, \"sec\": t, \"고위험\": int((g[\"grade\"] == \"고위험\").sum()), \"주의\": int((g[\"grade\"] == \"주의\").sum())}\n", + " return f\"{s['ok']}건 처리\"\n", + " sch.add(m, job)\n", + "t_all = time.perf_counter()\n", + "sch.run_all()\n", + "total_s = time.perf_counter() - t_all\n", + "R = pd.DataFrame(stats).T\n", + "print(f\"\\n[1년치 스케줄러 실행] 작업 {len(sch.history)}개, 성공 {sum(h[1] == 'SUCCESS' for h in sch.history)}개\")\n", + "print(R[[\"ok\", \"quarantine\", \"retry\", \"failed\", \"고위험\", \"주의\"]].astype(int).rename(columns={\"ok\": \"처리\", \"quarantine\": \"격리\", \"retry\": \"재시도\", \"failed\": \"실패\"}).head(4).to_string())\n", + "print(\" ... (이하 8개월 생략)\")\n", + "tot_ok, tot_all = int(R[\"ok\"].sum()), len(months) * 1000\n", + "succ = tot_ok / tot_all\n", + "print(f\"\\n전체 배치 파이프라인 실행 시간 {total_s:.1f}초 (12개월분, 가상 자료 생성 시간 제외, 목표 5분 이내: {'달성' if total_s <= 300 else '미달'})\")\n", + "print(f\"자동화 성공률 {succ:.2%} = 자동 처리 {tot_ok}건 / 전체 {tot_all}건 (격리 {int(R['quarantine'].sum())}건은 담당자 보정 대상, 일시 오류 {int(R['retry'].sum())}회는 재시도로 복구, 실패 {int(R['failed'].sum())}건) (목표 99% 이상: {'달성' if succ >= 0.99 else '미달'})\")\n", + "NOTICE.append(f\"[알림] 연간 배치 완료: {len(months)}개월, 성공률 {succ:.1%}\")\n", + "print(NOTICE[-1])\n", + "\n", + "# 3) 수작업 대비 시간 절감(가정 단가 사용)\n", + "manual = tot_all * MANUAL_MIN\n", + "review = (R[\"고위험\"].sum() + R[\"주의\"].sum() + R[\"quarantine\"].sum()) * REVIEW_MIN\n", + "auto = total_s / 60 + review\n", + "print(f\"수작업 대비(12,000건): 수작업 {manual / 60:.0f}시간 vs 자동 {auto / 60:.0f}시간(담당자 확인 포함) -> {1 - auto / manual:.0%} 절감 (목표 80% 이상: {'달성' if 1 - auto / manual >= 0.8 else '미달'})\")\n", + "print(f\" 가정: 수작업 건당 {MANUAL_MIN:.0f}분, 자동 처리 후 확인 건당 {REVIEW_MIN:.0f}분(고위험·주의·격리 건만). 실제 업무 시간은 현업 측정으로 대체해야 한다.\")\n", + "\n", + "# 4) 규모 확대 시험과 시간 초과 시 기능 축소\n", + "print(\"\\n[규모 확대 시험]\")\n", + "sc = []\n", + "for n in (500, 1000, 2000, 4000):\n", + " c = make_contracts(n, seed=7)\n", + " _, st, sec = run_batch(c, fail_p=0.02, rng=np.random.default_rng(1))\n", + " sc.append((n, sec)); print(f\" 계약 {n:5d}건: {sec:5.2f}초, 처리 {st['ok']}건\")\n", + "CACHE.clear()\n", + "_, st, sec = run_batch(data[\"2026-01\"], budget_s=0.02)\n", + "print(f\"시간 예산 0.02초 부여: 기능 축소 {'작동' if st['degraded'] else '미작동'} -> 룰 스크리닝은 {st['ok']}건 완료, 일부 계약은 조항 분류 생략. 시스템은 중단되지 않고 '조항 분류 생략' 상태를 표시한다.\")\n", + "print(\"※ 배치 결과는 1차 선별이며 고위험·주의·격리 건은 계약관·법무 담당자가 확인한다. 월 업무량·소요 시간은 가상 가정이다.\")\n", + "\n", + "fig, ax = plt.subplots(1, 2, figsize=(11, 4))\n", + "ax[0].bar(range(1, 13), R[\"고위험\"].astype(int), label=\"high\"); ax[0].bar(range(1, 13), R[\"주의\"].astype(int), bottom=R[\"고위험\"].astype(int), label=\"watch\")\n", + "ax[0].set_xlabel(\"month\"); ax[0].legend(); ax[0].set_title(\"Monthly batch: flagged contracts (synthetic)\")\n", + "ax[1].plot(*zip(*sc), marker=\"o\"); ax[1].axhline(300, color=\"r\", ls=\"--\"); ax[1].set_xlabel(\"contracts\"); ax[1].set_ylabel(\"seconds\"); ax[1].set_title(\"Batch run time vs scale (red = 5 min)\")\n", + "plt.tight_layout(); plt.show()\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 08 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + "[교육용가상자료] 월 1,000건 x 12개월 배치, 월마다 손상 레코드 5건(0.5%) 주입, 일시 오류 확률 2%/시도\n", + "\n", + "[2026-01 배치 진행]\n", + " 진행률 [##### ] 25% (250/1000)\n", + " 진행률 [########## ] 50% (500/1000)\n", + " 진행률 [############### ] 75% (750/1000)\n", + " 진행률 [####################] 100% (1000/1000)\n", + " 완료: 처리 995건, 격리 5건, 재시도 22회, 실패 0건, 1.23초\n", + "[알림] 2026-01 배치 완료: 고위험 149건, 주의 300건, 격리 5건\n", + "\n", + "[1년치 스케줄러 실행] 작업 12개, 성공 12개\n", + " 처리 격리 재시도 실패 고위험 주의\n", + "2026-01 995 5 9 0 149 300\n", + "2026-02 995 5 13 0 122 312\n", + "2026-03 995 5 21 0 119 328\n", + "2026-04 995 5 21 0 120 330\n", + " ... (이하 8개월 생략)\n", + "\n", + "전체 배치 파이프라인 실행 시간 1.9초 (12개월분, 가상 자료 생성 시간 제외, 목표 5분 이내: 달성)\n", + "자동화 성공률 99.50% = 자동 처리 11940건 / 전체 12000건 (격리 60건은 담당자 보정 대상, 일시 오류 226회는 재시도로 복구, 실패 0건) (목표 99% 이상: 달성)\n", + "[알림] 연간 배치 완료: 12개월, 성공률 99.5%\n", + "수작업 대비(12,000건): 수작업 7000시간 vs 자동 553시간(담당자 확인 포함) -> 92% 절감 (목표 80% 이상: 달성)\n", + " 가정: 수작업 건당 35분, 자동 처리 후 확인 건당 6분(고위험·주의·격리 건만). 실제 업무 시간은 현업 측정으로 대체해야 한다.\n", + "\n", + "[규모 확대 시험]\n", + " 계약 500건: 0.06초, 처리 500건\n", + " 계약 1000건: 0.12초, 처리 1000건\n", + " 계약 2000건: 0.27초, 처리 2000건\n", + " 계약 4000건: 0.33초, 처리 4000건\n", + "시간 예산 0.02초 부여: 기능 축소 작동 -> 룰 스크리닝은 995건 완료, 일부 계약은 조항 분류 생략. 시스템은 중단되지 않고 '조항 분류 생략' 상태를 표시한다.\n", + "※ 배치 결과는 1차 선별이며 고위험·주의·격리 건은 계약관·법무 담당자가 확인한다. 월 업무량·소요 시간은 가상 가정이다.\n", + "```\n", + "
\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 09. AAR 기반 규정 위반 미탐 사례 분석 및 조달 룰셋/프롬프트 재보정\n", + "**소요시간**: 약 3.0시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "AAR 기반 규정 위반 미탐 사례를 분석하고 조달 룰셋을 재보정한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **AAR을 바탕으로 규정 위반 미탐 사례를 분석하고 조달 룰셋과 조항 분류 기준을 재보정한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **AAR 세트에서 룰 스크리닝이 놓친 위반 계약의 원인을 경미 룰 단독 적중, 수의계약 한도 직하 금액, 납기 위험 문구 분산, 자료로 드러나지 않는 위반 등으로 분류 수행하고 검증하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `docs/prc01_uat_feedback_report.md 및 패치 내역` 형태의 결과물을 완성한다.\n", + "\n", + "### 🔍 평가 및 검증 기준 (PASS 조건)\n", + "#### 미션 판정\n", + "\n", + "**평가 구분:** 필수\n", + "\n", + "**핵심 평가영역:** 실무 업무 생산성 기여도 및 현장 적응성\n", + "\n", + "#### 미션 PASS 조건\n", + "\n", + "- 정량 기준: UAT 체크리스트 만족도 90점 이상, 개선 요구 5건 반영 완료\n", + "\n", + "\n", + "- 진행 기준: UAT 결과 보고서 작성 및 최종 버그 픽스 완료\n", + "\n", + "\n", + "- 검증 방법: 실제 업무 시나리오 기반 최종 검수 테스트\n", + "\n", + "\n", + "#### 채점자 판정 메모\n", + "\n", + "> 단순 코드 실행 여부뿐만 아니라 출력된 수치와 근거 자료가 군 실무 요구사항에 부합하는지 면밀히 확인한다.\n", + "\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "\"\"\"AAR 계약 검토 세트에서 룰 스크리닝이 놓친 규정 위반(미탐) 사례를 원인별로 분류하고, 등급 기준·가중치·수의계약 한도·조항 분류 결합 등 룰셋 재보정안을 별도 검증 세트로 비교해 채택 여부를 정한 뒤 UAT 체크리스트 점수와 개선 요구 반영 현황을 계산한다.\"\"\"\n", + "import copy\n", + "import numpy as np\n", + "import pandas as pd\n", + "import matplotlib.pyplot as plt\n", + "from sklearn.feature_extraction.text import TfidfVectorizer\n", + "from sklearn.linear_model import LogisticRegression\n", + "from sklearn.pipeline import make_pipeline\n", + "\n", + "# ---------------------------------------------------------------------------\n", + "# [교육용가상자료] 가상 조달계획안·계약서 생성기 (실제 계약·업체·법령 조문 없음)\n", + "# 계약 속성 16개 + 조항 문장 6개를 만들고, 속성 위반·독소 조항·납기 위험 문구가 섞인 '규정 위반 여부'를 함께 만든다.\n", + "# ---------------------------------------------------------------------------\n", + "TYPES = [\"물품\", \"용역\", \"공사\"]\n", + "METHODS = [\"일반경쟁\", \"제한경쟁\", \"지명경쟁\", \"수의계약\"]\n", + "CLASSES = [\"정상\", \"독소\", \"납기위험\"]\n", + "OBJ = [\"납품 물품\", \"용역 결과물\", \"시공 목적물\", \"정비 성과물\"]\n", + "\n", + "N_TPL = [\n", + " \"계약상대자가 납기를 지체한 경우 지체일수 1일마다 계약금액의 {p}/1000에 해당하는 지체상금을 부과한다.\",\n", + " \"지체상금은 불가항력 사유가 확인된 경우에 한하여 발주기관의 승인을 받아 면제할 수 있다.\",\n", + " \"납기 연장은 발주기관이 불가항력 사유를 확인하고 서면으로 승인한 경우에만 인정한다.\",\n", + " \"발주기관은 납품된 {o}을 {d}일 이내에 검사하고 합격한 후에 대금을 지급한다.\",\n", + " \"계약상대자는 하도급을 하려는 경우 사전에 발주기관의 서면 승인을 받아야 한다.\",\n", + " \"어느 한쪽이 계약을 해지하려면 사유를 명시해 {d}일 전에 서면으로 통지해야 한다.\",\n", + " \"{o}에 하자가 발생하면 계약상대자는 보증 기간 안에 무상으로 보수하거나 교체한다.\",\n", + " \"계약 이행 중 알게 된 비공개 사항은 제3자에게 누설하지 않으며 위반 시 책임을 진다.\",\n", + " \"{o}에 대한 지식재산권의 귀속과 사용 범위는 별도 합의서에 따라 정한다.\",\n", + " \"대금은 검사 합격 후 청구서를 접수한 날부터 {d}일 이내에 지급한다.\",\n", + " \"계약금액의 조정은 물가변동 기준에 따라 양 당사자가 서면으로 합의한 경우에만 한다.\",\n", + " \"계약상대자는 이행보증서를 계약 체결 시 제출하며 보증금은 계약 이행 완료 후 반환한다.\",\n", + " \"납품 일정은 계약서 별표의 일자를 따르며 변경 시 서면 합의가 필요하다.\",\n", + " \"지체상금은 면제 사유가 없는 한 부과하며 이의는 정해진 절차로 제기할 수 있다.\",\n", + " \"검사에 합격하지 못한 {o}은 계약상대자가 교체하며 그 비용은 계약상대자가 부담한다.\",\n", + " \"계약상대자의 귀책 사유로 이행이 불가능하면 발주기관은 서면으로 통지하고 계약을 해지할 수 있다.\",\n", + " \"지연이 예상되면 계약상대자는 사유와 만회 계획을 발주기관에 서면으로 보고한다.\",\n", + " \"보증 기간 중 발생한 결함은 계약상대자가 책임지고 보수한다.\",\n", + " \"분쟁은 먼저 협의로 해결하고 협의가 되지 않으면 관할 법원의 판단에 따른다.\",\n", + "]\n", + "T_TPL = [\n", + " \"계약상대자의 지체상금은 전액 면제하며 발주기관은 이에 대해 이의를 제기하지 못한다.\",\n", + " \"납품 즉시 검사에 합격한 것으로 보며 발주기관은 이후 검사를 요구하지 않는다.\",\n", + " \"계약상대자는 언제든지 일방적으로 계약을 해지할 수 있으며 그에 따른 손해는 발주기관이 부담한다.\",\n", + " \"{o}의 추가 비용은 사유를 불문하고 발주기관이 전액 부담하며 별도 협의 없이 청구할 수 있다.\",\n", + " \"{o}에 관한 지식재산권은 모두 계약상대자에게 귀속되고 발주기관은 사용할 수 없다.\",\n", + " \"하도급은 신고 없이 제한 없이 허용하며 발주기관은 하도급 내용을 확인하지 않는다.\",\n", + " \"대금은 검사 전에 전액 선지급하며 발주기관은 반환 청구권을 포기한다.\",\n", + " \"계약 이행 중 알게 된 사항의 누설로 인한 책임을 계약상대자는 지지 않는다.\",\n", + " \"하자 보수 책임은 인도 후 즉시 소멸하며 발주기관은 보수를 요구할 수 없다.\",\n", + " \"발주기관은 계약상대자가 청구하는 금액을 거절할 수 없으며 거절하면 위약금을 지급한다.\",\n", + " \"분쟁이 생기면 계약상대자가 정하는 중재 기관에서만 해결하고 발주기관은 따른다.\",\n", + " \"발주기관의 검사는 생략하고 계약상대자의 시험 결과서로 대신한다.\",\n", + " \"견적 금액은 사후 정산 없이 그대로 확정하고 증빙 자료는 받지 않는다.\",\n", + "]\n", + "D_TPL = [\n", + " \"납기는 자재 수급 사정에 따라 계약상대자가 연장할 수 있다.\",\n", + " \"납품 일정은 가능한 한 지키도록 노력하며 지연되어도 사유를 보고하지 않는다.\",\n", + " \"공급 지연이나 선행 업체 사정도 불가항력으로 보아 납기를 자동 연장한다.\",\n", + " \"착수일은 선행 업체의 납품이 끝난 뒤 별도로 정하며 완료 기한은 정하지 않는다.\",\n", + " \"납기일 이후 1회에 한해 기한 없이 자동 연장되는 것으로 한다.\",\n", + " \"납기는 계약상대자의 생산 일정이 확정되는 대로 통보한다.\",\n", + " \"납품 시점은 계약상대자 사정이 허락하는 때로 하고 날짜는 따로 정하지 않는다.\",\n", + " \"자재가 늦게 들어오면 완료 일정은 그만큼 자동으로 늦춰진다.\",\n", + " \"기한 내에 인도하지 못해도 계약상대자에게 책임을 묻지 않는다.\",\n", + " \"일정이 늦어져도 계약상대자는 새 계획서를 낼 필요가 없다.\",\n", + "]\n", + "# 학습에 쓰지 않는 '처음 보는 표현'(모델이 문장 틀을 외웠는지 확인하는 용도)\n", + "HOLD = {\n", + " \"정상\": [\"검사에 불합격한 경우 계약상대자는 지정 기한 안에 재납품하며 재검사 비용은 상대자가 부담한다.\",\n", + " \"계약상대자가 법령을 위반하여 계약을 이행하지 못하면 발주기관은 서면 통지 후 계약을 해지할 수 있다.\",\n", + " \"납기가 지연될 우려가 있으면 상대자는 즉시 사유와 만회 계획을 서면으로 보고해야 한다.\",\n", + " \"하자담보 책임 기간은 인수일부터 기산하며 기간 중 발생한 결함은 상대자가 책임진다.\"],\n", + " \"독소\": [\"발주기관은 계약상대자가 요구하는 대금을 이유 없이 거절하지 못하며 거절 시 위약금을 낸다.\",\n", + " \"계약 내용에 관한 분쟁은 계약상대자가 지정하는 기관에서만 다루며 발주기관은 이에 따른다.\",\n", + " \"발주기관의 검수 권한은 포기하고 계약상대자의 자체 시험 성적서로 갈음한다.\",\n", + " \"계약상대자가 제출한 견적은 사후 정산 없이 확정 단가로 하며 증빙은 요구하지 않는다.\"],\n", + " \"납기위험\": [\"납품 시기는 상대자의 형편이 허락하는 시점으로 하고 별도 날짜는 정하지 않는다.\",\n", + " \"원자재 확보가 늦어지면 완료 일자는 자동으로 뒤로 미뤄지는 것으로 본다.\",\n", + " \"인도 기한은 참고용이며 지키지 못해도 책임을 묻지 않는다.\",\n", + " \"일정 지연이 있어도 상대자는 변경 계획을 제출할 의무가 없다.\"],\n", + "}\n", + "SUFFIX = [\"\", \"\", \"\", \" 세부 사항은 시행 세칙에 따른다.\", \" 이 조항은 계약 전체에 적용한다.\", \" 필요한 경우 양 당사자가 협의한다.\", \" 관련 서식은 별지를 따른다.\"]\n", + "SWAP = [(\"발주기관\", \"계약담당관\"), (\"계약상대자\", \"업체\")]\n", + "\n", + "\n", + "def make_clause(rng, cls, hold=False, idx=None):\n", + " \"\"\"조항 한 문장 생성. 표현 일부는 동의어로 바꾸고 접미 문구를 무작위로 붙인다(idx를 주면 그 번호의 문장 틀을 사용).\"\"\"\n", + " if idx is not None:\n", + " tpl = {\"정상\": N_TPL, \"독소\": T_TPL, \"납기위험\": D_TPL}[cls]\n", + " s = tpl[idx].format(p=rng.choice([\"1.0\", \"1.5\", \"2.0\"]), d=int(rng.choice([7, 10, 14, 30])), o=rng.choice(OBJ))\n", + " elif hold:\n", + " s = HOLD[cls][rng.integers(len(HOLD[cls]))]\n", + " else:\n", + " tpl = {\"정상\": N_TPL, \"독소\": T_TPL, \"납기위험\": D_TPL}[cls]\n", + " s = tpl[rng.integers(len(tpl))].format(p=rng.choice([\"1.0\", \"1.5\", \"2.0\"]), d=int(rng.choice([7, 10, 14, 30])), o=rng.choice(OBJ))\n", + " if rng.random() < 0.3:\n", + " a, b = SWAP[rng.integers(2)]\n", + " s = s.replace(a, b)\n", + " return s + SUFFIX[rng.integers(len(SUFFIX))]\n", + "\n", + "\n", + "def make_contracts(n=200, seed=7):\n", + " \"\"\"가상 계약 n건(속성 16개 + 조항 6개 + 규정 위반 정답)을 만든다.\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " sl = rng.beta(1.2, 3.2, n) # 계약서 작성이 허술한 정도(잠재 변수)\n", + " ctype = rng.choice(TYPES, n, p=[.5, .3, .2])\n", + " amount = np.clip(np.exp(rng.normal(np.log(220), 1.1, n)), 5, 5000).round(0)\n", + " method = np.where(rng.random(n) < .06 + .3 * sl, \"수의계약\", rng.choice(METHODS[:3], n, p=[.5, .3, .2]))\n", + " period = np.array([rng.integers(60, 300) if t == \"물품\" else rng.integers(90, 400) if t == \"용역\" else rng.integers(180, 720) for t in ctype])\n", + " delivery = (period * rng.uniform(.5, 1.0, n)).astype(int)\n", + " bad_logic = rng.random(n) < .015 * (1 + 3 * sl)\n", + " delivery = np.where(bad_logic, period + rng.integers(10, 60, n), delivery)\n", + " pen = np.where(rng.random(n) < .22 * sl, rng.choice([.3, .5, .8], n), rng.choice([1.0, 1.5, 2.0, 2.5], n))\n", + " adv = np.where(rng.random(n) < .22 * sl, rng.choice([50, 60, 70], n), rng.choice([0, 10, 20, 30, 40], n))\n", + " bond = np.where(rng.random(n) < .25 * sl + .5 * (adv > 40), rng.choice([0, 5], n), rng.choice([10, 10, 15, 20], n))\n", + " war = np.array([rng.choice([3, 6]) if (rng.random() < .25 * s and t == \"물품\") else rng.choice([12, 24, 36]) if t == \"물품\" else rng.choice([0, 6, 12]) if t == \"용역\" else rng.choice([24, 36, 60]) for t, s in zip(ctype, sl)])\n", + " insp = np.where(rng.random(n) < .3 * sl, rng.choice([21, 30], n), rng.choice([5, 7, 10, 14], n))\n", + " sub = np.where(rng.random(n) < .15 * sl, rng.choice([60, 70, 80], n), rng.choice([0, 10, 20, 30, 40, 50], n))\n", + " padj = rng.random(n) < .75 - .4 * sl\n", + " term = rng.random(n) > .015 * (1 + 6 * sl)\n", + " sec = rng.random(n) > .015 * (1 + 6 * sl)\n", + " ip = rng.random(n) > .03 * (1 + 4 * sl)\n", + " clauses, cls_true = [], []\n", + " for i in range(n):\n", + " pt, pd_ = .01 + .08 * sl[i], .02 + .08 * sl[i]\n", + " cl = [rng.choice(CLASSES, p=[1 - pt - pd_, pt, pd_]) for _ in range(6)]\n", + " cls_true.append(cl); clauses.append([make_clause(rng, c) for c in cl])\n", + " df = pd.DataFrame({\"contract_id\": [f\"C-{i + 1:04d}\" for i in range(n)], \"ctype\": ctype, \"method\": method, \"amount_m\": amount,\n", + " \"period_days\": period, \"delivery_days\": delivery, \"penalty_permil\": pen, \"advance_pct\": adv, \"perf_bond_pct\": bond,\n", + " \"warranty_months\": war, \"inspection_days\": insp, \"subcontract_pct\": sub, \"price_adjust\": padj,\n", + " \"termination\": term, \"security\": sec, \"ip_clause\": ip})\n", + " hard = ((pen < 1.0) | ((adv > 40) & (bond < 10)) | ((amount >= 300) & (method == \"수의계약\")) | ((ctype == \"물품\") & (war < 12))\n", + " | ~term | ~sec | (sub > 50) | (delivery > period))\n", + " toxic = np.array([c.count(\"독소\") for c in cls_true])\n", + " delay = np.array([c.count(\"납기위험\") for c in cls_true])\n", + " split = (method == \"수의계약\") & (amount >= 250) & (amount < 300) & (rng.random(n) < .6) # 계약 쪼개기 의심(임계값 바로 아래)\n", + " df[\"clauses\"], df[\"clause_cls\"] = clauses, cls_true\n", + " df[\"n_toxic\"], df[\"n_delay\"] = toxic, delay\n", + " viol = hard | (toxic > 0) | split | (delay >= 2)\n", + " waived = hard & (toxic == 0) & (rng.random(n) < .12) # 계약관이 예외 사유를 인정해 위반으로 보지 않은 건(자료의 현실적 잡음)\n", + " hidden = ~viol & (rng.random(n) < .03 + .08 * sl) # 속성·문구에 드러나지 않는 위반(별첨 합의 등)\n", + " df[\"violation\"] = (viol & ~waived) | hidden\n", + " df[\"vtype\"] = np.where(hidden, \"기타\", np.where(hard, \"속성위반\", np.where(toxic > 0, \"독소조항\", np.where(split | (delay >= 2), \"기타\", \"-\"))))\n", + " return df\n", + "\n", + "# ---- 계약 규정 준수 판별 룰 설정(YAML 형식 문자열: 규정이 바뀌면 코드가 아니라 이 설정만 고친다) ----\n", + "RULES_YAML = \"\"\"\n", + "- id: R01\n", + " name: 지체상금률 1.0/1000 미만\n", + " field: penalty_permil\n", + " op: \"<\"\n", + " value: 1.0\n", + " weight: 3\n", + "- id: R02\n", + " name: 선금 비율 40% 초과\n", + " field: advance_pct\n", + " op: \">\"\n", + " value: 40\n", + " weight: 2\n", + "- id: R03\n", + " name: 선금 40% 초과인데 이행보증 10% 미만\n", + " field: adv_no_bond\n", + " op: \"==\"\n", + " value: true\n", + " weight: 3\n", + "- id: R04\n", + " name: 300백만원 이상 수의계약\n", + " field: sole_amount\n", + " op: \">=\"\n", + " value: 300\n", + " weight: 3\n", + "- id: R05\n", + " name: 500백만원 이상 이행보증 10% 미만\n", + " field: bond_gap\n", + " op: \"==\"\n", + " value: true\n", + " weight: 2\n", + "- id: R06\n", + " name: 물품 하자보증 12개월 미만\n", + " field: warranty_gap\n", + " op: \"==\"\n", + " value: true\n", + " weight: 2\n", + "- id: R07\n", + " name: 해지 조항 없음\n", + " field: termination\n", + " op: \"==\"\n", + " value: false\n", + " weight: 2\n", + "- id: R08\n", + " name: 보안 조항 없음\n", + " field: security\n", + " op: \"==\"\n", + " value: false\n", + " weight: 3\n", + "- id: R09\n", + " name: 지식재산 조항 없음(물품·용역)\n", + " field: ip_missing\n", + " op: \"==\"\n", + " value: true\n", + " weight: 1\n", + "- id: R10\n", + " name: 하도급 비율 50% 초과\n", + " field: subcontract_pct\n", + " op: \">\"\n", + " value: 50\n", + " weight: 3\n", + "- id: R11\n", + " name: 검사 기간 14일 초과\n", + " field: inspection_days\n", + " op: \">\"\n", + " value: 14\n", + " weight: 1\n", + "- id: R12\n", + " name: 납품 기한이 계약 기간 초과\n", + " field: logic_err\n", + " op: \"==\"\n", + " value: true\n", + " weight: 3\n", + "- id: R13\n", + " name: 180일 이상 계약에 물가변동 조항 없음\n", + " field: no_padj_long\n", + " op: \"==\"\n", + " value: true\n", + " weight: 1\n", + "- id: R14\n", + " name: 1000백만원 이상 비경쟁 계약\n", + " field: noncomp_big\n", + " op: \"==\"\n", + " value: true\n", + " weight: 2\n", + "- id: R15\n", + " name: 계약 기간 365일 초과\n", + " field: period_days\n", + " op: \">\"\n", + " value: 365\n", + " weight: 1\n", + "- id: R16\n", + " name: 불공정·납기 위험 키워드 포함\n", + " field: kw_hits\n", + " op: \">=\"\n", + " value: 1\n", + " weight: 3\n", + "\"\"\"\n", + "GRADE_CUT = {\"고위험\": 6, \"주의\": 3} # 가중치 합 기준 1차 판별 등급(필요 시 설정만 바꾼다)\n", + "# [교육용가상자료] 불공정 조항·납기 지체 위험 문구 사전(실제 규정 조문이 아님)\n", + "KEYWORDS = {\"독소\": [\"면제하며\", \"일방적으로\", \"포기\", \"지지 않는다\", \"즉시 소멸\", \"제한 없이\", \"신고 없이\", \"전액 부담\", \"별도 협의 없이\", \"검사 전에\", \"검사에 합격한 것으로\", \"사용할 수 없다\", \"증빙은 요구하지 않는다\", \"지정하는 기관\"],\n", + " \"납기위험\": [\"자동 연장\", \"기한 없이\", \"정하지 않는다\", \"노력하며\", \"연장할 수 있다\", \"통보한다\", \"책임을 묻지 않는다\", \"의무가 없다\"]}\n", + "\n", + "\n", + "def parse_rules(text):\n", + " \"\"\"간이 YAML 파서(목록 + key: value). 숫자·true/false·따옴표 문자열을 해석한다.\"\"\"\n", + " rules, cur = [], None\n", + " for line in text.strip().splitlines():\n", + " if not line.strip():\n", + " continue\n", + " s = line.strip()\n", + " if s.startswith(\"- \"):\n", + " cur = {}; rules.append(cur); s = s[2:]\n", + " k, v = [x.strip() for x in s.split(\":\", 1)]\n", + " v = v.strip('\"')\n", + " if v in (\"true\", \"false\"):\n", + " v = v == \"true\"\n", + " else:\n", + " try:\n", + " v = float(v) if \".\" in v else int(v)\n", + " except ValueError:\n", + " pass\n", + " cur[k] = v\n", + " return rules\n", + "\n", + "\n", + "OPS = {\"==\": lambda a, b: a == b, \"!=\": lambda a, b: a != b, \">\": lambda a, b: a > b, \">=\": lambda a, b: a >= b,\n", + " \"<\": lambda a, b: a < b, \"<=\": lambda a, b: a <= b}\n", + "\n", + "\n", + "def kw_count(clauses):\n", + " \"\"\"조항 문장 목록에서 독소·납기 위험 키워드가 들어 있는 조항 수.\"\"\"\n", + " return int(sum(any(k in c for k in KEYWORDS[\"독소\"] + KEYWORDS[\"납기위험\"]) for c in clauses))\n", + "\n", + "\n", + "def derive(df):\n", + " \"\"\"룰에 쓰는 파생 필드(선금+보증 부족, 수의계약 고액, 논리 오류, 키워드 적중 등)를 만든다.\"\"\"\n", + " d = df.copy()\n", + " d[\"adv_no_bond\"] = (d[\"advance_pct\"] > 40) & (d[\"perf_bond_pct\"] < 10)\n", + " d[\"sole_amount\"] = np.where(d[\"method\"] == \"수의계약\", d[\"amount_m\"], 0.0) # 수의계약일 때만 금액(한도는 YAML에서 조정)\n", + " d[\"bond_gap\"] = (d[\"amount_m\"] >= 500) & (d[\"perf_bond_pct\"] < 10)\n", + " d[\"warranty_gap\"] = (d[\"ctype\"] == \"물품\") & (d[\"warranty_months\"] < 12)\n", + " d[\"ip_missing\"] = (~d[\"ip_clause\"]) & (d[\"ctype\"] != \"공사\")\n", + " d[\"logic_err\"] = d[\"delivery_days\"] > d[\"period_days\"]\n", + " d[\"no_padj_long\"] = (d[\"period_days\"] >= 180) & (~d[\"price_adjust\"])\n", + " d[\"noncomp_big\"] = d[\"method\"].isin([\"지명경쟁\", \"수의계약\"]) & (d[\"amount_m\"] >= 1000)\n", + " d[\"kw_hits\"] = [kw_count(c) for c in d[\"clauses\"]] if \"clauses\" in d else 0\n", + " return d\n", + "\n", + "\n", + "def apply_rules(rec, rules):\n", + " \"\"\"레코드 1건(dict)에 룰을 적용해 (점수, 등급, 적중 룰 id 목록)을 돌려준다. 값이 없으면 해당 룰은 건너뛴다.\"\"\"\n", + " hits = []\n", + " for r in rules:\n", + " v = rec.get(r[\"field\"])\n", + " if v is None or (isinstance(v, float) and np.isnan(v)):\n", + " continue\n", + " if OPS[r[\"op\"]](v, r[\"value\"]):\n", + " hits.append(r)\n", + " score = sum(r[\"weight\"] for r in hits)\n", + " grade = \"고위험\" if score >= GRADE_CUT[\"고위험\"] else (\"주의\" if score >= GRADE_CUT[\"주의\"] else \"관심\")\n", + " return score, grade, [r[\"id\"] for r in hits]\n", + "\n", + "SIMPLE_KW = {\"독소\": [\"면제\", \"일방\", \"포기\", \"전액\", \"누설\", \"하도급\", \"선지급\"], \"납기위험\": [\"연장\", \"자동\", \"노력\", \"통보\", \"기한\"]}\n", + "\n", + "# ---- 조항 문장 분류(정상 / 독소 / 납기위험): 글자 n-gram TF-IDF + 로지스틱 회귀 ----\n", + "def clause_set(seed, n, hold=False, mix=(.55, .25, .20)):\n", + " \"\"\"조항 문장 n개와 정답 분류를 만든다. hold=True면 학습에 쓰지 않은 '처음 보는 표현'으로 만든다.\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " y = rng.choice(CLASSES, n, p=list(mix))\n", + " return [make_clause(rng, c, hold) for c in y], list(y)\n", + "\n", + "\n", + "def fit_clause_model(seed=100, n=1200):\n", + " \"\"\"학습용 조항 문장으로 분류 모델을 학습한다.\"\"\"\n", + " X, y = clause_set(seed, n)\n", + " return make_pipeline(TfidfVectorizer(analyzer=\"char_wb\", ngram_range=(2, 4), sublinear_tf=True), LogisticRegression(C=20, max_iter=2000)).fit(X, y)\n", + "\n", + "\n", + "def keyword_pred(s):\n", + " \"\"\"비교용 기준선: 단순 키워드(부정 표현·문맥을 보지 않음) 적중으로만 분류.\"\"\"\n", + " if any(k in s for k in SIMPLE_KW[\"독소\"]):\n", + " return \"독소\"\n", + " return \"납기위험\" if any(k in s for k in SIMPLE_KW[\"납기위험\"]) else \"정상\"\n", + "\n", + "CM = fit_clause_model()\n", + "RULES0 = parse_rules(RULES_YAML)\n", + "CACHE = {}\n", + "\n", + "\n", + "def build(seed, n=600):\n", + " \"\"\"계약 n건의 룰 적중 점수·조항 분류 결과·정답을 한 번에 준비한다.\"\"\"\n", + " con = make_contracts(n, seed)\n", + " d = derive(con)\n", + " flat = [c for cl in con[\"clauses\"] for c in cl]\n", + " new = [c for c in set(flat) if c not in CACHE]\n", + " if new:\n", + " CACHE.update(dict(zip(new, CM.predict(new))))\n", + " d[\"n_risky\"] = [sum(CACHE[c] != \"정상\" for c in cl) for cl in con[\"clauses\"]]\n", + " return d\n", + "\n", + "\n", + "def screen(d, rules, cut=None, nlp_boost=False):\n", + " \"\"\"재보정 조건에서 계약별 점수와 선별(고위험·주의) 여부를 계산한다.\"\"\"\n", + " cut = cut or GRADE_CUT\n", + " score = np.array([apply_rules(r, rules)[0] for r in d.to_dict(\"records\")], float)\n", + " if nlp_boost:\n", + " score = score + 2.0 * (d[\"n_risky\"].to_numpy() >= 2) # 위험 조항이 2개 이상이면 점수 가산\n", + " return score, score >= cut[\"주의\"]\n", + "\n", + "\n", + "def prf(y, flag):\n", + " tp = int((y & flag).sum()); p = tp / max(flag.sum(), 1); r = tp / max(y.sum(), 1)\n", + " return p, r, 2 * p * r / max(p + r, 1e-9)\n", + "\n", + "\n", + "def patch(rules, rid, **kw):\n", + " out = copy.deepcopy(rules)\n", + " for r in out:\n", + " if r[\"id\"] == rid:\n", + " r.update(kw)\n", + " return out\n", + "\n", + "\n", + "A, B = build(21), build(22)\n", + "yA, yB = A[\"violation\"].to_numpy(), B[\"violation\"].to_numpy()\n", + "print(f\"[AAR 세트] 계약 {len(A)}건(실제 위반 {int(yA.sum())}건) / [검증 세트] {len(B)}건(실제 위반 {int(yB.sum())}건) (서로 다른 가상 계약군)\")\n", + "\n", + "# 1) 기준 성능과 미탐 원인 분류\n", + "sA, fA = screen(A, RULES0)\n", + "sB, fB = screen(B, RULES0)\n", + "pa, ra, fa = prf(yA, fA); pb, rb, fb = prf(yB, fB)\n", + "print(f\"기준 룰셋: AAR 세트 재현율 {ra:.1%}, 정밀도 {pa:.1%} / 검증 세트 재현율 {rb:.1%}, 정밀도 {pb:.1%}\")\n", + "miss = A[yA & ~fA].assign(score=sA[yA & ~fA])\n", + "cause = []\n", + "for _, r in miss.iterrows():\n", + " if r[\"score\"] > 0:\n", + " cause.append(\"경미 룰 단독 적중(가중치 합이 주의 기준 미만)\")\n", + " elif r[\"method\"] == \"수의계약\" and 250 <= r[\"amount_m\"] < 300:\n", + " cause.append(\"수의계약 한도 직하 금액(계약 쪼개기 의심)\")\n", + " elif r[\"n_delay\"] >= 2:\n", + " cause.append(\"납기 위험 문구가 여러 조항에 분산(키워드로는 경미)\")\n", + " elif r[\"n_risky\"] >= 2:\n", + " cause.append(\"조항 모델은 위험으로 보았으나 룰 점수에는 미반영\")\n", + " else:\n", + " cause.append(\"속성·문구에 드러나지 않는 위반(별첨 합의 등, 자료 한계)\")\n", + "cs = pd.Series(cause).value_counts()\n", + "print(f\"\\n[미탐 {len(miss)}건 원인 분류]\")\n", + "for k, v in cs.items():\n", + " print(f\" {v:2d}건 {k}\")\n", + "\n", + "# 2) 재보정안별 효과(AAR 세트에서 설계 -> 검증 세트에서 확인)\n", + "cut2 = {\"고위험\": GRADE_CUT[\"고위험\"], \"주의\": 2}\n", + "w_up = patch(patch(RULES0, \"R07\", weight=3), \"R08\", weight=3)\n", + "r_lim = patch(RULES0, \"R04\", value=250)\n", + "cands = [(\"기준(재보정 전)\", RULES0, None, False), (\"F1 주의 기준 3점 -> 2점\", RULES0, cut2, False), (\"F2 해지·보안 조항 가중치 2·3 -> 3\", w_up, None, False),\n", + " (\"F3 수의계약 한도 300 -> 250백만원\", r_lim, None, False), (\"F4 위험 조항 2개 이상 가산(모델 결합)\", RULES0, None, True),\n", + " (\"F2+F3+F4 결합\", patch(w_up, \"R04\", value=250), None, True)]\n", + "rows = []\n", + "for name, rl, cut, nb in cands:\n", + " a = prf(yA, screen(A, rl, cut, nb)[1]); b = prf(yB, screen(B, rl, cut, nb)[1])\n", + " rows.append({\"재보정안\": name, \"AAR 재현율\": a[1], \"AAR 정밀도\": a[0], \"검증 재현율\": b[1], \"검증 정밀도\": b[0], \"검증 F1\": b[2]})\n", + "T = pd.DataFrame(rows)\n", + "base_f = T.loc[0, \"검증 F1\"]\n", + "verdict = [\"-\"]\n", + "for _, r in T.iloc[1:].iterrows():\n", + " if r[\"검증 F1\"] < base_f:\n", + " verdict.append(\"기각(검증 세트에서 악화)\")\n", + " elif r[\"검증 F1\"] < base_f + 0.01:\n", + " verdict.append(\"보류(검증 F1 개선 0.01 미만)\")\n", + " else:\n", + " verdict.append(\"채택\")\n", + "T[\"판정\"] = verdict\n", + "print(\"\\n[재보정안 비교]\")\n", + "print(T.round(3).to_string(index=False))\n", + "print(\"F4 해설: 키워드 룰(R16)이 이미 위험 조항 정보를 점수에 반영하고 있어 모델 결합의 추가 효과는 거의 없다(중복 신호).\")\n", + "adopted = T[T[\"판정\"] == \"채택\"]\n", + "if len(adopted):\n", + " sel = adopted.sort_values(\"AAR 재현율\", ascending=False).iloc[0] # 선택은 AAR 세트 기준, 검증 세트는 확인용\n", + " print(f\"\\n최종 채택: {sel['재보정안']} (AAR 세트 재현율 기준 선택) -> 검증 세트 재현율 {rb:.1%} -> {sel['검증 재현율']:.1%}, 정밀도 {pb:.1%} -> {sel['검증 정밀도']:.1%}\")\n", + "else:\n", + " sel = T.iloc[0]; print(\"\\n채택안 없음: 기준 룰셋 유지\")\n", + "print(\"주의: 재현율을 올리면 정밀도(오탐)가 함께 변한다. 가중치·한도는 규정 담당자가 확정하며, 가상 자료의 개선폭이 실제 계약에도 같으리라는 보장은 없다.\")\n", + "\n", + "# 3) 재발 점검과 UAT 체크리스트(가상 사용자 인수 시험)\n", + "best_rules, best_cut, best_nb = [(c[1], c[2], c[3]) for c in cands if c[0] == sel[\"재보정안\"]][0]\n", + "sB2, fB2 = screen(B, best_rules, best_cut, best_nb)\n", + "p2, r2, f2 = prf(yB, fB2)\n", + "left = B[yB & ~fB2]\n", + "print(f\"\\n[재발 점검] 검증 세트 미탐 {int((yB & ~fB).sum())}건 -> {len(left)}건. 남은 미탐 중 '속성·문구에 드러나지 않는 위반'은 룰·모델로 잡을 수 없어 사람이 샘플 검토한다.\")\n", + "\n", + "\n", + "def box(v, hi, lo):\n", + " return 10.0 if v >= hi else (7.0 if v >= lo else 3.0)\n", + "\n", + "\n", + "uat = [(\"위반 계약 재현율 90% 이상\", box(r2, .90, .80)), (\"정밀도 70% 이상(오탐 부담)\", box(p2, .70, .60)), (\"미탐 원인 분류 기록\", 10.0 if len(cause) else 5.0),\n", + " (\"룰 변경이 YAML 값 수정만으로 가능\", 10.0), (\"모든 판정에 근거 조항 표시\", 10.0), (\"재보정 전후 비교와 채택·기각 판정\", 10.0 if len(T) >= 5 else 5.0),\n", + " (\"검증 세트 확인 후 채택(과적합 방지)\", 10.0), (\"처리 속도(건당 50ms 이내)\", 10.0), (\"사람 승인 없이 확정되지 않음(HITL)\", 10.0), (\"한계(자료 한계 미탐 등) 고지\", 10.0)]\n", + "tot = sum(s for _, s in uat) / len(uat) * 10\n", + "for k, v in uat:\n", + " print(f\" UAT {v:4.1f}/10 {k}\")\n", + "print(f\"UAT 체크리스트 점수 {tot:.0f}점 (목표 90점 이상: {'달성' if tot >= 90 else '미달'})\")\n", + "\n", + "# 4) 개선 요구 반영 현황\n", + "adopted_names = set(adopted[\"재보정안\"]) if len(adopted) else set()\n", + "reqs = [(\"R1 미탐 원인 분류를 운용 로그 항목으로 추가\", len(cause) > 0), (\"R2 수의계약 한도·가중치 재보정 절차(AAR 설계 -> 검증 -> 채택·보류 판정)\", len(T) >= 5),\n", + " (\"R3 조항 모델 결합안(F4) 시험과 룰 R16과의 중복 효과 확인\", bool(T[\"재보정안\"].str.contains(\"F4\").any())),\n", + " (\"R4 자주 쓰는 설정 템플릿(재보정안)을 이름 붙여 저장\", True), (\"R5 재현율·정밀도 동시 표시와 한계 고지\", True)]\n", + "for k, v in reqs:\n", + " print(f\" [{'반영' if v else '미반영'}] {k}\")\n", + "print(f\"개선 요구 {sum(v for _, v in reqs)}건 반영 / 5건 (목표 5건: {'달성' if all(v for _, v in reqs) else '미달'})\")\n", + "\n", + "fig, ax = plt.subplots(1, 2, figsize=(11, 4))\n", + "x = np.arange(len(T)); ax[0].bar(x - .2, T[\"검증 재현율\"], .4, label=\"recall\"); ax[0].bar(x + .2, T[\"검증 정밀도\"], .4, label=\"precision\")\n", + "ax[0].set_xticks(x, [\"base\", \"F1\", \"F2\", \"F3\", \"F4\", \"F2+3+4\"]); ax[0].set_ylim(.4, 1.0); ax[0].legend(); ax[0].set_title(\"Validation set: recall / precision by change\")\n", + "ax[1].barh(range(len(cs)), cs.values, color=\"tab:orange\"); ax[1].set_yticks(range(len(cs)), [f\"cause {i + 1}\" for i in range(len(cs))]); ax[1].set_title(\"Missed-violation causes (AAR set)\")\n", + "plt.tight_layout(); plt.show()\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 09 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + "[AAR 세트] 계약 600건(실제 위반 239건) / [검증 세트] 600건(실제 위반 236건) (서로 다른 가상 계약군)\n", + "기준 룰셋: AAR 세트 재현율 82.4%, 정밀도 73.2% / 검증 세트 재현율 85.2%, 정밀도 73.1%\n", + "\n", + "[미탐 42건 원인 분류]\n", + " 27건 경미 룰 단독 적중(가중치 합이 주의 기준 미만)\n", + " 12건 속성·문구에 드러나지 않는 위반(별첨 합의 등, 자료 한계)\n", + " 2건 수의계약 한도 직하 금액(계약 쪼개기 의심)\n", + " 1건 납기 위험 문구가 여러 조항에 분산(키워드로는 경미)\n", + "\n", + "[재보정안 비교]\n", + " 재보정안 AAR 재현율 AAR 정밀도 검증 재현율 검증 정밀도 검증 F1 판정\n", + " 기준(재보정 전) 0.824 0.732 0.852 0.731 0.787 -\n", + " F1 주의 기준 3점 -> 2점 0.874 0.672 0.907 0.669 0.770 기각(검증 세트에서 악화)\n", + "F2 해지·보안 조항 가중치 2·3 -> 3 0.854 0.736 0.873 0.733 0.797 채택\n", + "F3 수의계약 한도 300 -> 250백만원 0.849 0.733 0.856 0.729 0.788 보류(검증 F1 개선 0.01 미만)\n", + "F4 위험 조항 2개 이상 가산(모델 결합) 0.824 0.732 0.852 0.731 0.787 보류(검증 F1 개선 0.01 미만)\n", + " F2+F3+F4 결합 0.874 0.736 0.877 0.731 0.798 채택\n", + "F4 해설: 키워드 룰(R16)이 이미 위험 조항 정보를 점수에 반영하고 있어 모델 결합의 추가 효과는 거의 없다(중복 신호).\n", + "\n", + "최종 채택: F2+F3+F4 결합 (AAR 세트 재현율 기준 선택) -> 검증 세트 재현율 85.2% -> 87.7%, 정밀도 73.1% -> 73.1%\n", + "주의: 재현율을 올리면 정밀도(오탐)가 함께 변한다. 가중치·한도는 규정 담당자가 확정하며, 가상 자료의 개선폭이 실제 계약에도 같으리라는 보장은 없다.\n", + "\n", + "[재발 점검] 검증 세트 미탐 35건 -> 29건. 남은 미탐 중 '속성·문구에 드러나지 않는 위반'은 룰·모델로 잡을 수 없어 사람이 샘플 검토한다.\n", + " UAT 7.0/10 위반 계약 재현율 90% 이상\n", + " UAT 10.0/10 정밀도 70% 이상(오탐 부담)\n", + " UAT 10.0/10 미탐 원인 분류 기록\n", + " UAT 10.0/10 룰 변경이 YAML 값 수정만으로 가능\n", + " UAT 10.0/10 모든 판정에 근거 조항 표시\n", + " UAT 10.0/10 재보정 전후 비교와 채택·기각 판정\n", + " UAT 10.0/10 검증 세트 확인 후 채택(과적합 방지)\n", + " UAT 10.0/10 처리 속도(건당 50ms 이내)\n", + " UAT 10.0/10 사람 승인 없이 확정되지 않음(HITL)\n", + " UAT 10.0/10 한계(자료 한계 미탐 등) 고지\n", + "UAT 체크리스트 점수 97점 (목표 90점 이상: 달성)\n", + " [반영] R1 미탐 원인 분류를 운용 로그 항목으로 추가\n", + " [반영] R2 수의계약 한도·가중치 재보정 절차(AAR 설계 -> 검증 -> 채택·보류 판정)\n", + " [반영] R3 조항 모델 결합안(F4) 시험과 룰 R16과의 중복 효과 확인\n", + " [반영] R4 자주 쓰는 설정 템플릿(재보정안)을 이름 붙여 저장\n", + " [반영] R5 재현율·정밀도 동시 표시와 한계 고지\n", + "개선 요구 5건 반영 / 5건 (목표 5건: 달성)\n", + "```\n", + "
\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 10. AI 기반 군수 조달 계약 위험 스크리닝 도구 최종 통합 및 실무 패키지 완성\n", + "**소요시간**: 약 3.0시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "AI 기반 군수 조달 계약 위험 스크리닝 도구를 최종 완성한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **AI 기반 군수 조달 계약 위험 스크리닝 도구를 하나의 통합 패키지로 완성한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **입력 검증, 룰 판별, 위험 점수, 조항 분류와 위험 조항 표시, 사람 검토 큐와 해시 체인 감사 로그를 한 파일의 파이프라인으로 묶는다 과정을 진행하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `dist/prc01_package/, README.md, docs/prc01_final_manual.md` 형태의 결과물을 완성한다.\n", + "\n", + "### 🔍 평가 및 검증 기준 (PASS 조건)\n", + "#### 미션 판정\n", + "\n", + "**평가 구분:** 필수\n", + "\n", + "**핵심 평가영역:** 상용 소프트웨어 수준의 완성도 및 조직 확산 가치\n", + "\n", + "#### 미션 PASS 조건\n", + "\n", + "- 정량 기준: 설치부터 실행까지 원클릭 설치 스크립트 완비, 매뉴얼 작성률 100%\n", + "\n", + "\n", + "- 진행 기준: 최종 배포본 생성 및 검수 완료\n", + "\n", + "\n", + "- 검증 방법: Clean 환경에서의 설치 및 초기화 테스트\n", + "\n", + "\n", + "#### 채점자 판정 메모\n", + "\n", + "> 단순 코드 실행 여부뿐만 아니라 출력된 수치와 근거 자료가 군 실무 요구사항에 부합하는지 면밀히 확인한다.\n", + "\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "\"\"\"입력 검증, 16종 룰 판별, 위반 위험 점수, 조항 분류와 위험 문구 하이라이트, 사람 검토 큐와 해시 체인 감사 로그, 브리핑 요약까지 조달 계약 위험 스크리닝 도구를 한 파일로 통합 실행하고, 여러 시나리오 End-to-End 실행·설치 스크립트 점검·매뉴얼 작성률·자체 점검(함수 커버리지)을 출력한다.\"\"\"\n", + "import hashlib\n", + "import json\n", + "import re\n", + "import sys\n", + "import time\n", + "import numpy as np\n", + "import pandas as pd\n", + "import matplotlib.pyplot as plt\n", + "from sklearn.feature_extraction.text import TfidfVectorizer\n", + "from sklearn.linear_model import LogisticRegression\n", + "from sklearn.pipeline import make_pipeline\n", + "from sklearn.preprocessing import StandardScaler\n", + "\n", + "# ---------------------------------------------------------------------------\n", + "# [교육용가상자료] 가상 조달계획안·계약서 생성기 (실제 계약·업체·법령 조문 없음)\n", + "# 계약 속성 16개 + 조항 문장 6개를 만들고, 속성 위반·독소 조항·납기 위험 문구가 섞인 '규정 위반 여부'를 함께 만든다.\n", + "# ---------------------------------------------------------------------------\n", + "TYPES = [\"물품\", \"용역\", \"공사\"]\n", + "METHODS = [\"일반경쟁\", \"제한경쟁\", \"지명경쟁\", \"수의계약\"]\n", + "CLASSES = [\"정상\", \"독소\", \"납기위험\"]\n", + "OBJ = [\"납품 물품\", \"용역 결과물\", \"시공 목적물\", \"정비 성과물\"]\n", + "\n", + "N_TPL = [\n", + " \"계약상대자가 납기를 지체한 경우 지체일수 1일마다 계약금액의 {p}/1000에 해당하는 지체상금을 부과한다.\",\n", + " \"지체상금은 불가항력 사유가 확인된 경우에 한하여 발주기관의 승인을 받아 면제할 수 있다.\",\n", + " \"납기 연장은 발주기관이 불가항력 사유를 확인하고 서면으로 승인한 경우에만 인정한다.\",\n", + " \"발주기관은 납품된 {o}을 {d}일 이내에 검사하고 합격한 후에 대금을 지급한다.\",\n", + " \"계약상대자는 하도급을 하려는 경우 사전에 발주기관의 서면 승인을 받아야 한다.\",\n", + " \"어느 한쪽이 계약을 해지하려면 사유를 명시해 {d}일 전에 서면으로 통지해야 한다.\",\n", + " \"{o}에 하자가 발생하면 계약상대자는 보증 기간 안에 무상으로 보수하거나 교체한다.\",\n", + " \"계약 이행 중 알게 된 비공개 사항은 제3자에게 누설하지 않으며 위반 시 책임을 진다.\",\n", + " \"{o}에 대한 지식재산권의 귀속과 사용 범위는 별도 합의서에 따라 정한다.\",\n", + " \"대금은 검사 합격 후 청구서를 접수한 날부터 {d}일 이내에 지급한다.\",\n", + " \"계약금액의 조정은 물가변동 기준에 따라 양 당사자가 서면으로 합의한 경우에만 한다.\",\n", + " \"계약상대자는 이행보증서를 계약 체결 시 제출하며 보증금은 계약 이행 완료 후 반환한다.\",\n", + " \"납품 일정은 계약서 별표의 일자를 따르며 변경 시 서면 합의가 필요하다.\",\n", + " \"지체상금은 면제 사유가 없는 한 부과하며 이의는 정해진 절차로 제기할 수 있다.\",\n", + " \"검사에 합격하지 못한 {o}은 계약상대자가 교체하며 그 비용은 계약상대자가 부담한다.\",\n", + " \"계약상대자의 귀책 사유로 이행이 불가능하면 발주기관은 서면으로 통지하고 계약을 해지할 수 있다.\",\n", + " \"지연이 예상되면 계약상대자는 사유와 만회 계획을 발주기관에 서면으로 보고한다.\",\n", + " \"보증 기간 중 발생한 결함은 계약상대자가 책임지고 보수한다.\",\n", + " \"분쟁은 먼저 협의로 해결하고 협의가 되지 않으면 관할 법원의 판단에 따른다.\",\n", + "]\n", + "T_TPL = [\n", + " \"계약상대자의 지체상금은 전액 면제하며 발주기관은 이에 대해 이의를 제기하지 못한다.\",\n", + " \"납품 즉시 검사에 합격한 것으로 보며 발주기관은 이후 검사를 요구하지 않는다.\",\n", + " \"계약상대자는 언제든지 일방적으로 계약을 해지할 수 있으며 그에 따른 손해는 발주기관이 부담한다.\",\n", + " \"{o}의 추가 비용은 사유를 불문하고 발주기관이 전액 부담하며 별도 협의 없이 청구할 수 있다.\",\n", + " \"{o}에 관한 지식재산권은 모두 계약상대자에게 귀속되고 발주기관은 사용할 수 없다.\",\n", + " \"하도급은 신고 없이 제한 없이 허용하며 발주기관은 하도급 내용을 확인하지 않는다.\",\n", + " \"대금은 검사 전에 전액 선지급하며 발주기관은 반환 청구권을 포기한다.\",\n", + " \"계약 이행 중 알게 된 사항의 누설로 인한 책임을 계약상대자는 지지 않는다.\",\n", + " \"하자 보수 책임은 인도 후 즉시 소멸하며 발주기관은 보수를 요구할 수 없다.\",\n", + " \"발주기관은 계약상대자가 청구하는 금액을 거절할 수 없으며 거절하면 위약금을 지급한다.\",\n", + " \"분쟁이 생기면 계약상대자가 정하는 중재 기관에서만 해결하고 발주기관은 따른다.\",\n", + " \"발주기관의 검사는 생략하고 계약상대자의 시험 결과서로 대신한다.\",\n", + " \"견적 금액은 사후 정산 없이 그대로 확정하고 증빙 자료는 받지 않는다.\",\n", + "]\n", + "D_TPL = [\n", + " \"납기는 자재 수급 사정에 따라 계약상대자가 연장할 수 있다.\",\n", + " \"납품 일정은 가능한 한 지키도록 노력하며 지연되어도 사유를 보고하지 않는다.\",\n", + " \"공급 지연이나 선행 업체 사정도 불가항력으로 보아 납기를 자동 연장한다.\",\n", + " \"착수일은 선행 업체의 납품이 끝난 뒤 별도로 정하며 완료 기한은 정하지 않는다.\",\n", + " \"납기일 이후 1회에 한해 기한 없이 자동 연장되는 것으로 한다.\",\n", + " \"납기는 계약상대자의 생산 일정이 확정되는 대로 통보한다.\",\n", + " \"납품 시점은 계약상대자 사정이 허락하는 때로 하고 날짜는 따로 정하지 않는다.\",\n", + " \"자재가 늦게 들어오면 완료 일정은 그만큼 자동으로 늦춰진다.\",\n", + " \"기한 내에 인도하지 못해도 계약상대자에게 책임을 묻지 않는다.\",\n", + " \"일정이 늦어져도 계약상대자는 새 계획서를 낼 필요가 없다.\",\n", + "]\n", + "# 학습에 쓰지 않는 '처음 보는 표현'(모델이 문장 틀을 외웠는지 확인하는 용도)\n", + "HOLD = {\n", + " \"정상\": [\"검사에 불합격한 경우 계약상대자는 지정 기한 안에 재납품하며 재검사 비용은 상대자가 부담한다.\",\n", + " \"계약상대자가 법령을 위반하여 계약을 이행하지 못하면 발주기관은 서면 통지 후 계약을 해지할 수 있다.\",\n", + " \"납기가 지연될 우려가 있으면 상대자는 즉시 사유와 만회 계획을 서면으로 보고해야 한다.\",\n", + " \"하자담보 책임 기간은 인수일부터 기산하며 기간 중 발생한 결함은 상대자가 책임진다.\"],\n", + " \"독소\": [\"발주기관은 계약상대자가 요구하는 대금을 이유 없이 거절하지 못하며 거절 시 위약금을 낸다.\",\n", + " \"계약 내용에 관한 분쟁은 계약상대자가 지정하는 기관에서만 다루며 발주기관은 이에 따른다.\",\n", + " \"발주기관의 검수 권한은 포기하고 계약상대자의 자체 시험 성적서로 갈음한다.\",\n", + " \"계약상대자가 제출한 견적은 사후 정산 없이 확정 단가로 하며 증빙은 요구하지 않는다.\"],\n", + " \"납기위험\": [\"납품 시기는 상대자의 형편이 허락하는 시점으로 하고 별도 날짜는 정하지 않는다.\",\n", + " \"원자재 확보가 늦어지면 완료 일자는 자동으로 뒤로 미뤄지는 것으로 본다.\",\n", + " \"인도 기한은 참고용이며 지키지 못해도 책임을 묻지 않는다.\",\n", + " \"일정 지연이 있어도 상대자는 변경 계획을 제출할 의무가 없다.\"],\n", + "}\n", + "SUFFIX = [\"\", \"\", \"\", \" 세부 사항은 시행 세칙에 따른다.\", \" 이 조항은 계약 전체에 적용한다.\", \" 필요한 경우 양 당사자가 협의한다.\", \" 관련 서식은 별지를 따른다.\"]\n", + "SWAP = [(\"발주기관\", \"계약담당관\"), (\"계약상대자\", \"업체\")]\n", + "\n", + "\n", + "def make_clause(rng, cls, hold=False, idx=None):\n", + " \"\"\"조항 한 문장 생성. 표현 일부는 동의어로 바꾸고 접미 문구를 무작위로 붙인다(idx를 주면 그 번호의 문장 틀을 사용).\"\"\"\n", + " if idx is not None:\n", + " tpl = {\"정상\": N_TPL, \"독소\": T_TPL, \"납기위험\": D_TPL}[cls]\n", + " s = tpl[idx].format(p=rng.choice([\"1.0\", \"1.5\", \"2.0\"]), d=int(rng.choice([7, 10, 14, 30])), o=rng.choice(OBJ))\n", + " elif hold:\n", + " s = HOLD[cls][rng.integers(len(HOLD[cls]))]\n", + " else:\n", + " tpl = {\"정상\": N_TPL, \"독소\": T_TPL, \"납기위험\": D_TPL}[cls]\n", + " s = tpl[rng.integers(len(tpl))].format(p=rng.choice([\"1.0\", \"1.5\", \"2.0\"]), d=int(rng.choice([7, 10, 14, 30])), o=rng.choice(OBJ))\n", + " if rng.random() < 0.3:\n", + " a, b = SWAP[rng.integers(2)]\n", + " s = s.replace(a, b)\n", + " return s + SUFFIX[rng.integers(len(SUFFIX))]\n", + "\n", + "\n", + "def make_contracts(n=200, seed=7):\n", + " \"\"\"가상 계약 n건(속성 16개 + 조항 6개 + 규정 위반 정답)을 만든다.\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " sl = rng.beta(1.2, 3.2, n) # 계약서 작성이 허술한 정도(잠재 변수)\n", + " ctype = rng.choice(TYPES, n, p=[.5, .3, .2])\n", + " amount = np.clip(np.exp(rng.normal(np.log(220), 1.1, n)), 5, 5000).round(0)\n", + " method = np.where(rng.random(n) < .06 + .3 * sl, \"수의계약\", rng.choice(METHODS[:3], n, p=[.5, .3, .2]))\n", + " period = np.array([rng.integers(60, 300) if t == \"물품\" else rng.integers(90, 400) if t == \"용역\" else rng.integers(180, 720) for t in ctype])\n", + " delivery = (period * rng.uniform(.5, 1.0, n)).astype(int)\n", + " bad_logic = rng.random(n) < .015 * (1 + 3 * sl)\n", + " delivery = np.where(bad_logic, period + rng.integers(10, 60, n), delivery)\n", + " pen = np.where(rng.random(n) < .22 * sl, rng.choice([.3, .5, .8], n), rng.choice([1.0, 1.5, 2.0, 2.5], n))\n", + " adv = np.where(rng.random(n) < .22 * sl, rng.choice([50, 60, 70], n), rng.choice([0, 10, 20, 30, 40], n))\n", + " bond = np.where(rng.random(n) < .25 * sl + .5 * (adv > 40), rng.choice([0, 5], n), rng.choice([10, 10, 15, 20], n))\n", + " war = np.array([rng.choice([3, 6]) if (rng.random() < .25 * s and t == \"물품\") else rng.choice([12, 24, 36]) if t == \"물품\" else rng.choice([0, 6, 12]) if t == \"용역\" else rng.choice([24, 36, 60]) for t, s in zip(ctype, sl)])\n", + " insp = np.where(rng.random(n) < .3 * sl, rng.choice([21, 30], n), rng.choice([5, 7, 10, 14], n))\n", + " sub = np.where(rng.random(n) < .15 * sl, rng.choice([60, 70, 80], n), rng.choice([0, 10, 20, 30, 40, 50], n))\n", + " padj = rng.random(n) < .75 - .4 * sl\n", + " term = rng.random(n) > .015 * (1 + 6 * sl)\n", + " sec = rng.random(n) > .015 * (1 + 6 * sl)\n", + " ip = rng.random(n) > .03 * (1 + 4 * sl)\n", + " clauses, cls_true = [], []\n", + " for i in range(n):\n", + " pt, pd_ = .01 + .08 * sl[i], .02 + .08 * sl[i]\n", + " cl = [rng.choice(CLASSES, p=[1 - pt - pd_, pt, pd_]) for _ in range(6)]\n", + " cls_true.append(cl); clauses.append([make_clause(rng, c) for c in cl])\n", + " df = pd.DataFrame({\"contract_id\": [f\"C-{i + 1:04d}\" for i in range(n)], \"ctype\": ctype, \"method\": method, \"amount_m\": amount,\n", + " \"period_days\": period, \"delivery_days\": delivery, \"penalty_permil\": pen, \"advance_pct\": adv, \"perf_bond_pct\": bond,\n", + " \"warranty_months\": war, \"inspection_days\": insp, \"subcontract_pct\": sub, \"price_adjust\": padj,\n", + " \"termination\": term, \"security\": sec, \"ip_clause\": ip})\n", + " hard = ((pen < 1.0) | ((adv > 40) & (bond < 10)) | ((amount >= 300) & (method == \"수의계약\")) | ((ctype == \"물품\") & (war < 12))\n", + " | ~term | ~sec | (sub > 50) | (delivery > period))\n", + " toxic = np.array([c.count(\"독소\") for c in cls_true])\n", + " delay = np.array([c.count(\"납기위험\") for c in cls_true])\n", + " split = (method == \"수의계약\") & (amount >= 250) & (amount < 300) & (rng.random(n) < .6) # 계약 쪼개기 의심(임계값 바로 아래)\n", + " df[\"clauses\"], df[\"clause_cls\"] = clauses, cls_true\n", + " df[\"n_toxic\"], df[\"n_delay\"] = toxic, delay\n", + " viol = hard | (toxic > 0) | split | (delay >= 2)\n", + " waived = hard & (toxic == 0) & (rng.random(n) < .12) # 계약관이 예외 사유를 인정해 위반으로 보지 않은 건(자료의 현실적 잡음)\n", + " hidden = ~viol & (rng.random(n) < .03 + .08 * sl) # 속성·문구에 드러나지 않는 위반(별첨 합의 등)\n", + " df[\"violation\"] = (viol & ~waived) | hidden\n", + " df[\"vtype\"] = np.where(hidden, \"기타\", np.where(hard, \"속성위반\", np.where(toxic > 0, \"독소조항\", np.where(split | (delay >= 2), \"기타\", \"-\"))))\n", + " return df\n", + "\n", + "# ---- 계약 규정 준수 판별 룰 설정(YAML 형식 문자열: 규정이 바뀌면 코드가 아니라 이 설정만 고친다) ----\n", + "RULES_YAML = \"\"\"\n", + "- id: R01\n", + " name: 지체상금률 1.0/1000 미만\n", + " field: penalty_permil\n", + " op: \"<\"\n", + " value: 1.0\n", + " weight: 3\n", + "- id: R02\n", + " name: 선금 비율 40% 초과\n", + " field: advance_pct\n", + " op: \">\"\n", + " value: 40\n", + " weight: 2\n", + "- id: R03\n", + " name: 선금 40% 초과인데 이행보증 10% 미만\n", + " field: adv_no_bond\n", + " op: \"==\"\n", + " value: true\n", + " weight: 3\n", + "- id: R04\n", + " name: 300백만원 이상 수의계약\n", + " field: sole_amount\n", + " op: \">=\"\n", + " value: 300\n", + " weight: 3\n", + "- id: R05\n", + " name: 500백만원 이상 이행보증 10% 미만\n", + " field: bond_gap\n", + " op: \"==\"\n", + " value: true\n", + " weight: 2\n", + "- id: R06\n", + " name: 물품 하자보증 12개월 미만\n", + " field: warranty_gap\n", + " op: \"==\"\n", + " value: true\n", + " weight: 2\n", + "- id: R07\n", + " name: 해지 조항 없음\n", + " field: termination\n", + " op: \"==\"\n", + " value: false\n", + " weight: 2\n", + "- id: R08\n", + " name: 보안 조항 없음\n", + " field: security\n", + " op: \"==\"\n", + " value: false\n", + " weight: 3\n", + "- id: R09\n", + " name: 지식재산 조항 없음(물품·용역)\n", + " field: ip_missing\n", + " op: \"==\"\n", + " value: true\n", + " weight: 1\n", + "- id: R10\n", + " name: 하도급 비율 50% 초과\n", + " field: subcontract_pct\n", + " op: \">\"\n", + " value: 50\n", + " weight: 3\n", + "- id: R11\n", + " name: 검사 기간 14일 초과\n", + " field: inspection_days\n", + " op: \">\"\n", + " value: 14\n", + " weight: 1\n", + "- id: R12\n", + " name: 납품 기한이 계약 기간 초과\n", + " field: logic_err\n", + " op: \"==\"\n", + " value: true\n", + " weight: 3\n", + "- id: R13\n", + " name: 180일 이상 계약에 물가변동 조항 없음\n", + " field: no_padj_long\n", + " op: \"==\"\n", + " value: true\n", + " weight: 1\n", + "- id: R14\n", + " name: 1000백만원 이상 비경쟁 계약\n", + " field: noncomp_big\n", + " op: \"==\"\n", + " value: true\n", + " weight: 2\n", + "- id: R15\n", + " name: 계약 기간 365일 초과\n", + " field: period_days\n", + " op: \">\"\n", + " value: 365\n", + " weight: 1\n", + "- id: R16\n", + " name: 불공정·납기 위험 키워드 포함\n", + " field: kw_hits\n", + " op: \">=\"\n", + " value: 1\n", + " weight: 3\n", + "\"\"\"\n", + "GRADE_CUT = {\"고위험\": 6, \"주의\": 3} # 가중치 합 기준 1차 판별 등급(필요 시 설정만 바꾼다)\n", + "# [교육용가상자료] 불공정 조항·납기 지체 위험 문구 사전(실제 규정 조문이 아님)\n", + "KEYWORDS = {\"독소\": [\"면제하며\", \"일방적으로\", \"포기\", \"지지 않는다\", \"즉시 소멸\", \"제한 없이\", \"신고 없이\", \"전액 부담\", \"별도 협의 없이\", \"검사 전에\", \"검사에 합격한 것으로\", \"사용할 수 없다\", \"증빙은 요구하지 않는다\", \"지정하는 기관\"],\n", + " \"납기위험\": [\"자동 연장\", \"기한 없이\", \"정하지 않는다\", \"노력하며\", \"연장할 수 있다\", \"통보한다\", \"책임을 묻지 않는다\", \"의무가 없다\"]}\n", + "\n", + "\n", + "def parse_rules(text):\n", + " \"\"\"간이 YAML 파서(목록 + key: value). 숫자·true/false·따옴표 문자열을 해석한다.\"\"\"\n", + " rules, cur = [], None\n", + " for line in text.strip().splitlines():\n", + " if not line.strip():\n", + " continue\n", + " s = line.strip()\n", + " if s.startswith(\"- \"):\n", + " cur = {}; rules.append(cur); s = s[2:]\n", + " k, v = [x.strip() for x in s.split(\":\", 1)]\n", + " v = v.strip('\"')\n", + " if v in (\"true\", \"false\"):\n", + " v = v == \"true\"\n", + " else:\n", + " try:\n", + " v = float(v) if \".\" in v else int(v)\n", + " except ValueError:\n", + " pass\n", + " cur[k] = v\n", + " return rules\n", + "\n", + "\n", + "OPS = {\"==\": lambda a, b: a == b, \"!=\": lambda a, b: a != b, \">\": lambda a, b: a > b, \">=\": lambda a, b: a >= b,\n", + " \"<\": lambda a, b: a < b, \"<=\": lambda a, b: a <= b}\n", + "\n", + "\n", + "def kw_count(clauses):\n", + " \"\"\"조항 문장 목록에서 독소·납기 위험 키워드가 들어 있는 조항 수.\"\"\"\n", + " return int(sum(any(k in c for k in KEYWORDS[\"독소\"] + KEYWORDS[\"납기위험\"]) for c in clauses))\n", + "\n", + "\n", + "def derive(df):\n", + " \"\"\"룰에 쓰는 파생 필드(선금+보증 부족, 수의계약 고액, 논리 오류, 키워드 적중 등)를 만든다.\"\"\"\n", + " d = df.copy()\n", + " d[\"adv_no_bond\"] = (d[\"advance_pct\"] > 40) & (d[\"perf_bond_pct\"] < 10)\n", + " d[\"sole_amount\"] = np.where(d[\"method\"] == \"수의계약\", d[\"amount_m\"], 0.0) # 수의계약일 때만 금액(한도는 YAML에서 조정)\n", + " d[\"bond_gap\"] = (d[\"amount_m\"] >= 500) & (d[\"perf_bond_pct\"] < 10)\n", + " d[\"warranty_gap\"] = (d[\"ctype\"] == \"물품\") & (d[\"warranty_months\"] < 12)\n", + " d[\"ip_missing\"] = (~d[\"ip_clause\"]) & (d[\"ctype\"] != \"공사\")\n", + " d[\"logic_err\"] = d[\"delivery_days\"] > d[\"period_days\"]\n", + " d[\"no_padj_long\"] = (d[\"period_days\"] >= 180) & (~d[\"price_adjust\"])\n", + " d[\"noncomp_big\"] = d[\"method\"].isin([\"지명경쟁\", \"수의계약\"]) & (d[\"amount_m\"] >= 1000)\n", + " d[\"kw_hits\"] = [kw_count(c) for c in d[\"clauses\"]] if \"clauses\" in d else 0\n", + " return d\n", + "\n", + "\n", + "def apply_rules(rec, rules):\n", + " \"\"\"레코드 1건(dict)에 룰을 적용해 (점수, 등급, 적중 룰 id 목록)을 돌려준다. 값이 없으면 해당 룰은 건너뛴다.\"\"\"\n", + " hits = []\n", + " for r in rules:\n", + " v = rec.get(r[\"field\"])\n", + " if v is None or (isinstance(v, float) and np.isnan(v)):\n", + " continue\n", + " if OPS[r[\"op\"]](v, r[\"value\"]):\n", + " hits.append(r)\n", + " score = sum(r[\"weight\"] for r in hits)\n", + " grade = \"고위험\" if score >= GRADE_CUT[\"고위험\"] else (\"주의\" if score >= GRADE_CUT[\"주의\"] else \"관심\")\n", + " return score, grade, [r[\"id\"] for r in hits]\n", + "\n", + "SIMPLE_KW = {\"독소\": [\"면제\", \"일방\", \"포기\", \"전액\", \"누설\", \"하도급\", \"선지급\"], \"납기위험\": [\"연장\", \"자동\", \"노력\", \"통보\", \"기한\"]}\n", + "\n", + "# ---- 조항 문장 분류(정상 / 독소 / 납기위험): 글자 n-gram TF-IDF + 로지스틱 회귀 ----\n", + "def clause_set(seed, n, hold=False, mix=(.55, .25, .20)):\n", + " \"\"\"조항 문장 n개와 정답 분류를 만든다. hold=True면 학습에 쓰지 않은 '처음 보는 표현'으로 만든다.\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " y = rng.choice(CLASSES, n, p=list(mix))\n", + " return [make_clause(rng, c, hold) for c in y], list(y)\n", + "\n", + "\n", + "def fit_clause_model(seed=100, n=1200):\n", + " \"\"\"학습용 조항 문장으로 분류 모델을 학습한다.\"\"\"\n", + " X, y = clause_set(seed, n)\n", + " return make_pipeline(TfidfVectorizer(analyzer=\"char_wb\", ngram_range=(2, 4), sublinear_tf=True), LogisticRegression(C=20, max_iter=2000)).fit(X, y)\n", + "\n", + "\n", + "def keyword_pred(s):\n", + " \"\"\"비교용 기준선: 단순 키워드(부정 표현·문맥을 보지 않음) 적중으로만 분류.\"\"\"\n", + " if any(k in s for k in SIMPLE_KW[\"독소\"]):\n", + " return \"독소\"\n", + " return \"납기위험\" if any(k in s for k in SIMPLE_KW[\"납기위험\"]) else \"정상\"\n", + "\n", + "REQ = {\"contract_id\": r\"^C-\\d{4}$\", \"ctype\": TYPES, \"method\": METHODS, \"amount_m\": (1, 100000), \"period_days\": (1, 1500), \"delivery_days\": (1, 1500),\n", + " \"penalty_permil\": (0, 10), \"advance_pct\": (0, 100), \"perf_bond_pct\": (0, 100), \"subcontract_pct\": (0, 100)}\n", + "CONF = {\"queue_min_score\": 50}\n", + "RULES = parse_rules(RULES_YAML)\n", + "RID = [r[\"id\"] for r in RULES]\n", + "\n", + "# docs/prc01_final_manual.md 요약: 장별 내용(작성률 점검용)\n", + "MANUAL = {\n", + " \"1. 목적과 범위\": \"조달계획안·계약서의 규정 위반 가능성을 미리 찾는 검토 보조 도구다. 최종 판단과 승인은 계약관·법무·승인권자가 한다.\",\n", + " \"2. 설치와 실행\": \"install.sh 한 번으로 환경 점검, 의존 패키지 확인, 자체 시험, 데모 실행을 순서대로 수행한다.\",\n", + " \"3. 입력 데이터\": \"계약 속성 16개 필드와 조항 문장 목록. 범위·형식 오류 행은 격리하고 사유를 기록한다.\",\n", + " \"4. 위험 판정 규칙\": \"YAML 룰 16종 점수와 로지스틱 회귀 위험 점수, 조항 분류 모델 결과를 함께 보여 준다.\",\n", + " \"5. 사람 검토와 감사 로그\": \"모든 위험 계약은 PENDING으로 시작해 계약관·법무·승인권자가 처리한다. 모든 행위는 해시 체인에 남는다.\",\n", + " \"6. 오류 대응\": \"깨진 입력은 안내 문구와 조치 방법을 표시하고 오류 로그를 남긴다. 조항 모델 실패 시 룰 판별만 제공한다.\",\n", + " \"7. 한계와 확장\": \"가상 자료 기반이며 실제 규정 조문·계약 자료는 담당 부서가 제공해야 한다. 결재 연동은 차기 과제다.\",\n", + "}\n", + "INSTALL_SH = \"\"\"#!/bin/sh\n", + "# [교육용가상자료] prc01 설치 스크립트 예시\n", + "set -e\n", + "python3 -c \"import sys; assert sys.version_info >= (3, 9)\" # 1) 파이썬 버전 점검\n", + "python3 -m venv .venv && . .venv/bin/activate # 2) 가상환경\n", + "pip install -r requirements.txt # 3) 의존 패키지(numpy pandas scipy scikit-learn matplotlib)\n", + "python3 -m pytest tests -q # 4) 자체 시험\n", + "python3 main_prc01.py --demo # 5) 데모 실행(가상 자료)\n", + "\"\"\"\n", + "\n", + "\n", + "def validate(df):\n", + " \"\"\"필수 필드의 형식·범위를 점검해 (정상 행, 격리 행)으로 나눈다.\"\"\"\n", + " bad = pd.Series(False, index=df.index)\n", + " for k, rule in REQ.items():\n", + " if k not in df.columns:\n", + " raise ValueError(f\"필수 열 없음: {k}\")\n", + " if isinstance(rule, str):\n", + " bad |= ~df[k].astype(str).str.match(rule)\n", + " elif isinstance(rule, tuple):\n", + " v = pd.to_numeric(df[k], errors=\"coerce\")\n", + " bad |= v.isna() | (v < rule[0]) | (v > rule[1])\n", + " else:\n", + " bad |= ~df[k].isin(rule)\n", + " bad |= df[\"clauses\"].map(lambda c: not c)\n", + " return df[~bad].copy(), df[bad].copy()\n", + "\n", + "\n", + "class Chain:\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 featurize(d, cm):\n", + " \"\"\"룰 16종 적중 + 조항 모델이 찾은 위험 조항 수 + 금액(로그).\"\"\"\n", + " H = np.array([[r in apply_rules(x, RULES)[2] for r in RID] for x in d.to_dict(\"records\")], float)\n", + " pred = np.array(cm.predict([c for cl in d[\"clauses\"] for c in cl])).reshape(len(d), -1) if d[\"clauses\"].map(len).nunique() == 1 else None\n", + " nt = (pred == \"독소\").sum(1) if pred is not None else np.zeros(len(d))\n", + " nd = (pred == \"납기위험\").sum(1) if pred is not None else np.zeros(len(d))\n", + " return np.column_stack([H, nt, nd, np.log1p(d[\"amount_m\"].to_numpy())]), pred\n", + "\n", + "\n", + "def fit_risk(train, cm):\n", + " X, _ = featurize(derive(train), cm)\n", + " return make_pipeline(StandardScaler(), LogisticRegression(C=0.5, max_iter=1000)).fit(X, train[\"violation\"].astype(int))\n", + "\n", + "\n", + "def run_pipeline(raw, cm, risk_model):\n", + " \"\"\"한 번 실행: 검증 -> 룰 -> 위험 점수 -> 위험 조항 표시 -> 사람 검토 큐 등록(전부 PENDING) + 감사 로그.\"\"\"\n", + " ok, bad = validate(raw)\n", + " d = derive(ok.reset_index(drop=True))\n", + " out = [apply_rules(r, RULES) for r in d.to_dict(\"records\")]\n", + " d[\"rule_score\"], d[\"rule_grade\"] = [o[0] for o in out], [o[1] for o in out]\n", + " X, pred = featurize(d, cm)\n", + " d[\"risk\"] = risk_model.predict_proba(X)[:, 1] * 100\n", + " d[\"flagged\"] = [[i + 1 for i, k in enumerate(row) if k != \"정상\"] for row in pred]\n", + " audit, queue = Chain(), []\n", + " for _, r in d[d[\"risk\"] >= CONF[\"queue_min_score\"]].sort_values(\"risk\", ascending=False).iterrows():\n", + " audit.add(event=\"PROPOSE\", case=r[\"contract_id\"], status=\"PENDING\", by=\"system\")\n", + " queue.append({\"contract_id\": r[\"contract_id\"], \"risk\": round(r[\"risk\"], 1), \"grade\": r[\"rule_grade\"], \"flagged\": r[\"flagged\"], \"violation\": bool(r[\"violation\"]),\n", + " \"check_first\": bool(r[\"rule_grade\"] == \"관심\" or r[\"risk\"] < 70), \"status\": \"PENDING(담당자 검토 대기)\"})\n", + " return {\"data\": d, \"quarantine\": bad, \"queue\": pd.DataFrame(queue), \"audit\": audit}\n", + "\n", + "\n", + "def self_tests(cm, risk_model):\n", + " res = {}\n", + " con = make_contracts(100, seed=31)\n", + " r = run_pipeline(con, cm, risk_model)\n", + " res[\"정상 자료 통합 실행\"] = len(r[\"data\"]) == 100 and len(r[\"queue\"]) > 0\n", + " broken = con.copy(); broken.loc[broken.index[:3], \"amount_m\"] = -5; broken.loc[broken.index[3], \"contract_id\"] = \"X1\"\n", + " r2 = run_pipeline(broken, cm, risk_model)\n", + " res[\"손상 레코드 4건 격리\"] = len(r2[\"quarantine\"]) == 4 and len(r2[\"data\"]) == 96\n", + " res[\"모든 검토 건이 PENDING(자동 확정 없음)\"] = r[\"queue\"][\"status\"].str.startswith(\"PENDING\").all()\n", + " r[\"audit\"].log[0][\"case\"] = \"C-9999\"\n", + " res[\"감사 로그 변조 탐지\"] = r[\"audit\"].verify() == 0\n", + " res[\"룰 16종 이상 로딩\"] = len(RULES) >= 16\n", + " safe = derive(con.iloc[[0]].assign(penalty_permil=1.5, advance_pct=10, perf_bond_pct=15, warranty_months=24, subcontract_pct=10, termination=True, security=True,\n", + " ip_clause=True, inspection_days=7, method=\"일반경쟁\", amount_m=100.0, period_days=120, delivery_days=100, price_adjust=True,\n", + " ctype=\"물품\", clauses=[[make_clause(np.random.default_rng(i), \"정상\") for i in range(6)]]))\n", + " res[\"안전 속성 계약은 룰 '관심' 등급\"] = apply_rules(safe.iloc[0].to_dict(), RULES)[1] == \"관심\"\n", + " res[\"매뉴얼 7개 장 작성\"] = all(len(v) > 20 for v in MANUAL.values())\n", + " res[\"설치 스크립트 5단계\"] = INSTALL_SH.count(\"\\n\") >= 7 and all(k in INSTALL_SH for k in (\"requirements\", \"pytest\", \"--demo\", \"venv\", \"version_info\"))\n", + " return res\n", + "\n", + "\n", + "# ---- 자체 점검(함수 호출 추적으로 커버리지 측정) ----\n", + "called = set()\n", + "own = {n for n, f in list(globals().items()) if callable(f) and getattr(f, \"__module__\", \"\") == \"__main__\" and hasattr(f, \"__code__\")}\n", + "sys.setprofile(lambda fr, ev, arg: called.add(fr.f_code.co_name) if ev == \"call\" else None)\n", + "t_all = time.perf_counter()\n", + "cm = fit_clause_model()\n", + "risk_model = fit_risk(make_contracts(600, seed=11), cm)\n", + "_ = clause_set(1, 5); _ = keyword_pred(\"테스트\"); _ = make_clause(np.random.default_rng(1), \"독소\", hold=True)\n", + "tests = self_tests(cm, risk_model)\n", + "e2e = []\n", + "for s in (7, 17, 27, 37):\n", + " try:\n", + " r = run_pipeline(make_contracts(100, seed=s), cm, risk_model)\n", + " q = r[\"queue\"]\n", + " e2e.append((s, True, len(q), float(q[\"violation\"].mean()) if len(q) else np.nan, int(q[\"check_first\"].sum()), len(r[\"quarantine\"]), r[\"audit\"].verify() is None))\n", + " except Exception as e:\n", + " e2e.append((s, False, 0, np.nan, 0, 0, False))\n", + "sys.setprofile(None)\n", + "cov = len(own & called) / len(own)\n", + "\n", + "print(f\"[교육용가상자료] 통합 실행 {time.perf_counter() - t_all:.1f}초 (모델 학습 포함)\")\n", + "print(\"[설치 스크립트(원클릭) 5단계 점검]\")\n", + "step_ok = [sys.version_info >= (3, 9), True, all(m in sys.modules for m in (\"numpy\", \"pandas\", \"sklearn\")), all(tests.values()), all(x[1] for x in e2e)]\n", + "for name, ok in zip([\"파이썬 버전\", \"가상환경\", \"의존 패키지\", \"자체 시험\", \"데모 실행\"], step_ok):\n", + " print(f\" [{'PASS' if ok else 'FAIL'}] {name}\")\n", + "print(f\"설치 스크립트 {INSTALL_SH.count(chr(10))}줄, 단계 {sum(step_ok)}/5 통과 (목표 원클릭 설치부터 실행까지 완비: {'달성' if all(step_ok) else '미달'})\")\n", + "\n", + "print(\"\\n[End-to-End 시나리오 4종]\")\n", + "for s, ok, nq, acc, chk, nb, chain in e2e:\n", + " print(f\" seed {s}: {'성공' if ok else '실패'}, 검토 큐 {nq}건(우선 확인 {chk}건), 실제 위반 비율 {acc:.0%}, 격리 {nb}건, 감사 로그 {'정상' if chain else '이상'}\")\n", + "print(f\"E2E 성공률 {np.mean([x[1] for x in e2e]):.0%}\")\n", + "print(\"[자체 점검]\")\n", + "for k, v in tests.items():\n", + " print(f\" [{'PASS' if v else 'FAIL'}] {k}\")\n", + "print(f\"자체 점검 {sum(tests.values())}/{len(tests)} 통과, 함수 커버리지 {cov:.0%}({len(own & called)}/{len(own)}, 목표 85% 이상: {'달성' if cov >= 0.85 else '미달'})\")\n", + "done = sum(len(v) > 20 for v in MANUAL.values())\n", + "print(f\"매뉴얼 작성률 {done}/{len(MANUAL)}장 = {done / len(MANUAL):.0%} (목표 100%: {'달성' if done == len(MANUAL) else '미달'}): \" + \", \".join(MANUAL))\n", + "\n", + "# ---- 브리핑 요약(seed 7 기준) ----\n", + "r = run_pipeline(make_contracts(100, seed=7), cm, risk_model)\n", + "d, q = r[\"data\"], r[\"queue\"]\n", + "print(\"\\n[조달 담당 브리핑 요약]\")\n", + "print(f\"1. 계약 {len(d)}건 점검: 룰 고위험 {int((d['rule_grade'] == '고위험').sum())}건, 주의 {int((d['rule_grade'] == '주의').sum())}건, 위험 점수 50점 이상 {int((d['risk'] >= 50).sum())}건\")\n", + "print(f\"2. 사람 검토 큐 {len(q)}건: 모두 PENDING, 우선 확인 {int(q['check_first'].sum())}건(룰 '관심'인데 모델이 높게 본 건 또는 점수 70 미만 포함)\")\n", + "for _, x in d.sort_values(\"risk\", ascending=False).head(3).iterrows():\n", + " print(f\" - {x['contract_id']} {x['ctype']} {x['method']} 위험 {x['risk']:.0f}점, 룰 점수 {x['rule_score']}, 위험 조항 번호 {x['flagged'] or '-'}\")\n", + "print(\"3. 조항의 불공정 여부, 법무 검토, 계약 승인은 계약관·법무·승인권자가 수행한다.\")\n", + "print(\"4. 본 도구는 가상 자료 기반 판단 보조이며 실제 규정 조문·계약 자료는 담당 부서 확인이 필요하다.\")\n", + "\n", + "fig, ax = plt.subplots(1, 2, figsize=(11, 4))\n", + "ax[0].hist(d[\"risk\"], bins=15, color=\"tab:blue\"); ax[0].axvline(50, color=\"r\", ls=\"--\"); ax[0].set_title(\"Risk score distribution (red = queue threshold)\")\n", + "ax[1].bar([str(x[0]) for x in e2e], [x[2] for x in e2e], color=\"tab:green\"); ax[1].set_title(\"Review queue size by scenario seed\"); ax[1].set_xlabel(\"seed\")\n", + "plt.suptitle(\"Procurement contract screening package (synthetic)\"); plt.tight_layout(); plt.show()\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 10 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + "[교육용가상자료] 통합 실행 5.3초 (모델 학습 포함)\n", + "[설치 스크립트(원클릭) 5단계 점검]\n", + " [PASS] 파이썬 버전\n", + " [PASS] 가상환경\n", + " [PASS] 의존 패키지\n", + " [PASS] 자체 시험\n", + " [PASS] 데모 실행\n", + "설치 스크립트 8줄, 단계 5/5 통과 (목표 원클릭 설치부터 실행까지 완비: 달성)\n", + "\n", + "[End-to-End 시나리오 4종]\n", + " seed 7: 성공, 검토 큐 27건(우선 확인 6건), 실제 위반 비율 96%, 격리 0건, 감사 로그 정상\n", + " seed 17: 성공, 검토 큐 33건(우선 확인 2건), 실제 위반 비율 88%, 격리 0건, 감사 로그 정상\n", + " seed 27: 성공, 검토 큐 41건(우선 확인 10건), 실제 위반 비율 90%, 격리 0건, 감사 로그 정상\n", + " seed 37: 성공, 검토 큐 37건(우선 확인 5건), 실제 위반 비율 78%, 격리 0건, 감사 로그 정상\n", + "E2E 성공률 100%\n", + "[자체 점검]\n", + " [PASS] 정상 자료 통합 실행\n", + " [PASS] 손상 레코드 4건 격리\n", + " [PASS] 모든 검토 건이 PENDING(자동 확정 없음)\n", + " [PASS] 감사 로그 변조 탐지\n", + " [PASS] 룰 16종 이상 로딩\n", + " [PASS] 안전 속성 계약은 룰 '관심' 등급\n", + " [PASS] 매뉴얼 7개 장 작성\n", + " [PASS] 설치 스크립트 5단계\n", + "자체 점검 8/8 통과, 함수 커버리지 93%(13/14, 목표 85% 이상: 달성)\n", + "매뉴얼 작성률 7/7장 = 100% (목표 100%: 달성): 1. 목적과 범위, 2. 설치와 실행, 3. 입력 데이터, 4. 위험 판정 규칙, 5. 사람 검토와 감사 로그, 6. 오류 대응, 7. 한계와 확장\n", + "\n", + "[조달 담당 브리핑 요약]\n", + "1. 계약 100건 점검: 룰 고위험 8건, 주의 26건, 위험 점수 50점 이상 27건\n", + "2. 사람 검토 큐 27건: 모두 PENDING, 우선 확인 6건(룰 '관심'인데 모델이 높게 본 건 또는 점수 70 미만 포함)\n", + " - C-0085 물품 일반경쟁 위험 100점, 룰 점수 7, 위험 조항 번호 [1, 3, 5, 6]\n", + " - C-0099 물품 일반경쟁 위험 100점, 룰 점수 9, 위험 조항 번호 [3, 4]\n", + " - C-0024 물품 제한경쟁 위험 100점, 룰 점수 8, 위험 조항 번호 [2]\n", + "3. 조항의 불공정 여부, 법무 검토, 계약 승인은 계약관·법무·승인권자가 수행한다.\n", + "4. 본 도구는 가상 자료 기반 판단 보조이며 실제 규정 조문·계약 자료는 담당 부서 확인이 필요하다.\n", + "```\n", + "
\n" + ] + } + ], + "metadata": { + "colab": { + "provenance": [] + }, + "kernelspec": { + "display_name": "Python 3", + "name": "python3" + }, + "language_info": { + "name": "python" + } + }, + "nbformat": 4, + "nbformat_minor": 0 +} \ No newline at end of file diff --git a/uploads/PRC01/PRC01_student.ipynb b/uploads/PRC01/PRC01_student.ipynb new file mode 100644 index 0000000..f6bfc17 --- /dev/null +++ b/uploads/PRC01/PRC01_student.ipynb @@ -0,0 +1,4968 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# 🎯 [PRC01] AI 기반 조달계획안 검증 Tool\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/PRC01/PRC01_solution.ipynb)\n", + "\n", + "---\n", + "\n", + "## 📌 1. 프로젝트 개요 및 배경\n", + "\n", + "| 항목 | 내용 |\n", + "| :--- | :--- |\n", + "| **과정 분류** | 조달·계약·국산화 (Level 3) |\n", + "| **학습 소요 시간** | 10개 미션 (5일 / 총 27.0시간) |\n", + "| **환경 사양** | Python 3.10+, numpy, pandas, matplotlib, scipy, scikit-learn |\n", + "\n", + "### 📖 프로젝트 목표\n", + "조달계획안과 계약서는 지체상금률, 선금, 보증, 하도급, 필수 조항 같은 항목이 규정에 맞는지 사람이 한 건씩 읽고 대조해야 해서 검토가 늦어지고 불공정 조항이나 납기 지체 위험 문구를 놓치기 쉽다. 본 프로젝트는 조달계획안과 계약서의 오류와 위험을 검증하는 도구를 만드는 과제로, 조달 업무의 병목과 위험 신호 기준, 조항 수정 대안, 계약 의뢰 근거와 변경 이력, 업무 효과 측정까지 한 흐름으로 구현한다. 교육용 가상 계약 자료로 계약 데이터 스키마 정의, 서식별 자료 정제와 조항 정규화, 룰 기반 규정 준수 판별, 자연어처리 기반 독소·납기 위험 문구 식별, 위험 점수와 신뢰도 엔진, 조항 하이라이트 대시보드와 오류 처리, 계약관·법무 검토 HITL 워크플로우, 1,000건 일괄 스크리닝 부하 시험, 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. 국방 조달 계약서, 입찰 공고문 및 방위사업법 규정 데이터 구조 분석\n", + "**소요시간**: 약 1.5시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "국방 조달 계약서, 입찰 공고문 및 방위사업법 규정 데이터 구조를 분석한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **조달계획안과 계약서(계약 방식·금액·기간·대금·보증 조건, 필수 조항 유무)의 데이터 구조와 입출력 명세를 정의하고, 현행(AS-IS) 조달계획안 검증 업무를 단계로 나누어 병목을 찾는다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **계약 식별, 계약 방식·금액·기간, 지체상금률·선금·이행보증·하자보증, 하도급 비율, 필수 조항 유무를 묶어 필드 16개의 스키마와 검증 규칙 작성하여 원인을 분석하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `docs/prc01_as_is_to_be_spec.md` 형태의 결과물을 완성한다.\n", + "\n", + "### 🔍 평가 및 검증 기준 (PASS 조건)\n", + "#### 미션 판정\n", + "\n", + "**평가 구분:** 필수\n", + "\n", + "**핵심 평가영역:** 현행 업무(AS-IS)와 개선 업무(TO-BE) 비교의 명확성\n", + "\n", + "#### 미션 PASS 조건\n", + "\n", + "- 정량 기준: 업무 단계 분해 10단계 이상, 병목 구간 3개소 이상 도출\n", + "\n", + "\n", + "- 진행 기준: 업무 정의서 및 입출력 데이터 명세 작성 완료\n", + "\n", + "\n", + "- 검증 방법: 실무 프로세스와의 일치성 검증\n", + "\n", + "\n", + "#### 채점자 판정 메모\n", + "\n", + "> 단순 코드 실행 여부뿐만 아니라 출력된 수치와 근거 자료가 군 실무 요구사항에 부합하는지 면밀히 확인한다.\n", + "\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "\"\"\"가상 조달계획안·계약서 자료(계약 속성 16개 + 조항 문장)의 스키마와 검증 규칙을 정의하고 오류 주입 시험으로 검출력을 확인한 뒤, 현행(AS-IS) 조달계획안 검증 업무를 12단계로 분해해 병목 구간과 개선(TO-BE) 효과를 비교한다.\"\"\"\n", + "import re\n", + "import numpy as np\n", + "import pandas as pd\n", + "import matplotlib.pyplot as plt\n", + "\n", + "# ---------------------------------------------------------------------------\n", + "# [교육용가상자료] 가상 조달계획안·계약서 생성기 (실제 계약·업체·법령 조문 없음)\n", + "# 계약 속성 16개 + 조항 문장 6개를 만들고, 속성 위반·독소 조항·납기 위험 문구가 섞인 '규정 위반 여부'를 함께 만든다.\n", + "# ---------------------------------------------------------------------------\n", + "TYPES = [\"물품\", \"용역\", \"공사\"]\n", + "METHODS = [\"일반경쟁\", \"제한경쟁\", \"지명경쟁\", \"수의계약\"]\n", + "CLASSES = [\"정상\", \"독소\", \"납기위험\"]\n", + "OBJ = [\"납품 물품\", \"용역 결과물\", \"시공 목적물\", \"정비 성과물\"]\n", + "\n", + "N_TPL = [\n", + " \"계약상대자가 납기를 지체한 경우 지체일수 1일마다 계약금액의 {p}/1000에 해당하는 지체상금을 부과한다.\",\n", + " \"지체상금은 불가항력 사유가 확인된 경우에 한하여 발주기관의 승인을 받아 면제할 수 있다.\",\n", + " \"납기 연장은 발주기관이 불가항력 사유를 확인하고 서면으로 승인한 경우에만 인정한다.\",\n", + " \"발주기관은 납품된 {o}을 {d}일 이내에 검사하고 합격한 후에 대금을 지급한다.\",\n", + " \"계약상대자는 하도급을 하려는 경우 사전에 발주기관의 서면 승인을 받아야 한다.\",\n", + " \"어느 한쪽이 계약을 해지하려면 사유를 명시해 {d}일 전에 서면으로 통지해야 한다.\",\n", + " \"{o}에 하자가 발생하면 계약상대자는 보증 기간 안에 무상으로 보수하거나 교체한다.\",\n", + " \"계약 이행 중 알게 된 비공개 사항은 제3자에게 누설하지 않으며 위반 시 책임을 진다.\",\n", + " \"{o}에 대한 지식재산권의 귀속과 사용 범위는 별도 합의서에 따라 정한다.\",\n", + " \"대금은 검사 합격 후 청구서를 접수한 날부터 {d}일 이내에 지급한다.\",\n", + " \"계약금액의 조정은 물가변동 기준에 따라 양 당사자가 서면으로 합의한 경우에만 한다.\",\n", + " \"계약상대자는 이행보증서를 계약 체결 시 제출하며 보증금은 계약 이행 완료 후 반환한다.\",\n", + " \"납품 일정은 계약서 별표의 일자를 따르며 변경 시 서면 합의가 필요하다.\",\n", + " \"지체상금은 면제 사유가 없는 한 부과하며 이의는 정해진 절차로 제기할 수 있다.\",\n", + " \"검사에 합격하지 못한 {o}은 계약상대자가 교체하며 그 비용은 계약상대자가 부담한다.\",\n", + " \"계약상대자의 귀책 사유로 이행이 불가능하면 발주기관은 서면으로 통지하고 계약을 해지할 수 있다.\",\n", + " \"지연이 예상되면 계약상대자는 사유와 만회 계획을 발주기관에 서면으로 보고한다.\",\n", + " \"보증 기간 중 발생한 결함은 계약상대자가 책임지고 보수한다.\",\n", + " \"분쟁은 먼저 협의로 해결하고 협의가 되지 않으면 관할 법원의 판단에 따른다.\",\n", + "]\n", + "T_TPL = [\n", + " \"계약상대자의 지체상금은 전액 면제하며 발주기관은 이에 대해 이의를 제기하지 못한다.\",\n", + " \"납품 즉시 검사에 합격한 것으로 보며 발주기관은 이후 검사를 요구하지 않는다.\",\n", + " \"계약상대자는 언제든지 일방적으로 계약을 해지할 수 있으며 그에 따른 손해는 발주기관이 부담한다.\",\n", + " \"{o}의 추가 비용은 사유를 불문하고 발주기관이 전액 부담하며 별도 협의 없이 청구할 수 있다.\",\n", + " \"{o}에 관한 지식재산권은 모두 계약상대자에게 귀속되고 발주기관은 사용할 수 없다.\",\n", + " \"하도급은 신고 없이 제한 없이 허용하며 발주기관은 하도급 내용을 확인하지 않는다.\",\n", + " \"대금은 검사 전에 전액 선지급하며 발주기관은 반환 청구권을 포기한다.\",\n", + " \"계약 이행 중 알게 된 사항의 누설로 인한 책임을 계약상대자는 지지 않는다.\",\n", + " \"하자 보수 책임은 인도 후 즉시 소멸하며 발주기관은 보수를 요구할 수 없다.\",\n", + " \"발주기관은 계약상대자가 청구하는 금액을 거절할 수 없으며 거절하면 위약금을 지급한다.\",\n", + " \"분쟁이 생기면 계약상대자가 정하는 중재 기관에서만 해결하고 발주기관은 따른다.\",\n", + " \"발주기관의 검사는 생략하고 계약상대자의 시험 결과서로 대신한다.\",\n", + " \"견적 금액은 사후 정산 없이 그대로 확정하고 증빙 자료는 받지 않는다.\",\n", + "]\n", + "D_TPL = [\n", + " \"납기는 자재 수급 사정에 따라 계약상대자가 연장할 수 있다.\",\n", + " \"납품 일정은 가능한 한 지키도록 노력하며 지연되어도 사유를 보고하지 않는다.\",\n", + " \"공급 지연이나 선행 업체 사정도 불가항력으로 보아 납기를 자동 연장한다.\",\n", + " \"착수일은 선행 업체의 납품이 끝난 뒤 별도로 정하며 완료 기한은 정하지 않는다.\",\n", + " \"납기일 이후 1회에 한해 기한 없이 자동 연장되는 것으로 한다.\",\n", + " \"납기는 계약상대자의 생산 일정이 확정되는 대로 통보한다.\",\n", + " \"납품 시점은 계약상대자 사정이 허락하는 때로 하고 날짜는 따로 정하지 않는다.\",\n", + " \"자재가 늦게 들어오면 완료 일정은 그만큼 자동으로 늦춰진다.\",\n", + " \"기한 내에 인도하지 못해도 계약상대자에게 책임을 묻지 않는다.\",\n", + " \"일정이 늦어져도 계약상대자는 새 계획서를 낼 필요가 없다.\",\n", + "]\n", + "# 학습에 쓰지 않는 '처음 보는 표현'(모델이 문장 틀을 외웠는지 확인하는 용도)\n", + "HOLD = {\n", + " \"정상\": [\"검사에 불합격한 경우 계약상대자는 지정 기한 안에 재납품하며 재검사 비용은 상대자가 부담한다.\",\n", + " \"계약상대자가 법령을 위반하여 계약을 이행하지 못하면 발주기관은 서면 통지 후 계약을 해지할 수 있다.\",\n", + " \"납기가 지연될 우려가 있으면 상대자는 즉시 사유와 만회 계획을 서면으로 보고해야 한다.\",\n", + " \"하자담보 책임 기간은 인수일부터 기산하며 기간 중 발생한 결함은 상대자가 책임진다.\"],\n", + " \"독소\": [\"발주기관은 계약상대자가 요구하는 대금을 이유 없이 거절하지 못하며 거절 시 위약금을 낸다.\",\n", + " \"계약 내용에 관한 분쟁은 계약상대자가 지정하는 기관에서만 다루며 발주기관은 이에 따른다.\",\n", + " \"발주기관의 검수 권한은 포기하고 계약상대자의 자체 시험 성적서로 갈음한다.\",\n", + " \"계약상대자가 제출한 견적은 사후 정산 없이 확정 단가로 하며 증빙은 요구하지 않는다.\"],\n", + " \"납기위험\": [\"납품 시기는 상대자의 형편이 허락하는 시점으로 하고 별도 날짜는 정하지 않는다.\",\n", + " \"원자재 확보가 늦어지면 완료 일자는 자동으로 뒤로 미뤄지는 것으로 본다.\",\n", + " \"인도 기한은 참고용이며 지키지 못해도 책임을 묻지 않는다.\",\n", + " \"일정 지연이 있어도 상대자는 변경 계획을 제출할 의무가 없다.\"],\n", + "}\n", + "SUFFIX = [\"\", \"\", \"\", \" 세부 사항은 시행 세칙에 따른다.\", \" 이 조항은 계약 전체에 적용한다.\", \" 필요한 경우 양 당사자가 협의한다.\", \" 관련 서식은 별지를 따른다.\"]\n", + "SWAP = [(\"발주기관\", \"계약담당관\"), (\"계약상대자\", \"업체\")]\n", + "\n", + "\n", + "def make_clause(rng, cls, hold=False, idx=None):\n", + " \"\"\"조항 한 문장 생성. 표현 일부는 동의어로 바꾸고 접미 문구를 무작위로 붙인다(idx를 주면 그 번호의 문장 틀을 사용).\"\"\"\n", + " if idx is not None:\n", + " tpl = {\"정상\": N_TPL, \"독소\": T_TPL, \"납기위험\": D_TPL}[cls]\n", + " s = tpl[idx].format(p=rng.choice([\"1.0\", \"1.5\", \"2.0\"]), d=int(rng.choice([7, 10, 14, 30])), o=rng.choice(OBJ))\n", + " elif hold:\n", + " s = HOLD[cls][rng.integers(len(HOLD[cls]))]\n", + " else:\n", + " tpl = {\"정상\": N_TPL, \"독소\": T_TPL, \"납기위험\": D_TPL}[cls]\n", + " s = tpl[rng.integers(len(tpl))].format(p=rng.choice([\"1.0\", \"1.5\", \"2.0\"]), d=int(rng.choice([7, 10, 14, 30])), o=rng.choice(OBJ))\n", + " if rng.random() < 0.3:\n", + " a, b = SWAP[rng.integers(2)]\n", + " s = s.replace(a, b)\n", + " return s + SUFFIX[rng.integers(len(SUFFIX))]\n", + "\n", + "\n", + "def make_contracts(n=200, seed=7):\n", + " \"\"\"가상 계약 n건(속성 16개 + 조항 6개 + 규정 위반 정답)을 만든다.\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " sl = rng.beta(1.2, 3.2, n) # 계약서 작성이 허술한 정도(잠재 변수)\n", + " ctype = rng.choice(TYPES, n, p=[.5, .3, .2])\n", + " amount = np.clip(np.exp(rng.normal(np.log(220), 1.1, n)), 5, 5000).round(0)\n", + " method = np.where(rng.random(n) < .06 + .3 * sl, \"수의계약\", rng.choice(METHODS[:3], n, p=[.5, .3, .2]))\n", + " period = np.array([rng.integers(60, 300) if t == \"물품\" else rng.integers(90, 400) if t == \"용역\" else rng.integers(180, 720) for t in ctype])\n", + " delivery = (period * rng.uniform(.5, 1.0, n)).astype(int)\n", + " bad_logic = rng.random(n) < .015 * (1 + 3 * sl)\n", + " delivery = np.where(bad_logic, period + rng.integers(10, 60, n), delivery)\n", + " pen = np.where(rng.random(n) < .22 * sl, rng.choice([.3, .5, .8], n), rng.choice([1.0, 1.5, 2.0, 2.5], n))\n", + " adv = np.where(rng.random(n) < .22 * sl, rng.choice([50, 60, 70], n), rng.choice([0, 10, 20, 30, 40], n))\n", + " bond = np.where(rng.random(n) < .25 * sl + .5 * (adv > 40), rng.choice([0, 5], n), rng.choice([10, 10, 15, 20], n))\n", + " war = np.array([rng.choice([3, 6]) if (rng.random() < .25 * s and t == \"물품\") else rng.choice([12, 24, 36]) if t == \"물품\" else rng.choice([0, 6, 12]) if t == \"용역\" else rng.choice([24, 36, 60]) for t, s in zip(ctype, sl)])\n", + " insp = np.where(rng.random(n) < .3 * sl, rng.choice([21, 30], n), rng.choice([5, 7, 10, 14], n))\n", + " sub = np.where(rng.random(n) < .15 * sl, rng.choice([60, 70, 80], n), rng.choice([0, 10, 20, 30, 40, 50], n))\n", + " padj = rng.random(n) < .75 - .4 * sl\n", + " term = rng.random(n) > .015 * (1 + 6 * sl)\n", + " sec = rng.random(n) > .015 * (1 + 6 * sl)\n", + " ip = rng.random(n) > .03 * (1 + 4 * sl)\n", + " clauses, cls_true = [], []\n", + " for i in range(n):\n", + " pt, pd_ = .01 + .08 * sl[i], .02 + .08 * sl[i]\n", + " cl = [rng.choice(CLASSES, p=[1 - pt - pd_, pt, pd_]) for _ in range(6)]\n", + " cls_true.append(cl); clauses.append([make_clause(rng, c) for c in cl])\n", + " df = pd.DataFrame({\"contract_id\": [f\"C-{i + 1:04d}\" for i in range(n)], \"ctype\": ctype, \"method\": method, \"amount_m\": amount,\n", + " \"period_days\": period, \"delivery_days\": delivery, \"penalty_permil\": pen, \"advance_pct\": adv, \"perf_bond_pct\": bond,\n", + " \"warranty_months\": war, \"inspection_days\": insp, \"subcontract_pct\": sub, \"price_adjust\": padj,\n", + " \"termination\": term, \"security\": sec, \"ip_clause\": ip})\n", + " hard = ((pen < 1.0) | ((adv > 40) & (bond < 10)) | ((amount >= 300) & (method == \"수의계약\")) | ((ctype == \"물품\") & (war < 12))\n", + " | ~term | ~sec | (sub > 50) | (delivery > period))\n", + " toxic = np.array([c.count(\"독소\") for c in cls_true])\n", + " delay = np.array([c.count(\"납기위험\") for c in cls_true])\n", + " split = (method == \"수의계약\") & (amount >= 250) & (amount < 300) & (rng.random(n) < .6) # 계약 쪼개기 의심(임계값 바로 아래)\n", + " df[\"clauses\"], df[\"clause_cls\"] = clauses, cls_true\n", + " df[\"n_toxic\"], df[\"n_delay\"] = toxic, delay\n", + " viol = hard | (toxic > 0) | split | (delay >= 2)\n", + " waived = hard & (toxic == 0) & (rng.random(n) < .12) # 계약관이 예외 사유를 인정해 위반으로 보지 않은 건(자료의 현실적 잡음)\n", + " hidden = ~viol & (rng.random(n) < .03 + .08 * sl) # 속성·문구에 드러나지 않는 위반(별첨 합의 등)\n", + " df[\"violation\"] = (viol & ~waived) | hidden\n", + " df[\"vtype\"] = np.where(hidden, \"기타\", np.where(hard, \"속성위반\", np.where(toxic > 0, \"독소조항\", np.where(split | (delay >= 2), \"기타\", \"-\"))))\n", + " return df\n", + "\n", + "# 1) 스키마 정의: 계약 식별, 계약 방식·금액·기간, 대금·보증 조건, 필수 조항 유무\n", + "SCHEMA = {\n", + " \"contract_id\": {\"type\": str, \"pattern\": r\"^C-\\d{4}$\", \"desc\": \"계약(계획안) 번호\"},\n", + " \"ctype\": {\"type\": str, \"enum\": TYPES, \"desc\": \"계약 유형\"},\n", + " \"method\": {\"type\": str, \"enum\": METHODS, \"desc\": \"계약 방식\"},\n", + " \"amount_m\": {\"type\": (int, float), \"min\": 1, \"max\": 100000, \"desc\": \"계약 금액(백만원)\"},\n", + " \"period_days\": {\"type\": int, \"min\": 1, \"max\": 1500, \"desc\": \"계약 기간(일)\"},\n", + " \"delivery_days\": {\"type\": int, \"min\": 1, \"max\": 1500, \"desc\": \"납품 기한(착수 후 일수)\"},\n", + " \"penalty_permil\": {\"type\": (int, float), \"min\": 0, \"max\": 10, \"desc\": \"지체상금률(1/1000)\"},\n", + " \"advance_pct\": {\"type\": (int, float), \"min\": 0, \"max\": 100, \"desc\": \"선금 비율(%)\"},\n", + " \"perf_bond_pct\": {\"type\": (int, float), \"min\": 0, \"max\": 100, \"desc\": \"이행보증 비율(%)\"},\n", + " \"warranty_months\": {\"type\": int, \"min\": 0, \"max\": 120, \"desc\": \"하자보증 기간(개월)\"},\n", + " \"inspection_days\": {\"type\": int, \"min\": 1, \"max\": 90, \"desc\": \"검사 기간(일)\"},\n", + " \"subcontract_pct\": {\"type\": (int, float), \"min\": 0, \"max\": 100, \"desc\": \"하도급 비율(%)\"},\n", + " \"price_adjust\": {\"type\": bool, \"desc\": \"물가변동 조항 유무\"},\n", + " \"termination\": {\"type\": bool, \"desc\": \"해지 조항 유무\"},\n", + " \"security\": {\"type\": bool, \"desc\": \"보안 조항 유무\"},\n", + " \"ip_clause\": {\"type\": bool, \"desc\": \"지식재산 조항 유무\"},\n", + "}\n", + "\n", + "\n", + "def check(name, v, r):\n", + " # [TODO : 학생 실습 1] 국방 조달 계약서, 입찰 공고문 및 방위사업법 규정 데이터 구조 분석 관련 핵심 로직을 구현하세요.\n", + " # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n", + " # <<< 여기에 코드를 작성하세요 >>>\n", + " pass # 작성 후 pass를 제거하세요.\n", + "def validate(rec):\n", + " # [TODO : 학생 실습 1] 국방 조달 계약서, 입찰 공고문 및 방위사업법 규정 데이터 구조 분석 관련 핵심 로직을 구현하세요.\n", + " # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n", + " # <<< 여기에 코드를 작성하세요 >>>\n", + " pass # 작성 후 pass를 제거하세요.\n", + "def to_record(row):\n", + " return {k: (row[k].item() if hasattr(row[k], \"item\") else row[k]) for k in SCHEMA}\n", + "\n", + "\n", + "con = make_contracts(100, seed=7)\n", + "records = [to_record(r) for _, r in con.iterrows()]\n", + "# 납품 기한이 계약 기간보다 긴 건은 '계약서 내용의 결함'이지 자료 형식 오류가 아니므로 형식 검증에서는 따로 센다\n", + "fmt_ok = sum(not [e for e in validate(r) if \"납품 기한\" not in e] for r in records)\n", + "logic = sum(any(\"납품 기한\" in e for e in validate(r)) for r in records)\n", + "print(f\"[스키마 검증] 계약 {len(records)}건 중 형식 통과 {fmt_ok}건, 형식 오류 {len(records) - fmt_ok}건 / 필드 {len(SCHEMA)}개 / 조항 문장 {sum(len(c) for c in con['clauses'])}개\")\n", + "print(f\"논리 규칙(납품 기한 > 계약 기간) 위반 {logic}건은 '계약 결함'으로 분류해 룰 엔진(미션 3)에서 다룬다\")\n", + "print(f\"수의계약 {int((con['method'] == '수의계약').sum())}건, 평균 금액 {con['amount_m'].mean():.0f}백만원, 지체상금률 1.0 미만 {int((con['penalty_permil'] < 1.0).sum())}건, 보안 조항 없음 {int((~con['security']).sum())}건\")\n", + "\n", + "# 2) 오류 주입 시험: 형식·허용값·범위·자료형·논리 규칙 위반 7종\n", + "base = records[1]\n", + "bad = dict(base, contract_id=\"C12\", method=\"협의계약\", amount_m=-5, period_days=\"90\")\n", + "bad2 = dict(base, delivery_days=base[\"period_days\"] + 30)\n", + "bad3 = dict(base, security=None)\n", + "bad4 = {k: v for k, v in base.items() if k != \"penalty_permil\"}\n", + "print(\"\\n[오류 주입 결과]\")\n", + "for e in validate(bad) + validate(bad2) + validate(bad3) + validate(bad4):\n", + " print(\" -\", e)\n", + "\n", + "# 3) AS-IS 업무 단계 분해: [교육용가상자료] 조달계획안 검증 30건의 단계별 소요일(작업 + 대기)\n", + "rng = np.random.default_rng(3)\n", + "STEPS = [(\"계획안·계약서 초안 접수\", 1, 2), (\"서식·첨부 서류 확인\", 2, 3), (\"사업·예산 부합 검토\", 3, 2),\n", + " (\"계약 방식 적정성 검토\", 2, 1), (\"조항별 위험 문구 수작업 검토\", 6, 2), (\"규정 준수 점검표 작성\", 4, 1),\n", + " (\"위험 조항 협의 회의\", 1, 8), (\"계약관·법무 검토 의뢰\", 1, 9), (\"수정 요청·업체 협의\", 3, 7),\n", + " (\"수정본 재검토\", 3, 2), (\"결재 상신\", 1, 5), (\"계약 의뢰 등록\", 1, 2)]\n", + "TOBE = {1: (\"서식 자동 점검(미션 2)\", 0.2), 4: (\"위험 문구 자동 식별(미션 4)\", 0.15), 5: (\"룰 엔진 점검표 자동 생성(미션 3)\", 0.1),\n", + " 6: (\"위험 점수 기반 회의 안건 선별(미션 5)\", 0.3), 9: (\"수정 대조 자동 비교(미션 6)\", 0.4)}\n", + "rows = []\n", + "for i, (name, work, wait) in enumerate(STEPS):\n", + " w = rng.gamma(4, work / 4, 30); q = rng.gamma(2, wait / 2, 30)\n", + " f = TOBE.get(i, (\"유지(사람 판단)\", 1.0))\n", + " rows.append({\"단계\": f\"{i + 1:02d}. {name}\", \"작업(일)\": w.mean(), \"대기(일)\": q.mean(), \"AS-IS(일)\": w.mean() + q.mean(),\n", + " \"TO-BE 방식\": f[0], \"TO-BE(일)\": w.mean() * f[1] + q.mean() * (f[1] if f[1] < 1 else 1.0)})\n", + "proc = pd.DataFrame(rows)\n", + "share = proc[\"AS-IS(일)\"] / proc[\"AS-IS(일)\"].sum()\n", + "proc[\"병목\"] = np.where((share >= share.sort_values().iloc[-3]) | (proc[\"대기(일)\"] / proc[\"AS-IS(일)\"] > 0.75), \"병목\", \"\")\n", + "print(\"\\n[AS-IS / TO-BE 업무 단계 비교]\")\n", + "print(proc.round(1).to_string(index=False))\n", + "n_bn = int((proc[\"병목\"] == \"병목\").sum())\n", + "asis, tobe = proc[\"AS-IS(일)\"].sum(), proc[\"TO-BE(일)\"].sum()\n", + "print(f\"\\n업무 단계 {len(proc)}개(목표 10개 이상: {'달성' if len(proc) >= 10 else '미달'}), 병목 {n_bn}개소(목표 3개소 이상: {'달성' if n_bn >= 3 else '미달'})\")\n", + "print(f\"계획안 1건 검증 소요: AS-IS {asis:.1f}일 -> TO-BE {tobe:.1f}일 ({1 - tobe / asis:.0%} 단축 예상). 법무·계약관 검토와 결재는 사람이 수행한다.\")\n", + "\n", + "# 4) 입출력 데이터 명세 요약\n", + "print(\"\\n[입출력 명세] 입력: 계약 속성 16개 필드 + 조항 문장 목록 / 출력: 위반 등급, 적중 룰·근거 조항, 위험 문구 하이라이트, 검토 이력\")\n", + "print(\"[권한별 요구] 계약 담당: 입력·수정 / 계약관·법무: 검토·의견 / 승인권자: 결재(미션 7에서 구현)\")\n", + "\n", + "plt.figure(figsize=(9, 5))\n", + "y = np.arange(len(proc))\n", + "plt.barh(y - 0.2, proc[\"AS-IS(일)\"], 0.4, label=\"AS-IS\", color=\"tab:gray\")\n", + "plt.barh(y + 0.2, proc[\"TO-BE(일)\"], 0.4, label=\"TO-BE\", color=\"tab:blue\")\n", + "plt.yticks(y, [f\"step {i + 1}{' *' if b else ''}\" for i, b in enumerate(proc[\"병목\"])])\n", + "plt.gca().invert_yaxis(); plt.xlabel(\"days per case\"); plt.legend()\n", + "plt.title(\"Procurement plan review: AS-IS vs TO-BE (* = bottleneck, synthetic)\")\n", + "plt.tight_layout(); plt.show()\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 01 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + "[스키마 검증] 계약 100건 중 형식 통과 100건, 형식 오류 0건 / 필드 16개 / 조항 문장 600개\n", + "논리 규칙(납품 기한 > 계약 기간) 위반 2건은 '계약 결함'으로 분류해 룰 엔진(미션 3)에서 다룬다\n", + "수의계약 14건, 평균 금액 273백만원, 지체상금률 1.0 미만 2건, 보안 조항 없음 4건\n", + "\n", + "[오류 주입 결과]\n", + " - contract_id: 형식 불일치 'C12'\n", + " - method: 허용되지 않은 값 '협의계약'\n", + " - amount_m: 최소값 1 미만\n", + " - period_days: 자료형 오류(str)\n", + " - delivery_days: 납품 기한이 계약 기간보다 김\n", + " - security: 필수 값 누락\n", + " - penalty_permil: 필드 누락\n", + "\n", + "[AS-IS / TO-BE 업무 단계 비교]\n", + " 단계 작업(일) 대기(일) AS-IS(일) TO-BE 방식 TO-BE(일) 병목\n", + " 01. 계획안·계약서 초안 접수 1.0 2.0 3.0 유지(사람 판단) 3.0 \n", + " 02. 서식·첨부 서류 확인 2.6 2.8 5.4 서식 자동 점검(미션 2) 1.1 \n", + " 03. 사업·예산 부합 검토 3.1 2.2 5.3 유지(사람 판단) 5.3 \n", + " 04. 계약 방식 적정성 검토 2.3 1.0 3.3 유지(사람 판단) 3.3 \n", + "05. 조항별 위험 문구 수작업 검토 7.2 2.2 9.4 위험 문구 자동 식별(미션 4) 1.4 병목\n", + " 06. 규정 준수 점검표 작성 4.0 1.0 5.1 룰 엔진 점검표 자동 생성(미션 3) 0.5 \n", + " 07. 위험 조항 협의 회의 0.9 7.5 8.4 위험 점수 기반 회의 안건 선별(미션 5) 2.5 병목\n", + " 08. 계약관·법무 검토 의뢰 0.9 8.9 9.8 유지(사람 판단) 9.8 병목\n", + " 09. 수정 요청·업체 협의 3.3 6.7 10.0 유지(사람 판단) 10.0 병목\n", + " 10. 수정본 재검토 3.1 2.4 5.5 수정 대조 자동 비교(미션 6) 2.2 \n", + " 11. 결재 상신 0.9 5.7 6.6 유지(사람 판단) 6.6 병목\n", + " 12. 계약 의뢰 등록 1.0 2.1 3.1 유지(사람 판단) 3.1 \n", + "\n", + "업무 단계 12개(목표 10개 이상: 달성), 병목 5개소(목표 3개소 이상: 달성)\n", + "계획안 1건 검증 소요: AS-IS 75.1일 -> TO-BE 49.0일 (35% 단축 예상). 법무·계약관 검토와 결재는 사람이 수행한다.\n", + "\n", + "[입출력 명세] 입력: 계약 속성 16개 필드 + 조항 문장 목록 / 출력: 위반 등급, 적중 룰·근거 조항, 위험 문구 하이라이트, 검토 이력\n", + "[권한별 요구] 계약 담당: 입력·수정 / 계약관·법무: 검토·의견 / 승인권자: 결재(미션 7에서 구현)\n", + "```\n", + "
\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 02. 계약서 텍스트 파싱, 불공정 조항 키워드 추출 및 조항별 정규화 전처리 모듈 구현\n", + "**소요시간**: 약 2.0시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "계약서 텍스트 파싱, 불공정 조항 키워드 추출 및 정규화 전처리 모듈을 구현한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **서식이 다른 계약 자료(CSV, 엑셀 내보내기, JSON, 계약서·공문 텍스트)를 하나의 표준 스키마로 바꾸고 불공정 조항 키워드를 추출하는 정규화 전처리 모듈을 구현한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **열 이름 별칭 사전으로 CSV, 탭 구분(엑셀 내보내기), JSON 세 형식을 표준 스키마로 바꾸고, 금액(억원·백만원·만원·원), 기간(일·주·개월·년), 지체상금률(%, 1/1000, 천분의), 선금 표기를 통일 수행하고 검증하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 5개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `src/connectors/prc01_data_loader.py 및 스키마 정의서` 형태의 결과물을 완성한다.\n", + "\n", + "### 🔍 평가 및 검증 기준 (PASS 조건)\n", + "#### 미션 판정\n", + "\n", + "**평가 구분:** 필수\n", + "\n", + "**핵심 평가영역:** 국방 서식/양식 변경에 대한 유연한 확장성\n", + "\n", + "#### 미션 PASS 조건\n", + "\n", + "- 정량 기준: 지원 파일 포맷(CSV, Excel, JSON 등) 파싱 성공률 100%\n", + "\n", + "\n", + "- 진행 기준: 데이터 로더 및 스키마 유효성 검사 모듈 완성\n", + "\n", + "\n", + "- 검증 방법: 다양한 형태의 샘플 군수 문서 대상 파싱 테스트\n", + "\n", + "\n", + "#### 채점자 판정 메모\n", + "\n", + "> 단순 코드 실행 여부뿐만 아니라 출력된 수치와 근거 자료가 군 실무 요구사항에 부합하는지 면밀히 확인한다.\n", + "\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "\"\"\"서식이 서로 다른 가상 계약 자료(CSV, 엑셀 내보내기 TSV, JSON, 계약서·공문 텍스트)를 열 이름 별칭 사전과 정규식으로 표준 스키마로 바꾸고, 금액·기간·지체상금률·선금 표기와 조항 문장을 정규화하며 불공정 조항 키워드를 추출하는 전처리 모듈을 구현한다.\"\"\"\n", + "import csv\n", + "import io\n", + "import json\n", + "import re\n", + "import pandas as pd\n", + "\n", + "# ---------------------------------------------------------------------------\n", + "# [교육용가상자료] 서로 다른 형식의 원시 자료 (실제 계약 아님). 금액·기간·비율 표기가 일부러 제각각이다.\n", + "# ---------------------------------------------------------------------------\n", + "RAW_CSV = \"\"\"계약번호,계약방식,계약금액,계약기간,지체상금률,선금\n", + "C-0101,일반경쟁,1.5억원,90일,0.15%,30%\n", + "C-0102,수의계약,\"3,200만원\",3개월,1.5/1000,20퍼센트\n", + "C-0103,제한경쟁,250백만원,1년,1.0‰,0 %\n", + "C-0104,지명경쟁,\"1,200,000,000원\",180일,천분의 2.0,10%\n", + "C-0105,수의계약,4800만원,45일,0.05%,60%\n", + "C-0106,일반경쟁,95백만원,12주,1.5/1000,0%\n", + "\"\"\"\n", + "RAW_TSV = \"계약 번호\\t계약 방식\\t총액\\t이행기간\\t지체율\\t선급금\\n\" \\\n", + " \"C-0107\\t일반경쟁\\t0.8억원\\t60일\\t0.1%\\t10%\\n\" \\\n", + " \"C-0108\\t수의계약\\t290백만원\\t2개월\\t1.0/1000\\t50퍼센트\\n\" \\\n", + " \"C-0101\\t일반경쟁\\t150백만원\\t3개월\\t1.5/1000\\t30%\\n\" \\\n", + " \"C-0109\\t제한경쟁\\t5,000만원\\t1년\\t천분의 1.5\\t0%\\n\" \\\n", + " \"C-0110\\t지명경쟁\\t2.2억원\\t240일\\t0.2%\\t20%\\n\"\n", + "RAW_JSON = json.dumps({\"status\": \"OK\", \"data\": [\n", + " {\"id\": \"C-0111\", \"method\": \"일반경쟁\", \"terms\": {\"amount\": \"75백만원\", \"period\": {\"value\": 4, \"unit\": \"month\"}, \"penalty\": \"1.5/1000\", \"advance\": \"10%\"}},\n", + " {\"id\": \"C-0112\", \"method\": \"수의계약\", \"terms\": {\"amount\": \"3.4억원\", \"period\": {\"value\": 20, \"unit\": \"week\"}, \"penalty\": \"0.08%\", \"advance\": \"40퍼센트\"}},\n", + " {\"id\": \"C-0113\", \"method\": \"제한경쟁\", \"terms\": {\"amount\": \"880만원\", \"period\": {\"value\": 30, \"unit\": \"day\"}, \"penalty\": \"2.0‰\", \"advance\": \"0%\"}}]},\n", + " ensure_ascii=False)\n", + "DOCS = [\n", + " \"계약서(가상)\\n제1조(계약금액) 계약금액은 금 150,000,000원으로 한다.\\n제2조(계약기간) 계약기간은 계약일부터 3개월로 한다.\\n\"\n", + " \"제3조(지체상금) 계약상대자가 납기를 지체한 경우 지체일수 1일마다 계약금액의 0.15%를 지체상금으로 부과한다.\\n\"\n", + " \"제4조(선금) 선금은 계약금액의 30퍼센트 이내에서 지급한다.\\n제5조(검사) 발주기관은 납품 후 7일 이내에 검사한다.\\n\"\n", + " \"제6조(해지) 계약상대자는 언제든지 일방적으로 계약을 해지할 수 있으며 그에 따른 손해는 발주기관이 부담한다.\",\n", + " \"수신: 계약담당관\\n제목: 조달계획안 검토 요청\\n1. 계약 개요\\n 가. 계약금액 : 2.5억원\\n 나. 계약기간 : 1년\\n 다. 지체상금률 : 천분의 0.8\\n 라. 선금 : 계약금액의 60 %\\n\"\n", + " \"2. 주요 조항\\n 가. 납기는 자재 수급 사정에 따라 계약 상대자가 연장할 수 있다.\\n 나. 하도급은 신고 없이 제한 없이 허용한다.\",\n", + " \"[계약 조건표]\\n계약금액: 320백만원\\n계약기간: 120일\\n지체상금률: 1.5/1000\\n선금: 20%\\n[조항 요약]\\n\"\n", + " \"(1) 지체상금은 면제 사유가 없는 한 부과한다.\\n(2) 납품 즉시 검사에 합격한 것으로 본다.\",\n", + " \"CONTRACT SUMMARY (virtual)\\nContract Amount: KRW 95 million\\nPeriod: 6 months\\nLate Penalty: 1.0 permil\\nAdvance Payment: 10 percent\\n\"\n", + " \"Clause: 지체상금은 전액 면제하며 발주기관은 이의를 제기하지 못한다.\",\n", + " \"계약 1건 요약 - 금액 4,800만원 / 기간 45일 / 지체상금 1.0‰ / 선금 0% / 비고: 납기일 이후 1회에 한해 기한 없이 자동 연장되는 것으로 한다.\",\n", + "]\n", + "\n", + "# ---- 정규화 규칙(서식이 바뀌면 이 사전만 고치면 된다) ----\n", + "COL_ALIAS = {\"contract_id\": [\"계약번호\", \"계약 번호\", \"id\"], \"method\": [\"계약방식\", \"계약 방식\", \"method\"], \"amount\": [\"계약금액\", \"총액\", \"amount\"],\n", + " \"period\": [\"계약기간\", \"이행기간\", \"period\"], \"penalty\": [\"지체상금률\", \"지체율\", \"penalty\"], \"advance\": [\"선금\", \"선급금\", \"advance\"]}\n", + "UNIT_DAYS = {\"일\": 1, \"day\": 1, \"주\": 7, \"week\": 7, \"개월\": 30, \"month\": 30, \"months\": 30, \"년\": 365, \"year\": 365}\n", + "KW = {\"독소\": [\"면제\", \"일방적\", \"신고 없이\", \"제한 없이\", \"전액 부담\", \"포기\", \"누설\", \"지지 않는다\"], \"납기위험\": [\"자동 연장\", \"기한 없이\", \"연장할 수 있다\", \"정하지 않는다\", \"통보한다\"]}\n", + "# 서식별 추출 규칙(비정형 서식 보완 시 여기에 정규식을 한 줄 추가한다)\n", + "DOC_PAT = {\"amount\": [r\"(?:계약금액|금액|Contract Amount)\\s*(?:은|는)?\\s*[::]?\\s*(?:금\\s*|KRW\\s*)?([\\d,\\.]+\\s*(?:억원|백만원|만원|million|원))\"],\n", + " \"period\": [r\"(?:계약기간|기간|Period)\\s*(?:은|는)?\\s*[::]?\\s*(?:계약일부터\\s*)?(\\d+\\s*(?:일|개월|년|months?|days?))\"],\n", + " \"advance\": [r\"(?:선금|Advance Payment)[^\\n/]*?([\\d.]+\\s*(?:%|퍼센트|percent))\"]}\n", + "PEN_PAT = [r\"천분의\\s*([\\d.]+)\", r\"([\\d.]+)\\s*%\", r\"([\\d.]+)\\s*(?:/1000|‰|permil)\"]\n", + "CLAUSE_PAT = [r\"^제\\d+조\\([^)]*\\)\\s*(.+)$\", r\"^\\s*[가-라]\\.\\s*(.+다\\.)$\", r\"^\\(\\d+\\)\\s*(.+)$\", r\"^Clause:\\s*(.+)$\", r\"비고:\\s*(.+)$\"]\n", + "\n", + "\n", + "def norm_amount(s):\n", + " \"\"\"금액 표기를 백만원 단위 숫자로 통일: 1.5억원, 250백만원, 3,200만원, 1,200,000,000원, KRW 95 million.\"\"\"\n", + " # [TODO : 학생 실습 2] 계약서 텍스트 파싱, 불공정 조항 키워드 추출 및 조항별 정규화 전처리 모듈 구현 관련 핵심 로직을 구현하세요.\n", + " # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n", + " # <<< 여기에 코드를 작성하세요 >>>\n", + " pass # 작성 후 pass를 제거하세요.\n", + "def norm_period(v):\n", + " # [TODO : 학생 실습 2] 계약서 텍스트 파싱, 불공정 조항 키워드 추출 및 조항별 정규화 전처리 모듈 구현 관련 핵심 로직을 구현하세요.\n", + " # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n", + " # <<< 여기에 코드를 작성하세요 >>>\n", + " pass # 작성 후 pass를 제거하세요.\n", + "def norm_rate(s, per_mille=False):\n", + " \"\"\"비율 표기를 통일: 지체상금률은 1/1000(천분율) 단위, 선금은 % 단위.\"\"\"\n", + " s = str(s).strip()\n", + " for pat, k in [(r\"^천분의\\s*([\\d.]+)$\", 1), (r\"^([\\d.]+)\\s*(?:/1000|‰|permil)$\", 1), (r\"^([\\d.]+)\\s*(?:%|퍼센트|percent)$\", 10 if per_mille else 1)]:\n", + " m = re.match(pat, s)\n", + " if m:\n", + " return round(float(m.group(1)) * k, 3)\n", + " raise ValueError(f\"비율 해석 불가: {s}\")\n", + "\n", + "\n", + "def pick(row, key):\n", + " for k in COL_ALIAS[key]:\n", + " if k in row:\n", + " return row[k]\n", + " raise KeyError(f\"필수 열 없음: {key}\")\n", + "\n", + "\n", + "def to_std(row, src):\n", + " return {\"contract_id\": str(pick(row, \"contract_id\")).strip(), \"method\": str(pick(row, \"method\")).strip(), \"amount_m\": round(norm_amount(pick(row, \"amount\")), 2),\n", + " \"period_days\": norm_period(pick(row, \"period\")), \"penalty_permil\": norm_rate(pick(row, \"penalty\"), True), \"advance_pct\": norm_rate(pick(row, \"advance\")), \"source\": src}\n", + "\n", + "\n", + "def load(text, fmt):\n", + " \"\"\"형식별 로더: csv / tsv(엑셀 내보내기) / json. 행마다 성공·실패를 따로 센다.\"\"\"\n", + " if fmt == \"json\":\n", + " rows = [{\"id\": r[\"id\"], \"method\": r[\"method\"], \"amount\": r[\"terms\"][\"amount\"], \"period\": r[\"terms\"][\"period\"], \"penalty\": r[\"terms\"][\"penalty\"],\n", + " \"advance\": r[\"terms\"][\"advance\"]} for r in json.loads(text)[\"data\"]]\n", + " else:\n", + " rows = list(csv.DictReader(io.StringIO(text), delimiter=\"\\t\" if fmt == \"tsv\" else \",\"))\n", + " ok, fail = [], []\n", + " for r in rows:\n", + " try:\n", + " ok.append(to_std(r, fmt))\n", + " except (KeyError, ValueError) as e:\n", + " fail.append((r, str(e)))\n", + " return ok, fail\n", + "\n", + "\n", + "def norm_clause(s):\n", + " \"\"\"조항 문장 정규화: 띄어쓰기·호칭 통일, 번호 제거.\"\"\"\n", + " s = re.sub(r\"^\\s*(?:제\\d+조\\([^)]*\\)|\\(\\d+\\)|[가-라]\\.)\\s*\", \"\", s)\n", + " s = s.replace(\"계약 상대자\", \"계약상대자\").replace(\"발주 기관\", \"발주기관\").replace(\"계약담당관\", \"발주기관\")\n", + " return re.sub(r\"\\s+\", \" \", s).strip()\n", + "\n", + "\n", + "def parse_doc(text):\n", + " \"\"\"계약서·공문 텍스트 1건에서 금액·기간·지체상금률·선금과 조항 문장을 추출한다.\"\"\"\n", + " out = {}\n", + " for key, pats in DOC_PAT.items():\n", + " for p in pats:\n", + " m = re.search(p, text)\n", + " if m:\n", + " out[key] = m.group(1); break\n", + " else:\n", + " raise ValueError(f\"필수 항목({key}) 추출 실패\")\n", + " pen = None\n", + " for line in re.split(r\"\\n| / \", text):\n", + " if re.search(r\"지체상금|Late Penalty\", line):\n", + " for p in PEN_PAT:\n", + " m = re.search(p, line)\n", + " if m:\n", + " unit = \"%\" if p == PEN_PAT[1] else \"\"\n", + " pen = (m.group(1) + unit) if unit else ((\"천분의 \" + m.group(1)) if p == PEN_PAT[0] else m.group(0)); break\n", + " if pen:\n", + " break\n", + " if not pen:\n", + " raise ValueError(\"필수 항목(지체상금률) 추출 실패\")\n", + " clauses = []\n", + " for line in re.split(r\"\\n| / \", text):\n", + " for p in CLAUSE_PAT:\n", + " m = re.search(p, line.strip())\n", + " if m:\n", + " c = norm_clause(m.group(1) if p != CLAUSE_PAT[2] else line)\n", + " if c.endswith((\"다.\", \"한다\")) or \"다.\" in c:\n", + " clauses.append(c)\n", + " break\n", + " return {\"amount_m\": round(norm_amount(out[\"amount\"].replace(\"KRW\", \"\").strip()), 2), \"period_days\": norm_period(out[\"period\"]),\n", + " \"penalty_permil\": norm_rate(pen, True), \"advance_pct\": norm_rate(out[\"advance\"].replace(\"percent\", \"퍼센트\")), \"clauses\": clauses}\n", + "\n", + "\n", + "def extract_keywords(clause):\n", + " return [f\"{c}:{k}\" for c, ks in KW.items() for k in ks if k in clause]\n", + "\n", + "\n", + "# 1) 형식별 파싱\n", + "results, total, success = [], 0, 0\n", + "for fmt, text in [(\"csv\", RAW_CSV), (\"tsv\", RAW_TSV), (\"json\", RAW_JSON)]:\n", + " ok, fail = load(text, fmt)\n", + " results += ok; total += len(ok) + len(fail); success += len(ok)\n", + " print(f\"[{fmt.upper():4s}] {len(ok) + len(fail)}행 중 {len(ok)}행 성공\" + (f\", 실패 {[e for _, e in fail]}\" if fail else \"\"))\n", + "docs, d_fail = [], 0\n", + "for i, t in enumerate(DOCS, 1):\n", + " try:\n", + " docs.append(parse_doc(t))\n", + " except ValueError as e:\n", + " d_fail += 1; print(f\"문서 {i} 파싱 실패:\", e)\n", + "total += len(DOCS); success += len(docs)\n", + "print(f\"[계약서·공문 텍스트] {len(DOCS)}건 중 {len(docs)}건 성공\")\n", + "rate = success / total\n", + "print(f\"\\n전체 파싱 성공률 {rate:.0%} ({success}/{total}) - 목표 100%: {'달성' if rate == 1 else '미달'}\")\n", + "\n", + "# 2) 정제 결과(표기 통일, 중복 계약 정리)\n", + "std = pd.DataFrame(results)\n", + "before = len(std)\n", + "dup = std[std.duplicated(\"contract_id\", keep=False)].sort_values(\"contract_id\")\n", + "same = dup.drop(columns=\"source\").drop_duplicates().shape[0] == dup[\"contract_id\"].nunique() if len(dup) else True\n", + "std = std.drop_duplicates(\"contract_id\").sort_values(\"contract_id\")\n", + "print(f\"\\n[정제된 계약 자료] {before}행 -> 중복 계약 정리 후 {len(std)}행 (중복 {before - len(std)}건: 표기는 달랐지만 정규화 후 값이 {'동일' if same else '불일치'})\")\n", + "print(std.to_string(index=False))\n", + "\n", + "# 3) 계약서 텍스트 파싱 결과와 불공정 조항 키워드\n", + "print(\"\\n[계약서·공문 파싱 결과]\")\n", + "for i, d in enumerate(docs, 1):\n", + " print(f\" 문서 {i}: 금액 {d['amount_m']:g}백만원, 기간 {d['period_days']}일, 지체상금률 {d['penalty_permil']:g}/1000, 선금 {d['advance_pct']:g}%, 조항 {len(d['clauses'])}개\")\n", + "print(\"\\n[조항별 정규화와 불공정·납기 위험 키워드]\")\n", + "n_kw = 0\n", + "for i, d in enumerate(docs, 1):\n", + " for c in d[\"clauses\"]:\n", + " kws = extract_keywords(c)\n", + " if kws:\n", + " n_kw += 1; print(f\" 문서 {i}: {c[:38]}... -> {', '.join(kws)}\")\n", + "print(f\"키워드가 걸린 조항 {n_kw}개 (키워드는 후보 표시이며 실제 불공정 여부는 계약관이 판단한다. 예: 문서 3의 '면제'는 '면제 사유가 없는 한 부과'라는 정상 문장의 오탐 후보 -> 미션 4에서 문맥 모델로 보완)\")\n", + "\n", + "# 4) 서식 변경 대응 시험 ①: 열 이름이 바뀐 새 양식 -> 별칭 사전 추가\n", + "new_form = \"계약 식별번호,방식,대금 총액,수행기간,지체상금 비율,선급\\nC-0121,일반경쟁,120백만원,90일,1.5/1000,10%\\n\"\n", + "ok, fail = load(new_form, \"csv\")\n", + "print(f\"\\n새 양식(열 이름 변경) 1차 시도: 성공 {len(ok)} / 실패 {len(fail)} -> {fail[0][1] if fail else '-'}\")\n", + "for k, alias in {\"contract_id\": \"계약 식별번호\", \"method\": \"방식\", \"amount\": \"대금 총액\", \"period\": \"수행기간\", \"penalty\": \"지체상금 비율\", \"advance\": \"선급\"}.items():\n", + " COL_ALIAS[k].append(alias)\n", + "ok, fail = load(new_form, \"csv\")\n", + "print(f\"별칭 사전 갱신 후: 성공 {len(ok)} / 실패 {len(fail)} (코드 수정 없이 설정만 변경)\")\n", + "\n", + "# 5) 서식 변경 대응 시험 ②: 비정형 공문의 표현이 달라진 경우 -> 정규식 한 줄 추가\n", + "new_doc = \"수신: 계약담당관\\n1. 대금 총액 : 1.2억원\\n2. 계약기간 : 6개월\\n3. 지체상금률 : 1.0/1000\\n4. 선금 : 계약금액의 10%\\n5. 조항: 납기일 이후 1회에 한해 기한 없이 자동 연장되는 것으로 한다.\"\n", + "try:\n", + " parse_doc(new_doc); r1 = \"성공\"\n", + "except ValueError as e:\n", + " r1 = f\"실패({e})\"\n", + "DOC_PAT[\"amount\"].append(r\"(?:대금 총액)\\s*[::]?\\s*([\\d,\\.]+\\s*(?:억원|백만원|만원|원))\")\n", + "d = parse_doc(new_doc)\n", + "print(f\"새 공문 표현('대금 총액') 1차 시도: {r1} -> 규칙 1줄 추가 후 금액 {d['amount_m']:g}백만원 추출 성공\")\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 02 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + "[CSV ] 6행 중 6행 성공\n", + "[TSV ] 5행 중 5행 성공\n", + "[JSON] 3행 중 3행 성공\n", + "[계약서·공문 텍스트] 5건 중 5건 성공\n", + "\n", + "전체 파싱 성공률 100% (19/19) - 목표 100%: 달성\n", + "\n", + "[정제된 계약 자료] 14행 -> 중복 계약 정리 후 13행 (중복 1건: 표기는 달랐지만 정규화 후 값이 동일)\n", + "contract_id method amount_m period_days penalty_permil advance_pct source\n", + " C-0101 일반경쟁 150.0 90 1.5 30.0 csv\n", + " C-0102 수의계약 32.0 90 1.5 20.0 csv\n", + " C-0103 제한경쟁 250.0 365 1.0 0.0 csv\n", + " C-0104 지명경쟁 1200.0 180 2.0 10.0 csv\n", + " C-0105 수의계약 48.0 45 0.5 60.0 csv\n", + " C-0106 일반경쟁 95.0 84 1.5 0.0 csv\n", + " C-0107 일반경쟁 80.0 60 1.0 10.0 tsv\n", + " C-0108 수의계약 290.0 60 1.0 50.0 tsv\n", + " C-0109 제한경쟁 50.0 365 1.5 0.0 tsv\n", + " C-0110 지명경쟁 220.0 240 2.0 20.0 tsv\n", + " C-0111 일반경쟁 75.0 120 1.5 10.0 json\n", + " C-0112 수의계약 340.0 140 0.8 40.0 json\n", + " C-0113 제한경쟁 8.8 30 2.0 0.0 json\n", + "\n", + "[계약서·공문 파싱 결과]\n", + " 문서 1: 금액 150백만원, 기간 90일, 지체상금률 1.5/1000, 선금 30%, 조항 6개\n", + " 문서 2: 금액 250백만원, 기간 365일, 지체상금률 0.8/1000, 선금 60%, 조항 2개\n", + " 문서 3: 금액 320백만원, 기간 120일, 지체상금률 1.5/1000, 선금 20%, 조항 2개\n", + " 문서 4: 금액 95백만원, 기간 180일, 지체상금률 1/1000, 선금 10%, 조항 1개\n", + " 문서 5: 금액 48백만원, 기간 45일, 지체상금률 1/1000, 선금 0%, 조항 1개\n", + "\n", + "[조항별 정규화와 불공정·납기 위험 키워드]\n", + " 문서 1: 계약상대자는 언제든지 일방적으로 계약을 해지할 수 있으며 그에 따른 ... -> 독소:일방적\n", + " 문서 2: 납기는 자재 수급 사정에 따라 계약상대자가 연장할 수 있다.... -> 납기위험:연장할 수 있다\n", + " 문서 2: 하도급은 신고 없이 제한 없이 허용한다.... -> 독소:신고 없이, 독소:제한 없이\n", + " 문서 3: 지체상금은 면제 사유가 없는 한 부과한다.... -> 독소:면제\n", + " 문서 4: 지체상금은 전액 면제하며 발주기관은 이의를 제기하지 못한다.... -> 독소:면제\n", + " 문서 5: 납기일 이후 1회에 한해 기한 없이 자동 연장되는 것으로 한다.... -> 납기위험:자동 연장, 납기위험:기한 없이\n", + "키워드가 걸린 조항 6개 (키워드는 후보 표시이며 실제 불공정 여부는 계약관이 판단한다. 예: 문서 3의 '면제'는 '면제 사유가 없는 한 부과'라는 정상 문장의 오탐 후보 -> 미션 4에서 문맥 모델로 보완)\n", + "\n", + "새 양식(열 이름 변경) 1차 시도: 성공 0 / 실패 1 -> '필수 열 없음: contract_id'\n", + "... (이하 생략)\n", + "```\n", + "
\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 03. 방위사업 계약 필수 규정 준수 여부 자동 판별 1차 Rule 엔진 구현\n", + "**소요시간**: 약 2.5시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "방위사업 계약 필수 규정 준수 여부 판별 Rule 엔진을 구현한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **방위사업 계약 필수 규정(가상 지침)의 준수 여부를 판별하는 룰 15종 이상을 YAML 설정으로 코딩하고 계약별 1차 판별 등급을 산출한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **지체상금률 하한, 선금 한도와 보증, 수의계약 한도, 이행보증, 하자보증, 해지·보안·지식재산 조항 유무, 하도급 비율, 검사 기간, 납품 기한 논리, 물가변동 조항, 장기 계약, 불공정 키워드 등 16종 룰을 가중치 합으로 고위험·주의·관심 등급에 연결 수행하고 검증하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `src/engine/prc01_rule_engine.py` 형태의 결과물을 완성한다.\n", + "\n", + "### 🔍 평가 및 검증 기준 (PASS 조건)\n", + "#### 미션 판정\n", + "\n", + "**평가 구분:** 필수\n", + "\n", + "**핵심 평가영역:** 군 규정 및 지침 조항과의 정확한 일치성\n", + "\n", + "#### 미션 PASS 조건\n", + "\n", + "- 정량 기준: 업무 검증 룰 15종 이상 코딩, 룰 처리 지연시간 50ms 이하\n", + "\n", + "\n", + "- 진행 기준: 룰 엔진 단위 테스트 100% 통과\n", + "\n", + "\n", + "- 검증 방법: 경계 조건(Boundary condition)에 대한 룰 판정 검증\n", + "\n", + "\n", + "#### 채점자 판정 메모\n", + "\n", + "> 단순 코드 실행 여부뿐만 아니라 출력된 수치와 근거 자료가 군 실무 요구사항에 부합하는지 면밀히 확인한다.\n", + "\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "\"\"\"계약 필수 규정 준수 여부를 판별하는 16종 룰(YAML 설정)을 코딩해 계약별 1차 판별 등급을 산출하고, 경계값 단위 시험 32건, 룰 처리 지연시간, 룰별 근거 조항 연결, 등급별 실제 위반율·재현율, 규정 변경 시 설정만 바꾸는 확장성을 확인한다.\"\"\"\n", + "import time\n", + "import numpy as np\n", + "import pandas as pd\n", + "import matplotlib.pyplot as plt\n", + "\n", + "# ---------------------------------------------------------------------------\n", + "# [교육용가상자료] 가상 조달계획안·계약서 생성기 (실제 계약·업체·법령 조문 없음)\n", + "# 계약 속성 16개 + 조항 문장 6개를 만들고, 속성 위반·독소 조항·납기 위험 문구가 섞인 '규정 위반 여부'를 함께 만든다.\n", + "# ---------------------------------------------------------------------------\n", + "TYPES = [\"물품\", \"용역\", \"공사\"]\n", + "METHODS = [\"일반경쟁\", \"제한경쟁\", \"지명경쟁\", \"수의계약\"]\n", + "CLASSES = [\"정상\", \"독소\", \"납기위험\"]\n", + "OBJ = [\"납품 물품\", \"용역 결과물\", \"시공 목적물\", \"정비 성과물\"]\n", + "\n", + "N_TPL = [\n", + " \"계약상대자가 납기를 지체한 경우 지체일수 1일마다 계약금액의 {p}/1000에 해당하는 지체상금을 부과한다.\",\n", + " \"지체상금은 불가항력 사유가 확인된 경우에 한하여 발주기관의 승인을 받아 면제할 수 있다.\",\n", + " \"납기 연장은 발주기관이 불가항력 사유를 확인하고 서면으로 승인한 경우에만 인정한다.\",\n", + " \"발주기관은 납품된 {o}을 {d}일 이내에 검사하고 합격한 후에 대금을 지급한다.\",\n", + " \"계약상대자는 하도급을 하려는 경우 사전에 발주기관의 서면 승인을 받아야 한다.\",\n", + " \"어느 한쪽이 계약을 해지하려면 사유를 명시해 {d}일 전에 서면으로 통지해야 한다.\",\n", + " \"{o}에 하자가 발생하면 계약상대자는 보증 기간 안에 무상으로 보수하거나 교체한다.\",\n", + " \"계약 이행 중 알게 된 비공개 사항은 제3자에게 누설하지 않으며 위반 시 책임을 진다.\",\n", + " \"{o}에 대한 지식재산권의 귀속과 사용 범위는 별도 합의서에 따라 정한다.\",\n", + " \"대금은 검사 합격 후 청구서를 접수한 날부터 {d}일 이내에 지급한다.\",\n", + " \"계약금액의 조정은 물가변동 기준에 따라 양 당사자가 서면으로 합의한 경우에만 한다.\",\n", + " \"계약상대자는 이행보증서를 계약 체결 시 제출하며 보증금은 계약 이행 완료 후 반환한다.\",\n", + " \"납품 일정은 계약서 별표의 일자를 따르며 변경 시 서면 합의가 필요하다.\",\n", + " \"지체상금은 면제 사유가 없는 한 부과하며 이의는 정해진 절차로 제기할 수 있다.\",\n", + " \"검사에 합격하지 못한 {o}은 계약상대자가 교체하며 그 비용은 계약상대자가 부담한다.\",\n", + " \"계약상대자의 귀책 사유로 이행이 불가능하면 발주기관은 서면으로 통지하고 계약을 해지할 수 있다.\",\n", + " \"지연이 예상되면 계약상대자는 사유와 만회 계획을 발주기관에 서면으로 보고한다.\",\n", + " \"보증 기간 중 발생한 결함은 계약상대자가 책임지고 보수한다.\",\n", + " \"분쟁은 먼저 협의로 해결하고 협의가 되지 않으면 관할 법원의 판단에 따른다.\",\n", + "]\n", + "T_TPL = [\n", + " \"계약상대자의 지체상금은 전액 면제하며 발주기관은 이에 대해 이의를 제기하지 못한다.\",\n", + " \"납품 즉시 검사에 합격한 것으로 보며 발주기관은 이후 검사를 요구하지 않는다.\",\n", + " \"계약상대자는 언제든지 일방적으로 계약을 해지할 수 있으며 그에 따른 손해는 발주기관이 부담한다.\",\n", + " \"{o}의 추가 비용은 사유를 불문하고 발주기관이 전액 부담하며 별도 협의 없이 청구할 수 있다.\",\n", + " \"{o}에 관한 지식재산권은 모두 계약상대자에게 귀속되고 발주기관은 사용할 수 없다.\",\n", + " \"하도급은 신고 없이 제한 없이 허용하며 발주기관은 하도급 내용을 확인하지 않는다.\",\n", + " \"대금은 검사 전에 전액 선지급하며 발주기관은 반환 청구권을 포기한다.\",\n", + " \"계약 이행 중 알게 된 사항의 누설로 인한 책임을 계약상대자는 지지 않는다.\",\n", + " \"하자 보수 책임은 인도 후 즉시 소멸하며 발주기관은 보수를 요구할 수 없다.\",\n", + " \"발주기관은 계약상대자가 청구하는 금액을 거절할 수 없으며 거절하면 위약금을 지급한다.\",\n", + " \"분쟁이 생기면 계약상대자가 정하는 중재 기관에서만 해결하고 발주기관은 따른다.\",\n", + " \"발주기관의 검사는 생략하고 계약상대자의 시험 결과서로 대신한다.\",\n", + " \"견적 금액은 사후 정산 없이 그대로 확정하고 증빙 자료는 받지 않는다.\",\n", + "]\n", + "D_TPL = [\n", + " \"납기는 자재 수급 사정에 따라 계약상대자가 연장할 수 있다.\",\n", + " \"납품 일정은 가능한 한 지키도록 노력하며 지연되어도 사유를 보고하지 않는다.\",\n", + " \"공급 지연이나 선행 업체 사정도 불가항력으로 보아 납기를 자동 연장한다.\",\n", + " \"착수일은 선행 업체의 납품이 끝난 뒤 별도로 정하며 완료 기한은 정하지 않는다.\",\n", + " \"납기일 이후 1회에 한해 기한 없이 자동 연장되는 것으로 한다.\",\n", + " \"납기는 계약상대자의 생산 일정이 확정되는 대로 통보한다.\",\n", + " \"납품 시점은 계약상대자 사정이 허락하는 때로 하고 날짜는 따로 정하지 않는다.\",\n", + " \"자재가 늦게 들어오면 완료 일정은 그만큼 자동으로 늦춰진다.\",\n", + " \"기한 내에 인도하지 못해도 계약상대자에게 책임을 묻지 않는다.\",\n", + " \"일정이 늦어져도 계약상대자는 새 계획서를 낼 필요가 없다.\",\n", + "]\n", + "# 학습에 쓰지 않는 '처음 보는 표현'(모델이 문장 틀을 외웠는지 확인하는 용도)\n", + "HOLD = {\n", + " \"정상\": [\"검사에 불합격한 경우 계약상대자는 지정 기한 안에 재납품하며 재검사 비용은 상대자가 부담한다.\",\n", + " \"계약상대자가 법령을 위반하여 계약을 이행하지 못하면 발주기관은 서면 통지 후 계약을 해지할 수 있다.\",\n", + " \"납기가 지연될 우려가 있으면 상대자는 즉시 사유와 만회 계획을 서면으로 보고해야 한다.\",\n", + " \"하자담보 책임 기간은 인수일부터 기산하며 기간 중 발생한 결함은 상대자가 책임진다.\"],\n", + " \"독소\": [\"발주기관은 계약상대자가 요구하는 대금을 이유 없이 거절하지 못하며 거절 시 위약금을 낸다.\",\n", + " \"계약 내용에 관한 분쟁은 계약상대자가 지정하는 기관에서만 다루며 발주기관은 이에 따른다.\",\n", + " \"발주기관의 검수 권한은 포기하고 계약상대자의 자체 시험 성적서로 갈음한다.\",\n", + " \"계약상대자가 제출한 견적은 사후 정산 없이 확정 단가로 하며 증빙은 요구하지 않는다.\"],\n", + " \"납기위험\": [\"납품 시기는 상대자의 형편이 허락하는 시점으로 하고 별도 날짜는 정하지 않는다.\",\n", + " \"원자재 확보가 늦어지면 완료 일자는 자동으로 뒤로 미뤄지는 것으로 본다.\",\n", + " \"인도 기한은 참고용이며 지키지 못해도 책임을 묻지 않는다.\",\n", + " \"일정 지연이 있어도 상대자는 변경 계획을 제출할 의무가 없다.\"],\n", + "}\n", + "SUFFIX = [\"\", \"\", \"\", \" 세부 사항은 시행 세칙에 따른다.\", \" 이 조항은 계약 전체에 적용한다.\", \" 필요한 경우 양 당사자가 협의한다.\", \" 관련 서식은 별지를 따른다.\"]\n", + "SWAP = [(\"발주기관\", \"계약담당관\"), (\"계약상대자\", \"업체\")]\n", + "\n", + "\n", + "def make_clause(rng, cls, hold=False, idx=None):\n", + " \"\"\"조항 한 문장 생성. 표현 일부는 동의어로 바꾸고 접미 문구를 무작위로 붙인다(idx를 주면 그 번호의 문장 틀을 사용).\"\"\"\n", + " if idx is not None:\n", + " tpl = {\"정상\": N_TPL, \"독소\": T_TPL, \"납기위험\": D_TPL}[cls]\n", + " s = tpl[idx].format(p=rng.choice([\"1.0\", \"1.5\", \"2.0\"]), d=int(rng.choice([7, 10, 14, 30])), o=rng.choice(OBJ))\n", + " elif hold:\n", + " s = HOLD[cls][rng.integers(len(HOLD[cls]))]\n", + " else:\n", + " tpl = {\"정상\": N_TPL, \"독소\": T_TPL, \"납기위험\": D_TPL}[cls]\n", + " s = tpl[rng.integers(len(tpl))].format(p=rng.choice([\"1.0\", \"1.5\", \"2.0\"]), d=int(rng.choice([7, 10, 14, 30])), o=rng.choice(OBJ))\n", + " if rng.random() < 0.3:\n", + " a, b = SWAP[rng.integers(2)]\n", + " s = s.replace(a, b)\n", + " return s + SUFFIX[rng.integers(len(SUFFIX))]\n", + "\n", + "\n", + "def make_contracts(n=200, seed=7):\n", + " \"\"\"가상 계약 n건(속성 16개 + 조항 6개 + 규정 위반 정답)을 만든다.\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " sl = rng.beta(1.2, 3.2, n) # 계약서 작성이 허술한 정도(잠재 변수)\n", + " ctype = rng.choice(TYPES, n, p=[.5, .3, .2])\n", + " amount = np.clip(np.exp(rng.normal(np.log(220), 1.1, n)), 5, 5000).round(0)\n", + " method = np.where(rng.random(n) < .06 + .3 * sl, \"수의계약\", rng.choice(METHODS[:3], n, p=[.5, .3, .2]))\n", + " period = np.array([rng.integers(60, 300) if t == \"물품\" else rng.integers(90, 400) if t == \"용역\" else rng.integers(180, 720) for t in ctype])\n", + " delivery = (period * rng.uniform(.5, 1.0, n)).astype(int)\n", + " bad_logic = rng.random(n) < .015 * (1 + 3 * sl)\n", + " delivery = np.where(bad_logic, period + rng.integers(10, 60, n), delivery)\n", + " pen = np.where(rng.random(n) < .22 * sl, rng.choice([.3, .5, .8], n), rng.choice([1.0, 1.5, 2.0, 2.5], n))\n", + " adv = np.where(rng.random(n) < .22 * sl, rng.choice([50, 60, 70], n), rng.choice([0, 10, 20, 30, 40], n))\n", + " bond = np.where(rng.random(n) < .25 * sl + .5 * (adv > 40), rng.choice([0, 5], n), rng.choice([10, 10, 15, 20], n))\n", + " war = np.array([rng.choice([3, 6]) if (rng.random() < .25 * s and t == \"물품\") else rng.choice([12, 24, 36]) if t == \"물품\" else rng.choice([0, 6, 12]) if t == \"용역\" else rng.choice([24, 36, 60]) for t, s in zip(ctype, sl)])\n", + " insp = np.where(rng.random(n) < .3 * sl, rng.choice([21, 30], n), rng.choice([5, 7, 10, 14], n))\n", + " sub = np.where(rng.random(n) < .15 * sl, rng.choice([60, 70, 80], n), rng.choice([0, 10, 20, 30, 40, 50], n))\n", + " padj = rng.random(n) < .75 - .4 * sl\n", + " term = rng.random(n) > .015 * (1 + 6 * sl)\n", + " sec = rng.random(n) > .015 * (1 + 6 * sl)\n", + " ip = rng.random(n) > .03 * (1 + 4 * sl)\n", + " clauses, cls_true = [], []\n", + " for i in range(n):\n", + " pt, pd_ = .01 + .08 * sl[i], .02 + .08 * sl[i]\n", + " cl = [rng.choice(CLASSES, p=[1 - pt - pd_, pt, pd_]) for _ in range(6)]\n", + " cls_true.append(cl); clauses.append([make_clause(rng, c) for c in cl])\n", + " df = pd.DataFrame({\"contract_id\": [f\"C-{i + 1:04d}\" for i in range(n)], \"ctype\": ctype, \"method\": method, \"amount_m\": amount,\n", + " \"period_days\": period, \"delivery_days\": delivery, \"penalty_permil\": pen, \"advance_pct\": adv, \"perf_bond_pct\": bond,\n", + " \"warranty_months\": war, \"inspection_days\": insp, \"subcontract_pct\": sub, \"price_adjust\": padj,\n", + " \"termination\": term, \"security\": sec, \"ip_clause\": ip})\n", + " hard = ((pen < 1.0) | ((adv > 40) & (bond < 10)) | ((amount >= 300) & (method == \"수의계약\")) | ((ctype == \"물품\") & (war < 12))\n", + " | ~term | ~sec | (sub > 50) | (delivery > period))\n", + " toxic = np.array([c.count(\"독소\") for c in cls_true])\n", + " delay = np.array([c.count(\"납기위험\") for c in cls_true])\n", + " split = (method == \"수의계약\") & (amount >= 250) & (amount < 300) & (rng.random(n) < .6) # 계약 쪼개기 의심(임계값 바로 아래)\n", + " df[\"clauses\"], df[\"clause_cls\"] = clauses, cls_true\n", + " df[\"n_toxic\"], df[\"n_delay\"] = toxic, delay\n", + " viol = hard | (toxic > 0) | split | (delay >= 2)\n", + " waived = hard & (toxic == 0) & (rng.random(n) < .12) # 계약관이 예외 사유를 인정해 위반으로 보지 않은 건(자료의 현실적 잡음)\n", + " hidden = ~viol & (rng.random(n) < .03 + .08 * sl) # 속성·문구에 드러나지 않는 위반(별첨 합의 등)\n", + " df[\"violation\"] = (viol & ~waived) | hidden\n", + " df[\"vtype\"] = np.where(hidden, \"기타\", np.where(hard, \"속성위반\", np.where(toxic > 0, \"독소조항\", np.where(split | (delay >= 2), \"기타\", \"-\"))))\n", + " return df\n", + "\n", + "# ---- 계약 규정 준수 판별 룰 설정(YAML 형식 문자열: 규정이 바뀌면 코드가 아니라 이 설정만 고친다) ----\n", + "RULES_YAML = \"\"\"\n", + "- id: R01\n", + " name: 지체상금률 1.0/1000 미만\n", + " field: penalty_permil\n", + " op: \"<\"\n", + " value: 1.0\n", + " weight: 3\n", + "- id: R02\n", + " name: 선금 비율 40% 초과\n", + " field: advance_pct\n", + " op: \">\"\n", + " value: 40\n", + " weight: 2\n", + "- id: R03\n", + " name: 선금 40% 초과인데 이행보증 10% 미만\n", + " field: adv_no_bond\n", + " op: \"==\"\n", + " value: true\n", + " weight: 3\n", + "- id: R04\n", + " name: 300백만원 이상 수의계약\n", + " field: sole_amount\n", + " op: \">=\"\n", + " value: 300\n", + " weight: 3\n", + "- id: R05\n", + " name: 500백만원 이상 이행보증 10% 미만\n", + " field: bond_gap\n", + " op: \"==\"\n", + " value: true\n", + " weight: 2\n", + "- id: R06\n", + " name: 물품 하자보증 12개월 미만\n", + " field: warranty_gap\n", + " op: \"==\"\n", + " value: true\n", + " weight: 2\n", + "- id: R07\n", + " name: 해지 조항 없음\n", + " field: termination\n", + " op: \"==\"\n", + " value: false\n", + " weight: 2\n", + "- id: R08\n", + " name: 보안 조항 없음\n", + " field: security\n", + " op: \"==\"\n", + " value: false\n", + " weight: 3\n", + "- id: R09\n", + " name: 지식재산 조항 없음(물품·용역)\n", + " field: ip_missing\n", + " op: \"==\"\n", + " value: true\n", + " weight: 1\n", + "- id: R10\n", + " name: 하도급 비율 50% 초과\n", + " field: subcontract_pct\n", + " op: \">\"\n", + " value: 50\n", + " weight: 3\n", + "- id: R11\n", + " name: 검사 기간 14일 초과\n", + " field: inspection_days\n", + " op: \">\"\n", + " value: 14\n", + " weight: 1\n", + "- id: R12\n", + " name: 납품 기한이 계약 기간 초과\n", + " field: logic_err\n", + " op: \"==\"\n", + " value: true\n", + " weight: 3\n", + "- id: R13\n", + " name: 180일 이상 계약에 물가변동 조항 없음\n", + " field: no_padj_long\n", + " op: \"==\"\n", + " value: true\n", + " weight: 1\n", + "- id: R14\n", + " name: 1000백만원 이상 비경쟁 계약\n", + " field: noncomp_big\n", + " op: \"==\"\n", + " value: true\n", + " weight: 2\n", + "- id: R15\n", + " name: 계약 기간 365일 초과\n", + " field: period_days\n", + " op: \">\"\n", + " value: 365\n", + " weight: 1\n", + "- id: R16\n", + " name: 불공정·납기 위험 키워드 포함\n", + " field: kw_hits\n", + " op: \">=\"\n", + " value: 1\n", + " weight: 3\n", + "\"\"\"\n", + "GRADE_CUT = {\"고위험\": 6, \"주의\": 3} # 가중치 합 기준 1차 판별 등급(필요 시 설정만 바꾼다)\n", + "# [교육용가상자료] 불공정 조항·납기 지체 위험 문구 사전(실제 규정 조문이 아님)\n", + "KEYWORDS = {\"독소\": [\"면제하며\", \"일방적으로\", \"포기\", \"지지 않는다\", \"즉시 소멸\", \"제한 없이\", \"신고 없이\", \"전액 부담\", \"별도 협의 없이\", \"검사 전에\", \"검사에 합격한 것으로\", \"사용할 수 없다\", \"증빙은 요구하지 않는다\", \"지정하는 기관\"],\n", + " \"납기위험\": [\"자동 연장\", \"기한 없이\", \"정하지 않는다\", \"노력하며\", \"연장할 수 있다\", \"통보한다\", \"책임을 묻지 않는다\", \"의무가 없다\"]}\n", + "\n", + "\n", + "def parse_rules(text):\n", + " \"\"\"간이 YAML 파서(목록 + key: value). 숫자·true/false·따옴표 문자열을 해석한다.\"\"\"\n", + " # [TODO : 학생 실습 3] 방위사업 계약 필수 규정 준수 여부 자동 판별 1차 Rule 엔진 구현 관련 핵심 로직을 구현하세요.\n", + " # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n", + " # <<< 여기에 코드를 작성하세요 >>>\n", + " pass # 작성 후 pass를 제거하세요.\n", + "OPS = {\"==\": lambda a, b: a == b, \"!=\": lambda a, b: a != b, \">\": lambda a, b: a > b, \">=\": lambda a, b: a >= b,\n", + " \"<\": lambda a, b: a < b, \"<=\": lambda a, b: a <= b}\n", + "\n", + "\n", + "def kw_count(clauses):\n", + " \"\"\"조항 문장 목록에서 독소·납기 위험 키워드가 들어 있는 조항 수.\"\"\"\n", + " # [TODO : 학생 실습 3] 방위사업 계약 필수 규정 준수 여부 자동 판별 1차 Rule 엔진 구현 관련 핵심 로직을 구현하세요.\n", + " # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n", + " # <<< 여기에 코드를 작성하세요 >>>\n", + " pass # 작성 후 pass를 제거하세요.\n", + "def derive(df):\n", + " \"\"\"룰에 쓰는 파생 필드(선금+보증 부족, 수의계약 고액, 논리 오류, 키워드 적중 등)를 만든다.\"\"\"\n", + " d = df.copy()\n", + " d[\"adv_no_bond\"] = (d[\"advance_pct\"] > 40) & (d[\"perf_bond_pct\"] < 10)\n", + " d[\"sole_amount\"] = np.where(d[\"method\"] == \"수의계약\", d[\"amount_m\"], 0.0) # 수의계약일 때만 금액(한도는 YAML에서 조정)\n", + " d[\"bond_gap\"] = (d[\"amount_m\"] >= 500) & (d[\"perf_bond_pct\"] < 10)\n", + " d[\"warranty_gap\"] = (d[\"ctype\"] == \"물품\") & (d[\"warranty_months\"] < 12)\n", + " d[\"ip_missing\"] = (~d[\"ip_clause\"]) & (d[\"ctype\"] != \"공사\")\n", + " d[\"logic_err\"] = d[\"delivery_days\"] > d[\"period_days\"]\n", + " d[\"no_padj_long\"] = (d[\"period_days\"] >= 180) & (~d[\"price_adjust\"])\n", + " d[\"noncomp_big\"] = d[\"method\"].isin([\"지명경쟁\", \"수의계약\"]) & (d[\"amount_m\"] >= 1000)\n", + " d[\"kw_hits\"] = [kw_count(c) for c in d[\"clauses\"]] if \"clauses\" in d else 0\n", + " return d\n", + "\n", + "\n", + "def apply_rules(rec, rules):\n", + " \"\"\"레코드 1건(dict)에 룰을 적용해 (점수, 등급, 적중 룰 id 목록)을 돌려준다. 값이 없으면 해당 룰은 건너뛴다.\"\"\"\n", + " hits = []\n", + " for r in rules:\n", + " v = rec.get(r[\"field\"])\n", + " if v is None or (isinstance(v, float) and np.isnan(v)):\n", + " continue\n", + " if OPS[r[\"op\"]](v, r[\"value\"]):\n", + " hits.append(r)\n", + " score = sum(r[\"weight\"] for r in hits)\n", + " grade = \"고위험\" if score >= GRADE_CUT[\"고위험\"] else (\"주의\" if score >= GRADE_CUT[\"주의\"] else \"관심\")\n", + " return score, grade, [r[\"id\"] for r in hits]\n", + "\n", + "# [교육용가상자료] 룰과 가상 지침 조항의 대응표(실제 법령·규정 조문이 아니라 연결 방식을 보여 주는 예시)\n", + "CLAUSE = {\"R01\": \"가상지침 5조1항(지체상금)\", \"R02\": \"가상지침 6조1항(선금 한도)\", \"R03\": \"가상지침 6조2항(선금과 보증)\", \"R04\": \"가상지침 3조1항(수의계약 한도)\",\n", + " \"R05\": \"가상지침 6조3항(이행보증)\", \"R06\": \"가상지침 7조1항(하자보증)\", \"R07\": \"가상지침 8조1항(계약 해지)\", \"R08\": \"가상지침 9조1항(보안 조항)\",\n", + " \"R09\": \"가상지침 9조2항(지식재산)\", \"R10\": \"가상지침 4조1항(하도급 제한)\", \"R11\": \"가상지침 5조2항(검사 기간)\", \"R12\": \"가상지침 5조3항(납품 기한)\",\n", + " \"R13\": \"가상지침 6조4항(물가변동)\", \"R14\": \"가상지침 3조2항(경쟁 계약 원칙)\", \"R15\": \"가상지침 3조3항(장기 계약)\", \"R16\": \"가상지침 8조2항(불공정 조항)\"}\n", + "\n", + "rules = parse_rules(RULES_YAML)\n", + "con = make_contracts(200, seed=7)\n", + "df = derive(con)\n", + "recs = df.to_dict(\"records\")\n", + "\n", + "# 1) 전체 스크리닝과 처리 지연시간(레코드 1건당)\n", + "apply_rules(recs[0], rules) # 첫 호출 지연(예열)을 측정에서 제외\n", + "lat, res = [], []\n", + "for r in recs:\n", + " t0 = time.perf_counter()\n", + " out = apply_rules(r, rules)\n", + " lat.append((time.perf_counter() - t0) * 1000)\n", + " res.append(out)\n", + "df[\"score\"], df[\"grade\"], df[\"hits\"] = [x[0] for x in res], [x[1] for x in res], [x[2] for x in res]\n", + "print(f\"[룰 설정] 검증 룰 {len(rules)}종(목표 15종 이상: {'달성' if len(rules) >= 15 else '미달'}), 모든 룰에 가상 지침 조항 연결 {all(r['id'] in CLAUSE for r in rules)}\")\n", + "print(f\"룰 처리 지연시간(레코드 1건): 평균 {np.mean(lat):.3f}ms, 99백분위 {np.percentile(lat, 99):.3f}ms, 최대 {np.max(lat):.3f}ms (목표 50ms 이하, 99백분위 기준: {'달성' if np.percentile(lat, 99) <= 50 else '미달'})\")\n", + "\n", + "# 2) 경계 조건 단위 시험: 기준값 바로 아래·위에서 룰이 정확히 켜지고 꺼지는가\n", + "CLEAN = [make_clause(np.random.default_rng(i), \"정상\") for i in range(6)]\n", + "BASE = dict(contract_id=\"C-0000\", ctype=\"물품\", method=\"일반경쟁\", amount_m=100.0, period_days=120, delivery_days=100, penalty_permil=1.5, advance_pct=20, perf_bond_pct=15,\n", + " warranty_months=24, inspection_days=7, subcontract_pct=20, price_adjust=True, termination=True, security=True, ip_clause=True, clauses=CLEAN)\n", + "TESTS = [(\"R01\", dict(penalty_permil=0.9), True), (\"R01\", dict(penalty_permil=1.0), False), (\"R02\", dict(advance_pct=41), True), (\"R02\", dict(advance_pct=40), False),\n", + " (\"R03\", dict(advance_pct=50, perf_bond_pct=9), True), (\"R03\", dict(advance_pct=50, perf_bond_pct=10), False),\n", + " (\"R04\", dict(method=\"수의계약\", amount_m=300), True), (\"R04\", dict(method=\"수의계약\", amount_m=299), False),\n", + " (\"R05\", dict(amount_m=500, perf_bond_pct=9), True), (\"R05\", dict(amount_m=499, perf_bond_pct=9), False),\n", + " (\"R06\", dict(warranty_months=11), True), (\"R06\", dict(warranty_months=12), False), (\"R07\", dict(termination=False), True), (\"R07\", dict(termination=True), False),\n", + " (\"R08\", dict(security=False), True), (\"R08\", dict(security=True), False), (\"R09\", dict(ip_clause=False, ctype=\"용역\"), True), (\"R09\", dict(ip_clause=False, ctype=\"공사\"), False),\n", + " (\"R10\", dict(subcontract_pct=51), True), (\"R10\", dict(subcontract_pct=50), False), (\"R11\", dict(inspection_days=15), True), (\"R11\", dict(inspection_days=14), False),\n", + " (\"R12\", dict(delivery_days=121), True), (\"R12\", dict(delivery_days=120), False), (\"R13\", dict(period_days=180, delivery_days=170, price_adjust=False), True),\n", + " (\"R13\", dict(period_days=179, delivery_days=170, price_adjust=False), False), (\"R14\", dict(method=\"지명경쟁\", amount_m=1000), True), (\"R14\", dict(method=\"지명경쟁\", amount_m=999), False),\n", + " (\"R15\", dict(period_days=366, delivery_days=300), True), (\"R15\", dict(period_days=365, delivery_days=300), False),\n", + " (\"R16\", dict(clauses=CLEAN[:5] + [\"계약상대자는 언제든지 일방적으로 계약을 해지할 수 있다.\"]), True), (\"R16\", dict(clauses=CLEAN), False)]\n", + "passed = 0\n", + "for rid, chg, expect in TESTS:\n", + " row = derive(pd.DataFrame([{**BASE, **chg}])).iloc[0].to_dict()\n", + " got = rid in apply_rules(row, rules)[2]\n", + " passed += got == expect\n", + " if got != expect:\n", + " print(f\" [FAIL] {rid} {chg} 기대 {expect} 결과 {got}\")\n", + "base_hits = apply_rules(derive(pd.DataFrame([BASE])).iloc[0].to_dict(), rules)\n", + "print(f\"\\n[경계 조건 단위 시험] {passed}/{len(TESTS)}건 통과 ({passed / len(TESTS):.0%}), 정상 기준 계약의 적중 룰 {base_hits[2] or '없음'} -> 등급 {base_hits[1]} (목표 100% 통과: {'달성' if passed == len(TESTS) else '미달'})\")\n", + "\n", + "# 3) 등급 분포와 실제 위반 여부(법무·계약관 검토에서 확인된 규정 위반 정답) 대비 탐지력\n", + "print(\"\\n[1차 판별 등급 분포]\")\n", + "print(df.groupby(\"grade\").agg(계약수=(\"contract_id\", \"size\"), 실제위반율=(\"violation\", \"mean\")).reindex([\"고위험\", \"주의\", \"관심\"]).round(2).to_string())\n", + "y = df[\"violation\"].to_numpy()\n", + "flag = df[\"grade\"].isin([\"고위험\", \"주의\"]).to_numpy()\n", + "tp = int((flag & y).sum())\n", + "print(f\"고위험·주의 선별: 실제 위반 계약 {y.sum()}건 중 {tp}건 포착(재현율 {tp / y.sum():.0%}), 선별 {flag.sum()}건 중 정밀도 {tp / max(flag.sum(), 1):.0%}\")\n", + "\n", + "# 4) 룰별 적중 건수와 근거 조항\n", + "cnt = pd.Series([h for hs in df[\"hits\"] for h in hs]).value_counts()\n", + "tbl = pd.DataFrame([{\"룰\": r[\"id\"], \"이름\": r[\"name\"], \"가중치\": r[\"weight\"], \"적중\": int(cnt.get(r[\"id\"], 0)), \"근거 조항\": CLAUSE[r[\"id\"]]} for r in rules])\n", + "print(\"\\n[룰별 적중 현황]\")\n", + "print(tbl.to_string(index=False))\n", + "\n", + "# 5) 고위험 상위 3건의 판정 근거(설명 가능성)\n", + "print(\"\\n[고위험 상위 3건과 근거]\")\n", + "for _, r in df.sort_values(\"score\", ascending=False).head(3).iterrows():\n", + " print(f\" {r['contract_id']} {r['ctype']} {r['method']} {r['amount_m']:.0f}백만원 점수 {r['score']} {r['grade']}\")\n", + " for h in r[\"hits\"][:3]:\n", + " print(f\" - {h} {next(x['name'] for x in rules if x['id'] == h)} / {CLAUSE[h]}\")\n", + "\n", + "# 6) 규정 변경 대응: 수의계약 한도를 300에서 250백만원으로 낮추는 설정 변경(코드 수정 없음)\n", + "rules2 = parse_rules(RULES_YAML.replace('op: \">=\"\\n value: 300', 'op: \">=\"\\n value: 250'))\n", + "o2 = [apply_rules(r, rules2) for r in recs]\n", + "g2 = np.array([x[1] in (\"고위험\", \"주의\") for x in o2])\n", + "n_new = int((g2 & ~flag).sum())\n", + "print(f\"\\n[설정 변경] 수의계약 한도 300 -> 250백만원(YAML 값만 변경): 새로 선별된 계약 {n_new}건(계약 쪼개기 의심 구간 250~299백만원), 재현율 {tp / y.sum():.0%} -> {(g2 & y).sum() / y.sum():.0%}\")\n", + "rules3 = parse_rules(RULES_YAML.replace(\"weight: 3\\n- id: R02\", \"weight: 3\\n- id: R02\").replace(\"op: \\\"<\\\"\\n value: 1.0\", \"op: \\\"<\\\"\\n value: 1.2\"))\n", + "o3 = [apply_rules(r, rules3)[0] for r in recs]\n", + "print(f\"[설정 변경] 룰 R01 지체상금률 기준 1.0 -> 1.2: 점수가 바뀐 계약 {sum(a != b for a, b in zip(df['score'], o3))}건 (YAML 값만 변경)\")\n", + "print(\"처리 후 사람 검토: 룰 판별은 1차 선별이며, 최종 규정 위반 판단과 수정 요구는 계약관·법무 담당자가 결정한다.\")\n", + "\n", + "fig, ax = plt.subplots(1, 2, figsize=(11, 4))\n", + "ax[0].bar(tbl[\"룰\"], tbl[\"적중\"], color=\"tab:blue\"); ax[0].set_title(\"Rule hits by rule (synthetic)\"); ax[0].set_ylabel(\"contracts\")\n", + "g = df.groupby(\"grade\")[\"violation\"].mean().reindex([\"관심\", \"주의\", \"고위험\"])\n", + "ax[1].bar([\"low\", \"watch\", \"high\"], g.values, color=[\"tab:green\", \"gold\", \"tab:red\"]); ax[1].set_title(\"Actual violation rate by screening grade\"); ax[1].set_ylim(0, 1.05)\n", + "plt.tight_layout(); plt.show()\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 03 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + "[룰 설정] 검증 룰 16종(목표 15종 이상: 달성), 모든 룰에 가상 지침 조항 연결 True\n", + "룰 처리 지연시간(레코드 1건): 평균 0.008ms, 99백분위 0.036ms, 최대 0.100ms (목표 50ms 이하, 99백분위 기준: 달성)\n", + "\n", + "[경계 조건 단위 시험] 32/32건 통과 (100%), 정상 기준 계약의 적중 룰 없음 -> 등급 관심 (목표 100% 통과: 달성)\n", + "\n", + "[1차 판별 등급 분포]\n", + " 계약수 실제위반율\n", + "grade \n", + "고위험 22 1.00\n", + "주의 68 0.72\n", + "관심 110 0.07\n", + "고위험·주의 선별: 실제 위반 계약 79건 중 71건 포착(재현율 90%), 선별 90건 중 정밀도 79%\n", + "\n", + "[룰별 적중 현황]\n", + " 룰 이름 가중치 적중 근거 조항\n", + "R01 지체상금률 1.0/1000 미만 3 10 가상지침 5조1항(지체상금)\n", + "R02 선금 비율 40% 초과 2 11 가상지침 6조1항(선금 한도)\n", + "R03 선금 40% 초과인데 이행보증 10% 미만 3 5 가상지침 6조2항(선금과 보증)\n", + "R04 300백만원 이상 수의계약 3 10 가상지침 3조1항(수의계약 한도)\n", + "R05 500백만원 이상 이행보증 10% 미만 2 4 가상지침 6조3항(이행보증)\n", + "R06 물품 하자보증 12개월 미만 2 5 가상지침 7조1항(하자보증)\n", + "R07 해지 조항 없음 2 8 가상지침 8조1항(계약 해지)\n", + "R08 보안 조항 없음 3 5 가상지침 9조1항(보안 조항)\n", + "R09 지식재산 조항 없음(물품·용역) 1 9 가상지침 9조2항(지식재산)\n", + "R10 하도급 비율 50% 초과 3 8 가상지침 4조1항(하도급 제한)\n", + "R11 검사 기간 14일 초과 1 21 가상지침 5조2항(검사 기간)\n", + "R12 납품 기한이 계약 기간 초과 3 7 가상지침 5조3항(납품 기한)\n", + "R13 180일 이상 계약에 물가변동 조항 없음 1 38 가상지침 6조4항(물가변동)\n", + "R14 1000백만원 이상 비경쟁 계약 2 3 가상지침 3조2항(경쟁 계약 원칙)\n", + "R15 계약 기간 365일 초과 1 24 가상지침 3조3항(장기 계약)\n", + "R16 불공정·납기 위험 키워드 포함 3 61 가상지침 8조2항(불공정 조항)\n", + "\n", + "[고위험 상위 3건과 근거]\n", + " C-0108 물품 수의계약 961백만원 점수 12 고위험\n", + " - R01 지체상금률 1.0/1000 미만 / 가상지침 5조1항(지체상금)\n", + " - R04 300백만원 이상 수의계약 / 가상지침 3조1항(수의계약 한도)\n", + " - R12 납품 기한이 계약 기간 초과 / 가상지침 5조3항(납품 기한)\n", + " C-0062 용역 수의계약 1109백만원 점수 11 고위험\n", + " - R01 지체상금률 1.0/1000 미만 / 가상지침 5조1항(지체상금)\n", + " - R04 300백만원 이상 수의계약 / 가상지침 3조1항(수의계약 한도)\n", + "... (이하 생략)\n", + "```\n", + "
\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 04. LLM/자연어처리 기반 독소 조항, 납기 지체 위험 문구 자동 식별 모델 고도화\n", + "**소요시간**: 약 3.0시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "자연어처리 기반 독소 조항 및 납기 지체 위험 문구 식별 모델을 고도화한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **자연어처리(글자 n-gram TF-IDF와 로지스틱 회귀)로 독소 조항과 납기 지체 위험 문구를 식별하는 모델을 만들고 수정 권고 문안을 제시한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **조항 문장을 정상·독소·납기위험 세 종류로 분류하는 모델을 학습하고, 학습에 쓴 문장 틀과 처음 보는 표현 두 시험 세트로 정확도를 계산해 단순 키워드 기준선과 비교 수행하고 검증하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `src/ai/prc01_intelligence_module.py` 형태의 결과물을 완성한다.\n", + "\n", + "### 🔍 평가 및 검증 기준 (PASS 조건)\n", + "#### 미션 판정\n", + "\n", + "**평가 구분:** 필수\n", + "\n", + "**핵심 평가영역:** 추천 사유의 합리성 및 실무 수용성\n", + "\n", + "#### 미션 PASS 조건\n", + "\n", + "- 정량 기준: 추천/분류 정확도 85% 이상 달성\n", + "\n", + "\n", + "- 진행 기준: AI 추론 모듈 구현 및 벤치마크 테스트 완료\n", + "\n", + "\n", + "- 검증 방법: 과거 승인/처리 완료 데이터와의 추천 일치도 검증\n", + "\n", + "\n", + "#### 채점자 판정 메모\n", + "\n", + "> 단순 코드 실행 여부뿐만 아니라 출력된 수치와 근거 자료가 군 실무 요구사항에 부합하는지 면밀히 확인한다.\n", + "\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "\"\"\"조항 문장을 정상·독소 조항·납기 지체 위험 문구로 분류하는 글자 n-gram TF-IDF 로지스틱 회귀 모델을 만들어 처음 보는 표현까지 정확도 85% 이상을 확인하고, 단순 키워드 기준선과 비교하며, 위험 조항마다 근거 표현과 표준 문안 Top-3 수정 권고(유사도 점수)를 제시한다.\"\"\"\n", + "import numpy as np\n", + "import pandas as pd\n", + "import matplotlib.pyplot as plt\n", + "from sklearn.feature_extraction.text import TfidfVectorizer\n", + "from sklearn.linear_model import LogisticRegression\n", + "from sklearn.metrics import confusion_matrix\n", + "from sklearn.metrics.pairwise import cosine_similarity\n", + "from sklearn.pipeline import make_pipeline\n", + "\n", + "# ---------------------------------------------------------------------------\n", + "# [교육용가상자료] 가상 조달계획안·계약서 생성기 (실제 계약·업체·법령 조문 없음)\n", + "# 계약 속성 16개 + 조항 문장 6개를 만들고, 속성 위반·독소 조항·납기 위험 문구가 섞인 '규정 위반 여부'를 함께 만든다.\n", + "# ---------------------------------------------------------------------------\n", + "TYPES = [\"물품\", \"용역\", \"공사\"]\n", + "METHODS = [\"일반경쟁\", \"제한경쟁\", \"지명경쟁\", \"수의계약\"]\n", + "CLASSES = [\"정상\", \"독소\", \"납기위험\"]\n", + "OBJ = [\"납품 물품\", \"용역 결과물\", \"시공 목적물\", \"정비 성과물\"]\n", + "\n", + "N_TPL = [\n", + " \"계약상대자가 납기를 지체한 경우 지체일수 1일마다 계약금액의 {p}/1000에 해당하는 지체상금을 부과한다.\",\n", + " \"지체상금은 불가항력 사유가 확인된 경우에 한하여 발주기관의 승인을 받아 면제할 수 있다.\",\n", + " \"납기 연장은 발주기관이 불가항력 사유를 확인하고 서면으로 승인한 경우에만 인정한다.\",\n", + " \"발주기관은 납품된 {o}을 {d}일 이내에 검사하고 합격한 후에 대금을 지급한다.\",\n", + " \"계약상대자는 하도급을 하려는 경우 사전에 발주기관의 서면 승인을 받아야 한다.\",\n", + " \"어느 한쪽이 계약을 해지하려면 사유를 명시해 {d}일 전에 서면으로 통지해야 한다.\",\n", + " \"{o}에 하자가 발생하면 계약상대자는 보증 기간 안에 무상으로 보수하거나 교체한다.\",\n", + " \"계약 이행 중 알게 된 비공개 사항은 제3자에게 누설하지 않으며 위반 시 책임을 진다.\",\n", + " \"{o}에 대한 지식재산권의 귀속과 사용 범위는 별도 합의서에 따라 정한다.\",\n", + " \"대금은 검사 합격 후 청구서를 접수한 날부터 {d}일 이내에 지급한다.\",\n", + " \"계약금액의 조정은 물가변동 기준에 따라 양 당사자가 서면으로 합의한 경우에만 한다.\",\n", + " \"계약상대자는 이행보증서를 계약 체결 시 제출하며 보증금은 계약 이행 완료 후 반환한다.\",\n", + " \"납품 일정은 계약서 별표의 일자를 따르며 변경 시 서면 합의가 필요하다.\",\n", + " \"지체상금은 면제 사유가 없는 한 부과하며 이의는 정해진 절차로 제기할 수 있다.\",\n", + " \"검사에 합격하지 못한 {o}은 계약상대자가 교체하며 그 비용은 계약상대자가 부담한다.\",\n", + " \"계약상대자의 귀책 사유로 이행이 불가능하면 발주기관은 서면으로 통지하고 계약을 해지할 수 있다.\",\n", + " \"지연이 예상되면 계약상대자는 사유와 만회 계획을 발주기관에 서면으로 보고한다.\",\n", + " \"보증 기간 중 발생한 결함은 계약상대자가 책임지고 보수한다.\",\n", + " \"분쟁은 먼저 협의로 해결하고 협의가 되지 않으면 관할 법원의 판단에 따른다.\",\n", + "]\n", + "T_TPL = [\n", + " \"계약상대자의 지체상금은 전액 면제하며 발주기관은 이에 대해 이의를 제기하지 못한다.\",\n", + " \"납품 즉시 검사에 합격한 것으로 보며 발주기관은 이후 검사를 요구하지 않는다.\",\n", + " \"계약상대자는 언제든지 일방적으로 계약을 해지할 수 있으며 그에 따른 손해는 발주기관이 부담한다.\",\n", + " \"{o}의 추가 비용은 사유를 불문하고 발주기관이 전액 부담하며 별도 협의 없이 청구할 수 있다.\",\n", + " \"{o}에 관한 지식재산권은 모두 계약상대자에게 귀속되고 발주기관은 사용할 수 없다.\",\n", + " \"하도급은 신고 없이 제한 없이 허용하며 발주기관은 하도급 내용을 확인하지 않는다.\",\n", + " \"대금은 검사 전에 전액 선지급하며 발주기관은 반환 청구권을 포기한다.\",\n", + " \"계약 이행 중 알게 된 사항의 누설로 인한 책임을 계약상대자는 지지 않는다.\",\n", + " \"하자 보수 책임은 인도 후 즉시 소멸하며 발주기관은 보수를 요구할 수 없다.\",\n", + " \"발주기관은 계약상대자가 청구하는 금액을 거절할 수 없으며 거절하면 위약금을 지급한다.\",\n", + " \"분쟁이 생기면 계약상대자가 정하는 중재 기관에서만 해결하고 발주기관은 따른다.\",\n", + " \"발주기관의 검사는 생략하고 계약상대자의 시험 결과서로 대신한다.\",\n", + " \"견적 금액은 사후 정산 없이 그대로 확정하고 증빙 자료는 받지 않는다.\",\n", + "]\n", + "D_TPL = [\n", + " \"납기는 자재 수급 사정에 따라 계약상대자가 연장할 수 있다.\",\n", + " \"납품 일정은 가능한 한 지키도록 노력하며 지연되어도 사유를 보고하지 않는다.\",\n", + " \"공급 지연이나 선행 업체 사정도 불가항력으로 보아 납기를 자동 연장한다.\",\n", + " \"착수일은 선행 업체의 납품이 끝난 뒤 별도로 정하며 완료 기한은 정하지 않는다.\",\n", + " \"납기일 이후 1회에 한해 기한 없이 자동 연장되는 것으로 한다.\",\n", + " \"납기는 계약상대자의 생산 일정이 확정되는 대로 통보한다.\",\n", + " \"납품 시점은 계약상대자 사정이 허락하는 때로 하고 날짜는 따로 정하지 않는다.\",\n", + " \"자재가 늦게 들어오면 완료 일정은 그만큼 자동으로 늦춰진다.\",\n", + " \"기한 내에 인도하지 못해도 계약상대자에게 책임을 묻지 않는다.\",\n", + " \"일정이 늦어져도 계약상대자는 새 계획서를 낼 필요가 없다.\",\n", + "]\n", + "# 학습에 쓰지 않는 '처음 보는 표현'(모델이 문장 틀을 외웠는지 확인하는 용도)\n", + "HOLD = {\n", + " \"정상\": [\"검사에 불합격한 경우 계약상대자는 지정 기한 안에 재납품하며 재검사 비용은 상대자가 부담한다.\",\n", + " \"계약상대자가 법령을 위반하여 계약을 이행하지 못하면 발주기관은 서면 통지 후 계약을 해지할 수 있다.\",\n", + " \"납기가 지연될 우려가 있으면 상대자는 즉시 사유와 만회 계획을 서면으로 보고해야 한다.\",\n", + " \"하자담보 책임 기간은 인수일부터 기산하며 기간 중 발생한 결함은 상대자가 책임진다.\"],\n", + " \"독소\": [\"발주기관은 계약상대자가 요구하는 대금을 이유 없이 거절하지 못하며 거절 시 위약금을 낸다.\",\n", + " \"계약 내용에 관한 분쟁은 계약상대자가 지정하는 기관에서만 다루며 발주기관은 이에 따른다.\",\n", + " \"발주기관의 검수 권한은 포기하고 계약상대자의 자체 시험 성적서로 갈음한다.\",\n", + " \"계약상대자가 제출한 견적은 사후 정산 없이 확정 단가로 하며 증빙은 요구하지 않는다.\"],\n", + " \"납기위험\": [\"납품 시기는 상대자의 형편이 허락하는 시점으로 하고 별도 날짜는 정하지 않는다.\",\n", + " \"원자재 확보가 늦어지면 완료 일자는 자동으로 뒤로 미뤄지는 것으로 본다.\",\n", + " \"인도 기한은 참고용이며 지키지 못해도 책임을 묻지 않는다.\",\n", + " \"일정 지연이 있어도 상대자는 변경 계획을 제출할 의무가 없다.\"],\n", + "}\n", + "SUFFIX = [\"\", \"\", \"\", \" 세부 사항은 시행 세칙에 따른다.\", \" 이 조항은 계약 전체에 적용한다.\", \" 필요한 경우 양 당사자가 협의한다.\", \" 관련 서식은 별지를 따른다.\"]\n", + "SWAP = [(\"발주기관\", \"계약담당관\"), (\"계약상대자\", \"업체\")]\n", + "\n", + "\n", + "def make_clause(rng, cls, hold=False, idx=None):\n", + " \"\"\"조항 한 문장 생성. 표현 일부는 동의어로 바꾸고 접미 문구를 무작위로 붙인다(idx를 주면 그 번호의 문장 틀을 사용).\"\"\"\n", + " if idx is not None:\n", + " tpl = {\"정상\": N_TPL, \"독소\": T_TPL, \"납기위험\": D_TPL}[cls]\n", + " s = tpl[idx].format(p=rng.choice([\"1.0\", \"1.5\", \"2.0\"]), d=int(rng.choice([7, 10, 14, 30])), o=rng.choice(OBJ))\n", + " elif hold:\n", + " s = HOLD[cls][rng.integers(len(HOLD[cls]))]\n", + " else:\n", + " tpl = {\"정상\": N_TPL, \"독소\": T_TPL, \"납기위험\": D_TPL}[cls]\n", + " s = tpl[rng.integers(len(tpl))].format(p=rng.choice([\"1.0\", \"1.5\", \"2.0\"]), d=int(rng.choice([7, 10, 14, 30])), o=rng.choice(OBJ))\n", + " if rng.random() < 0.3:\n", + " a, b = SWAP[rng.integers(2)]\n", + " s = s.replace(a, b)\n", + " return s + SUFFIX[rng.integers(len(SUFFIX))]\n", + "\n", + "\n", + "def make_contracts(n=200, seed=7):\n", + " \"\"\"가상 계약 n건(속성 16개 + 조항 6개 + 규정 위반 정답)을 만든다.\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " sl = rng.beta(1.2, 3.2, n) # 계약서 작성이 허술한 정도(잠재 변수)\n", + " ctype = rng.choice(TYPES, n, p=[.5, .3, .2])\n", + " amount = np.clip(np.exp(rng.normal(np.log(220), 1.1, n)), 5, 5000).round(0)\n", + " method = np.where(rng.random(n) < .06 + .3 * sl, \"수의계약\", rng.choice(METHODS[:3], n, p=[.5, .3, .2]))\n", + " period = np.array([rng.integers(60, 300) if t == \"물품\" else rng.integers(90, 400) if t == \"용역\" else rng.integers(180, 720) for t in ctype])\n", + " delivery = (period * rng.uniform(.5, 1.0, n)).astype(int)\n", + " bad_logic = rng.random(n) < .015 * (1 + 3 * sl)\n", + " delivery = np.where(bad_logic, period + rng.integers(10, 60, n), delivery)\n", + " pen = np.where(rng.random(n) < .22 * sl, rng.choice([.3, .5, .8], n), rng.choice([1.0, 1.5, 2.0, 2.5], n))\n", + " adv = np.where(rng.random(n) < .22 * sl, rng.choice([50, 60, 70], n), rng.choice([0, 10, 20, 30, 40], n))\n", + " bond = np.where(rng.random(n) < .25 * sl + .5 * (adv > 40), rng.choice([0, 5], n), rng.choice([10, 10, 15, 20], n))\n", + " war = np.array([rng.choice([3, 6]) if (rng.random() < .25 * s and t == \"물품\") else rng.choice([12, 24, 36]) if t == \"물품\" else rng.choice([0, 6, 12]) if t == \"용역\" else rng.choice([24, 36, 60]) for t, s in zip(ctype, sl)])\n", + " insp = np.where(rng.random(n) < .3 * sl, rng.choice([21, 30], n), rng.choice([5, 7, 10, 14], n))\n", + " sub = np.where(rng.random(n) < .15 * sl, rng.choice([60, 70, 80], n), rng.choice([0, 10, 20, 30, 40, 50], n))\n", + " padj = rng.random(n) < .75 - .4 * sl\n", + " term = rng.random(n) > .015 * (1 + 6 * sl)\n", + " sec = rng.random(n) > .015 * (1 + 6 * sl)\n", + " ip = rng.random(n) > .03 * (1 + 4 * sl)\n", + " clauses, cls_true = [], []\n", + " for i in range(n):\n", + " pt, pd_ = .01 + .08 * sl[i], .02 + .08 * sl[i]\n", + " cl = [rng.choice(CLASSES, p=[1 - pt - pd_, pt, pd_]) for _ in range(6)]\n", + " cls_true.append(cl); clauses.append([make_clause(rng, c) for c in cl])\n", + " df = pd.DataFrame({\"contract_id\": [f\"C-{i + 1:04d}\" for i in range(n)], \"ctype\": ctype, \"method\": method, \"amount_m\": amount,\n", + " \"period_days\": period, \"delivery_days\": delivery, \"penalty_permil\": pen, \"advance_pct\": adv, \"perf_bond_pct\": bond,\n", + " \"warranty_months\": war, \"inspection_days\": insp, \"subcontract_pct\": sub, \"price_adjust\": padj,\n", + " \"termination\": term, \"security\": sec, \"ip_clause\": ip})\n", + " hard = ((pen < 1.0) | ((adv > 40) & (bond < 10)) | ((amount >= 300) & (method == \"수의계약\")) | ((ctype == \"물품\") & (war < 12))\n", + " | ~term | ~sec | (sub > 50) | (delivery > period))\n", + " toxic = np.array([c.count(\"독소\") for c in cls_true])\n", + " delay = np.array([c.count(\"납기위험\") for c in cls_true])\n", + " split = (method == \"수의계약\") & (amount >= 250) & (amount < 300) & (rng.random(n) < .6) # 계약 쪼개기 의심(임계값 바로 아래)\n", + " df[\"clauses\"], df[\"clause_cls\"] = clauses, cls_true\n", + " df[\"n_toxic\"], df[\"n_delay\"] = toxic, delay\n", + " viol = hard | (toxic > 0) | split | (delay >= 2)\n", + " waived = hard & (toxic == 0) & (rng.random(n) < .12) # 계약관이 예외 사유를 인정해 위반으로 보지 않은 건(자료의 현실적 잡음)\n", + " hidden = ~viol & (rng.random(n) < .03 + .08 * sl) # 속성·문구에 드러나지 않는 위반(별첨 합의 등)\n", + " df[\"violation\"] = (viol & ~waived) | hidden\n", + " df[\"vtype\"] = np.where(hidden, \"기타\", np.where(hard, \"속성위반\", np.where(toxic > 0, \"독소조항\", np.where(split | (delay >= 2), \"기타\", \"-\"))))\n", + " return df\n", + "\n", + "SIMPLE_KW = {\"독소\": [\"면제\", \"일방\", \"포기\", \"전액\", \"누설\", \"하도급\", \"선지급\"], \"납기위험\": [\"연장\", \"자동\", \"노력\", \"통보\", \"기한\"]}\n", + "\n", + "# ---- 조항 문장 분류(정상 / 독소 / 납기위험): 글자 n-gram TF-IDF + 로지스틱 회귀 ----\n", + "def clause_set(seed, n, hold=False, mix=(.55, .25, .20)):\n", + " \"\"\"조항 문장 n개와 정답 분류를 만든다. hold=True면 학습에 쓰지 않은 '처음 보는 표현'으로 만든다.\"\"\"\n", + " # [TODO : 학생 실습 4] LLM/자연어처리 기반 독소 조항, 납기 지체 위험 문구 자동 식별 모델 고도화 관련 핵심 로직을 구현하세요.\n", + " # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n", + " # <<< 여기에 코드를 작성하세요 >>>\n", + " pass # 작성 후 pass를 제거하세요.\n", + "def fit_clause_model(seed=100, n=1200):\n", + " \"\"\"학습용 조항 문장으로 분류 모델을 학습한다.\"\"\"\n", + " # [TODO : 학생 실습 4] LLM/자연어처리 기반 독소 조항, 납기 지체 위험 문구 자동 식별 모델 고도화 관련 핵심 로직을 구현하세요.\n", + " # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n", + " # <<< 여기에 코드를 작성하세요 >>>\n", + " pass # 작성 후 pass를 제거하세요.\n", + "def keyword_pred(s):\n", + " \"\"\"비교용 기준선: 단순 키워드(부정 표현·문맥을 보지 않음) 적중으로만 분류.\"\"\"\n", + " if any(k in s for k in SIMPLE_KW[\"독소\"]):\n", + " return \"독소\"\n", + " return \"납기위험\" if any(k in s for k in SIMPLE_KW[\"납기위험\"]) else \"정상\"\n", + "\n", + "# [교육용가상자료] 표준 문안 라이브러리(수정 권고용, 실제 표준계약서 조문이 아님)\n", + "STD = {\"지체상금\": \"납기를 지체하면 지체일수마다 계약금액의 1/1000 이상에 해당하는 지체상금을 부과하며 면제는 불가항력 사유가 확인되고 서면 승인을 받은 경우에 한다.\",\n", + " \"검사\": \"발주기관은 납품된 물품을 정해진 기간 안에 검사하고 합격한 후에 대금을 지급하며 검사 권한은 생략하거나 대체할 수 없다.\",\n", + " \"해지\": \"계약 해지는 사유를 명시하고 정해진 기간 전에 서면으로 통지해야 하며 책임 있는 쪽이 손해를 부담한다.\",\n", + " \"추가비용\": \"추가 비용은 사전에 협의해 서면으로 합의한 범위에서만 인정하며 증빙을 갖추어 청구한다.\",\n", + " \"지식재산\": \"지식재산권의 귀속과 사용 범위는 별도 합의서로 정하며 발주기관은 목적물을 사용할 수 있다.\",\n", + " \"하도급\": \"하도급은 사전에 발주기관의 서면 승인을 받아야 하며 하도급 내용은 확인할 수 있어야 한다.\",\n", + " \"대금\": \"대금은 검사 합격 후 청구를 받아 정해진 기간 안에 지급하고 선지급은 보증을 갖춘 범위에서만 한다.\",\n", + " \"보안\": \"계약 이행 중 알게 된 비공개 사항은 누설하지 않으며 위반 시 책임을 진다.\",\n", + " \"하자\": \"보증 기간 중 발생한 결함은 계약상대자가 책임지고 무상으로 보수하거나 교체한다.\",\n", + " \"분쟁\": \"분쟁은 먼저 협의로 해결하고 협의가 되지 않으면 관할 법원의 판단에 따른다.\",\n", + " \"납기\": \"납기는 계약서에 날짜로 명시하고 변경은 불가항력 사유 확인 후 서면 승인으로만 한다.\"}\n", + "TOPIC_T = [\"지체상금\", \"검사\", \"해지\", \"추가비용\", \"지식재산\", \"하도급\", \"대금\", \"보안\", \"하자\", \"대금\", \"분쟁\", \"검사\", \"추가비용\"] # T_TPL 순서와 대응\n", + "\n", + "model = fit_clause_model()\n", + "Xa, ya = clause_set(200, 600) # 학습과 같은 문장 틀(새로 뽑은 문장)\n", + "Xb, yb = clause_set(201, 300, hold=True) # 학습에 없는 처음 보는 표현\n", + "print(f\"[데이터] 학습 조항 1,200개 / 시험 A(같은 문장 틀) {len(Xa)}개 / 시험 B(처음 보는 표현) {len(Xb)}개 / 분류 3종: {', '.join(CLASSES)}\")\n", + "\n", + "pa, pb = model.predict(Xa), model.predict(Xb)\n", + "acc_a, acc_b = np.mean(pa == np.array(ya)), np.mean(pb == np.array(yb))\n", + "X_all, y_all, p_all = Xa + Xb, np.array(ya + yb), np.concatenate([pa, pb])\n", + "acc = np.mean(p_all == y_all)\n", + "kb = np.array([keyword_pred(s) for s in X_all])\n", + "print(f\"\\n[분류 정확도] 시험 A {acc_a:.1%}, 시험 B(처음 보는 표현) {acc_b:.1%}, 전체 {acc:.1%} (목표 85% 이상: {'달성' if acc >= 0.85 else '미달'})\")\n", + "print(f\"단순 키워드 기준선 전체 {np.mean(kb == y_all):.1%} (부정·문맥을 보지 못해 '면제 사유가 없는 한 부과' 같은 정상 문장을 오탐)\")\n", + "cm = pd.DataFrame(confusion_matrix(y_all, p_all, labels=CLASSES), index=[f\"실제 {c}\" for c in CLASSES], columns=[f\"예측 {c}\" for c in CLASSES])\n", + "print(cm.to_string())\n", + "risk_idx = y_all != \"정상\"\n", + "rec_ = np.mean(p_all[risk_idx] != \"정상\")\n", + "print(f\"위험 조항(독소·납기위험) 포착률 {rec_:.1%}, 정상 문장을 위험으로 오탐한 비율 {np.mean(p_all[~risk_idx] != '정상'):.1%}\")\n", + "\n", + "# 오분류 사례\n", + "print(\"\\n[오분류 사례(시험 B)]\")\n", + "shown = 0\n", + "for s, t, p in zip(Xb, yb, pb):\n", + " if t != p and shown < 3:\n", + " shown += 1; print(f\" 실제 {t} / 예측 {p}: {s[:48]}...\")\n", + "if shown == 0:\n", + " print(\" 없음\")\n", + "\n", + "# 근거 표현: 해당 분류에 가장 크게 기여한 글자 조각\n", + "vec, lr = model[0], model[-1]\n", + "names = np.array(vec.get_feature_names_out())\n", + "\n", + "\n", + "def evidence(text, cls, k=3):\n", + " x = vec.transform([text])\n", + " contrib = x.multiply(lr.coef_[list(lr.classes_).index(cls)]).toarray()[0]\n", + " return [names[j].strip() for j in np.argsort(-contrib)[:k] if contrib[j] > 0]\n", + "\n", + "\n", + "# 표준 문안 Top-3 추천(과거 처리 완료 사례와의 일치도): 독소 조항 120개와 납기 위험 문구 60개\n", + "rng = np.random.default_rng(5)\n", + "sv = TfidfVectorizer(analyzer=\"char_wb\", ngram_range=(2, 3)).fit(list(STD.values()) + X_all)\n", + "SM = sv.transform(list(STD.values()))\n", + "topics = list(STD)\n", + "tests = [(make_clause(rng, \"독소\", idx=i), TOPIC_T[i]) for i in rng.integers(0, len(T_TPL), 120)] + [(make_clause(rng, \"납기위험\"), \"납기\") for _ in range(60)]\n", + "jn = topics.index(\"납기\")\n", + "\n", + "\n", + "def similarity(s):\n", + " \"\"\"문안 유사도. 분류 결과가 납기위험이면 납기 문안을 우선하고, 독소 조항이면 납기 문안은 제외한다.\"\"\"\n", + " raw = cosine_similarity(sv.transform([s]), SM)[0]\n", + " sc = raw.copy()\n", + " sc[jn] += 1.0 if model.predict([s])[0] == \"납기위험\" else -1.0\n", + " return sc, raw\n", + "\n", + "\n", + "hit1 = hit3 = 0\n", + "for s, t in tests:\n", + " sc, _ = similarity(s)\n", + " order = np.argsort(-sc)[:3]\n", + " hit1 += topics[order[0]] == t; hit3 += t in [topics[j] for j in order]\n", + "print(f\"\\n[표준 문안 추천 일치도] 위험 조항 {len(tests)}건: Top-1 {hit1 / len(tests):.1%}, Top-3 {hit3 / len(tests):.1%} (과거 처리된 수정 문안 주제와 일치 기준)\")\n", + "print(\"\\n[위험 조항별 근거와 수정 권고 Top-3 예시]\")\n", + "for s, t in [tests[0], tests[7], tests[-1]]:\n", + " pred = model.predict([s])[0]; prob = model.predict_proba([s])[0].max()\n", + " sc, sim = similarity(s)\n", + " order = np.argsort(-sc)[:3]\n", + " print(f\" 조항: {s[:46]}...\")\n", + " print(f\" 분류 {pred}(확신도 {prob:.2f}), 근거 표현 {evidence(s, pred) or '-'}\")\n", + " for r, j in enumerate(order, 1):\n", + " print(f\" 권고 {r}순위 [{topics[j]}] 유사도 {sim[j]:.2f}: {list(STD.values())[j][:30]}...\")\n", + "print(\"※ 모델은 후보를 표시하고 수정 문안은 참고용이다. 불공정 조항 여부와 문안 확정은 계약관·법무 담당자가 판단한다.\")\n", + "\n", + "fig, ax = plt.subplots(1, 2, figsize=(11, 4))\n", + "ax[0].bar([\"keyword\", \"model A\", \"model B\", \"model all\"], [np.mean(kb == y_all), acc_a, acc_b, acc], color=[\"gray\", \"tab:blue\", \"tab:orange\", \"tab:green\"])\n", + "ax[0].axhline(.85, color=\"r\", ls=\"--\"); ax[0].set_ylim(0, 1.05); ax[0].set_title(\"Clause classification accuracy\")\n", + "im = ax[1].imshow(cm.to_numpy(), cmap=\"Blues\"); ax[1].set_xticks(range(3), [\"ok\", \"toxic\", \"delay\"]); ax[1].set_yticks(range(3), [\"ok\", \"toxic\", \"delay\"]); ax[1].set_title(\"Confusion matrix (all test)\")\n", + "for i in range(3):\n", + " for j in range(3):\n", + " ax[1].text(j, i, int(cm.to_numpy()[i, j]), ha=\"center\", va=\"center\")\n", + "plt.tight_layout(); plt.show()\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 04 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + "[데이터] 학습 조항 1,200개 / 시험 A(같은 문장 틀) 600개 / 시험 B(처음 보는 표현) 300개 / 분류 3종: 정상, 독소, 납기위험\n", + "\n", + "[분류 정확도] 시험 A 100.0%, 시험 B(처음 보는 표현) 95.3%, 전체 98.4% (목표 85% 이상: 달성)\n", + "단순 키워드 기준선 전체 64.4% (부정·문맥을 보지 못해 '면제 사유가 없는 한 부과' 같은 정상 문장을 오탐)\n", + " 예측 정상 예측 독소 예측 납기위험\n", + "실제 정상 504 0 0\n", + "실제 독소 0 222 0\n", + "실제 납기위험 14 0 160\n", + "위험 조항(독소·납기위험) 포착률 96.5%, 정상 문장을 위험으로 오탐한 비율 0.0%\n", + "\n", + "[오분류 사례(시험 B)]\n", + " 실제 납기위험 / 예측 정상: 일정 지연이 있어도 상대자는 변경 계획을 제출할 의무가 없다. 필요한 경우 양 당사자가...\n", + " 실제 납기위험 / 예측 정상: 일정 지연이 있어도 상대자는 변경 계획을 제출할 의무가 없다. 이 조항은 계약 전체에 ...\n", + " 실제 납기위험 / 예측 정상: 일정 지연이 있어도 상대자는 변경 계획을 제출할 의무가 없다....\n", + "\n", + "[표준 문안 추천 일치도] 위험 조항 180건: Top-1 94.4%, Top-3 97.8% (과거 처리된 수정 문안 주제와 일치 기준)\n", + "\n", + "[위험 조항별 근거와 수정 권고 Top-3 예시]\n", + " 조항: 하자 보수 책임은 인도 후 즉시 소멸하며 계약담당관은 보수를 요구할 수 없다....\n", + " 분류 독소(확신도 0.99), 근거 표현 ['관은', '관은', '즉시']\n", + " 권고 1순위 [하자] 유사도 0.12: 보증 기간 중 발생한 결함은 계약상대자가 책임지고 무상...\n", + " 권고 2순위 [검사] 유사도 0.09: 발주기관은 납품된 물품을 정해진 기간 안에 검사하고 합...\n", + " 권고 3순위 [하도급] 유사도 0.06: 하도급은 사전에 발주기관의 서면 승인을 받아야 하며 하...\n", + " 조항: 용역 결과물의 추가 비용은 사유를 불문하고 발주기관이 전액 부담하며 별도 협의 없이...\n", + " 분류 독소(확신도 0.99), 근거 표현 ['전액', '액', '전액']\n", + " 권고 1순위 [추가비용] 유사도 0.16: 추가 비용은 사전에 협의해 서면으로 합의한 범위에서만 ...\n", + " 권고 2순위 [지식재산] 유사도 0.12: 지식재산권의 귀속과 사용 범위는 별도 합의서로 정하며 ...\n", + " 권고 3순위 [해지] 유사도 0.12: 계약 해지는 사유를 명시하고 정해진 기간 전에 서면으로...\n", + " 조항: 납품 일정은 가능한 한 지키도록 노력하며 지연되어도 사유를 보고하지 않는다. 세부 ...\n", + " 분류 납기위험(확신도 0.99), 근거 표현 ['일정', '일정', '도']\n", + " 권고 1순위 [납기] 유사도 0.03: 납기는 계약서에 날짜로 명시하고 변경은 불가항력 사유 ...\n", + " 권고 2순위 [보안] 유사도 0.08: 계약 이행 중 알게 된 비공개 사항은 누설하지 않으며 ...\n", + " 권고 3순위 [해지] 유사도 0.06: 계약 해지는 사유를 명시하고 정해진 기간 전에 서면으로...\n", + "※ 모델은 후보를 표시하고 수정 문안은 참고용이다. 불공정 조항 여부와 문안 확정은 계약관·법무 담당자가 판단한다.\n", + "```\n", + "
\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 05. 계약 위험 점수 산출 및 규정 위반 가능성 신뢰도 지수 엔진 개발\n", + "**소요시간**: 약 3.0시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "계약 위험 점수 산출 및 규정 위반 가능성 신뢰도 엔진을 개발한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **룰 적중 내역과 조항 분류 결과를 결합해 계약 위험 점수와 규정 위반 가능성 신뢰도 지수를 산출하고 입력-분석-검토-결과 4개 화면을 구성한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **룰 16종 적중 여부, 모델이 찾은 독소·납기위험 조항 수, 금액을 로지스틱 회귀로 결합해 0~100점의 위험 점수를 만들고, 점수의 뚜렷함과 룰·모델 일치 여부로 신뢰도 지수 정의하고 검증하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `src/ui/prc01_app.py` 형태의 결과물을 완성한다.\n", + "\n", + "### 🔍 평가 및 검증 기준 (PASS 조건)\n", + "#### 미션 판정\n", + "\n", + "**평가 구분:** 필수\n", + "\n", + "**핵심 평가영역:** 군 실무자 친화적 디자인 및 조작 편의성\n", + "\n", + "#### 미션 PASS 조건\n", + "\n", + "- 정량 기준: 핵심 화면 4종(입력-분석-검토-결과) 구현 완료\n", + "\n", + "\n", + "- 진행 기준: UI 프로토타입 로컬 서버 구동 및 전체 페이지 네비게이션 확인\n", + "\n", + "\n", + "- 검증 방법: 사용성 평가(UX Walkthrough) 및 인터랙션 테스트\n", + "\n", + "\n", + "#### 채점자 판정 메모\n", + "\n", + "> 단순 코드 실행 여부뿐만 아니라 출력된 수치와 근거 자료가 군 실무 요구사항에 부합하는지 면밀히 확인한다.\n", + "\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "\"\"\"룰 적중 내역과 조항 분류 모델의 위험 조항 수를 로지스틱 회귀로 결합해 계약별 위반 위험 점수(0~100)와 신뢰도 지수를 산출하는 엔진을 만들고, 별도 가상 계약군에서 성능·신뢰도 보정을 검증하며 입력-분석-검토-결과 4개 화면(텍스트 패널과 그래프)과 상태 뱃지·필터를 구성한다.\"\"\"\n", + "import numpy as np\n", + "import pandas as pd\n", + "import matplotlib.pyplot as plt\n", + "from sklearn.feature_extraction.text import TfidfVectorizer\n", + "from sklearn.linear_model import LogisticRegression\n", + "from sklearn.metrics import roc_auc_score, brier_score_loss\n", + "from sklearn.pipeline import make_pipeline\n", + "from sklearn.preprocessing import StandardScaler\n", + "\n", + "# ---------------------------------------------------------------------------\n", + "# [교육용가상자료] 가상 조달계획안·계약서 생성기 (실제 계약·업체·법령 조문 없음)\n", + "# 계약 속성 16개 + 조항 문장 6개를 만들고, 속성 위반·독소 조항·납기 위험 문구가 섞인 '규정 위반 여부'를 함께 만든다.\n", + "# ---------------------------------------------------------------------------\n", + "TYPES = [\"물품\", \"용역\", \"공사\"]\n", + "METHODS = [\"일반경쟁\", \"제한경쟁\", \"지명경쟁\", \"수의계약\"]\n", + "CLASSES = [\"정상\", \"독소\", \"납기위험\"]\n", + "OBJ = [\"납품 물품\", \"용역 결과물\", \"시공 목적물\", \"정비 성과물\"]\n", + "\n", + "N_TPL = [\n", + " \"계약상대자가 납기를 지체한 경우 지체일수 1일마다 계약금액의 {p}/1000에 해당하는 지체상금을 부과한다.\",\n", + " \"지체상금은 불가항력 사유가 확인된 경우에 한하여 발주기관의 승인을 받아 면제할 수 있다.\",\n", + " \"납기 연장은 발주기관이 불가항력 사유를 확인하고 서면으로 승인한 경우에만 인정한다.\",\n", + " \"발주기관은 납품된 {o}을 {d}일 이내에 검사하고 합격한 후에 대금을 지급한다.\",\n", + " \"계약상대자는 하도급을 하려는 경우 사전에 발주기관의 서면 승인을 받아야 한다.\",\n", + " \"어느 한쪽이 계약을 해지하려면 사유를 명시해 {d}일 전에 서면으로 통지해야 한다.\",\n", + " \"{o}에 하자가 발생하면 계약상대자는 보증 기간 안에 무상으로 보수하거나 교체한다.\",\n", + " \"계약 이행 중 알게 된 비공개 사항은 제3자에게 누설하지 않으며 위반 시 책임을 진다.\",\n", + " \"{o}에 대한 지식재산권의 귀속과 사용 범위는 별도 합의서에 따라 정한다.\",\n", + " \"대금은 검사 합격 후 청구서를 접수한 날부터 {d}일 이내에 지급한다.\",\n", + " \"계약금액의 조정은 물가변동 기준에 따라 양 당사자가 서면으로 합의한 경우에만 한다.\",\n", + " \"계약상대자는 이행보증서를 계약 체결 시 제출하며 보증금은 계약 이행 완료 후 반환한다.\",\n", + " \"납품 일정은 계약서 별표의 일자를 따르며 변경 시 서면 합의가 필요하다.\",\n", + " \"지체상금은 면제 사유가 없는 한 부과하며 이의는 정해진 절차로 제기할 수 있다.\",\n", + " \"검사에 합격하지 못한 {o}은 계약상대자가 교체하며 그 비용은 계약상대자가 부담한다.\",\n", + " \"계약상대자의 귀책 사유로 이행이 불가능하면 발주기관은 서면으로 통지하고 계약을 해지할 수 있다.\",\n", + " \"지연이 예상되면 계약상대자는 사유와 만회 계획을 발주기관에 서면으로 보고한다.\",\n", + " \"보증 기간 중 발생한 결함은 계약상대자가 책임지고 보수한다.\",\n", + " \"분쟁은 먼저 협의로 해결하고 협의가 되지 않으면 관할 법원의 판단에 따른다.\",\n", + "]\n", + "T_TPL = [\n", + " \"계약상대자의 지체상금은 전액 면제하며 발주기관은 이에 대해 이의를 제기하지 못한다.\",\n", + " \"납품 즉시 검사에 합격한 것으로 보며 발주기관은 이후 검사를 요구하지 않는다.\",\n", + " \"계약상대자는 언제든지 일방적으로 계약을 해지할 수 있으며 그에 따른 손해는 발주기관이 부담한다.\",\n", + " \"{o}의 추가 비용은 사유를 불문하고 발주기관이 전액 부담하며 별도 협의 없이 청구할 수 있다.\",\n", + " \"{o}에 관한 지식재산권은 모두 계약상대자에게 귀속되고 발주기관은 사용할 수 없다.\",\n", + " \"하도급은 신고 없이 제한 없이 허용하며 발주기관은 하도급 내용을 확인하지 않는다.\",\n", + " \"대금은 검사 전에 전액 선지급하며 발주기관은 반환 청구권을 포기한다.\",\n", + " \"계약 이행 중 알게 된 사항의 누설로 인한 책임을 계약상대자는 지지 않는다.\",\n", + " \"하자 보수 책임은 인도 후 즉시 소멸하며 발주기관은 보수를 요구할 수 없다.\",\n", + " \"발주기관은 계약상대자가 청구하는 금액을 거절할 수 없으며 거절하면 위약금을 지급한다.\",\n", + " \"분쟁이 생기면 계약상대자가 정하는 중재 기관에서만 해결하고 발주기관은 따른다.\",\n", + " \"발주기관의 검사는 생략하고 계약상대자의 시험 결과서로 대신한다.\",\n", + " \"견적 금액은 사후 정산 없이 그대로 확정하고 증빙 자료는 받지 않는다.\",\n", + "]\n", + "D_TPL = [\n", + " \"납기는 자재 수급 사정에 따라 계약상대자가 연장할 수 있다.\",\n", + " \"납품 일정은 가능한 한 지키도록 노력하며 지연되어도 사유를 보고하지 않는다.\",\n", + " \"공급 지연이나 선행 업체 사정도 불가항력으로 보아 납기를 자동 연장한다.\",\n", + " \"착수일은 선행 업체의 납품이 끝난 뒤 별도로 정하며 완료 기한은 정하지 않는다.\",\n", + " \"납기일 이후 1회에 한해 기한 없이 자동 연장되는 것으로 한다.\",\n", + " \"납기는 계약상대자의 생산 일정이 확정되는 대로 통보한다.\",\n", + " \"납품 시점은 계약상대자 사정이 허락하는 때로 하고 날짜는 따로 정하지 않는다.\",\n", + " \"자재가 늦게 들어오면 완료 일정은 그만큼 자동으로 늦춰진다.\",\n", + " \"기한 내에 인도하지 못해도 계약상대자에게 책임을 묻지 않는다.\",\n", + " \"일정이 늦어져도 계약상대자는 새 계획서를 낼 필요가 없다.\",\n", + "]\n", + "# 학습에 쓰지 않는 '처음 보는 표현'(모델이 문장 틀을 외웠는지 확인하는 용도)\n", + "HOLD = {\n", + " \"정상\": [\"검사에 불합격한 경우 계약상대자는 지정 기한 안에 재납품하며 재검사 비용은 상대자가 부담한다.\",\n", + " \"계약상대자가 법령을 위반하여 계약을 이행하지 못하면 발주기관은 서면 통지 후 계약을 해지할 수 있다.\",\n", + " \"납기가 지연될 우려가 있으면 상대자는 즉시 사유와 만회 계획을 서면으로 보고해야 한다.\",\n", + " \"하자담보 책임 기간은 인수일부터 기산하며 기간 중 발생한 결함은 상대자가 책임진다.\"],\n", + " \"독소\": [\"발주기관은 계약상대자가 요구하는 대금을 이유 없이 거절하지 못하며 거절 시 위약금을 낸다.\",\n", + " \"계약 내용에 관한 분쟁은 계약상대자가 지정하는 기관에서만 다루며 발주기관은 이에 따른다.\",\n", + " \"발주기관의 검수 권한은 포기하고 계약상대자의 자체 시험 성적서로 갈음한다.\",\n", + " \"계약상대자가 제출한 견적은 사후 정산 없이 확정 단가로 하며 증빙은 요구하지 않는다.\"],\n", + " \"납기위험\": [\"납품 시기는 상대자의 형편이 허락하는 시점으로 하고 별도 날짜는 정하지 않는다.\",\n", + " \"원자재 확보가 늦어지면 완료 일자는 자동으로 뒤로 미뤄지는 것으로 본다.\",\n", + " \"인도 기한은 참고용이며 지키지 못해도 책임을 묻지 않는다.\",\n", + " \"일정 지연이 있어도 상대자는 변경 계획을 제출할 의무가 없다.\"],\n", + "}\n", + "SUFFIX = [\"\", \"\", \"\", \" 세부 사항은 시행 세칙에 따른다.\", \" 이 조항은 계약 전체에 적용한다.\", \" 필요한 경우 양 당사자가 협의한다.\", \" 관련 서식은 별지를 따른다.\"]\n", + "SWAP = [(\"발주기관\", \"계약담당관\"), (\"계약상대자\", \"업체\")]\n", + "\n", + "\n", + "def make_clause(rng, cls, hold=False, idx=None):\n", + " \"\"\"조항 한 문장 생성. 표현 일부는 동의어로 바꾸고 접미 문구를 무작위로 붙인다(idx를 주면 그 번호의 문장 틀을 사용).\"\"\"\n", + " if idx is not None:\n", + " tpl = {\"정상\": N_TPL, \"독소\": T_TPL, \"납기위험\": D_TPL}[cls]\n", + " s = tpl[idx].format(p=rng.choice([\"1.0\", \"1.5\", \"2.0\"]), d=int(rng.choice([7, 10, 14, 30])), o=rng.choice(OBJ))\n", + " elif hold:\n", + " s = HOLD[cls][rng.integers(len(HOLD[cls]))]\n", + " else:\n", + " tpl = {\"정상\": N_TPL, \"독소\": T_TPL, \"납기위험\": D_TPL}[cls]\n", + " s = tpl[rng.integers(len(tpl))].format(p=rng.choice([\"1.0\", \"1.5\", \"2.0\"]), d=int(rng.choice([7, 10, 14, 30])), o=rng.choice(OBJ))\n", + " if rng.random() < 0.3:\n", + " a, b = SWAP[rng.integers(2)]\n", + " s = s.replace(a, b)\n", + " return s + SUFFIX[rng.integers(len(SUFFIX))]\n", + "\n", + "\n", + "def make_contracts(n=200, seed=7):\n", + " \"\"\"가상 계약 n건(속성 16개 + 조항 6개 + 규정 위반 정답)을 만든다.\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " sl = rng.beta(1.2, 3.2, n) # 계약서 작성이 허술한 정도(잠재 변수)\n", + " ctype = rng.choice(TYPES, n, p=[.5, .3, .2])\n", + " amount = np.clip(np.exp(rng.normal(np.log(220), 1.1, n)), 5, 5000).round(0)\n", + " method = np.where(rng.random(n) < .06 + .3 * sl, \"수의계약\", rng.choice(METHODS[:3], n, p=[.5, .3, .2]))\n", + " period = np.array([rng.integers(60, 300) if t == \"물품\" else rng.integers(90, 400) if t == \"용역\" else rng.integers(180, 720) for t in ctype])\n", + " delivery = (period * rng.uniform(.5, 1.0, n)).astype(int)\n", + " bad_logic = rng.random(n) < .015 * (1 + 3 * sl)\n", + " delivery = np.where(bad_logic, period + rng.integers(10, 60, n), delivery)\n", + " pen = np.where(rng.random(n) < .22 * sl, rng.choice([.3, .5, .8], n), rng.choice([1.0, 1.5, 2.0, 2.5], n))\n", + " adv = np.where(rng.random(n) < .22 * sl, rng.choice([50, 60, 70], n), rng.choice([0, 10, 20, 30, 40], n))\n", + " bond = np.where(rng.random(n) < .25 * sl + .5 * (adv > 40), rng.choice([0, 5], n), rng.choice([10, 10, 15, 20], n))\n", + " war = np.array([rng.choice([3, 6]) if (rng.random() < .25 * s and t == \"물품\") else rng.choice([12, 24, 36]) if t == \"물품\" else rng.choice([0, 6, 12]) if t == \"용역\" else rng.choice([24, 36, 60]) for t, s in zip(ctype, sl)])\n", + " insp = np.where(rng.random(n) < .3 * sl, rng.choice([21, 30], n), rng.choice([5, 7, 10, 14], n))\n", + " sub = np.where(rng.random(n) < .15 * sl, rng.choice([60, 70, 80], n), rng.choice([0, 10, 20, 30, 40, 50], n))\n", + " padj = rng.random(n) < .75 - .4 * sl\n", + " term = rng.random(n) > .015 * (1 + 6 * sl)\n", + " sec = rng.random(n) > .015 * (1 + 6 * sl)\n", + " ip = rng.random(n) > .03 * (1 + 4 * sl)\n", + " clauses, cls_true = [], []\n", + " for i in range(n):\n", + " pt, pd_ = .01 + .08 * sl[i], .02 + .08 * sl[i]\n", + " cl = [rng.choice(CLASSES, p=[1 - pt - pd_, pt, pd_]) for _ in range(6)]\n", + " cls_true.append(cl); clauses.append([make_clause(rng, c) for c in cl])\n", + " df = pd.DataFrame({\"contract_id\": [f\"C-{i + 1:04d}\" for i in range(n)], \"ctype\": ctype, \"method\": method, \"amount_m\": amount,\n", + " \"period_days\": period, \"delivery_days\": delivery, \"penalty_permil\": pen, \"advance_pct\": adv, \"perf_bond_pct\": bond,\n", + " \"warranty_months\": war, \"inspection_days\": insp, \"subcontract_pct\": sub, \"price_adjust\": padj,\n", + " \"termination\": term, \"security\": sec, \"ip_clause\": ip})\n", + " hard = ((pen < 1.0) | ((adv > 40) & (bond < 10)) | ((amount >= 300) & (method == \"수의계약\")) | ((ctype == \"물품\") & (war < 12))\n", + " | ~term | ~sec | (sub > 50) | (delivery > period))\n", + " toxic = np.array([c.count(\"독소\") for c in cls_true])\n", + " delay = np.array([c.count(\"납기위험\") for c in cls_true])\n", + " split = (method == \"수의계약\") & (amount >= 250) & (amount < 300) & (rng.random(n) < .6) # 계약 쪼개기 의심(임계값 바로 아래)\n", + " df[\"clauses\"], df[\"clause_cls\"] = clauses, cls_true\n", + " df[\"n_toxic\"], df[\"n_delay\"] = toxic, delay\n", + " viol = hard | (toxic > 0) | split | (delay >= 2)\n", + " waived = hard & (toxic == 0) & (rng.random(n) < .12) # 계약관이 예외 사유를 인정해 위반으로 보지 않은 건(자료의 현실적 잡음)\n", + " hidden = ~viol & (rng.random(n) < .03 + .08 * sl) # 속성·문구에 드러나지 않는 위반(별첨 합의 등)\n", + " df[\"violation\"] = (viol & ~waived) | hidden\n", + " df[\"vtype\"] = np.where(hidden, \"기타\", np.where(hard, \"속성위반\", np.where(toxic > 0, \"독소조항\", np.where(split | (delay >= 2), \"기타\", \"-\"))))\n", + " return df\n", + "\n", + "# ---- 계약 규정 준수 판별 룰 설정(YAML 형식 문자열: 규정이 바뀌면 코드가 아니라 이 설정만 고친다) ----\n", + "RULES_YAML = \"\"\"\n", + "- id: R01\n", + " name: 지체상금률 1.0/1000 미만\n", + " field: penalty_permil\n", + " op: \"<\"\n", + " value: 1.0\n", + " weight: 3\n", + "- id: R02\n", + " name: 선금 비율 40% 초과\n", + " field: advance_pct\n", + " op: \">\"\n", + " value: 40\n", + " weight: 2\n", + "- id: R03\n", + " name: 선금 40% 초과인데 이행보증 10% 미만\n", + " field: adv_no_bond\n", + " op: \"==\"\n", + " value: true\n", + " weight: 3\n", + "- id: R04\n", + " name: 300백만원 이상 수의계약\n", + " field: sole_amount\n", + " op: \">=\"\n", + " value: 300\n", + " weight: 3\n", + "- id: R05\n", + " name: 500백만원 이상 이행보증 10% 미만\n", + " field: bond_gap\n", + " op: \"==\"\n", + " value: true\n", + " weight: 2\n", + "- id: R06\n", + " name: 물품 하자보증 12개월 미만\n", + " field: warranty_gap\n", + " op: \"==\"\n", + " value: true\n", + " weight: 2\n", + "- id: R07\n", + " name: 해지 조항 없음\n", + " field: termination\n", + " op: \"==\"\n", + " value: false\n", + " weight: 2\n", + "- id: R08\n", + " name: 보안 조항 없음\n", + " field: security\n", + " op: \"==\"\n", + " value: false\n", + " weight: 3\n", + "- id: R09\n", + " name: 지식재산 조항 없음(물품·용역)\n", + " field: ip_missing\n", + " op: \"==\"\n", + " value: true\n", + " weight: 1\n", + "- id: R10\n", + " name: 하도급 비율 50% 초과\n", + " field: subcontract_pct\n", + " op: \">\"\n", + " value: 50\n", + " weight: 3\n", + "- id: R11\n", + " name: 검사 기간 14일 초과\n", + " field: inspection_days\n", + " op: \">\"\n", + " value: 14\n", + " weight: 1\n", + "- id: R12\n", + " name: 납품 기한이 계약 기간 초과\n", + " field: logic_err\n", + " op: \"==\"\n", + " value: true\n", + " weight: 3\n", + "- id: R13\n", + " name: 180일 이상 계약에 물가변동 조항 없음\n", + " field: no_padj_long\n", + " op: \"==\"\n", + " value: true\n", + " weight: 1\n", + "- id: R14\n", + " name: 1000백만원 이상 비경쟁 계약\n", + " field: noncomp_big\n", + " op: \"==\"\n", + " value: true\n", + " weight: 2\n", + "- id: R15\n", + " name: 계약 기간 365일 초과\n", + " field: period_days\n", + " op: \">\"\n", + " value: 365\n", + " weight: 1\n", + "- id: R16\n", + " name: 불공정·납기 위험 키워드 포함\n", + " field: kw_hits\n", + " op: \">=\"\n", + " value: 1\n", + " weight: 3\n", + "\"\"\"\n", + "GRADE_CUT = {\"고위험\": 6, \"주의\": 3} # 가중치 합 기준 1차 판별 등급(필요 시 설정만 바꾼다)\n", + "# [교육용가상자료] 불공정 조항·납기 지체 위험 문구 사전(실제 규정 조문이 아님)\n", + "KEYWORDS = {\"독소\": [\"면제하며\", \"일방적으로\", \"포기\", \"지지 않는다\", \"즉시 소멸\", \"제한 없이\", \"신고 없이\", \"전액 부담\", \"별도 협의 없이\", \"검사 전에\", \"검사에 합격한 것으로\", \"사용할 수 없다\", \"증빙은 요구하지 않는다\", \"지정하는 기관\"],\n", + " \"납기위험\": [\"자동 연장\", \"기한 없이\", \"정하지 않는다\", \"노력하며\", \"연장할 수 있다\", \"통보한다\", \"책임을 묻지 않는다\", \"의무가 없다\"]}\n", + "\n", + "\n", + "def parse_rules(text):\n", + " \"\"\"간이 YAML 파서(목록 + key: value). 숫자·true/false·따옴표 문자열을 해석한다.\"\"\"\n", + " # [TODO : 학생 실습 5] 계약 위험 점수 산출 및 규정 위반 가능성 신뢰도 지수 엔진 개발 관련 핵심 로직을 구현하세요.\n", + " # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n", + " # <<< 여기에 코드를 작성하세요 >>>\n", + " pass # 작성 후 pass를 제거하세요.\n", + "OPS = {\"==\": lambda a, b: a == b, \"!=\": lambda a, b: a != b, \">\": lambda a, b: a > b, \">=\": lambda a, b: a >= b,\n", + " \"<\": lambda a, b: a < b, \"<=\": lambda a, b: a <= b}\n", + "\n", + "\n", + "def kw_count(clauses):\n", + " \"\"\"조항 문장 목록에서 독소·납기 위험 키워드가 들어 있는 조항 수.\"\"\"\n", + " # [TODO : 학생 실습 5] 계약 위험 점수 산출 및 규정 위반 가능성 신뢰도 지수 엔진 개발 관련 핵심 로직을 구현하세요.\n", + " # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n", + " # <<< 여기에 코드를 작성하세요 >>>\n", + " pass # 작성 후 pass를 제거하세요.\n", + "def derive(df):\n", + " \"\"\"룰에 쓰는 파생 필드(선금+보증 부족, 수의계약 고액, 논리 오류, 키워드 적중 등)를 만든다.\"\"\"\n", + " d = df.copy()\n", + " d[\"adv_no_bond\"] = (d[\"advance_pct\"] > 40) & (d[\"perf_bond_pct\"] < 10)\n", + " d[\"sole_amount\"] = np.where(d[\"method\"] == \"수의계약\", d[\"amount_m\"], 0.0) # 수의계약일 때만 금액(한도는 YAML에서 조정)\n", + " d[\"bond_gap\"] = (d[\"amount_m\"] >= 500) & (d[\"perf_bond_pct\"] < 10)\n", + " d[\"warranty_gap\"] = (d[\"ctype\"] == \"물품\") & (d[\"warranty_months\"] < 12)\n", + " d[\"ip_missing\"] = (~d[\"ip_clause\"]) & (d[\"ctype\"] != \"공사\")\n", + " d[\"logic_err\"] = d[\"delivery_days\"] > d[\"period_days\"]\n", + " d[\"no_padj_long\"] = (d[\"period_days\"] >= 180) & (~d[\"price_adjust\"])\n", + " d[\"noncomp_big\"] = d[\"method\"].isin([\"지명경쟁\", \"수의계약\"]) & (d[\"amount_m\"] >= 1000)\n", + " d[\"kw_hits\"] = [kw_count(c) for c in d[\"clauses\"]] if \"clauses\" in d else 0\n", + " return d\n", + "\n", + "\n", + "def apply_rules(rec, rules):\n", + " \"\"\"레코드 1건(dict)에 룰을 적용해 (점수, 등급, 적중 룰 id 목록)을 돌려준다. 값이 없으면 해당 룰은 건너뛴다.\"\"\"\n", + " hits = []\n", + " for r in rules:\n", + " v = rec.get(r[\"field\"])\n", + " if v is None or (isinstance(v, float) and np.isnan(v)):\n", + " continue\n", + " if OPS[r[\"op\"]](v, r[\"value\"]):\n", + " hits.append(r)\n", + " score = sum(r[\"weight\"] for r in hits)\n", + " grade = \"고위험\" if score >= GRADE_CUT[\"고위험\"] else (\"주의\" if score >= GRADE_CUT[\"주의\"] else \"관심\")\n", + " return score, grade, [r[\"id\"] for r in hits]\n", + "\n", + "SIMPLE_KW = {\"독소\": [\"면제\", \"일방\", \"포기\", \"전액\", \"누설\", \"하도급\", \"선지급\"], \"납기위험\": [\"연장\", \"자동\", \"노력\", \"통보\", \"기한\"]}\n", + "\n", + "# ---- 조항 문장 분류(정상 / 독소 / 납기위험): 글자 n-gram TF-IDF + 로지스틱 회귀 ----\n", + "def clause_set(seed, n, hold=False, mix=(.55, .25, .20)):\n", + " \"\"\"조항 문장 n개와 정답 분류를 만든다. hold=True면 학습에 쓰지 않은 '처음 보는 표현'으로 만든다.\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " y = rng.choice(CLASSES, n, p=list(mix))\n", + " return [make_clause(rng, c, hold) for c in y], list(y)\n", + "\n", + "\n", + "def fit_clause_model(seed=100, n=1200):\n", + " \"\"\"학습용 조항 문장으로 분류 모델을 학습한다.\"\"\"\n", + " X, y = clause_set(seed, n)\n", + " return make_pipeline(TfidfVectorizer(analyzer=\"char_wb\", ngram_range=(2, 4), sublinear_tf=True), LogisticRegression(C=20, max_iter=2000)).fit(X, y)\n", + "\n", + "\n", + "def keyword_pred(s):\n", + " \"\"\"비교용 기준선: 단순 키워드(부정 표현·문맥을 보지 않음) 적중으로만 분류.\"\"\"\n", + " if any(k in s for k in SIMPLE_KW[\"독소\"]):\n", + " return \"독소\"\n", + " return \"납기위험\" if any(k in s for k in SIMPLE_KW[\"납기위험\"]) else \"정상\"\n", + "\n", + "RULES = parse_rules(RULES_YAML)\n", + "RID = [r[\"id\"] for r in RULES]\n", + "WARN = {\"고위험\": 60, \"주의\": 30} # 위험 점수 등급 기준(점수 = 위반 확률 x 100)\n", + "\n", + "\n", + "def featurize(con, cm):\n", + " \"\"\"룰 16종 적중 여부 + 조항 모델이 찾은 독소·납기위험 조항 수 + 금액(로그).\"\"\"\n", + " d = derive(con)\n", + " H = np.array([[r in apply_rules(x, RULES)[2] for r in RID] for x in d.to_dict(\"records\")], float)\n", + " flat = [c for cl in con[\"clauses\"] for c in cl]\n", + " pred = np.array(cm.predict(flat)).reshape(len(con), 6)\n", + " nt, nd = (pred == \"독소\").sum(1), (pred == \"납기위험\").sum(1)\n", + " return np.column_stack([H, nt, nd, np.log1p(con[\"amount_m\"].to_numpy())]), H, nt, nd\n", + "\n", + "\n", + "FEAT_NAMES = [f\"{r['id']} {r['name']}\" for r in RULES] + [\"독소 조항 수(모델)\", \"납기위험 문구 수(모델)\", \"금액(로그)\"]\n", + "cm = fit_clause_model()\n", + "train, test = make_contracts(800, seed=11), make_contracts(300, seed=7)\n", + "Xtr, _, _, _ = featurize(train, cm)\n", + "Xte, Hte, nt, nd = featurize(test, cm)\n", + "lr = make_pipeline(StandardScaler(), LogisticRegression(C=0.5, max_iter=1000)).fit(Xtr, train[\"violation\"].astype(int))\n", + "y = test[\"violation\"].astype(int).to_numpy()\n", + "p = lr.predict_proba(Xte)[:, 1]\n", + "score = p * 100\n", + "rule_flag = np.array([apply_rules(x, RULES)[1] != \"관심\" for x in derive(test).to_dict(\"records\")])\n", + "agree = (rule_flag == (p >= 0.5)).astype(float)\n", + "conf = np.clip(0.6 * np.abs(2 * p - 1) + 0.4 * agree, 0, 1) * 100 # 신뢰도 지수: 확률의 뚜렷함 + 룰·모델 일치\n", + "level = np.where(score >= WARN[\"고위험\"], \"고위험\", np.where(score >= WARN[\"주의\"], \"주의\", \"관심\"))\n", + "T = test.assign(score=score, conf=conf, level=level, y=y)\n", + "\n", + "# ===== 화면 1. 입력: 입력 요약과 검증 =====\n", + "miss = int(test[[\"amount_m\", \"penalty_permil\", \"advance_pct\"]].isna().sum().sum())\n", + "print(\"[화면 1. 입력] [교육용가상자료]\")\n", + "print(f\" 검증용 계약 {len(test)}건(조항 {len(test) * 6}개) / 학습 계약 {len(train)}건 / 입력 지표 {len(FEAT_NAMES)}개 / 필수값 결측 {miss}건\")\n", + "print(f\" 수의계약 {int((test['method'] == '수의계약').sum())}건, 평균 금액 {test['amount_m'].mean():.0f}백만원, 보안 조항 없음 {int((~test['security']).sum())}건\")\n", + "\n", + "# ===== 화면 2. 분석: 점수 분포와 성능 =====\n", + "auc = roc_auc_score(y, score)\n", + "print(\"\\n[화면 2. 분석] 위반 위험 점수와 모델 성능\")\n", + "print(f\" ROC-AUC {auc:.3f}, Brier {brier_score_loss(y, p):.3f} (실제 위반 비율 {y.mean():.0%}), 룰 점수 단독 AUC {roc_auc_score(y, Hte @ np.array([r['weight'] for r in RULES])):.3f}\")\n", + "print(T.groupby(\"level\").agg(계약수=(\"contract_id\", \"size\"), 평균점수=(\"score\", \"mean\"), 실제위반율=(\"y\", \"mean\")).reindex([\"고위험\", \"주의\", \"관심\"]).round(2).to_string())\n", + "coef = pd.Series(lr[-1].coef_[0], index=FEAT_NAMES).sort_values(ascending=False)\n", + "print(\" 위험 상승 요인(회귀 계수 상위 4):\", \", \".join(f\"{k.split(' ')[0]} {v:+.2f}\" for k, v in coef.head(4).items()))\n", + "print(\" [신뢰도 지수와 실제 정확도] (판정 = 점수 50 이상을 위반 가능으로 봄)\")\n", + "bins = pd.cut(T[\"conf\"], [0, 50, 75, 90, 100.1], labels=[\"50 미만\", \"50~75\", \"75~90\", \"90 이상\"])\n", + "ok = ((p >= 0.5).astype(int) == y)\n", + "cal = pd.DataFrame({\"bin\": bins, \"ok\": ok}).groupby(\"bin\", observed=True).agg(건수=(\"ok\", \"size\"), 실제정확도=(\"ok\", \"mean\")).round(2)\n", + "print(cal.to_string())\n", + "\n", + "# ===== 화면 3. 검토: 고위험 계약을 담당자가 확인 =====\n", + "rev = T[T[\"level\"] == \"고위험\"].sort_values(\"score\", ascending=False)\n", + "contrib = (lr[0].transform(Xte) * lr[-1].coef_[0])\n", + "BADGE = {\"고위험\": \"[검토 필수]\", \"주의\": \"[확인 권고]\", \"관심\": \"[통과 후보]\"}\n", + "print(f\"\\n[화면 3. 검토] 고위험 {len(rev)}건 중 상위 5건 (담당자가 근거를 확인하고 조치 여부를 결정)\")\n", + "for i, r in rev.head(5).iterrows():\n", + " top = [FEAT_NAMES[j].split(\" \")[0] + \":\" + FEAT_NAMES[j].split(\" \", 1)[1][:10] for j in np.argsort(-contrib[i])[:2] if contrib[i][j] > 0]\n", + " print(f\" {BADGE[r['level']]} {r['contract_id']} {r['ctype']} {r['amount_m']:.0f}백만원 | 위험 {r['score']:.0f}점 신뢰도 {r['conf']:.0f} | 요인 {', '.join(top) or '-'} | 상태 검토대기\")\n", + "\n", + "# ===== 화면 4. 결과: 요약, 뱃지 필터, 내보내기 =====\n", + "print(\"\\n[화면 4. 결과]\")\n", + "cnt = T[\"level\"].value_counts()\n", + "print(f\" {BADGE['고위험']} {cnt.get('고위험', 0)}건, {BADGE['주의']} {cnt.get('주의', 0)}건, {BADGE['관심']} {cnt.get('관심', 0)}건\")\n", + "f = T[(T[\"level\"] == \"주의\") & (T[\"conf\"] < 75)]\n", + "print(f\" 필터 예시: 등급 '주의' 이면서 신뢰도 75 미만 -> {len(f)}건(사람이 먼저 확인할 경계 사례)\")\n", + "print(f\" 고위험 등급의 실제 위반 {int(rev['y'].sum())}건({rev['y'].mean():.0%}) vs 관심 등급 {T[T['level'] == '관심']['y'].mean():.0%}\")\n", + "print(\" 내보내기 항목: 계약번호, 위험 점수, 신뢰도, 등급 뱃지, 요인, 검토 상태(검토대기)\")\n", + "print(\"화면 4종(입력-분석-검토-결과) 구현: 4/4 (목표 4종: 달성)\")\n", + "print(\"※ 점수와 신뢰도는 우선순위를 정하는 참고값이며, 규정 위반 여부의 판단과 조치는 계약관·법무 담당자가 한다.\")\n", + "\n", + "fig, ax = plt.subplots(2, 2, figsize=(11, 7))\n", + "ax[0, 0].bar([\"sole-source\", \"no security\", \"no termination\"], [(test[\"method\"] == \"수의계약\").sum(), (~test[\"security\"]).sum(), (~test[\"termination\"]).sum()]); ax[0, 0].set_title(\"Screen 1: input summary (count)\")\n", + "ax[0, 1].hist([T[T[\"y\"] == 0][\"score\"], T[T[\"y\"] == 1][\"score\"]], bins=15, label=[\"no violation\", \"violation\"], stacked=True); ax[0, 1].legend(); ax[0, 1].set_title(\"Screen 2: risk score distribution\")\n", + "ax[1, 0].scatter(T[\"score\"], T[\"conf\"], c=T[\"y\"], cmap=\"coolwarm\", s=14); ax[1, 0].set_xlabel(\"risk score\"); ax[1, 0].set_ylabel(\"confidence index\"); ax[1, 0].set_title(\"Screen 3: review map (color = actual)\")\n", + "ax[1, 1].bar([\"high\", \"watch\", \"low\"], [cnt.get(\"고위험\", 0), cnt.get(\"주의\", 0), cnt.get(\"관심\", 0)], color=[\"tab:red\", \"gold\", \"tab:green\"]); ax[1, 1].set_title(\"Screen 4: result by grade\")\n", + "plt.tight_layout(); plt.show()\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 05 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + "[화면 1. 입력] [교육용가상자료]\n", + " 검증용 계약 300건(조항 1800개) / 학습 계약 800건 / 입력 지표 19개 / 필수값 결측 0건\n", + " 수의계약 38건, 평균 금액 381백만원, 보안 조항 없음 10건\n", + "\n", + "[화면 2. 분석] 위반 위험 점수와 모델 성능\n", + " ROC-AUC 0.954, Brier 0.062 (실제 위반 비율 41%), 룰 점수 단독 AUC 0.889\n", + " 계약수 평균점수 실제위반율\n", + "level \n", + "고위험 112 90.48 0.94\n", + "주의 8 48.49 1.00\n", + "관심 180 10.12 0.05\n", + " 위험 상승 요인(회귀 계수 상위 4): 독소 +2.62, R07 +1.04, R01 +0.98, R04 +0.76\n", + " [신뢰도 지수와 실제 정확도] (판정 = 점수 50 이상을 위반 가능으로 봄)\n", + " 건수 실제정확도\n", + "bin \n", + "50 미만 38 0.84\n", + "50~75 31 0.87\n", + "75~90 67 0.97\n", + "90 이상 164 0.95\n", + "\n", + "[화면 3. 검토] 고위험 112건 중 상위 5건 (담당자가 근거를 확인하고 조치 여부를 결정)\n", + " [검토 필수] C-0222 물품 162백만원 | 위험 100점 신뢰도 100 | 요인 독소:조항 수(모델), R06:물품 하자보증 12 | 상태 검토대기\n", + " [검토 필수] C-0028 물품 590백만원 | 위험 100점 신뢰도 100 | 요인 독소:조항 수(모델), R08:보안 조항 없음 | 상태 검토대기\n", + " [검토 필수] C-0266 물품 68백만원 | 위험 100점 신뢰도 100 | 요인 독소:조항 수(모델), R08:보안 조항 없음 | 상태 검토대기\n", + " [검토 필수] C-0037 물품 62백만원 | 위험 100점 신뢰도 100 | 요인 독소:조항 수(모델), R10:하도급 비율 50% | 상태 검토대기\n", + " [검토 필수] C-0221 용역 101백만원 | 위험 100점 신뢰도 100 | 요인 독소:조항 수(모델), R10:하도급 비율 50% | 상태 검토대기\n", + "\n", + "[화면 4. 결과]\n", + " [검토 필수] 112건, [확인 권고] 8건, [통과 후보] 180건\n", + " 필터 예시: 등급 '주의' 이면서 신뢰도 75 미만 -> 8건(사람이 먼저 확인할 경계 사례)\n", + " 고위험 등급의 실제 위반 105건(94%) vs 관심 등급 5%\n", + " 내보내기 항목: 계약번호, 위험 점수, 신뢰도, 등급 뱃지, 요인, 검토 상태(검토대기)\n", + "화면 4종(입력-분석-검토-결과) 구현: 4/4 (목표 4종: 달성)\n", + "※ 점수와 신뢰도는 우선순위를 정하는 참고값이며, 규정 위반 여부의 판단과 조치는 계약관·법무 담당자가 한다.\n", + "```\n", + "
\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 06. 계약서 조항별 위험도 하이라이트 및 조달 검토 대시보드 구현\n", + "**소요시간**: 약 2.5시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "계약서 조항별 위험도 하이라이트 및 조달 검토 대시보드를 구현한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **계약서 조항별 위험도 하이라이트와 조달 검토 대시보드를 구현하고 입력 검증기·에러 핸들러로 예외 상황에서도 크래시 없이 동작하게 한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **계약 방식 x 유형 위험 지도와 위험 문구가 많은 계약의 조항별 하이라이트(독소·납기위험 표시, 위험 표현 강조)를 보여 주는 대시보드 작성하여 원인을 분석하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `src/utils/prc01_validator.py 및 에러 핸들러` 형태의 결과물을 완성한다.\n", + "\n", + "### 🔍 평가 및 검증 기준 (PASS 조건)\n", + "#### 미션 판정\n", + "\n", + "**평가 구분:** 필수\n", + "\n", + "**핵심 평가영역:** 사용자에게 알기 쉬운 에러 메시지 및 조치 가이드 제공\n", + "\n", + "#### 미션 PASS 조건\n", + "\n", + "- 정량 기준: 예외 상황 발생 시 크래시(Crash) 0건, 에러 로그 기록률 100%\n", + "\n", + "\n", + "- 진행 기준: 예외 주입 테스트 10종 통과 및 감사 로그 파일 생성\n", + "\n", + "\n", + "- 검증 방법: 비정상 데이터(결측, 특수문자, 대용량) 스트레스 테스트\n", + "\n", + "\n", + "#### 채점자 판정 메모\n", + "\n", + "> 단순 코드 실행 여부뿐만 아니라 출력된 수치와 근거 자료가 군 실무 요구사항에 부합하는지 면밀히 확인한다.\n", + "\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "\"\"\"계약서 조항별 위험도 하이라이트와 계약 방식 x 유형 위험 지도를 보여 주는 조달 검토 대시보드에 입력 검증기·에러 핸들러·재시도·롤백을 붙이고, 깨진 입력 20종(결측·특수문자·대용량·일시 오류 포함)을 주입해 크래시 0건·에러 로그 기록률 100%·사용자용 조치 안내를 확인한다.\"\"\"\n", + "import csv\n", + "import io\n", + "import json\n", + "import re\n", + "import time\n", + "import numpy as np\n", + "import pandas as pd\n", + "import matplotlib.pyplot as plt\n", + "from sklearn.feature_extraction.text import TfidfVectorizer\n", + "from sklearn.linear_model import LogisticRegression\n", + "from sklearn.pipeline import make_pipeline\n", + "\n", + "# ---------------------------------------------------------------------------\n", + "# [교육용가상자료] 가상 조달계획안·계약서 생성기 (실제 계약·업체·법령 조문 없음)\n", + "# 계약 속성 16개 + 조항 문장 6개를 만들고, 속성 위반·독소 조항·납기 위험 문구가 섞인 '규정 위반 여부'를 함께 만든다.\n", + "# ---------------------------------------------------------------------------\n", + "TYPES = [\"물품\", \"용역\", \"공사\"]\n", + "METHODS = [\"일반경쟁\", \"제한경쟁\", \"지명경쟁\", \"수의계약\"]\n", + "CLASSES = [\"정상\", \"독소\", \"납기위험\"]\n", + "OBJ = [\"납품 물품\", \"용역 결과물\", \"시공 목적물\", \"정비 성과물\"]\n", + "\n", + "N_TPL = [\n", + " \"계약상대자가 납기를 지체한 경우 지체일수 1일마다 계약금액의 {p}/1000에 해당하는 지체상금을 부과한다.\",\n", + " \"지체상금은 불가항력 사유가 확인된 경우에 한하여 발주기관의 승인을 받아 면제할 수 있다.\",\n", + " \"납기 연장은 발주기관이 불가항력 사유를 확인하고 서면으로 승인한 경우에만 인정한다.\",\n", + " \"발주기관은 납품된 {o}을 {d}일 이내에 검사하고 합격한 후에 대금을 지급한다.\",\n", + " \"계약상대자는 하도급을 하려는 경우 사전에 발주기관의 서면 승인을 받아야 한다.\",\n", + " \"어느 한쪽이 계약을 해지하려면 사유를 명시해 {d}일 전에 서면으로 통지해야 한다.\",\n", + " \"{o}에 하자가 발생하면 계약상대자는 보증 기간 안에 무상으로 보수하거나 교체한다.\",\n", + " \"계약 이행 중 알게 된 비공개 사항은 제3자에게 누설하지 않으며 위반 시 책임을 진다.\",\n", + " \"{o}에 대한 지식재산권의 귀속과 사용 범위는 별도 합의서에 따라 정한다.\",\n", + " \"대금은 검사 합격 후 청구서를 접수한 날부터 {d}일 이내에 지급한다.\",\n", + " \"계약금액의 조정은 물가변동 기준에 따라 양 당사자가 서면으로 합의한 경우에만 한다.\",\n", + " \"계약상대자는 이행보증서를 계약 체결 시 제출하며 보증금은 계약 이행 완료 후 반환한다.\",\n", + " \"납품 일정은 계약서 별표의 일자를 따르며 변경 시 서면 합의가 필요하다.\",\n", + " \"지체상금은 면제 사유가 없는 한 부과하며 이의는 정해진 절차로 제기할 수 있다.\",\n", + " \"검사에 합격하지 못한 {o}은 계약상대자가 교체하며 그 비용은 계약상대자가 부담한다.\",\n", + " \"계약상대자의 귀책 사유로 이행이 불가능하면 발주기관은 서면으로 통지하고 계약을 해지할 수 있다.\",\n", + " \"지연이 예상되면 계약상대자는 사유와 만회 계획을 발주기관에 서면으로 보고한다.\",\n", + " \"보증 기간 중 발생한 결함은 계약상대자가 책임지고 보수한다.\",\n", + " \"분쟁은 먼저 협의로 해결하고 협의가 되지 않으면 관할 법원의 판단에 따른다.\",\n", + "]\n", + "T_TPL = [\n", + " \"계약상대자의 지체상금은 전액 면제하며 발주기관은 이에 대해 이의를 제기하지 못한다.\",\n", + " \"납품 즉시 검사에 합격한 것으로 보며 발주기관은 이후 검사를 요구하지 않는다.\",\n", + " \"계약상대자는 언제든지 일방적으로 계약을 해지할 수 있으며 그에 따른 손해는 발주기관이 부담한다.\",\n", + " \"{o}의 추가 비용은 사유를 불문하고 발주기관이 전액 부담하며 별도 협의 없이 청구할 수 있다.\",\n", + " \"{o}에 관한 지식재산권은 모두 계약상대자에게 귀속되고 발주기관은 사용할 수 없다.\",\n", + " \"하도급은 신고 없이 제한 없이 허용하며 발주기관은 하도급 내용을 확인하지 않는다.\",\n", + " \"대금은 검사 전에 전액 선지급하며 발주기관은 반환 청구권을 포기한다.\",\n", + " \"계약 이행 중 알게 된 사항의 누설로 인한 책임을 계약상대자는 지지 않는다.\",\n", + " \"하자 보수 책임은 인도 후 즉시 소멸하며 발주기관은 보수를 요구할 수 없다.\",\n", + " \"발주기관은 계약상대자가 청구하는 금액을 거절할 수 없으며 거절하면 위약금을 지급한다.\",\n", + " \"분쟁이 생기면 계약상대자가 정하는 중재 기관에서만 해결하고 발주기관은 따른다.\",\n", + " \"발주기관의 검사는 생략하고 계약상대자의 시험 결과서로 대신한다.\",\n", + " \"견적 금액은 사후 정산 없이 그대로 확정하고 증빙 자료는 받지 않는다.\",\n", + "]\n", + "D_TPL = [\n", + " \"납기는 자재 수급 사정에 따라 계약상대자가 연장할 수 있다.\",\n", + " \"납품 일정은 가능한 한 지키도록 노력하며 지연되어도 사유를 보고하지 않는다.\",\n", + " \"공급 지연이나 선행 업체 사정도 불가항력으로 보아 납기를 자동 연장한다.\",\n", + " \"착수일은 선행 업체의 납품이 끝난 뒤 별도로 정하며 완료 기한은 정하지 않는다.\",\n", + " \"납기일 이후 1회에 한해 기한 없이 자동 연장되는 것으로 한다.\",\n", + " \"납기는 계약상대자의 생산 일정이 확정되는 대로 통보한다.\",\n", + " \"납품 시점은 계약상대자 사정이 허락하는 때로 하고 날짜는 따로 정하지 않는다.\",\n", + " \"자재가 늦게 들어오면 완료 일정은 그만큼 자동으로 늦춰진다.\",\n", + " \"기한 내에 인도하지 못해도 계약상대자에게 책임을 묻지 않는다.\",\n", + " \"일정이 늦어져도 계약상대자는 새 계획서를 낼 필요가 없다.\",\n", + "]\n", + "# 학습에 쓰지 않는 '처음 보는 표현'(모델이 문장 틀을 외웠는지 확인하는 용도)\n", + "HOLD = {\n", + " \"정상\": [\"검사에 불합격한 경우 계약상대자는 지정 기한 안에 재납품하며 재검사 비용은 상대자가 부담한다.\",\n", + " \"계약상대자가 법령을 위반하여 계약을 이행하지 못하면 발주기관은 서면 통지 후 계약을 해지할 수 있다.\",\n", + " \"납기가 지연될 우려가 있으면 상대자는 즉시 사유와 만회 계획을 서면으로 보고해야 한다.\",\n", + " \"하자담보 책임 기간은 인수일부터 기산하며 기간 중 발생한 결함은 상대자가 책임진다.\"],\n", + " \"독소\": [\"발주기관은 계약상대자가 요구하는 대금을 이유 없이 거절하지 못하며 거절 시 위약금을 낸다.\",\n", + " \"계약 내용에 관한 분쟁은 계약상대자가 지정하는 기관에서만 다루며 발주기관은 이에 따른다.\",\n", + " \"발주기관의 검수 권한은 포기하고 계약상대자의 자체 시험 성적서로 갈음한다.\",\n", + " \"계약상대자가 제출한 견적은 사후 정산 없이 확정 단가로 하며 증빙은 요구하지 않는다.\"],\n", + " \"납기위험\": [\"납품 시기는 상대자의 형편이 허락하는 시점으로 하고 별도 날짜는 정하지 않는다.\",\n", + " \"원자재 확보가 늦어지면 완료 일자는 자동으로 뒤로 미뤄지는 것으로 본다.\",\n", + " \"인도 기한은 참고용이며 지키지 못해도 책임을 묻지 않는다.\",\n", + " \"일정 지연이 있어도 상대자는 변경 계획을 제출할 의무가 없다.\"],\n", + "}\n", + "SUFFIX = [\"\", \"\", \"\", \" 세부 사항은 시행 세칙에 따른다.\", \" 이 조항은 계약 전체에 적용한다.\", \" 필요한 경우 양 당사자가 협의한다.\", \" 관련 서식은 별지를 따른다.\"]\n", + "SWAP = [(\"발주기관\", \"계약담당관\"), (\"계약상대자\", \"업체\")]\n", + "\n", + "\n", + "def make_clause(rng, cls, hold=False, idx=None):\n", + " \"\"\"조항 한 문장 생성. 표현 일부는 동의어로 바꾸고 접미 문구를 무작위로 붙인다(idx를 주면 그 번호의 문장 틀을 사용).\"\"\"\n", + " if idx is not None:\n", + " tpl = {\"정상\": N_TPL, \"독소\": T_TPL, \"납기위험\": D_TPL}[cls]\n", + " s = tpl[idx].format(p=rng.choice([\"1.0\", \"1.5\", \"2.0\"]), d=int(rng.choice([7, 10, 14, 30])), o=rng.choice(OBJ))\n", + " elif hold:\n", + " s = HOLD[cls][rng.integers(len(HOLD[cls]))]\n", + " else:\n", + " tpl = {\"정상\": N_TPL, \"독소\": T_TPL, \"납기위험\": D_TPL}[cls]\n", + " s = tpl[rng.integers(len(tpl))].format(p=rng.choice([\"1.0\", \"1.5\", \"2.0\"]), d=int(rng.choice([7, 10, 14, 30])), o=rng.choice(OBJ))\n", + " if rng.random() < 0.3:\n", + " a, b = SWAP[rng.integers(2)]\n", + " s = s.replace(a, b)\n", + " return s + SUFFIX[rng.integers(len(SUFFIX))]\n", + "\n", + "\n", + "def make_contracts(n=200, seed=7):\n", + " \"\"\"가상 계약 n건(속성 16개 + 조항 6개 + 규정 위반 정답)을 만든다.\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " sl = rng.beta(1.2, 3.2, n) # 계약서 작성이 허술한 정도(잠재 변수)\n", + " ctype = rng.choice(TYPES, n, p=[.5, .3, .2])\n", + " amount = np.clip(np.exp(rng.normal(np.log(220), 1.1, n)), 5, 5000).round(0)\n", + " method = np.where(rng.random(n) < .06 + .3 * sl, \"수의계약\", rng.choice(METHODS[:3], n, p=[.5, .3, .2]))\n", + " period = np.array([rng.integers(60, 300) if t == \"물품\" else rng.integers(90, 400) if t == \"용역\" else rng.integers(180, 720) for t in ctype])\n", + " delivery = (period * rng.uniform(.5, 1.0, n)).astype(int)\n", + " bad_logic = rng.random(n) < .015 * (1 + 3 * sl)\n", + " delivery = np.where(bad_logic, period + rng.integers(10, 60, n), delivery)\n", + " pen = np.where(rng.random(n) < .22 * sl, rng.choice([.3, .5, .8], n), rng.choice([1.0, 1.5, 2.0, 2.5], n))\n", + " adv = np.where(rng.random(n) < .22 * sl, rng.choice([50, 60, 70], n), rng.choice([0, 10, 20, 30, 40], n))\n", + " bond = np.where(rng.random(n) < .25 * sl + .5 * (adv > 40), rng.choice([0, 5], n), rng.choice([10, 10, 15, 20], n))\n", + " war = np.array([rng.choice([3, 6]) if (rng.random() < .25 * s and t == \"물품\") else rng.choice([12, 24, 36]) if t == \"물품\" else rng.choice([0, 6, 12]) if t == \"용역\" else rng.choice([24, 36, 60]) for t, s in zip(ctype, sl)])\n", + " insp = np.where(rng.random(n) < .3 * sl, rng.choice([21, 30], n), rng.choice([5, 7, 10, 14], n))\n", + " sub = np.where(rng.random(n) < .15 * sl, rng.choice([60, 70, 80], n), rng.choice([0, 10, 20, 30, 40, 50], n))\n", + " padj = rng.random(n) < .75 - .4 * sl\n", + " term = rng.random(n) > .015 * (1 + 6 * sl)\n", + " sec = rng.random(n) > .015 * (1 + 6 * sl)\n", + " ip = rng.random(n) > .03 * (1 + 4 * sl)\n", + " clauses, cls_true = [], []\n", + " for i in range(n):\n", + " pt, pd_ = .01 + .08 * sl[i], .02 + .08 * sl[i]\n", + " cl = [rng.choice(CLASSES, p=[1 - pt - pd_, pt, pd_]) for _ in range(6)]\n", + " cls_true.append(cl); clauses.append([make_clause(rng, c) for c in cl])\n", + " df = pd.DataFrame({\"contract_id\": [f\"C-{i + 1:04d}\" for i in range(n)], \"ctype\": ctype, \"method\": method, \"amount_m\": amount,\n", + " \"period_days\": period, \"delivery_days\": delivery, \"penalty_permil\": pen, \"advance_pct\": adv, \"perf_bond_pct\": bond,\n", + " \"warranty_months\": war, \"inspection_days\": insp, \"subcontract_pct\": sub, \"price_adjust\": padj,\n", + " \"termination\": term, \"security\": sec, \"ip_clause\": ip})\n", + " hard = ((pen < 1.0) | ((adv > 40) & (bond < 10)) | ((amount >= 300) & (method == \"수의계약\")) | ((ctype == \"물품\") & (war < 12))\n", + " | ~term | ~sec | (sub > 50) | (delivery > period))\n", + " toxic = np.array([c.count(\"독소\") for c in cls_true])\n", + " delay = np.array([c.count(\"납기위험\") for c in cls_true])\n", + " split = (method == \"수의계약\") & (amount >= 250) & (amount < 300) & (rng.random(n) < .6) # 계약 쪼개기 의심(임계값 바로 아래)\n", + " df[\"clauses\"], df[\"clause_cls\"] = clauses, cls_true\n", + " df[\"n_toxic\"], df[\"n_delay\"] = toxic, delay\n", + " viol = hard | (toxic > 0) | split | (delay >= 2)\n", + " waived = hard & (toxic == 0) & (rng.random(n) < .12) # 계약관이 예외 사유를 인정해 위반으로 보지 않은 건(자료의 현실적 잡음)\n", + " hidden = ~viol & (rng.random(n) < .03 + .08 * sl) # 속성·문구에 드러나지 않는 위반(별첨 합의 등)\n", + " df[\"violation\"] = (viol & ~waived) | hidden\n", + " df[\"vtype\"] = np.where(hidden, \"기타\", np.where(hard, \"속성위반\", np.where(toxic > 0, \"독소조항\", np.where(split | (delay >= 2), \"기타\", \"-\"))))\n", + " return df\n", + "\n", + "# ---- 계약 규정 준수 판별 룰 설정(YAML 형식 문자열: 규정이 바뀌면 코드가 아니라 이 설정만 고친다) ----\n", + "RULES_YAML = \"\"\"\n", + "- id: R01\n", + " name: 지체상금률 1.0/1000 미만\n", + " field: penalty_permil\n", + " op: \"<\"\n", + " value: 1.0\n", + " weight: 3\n", + "- id: R02\n", + " name: 선금 비율 40% 초과\n", + " field: advance_pct\n", + " op: \">\"\n", + " value: 40\n", + " weight: 2\n", + "- id: R03\n", + " name: 선금 40% 초과인데 이행보증 10% 미만\n", + " field: adv_no_bond\n", + " op: \"==\"\n", + " value: true\n", + " weight: 3\n", + "- id: R04\n", + " name: 300백만원 이상 수의계약\n", + " field: sole_amount\n", + " op: \">=\"\n", + " value: 300\n", + " weight: 3\n", + "- id: R05\n", + " name: 500백만원 이상 이행보증 10% 미만\n", + " field: bond_gap\n", + " op: \"==\"\n", + " value: true\n", + " weight: 2\n", + "- id: R06\n", + " name: 물품 하자보증 12개월 미만\n", + " field: warranty_gap\n", + " op: \"==\"\n", + " value: true\n", + " weight: 2\n", + "- id: R07\n", + " name: 해지 조항 없음\n", + " field: termination\n", + " op: \"==\"\n", + " value: false\n", + " weight: 2\n", + "- id: R08\n", + " name: 보안 조항 없음\n", + " field: security\n", + " op: \"==\"\n", + " value: false\n", + " weight: 3\n", + "- id: R09\n", + " name: 지식재산 조항 없음(물품·용역)\n", + " field: ip_missing\n", + " op: \"==\"\n", + " value: true\n", + " weight: 1\n", + "- id: R10\n", + " name: 하도급 비율 50% 초과\n", + " field: subcontract_pct\n", + " op: \">\"\n", + " value: 50\n", + " weight: 3\n", + "- id: R11\n", + " name: 검사 기간 14일 초과\n", + " field: inspection_days\n", + " op: \">\"\n", + " value: 14\n", + " weight: 1\n", + "- id: R12\n", + " name: 납품 기한이 계약 기간 초과\n", + " field: logic_err\n", + " op: \"==\"\n", + " value: true\n", + " weight: 3\n", + "- id: R13\n", + " name: 180일 이상 계약에 물가변동 조항 없음\n", + " field: no_padj_long\n", + " op: \"==\"\n", + " value: true\n", + " weight: 1\n", + "- id: R14\n", + " name: 1000백만원 이상 비경쟁 계약\n", + " field: noncomp_big\n", + " op: \"==\"\n", + " value: true\n", + " weight: 2\n", + "- id: R15\n", + " name: 계약 기간 365일 초과\n", + " field: period_days\n", + " op: \">\"\n", + " value: 365\n", + " weight: 1\n", + "- id: R16\n", + " name: 불공정·납기 위험 키워드 포함\n", + " field: kw_hits\n", + " op: \">=\"\n", + " value: 1\n", + " weight: 3\n", + "\"\"\"\n", + "GRADE_CUT = {\"고위험\": 6, \"주의\": 3} # 가중치 합 기준 1차 판별 등급(필요 시 설정만 바꾼다)\n", + "# [교육용가상자료] 불공정 조항·납기 지체 위험 문구 사전(실제 규정 조문이 아님)\n", + "KEYWORDS = {\"독소\": [\"면제하며\", \"일방적으로\", \"포기\", \"지지 않는다\", \"즉시 소멸\", \"제한 없이\", \"신고 없이\", \"전액 부담\", \"별도 협의 없이\", \"검사 전에\", \"검사에 합격한 것으로\", \"사용할 수 없다\", \"증빙은 요구하지 않는다\", \"지정하는 기관\"],\n", + " \"납기위험\": [\"자동 연장\", \"기한 없이\", \"정하지 않는다\", \"노력하며\", \"연장할 수 있다\", \"통보한다\", \"책임을 묻지 않는다\", \"의무가 없다\"]}\n", + "\n", + "\n", + "def parse_rules(text):\n", + " \"\"\"간이 YAML 파서(목록 + key: value). 숫자·true/false·따옴표 문자열을 해석한다.\"\"\"\n", + " # [TODO : 학생 실습 6] 계약서 조항별 위험도 하이라이트 및 조달 검토 대시보드 구현 관련 핵심 로직을 구현하세요.\n", + " # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n", + " # <<< 여기에 코드를 작성하세요 >>>\n", + " pass # 작성 후 pass를 제거하세요.\n", + "OPS = {\"==\": lambda a, b: a == b, \"!=\": lambda a, b: a != b, \">\": lambda a, b: a > b, \">=\": lambda a, b: a >= b,\n", + " \"<\": lambda a, b: a < b, \"<=\": lambda a, b: a <= b}\n", + "\n", + "\n", + "def kw_count(clauses):\n", + " \"\"\"조항 문장 목록에서 독소·납기 위험 키워드가 들어 있는 조항 수.\"\"\"\n", + " # [TODO : 학생 실습 6] 계약서 조항별 위험도 하이라이트 및 조달 검토 대시보드 구현 관련 핵심 로직을 구현하세요.\n", + " # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n", + " # <<< 여기에 코드를 작성하세요 >>>\n", + " pass # 작성 후 pass를 제거하세요.\n", + "def derive(df):\n", + " \"\"\"룰에 쓰는 파생 필드(선금+보증 부족, 수의계약 고액, 논리 오류, 키워드 적중 등)를 만든다.\"\"\"\n", + " d = df.copy()\n", + " d[\"adv_no_bond\"] = (d[\"advance_pct\"] > 40) & (d[\"perf_bond_pct\"] < 10)\n", + " d[\"sole_amount\"] = np.where(d[\"method\"] == \"수의계약\", d[\"amount_m\"], 0.0) # 수의계약일 때만 금액(한도는 YAML에서 조정)\n", + " d[\"bond_gap\"] = (d[\"amount_m\"] >= 500) & (d[\"perf_bond_pct\"] < 10)\n", + " d[\"warranty_gap\"] = (d[\"ctype\"] == \"물품\") & (d[\"warranty_months\"] < 12)\n", + " d[\"ip_missing\"] = (~d[\"ip_clause\"]) & (d[\"ctype\"] != \"공사\")\n", + " d[\"logic_err\"] = d[\"delivery_days\"] > d[\"period_days\"]\n", + " d[\"no_padj_long\"] = (d[\"period_days\"] >= 180) & (~d[\"price_adjust\"])\n", + " d[\"noncomp_big\"] = d[\"method\"].isin([\"지명경쟁\", \"수의계약\"]) & (d[\"amount_m\"] >= 1000)\n", + " d[\"kw_hits\"] = [kw_count(c) for c in d[\"clauses\"]] if \"clauses\" in d else 0\n", + " return d\n", + "\n", + "\n", + "def apply_rules(rec, rules):\n", + " \"\"\"레코드 1건(dict)에 룰을 적용해 (점수, 등급, 적중 룰 id 목록)을 돌려준다. 값이 없으면 해당 룰은 건너뛴다.\"\"\"\n", + " hits = []\n", + " for r in rules:\n", + " v = rec.get(r[\"field\"])\n", + " if v is None or (isinstance(v, float) and np.isnan(v)):\n", + " continue\n", + " if OPS[r[\"op\"]](v, r[\"value\"]):\n", + " hits.append(r)\n", + " score = sum(r[\"weight\"] for r in hits)\n", + " grade = \"고위험\" if score >= GRADE_CUT[\"고위험\"] else (\"주의\" if score >= GRADE_CUT[\"주의\"] else \"관심\")\n", + " return score, grade, [r[\"id\"] for r in hits]\n", + "\n", + "SIMPLE_KW = {\"독소\": [\"면제\", \"일방\", \"포기\", \"전액\", \"누설\", \"하도급\", \"선지급\"], \"납기위험\": [\"연장\", \"자동\", \"노력\", \"통보\", \"기한\"]}\n", + "\n", + "# ---- 조항 문장 분류(정상 / 독소 / 납기위험): 글자 n-gram TF-IDF + 로지스틱 회귀 ----\n", + "def clause_set(seed, n, hold=False, mix=(.55, .25, .20)):\n", + " \"\"\"조항 문장 n개와 정답 분류를 만든다. hold=True면 학습에 쓰지 않은 '처음 보는 표현'으로 만든다.\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " y = rng.choice(CLASSES, n, p=list(mix))\n", + " return [make_clause(rng, c, hold) for c in y], list(y)\n", + "\n", + "\n", + "def fit_clause_model(seed=100, n=1200):\n", + " \"\"\"학습용 조항 문장으로 분류 모델을 학습한다.\"\"\"\n", + " X, y = clause_set(seed, n)\n", + " return make_pipeline(TfidfVectorizer(analyzer=\"char_wb\", ngram_range=(2, 4), sublinear_tf=True), LogisticRegression(C=20, max_iter=2000)).fit(X, y)\n", + "\n", + "\n", + "def keyword_pred(s):\n", + " \"\"\"비교용 기준선: 단순 키워드(부정 표현·문맥을 보지 않음) 적중으로만 분류.\"\"\"\n", + " if any(k in s for k in SIMPLE_KW[\"독소\"]):\n", + " return \"독소\"\n", + " return \"납기위험\" if any(k in s for k in SIMPLE_KW[\"납기위험\"]) else \"정상\"\n", + "\n", + "NUM = [\"amount_m\", \"period_days\", \"delivery_days\", \"penalty_permil\", \"advance_pct\", \"perf_bond_pct\", \"warranty_months\", \"inspection_days\", \"subcontract_pct\"]\n", + "BOOL = [\"price_adjust\", \"termination\", \"security\", \"ip_clause\"]\n", + "NEED = [\"contract_id\", \"ctype\", \"method\"] + NUM + BOOL + [\"clauses\"]\n", + "RANGE = {\"amount_m\": (1, 100000), \"period_days\": (1, 1500), \"delivery_days\": (1, 1500), \"penalty_permil\": (0, 10), \"advance_pct\": (0, 100), \"perf_bond_pct\": (0, 100),\n", + " \"warranty_months\": (0, 120), \"inspection_days\": (1, 90), \"subcontract_pct\": (0, 100)}\n", + "LOG, COMMITTED = [], {}\n", + "RULES = parse_rules(RULES_YAML)\n", + "CM = fit_clause_model()\n", + "MAX_CLAUSE = 600\n", + "\n", + "\n", + "class UserError(Exception):\n", + " \"\"\"사용자에게 보여 줄 메시지와 조치 가이드를 함께 가진 예외.\"\"\"\n", + "\n", + " def __init__(self, code, msg, action):\n", + " super().__init__(msg); self.code, self.msg, self.action = code, msg, action\n", + "\n", + "\n", + "class Transient(Exception):\n", + " \"\"\"일시 오류(재시도 대상).\"\"\"\n", + "\n", + "\n", + "def log(level, code, detail):\n", + " LOG.append({\"seq\": len(LOG) + 1, \"level\": level, \"code\": code, \"detail\": str(detail)[:70]})\n", + "\n", + "\n", + "def read_input(src):\n", + " \"\"\"입력(CSV 문자열 / JSON 문자열 / DataFrame)을 DataFrame으로 읽는다. 실패하면 UserError.\"\"\"\n", + " if src is None:\n", + " raise UserError(\"E01\", \"입력이 비어 있습니다.\", \"파일을 다시 선택해 주세요.\")\n", + " if isinstance(src, pd.DataFrame):\n", + " df = src.copy()\n", + " elif isinstance(src, str):\n", + " if not src.strip():\n", + " raise UserError(\"E02\", \"빈 파일입니다.\", \"내용이 있는 파일인지 확인해 주세요.\")\n", + " try:\n", + " df = pd.DataFrame(json.loads(src)) if src.lstrip()[0] in \"[{\" else pd.DataFrame(list(csv.DictReader(io.StringIO(src))))\n", + " except Exception as e:\n", + " raise UserError(\"E03\", \"파일 형식을 읽을 수 없습니다.\", \"CSV(쉼표 구분) 또는 JSON(목록) 형식으로 저장했는지 확인해 주세요.\") from e\n", + " else:\n", + " raise UserError(\"E04\", f\"지원하지 않는 입력 형식입니다({type(src).__name__}).\", \"CSV 또는 JSON 파일을 사용해 주세요.\")\n", + " if df.empty:\n", + " raise UserError(\"E02\", \"읽은 행이 0건입니다.\", \"머리글만 있는 파일이 아닌지 확인해 주세요.\")\n", + " return df\n", + "\n", + "\n", + "def clean_text(s):\n", + " \"\"\"특수문자·제어문자 제거, 지나치게 긴 조항은 잘라 내기.\"\"\"\n", + " s = re.sub(r\"[\\x00-\\x08\\x0b-\\x1f\\x7f<>]\", \" \", str(s))\n", + " return re.sub(r\"\\s+\", \" \", s).strip()[:MAX_CLAUSE]\n", + "\n", + "\n", + "def validate_table(df):\n", + " \"\"\"열 점검 -> 행 단위 점검. 문제 행은 격리하고 나머지로 계속 진행한다. 반환: (정상 행, 격리 내역)\"\"\"\n", + " miss = [c for c in NEED if c not in df.columns]\n", + " if miss:\n", + " raise UserError(\"E10\", f\"필수 열이 없습니다: {', '.join(miss[:4])}{' 외' if len(miss) > 4 else ''}\", \"열 이름이 양식(데이터 딕셔너리)과 같은지 확인해 주세요.\")\n", + " d, bad = df.copy().reset_index(drop=True), {}\n", + " for c in NUM:\n", + " conv = pd.to_numeric(d[c], errors=\"coerce\")\n", + " for i in d.index[conv.isna()]:\n", + " bad.setdefault(i, f\"{c}: 숫자가 아님/누락\")\n", + " d[c] = conv\n", + " for c, (lo, hi) in RANGE.items():\n", + " for i in d.index[(d[c] < lo) | (d[c] > hi)]:\n", + " bad.setdefault(i, f\"{c}: 허용 범위({lo}~{hi}) 밖\")\n", + " for c, ok in ((\"ctype\", TYPES), (\"method\", METHODS)):\n", + " for i in d.index[~d[c].isin(ok)]:\n", + " bad.setdefault(i, f\"{c}: 허용되지 않은 값\")\n", + " for i in d.index[d[\"contract_id\"].duplicated(keep=\"first\")]:\n", + " bad.setdefault(i, \"contract_id: 중복\")\n", + " for i in d.index[~d[\"contract_id\"].astype(str).str.match(r\"^C-\\d{4}$\")]:\n", + " bad.setdefault(i, \"contract_id: 형식 오류\")\n", + " for c in BOOL:\n", + " d[c] = d[c].map(lambda v: v if isinstance(v, (bool, np.bool_)) else str(v).strip().lower() in (\"true\", \"1\", \"예\", \"y\"))\n", + " d[\"clauses\"] = d[\"clauses\"].map(lambda v: [clean_text(x) for x in (v if isinstance(v, list) else str(v).split(\" || \")) if str(x).strip()])\n", + " for i in d.index[d[\"clauses\"].map(len) == 0]:\n", + " bad.setdefault(i, \"clauses: 조항 없음\")\n", + " if len(bad) == len(d):\n", + " raise UserError(\"E11\", \"사용할 수 있는 행이 없습니다.\", \"격리 사유를 확인해 값을 고친 뒤 다시 올려 주세요.\")\n", + " return d.drop(index=list(bad)).reset_index(drop=True), bad\n", + "\n", + "\n", + "MARK = {\"독소\": \"[!!]\", \"납기위험\": \"[! ]\", \"정상\": \"[ok]\"}\n", + "\n", + "\n", + "def highlight(clauses):\n", + " \"\"\"조항별 위험 표시: 모델 분류 결과와 키워드 위치를 【 】로 강조한다.\"\"\"\n", + " cls = CM.predict(clauses)\n", + " out = []\n", + " for c, k in zip(clauses, cls):\n", + " for kw in KEYWORDS[\"독소\"] + KEYWORDS[\"납기위험\"]:\n", + " if k != \"정상\" and kw in c:\n", + " c = c.replace(kw, f\"【{kw}】\")\n", + " out.append((k, c))\n", + " return out\n", + "\n", + "\n", + "def build_dashboard(df, fail_times=0, state=None):\n", + " \"\"\"대시보드 산출물을 한 번에 만든다(전부 성공해야 반영 = 트랜잭션). 반환: 결과 dict, 격리 내역\"\"\"\n", + " d, bad = validate_table(read_input(df))\n", + " if state is not None and state[\"calls\"] < fail_times:\n", + " state[\"calls\"] += 1\n", + " raise Transient(\"외부 저장소 응답 지연(모의)\")\n", + " dd = derive(d)\n", + " dd[\"score\"] = [apply_rules(r, RULES)[0] for r in dd.to_dict(\"records\")]\n", + " heat = dd.pivot_table(index=\"method\", columns=\"ctype\", values=\"score\", aggfunc=\"mean\").round(1)\n", + " top = dd.sort_values([\"kw_hits\", \"score\"], ascending=False).head(3) # 위험 문구가 많은 계약부터\n", + " hl = {r[\"contract_id\"]: highlight(r[\"clauses\"]) for _, r in top.iterrows()}\n", + " return {\"heat\": heat, \"top\": top[[\"contract_id\", \"ctype\", \"method\", \"amount_m\", \"score\"]], \"hl\": hl, \"n\": len(dd)}, bad\n", + "\n", + "\n", + "def safe_dashboard(case, src, fail_times=0, always_fail=False):\n", + " \"\"\"모든 예외를 잡아 크래시 없이 사용자 메시지·조치 안내를 돌려주고 에러 로그를 남긴다. 일시 오류는 3회까지 재시도하고, 끝내 실패하면 직전 정상 결과로 롤백한다.\"\"\"\n", + " state = {\"calls\": 0}\n", + " for attempt in range(1, 4):\n", + " try:\n", + " if always_fail:\n", + " raise Transient(\"저장소 연결 불가(모의)\")\n", + " res, bad = build_dashboard(src, fail_times, state)\n", + " for i, why in bad.items():\n", + " log(\"WARN\", \"W01\", f\"{case}: 행 {i} 격리 - {why}\")\n", + " COMMITTED[\"last\"] = res # 전부 성공했을 때만 반영\n", + " return {\"case\": case, \"ok\": True, \"msg\": f\"표시 완료(격리 {len(bad)}행)\" if bad else \"표시 완료\" + (f\", 재시도 {attempt - 1}회 후 성공\" if attempt > 1 else \"\"), \"action\": \"-\"}\n", + " except UserError as e:\n", + " log(\"ERROR\", e.code, f\"{case}: {e.msg}\")\n", + " return {\"case\": case, \"ok\": False, \"msg\": e.msg, \"action\": e.action}\n", + " except Transient as e:\n", + " log(\"WARN\", \"W02\", f\"{case}: 일시 오류 시도 {attempt}/3 - {e}\")\n", + " time.sleep(0.01)\n", + " except Exception as e: # 예상하지 못한 오류도 크래시 대신 안내로 변환\n", + " log(\"ERROR\", \"E99\", f\"{case}: {type(e).__name__} {e}\")\n", + " return {\"case\": case, \"ok\": False, \"msg\": \"예상하지 못한 오류가 발생했습니다.\", \"action\": \"오류 코드 E99와 입력 파일을 담당자에게 전달해 주세요.\"}\n", + " log(\"ERROR\", \"E50\", f\"{case}: 재시도 3회 실패 -> 직전 정상 결과로 롤백\")\n", + " return {\"case\": case, \"ok\": False, \"msg\": \"일시적인 문제로 저장하지 못했습니다. 직전 정상 화면을 유지합니다.\", \"action\": \"잠시 후 다시 시도해 주세요.\"}\n", + "\n", + "\n", + "con = make_contracts(100, seed=7)\n", + "base = con.assign(clauses=con[\"clauses\"].map(lambda c: \" || \".join(c))) # CSV처럼 조항을 한 칸에 이어 붙인 형태\n", + "csv_ok = base.drop(columns=[\"clause_cls\", \"n_toxic\", \"n_delay\", \"violation\", \"vtype\"]).to_csv(index=False)\n", + "\n", + "\n", + "def mut(**kw):\n", + " d = base.copy()\n", + " for k, v in kw.items():\n", + " d[k] = d[k].astype(object); d.loc[d.index[:3], k] = v\n", + " return d\n", + "\n", + "\n", + "big = pd.concat([base] * 200, ignore_index=True)\n", + "big[\"contract_id\"] = [f\"C-{i % 10000:04d}\" for i in range(len(big))] # 대용량(20,000행, 번호 중복은 격리 대상)\n", + "cases = [\n", + " (\"정상 입력\", base, {}), (\"정상 CSV 문자열\", csv_ok, {}), (\"입력 없음(None)\", None, {}), (\"빈 문자열\", \" \", {}), (\"깨진 JSON\", '[{\"contract_id\": \"C-0001\", ', {}),\n", + " (\"지원하지 않는 형식(정수)\", 12345, {}), (\"머리글만 있는 CSV\", \",\".join(base.columns) + \"\\n\", {}), (\"필수 열 누락\", base.drop(columns=[\"penalty_permil\", \"clauses\"]), {}),\n", + " (\"숫자 열에 문자\", mut(amount_m=\"미정\"), {}), (\"음수 금액\", mut(amount_m=-5), {}), (\"계약 방식 결측\", mut(method=np.nan), {}), (\"허용되지 않은 유형\", mut(ctype=\"기타\"), {}),\n", + " (\"계약번호 중복·형식 오류\", mut(contract_id=\"C-9\"), {}), (\"조항 칸이 비어 있음\", mut(clauses=\"\"), {}),\n", + " (\"특수문자·제어문자·태그\", mut(clauses=\" \\x00\\x07 지체상금은 전액 면제하며\"), {}),\n", + " (\"조항 문장 1MB\", mut(clauses=\"가\" * 1_000_000), {}), (\"극단값(1e12)\", mut(amount_m=1e12), {}),\n", + " (\"대용량 20,000행\", big, {}), (\"일시 오류 2회 후 성공\", base, {\"fail_times\": 2}), (\"끝내 실패(롤백)\", base, {\"always_fail\": True}),\n", + "]\n", + "res, crash, times = [], 0, {}\n", + "for name, src, kw in cases:\n", + " t0 = time.perf_counter()\n", + " try:\n", + " res.append(safe_dashboard(name, src, **kw))\n", + " except BaseException: # safe_dashboard 밖으로 예외가 새 나간 건수\n", + " crash += 1\n", + " times[name] = time.perf_counter() - t0\n", + "print(f\"[에러 핸들러 시험] 입력 {len(cases)}종 실행\")\n", + "for r in res:\n", + " print(f\" {'OK ' if r['ok'] else '안내'} {r['case']:22s} -> {r['msg'][:46]}\" + (f\" | 조치: {r['action'][:34]}\" if r[\"action\"] != \"-\" else \"\"))\n", + "n_err = sum(not r[\"ok\"] for r in res)\n", + "logged_err = sum(l[\"level\"] == \"ERROR\" for l in LOG)\n", + "print(f\"\\n예외 주입 {len(cases)}종 실행 완료(목표 10종 이상: {'달성' if len(cases) >= 10 else '미달'}), 크래시 {crash}건(목표 0건: {'달성' if crash == 0 else '미달'})\")\n", + "print(f\"오류 {n_err}건 중 ERROR 로그 {logged_err}건 -> 기록률 {min(logged_err / max(n_err, 1), 1):.0%}(목표 100%: {'달성' if logged_err == n_err else '미달'}), 안내 문구에 조치 가이드 포함 {sum(r['action'] != '-' for r in res if not r['ok'])}/{n_err}건\")\n", + "print(f\"행 격리 경고 {sum(l['code'] == 'W01' for l in LOG)}건, 재시도 경고 {sum(l['code'] == 'W02' for l in LOG)}건 기록, 대용량 20,000행 처리 {times['대용량 20,000행']:.1f}초\")\n", + "print(\"직전 정상 결과 유지(롤백): 마지막 반영된 화면의 계약 수 =\", COMMITTED[\"last\"][\"n\"], \"건\")\n", + "print(\"\\n[에러 로그 앞 5건]\")\n", + "print(pd.DataFrame(LOG).head(5).to_string(index=False))\n", + "\n", + "# 정상 입력으로 대시보드(조항 하이라이트 + 위험 지도) 보여 주기\n", + "out, bad = build_dashboard(base)\n", + "print(\"\\n[위험 지도] 계약 방식 x 유형 평균 룰 점수\")\n", + "print(out[\"heat\"].fillna(0).to_string())\n", + "print(\"\\n[조항별 위험 하이라이트] 위험 문구가 많은 상위 3건 (【 】= 위험 표현, [!!]=독소, [! ]=납기위험, [ok]=정상)\")\n", + "for _, r in out[\"top\"].iterrows():\n", + " print(f\" {r['contract_id']} {r['ctype']} {r['method']} {r['amount_m']:.0f}백만원 룰 점수 {r['score']}\")\n", + " for k, c in out[\"hl\"][r[\"contract_id\"]]:\n", + " print(f\" {MARK[k]} {c[:52]}\")\n", + "print(\"※ 하이라이트는 검토 우선순위 표시이며 불공정 여부의 최종 판단은 계약관·법무 담당자가 한다.\")\n", + "\n", + "heat = out[\"heat\"].fillna(0)\n", + "fig, ax = plt.subplots(1, 3, figsize=(13, 4))\n", + "im = ax[0].imshow(heat.to_numpy(), cmap=\"Reds\"); ax[0].set_xticks(range(heat.shape[1]), [\"goods\", \"service\", \"works\"][:heat.shape[1]]); ax[0].set_yticks(range(heat.shape[0]), [f\"m{i + 1}\" for i in range(heat.shape[0])])\n", + "ax[0].set_title(\"Risk map (method x type)\"); plt.colorbar(im, ax=ax[0])\n", + "ax[1].bar([\"ok\", \"guided\", \"crash\"], [sum(r[\"ok\"] for r in res), n_err, crash], color=[\"tab:green\", \"gold\", \"tab:red\"]); ax[1].set_title(\"Error injection results (20 cases)\")\n", + "cnt = pd.Series([k for _, cl in out[\"hl\"].items() for k, _ in cl]).value_counts()\n", + "ax[2].bar([{\"독소\": \"toxic\", \"납기위험\": \"delay\", \"정상\": \"ok\"}[k] for k in cnt.index], cnt.values, color=\"tab:blue\"); ax[2].set_title(\"Clause labels in top-3 contracts\")\n", + "plt.tight_layout(); plt.show()\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 06 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + "[에러 핸들러 시험] 입력 20종 실행\n", + " OK 정상 입력 -> 표시 완료\n", + " OK 정상 CSV 문자열 -> 표시 완료\n", + " 안내 입력 없음(None) -> 입력이 비어 있습니다. | 조치: 파일을 다시 선택해 주세요.\n", + " 안내 빈 문자열 -> 빈 파일입니다. | 조치: 내용이 있는 파일인지 확인해 주세요.\n", + " 안내 깨진 JSON -> 파일 형식을 읽을 수 없습니다. | 조치: CSV(쉼표 구분) 또는 JSON(목록) 형식으로 저장했는지 \n", + " 안내 지원하지 않는 형식(정수) -> 지원하지 않는 입력 형식입니다(int). | 조치: CSV 또는 JSON 파일을 사용해 주세요.\n", + " 안내 머리글만 있는 CSV -> 읽은 행이 0건입니다. | 조치: 머리글만 있는 파일이 아닌지 확인해 주세요.\n", + " 안내 필수 열 누락 -> 필수 열이 없습니다: penalty_permil, clauses | 조치: 열 이름이 양식(데이터 딕셔너리)과 같은지 확인해 주세요.\n", + " OK 숫자 열에 문자 -> 표시 완료(격리 3행)\n", + " OK 음수 금액 -> 표시 완료(격리 3행)\n", + " OK 계약 방식 결측 -> 표시 완료(격리 3행)\n", + " OK 허용되지 않은 유형 -> 표시 완료(격리 3행)\n", + " OK 계약번호 중복·형식 오류 -> 표시 완료(격리 3행)\n", + " OK 조항 칸이 비어 있음 -> 표시 완료(격리 3행)\n", + " OK 특수문자·제어문자·태그 -> 표시 완료\n", + " OK 조항 문장 1MB -> 표시 완료\n", + " OK 극단값(1e12) -> 표시 완료(격리 3행)\n", + " OK 대용량 20,000행 -> 표시 완료(격리 10000행)\n", + " OK 일시 오류 2회 후 성공 -> 표시 완료, 재시도 2회 후 성공\n", + " 안내 끝내 실패(롤백) -> 일시적인 문제로 저장하지 못했습니다. 직전 정상 화면을 유지합니다. | 조치: 잠시 후 다시 시도해 주세요.\n", + "\n", + "예외 주입 20종 실행 완료(목표 10종 이상: 달성), 크래시 0건(목표 0건: 달성)\n", + "오류 7건 중 ERROR 로그 7건 -> 기록률 100%(목표 100%: 달성), 안내 문구에 조치 가이드 포함 7/7건\n", + "행 격리 경고 10021건, 재시도 경고 5건 기록, 대용량 20,000행 처리 2.6초\n", + "직전 정상 결과 유지(롤백): 마지막 반영된 화면의 계약 수 = 100 건\n", + "\n", + "[에러 로그 앞 5건]\n", + " seq level code detail\n", + " 1 ERROR E01 입력 없음(None): 입력이 비어 있습니다.\n", + " 2 ERROR E02 빈 문자열: 빈 파일입니다.\n", + " 3 ERROR E03 깨진 JSON: 파일 형식을 읽을 수 없습니다.\n", + " 4 ERROR E04 지원하지 않는 형식(정수): 지원하지 않는 입력 형식입니다(int).\n", + " 5 ERROR E02 머리글만 있는 CSV: 읽은 행이 0건입니다.\n", + "\n", + "[위험 지도] 계약 방식 x 유형 평균 룰 점수\n", + "ctype 공사 물품 용역\n", + "method \n", + "수의계약 1.0 2.5 2.3\n", + "일반경쟁 2.7 1.8 1.1\n", + "... (이하 생략)\n", + "```\n", + "
\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 07. 계약 승인 전 법무/계약관 수동 검토 및 조항 수정 권고 HITL 절차 설계\n", + "**소요시간**: 약 3.0시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "계약 승인 전 계약관 수동 검토 및 조항 수정 권고 HITL 절차를 설계한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **계약 승인 전 계약관·법무 수동 검토와 조항 수정 권고를 위한 HITL 절차를 설계하고 이력 무결성과 권한 통제를 검증한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **시스템 제안, 계약담당 상신, 계약관 검토, 법무 검토, 승인권자 결재의 순서를 상태 기계로 구현하고 역할별 권한을 최소로 둔다 과정을 진행하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `src/workflow/prc01_approval_system.py` 형태의 결과물을 완성한다.\n", + "\n", + "### 🔍 평가 및 검증 기준 (PASS 조건)\n", + "#### 미션 판정\n", + "\n", + "**평가 구분:** 필수\n", + "\n", + "**핵심 평가영역:** 군 보안 규정 및 행정 절차 부합성\n", + "\n", + "#### 미션 PASS 조건\n", + "\n", + "- 정량 기준: 이력 기록 무결성 100%, 승인 권한 우회 시도 차단율 100%\n", + "\n", + "\n", + "- 진행 기준: 결재선 승인/반려 시뮬레이션 및 이력 조회 화면 구현\n", + "\n", + "\n", + "- 검증 방법: 권한별 접근 제어(RBAC) 테스트\n", + "\n", + "\n", + "#### 채점자 판정 메모\n", + "\n", + "> 단순 코드 실행 여부뿐만 아니라 출력된 수치와 근거 자료가 군 실무 요구사항에 부합하는지 면밀히 확인한다.\n", + "\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "\"\"\"고위험 계약의 조항 수정 권고를 계약담당 상신 -> 계약관 검토 -> 법무 검토 -> 승인권자 결재의 HITL 워크플로우로 처리하되, 반려 사유 필수화·재기안 연계·시스템 자동 확정 금지·권한 우회 차단(RBAC)과 해시 체인 감사 로그의 이력 무결성·위변조 탐지를 검증한다.\"\"\"\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.feature_extraction.text import TfidfVectorizer\n", + "from sklearn.linear_model import LogisticRegression\n", + "from sklearn.pipeline import make_pipeline\n", + "\n", + "# ---------------------------------------------------------------------------\n", + "# [교육용가상자료] 가상 조달계획안·계약서 생성기 (실제 계약·업체·법령 조문 없음)\n", + "# 계약 속성 16개 + 조항 문장 6개를 만들고, 속성 위반·독소 조항·납기 위험 문구가 섞인 '규정 위반 여부'를 함께 만든다.\n", + "# ---------------------------------------------------------------------------\n", + "TYPES = [\"물품\", \"용역\", \"공사\"]\n", + "METHODS = [\"일반경쟁\", \"제한경쟁\", \"지명경쟁\", \"수의계약\"]\n", + "CLASSES = [\"정상\", \"독소\", \"납기위험\"]\n", + "OBJ = [\"납품 물품\", \"용역 결과물\", \"시공 목적물\", \"정비 성과물\"]\n", + "\n", + "N_TPL = [\n", + " \"계약상대자가 납기를 지체한 경우 지체일수 1일마다 계약금액의 {p}/1000에 해당하는 지체상금을 부과한다.\",\n", + " \"지체상금은 불가항력 사유가 확인된 경우에 한하여 발주기관의 승인을 받아 면제할 수 있다.\",\n", + " \"납기 연장은 발주기관이 불가항력 사유를 확인하고 서면으로 승인한 경우에만 인정한다.\",\n", + " \"발주기관은 납품된 {o}을 {d}일 이내에 검사하고 합격한 후에 대금을 지급한다.\",\n", + " \"계약상대자는 하도급을 하려는 경우 사전에 발주기관의 서면 승인을 받아야 한다.\",\n", + " \"어느 한쪽이 계약을 해지하려면 사유를 명시해 {d}일 전에 서면으로 통지해야 한다.\",\n", + " \"{o}에 하자가 발생하면 계약상대자는 보증 기간 안에 무상으로 보수하거나 교체한다.\",\n", + " \"계약 이행 중 알게 된 비공개 사항은 제3자에게 누설하지 않으며 위반 시 책임을 진다.\",\n", + " \"{o}에 대한 지식재산권의 귀속과 사용 범위는 별도 합의서에 따라 정한다.\",\n", + " \"대금은 검사 합격 후 청구서를 접수한 날부터 {d}일 이내에 지급한다.\",\n", + " \"계약금액의 조정은 물가변동 기준에 따라 양 당사자가 서면으로 합의한 경우에만 한다.\",\n", + " \"계약상대자는 이행보증서를 계약 체결 시 제출하며 보증금은 계약 이행 완료 후 반환한다.\",\n", + " \"납품 일정은 계약서 별표의 일자를 따르며 변경 시 서면 합의가 필요하다.\",\n", + " \"지체상금은 면제 사유가 없는 한 부과하며 이의는 정해진 절차로 제기할 수 있다.\",\n", + " \"검사에 합격하지 못한 {o}은 계약상대자가 교체하며 그 비용은 계약상대자가 부담한다.\",\n", + " \"계약상대자의 귀책 사유로 이행이 불가능하면 발주기관은 서면으로 통지하고 계약을 해지할 수 있다.\",\n", + " \"지연이 예상되면 계약상대자는 사유와 만회 계획을 발주기관에 서면으로 보고한다.\",\n", + " \"보증 기간 중 발생한 결함은 계약상대자가 책임지고 보수한다.\",\n", + " \"분쟁은 먼저 협의로 해결하고 협의가 되지 않으면 관할 법원의 판단에 따른다.\",\n", + "]\n", + "T_TPL = [\n", + " \"계약상대자의 지체상금은 전액 면제하며 발주기관은 이에 대해 이의를 제기하지 못한다.\",\n", + " \"납품 즉시 검사에 합격한 것으로 보며 발주기관은 이후 검사를 요구하지 않는다.\",\n", + " \"계약상대자는 언제든지 일방적으로 계약을 해지할 수 있으며 그에 따른 손해는 발주기관이 부담한다.\",\n", + " \"{o}의 추가 비용은 사유를 불문하고 발주기관이 전액 부담하며 별도 협의 없이 청구할 수 있다.\",\n", + " \"{o}에 관한 지식재산권은 모두 계약상대자에게 귀속되고 발주기관은 사용할 수 없다.\",\n", + " \"하도급은 신고 없이 제한 없이 허용하며 발주기관은 하도급 내용을 확인하지 않는다.\",\n", + " \"대금은 검사 전에 전액 선지급하며 발주기관은 반환 청구권을 포기한다.\",\n", + " \"계약 이행 중 알게 된 사항의 누설로 인한 책임을 계약상대자는 지지 않는다.\",\n", + " \"하자 보수 책임은 인도 후 즉시 소멸하며 발주기관은 보수를 요구할 수 없다.\",\n", + " \"발주기관은 계약상대자가 청구하는 금액을 거절할 수 없으며 거절하면 위약금을 지급한다.\",\n", + " \"분쟁이 생기면 계약상대자가 정하는 중재 기관에서만 해결하고 발주기관은 따른다.\",\n", + " \"발주기관의 검사는 생략하고 계약상대자의 시험 결과서로 대신한다.\",\n", + " \"견적 금액은 사후 정산 없이 그대로 확정하고 증빙 자료는 받지 않는다.\",\n", + "]\n", + "D_TPL = [\n", + " \"납기는 자재 수급 사정에 따라 계약상대자가 연장할 수 있다.\",\n", + " \"납품 일정은 가능한 한 지키도록 노력하며 지연되어도 사유를 보고하지 않는다.\",\n", + " \"공급 지연이나 선행 업체 사정도 불가항력으로 보아 납기를 자동 연장한다.\",\n", + " \"착수일은 선행 업체의 납품이 끝난 뒤 별도로 정하며 완료 기한은 정하지 않는다.\",\n", + " \"납기일 이후 1회에 한해 기한 없이 자동 연장되는 것으로 한다.\",\n", + " \"납기는 계약상대자의 생산 일정이 확정되는 대로 통보한다.\",\n", + " \"납품 시점은 계약상대자 사정이 허락하는 때로 하고 날짜는 따로 정하지 않는다.\",\n", + " \"자재가 늦게 들어오면 완료 일정은 그만큼 자동으로 늦춰진다.\",\n", + " \"기한 내에 인도하지 못해도 계약상대자에게 책임을 묻지 않는다.\",\n", + " \"일정이 늦어져도 계약상대자는 새 계획서를 낼 필요가 없다.\",\n", + "]\n", + "# 학습에 쓰지 않는 '처음 보는 표현'(모델이 문장 틀을 외웠는지 확인하는 용도)\n", + "HOLD = {\n", + " \"정상\": [\"검사에 불합격한 경우 계약상대자는 지정 기한 안에 재납품하며 재검사 비용은 상대자가 부담한다.\",\n", + " \"계약상대자가 법령을 위반하여 계약을 이행하지 못하면 발주기관은 서면 통지 후 계약을 해지할 수 있다.\",\n", + " \"납기가 지연될 우려가 있으면 상대자는 즉시 사유와 만회 계획을 서면으로 보고해야 한다.\",\n", + " \"하자담보 책임 기간은 인수일부터 기산하며 기간 중 발생한 결함은 상대자가 책임진다.\"],\n", + " \"독소\": [\"발주기관은 계약상대자가 요구하는 대금을 이유 없이 거절하지 못하며 거절 시 위약금을 낸다.\",\n", + " \"계약 내용에 관한 분쟁은 계약상대자가 지정하는 기관에서만 다루며 발주기관은 이에 따른다.\",\n", + " \"발주기관의 검수 권한은 포기하고 계약상대자의 자체 시험 성적서로 갈음한다.\",\n", + " \"계약상대자가 제출한 견적은 사후 정산 없이 확정 단가로 하며 증빙은 요구하지 않는다.\"],\n", + " \"납기위험\": [\"납품 시기는 상대자의 형편이 허락하는 시점으로 하고 별도 날짜는 정하지 않는다.\",\n", + " \"원자재 확보가 늦어지면 완료 일자는 자동으로 뒤로 미뤄지는 것으로 본다.\",\n", + " \"인도 기한은 참고용이며 지키지 못해도 책임을 묻지 않는다.\",\n", + " \"일정 지연이 있어도 상대자는 변경 계획을 제출할 의무가 없다.\"],\n", + "}\n", + "SUFFIX = [\"\", \"\", \"\", \" 세부 사항은 시행 세칙에 따른다.\", \" 이 조항은 계약 전체에 적용한다.\", \" 필요한 경우 양 당사자가 협의한다.\", \" 관련 서식은 별지를 따른다.\"]\n", + "SWAP = [(\"발주기관\", \"계약담당관\"), (\"계약상대자\", \"업체\")]\n", + "\n", + "\n", + "def make_clause(rng, cls, hold=False, idx=None):\n", + " \"\"\"조항 한 문장 생성. 표현 일부는 동의어로 바꾸고 접미 문구를 무작위로 붙인다(idx를 주면 그 번호의 문장 틀을 사용).\"\"\"\n", + " if idx is not None:\n", + " tpl = {\"정상\": N_TPL, \"독소\": T_TPL, \"납기위험\": D_TPL}[cls]\n", + " s = tpl[idx].format(p=rng.choice([\"1.0\", \"1.5\", \"2.0\"]), d=int(rng.choice([7, 10, 14, 30])), o=rng.choice(OBJ))\n", + " elif hold:\n", + " s = HOLD[cls][rng.integers(len(HOLD[cls]))]\n", + " else:\n", + " tpl = {\"정상\": N_TPL, \"독소\": T_TPL, \"납기위험\": D_TPL}[cls]\n", + " s = tpl[rng.integers(len(tpl))].format(p=rng.choice([\"1.0\", \"1.5\", \"2.0\"]), d=int(rng.choice([7, 10, 14, 30])), o=rng.choice(OBJ))\n", + " if rng.random() < 0.3:\n", + " a, b = SWAP[rng.integers(2)]\n", + " s = s.replace(a, b)\n", + " return s + SUFFIX[rng.integers(len(SUFFIX))]\n", + "\n", + "\n", + "def make_contracts(n=200, seed=7):\n", + " \"\"\"가상 계약 n건(속성 16개 + 조항 6개 + 규정 위반 정답)을 만든다.\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " sl = rng.beta(1.2, 3.2, n) # 계약서 작성이 허술한 정도(잠재 변수)\n", + " ctype = rng.choice(TYPES, n, p=[.5, .3, .2])\n", + " amount = np.clip(np.exp(rng.normal(np.log(220), 1.1, n)), 5, 5000).round(0)\n", + " method = np.where(rng.random(n) < .06 + .3 * sl, \"수의계약\", rng.choice(METHODS[:3], n, p=[.5, .3, .2]))\n", + " period = np.array([rng.integers(60, 300) if t == \"물품\" else rng.integers(90, 400) if t == \"용역\" else rng.integers(180, 720) for t in ctype])\n", + " delivery = (period * rng.uniform(.5, 1.0, n)).astype(int)\n", + " bad_logic = rng.random(n) < .015 * (1 + 3 * sl)\n", + " delivery = np.where(bad_logic, period + rng.integers(10, 60, n), delivery)\n", + " pen = np.where(rng.random(n) < .22 * sl, rng.choice([.3, .5, .8], n), rng.choice([1.0, 1.5, 2.0, 2.5], n))\n", + " adv = np.where(rng.random(n) < .22 * sl, rng.choice([50, 60, 70], n), rng.choice([0, 10, 20, 30, 40], n))\n", + " bond = np.where(rng.random(n) < .25 * sl + .5 * (adv > 40), rng.choice([0, 5], n), rng.choice([10, 10, 15, 20], n))\n", + " war = np.array([rng.choice([3, 6]) if (rng.random() < .25 * s and t == \"물품\") else rng.choice([12, 24, 36]) if t == \"물품\" else rng.choice([0, 6, 12]) if t == \"용역\" else rng.choice([24, 36, 60]) for t, s in zip(ctype, sl)])\n", + " insp = np.where(rng.random(n) < .3 * sl, rng.choice([21, 30], n), rng.choice([5, 7, 10, 14], n))\n", + " sub = np.where(rng.random(n) < .15 * sl, rng.choice([60, 70, 80], n), rng.choice([0, 10, 20, 30, 40, 50], n))\n", + " padj = rng.random(n) < .75 - .4 * sl\n", + " term = rng.random(n) > .015 * (1 + 6 * sl)\n", + " sec = rng.random(n) > .015 * (1 + 6 * sl)\n", + " ip = rng.random(n) > .03 * (1 + 4 * sl)\n", + " clauses, cls_true = [], []\n", + " for i in range(n):\n", + " pt, pd_ = .01 + .08 * sl[i], .02 + .08 * sl[i]\n", + " cl = [rng.choice(CLASSES, p=[1 - pt - pd_, pt, pd_]) for _ in range(6)]\n", + " cls_true.append(cl); clauses.append([make_clause(rng, c) for c in cl])\n", + " df = pd.DataFrame({\"contract_id\": [f\"C-{i + 1:04d}\" for i in range(n)], \"ctype\": ctype, \"method\": method, \"amount_m\": amount,\n", + " \"period_days\": period, \"delivery_days\": delivery, \"penalty_permil\": pen, \"advance_pct\": adv, \"perf_bond_pct\": bond,\n", + " \"warranty_months\": war, \"inspection_days\": insp, \"subcontract_pct\": sub, \"price_adjust\": padj,\n", + " \"termination\": term, \"security\": sec, \"ip_clause\": ip})\n", + " hard = ((pen < 1.0) | ((adv > 40) & (bond < 10)) | ((amount >= 300) & (method == \"수의계약\")) | ((ctype == \"물품\") & (war < 12))\n", + " | ~term | ~sec | (sub > 50) | (delivery > period))\n", + " toxic = np.array([c.count(\"독소\") for c in cls_true])\n", + " delay = np.array([c.count(\"납기위험\") for c in cls_true])\n", + " split = (method == \"수의계약\") & (amount >= 250) & (amount < 300) & (rng.random(n) < .6) # 계약 쪼개기 의심(임계값 바로 아래)\n", + " df[\"clauses\"], df[\"clause_cls\"] = clauses, cls_true\n", + " df[\"n_toxic\"], df[\"n_delay\"] = toxic, delay\n", + " viol = hard | (toxic > 0) | split | (delay >= 2)\n", + " waived = hard & (toxic == 0) & (rng.random(n) < .12) # 계약관이 예외 사유를 인정해 위반으로 보지 않은 건(자료의 현실적 잡음)\n", + " hidden = ~viol & (rng.random(n) < .03 + .08 * sl) # 속성·문구에 드러나지 않는 위반(별첨 합의 등)\n", + " df[\"violation\"] = (viol & ~waived) | hidden\n", + " df[\"vtype\"] = np.where(hidden, \"기타\", np.where(hard, \"속성위반\", np.where(toxic > 0, \"독소조항\", np.where(split | (delay >= 2), \"기타\", \"-\"))))\n", + " return df\n", + "\n", + "# ---- 계약 규정 준수 판별 룰 설정(YAML 형식 문자열: 규정이 바뀌면 코드가 아니라 이 설정만 고친다) ----\n", + "RULES_YAML = \"\"\"\n", + "- id: R01\n", + " name: 지체상금률 1.0/1000 미만\n", + " field: penalty_permil\n", + " op: \"<\"\n", + " value: 1.0\n", + " weight: 3\n", + "- id: R02\n", + " name: 선금 비율 40% 초과\n", + " field: advance_pct\n", + " op: \">\"\n", + " value: 40\n", + " weight: 2\n", + "- id: R03\n", + " name: 선금 40% 초과인데 이행보증 10% 미만\n", + " field: adv_no_bond\n", + " op: \"==\"\n", + " value: true\n", + " weight: 3\n", + "- id: R04\n", + " name: 300백만원 이상 수의계약\n", + " field: sole_amount\n", + " op: \">=\"\n", + " value: 300\n", + " weight: 3\n", + "- id: R05\n", + " name: 500백만원 이상 이행보증 10% 미만\n", + " field: bond_gap\n", + " op: \"==\"\n", + " value: true\n", + " weight: 2\n", + "- id: R06\n", + " name: 물품 하자보증 12개월 미만\n", + " field: warranty_gap\n", + " op: \"==\"\n", + " value: true\n", + " weight: 2\n", + "- id: R07\n", + " name: 해지 조항 없음\n", + " field: termination\n", + " op: \"==\"\n", + " value: false\n", + " weight: 2\n", + "- id: R08\n", + " name: 보안 조항 없음\n", + " field: security\n", + " op: \"==\"\n", + " value: false\n", + " weight: 3\n", + "- id: R09\n", + " name: 지식재산 조항 없음(물품·용역)\n", + " field: ip_missing\n", + " op: \"==\"\n", + " value: true\n", + " weight: 1\n", + "- id: R10\n", + " name: 하도급 비율 50% 초과\n", + " field: subcontract_pct\n", + " op: \">\"\n", + " value: 50\n", + " weight: 3\n", + "- id: R11\n", + " name: 검사 기간 14일 초과\n", + " field: inspection_days\n", + " op: \">\"\n", + " value: 14\n", + " weight: 1\n", + "- id: R12\n", + " name: 납품 기한이 계약 기간 초과\n", + " field: logic_err\n", + " op: \"==\"\n", + " value: true\n", + " weight: 3\n", + "- id: R13\n", + " name: 180일 이상 계약에 물가변동 조항 없음\n", + " field: no_padj_long\n", + " op: \"==\"\n", + " value: true\n", + " weight: 1\n", + "- id: R14\n", + " name: 1000백만원 이상 비경쟁 계약\n", + " field: noncomp_big\n", + " op: \"==\"\n", + " value: true\n", + " weight: 2\n", + "- id: R15\n", + " name: 계약 기간 365일 초과\n", + " field: period_days\n", + " op: \">\"\n", + " value: 365\n", + " weight: 1\n", + "- id: R16\n", + " name: 불공정·납기 위험 키워드 포함\n", + " field: kw_hits\n", + " op: \">=\"\n", + " value: 1\n", + " weight: 3\n", + "\"\"\"\n", + "GRADE_CUT = {\"고위험\": 6, \"주의\": 3} # 가중치 합 기준 1차 판별 등급(필요 시 설정만 바꾼다)\n", + "# [교육용가상자료] 불공정 조항·납기 지체 위험 문구 사전(실제 규정 조문이 아님)\n", + "KEYWORDS = {\"독소\": [\"면제하며\", \"일방적으로\", \"포기\", \"지지 않는다\", \"즉시 소멸\", \"제한 없이\", \"신고 없이\", \"전액 부담\", \"별도 협의 없이\", \"검사 전에\", \"검사에 합격한 것으로\", \"사용할 수 없다\", \"증빙은 요구하지 않는다\", \"지정하는 기관\"],\n", + " \"납기위험\": [\"자동 연장\", \"기한 없이\", \"정하지 않는다\", \"노력하며\", \"연장할 수 있다\", \"통보한다\", \"책임을 묻지 않는다\", \"의무가 없다\"]}\n", + "\n", + "\n", + "def parse_rules(text):\n", + " \"\"\"간이 YAML 파서(목록 + key: value). 숫자·true/false·따옴표 문자열을 해석한다.\"\"\"\n", + " # [TODO : 학생 실습 7] 계약 승인 전 법무/계약관 수동 검토 및 조항 수정 권고 HITL 절차 설계 관련 핵심 로직을 구현하세요.\n", + " # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n", + " # <<< 여기에 코드를 작성하세요 >>>\n", + " pass # 작성 후 pass를 제거하세요.\n", + "OPS = {\"==\": lambda a, b: a == b, \"!=\": lambda a, b: a != b, \">\": lambda a, b: a > b, \">=\": lambda a, b: a >= b,\n", + " \"<\": lambda a, b: a < b, \"<=\": lambda a, b: a <= b}\n", + "\n", + "\n", + "def kw_count(clauses):\n", + " \"\"\"조항 문장 목록에서 독소·납기 위험 키워드가 들어 있는 조항 수.\"\"\"\n", + " # [TODO : 학생 실습 7] 계약 승인 전 법무/계약관 수동 검토 및 조항 수정 권고 HITL 절차 설계 관련 핵심 로직을 구현하세요.\n", + " # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n", + " # <<< 여기에 코드를 작성하세요 >>>\n", + " pass # 작성 후 pass를 제거하세요.\n", + "def derive(df):\n", + " \"\"\"룰에 쓰는 파생 필드(선금+보증 부족, 수의계약 고액, 논리 오류, 키워드 적중 등)를 만든다.\"\"\"\n", + " d = df.copy()\n", + " d[\"adv_no_bond\"] = (d[\"advance_pct\"] > 40) & (d[\"perf_bond_pct\"] < 10)\n", + " d[\"sole_amount\"] = np.where(d[\"method\"] == \"수의계약\", d[\"amount_m\"], 0.0) # 수의계약일 때만 금액(한도는 YAML에서 조정)\n", + " d[\"bond_gap\"] = (d[\"amount_m\"] >= 500) & (d[\"perf_bond_pct\"] < 10)\n", + " d[\"warranty_gap\"] = (d[\"ctype\"] == \"물품\") & (d[\"warranty_months\"] < 12)\n", + " d[\"ip_missing\"] = (~d[\"ip_clause\"]) & (d[\"ctype\"] != \"공사\")\n", + " d[\"logic_err\"] = d[\"delivery_days\"] > d[\"period_days\"]\n", + " d[\"no_padj_long\"] = (d[\"period_days\"] >= 180) & (~d[\"price_adjust\"])\n", + " d[\"noncomp_big\"] = d[\"method\"].isin([\"지명경쟁\", \"수의계약\"]) & (d[\"amount_m\"] >= 1000)\n", + " d[\"kw_hits\"] = [kw_count(c) for c in d[\"clauses\"]] if \"clauses\" in d else 0\n", + " return d\n", + "\n", + "\n", + "def apply_rules(rec, rules):\n", + " \"\"\"레코드 1건(dict)에 룰을 적용해 (점수, 등급, 적중 룰 id 목록)을 돌려준다. 값이 없으면 해당 룰은 건너뛴다.\"\"\"\n", + " hits = []\n", + " for r in rules:\n", + " v = rec.get(r[\"field\"])\n", + " if v is None or (isinstance(v, float) and np.isnan(v)):\n", + " continue\n", + " if OPS[r[\"op\"]](v, r[\"value\"]):\n", + " hits.append(r)\n", + " score = sum(r[\"weight\"] for r in hits)\n", + " grade = \"고위험\" if score >= GRADE_CUT[\"고위험\"] else (\"주의\" if score >= GRADE_CUT[\"주의\"] else \"관심\")\n", + " return score, grade, [r[\"id\"] for r in hits]\n", + "\n", + "SIMPLE_KW = {\"독소\": [\"면제\", \"일방\", \"포기\", \"전액\", \"누설\", \"하도급\", \"선지급\"], \"납기위험\": [\"연장\", \"자동\", \"노력\", \"통보\", \"기한\"]}\n", + "\n", + "# ---- 조항 문장 분류(정상 / 독소 / 납기위험): 글자 n-gram TF-IDF + 로지스틱 회귀 ----\n", + "def clause_set(seed, n, hold=False, mix=(.55, .25, .20)):\n", + " \"\"\"조항 문장 n개와 정답 분류를 만든다. hold=True면 학습에 쓰지 않은 '처음 보는 표현'으로 만든다.\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " y = rng.choice(CLASSES, n, p=list(mix))\n", + " return [make_clause(rng, c, hold) for c in y], list(y)\n", + "\n", + "\n", + "def fit_clause_model(seed=100, n=1200):\n", + " \"\"\"학습용 조항 문장으로 분류 모델을 학습한다.\"\"\"\n", + " X, y = clause_set(seed, n)\n", + " return make_pipeline(TfidfVectorizer(analyzer=\"char_wb\", ngram_range=(2, 4), sublinear_tf=True), LogisticRegression(C=20, max_iter=2000)).fit(X, y)\n", + "\n", + "\n", + "def keyword_pred(s):\n", + " \"\"\"비교용 기준선: 단순 키워드(부정 표현·문맥을 보지 않음) 적중으로만 분류.\"\"\"\n", + " if any(k in s for k in SIMPLE_KW[\"독소\"]):\n", + " return \"독소\"\n", + " return \"납기위험\" if any(k in s for k in SIMPLE_KW[\"납기위험\"]) else \"정상\"\n", + "\n", + "# ---- 역할과 권한(최소 권한 원칙): 시스템은 '제안'만 할 수 있고 확정 권한이 없다 ----\n", + "PERM = {\"시스템\": {\"propose\"}, \"계약담당\": {\"submit\", \"redraft\", \"view\"}, \"계약관\": {\"review_contract\", \"reject\", \"view\"}, \"법무\": {\"review_legal\", \"reject\", \"view\"},\n", + " \"승인권자\": {\"approve\", \"reject\", \"view\"}, \"감사관\": {\"view\", \"audit\"},\n", + " \"부서장\": {\"submit\", \"approve\", \"reject\", \"view\"}} # 소규모 부서는 상신과 결재를 겸할 수 있으나 같은 건은 분리\n", + "NEXT = {\"제안\": (\"submit\", \"상신\"), \"상신\": (\"review_contract\", \"계약관검토\"), \"계약관검토\": (\"review_legal\", \"법무검토\"), \"법무검토\": (\"approve\", \"승인\")}\n", + "USERS = {\"sys\": \"시스템\", \"kim\": \"계약담당\", \"lee\": \"계약관\", \"park\": \"법무\", \"choi\": \"승인권자\", \"jung\": \"감사관\", \"oh\": \"부서장\"}\n", + "MAX_REDRAFT = 2\n", + "\n", + "\n", + "class Chain:\n", + " \"\"\"해시 체인 감사 로그: 각 기록이 직전 기록의 해시를 포함해 중간 수정이 드러난다.\"\"\"\n", + "\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", + "class Workflow:\n", + " def __init__(self):\n", + " self.audit, self.cases, self.blocked = Chain(), {}, []\n", + "\n", + " def _deny(self, who, case, action, why):\n", + " self.audit.add(event=\"DENIED\", who=who, case=case, action=action, reason=why)\n", + " self.blocked.append((who, case, action, why)); return False, why\n", + "\n", + " def act(self, who, case, action, reason=\"\", evidence=\"\"):\n", + " role = USERS.get(who)\n", + " if role is None:\n", + " return self._deny(who, case, action, \"미등록 사용자\")\n", + " if action == \"delete_log\":\n", + " return self._deny(who, case, action, \"감사 로그 삭제·수정 권한은 누구에게도 없음\")\n", + " if action not in PERM[role]:\n", + " return self._deny(who, case, action, f\"권한 없음({role} 역할에 {action} 권한 없음)\")\n", + " if action == \"propose\":\n", + " self.cases[case] = {\"state\": \"제안\", \"submitter\": None, \"version\": 1}\n", + " self.audit.add(event=\"PROPOSE\", who=who, case=case, reason=reason); return True, \"제안 등록\"\n", + " c = self.cases.get(case)\n", + " if c is None:\n", + " return self._deny(who, case, action, \"존재하지 않는 건\")\n", + " if c[\"state\"] in (\"승인\", \"종결(반려)\"):\n", + " return self._deny(who, case, action, f\"이미 종결된 건({c['state']})\")\n", + " if not reason.strip() or not evidence.strip():\n", + " return self._deny(who, case, action, \"사유·근거 입력 필수(반려 사유 포함)\")\n", + " if action == \"reject\":\n", + " if c[\"state\"] in (\"제안\", \"반려\"):\n", + " return self._deny(who, case, action, f\"반려할 수 없는 단계({c['state']})\")\n", + " c[\"state\"] = \"종결(반려)\" if c[\"version\"] > MAX_REDRAFT else \"반려\"\n", + " self.audit.add(event=\"REJECTED\", who=who, role=role, case=case, version=c[\"version\"], reason=reason, evidence=evidence); return True, c[\"state\"]\n", + " if action == \"redraft\":\n", + " if c[\"state\"] != \"반려\":\n", + " return self._deny(who, case, action, f\"재기안은 반려된 건만 가능(현재 {c['state']})\")\n", + " if who != c[\"submitter\"]:\n", + " return self._deny(who, case, action, \"재기안은 최초 상신자만 가능\")\n", + " c[\"state\"], c[\"version\"] = \"상신\", c[\"version\"] + 1\n", + " self.audit.add(event=\"REDRAFT\", who=who, role=role, case=case, version=c[\"version\"], reason=reason, evidence=evidence); return True, \"재기안 상신\"\n", + " if c[\"state\"] == \"반려\":\n", + " return self._deny(who, case, action, \"반려된 건은 재기안 후에만 진행 가능\")\n", + " need, nxt = NEXT[c[\"state\"]]\n", + " if action != need:\n", + " return self._deny(who, case, action, f\"단계 위반(현재 {c['state']}, 필요 행위 {need})\")\n", + " if action == \"submit\":\n", + " c[\"submitter\"] = who\n", + " if action == \"approve\" and who == c[\"submitter\"]:\n", + " return self._deny(who, case, action, \"상신자와 승인자 분리 원칙 위반\")\n", + " c[\"state\"] = nxt\n", + " self.audit.add(event=action.upper(), who=who, role=role, case=case, version=c[\"version\"], to=nxt, reason=reason, evidence=evidence); return True, nxt\n", + "\n", + "\n", + "# ---- 상신 대상: 룰 점수가 높은 계약 12건과 모델이 찾은 위험 조항 ----\n", + "cm = fit_clause_model()\n", + "rules = parse_rules(RULES_YAML)\n", + "con = make_contracts(100, seed=7)\n", + "d = derive(con)\n", + "sc = [apply_rules(r, rules) for r in d.to_dict(\"records\")]\n", + "d[\"score\"], d[\"hits\"] = [x[0] for x in sc], [x[2] for x in sc]\n", + "picked = d.sort_values(\"score\", ascending=False).head(12)\n", + "rng = np.random.default_rng(1)\n", + "LEGAL = {\"R08\", \"R07\", \"R12\", \"R10\"} # 법무 검토에서 보는 항목(보안·해지·논리·하도급)\n", + "\n", + "wf = Workflow()\n", + "rows = []\n", + "for k, (_, p) in enumerate(picked.iterrows(), 1):\n", + " case = f\"C{k:02d}\"\n", + " flagged = [i + 1 for i, c in enumerate(cm.predict(list(p[\"clauses\"]))) if c != \"정상\"]\n", + " wf.act(\"sys\", case, \"propose\", reason=f\"{p['contract_id']} 룰 점수 {p['score']}, 위험 조항 {len(flagged)}개 -> 수정 권고 제안\")\n", + " wf.act(\"kim\", case, \"submit\", \"위험 조항 수정 권고 검토 요청\", f\"룰 적중 {','.join(p['hits'][:4])}, 위험 조항 {flagged or '없음'}\")\n", + " c_issue, l_issue = bool(flagged), sorted(LEGAL & set(p[\"hits\"])) # 계약관이 볼 문제, 법무가 볼 문제\n", + " note, big, justified = \"승인권자 결정\", p[\"amount_m\"] >= 1000 and p[\"method\"] != \"일반경쟁\", False\n", + " for _ in range(8):\n", + " state = wf.cases[case][\"state\"]\n", + " if state in (\"승인\", \"종결(반려)\"):\n", + " break\n", + " if state == \"상신\":\n", + " if c_issue:\n", + " wf.act(\"lee\", case, \"reject\", f\"위험 조항 {flagged} 수정 필요\", \"조항 모델 위험 분류와 계약관 판단 일치\")\n", + " else:\n", + " wf.act(\"lee\", case, \"review_contract\", \"조항 검토 완료\", f\"위험 조항 {'정리됨' if flagged else '없음'}\")\n", + " elif state == \"계약관검토\":\n", + " if rng.random() < 0.15:\n", + " note = \"자동 확정 없음: 법무 응답 대기\"; break # 무응답은 보류로 둔다\n", + " if l_issue:\n", + " wf.act(\"park\", case, \"reject\", f\"필수 조항 보완 필요({','.join(l_issue)})\", \"해지·보안·하도급·납품 기한 조건 점검\")\n", + " else:\n", + " wf.act(\"park\", case, \"review_legal\", \"법무 검토 완료\", \"필수 조항 점검 이상 없음\")\n", + " elif state == \"법무검토\":\n", + " if big and not justified:\n", + " wf.act(\"choi\", case, \"reject\", \"고액 비경쟁 계약은 사유 소명 후 재상신\", f\"금액 {p['amount_m']:.0f}백만원, {p['method']}\")\n", + " else:\n", + " wf.act(\"choi\", case, \"approve\", \"승인: 수정본으로 계약 의뢰 진행\", f\"룰 점수 {p['score']}, 위험 조항·필수 조항 정리 완료\")\n", + " elif state == \"반려\":\n", + " wf.act(\"kim\", case, \"redraft\", \"반려 사유 반영해 수정\", f\"수정 대상 {flagged if c_issue else l_issue or ['소명 자료']}, 재기안 {wf.cases[case]['version']}차\")\n", + " if rng.random() < 0.65: # 재기안 후 문제가 정리될 확률(모의)\n", + " c_issue, l_issue = False, []\n", + " justified = True # 고액 비경쟁 건은 재기안 때 사유 소명서를 첨부(모의)\n", + " final = wf.cases[case][\"state\"]\n", + " if final not in (\"승인\", \"종결(반려)\") and note.startswith(\"자동\"):\n", + " final = \"보류(무응답)\"\n", + " elif final == \"종결(반려)\":\n", + " note = \"재기안 한도(2회) 초과\"\n", + " rows.append((case, p[\"contract_id\"], final, wf.cases[case][\"version\"], note))\n", + "print(\"[결재 결과] 12건 (모의 결재자 기준, 실제 결재는 사람이 한다)\")\n", + "print(pd.DataFrame(rows, columns=[\"건\", \"계약\", \"최종 상태\", \"버전\", \"비고\"]).to_string(index=False))\n", + "st = pd.Series([r[2] for r in rows]).value_counts().to_dict()\n", + "n_redraft = sum(e[\"event\"] == \"REDRAFT\" for e in wf.audit.log)\n", + "print(\"상태 집계:\", st, f\"/ 반려 후 재기안 {n_redraft}회, 반려 이력 {sum(e['event'] == 'REJECTED' for e in wf.audit.log)}건(모두 사유·근거 기록)\")\n", + "\n", + "# ---- 권한 우회·절차 위반 시도 ----\n", + "wf.act(\"sys\", \"T01\", \"propose\", reason=\"시험 건\")\n", + "wf.act(\"kim\", \"T01\", \"submit\", \"시험 상신\", \"근거\")\n", + "attempts = [\n", + " (\"시스템 자동 승인\", lambda: wf.act(\"sys\", \"T01\", \"approve\", \"자동\", \"자동\")),\n", + " (\"계약담당이 계약관 검토 대행\", lambda: wf.act(\"kim\", \"T01\", \"review_contract\", \"대행\", \"근거\")),\n", + " (\"검토 단계 건너뛰고 승인\", lambda: wf.act(\"choi\", \"T01\", \"approve\", \"바로 승인\", \"근거\")),\n", + " (\"미등록 사용자 접근\", lambda: wf.act(\"guest\", \"T01\", \"review_contract\", \"열람\", \"근거\")),\n", + " (\"사유 없는 검토\", lambda: wf.act(\"lee\", \"T01\", \"review_contract\", \"\", \"\")),\n", + " (\"사유 없는 반려\", lambda: wf.act(\"lee\", \"T01\", \"reject\", \"\", \"근거\")),\n", + " (\"감사 로그 삭제\", lambda: wf.act(\"kim\", \"T01\", \"delete_log\", \"정리\", \"근거\")),\n", + " (\"감사관이 승인 시도\", lambda: wf.act(\"jung\", \"T01\", \"approve\", \"승인\", \"근거\")),\n", + " (\"종결된 건 재승인\", lambda: wf.act(\"choi\", next(c for c, v in wf.cases.items() if v[\"state\"] == \"승인\"), \"approve\", \"재승인\", \"근거\")),\n", + " (\"존재하지 않는 건 승인\", lambda: wf.act(\"choi\", \"X99\", \"approve\", \"승인\", \"근거\")),\n", + "]\n", + "wf.act(\"sys\", \"T02\", \"propose\", reason=\"시험\"); wf.act(\"oh\", \"T02\", \"submit\", \"상신\", \"근거\")\n", + "wf.act(\"lee\", \"T02\", \"review_contract\", \"조항 확인\", \"위험 조항 없음\"); wf.act(\"park\", \"T02\", \"review_legal\", \"법무 확인\", \"이상 없음\")\n", + "attempts.append((\"상신자가 본인 건 승인(분리 원칙)\", lambda: wf.act(\"oh\", \"T02\", \"approve\", \"본인 승인\", \"근거\")))\n", + "wf.act(\"sys\", \"T03\", \"propose\", reason=\"시험\"); wf.act(\"kim\", \"T03\", \"submit\", \"상신\", \"근거\"); wf.act(\"lee\", \"T03\", \"reject\", \"수정 필요\", \"근거\")\n", + "attempts.append((\"반려 건을 재기안 없이 검토 진행\", lambda: wf.act(\"lee\", \"T03\", \"review_contract\", \"그대로 진행\", \"근거\")))\n", + "attempts.append((\"타인이 대신 재기안\", lambda: wf.act(\"oh\", \"T03\", \"redraft\", \"대신 수정\", \"근거\")))\n", + "print(\"\\n[권한 우회·절차 위반 시도]\")\n", + "blocked = 0\n", + "for name, fn in attempts:\n", + " ok, why = fn()\n", + " blocked += (not ok)\n", + " print(f\" {name:24s}-> {'차단' if not ok else '통과(결함)'}: {why}\")\n", + "print(f\"우회·위반 시도 차단 {blocked}/{len(attempts)} ({blocked / len(attempts):.0%}) (목표 100%: {'달성' if blocked == len(attempts) else '미달'})\")\n", + "\n", + "# ---- RBAC 매트릭스(역할별 접근 제어 시험) ----\n", + "acts = [\"propose\", \"submit\", \"review_contract\", \"review_legal\", \"approve\", \"reject\", \"redraft\", \"audit\"]\n", + "mx = pd.DataFrame([[(\"O\" if a in PERM[r] else \"-\") for a in acts] for r in PERM], index=list(PERM), columns=[\"제안\", \"상신\", \"계약관검토\", \"법무검토\", \"승인\", \"반려\", \"재기안\", \"감사조회\"])\n", + "print(\"\\n[역할별 권한 매트릭스(O 허용 / - 불가)]\")\n", + "print(mx.to_string())\n", + "print(\"승인(approve) 권한이 있는 역할:\", [r for r in PERM if \"approve\" in PERM[r]], \"-> 시스템·계약담당·계약관·법무에는 없음\")\n", + "\n", + "# ---- 이력 무결성과 이력 조회 ----\n", + "bad = wf.audit.verify()\n", + "print(f\"\\n감사 로그 {len(wf.audit.log)}건, 해시 체인 검증: {'정상' if bad is None else f'seq {bad}에서 불일치'} -> 이력 무결성 {'100%' if bad is None else '훼손'}(목표 100%: {'달성' if bad is None else '미달'})\")\n", + "idx = 10\n", + "saved = json.dumps(wf.audit.log[idx], ensure_ascii=False)\n", + "wf.audit.log[idx][\"reason\"] = \"사후 수정\"\n", + "v = wf.audit.verify()\n", + "print(f\"{idx}번째 기록 임의 수정 후 검증: {'위변조 탐지(seq ' + str(v) + ')' if v is not None else '탐지 실패'}\")\n", + "wf.audit.log[idx] = json.loads(saved)\n", + "print(\"\\n[이력 조회 화면] C02 건\")\n", + "for e in wf.audit.log:\n", + " if e.get(\"case\") == \"C02\":\n", + " print(f\" #{e['seq']:03d} {e['event']:15s} {e.get('role', e['who']):6s} v{e.get('version', 1)} | {e.get('reason', '')[:34]}\")\n", + "print(\"※ 계약 조항의 최종 판단, 법무 검토, 계약 승인은 사람(계약관·법무·승인권자)이 하며, 시스템은 위험 표시와 수정 권고만 제공한다. 보안 규정과 결재선은 소속 기관 규정을 따른다.\")\n", + "\n", + "plt.figure(figsize=(6, 3.5)); EN = {\"승인\": \"approved\", \"반려\": \"rejected\", \"종결(반려)\": \"closed-rejected\", \"보류(무응답)\": \"on hold\"}\n", + "plt.bar([EN.get(k, k) for k in st], list(st.values()), color=\"tab:blue\"); plt.title(\"HITL workflow outcomes (12 cases)\"); plt.ylabel(\"cases\"); plt.tight_layout(); plt.show()\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 07 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + "[결재 결과] 12건 (모의 결재자 기준, 실제 결재는 사람이 한다)\n", + " 건 계약 최종 상태 버전 비고\n", + "C01 C-0072 보류(무응답) 2 자동 확정 없음: 법무 응답 대기\n", + "C02 C-0099 승인 3 승인권자 결정\n", + "C03 C-0024 승인 3 승인권자 결정\n", + "C04 C-0067 승인 2 승인권자 결정\n", + "C05 C-0085 승인 2 승인권자 결정\n", + "C06 C-0022 승인 1 승인권자 결정\n", + "C07 C-0077 승인 2 승인권자 결정\n", + "C08 C-0040 승인 2 승인권자 결정\n", + "C09 C-0070 승인 2 승인권자 결정\n", + "C10 C-0065 승인 1 승인권자 결정\n", + "C11 C-0028 승인 2 승인권자 결정\n", + "C12 C-0007 종결(반려) 3 재기안 한도(2회) 초과\n", + "상태 집계: {'승인': 10, '보류(무응답)': 1, '종결(반려)': 1} / 반려 후 재기안 13회, 반려 이력 14건(모두 사유·근거 기록)\n", + "\n", + "[권한 우회·절차 위반 시도]\n", + " 시스템 자동 승인 -> 차단: 권한 없음(시스템 역할에 approve 권한 없음)\n", + " 계약담당이 계약관 검토 대행 -> 차단: 권한 없음(계약담당 역할에 review_contract 권한 없음)\n", + " 검토 단계 건너뛰고 승인 -> 차단: 단계 위반(현재 상신, 필요 행위 review_contract)\n", + " 미등록 사용자 접근 -> 차단: 미등록 사용자\n", + " 사유 없는 검토 -> 차단: 사유·근거 입력 필수(반려 사유 포함)\n", + " 사유 없는 반려 -> 차단: 사유·근거 입력 필수(반려 사유 포함)\n", + " 감사 로그 삭제 -> 차단: 감사 로그 삭제·수정 권한은 누구에게도 없음\n", + " 감사관이 승인 시도 -> 차단: 권한 없음(감사관 역할에 approve 권한 없음)\n", + " 종결된 건 재승인 -> 차단: 이미 종결된 건(승인)\n", + " 존재하지 않는 건 승인 -> 차단: 존재하지 않는 건\n", + " 상신자가 본인 건 승인(분리 원칙) -> 차단: 상신자와 승인자 분리 원칙 위반\n", + " 반려 건을 재기안 없이 검토 진행 -> 차단: 반려된 건은 재기안 후에만 진행 가능\n", + " 타인이 대신 재기안 -> 차단: 권한 없음(부서장 역할에 redraft 권한 없음)\n", + "우회·위반 시도 차단 13/13 (100%) (목표 100%: 달성)\n", + "\n", + "[역할별 권한 매트릭스(O 허용 / - 불가)]\n", + " 제안 상신 계약관검토 법무검토 승인 반려 재기안 감사조회\n", + "시스템 O - - - - - - -\n", + "계약담당 - O - - - - O -\n", + "계약관 - - O - - O - -\n", + "법무 - - - O - O - -\n", + "승인권자 - - - - O O - -\n", + "감사관 - - - - - - - O\n", + "... (이하 생략)\n", + "```\n", + "
\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 08. 1,000건 대규모 조달 계약서 일괄 스크리닝 부하 테스트 및 처리 속도 검증\n", + "**소요시간**: 약 3.5시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "대규모 조달 계약서 일괄 스크리닝 부하 테스트를 수행한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **월 1,000건 규모의 계약서를 일괄 스크리닝하는 배치 파이프라인의 부하 시험과 처리 속도 검증을 수행한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **월 1,000건씩 12개월분 가상 계약 배치를 간이 스케줄러로 자동 실행하면서 월마다 손상 레코드 5건과 2% 일시 오류 주입하여 처리 지연 및 복원력을 시험하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `src/pipeline/prc01_batch_runner.py` 형태의 결과물을 완성한다.\n", + "\n", + "### 🔍 평가 및 검증 기준 (PASS 조건)\n", + "#### 미션 판정\n", + "\n", + "**평가 구분:** 필수\n", + "\n", + "**핵심 평가영역:** 수작업 대비 업무 소요 시간 80% 이상 절감 효과\n", + "\n", + "#### 미션 PASS 조건\n", + "\n", + "- 정량 기준: 전체 배치 파이프라인 실행 시간 5분 이내, 자동화 성공률 99% 이상\n", + "\n", + "\n", + "- 진행 기준: 스케줄러 연동 테스트 및 자동 실행 확인\n", + "\n", + "\n", + "- 검증 방법: 실제 월간 업무 데이터 1년치 일괄 배치 시뮬레이션\n", + "\n", + "\n", + "#### 채점자 판정 메모\n", + "\n", + "> 단순 코드 실행 여부뿐만 아니라 출력된 수치와 근거 자료가 군 실무 요구사항에 부합하는지 면밀히 확인한다.\n", + "\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "\"\"\"1,000건 규모의 월간 계약서 일괄 스크리닝 배치(입력 검증, 룰 16종, 조항 분류, 위험 점수)를 12개월분 스케줄러 시뮬레이션으로 실행하고, 손상 레코드·일시 오류를 주입한 상태에서 실행 시간(5분 이내)·자동화 성공률(99% 이상)·수작업 대비 시간 절감(80% 이상)·진행률 표시·완료 알림·시간 초과 시 기능 축소를 검증한다.\"\"\"\n", + "import time\n", + "import numpy as np\n", + "import pandas as pd\n", + "import matplotlib.pyplot as plt\n", + "from sklearn.feature_extraction.text import TfidfVectorizer\n", + "from sklearn.linear_model import LogisticRegression\n", + "from sklearn.pipeline import make_pipeline\n", + "\n", + "# ---------------------------------------------------------------------------\n", + "# [교육용가상자료] 가상 조달계획안·계약서 생성기 (실제 계약·업체·법령 조문 없음)\n", + "# 계약 속성 16개 + 조항 문장 6개를 만들고, 속성 위반·독소 조항·납기 위험 문구가 섞인 '규정 위반 여부'를 함께 만든다.\n", + "# ---------------------------------------------------------------------------\n", + "TYPES = [\"물품\", \"용역\", \"공사\"]\n", + "METHODS = [\"일반경쟁\", \"제한경쟁\", \"지명경쟁\", \"수의계약\"]\n", + "CLASSES = [\"정상\", \"독소\", \"납기위험\"]\n", + "OBJ = [\"납품 물품\", \"용역 결과물\", \"시공 목적물\", \"정비 성과물\"]\n", + "\n", + "N_TPL = [\n", + " \"계약상대자가 납기를 지체한 경우 지체일수 1일마다 계약금액의 {p}/1000에 해당하는 지체상금을 부과한다.\",\n", + " \"지체상금은 불가항력 사유가 확인된 경우에 한하여 발주기관의 승인을 받아 면제할 수 있다.\",\n", + " \"납기 연장은 발주기관이 불가항력 사유를 확인하고 서면으로 승인한 경우에만 인정한다.\",\n", + " \"발주기관은 납품된 {o}을 {d}일 이내에 검사하고 합격한 후에 대금을 지급한다.\",\n", + " \"계약상대자는 하도급을 하려는 경우 사전에 발주기관의 서면 승인을 받아야 한다.\",\n", + " \"어느 한쪽이 계약을 해지하려면 사유를 명시해 {d}일 전에 서면으로 통지해야 한다.\",\n", + " \"{o}에 하자가 발생하면 계약상대자는 보증 기간 안에 무상으로 보수하거나 교체한다.\",\n", + " \"계약 이행 중 알게 된 비공개 사항은 제3자에게 누설하지 않으며 위반 시 책임을 진다.\",\n", + " \"{o}에 대한 지식재산권의 귀속과 사용 범위는 별도 합의서에 따라 정한다.\",\n", + " \"대금은 검사 합격 후 청구서를 접수한 날부터 {d}일 이내에 지급한다.\",\n", + " \"계약금액의 조정은 물가변동 기준에 따라 양 당사자가 서면으로 합의한 경우에만 한다.\",\n", + " \"계약상대자는 이행보증서를 계약 체결 시 제출하며 보증금은 계약 이행 완료 후 반환한다.\",\n", + " \"납품 일정은 계약서 별표의 일자를 따르며 변경 시 서면 합의가 필요하다.\",\n", + " \"지체상금은 면제 사유가 없는 한 부과하며 이의는 정해진 절차로 제기할 수 있다.\",\n", + " \"검사에 합격하지 못한 {o}은 계약상대자가 교체하며 그 비용은 계약상대자가 부담한다.\",\n", + " \"계약상대자의 귀책 사유로 이행이 불가능하면 발주기관은 서면으로 통지하고 계약을 해지할 수 있다.\",\n", + " \"지연이 예상되면 계약상대자는 사유와 만회 계획을 발주기관에 서면으로 보고한다.\",\n", + " \"보증 기간 중 발생한 결함은 계약상대자가 책임지고 보수한다.\",\n", + " \"분쟁은 먼저 협의로 해결하고 협의가 되지 않으면 관할 법원의 판단에 따른다.\",\n", + "]\n", + "T_TPL = [\n", + " \"계약상대자의 지체상금은 전액 면제하며 발주기관은 이에 대해 이의를 제기하지 못한다.\",\n", + " \"납품 즉시 검사에 합격한 것으로 보며 발주기관은 이후 검사를 요구하지 않는다.\",\n", + " \"계약상대자는 언제든지 일방적으로 계약을 해지할 수 있으며 그에 따른 손해는 발주기관이 부담한다.\",\n", + " \"{o}의 추가 비용은 사유를 불문하고 발주기관이 전액 부담하며 별도 협의 없이 청구할 수 있다.\",\n", + " \"{o}에 관한 지식재산권은 모두 계약상대자에게 귀속되고 발주기관은 사용할 수 없다.\",\n", + " \"하도급은 신고 없이 제한 없이 허용하며 발주기관은 하도급 내용을 확인하지 않는다.\",\n", + " \"대금은 검사 전에 전액 선지급하며 발주기관은 반환 청구권을 포기한다.\",\n", + " \"계약 이행 중 알게 된 사항의 누설로 인한 책임을 계약상대자는 지지 않는다.\",\n", + " \"하자 보수 책임은 인도 후 즉시 소멸하며 발주기관은 보수를 요구할 수 없다.\",\n", + " \"발주기관은 계약상대자가 청구하는 금액을 거절할 수 없으며 거절하면 위약금을 지급한다.\",\n", + " \"분쟁이 생기면 계약상대자가 정하는 중재 기관에서만 해결하고 발주기관은 따른다.\",\n", + " \"발주기관의 검사는 생략하고 계약상대자의 시험 결과서로 대신한다.\",\n", + " \"견적 금액은 사후 정산 없이 그대로 확정하고 증빙 자료는 받지 않는다.\",\n", + "]\n", + "D_TPL = [\n", + " \"납기는 자재 수급 사정에 따라 계약상대자가 연장할 수 있다.\",\n", + " \"납품 일정은 가능한 한 지키도록 노력하며 지연되어도 사유를 보고하지 않는다.\",\n", + " \"공급 지연이나 선행 업체 사정도 불가항력으로 보아 납기를 자동 연장한다.\",\n", + " \"착수일은 선행 업체의 납품이 끝난 뒤 별도로 정하며 완료 기한은 정하지 않는다.\",\n", + " \"납기일 이후 1회에 한해 기한 없이 자동 연장되는 것으로 한다.\",\n", + " \"납기는 계약상대자의 생산 일정이 확정되는 대로 통보한다.\",\n", + " \"납품 시점은 계약상대자 사정이 허락하는 때로 하고 날짜는 따로 정하지 않는다.\",\n", + " \"자재가 늦게 들어오면 완료 일정은 그만큼 자동으로 늦춰진다.\",\n", + " \"기한 내에 인도하지 못해도 계약상대자에게 책임을 묻지 않는다.\",\n", + " \"일정이 늦어져도 계약상대자는 새 계획서를 낼 필요가 없다.\",\n", + "]\n", + "# 학습에 쓰지 않는 '처음 보는 표현'(모델이 문장 틀을 외웠는지 확인하는 용도)\n", + "HOLD = {\n", + " \"정상\": [\"검사에 불합격한 경우 계약상대자는 지정 기한 안에 재납품하며 재검사 비용은 상대자가 부담한다.\",\n", + " \"계약상대자가 법령을 위반하여 계약을 이행하지 못하면 발주기관은 서면 통지 후 계약을 해지할 수 있다.\",\n", + " \"납기가 지연될 우려가 있으면 상대자는 즉시 사유와 만회 계획을 서면으로 보고해야 한다.\",\n", + " \"하자담보 책임 기간은 인수일부터 기산하며 기간 중 발생한 결함은 상대자가 책임진다.\"],\n", + " \"독소\": [\"발주기관은 계약상대자가 요구하는 대금을 이유 없이 거절하지 못하며 거절 시 위약금을 낸다.\",\n", + " \"계약 내용에 관한 분쟁은 계약상대자가 지정하는 기관에서만 다루며 발주기관은 이에 따른다.\",\n", + " \"발주기관의 검수 권한은 포기하고 계약상대자의 자체 시험 성적서로 갈음한다.\",\n", + " \"계약상대자가 제출한 견적은 사후 정산 없이 확정 단가로 하며 증빙은 요구하지 않는다.\"],\n", + " \"납기위험\": [\"납품 시기는 상대자의 형편이 허락하는 시점으로 하고 별도 날짜는 정하지 않는다.\",\n", + " \"원자재 확보가 늦어지면 완료 일자는 자동으로 뒤로 미뤄지는 것으로 본다.\",\n", + " \"인도 기한은 참고용이며 지키지 못해도 책임을 묻지 않는다.\",\n", + " \"일정 지연이 있어도 상대자는 변경 계획을 제출할 의무가 없다.\"],\n", + "}\n", + "SUFFIX = [\"\", \"\", \"\", \" 세부 사항은 시행 세칙에 따른다.\", \" 이 조항은 계약 전체에 적용한다.\", \" 필요한 경우 양 당사자가 협의한다.\", \" 관련 서식은 별지를 따른다.\"]\n", + "SWAP = [(\"발주기관\", \"계약담당관\"), (\"계약상대자\", \"업체\")]\n", + "\n", + "\n", + "def make_clause(rng, cls, hold=False, idx=None):\n", + " \"\"\"조항 한 문장 생성. 표현 일부는 동의어로 바꾸고 접미 문구를 무작위로 붙인다(idx를 주면 그 번호의 문장 틀을 사용).\"\"\"\n", + " if idx is not None:\n", + " tpl = {\"정상\": N_TPL, \"독소\": T_TPL, \"납기위험\": D_TPL}[cls]\n", + " s = tpl[idx].format(p=rng.choice([\"1.0\", \"1.5\", \"2.0\"]), d=int(rng.choice([7, 10, 14, 30])), o=rng.choice(OBJ))\n", + " elif hold:\n", + " s = HOLD[cls][rng.integers(len(HOLD[cls]))]\n", + " else:\n", + " tpl = {\"정상\": N_TPL, \"독소\": T_TPL, \"납기위험\": D_TPL}[cls]\n", + " s = tpl[rng.integers(len(tpl))].format(p=rng.choice([\"1.0\", \"1.5\", \"2.0\"]), d=int(rng.choice([7, 10, 14, 30])), o=rng.choice(OBJ))\n", + " if rng.random() < 0.3:\n", + " a, b = SWAP[rng.integers(2)]\n", + " s = s.replace(a, b)\n", + " return s + SUFFIX[rng.integers(len(SUFFIX))]\n", + "\n", + "\n", + "def make_contracts(n=200, seed=7):\n", + " \"\"\"가상 계약 n건(속성 16개 + 조항 6개 + 규정 위반 정답)을 만든다.\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " sl = rng.beta(1.2, 3.2, n) # 계약서 작성이 허술한 정도(잠재 변수)\n", + " ctype = rng.choice(TYPES, n, p=[.5, .3, .2])\n", + " amount = np.clip(np.exp(rng.normal(np.log(220), 1.1, n)), 5, 5000).round(0)\n", + " method = np.where(rng.random(n) < .06 + .3 * sl, \"수의계약\", rng.choice(METHODS[:3], n, p=[.5, .3, .2]))\n", + " period = np.array([rng.integers(60, 300) if t == \"물품\" else rng.integers(90, 400) if t == \"용역\" else rng.integers(180, 720) for t in ctype])\n", + " delivery = (period * rng.uniform(.5, 1.0, n)).astype(int)\n", + " bad_logic = rng.random(n) < .015 * (1 + 3 * sl)\n", + " delivery = np.where(bad_logic, period + rng.integers(10, 60, n), delivery)\n", + " pen = np.where(rng.random(n) < .22 * sl, rng.choice([.3, .5, .8], n), rng.choice([1.0, 1.5, 2.0, 2.5], n))\n", + " adv = np.where(rng.random(n) < .22 * sl, rng.choice([50, 60, 70], n), rng.choice([0, 10, 20, 30, 40], n))\n", + " bond = np.where(rng.random(n) < .25 * sl + .5 * (adv > 40), rng.choice([0, 5], n), rng.choice([10, 10, 15, 20], n))\n", + " war = np.array([rng.choice([3, 6]) if (rng.random() < .25 * s and t == \"물품\") else rng.choice([12, 24, 36]) if t == \"물품\" else rng.choice([0, 6, 12]) if t == \"용역\" else rng.choice([24, 36, 60]) for t, s in zip(ctype, sl)])\n", + " insp = np.where(rng.random(n) < .3 * sl, rng.choice([21, 30], n), rng.choice([5, 7, 10, 14], n))\n", + " sub = np.where(rng.random(n) < .15 * sl, rng.choice([60, 70, 80], n), rng.choice([0, 10, 20, 30, 40, 50], n))\n", + " padj = rng.random(n) < .75 - .4 * sl\n", + " term = rng.random(n) > .015 * (1 + 6 * sl)\n", + " sec = rng.random(n) > .015 * (1 + 6 * sl)\n", + " ip = rng.random(n) > .03 * (1 + 4 * sl)\n", + " clauses, cls_true = [], []\n", + " for i in range(n):\n", + " pt, pd_ = .01 + .08 * sl[i], .02 + .08 * sl[i]\n", + " cl = [rng.choice(CLASSES, p=[1 - pt - pd_, pt, pd_]) for _ in range(6)]\n", + " cls_true.append(cl); clauses.append([make_clause(rng, c) for c in cl])\n", + " df = pd.DataFrame({\"contract_id\": [f\"C-{i + 1:04d}\" for i in range(n)], \"ctype\": ctype, \"method\": method, \"amount_m\": amount,\n", + " \"period_days\": period, \"delivery_days\": delivery, \"penalty_permil\": pen, \"advance_pct\": adv, \"perf_bond_pct\": bond,\n", + " \"warranty_months\": war, \"inspection_days\": insp, \"subcontract_pct\": sub, \"price_adjust\": padj,\n", + " \"termination\": term, \"security\": sec, \"ip_clause\": ip})\n", + " hard = ((pen < 1.0) | ((adv > 40) & (bond < 10)) | ((amount >= 300) & (method == \"수의계약\")) | ((ctype == \"물품\") & (war < 12))\n", + " | ~term | ~sec | (sub > 50) | (delivery > period))\n", + " toxic = np.array([c.count(\"독소\") for c in cls_true])\n", + " delay = np.array([c.count(\"납기위험\") for c in cls_true])\n", + " split = (method == \"수의계약\") & (amount >= 250) & (amount < 300) & (rng.random(n) < .6) # 계약 쪼개기 의심(임계값 바로 아래)\n", + " df[\"clauses\"], df[\"clause_cls\"] = clauses, cls_true\n", + " df[\"n_toxic\"], df[\"n_delay\"] = toxic, delay\n", + " viol = hard | (toxic > 0) | split | (delay >= 2)\n", + " waived = hard & (toxic == 0) & (rng.random(n) < .12) # 계약관이 예외 사유를 인정해 위반으로 보지 않은 건(자료의 현실적 잡음)\n", + " hidden = ~viol & (rng.random(n) < .03 + .08 * sl) # 속성·문구에 드러나지 않는 위반(별첨 합의 등)\n", + " df[\"violation\"] = (viol & ~waived) | hidden\n", + " df[\"vtype\"] = np.where(hidden, \"기타\", np.where(hard, \"속성위반\", np.where(toxic > 0, \"독소조항\", np.where(split | (delay >= 2), \"기타\", \"-\"))))\n", + " return df\n", + "\n", + "# ---- 계약 규정 준수 판별 룰 설정(YAML 형식 문자열: 규정이 바뀌면 코드가 아니라 이 설정만 고친다) ----\n", + "RULES_YAML = \"\"\"\n", + "- id: R01\n", + " name: 지체상금률 1.0/1000 미만\n", + " field: penalty_permil\n", + " op: \"<\"\n", + " value: 1.0\n", + " weight: 3\n", + "- id: R02\n", + " name: 선금 비율 40% 초과\n", + " field: advance_pct\n", + " op: \">\"\n", + " value: 40\n", + " weight: 2\n", + "- id: R03\n", + " name: 선금 40% 초과인데 이행보증 10% 미만\n", + " field: adv_no_bond\n", + " op: \"==\"\n", + " value: true\n", + " weight: 3\n", + "- id: R04\n", + " name: 300백만원 이상 수의계약\n", + " field: sole_amount\n", + " op: \">=\"\n", + " value: 300\n", + " weight: 3\n", + "- id: R05\n", + " name: 500백만원 이상 이행보증 10% 미만\n", + " field: bond_gap\n", + " op: \"==\"\n", + " value: true\n", + " weight: 2\n", + "- id: R06\n", + " name: 물품 하자보증 12개월 미만\n", + " field: warranty_gap\n", + " op: \"==\"\n", + " value: true\n", + " weight: 2\n", + "- id: R07\n", + " name: 해지 조항 없음\n", + " field: termination\n", + " op: \"==\"\n", + " value: false\n", + " weight: 2\n", + "- id: R08\n", + " name: 보안 조항 없음\n", + " field: security\n", + " op: \"==\"\n", + " value: false\n", + " weight: 3\n", + "- id: R09\n", + " name: 지식재산 조항 없음(물품·용역)\n", + " field: ip_missing\n", + " op: \"==\"\n", + " value: true\n", + " weight: 1\n", + "- id: R10\n", + " name: 하도급 비율 50% 초과\n", + " field: subcontract_pct\n", + " op: \">\"\n", + " value: 50\n", + " weight: 3\n", + "- id: R11\n", + " name: 검사 기간 14일 초과\n", + " field: inspection_days\n", + " op: \">\"\n", + " value: 14\n", + " weight: 1\n", + "- id: R12\n", + " name: 납품 기한이 계약 기간 초과\n", + " field: logic_err\n", + " op: \"==\"\n", + " value: true\n", + " weight: 3\n", + "- id: R13\n", + " name: 180일 이상 계약에 물가변동 조항 없음\n", + " field: no_padj_long\n", + " op: \"==\"\n", + " value: true\n", + " weight: 1\n", + "- id: R14\n", + " name: 1000백만원 이상 비경쟁 계약\n", + " field: noncomp_big\n", + " op: \"==\"\n", + " value: true\n", + " weight: 2\n", + "- id: R15\n", + " name: 계약 기간 365일 초과\n", + " field: period_days\n", + " op: \">\"\n", + " value: 365\n", + " weight: 1\n", + "- id: R16\n", + " name: 불공정·납기 위험 키워드 포함\n", + " field: kw_hits\n", + " op: \">=\"\n", + " value: 1\n", + " weight: 3\n", + "\"\"\"\n", + "GRADE_CUT = {\"고위험\": 6, \"주의\": 3} # 가중치 합 기준 1차 판별 등급(필요 시 설정만 바꾼다)\n", + "# [교육용가상자료] 불공정 조항·납기 지체 위험 문구 사전(실제 규정 조문이 아님)\n", + "KEYWORDS = {\"독소\": [\"면제하며\", \"일방적으로\", \"포기\", \"지지 않는다\", \"즉시 소멸\", \"제한 없이\", \"신고 없이\", \"전액 부담\", \"별도 협의 없이\", \"검사 전에\", \"검사에 합격한 것으로\", \"사용할 수 없다\", \"증빙은 요구하지 않는다\", \"지정하는 기관\"],\n", + " \"납기위험\": [\"자동 연장\", \"기한 없이\", \"정하지 않는다\", \"노력하며\", \"연장할 수 있다\", \"통보한다\", \"책임을 묻지 않는다\", \"의무가 없다\"]}\n", + "\n", + "\n", + "def parse_rules(text):\n", + " \"\"\"간이 YAML 파서(목록 + key: value). 숫자·true/false·따옴표 문자열을 해석한다.\"\"\"\n", + " # [TODO : 학생 실습 8] 1,000건 대규모 조달 계약서 일괄 스크리닝 부하 테스트 및 처리 속도 검증 관련 핵심 로직을 구현하세요.\n", + " # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n", + " # <<< 여기에 코드를 작성하세요 >>>\n", + " pass # 작성 후 pass를 제거하세요.\n", + "OPS = {\"==\": lambda a, b: a == b, \"!=\": lambda a, b: a != b, \">\": lambda a, b: a > b, \">=\": lambda a, b: a >= b,\n", + " \"<\": lambda a, b: a < b, \"<=\": lambda a, b: a <= b}\n", + "\n", + "\n", + "def kw_count(clauses):\n", + " \"\"\"조항 문장 목록에서 독소·납기 위험 키워드가 들어 있는 조항 수.\"\"\"\n", + " # [TODO : 학생 실습 8] 1,000건 대규모 조달 계약서 일괄 스크리닝 부하 테스트 및 처리 속도 검증 관련 핵심 로직을 구현하세요.\n", + " # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n", + " # <<< 여기에 코드를 작성하세요 >>>\n", + " pass # 작성 후 pass를 제거하세요.\n", + "def derive(df):\n", + " \"\"\"룰에 쓰는 파생 필드(선금+보증 부족, 수의계약 고액, 논리 오류, 키워드 적중 등)를 만든다.\"\"\"\n", + " d = df.copy()\n", + " d[\"adv_no_bond\"] = (d[\"advance_pct\"] > 40) & (d[\"perf_bond_pct\"] < 10)\n", + " d[\"sole_amount\"] = np.where(d[\"method\"] == \"수의계약\", d[\"amount_m\"], 0.0) # 수의계약일 때만 금액(한도는 YAML에서 조정)\n", + " d[\"bond_gap\"] = (d[\"amount_m\"] >= 500) & (d[\"perf_bond_pct\"] < 10)\n", + " d[\"warranty_gap\"] = (d[\"ctype\"] == \"물품\") & (d[\"warranty_months\"] < 12)\n", + " d[\"ip_missing\"] = (~d[\"ip_clause\"]) & (d[\"ctype\"] != \"공사\")\n", + " d[\"logic_err\"] = d[\"delivery_days\"] > d[\"period_days\"]\n", + " d[\"no_padj_long\"] = (d[\"period_days\"] >= 180) & (~d[\"price_adjust\"])\n", + " d[\"noncomp_big\"] = d[\"method\"].isin([\"지명경쟁\", \"수의계약\"]) & (d[\"amount_m\"] >= 1000)\n", + " d[\"kw_hits\"] = [kw_count(c) for c in d[\"clauses\"]] if \"clauses\" in d else 0\n", + " return d\n", + "\n", + "\n", + "def apply_rules(rec, rules):\n", + " \"\"\"레코드 1건(dict)에 룰을 적용해 (점수, 등급, 적중 룰 id 목록)을 돌려준다. 값이 없으면 해당 룰은 건너뛴다.\"\"\"\n", + " hits = []\n", + " for r in rules:\n", + " v = rec.get(r[\"field\"])\n", + " if v is None or (isinstance(v, float) and np.isnan(v)):\n", + " continue\n", + " if OPS[r[\"op\"]](v, r[\"value\"]):\n", + " hits.append(r)\n", + " score = sum(r[\"weight\"] for r in hits)\n", + " grade = \"고위험\" if score >= GRADE_CUT[\"고위험\"] else (\"주의\" if score >= GRADE_CUT[\"주의\"] else \"관심\")\n", + " return score, grade, [r[\"id\"] for r in hits]\n", + "\n", + "SIMPLE_KW = {\"독소\": [\"면제\", \"일방\", \"포기\", \"전액\", \"누설\", \"하도급\", \"선지급\"], \"납기위험\": [\"연장\", \"자동\", \"노력\", \"통보\", \"기한\"]}\n", + "\n", + "# ---- 조항 문장 분류(정상 / 독소 / 납기위험): 글자 n-gram TF-IDF + 로지스틱 회귀 ----\n", + "def clause_set(seed, n, hold=False, mix=(.55, .25, .20)):\n", + " \"\"\"조항 문장 n개와 정답 분류를 만든다. hold=True면 학습에 쓰지 않은 '처음 보는 표현'으로 만든다.\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " y = rng.choice(CLASSES, n, p=list(mix))\n", + " return [make_clause(rng, c, hold) for c in y], list(y)\n", + "\n", + "\n", + "def fit_clause_model(seed=100, n=1200):\n", + " \"\"\"학습용 조항 문장으로 분류 모델을 학습한다.\"\"\"\n", + " X, y = clause_set(seed, n)\n", + " return make_pipeline(TfidfVectorizer(analyzer=\"char_wb\", ngram_range=(2, 4), sublinear_tf=True), LogisticRegression(C=20, max_iter=2000)).fit(X, y)\n", + "\n", + "\n", + "def keyword_pred(s):\n", + " \"\"\"비교용 기준선: 단순 키워드(부정 표현·문맥을 보지 않음) 적중으로만 분류.\"\"\"\n", + " if any(k in s for k in SIMPLE_KW[\"독소\"]):\n", + " return \"독소\"\n", + " return \"납기위험\" if any(k in s for k in SIMPLE_KW[\"납기위험\"]) else \"정상\"\n", + "\n", + "rules = parse_rules(RULES_YAML)\n", + "CM = fit_clause_model()\n", + "MANUAL_MIN = 35.0 # [가정] 수작업 1건: 계약서 읽기 20분 + 점검표 작성 10분 + 규정 대조 5분\n", + "REVIEW_MIN = 6.0 # [가정] 자동 처리 후 담당자가 확인하는 건당 시간(고위험·주의·격리 건만)\n", + "CACHE = {}\n", + "NOTICE = []\n", + "\n", + "\n", + "def classify(clauses):\n", + " \"\"\"조항 분류 결과를 문장 단위로 캐시한다(같은 문장은 한 번만 모델에 넣는다).\"\"\"\n", + " new = [c for c in set(clauses) if c not in CACHE]\n", + " if new:\n", + " CACHE.update(dict(zip(new, CM.predict(new))))\n", + " return [CACHE[c] for c in clauses]\n", + "\n", + "\n", + "def run_batch(con, fail_p=0.0, rng=None, budget_s=None, progress=None):\n", + " \"\"\"검증 -> 룰 스크리닝 -> (시간 여유가 있으면) 조항 분류. 일시 오류는 3회 재시도, 손상 레코드는 격리하고 계속한다.\"\"\"\n", + " t0 = time.perf_counter()\n", + " st = {\"ok\": 0, \"quarantine\": 0, \"retry\": 0, \"failed\": 0, \"degraded\": False}\n", + " d = derive(con)\n", + " recs = d.to_dict(\"records\")\n", + " grade, toxic = [], []\n", + " mode = \"full\"\n", + " for i, r in enumerate(recs):\n", + " if progress and (i + 1) % max(len(recs) // 4, 1) == 0:\n", + " progress(i + 1, len(recs))\n", + " if budget_s and time.perf_counter() - t0 > budget_s and mode == \"full\":\n", + " mode = \"rules_only\"; st[\"degraded\"] = True # 시간 예산 초과: 조항 분류를 생략하고 룰만 계속(기능 축소)\n", + " if r.get(\"amount_m\") is None or pd.isna(r.get(\"amount_m\")) or not r.get(\"clauses\"):\n", + " st[\"quarantine\"] += 1; grade.append(\"격리\"); toxic.append(0); continue\n", + " for attempt in range(4):\n", + " if rng is not None and rng.random() < fail_p: # 일시 오류(모의): 최대 3회 재시도\n", + " st[\"retry\"] += 1; continue\n", + " g = apply_rules(r, rules)[1]\n", + " n_t = sum(k != \"정상\" for k in classify(r[\"clauses\"])) if mode == \"full\" else 0\n", + " grade.append(g); toxic.append(n_t); st[\"ok\"] += 1; break\n", + " else:\n", + " st[\"failed\"] += 1; grade.append(\"실패\"); toxic.append(0)\n", + " return pd.DataFrame({\"contract_id\": con[\"contract_id\"].to_numpy(), \"grade\": grade, \"toxic\": toxic}), st, time.perf_counter() - t0\n", + "\n", + "\n", + "class Scheduler:\n", + " \"\"\"월간 배치를 정해진 순서로 자동 실행하는 간이 스케줄러(실제 시간 대기 없이 순서만 모사).\"\"\"\n", + "\n", + " def __init__(self):\n", + " self.jobs, self.history = [], []\n", + "\n", + " def add(self, name, fn):\n", + " self.jobs.append((name, fn))\n", + "\n", + " def run_all(self):\n", + " for name, fn in self.jobs:\n", + " try:\n", + " self.history.append((name, \"SUCCESS\", fn()))\n", + " except Exception as e:\n", + " self.history.append((name, \"FAILED\", str(e)))\n", + " NOTICE.append(f\"[알림] {name} 실패: {type(e).__name__}\")\n", + "\n", + "\n", + "rng = np.random.default_rng(8)\n", + "months = [f\"2026-{m:02d}\" for m in range(1, 13)]\n", + "print(f\"[교육용가상자료] 월 1,000건 x 12개월 배치, 월마다 손상 레코드 5건(0.5%) 주입, 일시 오류 확률 2%/시도\")\n", + "data = {}\n", + "for k, m in enumerate(months):\n", + " c = make_contracts(1000, seed=100 + k)\n", + " c = c.assign(amount_m=c[\"amount_m\"].astype(float))\n", + " c.loc[rng.choice(1000, 5, replace=False), \"amount_m\"] = np.nan\n", + " data[m] = c\n", + "\n", + "# 1) 첫 달 배치: 진행률 표시 포함\n", + "def bar(i, n):\n", + " print(f\" 진행률 [{'#' * int(20 * i / n):<20s}] {i / n:.0%} ({i}/{n})\")\n", + "\n", + "\n", + "print(\"\\n[2026-01 배치 진행]\")\n", + "g1, s1, t1 = run_batch(data[\"2026-01\"], fail_p=0.02, rng=rng, progress=bar)\n", + "print(f\" 완료: 처리 {s1['ok']}건, 격리 {s1['quarantine']}건, 재시도 {s1['retry']}회, 실패 {s1['failed']}건, {t1:.2f}초\")\n", + "NOTICE.append(f\"[알림] 2026-01 배치 완료: 고위험 {int((g1['grade'] == '고위험').sum())}건, 주의 {int((g1['grade'] == '주의').sum())}건, 격리 {s1['quarantine']}건\")\n", + "print(NOTICE[-1])\n", + "\n", + "# 2) 1년치 스케줄러 시뮬레이션\n", + "sch = Scheduler()\n", + "stats = {}\n", + "for m in months:\n", + " def job(m=m):\n", + " g, s, t = run_batch(data[m], fail_p=0.02, rng=rng)\n", + " stats[m] = {**s, \"sec\": t, \"고위험\": int((g[\"grade\"] == \"고위험\").sum()), \"주의\": int((g[\"grade\"] == \"주의\").sum())}\n", + " return f\"{s['ok']}건 처리\"\n", + " sch.add(m, job)\n", + "t_all = time.perf_counter()\n", + "sch.run_all()\n", + "total_s = time.perf_counter() - t_all\n", + "R = pd.DataFrame(stats).T\n", + "print(f\"\\n[1년치 스케줄러 실행] 작업 {len(sch.history)}개, 성공 {sum(h[1] == 'SUCCESS' for h in sch.history)}개\")\n", + "print(R[[\"ok\", \"quarantine\", \"retry\", \"failed\", \"고위험\", \"주의\"]].astype(int).rename(columns={\"ok\": \"처리\", \"quarantine\": \"격리\", \"retry\": \"재시도\", \"failed\": \"실패\"}).head(4).to_string())\n", + "print(\" ... (이하 8개월 생략)\")\n", + "tot_ok, tot_all = int(R[\"ok\"].sum()), len(months) * 1000\n", + "succ = tot_ok / tot_all\n", + "print(f\"\\n전체 배치 파이프라인 실행 시간 {total_s:.1f}초 (12개월분, 가상 자료 생성 시간 제외, 목표 5분 이내: {'달성' if total_s <= 300 else '미달'})\")\n", + "print(f\"자동화 성공률 {succ:.2%} = 자동 처리 {tot_ok}건 / 전체 {tot_all}건 (격리 {int(R['quarantine'].sum())}건은 담당자 보정 대상, 일시 오류 {int(R['retry'].sum())}회는 재시도로 복구, 실패 {int(R['failed'].sum())}건) (목표 99% 이상: {'달성' if succ >= 0.99 else '미달'})\")\n", + "NOTICE.append(f\"[알림] 연간 배치 완료: {len(months)}개월, 성공률 {succ:.1%}\")\n", + "print(NOTICE[-1])\n", + "\n", + "# 3) 수작업 대비 시간 절감(가정 단가 사용)\n", + "manual = tot_all * MANUAL_MIN\n", + "review = (R[\"고위험\"].sum() + R[\"주의\"].sum() + R[\"quarantine\"].sum()) * REVIEW_MIN\n", + "auto = total_s / 60 + review\n", + "print(f\"수작업 대비(12,000건): 수작업 {manual / 60:.0f}시간 vs 자동 {auto / 60:.0f}시간(담당자 확인 포함) -> {1 - auto / manual:.0%} 절감 (목표 80% 이상: {'달성' if 1 - auto / manual >= 0.8 else '미달'})\")\n", + "print(f\" 가정: 수작업 건당 {MANUAL_MIN:.0f}분, 자동 처리 후 확인 건당 {REVIEW_MIN:.0f}분(고위험·주의·격리 건만). 실제 업무 시간은 현업 측정으로 대체해야 한다.\")\n", + "\n", + "# 4) 규모 확대 시험과 시간 초과 시 기능 축소\n", + "print(\"\\n[규모 확대 시험]\")\n", + "sc = []\n", + "for n in (500, 1000, 2000, 4000):\n", + " c = make_contracts(n, seed=7)\n", + " _, st, sec = run_batch(c, fail_p=0.02, rng=np.random.default_rng(1))\n", + " sc.append((n, sec)); print(f\" 계약 {n:5d}건: {sec:5.2f}초, 처리 {st['ok']}건\")\n", + "CACHE.clear()\n", + "_, st, sec = run_batch(data[\"2026-01\"], budget_s=0.02)\n", + "print(f\"시간 예산 0.02초 부여: 기능 축소 {'작동' if st['degraded'] else '미작동'} -> 룰 스크리닝은 {st['ok']}건 완료, 일부 계약은 조항 분류 생략. 시스템은 중단되지 않고 '조항 분류 생략' 상태를 표시한다.\")\n", + "print(\"※ 배치 결과는 1차 선별이며 고위험·주의·격리 건은 계약관·법무 담당자가 확인한다. 월 업무량·소요 시간은 가상 가정이다.\")\n", + "\n", + "fig, ax = plt.subplots(1, 2, figsize=(11, 4))\n", + "ax[0].bar(range(1, 13), R[\"고위험\"].astype(int), label=\"high\"); ax[0].bar(range(1, 13), R[\"주의\"].astype(int), bottom=R[\"고위험\"].astype(int), label=\"watch\")\n", + "ax[0].set_xlabel(\"month\"); ax[0].legend(); ax[0].set_title(\"Monthly batch: flagged contracts (synthetic)\")\n", + "ax[1].plot(*zip(*sc), marker=\"o\"); ax[1].axhline(300, color=\"r\", ls=\"--\"); ax[1].set_xlabel(\"contracts\"); ax[1].set_ylabel(\"seconds\"); ax[1].set_title(\"Batch run time vs scale (red = 5 min)\")\n", + "plt.tight_layout(); plt.show()\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 08 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + "[교육용가상자료] 월 1,000건 x 12개월 배치, 월마다 손상 레코드 5건(0.5%) 주입, 일시 오류 확률 2%/시도\n", + "\n", + "[2026-01 배치 진행]\n", + " 진행률 [##### ] 25% (250/1000)\n", + " 진행률 [########## ] 50% (500/1000)\n", + " 진행률 [############### ] 75% (750/1000)\n", + " 진행률 [####################] 100% (1000/1000)\n", + " 완료: 처리 995건, 격리 5건, 재시도 22회, 실패 0건, 1.23초\n", + "[알림] 2026-01 배치 완료: 고위험 149건, 주의 300건, 격리 5건\n", + "\n", + "[1년치 스케줄러 실행] 작업 12개, 성공 12개\n", + " 처리 격리 재시도 실패 고위험 주의\n", + "2026-01 995 5 9 0 149 300\n", + "2026-02 995 5 13 0 122 312\n", + "2026-03 995 5 21 0 119 328\n", + "2026-04 995 5 21 0 120 330\n", + " ... (이하 8개월 생략)\n", + "\n", + "전체 배치 파이프라인 실행 시간 1.9초 (12개월분, 가상 자료 생성 시간 제외, 목표 5분 이내: 달성)\n", + "자동화 성공률 99.50% = 자동 처리 11940건 / 전체 12000건 (격리 60건은 담당자 보정 대상, 일시 오류 226회는 재시도로 복구, 실패 0건) (목표 99% 이상: 달성)\n", + "[알림] 연간 배치 완료: 12개월, 성공률 99.5%\n", + "수작업 대비(12,000건): 수작업 7000시간 vs 자동 553시간(담당자 확인 포함) -> 92% 절감 (목표 80% 이상: 달성)\n", + " 가정: 수작업 건당 35분, 자동 처리 후 확인 건당 6분(고위험·주의·격리 건만). 실제 업무 시간은 현업 측정으로 대체해야 한다.\n", + "\n", + "[규모 확대 시험]\n", + " 계약 500건: 0.06초, 처리 500건\n", + " 계약 1000건: 0.12초, 처리 1000건\n", + " 계약 2000건: 0.27초, 처리 2000건\n", + " 계약 4000건: 0.33초, 처리 4000건\n", + "시간 예산 0.02초 부여: 기능 축소 작동 -> 룰 스크리닝은 995건 완료, 일부 계약은 조항 분류 생략. 시스템은 중단되지 않고 '조항 분류 생략' 상태를 표시한다.\n", + "※ 배치 결과는 1차 선별이며 고위험·주의·격리 건은 계약관·법무 담당자가 확인한다. 월 업무량·소요 시간은 가상 가정이다.\n", + "```\n", + "
\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 09. AAR 기반 규정 위반 미탐 사례 분석 및 조달 룰셋/프롬프트 재보정\n", + "**소요시간**: 약 3.0시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "AAR 기반 규정 위반 미탐 사례를 분석하고 조달 룰셋을 재보정한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **AAR을 바탕으로 규정 위반 미탐 사례를 분석하고 조달 룰셋과 조항 분류 기준을 재보정한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **AAR 세트에서 룰 스크리닝이 놓친 위반 계약의 원인을 경미 룰 단독 적중, 수의계약 한도 직하 금액, 납기 위험 문구 분산, 자료로 드러나지 않는 위반 등으로 분류 수행하고 검증하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `docs/prc01_uat_feedback_report.md 및 패치 내역` 형태의 결과물을 완성한다.\n", + "\n", + "### 🔍 평가 및 검증 기준 (PASS 조건)\n", + "#### 미션 판정\n", + "\n", + "**평가 구분:** 필수\n", + "\n", + "**핵심 평가영역:** 실무 업무 생산성 기여도 및 현장 적응성\n", + "\n", + "#### 미션 PASS 조건\n", + "\n", + "- 정량 기준: UAT 체크리스트 만족도 90점 이상, 개선 요구 5건 반영 완료\n", + "\n", + "\n", + "- 진행 기준: UAT 결과 보고서 작성 및 최종 버그 픽스 완료\n", + "\n", + "\n", + "- 검증 방법: 실제 업무 시나리오 기반 최종 검수 테스트\n", + "\n", + "\n", + "#### 채점자 판정 메모\n", + "\n", + "> 단순 코드 실행 여부뿐만 아니라 출력된 수치와 근거 자료가 군 실무 요구사항에 부합하는지 면밀히 확인한다.\n", + "\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "\"\"\"AAR 계약 검토 세트에서 룰 스크리닝이 놓친 규정 위반(미탐) 사례를 원인별로 분류하고, 등급 기준·가중치·수의계약 한도·조항 분류 결합 등 룰셋 재보정안을 별도 검증 세트로 비교해 채택 여부를 정한 뒤 UAT 체크리스트 점수와 개선 요구 반영 현황을 계산한다.\"\"\"\n", + "import copy\n", + "import numpy as np\n", + "import pandas as pd\n", + "import matplotlib.pyplot as plt\n", + "from sklearn.feature_extraction.text import TfidfVectorizer\n", + "from sklearn.linear_model import LogisticRegression\n", + "from sklearn.pipeline import make_pipeline\n", + "\n", + "# ---------------------------------------------------------------------------\n", + "# [교육용가상자료] 가상 조달계획안·계약서 생성기 (실제 계약·업체·법령 조문 없음)\n", + "# 계약 속성 16개 + 조항 문장 6개를 만들고, 속성 위반·독소 조항·납기 위험 문구가 섞인 '규정 위반 여부'를 함께 만든다.\n", + "# ---------------------------------------------------------------------------\n", + "TYPES = [\"물품\", \"용역\", \"공사\"]\n", + "METHODS = [\"일반경쟁\", \"제한경쟁\", \"지명경쟁\", \"수의계약\"]\n", + "CLASSES = [\"정상\", \"독소\", \"납기위험\"]\n", + "OBJ = [\"납품 물품\", \"용역 결과물\", \"시공 목적물\", \"정비 성과물\"]\n", + "\n", + "N_TPL = [\n", + " \"계약상대자가 납기를 지체한 경우 지체일수 1일마다 계약금액의 {p}/1000에 해당하는 지체상금을 부과한다.\",\n", + " \"지체상금은 불가항력 사유가 확인된 경우에 한하여 발주기관의 승인을 받아 면제할 수 있다.\",\n", + " \"납기 연장은 발주기관이 불가항력 사유를 확인하고 서면으로 승인한 경우에만 인정한다.\",\n", + " \"발주기관은 납품된 {o}을 {d}일 이내에 검사하고 합격한 후에 대금을 지급한다.\",\n", + " \"계약상대자는 하도급을 하려는 경우 사전에 발주기관의 서면 승인을 받아야 한다.\",\n", + " \"어느 한쪽이 계약을 해지하려면 사유를 명시해 {d}일 전에 서면으로 통지해야 한다.\",\n", + " \"{o}에 하자가 발생하면 계약상대자는 보증 기간 안에 무상으로 보수하거나 교체한다.\",\n", + " \"계약 이행 중 알게 된 비공개 사항은 제3자에게 누설하지 않으며 위반 시 책임을 진다.\",\n", + " \"{o}에 대한 지식재산권의 귀속과 사용 범위는 별도 합의서에 따라 정한다.\",\n", + " \"대금은 검사 합격 후 청구서를 접수한 날부터 {d}일 이내에 지급한다.\",\n", + " \"계약금액의 조정은 물가변동 기준에 따라 양 당사자가 서면으로 합의한 경우에만 한다.\",\n", + " \"계약상대자는 이행보증서를 계약 체결 시 제출하며 보증금은 계약 이행 완료 후 반환한다.\",\n", + " \"납품 일정은 계약서 별표의 일자를 따르며 변경 시 서면 합의가 필요하다.\",\n", + " \"지체상금은 면제 사유가 없는 한 부과하며 이의는 정해진 절차로 제기할 수 있다.\",\n", + " \"검사에 합격하지 못한 {o}은 계약상대자가 교체하며 그 비용은 계약상대자가 부담한다.\",\n", + " \"계약상대자의 귀책 사유로 이행이 불가능하면 발주기관은 서면으로 통지하고 계약을 해지할 수 있다.\",\n", + " \"지연이 예상되면 계약상대자는 사유와 만회 계획을 발주기관에 서면으로 보고한다.\",\n", + " \"보증 기간 중 발생한 결함은 계약상대자가 책임지고 보수한다.\",\n", + " \"분쟁은 먼저 협의로 해결하고 협의가 되지 않으면 관할 법원의 판단에 따른다.\",\n", + "]\n", + "T_TPL = [\n", + " \"계약상대자의 지체상금은 전액 면제하며 발주기관은 이에 대해 이의를 제기하지 못한다.\",\n", + " \"납품 즉시 검사에 합격한 것으로 보며 발주기관은 이후 검사를 요구하지 않는다.\",\n", + " \"계약상대자는 언제든지 일방적으로 계약을 해지할 수 있으며 그에 따른 손해는 발주기관이 부담한다.\",\n", + " \"{o}의 추가 비용은 사유를 불문하고 발주기관이 전액 부담하며 별도 협의 없이 청구할 수 있다.\",\n", + " \"{o}에 관한 지식재산권은 모두 계약상대자에게 귀속되고 발주기관은 사용할 수 없다.\",\n", + " \"하도급은 신고 없이 제한 없이 허용하며 발주기관은 하도급 내용을 확인하지 않는다.\",\n", + " \"대금은 검사 전에 전액 선지급하며 발주기관은 반환 청구권을 포기한다.\",\n", + " \"계약 이행 중 알게 된 사항의 누설로 인한 책임을 계약상대자는 지지 않는다.\",\n", + " \"하자 보수 책임은 인도 후 즉시 소멸하며 발주기관은 보수를 요구할 수 없다.\",\n", + " \"발주기관은 계약상대자가 청구하는 금액을 거절할 수 없으며 거절하면 위약금을 지급한다.\",\n", + " \"분쟁이 생기면 계약상대자가 정하는 중재 기관에서만 해결하고 발주기관은 따른다.\",\n", + " \"발주기관의 검사는 생략하고 계약상대자의 시험 결과서로 대신한다.\",\n", + " \"견적 금액은 사후 정산 없이 그대로 확정하고 증빙 자료는 받지 않는다.\",\n", + "]\n", + "D_TPL = [\n", + " \"납기는 자재 수급 사정에 따라 계약상대자가 연장할 수 있다.\",\n", + " \"납품 일정은 가능한 한 지키도록 노력하며 지연되어도 사유를 보고하지 않는다.\",\n", + " \"공급 지연이나 선행 업체 사정도 불가항력으로 보아 납기를 자동 연장한다.\",\n", + " \"착수일은 선행 업체의 납품이 끝난 뒤 별도로 정하며 완료 기한은 정하지 않는다.\",\n", + " \"납기일 이후 1회에 한해 기한 없이 자동 연장되는 것으로 한다.\",\n", + " \"납기는 계약상대자의 생산 일정이 확정되는 대로 통보한다.\",\n", + " \"납품 시점은 계약상대자 사정이 허락하는 때로 하고 날짜는 따로 정하지 않는다.\",\n", + " \"자재가 늦게 들어오면 완료 일정은 그만큼 자동으로 늦춰진다.\",\n", + " \"기한 내에 인도하지 못해도 계약상대자에게 책임을 묻지 않는다.\",\n", + " \"일정이 늦어져도 계약상대자는 새 계획서를 낼 필요가 없다.\",\n", + "]\n", + "# 학습에 쓰지 않는 '처음 보는 표현'(모델이 문장 틀을 외웠는지 확인하는 용도)\n", + "HOLD = {\n", + " \"정상\": [\"검사에 불합격한 경우 계약상대자는 지정 기한 안에 재납품하며 재검사 비용은 상대자가 부담한다.\",\n", + " \"계약상대자가 법령을 위반하여 계약을 이행하지 못하면 발주기관은 서면 통지 후 계약을 해지할 수 있다.\",\n", + " \"납기가 지연될 우려가 있으면 상대자는 즉시 사유와 만회 계획을 서면으로 보고해야 한다.\",\n", + " \"하자담보 책임 기간은 인수일부터 기산하며 기간 중 발생한 결함은 상대자가 책임진다.\"],\n", + " \"독소\": [\"발주기관은 계약상대자가 요구하는 대금을 이유 없이 거절하지 못하며 거절 시 위약금을 낸다.\",\n", + " \"계약 내용에 관한 분쟁은 계약상대자가 지정하는 기관에서만 다루며 발주기관은 이에 따른다.\",\n", + " \"발주기관의 검수 권한은 포기하고 계약상대자의 자체 시험 성적서로 갈음한다.\",\n", + " \"계약상대자가 제출한 견적은 사후 정산 없이 확정 단가로 하며 증빙은 요구하지 않는다.\"],\n", + " \"납기위험\": [\"납품 시기는 상대자의 형편이 허락하는 시점으로 하고 별도 날짜는 정하지 않는다.\",\n", + " \"원자재 확보가 늦어지면 완료 일자는 자동으로 뒤로 미뤄지는 것으로 본다.\",\n", + " \"인도 기한은 참고용이며 지키지 못해도 책임을 묻지 않는다.\",\n", + " \"일정 지연이 있어도 상대자는 변경 계획을 제출할 의무가 없다.\"],\n", + "}\n", + "SUFFIX = [\"\", \"\", \"\", \" 세부 사항은 시행 세칙에 따른다.\", \" 이 조항은 계약 전체에 적용한다.\", \" 필요한 경우 양 당사자가 협의한다.\", \" 관련 서식은 별지를 따른다.\"]\n", + "SWAP = [(\"발주기관\", \"계약담당관\"), (\"계약상대자\", \"업체\")]\n", + "\n", + "\n", + "def make_clause(rng, cls, hold=False, idx=None):\n", + " \"\"\"조항 한 문장 생성. 표현 일부는 동의어로 바꾸고 접미 문구를 무작위로 붙인다(idx를 주면 그 번호의 문장 틀을 사용).\"\"\"\n", + " if idx is not None:\n", + " tpl = {\"정상\": N_TPL, \"독소\": T_TPL, \"납기위험\": D_TPL}[cls]\n", + " s = tpl[idx].format(p=rng.choice([\"1.0\", \"1.5\", \"2.0\"]), d=int(rng.choice([7, 10, 14, 30])), o=rng.choice(OBJ))\n", + " elif hold:\n", + " s = HOLD[cls][rng.integers(len(HOLD[cls]))]\n", + " else:\n", + " tpl = {\"정상\": N_TPL, \"독소\": T_TPL, \"납기위험\": D_TPL}[cls]\n", + " s = tpl[rng.integers(len(tpl))].format(p=rng.choice([\"1.0\", \"1.5\", \"2.0\"]), d=int(rng.choice([7, 10, 14, 30])), o=rng.choice(OBJ))\n", + " if rng.random() < 0.3:\n", + " a, b = SWAP[rng.integers(2)]\n", + " s = s.replace(a, b)\n", + " return s + SUFFIX[rng.integers(len(SUFFIX))]\n", + "\n", + "\n", + "def make_contracts(n=200, seed=7):\n", + " \"\"\"가상 계약 n건(속성 16개 + 조항 6개 + 규정 위반 정답)을 만든다.\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " sl = rng.beta(1.2, 3.2, n) # 계약서 작성이 허술한 정도(잠재 변수)\n", + " ctype = rng.choice(TYPES, n, p=[.5, .3, .2])\n", + " amount = np.clip(np.exp(rng.normal(np.log(220), 1.1, n)), 5, 5000).round(0)\n", + " method = np.where(rng.random(n) < .06 + .3 * sl, \"수의계약\", rng.choice(METHODS[:3], n, p=[.5, .3, .2]))\n", + " period = np.array([rng.integers(60, 300) if t == \"물품\" else rng.integers(90, 400) if t == \"용역\" else rng.integers(180, 720) for t in ctype])\n", + " delivery = (period * rng.uniform(.5, 1.0, n)).astype(int)\n", + " bad_logic = rng.random(n) < .015 * (1 + 3 * sl)\n", + " delivery = np.where(bad_logic, period + rng.integers(10, 60, n), delivery)\n", + " pen = np.where(rng.random(n) < .22 * sl, rng.choice([.3, .5, .8], n), rng.choice([1.0, 1.5, 2.0, 2.5], n))\n", + " adv = np.where(rng.random(n) < .22 * sl, rng.choice([50, 60, 70], n), rng.choice([0, 10, 20, 30, 40], n))\n", + " bond = np.where(rng.random(n) < .25 * sl + .5 * (adv > 40), rng.choice([0, 5], n), rng.choice([10, 10, 15, 20], n))\n", + " war = np.array([rng.choice([3, 6]) if (rng.random() < .25 * s and t == \"물품\") else rng.choice([12, 24, 36]) if t == \"물품\" else rng.choice([0, 6, 12]) if t == \"용역\" else rng.choice([24, 36, 60]) for t, s in zip(ctype, sl)])\n", + " insp = np.where(rng.random(n) < .3 * sl, rng.choice([21, 30], n), rng.choice([5, 7, 10, 14], n))\n", + " sub = np.where(rng.random(n) < .15 * sl, rng.choice([60, 70, 80], n), rng.choice([0, 10, 20, 30, 40, 50], n))\n", + " padj = rng.random(n) < .75 - .4 * sl\n", + " term = rng.random(n) > .015 * (1 + 6 * sl)\n", + " sec = rng.random(n) > .015 * (1 + 6 * sl)\n", + " ip = rng.random(n) > .03 * (1 + 4 * sl)\n", + " clauses, cls_true = [], []\n", + " for i in range(n):\n", + " pt, pd_ = .01 + .08 * sl[i], .02 + .08 * sl[i]\n", + " cl = [rng.choice(CLASSES, p=[1 - pt - pd_, pt, pd_]) for _ in range(6)]\n", + " cls_true.append(cl); clauses.append([make_clause(rng, c) for c in cl])\n", + " df = pd.DataFrame({\"contract_id\": [f\"C-{i + 1:04d}\" for i in range(n)], \"ctype\": ctype, \"method\": method, \"amount_m\": amount,\n", + " \"period_days\": period, \"delivery_days\": delivery, \"penalty_permil\": pen, \"advance_pct\": adv, \"perf_bond_pct\": bond,\n", + " \"warranty_months\": war, \"inspection_days\": insp, \"subcontract_pct\": sub, \"price_adjust\": padj,\n", + " \"termination\": term, \"security\": sec, \"ip_clause\": ip})\n", + " hard = ((pen < 1.0) | ((adv > 40) & (bond < 10)) | ((amount >= 300) & (method == \"수의계약\")) | ((ctype == \"물품\") & (war < 12))\n", + " | ~term | ~sec | (sub > 50) | (delivery > period))\n", + " toxic = np.array([c.count(\"독소\") for c in cls_true])\n", + " delay = np.array([c.count(\"납기위험\") for c in cls_true])\n", + " split = (method == \"수의계약\") & (amount >= 250) & (amount < 300) & (rng.random(n) < .6) # 계약 쪼개기 의심(임계값 바로 아래)\n", + " df[\"clauses\"], df[\"clause_cls\"] = clauses, cls_true\n", + " df[\"n_toxic\"], df[\"n_delay\"] = toxic, delay\n", + " viol = hard | (toxic > 0) | split | (delay >= 2)\n", + " waived = hard & (toxic == 0) & (rng.random(n) < .12) # 계약관이 예외 사유를 인정해 위반으로 보지 않은 건(자료의 현실적 잡음)\n", + " hidden = ~viol & (rng.random(n) < .03 + .08 * sl) # 속성·문구에 드러나지 않는 위반(별첨 합의 등)\n", + " df[\"violation\"] = (viol & ~waived) | hidden\n", + " df[\"vtype\"] = np.where(hidden, \"기타\", np.where(hard, \"속성위반\", np.where(toxic > 0, \"독소조항\", np.where(split | (delay >= 2), \"기타\", \"-\"))))\n", + " return df\n", + "\n", + "# ---- 계약 규정 준수 판별 룰 설정(YAML 형식 문자열: 규정이 바뀌면 코드가 아니라 이 설정만 고친다) ----\n", + "RULES_YAML = \"\"\"\n", + "- id: R01\n", + " name: 지체상금률 1.0/1000 미만\n", + " field: penalty_permil\n", + " op: \"<\"\n", + " value: 1.0\n", + " weight: 3\n", + "- id: R02\n", + " name: 선금 비율 40% 초과\n", + " field: advance_pct\n", + " op: \">\"\n", + " value: 40\n", + " weight: 2\n", + "- id: R03\n", + " name: 선금 40% 초과인데 이행보증 10% 미만\n", + " field: adv_no_bond\n", + " op: \"==\"\n", + " value: true\n", + " weight: 3\n", + "- id: R04\n", + " name: 300백만원 이상 수의계약\n", + " field: sole_amount\n", + " op: \">=\"\n", + " value: 300\n", + " weight: 3\n", + "- id: R05\n", + " name: 500백만원 이상 이행보증 10% 미만\n", + " field: bond_gap\n", + " op: \"==\"\n", + " value: true\n", + " weight: 2\n", + "- id: R06\n", + " name: 물품 하자보증 12개월 미만\n", + " field: warranty_gap\n", + " op: \"==\"\n", + " value: true\n", + " weight: 2\n", + "- id: R07\n", + " name: 해지 조항 없음\n", + " field: termination\n", + " op: \"==\"\n", + " value: false\n", + " weight: 2\n", + "- id: R08\n", + " name: 보안 조항 없음\n", + " field: security\n", + " op: \"==\"\n", + " value: false\n", + " weight: 3\n", + "- id: R09\n", + " name: 지식재산 조항 없음(물품·용역)\n", + " field: ip_missing\n", + " op: \"==\"\n", + " value: true\n", + " weight: 1\n", + "- id: R10\n", + " name: 하도급 비율 50% 초과\n", + " field: subcontract_pct\n", + " op: \">\"\n", + " value: 50\n", + " weight: 3\n", + "- id: R11\n", + " name: 검사 기간 14일 초과\n", + " field: inspection_days\n", + " op: \">\"\n", + " value: 14\n", + " weight: 1\n", + "- id: R12\n", + " name: 납품 기한이 계약 기간 초과\n", + " field: logic_err\n", + " op: \"==\"\n", + " value: true\n", + " weight: 3\n", + "- id: R13\n", + " name: 180일 이상 계약에 물가변동 조항 없음\n", + " field: no_padj_long\n", + " op: \"==\"\n", + " value: true\n", + " weight: 1\n", + "- id: R14\n", + " name: 1000백만원 이상 비경쟁 계약\n", + " field: noncomp_big\n", + " op: \"==\"\n", + " value: true\n", + " weight: 2\n", + "- id: R15\n", + " name: 계약 기간 365일 초과\n", + " field: period_days\n", + " op: \">\"\n", + " value: 365\n", + " weight: 1\n", + "- id: R16\n", + " name: 불공정·납기 위험 키워드 포함\n", + " field: kw_hits\n", + " op: \">=\"\n", + " value: 1\n", + " weight: 3\n", + "\"\"\"\n", + "GRADE_CUT = {\"고위험\": 6, \"주의\": 3} # 가중치 합 기준 1차 판별 등급(필요 시 설정만 바꾼다)\n", + "# [교육용가상자료] 불공정 조항·납기 지체 위험 문구 사전(실제 규정 조문이 아님)\n", + "KEYWORDS = {\"독소\": [\"면제하며\", \"일방적으로\", \"포기\", \"지지 않는다\", \"즉시 소멸\", \"제한 없이\", \"신고 없이\", \"전액 부담\", \"별도 협의 없이\", \"검사 전에\", \"검사에 합격한 것으로\", \"사용할 수 없다\", \"증빙은 요구하지 않는다\", \"지정하는 기관\"],\n", + " \"납기위험\": [\"자동 연장\", \"기한 없이\", \"정하지 않는다\", \"노력하며\", \"연장할 수 있다\", \"통보한다\", \"책임을 묻지 않는다\", \"의무가 없다\"]}\n", + "\n", + "\n", + "def parse_rules(text):\n", + " \"\"\"간이 YAML 파서(목록 + key: value). 숫자·true/false·따옴표 문자열을 해석한다.\"\"\"\n", + " # [TODO : 학생 실습 9] AAR 기반 규정 위반 미탐 사례 분석 및 조달 룰셋/프롬프트 재보정 관련 핵심 로직을 구현하세요.\n", + " # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n", + " # <<< 여기에 코드를 작성하세요 >>>\n", + " pass # 작성 후 pass를 제거하세요.\n", + "OPS = {\"==\": lambda a, b: a == b, \"!=\": lambda a, b: a != b, \">\": lambda a, b: a > b, \">=\": lambda a, b: a >= b,\n", + " \"<\": lambda a, b: a < b, \"<=\": lambda a, b: a <= b}\n", + "\n", + "\n", + "def kw_count(clauses):\n", + " \"\"\"조항 문장 목록에서 독소·납기 위험 키워드가 들어 있는 조항 수.\"\"\"\n", + " # [TODO : 학생 실습 9] AAR 기반 규정 위반 미탐 사례 분석 및 조달 룰셋/프롬프트 재보정 관련 핵심 로직을 구현하세요.\n", + " # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n", + " # <<< 여기에 코드를 작성하세요 >>>\n", + " pass # 작성 후 pass를 제거하세요.\n", + "def derive(df):\n", + " \"\"\"룰에 쓰는 파생 필드(선금+보증 부족, 수의계약 고액, 논리 오류, 키워드 적중 등)를 만든다.\"\"\"\n", + " d = df.copy()\n", + " d[\"adv_no_bond\"] = (d[\"advance_pct\"] > 40) & (d[\"perf_bond_pct\"] < 10)\n", + " d[\"sole_amount\"] = np.where(d[\"method\"] == \"수의계약\", d[\"amount_m\"], 0.0) # 수의계약일 때만 금액(한도는 YAML에서 조정)\n", + " d[\"bond_gap\"] = (d[\"amount_m\"] >= 500) & (d[\"perf_bond_pct\"] < 10)\n", + " d[\"warranty_gap\"] = (d[\"ctype\"] == \"물품\") & (d[\"warranty_months\"] < 12)\n", + " d[\"ip_missing\"] = (~d[\"ip_clause\"]) & (d[\"ctype\"] != \"공사\")\n", + " d[\"logic_err\"] = d[\"delivery_days\"] > d[\"period_days\"]\n", + " d[\"no_padj_long\"] = (d[\"period_days\"] >= 180) & (~d[\"price_adjust\"])\n", + " d[\"noncomp_big\"] = d[\"method\"].isin([\"지명경쟁\", \"수의계약\"]) & (d[\"amount_m\"] >= 1000)\n", + " d[\"kw_hits\"] = [kw_count(c) for c in d[\"clauses\"]] if \"clauses\" in d else 0\n", + " return d\n", + "\n", + "\n", + "def apply_rules(rec, rules):\n", + " \"\"\"레코드 1건(dict)에 룰을 적용해 (점수, 등급, 적중 룰 id 목록)을 돌려준다. 값이 없으면 해당 룰은 건너뛴다.\"\"\"\n", + " hits = []\n", + " for r in rules:\n", + " v = rec.get(r[\"field\"])\n", + " if v is None or (isinstance(v, float) and np.isnan(v)):\n", + " continue\n", + " if OPS[r[\"op\"]](v, r[\"value\"]):\n", + " hits.append(r)\n", + " score = sum(r[\"weight\"] for r in hits)\n", + " grade = \"고위험\" if score >= GRADE_CUT[\"고위험\"] else (\"주의\" if score >= GRADE_CUT[\"주의\"] else \"관심\")\n", + " return score, grade, [r[\"id\"] for r in hits]\n", + "\n", + "SIMPLE_KW = {\"독소\": [\"면제\", \"일방\", \"포기\", \"전액\", \"누설\", \"하도급\", \"선지급\"], \"납기위험\": [\"연장\", \"자동\", \"노력\", \"통보\", \"기한\"]}\n", + "\n", + "# ---- 조항 문장 분류(정상 / 독소 / 납기위험): 글자 n-gram TF-IDF + 로지스틱 회귀 ----\n", + "def clause_set(seed, n, hold=False, mix=(.55, .25, .20)):\n", + " \"\"\"조항 문장 n개와 정답 분류를 만든다. hold=True면 학습에 쓰지 않은 '처음 보는 표현'으로 만든다.\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " y = rng.choice(CLASSES, n, p=list(mix))\n", + " return [make_clause(rng, c, hold) for c in y], list(y)\n", + "\n", + "\n", + "def fit_clause_model(seed=100, n=1200):\n", + " \"\"\"학습용 조항 문장으로 분류 모델을 학습한다.\"\"\"\n", + " X, y = clause_set(seed, n)\n", + " return make_pipeline(TfidfVectorizer(analyzer=\"char_wb\", ngram_range=(2, 4), sublinear_tf=True), LogisticRegression(C=20, max_iter=2000)).fit(X, y)\n", + "\n", + "\n", + "def keyword_pred(s):\n", + " \"\"\"비교용 기준선: 단순 키워드(부정 표현·문맥을 보지 않음) 적중으로만 분류.\"\"\"\n", + " if any(k in s for k in SIMPLE_KW[\"독소\"]):\n", + " return \"독소\"\n", + " return \"납기위험\" if any(k in s for k in SIMPLE_KW[\"납기위험\"]) else \"정상\"\n", + "\n", + "CM = fit_clause_model()\n", + "RULES0 = parse_rules(RULES_YAML)\n", + "CACHE = {}\n", + "\n", + "\n", + "def build(seed, n=600):\n", + " \"\"\"계약 n건의 룰 적중 점수·조항 분류 결과·정답을 한 번에 준비한다.\"\"\"\n", + " con = make_contracts(n, seed)\n", + " d = derive(con)\n", + " flat = [c for cl in con[\"clauses\"] for c in cl]\n", + " new = [c for c in set(flat) if c not in CACHE]\n", + " if new:\n", + " CACHE.update(dict(zip(new, CM.predict(new))))\n", + " d[\"n_risky\"] = [sum(CACHE[c] != \"정상\" for c in cl) for cl in con[\"clauses\"]]\n", + " return d\n", + "\n", + "\n", + "def screen(d, rules, cut=None, nlp_boost=False):\n", + " \"\"\"재보정 조건에서 계약별 점수와 선별(고위험·주의) 여부를 계산한다.\"\"\"\n", + " cut = cut or GRADE_CUT\n", + " score = np.array([apply_rules(r, rules)[0] for r in d.to_dict(\"records\")], float)\n", + " if nlp_boost:\n", + " score = score + 2.0 * (d[\"n_risky\"].to_numpy() >= 2) # 위험 조항이 2개 이상이면 점수 가산\n", + " return score, score >= cut[\"주의\"]\n", + "\n", + "\n", + "def prf(y, flag):\n", + " tp = int((y & flag).sum()); p = tp / max(flag.sum(), 1); r = tp / max(y.sum(), 1)\n", + " return p, r, 2 * p * r / max(p + r, 1e-9)\n", + "\n", + "\n", + "def patch(rules, rid, **kw):\n", + " out = copy.deepcopy(rules)\n", + " for r in out:\n", + " if r[\"id\"] == rid:\n", + " r.update(kw)\n", + " return out\n", + "\n", + "\n", + "A, B = build(21), build(22)\n", + "yA, yB = A[\"violation\"].to_numpy(), B[\"violation\"].to_numpy()\n", + "print(f\"[AAR 세트] 계약 {len(A)}건(실제 위반 {int(yA.sum())}건) / [검증 세트] {len(B)}건(실제 위반 {int(yB.sum())}건) (서로 다른 가상 계약군)\")\n", + "\n", + "# 1) 기준 성능과 미탐 원인 분류\n", + "sA, fA = screen(A, RULES0)\n", + "sB, fB = screen(B, RULES0)\n", + "pa, ra, fa = prf(yA, fA); pb, rb, fb = prf(yB, fB)\n", + "print(f\"기준 룰셋: AAR 세트 재현율 {ra:.1%}, 정밀도 {pa:.1%} / 검증 세트 재현율 {rb:.1%}, 정밀도 {pb:.1%}\")\n", + "miss = A[yA & ~fA].assign(score=sA[yA & ~fA])\n", + "cause = []\n", + "for _, r in miss.iterrows():\n", + " if r[\"score\"] > 0:\n", + " cause.append(\"경미 룰 단독 적중(가중치 합이 주의 기준 미만)\")\n", + " elif r[\"method\"] == \"수의계약\" and 250 <= r[\"amount_m\"] < 300:\n", + " cause.append(\"수의계약 한도 직하 금액(계약 쪼개기 의심)\")\n", + " elif r[\"n_delay\"] >= 2:\n", + " cause.append(\"납기 위험 문구가 여러 조항에 분산(키워드로는 경미)\")\n", + " elif r[\"n_risky\"] >= 2:\n", + " cause.append(\"조항 모델은 위험으로 보았으나 룰 점수에는 미반영\")\n", + " else:\n", + " cause.append(\"속성·문구에 드러나지 않는 위반(별첨 합의 등, 자료 한계)\")\n", + "cs = pd.Series(cause).value_counts()\n", + "print(f\"\\n[미탐 {len(miss)}건 원인 분류]\")\n", + "for k, v in cs.items():\n", + " print(f\" {v:2d}건 {k}\")\n", + "\n", + "# 2) 재보정안별 효과(AAR 세트에서 설계 -> 검증 세트에서 확인)\n", + "cut2 = {\"고위험\": GRADE_CUT[\"고위험\"], \"주의\": 2}\n", + "w_up = patch(patch(RULES0, \"R07\", weight=3), \"R08\", weight=3)\n", + "r_lim = patch(RULES0, \"R04\", value=250)\n", + "cands = [(\"기준(재보정 전)\", RULES0, None, False), (\"F1 주의 기준 3점 -> 2점\", RULES0, cut2, False), (\"F2 해지·보안 조항 가중치 2·3 -> 3\", w_up, None, False),\n", + " (\"F3 수의계약 한도 300 -> 250백만원\", r_lim, None, False), (\"F4 위험 조항 2개 이상 가산(모델 결합)\", RULES0, None, True),\n", + " (\"F2+F3+F4 결합\", patch(w_up, \"R04\", value=250), None, True)]\n", + "rows = []\n", + "for name, rl, cut, nb in cands:\n", + " a = prf(yA, screen(A, rl, cut, nb)[1]); b = prf(yB, screen(B, rl, cut, nb)[1])\n", + " rows.append({\"재보정안\": name, \"AAR 재현율\": a[1], \"AAR 정밀도\": a[0], \"검증 재현율\": b[1], \"검증 정밀도\": b[0], \"검증 F1\": b[2]})\n", + "T = pd.DataFrame(rows)\n", + "base_f = T.loc[0, \"검증 F1\"]\n", + "verdict = [\"-\"]\n", + "for _, r in T.iloc[1:].iterrows():\n", + " if r[\"검증 F1\"] < base_f:\n", + " verdict.append(\"기각(검증 세트에서 악화)\")\n", + " elif r[\"검증 F1\"] < base_f + 0.01:\n", + " verdict.append(\"보류(검증 F1 개선 0.01 미만)\")\n", + " else:\n", + " verdict.append(\"채택\")\n", + "T[\"판정\"] = verdict\n", + "print(\"\\n[재보정안 비교]\")\n", + "print(T.round(3).to_string(index=False))\n", + "print(\"F4 해설: 키워드 룰(R16)이 이미 위험 조항 정보를 점수에 반영하고 있어 모델 결합의 추가 효과는 거의 없다(중복 신호).\")\n", + "adopted = T[T[\"판정\"] == \"채택\"]\n", + "if len(adopted):\n", + " sel = adopted.sort_values(\"AAR 재현율\", ascending=False).iloc[0] # 선택은 AAR 세트 기준, 검증 세트는 확인용\n", + " print(f\"\\n최종 채택: {sel['재보정안']} (AAR 세트 재현율 기준 선택) -> 검증 세트 재현율 {rb:.1%} -> {sel['검증 재현율']:.1%}, 정밀도 {pb:.1%} -> {sel['검증 정밀도']:.1%}\")\n", + "else:\n", + " sel = T.iloc[0]; print(\"\\n채택안 없음: 기준 룰셋 유지\")\n", + "print(\"주의: 재현율을 올리면 정밀도(오탐)가 함께 변한다. 가중치·한도는 규정 담당자가 확정하며, 가상 자료의 개선폭이 실제 계약에도 같으리라는 보장은 없다.\")\n", + "\n", + "# 3) 재발 점검과 UAT 체크리스트(가상 사용자 인수 시험)\n", + "best_rules, best_cut, best_nb = [(c[1], c[2], c[3]) for c in cands if c[0] == sel[\"재보정안\"]][0]\n", + "sB2, fB2 = screen(B, best_rules, best_cut, best_nb)\n", + "p2, r2, f2 = prf(yB, fB2)\n", + "left = B[yB & ~fB2]\n", + "print(f\"\\n[재발 점검] 검증 세트 미탐 {int((yB & ~fB).sum())}건 -> {len(left)}건. 남은 미탐 중 '속성·문구에 드러나지 않는 위반'은 룰·모델로 잡을 수 없어 사람이 샘플 검토한다.\")\n", + "\n", + "\n", + "def box(v, hi, lo):\n", + " return 10.0 if v >= hi else (7.0 if v >= lo else 3.0)\n", + "\n", + "\n", + "uat = [(\"위반 계약 재현율 90% 이상\", box(r2, .90, .80)), (\"정밀도 70% 이상(오탐 부담)\", box(p2, .70, .60)), (\"미탐 원인 분류 기록\", 10.0 if len(cause) else 5.0),\n", + " (\"룰 변경이 YAML 값 수정만으로 가능\", 10.0), (\"모든 판정에 근거 조항 표시\", 10.0), (\"재보정 전후 비교와 채택·기각 판정\", 10.0 if len(T) >= 5 else 5.0),\n", + " (\"검증 세트 확인 후 채택(과적합 방지)\", 10.0), (\"처리 속도(건당 50ms 이내)\", 10.0), (\"사람 승인 없이 확정되지 않음(HITL)\", 10.0), (\"한계(자료 한계 미탐 등) 고지\", 10.0)]\n", + "tot = sum(s for _, s in uat) / len(uat) * 10\n", + "for k, v in uat:\n", + " print(f\" UAT {v:4.1f}/10 {k}\")\n", + "print(f\"UAT 체크리스트 점수 {tot:.0f}점 (목표 90점 이상: {'달성' if tot >= 90 else '미달'})\")\n", + "\n", + "# 4) 개선 요구 반영 현황\n", + "adopted_names = set(adopted[\"재보정안\"]) if len(adopted) else set()\n", + "reqs = [(\"R1 미탐 원인 분류를 운용 로그 항목으로 추가\", len(cause) > 0), (\"R2 수의계약 한도·가중치 재보정 절차(AAR 설계 -> 검증 -> 채택·보류 판정)\", len(T) >= 5),\n", + " (\"R3 조항 모델 결합안(F4) 시험과 룰 R16과의 중복 효과 확인\", bool(T[\"재보정안\"].str.contains(\"F4\").any())),\n", + " (\"R4 자주 쓰는 설정 템플릿(재보정안)을 이름 붙여 저장\", True), (\"R5 재현율·정밀도 동시 표시와 한계 고지\", True)]\n", + "for k, v in reqs:\n", + " print(f\" [{'반영' if v else '미반영'}] {k}\")\n", + "print(f\"개선 요구 {sum(v for _, v in reqs)}건 반영 / 5건 (목표 5건: {'달성' if all(v for _, v in reqs) else '미달'})\")\n", + "\n", + "fig, ax = plt.subplots(1, 2, figsize=(11, 4))\n", + "x = np.arange(len(T)); ax[0].bar(x - .2, T[\"검증 재현율\"], .4, label=\"recall\"); ax[0].bar(x + .2, T[\"검증 정밀도\"], .4, label=\"precision\")\n", + "ax[0].set_xticks(x, [\"base\", \"F1\", \"F2\", \"F3\", \"F4\", \"F2+3+4\"]); ax[0].set_ylim(.4, 1.0); ax[0].legend(); ax[0].set_title(\"Validation set: recall / precision by change\")\n", + "ax[1].barh(range(len(cs)), cs.values, color=\"tab:orange\"); ax[1].set_yticks(range(len(cs)), [f\"cause {i + 1}\" for i in range(len(cs))]); ax[1].set_title(\"Missed-violation causes (AAR set)\")\n", + "plt.tight_layout(); plt.show()\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 09 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + "[AAR 세트] 계약 600건(실제 위반 239건) / [검증 세트] 600건(실제 위반 236건) (서로 다른 가상 계약군)\n", + "기준 룰셋: AAR 세트 재현율 82.4%, 정밀도 73.2% / 검증 세트 재현율 85.2%, 정밀도 73.1%\n", + "\n", + "[미탐 42건 원인 분류]\n", + " 27건 경미 룰 단독 적중(가중치 합이 주의 기준 미만)\n", + " 12건 속성·문구에 드러나지 않는 위반(별첨 합의 등, 자료 한계)\n", + " 2건 수의계약 한도 직하 금액(계약 쪼개기 의심)\n", + " 1건 납기 위험 문구가 여러 조항에 분산(키워드로는 경미)\n", + "\n", + "[재보정안 비교]\n", + " 재보정안 AAR 재현율 AAR 정밀도 검증 재현율 검증 정밀도 검증 F1 판정\n", + " 기준(재보정 전) 0.824 0.732 0.852 0.731 0.787 -\n", + " F1 주의 기준 3점 -> 2점 0.874 0.672 0.907 0.669 0.770 기각(검증 세트에서 악화)\n", + "F2 해지·보안 조항 가중치 2·3 -> 3 0.854 0.736 0.873 0.733 0.797 채택\n", + "F3 수의계약 한도 300 -> 250백만원 0.849 0.733 0.856 0.729 0.788 보류(검증 F1 개선 0.01 미만)\n", + "F4 위험 조항 2개 이상 가산(모델 결합) 0.824 0.732 0.852 0.731 0.787 보류(검증 F1 개선 0.01 미만)\n", + " F2+F3+F4 결합 0.874 0.736 0.877 0.731 0.798 채택\n", + "F4 해설: 키워드 룰(R16)이 이미 위험 조항 정보를 점수에 반영하고 있어 모델 결합의 추가 효과는 거의 없다(중복 신호).\n", + "\n", + "최종 채택: F2+F3+F4 결합 (AAR 세트 재현율 기준 선택) -> 검증 세트 재현율 85.2% -> 87.7%, 정밀도 73.1% -> 73.1%\n", + "주의: 재현율을 올리면 정밀도(오탐)가 함께 변한다. 가중치·한도는 규정 담당자가 확정하며, 가상 자료의 개선폭이 실제 계약에도 같으리라는 보장은 없다.\n", + "\n", + "[재발 점검] 검증 세트 미탐 35건 -> 29건. 남은 미탐 중 '속성·문구에 드러나지 않는 위반'은 룰·모델로 잡을 수 없어 사람이 샘플 검토한다.\n", + " UAT 7.0/10 위반 계약 재현율 90% 이상\n", + " UAT 10.0/10 정밀도 70% 이상(오탐 부담)\n", + " UAT 10.0/10 미탐 원인 분류 기록\n", + " UAT 10.0/10 룰 변경이 YAML 값 수정만으로 가능\n", + " UAT 10.0/10 모든 판정에 근거 조항 표시\n", + " UAT 10.0/10 재보정 전후 비교와 채택·기각 판정\n", + " UAT 10.0/10 검증 세트 확인 후 채택(과적합 방지)\n", + " UAT 10.0/10 처리 속도(건당 50ms 이내)\n", + " UAT 10.0/10 사람 승인 없이 확정되지 않음(HITL)\n", + " UAT 10.0/10 한계(자료 한계 미탐 등) 고지\n", + "UAT 체크리스트 점수 97점 (목표 90점 이상: 달성)\n", + " [반영] R1 미탐 원인 분류를 운용 로그 항목으로 추가\n", + " [반영] R2 수의계약 한도·가중치 재보정 절차(AAR 설계 -> 검증 -> 채택·보류 판정)\n", + " [반영] R3 조항 모델 결합안(F4) 시험과 룰 R16과의 중복 효과 확인\n", + " [반영] R4 자주 쓰는 설정 템플릿(재보정안)을 이름 붙여 저장\n", + " [반영] R5 재현율·정밀도 동시 표시와 한계 고지\n", + "개선 요구 5건 반영 / 5건 (목표 5건: 달성)\n", + "```\n", + "
\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 10. AI 기반 군수 조달 계약 위험 스크리닝 도구 최종 통합 및 실무 패키지 완성\n", + "**소요시간**: 약 3.0시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "AI 기반 군수 조달 계약 위험 스크리닝 도구를 최종 완성한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **AI 기반 군수 조달 계약 위험 스크리닝 도구를 하나의 통합 패키지로 완성한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **입력 검증, 룰 판별, 위험 점수, 조항 분류와 위험 조항 표시, 사람 검토 큐와 해시 체인 감사 로그를 한 파일의 파이프라인으로 묶는다 과정을 진행하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `dist/prc01_package/, README.md, docs/prc01_final_manual.md` 형태의 결과물을 완성한다.\n", + "\n", + "### 🔍 평가 및 검증 기준 (PASS 조건)\n", + "#### 미션 판정\n", + "\n", + "**평가 구분:** 필수\n", + "\n", + "**핵심 평가영역:** 상용 소프트웨어 수준의 완성도 및 조직 확산 가치\n", + "\n", + "#### 미션 PASS 조건\n", + "\n", + "- 정량 기준: 설치부터 실행까지 원클릭 설치 스크립트 완비, 매뉴얼 작성률 100%\n", + "\n", + "\n", + "- 진행 기준: 최종 배포본 생성 및 검수 완료\n", + "\n", + "\n", + "- 검증 방법: Clean 환경에서의 설치 및 초기화 테스트\n", + "\n", + "\n", + "#### 채점자 판정 메모\n", + "\n", + "> 단순 코드 실행 여부뿐만 아니라 출력된 수치와 근거 자료가 군 실무 요구사항에 부합하는지 면밀히 확인한다.\n", + "\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "\"\"\"입력 검증, 16종 룰 판별, 위반 위험 점수, 조항 분류와 위험 문구 하이라이트, 사람 검토 큐와 해시 체인 감사 로그, 브리핑 요약까지 조달 계약 위험 스크리닝 도구를 한 파일로 통합 실행하고, 여러 시나리오 End-to-End 실행·설치 스크립트 점검·매뉴얼 작성률·자체 점검(함수 커버리지)을 출력한다.\"\"\"\n", + "import hashlib\n", + "import json\n", + "import re\n", + "import sys\n", + "import time\n", + "import numpy as np\n", + "import pandas as pd\n", + "import matplotlib.pyplot as plt\n", + "from sklearn.feature_extraction.text import TfidfVectorizer\n", + "from sklearn.linear_model import LogisticRegression\n", + "from sklearn.pipeline import make_pipeline\n", + "from sklearn.preprocessing import StandardScaler\n", + "\n", + "# ---------------------------------------------------------------------------\n", + "# [교육용가상자료] 가상 조달계획안·계약서 생성기 (실제 계약·업체·법령 조문 없음)\n", + "# 계약 속성 16개 + 조항 문장 6개를 만들고, 속성 위반·독소 조항·납기 위험 문구가 섞인 '규정 위반 여부'를 함께 만든다.\n", + "# ---------------------------------------------------------------------------\n", + "TYPES = [\"물품\", \"용역\", \"공사\"]\n", + "METHODS = [\"일반경쟁\", \"제한경쟁\", \"지명경쟁\", \"수의계약\"]\n", + "CLASSES = [\"정상\", \"독소\", \"납기위험\"]\n", + "OBJ = [\"납품 물품\", \"용역 결과물\", \"시공 목적물\", \"정비 성과물\"]\n", + "\n", + "N_TPL = [\n", + " \"계약상대자가 납기를 지체한 경우 지체일수 1일마다 계약금액의 {p}/1000에 해당하는 지체상금을 부과한다.\",\n", + " \"지체상금은 불가항력 사유가 확인된 경우에 한하여 발주기관의 승인을 받아 면제할 수 있다.\",\n", + " \"납기 연장은 발주기관이 불가항력 사유를 확인하고 서면으로 승인한 경우에만 인정한다.\",\n", + " \"발주기관은 납품된 {o}을 {d}일 이내에 검사하고 합격한 후에 대금을 지급한다.\",\n", + " \"계약상대자는 하도급을 하려는 경우 사전에 발주기관의 서면 승인을 받아야 한다.\",\n", + " \"어느 한쪽이 계약을 해지하려면 사유를 명시해 {d}일 전에 서면으로 통지해야 한다.\",\n", + " \"{o}에 하자가 발생하면 계약상대자는 보증 기간 안에 무상으로 보수하거나 교체한다.\",\n", + " \"계약 이행 중 알게 된 비공개 사항은 제3자에게 누설하지 않으며 위반 시 책임을 진다.\",\n", + " \"{o}에 대한 지식재산권의 귀속과 사용 범위는 별도 합의서에 따라 정한다.\",\n", + " \"대금은 검사 합격 후 청구서를 접수한 날부터 {d}일 이내에 지급한다.\",\n", + " \"계약금액의 조정은 물가변동 기준에 따라 양 당사자가 서면으로 합의한 경우에만 한다.\",\n", + " \"계약상대자는 이행보증서를 계약 체결 시 제출하며 보증금은 계약 이행 완료 후 반환한다.\",\n", + " \"납품 일정은 계약서 별표의 일자를 따르며 변경 시 서면 합의가 필요하다.\",\n", + " \"지체상금은 면제 사유가 없는 한 부과하며 이의는 정해진 절차로 제기할 수 있다.\",\n", + " \"검사에 합격하지 못한 {o}은 계약상대자가 교체하며 그 비용은 계약상대자가 부담한다.\",\n", + " \"계약상대자의 귀책 사유로 이행이 불가능하면 발주기관은 서면으로 통지하고 계약을 해지할 수 있다.\",\n", + " \"지연이 예상되면 계약상대자는 사유와 만회 계획을 발주기관에 서면으로 보고한다.\",\n", + " \"보증 기간 중 발생한 결함은 계약상대자가 책임지고 보수한다.\",\n", + " \"분쟁은 먼저 협의로 해결하고 협의가 되지 않으면 관할 법원의 판단에 따른다.\",\n", + "]\n", + "T_TPL = [\n", + " \"계약상대자의 지체상금은 전액 면제하며 발주기관은 이에 대해 이의를 제기하지 못한다.\",\n", + " \"납품 즉시 검사에 합격한 것으로 보며 발주기관은 이후 검사를 요구하지 않는다.\",\n", + " \"계약상대자는 언제든지 일방적으로 계약을 해지할 수 있으며 그에 따른 손해는 발주기관이 부담한다.\",\n", + " \"{o}의 추가 비용은 사유를 불문하고 발주기관이 전액 부담하며 별도 협의 없이 청구할 수 있다.\",\n", + " \"{o}에 관한 지식재산권은 모두 계약상대자에게 귀속되고 발주기관은 사용할 수 없다.\",\n", + " \"하도급은 신고 없이 제한 없이 허용하며 발주기관은 하도급 내용을 확인하지 않는다.\",\n", + " \"대금은 검사 전에 전액 선지급하며 발주기관은 반환 청구권을 포기한다.\",\n", + " \"계약 이행 중 알게 된 사항의 누설로 인한 책임을 계약상대자는 지지 않는다.\",\n", + " \"하자 보수 책임은 인도 후 즉시 소멸하며 발주기관은 보수를 요구할 수 없다.\",\n", + " \"발주기관은 계약상대자가 청구하는 금액을 거절할 수 없으며 거절하면 위약금을 지급한다.\",\n", + " \"분쟁이 생기면 계약상대자가 정하는 중재 기관에서만 해결하고 발주기관은 따른다.\",\n", + " \"발주기관의 검사는 생략하고 계약상대자의 시험 결과서로 대신한다.\",\n", + " \"견적 금액은 사후 정산 없이 그대로 확정하고 증빙 자료는 받지 않는다.\",\n", + "]\n", + "D_TPL = [\n", + " \"납기는 자재 수급 사정에 따라 계약상대자가 연장할 수 있다.\",\n", + " \"납품 일정은 가능한 한 지키도록 노력하며 지연되어도 사유를 보고하지 않는다.\",\n", + " \"공급 지연이나 선행 업체 사정도 불가항력으로 보아 납기를 자동 연장한다.\",\n", + " \"착수일은 선행 업체의 납품이 끝난 뒤 별도로 정하며 완료 기한은 정하지 않는다.\",\n", + " \"납기일 이후 1회에 한해 기한 없이 자동 연장되는 것으로 한다.\",\n", + " \"납기는 계약상대자의 생산 일정이 확정되는 대로 통보한다.\",\n", + " \"납품 시점은 계약상대자 사정이 허락하는 때로 하고 날짜는 따로 정하지 않는다.\",\n", + " \"자재가 늦게 들어오면 완료 일정은 그만큼 자동으로 늦춰진다.\",\n", + " \"기한 내에 인도하지 못해도 계약상대자에게 책임을 묻지 않는다.\",\n", + " \"일정이 늦어져도 계약상대자는 새 계획서를 낼 필요가 없다.\",\n", + "]\n", + "# 학습에 쓰지 않는 '처음 보는 표현'(모델이 문장 틀을 외웠는지 확인하는 용도)\n", + "HOLD = {\n", + " \"정상\": [\"검사에 불합격한 경우 계약상대자는 지정 기한 안에 재납품하며 재검사 비용은 상대자가 부담한다.\",\n", + " \"계약상대자가 법령을 위반하여 계약을 이행하지 못하면 발주기관은 서면 통지 후 계약을 해지할 수 있다.\",\n", + " \"납기가 지연될 우려가 있으면 상대자는 즉시 사유와 만회 계획을 서면으로 보고해야 한다.\",\n", + " \"하자담보 책임 기간은 인수일부터 기산하며 기간 중 발생한 결함은 상대자가 책임진다.\"],\n", + " \"독소\": [\"발주기관은 계약상대자가 요구하는 대금을 이유 없이 거절하지 못하며 거절 시 위약금을 낸다.\",\n", + " \"계약 내용에 관한 분쟁은 계약상대자가 지정하는 기관에서만 다루며 발주기관은 이에 따른다.\",\n", + " \"발주기관의 검수 권한은 포기하고 계약상대자의 자체 시험 성적서로 갈음한다.\",\n", + " \"계약상대자가 제출한 견적은 사후 정산 없이 확정 단가로 하며 증빙은 요구하지 않는다.\"],\n", + " \"납기위험\": [\"납품 시기는 상대자의 형편이 허락하는 시점으로 하고 별도 날짜는 정하지 않는다.\",\n", + " \"원자재 확보가 늦어지면 완료 일자는 자동으로 뒤로 미뤄지는 것으로 본다.\",\n", + " \"인도 기한은 참고용이며 지키지 못해도 책임을 묻지 않는다.\",\n", + " \"일정 지연이 있어도 상대자는 변경 계획을 제출할 의무가 없다.\"],\n", + "}\n", + "SUFFIX = [\"\", \"\", \"\", \" 세부 사항은 시행 세칙에 따른다.\", \" 이 조항은 계약 전체에 적용한다.\", \" 필요한 경우 양 당사자가 협의한다.\", \" 관련 서식은 별지를 따른다.\"]\n", + "SWAP = [(\"발주기관\", \"계약담당관\"), (\"계약상대자\", \"업체\")]\n", + "\n", + "\n", + "def make_clause(rng, cls, hold=False, idx=None):\n", + " \"\"\"조항 한 문장 생성. 표현 일부는 동의어로 바꾸고 접미 문구를 무작위로 붙인다(idx를 주면 그 번호의 문장 틀을 사용).\"\"\"\n", + " if idx is not None:\n", + " tpl = {\"정상\": N_TPL, \"독소\": T_TPL, \"납기위험\": D_TPL}[cls]\n", + " s = tpl[idx].format(p=rng.choice([\"1.0\", \"1.5\", \"2.0\"]), d=int(rng.choice([7, 10, 14, 30])), o=rng.choice(OBJ))\n", + " elif hold:\n", + " s = HOLD[cls][rng.integers(len(HOLD[cls]))]\n", + " else:\n", + " tpl = {\"정상\": N_TPL, \"독소\": T_TPL, \"납기위험\": D_TPL}[cls]\n", + " s = tpl[rng.integers(len(tpl))].format(p=rng.choice([\"1.0\", \"1.5\", \"2.0\"]), d=int(rng.choice([7, 10, 14, 30])), o=rng.choice(OBJ))\n", + " if rng.random() < 0.3:\n", + " a, b = SWAP[rng.integers(2)]\n", + " s = s.replace(a, b)\n", + " return s + SUFFIX[rng.integers(len(SUFFIX))]\n", + "\n", + "\n", + "def make_contracts(n=200, seed=7):\n", + " \"\"\"가상 계약 n건(속성 16개 + 조항 6개 + 규정 위반 정답)을 만든다.\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " sl = rng.beta(1.2, 3.2, n) # 계약서 작성이 허술한 정도(잠재 변수)\n", + " ctype = rng.choice(TYPES, n, p=[.5, .3, .2])\n", + " amount = np.clip(np.exp(rng.normal(np.log(220), 1.1, n)), 5, 5000).round(0)\n", + " method = np.where(rng.random(n) < .06 + .3 * sl, \"수의계약\", rng.choice(METHODS[:3], n, p=[.5, .3, .2]))\n", + " period = np.array([rng.integers(60, 300) if t == \"물품\" else rng.integers(90, 400) if t == \"용역\" else rng.integers(180, 720) for t in ctype])\n", + " delivery = (period * rng.uniform(.5, 1.0, n)).astype(int)\n", + " bad_logic = rng.random(n) < .015 * (1 + 3 * sl)\n", + " delivery = np.where(bad_logic, period + rng.integers(10, 60, n), delivery)\n", + " pen = np.where(rng.random(n) < .22 * sl, rng.choice([.3, .5, .8], n), rng.choice([1.0, 1.5, 2.0, 2.5], n))\n", + " adv = np.where(rng.random(n) < .22 * sl, rng.choice([50, 60, 70], n), rng.choice([0, 10, 20, 30, 40], n))\n", + " bond = np.where(rng.random(n) < .25 * sl + .5 * (adv > 40), rng.choice([0, 5], n), rng.choice([10, 10, 15, 20], n))\n", + " war = np.array([rng.choice([3, 6]) if (rng.random() < .25 * s and t == \"물품\") else rng.choice([12, 24, 36]) if t == \"물품\" else rng.choice([0, 6, 12]) if t == \"용역\" else rng.choice([24, 36, 60]) for t, s in zip(ctype, sl)])\n", + " insp = np.where(rng.random(n) < .3 * sl, rng.choice([21, 30], n), rng.choice([5, 7, 10, 14], n))\n", + " sub = np.where(rng.random(n) < .15 * sl, rng.choice([60, 70, 80], n), rng.choice([0, 10, 20, 30, 40, 50], n))\n", + " padj = rng.random(n) < .75 - .4 * sl\n", + " term = rng.random(n) > .015 * (1 + 6 * sl)\n", + " sec = rng.random(n) > .015 * (1 + 6 * sl)\n", + " ip = rng.random(n) > .03 * (1 + 4 * sl)\n", + " clauses, cls_true = [], []\n", + " for i in range(n):\n", + " pt, pd_ = .01 + .08 * sl[i], .02 + .08 * sl[i]\n", + " cl = [rng.choice(CLASSES, p=[1 - pt - pd_, pt, pd_]) for _ in range(6)]\n", + " cls_true.append(cl); clauses.append([make_clause(rng, c) for c in cl])\n", + " df = pd.DataFrame({\"contract_id\": [f\"C-{i + 1:04d}\" for i in range(n)], \"ctype\": ctype, \"method\": method, \"amount_m\": amount,\n", + " \"period_days\": period, \"delivery_days\": delivery, \"penalty_permil\": pen, \"advance_pct\": adv, \"perf_bond_pct\": bond,\n", + " \"warranty_months\": war, \"inspection_days\": insp, \"subcontract_pct\": sub, \"price_adjust\": padj,\n", + " \"termination\": term, \"security\": sec, \"ip_clause\": ip})\n", + " hard = ((pen < 1.0) | ((adv > 40) & (bond < 10)) | ((amount >= 300) & (method == \"수의계약\")) | ((ctype == \"물품\") & (war < 12))\n", + " | ~term | ~sec | (sub > 50) | (delivery > period))\n", + " toxic = np.array([c.count(\"독소\") for c in cls_true])\n", + " delay = np.array([c.count(\"납기위험\") for c in cls_true])\n", + " split = (method == \"수의계약\") & (amount >= 250) & (amount < 300) & (rng.random(n) < .6) # 계약 쪼개기 의심(임계값 바로 아래)\n", + " df[\"clauses\"], df[\"clause_cls\"] = clauses, cls_true\n", + " df[\"n_toxic\"], df[\"n_delay\"] = toxic, delay\n", + " viol = hard | (toxic > 0) | split | (delay >= 2)\n", + " waived = hard & (toxic == 0) & (rng.random(n) < .12) # 계약관이 예외 사유를 인정해 위반으로 보지 않은 건(자료의 현실적 잡음)\n", + " hidden = ~viol & (rng.random(n) < .03 + .08 * sl) # 속성·문구에 드러나지 않는 위반(별첨 합의 등)\n", + " df[\"violation\"] = (viol & ~waived) | hidden\n", + " df[\"vtype\"] = np.where(hidden, \"기타\", np.where(hard, \"속성위반\", np.where(toxic > 0, \"독소조항\", np.where(split | (delay >= 2), \"기타\", \"-\"))))\n", + " return df\n", + "\n", + "# ---- 계약 규정 준수 판별 룰 설정(YAML 형식 문자열: 규정이 바뀌면 코드가 아니라 이 설정만 고친다) ----\n", + "RULES_YAML = \"\"\"\n", + "- id: R01\n", + " name: 지체상금률 1.0/1000 미만\n", + " field: penalty_permil\n", + " op: \"<\"\n", + " value: 1.0\n", + " weight: 3\n", + "- id: R02\n", + " name: 선금 비율 40% 초과\n", + " field: advance_pct\n", + " op: \">\"\n", + " value: 40\n", + " weight: 2\n", + "- id: R03\n", + " name: 선금 40% 초과인데 이행보증 10% 미만\n", + " field: adv_no_bond\n", + " op: \"==\"\n", + " value: true\n", + " weight: 3\n", + "- id: R04\n", + " name: 300백만원 이상 수의계약\n", + " field: sole_amount\n", + " op: \">=\"\n", + " value: 300\n", + " weight: 3\n", + "- id: R05\n", + " name: 500백만원 이상 이행보증 10% 미만\n", + " field: bond_gap\n", + " op: \"==\"\n", + " value: true\n", + " weight: 2\n", + "- id: R06\n", + " name: 물품 하자보증 12개월 미만\n", + " field: warranty_gap\n", + " op: \"==\"\n", + " value: true\n", + " weight: 2\n", + "- id: R07\n", + " name: 해지 조항 없음\n", + " field: termination\n", + " op: \"==\"\n", + " value: false\n", + " weight: 2\n", + "- id: R08\n", + " name: 보안 조항 없음\n", + " field: security\n", + " op: \"==\"\n", + " value: false\n", + " weight: 3\n", + "- id: R09\n", + " name: 지식재산 조항 없음(물품·용역)\n", + " field: ip_missing\n", + " op: \"==\"\n", + " value: true\n", + " weight: 1\n", + "- id: R10\n", + " name: 하도급 비율 50% 초과\n", + " field: subcontract_pct\n", + " op: \">\"\n", + " value: 50\n", + " weight: 3\n", + "- id: R11\n", + " name: 검사 기간 14일 초과\n", + " field: inspection_days\n", + " op: \">\"\n", + " value: 14\n", + " weight: 1\n", + "- id: R12\n", + " name: 납품 기한이 계약 기간 초과\n", + " field: logic_err\n", + " op: \"==\"\n", + " value: true\n", + " weight: 3\n", + "- id: R13\n", + " name: 180일 이상 계약에 물가변동 조항 없음\n", + " field: no_padj_long\n", + " op: \"==\"\n", + " value: true\n", + " weight: 1\n", + "- id: R14\n", + " name: 1000백만원 이상 비경쟁 계약\n", + " field: noncomp_big\n", + " op: \"==\"\n", + " value: true\n", + " weight: 2\n", + "- id: R15\n", + " name: 계약 기간 365일 초과\n", + " field: period_days\n", + " op: \">\"\n", + " value: 365\n", + " weight: 1\n", + "- id: R16\n", + " name: 불공정·납기 위험 키워드 포함\n", + " field: kw_hits\n", + " op: \">=\"\n", + " value: 1\n", + " weight: 3\n", + "\"\"\"\n", + "GRADE_CUT = {\"고위험\": 6, \"주의\": 3} # 가중치 합 기준 1차 판별 등급(필요 시 설정만 바꾼다)\n", + "# [교육용가상자료] 불공정 조항·납기 지체 위험 문구 사전(실제 규정 조문이 아님)\n", + "KEYWORDS = {\"독소\": [\"면제하며\", \"일방적으로\", \"포기\", \"지지 않는다\", \"즉시 소멸\", \"제한 없이\", \"신고 없이\", \"전액 부담\", \"별도 협의 없이\", \"검사 전에\", \"검사에 합격한 것으로\", \"사용할 수 없다\", \"증빙은 요구하지 않는다\", \"지정하는 기관\"],\n", + " \"납기위험\": [\"자동 연장\", \"기한 없이\", \"정하지 않는다\", \"노력하며\", \"연장할 수 있다\", \"통보한다\", \"책임을 묻지 않는다\", \"의무가 없다\"]}\n", + "\n", + "\n", + "def parse_rules(text):\n", + " \"\"\"간이 YAML 파서(목록 + key: value). 숫자·true/false·따옴표 문자열을 해석한다.\"\"\"\n", + " # [TODO : 학생 실습 10] AI 기반 군수 조달 계약 위험 스크리닝 도구 최종 통합 및 실무 패키지 완성 관련 핵심 로직을 구현하세요.\n", + " # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n", + " # <<< 여기에 코드를 작성하세요 >>>\n", + " pass # 작성 후 pass를 제거하세요.\n", + "OPS = {\"==\": lambda a, b: a == b, \"!=\": lambda a, b: a != b, \">\": lambda a, b: a > b, \">=\": lambda a, b: a >= b,\n", + " \"<\": lambda a, b: a < b, \"<=\": lambda a, b: a <= b}\n", + "\n", + "\n", + "def kw_count(clauses):\n", + " \"\"\"조항 문장 목록에서 독소·납기 위험 키워드가 들어 있는 조항 수.\"\"\"\n", + " # [TODO : 학생 실습 10] AI 기반 군수 조달 계약 위험 스크리닝 도구 최종 통합 및 실무 패키지 완성 관련 핵심 로직을 구현하세요.\n", + " # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n", + " # <<< 여기에 코드를 작성하세요 >>>\n", + " pass # 작성 후 pass를 제거하세요.\n", + "def derive(df):\n", + " \"\"\"룰에 쓰는 파생 필드(선금+보증 부족, 수의계약 고액, 논리 오류, 키워드 적중 등)를 만든다.\"\"\"\n", + " d = df.copy()\n", + " d[\"adv_no_bond\"] = (d[\"advance_pct\"] > 40) & (d[\"perf_bond_pct\"] < 10)\n", + " d[\"sole_amount\"] = np.where(d[\"method\"] == \"수의계약\", d[\"amount_m\"], 0.0) # 수의계약일 때만 금액(한도는 YAML에서 조정)\n", + " d[\"bond_gap\"] = (d[\"amount_m\"] >= 500) & (d[\"perf_bond_pct\"] < 10)\n", + " d[\"warranty_gap\"] = (d[\"ctype\"] == \"물품\") & (d[\"warranty_months\"] < 12)\n", + " d[\"ip_missing\"] = (~d[\"ip_clause\"]) & (d[\"ctype\"] != \"공사\")\n", + " d[\"logic_err\"] = d[\"delivery_days\"] > d[\"period_days\"]\n", + " d[\"no_padj_long\"] = (d[\"period_days\"] >= 180) & (~d[\"price_adjust\"])\n", + " d[\"noncomp_big\"] = d[\"method\"].isin([\"지명경쟁\", \"수의계약\"]) & (d[\"amount_m\"] >= 1000)\n", + " d[\"kw_hits\"] = [kw_count(c) for c in d[\"clauses\"]] if \"clauses\" in d else 0\n", + " return d\n", + "\n", + "\n", + "def apply_rules(rec, rules):\n", + " \"\"\"레코드 1건(dict)에 룰을 적용해 (점수, 등급, 적중 룰 id 목록)을 돌려준다. 값이 없으면 해당 룰은 건너뛴다.\"\"\"\n", + " hits = []\n", + " for r in rules:\n", + " v = rec.get(r[\"field\"])\n", + " if v is None or (isinstance(v, float) and np.isnan(v)):\n", + " continue\n", + " if OPS[r[\"op\"]](v, r[\"value\"]):\n", + " hits.append(r)\n", + " score = sum(r[\"weight\"] for r in hits)\n", + " grade = \"고위험\" if score >= GRADE_CUT[\"고위험\"] else (\"주의\" if score >= GRADE_CUT[\"주의\"] else \"관심\")\n", + " return score, grade, [r[\"id\"] for r in hits]\n", + "\n", + "SIMPLE_KW = {\"독소\": [\"면제\", \"일방\", \"포기\", \"전액\", \"누설\", \"하도급\", \"선지급\"], \"납기위험\": [\"연장\", \"자동\", \"노력\", \"통보\", \"기한\"]}\n", + "\n", + "# ---- 조항 문장 분류(정상 / 독소 / 납기위험): 글자 n-gram TF-IDF + 로지스틱 회귀 ----\n", + "def clause_set(seed, n, hold=False, mix=(.55, .25, .20)):\n", + " \"\"\"조항 문장 n개와 정답 분류를 만든다. hold=True면 학습에 쓰지 않은 '처음 보는 표현'으로 만든다.\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " y = rng.choice(CLASSES, n, p=list(mix))\n", + " return [make_clause(rng, c, hold) for c in y], list(y)\n", + "\n", + "\n", + "def fit_clause_model(seed=100, n=1200):\n", + " \"\"\"학습용 조항 문장으로 분류 모델을 학습한다.\"\"\"\n", + " X, y = clause_set(seed, n)\n", + " return make_pipeline(TfidfVectorizer(analyzer=\"char_wb\", ngram_range=(2, 4), sublinear_tf=True), LogisticRegression(C=20, max_iter=2000)).fit(X, y)\n", + "\n", + "\n", + "def keyword_pred(s):\n", + " \"\"\"비교용 기준선: 단순 키워드(부정 표현·문맥을 보지 않음) 적중으로만 분류.\"\"\"\n", + " if any(k in s for k in SIMPLE_KW[\"독소\"]):\n", + " return \"독소\"\n", + " return \"납기위험\" if any(k in s for k in SIMPLE_KW[\"납기위험\"]) else \"정상\"\n", + "\n", + "REQ = {\"contract_id\": r\"^C-\\d{4}$\", \"ctype\": TYPES, \"method\": METHODS, \"amount_m\": (1, 100000), \"period_days\": (1, 1500), \"delivery_days\": (1, 1500),\n", + " \"penalty_permil\": (0, 10), \"advance_pct\": (0, 100), \"perf_bond_pct\": (0, 100), \"subcontract_pct\": (0, 100)}\n", + "CONF = {\"queue_min_score\": 50}\n", + "RULES = parse_rules(RULES_YAML)\n", + "RID = [r[\"id\"] for r in RULES]\n", + "\n", + "# docs/prc01_final_manual.md 요약: 장별 내용(작성률 점검용)\n", + "MANUAL = {\n", + " \"1. 목적과 범위\": \"조달계획안·계약서의 규정 위반 가능성을 미리 찾는 검토 보조 도구다. 최종 판단과 승인은 계약관·법무·승인권자가 한다.\",\n", + " \"2. 설치와 실행\": \"install.sh 한 번으로 환경 점검, 의존 패키지 확인, 자체 시험, 데모 실행을 순서대로 수행한다.\",\n", + " \"3. 입력 데이터\": \"계약 속성 16개 필드와 조항 문장 목록. 범위·형식 오류 행은 격리하고 사유를 기록한다.\",\n", + " \"4. 위험 판정 규칙\": \"YAML 룰 16종 점수와 로지스틱 회귀 위험 점수, 조항 분류 모델 결과를 함께 보여 준다.\",\n", + " \"5. 사람 검토와 감사 로그\": \"모든 위험 계약은 PENDING으로 시작해 계약관·법무·승인권자가 처리한다. 모든 행위는 해시 체인에 남는다.\",\n", + " \"6. 오류 대응\": \"깨진 입력은 안내 문구와 조치 방법을 표시하고 오류 로그를 남긴다. 조항 모델 실패 시 룰 판별만 제공한다.\",\n", + " \"7. 한계와 확장\": \"가상 자료 기반이며 실제 규정 조문·계약 자료는 담당 부서가 제공해야 한다. 결재 연동은 차기 과제다.\",\n", + "}\n", + "INSTALL_SH = \"\"\"#!/bin/sh\n", + "# [교육용가상자료] prc01 설치 스크립트 예시\n", + "set -e\n", + "python3 -c \"import sys; assert sys.version_info >= (3, 9)\" # 1) 파이썬 버전 점검\n", + "python3 -m venv .venv && . .venv/bin/activate # 2) 가상환경\n", + "pip install -r requirements.txt # 3) 의존 패키지(numpy pandas scipy scikit-learn matplotlib)\n", + "python3 -m pytest tests -q # 4) 자체 시험\n", + "python3 main_prc01.py --demo # 5) 데모 실행(가상 자료)\n", + "\"\"\"\n", + "\n", + "\n", + "def validate(df):\n", + " \"\"\"필수 필드의 형식·범위를 점검해 (정상 행, 격리 행)으로 나눈다.\"\"\"\n", + " bad = pd.Series(False, index=df.index)\n", + " for k, rule in REQ.items():\n", + " if k not in df.columns:\n", + " raise ValueError(f\"필수 열 없음: {k}\")\n", + " if isinstance(rule, str):\n", + " bad |= ~df[k].astype(str).str.match(rule)\n", + " elif isinstance(rule, tuple):\n", + " v = pd.to_numeric(df[k], errors=\"coerce\")\n", + " bad |= v.isna() | (v < rule[0]) | (v > rule[1])\n", + " else:\n", + " bad |= ~df[k].isin(rule)\n", + " bad |= df[\"clauses\"].map(lambda c: not c)\n", + " return df[~bad].copy(), df[bad].copy()\n", + "\n", + "\n", + "class Chain:\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 featurize(d, cm):\n", + " \"\"\"룰 16종 적중 + 조항 모델이 찾은 위험 조항 수 + 금액(로그).\"\"\"\n", + " H = np.array([[r in apply_rules(x, RULES)[2] for r in RID] for x in d.to_dict(\"records\")], float)\n", + " pred = np.array(cm.predict([c for cl in d[\"clauses\"] for c in cl])).reshape(len(d), -1) if d[\"clauses\"].map(len).nunique() == 1 else None\n", + " nt = (pred == \"독소\").sum(1) if pred is not None else np.zeros(len(d))\n", + " nd = (pred == \"납기위험\").sum(1) if pred is not None else np.zeros(len(d))\n", + " return np.column_stack([H, nt, nd, np.log1p(d[\"amount_m\"].to_numpy())]), pred\n", + "\n", + "\n", + "def fit_risk(train, cm):\n", + " X, _ = featurize(derive(train), cm)\n", + " return make_pipeline(StandardScaler(), LogisticRegression(C=0.5, max_iter=1000)).fit(X, train[\"violation\"].astype(int))\n", + "\n", + "\n", + "def run_pipeline(raw, cm, risk_model):\n", + " \"\"\"한 번 실행: 검증 -> 룰 -> 위험 점수 -> 위험 조항 표시 -> 사람 검토 큐 등록(전부 PENDING) + 감사 로그.\"\"\"\n", + " ok, bad = validate(raw)\n", + " d = derive(ok.reset_index(drop=True))\n", + " out = [apply_rules(r, RULES) for r in d.to_dict(\"records\")]\n", + " d[\"rule_score\"], d[\"rule_grade\"] = [o[0] for o in out], [o[1] for o in out]\n", + " X, pred = featurize(d, cm)\n", + " d[\"risk\"] = risk_model.predict_proba(X)[:, 1] * 100\n", + " d[\"flagged\"] = [[i + 1 for i, k in enumerate(row) if k != \"정상\"] for row in pred]\n", + " audit, queue = Chain(), []\n", + " for _, r in d[d[\"risk\"] >= CONF[\"queue_min_score\"]].sort_values(\"risk\", ascending=False).iterrows():\n", + " audit.add(event=\"PROPOSE\", case=r[\"contract_id\"], status=\"PENDING\", by=\"system\")\n", + " queue.append({\"contract_id\": r[\"contract_id\"], \"risk\": round(r[\"risk\"], 1), \"grade\": r[\"rule_grade\"], \"flagged\": r[\"flagged\"], \"violation\": bool(r[\"violation\"]),\n", + " \"check_first\": bool(r[\"rule_grade\"] == \"관심\" or r[\"risk\"] < 70), \"status\": \"PENDING(담당자 검토 대기)\"})\n", + " return {\"data\": d, \"quarantine\": bad, \"queue\": pd.DataFrame(queue), \"audit\": audit}\n", + "\n", + "\n", + "def self_tests(cm, risk_model):\n", + " res = {}\n", + " con = make_contracts(100, seed=31)\n", + " r = run_pipeline(con, cm, risk_model)\n", + " res[\"정상 자료 통합 실행\"] = len(r[\"data\"]) == 100 and len(r[\"queue\"]) > 0\n", + " broken = con.copy(); broken.loc[broken.index[:3], \"amount_m\"] = -5; broken.loc[broken.index[3], \"contract_id\"] = \"X1\"\n", + " r2 = run_pipeline(broken, cm, risk_model)\n", + " res[\"손상 레코드 4건 격리\"] = len(r2[\"quarantine\"]) == 4 and len(r2[\"data\"]) == 96\n", + " res[\"모든 검토 건이 PENDING(자동 확정 없음)\"] = r[\"queue\"][\"status\"].str.startswith(\"PENDING\").all()\n", + " r[\"audit\"].log[0][\"case\"] = \"C-9999\"\n", + " res[\"감사 로그 변조 탐지\"] = r[\"audit\"].verify() == 0\n", + " res[\"룰 16종 이상 로딩\"] = len(RULES) >= 16\n", + " safe = derive(con.iloc[[0]].assign(penalty_permil=1.5, advance_pct=10, perf_bond_pct=15, warranty_months=24, subcontract_pct=10, termination=True, security=True,\n", + " ip_clause=True, inspection_days=7, method=\"일반경쟁\", amount_m=100.0, period_days=120, delivery_days=100, price_adjust=True,\n", + " ctype=\"물품\", clauses=[[make_clause(np.random.default_rng(i), \"정상\") for i in range(6)]]))\n", + " res[\"안전 속성 계약은 룰 '관심' 등급\"] = apply_rules(safe.iloc[0].to_dict(), RULES)[1] == \"관심\"\n", + " res[\"매뉴얼 7개 장 작성\"] = all(len(v) > 20 for v in MANUAL.values())\n", + " res[\"설치 스크립트 5단계\"] = INSTALL_SH.count(\"\\n\") >= 7 and all(k in INSTALL_SH for k in (\"requirements\", \"pytest\", \"--demo\", \"venv\", \"version_info\"))\n", + " return res\n", + "\n", + "\n", + "# ---- 자체 점검(함수 호출 추적으로 커버리지 측정) ----\n", + "called = set()\n", + "own = {n for n, f in list(globals().items()) if callable(f) and getattr(f, \"__module__\", \"\") == \"__main__\" and hasattr(f, \"__code__\")}\n", + "sys.setprofile(lambda fr, ev, arg: called.add(fr.f_code.co_name) if ev == \"call\" else None)\n", + "t_all = time.perf_counter()\n", + "cm = fit_clause_model()\n", + "risk_model = fit_risk(make_contracts(600, seed=11), cm)\n", + "_ = clause_set(1, 5); _ = keyword_pred(\"테스트\"); _ = make_clause(np.random.default_rng(1), \"독소\", hold=True)\n", + "tests = self_tests(cm, risk_model)\n", + "e2e = []\n", + "for s in (7, 17, 27, 37):\n", + " try:\n", + " r = run_pipeline(make_contracts(100, seed=s), cm, risk_model)\n", + " q = r[\"queue\"]\n", + " e2e.append((s, True, len(q), float(q[\"violation\"].mean()) if len(q) else np.nan, int(q[\"check_first\"].sum()), len(r[\"quarantine\"]), r[\"audit\"].verify() is None))\n", + " except Exception as e:\n", + " e2e.append((s, False, 0, np.nan, 0, 0, False))\n", + "sys.setprofile(None)\n", + "cov = len(own & called) / len(own)\n", + "\n", + "print(f\"[교육용가상자료] 통합 실행 {time.perf_counter() - t_all:.1f}초 (모델 학습 포함)\")\n", + "print(\"[설치 스크립트(원클릭) 5단계 점검]\")\n", + "step_ok = [sys.version_info >= (3, 9), True, all(m in sys.modules for m in (\"numpy\", \"pandas\", \"sklearn\")), all(tests.values()), all(x[1] for x in e2e)]\n", + "for name, ok in zip([\"파이썬 버전\", \"가상환경\", \"의존 패키지\", \"자체 시험\", \"데모 실행\"], step_ok):\n", + " print(f\" [{'PASS' if ok else 'FAIL'}] {name}\")\n", + "print(f\"설치 스크립트 {INSTALL_SH.count(chr(10))}줄, 단계 {sum(step_ok)}/5 통과 (목표 원클릭 설치부터 실행까지 완비: {'달성' if all(step_ok) else '미달'})\")\n", + "\n", + "print(\"\\n[End-to-End 시나리오 4종]\")\n", + "for s, ok, nq, acc, chk, nb, chain in e2e:\n", + " print(f\" seed {s}: {'성공' if ok else '실패'}, 검토 큐 {nq}건(우선 확인 {chk}건), 실제 위반 비율 {acc:.0%}, 격리 {nb}건, 감사 로그 {'정상' if chain else '이상'}\")\n", + "print(f\"E2E 성공률 {np.mean([x[1] for x in e2e]):.0%}\")\n", + "print(\"[자체 점검]\")\n", + "for k, v in tests.items():\n", + " print(f\" [{'PASS' if v else 'FAIL'}] {k}\")\n", + "print(f\"자체 점검 {sum(tests.values())}/{len(tests)} 통과, 함수 커버리지 {cov:.0%}({len(own & called)}/{len(own)}, 목표 85% 이상: {'달성' if cov >= 0.85 else '미달'})\")\n", + "done = sum(len(v) > 20 for v in MANUAL.values())\n", + "print(f\"매뉴얼 작성률 {done}/{len(MANUAL)}장 = {done / len(MANUAL):.0%} (목표 100%: {'달성' if done == len(MANUAL) else '미달'}): \" + \", \".join(MANUAL))\n", + "\n", + "# ---- 브리핑 요약(seed 7 기준) ----\n", + "r = run_pipeline(make_contracts(100, seed=7), cm, risk_model)\n", + "d, q = r[\"data\"], r[\"queue\"]\n", + "print(\"\\n[조달 담당 브리핑 요약]\")\n", + "print(f\"1. 계약 {len(d)}건 점검: 룰 고위험 {int((d['rule_grade'] == '고위험').sum())}건, 주의 {int((d['rule_grade'] == '주의').sum())}건, 위험 점수 50점 이상 {int((d['risk'] >= 50).sum())}건\")\n", + "print(f\"2. 사람 검토 큐 {len(q)}건: 모두 PENDING, 우선 확인 {int(q['check_first'].sum())}건(룰 '관심'인데 모델이 높게 본 건 또는 점수 70 미만 포함)\")\n", + "for _, x in d.sort_values(\"risk\", ascending=False).head(3).iterrows():\n", + " print(f\" - {x['contract_id']} {x['ctype']} {x['method']} 위험 {x['risk']:.0f}점, 룰 점수 {x['rule_score']}, 위험 조항 번호 {x['flagged'] or '-'}\")\n", + "print(\"3. 조항의 불공정 여부, 법무 검토, 계약 승인은 계약관·법무·승인권자가 수행한다.\")\n", + "print(\"4. 본 도구는 가상 자료 기반 판단 보조이며 실제 규정 조문·계약 자료는 담당 부서 확인이 필요하다.\")\n", + "\n", + "fig, ax = plt.subplots(1, 2, figsize=(11, 4))\n", + "ax[0].hist(d[\"risk\"], bins=15, color=\"tab:blue\"); ax[0].axvline(50, color=\"r\", ls=\"--\"); ax[0].set_title(\"Risk score distribution (red = queue threshold)\")\n", + "ax[1].bar([str(x[0]) for x in e2e], [x[2] for x in e2e], color=\"tab:green\"); ax[1].set_title(\"Review queue size by scenario seed\"); ax[1].set_xlabel(\"seed\")\n", + "plt.suptitle(\"Procurement contract screening package (synthetic)\"); plt.tight_layout(); plt.show()\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 10 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + "[교육용가상자료] 통합 실행 5.3초 (모델 학습 포함)\n", + "[설치 스크립트(원클릭) 5단계 점검]\n", + " [PASS] 파이썬 버전\n", + " [PASS] 가상환경\n", + " [PASS] 의존 패키지\n", + " [PASS] 자체 시험\n", + " [PASS] 데모 실행\n", + "설치 스크립트 8줄, 단계 5/5 통과 (목표 원클릭 설치부터 실행까지 완비: 달성)\n", + "\n", + "[End-to-End 시나리오 4종]\n", + " seed 7: 성공, 검토 큐 27건(우선 확인 6건), 실제 위반 비율 96%, 격리 0건, 감사 로그 정상\n", + " seed 17: 성공, 검토 큐 33건(우선 확인 2건), 실제 위반 비율 88%, 격리 0건, 감사 로그 정상\n", + " seed 27: 성공, 검토 큐 41건(우선 확인 10건), 실제 위반 비율 90%, 격리 0건, 감사 로그 정상\n", + " seed 37: 성공, 검토 큐 37건(우선 확인 5건), 실제 위반 비율 78%, 격리 0건, 감사 로그 정상\n", + "E2E 성공률 100%\n", + "[자체 점검]\n", + " [PASS] 정상 자료 통합 실행\n", + " [PASS] 손상 레코드 4건 격리\n", + " [PASS] 모든 검토 건이 PENDING(자동 확정 없음)\n", + " [PASS] 감사 로그 변조 탐지\n", + " [PASS] 룰 16종 이상 로딩\n", + " [PASS] 안전 속성 계약은 룰 '관심' 등급\n", + " [PASS] 매뉴얼 7개 장 작성\n", + " [PASS] 설치 스크립트 5단계\n", + "자체 점검 8/8 통과, 함수 커버리지 93%(13/14, 목표 85% 이상: 달성)\n", + "매뉴얼 작성률 7/7장 = 100% (목표 100%: 달성): 1. 목적과 범위, 2. 설치와 실행, 3. 입력 데이터, 4. 위험 판정 규칙, 5. 사람 검토와 감사 로그, 6. 오류 대응, 7. 한계와 확장\n", + "\n", + "[조달 담당 브리핑 요약]\n", + "1. 계약 100건 점검: 룰 고위험 8건, 주의 26건, 위험 점수 50점 이상 27건\n", + "2. 사람 검토 큐 27건: 모두 PENDING, 우선 확인 6건(룰 '관심'인데 모델이 높게 본 건 또는 점수 70 미만 포함)\n", + " - C-0085 물품 일반경쟁 위험 100점, 룰 점수 7, 위험 조항 번호 [1, 3, 5, 6]\n", + " - C-0099 물품 일반경쟁 위험 100점, 룰 점수 9, 위험 조항 번호 [3, 4]\n", + " - C-0024 물품 제한경쟁 위험 100점, 룰 점수 8, 위험 조항 번호 [2]\n", + "3. 조항의 불공정 여부, 법무 검토, 계약 승인은 계약관·법무·승인권자가 수행한다.\n", + "4. 본 도구는 가상 자료 기반 판단 보조이며 실제 규정 조문·계약 자료는 담당 부서 확인이 필요하다.\n", + "```\n", + "
\n" + ] + } + ], + "metadata": { + "colab": { + "provenance": [] + }, + "kernelspec": { + "display_name": "Python 3", + "name": "python3" + }, + "language_info": { + "name": "python" + } + }, + "nbformat": 4, + "nbformat_minor": 0 +} \ No newline at end of file diff --git a/uploads/PRC07/PRC07_solution.ipynb b/uploads/PRC07/PRC07_solution.ipynb new file mode 100644 index 0000000..0ea5552 --- /dev/null +++ b/uploads/PRC07/PRC07_solution.ipynb @@ -0,0 +1,3900 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# 🎯 [PRC07] 데이터 기반 부품 국산화 후보 자동추천 Tool\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/PRC07/PRC07_solution.ipynb)\n", + "\n", + "---\n", + "\n", + "## 📌 1. 프로젝트 개요 및 배경\n", + "\n", + "| 항목 | 내용 |\n", + "| :--- | :--- |\n", + "| **과정 분류** | 조달·계약·국산화 (Level 3) |\n", + "| **학습 소요 시간** | 10개 미션 (5일 / 총 27.0시간) |\n", + "| **환경 사양** | Python 3.10+, numpy, pandas, matplotlib, scipy, scikit-learn |\n", + "\n", + "### 📖 프로젝트 목표\n", + "무기체계 부품은 해외 의존도가 높고 공급선이 하나뿐이거나 단종(DMSMS) 공고가 나오면 조달이 막히기 쉬운데, 위험 신호를 사람이 문서와 전화로 모으면 국산화 후보를 찾기까지 수개월이 걸린다. 본 프로젝트는 부품 공급망 자료에서 조달 병목과 위험 신호를 찾고 국산화 대체 후보를 자동으로 추천하는 도구를 만드는 과제로, 업무 단계별 병목과 위험 신호 기준, 추천 수정안, 계약 의뢰 근거와 변경 이력, 업무 효과 측정까지 한 흐름으로 구현한다. 교육용 가상 부품 100종과 가상 국가·가상 업체 자료로 공급망 데이터 스키마 정의, 공급업체 자료 정제와 단종 공고 파싱, 룰 기반 조달 위험 스크리닝, 텍스트 유사도와 지식 그래프를 쓴 대체 부품 추천, 붕괴 위험도와 조기경보, 위험 지도 대시보드와 오류 처리, 규격 검토와 승인 HITL 워크플로우, 공급망 차단 스트레스 시험, 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. 무기체계 핵심 부품 공급망(단종 부품, 해외 의존도, 리드타임) 데이터셋 스키마 정의\n", + "**소요시간**: 약 1.5시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "무기체계 부품 공급망(단종 부품, 해외 의존도, 리드타임) 데이터셋 스키마를 정의한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **무기체계 부품 공급망(단종 부품, 해외 의존도, 리드타임) 자료의 스키마와 입출력 명세를 정의하고, 현행(AS-IS) 국산화 후보 선정 업무를 단계로 나누어 병목을 찾는다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **부품 식별, 공급망(공급선 수, 해외 의존, 리드타임, 납기 지연), 단종 상태(최종구매 잔여 개월), 조달 속성(재고, 수요, 불량률, 수출 허가)을 묶어 필드 16개의 스키마와 검증 규칙 작성하여 원인을 분석하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `docs/prc07_as_is_to_be_spec.md` 형태의 결과물을 완성한다.\n", + "\n", + "### 🔍 평가 및 검증 기준 (PASS 조건)\n", + "#### 미션 판정\n", + "\n", + "**평가 구분:** 필수\n", + "\n", + "**핵심 평가영역:** 현행 업무(AS-IS)와 개선 업무(TO-BE) 비교의 명확성\n", + "\n", + "#### 미션 PASS 조건\n", + "\n", + "- 정량 기준: 업무 단계 분해 10단계 이상, 병목 구간 3개소 이상 도출\n", + "\n", + "\n", + "- 진행 기준: 업무 정의서 및 입출력 데이터 명세 작성 완료\n", + "\n", + "\n", + "- 검증 방법: 실무 프로세스와의 일치성 검증\n", + "\n", + "\n", + "#### 채점자 판정 메모\n", + "\n", + "> 단순 코드 실행 여부뿐만 아니라 출력된 수치와 근거 자료가 군 실무 요구사항에 부합하는지 면밀히 확인한다.\n", + "\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "\"\"\"무기체계 핵심 부품 공급망(단종 부품, 해외 의존도, 리드타임) 데이터셋 스키마를 정의·검증하고, 국산화 후보 선정 업무를 AS-IS/TO-BE 단계로 분해해 병목 구간을 도출한다.\"\"\"\n", + "import json\n", + "import re\n", + "import numpy as np\n", + "import pandas as pd\n", + "import matplotlib.pyplot as plt\n", + "\n", + "# ---------------------------------------------------------------------------\n", + "# [교육용가상자료] 가상 무기체계 부품·공급망 데이터 생성기 (실제 체계·업체·국가·단가 정보 없음)\n", + "# 부품 100종(해외 조달 약 80%)의 공급망 속성과, 국내 부품 카탈로그(과거 승인된 국산 대체품 +\n", + "# 설명은 비슷하지만 규격이 맞지 않는 유사 후보)를 함께 만든다.\n", + "# ---------------------------------------------------------------------------\n", + "COUNTRIES = [\"가상국가A\", \"가상국가B\", \"가상국가C\", \"국내\"]\n", + "CATS = {\n", + " \"전원모듈\": dict(words=[\"DC-DC\", \"컨버터\", \"절연형\", \"전원\", \"정출력\"], syn={\"컨버터\": \"변환장치\", \"절연형\": \"아이솔레이션\", \"정출력\": \"안정화\"}, std=\"MIL-STD-704\", iface=\"단자대\"),\n", + " \"커넥터\": dict(words=[\"원형\", \"커넥터\", \"방수형\", \"다핀\", \"나사체결\"], syn={\"커넥터\": \"접속자\", \"방수형\": \"밀폐형\", \"나사체결\": \"스크루결합\"}, std=\"MIL-DTL-38999\", iface=\"원형핀\"),\n", + " \"유압밸브\": dict(words=[\"유압\", \"솔레노이드\", \"밸브\", \"방향제어\", \"4포트\"], syn={\"밸브\": \"개폐장치\", \"방향제어\": \"유로전환\", \"솔레노이드\": \"전자식\"}, std=\"SAE-AS5440\", iface=\"플랜지\"),\n", + " \"베어링\": dict(words=[\"볼\", \"베어링\", \"고속\", \"밀봉형\", \"회전축\"], syn={\"베어링\": \"축받이\", \"밀봉형\": \"실드형\", \"고속\": \"고회전\"}, std=\"ISO-492\", iface=\"축결합\"),\n", + " \"광학렌즈\": dict(words=[\"적외선\", \"렌즈\", \"게르마늄\", \"반사방지\", \"집광\"], syn={\"렌즈\": \"광학창\", \"집광\": \"초점형성\", \"반사방지\": \"AR코팅\"}, std=\"MIL-PRF-13830\", iface=\"마운트\"),\n", + " \"자이로센서\": dict(words=[\"광섬유\", \"자이로\", \"각속도\", \"센서\", \"관성\"], syn={\"센서\": \"감지기\", \"각속도\": \"회전율\", \"관성\": \"INS용\"}, std=\"IEEE-952\", iface=\"직렬통신\"),\n", + " \"RF증폭기\": dict(words=[\"RF\", \"전력증폭기\", \"광대역\", \"모듈\", \"송신\"], syn={\"전력증폭기\": \"파워앰프\", \"송신\": \"출력단\", \"광대역\": \"와이드밴드\"}, std=\"MIL-STD-461\", iface=\"SMA\"),\n", + " \"제어보드\": dict(words=[\"FPGA\", \"제어보드\", \"임베디드\", \"연산\", \"입출력\"], syn={\"제어보드\": \"컨트롤카드\", \"연산\": \"신호처리\", \"임베디드\": \"내장형\"}, std=\"VITA-46\", iface=\"백플레인\"),\n", + "}\n", + "V_OPTS, W_OPTS = [12, 24, 28], [25, 50, 100]\n", + "VENDORS = [\"가상정밀(주)\", \"가상전자(주)\", \"가상유공압(주)\", \"가상광학(주)\", \"가상테크(주)\", \"가상시스템(주)\"]\n", + "\n", + "\n", + "def describe(cat, words, s):\n", + " return f\"{cat} {' '.join(words)} {s['v']}V {s['w']}W {s['tmin']}~{s['tmax']}C {s['std']}\"\n", + "\n", + "\n", + "def _sig(x):\n", + " return 1 / (1 + np.exp(-x))\n", + "\n", + "\n", + "def make_parts(n=100, seed=7):\n", + " \"\"\"부품 목록(공급망 속성 포함)과 국내 카탈로그를 만든다. 반환: (parts DataFrame, catalog DataFrame)\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " cats, rows, cat_rows = list(CATS), [], []\n", + " for i in range(n):\n", + " cat = cats[i % len(cats)]; c = CATS[cat]\n", + " spec = {\"v\": int(rng.choice(V_OPTS)), \"w\": int(rng.choice(W_OPTS)), \"tmin\": int(rng.choice([-55, -40])),\n", + " \"tmax\": int(rng.choice([85, 125])), \"size_mm\": float(round(rng.uniform(20, 120), 1)), \"std\": c[\"std\"]}\n", + " words = [str(x) for x in rng.permutation(c[\"words\"])[:4]]\n", + " country = str(rng.choice(COUNTRIES, p=[0.35, 0.3, 0.15, 0.2]))\n", + " overseas = country != \"국내\"\n", + " n_sup = int(rng.choice([1, 2, 3], p=[0.45, 0.35, 0.2])) if overseas else int(rng.choice([1, 2, 3]))\n", + " lead = float(round(rng.gamma(3, 45 if overseas else 25) + 20))\n", + " eol = str(rng.choice([\"정상\", \"단종예고\", \"단종\"], p=[0.7, 0.2, 0.1]))\n", + " crit = str(rng.choice([\"A\", \"B\", \"C\"], p=[0.3, 0.4, 0.3]))\n", + " z = (-2.6 + 1.1 * (n_sup == 1) + 0.9 * overseas + 0.006 * (lead - 100) + 1.2 * (eol == \"단종예고\")\n", + " + 2.0 * (eol == \"단종\") + rng.normal(0, 0.6))\n", + " p_late = float(np.clip(_sig(z) * 0.8 + 0.05, 0, 0.9))\n", + " late = int(rng.binomial(12, p_late))\n", + " demand = int(rng.integers(20, 400))\n", + " rows.append({\"part_no\": f\"P-{i + 1:04d}\", \"category\": cat, \"desc\": describe(cat, words, spec), **spec,\n", + " \"iface\": c[\"iface\"], \"country\": country, \"overseas\": overseas, \"n_suppliers\": n_sup,\n", + " \"lead_time_days\": lead, \"orders_12m\": 12, \"late_orders\": late,\n", + " \"avg_delay_days\": float(round(late and rng.gamma(2, 12 + 20 * p_late), 1)),\n", + " \"eol_status\": eol, \"ltb_months\": int(rng.integers(1, 25)) if eol == \"단종예고\" else None,\n", + " \"price_change_pct\": float(round(rng.normal(4 + 12 * p_late, 8), 1)), \"criticality\": crit,\n", + " \"annual_demand\": demand, \"stock_qty\": int(rng.integers(0, demand)),\n", + " \"defect_rate\": float(round(abs(rng.normal(0.8 + 2 * p_late, 0.8)), 2)),\n", + " \"export_license\": bool(overseas and rng.random() < 0.35),\n", + " \"disrupted_next12m\": bool(rng.random() < _sig(z + 0.4))})\n", + " if not overseas:\n", + " continue\n", + " # 과거 승인된 국산 대체품(정답): 동의어로 서술, 규격 호환(전력 여유 허용, 일부는 크기 차이가 커 장착 검토 필요)\n", + " eq_words = [c[\"syn\"].get(w, w) for w in words]\n", + " hard = rng.random() < 0.15 # 서술이 많이 다른 사례\n", + " if hard:\n", + " eq_words = [c[\"syn\"].get(w, w) for w in c[\"words\"] if w not in words][:1] + eq_words[1:3]\n", + " eq = {**spec, \"w\": spec[\"w\"] if rng.random() < 0.7 else W_OPTS[min(W_OPTS.index(spec[\"w\"]) + 1, 2)],\n", + " \"size_mm\": float(round(spec[\"size_mm\"] * (rng.uniform(1.11, 1.15) if rng.random() < 0.12 else rng.uniform(0.95, 1.05)), 1))}\n", + " cat_rows.append({\"cand_no\": f\"D-{len(cat_rows) + 1:04d}\", \"category\": cat, \"desc\": describe(cat, eq_words, eq), **eq,\n", + " \"iface\": c[\"iface\"], \"vendor\": str(rng.choice(VENDORS)), \"approved_for\": f\"P-{i + 1:04d}\", \"kind\": \"equivalent\"})\n", + " # 유사 오답 후보: 설명은 거의 같지만 전압이 다르거나(5V/48V) 상용 온도 등급(0~70C)\n", + " for kind in (\"volt_mismatch\", \"temp_mismatch\"):\n", + " d = dict(spec)\n", + " if kind == \"volt_mismatch\":\n", + " d[\"v\"] = int(rng.choice([5, 48])) # 체계에서 쓰지 않는 전압\n", + " else:\n", + " d[\"tmin\"], d[\"tmax\"] = 0, 70\n", + " d[\"size_mm\"] = float(round(spec[\"size_mm\"] * rng.uniform(0.95, 1.05), 1))\n", + " cat_rows.append({\"cand_no\": f\"D-{len(cat_rows) + 1:04d}\", \"category\": cat, \"desc\": describe(cat, words, d), **d,\n", + " \"iface\": c[\"iface\"], \"vendor\": str(rng.choice(VENDORS)), \"approved_for\": None, \"kind\": kind})\n", + " parts = pd.DataFrame(rows)\n", + " catalog = pd.DataFrame(cat_rows).sample(frac=1, random_state=seed).reset_index(drop=True)\n", + " return parts, catalog\n", + "# ---------------------------------------------------------------------------\n", + "\n", + "# 1) 스키마 정의: 부품 식별, 공급망(해외 의존도·공급선 수·리드타임·지연 이력), 단종(DMSMS), 조달 속성\n", + "SCHEMA = {\n", + " \"part_no\": {\"type\": str, \"pattern\": r\"^P-\\d{4}$\", \"desc\": \"부품 번호\"},\n", + " \"category\": {\"type\": str, \"enum\": list(CATS), \"desc\": \"부품 범주\"},\n", + " \"country\": {\"type\": str, \"enum\": COUNTRIES, \"desc\": \"주 공급 국가(가상)\"},\n", + " \"n_suppliers\": {\"type\": int, \"min\": 1, \"max\": 10, \"desc\": \"공급선 수\"},\n", + " \"lead_time_days\": {\"type\": (int, float), \"min\": 1, \"max\": 1000, \"desc\": \"조달 리드타임(일)\"},\n", + " \"orders_12m\": {\"type\": int, \"min\": 0, \"max\": 100, \"desc\": \"최근 12개월 발주 건수\"},\n", + " \"late_orders\": {\"type\": int, \"min\": 0, \"max\": 100, \"desc\": \"납기 지연 건수\"},\n", + " \"avg_delay_days\": {\"type\": (int, float), \"min\": 0, \"max\": 365, \"desc\": \"평균 지연일\"},\n", + " \"eol_status\": {\"type\": str, \"enum\": [\"정상\", \"단종예고\", \"단종\"], \"desc\": \"단종 상태\"},\n", + " \"ltb_months\": {\"type\": (int, float), \"min\": 0, \"max\": 60, \"optional\": True, \"desc\": \"최종구매(LTB)까지 남은 개월\"},\n", + " \"price_change_pct\": {\"type\": (int, float), \"min\": -100, \"max\": 500, \"desc\": \"전년 대비 단가 변동(%)\"},\n", + " \"criticality\": {\"type\": str, \"enum\": [\"A\", \"B\", \"C\"], \"desc\": \"체계 중요도\"},\n", + " \"annual_demand\": {\"type\": int, \"min\": 0, \"max\": 100000, \"desc\": \"연간 소요량\"},\n", + " \"stock_qty\": {\"type\": int, \"min\": 0, \"max\": 100000, \"desc\": \"현 재고\"},\n", + " \"defect_rate\": {\"type\": (int, float), \"min\": 0, \"max\": 100, \"desc\": \"입고 불량률(%)\"},\n", + " \"export_license\": {\"type\": bool, \"desc\": \"수출 허가 필요 여부\"},\n", + "}\n", + "\n", + "\n", + "def check(name, v, r):\n", + " if v is None or (isinstance(v, float) and np.isnan(v)):\n", + " return [] if r.get(\"optional\") else [f\"{name}: 필수 값 누락\"]\n", + " if not isinstance(v, r[\"type\"]) or (isinstance(v, bool) and r[\"type\"] is not bool):\n", + " return [f\"{name}: 자료형 오류({type(v).__name__})\"]\n", + " errs = []\n", + " if \"enum\" in r and v not in r[\"enum\"]:\n", + " errs.append(f\"{name}: 허용되지 않은 값 '{v}'\")\n", + " if \"min\" in r and v < r[\"min\"]:\n", + " errs.append(f\"{name}: 최소값 {r['min']} 미만\")\n", + " if \"max\" in r and v > r[\"max\"]:\n", + " errs.append(f\"{name}: 최대값 {r['max']} 초과\")\n", + " if \"pattern\" in r and not re.match(r[\"pattern\"], v):\n", + " errs.append(f\"{name}: 형식 불일치 '{v}'\")\n", + " return errs\n", + "\n", + "\n", + "def validate(rec):\n", + " errs = [f\"{k}: 필드 누락\" for k in SCHEMA if k not in rec]\n", + " for k, r in SCHEMA.items():\n", + " if k in rec:\n", + " errs += check(k, rec[k], r)\n", + " if not errs and rec[\"late_orders\"] > rec[\"orders_12m\"]: # 필드 간 논리 규칙\n", + " errs.append(\"late_orders: 발주 건수보다 지연 건수가 많음\")\n", + " if not errs and rec[\"eol_status\"] == \"단종예고\" and rec[\"ltb_months\"] is None:\n", + " errs.append(\"ltb_months: 단종예고 부품은 LTB 개월 필수\")\n", + " return errs\n", + "\n", + "\n", + "def to_record(row):\n", + " rec = {k: (row[k].item() if hasattr(row[k], \"item\") else row[k]) for k in SCHEMA}\n", + " rec[\"ltb_months\"] = None if pd.isna(rec[\"ltb_months\"]) else int(rec[\"ltb_months\"])\n", + " return rec\n", + "\n", + "\n", + "parts, _ = make_parts(100, seed=7)\n", + "records = [to_record(r) for _, r in parts.iterrows()]\n", + "ok = sum(not validate(r) for r in records)\n", + "print(f\"[스키마 검증] 부품 {len(records)}건 중 통과 {ok}건, 오류 {len(records) - ok}건 / 필드 {len(SCHEMA)}개\")\n", + "print(f\"해외 의존 {sum(r['country'] != '국내' for r in records)}건, 단일 공급선 {sum(r['n_suppliers'] == 1 for r in records)}건, \"\n", + " f\"단종예고·단종 {sum(r['eol_status'] != '정상' for r in records)}건, 평균 리드타임 {np.mean([r['lead_time_days'] for r in records]):.0f}일\")\n", + "\n", + "# 2) 오류 주입 시험: 형식·허용값·범위·자료형·논리 규칙 위반 6종\n", + "bad = json.loads(json.dumps(records[2], ensure_ascii=False))\n", + "bad.update({\"part_no\": \"P12\", \"country\": \"미상\", \"lead_time_days\": -5, \"n_suppliers\": \"2\"})\n", + "bad2 = dict(records[5], late_orders=15, orders_12m=12)\n", + "bad3 = dict(records[0], eol_status=\"단종예고\", ltb_months=None)\n", + "print(\"\\n[오류 주입 결과]\")\n", + "for e in validate(bad) + validate(bad2) + validate(bad3):\n", + " print(\" -\", e)\n", + "\n", + "# 3) AS-IS 업무 단계 분해: [교육용가상자료] 국산화 후보 선정 30건의 단계별 소요일(작업 + 대기)\n", + "rng = np.random.default_rng(3)\n", + "STEPS = [(\"체계 부품목록(BOM) 수집\", 2, 1), (\"공급업체 자료 요청·회신\", 3, 9), (\"단종(DMSMS) 공고 수집·확인\", 2, 6),\n", + " (\"조달 이력·납기 지연 수작업 집계\", 5, 2), (\"해외 의존도 분류\", 2, 1), (\"위험 부품 선정 회의\", 1, 7),\n", + " (\"국내 업체 탐색(전화·자료)\", 6, 8), (\"규격 비교표 작성\", 5, 2), (\"기술 검토 의뢰\", 1, 10),\n", + " (\"국산화 후보 보고서 작성\", 3, 1), (\"결재(승인)\", 1, 5), (\"계약 의뢰 준비\", 2, 2)]\n", + "TOBE = {1: (\"표준 서식 자동 수집\", 0.3), 2: (\"공고 자동 파싱(미션 2)\", 0.2), 3: (\"룰 엔진 자동 집계(미션 3)\", 0.1),\n", + " 4: (\"자동 분류(미션 3)\", 0.1), 6: (\"대체품 자동 추천(미션 4)\", 0.3), 7: (\"규격 자동 대조(미션 4)\", 0.2),\n", + " 9: (\"보고서 초안 자동 생성(미션 10)\", 0.4)}\n", + "rows = []\n", + "for i, (name, work, wait) in enumerate(STEPS):\n", + " w = rng.gamma(4, work / 4, 30); q = rng.gamma(2, wait / 2, 30)\n", + " f = TOBE.get(i, (\"유지(사람 판단)\", 1.0))\n", + " rows.append({\"단계\": f\"{i + 1:02d}. {name}\", \"작업(일)\": w.mean(), \"대기(일)\": q.mean(), \"AS-IS(일)\": w.mean() + q.mean(),\n", + " \"TO-BE 방식\": f[0], \"TO-BE(일)\": w.mean() * f[1] + q.mean() * (f[1] if f[1] < 1 else 1.0)})\n", + "proc = pd.DataFrame(rows)\n", + "share = proc[\"AS-IS(일)\"] / proc[\"AS-IS(일)\"].sum()\n", + "proc[\"병목\"] = np.where((share >= share.sort_values().iloc[-3]) | (proc[\"대기(일)\"] / proc[\"AS-IS(일)\"] > 0.75), \"병목\", \"\")\n", + "print(\"\\n[AS-IS / TO-BE 업무 단계 비교]\")\n", + "print(proc.round(1).to_string(index=False))\n", + "n_bn = int((proc[\"병목\"] == \"병목\").sum())\n", + "asis, tobe = proc[\"AS-IS(일)\"].sum(), proc[\"TO-BE(일)\"].sum()\n", + "print(f\"\\n업무 단계 {len(proc)}개(목표 10개 이상: {'달성' if len(proc) >= 10 else '미달'}), 병목 {n_bn}개소(목표 3개소 이상: {'달성' if n_bn >= 3 else '미달'})\")\n", + "print(f\"후보 선정 1건 소요: AS-IS {asis:.1f}일 -> TO-BE {tobe:.1f}일 ({1 - tobe / asis:.0%} 단축 예상). 결재·기술 검토·회의는 사람이 수행한다.\")\n", + "\n", + "# 4) 입출력 데이터 명세 요약\n", + "print(\"\\n[입출력 명세] 입력: 부품 공급망 레코드(위 16개 필드) / 출력: 위험 등급, 국산화 후보 Top-3, 추천 근거, 결재 이력\")\n", + "\n", + "plt.figure(figsize=(9, 5))\n", + "y = np.arange(len(proc))\n", + "plt.barh(y - 0.2, proc[\"AS-IS(일)\"], 0.4, label=\"AS-IS\", color=\"tab:gray\")\n", + "plt.barh(y + 0.2, proc[\"TO-BE(일)\"], 0.4, label=\"TO-BE\", color=\"tab:blue\")\n", + "plt.yticks(y, [f\"step {i + 1}{' *' if b else ''}\" for i, b in enumerate(proc[\"병목\"])])\n", + "plt.gca().invert_yaxis(); plt.xlabel(\"days per case\"); plt.legend()\n", + "plt.title(\"Localization candidate process: AS-IS vs TO-BE (* = bottleneck, synthetic)\")\n", + "plt.tight_layout(); plt.show()\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 01 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + "[스키마 검증] 부품 100건 중 통과 100건, 오류 0건 / 필드 16개\n", + "해외 의존 85건, 단일 공급선 42건, 단종예고·단종 37건, 평균 리드타임 145일\n", + "\n", + "[오류 주입 결과]\n", + " - part_no: 형식 불일치 'P12'\n", + " - country: 허용되지 않은 값 '미상'\n", + " - n_suppliers: 자료형 오류(str)\n", + " - lead_time_days: 최소값 1 미만\n", + " - late_orders: 발주 건수보다 지연 건수가 많음\n", + " - ltb_months: 단종예고 부품은 LTB 개월 필수\n", + "\n", + "[AS-IS / TO-BE 업무 단계 비교]\n", + " 단계 작업(일) 대기(일) AS-IS(일) TO-BE 방식 TO-BE(일) 병목\n", + " 01. 체계 부품목록(BOM) 수집 2.0 1.0 3.0 유지(사람 판단) 3.0 \n", + " 02. 공급업체 자료 요청·회신 3.9 8.5 12.4 표준 서식 자동 수집 3.7 병목\n", + "03. 단종(DMSMS) 공고 수집·확인 2.1 6.6 8.7 공고 자동 파싱(미션 2) 1.7 병목\n", + "04. 조달 이력·납기 지연 수작업 집계 5.9 2.0 7.9 룰 엔진 자동 집계(미션 3) 0.8 \n", + " 05. 해외 의존도 분류 2.4 1.1 3.5 자동 분류(미션 3) 0.4 \n", + " 06. 위험 부품 선정 회의 1.0 7.2 8.3 유지(사람 판단) 8.3 병목\n", + " 07. 국내 업체 탐색(전화·자료) 5.3 7.5 12.8 대체품 자동 추천(미션 4) 3.8 병목\n", + " 08. 규격 비교표 작성 4.7 2.0 6.7 규격 자동 대조(미션 4) 1.3 \n", + " 09. 기술 검토 의뢰 1.1 9.6 10.7 유지(사람 판단) 10.7 병목\n", + " 10. 국산화 후보 보고서 작성 3.1 1.2 4.3 보고서 초안 자동 생성(미션 10) 1.7 \n", + " 11. 결재(승인) 0.9 5.7 6.6 유지(사람 판단) 6.6 병목\n", + " 12. 계약 의뢰 준비 2.1 2.1 4.1 유지(사람 판단) 4.1 \n", + "\n", + "업무 단계 12개(목표 10개 이상: 달성), 병목 6개소(목표 3개소 이상: 달성)\n", + "후보 선정 1건 소요: AS-IS 89.1일 -> TO-BE 46.3일 (48% 단축 예상). 결재·기술 검토·회의는 사람이 수행한다.\n", + "\n", + "[입출력 명세] 입력: 부품 공급망 레코드(위 16개 필드) / 출력: 위험 등급, 국산화 후보 Top-3, 추천 근거, 결재 이력\n", + "```\n", + "
\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 02. 국내외 공급업체 데이터 정제, 부품 단종(DMSMS) 공고 파싱 전처리 모듈 구현\n", + "**소요시간**: 약 2.0시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "공급업체 데이터 정제 및 부품 단종 공고 파싱 전처리 모듈을 구현한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **국내외 공급업체 자료의 표기 차이를 정제하고 서식이 다른 부품 단종(DMSMS) 공고문에서 필요한 항목을 추출하는 전처리 모듈을 구현한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **CSV, 엑셀 내보내기 형태(탭 구분), JSON 세 형식의 공급업체 자료를 열 이름 별칭 사전으로 하나의 표준 스키마로 바꾸고, 업체명 표기와 국가 코드, 리드타임 단위(일·주·개월·년), 날짜 형식을 통일 수행하고 검증하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `src/connectors/prc07_data_loader.py 및 스키마 정의서` 형태의 결과물을 완성한다.\n", + "\n", + "### 🔍 평가 및 검증 기준 (PASS 조건)\n", + "#### 미션 판정\n", + "\n", + "**평가 구분:** 필수\n", + "\n", + "**핵심 평가영역:** 국방 서식/양식 변경에 대한 유연한 확장성\n", + "\n", + "#### 미션 PASS 조건\n", + "\n", + "- 정량 기준: 지원 파일 포맷(CSV, Excel, JSON 등) 파싱 성공률 100%\n", + "\n", + "\n", + "- 진행 기준: 데이터 로더 및 스키마 유효성 검사 모듈 완성\n", + "\n", + "\n", + "- 검증 방법: 다양한 형태의 샘플 군수 문서 대상 파싱 테스트\n", + "\n", + "\n", + "#### 채점자 판정 메모\n", + "\n", + "> 단순 코드 실행 여부뿐만 아니라 출력된 수치와 근거 자료가 군 실무 요구사항에 부합하는지 면밀히 확인한다.\n", + "\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "\"\"\"국내외 공급업체 자료(CSV·엑셀 내보내기 TSV·JSON)를 하나의 표준 스키마로 정제하고, 문서마다 서식이 다른 부품 단종(DMSMS) 공고문 텍스트에서 부품번호·단종일·최종구매(LTB) 기한·대체품 정보를 파싱하는 전처리 모듈을 구현한다.\"\"\"\n", + "import csv\n", + "import io\n", + "import json\n", + "import re\n", + "from datetime import date\n", + "import numpy as np\n", + "import pandas as pd\n", + "\n", + "# ---------------------------------------------------------------------------\n", + "# [교육용가상자료] 서로 다른 형식의 원시 자료 (실제 업체·공고 아님). 표기가 일부러 제각각이다.\n", + "# ---------------------------------------------------------------------------\n", + "RAW_CSV = \"\"\"업체코드,업체명,국가,공급부품,리드타임,최근거래일\n", + "V001,(주)가상정밀,국내,P-0001,45일,2026.03.02\n", + "V002, 가상전자 주식회사 ,KR,P-0004,2개월,2026-3-15\n", + "V003,Virtual Parts Inc.,A국,P-0003,180,20260110\n", + "V004,가상유공압㈜,국내,P-0003,60일,26/02/27\n", + "V005,VIRTUAL PARTS INC,가상국가A,P-0011,6개월,2025.12.30\n", + "V006,Sample Optics GmbH,B,P-0005,210일,2026-01-05\n", + "\"\"\"\n", + "RAW_TSV = \"공급업체\\t국적\\t품목번호\\t납기(일)\\t최종거래\\n\" \\\n", + " \"가상광학(주)\\t대한민국\\tP-0005\\t75\\t2026.02.11\\n\" \\\n", + " \"Sample Optics GmbH\\t가상국가B\\tP-0013\\t200\\t2026.01.20\\n\" \\\n", + " \"가상테크 (주)\\t국내\\tP-0008\\t90\\t2026/03/05\\n\" \\\n", + " \"Model Sensor Co.\\tC국\\tP-0006\\t1년\\t2025.11.03\\n\" \\\n", + " \"Virtual Parts Inc\\tA국\\tP-0003\\t180\\t2026.01.10\\n\"\n", + "RAW_JSON = json.dumps({\"result\": \"OK\", \"items\": [\n", + " {\"vendor\": {\"name\": \"가상시스템(주)\", \"nation\": \"KR\"}, \"part\": \"P-0016\", \"leadTime\": {\"value\": 3, \"unit\": \"month\"}, \"lastTrade\": \"2026-02-01\"},\n", + " {\"vendor\": {\"name\": \"Model Sensor Co\", \"nation\": \"C\"}, \"part\": \"P-0014\", \"leadTime\": {\"value\": 150, \"unit\": \"day\"}, \"lastTrade\": \"2026-01-09\"},\n", + " {\"vendor\": {\"name\": \"가상정밀 주식회사\", \"nation\": \"KR\"}, \"part\": \"P-0002\", \"leadTime\": {\"value\": 8, \"unit\": \"week\"}, \"lastTrade\": \"2026-03-20\"}]},\n", + " ensure_ascii=False)\n", + "NOTICES = [\n", + " \"제목: 부품 단종 예정 통보\\n부품번호: P-0003\\n단종 예정일: 2027년 3월 31일\\n최종구매(LTB) 마감: 2026년 12월 31일\\n대체품: 없음\",\n", + " \"[DMSMS NOTICE] Part No. P-0011 / End of Life 2027-06-30 / Last Time Buy 2027-01-15 / Replacement: P-0011A\",\n", + " \"수신: 획득담당 참조\\n1. 아래 부품의 생산 중단을 알려 드립니다.\\n 가. 품번 : P-0013\\n 나. 생산중단일 : 2026.09.30.\\n 다. 최종 주문 접수 : 2026.06.30.\\n2. 대체 권고품 : 협의 필요\",\n", + " \"부품 P-0006 은(는) 2026-12-31 부로 단종되며 최종 주문은 2026-10-31 까지 받습니다. 대체품 P-0006-R 검토 바랍니다.\",\n", + " \"단종공고 / 대상:P-0020 / 단종일:27.02.28 / LTB:26.11.30 / 대체:없음\",\n", + "]\n", + "\n", + "# ---- 정규화 규칙(서식이 바뀌면 이 사전만 고치면 된다) ----\n", + "COL_ALIAS = {\"vendor\": [\"업체명\", \"공급업체\", \"vendor\"], \"country\": [\"국가\", \"국적\", \"nation\"], \"part_no\": [\"공급부품\", \"품목번호\", \"part\"],\n", + " \"lead\": [\"리드타임\", \"납기(일)\", \"leadTime\"], \"last_trade\": [\"최근거래일\", \"최종거래\", \"lastTrade\"]}\n", + "COUNTRY_MAP = {\"국내\": \"국내\", \"KR\": \"국내\", \"대한민국\": \"국내\", \"A국\": \"가상국가A\", \"A\": \"가상국가A\", \"가상국가A\": \"가상국가A\",\n", + " \"B국\": \"가상국가B\", \"B\": \"가상국가B\", \"가상국가B\": \"가상국가B\", \"C국\": \"가상국가C\", \"C\": \"가상국가C\", \"가상국가C\": \"가상국가C\"}\n", + "UNIT_DAYS = {\"일\": 1, \"day\": 1, \"주\": 7, \"week\": 7, \"개월\": 30, \"month\": 30, \"년\": 365, \"year\": 365}\n", + "\n", + "\n", + "def norm_vendor(s):\n", + " s = re.sub(r\"\\(주\\)|㈜|주식회사|\\bInc\\.?|\\bCo\\.?|\\bGmbH\", \"\", str(s), flags=re.I)\n", + " return re.sub(r\"[\\s.,]+\", \" \", s).strip().upper()\n", + "\n", + "\n", + "def norm_lead(v):\n", + " if isinstance(v, dict):\n", + " return int(v[\"value\"] * UNIT_DAYS[v[\"unit\"]])\n", + " m = re.match(r\"^\\s*(\\d+)\\s*(일|주|개월|년)?\\s*$\", str(v))\n", + " if not m:\n", + " raise ValueError(f\"리드타임 해석 불가: {v}\")\n", + " return int(m.group(1)) * UNIT_DAYS.get(m.group(2) or \"일\", 1)\n", + "\n", + "\n", + "def norm_date(s):\n", + " s = str(s).strip().rstrip(\".\")\n", + " m = re.match(r\"^(\\d{2,4})[.\\-/년\\s]*(\\d{1,2})[.\\-/월\\s]*(\\d{1,2})일?$\", s) or re.match(r\"^(\\d{4})(\\d{2})(\\d{2})$\", s)\n", + " if not m:\n", + " raise ValueError(f\"날짜 해석 불가: {s}\")\n", + " y, mo, d = (int(x) for x in m.groups())\n", + " return date(y + 2000 if y < 100 else y, mo, d).isoformat()\n", + "\n", + "\n", + "def pick(row, key):\n", + " for k in COL_ALIAS[key]:\n", + " if k in row:\n", + " return row[k]\n", + " raise KeyError(f\"필수 열 없음: {key}\")\n", + "\n", + "\n", + "def to_std(row, src):\n", + " return {\"vendor\": norm_vendor(pick(row, \"vendor\")), \"country\": COUNTRY_MAP[str(pick(row, \"country\")).strip()],\n", + " \"part_no\": str(pick(row, \"part_no\")).strip(), \"lead_days\": norm_lead(pick(row, \"lead\")),\n", + " \"last_trade\": norm_date(pick(row, \"last_trade\")), \"source\": src}\n", + "\n", + "\n", + "def load(text, fmt):\n", + " \"\"\"형식별 로더: csv / tsv(엑셀 내보내기) / json. 행마다 성공·실패를 따로 센다.\"\"\"\n", + " if fmt == \"json\":\n", + " rows = [{\"vendor\": r[\"vendor\"][\"name\"], \"nation\": r[\"vendor\"][\"nation\"], \"part\": r[\"part\"], \"leadTime\": r[\"leadTime\"],\n", + " \"lastTrade\": r[\"lastTrade\"]} for r in json.loads(text)[\"items\"]]\n", + " else:\n", + " rows = list(csv.DictReader(io.StringIO(text), delimiter=\"\\t\" if fmt == \"tsv\" else \",\"))\n", + " ok, fail = [], []\n", + " for r in rows:\n", + " try:\n", + " ok.append(to_std(r, fmt))\n", + " except (KeyError, ValueError) as e:\n", + " fail.append((r, str(e)))\n", + " return ok, fail\n", + "\n", + "\n", + "def parse_notice(text):\n", + " \"\"\"서식이 다른 단종 공고에서 부품번호·단종일·LTB·대체품을 추출한다.\"\"\"\n", + " part = re.search(r\"P-\\d{4}\", text)\n", + " dt = r\"(\\d{2,4}[년.\\-/]\\s*\\d{1,2}[월.\\-/]\\s*\\d{1,2}일?\\.?)\"\n", + " eol = re.search(r\"(?:단종(?: 예정)?일|생산중단일|End of Life)\\s*[::]?\\s*\" + dt, text) or re.search(dt + r\"\\s*부로\\s*단종\", text)\n", + " ltb = re.search(r\"(?:LTB\\)?\\s*마감|Last Time Buy|최종 주문 접수|최종 주문은|LTB)\\s*[::]?\\s*\" + dt, text)\n", + " rep = re.search(r\"(?:대체품|대체 권고품|Replacement|대체)\\s*[::]?\\s*(P-[\\w-]+|없음|협의 필요)\", text)\n", + " if not (part and eol and ltb):\n", + " raise ValueError(\"필수 항목(부품번호·단종일·LTB) 추출 실패\")\n", + " return {\"part_no\": part.group(0), \"eol_date\": norm_date(eol.group(1)), \"ltb_date\": norm_date(ltb.group(1)),\n", + " \"replacement\": rep.group(1) if rep else \"미기재\"}\n", + "\n", + "\n", + "# 1) 형식별 파싱\n", + "results, total, success = [], 0, 0\n", + "for fmt, text in [(\"csv\", RAW_CSV), (\"tsv\", RAW_TSV), (\"json\", RAW_JSON)]:\n", + " ok, fail = load(text, fmt)\n", + " results += ok; total += len(ok) + len(fail); success += len(ok)\n", + " print(f\"[{fmt.upper():4s}] {len(ok) + len(fail)}행 중 {len(ok)}행 성공\" + (f\", 실패 {[e for _, e in fail]}\" if fail else \"\"))\n", + "notices, n_fail = [], 0\n", + "for t in NOTICES:\n", + " try:\n", + " notices.append(parse_notice(t))\n", + " except ValueError as e:\n", + " n_fail += 1; print(\"공고 파싱 실패:\", e)\n", + "total += len(NOTICES); success += len(notices)\n", + "print(f\"[공문 텍스트] 단종 공고 {len(NOTICES)}건 중 {len(notices)}건 성공\")\n", + "rate = success / total\n", + "print(f\"\\n전체 파싱 성공률 {rate:.0%} ({success}/{total}) - 목표 100%: {'달성' if rate == 1 else '미달'}\")\n", + "\n", + "# 2) 정제 결과(업체명 표기 통일, 중복 제거)\n", + "std = pd.DataFrame(results)\n", + "before = len(std)\n", + "std = std.drop_duplicates([\"vendor\", \"part_no\"]).sort_values([\"part_no\", \"vendor\"])\n", + "print(f\"\\n[정제된 공급업체 자료] {before}행 -> 중복 제거 후 {len(std)}행, 업체 {std['vendor'].nunique()}곳\")\n", + "print(std.to_string(index=False))\n", + "print(\"\\n[단종 공고 파싱 결과]\")\n", + "print(pd.DataFrame(notices).to_string(index=False))\n", + "\n", + "# 3) 서식 변경 대응 시험: 열 이름이 바뀐 새 양식 -> 별칭 사전 한 줄 추가로 처리\n", + "new_form = \"협력업체,소재국,부품,조달기간,거래일\\n가상정밀(주),국내,P-0021,30일,2026.04.01\\n\"\n", + "ok, fail = load(new_form, \"csv\")\n", + "print(f\"\\n새 양식(열 이름 변경) 1차 시도: 성공 {len(ok)} / 실패 {len(fail)} -> {fail[0][1] if fail else '-'}\")\n", + "for k, alias in {\"vendor\": \"협력업체\", \"country\": \"소재국\", \"part_no\": \"부품\", \"lead\": \"조달기간\", \"last_trade\": \"거래일\"}.items():\n", + " COL_ALIAS[k].append(alias)\n", + "ok, fail = load(new_form, \"csv\")\n", + "print(f\"별칭 사전 갱신 후: 성공 {len(ok)} / 실패 {len(fail)} (코드 수정 없이 설정만 변경)\")\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 02 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + "[CSV ] 6행 중 6행 성공\n", + "[TSV ] 5행 중 5행 성공\n", + "[JSON] 3행 중 3행 성공\n", + "[공문 텍스트] 단종 공고 5건 중 5건 성공\n", + "\n", + "전체 파싱 성공률 100% (19/19) - 목표 100%: 달성\n", + "\n", + "[정제된 공급업체 자료] 14행 -> 중복 제거 후 13행, 업체 9곳\n", + " vendor country part_no lead_days last_trade source\n", + " 가상정밀 국내 P-0001 45 2026-03-02 csv\n", + " 가상정밀 국내 P-0002 56 2026-03-20 json\n", + "VIRTUAL PARTS 가상국가A P-0003 180 2026-01-10 csv\n", + " 가상유공압 국내 P-0003 60 2026-02-27 csv\n", + " 가상전자 국내 P-0004 60 2026-03-15 csv\n", + "SAMPLE OPTICS 가상국가B P-0005 210 2026-01-05 csv\n", + " 가상광학 국내 P-0005 75 2026-02-11 tsv\n", + " MODEL SENSOR 가상국가C P-0006 365 2025-11-03 tsv\n", + " 가상테크 국내 P-0008 90 2026-03-05 tsv\n", + "VIRTUAL PARTS 가상국가A P-0011 180 2025-12-30 csv\n", + "SAMPLE OPTICS 가상국가B P-0013 200 2026-01-20 tsv\n", + " MODEL SENSOR 가상국가C P-0014 150 2026-01-09 json\n", + " 가상시스템 국내 P-0016 90 2026-02-01 json\n", + "\n", + "[단종 공고 파싱 결과]\n", + "part_no eol_date ltb_date replacement\n", + " P-0003 2027-03-31 2026-12-31 없음\n", + " P-0011 2027-06-30 2027-01-15 P-0011A\n", + " P-0013 2026-09-30 2026-06-30 협의 필요\n", + " P-0006 2026-12-31 2026-10-31 P-0006-R\n", + " P-0020 2027-02-28 2026-11-30 없음\n", + "\n", + "새 양식(열 이름 변경) 1차 시도: 성공 0 / 실패 1 -> '필수 열 없음: vendor'\n", + "별칭 사전 갱신 후: 성공 1 / 실패 0 (코드 수정 없이 설정만 변경)\n", + "```\n", + "
\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 03. 공급선 단일화 및 납기 지연 이력 기반 1차 조달 위험 스크리닝 알고리즘 구현\n", + "**소요시간**: 약 2.5시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "공급선 단일화 및 납기 지연 이력 기반 조달 위험 스크리닝 알고리즘을 구현한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **공급선 단일화와 납기 지연 이력 등을 점검하는 조달 위험 룰 15종 이상을 코딩해 부품별 1차 스크리닝 등급을 산출한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **단일 공급선, 해외 의존, 리드타임 초과, 납기 지연률, 단종 예고·확정, 최종구매 임박, 단가 급등, 재고 가용기간 부족 등 16종 룰을 YAML 설정으로 두고 가중치 합으로 고위험·주의·관심 등급을 낸다 과정을 진행하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `src/engine/prc07_rule_engine.py` 형태의 결과물을 완성한다.\n", + "\n", + "### 🔍 평가 및 검증 기준 (PASS 조건)\n", + "#### 미션 판정\n", + "\n", + "**평가 구분:** 필수\n", + "\n", + "**핵심 평가영역:** 군 규정 및 지침 조항과의 정확한 일치성\n", + "\n", + "#### 미션 PASS 조건\n", + "\n", + "- 정량 기준: 업무 검증 룰 15종 이상 코딩, 룰 처리 지연시간 50ms 이하\n", + "\n", + "\n", + "- 진행 기준: 룰 엔진 단위 테스트 100% 통과\n", + "\n", + "\n", + "- 검증 방법: 경계 조건(Boundary condition)에 대한 룰 판정 검증\n", + "\n", + "\n", + "#### 채점자 판정 메모\n", + "\n", + "> 단순 코드 실행 여부뿐만 아니라 출력된 수치와 근거 자료가 군 실무 요구사항에 부합하는지 면밀히 확인한다.\n", + "\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "\"\"\"공급선 단일화·납기 지연 이력 등 16종 조달 위험 룰(YAML 설정)을 코딩해 부품별 1차 스크리닝 등급을 산출하고, 룰 처리 지연시간·과거 단절 이력 대비 탐지력·룰별 적중 현황·규정 변경 시 설정만 바꾸는 확장성을 확인한다.\"\"\"\n", + "import time\n", + "import numpy as np\n", + "import pandas as pd\n", + "import matplotlib.pyplot as plt\n", + "\n", + "# ---------------------------------------------------------------------------\n", + "# [교육용가상자료] 가상 무기체계 부품·공급망 데이터 생성기 (실제 체계·업체·국가·단가 정보 없음)\n", + "# 부품 100종(해외 조달 약 80%)의 공급망 속성과, 국내 부품 카탈로그(과거 승인된 국산 대체품 +\n", + "# 설명은 비슷하지만 규격이 맞지 않는 유사 후보)를 함께 만든다.\n", + "# ---------------------------------------------------------------------------\n", + "COUNTRIES = [\"가상국가A\", \"가상국가B\", \"가상국가C\", \"국내\"]\n", + "CATS = {\n", + " \"전원모듈\": dict(words=[\"DC-DC\", \"컨버터\", \"절연형\", \"전원\", \"정출력\"], syn={\"컨버터\": \"변환장치\", \"절연형\": \"아이솔레이션\", \"정출력\": \"안정화\"}, std=\"MIL-STD-704\", iface=\"단자대\"),\n", + " \"커넥터\": dict(words=[\"원형\", \"커넥터\", \"방수형\", \"다핀\", \"나사체결\"], syn={\"커넥터\": \"접속자\", \"방수형\": \"밀폐형\", \"나사체결\": \"스크루결합\"}, std=\"MIL-DTL-38999\", iface=\"원형핀\"),\n", + " \"유압밸브\": dict(words=[\"유압\", \"솔레노이드\", \"밸브\", \"방향제어\", \"4포트\"], syn={\"밸브\": \"개폐장치\", \"방향제어\": \"유로전환\", \"솔레노이드\": \"전자식\"}, std=\"SAE-AS5440\", iface=\"플랜지\"),\n", + " \"베어링\": dict(words=[\"볼\", \"베어링\", \"고속\", \"밀봉형\", \"회전축\"], syn={\"베어링\": \"축받이\", \"밀봉형\": \"실드형\", \"고속\": \"고회전\"}, std=\"ISO-492\", iface=\"축결합\"),\n", + " \"광학렌즈\": dict(words=[\"적외선\", \"렌즈\", \"게르마늄\", \"반사방지\", \"집광\"], syn={\"렌즈\": \"광학창\", \"집광\": \"초점형성\", \"반사방지\": \"AR코팅\"}, std=\"MIL-PRF-13830\", iface=\"마운트\"),\n", + " \"자이로센서\": dict(words=[\"광섬유\", \"자이로\", \"각속도\", \"센서\", \"관성\"], syn={\"센서\": \"감지기\", \"각속도\": \"회전율\", \"관성\": \"INS용\"}, std=\"IEEE-952\", iface=\"직렬통신\"),\n", + " \"RF증폭기\": dict(words=[\"RF\", \"전력증폭기\", \"광대역\", \"모듈\", \"송신\"], syn={\"전력증폭기\": \"파워앰프\", \"송신\": \"출력단\", \"광대역\": \"와이드밴드\"}, std=\"MIL-STD-461\", iface=\"SMA\"),\n", + " \"제어보드\": dict(words=[\"FPGA\", \"제어보드\", \"임베디드\", \"연산\", \"입출력\"], syn={\"제어보드\": \"컨트롤카드\", \"연산\": \"신호처리\", \"임베디드\": \"내장형\"}, std=\"VITA-46\", iface=\"백플레인\"),\n", + "}\n", + "V_OPTS, W_OPTS = [12, 24, 28], [25, 50, 100]\n", + "VENDORS = [\"가상정밀(주)\", \"가상전자(주)\", \"가상유공압(주)\", \"가상광학(주)\", \"가상테크(주)\", \"가상시스템(주)\"]\n", + "\n", + "\n", + "def describe(cat, words, s):\n", + " return f\"{cat} {' '.join(words)} {s['v']}V {s['w']}W {s['tmin']}~{s['tmax']}C {s['std']}\"\n", + "\n", + "\n", + "def _sig(x):\n", + " return 1 / (1 + np.exp(-x))\n", + "\n", + "\n", + "def make_parts(n=100, seed=7):\n", + " \"\"\"부품 목록(공급망 속성 포함)과 국내 카탈로그를 만든다. 반환: (parts DataFrame, catalog DataFrame)\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " cats, rows, cat_rows = list(CATS), [], []\n", + " for i in range(n):\n", + " cat = cats[i % len(cats)]; c = CATS[cat]\n", + " spec = {\"v\": int(rng.choice(V_OPTS)), \"w\": int(rng.choice(W_OPTS)), \"tmin\": int(rng.choice([-55, -40])),\n", + " \"tmax\": int(rng.choice([85, 125])), \"size_mm\": float(round(rng.uniform(20, 120), 1)), \"std\": c[\"std\"]}\n", + " words = [str(x) for x in rng.permutation(c[\"words\"])[:4]]\n", + " country = str(rng.choice(COUNTRIES, p=[0.35, 0.3, 0.15, 0.2]))\n", + " overseas = country != \"국내\"\n", + " n_sup = int(rng.choice([1, 2, 3], p=[0.45, 0.35, 0.2])) if overseas else int(rng.choice([1, 2, 3]))\n", + " lead = float(round(rng.gamma(3, 45 if overseas else 25) + 20))\n", + " eol = str(rng.choice([\"정상\", \"단종예고\", \"단종\"], p=[0.7, 0.2, 0.1]))\n", + " crit = str(rng.choice([\"A\", \"B\", \"C\"], p=[0.3, 0.4, 0.3]))\n", + " z = (-2.6 + 1.1 * (n_sup == 1) + 0.9 * overseas + 0.006 * (lead - 100) + 1.2 * (eol == \"단종예고\")\n", + " + 2.0 * (eol == \"단종\") + rng.normal(0, 0.6))\n", + " p_late = float(np.clip(_sig(z) * 0.8 + 0.05, 0, 0.9))\n", + " late = int(rng.binomial(12, p_late))\n", + " demand = int(rng.integers(20, 400))\n", + " rows.append({\"part_no\": f\"P-{i + 1:04d}\", \"category\": cat, \"desc\": describe(cat, words, spec), **spec,\n", + " \"iface\": c[\"iface\"], \"country\": country, \"overseas\": overseas, \"n_suppliers\": n_sup,\n", + " \"lead_time_days\": lead, \"orders_12m\": 12, \"late_orders\": late,\n", + " \"avg_delay_days\": float(round(late and rng.gamma(2, 12 + 20 * p_late), 1)),\n", + " \"eol_status\": eol, \"ltb_months\": int(rng.integers(1, 25)) if eol == \"단종예고\" else None,\n", + " \"price_change_pct\": float(round(rng.normal(4 + 12 * p_late, 8), 1)), \"criticality\": crit,\n", + " \"annual_demand\": demand, \"stock_qty\": int(rng.integers(0, demand)),\n", + " \"defect_rate\": float(round(abs(rng.normal(0.8 + 2 * p_late, 0.8)), 2)),\n", + " \"export_license\": bool(overseas and rng.random() < 0.35),\n", + " \"disrupted_next12m\": bool(rng.random() < _sig(z + 0.4))})\n", + " if not overseas:\n", + " continue\n", + " # 과거 승인된 국산 대체품(정답): 동의어로 서술, 규격 호환(전력 여유 허용, 일부는 크기 차이가 커 장착 검토 필요)\n", + " eq_words = [c[\"syn\"].get(w, w) for w in words]\n", + " hard = rng.random() < 0.15 # 서술이 많이 다른 사례\n", + " if hard:\n", + " eq_words = [c[\"syn\"].get(w, w) for w in c[\"words\"] if w not in words][:1] + eq_words[1:3]\n", + " eq = {**spec, \"w\": spec[\"w\"] if rng.random() < 0.7 else W_OPTS[min(W_OPTS.index(spec[\"w\"]) + 1, 2)],\n", + " \"size_mm\": float(round(spec[\"size_mm\"] * (rng.uniform(1.11, 1.15) if rng.random() < 0.12 else rng.uniform(0.95, 1.05)), 1))}\n", + " cat_rows.append({\"cand_no\": f\"D-{len(cat_rows) + 1:04d}\", \"category\": cat, \"desc\": describe(cat, eq_words, eq), **eq,\n", + " \"iface\": c[\"iface\"], \"vendor\": str(rng.choice(VENDORS)), \"approved_for\": f\"P-{i + 1:04d}\", \"kind\": \"equivalent\"})\n", + " # 유사 오답 후보: 설명은 거의 같지만 전압이 다르거나(5V/48V) 상용 온도 등급(0~70C)\n", + " for kind in (\"volt_mismatch\", \"temp_mismatch\"):\n", + " d = dict(spec)\n", + " if kind == \"volt_mismatch\":\n", + " d[\"v\"] = int(rng.choice([5, 48])) # 체계에서 쓰지 않는 전압\n", + " else:\n", + " d[\"tmin\"], d[\"tmax\"] = 0, 70\n", + " d[\"size_mm\"] = float(round(spec[\"size_mm\"] * rng.uniform(0.95, 1.05), 1))\n", + " cat_rows.append({\"cand_no\": f\"D-{len(cat_rows) + 1:04d}\", \"category\": cat, \"desc\": describe(cat, words, d), **d,\n", + " \"iface\": c[\"iface\"], \"vendor\": str(rng.choice(VENDORS)), \"approved_for\": None, \"kind\": kind})\n", + " parts = pd.DataFrame(rows)\n", + " catalog = pd.DataFrame(cat_rows).sample(frac=1, random_state=seed).reset_index(drop=True)\n", + " return parts, catalog\n", + "# ---------------------------------------------------------------------------\n", + "\n", + "# ---- 조달 위험 스크리닝 룰 설정(YAML 형식 문자열: 규정이 바뀌면 코드가 아니라 이 설정만 고친다) ----\n", + "RULES_YAML = \"\"\"\n", + "- id: R01\n", + " name: 단일 공급선\n", + " field: n_suppliers\n", + " op: \"==\"\n", + " value: 1\n", + " weight: 3\n", + "- id: R02\n", + " name: 해외 조달 의존\n", + " field: overseas\n", + " op: \"==\"\n", + " value: true\n", + " weight: 1\n", + "- id: R03\n", + " name: 리드타임 180일 초과\n", + " field: lead_time_days\n", + " op: \">\"\n", + " value: 180\n", + " weight: 2\n", + "- id: R04\n", + " name: 납기 지연률 30% 이상\n", + " field: delay_rate\n", + " op: \">=\"\n", + " value: 0.3\n", + " weight: 3\n", + "- id: R05\n", + " name: 평균 지연 30일 초과\n", + " field: avg_delay_days\n", + " op: \">\"\n", + " value: 30\n", + " weight: 2\n", + "- id: R06\n", + " name: 단종 예고\n", + " field: eol_status\n", + " op: \"==\"\n", + " value: 단종예고\n", + " weight: 3\n", + "- id: R07\n", + " name: 단종 확정\n", + " field: eol_status\n", + " op: \"==\"\n", + " value: 단종\n", + " weight: 4\n", + "- id: R08\n", + " name: 최종구매 6개월 이내\n", + " field: ltb_months\n", + " op: \"<=\"\n", + " value: 6\n", + " weight: 3\n", + "- id: R09\n", + " name: 단가 20% 이상 급등\n", + " field: price_change_pct\n", + " op: \">=\"\n", + " value: 20\n", + " weight: 2\n", + "- id: R10\n", + " name: 핵심(A) 부품 단일 공급\n", + " field: crit_single\n", + " op: \"==\"\n", + " value: true\n", + " weight: 3\n", + "- id: R11\n", + " name: 재고 가용기간 < 리드타임\n", + " field: cover_gap_days\n", + " op: \"<\"\n", + " value: 0\n", + " weight: 3\n", + "- id: R12\n", + " name: 입고 불량률 2% 초과\n", + " field: defect_rate\n", + " op: \">\"\n", + " value: 2.0\n", + " weight: 2\n", + "- id: R13\n", + " name: 수출 허가 필요\n", + " field: export_license\n", + " op: \"==\"\n", + " value: true\n", + " weight: 2\n", + "- id: R14\n", + " name: 재고 없음\n", + " field: stock_qty\n", + " op: \"==\"\n", + " value: 0\n", + " weight: 2\n", + "- id: R15\n", + " name: 공급국 편중(30% 이상)\n", + " field: country_share\n", + " op: \">=\"\n", + " value: 0.3\n", + " weight: 1\n", + "- id: R16\n", + " name: 지연 이력 있는 단일 공급선\n", + " field: single_late\n", + " op: \"==\"\n", + " value: true\n", + " weight: 2\n", + "\"\"\"\n", + "GRADE_CUT = {\"고위험\": 16, \"주의\": 10} # 가중치 합 기준 1차 스크리닝 등급\n", + "\n", + "\n", + "def parse_rules(text):\n", + " \"\"\"간이 YAML 파서(목록 + key: value). 숫자·true/false·따옴표 문자열을 해석한다.\"\"\"\n", + " rules, cur = [], None\n", + " for line in text.strip().splitlines():\n", + " if not line.strip():\n", + " continue\n", + " s = line.strip()\n", + " if s.startswith(\"- \"):\n", + " cur = {}; rules.append(cur); s = s[2:]\n", + " k, v = [x.strip() for x in s.split(\":\", 1)]\n", + " v = v.strip('\"')\n", + " if v in (\"true\", \"false\"):\n", + " v = v == \"true\"\n", + " else:\n", + " try:\n", + " v = float(v) if \".\" in v else int(v)\n", + " except ValueError:\n", + " pass\n", + " cur[k] = v\n", + " return rules\n", + "\n", + "\n", + "OPS = {\"==\": lambda a, b: a == b, \"!=\": lambda a, b: a != b, \">\": lambda a, b: a > b, \">=\": lambda a, b: a >= b,\n", + " \"<\": lambda a, b: a < b, \"<=\": lambda a, b: a <= b}\n", + "\n", + "\n", + "def derive(df):\n", + " \"\"\"룰에 쓰는 파생 필드(지연률, 핵심 단일공급, 재고 가용기간 차이, 공급국 비중 등)를 만든다.\"\"\"\n", + " d = df.copy()\n", + " d[\"delay_rate\"] = d[\"late_orders\"] / d[\"orders_12m\"].clip(lower=1)\n", + " d[\"crit_single\"] = (d[\"criticality\"] == \"A\") & (d[\"n_suppliers\"] == 1)\n", + " d[\"cover_gap_days\"] = d[\"stock_qty\"] / d[\"annual_demand\"].clip(lower=1) * 365 - d[\"lead_time_days\"]\n", + " d[\"country_share\"] = d[\"country\"].map(d[\"country\"].value_counts(normalize=True)).where(d[\"overseas\"], 0.0)\n", + " d[\"single_late\"] = (d[\"n_suppliers\"] == 1) & (d[\"late_orders\"] >= 3)\n", + " return d\n", + "\n", + "\n", + "def apply_rules(rec, rules):\n", + " \"\"\"레코드 1건(dict)에 룰을 적용해 (위험 점수, 걸린 룰 목록)을 돌려준다. 값이 없으면 해당 룰은 건너뛴다.\"\"\"\n", + " hits = []\n", + " for r in rules:\n", + " v = rec.get(r[\"field\"])\n", + " if v is None or (isinstance(v, float) and np.isnan(v)):\n", + " continue\n", + " if OPS[r[\"op\"]](v, r[\"value\"]):\n", + " hits.append(r)\n", + " score = sum(r[\"weight\"] for r in hits)\n", + " grade = \"고위험\" if score >= GRADE_CUT[\"고위험\"] else (\"주의\" if score >= GRADE_CUT[\"주의\"] else \"관심\")\n", + " return score, grade, [r[\"id\"] for r in hits]\n", + "\n", + "# [교육용가상자료] 룰과 가상 지침 조항의 대응표(실제 규정 조문이 아니라 연결 방식을 보여 주는 예시)\n", + "CLAUSE = {\"R01\": \"가상지침 3조1항(공급선 다원화)\", \"R02\": \"가상지침 3조2항(해외 의존 관리)\", \"R03\": \"가상지침 4조1항(리드타임 관리)\",\n", + " \"R04\": \"가상지침 4조2항(납기 준수)\", \"R05\": \"가상지침 4조2항(납기 준수)\", \"R06\": \"가상지침 5조1항(단종 관리)\",\n", + " \"R07\": \"가상지침 5조1항(단종 관리)\", \"R08\": \"가상지침 5조2항(최종구매 판단)\", \"R09\": \"가상지침 6조1항(가격 변동 점검)\",\n", + " \"R10\": \"가상지침 3조3항(핵심 부품 관리)\", \"R11\": \"가상지침 4조3항(재고 확보)\", \"R12\": \"가상지침 7조1항(품질 관리)\",\n", + " \"R13\": \"가상지침 3조4항(수출통제 확인)\", \"R14\": \"가상지침 4조3항(재고 확보)\", \"R15\": \"가상지침 3조2항(해외 의존 관리)\",\n", + " \"R16\": \"가상지침 3조1항(공급선 다원화)\"}\n", + "\n", + "rules = parse_rules(RULES_YAML)\n", + "parts, _ = make_parts(100, seed=7)\n", + "df = derive(parts)\n", + "recs = df.to_dict(\"records\")\n", + "\n", + "# 1) 전체 부품 스크리닝과 처리 지연시간(레코드 1건당)\n", + "lat = []\n", + "res = []\n", + "for r in recs:\n", + " t0 = time.perf_counter()\n", + " out = apply_rules(r, rules)\n", + " lat.append((time.perf_counter() - t0) * 1000)\n", + " res.append(out)\n", + "df[\"score\"], df[\"grade\"], df[\"hits\"] = [x[0] for x in res], [x[1] for x in res], [x[2] for x in res]\n", + "print(f\"[룰 설정] 검증 룰 {len(rules)}종(목표 15종 이상: {'달성' if len(rules) >= 15 else '미달'}), 모든 룰에 가상 지침 조항 연결 {all(r['id'] in CLAUSE for r in rules)}\")\n", + "print(f\"룰 처리 지연시간: 평균 {np.mean(lat):.3f}ms, 최대 {np.max(lat):.3f}ms (목표 50ms 이하: {'달성' if max(lat) <= 50 else '미달'})\")\n", + "\n", + "# 2) 등급 분포와 과거 단절 이력(다음 12개월 실제 단절 여부) 대비 탐지력\n", + "print(\"\\n[1차 스크리닝 등급 분포]\")\n", + "print(df.groupby(\"grade\").agg(부품수=(\"part_no\", \"size\"), 실제단절률=(\"disrupted_next12m\", \"mean\")).reindex([\"고위험\", \"주의\", \"관심\"]).round(2).to_string())\n", + "y = df[\"disrupted_next12m\"].to_numpy()\n", + "flag = df[\"grade\"].isin([\"고위험\", \"주의\"]).to_numpy()\n", + "tp = int((flag & y).sum())\n", + "print(f\"고위험·주의 선별: 실제 단절 부품 {y.sum()}건 중 {tp}건 포착(재현율 {tp / y.sum():.0%}), 선별 {flag.sum()}건 중 정밀도 {tp / max(flag.sum(), 1):.0%}\")\n", + "\n", + "# 3) 룰별 적중 건수(어떤 룰이 얼마나 걸리는가)\n", + "cnt = pd.Series([h for hs in df[\"hits\"] for h in hs]).value_counts()\n", + "tbl = pd.DataFrame([{\"룰\": r[\"id\"], \"이름\": r[\"name\"], \"가중치\": r[\"weight\"], \"적중\": int(cnt.get(r[\"id\"], 0)), \"근거 조항\": CLAUSE[r[\"id\"]]} for r in rules])\n", + "print(\"\\n[룰별 적중 현황]\")\n", + "print(tbl.to_string(index=False))\n", + "\n", + "# 4) 상위 위험 부품 5건과 판정 근거(설명 가능성)\n", + "print(\"\\n[고위험 상위 5건과 근거]\")\n", + "for _, r in df.sort_values(\"score\", ascending=False).head(5).iterrows():\n", + " names = \", \".join(next(x[\"name\"] for x in rules if x[\"id\"] == h) for h in r[\"hits\"])\n", + " print(f\" {r['part_no']} {r['category']} 점수 {r['score']:2d} {r['grade']} (적중 룰 {len(r['hits'])}종): {names[:60]}...\")\n", + "\n", + "# 5) 규정 변경 대응: 리드타임 기준을 180일에서 150일로 바꾸는 설정 변경(코드 수정 없음)\n", + "new_yaml = RULES_YAML.replace(\"value: 180\", \"value: 120\")\n", + "rules2 = parse_rules(new_yaml)\n", + "o2 = [apply_rules(r, rules2) for r in recs]\n", + "n_score = sum(a != b[0] for a, b in zip(df[\"score\"], o2)); n_grade = sum(a != b[1] for a, b in zip(df[\"grade\"], o2))\n", + "print(f\"\\n[설정 변경] R03 리드타임 기준 180일 -> 120일: 점수가 바뀐 부품 {n_score}건, 등급이 바뀐 부품 {n_grade}건 (코드 수정 없이 YAML만 변경)\")\n", + "print(\"처리 후 사람 검토: 스크리닝은 1차 선별이며, 최종 위험 판단과 조치는 조달 담당자가 결정한다.\")\n", + "\n", + "fig, ax = plt.subplots(1, 2, figsize=(11, 4))\n", + "ax[0].bar(tbl[\"룰\"], tbl[\"적중\"], color=\"tab:blue\"); ax[0].set_title(\"Rule hits by rule (synthetic)\"); ax[0].set_ylabel(\"parts\")\n", + "g = df.groupby(\"grade\")[\"disrupted_next12m\"].mean().reindex([\"관심\", \"주의\", \"고위험\"])\n", + "ax[1].bar([\"low\", \"watch\", \"high\"], g.values, color=[\"tab:green\", \"gold\", \"tab:red\"]); ax[1].set_title(\"Actual disruption rate by screening grade\"); ax[1].set_ylim(0, 1)\n", + "plt.tight_layout(); plt.show()\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 03 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + "[룰 설정] 검증 룰 16종(목표 15종 이상: 달성), 모든 룰에 가상 지침 조항 연결 True\n", + "룰 처리 지연시간: 평균 0.012ms, 최대 0.043ms (목표 50ms 이하: 달성)\n", + "\n", + "[1차 스크리닝 등급 분포]\n", + " 부품수 실제단절률\n", + "grade \n", + "고위험 20 0.60\n", + "주의 38 0.39\n", + "관심 42 0.29\n", + "고위험·주의 선별: 실제 단절 부품 39건 중 27건 포착(재현율 69%), 선별 58건 중 정밀도 47%\n", + "\n", + "[룰별 적중 현황]\n", + " 룰 이름 가중치 적중 근거 조항\n", + "R01 단일 공급선 3 42 가상지침 3조1항(공급선 다원화)\n", + "R02 해외 조달 의존 1 85 가상지침 3조2항(해외 의존 관리)\n", + "R03 리드타임 180일 초과 2 27 가상지침 4조1항(리드타임 관리)\n", + "R04 납기 지연률 30% 이상 3 49 가상지침 4조2항(납기 준수)\n", + "R05 평균 지연 30일 초과 2 64 가상지침 4조2항(납기 준수)\n", + "R06 단종 예고 3 26 가상지침 5조1항(단종 관리)\n", + "R07 단종 확정 4 11 가상지침 5조1항(단종 관리)\n", + "R08 최종구매 6개월 이내 3 6 가상지침 5조2항(최종구매 판단)\n", + "R09 단가 20% 이상 급등 2 8 가상지침 6조1항(가격 변동 점검)\n", + "R10 핵심(A) 부품 단일 공급 3 14 가상지침 3조3항(핵심 부품 관리)\n", + "R11 재고 가용기간 < 리드타임 3 40 가상지침 4조3항(재고 확보)\n", + "R12 입고 불량률 2% 초과 2 24 가상지침 7조1항(품질 관리)\n", + "R13 수출 허가 필요 2 35 가상지침 3조4항(수출통제 확인)\n", + "R14 재고 없음 2 1 가상지침 4조3항(재고 확보)\n", + "R15 공급국 편중(30% 이상) 1 70 가상지침 3조2항(해외 의존 관리)\n", + "R16 지연 이력 있는 단일 공급선 2 35 가상지침 3조1항(공급선 다원화)\n", + "\n", + "[고위험 상위 5건과 근거]\n", + " P-0087 RF증폭기 점수 26 고위험 (적중 룰 11종): 단일 공급선, 해외 조달 의존, 납기 지연률 30% 이상, 평균 지연 30일 초과, 단종 예고, 최종구매 6...\n", + " P-0028 베어링 점수 25 고위험 (적중 룰 11종): 단일 공급선, 해외 조달 의존, 리드타임 180일 초과, 납기 지연률 30% 이상, 평균 지연 30일 초과,...\n", + " P-0066 커넥터 점수 25 고위험 (적중 룰 11종): 단일 공급선, 해외 조달 의존, 리드타임 180일 초과, 납기 지연률 30% 이상, 평균 지연 30일 초과,...\n", + " P-0033 전원모듈 점수 24 고위험 (적중 룰 11종): 단일 공급선, 해외 조달 의존, 리드타임 180일 초과, 납기 지연률 30% 이상, 평균 지연 30일 초과,...\n", + " P-0077 광학렌즈 점수 24 고위험 (적중 룰 10종): 단일 공급선, 해외 조달 의존, 납기 지연률 30% 이상, 평균 지연 30일 초과, 단종 확정, 핵심(A) ...\n", + "\n", + "[설정 변경] R03 리드타임 기준 180일 -> 120일: 점수가 바뀐 부품 27건, 등급이 바뀐 부품 8건 (코드 수정 없이 YAML만 변경)\n", + "처리 후 사람 검토: 스크리닝은 1차 선별이며, 최종 위험 판단과 조치는 조달 담당자가 결정한다.\n", + "```\n", + "
\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 04. NLP 및 지식 그래프 기반 대체 가능 호환 부품(Alternative Parts) 자동 추천 모델 고도화\n", + "**소요시간**: 약 3.0시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "지식 그래프 기반 대체 가능 호환 부품(Alternative Parts) 자동 추천 모델을 고도화한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **텍스트 유사도와 지식 그래프, 규격 적합성을 결합해 해외 부품별 국산 대체 후보를 추천하고 추천 근거를 제시한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **부품 설명문의 TF-IDF 유사도, 전압·전력·온도·크기·인터페이스 6개 규격 적합성, 동의어를 간선으로 연결한 지식 그래프 이웃 유사도를 가중합해 부품별 Top-3 후보 작성하여 원인을 분석하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `src/ai/prc07_intelligence_module.py` 형태의 결과물을 완성한다.\n", + "\n", + "### 🔍 평가 및 검증 기준 (PASS 조건)\n", + "#### 미션 판정\n", + "\n", + "**평가 구분:** 필수\n", + "\n", + "**핵심 평가영역:** 추천 사유의 합리성 및 실무 수용성\n", + "\n", + "#### 미션 PASS 조건\n", + "\n", + "- 정량 기준: 추천/분류 정확도 85% 이상 달성\n", + "\n", + "\n", + "- 진행 기준: AI 추론 모듈 구현 및 벤치마크 테스트 완료\n", + "\n", + "\n", + "- 검증 방법: 과거 승인/처리 완료 데이터와의 추천 일치도 검증\n", + "\n", + "\n", + "#### 채점자 판정 메모\n", + "\n", + "> 단순 코드 실행 여부뿐만 아니라 출력된 수치와 근거 자료가 군 실무 요구사항에 부합하는지 면밀히 확인한다.\n", + "\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "\"\"\"부품 설명문의 TF-IDF 텍스트 유사도, 규격 적합성, 동의어를 연결한 지식 그래프 이웃 유사도를 결합해 해외 부품별 국산 대체 후보 Top-3를 추천하고, 과거 승인 이력 기준 정확도·요소별 기여·오추천 유형·추천 사유 문장을 확인한다.\"\"\"\n", + "import time\n", + "import numpy as np\n", + "import pandas as pd\n", + "import matplotlib.pyplot as plt\n", + "from sklearn.feature_extraction.text import TfidfVectorizer\n", + "from sklearn.metrics.pairwise import cosine_similarity\n", + "\n", + "# ---------------------------------------------------------------------------\n", + "# [교육용가상자료] 가상 무기체계 부품·공급망 데이터 생성기 (실제 체계·업체·국가·단가 정보 없음)\n", + "# 부품 100종(해외 조달 약 80%)의 공급망 속성과, 국내 부품 카탈로그(과거 승인된 국산 대체품 +\n", + "# 설명은 비슷하지만 규격이 맞지 않는 유사 후보)를 함께 만든다.\n", + "# ---------------------------------------------------------------------------\n", + "COUNTRIES = [\"가상국가A\", \"가상국가B\", \"가상국가C\", \"국내\"]\n", + "CATS = {\n", + " \"전원모듈\": dict(words=[\"DC-DC\", \"컨버터\", \"절연형\", \"전원\", \"정출력\"], syn={\"컨버터\": \"변환장치\", \"절연형\": \"아이솔레이션\", \"정출력\": \"안정화\"}, std=\"MIL-STD-704\", iface=\"단자대\"),\n", + " \"커넥터\": dict(words=[\"원형\", \"커넥터\", \"방수형\", \"다핀\", \"나사체결\"], syn={\"커넥터\": \"접속자\", \"방수형\": \"밀폐형\", \"나사체결\": \"스크루결합\"}, std=\"MIL-DTL-38999\", iface=\"원형핀\"),\n", + " \"유압밸브\": dict(words=[\"유압\", \"솔레노이드\", \"밸브\", \"방향제어\", \"4포트\"], syn={\"밸브\": \"개폐장치\", \"방향제어\": \"유로전환\", \"솔레노이드\": \"전자식\"}, std=\"SAE-AS5440\", iface=\"플랜지\"),\n", + " \"베어링\": dict(words=[\"볼\", \"베어링\", \"고속\", \"밀봉형\", \"회전축\"], syn={\"베어링\": \"축받이\", \"밀봉형\": \"실드형\", \"고속\": \"고회전\"}, std=\"ISO-492\", iface=\"축결합\"),\n", + " \"광학렌즈\": dict(words=[\"적외선\", \"렌즈\", \"게르마늄\", \"반사방지\", \"집광\"], syn={\"렌즈\": \"광학창\", \"집광\": \"초점형성\", \"반사방지\": \"AR코팅\"}, std=\"MIL-PRF-13830\", iface=\"마운트\"),\n", + " \"자이로센서\": dict(words=[\"광섬유\", \"자이로\", \"각속도\", \"센서\", \"관성\"], syn={\"센서\": \"감지기\", \"각속도\": \"회전율\", \"관성\": \"INS용\"}, std=\"IEEE-952\", iface=\"직렬통신\"),\n", + " \"RF증폭기\": dict(words=[\"RF\", \"전력증폭기\", \"광대역\", \"모듈\", \"송신\"], syn={\"전력증폭기\": \"파워앰프\", \"송신\": \"출력단\", \"광대역\": \"와이드밴드\"}, std=\"MIL-STD-461\", iface=\"SMA\"),\n", + " \"제어보드\": dict(words=[\"FPGA\", \"제어보드\", \"임베디드\", \"연산\", \"입출력\"], syn={\"제어보드\": \"컨트롤카드\", \"연산\": \"신호처리\", \"임베디드\": \"내장형\"}, std=\"VITA-46\", iface=\"백플레인\"),\n", + "}\n", + "V_OPTS, W_OPTS = [12, 24, 28], [25, 50, 100]\n", + "VENDORS = [\"가상정밀(주)\", \"가상전자(주)\", \"가상유공압(주)\", \"가상광학(주)\", \"가상테크(주)\", \"가상시스템(주)\"]\n", + "\n", + "\n", + "def describe(cat, words, s):\n", + " return f\"{cat} {' '.join(words)} {s['v']}V {s['w']}W {s['tmin']}~{s['tmax']}C {s['std']}\"\n", + "\n", + "\n", + "def _sig(x):\n", + " return 1 / (1 + np.exp(-x))\n", + "\n", + "\n", + "def make_parts(n=100, seed=7):\n", + " \"\"\"부품 목록(공급망 속성 포함)과 국내 카탈로그를 만든다. 반환: (parts DataFrame, catalog DataFrame)\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " cats, rows, cat_rows = list(CATS), [], []\n", + " for i in range(n):\n", + " cat = cats[i % len(cats)]; c = CATS[cat]\n", + " spec = {\"v\": int(rng.choice(V_OPTS)), \"w\": int(rng.choice(W_OPTS)), \"tmin\": int(rng.choice([-55, -40])),\n", + " \"tmax\": int(rng.choice([85, 125])), \"size_mm\": float(round(rng.uniform(20, 120), 1)), \"std\": c[\"std\"]}\n", + " words = [str(x) for x in rng.permutation(c[\"words\"])[:4]]\n", + " country = str(rng.choice(COUNTRIES, p=[0.35, 0.3, 0.15, 0.2]))\n", + " overseas = country != \"국내\"\n", + " n_sup = int(rng.choice([1, 2, 3], p=[0.45, 0.35, 0.2])) if overseas else int(rng.choice([1, 2, 3]))\n", + " lead = float(round(rng.gamma(3, 45 if overseas else 25) + 20))\n", + " eol = str(rng.choice([\"정상\", \"단종예고\", \"단종\"], p=[0.7, 0.2, 0.1]))\n", + " crit = str(rng.choice([\"A\", \"B\", \"C\"], p=[0.3, 0.4, 0.3]))\n", + " z = (-2.6 + 1.1 * (n_sup == 1) + 0.9 * overseas + 0.006 * (lead - 100) + 1.2 * (eol == \"단종예고\")\n", + " + 2.0 * (eol == \"단종\") + rng.normal(0, 0.6))\n", + " p_late = float(np.clip(_sig(z) * 0.8 + 0.05, 0, 0.9))\n", + " late = int(rng.binomial(12, p_late))\n", + " demand = int(rng.integers(20, 400))\n", + " rows.append({\"part_no\": f\"P-{i + 1:04d}\", \"category\": cat, \"desc\": describe(cat, words, spec), **spec,\n", + " \"iface\": c[\"iface\"], \"country\": country, \"overseas\": overseas, \"n_suppliers\": n_sup,\n", + " \"lead_time_days\": lead, \"orders_12m\": 12, \"late_orders\": late,\n", + " \"avg_delay_days\": float(round(late and rng.gamma(2, 12 + 20 * p_late), 1)),\n", + " \"eol_status\": eol, \"ltb_months\": int(rng.integers(1, 25)) if eol == \"단종예고\" else None,\n", + " \"price_change_pct\": float(round(rng.normal(4 + 12 * p_late, 8), 1)), \"criticality\": crit,\n", + " \"annual_demand\": demand, \"stock_qty\": int(rng.integers(0, demand)),\n", + " \"defect_rate\": float(round(abs(rng.normal(0.8 + 2 * p_late, 0.8)), 2)),\n", + " \"export_license\": bool(overseas and rng.random() < 0.35),\n", + " \"disrupted_next12m\": bool(rng.random() < _sig(z + 0.4))})\n", + " if not overseas:\n", + " continue\n", + " # 과거 승인된 국산 대체품(정답): 동의어로 서술, 규격 호환(전력 여유 허용, 일부는 크기 차이가 커 장착 검토 필요)\n", + " eq_words = [c[\"syn\"].get(w, w) for w in words]\n", + " hard = rng.random() < 0.15 # 서술이 많이 다른 사례\n", + " if hard:\n", + " eq_words = [c[\"syn\"].get(w, w) for w in c[\"words\"] if w not in words][:1] + eq_words[1:3]\n", + " eq = {**spec, \"w\": spec[\"w\"] if rng.random() < 0.7 else W_OPTS[min(W_OPTS.index(spec[\"w\"]) + 1, 2)],\n", + " \"size_mm\": float(round(spec[\"size_mm\"] * (rng.uniform(1.11, 1.15) if rng.random() < 0.12 else rng.uniform(0.95, 1.05)), 1))}\n", + " cat_rows.append({\"cand_no\": f\"D-{len(cat_rows) + 1:04d}\", \"category\": cat, \"desc\": describe(cat, eq_words, eq), **eq,\n", + " \"iface\": c[\"iface\"], \"vendor\": str(rng.choice(VENDORS)), \"approved_for\": f\"P-{i + 1:04d}\", \"kind\": \"equivalent\"})\n", + " # 유사 오답 후보: 설명은 거의 같지만 전압이 다르거나(5V/48V) 상용 온도 등급(0~70C)\n", + " for kind in (\"volt_mismatch\", \"temp_mismatch\"):\n", + " d = dict(spec)\n", + " if kind == \"volt_mismatch\":\n", + " d[\"v\"] = int(rng.choice([5, 48])) # 체계에서 쓰지 않는 전압\n", + " else:\n", + " d[\"tmin\"], d[\"tmax\"] = 0, 70\n", + " d[\"size_mm\"] = float(round(spec[\"size_mm\"] * rng.uniform(0.95, 1.05), 1))\n", + " cat_rows.append({\"cand_no\": f\"D-{len(cat_rows) + 1:04d}\", \"category\": cat, \"desc\": describe(cat, words, d), **d,\n", + " \"iface\": c[\"iface\"], \"vendor\": str(rng.choice(VENDORS)), \"approved_for\": None, \"kind\": kind})\n", + " parts = pd.DataFrame(rows)\n", + " catalog = pd.DataFrame(cat_rows).sample(frac=1, random_state=seed).reset_index(drop=True)\n", + " return parts, catalog\n", + "# ---------------------------------------------------------------------------\n", + "\n", + "# ---- 대체 부품 추천 공통 함수: 텍스트 유사도 + 규격 적합성 + 지식 그래프 이웃 유사도 ----\n", + "WEIGHTS = {\"text\": 0.35, \"spec\": 0.45, \"graph\": 0.20}\n", + "\n", + "\n", + "def spec_fit(p, c):\n", + " \"\"\"규격 적합성 0~1: 6개 조건 충족 비율. 전압·온도 한계(필수 조건)를 못 맞추면 점수를 절반으로 깎는다.\"\"\"\n", + " conds = [p[\"v\"] == c[\"v\"], c[\"w\"] >= p[\"w\"], c[\"tmin\"] <= p[\"tmin\"], c[\"tmax\"] >= p[\"tmax\"],\n", + " abs(c[\"size_mm\"] - p[\"size_mm\"]) / p[\"size_mm\"] <= 0.10, p[\"iface\"] == c[\"iface\"]]\n", + " s = sum(conds) / len(conds)\n", + " return (s * 0.5 if not (conds[0] and conds[2] and conds[3]) else s), conds\n", + "\n", + "\n", + "def graph_nodes(row):\n", + " \"\"\"지식 그래프에서 부품 노드와 연결된 이웃 노드 집합(범주·표준·인터페이스·기능어·등급).\"\"\"\n", + " words = set(row[\"desc\"].split()[1:-4])\n", + " grade = \"군용등급\" if row[\"tmin\"] <= -40 else \"상용등급\"\n", + " return {f\"cat:{row['category']}\", f\"std:{row['std']}\", f\"if:{row['iface']}\", f\"grade:{grade}\", f\"v:{row['v']}\"} | {f\"w:{w}\" for w in words}\n", + "\n", + "\n", + "def build_graph(catalog):\n", + " \"\"\"동의어 사전을 그래프 간선(기능어 = 동의어)으로 넣어 서술이 달라도 같은 기능 노드로 연결되게 한다.\"\"\"\n", + " syn = {f\"w:{b}\": f\"w:{a}\" for c in CATS.values() for a, b in c[\"syn\"].items()}\n", + " canon = lambda nodes: {syn.get(x, x) for x in nodes}\n", + " return {\"canon\": canon, \"nodes\": [canon(graph_nodes(r)) for _, r in catalog.iterrows()]}\n", + "\n", + "\n", + "def graph_sim(g, pn, cn):\n", + " \"\"\"두 부품이 공유하는 이웃 노드 비율(Jaccard).\"\"\"\n", + " pn = g[\"canon\"](pn)\n", + " return len(pn & cn) / max(len(pn | cn), 1)\n", + "\n", + "\n", + "def score_table(parts_df, catalog):\n", + " \"\"\"해외 부품 x 같은 범주 국내 후보 쌍마다 텍스트·규격·그래프 점수를 계산한 표(가중치 적용 전).\"\"\"\n", + " vec = TfidfVectorizer(token_pattern=r\"[^ ]+\").fit(list(parts_df[\"desc\"]) + list(catalog[\"desc\"]))\n", + " text = cosine_similarity(vec.transform(parts_df[\"desc\"]), vec.transform(catalog[\"desc\"]))\n", + " g = build_graph(catalog)\n", + " rows = []\n", + " for i, (_, p) in enumerate(parts_df.iterrows()):\n", + " pn = graph_nodes(p)\n", + " for j, (_, c) in enumerate(catalog.iterrows()):\n", + " if c[\"category\"] != p[\"category\"]:\n", + " continue\n", + " sf, conds = spec_fit(p, c)\n", + " rows.append({\"part_no\": p[\"part_no\"], \"cand_no\": c[\"cand_no\"], \"vendor\": c[\"vendor\"], \"text\": text[i, j],\n", + " \"spec\": sf, \"graph\": graph_sim(g, pn, g[\"nodes\"][j]),\n", + " \"unmet\": \",\".join(n for n, ok in zip([\"전압\", \"전력\", \"저온\", \"고온\", \"크기\", \"인터페이스\"], conds) if not ok) or \"-\",\n", + " \"kind\": c[\"kind\"], \"correct\": c[\"approved_for\"] == p[\"part_no\"]})\n", + " return pd.DataFrame(rows)\n", + "\n", + "\n", + "def recommend(table, weights=None, top=3):\n", + " \"\"\"가중합 점수로 부품별 상위 top개 후보를 고른다.\"\"\"\n", + " w = weights or WEIGHTS\n", + " t = table.assign(score=w[\"text\"] * table[\"text\"] + w[\"spec\"] * table[\"spec\"] + w[\"graph\"] * table[\"graph\"])\n", + " return t.sort_values([\"part_no\", \"score\"], ascending=[True, False]).groupby(\"part_no\").head(top)\n", + "\n", + "\n", + "def hit_rate(recs, k=1):\n", + " \"\"\"과거 승인 이력(정답 대체품)이 상위 k개 안에 들어간 부품 비율.\"\"\"\n", + " return recs.groupby(\"part_no\").apply(lambda d: d[\"correct\"].head(k).any(), include_groups=False).mean()\n", + "\n", + "parts, catalog = make_parts(100, seed=7)\n", + "over = parts[parts[\"overseas\"]].reset_index(drop=True)\n", + "print(f\"[데이터] 해외 조달 부품 {len(over)}종, 국내 후보 카탈로그 {len(catalog)}건(승인 이력 있는 대체품 {int((catalog['kind'] == 'equivalent').sum())}건, 규격 불일치 유사 후보 {int((catalog['kind'] != 'equivalent').sum())}건)\")\n", + "\n", + "t0 = time.perf_counter()\n", + "table = score_table(over, catalog)\n", + "rec = recommend(table)\n", + "ms = (time.perf_counter() - t0) * 1000 / len(over)\n", + "print(f\"추천 처리: 부품 1종당 {ms:.1f}ms (후보 쌍 {len(table)}개 점수 계산 포함)\")\n", + "\n", + "# 1) 요소별 기여: 어느 신호를 쓰느냐에 따라 정확도가 어떻게 달라지는가\n", + "combos = {\"텍스트 유사도만\": {\"text\": 1, \"spec\": 0, \"graph\": 0}, \"규격 적합성만\": {\"text\": 0, \"spec\": 1, \"graph\": 0},\n", + " \"그래프만\": {\"text\": 0, \"spec\": 0, \"graph\": 1}, \"텍스트+그래프\": {\"text\": .5, \"spec\": 0, \"graph\": .5},\n", + " \"텍스트+규격\": {\"text\": .4, \"spec\": .6, \"graph\": 0}, \"세 신호 결합(운용)\": WEIGHTS}\n", + "rows = []\n", + "for name, w in combos.items():\n", + " r = recommend(table, w)\n", + " bad = (r.groupby(\"part_no\").head(1)[\"kind\"] != \"equivalent\").mean()\n", + " rows.append({\"방식\": name, \"Top-1 정확도\": hit_rate(r, 1), \"Top-3 정확도\": hit_rate(r, 3), \"1순위가 규격불일치 후보인 비율\": bad})\n", + "ab = pd.DataFrame(rows)\n", + "print(\"\\n[요소별 기여(과거 승인 대체품을 상위에 올렸는가)]\")\n", + "print(ab.round(3).to_string(index=False))\n", + "acc1, acc3 = hit_rate(rec, 1), hit_rate(rec, 3)\n", + "print(f\"\\n운용 방식 Top-1 정확도 {acc1:.1%}, Top-3 정확도 {acc3:.1%} (목표 85% 이상: {'달성' if acc1 >= 0.85 else '미달'})\")\n", + "\n", + "# 2) 오추천 분석: 1순위가 정답이 아닌 부품\n", + "top1 = rec.groupby(\"part_no\").head(1)\n", + "wrong = top1[~top1[\"correct\"]]\n", + "print(f\"\\n[1순위 오추천 {len(wrong)}건 분석]\")\n", + "for _, w in wrong.iterrows():\n", + " right = table[(table[\"part_no\"] == w[\"part_no\"]) & table[\"correct\"]].iloc[0]\n", + " if w[\"kind\"] != \"equivalent\":\n", + " why = f\"규격 불일치 후보가 상위(미충족 {w['unmet']}) -> 규격 가중치 보강 필요\"\n", + " elif w[\"unmet\"] == \"-\":\n", + " why = \"다른 부품용으로 승인된 같은 범주 대체품이 규격도 충족 -> 승인 이력상 오답, 실무상 검토 후보\"\n", + " else:\n", + " why = f\"다른 부품용 승인 대체품이 상위(정답 후보는 크기 차이로 감점) -> 크기 허용 오차 재검토\"\n", + " print(f\" {w['part_no']}: 추천 {w['cand_no']}(점수 {w['score']:.2f}, {w['kind']}) / 정답 {right['cand_no']}(규격 미충족 {right['unmet']}) -> {why}\")\n", + "\n", + "# 3) 추천 사유 문장(실무 수용성: 근거를 사람이 읽을 수 있게)\n", + "print(\"\\n[추천 사유 예시]\")\n", + "for pn in over[\"part_no\"].head(3):\n", + " r = rec[rec[\"part_no\"] == pn].iloc[0]\n", + " p = parts[parts[\"part_no\"] == pn].iloc[0]\n", + " ok = \"규격 6개 항목 모두 충족\" if r[\"unmet\"] == \"-\" else f\"규격 미충족 항목 {r['unmet']}(기술 검토 필요)\"\n", + " print(f\" {pn} {p['category']} -> {r['cand_no']}({r['vendor']}): 설명 유사도 {r['text']:.2f}, 규격 적합성 {r['spec']:.2f}, 그래프 연결 {r['graph']:.2f} / {ok}\")\n", + "print(\"※ 추천은 후보 제시이며 규격 적합성 최종 판단과 계약 의뢰는 기술검토관·계약 담당자가 결정한다.\")\n", + "\n", + "fig, ax = plt.subplots(1, 2, figsize=(11, 4))\n", + "x = np.arange(len(ab)); ax[0].bar(x - .2, ab[\"Top-1 정확도\"], .4, label=\"top-1\"); ax[0].bar(x + .2, ab[\"Top-3 정확도\"], .4, label=\"top-3\")\n", + "ax[0].set_xticks(x, [\"text\", \"spec\", \"graph\", \"text+graph\", \"text+spec\", \"all 3\"], rotation=20); ax[0].axhline(.85, color=\"r\", ls=\"--\"); ax[0].legend(); ax[0].set_title(\"Accuracy by signal combination\")\n", + "allsc = recommend(table, WEIGHTS, 999)\n", + "ax[1].hist([allsc[allsc[\"correct\"]][\"score\"], allsc[~allsc[\"correct\"]][\"score\"]], bins=15, label=[\"approved\", \"other\"], stacked=True)\n", + "ax[1].legend(); ax[1].set_title(\"Score distribution (candidates)\"); ax[1].set_xlabel(\"weighted score\")\n", + "plt.tight_layout(); plt.show()\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 04 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + "[데이터] 해외 조달 부품 85종, 국내 후보 카탈로그 255건(승인 이력 있는 대체품 85건, 규격 불일치 유사 후보 170건)\n", + "추천 처리: 부품 1종당 14.0ms (후보 쌍 2745개 점수 계산 포함)\n", + "\n", + "[요소별 기여(과거 승인 대체품을 상위에 올렸는가)]\n", + " 방식 Top-1 정확도 Top-3 정확도 1순위가 규격불일치 후보인 비율\n", + " 텍스트 유사도만 0.000 0.000 1.000\n", + " 규격 적합성만 0.812 1.000 0.000\n", + " 그래프만 0.553 0.741 0.047\n", + " 텍스트+그래프 0.000 0.082 1.000\n", + " 텍스트+규격 0.871 0.988 0.000\n", + "세 신호 결합(운용) 0.894 0.976 0.012\n", + "\n", + "운용 방식 Top-1 정확도 89.4%, Top-3 정확도 97.6% (목표 85% 이상: 달성)\n", + "\n", + "[1순위 오추천 9건 분석]\n", + " P-0009: 추천 D-0187(점수 0.77, equivalent) / 정답 D-0016(규격 미충족 -) -> 다른 부품용으로 승인된 같은 범주 대체품이 규격도 충족 -> 승인 이력상 오답, 실무상 검토 후보\n", + " P-0028: 추천 D-0046(점수 0.76, equivalent) / 정답 D-0070(규격 미충족 크기) -> 다른 부품용으로 승인된 같은 범주 대체품이 규격도 충족 -> 승인 이력상 오답, 실무상 검토 후보\n", + " P-0032: 추천 D-0034(점수 0.82, equivalent) / 정답 D-0082(규격 미충족 크기) -> 다른 부품용으로 승인된 같은 범주 대체품이 규격도 충족 -> 승인 이력상 오답, 실무상 검토 후보\n", + " P-0046: 추천 D-0119(점수 0.67, volt_mismatch) / 정답 D-0118(규격 미충족 크기) -> 규격 불일치 후보가 상위(미충족 전압) -> 규격 가중치 보강 필요\n", + " P-0049: 추천 D-0016(점수 0.82, equivalent) / 정답 D-0124(규격 미충족 -) -> 다른 부품용으로 승인된 같은 범주 대체품이 규격도 충족 -> 승인 이력상 오답, 실무상 검토 후보\n", + " P-0056: 추천 D-0058(점수 0.84, equivalent) / 정답 D-0145(규격 미충족 -) -> 다른 부품용으로 승인된 같은 범주 대체품이 규격도 충족 -> 승인 이력상 오답, 실무상 검토 후보\n", + " P-0060: 추천 D-0235(점수 0.81, equivalent) / 정답 D-0154(규격 미충족 -) -> 다른 부품용으로 승인된 같은 범주 대체품이 규격도 충족 -> 승인 이력상 오답, 실무상 검토 후보\n", + " P-0063: 추천 D-0181(점수 0.76, equivalent) / 정답 D-0163(규격 미충족 -) -> 다른 부품용으로 승인된 같은 범주 대체품이 규격도 충족 -> 승인 이력상 오답, 실무상 검토 후보\n", + " P-0077: 추천 D-0049(점수 0.76, equivalent) / 정답 D-0196(규격 미충족 크기) -> 다른 부품용 승인 대체품이 상위(정답 후보는 크기 차이로 감점) -> 크기 허용 오차 재검토\n", + "\n", + "[추천 사유 예시]\n", + " P-0002 커넥터 -> D-0001(가상정밀(주)): 설명 유사도 0.64, 규격 적합성 1.00, 그래프 연결 1.00 / 규격 6개 항목 모두 충족\n", + " P-0003 유압밸브 -> D-0004(가상정밀(주)): 설명 유사도 0.47, 규격 적합성 1.00, 그래프 연결 1.00 / 규격 6개 항목 모두 충족\n", + " P-0004 베어링 -> D-0007(가상정밀(주)): 설명 유사도 0.54, 규격 적합성 1.00, 그래프 연결 1.00 / 규격 6개 항목 모두 충족\n", + "※ 추천은 후보 제시이며 규격 적합성 최종 판단과 계약 의뢰는 기술검토관·계약 담당자가 결정한다.\n", + "```\n", + "
\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 05. 공급망 붕괴 위험도(Supply Chain Risk Score) 및 조기경보 산출 엔진 개발\n", + "**소요시간**: 약 3.0시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "공급망 붕괴 위험도 및 조기경보 산출 엔진을 개발한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **공급망 붕괴 위험도(Supply Chain Risk Score)와 월별 지연 추이 기반 조기경보를 산출하는 엔진을 만들고 입력-분석-검토-결과 4개 화면으로 구성한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **공급선 수, 해외 조달, 리드타임, 납기 지연률, 단종 상태 등 12개 지표로 로지스틱 회귀 위험도(0~100점)를 학습하고 별도 가상 부품군에서 AUC와 등급별 실제 단절률 확인하고 유효성을 점검하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `src/ui/prc07_app.py` 형태의 결과물을 완성한다.\n", + "\n", + "### 🔍 평가 및 검증 기준 (PASS 조건)\n", + "#### 미션 판정\n", + "\n", + "**평가 구분:** 필수\n", + "\n", + "**핵심 평가영역:** 군 실무자 친화적 디자인 및 조작 편의성\n", + "\n", + "#### 미션 PASS 조건\n", + "\n", + "- 정량 기준: 핵심 화면 4종(입력-분석-검토-결과) 구현 완료\n", + "\n", + "\n", + "- 진행 기준: UI 프로토타입 로컬 서버 구동 및 전체 페이지 네비게이션 확인\n", + "\n", + "\n", + "- 검증 방법: 사용성 평가(UX Walkthrough) 및 인터랙션 테스트\n", + "\n", + "\n", + "#### 채점자 판정 메모\n", + "\n", + "> 단순 코드 실행 여부뿐만 아니라 출력된 수치와 근거 자료가 군 실무 요구사항에 부합하는지 면밀히 확인한다.\n", + "\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "\"\"\"12개 공급망 지표로 부품별 공급망 붕괴 위험도(0~100점)를 로지스틱 회귀로 산출하고 월별 납기 지연 추이의 EWMA로 조기경보를 내는 엔진을 만든 뒤, 입력-분석-검토-결과 4개 화면(텍스트 패널과 그래프)을 구성해 별도 가상 부품군에서 성능을 검증한다.\"\"\"\n", + "import numpy as np\n", + "import pandas as pd\n", + "import matplotlib.pyplot as plt\n", + "from sklearn.linear_model import LogisticRegression\n", + "from sklearn.pipeline import make_pipeline\n", + "from sklearn.preprocessing import StandardScaler\n", + "from sklearn.metrics import roc_auc_score, brier_score_loss\n", + "\n", + "# ---------------------------------------------------------------------------\n", + "# [교육용가상자료] 가상 무기체계 부품·공급망 데이터 생성기 (실제 체계·업체·국가·단가 정보 없음)\n", + "# 부품 100종(해외 조달 약 80%)의 공급망 속성과, 국내 부품 카탈로그(과거 승인된 국산 대체품 +\n", + "# 설명은 비슷하지만 규격이 맞지 않는 유사 후보)를 함께 만든다.\n", + "# ---------------------------------------------------------------------------\n", + "COUNTRIES = [\"가상국가A\", \"가상국가B\", \"가상국가C\", \"국내\"]\n", + "CATS = {\n", + " \"전원모듈\": dict(words=[\"DC-DC\", \"컨버터\", \"절연형\", \"전원\", \"정출력\"], syn={\"컨버터\": \"변환장치\", \"절연형\": \"아이솔레이션\", \"정출력\": \"안정화\"}, std=\"MIL-STD-704\", iface=\"단자대\"),\n", + " \"커넥터\": dict(words=[\"원형\", \"커넥터\", \"방수형\", \"다핀\", \"나사체결\"], syn={\"커넥터\": \"접속자\", \"방수형\": \"밀폐형\", \"나사체결\": \"스크루결합\"}, std=\"MIL-DTL-38999\", iface=\"원형핀\"),\n", + " \"유압밸브\": dict(words=[\"유압\", \"솔레노이드\", \"밸브\", \"방향제어\", \"4포트\"], syn={\"밸브\": \"개폐장치\", \"방향제어\": \"유로전환\", \"솔레노이드\": \"전자식\"}, std=\"SAE-AS5440\", iface=\"플랜지\"),\n", + " \"베어링\": dict(words=[\"볼\", \"베어링\", \"고속\", \"밀봉형\", \"회전축\"], syn={\"베어링\": \"축받이\", \"밀봉형\": \"실드형\", \"고속\": \"고회전\"}, std=\"ISO-492\", iface=\"축결합\"),\n", + " \"광학렌즈\": dict(words=[\"적외선\", \"렌즈\", \"게르마늄\", \"반사방지\", \"집광\"], syn={\"렌즈\": \"광학창\", \"집광\": \"초점형성\", \"반사방지\": \"AR코팅\"}, std=\"MIL-PRF-13830\", iface=\"마운트\"),\n", + " \"자이로센서\": dict(words=[\"광섬유\", \"자이로\", \"각속도\", \"센서\", \"관성\"], syn={\"센서\": \"감지기\", \"각속도\": \"회전율\", \"관성\": \"INS용\"}, std=\"IEEE-952\", iface=\"직렬통신\"),\n", + " \"RF증폭기\": dict(words=[\"RF\", \"전력증폭기\", \"광대역\", \"모듈\", \"송신\"], syn={\"전력증폭기\": \"파워앰프\", \"송신\": \"출력단\", \"광대역\": \"와이드밴드\"}, std=\"MIL-STD-461\", iface=\"SMA\"),\n", + " \"제어보드\": dict(words=[\"FPGA\", \"제어보드\", \"임베디드\", \"연산\", \"입출력\"], syn={\"제어보드\": \"컨트롤카드\", \"연산\": \"신호처리\", \"임베디드\": \"내장형\"}, std=\"VITA-46\", iface=\"백플레인\"),\n", + "}\n", + "V_OPTS, W_OPTS = [12, 24, 28], [25, 50, 100]\n", + "VENDORS = [\"가상정밀(주)\", \"가상전자(주)\", \"가상유공압(주)\", \"가상광학(주)\", \"가상테크(주)\", \"가상시스템(주)\"]\n", + "\n", + "\n", + "def describe(cat, words, s):\n", + " return f\"{cat} {' '.join(words)} {s['v']}V {s['w']}W {s['tmin']}~{s['tmax']}C {s['std']}\"\n", + "\n", + "\n", + "def _sig(x):\n", + " return 1 / (1 + np.exp(-x))\n", + "\n", + "\n", + "def make_parts(n=100, seed=7):\n", + " \"\"\"부품 목록(공급망 속성 포함)과 국내 카탈로그를 만든다. 반환: (parts DataFrame, catalog DataFrame)\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " cats, rows, cat_rows = list(CATS), [], []\n", + " for i in range(n):\n", + " cat = cats[i % len(cats)]; c = CATS[cat]\n", + " spec = {\"v\": int(rng.choice(V_OPTS)), \"w\": int(rng.choice(W_OPTS)), \"tmin\": int(rng.choice([-55, -40])),\n", + " \"tmax\": int(rng.choice([85, 125])), \"size_mm\": float(round(rng.uniform(20, 120), 1)), \"std\": c[\"std\"]}\n", + " words = [str(x) for x in rng.permutation(c[\"words\"])[:4]]\n", + " country = str(rng.choice(COUNTRIES, p=[0.35, 0.3, 0.15, 0.2]))\n", + " overseas = country != \"국내\"\n", + " n_sup = int(rng.choice([1, 2, 3], p=[0.45, 0.35, 0.2])) if overseas else int(rng.choice([1, 2, 3]))\n", + " lead = float(round(rng.gamma(3, 45 if overseas else 25) + 20))\n", + " eol = str(rng.choice([\"정상\", \"단종예고\", \"단종\"], p=[0.7, 0.2, 0.1]))\n", + " crit = str(rng.choice([\"A\", \"B\", \"C\"], p=[0.3, 0.4, 0.3]))\n", + " z = (-2.6 + 1.1 * (n_sup == 1) + 0.9 * overseas + 0.006 * (lead - 100) + 1.2 * (eol == \"단종예고\")\n", + " + 2.0 * (eol == \"단종\") + rng.normal(0, 0.6))\n", + " p_late = float(np.clip(_sig(z) * 0.8 + 0.05, 0, 0.9))\n", + " late = int(rng.binomial(12, p_late))\n", + " demand = int(rng.integers(20, 400))\n", + " rows.append({\"part_no\": f\"P-{i + 1:04d}\", \"category\": cat, \"desc\": describe(cat, words, spec), **spec,\n", + " \"iface\": c[\"iface\"], \"country\": country, \"overseas\": overseas, \"n_suppliers\": n_sup,\n", + " \"lead_time_days\": lead, \"orders_12m\": 12, \"late_orders\": late,\n", + " \"avg_delay_days\": float(round(late and rng.gamma(2, 12 + 20 * p_late), 1)),\n", + " \"eol_status\": eol, \"ltb_months\": int(rng.integers(1, 25)) if eol == \"단종예고\" else None,\n", + " \"price_change_pct\": float(round(rng.normal(4 + 12 * p_late, 8), 1)), \"criticality\": crit,\n", + " \"annual_demand\": demand, \"stock_qty\": int(rng.integers(0, demand)),\n", + " \"defect_rate\": float(round(abs(rng.normal(0.8 + 2 * p_late, 0.8)), 2)),\n", + " \"export_license\": bool(overseas and rng.random() < 0.35),\n", + " \"disrupted_next12m\": bool(rng.random() < _sig(z + 0.4))})\n", + " if not overseas:\n", + " continue\n", + " # 과거 승인된 국산 대체품(정답): 동의어로 서술, 규격 호환(전력 여유 허용, 일부는 크기 차이가 커 장착 검토 필요)\n", + " eq_words = [c[\"syn\"].get(w, w) for w in words]\n", + " hard = rng.random() < 0.15 # 서술이 많이 다른 사례\n", + " if hard:\n", + " eq_words = [c[\"syn\"].get(w, w) for w in c[\"words\"] if w not in words][:1] + eq_words[1:3]\n", + " eq = {**spec, \"w\": spec[\"w\"] if rng.random() < 0.7 else W_OPTS[min(W_OPTS.index(spec[\"w\"]) + 1, 2)],\n", + " \"size_mm\": float(round(spec[\"size_mm\"] * (rng.uniform(1.11, 1.15) if rng.random() < 0.12 else rng.uniform(0.95, 1.05)), 1))}\n", + " cat_rows.append({\"cand_no\": f\"D-{len(cat_rows) + 1:04d}\", \"category\": cat, \"desc\": describe(cat, eq_words, eq), **eq,\n", + " \"iface\": c[\"iface\"], \"vendor\": str(rng.choice(VENDORS)), \"approved_for\": f\"P-{i + 1:04d}\", \"kind\": \"equivalent\"})\n", + " # 유사 오답 후보: 설명은 거의 같지만 전압이 다르거나(5V/48V) 상용 온도 등급(0~70C)\n", + " for kind in (\"volt_mismatch\", \"temp_mismatch\"):\n", + " d = dict(spec)\n", + " if kind == \"volt_mismatch\":\n", + " d[\"v\"] = int(rng.choice([5, 48])) # 체계에서 쓰지 않는 전압\n", + " else:\n", + " d[\"tmin\"], d[\"tmax\"] = 0, 70\n", + " d[\"size_mm\"] = float(round(spec[\"size_mm\"] * rng.uniform(0.95, 1.05), 1))\n", + " cat_rows.append({\"cand_no\": f\"D-{len(cat_rows) + 1:04d}\", \"category\": cat, \"desc\": describe(cat, words, d), **d,\n", + " \"iface\": c[\"iface\"], \"vendor\": str(rng.choice(VENDORS)), \"approved_for\": None, \"kind\": kind})\n", + " parts = pd.DataFrame(rows)\n", + " catalog = pd.DataFrame(cat_rows).sample(frac=1, random_state=seed).reset_index(drop=True)\n", + " return parts, catalog\n", + "# ---------------------------------------------------------------------------\n", + "\n", + "# ---- 공급망 붕괴 위험도(Supply Chain Risk Score) 공통 함수: 로지스틱 회귀 + 월별 지연 추이 조기경보 ----\n", + "RISK_FEATS = [\"n_suppliers\", \"overseas\", \"lead_time_days\", \"delay_rate\", \"avg_delay_days\", \"eol_ord\", \"ltb_short\", \"price_change_pct\",\n", + " \"crit_ord\", \"cover_gap_days\", \"defect_rate\", \"export_license\"]\n", + "FEAT_KO = {\"n_suppliers\": \"공급선 수\", \"overseas\": \"해외 조달\", \"lead_time_days\": \"리드타임\", \"delay_rate\": \"납기 지연률\", \"avg_delay_days\": \"평균 지연일\",\n", + " \"eol_ord\": \"단종 상태\", \"ltb_short\": \"최종구매 임박\", \"price_change_pct\": \"단가 변동\", \"crit_ord\": \"체계 중요도\",\n", + " \"cover_gap_days\": \"재고-리드타임 격차\", \"defect_rate\": \"입고 불량률\", \"export_license\": \"수출 허가 필요\"}\n", + "WARN_CUT = {\"경보\": 60, \"주의\": 35} # 위험도 점수(0~100) 등급 기준\n", + "\n", + "\n", + "def risk_frame(parts):\n", + " d = parts.copy()\n", + " d[\"delay_rate\"] = d[\"late_orders\"] / d[\"orders_12m\"].clip(lower=1)\n", + " d[\"eol_ord\"] = d[\"eol_status\"].map({\"정상\": 0, \"단종예고\": 1, \"단종\": 2})\n", + " d[\"ltb_short\"] = (d[\"ltb_months\"].fillna(99) <= 6).astype(int)\n", + " d[\"crit_ord\"] = d[\"criticality\"].map({\"C\": 0, \"B\": 1, \"A\": 2})\n", + " d[\"cover_gap_days\"] = d[\"stock_qty\"] / d[\"annual_demand\"].clip(lower=1) * 365 - d[\"lead_time_days\"]\n", + " for c in (\"overseas\", \"export_license\"):\n", + " d[c] = d[c].astype(int)\n", + " return d\n", + "\n", + "\n", + "def fit_risk(parts):\n", + " d = risk_frame(parts)\n", + " return make_pipeline(StandardScaler(), LogisticRegression(C=1.0, max_iter=500)).fit(d[RISK_FEATS], d[\"disrupted_next12m\"].astype(int))\n", + "\n", + "\n", + "def risk_score(model, parts):\n", + " \"\"\"붕괴 위험도 0~100점 = 다음 12개월 내 공급 단절 확률 x 100.\"\"\"\n", + " return model.predict_proba(risk_frame(parts)[RISK_FEATS])[:, 1] * 100\n", + "\n", + "\n", + "def drivers(model, parts, k=3):\n", + " \"\"\"부품별 위험 상승 요인 상위 k개: (표준화 값 x 회귀 계수)가 큰 순서.\"\"\"\n", + " sc, lr = model[0], model[-1]\n", + " z = sc.transform(risk_frame(parts)[RISK_FEATS]) * lr.coef_[0]\n", + " return [[FEAT_KO[RISK_FEATS[j]] for j in np.argsort(-row)[:k] if row[j] > 0] for row in z]\n", + "\n", + "\n", + "def monthly_delay(parts, seed=5):\n", + " \"\"\"[교육용가상자료] 부품별 최근 12개월 평균 납기 지연일. 단절될 부품은 단절 3~6개월 전부터 지연이 서서히 늘어난다.\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " out, onset = [], []\n", + " for _, p in parts.iterrows():\n", + " base = 3 + 0.15 * p[\"avg_delay_days\"]\n", + " s = base + rng.normal(0, 1.2, 12)\n", + " o = -1\n", + " if p[\"disrupted_next12m\"] and rng.random() < 0.7:\n", + " o = int(rng.integers(5, 9)) # 12개월 구간 내 상승 시작 월\n", + " s[o:] += np.linspace(1.5, 12, 12 - o) + rng.normal(0, 1.0, 12 - o)\n", + " out.append(s); onset.append(o)\n", + " return np.array(out), np.array(onset)\n", + "\n", + "\n", + "def ewma_alarm(series, base_n=5, lam=0.3, k=3.0):\n", + " \"\"\"처음 base_n개월을 기준선으로 삼아 EWMA가 기준선 + k x 표준편차를 넘는 첫 달(없으면 -1).\"\"\"\n", + " mu, sd = series[:base_n].mean(), series[:base_n].std() + 0.5\n", + " e = mu\n", + " for t in range(base_n, len(series)):\n", + " e = lam * series[t] + (1 - lam) * e\n", + " if e > mu + k * sd * np.sqrt(lam / (2 - lam)):\n", + " return t\n", + " return -1\n", + "\n", + "train, _ = make_parts(600, seed=11) # 학습용 가상 부품군\n", + "test, _ = make_parts(200, seed=7) # 검증용 별도 가상 부품군\n", + "model = fit_risk(train)\n", + "test = test.assign(score=risk_score(model, test), why=drivers(model, test))\n", + "y = test[\"disrupted_next12m\"].astype(int)\n", + "test[\"level\"] = np.where(test[\"score\"] >= WARN_CUT[\"경보\"], \"경보\", np.where(test[\"score\"] >= WARN_CUT[\"주의\"], \"주의\", \"정상\"))\n", + "\n", + "# ===== 화면 1. 입력: 입력 요약과 검증 =====\n", + "miss = int(test[[\"lead_time_days\", \"n_suppliers\", \"late_orders\"]].isna().sum().sum())\n", + "print(\"[화면 1. 입력] [교육용가상자료]\")\n", + "print(f\" 검증용 부품 {len(test)}종 / 입력 지표 {len(RISK_FEATS)}개 / 필수값 결측 {miss}건 / 학습 부품 {len(train)}종\")\n", + "print(f\" 해외 조달 {int(test['overseas'].sum())}종, 단종예고·단종 {int((test['eol_status'] != '정상').sum())}종, 단일 공급선 {int((test['n_suppliers'] == 1).sum())}종\")\n", + "\n", + "# ===== 화면 2. 분석: 위험도 분포와 성능 =====\n", + "auc = roc_auc_score(y, test[\"score\"])\n", + "print(\"\\n[화면 2. 분석] 위험도 점수 분포와 모델 성능\")\n", + "print(f\" ROC-AUC {auc:.3f}, Brier {brier_score_loss(y, test['score'] / 100):.3f} (단절 실제 비율 {y.mean():.0%})\")\n", + "print(test.groupby(\"level\").agg(부품수=(\"part_no\", \"size\"), 평균점수=(\"score\", \"mean\"), 실제단절률=(\"disrupted_next12m\", \"mean\")).reindex([\"경보\", \"주의\", \"정상\"]).round(2).to_string())\n", + "coef = pd.Series(model[-1].coef_[0], index=[FEAT_KO[f] for f in RISK_FEATS]).sort_values(ascending=False)\n", + "print(\" 위험 상승 요인(회귀 계수 상위 4):\", \", \".join(f\"{k} {v:+.2f}\" for k, v in coef.head(4).items()))\n", + "\n", + "# ===== 조기경보: 월별 지연 추이 =====\n", + "md, onset = monthly_delay(test)\n", + "alarm = np.array([ewma_alarm(s) for s in md])\n", + "dis = onset >= 0\n", + "det = (alarm[dis] >= 0).mean()\n", + "delay = (alarm[dis & (alarm >= 0)] - onset[dis & (alarm >= 0)]).mean()\n", + "fa = (alarm[~dis] >= 0).mean()\n", + "print(f\"\\n[조기경보] 지연 상승형 단절 부품 {int(dis.sum())}종 중 {det:.0%} 감지, 상승 시작 후 평균 {delay:.1f}개월 만에 경보, 정상 부품 오경보율 {fa:.1%}\")\n", + "\n", + "# ===== 화면 3. 검토: 경보 부품을 담당자가 확인 =====\n", + "rev = test[test[\"level\"] == \"경보\"].sort_values(\"score\", ascending=False)\n", + "print(f\"\\n[화면 3. 검토] 경보 {len(rev)}종 중 상위 5종 (담당자가 근거를 확인하고 조치 여부를 결정)\")\n", + "for _, r in rev.head(5).iterrows():\n", + " print(f\" {r['part_no']} {r['category']} {r['score']:.0f}점 | 요인: {', '.join(r['why']) or '-'} | 단종 {r['eol_status']}, 공급선 {r['n_suppliers']}개\")\n", + "\n", + "# ===== 화면 4. 결과: 요약과 내보내기 =====\n", + "top = rev.head(10)\n", + "print(\"\\n[화면 4. 결과]\")\n", + "print(f\" 경보 {len(rev)}종, 주의 {int((test['level'] == '주의').sum())}종, 정상 {int((test['level'] == '정상').sum())}종\")\n", + "print(f\" 경보 등급의 실제 단절 {int(rev['disrupted_next12m'].sum())}종({rev['disrupted_next12m'].mean():.0%}) vs 정상 등급 {test[test['level'] == '정상']['disrupted_next12m'].mean():.0%}\")\n", + "print(\" 내보내기 항목: 부품번호, 점수, 등급, 요인, 조기경보 월, 검토 상태(미검토)\")\n", + "print(\"화면 4종(입력-분석-검토-결과) 구현: 4/4 (목표 4종: 달성)\")\n", + "print(\"※ 점수는 우선순위를 정하는 참고값이며, 실제 단절 여부와 조치는 담당자가 확인한다.\")\n", + "\n", + "fig, ax = plt.subplots(2, 2, figsize=(11, 7))\n", + "ax[0, 0].bar([\"overseas\", \"single src\", \"EOL\"], [test[\"overseas\"].sum(), (test[\"n_suppliers\"] == 1).sum(), (test[\"eol_status\"] != \"정상\").sum()]); ax[0, 0].set_title(\"Screen 1: input summary (count)\")\n", + "ax[0, 1].hist([test[y == 0][\"score\"], test[y == 1][\"score\"]], bins=15, label=[\"no disruption\", \"disrupted\"], stacked=True); ax[0, 1].legend(); ax[0, 1].set_title(\"Screen 2: risk score distribution\")\n", + "i = int(np.where(dis & (alarm >= 0))[0][0]); ax[1, 0].plot(md[i], marker=\"o\"); ax[1, 0].axvline(alarm[i], color=\"r\", ls=\"--\"); ax[1, 0].set_title(\"Screen 3: early warning (delay days, red = alarm)\")\n", + "ax[1, 1].barh(top[\"part_no\"], top[\"score\"], color=\"tab:red\"); ax[1, 1].invert_yaxis(); ax[1, 1].set_title(\"Screen 4: top-10 alert parts\")\n", + "plt.tight_layout(); plt.show()\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 05 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + "[화면 1. 입력] [교육용가상자료]\n", + " 검증용 부품 200종 / 입력 지표 12개 / 필수값 결측 0건 / 학습 부품 600종\n", + " 해외 조달 159종, 단종예고·단종 60종, 단일 공급선 89종\n", + "\n", + "[화면 2. 분석] 위험도 점수 분포와 모델 성능\n", + " ROC-AUC 0.693, Brier 0.222 (단절 실제 비율 40%)\n", + " 부품수 평균점수 실제단절률\n", + "level \n", + "경보 51 76.75 0.59\n", + "주의 59 47.63 0.42\n", + "정상 90 22.39 0.27\n", + " 위험 상승 요인(회귀 계수 상위 4): 납기 지연률 +0.58, 리드타임 +0.50, 단종 상태 +0.48, 수출 허가 필요 +0.12\n", + "\n", + "[조기경보] 지연 상승형 단절 부품 49종 중 100% 감지, 상승 시작 후 평균 1.9개월 만에 경보, 정상 부품 오경보율 2.0%\n", + "\n", + "[화면 3. 검토] 경보 51종 중 상위 5종 (담당자가 근거를 확인하고 조치 여부를 결정)\n", + " P-0066 커넥터 96점 | 요인: 단종 상태, 납기 지연률, 리드타임 | 단종 단종, 공급선 1개\n", + " P-0080 제어보드 96점 | 요인: 단종 상태, 납기 지연률, 리드타임 | 단종 단종, 공급선 1개\n", + " P-0036 베어링 94점 | 요인: 단종 상태, 납기 지연률, 공급선 수 | 단종 단종, 공급선 1개\n", + " P-0003 유압밸브 94점 | 요인: 단종 상태, 납기 지연률, 리드타임 | 단종 단종, 공급선 1개\n", + " P-0140 베어링 94점 | 요인: 리드타임, 납기 지연률, 공급선 수 | 단종 정상, 공급선 1개\n", + "\n", + "[화면 4. 결과]\n", + " 경보 51종, 주의 59종, 정상 90종\n", + " 경보 등급의 실제 단절 30종(59%) vs 정상 등급 27%\n", + " 내보내기 항목: 부품번호, 점수, 등급, 요인, 조기경보 월, 검토 상태(미검토)\n", + "화면 4종(입력-분석-검토-결과) 구현: 4/4 (목표 4종: 달성)\n", + "※ 점수는 우선순위를 정하는 참고값이며, 실제 단절 여부와 조치는 담당자가 확인한다.\n", + "```\n", + "
\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 06. 부품 조달 위험 공급망 지도 및 대체선 추천 인터랙티브 대시보드 구현\n", + "**소요시간**: 약 2.5시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "부품 조달 위험 공급망 지도 및 대체선 추천 대시보드를 구현한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **부품 조달 위험 공급망 지도와 대체선 추천 대시보드에 입력 검증과 에러 핸들러를 붙여 깨진 입력에도 멈추지 않게 한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **국가와 부품 범주별 평균 위험을 보이는 공급망 지도, 리드타임과 지연률 산점도, 위험 상위 부품의 대체 후보 규격 적합성 패널을 가진 대시보드 작성하여 원인을 분석하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `src/utils/prc07_validator.py 및 에러 핸들러` 형태의 결과물을 완성한다.\n", + "\n", + "### 🔍 평가 및 검증 기준 (PASS 조건)\n", + "#### 미션 판정\n", + "\n", + "**평가 구분:** 필수\n", + "\n", + "**핵심 평가영역:** 사용자에게 알기 쉬운 에러 메시지 및 조치 가이드 제공\n", + "\n", + "#### 미션 PASS 조건\n", + "\n", + "- 정량 기준: 예외 상황 발생 시 크래시(Crash) 0건, 에러 로그 기록률 100%\n", + "\n", + "\n", + "- 진행 기준: 예외 주입 테스트 10종 통과 및 감사 로그 파일 생성\n", + "\n", + "\n", + "- 검증 방법: 비정상 데이터(결측, 특수문자, 대용량) 스트레스 테스트\n", + "\n", + "\n", + "#### 채점자 판정 메모\n", + "\n", + "> 단순 코드 실행 여부뿐만 아니라 출력된 수치와 근거 자료가 군 실무 요구사항에 부합하는지 면밀히 확인한다.\n", + "\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "\"\"\"조달 위험 공급망 지도(국가 x 부품 범주 위험 히트맵)와 대체선 추천 패널을 그리는 대시보드에 입력 검증기와 에러 핸들러를 붙이고, 깨진 입력 14종을 주입해 크래시 0건·에러 로그 기록률 100%·사용자용 조치 안내 문구를 확인한다.\"\"\"\n", + "import csv\n", + "import io\n", + "import json\n", + "import re\n", + "import numpy as np\n", + "import pandas as pd\n", + "import matplotlib.pyplot as plt\n", + "from sklearn.feature_extraction.text import TfidfVectorizer\n", + "from sklearn.metrics.pairwise import cosine_similarity\n", + "\n", + "# ---------------------------------------------------------------------------\n", + "# [교육용가상자료] 가상 무기체계 부품·공급망 데이터 생성기 (실제 체계·업체·국가·단가 정보 없음)\n", + "# 부품 100종(해외 조달 약 80%)의 공급망 속성과, 국내 부품 카탈로그(과거 승인된 국산 대체품 +\n", + "# 설명은 비슷하지만 규격이 맞지 않는 유사 후보)를 함께 만든다.\n", + "# ---------------------------------------------------------------------------\n", + "COUNTRIES = [\"가상국가A\", \"가상국가B\", \"가상국가C\", \"국내\"]\n", + "CATS = {\n", + " \"전원모듈\": dict(words=[\"DC-DC\", \"컨버터\", \"절연형\", \"전원\", \"정출력\"], syn={\"컨버터\": \"변환장치\", \"절연형\": \"아이솔레이션\", \"정출력\": \"안정화\"}, std=\"MIL-STD-704\", iface=\"단자대\"),\n", + " \"커넥터\": dict(words=[\"원형\", \"커넥터\", \"방수형\", \"다핀\", \"나사체결\"], syn={\"커넥터\": \"접속자\", \"방수형\": \"밀폐형\", \"나사체결\": \"스크루결합\"}, std=\"MIL-DTL-38999\", iface=\"원형핀\"),\n", + " \"유압밸브\": dict(words=[\"유압\", \"솔레노이드\", \"밸브\", \"방향제어\", \"4포트\"], syn={\"밸브\": \"개폐장치\", \"방향제어\": \"유로전환\", \"솔레노이드\": \"전자식\"}, std=\"SAE-AS5440\", iface=\"플랜지\"),\n", + " \"베어링\": dict(words=[\"볼\", \"베어링\", \"고속\", \"밀봉형\", \"회전축\"], syn={\"베어링\": \"축받이\", \"밀봉형\": \"실드형\", \"고속\": \"고회전\"}, std=\"ISO-492\", iface=\"축결합\"),\n", + " \"광학렌즈\": dict(words=[\"적외선\", \"렌즈\", \"게르마늄\", \"반사방지\", \"집광\"], syn={\"렌즈\": \"광학창\", \"집광\": \"초점형성\", \"반사방지\": \"AR코팅\"}, std=\"MIL-PRF-13830\", iface=\"마운트\"),\n", + " \"자이로센서\": dict(words=[\"광섬유\", \"자이로\", \"각속도\", \"센서\", \"관성\"], syn={\"센서\": \"감지기\", \"각속도\": \"회전율\", \"관성\": \"INS용\"}, std=\"IEEE-952\", iface=\"직렬통신\"),\n", + " \"RF증폭기\": dict(words=[\"RF\", \"전력증폭기\", \"광대역\", \"모듈\", \"송신\"], syn={\"전력증폭기\": \"파워앰프\", \"송신\": \"출력단\", \"광대역\": \"와이드밴드\"}, std=\"MIL-STD-461\", iface=\"SMA\"),\n", + " \"제어보드\": dict(words=[\"FPGA\", \"제어보드\", \"임베디드\", \"연산\", \"입출력\"], syn={\"제어보드\": \"컨트롤카드\", \"연산\": \"신호처리\", \"임베디드\": \"내장형\"}, std=\"VITA-46\", iface=\"백플레인\"),\n", + "}\n", + "V_OPTS, W_OPTS = [12, 24, 28], [25, 50, 100]\n", + "VENDORS = [\"가상정밀(주)\", \"가상전자(주)\", \"가상유공압(주)\", \"가상광학(주)\", \"가상테크(주)\", \"가상시스템(주)\"]\n", + "\n", + "\n", + "def describe(cat, words, s):\n", + " return f\"{cat} {' '.join(words)} {s['v']}V {s['w']}W {s['tmin']}~{s['tmax']}C {s['std']}\"\n", + "\n", + "\n", + "def _sig(x):\n", + " return 1 / (1 + np.exp(-x))\n", + "\n", + "\n", + "def make_parts(n=100, seed=7):\n", + " \"\"\"부품 목록(공급망 속성 포함)과 국내 카탈로그를 만든다. 반환: (parts DataFrame, catalog DataFrame)\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " cats, rows, cat_rows = list(CATS), [], []\n", + " for i in range(n):\n", + " cat = cats[i % len(cats)]; c = CATS[cat]\n", + " spec = {\"v\": int(rng.choice(V_OPTS)), \"w\": int(rng.choice(W_OPTS)), \"tmin\": int(rng.choice([-55, -40])),\n", + " \"tmax\": int(rng.choice([85, 125])), \"size_mm\": float(round(rng.uniform(20, 120), 1)), \"std\": c[\"std\"]}\n", + " words = [str(x) for x in rng.permutation(c[\"words\"])[:4]]\n", + " country = str(rng.choice(COUNTRIES, p=[0.35, 0.3, 0.15, 0.2]))\n", + " overseas = country != \"국내\"\n", + " n_sup = int(rng.choice([1, 2, 3], p=[0.45, 0.35, 0.2])) if overseas else int(rng.choice([1, 2, 3]))\n", + " lead = float(round(rng.gamma(3, 45 if overseas else 25) + 20))\n", + " eol = str(rng.choice([\"정상\", \"단종예고\", \"단종\"], p=[0.7, 0.2, 0.1]))\n", + " crit = str(rng.choice([\"A\", \"B\", \"C\"], p=[0.3, 0.4, 0.3]))\n", + " z = (-2.6 + 1.1 * (n_sup == 1) + 0.9 * overseas + 0.006 * (lead - 100) + 1.2 * (eol == \"단종예고\")\n", + " + 2.0 * (eol == \"단종\") + rng.normal(0, 0.6))\n", + " p_late = float(np.clip(_sig(z) * 0.8 + 0.05, 0, 0.9))\n", + " late = int(rng.binomial(12, p_late))\n", + " demand = int(rng.integers(20, 400))\n", + " rows.append({\"part_no\": f\"P-{i + 1:04d}\", \"category\": cat, \"desc\": describe(cat, words, spec), **spec,\n", + " \"iface\": c[\"iface\"], \"country\": country, \"overseas\": overseas, \"n_suppliers\": n_sup,\n", + " \"lead_time_days\": lead, \"orders_12m\": 12, \"late_orders\": late,\n", + " \"avg_delay_days\": float(round(late and rng.gamma(2, 12 + 20 * p_late), 1)),\n", + " \"eol_status\": eol, \"ltb_months\": int(rng.integers(1, 25)) if eol == \"단종예고\" else None,\n", + " \"price_change_pct\": float(round(rng.normal(4 + 12 * p_late, 8), 1)), \"criticality\": crit,\n", + " \"annual_demand\": demand, \"stock_qty\": int(rng.integers(0, demand)),\n", + " \"defect_rate\": float(round(abs(rng.normal(0.8 + 2 * p_late, 0.8)), 2)),\n", + " \"export_license\": bool(overseas and rng.random() < 0.35),\n", + " \"disrupted_next12m\": bool(rng.random() < _sig(z + 0.4))})\n", + " if not overseas:\n", + " continue\n", + " # 과거 승인된 국산 대체품(정답): 동의어로 서술, 규격 호환(전력 여유 허용, 일부는 크기 차이가 커 장착 검토 필요)\n", + " eq_words = [c[\"syn\"].get(w, w) for w in words]\n", + " hard = rng.random() < 0.15 # 서술이 많이 다른 사례\n", + " if hard:\n", + " eq_words = [c[\"syn\"].get(w, w) for w in c[\"words\"] if w not in words][:1] + eq_words[1:3]\n", + " eq = {**spec, \"w\": spec[\"w\"] if rng.random() < 0.7 else W_OPTS[min(W_OPTS.index(spec[\"w\"]) + 1, 2)],\n", + " \"size_mm\": float(round(spec[\"size_mm\"] * (rng.uniform(1.11, 1.15) if rng.random() < 0.12 else rng.uniform(0.95, 1.05)), 1))}\n", + " cat_rows.append({\"cand_no\": f\"D-{len(cat_rows) + 1:04d}\", \"category\": cat, \"desc\": describe(cat, eq_words, eq), **eq,\n", + " \"iface\": c[\"iface\"], \"vendor\": str(rng.choice(VENDORS)), \"approved_for\": f\"P-{i + 1:04d}\", \"kind\": \"equivalent\"})\n", + " # 유사 오답 후보: 설명은 거의 같지만 전압이 다르거나(5V/48V) 상용 온도 등급(0~70C)\n", + " for kind in (\"volt_mismatch\", \"temp_mismatch\"):\n", + " d = dict(spec)\n", + " if kind == \"volt_mismatch\":\n", + " d[\"v\"] = int(rng.choice([5, 48])) # 체계에서 쓰지 않는 전압\n", + " else:\n", + " d[\"tmin\"], d[\"tmax\"] = 0, 70\n", + " d[\"size_mm\"] = float(round(spec[\"size_mm\"] * rng.uniform(0.95, 1.05), 1))\n", + " cat_rows.append({\"cand_no\": f\"D-{len(cat_rows) + 1:04d}\", \"category\": cat, \"desc\": describe(cat, words, d), **d,\n", + " \"iface\": c[\"iface\"], \"vendor\": str(rng.choice(VENDORS)), \"approved_for\": None, \"kind\": kind})\n", + " parts = pd.DataFrame(rows)\n", + " catalog = pd.DataFrame(cat_rows).sample(frac=1, random_state=seed).reset_index(drop=True)\n", + " return parts, catalog\n", + "# ---------------------------------------------------------------------------\n", + "\n", + "# ---- 대체 부품 추천 공통 함수: 텍스트 유사도 + 규격 적합성 + 지식 그래프 이웃 유사도 ----\n", + "WEIGHTS = {\"text\": 0.35, \"spec\": 0.45, \"graph\": 0.20}\n", + "\n", + "\n", + "def spec_fit(p, c):\n", + " \"\"\"규격 적합성 0~1: 6개 조건 충족 비율. 전압·온도 한계(필수 조건)를 못 맞추면 점수를 절반으로 깎는다.\"\"\"\n", + " conds = [p[\"v\"] == c[\"v\"], c[\"w\"] >= p[\"w\"], c[\"tmin\"] <= p[\"tmin\"], c[\"tmax\"] >= p[\"tmax\"],\n", + " abs(c[\"size_mm\"] - p[\"size_mm\"]) / p[\"size_mm\"] <= 0.10, p[\"iface\"] == c[\"iface\"]]\n", + " s = sum(conds) / len(conds)\n", + " return (s * 0.5 if not (conds[0] and conds[2] and conds[3]) else s), conds\n", + "\n", + "\n", + "def graph_nodes(row):\n", + " \"\"\"지식 그래프에서 부품 노드와 연결된 이웃 노드 집합(범주·표준·인터페이스·기능어·등급).\"\"\"\n", + " words = set(row[\"desc\"].split()[1:-4])\n", + " grade = \"군용등급\" if row[\"tmin\"] <= -40 else \"상용등급\"\n", + " return {f\"cat:{row['category']}\", f\"std:{row['std']}\", f\"if:{row['iface']}\", f\"grade:{grade}\", f\"v:{row['v']}\"} | {f\"w:{w}\" for w in words}\n", + "\n", + "\n", + "def build_graph(catalog):\n", + " \"\"\"동의어 사전을 그래프 간선(기능어 = 동의어)으로 넣어 서술이 달라도 같은 기능 노드로 연결되게 한다.\"\"\"\n", + " syn = {f\"w:{b}\": f\"w:{a}\" for c in CATS.values() for a, b in c[\"syn\"].items()}\n", + " canon = lambda nodes: {syn.get(x, x) for x in nodes}\n", + " return {\"canon\": canon, \"nodes\": [canon(graph_nodes(r)) for _, r in catalog.iterrows()]}\n", + "\n", + "\n", + "def graph_sim(g, pn, cn):\n", + " \"\"\"두 부품이 공유하는 이웃 노드 비율(Jaccard).\"\"\"\n", + " pn = g[\"canon\"](pn)\n", + " return len(pn & cn) / max(len(pn | cn), 1)\n", + "\n", + "\n", + "def score_table(parts_df, catalog):\n", + " \"\"\"해외 부품 x 같은 범주 국내 후보 쌍마다 텍스트·규격·그래프 점수를 계산한 표(가중치 적용 전).\"\"\"\n", + " vec = TfidfVectorizer(token_pattern=r\"[^ ]+\").fit(list(parts_df[\"desc\"]) + list(catalog[\"desc\"]))\n", + " text = cosine_similarity(vec.transform(parts_df[\"desc\"]), vec.transform(catalog[\"desc\"]))\n", + " g = build_graph(catalog)\n", + " rows = []\n", + " for i, (_, p) in enumerate(parts_df.iterrows()):\n", + " pn = graph_nodes(p)\n", + " for j, (_, c) in enumerate(catalog.iterrows()):\n", + " if c[\"category\"] != p[\"category\"]:\n", + " continue\n", + " sf, conds = spec_fit(p, c)\n", + " rows.append({\"part_no\": p[\"part_no\"], \"cand_no\": c[\"cand_no\"], \"vendor\": c[\"vendor\"], \"text\": text[i, j],\n", + " \"spec\": sf, \"graph\": graph_sim(g, pn, g[\"nodes\"][j]),\n", + " \"unmet\": \",\".join(n for n, ok in zip([\"전압\", \"전력\", \"저온\", \"고온\", \"크기\", \"인터페이스\"], conds) if not ok) or \"-\",\n", + " \"kind\": c[\"kind\"], \"correct\": c[\"approved_for\"] == p[\"part_no\"]})\n", + " return pd.DataFrame(rows)\n", + "\n", + "\n", + "def recommend(table, weights=None, top=3):\n", + " \"\"\"가중합 점수로 부품별 상위 top개 후보를 고른다.\"\"\"\n", + " w = weights or WEIGHTS\n", + " t = table.assign(score=w[\"text\"] * table[\"text\"] + w[\"spec\"] * table[\"spec\"] + w[\"graph\"] * table[\"graph\"])\n", + " return t.sort_values([\"part_no\", \"score\"], ascending=[True, False]).groupby(\"part_no\").head(top)\n", + "\n", + "\n", + "def hit_rate(recs, k=1):\n", + " \"\"\"과거 승인 이력(정답 대체품)이 상위 k개 안에 들어간 부품 비율.\"\"\"\n", + " return recs.groupby(\"part_no\").apply(lambda d: d[\"correct\"].head(k).any(), include_groups=False).mean()\n", + "\n", + "NEED = {\"part_no\": str, \"category\": str, \"country\": str, \"n_suppliers\": float, \"lead_time_days\": float, \"late_orders\": float,\n", + " \"orders_12m\": float, \"eol_status\": str, \"annual_demand\": float, \"stock_qty\": float, \"desc\": str, \"v\": float, \"w\": float,\n", + " \"tmin\": float, \"tmax\": float, \"size_mm\": float, \"std\": str, \"iface\": str}\n", + "RANGE = {\"n_suppliers\": (1, 10), \"lead_time_days\": (1, 1000), \"late_orders\": (0, 100), \"orders_12m\": (1, 100), \"annual_demand\": (1, 100000), \"stock_qty\": (0, 100000)}\n", + "LOG = []\n", + "\n", + "\n", + "class UserError(Exception):\n", + " \"\"\"사용자에게 보여 줄 메시지와 조치 가이드를 함께 가진 예외.\"\"\"\n", + "\n", + " def __init__(self, code, msg, action):\n", + " super().__init__(msg); self.code, self.msg, self.action = code, msg, action\n", + "\n", + "\n", + "def log(level, code, detail):\n", + " LOG.append({\"seq\": len(LOG) + 1, \"level\": level, \"code\": code, \"detail\": str(detail)[:80]})\n", + "\n", + "\n", + "def read_input(src):\n", + " \"\"\"입력(CSV 문자열 / JSON 문자열 / DataFrame)을 DataFrame으로 읽는다. 실패하면 UserError.\"\"\"\n", + " if src is None:\n", + " raise UserError(\"E01\", \"입력이 비어 있습니다.\", \"파일을 다시 선택해 주세요.\")\n", + " if isinstance(src, pd.DataFrame):\n", + " df = src.copy()\n", + " elif isinstance(src, str):\n", + " if not src.strip():\n", + " raise UserError(\"E02\", \"빈 파일입니다.\", \"내용이 있는 파일인지 확인해 주세요.\")\n", + " try:\n", + " df = pd.DataFrame(json.loads(src)) if src.lstrip()[0] in \"[{\" else pd.DataFrame(list(csv.DictReader(io.StringIO(src))))\n", + " except Exception as e:\n", + " raise UserError(\"E03\", \"파일 형식을 읽을 수 없습니다.\", \"CSV(쉼표 구분) 또는 JSON(목록) 형식으로 저장했는지 확인해 주세요.\") from e\n", + " else:\n", + " raise UserError(\"E04\", f\"지원하지 않는 입력 형식입니다({type(src).__name__}).\", \"CSV 또는 JSON 파일을 사용해 주세요.\")\n", + " if df.empty:\n", + " raise UserError(\"E02\", \"읽은 행이 0건입니다.\", \"머리글만 있는 파일이 아닌지 확인해 주세요.\")\n", + " return df\n", + "\n", + "\n", + "def validate_table(df):\n", + " \"\"\"열 점검 -> 행 단위 점검. 문제 행은 격리하고 나머지로 계속 진행한다. 반환: (정상 행, 격리 내역)\"\"\"\n", + " miss = [c for c in NEED if c not in df.columns]\n", + " if miss:\n", + " raise UserError(\"E10\", f\"필수 열이 없습니다: {', '.join(miss[:4])}{' 외' if len(miss) > 4 else ''}\", \"열 이름이 양식(데이터 딕셔너리)과 같은지 확인해 주세요.\")\n", + " d = df.copy(); bad = {}\n", + " for c, t in NEED.items():\n", + " if t is float:\n", + " conv = pd.to_numeric(d[c], errors=\"coerce\")\n", + " for i in d.index[conv.isna()]:\n", + " bad.setdefault(i, f\"{c}: 숫자가 아님/누락\")\n", + " d[c] = conv\n", + " for c, (lo, hi) in RANGE.items():\n", + " for i in d.index[(d[c] < lo) | (d[c] > hi)]:\n", + " bad.setdefault(i, f\"{c}: 허용 범위({lo}~{hi}) 밖\")\n", + " for i in d.index[d[\"country\"].isin(COUNTRIES) == False]:\n", + " bad.setdefault(i, \"country: 허용되지 않은 값\")\n", + " for i in d.index[d[\"part_no\"].duplicated(keep=\"first\")]:\n", + " bad.setdefault(i, \"part_no: 중복\")\n", + " for i in d.index[~d[\"part_no\"].astype(str).str.match(r\"^P-\\d{4}$\")]:\n", + " bad.setdefault(i, \"part_no: 형식 오류\")\n", + " if len(bad) == len(d):\n", + " raise UserError(\"E11\", \"사용할 수 있는 행이 없습니다.\", \"격리 사유를 확인해 값을 고친 뒤 다시 올려 주세요.\")\n", + " return d.drop(index=list(bad)), bad\n", + "\n", + "\n", + "def risk_points(d):\n", + " rate = d[\"late_orders\"] / d[\"orders_12m\"]\n", + " cover = d[\"stock_qty\"] / d[\"annual_demand\"] * 365 - d[\"lead_time_days\"]\n", + " return (30 * (d[\"n_suppliers\"] == 1) + 25 * np.clip(rate, 0, 1) + 20 * (d[\"eol_status\"] != \"정상\") + 15 * (cover < 0) + 10 * (d[\"country\"] != \"국내\")).round(1)\n", + "\n", + "\n", + "def dashboard(df, catalog, draw=True):\n", + " d, bad = validate_table(read_input(df))\n", + " d = d.assign(risk=risk_points(d))\n", + " heat = d.pivot_table(index=\"country\", columns=\"category\", values=\"risk\", aggfunc=\"mean\").round(1)\n", + " over = d[d[\"country\"] != \"국내\"].sort_values(\"risk\", ascending=False).head(6)\n", + " vec = TfidfVectorizer(token_pattern=r\"[^ ]+\").fit(list(d[\"desc\"]) + list(catalog[\"desc\"]))\n", + " alt = []\n", + " for _, p in over.iterrows():\n", + " cs = catalog[catalog[\"category\"] == p[\"category\"]]\n", + " if cs.empty:\n", + " alt.append((p[\"part_no\"], \"후보 없음\", 0.0)); continue\n", + " fit = np.array([spec_fit(p, c)[0] for _, c in cs.iterrows()])\n", + " sc = 0.45 * fit + 0.35 * cosine_similarity(vec.transform([p[\"desc\"]]), vec.transform(cs[\"desc\"]))[0]\n", + " j = int(np.argmax(sc)); alt.append((p[\"part_no\"], cs.iloc[j][\"cand_no\"], float(fit[j])))\n", + " if draw:\n", + " fig, ax = plt.subplots(1, 3, figsize=(13, 4))\n", + " im = ax[0].imshow(heat.fillna(0).to_numpy(), cmap=\"Reds\"); ax[0].set_xticks(range(heat.shape[1]), [f\"c{i + 1}\" for i in range(heat.shape[1])]); ax[0].set_yticks(range(heat.shape[0]), [\"A\", \"B\", \"C\", \"domestic\"][:heat.shape[0]])\n", + " ax[0].set_title(\"Supply risk map (country x category)\"); plt.colorbar(im, ax=ax[0])\n", + " ax[1].scatter(d[\"lead_time_days\"], d[\"late_orders\"] / d[\"orders_12m\"], c=d[\"risk\"], cmap=\"Reds\", s=25); ax[1].set_xlabel(\"lead time (days)\"); ax[1].set_ylabel(\"late order rate\"); ax[1].set_title(\"Lead time vs delay\")\n", + " ax[2].barh([a[0] for a in alt], [a[2] for a in alt], color=\"tab:blue\"); ax[2].invert_yaxis(); ax[2].set_xlim(0, 1); ax[2].set_title(\"Alternative spec fit (top-6 risk)\")\n", + " plt.tight_layout(); plt.show()\n", + " return heat, alt, bad\n", + "\n", + "\n", + "def safe_dashboard(case, src, catalog):\n", + " \"\"\"모든 예외를 잡아 크래시 없이 사용자 메시지·조치 안내를 돌려주고 에러 로그를 남긴다.\"\"\"\n", + " try:\n", + " heat, alt, bad = dashboard(src, catalog, draw=False)\n", + " for i, why in bad.items():\n", + " log(\"WARN\", \"W01\", f\"{case}: 행 {i} 격리 - {why}\")\n", + " return {\"case\": case, \"ok\": True, \"msg\": f\"표시 완료(격리 {len(bad)}행)\" if bad else \"표시 완료\", \"action\": \"-\", \"quarantined\": len(bad)}\n", + " except UserError as e:\n", + " log(\"ERROR\", e.code, f\"{case}: {e.msg}\")\n", + " return {\"case\": case, \"ok\": False, \"msg\": e.msg, \"action\": e.action, \"quarantined\": 0}\n", + " except Exception as e: # 예상하지 못한 오류도 크래시 대신 안내로 변환\n", + " log(\"ERROR\", \"E99\", f\"{case}: {type(e).__name__} {e}\")\n", + " return {\"case\": case, \"ok\": False, \"msg\": \"예상하지 못한 오류가 발생했습니다.\", \"action\": \"오류 코드 E99와 입력 파일을 담당자에게 전달해 주세요.\", \"quarantined\": 0}\n", + "\n", + "\n", + "parts, catalog = make_parts(100, seed=7)\n", + "base = parts.copy()\n", + "csv_ok = base.to_csv(index=False)\n", + "\n", + "def mut(**kw):\n", + " d = base.copy()\n", + " for k, v in kw.items():\n", + " d[k] = d[k].astype(object); d.loc[d.index[:3], k] = v\n", + " return d\n", + "\n", + "cases = [\n", + " (\"정상 입력\", base), (\"정상 CSV 문자열\", csv_ok), (\"입력 없음(None)\", None), (\"빈 문자열\", \" \"), (\"깨진 JSON\", '[{\"part_no\": \"P-0001\", '),\n", + " (\"지원하지 않는 형식(정수)\", 12345), (\"머리글만 있는 CSV\", \",\".join(base.columns) + \"\\n\"), (\"필수 열 누락\", base.drop(columns=[\"lead_time_days\", \"desc\"])),\n", + " (\"숫자 열에 문자\", mut(lead_time_days=\"미정\")), (\"음수 리드타임\", mut(lead_time_days=-5)), (\"공급선 수 결측\", mut(n_suppliers=np.nan)),\n", + " (\"허용되지 않은 국가\", mut(country=\"미상\")), (\"부품번호 중복·형식 오류\", mut(part_no=\"P-9\")), (\"연간 소요량 0(나눗셈 위험)\", mut(annual_demand=0)),\n", + " (\"극단값(1e12)\", mut(stock_qty=1e12)), (\"전부 불량 행\", base.assign(lead_time_days=-1)),\n", + "]\n", + "res = [safe_dashboard(n, s, catalog) for n, s in cases]\n", + "print(f\"[에러 핸들러 시험] 입력 {len(res)}종 실행\")\n", + "for r in res:\n", + " print(f\" {'OK ' if r['ok'] else '안내'} {r['case']:22s} -> {r['msg']}\" + (f\" | 조치: {r['action']}\" if r[\"action\"] != \"-\" else \"\"))\n", + "crash = 0 # safe_dashboard 밖으로 예외가 새 나간 건수(위에서 모두 포착)\n", + "n_err = sum(not r[\"ok\"] for r in res)\n", + "logged_err = sum(l[\"level\"] == \"ERROR\" for l in LOG)\n", + "print(f\"\\n크래시 {crash}건(목표 0건: {'달성' if crash == 0 else '미달'}), 오류 {n_err}건 중 로그 기록 {logged_err}건 -> 기록률 {logged_err / max(n_err, 1):.0%}(목표 100%: {'달성' if logged_err == n_err else '미달'})\")\n", + "print(f\"행 단위 격리 경고 {sum(l['level'] == 'WARN' for l in LOG)}건 기록, 안내 문구에 조치 가이드 포함 {sum(r['action'] != '-' for r in res if not r['ok'])}/{n_err}건\")\n", + "print(\"\\n[에러 로그 앞 5건]\")\n", + "print(pd.DataFrame(LOG).head(5).to_string(index=False))\n", + "\n", + "# 정상 입력으로 대시보드 그리기\n", + "heat, alt, bad = dashboard(base, catalog)\n", + "print(\"\\n[공급망 지도] 국가 x 범주 평균 위험 점수(상위 위험 국가 행)\")\n", + "print(heat.loc[[c for c in COUNTRIES if c in heat.index]].fillna(0).round(0).astype(int).to_string())\n", + "print(\"\\n[대체선 추천 패널] 위험 상위 해외 부품별 국산 후보와 규격 적합성\")\n", + "for a in alt:\n", + " print(f\" {a[0]} -> {a[1]} (규격 적합성 {a[2]:.2f})\")\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 06 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + "[에러 핸들러 시험] 입력 16종 실행\n", + " OK 정상 입력 -> 표시 완료\n", + " OK 정상 CSV 문자열 -> 표시 완료\n", + " 안내 입력 없음(None) -> 입력이 비어 있습니다. | 조치: 파일을 다시 선택해 주세요.\n", + " 안내 빈 문자열 -> 빈 파일입니다. | 조치: 내용이 있는 파일인지 확인해 주세요.\n", + " 안내 깨진 JSON -> 파일 형식을 읽을 수 없습니다. | 조치: CSV(쉼표 구분) 또는 JSON(목록) 형식으로 저장했는지 확인해 주세요.\n", + " 안내 지원하지 않는 형식(정수) -> 지원하지 않는 입력 형식입니다(int). | 조치: CSV 또는 JSON 파일을 사용해 주세요.\n", + " 안내 머리글만 있는 CSV -> 읽은 행이 0건입니다. | 조치: 머리글만 있는 파일이 아닌지 확인해 주세요.\n", + " 안내 필수 열 누락 -> 필수 열이 없습니다: lead_time_days, desc | 조치: 열 이름이 양식(데이터 딕셔너리)과 같은지 확인해 주세요.\n", + " OK 숫자 열에 문자 -> 표시 완료(격리 3행)\n", + " OK 음수 리드타임 -> 표시 완료(격리 3행)\n", + " OK 공급선 수 결측 -> 표시 완료(격리 3행)\n", + " OK 허용되지 않은 국가 -> 표시 완료(격리 3행)\n", + " OK 부품번호 중복·형식 오류 -> 표시 완료(격리 3행)\n", + " OK 연간 소요량 0(나눗셈 위험) -> 표시 완료(격리 3행)\n", + " OK 극단값(1e12) -> 표시 완료(격리 3행)\n", + " 안내 전부 불량 행 -> 사용할 수 있는 행이 없습니다. | 조치: 격리 사유를 확인해 값을 고친 뒤 다시 올려 주세요.\n", + "\n", + "크래시 0건(목표 0건: 달성), 오류 7건 중 로그 기록 7건 -> 기록률 100%(목표 100%: 달성)\n", + "행 단위 격리 경고 21건 기록, 안내 문구에 조치 가이드 포함 7/7건\n", + "\n", + "[에러 로그 앞 5건]\n", + " seq level code detail\n", + " 1 ERROR E01 입력 없음(None): 입력이 비어 있습니다.\n", + " 2 ERROR E02 빈 문자열: 빈 파일입니다.\n", + " 3 ERROR E03 깨진 JSON: 파일 형식을 읽을 수 없습니다.\n", + " 4 ERROR E04 지원하지 않는 형식(정수): 지원하지 않는 입력 형식입니다(int).\n", + " 5 ERROR E02 머리글만 있는 CSV: 읽은 행이 0건입니다.\n", + "\n", + "[공급망 지도] 국가 x 범주 평균 위험 점수(상위 위험 국가 행)\n", + "category RF증폭기 광학렌즈 베어링 유압밸브 자이로센서 전원모듈 제어보드 커넥터\n", + "country \n", + "가상국가A 39 58 48 41 44 36 46 69\n", + "가상국가B 42 49 75 54 51 68 57 30\n", + "가상국가C 14 63 39 0 16 0 71 29\n", + "국내 0 19 4 38 28 2 28 42\n", + "\n", + "[대체선 추천 패널] 위험 상위 해외 부품별 국산 후보와 규격 적합성\n", + " P-0080 -> D-0205 (규격 적합성 1.00)\n", + " P-0066 -> D-0172 (규격 적합성 1.00)\n", + "... (이하 생략)\n", + "```\n", + "
\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 07. 추천된 대체 부품의 규격 적합성 검토 및 조달 승인을 위한 HITL 워크플로우 설계\n", + "**소요시간**: 약 3.0시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "추천된 대체 부품의 규격 적합성 검토 및 조달 승인 HITL 워크플로우를 설계한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **추천된 대체 부품의 규격 적합성 검토와 조달 승인을 위한 HITL 워크플로우를 설계하고 이력 무결성과 권한 통제를 검증한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **시스템 제안, 조달담당 상신, 기술검토관 규격 검토, 보안담당 검토, 승인권자 결재의 순서를 상태 기계로 구현하고 역할별 권한을 최소로 둔다 과정을 진행하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `src/workflow/prc07_approval_system.py` 형태의 결과물을 완성한다.\n", + "\n", + "### 🔍 평가 및 검증 기준 (PASS 조건)\n", + "#### 미션 판정\n", + "\n", + "**평가 구분:** 필수\n", + "\n", + "**핵심 평가영역:** 군 보안 규정 및 행정 절차 부합성\n", + "\n", + "#### 미션 PASS 조건\n", + "\n", + "- 정량 기준: 이력 기록 무결성 100%, 승인 권한 우회 시도 차단율 100%\n", + "\n", + "\n", + "- 진행 기준: 결재선 승인/반려 시뮬레이션 및 이력 조회 화면 구현\n", + "\n", + "\n", + "- 검증 방법: 권한별 접근 제어(RBAC) 테스트\n", + "\n", + "\n", + "#### 채점자 판정 메모\n", + "\n", + "> 단순 코드 실행 여부뿐만 아니라 출력된 수치와 근거 자료가 군 실무 요구사항에 부합하는지 면밀히 확인한다.\n", + "\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "\"\"\"위험 상위 부품의 대체 부품 추천 결과를 조달담당 상신 -> 기술검토관 규격 검토 -> 보안담당 검토 -> 승인권자 결재의 HITL 워크플로우로 처리하되, 시스템의 자동 확정과 권한 우회 시도를 차단하고 모든 행위를 해시 체인 감사 로그에 남겨 이력 무결성과 위변조 탐지를 검증한다.\"\"\"\n", + "import hashlib\n", + "import json\n", + "import numpy as np\n", + "import pandas as pd\n", + "import matplotlib.pyplot as plt\n", + "from sklearn.feature_extraction.text import TfidfVectorizer\n", + "from sklearn.metrics.pairwise import cosine_similarity\n", + "\n", + "# ---------------------------------------------------------------------------\n", + "# [교육용가상자료] 가상 무기체계 부품·공급망 데이터 생성기 (실제 체계·업체·국가·단가 정보 없음)\n", + "# 부품 100종(해외 조달 약 80%)의 공급망 속성과, 국내 부품 카탈로그(과거 승인된 국산 대체품 +\n", + "# 설명은 비슷하지만 규격이 맞지 않는 유사 후보)를 함께 만든다.\n", + "# ---------------------------------------------------------------------------\n", + "COUNTRIES = [\"가상국가A\", \"가상국가B\", \"가상국가C\", \"국내\"]\n", + "CATS = {\n", + " \"전원모듈\": dict(words=[\"DC-DC\", \"컨버터\", \"절연형\", \"전원\", \"정출력\"], syn={\"컨버터\": \"변환장치\", \"절연형\": \"아이솔레이션\", \"정출력\": \"안정화\"}, std=\"MIL-STD-704\", iface=\"단자대\"),\n", + " \"커넥터\": dict(words=[\"원형\", \"커넥터\", \"방수형\", \"다핀\", \"나사체결\"], syn={\"커넥터\": \"접속자\", \"방수형\": \"밀폐형\", \"나사체결\": \"스크루결합\"}, std=\"MIL-DTL-38999\", iface=\"원형핀\"),\n", + " \"유압밸브\": dict(words=[\"유압\", \"솔레노이드\", \"밸브\", \"방향제어\", \"4포트\"], syn={\"밸브\": \"개폐장치\", \"방향제어\": \"유로전환\", \"솔레노이드\": \"전자식\"}, std=\"SAE-AS5440\", iface=\"플랜지\"),\n", + " \"베어링\": dict(words=[\"볼\", \"베어링\", \"고속\", \"밀봉형\", \"회전축\"], syn={\"베어링\": \"축받이\", \"밀봉형\": \"실드형\", \"고속\": \"고회전\"}, std=\"ISO-492\", iface=\"축결합\"),\n", + " \"광학렌즈\": dict(words=[\"적외선\", \"렌즈\", \"게르마늄\", \"반사방지\", \"집광\"], syn={\"렌즈\": \"광학창\", \"집광\": \"초점형성\", \"반사방지\": \"AR코팅\"}, std=\"MIL-PRF-13830\", iface=\"마운트\"),\n", + " \"자이로센서\": dict(words=[\"광섬유\", \"자이로\", \"각속도\", \"센서\", \"관성\"], syn={\"센서\": \"감지기\", \"각속도\": \"회전율\", \"관성\": \"INS용\"}, std=\"IEEE-952\", iface=\"직렬통신\"),\n", + " \"RF증폭기\": dict(words=[\"RF\", \"전력증폭기\", \"광대역\", \"모듈\", \"송신\"], syn={\"전력증폭기\": \"파워앰프\", \"송신\": \"출력단\", \"광대역\": \"와이드밴드\"}, std=\"MIL-STD-461\", iface=\"SMA\"),\n", + " \"제어보드\": dict(words=[\"FPGA\", \"제어보드\", \"임베디드\", \"연산\", \"입출력\"], syn={\"제어보드\": \"컨트롤카드\", \"연산\": \"신호처리\", \"임베디드\": \"내장형\"}, std=\"VITA-46\", iface=\"백플레인\"),\n", + "}\n", + "V_OPTS, W_OPTS = [12, 24, 28], [25, 50, 100]\n", + "VENDORS = [\"가상정밀(주)\", \"가상전자(주)\", \"가상유공압(주)\", \"가상광학(주)\", \"가상테크(주)\", \"가상시스템(주)\"]\n", + "\n", + "\n", + "def describe(cat, words, s):\n", + " return f\"{cat} {' '.join(words)} {s['v']}V {s['w']}W {s['tmin']}~{s['tmax']}C {s['std']}\"\n", + "\n", + "\n", + "def _sig(x):\n", + " return 1 / (1 + np.exp(-x))\n", + "\n", + "\n", + "def make_parts(n=100, seed=7):\n", + " \"\"\"부품 목록(공급망 속성 포함)과 국내 카탈로그를 만든다. 반환: (parts DataFrame, catalog DataFrame)\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " cats, rows, cat_rows = list(CATS), [], []\n", + " for i in range(n):\n", + " cat = cats[i % len(cats)]; c = CATS[cat]\n", + " spec = {\"v\": int(rng.choice(V_OPTS)), \"w\": int(rng.choice(W_OPTS)), \"tmin\": int(rng.choice([-55, -40])),\n", + " \"tmax\": int(rng.choice([85, 125])), \"size_mm\": float(round(rng.uniform(20, 120), 1)), \"std\": c[\"std\"]}\n", + " words = [str(x) for x in rng.permutation(c[\"words\"])[:4]]\n", + " country = str(rng.choice(COUNTRIES, p=[0.35, 0.3, 0.15, 0.2]))\n", + " overseas = country != \"국내\"\n", + " n_sup = int(rng.choice([1, 2, 3], p=[0.45, 0.35, 0.2])) if overseas else int(rng.choice([1, 2, 3]))\n", + " lead = float(round(rng.gamma(3, 45 if overseas else 25) + 20))\n", + " eol = str(rng.choice([\"정상\", \"단종예고\", \"단종\"], p=[0.7, 0.2, 0.1]))\n", + " crit = str(rng.choice([\"A\", \"B\", \"C\"], p=[0.3, 0.4, 0.3]))\n", + " z = (-2.6 + 1.1 * (n_sup == 1) + 0.9 * overseas + 0.006 * (lead - 100) + 1.2 * (eol == \"단종예고\")\n", + " + 2.0 * (eol == \"단종\") + rng.normal(0, 0.6))\n", + " p_late = float(np.clip(_sig(z) * 0.8 + 0.05, 0, 0.9))\n", + " late = int(rng.binomial(12, p_late))\n", + " demand = int(rng.integers(20, 400))\n", + " rows.append({\"part_no\": f\"P-{i + 1:04d}\", \"category\": cat, \"desc\": describe(cat, words, spec), **spec,\n", + " \"iface\": c[\"iface\"], \"country\": country, \"overseas\": overseas, \"n_suppliers\": n_sup,\n", + " \"lead_time_days\": lead, \"orders_12m\": 12, \"late_orders\": late,\n", + " \"avg_delay_days\": float(round(late and rng.gamma(2, 12 + 20 * p_late), 1)),\n", + " \"eol_status\": eol, \"ltb_months\": int(rng.integers(1, 25)) if eol == \"단종예고\" else None,\n", + " \"price_change_pct\": float(round(rng.normal(4 + 12 * p_late, 8), 1)), \"criticality\": crit,\n", + " \"annual_demand\": demand, \"stock_qty\": int(rng.integers(0, demand)),\n", + " \"defect_rate\": float(round(abs(rng.normal(0.8 + 2 * p_late, 0.8)), 2)),\n", + " \"export_license\": bool(overseas and rng.random() < 0.35),\n", + " \"disrupted_next12m\": bool(rng.random() < _sig(z + 0.4))})\n", + " if not overseas:\n", + " continue\n", + " # 과거 승인된 국산 대체품(정답): 동의어로 서술, 규격 호환(전력 여유 허용, 일부는 크기 차이가 커 장착 검토 필요)\n", + " eq_words = [c[\"syn\"].get(w, w) for w in words]\n", + " hard = rng.random() < 0.15 # 서술이 많이 다른 사례\n", + " if hard:\n", + " eq_words = [c[\"syn\"].get(w, w) for w in c[\"words\"] if w not in words][:1] + eq_words[1:3]\n", + " eq = {**spec, \"w\": spec[\"w\"] if rng.random() < 0.7 else W_OPTS[min(W_OPTS.index(spec[\"w\"]) + 1, 2)],\n", + " \"size_mm\": float(round(spec[\"size_mm\"] * (rng.uniform(1.11, 1.15) if rng.random() < 0.12 else rng.uniform(0.95, 1.05)), 1))}\n", + " cat_rows.append({\"cand_no\": f\"D-{len(cat_rows) + 1:04d}\", \"category\": cat, \"desc\": describe(cat, eq_words, eq), **eq,\n", + " \"iface\": c[\"iface\"], \"vendor\": str(rng.choice(VENDORS)), \"approved_for\": f\"P-{i + 1:04d}\", \"kind\": \"equivalent\"})\n", + " # 유사 오답 후보: 설명은 거의 같지만 전압이 다르거나(5V/48V) 상용 온도 등급(0~70C)\n", + " for kind in (\"volt_mismatch\", \"temp_mismatch\"):\n", + " d = dict(spec)\n", + " if kind == \"volt_mismatch\":\n", + " d[\"v\"] = int(rng.choice([5, 48])) # 체계에서 쓰지 않는 전압\n", + " else:\n", + " d[\"tmin\"], d[\"tmax\"] = 0, 70\n", + " d[\"size_mm\"] = float(round(spec[\"size_mm\"] * rng.uniform(0.95, 1.05), 1))\n", + " cat_rows.append({\"cand_no\": f\"D-{len(cat_rows) + 1:04d}\", \"category\": cat, \"desc\": describe(cat, words, d), **d,\n", + " \"iface\": c[\"iface\"], \"vendor\": str(rng.choice(VENDORS)), \"approved_for\": None, \"kind\": kind})\n", + " parts = pd.DataFrame(rows)\n", + " catalog = pd.DataFrame(cat_rows).sample(frac=1, random_state=seed).reset_index(drop=True)\n", + " return parts, catalog\n", + "# ---------------------------------------------------------------------------\n", + "\n", + "# ---- 대체 부품 추천 공통 함수: 텍스트 유사도 + 규격 적합성 + 지식 그래프 이웃 유사도 ----\n", + "WEIGHTS = {\"text\": 0.35, \"spec\": 0.45, \"graph\": 0.20}\n", + "\n", + "\n", + "def spec_fit(p, c):\n", + " \"\"\"규격 적합성 0~1: 6개 조건 충족 비율. 전압·온도 한계(필수 조건)를 못 맞추면 점수를 절반으로 깎는다.\"\"\"\n", + " conds = [p[\"v\"] == c[\"v\"], c[\"w\"] >= p[\"w\"], c[\"tmin\"] <= p[\"tmin\"], c[\"tmax\"] >= p[\"tmax\"],\n", + " abs(c[\"size_mm\"] - p[\"size_mm\"]) / p[\"size_mm\"] <= 0.10, p[\"iface\"] == c[\"iface\"]]\n", + " s = sum(conds) / len(conds)\n", + " return (s * 0.5 if not (conds[0] and conds[2] and conds[3]) else s), conds\n", + "\n", + "\n", + "def graph_nodes(row):\n", + " \"\"\"지식 그래프에서 부품 노드와 연결된 이웃 노드 집합(범주·표준·인터페이스·기능어·등급).\"\"\"\n", + " words = set(row[\"desc\"].split()[1:-4])\n", + " grade = \"군용등급\" if row[\"tmin\"] <= -40 else \"상용등급\"\n", + " return {f\"cat:{row['category']}\", f\"std:{row['std']}\", f\"if:{row['iface']}\", f\"grade:{grade}\", f\"v:{row['v']}\"} | {f\"w:{w}\" for w in words}\n", + "\n", + "\n", + "def build_graph(catalog):\n", + " \"\"\"동의어 사전을 그래프 간선(기능어 = 동의어)으로 넣어 서술이 달라도 같은 기능 노드로 연결되게 한다.\"\"\"\n", + " syn = {f\"w:{b}\": f\"w:{a}\" for c in CATS.values() for a, b in c[\"syn\"].items()}\n", + " canon = lambda nodes: {syn.get(x, x) for x in nodes}\n", + " return {\"canon\": canon, \"nodes\": [canon(graph_nodes(r)) for _, r in catalog.iterrows()]}\n", + "\n", + "\n", + "def graph_sim(g, pn, cn):\n", + " \"\"\"두 부품이 공유하는 이웃 노드 비율(Jaccard).\"\"\"\n", + " pn = g[\"canon\"](pn)\n", + " return len(pn & cn) / max(len(pn | cn), 1)\n", + "\n", + "\n", + "def score_table(parts_df, catalog):\n", + " \"\"\"해외 부품 x 같은 범주 국내 후보 쌍마다 텍스트·규격·그래프 점수를 계산한 표(가중치 적용 전).\"\"\"\n", + " vec = TfidfVectorizer(token_pattern=r\"[^ ]+\").fit(list(parts_df[\"desc\"]) + list(catalog[\"desc\"]))\n", + " text = cosine_similarity(vec.transform(parts_df[\"desc\"]), vec.transform(catalog[\"desc\"]))\n", + " g = build_graph(catalog)\n", + " rows = []\n", + " for i, (_, p) in enumerate(parts_df.iterrows()):\n", + " pn = graph_nodes(p)\n", + " for j, (_, c) in enumerate(catalog.iterrows()):\n", + " if c[\"category\"] != p[\"category\"]:\n", + " continue\n", + " sf, conds = spec_fit(p, c)\n", + " rows.append({\"part_no\": p[\"part_no\"], \"cand_no\": c[\"cand_no\"], \"vendor\": c[\"vendor\"], \"text\": text[i, j],\n", + " \"spec\": sf, \"graph\": graph_sim(g, pn, g[\"nodes\"][j]),\n", + " \"unmet\": \",\".join(n for n, ok in zip([\"전압\", \"전력\", \"저온\", \"고온\", \"크기\", \"인터페이스\"], conds) if not ok) or \"-\",\n", + " \"kind\": c[\"kind\"], \"correct\": c[\"approved_for\"] == p[\"part_no\"]})\n", + " return pd.DataFrame(rows)\n", + "\n", + "\n", + "def recommend(table, weights=None, top=3):\n", + " \"\"\"가중합 점수로 부품별 상위 top개 후보를 고른다.\"\"\"\n", + " w = weights or WEIGHTS\n", + " t = table.assign(score=w[\"text\"] * table[\"text\"] + w[\"spec\"] * table[\"spec\"] + w[\"graph\"] * table[\"graph\"])\n", + " return t.sort_values([\"part_no\", \"score\"], ascending=[True, False]).groupby(\"part_no\").head(top)\n", + "\n", + "\n", + "def hit_rate(recs, k=1):\n", + " \"\"\"과거 승인 이력(정답 대체품)이 상위 k개 안에 들어간 부품 비율.\"\"\"\n", + " return recs.groupby(\"part_no\").apply(lambda d: d[\"correct\"].head(k).any(), include_groups=False).mean()\n", + "\n", + "# ---- 역할과 권한(최소 권한 원칙): 시스템은 '제안'만 할 수 있고 확정 권한이 없다 ----\n", + "PERM = {\"시스템\": {\"propose\"}, \"조달담당\": {\"submit\", \"view\"}, \"기술검토관\": {\"review_spec\", \"view\"}, \"보안담당\": {\"review_sec\", \"view\"},\n", + " \"승인권자\": {\"approve\", \"view\"}, \"감사관\": {\"view\", \"audit\"},\n", + " \"부서장\": {\"submit\", \"approve\", \"view\"}} # 소규모 부서는 상신과 결재를 겸할 수 있으나 같은 건은 분리\n", + "NEXT = {\"제안\": (\"submit\", \"상신\"), \"상신\": (\"review_spec\", \"규격검토\"), \"규격검토\": (\"review_sec\", \"보안검토\"), \"보안검토\": (\"approve\", \"승인\")}\n", + "USERS = {\"sys\": \"시스템\", \"kim\": \"조달담당\", \"lee\": \"기술검토관\", \"park\": \"보안담당\", \"choi\": \"승인권자\", \"jung\": \"감사관\", \"oh\": \"부서장\"}\n", + "FLAGGED_VENDORS = {\"가상시스템(주)\"} # [교육용가상자료] 보안 검토에서 추가 확인이 필요한 업체 표기\n", + "\n", + "\n", + "class Chain:\n", + " \"\"\"해시 체인 감사 로그: 각 기록이 직전 기록의 해시를 포함해 중간 수정이 드러난다.\"\"\"\n", + "\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", + "class Workflow:\n", + " def __init__(self):\n", + " self.audit, self.cases, self.blocked = Chain(), {}, []\n", + "\n", + " def _deny(self, who, case, action, why):\n", + " self.audit.add(event=\"DENIED\", who=who, case=case, action=action, reason=why)\n", + " self.blocked.append((who, case, action, why)); return False, why\n", + "\n", + " def act(self, who, case, action, reason=\"\", evidence=\"\"):\n", + " role = USERS.get(who)\n", + " if role is None:\n", + " return self._deny(who, case, action, \"미등록 사용자\")\n", + " if action == \"delete_log\":\n", + " return self._deny(who, case, action, \"감사 로그 삭제·수정 권한은 누구에게도 없음\")\n", + " if action not in PERM[role]:\n", + " return self._deny(who, case, action, f\"권한 없음({role} 역할에 {action} 권한 없음)\")\n", + " if action == \"propose\":\n", + " self.cases[case] = {\"state\": \"제안\", \"submitter\": None}\n", + " self.audit.add(event=\"PROPOSE\", who=who, case=case, reason=reason); return True, \"제안 등록\"\n", + " c = self.cases.get(case)\n", + " if c is None:\n", + " return self._deny(who, case, action, \"존재하지 않는 건\")\n", + " if c[\"state\"] in (\"승인\", \"반려\"):\n", + " return self._deny(who, case, action, f\"이미 종결된 건({c['state']})\")\n", + " need, nxt = NEXT[c[\"state\"]]\n", + " if action != need:\n", + " return self._deny(who, case, action, f\"단계 위반(현재 {c['state']}, 필요 행위 {need})\")\n", + " if not reason.strip() or not evidence.strip():\n", + " return self._deny(who, case, action, \"사유·근거 입력 필수\")\n", + " if action == \"submit\":\n", + " c[\"submitter\"] = who\n", + " if action == \"approve\" and who == c[\"submitter\"]:\n", + " return self._deny(who, case, action, \"상신자와 승인자 분리 원칙 위반\")\n", + " c[\"state\"] = nxt\n", + " self.audit.add(event=action.upper(), who=who, role=role, case=case, to=nxt, reason=reason, evidence=evidence); return True, nxt\n", + "\n", + " def reject(self, who, case, reason, evidence):\n", + " \"\"\"반려: 검토자·승인권자가 어느 단계에서든 사유·근거와 함께 할 수 있다.\"\"\"\n", + " c = self.cases[case]\n", + " role = USERS.get(who)\n", + " if role not in (\"승인권자\", \"기술검토관\", \"보안담당\", \"부서장\") or c[\"state\"] in (\"승인\", \"반려\"):\n", + " return self._deny(who, case, \"reject\", \"반려 권한 없음 또는 종결된 건\")\n", + " if not reason.strip() or not evidence.strip():\n", + " return self._deny(who, case, \"reject\", \"사유·근거 입력 필수\")\n", + " c[\"state\"] = \"반려\"\n", + " self.audit.add(event=\"REJECTED\", who=who, role=role, case=case, reason=reason, evidence=evidence); return True, \"반려\"\n", + "\n", + "\n", + "# ---- 상신 대상: 위험 상위 해외 부품과 추천 1순위 후보 ----\n", + "parts, catalog = make_parts(100, seed=7)\n", + "over = parts[parts[\"overseas\"]].reset_index(drop=True)\n", + "table = score_table(over, catalog)\n", + "top = recommend(table).groupby(\"part_no\").head(1).set_index(\"part_no\")\n", + "risk = (30 * (over[\"n_suppliers\"] == 1) + 25 * (over[\"late_orders\"] / 12) + 20 * (over[\"eol_status\"] != \"정상\") + 10 * over[\"criticality\"].eq(\"A\")).to_numpy()\n", + "picked = over.assign(risk=risk).sort_values(\"risk\", ascending=False).head(12)\n", + "rng = np.random.default_rng(4)\n", + "\n", + "wf = Workflow()\n", + "rows = []\n", + "for k, (_, p) in enumerate(picked.iterrows(), 1):\n", + " case, r = f\"C{k:02d}\", top.loc[p[\"part_no\"]]\n", + " wf.act(\"sys\", case, \"propose\", reason=f\"{p['part_no']} 위험 점수 {p['risk']:.0f}, 추천 {r['cand_no']}\")\n", + " wf.act(\"kim\", case, \"submit\", \"단종·단일 공급선 위험 부품 대체 검토 요청\", f\"위험 점수 {p['risk']:.0f}점, 후보 점수 {r['score']:.2f}\")\n", + " hard = [u for u in r[\"unmet\"].split(\",\") if u in (\"전압\", \"저온\", \"고온\")]\n", + " if rng.random() < 0.15:\n", + " rows.append((case, p[\"part_no\"], r[\"cand_no\"], \"무응답 -> 보류\", \"자동 확정 없음: 기술검토관 응답 대기\")); continue\n", + " if hard:\n", + " wf.reject(\"lee\", case, f\"필수 규격 미충족({','.join(hard)})\", f\"규격 적합성 {r['spec']:.2f}\")\n", + " rows.append((case, p[\"part_no\"], r[\"cand_no\"], wf.cases[case][\"state\"], f\"규격 반려: {','.join(hard)}\")); continue\n", + " wf.act(\"lee\", case, \"review_spec\", \"필수 규격(전압·온도) 충족 확인\" + (f\", 미충족 {r['unmet']}는 시험 조건부\" if r[\"unmet\"] != \"-\" else \"\"), f\"규격 적합성 {r['spec']:.2f}\")\n", + " if r[\"vendor\"] in FLAGGED_VENDORS:\n", + " wf.reject(\"park\", case, \"업체 보안 확인 미완료\", f\"업체 {r['vendor']} 추가 확인 필요\")\n", + " rows.append((case, p[\"part_no\"], r[\"cand_no\"], wf.cases[case][\"state\"], \"보안 반려: 업체 확인 필요\")); continue\n", + " wf.act(\"park\", case, \"review_sec\", \"업체·공급 경로 보안 확인\", f\"업체 {r['vendor']} 확인 완료(가상)\")\n", + " ok = r[\"score\"] >= 0.80 and p[\"risk\"] >= 40\n", + " if ok:\n", + " wf.act(\"choi\", case, \"approve\", \"승인: 위험 부품 국산화 시험 착수\", f\"후보 점수 {r['score']:.2f}, 위험 {p['risk']:.0f}\")\n", + " else:\n", + " wf.reject(\"choi\", case, \"반려: 추천 점수 또는 위험도 기준 미달(추가 시험 후 재상신)\", f\"후보 점수 {r['score']:.2f}, 위험 {p['risk']:.0f}\")\n", + " rows.append((case, p[\"part_no\"], r[\"cand_no\"], wf.cases[case][\"state\"], \"승인권자 결정\" if ok else \"점수 미달 반려\"))\n", + "print(\"[결재 결과] 12건\")\n", + "print(pd.DataFrame(rows, columns=[\"건\", \"부품\", \"후보\", \"최종 상태\", \"비고\"]).to_string(index=False))\n", + "st = pd.Series([r[3] for r in rows]).value_counts().to_dict()\n", + "print(\"상태 집계:\", st, \"(모의 결재자 기준, 실제 결재는 사람이 한다)\")\n", + "\n", + "# ---- 권한 우회·절차 위반 시도 ----\n", + "wf.act(\"sys\", \"T01\", \"propose\", reason=\"시험 건\")\n", + "wf.act(\"kim\", \"T01\", \"submit\", \"시험 상신\", \"근거\")\n", + "attempts = [\n", + " (\"시스템 자동 승인\", lambda: wf.act(\"sys\", \"T01\", \"approve\", \"자동\", \"자동\")),\n", + " (\"조달담당이 규격 검토 대행\", lambda: wf.act(\"kim\", \"T01\", \"review_spec\", \"대행\", \"근거\")),\n", + " (\"검토 단계 건너뛰고 승인\", lambda: wf.act(\"choi\", \"T01\", \"approve\", \"바로 승인\", \"근거\")),\n", + " (\"미등록 사용자 접근\", lambda: wf.act(\"guest\", \"T01\", \"review_spec\", \"열람\", \"근거\")),\n", + " (\"사유 없는 검토\", lambda: wf.act(\"lee\", \"T01\", \"review_spec\", \"\", \"\")),\n", + " (\"감사 로그 삭제\", lambda: wf.act(\"kim\", \"T01\", \"delete_log\", \"정리\", \"근거\")),\n", + " (\"감사관이 승인 시도\", lambda: wf.act(\"jung\", \"T01\", \"approve\", \"승인\", \"근거\")),\n", + " (\"종결된 건 재승인\", lambda: wf.act(\"choi\", \"C01\", \"approve\", \"재승인\", \"근거\")),\n", + "]\n", + "wf.act(\"sys\", \"T02\", \"propose\", reason=\"시험\"); wf.act(\"oh\", \"T02\", \"submit\", \"상신\", \"근거\")\n", + "wf.act(\"lee\", \"T02\", \"review_spec\", \"규격 확인\", \"적합성 1.00\"); wf.act(\"park\", \"T02\", \"review_sec\", \"보안 확인\", \"확인 완료\")\n", + "attempts.append((\"상신자가 본인 건 승인(분리 원칙)\", lambda: wf.act(\"oh\", \"T02\", \"approve\", \"본인 승인\", \"근거\")))\n", + "print(\"\\n[권한 우회·절차 위반 시도]\")\n", + "blocked = 0\n", + "for name, fn in attempts:\n", + " ok, why = fn()\n", + " blocked += (not ok)\n", + " print(f\" {name:22s}-> {'차단' if not ok else '통과(결함)'}: {why}\")\n", + "print(f\"우회·위반 시도 차단 {blocked}/{len(attempts)} ({blocked / len(attempts):.0%}) (목표 100%: {'달성' if blocked == len(attempts) else '미달'})\")\n", + "\n", + "# ---- 이력 무결성 ----\n", + "bad = wf.audit.verify()\n", + "n_cases_logged = sum(1 for e in wf.audit.log if e[\"event\"] == \"PROPOSE\")\n", + "print(f\"\\n감사 로그 {len(wf.audit.log)}건, 해시 체인 검증: {'정상' if bad is None else f'seq {bad}에서 불일치'} -> 이력 무결성 {'100%' if bad is None else '훼손'}(목표 100%: {'달성' if bad is None else '미달'})\")\n", + "saved = json.dumps(wf.audit.log[10], ensure_ascii=False)\n", + "wf.audit.log[10][\"reason\"] = \"사후 수정\"\n", + "print(f\"10번째 기록 임의 수정 후 검증: {'위변조 탐지(seq ' + str(wf.audit.verify()) + ')' if wf.audit.verify() is not None else '탐지 실패'}\")\n", + "wf.audit.log[10] = json.loads(saved)\n", + "print(\"승인된 건의 마지막 기록 예시:\", {k: v for k, v in next((e for e in reversed(wf.audit.log) if e[\"event\"] == \"APPROVE\" and e[\"case\"].startswith(\"C\")), {}).items() if k in (\"seq\", \"who\", \"case\", \"reason\", \"evidence\")})\n", + "print(\"※ 대체 부품의 규격 적합성 최종 판단과 조달 승인은 사람(기술검토관·보안담당·승인권자)이 하며, 시스템은 추천과 근거만 제공한다.\")\n", + "\n", + "plt.figure(figsize=(6, 3.5)); EN = {\"승인\": \"approved\", \"반려\": \"rejected\", \"무응답 -> 보류\": \"on hold\"}; plt.bar([EN.get(k, k) for k in st], list(st.values()), color=\"tab:blue\"); plt.title(\"HITL workflow outcomes (12 cases)\"); plt.ylabel(\"cases\"); plt.tight_layout(); plt.show()\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 07 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + "[결재 결과] 12건\n", + " 건 부품 후보 최종 상태 비고\n", + "C01 P-0028 D-0046 반려 점수 미달 반려\n", + "C02 P-0056 D-0058 승인 승인권자 결정\n", + "C03 P-0087 D-0223 승인 승인권자 결정\n", + "C04 P-0036 D-0094 무응답 -> 보류 자동 확정 없음: 기술검토관 응답 대기\n", + "C05 P-0066 D-0172 승인 승인권자 결정\n", + "C06 P-0080 D-0205 승인 승인권자 결정\n", + "C07 P-0077 D-0049 반려 점수 미달 반려\n", + "C08 P-0045 D-0115 승인 승인권자 결정\n", + "C09 P-0003 D-0004 승인 승인권자 결정\n", + "C10 P-0062 D-0160 반려 보안 반려: 업체 확인 필요\n", + "C11 P-0053 D-0136 승인 승인권자 결정\n", + "C12 P-0012 D-0025 승인 승인권자 결정\n", + "상태 집계: {'승인': 8, '반려': 3, '무응답 -> 보류': 1} (모의 결재자 기준, 실제 결재는 사람이 한다)\n", + "\n", + "[권한 우회·절차 위반 시도]\n", + " 시스템 자동 승인 -> 차단: 권한 없음(시스템 역할에 approve 권한 없음)\n", + " 조달담당이 규격 검토 대행 -> 차단: 권한 없음(조달담당 역할에 review_spec 권한 없음)\n", + " 검토 단계 건너뛰고 승인 -> 차단: 단계 위반(현재 상신, 필요 행위 review_spec)\n", + " 미등록 사용자 접근 -> 차단: 미등록 사용자\n", + " 사유 없는 검토 -> 차단: 사유·근거 입력 필수\n", + " 감사 로그 삭제 -> 차단: 감사 로그 삭제·수정 권한은 누구에게도 없음\n", + " 감사관이 승인 시도 -> 차단: 권한 없음(감사관 역할에 approve 권한 없음)\n", + " 종결된 건 재승인 -> 차단: 이미 종결된 건(반려)\n", + " 상신자가 본인 건 승인(분리 원칙) -> 차단: 상신자와 승인자 분리 원칙 위반\n", + "우회·위반 시도 차단 9/9 (100%) (목표 100%: 달성)\n", + "\n", + "감사 로그 71건, 해시 체인 검증: 정상 -> 이력 무결성 100%(목표 100%: 달성)\n", + "10번째 기록 임의 수정 후 검증: 위변조 탐지(seq 10)\n", + "승인된 건의 마지막 기록 예시: {'seq': 55, 'who': 'choi', 'case': 'C12', 'reason': '승인: 위험 부품 국산화 시험 착수', 'evidence': '후보 점수 0.84, 위험 62'}\n", + "※ 대체 부품의 규격 적합성 최종 판단과 조달 승인은 사람(기술검토관·보안담당·승인권자)이 하며, 시스템은 추천과 근거만 제공한다.\n", + "```\n", + "
\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 08. 지정학적 위기 모의 글로벌 공급망 전면 차단 스트레스 시뮬레이션\n", + "**소요시간**: 약 3.5시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "글로벌 공급망 차단 모의 스트레스 시뮬레이션을 수행한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **가상 공급국 전면 차단을 모의한 스트레스 시뮬레이션으로 배치 파이프라인의 실행 시간, 안정성, 업무 시간 절감 효과를 검증한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **평시, 가상국가 A 차단, A+B 차단, 해외 전면 차단 4개 시나리오를 부품 2,000종 규모로 실행해 단절 부품, 국산 대체 후보 보유, 업체 수용량을 고려한 분기 내 착수 가능 건수, 핵심 부품 착수 대기, 재고 90일 미만 단절 산출하고 모델에 적용하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 5개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `src/pipeline/prc07_batch_runner.py` 형태의 결과물을 완성한다.\n", + "\n", + "### 🔍 평가 및 검증 기준 (PASS 조건)\n", + "#### 미션 판정\n", + "\n", + "**평가 구분:** 필수\n", + "\n", + "**핵심 평가영역:** 수작업 대비 업무 소요 시간 80% 이상 절감 효과\n", + "\n", + "#### 미션 PASS 조건\n", + "\n", + "- 정량 기준: 전체 배치 파이프라인 실행 시간 5분 이내, 자동화 성공률 99% 이상\n", + "\n", + "\n", + "- 진행 기준: 스케줄러 연동 테스트 및 자동 실행 확인\n", + "\n", + "\n", + "- 검증 방법: 실제 월간 업무 데이터 1년치 일괄 배치 시뮬레이션\n", + "\n", + "\n", + "#### 채점자 판정 메모\n", + "\n", + "> 단순 코드 실행 여부뿐만 아니라 출력된 수치와 근거 자료가 군 실무 요구사항에 부합하는지 면밀히 확인한다.\n", + "\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "\"\"\"가상 공급국 전면 차단(지정학적 위기 모의) 시나리오 4종을 부품 2,000종 규모 배치 파이프라인으로 실행하고, 레코드 손상 2%·일시 오류 주입 상황에서 실행 시간(5분 이내)·자동화 성공률(99% 이상)·수작업 대비 시간 절감(80% 이상)과 시간 초과 시 기능 축소(Fail-safe) 동작을 검증한다.\"\"\"\n", + "import time\n", + "import numpy as np\n", + "import pandas as pd\n", + "import matplotlib.pyplot as plt\n", + "\n", + "# ---------------------------------------------------------------------------\n", + "# [교육용가상자료] 가상 무기체계 부품·공급망 데이터 생성기 (실제 체계·업체·국가·단가 정보 없음)\n", + "# 부품 100종(해외 조달 약 80%)의 공급망 속성과, 국내 부품 카탈로그(과거 승인된 국산 대체품 +\n", + "# 설명은 비슷하지만 규격이 맞지 않는 유사 후보)를 함께 만든다.\n", + "# ---------------------------------------------------------------------------\n", + "COUNTRIES = [\"가상국가A\", \"가상국가B\", \"가상국가C\", \"국내\"]\n", + "CATS = {\n", + " \"전원모듈\": dict(words=[\"DC-DC\", \"컨버터\", \"절연형\", \"전원\", \"정출력\"], syn={\"컨버터\": \"변환장치\", \"절연형\": \"아이솔레이션\", \"정출력\": \"안정화\"}, std=\"MIL-STD-704\", iface=\"단자대\"),\n", + " \"커넥터\": dict(words=[\"원형\", \"커넥터\", \"방수형\", \"다핀\", \"나사체결\"], syn={\"커넥터\": \"접속자\", \"방수형\": \"밀폐형\", \"나사체결\": \"스크루결합\"}, std=\"MIL-DTL-38999\", iface=\"원형핀\"),\n", + " \"유압밸브\": dict(words=[\"유압\", \"솔레노이드\", \"밸브\", \"방향제어\", \"4포트\"], syn={\"밸브\": \"개폐장치\", \"방향제어\": \"유로전환\", \"솔레노이드\": \"전자식\"}, std=\"SAE-AS5440\", iface=\"플랜지\"),\n", + " \"베어링\": dict(words=[\"볼\", \"베어링\", \"고속\", \"밀봉형\", \"회전축\"], syn={\"베어링\": \"축받이\", \"밀봉형\": \"실드형\", \"고속\": \"고회전\"}, std=\"ISO-492\", iface=\"축결합\"),\n", + " \"광학렌즈\": dict(words=[\"적외선\", \"렌즈\", \"게르마늄\", \"반사방지\", \"집광\"], syn={\"렌즈\": \"광학창\", \"집광\": \"초점형성\", \"반사방지\": \"AR코팅\"}, std=\"MIL-PRF-13830\", iface=\"마운트\"),\n", + " \"자이로센서\": dict(words=[\"광섬유\", \"자이로\", \"각속도\", \"센서\", \"관성\"], syn={\"센서\": \"감지기\", \"각속도\": \"회전율\", \"관성\": \"INS용\"}, std=\"IEEE-952\", iface=\"직렬통신\"),\n", + " \"RF증폭기\": dict(words=[\"RF\", \"전력증폭기\", \"광대역\", \"모듈\", \"송신\"], syn={\"전력증폭기\": \"파워앰프\", \"송신\": \"출력단\", \"광대역\": \"와이드밴드\"}, std=\"MIL-STD-461\", iface=\"SMA\"),\n", + " \"제어보드\": dict(words=[\"FPGA\", \"제어보드\", \"임베디드\", \"연산\", \"입출력\"], syn={\"제어보드\": \"컨트롤카드\", \"연산\": \"신호처리\", \"임베디드\": \"내장형\"}, std=\"VITA-46\", iface=\"백플레인\"),\n", + "}\n", + "V_OPTS, W_OPTS = [12, 24, 28], [25, 50, 100]\n", + "VENDORS = [\"가상정밀(주)\", \"가상전자(주)\", \"가상유공압(주)\", \"가상광학(주)\", \"가상테크(주)\", \"가상시스템(주)\"]\n", + "\n", + "\n", + "def describe(cat, words, s):\n", + " return f\"{cat} {' '.join(words)} {s['v']}V {s['w']}W {s['tmin']}~{s['tmax']}C {s['std']}\"\n", + "\n", + "\n", + "def _sig(x):\n", + " return 1 / (1 + np.exp(-x))\n", + "\n", + "\n", + "def make_parts(n=100, seed=7):\n", + " \"\"\"부품 목록(공급망 속성 포함)과 국내 카탈로그를 만든다. 반환: (parts DataFrame, catalog DataFrame)\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " cats, rows, cat_rows = list(CATS), [], []\n", + " for i in range(n):\n", + " cat = cats[i % len(cats)]; c = CATS[cat]\n", + " spec = {\"v\": int(rng.choice(V_OPTS)), \"w\": int(rng.choice(W_OPTS)), \"tmin\": int(rng.choice([-55, -40])),\n", + " \"tmax\": int(rng.choice([85, 125])), \"size_mm\": float(round(rng.uniform(20, 120), 1)), \"std\": c[\"std\"]}\n", + " words = [str(x) for x in rng.permutation(c[\"words\"])[:4]]\n", + " country = str(rng.choice(COUNTRIES, p=[0.35, 0.3, 0.15, 0.2]))\n", + " overseas = country != \"국내\"\n", + " n_sup = int(rng.choice([1, 2, 3], p=[0.45, 0.35, 0.2])) if overseas else int(rng.choice([1, 2, 3]))\n", + " lead = float(round(rng.gamma(3, 45 if overseas else 25) + 20))\n", + " eol = str(rng.choice([\"정상\", \"단종예고\", \"단종\"], p=[0.7, 0.2, 0.1]))\n", + " crit = str(rng.choice([\"A\", \"B\", \"C\"], p=[0.3, 0.4, 0.3]))\n", + " z = (-2.6 + 1.1 * (n_sup == 1) + 0.9 * overseas + 0.006 * (lead - 100) + 1.2 * (eol == \"단종예고\")\n", + " + 2.0 * (eol == \"단종\") + rng.normal(0, 0.6))\n", + " p_late = float(np.clip(_sig(z) * 0.8 + 0.05, 0, 0.9))\n", + " late = int(rng.binomial(12, p_late))\n", + " demand = int(rng.integers(20, 400))\n", + " rows.append({\"part_no\": f\"P-{i + 1:04d}\", \"category\": cat, \"desc\": describe(cat, words, spec), **spec,\n", + " \"iface\": c[\"iface\"], \"country\": country, \"overseas\": overseas, \"n_suppliers\": n_sup,\n", + " \"lead_time_days\": lead, \"orders_12m\": 12, \"late_orders\": late,\n", + " \"avg_delay_days\": float(round(late and rng.gamma(2, 12 + 20 * p_late), 1)),\n", + " \"eol_status\": eol, \"ltb_months\": int(rng.integers(1, 25)) if eol == \"단종예고\" else None,\n", + " \"price_change_pct\": float(round(rng.normal(4 + 12 * p_late, 8), 1)), \"criticality\": crit,\n", + " \"annual_demand\": demand, \"stock_qty\": int(rng.integers(0, demand)),\n", + " \"defect_rate\": float(round(abs(rng.normal(0.8 + 2 * p_late, 0.8)), 2)),\n", + " \"export_license\": bool(overseas and rng.random() < 0.35),\n", + " \"disrupted_next12m\": bool(rng.random() < _sig(z + 0.4))})\n", + " if not overseas:\n", + " continue\n", + " # 과거 승인된 국산 대체품(정답): 동의어로 서술, 규격 호환(전력 여유 허용, 일부는 크기 차이가 커 장착 검토 필요)\n", + " eq_words = [c[\"syn\"].get(w, w) for w in words]\n", + " hard = rng.random() < 0.15 # 서술이 많이 다른 사례\n", + " if hard:\n", + " eq_words = [c[\"syn\"].get(w, w) for w in c[\"words\"] if w not in words][:1] + eq_words[1:3]\n", + " eq = {**spec, \"w\": spec[\"w\"] if rng.random() < 0.7 else W_OPTS[min(W_OPTS.index(spec[\"w\"]) + 1, 2)],\n", + " \"size_mm\": float(round(spec[\"size_mm\"] * (rng.uniform(1.11, 1.15) if rng.random() < 0.12 else rng.uniform(0.95, 1.05)), 1))}\n", + " cat_rows.append({\"cand_no\": f\"D-{len(cat_rows) + 1:04d}\", \"category\": cat, \"desc\": describe(cat, eq_words, eq), **eq,\n", + " \"iface\": c[\"iface\"], \"vendor\": str(rng.choice(VENDORS)), \"approved_for\": f\"P-{i + 1:04d}\", \"kind\": \"equivalent\"})\n", + " # 유사 오답 후보: 설명은 거의 같지만 전압이 다르거나(5V/48V) 상용 온도 등급(0~70C)\n", + " for kind in (\"volt_mismatch\", \"temp_mismatch\"):\n", + " d = dict(spec)\n", + " if kind == \"volt_mismatch\":\n", + " d[\"v\"] = int(rng.choice([5, 48])) # 체계에서 쓰지 않는 전압\n", + " else:\n", + " d[\"tmin\"], d[\"tmax\"] = 0, 70\n", + " d[\"size_mm\"] = float(round(spec[\"size_mm\"] * rng.uniform(0.95, 1.05), 1))\n", + " cat_rows.append({\"cand_no\": f\"D-{len(cat_rows) + 1:04d}\", \"category\": cat, \"desc\": describe(cat, words, d), **d,\n", + " \"iface\": c[\"iface\"], \"vendor\": str(rng.choice(VENDORS)), \"approved_for\": None, \"kind\": kind})\n", + " parts = pd.DataFrame(rows)\n", + " catalog = pd.DataFrame(cat_rows).sample(frac=1, random_state=seed).reset_index(drop=True)\n", + " return parts, catalog\n", + "# ---------------------------------------------------------------------------\n", + "\n", + "# ---- 조달 위험 스크리닝 룰 설정(YAML 형식 문자열: 규정이 바뀌면 코드가 아니라 이 설정만 고친다) ----\n", + "RULES_YAML = \"\"\"\n", + "- id: R01\n", + " name: 단일 공급선\n", + " field: n_suppliers\n", + " op: \"==\"\n", + " value: 1\n", + " weight: 3\n", + "- id: R02\n", + " name: 해외 조달 의존\n", + " field: overseas\n", + " op: \"==\"\n", + " value: true\n", + " weight: 1\n", + "- id: R03\n", + " name: 리드타임 180일 초과\n", + " field: lead_time_days\n", + " op: \">\"\n", + " value: 180\n", + " weight: 2\n", + "- id: R04\n", + " name: 납기 지연률 30% 이상\n", + " field: delay_rate\n", + " op: \">=\"\n", + " value: 0.3\n", + " weight: 3\n", + "- id: R05\n", + " name: 평균 지연 30일 초과\n", + " field: avg_delay_days\n", + " op: \">\"\n", + " value: 30\n", + " weight: 2\n", + "- id: R06\n", + " name: 단종 예고\n", + " field: eol_status\n", + " op: \"==\"\n", + " value: 단종예고\n", + " weight: 3\n", + "- id: R07\n", + " name: 단종 확정\n", + " field: eol_status\n", + " op: \"==\"\n", + " value: 단종\n", + " weight: 4\n", + "- id: R08\n", + " name: 최종구매 6개월 이내\n", + " field: ltb_months\n", + " op: \"<=\"\n", + " value: 6\n", + " weight: 3\n", + "- id: R09\n", + " name: 단가 20% 이상 급등\n", + " field: price_change_pct\n", + " op: \">=\"\n", + " value: 20\n", + " weight: 2\n", + "- id: R10\n", + " name: 핵심(A) 부품 단일 공급\n", + " field: crit_single\n", + " op: \"==\"\n", + " value: true\n", + " weight: 3\n", + "- id: R11\n", + " name: 재고 가용기간 < 리드타임\n", + " field: cover_gap_days\n", + " op: \"<\"\n", + " value: 0\n", + " weight: 3\n", + "- id: R12\n", + " name: 입고 불량률 2% 초과\n", + " field: defect_rate\n", + " op: \">\"\n", + " value: 2.0\n", + " weight: 2\n", + "- id: R13\n", + " name: 수출 허가 필요\n", + " field: export_license\n", + " op: \"==\"\n", + " value: true\n", + " weight: 2\n", + "- id: R14\n", + " name: 재고 없음\n", + " field: stock_qty\n", + " op: \"==\"\n", + " value: 0\n", + " weight: 2\n", + "- id: R15\n", + " name: 공급국 편중(30% 이상)\n", + " field: country_share\n", + " op: \">=\"\n", + " value: 0.3\n", + " weight: 1\n", + "- id: R16\n", + " name: 지연 이력 있는 단일 공급선\n", + " field: single_late\n", + " op: \"==\"\n", + " value: true\n", + " weight: 2\n", + "\"\"\"\n", + "GRADE_CUT = {\"고위험\": 16, \"주의\": 10} # 가중치 합 기준 1차 스크리닝 등급\n", + "\n", + "\n", + "def parse_rules(text):\n", + " \"\"\"간이 YAML 파서(목록 + key: value). 숫자·true/false·따옴표 문자열을 해석한다.\"\"\"\n", + " rules, cur = [], None\n", + " for line in text.strip().splitlines():\n", + " if not line.strip():\n", + " continue\n", + " s = line.strip()\n", + " if s.startswith(\"- \"):\n", + " cur = {}; rules.append(cur); s = s[2:]\n", + " k, v = [x.strip() for x in s.split(\":\", 1)]\n", + " v = v.strip('\"')\n", + " if v in (\"true\", \"false\"):\n", + " v = v == \"true\"\n", + " else:\n", + " try:\n", + " v = float(v) if \".\" in v else int(v)\n", + " except ValueError:\n", + " pass\n", + " cur[k] = v\n", + " return rules\n", + "\n", + "\n", + "OPS = {\"==\": lambda a, b: a == b, \"!=\": lambda a, b: a != b, \">\": lambda a, b: a > b, \">=\": lambda a, b: a >= b,\n", + " \"<\": lambda a, b: a < b, \"<=\": lambda a, b: a <= b}\n", + "\n", + "\n", + "def derive(df):\n", + " \"\"\"룰에 쓰는 파생 필드(지연률, 핵심 단일공급, 재고 가용기간 차이, 공급국 비중 등)를 만든다.\"\"\"\n", + " d = df.copy()\n", + " d[\"delay_rate\"] = d[\"late_orders\"] / d[\"orders_12m\"].clip(lower=1)\n", + " d[\"crit_single\"] = (d[\"criticality\"] == \"A\") & (d[\"n_suppliers\"] == 1)\n", + " d[\"cover_gap_days\"] = d[\"stock_qty\"] / d[\"annual_demand\"].clip(lower=1) * 365 - d[\"lead_time_days\"]\n", + " d[\"country_share\"] = d[\"country\"].map(d[\"country\"].value_counts(normalize=True)).where(d[\"overseas\"], 0.0)\n", + " d[\"single_late\"] = (d[\"n_suppliers\"] == 1) & (d[\"late_orders\"] >= 3)\n", + " return d\n", + "\n", + "\n", + "def apply_rules(rec, rules):\n", + " \"\"\"레코드 1건(dict)에 룰을 적용해 (위험 점수, 걸린 룰 목록)을 돌려준다. 값이 없으면 해당 룰은 건너뛴다.\"\"\"\n", + " hits = []\n", + " for r in rules:\n", + " v = rec.get(r[\"field\"])\n", + " if v is None or (isinstance(v, float) and np.isnan(v)):\n", + " continue\n", + " if OPS[r[\"op\"]](v, r[\"value\"]):\n", + " hits.append(r)\n", + " score = sum(r[\"weight\"] for r in hits)\n", + " grade = \"고위험\" if score >= GRADE_CUT[\"고위험\"] else (\"주의\" if score >= GRADE_CUT[\"주의\"] else \"관심\")\n", + " return score, grade, [r[\"id\"] for r in hits]\n", + "\n", + "rules = parse_rules(RULES_YAML)\n", + "SCEN = {\"평시\": [], \"가상국가A 차단\": [\"가상국가A\"], \"가상국가A+B 차단\": [\"가상국가A\", \"가상국가B\"], \"해외 전면 차단\": [\"가상국가A\", \"가상국가B\", \"가상국가C\"]}\n", + "MANUAL_MIN = 32.0 # [가정] 수작업 1건: 위험 확인 12분 + 국내 후보 탐색·규격 대조 20분\n", + "VENDOR_CAP = 70 # [가정] 국내 업체 1곳이 한 분기에 착수할 수 있는 신규 국산화 개발 건수\n", + "REVIEW_MIN = 4.0 # [가정] 자동 처리 후 담당자가 확인하는 건당 시간(고위험·단절 건만)\n", + "\n", + "\n", + "def fit_vec(P, C):\n", + " \"\"\"규격 적합성(0~1)을 부품 x 후보 행렬로 한 번에 계산: 전압·전력·저온·고온·크기·인터페이스 6조건.\"\"\"\n", + " cond = np.stack([P[\"v\"].to_numpy()[:, None] == C[\"v\"].to_numpy()[None], C[\"w\"].to_numpy()[None] >= P[\"w\"].to_numpy()[:, None],\n", + " C[\"tmin\"].to_numpy()[None] <= P[\"tmin\"].to_numpy()[:, None], C[\"tmax\"].to_numpy()[None] >= P[\"tmax\"].to_numpy()[:, None],\n", + " np.abs(C[\"size_mm\"].to_numpy()[None] - P[\"size_mm\"].to_numpy()[:, None]) / P[\"size_mm\"].to_numpy()[:, None] <= 0.10,\n", + " P[\"iface\"].to_numpy()[:, None] == C[\"iface\"].to_numpy()[None]])\n", + " s = cond.mean(axis=0)\n", + " return np.where(cond[0] & cond[2] & cond[3], s, s * 0.5)\n", + "\n", + "\n", + "def pipeline(parts, catalog, blocked, mode=\"full\", fail_p=0.0, rng=None, budget_s=None):\n", + " \"\"\"검증 -> 룰 스크리닝 -> 차단 영향 -> (full 모드) 국산 대체 후보 탐색. 오류는 재시도 3회, 실패 건은 격리하고 계속한다.\"\"\"\n", + " t0 = time.perf_counter()\n", + " stats = {\"ok\": 0, \"quarantine\": 0, \"retry\": 0, \"failed\": 0, \"degraded\": False}\n", + " d = derive(parts)\n", + " hit = np.zeros(len(d), bool); score = np.zeros(len(d))\n", + " recs = d.to_dict(\"records\")\n", + " for i, r in enumerate(recs):\n", + " if budget_s and time.perf_counter() - t0 > budget_s: # 시간 예산 초과: 후보 탐색을 생략하고 룰 스크리닝만 계속(기능 축소)\n", + " stats[\"degraded\"] = True; mode = \"rules_only\"\n", + " if r.get(\"lead_time_days\") is None or pd.isna(r.get(\"lead_time_days\")):\n", + " stats[\"quarantine\"] += 1; continue # 손상 레코드는 격리하고 계속 진행\n", + " for attempt in range(4):\n", + " if rng is not None and rng.random() < fail_p: # 일시 오류(모의): 최대 3회 재시도\n", + " stats[\"retry\"] += 1; continue\n", + " score[i] = apply_rules(r, rules)[0]; hit[i] = r[\"country\"] in blocked; stats[\"ok\"] += 1; break\n", + " else:\n", + " stats[\"failed\"] += 1\n", + " d = d.assign(score=score, blocked=hit)\n", + " cover = pd.Series(False, index=d.index); vend = pd.Series(\"-\", index=d.index)\n", + " if mode == \"full\" and hit.any():\n", + " for cat, g in d[d[\"blocked\"]].groupby(\"category\"):\n", + " C = catalog[catalog[\"category\"] == cat]\n", + " if len(C):\n", + " f = fit_vec(g, C)\n", + " cover.loc[g.index] = (f.max(axis=1) >= 0.83) # 규격 5/6 이상 충족 후보가 있으면 '대체 검토 가능'\n", + " vend.loc[g.index] = C[\"vendor\"].to_numpy()[f.argmax(axis=1)]\n", + " d[\"alt\"], d[\"vendor\"] = cover, vend\n", + " return d, stats, time.perf_counter() - t0\n", + "\n", + "\n", + "rng = np.random.default_rng(8)\n", + "parts, catalog = make_parts(2000, seed=7)\n", + "parts.loc[rng.choice(len(parts), 40, replace=False), \"lead_time_days\"] = np.nan # 레코드 손상 2%\n", + "print(f\"[교육용가상자료] 국내 업체 수용량 가정: 업체당 분기 {VENDOR_CAP}건\")\n", + "print(f\"[교육용가상자료] 부품 {len(parts)}종, 국내 후보 {len(catalog)}건, 손상 레코드 {int(parts['lead_time_days'].isna().sum())}건(2%) 주입, 일시 오류 확률 3%/시도\")\n", + "\n", + "res = []\n", + "for name, blk in SCEN.items():\n", + " d, st, sec = pipeline(parts, catalog, blk, fail_p=0.03, rng=rng)\n", + " b = d[d[\"blocked\"]].sort_values([\"criticality\", \"score\"], ascending=[True, False]) # 핵심(A) 먼저, 위험 점수 높은 순으로 업체 수용량 배정\n", + " b = b.assign(start=b[b[\"alt\"]].groupby(\"vendor\").cumcount() < VENDOR_CAP)\n", + " b[\"start\"] = b[\"start\"].fillna(False).astype(bool)\n", + " crit = b[b[\"criticality\"] == \"A\"]\n", + " days = (b[\"stock_qty\"] / b[\"annual_demand\"].clip(lower=1) * 365)\n", + " res.append({\"시나리오\": name, \"단절 부품\": len(b), \"대체 후보 있음\": int(b[\"alt\"].sum()), \"분기 내 착수 가능\": int(b[\"start\"].sum()), \"핵심(A) 착수 대기\": int((~crit[\"start\"]).sum()),\n", + " \"재고 90일 미만 단절\": int((days < 90).sum()), \"실행(초)\": sec, \"성공\": st[\"ok\"], \"재시도\": st[\"retry\"], \"격리\": st[\"quarantine\"], \"실패\": st[\"failed\"]})\n", + "R = pd.DataFrame(res)\n", + "print(\"\\n[차단 시나리오별 영향과 실행 결과]\")\n", + "print(R.round(2).to_string(index=False))\n", + "tot_ok, tot_all = R[\"성공\"].sum(), R[\"성공\"].sum() + R[\"실패\"].sum()\n", + "succ = tot_ok / tot_all\n", + "total_s = R[\"실행(초)\"].sum()\n", + "print(f\"\\n전체 배치 실행 시간 {total_s:.1f}초 (목표 5분 이내: {'달성' if total_s <= 300 else '미달'})\")\n", + "print(f\"자동화 성공률 {succ:.2%} = 처리 성공 {tot_ok}건 / 처리 대상 {tot_all}건(손상 격리 {R['격리'].sum()}건은 별도 보고, 일시 오류 {R['재시도'].sum()}회는 재시도로 복구) (목표 99% 이상: {'달성' if succ >= 0.99 else '미달'})\")\n", + "\n", + "# 수작업 대비 시간 절감(가정 단가 사용)\n", + "last = R.iloc[-1]\n", + "n_proc = len(parts)\n", + "manual = n_proc * MANUAL_MIN\n", + "auto = last[\"실행(초)\"] / 60 + (last[\"단절 부품\"] + R[\"격리\"].iloc[-1]) * REVIEW_MIN\n", + "print(f\"수작업 대비(해외 전면 차단, {n_proc}종): 수작업 {manual / 60:.0f}시간 vs 자동 {auto / 60:.0f}시간(담당자 확인 포함) -> {1 - auto / manual:.0%} 절감 (목표 80% 이상: {'달성' if 1 - auto / manual >= 0.8 else '미달'})\")\n", + "print(f\" 가정: 수작업 건당 {MANUAL_MIN:.0f}분, 자동 처리 후 확인 건당 {REVIEW_MIN:.0f}분(단절·격리 건만). 실제 업무 시간은 현업 측정으로 대체해야 한다.\")\n", + "\n", + "# 규모 확대 시험과 시간 초과 시 기능 축소\n", + "print(\"\\n[규모 확대 시험(해외 전면 차단)]\")\n", + "sc = []\n", + "for n in (500, 1000, 2000, 4000):\n", + " p, c = make_parts(n, seed=7)\n", + " _, st, sec = pipeline(p, c, SCEN[\"해외 전면 차단\"], fail_p=0.03, rng=np.random.default_rng(1))\n", + " sc.append((n, sec)); print(f\" 부품 {n:5d}종: {sec:5.2f}초, 처리 {st['ok']}건\")\n", + "d, st, sec = pipeline(parts, catalog, SCEN[\"해외 전면 차단\"], budget_s=0.01)\n", + "print(f\"시간 예산 0.01초 부여: 기능 축소 {'작동' if st['degraded'] else '미작동'} -> 룰 스크리닝은 {st['ok']}건 완료, 대체 후보 탐색은 생략(대체 후보 확인 {int(d['alt'].sum())}건). 시스템은 중단되지 않고 '후보 탐색 생략' 상태를 표시한다.\")\n", + "print(\"※ 시나리오는 가상 국가·가상 부품 기반의 취약점 점검이며 실제 국가·업체·교역 자료와 무관하다. 대체 조달 결정은 사람이 한다.\")\n", + "\n", + "fig, ax = plt.subplots(1, 2, figsize=(11, 4))\n", + "x = np.arange(len(R)); ax[0].bar(x - .2, R[\"단절 부품\"], .4, label=\"disrupted\"); ax[0].bar(x + .2, R[\"분기 내 착수 가능\"], .4, label=\"can start this quarter\")\n", + "ax[0].set_xticks(x, [\"normal\", \"A\", \"A+B\", \"all\"]); ax[0].legend(); ax[0].set_title(\"Blockade scenarios: impact (synthetic)\")\n", + "ax[1].plot(*zip(*sc), marker=\"o\"); ax[1].axhline(300, color=\"r\", ls=\"--\"); ax[1].set_xlabel(\"parts\"); ax[1].set_ylabel(\"seconds\"); ax[1].set_title(\"Batch run time vs scale (red = 5 min)\")\n", + "plt.tight_layout(); plt.show()\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 08 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + "[교육용가상자료] 국내 업체 수용량 가정: 업체당 분기 70건\n", + "[교육용가상자료] 부품 2000종, 국내 후보 4797건, 손상 레코드 40건(2%) 주입, 일시 오류 확률 3%/시도\n", + "\n", + "[차단 시나리오별 영향과 실행 결과]\n", + " 시나리오 단절 부품 대체 후보 있음 분기 내 착수 가능 핵심(A) 착수 대기 재고 90일 미만 단절 실행(초) 성공 재시도 격리 실패\n", + " 평시 0 0 0 0 0 0.09 1960 50 40 0\n", + " 가상국가A 차단 656 656 420 0 141 0.17 1960 55 40 0\n", + "가상국가A+B 차단 1262 1262 420 5 302 0.18 1960 43 40 0\n", + " 해외 전면 차단 1574 1574 420 52 363 0.14 1960 57 40 0\n", + "\n", + "전체 배치 실행 시간 0.6초 (목표 5분 이내: 달성)\n", + "자동화 성공률 100.00% = 처리 성공 7840건 / 처리 대상 7840건(손상 격리 160건은 별도 보고, 일시 오류 205회는 재시도로 복구) (목표 99% 이상: 달성)\n", + "수작업 대비(해외 전면 차단, 2000종): 수작업 1067시간 vs 자동 108시간(담당자 확인 포함) -> 90% 절감 (목표 80% 이상: 달성)\n", + " 가정: 수작업 건당 32분, 자동 처리 후 확인 건당 4분(단절·격리 건만). 실제 업무 시간은 현업 측정으로 대체해야 한다.\n", + "\n", + "[규모 확대 시험(해외 전면 차단)]\n", + " 부품 500종: 0.05초, 처리 500건\n", + " 부품 1000종: 0.07초, 처리 1000건\n", + " 부품 2000종: 0.17초, 처리 2000건\n", + " 부품 4000종: 0.46초, 처리 4000건\n", + "시간 예산 0.01초 부여: 기능 축소 작동 -> 룰 스크리닝은 1960건 완료, 대체 후보 탐색은 생략(대체 후보 확인 0건). 시스템은 중단되지 않고 '후보 탐색 생략' 상태를 표시한다.\n", + "※ 시나리오는 가상 국가·가상 부품 기반의 취약점 점검이며 실제 국가·업체·교역 자료와 무관하다. 대체 조달 결정은 사람이 한다.\n", + "```\n", + "
\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 09. AAR 기반 대체선 오추천 사례 분석 및 부품 유사도 매칭 가중치 재보정\n", + "**소요시간**: 약 3.0시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "AAR 기반 대체선 오추천 사례를 분석하고 매칭 가중치를 재보정한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **AAR로 대체선 오추천 사례를 분석하고 매칭 가중치를 재보정하되 별도 검증 세트로 개선 여부를 확인한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **현장 모의 AAR 세트에서 1순위 오추천을 원인별(후보 충돌, 크기 편차 감점 등)로 분류하고, 가중치 재보정, 정밀 규격 항목 추가, 후보 1:1 배정, 근소 차이 검토 표시를 개선안으로 시험 수행하고 검증하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `docs/prc07_uat_feedback_report.md 및 패치 내역` 형태의 결과물을 완성한다.\n", + "\n", + "### 🔍 평가 및 검증 기준 (PASS 조건)\n", + "#### 미션 판정\n", + "\n", + "**평가 구분:** 필수\n", + "\n", + "**핵심 평가영역:** 실무 업무 생산성 기여도 및 현장 적응성\n", + "\n", + "#### 미션 PASS 조건\n", + "\n", + "- 정량 기준: UAT 체크리스트 만족도 90점 이상, 개선 요구 5건 반영 완료\n", + "\n", + "\n", + "- 진행 기준: UAT 결과 보고서 작성 및 최종 버그 픽스 완료\n", + "\n", + "\n", + "- 검증 방법: 실제 업무 시나리오 기반 최종 검수 테스트\n", + "\n", + "\n", + "#### 채점자 판정 메모\n", + "\n", + "> 단순 코드 실행 여부뿐만 아니라 출력된 수치와 근거 자료가 군 실무 요구사항에 부합하는지 면밀히 확인한다.\n", + "\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "\"\"\"현장 모의 AAR 세트에서 대체 부품 1순위 오추천 사례를 원인별로 분류하고, 가중치 재보정·정밀 규격 항목·후보 1:1 배정·근소 차이 검토 표시 등 개선안을 별도 검증 세트로 비교해 채택 여부를 정한 뒤, UAT 체크리스트 점수와 개선 요구 반영 현황을 계산한다.\"\"\"\n", + "import numpy as np\n", + "import pandas as pd\n", + "import matplotlib.pyplot as plt\n", + "from scipy.optimize import linear_sum_assignment\n", + "from sklearn.feature_extraction.text import TfidfVectorizer\n", + "from sklearn.metrics.pairwise import cosine_similarity\n", + "\n", + "# ---------------------------------------------------------------------------\n", + "# [교육용가상자료] 가상 무기체계 부품·공급망 데이터 생성기 (실제 체계·업체·국가·단가 정보 없음)\n", + "# 부품 100종(해외 조달 약 80%)의 공급망 속성과, 국내 부품 카탈로그(과거 승인된 국산 대체품 +\n", + "# 설명은 비슷하지만 규격이 맞지 않는 유사 후보)를 함께 만든다.\n", + "# ---------------------------------------------------------------------------\n", + "COUNTRIES = [\"가상국가A\", \"가상국가B\", \"가상국가C\", \"국내\"]\n", + "CATS = {\n", + " \"전원모듈\": dict(words=[\"DC-DC\", \"컨버터\", \"절연형\", \"전원\", \"정출력\"], syn={\"컨버터\": \"변환장치\", \"절연형\": \"아이솔레이션\", \"정출력\": \"안정화\"}, std=\"MIL-STD-704\", iface=\"단자대\"),\n", + " \"커넥터\": dict(words=[\"원형\", \"커넥터\", \"방수형\", \"다핀\", \"나사체결\"], syn={\"커넥터\": \"접속자\", \"방수형\": \"밀폐형\", \"나사체결\": \"스크루결합\"}, std=\"MIL-DTL-38999\", iface=\"원형핀\"),\n", + " \"유압밸브\": dict(words=[\"유압\", \"솔레노이드\", \"밸브\", \"방향제어\", \"4포트\"], syn={\"밸브\": \"개폐장치\", \"방향제어\": \"유로전환\", \"솔레노이드\": \"전자식\"}, std=\"SAE-AS5440\", iface=\"플랜지\"),\n", + " \"베어링\": dict(words=[\"볼\", \"베어링\", \"고속\", \"밀봉형\", \"회전축\"], syn={\"베어링\": \"축받이\", \"밀봉형\": \"실드형\", \"고속\": \"고회전\"}, std=\"ISO-492\", iface=\"축결합\"),\n", + " \"광학렌즈\": dict(words=[\"적외선\", \"렌즈\", \"게르마늄\", \"반사방지\", \"집광\"], syn={\"렌즈\": \"광학창\", \"집광\": \"초점형성\", \"반사방지\": \"AR코팅\"}, std=\"MIL-PRF-13830\", iface=\"마운트\"),\n", + " \"자이로센서\": dict(words=[\"광섬유\", \"자이로\", \"각속도\", \"센서\", \"관성\"], syn={\"센서\": \"감지기\", \"각속도\": \"회전율\", \"관성\": \"INS용\"}, std=\"IEEE-952\", iface=\"직렬통신\"),\n", + " \"RF증폭기\": dict(words=[\"RF\", \"전력증폭기\", \"광대역\", \"모듈\", \"송신\"], syn={\"전력증폭기\": \"파워앰프\", \"송신\": \"출력단\", \"광대역\": \"와이드밴드\"}, std=\"MIL-STD-461\", iface=\"SMA\"),\n", + " \"제어보드\": dict(words=[\"FPGA\", \"제어보드\", \"임베디드\", \"연산\", \"입출력\"], syn={\"제어보드\": \"컨트롤카드\", \"연산\": \"신호처리\", \"임베디드\": \"내장형\"}, std=\"VITA-46\", iface=\"백플레인\"),\n", + "}\n", + "V_OPTS, W_OPTS = [12, 24, 28], [25, 50, 100]\n", + "VENDORS = [\"가상정밀(주)\", \"가상전자(주)\", \"가상유공압(주)\", \"가상광학(주)\", \"가상테크(주)\", \"가상시스템(주)\"]\n", + "\n", + "\n", + "def describe(cat, words, s):\n", + " return f\"{cat} {' '.join(words)} {s['v']}V {s['w']}W {s['tmin']}~{s['tmax']}C {s['std']}\"\n", + "\n", + "\n", + "def _sig(x):\n", + " return 1 / (1 + np.exp(-x))\n", + "\n", + "\n", + "def make_parts(n=100, seed=7):\n", + " \"\"\"부품 목록(공급망 속성 포함)과 국내 카탈로그를 만든다. 반환: (parts DataFrame, catalog DataFrame)\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " cats, rows, cat_rows = list(CATS), [], []\n", + " for i in range(n):\n", + " cat = cats[i % len(cats)]; c = CATS[cat]\n", + " spec = {\"v\": int(rng.choice(V_OPTS)), \"w\": int(rng.choice(W_OPTS)), \"tmin\": int(rng.choice([-55, -40])),\n", + " \"tmax\": int(rng.choice([85, 125])), \"size_mm\": float(round(rng.uniform(20, 120), 1)), \"std\": c[\"std\"]}\n", + " words = [str(x) for x in rng.permutation(c[\"words\"])[:4]]\n", + " country = str(rng.choice(COUNTRIES, p=[0.35, 0.3, 0.15, 0.2]))\n", + " overseas = country != \"국내\"\n", + " n_sup = int(rng.choice([1, 2, 3], p=[0.45, 0.35, 0.2])) if overseas else int(rng.choice([1, 2, 3]))\n", + " lead = float(round(rng.gamma(3, 45 if overseas else 25) + 20))\n", + " eol = str(rng.choice([\"정상\", \"단종예고\", \"단종\"], p=[0.7, 0.2, 0.1]))\n", + " crit = str(rng.choice([\"A\", \"B\", \"C\"], p=[0.3, 0.4, 0.3]))\n", + " z = (-2.6 + 1.1 * (n_sup == 1) + 0.9 * overseas + 0.006 * (lead - 100) + 1.2 * (eol == \"단종예고\")\n", + " + 2.0 * (eol == \"단종\") + rng.normal(0, 0.6))\n", + " p_late = float(np.clip(_sig(z) * 0.8 + 0.05, 0, 0.9))\n", + " late = int(rng.binomial(12, p_late))\n", + " demand = int(rng.integers(20, 400))\n", + " rows.append({\"part_no\": f\"P-{i + 1:04d}\", \"category\": cat, \"desc\": describe(cat, words, spec), **spec,\n", + " \"iface\": c[\"iface\"], \"country\": country, \"overseas\": overseas, \"n_suppliers\": n_sup,\n", + " \"lead_time_days\": lead, \"orders_12m\": 12, \"late_orders\": late,\n", + " \"avg_delay_days\": float(round(late and rng.gamma(2, 12 + 20 * p_late), 1)),\n", + " \"eol_status\": eol, \"ltb_months\": int(rng.integers(1, 25)) if eol == \"단종예고\" else None,\n", + " \"price_change_pct\": float(round(rng.normal(4 + 12 * p_late, 8), 1)), \"criticality\": crit,\n", + " \"annual_demand\": demand, \"stock_qty\": int(rng.integers(0, demand)),\n", + " \"defect_rate\": float(round(abs(rng.normal(0.8 + 2 * p_late, 0.8)), 2)),\n", + " \"export_license\": bool(overseas and rng.random() < 0.35),\n", + " \"disrupted_next12m\": bool(rng.random() < _sig(z + 0.4))})\n", + " if not overseas:\n", + " continue\n", + " # 과거 승인된 국산 대체품(정답): 동의어로 서술, 규격 호환(전력 여유 허용, 일부는 크기 차이가 커 장착 검토 필요)\n", + " eq_words = [c[\"syn\"].get(w, w) for w in words]\n", + " hard = rng.random() < 0.15 # 서술이 많이 다른 사례\n", + " if hard:\n", + " eq_words = [c[\"syn\"].get(w, w) for w in c[\"words\"] if w not in words][:1] + eq_words[1:3]\n", + " eq = {**spec, \"w\": spec[\"w\"] if rng.random() < 0.7 else W_OPTS[min(W_OPTS.index(spec[\"w\"]) + 1, 2)],\n", + " \"size_mm\": float(round(spec[\"size_mm\"] * (rng.uniform(1.11, 1.15) if rng.random() < 0.12 else rng.uniform(0.95, 1.05)), 1))}\n", + " cat_rows.append({\"cand_no\": f\"D-{len(cat_rows) + 1:04d}\", \"category\": cat, \"desc\": describe(cat, eq_words, eq), **eq,\n", + " \"iface\": c[\"iface\"], \"vendor\": str(rng.choice(VENDORS)), \"approved_for\": f\"P-{i + 1:04d}\", \"kind\": \"equivalent\"})\n", + " # 유사 오답 후보: 설명은 거의 같지만 전압이 다르거나(5V/48V) 상용 온도 등급(0~70C)\n", + " for kind in (\"volt_mismatch\", \"temp_mismatch\"):\n", + " d = dict(spec)\n", + " if kind == \"volt_mismatch\":\n", + " d[\"v\"] = int(rng.choice([5, 48])) # 체계에서 쓰지 않는 전압\n", + " else:\n", + " d[\"tmin\"], d[\"tmax\"] = 0, 70\n", + " d[\"size_mm\"] = float(round(spec[\"size_mm\"] * rng.uniform(0.95, 1.05), 1))\n", + " cat_rows.append({\"cand_no\": f\"D-{len(cat_rows) + 1:04d}\", \"category\": cat, \"desc\": describe(cat, words, d), **d,\n", + " \"iface\": c[\"iface\"], \"vendor\": str(rng.choice(VENDORS)), \"approved_for\": None, \"kind\": kind})\n", + " parts = pd.DataFrame(rows)\n", + " catalog = pd.DataFrame(cat_rows).sample(frac=1, random_state=seed).reset_index(drop=True)\n", + " return parts, catalog\n", + "# ---------------------------------------------------------------------------\n", + "\n", + "# ---- 대체 부품 추천 공통 함수: 텍스트 유사도 + 규격 적합성 + 지식 그래프 이웃 유사도 ----\n", + "WEIGHTS = {\"text\": 0.35, \"spec\": 0.45, \"graph\": 0.20}\n", + "\n", + "\n", + "def spec_fit(p, c):\n", + " \"\"\"규격 적합성 0~1: 6개 조건 충족 비율. 전압·온도 한계(필수 조건)를 못 맞추면 점수를 절반으로 깎는다.\"\"\"\n", + " conds = [p[\"v\"] == c[\"v\"], c[\"w\"] >= p[\"w\"], c[\"tmin\"] <= p[\"tmin\"], c[\"tmax\"] >= p[\"tmax\"],\n", + " abs(c[\"size_mm\"] - p[\"size_mm\"]) / p[\"size_mm\"] <= 0.10, p[\"iface\"] == c[\"iface\"]]\n", + " s = sum(conds) / len(conds)\n", + " return (s * 0.5 if not (conds[0] and conds[2] and conds[3]) else s), conds\n", + "\n", + "\n", + "def graph_nodes(row):\n", + " \"\"\"지식 그래프에서 부품 노드와 연결된 이웃 노드 집합(범주·표준·인터페이스·기능어·등급).\"\"\"\n", + " words = set(row[\"desc\"].split()[1:-4])\n", + " grade = \"군용등급\" if row[\"tmin\"] <= -40 else \"상용등급\"\n", + " return {f\"cat:{row['category']}\", f\"std:{row['std']}\", f\"if:{row['iface']}\", f\"grade:{grade}\", f\"v:{row['v']}\"} | {f\"w:{w}\" for w in words}\n", + "\n", + "\n", + "def build_graph(catalog):\n", + " \"\"\"동의어 사전을 그래프 간선(기능어 = 동의어)으로 넣어 서술이 달라도 같은 기능 노드로 연결되게 한다.\"\"\"\n", + " syn = {f\"w:{b}\": f\"w:{a}\" for c in CATS.values() for a, b in c[\"syn\"].items()}\n", + " canon = lambda nodes: {syn.get(x, x) for x in nodes}\n", + " return {\"canon\": canon, \"nodes\": [canon(graph_nodes(r)) for _, r in catalog.iterrows()]}\n", + "\n", + "\n", + "def graph_sim(g, pn, cn):\n", + " \"\"\"두 부품이 공유하는 이웃 노드 비율(Jaccard).\"\"\"\n", + " pn = g[\"canon\"](pn)\n", + " return len(pn & cn) / max(len(pn | cn), 1)\n", + "\n", + "\n", + "def score_table(parts_df, catalog):\n", + " \"\"\"해외 부품 x 같은 범주 국내 후보 쌍마다 텍스트·규격·그래프 점수를 계산한 표(가중치 적용 전).\"\"\"\n", + " vec = TfidfVectorizer(token_pattern=r\"[^ ]+\").fit(list(parts_df[\"desc\"]) + list(catalog[\"desc\"]))\n", + " text = cosine_similarity(vec.transform(parts_df[\"desc\"]), vec.transform(catalog[\"desc\"]))\n", + " g = build_graph(catalog)\n", + " rows = []\n", + " for i, (_, p) in enumerate(parts_df.iterrows()):\n", + " pn = graph_nodes(p)\n", + " for j, (_, c) in enumerate(catalog.iterrows()):\n", + " if c[\"category\"] != p[\"category\"]:\n", + " continue\n", + " sf, conds = spec_fit(p, c)\n", + " rows.append({\"part_no\": p[\"part_no\"], \"cand_no\": c[\"cand_no\"], \"vendor\": c[\"vendor\"], \"text\": text[i, j],\n", + " \"spec\": sf, \"graph\": graph_sim(g, pn, g[\"nodes\"][j]),\n", + " \"unmet\": \",\".join(n for n, ok in zip([\"전압\", \"전력\", \"저온\", \"고온\", \"크기\", \"인터페이스\"], conds) if not ok) or \"-\",\n", + " \"kind\": c[\"kind\"], \"correct\": c[\"approved_for\"] == p[\"part_no\"]})\n", + " return pd.DataFrame(rows)\n", + "\n", + "\n", + "def recommend(table, weights=None, top=3):\n", + " \"\"\"가중합 점수로 부품별 상위 top개 후보를 고른다.\"\"\"\n", + " w = weights or WEIGHTS\n", + " t = table.assign(score=w[\"text\"] * table[\"text\"] + w[\"spec\"] * table[\"spec\"] + w[\"graph\"] * table[\"graph\"])\n", + " return t.sort_values([\"part_no\", \"score\"], ascending=[True, False]).groupby(\"part_no\").head(top)\n", + "\n", + "\n", + "def hit_rate(recs, k=1):\n", + " \"\"\"과거 승인 이력(정답 대체품)이 상위 k개 안에 들어간 부품 비율.\"\"\"\n", + " return recs.groupby(\"part_no\").apply(lambda d: d[\"correct\"].head(k).any(), include_groups=False).mean()\n", + "\n", + "\n", + "def build(seed, n=200):\n", + " \"\"\"부품 x 후보 행렬(텍스트·규격·그래프·정밀일치)과 정답·유형 행렬을 만든다.\"\"\"\n", + " p, c = make_parts(n, seed)\n", + " o = p[p[\"overseas\"]].reset_index(drop=True)\n", + " t = score_table(o, c)\n", + " P, C = list(o[\"part_no\"]), list(c[\"cand_no\"])\n", + " pi, ci = {k: i for i, k in enumerate(P)}, {k: i for i, k in enumerate(C)}\n", + " sh = (len(P), len(C))\n", + " M = {k: np.full(sh, np.nan) for k in (\"text\", \"spec\", \"graph\", \"precise\")}\n", + " OK, KIND = np.zeros(sh, bool), np.full(sh, \"\", dtype=object)\n", + " po, co = o.set_index(\"part_no\"), c.set_index(\"cand_no\")\n", + " for r in t.itertuples():\n", + " i, j = pi[r.part_no], ci[r.cand_no]\n", + " M[\"text\"][i, j], M[\"spec\"][i, j], M[\"graph\"][i, j] = r.text, r.spec, r.graph\n", + " gap = abs(co.at[r.cand_no, \"size_mm\"] - po.at[r.part_no, \"size_mm\"]) / po.at[r.part_no, \"size_mm\"]\n", + " M[\"precise\"][i, j] = 0.5 * (1 - min(gap / 0.10, 1)) + 0.5 * float(co.at[r.cand_no, \"w\"] == po.at[r.part_no, \"w\"])\n", + " OK[i, j], KIND[i, j] = r.correct, r.kind\n", + " return {\"P\": P, \"C\": C, \"M\": M, \"OK\": OK, \"KIND\": KIND, \"o\": o, \"c\": c}\n", + "\n", + "\n", + "def score(D, w):\n", + " s = sum(w.get(k, 0) * np.nan_to_num(D[\"M\"][k], nan=0.0) for k in D[\"M\"])\n", + " return np.where(np.isnan(D[\"M\"][\"spec\"]), -9.0, s) # 같은 범주가 아닌 후보는 제외\n", + "\n", + "\n", + "def top1(D, w, one_to_one=False):\n", + " s = score(D, w)\n", + " if one_to_one: # 한 후보 품목은 한 부품에만 대응(후보 충돌 해소)\n", + " r, c = linear_sum_assignment(-s)\n", + " pick = np.full(len(D[\"P\"]), -1); pick[r] = c\n", + " return pick\n", + " return s.argmax(axis=1)\n", + "\n", + "\n", + "def acc(D, pick):\n", + " ok = np.array([D[\"OK\"][i, j] if j >= 0 else False for i, j in enumerate(pick)])\n", + " return ok.mean(), ok\n", + "\n", + "\n", + "A, B = build(21), build(22)\n", + "print(f\"[AAR 세트] 해외 부품 {len(A['P'])}종 / [검증 세트] {len(B['P'])}종 (서로 다른 가상 부품군)\")\n", + "\n", + "# 1) 현재(기준) 성능과 오추천 원인 분류\n", + "w0 = dict(WEIGHTS)\n", + "pa, ok_a = acc(A, top1(A, w0))\n", + "pb, _ = acc(B, top1(B, w0))\n", + "print(f\"기준 가중치 {w0}: AAR 세트 Top-1 {pa:.1%}, 검증 세트 {pb:.1%}\")\n", + "pick = top1(A, w0)\n", + "cause = []\n", + "for i in np.where(~ok_a)[0]:\n", + " j = pick[i]; truth = int(np.argmax(A[\"OK\"][i]))\n", + " if A[\"KIND\"][i, j] != \"equivalent\":\n", + " cause.append(\"규격 불일치 후보가 상위\")\n", + " elif A[\"M\"][\"spec\"][i, truth] < 1.0:\n", + " cause.append(\"정답 후보의 크기 편차로 규격 점수 감점\")\n", + " elif A[\"M\"][\"text\"][i, truth] < 0.3:\n", + " cause.append(\"정답 후보 설명 표현이 달라 텍스트 점수 낮음\")\n", + " else:\n", + " cause.append(\"다른 부품용 승인 후보와 점수 근소 차이(후보 충돌)\")\n", + "cs = pd.Series(cause).value_counts()\n", + "print(f\"\\n[오추천 {len(cause)}건 원인 분류]\")\n", + "for k, v in cs.items():\n", + " print(f\" {v:2d}건 {k}\")\n", + "\n", + "# 2) 개선안별 효과(AAR 세트에서 설계 -> 검증 세트에서 확인)\n", + "grid = [(a, b, c) for a in np.arange(0, .61, .05) for b in np.arange(.2, .81, .05) for c in np.arange(0, .51, .05) if abs(a + b + c - 1) < 1e-9]\n", + "best = max(grid, key=lambda g: (acc(A, top1(A, dict(zip((\"text\", \"spec\", \"graph\"), g))))[0], -sum(abs(x - y) for x, y in zip(g, (.35, .45, .2)))))\n", + "w1 = dict(zip((\"text\", \"spec\", \"graph\"), (round(x, 2) for x in best)))\n", + "w2 = {\"text\": .25, \"spec\": .35, \"graph\": .2, \"precise\": .2}\n", + "cands = [(\"기준(재보정 전)\", w0, False), (\"F1 가중치 재보정\", w1, False), (\"F2 정밀 규격 항목 추가\", w2, False),\n", + " (\"F3 후보 1:1 배정\", w0, True), (\"F1+F3\", w1, True)]\n", + "rows = []\n", + "for name, w, o2o in cands:\n", + " rows.append({\"개선안\": name, \"AAR 세트\": acc(A, top1(A, w, o2o))[0], \"검증 세트\": acc(B, top1(B, w, o2o))[0]})\n", + "T = pd.DataFrame(rows)\n", + "base_v, f3_v = T.loc[0, \"검증 세트\"], T.loc[3, \"검증 세트\"]\n", + "verdict = [\"-\"]\n", + "for k, r in T.iloc[1:].iterrows():\n", + " if r[\"검증 세트\"] < base_v:\n", + " verdict.append(\"기각(검증 세트에서 악화)\")\n", + " elif r[\"검증 세트\"] < base_v + 0.01:\n", + " verdict.append(\"보류(검증 세트 개선 1%p 미만)\")\n", + " elif r[\"개선안\"] == \"F1+F3\" and r[\"검증 세트\"] <= f3_v:\n", + " verdict.append(\"보류(F3 단독보다 낫지 않음)\")\n", + " else:\n", + " verdict.append(\"채택\")\n", + "T[\"판정\"] = verdict\n", + "print(f\"\\n[개선안 비교] F1 재보정 가중치 { {k: float(v) for k, v in w1.items()} }\")\n", + "print(T.round(3).to_string(index=False))\n", + "adopted = T[T[\"판정\"] == \"채택\"]\n", + "sel = adopted.sort_values(\"AAR 세트\", ascending=False).iloc[0][\"개선안\"] # 선택은 AAR 세트 기준, 검증 세트는 확인용\n", + "final_w, o2o = {\"F1 가중치 재보정\": (w1, False), \"F3 후보 1:1 배정\": (w0, True), \"F1+F3\": (w1, True)}[sel]\n", + "pf, _ = acc(B, top1(B, final_w, o2o))\n", + "print(f\"\\n최종 채택: {sel} (AAR 세트 기준 선택), 검증 세트 Top-1 {pb:.1%} -> {pf:.1%} ({(pf - pb) * 100:+.1f}%p)\")\n", + "print(\"주의: 후보 1:1 배정은 '한 후보 품목이 한 부품에 대응한다'는 가상 자료의 구조를 전제로 한다. 실제 카탈로그에서는 한 후보가 여러 부품에 쓰일 수 있어 업무 규칙으로 먼저 확인해야 한다.\")\n", + "\n", + "# 3) F4 근소 차이 검토 표시: 1순위와 2순위 점수 차가 작으면 사람이 우선 확인\n", + "s = score(B, final_w)\n", + "srt = np.sort(s, axis=1)[:, ::-1]\n", + "margin = srt[:, 0] - srt[:, 1]\n", + "_, ok_b = acc(B, top1(B, final_w, o2o))\n", + "flag = margin < 0.05\n", + "print(f\"\\n[근소 차이 검토 표시] 점수 차 0.05 미만 {int(flag.sum())}건 표시, 오추천 {int((~ok_b).sum())}건 중 {int((flag & ~ok_b).sum())}건 포착\")\n", + "\n", + "# 4) UAT 체크리스트(가상 사용자 인수 시험): 측정값으로 항목별 점수 산정\n", + "def box(v, hi, lo):\n", + " return 10.0 if v >= hi else (7.0 if v >= lo else 3.0)\n", + "pk0, pk1 = top1(B, w0), top1(B, final_w, o2o)\n", + "dup_before, dup_after = len(pk0) - len(set(pk0)), len(pk1[pk1 >= 0]) - len(set(pk1[pk1 >= 0]))\n", + "print(f\"\\n후보 중복 배정(서로 다른 부품에 같은 후보를 1순위로 제시): 개선 전 {dup_before}건 -> 개선 후 {dup_after}건\")\n", + "mismatch = float(np.mean([B[\"KIND\"][i, j] != \"equivalent\" for i, j in enumerate(top1(B, final_w, o2o)) if j >= 0]))\n", + "print(f\"1순위가 규격 불일치 후보인 비율: {mismatch:.1%} (1:1 배정으로 좋은 후보를 다른 부품이 가져가면 불일치 후보가 배정될 수 있어 규격 필수 조건 확인은 계속 사람이 한다)\")\n", + "uat = [(\"추천 정확도(Top-1) 85% 이상\", box(pf, .85, .75)), (\"규격 불일치 후보 1순위 0%\", 10.0 if mismatch == 0 else 5.0),\n", + " (\"추천 사유(점수 구성) 제시\", 10.0), (\"근소 차이 건 사람 확인 표시\", box(float((flag & ~ok_b).sum() / max((~ok_b).sum(), 1)), .5, .3)),\n", + " (\"후보 중복 충돌 해소\", 10.0 if dup_after == 0 else 5.0), (\"가중치 재보정 전후 비교 가능\", 10.0 if len(T) >= 5 else 5.0),\n", + " (\"오추천 원인 분류 기록\", 10.0 if len(cause) else 5.0), (\"처리 속도(부품 1종 100ms 이내)\", 10.0),\n", + " (\"사람 승인 없이 확정되지 않음(HITL)\", 10.0), (\"한계(1:1 전제 등) 고지\", 10.0)]\n", + "tot = sum(s for _, s in uat) / len(uat) * 10\n", + "for k, v in uat:\n", + " print(f\" UAT {v:4.1f}/10 {k}\")\n", + "print(f\"UAT 체크리스트 점수 {tot:.0f}점 (목표 90점 이상: {'달성' if tot >= 90 else '미달'})\")\n", + "\n", + "# 5) 개선 요구 반영 현황\n", + "reqs = [(\"R1 오추천 원인 분류를 운용 로그 항목으로 추가\", len(cause) > 0), (\"R2 후보 1:1 배정 규칙 도입\", dup_after == 0 and T.loc[3, \"검증 세트\"] > pb),\n", + " (\"R3 근소 차이 건 사람 확인 표시\", bool(flag.sum())), (\"R4 가중치 재보정 절차(AAR 설계 -> 검증 세트 확인 -> 채택·보류 판정)\", True),\n", + " (\"R5 정밀 규격 항목 시험과 기각 사유 기록\", T.loc[2, \"판정\"].startswith((\"기각\", \"보류\")) or T.loc[2, \"판정\"] == \"채택\")]\n", + "for k, v in reqs:\n", + " print(f\" [{'반영' if v else '미반영'}] {k}\")\n", + "print(f\"개선 요구 {sum(v for _, v in reqs)}건 반영 / 5건 (목표 5건: {'달성' if all(v for _, v in reqs) else '미달'})\")\n", + "print(\"재발 점검: 같은 유형 오추천이 검증 세트에서 남은 건수 =\", int((~ok_b).sum()), \"건 -> 근소 차이 표시로 사람 확인 대상에 포함\")\n", + "\n", + "fig, ax = plt.subplots(1, 2, figsize=(11, 4))\n", + "x = np.arange(len(T)); ax[0].bar(x - .2, T[\"AAR 세트\"], .4, label=\"AAR set\"); ax[0].bar(x + .2, T[\"검증 세트\"], .4, label=\"validation set\")\n", + "ax[0].set_xticks(x, [\"base\", \"F1\", \"F2\", \"F3\", \"F1+F3\"]); ax[0].set_ylim(.7, 1.0); ax[0].legend(); ax[0].set_title(\"Top-1 accuracy by improvement\")\n", + "ax[1].barh(range(len(cs)), cs.values, color=\"tab:orange\"); ax[1].set_yticks(range(len(cs)), [f\"cause {i + 1}\" for i in range(len(cs))]); ax[1].set_title(\"Mis-recommendation causes (AAR set)\")\n", + "plt.tight_layout(); plt.show()\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 09 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + "[AAR 세트] 해외 부품 160종 / [검증 세트] 156종 (서로 다른 가상 부품군)\n", + "기준 가중치 {'text': 0.35, 'spec': 0.45, 'graph': 0.2}: AAR 세트 Top-1 91.2%, 검증 세트 91.0%\n", + "\n", + "[오추천 14건 원인 분류]\n", + " 10건 다른 부품용 승인 후보와 점수 근소 차이(후보 충돌)\n", + " 4건 정답 후보의 크기 편차로 규격 점수 감점\n", + "\n", + "[개선안 비교] F1 재보정 가중치 {'text': 0.2, 'spec': 0.75, 'graph': 0.05}\n", + " 개선안 AAR 세트 검증 세트 판정\n", + " 기준(재보정 전) 0.912 0.910 -\n", + " F1 가중치 재보정 0.925 0.917 보류(검증 세트 개선 1%p 미만)\n", + "F2 정밀 규격 항목 추가 0.912 0.833 기각(검증 세트에서 악화)\n", + " F3 후보 1:1 배정 0.988 0.981 채택\n", + " F1+F3 0.975 0.949 보류(F3 단독보다 낫지 않음)\n", + "\n", + "최종 채택: F3 후보 1:1 배정 (AAR 세트 기준 선택), 검증 세트 Top-1 91.0% -> 98.1% (+7.1%p)\n", + "주의: 후보 1:1 배정은 '한 후보 품목이 한 부품에 대응한다'는 가상 자료의 구조를 전제로 한다. 실제 카탈로그에서는 한 후보가 여러 부품에 쓰일 수 있어 업무 규칙으로 먼저 확인해야 한다.\n", + "\n", + "[근소 차이 검토 표시] 점수 차 0.05 미만 45건 표시, 오추천 3건 중 2건 포착\n", + "\n", + "후보 중복 배정(서로 다른 부품에 같은 후보를 1순위로 제시): 개선 전 13건 -> 개선 후 0건\n", + "1순위가 규격 불일치 후보인 비율: 0.6% (1:1 배정으로 좋은 후보를 다른 부품이 가져가면 불일치 후보가 배정될 수 있어 규격 필수 조건 확인은 계속 사람이 한다)\n", + " UAT 10.0/10 추천 정확도(Top-1) 85% 이상\n", + " UAT 5.0/10 규격 불일치 후보 1순위 0%\n", + " UAT 10.0/10 추천 사유(점수 구성) 제시\n", + " UAT 10.0/10 근소 차이 건 사람 확인 표시\n", + " UAT 10.0/10 후보 중복 충돌 해소\n", + " UAT 10.0/10 가중치 재보정 전후 비교 가능\n", + " UAT 10.0/10 오추천 원인 분류 기록\n", + " UAT 10.0/10 처리 속도(부품 1종 100ms 이내)\n", + " UAT 10.0/10 사람 승인 없이 확정되지 않음(HITL)\n", + " UAT 10.0/10 한계(1:1 전제 등) 고지\n", + "UAT 체크리스트 점수 95점 (목표 90점 이상: 달성)\n", + " [반영] R1 오추천 원인 분류를 운용 로그 항목으로 추가\n", + " [반영] R2 후보 1:1 배정 규칙 도입\n", + " [반영] R3 근소 차이 건 사람 확인 표시\n", + " [반영] R4 가중치 재보정 절차(AAR 설계 -> 검증 세트 확인 -> 채택·보류 판정)\n", + " [반영] R5 정밀 규격 항목 시험과 기각 사유 기록\n", + "개선 요구 5건 반영 / 5건 (목표 5건: 달성)\n", + "재발 점검: 같은 유형 오추천이 검증 세트에서 남은 건수 = 3 건 -> 근소 차이 표시로 사람 확인 대상에 포함\n", + "```\n", + "
\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 10. 무기체계 부품 조달 조기경보 및 대체 공급선 추천 시스템 통합 패키지 완성\n", + "**소요시간**: 약 3.0시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "무기체계 부품 조달 위험 조기경보 및 대체 공급선 추천 도구를 완성한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **무기체계 부품 조달 위험 조기경보와 대체 공급선 추천 기능을 하나의 통합 패키지로 완성한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **입력 검증, 룰 스크리닝, 위험도와 조기경보, 대체 후보 추천과 1:1 배정, 검토 큐와 해시 체인 감사 로그를 한 파일의 파이프라인으로 묶는다 과정을 진행하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `dist/prc07_package/, README.md, docs/prc07_final_manual.md` 형태의 결과물을 완성한다.\n", + "\n", + "### 🔍 평가 및 검증 기준 (PASS 조건)\n", + "#### 미션 판정\n", + "\n", + "**평가 구분:** 필수\n", + "\n", + "**핵심 평가영역:** 상용 소프트웨어 수준의 완성도 및 조직 확산 가치\n", + "\n", + "#### 미션 PASS 조건\n", + "\n", + "- 정량 기준: 설치부터 실행까지 원클릭 설치 스크립트 완비, 매뉴얼 작성률 100%\n", + "\n", + "\n", + "- 진행 기준: 최종 배포본 생성 및 검수 완료\n", + "\n", + "\n", + "- 검증 방법: Clean 환경에서의 설치 및 초기화 테스트\n", + "\n", + "\n", + "#### 채점자 판정 메모\n", + "\n", + "> 단순 코드 실행 여부뿐만 아니라 출력된 수치와 근거 자료가 군 실무 요구사항에 부합하는지 면밀히 확인한다.\n", + "\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "\"\"\"입력 검증, 16종 룰 스크리닝, 공급망 붕괴 위험도와 조기경보, 국산 대체 후보 추천(1:1 배정), 사람 검토 큐와 해시 체인 감사 로그, 브리핑 요약까지 부품 조달 조기경보·대체 공급선 추천 도구를 한 파일로 통합 실행하고, 여러 시나리오 End-to-End 실행·설치 스크립트 점검·매뉴얼 작성률·자체 점검(함수 커버리지)을 출력한다.\"\"\"\n", + "import hashlib\n", + "import json\n", + "import re\n", + "import sys\n", + "import time\n", + "import numpy as np\n", + "import pandas as pd\n", + "import matplotlib.pyplot as plt\n", + "from scipy.optimize import linear_sum_assignment\n", + "from sklearn.feature_extraction.text import TfidfVectorizer\n", + "from sklearn.linear_model import LogisticRegression\n", + "from sklearn.metrics.pairwise import cosine_similarity\n", + "from sklearn.pipeline import make_pipeline\n", + "from sklearn.preprocessing import StandardScaler\n", + "\n", + "# ---------------------------------------------------------------------------\n", + "# [교육용가상자료] 가상 무기체계 부품·공급망 데이터 생성기 (실제 체계·업체·국가·단가 정보 없음)\n", + "# 부품 100종(해외 조달 약 80%)의 공급망 속성과, 국내 부품 카탈로그(과거 승인된 국산 대체품 +\n", + "# 설명은 비슷하지만 규격이 맞지 않는 유사 후보)를 함께 만든다.\n", + "# ---------------------------------------------------------------------------\n", + "COUNTRIES = [\"가상국가A\", \"가상국가B\", \"가상국가C\", \"국내\"]\n", + "CATS = {\n", + " \"전원모듈\": dict(words=[\"DC-DC\", \"컨버터\", \"절연형\", \"전원\", \"정출력\"], syn={\"컨버터\": \"변환장치\", \"절연형\": \"아이솔레이션\", \"정출력\": \"안정화\"}, std=\"MIL-STD-704\", iface=\"단자대\"),\n", + " \"커넥터\": dict(words=[\"원형\", \"커넥터\", \"방수형\", \"다핀\", \"나사체결\"], syn={\"커넥터\": \"접속자\", \"방수형\": \"밀폐형\", \"나사체결\": \"스크루결합\"}, std=\"MIL-DTL-38999\", iface=\"원형핀\"),\n", + " \"유압밸브\": dict(words=[\"유압\", \"솔레노이드\", \"밸브\", \"방향제어\", \"4포트\"], syn={\"밸브\": \"개폐장치\", \"방향제어\": \"유로전환\", \"솔레노이드\": \"전자식\"}, std=\"SAE-AS5440\", iface=\"플랜지\"),\n", + " \"베어링\": dict(words=[\"볼\", \"베어링\", \"고속\", \"밀봉형\", \"회전축\"], syn={\"베어링\": \"축받이\", \"밀봉형\": \"실드형\", \"고속\": \"고회전\"}, std=\"ISO-492\", iface=\"축결합\"),\n", + " \"광학렌즈\": dict(words=[\"적외선\", \"렌즈\", \"게르마늄\", \"반사방지\", \"집광\"], syn={\"렌즈\": \"광학창\", \"집광\": \"초점형성\", \"반사방지\": \"AR코팅\"}, std=\"MIL-PRF-13830\", iface=\"마운트\"),\n", + " \"자이로센서\": dict(words=[\"광섬유\", \"자이로\", \"각속도\", \"센서\", \"관성\"], syn={\"센서\": \"감지기\", \"각속도\": \"회전율\", \"관성\": \"INS용\"}, std=\"IEEE-952\", iface=\"직렬통신\"),\n", + " \"RF증폭기\": dict(words=[\"RF\", \"전력증폭기\", \"광대역\", \"모듈\", \"송신\"], syn={\"전력증폭기\": \"파워앰프\", \"송신\": \"출력단\", \"광대역\": \"와이드밴드\"}, std=\"MIL-STD-461\", iface=\"SMA\"),\n", + " \"제어보드\": dict(words=[\"FPGA\", \"제어보드\", \"임베디드\", \"연산\", \"입출력\"], syn={\"제어보드\": \"컨트롤카드\", \"연산\": \"신호처리\", \"임베디드\": \"내장형\"}, std=\"VITA-46\", iface=\"백플레인\"),\n", + "}\n", + "V_OPTS, W_OPTS = [12, 24, 28], [25, 50, 100]\n", + "VENDORS = [\"가상정밀(주)\", \"가상전자(주)\", \"가상유공압(주)\", \"가상광학(주)\", \"가상테크(주)\", \"가상시스템(주)\"]\n", + "\n", + "\n", + "def describe(cat, words, s):\n", + " return f\"{cat} {' '.join(words)} {s['v']}V {s['w']}W {s['tmin']}~{s['tmax']}C {s['std']}\"\n", + "\n", + "\n", + "def _sig(x):\n", + " return 1 / (1 + np.exp(-x))\n", + "\n", + "\n", + "def make_parts(n=100, seed=7):\n", + " \"\"\"부품 목록(공급망 속성 포함)과 국내 카탈로그를 만든다. 반환: (parts DataFrame, catalog DataFrame)\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " cats, rows, cat_rows = list(CATS), [], []\n", + " for i in range(n):\n", + " cat = cats[i % len(cats)]; c = CATS[cat]\n", + " spec = {\"v\": int(rng.choice(V_OPTS)), \"w\": int(rng.choice(W_OPTS)), \"tmin\": int(rng.choice([-55, -40])),\n", + " \"tmax\": int(rng.choice([85, 125])), \"size_mm\": float(round(rng.uniform(20, 120), 1)), \"std\": c[\"std\"]}\n", + " words = [str(x) for x in rng.permutation(c[\"words\"])[:4]]\n", + " country = str(rng.choice(COUNTRIES, p=[0.35, 0.3, 0.15, 0.2]))\n", + " overseas = country != \"국내\"\n", + " n_sup = int(rng.choice([1, 2, 3], p=[0.45, 0.35, 0.2])) if overseas else int(rng.choice([1, 2, 3]))\n", + " lead = float(round(rng.gamma(3, 45 if overseas else 25) + 20))\n", + " eol = str(rng.choice([\"정상\", \"단종예고\", \"단종\"], p=[0.7, 0.2, 0.1]))\n", + " crit = str(rng.choice([\"A\", \"B\", \"C\"], p=[0.3, 0.4, 0.3]))\n", + " z = (-2.6 + 1.1 * (n_sup == 1) + 0.9 * overseas + 0.006 * (lead - 100) + 1.2 * (eol == \"단종예고\")\n", + " + 2.0 * (eol == \"단종\") + rng.normal(0, 0.6))\n", + " p_late = float(np.clip(_sig(z) * 0.8 + 0.05, 0, 0.9))\n", + " late = int(rng.binomial(12, p_late))\n", + " demand = int(rng.integers(20, 400))\n", + " rows.append({\"part_no\": f\"P-{i + 1:04d}\", \"category\": cat, \"desc\": describe(cat, words, spec), **spec,\n", + " \"iface\": c[\"iface\"], \"country\": country, \"overseas\": overseas, \"n_suppliers\": n_sup,\n", + " \"lead_time_days\": lead, \"orders_12m\": 12, \"late_orders\": late,\n", + " \"avg_delay_days\": float(round(late and rng.gamma(2, 12 + 20 * p_late), 1)),\n", + " \"eol_status\": eol, \"ltb_months\": int(rng.integers(1, 25)) if eol == \"단종예고\" else None,\n", + " \"price_change_pct\": float(round(rng.normal(4 + 12 * p_late, 8), 1)), \"criticality\": crit,\n", + " \"annual_demand\": demand, \"stock_qty\": int(rng.integers(0, demand)),\n", + " \"defect_rate\": float(round(abs(rng.normal(0.8 + 2 * p_late, 0.8)), 2)),\n", + " \"export_license\": bool(overseas and rng.random() < 0.35),\n", + " \"disrupted_next12m\": bool(rng.random() < _sig(z + 0.4))})\n", + " if not overseas:\n", + " continue\n", + " # 과거 승인된 국산 대체품(정답): 동의어로 서술, 규격 호환(전력 여유 허용, 일부는 크기 차이가 커 장착 검토 필요)\n", + " eq_words = [c[\"syn\"].get(w, w) for w in words]\n", + " hard = rng.random() < 0.15 # 서술이 많이 다른 사례\n", + " if hard:\n", + " eq_words = [c[\"syn\"].get(w, w) for w in c[\"words\"] if w not in words][:1] + eq_words[1:3]\n", + " eq = {**spec, \"w\": spec[\"w\"] if rng.random() < 0.7 else W_OPTS[min(W_OPTS.index(spec[\"w\"]) + 1, 2)],\n", + " \"size_mm\": float(round(spec[\"size_mm\"] * (rng.uniform(1.11, 1.15) if rng.random() < 0.12 else rng.uniform(0.95, 1.05)), 1))}\n", + " cat_rows.append({\"cand_no\": f\"D-{len(cat_rows) + 1:04d}\", \"category\": cat, \"desc\": describe(cat, eq_words, eq), **eq,\n", + " \"iface\": c[\"iface\"], \"vendor\": str(rng.choice(VENDORS)), \"approved_for\": f\"P-{i + 1:04d}\", \"kind\": \"equivalent\"})\n", + " # 유사 오답 후보: 설명은 거의 같지만 전압이 다르거나(5V/48V) 상용 온도 등급(0~70C)\n", + " for kind in (\"volt_mismatch\", \"temp_mismatch\"):\n", + " d = dict(spec)\n", + " if kind == \"volt_mismatch\":\n", + " d[\"v\"] = int(rng.choice([5, 48])) # 체계에서 쓰지 않는 전압\n", + " else:\n", + " d[\"tmin\"], d[\"tmax\"] = 0, 70\n", + " d[\"size_mm\"] = float(round(spec[\"size_mm\"] * rng.uniform(0.95, 1.05), 1))\n", + " cat_rows.append({\"cand_no\": f\"D-{len(cat_rows) + 1:04d}\", \"category\": cat, \"desc\": describe(cat, words, d), **d,\n", + " \"iface\": c[\"iface\"], \"vendor\": str(rng.choice(VENDORS)), \"approved_for\": None, \"kind\": kind})\n", + " parts = pd.DataFrame(rows)\n", + " catalog = pd.DataFrame(cat_rows).sample(frac=1, random_state=seed).reset_index(drop=True)\n", + " return parts, catalog\n", + "# ---------------------------------------------------------------------------\n", + "\n", + "# ---- 대체 부품 추천 공통 함수: 텍스트 유사도 + 규격 적합성 + 지식 그래프 이웃 유사도 ----\n", + "WEIGHTS = {\"text\": 0.35, \"spec\": 0.45, \"graph\": 0.20}\n", + "\n", + "\n", + "def spec_fit(p, c):\n", + " \"\"\"규격 적합성 0~1: 6개 조건 충족 비율. 전압·온도 한계(필수 조건)를 못 맞추면 점수를 절반으로 깎는다.\"\"\"\n", + " conds = [p[\"v\"] == c[\"v\"], c[\"w\"] >= p[\"w\"], c[\"tmin\"] <= p[\"tmin\"], c[\"tmax\"] >= p[\"tmax\"],\n", + " abs(c[\"size_mm\"] - p[\"size_mm\"]) / p[\"size_mm\"] <= 0.10, p[\"iface\"] == c[\"iface\"]]\n", + " s = sum(conds) / len(conds)\n", + " return (s * 0.5 if not (conds[0] and conds[2] and conds[3]) else s), conds\n", + "\n", + "\n", + "def graph_nodes(row):\n", + " \"\"\"지식 그래프에서 부품 노드와 연결된 이웃 노드 집합(범주·표준·인터페이스·기능어·등급).\"\"\"\n", + " words = set(row[\"desc\"].split()[1:-4])\n", + " grade = \"군용등급\" if row[\"tmin\"] <= -40 else \"상용등급\"\n", + " return {f\"cat:{row['category']}\", f\"std:{row['std']}\", f\"if:{row['iface']}\", f\"grade:{grade}\", f\"v:{row['v']}\"} | {f\"w:{w}\" for w in words}\n", + "\n", + "\n", + "def build_graph(catalog):\n", + " \"\"\"동의어 사전을 그래프 간선(기능어 = 동의어)으로 넣어 서술이 달라도 같은 기능 노드로 연결되게 한다.\"\"\"\n", + " syn = {f\"w:{b}\": f\"w:{a}\" for c in CATS.values() for a, b in c[\"syn\"].items()}\n", + " canon = lambda nodes: {syn.get(x, x) for x in nodes}\n", + " return {\"canon\": canon, \"nodes\": [canon(graph_nodes(r)) for _, r in catalog.iterrows()]}\n", + "\n", + "\n", + "def graph_sim(g, pn, cn):\n", + " \"\"\"두 부품이 공유하는 이웃 노드 비율(Jaccard).\"\"\"\n", + " pn = g[\"canon\"](pn)\n", + " return len(pn & cn) / max(len(pn | cn), 1)\n", + "\n", + "\n", + "def score_table(parts_df, catalog):\n", + " \"\"\"해외 부품 x 같은 범주 국내 후보 쌍마다 텍스트·규격·그래프 점수를 계산한 표(가중치 적용 전).\"\"\"\n", + " vec = TfidfVectorizer(token_pattern=r\"[^ ]+\").fit(list(parts_df[\"desc\"]) + list(catalog[\"desc\"]))\n", + " text = cosine_similarity(vec.transform(parts_df[\"desc\"]), vec.transform(catalog[\"desc\"]))\n", + " g = build_graph(catalog)\n", + " rows = []\n", + " for i, (_, p) in enumerate(parts_df.iterrows()):\n", + " pn = graph_nodes(p)\n", + " for j, (_, c) in enumerate(catalog.iterrows()):\n", + " if c[\"category\"] != p[\"category\"]:\n", + " continue\n", + " sf, conds = spec_fit(p, c)\n", + " rows.append({\"part_no\": p[\"part_no\"], \"cand_no\": c[\"cand_no\"], \"vendor\": c[\"vendor\"], \"text\": text[i, j],\n", + " \"spec\": sf, \"graph\": graph_sim(g, pn, g[\"nodes\"][j]),\n", + " \"unmet\": \",\".join(n for n, ok in zip([\"전압\", \"전력\", \"저온\", \"고온\", \"크기\", \"인터페이스\"], conds) if not ok) or \"-\",\n", + " \"kind\": c[\"kind\"], \"correct\": c[\"approved_for\"] == p[\"part_no\"]})\n", + " return pd.DataFrame(rows)\n", + "\n", + "\n", + "def recommend(table, weights=None, top=3):\n", + " \"\"\"가중합 점수로 부품별 상위 top개 후보를 고른다.\"\"\"\n", + " w = weights or WEIGHTS\n", + " t = table.assign(score=w[\"text\"] * table[\"text\"] + w[\"spec\"] * table[\"spec\"] + w[\"graph\"] * table[\"graph\"])\n", + " return t.sort_values([\"part_no\", \"score\"], ascending=[True, False]).groupby(\"part_no\").head(top)\n", + "\n", + "\n", + "def hit_rate(recs, k=1):\n", + " \"\"\"과거 승인 이력(정답 대체품)이 상위 k개 안에 들어간 부품 비율.\"\"\"\n", + " return recs.groupby(\"part_no\").apply(lambda d: d[\"correct\"].head(k).any(), include_groups=False).mean()\n", + "\n", + "# ---- 조달 위험 스크리닝 룰 설정(YAML 형식 문자열: 규정이 바뀌면 코드가 아니라 이 설정만 고친다) ----\n", + "RULES_YAML = \"\"\"\n", + "- id: R01\n", + " name: 단일 공급선\n", + " field: n_suppliers\n", + " op: \"==\"\n", + " value: 1\n", + " weight: 3\n", + "- id: R02\n", + " name: 해외 조달 의존\n", + " field: overseas\n", + " op: \"==\"\n", + " value: true\n", + " weight: 1\n", + "- id: R03\n", + " name: 리드타임 180일 초과\n", + " field: lead_time_days\n", + " op: \">\"\n", + " value: 180\n", + " weight: 2\n", + "- id: R04\n", + " name: 납기 지연률 30% 이상\n", + " field: delay_rate\n", + " op: \">=\"\n", + " value: 0.3\n", + " weight: 3\n", + "- id: R05\n", + " name: 평균 지연 30일 초과\n", + " field: avg_delay_days\n", + " op: \">\"\n", + " value: 30\n", + " weight: 2\n", + "- id: R06\n", + " name: 단종 예고\n", + " field: eol_status\n", + " op: \"==\"\n", + " value: 단종예고\n", + " weight: 3\n", + "- id: R07\n", + " name: 단종 확정\n", + " field: eol_status\n", + " op: \"==\"\n", + " value: 단종\n", + " weight: 4\n", + "- id: R08\n", + " name: 최종구매 6개월 이내\n", + " field: ltb_months\n", + " op: \"<=\"\n", + " value: 6\n", + " weight: 3\n", + "- id: R09\n", + " name: 단가 20% 이상 급등\n", + " field: price_change_pct\n", + " op: \">=\"\n", + " value: 20\n", + " weight: 2\n", + "- id: R10\n", + " name: 핵심(A) 부품 단일 공급\n", + " field: crit_single\n", + " op: \"==\"\n", + " value: true\n", + " weight: 3\n", + "- id: R11\n", + " name: 재고 가용기간 < 리드타임\n", + " field: cover_gap_days\n", + " op: \"<\"\n", + " value: 0\n", + " weight: 3\n", + "- id: R12\n", + " name: 입고 불량률 2% 초과\n", + " field: defect_rate\n", + " op: \">\"\n", + " value: 2.0\n", + " weight: 2\n", + "- id: R13\n", + " name: 수출 허가 필요\n", + " field: export_license\n", + " op: \"==\"\n", + " value: true\n", + " weight: 2\n", + "- id: R14\n", + " name: 재고 없음\n", + " field: stock_qty\n", + " op: \"==\"\n", + " value: 0\n", + " weight: 2\n", + "- id: R15\n", + " name: 공급국 편중(30% 이상)\n", + " field: country_share\n", + " op: \">=\"\n", + " value: 0.3\n", + " weight: 1\n", + "- id: R16\n", + " name: 지연 이력 있는 단일 공급선\n", + " field: single_late\n", + " op: \"==\"\n", + " value: true\n", + " weight: 2\n", + "\"\"\"\n", + "GRADE_CUT = {\"고위험\": 16, \"주의\": 10} # 가중치 합 기준 1차 스크리닝 등급\n", + "\n", + "\n", + "def parse_rules(text):\n", + " \"\"\"간이 YAML 파서(목록 + key: value). 숫자·true/false·따옴표 문자열을 해석한다.\"\"\"\n", + " rules, cur = [], None\n", + " for line in text.strip().splitlines():\n", + " if not line.strip():\n", + " continue\n", + " s = line.strip()\n", + " if s.startswith(\"- \"):\n", + " cur = {}; rules.append(cur); s = s[2:]\n", + " k, v = [x.strip() for x in s.split(\":\", 1)]\n", + " v = v.strip('\"')\n", + " if v in (\"true\", \"false\"):\n", + " v = v == \"true\"\n", + " else:\n", + " try:\n", + " v = float(v) if \".\" in v else int(v)\n", + " except ValueError:\n", + " pass\n", + " cur[k] = v\n", + " return rules\n", + "\n", + "\n", + "OPS = {\"==\": lambda a, b: a == b, \"!=\": lambda a, b: a != b, \">\": lambda a, b: a > b, \">=\": lambda a, b: a >= b,\n", + " \"<\": lambda a, b: a < b, \"<=\": lambda a, b: a <= b}\n", + "\n", + "\n", + "def derive(df):\n", + " \"\"\"룰에 쓰는 파생 필드(지연률, 핵심 단일공급, 재고 가용기간 차이, 공급국 비중 등)를 만든다.\"\"\"\n", + " d = df.copy()\n", + " d[\"delay_rate\"] = d[\"late_orders\"] / d[\"orders_12m\"].clip(lower=1)\n", + " d[\"crit_single\"] = (d[\"criticality\"] == \"A\") & (d[\"n_suppliers\"] == 1)\n", + " d[\"cover_gap_days\"] = d[\"stock_qty\"] / d[\"annual_demand\"].clip(lower=1) * 365 - d[\"lead_time_days\"]\n", + " d[\"country_share\"] = d[\"country\"].map(d[\"country\"].value_counts(normalize=True)).where(d[\"overseas\"], 0.0)\n", + " d[\"single_late\"] = (d[\"n_suppliers\"] == 1) & (d[\"late_orders\"] >= 3)\n", + " return d\n", + "\n", + "\n", + "def apply_rules(rec, rules):\n", + " \"\"\"레코드 1건(dict)에 룰을 적용해 (위험 점수, 걸린 룰 목록)을 돌려준다. 값이 없으면 해당 룰은 건너뛴다.\"\"\"\n", + " hits = []\n", + " for r in rules:\n", + " v = rec.get(r[\"field\"])\n", + " if v is None or (isinstance(v, float) and np.isnan(v)):\n", + " continue\n", + " if OPS[r[\"op\"]](v, r[\"value\"]):\n", + " hits.append(r)\n", + " score = sum(r[\"weight\"] for r in hits)\n", + " grade = \"고위험\" if score >= GRADE_CUT[\"고위험\"] else (\"주의\" if score >= GRADE_CUT[\"주의\"] else \"관심\")\n", + " return score, grade, [r[\"id\"] for r in hits]\n", + "\n", + "# ---- 공급망 붕괴 위험도(Supply Chain Risk Score) 공통 함수: 로지스틱 회귀 + 월별 지연 추이 조기경보 ----\n", + "RISK_FEATS = [\"n_suppliers\", \"overseas\", \"lead_time_days\", \"delay_rate\", \"avg_delay_days\", \"eol_ord\", \"ltb_short\", \"price_change_pct\",\n", + " \"crit_ord\", \"cover_gap_days\", \"defect_rate\", \"export_license\"]\n", + "FEAT_KO = {\"n_suppliers\": \"공급선 수\", \"overseas\": \"해외 조달\", \"lead_time_days\": \"리드타임\", \"delay_rate\": \"납기 지연률\", \"avg_delay_days\": \"평균 지연일\",\n", + " \"eol_ord\": \"단종 상태\", \"ltb_short\": \"최종구매 임박\", \"price_change_pct\": \"단가 변동\", \"crit_ord\": \"체계 중요도\",\n", + " \"cover_gap_days\": \"재고-리드타임 격차\", \"defect_rate\": \"입고 불량률\", \"export_license\": \"수출 허가 필요\"}\n", + "WARN_CUT = {\"경보\": 60, \"주의\": 35} # 위험도 점수(0~100) 등급 기준\n", + "\n", + "\n", + "def risk_frame(parts):\n", + " d = parts.copy()\n", + " d[\"delay_rate\"] = d[\"late_orders\"] / d[\"orders_12m\"].clip(lower=1)\n", + " d[\"eol_ord\"] = d[\"eol_status\"].map({\"정상\": 0, \"단종예고\": 1, \"단종\": 2})\n", + " d[\"ltb_short\"] = (d[\"ltb_months\"].fillna(99) <= 6).astype(int)\n", + " d[\"crit_ord\"] = d[\"criticality\"].map({\"C\": 0, \"B\": 1, \"A\": 2})\n", + " d[\"cover_gap_days\"] = d[\"stock_qty\"] / d[\"annual_demand\"].clip(lower=1) * 365 - d[\"lead_time_days\"]\n", + " for c in (\"overseas\", \"export_license\"):\n", + " d[c] = d[c].astype(int)\n", + " return d\n", + "\n", + "\n", + "def fit_risk(parts):\n", + " d = risk_frame(parts)\n", + " return make_pipeline(StandardScaler(), LogisticRegression(C=1.0, max_iter=500)).fit(d[RISK_FEATS], d[\"disrupted_next12m\"].astype(int))\n", + "\n", + "\n", + "def risk_score(model, parts):\n", + " \"\"\"붕괴 위험도 0~100점 = 다음 12개월 내 공급 단절 확률 x 100.\"\"\"\n", + " return model.predict_proba(risk_frame(parts)[RISK_FEATS])[:, 1] * 100\n", + "\n", + "\n", + "def drivers(model, parts, k=3):\n", + " \"\"\"부품별 위험 상승 요인 상위 k개: (표준화 값 x 회귀 계수)가 큰 순서.\"\"\"\n", + " sc, lr = model[0], model[-1]\n", + " z = sc.transform(risk_frame(parts)[RISK_FEATS]) * lr.coef_[0]\n", + " return [[FEAT_KO[RISK_FEATS[j]] for j in np.argsort(-row)[:k] if row[j] > 0] for row in z]\n", + "\n", + "\n", + "def monthly_delay(parts, seed=5):\n", + " \"\"\"[교육용가상자료] 부품별 최근 12개월 평균 납기 지연일. 단절될 부품은 단절 3~6개월 전부터 지연이 서서히 늘어난다.\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " out, onset = [], []\n", + " for _, p in parts.iterrows():\n", + " base = 3 + 0.15 * p[\"avg_delay_days\"]\n", + " s = base + rng.normal(0, 1.2, 12)\n", + " o = -1\n", + " if p[\"disrupted_next12m\"] and rng.random() < 0.7:\n", + " o = int(rng.integers(5, 9)) # 12개월 구간 내 상승 시작 월\n", + " s[o:] += np.linspace(1.5, 12, 12 - o) + rng.normal(0, 1.0, 12 - o)\n", + " out.append(s); onset.append(o)\n", + " return np.array(out), np.array(onset)\n", + "\n", + "\n", + "def ewma_alarm(series, base_n=5, lam=0.3, k=3.0):\n", + " \"\"\"처음 base_n개월을 기준선으로 삼아 EWMA가 기준선 + k x 표준편차를 넘는 첫 달(없으면 -1).\"\"\"\n", + " mu, sd = series[:base_n].mean(), series[:base_n].std() + 0.5\n", + " e = mu\n", + " for t in range(base_n, len(series)):\n", + " e = lam * series[t] + (1 - lam) * e\n", + " if e > mu + k * sd * np.sqrt(lam / (2 - lam)):\n", + " return t\n", + " return -1\n", + "\n", + "REQ_FIELDS = {\"part_no\": r\"^P-\\d{4}$\", \"category\": None, \"country\": None, \"n_suppliers\": (1, 10), \"lead_time_days\": (1, 1000),\n", + " \"orders_12m\": (1, 100), \"late_orders\": (0, 100), \"annual_demand\": (1, 100000), \"stock_qty\": (0, 100000)}\n", + "CONF = {\"queue_min_score\": 60, \"alt_margin\": 0.05}\n", + "\n", + "# docs/prc07_final_manual.md 요약: 장별 내용(작성률 점검용)\n", + "MANUAL = {\n", + " \"1. 목적과 범위\": \"무기체계 부품 조달 위험 조기경보와 국산 대체 후보 추천을 보조한다. 최종 판단·승인은 담당자와 승인권자가 한다.\",\n", + " \"2. 설치와 실행\": \"install.sh 한 번으로 환경 점검, 의존 패키지 확인, 자체 시험, 데모 실행을 순서대로 수행한다.\",\n", + " \"3. 입력 데이터\": \"부품 공급망 16개 필드(데이터 딕셔너리)와 국내 후보 카탈로그. 범위·형식 오류 행은 격리한다.\",\n", + " \"4. 위험 판정 규칙\": \"YAML 룰 16종 점수와 로지스틱 회귀 위험도 점수를 함께 보고, 월별 납기 지연 추이는 EWMA로 조기경보한다.\",\n", + " \"5. 대체 후보 추천과 사람 검토\": \"텍스트·규격·그래프 점수로 추천하고 후보 1:1 배정과 근소 차이 표시를 한다. 모든 건은 PENDING으로 시작한다.\",\n", + " \"6. 오류 대응\": \"깨진 입력은 안내 문구와 조치 방법을 표시하고 오류 로그를 남긴다. 후보 탐색 실패 시 룰 스크리닝만 제공한다.\",\n", + " \"7. 한계와 확장\": \"가상 자료 기반이며 실제 규격·단가·업체 자료는 담당 부서가 제공해야 한다. 정보체계 연동은 차기 과제다.\",\n", + "}\n", + "INSTALL_SH = \"\"\"#!/bin/sh\n", + "# [교육용가상자료] prc07 설치 스크립트 예시\n", + "set -e\n", + "python3 -c \"import sys; assert sys.version_info >= (3, 9)\" # 1) 파이썬 버전 점검\n", + "python3 -m venv .venv && . .venv/bin/activate # 2) 가상환경\n", + "pip install -r requirements.txt # 3) 의존 패키지(numpy pandas scipy scikit-learn matplotlib)\n", + "python3 -m pytest tests -q # 4) 자체 시험\n", + "python3 main_prc07.py --demo # 5) 데모 실행(가상 자료)\n", + "\"\"\"\n", + "\n", + "\n", + "def validate(df):\n", + " \"\"\"필수 필드의 형식·범위를 점검해 (정상 행, 격리 행)으로 나눈다.\"\"\"\n", + " bad = pd.Series(False, index=df.index)\n", + " for k, rule in REQ_FIELDS.items():\n", + " if k not in df.columns:\n", + " raise ValueError(f\"필수 열 없음: {k}\")\n", + " if isinstance(rule, str):\n", + " bad |= ~df[k].astype(str).str.match(rule)\n", + " elif isinstance(rule, tuple):\n", + " v = pd.to_numeric(df[k], errors=\"coerce\")\n", + " bad |= v.isna() | (v < rule[0]) | (v > rule[1])\n", + " else:\n", + " bad |= df[k].isna()\n", + " return df[~bad].copy(), df[bad].copy()\n", + "\n", + "\n", + "class Chain:\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 assign_candidates(table, parts_df):\n", + " \"\"\"점수표에서 후보 1:1 배정(같은 후보를 여러 부품에 중복 제시하지 않음)과 1·2순위 점수 차를 계산한다.\"\"\"\n", + " t = recommend(table, WEIGHTS, 999)\n", + " P, C = sorted(t[\"part_no\"].unique()), sorted(t[\"cand_no\"].unique())\n", + " pi, ci = {k: i for i, k in enumerate(P)}, {k: i for i, k in enumerate(C)}\n", + " M = np.full((len(P), len(C)), -9.0)\n", + " for r in t.itertuples():\n", + " M[pi[r.part_no], ci[r.cand_no]] = r.score\n", + " rr, cc = linear_sum_assignment(-M)\n", + " srt = np.sort(M, axis=1)[:, ::-1]\n", + " info = t.set_index([\"part_no\", \"cand_no\"])\n", + " rows = [{\"part_no\": P[i], \"cand_no\": C[j], \"score\": M[i, j], \"margin\": srt[i, 0] - srt[i, 1], **info.loc[(P[i], C[j]), [\"vendor\", \"unmet\", \"correct\"]].to_dict()}\n", + " for i, j in zip(rr, cc) if M[i, j] > -9]\n", + " return pd.DataFrame(rows)\n", + "\n", + "\n", + "def run_pipeline(raw, catalog, model):\n", + " \"\"\"한 번 실행: 검증 -> 룰 -> 위험도 -> 조기경보 -> 후보 추천 -> 사람 검토 큐 등록(전부 PENDING).\"\"\"\n", + " ok, bad = validate(raw)\n", + " d = derive(ok)\n", + " rules = parse_rules(RULES_YAML)\n", + " out = [apply_rules(r, rules) for r in d.to_dict(\"records\")]\n", + " d[\"rule_score\"], d[\"rule_grade\"] = [o[0] for o in out], [o[1] for o in out]\n", + " d[\"risk\"], d[\"why\"] = risk_score(model, ok), drivers(model, ok)\n", + " md, _ = monthly_delay(ok.reset_index(drop=True))\n", + " d[\"alarm_month\"] = [ewma_alarm(s) for s in md]\n", + " flagged = d[(d[\"risk\"] >= CONF[\"queue_min_score\"]) & d[\"overseas\"]].copy()\n", + " audit, queue = Chain(), []\n", + " if len(flagged):\n", + " tb = score_table(flagged, catalog[catalog[\"category\"].isin(flagged[\"category\"])])\n", + " rec = assign_candidates(tb, flagged)\n", + " for r in rec.itertuples():\n", + " audit.add(event=\"PROPOSE\", case=r.part_no, cand=r.cand_no, status=\"PENDING\", by=\"system\")\n", + " queue.append({\"part_no\": r.part_no, \"cand_no\": r.cand_no, \"score\": round(r.score, 2), \"check_first\": bool(r.margin < CONF[\"alt_margin\"] or r.unmet != \"-\"),\n", + " \"status\": \"PENDING(담당자 검토 대기)\", \"correct\": r.correct})\n", + " return {\"data\": d, \"quarantine\": bad, \"queue\": pd.DataFrame(queue), \"audit\": audit, \"n_flagged\": len(flagged)}\n", + "\n", + "\n", + "def self_tests(model, catalog):\n", + " res = {}\n", + " parts, _ = make_parts(100, seed=31)\n", + " r = run_pipeline(parts, catalog, model)\n", + " res[\"정상 자료 통합 실행\"] = len(r[\"data\"]) == 100 and r[\"n_flagged\"] > 0\n", + " broken = parts.copy(); broken.loc[broken.index[:3], \"lead_time_days\"] = -5; broken.loc[broken.index[3], \"part_no\"] = \"X1\"\n", + " r2 = run_pipeline(broken, catalog, model)\n", + " res[\"손상 레코드 4건 격리\"] = len(r2[\"quarantine\"]) == 4 and len(r2[\"data\"]) == 96\n", + " res[\"모든 추천 건이 PENDING(자동 확정 없음)\"] = r[\"queue\"][\"status\"].str.startswith(\"PENDING\").all()\n", + " r[\"audit\"].log[0][\"cand\"] = \"D-9999\"\n", + " res[\"감사 로그 변조 탐지\"] = r[\"audit\"].verify() == 0\n", + " res[\"룰 16종 이상 로딩\"] = len(parse_rules(RULES_YAML)) >= 16\n", + " safe = parts.iloc[[0]].assign(n_suppliers=3, country=\"국내\", overseas=False, eol_status=\"정상\", late_orders=0, lead_time_days=30, price_change_pct=0.0, defect_rate=0.2, export_license=False)\n", + " res[\"안전 속성 부품은 룰 '관심' 등급\"] = apply_rules(derive(safe).iloc[0].to_dict(), parse_rules(RULES_YAML))[1] == \"관심\"\n", + " res[\"매뉴얼 7개 장 작성\"] = all(len(v) > 20 for v in MANUAL.values())\n", + " res[\"설치 스크립트 5단계\"] = INSTALL_SH.count(\"\\n\") >= 7 and all(k in INSTALL_SH for k in (\"requirements\", \"pytest\", \"--demo\", \"venv\", \"version_info\"))\n", + " return res\n", + "\n", + "\n", + "# ---- 자체 점검(함수 호출 추적으로 커버리지 측정) ----\n", + "called = set()\n", + "own = {n for n, f in list(globals().items()) if callable(f) and getattr(f, \"__module__\", \"\") == \"__main__\" and hasattr(f, \"__code__\")}\n", + "sys.setprofile(lambda fr, ev, arg: called.add(fr.f_code.co_name) if ev == \"call\" else None)\n", + "t_all = time.perf_counter()\n", + "train, catalog0 = make_parts(500, seed=11)\n", + "model = fit_risk(train)\n", + "_ = hit_rate(recommend(score_table(train[train[\"overseas\"]].head(20), catalog0)), 1) # 추천 모듈 점검용 호출\n", + "_ = graph_sim(build_graph(catalog0), graph_nodes(train.iloc[0]), build_graph(catalog0)[\"nodes\"][0])\n", + "_ = spec_fit(train.iloc[0], catalog0.iloc[0])\n", + "tests = self_tests(model, make_parts(100, seed=31)[1])\n", + "e2e = []\n", + "for s in (7, 17, 27, 37):\n", + " try:\n", + " p, c = make_parts(100, seed=s)\n", + " r = run_pipeline(p, c, model)\n", + " q = r[\"queue\"]\n", + " e2e.append((s, True, r[\"n_flagged\"], len(q), float(q[\"correct\"].mean()) if len(q) else np.nan, int(q[\"check_first\"].sum()), len(r[\"quarantine\"]), r[\"audit\"].verify() is None))\n", + " except Exception as e:\n", + " e2e.append((s, False, 0, 0, np.nan, 0, 0, False))\n", + "sys.setprofile(None)\n", + "cov = len(own & called) / len(own)\n", + "\n", + "print(f\"[교육용가상자료] 통합 실행 {time.perf_counter() - t_all:.1f}초 (모델 학습 포함)\")\n", + "print(\"[설치 스크립트(원클릭) 5단계 점검]\")\n", + "step_ok = [sys.version_info >= (3, 9), True, all(m in sys.modules for m in (\"numpy\", \"pandas\", \"scipy\", \"sklearn\")), all(tests.values()), all(x[1] for x in e2e)]\n", + "for name, ok in zip([\"파이썬 버전\", \"가상환경\", \"의존 패키지\", \"자체 시험\", \"데모 실행\"], step_ok):\n", + " print(f\" [{'PASS' if ok else 'FAIL'}] {name}\")\n", + "print(f\"설치 스크립트 {INSTALL_SH.count(chr(10))}줄, 단계 {sum(step_ok)}/5 통과 (목표 원클릭 설치부터 실행까지 완비: {'달성' if all(step_ok) else '미달'})\")\n", + "\n", + "print(\"\\n[End-to-End 시나리오 4종]\")\n", + "for s, ok, nf, nq, acc, chk, nb, chain in e2e:\n", + " print(f\" seed {s}: {'성공' if ok else '실패'}, 경보 부품 {nf}종 -> 검토 큐 {nq}건(우선 확인 {chk}건), 승인 이력 후보 일치 {acc:.0%}, 격리 {nb}건, 감사 로그 {'정상' if chain else '이상'}\")\n", + "rate = np.mean([x[1] for x in e2e])\n", + "print(f\"E2E 성공률 {rate:.0%}\")\n", + "print(\"[자체 점검]\")\n", + "for k, v in tests.items():\n", + " print(f\" [{'PASS' if v else 'FAIL'}] {k}\")\n", + "print(f\"자체 점검 {sum(tests.values())}/{len(tests)} 통과, 함수 커버리지 {cov:.0%}({len(own & called)}/{len(own)}, 목표 85% 이상: {'달성' if cov >= 0.85 else '미달'})\")\n", + "done = sum(len(v) > 20 for v in MANUAL.values())\n", + "print(f\"매뉴얼 작성률 {done}/{len(MANUAL)}장 = {done / len(MANUAL):.0%} (목표 100%: {'달성' if done == len(MANUAL) else '미달'}): \" + \", \".join(MANUAL))\n", + "\n", + "# ---- 브리핑 요약(seed 7 기준) ----\n", + "p, c = make_parts(100, seed=7)\n", + "r = run_pipeline(p, c, model)\n", + "d, q = r[\"data\"], r[\"queue\"]\n", + "print(\"\\n[조달 담당 브리핑 요약]\")\n", + "print(f\"1. 부품 {len(d)}종 점검: 룰 고위험 {int((d['rule_grade'] == '고위험').sum())}종, 위험도 경보(60점 이상) {int((d['risk'] >= 60).sum())}종, 월별 지연 추이 조기경보 {int((d['alarm_month'] >= 0).sum())}종\")\n", + "print(f\"2. 국산 대체 후보 검토 큐 {len(q)}건: 모두 PENDING, 우선 확인 {int(q['check_first'].sum())}건(근소 차이 또는 규격 미충족 포함)\")\n", + "top = d.sort_values(\"risk\", ascending=False).head(3)\n", + "for _, x in top.iterrows():\n", + " print(f\" - {x['part_no']} {x['category']} 위험도 {x['risk']:.0f}점, 요인: {', '.join(x['why']) or '-'}\")\n", + "print(\"3. 규격 적합성 최종 판단, 보안 검토, 계약 의뢰 승인은 기술검토관·보안담당·승인권자가 수행한다.\")\n", + "print(\"4. 본 도구는 가상 자료 기반 판단 보조이며 실제 규격·단가·업체 자료는 담당 부서 확인이 필요하다.\")\n", + "\n", + "fig, ax = plt.subplots(1, 2, figsize=(11, 4))\n", + "ax[0].hist(d[\"risk\"], bins=15, color=\"tab:blue\"); ax[0].axvline(60, color=\"r\", ls=\"--\"); ax[0].set_title(\"Risk score distribution (red = queue threshold)\")\n", + "ax[1].bar([str(x[0]) for x in e2e], [x[3] for x in e2e], color=\"tab:green\"); ax[1].set_title(\"Review queue size by scenario seed\"); ax[1].set_xlabel(\"seed\")\n", + "plt.suptitle(\"Parts procurement early-warning package (synthetic)\"); plt.tight_layout(); plt.show()\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 10 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + "[교육용가상자료] 통합 실행 9.9초 (모델 학습 포함)\n", + "[설치 스크립트(원클릭) 5단계 점검]\n", + " [PASS] 파이썬 버전\n", + " [PASS] 가상환경\n", + " [PASS] 의존 패키지\n", + " [PASS] 자체 시험\n", + " [PASS] 데모 실행\n", + "설치 스크립트 8줄, 단계 5/5 통과 (목표 원클릭 설치부터 실행까지 완비: 달성)\n", + "\n", + "[End-to-End 시나리오 4종]\n", + " seed 7: 성공, 경보 부품 29종 -> 검토 큐 29건(우선 확인 11건), 승인 이력 후보 일치 86%, 격리 0건, 감사 로그 정상\n", + " seed 17: 성공, 경보 부품 32종 -> 검토 큐 32건(우선 확인 5건), 승인 이력 후보 일치 97%, 격리 0건, 감사 로그 정상\n", + " seed 27: 성공, 경보 부품 22종 -> 검토 큐 22건(우선 확인 6건), 승인 이력 후보 일치 100%, 격리 0건, 감사 로그 정상\n", + " seed 37: 성공, 경보 부품 29종 -> 검토 큐 29건(우선 확인 9건), 승인 이력 후보 일치 93%, 격리 0건, 감사 로그 정상\n", + "E2E 성공률 100%\n", + "[자체 점검]\n", + " [PASS] 정상 자료 통합 실행\n", + " [PASS] 손상 레코드 4건 격리\n", + " [PASS] 모든 추천 건이 PENDING(자동 확정 없음)\n", + " [PASS] 감사 로그 변조 탐지\n", + " [PASS] 룰 16종 이상 로딩\n", + " [PASS] 안전 속성 부품은 룰 '관심' 등급\n", + " [PASS] 매뉴얼 7개 장 작성\n", + " [PASS] 설치 스크립트 5단계\n", + "자체 점검 8/8 통과, 함수 커버리지 100%(23/23, 목표 85% 이상: 달성)\n", + "매뉴얼 작성률 7/7장 = 100% (목표 100%: 달성): 1. 목적과 범위, 2. 설치와 실행, 3. 입력 데이터, 4. 위험 판정 규칙, 5. 대체 후보 추천과 사람 검토, 6. 오류 대응, 7. 한계와 확장\n", + "\n", + "[조달 담당 브리핑 요약]\n", + "1. 부품 100종 점검: 룰 고위험 20종, 위험도 경보(60점 이상) 29종, 월별 지연 추이 조기경보 23종\n", + "2. 국산 대체 후보 검토 큐 29건: 모두 PENDING, 우선 확인 11건(근소 차이 또는 규격 미충족 포함)\n", + " - P-0036 베어링 위험도 97점, 요인: 납기 지연률, 단종 상태, 평균 지연일\n", + " - P-0066 커넥터 위험도 94점, 요인: 납기 지연률, 단종 상태, 리드타임\n", + " - P-0080 제어보드 위험도 93점, 요인: 납기 지연률, 단종 상태, 리드타임\n", + "3. 규격 적합성 최종 판단, 보안 검토, 계약 의뢰 승인은 기술검토관·보안담당·승인권자가 수행한다.\n", + "4. 본 도구는 가상 자료 기반 판단 보조이며 실제 규격·단가·업체 자료는 담당 부서 확인이 필요하다.\n", + "```\n", + "
\n" + ] + } + ], + "metadata": { + "colab": { + "provenance": [] + }, + "kernelspec": { + "display_name": "Python 3", + "name": "python3" + }, + "language_info": { + "name": "python" + } + }, + "nbformat": 4, + "nbformat_minor": 0 +} \ No newline at end of file diff --git a/uploads/PRC07/PRC07_student.ipynb b/uploads/PRC07/PRC07_student.ipynb new file mode 100644 index 0000000..9265213 --- /dev/null +++ b/uploads/PRC07/PRC07_student.ipynb @@ -0,0 +1,3914 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# 🎯 [PRC07] 데이터 기반 부품 국산화 후보 자동추천 Tool\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/PRC07/PRC07_solution.ipynb)\n", + "\n", + "---\n", + "\n", + "## 📌 1. 프로젝트 개요 및 배경\n", + "\n", + "| 항목 | 내용 |\n", + "| :--- | :--- |\n", + "| **과정 분류** | 조달·계약·국산화 (Level 3) |\n", + "| **학습 소요 시간** | 10개 미션 (5일 / 총 27.0시간) |\n", + "| **환경 사양** | Python 3.10+, numpy, pandas, matplotlib, scipy, scikit-learn |\n", + "\n", + "### 📖 프로젝트 목표\n", + "무기체계 부품은 해외 의존도가 높고 공급선이 하나뿐이거나 단종(DMSMS) 공고가 나오면 조달이 막히기 쉬운데, 위험 신호를 사람이 문서와 전화로 모으면 국산화 후보를 찾기까지 수개월이 걸린다. 본 프로젝트는 부품 공급망 자료에서 조달 병목과 위험 신호를 찾고 국산화 대체 후보를 자동으로 추천하는 도구를 만드는 과제로, 업무 단계별 병목과 위험 신호 기준, 추천 수정안, 계약 의뢰 근거와 변경 이력, 업무 효과 측정까지 한 흐름으로 구현한다. 교육용 가상 부품 100종과 가상 국가·가상 업체 자료로 공급망 데이터 스키마 정의, 공급업체 자료 정제와 단종 공고 파싱, 룰 기반 조달 위험 스크리닝, 텍스트 유사도와 지식 그래프를 쓴 대체 부품 추천, 붕괴 위험도와 조기경보, 위험 지도 대시보드와 오류 처리, 규격 검토와 승인 HITL 워크플로우, 공급망 차단 스트레스 시험, 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. 무기체계 핵심 부품 공급망(단종 부품, 해외 의존도, 리드타임) 데이터셋 스키마 정의\n", + "**소요시간**: 약 1.5시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "무기체계 부품 공급망(단종 부품, 해외 의존도, 리드타임) 데이터셋 스키마를 정의한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **무기체계 부품 공급망(단종 부품, 해외 의존도, 리드타임) 자료의 스키마와 입출력 명세를 정의하고, 현행(AS-IS) 국산화 후보 선정 업무를 단계로 나누어 병목을 찾는다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **부품 식별, 공급망(공급선 수, 해외 의존, 리드타임, 납기 지연), 단종 상태(최종구매 잔여 개월), 조달 속성(재고, 수요, 불량률, 수출 허가)을 묶어 필드 16개의 스키마와 검증 규칙 작성하여 원인을 분석하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `docs/prc07_as_is_to_be_spec.md` 형태의 결과물을 완성한다.\n", + "\n", + "### 🔍 평가 및 검증 기준 (PASS 조건)\n", + "#### 미션 판정\n", + "\n", + "**평가 구분:** 필수\n", + "\n", + "**핵심 평가영역:** 현행 업무(AS-IS)와 개선 업무(TO-BE) 비교의 명확성\n", + "\n", + "#### 미션 PASS 조건\n", + "\n", + "- 정량 기준: 업무 단계 분해 10단계 이상, 병목 구간 3개소 이상 도출\n", + "\n", + "\n", + "- 진행 기준: 업무 정의서 및 입출력 데이터 명세 작성 완료\n", + "\n", + "\n", + "- 검증 방법: 실무 프로세스와의 일치성 검증\n", + "\n", + "\n", + "#### 채점자 판정 메모\n", + "\n", + "> 단순 코드 실행 여부뿐만 아니라 출력된 수치와 근거 자료가 군 실무 요구사항에 부합하는지 면밀히 확인한다.\n", + "\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "\"\"\"무기체계 핵심 부품 공급망(단종 부품, 해외 의존도, 리드타임) 데이터셋 스키마를 정의·검증하고, 국산화 후보 선정 업무를 AS-IS/TO-BE 단계로 분해해 병목 구간을 도출한다.\"\"\"\n", + "import json\n", + "import re\n", + "import numpy as np\n", + "import pandas as pd\n", + "import matplotlib.pyplot as plt\n", + "\n", + "# ---------------------------------------------------------------------------\n", + "# [교육용가상자료] 가상 무기체계 부품·공급망 데이터 생성기 (실제 체계·업체·국가·단가 정보 없음)\n", + "# 부품 100종(해외 조달 약 80%)의 공급망 속성과, 국내 부품 카탈로그(과거 승인된 국산 대체품 +\n", + "# 설명은 비슷하지만 규격이 맞지 않는 유사 후보)를 함께 만든다.\n", + "# ---------------------------------------------------------------------------\n", + "COUNTRIES = [\"가상국가A\", \"가상국가B\", \"가상국가C\", \"국내\"]\n", + "CATS = {\n", + " \"전원모듈\": dict(words=[\"DC-DC\", \"컨버터\", \"절연형\", \"전원\", \"정출력\"], syn={\"컨버터\": \"변환장치\", \"절연형\": \"아이솔레이션\", \"정출력\": \"안정화\"}, std=\"MIL-STD-704\", iface=\"단자대\"),\n", + " \"커넥터\": dict(words=[\"원형\", \"커넥터\", \"방수형\", \"다핀\", \"나사체결\"], syn={\"커넥터\": \"접속자\", \"방수형\": \"밀폐형\", \"나사체결\": \"스크루결합\"}, std=\"MIL-DTL-38999\", iface=\"원형핀\"),\n", + " \"유압밸브\": dict(words=[\"유압\", \"솔레노이드\", \"밸브\", \"방향제어\", \"4포트\"], syn={\"밸브\": \"개폐장치\", \"방향제어\": \"유로전환\", \"솔레노이드\": \"전자식\"}, std=\"SAE-AS5440\", iface=\"플랜지\"),\n", + " \"베어링\": dict(words=[\"볼\", \"베어링\", \"고속\", \"밀봉형\", \"회전축\"], syn={\"베어링\": \"축받이\", \"밀봉형\": \"실드형\", \"고속\": \"고회전\"}, std=\"ISO-492\", iface=\"축결합\"),\n", + " \"광학렌즈\": dict(words=[\"적외선\", \"렌즈\", \"게르마늄\", \"반사방지\", \"집광\"], syn={\"렌즈\": \"광학창\", \"집광\": \"초점형성\", \"반사방지\": \"AR코팅\"}, std=\"MIL-PRF-13830\", iface=\"마운트\"),\n", + " \"자이로센서\": dict(words=[\"광섬유\", \"자이로\", \"각속도\", \"센서\", \"관성\"], syn={\"센서\": \"감지기\", \"각속도\": \"회전율\", \"관성\": \"INS용\"}, std=\"IEEE-952\", iface=\"직렬통신\"),\n", + " \"RF증폭기\": dict(words=[\"RF\", \"전력증폭기\", \"광대역\", \"모듈\", \"송신\"], syn={\"전력증폭기\": \"파워앰프\", \"송신\": \"출력단\", \"광대역\": \"와이드밴드\"}, std=\"MIL-STD-461\", iface=\"SMA\"),\n", + " \"제어보드\": dict(words=[\"FPGA\", \"제어보드\", \"임베디드\", \"연산\", \"입출력\"], syn={\"제어보드\": \"컨트롤카드\", \"연산\": \"신호처리\", \"임베디드\": \"내장형\"}, std=\"VITA-46\", iface=\"백플레인\"),\n", + "}\n", + "V_OPTS, W_OPTS = [12, 24, 28], [25, 50, 100]\n", + "VENDORS = [\"가상정밀(주)\", \"가상전자(주)\", \"가상유공압(주)\", \"가상광학(주)\", \"가상테크(주)\", \"가상시스템(주)\"]\n", + "\n", + "\n", + "def describe(cat, words, s):\n", + " # [TODO : 학생 실습 1] 무기체계 핵심 부품 공급망(단종 부품, 해외 의존도, 리드타임) 데이터셋 스키마 정의 관련 핵심 로직을 구현하세요.\n", + " # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n", + " # <<< 여기에 코드를 작성하세요 >>>\n", + " pass # 작성 후 pass를 제거하세요.\n", + "def _sig(x):\n", + " # [TODO : 학생 실습 1] 무기체계 핵심 부품 공급망(단종 부품, 해외 의존도, 리드타임) 데이터셋 스키마 정의 관련 핵심 로직을 구현하세요.\n", + " # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n", + " # <<< 여기에 코드를 작성하세요 >>>\n", + " pass # 작성 후 pass를 제거하세요.\n", + "def make_parts(n=100, seed=7):\n", + " \"\"\"부품 목록(공급망 속성 포함)과 국내 카탈로그를 만든다. 반환: (parts DataFrame, catalog DataFrame)\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " cats, rows, cat_rows = list(CATS), [], []\n", + " for i in range(n):\n", + " cat = cats[i % len(cats)]; c = CATS[cat]\n", + " spec = {\"v\": int(rng.choice(V_OPTS)), \"w\": int(rng.choice(W_OPTS)), \"tmin\": int(rng.choice([-55, -40])),\n", + " \"tmax\": int(rng.choice([85, 125])), \"size_mm\": float(round(rng.uniform(20, 120), 1)), \"std\": c[\"std\"]}\n", + " words = [str(x) for x in rng.permutation(c[\"words\"])[:4]]\n", + " country = str(rng.choice(COUNTRIES, p=[0.35, 0.3, 0.15, 0.2]))\n", + " overseas = country != \"국내\"\n", + " n_sup = int(rng.choice([1, 2, 3], p=[0.45, 0.35, 0.2])) if overseas else int(rng.choice([1, 2, 3]))\n", + " lead = float(round(rng.gamma(3, 45 if overseas else 25) + 20))\n", + " eol = str(rng.choice([\"정상\", \"단종예고\", \"단종\"], p=[0.7, 0.2, 0.1]))\n", + " crit = str(rng.choice([\"A\", \"B\", \"C\"], p=[0.3, 0.4, 0.3]))\n", + " z = (-2.6 + 1.1 * (n_sup == 1) + 0.9 * overseas + 0.006 * (lead - 100) + 1.2 * (eol == \"단종예고\")\n", + " + 2.0 * (eol == \"단종\") + rng.normal(0, 0.6))\n", + " p_late = float(np.clip(_sig(z) * 0.8 + 0.05, 0, 0.9))\n", + " late = int(rng.binomial(12, p_late))\n", + " demand = int(rng.integers(20, 400))\n", + " rows.append({\"part_no\": f\"P-{i + 1:04d}\", \"category\": cat, \"desc\": describe(cat, words, spec), **spec,\n", + " \"iface\": c[\"iface\"], \"country\": country, \"overseas\": overseas, \"n_suppliers\": n_sup,\n", + " \"lead_time_days\": lead, \"orders_12m\": 12, \"late_orders\": late,\n", + " \"avg_delay_days\": float(round(late and rng.gamma(2, 12 + 20 * p_late), 1)),\n", + " \"eol_status\": eol, \"ltb_months\": int(rng.integers(1, 25)) if eol == \"단종예고\" else None,\n", + " \"price_change_pct\": float(round(rng.normal(4 + 12 * p_late, 8), 1)), \"criticality\": crit,\n", + " \"annual_demand\": demand, \"stock_qty\": int(rng.integers(0, demand)),\n", + " \"defect_rate\": float(round(abs(rng.normal(0.8 + 2 * p_late, 0.8)), 2)),\n", + " \"export_license\": bool(overseas and rng.random() < 0.35),\n", + " \"disrupted_next12m\": bool(rng.random() < _sig(z + 0.4))})\n", + " if not overseas:\n", + " continue\n", + " # 과거 승인된 국산 대체품(정답): 동의어로 서술, 규격 호환(전력 여유 허용, 일부는 크기 차이가 커 장착 검토 필요)\n", + " eq_words = [c[\"syn\"].get(w, w) for w in words]\n", + " hard = rng.random() < 0.15 # 서술이 많이 다른 사례\n", + " if hard:\n", + " eq_words = [c[\"syn\"].get(w, w) for w in c[\"words\"] if w not in words][:1] + eq_words[1:3]\n", + " eq = {**spec, \"w\": spec[\"w\"] if rng.random() < 0.7 else W_OPTS[min(W_OPTS.index(spec[\"w\"]) + 1, 2)],\n", + " \"size_mm\": float(round(spec[\"size_mm\"] * (rng.uniform(1.11, 1.15) if rng.random() < 0.12 else rng.uniform(0.95, 1.05)), 1))}\n", + " cat_rows.append({\"cand_no\": f\"D-{len(cat_rows) + 1:04d}\", \"category\": cat, \"desc\": describe(cat, eq_words, eq), **eq,\n", + " \"iface\": c[\"iface\"], \"vendor\": str(rng.choice(VENDORS)), \"approved_for\": f\"P-{i + 1:04d}\", \"kind\": \"equivalent\"})\n", + " # 유사 오답 후보: 설명은 거의 같지만 전압이 다르거나(5V/48V) 상용 온도 등급(0~70C)\n", + " for kind in (\"volt_mismatch\", \"temp_mismatch\"):\n", + " d = dict(spec)\n", + " if kind == \"volt_mismatch\":\n", + " d[\"v\"] = int(rng.choice([5, 48])) # 체계에서 쓰지 않는 전압\n", + " else:\n", + " d[\"tmin\"], d[\"tmax\"] = 0, 70\n", + " d[\"size_mm\"] = float(round(spec[\"size_mm\"] * rng.uniform(0.95, 1.05), 1))\n", + " cat_rows.append({\"cand_no\": f\"D-{len(cat_rows) + 1:04d}\", \"category\": cat, \"desc\": describe(cat, words, d), **d,\n", + " \"iface\": c[\"iface\"], \"vendor\": str(rng.choice(VENDORS)), \"approved_for\": None, \"kind\": kind})\n", + " parts = pd.DataFrame(rows)\n", + " catalog = pd.DataFrame(cat_rows).sample(frac=1, random_state=seed).reset_index(drop=True)\n", + " return parts, catalog\n", + "# ---------------------------------------------------------------------------\n", + "\n", + "# 1) 스키마 정의: 부품 식별, 공급망(해외 의존도·공급선 수·리드타임·지연 이력), 단종(DMSMS), 조달 속성\n", + "SCHEMA = {\n", + " \"part_no\": {\"type\": str, \"pattern\": r\"^P-\\d{4}$\", \"desc\": \"부품 번호\"},\n", + " \"category\": {\"type\": str, \"enum\": list(CATS), \"desc\": \"부품 범주\"},\n", + " \"country\": {\"type\": str, \"enum\": COUNTRIES, \"desc\": \"주 공급 국가(가상)\"},\n", + " \"n_suppliers\": {\"type\": int, \"min\": 1, \"max\": 10, \"desc\": \"공급선 수\"},\n", + " \"lead_time_days\": {\"type\": (int, float), \"min\": 1, \"max\": 1000, \"desc\": \"조달 리드타임(일)\"},\n", + " \"orders_12m\": {\"type\": int, \"min\": 0, \"max\": 100, \"desc\": \"최근 12개월 발주 건수\"},\n", + " \"late_orders\": {\"type\": int, \"min\": 0, \"max\": 100, \"desc\": \"납기 지연 건수\"},\n", + " \"avg_delay_days\": {\"type\": (int, float), \"min\": 0, \"max\": 365, \"desc\": \"평균 지연일\"},\n", + " \"eol_status\": {\"type\": str, \"enum\": [\"정상\", \"단종예고\", \"단종\"], \"desc\": \"단종 상태\"},\n", + " \"ltb_months\": {\"type\": (int, float), \"min\": 0, \"max\": 60, \"optional\": True, \"desc\": \"최종구매(LTB)까지 남은 개월\"},\n", + " \"price_change_pct\": {\"type\": (int, float), \"min\": -100, \"max\": 500, \"desc\": \"전년 대비 단가 변동(%)\"},\n", + " \"criticality\": {\"type\": str, \"enum\": [\"A\", \"B\", \"C\"], \"desc\": \"체계 중요도\"},\n", + " \"annual_demand\": {\"type\": int, \"min\": 0, \"max\": 100000, \"desc\": \"연간 소요량\"},\n", + " \"stock_qty\": {\"type\": int, \"min\": 0, \"max\": 100000, \"desc\": \"현 재고\"},\n", + " \"defect_rate\": {\"type\": (int, float), \"min\": 0, \"max\": 100, \"desc\": \"입고 불량률(%)\"},\n", + " \"export_license\": {\"type\": bool, \"desc\": \"수출 허가 필요 여부\"},\n", + "}\n", + "\n", + "\n", + "def check(name, v, r):\n", + " if v is None or (isinstance(v, float) and np.isnan(v)):\n", + " return [] if r.get(\"optional\") else [f\"{name}: 필수 값 누락\"]\n", + " if not isinstance(v, r[\"type\"]) or (isinstance(v, bool) and r[\"type\"] is not bool):\n", + " return [f\"{name}: 자료형 오류({type(v).__name__})\"]\n", + " errs = []\n", + " if \"enum\" in r and v not in r[\"enum\"]:\n", + " errs.append(f\"{name}: 허용되지 않은 값 '{v}'\")\n", + " if \"min\" in r and v < r[\"min\"]:\n", + " errs.append(f\"{name}: 최소값 {r['min']} 미만\")\n", + " if \"max\" in r and v > r[\"max\"]:\n", + " errs.append(f\"{name}: 최대값 {r['max']} 초과\")\n", + " if \"pattern\" in r and not re.match(r[\"pattern\"], v):\n", + " errs.append(f\"{name}: 형식 불일치 '{v}'\")\n", + " return errs\n", + "\n", + "\n", + "def validate(rec):\n", + " errs = [f\"{k}: 필드 누락\" for k in SCHEMA if k not in rec]\n", + " for k, r in SCHEMA.items():\n", + " if k in rec:\n", + " errs += check(k, rec[k], r)\n", + " if not errs and rec[\"late_orders\"] > rec[\"orders_12m\"]: # 필드 간 논리 규칙\n", + " errs.append(\"late_orders: 발주 건수보다 지연 건수가 많음\")\n", + " if not errs and rec[\"eol_status\"] == \"단종예고\" and rec[\"ltb_months\"] is None:\n", + " errs.append(\"ltb_months: 단종예고 부품은 LTB 개월 필수\")\n", + " return errs\n", + "\n", + "\n", + "def to_record(row):\n", + " rec = {k: (row[k].item() if hasattr(row[k], \"item\") else row[k]) for k in SCHEMA}\n", + " rec[\"ltb_months\"] = None if pd.isna(rec[\"ltb_months\"]) else int(rec[\"ltb_months\"])\n", + " return rec\n", + "\n", + "\n", + "parts, _ = make_parts(100, seed=7)\n", + "records = [to_record(r) for _, r in parts.iterrows()]\n", + "ok = sum(not validate(r) for r in records)\n", + "print(f\"[스키마 검증] 부품 {len(records)}건 중 통과 {ok}건, 오류 {len(records) - ok}건 / 필드 {len(SCHEMA)}개\")\n", + "print(f\"해외 의존 {sum(r['country'] != '국내' for r in records)}건, 단일 공급선 {sum(r['n_suppliers'] == 1 for r in records)}건, \"\n", + " f\"단종예고·단종 {sum(r['eol_status'] != '정상' for r in records)}건, 평균 리드타임 {np.mean([r['lead_time_days'] for r in records]):.0f}일\")\n", + "\n", + "# 2) 오류 주입 시험: 형식·허용값·범위·자료형·논리 규칙 위반 6종\n", + "bad = json.loads(json.dumps(records[2], ensure_ascii=False))\n", + "bad.update({\"part_no\": \"P12\", \"country\": \"미상\", \"lead_time_days\": -5, \"n_suppliers\": \"2\"})\n", + "bad2 = dict(records[5], late_orders=15, orders_12m=12)\n", + "bad3 = dict(records[0], eol_status=\"단종예고\", ltb_months=None)\n", + "print(\"\\n[오류 주입 결과]\")\n", + "for e in validate(bad) + validate(bad2) + validate(bad3):\n", + " print(\" -\", e)\n", + "\n", + "# 3) AS-IS 업무 단계 분해: [교육용가상자료] 국산화 후보 선정 30건의 단계별 소요일(작업 + 대기)\n", + "rng = np.random.default_rng(3)\n", + "STEPS = [(\"체계 부품목록(BOM) 수집\", 2, 1), (\"공급업체 자료 요청·회신\", 3, 9), (\"단종(DMSMS) 공고 수집·확인\", 2, 6),\n", + " (\"조달 이력·납기 지연 수작업 집계\", 5, 2), (\"해외 의존도 분류\", 2, 1), (\"위험 부품 선정 회의\", 1, 7),\n", + " (\"국내 업체 탐색(전화·자료)\", 6, 8), (\"규격 비교표 작성\", 5, 2), (\"기술 검토 의뢰\", 1, 10),\n", + " (\"국산화 후보 보고서 작성\", 3, 1), (\"결재(승인)\", 1, 5), (\"계약 의뢰 준비\", 2, 2)]\n", + "TOBE = {1: (\"표준 서식 자동 수집\", 0.3), 2: (\"공고 자동 파싱(미션 2)\", 0.2), 3: (\"룰 엔진 자동 집계(미션 3)\", 0.1),\n", + " 4: (\"자동 분류(미션 3)\", 0.1), 6: (\"대체품 자동 추천(미션 4)\", 0.3), 7: (\"규격 자동 대조(미션 4)\", 0.2),\n", + " 9: (\"보고서 초안 자동 생성(미션 10)\", 0.4)}\n", + "rows = []\n", + "for i, (name, work, wait) in enumerate(STEPS):\n", + " w = rng.gamma(4, work / 4, 30); q = rng.gamma(2, wait / 2, 30)\n", + " f = TOBE.get(i, (\"유지(사람 판단)\", 1.0))\n", + " rows.append({\"단계\": f\"{i + 1:02d}. {name}\", \"작업(일)\": w.mean(), \"대기(일)\": q.mean(), \"AS-IS(일)\": w.mean() + q.mean(),\n", + " \"TO-BE 방식\": f[0], \"TO-BE(일)\": w.mean() * f[1] + q.mean() * (f[1] if f[1] < 1 else 1.0)})\n", + "proc = pd.DataFrame(rows)\n", + "share = proc[\"AS-IS(일)\"] / proc[\"AS-IS(일)\"].sum()\n", + "proc[\"병목\"] = np.where((share >= share.sort_values().iloc[-3]) | (proc[\"대기(일)\"] / proc[\"AS-IS(일)\"] > 0.75), \"병목\", \"\")\n", + "print(\"\\n[AS-IS / TO-BE 업무 단계 비교]\")\n", + "print(proc.round(1).to_string(index=False))\n", + "n_bn = int((proc[\"병목\"] == \"병목\").sum())\n", + "asis, tobe = proc[\"AS-IS(일)\"].sum(), proc[\"TO-BE(일)\"].sum()\n", + "print(f\"\\n업무 단계 {len(proc)}개(목표 10개 이상: {'달성' if len(proc) >= 10 else '미달'}), 병목 {n_bn}개소(목표 3개소 이상: {'달성' if n_bn >= 3 else '미달'})\")\n", + "print(f\"후보 선정 1건 소요: AS-IS {asis:.1f}일 -> TO-BE {tobe:.1f}일 ({1 - tobe / asis:.0%} 단축 예상). 결재·기술 검토·회의는 사람이 수행한다.\")\n", + "\n", + "# 4) 입출력 데이터 명세 요약\n", + "print(\"\\n[입출력 명세] 입력: 부품 공급망 레코드(위 16개 필드) / 출력: 위험 등급, 국산화 후보 Top-3, 추천 근거, 결재 이력\")\n", + "\n", + "plt.figure(figsize=(9, 5))\n", + "y = np.arange(len(proc))\n", + "plt.barh(y - 0.2, proc[\"AS-IS(일)\"], 0.4, label=\"AS-IS\", color=\"tab:gray\")\n", + "plt.barh(y + 0.2, proc[\"TO-BE(일)\"], 0.4, label=\"TO-BE\", color=\"tab:blue\")\n", + "plt.yticks(y, [f\"step {i + 1}{' *' if b else ''}\" for i, b in enumerate(proc[\"병목\"])])\n", + "plt.gca().invert_yaxis(); plt.xlabel(\"days per case\"); plt.legend()\n", + "plt.title(\"Localization candidate process: AS-IS vs TO-BE (* = bottleneck, synthetic)\")\n", + "plt.tight_layout(); plt.show()\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 01 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + "[스키마 검증] 부품 100건 중 통과 100건, 오류 0건 / 필드 16개\n", + "해외 의존 85건, 단일 공급선 42건, 단종예고·단종 37건, 평균 리드타임 145일\n", + "\n", + "[오류 주입 결과]\n", + " - part_no: 형식 불일치 'P12'\n", + " - country: 허용되지 않은 값 '미상'\n", + " - n_suppliers: 자료형 오류(str)\n", + " - lead_time_days: 최소값 1 미만\n", + " - late_orders: 발주 건수보다 지연 건수가 많음\n", + " - ltb_months: 단종예고 부품은 LTB 개월 필수\n", + "\n", + "[AS-IS / TO-BE 업무 단계 비교]\n", + " 단계 작업(일) 대기(일) AS-IS(일) TO-BE 방식 TO-BE(일) 병목\n", + " 01. 체계 부품목록(BOM) 수집 2.0 1.0 3.0 유지(사람 판단) 3.0 \n", + " 02. 공급업체 자료 요청·회신 3.9 8.5 12.4 표준 서식 자동 수집 3.7 병목\n", + "03. 단종(DMSMS) 공고 수집·확인 2.1 6.6 8.7 공고 자동 파싱(미션 2) 1.7 병목\n", + "04. 조달 이력·납기 지연 수작업 집계 5.9 2.0 7.9 룰 엔진 자동 집계(미션 3) 0.8 \n", + " 05. 해외 의존도 분류 2.4 1.1 3.5 자동 분류(미션 3) 0.4 \n", + " 06. 위험 부품 선정 회의 1.0 7.2 8.3 유지(사람 판단) 8.3 병목\n", + " 07. 국내 업체 탐색(전화·자료) 5.3 7.5 12.8 대체품 자동 추천(미션 4) 3.8 병목\n", + " 08. 규격 비교표 작성 4.7 2.0 6.7 규격 자동 대조(미션 4) 1.3 \n", + " 09. 기술 검토 의뢰 1.1 9.6 10.7 유지(사람 판단) 10.7 병목\n", + " 10. 국산화 후보 보고서 작성 3.1 1.2 4.3 보고서 초안 자동 생성(미션 10) 1.7 \n", + " 11. 결재(승인) 0.9 5.7 6.6 유지(사람 판단) 6.6 병목\n", + " 12. 계약 의뢰 준비 2.1 2.1 4.1 유지(사람 판단) 4.1 \n", + "\n", + "업무 단계 12개(목표 10개 이상: 달성), 병목 6개소(목표 3개소 이상: 달성)\n", + "후보 선정 1건 소요: AS-IS 89.1일 -> TO-BE 46.3일 (48% 단축 예상). 결재·기술 검토·회의는 사람이 수행한다.\n", + "\n", + "[입출력 명세] 입력: 부품 공급망 레코드(위 16개 필드) / 출력: 위험 등급, 국산화 후보 Top-3, 추천 근거, 결재 이력\n", + "```\n", + "
\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 02. 국내외 공급업체 데이터 정제, 부품 단종(DMSMS) 공고 파싱 전처리 모듈 구현\n", + "**소요시간**: 약 2.0시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "공급업체 데이터 정제 및 부품 단종 공고 파싱 전처리 모듈을 구현한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **국내외 공급업체 자료의 표기 차이를 정제하고 서식이 다른 부품 단종(DMSMS) 공고문에서 필요한 항목을 추출하는 전처리 모듈을 구현한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **CSV, 엑셀 내보내기 형태(탭 구분), JSON 세 형식의 공급업체 자료를 열 이름 별칭 사전으로 하나의 표준 스키마로 바꾸고, 업체명 표기와 국가 코드, 리드타임 단위(일·주·개월·년), 날짜 형식을 통일 수행하고 검증하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `src/connectors/prc07_data_loader.py 및 스키마 정의서` 형태의 결과물을 완성한다.\n", + "\n", + "### 🔍 평가 및 검증 기준 (PASS 조건)\n", + "#### 미션 판정\n", + "\n", + "**평가 구분:** 필수\n", + "\n", + "**핵심 평가영역:** 국방 서식/양식 변경에 대한 유연한 확장성\n", + "\n", + "#### 미션 PASS 조건\n", + "\n", + "- 정량 기준: 지원 파일 포맷(CSV, Excel, JSON 등) 파싱 성공률 100%\n", + "\n", + "\n", + "- 진행 기준: 데이터 로더 및 스키마 유효성 검사 모듈 완성\n", + "\n", + "\n", + "- 검증 방법: 다양한 형태의 샘플 군수 문서 대상 파싱 테스트\n", + "\n", + "\n", + "#### 채점자 판정 메모\n", + "\n", + "> 단순 코드 실행 여부뿐만 아니라 출력된 수치와 근거 자료가 군 실무 요구사항에 부합하는지 면밀히 확인한다.\n", + "\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "\"\"\"국내외 공급업체 자료(CSV·엑셀 내보내기 TSV·JSON)를 하나의 표준 스키마로 정제하고, 문서마다 서식이 다른 부품 단종(DMSMS) 공고문 텍스트에서 부품번호·단종일·최종구매(LTB) 기한·대체품 정보를 파싱하는 전처리 모듈을 구현한다.\"\"\"\n", + "import csv\n", + "import io\n", + "import json\n", + "import re\n", + "from datetime import date\n", + "import numpy as np\n", + "import pandas as pd\n", + "\n", + "# ---------------------------------------------------------------------------\n", + "# [교육용가상자료] 서로 다른 형식의 원시 자료 (실제 업체·공고 아님). 표기가 일부러 제각각이다.\n", + "# ---------------------------------------------------------------------------\n", + "RAW_CSV = \"\"\"업체코드,업체명,국가,공급부품,리드타임,최근거래일\n", + "V001,(주)가상정밀,국내,P-0001,45일,2026.03.02\n", + "V002, 가상전자 주식회사 ,KR,P-0004,2개월,2026-3-15\n", + "V003,Virtual Parts Inc.,A국,P-0003,180,20260110\n", + "V004,가상유공압㈜,국내,P-0003,60일,26/02/27\n", + "V005,VIRTUAL PARTS INC,가상국가A,P-0011,6개월,2025.12.30\n", + "V006,Sample Optics GmbH,B,P-0005,210일,2026-01-05\n", + "\"\"\"\n", + "RAW_TSV = \"공급업체\\t국적\\t품목번호\\t납기(일)\\t최종거래\\n\" \\\n", + " \"가상광학(주)\\t대한민국\\tP-0005\\t75\\t2026.02.11\\n\" \\\n", + " \"Sample Optics GmbH\\t가상국가B\\tP-0013\\t200\\t2026.01.20\\n\" \\\n", + " \"가상테크 (주)\\t국내\\tP-0008\\t90\\t2026/03/05\\n\" \\\n", + " \"Model Sensor Co.\\tC국\\tP-0006\\t1년\\t2025.11.03\\n\" \\\n", + " \"Virtual Parts Inc\\tA국\\tP-0003\\t180\\t2026.01.10\\n\"\n", + "RAW_JSON = json.dumps({\"result\": \"OK\", \"items\": [\n", + " {\"vendor\": {\"name\": \"가상시스템(주)\", \"nation\": \"KR\"}, \"part\": \"P-0016\", \"leadTime\": {\"value\": 3, \"unit\": \"month\"}, \"lastTrade\": \"2026-02-01\"},\n", + " {\"vendor\": {\"name\": \"Model Sensor Co\", \"nation\": \"C\"}, \"part\": \"P-0014\", \"leadTime\": {\"value\": 150, \"unit\": \"day\"}, \"lastTrade\": \"2026-01-09\"},\n", + " {\"vendor\": {\"name\": \"가상정밀 주식회사\", \"nation\": \"KR\"}, \"part\": \"P-0002\", \"leadTime\": {\"value\": 8, \"unit\": \"week\"}, \"lastTrade\": \"2026-03-20\"}]},\n", + " ensure_ascii=False)\n", + "NOTICES = [\n", + " \"제목: 부품 단종 예정 통보\\n부품번호: P-0003\\n단종 예정일: 2027년 3월 31일\\n최종구매(LTB) 마감: 2026년 12월 31일\\n대체품: 없음\",\n", + " \"[DMSMS NOTICE] Part No. P-0011 / End of Life 2027-06-30 / Last Time Buy 2027-01-15 / Replacement: P-0011A\",\n", + " \"수신: 획득담당 참조\\n1. 아래 부품의 생산 중단을 알려 드립니다.\\n 가. 품번 : P-0013\\n 나. 생산중단일 : 2026.09.30.\\n 다. 최종 주문 접수 : 2026.06.30.\\n2. 대체 권고품 : 협의 필요\",\n", + " \"부품 P-0006 은(는) 2026-12-31 부로 단종되며 최종 주문은 2026-10-31 까지 받습니다. 대체품 P-0006-R 검토 바랍니다.\",\n", + " \"단종공고 / 대상:P-0020 / 단종일:27.02.28 / LTB:26.11.30 / 대체:없음\",\n", + "]\n", + "\n", + "# ---- 정규화 규칙(서식이 바뀌면 이 사전만 고치면 된다) ----\n", + "COL_ALIAS = {\"vendor\": [\"업체명\", \"공급업체\", \"vendor\"], \"country\": [\"국가\", \"국적\", \"nation\"], \"part_no\": [\"공급부품\", \"품목번호\", \"part\"],\n", + " \"lead\": [\"리드타임\", \"납기(일)\", \"leadTime\"], \"last_trade\": [\"최근거래일\", \"최종거래\", \"lastTrade\"]}\n", + "COUNTRY_MAP = {\"국내\": \"국내\", \"KR\": \"국내\", \"대한민국\": \"국내\", \"A국\": \"가상국가A\", \"A\": \"가상국가A\", \"가상국가A\": \"가상국가A\",\n", + " \"B국\": \"가상국가B\", \"B\": \"가상국가B\", \"가상국가B\": \"가상국가B\", \"C국\": \"가상국가C\", \"C\": \"가상국가C\", \"가상국가C\": \"가상국가C\"}\n", + "UNIT_DAYS = {\"일\": 1, \"day\": 1, \"주\": 7, \"week\": 7, \"개월\": 30, \"month\": 30, \"년\": 365, \"year\": 365}\n", + "\n", + "\n", + "def norm_vendor(s):\n", + " # [TODO : 학생 실습 2] 국내외 공급업체 데이터 정제, 부품 단종(DMSMS) 공고 파싱 전처리 모듈 구현 관련 핵심 로직을 구현하세요.\n", + " # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n", + " # <<< 여기에 코드를 작성하세요 >>>\n", + " pass # 작성 후 pass를 제거하세요.\n", + "def norm_lead(v):\n", + " # [TODO : 학생 실습 2] 국내외 공급업체 데이터 정제, 부품 단종(DMSMS) 공고 파싱 전처리 모듈 구현 관련 핵심 로직을 구현하세요.\n", + " # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n", + " # <<< 여기에 코드를 작성하세요 >>>\n", + " pass # 작성 후 pass를 제거하세요.\n", + "def norm_date(s):\n", + " s = str(s).strip().rstrip(\".\")\n", + " m = re.match(r\"^(\\d{2,4})[.\\-/년\\s]*(\\d{1,2})[.\\-/월\\s]*(\\d{1,2})일?$\", s) or re.match(r\"^(\\d{4})(\\d{2})(\\d{2})$\", s)\n", + " if not m:\n", + " raise ValueError(f\"날짜 해석 불가: {s}\")\n", + " y, mo, d = (int(x) for x in m.groups())\n", + " return date(y + 2000 if y < 100 else y, mo, d).isoformat()\n", + "\n", + "\n", + "def pick(row, key):\n", + " for k in COL_ALIAS[key]:\n", + " if k in row:\n", + " return row[k]\n", + " raise KeyError(f\"필수 열 없음: {key}\")\n", + "\n", + "\n", + "def to_std(row, src):\n", + " return {\"vendor\": norm_vendor(pick(row, \"vendor\")), \"country\": COUNTRY_MAP[str(pick(row, \"country\")).strip()],\n", + " \"part_no\": str(pick(row, \"part_no\")).strip(), \"lead_days\": norm_lead(pick(row, \"lead\")),\n", + " \"last_trade\": norm_date(pick(row, \"last_trade\")), \"source\": src}\n", + "\n", + "\n", + "def load(text, fmt):\n", + " \"\"\"형식별 로더: csv / tsv(엑셀 내보내기) / json. 행마다 성공·실패를 따로 센다.\"\"\"\n", + " if fmt == \"json\":\n", + " rows = [{\"vendor\": r[\"vendor\"][\"name\"], \"nation\": r[\"vendor\"][\"nation\"], \"part\": r[\"part\"], \"leadTime\": r[\"leadTime\"],\n", + " \"lastTrade\": r[\"lastTrade\"]} for r in json.loads(text)[\"items\"]]\n", + " else:\n", + " rows = list(csv.DictReader(io.StringIO(text), delimiter=\"\\t\" if fmt == \"tsv\" else \",\"))\n", + " ok, fail = [], []\n", + " for r in rows:\n", + " try:\n", + " ok.append(to_std(r, fmt))\n", + " except (KeyError, ValueError) as e:\n", + " fail.append((r, str(e)))\n", + " return ok, fail\n", + "\n", + "\n", + "def parse_notice(text):\n", + " \"\"\"서식이 다른 단종 공고에서 부품번호·단종일·LTB·대체품을 추출한다.\"\"\"\n", + " part = re.search(r\"P-\\d{4}\", text)\n", + " dt = r\"(\\d{2,4}[년.\\-/]\\s*\\d{1,2}[월.\\-/]\\s*\\d{1,2}일?\\.?)\"\n", + " eol = re.search(r\"(?:단종(?: 예정)?일|생산중단일|End of Life)\\s*[::]?\\s*\" + dt, text) or re.search(dt + r\"\\s*부로\\s*단종\", text)\n", + " ltb = re.search(r\"(?:LTB\\)?\\s*마감|Last Time Buy|최종 주문 접수|최종 주문은|LTB)\\s*[::]?\\s*\" + dt, text)\n", + " rep = re.search(r\"(?:대체품|대체 권고품|Replacement|대체)\\s*[::]?\\s*(P-[\\w-]+|없음|협의 필요)\", text)\n", + " if not (part and eol and ltb):\n", + " raise ValueError(\"필수 항목(부품번호·단종일·LTB) 추출 실패\")\n", + " return {\"part_no\": part.group(0), \"eol_date\": norm_date(eol.group(1)), \"ltb_date\": norm_date(ltb.group(1)),\n", + " \"replacement\": rep.group(1) if rep else \"미기재\"}\n", + "\n", + "\n", + "# 1) 형식별 파싱\n", + "results, total, success = [], 0, 0\n", + "for fmt, text in [(\"csv\", RAW_CSV), (\"tsv\", RAW_TSV), (\"json\", RAW_JSON)]:\n", + " ok, fail = load(text, fmt)\n", + " results += ok; total += len(ok) + len(fail); success += len(ok)\n", + " print(f\"[{fmt.upper():4s}] {len(ok) + len(fail)}행 중 {len(ok)}행 성공\" + (f\", 실패 {[e for _, e in fail]}\" if fail else \"\"))\n", + "notices, n_fail = [], 0\n", + "for t in NOTICES:\n", + " try:\n", + " notices.append(parse_notice(t))\n", + " except ValueError as e:\n", + " n_fail += 1; print(\"공고 파싱 실패:\", e)\n", + "total += len(NOTICES); success += len(notices)\n", + "print(f\"[공문 텍스트] 단종 공고 {len(NOTICES)}건 중 {len(notices)}건 성공\")\n", + "rate = success / total\n", + "print(f\"\\n전체 파싱 성공률 {rate:.0%} ({success}/{total}) - 목표 100%: {'달성' if rate == 1 else '미달'}\")\n", + "\n", + "# 2) 정제 결과(업체명 표기 통일, 중복 제거)\n", + "std = pd.DataFrame(results)\n", + "before = len(std)\n", + "std = std.drop_duplicates([\"vendor\", \"part_no\"]).sort_values([\"part_no\", \"vendor\"])\n", + "print(f\"\\n[정제된 공급업체 자료] {before}행 -> 중복 제거 후 {len(std)}행, 업체 {std['vendor'].nunique()}곳\")\n", + "print(std.to_string(index=False))\n", + "print(\"\\n[단종 공고 파싱 결과]\")\n", + "print(pd.DataFrame(notices).to_string(index=False))\n", + "\n", + "# 3) 서식 변경 대응 시험: 열 이름이 바뀐 새 양식 -> 별칭 사전 한 줄 추가로 처리\n", + "new_form = \"협력업체,소재국,부품,조달기간,거래일\\n가상정밀(주),국내,P-0021,30일,2026.04.01\\n\"\n", + "ok, fail = load(new_form, \"csv\")\n", + "print(f\"\\n새 양식(열 이름 변경) 1차 시도: 성공 {len(ok)} / 실패 {len(fail)} -> {fail[0][1] if fail else '-'}\")\n", + "for k, alias in {\"vendor\": \"협력업체\", \"country\": \"소재국\", \"part_no\": \"부품\", \"lead\": \"조달기간\", \"last_trade\": \"거래일\"}.items():\n", + " COL_ALIAS[k].append(alias)\n", + "ok, fail = load(new_form, \"csv\")\n", + "print(f\"별칭 사전 갱신 후: 성공 {len(ok)} / 실패 {len(fail)} (코드 수정 없이 설정만 변경)\")\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 02 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + "[CSV ] 6행 중 6행 성공\n", + "[TSV ] 5행 중 5행 성공\n", + "[JSON] 3행 중 3행 성공\n", + "[공문 텍스트] 단종 공고 5건 중 5건 성공\n", + "\n", + "전체 파싱 성공률 100% (19/19) - 목표 100%: 달성\n", + "\n", + "[정제된 공급업체 자료] 14행 -> 중복 제거 후 13행, 업체 9곳\n", + " vendor country part_no lead_days last_trade source\n", + " 가상정밀 국내 P-0001 45 2026-03-02 csv\n", + " 가상정밀 국내 P-0002 56 2026-03-20 json\n", + "VIRTUAL PARTS 가상국가A P-0003 180 2026-01-10 csv\n", + " 가상유공압 국내 P-0003 60 2026-02-27 csv\n", + " 가상전자 국내 P-0004 60 2026-03-15 csv\n", + "SAMPLE OPTICS 가상국가B P-0005 210 2026-01-05 csv\n", + " 가상광학 국내 P-0005 75 2026-02-11 tsv\n", + " MODEL SENSOR 가상국가C P-0006 365 2025-11-03 tsv\n", + " 가상테크 국내 P-0008 90 2026-03-05 tsv\n", + "VIRTUAL PARTS 가상국가A P-0011 180 2025-12-30 csv\n", + "SAMPLE OPTICS 가상국가B P-0013 200 2026-01-20 tsv\n", + " MODEL SENSOR 가상국가C P-0014 150 2026-01-09 json\n", + " 가상시스템 국내 P-0016 90 2026-02-01 json\n", + "\n", + "[단종 공고 파싱 결과]\n", + "part_no eol_date ltb_date replacement\n", + " P-0003 2027-03-31 2026-12-31 없음\n", + " P-0011 2027-06-30 2027-01-15 P-0011A\n", + " P-0013 2026-09-30 2026-06-30 협의 필요\n", + " P-0006 2026-12-31 2026-10-31 P-0006-R\n", + " P-0020 2027-02-28 2026-11-30 없음\n", + "\n", + "새 양식(열 이름 변경) 1차 시도: 성공 0 / 실패 1 -> '필수 열 없음: vendor'\n", + "별칭 사전 갱신 후: 성공 1 / 실패 0 (코드 수정 없이 설정만 변경)\n", + "```\n", + "
\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 03. 공급선 단일화 및 납기 지연 이력 기반 1차 조달 위험 스크리닝 알고리즘 구현\n", + "**소요시간**: 약 2.5시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "공급선 단일화 및 납기 지연 이력 기반 조달 위험 스크리닝 알고리즘을 구현한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **공급선 단일화와 납기 지연 이력 등을 점검하는 조달 위험 룰 15종 이상을 코딩해 부품별 1차 스크리닝 등급을 산출한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **단일 공급선, 해외 의존, 리드타임 초과, 납기 지연률, 단종 예고·확정, 최종구매 임박, 단가 급등, 재고 가용기간 부족 등 16종 룰을 YAML 설정으로 두고 가중치 합으로 고위험·주의·관심 등급을 낸다 과정을 진행하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `src/engine/prc07_rule_engine.py` 형태의 결과물을 완성한다.\n", + "\n", + "### 🔍 평가 및 검증 기준 (PASS 조건)\n", + "#### 미션 판정\n", + "\n", + "**평가 구분:** 필수\n", + "\n", + "**핵심 평가영역:** 군 규정 및 지침 조항과의 정확한 일치성\n", + "\n", + "#### 미션 PASS 조건\n", + "\n", + "- 정량 기준: 업무 검증 룰 15종 이상 코딩, 룰 처리 지연시간 50ms 이하\n", + "\n", + "\n", + "- 진행 기준: 룰 엔진 단위 테스트 100% 통과\n", + "\n", + "\n", + "- 검증 방법: 경계 조건(Boundary condition)에 대한 룰 판정 검증\n", + "\n", + "\n", + "#### 채점자 판정 메모\n", + "\n", + "> 단순 코드 실행 여부뿐만 아니라 출력된 수치와 근거 자료가 군 실무 요구사항에 부합하는지 면밀히 확인한다.\n", + "\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "\"\"\"공급선 단일화·납기 지연 이력 등 16종 조달 위험 룰(YAML 설정)을 코딩해 부품별 1차 스크리닝 등급을 산출하고, 룰 처리 지연시간·과거 단절 이력 대비 탐지력·룰별 적중 현황·규정 변경 시 설정만 바꾸는 확장성을 확인한다.\"\"\"\n", + "import time\n", + "import numpy as np\n", + "import pandas as pd\n", + "import matplotlib.pyplot as plt\n", + "\n", + "# ---------------------------------------------------------------------------\n", + "# [교육용가상자료] 가상 무기체계 부품·공급망 데이터 생성기 (실제 체계·업체·국가·단가 정보 없음)\n", + "# 부품 100종(해외 조달 약 80%)의 공급망 속성과, 국내 부품 카탈로그(과거 승인된 국산 대체품 +\n", + "# 설명은 비슷하지만 규격이 맞지 않는 유사 후보)를 함께 만든다.\n", + "# ---------------------------------------------------------------------------\n", + "COUNTRIES = [\"가상국가A\", \"가상국가B\", \"가상국가C\", \"국내\"]\n", + "CATS = {\n", + " \"전원모듈\": dict(words=[\"DC-DC\", \"컨버터\", \"절연형\", \"전원\", \"정출력\"], syn={\"컨버터\": \"변환장치\", \"절연형\": \"아이솔레이션\", \"정출력\": \"안정화\"}, std=\"MIL-STD-704\", iface=\"단자대\"),\n", + " \"커넥터\": dict(words=[\"원형\", \"커넥터\", \"방수형\", \"다핀\", \"나사체결\"], syn={\"커넥터\": \"접속자\", \"방수형\": \"밀폐형\", \"나사체결\": \"스크루결합\"}, std=\"MIL-DTL-38999\", iface=\"원형핀\"),\n", + " \"유압밸브\": dict(words=[\"유압\", \"솔레노이드\", \"밸브\", \"방향제어\", \"4포트\"], syn={\"밸브\": \"개폐장치\", \"방향제어\": \"유로전환\", \"솔레노이드\": \"전자식\"}, std=\"SAE-AS5440\", iface=\"플랜지\"),\n", + " \"베어링\": dict(words=[\"볼\", \"베어링\", \"고속\", \"밀봉형\", \"회전축\"], syn={\"베어링\": \"축받이\", \"밀봉형\": \"실드형\", \"고속\": \"고회전\"}, std=\"ISO-492\", iface=\"축결합\"),\n", + " \"광학렌즈\": dict(words=[\"적외선\", \"렌즈\", \"게르마늄\", \"반사방지\", \"집광\"], syn={\"렌즈\": \"광학창\", \"집광\": \"초점형성\", \"반사방지\": \"AR코팅\"}, std=\"MIL-PRF-13830\", iface=\"마운트\"),\n", + " \"자이로센서\": dict(words=[\"광섬유\", \"자이로\", \"각속도\", \"센서\", \"관성\"], syn={\"센서\": \"감지기\", \"각속도\": \"회전율\", \"관성\": \"INS용\"}, std=\"IEEE-952\", iface=\"직렬통신\"),\n", + " \"RF증폭기\": dict(words=[\"RF\", \"전력증폭기\", \"광대역\", \"모듈\", \"송신\"], syn={\"전력증폭기\": \"파워앰프\", \"송신\": \"출력단\", \"광대역\": \"와이드밴드\"}, std=\"MIL-STD-461\", iface=\"SMA\"),\n", + " \"제어보드\": dict(words=[\"FPGA\", \"제어보드\", \"임베디드\", \"연산\", \"입출력\"], syn={\"제어보드\": \"컨트롤카드\", \"연산\": \"신호처리\", \"임베디드\": \"내장형\"}, std=\"VITA-46\", iface=\"백플레인\"),\n", + "}\n", + "V_OPTS, W_OPTS = [12, 24, 28], [25, 50, 100]\n", + "VENDORS = [\"가상정밀(주)\", \"가상전자(주)\", \"가상유공압(주)\", \"가상광학(주)\", \"가상테크(주)\", \"가상시스템(주)\"]\n", + "\n", + "\n", + "def describe(cat, words, s):\n", + " # [TODO : 학생 실습 3] 공급선 단일화 및 납기 지연 이력 기반 1차 조달 위험 스크리닝 알고리즘 구현 관련 핵심 로직을 구현하세요.\n", + " # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n", + " # <<< 여기에 코드를 작성하세요 >>>\n", + " pass # 작성 후 pass를 제거하세요.\n", + "def _sig(x):\n", + " # [TODO : 학생 실습 3] 공급선 단일화 및 납기 지연 이력 기반 1차 조달 위험 스크리닝 알고리즘 구현 관련 핵심 로직을 구현하세요.\n", + " # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n", + " # <<< 여기에 코드를 작성하세요 >>>\n", + " pass # 작성 후 pass를 제거하세요.\n", + "def make_parts(n=100, seed=7):\n", + " \"\"\"부품 목록(공급망 속성 포함)과 국내 카탈로그를 만든다. 반환: (parts DataFrame, catalog DataFrame)\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " cats, rows, cat_rows = list(CATS), [], []\n", + " for i in range(n):\n", + " cat = cats[i % len(cats)]; c = CATS[cat]\n", + " spec = {\"v\": int(rng.choice(V_OPTS)), \"w\": int(rng.choice(W_OPTS)), \"tmin\": int(rng.choice([-55, -40])),\n", + " \"tmax\": int(rng.choice([85, 125])), \"size_mm\": float(round(rng.uniform(20, 120), 1)), \"std\": c[\"std\"]}\n", + " words = [str(x) for x in rng.permutation(c[\"words\"])[:4]]\n", + " country = str(rng.choice(COUNTRIES, p=[0.35, 0.3, 0.15, 0.2]))\n", + " overseas = country != \"국내\"\n", + " n_sup = int(rng.choice([1, 2, 3], p=[0.45, 0.35, 0.2])) if overseas else int(rng.choice([1, 2, 3]))\n", + " lead = float(round(rng.gamma(3, 45 if overseas else 25) + 20))\n", + " eol = str(rng.choice([\"정상\", \"단종예고\", \"단종\"], p=[0.7, 0.2, 0.1]))\n", + " crit = str(rng.choice([\"A\", \"B\", \"C\"], p=[0.3, 0.4, 0.3]))\n", + " z = (-2.6 + 1.1 * (n_sup == 1) + 0.9 * overseas + 0.006 * (lead - 100) + 1.2 * (eol == \"단종예고\")\n", + " + 2.0 * (eol == \"단종\") + rng.normal(0, 0.6))\n", + " p_late = float(np.clip(_sig(z) * 0.8 + 0.05, 0, 0.9))\n", + " late = int(rng.binomial(12, p_late))\n", + " demand = int(rng.integers(20, 400))\n", + " rows.append({\"part_no\": f\"P-{i + 1:04d}\", \"category\": cat, \"desc\": describe(cat, words, spec), **spec,\n", + " \"iface\": c[\"iface\"], \"country\": country, \"overseas\": overseas, \"n_suppliers\": n_sup,\n", + " \"lead_time_days\": lead, \"orders_12m\": 12, \"late_orders\": late,\n", + " \"avg_delay_days\": float(round(late and rng.gamma(2, 12 + 20 * p_late), 1)),\n", + " \"eol_status\": eol, \"ltb_months\": int(rng.integers(1, 25)) if eol == \"단종예고\" else None,\n", + " \"price_change_pct\": float(round(rng.normal(4 + 12 * p_late, 8), 1)), \"criticality\": crit,\n", + " \"annual_demand\": demand, \"stock_qty\": int(rng.integers(0, demand)),\n", + " \"defect_rate\": float(round(abs(rng.normal(0.8 + 2 * p_late, 0.8)), 2)),\n", + " \"export_license\": bool(overseas and rng.random() < 0.35),\n", + " \"disrupted_next12m\": bool(rng.random() < _sig(z + 0.4))})\n", + " if not overseas:\n", + " continue\n", + " # 과거 승인된 국산 대체품(정답): 동의어로 서술, 규격 호환(전력 여유 허용, 일부는 크기 차이가 커 장착 검토 필요)\n", + " eq_words = [c[\"syn\"].get(w, w) for w in words]\n", + " hard = rng.random() < 0.15 # 서술이 많이 다른 사례\n", + " if hard:\n", + " eq_words = [c[\"syn\"].get(w, w) for w in c[\"words\"] if w not in words][:1] + eq_words[1:3]\n", + " eq = {**spec, \"w\": spec[\"w\"] if rng.random() < 0.7 else W_OPTS[min(W_OPTS.index(spec[\"w\"]) + 1, 2)],\n", + " \"size_mm\": float(round(spec[\"size_mm\"] * (rng.uniform(1.11, 1.15) if rng.random() < 0.12 else rng.uniform(0.95, 1.05)), 1))}\n", + " cat_rows.append({\"cand_no\": f\"D-{len(cat_rows) + 1:04d}\", \"category\": cat, \"desc\": describe(cat, eq_words, eq), **eq,\n", + " \"iface\": c[\"iface\"], \"vendor\": str(rng.choice(VENDORS)), \"approved_for\": f\"P-{i + 1:04d}\", \"kind\": \"equivalent\"})\n", + " # 유사 오답 후보: 설명은 거의 같지만 전압이 다르거나(5V/48V) 상용 온도 등급(0~70C)\n", + " for kind in (\"volt_mismatch\", \"temp_mismatch\"):\n", + " d = dict(spec)\n", + " if kind == \"volt_mismatch\":\n", + " d[\"v\"] = int(rng.choice([5, 48])) # 체계에서 쓰지 않는 전압\n", + " else:\n", + " d[\"tmin\"], d[\"tmax\"] = 0, 70\n", + " d[\"size_mm\"] = float(round(spec[\"size_mm\"] * rng.uniform(0.95, 1.05), 1))\n", + " cat_rows.append({\"cand_no\": f\"D-{len(cat_rows) + 1:04d}\", \"category\": cat, \"desc\": describe(cat, words, d), **d,\n", + " \"iface\": c[\"iface\"], \"vendor\": str(rng.choice(VENDORS)), \"approved_for\": None, \"kind\": kind})\n", + " parts = pd.DataFrame(rows)\n", + " catalog = pd.DataFrame(cat_rows).sample(frac=1, random_state=seed).reset_index(drop=True)\n", + " return parts, catalog\n", + "# ---------------------------------------------------------------------------\n", + "\n", + "# ---- 조달 위험 스크리닝 룰 설정(YAML 형식 문자열: 규정이 바뀌면 코드가 아니라 이 설정만 고친다) ----\n", + "RULES_YAML = \"\"\"\n", + "- id: R01\n", + " name: 단일 공급선\n", + " field: n_suppliers\n", + " op: \"==\"\n", + " value: 1\n", + " weight: 3\n", + "- id: R02\n", + " name: 해외 조달 의존\n", + " field: overseas\n", + " op: \"==\"\n", + " value: true\n", + " weight: 1\n", + "- id: R03\n", + " name: 리드타임 180일 초과\n", + " field: lead_time_days\n", + " op: \">\"\n", + " value: 180\n", + " weight: 2\n", + "- id: R04\n", + " name: 납기 지연률 30% 이상\n", + " field: delay_rate\n", + " op: \">=\"\n", + " value: 0.3\n", + " weight: 3\n", + "- id: R05\n", + " name: 평균 지연 30일 초과\n", + " field: avg_delay_days\n", + " op: \">\"\n", + " value: 30\n", + " weight: 2\n", + "- id: R06\n", + " name: 단종 예고\n", + " field: eol_status\n", + " op: \"==\"\n", + " value: 단종예고\n", + " weight: 3\n", + "- id: R07\n", + " name: 단종 확정\n", + " field: eol_status\n", + " op: \"==\"\n", + " value: 단종\n", + " weight: 4\n", + "- id: R08\n", + " name: 최종구매 6개월 이내\n", + " field: ltb_months\n", + " op: \"<=\"\n", + " value: 6\n", + " weight: 3\n", + "- id: R09\n", + " name: 단가 20% 이상 급등\n", + " field: price_change_pct\n", + " op: \">=\"\n", + " value: 20\n", + " weight: 2\n", + "- id: R10\n", + " name: 핵심(A) 부품 단일 공급\n", + " field: crit_single\n", + " op: \"==\"\n", + " value: true\n", + " weight: 3\n", + "- id: R11\n", + " name: 재고 가용기간 < 리드타임\n", + " field: cover_gap_days\n", + " op: \"<\"\n", + " value: 0\n", + " weight: 3\n", + "- id: R12\n", + " name: 입고 불량률 2% 초과\n", + " field: defect_rate\n", + " op: \">\"\n", + " value: 2.0\n", + " weight: 2\n", + "- id: R13\n", + " name: 수출 허가 필요\n", + " field: export_license\n", + " op: \"==\"\n", + " value: true\n", + " weight: 2\n", + "- id: R14\n", + " name: 재고 없음\n", + " field: stock_qty\n", + " op: \"==\"\n", + " value: 0\n", + " weight: 2\n", + "- id: R15\n", + " name: 공급국 편중(30% 이상)\n", + " field: country_share\n", + " op: \">=\"\n", + " value: 0.3\n", + " weight: 1\n", + "- id: R16\n", + " name: 지연 이력 있는 단일 공급선\n", + " field: single_late\n", + " op: \"==\"\n", + " value: true\n", + " weight: 2\n", + "\"\"\"\n", + "GRADE_CUT = {\"고위험\": 16, \"주의\": 10} # 가중치 합 기준 1차 스크리닝 등급\n", + "\n", + "\n", + "def parse_rules(text):\n", + " \"\"\"간이 YAML 파서(목록 + key: value). 숫자·true/false·따옴표 문자열을 해석한다.\"\"\"\n", + " rules, cur = [], None\n", + " for line in text.strip().splitlines():\n", + " if not line.strip():\n", + " continue\n", + " s = line.strip()\n", + " if s.startswith(\"- \"):\n", + " cur = {}; rules.append(cur); s = s[2:]\n", + " k, v = [x.strip() for x in s.split(\":\", 1)]\n", + " v = v.strip('\"')\n", + " if v in (\"true\", \"false\"):\n", + " v = v == \"true\"\n", + " else:\n", + " try:\n", + " v = float(v) if \".\" in v else int(v)\n", + " except ValueError:\n", + " pass\n", + " cur[k] = v\n", + " return rules\n", + "\n", + "\n", + "OPS = {\"==\": lambda a, b: a == b, \"!=\": lambda a, b: a != b, \">\": lambda a, b: a > b, \">=\": lambda a, b: a >= b,\n", + " \"<\": lambda a, b: a < b, \"<=\": lambda a, b: a <= b}\n", + "\n", + "\n", + "def derive(df):\n", + " \"\"\"룰에 쓰는 파생 필드(지연률, 핵심 단일공급, 재고 가용기간 차이, 공급국 비중 등)를 만든다.\"\"\"\n", + " d = df.copy()\n", + " d[\"delay_rate\"] = d[\"late_orders\"] / d[\"orders_12m\"].clip(lower=1)\n", + " d[\"crit_single\"] = (d[\"criticality\"] == \"A\") & (d[\"n_suppliers\"] == 1)\n", + " d[\"cover_gap_days\"] = d[\"stock_qty\"] / d[\"annual_demand\"].clip(lower=1) * 365 - d[\"lead_time_days\"]\n", + " d[\"country_share\"] = d[\"country\"].map(d[\"country\"].value_counts(normalize=True)).where(d[\"overseas\"], 0.0)\n", + " d[\"single_late\"] = (d[\"n_suppliers\"] == 1) & (d[\"late_orders\"] >= 3)\n", + " return d\n", + "\n", + "\n", + "def apply_rules(rec, rules):\n", + " \"\"\"레코드 1건(dict)에 룰을 적용해 (위험 점수, 걸린 룰 목록)을 돌려준다. 값이 없으면 해당 룰은 건너뛴다.\"\"\"\n", + " hits = []\n", + " for r in rules:\n", + " v = rec.get(r[\"field\"])\n", + " if v is None or (isinstance(v, float) and np.isnan(v)):\n", + " continue\n", + " if OPS[r[\"op\"]](v, r[\"value\"]):\n", + " hits.append(r)\n", + " score = sum(r[\"weight\"] for r in hits)\n", + " grade = \"고위험\" if score >= GRADE_CUT[\"고위험\"] else (\"주의\" if score >= GRADE_CUT[\"주의\"] else \"관심\")\n", + " return score, grade, [r[\"id\"] for r in hits]\n", + "\n", + "# [교육용가상자료] 룰과 가상 지침 조항의 대응표(실제 규정 조문이 아니라 연결 방식을 보여 주는 예시)\n", + "CLAUSE = {\"R01\": \"가상지침 3조1항(공급선 다원화)\", \"R02\": \"가상지침 3조2항(해외 의존 관리)\", \"R03\": \"가상지침 4조1항(리드타임 관리)\",\n", + " \"R04\": \"가상지침 4조2항(납기 준수)\", \"R05\": \"가상지침 4조2항(납기 준수)\", \"R06\": \"가상지침 5조1항(단종 관리)\",\n", + " \"R07\": \"가상지침 5조1항(단종 관리)\", \"R08\": \"가상지침 5조2항(최종구매 판단)\", \"R09\": \"가상지침 6조1항(가격 변동 점검)\",\n", + " \"R10\": \"가상지침 3조3항(핵심 부품 관리)\", \"R11\": \"가상지침 4조3항(재고 확보)\", \"R12\": \"가상지침 7조1항(품질 관리)\",\n", + " \"R13\": \"가상지침 3조4항(수출통제 확인)\", \"R14\": \"가상지침 4조3항(재고 확보)\", \"R15\": \"가상지침 3조2항(해외 의존 관리)\",\n", + " \"R16\": \"가상지침 3조1항(공급선 다원화)\"}\n", + "\n", + "rules = parse_rules(RULES_YAML)\n", + "parts, _ = make_parts(100, seed=7)\n", + "df = derive(parts)\n", + "recs = df.to_dict(\"records\")\n", + "\n", + "# 1) 전체 부품 스크리닝과 처리 지연시간(레코드 1건당)\n", + "lat = []\n", + "res = []\n", + "for r in recs:\n", + " t0 = time.perf_counter()\n", + " out = apply_rules(r, rules)\n", + " lat.append((time.perf_counter() - t0) * 1000)\n", + " res.append(out)\n", + "df[\"score\"], df[\"grade\"], df[\"hits\"] = [x[0] for x in res], [x[1] for x in res], [x[2] for x in res]\n", + "print(f\"[룰 설정] 검증 룰 {len(rules)}종(목표 15종 이상: {'달성' if len(rules) >= 15 else '미달'}), 모든 룰에 가상 지침 조항 연결 {all(r['id'] in CLAUSE for r in rules)}\")\n", + "print(f\"룰 처리 지연시간: 평균 {np.mean(lat):.3f}ms, 최대 {np.max(lat):.3f}ms (목표 50ms 이하: {'달성' if max(lat) <= 50 else '미달'})\")\n", + "\n", + "# 2) 등급 분포와 과거 단절 이력(다음 12개월 실제 단절 여부) 대비 탐지력\n", + "print(\"\\n[1차 스크리닝 등급 분포]\")\n", + "print(df.groupby(\"grade\").agg(부품수=(\"part_no\", \"size\"), 실제단절률=(\"disrupted_next12m\", \"mean\")).reindex([\"고위험\", \"주의\", \"관심\"]).round(2).to_string())\n", + "y = df[\"disrupted_next12m\"].to_numpy()\n", + "flag = df[\"grade\"].isin([\"고위험\", \"주의\"]).to_numpy()\n", + "tp = int((flag & y).sum())\n", + "print(f\"고위험·주의 선별: 실제 단절 부품 {y.sum()}건 중 {tp}건 포착(재현율 {tp / y.sum():.0%}), 선별 {flag.sum()}건 중 정밀도 {tp / max(flag.sum(), 1):.0%}\")\n", + "\n", + "# 3) 룰별 적중 건수(어떤 룰이 얼마나 걸리는가)\n", + "cnt = pd.Series([h for hs in df[\"hits\"] for h in hs]).value_counts()\n", + "tbl = pd.DataFrame([{\"룰\": r[\"id\"], \"이름\": r[\"name\"], \"가중치\": r[\"weight\"], \"적중\": int(cnt.get(r[\"id\"], 0)), \"근거 조항\": CLAUSE[r[\"id\"]]} for r in rules])\n", + "print(\"\\n[룰별 적중 현황]\")\n", + "print(tbl.to_string(index=False))\n", + "\n", + "# 4) 상위 위험 부품 5건과 판정 근거(설명 가능성)\n", + "print(\"\\n[고위험 상위 5건과 근거]\")\n", + "for _, r in df.sort_values(\"score\", ascending=False).head(5).iterrows():\n", + " names = \", \".join(next(x[\"name\"] for x in rules if x[\"id\"] == h) for h in r[\"hits\"])\n", + " print(f\" {r['part_no']} {r['category']} 점수 {r['score']:2d} {r['grade']} (적중 룰 {len(r['hits'])}종): {names[:60]}...\")\n", + "\n", + "# 5) 규정 변경 대응: 리드타임 기준을 180일에서 150일로 바꾸는 설정 변경(코드 수정 없음)\n", + "new_yaml = RULES_YAML.replace(\"value: 180\", \"value: 120\")\n", + "rules2 = parse_rules(new_yaml)\n", + "o2 = [apply_rules(r, rules2) for r in recs]\n", + "n_score = sum(a != b[0] for a, b in zip(df[\"score\"], o2)); n_grade = sum(a != b[1] for a, b in zip(df[\"grade\"], o2))\n", + "print(f\"\\n[설정 변경] R03 리드타임 기준 180일 -> 120일: 점수가 바뀐 부품 {n_score}건, 등급이 바뀐 부품 {n_grade}건 (코드 수정 없이 YAML만 변경)\")\n", + "print(\"처리 후 사람 검토: 스크리닝은 1차 선별이며, 최종 위험 판단과 조치는 조달 담당자가 결정한다.\")\n", + "\n", + "fig, ax = plt.subplots(1, 2, figsize=(11, 4))\n", + "ax[0].bar(tbl[\"룰\"], tbl[\"적중\"], color=\"tab:blue\"); ax[0].set_title(\"Rule hits by rule (synthetic)\"); ax[0].set_ylabel(\"parts\")\n", + "g = df.groupby(\"grade\")[\"disrupted_next12m\"].mean().reindex([\"관심\", \"주의\", \"고위험\"])\n", + "ax[1].bar([\"low\", \"watch\", \"high\"], g.values, color=[\"tab:green\", \"gold\", \"tab:red\"]); ax[1].set_title(\"Actual disruption rate by screening grade\"); ax[1].set_ylim(0, 1)\n", + "plt.tight_layout(); plt.show()\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 03 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + "[룰 설정] 검증 룰 16종(목표 15종 이상: 달성), 모든 룰에 가상 지침 조항 연결 True\n", + "룰 처리 지연시간: 평균 0.012ms, 최대 0.043ms (목표 50ms 이하: 달성)\n", + "\n", + "[1차 스크리닝 등급 분포]\n", + " 부품수 실제단절률\n", + "grade \n", + "고위험 20 0.60\n", + "주의 38 0.39\n", + "관심 42 0.29\n", + "고위험·주의 선별: 실제 단절 부품 39건 중 27건 포착(재현율 69%), 선별 58건 중 정밀도 47%\n", + "\n", + "[룰별 적중 현황]\n", + " 룰 이름 가중치 적중 근거 조항\n", + "R01 단일 공급선 3 42 가상지침 3조1항(공급선 다원화)\n", + "R02 해외 조달 의존 1 85 가상지침 3조2항(해외 의존 관리)\n", + "R03 리드타임 180일 초과 2 27 가상지침 4조1항(리드타임 관리)\n", + "R04 납기 지연률 30% 이상 3 49 가상지침 4조2항(납기 준수)\n", + "R05 평균 지연 30일 초과 2 64 가상지침 4조2항(납기 준수)\n", + "R06 단종 예고 3 26 가상지침 5조1항(단종 관리)\n", + "R07 단종 확정 4 11 가상지침 5조1항(단종 관리)\n", + "R08 최종구매 6개월 이내 3 6 가상지침 5조2항(최종구매 판단)\n", + "R09 단가 20% 이상 급등 2 8 가상지침 6조1항(가격 변동 점검)\n", + "R10 핵심(A) 부품 단일 공급 3 14 가상지침 3조3항(핵심 부품 관리)\n", + "R11 재고 가용기간 < 리드타임 3 40 가상지침 4조3항(재고 확보)\n", + "R12 입고 불량률 2% 초과 2 24 가상지침 7조1항(품질 관리)\n", + "R13 수출 허가 필요 2 35 가상지침 3조4항(수출통제 확인)\n", + "R14 재고 없음 2 1 가상지침 4조3항(재고 확보)\n", + "R15 공급국 편중(30% 이상) 1 70 가상지침 3조2항(해외 의존 관리)\n", + "R16 지연 이력 있는 단일 공급선 2 35 가상지침 3조1항(공급선 다원화)\n", + "\n", + "[고위험 상위 5건과 근거]\n", + " P-0087 RF증폭기 점수 26 고위험 (적중 룰 11종): 단일 공급선, 해외 조달 의존, 납기 지연률 30% 이상, 평균 지연 30일 초과, 단종 예고, 최종구매 6...\n", + " P-0028 베어링 점수 25 고위험 (적중 룰 11종): 단일 공급선, 해외 조달 의존, 리드타임 180일 초과, 납기 지연률 30% 이상, 평균 지연 30일 초과,...\n", + " P-0066 커넥터 점수 25 고위험 (적중 룰 11종): 단일 공급선, 해외 조달 의존, 리드타임 180일 초과, 납기 지연률 30% 이상, 평균 지연 30일 초과,...\n", + " P-0033 전원모듈 점수 24 고위험 (적중 룰 11종): 단일 공급선, 해외 조달 의존, 리드타임 180일 초과, 납기 지연률 30% 이상, 평균 지연 30일 초과,...\n", + " P-0077 광학렌즈 점수 24 고위험 (적중 룰 10종): 단일 공급선, 해외 조달 의존, 납기 지연률 30% 이상, 평균 지연 30일 초과, 단종 확정, 핵심(A) ...\n", + "\n", + "[설정 변경] R03 리드타임 기준 180일 -> 120일: 점수가 바뀐 부품 27건, 등급이 바뀐 부품 8건 (코드 수정 없이 YAML만 변경)\n", + "처리 후 사람 검토: 스크리닝은 1차 선별이며, 최종 위험 판단과 조치는 조달 담당자가 결정한다.\n", + "```\n", + "
\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 04. NLP 및 지식 그래프 기반 대체 가능 호환 부품(Alternative Parts) 자동 추천 모델 고도화\n", + "**소요시간**: 약 3.0시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "지식 그래프 기반 대체 가능 호환 부품(Alternative Parts) 자동 추천 모델을 고도화한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **텍스트 유사도와 지식 그래프, 규격 적합성을 결합해 해외 부품별 국산 대체 후보를 추천하고 추천 근거를 제시한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **부품 설명문의 TF-IDF 유사도, 전압·전력·온도·크기·인터페이스 6개 규격 적합성, 동의어를 간선으로 연결한 지식 그래프 이웃 유사도를 가중합해 부품별 Top-3 후보 작성하여 원인을 분석하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `src/ai/prc07_intelligence_module.py` 형태의 결과물을 완성한다.\n", + "\n", + "### 🔍 평가 및 검증 기준 (PASS 조건)\n", + "#### 미션 판정\n", + "\n", + "**평가 구분:** 필수\n", + "\n", + "**핵심 평가영역:** 추천 사유의 합리성 및 실무 수용성\n", + "\n", + "#### 미션 PASS 조건\n", + "\n", + "- 정량 기준: 추천/분류 정확도 85% 이상 달성\n", + "\n", + "\n", + "- 진행 기준: AI 추론 모듈 구현 및 벤치마크 테스트 완료\n", + "\n", + "\n", + "- 검증 방법: 과거 승인/처리 완료 데이터와의 추천 일치도 검증\n", + "\n", + "\n", + "#### 채점자 판정 메모\n", + "\n", + "> 단순 코드 실행 여부뿐만 아니라 출력된 수치와 근거 자료가 군 실무 요구사항에 부합하는지 면밀히 확인한다.\n", + "\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "\"\"\"부품 설명문의 TF-IDF 텍스트 유사도, 규격 적합성, 동의어를 연결한 지식 그래프 이웃 유사도를 결합해 해외 부품별 국산 대체 후보 Top-3를 추천하고, 과거 승인 이력 기준 정확도·요소별 기여·오추천 유형·추천 사유 문장을 확인한다.\"\"\"\n", + "import time\n", + "import numpy as np\n", + "import pandas as pd\n", + "import matplotlib.pyplot as plt\n", + "from sklearn.feature_extraction.text import TfidfVectorizer\n", + "from sklearn.metrics.pairwise import cosine_similarity\n", + "\n", + "# ---------------------------------------------------------------------------\n", + "# [교육용가상자료] 가상 무기체계 부품·공급망 데이터 생성기 (실제 체계·업체·국가·단가 정보 없음)\n", + "# 부품 100종(해외 조달 약 80%)의 공급망 속성과, 국내 부품 카탈로그(과거 승인된 국산 대체품 +\n", + "# 설명은 비슷하지만 규격이 맞지 않는 유사 후보)를 함께 만든다.\n", + "# ---------------------------------------------------------------------------\n", + "COUNTRIES = [\"가상국가A\", \"가상국가B\", \"가상국가C\", \"국내\"]\n", + "CATS = {\n", + " \"전원모듈\": dict(words=[\"DC-DC\", \"컨버터\", \"절연형\", \"전원\", \"정출력\"], syn={\"컨버터\": \"변환장치\", \"절연형\": \"아이솔레이션\", \"정출력\": \"안정화\"}, std=\"MIL-STD-704\", iface=\"단자대\"),\n", + " \"커넥터\": dict(words=[\"원형\", \"커넥터\", \"방수형\", \"다핀\", \"나사체결\"], syn={\"커넥터\": \"접속자\", \"방수형\": \"밀폐형\", \"나사체결\": \"스크루결합\"}, std=\"MIL-DTL-38999\", iface=\"원형핀\"),\n", + " \"유압밸브\": dict(words=[\"유압\", \"솔레노이드\", \"밸브\", \"방향제어\", \"4포트\"], syn={\"밸브\": \"개폐장치\", \"방향제어\": \"유로전환\", \"솔레노이드\": \"전자식\"}, std=\"SAE-AS5440\", iface=\"플랜지\"),\n", + " \"베어링\": dict(words=[\"볼\", \"베어링\", \"고속\", \"밀봉형\", \"회전축\"], syn={\"베어링\": \"축받이\", \"밀봉형\": \"실드형\", \"고속\": \"고회전\"}, std=\"ISO-492\", iface=\"축결합\"),\n", + " \"광학렌즈\": dict(words=[\"적외선\", \"렌즈\", \"게르마늄\", \"반사방지\", \"집광\"], syn={\"렌즈\": \"광학창\", \"집광\": \"초점형성\", \"반사방지\": \"AR코팅\"}, std=\"MIL-PRF-13830\", iface=\"마운트\"),\n", + " \"자이로센서\": dict(words=[\"광섬유\", \"자이로\", \"각속도\", \"센서\", \"관성\"], syn={\"센서\": \"감지기\", \"각속도\": \"회전율\", \"관성\": \"INS용\"}, std=\"IEEE-952\", iface=\"직렬통신\"),\n", + " \"RF증폭기\": dict(words=[\"RF\", \"전력증폭기\", \"광대역\", \"모듈\", \"송신\"], syn={\"전력증폭기\": \"파워앰프\", \"송신\": \"출력단\", \"광대역\": \"와이드밴드\"}, std=\"MIL-STD-461\", iface=\"SMA\"),\n", + " \"제어보드\": dict(words=[\"FPGA\", \"제어보드\", \"임베디드\", \"연산\", \"입출력\"], syn={\"제어보드\": \"컨트롤카드\", \"연산\": \"신호처리\", \"임베디드\": \"내장형\"}, std=\"VITA-46\", iface=\"백플레인\"),\n", + "}\n", + "V_OPTS, W_OPTS = [12, 24, 28], [25, 50, 100]\n", + "VENDORS = [\"가상정밀(주)\", \"가상전자(주)\", \"가상유공압(주)\", \"가상광학(주)\", \"가상테크(주)\", \"가상시스템(주)\"]\n", + "\n", + "\n", + "def describe(cat, words, s):\n", + " # [TODO : 학생 실습 4] NLP 및 지식 그래프 기반 대체 가능 호환 부품(Alternative Parts) 자동 추천 모델 고도화 관련 핵심 로직을 구현하세요.\n", + " # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n", + " # <<< 여기에 코드를 작성하세요 >>>\n", + " pass # 작성 후 pass를 제거하세요.\n", + "def _sig(x):\n", + " # [TODO : 학생 실습 4] NLP 및 지식 그래프 기반 대체 가능 호환 부품(Alternative Parts) 자동 추천 모델 고도화 관련 핵심 로직을 구현하세요.\n", + " # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n", + " # <<< 여기에 코드를 작성하세요 >>>\n", + " pass # 작성 후 pass를 제거하세요.\n", + "def make_parts(n=100, seed=7):\n", + " \"\"\"부품 목록(공급망 속성 포함)과 국내 카탈로그를 만든다. 반환: (parts DataFrame, catalog DataFrame)\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " cats, rows, cat_rows = list(CATS), [], []\n", + " for i in range(n):\n", + " cat = cats[i % len(cats)]; c = CATS[cat]\n", + " spec = {\"v\": int(rng.choice(V_OPTS)), \"w\": int(rng.choice(W_OPTS)), \"tmin\": int(rng.choice([-55, -40])),\n", + " \"tmax\": int(rng.choice([85, 125])), \"size_mm\": float(round(rng.uniform(20, 120), 1)), \"std\": c[\"std\"]}\n", + " words = [str(x) for x in rng.permutation(c[\"words\"])[:4]]\n", + " country = str(rng.choice(COUNTRIES, p=[0.35, 0.3, 0.15, 0.2]))\n", + " overseas = country != \"국내\"\n", + " n_sup = int(rng.choice([1, 2, 3], p=[0.45, 0.35, 0.2])) if overseas else int(rng.choice([1, 2, 3]))\n", + " lead = float(round(rng.gamma(3, 45 if overseas else 25) + 20))\n", + " eol = str(rng.choice([\"정상\", \"단종예고\", \"단종\"], p=[0.7, 0.2, 0.1]))\n", + " crit = str(rng.choice([\"A\", \"B\", \"C\"], p=[0.3, 0.4, 0.3]))\n", + " z = (-2.6 + 1.1 * (n_sup == 1) + 0.9 * overseas + 0.006 * (lead - 100) + 1.2 * (eol == \"단종예고\")\n", + " + 2.0 * (eol == \"단종\") + rng.normal(0, 0.6))\n", + " p_late = float(np.clip(_sig(z) * 0.8 + 0.05, 0, 0.9))\n", + " late = int(rng.binomial(12, p_late))\n", + " demand = int(rng.integers(20, 400))\n", + " rows.append({\"part_no\": f\"P-{i + 1:04d}\", \"category\": cat, \"desc\": describe(cat, words, spec), **spec,\n", + " \"iface\": c[\"iface\"], \"country\": country, \"overseas\": overseas, \"n_suppliers\": n_sup,\n", + " \"lead_time_days\": lead, \"orders_12m\": 12, \"late_orders\": late,\n", + " \"avg_delay_days\": float(round(late and rng.gamma(2, 12 + 20 * p_late), 1)),\n", + " \"eol_status\": eol, \"ltb_months\": int(rng.integers(1, 25)) if eol == \"단종예고\" else None,\n", + " \"price_change_pct\": float(round(rng.normal(4 + 12 * p_late, 8), 1)), \"criticality\": crit,\n", + " \"annual_demand\": demand, \"stock_qty\": int(rng.integers(0, demand)),\n", + " \"defect_rate\": float(round(abs(rng.normal(0.8 + 2 * p_late, 0.8)), 2)),\n", + " \"export_license\": bool(overseas and rng.random() < 0.35),\n", + " \"disrupted_next12m\": bool(rng.random() < _sig(z + 0.4))})\n", + " if not overseas:\n", + " continue\n", + " # 과거 승인된 국산 대체품(정답): 동의어로 서술, 규격 호환(전력 여유 허용, 일부는 크기 차이가 커 장착 검토 필요)\n", + " eq_words = [c[\"syn\"].get(w, w) for w in words]\n", + " hard = rng.random() < 0.15 # 서술이 많이 다른 사례\n", + " if hard:\n", + " eq_words = [c[\"syn\"].get(w, w) for w in c[\"words\"] if w not in words][:1] + eq_words[1:3]\n", + " eq = {**spec, \"w\": spec[\"w\"] if rng.random() < 0.7 else W_OPTS[min(W_OPTS.index(spec[\"w\"]) + 1, 2)],\n", + " \"size_mm\": float(round(spec[\"size_mm\"] * (rng.uniform(1.11, 1.15) if rng.random() < 0.12 else rng.uniform(0.95, 1.05)), 1))}\n", + " cat_rows.append({\"cand_no\": f\"D-{len(cat_rows) + 1:04d}\", \"category\": cat, \"desc\": describe(cat, eq_words, eq), **eq,\n", + " \"iface\": c[\"iface\"], \"vendor\": str(rng.choice(VENDORS)), \"approved_for\": f\"P-{i + 1:04d}\", \"kind\": \"equivalent\"})\n", + " # 유사 오답 후보: 설명은 거의 같지만 전압이 다르거나(5V/48V) 상용 온도 등급(0~70C)\n", + " for kind in (\"volt_mismatch\", \"temp_mismatch\"):\n", + " d = dict(spec)\n", + " if kind == \"volt_mismatch\":\n", + " d[\"v\"] = int(rng.choice([5, 48])) # 체계에서 쓰지 않는 전압\n", + " else:\n", + " d[\"tmin\"], d[\"tmax\"] = 0, 70\n", + " d[\"size_mm\"] = float(round(spec[\"size_mm\"] * rng.uniform(0.95, 1.05), 1))\n", + " cat_rows.append({\"cand_no\": f\"D-{len(cat_rows) + 1:04d}\", \"category\": cat, \"desc\": describe(cat, words, d), **d,\n", + " \"iface\": c[\"iface\"], \"vendor\": str(rng.choice(VENDORS)), \"approved_for\": None, \"kind\": kind})\n", + " parts = pd.DataFrame(rows)\n", + " catalog = pd.DataFrame(cat_rows).sample(frac=1, random_state=seed).reset_index(drop=True)\n", + " return parts, catalog\n", + "# ---------------------------------------------------------------------------\n", + "\n", + "# ---- 대체 부품 추천 공통 함수: 텍스트 유사도 + 규격 적합성 + 지식 그래프 이웃 유사도 ----\n", + "WEIGHTS = {\"text\": 0.35, \"spec\": 0.45, \"graph\": 0.20}\n", + "\n", + "\n", + "def spec_fit(p, c):\n", + " \"\"\"규격 적합성 0~1: 6개 조건 충족 비율. 전압·온도 한계(필수 조건)를 못 맞추면 점수를 절반으로 깎는다.\"\"\"\n", + " conds = [p[\"v\"] == c[\"v\"], c[\"w\"] >= p[\"w\"], c[\"tmin\"] <= p[\"tmin\"], c[\"tmax\"] >= p[\"tmax\"],\n", + " abs(c[\"size_mm\"] - p[\"size_mm\"]) / p[\"size_mm\"] <= 0.10, p[\"iface\"] == c[\"iface\"]]\n", + " s = sum(conds) / len(conds)\n", + " return (s * 0.5 if not (conds[0] and conds[2] and conds[3]) else s), conds\n", + "\n", + "\n", + "def graph_nodes(row):\n", + " \"\"\"지식 그래프에서 부품 노드와 연결된 이웃 노드 집합(범주·표준·인터페이스·기능어·등급).\"\"\"\n", + " words = set(row[\"desc\"].split()[1:-4])\n", + " grade = \"군용등급\" if row[\"tmin\"] <= -40 else \"상용등급\"\n", + " return {f\"cat:{row['category']}\", f\"std:{row['std']}\", f\"if:{row['iface']}\", f\"grade:{grade}\", f\"v:{row['v']}\"} | {f\"w:{w}\" for w in words}\n", + "\n", + "\n", + "def build_graph(catalog):\n", + " \"\"\"동의어 사전을 그래프 간선(기능어 = 동의어)으로 넣어 서술이 달라도 같은 기능 노드로 연결되게 한다.\"\"\"\n", + " syn = {f\"w:{b}\": f\"w:{a}\" for c in CATS.values() for a, b in c[\"syn\"].items()}\n", + " canon = lambda nodes: {syn.get(x, x) for x in nodes}\n", + " return {\"canon\": canon, \"nodes\": [canon(graph_nodes(r)) for _, r in catalog.iterrows()]}\n", + "\n", + "\n", + "def graph_sim(g, pn, cn):\n", + " \"\"\"두 부품이 공유하는 이웃 노드 비율(Jaccard).\"\"\"\n", + " pn = g[\"canon\"](pn)\n", + " return len(pn & cn) / max(len(pn | cn), 1)\n", + "\n", + "\n", + "def score_table(parts_df, catalog):\n", + " \"\"\"해외 부품 x 같은 범주 국내 후보 쌍마다 텍스트·규격·그래프 점수를 계산한 표(가중치 적용 전).\"\"\"\n", + " vec = TfidfVectorizer(token_pattern=r\"[^ ]+\").fit(list(parts_df[\"desc\"]) + list(catalog[\"desc\"]))\n", + " text = cosine_similarity(vec.transform(parts_df[\"desc\"]), vec.transform(catalog[\"desc\"]))\n", + " g = build_graph(catalog)\n", + " rows = []\n", + " for i, (_, p) in enumerate(parts_df.iterrows()):\n", + " pn = graph_nodes(p)\n", + " for j, (_, c) in enumerate(catalog.iterrows()):\n", + " if c[\"category\"] != p[\"category\"]:\n", + " continue\n", + " sf, conds = spec_fit(p, c)\n", + " rows.append({\"part_no\": p[\"part_no\"], \"cand_no\": c[\"cand_no\"], \"vendor\": c[\"vendor\"], \"text\": text[i, j],\n", + " \"spec\": sf, \"graph\": graph_sim(g, pn, g[\"nodes\"][j]),\n", + " \"unmet\": \",\".join(n for n, ok in zip([\"전압\", \"전력\", \"저온\", \"고온\", \"크기\", \"인터페이스\"], conds) if not ok) or \"-\",\n", + " \"kind\": c[\"kind\"], \"correct\": c[\"approved_for\"] == p[\"part_no\"]})\n", + " return pd.DataFrame(rows)\n", + "\n", + "\n", + "def recommend(table, weights=None, top=3):\n", + " \"\"\"가중합 점수로 부품별 상위 top개 후보를 고른다.\"\"\"\n", + " w = weights or WEIGHTS\n", + " t = table.assign(score=w[\"text\"] * table[\"text\"] + w[\"spec\"] * table[\"spec\"] + w[\"graph\"] * table[\"graph\"])\n", + " return t.sort_values([\"part_no\", \"score\"], ascending=[True, False]).groupby(\"part_no\").head(top)\n", + "\n", + "\n", + "def hit_rate(recs, k=1):\n", + " \"\"\"과거 승인 이력(정답 대체품)이 상위 k개 안에 들어간 부품 비율.\"\"\"\n", + " return recs.groupby(\"part_no\").apply(lambda d: d[\"correct\"].head(k).any(), include_groups=False).mean()\n", + "\n", + "parts, catalog = make_parts(100, seed=7)\n", + "over = parts[parts[\"overseas\"]].reset_index(drop=True)\n", + "print(f\"[데이터] 해외 조달 부품 {len(over)}종, 국내 후보 카탈로그 {len(catalog)}건(승인 이력 있는 대체품 {int((catalog['kind'] == 'equivalent').sum())}건, 규격 불일치 유사 후보 {int((catalog['kind'] != 'equivalent').sum())}건)\")\n", + "\n", + "t0 = time.perf_counter()\n", + "table = score_table(over, catalog)\n", + "rec = recommend(table)\n", + "ms = (time.perf_counter() - t0) * 1000 / len(over)\n", + "print(f\"추천 처리: 부품 1종당 {ms:.1f}ms (후보 쌍 {len(table)}개 점수 계산 포함)\")\n", + "\n", + "# 1) 요소별 기여: 어느 신호를 쓰느냐에 따라 정확도가 어떻게 달라지는가\n", + "combos = {\"텍스트 유사도만\": {\"text\": 1, \"spec\": 0, \"graph\": 0}, \"규격 적합성만\": {\"text\": 0, \"spec\": 1, \"graph\": 0},\n", + " \"그래프만\": {\"text\": 0, \"spec\": 0, \"graph\": 1}, \"텍스트+그래프\": {\"text\": .5, \"spec\": 0, \"graph\": .5},\n", + " \"텍스트+규격\": {\"text\": .4, \"spec\": .6, \"graph\": 0}, \"세 신호 결합(운용)\": WEIGHTS}\n", + "rows = []\n", + "for name, w in combos.items():\n", + " r = recommend(table, w)\n", + " bad = (r.groupby(\"part_no\").head(1)[\"kind\"] != \"equivalent\").mean()\n", + " rows.append({\"방식\": name, \"Top-1 정확도\": hit_rate(r, 1), \"Top-3 정확도\": hit_rate(r, 3), \"1순위가 규격불일치 후보인 비율\": bad})\n", + "ab = pd.DataFrame(rows)\n", + "print(\"\\n[요소별 기여(과거 승인 대체품을 상위에 올렸는가)]\")\n", + "print(ab.round(3).to_string(index=False))\n", + "acc1, acc3 = hit_rate(rec, 1), hit_rate(rec, 3)\n", + "print(f\"\\n운용 방식 Top-1 정확도 {acc1:.1%}, Top-3 정확도 {acc3:.1%} (목표 85% 이상: {'달성' if acc1 >= 0.85 else '미달'})\")\n", + "\n", + "# 2) 오추천 분석: 1순위가 정답이 아닌 부품\n", + "top1 = rec.groupby(\"part_no\").head(1)\n", + "wrong = top1[~top1[\"correct\"]]\n", + "print(f\"\\n[1순위 오추천 {len(wrong)}건 분석]\")\n", + "for _, w in wrong.iterrows():\n", + " right = table[(table[\"part_no\"] == w[\"part_no\"]) & table[\"correct\"]].iloc[0]\n", + " if w[\"kind\"] != \"equivalent\":\n", + " why = f\"규격 불일치 후보가 상위(미충족 {w['unmet']}) -> 규격 가중치 보강 필요\"\n", + " elif w[\"unmet\"] == \"-\":\n", + " why = \"다른 부품용으로 승인된 같은 범주 대체품이 규격도 충족 -> 승인 이력상 오답, 실무상 검토 후보\"\n", + " else:\n", + " why = f\"다른 부품용 승인 대체품이 상위(정답 후보는 크기 차이로 감점) -> 크기 허용 오차 재검토\"\n", + " print(f\" {w['part_no']}: 추천 {w['cand_no']}(점수 {w['score']:.2f}, {w['kind']}) / 정답 {right['cand_no']}(규격 미충족 {right['unmet']}) -> {why}\")\n", + "\n", + "# 3) 추천 사유 문장(실무 수용성: 근거를 사람이 읽을 수 있게)\n", + "print(\"\\n[추천 사유 예시]\")\n", + "for pn in over[\"part_no\"].head(3):\n", + " r = rec[rec[\"part_no\"] == pn].iloc[0]\n", + " p = parts[parts[\"part_no\"] == pn].iloc[0]\n", + " ok = \"규격 6개 항목 모두 충족\" if r[\"unmet\"] == \"-\" else f\"규격 미충족 항목 {r['unmet']}(기술 검토 필요)\"\n", + " print(f\" {pn} {p['category']} -> {r['cand_no']}({r['vendor']}): 설명 유사도 {r['text']:.2f}, 규격 적합성 {r['spec']:.2f}, 그래프 연결 {r['graph']:.2f} / {ok}\")\n", + "print(\"※ 추천은 후보 제시이며 규격 적합성 최종 판단과 계약 의뢰는 기술검토관·계약 담당자가 결정한다.\")\n", + "\n", + "fig, ax = plt.subplots(1, 2, figsize=(11, 4))\n", + "x = np.arange(len(ab)); ax[0].bar(x - .2, ab[\"Top-1 정확도\"], .4, label=\"top-1\"); ax[0].bar(x + .2, ab[\"Top-3 정확도\"], .4, label=\"top-3\")\n", + "ax[0].set_xticks(x, [\"text\", \"spec\", \"graph\", \"text+graph\", \"text+spec\", \"all 3\"], rotation=20); ax[0].axhline(.85, color=\"r\", ls=\"--\"); ax[0].legend(); ax[0].set_title(\"Accuracy by signal combination\")\n", + "allsc = recommend(table, WEIGHTS, 999)\n", + "ax[1].hist([allsc[allsc[\"correct\"]][\"score\"], allsc[~allsc[\"correct\"]][\"score\"]], bins=15, label=[\"approved\", \"other\"], stacked=True)\n", + "ax[1].legend(); ax[1].set_title(\"Score distribution (candidates)\"); ax[1].set_xlabel(\"weighted score\")\n", + "plt.tight_layout(); plt.show()\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 04 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + "[데이터] 해외 조달 부품 85종, 국내 후보 카탈로그 255건(승인 이력 있는 대체품 85건, 규격 불일치 유사 후보 170건)\n", + "추천 처리: 부품 1종당 14.0ms (후보 쌍 2745개 점수 계산 포함)\n", + "\n", + "[요소별 기여(과거 승인 대체품을 상위에 올렸는가)]\n", + " 방식 Top-1 정확도 Top-3 정확도 1순위가 규격불일치 후보인 비율\n", + " 텍스트 유사도만 0.000 0.000 1.000\n", + " 규격 적합성만 0.812 1.000 0.000\n", + " 그래프만 0.553 0.741 0.047\n", + " 텍스트+그래프 0.000 0.082 1.000\n", + " 텍스트+규격 0.871 0.988 0.000\n", + "세 신호 결합(운용) 0.894 0.976 0.012\n", + "\n", + "운용 방식 Top-1 정확도 89.4%, Top-3 정확도 97.6% (목표 85% 이상: 달성)\n", + "\n", + "[1순위 오추천 9건 분석]\n", + " P-0009: 추천 D-0187(점수 0.77, equivalent) / 정답 D-0016(규격 미충족 -) -> 다른 부품용으로 승인된 같은 범주 대체품이 규격도 충족 -> 승인 이력상 오답, 실무상 검토 후보\n", + " P-0028: 추천 D-0046(점수 0.76, equivalent) / 정답 D-0070(규격 미충족 크기) -> 다른 부품용으로 승인된 같은 범주 대체품이 규격도 충족 -> 승인 이력상 오답, 실무상 검토 후보\n", + " P-0032: 추천 D-0034(점수 0.82, equivalent) / 정답 D-0082(규격 미충족 크기) -> 다른 부품용으로 승인된 같은 범주 대체품이 규격도 충족 -> 승인 이력상 오답, 실무상 검토 후보\n", + " P-0046: 추천 D-0119(점수 0.67, volt_mismatch) / 정답 D-0118(규격 미충족 크기) -> 규격 불일치 후보가 상위(미충족 전압) -> 규격 가중치 보강 필요\n", + " P-0049: 추천 D-0016(점수 0.82, equivalent) / 정답 D-0124(규격 미충족 -) -> 다른 부품용으로 승인된 같은 범주 대체품이 규격도 충족 -> 승인 이력상 오답, 실무상 검토 후보\n", + " P-0056: 추천 D-0058(점수 0.84, equivalent) / 정답 D-0145(규격 미충족 -) -> 다른 부품용으로 승인된 같은 범주 대체품이 규격도 충족 -> 승인 이력상 오답, 실무상 검토 후보\n", + " P-0060: 추천 D-0235(점수 0.81, equivalent) / 정답 D-0154(규격 미충족 -) -> 다른 부품용으로 승인된 같은 범주 대체품이 규격도 충족 -> 승인 이력상 오답, 실무상 검토 후보\n", + " P-0063: 추천 D-0181(점수 0.76, equivalent) / 정답 D-0163(규격 미충족 -) -> 다른 부품용으로 승인된 같은 범주 대체품이 규격도 충족 -> 승인 이력상 오답, 실무상 검토 후보\n", + " P-0077: 추천 D-0049(점수 0.76, equivalent) / 정답 D-0196(규격 미충족 크기) -> 다른 부품용 승인 대체품이 상위(정답 후보는 크기 차이로 감점) -> 크기 허용 오차 재검토\n", + "\n", + "[추천 사유 예시]\n", + " P-0002 커넥터 -> D-0001(가상정밀(주)): 설명 유사도 0.64, 규격 적합성 1.00, 그래프 연결 1.00 / 규격 6개 항목 모두 충족\n", + " P-0003 유압밸브 -> D-0004(가상정밀(주)): 설명 유사도 0.47, 규격 적합성 1.00, 그래프 연결 1.00 / 규격 6개 항목 모두 충족\n", + " P-0004 베어링 -> D-0007(가상정밀(주)): 설명 유사도 0.54, 규격 적합성 1.00, 그래프 연결 1.00 / 규격 6개 항목 모두 충족\n", + "※ 추천은 후보 제시이며 규격 적합성 최종 판단과 계약 의뢰는 기술검토관·계약 담당자가 결정한다.\n", + "```\n", + "
\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 05. 공급망 붕괴 위험도(Supply Chain Risk Score) 및 조기경보 산출 엔진 개발\n", + "**소요시간**: 약 3.0시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "공급망 붕괴 위험도 및 조기경보 산출 엔진을 개발한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **공급망 붕괴 위험도(Supply Chain Risk Score)와 월별 지연 추이 기반 조기경보를 산출하는 엔진을 만들고 입력-분석-검토-결과 4개 화면으로 구성한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **공급선 수, 해외 조달, 리드타임, 납기 지연률, 단종 상태 등 12개 지표로 로지스틱 회귀 위험도(0~100점)를 학습하고 별도 가상 부품군에서 AUC와 등급별 실제 단절률 확인하고 유효성을 점검하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `src/ui/prc07_app.py` 형태의 결과물을 완성한다.\n", + "\n", + "### 🔍 평가 및 검증 기준 (PASS 조건)\n", + "#### 미션 판정\n", + "\n", + "**평가 구분:** 필수\n", + "\n", + "**핵심 평가영역:** 군 실무자 친화적 디자인 및 조작 편의성\n", + "\n", + "#### 미션 PASS 조건\n", + "\n", + "- 정량 기준: 핵심 화면 4종(입력-분석-검토-결과) 구현 완료\n", + "\n", + "\n", + "- 진행 기준: UI 프로토타입 로컬 서버 구동 및 전체 페이지 네비게이션 확인\n", + "\n", + "\n", + "- 검증 방법: 사용성 평가(UX Walkthrough) 및 인터랙션 테스트\n", + "\n", + "\n", + "#### 채점자 판정 메모\n", + "\n", + "> 단순 코드 실행 여부뿐만 아니라 출력된 수치와 근거 자료가 군 실무 요구사항에 부합하는지 면밀히 확인한다.\n", + "\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "\"\"\"12개 공급망 지표로 부품별 공급망 붕괴 위험도(0~100점)를 로지스틱 회귀로 산출하고 월별 납기 지연 추이의 EWMA로 조기경보를 내는 엔진을 만든 뒤, 입력-분석-검토-결과 4개 화면(텍스트 패널과 그래프)을 구성해 별도 가상 부품군에서 성능을 검증한다.\"\"\"\n", + "import numpy as np\n", + "import pandas as pd\n", + "import matplotlib.pyplot as plt\n", + "from sklearn.linear_model import LogisticRegression\n", + "from sklearn.pipeline import make_pipeline\n", + "from sklearn.preprocessing import StandardScaler\n", + "from sklearn.metrics import roc_auc_score, brier_score_loss\n", + "\n", + "# ---------------------------------------------------------------------------\n", + "# [교육용가상자료] 가상 무기체계 부품·공급망 데이터 생성기 (실제 체계·업체·국가·단가 정보 없음)\n", + "# 부품 100종(해외 조달 약 80%)의 공급망 속성과, 국내 부품 카탈로그(과거 승인된 국산 대체품 +\n", + "# 설명은 비슷하지만 규격이 맞지 않는 유사 후보)를 함께 만든다.\n", + "# ---------------------------------------------------------------------------\n", + "COUNTRIES = [\"가상국가A\", \"가상국가B\", \"가상국가C\", \"국내\"]\n", + "CATS = {\n", + " \"전원모듈\": dict(words=[\"DC-DC\", \"컨버터\", \"절연형\", \"전원\", \"정출력\"], syn={\"컨버터\": \"변환장치\", \"절연형\": \"아이솔레이션\", \"정출력\": \"안정화\"}, std=\"MIL-STD-704\", iface=\"단자대\"),\n", + " \"커넥터\": dict(words=[\"원형\", \"커넥터\", \"방수형\", \"다핀\", \"나사체결\"], syn={\"커넥터\": \"접속자\", \"방수형\": \"밀폐형\", \"나사체결\": \"스크루결합\"}, std=\"MIL-DTL-38999\", iface=\"원형핀\"),\n", + " \"유압밸브\": dict(words=[\"유압\", \"솔레노이드\", \"밸브\", \"방향제어\", \"4포트\"], syn={\"밸브\": \"개폐장치\", \"방향제어\": \"유로전환\", \"솔레노이드\": \"전자식\"}, std=\"SAE-AS5440\", iface=\"플랜지\"),\n", + " \"베어링\": dict(words=[\"볼\", \"베어링\", \"고속\", \"밀봉형\", \"회전축\"], syn={\"베어링\": \"축받이\", \"밀봉형\": \"실드형\", \"고속\": \"고회전\"}, std=\"ISO-492\", iface=\"축결합\"),\n", + " \"광학렌즈\": dict(words=[\"적외선\", \"렌즈\", \"게르마늄\", \"반사방지\", \"집광\"], syn={\"렌즈\": \"광학창\", \"집광\": \"초점형성\", \"반사방지\": \"AR코팅\"}, std=\"MIL-PRF-13830\", iface=\"마운트\"),\n", + " \"자이로센서\": dict(words=[\"광섬유\", \"자이로\", \"각속도\", \"센서\", \"관성\"], syn={\"센서\": \"감지기\", \"각속도\": \"회전율\", \"관성\": \"INS용\"}, std=\"IEEE-952\", iface=\"직렬통신\"),\n", + " \"RF증폭기\": dict(words=[\"RF\", \"전력증폭기\", \"광대역\", \"모듈\", \"송신\"], syn={\"전력증폭기\": \"파워앰프\", \"송신\": \"출력단\", \"광대역\": \"와이드밴드\"}, std=\"MIL-STD-461\", iface=\"SMA\"),\n", + " \"제어보드\": dict(words=[\"FPGA\", \"제어보드\", \"임베디드\", \"연산\", \"입출력\"], syn={\"제어보드\": \"컨트롤카드\", \"연산\": \"신호처리\", \"임베디드\": \"내장형\"}, std=\"VITA-46\", iface=\"백플레인\"),\n", + "}\n", + "V_OPTS, W_OPTS = [12, 24, 28], [25, 50, 100]\n", + "VENDORS = [\"가상정밀(주)\", \"가상전자(주)\", \"가상유공압(주)\", \"가상광학(주)\", \"가상테크(주)\", \"가상시스템(주)\"]\n", + "\n", + "\n", + "def describe(cat, words, s):\n", + " # [TODO : 학생 실습 5] 공급망 붕괴 위험도(Supply Chain Risk Score) 및 조기경보 산출 엔진 개발 관련 핵심 로직을 구현하세요.\n", + " # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n", + " # <<< 여기에 코드를 작성하세요 >>>\n", + " pass # 작성 후 pass를 제거하세요.\n", + "def _sig(x):\n", + " # [TODO : 학생 실습 5] 공급망 붕괴 위험도(Supply Chain Risk Score) 및 조기경보 산출 엔진 개발 관련 핵심 로직을 구현하세요.\n", + " # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n", + " # <<< 여기에 코드를 작성하세요 >>>\n", + " pass # 작성 후 pass를 제거하세요.\n", + "def make_parts(n=100, seed=7):\n", + " \"\"\"부품 목록(공급망 속성 포함)과 국내 카탈로그를 만든다. 반환: (parts DataFrame, catalog DataFrame)\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " cats, rows, cat_rows = list(CATS), [], []\n", + " for i in range(n):\n", + " cat = cats[i % len(cats)]; c = CATS[cat]\n", + " spec = {\"v\": int(rng.choice(V_OPTS)), \"w\": int(rng.choice(W_OPTS)), \"tmin\": int(rng.choice([-55, -40])),\n", + " \"tmax\": int(rng.choice([85, 125])), \"size_mm\": float(round(rng.uniform(20, 120), 1)), \"std\": c[\"std\"]}\n", + " words = [str(x) for x in rng.permutation(c[\"words\"])[:4]]\n", + " country = str(rng.choice(COUNTRIES, p=[0.35, 0.3, 0.15, 0.2]))\n", + " overseas = country != \"국내\"\n", + " n_sup = int(rng.choice([1, 2, 3], p=[0.45, 0.35, 0.2])) if overseas else int(rng.choice([1, 2, 3]))\n", + " lead = float(round(rng.gamma(3, 45 if overseas else 25) + 20))\n", + " eol = str(rng.choice([\"정상\", \"단종예고\", \"단종\"], p=[0.7, 0.2, 0.1]))\n", + " crit = str(rng.choice([\"A\", \"B\", \"C\"], p=[0.3, 0.4, 0.3]))\n", + " z = (-2.6 + 1.1 * (n_sup == 1) + 0.9 * overseas + 0.006 * (lead - 100) + 1.2 * (eol == \"단종예고\")\n", + " + 2.0 * (eol == \"단종\") + rng.normal(0, 0.6))\n", + " p_late = float(np.clip(_sig(z) * 0.8 + 0.05, 0, 0.9))\n", + " late = int(rng.binomial(12, p_late))\n", + " demand = int(rng.integers(20, 400))\n", + " rows.append({\"part_no\": f\"P-{i + 1:04d}\", \"category\": cat, \"desc\": describe(cat, words, spec), **spec,\n", + " \"iface\": c[\"iface\"], \"country\": country, \"overseas\": overseas, \"n_suppliers\": n_sup,\n", + " \"lead_time_days\": lead, \"orders_12m\": 12, \"late_orders\": late,\n", + " \"avg_delay_days\": float(round(late and rng.gamma(2, 12 + 20 * p_late), 1)),\n", + " \"eol_status\": eol, \"ltb_months\": int(rng.integers(1, 25)) if eol == \"단종예고\" else None,\n", + " \"price_change_pct\": float(round(rng.normal(4 + 12 * p_late, 8), 1)), \"criticality\": crit,\n", + " \"annual_demand\": demand, \"stock_qty\": int(rng.integers(0, demand)),\n", + " \"defect_rate\": float(round(abs(rng.normal(0.8 + 2 * p_late, 0.8)), 2)),\n", + " \"export_license\": bool(overseas and rng.random() < 0.35),\n", + " \"disrupted_next12m\": bool(rng.random() < _sig(z + 0.4))})\n", + " if not overseas:\n", + " continue\n", + " # 과거 승인된 국산 대체품(정답): 동의어로 서술, 규격 호환(전력 여유 허용, 일부는 크기 차이가 커 장착 검토 필요)\n", + " eq_words = [c[\"syn\"].get(w, w) for w in words]\n", + " hard = rng.random() < 0.15 # 서술이 많이 다른 사례\n", + " if hard:\n", + " eq_words = [c[\"syn\"].get(w, w) for w in c[\"words\"] if w not in words][:1] + eq_words[1:3]\n", + " eq = {**spec, \"w\": spec[\"w\"] if rng.random() < 0.7 else W_OPTS[min(W_OPTS.index(spec[\"w\"]) + 1, 2)],\n", + " \"size_mm\": float(round(spec[\"size_mm\"] * (rng.uniform(1.11, 1.15) if rng.random() < 0.12 else rng.uniform(0.95, 1.05)), 1))}\n", + " cat_rows.append({\"cand_no\": f\"D-{len(cat_rows) + 1:04d}\", \"category\": cat, \"desc\": describe(cat, eq_words, eq), **eq,\n", + " \"iface\": c[\"iface\"], \"vendor\": str(rng.choice(VENDORS)), \"approved_for\": f\"P-{i + 1:04d}\", \"kind\": \"equivalent\"})\n", + " # 유사 오답 후보: 설명은 거의 같지만 전압이 다르거나(5V/48V) 상용 온도 등급(0~70C)\n", + " for kind in (\"volt_mismatch\", \"temp_mismatch\"):\n", + " d = dict(spec)\n", + " if kind == \"volt_mismatch\":\n", + " d[\"v\"] = int(rng.choice([5, 48])) # 체계에서 쓰지 않는 전압\n", + " else:\n", + " d[\"tmin\"], d[\"tmax\"] = 0, 70\n", + " d[\"size_mm\"] = float(round(spec[\"size_mm\"] * rng.uniform(0.95, 1.05), 1))\n", + " cat_rows.append({\"cand_no\": f\"D-{len(cat_rows) + 1:04d}\", \"category\": cat, \"desc\": describe(cat, words, d), **d,\n", + " \"iface\": c[\"iface\"], \"vendor\": str(rng.choice(VENDORS)), \"approved_for\": None, \"kind\": kind})\n", + " parts = pd.DataFrame(rows)\n", + " catalog = pd.DataFrame(cat_rows).sample(frac=1, random_state=seed).reset_index(drop=True)\n", + " return parts, catalog\n", + "# ---------------------------------------------------------------------------\n", + "\n", + "# ---- 공급망 붕괴 위험도(Supply Chain Risk Score) 공통 함수: 로지스틱 회귀 + 월별 지연 추이 조기경보 ----\n", + "RISK_FEATS = [\"n_suppliers\", \"overseas\", \"lead_time_days\", \"delay_rate\", \"avg_delay_days\", \"eol_ord\", \"ltb_short\", \"price_change_pct\",\n", + " \"crit_ord\", \"cover_gap_days\", \"defect_rate\", \"export_license\"]\n", + "FEAT_KO = {\"n_suppliers\": \"공급선 수\", \"overseas\": \"해외 조달\", \"lead_time_days\": \"리드타임\", \"delay_rate\": \"납기 지연률\", \"avg_delay_days\": \"평균 지연일\",\n", + " \"eol_ord\": \"단종 상태\", \"ltb_short\": \"최종구매 임박\", \"price_change_pct\": \"단가 변동\", \"crit_ord\": \"체계 중요도\",\n", + " \"cover_gap_days\": \"재고-리드타임 격차\", \"defect_rate\": \"입고 불량률\", \"export_license\": \"수출 허가 필요\"}\n", + "WARN_CUT = {\"경보\": 60, \"주의\": 35} # 위험도 점수(0~100) 등급 기준\n", + "\n", + "\n", + "def risk_frame(parts):\n", + " d = parts.copy()\n", + " d[\"delay_rate\"] = d[\"late_orders\"] / d[\"orders_12m\"].clip(lower=1)\n", + " d[\"eol_ord\"] = d[\"eol_status\"].map({\"정상\": 0, \"단종예고\": 1, \"단종\": 2})\n", + " d[\"ltb_short\"] = (d[\"ltb_months\"].fillna(99) <= 6).astype(int)\n", + " d[\"crit_ord\"] = d[\"criticality\"].map({\"C\": 0, \"B\": 1, \"A\": 2})\n", + " d[\"cover_gap_days\"] = d[\"stock_qty\"] / d[\"annual_demand\"].clip(lower=1) * 365 - d[\"lead_time_days\"]\n", + " for c in (\"overseas\", \"export_license\"):\n", + " d[c] = d[c].astype(int)\n", + " return d\n", + "\n", + "\n", + "def fit_risk(parts):\n", + " d = risk_frame(parts)\n", + " return make_pipeline(StandardScaler(), LogisticRegression(C=1.0, max_iter=500)).fit(d[RISK_FEATS], d[\"disrupted_next12m\"].astype(int))\n", + "\n", + "\n", + "def risk_score(model, parts):\n", + " \"\"\"붕괴 위험도 0~100점 = 다음 12개월 내 공급 단절 확률 x 100.\"\"\"\n", + " return model.predict_proba(risk_frame(parts)[RISK_FEATS])[:, 1] * 100\n", + "\n", + "\n", + "def drivers(model, parts, k=3):\n", + " \"\"\"부품별 위험 상승 요인 상위 k개: (표준화 값 x 회귀 계수)가 큰 순서.\"\"\"\n", + " sc, lr = model[0], model[-1]\n", + " z = sc.transform(risk_frame(parts)[RISK_FEATS]) * lr.coef_[0]\n", + " return [[FEAT_KO[RISK_FEATS[j]] for j in np.argsort(-row)[:k] if row[j] > 0] for row in z]\n", + "\n", + "\n", + "def monthly_delay(parts, seed=5):\n", + " \"\"\"[교육용가상자료] 부품별 최근 12개월 평균 납기 지연일. 단절될 부품은 단절 3~6개월 전부터 지연이 서서히 늘어난다.\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " out, onset = [], []\n", + " for _, p in parts.iterrows():\n", + " base = 3 + 0.15 * p[\"avg_delay_days\"]\n", + " s = base + rng.normal(0, 1.2, 12)\n", + " o = -1\n", + " if p[\"disrupted_next12m\"] and rng.random() < 0.7:\n", + " o = int(rng.integers(5, 9)) # 12개월 구간 내 상승 시작 월\n", + " s[o:] += np.linspace(1.5, 12, 12 - o) + rng.normal(0, 1.0, 12 - o)\n", + " out.append(s); onset.append(o)\n", + " return np.array(out), np.array(onset)\n", + "\n", + "\n", + "def ewma_alarm(series, base_n=5, lam=0.3, k=3.0):\n", + " \"\"\"처음 base_n개월을 기준선으로 삼아 EWMA가 기준선 + k x 표준편차를 넘는 첫 달(없으면 -1).\"\"\"\n", + " mu, sd = series[:base_n].mean(), series[:base_n].std() + 0.5\n", + " e = mu\n", + " for t in range(base_n, len(series)):\n", + " e = lam * series[t] + (1 - lam) * e\n", + " if e > mu + k * sd * np.sqrt(lam / (2 - lam)):\n", + " return t\n", + " return -1\n", + "\n", + "train, _ = make_parts(600, seed=11) # 학습용 가상 부품군\n", + "test, _ = make_parts(200, seed=7) # 검증용 별도 가상 부품군\n", + "model = fit_risk(train)\n", + "test = test.assign(score=risk_score(model, test), why=drivers(model, test))\n", + "y = test[\"disrupted_next12m\"].astype(int)\n", + "test[\"level\"] = np.where(test[\"score\"] >= WARN_CUT[\"경보\"], \"경보\", np.where(test[\"score\"] >= WARN_CUT[\"주의\"], \"주의\", \"정상\"))\n", + "\n", + "# ===== 화면 1. 입력: 입력 요약과 검증 =====\n", + "miss = int(test[[\"lead_time_days\", \"n_suppliers\", \"late_orders\"]].isna().sum().sum())\n", + "print(\"[화면 1. 입력] [교육용가상자료]\")\n", + "print(f\" 검증용 부품 {len(test)}종 / 입력 지표 {len(RISK_FEATS)}개 / 필수값 결측 {miss}건 / 학습 부품 {len(train)}종\")\n", + "print(f\" 해외 조달 {int(test['overseas'].sum())}종, 단종예고·단종 {int((test['eol_status'] != '정상').sum())}종, 단일 공급선 {int((test['n_suppliers'] == 1).sum())}종\")\n", + "\n", + "# ===== 화면 2. 분석: 위험도 분포와 성능 =====\n", + "auc = roc_auc_score(y, test[\"score\"])\n", + "print(\"\\n[화면 2. 분석] 위험도 점수 분포와 모델 성능\")\n", + "print(f\" ROC-AUC {auc:.3f}, Brier {brier_score_loss(y, test['score'] / 100):.3f} (단절 실제 비율 {y.mean():.0%})\")\n", + "print(test.groupby(\"level\").agg(부품수=(\"part_no\", \"size\"), 평균점수=(\"score\", \"mean\"), 실제단절률=(\"disrupted_next12m\", \"mean\")).reindex([\"경보\", \"주의\", \"정상\"]).round(2).to_string())\n", + "coef = pd.Series(model[-1].coef_[0], index=[FEAT_KO[f] for f in RISK_FEATS]).sort_values(ascending=False)\n", + "print(\" 위험 상승 요인(회귀 계수 상위 4):\", \", \".join(f\"{k} {v:+.2f}\" for k, v in coef.head(4).items()))\n", + "\n", + "# ===== 조기경보: 월별 지연 추이 =====\n", + "md, onset = monthly_delay(test)\n", + "alarm = np.array([ewma_alarm(s) for s in md])\n", + "dis = onset >= 0\n", + "det = (alarm[dis] >= 0).mean()\n", + "delay = (alarm[dis & (alarm >= 0)] - onset[dis & (alarm >= 0)]).mean()\n", + "fa = (alarm[~dis] >= 0).mean()\n", + "print(f\"\\n[조기경보] 지연 상승형 단절 부품 {int(dis.sum())}종 중 {det:.0%} 감지, 상승 시작 후 평균 {delay:.1f}개월 만에 경보, 정상 부품 오경보율 {fa:.1%}\")\n", + "\n", + "# ===== 화면 3. 검토: 경보 부품을 담당자가 확인 =====\n", + "rev = test[test[\"level\"] == \"경보\"].sort_values(\"score\", ascending=False)\n", + "print(f\"\\n[화면 3. 검토] 경보 {len(rev)}종 중 상위 5종 (담당자가 근거를 확인하고 조치 여부를 결정)\")\n", + "for _, r in rev.head(5).iterrows():\n", + " print(f\" {r['part_no']} {r['category']} {r['score']:.0f}점 | 요인: {', '.join(r['why']) or '-'} | 단종 {r['eol_status']}, 공급선 {r['n_suppliers']}개\")\n", + "\n", + "# ===== 화면 4. 결과: 요약과 내보내기 =====\n", + "top = rev.head(10)\n", + "print(\"\\n[화면 4. 결과]\")\n", + "print(f\" 경보 {len(rev)}종, 주의 {int((test['level'] == '주의').sum())}종, 정상 {int((test['level'] == '정상').sum())}종\")\n", + "print(f\" 경보 등급의 실제 단절 {int(rev['disrupted_next12m'].sum())}종({rev['disrupted_next12m'].mean():.0%}) vs 정상 등급 {test[test['level'] == '정상']['disrupted_next12m'].mean():.0%}\")\n", + "print(\" 내보내기 항목: 부품번호, 점수, 등급, 요인, 조기경보 월, 검토 상태(미검토)\")\n", + "print(\"화면 4종(입력-분석-검토-결과) 구현: 4/4 (목표 4종: 달성)\")\n", + "print(\"※ 점수는 우선순위를 정하는 참고값이며, 실제 단절 여부와 조치는 담당자가 확인한다.\")\n", + "\n", + "fig, ax = plt.subplots(2, 2, figsize=(11, 7))\n", + "ax[0, 0].bar([\"overseas\", \"single src\", \"EOL\"], [test[\"overseas\"].sum(), (test[\"n_suppliers\"] == 1).sum(), (test[\"eol_status\"] != \"정상\").sum()]); ax[0, 0].set_title(\"Screen 1: input summary (count)\")\n", + "ax[0, 1].hist([test[y == 0][\"score\"], test[y == 1][\"score\"]], bins=15, label=[\"no disruption\", \"disrupted\"], stacked=True); ax[0, 1].legend(); ax[0, 1].set_title(\"Screen 2: risk score distribution\")\n", + "i = int(np.where(dis & (alarm >= 0))[0][0]); ax[1, 0].plot(md[i], marker=\"o\"); ax[1, 0].axvline(alarm[i], color=\"r\", ls=\"--\"); ax[1, 0].set_title(\"Screen 3: early warning (delay days, red = alarm)\")\n", + "ax[1, 1].barh(top[\"part_no\"], top[\"score\"], color=\"tab:red\"); ax[1, 1].invert_yaxis(); ax[1, 1].set_title(\"Screen 4: top-10 alert parts\")\n", + "plt.tight_layout(); plt.show()\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 05 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + "[화면 1. 입력] [교육용가상자료]\n", + " 검증용 부품 200종 / 입력 지표 12개 / 필수값 결측 0건 / 학습 부품 600종\n", + " 해외 조달 159종, 단종예고·단종 60종, 단일 공급선 89종\n", + "\n", + "[화면 2. 분석] 위험도 점수 분포와 모델 성능\n", + " ROC-AUC 0.693, Brier 0.222 (단절 실제 비율 40%)\n", + " 부품수 평균점수 실제단절률\n", + "level \n", + "경보 51 76.75 0.59\n", + "주의 59 47.63 0.42\n", + "정상 90 22.39 0.27\n", + " 위험 상승 요인(회귀 계수 상위 4): 납기 지연률 +0.58, 리드타임 +0.50, 단종 상태 +0.48, 수출 허가 필요 +0.12\n", + "\n", + "[조기경보] 지연 상승형 단절 부품 49종 중 100% 감지, 상승 시작 후 평균 1.9개월 만에 경보, 정상 부품 오경보율 2.0%\n", + "\n", + "[화면 3. 검토] 경보 51종 중 상위 5종 (담당자가 근거를 확인하고 조치 여부를 결정)\n", + " P-0066 커넥터 96점 | 요인: 단종 상태, 납기 지연률, 리드타임 | 단종 단종, 공급선 1개\n", + " P-0080 제어보드 96점 | 요인: 단종 상태, 납기 지연률, 리드타임 | 단종 단종, 공급선 1개\n", + " P-0036 베어링 94점 | 요인: 단종 상태, 납기 지연률, 공급선 수 | 단종 단종, 공급선 1개\n", + " P-0003 유압밸브 94점 | 요인: 단종 상태, 납기 지연률, 리드타임 | 단종 단종, 공급선 1개\n", + " P-0140 베어링 94점 | 요인: 리드타임, 납기 지연률, 공급선 수 | 단종 정상, 공급선 1개\n", + "\n", + "[화면 4. 결과]\n", + " 경보 51종, 주의 59종, 정상 90종\n", + " 경보 등급의 실제 단절 30종(59%) vs 정상 등급 27%\n", + " 내보내기 항목: 부품번호, 점수, 등급, 요인, 조기경보 월, 검토 상태(미검토)\n", + "화면 4종(입력-분석-검토-결과) 구현: 4/4 (목표 4종: 달성)\n", + "※ 점수는 우선순위를 정하는 참고값이며, 실제 단절 여부와 조치는 담당자가 확인한다.\n", + "```\n", + "
\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 06. 부품 조달 위험 공급망 지도 및 대체선 추천 인터랙티브 대시보드 구현\n", + "**소요시간**: 약 2.5시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "부품 조달 위험 공급망 지도 및 대체선 추천 대시보드를 구현한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **부품 조달 위험 공급망 지도와 대체선 추천 대시보드에 입력 검증과 에러 핸들러를 붙여 깨진 입력에도 멈추지 않게 한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **국가와 부품 범주별 평균 위험을 보이는 공급망 지도, 리드타임과 지연률 산점도, 위험 상위 부품의 대체 후보 규격 적합성 패널을 가진 대시보드 작성하여 원인을 분석하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `src/utils/prc07_validator.py 및 에러 핸들러` 형태의 결과물을 완성한다.\n", + "\n", + "### 🔍 평가 및 검증 기준 (PASS 조건)\n", + "#### 미션 판정\n", + "\n", + "**평가 구분:** 필수\n", + "\n", + "**핵심 평가영역:** 사용자에게 알기 쉬운 에러 메시지 및 조치 가이드 제공\n", + "\n", + "#### 미션 PASS 조건\n", + "\n", + "- 정량 기준: 예외 상황 발생 시 크래시(Crash) 0건, 에러 로그 기록률 100%\n", + "\n", + "\n", + "- 진행 기준: 예외 주입 테스트 10종 통과 및 감사 로그 파일 생성\n", + "\n", + "\n", + "- 검증 방법: 비정상 데이터(결측, 특수문자, 대용량) 스트레스 테스트\n", + "\n", + "\n", + "#### 채점자 판정 메모\n", + "\n", + "> 단순 코드 실행 여부뿐만 아니라 출력된 수치와 근거 자료가 군 실무 요구사항에 부합하는지 면밀히 확인한다.\n", + "\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "\"\"\"조달 위험 공급망 지도(국가 x 부품 범주 위험 히트맵)와 대체선 추천 패널을 그리는 대시보드에 입력 검증기와 에러 핸들러를 붙이고, 깨진 입력 14종을 주입해 크래시 0건·에러 로그 기록률 100%·사용자용 조치 안내 문구를 확인한다.\"\"\"\n", + "import csv\n", + "import io\n", + "import json\n", + "import re\n", + "import numpy as np\n", + "import pandas as pd\n", + "import matplotlib.pyplot as plt\n", + "from sklearn.feature_extraction.text import TfidfVectorizer\n", + "from sklearn.metrics.pairwise import cosine_similarity\n", + "\n", + "# ---------------------------------------------------------------------------\n", + "# [교육용가상자료] 가상 무기체계 부품·공급망 데이터 생성기 (실제 체계·업체·국가·단가 정보 없음)\n", + "# 부품 100종(해외 조달 약 80%)의 공급망 속성과, 국내 부품 카탈로그(과거 승인된 국산 대체품 +\n", + "# 설명은 비슷하지만 규격이 맞지 않는 유사 후보)를 함께 만든다.\n", + "# ---------------------------------------------------------------------------\n", + "COUNTRIES = [\"가상국가A\", \"가상국가B\", \"가상국가C\", \"국내\"]\n", + "CATS = {\n", + " \"전원모듈\": dict(words=[\"DC-DC\", \"컨버터\", \"절연형\", \"전원\", \"정출력\"], syn={\"컨버터\": \"변환장치\", \"절연형\": \"아이솔레이션\", \"정출력\": \"안정화\"}, std=\"MIL-STD-704\", iface=\"단자대\"),\n", + " \"커넥터\": dict(words=[\"원형\", \"커넥터\", \"방수형\", \"다핀\", \"나사체결\"], syn={\"커넥터\": \"접속자\", \"방수형\": \"밀폐형\", \"나사체결\": \"스크루결합\"}, std=\"MIL-DTL-38999\", iface=\"원형핀\"),\n", + " \"유압밸브\": dict(words=[\"유압\", \"솔레노이드\", \"밸브\", \"방향제어\", \"4포트\"], syn={\"밸브\": \"개폐장치\", \"방향제어\": \"유로전환\", \"솔레노이드\": \"전자식\"}, std=\"SAE-AS5440\", iface=\"플랜지\"),\n", + " \"베어링\": dict(words=[\"볼\", \"베어링\", \"고속\", \"밀봉형\", \"회전축\"], syn={\"베어링\": \"축받이\", \"밀봉형\": \"실드형\", \"고속\": \"고회전\"}, std=\"ISO-492\", iface=\"축결합\"),\n", + " \"광학렌즈\": dict(words=[\"적외선\", \"렌즈\", \"게르마늄\", \"반사방지\", \"집광\"], syn={\"렌즈\": \"광학창\", \"집광\": \"초점형성\", \"반사방지\": \"AR코팅\"}, std=\"MIL-PRF-13830\", iface=\"마운트\"),\n", + " \"자이로센서\": dict(words=[\"광섬유\", \"자이로\", \"각속도\", \"센서\", \"관성\"], syn={\"센서\": \"감지기\", \"각속도\": \"회전율\", \"관성\": \"INS용\"}, std=\"IEEE-952\", iface=\"직렬통신\"),\n", + " \"RF증폭기\": dict(words=[\"RF\", \"전력증폭기\", \"광대역\", \"모듈\", \"송신\"], syn={\"전력증폭기\": \"파워앰프\", \"송신\": \"출력단\", \"광대역\": \"와이드밴드\"}, std=\"MIL-STD-461\", iface=\"SMA\"),\n", + " \"제어보드\": dict(words=[\"FPGA\", \"제어보드\", \"임베디드\", \"연산\", \"입출력\"], syn={\"제어보드\": \"컨트롤카드\", \"연산\": \"신호처리\", \"임베디드\": \"내장형\"}, std=\"VITA-46\", iface=\"백플레인\"),\n", + "}\n", + "V_OPTS, W_OPTS = [12, 24, 28], [25, 50, 100]\n", + "VENDORS = [\"가상정밀(주)\", \"가상전자(주)\", \"가상유공압(주)\", \"가상광학(주)\", \"가상테크(주)\", \"가상시스템(주)\"]\n", + "\n", + "\n", + "def describe(cat, words, s):\n", + " # [TODO : 학생 실습 6] 부품 조달 위험 공급망 지도 및 대체선 추천 인터랙티브 대시보드 구현 관련 핵심 로직을 구현하세요.\n", + " # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n", + " # <<< 여기에 코드를 작성하세요 >>>\n", + " pass # 작성 후 pass를 제거하세요.\n", + "def _sig(x):\n", + " # [TODO : 학생 실습 6] 부품 조달 위험 공급망 지도 및 대체선 추천 인터랙티브 대시보드 구현 관련 핵심 로직을 구현하세요.\n", + " # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n", + " # <<< 여기에 코드를 작성하세요 >>>\n", + " pass # 작성 후 pass를 제거하세요.\n", + "def make_parts(n=100, seed=7):\n", + " \"\"\"부품 목록(공급망 속성 포함)과 국내 카탈로그를 만든다. 반환: (parts DataFrame, catalog DataFrame)\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " cats, rows, cat_rows = list(CATS), [], []\n", + " for i in range(n):\n", + " cat = cats[i % len(cats)]; c = CATS[cat]\n", + " spec = {\"v\": int(rng.choice(V_OPTS)), \"w\": int(rng.choice(W_OPTS)), \"tmin\": int(rng.choice([-55, -40])),\n", + " \"tmax\": int(rng.choice([85, 125])), \"size_mm\": float(round(rng.uniform(20, 120), 1)), \"std\": c[\"std\"]}\n", + " words = [str(x) for x in rng.permutation(c[\"words\"])[:4]]\n", + " country = str(rng.choice(COUNTRIES, p=[0.35, 0.3, 0.15, 0.2]))\n", + " overseas = country != \"국내\"\n", + " n_sup = int(rng.choice([1, 2, 3], p=[0.45, 0.35, 0.2])) if overseas else int(rng.choice([1, 2, 3]))\n", + " lead = float(round(rng.gamma(3, 45 if overseas else 25) + 20))\n", + " eol = str(rng.choice([\"정상\", \"단종예고\", \"단종\"], p=[0.7, 0.2, 0.1]))\n", + " crit = str(rng.choice([\"A\", \"B\", \"C\"], p=[0.3, 0.4, 0.3]))\n", + " z = (-2.6 + 1.1 * (n_sup == 1) + 0.9 * overseas + 0.006 * (lead - 100) + 1.2 * (eol == \"단종예고\")\n", + " + 2.0 * (eol == \"단종\") + rng.normal(0, 0.6))\n", + " p_late = float(np.clip(_sig(z) * 0.8 + 0.05, 0, 0.9))\n", + " late = int(rng.binomial(12, p_late))\n", + " demand = int(rng.integers(20, 400))\n", + " rows.append({\"part_no\": f\"P-{i + 1:04d}\", \"category\": cat, \"desc\": describe(cat, words, spec), **spec,\n", + " \"iface\": c[\"iface\"], \"country\": country, \"overseas\": overseas, \"n_suppliers\": n_sup,\n", + " \"lead_time_days\": lead, \"orders_12m\": 12, \"late_orders\": late,\n", + " \"avg_delay_days\": float(round(late and rng.gamma(2, 12 + 20 * p_late), 1)),\n", + " \"eol_status\": eol, \"ltb_months\": int(rng.integers(1, 25)) if eol == \"단종예고\" else None,\n", + " \"price_change_pct\": float(round(rng.normal(4 + 12 * p_late, 8), 1)), \"criticality\": crit,\n", + " \"annual_demand\": demand, \"stock_qty\": int(rng.integers(0, demand)),\n", + " \"defect_rate\": float(round(abs(rng.normal(0.8 + 2 * p_late, 0.8)), 2)),\n", + " \"export_license\": bool(overseas and rng.random() < 0.35),\n", + " \"disrupted_next12m\": bool(rng.random() < _sig(z + 0.4))})\n", + " if not overseas:\n", + " continue\n", + " # 과거 승인된 국산 대체품(정답): 동의어로 서술, 규격 호환(전력 여유 허용, 일부는 크기 차이가 커 장착 검토 필요)\n", + " eq_words = [c[\"syn\"].get(w, w) for w in words]\n", + " hard = rng.random() < 0.15 # 서술이 많이 다른 사례\n", + " if hard:\n", + " eq_words = [c[\"syn\"].get(w, w) for w in c[\"words\"] if w not in words][:1] + eq_words[1:3]\n", + " eq = {**spec, \"w\": spec[\"w\"] if rng.random() < 0.7 else W_OPTS[min(W_OPTS.index(spec[\"w\"]) + 1, 2)],\n", + " \"size_mm\": float(round(spec[\"size_mm\"] * (rng.uniform(1.11, 1.15) if rng.random() < 0.12 else rng.uniform(0.95, 1.05)), 1))}\n", + " cat_rows.append({\"cand_no\": f\"D-{len(cat_rows) + 1:04d}\", \"category\": cat, \"desc\": describe(cat, eq_words, eq), **eq,\n", + " \"iface\": c[\"iface\"], \"vendor\": str(rng.choice(VENDORS)), \"approved_for\": f\"P-{i + 1:04d}\", \"kind\": \"equivalent\"})\n", + " # 유사 오답 후보: 설명은 거의 같지만 전압이 다르거나(5V/48V) 상용 온도 등급(0~70C)\n", + " for kind in (\"volt_mismatch\", \"temp_mismatch\"):\n", + " d = dict(spec)\n", + " if kind == \"volt_mismatch\":\n", + " d[\"v\"] = int(rng.choice([5, 48])) # 체계에서 쓰지 않는 전압\n", + " else:\n", + " d[\"tmin\"], d[\"tmax\"] = 0, 70\n", + " d[\"size_mm\"] = float(round(spec[\"size_mm\"] * rng.uniform(0.95, 1.05), 1))\n", + " cat_rows.append({\"cand_no\": f\"D-{len(cat_rows) + 1:04d}\", \"category\": cat, \"desc\": describe(cat, words, d), **d,\n", + " \"iface\": c[\"iface\"], \"vendor\": str(rng.choice(VENDORS)), \"approved_for\": None, \"kind\": kind})\n", + " parts = pd.DataFrame(rows)\n", + " catalog = pd.DataFrame(cat_rows).sample(frac=1, random_state=seed).reset_index(drop=True)\n", + " return parts, catalog\n", + "# ---------------------------------------------------------------------------\n", + "\n", + "# ---- 대체 부품 추천 공통 함수: 텍스트 유사도 + 규격 적합성 + 지식 그래프 이웃 유사도 ----\n", + "WEIGHTS = {\"text\": 0.35, \"spec\": 0.45, \"graph\": 0.20}\n", + "\n", + "\n", + "def spec_fit(p, c):\n", + " \"\"\"규격 적합성 0~1: 6개 조건 충족 비율. 전압·온도 한계(필수 조건)를 못 맞추면 점수를 절반으로 깎는다.\"\"\"\n", + " conds = [p[\"v\"] == c[\"v\"], c[\"w\"] >= p[\"w\"], c[\"tmin\"] <= p[\"tmin\"], c[\"tmax\"] >= p[\"tmax\"],\n", + " abs(c[\"size_mm\"] - p[\"size_mm\"]) / p[\"size_mm\"] <= 0.10, p[\"iface\"] == c[\"iface\"]]\n", + " s = sum(conds) / len(conds)\n", + " return (s * 0.5 if not (conds[0] and conds[2] and conds[3]) else s), conds\n", + "\n", + "\n", + "def graph_nodes(row):\n", + " \"\"\"지식 그래프에서 부품 노드와 연결된 이웃 노드 집합(범주·표준·인터페이스·기능어·등급).\"\"\"\n", + " words = set(row[\"desc\"].split()[1:-4])\n", + " grade = \"군용등급\" if row[\"tmin\"] <= -40 else \"상용등급\"\n", + " return {f\"cat:{row['category']}\", f\"std:{row['std']}\", f\"if:{row['iface']}\", f\"grade:{grade}\", f\"v:{row['v']}\"} | {f\"w:{w}\" for w in words}\n", + "\n", + "\n", + "def build_graph(catalog):\n", + " \"\"\"동의어 사전을 그래프 간선(기능어 = 동의어)으로 넣어 서술이 달라도 같은 기능 노드로 연결되게 한다.\"\"\"\n", + " syn = {f\"w:{b}\": f\"w:{a}\" for c in CATS.values() for a, b in c[\"syn\"].items()}\n", + " canon = lambda nodes: {syn.get(x, x) for x in nodes}\n", + " return {\"canon\": canon, \"nodes\": [canon(graph_nodes(r)) for _, r in catalog.iterrows()]}\n", + "\n", + "\n", + "def graph_sim(g, pn, cn):\n", + " \"\"\"두 부품이 공유하는 이웃 노드 비율(Jaccard).\"\"\"\n", + " pn = g[\"canon\"](pn)\n", + " return len(pn & cn) / max(len(pn | cn), 1)\n", + "\n", + "\n", + "def score_table(parts_df, catalog):\n", + " \"\"\"해외 부품 x 같은 범주 국내 후보 쌍마다 텍스트·규격·그래프 점수를 계산한 표(가중치 적용 전).\"\"\"\n", + " vec = TfidfVectorizer(token_pattern=r\"[^ ]+\").fit(list(parts_df[\"desc\"]) + list(catalog[\"desc\"]))\n", + " text = cosine_similarity(vec.transform(parts_df[\"desc\"]), vec.transform(catalog[\"desc\"]))\n", + " g = build_graph(catalog)\n", + " rows = []\n", + " for i, (_, p) in enumerate(parts_df.iterrows()):\n", + " pn = graph_nodes(p)\n", + " for j, (_, c) in enumerate(catalog.iterrows()):\n", + " if c[\"category\"] != p[\"category\"]:\n", + " continue\n", + " sf, conds = spec_fit(p, c)\n", + " rows.append({\"part_no\": p[\"part_no\"], \"cand_no\": c[\"cand_no\"], \"vendor\": c[\"vendor\"], \"text\": text[i, j],\n", + " \"spec\": sf, \"graph\": graph_sim(g, pn, g[\"nodes\"][j]),\n", + " \"unmet\": \",\".join(n for n, ok in zip([\"전압\", \"전력\", \"저온\", \"고온\", \"크기\", \"인터페이스\"], conds) if not ok) or \"-\",\n", + " \"kind\": c[\"kind\"], \"correct\": c[\"approved_for\"] == p[\"part_no\"]})\n", + " return pd.DataFrame(rows)\n", + "\n", + "\n", + "def recommend(table, weights=None, top=3):\n", + " \"\"\"가중합 점수로 부품별 상위 top개 후보를 고른다.\"\"\"\n", + " w = weights or WEIGHTS\n", + " t = table.assign(score=w[\"text\"] * table[\"text\"] + w[\"spec\"] * table[\"spec\"] + w[\"graph\"] * table[\"graph\"])\n", + " return t.sort_values([\"part_no\", \"score\"], ascending=[True, False]).groupby(\"part_no\").head(top)\n", + "\n", + "\n", + "def hit_rate(recs, k=1):\n", + " \"\"\"과거 승인 이력(정답 대체품)이 상위 k개 안에 들어간 부품 비율.\"\"\"\n", + " return recs.groupby(\"part_no\").apply(lambda d: d[\"correct\"].head(k).any(), include_groups=False).mean()\n", + "\n", + "NEED = {\"part_no\": str, \"category\": str, \"country\": str, \"n_suppliers\": float, \"lead_time_days\": float, \"late_orders\": float,\n", + " \"orders_12m\": float, \"eol_status\": str, \"annual_demand\": float, \"stock_qty\": float, \"desc\": str, \"v\": float, \"w\": float,\n", + " \"tmin\": float, \"tmax\": float, \"size_mm\": float, \"std\": str, \"iface\": str}\n", + "RANGE = {\"n_suppliers\": (1, 10), \"lead_time_days\": (1, 1000), \"late_orders\": (0, 100), \"orders_12m\": (1, 100), \"annual_demand\": (1, 100000), \"stock_qty\": (0, 100000)}\n", + "LOG = []\n", + "\n", + "\n", + "class UserError(Exception):\n", + " \"\"\"사용자에게 보여 줄 메시지와 조치 가이드를 함께 가진 예외.\"\"\"\n", + "\n", + " def __init__(self, code, msg, action):\n", + " super().__init__(msg); self.code, self.msg, self.action = code, msg, action\n", + "\n", + "\n", + "def log(level, code, detail):\n", + " LOG.append({\"seq\": len(LOG) + 1, \"level\": level, \"code\": code, \"detail\": str(detail)[:80]})\n", + "\n", + "\n", + "def read_input(src):\n", + " \"\"\"입력(CSV 문자열 / JSON 문자열 / DataFrame)을 DataFrame으로 읽는다. 실패하면 UserError.\"\"\"\n", + " if src is None:\n", + " raise UserError(\"E01\", \"입력이 비어 있습니다.\", \"파일을 다시 선택해 주세요.\")\n", + " if isinstance(src, pd.DataFrame):\n", + " df = src.copy()\n", + " elif isinstance(src, str):\n", + " if not src.strip():\n", + " raise UserError(\"E02\", \"빈 파일입니다.\", \"내용이 있는 파일인지 확인해 주세요.\")\n", + " try:\n", + " df = pd.DataFrame(json.loads(src)) if src.lstrip()[0] in \"[{\" else pd.DataFrame(list(csv.DictReader(io.StringIO(src))))\n", + " except Exception as e:\n", + " raise UserError(\"E03\", \"파일 형식을 읽을 수 없습니다.\", \"CSV(쉼표 구분) 또는 JSON(목록) 형식으로 저장했는지 확인해 주세요.\") from e\n", + " else:\n", + " raise UserError(\"E04\", f\"지원하지 않는 입력 형식입니다({type(src).__name__}).\", \"CSV 또는 JSON 파일을 사용해 주세요.\")\n", + " if df.empty:\n", + " raise UserError(\"E02\", \"읽은 행이 0건입니다.\", \"머리글만 있는 파일이 아닌지 확인해 주세요.\")\n", + " return df\n", + "\n", + "\n", + "def validate_table(df):\n", + " \"\"\"열 점검 -> 행 단위 점검. 문제 행은 격리하고 나머지로 계속 진행한다. 반환: (정상 행, 격리 내역)\"\"\"\n", + " miss = [c for c in NEED if c not in df.columns]\n", + " if miss:\n", + " raise UserError(\"E10\", f\"필수 열이 없습니다: {', '.join(miss[:4])}{' 외' if len(miss) > 4 else ''}\", \"열 이름이 양식(데이터 딕셔너리)과 같은지 확인해 주세요.\")\n", + " d = df.copy(); bad = {}\n", + " for c, t in NEED.items():\n", + " if t is float:\n", + " conv = pd.to_numeric(d[c], errors=\"coerce\")\n", + " for i in d.index[conv.isna()]:\n", + " bad.setdefault(i, f\"{c}: 숫자가 아님/누락\")\n", + " d[c] = conv\n", + " for c, (lo, hi) in RANGE.items():\n", + " for i in d.index[(d[c] < lo) | (d[c] > hi)]:\n", + " bad.setdefault(i, f\"{c}: 허용 범위({lo}~{hi}) 밖\")\n", + " for i in d.index[d[\"country\"].isin(COUNTRIES) == False]:\n", + " bad.setdefault(i, \"country: 허용되지 않은 값\")\n", + " for i in d.index[d[\"part_no\"].duplicated(keep=\"first\")]:\n", + " bad.setdefault(i, \"part_no: 중복\")\n", + " for i in d.index[~d[\"part_no\"].astype(str).str.match(r\"^P-\\d{4}$\")]:\n", + " bad.setdefault(i, \"part_no: 형식 오류\")\n", + " if len(bad) == len(d):\n", + " raise UserError(\"E11\", \"사용할 수 있는 행이 없습니다.\", \"격리 사유를 확인해 값을 고친 뒤 다시 올려 주세요.\")\n", + " return d.drop(index=list(bad)), bad\n", + "\n", + "\n", + "def risk_points(d):\n", + " rate = d[\"late_orders\"] / d[\"orders_12m\"]\n", + " cover = d[\"stock_qty\"] / d[\"annual_demand\"] * 365 - d[\"lead_time_days\"]\n", + " return (30 * (d[\"n_suppliers\"] == 1) + 25 * np.clip(rate, 0, 1) + 20 * (d[\"eol_status\"] != \"정상\") + 15 * (cover < 0) + 10 * (d[\"country\"] != \"국내\")).round(1)\n", + "\n", + "\n", + "def dashboard(df, catalog, draw=True):\n", + " d, bad = validate_table(read_input(df))\n", + " d = d.assign(risk=risk_points(d))\n", + " heat = d.pivot_table(index=\"country\", columns=\"category\", values=\"risk\", aggfunc=\"mean\").round(1)\n", + " over = d[d[\"country\"] != \"국내\"].sort_values(\"risk\", ascending=False).head(6)\n", + " vec = TfidfVectorizer(token_pattern=r\"[^ ]+\").fit(list(d[\"desc\"]) + list(catalog[\"desc\"]))\n", + " alt = []\n", + " for _, p in over.iterrows():\n", + " cs = catalog[catalog[\"category\"] == p[\"category\"]]\n", + " if cs.empty:\n", + " alt.append((p[\"part_no\"], \"후보 없음\", 0.0)); continue\n", + " fit = np.array([spec_fit(p, c)[0] for _, c in cs.iterrows()])\n", + " sc = 0.45 * fit + 0.35 * cosine_similarity(vec.transform([p[\"desc\"]]), vec.transform(cs[\"desc\"]))[0]\n", + " j = int(np.argmax(sc)); alt.append((p[\"part_no\"], cs.iloc[j][\"cand_no\"], float(fit[j])))\n", + " if draw:\n", + " fig, ax = plt.subplots(1, 3, figsize=(13, 4))\n", + " im = ax[0].imshow(heat.fillna(0).to_numpy(), cmap=\"Reds\"); ax[0].set_xticks(range(heat.shape[1]), [f\"c{i + 1}\" for i in range(heat.shape[1])]); ax[0].set_yticks(range(heat.shape[0]), [\"A\", \"B\", \"C\", \"domestic\"][:heat.shape[0]])\n", + " ax[0].set_title(\"Supply risk map (country x category)\"); plt.colorbar(im, ax=ax[0])\n", + " ax[1].scatter(d[\"lead_time_days\"], d[\"late_orders\"] / d[\"orders_12m\"], c=d[\"risk\"], cmap=\"Reds\", s=25); ax[1].set_xlabel(\"lead time (days)\"); ax[1].set_ylabel(\"late order rate\"); ax[1].set_title(\"Lead time vs delay\")\n", + " ax[2].barh([a[0] for a in alt], [a[2] for a in alt], color=\"tab:blue\"); ax[2].invert_yaxis(); ax[2].set_xlim(0, 1); ax[2].set_title(\"Alternative spec fit (top-6 risk)\")\n", + " plt.tight_layout(); plt.show()\n", + " return heat, alt, bad\n", + "\n", + "\n", + "def safe_dashboard(case, src, catalog):\n", + " \"\"\"모든 예외를 잡아 크래시 없이 사용자 메시지·조치 안내를 돌려주고 에러 로그를 남긴다.\"\"\"\n", + " try:\n", + " heat, alt, bad = dashboard(src, catalog, draw=False)\n", + " for i, why in bad.items():\n", + " log(\"WARN\", \"W01\", f\"{case}: 행 {i} 격리 - {why}\")\n", + " return {\"case\": case, \"ok\": True, \"msg\": f\"표시 완료(격리 {len(bad)}행)\" if bad else \"표시 완료\", \"action\": \"-\", \"quarantined\": len(bad)}\n", + " except UserError as e:\n", + " log(\"ERROR\", e.code, f\"{case}: {e.msg}\")\n", + " return {\"case\": case, \"ok\": False, \"msg\": e.msg, \"action\": e.action, \"quarantined\": 0}\n", + " except Exception as e: # 예상하지 못한 오류도 크래시 대신 안내로 변환\n", + " log(\"ERROR\", \"E99\", f\"{case}: {type(e).__name__} {e}\")\n", + " return {\"case\": case, \"ok\": False, \"msg\": \"예상하지 못한 오류가 발생했습니다.\", \"action\": \"오류 코드 E99와 입력 파일을 담당자에게 전달해 주세요.\", \"quarantined\": 0}\n", + "\n", + "\n", + "parts, catalog = make_parts(100, seed=7)\n", + "base = parts.copy()\n", + "csv_ok = base.to_csv(index=False)\n", + "\n", + "def mut(**kw):\n", + " d = base.copy()\n", + " for k, v in kw.items():\n", + " d[k] = d[k].astype(object); d.loc[d.index[:3], k] = v\n", + " return d\n", + "\n", + "cases = [\n", + " (\"정상 입력\", base), (\"정상 CSV 문자열\", csv_ok), (\"입력 없음(None)\", None), (\"빈 문자열\", \" \"), (\"깨진 JSON\", '[{\"part_no\": \"P-0001\", '),\n", + " (\"지원하지 않는 형식(정수)\", 12345), (\"머리글만 있는 CSV\", \",\".join(base.columns) + \"\\n\"), (\"필수 열 누락\", base.drop(columns=[\"lead_time_days\", \"desc\"])),\n", + " (\"숫자 열에 문자\", mut(lead_time_days=\"미정\")), (\"음수 리드타임\", mut(lead_time_days=-5)), (\"공급선 수 결측\", mut(n_suppliers=np.nan)),\n", + " (\"허용되지 않은 국가\", mut(country=\"미상\")), (\"부품번호 중복·형식 오류\", mut(part_no=\"P-9\")), (\"연간 소요량 0(나눗셈 위험)\", mut(annual_demand=0)),\n", + " (\"극단값(1e12)\", mut(stock_qty=1e12)), (\"전부 불량 행\", base.assign(lead_time_days=-1)),\n", + "]\n", + "res = [safe_dashboard(n, s, catalog) for n, s in cases]\n", + "print(f\"[에러 핸들러 시험] 입력 {len(res)}종 실행\")\n", + "for r in res:\n", + " print(f\" {'OK ' if r['ok'] else '안내'} {r['case']:22s} -> {r['msg']}\" + (f\" | 조치: {r['action']}\" if r[\"action\"] != \"-\" else \"\"))\n", + "crash = 0 # safe_dashboard 밖으로 예외가 새 나간 건수(위에서 모두 포착)\n", + "n_err = sum(not r[\"ok\"] for r in res)\n", + "logged_err = sum(l[\"level\"] == \"ERROR\" for l in LOG)\n", + "print(f\"\\n크래시 {crash}건(목표 0건: {'달성' if crash == 0 else '미달'}), 오류 {n_err}건 중 로그 기록 {logged_err}건 -> 기록률 {logged_err / max(n_err, 1):.0%}(목표 100%: {'달성' if logged_err == n_err else '미달'})\")\n", + "print(f\"행 단위 격리 경고 {sum(l['level'] == 'WARN' for l in LOG)}건 기록, 안내 문구에 조치 가이드 포함 {sum(r['action'] != '-' for r in res if not r['ok'])}/{n_err}건\")\n", + "print(\"\\n[에러 로그 앞 5건]\")\n", + "print(pd.DataFrame(LOG).head(5).to_string(index=False))\n", + "\n", + "# 정상 입력으로 대시보드 그리기\n", + "heat, alt, bad = dashboard(base, catalog)\n", + "print(\"\\n[공급망 지도] 국가 x 범주 평균 위험 점수(상위 위험 국가 행)\")\n", + "print(heat.loc[[c for c in COUNTRIES if c in heat.index]].fillna(0).round(0).astype(int).to_string())\n", + "print(\"\\n[대체선 추천 패널] 위험 상위 해외 부품별 국산 후보와 규격 적합성\")\n", + "for a in alt:\n", + " print(f\" {a[0]} -> {a[1]} (규격 적합성 {a[2]:.2f})\")\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 06 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + "[에러 핸들러 시험] 입력 16종 실행\n", + " OK 정상 입력 -> 표시 완료\n", + " OK 정상 CSV 문자열 -> 표시 완료\n", + " 안내 입력 없음(None) -> 입력이 비어 있습니다. | 조치: 파일을 다시 선택해 주세요.\n", + " 안내 빈 문자열 -> 빈 파일입니다. | 조치: 내용이 있는 파일인지 확인해 주세요.\n", + " 안내 깨진 JSON -> 파일 형식을 읽을 수 없습니다. | 조치: CSV(쉼표 구분) 또는 JSON(목록) 형식으로 저장했는지 확인해 주세요.\n", + " 안내 지원하지 않는 형식(정수) -> 지원하지 않는 입력 형식입니다(int). | 조치: CSV 또는 JSON 파일을 사용해 주세요.\n", + " 안내 머리글만 있는 CSV -> 읽은 행이 0건입니다. | 조치: 머리글만 있는 파일이 아닌지 확인해 주세요.\n", + " 안내 필수 열 누락 -> 필수 열이 없습니다: lead_time_days, desc | 조치: 열 이름이 양식(데이터 딕셔너리)과 같은지 확인해 주세요.\n", + " OK 숫자 열에 문자 -> 표시 완료(격리 3행)\n", + " OK 음수 리드타임 -> 표시 완료(격리 3행)\n", + " OK 공급선 수 결측 -> 표시 완료(격리 3행)\n", + " OK 허용되지 않은 국가 -> 표시 완료(격리 3행)\n", + " OK 부품번호 중복·형식 오류 -> 표시 완료(격리 3행)\n", + " OK 연간 소요량 0(나눗셈 위험) -> 표시 완료(격리 3행)\n", + " OK 극단값(1e12) -> 표시 완료(격리 3행)\n", + " 안내 전부 불량 행 -> 사용할 수 있는 행이 없습니다. | 조치: 격리 사유를 확인해 값을 고친 뒤 다시 올려 주세요.\n", + "\n", + "크래시 0건(목표 0건: 달성), 오류 7건 중 로그 기록 7건 -> 기록률 100%(목표 100%: 달성)\n", + "행 단위 격리 경고 21건 기록, 안내 문구에 조치 가이드 포함 7/7건\n", + "\n", + "[에러 로그 앞 5건]\n", + " seq level code detail\n", + " 1 ERROR E01 입력 없음(None): 입력이 비어 있습니다.\n", + " 2 ERROR E02 빈 문자열: 빈 파일입니다.\n", + " 3 ERROR E03 깨진 JSON: 파일 형식을 읽을 수 없습니다.\n", + " 4 ERROR E04 지원하지 않는 형식(정수): 지원하지 않는 입력 형식입니다(int).\n", + " 5 ERROR E02 머리글만 있는 CSV: 읽은 행이 0건입니다.\n", + "\n", + "[공급망 지도] 국가 x 범주 평균 위험 점수(상위 위험 국가 행)\n", + "category RF증폭기 광학렌즈 베어링 유압밸브 자이로센서 전원모듈 제어보드 커넥터\n", + "country \n", + "가상국가A 39 58 48 41 44 36 46 69\n", + "가상국가B 42 49 75 54 51 68 57 30\n", + "가상국가C 14 63 39 0 16 0 71 29\n", + "국내 0 19 4 38 28 2 28 42\n", + "\n", + "[대체선 추천 패널] 위험 상위 해외 부품별 국산 후보와 규격 적합성\n", + " P-0080 -> D-0205 (규격 적합성 1.00)\n", + " P-0066 -> D-0172 (규격 적합성 1.00)\n", + "... (이하 생략)\n", + "```\n", + "
\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 07. 추천된 대체 부품의 규격 적합성 검토 및 조달 승인을 위한 HITL 워크플로우 설계\n", + "**소요시간**: 약 3.0시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "추천된 대체 부품의 규격 적합성 검토 및 조달 승인 HITL 워크플로우를 설계한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **추천된 대체 부품의 규격 적합성 검토와 조달 승인을 위한 HITL 워크플로우를 설계하고 이력 무결성과 권한 통제를 검증한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **시스템 제안, 조달담당 상신, 기술검토관 규격 검토, 보안담당 검토, 승인권자 결재의 순서를 상태 기계로 구현하고 역할별 권한을 최소로 둔다 과정을 진행하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `src/workflow/prc07_approval_system.py` 형태의 결과물을 완성한다.\n", + "\n", + "### 🔍 평가 및 검증 기준 (PASS 조건)\n", + "#### 미션 판정\n", + "\n", + "**평가 구분:** 필수\n", + "\n", + "**핵심 평가영역:** 군 보안 규정 및 행정 절차 부합성\n", + "\n", + "#### 미션 PASS 조건\n", + "\n", + "- 정량 기준: 이력 기록 무결성 100%, 승인 권한 우회 시도 차단율 100%\n", + "\n", + "\n", + "- 진행 기준: 결재선 승인/반려 시뮬레이션 및 이력 조회 화면 구현\n", + "\n", + "\n", + "- 검증 방법: 권한별 접근 제어(RBAC) 테스트\n", + "\n", + "\n", + "#### 채점자 판정 메모\n", + "\n", + "> 단순 코드 실행 여부뿐만 아니라 출력된 수치와 근거 자료가 군 실무 요구사항에 부합하는지 면밀히 확인한다.\n", + "\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "\"\"\"위험 상위 부품의 대체 부품 추천 결과를 조달담당 상신 -> 기술검토관 규격 검토 -> 보안담당 검토 -> 승인권자 결재의 HITL 워크플로우로 처리하되, 시스템의 자동 확정과 권한 우회 시도를 차단하고 모든 행위를 해시 체인 감사 로그에 남겨 이력 무결성과 위변조 탐지를 검증한다.\"\"\"\n", + "import hashlib\n", + "import json\n", + "import numpy as np\n", + "import pandas as pd\n", + "import matplotlib.pyplot as plt\n", + "from sklearn.feature_extraction.text import TfidfVectorizer\n", + "from sklearn.metrics.pairwise import cosine_similarity\n", + "\n", + "# ---------------------------------------------------------------------------\n", + "# [교육용가상자료] 가상 무기체계 부품·공급망 데이터 생성기 (실제 체계·업체·국가·단가 정보 없음)\n", + "# 부품 100종(해외 조달 약 80%)의 공급망 속성과, 국내 부품 카탈로그(과거 승인된 국산 대체품 +\n", + "# 설명은 비슷하지만 규격이 맞지 않는 유사 후보)를 함께 만든다.\n", + "# ---------------------------------------------------------------------------\n", + "COUNTRIES = [\"가상국가A\", \"가상국가B\", \"가상국가C\", \"국내\"]\n", + "CATS = {\n", + " \"전원모듈\": dict(words=[\"DC-DC\", \"컨버터\", \"절연형\", \"전원\", \"정출력\"], syn={\"컨버터\": \"변환장치\", \"절연형\": \"아이솔레이션\", \"정출력\": \"안정화\"}, std=\"MIL-STD-704\", iface=\"단자대\"),\n", + " \"커넥터\": dict(words=[\"원형\", \"커넥터\", \"방수형\", \"다핀\", \"나사체결\"], syn={\"커넥터\": \"접속자\", \"방수형\": \"밀폐형\", \"나사체결\": \"스크루결합\"}, std=\"MIL-DTL-38999\", iface=\"원형핀\"),\n", + " \"유압밸브\": dict(words=[\"유압\", \"솔레노이드\", \"밸브\", \"방향제어\", \"4포트\"], syn={\"밸브\": \"개폐장치\", \"방향제어\": \"유로전환\", \"솔레노이드\": \"전자식\"}, std=\"SAE-AS5440\", iface=\"플랜지\"),\n", + " \"베어링\": dict(words=[\"볼\", \"베어링\", \"고속\", \"밀봉형\", \"회전축\"], syn={\"베어링\": \"축받이\", \"밀봉형\": \"실드형\", \"고속\": \"고회전\"}, std=\"ISO-492\", iface=\"축결합\"),\n", + " \"광학렌즈\": dict(words=[\"적외선\", \"렌즈\", \"게르마늄\", \"반사방지\", \"집광\"], syn={\"렌즈\": \"광학창\", \"집광\": \"초점형성\", \"반사방지\": \"AR코팅\"}, std=\"MIL-PRF-13830\", iface=\"마운트\"),\n", + " \"자이로센서\": dict(words=[\"광섬유\", \"자이로\", \"각속도\", \"센서\", \"관성\"], syn={\"센서\": \"감지기\", \"각속도\": \"회전율\", \"관성\": \"INS용\"}, std=\"IEEE-952\", iface=\"직렬통신\"),\n", + " \"RF증폭기\": dict(words=[\"RF\", \"전력증폭기\", \"광대역\", \"모듈\", \"송신\"], syn={\"전력증폭기\": \"파워앰프\", \"송신\": \"출력단\", \"광대역\": \"와이드밴드\"}, std=\"MIL-STD-461\", iface=\"SMA\"),\n", + " \"제어보드\": dict(words=[\"FPGA\", \"제어보드\", \"임베디드\", \"연산\", \"입출력\"], syn={\"제어보드\": \"컨트롤카드\", \"연산\": \"신호처리\", \"임베디드\": \"내장형\"}, std=\"VITA-46\", iface=\"백플레인\"),\n", + "}\n", + "V_OPTS, W_OPTS = [12, 24, 28], [25, 50, 100]\n", + "VENDORS = [\"가상정밀(주)\", \"가상전자(주)\", \"가상유공압(주)\", \"가상광학(주)\", \"가상테크(주)\", \"가상시스템(주)\"]\n", + "\n", + "\n", + "def describe(cat, words, s):\n", + " # [TODO : 학생 실습 7] 추천된 대체 부품의 규격 적합성 검토 및 조달 승인을 위한 HITL 워크플로우 설계 관련 핵심 로직을 구현하세요.\n", + " # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n", + " # <<< 여기에 코드를 작성하세요 >>>\n", + " pass # 작성 후 pass를 제거하세요.\n", + "def _sig(x):\n", + " # [TODO : 학생 실습 7] 추천된 대체 부품의 규격 적합성 검토 및 조달 승인을 위한 HITL 워크플로우 설계 관련 핵심 로직을 구현하세요.\n", + " # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n", + " # <<< 여기에 코드를 작성하세요 >>>\n", + " pass # 작성 후 pass를 제거하세요.\n", + "def make_parts(n=100, seed=7):\n", + " \"\"\"부품 목록(공급망 속성 포함)과 국내 카탈로그를 만든다. 반환: (parts DataFrame, catalog DataFrame)\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " cats, rows, cat_rows = list(CATS), [], []\n", + " for i in range(n):\n", + " cat = cats[i % len(cats)]; c = CATS[cat]\n", + " spec = {\"v\": int(rng.choice(V_OPTS)), \"w\": int(rng.choice(W_OPTS)), \"tmin\": int(rng.choice([-55, -40])),\n", + " \"tmax\": int(rng.choice([85, 125])), \"size_mm\": float(round(rng.uniform(20, 120), 1)), \"std\": c[\"std\"]}\n", + " words = [str(x) for x in rng.permutation(c[\"words\"])[:4]]\n", + " country = str(rng.choice(COUNTRIES, p=[0.35, 0.3, 0.15, 0.2]))\n", + " overseas = country != \"국내\"\n", + " n_sup = int(rng.choice([1, 2, 3], p=[0.45, 0.35, 0.2])) if overseas else int(rng.choice([1, 2, 3]))\n", + " lead = float(round(rng.gamma(3, 45 if overseas else 25) + 20))\n", + " eol = str(rng.choice([\"정상\", \"단종예고\", \"단종\"], p=[0.7, 0.2, 0.1]))\n", + " crit = str(rng.choice([\"A\", \"B\", \"C\"], p=[0.3, 0.4, 0.3]))\n", + " z = (-2.6 + 1.1 * (n_sup == 1) + 0.9 * overseas + 0.006 * (lead - 100) + 1.2 * (eol == \"단종예고\")\n", + " + 2.0 * (eol == \"단종\") + rng.normal(0, 0.6))\n", + " p_late = float(np.clip(_sig(z) * 0.8 + 0.05, 0, 0.9))\n", + " late = int(rng.binomial(12, p_late))\n", + " demand = int(rng.integers(20, 400))\n", + " rows.append({\"part_no\": f\"P-{i + 1:04d}\", \"category\": cat, \"desc\": describe(cat, words, spec), **spec,\n", + " \"iface\": c[\"iface\"], \"country\": country, \"overseas\": overseas, \"n_suppliers\": n_sup,\n", + " \"lead_time_days\": lead, \"orders_12m\": 12, \"late_orders\": late,\n", + " \"avg_delay_days\": float(round(late and rng.gamma(2, 12 + 20 * p_late), 1)),\n", + " \"eol_status\": eol, \"ltb_months\": int(rng.integers(1, 25)) if eol == \"단종예고\" else None,\n", + " \"price_change_pct\": float(round(rng.normal(4 + 12 * p_late, 8), 1)), \"criticality\": crit,\n", + " \"annual_demand\": demand, \"stock_qty\": int(rng.integers(0, demand)),\n", + " \"defect_rate\": float(round(abs(rng.normal(0.8 + 2 * p_late, 0.8)), 2)),\n", + " \"export_license\": bool(overseas and rng.random() < 0.35),\n", + " \"disrupted_next12m\": bool(rng.random() < _sig(z + 0.4))})\n", + " if not overseas:\n", + " continue\n", + " # 과거 승인된 국산 대체품(정답): 동의어로 서술, 규격 호환(전력 여유 허용, 일부는 크기 차이가 커 장착 검토 필요)\n", + " eq_words = [c[\"syn\"].get(w, w) for w in words]\n", + " hard = rng.random() < 0.15 # 서술이 많이 다른 사례\n", + " if hard:\n", + " eq_words = [c[\"syn\"].get(w, w) for w in c[\"words\"] if w not in words][:1] + eq_words[1:3]\n", + " eq = {**spec, \"w\": spec[\"w\"] if rng.random() < 0.7 else W_OPTS[min(W_OPTS.index(spec[\"w\"]) + 1, 2)],\n", + " \"size_mm\": float(round(spec[\"size_mm\"] * (rng.uniform(1.11, 1.15) if rng.random() < 0.12 else rng.uniform(0.95, 1.05)), 1))}\n", + " cat_rows.append({\"cand_no\": f\"D-{len(cat_rows) + 1:04d}\", \"category\": cat, \"desc\": describe(cat, eq_words, eq), **eq,\n", + " \"iface\": c[\"iface\"], \"vendor\": str(rng.choice(VENDORS)), \"approved_for\": f\"P-{i + 1:04d}\", \"kind\": \"equivalent\"})\n", + " # 유사 오답 후보: 설명은 거의 같지만 전압이 다르거나(5V/48V) 상용 온도 등급(0~70C)\n", + " for kind in (\"volt_mismatch\", \"temp_mismatch\"):\n", + " d = dict(spec)\n", + " if kind == \"volt_mismatch\":\n", + " d[\"v\"] = int(rng.choice([5, 48])) # 체계에서 쓰지 않는 전압\n", + " else:\n", + " d[\"tmin\"], d[\"tmax\"] = 0, 70\n", + " d[\"size_mm\"] = float(round(spec[\"size_mm\"] * rng.uniform(0.95, 1.05), 1))\n", + " cat_rows.append({\"cand_no\": f\"D-{len(cat_rows) + 1:04d}\", \"category\": cat, \"desc\": describe(cat, words, d), **d,\n", + " \"iface\": c[\"iface\"], \"vendor\": str(rng.choice(VENDORS)), \"approved_for\": None, \"kind\": kind})\n", + " parts = pd.DataFrame(rows)\n", + " catalog = pd.DataFrame(cat_rows).sample(frac=1, random_state=seed).reset_index(drop=True)\n", + " return parts, catalog\n", + "# ---------------------------------------------------------------------------\n", + "\n", + "# ---- 대체 부품 추천 공통 함수: 텍스트 유사도 + 규격 적합성 + 지식 그래프 이웃 유사도 ----\n", + "WEIGHTS = {\"text\": 0.35, \"spec\": 0.45, \"graph\": 0.20}\n", + "\n", + "\n", + "def spec_fit(p, c):\n", + " \"\"\"규격 적합성 0~1: 6개 조건 충족 비율. 전압·온도 한계(필수 조건)를 못 맞추면 점수를 절반으로 깎는다.\"\"\"\n", + " conds = [p[\"v\"] == c[\"v\"], c[\"w\"] >= p[\"w\"], c[\"tmin\"] <= p[\"tmin\"], c[\"tmax\"] >= p[\"tmax\"],\n", + " abs(c[\"size_mm\"] - p[\"size_mm\"]) / p[\"size_mm\"] <= 0.10, p[\"iface\"] == c[\"iface\"]]\n", + " s = sum(conds) / len(conds)\n", + " return (s * 0.5 if not (conds[0] and conds[2] and conds[3]) else s), conds\n", + "\n", + "\n", + "def graph_nodes(row):\n", + " \"\"\"지식 그래프에서 부품 노드와 연결된 이웃 노드 집합(범주·표준·인터페이스·기능어·등급).\"\"\"\n", + " words = set(row[\"desc\"].split()[1:-4])\n", + " grade = \"군용등급\" if row[\"tmin\"] <= -40 else \"상용등급\"\n", + " return {f\"cat:{row['category']}\", f\"std:{row['std']}\", f\"if:{row['iface']}\", f\"grade:{grade}\", f\"v:{row['v']}\"} | {f\"w:{w}\" for w in words}\n", + "\n", + "\n", + "def build_graph(catalog):\n", + " \"\"\"동의어 사전을 그래프 간선(기능어 = 동의어)으로 넣어 서술이 달라도 같은 기능 노드로 연결되게 한다.\"\"\"\n", + " syn = {f\"w:{b}\": f\"w:{a}\" for c in CATS.values() for a, b in c[\"syn\"].items()}\n", + " canon = lambda nodes: {syn.get(x, x) for x in nodes}\n", + " return {\"canon\": canon, \"nodes\": [canon(graph_nodes(r)) for _, r in catalog.iterrows()]}\n", + "\n", + "\n", + "def graph_sim(g, pn, cn):\n", + " \"\"\"두 부품이 공유하는 이웃 노드 비율(Jaccard).\"\"\"\n", + " pn = g[\"canon\"](pn)\n", + " return len(pn & cn) / max(len(pn | cn), 1)\n", + "\n", + "\n", + "def score_table(parts_df, catalog):\n", + " \"\"\"해외 부품 x 같은 범주 국내 후보 쌍마다 텍스트·규격·그래프 점수를 계산한 표(가중치 적용 전).\"\"\"\n", + " vec = TfidfVectorizer(token_pattern=r\"[^ ]+\").fit(list(parts_df[\"desc\"]) + list(catalog[\"desc\"]))\n", + " text = cosine_similarity(vec.transform(parts_df[\"desc\"]), vec.transform(catalog[\"desc\"]))\n", + " g = build_graph(catalog)\n", + " rows = []\n", + " for i, (_, p) in enumerate(parts_df.iterrows()):\n", + " pn = graph_nodes(p)\n", + " for j, (_, c) in enumerate(catalog.iterrows()):\n", + " if c[\"category\"] != p[\"category\"]:\n", + " continue\n", + " sf, conds = spec_fit(p, c)\n", + " rows.append({\"part_no\": p[\"part_no\"], \"cand_no\": c[\"cand_no\"], \"vendor\": c[\"vendor\"], \"text\": text[i, j],\n", + " \"spec\": sf, \"graph\": graph_sim(g, pn, g[\"nodes\"][j]),\n", + " \"unmet\": \",\".join(n for n, ok in zip([\"전압\", \"전력\", \"저온\", \"고온\", \"크기\", \"인터페이스\"], conds) if not ok) or \"-\",\n", + " \"kind\": c[\"kind\"], \"correct\": c[\"approved_for\"] == p[\"part_no\"]})\n", + " return pd.DataFrame(rows)\n", + "\n", + "\n", + "def recommend(table, weights=None, top=3):\n", + " \"\"\"가중합 점수로 부품별 상위 top개 후보를 고른다.\"\"\"\n", + " w = weights or WEIGHTS\n", + " t = table.assign(score=w[\"text\"] * table[\"text\"] + w[\"spec\"] * table[\"spec\"] + w[\"graph\"] * table[\"graph\"])\n", + " return t.sort_values([\"part_no\", \"score\"], ascending=[True, False]).groupby(\"part_no\").head(top)\n", + "\n", + "\n", + "def hit_rate(recs, k=1):\n", + " \"\"\"과거 승인 이력(정답 대체품)이 상위 k개 안에 들어간 부품 비율.\"\"\"\n", + " return recs.groupby(\"part_no\").apply(lambda d: d[\"correct\"].head(k).any(), include_groups=False).mean()\n", + "\n", + "# ---- 역할과 권한(최소 권한 원칙): 시스템은 '제안'만 할 수 있고 확정 권한이 없다 ----\n", + "PERM = {\"시스템\": {\"propose\"}, \"조달담당\": {\"submit\", \"view\"}, \"기술검토관\": {\"review_spec\", \"view\"}, \"보안담당\": {\"review_sec\", \"view\"},\n", + " \"승인권자\": {\"approve\", \"view\"}, \"감사관\": {\"view\", \"audit\"},\n", + " \"부서장\": {\"submit\", \"approve\", \"view\"}} # 소규모 부서는 상신과 결재를 겸할 수 있으나 같은 건은 분리\n", + "NEXT = {\"제안\": (\"submit\", \"상신\"), \"상신\": (\"review_spec\", \"규격검토\"), \"규격검토\": (\"review_sec\", \"보안검토\"), \"보안검토\": (\"approve\", \"승인\")}\n", + "USERS = {\"sys\": \"시스템\", \"kim\": \"조달담당\", \"lee\": \"기술검토관\", \"park\": \"보안담당\", \"choi\": \"승인권자\", \"jung\": \"감사관\", \"oh\": \"부서장\"}\n", + "FLAGGED_VENDORS = {\"가상시스템(주)\"} # [교육용가상자료] 보안 검토에서 추가 확인이 필요한 업체 표기\n", + "\n", + "\n", + "class Chain:\n", + " \"\"\"해시 체인 감사 로그: 각 기록이 직전 기록의 해시를 포함해 중간 수정이 드러난다.\"\"\"\n", + "\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", + "class Workflow:\n", + " def __init__(self):\n", + " self.audit, self.cases, self.blocked = Chain(), {}, []\n", + "\n", + " def _deny(self, who, case, action, why):\n", + " self.audit.add(event=\"DENIED\", who=who, case=case, action=action, reason=why)\n", + " self.blocked.append((who, case, action, why)); return False, why\n", + "\n", + " def act(self, who, case, action, reason=\"\", evidence=\"\"):\n", + " role = USERS.get(who)\n", + " if role is None:\n", + " return self._deny(who, case, action, \"미등록 사용자\")\n", + " if action == \"delete_log\":\n", + " return self._deny(who, case, action, \"감사 로그 삭제·수정 권한은 누구에게도 없음\")\n", + " if action not in PERM[role]:\n", + " return self._deny(who, case, action, f\"권한 없음({role} 역할에 {action} 권한 없음)\")\n", + " if action == \"propose\":\n", + " self.cases[case] = {\"state\": \"제안\", \"submitter\": None}\n", + " self.audit.add(event=\"PROPOSE\", who=who, case=case, reason=reason); return True, \"제안 등록\"\n", + " c = self.cases.get(case)\n", + " if c is None:\n", + " return self._deny(who, case, action, \"존재하지 않는 건\")\n", + " if c[\"state\"] in (\"승인\", \"반려\"):\n", + " return self._deny(who, case, action, f\"이미 종결된 건({c['state']})\")\n", + " need, nxt = NEXT[c[\"state\"]]\n", + " if action != need:\n", + " return self._deny(who, case, action, f\"단계 위반(현재 {c['state']}, 필요 행위 {need})\")\n", + " if not reason.strip() or not evidence.strip():\n", + " return self._deny(who, case, action, \"사유·근거 입력 필수\")\n", + " if action == \"submit\":\n", + " c[\"submitter\"] = who\n", + " if action == \"approve\" and who == c[\"submitter\"]:\n", + " return self._deny(who, case, action, \"상신자와 승인자 분리 원칙 위반\")\n", + " c[\"state\"] = nxt\n", + " self.audit.add(event=action.upper(), who=who, role=role, case=case, to=nxt, reason=reason, evidence=evidence); return True, nxt\n", + "\n", + " def reject(self, who, case, reason, evidence):\n", + " \"\"\"반려: 검토자·승인권자가 어느 단계에서든 사유·근거와 함께 할 수 있다.\"\"\"\n", + " c = self.cases[case]\n", + " role = USERS.get(who)\n", + " if role not in (\"승인권자\", \"기술검토관\", \"보안담당\", \"부서장\") or c[\"state\"] in (\"승인\", \"반려\"):\n", + " return self._deny(who, case, \"reject\", \"반려 권한 없음 또는 종결된 건\")\n", + " if not reason.strip() or not evidence.strip():\n", + " return self._deny(who, case, \"reject\", \"사유·근거 입력 필수\")\n", + " c[\"state\"] = \"반려\"\n", + " self.audit.add(event=\"REJECTED\", who=who, role=role, case=case, reason=reason, evidence=evidence); return True, \"반려\"\n", + "\n", + "\n", + "# ---- 상신 대상: 위험 상위 해외 부품과 추천 1순위 후보 ----\n", + "parts, catalog = make_parts(100, seed=7)\n", + "over = parts[parts[\"overseas\"]].reset_index(drop=True)\n", + "table = score_table(over, catalog)\n", + "top = recommend(table).groupby(\"part_no\").head(1).set_index(\"part_no\")\n", + "risk = (30 * (over[\"n_suppliers\"] == 1) + 25 * (over[\"late_orders\"] / 12) + 20 * (over[\"eol_status\"] != \"정상\") + 10 * over[\"criticality\"].eq(\"A\")).to_numpy()\n", + "picked = over.assign(risk=risk).sort_values(\"risk\", ascending=False).head(12)\n", + "rng = np.random.default_rng(4)\n", + "\n", + "wf = Workflow()\n", + "rows = []\n", + "for k, (_, p) in enumerate(picked.iterrows(), 1):\n", + " case, r = f\"C{k:02d}\", top.loc[p[\"part_no\"]]\n", + " wf.act(\"sys\", case, \"propose\", reason=f\"{p['part_no']} 위험 점수 {p['risk']:.0f}, 추천 {r['cand_no']}\")\n", + " wf.act(\"kim\", case, \"submit\", \"단종·단일 공급선 위험 부품 대체 검토 요청\", f\"위험 점수 {p['risk']:.0f}점, 후보 점수 {r['score']:.2f}\")\n", + " hard = [u for u in r[\"unmet\"].split(\",\") if u in (\"전압\", \"저온\", \"고온\")]\n", + " if rng.random() < 0.15:\n", + " rows.append((case, p[\"part_no\"], r[\"cand_no\"], \"무응답 -> 보류\", \"자동 확정 없음: 기술검토관 응답 대기\")); continue\n", + " if hard:\n", + " wf.reject(\"lee\", case, f\"필수 규격 미충족({','.join(hard)})\", f\"규격 적합성 {r['spec']:.2f}\")\n", + " rows.append((case, p[\"part_no\"], r[\"cand_no\"], wf.cases[case][\"state\"], f\"규격 반려: {','.join(hard)}\")); continue\n", + " wf.act(\"lee\", case, \"review_spec\", \"필수 규격(전압·온도) 충족 확인\" + (f\", 미충족 {r['unmet']}는 시험 조건부\" if r[\"unmet\"] != \"-\" else \"\"), f\"규격 적합성 {r['spec']:.2f}\")\n", + " if r[\"vendor\"] in FLAGGED_VENDORS:\n", + " wf.reject(\"park\", case, \"업체 보안 확인 미완료\", f\"업체 {r['vendor']} 추가 확인 필요\")\n", + " rows.append((case, p[\"part_no\"], r[\"cand_no\"], wf.cases[case][\"state\"], \"보안 반려: 업체 확인 필요\")); continue\n", + " wf.act(\"park\", case, \"review_sec\", \"업체·공급 경로 보안 확인\", f\"업체 {r['vendor']} 확인 완료(가상)\")\n", + " ok = r[\"score\"] >= 0.80 and p[\"risk\"] >= 40\n", + " if ok:\n", + " wf.act(\"choi\", case, \"approve\", \"승인: 위험 부품 국산화 시험 착수\", f\"후보 점수 {r['score']:.2f}, 위험 {p['risk']:.0f}\")\n", + " else:\n", + " wf.reject(\"choi\", case, \"반려: 추천 점수 또는 위험도 기준 미달(추가 시험 후 재상신)\", f\"후보 점수 {r['score']:.2f}, 위험 {p['risk']:.0f}\")\n", + " rows.append((case, p[\"part_no\"], r[\"cand_no\"], wf.cases[case][\"state\"], \"승인권자 결정\" if ok else \"점수 미달 반려\"))\n", + "print(\"[결재 결과] 12건\")\n", + "print(pd.DataFrame(rows, columns=[\"건\", \"부품\", \"후보\", \"최종 상태\", \"비고\"]).to_string(index=False))\n", + "st = pd.Series([r[3] for r in rows]).value_counts().to_dict()\n", + "print(\"상태 집계:\", st, \"(모의 결재자 기준, 실제 결재는 사람이 한다)\")\n", + "\n", + "# ---- 권한 우회·절차 위반 시도 ----\n", + "wf.act(\"sys\", \"T01\", \"propose\", reason=\"시험 건\")\n", + "wf.act(\"kim\", \"T01\", \"submit\", \"시험 상신\", \"근거\")\n", + "attempts = [\n", + " (\"시스템 자동 승인\", lambda: wf.act(\"sys\", \"T01\", \"approve\", \"자동\", \"자동\")),\n", + " (\"조달담당이 규격 검토 대행\", lambda: wf.act(\"kim\", \"T01\", \"review_spec\", \"대행\", \"근거\")),\n", + " (\"검토 단계 건너뛰고 승인\", lambda: wf.act(\"choi\", \"T01\", \"approve\", \"바로 승인\", \"근거\")),\n", + " (\"미등록 사용자 접근\", lambda: wf.act(\"guest\", \"T01\", \"review_spec\", \"열람\", \"근거\")),\n", + " (\"사유 없는 검토\", lambda: wf.act(\"lee\", \"T01\", \"review_spec\", \"\", \"\")),\n", + " (\"감사 로그 삭제\", lambda: wf.act(\"kim\", \"T01\", \"delete_log\", \"정리\", \"근거\")),\n", + " (\"감사관이 승인 시도\", lambda: wf.act(\"jung\", \"T01\", \"approve\", \"승인\", \"근거\")),\n", + " (\"종결된 건 재승인\", lambda: wf.act(\"choi\", \"C01\", \"approve\", \"재승인\", \"근거\")),\n", + "]\n", + "wf.act(\"sys\", \"T02\", \"propose\", reason=\"시험\"); wf.act(\"oh\", \"T02\", \"submit\", \"상신\", \"근거\")\n", + "wf.act(\"lee\", \"T02\", \"review_spec\", \"규격 확인\", \"적합성 1.00\"); wf.act(\"park\", \"T02\", \"review_sec\", \"보안 확인\", \"확인 완료\")\n", + "attempts.append((\"상신자가 본인 건 승인(분리 원칙)\", lambda: wf.act(\"oh\", \"T02\", \"approve\", \"본인 승인\", \"근거\")))\n", + "print(\"\\n[권한 우회·절차 위반 시도]\")\n", + "blocked = 0\n", + "for name, fn in attempts:\n", + " ok, why = fn()\n", + " blocked += (not ok)\n", + " print(f\" {name:22s}-> {'차단' if not ok else '통과(결함)'}: {why}\")\n", + "print(f\"우회·위반 시도 차단 {blocked}/{len(attempts)} ({blocked / len(attempts):.0%}) (목표 100%: {'달성' if blocked == len(attempts) else '미달'})\")\n", + "\n", + "# ---- 이력 무결성 ----\n", + "bad = wf.audit.verify()\n", + "n_cases_logged = sum(1 for e in wf.audit.log if e[\"event\"] == \"PROPOSE\")\n", + "print(f\"\\n감사 로그 {len(wf.audit.log)}건, 해시 체인 검증: {'정상' if bad is None else f'seq {bad}에서 불일치'} -> 이력 무결성 {'100%' if bad is None else '훼손'}(목표 100%: {'달성' if bad is None else '미달'})\")\n", + "saved = json.dumps(wf.audit.log[10], ensure_ascii=False)\n", + "wf.audit.log[10][\"reason\"] = \"사후 수정\"\n", + "print(f\"10번째 기록 임의 수정 후 검증: {'위변조 탐지(seq ' + str(wf.audit.verify()) + ')' if wf.audit.verify() is not None else '탐지 실패'}\")\n", + "wf.audit.log[10] = json.loads(saved)\n", + "print(\"승인된 건의 마지막 기록 예시:\", {k: v for k, v in next((e for e in reversed(wf.audit.log) if e[\"event\"] == \"APPROVE\" and e[\"case\"].startswith(\"C\")), {}).items() if k in (\"seq\", \"who\", \"case\", \"reason\", \"evidence\")})\n", + "print(\"※ 대체 부품의 규격 적합성 최종 판단과 조달 승인은 사람(기술검토관·보안담당·승인권자)이 하며, 시스템은 추천과 근거만 제공한다.\")\n", + "\n", + "plt.figure(figsize=(6, 3.5)); EN = {\"승인\": \"approved\", \"반려\": \"rejected\", \"무응답 -> 보류\": \"on hold\"}; plt.bar([EN.get(k, k) for k in st], list(st.values()), color=\"tab:blue\"); plt.title(\"HITL workflow outcomes (12 cases)\"); plt.ylabel(\"cases\"); plt.tight_layout(); plt.show()\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 07 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + "[결재 결과] 12건\n", + " 건 부품 후보 최종 상태 비고\n", + "C01 P-0028 D-0046 반려 점수 미달 반려\n", + "C02 P-0056 D-0058 승인 승인권자 결정\n", + "C03 P-0087 D-0223 승인 승인권자 결정\n", + "C04 P-0036 D-0094 무응답 -> 보류 자동 확정 없음: 기술검토관 응답 대기\n", + "C05 P-0066 D-0172 승인 승인권자 결정\n", + "C06 P-0080 D-0205 승인 승인권자 결정\n", + "C07 P-0077 D-0049 반려 점수 미달 반려\n", + "C08 P-0045 D-0115 승인 승인권자 결정\n", + "C09 P-0003 D-0004 승인 승인권자 결정\n", + "C10 P-0062 D-0160 반려 보안 반려: 업체 확인 필요\n", + "C11 P-0053 D-0136 승인 승인권자 결정\n", + "C12 P-0012 D-0025 승인 승인권자 결정\n", + "상태 집계: {'승인': 8, '반려': 3, '무응답 -> 보류': 1} (모의 결재자 기준, 실제 결재는 사람이 한다)\n", + "\n", + "[권한 우회·절차 위반 시도]\n", + " 시스템 자동 승인 -> 차단: 권한 없음(시스템 역할에 approve 권한 없음)\n", + " 조달담당이 규격 검토 대행 -> 차단: 권한 없음(조달담당 역할에 review_spec 권한 없음)\n", + " 검토 단계 건너뛰고 승인 -> 차단: 단계 위반(현재 상신, 필요 행위 review_spec)\n", + " 미등록 사용자 접근 -> 차단: 미등록 사용자\n", + " 사유 없는 검토 -> 차단: 사유·근거 입력 필수\n", + " 감사 로그 삭제 -> 차단: 감사 로그 삭제·수정 권한은 누구에게도 없음\n", + " 감사관이 승인 시도 -> 차단: 권한 없음(감사관 역할에 approve 권한 없음)\n", + " 종결된 건 재승인 -> 차단: 이미 종결된 건(반려)\n", + " 상신자가 본인 건 승인(분리 원칙) -> 차단: 상신자와 승인자 분리 원칙 위반\n", + "우회·위반 시도 차단 9/9 (100%) (목표 100%: 달성)\n", + "\n", + "감사 로그 71건, 해시 체인 검증: 정상 -> 이력 무결성 100%(목표 100%: 달성)\n", + "10번째 기록 임의 수정 후 검증: 위변조 탐지(seq 10)\n", + "승인된 건의 마지막 기록 예시: {'seq': 55, 'who': 'choi', 'case': 'C12', 'reason': '승인: 위험 부품 국산화 시험 착수', 'evidence': '후보 점수 0.84, 위험 62'}\n", + "※ 대체 부품의 규격 적합성 최종 판단과 조달 승인은 사람(기술검토관·보안담당·승인권자)이 하며, 시스템은 추천과 근거만 제공한다.\n", + "```\n", + "
\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 08. 지정학적 위기 모의 글로벌 공급망 전면 차단 스트레스 시뮬레이션\n", + "**소요시간**: 약 3.5시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "글로벌 공급망 차단 모의 스트레스 시뮬레이션을 수행한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **가상 공급국 전면 차단을 모의한 스트레스 시뮬레이션으로 배치 파이프라인의 실행 시간, 안정성, 업무 시간 절감 효과를 검증한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **평시, 가상국가 A 차단, A+B 차단, 해외 전면 차단 4개 시나리오를 부품 2,000종 규모로 실행해 단절 부품, 국산 대체 후보 보유, 업체 수용량을 고려한 분기 내 착수 가능 건수, 핵심 부품 착수 대기, 재고 90일 미만 단절 산출하고 모델에 적용하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 5개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `src/pipeline/prc07_batch_runner.py` 형태의 결과물을 완성한다.\n", + "\n", + "### 🔍 평가 및 검증 기준 (PASS 조건)\n", + "#### 미션 판정\n", + "\n", + "**평가 구분:** 필수\n", + "\n", + "**핵심 평가영역:** 수작업 대비 업무 소요 시간 80% 이상 절감 효과\n", + "\n", + "#### 미션 PASS 조건\n", + "\n", + "- 정량 기준: 전체 배치 파이프라인 실행 시간 5분 이내, 자동화 성공률 99% 이상\n", + "\n", + "\n", + "- 진행 기준: 스케줄러 연동 테스트 및 자동 실행 확인\n", + "\n", + "\n", + "- 검증 방법: 실제 월간 업무 데이터 1년치 일괄 배치 시뮬레이션\n", + "\n", + "\n", + "#### 채점자 판정 메모\n", + "\n", + "> 단순 코드 실행 여부뿐만 아니라 출력된 수치와 근거 자료가 군 실무 요구사항에 부합하는지 면밀히 확인한다.\n", + "\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "\"\"\"가상 공급국 전면 차단(지정학적 위기 모의) 시나리오 4종을 부품 2,000종 규모 배치 파이프라인으로 실행하고, 레코드 손상 2%·일시 오류 주입 상황에서 실행 시간(5분 이내)·자동화 성공률(99% 이상)·수작업 대비 시간 절감(80% 이상)과 시간 초과 시 기능 축소(Fail-safe) 동작을 검증한다.\"\"\"\n", + "import time\n", + "import numpy as np\n", + "import pandas as pd\n", + "import matplotlib.pyplot as plt\n", + "\n", + "# ---------------------------------------------------------------------------\n", + "# [교육용가상자료] 가상 무기체계 부품·공급망 데이터 생성기 (실제 체계·업체·국가·단가 정보 없음)\n", + "# 부품 100종(해외 조달 약 80%)의 공급망 속성과, 국내 부품 카탈로그(과거 승인된 국산 대체품 +\n", + "# 설명은 비슷하지만 규격이 맞지 않는 유사 후보)를 함께 만든다.\n", + "# ---------------------------------------------------------------------------\n", + "COUNTRIES = [\"가상국가A\", \"가상국가B\", \"가상국가C\", \"국내\"]\n", + "CATS = {\n", + " \"전원모듈\": dict(words=[\"DC-DC\", \"컨버터\", \"절연형\", \"전원\", \"정출력\"], syn={\"컨버터\": \"변환장치\", \"절연형\": \"아이솔레이션\", \"정출력\": \"안정화\"}, std=\"MIL-STD-704\", iface=\"단자대\"),\n", + " \"커넥터\": dict(words=[\"원형\", \"커넥터\", \"방수형\", \"다핀\", \"나사체결\"], syn={\"커넥터\": \"접속자\", \"방수형\": \"밀폐형\", \"나사체결\": \"스크루결합\"}, std=\"MIL-DTL-38999\", iface=\"원형핀\"),\n", + " \"유압밸브\": dict(words=[\"유압\", \"솔레노이드\", \"밸브\", \"방향제어\", \"4포트\"], syn={\"밸브\": \"개폐장치\", \"방향제어\": \"유로전환\", \"솔레노이드\": \"전자식\"}, std=\"SAE-AS5440\", iface=\"플랜지\"),\n", + " \"베어링\": dict(words=[\"볼\", \"베어링\", \"고속\", \"밀봉형\", \"회전축\"], syn={\"베어링\": \"축받이\", \"밀봉형\": \"실드형\", \"고속\": \"고회전\"}, std=\"ISO-492\", iface=\"축결합\"),\n", + " \"광학렌즈\": dict(words=[\"적외선\", \"렌즈\", \"게르마늄\", \"반사방지\", \"집광\"], syn={\"렌즈\": \"광학창\", \"집광\": \"초점형성\", \"반사방지\": \"AR코팅\"}, std=\"MIL-PRF-13830\", iface=\"마운트\"),\n", + " \"자이로센서\": dict(words=[\"광섬유\", \"자이로\", \"각속도\", \"센서\", \"관성\"], syn={\"센서\": \"감지기\", \"각속도\": \"회전율\", \"관성\": \"INS용\"}, std=\"IEEE-952\", iface=\"직렬통신\"),\n", + " \"RF증폭기\": dict(words=[\"RF\", \"전력증폭기\", \"광대역\", \"모듈\", \"송신\"], syn={\"전력증폭기\": \"파워앰프\", \"송신\": \"출력단\", \"광대역\": \"와이드밴드\"}, std=\"MIL-STD-461\", iface=\"SMA\"),\n", + " \"제어보드\": dict(words=[\"FPGA\", \"제어보드\", \"임베디드\", \"연산\", \"입출력\"], syn={\"제어보드\": \"컨트롤카드\", \"연산\": \"신호처리\", \"임베디드\": \"내장형\"}, std=\"VITA-46\", iface=\"백플레인\"),\n", + "}\n", + "V_OPTS, W_OPTS = [12, 24, 28], [25, 50, 100]\n", + "VENDORS = [\"가상정밀(주)\", \"가상전자(주)\", \"가상유공압(주)\", \"가상광학(주)\", \"가상테크(주)\", \"가상시스템(주)\"]\n", + "\n", + "\n", + "def describe(cat, words, s):\n", + " # [TODO : 학생 실습 8] 지정학적 위기 모의 글로벌 공급망 전면 차단 스트레스 시뮬레이션 관련 핵심 로직을 구현하세요.\n", + " # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n", + " # <<< 여기에 코드를 작성하세요 >>>\n", + " pass # 작성 후 pass를 제거하세요.\n", + "def _sig(x):\n", + " # [TODO : 학생 실습 8] 지정학적 위기 모의 글로벌 공급망 전면 차단 스트레스 시뮬레이션 관련 핵심 로직을 구현하세요.\n", + " # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n", + " # <<< 여기에 코드를 작성하세요 >>>\n", + " pass # 작성 후 pass를 제거하세요.\n", + "def make_parts(n=100, seed=7):\n", + " \"\"\"부품 목록(공급망 속성 포함)과 국내 카탈로그를 만든다. 반환: (parts DataFrame, catalog DataFrame)\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " cats, rows, cat_rows = list(CATS), [], []\n", + " for i in range(n):\n", + " cat = cats[i % len(cats)]; c = CATS[cat]\n", + " spec = {\"v\": int(rng.choice(V_OPTS)), \"w\": int(rng.choice(W_OPTS)), \"tmin\": int(rng.choice([-55, -40])),\n", + " \"tmax\": int(rng.choice([85, 125])), \"size_mm\": float(round(rng.uniform(20, 120), 1)), \"std\": c[\"std\"]}\n", + " words = [str(x) for x in rng.permutation(c[\"words\"])[:4]]\n", + " country = str(rng.choice(COUNTRIES, p=[0.35, 0.3, 0.15, 0.2]))\n", + " overseas = country != \"국내\"\n", + " n_sup = int(rng.choice([1, 2, 3], p=[0.45, 0.35, 0.2])) if overseas else int(rng.choice([1, 2, 3]))\n", + " lead = float(round(rng.gamma(3, 45 if overseas else 25) + 20))\n", + " eol = str(rng.choice([\"정상\", \"단종예고\", \"단종\"], p=[0.7, 0.2, 0.1]))\n", + " crit = str(rng.choice([\"A\", \"B\", \"C\"], p=[0.3, 0.4, 0.3]))\n", + " z = (-2.6 + 1.1 * (n_sup == 1) + 0.9 * overseas + 0.006 * (lead - 100) + 1.2 * (eol == \"단종예고\")\n", + " + 2.0 * (eol == \"단종\") + rng.normal(0, 0.6))\n", + " p_late = float(np.clip(_sig(z) * 0.8 + 0.05, 0, 0.9))\n", + " late = int(rng.binomial(12, p_late))\n", + " demand = int(rng.integers(20, 400))\n", + " rows.append({\"part_no\": f\"P-{i + 1:04d}\", \"category\": cat, \"desc\": describe(cat, words, spec), **spec,\n", + " \"iface\": c[\"iface\"], \"country\": country, \"overseas\": overseas, \"n_suppliers\": n_sup,\n", + " \"lead_time_days\": lead, \"orders_12m\": 12, \"late_orders\": late,\n", + " \"avg_delay_days\": float(round(late and rng.gamma(2, 12 + 20 * p_late), 1)),\n", + " \"eol_status\": eol, \"ltb_months\": int(rng.integers(1, 25)) if eol == \"단종예고\" else None,\n", + " \"price_change_pct\": float(round(rng.normal(4 + 12 * p_late, 8), 1)), \"criticality\": crit,\n", + " \"annual_demand\": demand, \"stock_qty\": int(rng.integers(0, demand)),\n", + " \"defect_rate\": float(round(abs(rng.normal(0.8 + 2 * p_late, 0.8)), 2)),\n", + " \"export_license\": bool(overseas and rng.random() < 0.35),\n", + " \"disrupted_next12m\": bool(rng.random() < _sig(z + 0.4))})\n", + " if not overseas:\n", + " continue\n", + " # 과거 승인된 국산 대체품(정답): 동의어로 서술, 규격 호환(전력 여유 허용, 일부는 크기 차이가 커 장착 검토 필요)\n", + " eq_words = [c[\"syn\"].get(w, w) for w in words]\n", + " hard = rng.random() < 0.15 # 서술이 많이 다른 사례\n", + " if hard:\n", + " eq_words = [c[\"syn\"].get(w, w) for w in c[\"words\"] if w not in words][:1] + eq_words[1:3]\n", + " eq = {**spec, \"w\": spec[\"w\"] if rng.random() < 0.7 else W_OPTS[min(W_OPTS.index(spec[\"w\"]) + 1, 2)],\n", + " \"size_mm\": float(round(spec[\"size_mm\"] * (rng.uniform(1.11, 1.15) if rng.random() < 0.12 else rng.uniform(0.95, 1.05)), 1))}\n", + " cat_rows.append({\"cand_no\": f\"D-{len(cat_rows) + 1:04d}\", \"category\": cat, \"desc\": describe(cat, eq_words, eq), **eq,\n", + " \"iface\": c[\"iface\"], \"vendor\": str(rng.choice(VENDORS)), \"approved_for\": f\"P-{i + 1:04d}\", \"kind\": \"equivalent\"})\n", + " # 유사 오답 후보: 설명은 거의 같지만 전압이 다르거나(5V/48V) 상용 온도 등급(0~70C)\n", + " for kind in (\"volt_mismatch\", \"temp_mismatch\"):\n", + " d = dict(spec)\n", + " if kind == \"volt_mismatch\":\n", + " d[\"v\"] = int(rng.choice([5, 48])) # 체계에서 쓰지 않는 전압\n", + " else:\n", + " d[\"tmin\"], d[\"tmax\"] = 0, 70\n", + " d[\"size_mm\"] = float(round(spec[\"size_mm\"] * rng.uniform(0.95, 1.05), 1))\n", + " cat_rows.append({\"cand_no\": f\"D-{len(cat_rows) + 1:04d}\", \"category\": cat, \"desc\": describe(cat, words, d), **d,\n", + " \"iface\": c[\"iface\"], \"vendor\": str(rng.choice(VENDORS)), \"approved_for\": None, \"kind\": kind})\n", + " parts = pd.DataFrame(rows)\n", + " catalog = pd.DataFrame(cat_rows).sample(frac=1, random_state=seed).reset_index(drop=True)\n", + " return parts, catalog\n", + "# ---------------------------------------------------------------------------\n", + "\n", + "# ---- 조달 위험 스크리닝 룰 설정(YAML 형식 문자열: 규정이 바뀌면 코드가 아니라 이 설정만 고친다) ----\n", + "RULES_YAML = \"\"\"\n", + "- id: R01\n", + " name: 단일 공급선\n", + " field: n_suppliers\n", + " op: \"==\"\n", + " value: 1\n", + " weight: 3\n", + "- id: R02\n", + " name: 해외 조달 의존\n", + " field: overseas\n", + " op: \"==\"\n", + " value: true\n", + " weight: 1\n", + "- id: R03\n", + " name: 리드타임 180일 초과\n", + " field: lead_time_days\n", + " op: \">\"\n", + " value: 180\n", + " weight: 2\n", + "- id: R04\n", + " name: 납기 지연률 30% 이상\n", + " field: delay_rate\n", + " op: \">=\"\n", + " value: 0.3\n", + " weight: 3\n", + "- id: R05\n", + " name: 평균 지연 30일 초과\n", + " field: avg_delay_days\n", + " op: \">\"\n", + " value: 30\n", + " weight: 2\n", + "- id: R06\n", + " name: 단종 예고\n", + " field: eol_status\n", + " op: \"==\"\n", + " value: 단종예고\n", + " weight: 3\n", + "- id: R07\n", + " name: 단종 확정\n", + " field: eol_status\n", + " op: \"==\"\n", + " value: 단종\n", + " weight: 4\n", + "- id: R08\n", + " name: 최종구매 6개월 이내\n", + " field: ltb_months\n", + " op: \"<=\"\n", + " value: 6\n", + " weight: 3\n", + "- id: R09\n", + " name: 단가 20% 이상 급등\n", + " field: price_change_pct\n", + " op: \">=\"\n", + " value: 20\n", + " weight: 2\n", + "- id: R10\n", + " name: 핵심(A) 부품 단일 공급\n", + " field: crit_single\n", + " op: \"==\"\n", + " value: true\n", + " weight: 3\n", + "- id: R11\n", + " name: 재고 가용기간 < 리드타임\n", + " field: cover_gap_days\n", + " op: \"<\"\n", + " value: 0\n", + " weight: 3\n", + "- id: R12\n", + " name: 입고 불량률 2% 초과\n", + " field: defect_rate\n", + " op: \">\"\n", + " value: 2.0\n", + " weight: 2\n", + "- id: R13\n", + " name: 수출 허가 필요\n", + " field: export_license\n", + " op: \"==\"\n", + " value: true\n", + " weight: 2\n", + "- id: R14\n", + " name: 재고 없음\n", + " field: stock_qty\n", + " op: \"==\"\n", + " value: 0\n", + " weight: 2\n", + "- id: R15\n", + " name: 공급국 편중(30% 이상)\n", + " field: country_share\n", + " op: \">=\"\n", + " value: 0.3\n", + " weight: 1\n", + "- id: R16\n", + " name: 지연 이력 있는 단일 공급선\n", + " field: single_late\n", + " op: \"==\"\n", + " value: true\n", + " weight: 2\n", + "\"\"\"\n", + "GRADE_CUT = {\"고위험\": 16, \"주의\": 10} # 가중치 합 기준 1차 스크리닝 등급\n", + "\n", + "\n", + "def parse_rules(text):\n", + " \"\"\"간이 YAML 파서(목록 + key: value). 숫자·true/false·따옴표 문자열을 해석한다.\"\"\"\n", + " rules, cur = [], None\n", + " for line in text.strip().splitlines():\n", + " if not line.strip():\n", + " continue\n", + " s = line.strip()\n", + " if s.startswith(\"- \"):\n", + " cur = {}; rules.append(cur); s = s[2:]\n", + " k, v = [x.strip() for x in s.split(\":\", 1)]\n", + " v = v.strip('\"')\n", + " if v in (\"true\", \"false\"):\n", + " v = v == \"true\"\n", + " else:\n", + " try:\n", + " v = float(v) if \".\" in v else int(v)\n", + " except ValueError:\n", + " pass\n", + " cur[k] = v\n", + " return rules\n", + "\n", + "\n", + "OPS = {\"==\": lambda a, b: a == b, \"!=\": lambda a, b: a != b, \">\": lambda a, b: a > b, \">=\": lambda a, b: a >= b,\n", + " \"<\": lambda a, b: a < b, \"<=\": lambda a, b: a <= b}\n", + "\n", + "\n", + "def derive(df):\n", + " \"\"\"룰에 쓰는 파생 필드(지연률, 핵심 단일공급, 재고 가용기간 차이, 공급국 비중 등)를 만든다.\"\"\"\n", + " d = df.copy()\n", + " d[\"delay_rate\"] = d[\"late_orders\"] / d[\"orders_12m\"].clip(lower=1)\n", + " d[\"crit_single\"] = (d[\"criticality\"] == \"A\") & (d[\"n_suppliers\"] == 1)\n", + " d[\"cover_gap_days\"] = d[\"stock_qty\"] / d[\"annual_demand\"].clip(lower=1) * 365 - d[\"lead_time_days\"]\n", + " d[\"country_share\"] = d[\"country\"].map(d[\"country\"].value_counts(normalize=True)).where(d[\"overseas\"], 0.0)\n", + " d[\"single_late\"] = (d[\"n_suppliers\"] == 1) & (d[\"late_orders\"] >= 3)\n", + " return d\n", + "\n", + "\n", + "def apply_rules(rec, rules):\n", + " \"\"\"레코드 1건(dict)에 룰을 적용해 (위험 점수, 걸린 룰 목록)을 돌려준다. 값이 없으면 해당 룰은 건너뛴다.\"\"\"\n", + " hits = []\n", + " for r in rules:\n", + " v = rec.get(r[\"field\"])\n", + " if v is None or (isinstance(v, float) and np.isnan(v)):\n", + " continue\n", + " if OPS[r[\"op\"]](v, r[\"value\"]):\n", + " hits.append(r)\n", + " score = sum(r[\"weight\"] for r in hits)\n", + " grade = \"고위험\" if score >= GRADE_CUT[\"고위험\"] else (\"주의\" if score >= GRADE_CUT[\"주의\"] else \"관심\")\n", + " return score, grade, [r[\"id\"] for r in hits]\n", + "\n", + "rules = parse_rules(RULES_YAML)\n", + "SCEN = {\"평시\": [], \"가상국가A 차단\": [\"가상국가A\"], \"가상국가A+B 차단\": [\"가상국가A\", \"가상국가B\"], \"해외 전면 차단\": [\"가상국가A\", \"가상국가B\", \"가상국가C\"]}\n", + "MANUAL_MIN = 32.0 # [가정] 수작업 1건: 위험 확인 12분 + 국내 후보 탐색·규격 대조 20분\n", + "VENDOR_CAP = 70 # [가정] 국내 업체 1곳이 한 분기에 착수할 수 있는 신규 국산화 개발 건수\n", + "REVIEW_MIN = 4.0 # [가정] 자동 처리 후 담당자가 확인하는 건당 시간(고위험·단절 건만)\n", + "\n", + "\n", + "def fit_vec(P, C):\n", + " \"\"\"규격 적합성(0~1)을 부품 x 후보 행렬로 한 번에 계산: 전압·전력·저온·고온·크기·인터페이스 6조건.\"\"\"\n", + " cond = np.stack([P[\"v\"].to_numpy()[:, None] == C[\"v\"].to_numpy()[None], C[\"w\"].to_numpy()[None] >= P[\"w\"].to_numpy()[:, None],\n", + " C[\"tmin\"].to_numpy()[None] <= P[\"tmin\"].to_numpy()[:, None], C[\"tmax\"].to_numpy()[None] >= P[\"tmax\"].to_numpy()[:, None],\n", + " np.abs(C[\"size_mm\"].to_numpy()[None] - P[\"size_mm\"].to_numpy()[:, None]) / P[\"size_mm\"].to_numpy()[:, None] <= 0.10,\n", + " P[\"iface\"].to_numpy()[:, None] == C[\"iface\"].to_numpy()[None]])\n", + " s = cond.mean(axis=0)\n", + " return np.where(cond[0] & cond[2] & cond[3], s, s * 0.5)\n", + "\n", + "\n", + "def pipeline(parts, catalog, blocked, mode=\"full\", fail_p=0.0, rng=None, budget_s=None):\n", + " \"\"\"검증 -> 룰 스크리닝 -> 차단 영향 -> (full 모드) 국산 대체 후보 탐색. 오류는 재시도 3회, 실패 건은 격리하고 계속한다.\"\"\"\n", + " t0 = time.perf_counter()\n", + " stats = {\"ok\": 0, \"quarantine\": 0, \"retry\": 0, \"failed\": 0, \"degraded\": False}\n", + " d = derive(parts)\n", + " hit = np.zeros(len(d), bool); score = np.zeros(len(d))\n", + " recs = d.to_dict(\"records\")\n", + " for i, r in enumerate(recs):\n", + " if budget_s and time.perf_counter() - t0 > budget_s: # 시간 예산 초과: 후보 탐색을 생략하고 룰 스크리닝만 계속(기능 축소)\n", + " stats[\"degraded\"] = True; mode = \"rules_only\"\n", + " if r.get(\"lead_time_days\") is None or pd.isna(r.get(\"lead_time_days\")):\n", + " stats[\"quarantine\"] += 1; continue # 손상 레코드는 격리하고 계속 진행\n", + " for attempt in range(4):\n", + " if rng is not None and rng.random() < fail_p: # 일시 오류(모의): 최대 3회 재시도\n", + " stats[\"retry\"] += 1; continue\n", + " score[i] = apply_rules(r, rules)[0]; hit[i] = r[\"country\"] in blocked; stats[\"ok\"] += 1; break\n", + " else:\n", + " stats[\"failed\"] += 1\n", + " d = d.assign(score=score, blocked=hit)\n", + " cover = pd.Series(False, index=d.index); vend = pd.Series(\"-\", index=d.index)\n", + " if mode == \"full\" and hit.any():\n", + " for cat, g in d[d[\"blocked\"]].groupby(\"category\"):\n", + " C = catalog[catalog[\"category\"] == cat]\n", + " if len(C):\n", + " f = fit_vec(g, C)\n", + " cover.loc[g.index] = (f.max(axis=1) >= 0.83) # 규격 5/6 이상 충족 후보가 있으면 '대체 검토 가능'\n", + " vend.loc[g.index] = C[\"vendor\"].to_numpy()[f.argmax(axis=1)]\n", + " d[\"alt\"], d[\"vendor\"] = cover, vend\n", + " return d, stats, time.perf_counter() - t0\n", + "\n", + "\n", + "rng = np.random.default_rng(8)\n", + "parts, catalog = make_parts(2000, seed=7)\n", + "parts.loc[rng.choice(len(parts), 40, replace=False), \"lead_time_days\"] = np.nan # 레코드 손상 2%\n", + "print(f\"[교육용가상자료] 국내 업체 수용량 가정: 업체당 분기 {VENDOR_CAP}건\")\n", + "print(f\"[교육용가상자료] 부품 {len(parts)}종, 국내 후보 {len(catalog)}건, 손상 레코드 {int(parts['lead_time_days'].isna().sum())}건(2%) 주입, 일시 오류 확률 3%/시도\")\n", + "\n", + "res = []\n", + "for name, blk in SCEN.items():\n", + " d, st, sec = pipeline(parts, catalog, blk, fail_p=0.03, rng=rng)\n", + " b = d[d[\"blocked\"]].sort_values([\"criticality\", \"score\"], ascending=[True, False]) # 핵심(A) 먼저, 위험 점수 높은 순으로 업체 수용량 배정\n", + " b = b.assign(start=b[b[\"alt\"]].groupby(\"vendor\").cumcount() < VENDOR_CAP)\n", + " b[\"start\"] = b[\"start\"].fillna(False).astype(bool)\n", + " crit = b[b[\"criticality\"] == \"A\"]\n", + " days = (b[\"stock_qty\"] / b[\"annual_demand\"].clip(lower=1) * 365)\n", + " res.append({\"시나리오\": name, \"단절 부품\": len(b), \"대체 후보 있음\": int(b[\"alt\"].sum()), \"분기 내 착수 가능\": int(b[\"start\"].sum()), \"핵심(A) 착수 대기\": int((~crit[\"start\"]).sum()),\n", + " \"재고 90일 미만 단절\": int((days < 90).sum()), \"실행(초)\": sec, \"성공\": st[\"ok\"], \"재시도\": st[\"retry\"], \"격리\": st[\"quarantine\"], \"실패\": st[\"failed\"]})\n", + "R = pd.DataFrame(res)\n", + "print(\"\\n[차단 시나리오별 영향과 실행 결과]\")\n", + "print(R.round(2).to_string(index=False))\n", + "tot_ok, tot_all = R[\"성공\"].sum(), R[\"성공\"].sum() + R[\"실패\"].sum()\n", + "succ = tot_ok / tot_all\n", + "total_s = R[\"실행(초)\"].sum()\n", + "print(f\"\\n전체 배치 실행 시간 {total_s:.1f}초 (목표 5분 이내: {'달성' if total_s <= 300 else '미달'})\")\n", + "print(f\"자동화 성공률 {succ:.2%} = 처리 성공 {tot_ok}건 / 처리 대상 {tot_all}건(손상 격리 {R['격리'].sum()}건은 별도 보고, 일시 오류 {R['재시도'].sum()}회는 재시도로 복구) (목표 99% 이상: {'달성' if succ >= 0.99 else '미달'})\")\n", + "\n", + "# 수작업 대비 시간 절감(가정 단가 사용)\n", + "last = R.iloc[-1]\n", + "n_proc = len(parts)\n", + "manual = n_proc * MANUAL_MIN\n", + "auto = last[\"실행(초)\"] / 60 + (last[\"단절 부품\"] + R[\"격리\"].iloc[-1]) * REVIEW_MIN\n", + "print(f\"수작업 대비(해외 전면 차단, {n_proc}종): 수작업 {manual / 60:.0f}시간 vs 자동 {auto / 60:.0f}시간(담당자 확인 포함) -> {1 - auto / manual:.0%} 절감 (목표 80% 이상: {'달성' if 1 - auto / manual >= 0.8 else '미달'})\")\n", + "print(f\" 가정: 수작업 건당 {MANUAL_MIN:.0f}분, 자동 처리 후 확인 건당 {REVIEW_MIN:.0f}분(단절·격리 건만). 실제 업무 시간은 현업 측정으로 대체해야 한다.\")\n", + "\n", + "# 규모 확대 시험과 시간 초과 시 기능 축소\n", + "print(\"\\n[규모 확대 시험(해외 전면 차단)]\")\n", + "sc = []\n", + "for n in (500, 1000, 2000, 4000):\n", + " p, c = make_parts(n, seed=7)\n", + " _, st, sec = pipeline(p, c, SCEN[\"해외 전면 차단\"], fail_p=0.03, rng=np.random.default_rng(1))\n", + " sc.append((n, sec)); print(f\" 부품 {n:5d}종: {sec:5.2f}초, 처리 {st['ok']}건\")\n", + "d, st, sec = pipeline(parts, catalog, SCEN[\"해외 전면 차단\"], budget_s=0.01)\n", + "print(f\"시간 예산 0.01초 부여: 기능 축소 {'작동' if st['degraded'] else '미작동'} -> 룰 스크리닝은 {st['ok']}건 완료, 대체 후보 탐색은 생략(대체 후보 확인 {int(d['alt'].sum())}건). 시스템은 중단되지 않고 '후보 탐색 생략' 상태를 표시한다.\")\n", + "print(\"※ 시나리오는 가상 국가·가상 부품 기반의 취약점 점검이며 실제 국가·업체·교역 자료와 무관하다. 대체 조달 결정은 사람이 한다.\")\n", + "\n", + "fig, ax = plt.subplots(1, 2, figsize=(11, 4))\n", + "x = np.arange(len(R)); ax[0].bar(x - .2, R[\"단절 부품\"], .4, label=\"disrupted\"); ax[0].bar(x + .2, R[\"분기 내 착수 가능\"], .4, label=\"can start this quarter\")\n", + "ax[0].set_xticks(x, [\"normal\", \"A\", \"A+B\", \"all\"]); ax[0].legend(); ax[0].set_title(\"Blockade scenarios: impact (synthetic)\")\n", + "ax[1].plot(*zip(*sc), marker=\"o\"); ax[1].axhline(300, color=\"r\", ls=\"--\"); ax[1].set_xlabel(\"parts\"); ax[1].set_ylabel(\"seconds\"); ax[1].set_title(\"Batch run time vs scale (red = 5 min)\")\n", + "plt.tight_layout(); plt.show()\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 08 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + "[교육용가상자료] 국내 업체 수용량 가정: 업체당 분기 70건\n", + "[교육용가상자료] 부품 2000종, 국내 후보 4797건, 손상 레코드 40건(2%) 주입, 일시 오류 확률 3%/시도\n", + "\n", + "[차단 시나리오별 영향과 실행 결과]\n", + " 시나리오 단절 부품 대체 후보 있음 분기 내 착수 가능 핵심(A) 착수 대기 재고 90일 미만 단절 실행(초) 성공 재시도 격리 실패\n", + " 평시 0 0 0 0 0 0.09 1960 50 40 0\n", + " 가상국가A 차단 656 656 420 0 141 0.17 1960 55 40 0\n", + "가상국가A+B 차단 1262 1262 420 5 302 0.18 1960 43 40 0\n", + " 해외 전면 차단 1574 1574 420 52 363 0.14 1960 57 40 0\n", + "\n", + "전체 배치 실행 시간 0.6초 (목표 5분 이내: 달성)\n", + "자동화 성공률 100.00% = 처리 성공 7840건 / 처리 대상 7840건(손상 격리 160건은 별도 보고, 일시 오류 205회는 재시도로 복구) (목표 99% 이상: 달성)\n", + "수작업 대비(해외 전면 차단, 2000종): 수작업 1067시간 vs 자동 108시간(담당자 확인 포함) -> 90% 절감 (목표 80% 이상: 달성)\n", + " 가정: 수작업 건당 32분, 자동 처리 후 확인 건당 4분(단절·격리 건만). 실제 업무 시간은 현업 측정으로 대체해야 한다.\n", + "\n", + "[규모 확대 시험(해외 전면 차단)]\n", + " 부품 500종: 0.05초, 처리 500건\n", + " 부품 1000종: 0.07초, 처리 1000건\n", + " 부품 2000종: 0.17초, 처리 2000건\n", + " 부품 4000종: 0.46초, 처리 4000건\n", + "시간 예산 0.01초 부여: 기능 축소 작동 -> 룰 스크리닝은 1960건 완료, 대체 후보 탐색은 생략(대체 후보 확인 0건). 시스템은 중단되지 않고 '후보 탐색 생략' 상태를 표시한다.\n", + "※ 시나리오는 가상 국가·가상 부품 기반의 취약점 점검이며 실제 국가·업체·교역 자료와 무관하다. 대체 조달 결정은 사람이 한다.\n", + "```\n", + "
\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 09. AAR 기반 대체선 오추천 사례 분석 및 부품 유사도 매칭 가중치 재보정\n", + "**소요시간**: 약 3.0시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "AAR 기반 대체선 오추천 사례를 분석하고 매칭 가중치를 재보정한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **AAR로 대체선 오추천 사례를 분석하고 매칭 가중치를 재보정하되 별도 검증 세트로 개선 여부를 확인한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **현장 모의 AAR 세트에서 1순위 오추천을 원인별(후보 충돌, 크기 편차 감점 등)로 분류하고, 가중치 재보정, 정밀 규격 항목 추가, 후보 1:1 배정, 근소 차이 검토 표시를 개선안으로 시험 수행하고 검증하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `docs/prc07_uat_feedback_report.md 및 패치 내역` 형태의 결과물을 완성한다.\n", + "\n", + "### 🔍 평가 및 검증 기준 (PASS 조건)\n", + "#### 미션 판정\n", + "\n", + "**평가 구분:** 필수\n", + "\n", + "**핵심 평가영역:** 실무 업무 생산성 기여도 및 현장 적응성\n", + "\n", + "#### 미션 PASS 조건\n", + "\n", + "- 정량 기준: UAT 체크리스트 만족도 90점 이상, 개선 요구 5건 반영 완료\n", + "\n", + "\n", + "- 진행 기준: UAT 결과 보고서 작성 및 최종 버그 픽스 완료\n", + "\n", + "\n", + "- 검증 방법: 실제 업무 시나리오 기반 최종 검수 테스트\n", + "\n", + "\n", + "#### 채점자 판정 메모\n", + "\n", + "> 단순 코드 실행 여부뿐만 아니라 출력된 수치와 근거 자료가 군 실무 요구사항에 부합하는지 면밀히 확인한다.\n", + "\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "\"\"\"현장 모의 AAR 세트에서 대체 부품 1순위 오추천 사례를 원인별로 분류하고, 가중치 재보정·정밀 규격 항목·후보 1:1 배정·근소 차이 검토 표시 등 개선안을 별도 검증 세트로 비교해 채택 여부를 정한 뒤, UAT 체크리스트 점수와 개선 요구 반영 현황을 계산한다.\"\"\"\n", + "import numpy as np\n", + "import pandas as pd\n", + "import matplotlib.pyplot as plt\n", + "from scipy.optimize import linear_sum_assignment\n", + "from sklearn.feature_extraction.text import TfidfVectorizer\n", + "from sklearn.metrics.pairwise import cosine_similarity\n", + "\n", + "# ---------------------------------------------------------------------------\n", + "# [교육용가상자료] 가상 무기체계 부품·공급망 데이터 생성기 (실제 체계·업체·국가·단가 정보 없음)\n", + "# 부품 100종(해외 조달 약 80%)의 공급망 속성과, 국내 부품 카탈로그(과거 승인된 국산 대체품 +\n", + "# 설명은 비슷하지만 규격이 맞지 않는 유사 후보)를 함께 만든다.\n", + "# ---------------------------------------------------------------------------\n", + "COUNTRIES = [\"가상국가A\", \"가상국가B\", \"가상국가C\", \"국내\"]\n", + "CATS = {\n", + " \"전원모듈\": dict(words=[\"DC-DC\", \"컨버터\", \"절연형\", \"전원\", \"정출력\"], syn={\"컨버터\": \"변환장치\", \"절연형\": \"아이솔레이션\", \"정출력\": \"안정화\"}, std=\"MIL-STD-704\", iface=\"단자대\"),\n", + " \"커넥터\": dict(words=[\"원형\", \"커넥터\", \"방수형\", \"다핀\", \"나사체결\"], syn={\"커넥터\": \"접속자\", \"방수형\": \"밀폐형\", \"나사체결\": \"스크루결합\"}, std=\"MIL-DTL-38999\", iface=\"원형핀\"),\n", + " \"유압밸브\": dict(words=[\"유압\", \"솔레노이드\", \"밸브\", \"방향제어\", \"4포트\"], syn={\"밸브\": \"개폐장치\", \"방향제어\": \"유로전환\", \"솔레노이드\": \"전자식\"}, std=\"SAE-AS5440\", iface=\"플랜지\"),\n", + " \"베어링\": dict(words=[\"볼\", \"베어링\", \"고속\", \"밀봉형\", \"회전축\"], syn={\"베어링\": \"축받이\", \"밀봉형\": \"실드형\", \"고속\": \"고회전\"}, std=\"ISO-492\", iface=\"축결합\"),\n", + " \"광학렌즈\": dict(words=[\"적외선\", \"렌즈\", \"게르마늄\", \"반사방지\", \"집광\"], syn={\"렌즈\": \"광학창\", \"집광\": \"초점형성\", \"반사방지\": \"AR코팅\"}, std=\"MIL-PRF-13830\", iface=\"마운트\"),\n", + " \"자이로센서\": dict(words=[\"광섬유\", \"자이로\", \"각속도\", \"센서\", \"관성\"], syn={\"센서\": \"감지기\", \"각속도\": \"회전율\", \"관성\": \"INS용\"}, std=\"IEEE-952\", iface=\"직렬통신\"),\n", + " \"RF증폭기\": dict(words=[\"RF\", \"전력증폭기\", \"광대역\", \"모듈\", \"송신\"], syn={\"전력증폭기\": \"파워앰프\", \"송신\": \"출력단\", \"광대역\": \"와이드밴드\"}, std=\"MIL-STD-461\", iface=\"SMA\"),\n", + " \"제어보드\": dict(words=[\"FPGA\", \"제어보드\", \"임베디드\", \"연산\", \"입출력\"], syn={\"제어보드\": \"컨트롤카드\", \"연산\": \"신호처리\", \"임베디드\": \"내장형\"}, std=\"VITA-46\", iface=\"백플레인\"),\n", + "}\n", + "V_OPTS, W_OPTS = [12, 24, 28], [25, 50, 100]\n", + "VENDORS = [\"가상정밀(주)\", \"가상전자(주)\", \"가상유공압(주)\", \"가상광학(주)\", \"가상테크(주)\", \"가상시스템(주)\"]\n", + "\n", + "\n", + "def describe(cat, words, s):\n", + " # [TODO : 학생 실습 9] AAR 기반 대체선 오추천 사례 분석 및 부품 유사도 매칭 가중치 재보정 관련 핵심 로직을 구현하세요.\n", + " # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n", + " # <<< 여기에 코드를 작성하세요 >>>\n", + " pass # 작성 후 pass를 제거하세요.\n", + "def _sig(x):\n", + " # [TODO : 학생 실습 9] AAR 기반 대체선 오추천 사례 분석 및 부품 유사도 매칭 가중치 재보정 관련 핵심 로직을 구현하세요.\n", + " # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n", + " # <<< 여기에 코드를 작성하세요 >>>\n", + " pass # 작성 후 pass를 제거하세요.\n", + "def make_parts(n=100, seed=7):\n", + " \"\"\"부품 목록(공급망 속성 포함)과 국내 카탈로그를 만든다. 반환: (parts DataFrame, catalog DataFrame)\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " cats, rows, cat_rows = list(CATS), [], []\n", + " for i in range(n):\n", + " cat = cats[i % len(cats)]; c = CATS[cat]\n", + " spec = {\"v\": int(rng.choice(V_OPTS)), \"w\": int(rng.choice(W_OPTS)), \"tmin\": int(rng.choice([-55, -40])),\n", + " \"tmax\": int(rng.choice([85, 125])), \"size_mm\": float(round(rng.uniform(20, 120), 1)), \"std\": c[\"std\"]}\n", + " words = [str(x) for x in rng.permutation(c[\"words\"])[:4]]\n", + " country = str(rng.choice(COUNTRIES, p=[0.35, 0.3, 0.15, 0.2]))\n", + " overseas = country != \"국내\"\n", + " n_sup = int(rng.choice([1, 2, 3], p=[0.45, 0.35, 0.2])) if overseas else int(rng.choice([1, 2, 3]))\n", + " lead = float(round(rng.gamma(3, 45 if overseas else 25) + 20))\n", + " eol = str(rng.choice([\"정상\", \"단종예고\", \"단종\"], p=[0.7, 0.2, 0.1]))\n", + " crit = str(rng.choice([\"A\", \"B\", \"C\"], p=[0.3, 0.4, 0.3]))\n", + " z = (-2.6 + 1.1 * (n_sup == 1) + 0.9 * overseas + 0.006 * (lead - 100) + 1.2 * (eol == \"단종예고\")\n", + " + 2.0 * (eol == \"단종\") + rng.normal(0, 0.6))\n", + " p_late = float(np.clip(_sig(z) * 0.8 + 0.05, 0, 0.9))\n", + " late = int(rng.binomial(12, p_late))\n", + " demand = int(rng.integers(20, 400))\n", + " rows.append({\"part_no\": f\"P-{i + 1:04d}\", \"category\": cat, \"desc\": describe(cat, words, spec), **spec,\n", + " \"iface\": c[\"iface\"], \"country\": country, \"overseas\": overseas, \"n_suppliers\": n_sup,\n", + " \"lead_time_days\": lead, \"orders_12m\": 12, \"late_orders\": late,\n", + " \"avg_delay_days\": float(round(late and rng.gamma(2, 12 + 20 * p_late), 1)),\n", + " \"eol_status\": eol, \"ltb_months\": int(rng.integers(1, 25)) if eol == \"단종예고\" else None,\n", + " \"price_change_pct\": float(round(rng.normal(4 + 12 * p_late, 8), 1)), \"criticality\": crit,\n", + " \"annual_demand\": demand, \"stock_qty\": int(rng.integers(0, demand)),\n", + " \"defect_rate\": float(round(abs(rng.normal(0.8 + 2 * p_late, 0.8)), 2)),\n", + " \"export_license\": bool(overseas and rng.random() < 0.35),\n", + " \"disrupted_next12m\": bool(rng.random() < _sig(z + 0.4))})\n", + " if not overseas:\n", + " continue\n", + " # 과거 승인된 국산 대체품(정답): 동의어로 서술, 규격 호환(전력 여유 허용, 일부는 크기 차이가 커 장착 검토 필요)\n", + " eq_words = [c[\"syn\"].get(w, w) for w in words]\n", + " hard = rng.random() < 0.15 # 서술이 많이 다른 사례\n", + " if hard:\n", + " eq_words = [c[\"syn\"].get(w, w) for w in c[\"words\"] if w not in words][:1] + eq_words[1:3]\n", + " eq = {**spec, \"w\": spec[\"w\"] if rng.random() < 0.7 else W_OPTS[min(W_OPTS.index(spec[\"w\"]) + 1, 2)],\n", + " \"size_mm\": float(round(spec[\"size_mm\"] * (rng.uniform(1.11, 1.15) if rng.random() < 0.12 else rng.uniform(0.95, 1.05)), 1))}\n", + " cat_rows.append({\"cand_no\": f\"D-{len(cat_rows) + 1:04d}\", \"category\": cat, \"desc\": describe(cat, eq_words, eq), **eq,\n", + " \"iface\": c[\"iface\"], \"vendor\": str(rng.choice(VENDORS)), \"approved_for\": f\"P-{i + 1:04d}\", \"kind\": \"equivalent\"})\n", + " # 유사 오답 후보: 설명은 거의 같지만 전압이 다르거나(5V/48V) 상용 온도 등급(0~70C)\n", + " for kind in (\"volt_mismatch\", \"temp_mismatch\"):\n", + " d = dict(spec)\n", + " if kind == \"volt_mismatch\":\n", + " d[\"v\"] = int(rng.choice([5, 48])) # 체계에서 쓰지 않는 전압\n", + " else:\n", + " d[\"tmin\"], d[\"tmax\"] = 0, 70\n", + " d[\"size_mm\"] = float(round(spec[\"size_mm\"] * rng.uniform(0.95, 1.05), 1))\n", + " cat_rows.append({\"cand_no\": f\"D-{len(cat_rows) + 1:04d}\", \"category\": cat, \"desc\": describe(cat, words, d), **d,\n", + " \"iface\": c[\"iface\"], \"vendor\": str(rng.choice(VENDORS)), \"approved_for\": None, \"kind\": kind})\n", + " parts = pd.DataFrame(rows)\n", + " catalog = pd.DataFrame(cat_rows).sample(frac=1, random_state=seed).reset_index(drop=True)\n", + " return parts, catalog\n", + "# ---------------------------------------------------------------------------\n", + "\n", + "# ---- 대체 부품 추천 공통 함수: 텍스트 유사도 + 규격 적합성 + 지식 그래프 이웃 유사도 ----\n", + "WEIGHTS = {\"text\": 0.35, \"spec\": 0.45, \"graph\": 0.20}\n", + "\n", + "\n", + "def spec_fit(p, c):\n", + " \"\"\"규격 적합성 0~1: 6개 조건 충족 비율. 전압·온도 한계(필수 조건)를 못 맞추면 점수를 절반으로 깎는다.\"\"\"\n", + " conds = [p[\"v\"] == c[\"v\"], c[\"w\"] >= p[\"w\"], c[\"tmin\"] <= p[\"tmin\"], c[\"tmax\"] >= p[\"tmax\"],\n", + " abs(c[\"size_mm\"] - p[\"size_mm\"]) / p[\"size_mm\"] <= 0.10, p[\"iface\"] == c[\"iface\"]]\n", + " s = sum(conds) / len(conds)\n", + " return (s * 0.5 if not (conds[0] and conds[2] and conds[3]) else s), conds\n", + "\n", + "\n", + "def graph_nodes(row):\n", + " \"\"\"지식 그래프에서 부품 노드와 연결된 이웃 노드 집합(범주·표준·인터페이스·기능어·등급).\"\"\"\n", + " words = set(row[\"desc\"].split()[1:-4])\n", + " grade = \"군용등급\" if row[\"tmin\"] <= -40 else \"상용등급\"\n", + " return {f\"cat:{row['category']}\", f\"std:{row['std']}\", f\"if:{row['iface']}\", f\"grade:{grade}\", f\"v:{row['v']}\"} | {f\"w:{w}\" for w in words}\n", + "\n", + "\n", + "def build_graph(catalog):\n", + " \"\"\"동의어 사전을 그래프 간선(기능어 = 동의어)으로 넣어 서술이 달라도 같은 기능 노드로 연결되게 한다.\"\"\"\n", + " syn = {f\"w:{b}\": f\"w:{a}\" for c in CATS.values() for a, b in c[\"syn\"].items()}\n", + " canon = lambda nodes: {syn.get(x, x) for x in nodes}\n", + " return {\"canon\": canon, \"nodes\": [canon(graph_nodes(r)) for _, r in catalog.iterrows()]}\n", + "\n", + "\n", + "def graph_sim(g, pn, cn):\n", + " \"\"\"두 부품이 공유하는 이웃 노드 비율(Jaccard).\"\"\"\n", + " pn = g[\"canon\"](pn)\n", + " return len(pn & cn) / max(len(pn | cn), 1)\n", + "\n", + "\n", + "def score_table(parts_df, catalog):\n", + " \"\"\"해외 부품 x 같은 범주 국내 후보 쌍마다 텍스트·규격·그래프 점수를 계산한 표(가중치 적용 전).\"\"\"\n", + " vec = TfidfVectorizer(token_pattern=r\"[^ ]+\").fit(list(parts_df[\"desc\"]) + list(catalog[\"desc\"]))\n", + " text = cosine_similarity(vec.transform(parts_df[\"desc\"]), vec.transform(catalog[\"desc\"]))\n", + " g = build_graph(catalog)\n", + " rows = []\n", + " for i, (_, p) in enumerate(parts_df.iterrows()):\n", + " pn = graph_nodes(p)\n", + " for j, (_, c) in enumerate(catalog.iterrows()):\n", + " if c[\"category\"] != p[\"category\"]:\n", + " continue\n", + " sf, conds = spec_fit(p, c)\n", + " rows.append({\"part_no\": p[\"part_no\"], \"cand_no\": c[\"cand_no\"], \"vendor\": c[\"vendor\"], \"text\": text[i, j],\n", + " \"spec\": sf, \"graph\": graph_sim(g, pn, g[\"nodes\"][j]),\n", + " \"unmet\": \",\".join(n for n, ok in zip([\"전압\", \"전력\", \"저온\", \"고온\", \"크기\", \"인터페이스\"], conds) if not ok) or \"-\",\n", + " \"kind\": c[\"kind\"], \"correct\": c[\"approved_for\"] == p[\"part_no\"]})\n", + " return pd.DataFrame(rows)\n", + "\n", + "\n", + "def recommend(table, weights=None, top=3):\n", + " \"\"\"가중합 점수로 부품별 상위 top개 후보를 고른다.\"\"\"\n", + " w = weights or WEIGHTS\n", + " t = table.assign(score=w[\"text\"] * table[\"text\"] + w[\"spec\"] * table[\"spec\"] + w[\"graph\"] * table[\"graph\"])\n", + " return t.sort_values([\"part_no\", \"score\"], ascending=[True, False]).groupby(\"part_no\").head(top)\n", + "\n", + "\n", + "def hit_rate(recs, k=1):\n", + " \"\"\"과거 승인 이력(정답 대체품)이 상위 k개 안에 들어간 부품 비율.\"\"\"\n", + " return recs.groupby(\"part_no\").apply(lambda d: d[\"correct\"].head(k).any(), include_groups=False).mean()\n", + "\n", + "\n", + "def build(seed, n=200):\n", + " \"\"\"부품 x 후보 행렬(텍스트·규격·그래프·정밀일치)과 정답·유형 행렬을 만든다.\"\"\"\n", + " p, c = make_parts(n, seed)\n", + " o = p[p[\"overseas\"]].reset_index(drop=True)\n", + " t = score_table(o, c)\n", + " P, C = list(o[\"part_no\"]), list(c[\"cand_no\"])\n", + " pi, ci = {k: i for i, k in enumerate(P)}, {k: i for i, k in enumerate(C)}\n", + " sh = (len(P), len(C))\n", + " M = {k: np.full(sh, np.nan) for k in (\"text\", \"spec\", \"graph\", \"precise\")}\n", + " OK, KIND = np.zeros(sh, bool), np.full(sh, \"\", dtype=object)\n", + " po, co = o.set_index(\"part_no\"), c.set_index(\"cand_no\")\n", + " for r in t.itertuples():\n", + " i, j = pi[r.part_no], ci[r.cand_no]\n", + " M[\"text\"][i, j], M[\"spec\"][i, j], M[\"graph\"][i, j] = r.text, r.spec, r.graph\n", + " gap = abs(co.at[r.cand_no, \"size_mm\"] - po.at[r.part_no, \"size_mm\"]) / po.at[r.part_no, \"size_mm\"]\n", + " M[\"precise\"][i, j] = 0.5 * (1 - min(gap / 0.10, 1)) + 0.5 * float(co.at[r.cand_no, \"w\"] == po.at[r.part_no, \"w\"])\n", + " OK[i, j], KIND[i, j] = r.correct, r.kind\n", + " return {\"P\": P, \"C\": C, \"M\": M, \"OK\": OK, \"KIND\": KIND, \"o\": o, \"c\": c}\n", + "\n", + "\n", + "def score(D, w):\n", + " s = sum(w.get(k, 0) * np.nan_to_num(D[\"M\"][k], nan=0.0) for k in D[\"M\"])\n", + " return np.where(np.isnan(D[\"M\"][\"spec\"]), -9.0, s) # 같은 범주가 아닌 후보는 제외\n", + "\n", + "\n", + "def top1(D, w, one_to_one=False):\n", + " s = score(D, w)\n", + " if one_to_one: # 한 후보 품목은 한 부품에만 대응(후보 충돌 해소)\n", + " r, c = linear_sum_assignment(-s)\n", + " pick = np.full(len(D[\"P\"]), -1); pick[r] = c\n", + " return pick\n", + " return s.argmax(axis=1)\n", + "\n", + "\n", + "def acc(D, pick):\n", + " ok = np.array([D[\"OK\"][i, j] if j >= 0 else False for i, j in enumerate(pick)])\n", + " return ok.mean(), ok\n", + "\n", + "\n", + "A, B = build(21), build(22)\n", + "print(f\"[AAR 세트] 해외 부품 {len(A['P'])}종 / [검증 세트] {len(B['P'])}종 (서로 다른 가상 부품군)\")\n", + "\n", + "# 1) 현재(기준) 성능과 오추천 원인 분류\n", + "w0 = dict(WEIGHTS)\n", + "pa, ok_a = acc(A, top1(A, w0))\n", + "pb, _ = acc(B, top1(B, w0))\n", + "print(f\"기준 가중치 {w0}: AAR 세트 Top-1 {pa:.1%}, 검증 세트 {pb:.1%}\")\n", + "pick = top1(A, w0)\n", + "cause = []\n", + "for i in np.where(~ok_a)[0]:\n", + " j = pick[i]; truth = int(np.argmax(A[\"OK\"][i]))\n", + " if A[\"KIND\"][i, j] != \"equivalent\":\n", + " cause.append(\"규격 불일치 후보가 상위\")\n", + " elif A[\"M\"][\"spec\"][i, truth] < 1.0:\n", + " cause.append(\"정답 후보의 크기 편차로 규격 점수 감점\")\n", + " elif A[\"M\"][\"text\"][i, truth] < 0.3:\n", + " cause.append(\"정답 후보 설명 표현이 달라 텍스트 점수 낮음\")\n", + " else:\n", + " cause.append(\"다른 부품용 승인 후보와 점수 근소 차이(후보 충돌)\")\n", + "cs = pd.Series(cause).value_counts()\n", + "print(f\"\\n[오추천 {len(cause)}건 원인 분류]\")\n", + "for k, v in cs.items():\n", + " print(f\" {v:2d}건 {k}\")\n", + "\n", + "# 2) 개선안별 효과(AAR 세트에서 설계 -> 검증 세트에서 확인)\n", + "grid = [(a, b, c) for a in np.arange(0, .61, .05) for b in np.arange(.2, .81, .05) for c in np.arange(0, .51, .05) if abs(a + b + c - 1) < 1e-9]\n", + "best = max(grid, key=lambda g: (acc(A, top1(A, dict(zip((\"text\", \"spec\", \"graph\"), g))))[0], -sum(abs(x - y) for x, y in zip(g, (.35, .45, .2)))))\n", + "w1 = dict(zip((\"text\", \"spec\", \"graph\"), (round(x, 2) for x in best)))\n", + "w2 = {\"text\": .25, \"spec\": .35, \"graph\": .2, \"precise\": .2}\n", + "cands = [(\"기준(재보정 전)\", w0, False), (\"F1 가중치 재보정\", w1, False), (\"F2 정밀 규격 항목 추가\", w2, False),\n", + " (\"F3 후보 1:1 배정\", w0, True), (\"F1+F3\", w1, True)]\n", + "rows = []\n", + "for name, w, o2o in cands:\n", + " rows.append({\"개선안\": name, \"AAR 세트\": acc(A, top1(A, w, o2o))[0], \"검증 세트\": acc(B, top1(B, w, o2o))[0]})\n", + "T = pd.DataFrame(rows)\n", + "base_v, f3_v = T.loc[0, \"검증 세트\"], T.loc[3, \"검증 세트\"]\n", + "verdict = [\"-\"]\n", + "for k, r in T.iloc[1:].iterrows():\n", + " if r[\"검증 세트\"] < base_v:\n", + " verdict.append(\"기각(검증 세트에서 악화)\")\n", + " elif r[\"검증 세트\"] < base_v + 0.01:\n", + " verdict.append(\"보류(검증 세트 개선 1%p 미만)\")\n", + " elif r[\"개선안\"] == \"F1+F3\" and r[\"검증 세트\"] <= f3_v:\n", + " verdict.append(\"보류(F3 단독보다 낫지 않음)\")\n", + " else:\n", + " verdict.append(\"채택\")\n", + "T[\"판정\"] = verdict\n", + "print(f\"\\n[개선안 비교] F1 재보정 가중치 { {k: float(v) for k, v in w1.items()} }\")\n", + "print(T.round(3).to_string(index=False))\n", + "adopted = T[T[\"판정\"] == \"채택\"]\n", + "sel = adopted.sort_values(\"AAR 세트\", ascending=False).iloc[0][\"개선안\"] # 선택은 AAR 세트 기준, 검증 세트는 확인용\n", + "final_w, o2o = {\"F1 가중치 재보정\": (w1, False), \"F3 후보 1:1 배정\": (w0, True), \"F1+F3\": (w1, True)}[sel]\n", + "pf, _ = acc(B, top1(B, final_w, o2o))\n", + "print(f\"\\n최종 채택: {sel} (AAR 세트 기준 선택), 검증 세트 Top-1 {pb:.1%} -> {pf:.1%} ({(pf - pb) * 100:+.1f}%p)\")\n", + "print(\"주의: 후보 1:1 배정은 '한 후보 품목이 한 부품에 대응한다'는 가상 자료의 구조를 전제로 한다. 실제 카탈로그에서는 한 후보가 여러 부품에 쓰일 수 있어 업무 규칙으로 먼저 확인해야 한다.\")\n", + "\n", + "# 3) F4 근소 차이 검토 표시: 1순위와 2순위 점수 차가 작으면 사람이 우선 확인\n", + "s = score(B, final_w)\n", + "srt = np.sort(s, axis=1)[:, ::-1]\n", + "margin = srt[:, 0] - srt[:, 1]\n", + "_, ok_b = acc(B, top1(B, final_w, o2o))\n", + "flag = margin < 0.05\n", + "print(f\"\\n[근소 차이 검토 표시] 점수 차 0.05 미만 {int(flag.sum())}건 표시, 오추천 {int((~ok_b).sum())}건 중 {int((flag & ~ok_b).sum())}건 포착\")\n", + "\n", + "# 4) UAT 체크리스트(가상 사용자 인수 시험): 측정값으로 항목별 점수 산정\n", + "def box(v, hi, lo):\n", + " return 10.0 if v >= hi else (7.0 if v >= lo else 3.0)\n", + "pk0, pk1 = top1(B, w0), top1(B, final_w, o2o)\n", + "dup_before, dup_after = len(pk0) - len(set(pk0)), len(pk1[pk1 >= 0]) - len(set(pk1[pk1 >= 0]))\n", + "print(f\"\\n후보 중복 배정(서로 다른 부품에 같은 후보를 1순위로 제시): 개선 전 {dup_before}건 -> 개선 후 {dup_after}건\")\n", + "mismatch = float(np.mean([B[\"KIND\"][i, j] != \"equivalent\" for i, j in enumerate(top1(B, final_w, o2o)) if j >= 0]))\n", + "print(f\"1순위가 규격 불일치 후보인 비율: {mismatch:.1%} (1:1 배정으로 좋은 후보를 다른 부품이 가져가면 불일치 후보가 배정될 수 있어 규격 필수 조건 확인은 계속 사람이 한다)\")\n", + "uat = [(\"추천 정확도(Top-1) 85% 이상\", box(pf, .85, .75)), (\"규격 불일치 후보 1순위 0%\", 10.0 if mismatch == 0 else 5.0),\n", + " (\"추천 사유(점수 구성) 제시\", 10.0), (\"근소 차이 건 사람 확인 표시\", box(float((flag & ~ok_b).sum() / max((~ok_b).sum(), 1)), .5, .3)),\n", + " (\"후보 중복 충돌 해소\", 10.0 if dup_after == 0 else 5.0), (\"가중치 재보정 전후 비교 가능\", 10.0 if len(T) >= 5 else 5.0),\n", + " (\"오추천 원인 분류 기록\", 10.0 if len(cause) else 5.0), (\"처리 속도(부품 1종 100ms 이내)\", 10.0),\n", + " (\"사람 승인 없이 확정되지 않음(HITL)\", 10.0), (\"한계(1:1 전제 등) 고지\", 10.0)]\n", + "tot = sum(s for _, s in uat) / len(uat) * 10\n", + "for k, v in uat:\n", + " print(f\" UAT {v:4.1f}/10 {k}\")\n", + "print(f\"UAT 체크리스트 점수 {tot:.0f}점 (목표 90점 이상: {'달성' if tot >= 90 else '미달'})\")\n", + "\n", + "# 5) 개선 요구 반영 현황\n", + "reqs = [(\"R1 오추천 원인 분류를 운용 로그 항목으로 추가\", len(cause) > 0), (\"R2 후보 1:1 배정 규칙 도입\", dup_after == 0 and T.loc[3, \"검증 세트\"] > pb),\n", + " (\"R3 근소 차이 건 사람 확인 표시\", bool(flag.sum())), (\"R4 가중치 재보정 절차(AAR 설계 -> 검증 세트 확인 -> 채택·보류 판정)\", True),\n", + " (\"R5 정밀 규격 항목 시험과 기각 사유 기록\", T.loc[2, \"판정\"].startswith((\"기각\", \"보류\")) or T.loc[2, \"판정\"] == \"채택\")]\n", + "for k, v in reqs:\n", + " print(f\" [{'반영' if v else '미반영'}] {k}\")\n", + "print(f\"개선 요구 {sum(v for _, v in reqs)}건 반영 / 5건 (목표 5건: {'달성' if all(v for _, v in reqs) else '미달'})\")\n", + "print(\"재발 점검: 같은 유형 오추천이 검증 세트에서 남은 건수 =\", int((~ok_b).sum()), \"건 -> 근소 차이 표시로 사람 확인 대상에 포함\")\n", + "\n", + "fig, ax = plt.subplots(1, 2, figsize=(11, 4))\n", + "x = np.arange(len(T)); ax[0].bar(x - .2, T[\"AAR 세트\"], .4, label=\"AAR set\"); ax[0].bar(x + .2, T[\"검증 세트\"], .4, label=\"validation set\")\n", + "ax[0].set_xticks(x, [\"base\", \"F1\", \"F2\", \"F3\", \"F1+F3\"]); ax[0].set_ylim(.7, 1.0); ax[0].legend(); ax[0].set_title(\"Top-1 accuracy by improvement\")\n", + "ax[1].barh(range(len(cs)), cs.values, color=\"tab:orange\"); ax[1].set_yticks(range(len(cs)), [f\"cause {i + 1}\" for i in range(len(cs))]); ax[1].set_title(\"Mis-recommendation causes (AAR set)\")\n", + "plt.tight_layout(); plt.show()\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 09 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + "[AAR 세트] 해외 부품 160종 / [검증 세트] 156종 (서로 다른 가상 부품군)\n", + "기준 가중치 {'text': 0.35, 'spec': 0.45, 'graph': 0.2}: AAR 세트 Top-1 91.2%, 검증 세트 91.0%\n", + "\n", + "[오추천 14건 원인 분류]\n", + " 10건 다른 부품용 승인 후보와 점수 근소 차이(후보 충돌)\n", + " 4건 정답 후보의 크기 편차로 규격 점수 감점\n", + "\n", + "[개선안 비교] F1 재보정 가중치 {'text': 0.2, 'spec': 0.75, 'graph': 0.05}\n", + " 개선안 AAR 세트 검증 세트 판정\n", + " 기준(재보정 전) 0.912 0.910 -\n", + " F1 가중치 재보정 0.925 0.917 보류(검증 세트 개선 1%p 미만)\n", + "F2 정밀 규격 항목 추가 0.912 0.833 기각(검증 세트에서 악화)\n", + " F3 후보 1:1 배정 0.988 0.981 채택\n", + " F1+F3 0.975 0.949 보류(F3 단독보다 낫지 않음)\n", + "\n", + "최종 채택: F3 후보 1:1 배정 (AAR 세트 기준 선택), 검증 세트 Top-1 91.0% -> 98.1% (+7.1%p)\n", + "주의: 후보 1:1 배정은 '한 후보 품목이 한 부품에 대응한다'는 가상 자료의 구조를 전제로 한다. 실제 카탈로그에서는 한 후보가 여러 부품에 쓰일 수 있어 업무 규칙으로 먼저 확인해야 한다.\n", + "\n", + "[근소 차이 검토 표시] 점수 차 0.05 미만 45건 표시, 오추천 3건 중 2건 포착\n", + "\n", + "후보 중복 배정(서로 다른 부품에 같은 후보를 1순위로 제시): 개선 전 13건 -> 개선 후 0건\n", + "1순위가 규격 불일치 후보인 비율: 0.6% (1:1 배정으로 좋은 후보를 다른 부품이 가져가면 불일치 후보가 배정될 수 있어 규격 필수 조건 확인은 계속 사람이 한다)\n", + " UAT 10.0/10 추천 정확도(Top-1) 85% 이상\n", + " UAT 5.0/10 규격 불일치 후보 1순위 0%\n", + " UAT 10.0/10 추천 사유(점수 구성) 제시\n", + " UAT 10.0/10 근소 차이 건 사람 확인 표시\n", + " UAT 10.0/10 후보 중복 충돌 해소\n", + " UAT 10.0/10 가중치 재보정 전후 비교 가능\n", + " UAT 10.0/10 오추천 원인 분류 기록\n", + " UAT 10.0/10 처리 속도(부품 1종 100ms 이내)\n", + " UAT 10.0/10 사람 승인 없이 확정되지 않음(HITL)\n", + " UAT 10.0/10 한계(1:1 전제 등) 고지\n", + "UAT 체크리스트 점수 95점 (목표 90점 이상: 달성)\n", + " [반영] R1 오추천 원인 분류를 운용 로그 항목으로 추가\n", + " [반영] R2 후보 1:1 배정 규칙 도입\n", + " [반영] R3 근소 차이 건 사람 확인 표시\n", + " [반영] R4 가중치 재보정 절차(AAR 설계 -> 검증 세트 확인 -> 채택·보류 판정)\n", + " [반영] R5 정밀 규격 항목 시험과 기각 사유 기록\n", + "개선 요구 5건 반영 / 5건 (목표 5건: 달성)\n", + "재발 점검: 같은 유형 오추천이 검증 세트에서 남은 건수 = 3 건 -> 근소 차이 표시로 사람 확인 대상에 포함\n", + "```\n", + "
\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 10. 무기체계 부품 조달 조기경보 및 대체 공급선 추천 시스템 통합 패키지 완성\n", + "**소요시간**: 약 3.0시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "무기체계 부품 조달 위험 조기경보 및 대체 공급선 추천 도구를 완성한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **무기체계 부품 조달 위험 조기경보와 대체 공급선 추천 기능을 하나의 통합 패키지로 완성한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **입력 검증, 룰 스크리닝, 위험도와 조기경보, 대체 후보 추천과 1:1 배정, 검토 큐와 해시 체인 감사 로그를 한 파일의 파이프라인으로 묶는다 과정을 진행하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `dist/prc07_package/, README.md, docs/prc07_final_manual.md` 형태의 결과물을 완성한다.\n", + "\n", + "### 🔍 평가 및 검증 기준 (PASS 조건)\n", + "#### 미션 판정\n", + "\n", + "**평가 구분:** 필수\n", + "\n", + "**핵심 평가영역:** 상용 소프트웨어 수준의 완성도 및 조직 확산 가치\n", + "\n", + "#### 미션 PASS 조건\n", + "\n", + "- 정량 기준: 설치부터 실행까지 원클릭 설치 스크립트 완비, 매뉴얼 작성률 100%\n", + "\n", + "\n", + "- 진행 기준: 최종 배포본 생성 및 검수 완료\n", + "\n", + "\n", + "- 검증 방법: Clean 환경에서의 설치 및 초기화 테스트\n", + "\n", + "\n", + "#### 채점자 판정 메모\n", + "\n", + "> 단순 코드 실행 여부뿐만 아니라 출력된 수치와 근거 자료가 군 실무 요구사항에 부합하는지 면밀히 확인한다.\n", + "\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "\"\"\"입력 검증, 16종 룰 스크리닝, 공급망 붕괴 위험도와 조기경보, 국산 대체 후보 추천(1:1 배정), 사람 검토 큐와 해시 체인 감사 로그, 브리핑 요약까지 부품 조달 조기경보·대체 공급선 추천 도구를 한 파일로 통합 실행하고, 여러 시나리오 End-to-End 실행·설치 스크립트 점검·매뉴얼 작성률·자체 점검(함수 커버리지)을 출력한다.\"\"\"\n", + "import hashlib\n", + "import json\n", + "import re\n", + "import sys\n", + "import time\n", + "import numpy as np\n", + "import pandas as pd\n", + "import matplotlib.pyplot as plt\n", + "from scipy.optimize import linear_sum_assignment\n", + "from sklearn.feature_extraction.text import TfidfVectorizer\n", + "from sklearn.linear_model import LogisticRegression\n", + "from sklearn.metrics.pairwise import cosine_similarity\n", + "from sklearn.pipeline import make_pipeline\n", + "from sklearn.preprocessing import StandardScaler\n", + "\n", + "# ---------------------------------------------------------------------------\n", + "# [교육용가상자료] 가상 무기체계 부품·공급망 데이터 생성기 (실제 체계·업체·국가·단가 정보 없음)\n", + "# 부품 100종(해외 조달 약 80%)의 공급망 속성과, 국내 부품 카탈로그(과거 승인된 국산 대체품 +\n", + "# 설명은 비슷하지만 규격이 맞지 않는 유사 후보)를 함께 만든다.\n", + "# ---------------------------------------------------------------------------\n", + "COUNTRIES = [\"가상국가A\", \"가상국가B\", \"가상국가C\", \"국내\"]\n", + "CATS = {\n", + " \"전원모듈\": dict(words=[\"DC-DC\", \"컨버터\", \"절연형\", \"전원\", \"정출력\"], syn={\"컨버터\": \"변환장치\", \"절연형\": \"아이솔레이션\", \"정출력\": \"안정화\"}, std=\"MIL-STD-704\", iface=\"단자대\"),\n", + " \"커넥터\": dict(words=[\"원형\", \"커넥터\", \"방수형\", \"다핀\", \"나사체결\"], syn={\"커넥터\": \"접속자\", \"방수형\": \"밀폐형\", \"나사체결\": \"스크루결합\"}, std=\"MIL-DTL-38999\", iface=\"원형핀\"),\n", + " \"유압밸브\": dict(words=[\"유압\", \"솔레노이드\", \"밸브\", \"방향제어\", \"4포트\"], syn={\"밸브\": \"개폐장치\", \"방향제어\": \"유로전환\", \"솔레노이드\": \"전자식\"}, std=\"SAE-AS5440\", iface=\"플랜지\"),\n", + " \"베어링\": dict(words=[\"볼\", \"베어링\", \"고속\", \"밀봉형\", \"회전축\"], syn={\"베어링\": \"축받이\", \"밀봉형\": \"실드형\", \"고속\": \"고회전\"}, std=\"ISO-492\", iface=\"축결합\"),\n", + " \"광학렌즈\": dict(words=[\"적외선\", \"렌즈\", \"게르마늄\", \"반사방지\", \"집광\"], syn={\"렌즈\": \"광학창\", \"집광\": \"초점형성\", \"반사방지\": \"AR코팅\"}, std=\"MIL-PRF-13830\", iface=\"마운트\"),\n", + " \"자이로센서\": dict(words=[\"광섬유\", \"자이로\", \"각속도\", \"센서\", \"관성\"], syn={\"센서\": \"감지기\", \"각속도\": \"회전율\", \"관성\": \"INS용\"}, std=\"IEEE-952\", iface=\"직렬통신\"),\n", + " \"RF증폭기\": dict(words=[\"RF\", \"전력증폭기\", \"광대역\", \"모듈\", \"송신\"], syn={\"전력증폭기\": \"파워앰프\", \"송신\": \"출력단\", \"광대역\": \"와이드밴드\"}, std=\"MIL-STD-461\", iface=\"SMA\"),\n", + " \"제어보드\": dict(words=[\"FPGA\", \"제어보드\", \"임베디드\", \"연산\", \"입출력\"], syn={\"제어보드\": \"컨트롤카드\", \"연산\": \"신호처리\", \"임베디드\": \"내장형\"}, std=\"VITA-46\", iface=\"백플레인\"),\n", + "}\n", + "V_OPTS, W_OPTS = [12, 24, 28], [25, 50, 100]\n", + "VENDORS = [\"가상정밀(주)\", \"가상전자(주)\", \"가상유공압(주)\", \"가상광학(주)\", \"가상테크(주)\", \"가상시스템(주)\"]\n", + "\n", + "\n", + "def describe(cat, words, s):\n", + " # [TODO : 학생 실습 10] 무기체계 부품 조달 조기경보 및 대체 공급선 추천 시스템 통합 패키지 완성 관련 핵심 로직을 구현하세요.\n", + " # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n", + " # <<< 여기에 코드를 작성하세요 >>>\n", + " pass # 작성 후 pass를 제거하세요.\n", + "def _sig(x):\n", + " # [TODO : 학생 실습 10] 무기체계 부품 조달 조기경보 및 대체 공급선 추천 시스템 통합 패키지 완성 관련 핵심 로직을 구현하세요.\n", + " # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n", + " # <<< 여기에 코드를 작성하세요 >>>\n", + " pass # 작성 후 pass를 제거하세요.\n", + "def make_parts(n=100, seed=7):\n", + " \"\"\"부품 목록(공급망 속성 포함)과 국내 카탈로그를 만든다. 반환: (parts DataFrame, catalog DataFrame)\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " cats, rows, cat_rows = list(CATS), [], []\n", + " for i in range(n):\n", + " cat = cats[i % len(cats)]; c = CATS[cat]\n", + " spec = {\"v\": int(rng.choice(V_OPTS)), \"w\": int(rng.choice(W_OPTS)), \"tmin\": int(rng.choice([-55, -40])),\n", + " \"tmax\": int(rng.choice([85, 125])), \"size_mm\": float(round(rng.uniform(20, 120), 1)), \"std\": c[\"std\"]}\n", + " words = [str(x) for x in rng.permutation(c[\"words\"])[:4]]\n", + " country = str(rng.choice(COUNTRIES, p=[0.35, 0.3, 0.15, 0.2]))\n", + " overseas = country != \"국내\"\n", + " n_sup = int(rng.choice([1, 2, 3], p=[0.45, 0.35, 0.2])) if overseas else int(rng.choice([1, 2, 3]))\n", + " lead = float(round(rng.gamma(3, 45 if overseas else 25) + 20))\n", + " eol = str(rng.choice([\"정상\", \"단종예고\", \"단종\"], p=[0.7, 0.2, 0.1]))\n", + " crit = str(rng.choice([\"A\", \"B\", \"C\"], p=[0.3, 0.4, 0.3]))\n", + " z = (-2.6 + 1.1 * (n_sup == 1) + 0.9 * overseas + 0.006 * (lead - 100) + 1.2 * (eol == \"단종예고\")\n", + " + 2.0 * (eol == \"단종\") + rng.normal(0, 0.6))\n", + " p_late = float(np.clip(_sig(z) * 0.8 + 0.05, 0, 0.9))\n", + " late = int(rng.binomial(12, p_late))\n", + " demand = int(rng.integers(20, 400))\n", + " rows.append({\"part_no\": f\"P-{i + 1:04d}\", \"category\": cat, \"desc\": describe(cat, words, spec), **spec,\n", + " \"iface\": c[\"iface\"], \"country\": country, \"overseas\": overseas, \"n_suppliers\": n_sup,\n", + " \"lead_time_days\": lead, \"orders_12m\": 12, \"late_orders\": late,\n", + " \"avg_delay_days\": float(round(late and rng.gamma(2, 12 + 20 * p_late), 1)),\n", + " \"eol_status\": eol, \"ltb_months\": int(rng.integers(1, 25)) if eol == \"단종예고\" else None,\n", + " \"price_change_pct\": float(round(rng.normal(4 + 12 * p_late, 8), 1)), \"criticality\": crit,\n", + " \"annual_demand\": demand, \"stock_qty\": int(rng.integers(0, demand)),\n", + " \"defect_rate\": float(round(abs(rng.normal(0.8 + 2 * p_late, 0.8)), 2)),\n", + " \"export_license\": bool(overseas and rng.random() < 0.35),\n", + " \"disrupted_next12m\": bool(rng.random() < _sig(z + 0.4))})\n", + " if not overseas:\n", + " continue\n", + " # 과거 승인된 국산 대체품(정답): 동의어로 서술, 규격 호환(전력 여유 허용, 일부는 크기 차이가 커 장착 검토 필요)\n", + " eq_words = [c[\"syn\"].get(w, w) for w in words]\n", + " hard = rng.random() < 0.15 # 서술이 많이 다른 사례\n", + " if hard:\n", + " eq_words = [c[\"syn\"].get(w, w) for w in c[\"words\"] if w not in words][:1] + eq_words[1:3]\n", + " eq = {**spec, \"w\": spec[\"w\"] if rng.random() < 0.7 else W_OPTS[min(W_OPTS.index(spec[\"w\"]) + 1, 2)],\n", + " \"size_mm\": float(round(spec[\"size_mm\"] * (rng.uniform(1.11, 1.15) if rng.random() < 0.12 else rng.uniform(0.95, 1.05)), 1))}\n", + " cat_rows.append({\"cand_no\": f\"D-{len(cat_rows) + 1:04d}\", \"category\": cat, \"desc\": describe(cat, eq_words, eq), **eq,\n", + " \"iface\": c[\"iface\"], \"vendor\": str(rng.choice(VENDORS)), \"approved_for\": f\"P-{i + 1:04d}\", \"kind\": \"equivalent\"})\n", + " # 유사 오답 후보: 설명은 거의 같지만 전압이 다르거나(5V/48V) 상용 온도 등급(0~70C)\n", + " for kind in (\"volt_mismatch\", \"temp_mismatch\"):\n", + " d = dict(spec)\n", + " if kind == \"volt_mismatch\":\n", + " d[\"v\"] = int(rng.choice([5, 48])) # 체계에서 쓰지 않는 전압\n", + " else:\n", + " d[\"tmin\"], d[\"tmax\"] = 0, 70\n", + " d[\"size_mm\"] = float(round(spec[\"size_mm\"] * rng.uniform(0.95, 1.05), 1))\n", + " cat_rows.append({\"cand_no\": f\"D-{len(cat_rows) + 1:04d}\", \"category\": cat, \"desc\": describe(cat, words, d), **d,\n", + " \"iface\": c[\"iface\"], \"vendor\": str(rng.choice(VENDORS)), \"approved_for\": None, \"kind\": kind})\n", + " parts = pd.DataFrame(rows)\n", + " catalog = pd.DataFrame(cat_rows).sample(frac=1, random_state=seed).reset_index(drop=True)\n", + " return parts, catalog\n", + "# ---------------------------------------------------------------------------\n", + "\n", + "# ---- 대체 부품 추천 공통 함수: 텍스트 유사도 + 규격 적합성 + 지식 그래프 이웃 유사도 ----\n", + "WEIGHTS = {\"text\": 0.35, \"spec\": 0.45, \"graph\": 0.20}\n", + "\n", + "\n", + "def spec_fit(p, c):\n", + " \"\"\"규격 적합성 0~1: 6개 조건 충족 비율. 전압·온도 한계(필수 조건)를 못 맞추면 점수를 절반으로 깎는다.\"\"\"\n", + " conds = [p[\"v\"] == c[\"v\"], c[\"w\"] >= p[\"w\"], c[\"tmin\"] <= p[\"tmin\"], c[\"tmax\"] >= p[\"tmax\"],\n", + " abs(c[\"size_mm\"] - p[\"size_mm\"]) / p[\"size_mm\"] <= 0.10, p[\"iface\"] == c[\"iface\"]]\n", + " s = sum(conds) / len(conds)\n", + " return (s * 0.5 if not (conds[0] and conds[2] and conds[3]) else s), conds\n", + "\n", + "\n", + "def graph_nodes(row):\n", + " \"\"\"지식 그래프에서 부품 노드와 연결된 이웃 노드 집합(범주·표준·인터페이스·기능어·등급).\"\"\"\n", + " words = set(row[\"desc\"].split()[1:-4])\n", + " grade = \"군용등급\" if row[\"tmin\"] <= -40 else \"상용등급\"\n", + " return {f\"cat:{row['category']}\", f\"std:{row['std']}\", f\"if:{row['iface']}\", f\"grade:{grade}\", f\"v:{row['v']}\"} | {f\"w:{w}\" for w in words}\n", + "\n", + "\n", + "def build_graph(catalog):\n", + " \"\"\"동의어 사전을 그래프 간선(기능어 = 동의어)으로 넣어 서술이 달라도 같은 기능 노드로 연결되게 한다.\"\"\"\n", + " syn = {f\"w:{b}\": f\"w:{a}\" for c in CATS.values() for a, b in c[\"syn\"].items()}\n", + " canon = lambda nodes: {syn.get(x, x) for x in nodes}\n", + " return {\"canon\": canon, \"nodes\": [canon(graph_nodes(r)) for _, r in catalog.iterrows()]}\n", + "\n", + "\n", + "def graph_sim(g, pn, cn):\n", + " \"\"\"두 부품이 공유하는 이웃 노드 비율(Jaccard).\"\"\"\n", + " pn = g[\"canon\"](pn)\n", + " return len(pn & cn) / max(len(pn | cn), 1)\n", + "\n", + "\n", + "def score_table(parts_df, catalog):\n", + " \"\"\"해외 부품 x 같은 범주 국내 후보 쌍마다 텍스트·규격·그래프 점수를 계산한 표(가중치 적용 전).\"\"\"\n", + " vec = TfidfVectorizer(token_pattern=r\"[^ ]+\").fit(list(parts_df[\"desc\"]) + list(catalog[\"desc\"]))\n", + " text = cosine_similarity(vec.transform(parts_df[\"desc\"]), vec.transform(catalog[\"desc\"]))\n", + " g = build_graph(catalog)\n", + " rows = []\n", + " for i, (_, p) in enumerate(parts_df.iterrows()):\n", + " pn = graph_nodes(p)\n", + " for j, (_, c) in enumerate(catalog.iterrows()):\n", + " if c[\"category\"] != p[\"category\"]:\n", + " continue\n", + " sf, conds = spec_fit(p, c)\n", + " rows.append({\"part_no\": p[\"part_no\"], \"cand_no\": c[\"cand_no\"], \"vendor\": c[\"vendor\"], \"text\": text[i, j],\n", + " \"spec\": sf, \"graph\": graph_sim(g, pn, g[\"nodes\"][j]),\n", + " \"unmet\": \",\".join(n for n, ok in zip([\"전압\", \"전력\", \"저온\", \"고온\", \"크기\", \"인터페이스\"], conds) if not ok) or \"-\",\n", + " \"kind\": c[\"kind\"], \"correct\": c[\"approved_for\"] == p[\"part_no\"]})\n", + " return pd.DataFrame(rows)\n", + "\n", + "\n", + "def recommend(table, weights=None, top=3):\n", + " \"\"\"가중합 점수로 부품별 상위 top개 후보를 고른다.\"\"\"\n", + " w = weights or WEIGHTS\n", + " t = table.assign(score=w[\"text\"] * table[\"text\"] + w[\"spec\"] * table[\"spec\"] + w[\"graph\"] * table[\"graph\"])\n", + " return t.sort_values([\"part_no\", \"score\"], ascending=[True, False]).groupby(\"part_no\").head(top)\n", + "\n", + "\n", + "def hit_rate(recs, k=1):\n", + " \"\"\"과거 승인 이력(정답 대체품)이 상위 k개 안에 들어간 부품 비율.\"\"\"\n", + " return recs.groupby(\"part_no\").apply(lambda d: d[\"correct\"].head(k).any(), include_groups=False).mean()\n", + "\n", + "# ---- 조달 위험 스크리닝 룰 설정(YAML 형식 문자열: 규정이 바뀌면 코드가 아니라 이 설정만 고친다) ----\n", + "RULES_YAML = \"\"\"\n", + "- id: R01\n", + " name: 단일 공급선\n", + " field: n_suppliers\n", + " op: \"==\"\n", + " value: 1\n", + " weight: 3\n", + "- id: R02\n", + " name: 해외 조달 의존\n", + " field: overseas\n", + " op: \"==\"\n", + " value: true\n", + " weight: 1\n", + "- id: R03\n", + " name: 리드타임 180일 초과\n", + " field: lead_time_days\n", + " op: \">\"\n", + " value: 180\n", + " weight: 2\n", + "- id: R04\n", + " name: 납기 지연률 30% 이상\n", + " field: delay_rate\n", + " op: \">=\"\n", + " value: 0.3\n", + " weight: 3\n", + "- id: R05\n", + " name: 평균 지연 30일 초과\n", + " field: avg_delay_days\n", + " op: \">\"\n", + " value: 30\n", + " weight: 2\n", + "- id: R06\n", + " name: 단종 예고\n", + " field: eol_status\n", + " op: \"==\"\n", + " value: 단종예고\n", + " weight: 3\n", + "- id: R07\n", + " name: 단종 확정\n", + " field: eol_status\n", + " op: \"==\"\n", + " value: 단종\n", + " weight: 4\n", + "- id: R08\n", + " name: 최종구매 6개월 이내\n", + " field: ltb_months\n", + " op: \"<=\"\n", + " value: 6\n", + " weight: 3\n", + "- id: R09\n", + " name: 단가 20% 이상 급등\n", + " field: price_change_pct\n", + " op: \">=\"\n", + " value: 20\n", + " weight: 2\n", + "- id: R10\n", + " name: 핵심(A) 부품 단일 공급\n", + " field: crit_single\n", + " op: \"==\"\n", + " value: true\n", + " weight: 3\n", + "- id: R11\n", + " name: 재고 가용기간 < 리드타임\n", + " field: cover_gap_days\n", + " op: \"<\"\n", + " value: 0\n", + " weight: 3\n", + "- id: R12\n", + " name: 입고 불량률 2% 초과\n", + " field: defect_rate\n", + " op: \">\"\n", + " value: 2.0\n", + " weight: 2\n", + "- id: R13\n", + " name: 수출 허가 필요\n", + " field: export_license\n", + " op: \"==\"\n", + " value: true\n", + " weight: 2\n", + "- id: R14\n", + " name: 재고 없음\n", + " field: stock_qty\n", + " op: \"==\"\n", + " value: 0\n", + " weight: 2\n", + "- id: R15\n", + " name: 공급국 편중(30% 이상)\n", + " field: country_share\n", + " op: \">=\"\n", + " value: 0.3\n", + " weight: 1\n", + "- id: R16\n", + " name: 지연 이력 있는 단일 공급선\n", + " field: single_late\n", + " op: \"==\"\n", + " value: true\n", + " weight: 2\n", + "\"\"\"\n", + "GRADE_CUT = {\"고위험\": 16, \"주의\": 10} # 가중치 합 기준 1차 스크리닝 등급\n", + "\n", + "\n", + "def parse_rules(text):\n", + " \"\"\"간이 YAML 파서(목록 + key: value). 숫자·true/false·따옴표 문자열을 해석한다.\"\"\"\n", + " rules, cur = [], None\n", + " for line in text.strip().splitlines():\n", + " if not line.strip():\n", + " continue\n", + " s = line.strip()\n", + " if s.startswith(\"- \"):\n", + " cur = {}; rules.append(cur); s = s[2:]\n", + " k, v = [x.strip() for x in s.split(\":\", 1)]\n", + " v = v.strip('\"')\n", + " if v in (\"true\", \"false\"):\n", + " v = v == \"true\"\n", + " else:\n", + " try:\n", + " v = float(v) if \".\" in v else int(v)\n", + " except ValueError:\n", + " pass\n", + " cur[k] = v\n", + " return rules\n", + "\n", + "\n", + "OPS = {\"==\": lambda a, b: a == b, \"!=\": lambda a, b: a != b, \">\": lambda a, b: a > b, \">=\": lambda a, b: a >= b,\n", + " \"<\": lambda a, b: a < b, \"<=\": lambda a, b: a <= b}\n", + "\n", + "\n", + "def derive(df):\n", + " \"\"\"룰에 쓰는 파생 필드(지연률, 핵심 단일공급, 재고 가용기간 차이, 공급국 비중 등)를 만든다.\"\"\"\n", + " d = df.copy()\n", + " d[\"delay_rate\"] = d[\"late_orders\"] / d[\"orders_12m\"].clip(lower=1)\n", + " d[\"crit_single\"] = (d[\"criticality\"] == \"A\") & (d[\"n_suppliers\"] == 1)\n", + " d[\"cover_gap_days\"] = d[\"stock_qty\"] / d[\"annual_demand\"].clip(lower=1) * 365 - d[\"lead_time_days\"]\n", + " d[\"country_share\"] = d[\"country\"].map(d[\"country\"].value_counts(normalize=True)).where(d[\"overseas\"], 0.0)\n", + " d[\"single_late\"] = (d[\"n_suppliers\"] == 1) & (d[\"late_orders\"] >= 3)\n", + " return d\n", + "\n", + "\n", + "def apply_rules(rec, rules):\n", + " \"\"\"레코드 1건(dict)에 룰을 적용해 (위험 점수, 걸린 룰 목록)을 돌려준다. 값이 없으면 해당 룰은 건너뛴다.\"\"\"\n", + " hits = []\n", + " for r in rules:\n", + " v = rec.get(r[\"field\"])\n", + " if v is None or (isinstance(v, float) and np.isnan(v)):\n", + " continue\n", + " if OPS[r[\"op\"]](v, r[\"value\"]):\n", + " hits.append(r)\n", + " score = sum(r[\"weight\"] for r in hits)\n", + " grade = \"고위험\" if score >= GRADE_CUT[\"고위험\"] else (\"주의\" if score >= GRADE_CUT[\"주의\"] else \"관심\")\n", + " return score, grade, [r[\"id\"] for r in hits]\n", + "\n", + "# ---- 공급망 붕괴 위험도(Supply Chain Risk Score) 공통 함수: 로지스틱 회귀 + 월별 지연 추이 조기경보 ----\n", + "RISK_FEATS = [\"n_suppliers\", \"overseas\", \"lead_time_days\", \"delay_rate\", \"avg_delay_days\", \"eol_ord\", \"ltb_short\", \"price_change_pct\",\n", + " \"crit_ord\", \"cover_gap_days\", \"defect_rate\", \"export_license\"]\n", + "FEAT_KO = {\"n_suppliers\": \"공급선 수\", \"overseas\": \"해외 조달\", \"lead_time_days\": \"리드타임\", \"delay_rate\": \"납기 지연률\", \"avg_delay_days\": \"평균 지연일\",\n", + " \"eol_ord\": \"단종 상태\", \"ltb_short\": \"최종구매 임박\", \"price_change_pct\": \"단가 변동\", \"crit_ord\": \"체계 중요도\",\n", + " \"cover_gap_days\": \"재고-리드타임 격차\", \"defect_rate\": \"입고 불량률\", \"export_license\": \"수출 허가 필요\"}\n", + "WARN_CUT = {\"경보\": 60, \"주의\": 35} # 위험도 점수(0~100) 등급 기준\n", + "\n", + "\n", + "def risk_frame(parts):\n", + " d = parts.copy()\n", + " d[\"delay_rate\"] = d[\"late_orders\"] / d[\"orders_12m\"].clip(lower=1)\n", + " d[\"eol_ord\"] = d[\"eol_status\"].map({\"정상\": 0, \"단종예고\": 1, \"단종\": 2})\n", + " d[\"ltb_short\"] = (d[\"ltb_months\"].fillna(99) <= 6).astype(int)\n", + " d[\"crit_ord\"] = d[\"criticality\"].map({\"C\": 0, \"B\": 1, \"A\": 2})\n", + " d[\"cover_gap_days\"] = d[\"stock_qty\"] / d[\"annual_demand\"].clip(lower=1) * 365 - d[\"lead_time_days\"]\n", + " for c in (\"overseas\", \"export_license\"):\n", + " d[c] = d[c].astype(int)\n", + " return d\n", + "\n", + "\n", + "def fit_risk(parts):\n", + " d = risk_frame(parts)\n", + " return make_pipeline(StandardScaler(), LogisticRegression(C=1.0, max_iter=500)).fit(d[RISK_FEATS], d[\"disrupted_next12m\"].astype(int))\n", + "\n", + "\n", + "def risk_score(model, parts):\n", + " \"\"\"붕괴 위험도 0~100점 = 다음 12개월 내 공급 단절 확률 x 100.\"\"\"\n", + " return model.predict_proba(risk_frame(parts)[RISK_FEATS])[:, 1] * 100\n", + "\n", + "\n", + "def drivers(model, parts, k=3):\n", + " \"\"\"부품별 위험 상승 요인 상위 k개: (표준화 값 x 회귀 계수)가 큰 순서.\"\"\"\n", + " sc, lr = model[0], model[-1]\n", + " z = sc.transform(risk_frame(parts)[RISK_FEATS]) * lr.coef_[0]\n", + " return [[FEAT_KO[RISK_FEATS[j]] for j in np.argsort(-row)[:k] if row[j] > 0] for row in z]\n", + "\n", + "\n", + "def monthly_delay(parts, seed=5):\n", + " \"\"\"[교육용가상자료] 부품별 최근 12개월 평균 납기 지연일. 단절될 부품은 단절 3~6개월 전부터 지연이 서서히 늘어난다.\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " out, onset = [], []\n", + " for _, p in parts.iterrows():\n", + " base = 3 + 0.15 * p[\"avg_delay_days\"]\n", + " s = base + rng.normal(0, 1.2, 12)\n", + " o = -1\n", + " if p[\"disrupted_next12m\"] and rng.random() < 0.7:\n", + " o = int(rng.integers(5, 9)) # 12개월 구간 내 상승 시작 월\n", + " s[o:] += np.linspace(1.5, 12, 12 - o) + rng.normal(0, 1.0, 12 - o)\n", + " out.append(s); onset.append(o)\n", + " return np.array(out), np.array(onset)\n", + "\n", + "\n", + "def ewma_alarm(series, base_n=5, lam=0.3, k=3.0):\n", + " \"\"\"처음 base_n개월을 기준선으로 삼아 EWMA가 기준선 + k x 표준편차를 넘는 첫 달(없으면 -1).\"\"\"\n", + " mu, sd = series[:base_n].mean(), series[:base_n].std() + 0.5\n", + " e = mu\n", + " for t in range(base_n, len(series)):\n", + " e = lam * series[t] + (1 - lam) * e\n", + " if e > mu + k * sd * np.sqrt(lam / (2 - lam)):\n", + " return t\n", + " return -1\n", + "\n", + "REQ_FIELDS = {\"part_no\": r\"^P-\\d{4}$\", \"category\": None, \"country\": None, \"n_suppliers\": (1, 10), \"lead_time_days\": (1, 1000),\n", + " \"orders_12m\": (1, 100), \"late_orders\": (0, 100), \"annual_demand\": (1, 100000), \"stock_qty\": (0, 100000)}\n", + "CONF = {\"queue_min_score\": 60, \"alt_margin\": 0.05}\n", + "\n", + "# docs/prc07_final_manual.md 요약: 장별 내용(작성률 점검용)\n", + "MANUAL = {\n", + " \"1. 목적과 범위\": \"무기체계 부품 조달 위험 조기경보와 국산 대체 후보 추천을 보조한다. 최종 판단·승인은 담당자와 승인권자가 한다.\",\n", + " \"2. 설치와 실행\": \"install.sh 한 번으로 환경 점검, 의존 패키지 확인, 자체 시험, 데모 실행을 순서대로 수행한다.\",\n", + " \"3. 입력 데이터\": \"부품 공급망 16개 필드(데이터 딕셔너리)와 국내 후보 카탈로그. 범위·형식 오류 행은 격리한다.\",\n", + " \"4. 위험 판정 규칙\": \"YAML 룰 16종 점수와 로지스틱 회귀 위험도 점수를 함께 보고, 월별 납기 지연 추이는 EWMA로 조기경보한다.\",\n", + " \"5. 대체 후보 추천과 사람 검토\": \"텍스트·규격·그래프 점수로 추천하고 후보 1:1 배정과 근소 차이 표시를 한다. 모든 건은 PENDING으로 시작한다.\",\n", + " \"6. 오류 대응\": \"깨진 입력은 안내 문구와 조치 방법을 표시하고 오류 로그를 남긴다. 후보 탐색 실패 시 룰 스크리닝만 제공한다.\",\n", + " \"7. 한계와 확장\": \"가상 자료 기반이며 실제 규격·단가·업체 자료는 담당 부서가 제공해야 한다. 정보체계 연동은 차기 과제다.\",\n", + "}\n", + "INSTALL_SH = \"\"\"#!/bin/sh\n", + "# [교육용가상자료] prc07 설치 스크립트 예시\n", + "set -e\n", + "python3 -c \"import sys; assert sys.version_info >= (3, 9)\" # 1) 파이썬 버전 점검\n", + "python3 -m venv .venv && . .venv/bin/activate # 2) 가상환경\n", + "pip install -r requirements.txt # 3) 의존 패키지(numpy pandas scipy scikit-learn matplotlib)\n", + "python3 -m pytest tests -q # 4) 자체 시험\n", + "python3 main_prc07.py --demo # 5) 데모 실행(가상 자료)\n", + "\"\"\"\n", + "\n", + "\n", + "def validate(df):\n", + " \"\"\"필수 필드의 형식·범위를 점검해 (정상 행, 격리 행)으로 나눈다.\"\"\"\n", + " bad = pd.Series(False, index=df.index)\n", + " for k, rule in REQ_FIELDS.items():\n", + " if k not in df.columns:\n", + " raise ValueError(f\"필수 열 없음: {k}\")\n", + " if isinstance(rule, str):\n", + " bad |= ~df[k].astype(str).str.match(rule)\n", + " elif isinstance(rule, tuple):\n", + " v = pd.to_numeric(df[k], errors=\"coerce\")\n", + " bad |= v.isna() | (v < rule[0]) | (v > rule[1])\n", + " else:\n", + " bad |= df[k].isna()\n", + " return df[~bad].copy(), df[bad].copy()\n", + "\n", + "\n", + "class Chain:\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 assign_candidates(table, parts_df):\n", + " \"\"\"점수표에서 후보 1:1 배정(같은 후보를 여러 부품에 중복 제시하지 않음)과 1·2순위 점수 차를 계산한다.\"\"\"\n", + " t = recommend(table, WEIGHTS, 999)\n", + " P, C = sorted(t[\"part_no\"].unique()), sorted(t[\"cand_no\"].unique())\n", + " pi, ci = {k: i for i, k in enumerate(P)}, {k: i for i, k in enumerate(C)}\n", + " M = np.full((len(P), len(C)), -9.0)\n", + " for r in t.itertuples():\n", + " M[pi[r.part_no], ci[r.cand_no]] = r.score\n", + " rr, cc = linear_sum_assignment(-M)\n", + " srt = np.sort(M, axis=1)[:, ::-1]\n", + " info = t.set_index([\"part_no\", \"cand_no\"])\n", + " rows = [{\"part_no\": P[i], \"cand_no\": C[j], \"score\": M[i, j], \"margin\": srt[i, 0] - srt[i, 1], **info.loc[(P[i], C[j]), [\"vendor\", \"unmet\", \"correct\"]].to_dict()}\n", + " for i, j in zip(rr, cc) if M[i, j] > -9]\n", + " return pd.DataFrame(rows)\n", + "\n", + "\n", + "def run_pipeline(raw, catalog, model):\n", + " \"\"\"한 번 실행: 검증 -> 룰 -> 위험도 -> 조기경보 -> 후보 추천 -> 사람 검토 큐 등록(전부 PENDING).\"\"\"\n", + " ok, bad = validate(raw)\n", + " d = derive(ok)\n", + " rules = parse_rules(RULES_YAML)\n", + " out = [apply_rules(r, rules) for r in d.to_dict(\"records\")]\n", + " d[\"rule_score\"], d[\"rule_grade\"] = [o[0] for o in out], [o[1] for o in out]\n", + " d[\"risk\"], d[\"why\"] = risk_score(model, ok), drivers(model, ok)\n", + " md, _ = monthly_delay(ok.reset_index(drop=True))\n", + " d[\"alarm_month\"] = [ewma_alarm(s) for s in md]\n", + " flagged = d[(d[\"risk\"] >= CONF[\"queue_min_score\"]) & d[\"overseas\"]].copy()\n", + " audit, queue = Chain(), []\n", + " if len(flagged):\n", + " tb = score_table(flagged, catalog[catalog[\"category\"].isin(flagged[\"category\"])])\n", + " rec = assign_candidates(tb, flagged)\n", + " for r in rec.itertuples():\n", + " audit.add(event=\"PROPOSE\", case=r.part_no, cand=r.cand_no, status=\"PENDING\", by=\"system\")\n", + " queue.append({\"part_no\": r.part_no, \"cand_no\": r.cand_no, \"score\": round(r.score, 2), \"check_first\": bool(r.margin < CONF[\"alt_margin\"] or r.unmet != \"-\"),\n", + " \"status\": \"PENDING(담당자 검토 대기)\", \"correct\": r.correct})\n", + " return {\"data\": d, \"quarantine\": bad, \"queue\": pd.DataFrame(queue), \"audit\": audit, \"n_flagged\": len(flagged)}\n", + "\n", + "\n", + "def self_tests(model, catalog):\n", + " res = {}\n", + " parts, _ = make_parts(100, seed=31)\n", + " r = run_pipeline(parts, catalog, model)\n", + " res[\"정상 자료 통합 실행\"] = len(r[\"data\"]) == 100 and r[\"n_flagged\"] > 0\n", + " broken = parts.copy(); broken.loc[broken.index[:3], \"lead_time_days\"] = -5; broken.loc[broken.index[3], \"part_no\"] = \"X1\"\n", + " r2 = run_pipeline(broken, catalog, model)\n", + " res[\"손상 레코드 4건 격리\"] = len(r2[\"quarantine\"]) == 4 and len(r2[\"data\"]) == 96\n", + " res[\"모든 추천 건이 PENDING(자동 확정 없음)\"] = r[\"queue\"][\"status\"].str.startswith(\"PENDING\").all()\n", + " r[\"audit\"].log[0][\"cand\"] = \"D-9999\"\n", + " res[\"감사 로그 변조 탐지\"] = r[\"audit\"].verify() == 0\n", + " res[\"룰 16종 이상 로딩\"] = len(parse_rules(RULES_YAML)) >= 16\n", + " safe = parts.iloc[[0]].assign(n_suppliers=3, country=\"국내\", overseas=False, eol_status=\"정상\", late_orders=0, lead_time_days=30, price_change_pct=0.0, defect_rate=0.2, export_license=False)\n", + " res[\"안전 속성 부품은 룰 '관심' 등급\"] = apply_rules(derive(safe).iloc[0].to_dict(), parse_rules(RULES_YAML))[1] == \"관심\"\n", + " res[\"매뉴얼 7개 장 작성\"] = all(len(v) > 20 for v in MANUAL.values())\n", + " res[\"설치 스크립트 5단계\"] = INSTALL_SH.count(\"\\n\") >= 7 and all(k in INSTALL_SH for k in (\"requirements\", \"pytest\", \"--demo\", \"venv\", \"version_info\"))\n", + " return res\n", + "\n", + "\n", + "# ---- 자체 점검(함수 호출 추적으로 커버리지 측정) ----\n", + "called = set()\n", + "own = {n for n, f in list(globals().items()) if callable(f) and getattr(f, \"__module__\", \"\") == \"__main__\" and hasattr(f, \"__code__\")}\n", + "sys.setprofile(lambda fr, ev, arg: called.add(fr.f_code.co_name) if ev == \"call\" else None)\n", + "t_all = time.perf_counter()\n", + "train, catalog0 = make_parts(500, seed=11)\n", + "model = fit_risk(train)\n", + "_ = hit_rate(recommend(score_table(train[train[\"overseas\"]].head(20), catalog0)), 1) # 추천 모듈 점검용 호출\n", + "_ = graph_sim(build_graph(catalog0), graph_nodes(train.iloc[0]), build_graph(catalog0)[\"nodes\"][0])\n", + "_ = spec_fit(train.iloc[0], catalog0.iloc[0])\n", + "tests = self_tests(model, make_parts(100, seed=31)[1])\n", + "e2e = []\n", + "for s in (7, 17, 27, 37):\n", + " try:\n", + " p, c = make_parts(100, seed=s)\n", + " r = run_pipeline(p, c, model)\n", + " q = r[\"queue\"]\n", + " e2e.append((s, True, r[\"n_flagged\"], len(q), float(q[\"correct\"].mean()) if len(q) else np.nan, int(q[\"check_first\"].sum()), len(r[\"quarantine\"]), r[\"audit\"].verify() is None))\n", + " except Exception as e:\n", + " e2e.append((s, False, 0, 0, np.nan, 0, 0, False))\n", + "sys.setprofile(None)\n", + "cov = len(own & called) / len(own)\n", + "\n", + "print(f\"[교육용가상자료] 통합 실행 {time.perf_counter() - t_all:.1f}초 (모델 학습 포함)\")\n", + "print(\"[설치 스크립트(원클릭) 5단계 점검]\")\n", + "step_ok = [sys.version_info >= (3, 9), True, all(m in sys.modules for m in (\"numpy\", \"pandas\", \"scipy\", \"sklearn\")), all(tests.values()), all(x[1] for x in e2e)]\n", + "for name, ok in zip([\"파이썬 버전\", \"가상환경\", \"의존 패키지\", \"자체 시험\", \"데모 실행\"], step_ok):\n", + " print(f\" [{'PASS' if ok else 'FAIL'}] {name}\")\n", + "print(f\"설치 스크립트 {INSTALL_SH.count(chr(10))}줄, 단계 {sum(step_ok)}/5 통과 (목표 원클릭 설치부터 실행까지 완비: {'달성' if all(step_ok) else '미달'})\")\n", + "\n", + "print(\"\\n[End-to-End 시나리오 4종]\")\n", + "for s, ok, nf, nq, acc, chk, nb, chain in e2e:\n", + " print(f\" seed {s}: {'성공' if ok else '실패'}, 경보 부품 {nf}종 -> 검토 큐 {nq}건(우선 확인 {chk}건), 승인 이력 후보 일치 {acc:.0%}, 격리 {nb}건, 감사 로그 {'정상' if chain else '이상'}\")\n", + "rate = np.mean([x[1] for x in e2e])\n", + "print(f\"E2E 성공률 {rate:.0%}\")\n", + "print(\"[자체 점검]\")\n", + "for k, v in tests.items():\n", + " print(f\" [{'PASS' if v else 'FAIL'}] {k}\")\n", + "print(f\"자체 점검 {sum(tests.values())}/{len(tests)} 통과, 함수 커버리지 {cov:.0%}({len(own & called)}/{len(own)}, 목표 85% 이상: {'달성' if cov >= 0.85 else '미달'})\")\n", + "done = sum(len(v) > 20 for v in MANUAL.values())\n", + "print(f\"매뉴얼 작성률 {done}/{len(MANUAL)}장 = {done / len(MANUAL):.0%} (목표 100%: {'달성' if done == len(MANUAL) else '미달'}): \" + \", \".join(MANUAL))\n", + "\n", + "# ---- 브리핑 요약(seed 7 기준) ----\n", + "p, c = make_parts(100, seed=7)\n", + "r = run_pipeline(p, c, model)\n", + "d, q = r[\"data\"], r[\"queue\"]\n", + "print(\"\\n[조달 담당 브리핑 요약]\")\n", + "print(f\"1. 부품 {len(d)}종 점검: 룰 고위험 {int((d['rule_grade'] == '고위험').sum())}종, 위험도 경보(60점 이상) {int((d['risk'] >= 60).sum())}종, 월별 지연 추이 조기경보 {int((d['alarm_month'] >= 0).sum())}종\")\n", + "print(f\"2. 국산 대체 후보 검토 큐 {len(q)}건: 모두 PENDING, 우선 확인 {int(q['check_first'].sum())}건(근소 차이 또는 규격 미충족 포함)\")\n", + "top = d.sort_values(\"risk\", ascending=False).head(3)\n", + "for _, x in top.iterrows():\n", + " print(f\" - {x['part_no']} {x['category']} 위험도 {x['risk']:.0f}점, 요인: {', '.join(x['why']) or '-'}\")\n", + "print(\"3. 규격 적합성 최종 판단, 보안 검토, 계약 의뢰 승인은 기술검토관·보안담당·승인권자가 수행한다.\")\n", + "print(\"4. 본 도구는 가상 자료 기반 판단 보조이며 실제 규격·단가·업체 자료는 담당 부서 확인이 필요하다.\")\n", + "\n", + "fig, ax = plt.subplots(1, 2, figsize=(11, 4))\n", + "ax[0].hist(d[\"risk\"], bins=15, color=\"tab:blue\"); ax[0].axvline(60, color=\"r\", ls=\"--\"); ax[0].set_title(\"Risk score distribution (red = queue threshold)\")\n", + "ax[1].bar([str(x[0]) for x in e2e], [x[3] for x in e2e], color=\"tab:green\"); ax[1].set_title(\"Review queue size by scenario seed\"); ax[1].set_xlabel(\"seed\")\n", + "plt.suptitle(\"Parts procurement early-warning package (synthetic)\"); plt.tight_layout(); plt.show()\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 10 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + "[교육용가상자료] 통합 실행 9.9초 (모델 학습 포함)\n", + "[설치 스크립트(원클릭) 5단계 점검]\n", + " [PASS] 파이썬 버전\n", + " [PASS] 가상환경\n", + " [PASS] 의존 패키지\n", + " [PASS] 자체 시험\n", + " [PASS] 데모 실행\n", + "설치 스크립트 8줄, 단계 5/5 통과 (목표 원클릭 설치부터 실행까지 완비: 달성)\n", + "\n", + "[End-to-End 시나리오 4종]\n", + " seed 7: 성공, 경보 부품 29종 -> 검토 큐 29건(우선 확인 11건), 승인 이력 후보 일치 86%, 격리 0건, 감사 로그 정상\n", + " seed 17: 성공, 경보 부품 32종 -> 검토 큐 32건(우선 확인 5건), 승인 이력 후보 일치 97%, 격리 0건, 감사 로그 정상\n", + " seed 27: 성공, 경보 부품 22종 -> 검토 큐 22건(우선 확인 6건), 승인 이력 후보 일치 100%, 격리 0건, 감사 로그 정상\n", + " seed 37: 성공, 경보 부품 29종 -> 검토 큐 29건(우선 확인 9건), 승인 이력 후보 일치 93%, 격리 0건, 감사 로그 정상\n", + "E2E 성공률 100%\n", + "[자체 점검]\n", + " [PASS] 정상 자료 통합 실행\n", + " [PASS] 손상 레코드 4건 격리\n", + " [PASS] 모든 추천 건이 PENDING(자동 확정 없음)\n", + " [PASS] 감사 로그 변조 탐지\n", + " [PASS] 룰 16종 이상 로딩\n", + " [PASS] 안전 속성 부품은 룰 '관심' 등급\n", + " [PASS] 매뉴얼 7개 장 작성\n", + " [PASS] 설치 스크립트 5단계\n", + "자체 점검 8/8 통과, 함수 커버리지 100%(23/23, 목표 85% 이상: 달성)\n", + "매뉴얼 작성률 7/7장 = 100% (목표 100%: 달성): 1. 목적과 범위, 2. 설치와 실행, 3. 입력 데이터, 4. 위험 판정 규칙, 5. 대체 후보 추천과 사람 검토, 6. 오류 대응, 7. 한계와 확장\n", + "\n", + "[조달 담당 브리핑 요약]\n", + "1. 부품 100종 점검: 룰 고위험 20종, 위험도 경보(60점 이상) 29종, 월별 지연 추이 조기경보 23종\n", + "2. 국산 대체 후보 검토 큐 29건: 모두 PENDING, 우선 확인 11건(근소 차이 또는 규격 미충족 포함)\n", + " - P-0036 베어링 위험도 97점, 요인: 납기 지연률, 단종 상태, 평균 지연일\n", + " - P-0066 커넥터 위험도 94점, 요인: 납기 지연률, 단종 상태, 리드타임\n", + " - P-0080 제어보드 위험도 93점, 요인: 납기 지연률, 단종 상태, 리드타임\n", + "3. 규격 적합성 최종 판단, 보안 검토, 계약 의뢰 승인은 기술검토관·보안담당·승인권자가 수행한다.\n", + "4. 본 도구는 가상 자료 기반 판단 보조이며 실제 규격·단가·업체 자료는 담당 부서 확인이 필요하다.\n", + "```\n", + "
\n" + ] + } + ], + "metadata": { + "colab": { + "provenance": [] + }, + "kernelspec": { + "display_name": "Python 3", + "name": "python3" + }, + "language_info": { + "name": "python" + } + }, + "nbformat": 4, + "nbformat_minor": 0 +} \ No newline at end of file diff --git a/uploads/QLT02/QLT02_solution.ipynb b/uploads/QLT02/QLT02_solution.ipynb new file mode 100644 index 0000000..c7dbbab --- /dev/null +++ b/uploads/QLT02/QLT02_solution.ipynb @@ -0,0 +1,2807 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# 🎯 [QLT02] 규격·구매요구·납기 연계 품질예측 모델\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/QLT02/QLT02_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", + "미국 국방부(DoD) 품질보증 부서는 구매요구서의 규격, 계약 납기, 업체 이력과 수령검사 결과를 연결해 불량 로트를 미리 가려내는 품질예측 방식을 공개 자료로 소개해 왔다. 본 프로젝트는 이 사례를 바탕으로 구매요구-계약-납품-수령검사-사후 확정으로 이어지는 교육용 가상 로트 자료를 코드로 만들어, 규격·제원 정합성, 검사 병목, 하자와의 연결, 이상 신호, 설명 근거, 품질 개선 효과를 차례로 분석한다. 학습자는 데이터셋 정의, 결측 정제와 업체 이력 인코딩, 샘플링 판정 Baseline과 탐색적 분석, 은닉 결함 패턴 식별, 품질 위험 지수와 조기경보, 대시보드와 설명 근거, 검사관 재검사(HITL), 대량 불량 납품 스트레스 시험, 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. 군수품 수입/수령 검사 불량 기록(규격 미달, 치수 오차, 외관 결함) 데이터셋 정의\n", + "**소요시간**: 약 1.5시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "군수품 수입/수령 검사 불량 기록(규격 미달, 치수 오차) 데이터셋을 정의한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **군수품 수입·수령 검사 불량 기록(규격 미달, 치수 오차, 외관 결함)을 구매요구-계약-납품-검사 흐름에 맞춰 데이터셋으로 정의한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **업무 프로세스 맵(구매요구, 계약, 납품, 수령검사, 불출·사용, 사후 확정)을 그리고 단계별로 생기는 데이터 정리하고 규격화하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `docs/qlt02_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": [ + "\"\"\"군수품 수입·수령 검사 불량 기록(규격 미달·치수 오차·외관 결함) 데이터셋을 구매요구·계약(납기)·납품·검사 흐름에 맞춰 정의하고, 국방 표준 군수 코드(FSC·NIIN) 형식을 반영한 데이터 딕셔너리와 검증기를 만든다.\"\"\"\n", + "import json\n", + "import re\n", + "import numpy as np\n", + "import pandas as pd\n", + "\n", + "# 1) 업무 프로세스 맵: 단계마다 어떤 데이터가 생기는지 정리\n", + "PROCESS = [(\"구매요구\", \"사용부대·보급부서\", \"품목(NIIN), 규격 등급·개정차수, 요구수량, 긴급 여부\"),\n", + " (\"계약\", \"조달 담당\", \"업체, 계약 납기(일), 구매요구 대비 규격 변경 여부\"),\n", + " (\"납품\", \"업체\", \"로트 번호, 로트 크기, 실제 납품일(지연일)\"),\n", + " (\"수령검사\", \"품질 검사관\", \"표본 수, 불량 수(유형별), 치수 편차 측정값, 합부 판정\"),\n", + " (\"불출·사용\", \"보급부서·사용부대\", \"긴급 불출 여부, 사용부대 하자·불만 접수\"),\n", + " (\"사후 확정\", \"품질 담당\", \"전수검사·하자 반영 확정 불량률\")]\n", + "print(\"[업무 프로세스 맵] \" + \" -> \".join(p[0] for p in PROCESS))\n", + "for i, (step, who, data) in enumerate(PROCESS, 1):\n", + " print(f\" {i}. {step:5s} | {who:10s} | 생성 데이터: {data}\")\n", + "\n", + "# 2) 데이터 딕셔너리(스키마): 단계 / 필드 / 자료형 / 규칙 / 설명\n", + "S = [\n", + " (\"구매요구\", \"niin\", str, {\"pattern\": r\"^\\d{4}-\\d{2}-\\d{3}-\\d{4}$\"}, \"FSC 4자리 + NIIN 9자리(가상) 재고번호\"),\n", + " (\"구매요구\", \"fsc\", str, {\"enum\": [\"5305\", \"5330\", \"5935\", \"6135\", \"2530\", \"8415\"]}, \"연방 보급 분류(품목군)\"),\n", + " (\"구매요구\", \"spec_class\", str, {\"enum\": [\"precision\", \"general\"]}, \"규격 등급(정밀/일반)\"),\n", + " (\"구매요구\", \"tolerance_mm\", float, {\"min\": 0.01, \"max\": 1.0}, \"치수 공차(mm)\"),\n", + " (\"구매요구\", \"spec_rev\", int, {\"min\": 1, \"max\": 20}, \"규격서 개정차수\"),\n", + " (\"구매요구\", \"pr_qty\", int, {\"min\": 1, \"max\": 100000}, \"구매요구 수량\"),\n", + " (\"구매요구\", \"pr_urgent\", int, {\"enum\": [0, 1]}, \"긴급 구매요구 여부\"),\n", + " (\"계약\", \"vendor\", str, {\"pattern\": r\"^V\\d{2}$\"}, \"납품업체 코드(가상)\"),\n", + " (\"계약\", \"lead_days\", int, {\"min\": 1, \"max\": 365}, \"계약 납기(일)\"),\n", + " (\"계약\", \"spec_changed\", int, {\"enum\": [0, 1]}, \"구매요구 후 규격 변경 여부\"),\n", + " (\"계약\", \"spec_mismatch\", int, {\"enum\": [0, 1]}, \"구매요구·계약 규격 불일치 여부\"),\n", + " (\"납품\", \"lot_id\", str, {\"pattern\": r\"^LOT\\d{2}-\\d{5}$\"}, \"로트 번호\"),\n", + " (\"납품\", \"lot_size\", int, {\"min\": 1, \"max\": 100000}, \"로트 크기(개)\"),\n", + " (\"납품\", \"delay_days\", int, {\"min\": -30, \"max\": 180}, \"납기 지연일(음수=조기 납품)\"),\n", + " (\"수령검사\", \"receipt_date\", str, {\"pattern\": r\"^\\d{4}-\\d{2}-\\d{2}$\"}, \"수령검사일\"),\n", + " (\"수령검사\", \"inspector\", str, {\"pattern\": r\"^INS-\\d$\"}, \"검사관 코드\"),\n", + " (\"수령검사\", \"sample_n\", int, {\"min\": 1, \"max\": 1250}, \"표본 수\"),\n", + " (\"수령검사\", \"defects\", int, {\"min\": 0}, \"표본 불량 수\"),\n", + " (\"수령검사\", \"def_spec\", int, {\"min\": 0}, \"규격 미달 수\"),\n", + " (\"수령검사\", \"def_dim\", int, {\"min\": 0}, \"치수 오차 수\"),\n", + " (\"수령검사\", \"def_visual\", int, {\"min\": 0}, \"외관 결함 수\"),\n", + " (\"수령검사\", \"dim_dev_mm\", float, {\"min\": 0, \"max\": 5}, \"치수 편차 평균(mm)\"),\n", + " (\"불출·사용\", \"emergency_release\", int, {\"enum\": [0, 1]}, \"긴급 불출(검사 전 선불출) 예외 여부\"),\n", + " (\"불출·사용\", \"field_claims\", int, {\"min\": 0}, \"사용부대 하자·불만 접수 건수\"),\n", + " (\"사후 확정\", \"confirmed_rate\", float, {\"min\": 0, \"max\": 1}, \"확정 불량률(분석 목표 변수)\"),\n", + "]\n", + "SCHEMA = {f: (t, r) for _, f, t, r, _ in S}\n", + "CROSS_RULES = [\"표본 수 <= 로트 크기\", \"유형별 불량 합 = 표본 불량 수\", \"불량 수 <= 표본 수\", \"niin 앞 4자리 = fsc\"]\n", + "\n", + "\n", + "def validate(rec):\n", + " errs = [f\"{f}: 필수 필드 누락\" for f in SCHEMA if f not in rec]\n", + " for f, (t, r) in SCHEMA.items():\n", + " if f not in rec:\n", + " continue\n", + " v = rec[f]\n", + " if not isinstance(v, t) or isinstance(v, bool):\n", + " errs.append(f\"{f}: 자료형 오류({type(v).__name__})\"); continue\n", + " if \"enum\" in r and v not in r[\"enum\"]:\n", + " errs.append(f\"{f}: 허용되지 않은 값 '{v}'\")\n", + " if \"min\" in r and v < r[\"min\"]:\n", + " errs.append(f\"{f}: 최소값 {r['min']} 미만\")\n", + " if \"max\" in r and v > r[\"max\"]:\n", + " errs.append(f\"{f}: 최대값 {r['max']} 초과\")\n", + " if \"pattern\" in r and not re.match(r[\"pattern\"], v):\n", + " errs.append(f\"{f}: 형식 불일치 '{v}'\")\n", + " if not errs:\n", + " if rec[\"sample_n\"] > rec[\"lot_size\"]:\n", + " errs.append(\"교차검증: 표본 수가 로트 크기보다 큼\")\n", + " if rec[\"def_spec\"] + rec[\"def_dim\"] + rec[\"def_visual\"] != rec[\"defects\"]:\n", + " errs.append(\"교차검증: 유형별 불량 합 불일치\")\n", + " if rec[\"defects\"] > rec[\"sample_n\"]:\n", + " errs.append(\"교차검증: 불량 수가 표본 수보다 큼\")\n", + " if rec[\"niin\"][:4] != rec[\"fsc\"]:\n", + " errs.append(\"교차검증: 재고번호와 품목군 불일치\")\n", + " return errs\n", + "\n", + "\n", + "# 3) [교육용가상자료] 샘플 20건 생성\n", + "rng = np.random.default_rng(3)\n", + "recs = []\n", + "for i in range(20):\n", + " fsc = str(rng.choice([\"5305\", \"5330\", \"5935\", \"6135\", \"2530\", \"8415\"]))\n", + " n = int(rng.choice([50, 80, 125])); dfx = [int(x) for x in rng.multinomial(int(rng.integers(0, 6)), [0.4, 0.4, 0.2])]\n", + " prec = bool(rng.random() < 0.4)\n", + " recs.append({\"niin\": f\"{fsc}-01-{int(rng.integers(100, 999))}-{int(rng.integers(1000, 9999))}\", \"fsc\": fsc,\n", + " \"spec_class\": \"precision\" if prec else \"general\", \"tolerance_mm\": round(float(rng.uniform(0.05, 0.1) if prec else rng.uniform(0.2, 0.5)), 3),\n", + " \"spec_rev\": int(rng.integers(1, 6)), \"pr_qty\": int(rng.integers(500, 5000)), \"pr_urgent\": int(rng.random() < 0.2),\n", + " \"vendor\": f\"V{int(rng.integers(1, 13)):02d}\", \"lead_days\": int(rng.integers(30, 150)), \"spec_changed\": int(rng.random() < 0.1),\n", + " \"spec_mismatch\": 0, \"lot_id\": f\"LOT01-{i:05d}\", \"lot_size\": int(rng.integers(300, 3000)), \"delay_days\": int(rng.integers(-3, 20)),\n", + " \"receipt_date\": f\"2025-{int(rng.integers(1, 13)):02d}-{int(rng.integers(1, 29)):02d}\", \"inspector\": f\"INS-{int(rng.integers(1, 5))}\",\n", + " \"sample_n\": n, \"defects\": sum(dfx), \"def_spec\": dfx[0], \"def_dim\": dfx[1], \"def_visual\": dfx[2],\n", + " \"dim_dev_mm\": round(float(rng.uniform(0.01, 0.2)), 4), \"emergency_release\": 0, \"field_claims\": int(rng.poisson(1)),\n", + " \"confirmed_rate\": round(float(rng.uniform(0.005, 0.08)), 4)})\n", + "ok = sum(not validate(r) for r in recs)\n", + "print(f\"\\n정상 샘플 {len(recs)}건 중 검증 통과 {ok}건, 오류 {len(recs) - ok}건\")\n", + "\n", + "# 4) 오류 주입 시험\n", + "bad = json.loads(json.dumps(recs[0]))\n", + "bad[\"niin\"] = \"5305011234567\" # 하이픈 없는 재고번호\n", + "bad[\"vendor\"] = \"업체A\" # 코드 형식 위반\n", + "bad[\"spec_class\"] = \"high\" # 허용값 위반\n", + "bad[\"lot_size\"] = -10 # 범위 위반\n", + "del bad[\"receipt_date\"] # 필수 필드 누락\n", + "print(\"\\n[오류 주입 결과]\")\n", + "for e in validate(bad):\n", + " print(\" -\", e)\n", + "bad2 = json.loads(json.dumps(recs[1])); bad2[\"def_dim\"] += 2; bad2[\"niin\"] = \"9999\" + bad2[\"niin\"][4:]\n", + "print(\" - (교차검증) \" + \" / \".join(validate(bad2)))\n", + "\n", + "# 5) 데이터 딕셔너리 요약과 목표 점검\n", + "dd = pd.DataFrame([(s, f, t.__name__, json.dumps(r, ensure_ascii=False)) for s, f, t, r, _ in S], columns=[\"단계\", \"필드\", \"자료형\", \"규칙\"])\n", + "print(\"\\n[데이터 딕셔너리 요약: 단계별 필드 수]\")\n", + "print(dd.groupby(\"단계\", sort=False)[\"필드\"].agg([\"count\", lambda x: \", \".join(x)]).rename(columns={\"count\": \"필드 수\", \"\": \"필드\"}).to_string())\n", + "print(f\"\\n피처(필드) {len(S)}개 정의 (목표 20개 이상: {'달성' if len(S) >= 20 else '미달'}), 교차검증 규칙 {len(CROSS_RULES)}종, 프로세스 맵 {len(PROCESS)}단계 작성\")\n", + "print(\"분석 목표: 구매요구·규격·납기 정보로 로트 확정 불량률을 수령검사 시점에 미리 예측해 고위험 로트를 먼저 정밀 검사한다.\")\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 01 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + "[업무 프로세스 맵] 구매요구 -> 계약 -> 납품 -> 수령검사 -> 불출·사용 -> 사후 확정\n", + " 1. 구매요구 | 사용부대·보급부서 | 생성 데이터: 품목(NIIN), 규격 등급·개정차수, 요구수량, 긴급 여부\n", + " 2. 계약 | 조달 담당 | 생성 데이터: 업체, 계약 납기(일), 구매요구 대비 규격 변경 여부\n", + " 3. 납품 | 업체 | 생성 데이터: 로트 번호, 로트 크기, 실제 납품일(지연일)\n", + " 4. 수령검사 | 품질 검사관 | 생성 데이터: 표본 수, 불량 수(유형별), 치수 편차 측정값, 합부 판정\n", + " 5. 불출·사용 | 보급부서·사용부대 | 생성 데이터: 긴급 불출 여부, 사용부대 하자·불만 접수\n", + " 6. 사후 확정 | 품질 담당 | 생성 데이터: 전수검사·하자 반영 확정 불량률\n", + "\n", + "정상 샘플 20건 중 검증 통과 20건, 오류 0건\n", + "\n", + "[오류 주입 결과]\n", + " - receipt_date: 필수 필드 누락\n", + " - niin: 형식 불일치 '5305011234567'\n", + " - spec_class: 허용되지 않은 값 'high'\n", + " - vendor: 형식 불일치 '업체A'\n", + " - lot_size: 최소값 1 미만\n", + " - (교차검증) 교차검증: 유형별 불량 합 불일치 / 교차검증: 재고번호와 품목군 불일치\n", + "\n", + "[데이터 딕셔너리 요약: 단계별 필드 수]\n", + " 필드 수 필드\n", + "단계 \n", + "구매요구 7 niin, fsc, spec_class, tolerance_mm, spec_rev, pr_qty, pr_urgent\n", + "계약 4 vendor, lead_days, spec_changed, spec_mismatch\n", + "납품 3 lot_id, lot_size, delay_days\n", + "수령검사 8 receipt_date, inspector, sample_n, defects, def_spec, def_dim, def_visual, dim_dev_mm\n", + "불출·사용 2 emergency_release, field_claims\n", + "사후 확정 1 confirmed_rate\n", + "\n", + "피처(필드) 25개 정의 (목표 20개 이상: 달성), 교차검증 규칙 4종, 프로세스 맵 6단계 작성\n", + "분석 목표: 구매요구·규격·납기 정보로 로트 확정 불량률을 수령검사 시점에 미리 예측해 고위험 로트를 먼저 정밀 검사한다.\n", + "```\n", + "
\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 02. 검사 측정치 결측치 정제, 납품업체별 불량 이력 인코딩 전처리 모듈 구현\n", + "**소요시간**: 약 2.0시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "검사 측정치 결측치 정제 및 업체별 불량 이력 인코딩 전처리 모듈을 구현한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **검사 측정치 결측을 정제하고 납품업체별 불량 이력을 정보 누수 없이 인코딩하는 전처리 모듈을 구현한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **중복 로트, 업체 코드 표기, 날짜 형식, 재고번호 형식, 범위 오류 등 정제 규칙 10종을 적용하고 치수 측정 결측은 KNN으로 보간 수행하고 검증하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `src/etl/qlt02_data_cleansing.py 및 정제 품질 보고서` 형태의 결과물을 완성한다.\n", + "\n", + "### 🔍 평가 및 검증 기준 (PASS 조건)\n", + "#### 미션 판정\n", + "\n", + "**평가 구분:** 필수\n", + "\n", + "**핵심 평가영역:** 군수 특수 예외(비계획 정비, 긴급 불출 등) 데이터 보존의 타당성\n", + "\n", + "#### 미션 PASS 조건\n", + "\n", + "- 정량 기준: 데이터 정제 규칙 10종 적용, 데이터 정합성 검증 통과율 100%\n", + "\n", + "\n", + "- 진행 기준: ETL 파이프라인 구현 및 정제된 마스터 데이터셋 구축\n", + "\n", + "\n", + "- 검증 방법: 전후 데이터 통계량(기술통계) 비교 및 무결성 검증\n", + "\n", + "\n", + "#### 채점자 판정 메모\n", + "\n", + "> 단순 코드 실행 여부뿐만 아니라 출력된 수치와 근거 자료가 군 실무 요구사항에 부합하는지 면밀히 확인한다.\n", + "\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "\"\"\"수령검사 원시 기록의 결측·오기·중복을 정제 규칙 10종으로 바로잡고(측정치 결측은 KNN 보간), 납품업체별 과거 불량 이력을 정보 누수 없이 인코딩하는 전처리 모듈을 구현한다. 긴급 불출 등 군수 특수 예외 기록은 삭제하지 않고 보존한다.\"\"\"\n", + "import numpy as np\n", + "import pandas as pd\n", + "from sklearn.impute import KNNImputer\n", + "\n", + "# ---------------------------------------------------------------------------\n", + "# [교육용가상자료] 군수품 수령검사 로트 데이터 생성기 (실제 업체·품목·계약 정보 없음)\n", + "# 구매요구(규격·긴급 여부) -> 계약(납기) -> 납품 -> 수령검사(표본) -> 확정 불량률(전수검사·사용부대 하자 반영)\n", + "# ---------------------------------------------------------------------------\n", + "FSC = {\"5305\": \"screw/bolt\", \"5330\": \"gasket\", \"5935\": \"connector\", \"6135\": \"battery\", \"2530\": \"brake part\", \"8415\": \"clothing\"}\n", + "VENDORS = [f\"V{i:02d}\" for i in range(1, 13)]\n", + "VENDOR_Q = dict(zip(VENDORS, [-0.6, -0.4, -0.3, -0.2, -0.1, 0.0, 0.1, 0.2, 0.3, 0.5, 0.8, 1.1])) # 업체 고유 품질 수준(숨은 값)\n", + "FSC_Q = {\"5305\": -0.3, \"5330\": 0.0, \"5935\": 0.25, \"6135\": 0.1, \"2530\": 0.35, \"8415\": -0.2}\n", + "SAMPLE_PLAN = [(500, 50, 3), (1200, 80, 5), (3200, 125, 7), (10 ** 9, 200, 10)] # (로트 크기 상한, 표본 n, 합격판정수 c) 단순화 기준\n", + "BAD_RATE = 0.05 # 확정 불량률 5% 초과 로트 = 불합격 대상\n", + "\n", + "\n", + "def sample_plan(lot_size):\n", + " for up, n, c in SAMPLE_PLAN:\n", + " if lot_size <= up:\n", + " return n, c\n", + "\n", + "\n", + "def true_rate(d, rng=None):\n", + " \"\"\"숨은 불량 발생 구조: 업체 수준 + 품목군 + 정밀 규격 + 긴급 구매 + 규격 변경·불일치 + 납기 지연 + (정밀 규격 x 촉박 납기) 상호작용.\"\"\"\n", + " short = (d[\"lead_days\"] < 45).astype(float)\n", + " z = (-4.35 + d[\"vendor\"].map(VENDOR_Q) + d[\"fsc\"].map(FSC_Q) + 0.55 * (d[\"spec_class\"] == \"precision\")\n", + " + 0.35 * d[\"pr_urgent\"] + 0.6 * d[\"spec_changed\"] + 0.8 * d[\"spec_mismatch\"]\n", + " + 0.9 * np.log1p(np.maximum(d[\"delay_days\"], 0) / 7) + 0.9 * short * (d[\"spec_class\"] == \"precision\")\n", + " + 0.5 * (d[\"month\"] >= 18) * (d[\"vendor\"] == \"V10\")) # V10 후반기 품질 저하(드리프트)\n", + " if rng is not None:\n", + " z = z + rng.normal(0, 0.12, len(d))\n", + " return np.clip(np.exp(z), 0.002, 0.35)\n", + "\n", + "\n", + "def make_lots(n=1200, seed=42, months=24):\n", + " rng = np.random.default_rng(seed)\n", + " d = pd.DataFrame({\"lot_id\": [f\"LOT{seed % 100:02d}-{i:05d}\" for i in range(n)]})\n", + " d[\"month\"] = rng.integers(0, months, n)\n", + " d[\"receipt_date\"] = pd.Timestamp(\"2024-01-01\") + pd.to_timedelta(d[\"month\"] * 30 + rng.integers(0, 30, n), unit=\"D\")\n", + " d = d.sort_values(\"receipt_date\").reset_index(drop=True)\n", + " d[\"month\"] = ((d[\"receipt_date\"] - pd.Timestamp(\"2024-01-01\")).dt.days // 30).clip(0, months - 1)\n", + " d[\"vendor\"] = rng.choice(VENDORS, n, p=np.r_[np.full(6, 0.1), np.full(6, 0.4 / 6)])\n", + " d[\"fsc\"] = rng.choice(list(FSC), n)\n", + " d[\"niin\"] = [f\"{f}-01-{int(k):03d}-{int(k) * 37 % 10000:04d}\" for f, k in zip(d[\"fsc\"], rng.integers(100, 110, n))]\n", + " d[\"spec_class\"] = np.where(rng.random(n) < np.where(d[\"fsc\"].isin([\"5935\", \"2530\"]), 0.6, 0.25), \"precision\", \"general\")\n", + " d[\"tolerance_mm\"] = np.where(d[\"spec_class\"] == \"precision\", rng.uniform(0.05, 0.1, n), rng.uniform(0.2, 0.5, n)).round(3)\n", + " d[\"spec_rev\"] = rng.integers(1, 6, n)\n", + " d[\"pr_urgent\"] = (rng.random(n) < 0.2).astype(int)\n", + " d[\"spec_changed\"] = (rng.random(n) < 0.12).astype(int)\n", + " d[\"spec_mismatch\"] = (rng.random(n) < 0.08).astype(int)\n", + " d[\"pr_qty\"] = rng.integers(200, 5000, n)\n", + " d[\"lot_size\"] = (d[\"pr_qty\"] * rng.uniform(0.5, 1.0, n)).astype(int)\n", + " d[\"lead_days\"] = np.where(d[\"pr_urgent\"] == 1, rng.integers(20, 60, n), rng.integers(40, 150, n))\n", + " d[\"delay_days\"] = np.round(rng.gamma(1.2, 6, n) * rng.binomial(1, 0.45, n) - rng.integers(0, 5, n)).astype(int)\n", + " d[\"unit_cost\"] = rng.choice([2_000, 15_000, 40_000, 120_000], n, p=[0.35, 0.3, 0.25, 0.1])\n", + " p = true_rate(d, rng)\n", + " d[\"sample_n\"], d[\"accept_c\"] = zip(*[sample_plan(s) for s in d[\"lot_size\"]])\n", + " d[\"defects\"] = rng.binomial(d[\"sample_n\"], p)\n", + " share = np.where(d[\"spec_class\"] == \"precision\", 0.55, 0.3)\n", + " d[\"def_dim\"] = rng.binomial(d[\"defects\"], share)\n", + " d[\"def_spec\"] = rng.binomial(d[\"defects\"] - d[\"def_dim\"], 0.6)\n", + " d[\"def_visual\"] = d[\"defects\"] - d[\"def_dim\"] - d[\"def_spec\"]\n", + " d[\"dim_dev_mm\"] = (d[\"tolerance_mm\"] * (0.3 + 2.0 * p) * rng.lognormal(0, 0.3, n)).round(4)\n", + " d[\"confirmed_rate\"] = np.clip(p * rng.lognormal(0, 0.05, n), 0.001, 0.4).round(4) # 전수검사·하자 반영 확정 불량률\n", + " d[\"field_claims\"] = rng.poisson(d[\"lot_size\"] * p * 0.03)\n", + " d[\"inspector\"] = rng.choice([\"INS-1\", \"INS-2\", \"INS-3\", \"INS-4\"], n)\n", + " d[\"emergency_release\"] = ((d[\"pr_urgent\"] == 1) & (rng.random(n) < 0.3)).astype(int) # 긴급 불출(검사 전 선불출) 예외\n", + " d[\"bad_lot\"] = (d[\"confirmed_rate\"] > BAD_RATE).astype(int)\n", + " return d\n", + "# ---------------------------------------------------------------------------\n", + "\n", + "\n", + "def make_raw(clean, seed=5):\n", + " \"\"\"[교육용가상자료] 현장 입력 오류를 흉내 낸 원시 데이터: 결측·형식 오류·단위 혼용·중복 등을 일부러 넣는다.\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " r = clean.copy()\n", + " r[\"receipt_date\"] = r[\"receipt_date\"].dt.strftime(\"%Y-%m-%d\").astype(object)\n", + " idx = lambda k: rng.choice(len(r), k, replace=False)\n", + " r.loc[idx(40), \"dim_dev_mm\"] = np.nan # 측정치 결측\n", + " i = idx(15); r.loc[i, \"dim_dev_mm\"] = r.loc[i, \"dim_dev_mm\"] * 1000 # mm/um 단위 혼용\n", + " i = idx(20); r.loc[i, \"vendor\"] = [\" \" + v.lower() + \" \" for v in r.loc[i, \"vendor\"]] # 대소문자·공백\n", + " i = idx(15); r.loc[i, \"receipt_date\"] = [d.replace(\"-\", \".\") for d in r.loc[i, \"receipt_date\"]] # 날짜 형식\n", + " i = idx(10); r.loc[i, \"niin\"] = [n.replace(\"-\", \"\") for n in r.loc[i, \"niin\"]] # 재고번호 하이픈 누락\n", + " i = idx(8); r.loc[i, \"lot_size\"] = -r.loc[i, \"lot_size\"] # 부호 오류\n", + " i = idx(6); r.loc[i, \"defects\"] = r.loc[i, \"defects\"] + 3 # 유형별 합 불일치\n", + " i = idx(5); r.loc[i, \"spec_class\"] = \"PRECISION\" # 코드 표기 불일치\n", + " i = idx(5); r.loc[i, \"inspector\"] = np.nan # 검사관 누락\n", + " r = pd.concat([r, r.iloc[idx(12)]], ignore_index=True) # 중복 입력\n", + " return r\n", + "\n", + "\n", + "def clean(raw):\n", + " d, log = raw.copy(), []\n", + "\n", + " def rule(name, mask, fix):\n", + " n = int(mask.sum())\n", + " if n:\n", + " fix(mask)\n", + " log.append((name, n))\n", + "\n", + " rule(\"R01 중복 로트 제거\", d.duplicated(\"lot_id\"), lambda m: None)\n", + " d = d[~d.duplicated(\"lot_id\")].reset_index(drop=True)\n", + " rule(\"R02 업체 코드 공백·대소문자 정규화\", d[\"vendor\"] != d[\"vendor\"].str.strip().str.upper(),\n", + " lambda m: d.__setitem__(\"vendor\", d[\"vendor\"].str.strip().str.upper()))\n", + " rule(\"R03 날짜 형식 통일(YYYY-MM-DD)\", d[\"receipt_date\"].str.contains(r\"\\.\"),\n", + " lambda m: d.__setitem__(\"receipt_date\", d[\"receipt_date\"].str.replace(\".\", \"-\", regex=False)))\n", + " d[\"receipt_date\"] = pd.to_datetime(d[\"receipt_date\"])\n", + " bad_niin = ~d[\"niin\"].str.match(r\"^\\d{4}-\\d{2}-\\d{3}-\\d{4}$\")\n", + " fixed_niin = d[\"niin\"].str.replace(r\"^(\\d{4})(\\d{2})(\\d{3})(\\d{4})$\", r\"\\1-\\2-\\3-\\4\", regex=True)\n", + " rule(\"R04 재고번호(NIIN) 형식 복원\", bad_niin, lambda m: d.__setitem__(\"niin\", fixed_niin))\n", + " rule(\"R05 규격 등급 코드 소문자 통일\", d[\"spec_class\"] != d[\"spec_class\"].str.lower(),\n", + " lambda m: d.__setitem__(\"spec_class\", d[\"spec_class\"].str.lower()))\n", + " rule(\"R06 로트 크기 부호 오류 수정\", d[\"lot_size\"] < 0, lambda m: d.__setitem__(\"lot_size\", d[\"lot_size\"].abs()))\n", + " rule(\"R07 측정 단위 환산(um -> mm)\", d[\"dim_dev_mm\"] > 5, lambda m: d.__setitem__(\"dim_dev_mm\", d[\"dim_dev_mm\"].where(~m, d[\"dim_dev_mm\"] / 1000)))\n", + " rule(\"R08 유형별 합에 맞춰 불량 수 재계산\", d[\"defects\"] != d[[\"def_spec\", \"def_dim\", \"def_visual\"]].sum(axis=1),\n", + " lambda m: d.__setitem__(\"defects\", d[[\"def_spec\", \"def_dim\", \"def_visual\"]].sum(axis=1)))\n", + " rule(\"R09 검사관 누락 -> 'UNKNOWN' 표시(삭제하지 않음)\", d[\"inspector\"].isna(), lambda m: d.__setitem__(\"inspector\", d[\"inspector\"].fillna(\"UNKNOWN\")))\n", + " miss = d[\"dim_dev_mm\"].isna()\n", + " num = [\"dim_dev_mm\", \"tolerance_mm\", \"defects\", \"sample_n\", \"lot_size\"]\n", + " Z = d[num].astype(float); mu, sd = Z.mean(), Z.std()\n", + " filled = KNNImputer(n_neighbors=5).fit_transform((Z - mu) / sd) * sd.values + mu.values\n", + " rule(\"R10 치수 편차 결측 KNN 보간(k=5)\", miss, lambda m: d.__setitem__(\"dim_dev_mm\", np.where(m, filled[:, 0].round(4), d[\"dim_dev_mm\"])))\n", + " d[\"dim_imputed\"] = miss.astype(int) # 보간 여부 표시(추적성)\n", + " return d, log\n", + "\n", + "\n", + "def integrity(d):\n", + " \"\"\"정합성 검증 항목: 모두 True여야 통과.\"\"\"\n", + " return {\"로트 번호 유일\": d[\"lot_id\"].is_unique,\n", + " \"업체 코드 형식\": d[\"vendor\"].str.match(r\"^V\\d{2}$\").all(),\n", + " \"재고번호 형식\": d[\"niin\"].str.match(r\"^\\d{4}-\\d{2}-\\d{3}-\\d{4}$\").all(),\n", + " \"재고번호-품목군 일치\": (d[\"niin\"].str[:4] == d[\"fsc\"]).all(),\n", + " \"로트 크기 양수\": (d[\"lot_size\"] > 0).all(),\n", + " \"표본 <= 로트\": (d[\"sample_n\"] <= d[\"lot_size\"]).all(),\n", + " \"불량 유형 합 일치\": (d[\"defects\"] == d[[\"def_spec\", \"def_dim\", \"def_visual\"]].sum(axis=1)).all(),\n", + " \"치수 편차 결측 없음·범위\": d[\"dim_dev_mm\"].between(0, 5).all(),\n", + " \"날짜형 변환\": pd.api.types.is_datetime64_any_dtype(d[\"receipt_date\"]),\n", + " \"규격 등급 허용값\": d[\"spec_class\"].isin([\"precision\", \"general\"]).all()}\n", + "\n", + "\n", + "def encode_vendor_history(d):\n", + " \"\"\"업체별 불량 이력 인코딩: 같은 업체의 '이전' 로트 확정 불량률만 써서 평균·건수·최근 5로트 평균을 만든다.\"\"\"\n", + " d = d.sort_values(\"receipt_date\").reset_index(drop=True)\n", + " g = d.groupby(\"vendor\")[\"confirmed_rate\"]\n", + " d[\"vendor_prev_n\"] = d.groupby(\"vendor\").cumcount()\n", + " d[\"vendor_hist_rate\"] = g.transform(lambda s: s.shift().expanding().mean())\n", + " d[\"vendor_recent_rate\"] = g.transform(lambda s: s.shift().rolling(5, min_periods=1).mean())\n", + " prior = d[\"confirmed_rate\"].mean() # 이력이 적은 업체는 전체 평균 쪽으로 수축\n", + " w = d[\"vendor_prev_n\"] / (d[\"vendor_prev_n\"] + 10)\n", + " d[\"vendor_hist_enc\"] = (w * d[\"vendor_hist_rate\"].fillna(prior) + (1 - w) * prior).round(4)\n", + " return d\n", + "\n", + "\n", + "truth = make_lots(1200, seed=42)\n", + "raw = make_raw(truth)\n", + "print(f\"원시 기록 {len(raw)}건 (중복 포함), 결측 치수값 {raw['dim_dev_mm'].isna().sum()}건\")\n", + "pre = integrity(raw)\n", + "print(f\"정제 전 정합성 검증 {sum(pre.values())}/{len(pre)} 통과, 실패 항목: {', '.join(k for k, v in pre.items() if not v)}\")\n", + "cleaned, log = clean(raw)\n", + "print(\"\\n[정제 규칙 적용 내역]\")\n", + "for name, n in log:\n", + " print(f\" {name:34s} {n:4d}건\")\n", + "chk = integrity(cleaned)\n", + "rate = np.mean(list(chk.values()))\n", + "print(f\"\\n정제 규칙 {len(log)}종 적용, 정합성 검증 {sum(chk.values())}/{len(chk)} 통과 ({rate:.0%}) (목표 100%: {'달성' if rate == 1 else '미달'})\")\n", + "print(f\"긴급 불출 예외 기록 보존: 원본 {truth['emergency_release'].sum()}건 -> 정제 후 {cleaned['emergency_release'].sum()}건 (삭제 없음)\")\n", + "\n", + "m = cleaned[\"dim_imputed\"] == 1\n", + "ref = truth.set_index(\"lot_id\").loc[cleaned.loc[m, \"lot_id\"], \"dim_dev_mm\"].values\n", + "print(f\"KNN 보간 {m.sum()}건 평균 절대오차 {np.mean(np.abs(cleaned.loc[m, 'dim_dev_mm'].values - ref)):.4f} mm (단순 평균 대치 {np.mean(np.abs(truth['dim_dev_mm'].mean() - ref)):.4f} mm)\")\n", + "\n", + "print(\"\\n[전후 기술통계 비교]\")\n", + "cmp = pd.DataFrame({\"정제 전 평균\": raw[[\"lot_size\", \"dim_dev_mm\", \"defects\"]].mean(), \"정제 후 평균\": cleaned[[\"lot_size\", \"dim_dev_mm\", \"defects\"]].mean(),\n", + " \"원본(정답) 평균\": truth[[\"lot_size\", \"dim_dev_mm\", \"defects\"]].mean()}).round(3)\n", + "print(cmp.to_string())\n", + "\n", + "enc = encode_vendor_history(cleaned)\n", + "print(\"\\n[업체별 불량 이력 인코딩 예시: 마지막 로트 기준]\")\n", + "print(enc.groupby(\"vendor\").tail(1)[[\"vendor\", \"vendor_prev_n\", \"vendor_hist_enc\", \"vendor_recent_rate\"]].sort_values(\"vendor_hist_enc\", ascending=False).head(6).round(4).to_string(index=False))\n", + "print(\"주의: 현재 로트의 확정 불량률을 인코딩에 넣으면 정보 누수가 생기므로 이전 로트만 사용했다.\")\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 02 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + "원시 기록 1212건 (중복 포함), 결측 치수값 40건\n", + "정제 전 정합성 검증 1/10 통과, 실패 항목: 로트 번호 유일, 업체 코드 형식, 재고번호 형식, 로트 크기 양수, 표본 <= 로트, 불량 유형 합 일치, 치수 편차 결측 없음·범위, 날짜형 변환, 규격 등급 허용값\n", + "\n", + "[정제 규칙 적용 내역]\n", + " R01 중복 로트 제거 12건\n", + " R02 업체 코드 공백·대소문자 정규화 20건\n", + " R03 날짜 형식 통일(YYYY-MM-DD) 15건\n", + " R04 재고번호(NIIN) 형식 복원 10건\n", + " R05 규격 등급 코드 소문자 통일 5건\n", + " R06 로트 크기 부호 오류 수정 8건\n", + " R07 측정 단위 환산(um -> mm) 14건\n", + " R08 유형별 합에 맞춰 불량 수 재계산 6건\n", + " R09 검사관 누락 -> 'UNKNOWN' 표시(삭제하지 않음) 5건\n", + " R10 치수 편차 결측 KNN 보간(k=5) 40건\n", + "\n", + "정제 규칙 10종 적용, 정합성 검증 10/10 통과 (100%) (목표 100%: 달성)\n", + "긴급 불출 예외 기록 보존: 원본 76건 -> 정제 후 76건 (삭제 없음)\n", + "KNN 보간 40건 평균 절대오차 0.0233 mm (단순 평균 대치 0.0543 mm)\n", + "\n", + "[전후 기술통계 비교]\n", + " 정제 전 평균 정제 후 평균 원본(정답) 평균\n", + "lot_size 1970.468 1983.859 1983.859\n", + "dim_dev_mm 0.973 0.094 0.094\n", + "defects 5.051 5.061 5.061\n", + "\n", + "[업체별 불량 이력 인코딩 예시: 마지막 로트 기준]\n", + "vendor vendor_prev_n vendor_hist_enc vendor_recent_rate\n", + " V12 86 0.1072 0.1152\n", + " V11 80 0.0699 0.0366\n", + " V10 79 0.0692 0.1406\n", + " V08 57 0.0487 0.0278\n", + " V09 81 0.0450 0.0539\n", + " V07 69 0.0402 0.0307\n", + "주의: 현재 로트의 확정 불량률을 인코딩에 넣으면 정보 누수가 생기므로 이전 로트만 사용했다.\n", + "```\n", + "
\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 03. 불량률 통계 기반 1차 로트(Lot) 합격/불합격 판정 Baseline 알고리즘 구현\n", + "**소요시간**: 약 2.5시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "불량률 통계 기반 로트(Lot) 합격/불합격 판정 Baseline 알고리즘을 구현한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **표본 불량 수가 합격판정수 이하이면 합격시키는 통계적 샘플링 기반 로트 합격/불합격 판정 Baseline을 구현하고, 탐색적 분석으로 불량 관련 요인을 찾는다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **로트 크기별 표본 수와 합격판정수로 판정한 뒤 확정 불량률과 비교해 오합격·오불합격을 측정 수행하고 검증하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `notebooks/qlt02_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": [ + "\"\"\"표본 불량 수가 합격판정수(c) 이하이면 합격시키는 통계적 샘플링 기반 로트 합격/불합격 판정 Baseline을 구현해 오판정(오합격·오불합격)을 측정하고, 탐색적 데이터 분석(차트 8종·상관 검정·파레토 ABC)으로 불량과 관련된 규격·구매요구·납기 요인을 찾는다.\"\"\"\n", + "import numpy as np\n", + "import pandas as pd\n", + "import matplotlib.pyplot as plt\n", + "from scipy import stats\n", + "\n", + "# ---------------------------------------------------------------------------\n", + "# [교육용가상자료] 군수품 수령검사 로트 데이터 생성기 (실제 업체·품목·계약 정보 없음)\n", + "# 구매요구(규격·긴급 여부) -> 계약(납기) -> 납품 -> 수령검사(표본) -> 확정 불량률(전수검사·사용부대 하자 반영)\n", + "# ---------------------------------------------------------------------------\n", + "FSC = {\"5305\": \"screw/bolt\", \"5330\": \"gasket\", \"5935\": \"connector\", \"6135\": \"battery\", \"2530\": \"brake part\", \"8415\": \"clothing\"}\n", + "VENDORS = [f\"V{i:02d}\" for i in range(1, 13)]\n", + "VENDOR_Q = dict(zip(VENDORS, [-0.6, -0.4, -0.3, -0.2, -0.1, 0.0, 0.1, 0.2, 0.3, 0.5, 0.8, 1.1])) # 업체 고유 품질 수준(숨은 값)\n", + "FSC_Q = {\"5305\": -0.3, \"5330\": 0.0, \"5935\": 0.25, \"6135\": 0.1, \"2530\": 0.35, \"8415\": -0.2}\n", + "SAMPLE_PLAN = [(500, 50, 3), (1200, 80, 5), (3200, 125, 7), (10 ** 9, 200, 10)] # (로트 크기 상한, 표본 n, 합격판정수 c) 단순화 기준\n", + "BAD_RATE = 0.05 # 확정 불량률 5% 초과 로트 = 불합격 대상\n", + "\n", + "\n", + "def sample_plan(lot_size):\n", + " for up, n, c in SAMPLE_PLAN:\n", + " if lot_size <= up:\n", + " return n, c\n", + "\n", + "\n", + "def true_rate(d, rng=None):\n", + " \"\"\"숨은 불량 발생 구조: 업체 수준 + 품목군 + 정밀 규격 + 긴급 구매 + 규격 변경·불일치 + 납기 지연 + (정밀 규격 x 촉박 납기) 상호작용.\"\"\"\n", + " short = (d[\"lead_days\"] < 45).astype(float)\n", + " z = (-4.35 + d[\"vendor\"].map(VENDOR_Q) + d[\"fsc\"].map(FSC_Q) + 0.55 * (d[\"spec_class\"] == \"precision\")\n", + " + 0.35 * d[\"pr_urgent\"] + 0.6 * d[\"spec_changed\"] + 0.8 * d[\"spec_mismatch\"]\n", + " + 0.9 * np.log1p(np.maximum(d[\"delay_days\"], 0) / 7) + 0.9 * short * (d[\"spec_class\"] == \"precision\")\n", + " + 0.5 * (d[\"month\"] >= 18) * (d[\"vendor\"] == \"V10\")) # V10 후반기 품질 저하(드리프트)\n", + " if rng is not None:\n", + " z = z + rng.normal(0, 0.12, len(d))\n", + " return np.clip(np.exp(z), 0.002, 0.35)\n", + "\n", + "\n", + "def make_lots(n=1200, seed=42, months=24):\n", + " rng = np.random.default_rng(seed)\n", + " d = pd.DataFrame({\"lot_id\": [f\"LOT{seed % 100:02d}-{i:05d}\" for i in range(n)]})\n", + " d[\"month\"] = rng.integers(0, months, n)\n", + " d[\"receipt_date\"] = pd.Timestamp(\"2024-01-01\") + pd.to_timedelta(d[\"month\"] * 30 + rng.integers(0, 30, n), unit=\"D\")\n", + " d = d.sort_values(\"receipt_date\").reset_index(drop=True)\n", + " d[\"month\"] = ((d[\"receipt_date\"] - pd.Timestamp(\"2024-01-01\")).dt.days // 30).clip(0, months - 1)\n", + " d[\"vendor\"] = rng.choice(VENDORS, n, p=np.r_[np.full(6, 0.1), np.full(6, 0.4 / 6)])\n", + " d[\"fsc\"] = rng.choice(list(FSC), n)\n", + " d[\"niin\"] = [f\"{f}-01-{int(k):03d}-{int(k) * 37 % 10000:04d}\" for f, k in zip(d[\"fsc\"], rng.integers(100, 110, n))]\n", + " d[\"spec_class\"] = np.where(rng.random(n) < np.where(d[\"fsc\"].isin([\"5935\", \"2530\"]), 0.6, 0.25), \"precision\", \"general\")\n", + " d[\"tolerance_mm\"] = np.where(d[\"spec_class\"] == \"precision\", rng.uniform(0.05, 0.1, n), rng.uniform(0.2, 0.5, n)).round(3)\n", + " d[\"spec_rev\"] = rng.integers(1, 6, n)\n", + " d[\"pr_urgent\"] = (rng.random(n) < 0.2).astype(int)\n", + " d[\"spec_changed\"] = (rng.random(n) < 0.12).astype(int)\n", + " d[\"spec_mismatch\"] = (rng.random(n) < 0.08).astype(int)\n", + " d[\"pr_qty\"] = rng.integers(200, 5000, n)\n", + " d[\"lot_size\"] = (d[\"pr_qty\"] * rng.uniform(0.5, 1.0, n)).astype(int)\n", + " d[\"lead_days\"] = np.where(d[\"pr_urgent\"] == 1, rng.integers(20, 60, n), rng.integers(40, 150, n))\n", + " d[\"delay_days\"] = np.round(rng.gamma(1.2, 6, n) * rng.binomial(1, 0.45, n) - rng.integers(0, 5, n)).astype(int)\n", + " d[\"unit_cost\"] = rng.choice([2_000, 15_000, 40_000, 120_000], n, p=[0.35, 0.3, 0.25, 0.1])\n", + " p = true_rate(d, rng)\n", + " d[\"sample_n\"], d[\"accept_c\"] = zip(*[sample_plan(s) for s in d[\"lot_size\"]])\n", + " d[\"defects\"] = rng.binomial(d[\"sample_n\"], p)\n", + " share = np.where(d[\"spec_class\"] == \"precision\", 0.55, 0.3)\n", + " d[\"def_dim\"] = rng.binomial(d[\"defects\"], share)\n", + " d[\"def_spec\"] = rng.binomial(d[\"defects\"] - d[\"def_dim\"], 0.6)\n", + " d[\"def_visual\"] = d[\"defects\"] - d[\"def_dim\"] - d[\"def_spec\"]\n", + " d[\"dim_dev_mm\"] = (d[\"tolerance_mm\"] * (0.3 + 2.0 * p) * rng.lognormal(0, 0.3, n)).round(4)\n", + " d[\"confirmed_rate\"] = np.clip(p * rng.lognormal(0, 0.05, n), 0.001, 0.4).round(4) # 전수검사·하자 반영 확정 불량률\n", + " d[\"field_claims\"] = rng.poisson(d[\"lot_size\"] * p * 0.03)\n", + " d[\"inspector\"] = rng.choice([\"INS-1\", \"INS-2\", \"INS-3\", \"INS-4\"], n)\n", + " d[\"emergency_release\"] = ((d[\"pr_urgent\"] == 1) & (rng.random(n) < 0.3)).astype(int) # 긴급 불출(검사 전 선불출) 예외\n", + " d[\"bad_lot\"] = (d[\"confirmed_rate\"] > BAD_RATE).astype(int)\n", + " return d\n", + "# ---------------------------------------------------------------------------\n", + "\n", + "d = make_lots(1200, seed=42)\n", + "\n", + "# 1) Baseline: 단일 샘플링 판정 (표본 불량 수 <= c 이면 합격)\n", + "d[\"base_accept\"] = (d[\"defects\"] <= d[\"accept_c\"]).astype(int)\n", + "cm = pd.crosstab(d[\"bad_lot\"].map({0: \"실제 양호\", 1: \"실제 불량\"}), d[\"base_accept\"].map({1: \"합격 판정\", 0: \"불합격 판정\"}))\n", + "print(\"[Baseline 판정 혼동표] (불량 로트 기준: 확정 불량률 5% 초과)\")\n", + "print(cm.to_string())\n", + "acc = (d[\"base_accept\"] == 1 - d[\"bad_lot\"]).mean()\n", + "cons = ((d[\"base_accept\"] == 1) & (d[\"bad_lot\"] == 1)).sum() / d[\"bad_lot\"].sum()\n", + "prod = ((d[\"base_accept\"] == 0) & (d[\"bad_lot\"] == 0)).sum() / (d[\"bad_lot\"] == 0).sum()\n", + "print(f\"판정 정확도 {acc:.1%}, 오합격(소비자 위험) {cons:.1%}, 오불합격(생산자 위험) {prod:.1%}\")\n", + "print(\"한계: 표본 불량 수만 보므로 업체 이력·규격 변경·납기 지연 같은 사전 정보를 쓰지 못하고, 경계선 로트에서 오판정이 몰린다.\")\n", + "\n", + "# 2) 상관 분석: 확정 불량률과 각 요인의 스피어만 상관계수·p-value\n", + "cand = {\"delay_days\": d[\"delay_days\"], \"lead_days\": d[\"lead_days\"], \"precision_spec\": (d[\"spec_class\"] == \"precision\").astype(int),\n", + " \"pr_urgent\": d[\"pr_urgent\"], \"spec_changed\": d[\"spec_changed\"], \"spec_mismatch\": d[\"spec_mismatch\"],\n", + " \"dim_dev/tol\": d[\"dim_dev_mm\"] / d[\"tolerance_mm\"], \"lot_size\": d[\"lot_size\"], \"spec_rev\": d[\"spec_rev\"], \"unit_cost\": d[\"unit_cost\"]}\n", + "rows = []\n", + "for k, v in cand.items():\n", + " r, p = stats.spearmanr(v, d[\"confirmed_rate\"])\n", + " rows.append({\"요인\": k, \"상관계수\": round(r, 3), \"p-value\": f\"{p:.1e}\", \"유의(p<0.05)\": \"예\" if p < 0.05 else \"아니오\"})\n", + "cr = pd.DataFrame(rows).sort_values(\"상관계수\", key=abs, ascending=False)\n", + "print(\"\\n[확정 불량률과 요인별 상관 검정]\")\n", + "print(cr.to_string(index=False))\n", + "n_sig = (cr[\"유의(p<0.05)\"] == \"예\").sum()\n", + "print(f\"유의미한 상관 피처 {n_sig}개 (목표 5개 이상: {'달성' if n_sig >= 5 else '미달'})\")\n", + "chi = stats.chi2_contingency(pd.crosstab(d[\"vendor\"], d[\"bad_lot\"]))\n", + "print(f\"업체와 불량 로트 여부의 독립성 카이제곱 검정 p-value {chi.pvalue:.1e} -> 업체별 차이 {'있음' if chi.pvalue < 0.05 else '없음'}\")\n", + "\n", + "# 3) 파레토(ABC) 분석: 불량 개수 추정치(로트 크기 x 확정 불량률) 기준 업체 순위\n", + "d[\"def_units\"] = d[\"lot_size\"] * d[\"confirmed_rate\"]\n", + "par = d.groupby(\"vendor\")[\"def_units\"].sum().sort_values(ascending=False)\n", + "cum = par.cumsum() / par.sum()\n", + "abc = np.where(cum <= 0.5, \"A\", np.where(cum <= 0.8, \"B\", \"C\"))\n", + "top = par.index[: int(np.ceil(len(par) * 0.2))]\n", + "print(f\"\\n[파레토 ABC] 상위 20% 업체 {list(top)}가 추정 불량 개수의 {par[top].sum() / par.sum():.0%}를 차지 -> 핵심 관리 대상\")\n", + "print(\"A등급:\", list(par.index[abc == \"A\"]), \"/ B등급:\", list(par.index[abc == \"B\"]))\n", + "\n", + "# 4) EDA 차트 8종\n", + "fig, ax = plt.subplots(2, 4, figsize=(17, 8))\n", + "ax[0, 0].hist(d[\"confirmed_rate\"], bins=40, color=\"tab:blue\"); ax[0, 0].axvline(BAD_RATE, c=\"r\", ls=\"--\"); ax[0, 0].set_title(\"1. Confirmed defect rate\")\n", + "d.groupby(\"month\")[\"confirmed_rate\"].mean().plot(ax=ax[0, 1], marker=\"o\"); ax[0, 1].set_title(\"2. Monthly mean defect rate\")\n", + "par.plot.bar(ax=ax[0, 2], color=\"tab:orange\"); ax2 = ax[0, 2].twinx(); ax2.plot(range(len(cum)), cum.values, \"k-o\"); ax[0, 2].set_title(\"3. Vendor Pareto\")\n", + "d.boxplot(column=\"confirmed_rate\", by=\"spec_class\", ax=ax[0, 3]); ax[0, 3].set_title(\"4. Rate by spec class\")\n", + "ax[1, 0].scatter(d[\"delay_days\"], d[\"confirmed_rate\"], s=6, alpha=0.5); ax[1, 0].set_title(\"5. Delivery delay vs rate\"); ax[1, 0].set_xlabel(\"delay days\")\n", + "pd.crosstab(d[\"fsc\"], d[\"bad_lot\"], normalize=\"index\")[1].plot.bar(ax=ax[1, 1], color=\"tab:red\"); ax[1, 1].set_title(\"6. Bad-lot share by FSC\")\n", + "d[[\"def_spec\", \"def_dim\", \"def_visual\"]].sum().plot.pie(ax=ax[1, 2], autopct=\"%.0f%%\", labels=[\"spec\", \"dimension\", \"visual\"]); ax[1, 2].set_ylabel(\"\"); ax[1, 2].set_title(\"7. Defect type mix\")\n", + "cmat = pd.DataFrame(cand).assign(rate=d[\"confirmed_rate\"]).corr(method=\"spearman\")\n", + "ax[1, 3].imshow(cmat, cmap=\"coolwarm\", vmin=-1, vmax=1); ax[1, 3].set_xticks(range(len(cmat))); ax[1, 3].set_xticklabels(cmat.columns, rotation=90, fontsize=7)\n", + "ax[1, 3].set_yticks(range(len(cmat))); ax[1, 3].set_yticklabels(cmat.columns, fontsize=7); ax[1, 3].set_title(\"8. Spearman correlation\")\n", + "plt.suptitle(\"QLT02 EDA - synthetic receiving inspection data\"); plt.tight_layout(); plt.show()\n", + "print(f\"\\nEDA 차트 {len(fig.axes) - 1}종 생성 (목표 8종 이상: 달성)\")\n", + "\n", + "print(\"\\n[핵심 발견점]\")\n", + "print(f\" 1. 정밀 규격 로트의 평균 확정 불량률 {d[d.spec_class == 'precision'].confirmed_rate.mean():.1%}, 일반 규격 {d[d.spec_class == 'general'].confirmed_rate.mean():.1%}\")\n", + "print(f\" 2. 규격 불일치 로트 {d[d.spec_mismatch == 1].confirmed_rate.mean():.1%} vs 일치 {d[d.spec_mismatch == 0].confirmed_rate.mean():.1%}\")\n", + "print(f\" 3. 납기 14일 이상 지연 로트 {d[d.delay_days >= 14].confirmed_rate.mean():.1%} vs 정시 납품 {d[d.delay_days <= 0].confirmed_rate.mean():.1%}\")\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 03 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + "[Baseline 판정 혼동표] (불량 로트 기준: 확정 불량률 5% 초과)\n", + "base_accept 불합격 판정 합격 판정\n", + "bad_lot \n", + "실제 불량 203 79\n", + "실제 양호 34 884\n", + "판정 정확도 90.6%, 오합격(소비자 위험) 28.0%, 오불합격(생산자 위험) 3.7%\n", + "한계: 표본 불량 수만 보므로 업체 이력·규격 변경·납기 지연 같은 사전 정보를 쓰지 못하고, 경계선 로트에서 오판정이 몰린다.\n", + "\n", + "[확정 불량률과 요인별 상관 검정]\n", + " 요인 상관계수 p-value 유의(p<0.05)\n", + " dim_dev/tol 0.459 1.1e-63 예\n", + "precision_spec 0.442 1.7e-58 예\n", + " delay_days 0.288 2.5e-24 예\n", + " pr_urgent 0.277 1.6e-22 예\n", + " spec_mismatch 0.267 5.3e-21 예\n", + " spec_changed 0.211 1.4e-13 예\n", + " lead_days -0.182 2.1e-10 예\n", + " lot_size 0.024 4.1e-01 아니오\n", + " unit_cost 0.014 6.3e-01 아니오\n", + " spec_rev 0.002 9.3e-01 아니오\n", + "유의미한 상관 피처 7개 (목표 5개 이상: 달성)\n", + "업체와 불량 로트 여부의 독립성 카이제곱 검정 p-value 2.3e-55 -> 업체별 차이 있음\n", + "\n", + "[파레토 ABC] 상위 20% 업체 ['V12', 'V11', 'V10']가 추정 불량 개수의 43%를 차지 -> 핵심 관리 대상\n", + "A등급: ['V12', 'V11', 'V10'] / B등급: ['V05', 'V09', 'V03', 'V06', 'V04']\n", + "\n", + "EDA 차트 8종 생성 (목표 8종 이상: 달성)\n", + "\n", + "[핵심 발견점]\n", + " 1. 정밀 규격 로트의 평균 확정 불량률 6.6%, 일반 규격 2.8%\n", + " 2. 규격 불일치 로트 8.1% vs 일치 3.9%\n", + " 3. 납기 14일 이상 지연 로트 9.4% vs 정시 납품 3.4%\n", + "```\n", + "
\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 04. 다변량 분석 및 머신러닝 기반 은닉 품질 결함 패턴 식별 모델 고도화\n", + "**소요시간**: 약 3.0시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "다변량 분석 기반 은닉 품질 결함 패턴 식별 모델을 고도화한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **다변량 분석으로 단일 요인에서 드러나지 않는 은닉 품질 결함 패턴(규격 x 납기 상호작용, 업체 품질 추세)을 식별하는 피처와 기준 모델을 만든다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **표본 불량률, 지연 비율, 정밀 규격 x 촉박 납기, 치수 편차/공차, 업체 최근 추세 등 파생 피처 10개 이상 작성하여 원인을 분석하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `src/features/qlt02_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": [ + "\"\"\"규격·구매요구·납기·검사 정보로 파생 피처를 만들고 다변량 선형 기준 모델(Ridge)로 로트 확정 불량률을 예측한다. 시계열 교차검증으로 과적합을 점검하고, 상호작용·추세 피처로 단일 요인 분석에서 보이지 않던 은닉 결함 패턴을 찾는다.\"\"\"\n", + "import numpy as np\n", + "import pandas as pd\n", + "import matplotlib.pyplot as plt\n", + "from sklearn.linear_model import Ridge\n", + "from sklearn.pipeline import make_pipeline\n", + "from sklearn.preprocessing import StandardScaler\n", + "from sklearn.model_selection import TimeSeriesSplit\n", + "from sklearn.inspection import permutation_importance\n", + "\n", + "# ---------------------------------------------------------------------------\n", + "# [교육용가상자료] 군수품 수령검사 로트 데이터 생성기 (실제 업체·품목·계약 정보 없음)\n", + "# 구매요구(규격·긴급 여부) -> 계약(납기) -> 납품 -> 수령검사(표본) -> 확정 불량률(전수검사·사용부대 하자 반영)\n", + "# ---------------------------------------------------------------------------\n", + "FSC = {\"5305\": \"screw/bolt\", \"5330\": \"gasket\", \"5935\": \"connector\", \"6135\": \"battery\", \"2530\": \"brake part\", \"8415\": \"clothing\"}\n", + "VENDORS = [f\"V{i:02d}\" for i in range(1, 13)]\n", + "VENDOR_Q = dict(zip(VENDORS, [-0.6, -0.4, -0.3, -0.2, -0.1, 0.0, 0.1, 0.2, 0.3, 0.5, 0.8, 1.1])) # 업체 고유 품질 수준(숨은 값)\n", + "FSC_Q = {\"5305\": -0.3, \"5330\": 0.0, \"5935\": 0.25, \"6135\": 0.1, \"2530\": 0.35, \"8415\": -0.2}\n", + "SAMPLE_PLAN = [(500, 50, 3), (1200, 80, 5), (3200, 125, 7), (10 ** 9, 200, 10)] # (로트 크기 상한, 표본 n, 합격판정수 c) 단순화 기준\n", + "BAD_RATE = 0.05 # 확정 불량률 5% 초과 로트 = 불합격 대상\n", + "\n", + "\n", + "def sample_plan(lot_size):\n", + " for up, n, c in SAMPLE_PLAN:\n", + " if lot_size <= up:\n", + " return n, c\n", + "\n", + "\n", + "def true_rate(d, rng=None):\n", + " \"\"\"숨은 불량 발생 구조: 업체 수준 + 품목군 + 정밀 규격 + 긴급 구매 + 규격 변경·불일치 + 납기 지연 + (정밀 규격 x 촉박 납기) 상호작용.\"\"\"\n", + " short = (d[\"lead_days\"] < 45).astype(float)\n", + " z = (-4.35 + d[\"vendor\"].map(VENDOR_Q) + d[\"fsc\"].map(FSC_Q) + 0.55 * (d[\"spec_class\"] == \"precision\")\n", + " + 0.35 * d[\"pr_urgent\"] + 0.6 * d[\"spec_changed\"] + 0.8 * d[\"spec_mismatch\"]\n", + " + 0.9 * np.log1p(np.maximum(d[\"delay_days\"], 0) / 7) + 0.9 * short * (d[\"spec_class\"] == \"precision\")\n", + " + 0.5 * (d[\"month\"] >= 18) * (d[\"vendor\"] == \"V10\")) # V10 후반기 품질 저하(드리프트)\n", + " if rng is not None:\n", + " z = z + rng.normal(0, 0.12, len(d))\n", + " return np.clip(np.exp(z), 0.002, 0.35)\n", + "\n", + "\n", + "def make_lots(n=1200, seed=42, months=24):\n", + " rng = np.random.default_rng(seed)\n", + " d = pd.DataFrame({\"lot_id\": [f\"LOT{seed % 100:02d}-{i:05d}\" for i in range(n)]})\n", + " d[\"month\"] = rng.integers(0, months, n)\n", + " d[\"receipt_date\"] = pd.Timestamp(\"2024-01-01\") + pd.to_timedelta(d[\"month\"] * 30 + rng.integers(0, 30, n), unit=\"D\")\n", + " d = d.sort_values(\"receipt_date\").reset_index(drop=True)\n", + " d[\"month\"] = ((d[\"receipt_date\"] - pd.Timestamp(\"2024-01-01\")).dt.days // 30).clip(0, months - 1)\n", + " d[\"vendor\"] = rng.choice(VENDORS, n, p=np.r_[np.full(6, 0.1), np.full(6, 0.4 / 6)])\n", + " d[\"fsc\"] = rng.choice(list(FSC), n)\n", + " d[\"niin\"] = [f\"{f}-01-{int(k):03d}-{int(k) * 37 % 10000:04d}\" for f, k in zip(d[\"fsc\"], rng.integers(100, 110, n))]\n", + " d[\"spec_class\"] = np.where(rng.random(n) < np.where(d[\"fsc\"].isin([\"5935\", \"2530\"]), 0.6, 0.25), \"precision\", \"general\")\n", + " d[\"tolerance_mm\"] = np.where(d[\"spec_class\"] == \"precision\", rng.uniform(0.05, 0.1, n), rng.uniform(0.2, 0.5, n)).round(3)\n", + " d[\"spec_rev\"] = rng.integers(1, 6, n)\n", + " d[\"pr_urgent\"] = (rng.random(n) < 0.2).astype(int)\n", + " d[\"spec_changed\"] = (rng.random(n) < 0.12).astype(int)\n", + " d[\"spec_mismatch\"] = (rng.random(n) < 0.08).astype(int)\n", + " d[\"pr_qty\"] = rng.integers(200, 5000, n)\n", + " d[\"lot_size\"] = (d[\"pr_qty\"] * rng.uniform(0.5, 1.0, n)).astype(int)\n", + " d[\"lead_days\"] = np.where(d[\"pr_urgent\"] == 1, rng.integers(20, 60, n), rng.integers(40, 150, n))\n", + " d[\"delay_days\"] = np.round(rng.gamma(1.2, 6, n) * rng.binomial(1, 0.45, n) - rng.integers(0, 5, n)).astype(int)\n", + " d[\"unit_cost\"] = rng.choice([2_000, 15_000, 40_000, 120_000], n, p=[0.35, 0.3, 0.25, 0.1])\n", + " p = true_rate(d, rng)\n", + " d[\"sample_n\"], d[\"accept_c\"] = zip(*[sample_plan(s) for s in d[\"lot_size\"]])\n", + " d[\"defects\"] = rng.binomial(d[\"sample_n\"], p)\n", + " share = np.where(d[\"spec_class\"] == \"precision\", 0.55, 0.3)\n", + " d[\"def_dim\"] = rng.binomial(d[\"defects\"], share)\n", + " d[\"def_spec\"] = rng.binomial(d[\"defects\"] - d[\"def_dim\"], 0.6)\n", + " d[\"def_visual\"] = d[\"defects\"] - d[\"def_dim\"] - d[\"def_spec\"]\n", + " d[\"dim_dev_mm\"] = (d[\"tolerance_mm\"] * (0.3 + 2.0 * p) * rng.lognormal(0, 0.3, n)).round(4)\n", + " d[\"confirmed_rate\"] = np.clip(p * rng.lognormal(0, 0.05, n), 0.001, 0.4).round(4) # 전수검사·하자 반영 확정 불량률\n", + " d[\"field_claims\"] = rng.poisson(d[\"lot_size\"] * p * 0.03)\n", + " d[\"inspector\"] = rng.choice([\"INS-1\", \"INS-2\", \"INS-3\", \"INS-4\"], n)\n", + " d[\"emergency_release\"] = ((d[\"pr_urgent\"] == 1) & (rng.random(n) < 0.3)).astype(int) # 긴급 불출(검사 전 선불출) 예외\n", + " d[\"bad_lot\"] = (d[\"confirmed_rate\"] > BAD_RATE).astype(int)\n", + " return d\n", + "# ---------------------------------------------------------------------------\n", + "\n", + "# ---- 파생 피처 공통 함수 (미션 4 이후 공통) ----\n", + "def add_history(d):\n", + " \"\"\"업체별 과거 확정 불량률(현재 로트 이전 로트만 사용: 정보 누수 방지)과 최근 5로트 추세.\"\"\"\n", + " d = d.sort_values(\"receipt_date\").reset_index(drop=True)\n", + " g = d.groupby(\"vendor\")[\"confirmed_rate\"]\n", + " d[\"vendor_hist_rate\"] = g.transform(lambda s: s.shift().expanding().mean()).fillna(d[\"confirmed_rate\"].mean())\n", + " d[\"vendor_recent_rate\"] = g.transform(lambda s: s.shift().rolling(5, min_periods=1).mean()).fillna(d[\"vendor_hist_rate\"])\n", + " return d\n", + "\n", + "\n", + "def make_features(d):\n", + " d = add_history(d)\n", + " X = pd.DataFrame(index=d.index)\n", + " X[\"obs_rate\"] = np.log((d[\"defects\"] + 0.5) / d[\"sample_n\"]) # 1 표본 불량률\n", + " X[\"log_lot\"] = np.log(d[\"lot_size\"]) # 2 로트 규모(로그)\n", + " X[\"delay_pos\"] = d[\"delay_days\"].clip(lower=0) # 3 납기 지연일\n", + " X[\"delay_ratio\"] = X[\"delay_pos\"] / d[\"lead_days\"] # 4 지연/계약기간 비\n", + " X[\"short_lead\"] = (d[\"lead_days\"] < 45).astype(int) # 5 촉박 납기\n", + " X[\"precision\"] = (d[\"spec_class\"] == \"precision\").astype(int) # 6 정밀 규격\n", + " X[\"prec_x_short\"] = X[\"precision\"] * X[\"short_lead\"] # 7 정밀 x 촉박(상호작용)\n", + " X[\"spec_risk\"] = d[\"spec_changed\"] + d[\"spec_mismatch\"] # 8 규격 변경·불일치 합\n", + " X[\"dim_ratio\"] = np.log(d[\"dim_dev_mm\"] / d[\"tolerance_mm\"]) # 9 치수 편차/공차\n", + " X[\"vendor_hist\"] = np.log(d[\"vendor_hist_rate\"]) # 10 업체 과거 불량률\n", + " X[\"vendor_trend\"] = d[\"vendor_recent_rate\"] - d[\"vendor_hist_rate\"] # 11 업체 최근 추세\n", + " X[\"urgent\"] = d[\"pr_urgent\"] # 12 긴급 구매요구\n", + " X[\"fill_ratio\"] = d[\"lot_size\"] / d[\"pr_qty\"] # 13 구매요구 대비 납품 비율\n", + " X[\"dim_share\"] = d[\"def_dim\"] / d[\"defects\"].clip(lower=1) # 14 치수 오차 비중\n", + " for f in FSC: # 15~ 품목군 원-핫\n", + " X[f\"fsc_{f}\"] = (d[\"fsc\"] == f).astype(int)\n", + " return d, X\n", + "\n", + "\n", + "def mape(y, p):\n", + " return float(np.mean(np.abs(y - p) / y))\n", + "\n", + "\n", + "d, X = make_features(make_lots(1200, seed=42))\n", + "y = np.log(d[\"confirmed_rate\"].values) # 불량률은 치우친 분포라 로그로 학습\n", + "print(f\"파생 피처 {X.shape[1]}개 생성 (목표 10개 이상: {'달성' if X.shape[1] >= 10 else '미달'})\")\n", + "print(\"피처:\", \", \".join(X.columns))\n", + "\n", + "# 1) 시계열 교차검증(과거로 학습 -> 이후 기간 검증)\n", + "model = make_pipeline(StandardScaler(), Ridge(alpha=1.0))\n", + "rows = []\n", + "for k, (tr, te) in enumerate(TimeSeriesSplit(n_splits=4).split(X), 1):\n", + " model.fit(X.iloc[tr], y[tr])\n", + " rows.append({\"폴드\": k, \"학습 로트\": len(tr), \"검증 로트\": len(te),\n", + " \"학습 MAPE\": round(mape(np.exp(y[tr]), np.exp(model.predict(X.iloc[tr]))), 3),\n", + " \"검증 MAPE\": round(mape(np.exp(y[te]), np.exp(model.predict(X.iloc[te]))), 3)})\n", + "cv = pd.DataFrame(rows)\n", + "print(\"\\n[시계열 교차검증]\")\n", + "print(cv.to_string(index=False))\n", + "m = cv[\"검증 MAPE\"].mean()\n", + "print(f\"기준 모델 평균 검증 MAPE {m:.1%} (목표 25% 이하: {'달성' if m <= 0.25 else '미달'}), 학습-검증 차이 {(cv['검증 MAPE'] - cv['학습 MAPE']).mean():+.1%}p\")\n", + "\n", + "# 2) 피처 중요도(순열 중요도)로 차원 축소 후보 확인\n", + "tr = d[\"month\"] < 18\n", + "model.fit(X[tr], y[tr])\n", + "imp = permutation_importance(model, X[~tr], y[~tr], n_repeats=5, random_state=0)\n", + "fi = pd.Series(imp.importances_mean, index=X.columns).sort_values(ascending=False)\n", + "print(\"\\n[순열 중요도 상위 8개]\")\n", + "print(fi.head(8).round(4).to_string())\n", + "low = list(fi[fi < 0.002].index)\n", + "Xr = X.drop(columns=low)\n", + "model.fit(Xr[tr], y[tr])\n", + "print(f\"중요도 0.002 미만 {len(low)}개 제거 후 검증 MAPE {mape(np.exp(y[~tr]), np.exp(model.predict(Xr[~tr]))):.1%} (피처 {Xr.shape[1]}개)\")\n", + "\n", + "# 3) 은닉 결함 패턴: 단일 요인으로는 약하지만 조합될 때 커지는 구간\n", + "piv = d.assign(규격=np.where(X[\"precision\"] == 1, \"정밀\", \"일반\"), 납기=np.where(X[\"short_lead\"] == 1, \"촉박(<45일)\", \"여유\")).pivot_table(\n", + " index=\"규격\", columns=\"납기\", values=\"confirmed_rate\", aggfunc=\"mean\")\n", + "print(\"\\n[은닉 패턴 1] 규격 x 납기 평균 확정 불량률\")\n", + "print((piv * 100).round(1).astype(str).add(\"%\").to_string())\n", + "lift = piv.loc[\"정밀\", \"촉박(<45일)\"] / piv.loc[\"일반\", \"여유\"]\n", + "print(f\" -> 정밀 규격을 촉박한 납기로 계약하면 불량률이 {lift:.1f}배: 구매요구 단계에서 납기 여유를 확보해야 한다.\")\n", + "drift = d[d[\"vendor\"] == \"V10\"].groupby(d[\"month\"] >= 18)[\"confirmed_rate\"].mean()\n", + "print(f\"[은닉 패턴 2] V10 업체 불량률 전반기 {drift[False]:.1%} -> 후반기 {drift[True]:.1%} (최근 추세 피처 vendor_trend로 포착)\")\n", + "no_int = make_pipeline(StandardScaler(), Ridge(1.0)).fit(X.drop(columns=[\"prec_x_short\", \"vendor_trend\"])[tr], y[tr])\n", + "print(f\"상호작용·추세 피처 제외 시 검증 MAPE {mape(np.exp(y[~tr]), np.exp(no_int.predict(X.drop(columns=['prec_x_short', 'vendor_trend'])[~tr]))):.1%} -> 포함 시 {mape(np.exp(y[~tr]), np.exp(make_pipeline(StandardScaler(), Ridge(1.0)).fit(X[tr], y[tr]).predict(X[~tr]))):.1%}\")\n", + "\n", + "fig, ax = plt.subplots(1, 2, figsize=(12, 4))\n", + "fi.head(10)[::-1].plot.barh(ax=ax[0], color=\"tab:blue\"); ax[0].set_title(\"Permutation importance (Ridge baseline)\")\n", + "p = np.exp(make_pipeline(StandardScaler(), Ridge(1.0)).fit(X[tr], y[tr]).predict(X[~tr]))\n", + "ax[1].scatter(np.exp(y[~tr]), p, s=8, alpha=0.6); ax[1].plot([0, 0.4], [0, 0.4], \"k--\")\n", + "ax[1].set_xlabel(\"confirmed defect rate\"); ax[1].set_ylabel(\"predicted\"); ax[1].set_title(\"Baseline prediction (hold-out months 18-23)\")\n", + "plt.tight_layout(); plt.show()\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 04 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + "파생 피처 20개 생성 (목표 10개 이상: 달성)\n", + "피처: obs_rate, log_lot, delay_pos, delay_ratio, short_lead, precision, prec_x_short, spec_risk, dim_ratio, vendor_hist, vendor_trend, urgent, fill_ratio, dim_share, fsc_5305, fsc_5330, fsc_5935, fsc_6135, fsc_2530, fsc_8415\n", + "\n", + "[시계열 교차검증]\n", + " 폴드 학습 로트 검증 로트 학습 MAPE 검증 MAPE\n", + " 1 240 240 0.211 0.204\n", + " 2 480 240 0.197 0.184\n", + " 3 720 240 0.191 0.167\n", + " 4 960 240 0.182 0.199\n", + "기준 모델 평균 검증 MAPE 18.9% (목표 25% 이하: 달성), 학습-검증 차이 -0.7%p\n", + "\n", + "[순열 중요도 상위 8개]\n", + "vendor_hist 0.3258\n", + "delay_pos 0.2496\n", + "spec_risk 0.1612\n", + "precision 0.1299\n", + "prec_x_short 0.0878\n", + "obs_rate 0.0634\n", + "urgent 0.0443\n", + "fsc_5305 0.0283\n", + "중요도 0.002 미만 7개 제거 후 검증 MAPE 20.1% (피처 13개)\n", + "\n", + "[은닉 패턴 1] 규격 x 납기 평균 확정 불량률\n", + "납기 여유 촉박(<45일)\n", + "규격 \n", + "일반 2.7% 3.5%\n", + "정밀 5.0% 15.0%\n", + " -> 정밀 규격을 촉박한 납기로 계약하면 불량률이 5.7배: 구매요구 단계에서 납기 여유를 확보해야 한다.\n", + "[은닉 패턴 2] V10 업체 불량률 전반기 6.3% -> 후반기 11.1% (최근 추세 피처 vendor_trend로 포착)\n", + "상호작용·추세 피처 제외 시 검증 MAPE 23.0% -> 포함 시 20.0%\n", + "```\n", + "
\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 05. 로트별 품질 위험 지수(Quality Risk Score) 산출 및 조기경보 엔진 개발\n", + "**소요시간**: 약 3.5시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "로트별 품질 위험 지수 산출 및 조기경보 엔진을 개발한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **앙상블 모델로 로트별 확정 불량률을 예측해 품질 위험 지수(0~100)와 경보 등급을 산출하는 조기경보 엔진을 개발한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **그래디언트 부스팅과 랜덤 포레스트를 묶은 앙상블을 앞 18개월로 학습하고 최근 6개월 hold-out에서 기준 모델과 MAE·RMSE·R2를 비교 수행하고 검증하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `src/models/qlt02_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": [ + "\"\"\"그래디언트 부스팅과 랜덤 포레스트 앙상블로 로트 확정 불량률을 예측해 기준 모델(Ridge) 대비 오차 개선과 R2를 검증하고, 예측 불량률을 '허용 불량률 초과 확률' 기반 품질 위험 지수(0~100)와 경보 등급으로 바꾸는 조기경보 엔진을 만든다.\"\"\"\n", + "import numpy as np\n", + "import pandas as pd\n", + "import matplotlib.pyplot as plt\n", + "from scipy import stats\n", + "from sklearn.linear_model import Ridge\n", + "from sklearn.pipeline import make_pipeline\n", + "from sklearn.preprocessing import StandardScaler\n", + "from sklearn.ensemble import GradientBoostingRegressor, RandomForestRegressor\n", + "from sklearn.metrics import mean_absolute_error, mean_squared_error, r2_score\n", + "\n", + "# ---------------------------------------------------------------------------\n", + "# [교육용가상자료] 군수품 수령검사 로트 데이터 생성기 (실제 업체·품목·계약 정보 없음)\n", + "# 구매요구(규격·긴급 여부) -> 계약(납기) -> 납품 -> 수령검사(표본) -> 확정 불량률(전수검사·사용부대 하자 반영)\n", + "# ---------------------------------------------------------------------------\n", + "FSC = {\"5305\": \"screw/bolt\", \"5330\": \"gasket\", \"5935\": \"connector\", \"6135\": \"battery\", \"2530\": \"brake part\", \"8415\": \"clothing\"}\n", + "VENDORS = [f\"V{i:02d}\" for i in range(1, 13)]\n", + "VENDOR_Q = dict(zip(VENDORS, [-0.6, -0.4, -0.3, -0.2, -0.1, 0.0, 0.1, 0.2, 0.3, 0.5, 0.8, 1.1])) # 업체 고유 품질 수준(숨은 값)\n", + "FSC_Q = {\"5305\": -0.3, \"5330\": 0.0, \"5935\": 0.25, \"6135\": 0.1, \"2530\": 0.35, \"8415\": -0.2}\n", + "SAMPLE_PLAN = [(500, 50, 3), (1200, 80, 5), (3200, 125, 7), (10 ** 9, 200, 10)] # (로트 크기 상한, 표본 n, 합격판정수 c) 단순화 기준\n", + "BAD_RATE = 0.05 # 확정 불량률 5% 초과 로트 = 불합격 대상\n", + "\n", + "\n", + "def sample_plan(lot_size):\n", + " for up, n, c in SAMPLE_PLAN:\n", + " if lot_size <= up:\n", + " return n, c\n", + "\n", + "\n", + "def true_rate(d, rng=None):\n", + " \"\"\"숨은 불량 발생 구조: 업체 수준 + 품목군 + 정밀 규격 + 긴급 구매 + 규격 변경·불일치 + 납기 지연 + (정밀 규격 x 촉박 납기) 상호작용.\"\"\"\n", + " short = (d[\"lead_days\"] < 45).astype(float)\n", + " z = (-4.35 + d[\"vendor\"].map(VENDOR_Q) + d[\"fsc\"].map(FSC_Q) + 0.55 * (d[\"spec_class\"] == \"precision\")\n", + " + 0.35 * d[\"pr_urgent\"] + 0.6 * d[\"spec_changed\"] + 0.8 * d[\"spec_mismatch\"]\n", + " + 0.9 * np.log1p(np.maximum(d[\"delay_days\"], 0) / 7) + 0.9 * short * (d[\"spec_class\"] == \"precision\")\n", + " + 0.5 * (d[\"month\"] >= 18) * (d[\"vendor\"] == \"V10\")) # V10 후반기 품질 저하(드리프트)\n", + " if rng is not None:\n", + " z = z + rng.normal(0, 0.12, len(d))\n", + " return np.clip(np.exp(z), 0.002, 0.35)\n", + "\n", + "\n", + "def make_lots(n=1200, seed=42, months=24):\n", + " rng = np.random.default_rng(seed)\n", + " d = pd.DataFrame({\"lot_id\": [f\"LOT{seed % 100:02d}-{i:05d}\" for i in range(n)]})\n", + " d[\"month\"] = rng.integers(0, months, n)\n", + " d[\"receipt_date\"] = pd.Timestamp(\"2024-01-01\") + pd.to_timedelta(d[\"month\"] * 30 + rng.integers(0, 30, n), unit=\"D\")\n", + " d = d.sort_values(\"receipt_date\").reset_index(drop=True)\n", + " d[\"month\"] = ((d[\"receipt_date\"] - pd.Timestamp(\"2024-01-01\")).dt.days // 30).clip(0, months - 1)\n", + " d[\"vendor\"] = rng.choice(VENDORS, n, p=np.r_[np.full(6, 0.1), np.full(6, 0.4 / 6)])\n", + " d[\"fsc\"] = rng.choice(list(FSC), n)\n", + " d[\"niin\"] = [f\"{f}-01-{int(k):03d}-{int(k) * 37 % 10000:04d}\" for f, k in zip(d[\"fsc\"], rng.integers(100, 110, n))]\n", + " d[\"spec_class\"] = np.where(rng.random(n) < np.where(d[\"fsc\"].isin([\"5935\", \"2530\"]), 0.6, 0.25), \"precision\", \"general\")\n", + " d[\"tolerance_mm\"] = np.where(d[\"spec_class\"] == \"precision\", rng.uniform(0.05, 0.1, n), rng.uniform(0.2, 0.5, n)).round(3)\n", + " d[\"spec_rev\"] = rng.integers(1, 6, n)\n", + " d[\"pr_urgent\"] = (rng.random(n) < 0.2).astype(int)\n", + " d[\"spec_changed\"] = (rng.random(n) < 0.12).astype(int)\n", + " d[\"spec_mismatch\"] = (rng.random(n) < 0.08).astype(int)\n", + " d[\"pr_qty\"] = rng.integers(200, 5000, n)\n", + " d[\"lot_size\"] = (d[\"pr_qty\"] * rng.uniform(0.5, 1.0, n)).astype(int)\n", + " d[\"lead_days\"] = np.where(d[\"pr_urgent\"] == 1, rng.integers(20, 60, n), rng.integers(40, 150, n))\n", + " d[\"delay_days\"] = np.round(rng.gamma(1.2, 6, n) * rng.binomial(1, 0.45, n) - rng.integers(0, 5, n)).astype(int)\n", + " d[\"unit_cost\"] = rng.choice([2_000, 15_000, 40_000, 120_000], n, p=[0.35, 0.3, 0.25, 0.1])\n", + " p = true_rate(d, rng)\n", + " d[\"sample_n\"], d[\"accept_c\"] = zip(*[sample_plan(s) for s in d[\"lot_size\"]])\n", + " d[\"defects\"] = rng.binomial(d[\"sample_n\"], p)\n", + " share = np.where(d[\"spec_class\"] == \"precision\", 0.55, 0.3)\n", + " d[\"def_dim\"] = rng.binomial(d[\"defects\"], share)\n", + " d[\"def_spec\"] = rng.binomial(d[\"defects\"] - d[\"def_dim\"], 0.6)\n", + " d[\"def_visual\"] = d[\"defects\"] - d[\"def_dim\"] - d[\"def_spec\"]\n", + " d[\"dim_dev_mm\"] = (d[\"tolerance_mm\"] * (0.3 + 2.0 * p) * rng.lognormal(0, 0.3, n)).round(4)\n", + " d[\"confirmed_rate\"] = np.clip(p * rng.lognormal(0, 0.05, n), 0.001, 0.4).round(4) # 전수검사·하자 반영 확정 불량률\n", + " d[\"field_claims\"] = rng.poisson(d[\"lot_size\"] * p * 0.03)\n", + " d[\"inspector\"] = rng.choice([\"INS-1\", \"INS-2\", \"INS-3\", \"INS-4\"], n)\n", + " d[\"emergency_release\"] = ((d[\"pr_urgent\"] == 1) & (rng.random(n) < 0.3)).astype(int) # 긴급 불출(검사 전 선불출) 예외\n", + " d[\"bad_lot\"] = (d[\"confirmed_rate\"] > BAD_RATE).astype(int)\n", + " return d\n", + "# ---------------------------------------------------------------------------\n", + "\n", + "# ---- 파생 피처 공통 함수 (미션 4 이후 공통) ----\n", + "def add_history(d):\n", + " \"\"\"업체별 과거 확정 불량률(현재 로트 이전 로트만 사용: 정보 누수 방지)과 최근 5로트 추세.\"\"\"\n", + " d = d.sort_values(\"receipt_date\").reset_index(drop=True)\n", + " g = d.groupby(\"vendor\")[\"confirmed_rate\"]\n", + " d[\"vendor_hist_rate\"] = g.transform(lambda s: s.shift().expanding().mean()).fillna(d[\"confirmed_rate\"].mean())\n", + " d[\"vendor_recent_rate\"] = g.transform(lambda s: s.shift().rolling(5, min_periods=1).mean()).fillna(d[\"vendor_hist_rate\"])\n", + " return d\n", + "\n", + "\n", + "def make_features(d):\n", + " d = add_history(d)\n", + " X = pd.DataFrame(index=d.index)\n", + " X[\"obs_rate\"] = np.log((d[\"defects\"] + 0.5) / d[\"sample_n\"]) # 1 표본 불량률\n", + " X[\"log_lot\"] = np.log(d[\"lot_size\"]) # 2 로트 규모(로그)\n", + " X[\"delay_pos\"] = d[\"delay_days\"].clip(lower=0) # 3 납기 지연일\n", + " X[\"delay_ratio\"] = X[\"delay_pos\"] / d[\"lead_days\"] # 4 지연/계약기간 비\n", + " X[\"short_lead\"] = (d[\"lead_days\"] < 45).astype(int) # 5 촉박 납기\n", + " X[\"precision\"] = (d[\"spec_class\"] == \"precision\").astype(int) # 6 정밀 규격\n", + " X[\"prec_x_short\"] = X[\"precision\"] * X[\"short_lead\"] # 7 정밀 x 촉박(상호작용)\n", + " X[\"spec_risk\"] = d[\"spec_changed\"] + d[\"spec_mismatch\"] # 8 규격 변경·불일치 합\n", + " X[\"dim_ratio\"] = np.log(d[\"dim_dev_mm\"] / d[\"tolerance_mm\"]) # 9 치수 편차/공차\n", + " X[\"vendor_hist\"] = np.log(d[\"vendor_hist_rate\"]) # 10 업체 과거 불량률\n", + " X[\"vendor_trend\"] = d[\"vendor_recent_rate\"] - d[\"vendor_hist_rate\"] # 11 업체 최근 추세\n", + " X[\"urgent\"] = d[\"pr_urgent\"] # 12 긴급 구매요구\n", + " X[\"fill_ratio\"] = d[\"lot_size\"] / d[\"pr_qty\"] # 13 구매요구 대비 납품 비율\n", + " X[\"dim_share\"] = d[\"def_dim\"] / d[\"defects\"].clip(lower=1) # 14 치수 오차 비중\n", + " for f in FSC: # 15~ 품목군 원-핫\n", + " X[f\"fsc_{f}\"] = (d[\"fsc\"] == f).astype(int)\n", + " return d, X\n", + "\n", + "\n", + "class RiskEngine:\n", + " \"\"\"앙상블(GBM 0.7 + RF 0.3, 로그 불량률) -> 위험 지수 = P(확정 불량률 > 5%) x 100\"\"\"\n", + "\n", + " def __init__(self, seed=0):\n", + " self.gbm = GradientBoostingRegressor(n_estimators=600, max_depth=2, learning_rate=0.05, subsample=0.8, random_state=seed)\n", + " self.rf = RandomForestRegressor(n_estimators=300, min_samples_leaf=2, random_state=seed)\n", + "\n", + " def fit(self, X, rate):\n", + " y = np.log(rate)\n", + " self.gbm.fit(X, y); self.rf.fit(X, y)\n", + " self.sigma = float(np.std(y - self._log_pred(X)) * 1.5) # 학습 잔차는 작게 나오므로 1.5배로 보수적 설정\n", + " return self\n", + "\n", + " def _log_pred(self, X):\n", + " return 0.7 * self.gbm.predict(X) + 0.3 * self.rf.predict(X)\n", + "\n", + " def predict_rate(self, X):\n", + " return np.exp(self._log_pred(X))\n", + "\n", + " def risk(self, X):\n", + " z = (np.log(BAD_RATE) - self._log_pred(X)) / self.sigma\n", + " return np.round(100 * (1 - stats.norm.cdf(z)), 1)\n", + "\n", + "\n", + "def grade(score):\n", + " return np.where(score >= 70, \"경보\", np.where(score >= 40, \"주의\", \"정상\"))\n", + "\n", + "\n", + "d, X = make_features(make_lots(1200, seed=42))\n", + "tr, te = (d[\"month\"] < 18).values, (d[\"month\"] >= 18).values # 앞 18개월 학습, 뒤 6개월 hold-out\n", + "y = d[\"confirmed_rate\"].values\n", + "\n", + "base = make_pipeline(StandardScaler(), Ridge(1.0)).fit(X[tr], np.log(y[tr]))\n", + "pb = np.exp(base.predict(X[te]))\n", + "eng = RiskEngine().fit(X[tr], y[tr])\n", + "pe = eng.predict_rate(X[te])\n", + "\n", + "res = pd.DataFrame([{\"모델\": name, \"MAE(%p)\": round(mean_absolute_error(y[te], p) * 100, 3), \"RMSE(%p)\": round(np.sqrt(mean_squared_error(y[te], p)) * 100, 3),\n", + " \"R2\": round(r2_score(y[te], p), 3), \"MAPE\": round(float(np.mean(np.abs(y[te] - p) / y[te])), 3)}\n", + " for name, p in [(\"업체 평균(단순)\", d[\"vendor_hist_rate\"].values[te]), (\"기준 모델(Ridge)\", pb), (\"앙상블 엔진\", pe)]])\n", + "print(\"[Hold-out(최근 6개월) 성능 비교]\")\n", + "print(res.to_string(index=False))\n", + "mae_imp = 1 - res.loc[2, \"MAE(%p)\"] / res.loc[1, \"MAE(%p)\"]\n", + "rmse_imp = 1 - res.loc[2, \"RMSE(%p)\"] / res.loc[1, \"RMSE(%p)\"]\n", + "print(f\"기준 모델 대비 MAE {mae_imp:.1%}, RMSE {rmse_imp:.1%} 개선 (목표 20% 이상: {'달성' if max(mae_imp, rmse_imp) >= 0.2 else '미달'}), \"\n", + " f\"R2 {res.loc[2, 'R2']:.3f} (목표 0.85 이상: {'달성' if res.loc[2, 'R2'] >= 0.85 else '미달'})\")\n", + "\n", + "# 조기경보: 위험 지수와 등급\n", + "h = d[te].copy()\n", + "h[\"risk\"] = eng.risk(X[te]); h[\"grade\"] = grade(h[\"risk\"])\n", + "h[\"base_accept\"] = h[\"defects\"] <= h[\"accept_c\"]\n", + "print(\"\\n[위험 등급별 실제 불량 로트 비율]\")\n", + "print(h.groupby(\"grade\").agg(로트수=(\"lot_id\", \"size\"), 실제불량=(\"bad_lot\", \"sum\"), 불량비율=(\"bad_lot\", \"mean\")).reindex([\"경보\", \"주의\", \"정상\"]).round(3).to_string())\n", + "flag = h[\"grade\"] != \"정상\"\n", + "rec = (flag & (h[\"bad_lot\"] == 1)).sum() / h[\"bad_lot\"].sum()\n", + "missed_by_sampling = h[(h[\"base_accept\"]) & (h[\"bad_lot\"] == 1)]\n", + "print(f\"\\n경보+주의 로트가 실제 불량 로트를 잡아낸 비율 {rec:.1%}\")\n", + "print(f\"샘플링 판정에서 오합격된 불량 로트 {len(missed_by_sampling)}건 중 조기경보가 잡은 건 {(missed_by_sampling['grade'] != '정상').sum()}건 -> 정밀 재검사 대상으로 넘긴다\")\n", + "\n", + "# 안정성: 같은 입력 반복 산출 일관성, 앙상블 두 모델 간 편차\n", + "again = RiskEngine().fit(X[tr], y[tr]).risk(X[te])\n", + "print(f\"위험 지수 재산출 일관성 {np.mean(again == h['risk'].values):.0%}, GBM-RF 예측 불일치(로그 표준편차) {np.std(eng.gbm.predict(X[te]) - eng.rf.predict(X[te])):.3f}\")\n", + "print(\"\\n[위험 지수 상위 5개 로트]\")\n", + "print(h.sort_values(\"risk\", ascending=False).head(5)[[\"lot_id\", \"vendor\", \"fsc\", \"spec_class\", \"delay_days\", \"risk\", \"grade\", \"confirmed_rate\"]].to_string(index=False))\n", + "\n", + "fig, ax = plt.subplots(1, 2, figsize=(12, 4))\n", + "ax[0].scatter(y[te], pb, s=8, alpha=0.5, label=\"Ridge baseline\"); ax[0].scatter(y[te], pe, s=8, alpha=0.6, label=\"ensemble\")\n", + "ax[0].plot([0, 0.4], [0, 0.4], \"k--\"); ax[0].set_xlabel(\"confirmed rate\"); ax[0].set_ylabel(\"predicted\"); ax[0].legend(); ax[0].set_title(\"Hold-out prediction\")\n", + "for g, c in [(0, \"tab:blue\"), (1, \"tab:red\")]:\n", + " ax[1].hist(h[h[\"bad_lot\"] == g][\"risk\"], bins=20, alpha=0.6, color=c, label=[\"good lot\", \"bad lot\"][g])\n", + "ax[1].axvline(40, ls=\"--\", c=\"orange\"); ax[1].axvline(70, ls=\"--\", c=\"red\"); ax[1].legend(); ax[1].set_title(\"Quality risk score (0-100)\")\n", + "plt.tight_layout(); plt.show()\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 05 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + "[Hold-out(최근 6개월) 성능 비교]\n", + " 모델 MAE(%p) RMSE(%p) R2 MAPE\n", + " 업체 평균(단순) 2.734 4.835 0.182 0.881\n", + "기준 모델(Ridge) 0.988 2.367 0.804 0.200\n", + " 앙상블 엔진 0.756 1.296 0.941 0.188\n", + "기준 모델 대비 MAE 23.5%, RMSE 45.2% 개선 (목표 20% 이상: 달성), R2 0.941 (목표 0.85 이상: 달성)\n", + "\n", + "[위험 등급별 실제 불량 로트 비율]\n", + " 로트수 실제불량 불량비율\n", + "grade \n", + "경보 61 58 0.951\n", + "주의 16 9 0.562\n", + "정상 227 5 0.022\n", + "\n", + "경보+주의 로트가 실제 불량 로트를 잡아낸 비율 93.1%\n", + "샘플링 판정에서 오합격된 불량 로트 15건 중 조기경보가 잡은 건 10건 -> 정밀 재검사 대상으로 넘긴다\n", + "위험 지수 재산출 일관성 100%, GBM-RF 예측 불일치(로그 표준편차) 0.177\n", + "\n", + "[위험 지수 상위 5개 로트]\n", + " lot_id vendor fsc spec_class delay_days risk grade confirmed_rate\n", + "LOT42-00958 V09 5935 precision -4 100.0 경보 0.1037\n", + "LOT42-00469 V10 5935 precision -3 100.0 경보 0.3054\n", + "LOT42-01133 V11 2530 precision 6 100.0 경보 0.3937\n", + "LOT42-00827 V12 2530 precision -1 100.0 경보 0.1025\n", + "LOT42-00303 V11 2530 precision -2 100.0 경보 0.1144\n", + "```\n", + "
\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 06. 수입 검사 불량 추세 및 고위험 납품업체 모니터링 품질 대시보드 구현\n", + "**소요시간**: 약 3.0시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "수입 검사 불량 추세 및 고위험 납품업체 모니터링 대시보드를 구현한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **수입 검사 불량 추세와 고위험 납품업체를 모니터링하는 품질 대시보드를 만들고, 경보 로트마다 기여도 상위 3개 인자를 설명한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **월별 표본 불량률과 관리한계, 업체별 평균 위험 지수와 추세(악화·유지), 위험 지수 분포, 대표 로트의 기여도 워터폴을 2x2 대시보드로 그린다 과정을 진행하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `src/explainability/qlt02_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": [ + "\"\"\"수령검사 불량 추세와 고위험 납품업체를 모니터링하는 품질 대시보드를 만들고, 경보 로트마다 7개 요인군의 정확한 섀플리 기여도를 계산해 기여도 상위 3개 인자와 자연어 설명·조치 제안을 자동 생성한다.\"\"\"\n", + "import itertools\n", + "import math\n", + "import numpy as np\n", + "import pandas as pd\n", + "import matplotlib.pyplot as plt\n", + "from scipy import stats\n", + "from sklearn.ensemble import GradientBoostingRegressor\n", + "\n", + "# ---------------------------------------------------------------------------\n", + "# [교육용가상자료] 군수품 수령검사 로트 데이터 생성기 (실제 업체·품목·계약 정보 없음)\n", + "# 구매요구(규격·긴급 여부) -> 계약(납기) -> 납품 -> 수령검사(표본) -> 확정 불량률(전수검사·사용부대 하자 반영)\n", + "# ---------------------------------------------------------------------------\n", + "FSC = {\"5305\": \"screw/bolt\", \"5330\": \"gasket\", \"5935\": \"connector\", \"6135\": \"battery\", \"2530\": \"brake part\", \"8415\": \"clothing\"}\n", + "VENDORS = [f\"V{i:02d}\" for i in range(1, 13)]\n", + "VENDOR_Q = dict(zip(VENDORS, [-0.6, -0.4, -0.3, -0.2, -0.1, 0.0, 0.1, 0.2, 0.3, 0.5, 0.8, 1.1])) # 업체 고유 품질 수준(숨은 값)\n", + "FSC_Q = {\"5305\": -0.3, \"5330\": 0.0, \"5935\": 0.25, \"6135\": 0.1, \"2530\": 0.35, \"8415\": -0.2}\n", + "SAMPLE_PLAN = [(500, 50, 3), (1200, 80, 5), (3200, 125, 7), (10 ** 9, 200, 10)] # (로트 크기 상한, 표본 n, 합격판정수 c) 단순화 기준\n", + "BAD_RATE = 0.05 # 확정 불량률 5% 초과 로트 = 불합격 대상\n", + "\n", + "\n", + "def sample_plan(lot_size):\n", + " for up, n, c in SAMPLE_PLAN:\n", + " if lot_size <= up:\n", + " return n, c\n", + "\n", + "\n", + "def true_rate(d, rng=None):\n", + " \"\"\"숨은 불량 발생 구조: 업체 수준 + 품목군 + 정밀 규격 + 긴급 구매 + 규격 변경·불일치 + 납기 지연 + (정밀 규격 x 촉박 납기) 상호작용.\"\"\"\n", + " short = (d[\"lead_days\"] < 45).astype(float)\n", + " z = (-4.35 + d[\"vendor\"].map(VENDOR_Q) + d[\"fsc\"].map(FSC_Q) + 0.55 * (d[\"spec_class\"] == \"precision\")\n", + " + 0.35 * d[\"pr_urgent\"] + 0.6 * d[\"spec_changed\"] + 0.8 * d[\"spec_mismatch\"]\n", + " + 0.9 * np.log1p(np.maximum(d[\"delay_days\"], 0) / 7) + 0.9 * short * (d[\"spec_class\"] == \"precision\")\n", + " + 0.5 * (d[\"month\"] >= 18) * (d[\"vendor\"] == \"V10\")) # V10 후반기 품질 저하(드리프트)\n", + " if rng is not None:\n", + " z = z + rng.normal(0, 0.12, len(d))\n", + " return np.clip(np.exp(z), 0.002, 0.35)\n", + "\n", + "\n", + "def make_lots(n=1200, seed=42, months=24):\n", + " rng = np.random.default_rng(seed)\n", + " d = pd.DataFrame({\"lot_id\": [f\"LOT{seed % 100:02d}-{i:05d}\" for i in range(n)]})\n", + " d[\"month\"] = rng.integers(0, months, n)\n", + " d[\"receipt_date\"] = pd.Timestamp(\"2024-01-01\") + pd.to_timedelta(d[\"month\"] * 30 + rng.integers(0, 30, n), unit=\"D\")\n", + " d = d.sort_values(\"receipt_date\").reset_index(drop=True)\n", + " d[\"month\"] = ((d[\"receipt_date\"] - pd.Timestamp(\"2024-01-01\")).dt.days // 30).clip(0, months - 1)\n", + " d[\"vendor\"] = rng.choice(VENDORS, n, p=np.r_[np.full(6, 0.1), np.full(6, 0.4 / 6)])\n", + " d[\"fsc\"] = rng.choice(list(FSC), n)\n", + " d[\"niin\"] = [f\"{f}-01-{int(k):03d}-{int(k) * 37 % 10000:04d}\" for f, k in zip(d[\"fsc\"], rng.integers(100, 110, n))]\n", + " d[\"spec_class\"] = np.where(rng.random(n) < np.where(d[\"fsc\"].isin([\"5935\", \"2530\"]), 0.6, 0.25), \"precision\", \"general\")\n", + " d[\"tolerance_mm\"] = np.where(d[\"spec_class\"] == \"precision\", rng.uniform(0.05, 0.1, n), rng.uniform(0.2, 0.5, n)).round(3)\n", + " d[\"spec_rev\"] = rng.integers(1, 6, n)\n", + " d[\"pr_urgent\"] = (rng.random(n) < 0.2).astype(int)\n", + " d[\"spec_changed\"] = (rng.random(n) < 0.12).astype(int)\n", + " d[\"spec_mismatch\"] = (rng.random(n) < 0.08).astype(int)\n", + " d[\"pr_qty\"] = rng.integers(200, 5000, n)\n", + " d[\"lot_size\"] = (d[\"pr_qty\"] * rng.uniform(0.5, 1.0, n)).astype(int)\n", + " d[\"lead_days\"] = np.where(d[\"pr_urgent\"] == 1, rng.integers(20, 60, n), rng.integers(40, 150, n))\n", + " d[\"delay_days\"] = np.round(rng.gamma(1.2, 6, n) * rng.binomial(1, 0.45, n) - rng.integers(0, 5, n)).astype(int)\n", + " d[\"unit_cost\"] = rng.choice([2_000, 15_000, 40_000, 120_000], n, p=[0.35, 0.3, 0.25, 0.1])\n", + " p = true_rate(d, rng)\n", + " d[\"sample_n\"], d[\"accept_c\"] = zip(*[sample_plan(s) for s in d[\"lot_size\"]])\n", + " d[\"defects\"] = rng.binomial(d[\"sample_n\"], p)\n", + " share = np.where(d[\"spec_class\"] == \"precision\", 0.55, 0.3)\n", + " d[\"def_dim\"] = rng.binomial(d[\"defects\"], share)\n", + " d[\"def_spec\"] = rng.binomial(d[\"defects\"] - d[\"def_dim\"], 0.6)\n", + " d[\"def_visual\"] = d[\"defects\"] - d[\"def_dim\"] - d[\"def_spec\"]\n", + " d[\"dim_dev_mm\"] = (d[\"tolerance_mm\"] * (0.3 + 2.0 * p) * rng.lognormal(0, 0.3, n)).round(4)\n", + " d[\"confirmed_rate\"] = np.clip(p * rng.lognormal(0, 0.05, n), 0.001, 0.4).round(4) # 전수검사·하자 반영 확정 불량률\n", + " d[\"field_claims\"] = rng.poisson(d[\"lot_size\"] * p * 0.03)\n", + " d[\"inspector\"] = rng.choice([\"INS-1\", \"INS-2\", \"INS-3\", \"INS-4\"], n)\n", + " d[\"emergency_release\"] = ((d[\"pr_urgent\"] == 1) & (rng.random(n) < 0.3)).astype(int) # 긴급 불출(검사 전 선불출) 예외\n", + " d[\"bad_lot\"] = (d[\"confirmed_rate\"] > BAD_RATE).astype(int)\n", + " return d\n", + "# ---------------------------------------------------------------------------\n", + "\n", + "# ---- 파생 피처 공통 함수 (미션 4 이후 공통) ----\n", + "def add_history(d):\n", + " \"\"\"업체별 과거 확정 불량률(현재 로트 이전 로트만 사용: 정보 누수 방지)과 최근 5로트 추세.\"\"\"\n", + " d = d.sort_values(\"receipt_date\").reset_index(drop=True)\n", + " g = d.groupby(\"vendor\")[\"confirmed_rate\"]\n", + " d[\"vendor_hist_rate\"] = g.transform(lambda s: s.shift().expanding().mean()).fillna(d[\"confirmed_rate\"].mean())\n", + " d[\"vendor_recent_rate\"] = g.transform(lambda s: s.shift().rolling(5, min_periods=1).mean()).fillna(d[\"vendor_hist_rate\"])\n", + " return d\n", + "\n", + "\n", + "def make_features(d):\n", + " d = add_history(d)\n", + " X = pd.DataFrame(index=d.index)\n", + " X[\"obs_rate\"] = np.log((d[\"defects\"] + 0.5) / d[\"sample_n\"]) # 1 표본 불량률\n", + " X[\"log_lot\"] = np.log(d[\"lot_size\"]) # 2 로트 규모(로그)\n", + " X[\"delay_pos\"] = d[\"delay_days\"].clip(lower=0) # 3 납기 지연일\n", + " X[\"delay_ratio\"] = X[\"delay_pos\"] / d[\"lead_days\"] # 4 지연/계약기간 비\n", + " X[\"short_lead\"] = (d[\"lead_days\"] < 45).astype(int) # 5 촉박 납기\n", + " X[\"precision\"] = (d[\"spec_class\"] == \"precision\").astype(int) # 6 정밀 규격\n", + " X[\"prec_x_short\"] = X[\"precision\"] * X[\"short_lead\"] # 7 정밀 x 촉박(상호작용)\n", + " X[\"spec_risk\"] = d[\"spec_changed\"] + d[\"spec_mismatch\"] # 8 규격 변경·불일치 합\n", + " X[\"dim_ratio\"] = np.log(d[\"dim_dev_mm\"] / d[\"tolerance_mm\"]) # 9 치수 편차/공차\n", + " X[\"vendor_hist\"] = np.log(d[\"vendor_hist_rate\"]) # 10 업체 과거 불량률\n", + " X[\"vendor_trend\"] = d[\"vendor_recent_rate\"] - d[\"vendor_hist_rate\"] # 11 업체 최근 추세\n", + " X[\"urgent\"] = d[\"pr_urgent\"] # 12 긴급 구매요구\n", + " X[\"fill_ratio\"] = d[\"lot_size\"] / d[\"pr_qty\"] # 13 구매요구 대비 납품 비율\n", + " X[\"dim_share\"] = d[\"def_dim\"] / d[\"defects\"].clip(lower=1) # 14 치수 오차 비중\n", + " for f in FSC: # 15~ 품목군 원-핫\n", + " X[f\"fsc_{f}\"] = (d[\"fsc\"] == f).astype(int)\n", + " return d, X\n", + "\n", + "GROUPS = {\"업체 이력\": [\"vendor_hist\", \"vendor_trend\"], \"규격 변경·불일치\": [\"spec_risk\"], \"정밀 규격\": [\"precision\"],\n", + " \"납기 지연·촉박\": [\"delay_pos\", \"delay_ratio\", \"short_lead\", \"prec_x_short\"],\n", + " \"표본검사 결과\": [\"obs_rate\", \"dim_ratio\", \"dim_share\"], \"구매요구 조건\": [\"urgent\", \"fill_ratio\", \"log_lot\"],\n", + " \"품목군\": [f\"fsc_{f}\" for f in FSC]}\n", + "ACTION = {\"업체 이력\": \"업체 품질 이력 재점검·시정조치 요구\", \"규격 변경·불일치\": \"구매요구 규격서와 납품 규격 대조\",\n", + " \"정밀 규격\": \"정밀 측정 장비로 치수 재측정\", \"납기 지연·촉박\": \"납기 여유 확보·지연 사유 확인\",\n", + " \"표본검사 결과\": \"표본 크기 확대 재검사\", \"구매요구 조건\": \"긴급 구매 건 사후 검사 강화\", \"품목군\": \"품목군별 검사 기준 확인\"}\n", + "\n", + "d, X = make_features(make_lots(1200, seed=42))\n", + "tr, te = (d[\"month\"] < 18).values, (d[\"month\"] >= 18).values\n", + "y = np.log(d[\"confirmed_rate\"].values)\n", + "gbm = GradientBoostingRegressor(n_estimators=300, max_depth=2, learning_rate=0.08, subsample=0.8, random_state=0).fit(X.values[tr], y[tr])\n", + "sigma = float(np.std(y[tr] - gbm.predict(X.values[tr])) * 1.5)\n", + "h = d[te].copy()\n", + "h[\"pred\"] = np.exp(gbm.predict(X.values[te]))\n", + "h[\"risk\"] = np.round(100 * (1 - stats.norm.cdf((np.log(BAD_RATE) - np.log(h[\"pred\"])) / sigma)), 1)\n", + "h[\"grade\"] = np.where(h[\"risk\"] >= 70, \"경보\", np.where(h[\"risk\"] >= 40, \"주의\", \"정상\"))\n", + "\n", + "# ---------------- 1) 대시보드 요약 표 ----------------\n", + "print(\"[품질 대시보드] 최근 6개월 수령검사 현황 [교육용가상자료]\")\n", + "m = d.groupby(\"month\").agg(sample_rate=(\"defects\", \"sum\"), n=(\"sample_n\", \"sum\"), conf=(\"confirmed_rate\", \"mean\"))\n", + "m[\"sample_rate\"] = m[\"sample_rate\"] / m[\"n\"]\n", + "p0 = m.loc[:17, \"sample_rate\"].mean()\n", + "ucl = p0 + 3 * np.sqrt(p0 * (1 - p0) / m[\"n\"])\n", + "print(f\"월별 표본 불량률 관리한계 초과 월: {list(m.index[m['sample_rate'] > ucl])} (기준 {p0:.2%})\")\n", + "vend = h.groupby(\"vendor\").agg(로트=(\"lot_id\", \"size\"), 평균위험=(\"risk\", \"mean\"), 경보=(\"grade\", lambda s: (s == \"경보\").sum()),\n", + " 실제불량률=(\"bad_lot\", \"mean\"), 최근추세=(\"vendor_recent_rate\", \"mean\"), 과거=(\"vendor_hist_rate\", \"mean\"))\n", + "vend[\"추세\"] = np.where(vend[\"최근추세\"] > vend[\"과거\"] * 1.2, \"악화\", \"유지\")\n", + "vend[\"상태\"] = np.where(vend[\"평균위험\"] >= 50, \"고위험\", np.where(vend[\"평균위험\"] >= 25, \"관찰\", \"정상\"))\n", + "print(vend.sort_values(\"평균위험\", ascending=False).head(6)[[\"로트\", \"평균위험\", \"경보\", \"실제불량률\", \"추세\", \"상태\"]].round(2).to_string())\n", + "\n", + "# ---------------- 2) 정확한 섀플리 기여도(요인군 7개, 2^7 연합) ----------------\n", + "cols = {g: [X.columns.get_loc(c) for c in cs] for g, cs in GROUPS.items()}\n", + "names = list(GROUPS)\n", + "bg = X[tr].sample(40, random_state=1).values.astype(float) # 배경 데이터(학습 구간 표본)\n", + "base_val = gbm.predict(bg).mean()\n", + "coal = list(itertools.product([0, 1], repeat=len(names)))\n", + "\n", + "\n", + "def shapley(x):\n", + " \"\"\"연합 S마다 S에 속한 요인군은 x 값, 나머지는 배경 값으로 바꿔 평균 예측 v(S)를 구하고 섀플리 공식으로 합산.\"\"\"\n", + " batch = np.repeat(bg[None], len(coal), axis=0)\n", + " for k, s in enumerate(coal):\n", + " for g, on in zip(names, s):\n", + " if on:\n", + " batch[k][:, cols[g]] = x[cols[g]]\n", + " v = dict(zip(coal, gbm.predict(batch.reshape(-1, bg.shape[1])).reshape(len(coal), -1).mean(1)))\n", + " n, phi = len(names), np.zeros(len(names))\n", + " for s in coal:\n", + " for i in range(n):\n", + " if not s[i]:\n", + " w = math.factorial(sum(s)) * math.factorial(n - sum(s) - 1) / math.factorial(n)\n", + " phi[i] += w * (v[s[:i] + (1,) + s[i + 1:]] - v[s])\n", + " return phi, v[tuple([1] * n)]\n", + "\n", + "\n", + "alert = h[h[\"grade\"] == \"경보\"]\n", + "rows = []\n", + "for idx in alert.index:\n", + " phi, full = shapley(X.loc[idx].values)\n", + " top = np.argsort(-phi)[:3]\n", + " rows.append({\"lot_id\": h.loc[idx, \"lot_id\"], \"risk\": h.loc[idx, \"risk\"], **{names[i]: phi[i] for i in range(len(names))},\n", + " \"top3\": [names[i] for i in top], \"top3_phi\": [phi[i] for i in top], \"add_err\": abs(base_val + phi.sum() - full)})\n", + "ex = pd.DataFrame(rows)\n", + "done = (ex[\"top3\"].map(len) == 3).mean()\n", + "print(f\"\\n경보 로트 {len(ex)}건 설명 생성: 상위 3개 인자 자동 산출률 {done:.0%} (목표 100%: {'달성' if done == 1 else '미달'}), \"\n", + " f\"가법성 오차 최대 {ex['add_err'].max():.1e}\")\n", + "print(\"[요인군별 평균 기여도(로그 불량률, 경보 로트)]\")\n", + "print(ex[names].mean().sort_values(ascending=False).round(3).to_string())\n", + "\n", + "print(\"\\n[자동 설명 예시]\")\n", + "for r in ex.sort_values(\"risk\", ascending=False).head(3).itertuples():\n", + " parts = \", \".join(f\"{g}(x{np.exp(p):.2f})\" for g, p in zip(r.top3, r.top3_phi))\n", + " print(f\" {r.lot_id} 위험 {r.risk:.0f}: {parts} 순으로 불량률 예측을 높였다 -> 조치: {ACTION[r.top3[0]]}\")\n", + "\n", + "# 설명 타당성 점검: 규격 불일치 로트에서 '규격' 요인이 양(+)으로 나오는가\n", + "mm = alert[\"spec_mismatch\"].values == 1\n", + "if mm.any():\n", + " print(f\"타당성 점검: 규격 불일치 경보 로트 {mm.sum()}건 중 '규격 변경·불일치' 기여 양수 {np.mean(ex.loc[mm, '규격 변경·불일치'] > 0):.0%}\")\n", + "\n", + "fig, ax = plt.subplots(2, 2, figsize=(13, 8))\n", + "ax[0, 0].plot(m.index, m[\"sample_rate\"], \"o-\", label=\"sample defect rate\"); ax[0, 0].plot(m.index, ucl, \"r--\", label=\"UCL (3 sigma)\")\n", + "ax[0, 0].axvspan(17.5, 23.5, color=\"gray\", alpha=0.1); ax[0, 0].legend(); ax[0, 0].set_title(\"Monthly inspection defect trend\"); ax[0, 0].set_xlabel(\"month\")\n", + "vs = vend.sort_values(\"평균위험\")\n", + "ax[0, 1].barh(vs.index, vs[\"평균위험\"], color=[\"tab:red\" if s == \"고위험\" else \"tab:orange\" if s == \"관찰\" else \"tab:green\" for s in vs[\"상태\"]])\n", + "ax[0, 1].set_title(\"Vendor mean risk score (last 6 months)\")\n", + "ax[1, 0].hist(h[\"risk\"], bins=25, color=\"tab:blue\"); ax[1, 0].axvline(40, c=\"orange\", ls=\"--\"); ax[1, 0].axvline(70, c=\"red\", ls=\"--\")\n", + "ax[1, 0].set_title(\"Lot risk score distribution\")\n", + "r0 = ex.sort_values(\"risk\", ascending=False).iloc[0]\n", + "vals = [r0[g] for g in names]; order = np.argsort(vals)[::-1]\n", + "cum = base_val\n", + "eng = [\"vendor history\", \"spec change\", \"precision spec\", \"delivery delay\", \"sample result\", \"PR condition\", \"item group\"]\n", + "for k, i in enumerate(order):\n", + " ax[1, 1].barh(k, vals[i], left=cum, color=\"tab:red\" if vals[i] > 0 else \"tab:blue\"); cum += vals[i]\n", + "ax[1, 1].set_yticks(range(len(order))); ax[1, 1].set_yticklabels([eng[i] for i in order]); ax[1, 1].invert_yaxis()\n", + "ax[1, 1].set_xlabel(\"log defect rate\"); ax[1, 1].set_title(f\"Waterfall: {r0['lot_id']}\")\n", + "plt.suptitle(\"QLT02 incoming inspection quality dashboard (synthetic)\"); plt.tight_layout(); plt.show()\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 06 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + "[품질 대시보드] 최근 6개월 수령검사 현황 [교육용가상자료]\n", + "월별 표본 불량률 관리한계 초과 월: [1, 11, 13, 18] (기준 4.09%)\n", + " 로트 평균위험 경보 실제불량률 추세 상태\n", + "vendor \n", + "V12 19 72.55 12 0.79 유지 고위험\n", + "V10 16 55.41 8 0.69 악화 고위험\n", + "V11 22 49.74 8 0.59 악화 관찰\n", + "V07 14 28.34 3 0.21 유지 관찰\n", + "V09 21 26.50 5 0.29 유지 관찰\n", + "V06 31 21.46 5 0.19 유지 정상\n", + "\n", + "경보 로트 59건 설명 생성: 상위 3개 인자 자동 산출률 100% (목표 100%: 달성), 가법성 오차 최대 6.7e-16\n", + "[요인군별 평균 기여도(로그 불량률, 경보 로트)]\n", + "표본검사 결과 0.615\n", + "납기 지연·촉박 0.216\n", + "정밀 규격 0.165\n", + "업체 이력 0.116\n", + "품목군 0.053\n", + "구매요구 조건 0.049\n", + "규격 변경·불일치 0.038\n", + "\n", + "[자동 설명 예시]\n", + " LOT42-00172 위험 100: 표본검사 결과(x3.45), 납기 지연·촉박(x2.22), 정밀 규격(x1.33) 순으로 불량률 예측을 높였다 -> 조치: 표본 크기 확대 재검사\n", + " LOT42-01133 위험 100: 표본검사 결과(x3.37), 납기 지연·촉박(x1.84), 정밀 규격(x1.28) 순으로 불량률 예측을 높였다 -> 조치: 표본 크기 확대 재검사\n", + " LOT42-01072 위험 100: 표본검사 결과(x2.72), 업체 이력(x1.62), 정밀 규격(x1.26) 순으로 불량률 예측을 높였다 -> 조치: 표본 크기 확대 재검사\n", + "타당성 점검: 규격 불일치 경보 로트 10건 중 '규격 변경·불일치' 기여 양수 100%\n", + "```\n", + "
\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 07. 합부 판정 경계선 품목에 대한 품질 검사관 정밀 재검사(HITL) 워크플로우 설계\n", + "**소요시간**: 약 3.5시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "합부 판정 경계선 품목에 대한 검사관 정밀 재검사(HITL) 워크플로우를 설계한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **합부 판정 경계선에 있는 로트를 자동 확정하지 않고 품질 검사관 정밀 재검사로 넘기는 인간 개입(HITL) 워크플로우를 설계하고, 한정된 검사 시간을 최적 배분한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **표본 불량 수가 합격판정수 근처이거나 위험 확률이 중간인 로트를 재검사 대기열에 등록 수행하고 검증하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `src/optimization/qlt02_simulator.py` 형태의 결과물을 완성한다.\n", + "\n", + "### 🔍 평가 및 검증 기준 (PASS 조건)\n", + "#### 미션 판정\n", + "\n", + "**평가 구분:** 필수\n", + "\n", + "**핵심 평가영역:** 현실적 군수 제약조건 반영도 및 시나리오별 대안 제시 적절성\n", + "\n", + "#### 미션 PASS 조건\n", + "\n", + "- 정량 기준: 최적화 적용 시 비용 15% 절감 또는 재고 회전율 20% 향상\n", + "\n", + "\n", + "- 진행 기준: 선형/정수 계획법(LP/MIP) 최적화 해 도출 및 시뮬레이션 완료\n", + "\n", + "\n", + "- 검증 방법: 과거 실적 데이터 대비 최적화 결과 비교 백테스팅\n", + "\n", + "\n", + "#### 채점자 판정 메모\n", + "\n", + "> 단순 코드 실행 여부뿐만 아니라 출력된 수치와 근거 자료가 군 실무 요구사항에 부합하는지 면밀히 확인한다.\n", + "\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "\"\"\"샘플링 판정과 위험 지수가 경계선에 있는 로트를 자동 확정하지 않고 검사관 정밀 재검사 대기열로 보내는 인간 개입(HITL) 워크플로우를 설계하고, 한정된 검사관 시간을 정수계획(MIP)으로 배분해 현행 선착순 방식 대비 품질 비용 절감과 수요 급증 민감도를 시뮬레이션한다.\"\"\"\n", + "import hashlib\n", + "import numpy as np\n", + "import pandas as pd\n", + "import matplotlib.pyplot as plt\n", + "from scipy import stats\n", + "from scipy.optimize import milp, LinearConstraint, Bounds\n", + "from sklearn.ensemble import GradientBoostingRegressor\n", + "\n", + "# ---------------------------------------------------------------------------\n", + "# [교육용가상자료] 군수품 수령검사 로트 데이터 생성기 (실제 업체·품목·계약 정보 없음)\n", + "# 구매요구(규격·긴급 여부) -> 계약(납기) -> 납품 -> 수령검사(표본) -> 확정 불량률(전수검사·사용부대 하자 반영)\n", + "# ---------------------------------------------------------------------------\n", + "FSC = {\"5305\": \"screw/bolt\", \"5330\": \"gasket\", \"5935\": \"connector\", \"6135\": \"battery\", \"2530\": \"brake part\", \"8415\": \"clothing\"}\n", + "VENDORS = [f\"V{i:02d}\" for i in range(1, 13)]\n", + "VENDOR_Q = dict(zip(VENDORS, [-0.6, -0.4, -0.3, -0.2, -0.1, 0.0, 0.1, 0.2, 0.3, 0.5, 0.8, 1.1])) # 업체 고유 품질 수준(숨은 값)\n", + "FSC_Q = {\"5305\": -0.3, \"5330\": 0.0, \"5935\": 0.25, \"6135\": 0.1, \"2530\": 0.35, \"8415\": -0.2}\n", + "SAMPLE_PLAN = [(500, 50, 3), (1200, 80, 5), (3200, 125, 7), (10 ** 9, 200, 10)] # (로트 크기 상한, 표본 n, 합격판정수 c) 단순화 기준\n", + "BAD_RATE = 0.05 # 확정 불량률 5% 초과 로트 = 불합격 대상\n", + "\n", + "\n", + "def sample_plan(lot_size):\n", + " for up, n, c in SAMPLE_PLAN:\n", + " if lot_size <= up:\n", + " return n, c\n", + "\n", + "\n", + "def true_rate(d, rng=None):\n", + " \"\"\"숨은 불량 발생 구조: 업체 수준 + 품목군 + 정밀 규격 + 긴급 구매 + 규격 변경·불일치 + 납기 지연 + (정밀 규격 x 촉박 납기) 상호작용.\"\"\"\n", + " short = (d[\"lead_days\"] < 45).astype(float)\n", + " z = (-4.35 + d[\"vendor\"].map(VENDOR_Q) + d[\"fsc\"].map(FSC_Q) + 0.55 * (d[\"spec_class\"] == \"precision\")\n", + " + 0.35 * d[\"pr_urgent\"] + 0.6 * d[\"spec_changed\"] + 0.8 * d[\"spec_mismatch\"]\n", + " + 0.9 * np.log1p(np.maximum(d[\"delay_days\"], 0) / 7) + 0.9 * short * (d[\"spec_class\"] == \"precision\")\n", + " + 0.5 * (d[\"month\"] >= 18) * (d[\"vendor\"] == \"V10\")) # V10 후반기 품질 저하(드리프트)\n", + " if rng is not None:\n", + " z = z + rng.normal(0, 0.12, len(d))\n", + " return np.clip(np.exp(z), 0.002, 0.35)\n", + "\n", + "\n", + "def make_lots(n=1200, seed=42, months=24):\n", + " rng = np.random.default_rng(seed)\n", + " d = pd.DataFrame({\"lot_id\": [f\"LOT{seed % 100:02d}-{i:05d}\" for i in range(n)]})\n", + " d[\"month\"] = rng.integers(0, months, n)\n", + " d[\"receipt_date\"] = pd.Timestamp(\"2024-01-01\") + pd.to_timedelta(d[\"month\"] * 30 + rng.integers(0, 30, n), unit=\"D\")\n", + " d = d.sort_values(\"receipt_date\").reset_index(drop=True)\n", + " d[\"month\"] = ((d[\"receipt_date\"] - pd.Timestamp(\"2024-01-01\")).dt.days // 30).clip(0, months - 1)\n", + " d[\"vendor\"] = rng.choice(VENDORS, n, p=np.r_[np.full(6, 0.1), np.full(6, 0.4 / 6)])\n", + " d[\"fsc\"] = rng.choice(list(FSC), n)\n", + " d[\"niin\"] = [f\"{f}-01-{int(k):03d}-{int(k) * 37 % 10000:04d}\" for f, k in zip(d[\"fsc\"], rng.integers(100, 110, n))]\n", + " d[\"spec_class\"] = np.where(rng.random(n) < np.where(d[\"fsc\"].isin([\"5935\", \"2530\"]), 0.6, 0.25), \"precision\", \"general\")\n", + " d[\"tolerance_mm\"] = np.where(d[\"spec_class\"] == \"precision\", rng.uniform(0.05, 0.1, n), rng.uniform(0.2, 0.5, n)).round(3)\n", + " d[\"spec_rev\"] = rng.integers(1, 6, n)\n", + " d[\"pr_urgent\"] = (rng.random(n) < 0.2).astype(int)\n", + " d[\"spec_changed\"] = (rng.random(n) < 0.12).astype(int)\n", + " d[\"spec_mismatch\"] = (rng.random(n) < 0.08).astype(int)\n", + " d[\"pr_qty\"] = rng.integers(200, 5000, n)\n", + " d[\"lot_size\"] = (d[\"pr_qty\"] * rng.uniform(0.5, 1.0, n)).astype(int)\n", + " d[\"lead_days\"] = np.where(d[\"pr_urgent\"] == 1, rng.integers(20, 60, n), rng.integers(40, 150, n))\n", + " d[\"delay_days\"] = np.round(rng.gamma(1.2, 6, n) * rng.binomial(1, 0.45, n) - rng.integers(0, 5, n)).astype(int)\n", + " d[\"unit_cost\"] = rng.choice([2_000, 15_000, 40_000, 120_000], n, p=[0.35, 0.3, 0.25, 0.1])\n", + " p = true_rate(d, rng)\n", + " d[\"sample_n\"], d[\"accept_c\"] = zip(*[sample_plan(s) for s in d[\"lot_size\"]])\n", + " d[\"defects\"] = rng.binomial(d[\"sample_n\"], p)\n", + " share = np.where(d[\"spec_class\"] == \"precision\", 0.55, 0.3)\n", + " d[\"def_dim\"] = rng.binomial(d[\"defects\"], share)\n", + " d[\"def_spec\"] = rng.binomial(d[\"defects\"] - d[\"def_dim\"], 0.6)\n", + " d[\"def_visual\"] = d[\"defects\"] - d[\"def_dim\"] - d[\"def_spec\"]\n", + " d[\"dim_dev_mm\"] = (d[\"tolerance_mm\"] * (0.3 + 2.0 * p) * rng.lognormal(0, 0.3, n)).round(4)\n", + " d[\"confirmed_rate\"] = np.clip(p * rng.lognormal(0, 0.05, n), 0.001, 0.4).round(4) # 전수검사·하자 반영 확정 불량률\n", + " d[\"field_claims\"] = rng.poisson(d[\"lot_size\"] * p * 0.03)\n", + " d[\"inspector\"] = rng.choice([\"INS-1\", \"INS-2\", \"INS-3\", \"INS-4\"], n)\n", + " d[\"emergency_release\"] = ((d[\"pr_urgent\"] == 1) & (rng.random(n) < 0.3)).astype(int) # 긴급 불출(검사 전 선불출) 예외\n", + " d[\"bad_lot\"] = (d[\"confirmed_rate\"] > BAD_RATE).astype(int)\n", + " return d\n", + "# ---------------------------------------------------------------------------\n", + "\n", + "# ---- 파생 피처 공통 함수 (미션 4 이후 공통) ----\n", + "def add_history(d):\n", + " \"\"\"업체별 과거 확정 불량률(현재 로트 이전 로트만 사용: 정보 누수 방지)과 최근 5로트 추세.\"\"\"\n", + " d = d.sort_values(\"receipt_date\").reset_index(drop=True)\n", + " g = d.groupby(\"vendor\")[\"confirmed_rate\"]\n", + " d[\"vendor_hist_rate\"] = g.transform(lambda s: s.shift().expanding().mean()).fillna(d[\"confirmed_rate\"].mean())\n", + " d[\"vendor_recent_rate\"] = g.transform(lambda s: s.shift().rolling(5, min_periods=1).mean()).fillna(d[\"vendor_hist_rate\"])\n", + " return d\n", + "\n", + "\n", + "def make_features(d):\n", + " d = add_history(d)\n", + " X = pd.DataFrame(index=d.index)\n", + " X[\"obs_rate\"] = np.log((d[\"defects\"] + 0.5) / d[\"sample_n\"]) # 1 표본 불량률\n", + " X[\"log_lot\"] = np.log(d[\"lot_size\"]) # 2 로트 규모(로그)\n", + " X[\"delay_pos\"] = d[\"delay_days\"].clip(lower=0) # 3 납기 지연일\n", + " X[\"delay_ratio\"] = X[\"delay_pos\"] / d[\"lead_days\"] # 4 지연/계약기간 비\n", + " X[\"short_lead\"] = (d[\"lead_days\"] < 45).astype(int) # 5 촉박 납기\n", + " X[\"precision\"] = (d[\"spec_class\"] == \"precision\").astype(int) # 6 정밀 규격\n", + " X[\"prec_x_short\"] = X[\"precision\"] * X[\"short_lead\"] # 7 정밀 x 촉박(상호작용)\n", + " X[\"spec_risk\"] = d[\"spec_changed\"] + d[\"spec_mismatch\"] # 8 규격 변경·불일치 합\n", + " X[\"dim_ratio\"] = np.log(d[\"dim_dev_mm\"] / d[\"tolerance_mm\"]) # 9 치수 편차/공차\n", + " X[\"vendor_hist\"] = np.log(d[\"vendor_hist_rate\"]) # 10 업체 과거 불량률\n", + " X[\"vendor_trend\"] = d[\"vendor_recent_rate\"] - d[\"vendor_hist_rate\"] # 11 업체 최근 추세\n", + " X[\"urgent\"] = d[\"pr_urgent\"] # 12 긴급 구매요구\n", + " X[\"fill_ratio\"] = d[\"lot_size\"] / d[\"pr_qty\"] # 13 구매요구 대비 납품 비율\n", + " X[\"dim_share\"] = d[\"def_dim\"] / d[\"defects\"].clip(lower=1) # 14 치수 오차 비중\n", + " for f in FSC: # 15~ 품목군 원-핫\n", + " X[f\"fsc_{f}\"] = (d[\"fsc\"] == f).astype(int)\n", + " return d, X\n", + "\n", + "# 교육용 가정 단가(원): 실제 계약·예산 자료 아님\n", + "HOUR_COST = 50_000 # 검사관 1시간 비용\n", + "MISS_FACTOR = 3.0 # 불량품이 부대로 불출될 때 단가 대비 처리비(교체·재수송·정비 손실) 배수\n", + "REJECT_COST_RATE = 0.03 # 양품 로트를 잘못 반품할 때 로트 금액 대비 지연·재납품 비용\n", + "CAP_HOURS = 24 # 월 정밀 재검사 가용 시간(검사관 2명 x 12시간)\n", + "\n", + "d, X = make_features(make_lots(1200, seed=42))\n", + "tr, te = (d[\"month\"] < 18).values, (d[\"month\"] >= 18).values\n", + "y = np.log(d[\"confirmed_rate\"].values)\n", + "gbm = GradientBoostingRegressor(n_estimators=300, max_depth=2, learning_rate=0.08, subsample=0.8, random_state=0).fit(X.values[tr], y[tr])\n", + "sigma = float(np.std(y[tr] - gbm.predict(X.values[tr])) * 1.5)\n", + "h = d[te].copy()\n", + "h[\"p_bad\"] = 1 - stats.norm.cdf((np.log(BAD_RATE) - gbm.predict(X.values[te])) / sigma)\n", + "h[\"accept\"] = h[\"defects\"] <= h[\"accept_c\"]\n", + "\n", + "\n", + "def costs(df):\n", + " value = df[\"lot_size\"] * df[\"unit_cost\"]\n", + " df[\"c_fa\"] = df[\"lot_size\"] * df[\"confirmed_rate\"] * df[\"unit_cost\"] * MISS_FACTOR # 오합격 시 실제 비용\n", + " df[\"c_fr\"] = value * REJECT_COST_RATE # 오불합격 시 실제 비용\n", + " df[\"hours\"] = np.where(df[\"spec_class\"] == \"precision\", 4.0, 2.0) + df[\"lot_size\"] / 2000\n", + " # 재검사 전 기대 비용(모델 추정): 합격 판정이면 P(불량)x오합격비, 불합격 판정이면 (1-P)x오불합격비\n", + " exp_fa = df[\"p_bad\"] * df[\"lot_size\"] * np.exp(np.log(BAD_RATE) + 0.7) * df[\"unit_cost\"] * MISS_FACTOR\n", + " df[\"benefit\"] = np.where(df[\"accept\"], exp_fa, (1 - df[\"p_bad\"]) * df[\"c_fr\"]) - df[\"hours\"] * HOUR_COST\n", + " df[\"realized_no\"] = np.where(df[\"accept\"], df[\"bad_lot\"] * df[\"c_fa\"], (1 - df[\"bad_lot\"]) * df[\"c_fr\"])\n", + " return df\n", + "\n", + "\n", + "# ---------------- 1) 경계선 로트 선별 -> 재검사 대기열(자동 확정 금지) ----------------\n", + "h[\"margin\"] = h[\"defects\"] - h[\"accept_c\"]\n", + "h[\"border\"] = (h[\"margin\"].abs() <= 1) | h[\"p_bad\"].between(0.3, 0.8) | (h[\"accept\"] & (h[\"p_bad\"] > 0.5))\n", + "q = costs(h[h[\"border\"]].copy())\n", + "print(f\"[1] 최근 6개월 로트 {len(h)}건 중 경계선 {len(q)}건 -> 검사관 재검사 대기열 등록, 자동 확정 0건 [교육용가상자료]\")\n", + "print(f\" 선별 기준: |표본불량-합격판정수|<=1, 위험확률 30~80%, 합격 판정인데 위험확률>50%\")\n", + "\n", + "\n", + "def select(df, policy, cap=CAP_HOURS):\n", + " \"\"\"월별 가용 시간 안에서 재검사 대상 선택. fifo=입고순, mip=기대 순편익 최대화 0-1 정수계획.\"\"\"\n", + " pick = np.zeros(len(df), bool)\n", + " for mth, g in df.groupby(\"month\"):\n", + " idx = np.where(df[\"month\"].values == mth)[0]\n", + " if policy == \"fifo\":\n", + " used = 0\n", + " for i in idx[np.argsort(df[\"receipt_date\"].values[idx])]:\n", + " if used + df[\"hours\"].values[i] <= cap:\n", + " pick[i] = True; used += df[\"hours\"].values[i]\n", + " else:\n", + " b, w = df[\"benefit\"].values[idx], df[\"hours\"].values[idx]\n", + " res = milp(c=-b, constraints=[LinearConstraint(w[None, :], 0, cap)], integrality=np.ones(len(idx)), bounds=Bounds(0, 1))\n", + " pick[idx] = res.x > 0.5\n", + " return pick\n", + "\n", + "\n", + "def total_cost(df, pick):\n", + " \"\"\"실제(확정) 결과로 계산: 재검사 로트는 검사비만(정밀 재검사로 정답 판정), 나머지는 샘플링 판정 오류 비용.\"\"\"\n", + " return float(np.sum(np.where(pick, df[\"hours\"] * HOUR_COST, df[\"realized_no\"])))\n", + "\n", + "\n", + "res = {}\n", + "for pol in (\"none\", \"fifo\", \"mip\"):\n", + " pick = np.zeros(len(q), bool) if pol == \"none\" else select(q, pol)\n", + " res[pol] = {\"재검사 로트\": int(pick.sum()), \"사용 시간\": round(float(q[\"hours\"][pick].sum()), 1), \"총 품질비용(백만원)\": round(total_cost(q, pick) / 1e6, 1),\n", + " \"놓친 불량 로트\": int(((~pick) & q[\"accept\"].values & (q[\"bad_lot\"].values == 1)).sum())}\n", + " if pol == \"mip\":\n", + " q[\"pick\"] = pick\n", + "tab = pd.DataFrame(res).T.astype({\"재검사 로트\": int, \"놓친 불량 로트\": int}).rename(index={\"none\": \"재검사 없음\", \"fifo\": \"현행(선착순)\", \"mip\": \"최적화(MIP)\"})\n", + "print(\"\\n[2] 재검사 배분 방식 비교 (월 가용 24시간)\")\n", + "print(tab.to_string())\n", + "cut = 1 - res[\"mip\"][\"총 품질비용(백만원)\"] / res[\"fifo\"][\"총 품질비용(백만원)\"]\n", + "print(f\"현행 대비 품질비용 {cut:.1%} 절감 (목표 15% 이상: {'달성' if cut >= 0.15 else '미달'})\")\n", + "\n", + "# ---------------- 3) 검사관 결정 기록(HITL) ----------------\n", + "log, prev, n_block = [], \"GENESIS\", 0\n", + "INSP = [\"INS-1\", \"INS-2\", \"INS-3\", \"INS-4\"]\n", + "for i, r in enumerate(q[q[\"pick\"]].head(12).itertuples()):\n", + " who = INSP[(INSP.index(r.inspector) + 1 + i) % 4]\n", + " if who == r.inspector: # 최초 검사자는 재검사 불가(이해충돌)\n", + " n_block += 1; who = INSP[(INSP.index(who) + 1) % 4]\n", + " verdict = \"불합격\" if r.bad_lot else \"합격\"\n", + " reason = f\"전수 측정 확정불량률 {r.confirmed_rate:.1%}, 치수편차 {r.dim_dev_mm}mm\"\n", + " rec = f\"{r.lot_id}|{who}|{verdict}|{reason}|{prev}\"\n", + " prev = hashlib.sha256(rec.encode()).hexdigest()[:12]\n", + " log.append({\"로트\": r.lot_id, \"샘플링판정\": \"합격\" if r.accept else \"불합격\", \"위험확률\": round(r.p_bad, 2), \"재검사관\": who, \"최종\": verdict, \"근거\": reason, \"hash\": prev})\n", + "L = pd.DataFrame(log)\n", + "flip = (L[\"샘플링판정\"] != L[\"최종\"]).sum()\n", + "print(f\"\\n[3] 검사관 재검사 기록 예시(12건 중 판정 번복 {flip}건, 근거 미기재 결정 0건, 최초 검사자 배정 차단 {n_block}건)\")\n", + "print(L.head(5)[[\"로트\", \"샘플링판정\", \"위험확률\", \"재검사관\", \"최종\", \"근거\"]].to_string(index=False))\n", + "\n", + "# ---------------- 4) 민감도: 전시 전환 등 돌발 수요 ----------------\n", + "print(\"\\n[4] 민감도 분석 (경계선 로트 증가, 검사 가용 시간 변화)\")\n", + "rows = []\n", + "for surge, capf in [(1.0, 1.0), (1.5, 1.0), (2.0, 1.0), (2.0, 0.7)]:\n", + " qq = q.sample(frac=surge, replace=surge > 1, random_state=7).reset_index(drop=True)\n", + " cf, cm = [total_cost(qq, select(qq, p, CAP_HOURS * capf)) for p in (\"fifo\", \"mip\")]\n", + " rows.append({\"물량 배수\": surge, \"가용시간\": f\"{capf:.0%}\", \"선착순(백만원)\": round(cf / 1e6, 1), \"MIP(백만원)\": round(cm / 1e6, 1), \"절감액(백만원)\": round((cf - cm) / 1e6, 1), \"절감률\": f\"{1 - cm / cf:.1%}\"})\n", + "sens = pd.DataFrame(rows)\n", + "print(sens.to_string(index=False))\n", + "print(\"물량이 늘면 절감률은 낮아지지만 절감액은 커진다. 가용 시간이 부족할수록 기대 손실이 큰 로트부터 재검사해야 한다.\")\n", + "print(\"최종 합부 판정은 검사관이 내리고, 모델은 재검사 순서만 제안한다.\")\n", + "\n", + "fig, ax = plt.subplots(1, 3, figsize=(15, 4))\n", + "ax[0].bar([\"none\", \"FIFO\", \"MIP\"], tab[\"총 품질비용(백만원)\"], color=[\"gray\", \"tab:orange\", \"tab:green\"]); ax[0].set_ylabel(\"quality cost (M KRW)\"); ax[0].set_title(\"Re-inspection allocation\")\n", + "ax[1].scatter(q[\"hours\"], q[\"benefit\"] / 1e6, c=q[\"pick\"].map({True: \"tab:green\", False: \"lightgray\"}), s=14)\n", + "ax[1].set_xlabel(\"re-inspection hours\"); ax[1].set_ylabel(\"expected net benefit (M KRW)\"); ax[1].set_title(\"MIP selection (green = selected)\")\n", + "ax[2].plot(range(len(sens)), [float(s[:-1]) for s in sens[\"절감률\"]], \"o-\"); ax[2].set_xticks(range(len(sens)))\n", + "ax[2].set_xticklabels([f\"x{a} / {b}\" for a, b in zip(sens[\"물량 배수\"], sens[\"가용시간\"])]); ax[2].set_ylabel(\"saving vs FIFO (%)\"); ax[2].set_title(\"Sensitivity: surge / capacity\")\n", + "plt.tight_layout(); plt.show()\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 07 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + "[1] 최근 6개월 로트 304건 중 경계선 57건 -> 검사관 재검사 대기열 등록, 자동 확정 0건 [교육용가상자료]\n", + " 선별 기준: |표본불량-합격판정수|<=1, 위험확률 30~80%, 합격 판정인데 위험확률>50%\n", + "\n", + "[2] 재검사 배분 방식 비교 (월 가용 24시간)\n", + " 재검사 로트 사용 시간 총 품질비용(백만원) 놓친 불량 로트\n", + "재검사 없음 0 0.0 57.5 13\n", + "현행(선착순) 35 137.7 25.7 5\n", + "최적화(MIP) 32 121.3 8.8 3\n", + "현행 대비 품질비용 65.8% 절감 (목표 15% 이상: 달성)\n", + "\n", + "[3] 검사관 재검사 기록 예시(12건 중 판정 번복 6건, 근거 미기재 결정 0건, 최초 검사자 배정 차단 3건)\n", + " 로트 샘플링판정 위험확률 재검사관 최종 근거\n", + "LOT42-00127 합격 0.89 INS-2 불합격 전수 측정 확정불량률 5.7%, 치수편차 0.0523mm\n", + "LOT42-00597 합격 0.11 INS-2 합격 전수 측정 확정불량률 3.3%, 치수편차 0.1563mm\n", + "LOT42-00159 합격 0.18 INS-1 합격 전수 측정 확정불량률 2.8%, 치수편차 0.1883mm\n", + "LOT42-01097 합격 0.08 INS-2 합격 전수 측정 확정불량률 2.6%, 치수편차 0.0287mm\n", + "LOT42-00246 합격 0.33 INS-2 불합격 전수 측정 확정불량률 6.7%, 치수편차 0.21mm\n", + "\n", + "[4] 민감도 분석 (경계선 로트 증가, 검사 가용 시간 변화)\n", + " 물량 배수 가용시간 선착순(백만원) MIP(백만원) 절감액(백만원) 절감률\n", + " 1.0 100% 25.7 8.8 16.9 65.9%\n", + " 1.5 100% 92.1 51.7 40.5 43.9%\n", + " 2.0 100% 123.6 77.9 45.7 36.9%\n", + " 2.0 70% 145.9 100.5 45.4 31.1%\n", + "물량이 늘면 절감률은 낮아지지만 절감액은 커진다. 가용 시간이 부족할수록 기대 손실이 큰 로트부터 재검사해야 한다.\n", + "최종 합부 판정은 검사관이 내리고, 모델은 재검사 순서만 제안한다.\n", + "```\n", + "
\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 08. 불량 부품 대량 납품 가상 주입 시 조기경보 신속성 스트레스 테스트\n", + "**소요시간**: 약 3.0시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "불량 부품 대량 납품 가상 주입 시 조기경보 스트레스 테스트를 수행한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **평소 품질이 좋던 업체가 불량 부품을 대량 납품하는 상황을 가상으로 주입해 조기경보의 정확도와 신속성, 대시보드 갱신 성능을 시험한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **우수 업체 V03에 불량 로트 위주의 대량 납품 60로트와 손상 레코드 12건 주입하여 처리 지연 및 복원력을 시험하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `src/dashboard/qlt02_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": [ + "\"\"\"평소 품질이 좋던 가상 업체가 불량 로트를 대량 납품하는 상황과 손상 레코드를 주입해, 로트 경보 정확도·업체 단위 누적합(CUSUM) 경보의 신속성·대시보드 갱신 시간(물량 1~50배)을 측정하는 조기경보 스트레스 테스트를 수행한다.\"\"\"\n", + "import time\n", + "import numpy as np\n", + "import pandas as pd\n", + "import matplotlib.pyplot as plt\n", + "from scipy import stats\n", + "from sklearn.ensemble import GradientBoostingRegressor\n", + "\n", + "# ---------------------------------------------------------------------------\n", + "# [교육용가상자료] 군수품 수령검사 로트 데이터 생성기 (실제 업체·품목·계약 정보 없음)\n", + "# 구매요구(규격·긴급 여부) -> 계약(납기) -> 납품 -> 수령검사(표본) -> 확정 불량률(전수검사·사용부대 하자 반영)\n", + "# ---------------------------------------------------------------------------\n", + "FSC = {\"5305\": \"screw/bolt\", \"5330\": \"gasket\", \"5935\": \"connector\", \"6135\": \"battery\", \"2530\": \"brake part\", \"8415\": \"clothing\"}\n", + "VENDORS = [f\"V{i:02d}\" for i in range(1, 13)]\n", + "VENDOR_Q = dict(zip(VENDORS, [-0.6, -0.4, -0.3, -0.2, -0.1, 0.0, 0.1, 0.2, 0.3, 0.5, 0.8, 1.1])) # 업체 고유 품질 수준(숨은 값)\n", + "FSC_Q = {\"5305\": -0.3, \"5330\": 0.0, \"5935\": 0.25, \"6135\": 0.1, \"2530\": 0.35, \"8415\": -0.2}\n", + "SAMPLE_PLAN = [(500, 50, 3), (1200, 80, 5), (3200, 125, 7), (10 ** 9, 200, 10)] # (로트 크기 상한, 표본 n, 합격판정수 c) 단순화 기준\n", + "BAD_RATE = 0.05 # 확정 불량률 5% 초과 로트 = 불합격 대상\n", + "\n", + "\n", + "def sample_plan(lot_size):\n", + " for up, n, c in SAMPLE_PLAN:\n", + " if lot_size <= up:\n", + " return n, c\n", + "\n", + "\n", + "def true_rate(d, rng=None):\n", + " \"\"\"숨은 불량 발생 구조: 업체 수준 + 품목군 + 정밀 규격 + 긴급 구매 + 규격 변경·불일치 + 납기 지연 + (정밀 규격 x 촉박 납기) 상호작용.\"\"\"\n", + " short = (d[\"lead_days\"] < 45).astype(float)\n", + " z = (-4.35 + d[\"vendor\"].map(VENDOR_Q) + d[\"fsc\"].map(FSC_Q) + 0.55 * (d[\"spec_class\"] == \"precision\")\n", + " + 0.35 * d[\"pr_urgent\"] + 0.6 * d[\"spec_changed\"] + 0.8 * d[\"spec_mismatch\"]\n", + " + 0.9 * np.log1p(np.maximum(d[\"delay_days\"], 0) / 7) + 0.9 * short * (d[\"spec_class\"] == \"precision\")\n", + " + 0.5 * (d[\"month\"] >= 18) * (d[\"vendor\"] == \"V10\")) # V10 후반기 품질 저하(드리프트)\n", + " if rng is not None:\n", + " z = z + rng.normal(0, 0.12, len(d))\n", + " return np.clip(np.exp(z), 0.002, 0.35)\n", + "\n", + "\n", + "def make_lots(n=1200, seed=42, months=24):\n", + " rng = np.random.default_rng(seed)\n", + " d = pd.DataFrame({\"lot_id\": [f\"LOT{seed % 100:02d}-{i:05d}\" for i in range(n)]})\n", + " d[\"month\"] = rng.integers(0, months, n)\n", + " d[\"receipt_date\"] = pd.Timestamp(\"2024-01-01\") + pd.to_timedelta(d[\"month\"] * 30 + rng.integers(0, 30, n), unit=\"D\")\n", + " d = d.sort_values(\"receipt_date\").reset_index(drop=True)\n", + " d[\"month\"] = ((d[\"receipt_date\"] - pd.Timestamp(\"2024-01-01\")).dt.days // 30).clip(0, months - 1)\n", + " d[\"vendor\"] = rng.choice(VENDORS, n, p=np.r_[np.full(6, 0.1), np.full(6, 0.4 / 6)])\n", + " d[\"fsc\"] = rng.choice(list(FSC), n)\n", + " d[\"niin\"] = [f\"{f}-01-{int(k):03d}-{int(k) * 37 % 10000:04d}\" for f, k in zip(d[\"fsc\"], rng.integers(100, 110, n))]\n", + " d[\"spec_class\"] = np.where(rng.random(n) < np.where(d[\"fsc\"].isin([\"5935\", \"2530\"]), 0.6, 0.25), \"precision\", \"general\")\n", + " d[\"tolerance_mm\"] = np.where(d[\"spec_class\"] == \"precision\", rng.uniform(0.05, 0.1, n), rng.uniform(0.2, 0.5, n)).round(3)\n", + " d[\"spec_rev\"] = rng.integers(1, 6, n)\n", + " d[\"pr_urgent\"] = (rng.random(n) < 0.2).astype(int)\n", + " d[\"spec_changed\"] = (rng.random(n) < 0.12).astype(int)\n", + " d[\"spec_mismatch\"] = (rng.random(n) < 0.08).astype(int)\n", + " d[\"pr_qty\"] = rng.integers(200, 5000, n)\n", + " d[\"lot_size\"] = (d[\"pr_qty\"] * rng.uniform(0.5, 1.0, n)).astype(int)\n", + " d[\"lead_days\"] = np.where(d[\"pr_urgent\"] == 1, rng.integers(20, 60, n), rng.integers(40, 150, n))\n", + " d[\"delay_days\"] = np.round(rng.gamma(1.2, 6, n) * rng.binomial(1, 0.45, n) - rng.integers(0, 5, n)).astype(int)\n", + " d[\"unit_cost\"] = rng.choice([2_000, 15_000, 40_000, 120_000], n, p=[0.35, 0.3, 0.25, 0.1])\n", + " p = true_rate(d, rng)\n", + " d[\"sample_n\"], d[\"accept_c\"] = zip(*[sample_plan(s) for s in d[\"lot_size\"]])\n", + " d[\"defects\"] = rng.binomial(d[\"sample_n\"], p)\n", + " share = np.where(d[\"spec_class\"] == \"precision\", 0.55, 0.3)\n", + " d[\"def_dim\"] = rng.binomial(d[\"defects\"], share)\n", + " d[\"def_spec\"] = rng.binomial(d[\"defects\"] - d[\"def_dim\"], 0.6)\n", + " d[\"def_visual\"] = d[\"defects\"] - d[\"def_dim\"] - d[\"def_spec\"]\n", + " d[\"dim_dev_mm\"] = (d[\"tolerance_mm\"] * (0.3 + 2.0 * p) * rng.lognormal(0, 0.3, n)).round(4)\n", + " d[\"confirmed_rate\"] = np.clip(p * rng.lognormal(0, 0.05, n), 0.001, 0.4).round(4) # 전수검사·하자 반영 확정 불량률\n", + " d[\"field_claims\"] = rng.poisson(d[\"lot_size\"] * p * 0.03)\n", + " d[\"inspector\"] = rng.choice([\"INS-1\", \"INS-2\", \"INS-3\", \"INS-4\"], n)\n", + " d[\"emergency_release\"] = ((d[\"pr_urgent\"] == 1) & (rng.random(n) < 0.3)).astype(int) # 긴급 불출(검사 전 선불출) 예외\n", + " d[\"bad_lot\"] = (d[\"confirmed_rate\"] > BAD_RATE).astype(int)\n", + " return d\n", + "# ---------------------------------------------------------------------------\n", + "\n", + "# ---- 파생 피처 공통 함수 (미션 4 이후 공통) ----\n", + "def add_history(d):\n", + " \"\"\"업체별 과거 확정 불량률(현재 로트 이전 로트만 사용: 정보 누수 방지)과 최근 5로트 추세.\"\"\"\n", + " d = d.sort_values(\"receipt_date\").reset_index(drop=True)\n", + " g = d.groupby(\"vendor\")[\"confirmed_rate\"]\n", + " d[\"vendor_hist_rate\"] = g.transform(lambda s: s.shift().expanding().mean()).fillna(d[\"confirmed_rate\"].mean())\n", + " d[\"vendor_recent_rate\"] = g.transform(lambda s: s.shift().rolling(5, min_periods=1).mean()).fillna(d[\"vendor_hist_rate\"])\n", + " return d\n", + "\n", + "\n", + "def make_features(d):\n", + " d = add_history(d)\n", + " X = pd.DataFrame(index=d.index)\n", + " X[\"obs_rate\"] = np.log((d[\"defects\"] + 0.5) / d[\"sample_n\"]) # 1 표본 불량률\n", + " X[\"log_lot\"] = np.log(d[\"lot_size\"]) # 2 로트 규모(로그)\n", + " X[\"delay_pos\"] = d[\"delay_days\"].clip(lower=0) # 3 납기 지연일\n", + " X[\"delay_ratio\"] = X[\"delay_pos\"] / d[\"lead_days\"] # 4 지연/계약기간 비\n", + " X[\"short_lead\"] = (d[\"lead_days\"] < 45).astype(int) # 5 촉박 납기\n", + " X[\"precision\"] = (d[\"spec_class\"] == \"precision\").astype(int) # 6 정밀 규격\n", + " X[\"prec_x_short\"] = X[\"precision\"] * X[\"short_lead\"] # 7 정밀 x 촉박(상호작용)\n", + " X[\"spec_risk\"] = d[\"spec_changed\"] + d[\"spec_mismatch\"] # 8 규격 변경·불일치 합\n", + " X[\"dim_ratio\"] = np.log(d[\"dim_dev_mm\"] / d[\"tolerance_mm\"]) # 9 치수 편차/공차\n", + " X[\"vendor_hist\"] = np.log(d[\"vendor_hist_rate\"]) # 10 업체 과거 불량률\n", + " X[\"vendor_trend\"] = d[\"vendor_recent_rate\"] - d[\"vendor_hist_rate\"] # 11 업체 최근 추세\n", + " X[\"urgent\"] = d[\"pr_urgent\"] # 12 긴급 구매요구\n", + " X[\"fill_ratio\"] = d[\"lot_size\"] / d[\"pr_qty\"] # 13 구매요구 대비 납품 비율\n", + " X[\"dim_share\"] = d[\"def_dim\"] / d[\"defects\"].clip(lower=1) # 14 치수 오차 비중\n", + " for f in FSC: # 15~ 품목군 원-핫\n", + " X[f\"fsc_{f}\"] = (d[\"fsc\"] == f).astype(int)\n", + " return d, X\n", + "\n", + "base = make_lots(1200, seed=42)\n", + "# ---------------- 1) 대량 불량 납품 주입 ----------------\n", + "rng = np.random.default_rng(88)\n", + "inj = make_lots(60, seed=88)\n", + "inj[\"vendor\"] = \"V03\" # 평소 우수 업체(숨은 품질 -0.3)\n", + "inj[\"receipt_date\"] = pd.Timestamp(\"2025-09-01\") + pd.to_timedelta(np.sort(rng.integers(0, 25, 60)), unit=\"D\")\n", + "inj[\"month\"] = 20\n", + "inj[\"lot_size\"] = (inj[\"lot_size\"] * 2).astype(int) # 대량 납품\n", + "p = np.where(rng.random(60) < 0.8, rng.uniform(0.08, 0.25, 60), rng.uniform(0.01, 0.03, 60)) # 80%는 불량 로트\n", + "inj[\"sample_n\"], inj[\"accept_c\"] = zip(*[sample_plan(s) for s in inj[\"lot_size\"]])\n", + "inj[\"defects\"] = rng.binomial(inj[\"sample_n\"], p)\n", + "inj[\"def_dim\"] = rng.binomial(inj[\"defects\"], 0.5); inj[\"def_spec\"] = inj[\"defects\"] - inj[\"def_dim\"]; inj[\"def_visual\"] = 0\n", + "inj[\"dim_dev_mm\"] = (inj[\"tolerance_mm\"] * (0.3 + 2.0 * p) * rng.lognormal(0, 0.3, 60)).round(4)\n", + "inj[\"confirmed_rate\"] = p.round(4); inj[\"bad_lot\"] = (p > BAD_RATE).astype(int)\n", + "inj[\"lot_id\"] = [f\"INJ-{i:03d}\" for i in range(60)]\n", + "inj[\"injected\"] = 1\n", + "base[\"injected\"] = 0\n", + "allx = pd.concat([base, inj], ignore_index=True)\n", + "\n", + "# 손상 레코드 주입(측정치 결측, 음수 불량 수, 표본 0) -> 격리\n", + "bad_idx = rng.choice(len(allx), 12, replace=False)\n", + "allx.loc[bad_idx[:4], \"dim_dev_mm\"] = np.nan\n", + "allx.loc[bad_idx[4:8], \"defects\"] = -1\n", + "allx.loc[bad_idx[8:], \"sample_n\"] = 0\n", + "\n", + "\n", + "def quarantine(df):\n", + " ok = df[\"dim_dev_mm\"].notna() & (df[\"defects\"] >= 0) & (df[\"sample_n\"] > 0) & (df[\"defects\"] <= df[\"sample_n\"])\n", + " return df[ok].copy(), df[~ok].copy()\n", + "\n", + "\n", + "clean, quar = quarantine(allx)\n", + "print(f\"[1] 주입: V03 업체 대량 납품 60로트(불량 {inj['bad_lot'].sum()}로트) + 손상 레코드 {len(bad_idx)}건 [교육용가상자료]\")\n", + "print(f\" 손상 레코드 {len(quar)}건 격리(재입력 요청), 파이프라인 중단 0건\")\n", + "\n", + "d, X = make_features(clean)\n", + "tr = (d[\"month\"] < 18).values\n", + "y = np.log(d[\"confirmed_rate\"].values)\n", + "gbm = GradientBoostingRegressor(n_estimators=300, max_depth=2, learning_rate=0.08, subsample=0.8, random_state=0).fit(X.values[tr], y[tr])\n", + "sigma = float(np.std(y[tr] - gbm.predict(X.values[tr])) * 1.5)\n", + "\n", + "\n", + "def score(Xv):\n", + " return 100 * (1 - stats.norm.cdf((np.log(BAD_RATE) - gbm.predict(Xv)) / sigma))\n", + "\n", + "\n", + "# ---------------- 2) 로트 경보 정확도 ----------------\n", + "win = (d[\"month\"] >= 18).values\n", + "w = d[win].copy(); w[\"risk\"] = score(X.values[win])\n", + "w[\"alert\"] = (w[\"risk\"] >= 70) | (w[\"defects\"] > w[\"accept_c\"]) # 위험 지수 경보 또는 샘플링 불합격\n", + "acc = (w[\"alert\"] == (w[\"bad_lot\"] == 1)).mean()\n", + "wi = w[w[\"injected\"] == 1]\n", + "tp, fp = int((w[\"alert\"] & (w[\"bad_lot\"] == 1)).sum()), int((w[\"alert\"] & (w[\"bad_lot\"] == 0)).sum())\n", + "print(f\"\\n[2] 스트레스 구간 로트 {len(w)}건: 경보 발령 정확도 {acc:.1%} (목표 95% 이상: {'달성' if acc >= 0.95 else '미달'})\")\n", + "print(f\" 주입 불량 로트 탐지 {int((wi['alert'] & (wi['bad_lot'] == 1)).sum())}/{int(wi['bad_lot'].sum())}, 전체 정탐 {tp}건, 오경보 {fp}건\")\n", + "acc_i = (wi[\"alert\"] == (wi[\"bad_lot\"] == 1)).mean()\n", + "print(f\" 대량 납품 주입 로트 {len(wi)}건 경보 정확도 {acc_i:.1%} (목표 95% 이상: {'달성' if acc_i >= 0.95 else '미달'})\")\n", + "print(f\" 샘플링 판정만 사용 시 정확도 {((w['defects'] > w['accept_c']) == (w['bad_lot'] == 1)).mean():.1%}\")\n", + "\n", + "# ---------------- 3) 업체 단위 CUSUM 경보 신속성 ----------------\n", + "v3 = d[d[\"vendor\"] == \"V03\"].sort_values(\"receipt_date\")\n", + "ref = v3[v3[\"month\"] < 18]\n", + "p0 = (ref[\"defects\"].sum() / ref[\"sample_n\"].sum())\n", + "first = v3[v3[\"injected\"] == 1][\"receipt_date\"].min()\n", + "k, hlim, s, alarm, false_alarm = p0 * 2, 0.15, 0.0, None, 0\n", + "trace = []\n", + "for r in v3.itertuples():\n", + " s = max(0.0, s + r.defects / r.sample_n - k) # 기준의 2배를 넘는 표본 불량률만 누적\n", + " trace.append(s)\n", + " if s > hlim and r.receipt_date < first:\n", + " false_alarm += 1; s = 0.0 # 평시 오경보: 기록 후 초기화\n", + " if alarm is None and s > hlim and r.injected == 1:\n", + " alarm = r\n", + "n_before = int((v3[v3[\"injected\"] == 1][\"receipt_date\"] <= alarm.receipt_date).sum()) if alarm is not None else None\n", + "print(f\"\\n[3] V03 업체 CUSUM(기준 표본불량률 {p0:.2%}): 대량 납품 시작 후 {n_before}번째 로트에서 업체 경보 발령\"\n", + " f\" ({(alarm.receipt_date - first).days}일 경과) -> 해당 업체 후속 로트 전수검사 전환 건의\")\n", + "print(f\" 주입 이전 V03 로트 {int((v3['receipt_date'] < first).sum())}건 중 업체 오경보 {false_alarm}건\")\n", + "\n", + "# ---------------- 4) 대시보드 갱신 시간 ----------------\n", + "def refresh(df, Xv):\n", + " t0 = time.perf_counter()\n", + " rk = score(Xv)\n", + " vend = pd.DataFrame({\"vendor\": df[\"vendor\"].values, \"risk\": rk, \"bad\": df[\"bad_lot\"].values}).groupby(\"vendor\").agg([\"mean\", \"size\"])\n", + " trend = df.groupby(\"month\")[\"defects\"].sum() / df.groupby(\"month\")[\"sample_n\"].sum()\n", + " top = np.argsort(-rk)[:20]\n", + " return time.perf_counter() - t0\n", + "\n", + "\n", + "print(\"\\n[4] 대시보드 갱신 시간(위험 산출+업체 집계+추세+상위 20건)\")\n", + "rows = []\n", + "for mult in (1, 10, 50):\n", + " big = pd.concat([d] * mult, ignore_index=True); Xb = np.tile(X.values, (mult, 1))\n", + " tt = np.median([refresh(big, Xb) for _ in range(3)])\n", + " rows.append({\"물량 배수\": mult, \"로트 수\": len(big), \"갱신 시간(초)\": round(tt, 3), \"2초 이내\": \"달성\" if tt <= 2 else \"미달\"})\n", + "print(pd.DataFrame(rows).to_string(index=False))\n", + "\n", + "fig, ax = plt.subplots(1, 3, figsize=(15, 4))\n", + "ax[0].scatter(w[\"risk\"], w[\"confirmed_rate\"], c=np.where(w[\"injected\"] == 1, \"tab:red\", \"tab:blue\"), s=10)\n", + "ax[0].axvline(70, ls=\"--\", c=\"k\"); ax[0].axhline(BAD_RATE, ls=\":\", c=\"k\"); ax[0].set_xlabel(\"risk score\"); ax[0].set_ylabel(\"confirmed rate\")\n", + "ax[0].set_title(\"Lot alerts (red = injected surge)\")\n", + "ax[1].plot(range(len(trace)), trace); ax[1].axhline(hlim, c=\"r\", ls=\"--\"); ax[1].set_title(\"Vendor V03 CUSUM\"); ax[1].set_xlabel(\"lot sequence\")\n", + "rr = pd.DataFrame(rows); ax[2].plot(rr[\"로트 수\"], rr[\"갱신 시간(초)\"], \"o-\"); ax[2].axhline(2, c=\"r\", ls=\"--\"); ax[2].set_xscale(\"log\")\n", + "ax[2].set_xlabel(\"lots\"); ax[2].set_ylabel(\"seconds\"); ax[2].set_title(\"Dashboard refresh time\")\n", + "plt.tight_layout(); plt.show()\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 08 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + "[1] 주입: V03 업체 대량 납품 60로트(불량 54로트) + 손상 레코드 12건 [교육용가상자료]\n", + " 손상 레코드 12건 격리(재입력 요청), 파이프라인 중단 0건\n", + "\n", + "[2] 스트레스 구간 로트 360건: 경보 발령 정확도 94.2% (목표 95% 이상: 미달)\n", + " 주입 불량 로트 탐지 54/54, 전체 정탐 117건, 오경보 12건\n", + " 대량 납품 주입 로트 60건 경보 정확도 98.3% (목표 95% 이상: 달성)\n", + " 샘플링 판정만 사용 시 정확도 93.1%\n", + "\n", + "[3] V03 업체 CUSUM(기준 표본불량률 2.40%): 대량 납품 시작 후 6번째 로트에서 업체 경보 발령 (1일 경과) -> 해당 업체 후속 로트 전수검사 전환 건의\n", + " 주입 이전 V03 로트 107건 중 업체 오경보 1건\n", + "\n", + "[4] 대시보드 갱신 시간(위험 산출+업체 집계+추세+상위 20건)\n", + " 물량 배수 로트 수 갱신 시간(초) 2초 이내\n", + " 1 1248 0.012 달성\n", + " 10 12480 0.044 달성\n", + " 50 62400 0.172 달성\n", + "```\n", + "
\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 09. AAR 기반 불량 오판정 사례 분석 및 검사 샘플링 파라미터 재보정\n", + "**소요시간**: 약 3.0시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "AAR 기반 불량 오판정 사례를 분석하고 샘플링 파라미터를 재보정한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **고정 샘플링 기준의 불량 오판정 사례를 사후검토(AAR)로 분석하고, 위험 등급별 샘플링 파라미터를 재보정해 정량 효과를 산출한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **학습 구간의 오합격·오불합격 사례를 경계선 불량률, 업체 이력 미반영, 정밀 규격 기준 동일 등 원인으로 분류 수행하고 검증하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `reports/qlt02_roi_analysis.md` 형태의 결과물을 완성한다.\n", + "\n", + "### 🔍 평가 및 검증 기준 (PASS 조건)\n", + "#### 미션 판정\n", + "\n", + "**평가 구분:** 필수\n", + "\n", + "**핵심 평가영역:** 추정 산식의 객관성 및 군 재정/군수 기획 부서 설득력\n", + "\n", + "#### 미션 PASS 조건\n", + "\n", + "- 정량 기준: 비용 절감액, 시간 단축률 등 3대 정량 효과 지표 산출\n", + "\n", + "\n", + "- 진행 기준: ROI 분석 보고서 완성 및 주요 지표 민감도 분석 수록\n", + "\n", + "\n", + "- 검증 방법: 회계/기획 기준 모델과의 비교 검증\n", + "\n", + "\n", + "#### 채점자 판정 메모\n", + "\n", + "> 단순 코드 실행 여부뿐만 아니라 출력된 수치와 근거 자료가 군 실무 요구사항에 부합하는지 면밀히 확인한다.\n", + "\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "\"\"\"고정 샘플링 기준의 오판정(오합격·오불합격) 사례를 사후검토(AAR)로 원인 분류하고, 입고 전 정보로 나눈 위험 등급별 표본 크기·합격판정수를 학습 구간에서 재보정해 검증 구간에서 비용 절감액·검사 시간 단축률·불량 유출 감소율의 3대 효과 지표를 산출한다.\"\"\"\n", + "import numpy as np\n", + "import pandas as pd\n", + "import matplotlib.pyplot as plt\n", + "from sklearn.linear_model import LogisticRegression\n", + "\n", + "# ---------------------------------------------------------------------------\n", + "# [교육용가상자료] 군수품 수령검사 로트 데이터 생성기 (실제 업체·품목·계약 정보 없음)\n", + "# 구매요구(규격·긴급 여부) -> 계약(납기) -> 납품 -> 수령검사(표본) -> 확정 불량률(전수검사·사용부대 하자 반영)\n", + "# ---------------------------------------------------------------------------\n", + "FSC = {\"5305\": \"screw/bolt\", \"5330\": \"gasket\", \"5935\": \"connector\", \"6135\": \"battery\", \"2530\": \"brake part\", \"8415\": \"clothing\"}\n", + "VENDORS = [f\"V{i:02d}\" for i in range(1, 13)]\n", + "VENDOR_Q = dict(zip(VENDORS, [-0.6, -0.4, -0.3, -0.2, -0.1, 0.0, 0.1, 0.2, 0.3, 0.5, 0.8, 1.1])) # 업체 고유 품질 수준(숨은 값)\n", + "FSC_Q = {\"5305\": -0.3, \"5330\": 0.0, \"5935\": 0.25, \"6135\": 0.1, \"2530\": 0.35, \"8415\": -0.2}\n", + "SAMPLE_PLAN = [(500, 50, 3), (1200, 80, 5), (3200, 125, 7), (10 ** 9, 200, 10)] # (로트 크기 상한, 표본 n, 합격판정수 c) 단순화 기준\n", + "BAD_RATE = 0.05 # 확정 불량률 5% 초과 로트 = 불합격 대상\n", + "\n", + "\n", + "def sample_plan(lot_size):\n", + " for up, n, c in SAMPLE_PLAN:\n", + " if lot_size <= up:\n", + " return n, c\n", + "\n", + "\n", + "def true_rate(d, rng=None):\n", + " \"\"\"숨은 불량 발생 구조: 업체 수준 + 품목군 + 정밀 규격 + 긴급 구매 + 규격 변경·불일치 + 납기 지연 + (정밀 규격 x 촉박 납기) 상호작용.\"\"\"\n", + " short = (d[\"lead_days\"] < 45).astype(float)\n", + " z = (-4.35 + d[\"vendor\"].map(VENDOR_Q) + d[\"fsc\"].map(FSC_Q) + 0.55 * (d[\"spec_class\"] == \"precision\")\n", + " + 0.35 * d[\"pr_urgent\"] + 0.6 * d[\"spec_changed\"] + 0.8 * d[\"spec_mismatch\"]\n", + " + 0.9 * np.log1p(np.maximum(d[\"delay_days\"], 0) / 7) + 0.9 * short * (d[\"spec_class\"] == \"precision\")\n", + " + 0.5 * (d[\"month\"] >= 18) * (d[\"vendor\"] == \"V10\")) # V10 후반기 품질 저하(드리프트)\n", + " if rng is not None:\n", + " z = z + rng.normal(0, 0.12, len(d))\n", + " return np.clip(np.exp(z), 0.002, 0.35)\n", + "\n", + "\n", + "def make_lots(n=1200, seed=42, months=24):\n", + " rng = np.random.default_rng(seed)\n", + " d = pd.DataFrame({\"lot_id\": [f\"LOT{seed % 100:02d}-{i:05d}\" for i in range(n)]})\n", + " d[\"month\"] = rng.integers(0, months, n)\n", + " d[\"receipt_date\"] = pd.Timestamp(\"2024-01-01\") + pd.to_timedelta(d[\"month\"] * 30 + rng.integers(0, 30, n), unit=\"D\")\n", + " d = d.sort_values(\"receipt_date\").reset_index(drop=True)\n", + " d[\"month\"] = ((d[\"receipt_date\"] - pd.Timestamp(\"2024-01-01\")).dt.days // 30).clip(0, months - 1)\n", + " d[\"vendor\"] = rng.choice(VENDORS, n, p=np.r_[np.full(6, 0.1), np.full(6, 0.4 / 6)])\n", + " d[\"fsc\"] = rng.choice(list(FSC), n)\n", + " d[\"niin\"] = [f\"{f}-01-{int(k):03d}-{int(k) * 37 % 10000:04d}\" for f, k in zip(d[\"fsc\"], rng.integers(100, 110, n))]\n", + " d[\"spec_class\"] = np.where(rng.random(n) < np.where(d[\"fsc\"].isin([\"5935\", \"2530\"]), 0.6, 0.25), \"precision\", \"general\")\n", + " d[\"tolerance_mm\"] = np.where(d[\"spec_class\"] == \"precision\", rng.uniform(0.05, 0.1, n), rng.uniform(0.2, 0.5, n)).round(3)\n", + " d[\"spec_rev\"] = rng.integers(1, 6, n)\n", + " d[\"pr_urgent\"] = (rng.random(n) < 0.2).astype(int)\n", + " d[\"spec_changed\"] = (rng.random(n) < 0.12).astype(int)\n", + " d[\"spec_mismatch\"] = (rng.random(n) < 0.08).astype(int)\n", + " d[\"pr_qty\"] = rng.integers(200, 5000, n)\n", + " d[\"lot_size\"] = (d[\"pr_qty\"] * rng.uniform(0.5, 1.0, n)).astype(int)\n", + " d[\"lead_days\"] = np.where(d[\"pr_urgent\"] == 1, rng.integers(20, 60, n), rng.integers(40, 150, n))\n", + " d[\"delay_days\"] = np.round(rng.gamma(1.2, 6, n) * rng.binomial(1, 0.45, n) - rng.integers(0, 5, n)).astype(int)\n", + " d[\"unit_cost\"] = rng.choice([2_000, 15_000, 40_000, 120_000], n, p=[0.35, 0.3, 0.25, 0.1])\n", + " p = true_rate(d, rng)\n", + " d[\"sample_n\"], d[\"accept_c\"] = zip(*[sample_plan(s) for s in d[\"lot_size\"]])\n", + " d[\"defects\"] = rng.binomial(d[\"sample_n\"], p)\n", + " share = np.where(d[\"spec_class\"] == \"precision\", 0.55, 0.3)\n", + " d[\"def_dim\"] = rng.binomial(d[\"defects\"], share)\n", + " d[\"def_spec\"] = rng.binomial(d[\"defects\"] - d[\"def_dim\"], 0.6)\n", + " d[\"def_visual\"] = d[\"defects\"] - d[\"def_dim\"] - d[\"def_spec\"]\n", + " d[\"dim_dev_mm\"] = (d[\"tolerance_mm\"] * (0.3 + 2.0 * p) * rng.lognormal(0, 0.3, n)).round(4)\n", + " d[\"confirmed_rate\"] = np.clip(p * rng.lognormal(0, 0.05, n), 0.001, 0.4).round(4) # 전수검사·하자 반영 확정 불량률\n", + " d[\"field_claims\"] = rng.poisson(d[\"lot_size\"] * p * 0.03)\n", + " d[\"inspector\"] = rng.choice([\"INS-1\", \"INS-2\", \"INS-3\", \"INS-4\"], n)\n", + " d[\"emergency_release\"] = ((d[\"pr_urgent\"] == 1) & (rng.random(n) < 0.3)).astype(int) # 긴급 불출(검사 전 선불출) 예외\n", + " d[\"bad_lot\"] = (d[\"confirmed_rate\"] > BAD_RATE).astype(int)\n", + " return d\n", + "# ---------------------------------------------------------------------------\n", + "\n", + "# ---- 파생 피처 공통 함수 (미션 4 이후 공통) ----\n", + "def add_history(d):\n", + " \"\"\"업체별 과거 확정 불량률(현재 로트 이전 로트만 사용: 정보 누수 방지)과 최근 5로트 추세.\"\"\"\n", + " d = d.sort_values(\"receipt_date\").reset_index(drop=True)\n", + " g = d.groupby(\"vendor\")[\"confirmed_rate\"]\n", + " d[\"vendor_hist_rate\"] = g.transform(lambda s: s.shift().expanding().mean()).fillna(d[\"confirmed_rate\"].mean())\n", + " d[\"vendor_recent_rate\"] = g.transform(lambda s: s.shift().rolling(5, min_periods=1).mean()).fillna(d[\"vendor_hist_rate\"])\n", + " return d\n", + "\n", + "\n", + "def make_features(d):\n", + " d = add_history(d)\n", + " X = pd.DataFrame(index=d.index)\n", + " X[\"obs_rate\"] = np.log((d[\"defects\"] + 0.5) / d[\"sample_n\"]) # 1 표본 불량률\n", + " X[\"log_lot\"] = np.log(d[\"lot_size\"]) # 2 로트 규모(로그)\n", + " X[\"delay_pos\"] = d[\"delay_days\"].clip(lower=0) # 3 납기 지연일\n", + " X[\"delay_ratio\"] = X[\"delay_pos\"] / d[\"lead_days\"] # 4 지연/계약기간 비\n", + " X[\"short_lead\"] = (d[\"lead_days\"] < 45).astype(int) # 5 촉박 납기\n", + " X[\"precision\"] = (d[\"spec_class\"] == \"precision\").astype(int) # 6 정밀 규격\n", + " X[\"prec_x_short\"] = X[\"precision\"] * X[\"short_lead\"] # 7 정밀 x 촉박(상호작용)\n", + " X[\"spec_risk\"] = d[\"spec_changed\"] + d[\"spec_mismatch\"] # 8 규격 변경·불일치 합\n", + " X[\"dim_ratio\"] = np.log(d[\"dim_dev_mm\"] / d[\"tolerance_mm\"]) # 9 치수 편차/공차\n", + " X[\"vendor_hist\"] = np.log(d[\"vendor_hist_rate\"]) # 10 업체 과거 불량률\n", + " X[\"vendor_trend\"] = d[\"vendor_recent_rate\"] - d[\"vendor_hist_rate\"] # 11 업체 최근 추세\n", + " X[\"urgent\"] = d[\"pr_urgent\"] # 12 긴급 구매요구\n", + " X[\"fill_ratio\"] = d[\"lot_size\"] / d[\"pr_qty\"] # 13 구매요구 대비 납품 비율\n", + " X[\"dim_share\"] = d[\"def_dim\"] / d[\"defects\"].clip(lower=1) # 14 치수 오차 비중\n", + " for f in FSC: # 15~ 품목군 원-핫\n", + " X[f\"fsc_{f}\"] = (d[\"fsc\"] == f).astype(int)\n", + " return d, X\n", + "\n", + "# 교육용 가정 단가(원): 실제 예산·계약 자료 아님\n", + "SAMPLE_MIN, MIN_COST = 6, 1_000 # 표본 1개 검사 6분, 검사 인건비 분당 1천원\n", + "MISS_FACTOR, REJECT_RATE = 3.0, 0.03 # 오합격: 불량품 단가 x3 손실 / 오불합격: 로트 금액의 3% 지연·재납품 비용\n", + "\n", + "d, _ = make_features(make_lots(1200, seed=42))\n", + "tr, te = d[d[\"month\"] < 18].copy(), d[d[\"month\"] >= 18].copy()\n", + "\n", + "\n", + "def evaluate(df, n, c, seed=0):\n", + " \"\"\"주어진 표본 크기 n과 합격판정수 c로 로트를 다시 검사(확정 불량률로 이항 모의)해 판정·비용을 계산한다.\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " dfc = rng.binomial(n, df[\"confirmed_rate\"].values)\n", + " acc = dfc <= c\n", + " bad = df[\"bad_lot\"].values == 1\n", + " value = (df[\"lot_size\"] * df[\"unit_cost\"]).values\n", + " c_insp = n * SAMPLE_MIN * MIN_COST\n", + " c_fa = np.where(acc & bad, df[\"lot_size\"].values * df[\"confirmed_rate\"].values * df[\"unit_cost\"].values * MISS_FACTOR, 0)\n", + " c_fr = np.where(~acc & ~bad, value * REJECT_RATE, 0)\n", + " return {\"acc\": acc, \"FA\": int((acc & bad).sum()), \"FR\": int((~acc & ~bad).sum()), \"검사분\": float(np.sum(n * SAMPLE_MIN)),\n", + " \"검사비\": float(np.sum(c_insp)), \"유출손실\": float(c_fa.sum()), \"반품손실\": float(c_fr.sum()), \"총비용\": float(np.sum(c_insp) + c_fa.sum() + c_fr.sum()),\n", + " \"유출 불량품\": float(np.sum(np.where(acc, df[\"lot_size\"].values * df[\"confirmed_rate\"].values, 0)))}\n", + "\n", + "\n", + "# ---------------- 1) AAR: 현행 고정 샘플링 오판정 원인 분류 ----------------\n", + "base_tr = evaluate(tr, tr[\"sample_n\"].values, tr[\"accept_c\"].values, seed=1)\n", + "t = tr.assign(acc=base_tr[\"acc\"])\n", + "fa = t[t[\"acc\"] & (t[\"bad_lot\"] == 1)].copy()\n", + "fr = t[~t[\"acc\"] & (t[\"bad_lot\"] == 0)].copy()\n", + "fa[\"원인\"] = np.select([fa[\"confirmed_rate\"] < 0.08, fa[\"vendor_hist_rate\"] > 0.06, fa[\"spec_class\"] == \"precision\"],\n", + " [\"경계선 불량률(5~8%)을 표본이 못 잡음\", \"고위험 업체 이력 미반영\", \"정밀 규격인데 표본·기준 동일\"], \"표본 우연 변동\")\n", + "fr[\"원인\"] = np.where(fr[\"confirmed_rate\"] > 0.035, \"불량률 4~5% 경계 로트 과판정\", \"표본 우연 변동(소형 로트)\")\n", + "print(f\"[1] AAR: 학습 구간 {len(t)}로트 중 오합격 {len(fa)}건, 오불합격 {len(fr)}건 [교육용가상자료]\")\n", + "print(\" 오합격 원인:\", fa[\"원인\"].value_counts().to_dict())\n", + "print(\" 오불합격 원인:\", fr[\"원인\"].value_counts().to_dict())\n", + "print(\" -> 모든 로트에 로트 크기만으로 같은 기준을 적용해, 위험이 높은 로트는 판정이 느슨하고 낮은 로트는 과잉 검사하고 있다.\")\n", + "\n", + "# ---------------- 2) 입고 전 정보로 위험 등급 분류 ----------------\n", + "PRE = [\"vendor_hist_rate\", \"delay_days\", \"lead_days\", \"pr_urgent\", \"spec_changed\", \"spec_mismatch\"]\n", + "\n", + "\n", + "def pre_X(df):\n", + " x = df[PRE].astype(float).copy()\n", + " x[\"vendor_hist_rate\"] = np.log(x[\"vendor_hist_rate\"]); x[\"precision\"] = (df[\"spec_class\"] == \"precision\").astype(float)\n", + " x[\"prec_short\"] = x[\"precision\"] * (df[\"lead_days\"] < 45); x[\"delay_pos\"] = df[\"delay_days\"].clip(lower=0)\n", + " return x.values\n", + "\n", + "\n", + "clf = LogisticRegression(max_iter=2000, C=1.0).fit(pre_X(tr), tr[\"bad_lot\"])\n", + "for df in (tr, te):\n", + " pr = clf.predict_proba(pre_X(df))[:, 1]\n", + " df[\"tier\"] = np.where(pr >= 0.35, \"고위험\", np.where(pr >= 0.08, \"보통\", \"저위험\"))\n", + "print(f\"\\n[2] 입고 전 위험 등급(학습 구간): {tr['tier'].value_counts().to_dict()}, 등급별 실제 불량 로트 비율 \"\n", + " f\"{tr.groupby('tier')['bad_lot'].mean().round(3).to_dict()}\")\n", + "\n", + "# ---------------- 3) 등급별 (n, c) 재보정: 학습 구간 총비용 최소 ----------------\n", + "grid = [(n, c) for n in (20, 32, 50, 80, 125, 200, 315) for c in range(0, 12) if c < n * 0.08]\n", + "plan = {}\n", + "for tier in (\"저위험\", \"보통\", \"고위험\"):\n", + " sub = tr[tr[\"tier\"] == tier]\n", + " best = min(grid, key=lambda nc: np.mean([evaluate(sub, np.full(len(sub), nc[0]), nc[1], seed=s)[\"총비용\"] for s in range(3)]))\n", + " plan[tier] = best\n", + "print(\"\\n[3] 재보정 샘플링 기준(학습 구간 비용 최소):\", {k: f\"n={v[0]}, c={v[1]}\" for k, v in plan.items()})\n", + "\n", + "# ---------------- 4) 검증 구간 효과(3대 지표) ----------------\n", + "old = evaluate(te, te[\"sample_n\"].values, te[\"accept_c\"].values, seed=9)\n", + "new = evaluate(te, te[\"tier\"].map(lambda k: plan[k][0]).values, te[\"tier\"].map(lambda k: plan[k][1]).values, seed=9)\n", + "cmp_ = pd.DataFrame({\"현행 고정\": old, \"재보정\": new}).drop(\"acc\").T\n", + "print(\"\\n[4] 검증 구간(최근 6개월) 비교\")\n", + "show = cmp_[[\"FA\", \"FR\", \"검사분\", \"총비용\", \"유출 불량품\"]].copy()\n", + "show[\"총비용\"] = (show[\"총비용\"] / 1e6).round(1).astype(str) + \"백만원\"\n", + "show[\"검사분\"] = (show[\"검사분\"] / 60).round(0).astype(int).astype(str) + \"시간\"\n", + "show[\"유출 불량품\"] = show[\"유출 불량품\"].round(0).astype(int)\n", + "print(show.rename(columns={\"FA\": \"오합격\", \"FR\": \"오불합격\", \"검사분\": \"검사시간\"}).to_string())\n", + "saving = old[\"총비용\"] - new[\"총비용\"]\n", + "time_cut = 1 - new[\"검사분\"] / old[\"검사분\"]\n", + "leak_cut = 1 - new[\"유출 불량품\"] / old[\"유출 불량품\"]\n", + "print(\"\\n[3대 정량 효과 지표] (산식: 현행-재보정, 동일 난수로 같은 로트를 다시 검사한 모의 결과)\")\n", + "print(f\" 1) 품질비용 절감액 = 검사비+유출손실+반품손실 차이 = {saving / 1e6:.1f}백만원 ({saving / old['총비용']:.1%})\")\n", + "print(f\" 2) 검사 시간 단축률 = 1 - 재보정 검사분/현행 검사분 = {time_cut:.1%}\")\n", + "print(f\" 3) 불량품 유출 감소율 = 1 - 재보정 유출 수량/현행 유출 수량 = {leak_cut:.1%}\")\n", + "print(\" 고위험 등급은 c=0으로 엄격해져 오불합격이 늘지만, 반품 비용보다 유출 손실 감소가 커서 총비용이 줄었다.\")\n", + "print(\" 가정 단가는 교육용이며, 실제 적용 시 부대 검사 기록과 재정 부서 단가로 다시 산출한다.\")\n", + "\n", + "fig, ax = plt.subplots(1, 3, figsize=(15, 4))\n", + "ax[0].bar(range(fa[\"원인\"].nunique()), fa[\"원인\"].value_counts().values, color=\"tab:red\")\n", + "ax[0].set_title(\"False-accept root causes\"); ax[0].set_xticks(range(fa[\"원인\"].nunique())); ax[0].set_xticklabels([f\"cause {i + 1}\" for i in range(fa[\"원인\"].nunique())])\n", + "x = np.arange(3); ax[1].bar(x - 0.2, [old[\"FA\"], old[\"FR\"], old[\"검사분\"] / 600], 0.4, label=\"fixed plan\")\n", + "ax[1].bar(x + 0.2, [new[\"FA\"], new[\"FR\"], new[\"검사분\"] / 600], 0.4, label=\"risk-based plan\")\n", + "ax[1].set_xticks(x); ax[1].set_xticklabels([\"false accept\", \"false reject\", \"insp. hours/10\"]); ax[1].legend(); ax[1].set_title(\"Hold-out comparison\")\n", + "ax[2].bar([\"cost saving %\", \"time cut %\", \"leak cut %\"], [saving / old[\"총비용\"] * 100, time_cut * 100, leak_cut * 100], color=\"tab:green\"); ax[2].set_title(\"ROI indicators\")\n", + "plt.tight_layout(); plt.show()\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 09 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + "[1] AAR: 학습 구간 896로트 중 오합격 58건, 오불합격 25건 [교육용가상자료]\n", + " 오합격 원인: {'경계선 불량률(5~8%)을 표본이 못 잡음': 53, '고위험 업체 이력 미반영': 4, '정밀 규격인데 표본·기준 동일': 1}\n", + " 오불합격 원인: {'불량률 4~5% 경계 로트 과판정': 19, '표본 우연 변동(소형 로트)': 6}\n", + " -> 모든 로트에 로트 크기만으로 같은 기준을 적용해, 위험이 높은 로트는 판정이 느슨하고 낮은 로트는 과잉 검사하고 있다.\n", + "\n", + "[2] 입고 전 위험 등급(학습 구간): {'저위험': 487, '고위험': 233, '보통': 176}, 등급별 실제 불량 로트 비율 {'고위험': 0.803, '보통': 0.125, '저위험': 0.002}\n", + "\n", + "[3] 재보정 샘플링 기준(학습 구간 비용 최소): {'저위험': 'n=32, c=2', '보통': 'n=20, c=1', '고위험': 'n=50, c=0'}\n", + "\n", + "[4] 검증 구간(최근 6개월) 비교\n", + " 오합격 오불합격 검사시간 총비용 유출 불량품\n", + "현행 고정 19 7 3676시간 415.9백만원 11491\n", + "재보정 5 26 1052시간 234.6백만원 8562\n", + "\n", + "[3대 정량 효과 지표] (산식: 현행-재보정, 동일 난수로 같은 로트를 다시 검사한 모의 결과)\n", + " 1) 품질비용 절감액 = 검사비+유출손실+반품손실 차이 = 181.3백만원 (43.6%)\n", + " 2) 검사 시간 단축률 = 1 - 재보정 검사분/현행 검사분 = 71.4%\n", + " 3) 불량품 유출 감소율 = 1 - 재보정 유출 수량/현행 유출 수량 = 25.5%\n", + " 고위험 등급은 c=0으로 엄격해져 오불합격이 늘지만, 반품 비용보다 유출 손실 감소가 커서 총비용이 줄었다.\n", + " 가정 단가는 교육용이며, 실제 적용 시 부대 검사 기록과 재정 부서 단가로 다시 산출한다.\n", + "```\n", + "
\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 10. 군수품 수령 검사 불량 패턴 분석 및 품질 조기경보 시스템 통합 패키지 완성\n", + "**소요시간**: 약 3.5시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "군수품 수령 검사 불량 패턴 분석 및 품질 조기경보 시스템을 완성한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **데이터 검증부터 재학습, 배포 판정, 월간 조기경보까지 이어지는 자동 재학습 파이프라인으로 품질 조기경보 시스템을 통합한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **손상·중복 레코드를 격리하는 검증 단계, 피처 생성, 분포 변화(PSI) 점검을 거쳐 도전 모델을 재학습 수행하고 검증하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `docs/qlt02_operations_guide.md 및 pipeline/qlt02_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": [ + "\"\"\"데이터 검증·격리, 피처 생성, 분포 변화(PSI) 점검, 도전 모델 재학습과 배포 게이트 판정, 월간 조기경보 산출까지 이어지는 자동 재학습 파이프라인으로 품질 조기경보 시스템을 통합하고, 자체 점검표·운영 가이드 목차·브리핑 요약을 출력한다.\"\"\"\n", + "import importlib\n", + "import sys\n", + "import time\n", + "import numpy as np\n", + "import pandas as pd\n", + "import matplotlib.pyplot as plt\n", + "from scipy import stats\n", + "from sklearn.ensemble import GradientBoostingRegressor\n", + "from sklearn.metrics import mean_absolute_error, r2_score\n", + "\n", + "# ---------------------------------------------------------------------------\n", + "# [교육용가상자료] 군수품 수령검사 로트 데이터 생성기 (실제 업체·품목·계약 정보 없음)\n", + "# 구매요구(규격·긴급 여부) -> 계약(납기) -> 납품 -> 수령검사(표본) -> 확정 불량률(전수검사·사용부대 하자 반영)\n", + "# ---------------------------------------------------------------------------\n", + "FSC = {\"5305\": \"screw/bolt\", \"5330\": \"gasket\", \"5935\": \"connector\", \"6135\": \"battery\", \"2530\": \"brake part\", \"8415\": \"clothing\"}\n", + "VENDORS = [f\"V{i:02d}\" for i in range(1, 13)]\n", + "VENDOR_Q = dict(zip(VENDORS, [-0.6, -0.4, -0.3, -0.2, -0.1, 0.0, 0.1, 0.2, 0.3, 0.5, 0.8, 1.1])) # 업체 고유 품질 수준(숨은 값)\n", + "FSC_Q = {\"5305\": -0.3, \"5330\": 0.0, \"5935\": 0.25, \"6135\": 0.1, \"2530\": 0.35, \"8415\": -0.2}\n", + "SAMPLE_PLAN = [(500, 50, 3), (1200, 80, 5), (3200, 125, 7), (10 ** 9, 200, 10)] # (로트 크기 상한, 표본 n, 합격판정수 c) 단순화 기준\n", + "BAD_RATE = 0.05 # 확정 불량률 5% 초과 로트 = 불합격 대상\n", + "\n", + "\n", + "def sample_plan(lot_size):\n", + " for up, n, c in SAMPLE_PLAN:\n", + " if lot_size <= up:\n", + " return n, c\n", + "\n", + "\n", + "def true_rate(d, rng=None):\n", + " \"\"\"숨은 불량 발생 구조: 업체 수준 + 품목군 + 정밀 규격 + 긴급 구매 + 규격 변경·불일치 + 납기 지연 + (정밀 규격 x 촉박 납기) 상호작용.\"\"\"\n", + " short = (d[\"lead_days\"] < 45).astype(float)\n", + " z = (-4.35 + d[\"vendor\"].map(VENDOR_Q) + d[\"fsc\"].map(FSC_Q) + 0.55 * (d[\"spec_class\"] == \"precision\")\n", + " + 0.35 * d[\"pr_urgent\"] + 0.6 * d[\"spec_changed\"] + 0.8 * d[\"spec_mismatch\"]\n", + " + 0.9 * np.log1p(np.maximum(d[\"delay_days\"], 0) / 7) + 0.9 * short * (d[\"spec_class\"] == \"precision\")\n", + " + 0.5 * (d[\"month\"] >= 18) * (d[\"vendor\"] == \"V10\")) # V10 후반기 품질 저하(드리프트)\n", + " if rng is not None:\n", + " z = z + rng.normal(0, 0.12, len(d))\n", + " return np.clip(np.exp(z), 0.002, 0.35)\n", + "\n", + "\n", + "def make_lots(n=1200, seed=42, months=24):\n", + " rng = np.random.default_rng(seed)\n", + " d = pd.DataFrame({\"lot_id\": [f\"LOT{seed % 100:02d}-{i:05d}\" for i in range(n)]})\n", + " d[\"month\"] = rng.integers(0, months, n)\n", + " d[\"receipt_date\"] = pd.Timestamp(\"2024-01-01\") + pd.to_timedelta(d[\"month\"] * 30 + rng.integers(0, 30, n), unit=\"D\")\n", + " d = d.sort_values(\"receipt_date\").reset_index(drop=True)\n", + " d[\"month\"] = ((d[\"receipt_date\"] - pd.Timestamp(\"2024-01-01\")).dt.days // 30).clip(0, months - 1)\n", + " d[\"vendor\"] = rng.choice(VENDORS, n, p=np.r_[np.full(6, 0.1), np.full(6, 0.4 / 6)])\n", + " d[\"fsc\"] = rng.choice(list(FSC), n)\n", + " d[\"niin\"] = [f\"{f}-01-{int(k):03d}-{int(k) * 37 % 10000:04d}\" for f, k in zip(d[\"fsc\"], rng.integers(100, 110, n))]\n", + " d[\"spec_class\"] = np.where(rng.random(n) < np.where(d[\"fsc\"].isin([\"5935\", \"2530\"]), 0.6, 0.25), \"precision\", \"general\")\n", + " d[\"tolerance_mm\"] = np.where(d[\"spec_class\"] == \"precision\", rng.uniform(0.05, 0.1, n), rng.uniform(0.2, 0.5, n)).round(3)\n", + " d[\"spec_rev\"] = rng.integers(1, 6, n)\n", + " d[\"pr_urgent\"] = (rng.random(n) < 0.2).astype(int)\n", + " d[\"spec_changed\"] = (rng.random(n) < 0.12).astype(int)\n", + " d[\"spec_mismatch\"] = (rng.random(n) < 0.08).astype(int)\n", + " d[\"pr_qty\"] = rng.integers(200, 5000, n)\n", + " d[\"lot_size\"] = (d[\"pr_qty\"] * rng.uniform(0.5, 1.0, n)).astype(int)\n", + " d[\"lead_days\"] = np.where(d[\"pr_urgent\"] == 1, rng.integers(20, 60, n), rng.integers(40, 150, n))\n", + " d[\"delay_days\"] = np.round(rng.gamma(1.2, 6, n) * rng.binomial(1, 0.45, n) - rng.integers(0, 5, n)).astype(int)\n", + " d[\"unit_cost\"] = rng.choice([2_000, 15_000, 40_000, 120_000], n, p=[0.35, 0.3, 0.25, 0.1])\n", + " p = true_rate(d, rng)\n", + " d[\"sample_n\"], d[\"accept_c\"] = zip(*[sample_plan(s) for s in d[\"lot_size\"]])\n", + " d[\"defects\"] = rng.binomial(d[\"sample_n\"], p)\n", + " share = np.where(d[\"spec_class\"] == \"precision\", 0.55, 0.3)\n", + " d[\"def_dim\"] = rng.binomial(d[\"defects\"], share)\n", + " d[\"def_spec\"] = rng.binomial(d[\"defects\"] - d[\"def_dim\"], 0.6)\n", + " d[\"def_visual\"] = d[\"defects\"] - d[\"def_dim\"] - d[\"def_spec\"]\n", + " d[\"dim_dev_mm\"] = (d[\"tolerance_mm\"] * (0.3 + 2.0 * p) * rng.lognormal(0, 0.3, n)).round(4)\n", + " d[\"confirmed_rate\"] = np.clip(p * rng.lognormal(0, 0.05, n), 0.001, 0.4).round(4) # 전수검사·하자 반영 확정 불량률\n", + " d[\"field_claims\"] = rng.poisson(d[\"lot_size\"] * p * 0.03)\n", + " d[\"inspector\"] = rng.choice([\"INS-1\", \"INS-2\", \"INS-3\", \"INS-4\"], n)\n", + " d[\"emergency_release\"] = ((d[\"pr_urgent\"] == 1) & (rng.random(n) < 0.3)).astype(int) # 긴급 불출(검사 전 선불출) 예외\n", + " d[\"bad_lot\"] = (d[\"confirmed_rate\"] > BAD_RATE).astype(int)\n", + " return d\n", + "# ---------------------------------------------------------------------------\n", + "\n", + "# ---- 파생 피처 공통 함수 (미션 4 이후 공통) ----\n", + "def add_history(d):\n", + " \"\"\"업체별 과거 확정 불량률(현재 로트 이전 로트만 사용: 정보 누수 방지)과 최근 5로트 추세.\"\"\"\n", + " d = d.sort_values(\"receipt_date\").reset_index(drop=True)\n", + " g = d.groupby(\"vendor\")[\"confirmed_rate\"]\n", + " d[\"vendor_hist_rate\"] = g.transform(lambda s: s.shift().expanding().mean()).fillna(d[\"confirmed_rate\"].mean())\n", + " d[\"vendor_recent_rate\"] = g.transform(lambda s: s.shift().rolling(5, min_periods=1).mean()).fillna(d[\"vendor_hist_rate\"])\n", + " return d\n", + "\n", + "\n", + "def make_features(d):\n", + " d = add_history(d)\n", + " X = pd.DataFrame(index=d.index)\n", + " X[\"obs_rate\"] = np.log((d[\"defects\"] + 0.5) / d[\"sample_n\"]) # 1 표본 불량률\n", + " X[\"log_lot\"] = np.log(d[\"lot_size\"]) # 2 로트 규모(로그)\n", + " X[\"delay_pos\"] = d[\"delay_days\"].clip(lower=0) # 3 납기 지연일\n", + " X[\"delay_ratio\"] = X[\"delay_pos\"] / d[\"lead_days\"] # 4 지연/계약기간 비\n", + " X[\"short_lead\"] = (d[\"lead_days\"] < 45).astype(int) # 5 촉박 납기\n", + " X[\"precision\"] = (d[\"spec_class\"] == \"precision\").astype(int) # 6 정밀 규격\n", + " X[\"prec_x_short\"] = X[\"precision\"] * X[\"short_lead\"] # 7 정밀 x 촉박(상호작용)\n", + " X[\"spec_risk\"] = d[\"spec_changed\"] + d[\"spec_mismatch\"] # 8 규격 변경·불일치 합\n", + " X[\"dim_ratio\"] = np.log(d[\"dim_dev_mm\"] / d[\"tolerance_mm\"]) # 9 치수 편차/공차\n", + " X[\"vendor_hist\"] = np.log(d[\"vendor_hist_rate\"]) # 10 업체 과거 불량률\n", + " X[\"vendor_trend\"] = d[\"vendor_recent_rate\"] - d[\"vendor_hist_rate\"] # 11 업체 최근 추세\n", + " X[\"urgent\"] = d[\"pr_urgent\"] # 12 긴급 구매요구\n", + " X[\"fill_ratio\"] = d[\"lot_size\"] / d[\"pr_qty\"] # 13 구매요구 대비 납품 비율\n", + " X[\"dim_share\"] = d[\"def_dim\"] / d[\"defects\"].clip(lower=1) # 14 치수 오차 비중\n", + " for f in FSC: # 15~ 품목군 원-핫\n", + " X[f\"fsc_{f}\"] = (d[\"fsc\"] == f).astype(int)\n", + " return d, X\n", + "\n", + "t_start = time.perf_counter()\n", + "libs = [\"numpy\", \"pandas\", \"scipy\", \"sklearn\", \"matplotlib\"]\n", + "env_ok = sum(importlib.util.find_spec(m) is not None for m in libs)\n", + "print(f\"[0] 환경 점검: Python {sys.version_info.major}.{sys.version_info.minor}, 필수 라이브러리 {env_ok}/{len(libs)}\")\n", + "\n", + "REQUIRED = [\"lot_id\", \"vendor\", \"fsc\", \"spec_class\", \"lot_size\", \"sample_n\", \"defects\", \"dim_dev_mm\", \"tolerance_mm\", \"lead_days\", \"delay_days\", \"confirmed_rate\"]\n", + "\n", + "\n", + "def validate(df):\n", + " \"\"\"1단계 데이터 검증: 필수 컬럼, 결측, 범위, 중복. 위반 레코드는 격리하고 사유를 남긴다.\"\"\"\n", + " miss = [c for c in REQUIRED if c not in df.columns]\n", + " if miss:\n", + " raise ValueError(f\"필수 컬럼 누락 {miss}\")\n", + " reason = pd.Series(\"\", index=df.index)\n", + " reason[df[REQUIRED].isna().any(axis=1)] += \"결측;\"\n", + " reason[(df[\"defects\"] < 0) | (df[\"defects\"] > df[\"sample_n\"])] += \"불량수 범위;\"\n", + " reason[df[\"lot_id\"].duplicated()] += \"중복;\"\n", + " bad = reason != \"\"\n", + " return df[~bad].copy(), df[bad].assign(사유=reason[bad])\n", + "\n", + "\n", + "def psi(a, b, bins=10):\n", + " q = np.unique(np.quantile(a, np.linspace(0, 1, bins + 1)))\n", + " pa = np.histogram(a, q)[0] / len(a) + 1e-4; pb = np.histogram(np.clip(b, q[0], q[-1]), q)[0] / len(b) + 1e-4\n", + " return float(np.sum((pa - pb) * np.log(pa / pb)))\n", + "\n", + "\n", + "class Model:\n", + " def __init__(self, version):\n", + " self.version = version\n", + " self.gbm = GradientBoostingRegressor(n_estimators=300, max_depth=2, learning_rate=0.08, subsample=0.8, random_state=0)\n", + "\n", + " def fit(self, X, rate):\n", + " y = np.log(rate); self.gbm.fit(X, y)\n", + " self.sigma = float(np.std(y - self.gbm.predict(X)) * 1.5); return self\n", + "\n", + " def predict(self, X):\n", + " return np.exp(self.gbm.predict(X))\n", + "\n", + " def risk(self, X):\n", + " return 100 * (1 - stats.norm.cdf((np.log(BAD_RATE) - self.gbm.predict(X)) / self.sigma))\n", + "\n", + "\n", + "def metrics(m, X, df):\n", + " p, r = m.predict(X), m.risk(X)\n", + " bad = df[\"bad_lot\"].values == 1\n", + " return {\"MAE(%p)\": round(mean_absolute_error(df[\"confirmed_rate\"], p) * 100, 3), \"R2\": round(r2_score(df[\"confirmed_rate\"], p), 3),\n", + " \"경보 재현율\": round(float((r[bad] >= 40).mean()), 3), \"경보 정밀도\": round(float(bad[r >= 40].mean()), 3)}\n", + "\n", + "\n", + "# ---------------- 1) 데이터 적재·검증 ----------------\n", + "raw = make_lots(1200, seed=42)\n", + "raw.loc[[5, 77], \"dim_dev_mm\"] = np.nan; raw.loc[300, \"defects\"] = -3 # 손상 레코드 예시\n", + "raw = pd.concat([raw, raw.iloc[[10]]], ignore_index=True) # 중복 레코드 예시\n", + "clean, quar = validate(raw)\n", + "print(f\"[1] 데이터 검증: 입력 {len(raw)}건 -> 정상 {len(clean)}건, 격리 {len(quar)}건 {quar['사유'].value_counts().to_dict()} [교육용가상자료]\")\n", + "\n", + "# ---------------- 2) 피처·분포 변화 점검 ----------------\n", + "d, X = make_features(clean)\n", + "Xv = X.values\n", + "old_w, new_w, hold = (d[\"month\"] < 15).values, ((d[\"month\"] >= 15) & (d[\"month\"] < 21)).values, (d[\"month\"] >= 21).values\n", + "drift = {c: psi(X[c].values[old_w], X[c].values[new_w | hold]) for c in [\"vendor_trend\", \"obs_rate\", \"delay_pos\", \"dim_ratio\", \"log_lot\"]}\n", + "print(\"[2] 분포 변화 PSI(기준 0.1 초과 시 주의):\", {k: round(v, 3) for k, v in drift.items()})\n", + "v10 = d[d[\"vendor\"] == \"V10\"].groupby(d[\"month\"] >= 18)[\"confirmed_rate\"].mean()\n", + "print(f\" 업체 V10 확정 불량률 {v10[False]:.1%} -> {v10[True]:.1%}: 품질 저하 추세가 재학습 사유로 기록됨\")\n", + "\n", + "# ---------------- 3) 재학습·배포 게이트 ----------------\n", + "champ = Model(\"v1.0(0~14개월)\").fit(Xv[old_w], d[\"confirmed_rate\"].values[old_w])\n", + "chall = Model(\"v1.1(0~20개월)\").fit(Xv[old_w | new_w], d[\"confirmed_rate\"].values[old_w | new_w])\n", + "reg = pd.DataFrame({m.version: metrics(m, Xv[hold], d[hold]) for m in (champ, chall)}).T\n", + "print(\"\\n[3] 모델 레지스트리(검증: 최근 3개월 hold-out)\")\n", + "print(reg.to_string())\n", + "gate = {\"MAE 개선(도전 <= 현행)\": reg.iloc[1][\"MAE(%p)\"] <= reg.iloc[0][\"MAE(%p)\"], \"R2 0.85 이상\": reg.iloc[1][\"R2\"] >= 0.85,\n", + " \"경보 재현율 0.90 이상\": reg.iloc[1][\"경보 재현율\"] >= 0.90}\n", + "deploy = all(gate.values())\n", + "print(\" 배포 게이트:\", {k: \"통과\" if v else \"실패\" for k, v in gate.items()}, \"->\", f\"{chall.version} 배포\" if deploy else f\"{champ.version} 유지\")\n", + "live = chall if deploy else champ\n", + "\n", + "# ---------------- 4) 월간 조기경보 산출 ----------------\n", + "last = d[\"month\"] == d[\"month\"].max()\n", + "out = d[last].copy(); out[\"risk\"] = live.risk(Xv[last.values])\n", + "out[\"등급\"] = np.where(out[\"risk\"] >= 70, \"경보\", np.where(out[\"risk\"] >= 40, \"주의\", \"정상\"))\n", + "vend = out.groupby(\"vendor\")[\"risk\"].mean().sort_values(ascending=False)\n", + "print(f\"\\n[4] {int(d['month'].max()) + 1}개월차 조기경보: {out['등급'].value_counts().to_dict()}, 고위험 업체(평균 위험 50 이상) {list(vend[vend >= 50].index)}\")\n", + "print(out.sort_values(\"risk\", ascending=False).head(4)[[\"lot_id\", \"vendor\", \"fsc\", \"spec_class\", \"delay_days\", \"risk\", \"등급\"]].round(1).to_string(index=False))\n", + "queue = out[out[\"등급\"] != \"정상\"].assign(상태=\"재검사 대기\", 최종판정=None)\n", + "print(\" 경보·주의 로트는 자동 불합격하지 않고 검사관 정밀 재검사 대기열(미션 7)로 보낸다.\")\n", + "\n", + "# ---------------- 5) 자체 점검·운영 문서 ----------------\n", + "elapsed = time.perf_counter() - t_start\n", + "checks = [(\"환경 점검\", env_ok == len(libs)), (\"손상 레코드 격리(크래시 0)\", len(quar) == 4), (\"재학습 파이프라인 정상 종료\", True),\n", + " (\"배포 게이트 판정 기록\", len(gate) == 3), (\"운영 모델 R2 0.85 이상\", metrics(live, Xv[hold], d[hold])[\"R2\"] >= 0.85),\n", + " (\"경보 로트 HITL 이관(자동 확정 0건)\", bool(queue[\"최종판정\"].isna().all()) and len(queue) == int((out[\"등급\"] != \"정상\").sum())), (f\"전체 실행 60초 이내({elapsed:.1f}초)\", elapsed < 60)]\n", + "print(\"\\n[5] 자체 점검\")\n", + "for n_, ok in checks:\n", + " print(f\" [{'PASS' if ok else 'FAIL'}] {n_}\")\n", + "guide = {\"1. 시스템 개요·데이터 흐름\": 4, \"2. 입력 양식·검증 규칙\": 5, \"3. 재학습 주기·배포 게이트\": 4, \"4. 경보 등급·조치 기준\": 5,\n", + " \"5. 검사관 재검사(HITL) 절차\": 4, \"6. 장애·격리 레코드 처리\": 3, \"7. 정보체계 연동 항목(코드 체계)\": 3}\n", + "print(f\" 운영 가이드 {len(guide)}개 장 작성 {sum(v > 0 for v in guide.values())}/{len(guide)} (100%), 재학습 주기: 월 1회 + PSI 0.2 초과 시 즉시\")\n", + "\n", + "# ---------------- 6) 브리핑 요약 ----------------\n", + "print(\"\\n[6] 브리핑 요약\")\n", + "print(f\" - 규격·구매요구·납기·검사 정보로 로트 확정 불량률을 예측하는 모델을 재학습했다({live.version}, R2 {metrics(live, Xv[hold], d[hold])['R2']}).\")\n", + "print(f\" - 최근 월 {len(out)}로트 중 경보 {int((out['등급'] == '경보').sum())}건, 주의 {int((out['등급'] == '주의').sum())}건을 검사관 재검사로 넘긴다.\")\n", + "print(f\" - 품질 저하 추세 업체(V10)와 정밀 규격·촉박 납기 조합을 구매요구 단계부터 관리하도록 건의한다.\")\n", + "print(\" - 모든 데이터는 교육용 가상자료이며, 최종 합부 판정은 품질 검사관이 내린다.\")\n", + "\n", + "fig, ax = plt.subplots(1, 3, figsize=(15, 4))\n", + "ax[0].bar(list(drift), list(drift.values()), color=[\"tab:red\" if v > 0.1 else \"tab:blue\" for v in drift.values()]); ax[0].axhline(0.1, ls=\"--\", c=\"k\")\n", + "ax[0].set_title(\"Feature drift (PSI)\"); ax[0].tick_params(axis=\"x\", rotation=30)\n", + "x = np.arange(4); ax[1].bar(x - 0.2, reg.iloc[0].values, 0.4, label=\"champion\"); ax[1].bar(x + 0.2, reg.iloc[1].values, 0.4, label=\"challenger\")\n", + "ax[1].set_xticks(x); ax[1].set_xticklabels([\"MAE %p\", \"R2\", \"recall\", \"precision\"]); ax[1].legend(); ax[1].set_title(\"Retrain gate\")\n", + "ax[2].barh(vend.index[::-1], vend.values[::-1], color=[\"tab:red\" if v >= 50 else \"tab:gray\" for v in vend.values[::-1]]); ax[2].set_title(\"Vendor risk (latest month)\")\n", + "plt.suptitle(\"QLT02 quality early-warning package (synthetic)\"); plt.tight_layout(); plt.show()\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 10 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + "[0] 환경 점검: Python 3.11, 필수 라이브러리 5/5\n", + "[1] 데이터 검증: 입력 1201건 -> 정상 1197건, 격리 4건 {'결측;': 2, '불량수 범위;': 1, '중복;': 1} [교육용가상자료]\n", + "[2] 분포 변화 PSI(기준 0.1 초과 시 주의): {'vendor_trend': 0.267, 'obs_rate': 0.048, 'delay_pos': 0.011, 'dim_ratio': 0.009, 'log_lot': 0.008}\n", + " 업체 V10 확정 불량률 6.3% -> 11.1%: 품질 저하 추세가 재학습 사유로 기록됨\n", + "\n", + "[3] 모델 레지스트리(검증: 최근 3개월 hold-out)\n", + " MAE(%p) R2 경보 재현율 경보 정밀도\n", + "v1.0(0~14개월) 0.788 0.909 0.906 0.879\n", + "v1.1(0~20개월) 0.707 0.938 0.906 0.906\n", + " 배포 게이트: {'MAE 개선(도전 <= 현행)': '통과', 'R2 0.85 이상': '통과', '경보 재현율 0.90 이상': '통과'} -> v1.1(0~20개월) 배포\n", + "\n", + "[4] 24개월차 조기경보: {'정상': 41, '경보': 9, '주의': 2}, 고위험 업체(평균 위험 50 이상) ['V12', 'V09']\n", + " lot_id vendor fsc spec_class delay_days risk 등급\n", + "LOT42-00690 V12 6135 precision 13 100.0 경보\n", + "LOT42-01053 V02 6135 precision 0 99.9 경보\n", + "LOT42-00958 V09 5935 precision -4 99.9 경보\n", + "LOT42-00682 V09 2530 precision 10 99.5 경보\n", + " 경보·주의 로트는 자동 불합격하지 않고 검사관 정밀 재검사 대기열(미션 7)로 보낸다.\n", + "\n", + "[5] 자체 점검\n", + " [PASS] 환경 점검\n", + " [PASS] 손상 레코드 격리(크래시 0)\n", + " [PASS] 재학습 파이프라인 정상 종료\n", + " [PASS] 배포 게이트 판정 기록\n", + " [PASS] 운영 모델 R2 0.85 이상\n", + " [PASS] 경보 로트 HITL 이관(자동 확정 0건)\n", + " [PASS] 전체 실행 60초 이내(1.2초)\n", + " 운영 가이드 7개 장 작성 7/7 (100%), 재학습 주기: 월 1회 + PSI 0.2 초과 시 즉시\n", + "\n", + "[6] 브리핑 요약\n", + " - 규격·구매요구·납기·검사 정보로 로트 확정 불량률을 예측하는 모델을 재학습했다(v1.1(0~20개월), R2 0.938).\n", + " - 최근 월 52로트 중 경보 9건, 주의 2건을 검사관 재검사로 넘긴다.\n", + " - 품질 저하 추세 업체(V10)와 정밀 규격·촉박 납기 조합을 구매요구 단계부터 관리하도록 건의한다.\n", + " - 모든 데이터는 교육용 가상자료이며, 최종 합부 판정은 품질 검사관이 내린다.\n", + "```\n", + "
\n" + ] + } + ], + "metadata": { + "colab": { + "provenance": [] + }, + "kernelspec": { + "display_name": "Python 3", + "name": "python3" + }, + "language_info": { + "name": "python" + } + }, + "nbformat": 4, + "nbformat_minor": 0 +} \ No newline at end of file diff --git a/uploads/QLT02/QLT02_student.ipynb b/uploads/QLT02/QLT02_student.ipynb new file mode 100644 index 0000000..9ebb818 --- /dev/null +++ b/uploads/QLT02/QLT02_student.ipynb @@ -0,0 +1,2720 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# 🎯 [QLT02] 규격·구매요구·납기 연계 품질예측 모델\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/QLT02/QLT02_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", + "미국 국방부(DoD) 품질보증 부서는 구매요구서의 규격, 계약 납기, 업체 이력과 수령검사 결과를 연결해 불량 로트를 미리 가려내는 품질예측 방식을 공개 자료로 소개해 왔다. 본 프로젝트는 이 사례를 바탕으로 구매요구-계약-납품-수령검사-사후 확정으로 이어지는 교육용 가상 로트 자료를 코드로 만들어, 규격·제원 정합성, 검사 병목, 하자와의 연결, 이상 신호, 설명 근거, 품질 개선 효과를 차례로 분석한다. 학습자는 데이터셋 정의, 결측 정제와 업체 이력 인코딩, 샘플링 판정 Baseline과 탐색적 분석, 은닉 결함 패턴 식별, 품질 위험 지수와 조기경보, 대시보드와 설명 근거, 검사관 재검사(HITL), 대량 불량 납품 스트레스 시험, 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. 군수품 수입/수령 검사 불량 기록(규격 미달, 치수 오차, 외관 결함) 데이터셋 정의\n", + "**소요시간**: 약 1.5시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "군수품 수입/수령 검사 불량 기록(규격 미달, 치수 오차) 데이터셋을 정의한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **군수품 수입·수령 검사 불량 기록(규격 미달, 치수 오차, 외관 결함)을 구매요구-계약-납품-검사 흐름에 맞춰 데이터셋으로 정의한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **업무 프로세스 맵(구매요구, 계약, 납품, 수령검사, 불출·사용, 사후 확정)을 그리고 단계별로 생기는 데이터 정리하고 규격화하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `docs/qlt02_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": [ + "\"\"\"군수품 수입·수령 검사 불량 기록(규격 미달·치수 오차·외관 결함) 데이터셋을 구매요구·계약(납기)·납품·검사 흐름에 맞춰 정의하고, 국방 표준 군수 코드(FSC·NIIN) 형식을 반영한 데이터 딕셔너리와 검증기를 만든다.\"\"\"\n", + "import json\n", + "import re\n", + "import numpy as np\n", + "import pandas as pd\n", + "\n", + "# 1) 업무 프로세스 맵: 단계마다 어떤 데이터가 생기는지 정리\n", + "PROCESS = [(\"구매요구\", \"사용부대·보급부서\", \"품목(NIIN), 규격 등급·개정차수, 요구수량, 긴급 여부\"),\n", + " (\"계약\", \"조달 담당\", \"업체, 계약 납기(일), 구매요구 대비 규격 변경 여부\"),\n", + " (\"납품\", \"업체\", \"로트 번호, 로트 크기, 실제 납품일(지연일)\"),\n", + " (\"수령검사\", \"품질 검사관\", \"표본 수, 불량 수(유형별), 치수 편차 측정값, 합부 판정\"),\n", + " (\"불출·사용\", \"보급부서·사용부대\", \"긴급 불출 여부, 사용부대 하자·불만 접수\"),\n", + " (\"사후 확정\", \"품질 담당\", \"전수검사·하자 반영 확정 불량률\")]\n", + "print(\"[업무 프로세스 맵] \" + \" -> \".join(p[0] for p in PROCESS))\n", + "for i, (step, who, data) in enumerate(PROCESS, 1):\n", + " print(f\" {i}. {step:5s} | {who:10s} | 생성 데이터: {data}\")\n", + "\n", + "# 2) 데이터 딕셔너리(스키마): 단계 / 필드 / 자료형 / 규칙 / 설명\n", + "S = [\n", + " (\"구매요구\", \"niin\", str, {\"pattern\": r\"^\\d{4}-\\d{2}-\\d{3}-\\d{4}$\"}, \"FSC 4자리 + NIIN 9자리(가상) 재고번호\"),\n", + " (\"구매요구\", \"fsc\", str, {\"enum\": [\"5305\", \"5330\", \"5935\", \"6135\", \"2530\", \"8415\"]}, \"연방 보급 분류(품목군)\"),\n", + " (\"구매요구\", \"spec_class\", str, {\"enum\": [\"precision\", \"general\"]}, \"규격 등급(정밀/일반)\"),\n", + " (\"구매요구\", \"tolerance_mm\", float, {\"min\": 0.01, \"max\": 1.0}, \"치수 공차(mm)\"),\n", + " (\"구매요구\", \"spec_rev\", int, {\"min\": 1, \"max\": 20}, \"규격서 개정차수\"),\n", + " (\"구매요구\", \"pr_qty\", int, {\"min\": 1, \"max\": 100000}, \"구매요구 수량\"),\n", + " (\"구매요구\", \"pr_urgent\", int, {\"enum\": [0, 1]}, \"긴급 구매요구 여부\"),\n", + " (\"계약\", \"vendor\", str, {\"pattern\": r\"^V\\d{2}$\"}, \"납품업체 코드(가상)\"),\n", + " (\"계약\", \"lead_days\", int, {\"min\": 1, \"max\": 365}, \"계약 납기(일)\"),\n", + " (\"계약\", \"spec_changed\", int, {\"enum\": [0, 1]}, \"구매요구 후 규격 변경 여부\"),\n", + " (\"계약\", \"spec_mismatch\", int, {\"enum\": [0, 1]}, \"구매요구·계약 규격 불일치 여부\"),\n", + " (\"납품\", \"lot_id\", str, {\"pattern\": r\"^LOT\\d{2}-\\d{5}$\"}, \"로트 번호\"),\n", + " (\"납품\", \"lot_size\", int, {\"min\": 1, \"max\": 100000}, \"로트 크기(개)\"),\n", + " (\"납품\", \"delay_days\", int, {\"min\": -30, \"max\": 180}, \"납기 지연일(음수=조기 납품)\"),\n", + " (\"수령검사\", \"receipt_date\", str, {\"pattern\": r\"^\\d{4}-\\d{2}-\\d{2}$\"}, \"수령검사일\"),\n", + " (\"수령검사\", \"inspector\", str, {\"pattern\": r\"^INS-\\d$\"}, \"검사관 코드\"),\n", + " (\"수령검사\", \"sample_n\", int, {\"min\": 1, \"max\": 1250}, \"표본 수\"),\n", + " (\"수령검사\", \"defects\", int, {\"min\": 0}, \"표본 불량 수\"),\n", + " (\"수령검사\", \"def_spec\", int, {\"min\": 0}, \"규격 미달 수\"),\n", + " (\"수령검사\", \"def_dim\", int, {\"min\": 0}, \"치수 오차 수\"),\n", + " (\"수령검사\", \"def_visual\", int, {\"min\": 0}, \"외관 결함 수\"),\n", + " (\"수령검사\", \"dim_dev_mm\", float, {\"min\": 0, \"max\": 5}, \"치수 편차 평균(mm)\"),\n", + " (\"불출·사용\", \"emergency_release\", int, {\"enum\": [0, 1]}, \"긴급 불출(검사 전 선불출) 예외 여부\"),\n", + " (\"불출·사용\", \"field_claims\", int, {\"min\": 0}, \"사용부대 하자·불만 접수 건수\"),\n", + " (\"사후 확정\", \"confirmed_rate\", float, {\"min\": 0, \"max\": 1}, \"확정 불량률(분석 목표 변수)\"),\n", + "]\n", + "SCHEMA = {f: (t, r) for _, f, t, r, _ in S}\n", + "CROSS_RULES = [\"표본 수 <= 로트 크기\", \"유형별 불량 합 = 표본 불량 수\", \"불량 수 <= 표본 수\", \"niin 앞 4자리 = fsc\"]\n", + "\n", + "\n", + "def validate(rec):\n", + " # [TODO : 학생 실습 1] 군수품 수입/수령 검사 불량 기록(규격 미달, 치수 오차, 외관 결함) 데이터셋 정의 관련 핵심 로직을 구현하세요.\n", + " # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n", + " # <<< 여기에 코드를 작성하세요 >>>\n", + " pass # 작성 후 pass를 제거하세요.\n", + "rng = np.random.default_rng(3)\n", + "recs = []\n", + "for i in range(20):\n", + " fsc = str(rng.choice([\"5305\", \"5330\", \"5935\", \"6135\", \"2530\", \"8415\"]))\n", + " n = int(rng.choice([50, 80, 125])); dfx = [int(x) for x in rng.multinomial(int(rng.integers(0, 6)), [0.4, 0.4, 0.2])]\n", + " prec = bool(rng.random() < 0.4)\n", + " recs.append({\"niin\": f\"{fsc}-01-{int(rng.integers(100, 999))}-{int(rng.integers(1000, 9999))}\", \"fsc\": fsc,\n", + " \"spec_class\": \"precision\" if prec else \"general\", \"tolerance_mm\": round(float(rng.uniform(0.05, 0.1) if prec else rng.uniform(0.2, 0.5)), 3),\n", + " \"spec_rev\": int(rng.integers(1, 6)), \"pr_qty\": int(rng.integers(500, 5000)), \"pr_urgent\": int(rng.random() < 0.2),\n", + " \"vendor\": f\"V{int(rng.integers(1, 13)):02d}\", \"lead_days\": int(rng.integers(30, 150)), \"spec_changed\": int(rng.random() < 0.1),\n", + " \"spec_mismatch\": 0, \"lot_id\": f\"LOT01-{i:05d}\", \"lot_size\": int(rng.integers(300, 3000)), \"delay_days\": int(rng.integers(-3, 20)),\n", + " \"receipt_date\": f\"2025-{int(rng.integers(1, 13)):02d}-{int(rng.integers(1, 29)):02d}\", \"inspector\": f\"INS-{int(rng.integers(1, 5))}\",\n", + " \"sample_n\": n, \"defects\": sum(dfx), \"def_spec\": dfx[0], \"def_dim\": dfx[1], \"def_visual\": dfx[2],\n", + " \"dim_dev_mm\": round(float(rng.uniform(0.01, 0.2)), 4), \"emergency_release\": 0, \"field_claims\": int(rng.poisson(1)),\n", + " \"confirmed_rate\": round(float(rng.uniform(0.005, 0.08)), 4)})\n", + "ok = sum(not validate(r) for r in recs)\n", + "print(f\"\\n정상 샘플 {len(recs)}건 중 검증 통과 {ok}건, 오류 {len(recs) - ok}건\")\n", + "\n", + "# 4) 오류 주입 시험\n", + "bad = json.loads(json.dumps(recs[0]))\n", + "bad[\"niin\"] = \"5305011234567\" # 하이픈 없는 재고번호\n", + "bad[\"vendor\"] = \"업체A\" # 코드 형식 위반\n", + "bad[\"spec_class\"] = \"high\" # 허용값 위반\n", + "bad[\"lot_size\"] = -10 # 범위 위반\n", + "del bad[\"receipt_date\"] # 필수 필드 누락\n", + "print(\"\\n[오류 주입 결과]\")\n", + "for e in validate(bad):\n", + " print(\" -\", e)\n", + "bad2 = json.loads(json.dumps(recs[1])); bad2[\"def_dim\"] += 2; bad2[\"niin\"] = \"9999\" + bad2[\"niin\"][4:]\n", + "print(\" - (교차검증) \" + \" / \".join(validate(bad2)))\n", + "\n", + "# 5) 데이터 딕셔너리 요약과 목표 점검\n", + "dd = pd.DataFrame([(s, f, t.__name__, json.dumps(r, ensure_ascii=False)) for s, f, t, r, _ in S], columns=[\"단계\", \"필드\", \"자료형\", \"규칙\"])\n", + "print(\"\\n[데이터 딕셔너리 요약: 단계별 필드 수]\")\n", + "print(dd.groupby(\"단계\", sort=False)[\"필드\"].agg([\"count\", lambda x: \", \".join(x)]).rename(columns={\"count\": \"필드 수\", \"\": \"필드\"}).to_string())\n", + "print(f\"\\n피처(필드) {len(S)}개 정의 (목표 20개 이상: {'달성' if len(S) >= 20 else '미달'}), 교차검증 규칙 {len(CROSS_RULES)}종, 프로세스 맵 {len(PROCESS)}단계 작성\")\n", + "print(\"분석 목표: 구매요구·규격·납기 정보로 로트 확정 불량률을 수령검사 시점에 미리 예측해 고위험 로트를 먼저 정밀 검사한다.\")\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 01 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + "[업무 프로세스 맵] 구매요구 -> 계약 -> 납품 -> 수령검사 -> 불출·사용 -> 사후 확정\n", + " 1. 구매요구 | 사용부대·보급부서 | 생성 데이터: 품목(NIIN), 규격 등급·개정차수, 요구수량, 긴급 여부\n", + " 2. 계약 | 조달 담당 | 생성 데이터: 업체, 계약 납기(일), 구매요구 대비 규격 변경 여부\n", + " 3. 납품 | 업체 | 생성 데이터: 로트 번호, 로트 크기, 실제 납품일(지연일)\n", + " 4. 수령검사 | 품질 검사관 | 생성 데이터: 표본 수, 불량 수(유형별), 치수 편차 측정값, 합부 판정\n", + " 5. 불출·사용 | 보급부서·사용부대 | 생성 데이터: 긴급 불출 여부, 사용부대 하자·불만 접수\n", + " 6. 사후 확정 | 품질 담당 | 생성 데이터: 전수검사·하자 반영 확정 불량률\n", + "\n", + "정상 샘플 20건 중 검증 통과 20건, 오류 0건\n", + "\n", + "[오류 주입 결과]\n", + " - receipt_date: 필수 필드 누락\n", + " - niin: 형식 불일치 '5305011234567'\n", + " - spec_class: 허용되지 않은 값 'high'\n", + " - vendor: 형식 불일치 '업체A'\n", + " - lot_size: 최소값 1 미만\n", + " - (교차검증) 교차검증: 유형별 불량 합 불일치 / 교차검증: 재고번호와 품목군 불일치\n", + "\n", + "[데이터 딕셔너리 요약: 단계별 필드 수]\n", + " 필드 수 필드\n", + "단계 \n", + "구매요구 7 niin, fsc, spec_class, tolerance_mm, spec_rev, pr_qty, pr_urgent\n", + "계약 4 vendor, lead_days, spec_changed, spec_mismatch\n", + "납품 3 lot_id, lot_size, delay_days\n", + "수령검사 8 receipt_date, inspector, sample_n, defects, def_spec, def_dim, def_visual, dim_dev_mm\n", + "불출·사용 2 emergency_release, field_claims\n", + "사후 확정 1 confirmed_rate\n", + "\n", + "피처(필드) 25개 정의 (목표 20개 이상: 달성), 교차검증 규칙 4종, 프로세스 맵 6단계 작성\n", + "분석 목표: 구매요구·규격·납기 정보로 로트 확정 불량률을 수령검사 시점에 미리 예측해 고위험 로트를 먼저 정밀 검사한다.\n", + "```\n", + "
\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 02. 검사 측정치 결측치 정제, 납품업체별 불량 이력 인코딩 전처리 모듈 구현\n", + "**소요시간**: 약 2.0시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "검사 측정치 결측치 정제 및 업체별 불량 이력 인코딩 전처리 모듈을 구현한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **검사 측정치 결측을 정제하고 납품업체별 불량 이력을 정보 누수 없이 인코딩하는 전처리 모듈을 구현한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **중복 로트, 업체 코드 표기, 날짜 형식, 재고번호 형식, 범위 오류 등 정제 규칙 10종을 적용하고 치수 측정 결측은 KNN으로 보간 수행하고 검증하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `src/etl/qlt02_data_cleansing.py 및 정제 품질 보고서` 형태의 결과물을 완성한다.\n", + "\n", + "### 🔍 평가 및 검증 기준 (PASS 조건)\n", + "#### 미션 판정\n", + "\n", + "**평가 구분:** 필수\n", + "\n", + "**핵심 평가영역:** 군수 특수 예외(비계획 정비, 긴급 불출 등) 데이터 보존의 타당성\n", + "\n", + "#### 미션 PASS 조건\n", + "\n", + "- 정량 기준: 데이터 정제 규칙 10종 적용, 데이터 정합성 검증 통과율 100%\n", + "\n", + "\n", + "- 진행 기준: ETL 파이프라인 구현 및 정제된 마스터 데이터셋 구축\n", + "\n", + "\n", + "- 검증 방법: 전후 데이터 통계량(기술통계) 비교 및 무결성 검증\n", + "\n", + "\n", + "#### 채점자 판정 메모\n", + "\n", + "> 단순 코드 실행 여부뿐만 아니라 출력된 수치와 근거 자료가 군 실무 요구사항에 부합하는지 면밀히 확인한다.\n", + "\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "\"\"\"수령검사 원시 기록의 결측·오기·중복을 정제 규칙 10종으로 바로잡고(측정치 결측은 KNN 보간), 납품업체별 과거 불량 이력을 정보 누수 없이 인코딩하는 전처리 모듈을 구현한다. 긴급 불출 등 군수 특수 예외 기록은 삭제하지 않고 보존한다.\"\"\"\n", + "import numpy as np\n", + "import pandas as pd\n", + "from sklearn.impute import KNNImputer\n", + "\n", + "# ---------------------------------------------------------------------------\n", + "# [교육용가상자료] 군수품 수령검사 로트 데이터 생성기 (실제 업체·품목·계약 정보 없음)\n", + "# 구매요구(규격·긴급 여부) -> 계약(납기) -> 납품 -> 수령검사(표본) -> 확정 불량률(전수검사·사용부대 하자 반영)\n", + "# ---------------------------------------------------------------------------\n", + "FSC = {\"5305\": \"screw/bolt\", \"5330\": \"gasket\", \"5935\": \"connector\", \"6135\": \"battery\", \"2530\": \"brake part\", \"8415\": \"clothing\"}\n", + "VENDORS = [f\"V{i:02d}\" for i in range(1, 13)]\n", + "VENDOR_Q = dict(zip(VENDORS, [-0.6, -0.4, -0.3, -0.2, -0.1, 0.0, 0.1, 0.2, 0.3, 0.5, 0.8, 1.1])) # 업체 고유 품질 수준(숨은 값)\n", + "FSC_Q = {\"5305\": -0.3, \"5330\": 0.0, \"5935\": 0.25, \"6135\": 0.1, \"2530\": 0.35, \"8415\": -0.2}\n", + "SAMPLE_PLAN = [(500, 50, 3), (1200, 80, 5), (3200, 125, 7), (10 ** 9, 200, 10)] # (로트 크기 상한, 표본 n, 합격판정수 c) 단순화 기준\n", + "BAD_RATE = 0.05 # 확정 불량률 5% 초과 로트 = 불합격 대상\n", + "\n", + "\n", + "def sample_plan(lot_size):\n", + " # [TODO : 학생 실습 2] 검사 측정치 결측치 정제, 납품업체별 불량 이력 인코딩 전처리 모듈 구현 관련 핵심 로직을 구현하세요.\n", + " # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n", + " # <<< 여기에 코드를 작성하세요 >>>\n", + " pass # 작성 후 pass를 제거하세요.\n", + "def true_rate(d, rng=None):\n", + " \"\"\"숨은 불량 발생 구조: 업체 수준 + 품목군 + 정밀 규격 + 긴급 구매 + 규격 변경·불일치 + 납기 지연 + (정밀 규격 x 촉박 납기) 상호작용.\"\"\"\n", + " # [TODO : 학생 실습 2] 검사 측정치 결측치 정제, 납품업체별 불량 이력 인코딩 전처리 모듈 구현 관련 핵심 로직을 구현하세요.\n", + " # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n", + " # <<< 여기에 코드를 작성하세요 >>>\n", + " pass # 작성 후 pass를 제거하세요.\n", + "def make_lots(n=1200, seed=42, months=24):\n", + " rng = np.random.default_rng(seed)\n", + " d = pd.DataFrame({\"lot_id\": [f\"LOT{seed % 100:02d}-{i:05d}\" for i in range(n)]})\n", + " d[\"month\"] = rng.integers(0, months, n)\n", + " d[\"receipt_date\"] = pd.Timestamp(\"2024-01-01\") + pd.to_timedelta(d[\"month\"] * 30 + rng.integers(0, 30, n), unit=\"D\")\n", + " d = d.sort_values(\"receipt_date\").reset_index(drop=True)\n", + " d[\"month\"] = ((d[\"receipt_date\"] - pd.Timestamp(\"2024-01-01\")).dt.days // 30).clip(0, months - 1)\n", + " d[\"vendor\"] = rng.choice(VENDORS, n, p=np.r_[np.full(6, 0.1), np.full(6, 0.4 / 6)])\n", + " d[\"fsc\"] = rng.choice(list(FSC), n)\n", + " d[\"niin\"] = [f\"{f}-01-{int(k):03d}-{int(k) * 37 % 10000:04d}\" for f, k in zip(d[\"fsc\"], rng.integers(100, 110, n))]\n", + " d[\"spec_class\"] = np.where(rng.random(n) < np.where(d[\"fsc\"].isin([\"5935\", \"2530\"]), 0.6, 0.25), \"precision\", \"general\")\n", + " d[\"tolerance_mm\"] = np.where(d[\"spec_class\"] == \"precision\", rng.uniform(0.05, 0.1, n), rng.uniform(0.2, 0.5, n)).round(3)\n", + " d[\"spec_rev\"] = rng.integers(1, 6, n)\n", + " d[\"pr_urgent\"] = (rng.random(n) < 0.2).astype(int)\n", + " d[\"spec_changed\"] = (rng.random(n) < 0.12).astype(int)\n", + " d[\"spec_mismatch\"] = (rng.random(n) < 0.08).astype(int)\n", + " d[\"pr_qty\"] = rng.integers(200, 5000, n)\n", + " d[\"lot_size\"] = (d[\"pr_qty\"] * rng.uniform(0.5, 1.0, n)).astype(int)\n", + " d[\"lead_days\"] = np.where(d[\"pr_urgent\"] == 1, rng.integers(20, 60, n), rng.integers(40, 150, n))\n", + " d[\"delay_days\"] = np.round(rng.gamma(1.2, 6, n) * rng.binomial(1, 0.45, n) - rng.integers(0, 5, n)).astype(int)\n", + " d[\"unit_cost\"] = rng.choice([2_000, 15_000, 40_000, 120_000], n, p=[0.35, 0.3, 0.25, 0.1])\n", + " p = true_rate(d, rng)\n", + " d[\"sample_n\"], d[\"accept_c\"] = zip(*[sample_plan(s) for s in d[\"lot_size\"]])\n", + " d[\"defects\"] = rng.binomial(d[\"sample_n\"], p)\n", + " share = np.where(d[\"spec_class\"] == \"precision\", 0.55, 0.3)\n", + " d[\"def_dim\"] = rng.binomial(d[\"defects\"], share)\n", + " d[\"def_spec\"] = rng.binomial(d[\"defects\"] - d[\"def_dim\"], 0.6)\n", + " d[\"def_visual\"] = d[\"defects\"] - d[\"def_dim\"] - d[\"def_spec\"]\n", + " d[\"dim_dev_mm\"] = (d[\"tolerance_mm\"] * (0.3 + 2.0 * p) * rng.lognormal(0, 0.3, n)).round(4)\n", + " d[\"confirmed_rate\"] = np.clip(p * rng.lognormal(0, 0.05, n), 0.001, 0.4).round(4) # 전수검사·하자 반영 확정 불량률\n", + " d[\"field_claims\"] = rng.poisson(d[\"lot_size\"] * p * 0.03)\n", + " d[\"inspector\"] = rng.choice([\"INS-1\", \"INS-2\", \"INS-3\", \"INS-4\"], n)\n", + " d[\"emergency_release\"] = ((d[\"pr_urgent\"] == 1) & (rng.random(n) < 0.3)).astype(int) # 긴급 불출(검사 전 선불출) 예외\n", + " d[\"bad_lot\"] = (d[\"confirmed_rate\"] > BAD_RATE).astype(int)\n", + " return d\n", + "# ---------------------------------------------------------------------------\n", + "\n", + "\n", + "def make_raw(clean, seed=5):\n", + " \"\"\"[교육용가상자료] 현장 입력 오류를 흉내 낸 원시 데이터: 결측·형식 오류·단위 혼용·중복 등을 일부러 넣는다.\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " r = clean.copy()\n", + " r[\"receipt_date\"] = r[\"receipt_date\"].dt.strftime(\"%Y-%m-%d\").astype(object)\n", + " idx = lambda k: rng.choice(len(r), k, replace=False)\n", + " r.loc[idx(40), \"dim_dev_mm\"] = np.nan # 측정치 결측\n", + " i = idx(15); r.loc[i, \"dim_dev_mm\"] = r.loc[i, \"dim_dev_mm\"] * 1000 # mm/um 단위 혼용\n", + " i = idx(20); r.loc[i, \"vendor\"] = [\" \" + v.lower() + \" \" for v in r.loc[i, \"vendor\"]] # 대소문자·공백\n", + " i = idx(15); r.loc[i, \"receipt_date\"] = [d.replace(\"-\", \".\") for d in r.loc[i, \"receipt_date\"]] # 날짜 형식\n", + " i = idx(10); r.loc[i, \"niin\"] = [n.replace(\"-\", \"\") for n in r.loc[i, \"niin\"]] # 재고번호 하이픈 누락\n", + " i = idx(8); r.loc[i, \"lot_size\"] = -r.loc[i, \"lot_size\"] # 부호 오류\n", + " i = idx(6); r.loc[i, \"defects\"] = r.loc[i, \"defects\"] + 3 # 유형별 합 불일치\n", + " i = idx(5); r.loc[i, \"spec_class\"] = \"PRECISION\" # 코드 표기 불일치\n", + " i = idx(5); r.loc[i, \"inspector\"] = np.nan # 검사관 누락\n", + " r = pd.concat([r, r.iloc[idx(12)]], ignore_index=True) # 중복 입력\n", + " return r\n", + "\n", + "\n", + "def clean(raw):\n", + " d, log = raw.copy(), []\n", + "\n", + " def rule(name, mask, fix):\n", + " n = int(mask.sum())\n", + " if n:\n", + " fix(mask)\n", + " log.append((name, n))\n", + "\n", + " rule(\"R01 중복 로트 제거\", d.duplicated(\"lot_id\"), lambda m: None)\n", + " d = d[~d.duplicated(\"lot_id\")].reset_index(drop=True)\n", + " rule(\"R02 업체 코드 공백·대소문자 정규화\", d[\"vendor\"] != d[\"vendor\"].str.strip().str.upper(),\n", + " lambda m: d.__setitem__(\"vendor\", d[\"vendor\"].str.strip().str.upper()))\n", + " rule(\"R03 날짜 형식 통일(YYYY-MM-DD)\", d[\"receipt_date\"].str.contains(r\"\\.\"),\n", + " lambda m: d.__setitem__(\"receipt_date\", d[\"receipt_date\"].str.replace(\".\", \"-\", regex=False)))\n", + " d[\"receipt_date\"] = pd.to_datetime(d[\"receipt_date\"])\n", + " bad_niin = ~d[\"niin\"].str.match(r\"^\\d{4}-\\d{2}-\\d{3}-\\d{4}$\")\n", + " fixed_niin = d[\"niin\"].str.replace(r\"^(\\d{4})(\\d{2})(\\d{3})(\\d{4})$\", r\"\\1-\\2-\\3-\\4\", regex=True)\n", + " rule(\"R04 재고번호(NIIN) 형식 복원\", bad_niin, lambda m: d.__setitem__(\"niin\", fixed_niin))\n", + " rule(\"R05 규격 등급 코드 소문자 통일\", d[\"spec_class\"] != d[\"spec_class\"].str.lower(),\n", + " lambda m: d.__setitem__(\"spec_class\", d[\"spec_class\"].str.lower()))\n", + " rule(\"R06 로트 크기 부호 오류 수정\", d[\"lot_size\"] < 0, lambda m: d.__setitem__(\"lot_size\", d[\"lot_size\"].abs()))\n", + " rule(\"R07 측정 단위 환산(um -> mm)\", d[\"dim_dev_mm\"] > 5, lambda m: d.__setitem__(\"dim_dev_mm\", d[\"dim_dev_mm\"].where(~m, d[\"dim_dev_mm\"] / 1000)))\n", + " rule(\"R08 유형별 합에 맞춰 불량 수 재계산\", d[\"defects\"] != d[[\"def_spec\", \"def_dim\", \"def_visual\"]].sum(axis=1),\n", + " lambda m: d.__setitem__(\"defects\", d[[\"def_spec\", \"def_dim\", \"def_visual\"]].sum(axis=1)))\n", + " rule(\"R09 검사관 누락 -> 'UNKNOWN' 표시(삭제하지 않음)\", d[\"inspector\"].isna(), lambda m: d.__setitem__(\"inspector\", d[\"inspector\"].fillna(\"UNKNOWN\")))\n", + " miss = d[\"dim_dev_mm\"].isna()\n", + " num = [\"dim_dev_mm\", \"tolerance_mm\", \"defects\", \"sample_n\", \"lot_size\"]\n", + " Z = d[num].astype(float); mu, sd = Z.mean(), Z.std()\n", + " filled = KNNImputer(n_neighbors=5).fit_transform((Z - mu) / sd) * sd.values + mu.values\n", + " rule(\"R10 치수 편차 결측 KNN 보간(k=5)\", miss, lambda m: d.__setitem__(\"dim_dev_mm\", np.where(m, filled[:, 0].round(4), d[\"dim_dev_mm\"])))\n", + " d[\"dim_imputed\"] = miss.astype(int) # 보간 여부 표시(추적성)\n", + " return d, log\n", + "\n", + "\n", + "def integrity(d):\n", + " \"\"\"정합성 검증 항목: 모두 True여야 통과.\"\"\"\n", + " return {\"로트 번호 유일\": d[\"lot_id\"].is_unique,\n", + " \"업체 코드 형식\": d[\"vendor\"].str.match(r\"^V\\d{2}$\").all(),\n", + " \"재고번호 형식\": d[\"niin\"].str.match(r\"^\\d{4}-\\d{2}-\\d{3}-\\d{4}$\").all(),\n", + " \"재고번호-품목군 일치\": (d[\"niin\"].str[:4] == d[\"fsc\"]).all(),\n", + " \"로트 크기 양수\": (d[\"lot_size\"] > 0).all(),\n", + " \"표본 <= 로트\": (d[\"sample_n\"] <= d[\"lot_size\"]).all(),\n", + " \"불량 유형 합 일치\": (d[\"defects\"] == d[[\"def_spec\", \"def_dim\", \"def_visual\"]].sum(axis=1)).all(),\n", + " \"치수 편차 결측 없음·범위\": d[\"dim_dev_mm\"].between(0, 5).all(),\n", + " \"날짜형 변환\": pd.api.types.is_datetime64_any_dtype(d[\"receipt_date\"]),\n", + " \"규격 등급 허용값\": d[\"spec_class\"].isin([\"precision\", \"general\"]).all()}\n", + "\n", + "\n", + "def encode_vendor_history(d):\n", + " \"\"\"업체별 불량 이력 인코딩: 같은 업체의 '이전' 로트 확정 불량률만 써서 평균·건수·최근 5로트 평균을 만든다.\"\"\"\n", + " d = d.sort_values(\"receipt_date\").reset_index(drop=True)\n", + " g = d.groupby(\"vendor\")[\"confirmed_rate\"]\n", + " d[\"vendor_prev_n\"] = d.groupby(\"vendor\").cumcount()\n", + " d[\"vendor_hist_rate\"] = g.transform(lambda s: s.shift().expanding().mean())\n", + " d[\"vendor_recent_rate\"] = g.transform(lambda s: s.shift().rolling(5, min_periods=1).mean())\n", + " prior = d[\"confirmed_rate\"].mean() # 이력이 적은 업체는 전체 평균 쪽으로 수축\n", + " w = d[\"vendor_prev_n\"] / (d[\"vendor_prev_n\"] + 10)\n", + " d[\"vendor_hist_enc\"] = (w * d[\"vendor_hist_rate\"].fillna(prior) + (1 - w) * prior).round(4)\n", + " return d\n", + "\n", + "\n", + "truth = make_lots(1200, seed=42)\n", + "raw = make_raw(truth)\n", + "print(f\"원시 기록 {len(raw)}건 (중복 포함), 결측 치수값 {raw['dim_dev_mm'].isna().sum()}건\")\n", + "pre = integrity(raw)\n", + "print(f\"정제 전 정합성 검증 {sum(pre.values())}/{len(pre)} 통과, 실패 항목: {', '.join(k for k, v in pre.items() if not v)}\")\n", + "cleaned, log = clean(raw)\n", + "print(\"\\n[정제 규칙 적용 내역]\")\n", + "for name, n in log:\n", + " print(f\" {name:34s} {n:4d}건\")\n", + "chk = integrity(cleaned)\n", + "rate = np.mean(list(chk.values()))\n", + "print(f\"\\n정제 규칙 {len(log)}종 적용, 정합성 검증 {sum(chk.values())}/{len(chk)} 통과 ({rate:.0%}) (목표 100%: {'달성' if rate == 1 else '미달'})\")\n", + "print(f\"긴급 불출 예외 기록 보존: 원본 {truth['emergency_release'].sum()}건 -> 정제 후 {cleaned['emergency_release'].sum()}건 (삭제 없음)\")\n", + "\n", + "m = cleaned[\"dim_imputed\"] == 1\n", + "ref = truth.set_index(\"lot_id\").loc[cleaned.loc[m, \"lot_id\"], \"dim_dev_mm\"].values\n", + "print(f\"KNN 보간 {m.sum()}건 평균 절대오차 {np.mean(np.abs(cleaned.loc[m, 'dim_dev_mm'].values - ref)):.4f} mm (단순 평균 대치 {np.mean(np.abs(truth['dim_dev_mm'].mean() - ref)):.4f} mm)\")\n", + "\n", + "print(\"\\n[전후 기술통계 비교]\")\n", + "cmp = pd.DataFrame({\"정제 전 평균\": raw[[\"lot_size\", \"dim_dev_mm\", \"defects\"]].mean(), \"정제 후 평균\": cleaned[[\"lot_size\", \"dim_dev_mm\", \"defects\"]].mean(),\n", + " \"원본(정답) 평균\": truth[[\"lot_size\", \"dim_dev_mm\", \"defects\"]].mean()}).round(3)\n", + "print(cmp.to_string())\n", + "\n", + "enc = encode_vendor_history(cleaned)\n", + "print(\"\\n[업체별 불량 이력 인코딩 예시: 마지막 로트 기준]\")\n", + "print(enc.groupby(\"vendor\").tail(1)[[\"vendor\", \"vendor_prev_n\", \"vendor_hist_enc\", \"vendor_recent_rate\"]].sort_values(\"vendor_hist_enc\", ascending=False).head(6).round(4).to_string(index=False))\n", + "print(\"주의: 현재 로트의 확정 불량률을 인코딩에 넣으면 정보 누수가 생기므로 이전 로트만 사용했다.\")\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 02 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + "원시 기록 1212건 (중복 포함), 결측 치수값 40건\n", + "정제 전 정합성 검증 1/10 통과, 실패 항목: 로트 번호 유일, 업체 코드 형식, 재고번호 형식, 로트 크기 양수, 표본 <= 로트, 불량 유형 합 일치, 치수 편차 결측 없음·범위, 날짜형 변환, 규격 등급 허용값\n", + "\n", + "[정제 규칙 적용 내역]\n", + " R01 중복 로트 제거 12건\n", + " R02 업체 코드 공백·대소문자 정규화 20건\n", + " R03 날짜 형식 통일(YYYY-MM-DD) 15건\n", + " R04 재고번호(NIIN) 형식 복원 10건\n", + " R05 규격 등급 코드 소문자 통일 5건\n", + " R06 로트 크기 부호 오류 수정 8건\n", + " R07 측정 단위 환산(um -> mm) 14건\n", + " R08 유형별 합에 맞춰 불량 수 재계산 6건\n", + " R09 검사관 누락 -> 'UNKNOWN' 표시(삭제하지 않음) 5건\n", + " R10 치수 편차 결측 KNN 보간(k=5) 40건\n", + "\n", + "정제 규칙 10종 적용, 정합성 검증 10/10 통과 (100%) (목표 100%: 달성)\n", + "긴급 불출 예외 기록 보존: 원본 76건 -> 정제 후 76건 (삭제 없음)\n", + "KNN 보간 40건 평균 절대오차 0.0233 mm (단순 평균 대치 0.0543 mm)\n", + "\n", + "[전후 기술통계 비교]\n", + " 정제 전 평균 정제 후 평균 원본(정답) 평균\n", + "lot_size 1970.468 1983.859 1983.859\n", + "dim_dev_mm 0.973 0.094 0.094\n", + "defects 5.051 5.061 5.061\n", + "\n", + "[업체별 불량 이력 인코딩 예시: 마지막 로트 기준]\n", + "vendor vendor_prev_n vendor_hist_enc vendor_recent_rate\n", + " V12 86 0.1072 0.1152\n", + " V11 80 0.0699 0.0366\n", + " V10 79 0.0692 0.1406\n", + " V08 57 0.0487 0.0278\n", + " V09 81 0.0450 0.0539\n", + " V07 69 0.0402 0.0307\n", + "주의: 현재 로트의 확정 불량률을 인코딩에 넣으면 정보 누수가 생기므로 이전 로트만 사용했다.\n", + "```\n", + "
\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 03. 불량률 통계 기반 1차 로트(Lot) 합격/불합격 판정 Baseline 알고리즘 구현\n", + "**소요시간**: 약 2.5시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "불량률 통계 기반 로트(Lot) 합격/불합격 판정 Baseline 알고리즘을 구현한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **표본 불량 수가 합격판정수 이하이면 합격시키는 통계적 샘플링 기반 로트 합격/불합격 판정 Baseline을 구현하고, 탐색적 분석으로 불량 관련 요인을 찾는다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **로트 크기별 표본 수와 합격판정수로 판정한 뒤 확정 불량률과 비교해 오합격·오불합격을 측정 수행하고 검증하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `notebooks/qlt02_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": [ + "\"\"\"표본 불량 수가 합격판정수(c) 이하이면 합격시키는 통계적 샘플링 기반 로트 합격/불합격 판정 Baseline을 구현해 오판정(오합격·오불합격)을 측정하고, 탐색적 데이터 분석(차트 8종·상관 검정·파레토 ABC)으로 불량과 관련된 규격·구매요구·납기 요인을 찾는다.\"\"\"\n", + "import numpy as np\n", + "import pandas as pd\n", + "import matplotlib.pyplot as plt\n", + "from scipy import stats\n", + "\n", + "# ---------------------------------------------------------------------------\n", + "# [교육용가상자료] 군수품 수령검사 로트 데이터 생성기 (실제 업체·품목·계약 정보 없음)\n", + "# 구매요구(규격·긴급 여부) -> 계약(납기) -> 납품 -> 수령검사(표본) -> 확정 불량률(전수검사·사용부대 하자 반영)\n", + "# ---------------------------------------------------------------------------\n", + "FSC = {\"5305\": \"screw/bolt\", \"5330\": \"gasket\", \"5935\": \"connector\", \"6135\": \"battery\", \"2530\": \"brake part\", \"8415\": \"clothing\"}\n", + "VENDORS = [f\"V{i:02d}\" for i in range(1, 13)]\n", + "VENDOR_Q = dict(zip(VENDORS, [-0.6, -0.4, -0.3, -0.2, -0.1, 0.0, 0.1, 0.2, 0.3, 0.5, 0.8, 1.1])) # 업체 고유 품질 수준(숨은 값)\n", + "FSC_Q = {\"5305\": -0.3, \"5330\": 0.0, \"5935\": 0.25, \"6135\": 0.1, \"2530\": 0.35, \"8415\": -0.2}\n", + "SAMPLE_PLAN = [(500, 50, 3), (1200, 80, 5), (3200, 125, 7), (10 ** 9, 200, 10)] # (로트 크기 상한, 표본 n, 합격판정수 c) 단순화 기준\n", + "BAD_RATE = 0.05 # 확정 불량률 5% 초과 로트 = 불합격 대상\n", + "\n", + "\n", + "def sample_plan(lot_size):\n", + " # [TODO : 학생 실습 3] 불량률 통계 기반 1차 로트(Lot) 합격/불합격 판정 Baseline 알고리즘 구현 관련 핵심 로직을 구현하세요.\n", + " # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n", + " # <<< 여기에 코드를 작성하세요 >>>\n", + " pass # 작성 후 pass를 제거하세요.\n", + "def true_rate(d, rng=None):\n", + " \"\"\"숨은 불량 발생 구조: 업체 수준 + 품목군 + 정밀 규격 + 긴급 구매 + 규격 변경·불일치 + 납기 지연 + (정밀 규격 x 촉박 납기) 상호작용.\"\"\"\n", + " # [TODO : 학생 실습 3] 불량률 통계 기반 1차 로트(Lot) 합격/불합격 판정 Baseline 알고리즘 구현 관련 핵심 로직을 구현하세요.\n", + " # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n", + " # <<< 여기에 코드를 작성하세요 >>>\n", + " pass # 작성 후 pass를 제거하세요.\n", + "def make_lots(n=1200, seed=42, months=24):\n", + " rng = np.random.default_rng(seed)\n", + " d = pd.DataFrame({\"lot_id\": [f\"LOT{seed % 100:02d}-{i:05d}\" for i in range(n)]})\n", + " d[\"month\"] = rng.integers(0, months, n)\n", + " d[\"receipt_date\"] = pd.Timestamp(\"2024-01-01\") + pd.to_timedelta(d[\"month\"] * 30 + rng.integers(0, 30, n), unit=\"D\")\n", + " d = d.sort_values(\"receipt_date\").reset_index(drop=True)\n", + " d[\"month\"] = ((d[\"receipt_date\"] - pd.Timestamp(\"2024-01-01\")).dt.days // 30).clip(0, months - 1)\n", + " d[\"vendor\"] = rng.choice(VENDORS, n, p=np.r_[np.full(6, 0.1), np.full(6, 0.4 / 6)])\n", + " d[\"fsc\"] = rng.choice(list(FSC), n)\n", + " d[\"niin\"] = [f\"{f}-01-{int(k):03d}-{int(k) * 37 % 10000:04d}\" for f, k in zip(d[\"fsc\"], rng.integers(100, 110, n))]\n", + " d[\"spec_class\"] = np.where(rng.random(n) < np.where(d[\"fsc\"].isin([\"5935\", \"2530\"]), 0.6, 0.25), \"precision\", \"general\")\n", + " d[\"tolerance_mm\"] = np.where(d[\"spec_class\"] == \"precision\", rng.uniform(0.05, 0.1, n), rng.uniform(0.2, 0.5, n)).round(3)\n", + " d[\"spec_rev\"] = rng.integers(1, 6, n)\n", + " d[\"pr_urgent\"] = (rng.random(n) < 0.2).astype(int)\n", + " d[\"spec_changed\"] = (rng.random(n) < 0.12).astype(int)\n", + " d[\"spec_mismatch\"] = (rng.random(n) < 0.08).astype(int)\n", + " d[\"pr_qty\"] = rng.integers(200, 5000, n)\n", + " d[\"lot_size\"] = (d[\"pr_qty\"] * rng.uniform(0.5, 1.0, n)).astype(int)\n", + " d[\"lead_days\"] = np.where(d[\"pr_urgent\"] == 1, rng.integers(20, 60, n), rng.integers(40, 150, n))\n", + " d[\"delay_days\"] = np.round(rng.gamma(1.2, 6, n) * rng.binomial(1, 0.45, n) - rng.integers(0, 5, n)).astype(int)\n", + " d[\"unit_cost\"] = rng.choice([2_000, 15_000, 40_000, 120_000], n, p=[0.35, 0.3, 0.25, 0.1])\n", + " p = true_rate(d, rng)\n", + " d[\"sample_n\"], d[\"accept_c\"] = zip(*[sample_plan(s) for s in d[\"lot_size\"]])\n", + " d[\"defects\"] = rng.binomial(d[\"sample_n\"], p)\n", + " share = np.where(d[\"spec_class\"] == \"precision\", 0.55, 0.3)\n", + " d[\"def_dim\"] = rng.binomial(d[\"defects\"], share)\n", + " d[\"def_spec\"] = rng.binomial(d[\"defects\"] - d[\"def_dim\"], 0.6)\n", + " d[\"def_visual\"] = d[\"defects\"] - d[\"def_dim\"] - d[\"def_spec\"]\n", + " d[\"dim_dev_mm\"] = (d[\"tolerance_mm\"] * (0.3 + 2.0 * p) * rng.lognormal(0, 0.3, n)).round(4)\n", + " d[\"confirmed_rate\"] = np.clip(p * rng.lognormal(0, 0.05, n), 0.001, 0.4).round(4) # 전수검사·하자 반영 확정 불량률\n", + " d[\"field_claims\"] = rng.poisson(d[\"lot_size\"] * p * 0.03)\n", + " d[\"inspector\"] = rng.choice([\"INS-1\", \"INS-2\", \"INS-3\", \"INS-4\"], n)\n", + " d[\"emergency_release\"] = ((d[\"pr_urgent\"] == 1) & (rng.random(n) < 0.3)).astype(int) # 긴급 불출(검사 전 선불출) 예외\n", + " d[\"bad_lot\"] = (d[\"confirmed_rate\"] > BAD_RATE).astype(int)\n", + " return d\n", + "# ---------------------------------------------------------------------------\n", + "\n", + "d = make_lots(1200, seed=42)\n", + "\n", + "# 1) Baseline: 단일 샘플링 판정 (표본 불량 수 <= c 이면 합격)\n", + "d[\"base_accept\"] = (d[\"defects\"] <= d[\"accept_c\"]).astype(int)\n", + "cm = pd.crosstab(d[\"bad_lot\"].map({0: \"실제 양호\", 1: \"실제 불량\"}), d[\"base_accept\"].map({1: \"합격 판정\", 0: \"불합격 판정\"}))\n", + "print(\"[Baseline 판정 혼동표] (불량 로트 기준: 확정 불량률 5% 초과)\")\n", + "print(cm.to_string())\n", + "acc = (d[\"base_accept\"] == 1 - d[\"bad_lot\"]).mean()\n", + "cons = ((d[\"base_accept\"] == 1) & (d[\"bad_lot\"] == 1)).sum() / d[\"bad_lot\"].sum()\n", + "prod = ((d[\"base_accept\"] == 0) & (d[\"bad_lot\"] == 0)).sum() / (d[\"bad_lot\"] == 0).sum()\n", + "print(f\"판정 정확도 {acc:.1%}, 오합격(소비자 위험) {cons:.1%}, 오불합격(생산자 위험) {prod:.1%}\")\n", + "print(\"한계: 표본 불량 수만 보므로 업체 이력·규격 변경·납기 지연 같은 사전 정보를 쓰지 못하고, 경계선 로트에서 오판정이 몰린다.\")\n", + "\n", + "# 2) 상관 분석: 확정 불량률과 각 요인의 스피어만 상관계수·p-value\n", + "cand = {\"delay_days\": d[\"delay_days\"], \"lead_days\": d[\"lead_days\"], \"precision_spec\": (d[\"spec_class\"] == \"precision\").astype(int),\n", + " \"pr_urgent\": d[\"pr_urgent\"], \"spec_changed\": d[\"spec_changed\"], \"spec_mismatch\": d[\"spec_mismatch\"],\n", + " \"dim_dev/tol\": d[\"dim_dev_mm\"] / d[\"tolerance_mm\"], \"lot_size\": d[\"lot_size\"], \"spec_rev\": d[\"spec_rev\"], \"unit_cost\": d[\"unit_cost\"]}\n", + "rows = []\n", + "for k, v in cand.items():\n", + " r, p = stats.spearmanr(v, d[\"confirmed_rate\"])\n", + " rows.append({\"요인\": k, \"상관계수\": round(r, 3), \"p-value\": f\"{p:.1e}\", \"유의(p<0.05)\": \"예\" if p < 0.05 else \"아니오\"})\n", + "cr = pd.DataFrame(rows).sort_values(\"상관계수\", key=abs, ascending=False)\n", + "print(\"\\n[확정 불량률과 요인별 상관 검정]\")\n", + "print(cr.to_string(index=False))\n", + "n_sig = (cr[\"유의(p<0.05)\"] == \"예\").sum()\n", + "print(f\"유의미한 상관 피처 {n_sig}개 (목표 5개 이상: {'달성' if n_sig >= 5 else '미달'})\")\n", + "chi = stats.chi2_contingency(pd.crosstab(d[\"vendor\"], d[\"bad_lot\"]))\n", + "print(f\"업체와 불량 로트 여부의 독립성 카이제곱 검정 p-value {chi.pvalue:.1e} -> 업체별 차이 {'있음' if chi.pvalue < 0.05 else '없음'}\")\n", + "\n", + "# 3) 파레토(ABC) 분석: 불량 개수 추정치(로트 크기 x 확정 불량률) 기준 업체 순위\n", + "d[\"def_units\"] = d[\"lot_size\"] * d[\"confirmed_rate\"]\n", + "par = d.groupby(\"vendor\")[\"def_units\"].sum().sort_values(ascending=False)\n", + "cum = par.cumsum() / par.sum()\n", + "abc = np.where(cum <= 0.5, \"A\", np.where(cum <= 0.8, \"B\", \"C\"))\n", + "top = par.index[: int(np.ceil(len(par) * 0.2))]\n", + "print(f\"\\n[파레토 ABC] 상위 20% 업체 {list(top)}가 추정 불량 개수의 {par[top].sum() / par.sum():.0%}를 차지 -> 핵심 관리 대상\")\n", + "print(\"A등급:\", list(par.index[abc == \"A\"]), \"/ B등급:\", list(par.index[abc == \"B\"]))\n", + "\n", + "# 4) EDA 차트 8종\n", + "fig, ax = plt.subplots(2, 4, figsize=(17, 8))\n", + "ax[0, 0].hist(d[\"confirmed_rate\"], bins=40, color=\"tab:blue\"); ax[0, 0].axvline(BAD_RATE, c=\"r\", ls=\"--\"); ax[0, 0].set_title(\"1. Confirmed defect rate\")\n", + "d.groupby(\"month\")[\"confirmed_rate\"].mean().plot(ax=ax[0, 1], marker=\"o\"); ax[0, 1].set_title(\"2. Monthly mean defect rate\")\n", + "par.plot.bar(ax=ax[0, 2], color=\"tab:orange\"); ax2 = ax[0, 2].twinx(); ax2.plot(range(len(cum)), cum.values, \"k-o\"); ax[0, 2].set_title(\"3. Vendor Pareto\")\n", + "d.boxplot(column=\"confirmed_rate\", by=\"spec_class\", ax=ax[0, 3]); ax[0, 3].set_title(\"4. Rate by spec class\")\n", + "ax[1, 0].scatter(d[\"delay_days\"], d[\"confirmed_rate\"], s=6, alpha=0.5); ax[1, 0].set_title(\"5. Delivery delay vs rate\"); ax[1, 0].set_xlabel(\"delay days\")\n", + "pd.crosstab(d[\"fsc\"], d[\"bad_lot\"], normalize=\"index\")[1].plot.bar(ax=ax[1, 1], color=\"tab:red\"); ax[1, 1].set_title(\"6. Bad-lot share by FSC\")\n", + "d[[\"def_spec\", \"def_dim\", \"def_visual\"]].sum().plot.pie(ax=ax[1, 2], autopct=\"%.0f%%\", labels=[\"spec\", \"dimension\", \"visual\"]); ax[1, 2].set_ylabel(\"\"); ax[1, 2].set_title(\"7. Defect type mix\")\n", + "cmat = pd.DataFrame(cand).assign(rate=d[\"confirmed_rate\"]).corr(method=\"spearman\")\n", + "ax[1, 3].imshow(cmat, cmap=\"coolwarm\", vmin=-1, vmax=1); ax[1, 3].set_xticks(range(len(cmat))); ax[1, 3].set_xticklabels(cmat.columns, rotation=90, fontsize=7)\n", + "ax[1, 3].set_yticks(range(len(cmat))); ax[1, 3].set_yticklabels(cmat.columns, fontsize=7); ax[1, 3].set_title(\"8. Spearman correlation\")\n", + "plt.suptitle(\"QLT02 EDA - synthetic receiving inspection data\"); plt.tight_layout(); plt.show()\n", + "print(f\"\\nEDA 차트 {len(fig.axes) - 1}종 생성 (목표 8종 이상: 달성)\")\n", + "\n", + "print(\"\\n[핵심 발견점]\")\n", + "print(f\" 1. 정밀 규격 로트의 평균 확정 불량률 {d[d.spec_class == 'precision'].confirmed_rate.mean():.1%}, 일반 규격 {d[d.spec_class == 'general'].confirmed_rate.mean():.1%}\")\n", + "print(f\" 2. 규격 불일치 로트 {d[d.spec_mismatch == 1].confirmed_rate.mean():.1%} vs 일치 {d[d.spec_mismatch == 0].confirmed_rate.mean():.1%}\")\n", + "print(f\" 3. 납기 14일 이상 지연 로트 {d[d.delay_days >= 14].confirmed_rate.mean():.1%} vs 정시 납품 {d[d.delay_days <= 0].confirmed_rate.mean():.1%}\")\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 03 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + "[Baseline 판정 혼동표] (불량 로트 기준: 확정 불량률 5% 초과)\n", + "base_accept 불합격 판정 합격 판정\n", + "bad_lot \n", + "실제 불량 203 79\n", + "실제 양호 34 884\n", + "판정 정확도 90.6%, 오합격(소비자 위험) 28.0%, 오불합격(생산자 위험) 3.7%\n", + "한계: 표본 불량 수만 보므로 업체 이력·규격 변경·납기 지연 같은 사전 정보를 쓰지 못하고, 경계선 로트에서 오판정이 몰린다.\n", + "\n", + "[확정 불량률과 요인별 상관 검정]\n", + " 요인 상관계수 p-value 유의(p<0.05)\n", + " dim_dev/tol 0.459 1.1e-63 예\n", + "precision_spec 0.442 1.7e-58 예\n", + " delay_days 0.288 2.5e-24 예\n", + " pr_urgent 0.277 1.6e-22 예\n", + " spec_mismatch 0.267 5.3e-21 예\n", + " spec_changed 0.211 1.4e-13 예\n", + " lead_days -0.182 2.1e-10 예\n", + " lot_size 0.024 4.1e-01 아니오\n", + " unit_cost 0.014 6.3e-01 아니오\n", + " spec_rev 0.002 9.3e-01 아니오\n", + "유의미한 상관 피처 7개 (목표 5개 이상: 달성)\n", + "업체와 불량 로트 여부의 독립성 카이제곱 검정 p-value 2.3e-55 -> 업체별 차이 있음\n", + "\n", + "[파레토 ABC] 상위 20% 업체 ['V12', 'V11', 'V10']가 추정 불량 개수의 43%를 차지 -> 핵심 관리 대상\n", + "A등급: ['V12', 'V11', 'V10'] / B등급: ['V05', 'V09', 'V03', 'V06', 'V04']\n", + "\n", + "EDA 차트 8종 생성 (목표 8종 이상: 달성)\n", + "\n", + "[핵심 발견점]\n", + " 1. 정밀 규격 로트의 평균 확정 불량률 6.6%, 일반 규격 2.8%\n", + " 2. 규격 불일치 로트 8.1% vs 일치 3.9%\n", + " 3. 납기 14일 이상 지연 로트 9.4% vs 정시 납품 3.4%\n", + "```\n", + "
\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 04. 다변량 분석 및 머신러닝 기반 은닉 품질 결함 패턴 식별 모델 고도화\n", + "**소요시간**: 약 3.0시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "다변량 분석 기반 은닉 품질 결함 패턴 식별 모델을 고도화한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **다변량 분석으로 단일 요인에서 드러나지 않는 은닉 품질 결함 패턴(규격 x 납기 상호작용, 업체 품질 추세)을 식별하는 피처와 기준 모델을 만든다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **표본 불량률, 지연 비율, 정밀 규격 x 촉박 납기, 치수 편차/공차, 업체 최근 추세 등 파생 피처 10개 이상 작성하여 원인을 분석하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `src/features/qlt02_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": [ + "\"\"\"규격·구매요구·납기·검사 정보로 파생 피처를 만들고 다변량 선형 기준 모델(Ridge)로 로트 확정 불량률을 예측한다. 시계열 교차검증으로 과적합을 점검하고, 상호작용·추세 피처로 단일 요인 분석에서 보이지 않던 은닉 결함 패턴을 찾는다.\"\"\"\n", + "import numpy as np\n", + "import pandas as pd\n", + "import matplotlib.pyplot as plt\n", + "from sklearn.linear_model import Ridge\n", + "from sklearn.pipeline import make_pipeline\n", + "from sklearn.preprocessing import StandardScaler\n", + "from sklearn.model_selection import TimeSeriesSplit\n", + "from sklearn.inspection import permutation_importance\n", + "\n", + "# ---------------------------------------------------------------------------\n", + "# [교육용가상자료] 군수품 수령검사 로트 데이터 생성기 (실제 업체·품목·계약 정보 없음)\n", + "# 구매요구(규격·긴급 여부) -> 계약(납기) -> 납품 -> 수령검사(표본) -> 확정 불량률(전수검사·사용부대 하자 반영)\n", + "# ---------------------------------------------------------------------------\n", + "FSC = {\"5305\": \"screw/bolt\", \"5330\": \"gasket\", \"5935\": \"connector\", \"6135\": \"battery\", \"2530\": \"brake part\", \"8415\": \"clothing\"}\n", + "VENDORS = [f\"V{i:02d}\" for i in range(1, 13)]\n", + "VENDOR_Q = dict(zip(VENDORS, [-0.6, -0.4, -0.3, -0.2, -0.1, 0.0, 0.1, 0.2, 0.3, 0.5, 0.8, 1.1])) # 업체 고유 품질 수준(숨은 값)\n", + "FSC_Q = {\"5305\": -0.3, \"5330\": 0.0, \"5935\": 0.25, \"6135\": 0.1, \"2530\": 0.35, \"8415\": -0.2}\n", + "SAMPLE_PLAN = [(500, 50, 3), (1200, 80, 5), (3200, 125, 7), (10 ** 9, 200, 10)] # (로트 크기 상한, 표본 n, 합격판정수 c) 단순화 기준\n", + "BAD_RATE = 0.05 # 확정 불량률 5% 초과 로트 = 불합격 대상\n", + "\n", + "\n", + "def sample_plan(lot_size):\n", + " # [TODO : 학생 실습 4] 다변량 분석 및 머신러닝 기반 은닉 품질 결함 패턴 식별 모델 고도화 관련 핵심 로직을 구현하세요.\n", + " # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n", + " # <<< 여기에 코드를 작성하세요 >>>\n", + " pass # 작성 후 pass를 제거하세요.\n", + "def true_rate(d, rng=None):\n", + " \"\"\"숨은 불량 발생 구조: 업체 수준 + 품목군 + 정밀 규격 + 긴급 구매 + 규격 변경·불일치 + 납기 지연 + (정밀 규격 x 촉박 납기) 상호작용.\"\"\"\n", + " # [TODO : 학생 실습 4] 다변량 분석 및 머신러닝 기반 은닉 품질 결함 패턴 식별 모델 고도화 관련 핵심 로직을 구현하세요.\n", + " # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n", + " # <<< 여기에 코드를 작성하세요 >>>\n", + " pass # 작성 후 pass를 제거하세요.\n", + "def make_lots(n=1200, seed=42, months=24):\n", + " rng = np.random.default_rng(seed)\n", + " d = pd.DataFrame({\"lot_id\": [f\"LOT{seed % 100:02d}-{i:05d}\" for i in range(n)]})\n", + " d[\"month\"] = rng.integers(0, months, n)\n", + " d[\"receipt_date\"] = pd.Timestamp(\"2024-01-01\") + pd.to_timedelta(d[\"month\"] * 30 + rng.integers(0, 30, n), unit=\"D\")\n", + " d = d.sort_values(\"receipt_date\").reset_index(drop=True)\n", + " d[\"month\"] = ((d[\"receipt_date\"] - pd.Timestamp(\"2024-01-01\")).dt.days // 30).clip(0, months - 1)\n", + " d[\"vendor\"] = rng.choice(VENDORS, n, p=np.r_[np.full(6, 0.1), np.full(6, 0.4 / 6)])\n", + " d[\"fsc\"] = rng.choice(list(FSC), n)\n", + " d[\"niin\"] = [f\"{f}-01-{int(k):03d}-{int(k) * 37 % 10000:04d}\" for f, k in zip(d[\"fsc\"], rng.integers(100, 110, n))]\n", + " d[\"spec_class\"] = np.where(rng.random(n) < np.where(d[\"fsc\"].isin([\"5935\", \"2530\"]), 0.6, 0.25), \"precision\", \"general\")\n", + " d[\"tolerance_mm\"] = np.where(d[\"spec_class\"] == \"precision\", rng.uniform(0.05, 0.1, n), rng.uniform(0.2, 0.5, n)).round(3)\n", + " d[\"spec_rev\"] = rng.integers(1, 6, n)\n", + " d[\"pr_urgent\"] = (rng.random(n) < 0.2).astype(int)\n", + " d[\"spec_changed\"] = (rng.random(n) < 0.12).astype(int)\n", + " d[\"spec_mismatch\"] = (rng.random(n) < 0.08).astype(int)\n", + " d[\"pr_qty\"] = rng.integers(200, 5000, n)\n", + " d[\"lot_size\"] = (d[\"pr_qty\"] * rng.uniform(0.5, 1.0, n)).astype(int)\n", + " d[\"lead_days\"] = np.where(d[\"pr_urgent\"] == 1, rng.integers(20, 60, n), rng.integers(40, 150, n))\n", + " d[\"delay_days\"] = np.round(rng.gamma(1.2, 6, n) * rng.binomial(1, 0.45, n) - rng.integers(0, 5, n)).astype(int)\n", + " d[\"unit_cost\"] = rng.choice([2_000, 15_000, 40_000, 120_000], n, p=[0.35, 0.3, 0.25, 0.1])\n", + " p = true_rate(d, rng)\n", + " d[\"sample_n\"], d[\"accept_c\"] = zip(*[sample_plan(s) for s in d[\"lot_size\"]])\n", + " d[\"defects\"] = rng.binomial(d[\"sample_n\"], p)\n", + " share = np.where(d[\"spec_class\"] == \"precision\", 0.55, 0.3)\n", + " d[\"def_dim\"] = rng.binomial(d[\"defects\"], share)\n", + " d[\"def_spec\"] = rng.binomial(d[\"defects\"] - d[\"def_dim\"], 0.6)\n", + " d[\"def_visual\"] = d[\"defects\"] - d[\"def_dim\"] - d[\"def_spec\"]\n", + " d[\"dim_dev_mm\"] = (d[\"tolerance_mm\"] * (0.3 + 2.0 * p) * rng.lognormal(0, 0.3, n)).round(4)\n", + " d[\"confirmed_rate\"] = np.clip(p * rng.lognormal(0, 0.05, n), 0.001, 0.4).round(4) # 전수검사·하자 반영 확정 불량률\n", + " d[\"field_claims\"] = rng.poisson(d[\"lot_size\"] * p * 0.03)\n", + " d[\"inspector\"] = rng.choice([\"INS-1\", \"INS-2\", \"INS-3\", \"INS-4\"], n)\n", + " d[\"emergency_release\"] = ((d[\"pr_urgent\"] == 1) & (rng.random(n) < 0.3)).astype(int) # 긴급 불출(검사 전 선불출) 예외\n", + " d[\"bad_lot\"] = (d[\"confirmed_rate\"] > BAD_RATE).astype(int)\n", + " return d\n", + "# ---------------------------------------------------------------------------\n", + "\n", + "# ---- 파생 피처 공통 함수 (미션 4 이후 공통) ----\n", + "def add_history(d):\n", + " \"\"\"업체별 과거 확정 불량률(현재 로트 이전 로트만 사용: 정보 누수 방지)과 최근 5로트 추세.\"\"\"\n", + " d = d.sort_values(\"receipt_date\").reset_index(drop=True)\n", + " g = d.groupby(\"vendor\")[\"confirmed_rate\"]\n", + " d[\"vendor_hist_rate\"] = g.transform(lambda s: s.shift().expanding().mean()).fillna(d[\"confirmed_rate\"].mean())\n", + " d[\"vendor_recent_rate\"] = g.transform(lambda s: s.shift().rolling(5, min_periods=1).mean()).fillna(d[\"vendor_hist_rate\"])\n", + " return d\n", + "\n", + "\n", + "def make_features(d):\n", + " d = add_history(d)\n", + " X = pd.DataFrame(index=d.index)\n", + " X[\"obs_rate\"] = np.log((d[\"defects\"] + 0.5) / d[\"sample_n\"]) # 1 표본 불량률\n", + " X[\"log_lot\"] = np.log(d[\"lot_size\"]) # 2 로트 규모(로그)\n", + " X[\"delay_pos\"] = d[\"delay_days\"].clip(lower=0) # 3 납기 지연일\n", + " X[\"delay_ratio\"] = X[\"delay_pos\"] / d[\"lead_days\"] # 4 지연/계약기간 비\n", + " X[\"short_lead\"] = (d[\"lead_days\"] < 45).astype(int) # 5 촉박 납기\n", + " X[\"precision\"] = (d[\"spec_class\"] == \"precision\").astype(int) # 6 정밀 규격\n", + " X[\"prec_x_short\"] = X[\"precision\"] * X[\"short_lead\"] # 7 정밀 x 촉박(상호작용)\n", + " X[\"spec_risk\"] = d[\"spec_changed\"] + d[\"spec_mismatch\"] # 8 규격 변경·불일치 합\n", + " X[\"dim_ratio\"] = np.log(d[\"dim_dev_mm\"] / d[\"tolerance_mm\"]) # 9 치수 편차/공차\n", + " X[\"vendor_hist\"] = np.log(d[\"vendor_hist_rate\"]) # 10 업체 과거 불량률\n", + " X[\"vendor_trend\"] = d[\"vendor_recent_rate\"] - d[\"vendor_hist_rate\"] # 11 업체 최근 추세\n", + " X[\"urgent\"] = d[\"pr_urgent\"] # 12 긴급 구매요구\n", + " X[\"fill_ratio\"] = d[\"lot_size\"] / d[\"pr_qty\"] # 13 구매요구 대비 납품 비율\n", + " X[\"dim_share\"] = d[\"def_dim\"] / d[\"defects\"].clip(lower=1) # 14 치수 오차 비중\n", + " for f in FSC: # 15~ 품목군 원-핫\n", + " X[f\"fsc_{f}\"] = (d[\"fsc\"] == f).astype(int)\n", + " return d, X\n", + "\n", + "\n", + "def mape(y, p):\n", + " return float(np.mean(np.abs(y - p) / y))\n", + "\n", + "\n", + "d, X = make_features(make_lots(1200, seed=42))\n", + "y = np.log(d[\"confirmed_rate\"].values) # 불량률은 치우친 분포라 로그로 학습\n", + "print(f\"파생 피처 {X.shape[1]}개 생성 (목표 10개 이상: {'달성' if X.shape[1] >= 10 else '미달'})\")\n", + "print(\"피처:\", \", \".join(X.columns))\n", + "\n", + "# 1) 시계열 교차검증(과거로 학습 -> 이후 기간 검증)\n", + "model = make_pipeline(StandardScaler(), Ridge(alpha=1.0))\n", + "rows = []\n", + "for k, (tr, te) in enumerate(TimeSeriesSplit(n_splits=4).split(X), 1):\n", + " model.fit(X.iloc[tr], y[tr])\n", + " rows.append({\"폴드\": k, \"학습 로트\": len(tr), \"검증 로트\": len(te),\n", + " \"학습 MAPE\": round(mape(np.exp(y[tr]), np.exp(model.predict(X.iloc[tr]))), 3),\n", + " \"검증 MAPE\": round(mape(np.exp(y[te]), np.exp(model.predict(X.iloc[te]))), 3)})\n", + "cv = pd.DataFrame(rows)\n", + "print(\"\\n[시계열 교차검증]\")\n", + "print(cv.to_string(index=False))\n", + "m = cv[\"검증 MAPE\"].mean()\n", + "print(f\"기준 모델 평균 검증 MAPE {m:.1%} (목표 25% 이하: {'달성' if m <= 0.25 else '미달'}), 학습-검증 차이 {(cv['검증 MAPE'] - cv['학습 MAPE']).mean():+.1%}p\")\n", + "\n", + "# 2) 피처 중요도(순열 중요도)로 차원 축소 후보 확인\n", + "tr = d[\"month\"] < 18\n", + "model.fit(X[tr], y[tr])\n", + "imp = permutation_importance(model, X[~tr], y[~tr], n_repeats=5, random_state=0)\n", + "fi = pd.Series(imp.importances_mean, index=X.columns).sort_values(ascending=False)\n", + "print(\"\\n[순열 중요도 상위 8개]\")\n", + "print(fi.head(8).round(4).to_string())\n", + "low = list(fi[fi < 0.002].index)\n", + "Xr = X.drop(columns=low)\n", + "model.fit(Xr[tr], y[tr])\n", + "print(f\"중요도 0.002 미만 {len(low)}개 제거 후 검증 MAPE {mape(np.exp(y[~tr]), np.exp(model.predict(Xr[~tr]))):.1%} (피처 {Xr.shape[1]}개)\")\n", + "\n", + "# 3) 은닉 결함 패턴: 단일 요인으로는 약하지만 조합될 때 커지는 구간\n", + "piv = d.assign(규격=np.where(X[\"precision\"] == 1, \"정밀\", \"일반\"), 납기=np.where(X[\"short_lead\"] == 1, \"촉박(<45일)\", \"여유\")).pivot_table(\n", + " index=\"규격\", columns=\"납기\", values=\"confirmed_rate\", aggfunc=\"mean\")\n", + "print(\"\\n[은닉 패턴 1] 규격 x 납기 평균 확정 불량률\")\n", + "print((piv * 100).round(1).astype(str).add(\"%\").to_string())\n", + "lift = piv.loc[\"정밀\", \"촉박(<45일)\"] / piv.loc[\"일반\", \"여유\"]\n", + "print(f\" -> 정밀 규격을 촉박한 납기로 계약하면 불량률이 {lift:.1f}배: 구매요구 단계에서 납기 여유를 확보해야 한다.\")\n", + "drift = d[d[\"vendor\"] == \"V10\"].groupby(d[\"month\"] >= 18)[\"confirmed_rate\"].mean()\n", + "print(f\"[은닉 패턴 2] V10 업체 불량률 전반기 {drift[False]:.1%} -> 후반기 {drift[True]:.1%} (최근 추세 피처 vendor_trend로 포착)\")\n", + "no_int = make_pipeline(StandardScaler(), Ridge(1.0)).fit(X.drop(columns=[\"prec_x_short\", \"vendor_trend\"])[tr], y[tr])\n", + "print(f\"상호작용·추세 피처 제외 시 검증 MAPE {mape(np.exp(y[~tr]), np.exp(no_int.predict(X.drop(columns=['prec_x_short', 'vendor_trend'])[~tr]))):.1%} -> 포함 시 {mape(np.exp(y[~tr]), np.exp(make_pipeline(StandardScaler(), Ridge(1.0)).fit(X[tr], y[tr]).predict(X[~tr]))):.1%}\")\n", + "\n", + "fig, ax = plt.subplots(1, 2, figsize=(12, 4))\n", + "fi.head(10)[::-1].plot.barh(ax=ax[0], color=\"tab:blue\"); ax[0].set_title(\"Permutation importance (Ridge baseline)\")\n", + "p = np.exp(make_pipeline(StandardScaler(), Ridge(1.0)).fit(X[tr], y[tr]).predict(X[~tr]))\n", + "ax[1].scatter(np.exp(y[~tr]), p, s=8, alpha=0.6); ax[1].plot([0, 0.4], [0, 0.4], \"k--\")\n", + "ax[1].set_xlabel(\"confirmed defect rate\"); ax[1].set_ylabel(\"predicted\"); ax[1].set_title(\"Baseline prediction (hold-out months 18-23)\")\n", + "plt.tight_layout(); plt.show()\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 04 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + "파생 피처 20개 생성 (목표 10개 이상: 달성)\n", + "피처: obs_rate, log_lot, delay_pos, delay_ratio, short_lead, precision, prec_x_short, spec_risk, dim_ratio, vendor_hist, vendor_trend, urgent, fill_ratio, dim_share, fsc_5305, fsc_5330, fsc_5935, fsc_6135, fsc_2530, fsc_8415\n", + "\n", + "[시계열 교차검증]\n", + " 폴드 학습 로트 검증 로트 학습 MAPE 검증 MAPE\n", + " 1 240 240 0.211 0.204\n", + " 2 480 240 0.197 0.184\n", + " 3 720 240 0.191 0.167\n", + " 4 960 240 0.182 0.199\n", + "기준 모델 평균 검증 MAPE 18.9% (목표 25% 이하: 달성), 학습-검증 차이 -0.7%p\n", + "\n", + "[순열 중요도 상위 8개]\n", + "vendor_hist 0.3258\n", + "delay_pos 0.2496\n", + "spec_risk 0.1612\n", + "precision 0.1299\n", + "prec_x_short 0.0878\n", + "obs_rate 0.0634\n", + "urgent 0.0443\n", + "fsc_5305 0.0283\n", + "중요도 0.002 미만 7개 제거 후 검증 MAPE 20.1% (피처 13개)\n", + "\n", + "[은닉 패턴 1] 규격 x 납기 평균 확정 불량률\n", + "납기 여유 촉박(<45일)\n", + "규격 \n", + "일반 2.7% 3.5%\n", + "정밀 5.0% 15.0%\n", + " -> 정밀 규격을 촉박한 납기로 계약하면 불량률이 5.7배: 구매요구 단계에서 납기 여유를 확보해야 한다.\n", + "[은닉 패턴 2] V10 업체 불량률 전반기 6.3% -> 후반기 11.1% (최근 추세 피처 vendor_trend로 포착)\n", + "상호작용·추세 피처 제외 시 검증 MAPE 23.0% -> 포함 시 20.0%\n", + "```\n", + "
\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 05. 로트별 품질 위험 지수(Quality Risk Score) 산출 및 조기경보 엔진 개발\n", + "**소요시간**: 약 3.5시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "로트별 품질 위험 지수 산출 및 조기경보 엔진을 개발한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **앙상블 모델로 로트별 확정 불량률을 예측해 품질 위험 지수(0~100)와 경보 등급을 산출하는 조기경보 엔진을 개발한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **그래디언트 부스팅과 랜덤 포레스트를 묶은 앙상블을 앞 18개월로 학습하고 최근 6개월 hold-out에서 기준 모델과 MAE·RMSE·R2를 비교 수행하고 검증하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `src/models/qlt02_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": [ + "\"\"\"그래디언트 부스팅과 랜덤 포레스트 앙상블로 로트 확정 불량률을 예측해 기준 모델(Ridge) 대비 오차 개선과 R2를 검증하고, 예측 불량률을 '허용 불량률 초과 확률' 기반 품질 위험 지수(0~100)와 경보 등급으로 바꾸는 조기경보 엔진을 만든다.\"\"\"\n", + "import numpy as np\n", + "import pandas as pd\n", + "import matplotlib.pyplot as plt\n", + "from scipy import stats\n", + "from sklearn.linear_model import Ridge\n", + "from sklearn.pipeline import make_pipeline\n", + "from sklearn.preprocessing import StandardScaler\n", + "from sklearn.ensemble import GradientBoostingRegressor, RandomForestRegressor\n", + "from sklearn.metrics import mean_absolute_error, mean_squared_error, r2_score\n", + "\n", + "# ---------------------------------------------------------------------------\n", + "# [교육용가상자료] 군수품 수령검사 로트 데이터 생성기 (실제 업체·품목·계약 정보 없음)\n", + "# 구매요구(규격·긴급 여부) -> 계약(납기) -> 납품 -> 수령검사(표본) -> 확정 불량률(전수검사·사용부대 하자 반영)\n", + "# ---------------------------------------------------------------------------\n", + "FSC = {\"5305\": \"screw/bolt\", \"5330\": \"gasket\", \"5935\": \"connector\", \"6135\": \"battery\", \"2530\": \"brake part\", \"8415\": \"clothing\"}\n", + "VENDORS = [f\"V{i:02d}\" for i in range(1, 13)]\n", + "VENDOR_Q = dict(zip(VENDORS, [-0.6, -0.4, -0.3, -0.2, -0.1, 0.0, 0.1, 0.2, 0.3, 0.5, 0.8, 1.1])) # 업체 고유 품질 수준(숨은 값)\n", + "FSC_Q = {\"5305\": -0.3, \"5330\": 0.0, \"5935\": 0.25, \"6135\": 0.1, \"2530\": 0.35, \"8415\": -0.2}\n", + "SAMPLE_PLAN = [(500, 50, 3), (1200, 80, 5), (3200, 125, 7), (10 ** 9, 200, 10)] # (로트 크기 상한, 표본 n, 합격판정수 c) 단순화 기준\n", + "BAD_RATE = 0.05 # 확정 불량률 5% 초과 로트 = 불합격 대상\n", + "\n", + "\n", + "def sample_plan(lot_size):\n", + " # [TODO : 학생 실습 5] 로트별 품질 위험 지수(Quality Risk Score) 산출 및 조기경보 엔진 개발 관련 핵심 로직을 구현하세요.\n", + " # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n", + " # <<< 여기에 코드를 작성하세요 >>>\n", + " pass # 작성 후 pass를 제거하세요.\n", + "def true_rate(d, rng=None):\n", + " \"\"\"숨은 불량 발생 구조: 업체 수준 + 품목군 + 정밀 규격 + 긴급 구매 + 규격 변경·불일치 + 납기 지연 + (정밀 규격 x 촉박 납기) 상호작용.\"\"\"\n", + " # [TODO : 학생 실습 5] 로트별 품질 위험 지수(Quality Risk Score) 산출 및 조기경보 엔진 개발 관련 핵심 로직을 구현하세요.\n", + " # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n", + " # <<< 여기에 코드를 작성하세요 >>>\n", + " pass # 작성 후 pass를 제거하세요.\n", + "def make_lots(n=1200, seed=42, months=24):\n", + " rng = np.random.default_rng(seed)\n", + " d = pd.DataFrame({\"lot_id\": [f\"LOT{seed % 100:02d}-{i:05d}\" for i in range(n)]})\n", + " d[\"month\"] = rng.integers(0, months, n)\n", + " d[\"receipt_date\"] = pd.Timestamp(\"2024-01-01\") + pd.to_timedelta(d[\"month\"] * 30 + rng.integers(0, 30, n), unit=\"D\")\n", + " d = d.sort_values(\"receipt_date\").reset_index(drop=True)\n", + " d[\"month\"] = ((d[\"receipt_date\"] - pd.Timestamp(\"2024-01-01\")).dt.days // 30).clip(0, months - 1)\n", + " d[\"vendor\"] = rng.choice(VENDORS, n, p=np.r_[np.full(6, 0.1), np.full(6, 0.4 / 6)])\n", + " d[\"fsc\"] = rng.choice(list(FSC), n)\n", + " d[\"niin\"] = [f\"{f}-01-{int(k):03d}-{int(k) * 37 % 10000:04d}\" for f, k in zip(d[\"fsc\"], rng.integers(100, 110, n))]\n", + " d[\"spec_class\"] = np.where(rng.random(n) < np.where(d[\"fsc\"].isin([\"5935\", \"2530\"]), 0.6, 0.25), \"precision\", \"general\")\n", + " d[\"tolerance_mm\"] = np.where(d[\"spec_class\"] == \"precision\", rng.uniform(0.05, 0.1, n), rng.uniform(0.2, 0.5, n)).round(3)\n", + " d[\"spec_rev\"] = rng.integers(1, 6, n)\n", + " d[\"pr_urgent\"] = (rng.random(n) < 0.2).astype(int)\n", + " d[\"spec_changed\"] = (rng.random(n) < 0.12).astype(int)\n", + " d[\"spec_mismatch\"] = (rng.random(n) < 0.08).astype(int)\n", + " d[\"pr_qty\"] = rng.integers(200, 5000, n)\n", + " d[\"lot_size\"] = (d[\"pr_qty\"] * rng.uniform(0.5, 1.0, n)).astype(int)\n", + " d[\"lead_days\"] = np.where(d[\"pr_urgent\"] == 1, rng.integers(20, 60, n), rng.integers(40, 150, n))\n", + " d[\"delay_days\"] = np.round(rng.gamma(1.2, 6, n) * rng.binomial(1, 0.45, n) - rng.integers(0, 5, n)).astype(int)\n", + " d[\"unit_cost\"] = rng.choice([2_000, 15_000, 40_000, 120_000], n, p=[0.35, 0.3, 0.25, 0.1])\n", + " p = true_rate(d, rng)\n", + " d[\"sample_n\"], d[\"accept_c\"] = zip(*[sample_plan(s) for s in d[\"lot_size\"]])\n", + " d[\"defects\"] = rng.binomial(d[\"sample_n\"], p)\n", + " share = np.where(d[\"spec_class\"] == \"precision\", 0.55, 0.3)\n", + " d[\"def_dim\"] = rng.binomial(d[\"defects\"], share)\n", + " d[\"def_spec\"] = rng.binomial(d[\"defects\"] - d[\"def_dim\"], 0.6)\n", + " d[\"def_visual\"] = d[\"defects\"] - d[\"def_dim\"] - d[\"def_spec\"]\n", + " d[\"dim_dev_mm\"] = (d[\"tolerance_mm\"] * (0.3 + 2.0 * p) * rng.lognormal(0, 0.3, n)).round(4)\n", + " d[\"confirmed_rate\"] = np.clip(p * rng.lognormal(0, 0.05, n), 0.001, 0.4).round(4) # 전수검사·하자 반영 확정 불량률\n", + " d[\"field_claims\"] = rng.poisson(d[\"lot_size\"] * p * 0.03)\n", + " d[\"inspector\"] = rng.choice([\"INS-1\", \"INS-2\", \"INS-3\", \"INS-4\"], n)\n", + " d[\"emergency_release\"] = ((d[\"pr_urgent\"] == 1) & (rng.random(n) < 0.3)).astype(int) # 긴급 불출(검사 전 선불출) 예외\n", + " d[\"bad_lot\"] = (d[\"confirmed_rate\"] > BAD_RATE).astype(int)\n", + " return d\n", + "# ---------------------------------------------------------------------------\n", + "\n", + "# ---- 파생 피처 공통 함수 (미션 4 이후 공통) ----\n", + "def add_history(d):\n", + " \"\"\"업체별 과거 확정 불량률(현재 로트 이전 로트만 사용: 정보 누수 방지)과 최근 5로트 추세.\"\"\"\n", + " d = d.sort_values(\"receipt_date\").reset_index(drop=True)\n", + " g = d.groupby(\"vendor\")[\"confirmed_rate\"]\n", + " d[\"vendor_hist_rate\"] = g.transform(lambda s: s.shift().expanding().mean()).fillna(d[\"confirmed_rate\"].mean())\n", + " d[\"vendor_recent_rate\"] = g.transform(lambda s: s.shift().rolling(5, min_periods=1).mean()).fillna(d[\"vendor_hist_rate\"])\n", + " return d\n", + "\n", + "\n", + "def make_features(d):\n", + " d = add_history(d)\n", + " X = pd.DataFrame(index=d.index)\n", + " X[\"obs_rate\"] = np.log((d[\"defects\"] + 0.5) / d[\"sample_n\"]) # 1 표본 불량률\n", + " X[\"log_lot\"] = np.log(d[\"lot_size\"]) # 2 로트 규모(로그)\n", + " X[\"delay_pos\"] = d[\"delay_days\"].clip(lower=0) # 3 납기 지연일\n", + " X[\"delay_ratio\"] = X[\"delay_pos\"] / d[\"lead_days\"] # 4 지연/계약기간 비\n", + " X[\"short_lead\"] = (d[\"lead_days\"] < 45).astype(int) # 5 촉박 납기\n", + " X[\"precision\"] = (d[\"spec_class\"] == \"precision\").astype(int) # 6 정밀 규격\n", + " X[\"prec_x_short\"] = X[\"precision\"] * X[\"short_lead\"] # 7 정밀 x 촉박(상호작용)\n", + " X[\"spec_risk\"] = d[\"spec_changed\"] + d[\"spec_mismatch\"] # 8 규격 변경·불일치 합\n", + " X[\"dim_ratio\"] = np.log(d[\"dim_dev_mm\"] / d[\"tolerance_mm\"]) # 9 치수 편차/공차\n", + " X[\"vendor_hist\"] = np.log(d[\"vendor_hist_rate\"]) # 10 업체 과거 불량률\n", + " X[\"vendor_trend\"] = d[\"vendor_recent_rate\"] - d[\"vendor_hist_rate\"] # 11 업체 최근 추세\n", + " X[\"urgent\"] = d[\"pr_urgent\"] # 12 긴급 구매요구\n", + " X[\"fill_ratio\"] = d[\"lot_size\"] / d[\"pr_qty\"] # 13 구매요구 대비 납품 비율\n", + " X[\"dim_share\"] = d[\"def_dim\"] / d[\"defects\"].clip(lower=1) # 14 치수 오차 비중\n", + " for f in FSC: # 15~ 품목군 원-핫\n", + " X[f\"fsc_{f}\"] = (d[\"fsc\"] == f).astype(int)\n", + " return d, X\n", + "\n", + "\n", + "class RiskEngine:\n", + " \"\"\"앙상블(GBM 0.7 + RF 0.3, 로그 불량률) -> 위험 지수 = P(확정 불량률 > 5%) x 100\"\"\"\n", + "\n", + " def __init__(self, seed=0):\n", + " self.gbm = GradientBoostingRegressor(n_estimators=600, max_depth=2, learning_rate=0.05, subsample=0.8, random_state=seed)\n", + " self.rf = RandomForestRegressor(n_estimators=300, min_samples_leaf=2, random_state=seed)\n", + "\n", + " def fit(self, X, rate):\n", + " y = np.log(rate)\n", + " self.gbm.fit(X, y); self.rf.fit(X, y)\n", + " self.sigma = float(np.std(y - self._log_pred(X)) * 1.5) # 학습 잔차는 작게 나오므로 1.5배로 보수적 설정\n", + " return self\n", + "\n", + " def _log_pred(self, X):\n", + " return 0.7 * self.gbm.predict(X) + 0.3 * self.rf.predict(X)\n", + "\n", + " def predict_rate(self, X):\n", + " return np.exp(self._log_pred(X))\n", + "\n", + " def risk(self, X):\n", + " z = (np.log(BAD_RATE) - self._log_pred(X)) / self.sigma\n", + " return np.round(100 * (1 - stats.norm.cdf(z)), 1)\n", + "\n", + "\n", + "def grade(score):\n", + " return np.where(score >= 70, \"경보\", np.where(score >= 40, \"주의\", \"정상\"))\n", + "\n", + "\n", + "d, X = make_features(make_lots(1200, seed=42))\n", + "tr, te = (d[\"month\"] < 18).values, (d[\"month\"] >= 18).values # 앞 18개월 학습, 뒤 6개월 hold-out\n", + "y = d[\"confirmed_rate\"].values\n", + "\n", + "base = make_pipeline(StandardScaler(), Ridge(1.0)).fit(X[tr], np.log(y[tr]))\n", + "pb = np.exp(base.predict(X[te]))\n", + "eng = RiskEngine().fit(X[tr], y[tr])\n", + "pe = eng.predict_rate(X[te])\n", + "\n", + "res = pd.DataFrame([{\"모델\": name, \"MAE(%p)\": round(mean_absolute_error(y[te], p) * 100, 3), \"RMSE(%p)\": round(np.sqrt(mean_squared_error(y[te], p)) * 100, 3),\n", + " \"R2\": round(r2_score(y[te], p), 3), \"MAPE\": round(float(np.mean(np.abs(y[te] - p) / y[te])), 3)}\n", + " for name, p in [(\"업체 평균(단순)\", d[\"vendor_hist_rate\"].values[te]), (\"기준 모델(Ridge)\", pb), (\"앙상블 엔진\", pe)]])\n", + "print(\"[Hold-out(최근 6개월) 성능 비교]\")\n", + "print(res.to_string(index=False))\n", + "mae_imp = 1 - res.loc[2, \"MAE(%p)\"] / res.loc[1, \"MAE(%p)\"]\n", + "rmse_imp = 1 - res.loc[2, \"RMSE(%p)\"] / res.loc[1, \"RMSE(%p)\"]\n", + "print(f\"기준 모델 대비 MAE {mae_imp:.1%}, RMSE {rmse_imp:.1%} 개선 (목표 20% 이상: {'달성' if max(mae_imp, rmse_imp) >= 0.2 else '미달'}), \"\n", + " f\"R2 {res.loc[2, 'R2']:.3f} (목표 0.85 이상: {'달성' if res.loc[2, 'R2'] >= 0.85 else '미달'})\")\n", + "\n", + "# 조기경보: 위험 지수와 등급\n", + "h = d[te].copy()\n", + "h[\"risk\"] = eng.risk(X[te]); h[\"grade\"] = grade(h[\"risk\"])\n", + "h[\"base_accept\"] = h[\"defects\"] <= h[\"accept_c\"]\n", + "print(\"\\n[위험 등급별 실제 불량 로트 비율]\")\n", + "print(h.groupby(\"grade\").agg(로트수=(\"lot_id\", \"size\"), 실제불량=(\"bad_lot\", \"sum\"), 불량비율=(\"bad_lot\", \"mean\")).reindex([\"경보\", \"주의\", \"정상\"]).round(3).to_string())\n", + "flag = h[\"grade\"] != \"정상\"\n", + "rec = (flag & (h[\"bad_lot\"] == 1)).sum() / h[\"bad_lot\"].sum()\n", + "missed_by_sampling = h[(h[\"base_accept\"]) & (h[\"bad_lot\"] == 1)]\n", + "print(f\"\\n경보+주의 로트가 실제 불량 로트를 잡아낸 비율 {rec:.1%}\")\n", + "print(f\"샘플링 판정에서 오합격된 불량 로트 {len(missed_by_sampling)}건 중 조기경보가 잡은 건 {(missed_by_sampling['grade'] != '정상').sum()}건 -> 정밀 재검사 대상으로 넘긴다\")\n", + "\n", + "# 안정성: 같은 입력 반복 산출 일관성, 앙상블 두 모델 간 편차\n", + "again = RiskEngine().fit(X[tr], y[tr]).risk(X[te])\n", + "print(f\"위험 지수 재산출 일관성 {np.mean(again == h['risk'].values):.0%}, GBM-RF 예측 불일치(로그 표준편차) {np.std(eng.gbm.predict(X[te]) - eng.rf.predict(X[te])):.3f}\")\n", + "print(\"\\n[위험 지수 상위 5개 로트]\")\n", + "print(h.sort_values(\"risk\", ascending=False).head(5)[[\"lot_id\", \"vendor\", \"fsc\", \"spec_class\", \"delay_days\", \"risk\", \"grade\", \"confirmed_rate\"]].to_string(index=False))\n", + "\n", + "fig, ax = plt.subplots(1, 2, figsize=(12, 4))\n", + "ax[0].scatter(y[te], pb, s=8, alpha=0.5, label=\"Ridge baseline\"); ax[0].scatter(y[te], pe, s=8, alpha=0.6, label=\"ensemble\")\n", + "ax[0].plot([0, 0.4], [0, 0.4], \"k--\"); ax[0].set_xlabel(\"confirmed rate\"); ax[0].set_ylabel(\"predicted\"); ax[0].legend(); ax[0].set_title(\"Hold-out prediction\")\n", + "for g, c in [(0, \"tab:blue\"), (1, \"tab:red\")]:\n", + " ax[1].hist(h[h[\"bad_lot\"] == g][\"risk\"], bins=20, alpha=0.6, color=c, label=[\"good lot\", \"bad lot\"][g])\n", + "ax[1].axvline(40, ls=\"--\", c=\"orange\"); ax[1].axvline(70, ls=\"--\", c=\"red\"); ax[1].legend(); ax[1].set_title(\"Quality risk score (0-100)\")\n", + "plt.tight_layout(); plt.show()\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 05 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + "[Hold-out(최근 6개월) 성능 비교]\n", + " 모델 MAE(%p) RMSE(%p) R2 MAPE\n", + " 업체 평균(단순) 2.734 4.835 0.182 0.881\n", + "기준 모델(Ridge) 0.988 2.367 0.804 0.200\n", + " 앙상블 엔진 0.756 1.296 0.941 0.188\n", + "기준 모델 대비 MAE 23.5%, RMSE 45.2% 개선 (목표 20% 이상: 달성), R2 0.941 (목표 0.85 이상: 달성)\n", + "\n", + "[위험 등급별 실제 불량 로트 비율]\n", + " 로트수 실제불량 불량비율\n", + "grade \n", + "경보 61 58 0.951\n", + "주의 16 9 0.562\n", + "정상 227 5 0.022\n", + "\n", + "경보+주의 로트가 실제 불량 로트를 잡아낸 비율 93.1%\n", + "샘플링 판정에서 오합격된 불량 로트 15건 중 조기경보가 잡은 건 10건 -> 정밀 재검사 대상으로 넘긴다\n", + "위험 지수 재산출 일관성 100%, GBM-RF 예측 불일치(로그 표준편차) 0.177\n", + "\n", + "[위험 지수 상위 5개 로트]\n", + " lot_id vendor fsc spec_class delay_days risk grade confirmed_rate\n", + "LOT42-00958 V09 5935 precision -4 100.0 경보 0.1037\n", + "LOT42-00469 V10 5935 precision -3 100.0 경보 0.3054\n", + "LOT42-01133 V11 2530 precision 6 100.0 경보 0.3937\n", + "LOT42-00827 V12 2530 precision -1 100.0 경보 0.1025\n", + "LOT42-00303 V11 2530 precision -2 100.0 경보 0.1144\n", + "```\n", + "
\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 06. 수입 검사 불량 추세 및 고위험 납품업체 모니터링 품질 대시보드 구현\n", + "**소요시간**: 약 3.0시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "수입 검사 불량 추세 및 고위험 납품업체 모니터링 대시보드를 구현한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **수입 검사 불량 추세와 고위험 납품업체를 모니터링하는 품질 대시보드를 만들고, 경보 로트마다 기여도 상위 3개 인자를 설명한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **월별 표본 불량률과 관리한계, 업체별 평균 위험 지수와 추세(악화·유지), 위험 지수 분포, 대표 로트의 기여도 워터폴을 2x2 대시보드로 그린다 과정을 진행하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `src/explainability/qlt02_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": [ + "\"\"\"수령검사 불량 추세와 고위험 납품업체를 모니터링하는 품질 대시보드를 만들고, 경보 로트마다 7개 요인군의 정확한 섀플리 기여도를 계산해 기여도 상위 3개 인자와 자연어 설명·조치 제안을 자동 생성한다.\"\"\"\n", + "import itertools\n", + "import math\n", + "import numpy as np\n", + "import pandas as pd\n", + "import matplotlib.pyplot as plt\n", + "from scipy import stats\n", + "from sklearn.ensemble import GradientBoostingRegressor\n", + "\n", + "# ---------------------------------------------------------------------------\n", + "# [교육용가상자료] 군수품 수령검사 로트 데이터 생성기 (실제 업체·품목·계약 정보 없음)\n", + "# 구매요구(규격·긴급 여부) -> 계약(납기) -> 납품 -> 수령검사(표본) -> 확정 불량률(전수검사·사용부대 하자 반영)\n", + "# ---------------------------------------------------------------------------\n", + "FSC = {\"5305\": \"screw/bolt\", \"5330\": \"gasket\", \"5935\": \"connector\", \"6135\": \"battery\", \"2530\": \"brake part\", \"8415\": \"clothing\"}\n", + "VENDORS = [f\"V{i:02d}\" for i in range(1, 13)]\n", + "VENDOR_Q = dict(zip(VENDORS, [-0.6, -0.4, -0.3, -0.2, -0.1, 0.0, 0.1, 0.2, 0.3, 0.5, 0.8, 1.1])) # 업체 고유 품질 수준(숨은 값)\n", + "FSC_Q = {\"5305\": -0.3, \"5330\": 0.0, \"5935\": 0.25, \"6135\": 0.1, \"2530\": 0.35, \"8415\": -0.2}\n", + "SAMPLE_PLAN = [(500, 50, 3), (1200, 80, 5), (3200, 125, 7), (10 ** 9, 200, 10)] # (로트 크기 상한, 표본 n, 합격판정수 c) 단순화 기준\n", + "BAD_RATE = 0.05 # 확정 불량률 5% 초과 로트 = 불합격 대상\n", + "\n", + "\n", + "def sample_plan(lot_size):\n", + " # [TODO : 학생 실습 6] 수입 검사 불량 추세 및 고위험 납품업체 모니터링 품질 대시보드 구현 관련 핵심 로직을 구현하세요.\n", + " # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n", + " # <<< 여기에 코드를 작성하세요 >>>\n", + " pass # 작성 후 pass를 제거하세요.\n", + "def true_rate(d, rng=None):\n", + " \"\"\"숨은 불량 발생 구조: 업체 수준 + 품목군 + 정밀 규격 + 긴급 구매 + 규격 변경·불일치 + 납기 지연 + (정밀 규격 x 촉박 납기) 상호작용.\"\"\"\n", + " # [TODO : 학생 실습 6] 수입 검사 불량 추세 및 고위험 납품업체 모니터링 품질 대시보드 구현 관련 핵심 로직을 구현하세요.\n", + " # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n", + " # <<< 여기에 코드를 작성하세요 >>>\n", + " pass # 작성 후 pass를 제거하세요.\n", + "def make_lots(n=1200, seed=42, months=24):\n", + " rng = np.random.default_rng(seed)\n", + " d = pd.DataFrame({\"lot_id\": [f\"LOT{seed % 100:02d}-{i:05d}\" for i in range(n)]})\n", + " d[\"month\"] = rng.integers(0, months, n)\n", + " d[\"receipt_date\"] = pd.Timestamp(\"2024-01-01\") + pd.to_timedelta(d[\"month\"] * 30 + rng.integers(0, 30, n), unit=\"D\")\n", + " d = d.sort_values(\"receipt_date\").reset_index(drop=True)\n", + " d[\"month\"] = ((d[\"receipt_date\"] - pd.Timestamp(\"2024-01-01\")).dt.days // 30).clip(0, months - 1)\n", + " d[\"vendor\"] = rng.choice(VENDORS, n, p=np.r_[np.full(6, 0.1), np.full(6, 0.4 / 6)])\n", + " d[\"fsc\"] = rng.choice(list(FSC), n)\n", + " d[\"niin\"] = [f\"{f}-01-{int(k):03d}-{int(k) * 37 % 10000:04d}\" for f, k in zip(d[\"fsc\"], rng.integers(100, 110, n))]\n", + " d[\"spec_class\"] = np.where(rng.random(n) < np.where(d[\"fsc\"].isin([\"5935\", \"2530\"]), 0.6, 0.25), \"precision\", \"general\")\n", + " d[\"tolerance_mm\"] = np.where(d[\"spec_class\"] == \"precision\", rng.uniform(0.05, 0.1, n), rng.uniform(0.2, 0.5, n)).round(3)\n", + " d[\"spec_rev\"] = rng.integers(1, 6, n)\n", + " d[\"pr_urgent\"] = (rng.random(n) < 0.2).astype(int)\n", + " d[\"spec_changed\"] = (rng.random(n) < 0.12).astype(int)\n", + " d[\"spec_mismatch\"] = (rng.random(n) < 0.08).astype(int)\n", + " d[\"pr_qty\"] = rng.integers(200, 5000, n)\n", + " d[\"lot_size\"] = (d[\"pr_qty\"] * rng.uniform(0.5, 1.0, n)).astype(int)\n", + " d[\"lead_days\"] = np.where(d[\"pr_urgent\"] == 1, rng.integers(20, 60, n), rng.integers(40, 150, n))\n", + " d[\"delay_days\"] = np.round(rng.gamma(1.2, 6, n) * rng.binomial(1, 0.45, n) - rng.integers(0, 5, n)).astype(int)\n", + " d[\"unit_cost\"] = rng.choice([2_000, 15_000, 40_000, 120_000], n, p=[0.35, 0.3, 0.25, 0.1])\n", + " p = true_rate(d, rng)\n", + " d[\"sample_n\"], d[\"accept_c\"] = zip(*[sample_plan(s) for s in d[\"lot_size\"]])\n", + " d[\"defects\"] = rng.binomial(d[\"sample_n\"], p)\n", + " share = np.where(d[\"spec_class\"] == \"precision\", 0.55, 0.3)\n", + " d[\"def_dim\"] = rng.binomial(d[\"defects\"], share)\n", + " d[\"def_spec\"] = rng.binomial(d[\"defects\"] - d[\"def_dim\"], 0.6)\n", + " d[\"def_visual\"] = d[\"defects\"] - d[\"def_dim\"] - d[\"def_spec\"]\n", + " d[\"dim_dev_mm\"] = (d[\"tolerance_mm\"] * (0.3 + 2.0 * p) * rng.lognormal(0, 0.3, n)).round(4)\n", + " d[\"confirmed_rate\"] = np.clip(p * rng.lognormal(0, 0.05, n), 0.001, 0.4).round(4) # 전수검사·하자 반영 확정 불량률\n", + " d[\"field_claims\"] = rng.poisson(d[\"lot_size\"] * p * 0.03)\n", + " d[\"inspector\"] = rng.choice([\"INS-1\", \"INS-2\", \"INS-3\", \"INS-4\"], n)\n", + " d[\"emergency_release\"] = ((d[\"pr_urgent\"] == 1) & (rng.random(n) < 0.3)).astype(int) # 긴급 불출(검사 전 선불출) 예외\n", + " d[\"bad_lot\"] = (d[\"confirmed_rate\"] > BAD_RATE).astype(int)\n", + " return d\n", + "# ---------------------------------------------------------------------------\n", + "\n", + "# ---- 파생 피처 공통 함수 (미션 4 이후 공통) ----\n", + "def add_history(d):\n", + " \"\"\"업체별 과거 확정 불량률(현재 로트 이전 로트만 사용: 정보 누수 방지)과 최근 5로트 추세.\"\"\"\n", + " d = d.sort_values(\"receipt_date\").reset_index(drop=True)\n", + " g = d.groupby(\"vendor\")[\"confirmed_rate\"]\n", + " d[\"vendor_hist_rate\"] = g.transform(lambda s: s.shift().expanding().mean()).fillna(d[\"confirmed_rate\"].mean())\n", + " d[\"vendor_recent_rate\"] = g.transform(lambda s: s.shift().rolling(5, min_periods=1).mean()).fillna(d[\"vendor_hist_rate\"])\n", + " return d\n", + "\n", + "\n", + "def make_features(d):\n", + " d = add_history(d)\n", + " X = pd.DataFrame(index=d.index)\n", + " X[\"obs_rate\"] = np.log((d[\"defects\"] + 0.5) / d[\"sample_n\"]) # 1 표본 불량률\n", + " X[\"log_lot\"] = np.log(d[\"lot_size\"]) # 2 로트 규모(로그)\n", + " X[\"delay_pos\"] = d[\"delay_days\"].clip(lower=0) # 3 납기 지연일\n", + " X[\"delay_ratio\"] = X[\"delay_pos\"] / d[\"lead_days\"] # 4 지연/계약기간 비\n", + " X[\"short_lead\"] = (d[\"lead_days\"] < 45).astype(int) # 5 촉박 납기\n", + " X[\"precision\"] = (d[\"spec_class\"] == \"precision\").astype(int) # 6 정밀 규격\n", + " X[\"prec_x_short\"] = X[\"precision\"] * X[\"short_lead\"] # 7 정밀 x 촉박(상호작용)\n", + " X[\"spec_risk\"] = d[\"spec_changed\"] + d[\"spec_mismatch\"] # 8 규격 변경·불일치 합\n", + " X[\"dim_ratio\"] = np.log(d[\"dim_dev_mm\"] / d[\"tolerance_mm\"]) # 9 치수 편차/공차\n", + " X[\"vendor_hist\"] = np.log(d[\"vendor_hist_rate\"]) # 10 업체 과거 불량률\n", + " X[\"vendor_trend\"] = d[\"vendor_recent_rate\"] - d[\"vendor_hist_rate\"] # 11 업체 최근 추세\n", + " X[\"urgent\"] = d[\"pr_urgent\"] # 12 긴급 구매요구\n", + " X[\"fill_ratio\"] = d[\"lot_size\"] / d[\"pr_qty\"] # 13 구매요구 대비 납품 비율\n", + " X[\"dim_share\"] = d[\"def_dim\"] / d[\"defects\"].clip(lower=1) # 14 치수 오차 비중\n", + " for f in FSC: # 15~ 품목군 원-핫\n", + " X[f\"fsc_{f}\"] = (d[\"fsc\"] == f).astype(int)\n", + " return d, X\n", + "\n", + "GROUPS = {\"업체 이력\": [\"vendor_hist\", \"vendor_trend\"], \"규격 변경·불일치\": [\"spec_risk\"], \"정밀 규격\": [\"precision\"],\n", + " \"납기 지연·촉박\": [\"delay_pos\", \"delay_ratio\", \"short_lead\", \"prec_x_short\"],\n", + " \"표본검사 결과\": [\"obs_rate\", \"dim_ratio\", \"dim_share\"], \"구매요구 조건\": [\"urgent\", \"fill_ratio\", \"log_lot\"],\n", + " \"품목군\": [f\"fsc_{f}\" for f in FSC]}\n", + "ACTION = {\"업체 이력\": \"업체 품질 이력 재점검·시정조치 요구\", \"규격 변경·불일치\": \"구매요구 규격서와 납품 규격 대조\",\n", + " \"정밀 규격\": \"정밀 측정 장비로 치수 재측정\", \"납기 지연·촉박\": \"납기 여유 확보·지연 사유 확인\",\n", + " \"표본검사 결과\": \"표본 크기 확대 재검사\", \"구매요구 조건\": \"긴급 구매 건 사후 검사 강화\", \"품목군\": \"품목군별 검사 기준 확인\"}\n", + "\n", + "d, X = make_features(make_lots(1200, seed=42))\n", + "tr, te = (d[\"month\"] < 18).values, (d[\"month\"] >= 18).values\n", + "y = np.log(d[\"confirmed_rate\"].values)\n", + "gbm = GradientBoostingRegressor(n_estimators=300, max_depth=2, learning_rate=0.08, subsample=0.8, random_state=0).fit(X.values[tr], y[tr])\n", + "sigma = float(np.std(y[tr] - gbm.predict(X.values[tr])) * 1.5)\n", + "h = d[te].copy()\n", + "h[\"pred\"] = np.exp(gbm.predict(X.values[te]))\n", + "h[\"risk\"] = np.round(100 * (1 - stats.norm.cdf((np.log(BAD_RATE) - np.log(h[\"pred\"])) / sigma)), 1)\n", + "h[\"grade\"] = np.where(h[\"risk\"] >= 70, \"경보\", np.where(h[\"risk\"] >= 40, \"주의\", \"정상\"))\n", + "\n", + "# ---------------- 1) 대시보드 요약 표 ----------------\n", + "print(\"[품질 대시보드] 최근 6개월 수령검사 현황 [교육용가상자료]\")\n", + "m = d.groupby(\"month\").agg(sample_rate=(\"defects\", \"sum\"), n=(\"sample_n\", \"sum\"), conf=(\"confirmed_rate\", \"mean\"))\n", + "m[\"sample_rate\"] = m[\"sample_rate\"] / m[\"n\"]\n", + "p0 = m.loc[:17, \"sample_rate\"].mean()\n", + "ucl = p0 + 3 * np.sqrt(p0 * (1 - p0) / m[\"n\"])\n", + "print(f\"월별 표본 불량률 관리한계 초과 월: {list(m.index[m['sample_rate'] > ucl])} (기준 {p0:.2%})\")\n", + "vend = h.groupby(\"vendor\").agg(로트=(\"lot_id\", \"size\"), 평균위험=(\"risk\", \"mean\"), 경보=(\"grade\", lambda s: (s == \"경보\").sum()),\n", + " 실제불량률=(\"bad_lot\", \"mean\"), 최근추세=(\"vendor_recent_rate\", \"mean\"), 과거=(\"vendor_hist_rate\", \"mean\"))\n", + "vend[\"추세\"] = np.where(vend[\"최근추세\"] > vend[\"과거\"] * 1.2, \"악화\", \"유지\")\n", + "vend[\"상태\"] = np.where(vend[\"평균위험\"] >= 50, \"고위험\", np.where(vend[\"평균위험\"] >= 25, \"관찰\", \"정상\"))\n", + "print(vend.sort_values(\"평균위험\", ascending=False).head(6)[[\"로트\", \"평균위험\", \"경보\", \"실제불량률\", \"추세\", \"상태\"]].round(2).to_string())\n", + "\n", + "# ---------------- 2) 정확한 섀플리 기여도(요인군 7개, 2^7 연합) ----------------\n", + "cols = {g: [X.columns.get_loc(c) for c in cs] for g, cs in GROUPS.items()}\n", + "names = list(GROUPS)\n", + "bg = X[tr].sample(40, random_state=1).values.astype(float) # 배경 데이터(학습 구간 표본)\n", + "base_val = gbm.predict(bg).mean()\n", + "coal = list(itertools.product([0, 1], repeat=len(names)))\n", + "\n", + "\n", + "def shapley(x):\n", + " \"\"\"연합 S마다 S에 속한 요인군은 x 값, 나머지는 배경 값으로 바꿔 평균 예측 v(S)를 구하고 섀플리 공식으로 합산.\"\"\"\n", + " batch = np.repeat(bg[None], len(coal), axis=0)\n", + " for k, s in enumerate(coal):\n", + " for g, on in zip(names, s):\n", + " if on:\n", + " batch[k][:, cols[g]] = x[cols[g]]\n", + " v = dict(zip(coal, gbm.predict(batch.reshape(-1, bg.shape[1])).reshape(len(coal), -1).mean(1)))\n", + " n, phi = len(names), np.zeros(len(names))\n", + " for s in coal:\n", + " for i in range(n):\n", + " if not s[i]:\n", + " w = math.factorial(sum(s)) * math.factorial(n - sum(s) - 1) / math.factorial(n)\n", + " phi[i] += w * (v[s[:i] + (1,) + s[i + 1:]] - v[s])\n", + " return phi, v[tuple([1] * n)]\n", + "\n", + "\n", + "alert = h[h[\"grade\"] == \"경보\"]\n", + "rows = []\n", + "for idx in alert.index:\n", + " phi, full = shapley(X.loc[idx].values)\n", + " top = np.argsort(-phi)[:3]\n", + " rows.append({\"lot_id\": h.loc[idx, \"lot_id\"], \"risk\": h.loc[idx, \"risk\"], **{names[i]: phi[i] for i in range(len(names))},\n", + " \"top3\": [names[i] for i in top], \"top3_phi\": [phi[i] for i in top], \"add_err\": abs(base_val + phi.sum() - full)})\n", + "ex = pd.DataFrame(rows)\n", + "done = (ex[\"top3\"].map(len) == 3).mean()\n", + "print(f\"\\n경보 로트 {len(ex)}건 설명 생성: 상위 3개 인자 자동 산출률 {done:.0%} (목표 100%: {'달성' if done == 1 else '미달'}), \"\n", + " f\"가법성 오차 최대 {ex['add_err'].max():.1e}\")\n", + "print(\"[요인군별 평균 기여도(로그 불량률, 경보 로트)]\")\n", + "print(ex[names].mean().sort_values(ascending=False).round(3).to_string())\n", + "\n", + "print(\"\\n[자동 설명 예시]\")\n", + "for r in ex.sort_values(\"risk\", ascending=False).head(3).itertuples():\n", + " parts = \", \".join(f\"{g}(x{np.exp(p):.2f})\" for g, p in zip(r.top3, r.top3_phi))\n", + " print(f\" {r.lot_id} 위험 {r.risk:.0f}: {parts} 순으로 불량률 예측을 높였다 -> 조치: {ACTION[r.top3[0]]}\")\n", + "\n", + "# 설명 타당성 점검: 규격 불일치 로트에서 '규격' 요인이 양(+)으로 나오는가\n", + "mm = alert[\"spec_mismatch\"].values == 1\n", + "if mm.any():\n", + " print(f\"타당성 점검: 규격 불일치 경보 로트 {mm.sum()}건 중 '규격 변경·불일치' 기여 양수 {np.mean(ex.loc[mm, '규격 변경·불일치'] > 0):.0%}\")\n", + "\n", + "fig, ax = plt.subplots(2, 2, figsize=(13, 8))\n", + "ax[0, 0].plot(m.index, m[\"sample_rate\"], \"o-\", label=\"sample defect rate\"); ax[0, 0].plot(m.index, ucl, \"r--\", label=\"UCL (3 sigma)\")\n", + "ax[0, 0].axvspan(17.5, 23.5, color=\"gray\", alpha=0.1); ax[0, 0].legend(); ax[0, 0].set_title(\"Monthly inspection defect trend\"); ax[0, 0].set_xlabel(\"month\")\n", + "vs = vend.sort_values(\"평균위험\")\n", + "ax[0, 1].barh(vs.index, vs[\"평균위험\"], color=[\"tab:red\" if s == \"고위험\" else \"tab:orange\" if s == \"관찰\" else \"tab:green\" for s in vs[\"상태\"]])\n", + "ax[0, 1].set_title(\"Vendor mean risk score (last 6 months)\")\n", + "ax[1, 0].hist(h[\"risk\"], bins=25, color=\"tab:blue\"); ax[1, 0].axvline(40, c=\"orange\", ls=\"--\"); ax[1, 0].axvline(70, c=\"red\", ls=\"--\")\n", + "ax[1, 0].set_title(\"Lot risk score distribution\")\n", + "r0 = ex.sort_values(\"risk\", ascending=False).iloc[0]\n", + "vals = [r0[g] for g in names]; order = np.argsort(vals)[::-1]\n", + "cum = base_val\n", + "eng = [\"vendor history\", \"spec change\", \"precision spec\", \"delivery delay\", \"sample result\", \"PR condition\", \"item group\"]\n", + "for k, i in enumerate(order):\n", + " ax[1, 1].barh(k, vals[i], left=cum, color=\"tab:red\" if vals[i] > 0 else \"tab:blue\"); cum += vals[i]\n", + "ax[1, 1].set_yticks(range(len(order))); ax[1, 1].set_yticklabels([eng[i] for i in order]); ax[1, 1].invert_yaxis()\n", + "ax[1, 1].set_xlabel(\"log defect rate\"); ax[1, 1].set_title(f\"Waterfall: {r0['lot_id']}\")\n", + "plt.suptitle(\"QLT02 incoming inspection quality dashboard (synthetic)\"); plt.tight_layout(); plt.show()\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 06 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + "[품질 대시보드] 최근 6개월 수령검사 현황 [교육용가상자료]\n", + "월별 표본 불량률 관리한계 초과 월: [1, 11, 13, 18] (기준 4.09%)\n", + " 로트 평균위험 경보 실제불량률 추세 상태\n", + "vendor \n", + "V12 19 72.55 12 0.79 유지 고위험\n", + "V10 16 55.41 8 0.69 악화 고위험\n", + "V11 22 49.74 8 0.59 악화 관찰\n", + "V07 14 28.34 3 0.21 유지 관찰\n", + "V09 21 26.50 5 0.29 유지 관찰\n", + "V06 31 21.46 5 0.19 유지 정상\n", + "\n", + "경보 로트 59건 설명 생성: 상위 3개 인자 자동 산출률 100% (목표 100%: 달성), 가법성 오차 최대 6.7e-16\n", + "[요인군별 평균 기여도(로그 불량률, 경보 로트)]\n", + "표본검사 결과 0.615\n", + "납기 지연·촉박 0.216\n", + "정밀 규격 0.165\n", + "업체 이력 0.116\n", + "품목군 0.053\n", + "구매요구 조건 0.049\n", + "규격 변경·불일치 0.038\n", + "\n", + "[자동 설명 예시]\n", + " LOT42-00172 위험 100: 표본검사 결과(x3.45), 납기 지연·촉박(x2.22), 정밀 규격(x1.33) 순으로 불량률 예측을 높였다 -> 조치: 표본 크기 확대 재검사\n", + " LOT42-01133 위험 100: 표본검사 결과(x3.37), 납기 지연·촉박(x1.84), 정밀 규격(x1.28) 순으로 불량률 예측을 높였다 -> 조치: 표본 크기 확대 재검사\n", + " LOT42-01072 위험 100: 표본검사 결과(x2.72), 업체 이력(x1.62), 정밀 규격(x1.26) 순으로 불량률 예측을 높였다 -> 조치: 표본 크기 확대 재검사\n", + "타당성 점검: 규격 불일치 경보 로트 10건 중 '규격 변경·불일치' 기여 양수 100%\n", + "```\n", + "
\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 07. 합부 판정 경계선 품목에 대한 품질 검사관 정밀 재검사(HITL) 워크플로우 설계\n", + "**소요시간**: 약 3.5시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "합부 판정 경계선 품목에 대한 검사관 정밀 재검사(HITL) 워크플로우를 설계한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **합부 판정 경계선에 있는 로트를 자동 확정하지 않고 품질 검사관 정밀 재검사로 넘기는 인간 개입(HITL) 워크플로우를 설계하고, 한정된 검사 시간을 최적 배분한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **표본 불량 수가 합격판정수 근처이거나 위험 확률이 중간인 로트를 재검사 대기열에 등록 수행하고 검증하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `src/optimization/qlt02_simulator.py` 형태의 결과물을 완성한다.\n", + "\n", + "### 🔍 평가 및 검증 기준 (PASS 조건)\n", + "#### 미션 판정\n", + "\n", + "**평가 구분:** 필수\n", + "\n", + "**핵심 평가영역:** 현실적 군수 제약조건 반영도 및 시나리오별 대안 제시 적절성\n", + "\n", + "#### 미션 PASS 조건\n", + "\n", + "- 정량 기준: 최적화 적용 시 비용 15% 절감 또는 재고 회전율 20% 향상\n", + "\n", + "\n", + "- 진행 기준: 선형/정수 계획법(LP/MIP) 최적화 해 도출 및 시뮬레이션 완료\n", + "\n", + "\n", + "- 검증 방법: 과거 실적 데이터 대비 최적화 결과 비교 백테스팅\n", + "\n", + "\n", + "#### 채점자 판정 메모\n", + "\n", + "> 단순 코드 실행 여부뿐만 아니라 출력된 수치와 근거 자료가 군 실무 요구사항에 부합하는지 면밀히 확인한다.\n", + "\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "\"\"\"샘플링 판정과 위험 지수가 경계선에 있는 로트를 자동 확정하지 않고 검사관 정밀 재검사 대기열로 보내는 인간 개입(HITL) 워크플로우를 설계하고, 한정된 검사관 시간을 정수계획(MIP)으로 배분해 현행 선착순 방식 대비 품질 비용 절감과 수요 급증 민감도를 시뮬레이션한다.\"\"\"\n", + "import hashlib\n", + "import numpy as np\n", + "import pandas as pd\n", + "import matplotlib.pyplot as plt\n", + "from scipy import stats\n", + "from scipy.optimize import milp, LinearConstraint, Bounds\n", + "from sklearn.ensemble import GradientBoostingRegressor\n", + "\n", + "# ---------------------------------------------------------------------------\n", + "# [교육용가상자료] 군수품 수령검사 로트 데이터 생성기 (실제 업체·품목·계약 정보 없음)\n", + "# 구매요구(규격·긴급 여부) -> 계약(납기) -> 납품 -> 수령검사(표본) -> 확정 불량률(전수검사·사용부대 하자 반영)\n", + "# ---------------------------------------------------------------------------\n", + "FSC = {\"5305\": \"screw/bolt\", \"5330\": \"gasket\", \"5935\": \"connector\", \"6135\": \"battery\", \"2530\": \"brake part\", \"8415\": \"clothing\"}\n", + "VENDORS = [f\"V{i:02d}\" for i in range(1, 13)]\n", + "VENDOR_Q = dict(zip(VENDORS, [-0.6, -0.4, -0.3, -0.2, -0.1, 0.0, 0.1, 0.2, 0.3, 0.5, 0.8, 1.1])) # 업체 고유 품질 수준(숨은 값)\n", + "FSC_Q = {\"5305\": -0.3, \"5330\": 0.0, \"5935\": 0.25, \"6135\": 0.1, \"2530\": 0.35, \"8415\": -0.2}\n", + "SAMPLE_PLAN = [(500, 50, 3), (1200, 80, 5), (3200, 125, 7), (10 ** 9, 200, 10)] # (로트 크기 상한, 표본 n, 합격판정수 c) 단순화 기준\n", + "BAD_RATE = 0.05 # 확정 불량률 5% 초과 로트 = 불합격 대상\n", + "\n", + "\n", + "def sample_plan(lot_size):\n", + " # [TODO : 학생 실습 7] 합부 판정 경계선 품목에 대한 품질 검사관 정밀 재검사(HITL) 워크플로우 설계 관련 핵심 로직을 구현하세요.\n", + " # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n", + " # <<< 여기에 코드를 작성하세요 >>>\n", + " pass # 작성 후 pass를 제거하세요.\n", + "def true_rate(d, rng=None):\n", + " \"\"\"숨은 불량 발생 구조: 업체 수준 + 품목군 + 정밀 규격 + 긴급 구매 + 규격 변경·불일치 + 납기 지연 + (정밀 규격 x 촉박 납기) 상호작용.\"\"\"\n", + " # [TODO : 학생 실습 7] 합부 판정 경계선 품목에 대한 품질 검사관 정밀 재검사(HITL) 워크플로우 설계 관련 핵심 로직을 구현하세요.\n", + " # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n", + " # <<< 여기에 코드를 작성하세요 >>>\n", + " pass # 작성 후 pass를 제거하세요.\n", + "def make_lots(n=1200, seed=42, months=24):\n", + " rng = np.random.default_rng(seed)\n", + " d = pd.DataFrame({\"lot_id\": [f\"LOT{seed % 100:02d}-{i:05d}\" for i in range(n)]})\n", + " d[\"month\"] = rng.integers(0, months, n)\n", + " d[\"receipt_date\"] = pd.Timestamp(\"2024-01-01\") + pd.to_timedelta(d[\"month\"] * 30 + rng.integers(0, 30, n), unit=\"D\")\n", + " d = d.sort_values(\"receipt_date\").reset_index(drop=True)\n", + " d[\"month\"] = ((d[\"receipt_date\"] - pd.Timestamp(\"2024-01-01\")).dt.days // 30).clip(0, months - 1)\n", + " d[\"vendor\"] = rng.choice(VENDORS, n, p=np.r_[np.full(6, 0.1), np.full(6, 0.4 / 6)])\n", + " d[\"fsc\"] = rng.choice(list(FSC), n)\n", + " d[\"niin\"] = [f\"{f}-01-{int(k):03d}-{int(k) * 37 % 10000:04d}\" for f, k in zip(d[\"fsc\"], rng.integers(100, 110, n))]\n", + " d[\"spec_class\"] = np.where(rng.random(n) < np.where(d[\"fsc\"].isin([\"5935\", \"2530\"]), 0.6, 0.25), \"precision\", \"general\")\n", + " d[\"tolerance_mm\"] = np.where(d[\"spec_class\"] == \"precision\", rng.uniform(0.05, 0.1, n), rng.uniform(0.2, 0.5, n)).round(3)\n", + " d[\"spec_rev\"] = rng.integers(1, 6, n)\n", + " d[\"pr_urgent\"] = (rng.random(n) < 0.2).astype(int)\n", + " d[\"spec_changed\"] = (rng.random(n) < 0.12).astype(int)\n", + " d[\"spec_mismatch\"] = (rng.random(n) < 0.08).astype(int)\n", + " d[\"pr_qty\"] = rng.integers(200, 5000, n)\n", + " d[\"lot_size\"] = (d[\"pr_qty\"] * rng.uniform(0.5, 1.0, n)).astype(int)\n", + " d[\"lead_days\"] = np.where(d[\"pr_urgent\"] == 1, rng.integers(20, 60, n), rng.integers(40, 150, n))\n", + " d[\"delay_days\"] = np.round(rng.gamma(1.2, 6, n) * rng.binomial(1, 0.45, n) - rng.integers(0, 5, n)).astype(int)\n", + " d[\"unit_cost\"] = rng.choice([2_000, 15_000, 40_000, 120_000], n, p=[0.35, 0.3, 0.25, 0.1])\n", + " p = true_rate(d, rng)\n", + " d[\"sample_n\"], d[\"accept_c\"] = zip(*[sample_plan(s) for s in d[\"lot_size\"]])\n", + " d[\"defects\"] = rng.binomial(d[\"sample_n\"], p)\n", + " share = np.where(d[\"spec_class\"] == \"precision\", 0.55, 0.3)\n", + " d[\"def_dim\"] = rng.binomial(d[\"defects\"], share)\n", + " d[\"def_spec\"] = rng.binomial(d[\"defects\"] - d[\"def_dim\"], 0.6)\n", + " d[\"def_visual\"] = d[\"defects\"] - d[\"def_dim\"] - d[\"def_spec\"]\n", + " d[\"dim_dev_mm\"] = (d[\"tolerance_mm\"] * (0.3 + 2.0 * p) * rng.lognormal(0, 0.3, n)).round(4)\n", + " d[\"confirmed_rate\"] = np.clip(p * rng.lognormal(0, 0.05, n), 0.001, 0.4).round(4) # 전수검사·하자 반영 확정 불량률\n", + " d[\"field_claims\"] = rng.poisson(d[\"lot_size\"] * p * 0.03)\n", + " d[\"inspector\"] = rng.choice([\"INS-1\", \"INS-2\", \"INS-3\", \"INS-4\"], n)\n", + " d[\"emergency_release\"] = ((d[\"pr_urgent\"] == 1) & (rng.random(n) < 0.3)).astype(int) # 긴급 불출(검사 전 선불출) 예외\n", + " d[\"bad_lot\"] = (d[\"confirmed_rate\"] > BAD_RATE).astype(int)\n", + " return d\n", + "# ---------------------------------------------------------------------------\n", + "\n", + "# ---- 파생 피처 공통 함수 (미션 4 이후 공통) ----\n", + "def add_history(d):\n", + " \"\"\"업체별 과거 확정 불량률(현재 로트 이전 로트만 사용: 정보 누수 방지)과 최근 5로트 추세.\"\"\"\n", + " d = d.sort_values(\"receipt_date\").reset_index(drop=True)\n", + " g = d.groupby(\"vendor\")[\"confirmed_rate\"]\n", + " d[\"vendor_hist_rate\"] = g.transform(lambda s: s.shift().expanding().mean()).fillna(d[\"confirmed_rate\"].mean())\n", + " d[\"vendor_recent_rate\"] = g.transform(lambda s: s.shift().rolling(5, min_periods=1).mean()).fillna(d[\"vendor_hist_rate\"])\n", + " return d\n", + "\n", + "\n", + "def make_features(d):\n", + " d = add_history(d)\n", + " X = pd.DataFrame(index=d.index)\n", + " X[\"obs_rate\"] = np.log((d[\"defects\"] + 0.5) / d[\"sample_n\"]) # 1 표본 불량률\n", + " X[\"log_lot\"] = np.log(d[\"lot_size\"]) # 2 로트 규모(로그)\n", + " X[\"delay_pos\"] = d[\"delay_days\"].clip(lower=0) # 3 납기 지연일\n", + " X[\"delay_ratio\"] = X[\"delay_pos\"] / d[\"lead_days\"] # 4 지연/계약기간 비\n", + " X[\"short_lead\"] = (d[\"lead_days\"] < 45).astype(int) # 5 촉박 납기\n", + " X[\"precision\"] = (d[\"spec_class\"] == \"precision\").astype(int) # 6 정밀 규격\n", + " X[\"prec_x_short\"] = X[\"precision\"] * X[\"short_lead\"] # 7 정밀 x 촉박(상호작용)\n", + " X[\"spec_risk\"] = d[\"spec_changed\"] + d[\"spec_mismatch\"] # 8 규격 변경·불일치 합\n", + " X[\"dim_ratio\"] = np.log(d[\"dim_dev_mm\"] / d[\"tolerance_mm\"]) # 9 치수 편차/공차\n", + " X[\"vendor_hist\"] = np.log(d[\"vendor_hist_rate\"]) # 10 업체 과거 불량률\n", + " X[\"vendor_trend\"] = d[\"vendor_recent_rate\"] - d[\"vendor_hist_rate\"] # 11 업체 최근 추세\n", + " X[\"urgent\"] = d[\"pr_urgent\"] # 12 긴급 구매요구\n", + " X[\"fill_ratio\"] = d[\"lot_size\"] / d[\"pr_qty\"] # 13 구매요구 대비 납품 비율\n", + " X[\"dim_share\"] = d[\"def_dim\"] / d[\"defects\"].clip(lower=1) # 14 치수 오차 비중\n", + " for f in FSC: # 15~ 품목군 원-핫\n", + " X[f\"fsc_{f}\"] = (d[\"fsc\"] == f).astype(int)\n", + " return d, X\n", + "\n", + "# 교육용 가정 단가(원): 실제 계약·예산 자료 아님\n", + "HOUR_COST = 50_000 # 검사관 1시간 비용\n", + "MISS_FACTOR = 3.0 # 불량품이 부대로 불출될 때 단가 대비 처리비(교체·재수송·정비 손실) 배수\n", + "REJECT_COST_RATE = 0.03 # 양품 로트를 잘못 반품할 때 로트 금액 대비 지연·재납품 비용\n", + "CAP_HOURS = 24 # 월 정밀 재검사 가용 시간(검사관 2명 x 12시간)\n", + "\n", + "d, X = make_features(make_lots(1200, seed=42))\n", + "tr, te = (d[\"month\"] < 18).values, (d[\"month\"] >= 18).values\n", + "y = np.log(d[\"confirmed_rate\"].values)\n", + "gbm = GradientBoostingRegressor(n_estimators=300, max_depth=2, learning_rate=0.08, subsample=0.8, random_state=0).fit(X.values[tr], y[tr])\n", + "sigma = float(np.std(y[tr] - gbm.predict(X.values[tr])) * 1.5)\n", + "h = d[te].copy()\n", + "h[\"p_bad\"] = 1 - stats.norm.cdf((np.log(BAD_RATE) - gbm.predict(X.values[te])) / sigma)\n", + "h[\"accept\"] = h[\"defects\"] <= h[\"accept_c\"]\n", + "\n", + "\n", + "def costs(df):\n", + " value = df[\"lot_size\"] * df[\"unit_cost\"]\n", + " df[\"c_fa\"] = df[\"lot_size\"] * df[\"confirmed_rate\"] * df[\"unit_cost\"] * MISS_FACTOR # 오합격 시 실제 비용\n", + " df[\"c_fr\"] = value * REJECT_COST_RATE # 오불합격 시 실제 비용\n", + " df[\"hours\"] = np.where(df[\"spec_class\"] == \"precision\", 4.0, 2.0) + df[\"lot_size\"] / 2000\n", + " # 재검사 전 기대 비용(모델 추정): 합격 판정이면 P(불량)x오합격비, 불합격 판정이면 (1-P)x오불합격비\n", + " exp_fa = df[\"p_bad\"] * df[\"lot_size\"] * np.exp(np.log(BAD_RATE) + 0.7) * df[\"unit_cost\"] * MISS_FACTOR\n", + " df[\"benefit\"] = np.where(df[\"accept\"], exp_fa, (1 - df[\"p_bad\"]) * df[\"c_fr\"]) - df[\"hours\"] * HOUR_COST\n", + " df[\"realized_no\"] = np.where(df[\"accept\"], df[\"bad_lot\"] * df[\"c_fa\"], (1 - df[\"bad_lot\"]) * df[\"c_fr\"])\n", + " return df\n", + "\n", + "\n", + "# ---------------- 1) 경계선 로트 선별 -> 재검사 대기열(자동 확정 금지) ----------------\n", + "h[\"margin\"] = h[\"defects\"] - h[\"accept_c\"]\n", + "h[\"border\"] = (h[\"margin\"].abs() <= 1) | h[\"p_bad\"].between(0.3, 0.8) | (h[\"accept\"] & (h[\"p_bad\"] > 0.5))\n", + "q = costs(h[h[\"border\"]].copy())\n", + "print(f\"[1] 최근 6개월 로트 {len(h)}건 중 경계선 {len(q)}건 -> 검사관 재검사 대기열 등록, 자동 확정 0건 [교육용가상자료]\")\n", + "print(f\" 선별 기준: |표본불량-합격판정수|<=1, 위험확률 30~80%, 합격 판정인데 위험확률>50%\")\n", + "\n", + "\n", + "def select(df, policy, cap=CAP_HOURS):\n", + " \"\"\"월별 가용 시간 안에서 재검사 대상 선택. fifo=입고순, mip=기대 순편익 최대화 0-1 정수계획.\"\"\"\n", + " pick = np.zeros(len(df), bool)\n", + " for mth, g in df.groupby(\"month\"):\n", + " idx = np.where(df[\"month\"].values == mth)[0]\n", + " if policy == \"fifo\":\n", + " used = 0\n", + " for i in idx[np.argsort(df[\"receipt_date\"].values[idx])]:\n", + " if used + df[\"hours\"].values[i] <= cap:\n", + " pick[i] = True; used += df[\"hours\"].values[i]\n", + " else:\n", + " b, w = df[\"benefit\"].values[idx], df[\"hours\"].values[idx]\n", + " res = milp(c=-b, constraints=[LinearConstraint(w[None, :], 0, cap)], integrality=np.ones(len(idx)), bounds=Bounds(0, 1))\n", + " pick[idx] = res.x > 0.5\n", + " return pick\n", + "\n", + "\n", + "def total_cost(df, pick):\n", + " \"\"\"실제(확정) 결과로 계산: 재검사 로트는 검사비만(정밀 재검사로 정답 판정), 나머지는 샘플링 판정 오류 비용.\"\"\"\n", + " return float(np.sum(np.where(pick, df[\"hours\"] * HOUR_COST, df[\"realized_no\"])))\n", + "\n", + "\n", + "res = {}\n", + "for pol in (\"none\", \"fifo\", \"mip\"):\n", + " pick = np.zeros(len(q), bool) if pol == \"none\" else select(q, pol)\n", + " res[pol] = {\"재검사 로트\": int(pick.sum()), \"사용 시간\": round(float(q[\"hours\"][pick].sum()), 1), \"총 품질비용(백만원)\": round(total_cost(q, pick) / 1e6, 1),\n", + " \"놓친 불량 로트\": int(((~pick) & q[\"accept\"].values & (q[\"bad_lot\"].values == 1)).sum())}\n", + " if pol == \"mip\":\n", + " q[\"pick\"] = pick\n", + "tab = pd.DataFrame(res).T.astype({\"재검사 로트\": int, \"놓친 불량 로트\": int}).rename(index={\"none\": \"재검사 없음\", \"fifo\": \"현행(선착순)\", \"mip\": \"최적화(MIP)\"})\n", + "print(\"\\n[2] 재검사 배분 방식 비교 (월 가용 24시간)\")\n", + "print(tab.to_string())\n", + "cut = 1 - res[\"mip\"][\"총 품질비용(백만원)\"] / res[\"fifo\"][\"총 품질비용(백만원)\"]\n", + "print(f\"현행 대비 품질비용 {cut:.1%} 절감 (목표 15% 이상: {'달성' if cut >= 0.15 else '미달'})\")\n", + "\n", + "# ---------------- 3) 검사관 결정 기록(HITL) ----------------\n", + "log, prev, n_block = [], \"GENESIS\", 0\n", + "INSP = [\"INS-1\", \"INS-2\", \"INS-3\", \"INS-4\"]\n", + "for i, r in enumerate(q[q[\"pick\"]].head(12).itertuples()):\n", + " who = INSP[(INSP.index(r.inspector) + 1 + i) % 4]\n", + " if who == r.inspector: # 최초 검사자는 재검사 불가(이해충돌)\n", + " n_block += 1; who = INSP[(INSP.index(who) + 1) % 4]\n", + " verdict = \"불합격\" if r.bad_lot else \"합격\"\n", + " reason = f\"전수 측정 확정불량률 {r.confirmed_rate:.1%}, 치수편차 {r.dim_dev_mm}mm\"\n", + " rec = f\"{r.lot_id}|{who}|{verdict}|{reason}|{prev}\"\n", + " prev = hashlib.sha256(rec.encode()).hexdigest()[:12]\n", + " log.append({\"로트\": r.lot_id, \"샘플링판정\": \"합격\" if r.accept else \"불합격\", \"위험확률\": round(r.p_bad, 2), \"재검사관\": who, \"최종\": verdict, \"근거\": reason, \"hash\": prev})\n", + "L = pd.DataFrame(log)\n", + "flip = (L[\"샘플링판정\"] != L[\"최종\"]).sum()\n", + "print(f\"\\n[3] 검사관 재검사 기록 예시(12건 중 판정 번복 {flip}건, 근거 미기재 결정 0건, 최초 검사자 배정 차단 {n_block}건)\")\n", + "print(L.head(5)[[\"로트\", \"샘플링판정\", \"위험확률\", \"재검사관\", \"최종\", \"근거\"]].to_string(index=False))\n", + "\n", + "# ---------------- 4) 민감도: 전시 전환 등 돌발 수요 ----------------\n", + "print(\"\\n[4] 민감도 분석 (경계선 로트 증가, 검사 가용 시간 변화)\")\n", + "rows = []\n", + "for surge, capf in [(1.0, 1.0), (1.5, 1.0), (2.0, 1.0), (2.0, 0.7)]:\n", + " qq = q.sample(frac=surge, replace=surge > 1, random_state=7).reset_index(drop=True)\n", + " cf, cm = [total_cost(qq, select(qq, p, CAP_HOURS * capf)) for p in (\"fifo\", \"mip\")]\n", + " rows.append({\"물량 배수\": surge, \"가용시간\": f\"{capf:.0%}\", \"선착순(백만원)\": round(cf / 1e6, 1), \"MIP(백만원)\": round(cm / 1e6, 1), \"절감액(백만원)\": round((cf - cm) / 1e6, 1), \"절감률\": f\"{1 - cm / cf:.1%}\"})\n", + "sens = pd.DataFrame(rows)\n", + "print(sens.to_string(index=False))\n", + "print(\"물량이 늘면 절감률은 낮아지지만 절감액은 커진다. 가용 시간이 부족할수록 기대 손실이 큰 로트부터 재검사해야 한다.\")\n", + "print(\"최종 합부 판정은 검사관이 내리고, 모델은 재검사 순서만 제안한다.\")\n", + "\n", + "fig, ax = plt.subplots(1, 3, figsize=(15, 4))\n", + "ax[0].bar([\"none\", \"FIFO\", \"MIP\"], tab[\"총 품질비용(백만원)\"], color=[\"gray\", \"tab:orange\", \"tab:green\"]); ax[0].set_ylabel(\"quality cost (M KRW)\"); ax[0].set_title(\"Re-inspection allocation\")\n", + "ax[1].scatter(q[\"hours\"], q[\"benefit\"] / 1e6, c=q[\"pick\"].map({True: \"tab:green\", False: \"lightgray\"}), s=14)\n", + "ax[1].set_xlabel(\"re-inspection hours\"); ax[1].set_ylabel(\"expected net benefit (M KRW)\"); ax[1].set_title(\"MIP selection (green = selected)\")\n", + "ax[2].plot(range(len(sens)), [float(s[:-1]) for s in sens[\"절감률\"]], \"o-\"); ax[2].set_xticks(range(len(sens)))\n", + "ax[2].set_xticklabels([f\"x{a} / {b}\" for a, b in zip(sens[\"물량 배수\"], sens[\"가용시간\"])]); ax[2].set_ylabel(\"saving vs FIFO (%)\"); ax[2].set_title(\"Sensitivity: surge / capacity\")\n", + "plt.tight_layout(); plt.show()\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 07 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + "[1] 최근 6개월 로트 304건 중 경계선 57건 -> 검사관 재검사 대기열 등록, 자동 확정 0건 [교육용가상자료]\n", + " 선별 기준: |표본불량-합격판정수|<=1, 위험확률 30~80%, 합격 판정인데 위험확률>50%\n", + "\n", + "[2] 재검사 배분 방식 비교 (월 가용 24시간)\n", + " 재검사 로트 사용 시간 총 품질비용(백만원) 놓친 불량 로트\n", + "재검사 없음 0 0.0 57.5 13\n", + "현행(선착순) 35 137.7 25.7 5\n", + "최적화(MIP) 32 121.3 8.8 3\n", + "현행 대비 품질비용 65.8% 절감 (목표 15% 이상: 달성)\n", + "\n", + "[3] 검사관 재검사 기록 예시(12건 중 판정 번복 6건, 근거 미기재 결정 0건, 최초 검사자 배정 차단 3건)\n", + " 로트 샘플링판정 위험확률 재검사관 최종 근거\n", + "LOT42-00127 합격 0.89 INS-2 불합격 전수 측정 확정불량률 5.7%, 치수편차 0.0523mm\n", + "LOT42-00597 합격 0.11 INS-2 합격 전수 측정 확정불량률 3.3%, 치수편차 0.1563mm\n", + "LOT42-00159 합격 0.18 INS-1 합격 전수 측정 확정불량률 2.8%, 치수편차 0.1883mm\n", + "LOT42-01097 합격 0.08 INS-2 합격 전수 측정 확정불량률 2.6%, 치수편차 0.0287mm\n", + "LOT42-00246 합격 0.33 INS-2 불합격 전수 측정 확정불량률 6.7%, 치수편차 0.21mm\n", + "\n", + "[4] 민감도 분석 (경계선 로트 증가, 검사 가용 시간 변화)\n", + " 물량 배수 가용시간 선착순(백만원) MIP(백만원) 절감액(백만원) 절감률\n", + " 1.0 100% 25.7 8.8 16.9 65.9%\n", + " 1.5 100% 92.1 51.7 40.5 43.9%\n", + " 2.0 100% 123.6 77.9 45.7 36.9%\n", + " 2.0 70% 145.9 100.5 45.4 31.1%\n", + "물량이 늘면 절감률은 낮아지지만 절감액은 커진다. 가용 시간이 부족할수록 기대 손실이 큰 로트부터 재검사해야 한다.\n", + "최종 합부 판정은 검사관이 내리고, 모델은 재검사 순서만 제안한다.\n", + "```\n", + "
\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 08. 불량 부품 대량 납품 가상 주입 시 조기경보 신속성 스트레스 테스트\n", + "**소요시간**: 약 3.0시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "불량 부품 대량 납품 가상 주입 시 조기경보 스트레스 테스트를 수행한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **평소 품질이 좋던 업체가 불량 부품을 대량 납품하는 상황을 가상으로 주입해 조기경보의 정확도와 신속성, 대시보드 갱신 성능을 시험한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **우수 업체 V03에 불량 로트 위주의 대량 납품 60로트와 손상 레코드 12건 주입하여 처리 지연 및 복원력을 시험하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `src/dashboard/qlt02_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": [ + "\"\"\"평소 품질이 좋던 가상 업체가 불량 로트를 대량 납품하는 상황과 손상 레코드를 주입해, 로트 경보 정확도·업체 단위 누적합(CUSUM) 경보의 신속성·대시보드 갱신 시간(물량 1~50배)을 측정하는 조기경보 스트레스 테스트를 수행한다.\"\"\"\n", + "import time\n", + "import numpy as np\n", + "import pandas as pd\n", + "import matplotlib.pyplot as plt\n", + "from scipy import stats\n", + "from sklearn.ensemble import GradientBoostingRegressor\n", + "\n", + "# ---------------------------------------------------------------------------\n", + "# [교육용가상자료] 군수품 수령검사 로트 데이터 생성기 (실제 업체·품목·계약 정보 없음)\n", + "# 구매요구(규격·긴급 여부) -> 계약(납기) -> 납품 -> 수령검사(표본) -> 확정 불량률(전수검사·사용부대 하자 반영)\n", + "# ---------------------------------------------------------------------------\n", + "FSC = {\"5305\": \"screw/bolt\", \"5330\": \"gasket\", \"5935\": \"connector\", \"6135\": \"battery\", \"2530\": \"brake part\", \"8415\": \"clothing\"}\n", + "VENDORS = [f\"V{i:02d}\" for i in range(1, 13)]\n", + "VENDOR_Q = dict(zip(VENDORS, [-0.6, -0.4, -0.3, -0.2, -0.1, 0.0, 0.1, 0.2, 0.3, 0.5, 0.8, 1.1])) # 업체 고유 품질 수준(숨은 값)\n", + "FSC_Q = {\"5305\": -0.3, \"5330\": 0.0, \"5935\": 0.25, \"6135\": 0.1, \"2530\": 0.35, \"8415\": -0.2}\n", + "SAMPLE_PLAN = [(500, 50, 3), (1200, 80, 5), (3200, 125, 7), (10 ** 9, 200, 10)] # (로트 크기 상한, 표본 n, 합격판정수 c) 단순화 기준\n", + "BAD_RATE = 0.05 # 확정 불량률 5% 초과 로트 = 불합격 대상\n", + "\n", + "\n", + "def sample_plan(lot_size):\n", + " # [TODO : 학생 실습 8] 불량 부품 대량 납품 가상 주입 시 조기경보 신속성 스트레스 테스트 관련 핵심 로직을 구현하세요.\n", + " # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n", + " # <<< 여기에 코드를 작성하세요 >>>\n", + " pass # 작성 후 pass를 제거하세요.\n", + "def true_rate(d, rng=None):\n", + " \"\"\"숨은 불량 발생 구조: 업체 수준 + 품목군 + 정밀 규격 + 긴급 구매 + 규격 변경·불일치 + 납기 지연 + (정밀 규격 x 촉박 납기) 상호작용.\"\"\"\n", + " # [TODO : 학생 실습 8] 불량 부품 대량 납품 가상 주입 시 조기경보 신속성 스트레스 테스트 관련 핵심 로직을 구현하세요.\n", + " # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n", + " # <<< 여기에 코드를 작성하세요 >>>\n", + " pass # 작성 후 pass를 제거하세요.\n", + "def make_lots(n=1200, seed=42, months=24):\n", + " rng = np.random.default_rng(seed)\n", + " d = pd.DataFrame({\"lot_id\": [f\"LOT{seed % 100:02d}-{i:05d}\" for i in range(n)]})\n", + " d[\"month\"] = rng.integers(0, months, n)\n", + " d[\"receipt_date\"] = pd.Timestamp(\"2024-01-01\") + pd.to_timedelta(d[\"month\"] * 30 + rng.integers(0, 30, n), unit=\"D\")\n", + " d = d.sort_values(\"receipt_date\").reset_index(drop=True)\n", + " d[\"month\"] = ((d[\"receipt_date\"] - pd.Timestamp(\"2024-01-01\")).dt.days // 30).clip(0, months - 1)\n", + " d[\"vendor\"] = rng.choice(VENDORS, n, p=np.r_[np.full(6, 0.1), np.full(6, 0.4 / 6)])\n", + " d[\"fsc\"] = rng.choice(list(FSC), n)\n", + " d[\"niin\"] = [f\"{f}-01-{int(k):03d}-{int(k) * 37 % 10000:04d}\" for f, k in zip(d[\"fsc\"], rng.integers(100, 110, n))]\n", + " d[\"spec_class\"] = np.where(rng.random(n) < np.where(d[\"fsc\"].isin([\"5935\", \"2530\"]), 0.6, 0.25), \"precision\", \"general\")\n", + " d[\"tolerance_mm\"] = np.where(d[\"spec_class\"] == \"precision\", rng.uniform(0.05, 0.1, n), rng.uniform(0.2, 0.5, n)).round(3)\n", + " d[\"spec_rev\"] = rng.integers(1, 6, n)\n", + " d[\"pr_urgent\"] = (rng.random(n) < 0.2).astype(int)\n", + " d[\"spec_changed\"] = (rng.random(n) < 0.12).astype(int)\n", + " d[\"spec_mismatch\"] = (rng.random(n) < 0.08).astype(int)\n", + " d[\"pr_qty\"] = rng.integers(200, 5000, n)\n", + " d[\"lot_size\"] = (d[\"pr_qty\"] * rng.uniform(0.5, 1.0, n)).astype(int)\n", + " d[\"lead_days\"] = np.where(d[\"pr_urgent\"] == 1, rng.integers(20, 60, n), rng.integers(40, 150, n))\n", + " d[\"delay_days\"] = np.round(rng.gamma(1.2, 6, n) * rng.binomial(1, 0.45, n) - rng.integers(0, 5, n)).astype(int)\n", + " d[\"unit_cost\"] = rng.choice([2_000, 15_000, 40_000, 120_000], n, p=[0.35, 0.3, 0.25, 0.1])\n", + " p = true_rate(d, rng)\n", + " d[\"sample_n\"], d[\"accept_c\"] = zip(*[sample_plan(s) for s in d[\"lot_size\"]])\n", + " d[\"defects\"] = rng.binomial(d[\"sample_n\"], p)\n", + " share = np.where(d[\"spec_class\"] == \"precision\", 0.55, 0.3)\n", + " d[\"def_dim\"] = rng.binomial(d[\"defects\"], share)\n", + " d[\"def_spec\"] = rng.binomial(d[\"defects\"] - d[\"def_dim\"], 0.6)\n", + " d[\"def_visual\"] = d[\"defects\"] - d[\"def_dim\"] - d[\"def_spec\"]\n", + " d[\"dim_dev_mm\"] = (d[\"tolerance_mm\"] * (0.3 + 2.0 * p) * rng.lognormal(0, 0.3, n)).round(4)\n", + " d[\"confirmed_rate\"] = np.clip(p * rng.lognormal(0, 0.05, n), 0.001, 0.4).round(4) # 전수검사·하자 반영 확정 불량률\n", + " d[\"field_claims\"] = rng.poisson(d[\"lot_size\"] * p * 0.03)\n", + " d[\"inspector\"] = rng.choice([\"INS-1\", \"INS-2\", \"INS-3\", \"INS-4\"], n)\n", + " d[\"emergency_release\"] = ((d[\"pr_urgent\"] == 1) & (rng.random(n) < 0.3)).astype(int) # 긴급 불출(검사 전 선불출) 예외\n", + " d[\"bad_lot\"] = (d[\"confirmed_rate\"] > BAD_RATE).astype(int)\n", + " return d\n", + "# ---------------------------------------------------------------------------\n", + "\n", + "# ---- 파생 피처 공통 함수 (미션 4 이후 공통) ----\n", + "def add_history(d):\n", + " \"\"\"업체별 과거 확정 불량률(현재 로트 이전 로트만 사용: 정보 누수 방지)과 최근 5로트 추세.\"\"\"\n", + " d = d.sort_values(\"receipt_date\").reset_index(drop=True)\n", + " g = d.groupby(\"vendor\")[\"confirmed_rate\"]\n", + " d[\"vendor_hist_rate\"] = g.transform(lambda s: s.shift().expanding().mean()).fillna(d[\"confirmed_rate\"].mean())\n", + " d[\"vendor_recent_rate\"] = g.transform(lambda s: s.shift().rolling(5, min_periods=1).mean()).fillna(d[\"vendor_hist_rate\"])\n", + " return d\n", + "\n", + "\n", + "def make_features(d):\n", + " d = add_history(d)\n", + " X = pd.DataFrame(index=d.index)\n", + " X[\"obs_rate\"] = np.log((d[\"defects\"] + 0.5) / d[\"sample_n\"]) # 1 표본 불량률\n", + " X[\"log_lot\"] = np.log(d[\"lot_size\"]) # 2 로트 규모(로그)\n", + " X[\"delay_pos\"] = d[\"delay_days\"].clip(lower=0) # 3 납기 지연일\n", + " X[\"delay_ratio\"] = X[\"delay_pos\"] / d[\"lead_days\"] # 4 지연/계약기간 비\n", + " X[\"short_lead\"] = (d[\"lead_days\"] < 45).astype(int) # 5 촉박 납기\n", + " X[\"precision\"] = (d[\"spec_class\"] == \"precision\").astype(int) # 6 정밀 규격\n", + " X[\"prec_x_short\"] = X[\"precision\"] * X[\"short_lead\"] # 7 정밀 x 촉박(상호작용)\n", + " X[\"spec_risk\"] = d[\"spec_changed\"] + d[\"spec_mismatch\"] # 8 규격 변경·불일치 합\n", + " X[\"dim_ratio\"] = np.log(d[\"dim_dev_mm\"] / d[\"tolerance_mm\"]) # 9 치수 편차/공차\n", + " X[\"vendor_hist\"] = np.log(d[\"vendor_hist_rate\"]) # 10 업체 과거 불량률\n", + " X[\"vendor_trend\"] = d[\"vendor_recent_rate\"] - d[\"vendor_hist_rate\"] # 11 업체 최근 추세\n", + " X[\"urgent\"] = d[\"pr_urgent\"] # 12 긴급 구매요구\n", + " X[\"fill_ratio\"] = d[\"lot_size\"] / d[\"pr_qty\"] # 13 구매요구 대비 납품 비율\n", + " X[\"dim_share\"] = d[\"def_dim\"] / d[\"defects\"].clip(lower=1) # 14 치수 오차 비중\n", + " for f in FSC: # 15~ 품목군 원-핫\n", + " X[f\"fsc_{f}\"] = (d[\"fsc\"] == f).astype(int)\n", + " return d, X\n", + "\n", + "base = make_lots(1200, seed=42)\n", + "# ---------------- 1) 대량 불량 납품 주입 ----------------\n", + "rng = np.random.default_rng(88)\n", + "inj = make_lots(60, seed=88)\n", + "inj[\"vendor\"] = \"V03\" # 평소 우수 업체(숨은 품질 -0.3)\n", + "inj[\"receipt_date\"] = pd.Timestamp(\"2025-09-01\") + pd.to_timedelta(np.sort(rng.integers(0, 25, 60)), unit=\"D\")\n", + "inj[\"month\"] = 20\n", + "inj[\"lot_size\"] = (inj[\"lot_size\"] * 2).astype(int) # 대량 납품\n", + "p = np.where(rng.random(60) < 0.8, rng.uniform(0.08, 0.25, 60), rng.uniform(0.01, 0.03, 60)) # 80%는 불량 로트\n", + "inj[\"sample_n\"], inj[\"accept_c\"] = zip(*[sample_plan(s) for s in inj[\"lot_size\"]])\n", + "inj[\"defects\"] = rng.binomial(inj[\"sample_n\"], p)\n", + "inj[\"def_dim\"] = rng.binomial(inj[\"defects\"], 0.5); inj[\"def_spec\"] = inj[\"defects\"] - inj[\"def_dim\"]; inj[\"def_visual\"] = 0\n", + "inj[\"dim_dev_mm\"] = (inj[\"tolerance_mm\"] * (0.3 + 2.0 * p) * rng.lognormal(0, 0.3, 60)).round(4)\n", + "inj[\"confirmed_rate\"] = p.round(4); inj[\"bad_lot\"] = (p > BAD_RATE).astype(int)\n", + "inj[\"lot_id\"] = [f\"INJ-{i:03d}\" for i in range(60)]\n", + "inj[\"injected\"] = 1\n", + "base[\"injected\"] = 0\n", + "allx = pd.concat([base, inj], ignore_index=True)\n", + "\n", + "# 손상 레코드 주입(측정치 결측, 음수 불량 수, 표본 0) -> 격리\n", + "bad_idx = rng.choice(len(allx), 12, replace=False)\n", + "allx.loc[bad_idx[:4], \"dim_dev_mm\"] = np.nan\n", + "allx.loc[bad_idx[4:8], \"defects\"] = -1\n", + "allx.loc[bad_idx[8:], \"sample_n\"] = 0\n", + "\n", + "\n", + "def quarantine(df):\n", + " ok = df[\"dim_dev_mm\"].notna() & (df[\"defects\"] >= 0) & (df[\"sample_n\"] > 0) & (df[\"defects\"] <= df[\"sample_n\"])\n", + " return df[ok].copy(), df[~ok].copy()\n", + "\n", + "\n", + "clean, quar = quarantine(allx)\n", + "print(f\"[1] 주입: V03 업체 대량 납품 60로트(불량 {inj['bad_lot'].sum()}로트) + 손상 레코드 {len(bad_idx)}건 [교육용가상자료]\")\n", + "print(f\" 손상 레코드 {len(quar)}건 격리(재입력 요청), 파이프라인 중단 0건\")\n", + "\n", + "d, X = make_features(clean)\n", + "tr = (d[\"month\"] < 18).values\n", + "y = np.log(d[\"confirmed_rate\"].values)\n", + "gbm = GradientBoostingRegressor(n_estimators=300, max_depth=2, learning_rate=0.08, subsample=0.8, random_state=0).fit(X.values[tr], y[tr])\n", + "sigma = float(np.std(y[tr] - gbm.predict(X.values[tr])) * 1.5)\n", + "\n", + "\n", + "def score(Xv):\n", + " return 100 * (1 - stats.norm.cdf((np.log(BAD_RATE) - gbm.predict(Xv)) / sigma))\n", + "\n", + "\n", + "# ---------------- 2) 로트 경보 정확도 ----------------\n", + "win = (d[\"month\"] >= 18).values\n", + "w = d[win].copy(); w[\"risk\"] = score(X.values[win])\n", + "w[\"alert\"] = (w[\"risk\"] >= 70) | (w[\"defects\"] > w[\"accept_c\"]) # 위험 지수 경보 또는 샘플링 불합격\n", + "acc = (w[\"alert\"] == (w[\"bad_lot\"] == 1)).mean()\n", + "wi = w[w[\"injected\"] == 1]\n", + "tp, fp = int((w[\"alert\"] & (w[\"bad_lot\"] == 1)).sum()), int((w[\"alert\"] & (w[\"bad_lot\"] == 0)).sum())\n", + "print(f\"\\n[2] 스트레스 구간 로트 {len(w)}건: 경보 발령 정확도 {acc:.1%} (목표 95% 이상: {'달성' if acc >= 0.95 else '미달'})\")\n", + "print(f\" 주입 불량 로트 탐지 {int((wi['alert'] & (wi['bad_lot'] == 1)).sum())}/{int(wi['bad_lot'].sum())}, 전체 정탐 {tp}건, 오경보 {fp}건\")\n", + "acc_i = (wi[\"alert\"] == (wi[\"bad_lot\"] == 1)).mean()\n", + "print(f\" 대량 납품 주입 로트 {len(wi)}건 경보 정확도 {acc_i:.1%} (목표 95% 이상: {'달성' if acc_i >= 0.95 else '미달'})\")\n", + "print(f\" 샘플링 판정만 사용 시 정확도 {((w['defects'] > w['accept_c']) == (w['bad_lot'] == 1)).mean():.1%}\")\n", + "\n", + "# ---------------- 3) 업체 단위 CUSUM 경보 신속성 ----------------\n", + "v3 = d[d[\"vendor\"] == \"V03\"].sort_values(\"receipt_date\")\n", + "ref = v3[v3[\"month\"] < 18]\n", + "p0 = (ref[\"defects\"].sum() / ref[\"sample_n\"].sum())\n", + "first = v3[v3[\"injected\"] == 1][\"receipt_date\"].min()\n", + "k, hlim, s, alarm, false_alarm = p0 * 2, 0.15, 0.0, None, 0\n", + "trace = []\n", + "for r in v3.itertuples():\n", + " s = max(0.0, s + r.defects / r.sample_n - k) # 기준의 2배를 넘는 표본 불량률만 누적\n", + " trace.append(s)\n", + " if s > hlim and r.receipt_date < first:\n", + " false_alarm += 1; s = 0.0 # 평시 오경보: 기록 후 초기화\n", + " if alarm is None and s > hlim and r.injected == 1:\n", + " alarm = r\n", + "n_before = int((v3[v3[\"injected\"] == 1][\"receipt_date\"] <= alarm.receipt_date).sum()) if alarm is not None else None\n", + "print(f\"\\n[3] V03 업체 CUSUM(기준 표본불량률 {p0:.2%}): 대량 납품 시작 후 {n_before}번째 로트에서 업체 경보 발령\"\n", + " f\" ({(alarm.receipt_date - first).days}일 경과) -> 해당 업체 후속 로트 전수검사 전환 건의\")\n", + "print(f\" 주입 이전 V03 로트 {int((v3['receipt_date'] < first).sum())}건 중 업체 오경보 {false_alarm}건\")\n", + "\n", + "# ---------------- 4) 대시보드 갱신 시간 ----------------\n", + "def refresh(df, Xv):\n", + " t0 = time.perf_counter()\n", + " rk = score(Xv)\n", + " vend = pd.DataFrame({\"vendor\": df[\"vendor\"].values, \"risk\": rk, \"bad\": df[\"bad_lot\"].values}).groupby(\"vendor\").agg([\"mean\", \"size\"])\n", + " trend = df.groupby(\"month\")[\"defects\"].sum() / df.groupby(\"month\")[\"sample_n\"].sum()\n", + " top = np.argsort(-rk)[:20]\n", + " return time.perf_counter() - t0\n", + "\n", + "\n", + "print(\"\\n[4] 대시보드 갱신 시간(위험 산출+업체 집계+추세+상위 20건)\")\n", + "rows = []\n", + "for mult in (1, 10, 50):\n", + " big = pd.concat([d] * mult, ignore_index=True); Xb = np.tile(X.values, (mult, 1))\n", + " tt = np.median([refresh(big, Xb) for _ in range(3)])\n", + " rows.append({\"물량 배수\": mult, \"로트 수\": len(big), \"갱신 시간(초)\": round(tt, 3), \"2초 이내\": \"달성\" if tt <= 2 else \"미달\"})\n", + "print(pd.DataFrame(rows).to_string(index=False))\n", + "\n", + "fig, ax = plt.subplots(1, 3, figsize=(15, 4))\n", + "ax[0].scatter(w[\"risk\"], w[\"confirmed_rate\"], c=np.where(w[\"injected\"] == 1, \"tab:red\", \"tab:blue\"), s=10)\n", + "ax[0].axvline(70, ls=\"--\", c=\"k\"); ax[0].axhline(BAD_RATE, ls=\":\", c=\"k\"); ax[0].set_xlabel(\"risk score\"); ax[0].set_ylabel(\"confirmed rate\")\n", + "ax[0].set_title(\"Lot alerts (red = injected surge)\")\n", + "ax[1].plot(range(len(trace)), trace); ax[1].axhline(hlim, c=\"r\", ls=\"--\"); ax[1].set_title(\"Vendor V03 CUSUM\"); ax[1].set_xlabel(\"lot sequence\")\n", + "rr = pd.DataFrame(rows); ax[2].plot(rr[\"로트 수\"], rr[\"갱신 시간(초)\"], \"o-\"); ax[2].axhline(2, c=\"r\", ls=\"--\"); ax[2].set_xscale(\"log\")\n", + "ax[2].set_xlabel(\"lots\"); ax[2].set_ylabel(\"seconds\"); ax[2].set_title(\"Dashboard refresh time\")\n", + "plt.tight_layout(); plt.show()\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 08 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + "[1] 주입: V03 업체 대량 납품 60로트(불량 54로트) + 손상 레코드 12건 [교육용가상자료]\n", + " 손상 레코드 12건 격리(재입력 요청), 파이프라인 중단 0건\n", + "\n", + "[2] 스트레스 구간 로트 360건: 경보 발령 정확도 94.2% (목표 95% 이상: 미달)\n", + " 주입 불량 로트 탐지 54/54, 전체 정탐 117건, 오경보 12건\n", + " 대량 납품 주입 로트 60건 경보 정확도 98.3% (목표 95% 이상: 달성)\n", + " 샘플링 판정만 사용 시 정확도 93.1%\n", + "\n", + "[3] V03 업체 CUSUM(기준 표본불량률 2.40%): 대량 납품 시작 후 6번째 로트에서 업체 경보 발령 (1일 경과) -> 해당 업체 후속 로트 전수검사 전환 건의\n", + " 주입 이전 V03 로트 107건 중 업체 오경보 1건\n", + "\n", + "[4] 대시보드 갱신 시간(위험 산출+업체 집계+추세+상위 20건)\n", + " 물량 배수 로트 수 갱신 시간(초) 2초 이내\n", + " 1 1248 0.012 달성\n", + " 10 12480 0.044 달성\n", + " 50 62400 0.172 달성\n", + "```\n", + "
\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 09. AAR 기반 불량 오판정 사례 분석 및 검사 샘플링 파라미터 재보정\n", + "**소요시간**: 약 3.0시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "AAR 기반 불량 오판정 사례를 분석하고 샘플링 파라미터를 재보정한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **고정 샘플링 기준의 불량 오판정 사례를 사후검토(AAR)로 분석하고, 위험 등급별 샘플링 파라미터를 재보정해 정량 효과를 산출한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **학습 구간의 오합격·오불합격 사례를 경계선 불량률, 업체 이력 미반영, 정밀 규격 기준 동일 등 원인으로 분류 수행하고 검증하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `reports/qlt02_roi_analysis.md` 형태의 결과물을 완성한다.\n", + "\n", + "### 🔍 평가 및 검증 기준 (PASS 조건)\n", + "#### 미션 판정\n", + "\n", + "**평가 구분:** 필수\n", + "\n", + "**핵심 평가영역:** 추정 산식의 객관성 및 군 재정/군수 기획 부서 설득력\n", + "\n", + "#### 미션 PASS 조건\n", + "\n", + "- 정량 기준: 비용 절감액, 시간 단축률 등 3대 정량 효과 지표 산출\n", + "\n", + "\n", + "- 진행 기준: ROI 분석 보고서 완성 및 주요 지표 민감도 분석 수록\n", + "\n", + "\n", + "- 검증 방법: 회계/기획 기준 모델과의 비교 검증\n", + "\n", + "\n", + "#### 채점자 판정 메모\n", + "\n", + "> 단순 코드 실행 여부뿐만 아니라 출력된 수치와 근거 자료가 군 실무 요구사항에 부합하는지 면밀히 확인한다.\n", + "\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "\"\"\"고정 샘플링 기준의 오판정(오합격·오불합격) 사례를 사후검토(AAR)로 원인 분류하고, 입고 전 정보로 나눈 위험 등급별 표본 크기·합격판정수를 학습 구간에서 재보정해 검증 구간에서 비용 절감액·검사 시간 단축률·불량 유출 감소율의 3대 효과 지표를 산출한다.\"\"\"\n", + "import numpy as np\n", + "import pandas as pd\n", + "import matplotlib.pyplot as plt\n", + "from sklearn.linear_model import LogisticRegression\n", + "\n", + "# ---------------------------------------------------------------------------\n", + "# [교육용가상자료] 군수품 수령검사 로트 데이터 생성기 (실제 업체·품목·계약 정보 없음)\n", + "# 구매요구(규격·긴급 여부) -> 계약(납기) -> 납품 -> 수령검사(표본) -> 확정 불량률(전수검사·사용부대 하자 반영)\n", + "# ---------------------------------------------------------------------------\n", + "FSC = {\"5305\": \"screw/bolt\", \"5330\": \"gasket\", \"5935\": \"connector\", \"6135\": \"battery\", \"2530\": \"brake part\", \"8415\": \"clothing\"}\n", + "VENDORS = [f\"V{i:02d}\" for i in range(1, 13)]\n", + "VENDOR_Q = dict(zip(VENDORS, [-0.6, -0.4, -0.3, -0.2, -0.1, 0.0, 0.1, 0.2, 0.3, 0.5, 0.8, 1.1])) # 업체 고유 품질 수준(숨은 값)\n", + "FSC_Q = {\"5305\": -0.3, \"5330\": 0.0, \"5935\": 0.25, \"6135\": 0.1, \"2530\": 0.35, \"8415\": -0.2}\n", + "SAMPLE_PLAN = [(500, 50, 3), (1200, 80, 5), (3200, 125, 7), (10 ** 9, 200, 10)] # (로트 크기 상한, 표본 n, 합격판정수 c) 단순화 기준\n", + "BAD_RATE = 0.05 # 확정 불량률 5% 초과 로트 = 불합격 대상\n", + "\n", + "\n", + "def sample_plan(lot_size):\n", + " # [TODO : 학생 실습 9] AAR 기반 불량 오판정 사례 분석 및 검사 샘플링 파라미터 재보정 관련 핵심 로직을 구현하세요.\n", + " # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n", + " # <<< 여기에 코드를 작성하세요 >>>\n", + " pass # 작성 후 pass를 제거하세요.\n", + "def true_rate(d, rng=None):\n", + " \"\"\"숨은 불량 발생 구조: 업체 수준 + 품목군 + 정밀 규격 + 긴급 구매 + 규격 변경·불일치 + 납기 지연 + (정밀 규격 x 촉박 납기) 상호작용.\"\"\"\n", + " # [TODO : 학생 실습 9] AAR 기반 불량 오판정 사례 분석 및 검사 샘플링 파라미터 재보정 관련 핵심 로직을 구현하세요.\n", + " # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n", + " # <<< 여기에 코드를 작성하세요 >>>\n", + " pass # 작성 후 pass를 제거하세요.\n", + "def make_lots(n=1200, seed=42, months=24):\n", + " rng = np.random.default_rng(seed)\n", + " d = pd.DataFrame({\"lot_id\": [f\"LOT{seed % 100:02d}-{i:05d}\" for i in range(n)]})\n", + " d[\"month\"] = rng.integers(0, months, n)\n", + " d[\"receipt_date\"] = pd.Timestamp(\"2024-01-01\") + pd.to_timedelta(d[\"month\"] * 30 + rng.integers(0, 30, n), unit=\"D\")\n", + " d = d.sort_values(\"receipt_date\").reset_index(drop=True)\n", + " d[\"month\"] = ((d[\"receipt_date\"] - pd.Timestamp(\"2024-01-01\")).dt.days // 30).clip(0, months - 1)\n", + " d[\"vendor\"] = rng.choice(VENDORS, n, p=np.r_[np.full(6, 0.1), np.full(6, 0.4 / 6)])\n", + " d[\"fsc\"] = rng.choice(list(FSC), n)\n", + " d[\"niin\"] = [f\"{f}-01-{int(k):03d}-{int(k) * 37 % 10000:04d}\" for f, k in zip(d[\"fsc\"], rng.integers(100, 110, n))]\n", + " d[\"spec_class\"] = np.where(rng.random(n) < np.where(d[\"fsc\"].isin([\"5935\", \"2530\"]), 0.6, 0.25), \"precision\", \"general\")\n", + " d[\"tolerance_mm\"] = np.where(d[\"spec_class\"] == \"precision\", rng.uniform(0.05, 0.1, n), rng.uniform(0.2, 0.5, n)).round(3)\n", + " d[\"spec_rev\"] = rng.integers(1, 6, n)\n", + " d[\"pr_urgent\"] = (rng.random(n) < 0.2).astype(int)\n", + " d[\"spec_changed\"] = (rng.random(n) < 0.12).astype(int)\n", + " d[\"spec_mismatch\"] = (rng.random(n) < 0.08).astype(int)\n", + " d[\"pr_qty\"] = rng.integers(200, 5000, n)\n", + " d[\"lot_size\"] = (d[\"pr_qty\"] * rng.uniform(0.5, 1.0, n)).astype(int)\n", + " d[\"lead_days\"] = np.where(d[\"pr_urgent\"] == 1, rng.integers(20, 60, n), rng.integers(40, 150, n))\n", + " d[\"delay_days\"] = np.round(rng.gamma(1.2, 6, n) * rng.binomial(1, 0.45, n) - rng.integers(0, 5, n)).astype(int)\n", + " d[\"unit_cost\"] = rng.choice([2_000, 15_000, 40_000, 120_000], n, p=[0.35, 0.3, 0.25, 0.1])\n", + " p = true_rate(d, rng)\n", + " d[\"sample_n\"], d[\"accept_c\"] = zip(*[sample_plan(s) for s in d[\"lot_size\"]])\n", + " d[\"defects\"] = rng.binomial(d[\"sample_n\"], p)\n", + " share = np.where(d[\"spec_class\"] == \"precision\", 0.55, 0.3)\n", + " d[\"def_dim\"] = rng.binomial(d[\"defects\"], share)\n", + " d[\"def_spec\"] = rng.binomial(d[\"defects\"] - d[\"def_dim\"], 0.6)\n", + " d[\"def_visual\"] = d[\"defects\"] - d[\"def_dim\"] - d[\"def_spec\"]\n", + " d[\"dim_dev_mm\"] = (d[\"tolerance_mm\"] * (0.3 + 2.0 * p) * rng.lognormal(0, 0.3, n)).round(4)\n", + " d[\"confirmed_rate\"] = np.clip(p * rng.lognormal(0, 0.05, n), 0.001, 0.4).round(4) # 전수검사·하자 반영 확정 불량률\n", + " d[\"field_claims\"] = rng.poisson(d[\"lot_size\"] * p * 0.03)\n", + " d[\"inspector\"] = rng.choice([\"INS-1\", \"INS-2\", \"INS-3\", \"INS-4\"], n)\n", + " d[\"emergency_release\"] = ((d[\"pr_urgent\"] == 1) & (rng.random(n) < 0.3)).astype(int) # 긴급 불출(검사 전 선불출) 예외\n", + " d[\"bad_lot\"] = (d[\"confirmed_rate\"] > BAD_RATE).astype(int)\n", + " return d\n", + "# ---------------------------------------------------------------------------\n", + "\n", + "# ---- 파생 피처 공통 함수 (미션 4 이후 공통) ----\n", + "def add_history(d):\n", + " \"\"\"업체별 과거 확정 불량률(현재 로트 이전 로트만 사용: 정보 누수 방지)과 최근 5로트 추세.\"\"\"\n", + " d = d.sort_values(\"receipt_date\").reset_index(drop=True)\n", + " g = d.groupby(\"vendor\")[\"confirmed_rate\"]\n", + " d[\"vendor_hist_rate\"] = g.transform(lambda s: s.shift().expanding().mean()).fillna(d[\"confirmed_rate\"].mean())\n", + " d[\"vendor_recent_rate\"] = g.transform(lambda s: s.shift().rolling(5, min_periods=1).mean()).fillna(d[\"vendor_hist_rate\"])\n", + " return d\n", + "\n", + "\n", + "def make_features(d):\n", + " d = add_history(d)\n", + " X = pd.DataFrame(index=d.index)\n", + " X[\"obs_rate\"] = np.log((d[\"defects\"] + 0.5) / d[\"sample_n\"]) # 1 표본 불량률\n", + " X[\"log_lot\"] = np.log(d[\"lot_size\"]) # 2 로트 규모(로그)\n", + " X[\"delay_pos\"] = d[\"delay_days\"].clip(lower=0) # 3 납기 지연일\n", + " X[\"delay_ratio\"] = X[\"delay_pos\"] / d[\"lead_days\"] # 4 지연/계약기간 비\n", + " X[\"short_lead\"] = (d[\"lead_days\"] < 45).astype(int) # 5 촉박 납기\n", + " X[\"precision\"] = (d[\"spec_class\"] == \"precision\").astype(int) # 6 정밀 규격\n", + " X[\"prec_x_short\"] = X[\"precision\"] * X[\"short_lead\"] # 7 정밀 x 촉박(상호작용)\n", + " X[\"spec_risk\"] = d[\"spec_changed\"] + d[\"spec_mismatch\"] # 8 규격 변경·불일치 합\n", + " X[\"dim_ratio\"] = np.log(d[\"dim_dev_mm\"] / d[\"tolerance_mm\"]) # 9 치수 편차/공차\n", + " X[\"vendor_hist\"] = np.log(d[\"vendor_hist_rate\"]) # 10 업체 과거 불량률\n", + " X[\"vendor_trend\"] = d[\"vendor_recent_rate\"] - d[\"vendor_hist_rate\"] # 11 업체 최근 추세\n", + " X[\"urgent\"] = d[\"pr_urgent\"] # 12 긴급 구매요구\n", + " X[\"fill_ratio\"] = d[\"lot_size\"] / d[\"pr_qty\"] # 13 구매요구 대비 납품 비율\n", + " X[\"dim_share\"] = d[\"def_dim\"] / d[\"defects\"].clip(lower=1) # 14 치수 오차 비중\n", + " for f in FSC: # 15~ 품목군 원-핫\n", + " X[f\"fsc_{f}\"] = (d[\"fsc\"] == f).astype(int)\n", + " return d, X\n", + "\n", + "# 교육용 가정 단가(원): 실제 예산·계약 자료 아님\n", + "SAMPLE_MIN, MIN_COST = 6, 1_000 # 표본 1개 검사 6분, 검사 인건비 분당 1천원\n", + "MISS_FACTOR, REJECT_RATE = 3.0, 0.03 # 오합격: 불량품 단가 x3 손실 / 오불합격: 로트 금액의 3% 지연·재납품 비용\n", + "\n", + "d, _ = make_features(make_lots(1200, seed=42))\n", + "tr, te = d[d[\"month\"] < 18].copy(), d[d[\"month\"] >= 18].copy()\n", + "\n", + "\n", + "def evaluate(df, n, c, seed=0):\n", + " \"\"\"주어진 표본 크기 n과 합격판정수 c로 로트를 다시 검사(확정 불량률로 이항 모의)해 판정·비용을 계산한다.\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " dfc = rng.binomial(n, df[\"confirmed_rate\"].values)\n", + " acc = dfc <= c\n", + " bad = df[\"bad_lot\"].values == 1\n", + " value = (df[\"lot_size\"] * df[\"unit_cost\"]).values\n", + " c_insp = n * SAMPLE_MIN * MIN_COST\n", + " c_fa = np.where(acc & bad, df[\"lot_size\"].values * df[\"confirmed_rate\"].values * df[\"unit_cost\"].values * MISS_FACTOR, 0)\n", + " c_fr = np.where(~acc & ~bad, value * REJECT_RATE, 0)\n", + " return {\"acc\": acc, \"FA\": int((acc & bad).sum()), \"FR\": int((~acc & ~bad).sum()), \"검사분\": float(np.sum(n * SAMPLE_MIN)),\n", + " \"검사비\": float(np.sum(c_insp)), \"유출손실\": float(c_fa.sum()), \"반품손실\": float(c_fr.sum()), \"총비용\": float(np.sum(c_insp) + c_fa.sum() + c_fr.sum()),\n", + " \"유출 불량품\": float(np.sum(np.where(acc, df[\"lot_size\"].values * df[\"confirmed_rate\"].values, 0)))}\n", + "\n", + "\n", + "# ---------------- 1) AAR: 현행 고정 샘플링 오판정 원인 분류 ----------------\n", + "base_tr = evaluate(tr, tr[\"sample_n\"].values, tr[\"accept_c\"].values, seed=1)\n", + "t = tr.assign(acc=base_tr[\"acc\"])\n", + "fa = t[t[\"acc\"] & (t[\"bad_lot\"] == 1)].copy()\n", + "fr = t[~t[\"acc\"] & (t[\"bad_lot\"] == 0)].copy()\n", + "fa[\"원인\"] = np.select([fa[\"confirmed_rate\"] < 0.08, fa[\"vendor_hist_rate\"] > 0.06, fa[\"spec_class\"] == \"precision\"],\n", + " [\"경계선 불량률(5~8%)을 표본이 못 잡음\", \"고위험 업체 이력 미반영\", \"정밀 규격인데 표본·기준 동일\"], \"표본 우연 변동\")\n", + "fr[\"원인\"] = np.where(fr[\"confirmed_rate\"] > 0.035, \"불량률 4~5% 경계 로트 과판정\", \"표본 우연 변동(소형 로트)\")\n", + "print(f\"[1] AAR: 학습 구간 {len(t)}로트 중 오합격 {len(fa)}건, 오불합격 {len(fr)}건 [교육용가상자료]\")\n", + "print(\" 오합격 원인:\", fa[\"원인\"].value_counts().to_dict())\n", + "print(\" 오불합격 원인:\", fr[\"원인\"].value_counts().to_dict())\n", + "print(\" -> 모든 로트에 로트 크기만으로 같은 기준을 적용해, 위험이 높은 로트는 판정이 느슨하고 낮은 로트는 과잉 검사하고 있다.\")\n", + "\n", + "# ---------------- 2) 입고 전 정보로 위험 등급 분류 ----------------\n", + "PRE = [\"vendor_hist_rate\", \"delay_days\", \"lead_days\", \"pr_urgent\", \"spec_changed\", \"spec_mismatch\"]\n", + "\n", + "\n", + "def pre_X(df):\n", + " x = df[PRE].astype(float).copy()\n", + " x[\"vendor_hist_rate\"] = np.log(x[\"vendor_hist_rate\"]); x[\"precision\"] = (df[\"spec_class\"] == \"precision\").astype(float)\n", + " x[\"prec_short\"] = x[\"precision\"] * (df[\"lead_days\"] < 45); x[\"delay_pos\"] = df[\"delay_days\"].clip(lower=0)\n", + " return x.values\n", + "\n", + "\n", + "clf = LogisticRegression(max_iter=2000, C=1.0).fit(pre_X(tr), tr[\"bad_lot\"])\n", + "for df in (tr, te):\n", + " pr = clf.predict_proba(pre_X(df))[:, 1]\n", + " df[\"tier\"] = np.where(pr >= 0.35, \"고위험\", np.where(pr >= 0.08, \"보통\", \"저위험\"))\n", + "print(f\"\\n[2] 입고 전 위험 등급(학습 구간): {tr['tier'].value_counts().to_dict()}, 등급별 실제 불량 로트 비율 \"\n", + " f\"{tr.groupby('tier')['bad_lot'].mean().round(3).to_dict()}\")\n", + "\n", + "# ---------------- 3) 등급별 (n, c) 재보정: 학습 구간 총비용 최소 ----------------\n", + "grid = [(n, c) for n in (20, 32, 50, 80, 125, 200, 315) for c in range(0, 12) if c < n * 0.08]\n", + "plan = {}\n", + "for tier in (\"저위험\", \"보통\", \"고위험\"):\n", + " sub = tr[tr[\"tier\"] == tier]\n", + " best = min(grid, key=lambda nc: np.mean([evaluate(sub, np.full(len(sub), nc[0]), nc[1], seed=s)[\"총비용\"] for s in range(3)]))\n", + " plan[tier] = best\n", + "print(\"\\n[3] 재보정 샘플링 기준(학습 구간 비용 최소):\", {k: f\"n={v[0]}, c={v[1]}\" for k, v in plan.items()})\n", + "\n", + "# ---------------- 4) 검증 구간 효과(3대 지표) ----------------\n", + "old = evaluate(te, te[\"sample_n\"].values, te[\"accept_c\"].values, seed=9)\n", + "new = evaluate(te, te[\"tier\"].map(lambda k: plan[k][0]).values, te[\"tier\"].map(lambda k: plan[k][1]).values, seed=9)\n", + "cmp_ = pd.DataFrame({\"현행 고정\": old, \"재보정\": new}).drop(\"acc\").T\n", + "print(\"\\n[4] 검증 구간(최근 6개월) 비교\")\n", + "show = cmp_[[\"FA\", \"FR\", \"검사분\", \"총비용\", \"유출 불량품\"]].copy()\n", + "show[\"총비용\"] = (show[\"총비용\"] / 1e6).round(1).astype(str) + \"백만원\"\n", + "show[\"검사분\"] = (show[\"검사분\"] / 60).round(0).astype(int).astype(str) + \"시간\"\n", + "show[\"유출 불량품\"] = show[\"유출 불량품\"].round(0).astype(int)\n", + "print(show.rename(columns={\"FA\": \"오합격\", \"FR\": \"오불합격\", \"검사분\": \"검사시간\"}).to_string())\n", + "saving = old[\"총비용\"] - new[\"총비용\"]\n", + "time_cut = 1 - new[\"검사분\"] / old[\"검사분\"]\n", + "leak_cut = 1 - new[\"유출 불량품\"] / old[\"유출 불량품\"]\n", + "print(\"\\n[3대 정량 효과 지표] (산식: 현행-재보정, 동일 난수로 같은 로트를 다시 검사한 모의 결과)\")\n", + "print(f\" 1) 품질비용 절감액 = 검사비+유출손실+반품손실 차이 = {saving / 1e6:.1f}백만원 ({saving / old['총비용']:.1%})\")\n", + "print(f\" 2) 검사 시간 단축률 = 1 - 재보정 검사분/현행 검사분 = {time_cut:.1%}\")\n", + "print(f\" 3) 불량품 유출 감소율 = 1 - 재보정 유출 수량/현행 유출 수량 = {leak_cut:.1%}\")\n", + "print(\" 고위험 등급은 c=0으로 엄격해져 오불합격이 늘지만, 반품 비용보다 유출 손실 감소가 커서 총비용이 줄었다.\")\n", + "print(\" 가정 단가는 교육용이며, 실제 적용 시 부대 검사 기록과 재정 부서 단가로 다시 산출한다.\")\n", + "\n", + "fig, ax = plt.subplots(1, 3, figsize=(15, 4))\n", + "ax[0].bar(range(fa[\"원인\"].nunique()), fa[\"원인\"].value_counts().values, color=\"tab:red\")\n", + "ax[0].set_title(\"False-accept root causes\"); ax[0].set_xticks(range(fa[\"원인\"].nunique())); ax[0].set_xticklabels([f\"cause {i + 1}\" for i in range(fa[\"원인\"].nunique())])\n", + "x = np.arange(3); ax[1].bar(x - 0.2, [old[\"FA\"], old[\"FR\"], old[\"검사분\"] / 600], 0.4, label=\"fixed plan\")\n", + "ax[1].bar(x + 0.2, [new[\"FA\"], new[\"FR\"], new[\"검사분\"] / 600], 0.4, label=\"risk-based plan\")\n", + "ax[1].set_xticks(x); ax[1].set_xticklabels([\"false accept\", \"false reject\", \"insp. hours/10\"]); ax[1].legend(); ax[1].set_title(\"Hold-out comparison\")\n", + "ax[2].bar([\"cost saving %\", \"time cut %\", \"leak cut %\"], [saving / old[\"총비용\"] * 100, time_cut * 100, leak_cut * 100], color=\"tab:green\"); ax[2].set_title(\"ROI indicators\")\n", + "plt.tight_layout(); plt.show()\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 09 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + "[1] AAR: 학습 구간 896로트 중 오합격 58건, 오불합격 25건 [교육용가상자료]\n", + " 오합격 원인: {'경계선 불량률(5~8%)을 표본이 못 잡음': 53, '고위험 업체 이력 미반영': 4, '정밀 규격인데 표본·기준 동일': 1}\n", + " 오불합격 원인: {'불량률 4~5% 경계 로트 과판정': 19, '표본 우연 변동(소형 로트)': 6}\n", + " -> 모든 로트에 로트 크기만으로 같은 기준을 적용해, 위험이 높은 로트는 판정이 느슨하고 낮은 로트는 과잉 검사하고 있다.\n", + "\n", + "[2] 입고 전 위험 등급(학습 구간): {'저위험': 487, '고위험': 233, '보통': 176}, 등급별 실제 불량 로트 비율 {'고위험': 0.803, '보통': 0.125, '저위험': 0.002}\n", + "\n", + "[3] 재보정 샘플링 기준(학습 구간 비용 최소): {'저위험': 'n=32, c=2', '보통': 'n=20, c=1', '고위험': 'n=50, c=0'}\n", + "\n", + "[4] 검증 구간(최근 6개월) 비교\n", + " 오합격 오불합격 검사시간 총비용 유출 불량품\n", + "현행 고정 19 7 3676시간 415.9백만원 11491\n", + "재보정 5 26 1052시간 234.6백만원 8562\n", + "\n", + "[3대 정량 효과 지표] (산식: 현행-재보정, 동일 난수로 같은 로트를 다시 검사한 모의 결과)\n", + " 1) 품질비용 절감액 = 검사비+유출손실+반품손실 차이 = 181.3백만원 (43.6%)\n", + " 2) 검사 시간 단축률 = 1 - 재보정 검사분/현행 검사분 = 71.4%\n", + " 3) 불량품 유출 감소율 = 1 - 재보정 유출 수량/현행 유출 수량 = 25.5%\n", + " 고위험 등급은 c=0으로 엄격해져 오불합격이 늘지만, 반품 비용보다 유출 손실 감소가 커서 총비용이 줄었다.\n", + " 가정 단가는 교육용이며, 실제 적용 시 부대 검사 기록과 재정 부서 단가로 다시 산출한다.\n", + "```\n", + "
\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 10. 군수품 수령 검사 불량 패턴 분석 및 품질 조기경보 시스템 통합 패키지 완성\n", + "**소요시간**: 약 3.5시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "군수품 수령 검사 불량 패턴 분석 및 품질 조기경보 시스템을 완성한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **데이터 검증부터 재학습, 배포 판정, 월간 조기경보까지 이어지는 자동 재학습 파이프라인으로 품질 조기경보 시스템을 통합한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **손상·중복 레코드를 격리하는 검증 단계, 피처 생성, 분포 변화(PSI) 점검을 거쳐 도전 모델을 재학습 수행하고 검증하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `docs/qlt02_operations_guide.md 및 pipeline/qlt02_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": [ + "\"\"\"데이터 검증·격리, 피처 생성, 분포 변화(PSI) 점검, 도전 모델 재학습과 배포 게이트 판정, 월간 조기경보 산출까지 이어지는 자동 재학습 파이프라인으로 품질 조기경보 시스템을 통합하고, 자체 점검표·운영 가이드 목차·브리핑 요약을 출력한다.\"\"\"\n", + "import importlib\n", + "import sys\n", + "import time\n", + "import numpy as np\n", + "import pandas as pd\n", + "import matplotlib.pyplot as plt\n", + "from scipy import stats\n", + "from sklearn.ensemble import GradientBoostingRegressor\n", + "from sklearn.metrics import mean_absolute_error, r2_score\n", + "\n", + "# ---------------------------------------------------------------------------\n", + "# [교육용가상자료] 군수품 수령검사 로트 데이터 생성기 (실제 업체·품목·계약 정보 없음)\n", + "# 구매요구(규격·긴급 여부) -> 계약(납기) -> 납품 -> 수령검사(표본) -> 확정 불량률(전수검사·사용부대 하자 반영)\n", + "# ---------------------------------------------------------------------------\n", + "FSC = {\"5305\": \"screw/bolt\", \"5330\": \"gasket\", \"5935\": \"connector\", \"6135\": \"battery\", \"2530\": \"brake part\", \"8415\": \"clothing\"}\n", + "VENDORS = [f\"V{i:02d}\" for i in range(1, 13)]\n", + "VENDOR_Q = dict(zip(VENDORS, [-0.6, -0.4, -0.3, -0.2, -0.1, 0.0, 0.1, 0.2, 0.3, 0.5, 0.8, 1.1])) # 업체 고유 품질 수준(숨은 값)\n", + "FSC_Q = {\"5305\": -0.3, \"5330\": 0.0, \"5935\": 0.25, \"6135\": 0.1, \"2530\": 0.35, \"8415\": -0.2}\n", + "SAMPLE_PLAN = [(500, 50, 3), (1200, 80, 5), (3200, 125, 7), (10 ** 9, 200, 10)] # (로트 크기 상한, 표본 n, 합격판정수 c) 단순화 기준\n", + "BAD_RATE = 0.05 # 확정 불량률 5% 초과 로트 = 불합격 대상\n", + "\n", + "\n", + "def sample_plan(lot_size):\n", + " # [TODO : 학생 실습 10] 군수품 수령 검사 불량 패턴 분석 및 품질 조기경보 시스템 통합 패키지 완성 관련 핵심 로직을 구현하세요.\n", + " # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n", + " # <<< 여기에 코드를 작성하세요 >>>\n", + " pass # 작성 후 pass를 제거하세요.\n", + "def true_rate(d, rng=None):\n", + " \"\"\"숨은 불량 발생 구조: 업체 수준 + 품목군 + 정밀 규격 + 긴급 구매 + 규격 변경·불일치 + 납기 지연 + (정밀 규격 x 촉박 납기) 상호작용.\"\"\"\n", + " # [TODO : 학생 실습 10] 군수품 수령 검사 불량 패턴 분석 및 품질 조기경보 시스템 통합 패키지 완성 관련 핵심 로직을 구현하세요.\n", + " # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n", + " # <<< 여기에 코드를 작성하세요 >>>\n", + " pass # 작성 후 pass를 제거하세요.\n", + "def make_lots(n=1200, seed=42, months=24):\n", + " rng = np.random.default_rng(seed)\n", + " d = pd.DataFrame({\"lot_id\": [f\"LOT{seed % 100:02d}-{i:05d}\" for i in range(n)]})\n", + " d[\"month\"] = rng.integers(0, months, n)\n", + " d[\"receipt_date\"] = pd.Timestamp(\"2024-01-01\") + pd.to_timedelta(d[\"month\"] * 30 + rng.integers(0, 30, n), unit=\"D\")\n", + " d = d.sort_values(\"receipt_date\").reset_index(drop=True)\n", + " d[\"month\"] = ((d[\"receipt_date\"] - pd.Timestamp(\"2024-01-01\")).dt.days // 30).clip(0, months - 1)\n", + " d[\"vendor\"] = rng.choice(VENDORS, n, p=np.r_[np.full(6, 0.1), np.full(6, 0.4 / 6)])\n", + " d[\"fsc\"] = rng.choice(list(FSC), n)\n", + " d[\"niin\"] = [f\"{f}-01-{int(k):03d}-{int(k) * 37 % 10000:04d}\" for f, k in zip(d[\"fsc\"], rng.integers(100, 110, n))]\n", + " d[\"spec_class\"] = np.where(rng.random(n) < np.where(d[\"fsc\"].isin([\"5935\", \"2530\"]), 0.6, 0.25), \"precision\", \"general\")\n", + " d[\"tolerance_mm\"] = np.where(d[\"spec_class\"] == \"precision\", rng.uniform(0.05, 0.1, n), rng.uniform(0.2, 0.5, n)).round(3)\n", + " d[\"spec_rev\"] = rng.integers(1, 6, n)\n", + " d[\"pr_urgent\"] = (rng.random(n) < 0.2).astype(int)\n", + " d[\"spec_changed\"] = (rng.random(n) < 0.12).astype(int)\n", + " d[\"spec_mismatch\"] = (rng.random(n) < 0.08).astype(int)\n", + " d[\"pr_qty\"] = rng.integers(200, 5000, n)\n", + " d[\"lot_size\"] = (d[\"pr_qty\"] * rng.uniform(0.5, 1.0, n)).astype(int)\n", + " d[\"lead_days\"] = np.where(d[\"pr_urgent\"] == 1, rng.integers(20, 60, n), rng.integers(40, 150, n))\n", + " d[\"delay_days\"] = np.round(rng.gamma(1.2, 6, n) * rng.binomial(1, 0.45, n) - rng.integers(0, 5, n)).astype(int)\n", + " d[\"unit_cost\"] = rng.choice([2_000, 15_000, 40_000, 120_000], n, p=[0.35, 0.3, 0.25, 0.1])\n", + " p = true_rate(d, rng)\n", + " d[\"sample_n\"], d[\"accept_c\"] = zip(*[sample_plan(s) for s in d[\"lot_size\"]])\n", + " d[\"defects\"] = rng.binomial(d[\"sample_n\"], p)\n", + " share = np.where(d[\"spec_class\"] == \"precision\", 0.55, 0.3)\n", + " d[\"def_dim\"] = rng.binomial(d[\"defects\"], share)\n", + " d[\"def_spec\"] = rng.binomial(d[\"defects\"] - d[\"def_dim\"], 0.6)\n", + " d[\"def_visual\"] = d[\"defects\"] - d[\"def_dim\"] - d[\"def_spec\"]\n", + " d[\"dim_dev_mm\"] = (d[\"tolerance_mm\"] * (0.3 + 2.0 * p) * rng.lognormal(0, 0.3, n)).round(4)\n", + " d[\"confirmed_rate\"] = np.clip(p * rng.lognormal(0, 0.05, n), 0.001, 0.4).round(4) # 전수검사·하자 반영 확정 불량률\n", + " d[\"field_claims\"] = rng.poisson(d[\"lot_size\"] * p * 0.03)\n", + " d[\"inspector\"] = rng.choice([\"INS-1\", \"INS-2\", \"INS-3\", \"INS-4\"], n)\n", + " d[\"emergency_release\"] = ((d[\"pr_urgent\"] == 1) & (rng.random(n) < 0.3)).astype(int) # 긴급 불출(검사 전 선불출) 예외\n", + " d[\"bad_lot\"] = (d[\"confirmed_rate\"] > BAD_RATE).astype(int)\n", + " return d\n", + "# ---------------------------------------------------------------------------\n", + "\n", + "# ---- 파생 피처 공통 함수 (미션 4 이후 공통) ----\n", + "def add_history(d):\n", + " \"\"\"업체별 과거 확정 불량률(현재 로트 이전 로트만 사용: 정보 누수 방지)과 최근 5로트 추세.\"\"\"\n", + " d = d.sort_values(\"receipt_date\").reset_index(drop=True)\n", + " g = d.groupby(\"vendor\")[\"confirmed_rate\"]\n", + " d[\"vendor_hist_rate\"] = g.transform(lambda s: s.shift().expanding().mean()).fillna(d[\"confirmed_rate\"].mean())\n", + " d[\"vendor_recent_rate\"] = g.transform(lambda s: s.shift().rolling(5, min_periods=1).mean()).fillna(d[\"vendor_hist_rate\"])\n", + " return d\n", + "\n", + "\n", + "def make_features(d):\n", + " d = add_history(d)\n", + " X = pd.DataFrame(index=d.index)\n", + " X[\"obs_rate\"] = np.log((d[\"defects\"] + 0.5) / d[\"sample_n\"]) # 1 표본 불량률\n", + " X[\"log_lot\"] = np.log(d[\"lot_size\"]) # 2 로트 규모(로그)\n", + " X[\"delay_pos\"] = d[\"delay_days\"].clip(lower=0) # 3 납기 지연일\n", + " X[\"delay_ratio\"] = X[\"delay_pos\"] / d[\"lead_days\"] # 4 지연/계약기간 비\n", + " X[\"short_lead\"] = (d[\"lead_days\"] < 45).astype(int) # 5 촉박 납기\n", + " X[\"precision\"] = (d[\"spec_class\"] == \"precision\").astype(int) # 6 정밀 규격\n", + " X[\"prec_x_short\"] = X[\"precision\"] * X[\"short_lead\"] # 7 정밀 x 촉박(상호작용)\n", + " X[\"spec_risk\"] = d[\"spec_changed\"] + d[\"spec_mismatch\"] # 8 규격 변경·불일치 합\n", + " X[\"dim_ratio\"] = np.log(d[\"dim_dev_mm\"] / d[\"tolerance_mm\"]) # 9 치수 편차/공차\n", + " X[\"vendor_hist\"] = np.log(d[\"vendor_hist_rate\"]) # 10 업체 과거 불량률\n", + " X[\"vendor_trend\"] = d[\"vendor_recent_rate\"] - d[\"vendor_hist_rate\"] # 11 업체 최근 추세\n", + " X[\"urgent\"] = d[\"pr_urgent\"] # 12 긴급 구매요구\n", + " X[\"fill_ratio\"] = d[\"lot_size\"] / d[\"pr_qty\"] # 13 구매요구 대비 납품 비율\n", + " X[\"dim_share\"] = d[\"def_dim\"] / d[\"defects\"].clip(lower=1) # 14 치수 오차 비중\n", + " for f in FSC: # 15~ 품목군 원-핫\n", + " X[f\"fsc_{f}\"] = (d[\"fsc\"] == f).astype(int)\n", + " return d, X\n", + "\n", + "t_start = time.perf_counter()\n", + "libs = [\"numpy\", \"pandas\", \"scipy\", \"sklearn\", \"matplotlib\"]\n", + "env_ok = sum(importlib.util.find_spec(m) is not None for m in libs)\n", + "print(f\"[0] 환경 점검: Python {sys.version_info.major}.{sys.version_info.minor}, 필수 라이브러리 {env_ok}/{len(libs)}\")\n", + "\n", + "REQUIRED = [\"lot_id\", \"vendor\", \"fsc\", \"spec_class\", \"lot_size\", \"sample_n\", \"defects\", \"dim_dev_mm\", \"tolerance_mm\", \"lead_days\", \"delay_days\", \"confirmed_rate\"]\n", + "\n", + "\n", + "def validate(df):\n", + " \"\"\"1단계 데이터 검증: 필수 컬럼, 결측, 범위, 중복. 위반 레코드는 격리하고 사유를 남긴다.\"\"\"\n", + " miss = [c for c in REQUIRED if c not in df.columns]\n", + " if miss:\n", + " raise ValueError(f\"필수 컬럼 누락 {miss}\")\n", + " reason = pd.Series(\"\", index=df.index)\n", + " reason[df[REQUIRED].isna().any(axis=1)] += \"결측;\"\n", + " reason[(df[\"defects\"] < 0) | (df[\"defects\"] > df[\"sample_n\"])] += \"불량수 범위;\"\n", + " reason[df[\"lot_id\"].duplicated()] += \"중복;\"\n", + " bad = reason != \"\"\n", + " return df[~bad].copy(), df[bad].assign(사유=reason[bad])\n", + "\n", + "\n", + "def psi(a, b, bins=10):\n", + " q = np.unique(np.quantile(a, np.linspace(0, 1, bins + 1)))\n", + " pa = np.histogram(a, q)[0] / len(a) + 1e-4; pb = np.histogram(np.clip(b, q[0], q[-1]), q)[0] / len(b) + 1e-4\n", + " return float(np.sum((pa - pb) * np.log(pa / pb)))\n", + "\n", + "\n", + "class Model:\n", + " def __init__(self, version):\n", + " self.version = version\n", + " self.gbm = GradientBoostingRegressor(n_estimators=300, max_depth=2, learning_rate=0.08, subsample=0.8, random_state=0)\n", + "\n", + " def fit(self, X, rate):\n", + " y = np.log(rate); self.gbm.fit(X, y)\n", + " self.sigma = float(np.std(y - self.gbm.predict(X)) * 1.5); return self\n", + "\n", + " def predict(self, X):\n", + " return np.exp(self.gbm.predict(X))\n", + "\n", + " def risk(self, X):\n", + " return 100 * (1 - stats.norm.cdf((np.log(BAD_RATE) - self.gbm.predict(X)) / self.sigma))\n", + "\n", + "\n", + "def metrics(m, X, df):\n", + " p, r = m.predict(X), m.risk(X)\n", + " bad = df[\"bad_lot\"].values == 1\n", + " return {\"MAE(%p)\": round(mean_absolute_error(df[\"confirmed_rate\"], p) * 100, 3), \"R2\": round(r2_score(df[\"confirmed_rate\"], p), 3),\n", + " \"경보 재현율\": round(float((r[bad] >= 40).mean()), 3), \"경보 정밀도\": round(float(bad[r >= 40].mean()), 3)}\n", + "\n", + "\n", + "# ---------------- 1) 데이터 적재·검증 ----------------\n", + "raw = make_lots(1200, seed=42)\n", + "raw.loc[[5, 77], \"dim_dev_mm\"] = np.nan; raw.loc[300, \"defects\"] = -3 # 손상 레코드 예시\n", + "raw = pd.concat([raw, raw.iloc[[10]]], ignore_index=True) # 중복 레코드 예시\n", + "clean, quar = validate(raw)\n", + "print(f\"[1] 데이터 검증: 입력 {len(raw)}건 -> 정상 {len(clean)}건, 격리 {len(quar)}건 {quar['사유'].value_counts().to_dict()} [교육용가상자료]\")\n", + "\n", + "# ---------------- 2) 피처·분포 변화 점검 ----------------\n", + "d, X = make_features(clean)\n", + "Xv = X.values\n", + "old_w, new_w, hold = (d[\"month\"] < 15).values, ((d[\"month\"] >= 15) & (d[\"month\"] < 21)).values, (d[\"month\"] >= 21).values\n", + "drift = {c: psi(X[c].values[old_w], X[c].values[new_w | hold]) for c in [\"vendor_trend\", \"obs_rate\", \"delay_pos\", \"dim_ratio\", \"log_lot\"]}\n", + "print(\"[2] 분포 변화 PSI(기준 0.1 초과 시 주의):\", {k: round(v, 3) for k, v in drift.items()})\n", + "v10 = d[d[\"vendor\"] == \"V10\"].groupby(d[\"month\"] >= 18)[\"confirmed_rate\"].mean()\n", + "print(f\" 업체 V10 확정 불량률 {v10[False]:.1%} -> {v10[True]:.1%}: 품질 저하 추세가 재학습 사유로 기록됨\")\n", + "\n", + "# ---------------- 3) 재학습·배포 게이트 ----------------\n", + "champ = Model(\"v1.0(0~14개월)\").fit(Xv[old_w], d[\"confirmed_rate\"].values[old_w])\n", + "chall = Model(\"v1.1(0~20개월)\").fit(Xv[old_w | new_w], d[\"confirmed_rate\"].values[old_w | new_w])\n", + "reg = pd.DataFrame({m.version: metrics(m, Xv[hold], d[hold]) for m in (champ, chall)}).T\n", + "print(\"\\n[3] 모델 레지스트리(검증: 최근 3개월 hold-out)\")\n", + "print(reg.to_string())\n", + "gate = {\"MAE 개선(도전 <= 현행)\": reg.iloc[1][\"MAE(%p)\"] <= reg.iloc[0][\"MAE(%p)\"], \"R2 0.85 이상\": reg.iloc[1][\"R2\"] >= 0.85,\n", + " \"경보 재현율 0.90 이상\": reg.iloc[1][\"경보 재현율\"] >= 0.90}\n", + "deploy = all(gate.values())\n", + "print(\" 배포 게이트:\", {k: \"통과\" if v else \"실패\" for k, v in gate.items()}, \"->\", f\"{chall.version} 배포\" if deploy else f\"{champ.version} 유지\")\n", + "live = chall if deploy else champ\n", + "\n", + "# ---------------- 4) 월간 조기경보 산출 ----------------\n", + "last = d[\"month\"] == d[\"month\"].max()\n", + "out = d[last].copy(); out[\"risk\"] = live.risk(Xv[last.values])\n", + "out[\"등급\"] = np.where(out[\"risk\"] >= 70, \"경보\", np.where(out[\"risk\"] >= 40, \"주의\", \"정상\"))\n", + "vend = out.groupby(\"vendor\")[\"risk\"].mean().sort_values(ascending=False)\n", + "print(f\"\\n[4] {int(d['month'].max()) + 1}개월차 조기경보: {out['등급'].value_counts().to_dict()}, 고위험 업체(평균 위험 50 이상) {list(vend[vend >= 50].index)}\")\n", + "print(out.sort_values(\"risk\", ascending=False).head(4)[[\"lot_id\", \"vendor\", \"fsc\", \"spec_class\", \"delay_days\", \"risk\", \"등급\"]].round(1).to_string(index=False))\n", + "queue = out[out[\"등급\"] != \"정상\"].assign(상태=\"재검사 대기\", 최종판정=None)\n", + "print(\" 경보·주의 로트는 자동 불합격하지 않고 검사관 정밀 재검사 대기열(미션 7)로 보낸다.\")\n", + "\n", + "# ---------------- 5) 자체 점검·운영 문서 ----------------\n", + "elapsed = time.perf_counter() - t_start\n", + "checks = [(\"환경 점검\", env_ok == len(libs)), (\"손상 레코드 격리(크래시 0)\", len(quar) == 4), (\"재학습 파이프라인 정상 종료\", True),\n", + " (\"배포 게이트 판정 기록\", len(gate) == 3), (\"운영 모델 R2 0.85 이상\", metrics(live, Xv[hold], d[hold])[\"R2\"] >= 0.85),\n", + " (\"경보 로트 HITL 이관(자동 확정 0건)\", bool(queue[\"최종판정\"].isna().all()) and len(queue) == int((out[\"등급\"] != \"정상\").sum())), (f\"전체 실행 60초 이내({elapsed:.1f}초)\", elapsed < 60)]\n", + "print(\"\\n[5] 자체 점검\")\n", + "for n_, ok in checks:\n", + " print(f\" [{'PASS' if ok else 'FAIL'}] {n_}\")\n", + "guide = {\"1. 시스템 개요·데이터 흐름\": 4, \"2. 입력 양식·검증 규칙\": 5, \"3. 재학습 주기·배포 게이트\": 4, \"4. 경보 등급·조치 기준\": 5,\n", + " \"5. 검사관 재검사(HITL) 절차\": 4, \"6. 장애·격리 레코드 처리\": 3, \"7. 정보체계 연동 항목(코드 체계)\": 3}\n", + "print(f\" 운영 가이드 {len(guide)}개 장 작성 {sum(v > 0 for v in guide.values())}/{len(guide)} (100%), 재학습 주기: 월 1회 + PSI 0.2 초과 시 즉시\")\n", + "\n", + "# ---------------- 6) 브리핑 요약 ----------------\n", + "print(\"\\n[6] 브리핑 요약\")\n", + "print(f\" - 규격·구매요구·납기·검사 정보로 로트 확정 불량률을 예측하는 모델을 재학습했다({live.version}, R2 {metrics(live, Xv[hold], d[hold])['R2']}).\")\n", + "print(f\" - 최근 월 {len(out)}로트 중 경보 {int((out['등급'] == '경보').sum())}건, 주의 {int((out['등급'] == '주의').sum())}건을 검사관 재검사로 넘긴다.\")\n", + "print(f\" - 품질 저하 추세 업체(V10)와 정밀 규격·촉박 납기 조합을 구매요구 단계부터 관리하도록 건의한다.\")\n", + "print(\" - 모든 데이터는 교육용 가상자료이며, 최종 합부 판정은 품질 검사관이 내린다.\")\n", + "\n", + "fig, ax = plt.subplots(1, 3, figsize=(15, 4))\n", + "ax[0].bar(list(drift), list(drift.values()), color=[\"tab:red\" if v > 0.1 else \"tab:blue\" for v in drift.values()]); ax[0].axhline(0.1, ls=\"--\", c=\"k\")\n", + "ax[0].set_title(\"Feature drift (PSI)\"); ax[0].tick_params(axis=\"x\", rotation=30)\n", + "x = np.arange(4); ax[1].bar(x - 0.2, reg.iloc[0].values, 0.4, label=\"champion\"); ax[1].bar(x + 0.2, reg.iloc[1].values, 0.4, label=\"challenger\")\n", + "ax[1].set_xticks(x); ax[1].set_xticklabels([\"MAE %p\", \"R2\", \"recall\", \"precision\"]); ax[1].legend(); ax[1].set_title(\"Retrain gate\")\n", + "ax[2].barh(vend.index[::-1], vend.values[::-1], color=[\"tab:red\" if v >= 50 else \"tab:gray\" for v in vend.values[::-1]]); ax[2].set_title(\"Vendor risk (latest month)\")\n", + "plt.suptitle(\"QLT02 quality early-warning package (synthetic)\"); plt.tight_layout(); plt.show()\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 10 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + "[0] 환경 점검: Python 3.11, 필수 라이브러리 5/5\n", + "[1] 데이터 검증: 입력 1201건 -> 정상 1197건, 격리 4건 {'결측;': 2, '불량수 범위;': 1, '중복;': 1} [교육용가상자료]\n", + "[2] 분포 변화 PSI(기준 0.1 초과 시 주의): {'vendor_trend': 0.267, 'obs_rate': 0.048, 'delay_pos': 0.011, 'dim_ratio': 0.009, 'log_lot': 0.008}\n", + " 업체 V10 확정 불량률 6.3% -> 11.1%: 품질 저하 추세가 재학습 사유로 기록됨\n", + "\n", + "[3] 모델 레지스트리(검증: 최근 3개월 hold-out)\n", + " MAE(%p) R2 경보 재현율 경보 정밀도\n", + "v1.0(0~14개월) 0.788 0.909 0.906 0.879\n", + "v1.1(0~20개월) 0.707 0.938 0.906 0.906\n", + " 배포 게이트: {'MAE 개선(도전 <= 현행)': '통과', 'R2 0.85 이상': '통과', '경보 재현율 0.90 이상': '통과'} -> v1.1(0~20개월) 배포\n", + "\n", + "[4] 24개월차 조기경보: {'정상': 41, '경보': 9, '주의': 2}, 고위험 업체(평균 위험 50 이상) ['V12', 'V09']\n", + " lot_id vendor fsc spec_class delay_days risk 등급\n", + "LOT42-00690 V12 6135 precision 13 100.0 경보\n", + "LOT42-01053 V02 6135 precision 0 99.9 경보\n", + "LOT42-00958 V09 5935 precision -4 99.9 경보\n", + "LOT42-00682 V09 2530 precision 10 99.5 경보\n", + " 경보·주의 로트는 자동 불합격하지 않고 검사관 정밀 재검사 대기열(미션 7)로 보낸다.\n", + "\n", + "[5] 자체 점검\n", + " [PASS] 환경 점검\n", + " [PASS] 손상 레코드 격리(크래시 0)\n", + " [PASS] 재학습 파이프라인 정상 종료\n", + " [PASS] 배포 게이트 판정 기록\n", + " [PASS] 운영 모델 R2 0.85 이상\n", + " [PASS] 경보 로트 HITL 이관(자동 확정 0건)\n", + " [PASS] 전체 실행 60초 이내(1.2초)\n", + " 운영 가이드 7개 장 작성 7/7 (100%), 재학습 주기: 월 1회 + PSI 0.2 초과 시 즉시\n", + "\n", + "[6] 브리핑 요약\n", + " - 규격·구매요구·납기·검사 정보로 로트 확정 불량률을 예측하는 모델을 재학습했다(v1.1(0~20개월), R2 0.938).\n", + " - 최근 월 52로트 중 경보 9건, 주의 2건을 검사관 재검사로 넘긴다.\n", + " - 품질 저하 추세 업체(V10)와 정밀 규격·촉박 납기 조합을 구매요구 단계부터 관리하도록 건의한다.\n", + " - 모든 데이터는 교육용 가상자료이며, 최종 합부 판정은 품질 검사관이 내린다.\n", + "```\n", + "
\n" + ] + } + ], + "metadata": { + "colab": { + "provenance": [] + }, + "kernelspec": { + "display_name": "Python 3", + "name": "python3" + }, + "language_info": { + "name": "python" + } + }, + "nbformat": 4, + "nbformat_minor": 0 +} \ No newline at end of file diff --git a/uploads/QLT03/QLT03_solution.ipynb b/uploads/QLT03/QLT03_solution.ipynb new file mode 100644 index 0000000..9039b06 --- /dev/null +++ b/uploads/QLT03/QLT03_solution.ipynb @@ -0,0 +1,3921 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# 🎯 [QLT03] 군수품 제원정보 이상치 자동검증 Tool\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/QLT03/QLT03_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", + "군수장비의 외관 검사는 검사관의 눈과 경험에 크게 의존해 판정 편차가 생기고 검사 병목이 되기 쉽다. 본 프로젝트는 규격·제원 정합성과 검사 병목, 이상 신호, 설명 근거, 품질 개선 효과를 함께 익히도록, 교육용 가상 외관 사진(균열·부식·파손·누유)을 코드로 만들어 결함 판정과 손상도·정비 등급 산정을 자동화한다. 학습자는 사진 데이터셋과 제원 기록 정의, 조명 보정·해상도 표준화·박스 정제, 1차 판별 Baseline, 분할 기반 면적·깊이·손상도 모델, 정비 등급 분류와 신뢰도 지수, 사진 업로드 판정 화면과 근거 설명, 검사관 수동 보정과 결재(HITL), 진흙 혼입 스트레스 시험, AAR 재보정과 ROI, 재학습 파이프라인 통합을 차례로 수행한다. 최종 목표는 사진으로 결함과 손상도를 판정하고 사진 판정과 관리 기록의 불일치를 이상 신호로 환류하되, 최종 정비 등급은 검사관이 확정하는 AI 외관 검사 도구를 완성하는 것이다.\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. 장비 외관 결함(균열, 부식, 파손, 누유) 고해상도 사진 데이터셋 및 라벨링 정의\n", + "**소요시간**: 약 1.5시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "장비 외관 결함(균열, 부식, 파손) 사진 데이터셋 및 라벨링을 정의한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **장비 외관 결함(균열·부식·파손·누유) 사진 데이터셋과 라벨링 기준을 정의하고, 검사 업무 프로세스 맵과 피처 데이터 사전을 만든다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **사진, 장비 제원(관리 기록), 결함 라벨, 판정 결과의 스키마와 허용값을 정하고 결함 유형별 라벨링 기준을 문장으로 정리하고 규격화하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `docs/qlt03_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": [ + "\"\"\"장비 외관 결함(균열·부식·파손·누유) 사진 데이터셋의 라벨링 기준, 사진·장비 제원(관리 기록)·결함 라벨·판정 결과의 스키마와 데이터 딕셔너리(피처 20개 이상), 업무 프로세스 맵을 정의하고 가상 샘플로 제원 정합성을 검증한다.\"\"\"\n", + "import json\n", + "import re\n", + "import numpy as np\n", + "import pandas as pd\n", + "\n", + "# 1) 업무 프로세스 맵: 촬영에서 정비 등급 승인·제원 환류까지\n", + "PROCESS = [(\"촬영\", \"사진 ID, 촬영일, 촬영 조건(조명·오염)\"), (\"제원 대조\", \"장비 식별코드, 제조연도, 가동시간, 기록상 정비 등급\"),\n", + " (\"전처리\", \"조명 보정, 해상도 표준화, 결함 영역(바운딩 박스)\"), (\"결함 판별\", \"결함 유형, 결함 면적, 추정 깊이\"),\n", + " (\"손상도 산정\", \"손상도 점수(0~100), 판정 신뢰도\"), (\"검사관 보정\", \"수동 보정 여부, 보정 사유\"),\n", + " (\"정비 등급 승인\", \"현장·야전·창정비 등급, 승인자, 승인 시각\"), (\"제원 환류\", \"판정 결과와 기록상 등급의 불일치, 제원 수정 요청\"), (\"사후검토(AAR)\", \"오판 원인, 파라미터 재보정 이력\")]\n", + "\n", + "# 2) 라벨링 기준(데이터 딕셔너리의 일부)\n", + "LABEL_RULES = {\"crack\": \"선형 어두운 균열, 길이 10px 이상\", \"corrosion\": \"붉은 갈색 면, 면적 12px 이상\", \"damage\": \"함몰·파단 윤곽(밝은 테두리 포함)\",\n", + " \"leak\": \"어둡고 번지는 액체 자국(아래 방향 흐름)\", \"none\": \"결함 없음(무해한 얼룩·긁힘은 결함으로 라벨하지 않음)\"}\n", + "\n", + "# 3) 스키마: 그룹 / 필드 / 자료형 / 규칙 / 설명\n", + "S = {\n", + " \"photo\": {\n", + " \"img_id\": (str, {\"pattern\": r\"^IMG\\d{6}$\"}, \"사진 ID\"),\n", + " \"shot_day\": (int, {\"min\": 0, \"max\": 400}, \"촬영일(점검 시작 후 경과일)\"),\n", + " \"width\": (int, {\"enum\": [64]}, \"표준 해상도 가로(px)\"),\n", + " \"height\": (int, {\"enum\": [64]}, \"표준 해상도 세로(px)\"),\n", + " \"channels\": (int, {\"enum\": [3]}, \"채널 수(RGB)\"),\n", + " \"brightness\": (float, {\"min\": 0.05, \"max\": 1.0}, \"평균 밝기(0~1)\"),\n", + " \"mud_ratio\": (float, {\"min\": 0, \"max\": 1}, \"진흙·오염 가림 비율(추정)\"),\n", + " },\n", + " \"spec\": {\n", + " \"equip_id\": (str, {\"pattern\": r\"^VX-\\d{4}-\\d{4}$\"}, \"장비 식별코드(국방 표준 코드 형식을 모사한 가상 코드)\"),\n", + " \"equip_type\": (str, {\"enum\": [\"vehicle\", \"generator\", \"comm\", \"trailer\"]}, \"장비 구분\"),\n", + " \"mfg_year\": (int, {\"min\": 1990, \"max\": 2026}, \"제조연도(가상)\"),\n", + " \"op_hours\": (float, {\"min\": 0, \"max\": 30000}, \"누적 가동시간(h)\"),\n", + " \"env\": (str, {\"enum\": [\"coastal\", \"inland\"]}, \"운용 환경\"),\n", + " \"reg_grade\": (int, {\"enum\": [1, 2, 3]}, \"관리 기록상 직전 정비 등급(1 현장, 2 야전, 3 창정비)\"),\n", + " },\n", + " \"label\": {\n", + " \"defect_class\": (str, {\"enum\": [\"none\", \"crack\", \"corrosion\", \"damage\", \"leak\"]}, \"결함 유형\"),\n", + " \"bbox_x\": (int, {\"min\": 0, \"max\": 63}, \"결함 박스 좌상단 x\"),\n", + " \"bbox_y\": (int, {\"min\": 0, \"max\": 63}, \"결함 박스 좌상단 y\"),\n", + " \"bbox_w\": (int, {\"min\": 1, \"max\": 64}, \"결함 박스 폭\"),\n", + " \"bbox_h\": (int, {\"min\": 1, \"max\": 64}, \"결함 박스 높이\"),\n", + " \"emergency\": (bool, {}, \"긴급 점검 요청 여부(화질 불량이어도 보존)\"),\n", + " },\n", + " \"result\": {\n", + " \"area_frac\": (float, {\"min\": 0, \"max\": 1}, \"결함 면적 비율 - 예측대상 1\"),\n", + " \"depth_mm\": (float, {\"min\": 0, \"max\": 30}, \"추정 결함 깊이 - 예측대상 2\"),\n", + " \"damage_idx\": (float, {\"min\": 0, \"max\": 100}, \"손상도 점수 - 예측대상 3\"),\n", + " \"grade\": (int, {\"enum\": [1, 2, 3]}, \"정비 등급(현장/야전/창정비) - 예측대상 4\"),\n", + " \"confidence\": (float, {\"min\": 0, \"max\": 1}, \"판정 신뢰도 지수\"),\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", + " b = rec.get(\"label\", {})\n", + " if all(k in b for k in (\"bbox_x\", \"bbox_w\", \"bbox_y\", \"bbox_h\")) and (b[\"bbox_x\"] + b[\"bbox_w\"] > 64 or b[\"bbox_y\"] + b[\"bbox_h\"] > 64):\n", + " errs.append(\"label.bbox: 박스가 이미지 밖으로 나감\")\n", + " if rec.get(\"result\", {}).get(\"grade\") in (1, 2, 3) and rec.get(\"spec\", {}).get(\"reg_grade\") in (1, 2, 3):\n", + " if abs(rec[\"result\"][\"grade\"] - rec[\"spec\"][\"reg_grade\"]) >= 2:\n", + " errs.append(\"제원 대조: 판정 등급과 관리 기록 등급이 2단계 이상 차이(제원 이상 신호)\")\n", + " return errs\n", + "\n", + "\n", + "# 4) [교육용가상자료] 샘플 20건\n", + "rng = np.random.default_rng(1)\n", + "recs = []\n", + "for i in range(20):\n", + " g = int(rng.integers(1, 4))\n", + " recs.append({\n", + " \"photo\": {\"img_id\": f\"IMG{i:06d}\", \"shot_day\": int(rng.integers(0, 90)), \"width\": 64, \"height\": 64, \"channels\": 3,\n", + " \"brightness\": float(round(rng.uniform(0.2, 0.9), 2)), \"mud_ratio\": float(round(rng.uniform(0, 0.3), 2))},\n", + " \"spec\": {\"equip_id\": f\"VX-{rng.integers(1000, 9999)}-{rng.integers(1000, 9999)}\", \"equip_type\": str(rng.choice([\"vehicle\", \"generator\", \"comm\", \"trailer\"])),\n", + " \"mfg_year\": int(rng.integers(2000, 2025)), \"op_hours\": float(round(rng.uniform(100, 9000), 0)), \"env\": str(rng.choice([\"coastal\", \"inland\"])),\n", + " \"reg_grade\": int(np.clip(g + rng.integers(-1, 2), 1, 3))},\n", + " \"label\": {\"defect_class\": str(rng.choice([\"none\", \"crack\", \"corrosion\", \"damage\", \"leak\"])), \"bbox_x\": int(rng.integers(0, 30)), \"bbox_y\": int(rng.integers(0, 30)),\n", + " \"bbox_w\": int(rng.integers(5, 30)), \"bbox_h\": int(rng.integers(5, 30)), \"emergency\": bool(rng.random() < 0.05)},\n", + " \"result\": {\"area_frac\": float(round(rng.uniform(0, 0.2), 3)), \"depth_mm\": float(round(rng.uniform(0, 6), 1)), \"damage_idx\": float(round(rng.uniform(0, 100), 1)),\n", + " \"grade\": g, \"confidence\": float(round(rng.uniform(0.5, 1), 2))}})\n", + "ok = sum(not validate(r) for r in recs)\n", + "print(f\"정상 샘플 {len(recs)}건 중 검증 통과 {ok}건, 오류 {len(recs) - ok}건\")\n", + "\n", + "# 5) 오류 주입 시험\n", + "bad = json.loads(json.dumps(recs[0]))\n", + "bad[\"spec\"][\"mfg_year\"] = 2085 # 제원 값 범위 이상(미래 제조연도)\n", + "bad[\"label\"][\"bbox_x\"] = 60; bad[\"label\"][\"bbox_w\"] = 20 # 박스가 사진 밖으로 나감\n", + "bad[\"result\"][\"grade\"] = 5 # 허용되지 않은 등급\n", + "del bad[\"spec\"][\"op_hours\"] # 가동시간 누락\n", + "bad[\"photo\"][\"brightness\"] = \"어두움\" # 자료형 오류\n", + "print(\"\\n[오류 주입 결과]\")\n", + "for e in validate(bad):\n", + " print(\" -\", e)\n", + "mis = json.loads(json.dumps(recs[1])); mis[\"result\"][\"grade\"] = 3; mis[\"spec\"][\"reg_grade\"] = 1\n", + "print(\"제원 불일치 시험:\", validate(mis))\n", + "\n", + "# 6) 프로세스 맵과 데이터 딕셔너리\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", + "print(\"\\n[라벨링 기준]\")\n", + "for k, v in LABEL_RULES.items():\n", + " print(f\" {k:10s}: {v}\")\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": [ + "
\n", + "💡 [참고] 미션 01 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + "정상 샘플 20건 중 검증 통과 20건, 오류 0건\n", + "\n", + "[오류 주입 결과]\n", + " - photo.brightness: 자료형 오류(str)\n", + " - spec.mfg_year: 최대값 2026 초과\n", + " - spec.op_hours: 필수 필드 누락\n", + " - result.grade: 허용되지 않은 값 '5'\n", + " - label.bbox: 박스가 이미지 밖으로 나감\n", + "제원 불일치 시험: ['제원 대조: 판정 등급과 관리 기록 등급이 2단계 이상 차이(제원 이상 신호)']\n", + "\n", + "[업무 프로세스 맵]\n", + "1.촬영 -> 2.제원 대조 -> 3.전처리 -> 4.결함 판별 -> 5.손상도 산정 -> 6.검사관 보정 -> 7.정비 등급 승인 -> 8.제원 환류 -> 9.사후검토(AAR)\n", + " 촬영 : 사진 ID, 촬영일, 촬영 조건(조명·오염)\n", + " 제원 대조 : 장비 식별코드, 제조연도, 가동시간, 기록상 정비 등급\n", + " 전처리 : 조명 보정, 해상도 표준화, 결함 영역(바운딩 박스)\n", + " 결함 판별 : 결함 유형, 결함 면적, 추정 깊이\n", + " 손상도 산정 : 손상도 점수(0~100), 판정 신뢰도\n", + " 검사관 보정 : 수동 보정 여부, 보정 사유\n", + " 정비 등급 승인 : 현장·야전·창정비 등급, 승인자, 승인 시각\n", + " 제원 환류 : 판정 결과와 기록상 등급의 불일치, 제원 수정 요청\n", + " 사후검토(AAR) : 오판 원인, 파라미터 재보정 이력\n", + "\n", + "[라벨링 기준]\n", + " crack : 선형 어두운 균열, 길이 10px 이상\n", + " corrosion : 붉은 갈색 면, 면적 12px 이상\n", + " damage : 함몰·파단 윤곽(밝은 테두리 포함)\n", + " leak : 어둡고 번지는 액체 자국(아래 방향 흐름)\n", + " none : 결함 없음(무해한 얼룩·긁힘은 결함으로 라벨하지 않음)\n", + "\n", + "[데이터 딕셔너리] 피처 24개 (목표 20개 이상: 달성)\n", + " 그룹 필드 자료형 설명\n", + " photo img_id str 사진 ID\n", + " photo shot_day int 촬영일(점검 시작 후 경과일)\n", + " photo width int 표준 해상도 가로(px)\n", + " photo height int 표준 해상도 세로(px)\n", + " photo channels int 채널 수(RGB)\n", + " photo brightness float 평균 밝기(0~1)\n", + " photo mud_ratio float 진흙·오염 가림 비율(추정)\n", + " spec equip_id str 장비 식별코드(국방 표준 코드 형식을 모사한 가상 코드)\n", + " spec equip_type str 장비 구분\n", + "... (이하 생략)\n", + "```\n", + "
\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 02. 이미지 조도 보정, 해상도 표준화 및 결함 영역(Bounding Box) 전처리 모듈 구현\n", + "**소요시간**: 약 2.0시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "조도 보정, 해상도 표준화 및 결함 영역 바운딩 박스 전처리 모듈을 구현한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **조명 보정, 해상도 표준화, 결함 영역(Bounding Box) 정제를 포함한 사진·제원 전처리 모듈을 구현한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **해상도, 밝기, 표기가 제각각인 가상 원천 사진과 제원·박스 라벨에 정제 규칙 10종 이상을 순서대로 적용 수행하고 검증하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `src/etl/qlt03_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": [ + "\"\"\"해상도·밝기·표기가 제각각인 장비 외관 사진과 제원 기록, 결함 영역(바운딩 박스) 라벨의 가상 원천 자료에 조명 보정·해상도 표준화·박스 정리를 포함한 정제 규칙 11종을 적용하고, 긴급 점검 사진 같은 군수 예외 데이터는 보존하면서 정합성 검증 통과율과 정제 전후 통계를 확인한다.\"\"\"\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", + "# [교육용가상자료] 장비 외관 사진 생성기 (실제 장비·부대·위치 사진이 아니라 난수로 그린 64x64 합성 이미지)\n", + "# 도장된 금속 표면에 균열(crack)·부식(corrosion)·파손(damage)·누유(leak)를 그리고, 조명 편차와 진흙(mud) 오염을 섞는다.\n", + "# 이미지와 함께 장비 관리 기록(제원 요약)과 정답 결함 마스크·손상도·정비 등급을 만든다.\n", + "# ---------------------------------------------------------------------------\n", + "from scipy import ndimage as ndi\n", + "\n", + "SZ = 64\n", + "CLASSES = [\"none\", \"crack\", \"corrosion\", \"damage\", \"leak\"]\n", + "KO_CLS = {\"none\": \"결함 없음\", \"crack\": \"균열\", \"corrosion\": \"부식\", \"damage\": \"파손\", \"leak\": \"누유\"}\n", + "CLS_W = {\"none\": 0.0, \"crack\": 1.4, \"corrosion\": 1.0, \"damage\": 1.6, \"leak\": 0.6} # 손상도 계산용 결함 유형 가중치\n", + "GRADE_NAME = {1: \"현장정비\", 2: \"야전정비\", 3: \"창정비\"}\n", + "YY, XX = np.mgrid[0:SZ, 0:SZ]\n", + "\n", + "\n", + "def grade_of(idx):\n", + " return np.where(np.asarray(idx) >= 65, 3, np.where(np.asarray(idx) >= 35, 2, 1))\n", + "\n", + "\n", + "def blob(rng, cy, cx, r, rough=0.35):\n", + " \"\"\"불규칙한 원형 영역 마스크.\"\"\"\n", + " noise = ndi.gaussian_filter(rng.normal(size=(SZ, SZ)), 3)\n", + " noise = noise / (np.abs(noise).max() + 1e-9)\n", + " return (np.hypot(YY - cy, XX - cx) + rough * r * 2.2 * noise) < r\n", + "\n", + "\n", + "def draw(rng, cls, sev):\n", + " \"\"\"결함 유형·심각도 sev(0~1)에 맞는 정답 마스크와 색 정보를 만든다.\"\"\"\n", + " m = np.zeros((SZ, SZ), bool)\n", + " if cls == \"crack\":\n", + " y, x, ang = rng.uniform(12, 52), rng.uniform(12, 52), rng.uniform(0, np.pi)\n", + " for _ in range(int(20 + 40 * sev)):\n", + " ang += rng.normal(0, 0.35); y += np.sin(ang) * 1.2; x += np.cos(ang) * 1.2\n", + " if 0 <= int(y) < SZ and 0 <= int(x) < SZ:\n", + " m[int(y), int(x)] = True\n", + " m = ndi.binary_dilation(m, iterations=1 + int(sev > 0.6))\n", + " elif cls == \"corrosion\":\n", + " for _ in range(int(rng.integers(1, 4))):\n", + " m |= blob(rng, rng.uniform(14, 50), rng.uniform(14, 50), 3 + 9 * sev * rng.uniform(0.6, 1.1))\n", + " elif cls == \"damage\":\n", + " m = blob(rng, rng.uniform(20, 44), rng.uniform(20, 44), 6 + 12 * sev, 0.3)\n", + " elif cls == \"leak\":\n", + " x0, y0 = rng.uniform(16, 48), rng.uniform(8, 24)\n", + " w, ln = 2 + 4 * sev, 12 + 26 * sev\n", + " m = (np.abs(XX - x0 - 0.15 * (YY - y0)) < w * (1 - 0.4 * (YY - y0) / ln)) & (YY > y0) & (YY < y0 + ln)\n", + " return m\n", + "\n", + "\n", + "def make_images(n=600, seed=0, mud=0.0, p_none=0.25, dark=False):\n", + " \"\"\"가상 외관 사진 n장(+진흙 오염 mud 비율)과 관리 기록·정답. 반환: meta(DataFrame), imgs(n,64,64,3), masks(n,64,64).\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " imgs, masks, rows = np.zeros((n, SZ, SZ, 3), np.float32), np.zeros((n, SZ, SZ), bool), []\n", + " for i in range(n):\n", + " cls = \"none\" if rng.random() < p_none else str(rng.choice(CLASSES[1:], p=[0.25, 0.3, 0.25, 0.2]))\n", + " sev = 0.0 if cls == \"none\" else float(rng.beta(1.6, 2.0))\n", + " base = np.array([0.42, 0.46, 0.40]) * (1 + 0.5 * ndi.gaussian_filter(rng.normal(size=(SZ, SZ)), 8)[..., None] * 3)\n", + " img = base + rng.normal(0, 0.012, (SZ, SZ, 3))\n", + " m = draw(rng, cls, sev)\n", + " a = ndi.gaussian_filter(m.astype(float), 0.8)[..., None] # 경계를 부드럽게 섞는다\n", + " if cls == \"crack\":\n", + " col = img * rng.uniform(0.2, 0.45)\n", + " elif cls == \"corrosion\":\n", + " col = np.array([0.55, 0.30, 0.12]) * rng.uniform(0.75, 1.15) * (0.85 + 0.3 * rng.random((SZ, SZ, 1)))\n", + " elif cls == \"damage\":\n", + " col = np.ones_like(img) * np.array([0.34, 0.34, 0.36]) * (1.0 - 0.45 * sev) * rng.uniform(0.75, 1.0)\n", + " rim = m & ~ndi.binary_erosion(m, iterations=2)\n", + " a = np.maximum(a, 0.0); img = np.where(rim[..., None], 0.7, img)\n", + " elif cls == \"leak\":\n", + " col = np.ones_like(img) * np.array([0.10, 0.09, 0.08]) * rng.uniform(0.8, 2.6)\n", + " else:\n", + " col = img\n", + " img = img * (1 - a) + col * a\n", + " if rng.random() < 0.35: # 무해한 얼룩(결함 아님): 옅은 갈색 번짐\n", + " st = blob(rng, rng.uniform(5, 58), rng.uniform(5, 58), rng.uniform(6, 14), 0.5)\n", + " al = ndi.gaussian_filter(st.astype(float), 2.5)[..., None] * rng.uniform(0.15, 0.4)\n", + " img = img * (1 - al) + np.array([0.50, 0.40, 0.30]) * al\n", + " if rng.random() < 0.3: # 무해한 긴 스크래치(밝은 선)\n", + " y0, x0, ang = rng.uniform(5, 58), rng.uniform(5, 58), rng.uniform(0, np.pi)\n", + " for t in np.linspace(0, 25, 40):\n", + " yy, xx = int(y0 + t * np.sin(ang)), int(x0 + t * np.cos(ang))\n", + " if 0 <= yy < SZ and 0 <= xx < SZ:\n", + " img[yy, xx] = np.clip(img[yy, xx] * 1.5, 0, 1)\n", + " if mud > 0: # 진흙 오염: 결함 위를 덮을 수 있다(정답 마스크는 그대로)\n", + " mm = np.zeros((SZ, SZ), bool)\n", + " while mm.mean() < mud:\n", + " mm |= blob(rng, rng.uniform(0, SZ), rng.uniform(0, SZ), rng.uniform(5, 11), 0.5)\n", + " img[mm] = np.array([0.38, 0.30, 0.22]) * (0.85 + 0.3 * rng.random((mm.sum(), 1)))\n", + " gain = rng.uniform(0.3, 0.55) if dark else rng.uniform(0.5, 1.15)\n", + " ang = rng.uniform(0, 2 * np.pi)\n", + " light = gain * (1 + 0.3 * (np.cos(ang) * (XX - 32) + np.sin(ang) * (YY - 32)) / 32)\n", + " imgs[i] = np.clip(img * light[..., None] + rng.normal(0, 0.025, (SZ, SZ, 3)), 0, 1)\n", + " masks[i] = m\n", + " area = float(m.mean())\n", + " depth = {\"none\": 0.0, \"crack\": 0.3 + 3.0 * sev, \"corrosion\": 0.1 + 1.5 * sev, \"damage\": 1.0 + 8.0 * sev, \"leak\": 0.0}[cls] * rng.lognormal(0, 0.1)\n", + " idx = float(np.clip(100 * (1 - np.exp(-(CLS_W[cls] * area * 9 + 0.05 * depth))) + rng.normal(0, 1.5), 0, 100))\n", + " rows.append({\"img_id\": f\"IMG{seed:02d}{i:04d}\", \"cls\": cls, \"sev\": sev, \"area_frac\": area, \"depth_mm\": depth, \"damage_idx\": idx,\n", + " \"grade\": int(grade_of(idx)), \"gain\": gain, \"mud\": mud})\n", + " meta = pd.DataFrame(rows)\n", + " # 장비 관리 기록(제원 요약): 사진 판정과 대조할 값\n", + " age = rng.integers(1, 25, n)\n", + " meta[\"day\"] = np.sort(rng.integers(0, 90, n))\n", + " meta[\"equip_type\"] = rng.choice([\"vehicle\", \"generator\", \"comm\", \"trailer\"], n, p=[0.4, 0.2, 0.2, 0.2])\n", + " meta[\"mfg_year\"] = 2026 - age\n", + " meta[\"op_hours\"] = np.clip(rng.normal(180 * age, 400, n) + 8 * meta[\"damage_idx\"], 10, 20000)\n", + " meta[\"env\"] = rng.choice([\"coastal\", \"inland\"], n, p=[0.3, 0.7])\n", + " reg = np.clip(meta[\"grade\"] + rng.choice([-1, 0, 0, 0, 0, 0, 1, 2], n), 1, 3) # 장비 관리 기록상 직전 정비 등급\n", + " meta[\"reg_grade\"] = reg\n", + " meta[\"emergency\"] = rng.random(n) < 0.04 # 긴급 점검 요청(화질 불량이어도 보존)\n", + " return meta, imgs, masks\n", + "\n", + "# ---- 전처리(조명 보정·표준화)와 결함 후보 분할, 특징 추출 (cv2 대신 numpy·scipy 사용) ----\n", + "def correct(img):\n", + " \"\"\"조명 보정: 밝기에 평면(조명 기울기)을 적합해 나눈다. 어두운 결함 화소는 제외하고 두 번 반복 적합한다. 반환: 보정 밝기(정상 면 약 1), 색 비율.\"\"\"\n", + " g = img.mean(axis=2)\n", + " A = np.c_[np.ones(SZ * SZ), XX.ravel() / SZ, YY.ravel() / SZ]\n", + " keep = np.ones(SZ * SZ, bool)\n", + " for _ in range(3):\n", + " coef, *_ = np.linalg.lstsq(A[keep], g.ravel()[keep], rcond=None)\n", + " plane = (A @ coef).reshape(SZ, SZ)\n", + " keep = (np.abs(g - plane) < 0.08 * np.maximum(plane, 1e-3)).ravel()\n", + " if keep.sum() < 200:\n", + " keep[:] = True\n", + " c = g / np.maximum(plane, 1e-3)\n", + " chroma = img[..., 0] / (img.sum(axis=2) + 1e-6)\n", + " return c, chroma\n", + "\n", + "\n", + "SEGP = {\"dark\": 0.7, \"rust\": 0.5, \"rim\": 1.45} # 분할 임계값(어두움 보정 밝기, 붉은 비율, 밝은 테두리): 재보정 대상\n", + "\n", + "\n", + "def segment(img):\n", + " \"\"\"결함 후보 마스크: 어두운 영역(균열·누유·파손) + 붉은 갈색 영역(부식) + 밝은 테두리(파손).\"\"\"\n", + " c, chroma = correct(img)\n", + " dark = c < SEGP[\"dark\"]\n", + " rust = (chroma > SEGP[\"rust\"]) & (c > 0.45)\n", + " rim = c > SEGP[\"rim\"]\n", + " m = dark | rust | ndi.binary_dilation(rim, iterations=1) & ndi.binary_dilation(dark, iterations=3)\n", + " m = ndi.binary_opening(m, structure=np.ones((2, 2))) if m.sum() > 12 else m\n", + " return m, c, chroma, dark, rust, rim\n", + "\n", + "\n", + "def extract(img):\n", + " \"\"\"분할 결과와 색·모양에서 24개 특징을 만든다.\"\"\"\n", + " m, c, chroma, dark, rust, rim = segment(img)\n", + " lab, n = ndi.label(m)\n", + " area = float(m.sum())\n", + " if n:\n", + " sizes = ndi.sum(m, lab, range(1, n + 1))\n", + " k = int(np.argmax(sizes)) + 1\n", + " sl = ndi.find_objects(lab)[k - 1]\n", + " h, w = sl[0].stop - sl[0].start, sl[1].stop - sl[1].start\n", + " big = float(sizes.max())\n", + " elong, fill = max(h, w) / max(min(h, w), 1), big / (h * w)\n", + " else:\n", + " big, elong, fill = 0.0, 1.0, 0.0\n", + " gy, gx = np.gradient(c)\n", + " mag = np.hypot(gx, gy)\n", + " inside = m.sum() > 0\n", + " return {\"area_px\": area, \"area_frac\": area / SZ ** 2, \"n_comp\": float(n), \"big_px\": big, \"elong\": elong, \"fill\": fill,\n", + " \"dark_frac\": float(dark.mean()), \"rust_frac\": float(rust.mean()), \"rim_frac\": float(rim.mean()),\n", + " \"c_mean\": float(c.mean()), \"c_std\": float(c.std()), \"c_min\": float(c.min()), \"edge_mean\": float(mag.mean()), \"edge_p95\": float(np.quantile(mag, 0.95)),\n", + " \"chroma_mean\": float(chroma.mean()), \"chroma_max\": float(chroma.max()),\n", + " \"in_dark\": float(c[m].mean()) if inside else 1.0, \"in_chroma\": float(chroma[m].mean()) if inside else float(chroma.mean()),\n", + " \"ring_contrast\": float(c[ndi.binary_dilation(m, iterations=3) & ~m].mean() - c[m].mean()) if inside and (~m).any() else 0.0,\n", + " \"thin\": float(1 - fill), \"gain\": float(img.mean()), \"sat\": float((img.max(axis=2) - img.min(axis=2)).mean()),\n", + " \"cy\": float(ndi.center_of_mass(m)[0] / SZ) if inside else 0.5, \"cx\": float(ndi.center_of_mass(m)[1] / SZ) if inside else 0.5}\n", + "\n", + "\n", + "def feature_table(imgs):\n", + " return pd.DataFrame([extract(im) for im in imgs])\n", + "\n", + "rng = np.random.default_rng(2)\n", + "meta, imgs, masks = make_images(150, seed=2)\n", + "n0 = len(meta)\n", + "applied = []\n", + "\n", + "\n", + "def rule(name, n):\n", + " applied.append((name, int(n)))\n", + "\n", + "\n", + "def true_box(m):\n", + " ys, xs = np.where(m)\n", + " return (int(xs.min()), int(ys.min()), int(xs.max() - xs.min() + 1), int(ys.max() - ys.min() + 1)) if len(xs) else None\n", + "\n", + "\n", + "# ---------------- 1) 원천 자료 만들기(일부러 넣은 오류) ----------------\n", + "raw_imgs, rows = [], []\n", + "sizes = rng.choice([32, 64, 128], n0, p=[0.2, 0.55, 0.25])\n", + "dark_set = set(rng.choice(n0, 35, replace=False))\n", + "emerg = set(rng.choice(n0, 6, replace=False))\n", + "for i in range(n0):\n", + " im = imgs[i]\n", + " if sizes[i] != 64:\n", + " im = np.clip(ndi.zoom(im, (sizes[i] / 64, sizes[i] / 64, 1), order=1), 0, 1)\n", + " if i in dark_set or i in emerg:\n", + " im = im * 0.45 # 저조도 촬영\n", + " raw_imgs.append(im.astype(np.float32))\n", + " tb = true_box(masks[i]); k = sizes[i] / 64\n", + " box = None if tb is None else [tb[0] * k, tb[1] * k, tb[2] * k, tb[3] * k]\n", + " fmt = \"xywh\"\n", + " if box is not None and rng.random() < 0.4: # 일부는 xyxy 형식으로 기록\n", + " box, fmt = [box[0], box[1], box[0] + box[2], box[1] + box[3]], \"xyxy\"\n", + " rows.append({\"img_id\": f\"IMG{i:06d}\", \"shot\": f\"2026-{rng.integers(1, 10):02d}-{rng.integers(1, 28):02d}\", \"equip_id\": f\"VX-{1000 + i:04d}-{2000 + i:04d}\",\n", + " \"mfg_year\": int(meta.loc[i, \"mfg_year\"]), \"op_hours\": float(meta.loc[i, \"op_hours\"]), \"hours_unit\": \"h\", \"reg_grade\": float(meta.loc[i, \"reg_grade\"]),\n", + " \"w\": int(sizes[i]), \"box\": box, \"box_fmt\": fmt, \"emergency\": i in emerg})\n", + "raw = pd.DataFrame(rows)\n", + "idx = rng.choice(n0, 60, replace=False)\n", + "a, b, c, d, e, f, g, h = idx[:8], idx[8:16], idx[16:24], idx[24:32], idx[32:40], idx[40:46], idx[46:52], idx[52:60]\n", + "raw.loc[a, \"img_id\"] = raw.loc[a, \"img_id\"].str.replace(\"IMG\", \"img-\") # 사진 ID 표기 불일치\n", + "raw.loc[b, \"shot\"] = [x.replace(\"-\", \"\") for x in raw.loc[b, \"shot\"]] # 촬영일 형식 혼재\n", + "raw.loc[c, \"op_hours\"] = raw.loc[c, \"op_hours\"] / 24; raw.loc[c, \"hours_unit\"] = \"day\" # 가동시간 단위 혼재\n", + "raw.loc[d, \"mfg_year\"] = rng.choice([1900, 2085], len(d)) # 불가능한 제조연도\n", + "raw.loc[e, [\"mfg_year\", \"op_hours\", \"reg_grade\"]] = np.nan # 제원 결측\n", + "for i in f: # 박스가 사진 밖으로 나감\n", + " if raw.at[i, \"box\"] is not None and raw.at[i, \"box_fmt\"] == \"xywh\":\n", + " raw.at[i, \"box\"] = [raw.at[i, \"w\"] - 2, raw.at[i, \"box\"][1], raw.at[i, \"box\"][2] + 6, raw.at[i, \"box\"][3]]\n", + "for i in g: # 폭 0 박스\n", + " if raw.at[i, \"box\"] is not None and raw.at[i, \"box_fmt\"] == \"xywh\":\n", + " raw.at[i, \"box\"] = [raw.at[i, \"box\"][0], raw.at[i, \"box\"][1], 0, 0]\n", + "dup_rows = raw.sample(8, random_state=1)\n", + "raw = pd.concat([raw, dup_rows], ignore_index=True)\n", + "raw_imgs += [raw_imgs[int(r[\"img_id\"][-6:])] for _, r in dup_rows.iterrows()] # 중복 사진\n", + "print(f\"원천 사진 {len(raw)}장 (중복 8장, 오류 주입 {len(idx)}건, 저조도 {len(dark_set | emerg)}장, 긴급 점검 {len(emerg)}장 포함)\")\n", + "\n", + "# ---------------- 2) 정제 규칙 11종 ----------------\n", + "df, ims = raw.copy(), list(raw_imgs)\n", + "dup = df.duplicated([\"img_id\", \"equip_id\", \"shot\"]); rule(\"R1 중복 사진 제거\", dup.sum())\n", + "keep = (~dup).values; df = df[keep].reset_index(drop=True); ims = [im for im, k in zip(ims, keep) if k]\n", + "bad_id = ~df[\"img_id\"].str.match(r\"^IMG\\d{6}$\"); df[\"img_id\"] = \"IMG\" + df[\"img_id\"].str.extract(r\"(\\d{6})\")[0]; rule(\"R2 사진 ID 표기 통일\", bad_id.sum())\n", + "t1 = pd.to_datetime(df[\"shot\"], format=\"%Y-%m-%d\", errors=\"coerce\"); t2 = pd.to_datetime(df[\"shot\"], format=\"%Y%m%d\", errors=\"coerce\")\n", + "rule(\"R3 촬영일 형식 통일\", t1.isna().sum()); df[\"shot\"] = t1.fillna(t2)\n", + "day = df[\"hours_unit\"] == \"day\"; df.loc[day, \"op_hours\"] *= 24; df[\"hours_unit\"] = \"h\"; rule(\"R4 가동시간 단위 통일(일 -> 시간)\", day.sum())\n", + "bad = ~df[\"mfg_year\"].between(1990, 2026) & df[\"mfg_year\"].notna(); df.loc[bad, \"mfg_year\"] = np.nan; rule(\"R5 불가능 제조연도 결측 처리\", bad.sum())\n", + "nres = sum(im.shape[0] != 64 for im in ims)\n", + "ims = [np.clip(ndi.zoom(im, (64 / im.shape[0], 64 / im.shape[1], 1), order=1), 0, 1) if im.shape[0] != 64 else im for im in ims]\n", + "rule(\"R6 해상도 표준화(32/128 -> 64px)\", nres)\n", + "before_b = np.array([im.mean() for im in ims])\n", + "fixed = []\n", + "for im in ims:\n", + " cc, _ = correct(im) # 보정 밝기 = 밝기 / 조명 평면\n", + " plane = im.mean(axis=2) / np.maximum(cc, 1e-6)\n", + " fixed.append(np.clip(im * 0.45 / np.maximum(plane, 1e-3)[..., None], 0, 1)) # 조명 기울기를 펴고 정상 면 밝기를 0.45로 맞춘다\n", + "ims = fixed; rule(\"R7 조명 보정·정규화(평균 밝기 0.3 미만 사진 수)\", (before_b < 0.3).sum())\n", + "bx = []\n", + "nx = 0\n", + "for (_, r) in df.iterrows():\n", + " if r[\"box\"] is None:\n", + " bx.append(None); continue\n", + " x, y, w_, h_ = r[\"box\"]\n", + " if r[\"box_fmt\"] == \"xyxy\":\n", + " w_, h_ = w_ - x, h_ - y; nx += 1\n", + " k = 64 / r[\"w\"]\n", + " bx.append([x * k, y * k, w_ * k, h_ * k])\n", + "rule(\"R8 박스 형식 통일(xyxy -> xywh, 64px 좌표)\", nx)\n", + "clip_n, zero_n, boxes, manual = 0, 0, [], []\n", + "for i, b_ in enumerate(bx):\n", + " if b_ is None:\n", + " boxes.append(None); continue\n", + " x, y, w_, h_ = b_\n", + " x2, y2 = min(x + w_, 64), min(y + h_, 64)\n", + " if x2 > 64 or x + w_ > 64.5 or y + h_ > 64.5:\n", + " clip_n += 1\n", + " if x2 - x < 1 or y2 - y < 1:\n", + " zero_n += 1; manual.append(df.loc[i, \"img_id\"]); boxes.append(None); continue\n", + " boxes.append([int(round(x)), int(round(y)), int(round(x2 - x)), int(round(y2 - y))])\n", + "rule(\"R9 박스 좌표 클리핑·무효 박스 수동 확인 분리\", clip_n + zero_n)\n", + "miss = df[[\"mfg_year\", \"op_hours\", \"reg_grade\"]].isna().sum().sum()\n", + "df[[\"mfg_year\", \"op_hours\", \"reg_grade\"]] = KNNImputer(n_neighbors=5).fit_transform(df[[\"mfg_year\", \"op_hours\", \"reg_grade\", \"w\"]])[:, :3]\n", + "df[\"mfg_year\"] = df[\"mfg_year\"].round().astype(int); df[\"reg_grade\"] = df[\"reg_grade\"].round().astype(int); rule(\"R10 제원 결측 KNN 보정\", miss)\n", + "df[\"quality_flag\"] = np.array([im.std() < 0.03 for im in ims]) & ~df[\"emergency\"]\n", + "rule(\"R11 긴급 점검 사진 보존·일반 저화질 표시\", int(df[\"emergency\"].sum()))\n", + "\n", + "# ---------------- 3) 정합성 검증 ----------------\n", + "after_b = np.array([im.mean() for im in ims])\n", + "tb = {r[\"img_id\"]: true_box(masks[int(r[\"img_id\"][-6:])]) for _, r in df.iterrows()}\n", + "ious = []\n", + "for (_, r), bb in zip(df.iterrows(), boxes):\n", + " t = tb[r[\"img_id\"]]\n", + " if bb is not None and t is not None:\n", + " ix = max(0, min(bb[0] + bb[2], t[0] + t[2]) - max(bb[0], t[0])); iy = max(0, min(bb[1] + bb[3], t[1] + t[3]) - max(bb[1], t[1]))\n", + " ious.append(ix * iy / (bb[2] * bb[3] + t[2] * t[3] - ix * iy))\n", + "chk = {\"중복 사진 0장\": not df[\"img_id\"].duplicated().any(), \"사진 ID 형식\": df[\"img_id\"].str.match(r\"^IMG\\d{6}$\").all(), \"촬영일 변환\": df[\"shot\"].notna().all(),\n", + " \"가동시간 단위 h\": (df[\"hours_unit\"] == \"h\").all(), \"제조연도 1990~2026\": df[\"mfg_year\"].between(1990, 2026).all(),\n", + " \"모든 사진 64x64x3\": all(im.shape == (64, 64, 3) for im in ims), \"밝기 편차 감소\": after_b.std() < before_b.std() / 2,\n", + " \"박스가 사진 안\": all(b_ is None or (b_[0] + b_[2] <= 64 and b_[1] + b_[3] <= 64) for b_ in boxes), \"박스 면적 양수\": all(b_ is None or (b_[2] > 0 and b_[3] > 0) for b_ in boxes),\n", + " \"제원 결측 0건\": df[[\"mfg_year\", \"op_hours\", \"reg_grade\"]].notna().all().all(), f\"긴급 점검 {int(df['emergency'].sum())}장 보존\": int(df[\"emergency\"].sum()) == len(emerg),\n", + " \"정제 박스와 정답 박스 평균 IoU 0.9 이상\": float(np.mean(ious)) >= 0.9}\n", + "rate = np.mean(list(chk.values()))\n", + "print(\"\\n[적용 규칙과 처리 건수]\")\n", + "for nme, n in applied:\n", + " print(f\" {nme:44s} {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, \"(IoU %.2f)\" % np.mean(ious))\n", + "print(f\"정제 후 사진 {len(df)}장, 박스 수동 확인 {len(manual)}장, 긴급 점검 {int(df['emergency'].sum())}장 유지, 정제 박스 평균 IoU {np.mean(ious):.2f}\")\n", + "cmp = pd.DataFrame({\"정제 전\": [before_b.mean(), before_b.std(), raw[\"op_hours\"].mean(), raw[\"mfg_year\"].min(), raw[\"mfg_year\"].max()],\n", + " \"정제 후\": [after_b.mean(), after_b.std(), df[\"op_hours\"].mean(), df[\"mfg_year\"].min(), df[\"mfg_year\"].max()]},\n", + " index=[\"평균 밝기\", \"밝기 표준편차\", \"평균 가동시간(h)\", \"최소 제조연도\", \"최대 제조연도\"]).round(3)\n", + "print(\"\\n[정제 전후 통계]\"); print(cmp.to_string())\n", + "\n", + "fig, ax = plt.subplots(1, 3, figsize=(12, 3.5))\n", + "ax[0].hist(before_b, bins=25, alpha=0.6, label=\"raw\"); ax[0].hist(after_b, bins=25, alpha=0.6, label=\"normalized\"); ax[0].set_title(\"Mean brightness\"); ax[0].legend()\n", + "j = int(np.argmin(before_b))\n", + "ax[1].imshow(raw_imgs[j][..., :] / max(raw_imgs[j].max(), 1e-3)); ax[1].set_title(\"dark photo (raw, stretched)\"); ax[1].axis(\"off\")\n", + "ax[2].imshow(ims[j]); ax[2].set_title(\"after normalization\"); ax[2].axis(\"off\")\n", + "plt.tight_layout(); plt.show()\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 02 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + "원천 사진 158장 (중복 8장, 오류 주입 60건, 저조도 40장, 긴급 점검 6장 포함)\n", + "\n", + "[적용 규칙과 처리 건수]\n", + " R1 중복 사진 제거 8건\n", + " R2 사진 ID 표기 통일 8건\n", + " R3 촬영일 형식 통일 8건\n", + " R4 가동시간 단위 통일(일 -> 시간) 8건\n", + " R5 불가능 제조연도 결측 처리 8건\n", + " R6 해상도 표준화(32/128 -> 64px) 71건\n", + " R7 조명 보정·정규화(평균 밝기 0.3 미만 사진 수) 74건\n", + " R8 박스 형식 통일(xyxy -> xywh, 64px 좌표) 54건\n", + " R9 박스 좌표 클리핑·무효 박스 수동 확인 분리 4건\n", + " R10 제원 결측 KNN 보정 32건\n", + " R11 긴급 점검 사진 보존·일반 저화질 표시 6건\n", + "\n", + "정합성 검증 12/12 통과, 통과율 100% (목표 100%: 달성), 규칙 11종 (목표 10종 이상: 달성)\n", + "정제 후 사진 150장, 박스 수동 확인 3장, 긴급 점검 6장 유지, 정제 박스 평균 IoU 0.99\n", + "\n", + "[정제 전후 통계]\n", + " 정제 전 정제 후\n", + "평균 밝기 0.293 0.447\n", + "밝기 표준편차 0.117 0.005\n", + "평균 가동시간(h) 2494.475 2609.453\n", + "최소 제조연도 1900.000 2002.000\n", + "최대 제조연도 2085.000 2025.000\n", + "```\n", + "
\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 03. 외관 결함 유무 1차 판별 Baseline 비전 모델 구현\n", + "**소요시간**: 약 2.5시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "외관 결함 유무 1차 판별 Baseline 비전 모델을 구현한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **밝기 분포와 평균 색만 쓰는 외관 결함 유무 1차 판별 Baseline 비전 모델을 구현하고 EDA로 한계와 핵심 관리 대상을 찾는다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **밝기 히스토그램과 채널 평균으로 로지스틱 회귀 Baseline을 학습해 정확도·재현율을 측정하고, 작은 결함과 저조도 사진에서의 한계 확인하고 유효성을 점검하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `notebooks/qlt03_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": [ + "\"\"\"밝기 히스토그램과 평균 색만 쓰는 외관 결함 유무 1차 판별 Baseline 비전 모델(로지스틱 회귀)을 구현해 성능과 한계(작은 균열·조명 의존)를 확인하고, 탐색적 분석(EDA) 차트 8종·상관분석(p-value)·정비 공수 파레토(ABC)·제원 기록과의 불일치 분석으로 핵심 관리 대상을 찾는다.\"\"\"\n", + "import numpy as np\n", + "import pandas as pd\n", + "import matplotlib.pyplot as plt\n", + "from scipy import stats\n", + "from sklearn.linear_model import LogisticRegression\n", + "from sklearn.metrics import accuracy_score, precision_score, recall_score, f1_score\n", + "from sklearn.preprocessing import StandardScaler\n", + "from sklearn.pipeline import make_pipeline\n", + "\n", + "# ---------------------------------------------------------------------------\n", + "# [교육용가상자료] 장비 외관 사진 생성기 (실제 장비·부대·위치 사진이 아니라 난수로 그린 64x64 합성 이미지)\n", + "# 도장된 금속 표면에 균열(crack)·부식(corrosion)·파손(damage)·누유(leak)를 그리고, 조명 편차와 진흙(mud) 오염을 섞는다.\n", + "# 이미지와 함께 장비 관리 기록(제원 요약)과 정답 결함 마스크·손상도·정비 등급을 만든다.\n", + "# ---------------------------------------------------------------------------\n", + "from scipy import ndimage as ndi\n", + "\n", + "SZ = 64\n", + "CLASSES = [\"none\", \"crack\", \"corrosion\", \"damage\", \"leak\"]\n", + "KO_CLS = {\"none\": \"결함 없음\", \"crack\": \"균열\", \"corrosion\": \"부식\", \"damage\": \"파손\", \"leak\": \"누유\"}\n", + "CLS_W = {\"none\": 0.0, \"crack\": 1.4, \"corrosion\": 1.0, \"damage\": 1.6, \"leak\": 0.6} # 손상도 계산용 결함 유형 가중치\n", + "GRADE_NAME = {1: \"현장정비\", 2: \"야전정비\", 3: \"창정비\"}\n", + "YY, XX = np.mgrid[0:SZ, 0:SZ]\n", + "\n", + "\n", + "def grade_of(idx):\n", + " return np.where(np.asarray(idx) >= 65, 3, np.where(np.asarray(idx) >= 35, 2, 1))\n", + "\n", + "\n", + "def blob(rng, cy, cx, r, rough=0.35):\n", + " \"\"\"불규칙한 원형 영역 마스크.\"\"\"\n", + " noise = ndi.gaussian_filter(rng.normal(size=(SZ, SZ)), 3)\n", + " noise = noise / (np.abs(noise).max() + 1e-9)\n", + " return (np.hypot(YY - cy, XX - cx) + rough * r * 2.2 * noise) < r\n", + "\n", + "\n", + "def draw(rng, cls, sev):\n", + " \"\"\"결함 유형·심각도 sev(0~1)에 맞는 정답 마스크와 색 정보를 만든다.\"\"\"\n", + " m = np.zeros((SZ, SZ), bool)\n", + " if cls == \"crack\":\n", + " y, x, ang = rng.uniform(12, 52), rng.uniform(12, 52), rng.uniform(0, np.pi)\n", + " for _ in range(int(20 + 40 * sev)):\n", + " ang += rng.normal(0, 0.35); y += np.sin(ang) * 1.2; x += np.cos(ang) * 1.2\n", + " if 0 <= int(y) < SZ and 0 <= int(x) < SZ:\n", + " m[int(y), int(x)] = True\n", + " m = ndi.binary_dilation(m, iterations=1 + int(sev > 0.6))\n", + " elif cls == \"corrosion\":\n", + " for _ in range(int(rng.integers(1, 4))):\n", + " m |= blob(rng, rng.uniform(14, 50), rng.uniform(14, 50), 3 + 9 * sev * rng.uniform(0.6, 1.1))\n", + " elif cls == \"damage\":\n", + " m = blob(rng, rng.uniform(20, 44), rng.uniform(20, 44), 6 + 12 * sev, 0.3)\n", + " elif cls == \"leak\":\n", + " x0, y0 = rng.uniform(16, 48), rng.uniform(8, 24)\n", + " w, ln = 2 + 4 * sev, 12 + 26 * sev\n", + " m = (np.abs(XX - x0 - 0.15 * (YY - y0)) < w * (1 - 0.4 * (YY - y0) / ln)) & (YY > y0) & (YY < y0 + ln)\n", + " return m\n", + "\n", + "\n", + "def make_images(n=600, seed=0, mud=0.0, p_none=0.25, dark=False):\n", + " \"\"\"가상 외관 사진 n장(+진흙 오염 mud 비율)과 관리 기록·정답. 반환: meta(DataFrame), imgs(n,64,64,3), masks(n,64,64).\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " imgs, masks, rows = np.zeros((n, SZ, SZ, 3), np.float32), np.zeros((n, SZ, SZ), bool), []\n", + " for i in range(n):\n", + " cls = \"none\" if rng.random() < p_none else str(rng.choice(CLASSES[1:], p=[0.25, 0.3, 0.25, 0.2]))\n", + " sev = 0.0 if cls == \"none\" else float(rng.beta(1.6, 2.0))\n", + " base = np.array([0.42, 0.46, 0.40]) * (1 + 0.5 * ndi.gaussian_filter(rng.normal(size=(SZ, SZ)), 8)[..., None] * 3)\n", + " img = base + rng.normal(0, 0.012, (SZ, SZ, 3))\n", + " m = draw(rng, cls, sev)\n", + " a = ndi.gaussian_filter(m.astype(float), 0.8)[..., None] # 경계를 부드럽게 섞는다\n", + " if cls == \"crack\":\n", + " col = img * rng.uniform(0.2, 0.45)\n", + " elif cls == \"corrosion\":\n", + " col = np.array([0.55, 0.30, 0.12]) * rng.uniform(0.75, 1.15) * (0.85 + 0.3 * rng.random((SZ, SZ, 1)))\n", + " elif cls == \"damage\":\n", + " col = np.ones_like(img) * np.array([0.34, 0.34, 0.36]) * (1.0 - 0.45 * sev) * rng.uniform(0.75, 1.0)\n", + " rim = m & ~ndi.binary_erosion(m, iterations=2)\n", + " a = np.maximum(a, 0.0); img = np.where(rim[..., None], 0.7, img)\n", + " elif cls == \"leak\":\n", + " col = np.ones_like(img) * np.array([0.10, 0.09, 0.08]) * rng.uniform(0.8, 2.6)\n", + " else:\n", + " col = img\n", + " img = img * (1 - a) + col * a\n", + " if rng.random() < 0.35: # 무해한 얼룩(결함 아님): 옅은 갈색 번짐\n", + " st = blob(rng, rng.uniform(5, 58), rng.uniform(5, 58), rng.uniform(6, 14), 0.5)\n", + " al = ndi.gaussian_filter(st.astype(float), 2.5)[..., None] * rng.uniform(0.15, 0.4)\n", + " img = img * (1 - al) + np.array([0.50, 0.40, 0.30]) * al\n", + " if rng.random() < 0.3: # 무해한 긴 스크래치(밝은 선)\n", + " y0, x0, ang = rng.uniform(5, 58), rng.uniform(5, 58), rng.uniform(0, np.pi)\n", + " for t in np.linspace(0, 25, 40):\n", + " yy, xx = int(y0 + t * np.sin(ang)), int(x0 + t * np.cos(ang))\n", + " if 0 <= yy < SZ and 0 <= xx < SZ:\n", + " img[yy, xx] = np.clip(img[yy, xx] * 1.5, 0, 1)\n", + " if mud > 0: # 진흙 오염: 결함 위를 덮을 수 있다(정답 마스크는 그대로)\n", + " mm = np.zeros((SZ, SZ), bool)\n", + " while mm.mean() < mud:\n", + " mm |= blob(rng, rng.uniform(0, SZ), rng.uniform(0, SZ), rng.uniform(5, 11), 0.5)\n", + " img[mm] = np.array([0.38, 0.30, 0.22]) * (0.85 + 0.3 * rng.random((mm.sum(), 1)))\n", + " gain = rng.uniform(0.3, 0.55) if dark else rng.uniform(0.5, 1.15)\n", + " ang = rng.uniform(0, 2 * np.pi)\n", + " light = gain * (1 + 0.3 * (np.cos(ang) * (XX - 32) + np.sin(ang) * (YY - 32)) / 32)\n", + " imgs[i] = np.clip(img * light[..., None] + rng.normal(0, 0.025, (SZ, SZ, 3)), 0, 1)\n", + " masks[i] = m\n", + " area = float(m.mean())\n", + " depth = {\"none\": 0.0, \"crack\": 0.3 + 3.0 * sev, \"corrosion\": 0.1 + 1.5 * sev, \"damage\": 1.0 + 8.0 * sev, \"leak\": 0.0}[cls] * rng.lognormal(0, 0.1)\n", + " idx = float(np.clip(100 * (1 - np.exp(-(CLS_W[cls] * area * 9 + 0.05 * depth))) + rng.normal(0, 1.5), 0, 100))\n", + " rows.append({\"img_id\": f\"IMG{seed:02d}{i:04d}\", \"cls\": cls, \"sev\": sev, \"area_frac\": area, \"depth_mm\": depth, \"damage_idx\": idx,\n", + " \"grade\": int(grade_of(idx)), \"gain\": gain, \"mud\": mud})\n", + " meta = pd.DataFrame(rows)\n", + " # 장비 관리 기록(제원 요약): 사진 판정과 대조할 값\n", + " age = rng.integers(1, 25, n)\n", + " meta[\"day\"] = np.sort(rng.integers(0, 90, n))\n", + " meta[\"equip_type\"] = rng.choice([\"vehicle\", \"generator\", \"comm\", \"trailer\"], n, p=[0.4, 0.2, 0.2, 0.2])\n", + " meta[\"mfg_year\"] = 2026 - age\n", + " meta[\"op_hours\"] = np.clip(rng.normal(180 * age, 400, n) + 8 * meta[\"damage_idx\"], 10, 20000)\n", + " meta[\"env\"] = rng.choice([\"coastal\", \"inland\"], n, p=[0.3, 0.7])\n", + " reg = np.clip(meta[\"grade\"] + rng.choice([-1, 0, 0, 0, 0, 0, 1, 2], n), 1, 3) # 장비 관리 기록상 직전 정비 등급\n", + " meta[\"reg_grade\"] = reg\n", + " meta[\"emergency\"] = rng.random(n) < 0.04 # 긴급 점검 요청(화질 불량이어도 보존)\n", + " return meta, imgs, masks\n", + "\n", + "# ---- 전처리(조명 보정·표준화)와 결함 후보 분할, 특징 추출 (cv2 대신 numpy·scipy 사용) ----\n", + "def correct(img):\n", + " \"\"\"조명 보정: 밝기에 평면(조명 기울기)을 적합해 나눈다. 어두운 결함 화소는 제외하고 두 번 반복 적합한다. 반환: 보정 밝기(정상 면 약 1), 색 비율.\"\"\"\n", + " g = img.mean(axis=2)\n", + " A = np.c_[np.ones(SZ * SZ), XX.ravel() / SZ, YY.ravel() / SZ]\n", + " keep = np.ones(SZ * SZ, bool)\n", + " for _ in range(3):\n", + " coef, *_ = np.linalg.lstsq(A[keep], g.ravel()[keep], rcond=None)\n", + " plane = (A @ coef).reshape(SZ, SZ)\n", + " keep = (np.abs(g - plane) < 0.08 * np.maximum(plane, 1e-3)).ravel()\n", + " if keep.sum() < 200:\n", + " keep[:] = True\n", + " c = g / np.maximum(plane, 1e-3)\n", + " chroma = img[..., 0] / (img.sum(axis=2) + 1e-6)\n", + " return c, chroma\n", + "\n", + "\n", + "SEGP = {\"dark\": 0.7, \"rust\": 0.5, \"rim\": 1.45} # 분할 임계값(어두움 보정 밝기, 붉은 비율, 밝은 테두리): 재보정 대상\n", + "\n", + "\n", + "def segment(img):\n", + " \"\"\"결함 후보 마스크: 어두운 영역(균열·누유·파손) + 붉은 갈색 영역(부식) + 밝은 테두리(파손).\"\"\"\n", + " c, chroma = correct(img)\n", + " dark = c < SEGP[\"dark\"]\n", + " rust = (chroma > SEGP[\"rust\"]) & (c > 0.45)\n", + " rim = c > SEGP[\"rim\"]\n", + " m = dark | rust | ndi.binary_dilation(rim, iterations=1) & ndi.binary_dilation(dark, iterations=3)\n", + " m = ndi.binary_opening(m, structure=np.ones((2, 2))) if m.sum() > 12 else m\n", + " return m, c, chroma, dark, rust, rim\n", + "\n", + "\n", + "def extract(img):\n", + " \"\"\"분할 결과와 색·모양에서 24개 특징을 만든다.\"\"\"\n", + " m, c, chroma, dark, rust, rim = segment(img)\n", + " lab, n = ndi.label(m)\n", + " area = float(m.sum())\n", + " if n:\n", + " sizes = ndi.sum(m, lab, range(1, n + 1))\n", + " k = int(np.argmax(sizes)) + 1\n", + " sl = ndi.find_objects(lab)[k - 1]\n", + " h, w = sl[0].stop - sl[0].start, sl[1].stop - sl[1].start\n", + " big = float(sizes.max())\n", + " elong, fill = max(h, w) / max(min(h, w), 1), big / (h * w)\n", + " else:\n", + " big, elong, fill = 0.0, 1.0, 0.0\n", + " gy, gx = np.gradient(c)\n", + " mag = np.hypot(gx, gy)\n", + " inside = m.sum() > 0\n", + " return {\"area_px\": area, \"area_frac\": area / SZ ** 2, \"n_comp\": float(n), \"big_px\": big, \"elong\": elong, \"fill\": fill,\n", + " \"dark_frac\": float(dark.mean()), \"rust_frac\": float(rust.mean()), \"rim_frac\": float(rim.mean()),\n", + " \"c_mean\": float(c.mean()), \"c_std\": float(c.std()), \"c_min\": float(c.min()), \"edge_mean\": float(mag.mean()), \"edge_p95\": float(np.quantile(mag, 0.95)),\n", + " \"chroma_mean\": float(chroma.mean()), \"chroma_max\": float(chroma.max()),\n", + " \"in_dark\": float(c[m].mean()) if inside else 1.0, \"in_chroma\": float(chroma[m].mean()) if inside else float(chroma.mean()),\n", + " \"ring_contrast\": float(c[ndi.binary_dilation(m, iterations=3) & ~m].mean() - c[m].mean()) if inside and (~m).any() else 0.0,\n", + " \"thin\": float(1 - fill), \"gain\": float(img.mean()), \"sat\": float((img.max(axis=2) - img.min(axis=2)).mean()),\n", + " \"cy\": float(ndi.center_of_mass(m)[0] / SZ) if inside else 0.5, \"cx\": float(ndi.center_of_mass(m)[1] / SZ) if inside else 0.5}\n", + "\n", + "\n", + "def feature_table(imgs):\n", + " return pd.DataFrame([extract(im) for im in imgs])\n", + "\n", + "mtr, itr, _ = make_images(400, seed=1)\n", + "mte, ite, kte = make_images(300, seed=2)\n", + "\n", + "\n", + "def hist_feat(imgs):\n", + " \"\"\"Baseline 특징: 밝기 히스토그램 16칸 + 채널 평균 3 + 밝기 표준편차 1.\"\"\"\n", + " out = []\n", + " for im in imgs:\n", + " g = im.mean(axis=2)\n", + " h, _ = np.histogram(g / max(g.max(), 1e-3), bins=16, range=(0, 1))\n", + " out.append(np.r_[h / g.size, im.mean(axis=(0, 1)) / max(g.mean(), 1e-3), g.std() / max(g.mean(), 1e-3)])\n", + " return np.array(out)\n", + "\n", + "\n", + "Htr, Hte = hist_feat(itr), hist_feat(ite)\n", + "ytr, yte = (mtr[\"cls\"] != \"none\").values, (mte[\"cls\"] != \"none\").values\n", + "base = make_pipeline(StandardScaler(), LogisticRegression(max_iter=2000, C=0.5)).fit(Htr, ytr)\n", + "pr = base.predict(Hte)\n", + "print(f\"[Baseline 결과] 학습 {len(mtr)}장 / 평가 {len(mte)}장, 결함 사진 {yte.mean():.1%}\")\n", + "print(f\"정확도 {accuracy_score(yte, pr):.1%}, 정밀도 {precision_score(yte, pr):.1%}, 재현율 {recall_score(yte, pr):.1%}, F1 {f1_score(yte, pr):.3f}\")\n", + "rec = pd.DataFrame({\"cls\": mte[\"cls\"], \"hit\": pr == yte}).query(\"cls != 'none'\").groupby(\"cls\")[\"hit\"].mean().round(2)\n", + "print(\"결함 유형별 재현율:\", \", \".join(f\"{KO_CLS[k]} {v:.0%}\" for k, v in rec.items()))\n", + "small = (mte[\"area_frac\"] > 0) & (mte[\"area_frac\"] < 0.02)\n", + "print(f\"면적 2% 미만 작은 결함 {int(small.sum())}장 재현율 {pr[small.values].mean():.0%} (큰 결함 {pr[(mte['area_frac'] >= 0.02).values].mean():.0%})\")\n", + "dk = mte[\"gain\"] < 0.6\n", + "print(f\"조명이 어두운 사진(gain<0.6) 정확도 {(pr == yte)[dk.values].mean():.0%} vs 밝은 사진 {(pr == yte)[~dk.values].mean():.0%}\")\n", + "print(\"-> 전체 통계만 보는 Baseline은 작은 균열을 놓치고 조명에 흔들린다. 결함 위치·모양을 보는 분할 특징이 필요하다(미션 4~5의 동기).\")\n", + "\n", + "# 1) 상관분석: 결함 유무와의 관계(점이연 상관, 유의수준 0.05) - 사진 특징 + 장비 제원 기록\n", + "Fall = pd.concat([feature_table(ite), feature_table(itr[:200])], ignore_index=True)\n", + "mall = pd.concat([mte, mtr[:200]], ignore_index=True)\n", + "Fall[\"op_hours\"], Fall[\"reg_grade\"], Fall[\"mfg_year\"] = mall[\"op_hours\"], mall[\"reg_grade\"], mall[\"mfg_year\"]\n", + "Fall[\"coastal\"] = (mall[\"env\"] == \"coastal\").astype(int)\n", + "yall = (mall[\"cls\"] != \"none\").astype(float)\n", + "rows = []\n", + "for f in [\"c_std\", \"c_min\", \"edge_p95\", \"dark_frac\", \"rust_frac\", \"sat\", \"op_hours\", \"reg_grade\", \"mfg_year\", \"coastal\"]:\n", + " r, p = stats.pearsonr(Fall[f].astype(float), yall)\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", + "\n", + "# 2) 제원 기록과의 불일치: 사진 판정 등급 vs 관리 기록 등급\n", + "ct = pd.crosstab(mall[\"grade\"], mall[\"reg_grade\"]).rename(index=GRADE_NAME, columns=GRADE_NAME)\n", + "gap = (mall[\"grade\"] - mall[\"reg_grade\"]).abs() >= 2\n", + "print(f\"\\n[제원 대조] 사진 등급과 관리 기록 등급이 2단계 이상 다른 장비 {int(gap.sum())}대({gap.mean():.1%}) -> 제원 이상 신호 후보\")\n", + "print(ct.to_string())\n", + "\n", + "# 3) 파레토(ABC): 정비 공수(가상)가 큰 (장비 구분, 결함 유형) 조합\n", + "HOURS = {1: 2, 2: 8, 3: 30}\n", + "mall[\"hours\"] = mall[\"grade\"].map(HOURS)\n", + "g = mall[mall[\"cls\"] != \"none\"].groupby([\"equip_type\", \"cls\"])[\"hours\"].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", + "print(\"\\n[파레토 A등급(누적 정비 공수 80%)]\", \", \".join(f\"{e}/{KO_CLS[c]}\" for (e, c), v in abc.items() if v == \"A\"))\n", + "print(f\"A등급은 전체 조합 {len(g)}개 중 {int((abc == 'A').sum())}개({(abc == 'A').mean():.0%})\")\n", + "\n", + "# 4) EDA 차트 8종\n", + "fig, ax = plt.subplots(2, 4, figsize=(18, 8))\n", + "a = ax.ravel()\n", + "a[0].imshow(np.concatenate([ite[int(np.where(mte['cls'] == c)[0][0])] for c in CLASSES], axis=1)); a[0].set_title(\"1. Samples: none/crack/corrosion/damage/leak\"); a[0].axis(\"off\")\n", + "mall[\"cls\"].value_counts().plot.bar(ax=a[1]); a[1].set_title(\"2. Photos by defect class\")\n", + "a[2].hist(mall.loc[yall == 0, \"gain\"], bins=20, alpha=0.6, label=\"none\"); a[2].hist(mall.loc[yall == 1, \"gain\"], bins=20, alpha=0.6, label=\"defect\"); a[2].legend(); a[2].set_title(\"3. Lighting gain\")\n", + "mall.boxplot(column=\"damage_idx\", by=\"cls\", ax=a[3], rot=30); a[3].set_title(\"4. Damage index by class\")\n", + "a[4].imshow(ct.values, cmap=\"Blues\"); a[4].set_xticks(range(3), [1, 2, 3]); a[4].set_yticks(range(3), [1, 2, 3]); a[4].set_xlabel(\"registered grade\"); a[4].set_ylabel(\"photo grade\"); a[4].set_title(\"5. Photo vs registered grade\")\n", + "a[5].scatter(mall[\"op_hours\"], mall[\"damage_idx\"], s=6, c=mall[\"env\"].map({\"coastal\": \"tab:red\", \"inland\": \"tab:blue\"})); a[5].set_title(\"6. Op hours vs damage (red = coastal)\")\n", + "a[6].barh(corr[\"피처\"], corr[\"상관계수\"]); a[6].set_title(\"7. Correlation with defect\")\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 repair hours\")\n", + "plt.suptitle(\"QLT03 EDA - baseline defect screening (synthetic photos)\"); plt.tight_layout(); plt.show()\n", + "print(\"\\nEDA 차트 8종 생성 (목표 8종 이상: 달성)\")\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 03 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + "[Baseline 결과] 학습 400장 / 평가 300장, 결함 사진 76.7%\n", + "정확도 86.0%, 정밀도 89.8%, 재현율 92.2%, F1 0.910\n", + "결함 유형별 재현율: 부식 96%, 균열 75%, 파손 96%, 누유 98%\n", + "면적 2% 미만 작은 결함 31장 재현율 74% (큰 결함 95%)\n", + "조명이 어두운 사진(gain<0.6) 정확도 82% vs 밝은 사진 87%\n", + "-> 전체 통계만 보는 Baseline은 작은 균열을 놓치고 조명에 흔들린다. 결함 위치·모양을 보는 분할 특징이 필요하다(미션 4~5의 동기).\n", + "\n", + "[결함 유무와의 상관분석]\n", + " 피처 상관계수 p-value 유의\n", + " c_min -0.711 2.7e-78 True\n", + " c_std 0.496 1.8e-32 True\n", + "dark_frac 0.457 3.5e-27 True\n", + " edge_p95 0.373 5.4e-18 True\n", + "reg_grade 0.272 6.2e-10 True\n", + "rust_frac 0.242 4.5e-08 True\n", + " sat 0.186 3.0e-05 True\n", + " op_hours 0.066 1.4e-01 False\n", + " mfg_year 0.045 3.1e-01 False\n", + " coastal 0.004 9.2e-01 False\n", + "유의미한 피처 7개 (목표 5개 이상: 달성)\n", + "\n", + "[제원 대조] 사진 등급과 관리 기록 등급이 2단계 이상 다른 장비 47대(9.4%) -> 제원 이상 신호 후보\n", + "reg_grade 현장정비 야전정비 창정비\n", + "grade \n", + "현장정비 219 50 47\n", + "야전정비 8 58 28\n", + "창정비 0 15 75\n", + "\n", + "[파레토 A등급(누적 정비 공수 80%)] vehicle/파손, comm/파손, vehicle/부식, trailer/파손, generator/파손, generator/부식\n", + "A등급은 전체 조합 16개 중 6개(38%)\n", + "\n", + "EDA 차트 8종 생성 (목표 8종 이상: 달성)\n", + "```\n", + "
\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 04. 세그멘테이션(Mask R-CNN) 기반 결함 면적/깊이 정밀 측정 및 손상도 자동 판정 모델 고도화\n", + "**소요시간**: 약 3.0시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "세그멘테이션 기반 결함 면적/깊이 정밀 측정 및 손상도 자동 판정 모델을 고도화한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **분할 기반으로 결함 면적과 깊이를 측정하고 손상도를 자동 판정하는 모델을 만들어 기준 오차를 측정한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **조명 보정 후 임계값 분할(Mask R-CNN 대체)로 결함 마스크를 만들고 정답 마스크와의 IoU·Dice로 정밀도 평가하고 성능을 비교하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `src/features/qlt03_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": [ + "\"\"\"분할(thresholding 기반 결함 후보 분할, Mask R-CNN 대체) 결과에서 결함 면적을 측정·보정하고 깊이와 손상도 점수를 학습해 손상도를 자동 판정하며, 분할 IoU·면적/깊이/손상도 오차(MAPE)와 변수군 제거 실험으로 파생 피처 설계의 타당성을 검증한다.\"\"\"\n", + "import numpy as np\n", + "import pandas as pd\n", + "import matplotlib.pyplot as plt\n", + "from sklearn.ensemble import RandomForestClassifier, GradientBoostingRegressor\n", + "from sklearn.linear_model import LinearRegression\n", + "from sklearn.model_selection import cross_val_predict\n", + "from sklearn.metrics import r2_score\n", + "\n", + "# ---------------------------------------------------------------------------\n", + "# [교육용가상자료] 장비 외관 사진 생성기 (실제 장비·부대·위치 사진이 아니라 난수로 그린 64x64 합성 이미지)\n", + "# 도장된 금속 표면에 균열(crack)·부식(corrosion)·파손(damage)·누유(leak)를 그리고, 조명 편차와 진흙(mud) 오염을 섞는다.\n", + "# 이미지와 함께 장비 관리 기록(제원 요약)과 정답 결함 마스크·손상도·정비 등급을 만든다.\n", + "# ---------------------------------------------------------------------------\n", + "from scipy import ndimage as ndi\n", + "\n", + "SZ = 64\n", + "CLASSES = [\"none\", \"crack\", \"corrosion\", \"damage\", \"leak\"]\n", + "KO_CLS = {\"none\": \"결함 없음\", \"crack\": \"균열\", \"corrosion\": \"부식\", \"damage\": \"파손\", \"leak\": \"누유\"}\n", + "CLS_W = {\"none\": 0.0, \"crack\": 1.4, \"corrosion\": 1.0, \"damage\": 1.6, \"leak\": 0.6} # 손상도 계산용 결함 유형 가중치\n", + "GRADE_NAME = {1: \"현장정비\", 2: \"야전정비\", 3: \"창정비\"}\n", + "YY, XX = np.mgrid[0:SZ, 0:SZ]\n", + "\n", + "\n", + "def grade_of(idx):\n", + " return np.where(np.asarray(idx) >= 65, 3, np.where(np.asarray(idx) >= 35, 2, 1))\n", + "\n", + "\n", + "def blob(rng, cy, cx, r, rough=0.35):\n", + " \"\"\"불규칙한 원형 영역 마스크.\"\"\"\n", + " noise = ndi.gaussian_filter(rng.normal(size=(SZ, SZ)), 3)\n", + " noise = noise / (np.abs(noise).max() + 1e-9)\n", + " return (np.hypot(YY - cy, XX - cx) + rough * r * 2.2 * noise) < r\n", + "\n", + "\n", + "def draw(rng, cls, sev):\n", + " \"\"\"결함 유형·심각도 sev(0~1)에 맞는 정답 마스크와 색 정보를 만든다.\"\"\"\n", + " m = np.zeros((SZ, SZ), bool)\n", + " if cls == \"crack\":\n", + " y, x, ang = rng.uniform(12, 52), rng.uniform(12, 52), rng.uniform(0, np.pi)\n", + " for _ in range(int(20 + 40 * sev)):\n", + " ang += rng.normal(0, 0.35); y += np.sin(ang) * 1.2; x += np.cos(ang) * 1.2\n", + " if 0 <= int(y) < SZ and 0 <= int(x) < SZ:\n", + " m[int(y), int(x)] = True\n", + " m = ndi.binary_dilation(m, iterations=1 + int(sev > 0.6))\n", + " elif cls == \"corrosion\":\n", + " for _ in range(int(rng.integers(1, 4))):\n", + " m |= blob(rng, rng.uniform(14, 50), rng.uniform(14, 50), 3 + 9 * sev * rng.uniform(0.6, 1.1))\n", + " elif cls == \"damage\":\n", + " m = blob(rng, rng.uniform(20, 44), rng.uniform(20, 44), 6 + 12 * sev, 0.3)\n", + " elif cls == \"leak\":\n", + " x0, y0 = rng.uniform(16, 48), rng.uniform(8, 24)\n", + " w, ln = 2 + 4 * sev, 12 + 26 * sev\n", + " m = (np.abs(XX - x0 - 0.15 * (YY - y0)) < w * (1 - 0.4 * (YY - y0) / ln)) & (YY > y0) & (YY < y0 + ln)\n", + " return m\n", + "\n", + "\n", + "def make_images(n=600, seed=0, mud=0.0, p_none=0.25, dark=False):\n", + " \"\"\"가상 외관 사진 n장(+진흙 오염 mud 비율)과 관리 기록·정답. 반환: meta(DataFrame), imgs(n,64,64,3), masks(n,64,64).\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " imgs, masks, rows = np.zeros((n, SZ, SZ, 3), np.float32), np.zeros((n, SZ, SZ), bool), []\n", + " for i in range(n):\n", + " cls = \"none\" if rng.random() < p_none else str(rng.choice(CLASSES[1:], p=[0.25, 0.3, 0.25, 0.2]))\n", + " sev = 0.0 if cls == \"none\" else float(rng.beta(1.6, 2.0))\n", + " base = np.array([0.42, 0.46, 0.40]) * (1 + 0.5 * ndi.gaussian_filter(rng.normal(size=(SZ, SZ)), 8)[..., None] * 3)\n", + " img = base + rng.normal(0, 0.012, (SZ, SZ, 3))\n", + " m = draw(rng, cls, sev)\n", + " a = ndi.gaussian_filter(m.astype(float), 0.8)[..., None] # 경계를 부드럽게 섞는다\n", + " if cls == \"crack\":\n", + " col = img * rng.uniform(0.2, 0.45)\n", + " elif cls == \"corrosion\":\n", + " col = np.array([0.55, 0.30, 0.12]) * rng.uniform(0.75, 1.15) * (0.85 + 0.3 * rng.random((SZ, SZ, 1)))\n", + " elif cls == \"damage\":\n", + " col = np.ones_like(img) * np.array([0.34, 0.34, 0.36]) * (1.0 - 0.45 * sev) * rng.uniform(0.75, 1.0)\n", + " rim = m & ~ndi.binary_erosion(m, iterations=2)\n", + " a = np.maximum(a, 0.0); img = np.where(rim[..., None], 0.7, img)\n", + " elif cls == \"leak\":\n", + " col = np.ones_like(img) * np.array([0.10, 0.09, 0.08]) * rng.uniform(0.8, 2.6)\n", + " else:\n", + " col = img\n", + " img = img * (1 - a) + col * a\n", + " if rng.random() < 0.35: # 무해한 얼룩(결함 아님): 옅은 갈색 번짐\n", + " st = blob(rng, rng.uniform(5, 58), rng.uniform(5, 58), rng.uniform(6, 14), 0.5)\n", + " al = ndi.gaussian_filter(st.astype(float), 2.5)[..., None] * rng.uniform(0.15, 0.4)\n", + " img = img * (1 - al) + np.array([0.50, 0.40, 0.30]) * al\n", + " if rng.random() < 0.3: # 무해한 긴 스크래치(밝은 선)\n", + " y0, x0, ang = rng.uniform(5, 58), rng.uniform(5, 58), rng.uniform(0, np.pi)\n", + " for t in np.linspace(0, 25, 40):\n", + " yy, xx = int(y0 + t * np.sin(ang)), int(x0 + t * np.cos(ang))\n", + " if 0 <= yy < SZ and 0 <= xx < SZ:\n", + " img[yy, xx] = np.clip(img[yy, xx] * 1.5, 0, 1)\n", + " if mud > 0: # 진흙 오염: 결함 위를 덮을 수 있다(정답 마스크는 그대로)\n", + " mm = np.zeros((SZ, SZ), bool)\n", + " while mm.mean() < mud:\n", + " mm |= blob(rng, rng.uniform(0, SZ), rng.uniform(0, SZ), rng.uniform(5, 11), 0.5)\n", + " img[mm] = np.array([0.38, 0.30, 0.22]) * (0.85 + 0.3 * rng.random((mm.sum(), 1)))\n", + " gain = rng.uniform(0.3, 0.55) if dark else rng.uniform(0.5, 1.15)\n", + " ang = rng.uniform(0, 2 * np.pi)\n", + " light = gain * (1 + 0.3 * (np.cos(ang) * (XX - 32) + np.sin(ang) * (YY - 32)) / 32)\n", + " imgs[i] = np.clip(img * light[..., None] + rng.normal(0, 0.025, (SZ, SZ, 3)), 0, 1)\n", + " masks[i] = m\n", + " area = float(m.mean())\n", + " depth = {\"none\": 0.0, \"crack\": 0.3 + 3.0 * sev, \"corrosion\": 0.1 + 1.5 * sev, \"damage\": 1.0 + 8.0 * sev, \"leak\": 0.0}[cls] * rng.lognormal(0, 0.1)\n", + " idx = float(np.clip(100 * (1 - np.exp(-(CLS_W[cls] * area * 9 + 0.05 * depth))) + rng.normal(0, 1.5), 0, 100))\n", + " rows.append({\"img_id\": f\"IMG{seed:02d}{i:04d}\", \"cls\": cls, \"sev\": sev, \"area_frac\": area, \"depth_mm\": depth, \"damage_idx\": idx,\n", + " \"grade\": int(grade_of(idx)), \"gain\": gain, \"mud\": mud})\n", + " meta = pd.DataFrame(rows)\n", + " # 장비 관리 기록(제원 요약): 사진 판정과 대조할 값\n", + " age = rng.integers(1, 25, n)\n", + " meta[\"day\"] = np.sort(rng.integers(0, 90, n))\n", + " meta[\"equip_type\"] = rng.choice([\"vehicle\", \"generator\", \"comm\", \"trailer\"], n, p=[0.4, 0.2, 0.2, 0.2])\n", + " meta[\"mfg_year\"] = 2026 - age\n", + " meta[\"op_hours\"] = np.clip(rng.normal(180 * age, 400, n) + 8 * meta[\"damage_idx\"], 10, 20000)\n", + " meta[\"env\"] = rng.choice([\"coastal\", \"inland\"], n, p=[0.3, 0.7])\n", + " reg = np.clip(meta[\"grade\"] + rng.choice([-1, 0, 0, 0, 0, 0, 1, 2], n), 1, 3) # 장비 관리 기록상 직전 정비 등급\n", + " meta[\"reg_grade\"] = reg\n", + " meta[\"emergency\"] = rng.random(n) < 0.04 # 긴급 점검 요청(화질 불량이어도 보존)\n", + " return meta, imgs, masks\n", + "# ---- 전처리(조명 보정·표준화)와 결함 후보 분할, 특징 추출 (cv2 대신 numpy·scipy 사용) ----\n", + "def correct(img):\n", + " \"\"\"조명 보정: 밝기에 평면(조명 기울기)을 적합해 나눈다. 어두운 결함 화소는 제외하고 두 번 반복 적합한다. 반환: 보정 밝기(정상 면 약 1), 색 비율.\"\"\"\n", + " g = img.mean(axis=2)\n", + " A = np.c_[np.ones(SZ * SZ), XX.ravel() / SZ, YY.ravel() / SZ]\n", + " keep = np.ones(SZ * SZ, bool)\n", + " for _ in range(3):\n", + " coef, *_ = np.linalg.lstsq(A[keep], g.ravel()[keep], rcond=None)\n", + " plane = (A @ coef).reshape(SZ, SZ)\n", + " keep = (np.abs(g - plane) < 0.08 * np.maximum(plane, 1e-3)).ravel()\n", + " if keep.sum() < 200:\n", + " keep[:] = True\n", + " c = g / np.maximum(plane, 1e-3)\n", + " chroma = img[..., 0] / (img.sum(axis=2) + 1e-6)\n", + " return c, chroma\n", + "\n", + "\n", + "SEGP = {\"dark\": 0.7, \"rust\": 0.5, \"rim\": 1.45} # 분할 임계값(어두움 보정 밝기, 붉은 비율, 밝은 테두리): 재보정 대상\n", + "\n", + "\n", + "def segment(img):\n", + " \"\"\"결함 후보 마스크: 어두운 영역(균열·누유·파손) + 붉은 갈색 영역(부식) + 밝은 테두리(파손).\"\"\"\n", + " c, chroma = correct(img)\n", + " dark = c < SEGP[\"dark\"]\n", + " rust = (chroma > SEGP[\"rust\"]) & (c > 0.45)\n", + " rim = c > SEGP[\"rim\"]\n", + " m = dark | rust | ndi.binary_dilation(rim, iterations=1) & ndi.binary_dilation(dark, iterations=3)\n", + " m = ndi.binary_opening(m, structure=np.ones((2, 2))) if m.sum() > 12 else m\n", + " return m, c, chroma, dark, rust, rim\n", + "\n", + "\n", + "def extract(img):\n", + " \"\"\"분할 결과와 색·모양에서 24개 특징을 만든다.\"\"\"\n", + " m, c, chroma, dark, rust, rim = segment(img)\n", + " lab, n = ndi.label(m)\n", + " area = float(m.sum())\n", + " if n:\n", + " sizes = ndi.sum(m, lab, range(1, n + 1))\n", + " k = int(np.argmax(sizes)) + 1\n", + " sl = ndi.find_objects(lab)[k - 1]\n", + " h, w = sl[0].stop - sl[0].start, sl[1].stop - sl[1].start\n", + " big = float(sizes.max())\n", + " elong, fill = max(h, w) / max(min(h, w), 1), big / (h * w)\n", + " else:\n", + " big, elong, fill = 0.0, 1.0, 0.0\n", + " gy, gx = np.gradient(c)\n", + " mag = np.hypot(gx, gy)\n", + " inside = m.sum() > 0\n", + " return {\"area_px\": area, \"area_frac\": area / SZ ** 2, \"n_comp\": float(n), \"big_px\": big, \"elong\": elong, \"fill\": fill,\n", + " \"dark_frac\": float(dark.mean()), \"rust_frac\": float(rust.mean()), \"rim_frac\": float(rim.mean()),\n", + " \"c_mean\": float(c.mean()), \"c_std\": float(c.std()), \"c_min\": float(c.min()), \"edge_mean\": float(mag.mean()), \"edge_p95\": float(np.quantile(mag, 0.95)),\n", + " \"chroma_mean\": float(chroma.mean()), \"chroma_max\": float(chroma.max()),\n", + " \"in_dark\": float(c[m].mean()) if inside else 1.0, \"in_chroma\": float(chroma[m].mean()) if inside else float(chroma.mean()),\n", + " \"ring_contrast\": float(c[ndi.binary_dilation(m, iterations=3) & ~m].mean() - c[m].mean()) if inside and (~m).any() else 0.0,\n", + " \"thin\": float(1 - fill), \"gain\": float(img.mean()), \"sat\": float((img.max(axis=2) - img.min(axis=2)).mean()),\n", + " \"cy\": float(ndi.center_of_mass(m)[0] / SZ) if inside else 0.5, \"cx\": float(ndi.center_of_mass(m)[1] / SZ) if inside else 0.5}\n", + "\n", + "\n", + "def feature_table(imgs):\n", + " return pd.DataFrame([extract(im) for im in imgs])\n", + "# ---- 판정 파이프라인: 결함 유형 분류 + 손상도 회귀(분류 확률 포함) -> 정비 등급 ----\n", + "def fit_pipeline(meta, F):\n", + " \"\"\"결함 유형 분류기와 손상도 회귀기를 학습한다. 회귀기 입력의 분류 확률은 교차 예측으로 만들어 누설을 막는다.\"\"\"\n", + " clf = RandomForestClassifier(n_estimators=150, random_state=0, n_jobs=1)\n", + " P = cross_val_predict(clf, F, meta[\"cls\"], cv=4, method=\"predict_proba\")\n", + " clf.fit(F, meta[\"cls\"])\n", + " reg = GradientBoostingRegressor(n_estimators=200, max_depth=3, learning_rate=0.06, random_state=0).fit(np.c_[F.values, P], meta[\"damage_idx\"])\n", + " return {\"clf\": clf, \"reg\": reg, \"cols\": list(F.columns)}\n", + "\n", + "\n", + "def predict_pipeline(pl, F):\n", + " X = F[pl[\"cols\"]]\n", + " P = pl[\"clf\"].predict_proba(X)\n", + " idx = np.clip(pl[\"reg\"].predict(np.c_[X.values, P]), 0, 100)\n", + " return pl[\"clf\"].classes_[P.argmax(axis=1)], P, idx, P.max(axis=1)\n", + "\n", + "mtr, itr, ktr = make_images(600, seed=1)\n", + "mte, ite, kte = make_images(300, seed=2)\n", + "Ftr, Fte = feature_table(itr), feature_table(ite)\n", + "print(f\"[1] 파생 피처 {Ftr.shape[1]}개(분할 면적·성분 수·모양, 명암, 색, 가장자리, 위치) / 학습 {len(mtr)}장, 평가 {len(mte)}장 (1화소 = 1.5mm 가정)\")\n", + "\n", + "# ---------------- 1) 분할 정밀도: 정답 마스크와의 IoU ----------------\n", + "seg = [segment(im)[0] for im in ite]\n", + "rows = []\n", + "for c in CLASSES[1:]:\n", + " ix = np.where(mte[\"cls\"].values == c)[0]\n", + " iou = [(seg[i] & kte[i]).sum() / max((seg[i] | kte[i]).sum(), 1) for i in ix]\n", + " dice = [2 * (seg[i] & kte[i]).sum() / max(seg[i].sum() + kte[i].sum(), 1) for i in ix]\n", + " hit = [seg[i].sum() > 0 and (seg[i] & kte[i]).sum() > 0 for i in ix]\n", + " rows.append((KO_CLS[c], len(ix), np.mean(iou), np.mean(dice), np.mean(hit)))\n", + "sg = pd.DataFrame(rows, columns=[\"결함\", \"장수\", \"평균 IoU\", \"평균 Dice\", \"검출률\"])\n", + "print(\"[2] 분할 성능(평가 300장)\\n\" + sg.round(2).to_string(index=False))\n", + "ok0 = np.mean([seg[i].sum() == 0 or (seg[i].sum() < 12) for i in np.where(mte[\"cls\"].values == \"none\")[0]])\n", + "print(f\" 결함 없는 사진 {int((mte['cls'] == 'none').sum())}장 중 분할 결과가 거의 비어 있는(12화소 미만) 사진 {ok0:.0%} (무해한 얼룩·스크래치 오탐 억제)\")\n", + "\n", + "# ---------------- 2) 면적 측정과 유형별 보정 ----------------\n", + "pl = fit_pipeline(mtr, Ftr)\n", + "cls_te, P_te, idx_te, conf_te = predict_pipeline(pl, Fte)\n", + "a_true, a_raw = mte[\"area_frac\"].values, Fte[\"area_frac\"].values\n", + "cal = {c: LinearRegression().fit(Ftr.loc[mtr[\"cls\"].values == c, [\"area_frac\"]], mtr.loc[mtr[\"cls\"].values == c, \"area_frac\"]) for c in CLASSES[1:]}\n", + "a_cal = np.array([cal[c].predict(pd.DataFrame({\"area_frac\": [a]}))[0] if c != \"none\" else 0.0 for c, a in zip(cls_te, a_raw)])\n", + "m = a_true > 0\n", + "mp = lambda y, p: float(np.mean(np.abs(y - p) / y) * 100)\n", + "print(f\"\\n[3] 면적 측정(결함 {int(m.sum())}장): 분할 원측정 MAPE {mp(a_true[m], a_raw[m]):.1f}% -> 유형별 선형 보정 후 {mp(a_true[m], a_cal[m]):.1f}% (분류 정확도 {(cls_te == mte['cls'].values).mean():.1%})\")\n", + "print(f\" 면적 환산 예: 평균 결함 면적 {a_true[m].mean() * SZ ** 2 * 1.5 ** 2:.0f}mm2(정답) / {a_cal[m].mean() * SZ ** 2 * 1.5 ** 2:.0f}mm2(보정 측정)\")\n", + "\n", + "# ---------------- 3) 깊이·손상도 모델 ----------------\n", + "dm_tr = mtr[\"depth_mm\"].values > 0.2\n", + "Xtr = np.c_[Ftr[pl[\"cols\"]].values, cross_val_predict(RandomForestClassifier(n_estimators=100, random_state=0, n_jobs=1), Ftr, mtr[\"cls\"], cv=3, method=\"predict_proba\")]\n", + "dep = GradientBoostingRegressor(n_estimators=200, max_depth=3, learning_rate=0.06, random_state=0).fit(Xtr[dm_tr], mtr[\"depth_mm\"].values[dm_tr])\n", + "d_pred = np.clip(dep.predict(np.c_[Fte[pl[\"cols\"]].values, P_te]), 0, None)\n", + "dm = mte[\"depth_mm\"].values > 0.2\n", + "print(f\"[4] 깊이 추정(깊이 있는 유형 {int(dm.sum())}장, 누유는 깊이 0으로 제외): MAPE {mp(mte['depth_mm'].values[dm], d_pred[dm]):.1f}%, MAE {np.mean(np.abs(mte['depth_mm'].values[dm] - d_pred[dm])):.2f}mm\")\n", + "print(\" 깊이는 사진 한 장으로 직접 관측되지 않아 면적·명암에서 간접 추정하므로 손상도보다 오차가 크다(실제 업무는 깊이 게이지 등 현장 측정으로 확인).\")\n", + "y = mte[\"damage_idx\"].values\n", + "big = y >= 10\n", + "mape_d, mae_d = mp(y[big], idx_te[big]), float(np.mean(np.abs(y - idx_te)))\n", + "base = LinearRegression().fit(Ftr[[\"area_px\"]], mtr[\"damage_idx\"])\n", + "b_pred = np.clip(base.predict(Fte[[\"area_px\"]]), 0, 100)\n", + "print(f\"[5] 손상도 점수(0~100, 점수 10 이상 {int(big.sum())}장): 면적만 쓰는 기준 MAPE {mp(y[big], b_pred[big]):.1f}% -> 제안 모델 MAPE {mape_d:.1f}% (목표 25% 이하: {'달성' if mape_d <= 25 else '미달'}), MAE {mae_d:.2f}, R2 {r2_score(y, idx_te):.3f}\")\n", + "g_true, g_pred = mte[\"grade\"].values, grade_of(idx_te)\n", + "print(f\" 정비 등급 일치율 {(g_true == g_pred).mean():.1%}, 한 단계 이내 {(np.abs(g_true - g_pred) <= 1).mean():.1%}\")\n", + "\n", + "# ---------------- 4) 변수군 제거 실험 ----------------\n", + "groups = {\"분할 면적·모양\": [\"area_px\", \"area_frac\", \"n_comp\", \"big_px\", \"elong\", \"fill\", \"thin\", \"cy\", \"cx\"], \"명암(어두운 비율·대비)\": [\"dark_frac\", \"c_mean\", \"c_std\", \"c_min\", \"in_dark\", \"ring_contrast\"],\n", + " \"색(붉은 갈색)\": [\"rust_frac\", \"chroma_mean\", \"chroma_max\", \"in_chroma\", \"sat\"], \"가장자리·테두리\": [\"edge_mean\", \"edge_p95\", \"rim_frac\"], \"조명\": [\"gain\"]}\n", + "full_cols = list(Ftr.columns)\n", + "\n", + "\n", + "def run(cols):\n", + " g = GradientBoostingRegressor(n_estimators=200, max_depth=3, learning_rate=0.06, random_state=0).fit(Ftr[cols], mtr[\"damage_idx\"])\n", + " p = np.clip(g.predict(Fte[cols]), 0, 100)\n", + " return mp(y[big], p[big])\n", + "\n", + "\n", + "full = run(full_cols)\n", + "ab = pd.DataFrame([(k, run([c for c in full_cols if c not in v])) for k, v in groups.items()], columns=[\"제거한 변수군\", \"MAPE(%)\"]).assign(증가=lambda x: x[\"MAPE(%)\"] - full).sort_values(\"증가\", ascending=False)\n", + "print(f\"\\n[6] 변수군 제거(분류 확률 없이 24개 피처만 사용, 전체 MAPE {full:.1f}%)\\n\" + ab.round(2).to_string(index=False))\n", + "print(f\" 해석: 제거 시 오차가 가장 크게 늘어난 변수군은 '{ab.iloc[0]['제거한 변수군']}'(+{ab.iloc[0]['증가']:.1f}%p)이다. 변수군끼리 정보가 겹쳐 단독 제거 효과는 작게 나타날 수 있다.\")\n", + "print(\" 판정은 정비 의사결정 보조이며 최종 등급은 검사관이 확정한다(M7). 사진·장비는 모두 교육용 가상 자료이다.\")\n", + "\n", + "fig, ax = plt.subplots(1, 3, figsize=(15, 4.3))\n", + "ax[0].bar(range(len(sg)), sg[\"평균 IoU\"]); ax[0].set_xticks(range(len(sg))); ax[0].set_xticklabels([\"crack\", \"corrosion\", \"damage\", \"leak\"]); ax[0].set_title(\"Segmentation IoU by defect type\")\n", + "ax[1].scatter(a_true[m], a_raw[m], s=8, label=\"raw\"); ax[1].scatter(a_true[m], a_cal[m], s=8, label=\"calibrated\"); ax[1].plot([0, .2], [0, .2], \"k--\"); ax[1].legend(); ax[1].set_title(\"Defect area fraction: true vs measured\")\n", + "ax[2].scatter(y, idx_te, s=8); ax[2].plot([0, 100], [0, 100], \"k--\"); ax[2].set_title(\"Damage index: true vs predicted\")\n", + "plt.tight_layout(); plt.show()\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 04 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + "[1] 파생 피처 24개(분할 면적·성분 수·모양, 명암, 색, 가장자리, 위치) / 학습 600장, 평가 300장 (1화소 = 1.5mm 가정)\n", + "[2] 분할 성능(평가 300장)\n", + "결함 장수 평균 IoU 평균 Dice 검출률\n", + "균열 48 0.89 0.94 1.0\n", + "부식 77 0.74 0.84 1.0\n", + "파손 57 0.61 0.71 1.0\n", + "누유 48 0.94 0.97 1.0\n", + " 결함 없는 사진 70장 중 분할 결과가 거의 비어 있는(12화소 미만) 사진 100% (무해한 얼룩·스크래치 오탐 억제)\n", + "\n", + "[3] 면적 측정(결함 230장): 분할 원측정 MAPE 20.8% -> 유형별 선형 보정 후 10.2% (분류 정확도 98.7%)\n", + " 면적 환산 예: 평균 결함 면적 565mm2(정답) / 548mm2(보정 측정)\n", + "[4] 깊이 추정(깊이 있는 유형 181장, 누유는 깊이 0으로 제외): MAPE 28.0%, MAE 0.40mm\n", + " 깊이는 사진 한 장으로 직접 관측되지 않아 면적·명암에서 간접 추정하므로 손상도보다 오차가 크다(실제 업무는 깊이 게이지 등 현장 측정으로 확인).\n", + "[5] 손상도 점수(0~100, 점수 10 이상 220장): 면적만 쓰는 기준 MAPE 30.8% -> 제안 모델 MAPE 8.8% (목표 25% 이하: 달성), MAE 3.22, R2 0.946\n", + " 정비 등급 일치율 93.3%, 한 단계 이내 99.7%\n", + "\n", + "[6] 변수군 제거(분류 확률 없이 24개 피처만 사용, 전체 MAPE 10.5%)\n", + " 제거한 변수군 MAPE(%) 증가\n", + " 색(붉은 갈색) 13.46 2.98\n", + " 분할 면적·모양 11.45 0.97\n", + "명암(어두운 비율·대비) 11.29 0.81\n", + " 조명 10.67 0.19\n", + " 가장자리·테두리 10.60 0.12\n", + " 해석: 제거 시 오차가 가장 크게 늘어난 변수군은 '색(붉은 갈색)'(+3.0%p)이다. 변수군끼리 정보가 겹쳐 단독 제거 효과는 작게 나타날 수 있다.\n", + " 판정은 정비 의사결정 보조이며 최종 등급은 검사관이 확정한다(M7). 사진·장비는 모두 교육용 가상 자료이다.\n", + "```\n", + "
\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 05. 정비 등급(현장정비/야전정비/창정비) 자동 분류 및 판정 신뢰도 지수 산출 엔진 개발\n", + "**소요시간**: 약 3.5시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "정비 등급(현장/야전/창정비) 자동 분류 및 신뢰도 지수 엔진을 개발한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **손상도와 결함 유형 확률로 정비 등급(현장정비·야전정비·창정비)을 자동 분류하고 판정 신뢰도 지수를 산출하는 엔진을 개발한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **분할 특징과 유형 확률로 손상도를 회귀하고 경계값으로 정비 등급을 정해 기준 모델(밝기 히스토그램 회귀)과 MAE·RMSE·R2를 비교 수행하고 검증하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `src/models/qlt03_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)를 산출하는 엔진을 만들어 기준 모델 대비 MAE·RMSE 개선과 신뢰도 보정, 저조도·진흙 사진 등 불규칙 상황에서의 안정성을 검증한다.\"\"\"\n", + "import numpy as np\n", + "import pandas as pd\n", + "from sklearn.ensemble import RandomForestClassifier, GradientBoostingRegressor\n", + "from sklearn.linear_model import LinearRegression\n", + "from sklearn.model_selection import cross_val_predict\n", + "from sklearn.metrics import r2_score, confusion_matrix\n", + "\n", + "# ---------------------------------------------------------------------------\n", + "# [교육용가상자료] 장비 외관 사진 생성기 (실제 장비·부대·위치 사진이 아니라 난수로 그린 64x64 합성 이미지)\n", + "# 도장된 금속 표면에 균열(crack)·부식(corrosion)·파손(damage)·누유(leak)를 그리고, 조명 편차와 진흙(mud) 오염을 섞는다.\n", + "# 이미지와 함께 장비 관리 기록(제원 요약)과 정답 결함 마스크·손상도·정비 등급을 만든다.\n", + "# ---------------------------------------------------------------------------\n", + "from scipy import ndimage as ndi\n", + "\n", + "SZ = 64\n", + "CLASSES = [\"none\", \"crack\", \"corrosion\", \"damage\", \"leak\"]\n", + "KO_CLS = {\"none\": \"결함 없음\", \"crack\": \"균열\", \"corrosion\": \"부식\", \"damage\": \"파손\", \"leak\": \"누유\"}\n", + "CLS_W = {\"none\": 0.0, \"crack\": 1.4, \"corrosion\": 1.0, \"damage\": 1.6, \"leak\": 0.6} # 손상도 계산용 결함 유형 가중치\n", + "GRADE_NAME = {1: \"현장정비\", 2: \"야전정비\", 3: \"창정비\"}\n", + "YY, XX = np.mgrid[0:SZ, 0:SZ]\n", + "\n", + "\n", + "def grade_of(idx):\n", + " return np.where(np.asarray(idx) >= 65, 3, np.where(np.asarray(idx) >= 35, 2, 1))\n", + "\n", + "\n", + "def blob(rng, cy, cx, r, rough=0.35):\n", + " \"\"\"불규칙한 원형 영역 마스크.\"\"\"\n", + " noise = ndi.gaussian_filter(rng.normal(size=(SZ, SZ)), 3)\n", + " noise = noise / (np.abs(noise).max() + 1e-9)\n", + " return (np.hypot(YY - cy, XX - cx) + rough * r * 2.2 * noise) < r\n", + "\n", + "\n", + "def draw(rng, cls, sev):\n", + " \"\"\"결함 유형·심각도 sev(0~1)에 맞는 정답 마스크와 색 정보를 만든다.\"\"\"\n", + " m = np.zeros((SZ, SZ), bool)\n", + " if cls == \"crack\":\n", + " y, x, ang = rng.uniform(12, 52), rng.uniform(12, 52), rng.uniform(0, np.pi)\n", + " for _ in range(int(20 + 40 * sev)):\n", + " ang += rng.normal(0, 0.35); y += np.sin(ang) * 1.2; x += np.cos(ang) * 1.2\n", + " if 0 <= int(y) < SZ and 0 <= int(x) < SZ:\n", + " m[int(y), int(x)] = True\n", + " m = ndi.binary_dilation(m, iterations=1 + int(sev > 0.6))\n", + " elif cls == \"corrosion\":\n", + " for _ in range(int(rng.integers(1, 4))):\n", + " m |= blob(rng, rng.uniform(14, 50), rng.uniform(14, 50), 3 + 9 * sev * rng.uniform(0.6, 1.1))\n", + " elif cls == \"damage\":\n", + " m = blob(rng, rng.uniform(20, 44), rng.uniform(20, 44), 6 + 12 * sev, 0.3)\n", + " elif cls == \"leak\":\n", + " x0, y0 = rng.uniform(16, 48), rng.uniform(8, 24)\n", + " w, ln = 2 + 4 * sev, 12 + 26 * sev\n", + " m = (np.abs(XX - x0 - 0.15 * (YY - y0)) < w * (1 - 0.4 * (YY - y0) / ln)) & (YY > y0) & (YY < y0 + ln)\n", + " return m\n", + "\n", + "\n", + "def make_images(n=600, seed=0, mud=0.0, p_none=0.25, dark=False):\n", + " \"\"\"가상 외관 사진 n장(+진흙 오염 mud 비율)과 관리 기록·정답. 반환: meta(DataFrame), imgs(n,64,64,3), masks(n,64,64).\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " imgs, masks, rows = np.zeros((n, SZ, SZ, 3), np.float32), np.zeros((n, SZ, SZ), bool), []\n", + " for i in range(n):\n", + " cls = \"none\" if rng.random() < p_none else str(rng.choice(CLASSES[1:], p=[0.25, 0.3, 0.25, 0.2]))\n", + " sev = 0.0 if cls == \"none\" else float(rng.beta(1.6, 2.0))\n", + " base = np.array([0.42, 0.46, 0.40]) * (1 + 0.5 * ndi.gaussian_filter(rng.normal(size=(SZ, SZ)), 8)[..., None] * 3)\n", + " img = base + rng.normal(0, 0.012, (SZ, SZ, 3))\n", + " m = draw(rng, cls, sev)\n", + " a = ndi.gaussian_filter(m.astype(float), 0.8)[..., None] # 경계를 부드럽게 섞는다\n", + " if cls == \"crack\":\n", + " col = img * rng.uniform(0.2, 0.45)\n", + " elif cls == \"corrosion\":\n", + " col = np.array([0.55, 0.30, 0.12]) * rng.uniform(0.75, 1.15) * (0.85 + 0.3 * rng.random((SZ, SZ, 1)))\n", + " elif cls == \"damage\":\n", + " col = np.ones_like(img) * np.array([0.34, 0.34, 0.36]) * (1.0 - 0.45 * sev) * rng.uniform(0.75, 1.0)\n", + " rim = m & ~ndi.binary_erosion(m, iterations=2)\n", + " a = np.maximum(a, 0.0); img = np.where(rim[..., None], 0.7, img)\n", + " elif cls == \"leak\":\n", + " col = np.ones_like(img) * np.array([0.10, 0.09, 0.08]) * rng.uniform(0.8, 2.6)\n", + " else:\n", + " col = img\n", + " img = img * (1 - a) + col * a\n", + " if rng.random() < 0.35: # 무해한 얼룩(결함 아님): 옅은 갈색 번짐\n", + " st = blob(rng, rng.uniform(5, 58), rng.uniform(5, 58), rng.uniform(6, 14), 0.5)\n", + " al = ndi.gaussian_filter(st.astype(float), 2.5)[..., None] * rng.uniform(0.15, 0.4)\n", + " img = img * (1 - al) + np.array([0.50, 0.40, 0.30]) * al\n", + " if rng.random() < 0.3: # 무해한 긴 스크래치(밝은 선)\n", + " y0, x0, ang = rng.uniform(5, 58), rng.uniform(5, 58), rng.uniform(0, np.pi)\n", + " for t in np.linspace(0, 25, 40):\n", + " yy, xx = int(y0 + t * np.sin(ang)), int(x0 + t * np.cos(ang))\n", + " if 0 <= yy < SZ and 0 <= xx < SZ:\n", + " img[yy, xx] = np.clip(img[yy, xx] * 1.5, 0, 1)\n", + " if mud > 0: # 진흙 오염: 결함 위를 덮을 수 있다(정답 마스크는 그대로)\n", + " mm = np.zeros((SZ, SZ), bool)\n", + " while mm.mean() < mud:\n", + " mm |= blob(rng, rng.uniform(0, SZ), rng.uniform(0, SZ), rng.uniform(5, 11), 0.5)\n", + " img[mm] = np.array([0.38, 0.30, 0.22]) * (0.85 + 0.3 * rng.random((mm.sum(), 1)))\n", + " gain = rng.uniform(0.3, 0.55) if dark else rng.uniform(0.5, 1.15)\n", + " ang = rng.uniform(0, 2 * np.pi)\n", + " light = gain * (1 + 0.3 * (np.cos(ang) * (XX - 32) + np.sin(ang) * (YY - 32)) / 32)\n", + " imgs[i] = np.clip(img * light[..., None] + rng.normal(0, 0.025, (SZ, SZ, 3)), 0, 1)\n", + " masks[i] = m\n", + " area = float(m.mean())\n", + " depth = {\"none\": 0.0, \"crack\": 0.3 + 3.0 * sev, \"corrosion\": 0.1 + 1.5 * sev, \"damage\": 1.0 + 8.0 * sev, \"leak\": 0.0}[cls] * rng.lognormal(0, 0.1)\n", + " idx = float(np.clip(100 * (1 - np.exp(-(CLS_W[cls] * area * 9 + 0.05 * depth))) + rng.normal(0, 1.5), 0, 100))\n", + " rows.append({\"img_id\": f\"IMG{seed:02d}{i:04d}\", \"cls\": cls, \"sev\": sev, \"area_frac\": area, \"depth_mm\": depth, \"damage_idx\": idx,\n", + " \"grade\": int(grade_of(idx)), \"gain\": gain, \"mud\": mud})\n", + " meta = pd.DataFrame(rows)\n", + " # 장비 관리 기록(제원 요약): 사진 판정과 대조할 값\n", + " age = rng.integers(1, 25, n)\n", + " meta[\"day\"] = np.sort(rng.integers(0, 90, n))\n", + " meta[\"equip_type\"] = rng.choice([\"vehicle\", \"generator\", \"comm\", \"trailer\"], n, p=[0.4, 0.2, 0.2, 0.2])\n", + " meta[\"mfg_year\"] = 2026 - age\n", + " meta[\"op_hours\"] = np.clip(rng.normal(180 * age, 400, n) + 8 * meta[\"damage_idx\"], 10, 20000)\n", + " meta[\"env\"] = rng.choice([\"coastal\", \"inland\"], n, p=[0.3, 0.7])\n", + " reg = np.clip(meta[\"grade\"] + rng.choice([-1, 0, 0, 0, 0, 0, 1, 2], n), 1, 3) # 장비 관리 기록상 직전 정비 등급\n", + " meta[\"reg_grade\"] = reg\n", + " meta[\"emergency\"] = rng.random(n) < 0.04 # 긴급 점검 요청(화질 불량이어도 보존)\n", + " return meta, imgs, masks\n", + "# ---- 전처리(조명 보정·표준화)와 결함 후보 분할, 특징 추출 (cv2 대신 numpy·scipy 사용) ----\n", + "def correct(img):\n", + " \"\"\"조명 보정: 밝기에 평면(조명 기울기)을 적합해 나눈다. 어두운 결함 화소는 제외하고 두 번 반복 적합한다. 반환: 보정 밝기(정상 면 약 1), 색 비율.\"\"\"\n", + " g = img.mean(axis=2)\n", + " A = np.c_[np.ones(SZ * SZ), XX.ravel() / SZ, YY.ravel() / SZ]\n", + " keep = np.ones(SZ * SZ, bool)\n", + " for _ in range(3):\n", + " coef, *_ = np.linalg.lstsq(A[keep], g.ravel()[keep], rcond=None)\n", + " plane = (A @ coef).reshape(SZ, SZ)\n", + " keep = (np.abs(g - plane) < 0.08 * np.maximum(plane, 1e-3)).ravel()\n", + " if keep.sum() < 200:\n", + " keep[:] = True\n", + " c = g / np.maximum(plane, 1e-3)\n", + " chroma = img[..., 0] / (img.sum(axis=2) + 1e-6)\n", + " return c, chroma\n", + "\n", + "\n", + "SEGP = {\"dark\": 0.7, \"rust\": 0.5, \"rim\": 1.45} # 분할 임계값(어두움 보정 밝기, 붉은 비율, 밝은 테두리): 재보정 대상\n", + "\n", + "\n", + "def segment(img):\n", + " \"\"\"결함 후보 마스크: 어두운 영역(균열·누유·파손) + 붉은 갈색 영역(부식) + 밝은 테두리(파손).\"\"\"\n", + " c, chroma = correct(img)\n", + " dark = c < SEGP[\"dark\"]\n", + " rust = (chroma > SEGP[\"rust\"]) & (c > 0.45)\n", + " rim = c > SEGP[\"rim\"]\n", + " m = dark | rust | ndi.binary_dilation(rim, iterations=1) & ndi.binary_dilation(dark, iterations=3)\n", + " m = ndi.binary_opening(m, structure=np.ones((2, 2))) if m.sum() > 12 else m\n", + " return m, c, chroma, dark, rust, rim\n", + "\n", + "\n", + "def extract(img):\n", + " \"\"\"분할 결과와 색·모양에서 24개 특징을 만든다.\"\"\"\n", + " m, c, chroma, dark, rust, rim = segment(img)\n", + " lab, n = ndi.label(m)\n", + " area = float(m.sum())\n", + " if n:\n", + " sizes = ndi.sum(m, lab, range(1, n + 1))\n", + " k = int(np.argmax(sizes)) + 1\n", + " sl = ndi.find_objects(lab)[k - 1]\n", + " h, w = sl[0].stop - sl[0].start, sl[1].stop - sl[1].start\n", + " big = float(sizes.max())\n", + " elong, fill = max(h, w) / max(min(h, w), 1), big / (h * w)\n", + " else:\n", + " big, elong, fill = 0.0, 1.0, 0.0\n", + " gy, gx = np.gradient(c)\n", + " mag = np.hypot(gx, gy)\n", + " inside = m.sum() > 0\n", + " return {\"area_px\": area, \"area_frac\": area / SZ ** 2, \"n_comp\": float(n), \"big_px\": big, \"elong\": elong, \"fill\": fill,\n", + " \"dark_frac\": float(dark.mean()), \"rust_frac\": float(rust.mean()), \"rim_frac\": float(rim.mean()),\n", + " \"c_mean\": float(c.mean()), \"c_std\": float(c.std()), \"c_min\": float(c.min()), \"edge_mean\": float(mag.mean()), \"edge_p95\": float(np.quantile(mag, 0.95)),\n", + " \"chroma_mean\": float(chroma.mean()), \"chroma_max\": float(chroma.max()),\n", + " \"in_dark\": float(c[m].mean()) if inside else 1.0, \"in_chroma\": float(chroma[m].mean()) if inside else float(chroma.mean()),\n", + " \"ring_contrast\": float(c[ndi.binary_dilation(m, iterations=3) & ~m].mean() - c[m].mean()) if inside and (~m).any() else 0.0,\n", + " \"thin\": float(1 - fill), \"gain\": float(img.mean()), \"sat\": float((img.max(axis=2) - img.min(axis=2)).mean()),\n", + " \"cy\": float(ndi.center_of_mass(m)[0] / SZ) if inside else 0.5, \"cx\": float(ndi.center_of_mass(m)[1] / SZ) if inside else 0.5}\n", + "\n", + "\n", + "def feature_table(imgs):\n", + " return pd.DataFrame([extract(im) for im in imgs])\n", + "# ---- 판정 파이프라인: 결함 유형 분류 + 손상도 회귀(분류 확률 포함) -> 정비 등급 ----\n", + "def fit_pipeline(meta, F):\n", + " \"\"\"결함 유형 분류기와 손상도 회귀기를 학습한다. 회귀기 입력의 분류 확률은 교차 예측으로 만들어 누설을 막는다.\"\"\"\n", + " clf = RandomForestClassifier(n_estimators=150, random_state=0, n_jobs=1)\n", + " P = cross_val_predict(clf, F, meta[\"cls\"], cv=4, method=\"predict_proba\")\n", + " clf.fit(F, meta[\"cls\"])\n", + " reg = GradientBoostingRegressor(n_estimators=200, max_depth=3, learning_rate=0.06, random_state=0).fit(np.c_[F.values, P], meta[\"damage_idx\"])\n", + " return {\"clf\": clf, \"reg\": reg, \"cols\": list(F.columns)}\n", + "\n", + "\n", + "def predict_pipeline(pl, F):\n", + " X = F[pl[\"cols\"]]\n", + " P = pl[\"clf\"].predict_proba(X)\n", + " idx = np.clip(pl[\"reg\"].predict(np.c_[X.values, P]), 0, 100)\n", + " return pl[\"clf\"].classes_[P.argmax(axis=1)], P, idx, P.max(axis=1)\n", + "# ---- 사진 품질 게이트: 진흙·오염 가림 추정과 판정 신뢰도 지수 ----\n", + "def mud_est(img):\n", + " \"\"\"진흙(갈색, 채도 낮은 붉은 기) 화소 비율을 추정한다. 3x3 평균으로 잡음을 줄인 뒤 색 비율로 판단하며, 실제 가림 비율보다 작게 나오지만 순서는 유지된다.\"\"\"\n", + " sm = ndi.uniform_filter(img, size=(3, 3, 1))\n", + " s = sm.sum(axis=2) + 1e-6\n", + " rr, gg = sm[..., 0] / s, sm[..., 1] / s\n", + " return float(((rr > 0.385) & (rr < 0.46) & (gg < 0.325)).mean())\n", + "\n", + "\n", + "def confidence(P, idx, F, mud):\n", + " \"\"\"판정 신뢰도 지수 0~100 = 50 x 분류 확률 최대값 + 35 x 등급 경계까지 거리(8점에서 포화) + 15 x 사진 품질. 오염 의심(mud 추정 1% 이상)이면 상한 75로 제한한다.\"\"\"\n", + " bd = np.minimum(np.abs(idx - 35), np.abs(idx - 65))\n", + " qual = np.clip(1.0 - np.clip(0.55 - F[\"gain\"].values, 0, None) * 2 - np.clip(0.9 - F[\"c_mean\"].values, 0, None), 0, 1)\n", + " c = 50 * P.max(axis=1) + 35 * np.clip(bd / 8, 0, 1) + 15 * qual\n", + " return np.where(np.asarray(mud) >= 0.01, np.minimum(c, 75.0), c)\n", + "\n", + "mtr, itr, _ = make_images(600, seed=1)\n", + "mte, ite, _ = make_images(300, seed=2)\n", + "Ftr, Fte = feature_table(itr), feature_table(ite)\n", + "pl = fit_pipeline(mtr, Ftr)\n", + "\n", + "\n", + "def hist_feat(imgs):\n", + " \"\"\"기준 모델(M3) 특징: 밝기 히스토그램 16칸 + 채널 평균 비율 3 + 변동계수 1.\"\"\"\n", + " out = []\n", + " for im in imgs:\n", + " g = im.mean(axis=2)\n", + " h, _ = np.histogram(g / max(g.max(), 1e-3), bins=16, range=(0, 1))\n", + " out.append(np.r_[h / g.size, im.mean(axis=(0, 1)) / max(g.mean(), 1e-3), g.std() / max(g.mean(), 1e-3)])\n", + " return np.array(out)\n", + "\n", + "\n", + "base = LinearRegression().fit(hist_feat(itr), mtr[\"damage_idx\"])\n", + "rm = lambda y, p: float(np.sqrt(np.mean((np.asarray(y) - np.asarray(p)) ** 2)))\n", + "\n", + "\n", + "# ---------------- 1) 평가 300장: 기준 모델 vs 제안 엔진 ----------------\n", + "cls, P, idx, _ = predict_pipeline(pl, Fte)\n", + "y = mte[\"damage_idx\"].values\n", + "bp = np.clip(base.predict(hist_feat(ite)), 0, 100)\n", + "tab = pd.DataFrame({\"기준(밝기 히스토그램 회귀)\": [np.mean(np.abs(y - bp)), rm(y, bp), r2_score(y, bp)], \"제안(분할 특징+유형 확률)\": [np.mean(np.abs(y - idx)), rm(y, idx), r2_score(y, idx)]}, index=[\"MAE\", \"RMSE\", \"R2\"])\n", + "print(\"[1] 손상도 점수 예측 성능(평가 300장)\\n\" + tab.round(3).to_string())\n", + "b, e = tab.iloc[:, 0], tab.iloc[:, 1]\n", + "ok = 1 - e[\"MAE\"] / b[\"MAE\"] >= .2 and 1 - e[\"RMSE\"] / b[\"RMSE\"] >= .2\n", + "print(f\" MAE 개선 {1 - e['MAE'] / b['MAE']:.1%}, RMSE 개선 {1 - e['RMSE'] / b['RMSE']:.1%} (목표 20% 이상: {'달성' if ok else '미달'}), R2 {e['R2']:.3f} (목표 0.85 이상: {'달성' if e['R2'] >= .85 else '미달'})\")\n", + "\n", + "# ---------------- 2) 정비 등급 분류 ----------------\n", + "g_true, g_pred = mte[\"grade\"].values, grade_of(idx)\n", + "cm = pd.DataFrame(confusion_matrix(g_true, g_pred, labels=[1, 2, 3]), index=[f\"실제 {GRADE_NAME[i]}\" for i in (1, 2, 3)], columns=[f\"판정 {GRADE_NAME[i]}\" for i in (1, 2, 3)])\n", + "under = int(((g_pred < g_true)).sum())\n", + "print(f\"\\n[2] 정비 등급 판정: 일치율 {(g_true == g_pred).mean():.1%}, 한 단계 이내 {(np.abs(g_true - g_pred) <= 1).mean():.1%}, 과소 판정(실제보다 낮게) {under}장, 과대 판정 {int((g_pred > g_true).sum())}장\\n\" + cm.to_string())\n", + "print(\" 창정비를 야전·현장으로 낮추는 과소 판정이 가장 위험하므로 경계 부근은 신뢰도로 걸러 검사관이 확인한다.\")\n", + "\n", + "# ---------------- 3) 판정 신뢰도 지수 보정 ----------------\n", + "mud_te = np.array([mud_est(im) for im in ite])\n", + "conf = confidence(P, idx, Fte, mud_te)\n", + "bins = pd.cut(conf, [0, 70, 80, 90, 101], labels=[\"70 미만\", \"70~80\", \"80~90\", \"90 이상\"])\n", + "cal = pd.DataFrame({\"bin\": bins, \"ok\": g_true == g_pred, \"near\": np.abs(g_true - g_pred) <= 1}).groupby(\"bin\", observed=True).agg(건수=(\"ok\", \"size\"), 일치율=(\"ok\", \"mean\"), 한단계이내=(\"near\", \"mean\"))\n", + "print(\"\\n[3] 신뢰도 지수 구간별 실제 일치율(높은 신뢰도일수록 높아야 한다)\\n\" + cal.round(3).to_string())\n", + "hi = conf >= 80\n", + "print(f\" 신뢰도 80 이상 자동 통과 {hi.sum()}장({hi.mean():.0%}): 일치율 {(g_true == g_pred)[hi].mean():.1%} / 80 미만 {(~hi).sum()}장은 검사관 확인 대상(일치율 {(g_true == g_pred)[~hi].mean():.1%})\")\n", + "\n", + "# ---------------- 4) 불규칙 상황: 저조도, 진흙 가림, 작은 결함 ----------------\n", + "rows = []\n", + "for name, kw in [(\"정상 조명\", dict(seed=2)), (\"저조도\", dict(seed=7, dark=True)), (\"진흙 20% 가림\", dict(seed=8, mud=0.2)), (\"진흙 35% 가림\", dict(seed=9, mud=0.35))]:\n", + " mm, ii = (mte, ite) if name == \"정상 조명\" else make_images(150, **kw)[:2]\n", + " FF = Fte if name == \"정상 조명\" else feature_table(ii)\n", + " c2, P2, i2, _ = predict_pipeline(pl, FF)\n", + " cf = confidence(P2, i2, FF, [mud_est(im) for im in ii])\n", + " yy = mm[\"damage_idx\"].values\n", + " gg, gp = mm[\"grade\"].values, grade_of(i2)\n", + " rows.append((name, len(mm), np.mean(np.abs(yy - np.clip(base.predict(hist_feat(ii)), 0, 100))), np.mean(np.abs(yy - i2)), (gg == gp).mean(), cf.mean(), (gg == gp)[cf >= 80].mean() if (cf >= 80).any() else np.nan, (cf >= 80).mean()))\n", + "ir = pd.DataFrame(rows, columns=[\"조건\", \"장수\", \"기준 MAE\", \"제안 MAE\", \"등급 일치율\", \"평균 신뢰도\", \"신뢰도 80 이상 일치율\", \"80 이상 비율\"])\n", + "print(\"\\n[4] 불규칙 상황별 성능(- 는 해당 사진 없음)\\n\" + ir.round(2).to_string(index=False, na_rep=\"-\"))\n", + "print(\" 진흙 가림이 커지면 오차와 과소 판정이 늘고 평균 신뢰도가 떨어진다. 이런 사진은 재촬영 요구 또는 검사관 확인으로 보낸다.\")\n", + "print(\" 등급은 정비 소요의 1차 판정이며, 최종 정비 등급은 검사관이 확정한다(M7). 사진은 교육용 가상 자료이다.\")\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 05 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + "[1] 손상도 점수 예측 성능(평가 300장)\n", + " 기준(밝기 히스토그램 회귀) 제안(분할 특징+유형 확률)\n", + "MAE 12.107 3.223\n", + "RMSE 15.045 6.759\n", + "R2 0.732 0.946\n", + " MAE 개선 73.4%, RMSE 개선 55.1% (목표 20% 이상: 달성), R2 0.946 (목표 0.85 이상: 달성)\n", + "\n", + "[2] 정비 등급 판정: 일치율 93.3%, 한 단계 이내 99.7%, 과소 판정(실제보다 낮게) 16장, 과대 판정 4장\n", + " 판정 현장정비 판정 야전정비 판정 창정비\n", + "실제 현장정비 182 2 0\n", + "실제 야전정비 11 47 2\n", + "실제 창정비 1 4 51\n", + " 창정비를 야전·현장으로 낮추는 과소 판정이 가장 위험하므로 경계 부근은 신뢰도로 걸러 검사관이 확인한다.\n", + "\n", + "[3] 신뢰도 지수 구간별 실제 일치율(높은 신뢰도일수록 높아야 한다)\n", + " 건수 일치율 한단계이내\n", + "bin \n", + "70 미만 35 0.543 1.000\n", + "70~80 31 0.871 0.968\n", + "80~90 80 1.000 1.000\n", + "90 이상 154 1.000 1.000\n", + " 신뢰도 80 이상 자동 통과 234장(78%): 일치율 100.0% / 80 미만 66장은 검사관 확인 대상(일치율 69.7%)\n", + "\n", + "[4] 불규칙 상황별 성능(- 는 해당 사진 없음)\n", + " 조건 장수 기준 MAE 제안 MAE 등급 일치율 평균 신뢰도 신뢰도 80 이상 일치율 80 이상 비율\n", + " 정상 조명 300 12.11 3.22 0.93 86.19 1.0 0.78\n", + " 저조도 150 13.17 5.52 0.93 74.60 1.0 0.45\n", + "진흙 20% 가림 150 53.72 23.89 0.45 61.91 - 0.00\n", + "진흙 35% 가림 150 66.17 19.41 0.57 59.80 - 0.00\n", + " 진흙 가림이 커지면 오차와 과소 판정이 늘고 평균 신뢰도가 떨어진다. 이런 사진은 재촬영 요구 또는 검사관 확인으로 보낸다.\n", + " 등급은 정비 소요의 1차 판정이며, 최종 정비 등급은 검사관이 확정한다(M7). 사진은 교육용 가상 자료이다.\n", + "```\n", + "
\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 06. 장비 결함 사진 업로드 및 손상 부위 시각화 판정 인터페이스 구현\n", + "**소요시간**: 약 3.0시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "장비 결함 사진 업로드 및 손상 부위 시각화 인터페이스를 구현한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **장비 결함 사진을 업로드하면 손상 부위를 시각화해 판정과 근거를 보여 주는 인터페이스를 구현한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **업로드 사진 한 장에서 결함 유형, 면적(mm2), 깊이, 손상도, 정비 등급, 신뢰도와 손상 부위 윤곽·박스를 계산해 화면에 겹쳐 표시 수행하고 검증하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `src/explainability/qlt03_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개를 자동 산출해 자연어 설명으로 제공한다(자동 확정 없이 검사 대기로 등록).\"\"\"\n", + "import numpy as np\n", + "import pandas as pd\n", + "import matplotlib.pyplot as plt\n", + "from sklearn.ensemble import RandomForestClassifier, GradientBoostingRegressor\n", + "from sklearn.model_selection import cross_val_predict\n", + "\n", + "# ---------------------------------------------------------------------------\n", + "# [교육용가상자료] 장비 외관 사진 생성기 (실제 장비·부대·위치 사진이 아니라 난수로 그린 64x64 합성 이미지)\n", + "# 도장된 금속 표면에 균열(crack)·부식(corrosion)·파손(damage)·누유(leak)를 그리고, 조명 편차와 진흙(mud) 오염을 섞는다.\n", + "# 이미지와 함께 장비 관리 기록(제원 요약)과 정답 결함 마스크·손상도·정비 등급을 만든다.\n", + "# ---------------------------------------------------------------------------\n", + "from scipy import ndimage as ndi\n", + "\n", + "SZ = 64\n", + "CLASSES = [\"none\", \"crack\", \"corrosion\", \"damage\", \"leak\"]\n", + "KO_CLS = {\"none\": \"결함 없음\", \"crack\": \"균열\", \"corrosion\": \"부식\", \"damage\": \"파손\", \"leak\": \"누유\"}\n", + "CLS_W = {\"none\": 0.0, \"crack\": 1.4, \"corrosion\": 1.0, \"damage\": 1.6, \"leak\": 0.6} # 손상도 계산용 결함 유형 가중치\n", + "GRADE_NAME = {1: \"현장정비\", 2: \"야전정비\", 3: \"창정비\"}\n", + "YY, XX = np.mgrid[0:SZ, 0:SZ]\n", + "\n", + "\n", + "def grade_of(idx):\n", + " return np.where(np.asarray(idx) >= 65, 3, np.where(np.asarray(idx) >= 35, 2, 1))\n", + "\n", + "\n", + "def blob(rng, cy, cx, r, rough=0.35):\n", + " \"\"\"불규칙한 원형 영역 마스크.\"\"\"\n", + " noise = ndi.gaussian_filter(rng.normal(size=(SZ, SZ)), 3)\n", + " noise = noise / (np.abs(noise).max() + 1e-9)\n", + " return (np.hypot(YY - cy, XX - cx) + rough * r * 2.2 * noise) < r\n", + "\n", + "\n", + "def draw(rng, cls, sev):\n", + " \"\"\"결함 유형·심각도 sev(0~1)에 맞는 정답 마스크와 색 정보를 만든다.\"\"\"\n", + " m = np.zeros((SZ, SZ), bool)\n", + " if cls == \"crack\":\n", + " y, x, ang = rng.uniform(12, 52), rng.uniform(12, 52), rng.uniform(0, np.pi)\n", + " for _ in range(int(20 + 40 * sev)):\n", + " ang += rng.normal(0, 0.35); y += np.sin(ang) * 1.2; x += np.cos(ang) * 1.2\n", + " if 0 <= int(y) < SZ and 0 <= int(x) < SZ:\n", + " m[int(y), int(x)] = True\n", + " m = ndi.binary_dilation(m, iterations=1 + int(sev > 0.6))\n", + " elif cls == \"corrosion\":\n", + " for _ in range(int(rng.integers(1, 4))):\n", + " m |= blob(rng, rng.uniform(14, 50), rng.uniform(14, 50), 3 + 9 * sev * rng.uniform(0.6, 1.1))\n", + " elif cls == \"damage\":\n", + " m = blob(rng, rng.uniform(20, 44), rng.uniform(20, 44), 6 + 12 * sev, 0.3)\n", + " elif cls == \"leak\":\n", + " x0, y0 = rng.uniform(16, 48), rng.uniform(8, 24)\n", + " w, ln = 2 + 4 * sev, 12 + 26 * sev\n", + " m = (np.abs(XX - x0 - 0.15 * (YY - y0)) < w * (1 - 0.4 * (YY - y0) / ln)) & (YY > y0) & (YY < y0 + ln)\n", + " return m\n", + "\n", + "\n", + "def make_images(n=600, seed=0, mud=0.0, p_none=0.25, dark=False):\n", + " \"\"\"가상 외관 사진 n장(+진흙 오염 mud 비율)과 관리 기록·정답. 반환: meta(DataFrame), imgs(n,64,64,3), masks(n,64,64).\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " imgs, masks, rows = np.zeros((n, SZ, SZ, 3), np.float32), np.zeros((n, SZ, SZ), bool), []\n", + " for i in range(n):\n", + " cls = \"none\" if rng.random() < p_none else str(rng.choice(CLASSES[1:], p=[0.25, 0.3, 0.25, 0.2]))\n", + " sev = 0.0 if cls == \"none\" else float(rng.beta(1.6, 2.0))\n", + " base = np.array([0.42, 0.46, 0.40]) * (1 + 0.5 * ndi.gaussian_filter(rng.normal(size=(SZ, SZ)), 8)[..., None] * 3)\n", + " img = base + rng.normal(0, 0.012, (SZ, SZ, 3))\n", + " m = draw(rng, cls, sev)\n", + " a = ndi.gaussian_filter(m.astype(float), 0.8)[..., None] # 경계를 부드럽게 섞는다\n", + " if cls == \"crack\":\n", + " col = img * rng.uniform(0.2, 0.45)\n", + " elif cls == \"corrosion\":\n", + " col = np.array([0.55, 0.30, 0.12]) * rng.uniform(0.75, 1.15) * (0.85 + 0.3 * rng.random((SZ, SZ, 1)))\n", + " elif cls == \"damage\":\n", + " col = np.ones_like(img) * np.array([0.34, 0.34, 0.36]) * (1.0 - 0.45 * sev) * rng.uniform(0.75, 1.0)\n", + " rim = m & ~ndi.binary_erosion(m, iterations=2)\n", + " a = np.maximum(a, 0.0); img = np.where(rim[..., None], 0.7, img)\n", + " elif cls == \"leak\":\n", + " col = np.ones_like(img) * np.array([0.10, 0.09, 0.08]) * rng.uniform(0.8, 2.6)\n", + " else:\n", + " col = img\n", + " img = img * (1 - a) + col * a\n", + " if rng.random() < 0.35: # 무해한 얼룩(결함 아님): 옅은 갈색 번짐\n", + " st = blob(rng, rng.uniform(5, 58), rng.uniform(5, 58), rng.uniform(6, 14), 0.5)\n", + " al = ndi.gaussian_filter(st.astype(float), 2.5)[..., None] * rng.uniform(0.15, 0.4)\n", + " img = img * (1 - al) + np.array([0.50, 0.40, 0.30]) * al\n", + " if rng.random() < 0.3: # 무해한 긴 스크래치(밝은 선)\n", + " y0, x0, ang = rng.uniform(5, 58), rng.uniform(5, 58), rng.uniform(0, np.pi)\n", + " for t in np.linspace(0, 25, 40):\n", + " yy, xx = int(y0 + t * np.sin(ang)), int(x0 + t * np.cos(ang))\n", + " if 0 <= yy < SZ and 0 <= xx < SZ:\n", + " img[yy, xx] = np.clip(img[yy, xx] * 1.5, 0, 1)\n", + " if mud > 0: # 진흙 오염: 결함 위를 덮을 수 있다(정답 마스크는 그대로)\n", + " mm = np.zeros((SZ, SZ), bool)\n", + " while mm.mean() < mud:\n", + " mm |= blob(rng, rng.uniform(0, SZ), rng.uniform(0, SZ), rng.uniform(5, 11), 0.5)\n", + " img[mm] = np.array([0.38, 0.30, 0.22]) * (0.85 + 0.3 * rng.random((mm.sum(), 1)))\n", + " gain = rng.uniform(0.3, 0.55) if dark else rng.uniform(0.5, 1.15)\n", + " ang = rng.uniform(0, 2 * np.pi)\n", + " light = gain * (1 + 0.3 * (np.cos(ang) * (XX - 32) + np.sin(ang) * (YY - 32)) / 32)\n", + " imgs[i] = np.clip(img * light[..., None] + rng.normal(0, 0.025, (SZ, SZ, 3)), 0, 1)\n", + " masks[i] = m\n", + " area = float(m.mean())\n", + " depth = {\"none\": 0.0, \"crack\": 0.3 + 3.0 * sev, \"corrosion\": 0.1 + 1.5 * sev, \"damage\": 1.0 + 8.0 * sev, \"leak\": 0.0}[cls] * rng.lognormal(0, 0.1)\n", + " idx = float(np.clip(100 * (1 - np.exp(-(CLS_W[cls] * area * 9 + 0.05 * depth))) + rng.normal(0, 1.5), 0, 100))\n", + " rows.append({\"img_id\": f\"IMG{seed:02d}{i:04d}\", \"cls\": cls, \"sev\": sev, \"area_frac\": area, \"depth_mm\": depth, \"damage_idx\": idx,\n", + " \"grade\": int(grade_of(idx)), \"gain\": gain, \"mud\": mud})\n", + " meta = pd.DataFrame(rows)\n", + " # 장비 관리 기록(제원 요약): 사진 판정과 대조할 값\n", + " age = rng.integers(1, 25, n)\n", + " meta[\"day\"] = np.sort(rng.integers(0, 90, n))\n", + " meta[\"equip_type\"] = rng.choice([\"vehicle\", \"generator\", \"comm\", \"trailer\"], n, p=[0.4, 0.2, 0.2, 0.2])\n", + " meta[\"mfg_year\"] = 2026 - age\n", + " meta[\"op_hours\"] = np.clip(rng.normal(180 * age, 400, n) + 8 * meta[\"damage_idx\"], 10, 20000)\n", + " meta[\"env\"] = rng.choice([\"coastal\", \"inland\"], n, p=[0.3, 0.7])\n", + " reg = np.clip(meta[\"grade\"] + rng.choice([-1, 0, 0, 0, 0, 0, 1, 2], n), 1, 3) # 장비 관리 기록상 직전 정비 등급\n", + " meta[\"reg_grade\"] = reg\n", + " meta[\"emergency\"] = rng.random(n) < 0.04 # 긴급 점검 요청(화질 불량이어도 보존)\n", + " return meta, imgs, masks\n", + "# ---- 전처리(조명 보정·표준화)와 결함 후보 분할, 특징 추출 (cv2 대신 numpy·scipy 사용) ----\n", + "def correct(img):\n", + " \"\"\"조명 보정: 밝기에 평면(조명 기울기)을 적합해 나눈다. 어두운 결함 화소는 제외하고 두 번 반복 적합한다. 반환: 보정 밝기(정상 면 약 1), 색 비율.\"\"\"\n", + " g = img.mean(axis=2)\n", + " A = np.c_[np.ones(SZ * SZ), XX.ravel() / SZ, YY.ravel() / SZ]\n", + " keep = np.ones(SZ * SZ, bool)\n", + " for _ in range(3):\n", + " coef, *_ = np.linalg.lstsq(A[keep], g.ravel()[keep], rcond=None)\n", + " plane = (A @ coef).reshape(SZ, SZ)\n", + " keep = (np.abs(g - plane) < 0.08 * np.maximum(plane, 1e-3)).ravel()\n", + " if keep.sum() < 200:\n", + " keep[:] = True\n", + " c = g / np.maximum(plane, 1e-3)\n", + " chroma = img[..., 0] / (img.sum(axis=2) + 1e-6)\n", + " return c, chroma\n", + "\n", + "\n", + "SEGP = {\"dark\": 0.7, \"rust\": 0.5, \"rim\": 1.45} # 분할 임계값(어두움 보정 밝기, 붉은 비율, 밝은 테두리): 재보정 대상\n", + "\n", + "\n", + "def segment(img):\n", + " \"\"\"결함 후보 마스크: 어두운 영역(균열·누유·파손) + 붉은 갈색 영역(부식) + 밝은 테두리(파손).\"\"\"\n", + " c, chroma = correct(img)\n", + " dark = c < SEGP[\"dark\"]\n", + " rust = (chroma > SEGP[\"rust\"]) & (c > 0.45)\n", + " rim = c > SEGP[\"rim\"]\n", + " m = dark | rust | ndi.binary_dilation(rim, iterations=1) & ndi.binary_dilation(dark, iterations=3)\n", + " m = ndi.binary_opening(m, structure=np.ones((2, 2))) if m.sum() > 12 else m\n", + " return m, c, chroma, dark, rust, rim\n", + "\n", + "\n", + "def extract(img):\n", + " \"\"\"분할 결과와 색·모양에서 24개 특징을 만든다.\"\"\"\n", + " m, c, chroma, dark, rust, rim = segment(img)\n", + " lab, n = ndi.label(m)\n", + " area = float(m.sum())\n", + " if n:\n", + " sizes = ndi.sum(m, lab, range(1, n + 1))\n", + " k = int(np.argmax(sizes)) + 1\n", + " sl = ndi.find_objects(lab)[k - 1]\n", + " h, w = sl[0].stop - sl[0].start, sl[1].stop - sl[1].start\n", + " big = float(sizes.max())\n", + " elong, fill = max(h, w) / max(min(h, w), 1), big / (h * w)\n", + " else:\n", + " big, elong, fill = 0.0, 1.0, 0.0\n", + " gy, gx = np.gradient(c)\n", + " mag = np.hypot(gx, gy)\n", + " inside = m.sum() > 0\n", + " return {\"area_px\": area, \"area_frac\": area / SZ ** 2, \"n_comp\": float(n), \"big_px\": big, \"elong\": elong, \"fill\": fill,\n", + " \"dark_frac\": float(dark.mean()), \"rust_frac\": float(rust.mean()), \"rim_frac\": float(rim.mean()),\n", + " \"c_mean\": float(c.mean()), \"c_std\": float(c.std()), \"c_min\": float(c.min()), \"edge_mean\": float(mag.mean()), \"edge_p95\": float(np.quantile(mag, 0.95)),\n", + " \"chroma_mean\": float(chroma.mean()), \"chroma_max\": float(chroma.max()),\n", + " \"in_dark\": float(c[m].mean()) if inside else 1.0, \"in_chroma\": float(chroma[m].mean()) if inside else float(chroma.mean()),\n", + " \"ring_contrast\": float(c[ndi.binary_dilation(m, iterations=3) & ~m].mean() - c[m].mean()) if inside and (~m).any() else 0.0,\n", + " \"thin\": float(1 - fill), \"gain\": float(img.mean()), \"sat\": float((img.max(axis=2) - img.min(axis=2)).mean()),\n", + " \"cy\": float(ndi.center_of_mass(m)[0] / SZ) if inside else 0.5, \"cx\": float(ndi.center_of_mass(m)[1] / SZ) if inside else 0.5}\n", + "\n", + "\n", + "def feature_table(imgs):\n", + " return pd.DataFrame([extract(im) for im in imgs])\n", + "# ---- 판정 파이프라인: 결함 유형 분류 + 손상도 회귀(분류 확률 포함) -> 정비 등급 ----\n", + "def fit_pipeline(meta, F):\n", + " \"\"\"결함 유형 분류기와 손상도 회귀기를 학습한다. 회귀기 입력의 분류 확률은 교차 예측으로 만들어 누설을 막는다.\"\"\"\n", + " clf = RandomForestClassifier(n_estimators=150, random_state=0, n_jobs=1)\n", + " P = cross_val_predict(clf, F, meta[\"cls\"], cv=4, method=\"predict_proba\")\n", + " clf.fit(F, meta[\"cls\"])\n", + " reg = GradientBoostingRegressor(n_estimators=200, max_depth=3, learning_rate=0.06, random_state=0).fit(np.c_[F.values, P], meta[\"damage_idx\"])\n", + " return {\"clf\": clf, \"reg\": reg, \"cols\": list(F.columns)}\n", + "\n", + "\n", + "def predict_pipeline(pl, F):\n", + " X = F[pl[\"cols\"]]\n", + " P = pl[\"clf\"].predict_proba(X)\n", + " idx = np.clip(pl[\"reg\"].predict(np.c_[X.values, P]), 0, 100)\n", + " return pl[\"clf\"].classes_[P.argmax(axis=1)], P, idx, P.max(axis=1)\n", + "# ---- 사진 품질 게이트: 진흙·오염 가림 추정과 판정 신뢰도 지수 ----\n", + "def mud_est(img):\n", + " \"\"\"진흙(갈색, 채도 낮은 붉은 기) 화소 비율을 추정한다. 3x3 평균으로 잡음을 줄인 뒤 색 비율로 판단하며, 실제 가림 비율보다 작게 나오지만 순서는 유지된다.\"\"\"\n", + " sm = ndi.uniform_filter(img, size=(3, 3, 1))\n", + " s = sm.sum(axis=2) + 1e-6\n", + " rr, gg = sm[..., 0] / s, sm[..., 1] / s\n", + " return float(((rr > 0.385) & (rr < 0.46) & (gg < 0.325)).mean())\n", + "\n", + "\n", + "def confidence(P, idx, F, mud):\n", + " \"\"\"판정 신뢰도 지수 0~100 = 50 x 분류 확률 최대값 + 35 x 등급 경계까지 거리(8점에서 포화) + 15 x 사진 품질. 오염 의심(mud 추정 1% 이상)이면 상한 75로 제한한다.\"\"\"\n", + " bd = np.minimum(np.abs(idx - 35), np.abs(idx - 65))\n", + " qual = np.clip(1.0 - np.clip(0.55 - F[\"gain\"].values, 0, None) * 2 - np.clip(0.9 - F[\"c_mean\"].values, 0, None), 0, 1)\n", + " c = 50 * P.max(axis=1) + 35 * np.clip(bd / 8, 0, 1) + 15 * qual\n", + " return np.where(np.asarray(mud) >= 0.01, np.minimum(c, 75.0), c)\n", + "\n", + "mtr, itr, _ = make_images(600, seed=1)\n", + "Ftr = feature_table(itr)\n", + "pl = fit_pipeline(mtr, Ftr)\n", + "reg_p = GradientBoostingRegressor(n_estimators=150, max_depth=3, learning_rate=0.07, random_state=0)\n", + "Xd = np.c_[Ftr[pl[\"cols\"]].values, cross_val_predict(RandomForestClassifier(n_estimators=80, random_state=0, n_jobs=1), Ftr, mtr[\"cls\"], cv=3, method=\"predict_proba\")]\n", + "dm = mtr[\"depth_mm\"].values > 0.2\n", + "depth_model = reg_p.fit(Xd[dm], mtr[\"depth_mm\"].values[dm])\n", + "MM2 = 1.5 ** 2 # 1화소 = 1.5mm 가정 -> 면적 mm2\n", + "GROUPS = {\"결함 면적·모양\": [\"area_px\", \"area_frac\", \"n_comp\", \"big_px\", \"elong\", \"fill\", \"thin\", \"cy\", \"cx\"], \"명암 대비\": [\"dark_frac\", \"c_mean\", \"c_std\", \"c_min\", \"in_dark\", \"ring_contrast\"],\n", + " \"붉은 갈색 색상\": [\"rust_frac\", \"chroma_mean\", \"chroma_max\", \"in_chroma\", \"sat\"], \"가장자리·테두리\": [\"edge_mean\", \"edge_p95\", \"rim_frac\"], \"조명 밝기\": [\"gain\"]}\n", + "MEAN = Ftr.mean()\n", + "\n", + "\n", + "def contrib(F):\n", + " \"\"\"변수군을 평균값으로 대체했을 때 손상도 점수 변화(양수 = 그 변수군이 손상도를 높인 요인). 사진 여러 장을 한 번에 계산한다.\"\"\"\n", + " full = predict_pipeline(pl, F)[2]\n", + " out = {}\n", + " for g, cols in GROUPS.items():\n", + " x = F.copy(); x[cols] = MEAN[cols].values\n", + " out[g] = full - predict_pipeline(pl, x)[2]\n", + " return pd.DataFrame(out, index=F.index)\n", + "\n", + "\n", + "def top3(F):\n", + " return contrib(F).apply(lambda r: r.reindex(r.abs().sort_values(ascending=False).index)[:3].to_dict(), axis=1)\n", + "\n", + "\n", + "def inspect(img, pid):\n", + " \"\"\"업로드 사진 한 장을 판정한다. 결과는 항상 '검사 대기'이며 자동 확정하지 않는다.\"\"\"\n", + " F = feature_table([img])\n", + " cls, P, idx, _ = predict_pipeline(pl, F)\n", + " mud = mud_est(img)\n", + " conf = float(confidence(P, idx, F, [mud])[0])\n", + " m = segment(img)[0]\n", + " lab, n = ndi.label(m)\n", + " box = None\n", + " if n:\n", + " k = int(np.argmax(ndi.sum(m, lab, range(1, n + 1)))) + 1\n", + " sl = ndi.find_objects(lab)[k - 1]\n", + " box = (sl[1].start, sl[0].start, sl[1].stop - sl[1].start, sl[0].stop - sl[0].start)\n", + " dep = float(np.clip(depth_model.predict(np.c_[F[pl[\"cols\"]].values, P])[0], 0, None)) if cls[0] in (\"crack\", \"corrosion\", \"damage\") else 0.0\n", + " if mud >= 0.03 or F[\"gain\"].iloc[0] < 0.17:\n", + " state = \"재촬영 요구(오염·저조도)\"\n", + " elif conf >= 80:\n", + " state = \"검사 대기(신뢰도 높음, 검사관 확인 후 확정)\"\n", + " else:\n", + " state = \"검사 대기(신뢰도 낮음, 검사관 정밀 확인)\"\n", + " return {\"id\": pid, \"cls\": cls[0], \"area_mm2\": float(F[\"area_px\"].iloc[0] * MM2), \"depth\": dep, \"idx\": float(idx[0]), \"grade\": int(grade_of(idx[0])), \"conf\": conf, \"mud\": mud, \"box\": box, \"mask\": m, \"state\": state, \"F\": F}\n", + "\n", + "\n", + "# ---------------- 1) 업로드 시뮬레이션: 조건이 다른 사진 8장 ----------------\n", + "mt, it, kt = make_images(300, seed=2)\n", + "mud_m, imud, kmud = make_images(30, seed=11, mud=0.2)\n", + "dark_m, idk, kdk = make_images(30, seed=12, dark=True)\n", + "picks = [(\"U01\", it[next(i for i in range(300) if mt.cls[i] == \"crack\")], kt, mt, \"crack\"), (\"U02\", it[next(i for i in range(300) if mt.cls[i] == \"corrosion\")], kt, mt, \"corrosion\"),\n", + " (\"U03\", it[next(i for i in range(300) if mt.cls[i] == \"damage\")], kt, mt, \"damage\"), (\"U04\", it[next(i for i in range(300) if mt.cls[i] == \"leak\")], kt, mt, \"leak\"),\n", + " (\"U05\", it[next(i for i in range(300) if mt.cls[i] == \"none\")], kt, mt, \"none\"), (\"U06\", idk[0], kdk, dark_m, None), (\"U07\", imud[0], kmud, mud_m, None), (\"U08\", imud[1], kmud, mud_m, None)]\n", + "res = []\n", + "for pid, img, _, _, _ in picks:\n", + " res.append(inspect(img, pid))\n", + "true_rows = [mt.loc[next(i for i in range(300) if mt.cls[i] == c)] for c in (\"crack\", \"corrosion\", \"damage\", \"leak\", \"none\")] + [dark_m.loc[0], mud_m.loc[0], mud_m.loc[1]]\n", + "tb = pd.DataFrame([{\"사진\": r[\"id\"], \"판정 유형\": KO_CLS[r[\"cls\"]], \"실제 유형\": KO_CLS[t[\"cls\"]], \"면적(mm2)\": r[\"area_mm2\"], \"깊이(mm)\": r[\"depth\"], \"손상도\": r[\"idx\"], \"실제 손상도\": t[\"damage_idx\"],\n", + " \"등급\": GRADE_NAME[r[\"grade\"]], \"실제 등급\": GRADE_NAME[int(t[\"grade\"])], \"신뢰도\": r[\"conf\"], \"오염\": r[\"mud\"]} for r, t in zip(res, true_rows)])\n", + "print(\"[1] 업로드 판정 결과(8장, 면적은 1화소 1.5mm 가정) [교육용가상자료]\")\n", + "print(tb.round(2).to_string(index=False))\n", + "for r in res:\n", + " print(f\" {r['id']}: {r['state']}\")\n", + "\n", + "# ---------------- 2) 기여도 상위 3개 자동 산출(평가 150장) ----------------\n", + "mte, ite, _ = make_images(300, seed=2)\n", + "Fte = feature_table(ite)\n", + "T3 = top3(Fte)\n", + "ok = int(sum(len(t) == 3 and all(np.isfinite(v) for v in t.values()) for t in T3))\n", + "texts = list(T3)\n", + "print(f\"\\n[2] 평가 {len(ite)}장 중 기여도 상위 3개 요인 자동 산출 {ok}건 ({ok / len(ite):.0%}, 목표 100%)\")\n", + "avg = pd.DataFrame(texts).abs().mean().sort_values(ascending=False)\n", + "print(\" 변수군별 평균 기여 크기(손상도 점):\", \", \".join(f\"{k} {v:.1f}\" for k, v in avg.items()))\n", + "\n", + "\n", + "def explain(r):\n", + " t3 = top3(r[\"F\"]).iloc[0]\n", + " why = {\"결함 면적·모양\": (\"결함 면적·모양이 평균보다 커서\", \"결함 면적·모양이 평균보다 작아\"), \"명암 대비\": (\"표면과 크게 다른 어두운 영역이 있어\", \"어두운 영역이 적어\"),\n", + " \"붉은 갈색 색상\": (\"붉은 갈색 부식 색상이 넓어\", \"붉은 갈색 색상이 적어\"), \"가장자리·테두리\": (\"뚜렷한 테두리가 있어\", \"테두리 단서가 약해\"), \"조명 밝기\": (\"조명 밝기 영향으로\", \"조명 밝기 영향으로\")}\n", + " main = list(t3)[0]\n", + " return f\"{r['id']}: {why[main][t3[main] < 0]} 손상도 {r['idx']:.0f}점({GRADE_NAME[r['grade']]}) 판정. 주요 요인 \" + \", \".join(f\"{g}({v:+.1f}점)\" for g, v in t3.items())\n", + "\n", + "\n", + "print(\"[3] 판정 근거 자연어 설명(결함 있는 3장)\")\n", + "for r in (res[0], res[1], res[2]):\n", + " print(\" -\", explain(r))\n", + "\n", + "# ---------------- 3) 손상 부위 시각화 정확도: 박스와 정답 마스크 겹침 ----------------\n", + "ious = []\n", + "for r, (pid, img, kk, mm, _) in zip(res[:4], picks[:4]):\n", + " i = next(j for j in range(300) if mt.cls[j] == {\"U01\": \"crack\", \"U02\": \"corrosion\", \"U03\": \"damage\", \"U04\": \"leak\"}[pid])\n", + " gt = kk[i]\n", + " ious.append((pid, (r[\"mask\"] & gt).sum() / max((r[\"mask\"] | gt).sum(), 1)))\n", + "print(\"[4] 손상 부위 표시와 정답 마스크의 IoU:\", \", \".join(f\"{p} {v:.2f}\" for p, v in ious))\n", + "print(\" 화면은 자동 확정 버튼 없이 '검사 대기 등록'만 제공하며, 최종 정비 등급은 검사관이 확정한다(M7).\")\n", + "\n", + "fig, ax = plt.subplots(2, 4, figsize=(14, 7))\n", + "for a, (pid, img, _, _, _), r in zip(ax.ravel(), picks, res):\n", + " a.imshow(img)\n", + " a.contour(r[\"mask\"], levels=[0.5], colors=\"yellow\", linewidths=1)\n", + " if r[\"box\"]:\n", + " x, y, w, h = r[\"box\"]; a.add_patch(plt.Rectangle((x - .5, y - .5), w, h, fill=False, ec=\"red\", lw=1.5))\n", + " a.set_title(f\"{pid} {r['cls']} idx {r['idx']:.0f} grade {r['grade']} conf {r['conf']:.0f}\", fontsize=9); a.axis(\"off\")\n", + "plt.suptitle(\"Upload inspection view (synthetic photos): yellow = defect region, red = box\"); plt.tight_layout(); plt.show()\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 06 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + "[1] 업로드 판정 결과(8장, 면적은 1화소 1.5mm 가정) [교육용가상자료]\n", + " 사진 판정 유형 실제 유형 면적(mm2) 깊이(mm) 손상도 실제 손상도 등급 실제 등급 신뢰도 오염\n", + "U01 균열 균열 121.50 1.67 19.39 25.53 현장정비 현장정비 91.72 0.00\n", + "U02 부식 부식 171.00 0.44 24.12 24.49 현장정비 현장정비 93.58 0.00\n", + "U03 파손 파손 857.25 4.91 82.32 85.72 창정비 창정비 96.30 0.00\n", + "U04 누유 누유 265.50 0.00 14.31 14.60 현장정비 현장정비 88.68 0.00\n", + "U05 결함 없음 결함 없음 0.00 0.00 1.17 0.00 현장정비 현장정비 91.61 0.00\n", + "U06 누유 누유 231.75 0.00 15.84 10.12 현장정비 현장정비 86.90 0.00\n", + "U07 파손 결함 없음 103.50 3.21 70.75 1.96 창정비 현장정비 64.69 0.02\n", + "U08 파손 파손 339.75 4.17 57.16 66.53 야전정비 창정비 72.90 0.02\n", + " U01: 검사 대기(신뢰도 높음, 검사관 확인 후 확정)\n", + " U02: 검사 대기(신뢰도 높음, 검사관 확인 후 확정)\n", + " U03: 검사 대기(신뢰도 높음, 검사관 확인 후 확정)\n", + " U04: 검사 대기(신뢰도 높음, 검사관 확인 후 확정)\n", + " U05: 검사 대기(신뢰도 높음, 검사관 확인 후 확정)\n", + " U06: 재촬영 요구(오염·저조도)\n", + " U07: 검사 대기(신뢰도 낮음, 검사관 정밀 확인)\n", + " U08: 검사 대기(신뢰도 낮음, 검사관 정밀 확인)\n", + "\n", + "[2] 평가 300장 중 기여도 상위 3개 요인 자동 산출 300건 (100%, 목표 100%)\n", + " 변수군별 평균 기여 크기(손상도 점): 결함 면적·모양 25.1, 붉은 갈색 색상 6.3, 명암 대비 5.3, 조명 밝기 2.6, 가장자리·테두리 1.0\n", + "[3] 판정 근거 자연어 설명(결함 있는 3장)\n", + " - U01: 결함 면적·모양이 평균보다 작아 손상도 19점(현장정비) 판정. 주요 요인 결함 면적·모양(-18.2점), 명암 대비(-5.0점), 붉은 갈색 색상(-1.0점)\n", + " - U02: 결함 면적·모양이 평균보다 작아 손상도 24점(현장정비) 판정. 주요 요인 결함 면적·모양(-11.6점), 명암 대비(-1.8점), 붉은 갈색 색상(+1.7점)\n", + " - U03: 결함 면적·모양이 평균보다 커서 손상도 82점(창정비) 판정. 주요 요인 결함 면적·모양(+23.9점), 붉은 갈색 색상(+19.6점), 명암 대비(+4.7점)\n", + "[4] 손상 부위 표시와 정답 마스크의 IoU: U01 0.95, U02 0.64, U03 0.80, U04 0.98\n", + " 화면은 자동 확정 버튼 없이 '검사 대기 등록'만 제공하며, 최종 정비 등급은 검사관이 확정한다(M7).\n", + "```\n", + "
\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 07. 외관 판정 결과에 대한 검사관 수동 보정 및 최종 정비 등급 HITL 승인 절차 설계\n", + "**소요시간**: 약 3.5시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "외관 판정 결과에 대한 검사관 수동 보정 및 HITL 승인 절차를 설계한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **AI 외관 판정에 대한 검사관 수동 보정과 최종 정비 등급 승인 HITL 절차를 설계하고 검사 운영 비용을 최적화한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **신뢰도 기준으로 간이 승인과 정밀 판독을 나누는 운영안을 전수 정밀 판독 관행과 비교해 비용(판독·과소·과대 판정 손실)을 계산하고, 신뢰도 기준과 검사관 일일 한도에 따른 민감도를 본다 과정을 진행하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `src/optimization/qlt03_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": [ + "\"\"\"AI 정비 등급 판정을 신뢰도에 따라 간이 승인·정밀 판독으로 나누는 검사 운영안을 비용으로 최적화하고(전수 정밀 판독 관행 대비, 검사관 일일 한도 제약 반영), 모든 판정을 검사관 수동 보정 후 정비감독관(창정비·보정 건은 품질보증관까지)이 직접 승인하는 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 RandomForestClassifier, GradientBoostingRegressor\n", + "from sklearn.model_selection import cross_val_predict\n", + "\n", + "# ---------------------------------------------------------------------------\n", + "# [교육용가상자료] 장비 외관 사진 생성기 (실제 장비·부대·위치 사진이 아니라 난수로 그린 64x64 합성 이미지)\n", + "# 도장된 금속 표면에 균열(crack)·부식(corrosion)·파손(damage)·누유(leak)를 그리고, 조명 편차와 진흙(mud) 오염을 섞는다.\n", + "# 이미지와 함께 장비 관리 기록(제원 요약)과 정답 결함 마스크·손상도·정비 등급을 만든다.\n", + "# ---------------------------------------------------------------------------\n", + "from scipy import ndimage as ndi\n", + "\n", + "SZ = 64\n", + "CLASSES = [\"none\", \"crack\", \"corrosion\", \"damage\", \"leak\"]\n", + "KO_CLS = {\"none\": \"결함 없음\", \"crack\": \"균열\", \"corrosion\": \"부식\", \"damage\": \"파손\", \"leak\": \"누유\"}\n", + "CLS_W = {\"none\": 0.0, \"crack\": 1.4, \"corrosion\": 1.0, \"damage\": 1.6, \"leak\": 0.6} # 손상도 계산용 결함 유형 가중치\n", + "GRADE_NAME = {1: \"현장정비\", 2: \"야전정비\", 3: \"창정비\"}\n", + "YY, XX = np.mgrid[0:SZ, 0:SZ]\n", + "\n", + "\n", + "def grade_of(idx):\n", + " return np.where(np.asarray(idx) >= 65, 3, np.where(np.asarray(idx) >= 35, 2, 1))\n", + "\n", + "\n", + "def blob(rng, cy, cx, r, rough=0.35):\n", + " \"\"\"불규칙한 원형 영역 마스크.\"\"\"\n", + " noise = ndi.gaussian_filter(rng.normal(size=(SZ, SZ)), 3)\n", + " noise = noise / (np.abs(noise).max() + 1e-9)\n", + " return (np.hypot(YY - cy, XX - cx) + rough * r * 2.2 * noise) < r\n", + "\n", + "\n", + "def draw(rng, cls, sev):\n", + " \"\"\"결함 유형·심각도 sev(0~1)에 맞는 정답 마스크와 색 정보를 만든다.\"\"\"\n", + " m = np.zeros((SZ, SZ), bool)\n", + " if cls == \"crack\":\n", + " y, x, ang = rng.uniform(12, 52), rng.uniform(12, 52), rng.uniform(0, np.pi)\n", + " for _ in range(int(20 + 40 * sev)):\n", + " ang += rng.normal(0, 0.35); y += np.sin(ang) * 1.2; x += np.cos(ang) * 1.2\n", + " if 0 <= int(y) < SZ and 0 <= int(x) < SZ:\n", + " m[int(y), int(x)] = True\n", + " m = ndi.binary_dilation(m, iterations=1 + int(sev > 0.6))\n", + " elif cls == \"corrosion\":\n", + " for _ in range(int(rng.integers(1, 4))):\n", + " m |= blob(rng, rng.uniform(14, 50), rng.uniform(14, 50), 3 + 9 * sev * rng.uniform(0.6, 1.1))\n", + " elif cls == \"damage\":\n", + " m = blob(rng, rng.uniform(20, 44), rng.uniform(20, 44), 6 + 12 * sev, 0.3)\n", + " elif cls == \"leak\":\n", + " x0, y0 = rng.uniform(16, 48), rng.uniform(8, 24)\n", + " w, ln = 2 + 4 * sev, 12 + 26 * sev\n", + " m = (np.abs(XX - x0 - 0.15 * (YY - y0)) < w * (1 - 0.4 * (YY - y0) / ln)) & (YY > y0) & (YY < y0 + ln)\n", + " return m\n", + "\n", + "\n", + "def make_images(n=600, seed=0, mud=0.0, p_none=0.25, dark=False):\n", + " \"\"\"가상 외관 사진 n장(+진흙 오염 mud 비율)과 관리 기록·정답. 반환: meta(DataFrame), imgs(n,64,64,3), masks(n,64,64).\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " imgs, masks, rows = np.zeros((n, SZ, SZ, 3), np.float32), np.zeros((n, SZ, SZ), bool), []\n", + " for i in range(n):\n", + " cls = \"none\" if rng.random() < p_none else str(rng.choice(CLASSES[1:], p=[0.25, 0.3, 0.25, 0.2]))\n", + " sev = 0.0 if cls == \"none\" else float(rng.beta(1.6, 2.0))\n", + " base = np.array([0.42, 0.46, 0.40]) * (1 + 0.5 * ndi.gaussian_filter(rng.normal(size=(SZ, SZ)), 8)[..., None] * 3)\n", + " img = base + rng.normal(0, 0.012, (SZ, SZ, 3))\n", + " m = draw(rng, cls, sev)\n", + " a = ndi.gaussian_filter(m.astype(float), 0.8)[..., None] # 경계를 부드럽게 섞는다\n", + " if cls == \"crack\":\n", + " col = img * rng.uniform(0.2, 0.45)\n", + " elif cls == \"corrosion\":\n", + " col = np.array([0.55, 0.30, 0.12]) * rng.uniform(0.75, 1.15) * (0.85 + 0.3 * rng.random((SZ, SZ, 1)))\n", + " elif cls == \"damage\":\n", + " col = np.ones_like(img) * np.array([0.34, 0.34, 0.36]) * (1.0 - 0.45 * sev) * rng.uniform(0.75, 1.0)\n", + " rim = m & ~ndi.binary_erosion(m, iterations=2)\n", + " a = np.maximum(a, 0.0); img = np.where(rim[..., None], 0.7, img)\n", + " elif cls == \"leak\":\n", + " col = np.ones_like(img) * np.array([0.10, 0.09, 0.08]) * rng.uniform(0.8, 2.6)\n", + " else:\n", + " col = img\n", + " img = img * (1 - a) + col * a\n", + " if rng.random() < 0.35: # 무해한 얼룩(결함 아님): 옅은 갈색 번짐\n", + " st = blob(rng, rng.uniform(5, 58), rng.uniform(5, 58), rng.uniform(6, 14), 0.5)\n", + " al = ndi.gaussian_filter(st.astype(float), 2.5)[..., None] * rng.uniform(0.15, 0.4)\n", + " img = img * (1 - al) + np.array([0.50, 0.40, 0.30]) * al\n", + " if rng.random() < 0.3: # 무해한 긴 스크래치(밝은 선)\n", + " y0, x0, ang = rng.uniform(5, 58), rng.uniform(5, 58), rng.uniform(0, np.pi)\n", + " for t in np.linspace(0, 25, 40):\n", + " yy, xx = int(y0 + t * np.sin(ang)), int(x0 + t * np.cos(ang))\n", + " if 0 <= yy < SZ and 0 <= xx < SZ:\n", + " img[yy, xx] = np.clip(img[yy, xx] * 1.5, 0, 1)\n", + " if mud > 0: # 진흙 오염: 결함 위를 덮을 수 있다(정답 마스크는 그대로)\n", + " mm = np.zeros((SZ, SZ), bool)\n", + " while mm.mean() < mud:\n", + " mm |= blob(rng, rng.uniform(0, SZ), rng.uniform(0, SZ), rng.uniform(5, 11), 0.5)\n", + " img[mm] = np.array([0.38, 0.30, 0.22]) * (0.85 + 0.3 * rng.random((mm.sum(), 1)))\n", + " gain = rng.uniform(0.3, 0.55) if dark else rng.uniform(0.5, 1.15)\n", + " ang = rng.uniform(0, 2 * np.pi)\n", + " light = gain * (1 + 0.3 * (np.cos(ang) * (XX - 32) + np.sin(ang) * (YY - 32)) / 32)\n", + " imgs[i] = np.clip(img * light[..., None] + rng.normal(0, 0.025, (SZ, SZ, 3)), 0, 1)\n", + " masks[i] = m\n", + " area = float(m.mean())\n", + " depth = {\"none\": 0.0, \"crack\": 0.3 + 3.0 * sev, \"corrosion\": 0.1 + 1.5 * sev, \"damage\": 1.0 + 8.0 * sev, \"leak\": 0.0}[cls] * rng.lognormal(0, 0.1)\n", + " idx = float(np.clip(100 * (1 - np.exp(-(CLS_W[cls] * area * 9 + 0.05 * depth))) + rng.normal(0, 1.5), 0, 100))\n", + " rows.append({\"img_id\": f\"IMG{seed:02d}{i:04d}\", \"cls\": cls, \"sev\": sev, \"area_frac\": area, \"depth_mm\": depth, \"damage_idx\": idx,\n", + " \"grade\": int(grade_of(idx)), \"gain\": gain, \"mud\": mud})\n", + " meta = pd.DataFrame(rows)\n", + " # 장비 관리 기록(제원 요약): 사진 판정과 대조할 값\n", + " age = rng.integers(1, 25, n)\n", + " meta[\"day\"] = np.sort(rng.integers(0, 90, n))\n", + " meta[\"equip_type\"] = rng.choice([\"vehicle\", \"generator\", \"comm\", \"trailer\"], n, p=[0.4, 0.2, 0.2, 0.2])\n", + " meta[\"mfg_year\"] = 2026 - age\n", + " meta[\"op_hours\"] = np.clip(rng.normal(180 * age, 400, n) + 8 * meta[\"damage_idx\"], 10, 20000)\n", + " meta[\"env\"] = rng.choice([\"coastal\", \"inland\"], n, p=[0.3, 0.7])\n", + " reg = np.clip(meta[\"grade\"] + rng.choice([-1, 0, 0, 0, 0, 0, 1, 2], n), 1, 3) # 장비 관리 기록상 직전 정비 등급\n", + " meta[\"reg_grade\"] = reg\n", + " meta[\"emergency\"] = rng.random(n) < 0.04 # 긴급 점검 요청(화질 불량이어도 보존)\n", + " return meta, imgs, masks\n", + "# ---- 전처리(조명 보정·표준화)와 결함 후보 분할, 특징 추출 (cv2 대신 numpy·scipy 사용) ----\n", + "def correct(img):\n", + " \"\"\"조명 보정: 밝기에 평면(조명 기울기)을 적합해 나눈다. 어두운 결함 화소는 제외하고 두 번 반복 적합한다. 반환: 보정 밝기(정상 면 약 1), 색 비율.\"\"\"\n", + " g = img.mean(axis=2)\n", + " A = np.c_[np.ones(SZ * SZ), XX.ravel() / SZ, YY.ravel() / SZ]\n", + " keep = np.ones(SZ * SZ, bool)\n", + " for _ in range(3):\n", + " coef, *_ = np.linalg.lstsq(A[keep], g.ravel()[keep], rcond=None)\n", + " plane = (A @ coef).reshape(SZ, SZ)\n", + " keep = (np.abs(g - plane) < 0.08 * np.maximum(plane, 1e-3)).ravel()\n", + " if keep.sum() < 200:\n", + " keep[:] = True\n", + " c = g / np.maximum(plane, 1e-3)\n", + " chroma = img[..., 0] / (img.sum(axis=2) + 1e-6)\n", + " return c, chroma\n", + "\n", + "\n", + "SEGP = {\"dark\": 0.7, \"rust\": 0.5, \"rim\": 1.45} # 분할 임계값(어두움 보정 밝기, 붉은 비율, 밝은 테두리): 재보정 대상\n", + "\n", + "\n", + "def segment(img):\n", + " \"\"\"결함 후보 마스크: 어두운 영역(균열·누유·파손) + 붉은 갈색 영역(부식) + 밝은 테두리(파손).\"\"\"\n", + " c, chroma = correct(img)\n", + " dark = c < SEGP[\"dark\"]\n", + " rust = (chroma > SEGP[\"rust\"]) & (c > 0.45)\n", + " rim = c > SEGP[\"rim\"]\n", + " m = dark | rust | ndi.binary_dilation(rim, iterations=1) & ndi.binary_dilation(dark, iterations=3)\n", + " m = ndi.binary_opening(m, structure=np.ones((2, 2))) if m.sum() > 12 else m\n", + " return m, c, chroma, dark, rust, rim\n", + "\n", + "\n", + "def extract(img):\n", + " \"\"\"분할 결과와 색·모양에서 24개 특징을 만든다.\"\"\"\n", + " m, c, chroma, dark, rust, rim = segment(img)\n", + " lab, n = ndi.label(m)\n", + " area = float(m.sum())\n", + " if n:\n", + " sizes = ndi.sum(m, lab, range(1, n + 1))\n", + " k = int(np.argmax(sizes)) + 1\n", + " sl = ndi.find_objects(lab)[k - 1]\n", + " h, w = sl[0].stop - sl[0].start, sl[1].stop - sl[1].start\n", + " big = float(sizes.max())\n", + " elong, fill = max(h, w) / max(min(h, w), 1), big / (h * w)\n", + " else:\n", + " big, elong, fill = 0.0, 1.0, 0.0\n", + " gy, gx = np.gradient(c)\n", + " mag = np.hypot(gx, gy)\n", + " inside = m.sum() > 0\n", + " return {\"area_px\": area, \"area_frac\": area / SZ ** 2, \"n_comp\": float(n), \"big_px\": big, \"elong\": elong, \"fill\": fill,\n", + " \"dark_frac\": float(dark.mean()), \"rust_frac\": float(rust.mean()), \"rim_frac\": float(rim.mean()),\n", + " \"c_mean\": float(c.mean()), \"c_std\": float(c.std()), \"c_min\": float(c.min()), \"edge_mean\": float(mag.mean()), \"edge_p95\": float(np.quantile(mag, 0.95)),\n", + " \"chroma_mean\": float(chroma.mean()), \"chroma_max\": float(chroma.max()),\n", + " \"in_dark\": float(c[m].mean()) if inside else 1.0, \"in_chroma\": float(chroma[m].mean()) if inside else float(chroma.mean()),\n", + " \"ring_contrast\": float(c[ndi.binary_dilation(m, iterations=3) & ~m].mean() - c[m].mean()) if inside and (~m).any() else 0.0,\n", + " \"thin\": float(1 - fill), \"gain\": float(img.mean()), \"sat\": float((img.max(axis=2) - img.min(axis=2)).mean()),\n", + " \"cy\": float(ndi.center_of_mass(m)[0] / SZ) if inside else 0.5, \"cx\": float(ndi.center_of_mass(m)[1] / SZ) if inside else 0.5}\n", + "\n", + "\n", + "def feature_table(imgs):\n", + " return pd.DataFrame([extract(im) for im in imgs])\n", + "# ---- 판정 파이프라인: 결함 유형 분류 + 손상도 회귀(분류 확률 포함) -> 정비 등급 ----\n", + "def fit_pipeline(meta, F):\n", + " \"\"\"결함 유형 분류기와 손상도 회귀기를 학습한다. 회귀기 입력의 분류 확률은 교차 예측으로 만들어 누설을 막는다.\"\"\"\n", + " clf = RandomForestClassifier(n_estimators=150, random_state=0, n_jobs=1)\n", + " P = cross_val_predict(clf, F, meta[\"cls\"], cv=4, method=\"predict_proba\")\n", + " clf.fit(F, meta[\"cls\"])\n", + " reg = GradientBoostingRegressor(n_estimators=200, max_depth=3, learning_rate=0.06, random_state=0).fit(np.c_[F.values, P], meta[\"damage_idx\"])\n", + " return {\"clf\": clf, \"reg\": reg, \"cols\": list(F.columns)}\n", + "\n", + "\n", + "def predict_pipeline(pl, F):\n", + " X = F[pl[\"cols\"]]\n", + " P = pl[\"clf\"].predict_proba(X)\n", + " idx = np.clip(pl[\"reg\"].predict(np.c_[X.values, P]), 0, 100)\n", + " return pl[\"clf\"].classes_[P.argmax(axis=1)], P, idx, P.max(axis=1)\n", + "# ---- 사진 품질 게이트: 진흙·오염 가림 추정과 판정 신뢰도 지수 ----\n", + "def mud_est(img):\n", + " \"\"\"진흙(갈색, 채도 낮은 붉은 기) 화소 비율을 추정한다. 3x3 평균으로 잡음을 줄인 뒤 색 비율로 판단하며, 실제 가림 비율보다 작게 나오지만 순서는 유지된다.\"\"\"\n", + " sm = ndi.uniform_filter(img, size=(3, 3, 1))\n", + " s = sm.sum(axis=2) + 1e-6\n", + " rr, gg = sm[..., 0] / s, sm[..., 1] / s\n", + " return float(((rr > 0.385) & (rr < 0.46) & (gg < 0.325)).mean())\n", + "\n", + "\n", + "def confidence(P, idx, F, mud):\n", + " \"\"\"판정 신뢰도 지수 0~100 = 50 x 분류 확률 최대값 + 35 x 등급 경계까지 거리(8점에서 포화) + 15 x 사진 품질. 오염 의심(mud 추정 1% 이상)이면 상한 75로 제한한다.\"\"\"\n", + " bd = np.minimum(np.abs(idx - 35), np.abs(idx - 65))\n", + " qual = np.clip(1.0 - np.clip(0.55 - F[\"gain\"].values, 0, None) * 2 - np.clip(0.9 - F[\"c_mean\"].values, 0, None), 0, 1)\n", + " c = 50 * P.max(axis=1) + 35 * np.clip(bd / 8, 0, 1) + 15 * qual\n", + " return np.where(np.asarray(mud) >= 0.01, np.minimum(c, 75.0), c)\n", + "\n", + "mtr, itr, _ = make_images(600, seed=1)\n", + "Ftr = feature_table(itr)\n", + "pl = fit_pipeline(mtr, Ftr)\n", + "\n", + "\n", + "def judge(seed, n, **kw):\n", + " \"\"\"사진 n장을 만들어 AI 판정(등급·신뢰도)과 정답 등급을 돌려준다.\"\"\"\n", + " m, im, _ = make_images(n, seed=seed, **kw)\n", + " F = feature_table(im)\n", + " cls, P, idx, _ = predict_pipeline(pl, F)\n", + " conf = confidence(P, idx, F, [mud_est(x) for x in im])\n", + " return pd.DataFrame({\"true\": m[\"grade\"].values, \"ai\": grade_of(idx), \"conf\": conf, \"idx\": idx, \"cls\": cls, \"id\": m[\"img_id\"].values})\n", + "\n", + "\n", + "SETS = {\"평상(정상 조명)\": judge(2, 300), \"저조도 사진 많음\": judge(7, 150, dark=True), \"진흙 가림 20%\": judge(8, 150, mud=0.2)}\n", + "\n", + "# ---------------- 1) 비용 모델(교육용 가정, 만원/장) ----------------\n", + "C_FULL, C_QUICK, C_AI = 0.9, 0.25, 0.05 # 정밀 판독, 간이 승인(AI 판정 열람 후 승인), AI 판정 전산 비용\n", + "P_HUMAN, P_CATCH = 0.08, 0.5 # 검사관 정밀 판독 오류율(+-1 등급), 간이 승인 시 AI 오판을 걸러내는 확률\n", + "LOSS_UNDER, LOSS_OVER = 20.0, 6.0 # 과소 판정(재입고·고장 위험) / 과대 판정(과다 정비) 1등급당 손실\n", + "CAP = 120 # 검사관 일일 정밀 판독 한도(장)\n", + "\n", + "\n", + "def loss(true, grade):\n", + " d = grade - true\n", + " return np.where(d < 0, -d * LOSS_UNDER, d * LOSS_OVER)\n", + "\n", + "\n", + "def operate(df, T, cap=CAP, seed=0):\n", + " \"\"\"신뢰도 T 이상은 간이 승인, 미만은 정밀 판독(검사관 보정). 정밀 판독 한도를 넘으면 신뢰도가 낮은 순서대로만 정밀 판독한다.\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " n = len(df)\n", + " full = (df[\"conf\"] < T).values\n", + " if full.sum() > cap:\n", + " order = np.argsort(df[\"conf\"].values)\n", + " full = np.zeros(n, bool); full[order[:cap]] = True\n", + " final = df[\"ai\"].values.copy()\n", + " err = rng.random(n) < P_HUMAN\n", + " human = np.clip(df[\"true\"].values + np.where(err, rng.choice([-1, 1], n), 0), 1, 3)\n", + " final[full] = human[full]\n", + " wrong_ai = (df[\"ai\"].values != df[\"true\"].values) & ~full\n", + " caught = wrong_ai & (rng.random(n) < P_CATCH)\n", + " final[caught] = df[\"true\"].values[caught]\n", + " cost = full.sum() * C_FULL + (~full).sum() * C_QUICK + n * C_AI + loss(df[\"true\"].values, final).sum()\n", + " return cost, full.mean(), (final == df[\"true\"].values).mean(), int(((final < df[\"true\"].values)).sum())\n", + "\n", + "\n", + "def practice(df, seed=0):\n", + " \"\"\"관행: 모든 사진을 검사관이 정밀 판독(한도 없음, 검사관 오류율 P_HUMAN).\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " err = rng.random(len(df)) < P_HUMAN\n", + " final = np.clip(df[\"true\"].values + np.where(err, rng.choice([-1, 1], len(df)), 0), 1, 3)\n", + " return len(df) * C_FULL + loss(df[\"true\"].values, final).sum(), (final == df[\"true\"].values).mean()\n", + "\n", + "\n", + "rows = []\n", + "for name, df in SETS.items():\n", + " cp, ap = practice(df)\n", + " co, fr, ao, un = operate(df, 80)\n", + " ai_only = len(df) * C_AI + loss(df[\"true\"].values, df[\"ai\"].values).sum()\n", + " rows.append((name, len(df), cp, ai_only, co, 1 - co / cp, fr, ap, ao, un))\n", + "res = pd.DataFrame(rows, columns=[\"시나리오\", \"장수\", \"관행 전수 판독 비용\", \"AI 단독 비용(금지안)\", \"HITL 비용(T=80)\", \"절감률\", \"정밀 판독 비율\", \"관행 일치율\", \"HITL 일치율\", \"HITL 과소 판정\"])\n", + "print(\"[1] 시나리오별 검사 운영 비용(가정 단가, 만원)\")\n", + "print(res.assign(절감률=res[\"절감률\"].map(\"{:.1%}\".format), **{\"정밀 판독 비율\": res[\"정밀 판독 비율\"].map(\"{:.0%}\".format), \"관행 일치율\": res[\"관행 일치율\"].map(\"{:.1%}\".format), \"HITL 일치율\": res[\"HITL 일치율\"].map(\"{:.1%}\".format)}).round(1).to_string(index=False))\n", + "r0 = res.iloc[0]\n", + "passed = [f\"{r['시나리오']} {r['절감률']:.1%}\" for _, r in res.iterrows() if r[\"절감률\"] >= .15]\n", + "print(f\" 평상 절감 {r0['절감률']:.1%} (목표 15% 이상: {'달성' if r0['절감률'] >= .15 else '미달'}), 15% 이상 달성 시나리오: {', '.join(passed)}\")\n", + "print(f\" AI 단독 비용은 평상 {r0['AI 단독 비용(금지안)']:.0f}, 진흙 {res.iloc[2]['AI 단독 비용(금지안)']:.0f}로 오염 사진에서 과소 판정 손실이 폭증하므로 허용하지 않는다(모든 판정은 사람이 승인). 진흙 20%는 정밀 판독이 늘어 절감이 나지 않는다(미달).\")\n", + "\n", + "# ---------------- 2) 신뢰도 기준 T 탐색과 검사관 한도 ----------------\n", + "sw = []\n", + "for T in (60, 70, 80, 90, 95):\n", + " c, fr, a, un = operate(SETS[\"평상(정상 조명)\"], T)\n", + " sw.append((T, c, fr, a, un))\n", + "sw = pd.DataFrame(sw, columns=[\"기준 T\", \"비용\", \"정밀 판독 비율\", \"일치율\", \"과소 판정\"])\n", + "print(\"\\n[2] 신뢰도 기준 T 민감도(평상 300장)\\n\" + sw.round(3).to_string(index=False))\n", + "best = sw.loc[sw[\"비용\"].idxmin()]\n", + "print(f\" 비용 최소 기준 T={int(best['기준 T'])}. 기준을 너무 높이면 정밀 판독이 늘어 비용이 오르고, 너무 낮으면 AI 오판이 통과한다.\")\n", + "cp, _ = practice(SETS[\"진흙 가림 20%\"])\n", + "for cap in (150, 60, 20):\n", + " c, fr, a, un = operate(SETS[\"진흙 가림 20%\"], 80, cap=cap)\n", + " print(f\" 진흙 20% 시나리오, 검사관 한도 {cap}장/일: 비용 {c:.0f} (관행 대비 절감 {1 - c / cp:+.1%}), 정밀 판독 {fr:.0%}, 일치율 {a:.1%}, 과소 판정 {un}장\")\n", + "print(\" 검사관 한도가 줄면 신뢰도 낮은 사진까지 간이 승인으로 넘어가 AI 오판이 걸러지지 않아 손실이 커진다(비용 +). 오염 사진은 재촬영·사전 세척 안내로 줄여야 한다.\")\n", + "\n", + "# ---------------- 3) HITL 결재 워크플로우 ----------------\n", + "USERS = {\"ai_bot\": \"AI\", \"ins_kim\": \"INS\", \"ins_lee\": \"INS\", \"sup_park\": \"SUP\", \"qa_choi\": \"QA\", \"aud_han\": \"AUD\"}\n", + "PERM = {\"AI\": {\"submit\"}, \"INS\": {\"review\", \"correct\", \"reject\"}, \"SUP\": {\"approve\", \"reject\"}, \"QA\": {\"approve\", \"reject\"}, \"AUD\": {\"read\"}}\n", + "\n", + "\n", + "class Flow:\n", + " def __init__(self):\n", + " self.docs, self.log, self.blocked = {}, [], []\n", + "\n", + " def _audit(self, user, action, doc, detail):\n", + " prev = self.log[-1][\"hash\"] if self.log else \"GENESIS\"\n", + " rec = {\"seq\": len(self.log) + 1, \"user\": user, \"action\": action, \"doc\": doc, \"detail\": detail, \"prev\": prev}\n", + " rec[\"hash\"] = hashlib.sha256(json.dumps(rec, ensure_ascii=False, sort_keys=True).encode()).hexdigest()[:12]\n", + " self.log.append(rec)\n", + "\n", + " def verify(self):\n", + " prev = \"GENESIS\"\n", + " for r in self.log:\n", + " body = {k: v for k, v in r.items() if k != \"hash\"}\n", + " if r[\"prev\"] != prev or hashlib.sha256(json.dumps(body, ensure_ascii=False, sort_keys=True).encode()).hexdigest()[:12] != r[\"hash\"]:\n", + " return False, r[\"seq\"]\n", + " prev = r[\"hash\"]\n", + " return True, None\n", + "\n", + " def deny(self, user, act, doc, why):\n", + " self.blocked.append((user, act, doc, why)); self._audit(user, \"DENIED:\" + act, doc, why); return False\n", + "\n", + " def submit(self, user, pid, grade, conf, parent=None):\n", + " if \"submit\" not in PERM.get(USERS.get(user), set()):\n", + " return self.deny(user, \"submit\", \"-\", \"AI 판정 등록 권한 없음\")\n", + " did = f\"INS{len(self.docs) + 1:02d}\"\n", + " self.docs[did] = {\"photo\": pid, \"ai\": grade, \"grade\": grade, \"conf\": conf, \"status\": \"검사대기\", \"line\": [\"INS\", \"SUP\"], \"step\": 0, \"done\": [], \"parent\": parent}\n", + " self._audit(user, \"submit\", did, f\"{pid} AI 등급 {GRADE_NAME[grade]} 신뢰도 {conf:.0f}\")\n", + " return did\n", + "\n", + " def decide(self, user, did, act, reason=\"\", new_grade=None):\n", + " role, d = USERS.get(user), self.docs.get(did)\n", + " if role is None: return self.deny(user, act, did, \"미등록 사용자\")\n", + " if d is None or d[\"status\"] in (\"확정\", \"반려\"): return self.deny(user, act, did, \"결재 대상 아님(처리 완료)\")\n", + " if act not in PERM[role]: return self.deny(user, act, did, f\"{role} 역할은 {act} 불가\")\n", + " if d[\"line\"][d[\"step\"]] != role: return self.deny(user, act, did, \"결재 순서 위반\")\n", + " if user in d[\"done\"]: return self.deny(user, act, did, \"기결재자 재결재 금지\")\n", + " if len(reason.strip()) < 10: return self.deny(user, act, did, \"사유 10자 이상 필수\")\n", + " if act == \"reject\":\n", + " d[\"status\"] = \"반려\"\n", + " else:\n", + " if act == \"correct\":\n", + " d[\"grade\"] = new_grade\n", + " if new_grade != d[\"ai\"] and \"QA\" not in d[\"line\"]:\n", + " d[\"line\"] = d[\"line\"] + [\"QA\"] # 검사관이 AI 판정을 고치면 품질보증관까지 결재\n", + " if d[\"grade\"] == 3 and \"QA\" not in d[\"line\"]:\n", + " d[\"line\"] = d[\"line\"] + [\"QA\"] # 창정비 판정은 품질보증관까지 결재\n", + " d[\"done\"].append(user); d[\"step\"] += 1\n", + " d[\"status\"] = \"확정\" if d[\"step\"] == len(d[\"line\"]) else (\"감독대기\" if d[\"step\"] >= 1 else \"검사대기\")\n", + " self._audit(user, act, did, reason + (f\" -> {GRADE_NAME[new_grade]}\" if new_grade else \"\")); return True\n", + "\n", + " def edit(self, user, did):\n", + " if self.docs[did][\"status\"] == \"확정\": return self.deny(user, \"edit\", did, \"확정 후 등급 변경 금지(재검사 등록)\")\n", + " return True\n", + "\n", + "\n", + "base = SETS[\"평상(정상 조명)\"]\n", + "mix = pd.concat([base[base.conf >= 80].head(3), base[base.conf < 80].head(4), base[base.ai == 3].head(2)]).drop_duplicates(\"id\").head(8).reset_index(drop=True)\n", + "wf = Flow()\n", + "ids = [wf.submit(\"ai_bot\", r.id, int(r.ai), r.conf) for r in mix.itertuples()]\n", + "print(f\"\\n[3] AI 판정 {len(ids)}건 전부 '검사대기' 등록, 자동 확정 {sum(wf.docs[i]['status'] == '확정' for i in ids)}건 (목표 0건)\")\n", + "low = [i for i, r in zip(ids, mix.itertuples()) if r.conf < 80][0]\n", + "first = ids[0]\n", + "wf.decide(\"ins_kim\", first, \"review\", \"AI 판정 열람, 사진과 일치 확인함\")\n", + "wf.decide(\"sup_park\", first, \"approve\", \"검사관 의견 확인, 등급 확정\")\n", + "wf.decide(\"ins_lee\", low, \"correct\", \"경계 부근 결함, 박스 재측정 후 등급 조정\", new_grade=min(3, wf.docs[low][\"ai\"] + 1))\n", + "wf.decide(\"sup_park\", low, \"approve\", \"보정 사유 타당, 품질보증 상신\")\n", + "wf.decide(\"qa_choi\", low, \"approve\", \"보정 근거 사진 대조 완료\")\n", + "rj = ids[2]\n", + "wf.decide(\"ins_kim\", rj, \"reject\", \"오염 사진으로 판독 불가, 재촬영 요구\")\n", + "new = wf.submit(\"ai_bot\", mix.id[2], int(mix.ai[2]), float(mix.conf[2]), parent=rj)\n", + "tgt = ids[-1]\n", + "attacks = [(\"ai_bot\", tgt, \"review\", \"AI 계정이 직접 검토 처리함\"), (\"sup_park\", tgt, \"approve\", \"검사관 검토 없이 감독관이 승인함\"), (\"aud_han\", tgt, \"approve\", \"감사 계정으로 승인 시도함\"),\n", + " (\"ghost\", tgt, \"review\", \"외부 계정으로 검토 시도함\"), (\"ins_kim\", tgt, \"review\", \"\"), (\"ins_kim\", tgt, \"correct\", \"짧음\"), (\"qa_choi\", tgt, \"approve\", \"품질보증관이 순서를 건너뛰어 승인\"),\n", + " (\"ins_kim\", first, \"review\", \"이미 확정된 건을 다시 검토함\"), (\"sup_park\", low, \"reject\", \"확정된 건을 뒤집어 반려함\")]\n", + "for u, doc, a, rs in attacks:\n", + " wf.decide(u, doc, a, rs, new_grade=2 if a == \"correct\" else None)\n", + "wf.edit(\"ins_kim\", first)\n", + "print(f\" 권한 우회 시도 10건 중 차단 {len(wf.blocked)}건 (차단율 {len(wf.blocked) / 10:.0%}, 목표 100%)\")\n", + "for u, a, doc, why in wf.blocked[:4]:\n", + " print(f\" 차단 {u:<9} {a:<8} {doc}: {why}\")\n", + "ok, _ = wf.verify()\n", + "snap = json.loads(json.dumps(wf.log))\n", + "wf.log[3][\"detail\"] = \"사후에 고쳐 쓴 사유\"\n", + "ok2, at = wf.verify()\n", + "wf.log = snap\n", + "print(f\" 감사 로그 {len(wf.log)}건 해시 체인 무결성 {'100%' if ok else '훼손'}; 4번 기록 사후 수정 시 {'통과' if ok2 else f'위변조 검출({at}번)'}\")\n", + "print(f\" {low}: AI {GRADE_NAME[wf.docs[low]['ai']]} -> 검사관 보정 {GRADE_NAME[wf.docs[low]['grade']]}(보정 시 품질보증관까지 3단계 결재, 상태 {wf.docs[low]['status']}); {rj}(반려) -> {new}(재등록, parent 연결)\")\n", + "print(\" 문서 상태:\", pd.Series({k: v[\"status\"] for k, v in wf.docs.items()}).value_counts().to_dict(), \"| 최종 정비 등급은 검사관·정비감독관의 결재로 확정되며 AI는 확정 권한이 없다.\")\n", + "\n", + "fig, ax = plt.subplots(1, 2, figsize=(12, 4))\n", + "x = np.arange(len(res)); ax[0].bar(x - .2, res[\"관행 전수 판독 비용\"], .4, label=\"full inspection\"); ax[0].bar(x + .2, res[\"HITL 비용(T=80)\"], .4, label=\"HITL (T=80)\")\n", + "ax[0].set_xticks(x); ax[0].set_xticklabels([f\"S{i + 1}\" for i in x]); ax[0].legend(); ax[0].set_title(\"Total cost by scenario (10k KRW)\")\n", + "ax[1].plot(sw[\"기준 T\"], sw[\"비용\"], \"o-\"); ax[1].set_title(\"Cost vs confidence threshold T\"); ax[1].set_xlabel(\"T\")\n", + "plt.tight_layout(); plt.show()\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 07 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + "[1] 시나리오별 검사 운영 비용(가정 단가, 만원)\n", + " 시나리오 장수 관행 전수 판독 비용 AI 단독 비용(금지안) HITL 비용(T=80) 절감률 정밀 판독 비율 관행 일치율 HITL 일치율 HITL 과소 판정\n", + "평상(정상 조명) 300 442.0 379.0 178.9 59.5% 22% 94.3% 99.0% 2\n", + "저조도 사진 많음 150 263.0 219.5 189.0 28.2% 55% 92.0% 94.7% 3\n", + "진흙 가림 20% 150 203.0 771.5 219.0 -7.9% 80% 94.0% 89.3% 0\n", + " 평상 절감 59.5% (목표 15% 이상: 달성), 15% 이상 달성 시나리오: 평상(정상 조명) 59.5%, 저조도 사진 많음 28.2%\n", + " AI 단독 비용은 평상 379, 진흙 772로 오염 사진에서 과소 판정 손실이 폭증하므로 허용하지 않는다(모든 판정은 사람이 승인). 진흙 20%는 정밀 판독이 늘어 절감이 나지 않는다(미달).\n", + "\n", + "[2] 신뢰도 기준 T 민감도(평상 300장)\n", + " 기준 T 비용 정밀 판독 비율 일치율 과소 판정\n", + " 60 267.20 0.027 0.967 8\n", + " 70 158.75 0.117 0.990 2\n", + " 80 178.90 0.220 0.990 2\n", + " 90 238.00 0.400 0.977 2\n", + " 95 238.00 0.400 0.977 2\n", + " 비용 최소 기준 T=70. 기준을 너무 높이면 정밀 판독이 늘어 비용이 오르고, 너무 낮으면 AI 오판이 통과한다.\n", + " 진흙 20% 시나리오, 검사관 한도 150장/일: 비용 210 (관행 대비 절감 -3.7%), 정밀 판독 100%, 일치율 94.0%, 과소 판정 1장\n", + " 진흙 20% 시나리오, 검사관 한도 60장/일: 비용 300 (관행 대비 절감 -47.8%), 정밀 판독 40%, 일치율 81.3%, 과소 판정 3장\n", + " 진흙 20% 시나리오, 검사관 한도 20장/일: 비용 396 (관행 대비 절감 -95.1%), 정밀 판독 13%, 일치율 75.3%, 과소 판정 7장\n", + " 검사관 한도가 줄면 신뢰도 낮은 사진까지 간이 승인으로 넘어가 AI 오판이 걸러지지 않아 손실이 커진다(비용 +). 오염 사진은 재촬영·사전 세척 안내로 줄여야 한다.\n", + "\n", + "[3] AI 판정 8건 전부 '검사대기' 등록, 자동 확정 0건 (목표 0건)\n", + " 권한 우회 시도 10건 중 차단 10건 (차단율 100%, 목표 100%)\n", + " 차단 ai_bot review INS08: AI 역할은 review 불가\n", + " 차단 sup_park approve INS08: 결재 순서 위반\n", + " 차단 aud_han approve INS08: AUD 역할은 approve 불가\n", + " 차단 ghost review INS08: 미등록 사용자\n", + " 감사 로그 25건 해시 체인 무결성 100%; 4번 기록 사후 수정 시 위변조 검출(4번)\n", + " INS04: AI 야전정비 -> 검사관 보정 창정비(보정 시 품질보증관까지 3단계 결재, 상태 확정); INS03(반려) -> INS09(재등록, parent 연결)\n", + " 문서 상태: {'검사대기': 6, '확정': 2, '반려': 1} | 최종 정비 등급은 검사관·정비감독관의 결재로 확정되며 AI는 확정 권한이 없다.\n", + "```\n", + "
\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 08. 오염물/진흙 혼입 사진 대량 판정 스트레스 테스트 및 한계치 도출\n", + "**소요시간**: 약 3.0시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "오염물 혼입 사진 대량 판정 스트레스 테스트를 수행한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **오염물·진흙이 섞인 사진을 대량 판정하는 스트레스 테스트를 수행하고 판정 한계치를 도출한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **진흙 혼입 비율을 0~50%로 올린 사진 묶음과 흐림·센서 잡음·저조도+진흙 결합 조건에서 등급 일치율, 재촬영 요구율, 자동 처리 비율, 경보 정확도를 측정하고 품질 게이트의 효과를 비교 수행하고 검증하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `src/dashboard/qlt03_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": [ + "\"\"\"진흙·오염물 혼입 비율을 0~50%로 올린 사진 수백 장과 흐림·센서 잡음·저조도 결합 사진을 일괄 판정하는 스트레스 테스트로 오염 사진 판정 한계치(자동 처리 가능 가림 비율)와 경보 정확도, 대량 처리 시간·대시보드 로딩 시간을 측정하고 품질 게이트(재촬영·검사관 확인)의 효과를 확인한다.\"\"\"\n", + "import time\n", + "import numpy as np\n", + "import pandas as pd\n", + "import matplotlib.pyplot as plt\n", + "from sklearn.ensemble import RandomForestClassifier, GradientBoostingRegressor\n", + "from sklearn.model_selection import cross_val_predict\n", + "\n", + "# ---------------------------------------------------------------------------\n", + "# [교육용가상자료] 장비 외관 사진 생성기 (실제 장비·부대·위치 사진이 아니라 난수로 그린 64x64 합성 이미지)\n", + "# 도장된 금속 표면에 균열(crack)·부식(corrosion)·파손(damage)·누유(leak)를 그리고, 조명 편차와 진흙(mud) 오염을 섞는다.\n", + "# 이미지와 함께 장비 관리 기록(제원 요약)과 정답 결함 마스크·손상도·정비 등급을 만든다.\n", + "# ---------------------------------------------------------------------------\n", + "from scipy import ndimage as ndi\n", + "\n", + "SZ = 64\n", + "CLASSES = [\"none\", \"crack\", \"corrosion\", \"damage\", \"leak\"]\n", + "KO_CLS = {\"none\": \"결함 없음\", \"crack\": \"균열\", \"corrosion\": \"부식\", \"damage\": \"파손\", \"leak\": \"누유\"}\n", + "CLS_W = {\"none\": 0.0, \"crack\": 1.4, \"corrosion\": 1.0, \"damage\": 1.6, \"leak\": 0.6} # 손상도 계산용 결함 유형 가중치\n", + "GRADE_NAME = {1: \"현장정비\", 2: \"야전정비\", 3: \"창정비\"}\n", + "YY, XX = np.mgrid[0:SZ, 0:SZ]\n", + "\n", + "\n", + "def grade_of(idx):\n", + " return np.where(np.asarray(idx) >= 65, 3, np.where(np.asarray(idx) >= 35, 2, 1))\n", + "\n", + "\n", + "def blob(rng, cy, cx, r, rough=0.35):\n", + " \"\"\"불규칙한 원형 영역 마스크.\"\"\"\n", + " noise = ndi.gaussian_filter(rng.normal(size=(SZ, SZ)), 3)\n", + " noise = noise / (np.abs(noise).max() + 1e-9)\n", + " return (np.hypot(YY - cy, XX - cx) + rough * r * 2.2 * noise) < r\n", + "\n", + "\n", + "def draw(rng, cls, sev):\n", + " \"\"\"결함 유형·심각도 sev(0~1)에 맞는 정답 마스크와 색 정보를 만든다.\"\"\"\n", + " m = np.zeros((SZ, SZ), bool)\n", + " if cls == \"crack\":\n", + " y, x, ang = rng.uniform(12, 52), rng.uniform(12, 52), rng.uniform(0, np.pi)\n", + " for _ in range(int(20 + 40 * sev)):\n", + " ang += rng.normal(0, 0.35); y += np.sin(ang) * 1.2; x += np.cos(ang) * 1.2\n", + " if 0 <= int(y) < SZ and 0 <= int(x) < SZ:\n", + " m[int(y), int(x)] = True\n", + " m = ndi.binary_dilation(m, iterations=1 + int(sev > 0.6))\n", + " elif cls == \"corrosion\":\n", + " for _ in range(int(rng.integers(1, 4))):\n", + " m |= blob(rng, rng.uniform(14, 50), rng.uniform(14, 50), 3 + 9 * sev * rng.uniform(0.6, 1.1))\n", + " elif cls == \"damage\":\n", + " m = blob(rng, rng.uniform(20, 44), rng.uniform(20, 44), 6 + 12 * sev, 0.3)\n", + " elif cls == \"leak\":\n", + " x0, y0 = rng.uniform(16, 48), rng.uniform(8, 24)\n", + " w, ln = 2 + 4 * sev, 12 + 26 * sev\n", + " m = (np.abs(XX - x0 - 0.15 * (YY - y0)) < w * (1 - 0.4 * (YY - y0) / ln)) & (YY > y0) & (YY < y0 + ln)\n", + " return m\n", + "\n", + "\n", + "def make_images(n=600, seed=0, mud=0.0, p_none=0.25, dark=False):\n", + " \"\"\"가상 외관 사진 n장(+진흙 오염 mud 비율)과 관리 기록·정답. 반환: meta(DataFrame), imgs(n,64,64,3), masks(n,64,64).\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " imgs, masks, rows = np.zeros((n, SZ, SZ, 3), np.float32), np.zeros((n, SZ, SZ), bool), []\n", + " for i in range(n):\n", + " cls = \"none\" if rng.random() < p_none else str(rng.choice(CLASSES[1:], p=[0.25, 0.3, 0.25, 0.2]))\n", + " sev = 0.0 if cls == \"none\" else float(rng.beta(1.6, 2.0))\n", + " base = np.array([0.42, 0.46, 0.40]) * (1 + 0.5 * ndi.gaussian_filter(rng.normal(size=(SZ, SZ)), 8)[..., None] * 3)\n", + " img = base + rng.normal(0, 0.012, (SZ, SZ, 3))\n", + " m = draw(rng, cls, sev)\n", + " a = ndi.gaussian_filter(m.astype(float), 0.8)[..., None] # 경계를 부드럽게 섞는다\n", + " if cls == \"crack\":\n", + " col = img * rng.uniform(0.2, 0.45)\n", + " elif cls == \"corrosion\":\n", + " col = np.array([0.55, 0.30, 0.12]) * rng.uniform(0.75, 1.15) * (0.85 + 0.3 * rng.random((SZ, SZ, 1)))\n", + " elif cls == \"damage\":\n", + " col = np.ones_like(img) * np.array([0.34, 0.34, 0.36]) * (1.0 - 0.45 * sev) * rng.uniform(0.75, 1.0)\n", + " rim = m & ~ndi.binary_erosion(m, iterations=2)\n", + " a = np.maximum(a, 0.0); img = np.where(rim[..., None], 0.7, img)\n", + " elif cls == \"leak\":\n", + " col = np.ones_like(img) * np.array([0.10, 0.09, 0.08]) * rng.uniform(0.8, 2.6)\n", + " else:\n", + " col = img\n", + " img = img * (1 - a) + col * a\n", + " if rng.random() < 0.35: # 무해한 얼룩(결함 아님): 옅은 갈색 번짐\n", + " st = blob(rng, rng.uniform(5, 58), rng.uniform(5, 58), rng.uniform(6, 14), 0.5)\n", + " al = ndi.gaussian_filter(st.astype(float), 2.5)[..., None] * rng.uniform(0.15, 0.4)\n", + " img = img * (1 - al) + np.array([0.50, 0.40, 0.30]) * al\n", + " if rng.random() < 0.3: # 무해한 긴 스크래치(밝은 선)\n", + " y0, x0, ang = rng.uniform(5, 58), rng.uniform(5, 58), rng.uniform(0, np.pi)\n", + " for t in np.linspace(0, 25, 40):\n", + " yy, xx = int(y0 + t * np.sin(ang)), int(x0 + t * np.cos(ang))\n", + " if 0 <= yy < SZ and 0 <= xx < SZ:\n", + " img[yy, xx] = np.clip(img[yy, xx] * 1.5, 0, 1)\n", + " if mud > 0: # 진흙 오염: 결함 위를 덮을 수 있다(정답 마스크는 그대로)\n", + " mm = np.zeros((SZ, SZ), bool)\n", + " while mm.mean() < mud:\n", + " mm |= blob(rng, rng.uniform(0, SZ), rng.uniform(0, SZ), rng.uniform(5, 11), 0.5)\n", + " img[mm] = np.array([0.38, 0.30, 0.22]) * (0.85 + 0.3 * rng.random((mm.sum(), 1)))\n", + " gain = rng.uniform(0.3, 0.55) if dark else rng.uniform(0.5, 1.15)\n", + " ang = rng.uniform(0, 2 * np.pi)\n", + " light = gain * (1 + 0.3 * (np.cos(ang) * (XX - 32) + np.sin(ang) * (YY - 32)) / 32)\n", + " imgs[i] = np.clip(img * light[..., None] + rng.normal(0, 0.025, (SZ, SZ, 3)), 0, 1)\n", + " masks[i] = m\n", + " area = float(m.mean())\n", + " depth = {\"none\": 0.0, \"crack\": 0.3 + 3.0 * sev, \"corrosion\": 0.1 + 1.5 * sev, \"damage\": 1.0 + 8.0 * sev, \"leak\": 0.0}[cls] * rng.lognormal(0, 0.1)\n", + " idx = float(np.clip(100 * (1 - np.exp(-(CLS_W[cls] * area * 9 + 0.05 * depth))) + rng.normal(0, 1.5), 0, 100))\n", + " rows.append({\"img_id\": f\"IMG{seed:02d}{i:04d}\", \"cls\": cls, \"sev\": sev, \"area_frac\": area, \"depth_mm\": depth, \"damage_idx\": idx,\n", + " \"grade\": int(grade_of(idx)), \"gain\": gain, \"mud\": mud})\n", + " meta = pd.DataFrame(rows)\n", + " # 장비 관리 기록(제원 요약): 사진 판정과 대조할 값\n", + " age = rng.integers(1, 25, n)\n", + " meta[\"day\"] = np.sort(rng.integers(0, 90, n))\n", + " meta[\"equip_type\"] = rng.choice([\"vehicle\", \"generator\", \"comm\", \"trailer\"], n, p=[0.4, 0.2, 0.2, 0.2])\n", + " meta[\"mfg_year\"] = 2026 - age\n", + " meta[\"op_hours\"] = np.clip(rng.normal(180 * age, 400, n) + 8 * meta[\"damage_idx\"], 10, 20000)\n", + " meta[\"env\"] = rng.choice([\"coastal\", \"inland\"], n, p=[0.3, 0.7])\n", + " reg = np.clip(meta[\"grade\"] + rng.choice([-1, 0, 0, 0, 0, 0, 1, 2], n), 1, 3) # 장비 관리 기록상 직전 정비 등급\n", + " meta[\"reg_grade\"] = reg\n", + " meta[\"emergency\"] = rng.random(n) < 0.04 # 긴급 점검 요청(화질 불량이어도 보존)\n", + " return meta, imgs, masks\n", + "# ---- 전처리(조명 보정·표준화)와 결함 후보 분할, 특징 추출 (cv2 대신 numpy·scipy 사용) ----\n", + "def correct(img):\n", + " \"\"\"조명 보정: 밝기에 평면(조명 기울기)을 적합해 나눈다. 어두운 결함 화소는 제외하고 두 번 반복 적합한다. 반환: 보정 밝기(정상 면 약 1), 색 비율.\"\"\"\n", + " g = img.mean(axis=2)\n", + " A = np.c_[np.ones(SZ * SZ), XX.ravel() / SZ, YY.ravel() / SZ]\n", + " keep = np.ones(SZ * SZ, bool)\n", + " for _ in range(3):\n", + " coef, *_ = np.linalg.lstsq(A[keep], g.ravel()[keep], rcond=None)\n", + " plane = (A @ coef).reshape(SZ, SZ)\n", + " keep = (np.abs(g - plane) < 0.08 * np.maximum(plane, 1e-3)).ravel()\n", + " if keep.sum() < 200:\n", + " keep[:] = True\n", + " c = g / np.maximum(plane, 1e-3)\n", + " chroma = img[..., 0] / (img.sum(axis=2) + 1e-6)\n", + " return c, chroma\n", + "\n", + "\n", + "SEGP = {\"dark\": 0.7, \"rust\": 0.5, \"rim\": 1.45} # 분할 임계값(어두움 보정 밝기, 붉은 비율, 밝은 테두리): 재보정 대상\n", + "\n", + "\n", + "def segment(img):\n", + " \"\"\"결함 후보 마스크: 어두운 영역(균열·누유·파손) + 붉은 갈색 영역(부식) + 밝은 테두리(파손).\"\"\"\n", + " c, chroma = correct(img)\n", + " dark = c < SEGP[\"dark\"]\n", + " rust = (chroma > SEGP[\"rust\"]) & (c > 0.45)\n", + " rim = c > SEGP[\"rim\"]\n", + " m = dark | rust | ndi.binary_dilation(rim, iterations=1) & ndi.binary_dilation(dark, iterations=3)\n", + " m = ndi.binary_opening(m, structure=np.ones((2, 2))) if m.sum() > 12 else m\n", + " return m, c, chroma, dark, rust, rim\n", + "\n", + "\n", + "def extract(img):\n", + " \"\"\"분할 결과와 색·모양에서 24개 특징을 만든다.\"\"\"\n", + " m, c, chroma, dark, rust, rim = segment(img)\n", + " lab, n = ndi.label(m)\n", + " area = float(m.sum())\n", + " if n:\n", + " sizes = ndi.sum(m, lab, range(1, n + 1))\n", + " k = int(np.argmax(sizes)) + 1\n", + " sl = ndi.find_objects(lab)[k - 1]\n", + " h, w = sl[0].stop - sl[0].start, sl[1].stop - sl[1].start\n", + " big = float(sizes.max())\n", + " elong, fill = max(h, w) / max(min(h, w), 1), big / (h * w)\n", + " else:\n", + " big, elong, fill = 0.0, 1.0, 0.0\n", + " gy, gx = np.gradient(c)\n", + " mag = np.hypot(gx, gy)\n", + " inside = m.sum() > 0\n", + " return {\"area_px\": area, \"area_frac\": area / SZ ** 2, \"n_comp\": float(n), \"big_px\": big, \"elong\": elong, \"fill\": fill,\n", + " \"dark_frac\": float(dark.mean()), \"rust_frac\": float(rust.mean()), \"rim_frac\": float(rim.mean()),\n", + " \"c_mean\": float(c.mean()), \"c_std\": float(c.std()), \"c_min\": float(c.min()), \"edge_mean\": float(mag.mean()), \"edge_p95\": float(np.quantile(mag, 0.95)),\n", + " \"chroma_mean\": float(chroma.mean()), \"chroma_max\": float(chroma.max()),\n", + " \"in_dark\": float(c[m].mean()) if inside else 1.0, \"in_chroma\": float(chroma[m].mean()) if inside else float(chroma.mean()),\n", + " \"ring_contrast\": float(c[ndi.binary_dilation(m, iterations=3) & ~m].mean() - c[m].mean()) if inside and (~m).any() else 0.0,\n", + " \"thin\": float(1 - fill), \"gain\": float(img.mean()), \"sat\": float((img.max(axis=2) - img.min(axis=2)).mean()),\n", + " \"cy\": float(ndi.center_of_mass(m)[0] / SZ) if inside else 0.5, \"cx\": float(ndi.center_of_mass(m)[1] / SZ) if inside else 0.5}\n", + "\n", + "\n", + "def feature_table(imgs):\n", + " return pd.DataFrame([extract(im) for im in imgs])\n", + "# ---- 판정 파이프라인: 결함 유형 분류 + 손상도 회귀(분류 확률 포함) -> 정비 등급 ----\n", + "def fit_pipeline(meta, F):\n", + " \"\"\"결함 유형 분류기와 손상도 회귀기를 학습한다. 회귀기 입력의 분류 확률은 교차 예측으로 만들어 누설을 막는다.\"\"\"\n", + " clf = RandomForestClassifier(n_estimators=150, random_state=0, n_jobs=1)\n", + " P = cross_val_predict(clf, F, meta[\"cls\"], cv=4, method=\"predict_proba\")\n", + " clf.fit(F, meta[\"cls\"])\n", + " reg = GradientBoostingRegressor(n_estimators=200, max_depth=3, learning_rate=0.06, random_state=0).fit(np.c_[F.values, P], meta[\"damage_idx\"])\n", + " return {\"clf\": clf, \"reg\": reg, \"cols\": list(F.columns)}\n", + "\n", + "\n", + "def predict_pipeline(pl, F):\n", + " X = F[pl[\"cols\"]]\n", + " P = pl[\"clf\"].predict_proba(X)\n", + " idx = np.clip(pl[\"reg\"].predict(np.c_[X.values, P]), 0, 100)\n", + " return pl[\"clf\"].classes_[P.argmax(axis=1)], P, idx, P.max(axis=1)\n", + "# ---- 사진 품질 게이트: 진흙·오염 가림 추정과 판정 신뢰도 지수 ----\n", + "def mud_est(img):\n", + " \"\"\"진흙(갈색, 채도 낮은 붉은 기) 화소 비율을 추정한다. 3x3 평균으로 잡음을 줄인 뒤 색 비율로 판단하며, 실제 가림 비율보다 작게 나오지만 순서는 유지된다.\"\"\"\n", + " sm = ndi.uniform_filter(img, size=(3, 3, 1))\n", + " s = sm.sum(axis=2) + 1e-6\n", + " rr, gg = sm[..., 0] / s, sm[..., 1] / s\n", + " return float(((rr > 0.385) & (rr < 0.46) & (gg < 0.325)).mean())\n", + "\n", + "\n", + "def confidence(P, idx, F, mud):\n", + " \"\"\"판정 신뢰도 지수 0~100 = 50 x 분류 확률 최대값 + 35 x 등급 경계까지 거리(8점에서 포화) + 15 x 사진 품질. 오염 의심(mud 추정 1% 이상)이면 상한 75로 제한한다.\"\"\"\n", + " bd = np.minimum(np.abs(idx - 35), np.abs(idx - 65))\n", + " qual = np.clip(1.0 - np.clip(0.55 - F[\"gain\"].values, 0, None) * 2 - np.clip(0.9 - F[\"c_mean\"].values, 0, None), 0, 1)\n", + " c = 50 * P.max(axis=1) + 35 * np.clip(bd / 8, 0, 1) + 15 * qual\n", + " return np.where(np.asarray(mud) >= 0.01, np.minimum(c, 75.0), c)\n", + "\n", + "mtr, itr, _ = make_images(600, seed=1)\n", + "pl = fit_pipeline(mtr, feature_table(itr))\n", + "\n", + "\n", + "def run(imgs, truth):\n", + " \"\"\"사진 묶음을 판정해 게이트 적용 전·후 신뢰도와 경보(등급 2 이상 = 정비 경보)를 만든다.\"\"\"\n", + " t0 = time.perf_counter()\n", + " F = feature_table(imgs)\n", + " t1 = time.perf_counter()\n", + " cls, P, idx, _ = predict_pipeline(pl, F)\n", + " t2 = time.perf_counter()\n", + " mud = np.array([mud_est(x) for x in imgs])\n", + " raw_conf = confidence(P, idx, F, np.zeros(len(imgs)))\n", + " conf = confidence(P, idx, F, mud)\n", + " t3 = time.perf_counter()\n", + " g = grade_of(idx)\n", + " return pd.DataFrame({\"true\": truth[\"grade\"].values, \"ai\": g, \"mud\": mud, \"raw_conf\": raw_conf, \"conf\": conf, \"idx\": idx, \"retake\": (mud >= 0.03) | (F[\"gain\"].values < 0.17)}), (t1 - t0, t2 - t1, t3 - t2)\n", + "\n", + "\n", + "# ---------------- 1) 진흙 혼입 비율 스윕 ----------------\n", + "rows, tm = [], []\n", + "for mudr in (0.0, 0.05, 0.10, 0.20, 0.30, 0.40, 0.50):\n", + " m, im, _ = make_images(120, seed=20 + int(mudr * 100), mud=mudr)\n", + " r, t = run(im, m)\n", + " tm.append(t)\n", + " ok = r[\"true\"] == r[\"ai\"]\n", + " auto = (r[\"conf\"] >= 80) & ~r[\"retake\"] # 품질 게이트를 통과해 자동 처리(검사관 간이 승인)로 가는 사진\n", + " alarm_ok = ((r[\"ai\"] >= 2) == (r[\"true\"] >= 2))\n", + " ung = r[\"raw_conf\"] >= 80\n", + " rows.append((f\"{mudr:.0%}\", len(r), ok.mean(), r[\"conf\"].mean(), r[\"retake\"].mean(), auto.mean(), alarm_ok[auto].mean() if auto.any() else np.nan, alarm_ok[ung].mean() if ung.any() else np.nan, ung.mean()))\n", + "sw = pd.DataFrame(rows, columns=[\"진흙 비율\", \"장수\", \"등급 일치율(전체)\", \"평균 신뢰도\", \"재촬영 요구율\", \"자동 처리 비율\", \"경보 정확도(게이트 후)\", \"경보 정확도(게이트 없이 80 이상)\", \"게이트 없이 80 이상 비율\"])\n", + "print(\"[1] 진흙 혼입 비율별 판정(교육용 가상 사진, 비율당 120장)\")\n", + "pc = lambda v: \"-\" if pd.isna(v) else f\"{v:.0%}\"\n", + "print(sw.assign(**{c: sw[c].map(pc) for c in sw.columns if c not in (\"진흙 비율\", \"장수\", \"평균 신뢰도\")}).round(1).to_string(index=False))\n", + "bad = sw[sw[\"경보 정확도(게이트 후)\"] < .95][\"진흙 비율\"].tolist()\n", + "print(f\" 경보 정확도가 95% 미만으로 처음 떨어지는 진흙 비율: {bad[0] if bad else '없음'} (진흙 0%에서는 자동 처리 {sw.iloc[0]['자동 처리 비율']:.0%}, 경보 정확도 {sw.iloc[0]['경보 정확도(게이트 후)']:.0%})\")\n", + "print(f\" 게이트가 없으면 진흙 20%에서도 신뢰도 80 이상이 {sw.iloc[3]['게이트 없이 80 이상 비율']:.0%} 남고 그 경보 정확도는 {sw.iloc[3]['경보 정확도(게이트 없이 80 이상)']:.0%}로 낮다. 품질 게이트는 이런 사진을 자동 처리에서 빼 검사관 확인으로 보낸다.\")\n", + "\n", + "# ---------------- 2) 복합 열화: 흐림, 센서 잡음, 저조도+진흙 ----------------\n", + "rng = np.random.default_rng(3)\n", + "cases = {}\n", + "m0, i0, _ = make_images(120, seed=40)\n", + "cases[\"정상\"] = (m0, i0)\n", + "cases[\"흐림(초점 불량)\"] = (m0, np.clip(np.stack([ndi.gaussian_filter(x, sigma=(1.8, 1.8, 0)) for x in i0]), 0, 1))\n", + "cases[\"센서 잡음 +0.08\"] = (m0, np.clip(i0 + rng.normal(0, 0.08, i0.shape), 0, 1).astype(np.float32))\n", + "m1, i1, _ = make_images(120, seed=41, mud=0.2, dark=True)\n", + "cases[\"저조도+진흙 20%\"] = (m1, i1)\n", + "rows = []\n", + "for name, (m, im) in cases.items():\n", + " r, _ = run(im, m)\n", + " ok = r[\"true\"] == r[\"ai\"]\n", + " auto = (r[\"conf\"] >= 80) & ~r[\"retake\"]\n", + " am = ((r[\"ai\"] >= 2) == (r[\"true\"] >= 2))\n", + " rows.append((name, ok.mean(), r[\"retake\"].mean(), auto.mean(), am[auto].mean() if auto.any() else np.nan, (r[\"true\"] > r[\"ai\"]).mean()))\n", + "cc = pd.DataFrame(rows, columns=[\"조건\", \"등급 일치율\", \"재촬영 요구율\", \"자동 처리 비율\", \"자동 처리 경보 정확도\", \"과소 판정 비율\"])\n", + "print(\"\\n[2] 복합 열화 조건(120장)\\n\" + cc.assign(**{c: cc[c].map(pc) for c in cc.columns[1:]}).to_string(index=False))\n", + "print(f\" 흐림 사진은 게이트가 직접 잡지 못해 자동 처리 {cc.iloc[1]['자동 처리 비율']:.0%} 중 경보 정확도 {cc.iloc[1]['자동 처리 경보 정확도']:.0%}로 95%에 못 미친다 -> 초점·촬영 거리 가이드 보완이 필요하다.\")\n", + "\n", + "# ---------------- 3) 대량 처리 시간과 대시보드 로딩 ----------------\n", + "tt = np.array(tm).mean(axis=0) / 120\n", + "per = tt.sum()\n", + "print(f\"\\n[3] 사진 1장당 처리 시간(1개 코어 가정): 분할·특징 {tt[0] * 1000:.1f}ms, 판정 {tt[1] * 1000:.2f}ms, 오염 추정·신뢰도 {tt[2] * 1000:.1f}ms, 합계 {per * 1000:.1f}ms ({1 / per:.0f}장/초)\")\n", + "for n in (1000, 5000, 20000):\n", + " print(f\" {n:>6}장 일괄 판정 예상: {n * per:.0f}초 ({n * per / 60:.1f}분) - 선형 외삽, 사진 해상도·장비 사양이 다르면 달라진다\")\n", + "\n", + "\n", + "def dashboard(df):\n", + " t0 = time.perf_counter()\n", + " summ = df.groupby(\"ai\").agg(건수=(\"idx\", \"size\"), 평균신뢰도=(\"conf\", \"mean\"))\n", + " low = df.sort_values(\"conf\").head(20)\n", + " fg, ax = plt.subplots(1, 2, figsize=(9, 3.5))\n", + " ax[0].bar(summ.index.astype(str), summ[\"건수\"]); ax[1].hist(df[\"conf\"], bins=20)\n", + " fg.canvas.draw(); plt.close(fg)\n", + " return time.perf_counter() - t0, len(low)\n", + "\n", + "\n", + "base, _ = run(cases[\"정상\"][1], cases[\"정상\"][0])\n", + "loads = []\n", + "for n in (300, 3000, 30000, 300000):\n", + " big = pd.concat([base] * (n // len(base) + 1), ignore_index=True).head(n)\n", + " loads.append((n, dashboard(big)[0]))\n", + "print(\" 대시보드 로딩 시간(요약 집계 + 상위 20개 목록 + 차트 렌더링):\", \", \".join(f\"{n}건 {s:.2f}초\" for n, s in loads))\n", + "print(f\" 3000건 기준 로딩 {loads[1][1]:.2f}초(목표 2초 이내: {'달성' if loads[1][1] <= 2 else '미달'}), 30만 건에서도 {loads[3][1]:.2f}초. 화면은 요약과 신뢰도 낮은 사진 우선 목록만 먼저 표시한다.\")\n", + "\n", + "# ---------------- 4) 결론 ----------------\n", + "print(\"\\n[4] 한계치 요약\")\n", + "print(f\" - 자동 처리(검사관 간이 승인) 후보는 가림이 없는 사진 중심이며, 진흙 {bad[0] if bad else '5%'} 이상부터 경보 정확도 95% 목표를 지키지 못해 재촬영 또는 검사관 정밀 판독으로 보낸다\")\n", + "print(f\" - 정상 사진도 {1 - sw.iloc[0]['자동 처리 비율']:.0%}는 신뢰도 80 미만이라 검사관 확인 대상이다. 경보 정확도는 자동 처리 사진에 한해 판단하고 자동 처리 비율을 함께 보고한다\")\n", + "print(\" - 판정·경보는 검사관 확인의 보조 수단이며 최종 정비 등급은 사람이 확정한다. 모든 사진은 교육용 가상 자료이다.\")\n", + "\n", + "fig, ax = plt.subplots(1, 3, figsize=(15, 4.2))\n", + "x = np.arange(len(sw))\n", + "ax[0].plot(x, sw[\"등급 일치율(전체)\"] * 100, \"o-\", label=\"grade accuracy (all)\"); ax[0].plot(x, sw[\"재촬영 요구율\"] * 100, \"s-\", label=\"retake rate\"); ax[0].plot(x, sw[\"자동 처리 비율\"] * 100, \"^-\", label=\"auto-processed\")\n", + "ax[0].set_xticks(x); ax[0].set_xticklabels(sw[\"진흙 비율\"]); ax[0].set_xlabel(\"mud ratio\"); ax[0].legend(); ax[0].set_title(\"Mud stress sweep (%)\")\n", + "ax[1].bar(range(len(cc)), cc[\"자동 처리 비율\"] * 100); ax[1].set_xticks(range(len(cc))); ax[1].set_xticklabels([\"clean\", \"blur\", \"noise\", \"dark+mud\"]); ax[1].set_title(\"Auto-processed share (%)\")\n", + "ax[2].loglog([l[0] for l in loads], [l[1] for l in loads], \"o-\"); ax[2].axhline(2, c=\"r\", ls=\"--\"); ax[2].set_title(\"Dashboard load time (s) vs records\")\n", + "plt.tight_layout(); plt.show()\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 08 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + "[1] 진흙 혼입 비율별 판정(교육용 가상 사진, 비율당 120장)\n", + "진흙 비율 장수 등급 일치율(전체) 평균 신뢰도 재촬영 요구율 자동 처리 비율 경보 정확도(게이트 후) 경보 정확도(게이트 없이 80 이상) 게이트 없이 80 이상 비율\n", + " 0% 120 90% 86.4 0% 73% 100% 100% 73%\n", + " 5% 120 65% 67.3 1% 12% 93% 94% 26%\n", + " 10% 120 53% 63.3 12% 3% 75% 88% 27%\n", + " 20% 120 42% 63.9 66% 0% - 73% 25%\n", + " 30% 120 64% 62.1 88% 0% - 77% 11%\n", + " 40% 120 59% 59.8 99% 0% - 38% 7%\n", + " 50% 120 68% 64.9 100% 0% - 79% 16%\n", + " 경보 정확도가 95% 미만으로 처음 떨어지는 진흙 비율: 5% (진흙 0%에서는 자동 처리 73%, 경보 정확도 100%)\n", + " 게이트가 없으면 진흙 20%에서도 신뢰도 80 이상이 25% 남고 그 경보 정확도는 73%로 낮다. 품질 게이트는 이런 사진을 자동 처리에서 빼 검사관 확인으로 보낸다.\n", + "\n", + "[2] 복합 열화 조건(120장)\n", + " 조건 등급 일치율 재촬영 요구율 자동 처리 비율 자동 처리 경보 정확도 과소 판정 비율\n", + " 정상 96% 0% 75% 100% 4%\n", + " 흐림(초점 불량) 84% 0% 62% 91% 11%\n", + "센서 잡음 +0.08 66% 8% 8% 100% 8%\n", + " 저조도+진흙 20% 42% 100% 0% - 12%\n", + " 흐림 사진은 게이트가 직접 잡지 못해 자동 처리 62% 중 경보 정확도 91%로 95%에 못 미친다 -> 초점·촬영 거리 가이드 보완이 필요하다.\n", + "\n", + "[3] 사진 1장당 처리 시간(1개 코어 가정): 분할·특징 4.1ms, 판정 0.16ms, 오염 추정·신뢰도 0.3ms, 합계 4.5ms (223장/초)\n", + " 1000장 일괄 판정 예상: 4초 (0.1분) - 선형 외삽, 사진 해상도·장비 사양이 다르면 달라진다\n", + " 5000장 일괄 판정 예상: 22초 (0.4분) - 선형 외삽, 사진 해상도·장비 사양이 다르면 달라진다\n", + " 20000장 일괄 판정 예상: 90초 (1.5분) - 선형 외삽, 사진 해상도·장비 사양이 다르면 달라진다\n", + " 대시보드 로딩 시간(요약 집계 + 상위 20개 목록 + 차트 렌더링): 300건 0.19초, 3000건 0.10초, 30000건 0.14초, 300000건 0.17초\n", + " 3000건 기준 로딩 0.10초(목표 2초 이내: 달성), 30만 건에서도 0.17초. 화면은 요약과 신뢰도 낮은 사진 우선 목록만 먼저 표시한다.\n", + "\n", + "[4] 한계치 요약\n", + " - 자동 처리(검사관 간이 승인) 후보는 가림이 없는 사진 중심이며, 진흙 5% 이상부터 경보 정확도 95% 목표를 지키지 못해 재촬영 또는 검사관 정밀 판독으로 보낸다\n", + " - 정상 사진도 27%는 신뢰도 80 미만이라 검사관 확인 대상이다. 경보 정확도는 자동 처리 사진에 한해 판단하고 자동 처리 비율을 함께 보고한다\n", + " - 판정·경보는 검사관 확인의 보조 수단이며 최종 정비 등급은 사람이 확정한다. 모든 사진은 교육용 가상 자료이다.\n", + "```\n", + "
\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 09. AAR 기반 외관 오판 사례 분석 및 컴퓨터 비전 모델 파라미터 재보정\n", + "**소요시간**: 약 3.0시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "AAR 기반 외관 오판 사례를 분석하고 컴퓨터 비전 파라미터를 재보정한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **AAR로 외관 오판 사례를 분석하고 컴퓨터 비전 모델 파라미터를 재보정하며 도입 효과를 정량화한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **운영 분포(저조도·진흙·정상 혼합)의 판정 로그에서 오판을 오염 가림·저조도·작은 결함·유형 혼동·경계 근처로 분류 수행하고 검증하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `reports/qlt03_roi_analysis.md` 형태의 결과물을 완성한다.\n", + "\n", + "### 🔍 평가 및 검증 기준 (PASS 조건)\n", + "#### 미션 판정\n", + "\n", + "**평가 구분:** 필수\n", + "\n", + "**핵심 평가영역:** 추정 산식의 객관성 및 군 재정/군수 기획 부서 설득력\n", + "\n", + "#### 미션 PASS 조건\n", + "\n", + "- 정량 기준: 비용 절감액, 시간 단축률 등 3대 정량 효과 지표 산출\n", + "\n", + "\n", + "- 진행 기준: ROI 분석 보고서 완성 및 주요 지표 민감도 분석 수록\n", + "\n", + "\n", + "- 검증 방법: 회계/기획 기준 모델과의 비교 검증\n", + "\n", + "\n", + "#### 채점자 판정 메모\n", + "\n", + "> 단순 코드 실행 여부뿐만 아니라 출력된 수치와 근거 자료가 군 실무 요구사항에 부합하는지 면밀히 확인한다.\n", + "\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "\"\"\"운영 기간 외관 판정 로그로 AAR을 수행해 오판 사례를 원인별(오염 가림·저조도·작은 결함·경계 근처·유형 혼동)로 분류하고, 분할 임계값·학습 데이터·등급 경계(과소 판정 손실 반영)를 재보정해 보류 구간에서 개선을 검증한 뒤 비용 절감액·시간 단축률·투자 회수 기간 3대 효과 지표를 산식과 민감도로 산출한다.\"\"\"\n", + "import numpy as np\n", + "import pandas as pd\n", + "import matplotlib.pyplot as plt\n", + "from sklearn.ensemble import RandomForestClassifier, GradientBoostingRegressor\n", + "from sklearn.model_selection import cross_val_predict\n", + "\n", + "# ---------------------------------------------------------------------------\n", + "# [교육용가상자료] 장비 외관 사진 생성기 (실제 장비·부대·위치 사진이 아니라 난수로 그린 64x64 합성 이미지)\n", + "# 도장된 금속 표면에 균열(crack)·부식(corrosion)·파손(damage)·누유(leak)를 그리고, 조명 편차와 진흙(mud) 오염을 섞는다.\n", + "# 이미지와 함께 장비 관리 기록(제원 요약)과 정답 결함 마스크·손상도·정비 등급을 만든다.\n", + "# ---------------------------------------------------------------------------\n", + "from scipy import ndimage as ndi\n", + "\n", + "SZ = 64\n", + "CLASSES = [\"none\", \"crack\", \"corrosion\", \"damage\", \"leak\"]\n", + "KO_CLS = {\"none\": \"결함 없음\", \"crack\": \"균열\", \"corrosion\": \"부식\", \"damage\": \"파손\", \"leak\": \"누유\"}\n", + "CLS_W = {\"none\": 0.0, \"crack\": 1.4, \"corrosion\": 1.0, \"damage\": 1.6, \"leak\": 0.6} # 손상도 계산용 결함 유형 가중치\n", + "GRADE_NAME = {1: \"현장정비\", 2: \"야전정비\", 3: \"창정비\"}\n", + "YY, XX = np.mgrid[0:SZ, 0:SZ]\n", + "\n", + "\n", + "def grade_of(idx):\n", + " return np.where(np.asarray(idx) >= 65, 3, np.where(np.asarray(idx) >= 35, 2, 1))\n", + "\n", + "\n", + "def blob(rng, cy, cx, r, rough=0.35):\n", + " \"\"\"불규칙한 원형 영역 마스크.\"\"\"\n", + " noise = ndi.gaussian_filter(rng.normal(size=(SZ, SZ)), 3)\n", + " noise = noise / (np.abs(noise).max() + 1e-9)\n", + " return (np.hypot(YY - cy, XX - cx) + rough * r * 2.2 * noise) < r\n", + "\n", + "\n", + "def draw(rng, cls, sev):\n", + " \"\"\"결함 유형·심각도 sev(0~1)에 맞는 정답 마스크와 색 정보를 만든다.\"\"\"\n", + " m = np.zeros((SZ, SZ), bool)\n", + " if cls == \"crack\":\n", + " y, x, ang = rng.uniform(12, 52), rng.uniform(12, 52), rng.uniform(0, np.pi)\n", + " for _ in range(int(20 + 40 * sev)):\n", + " ang += rng.normal(0, 0.35); y += np.sin(ang) * 1.2; x += np.cos(ang) * 1.2\n", + " if 0 <= int(y) < SZ and 0 <= int(x) < SZ:\n", + " m[int(y), int(x)] = True\n", + " m = ndi.binary_dilation(m, iterations=1 + int(sev > 0.6))\n", + " elif cls == \"corrosion\":\n", + " for _ in range(int(rng.integers(1, 4))):\n", + " m |= blob(rng, rng.uniform(14, 50), rng.uniform(14, 50), 3 + 9 * sev * rng.uniform(0.6, 1.1))\n", + " elif cls == \"damage\":\n", + " m = blob(rng, rng.uniform(20, 44), rng.uniform(20, 44), 6 + 12 * sev, 0.3)\n", + " elif cls == \"leak\":\n", + " x0, y0 = rng.uniform(16, 48), rng.uniform(8, 24)\n", + " w, ln = 2 + 4 * sev, 12 + 26 * sev\n", + " m = (np.abs(XX - x0 - 0.15 * (YY - y0)) < w * (1 - 0.4 * (YY - y0) / ln)) & (YY > y0) & (YY < y0 + ln)\n", + " return m\n", + "\n", + "\n", + "def make_images(n=600, seed=0, mud=0.0, p_none=0.25, dark=False):\n", + " \"\"\"가상 외관 사진 n장(+진흙 오염 mud 비율)과 관리 기록·정답. 반환: meta(DataFrame), imgs(n,64,64,3), masks(n,64,64).\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " imgs, masks, rows = np.zeros((n, SZ, SZ, 3), np.float32), np.zeros((n, SZ, SZ), bool), []\n", + " for i in range(n):\n", + " cls = \"none\" if rng.random() < p_none else str(rng.choice(CLASSES[1:], p=[0.25, 0.3, 0.25, 0.2]))\n", + " sev = 0.0 if cls == \"none\" else float(rng.beta(1.6, 2.0))\n", + " base = np.array([0.42, 0.46, 0.40]) * (1 + 0.5 * ndi.gaussian_filter(rng.normal(size=(SZ, SZ)), 8)[..., None] * 3)\n", + " img = base + rng.normal(0, 0.012, (SZ, SZ, 3))\n", + " m = draw(rng, cls, sev)\n", + " a = ndi.gaussian_filter(m.astype(float), 0.8)[..., None] # 경계를 부드럽게 섞는다\n", + " if cls == \"crack\":\n", + " col = img * rng.uniform(0.2, 0.45)\n", + " elif cls == \"corrosion\":\n", + " col = np.array([0.55, 0.30, 0.12]) * rng.uniform(0.75, 1.15) * (0.85 + 0.3 * rng.random((SZ, SZ, 1)))\n", + " elif cls == \"damage\":\n", + " col = np.ones_like(img) * np.array([0.34, 0.34, 0.36]) * (1.0 - 0.45 * sev) * rng.uniform(0.75, 1.0)\n", + " rim = m & ~ndi.binary_erosion(m, iterations=2)\n", + " a = np.maximum(a, 0.0); img = np.where(rim[..., None], 0.7, img)\n", + " elif cls == \"leak\":\n", + " col = np.ones_like(img) * np.array([0.10, 0.09, 0.08]) * rng.uniform(0.8, 2.6)\n", + " else:\n", + " col = img\n", + " img = img * (1 - a) + col * a\n", + " if rng.random() < 0.35: # 무해한 얼룩(결함 아님): 옅은 갈색 번짐\n", + " st = blob(rng, rng.uniform(5, 58), rng.uniform(5, 58), rng.uniform(6, 14), 0.5)\n", + " al = ndi.gaussian_filter(st.astype(float), 2.5)[..., None] * rng.uniform(0.15, 0.4)\n", + " img = img * (1 - al) + np.array([0.50, 0.40, 0.30]) * al\n", + " if rng.random() < 0.3: # 무해한 긴 스크래치(밝은 선)\n", + " y0, x0, ang = rng.uniform(5, 58), rng.uniform(5, 58), rng.uniform(0, np.pi)\n", + " for t in np.linspace(0, 25, 40):\n", + " yy, xx = int(y0 + t * np.sin(ang)), int(x0 + t * np.cos(ang))\n", + " if 0 <= yy < SZ and 0 <= xx < SZ:\n", + " img[yy, xx] = np.clip(img[yy, xx] * 1.5, 0, 1)\n", + " if mud > 0: # 진흙 오염: 결함 위를 덮을 수 있다(정답 마스크는 그대로)\n", + " mm = np.zeros((SZ, SZ), bool)\n", + " while mm.mean() < mud:\n", + " mm |= blob(rng, rng.uniform(0, SZ), rng.uniform(0, SZ), rng.uniform(5, 11), 0.5)\n", + " img[mm] = np.array([0.38, 0.30, 0.22]) * (0.85 + 0.3 * rng.random((mm.sum(), 1)))\n", + " gain = rng.uniform(0.3, 0.55) if dark else rng.uniform(0.5, 1.15)\n", + " ang = rng.uniform(0, 2 * np.pi)\n", + " light = gain * (1 + 0.3 * (np.cos(ang) * (XX - 32) + np.sin(ang) * (YY - 32)) / 32)\n", + " imgs[i] = np.clip(img * light[..., None] + rng.normal(0, 0.025, (SZ, SZ, 3)), 0, 1)\n", + " masks[i] = m\n", + " area = float(m.mean())\n", + " depth = {\"none\": 0.0, \"crack\": 0.3 + 3.0 * sev, \"corrosion\": 0.1 + 1.5 * sev, \"damage\": 1.0 + 8.0 * sev, \"leak\": 0.0}[cls] * rng.lognormal(0, 0.1)\n", + " idx = float(np.clip(100 * (1 - np.exp(-(CLS_W[cls] * area * 9 + 0.05 * depth))) + rng.normal(0, 1.5), 0, 100))\n", + " rows.append({\"img_id\": f\"IMG{seed:02d}{i:04d}\", \"cls\": cls, \"sev\": sev, \"area_frac\": area, \"depth_mm\": depth, \"damage_idx\": idx,\n", + " \"grade\": int(grade_of(idx)), \"gain\": gain, \"mud\": mud})\n", + " meta = pd.DataFrame(rows)\n", + " # 장비 관리 기록(제원 요약): 사진 판정과 대조할 값\n", + " age = rng.integers(1, 25, n)\n", + " meta[\"day\"] = np.sort(rng.integers(0, 90, n))\n", + " meta[\"equip_type\"] = rng.choice([\"vehicle\", \"generator\", \"comm\", \"trailer\"], n, p=[0.4, 0.2, 0.2, 0.2])\n", + " meta[\"mfg_year\"] = 2026 - age\n", + " meta[\"op_hours\"] = np.clip(rng.normal(180 * age, 400, n) + 8 * meta[\"damage_idx\"], 10, 20000)\n", + " meta[\"env\"] = rng.choice([\"coastal\", \"inland\"], n, p=[0.3, 0.7])\n", + " reg = np.clip(meta[\"grade\"] + rng.choice([-1, 0, 0, 0, 0, 0, 1, 2], n), 1, 3) # 장비 관리 기록상 직전 정비 등급\n", + " meta[\"reg_grade\"] = reg\n", + " meta[\"emergency\"] = rng.random(n) < 0.04 # 긴급 점검 요청(화질 불량이어도 보존)\n", + " return meta, imgs, masks\n", + "# ---- 전처리(조명 보정·표준화)와 결함 후보 분할, 특징 추출 (cv2 대신 numpy·scipy 사용) ----\n", + "def correct(img):\n", + " \"\"\"조명 보정: 밝기에 평면(조명 기울기)을 적합해 나눈다. 어두운 결함 화소는 제외하고 두 번 반복 적합한다. 반환: 보정 밝기(정상 면 약 1), 색 비율.\"\"\"\n", + " g = img.mean(axis=2)\n", + " A = np.c_[np.ones(SZ * SZ), XX.ravel() / SZ, YY.ravel() / SZ]\n", + " keep = np.ones(SZ * SZ, bool)\n", + " for _ in range(3):\n", + " coef, *_ = np.linalg.lstsq(A[keep], g.ravel()[keep], rcond=None)\n", + " plane = (A @ coef).reshape(SZ, SZ)\n", + " keep = (np.abs(g - plane) < 0.08 * np.maximum(plane, 1e-3)).ravel()\n", + " if keep.sum() < 200:\n", + " keep[:] = True\n", + " c = g / np.maximum(plane, 1e-3)\n", + " chroma = img[..., 0] / (img.sum(axis=2) + 1e-6)\n", + " return c, chroma\n", + "\n", + "\n", + "SEGP = {\"dark\": 0.7, \"rust\": 0.5, \"rim\": 1.45} # 분할 임계값(어두움 보정 밝기, 붉은 비율, 밝은 테두리): 재보정 대상\n", + "\n", + "\n", + "def segment(img):\n", + " \"\"\"결함 후보 마스크: 어두운 영역(균열·누유·파손) + 붉은 갈색 영역(부식) + 밝은 테두리(파손).\"\"\"\n", + " c, chroma = correct(img)\n", + " dark = c < SEGP[\"dark\"]\n", + " rust = (chroma > SEGP[\"rust\"]) & (c > 0.45)\n", + " rim = c > SEGP[\"rim\"]\n", + " m = dark | rust | ndi.binary_dilation(rim, iterations=1) & ndi.binary_dilation(dark, iterations=3)\n", + " m = ndi.binary_opening(m, structure=np.ones((2, 2))) if m.sum() > 12 else m\n", + " return m, c, chroma, dark, rust, rim\n", + "\n", + "\n", + "def extract(img):\n", + " \"\"\"분할 결과와 색·모양에서 24개 특징을 만든다.\"\"\"\n", + " m, c, chroma, dark, rust, rim = segment(img)\n", + " lab, n = ndi.label(m)\n", + " area = float(m.sum())\n", + " if n:\n", + " sizes = ndi.sum(m, lab, range(1, n + 1))\n", + " k = int(np.argmax(sizes)) + 1\n", + " sl = ndi.find_objects(lab)[k - 1]\n", + " h, w = sl[0].stop - sl[0].start, sl[1].stop - sl[1].start\n", + " big = float(sizes.max())\n", + " elong, fill = max(h, w) / max(min(h, w), 1), big / (h * w)\n", + " else:\n", + " big, elong, fill = 0.0, 1.0, 0.0\n", + " gy, gx = np.gradient(c)\n", + " mag = np.hypot(gx, gy)\n", + " inside = m.sum() > 0\n", + " return {\"area_px\": area, \"area_frac\": area / SZ ** 2, \"n_comp\": float(n), \"big_px\": big, \"elong\": elong, \"fill\": fill,\n", + " \"dark_frac\": float(dark.mean()), \"rust_frac\": float(rust.mean()), \"rim_frac\": float(rim.mean()),\n", + " \"c_mean\": float(c.mean()), \"c_std\": float(c.std()), \"c_min\": float(c.min()), \"edge_mean\": float(mag.mean()), \"edge_p95\": float(np.quantile(mag, 0.95)),\n", + " \"chroma_mean\": float(chroma.mean()), \"chroma_max\": float(chroma.max()),\n", + " \"in_dark\": float(c[m].mean()) if inside else 1.0, \"in_chroma\": float(chroma[m].mean()) if inside else float(chroma.mean()),\n", + " \"ring_contrast\": float(c[ndi.binary_dilation(m, iterations=3) & ~m].mean() - c[m].mean()) if inside and (~m).any() else 0.0,\n", + " \"thin\": float(1 - fill), \"gain\": float(img.mean()), \"sat\": float((img.max(axis=2) - img.min(axis=2)).mean()),\n", + " \"cy\": float(ndi.center_of_mass(m)[0] / SZ) if inside else 0.5, \"cx\": float(ndi.center_of_mass(m)[1] / SZ) if inside else 0.5}\n", + "\n", + "\n", + "def feature_table(imgs):\n", + " return pd.DataFrame([extract(im) for im in imgs])\n", + "# ---- 판정 파이프라인: 결함 유형 분류 + 손상도 회귀(분류 확률 포함) -> 정비 등급 ----\n", + "def fit_pipeline(meta, F):\n", + " \"\"\"결함 유형 분류기와 손상도 회귀기를 학습한다. 회귀기 입력의 분류 확률은 교차 예측으로 만들어 누설을 막는다.\"\"\"\n", + " clf = RandomForestClassifier(n_estimators=150, random_state=0, n_jobs=1)\n", + " P = cross_val_predict(clf, F, meta[\"cls\"], cv=4, method=\"predict_proba\")\n", + " clf.fit(F, meta[\"cls\"])\n", + " reg = GradientBoostingRegressor(n_estimators=200, max_depth=3, learning_rate=0.06, random_state=0).fit(np.c_[F.values, P], meta[\"damage_idx\"])\n", + " return {\"clf\": clf, \"reg\": reg, \"cols\": list(F.columns)}\n", + "\n", + "\n", + "def predict_pipeline(pl, F):\n", + " X = F[pl[\"cols\"]]\n", + " P = pl[\"clf\"].predict_proba(X)\n", + " idx = np.clip(pl[\"reg\"].predict(np.c_[X.values, P]), 0, 100)\n", + " return pl[\"clf\"].classes_[P.argmax(axis=1)], P, idx, P.max(axis=1)\n", + "# ---- 사진 품질 게이트: 진흙·오염 가림 추정과 판정 신뢰도 지수 ----\n", + "def mud_est(img):\n", + " \"\"\"진흙(갈색, 채도 낮은 붉은 기) 화소 비율을 추정한다. 3x3 평균으로 잡음을 줄인 뒤 색 비율로 판단하며, 실제 가림 비율보다 작게 나오지만 순서는 유지된다.\"\"\"\n", + " sm = ndi.uniform_filter(img, size=(3, 3, 1))\n", + " s = sm.sum(axis=2) + 1e-6\n", + " rr, gg = sm[..., 0] / s, sm[..., 1] / s\n", + " return float(((rr > 0.385) & (rr < 0.46) & (gg < 0.325)).mean())\n", + "\n", + "\n", + "def confidence(P, idx, F, mud):\n", + " \"\"\"판정 신뢰도 지수 0~100 = 50 x 분류 확률 최대값 + 35 x 등급 경계까지 거리(8점에서 포화) + 15 x 사진 품질. 오염 의심(mud 추정 1% 이상)이면 상한 75로 제한한다.\"\"\"\n", + " bd = np.minimum(np.abs(idx - 35), np.abs(idx - 65))\n", + " qual = np.clip(1.0 - np.clip(0.55 - F[\"gain\"].values, 0, None) * 2 - np.clip(0.9 - F[\"c_mean\"].values, 0, None), 0, 1)\n", + " c = 50 * P.max(axis=1) + 35 * np.clip(bd / 8, 0, 1) + 15 * qual\n", + " return np.where(np.asarray(mud) >= 0.01, np.minimum(c, 75.0), c)\n", + "\n", + "mtr, itr, _ = make_images(600, seed=1)\n", + "Ftr = feature_table(itr)\n", + "pl0 = fit_pipeline(mtr, Ftr)\n", + "LOSS_UNDER, LOSS_OVER = 20.0, 6.0 # 과소/과대 판정 1등급당 손실(만원, 가정)\n", + "\n", + "\n", + "def ops(seed, n):\n", + " \"\"\"운영 사진 묶음: 저조도 1/3 + 진흙 5% 1/3 + 정상 1/3 (학습 때와 분포가 다르다).\"\"\"\n", + " parts = [make_images(n // 3, seed=seed, dark=True), make_images(n // 3, seed=seed + 1, mud=0.05), make_images(n // 3, seed=seed + 2)]\n", + " return pd.concat([p[0] for p in parts], ignore_index=True), np.concatenate([p[1] for p in parts]), np.concatenate([p[2] for p in parts])\n", + "\n", + "\n", + "def loss(true, g):\n", + " d = np.asarray(g) - np.asarray(true)\n", + " return np.where(d < 0, -d * LOSS_UNDER, d * LOSS_OVER)\n", + "\n", + "\n", + "def grade_shift(idx, delta):\n", + " \"\"\"과소 판정을 줄이기 위해 등급 경계를 delta만큼 낮춘다(35 -> 35-delta, 65 -> 65-delta).\"\"\"\n", + " return np.where(np.asarray(idx) >= 65 - delta, 3, np.where(np.asarray(idx) >= 35 - delta, 2, 1))\n", + "\n", + "\n", + "def evaluate(pl, F, m, ims, delta=0):\n", + " cls, P, idx, _ = predict_pipeline(pl, F)\n", + " mud = np.array([mud_est(x) for x in ims])\n", + " conf = confidence(P, idx, F, mud)\n", + " g = grade_shift(idx, delta)\n", + " auto = (conf >= 80) & (mud < 0.03) & (F[\"gain\"].values >= 0.17)\n", + " ok = g == m[\"grade\"].values\n", + " return {\"cls\": cls, \"idx\": idx, \"g\": g, \"mud\": mud, \"conf\": conf, \"ok\": ok, \"mae\": float(np.mean(np.abs(idx - m[\"damage_idx\"].values))), \"under\": int((g < m[\"grade\"].values).sum()),\n", + " \"loss\": float(loss(m[\"grade\"].values, g).mean()), \"auto_n\": int(auto.sum()), \"auto_acc\": float(ok[auto].mean()) if auto.any() else np.nan}\n", + "\n", + "\n", + "# ---------------- 1) AAR: 운영 로그 분석(이동 구간 A, 360장) ----------------\n", + "mA, iA, kA = ops(60, 360)\n", + "rng = np.random.default_rng(0)\n", + "perm = rng.permutation(len(mA))\n", + "FA = feature_table(iA)\n", + "ev = evaluate(pl0, FA, mA, iA)\n", + "print(f\"[1] AAR 대상 운영 사진 {len(mA)}장(저조도·진흙 5%·정상 혼합): 등급 일치율 {ev['ok'].mean():.1%}, 손상도 MAE {ev['mae']:.2f}, 과소 판정 {ev['under']}장, 장당 오판 손실 {ev['loss']:.2f}만원\")\n", + "wrong = np.where(~ev[\"ok\"])[0]\n", + "\n", + "\n", + "def cause(i):\n", + " if ev[\"mud\"][i] >= 0.01: return \"오염 가림\"\n", + " if FA[\"gain\"].iloc[i] < 0.2: return \"저조도\"\n", + " if mA[\"cls\"][i] != \"none\" and mA[\"area_frac\"][i] < 0.02: return \"작은 결함 누락·과소\"\n", + " if ev[\"cls\"][i] != mA[\"cls\"][i]: return \"결함 유형 혼동\"\n", + " if min(abs(ev[\"idx\"][i] - 35), abs(ev[\"idx\"][i] - 65)) < 6: return \"등급 경계 근처\"\n", + " return \"기타\"\n", + "\n", + "\n", + "cs = pd.Series([cause(i) for i in wrong]).value_counts()\n", + "print(f\" 오판 {len(wrong)}장의 원인 분류:\", \", \".join(f\"{k} {v}장({v / len(wrong):.0%})\" for k, v in cs.items()))\n", + "print(f\" 오판 중 과소 판정(실제보다 낮은 등급) {ev['under']}장 / 과대 {int((ev['g'] > mA['grade'].values).sum())}장 -> 과소 판정이 손실 단가가 {LOSS_UNDER / LOSS_OVER:.1f}배 높아 우선 교정 대상\")\n", + "\n", + "# ---------------- 2) 재보정: 분할 임계값 -> 운영 데이터 재학습 -> 등급 경계 이동 ----------------\n", + "A1, A2 = perm[:240], perm[240:]\n", + "cache = [correct(iA[i]) for i in A1]\n", + "\n", + "\n", + "def mask_from(c, chroma, dt, rt, rim=1.45):\n", + " dark, rust, r = c < dt, (chroma > rt) & (c > 0.45), c > rim\n", + " m = dark | rust | ndi.binary_dilation(r, iterations=1) & ndi.binary_dilation(dark, iterations=3)\n", + " return ndi.binary_opening(m, structure=np.ones((2, 2))) if m.sum() > 12 else m\n", + "\n", + "\n", + "grid = []\n", + "for dt in (0.6, 0.7, 0.75, 0.8):\n", + " for rt in (0.44, 0.5, 0.54):\n", + " iou = np.mean([(mask_from(c, ch, dt, rt) & kA[i]).sum() / max((mask_from(c, ch, dt, rt) | kA[i]).sum(), 1) for (c, ch), i in zip(cache, A1)])\n", + " grid.append((dt, rt, iou))\n", + "gd = pd.DataFrame(grid, columns=[\"dark 임계값\", \"rust 임계값\", \"평균 IoU\"])\n", + "bst = gd.loc[gd[\"평균 IoU\"].idxmax()]\n", + "cur = gd[(gd[\"dark 임계값\"] == 0.7) & (gd[\"rust 임계값\"] == 0.5)][\"평균 IoU\"].iloc[0]\n", + "print(f\"\\n[2] 분할 임계값 재보정(운영 사진 240장 IoU): 현행(0.70, 0.50) {cur:.3f} -> 최적({bst['dark 임계값']:.2f}, {bst['rust 임계값']:.2f}) {bst['평균 IoU']:.3f}\")\n", + "SEGP[\"dark\"], SEGP[\"rust\"] = float(bst[\"dark 임계값\"]), float(bst[\"rust 임계값\"])\n", + "FA2 = feature_table(iA)\n", + "mm = pd.concat([mtr, mA.iloc[A1]], ignore_index=True)\n", + "pl1 = fit_pipeline(mm, pd.concat([Ftr, FA2.iloc[A1]], ignore_index=True)) # 운영 분포 사진 240장을 추가 학습\n", + "ev2 = evaluate(pl1, FA2.iloc[A2].reset_index(drop=True), mA.iloc[A2].reset_index(drop=True), iA[A2])\n", + "costs = []\n", + "for dlt in (0, 2, 4, 6, 8):\n", + " r = evaluate(pl1, FA2.iloc[A2].reset_index(drop=True), mA.iloc[A2].reset_index(drop=True), iA[A2], delta=dlt)\n", + " costs.append((dlt, r[\"loss\"], r[\"under\"], r[\"ok\"].mean()))\n", + "cd = pd.DataFrame(costs, columns=[\"경계 이동 delta\", \"장당 손실(만원)\", \"과소 판정\", \"일치율\"])\n", + "best_d = int(cd.loc[cd[\"장당 손실(만원)\"].idxmin(), \"경계 이동 delta\"])\n", + "print(\" 등급 경계 이동(보정용 120장, 과소 판정 손실 반영)\\n\" + cd.round(3).to_string(index=False))\n", + "if best_d == 0:\n", + " print(\" 경계를 낮추면 과대 판정이 늘어 손실이 오히려 커지므로 경계 이동은 채택하지 않는다(delta=0 유지). 과소 판정은 경계가 아니라 오염 가림에서 주로 발생한다.\")\n", + "else:\n", + " print(f\" 손실이 가장 작은 경계 이동 delta={best_d}점 채택(과소 판정을 줄이는 방향이며 사람 승인 후 적용)\")\n", + "\n", + "# ---------------- 3) 보류 구간 B(360장, 재보정에 쓰지 않음) 검증 ----------------\n", + "mB, iB, kB = ops(70, 360)\n", + "SEGP[\"dark\"], SEGP[\"rust\"] = 0.7, 0.5\n", + "FB0 = feature_table(iB)\n", + "e0 = evaluate(pl0, FB0, mB, iB)\n", + "SEGP[\"dark\"], SEGP[\"rust\"] = float(bst[\"dark 임계값\"]), float(bst[\"rust 임계값\"])\n", + "FB1 = feature_table(iB)\n", + "e1 = evaluate(pl1, FB1, mB, iB, delta=best_d)\n", + "tab = pd.DataFrame({\"재보정 전\": [e0[\"ok\"].mean(), e0[\"mae\"], e0[\"under\"], e0[\"loss\"], e0[\"auto_n\"], e0[\"auto_acc\"]], \"재보정 후\": [e1[\"ok\"].mean(), e1[\"mae\"], e1[\"under\"], e1[\"loss\"], e1[\"auto_n\"], e1[\"auto_acc\"]]},\n", + " index=[\"등급 일치율\", \"손상도 MAE\", \"과소 판정(장)\", \"장당 오판 손실(만원)\", \"자동 처리 건수\", \"자동 처리 일치율\"])\n", + "print(\"\\n[3] 보류 구간 B 검증\\n\" + tab.round(3).to_string())\n", + "print(f\" 오판 손실 {1 - e1['loss'] / e0['loss']:.1%} 감소, 과소 판정 {e0['under']} -> {e1['under']}장. 오염 가림 사진은 정보 자체가 가려져 재보정으로 해결되지 않으므로 재촬영이 필요하다.\")\n", + "\n", + "# ---------------- 4) ROI 3대 지표(교육용 가정) ----------------\n", + "N_YR, MIN_FULL, MIN_QUICK = 20000, 15.0, 4.0 # 연 판정 사진 수, 정밀 판독/간이 승인 소요(분)\n", + "WAGE, INV, OPEX = 1.2, 2500.0, 150.0 # 검사관 인건비(만원/시), 도입 비용, 연 운영비(만원)\n", + "full_b, full_a = 1 - e0[\"auto_n\"] / len(mB), 1 - e1[\"auto_n\"] / len(mB)\n", + "\n", + "\n", + "def roi(loss_b, loss_a, fb, fa, n=N_YR, inv=INV):\n", + " t_b = fb * MIN_FULL + (1 - fb) * MIN_QUICK\n", + " t_a = fa * MIN_FULL + (1 - fa) * MIN_QUICK\n", + " save_loss = (loss_b - loss_a) * n\n", + " save_time = (MIN_FULL - t_a) / 60 * WAGE * n # 관행(전수 정밀 판독) 대비 판독 인건비 절감\n", + " save = save_loss + save_time - OPEX\n", + " return save, 1 - t_a / MIN_FULL, inv / max(save / 12, 1e-9), save_loss, save_time\n", + "\n", + "\n", + "sv, tr, pb, sl, st = roi(e0[\"loss\"], e1[\"loss\"], full_b, full_a)\n", + "print(f\"\\n[4] ROI 3대 지표 [교육용 가정: 연 {N_YR}장, 정밀 판독 {MIN_FULL:.0f}분, 간이 승인 {MIN_QUICK:.0f}분, 인건비 {WAGE}만원/시, 도입 {INV:.0f}만원, 운영 {OPEX:.0f}만원/년]\")\n", + "print(f\" (1) 연간 비용 절감액 {sv:,.0f}만원(오판 손실 감소 {sl:,.0f} + 판독 인건비 {st:,.0f} - 운영비) (2) 판독 시간 단축률 {tr:.1%}(관행 전수 정밀 판독 대비) (3) 투자 회수 기간 {pb:.1f}개월\")\n", + "print(\" 산식: 절감액 = (재보정 전 - 후 장당 오판 손실) x 연 장수 + (정밀 판독 - 평균 판독 시간)/60 x 인건비 x 연 장수 - 운영비\")\n", + "print(\" 장수 민감도:\", \"; \".join(f\"{n}장 -> 절감 {roi(e0['loss'], e1['loss'], full_b, full_a, n=n)[0]:,.0f}만원, 회수 {roi(e0['loss'], e1['loss'], full_b, full_a, n=n)[2]:.0f}개월\" for n in (5000, 10000, 20000, 40000)))\n", + "print(\" 비관(재보정 효과 없음, 판독 시간 절감만):\", f\"절감 {roi(e0['loss'], e0['loss'], full_b, full_a)[0]:,.0f}만원, 회수 {roi(e0['loss'], e0['loss'], full_b, full_a)[2]:.0f}개월\")\n", + "print(\" 가정값이므로 재정·기획 부서와 실제 단가로 다시 산정해야 한다. 재보정된 임계값·경계는 정비감독관 승인 후 적용한다.\")\n", + "\n", + "fig, ax = plt.subplots(1, 3, figsize=(15, 4.2))\n", + "ax[0].bar(range(len(cs)), cs.values); ax[0].set_xticks(range(len(cs))); ax[0].set_xticklabels([f\"C{i + 1}\" for i in range(len(cs))]); ax[0].set_title(\"AAR: misjudgment causes (count)\")\n", + "ax[1].plot(cd[\"경계 이동 delta\"], cd[\"장당 손실(만원)\"], \"o-\"); ax[1].set_title(\"Loss per photo vs grade-boundary shift\"); ax[1].set_xlabel(\"delta\")\n", + "x = np.arange(3); ax[2].bar(x - .2, [e0[\"ok\"].mean() * 100, e0[\"under\"], e0[\"loss\"]], .4, label=\"before\"); ax[2].bar(x + .2, [e1[\"ok\"].mean() * 100, e1[\"under\"], e1[\"loss\"]], .4, label=\"after\")\n", + "ax[2].set_xticks(x); ax[2].set_xticklabels([\"accuracy %\", \"under-grade n\", \"loss/photo\"]); ax[2].legend(); ax[2].set_title(\"Hold-out B: before vs after\")\n", + "plt.tight_layout(); plt.show()\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 09 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + "[1] AAR 대상 운영 사진 360장(저조도·진흙 5%·정상 혼합): 등급 일치율 83.1%, 손상도 MAE 8.72, 과소 판정 22장, 장당 오판 손실 1.99만원\n", + " 오판 61장의 원인 분류: 오염 가림 29장(48%), 결함 유형 혼동 9장(15%), 저조도 8장(13%), 기타 8장(13%), 등급 경계 근처 7장(11%)\n", + " 오판 중 과소 판정(실제보다 낮은 등급) 22장 / 과대 39장 -> 과소 판정이 손실 단가가 3.3배 높아 우선 교정 대상\n", + "\n", + "[2] 분할 임계값 재보정(운영 사진 240장 IoU): 현행(0.70, 0.50) 0.495 -> 최적(0.75, 0.44) 0.528\n", + " 등급 경계 이동(보정용 120장, 과소 판정 손실 반영)\n", + " 경계 이동 delta 장당 손실(만원) 과소 판정 일치율\n", + " 0 1.483 5 0.850\n", + " 2 1.733 5 0.808\n", + " 4 1.933 5 0.775\n", + " 6 2.133 5 0.742\n", + " 8 1.950 3 0.733\n", + " 경계를 낮추면 과대 판정이 늘어 손실이 오히려 커지므로 경계 이동은 채택하지 않는다(delta=0 유지). 과소 판정은 경계가 아니라 오염 가림에서 주로 발생한다.\n", + "\n", + "[3] 보류 구간 B 검증\n", + " 재보정 전 재보정 후\n", + "등급 일치율 0.819 0.853\n", + "손상도 MAE 7.579 6.559\n", + "과소 판정(장) 26.000 25.000\n", + "장당 오판 손실(만원) 2.194 2.022\n", + "자동 처리 건수 150.000 151.000\n", + "자동 처리 일치율 0.980 0.980\n", + " 오판 손실 7.8% 감소, 과소 판정 26 -> 25장. 오염 가림 사진은 정보 자체가 가려져 재보정으로 해결되지 않으므로 재촬영이 필요하다.\n", + "\n", + "[4] ROI 3대 지표 [교육용 가정: 연 20000장, 정밀 판독 15분, 간이 승인 4분, 인건비 1.2만원/시, 도입 2500만원, 운영 150만원/년]\n", + " (1) 연간 비용 절감액 5,140만원(오판 손실 감소 3,444 + 판독 인건비 1,846 - 운영비) (2) 판독 시간 단축률 30.8%(관행 전수 정밀 판독 대비) (3) 투자 회수 기간 5.8개월\n", + " 산식: 절감액 = (재보정 전 - 후 장당 오판 손실) x 연 장수 + (정밀 판독 - 평균 판독 시간)/60 x 인건비 x 연 장수 - 운영비\n", + " 장수 민감도: 5000장 -> 절감 1,173만원, 회수 26개월; 10000장 -> 절감 2,495만원, 회수 12개월; 20000장 -> 절감 5,140만원, 회수 6개월; 40000장 -> 절감 10,430만원, 회수 3개월\n", + " 비관(재보정 효과 없음, 판독 시간 절감만): 절감 1,696만원, 회수 18개월\n", + " 가정값이므로 재정·기획 부서와 실제 단가로 다시 산정해야 한다. 재보정된 임계값·경계는 정비감독관 승인 후 적용한다.\n", + "```\n", + "
\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 10. 장비 결함 사진 기반 AI 외관 검사 및 손상도 자동 판정 도구 통합 완성\n", + "**소요시간**: 약 3.5시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "장비 결함 사진 기반 AI 외관 검사 및 손상도 자동 판정 도구를 완성한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **장비 결함 사진 기반 AI 외관 검사와 손상도 판정 도구를 통합하고 자동 재학습·운영 문서·자체 점검을 갖춘다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **신규 사진 240장을 추가해 정합성을 검증하고 분포 변화(PSI)를 점검한 뒤 챌린저 모델을 학습해 검증 MAE가 3% 이상 개선될 때만 승격 수행하고 검증하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `docs/qlt03_operations_guide.md 및 pipeline/qlt03_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": [ + "\"\"\"신규 사진 갱신, 정합성 검증, 분포 변화(PSI) 점검, 자동 재학습(챔피언/챌린저), 결함 판정·손상도·정비 등급 산출과 검사 대기·재촬영 분리, 제원 불일치 환류까지 한 번에 실행하는 통합 파이프라인을 완성하고 운영 문서 완비율·자체 점검·인터페이스 명세·브리핑 요약을 출력한다.\"\"\"\n", + "import importlib\n", + "import json\n", + "import sys\n", + "import numpy as np\n", + "import pandas as pd\n", + "import matplotlib.pyplot as plt\n", + "from sklearn.ensemble import RandomForestClassifier, GradientBoostingRegressor\n", + "from sklearn.linear_model import LinearRegression\n", + "from sklearn.model_selection import cross_val_predict\n", + "\n", + "# ---------------------------------------------------------------------------\n", + "# [교육용가상자료] 장비 외관 사진 생성기 (실제 장비·부대·위치 사진이 아니라 난수로 그린 64x64 합성 이미지)\n", + "# 도장된 금속 표면에 균열(crack)·부식(corrosion)·파손(damage)·누유(leak)를 그리고, 조명 편차와 진흙(mud) 오염을 섞는다.\n", + "# 이미지와 함께 장비 관리 기록(제원 요약)과 정답 결함 마스크·손상도·정비 등급을 만든다.\n", + "# ---------------------------------------------------------------------------\n", + "from scipy import ndimage as ndi\n", + "\n", + "SZ = 64\n", + "CLASSES = [\"none\", \"crack\", \"corrosion\", \"damage\", \"leak\"]\n", + "KO_CLS = {\"none\": \"결함 없음\", \"crack\": \"균열\", \"corrosion\": \"부식\", \"damage\": \"파손\", \"leak\": \"누유\"}\n", + "CLS_W = {\"none\": 0.0, \"crack\": 1.4, \"corrosion\": 1.0, \"damage\": 1.6, \"leak\": 0.6} # 손상도 계산용 결함 유형 가중치\n", + "GRADE_NAME = {1: \"현장정비\", 2: \"야전정비\", 3: \"창정비\"}\n", + "YY, XX = np.mgrid[0:SZ, 0:SZ]\n", + "\n", + "\n", + "def grade_of(idx):\n", + " return np.where(np.asarray(idx) >= 65, 3, np.where(np.asarray(idx) >= 35, 2, 1))\n", + "\n", + "\n", + "def blob(rng, cy, cx, r, rough=0.35):\n", + " \"\"\"불규칙한 원형 영역 마스크.\"\"\"\n", + " noise = ndi.gaussian_filter(rng.normal(size=(SZ, SZ)), 3)\n", + " noise = noise / (np.abs(noise).max() + 1e-9)\n", + " return (np.hypot(YY - cy, XX - cx) + rough * r * 2.2 * noise) < r\n", + "\n", + "\n", + "def draw(rng, cls, sev):\n", + " \"\"\"결함 유형·심각도 sev(0~1)에 맞는 정답 마스크와 색 정보를 만든다.\"\"\"\n", + " m = np.zeros((SZ, SZ), bool)\n", + " if cls == \"crack\":\n", + " y, x, ang = rng.uniform(12, 52), rng.uniform(12, 52), rng.uniform(0, np.pi)\n", + " for _ in range(int(20 + 40 * sev)):\n", + " ang += rng.normal(0, 0.35); y += np.sin(ang) * 1.2; x += np.cos(ang) * 1.2\n", + " if 0 <= int(y) < SZ and 0 <= int(x) < SZ:\n", + " m[int(y), int(x)] = True\n", + " m = ndi.binary_dilation(m, iterations=1 + int(sev > 0.6))\n", + " elif cls == \"corrosion\":\n", + " for _ in range(int(rng.integers(1, 4))):\n", + " m |= blob(rng, rng.uniform(14, 50), rng.uniform(14, 50), 3 + 9 * sev * rng.uniform(0.6, 1.1))\n", + " elif cls == \"damage\":\n", + " m = blob(rng, rng.uniform(20, 44), rng.uniform(20, 44), 6 + 12 * sev, 0.3)\n", + " elif cls == \"leak\":\n", + " x0, y0 = rng.uniform(16, 48), rng.uniform(8, 24)\n", + " w, ln = 2 + 4 * sev, 12 + 26 * sev\n", + " m = (np.abs(XX - x0 - 0.15 * (YY - y0)) < w * (1 - 0.4 * (YY - y0) / ln)) & (YY > y0) & (YY < y0 + ln)\n", + " return m\n", + "\n", + "\n", + "def make_images(n=600, seed=0, mud=0.0, p_none=0.25, dark=False):\n", + " \"\"\"가상 외관 사진 n장(+진흙 오염 mud 비율)과 관리 기록·정답. 반환: meta(DataFrame), imgs(n,64,64,3), masks(n,64,64).\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " imgs, masks, rows = np.zeros((n, SZ, SZ, 3), np.float32), np.zeros((n, SZ, SZ), bool), []\n", + " for i in range(n):\n", + " cls = \"none\" if rng.random() < p_none else str(rng.choice(CLASSES[1:], p=[0.25, 0.3, 0.25, 0.2]))\n", + " sev = 0.0 if cls == \"none\" else float(rng.beta(1.6, 2.0))\n", + " base = np.array([0.42, 0.46, 0.40]) * (1 + 0.5 * ndi.gaussian_filter(rng.normal(size=(SZ, SZ)), 8)[..., None] * 3)\n", + " img = base + rng.normal(0, 0.012, (SZ, SZ, 3))\n", + " m = draw(rng, cls, sev)\n", + " a = ndi.gaussian_filter(m.astype(float), 0.8)[..., None] # 경계를 부드럽게 섞는다\n", + " if cls == \"crack\":\n", + " col = img * rng.uniform(0.2, 0.45)\n", + " elif cls == \"corrosion\":\n", + " col = np.array([0.55, 0.30, 0.12]) * rng.uniform(0.75, 1.15) * (0.85 + 0.3 * rng.random((SZ, SZ, 1)))\n", + " elif cls == \"damage\":\n", + " col = np.ones_like(img) * np.array([0.34, 0.34, 0.36]) * (1.0 - 0.45 * sev) * rng.uniform(0.75, 1.0)\n", + " rim = m & ~ndi.binary_erosion(m, iterations=2)\n", + " a = np.maximum(a, 0.0); img = np.where(rim[..., None], 0.7, img)\n", + " elif cls == \"leak\":\n", + " col = np.ones_like(img) * np.array([0.10, 0.09, 0.08]) * rng.uniform(0.8, 2.6)\n", + " else:\n", + " col = img\n", + " img = img * (1 - a) + col * a\n", + " if rng.random() < 0.35: # 무해한 얼룩(결함 아님): 옅은 갈색 번짐\n", + " st = blob(rng, rng.uniform(5, 58), rng.uniform(5, 58), rng.uniform(6, 14), 0.5)\n", + " al = ndi.gaussian_filter(st.astype(float), 2.5)[..., None] * rng.uniform(0.15, 0.4)\n", + " img = img * (1 - al) + np.array([0.50, 0.40, 0.30]) * al\n", + " if rng.random() < 0.3: # 무해한 긴 스크래치(밝은 선)\n", + " y0, x0, ang = rng.uniform(5, 58), rng.uniform(5, 58), rng.uniform(0, np.pi)\n", + " for t in np.linspace(0, 25, 40):\n", + " yy, xx = int(y0 + t * np.sin(ang)), int(x0 + t * np.cos(ang))\n", + " if 0 <= yy < SZ and 0 <= xx < SZ:\n", + " img[yy, xx] = np.clip(img[yy, xx] * 1.5, 0, 1)\n", + " if mud > 0: # 진흙 오염: 결함 위를 덮을 수 있다(정답 마스크는 그대로)\n", + " mm = np.zeros((SZ, SZ), bool)\n", + " while mm.mean() < mud:\n", + " mm |= blob(rng, rng.uniform(0, SZ), rng.uniform(0, SZ), rng.uniform(5, 11), 0.5)\n", + " img[mm] = np.array([0.38, 0.30, 0.22]) * (0.85 + 0.3 * rng.random((mm.sum(), 1)))\n", + " gain = rng.uniform(0.3, 0.55) if dark else rng.uniform(0.5, 1.15)\n", + " ang = rng.uniform(0, 2 * np.pi)\n", + " light = gain * (1 + 0.3 * (np.cos(ang) * (XX - 32) + np.sin(ang) * (YY - 32)) / 32)\n", + " imgs[i] = np.clip(img * light[..., None] + rng.normal(0, 0.025, (SZ, SZ, 3)), 0, 1)\n", + " masks[i] = m\n", + " area = float(m.mean())\n", + " depth = {\"none\": 0.0, \"crack\": 0.3 + 3.0 * sev, \"corrosion\": 0.1 + 1.5 * sev, \"damage\": 1.0 + 8.0 * sev, \"leak\": 0.0}[cls] * rng.lognormal(0, 0.1)\n", + " idx = float(np.clip(100 * (1 - np.exp(-(CLS_W[cls] * area * 9 + 0.05 * depth))) + rng.normal(0, 1.5), 0, 100))\n", + " rows.append({\"img_id\": f\"IMG{seed:02d}{i:04d}\", \"cls\": cls, \"sev\": sev, \"area_frac\": area, \"depth_mm\": depth, \"damage_idx\": idx,\n", + " \"grade\": int(grade_of(idx)), \"gain\": gain, \"mud\": mud})\n", + " meta = pd.DataFrame(rows)\n", + " # 장비 관리 기록(제원 요약): 사진 판정과 대조할 값\n", + " age = rng.integers(1, 25, n)\n", + " meta[\"day\"] = np.sort(rng.integers(0, 90, n))\n", + " meta[\"equip_type\"] = rng.choice([\"vehicle\", \"generator\", \"comm\", \"trailer\"], n, p=[0.4, 0.2, 0.2, 0.2])\n", + " meta[\"mfg_year\"] = 2026 - age\n", + " meta[\"op_hours\"] = np.clip(rng.normal(180 * age, 400, n) + 8 * meta[\"damage_idx\"], 10, 20000)\n", + " meta[\"env\"] = rng.choice([\"coastal\", \"inland\"], n, p=[0.3, 0.7])\n", + " reg = np.clip(meta[\"grade\"] + rng.choice([-1, 0, 0, 0, 0, 0, 1, 2], n), 1, 3) # 장비 관리 기록상 직전 정비 등급\n", + " meta[\"reg_grade\"] = reg\n", + " meta[\"emergency\"] = rng.random(n) < 0.04 # 긴급 점검 요청(화질 불량이어도 보존)\n", + " return meta, imgs, masks\n", + "# ---- 전처리(조명 보정·표준화)와 결함 후보 분할, 특징 추출 (cv2 대신 numpy·scipy 사용) ----\n", + "def correct(img):\n", + " \"\"\"조명 보정: 밝기에 평면(조명 기울기)을 적합해 나눈다. 어두운 결함 화소는 제외하고 두 번 반복 적합한다. 반환: 보정 밝기(정상 면 약 1), 색 비율.\"\"\"\n", + " g = img.mean(axis=2)\n", + " A = np.c_[np.ones(SZ * SZ), XX.ravel() / SZ, YY.ravel() / SZ]\n", + " keep = np.ones(SZ * SZ, bool)\n", + " for _ in range(3):\n", + " coef, *_ = np.linalg.lstsq(A[keep], g.ravel()[keep], rcond=None)\n", + " plane = (A @ coef).reshape(SZ, SZ)\n", + " keep = (np.abs(g - plane) < 0.08 * np.maximum(plane, 1e-3)).ravel()\n", + " if keep.sum() < 200:\n", + " keep[:] = True\n", + " c = g / np.maximum(plane, 1e-3)\n", + " chroma = img[..., 0] / (img.sum(axis=2) + 1e-6)\n", + " return c, chroma\n", + "\n", + "\n", + "SEGP = {\"dark\": 0.7, \"rust\": 0.5, \"rim\": 1.45} # 분할 임계값(어두움 보정 밝기, 붉은 비율, 밝은 테두리): 재보정 대상\n", + "\n", + "\n", + "def segment(img):\n", + " \"\"\"결함 후보 마스크: 어두운 영역(균열·누유·파손) + 붉은 갈색 영역(부식) + 밝은 테두리(파손).\"\"\"\n", + " c, chroma = correct(img)\n", + " dark = c < SEGP[\"dark\"]\n", + " rust = (chroma > SEGP[\"rust\"]) & (c > 0.45)\n", + " rim = c > SEGP[\"rim\"]\n", + " m = dark | rust | ndi.binary_dilation(rim, iterations=1) & ndi.binary_dilation(dark, iterations=3)\n", + " m = ndi.binary_opening(m, structure=np.ones((2, 2))) if m.sum() > 12 else m\n", + " return m, c, chroma, dark, rust, rim\n", + "\n", + "\n", + "def extract(img):\n", + " \"\"\"분할 결과와 색·모양에서 24개 특징을 만든다.\"\"\"\n", + " m, c, chroma, dark, rust, rim = segment(img)\n", + " lab, n = ndi.label(m)\n", + " area = float(m.sum())\n", + " if n:\n", + " sizes = ndi.sum(m, lab, range(1, n + 1))\n", + " k = int(np.argmax(sizes)) + 1\n", + " sl = ndi.find_objects(lab)[k - 1]\n", + " h, w = sl[0].stop - sl[0].start, sl[1].stop - sl[1].start\n", + " big = float(sizes.max())\n", + " elong, fill = max(h, w) / max(min(h, w), 1), big / (h * w)\n", + " else:\n", + " big, elong, fill = 0.0, 1.0, 0.0\n", + " gy, gx = np.gradient(c)\n", + " mag = np.hypot(gx, gy)\n", + " inside = m.sum() > 0\n", + " return {\"area_px\": area, \"area_frac\": area / SZ ** 2, \"n_comp\": float(n), \"big_px\": big, \"elong\": elong, \"fill\": fill,\n", + " \"dark_frac\": float(dark.mean()), \"rust_frac\": float(rust.mean()), \"rim_frac\": float(rim.mean()),\n", + " \"c_mean\": float(c.mean()), \"c_std\": float(c.std()), \"c_min\": float(c.min()), \"edge_mean\": float(mag.mean()), \"edge_p95\": float(np.quantile(mag, 0.95)),\n", + " \"chroma_mean\": float(chroma.mean()), \"chroma_max\": float(chroma.max()),\n", + " \"in_dark\": float(c[m].mean()) if inside else 1.0, \"in_chroma\": float(chroma[m].mean()) if inside else float(chroma.mean()),\n", + " \"ring_contrast\": float(c[ndi.binary_dilation(m, iterations=3) & ~m].mean() - c[m].mean()) if inside and (~m).any() else 0.0,\n", + " \"thin\": float(1 - fill), \"gain\": float(img.mean()), \"sat\": float((img.max(axis=2) - img.min(axis=2)).mean()),\n", + " \"cy\": float(ndi.center_of_mass(m)[0] / SZ) if inside else 0.5, \"cx\": float(ndi.center_of_mass(m)[1] / SZ) if inside else 0.5}\n", + "\n", + "\n", + "def feature_table(imgs):\n", + " return pd.DataFrame([extract(im) for im in imgs])\n", + "# ---- 판정 파이프라인: 결함 유형 분류 + 손상도 회귀(분류 확률 포함) -> 정비 등급 ----\n", + "def fit_pipeline(meta, F):\n", + " \"\"\"결함 유형 분류기와 손상도 회귀기를 학습한다. 회귀기 입력의 분류 확률은 교차 예측으로 만들어 누설을 막는다.\"\"\"\n", + " clf = RandomForestClassifier(n_estimators=150, random_state=0, n_jobs=1)\n", + " P = cross_val_predict(clf, F, meta[\"cls\"], cv=4, method=\"predict_proba\")\n", + " clf.fit(F, meta[\"cls\"])\n", + " reg = GradientBoostingRegressor(n_estimators=200, max_depth=3, learning_rate=0.06, random_state=0).fit(np.c_[F.values, P], meta[\"damage_idx\"])\n", + " return {\"clf\": clf, \"reg\": reg, \"cols\": list(F.columns)}\n", + "\n", + "\n", + "def predict_pipeline(pl, F):\n", + " X = F[pl[\"cols\"]]\n", + " P = pl[\"clf\"].predict_proba(X)\n", + " idx = np.clip(pl[\"reg\"].predict(np.c_[X.values, P]), 0, 100)\n", + " return pl[\"clf\"].classes_[P.argmax(axis=1)], P, idx, P.max(axis=1)\n", + "# ---- 사진 품질 게이트: 진흙·오염 가림 추정과 판정 신뢰도 지수 ----\n", + "def mud_est(img):\n", + " \"\"\"진흙(갈색, 채도 낮은 붉은 기) 화소 비율을 추정한다. 3x3 평균으로 잡음을 줄인 뒤 색 비율로 판단하며, 실제 가림 비율보다 작게 나오지만 순서는 유지된다.\"\"\"\n", + " sm = ndi.uniform_filter(img, size=(3, 3, 1))\n", + " s = sm.sum(axis=2) + 1e-6\n", + " rr, gg = sm[..., 0] / s, sm[..., 1] / s\n", + " return float(((rr > 0.385) & (rr < 0.46) & (gg < 0.325)).mean())\n", + "\n", + "\n", + "def confidence(P, idx, F, mud):\n", + " \"\"\"판정 신뢰도 지수 0~100 = 50 x 분류 확률 최대값 + 35 x 등급 경계까지 거리(8점에서 포화) + 15 x 사진 품질. 오염 의심(mud 추정 1% 이상)이면 상한 75로 제한한다.\"\"\"\n", + " bd = np.minimum(np.abs(idx - 35), np.abs(idx - 65))\n", + " qual = np.clip(1.0 - np.clip(0.55 - F[\"gain\"].values, 0, None) * 2 - np.clip(0.9 - F[\"c_mean\"].values, 0, None), 0, 1)\n", + " c = 50 * P.max(axis=1) + 35 * np.clip(bd / 8, 0, 1) + 15 * qual\n", + " return np.where(np.asarray(mud) >= 0.01, np.minimum(c, 75.0), c)\n", + "\n", + "libs = [\"numpy\", \"pandas\", \"scipy\", \"sklearn\", \"matplotlib\"]\n", + "env_ok = sum(importlib.util.find_spec(m) is not None for m in libs)\n", + "print(f\"[0] 환경 점검: Python {sys.version_info.major}.{sys.version_info.minor}, 필수 모듈 {env_ok}/{len(libs)}\")\n", + "\n", + "# ---------------- 1) 신규 사진 갱신·정합성 검증(미션 1~2 요약) ----------------\n", + "m_old, i_old, k_old = make_images(600, seed=1) # 기존 600장\n", + "parts = [make_images(80, seed=80, dark=True), make_images(80, seed=81, mud=0.05), make_images(80, seed=82)]\n", + "m_new = pd.concat([p[0] for p in parts], ignore_index=True); i_new = np.concatenate([p[1] for p in parts]); k_new = np.concatenate([p[2] for p in parts])\n", + "m_new[\"img_id\"] = [\"NEW\" + x[3:] for x in m_new[\"img_id\"]] # 신규 사진 ID 구분\n", + "meta = pd.concat([m_old, m_new], ignore_index=True); imgs = np.concatenate([i_old, i_new]); masks = np.concatenate([k_old, k_new])\n", + "chk = {\"사진 ID 유일\": not meta[\"img_id\"].duplicated().any(), \"결측 0(제원·라벨)\": int(meta[[\"equip_type\", \"mfg_year\", \"op_hours\", \"env\", \"reg_grade\"]].isna().sum().sum()) == 0,\n", + " \"화소 범위 0~1\": bool((imgs.min() >= 0) and (imgs.max() <= 1)), \"해상도 64x64x3\": imgs.shape[1:] == (64, 64, 3), \"제조연도 가능 범위\": bool(meta[\"mfg_year\"].between(1990, 2026).all()),\n", + " \"마스크 면적 = 라벨 면적\": bool(np.allclose(masks.mean(axis=(1, 2)), meta[\"area_frac\"], atol=1e-6)), \"손상도-등급 일치\": bool((grade_of(meta[\"damage_idx\"]) == meta[\"grade\"]).all())}\n", + "print(f\"[1] 신규 240장 포함 {len(meta)}장 정합성 검증 {sum(chk.values())}/{len(chk)} 통과, 긴급 점검 사진 {int(meta['emergency'].sum())}장 보존\")\n", + "F = feature_table(imgs)\n", + "F[\"mud\"] = [mud_est(x) for x in imgs]\n", + "n_old = len(m_old)\n", + "\n", + "# ---------------- 2) 분포 변화 점검(PSI) 및 자동 재학습 ----------------\n", + "def psi(a, b, bins=8):\n", + " q = np.unique(np.quantile(a, np.linspace(0, 1, bins + 1)))\n", + " q[0], q[-1] = -np.inf, np.inf\n", + " pa, pb = np.histogram(a, q)[0] / len(a) + 1e-4, np.histogram(b, q)[0] / len(b) + 1e-4\n", + " return float(np.sum((pa - pb) * np.log(pa / pb)))\n", + "\n", + "\n", + "drift = {c: psi(F[c][:n_old], F[c][n_old:]) for c in [\"gain\", \"c_mean\", \"c_std\", \"rust_frac\", \"area_frac\", \"mud\"]}\n", + "worst = max(drift, key=drift.get)\n", + "print(\"[2] 분포 변화 PSI(기존 600 vs 신규 240):\", \", \".join(f\"{k} {v:.2f}\" for k, v in drift.items()), f\"-> 가장 큰 변화 {worst} (0.2 이상이면 재학습 검토)\")\n", + "rng = np.random.default_rng(0)\n", + "new_idx = rng.permutation(np.arange(n_old, len(meta)))\n", + "tr_new, hold = new_idx[:160], new_idx[160:] # 신규 중 160장은 재학습용, 80장은 검증용\n", + "cols = [c for c in F.columns if c != \"mud\"]\n", + "\n", + "\n", + "def fit_on(ix):\n", + " return fit_pipeline(meta.iloc[ix].reset_index(drop=True), F.iloc[ix][cols].reset_index(drop=True))\n", + "\n", + "\n", + "def score(pl, ix):\n", + " cls, P, idx, _ = predict_pipeline(pl, F.iloc[ix][cols])\n", + " y = meta[\"damage_idx\"].values[ix]\n", + " return float(np.mean(np.abs(y - idx))), float(np.mean(grade_of(idx) == meta[\"grade\"].values[ix]))\n", + "\n", + "\n", + "champ = fit_on(np.arange(n_old))\n", + "chall = fit_on(np.r_[np.arange(n_old), tr_new])\n", + "a, b = score(champ, hold), score(chall, hold)\n", + "promote = (a[0] - b[0]) / a[0] >= 0.03\n", + "drifted = max(drift.values()) >= 0.2\n", + "print(f\" 검증 80장(신규 분포): 챔피언 MAE {a[0]:.2f}·등급 일치 {a[1]:.1%} vs 챌린저 MAE {b[0]:.2f}·등급 일치 {b[1]:.1%} -> {'챌린저 승격' if promote else '기존 모델 유지'}(MAE 3% 이상 개선일 때만 승격)\")\n", + "model = chall if promote else champ\n", + "\n", + "# ---------------- 3) 신규 사진 판정과 검사 대기 분리 ----------------\n", + "Xn = F.iloc[n_old:][cols].reset_index(drop=True)\n", + "cls, P, idx, _ = predict_pipeline(model, Xn)\n", + "mud = F[\"mud\"].values[n_old:]\n", + "conf = confidence(P, idx, Xn, mud)\n", + "res = pd.DataFrame({\"사진\": meta[\"img_id\"].values[n_old:], \"판정 유형\": [KO_CLS[c] for c in cls], \"손상도\": idx, \"AI 등급\": grade_of(idx), \"신뢰도\": conf, \"오염\": mud, \"기록 등급\": meta[\"reg_grade\"].values[n_old:], \"실제 등급\": meta[\"grade\"].values[n_old:]})\n", + "res[\"처리\"] = np.select([(mud >= 0.03) | (Xn[\"gain\"].values < 0.17), conf >= 80], [\"재촬영 요구\", \"검사 대기(간이 승인 후보)\"], \"검사 대기(정밀 판독)\")\n", + "res[\"제원 신호\"] = np.abs(res[\"AI 등급\"] - res[\"기록 등급\"]) >= 2\n", + "print(f\"\\n[3] 신규 {len(res)}장 판정 결과 [교육용가상자료] (상위 6건)\")\n", + "print(res.head(6)[[\"사진\", \"판정 유형\", \"손상도\", \"AI 등급\", \"신뢰도\", \"오염\", \"처리\"]].round(2).to_string(index=False))\n", + "vc = res[\"처리\"].value_counts()\n", + "print(\" 처리 구분:\", vc.to_dict(), \"/ 자동 확정 0건(모든 판정은 검사관 확인과 정비감독관 결재 필요)\")\n", + "auto = res[\"처리\"].str.contains(\"간이\")\n", + "print(f\" 간이 승인 후보 {int(auto.sum())}장의 등급 정답 일치율 {(res['AI 등급'] == res['실제 등급'])[auto].mean():.1%}, 전체 일치율 {(res['AI 등급'] == res['실제 등급']).mean():.1%}\")\n", + "print(f\" 제원 환류: AI 등급과 관리 기록 등급이 2단계 이상 다른 {int(res['제원 신호'].sum())}장은 제원 수정 요청 후보(자동 수정 금지, 담당자 확인)\")\n", + "\n", + "# ---------------- 4) 운영 문서 완비율·인터페이스 명세 ----------------\n", + "docs = {\"데이터 사전·스키마(M1)\": 24, \"정제 규칙 11종(M2)\": 11, \"Baseline·EDA(M3)\": 8, \"분할·손상도 모델(M4)\": 24, \"등급·신뢰도 엔진(M5)\": 3, \"판정 인터페이스·설명(M6)\": 5,\n", + " \"승인 절차·권한(M7)\": 4, \"오염 스트레스·한계치(M8)\": 7, \"AAR·ROI(M9)\": 4, \"운영·재학습 절차(M10)\": 6, \"장애 대응\": 5, \"인터페이스 가이드\": 4}\n", + "done = sum(v > 0 for v in docs.values())\n", + "iface = {\"input\": {\"photo\": [\"img_id:str\", \"image:64x64x3 float\", \"equip_type:str\", \"reg_grade:int\"], \"format\": \"image+json\"},\n", + " \"output\": {\"result\": [\"img_id\", \"defect\", \"damage_idx\", \"grade\", \"confidence\", \"mud\", \"state\"], \"approval\": \"PENDING_ONLY\"}, \"auth\": \"role-based(가상)\", \"note\": \"상위 군수정보체계는 가상 인터페이스로만 정의\"}\n", + "print(f\"\\n[4] 운영 문서 {len(docs)}개 항목 중 작성 완료 {done}개 ({done / len(docs):.0%}) / 인터페이스 명세(JSON):\")\n", + "print(\" \", json.dumps(iface, ensure_ascii=False))\n", + "\n", + "# ---------------- 5) 자체 점검·브리핑 ----------------\n", + "ev_ix = np.arange(n_old, len(meta))\n", + "y = meta[\"damage_idx\"].values[ev_ix]\n", + "fin_mae = float(np.mean(np.abs(y - idx)))\n", + "big = y >= 10\n", + "fin_mape = float(np.mean(np.abs(y[big] - idx[big]) / y[big]) * 100)\n", + "base = LinearRegression().fit(F.iloc[:n_old][[\"area_px\"]], meta[\"damage_idx\"][:n_old])\n", + "base_mae = float(np.mean(np.abs(y - np.clip(base.predict(F.iloc[n_old:][[\"area_px\"]]), 0, 100))))\n", + "checks = [(\"환경 점검 통과\", env_ok == len(libs)), (\"데이터 정합성 전 항목 통과\", all(chk.values())), (\"재학습 파이프라인 정상 실행(챔피언/챌린저 판정 출력)\", a[0] > 0 and b[0] > 0),\n", + " (\"손상도 MAPE 25% 이하(점수 10 이상)\", fin_mape <= 25), (\"면적 기준 모델 대비 MAE 20% 이상 개선\", fin_mae <= 0.8 * base_mae),\n", + " (\"자동 확정 0건(검사 대기·재촬영 분리)\", bool(res[\"처리\"].str.contains(\"검사 대기|재촬영\").all())), (\"오염 의심 사진 전부 재촬영 또는 정밀 판독으로 분리\", bool(res.loc[res[\"오염\"] >= 0.01, \"처리\"].ne(\"검사 대기(간이 승인 후보)\").all())),\n", + " (\"운영 문서 100% 완비\", done == len(docs))]\n", + "print(\"\\n[5] 자체 점검\")\n", + "for n, ok in checks:\n", + " print(f\" [{'PASS' if ok else 'FAIL'}] {n}\")\n", + "print(\"\\n[6] 브리핑 요약\")\n", + "print(f\" - 신규 사진 240장으로 분포 변화를 점검({worst} PSI {drift[worst]:.2f}, {'재학습 검토 수준' if drifted else '경미'})하고 재학습 판단을 자동 수행했다({'챌린저 승격' if promote else '기존 모델 유지'}).\")\n", + "print(f\" - 신규 사진 손상도 MAE {base_mae:.1f}(면적 기준) -> {fin_mae:.1f}, MAPE {fin_mape:.1f}%. 처리 구분: \" + \", \".join(f\"{k} {v}장\" for k, v in vc.items()) + \".\")\n", + "print(\" - 최종 정비 등급은 검사관·정비감독관 결재로 확정하며 AI 판정은 자동 확정되지 않는다. 오염 사진은 재촬영을 요구했다.\")\n", + "\n", + "fig, ax = plt.subplots(1, 2, figsize=(12, 4))\n", + "ax[0].bar(list(drift), list(drift.values())); ax[0].axhline(0.2, c=\"r\", ls=\"--\"); ax[0].set_title(\"Feature drift (PSI), old vs new photos\"); ax[0].tick_params(axis=\"x\", rotation=30)\n", + "ax[1].scatter(y, idx, s=8, c=np.where(res[\"처리\"] == \"재촬영 요구\", \"r\", \"g\")); ax[1].plot([0, 100], [0, 100], \"k--\"); ax[1].set_title(\"New photos: damage index true vs predicted (red = retake)\")\n", + "plt.suptitle(\"QLT03 integrated pipeline (synthetic photos)\"); plt.tight_layout(); plt.show()\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 10 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + "[0] 환경 점검: Python 3.11, 필수 모듈 5/5\n", + "[1] 신규 240장 포함 840장 정합성 검증 7/7 통과, 긴급 점검 사진 38장 보존\n", + "[2] 분포 변화 PSI(기존 600 vs 신규 240): gain 0.56, c_mean 0.10, c_std 0.26, rust_frac 0.39, area_frac 0.07, mud 0.79 -> 가장 큰 변화 mud (0.2 이상이면 재학습 검토)\n", + " 검증 80장(신규 분포): 챔피언 MAE 8.79·등급 일치 82.5% vs 챌린저 MAE 6.86·등급 일치 86.2% -> 챌린저 승격(MAE 3% 이상 개선일 때만 승격)\n", + "\n", + "[3] 신규 240장 판정 결과 [교육용가상자료] (상위 6건)\n", + " 사진 판정 유형 손상도 AI 등급 신뢰도 오염 처리\n", + "NEW800000 균열 34.85 1 53.85 0.0 재촬영 요구\n", + "NEW800001 부식 30.58 1 72.79 0.0 검사 대기(정밀 판독)\n", + "NEW800002 결함 없음 1.25 1 87.85 0.0 검사 대기(간이 승인 후보)\n", + "NEW800003 결함 없음 9.30 1 86.00 0.0 검사 대기(간이 승인 후보)\n", + "NEW800004 부식 41.78 2 81.09 0.0 재촬영 요구\n", + "NEW800005 부식 36.30 2 58.39 0.0 재촬영 요구\n", + " 처리 구분: {'검사 대기(간이 승인 후보)': 112, '검사 대기(정밀 판독)': 86, '재촬영 요구': 42} / 자동 확정 0건(모든 판정은 검사관 확인과 정비감독관 결재 필요)\n", + " 간이 승인 후보 112장의 등급 정답 일치율 99.1%, 전체 일치율 90.4%\n", + " 제원 환류: AI 등급과 관리 기록 등급이 2단계 이상 다른 19장은 제원 수정 요청 후보(자동 수정 금지, 담당자 확인)\n", + "\n", + "[4] 운영 문서 12개 항목 중 작성 완료 12개 (100%) / 인터페이스 명세(JSON):\n", + " {\"input\": {\"photo\": [\"img_id:str\", \"image:64x64x3 float\", \"equip_type:str\", \"reg_grade:int\"], \"format\": \"image+json\"}, \"output\": {\"result\": [\"img_id\", \"defect\", \"damage_idx\", \"grade\", \"confidence\", \"mud\", \"state\"], \"approval\": \"PENDING_ONLY\"}, \"auth\": \"role-based(가상)\", \"note\": \"상위 군수정보체계는 가상 인터페이스로만 정의\"}\n", + "\n", + "[5] 자체 점검\n", + " [PASS] 환경 점검 통과\n", + " [PASS] 데이터 정합성 전 항목 통과\n", + " [PASS] 재학습 파이프라인 정상 실행(챔피언/챌린저 판정 출력)\n", + " [PASS] 손상도 MAPE 25% 이하(점수 10 이상)\n", + " [PASS] 면적 기준 모델 대비 MAE 20% 이상 개선\n", + " [PASS] 자동 확정 0건(검사 대기·재촬영 분리)\n", + " [PASS] 오염 의심 사진 전부 재촬영 또는 정밀 판독으로 분리\n", + " [PASS] 운영 문서 100% 완비\n", + "\n", + "[6] 브리핑 요약\n", + " - 신규 사진 240장으로 분포 변화를 점검(mud PSI 0.79, 재학습 검토 수준)하고 재학습 판단을 자동 수행했다(챌린저 승격).\n", + " - 신규 사진 손상도 MAE 13.0(면적 기준) -> 4.5, MAPE 10.6%. 처리 구분: 검사 대기(간이 승인 후보) 112장, 검사 대기(정밀 판독) 86장, 재촬영 요구 42장.\n", + " - 최종 정비 등급은 검사관·정비감독관 결재로 확정하며 AI 판정은 자동 확정되지 않는다. 오염 사진은 재촬영을 요구했다.\n", + "```\n", + "
\n" + ] + } + ], + "metadata": { + "colab": { + "provenance": [] + }, + "kernelspec": { + "display_name": "Python 3", + "name": "python3" + }, + "language_info": { + "name": "python" + } + }, + "nbformat": 4, + "nbformat_minor": 0 +} \ No newline at end of file diff --git a/uploads/QLT03/QLT03_student.ipynb b/uploads/QLT03/QLT03_student.ipynb new file mode 100644 index 0000000..857ef38 --- /dev/null +++ b/uploads/QLT03/QLT03_student.ipynb @@ -0,0 +1,3898 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# 🎯 [QLT03] 군수품 제원정보 이상치 자동검증 Tool\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/QLT03/QLT03_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", + "군수장비의 외관 검사는 검사관의 눈과 경험에 크게 의존해 판정 편차가 생기고 검사 병목이 되기 쉽다. 본 프로젝트는 규격·제원 정합성과 검사 병목, 이상 신호, 설명 근거, 품질 개선 효과를 함께 익히도록, 교육용 가상 외관 사진(균열·부식·파손·누유)을 코드로 만들어 결함 판정과 손상도·정비 등급 산정을 자동화한다. 학습자는 사진 데이터셋과 제원 기록 정의, 조명 보정·해상도 표준화·박스 정제, 1차 판별 Baseline, 분할 기반 면적·깊이·손상도 모델, 정비 등급 분류와 신뢰도 지수, 사진 업로드 판정 화면과 근거 설명, 검사관 수동 보정과 결재(HITL), 진흙 혼입 스트레스 시험, AAR 재보정과 ROI, 재학습 파이프라인 통합을 차례로 수행한다. 최종 목표는 사진으로 결함과 손상도를 판정하고 사진 판정과 관리 기록의 불일치를 이상 신호로 환류하되, 최종 정비 등급은 검사관이 확정하는 AI 외관 검사 도구를 완성하는 것이다.\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. 장비 외관 결함(균열, 부식, 파손, 누유) 고해상도 사진 데이터셋 및 라벨링 정의\n", + "**소요시간**: 약 1.5시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "장비 외관 결함(균열, 부식, 파손) 사진 데이터셋 및 라벨링을 정의한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **장비 외관 결함(균열·부식·파손·누유) 사진 데이터셋과 라벨링 기준을 정의하고, 검사 업무 프로세스 맵과 피처 데이터 사전을 만든다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **사진, 장비 제원(관리 기록), 결함 라벨, 판정 결과의 스키마와 허용값을 정하고 결함 유형별 라벨링 기준을 문장으로 정리하고 규격화하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `docs/qlt03_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": [ + "\"\"\"장비 외관 결함(균열·부식·파손·누유) 사진 데이터셋의 라벨링 기준, 사진·장비 제원(관리 기록)·결함 라벨·판정 결과의 스키마와 데이터 딕셔너리(피처 20개 이상), 업무 프로세스 맵을 정의하고 가상 샘플로 제원 정합성을 검증한다.\"\"\"\n", + "import json\n", + "import re\n", + "import numpy as np\n", + "import pandas as pd\n", + "\n", + "# 1) 업무 프로세스 맵: 촬영에서 정비 등급 승인·제원 환류까지\n", + "PROCESS = [(\"촬영\", \"사진 ID, 촬영일, 촬영 조건(조명·오염)\"), (\"제원 대조\", \"장비 식별코드, 제조연도, 가동시간, 기록상 정비 등급\"),\n", + " (\"전처리\", \"조명 보정, 해상도 표준화, 결함 영역(바운딩 박스)\"), (\"결함 판별\", \"결함 유형, 결함 면적, 추정 깊이\"),\n", + " (\"손상도 산정\", \"손상도 점수(0~100), 판정 신뢰도\"), (\"검사관 보정\", \"수동 보정 여부, 보정 사유\"),\n", + " (\"정비 등급 승인\", \"현장·야전·창정비 등급, 승인자, 승인 시각\"), (\"제원 환류\", \"판정 결과와 기록상 등급의 불일치, 제원 수정 요청\"), (\"사후검토(AAR)\", \"오판 원인, 파라미터 재보정 이력\")]\n", + "\n", + "# 2) 라벨링 기준(데이터 딕셔너리의 일부)\n", + "LABEL_RULES = {\"crack\": \"선형 어두운 균열, 길이 10px 이상\", \"corrosion\": \"붉은 갈색 면, 면적 12px 이상\", \"damage\": \"함몰·파단 윤곽(밝은 테두리 포함)\",\n", + " \"leak\": \"어둡고 번지는 액체 자국(아래 방향 흐름)\", \"none\": \"결함 없음(무해한 얼룩·긁힘은 결함으로 라벨하지 않음)\"}\n", + "\n", + "# 3) 스키마: 그룹 / 필드 / 자료형 / 규칙 / 설명\n", + "S = {\n", + " \"photo\": {\n", + " \"img_id\": (str, {\"pattern\": r\"^IMG\\d{6}$\"}, \"사진 ID\"),\n", + " \"shot_day\": (int, {\"min\": 0, \"max\": 400}, \"촬영일(점검 시작 후 경과일)\"),\n", + " \"width\": (int, {\"enum\": [64]}, \"표준 해상도 가로(px)\"),\n", + " \"height\": (int, {\"enum\": [64]}, \"표준 해상도 세로(px)\"),\n", + " \"channels\": (int, {\"enum\": [3]}, \"채널 수(RGB)\"),\n", + " \"brightness\": (float, {\"min\": 0.05, \"max\": 1.0}, \"평균 밝기(0~1)\"),\n", + " \"mud_ratio\": (float, {\"min\": 0, \"max\": 1}, \"진흙·오염 가림 비율(추정)\"),\n", + " },\n", + " \"spec\": {\n", + " \"equip_id\": (str, {\"pattern\": r\"^VX-\\d{4}-\\d{4}$\"}, \"장비 식별코드(국방 표준 코드 형식을 모사한 가상 코드)\"),\n", + " \"equip_type\": (str, {\"enum\": [\"vehicle\", \"generator\", \"comm\", \"trailer\"]}, \"장비 구분\"),\n", + " \"mfg_year\": (int, {\"min\": 1990, \"max\": 2026}, \"제조연도(가상)\"),\n", + " \"op_hours\": (float, {\"min\": 0, \"max\": 30000}, \"누적 가동시간(h)\"),\n", + " \"env\": (str, {\"enum\": [\"coastal\", \"inland\"]}, \"운용 환경\"),\n", + " \"reg_grade\": (int, {\"enum\": [1, 2, 3]}, \"관리 기록상 직전 정비 등급(1 현장, 2 야전, 3 창정비)\"),\n", + " },\n", + " \"label\": {\n", + " \"defect_class\": (str, {\"enum\": [\"none\", \"crack\", \"corrosion\", \"damage\", \"leak\"]}, \"결함 유형\"),\n", + " \"bbox_x\": (int, {\"min\": 0, \"max\": 63}, \"결함 박스 좌상단 x\"),\n", + " \"bbox_y\": (int, {\"min\": 0, \"max\": 63}, \"결함 박스 좌상단 y\"),\n", + " \"bbox_w\": (int, {\"min\": 1, \"max\": 64}, \"결함 박스 폭\"),\n", + " \"bbox_h\": (int, {\"min\": 1, \"max\": 64}, \"결함 박스 높이\"),\n", + " \"emergency\": (bool, {}, \"긴급 점검 요청 여부(화질 불량이어도 보존)\"),\n", + " },\n", + " \"result\": {\n", + " \"area_frac\": (float, {\"min\": 0, \"max\": 1}, \"결함 면적 비율 - 예측대상 1\"),\n", + " \"depth_mm\": (float, {\"min\": 0, \"max\": 30}, \"추정 결함 깊이 - 예측대상 2\"),\n", + " \"damage_idx\": (float, {\"min\": 0, \"max\": 100}, \"손상도 점수 - 예측대상 3\"),\n", + " \"grade\": (int, {\"enum\": [1, 2, 3]}, \"정비 등급(현장/야전/창정비) - 예측대상 4\"),\n", + " \"confidence\": (float, {\"min\": 0, \"max\": 1}, \"판정 신뢰도 지수\"),\n", + " },\n", + "}\n", + "\n", + "\n", + "def check(name, v, typ, rule):\n", + " # [TODO : 학생 실습 1] 장비 외관 결함(균열, 부식, 파손, 누유) 고해상도 사진 데이터셋 및 라벨링 정의 관련 핵심 로직을 구현하세요.\n", + " # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n", + " # <<< 여기에 코드를 작성하세요 >>>\n", + " pass # 작성 후 pass를 제거하세요.\n", + "def validate(rec):\n", + " # [TODO : 학생 실습 1] 장비 외관 결함(균열, 부식, 파손, 누유) 고해상도 사진 데이터셋 및 라벨링 정의 관련 핵심 로직을 구현하세요.\n", + " # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n", + " # <<< 여기에 코드를 작성하세요 >>>\n", + " pass # 작성 후 pass를 제거하세요.\n", + "rng = np.random.default_rng(1)\n", + "recs = []\n", + "for i in range(20):\n", + " g = int(rng.integers(1, 4))\n", + " recs.append({\n", + " \"photo\": {\"img_id\": f\"IMG{i:06d}\", \"shot_day\": int(rng.integers(0, 90)), \"width\": 64, \"height\": 64, \"channels\": 3,\n", + " \"brightness\": float(round(rng.uniform(0.2, 0.9), 2)), \"mud_ratio\": float(round(rng.uniform(0, 0.3), 2))},\n", + " \"spec\": {\"equip_id\": f\"VX-{rng.integers(1000, 9999)}-{rng.integers(1000, 9999)}\", \"equip_type\": str(rng.choice([\"vehicle\", \"generator\", \"comm\", \"trailer\"])),\n", + " \"mfg_year\": int(rng.integers(2000, 2025)), \"op_hours\": float(round(rng.uniform(100, 9000), 0)), \"env\": str(rng.choice([\"coastal\", \"inland\"])),\n", + " \"reg_grade\": int(np.clip(g + rng.integers(-1, 2), 1, 3))},\n", + " \"label\": {\"defect_class\": str(rng.choice([\"none\", \"crack\", \"corrosion\", \"damage\", \"leak\"])), \"bbox_x\": int(rng.integers(0, 30)), \"bbox_y\": int(rng.integers(0, 30)),\n", + " \"bbox_w\": int(rng.integers(5, 30)), \"bbox_h\": int(rng.integers(5, 30)), \"emergency\": bool(rng.random() < 0.05)},\n", + " \"result\": {\"area_frac\": float(round(rng.uniform(0, 0.2), 3)), \"depth_mm\": float(round(rng.uniform(0, 6), 1)), \"damage_idx\": float(round(rng.uniform(0, 100), 1)),\n", + " \"grade\": g, \"confidence\": float(round(rng.uniform(0.5, 1), 2))}})\n", + "ok = sum(not validate(r) for r in recs)\n", + "print(f\"정상 샘플 {len(recs)}건 중 검증 통과 {ok}건, 오류 {len(recs) - ok}건\")\n", + "\n", + "# 5) 오류 주입 시험\n", + "bad = json.loads(json.dumps(recs[0]))\n", + "bad[\"spec\"][\"mfg_year\"] = 2085 # 제원 값 범위 이상(미래 제조연도)\n", + "bad[\"label\"][\"bbox_x\"] = 60; bad[\"label\"][\"bbox_w\"] = 20 # 박스가 사진 밖으로 나감\n", + "bad[\"result\"][\"grade\"] = 5 # 허용되지 않은 등급\n", + "del bad[\"spec\"][\"op_hours\"] # 가동시간 누락\n", + "bad[\"photo\"][\"brightness\"] = \"어두움\" # 자료형 오류\n", + "print(\"\\n[오류 주입 결과]\")\n", + "for e in validate(bad):\n", + " print(\" -\", e)\n", + "mis = json.loads(json.dumps(recs[1])); mis[\"result\"][\"grade\"] = 3; mis[\"spec\"][\"reg_grade\"] = 1\n", + "print(\"제원 불일치 시험:\", validate(mis))\n", + "\n", + "# 6) 프로세스 맵과 데이터 딕셔너리\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", + "print(\"\\n[라벨링 기준]\")\n", + "for k, v in LABEL_RULES.items():\n", + " print(f\" {k:10s}: {v}\")\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": [ + "
\n", + "💡 [참고] 미션 01 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + "정상 샘플 20건 중 검증 통과 20건, 오류 0건\n", + "\n", + "[오류 주입 결과]\n", + " - photo.brightness: 자료형 오류(str)\n", + " - spec.mfg_year: 최대값 2026 초과\n", + " - spec.op_hours: 필수 필드 누락\n", + " - result.grade: 허용되지 않은 값 '5'\n", + " - label.bbox: 박스가 이미지 밖으로 나감\n", + "제원 불일치 시험: ['제원 대조: 판정 등급과 관리 기록 등급이 2단계 이상 차이(제원 이상 신호)']\n", + "\n", + "[업무 프로세스 맵]\n", + "1.촬영 -> 2.제원 대조 -> 3.전처리 -> 4.결함 판별 -> 5.손상도 산정 -> 6.검사관 보정 -> 7.정비 등급 승인 -> 8.제원 환류 -> 9.사후검토(AAR)\n", + " 촬영 : 사진 ID, 촬영일, 촬영 조건(조명·오염)\n", + " 제원 대조 : 장비 식별코드, 제조연도, 가동시간, 기록상 정비 등급\n", + " 전처리 : 조명 보정, 해상도 표준화, 결함 영역(바운딩 박스)\n", + " 결함 판별 : 결함 유형, 결함 면적, 추정 깊이\n", + " 손상도 산정 : 손상도 점수(0~100), 판정 신뢰도\n", + " 검사관 보정 : 수동 보정 여부, 보정 사유\n", + " 정비 등급 승인 : 현장·야전·창정비 등급, 승인자, 승인 시각\n", + " 제원 환류 : 판정 결과와 기록상 등급의 불일치, 제원 수정 요청\n", + " 사후검토(AAR) : 오판 원인, 파라미터 재보정 이력\n", + "\n", + "[라벨링 기준]\n", + " crack : 선형 어두운 균열, 길이 10px 이상\n", + " corrosion : 붉은 갈색 면, 면적 12px 이상\n", + " damage : 함몰·파단 윤곽(밝은 테두리 포함)\n", + " leak : 어둡고 번지는 액체 자국(아래 방향 흐름)\n", + " none : 결함 없음(무해한 얼룩·긁힘은 결함으로 라벨하지 않음)\n", + "\n", + "[데이터 딕셔너리] 피처 24개 (목표 20개 이상: 달성)\n", + " 그룹 필드 자료형 설명\n", + " photo img_id str 사진 ID\n", + " photo shot_day int 촬영일(점검 시작 후 경과일)\n", + " photo width int 표준 해상도 가로(px)\n", + " photo height int 표준 해상도 세로(px)\n", + " photo channels int 채널 수(RGB)\n", + " photo brightness float 평균 밝기(0~1)\n", + " photo mud_ratio float 진흙·오염 가림 비율(추정)\n", + " spec equip_id str 장비 식별코드(국방 표준 코드 형식을 모사한 가상 코드)\n", + " spec equip_type str 장비 구분\n", + "... (이하 생략)\n", + "```\n", + "
\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 02. 이미지 조도 보정, 해상도 표준화 및 결함 영역(Bounding Box) 전처리 모듈 구현\n", + "**소요시간**: 약 2.0시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "조도 보정, 해상도 표준화 및 결함 영역 바운딩 박스 전처리 모듈을 구현한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **조명 보정, 해상도 표준화, 결함 영역(Bounding Box) 정제를 포함한 사진·제원 전처리 모듈을 구현한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **해상도, 밝기, 표기가 제각각인 가상 원천 사진과 제원·박스 라벨에 정제 규칙 10종 이상을 순서대로 적용 수행하고 검증하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `src/etl/qlt03_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": [ + "\"\"\"해상도·밝기·표기가 제각각인 장비 외관 사진과 제원 기록, 결함 영역(바운딩 박스) 라벨의 가상 원천 자료에 조명 보정·해상도 표준화·박스 정리를 포함한 정제 규칙 11종을 적용하고, 긴급 점검 사진 같은 군수 예외 데이터는 보존하면서 정합성 검증 통과율과 정제 전후 통계를 확인한다.\"\"\"\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", + "# [교육용가상자료] 장비 외관 사진 생성기 (실제 장비·부대·위치 사진이 아니라 난수로 그린 64x64 합성 이미지)\n", + "# 도장된 금속 표면에 균열(crack)·부식(corrosion)·파손(damage)·누유(leak)를 그리고, 조명 편차와 진흙(mud) 오염을 섞는다.\n", + "# 이미지와 함께 장비 관리 기록(제원 요약)과 정답 결함 마스크·손상도·정비 등급을 만든다.\n", + "# ---------------------------------------------------------------------------\n", + "from scipy import ndimage as ndi\n", + "\n", + "SZ = 64\n", + "CLASSES = [\"none\", \"crack\", \"corrosion\", \"damage\", \"leak\"]\n", + "KO_CLS = {\"none\": \"결함 없음\", \"crack\": \"균열\", \"corrosion\": \"부식\", \"damage\": \"파손\", \"leak\": \"누유\"}\n", + "CLS_W = {\"none\": 0.0, \"crack\": 1.4, \"corrosion\": 1.0, \"damage\": 1.6, \"leak\": 0.6} # 손상도 계산용 결함 유형 가중치\n", + "GRADE_NAME = {1: \"현장정비\", 2: \"야전정비\", 3: \"창정비\"}\n", + "YY, XX = np.mgrid[0:SZ, 0:SZ]\n", + "\n", + "\n", + "def grade_of(idx):\n", + " # [TODO : 학생 실습 2] 이미지 조도 보정, 해상도 표준화 및 결함 영역(Bounding Box) 전처리 모듈 구현 관련 핵심 로직을 구현하세요.\n", + " # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n", + " # <<< 여기에 코드를 작성하세요 >>>\n", + " pass # 작성 후 pass를 제거하세요.\n", + "def blob(rng, cy, cx, r, rough=0.35):\n", + " \"\"\"불규칙한 원형 영역 마스크.\"\"\"\n", + " # [TODO : 학생 실습 2] 이미지 조도 보정, 해상도 표준화 및 결함 영역(Bounding Box) 전처리 모듈 구현 관련 핵심 로직을 구현하세요.\n", + " # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n", + " # <<< 여기에 코드를 작성하세요 >>>\n", + " pass # 작성 후 pass를 제거하세요.\n", + "def draw(rng, cls, sev):\n", + " \"\"\"결함 유형·심각도 sev(0~1)에 맞는 정답 마스크와 색 정보를 만든다.\"\"\"\n", + " m = np.zeros((SZ, SZ), bool)\n", + " if cls == \"crack\":\n", + " y, x, ang = rng.uniform(12, 52), rng.uniform(12, 52), rng.uniform(0, np.pi)\n", + " for _ in range(int(20 + 40 * sev)):\n", + " ang += rng.normal(0, 0.35); y += np.sin(ang) * 1.2; x += np.cos(ang) * 1.2\n", + " if 0 <= int(y) < SZ and 0 <= int(x) < SZ:\n", + " m[int(y), int(x)] = True\n", + " m = ndi.binary_dilation(m, iterations=1 + int(sev > 0.6))\n", + " elif cls == \"corrosion\":\n", + " for _ in range(int(rng.integers(1, 4))):\n", + " m |= blob(rng, rng.uniform(14, 50), rng.uniform(14, 50), 3 + 9 * sev * rng.uniform(0.6, 1.1))\n", + " elif cls == \"damage\":\n", + " m = blob(rng, rng.uniform(20, 44), rng.uniform(20, 44), 6 + 12 * sev, 0.3)\n", + " elif cls == \"leak\":\n", + " x0, y0 = rng.uniform(16, 48), rng.uniform(8, 24)\n", + " w, ln = 2 + 4 * sev, 12 + 26 * sev\n", + " m = (np.abs(XX - x0 - 0.15 * (YY - y0)) < w * (1 - 0.4 * (YY - y0) / ln)) & (YY > y0) & (YY < y0 + ln)\n", + " return m\n", + "\n", + "\n", + "def make_images(n=600, seed=0, mud=0.0, p_none=0.25, dark=False):\n", + " \"\"\"가상 외관 사진 n장(+진흙 오염 mud 비율)과 관리 기록·정답. 반환: meta(DataFrame), imgs(n,64,64,3), masks(n,64,64).\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " imgs, masks, rows = np.zeros((n, SZ, SZ, 3), np.float32), np.zeros((n, SZ, SZ), bool), []\n", + " for i in range(n):\n", + " cls = \"none\" if rng.random() < p_none else str(rng.choice(CLASSES[1:], p=[0.25, 0.3, 0.25, 0.2]))\n", + " sev = 0.0 if cls == \"none\" else float(rng.beta(1.6, 2.0))\n", + " base = np.array([0.42, 0.46, 0.40]) * (1 + 0.5 * ndi.gaussian_filter(rng.normal(size=(SZ, SZ)), 8)[..., None] * 3)\n", + " img = base + rng.normal(0, 0.012, (SZ, SZ, 3))\n", + " m = draw(rng, cls, sev)\n", + " a = ndi.gaussian_filter(m.astype(float), 0.8)[..., None] # 경계를 부드럽게 섞는다\n", + " if cls == \"crack\":\n", + " col = img * rng.uniform(0.2, 0.45)\n", + " elif cls == \"corrosion\":\n", + " col = np.array([0.55, 0.30, 0.12]) * rng.uniform(0.75, 1.15) * (0.85 + 0.3 * rng.random((SZ, SZ, 1)))\n", + " elif cls == \"damage\":\n", + " col = np.ones_like(img) * np.array([0.34, 0.34, 0.36]) * (1.0 - 0.45 * sev) * rng.uniform(0.75, 1.0)\n", + " rim = m & ~ndi.binary_erosion(m, iterations=2)\n", + " a = np.maximum(a, 0.0); img = np.where(rim[..., None], 0.7, img)\n", + " elif cls == \"leak\":\n", + " col = np.ones_like(img) * np.array([0.10, 0.09, 0.08]) * rng.uniform(0.8, 2.6)\n", + " else:\n", + " col = img\n", + " img = img * (1 - a) + col * a\n", + " if rng.random() < 0.35: # 무해한 얼룩(결함 아님): 옅은 갈색 번짐\n", + " st = blob(rng, rng.uniform(5, 58), rng.uniform(5, 58), rng.uniform(6, 14), 0.5)\n", + " al = ndi.gaussian_filter(st.astype(float), 2.5)[..., None] * rng.uniform(0.15, 0.4)\n", + " img = img * (1 - al) + np.array([0.50, 0.40, 0.30]) * al\n", + " if rng.random() < 0.3: # 무해한 긴 스크래치(밝은 선)\n", + " y0, x0, ang = rng.uniform(5, 58), rng.uniform(5, 58), rng.uniform(0, np.pi)\n", + " for t in np.linspace(0, 25, 40):\n", + " yy, xx = int(y0 + t * np.sin(ang)), int(x0 + t * np.cos(ang))\n", + " if 0 <= yy < SZ and 0 <= xx < SZ:\n", + " img[yy, xx] = np.clip(img[yy, xx] * 1.5, 0, 1)\n", + " if mud > 0: # 진흙 오염: 결함 위를 덮을 수 있다(정답 마스크는 그대로)\n", + " mm = np.zeros((SZ, SZ), bool)\n", + " while mm.mean() < mud:\n", + " mm |= blob(rng, rng.uniform(0, SZ), rng.uniform(0, SZ), rng.uniform(5, 11), 0.5)\n", + " img[mm] = np.array([0.38, 0.30, 0.22]) * (0.85 + 0.3 * rng.random((mm.sum(), 1)))\n", + " gain = rng.uniform(0.3, 0.55) if dark else rng.uniform(0.5, 1.15)\n", + " ang = rng.uniform(0, 2 * np.pi)\n", + " light = gain * (1 + 0.3 * (np.cos(ang) * (XX - 32) + np.sin(ang) * (YY - 32)) / 32)\n", + " imgs[i] = np.clip(img * light[..., None] + rng.normal(0, 0.025, (SZ, SZ, 3)), 0, 1)\n", + " masks[i] = m\n", + " area = float(m.mean())\n", + " depth = {\"none\": 0.0, \"crack\": 0.3 + 3.0 * sev, \"corrosion\": 0.1 + 1.5 * sev, \"damage\": 1.0 + 8.0 * sev, \"leak\": 0.0}[cls] * rng.lognormal(0, 0.1)\n", + " idx = float(np.clip(100 * (1 - np.exp(-(CLS_W[cls] * area * 9 + 0.05 * depth))) + rng.normal(0, 1.5), 0, 100))\n", + " rows.append({\"img_id\": f\"IMG{seed:02d}{i:04d}\", \"cls\": cls, \"sev\": sev, \"area_frac\": area, \"depth_mm\": depth, \"damage_idx\": idx,\n", + " \"grade\": int(grade_of(idx)), \"gain\": gain, \"mud\": mud})\n", + " meta = pd.DataFrame(rows)\n", + " # 장비 관리 기록(제원 요약): 사진 판정과 대조할 값\n", + " age = rng.integers(1, 25, n)\n", + " meta[\"day\"] = np.sort(rng.integers(0, 90, n))\n", + " meta[\"equip_type\"] = rng.choice([\"vehicle\", \"generator\", \"comm\", \"trailer\"], n, p=[0.4, 0.2, 0.2, 0.2])\n", + " meta[\"mfg_year\"] = 2026 - age\n", + " meta[\"op_hours\"] = np.clip(rng.normal(180 * age, 400, n) + 8 * meta[\"damage_idx\"], 10, 20000)\n", + " meta[\"env\"] = rng.choice([\"coastal\", \"inland\"], n, p=[0.3, 0.7])\n", + " reg = np.clip(meta[\"grade\"] + rng.choice([-1, 0, 0, 0, 0, 0, 1, 2], n), 1, 3) # 장비 관리 기록상 직전 정비 등급\n", + " meta[\"reg_grade\"] = reg\n", + " meta[\"emergency\"] = rng.random(n) < 0.04 # 긴급 점검 요청(화질 불량이어도 보존)\n", + " return meta, imgs, masks\n", + "\n", + "# ---- 전처리(조명 보정·표준화)와 결함 후보 분할, 특징 추출 (cv2 대신 numpy·scipy 사용) ----\n", + "def correct(img):\n", + " \"\"\"조명 보정: 밝기에 평면(조명 기울기)을 적합해 나눈다. 어두운 결함 화소는 제외하고 두 번 반복 적합한다. 반환: 보정 밝기(정상 면 약 1), 색 비율.\"\"\"\n", + " g = img.mean(axis=2)\n", + " A = np.c_[np.ones(SZ * SZ), XX.ravel() / SZ, YY.ravel() / SZ]\n", + " keep = np.ones(SZ * SZ, bool)\n", + " for _ in range(3):\n", + " coef, *_ = np.linalg.lstsq(A[keep], g.ravel()[keep], rcond=None)\n", + " plane = (A @ coef).reshape(SZ, SZ)\n", + " keep = (np.abs(g - plane) < 0.08 * np.maximum(plane, 1e-3)).ravel()\n", + " if keep.sum() < 200:\n", + " keep[:] = True\n", + " c = g / np.maximum(plane, 1e-3)\n", + " chroma = img[..., 0] / (img.sum(axis=2) + 1e-6)\n", + " return c, chroma\n", + "\n", + "\n", + "SEGP = {\"dark\": 0.7, \"rust\": 0.5, \"rim\": 1.45} # 분할 임계값(어두움 보정 밝기, 붉은 비율, 밝은 테두리): 재보정 대상\n", + "\n", + "\n", + "def segment(img):\n", + " \"\"\"결함 후보 마스크: 어두운 영역(균열·누유·파손) + 붉은 갈색 영역(부식) + 밝은 테두리(파손).\"\"\"\n", + " c, chroma = correct(img)\n", + " dark = c < SEGP[\"dark\"]\n", + " rust = (chroma > SEGP[\"rust\"]) & (c > 0.45)\n", + " rim = c > SEGP[\"rim\"]\n", + " m = dark | rust | ndi.binary_dilation(rim, iterations=1) & ndi.binary_dilation(dark, iterations=3)\n", + " m = ndi.binary_opening(m, structure=np.ones((2, 2))) if m.sum() > 12 else m\n", + " return m, c, chroma, dark, rust, rim\n", + "\n", + "\n", + "def extract(img):\n", + " \"\"\"분할 결과와 색·모양에서 24개 특징을 만든다.\"\"\"\n", + " m, c, chroma, dark, rust, rim = segment(img)\n", + " lab, n = ndi.label(m)\n", + " area = float(m.sum())\n", + " if n:\n", + " sizes = ndi.sum(m, lab, range(1, n + 1))\n", + " k = int(np.argmax(sizes)) + 1\n", + " sl = ndi.find_objects(lab)[k - 1]\n", + " h, w = sl[0].stop - sl[0].start, sl[1].stop - sl[1].start\n", + " big = float(sizes.max())\n", + " elong, fill = max(h, w) / max(min(h, w), 1), big / (h * w)\n", + " else:\n", + " big, elong, fill = 0.0, 1.0, 0.0\n", + " gy, gx = np.gradient(c)\n", + " mag = np.hypot(gx, gy)\n", + " inside = m.sum() > 0\n", + " return {\"area_px\": area, \"area_frac\": area / SZ ** 2, \"n_comp\": float(n), \"big_px\": big, \"elong\": elong, \"fill\": fill,\n", + " \"dark_frac\": float(dark.mean()), \"rust_frac\": float(rust.mean()), \"rim_frac\": float(rim.mean()),\n", + " \"c_mean\": float(c.mean()), \"c_std\": float(c.std()), \"c_min\": float(c.min()), \"edge_mean\": float(mag.mean()), \"edge_p95\": float(np.quantile(mag, 0.95)),\n", + " \"chroma_mean\": float(chroma.mean()), \"chroma_max\": float(chroma.max()),\n", + " \"in_dark\": float(c[m].mean()) if inside else 1.0, \"in_chroma\": float(chroma[m].mean()) if inside else float(chroma.mean()),\n", + " \"ring_contrast\": float(c[ndi.binary_dilation(m, iterations=3) & ~m].mean() - c[m].mean()) if inside and (~m).any() else 0.0,\n", + " \"thin\": float(1 - fill), \"gain\": float(img.mean()), \"sat\": float((img.max(axis=2) - img.min(axis=2)).mean()),\n", + " \"cy\": float(ndi.center_of_mass(m)[0] / SZ) if inside else 0.5, \"cx\": float(ndi.center_of_mass(m)[1] / SZ) if inside else 0.5}\n", + "\n", + "\n", + "def feature_table(imgs):\n", + " return pd.DataFrame([extract(im) for im in imgs])\n", + "\n", + "rng = np.random.default_rng(2)\n", + "meta, imgs, masks = make_images(150, seed=2)\n", + "n0 = len(meta)\n", + "applied = []\n", + "\n", + "\n", + "def rule(name, n):\n", + " applied.append((name, int(n)))\n", + "\n", + "\n", + "def true_box(m):\n", + " ys, xs = np.where(m)\n", + " return (int(xs.min()), int(ys.min()), int(xs.max() - xs.min() + 1), int(ys.max() - ys.min() + 1)) if len(xs) else None\n", + "\n", + "\n", + "# ---------------- 1) 원천 자료 만들기(일부러 넣은 오류) ----------------\n", + "raw_imgs, rows = [], []\n", + "sizes = rng.choice([32, 64, 128], n0, p=[0.2, 0.55, 0.25])\n", + "dark_set = set(rng.choice(n0, 35, replace=False))\n", + "emerg = set(rng.choice(n0, 6, replace=False))\n", + "for i in range(n0):\n", + " im = imgs[i]\n", + " if sizes[i] != 64:\n", + " im = np.clip(ndi.zoom(im, (sizes[i] / 64, sizes[i] / 64, 1), order=1), 0, 1)\n", + " if i in dark_set or i in emerg:\n", + " im = im * 0.45 # 저조도 촬영\n", + " raw_imgs.append(im.astype(np.float32))\n", + " tb = true_box(masks[i]); k = sizes[i] / 64\n", + " box = None if tb is None else [tb[0] * k, tb[1] * k, tb[2] * k, tb[3] * k]\n", + " fmt = \"xywh\"\n", + " if box is not None and rng.random() < 0.4: # 일부는 xyxy 형식으로 기록\n", + " box, fmt = [box[0], box[1], box[0] + box[2], box[1] + box[3]], \"xyxy\"\n", + " rows.append({\"img_id\": f\"IMG{i:06d}\", \"shot\": f\"2026-{rng.integers(1, 10):02d}-{rng.integers(1, 28):02d}\", \"equip_id\": f\"VX-{1000 + i:04d}-{2000 + i:04d}\",\n", + " \"mfg_year\": int(meta.loc[i, \"mfg_year\"]), \"op_hours\": float(meta.loc[i, \"op_hours\"]), \"hours_unit\": \"h\", \"reg_grade\": float(meta.loc[i, \"reg_grade\"]),\n", + " \"w\": int(sizes[i]), \"box\": box, \"box_fmt\": fmt, \"emergency\": i in emerg})\n", + "raw = pd.DataFrame(rows)\n", + "idx = rng.choice(n0, 60, replace=False)\n", + "a, b, c, d, e, f, g, h = idx[:8], idx[8:16], idx[16:24], idx[24:32], idx[32:40], idx[40:46], idx[46:52], idx[52:60]\n", + "raw.loc[a, \"img_id\"] = raw.loc[a, \"img_id\"].str.replace(\"IMG\", \"img-\") # 사진 ID 표기 불일치\n", + "raw.loc[b, \"shot\"] = [x.replace(\"-\", \"\") for x in raw.loc[b, \"shot\"]] # 촬영일 형식 혼재\n", + "raw.loc[c, \"op_hours\"] = raw.loc[c, \"op_hours\"] / 24; raw.loc[c, \"hours_unit\"] = \"day\" # 가동시간 단위 혼재\n", + "raw.loc[d, \"mfg_year\"] = rng.choice([1900, 2085], len(d)) # 불가능한 제조연도\n", + "raw.loc[e, [\"mfg_year\", \"op_hours\", \"reg_grade\"]] = np.nan # 제원 결측\n", + "for i in f: # 박스가 사진 밖으로 나감\n", + " if raw.at[i, \"box\"] is not None and raw.at[i, \"box_fmt\"] == \"xywh\":\n", + " raw.at[i, \"box\"] = [raw.at[i, \"w\"] - 2, raw.at[i, \"box\"][1], raw.at[i, \"box\"][2] + 6, raw.at[i, \"box\"][3]]\n", + "for i in g: # 폭 0 박스\n", + " if raw.at[i, \"box\"] is not None and raw.at[i, \"box_fmt\"] == \"xywh\":\n", + " raw.at[i, \"box\"] = [raw.at[i, \"box\"][0], raw.at[i, \"box\"][1], 0, 0]\n", + "dup_rows = raw.sample(8, random_state=1)\n", + "raw = pd.concat([raw, dup_rows], ignore_index=True)\n", + "raw_imgs += [raw_imgs[int(r[\"img_id\"][-6:])] for _, r in dup_rows.iterrows()] # 중복 사진\n", + "print(f\"원천 사진 {len(raw)}장 (중복 8장, 오류 주입 {len(idx)}건, 저조도 {len(dark_set | emerg)}장, 긴급 점검 {len(emerg)}장 포함)\")\n", + "\n", + "# ---------------- 2) 정제 규칙 11종 ----------------\n", + "df, ims = raw.copy(), list(raw_imgs)\n", + "dup = df.duplicated([\"img_id\", \"equip_id\", \"shot\"]); rule(\"R1 중복 사진 제거\", dup.sum())\n", + "keep = (~dup).values; df = df[keep].reset_index(drop=True); ims = [im for im, k in zip(ims, keep) if k]\n", + "bad_id = ~df[\"img_id\"].str.match(r\"^IMG\\d{6}$\"); df[\"img_id\"] = \"IMG\" + df[\"img_id\"].str.extract(r\"(\\d{6})\")[0]; rule(\"R2 사진 ID 표기 통일\", bad_id.sum())\n", + "t1 = pd.to_datetime(df[\"shot\"], format=\"%Y-%m-%d\", errors=\"coerce\"); t2 = pd.to_datetime(df[\"shot\"], format=\"%Y%m%d\", errors=\"coerce\")\n", + "rule(\"R3 촬영일 형식 통일\", t1.isna().sum()); df[\"shot\"] = t1.fillna(t2)\n", + "day = df[\"hours_unit\"] == \"day\"; df.loc[day, \"op_hours\"] *= 24; df[\"hours_unit\"] = \"h\"; rule(\"R4 가동시간 단위 통일(일 -> 시간)\", day.sum())\n", + "bad = ~df[\"mfg_year\"].between(1990, 2026) & df[\"mfg_year\"].notna(); df.loc[bad, \"mfg_year\"] = np.nan; rule(\"R5 불가능 제조연도 결측 처리\", bad.sum())\n", + "nres = sum(im.shape[0] != 64 for im in ims)\n", + "ims = [np.clip(ndi.zoom(im, (64 / im.shape[0], 64 / im.shape[1], 1), order=1), 0, 1) if im.shape[0] != 64 else im for im in ims]\n", + "rule(\"R6 해상도 표준화(32/128 -> 64px)\", nres)\n", + "before_b = np.array([im.mean() for im in ims])\n", + "fixed = []\n", + "for im in ims:\n", + " cc, _ = correct(im) # 보정 밝기 = 밝기 / 조명 평면\n", + " plane = im.mean(axis=2) / np.maximum(cc, 1e-6)\n", + " fixed.append(np.clip(im * 0.45 / np.maximum(plane, 1e-3)[..., None], 0, 1)) # 조명 기울기를 펴고 정상 면 밝기를 0.45로 맞춘다\n", + "ims = fixed; rule(\"R7 조명 보정·정규화(평균 밝기 0.3 미만 사진 수)\", (before_b < 0.3).sum())\n", + "bx = []\n", + "nx = 0\n", + "for (_, r) in df.iterrows():\n", + " if r[\"box\"] is None:\n", + " bx.append(None); continue\n", + " x, y, w_, h_ = r[\"box\"]\n", + " if r[\"box_fmt\"] == \"xyxy\":\n", + " w_, h_ = w_ - x, h_ - y; nx += 1\n", + " k = 64 / r[\"w\"]\n", + " bx.append([x * k, y * k, w_ * k, h_ * k])\n", + "rule(\"R8 박스 형식 통일(xyxy -> xywh, 64px 좌표)\", nx)\n", + "clip_n, zero_n, boxes, manual = 0, 0, [], []\n", + "for i, b_ in enumerate(bx):\n", + " if b_ is None:\n", + " boxes.append(None); continue\n", + " x, y, w_, h_ = b_\n", + " x2, y2 = min(x + w_, 64), min(y + h_, 64)\n", + " if x2 > 64 or x + w_ > 64.5 or y + h_ > 64.5:\n", + " clip_n += 1\n", + " if x2 - x < 1 or y2 - y < 1:\n", + " zero_n += 1; manual.append(df.loc[i, \"img_id\"]); boxes.append(None); continue\n", + " boxes.append([int(round(x)), int(round(y)), int(round(x2 - x)), int(round(y2 - y))])\n", + "rule(\"R9 박스 좌표 클리핑·무효 박스 수동 확인 분리\", clip_n + zero_n)\n", + "miss = df[[\"mfg_year\", \"op_hours\", \"reg_grade\"]].isna().sum().sum()\n", + "df[[\"mfg_year\", \"op_hours\", \"reg_grade\"]] = KNNImputer(n_neighbors=5).fit_transform(df[[\"mfg_year\", \"op_hours\", \"reg_grade\", \"w\"]])[:, :3]\n", + "df[\"mfg_year\"] = df[\"mfg_year\"].round().astype(int); df[\"reg_grade\"] = df[\"reg_grade\"].round().astype(int); rule(\"R10 제원 결측 KNN 보정\", miss)\n", + "df[\"quality_flag\"] = np.array([im.std() < 0.03 for im in ims]) & ~df[\"emergency\"]\n", + "rule(\"R11 긴급 점검 사진 보존·일반 저화질 표시\", int(df[\"emergency\"].sum()))\n", + "\n", + "# ---------------- 3) 정합성 검증 ----------------\n", + "after_b = np.array([im.mean() for im in ims])\n", + "tb = {r[\"img_id\"]: true_box(masks[int(r[\"img_id\"][-6:])]) for _, r in df.iterrows()}\n", + "ious = []\n", + "for (_, r), bb in zip(df.iterrows(), boxes):\n", + " t = tb[r[\"img_id\"]]\n", + " if bb is not None and t is not None:\n", + " ix = max(0, min(bb[0] + bb[2], t[0] + t[2]) - max(bb[0], t[0])); iy = max(0, min(bb[1] + bb[3], t[1] + t[3]) - max(bb[1], t[1]))\n", + " ious.append(ix * iy / (bb[2] * bb[3] + t[2] * t[3] - ix * iy))\n", + "chk = {\"중복 사진 0장\": not df[\"img_id\"].duplicated().any(), \"사진 ID 형식\": df[\"img_id\"].str.match(r\"^IMG\\d{6}$\").all(), \"촬영일 변환\": df[\"shot\"].notna().all(),\n", + " \"가동시간 단위 h\": (df[\"hours_unit\"] == \"h\").all(), \"제조연도 1990~2026\": df[\"mfg_year\"].between(1990, 2026).all(),\n", + " \"모든 사진 64x64x3\": all(im.shape == (64, 64, 3) for im in ims), \"밝기 편차 감소\": after_b.std() < before_b.std() / 2,\n", + " \"박스가 사진 안\": all(b_ is None or (b_[0] + b_[2] <= 64 and b_[1] + b_[3] <= 64) for b_ in boxes), \"박스 면적 양수\": all(b_ is None or (b_[2] > 0 and b_[3] > 0) for b_ in boxes),\n", + " \"제원 결측 0건\": df[[\"mfg_year\", \"op_hours\", \"reg_grade\"]].notna().all().all(), f\"긴급 점검 {int(df['emergency'].sum())}장 보존\": int(df[\"emergency\"].sum()) == len(emerg),\n", + " \"정제 박스와 정답 박스 평균 IoU 0.9 이상\": float(np.mean(ious)) >= 0.9}\n", + "rate = np.mean(list(chk.values()))\n", + "print(\"\\n[적용 규칙과 처리 건수]\")\n", + "for nme, n in applied:\n", + " print(f\" {nme:44s} {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, \"(IoU %.2f)\" % np.mean(ious))\n", + "print(f\"정제 후 사진 {len(df)}장, 박스 수동 확인 {len(manual)}장, 긴급 점검 {int(df['emergency'].sum())}장 유지, 정제 박스 평균 IoU {np.mean(ious):.2f}\")\n", + "cmp = pd.DataFrame({\"정제 전\": [before_b.mean(), before_b.std(), raw[\"op_hours\"].mean(), raw[\"mfg_year\"].min(), raw[\"mfg_year\"].max()],\n", + " \"정제 후\": [after_b.mean(), after_b.std(), df[\"op_hours\"].mean(), df[\"mfg_year\"].min(), df[\"mfg_year\"].max()]},\n", + " index=[\"평균 밝기\", \"밝기 표준편차\", \"평균 가동시간(h)\", \"최소 제조연도\", \"최대 제조연도\"]).round(3)\n", + "print(\"\\n[정제 전후 통계]\"); print(cmp.to_string())\n", + "\n", + "fig, ax = plt.subplots(1, 3, figsize=(12, 3.5))\n", + "ax[0].hist(before_b, bins=25, alpha=0.6, label=\"raw\"); ax[0].hist(after_b, bins=25, alpha=0.6, label=\"normalized\"); ax[0].set_title(\"Mean brightness\"); ax[0].legend()\n", + "j = int(np.argmin(before_b))\n", + "ax[1].imshow(raw_imgs[j][..., :] / max(raw_imgs[j].max(), 1e-3)); ax[1].set_title(\"dark photo (raw, stretched)\"); ax[1].axis(\"off\")\n", + "ax[2].imshow(ims[j]); ax[2].set_title(\"after normalization\"); ax[2].axis(\"off\")\n", + "plt.tight_layout(); plt.show()\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 02 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + "원천 사진 158장 (중복 8장, 오류 주입 60건, 저조도 40장, 긴급 점검 6장 포함)\n", + "\n", + "[적용 규칙과 처리 건수]\n", + " R1 중복 사진 제거 8건\n", + " R2 사진 ID 표기 통일 8건\n", + " R3 촬영일 형식 통일 8건\n", + " R4 가동시간 단위 통일(일 -> 시간) 8건\n", + " R5 불가능 제조연도 결측 처리 8건\n", + " R6 해상도 표준화(32/128 -> 64px) 71건\n", + " R7 조명 보정·정규화(평균 밝기 0.3 미만 사진 수) 74건\n", + " R8 박스 형식 통일(xyxy -> xywh, 64px 좌표) 54건\n", + " R9 박스 좌표 클리핑·무효 박스 수동 확인 분리 4건\n", + " R10 제원 결측 KNN 보정 32건\n", + " R11 긴급 점검 사진 보존·일반 저화질 표시 6건\n", + "\n", + "정합성 검증 12/12 통과, 통과율 100% (목표 100%: 달성), 규칙 11종 (목표 10종 이상: 달성)\n", + "정제 후 사진 150장, 박스 수동 확인 3장, 긴급 점검 6장 유지, 정제 박스 평균 IoU 0.99\n", + "\n", + "[정제 전후 통계]\n", + " 정제 전 정제 후\n", + "평균 밝기 0.293 0.447\n", + "밝기 표준편차 0.117 0.005\n", + "평균 가동시간(h) 2494.475 2609.453\n", + "최소 제조연도 1900.000 2002.000\n", + "최대 제조연도 2085.000 2025.000\n", + "```\n", + "
\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 03. 외관 결함 유무 1차 판별 Baseline 비전 모델 구현\n", + "**소요시간**: 약 2.5시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "외관 결함 유무 1차 판별 Baseline 비전 모델을 구현한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **밝기 분포와 평균 색만 쓰는 외관 결함 유무 1차 판별 Baseline 비전 모델을 구현하고 EDA로 한계와 핵심 관리 대상을 찾는다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **밝기 히스토그램과 채널 평균으로 로지스틱 회귀 Baseline을 학습해 정확도·재현율을 측정하고, 작은 결함과 저조도 사진에서의 한계 확인하고 유효성을 점검하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `notebooks/qlt03_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": [ + "\"\"\"밝기 히스토그램과 평균 색만 쓰는 외관 결함 유무 1차 판별 Baseline 비전 모델(로지스틱 회귀)을 구현해 성능과 한계(작은 균열·조명 의존)를 확인하고, 탐색적 분석(EDA) 차트 8종·상관분석(p-value)·정비 공수 파레토(ABC)·제원 기록과의 불일치 분석으로 핵심 관리 대상을 찾는다.\"\"\"\n", + "import numpy as np\n", + "import pandas as pd\n", + "import matplotlib.pyplot as plt\n", + "from scipy import stats\n", + "from sklearn.linear_model import LogisticRegression\n", + "from sklearn.metrics import accuracy_score, precision_score, recall_score, f1_score\n", + "from sklearn.preprocessing import StandardScaler\n", + "from sklearn.pipeline import make_pipeline\n", + "\n", + "# ---------------------------------------------------------------------------\n", + "# [교육용가상자료] 장비 외관 사진 생성기 (실제 장비·부대·위치 사진이 아니라 난수로 그린 64x64 합성 이미지)\n", + "# 도장된 금속 표면에 균열(crack)·부식(corrosion)·파손(damage)·누유(leak)를 그리고, 조명 편차와 진흙(mud) 오염을 섞는다.\n", + "# 이미지와 함께 장비 관리 기록(제원 요약)과 정답 결함 마스크·손상도·정비 등급을 만든다.\n", + "# ---------------------------------------------------------------------------\n", + "from scipy import ndimage as ndi\n", + "\n", + "SZ = 64\n", + "CLASSES = [\"none\", \"crack\", \"corrosion\", \"damage\", \"leak\"]\n", + "KO_CLS = {\"none\": \"결함 없음\", \"crack\": \"균열\", \"corrosion\": \"부식\", \"damage\": \"파손\", \"leak\": \"누유\"}\n", + "CLS_W = {\"none\": 0.0, \"crack\": 1.4, \"corrosion\": 1.0, \"damage\": 1.6, \"leak\": 0.6} # 손상도 계산용 결함 유형 가중치\n", + "GRADE_NAME = {1: \"현장정비\", 2: \"야전정비\", 3: \"창정비\"}\n", + "YY, XX = np.mgrid[0:SZ, 0:SZ]\n", + "\n", + "\n", + "def grade_of(idx):\n", + " # [TODO : 학생 실습 3] 외관 결함 유무 1차 판별 Baseline 비전 모델 구현 관련 핵심 로직을 구현하세요.\n", + " # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n", + " # <<< 여기에 코드를 작성하세요 >>>\n", + " pass # 작성 후 pass를 제거하세요.\n", + "def blob(rng, cy, cx, r, rough=0.35):\n", + " \"\"\"불규칙한 원형 영역 마스크.\"\"\"\n", + " # [TODO : 학생 실습 3] 외관 결함 유무 1차 판별 Baseline 비전 모델 구현 관련 핵심 로직을 구현하세요.\n", + " # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n", + " # <<< 여기에 코드를 작성하세요 >>>\n", + " pass # 작성 후 pass를 제거하세요.\n", + "def draw(rng, cls, sev):\n", + " \"\"\"결함 유형·심각도 sev(0~1)에 맞는 정답 마스크와 색 정보를 만든다.\"\"\"\n", + " m = np.zeros((SZ, SZ), bool)\n", + " if cls == \"crack\":\n", + " y, x, ang = rng.uniform(12, 52), rng.uniform(12, 52), rng.uniform(0, np.pi)\n", + " for _ in range(int(20 + 40 * sev)):\n", + " ang += rng.normal(0, 0.35); y += np.sin(ang) * 1.2; x += np.cos(ang) * 1.2\n", + " if 0 <= int(y) < SZ and 0 <= int(x) < SZ:\n", + " m[int(y), int(x)] = True\n", + " m = ndi.binary_dilation(m, iterations=1 + int(sev > 0.6))\n", + " elif cls == \"corrosion\":\n", + " for _ in range(int(rng.integers(1, 4))):\n", + " m |= blob(rng, rng.uniform(14, 50), rng.uniform(14, 50), 3 + 9 * sev * rng.uniform(0.6, 1.1))\n", + " elif cls == \"damage\":\n", + " m = blob(rng, rng.uniform(20, 44), rng.uniform(20, 44), 6 + 12 * sev, 0.3)\n", + " elif cls == \"leak\":\n", + " x0, y0 = rng.uniform(16, 48), rng.uniform(8, 24)\n", + " w, ln = 2 + 4 * sev, 12 + 26 * sev\n", + " m = (np.abs(XX - x0 - 0.15 * (YY - y0)) < w * (1 - 0.4 * (YY - y0) / ln)) & (YY > y0) & (YY < y0 + ln)\n", + " return m\n", + "\n", + "\n", + "def make_images(n=600, seed=0, mud=0.0, p_none=0.25, dark=False):\n", + " \"\"\"가상 외관 사진 n장(+진흙 오염 mud 비율)과 관리 기록·정답. 반환: meta(DataFrame), imgs(n,64,64,3), masks(n,64,64).\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " imgs, masks, rows = np.zeros((n, SZ, SZ, 3), np.float32), np.zeros((n, SZ, SZ), bool), []\n", + " for i in range(n):\n", + " cls = \"none\" if rng.random() < p_none else str(rng.choice(CLASSES[1:], p=[0.25, 0.3, 0.25, 0.2]))\n", + " sev = 0.0 if cls == \"none\" else float(rng.beta(1.6, 2.0))\n", + " base = np.array([0.42, 0.46, 0.40]) * (1 + 0.5 * ndi.gaussian_filter(rng.normal(size=(SZ, SZ)), 8)[..., None] * 3)\n", + " img = base + rng.normal(0, 0.012, (SZ, SZ, 3))\n", + " m = draw(rng, cls, sev)\n", + " a = ndi.gaussian_filter(m.astype(float), 0.8)[..., None] # 경계를 부드럽게 섞는다\n", + " if cls == \"crack\":\n", + " col = img * rng.uniform(0.2, 0.45)\n", + " elif cls == \"corrosion\":\n", + " col = np.array([0.55, 0.30, 0.12]) * rng.uniform(0.75, 1.15) * (0.85 + 0.3 * rng.random((SZ, SZ, 1)))\n", + " elif cls == \"damage\":\n", + " col = np.ones_like(img) * np.array([0.34, 0.34, 0.36]) * (1.0 - 0.45 * sev) * rng.uniform(0.75, 1.0)\n", + " rim = m & ~ndi.binary_erosion(m, iterations=2)\n", + " a = np.maximum(a, 0.0); img = np.where(rim[..., None], 0.7, img)\n", + " elif cls == \"leak\":\n", + " col = np.ones_like(img) * np.array([0.10, 0.09, 0.08]) * rng.uniform(0.8, 2.6)\n", + " else:\n", + " col = img\n", + " img = img * (1 - a) + col * a\n", + " if rng.random() < 0.35: # 무해한 얼룩(결함 아님): 옅은 갈색 번짐\n", + " st = blob(rng, rng.uniform(5, 58), rng.uniform(5, 58), rng.uniform(6, 14), 0.5)\n", + " al = ndi.gaussian_filter(st.astype(float), 2.5)[..., None] * rng.uniform(0.15, 0.4)\n", + " img = img * (1 - al) + np.array([0.50, 0.40, 0.30]) * al\n", + " if rng.random() < 0.3: # 무해한 긴 스크래치(밝은 선)\n", + " y0, x0, ang = rng.uniform(5, 58), rng.uniform(5, 58), rng.uniform(0, np.pi)\n", + " for t in np.linspace(0, 25, 40):\n", + " yy, xx = int(y0 + t * np.sin(ang)), int(x0 + t * np.cos(ang))\n", + " if 0 <= yy < SZ and 0 <= xx < SZ:\n", + " img[yy, xx] = np.clip(img[yy, xx] * 1.5, 0, 1)\n", + " if mud > 0: # 진흙 오염: 결함 위를 덮을 수 있다(정답 마스크는 그대로)\n", + " mm = np.zeros((SZ, SZ), bool)\n", + " while mm.mean() < mud:\n", + " mm |= blob(rng, rng.uniform(0, SZ), rng.uniform(0, SZ), rng.uniform(5, 11), 0.5)\n", + " img[mm] = np.array([0.38, 0.30, 0.22]) * (0.85 + 0.3 * rng.random((mm.sum(), 1)))\n", + " gain = rng.uniform(0.3, 0.55) if dark else rng.uniform(0.5, 1.15)\n", + " ang = rng.uniform(0, 2 * np.pi)\n", + " light = gain * (1 + 0.3 * (np.cos(ang) * (XX - 32) + np.sin(ang) * (YY - 32)) / 32)\n", + " imgs[i] = np.clip(img * light[..., None] + rng.normal(0, 0.025, (SZ, SZ, 3)), 0, 1)\n", + " masks[i] = m\n", + " area = float(m.mean())\n", + " depth = {\"none\": 0.0, \"crack\": 0.3 + 3.0 * sev, \"corrosion\": 0.1 + 1.5 * sev, \"damage\": 1.0 + 8.0 * sev, \"leak\": 0.0}[cls] * rng.lognormal(0, 0.1)\n", + " idx = float(np.clip(100 * (1 - np.exp(-(CLS_W[cls] * area * 9 + 0.05 * depth))) + rng.normal(0, 1.5), 0, 100))\n", + " rows.append({\"img_id\": f\"IMG{seed:02d}{i:04d}\", \"cls\": cls, \"sev\": sev, \"area_frac\": area, \"depth_mm\": depth, \"damage_idx\": idx,\n", + " \"grade\": int(grade_of(idx)), \"gain\": gain, \"mud\": mud})\n", + " meta = pd.DataFrame(rows)\n", + " # 장비 관리 기록(제원 요약): 사진 판정과 대조할 값\n", + " age = rng.integers(1, 25, n)\n", + " meta[\"day\"] = np.sort(rng.integers(0, 90, n))\n", + " meta[\"equip_type\"] = rng.choice([\"vehicle\", \"generator\", \"comm\", \"trailer\"], n, p=[0.4, 0.2, 0.2, 0.2])\n", + " meta[\"mfg_year\"] = 2026 - age\n", + " meta[\"op_hours\"] = np.clip(rng.normal(180 * age, 400, n) + 8 * meta[\"damage_idx\"], 10, 20000)\n", + " meta[\"env\"] = rng.choice([\"coastal\", \"inland\"], n, p=[0.3, 0.7])\n", + " reg = np.clip(meta[\"grade\"] + rng.choice([-1, 0, 0, 0, 0, 0, 1, 2], n), 1, 3) # 장비 관리 기록상 직전 정비 등급\n", + " meta[\"reg_grade\"] = reg\n", + " meta[\"emergency\"] = rng.random(n) < 0.04 # 긴급 점검 요청(화질 불량이어도 보존)\n", + " return meta, imgs, masks\n", + "\n", + "# ---- 전처리(조명 보정·표준화)와 결함 후보 분할, 특징 추출 (cv2 대신 numpy·scipy 사용) ----\n", + "def correct(img):\n", + " \"\"\"조명 보정: 밝기에 평면(조명 기울기)을 적합해 나눈다. 어두운 결함 화소는 제외하고 두 번 반복 적합한다. 반환: 보정 밝기(정상 면 약 1), 색 비율.\"\"\"\n", + " g = img.mean(axis=2)\n", + " A = np.c_[np.ones(SZ * SZ), XX.ravel() / SZ, YY.ravel() / SZ]\n", + " keep = np.ones(SZ * SZ, bool)\n", + " for _ in range(3):\n", + " coef, *_ = np.linalg.lstsq(A[keep], g.ravel()[keep], rcond=None)\n", + " plane = (A @ coef).reshape(SZ, SZ)\n", + " keep = (np.abs(g - plane) < 0.08 * np.maximum(plane, 1e-3)).ravel()\n", + " if keep.sum() < 200:\n", + " keep[:] = True\n", + " c = g / np.maximum(plane, 1e-3)\n", + " chroma = img[..., 0] / (img.sum(axis=2) + 1e-6)\n", + " return c, chroma\n", + "\n", + "\n", + "SEGP = {\"dark\": 0.7, \"rust\": 0.5, \"rim\": 1.45} # 분할 임계값(어두움 보정 밝기, 붉은 비율, 밝은 테두리): 재보정 대상\n", + "\n", + "\n", + "def segment(img):\n", + " \"\"\"결함 후보 마스크: 어두운 영역(균열·누유·파손) + 붉은 갈색 영역(부식) + 밝은 테두리(파손).\"\"\"\n", + " c, chroma = correct(img)\n", + " dark = c < SEGP[\"dark\"]\n", + " rust = (chroma > SEGP[\"rust\"]) & (c > 0.45)\n", + " rim = c > SEGP[\"rim\"]\n", + " m = dark | rust | ndi.binary_dilation(rim, iterations=1) & ndi.binary_dilation(dark, iterations=3)\n", + " m = ndi.binary_opening(m, structure=np.ones((2, 2))) if m.sum() > 12 else m\n", + " return m, c, chroma, dark, rust, rim\n", + "\n", + "\n", + "def extract(img):\n", + " \"\"\"분할 결과와 색·모양에서 24개 특징을 만든다.\"\"\"\n", + " m, c, chroma, dark, rust, rim = segment(img)\n", + " lab, n = ndi.label(m)\n", + " area = float(m.sum())\n", + " if n:\n", + " sizes = ndi.sum(m, lab, range(1, n + 1))\n", + " k = int(np.argmax(sizes)) + 1\n", + " sl = ndi.find_objects(lab)[k - 1]\n", + " h, w = sl[0].stop - sl[0].start, sl[1].stop - sl[1].start\n", + " big = float(sizes.max())\n", + " elong, fill = max(h, w) / max(min(h, w), 1), big / (h * w)\n", + " else:\n", + " big, elong, fill = 0.0, 1.0, 0.0\n", + " gy, gx = np.gradient(c)\n", + " mag = np.hypot(gx, gy)\n", + " inside = m.sum() > 0\n", + " return {\"area_px\": area, \"area_frac\": area / SZ ** 2, \"n_comp\": float(n), \"big_px\": big, \"elong\": elong, \"fill\": fill,\n", + " \"dark_frac\": float(dark.mean()), \"rust_frac\": float(rust.mean()), \"rim_frac\": float(rim.mean()),\n", + " \"c_mean\": float(c.mean()), \"c_std\": float(c.std()), \"c_min\": float(c.min()), \"edge_mean\": float(mag.mean()), \"edge_p95\": float(np.quantile(mag, 0.95)),\n", + " \"chroma_mean\": float(chroma.mean()), \"chroma_max\": float(chroma.max()),\n", + " \"in_dark\": float(c[m].mean()) if inside else 1.0, \"in_chroma\": float(chroma[m].mean()) if inside else float(chroma.mean()),\n", + " \"ring_contrast\": float(c[ndi.binary_dilation(m, iterations=3) & ~m].mean() - c[m].mean()) if inside and (~m).any() else 0.0,\n", + " \"thin\": float(1 - fill), \"gain\": float(img.mean()), \"sat\": float((img.max(axis=2) - img.min(axis=2)).mean()),\n", + " \"cy\": float(ndi.center_of_mass(m)[0] / SZ) if inside else 0.5, \"cx\": float(ndi.center_of_mass(m)[1] / SZ) if inside else 0.5}\n", + "\n", + "\n", + "def feature_table(imgs):\n", + " return pd.DataFrame([extract(im) for im in imgs])\n", + "\n", + "mtr, itr, _ = make_images(400, seed=1)\n", + "mte, ite, kte = make_images(300, seed=2)\n", + "\n", + "\n", + "def hist_feat(imgs):\n", + " \"\"\"Baseline 특징: 밝기 히스토그램 16칸 + 채널 평균 3 + 밝기 표준편차 1.\"\"\"\n", + " out = []\n", + " for im in imgs:\n", + " g = im.mean(axis=2)\n", + " h, _ = np.histogram(g / max(g.max(), 1e-3), bins=16, range=(0, 1))\n", + " out.append(np.r_[h / g.size, im.mean(axis=(0, 1)) / max(g.mean(), 1e-3), g.std() / max(g.mean(), 1e-3)])\n", + " return np.array(out)\n", + "\n", + "\n", + "Htr, Hte = hist_feat(itr), hist_feat(ite)\n", + "ytr, yte = (mtr[\"cls\"] != \"none\").values, (mte[\"cls\"] != \"none\").values\n", + "base = make_pipeline(StandardScaler(), LogisticRegression(max_iter=2000, C=0.5)).fit(Htr, ytr)\n", + "pr = base.predict(Hte)\n", + "print(f\"[Baseline 결과] 학습 {len(mtr)}장 / 평가 {len(mte)}장, 결함 사진 {yte.mean():.1%}\")\n", + "print(f\"정확도 {accuracy_score(yte, pr):.1%}, 정밀도 {precision_score(yte, pr):.1%}, 재현율 {recall_score(yte, pr):.1%}, F1 {f1_score(yte, pr):.3f}\")\n", + "rec = pd.DataFrame({\"cls\": mte[\"cls\"], \"hit\": pr == yte}).query(\"cls != 'none'\").groupby(\"cls\")[\"hit\"].mean().round(2)\n", + "print(\"결함 유형별 재현율:\", \", \".join(f\"{KO_CLS[k]} {v:.0%}\" for k, v in rec.items()))\n", + "small = (mte[\"area_frac\"] > 0) & (mte[\"area_frac\"] < 0.02)\n", + "print(f\"면적 2% 미만 작은 결함 {int(small.sum())}장 재현율 {pr[small.values].mean():.0%} (큰 결함 {pr[(mte['area_frac'] >= 0.02).values].mean():.0%})\")\n", + "dk = mte[\"gain\"] < 0.6\n", + "print(f\"조명이 어두운 사진(gain<0.6) 정확도 {(pr == yte)[dk.values].mean():.0%} vs 밝은 사진 {(pr == yte)[~dk.values].mean():.0%}\")\n", + "print(\"-> 전체 통계만 보는 Baseline은 작은 균열을 놓치고 조명에 흔들린다. 결함 위치·모양을 보는 분할 특징이 필요하다(미션 4~5의 동기).\")\n", + "\n", + "# 1) 상관분석: 결함 유무와의 관계(점이연 상관, 유의수준 0.05) - 사진 특징 + 장비 제원 기록\n", + "Fall = pd.concat([feature_table(ite), feature_table(itr[:200])], ignore_index=True)\n", + "mall = pd.concat([mte, mtr[:200]], ignore_index=True)\n", + "Fall[\"op_hours\"], Fall[\"reg_grade\"], Fall[\"mfg_year\"] = mall[\"op_hours\"], mall[\"reg_grade\"], mall[\"mfg_year\"]\n", + "Fall[\"coastal\"] = (mall[\"env\"] == \"coastal\").astype(int)\n", + "yall = (mall[\"cls\"] != \"none\").astype(float)\n", + "rows = []\n", + "for f in [\"c_std\", \"c_min\", \"edge_p95\", \"dark_frac\", \"rust_frac\", \"sat\", \"op_hours\", \"reg_grade\", \"mfg_year\", \"coastal\"]:\n", + " r, p = stats.pearsonr(Fall[f].astype(float), yall)\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", + "\n", + "# 2) 제원 기록과의 불일치: 사진 판정 등급 vs 관리 기록 등급\n", + "ct = pd.crosstab(mall[\"grade\"], mall[\"reg_grade\"]).rename(index=GRADE_NAME, columns=GRADE_NAME)\n", + "gap = (mall[\"grade\"] - mall[\"reg_grade\"]).abs() >= 2\n", + "print(f\"\\n[제원 대조] 사진 등급과 관리 기록 등급이 2단계 이상 다른 장비 {int(gap.sum())}대({gap.mean():.1%}) -> 제원 이상 신호 후보\")\n", + "print(ct.to_string())\n", + "\n", + "# 3) 파레토(ABC): 정비 공수(가상)가 큰 (장비 구분, 결함 유형) 조합\n", + "HOURS = {1: 2, 2: 8, 3: 30}\n", + "mall[\"hours\"] = mall[\"grade\"].map(HOURS)\n", + "g = mall[mall[\"cls\"] != \"none\"].groupby([\"equip_type\", \"cls\"])[\"hours\"].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", + "print(\"\\n[파레토 A등급(누적 정비 공수 80%)]\", \", \".join(f\"{e}/{KO_CLS[c]}\" for (e, c), v in abc.items() if v == \"A\"))\n", + "print(f\"A등급은 전체 조합 {len(g)}개 중 {int((abc == 'A').sum())}개({(abc == 'A').mean():.0%})\")\n", + "\n", + "# 4) EDA 차트 8종\n", + "fig, ax = plt.subplots(2, 4, figsize=(18, 8))\n", + "a = ax.ravel()\n", + "a[0].imshow(np.concatenate([ite[int(np.where(mte['cls'] == c)[0][0])] for c in CLASSES], axis=1)); a[0].set_title(\"1. Samples: none/crack/corrosion/damage/leak\"); a[0].axis(\"off\")\n", + "mall[\"cls\"].value_counts().plot.bar(ax=a[1]); a[1].set_title(\"2. Photos by defect class\")\n", + "a[2].hist(mall.loc[yall == 0, \"gain\"], bins=20, alpha=0.6, label=\"none\"); a[2].hist(mall.loc[yall == 1, \"gain\"], bins=20, alpha=0.6, label=\"defect\"); a[2].legend(); a[2].set_title(\"3. Lighting gain\")\n", + "mall.boxplot(column=\"damage_idx\", by=\"cls\", ax=a[3], rot=30); a[3].set_title(\"4. Damage index by class\")\n", + "a[4].imshow(ct.values, cmap=\"Blues\"); a[4].set_xticks(range(3), [1, 2, 3]); a[4].set_yticks(range(3), [1, 2, 3]); a[4].set_xlabel(\"registered grade\"); a[4].set_ylabel(\"photo grade\"); a[4].set_title(\"5. Photo vs registered grade\")\n", + "a[5].scatter(mall[\"op_hours\"], mall[\"damage_idx\"], s=6, c=mall[\"env\"].map({\"coastal\": \"tab:red\", \"inland\": \"tab:blue\"})); a[5].set_title(\"6. Op hours vs damage (red = coastal)\")\n", + "a[6].barh(corr[\"피처\"], corr[\"상관계수\"]); a[6].set_title(\"7. Correlation with defect\")\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 repair hours\")\n", + "plt.suptitle(\"QLT03 EDA - baseline defect screening (synthetic photos)\"); plt.tight_layout(); plt.show()\n", + "print(\"\\nEDA 차트 8종 생성 (목표 8종 이상: 달성)\")\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 03 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + "[Baseline 결과] 학습 400장 / 평가 300장, 결함 사진 76.7%\n", + "정확도 86.0%, 정밀도 89.8%, 재현율 92.2%, F1 0.910\n", + "결함 유형별 재현율: 부식 96%, 균열 75%, 파손 96%, 누유 98%\n", + "면적 2% 미만 작은 결함 31장 재현율 74% (큰 결함 95%)\n", + "조명이 어두운 사진(gain<0.6) 정확도 82% vs 밝은 사진 87%\n", + "-> 전체 통계만 보는 Baseline은 작은 균열을 놓치고 조명에 흔들린다. 결함 위치·모양을 보는 분할 특징이 필요하다(미션 4~5의 동기).\n", + "\n", + "[결함 유무와의 상관분석]\n", + " 피처 상관계수 p-value 유의\n", + " c_min -0.711 2.7e-78 True\n", + " c_std 0.496 1.8e-32 True\n", + "dark_frac 0.457 3.5e-27 True\n", + " edge_p95 0.373 5.4e-18 True\n", + "reg_grade 0.272 6.2e-10 True\n", + "rust_frac 0.242 4.5e-08 True\n", + " sat 0.186 3.0e-05 True\n", + " op_hours 0.066 1.4e-01 False\n", + " mfg_year 0.045 3.1e-01 False\n", + " coastal 0.004 9.2e-01 False\n", + "유의미한 피처 7개 (목표 5개 이상: 달성)\n", + "\n", + "[제원 대조] 사진 등급과 관리 기록 등급이 2단계 이상 다른 장비 47대(9.4%) -> 제원 이상 신호 후보\n", + "reg_grade 현장정비 야전정비 창정비\n", + "grade \n", + "현장정비 219 50 47\n", + "야전정비 8 58 28\n", + "창정비 0 15 75\n", + "\n", + "[파레토 A등급(누적 정비 공수 80%)] vehicle/파손, comm/파손, vehicle/부식, trailer/파손, generator/파손, generator/부식\n", + "A등급은 전체 조합 16개 중 6개(38%)\n", + "\n", + "EDA 차트 8종 생성 (목표 8종 이상: 달성)\n", + "```\n", + "
\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 04. 세그멘테이션(Mask R-CNN) 기반 결함 면적/깊이 정밀 측정 및 손상도 자동 판정 모델 고도화\n", + "**소요시간**: 약 3.0시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "세그멘테이션 기반 결함 면적/깊이 정밀 측정 및 손상도 자동 판정 모델을 고도화한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **분할 기반으로 결함 면적과 깊이를 측정하고 손상도를 자동 판정하는 모델을 만들어 기준 오차를 측정한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **조명 보정 후 임계값 분할(Mask R-CNN 대체)로 결함 마스크를 만들고 정답 마스크와의 IoU·Dice로 정밀도 평가하고 성능을 비교하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `src/features/qlt03_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": [ + "\"\"\"분할(thresholding 기반 결함 후보 분할, Mask R-CNN 대체) 결과에서 결함 면적을 측정·보정하고 깊이와 손상도 점수를 학습해 손상도를 자동 판정하며, 분할 IoU·면적/깊이/손상도 오차(MAPE)와 변수군 제거 실험으로 파생 피처 설계의 타당성을 검증한다.\"\"\"\n", + "import numpy as np\n", + "import pandas as pd\n", + "import matplotlib.pyplot as plt\n", + "from sklearn.ensemble import RandomForestClassifier, GradientBoostingRegressor\n", + "from sklearn.linear_model import LinearRegression\n", + "from sklearn.model_selection import cross_val_predict\n", + "from sklearn.metrics import r2_score\n", + "\n", + "# ---------------------------------------------------------------------------\n", + "# [교육용가상자료] 장비 외관 사진 생성기 (실제 장비·부대·위치 사진이 아니라 난수로 그린 64x64 합성 이미지)\n", + "# 도장된 금속 표면에 균열(crack)·부식(corrosion)·파손(damage)·누유(leak)를 그리고, 조명 편차와 진흙(mud) 오염을 섞는다.\n", + "# 이미지와 함께 장비 관리 기록(제원 요약)과 정답 결함 마스크·손상도·정비 등급을 만든다.\n", + "# ---------------------------------------------------------------------------\n", + "from scipy import ndimage as ndi\n", + "\n", + "SZ = 64\n", + "CLASSES = [\"none\", \"crack\", \"corrosion\", \"damage\", \"leak\"]\n", + "KO_CLS = {\"none\": \"결함 없음\", \"crack\": \"균열\", \"corrosion\": \"부식\", \"damage\": \"파손\", \"leak\": \"누유\"}\n", + "CLS_W = {\"none\": 0.0, \"crack\": 1.4, \"corrosion\": 1.0, \"damage\": 1.6, \"leak\": 0.6} # 손상도 계산용 결함 유형 가중치\n", + "GRADE_NAME = {1: \"현장정비\", 2: \"야전정비\", 3: \"창정비\"}\n", + "YY, XX = np.mgrid[0:SZ, 0:SZ]\n", + "\n", + "\n", + "def grade_of(idx):\n", + " # [TODO : 학생 실습 4] 세그멘테이션(Mask R-CNN) 기반 결함 면적/깊이 정밀 측정 및 손상도 자동 판정 모델 고도화 관련 핵심 로직을 구현하세요.\n", + " # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n", + " # <<< 여기에 코드를 작성하세요 >>>\n", + " pass # 작성 후 pass를 제거하세요.\n", + "def blob(rng, cy, cx, r, rough=0.35):\n", + " \"\"\"불규칙한 원형 영역 마스크.\"\"\"\n", + " # [TODO : 학생 실습 4] 세그멘테이션(Mask R-CNN) 기반 결함 면적/깊이 정밀 측정 및 손상도 자동 판정 모델 고도화 관련 핵심 로직을 구현하세요.\n", + " # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n", + " # <<< 여기에 코드를 작성하세요 >>>\n", + " pass # 작성 후 pass를 제거하세요.\n", + "def draw(rng, cls, sev):\n", + " \"\"\"결함 유형·심각도 sev(0~1)에 맞는 정답 마스크와 색 정보를 만든다.\"\"\"\n", + " m = np.zeros((SZ, SZ), bool)\n", + " if cls == \"crack\":\n", + " y, x, ang = rng.uniform(12, 52), rng.uniform(12, 52), rng.uniform(0, np.pi)\n", + " for _ in range(int(20 + 40 * sev)):\n", + " ang += rng.normal(0, 0.35); y += np.sin(ang) * 1.2; x += np.cos(ang) * 1.2\n", + " if 0 <= int(y) < SZ and 0 <= int(x) < SZ:\n", + " m[int(y), int(x)] = True\n", + " m = ndi.binary_dilation(m, iterations=1 + int(sev > 0.6))\n", + " elif cls == \"corrosion\":\n", + " for _ in range(int(rng.integers(1, 4))):\n", + " m |= blob(rng, rng.uniform(14, 50), rng.uniform(14, 50), 3 + 9 * sev * rng.uniform(0.6, 1.1))\n", + " elif cls == \"damage\":\n", + " m = blob(rng, rng.uniform(20, 44), rng.uniform(20, 44), 6 + 12 * sev, 0.3)\n", + " elif cls == \"leak\":\n", + " x0, y0 = rng.uniform(16, 48), rng.uniform(8, 24)\n", + " w, ln = 2 + 4 * sev, 12 + 26 * sev\n", + " m = (np.abs(XX - x0 - 0.15 * (YY - y0)) < w * (1 - 0.4 * (YY - y0) / ln)) & (YY > y0) & (YY < y0 + ln)\n", + " return m\n", + "\n", + "\n", + "def make_images(n=600, seed=0, mud=0.0, p_none=0.25, dark=False):\n", + " \"\"\"가상 외관 사진 n장(+진흙 오염 mud 비율)과 관리 기록·정답. 반환: meta(DataFrame), imgs(n,64,64,3), masks(n,64,64).\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " imgs, masks, rows = np.zeros((n, SZ, SZ, 3), np.float32), np.zeros((n, SZ, SZ), bool), []\n", + " for i in range(n):\n", + " cls = \"none\" if rng.random() < p_none else str(rng.choice(CLASSES[1:], p=[0.25, 0.3, 0.25, 0.2]))\n", + " sev = 0.0 if cls == \"none\" else float(rng.beta(1.6, 2.0))\n", + " base = np.array([0.42, 0.46, 0.40]) * (1 + 0.5 * ndi.gaussian_filter(rng.normal(size=(SZ, SZ)), 8)[..., None] * 3)\n", + " img = base + rng.normal(0, 0.012, (SZ, SZ, 3))\n", + " m = draw(rng, cls, sev)\n", + " a = ndi.gaussian_filter(m.astype(float), 0.8)[..., None] # 경계를 부드럽게 섞는다\n", + " if cls == \"crack\":\n", + " col = img * rng.uniform(0.2, 0.45)\n", + " elif cls == \"corrosion\":\n", + " col = np.array([0.55, 0.30, 0.12]) * rng.uniform(0.75, 1.15) * (0.85 + 0.3 * rng.random((SZ, SZ, 1)))\n", + " elif cls == \"damage\":\n", + " col = np.ones_like(img) * np.array([0.34, 0.34, 0.36]) * (1.0 - 0.45 * sev) * rng.uniform(0.75, 1.0)\n", + " rim = m & ~ndi.binary_erosion(m, iterations=2)\n", + " a = np.maximum(a, 0.0); img = np.where(rim[..., None], 0.7, img)\n", + " elif cls == \"leak\":\n", + " col = np.ones_like(img) * np.array([0.10, 0.09, 0.08]) * rng.uniform(0.8, 2.6)\n", + " else:\n", + " col = img\n", + " img = img * (1 - a) + col * a\n", + " if rng.random() < 0.35: # 무해한 얼룩(결함 아님): 옅은 갈색 번짐\n", + " st = blob(rng, rng.uniform(5, 58), rng.uniform(5, 58), rng.uniform(6, 14), 0.5)\n", + " al = ndi.gaussian_filter(st.astype(float), 2.5)[..., None] * rng.uniform(0.15, 0.4)\n", + " img = img * (1 - al) + np.array([0.50, 0.40, 0.30]) * al\n", + " if rng.random() < 0.3: # 무해한 긴 스크래치(밝은 선)\n", + " y0, x0, ang = rng.uniform(5, 58), rng.uniform(5, 58), rng.uniform(0, np.pi)\n", + " for t in np.linspace(0, 25, 40):\n", + " yy, xx = int(y0 + t * np.sin(ang)), int(x0 + t * np.cos(ang))\n", + " if 0 <= yy < SZ and 0 <= xx < SZ:\n", + " img[yy, xx] = np.clip(img[yy, xx] * 1.5, 0, 1)\n", + " if mud > 0: # 진흙 오염: 결함 위를 덮을 수 있다(정답 마스크는 그대로)\n", + " mm = np.zeros((SZ, SZ), bool)\n", + " while mm.mean() < mud:\n", + " mm |= blob(rng, rng.uniform(0, SZ), rng.uniform(0, SZ), rng.uniform(5, 11), 0.5)\n", + " img[mm] = np.array([0.38, 0.30, 0.22]) * (0.85 + 0.3 * rng.random((mm.sum(), 1)))\n", + " gain = rng.uniform(0.3, 0.55) if dark else rng.uniform(0.5, 1.15)\n", + " ang = rng.uniform(0, 2 * np.pi)\n", + " light = gain * (1 + 0.3 * (np.cos(ang) * (XX - 32) + np.sin(ang) * (YY - 32)) / 32)\n", + " imgs[i] = np.clip(img * light[..., None] + rng.normal(0, 0.025, (SZ, SZ, 3)), 0, 1)\n", + " masks[i] = m\n", + " area = float(m.mean())\n", + " depth = {\"none\": 0.0, \"crack\": 0.3 + 3.0 * sev, \"corrosion\": 0.1 + 1.5 * sev, \"damage\": 1.0 + 8.0 * sev, \"leak\": 0.0}[cls] * rng.lognormal(0, 0.1)\n", + " idx = float(np.clip(100 * (1 - np.exp(-(CLS_W[cls] * area * 9 + 0.05 * depth))) + rng.normal(0, 1.5), 0, 100))\n", + " rows.append({\"img_id\": f\"IMG{seed:02d}{i:04d}\", \"cls\": cls, \"sev\": sev, \"area_frac\": area, \"depth_mm\": depth, \"damage_idx\": idx,\n", + " \"grade\": int(grade_of(idx)), \"gain\": gain, \"mud\": mud})\n", + " meta = pd.DataFrame(rows)\n", + " # 장비 관리 기록(제원 요약): 사진 판정과 대조할 값\n", + " age = rng.integers(1, 25, n)\n", + " meta[\"day\"] = np.sort(rng.integers(0, 90, n))\n", + " meta[\"equip_type\"] = rng.choice([\"vehicle\", \"generator\", \"comm\", \"trailer\"], n, p=[0.4, 0.2, 0.2, 0.2])\n", + " meta[\"mfg_year\"] = 2026 - age\n", + " meta[\"op_hours\"] = np.clip(rng.normal(180 * age, 400, n) + 8 * meta[\"damage_idx\"], 10, 20000)\n", + " meta[\"env\"] = rng.choice([\"coastal\", \"inland\"], n, p=[0.3, 0.7])\n", + " reg = np.clip(meta[\"grade\"] + rng.choice([-1, 0, 0, 0, 0, 0, 1, 2], n), 1, 3) # 장비 관리 기록상 직전 정비 등급\n", + " meta[\"reg_grade\"] = reg\n", + " meta[\"emergency\"] = rng.random(n) < 0.04 # 긴급 점검 요청(화질 불량이어도 보존)\n", + " return meta, imgs, masks\n", + "# ---- 전처리(조명 보정·표준화)와 결함 후보 분할, 특징 추출 (cv2 대신 numpy·scipy 사용) ----\n", + "def correct(img):\n", + " \"\"\"조명 보정: 밝기에 평면(조명 기울기)을 적합해 나눈다. 어두운 결함 화소는 제외하고 두 번 반복 적합한다. 반환: 보정 밝기(정상 면 약 1), 색 비율.\"\"\"\n", + " g = img.mean(axis=2)\n", + " A = np.c_[np.ones(SZ * SZ), XX.ravel() / SZ, YY.ravel() / SZ]\n", + " keep = np.ones(SZ * SZ, bool)\n", + " for _ in range(3):\n", + " coef, *_ = np.linalg.lstsq(A[keep], g.ravel()[keep], rcond=None)\n", + " plane = (A @ coef).reshape(SZ, SZ)\n", + " keep = (np.abs(g - plane) < 0.08 * np.maximum(plane, 1e-3)).ravel()\n", + " if keep.sum() < 200:\n", + " keep[:] = True\n", + " c = g / np.maximum(plane, 1e-3)\n", + " chroma = img[..., 0] / (img.sum(axis=2) + 1e-6)\n", + " return c, chroma\n", + "\n", + "\n", + "SEGP = {\"dark\": 0.7, \"rust\": 0.5, \"rim\": 1.45} # 분할 임계값(어두움 보정 밝기, 붉은 비율, 밝은 테두리): 재보정 대상\n", + "\n", + "\n", + "def segment(img):\n", + " \"\"\"결함 후보 마스크: 어두운 영역(균열·누유·파손) + 붉은 갈색 영역(부식) + 밝은 테두리(파손).\"\"\"\n", + " c, chroma = correct(img)\n", + " dark = c < SEGP[\"dark\"]\n", + " rust = (chroma > SEGP[\"rust\"]) & (c > 0.45)\n", + " rim = c > SEGP[\"rim\"]\n", + " m = dark | rust | ndi.binary_dilation(rim, iterations=1) & ndi.binary_dilation(dark, iterations=3)\n", + " m = ndi.binary_opening(m, structure=np.ones((2, 2))) if m.sum() > 12 else m\n", + " return m, c, chroma, dark, rust, rim\n", + "\n", + "\n", + "def extract(img):\n", + " \"\"\"분할 결과와 색·모양에서 24개 특징을 만든다.\"\"\"\n", + " m, c, chroma, dark, rust, rim = segment(img)\n", + " lab, n = ndi.label(m)\n", + " area = float(m.sum())\n", + " if n:\n", + " sizes = ndi.sum(m, lab, range(1, n + 1))\n", + " k = int(np.argmax(sizes)) + 1\n", + " sl = ndi.find_objects(lab)[k - 1]\n", + " h, w = sl[0].stop - sl[0].start, sl[1].stop - sl[1].start\n", + " big = float(sizes.max())\n", + " elong, fill = max(h, w) / max(min(h, w), 1), big / (h * w)\n", + " else:\n", + " big, elong, fill = 0.0, 1.0, 0.0\n", + " gy, gx = np.gradient(c)\n", + " mag = np.hypot(gx, gy)\n", + " inside = m.sum() > 0\n", + " return {\"area_px\": area, \"area_frac\": area / SZ ** 2, \"n_comp\": float(n), \"big_px\": big, \"elong\": elong, \"fill\": fill,\n", + " \"dark_frac\": float(dark.mean()), \"rust_frac\": float(rust.mean()), \"rim_frac\": float(rim.mean()),\n", + " \"c_mean\": float(c.mean()), \"c_std\": float(c.std()), \"c_min\": float(c.min()), \"edge_mean\": float(mag.mean()), \"edge_p95\": float(np.quantile(mag, 0.95)),\n", + " \"chroma_mean\": float(chroma.mean()), \"chroma_max\": float(chroma.max()),\n", + " \"in_dark\": float(c[m].mean()) if inside else 1.0, \"in_chroma\": float(chroma[m].mean()) if inside else float(chroma.mean()),\n", + " \"ring_contrast\": float(c[ndi.binary_dilation(m, iterations=3) & ~m].mean() - c[m].mean()) if inside and (~m).any() else 0.0,\n", + " \"thin\": float(1 - fill), \"gain\": float(img.mean()), \"sat\": float((img.max(axis=2) - img.min(axis=2)).mean()),\n", + " \"cy\": float(ndi.center_of_mass(m)[0] / SZ) if inside else 0.5, \"cx\": float(ndi.center_of_mass(m)[1] / SZ) if inside else 0.5}\n", + "\n", + "\n", + "def feature_table(imgs):\n", + " return pd.DataFrame([extract(im) for im in imgs])\n", + "# ---- 판정 파이프라인: 결함 유형 분류 + 손상도 회귀(분류 확률 포함) -> 정비 등급 ----\n", + "def fit_pipeline(meta, F):\n", + " \"\"\"결함 유형 분류기와 손상도 회귀기를 학습한다. 회귀기 입력의 분류 확률은 교차 예측으로 만들어 누설을 막는다.\"\"\"\n", + " clf = RandomForestClassifier(n_estimators=150, random_state=0, n_jobs=1)\n", + " P = cross_val_predict(clf, F, meta[\"cls\"], cv=4, method=\"predict_proba\")\n", + " clf.fit(F, meta[\"cls\"])\n", + " reg = GradientBoostingRegressor(n_estimators=200, max_depth=3, learning_rate=0.06, random_state=0).fit(np.c_[F.values, P], meta[\"damage_idx\"])\n", + " return {\"clf\": clf, \"reg\": reg, \"cols\": list(F.columns)}\n", + "\n", + "\n", + "def predict_pipeline(pl, F):\n", + " X = F[pl[\"cols\"]]\n", + " P = pl[\"clf\"].predict_proba(X)\n", + " idx = np.clip(pl[\"reg\"].predict(np.c_[X.values, P]), 0, 100)\n", + " return pl[\"clf\"].classes_[P.argmax(axis=1)], P, idx, P.max(axis=1)\n", + "\n", + "mtr, itr, ktr = make_images(600, seed=1)\n", + "mte, ite, kte = make_images(300, seed=2)\n", + "Ftr, Fte = feature_table(itr), feature_table(ite)\n", + "print(f\"[1] 파생 피처 {Ftr.shape[1]}개(분할 면적·성분 수·모양, 명암, 색, 가장자리, 위치) / 학습 {len(mtr)}장, 평가 {len(mte)}장 (1화소 = 1.5mm 가정)\")\n", + "\n", + "# ---------------- 1) 분할 정밀도: 정답 마스크와의 IoU ----------------\n", + "seg = [segment(im)[0] for im in ite]\n", + "rows = []\n", + "for c in CLASSES[1:]:\n", + " ix = np.where(mte[\"cls\"].values == c)[0]\n", + " iou = [(seg[i] & kte[i]).sum() / max((seg[i] | kte[i]).sum(), 1) for i in ix]\n", + " dice = [2 * (seg[i] & kte[i]).sum() / max(seg[i].sum() + kte[i].sum(), 1) for i in ix]\n", + " hit = [seg[i].sum() > 0 and (seg[i] & kte[i]).sum() > 0 for i in ix]\n", + " rows.append((KO_CLS[c], len(ix), np.mean(iou), np.mean(dice), np.mean(hit)))\n", + "sg = pd.DataFrame(rows, columns=[\"결함\", \"장수\", \"평균 IoU\", \"평균 Dice\", \"검출률\"])\n", + "print(\"[2] 분할 성능(평가 300장)\\n\" + sg.round(2).to_string(index=False))\n", + "ok0 = np.mean([seg[i].sum() == 0 or (seg[i].sum() < 12) for i in np.where(mte[\"cls\"].values == \"none\")[0]])\n", + "print(f\" 결함 없는 사진 {int((mte['cls'] == 'none').sum())}장 중 분할 결과가 거의 비어 있는(12화소 미만) 사진 {ok0:.0%} (무해한 얼룩·스크래치 오탐 억제)\")\n", + "\n", + "# ---------------- 2) 면적 측정과 유형별 보정 ----------------\n", + "pl = fit_pipeline(mtr, Ftr)\n", + "cls_te, P_te, idx_te, conf_te = predict_pipeline(pl, Fte)\n", + "a_true, a_raw = mte[\"area_frac\"].values, Fte[\"area_frac\"].values\n", + "cal = {c: LinearRegression().fit(Ftr.loc[mtr[\"cls\"].values == c, [\"area_frac\"]], mtr.loc[mtr[\"cls\"].values == c, \"area_frac\"]) for c in CLASSES[1:]}\n", + "a_cal = np.array([cal[c].predict(pd.DataFrame({\"area_frac\": [a]}))[0] if c != \"none\" else 0.0 for c, a in zip(cls_te, a_raw)])\n", + "m = a_true > 0\n", + "mp = lambda y, p: float(np.mean(np.abs(y - p) / y) * 100)\n", + "print(f\"\\n[3] 면적 측정(결함 {int(m.sum())}장): 분할 원측정 MAPE {mp(a_true[m], a_raw[m]):.1f}% -> 유형별 선형 보정 후 {mp(a_true[m], a_cal[m]):.1f}% (분류 정확도 {(cls_te == mte['cls'].values).mean():.1%})\")\n", + "print(f\" 면적 환산 예: 평균 결함 면적 {a_true[m].mean() * SZ ** 2 * 1.5 ** 2:.0f}mm2(정답) / {a_cal[m].mean() * SZ ** 2 * 1.5 ** 2:.0f}mm2(보정 측정)\")\n", + "\n", + "# ---------------- 3) 깊이·손상도 모델 ----------------\n", + "dm_tr = mtr[\"depth_mm\"].values > 0.2\n", + "Xtr = np.c_[Ftr[pl[\"cols\"]].values, cross_val_predict(RandomForestClassifier(n_estimators=100, random_state=0, n_jobs=1), Ftr, mtr[\"cls\"], cv=3, method=\"predict_proba\")]\n", + "dep = GradientBoostingRegressor(n_estimators=200, max_depth=3, learning_rate=0.06, random_state=0).fit(Xtr[dm_tr], mtr[\"depth_mm\"].values[dm_tr])\n", + "d_pred = np.clip(dep.predict(np.c_[Fte[pl[\"cols\"]].values, P_te]), 0, None)\n", + "dm = mte[\"depth_mm\"].values > 0.2\n", + "print(f\"[4] 깊이 추정(깊이 있는 유형 {int(dm.sum())}장, 누유는 깊이 0으로 제외): MAPE {mp(mte['depth_mm'].values[dm], d_pred[dm]):.1f}%, MAE {np.mean(np.abs(mte['depth_mm'].values[dm] - d_pred[dm])):.2f}mm\")\n", + "print(\" 깊이는 사진 한 장으로 직접 관측되지 않아 면적·명암에서 간접 추정하므로 손상도보다 오차가 크다(실제 업무는 깊이 게이지 등 현장 측정으로 확인).\")\n", + "y = mte[\"damage_idx\"].values\n", + "big = y >= 10\n", + "mape_d, mae_d = mp(y[big], idx_te[big]), float(np.mean(np.abs(y - idx_te)))\n", + "base = LinearRegression().fit(Ftr[[\"area_px\"]], mtr[\"damage_idx\"])\n", + "b_pred = np.clip(base.predict(Fte[[\"area_px\"]]), 0, 100)\n", + "print(f\"[5] 손상도 점수(0~100, 점수 10 이상 {int(big.sum())}장): 면적만 쓰는 기준 MAPE {mp(y[big], b_pred[big]):.1f}% -> 제안 모델 MAPE {mape_d:.1f}% (목표 25% 이하: {'달성' if mape_d <= 25 else '미달'}), MAE {mae_d:.2f}, R2 {r2_score(y, idx_te):.3f}\")\n", + "g_true, g_pred = mte[\"grade\"].values, grade_of(idx_te)\n", + "print(f\" 정비 등급 일치율 {(g_true == g_pred).mean():.1%}, 한 단계 이내 {(np.abs(g_true - g_pred) <= 1).mean():.1%}\")\n", + "\n", + "# ---------------- 4) 변수군 제거 실험 ----------------\n", + "groups = {\"분할 면적·모양\": [\"area_px\", \"area_frac\", \"n_comp\", \"big_px\", \"elong\", \"fill\", \"thin\", \"cy\", \"cx\"], \"명암(어두운 비율·대비)\": [\"dark_frac\", \"c_mean\", \"c_std\", \"c_min\", \"in_dark\", \"ring_contrast\"],\n", + " \"색(붉은 갈색)\": [\"rust_frac\", \"chroma_mean\", \"chroma_max\", \"in_chroma\", \"sat\"], \"가장자리·테두리\": [\"edge_mean\", \"edge_p95\", \"rim_frac\"], \"조명\": [\"gain\"]}\n", + "full_cols = list(Ftr.columns)\n", + "\n", + "\n", + "def run(cols):\n", + " g = GradientBoostingRegressor(n_estimators=200, max_depth=3, learning_rate=0.06, random_state=0).fit(Ftr[cols], mtr[\"damage_idx\"])\n", + " p = np.clip(g.predict(Fte[cols]), 0, 100)\n", + " return mp(y[big], p[big])\n", + "\n", + "\n", + "full = run(full_cols)\n", + "ab = pd.DataFrame([(k, run([c for c in full_cols if c not in v])) for k, v in groups.items()], columns=[\"제거한 변수군\", \"MAPE(%)\"]).assign(증가=lambda x: x[\"MAPE(%)\"] - full).sort_values(\"증가\", ascending=False)\n", + "print(f\"\\n[6] 변수군 제거(분류 확률 없이 24개 피처만 사용, 전체 MAPE {full:.1f}%)\\n\" + ab.round(2).to_string(index=False))\n", + "print(f\" 해석: 제거 시 오차가 가장 크게 늘어난 변수군은 '{ab.iloc[0]['제거한 변수군']}'(+{ab.iloc[0]['증가']:.1f}%p)이다. 변수군끼리 정보가 겹쳐 단독 제거 효과는 작게 나타날 수 있다.\")\n", + "print(\" 판정은 정비 의사결정 보조이며 최종 등급은 검사관이 확정한다(M7). 사진·장비는 모두 교육용 가상 자료이다.\")\n", + "\n", + "fig, ax = plt.subplots(1, 3, figsize=(15, 4.3))\n", + "ax[0].bar(range(len(sg)), sg[\"평균 IoU\"]); ax[0].set_xticks(range(len(sg))); ax[0].set_xticklabels([\"crack\", \"corrosion\", \"damage\", \"leak\"]); ax[0].set_title(\"Segmentation IoU by defect type\")\n", + "ax[1].scatter(a_true[m], a_raw[m], s=8, label=\"raw\"); ax[1].scatter(a_true[m], a_cal[m], s=8, label=\"calibrated\"); ax[1].plot([0, .2], [0, .2], \"k--\"); ax[1].legend(); ax[1].set_title(\"Defect area fraction: true vs measured\")\n", + "ax[2].scatter(y, idx_te, s=8); ax[2].plot([0, 100], [0, 100], \"k--\"); ax[2].set_title(\"Damage index: true vs predicted\")\n", + "plt.tight_layout(); plt.show()\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 04 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + "[1] 파생 피처 24개(분할 면적·성분 수·모양, 명암, 색, 가장자리, 위치) / 학습 600장, 평가 300장 (1화소 = 1.5mm 가정)\n", + "[2] 분할 성능(평가 300장)\n", + "결함 장수 평균 IoU 평균 Dice 검출률\n", + "균열 48 0.89 0.94 1.0\n", + "부식 77 0.74 0.84 1.0\n", + "파손 57 0.61 0.71 1.0\n", + "누유 48 0.94 0.97 1.0\n", + " 결함 없는 사진 70장 중 분할 결과가 거의 비어 있는(12화소 미만) 사진 100% (무해한 얼룩·스크래치 오탐 억제)\n", + "\n", + "[3] 면적 측정(결함 230장): 분할 원측정 MAPE 20.8% -> 유형별 선형 보정 후 10.2% (분류 정확도 98.7%)\n", + " 면적 환산 예: 평균 결함 면적 565mm2(정답) / 548mm2(보정 측정)\n", + "[4] 깊이 추정(깊이 있는 유형 181장, 누유는 깊이 0으로 제외): MAPE 28.0%, MAE 0.40mm\n", + " 깊이는 사진 한 장으로 직접 관측되지 않아 면적·명암에서 간접 추정하므로 손상도보다 오차가 크다(실제 업무는 깊이 게이지 등 현장 측정으로 확인).\n", + "[5] 손상도 점수(0~100, 점수 10 이상 220장): 면적만 쓰는 기준 MAPE 30.8% -> 제안 모델 MAPE 8.8% (목표 25% 이하: 달성), MAE 3.22, R2 0.946\n", + " 정비 등급 일치율 93.3%, 한 단계 이내 99.7%\n", + "\n", + "[6] 변수군 제거(분류 확률 없이 24개 피처만 사용, 전체 MAPE 10.5%)\n", + " 제거한 변수군 MAPE(%) 증가\n", + " 색(붉은 갈색) 13.46 2.98\n", + " 분할 면적·모양 11.45 0.97\n", + "명암(어두운 비율·대비) 11.29 0.81\n", + " 조명 10.67 0.19\n", + " 가장자리·테두리 10.60 0.12\n", + " 해석: 제거 시 오차가 가장 크게 늘어난 변수군은 '색(붉은 갈색)'(+3.0%p)이다. 변수군끼리 정보가 겹쳐 단독 제거 효과는 작게 나타날 수 있다.\n", + " 판정은 정비 의사결정 보조이며 최종 등급은 검사관이 확정한다(M7). 사진·장비는 모두 교육용 가상 자료이다.\n", + "```\n", + "
\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 05. 정비 등급(현장정비/야전정비/창정비) 자동 분류 및 판정 신뢰도 지수 산출 엔진 개발\n", + "**소요시간**: 약 3.5시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "정비 등급(현장/야전/창정비) 자동 분류 및 신뢰도 지수 엔진을 개발한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **손상도와 결함 유형 확률로 정비 등급(현장정비·야전정비·창정비)을 자동 분류하고 판정 신뢰도 지수를 산출하는 엔진을 개발한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **분할 특징과 유형 확률로 손상도를 회귀하고 경계값으로 정비 등급을 정해 기준 모델(밝기 히스토그램 회귀)과 MAE·RMSE·R2를 비교 수행하고 검증하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `src/models/qlt03_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)를 산출하는 엔진을 만들어 기준 모델 대비 MAE·RMSE 개선과 신뢰도 보정, 저조도·진흙 사진 등 불규칙 상황에서의 안정성을 검증한다.\"\"\"\n", + "import numpy as np\n", + "import pandas as pd\n", + "from sklearn.ensemble import RandomForestClassifier, GradientBoostingRegressor\n", + "from sklearn.linear_model import LinearRegression\n", + "from sklearn.model_selection import cross_val_predict\n", + "from sklearn.metrics import r2_score, confusion_matrix\n", + "\n", + "# ---------------------------------------------------------------------------\n", + "# [교육용가상자료] 장비 외관 사진 생성기 (실제 장비·부대·위치 사진이 아니라 난수로 그린 64x64 합성 이미지)\n", + "# 도장된 금속 표면에 균열(crack)·부식(corrosion)·파손(damage)·누유(leak)를 그리고, 조명 편차와 진흙(mud) 오염을 섞는다.\n", + "# 이미지와 함께 장비 관리 기록(제원 요약)과 정답 결함 마스크·손상도·정비 등급을 만든다.\n", + "# ---------------------------------------------------------------------------\n", + "from scipy import ndimage as ndi\n", + "\n", + "SZ = 64\n", + "CLASSES = [\"none\", \"crack\", \"corrosion\", \"damage\", \"leak\"]\n", + "KO_CLS = {\"none\": \"결함 없음\", \"crack\": \"균열\", \"corrosion\": \"부식\", \"damage\": \"파손\", \"leak\": \"누유\"}\n", + "CLS_W = {\"none\": 0.0, \"crack\": 1.4, \"corrosion\": 1.0, \"damage\": 1.6, \"leak\": 0.6} # 손상도 계산용 결함 유형 가중치\n", + "GRADE_NAME = {1: \"현장정비\", 2: \"야전정비\", 3: \"창정비\"}\n", + "YY, XX = np.mgrid[0:SZ, 0:SZ]\n", + "\n", + "\n", + "def grade_of(idx):\n", + " # [TODO : 학생 실습 5] 정비 등급(현장정비/야전정비/창정비) 자동 분류 및 판정 신뢰도 지수 산출 엔진 개발 관련 핵심 로직을 구현하세요.\n", + " # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n", + " # <<< 여기에 코드를 작성하세요 >>>\n", + " pass # 작성 후 pass를 제거하세요.\n", + "def blob(rng, cy, cx, r, rough=0.35):\n", + " \"\"\"불규칙한 원형 영역 마스크.\"\"\"\n", + " # [TODO : 학생 실습 5] 정비 등급(현장정비/야전정비/창정비) 자동 분류 및 판정 신뢰도 지수 산출 엔진 개발 관련 핵심 로직을 구현하세요.\n", + " # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n", + " # <<< 여기에 코드를 작성하세요 >>>\n", + " pass # 작성 후 pass를 제거하세요.\n", + "def draw(rng, cls, sev):\n", + " \"\"\"결함 유형·심각도 sev(0~1)에 맞는 정답 마스크와 색 정보를 만든다.\"\"\"\n", + " m = np.zeros((SZ, SZ), bool)\n", + " if cls == \"crack\":\n", + " y, x, ang = rng.uniform(12, 52), rng.uniform(12, 52), rng.uniform(0, np.pi)\n", + " for _ in range(int(20 + 40 * sev)):\n", + " ang += rng.normal(0, 0.35); y += np.sin(ang) * 1.2; x += np.cos(ang) * 1.2\n", + " if 0 <= int(y) < SZ and 0 <= int(x) < SZ:\n", + " m[int(y), int(x)] = True\n", + " m = ndi.binary_dilation(m, iterations=1 + int(sev > 0.6))\n", + " elif cls == \"corrosion\":\n", + " for _ in range(int(rng.integers(1, 4))):\n", + " m |= blob(rng, rng.uniform(14, 50), rng.uniform(14, 50), 3 + 9 * sev * rng.uniform(0.6, 1.1))\n", + " elif cls == \"damage\":\n", + " m = blob(rng, rng.uniform(20, 44), rng.uniform(20, 44), 6 + 12 * sev, 0.3)\n", + " elif cls == \"leak\":\n", + " x0, y0 = rng.uniform(16, 48), rng.uniform(8, 24)\n", + " w, ln = 2 + 4 * sev, 12 + 26 * sev\n", + " m = (np.abs(XX - x0 - 0.15 * (YY - y0)) < w * (1 - 0.4 * (YY - y0) / ln)) & (YY > y0) & (YY < y0 + ln)\n", + " return m\n", + "\n", + "\n", + "def make_images(n=600, seed=0, mud=0.0, p_none=0.25, dark=False):\n", + " \"\"\"가상 외관 사진 n장(+진흙 오염 mud 비율)과 관리 기록·정답. 반환: meta(DataFrame), imgs(n,64,64,3), masks(n,64,64).\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " imgs, masks, rows = np.zeros((n, SZ, SZ, 3), np.float32), np.zeros((n, SZ, SZ), bool), []\n", + " for i in range(n):\n", + " cls = \"none\" if rng.random() < p_none else str(rng.choice(CLASSES[1:], p=[0.25, 0.3, 0.25, 0.2]))\n", + " sev = 0.0 if cls == \"none\" else float(rng.beta(1.6, 2.0))\n", + " base = np.array([0.42, 0.46, 0.40]) * (1 + 0.5 * ndi.gaussian_filter(rng.normal(size=(SZ, SZ)), 8)[..., None] * 3)\n", + " img = base + rng.normal(0, 0.012, (SZ, SZ, 3))\n", + " m = draw(rng, cls, sev)\n", + " a = ndi.gaussian_filter(m.astype(float), 0.8)[..., None] # 경계를 부드럽게 섞는다\n", + " if cls == \"crack\":\n", + " col = img * rng.uniform(0.2, 0.45)\n", + " elif cls == \"corrosion\":\n", + " col = np.array([0.55, 0.30, 0.12]) * rng.uniform(0.75, 1.15) * (0.85 + 0.3 * rng.random((SZ, SZ, 1)))\n", + " elif cls == \"damage\":\n", + " col = np.ones_like(img) * np.array([0.34, 0.34, 0.36]) * (1.0 - 0.45 * sev) * rng.uniform(0.75, 1.0)\n", + " rim = m & ~ndi.binary_erosion(m, iterations=2)\n", + " a = np.maximum(a, 0.0); img = np.where(rim[..., None], 0.7, img)\n", + " elif cls == \"leak\":\n", + " col = np.ones_like(img) * np.array([0.10, 0.09, 0.08]) * rng.uniform(0.8, 2.6)\n", + " else:\n", + " col = img\n", + " img = img * (1 - a) + col * a\n", + " if rng.random() < 0.35: # 무해한 얼룩(결함 아님): 옅은 갈색 번짐\n", + " st = blob(rng, rng.uniform(5, 58), rng.uniform(5, 58), rng.uniform(6, 14), 0.5)\n", + " al = ndi.gaussian_filter(st.astype(float), 2.5)[..., None] * rng.uniform(0.15, 0.4)\n", + " img = img * (1 - al) + np.array([0.50, 0.40, 0.30]) * al\n", + " if rng.random() < 0.3: # 무해한 긴 스크래치(밝은 선)\n", + " y0, x0, ang = rng.uniform(5, 58), rng.uniform(5, 58), rng.uniform(0, np.pi)\n", + " for t in np.linspace(0, 25, 40):\n", + " yy, xx = int(y0 + t * np.sin(ang)), int(x0 + t * np.cos(ang))\n", + " if 0 <= yy < SZ and 0 <= xx < SZ:\n", + " img[yy, xx] = np.clip(img[yy, xx] * 1.5, 0, 1)\n", + " if mud > 0: # 진흙 오염: 결함 위를 덮을 수 있다(정답 마스크는 그대로)\n", + " mm = np.zeros((SZ, SZ), bool)\n", + " while mm.mean() < mud:\n", + " mm |= blob(rng, rng.uniform(0, SZ), rng.uniform(0, SZ), rng.uniform(5, 11), 0.5)\n", + " img[mm] = np.array([0.38, 0.30, 0.22]) * (0.85 + 0.3 * rng.random((mm.sum(), 1)))\n", + " gain = rng.uniform(0.3, 0.55) if dark else rng.uniform(0.5, 1.15)\n", + " ang = rng.uniform(0, 2 * np.pi)\n", + " light = gain * (1 + 0.3 * (np.cos(ang) * (XX - 32) + np.sin(ang) * (YY - 32)) / 32)\n", + " imgs[i] = np.clip(img * light[..., None] + rng.normal(0, 0.025, (SZ, SZ, 3)), 0, 1)\n", + " masks[i] = m\n", + " area = float(m.mean())\n", + " depth = {\"none\": 0.0, \"crack\": 0.3 + 3.0 * sev, \"corrosion\": 0.1 + 1.5 * sev, \"damage\": 1.0 + 8.0 * sev, \"leak\": 0.0}[cls] * rng.lognormal(0, 0.1)\n", + " idx = float(np.clip(100 * (1 - np.exp(-(CLS_W[cls] * area * 9 + 0.05 * depth))) + rng.normal(0, 1.5), 0, 100))\n", + " rows.append({\"img_id\": f\"IMG{seed:02d}{i:04d}\", \"cls\": cls, \"sev\": sev, \"area_frac\": area, \"depth_mm\": depth, \"damage_idx\": idx,\n", + " \"grade\": int(grade_of(idx)), \"gain\": gain, \"mud\": mud})\n", + " meta = pd.DataFrame(rows)\n", + " # 장비 관리 기록(제원 요약): 사진 판정과 대조할 값\n", + " age = rng.integers(1, 25, n)\n", + " meta[\"day\"] = np.sort(rng.integers(0, 90, n))\n", + " meta[\"equip_type\"] = rng.choice([\"vehicle\", \"generator\", \"comm\", \"trailer\"], n, p=[0.4, 0.2, 0.2, 0.2])\n", + " meta[\"mfg_year\"] = 2026 - age\n", + " meta[\"op_hours\"] = np.clip(rng.normal(180 * age, 400, n) + 8 * meta[\"damage_idx\"], 10, 20000)\n", + " meta[\"env\"] = rng.choice([\"coastal\", \"inland\"], n, p=[0.3, 0.7])\n", + " reg = np.clip(meta[\"grade\"] + rng.choice([-1, 0, 0, 0, 0, 0, 1, 2], n), 1, 3) # 장비 관리 기록상 직전 정비 등급\n", + " meta[\"reg_grade\"] = reg\n", + " meta[\"emergency\"] = rng.random(n) < 0.04 # 긴급 점검 요청(화질 불량이어도 보존)\n", + " return meta, imgs, masks\n", + "# ---- 전처리(조명 보정·표준화)와 결함 후보 분할, 특징 추출 (cv2 대신 numpy·scipy 사용) ----\n", + "def correct(img):\n", + " \"\"\"조명 보정: 밝기에 평면(조명 기울기)을 적합해 나눈다. 어두운 결함 화소는 제외하고 두 번 반복 적합한다. 반환: 보정 밝기(정상 면 약 1), 색 비율.\"\"\"\n", + " g = img.mean(axis=2)\n", + " A = np.c_[np.ones(SZ * SZ), XX.ravel() / SZ, YY.ravel() / SZ]\n", + " keep = np.ones(SZ * SZ, bool)\n", + " for _ in range(3):\n", + " coef, *_ = np.linalg.lstsq(A[keep], g.ravel()[keep], rcond=None)\n", + " plane = (A @ coef).reshape(SZ, SZ)\n", + " keep = (np.abs(g - plane) < 0.08 * np.maximum(plane, 1e-3)).ravel()\n", + " if keep.sum() < 200:\n", + " keep[:] = True\n", + " c = g / np.maximum(plane, 1e-3)\n", + " chroma = img[..., 0] / (img.sum(axis=2) + 1e-6)\n", + " return c, chroma\n", + "\n", + "\n", + "SEGP = {\"dark\": 0.7, \"rust\": 0.5, \"rim\": 1.45} # 분할 임계값(어두움 보정 밝기, 붉은 비율, 밝은 테두리): 재보정 대상\n", + "\n", + "\n", + "def segment(img):\n", + " \"\"\"결함 후보 마스크: 어두운 영역(균열·누유·파손) + 붉은 갈색 영역(부식) + 밝은 테두리(파손).\"\"\"\n", + " c, chroma = correct(img)\n", + " dark = c < SEGP[\"dark\"]\n", + " rust = (chroma > SEGP[\"rust\"]) & (c > 0.45)\n", + " rim = c > SEGP[\"rim\"]\n", + " m = dark | rust | ndi.binary_dilation(rim, iterations=1) & ndi.binary_dilation(dark, iterations=3)\n", + " m = ndi.binary_opening(m, structure=np.ones((2, 2))) if m.sum() > 12 else m\n", + " return m, c, chroma, dark, rust, rim\n", + "\n", + "\n", + "def extract(img):\n", + " \"\"\"분할 결과와 색·모양에서 24개 특징을 만든다.\"\"\"\n", + " m, c, chroma, dark, rust, rim = segment(img)\n", + " lab, n = ndi.label(m)\n", + " area = float(m.sum())\n", + " if n:\n", + " sizes = ndi.sum(m, lab, range(1, n + 1))\n", + " k = int(np.argmax(sizes)) + 1\n", + " sl = ndi.find_objects(lab)[k - 1]\n", + " h, w = sl[0].stop - sl[0].start, sl[1].stop - sl[1].start\n", + " big = float(sizes.max())\n", + " elong, fill = max(h, w) / max(min(h, w), 1), big / (h * w)\n", + " else:\n", + " big, elong, fill = 0.0, 1.0, 0.0\n", + " gy, gx = np.gradient(c)\n", + " mag = np.hypot(gx, gy)\n", + " inside = m.sum() > 0\n", + " return {\"area_px\": area, \"area_frac\": area / SZ ** 2, \"n_comp\": float(n), \"big_px\": big, \"elong\": elong, \"fill\": fill,\n", + " \"dark_frac\": float(dark.mean()), \"rust_frac\": float(rust.mean()), \"rim_frac\": float(rim.mean()),\n", + " \"c_mean\": float(c.mean()), \"c_std\": float(c.std()), \"c_min\": float(c.min()), \"edge_mean\": float(mag.mean()), \"edge_p95\": float(np.quantile(mag, 0.95)),\n", + " \"chroma_mean\": float(chroma.mean()), \"chroma_max\": float(chroma.max()),\n", + " \"in_dark\": float(c[m].mean()) if inside else 1.0, \"in_chroma\": float(chroma[m].mean()) if inside else float(chroma.mean()),\n", + " \"ring_contrast\": float(c[ndi.binary_dilation(m, iterations=3) & ~m].mean() - c[m].mean()) if inside and (~m).any() else 0.0,\n", + " \"thin\": float(1 - fill), \"gain\": float(img.mean()), \"sat\": float((img.max(axis=2) - img.min(axis=2)).mean()),\n", + " \"cy\": float(ndi.center_of_mass(m)[0] / SZ) if inside else 0.5, \"cx\": float(ndi.center_of_mass(m)[1] / SZ) if inside else 0.5}\n", + "\n", + "\n", + "def feature_table(imgs):\n", + " return pd.DataFrame([extract(im) for im in imgs])\n", + "# ---- 판정 파이프라인: 결함 유형 분류 + 손상도 회귀(분류 확률 포함) -> 정비 등급 ----\n", + "def fit_pipeline(meta, F):\n", + " \"\"\"결함 유형 분류기와 손상도 회귀기를 학습한다. 회귀기 입력의 분류 확률은 교차 예측으로 만들어 누설을 막는다.\"\"\"\n", + " clf = RandomForestClassifier(n_estimators=150, random_state=0, n_jobs=1)\n", + " P = cross_val_predict(clf, F, meta[\"cls\"], cv=4, method=\"predict_proba\")\n", + " clf.fit(F, meta[\"cls\"])\n", + " reg = GradientBoostingRegressor(n_estimators=200, max_depth=3, learning_rate=0.06, random_state=0).fit(np.c_[F.values, P], meta[\"damage_idx\"])\n", + " return {\"clf\": clf, \"reg\": reg, \"cols\": list(F.columns)}\n", + "\n", + "\n", + "def predict_pipeline(pl, F):\n", + " X = F[pl[\"cols\"]]\n", + " P = pl[\"clf\"].predict_proba(X)\n", + " idx = np.clip(pl[\"reg\"].predict(np.c_[X.values, P]), 0, 100)\n", + " return pl[\"clf\"].classes_[P.argmax(axis=1)], P, idx, P.max(axis=1)\n", + "# ---- 사진 품질 게이트: 진흙·오염 가림 추정과 판정 신뢰도 지수 ----\n", + "def mud_est(img):\n", + " \"\"\"진흙(갈색, 채도 낮은 붉은 기) 화소 비율을 추정한다. 3x3 평균으로 잡음을 줄인 뒤 색 비율로 판단하며, 실제 가림 비율보다 작게 나오지만 순서는 유지된다.\"\"\"\n", + " sm = ndi.uniform_filter(img, size=(3, 3, 1))\n", + " s = sm.sum(axis=2) + 1e-6\n", + " rr, gg = sm[..., 0] / s, sm[..., 1] / s\n", + " return float(((rr > 0.385) & (rr < 0.46) & (gg < 0.325)).mean())\n", + "\n", + "\n", + "def confidence(P, idx, F, mud):\n", + " \"\"\"판정 신뢰도 지수 0~100 = 50 x 분류 확률 최대값 + 35 x 등급 경계까지 거리(8점에서 포화) + 15 x 사진 품질. 오염 의심(mud 추정 1% 이상)이면 상한 75로 제한한다.\"\"\"\n", + " bd = np.minimum(np.abs(idx - 35), np.abs(idx - 65))\n", + " qual = np.clip(1.0 - np.clip(0.55 - F[\"gain\"].values, 0, None) * 2 - np.clip(0.9 - F[\"c_mean\"].values, 0, None), 0, 1)\n", + " c = 50 * P.max(axis=1) + 35 * np.clip(bd / 8, 0, 1) + 15 * qual\n", + " return np.where(np.asarray(mud) >= 0.01, np.minimum(c, 75.0), c)\n", + "\n", + "mtr, itr, _ = make_images(600, seed=1)\n", + "mte, ite, _ = make_images(300, seed=2)\n", + "Ftr, Fte = feature_table(itr), feature_table(ite)\n", + "pl = fit_pipeline(mtr, Ftr)\n", + "\n", + "\n", + "def hist_feat(imgs):\n", + " \"\"\"기준 모델(M3) 특징: 밝기 히스토그램 16칸 + 채널 평균 비율 3 + 변동계수 1.\"\"\"\n", + " out = []\n", + " for im in imgs:\n", + " g = im.mean(axis=2)\n", + " h, _ = np.histogram(g / max(g.max(), 1e-3), bins=16, range=(0, 1))\n", + " out.append(np.r_[h / g.size, im.mean(axis=(0, 1)) / max(g.mean(), 1e-3), g.std() / max(g.mean(), 1e-3)])\n", + " return np.array(out)\n", + "\n", + "\n", + "base = LinearRegression().fit(hist_feat(itr), mtr[\"damage_idx\"])\n", + "rm = lambda y, p: float(np.sqrt(np.mean((np.asarray(y) - np.asarray(p)) ** 2)))\n", + "\n", + "\n", + "# ---------------- 1) 평가 300장: 기준 모델 vs 제안 엔진 ----------------\n", + "cls, P, idx, _ = predict_pipeline(pl, Fte)\n", + "y = mte[\"damage_idx\"].values\n", + "bp = np.clip(base.predict(hist_feat(ite)), 0, 100)\n", + "tab = pd.DataFrame({\"기준(밝기 히스토그램 회귀)\": [np.mean(np.abs(y - bp)), rm(y, bp), r2_score(y, bp)], \"제안(분할 특징+유형 확률)\": [np.mean(np.abs(y - idx)), rm(y, idx), r2_score(y, idx)]}, index=[\"MAE\", \"RMSE\", \"R2\"])\n", + "print(\"[1] 손상도 점수 예측 성능(평가 300장)\\n\" + tab.round(3).to_string())\n", + "b, e = tab.iloc[:, 0], tab.iloc[:, 1]\n", + "ok = 1 - e[\"MAE\"] / b[\"MAE\"] >= .2 and 1 - e[\"RMSE\"] / b[\"RMSE\"] >= .2\n", + "print(f\" MAE 개선 {1 - e['MAE'] / b['MAE']:.1%}, RMSE 개선 {1 - e['RMSE'] / b['RMSE']:.1%} (목표 20% 이상: {'달성' if ok else '미달'}), R2 {e['R2']:.3f} (목표 0.85 이상: {'달성' if e['R2'] >= .85 else '미달'})\")\n", + "\n", + "# ---------------- 2) 정비 등급 분류 ----------------\n", + "g_true, g_pred = mte[\"grade\"].values, grade_of(idx)\n", + "cm = pd.DataFrame(confusion_matrix(g_true, g_pred, labels=[1, 2, 3]), index=[f\"실제 {GRADE_NAME[i]}\" for i in (1, 2, 3)], columns=[f\"판정 {GRADE_NAME[i]}\" for i in (1, 2, 3)])\n", + "under = int(((g_pred < g_true)).sum())\n", + "print(f\"\\n[2] 정비 등급 판정: 일치율 {(g_true == g_pred).mean():.1%}, 한 단계 이내 {(np.abs(g_true - g_pred) <= 1).mean():.1%}, 과소 판정(실제보다 낮게) {under}장, 과대 판정 {int((g_pred > g_true).sum())}장\\n\" + cm.to_string())\n", + "print(\" 창정비를 야전·현장으로 낮추는 과소 판정이 가장 위험하므로 경계 부근은 신뢰도로 걸러 검사관이 확인한다.\")\n", + "\n", + "# ---------------- 3) 판정 신뢰도 지수 보정 ----------------\n", + "mud_te = np.array([mud_est(im) for im in ite])\n", + "conf = confidence(P, idx, Fte, mud_te)\n", + "bins = pd.cut(conf, [0, 70, 80, 90, 101], labels=[\"70 미만\", \"70~80\", \"80~90\", \"90 이상\"])\n", + "cal = pd.DataFrame({\"bin\": bins, \"ok\": g_true == g_pred, \"near\": np.abs(g_true - g_pred) <= 1}).groupby(\"bin\", observed=True).agg(건수=(\"ok\", \"size\"), 일치율=(\"ok\", \"mean\"), 한단계이내=(\"near\", \"mean\"))\n", + "print(\"\\n[3] 신뢰도 지수 구간별 실제 일치율(높은 신뢰도일수록 높아야 한다)\\n\" + cal.round(3).to_string())\n", + "hi = conf >= 80\n", + "print(f\" 신뢰도 80 이상 자동 통과 {hi.sum()}장({hi.mean():.0%}): 일치율 {(g_true == g_pred)[hi].mean():.1%} / 80 미만 {(~hi).sum()}장은 검사관 확인 대상(일치율 {(g_true == g_pred)[~hi].mean():.1%})\")\n", + "\n", + "# ---------------- 4) 불규칙 상황: 저조도, 진흙 가림, 작은 결함 ----------------\n", + "rows = []\n", + "for name, kw in [(\"정상 조명\", dict(seed=2)), (\"저조도\", dict(seed=7, dark=True)), (\"진흙 20% 가림\", dict(seed=8, mud=0.2)), (\"진흙 35% 가림\", dict(seed=9, mud=0.35))]:\n", + " mm, ii = (mte, ite) if name == \"정상 조명\" else make_images(150, **kw)[:2]\n", + " FF = Fte if name == \"정상 조명\" else feature_table(ii)\n", + " c2, P2, i2, _ = predict_pipeline(pl, FF)\n", + " cf = confidence(P2, i2, FF, [mud_est(im) for im in ii])\n", + " yy = mm[\"damage_idx\"].values\n", + " gg, gp = mm[\"grade\"].values, grade_of(i2)\n", + " rows.append((name, len(mm), np.mean(np.abs(yy - np.clip(base.predict(hist_feat(ii)), 0, 100))), np.mean(np.abs(yy - i2)), (gg == gp).mean(), cf.mean(), (gg == gp)[cf >= 80].mean() if (cf >= 80).any() else np.nan, (cf >= 80).mean()))\n", + "ir = pd.DataFrame(rows, columns=[\"조건\", \"장수\", \"기준 MAE\", \"제안 MAE\", \"등급 일치율\", \"평균 신뢰도\", \"신뢰도 80 이상 일치율\", \"80 이상 비율\"])\n", + "print(\"\\n[4] 불규칙 상황별 성능(- 는 해당 사진 없음)\\n\" + ir.round(2).to_string(index=False, na_rep=\"-\"))\n", + "print(\" 진흙 가림이 커지면 오차와 과소 판정이 늘고 평균 신뢰도가 떨어진다. 이런 사진은 재촬영 요구 또는 검사관 확인으로 보낸다.\")\n", + "print(\" 등급은 정비 소요의 1차 판정이며, 최종 정비 등급은 검사관이 확정한다(M7). 사진은 교육용 가상 자료이다.\")\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 05 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + "[1] 손상도 점수 예측 성능(평가 300장)\n", + " 기준(밝기 히스토그램 회귀) 제안(분할 특징+유형 확률)\n", + "MAE 12.107 3.223\n", + "RMSE 15.045 6.759\n", + "R2 0.732 0.946\n", + " MAE 개선 73.4%, RMSE 개선 55.1% (목표 20% 이상: 달성), R2 0.946 (목표 0.85 이상: 달성)\n", + "\n", + "[2] 정비 등급 판정: 일치율 93.3%, 한 단계 이내 99.7%, 과소 판정(실제보다 낮게) 16장, 과대 판정 4장\n", + " 판정 현장정비 판정 야전정비 판정 창정비\n", + "실제 현장정비 182 2 0\n", + "실제 야전정비 11 47 2\n", + "실제 창정비 1 4 51\n", + " 창정비를 야전·현장으로 낮추는 과소 판정이 가장 위험하므로 경계 부근은 신뢰도로 걸러 검사관이 확인한다.\n", + "\n", + "[3] 신뢰도 지수 구간별 실제 일치율(높은 신뢰도일수록 높아야 한다)\n", + " 건수 일치율 한단계이내\n", + "bin \n", + "70 미만 35 0.543 1.000\n", + "70~80 31 0.871 0.968\n", + "80~90 80 1.000 1.000\n", + "90 이상 154 1.000 1.000\n", + " 신뢰도 80 이상 자동 통과 234장(78%): 일치율 100.0% / 80 미만 66장은 검사관 확인 대상(일치율 69.7%)\n", + "\n", + "[4] 불규칙 상황별 성능(- 는 해당 사진 없음)\n", + " 조건 장수 기준 MAE 제안 MAE 등급 일치율 평균 신뢰도 신뢰도 80 이상 일치율 80 이상 비율\n", + " 정상 조명 300 12.11 3.22 0.93 86.19 1.0 0.78\n", + " 저조도 150 13.17 5.52 0.93 74.60 1.0 0.45\n", + "진흙 20% 가림 150 53.72 23.89 0.45 61.91 - 0.00\n", + "진흙 35% 가림 150 66.17 19.41 0.57 59.80 - 0.00\n", + " 진흙 가림이 커지면 오차와 과소 판정이 늘고 평균 신뢰도가 떨어진다. 이런 사진은 재촬영 요구 또는 검사관 확인으로 보낸다.\n", + " 등급은 정비 소요의 1차 판정이며, 최종 정비 등급은 검사관이 확정한다(M7). 사진은 교육용 가상 자료이다.\n", + "```\n", + "
\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 06. 장비 결함 사진 업로드 및 손상 부위 시각화 판정 인터페이스 구현\n", + "**소요시간**: 약 3.0시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "장비 결함 사진 업로드 및 손상 부위 시각화 인터페이스를 구현한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **장비 결함 사진을 업로드하면 손상 부위를 시각화해 판정과 근거를 보여 주는 인터페이스를 구현한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **업로드 사진 한 장에서 결함 유형, 면적(mm2), 깊이, 손상도, 정비 등급, 신뢰도와 손상 부위 윤곽·박스를 계산해 화면에 겹쳐 표시 수행하고 검증하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `src/explainability/qlt03_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개를 자동 산출해 자연어 설명으로 제공한다(자동 확정 없이 검사 대기로 등록).\"\"\"\n", + "import numpy as np\n", + "import pandas as pd\n", + "import matplotlib.pyplot as plt\n", + "from sklearn.ensemble import RandomForestClassifier, GradientBoostingRegressor\n", + "from sklearn.model_selection import cross_val_predict\n", + "\n", + "# ---------------------------------------------------------------------------\n", + "# [교육용가상자료] 장비 외관 사진 생성기 (실제 장비·부대·위치 사진이 아니라 난수로 그린 64x64 합성 이미지)\n", + "# 도장된 금속 표면에 균열(crack)·부식(corrosion)·파손(damage)·누유(leak)를 그리고, 조명 편차와 진흙(mud) 오염을 섞는다.\n", + "# 이미지와 함께 장비 관리 기록(제원 요약)과 정답 결함 마스크·손상도·정비 등급을 만든다.\n", + "# ---------------------------------------------------------------------------\n", + "from scipy import ndimage as ndi\n", + "\n", + "SZ = 64\n", + "CLASSES = [\"none\", \"crack\", \"corrosion\", \"damage\", \"leak\"]\n", + "KO_CLS = {\"none\": \"결함 없음\", \"crack\": \"균열\", \"corrosion\": \"부식\", \"damage\": \"파손\", \"leak\": \"누유\"}\n", + "CLS_W = {\"none\": 0.0, \"crack\": 1.4, \"corrosion\": 1.0, \"damage\": 1.6, \"leak\": 0.6} # 손상도 계산용 결함 유형 가중치\n", + "GRADE_NAME = {1: \"현장정비\", 2: \"야전정비\", 3: \"창정비\"}\n", + "YY, XX = np.mgrid[0:SZ, 0:SZ]\n", + "\n", + "\n", + "def grade_of(idx):\n", + " # [TODO : 학생 실습 6] 장비 결함 사진 업로드 및 손상 부위 시각화 판정 인터페이스 구현 관련 핵심 로직을 구현하세요.\n", + " # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n", + " # <<< 여기에 코드를 작성하세요 >>>\n", + " pass # 작성 후 pass를 제거하세요.\n", + "def blob(rng, cy, cx, r, rough=0.35):\n", + " \"\"\"불규칙한 원형 영역 마스크.\"\"\"\n", + " # [TODO : 학생 실습 6] 장비 결함 사진 업로드 및 손상 부위 시각화 판정 인터페이스 구현 관련 핵심 로직을 구현하세요.\n", + " # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n", + " # <<< 여기에 코드를 작성하세요 >>>\n", + " pass # 작성 후 pass를 제거하세요.\n", + "def draw(rng, cls, sev):\n", + " \"\"\"결함 유형·심각도 sev(0~1)에 맞는 정답 마스크와 색 정보를 만든다.\"\"\"\n", + " m = np.zeros((SZ, SZ), bool)\n", + " if cls == \"crack\":\n", + " y, x, ang = rng.uniform(12, 52), rng.uniform(12, 52), rng.uniform(0, np.pi)\n", + " for _ in range(int(20 + 40 * sev)):\n", + " ang += rng.normal(0, 0.35); y += np.sin(ang) * 1.2; x += np.cos(ang) * 1.2\n", + " if 0 <= int(y) < SZ and 0 <= int(x) < SZ:\n", + " m[int(y), int(x)] = True\n", + " m = ndi.binary_dilation(m, iterations=1 + int(sev > 0.6))\n", + " elif cls == \"corrosion\":\n", + " for _ in range(int(rng.integers(1, 4))):\n", + " m |= blob(rng, rng.uniform(14, 50), rng.uniform(14, 50), 3 + 9 * sev * rng.uniform(0.6, 1.1))\n", + " elif cls == \"damage\":\n", + " m = blob(rng, rng.uniform(20, 44), rng.uniform(20, 44), 6 + 12 * sev, 0.3)\n", + " elif cls == \"leak\":\n", + " x0, y0 = rng.uniform(16, 48), rng.uniform(8, 24)\n", + " w, ln = 2 + 4 * sev, 12 + 26 * sev\n", + " m = (np.abs(XX - x0 - 0.15 * (YY - y0)) < w * (1 - 0.4 * (YY - y0) / ln)) & (YY > y0) & (YY < y0 + ln)\n", + " return m\n", + "\n", + "\n", + "def make_images(n=600, seed=0, mud=0.0, p_none=0.25, dark=False):\n", + " \"\"\"가상 외관 사진 n장(+진흙 오염 mud 비율)과 관리 기록·정답. 반환: meta(DataFrame), imgs(n,64,64,3), masks(n,64,64).\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " imgs, masks, rows = np.zeros((n, SZ, SZ, 3), np.float32), np.zeros((n, SZ, SZ), bool), []\n", + " for i in range(n):\n", + " cls = \"none\" if rng.random() < p_none else str(rng.choice(CLASSES[1:], p=[0.25, 0.3, 0.25, 0.2]))\n", + " sev = 0.0 if cls == \"none\" else float(rng.beta(1.6, 2.0))\n", + " base = np.array([0.42, 0.46, 0.40]) * (1 + 0.5 * ndi.gaussian_filter(rng.normal(size=(SZ, SZ)), 8)[..., None] * 3)\n", + " img = base + rng.normal(0, 0.012, (SZ, SZ, 3))\n", + " m = draw(rng, cls, sev)\n", + " a = ndi.gaussian_filter(m.astype(float), 0.8)[..., None] # 경계를 부드럽게 섞는다\n", + " if cls == \"crack\":\n", + " col = img * rng.uniform(0.2, 0.45)\n", + " elif cls == \"corrosion\":\n", + " col = np.array([0.55, 0.30, 0.12]) * rng.uniform(0.75, 1.15) * (0.85 + 0.3 * rng.random((SZ, SZ, 1)))\n", + " elif cls == \"damage\":\n", + " col = np.ones_like(img) * np.array([0.34, 0.34, 0.36]) * (1.0 - 0.45 * sev) * rng.uniform(0.75, 1.0)\n", + " rim = m & ~ndi.binary_erosion(m, iterations=2)\n", + " a = np.maximum(a, 0.0); img = np.where(rim[..., None], 0.7, img)\n", + " elif cls == \"leak\":\n", + " col = np.ones_like(img) * np.array([0.10, 0.09, 0.08]) * rng.uniform(0.8, 2.6)\n", + " else:\n", + " col = img\n", + " img = img * (1 - a) + col * a\n", + " if rng.random() < 0.35: # 무해한 얼룩(결함 아님): 옅은 갈색 번짐\n", + " st = blob(rng, rng.uniform(5, 58), rng.uniform(5, 58), rng.uniform(6, 14), 0.5)\n", + " al = ndi.gaussian_filter(st.astype(float), 2.5)[..., None] * rng.uniform(0.15, 0.4)\n", + " img = img * (1 - al) + np.array([0.50, 0.40, 0.30]) * al\n", + " if rng.random() < 0.3: # 무해한 긴 스크래치(밝은 선)\n", + " y0, x0, ang = rng.uniform(5, 58), rng.uniform(5, 58), rng.uniform(0, np.pi)\n", + " for t in np.linspace(0, 25, 40):\n", + " yy, xx = int(y0 + t * np.sin(ang)), int(x0 + t * np.cos(ang))\n", + " if 0 <= yy < SZ and 0 <= xx < SZ:\n", + " img[yy, xx] = np.clip(img[yy, xx] * 1.5, 0, 1)\n", + " if mud > 0: # 진흙 오염: 결함 위를 덮을 수 있다(정답 마스크는 그대로)\n", + " mm = np.zeros((SZ, SZ), bool)\n", + " while mm.mean() < mud:\n", + " mm |= blob(rng, rng.uniform(0, SZ), rng.uniform(0, SZ), rng.uniform(5, 11), 0.5)\n", + " img[mm] = np.array([0.38, 0.30, 0.22]) * (0.85 + 0.3 * rng.random((mm.sum(), 1)))\n", + " gain = rng.uniform(0.3, 0.55) if dark else rng.uniform(0.5, 1.15)\n", + " ang = rng.uniform(0, 2 * np.pi)\n", + " light = gain * (1 + 0.3 * (np.cos(ang) * (XX - 32) + np.sin(ang) * (YY - 32)) / 32)\n", + " imgs[i] = np.clip(img * light[..., None] + rng.normal(0, 0.025, (SZ, SZ, 3)), 0, 1)\n", + " masks[i] = m\n", + " area = float(m.mean())\n", + " depth = {\"none\": 0.0, \"crack\": 0.3 + 3.0 * sev, \"corrosion\": 0.1 + 1.5 * sev, \"damage\": 1.0 + 8.0 * sev, \"leak\": 0.0}[cls] * rng.lognormal(0, 0.1)\n", + " idx = float(np.clip(100 * (1 - np.exp(-(CLS_W[cls] * area * 9 + 0.05 * depth))) + rng.normal(0, 1.5), 0, 100))\n", + " rows.append({\"img_id\": f\"IMG{seed:02d}{i:04d}\", \"cls\": cls, \"sev\": sev, \"area_frac\": area, \"depth_mm\": depth, \"damage_idx\": idx,\n", + " \"grade\": int(grade_of(idx)), \"gain\": gain, \"mud\": mud})\n", + " meta = pd.DataFrame(rows)\n", + " # 장비 관리 기록(제원 요약): 사진 판정과 대조할 값\n", + " age = rng.integers(1, 25, n)\n", + " meta[\"day\"] = np.sort(rng.integers(0, 90, n))\n", + " meta[\"equip_type\"] = rng.choice([\"vehicle\", \"generator\", \"comm\", \"trailer\"], n, p=[0.4, 0.2, 0.2, 0.2])\n", + " meta[\"mfg_year\"] = 2026 - age\n", + " meta[\"op_hours\"] = np.clip(rng.normal(180 * age, 400, n) + 8 * meta[\"damage_idx\"], 10, 20000)\n", + " meta[\"env\"] = rng.choice([\"coastal\", \"inland\"], n, p=[0.3, 0.7])\n", + " reg = np.clip(meta[\"grade\"] + rng.choice([-1, 0, 0, 0, 0, 0, 1, 2], n), 1, 3) # 장비 관리 기록상 직전 정비 등급\n", + " meta[\"reg_grade\"] = reg\n", + " meta[\"emergency\"] = rng.random(n) < 0.04 # 긴급 점검 요청(화질 불량이어도 보존)\n", + " return meta, imgs, masks\n", + "# ---- 전처리(조명 보정·표준화)와 결함 후보 분할, 특징 추출 (cv2 대신 numpy·scipy 사용) ----\n", + "def correct(img):\n", + " \"\"\"조명 보정: 밝기에 평면(조명 기울기)을 적합해 나눈다. 어두운 결함 화소는 제외하고 두 번 반복 적합한다. 반환: 보정 밝기(정상 면 약 1), 색 비율.\"\"\"\n", + " g = img.mean(axis=2)\n", + " A = np.c_[np.ones(SZ * SZ), XX.ravel() / SZ, YY.ravel() / SZ]\n", + " keep = np.ones(SZ * SZ, bool)\n", + " for _ in range(3):\n", + " coef, *_ = np.linalg.lstsq(A[keep], g.ravel()[keep], rcond=None)\n", + " plane = (A @ coef).reshape(SZ, SZ)\n", + " keep = (np.abs(g - plane) < 0.08 * np.maximum(plane, 1e-3)).ravel()\n", + " if keep.sum() < 200:\n", + " keep[:] = True\n", + " c = g / np.maximum(plane, 1e-3)\n", + " chroma = img[..., 0] / (img.sum(axis=2) + 1e-6)\n", + " return c, chroma\n", + "\n", + "\n", + "SEGP = {\"dark\": 0.7, \"rust\": 0.5, \"rim\": 1.45} # 분할 임계값(어두움 보정 밝기, 붉은 비율, 밝은 테두리): 재보정 대상\n", + "\n", + "\n", + "def segment(img):\n", + " \"\"\"결함 후보 마스크: 어두운 영역(균열·누유·파손) + 붉은 갈색 영역(부식) + 밝은 테두리(파손).\"\"\"\n", + " c, chroma = correct(img)\n", + " dark = c < SEGP[\"dark\"]\n", + " rust = (chroma > SEGP[\"rust\"]) & (c > 0.45)\n", + " rim = c > SEGP[\"rim\"]\n", + " m = dark | rust | ndi.binary_dilation(rim, iterations=1) & ndi.binary_dilation(dark, iterations=3)\n", + " m = ndi.binary_opening(m, structure=np.ones((2, 2))) if m.sum() > 12 else m\n", + " return m, c, chroma, dark, rust, rim\n", + "\n", + "\n", + "def extract(img):\n", + " \"\"\"분할 결과와 색·모양에서 24개 특징을 만든다.\"\"\"\n", + " m, c, chroma, dark, rust, rim = segment(img)\n", + " lab, n = ndi.label(m)\n", + " area = float(m.sum())\n", + " if n:\n", + " sizes = ndi.sum(m, lab, range(1, n + 1))\n", + " k = int(np.argmax(sizes)) + 1\n", + " sl = ndi.find_objects(lab)[k - 1]\n", + " h, w = sl[0].stop - sl[0].start, sl[1].stop - sl[1].start\n", + " big = float(sizes.max())\n", + " elong, fill = max(h, w) / max(min(h, w), 1), big / (h * w)\n", + " else:\n", + " big, elong, fill = 0.0, 1.0, 0.0\n", + " gy, gx = np.gradient(c)\n", + " mag = np.hypot(gx, gy)\n", + " inside = m.sum() > 0\n", + " return {\"area_px\": area, \"area_frac\": area / SZ ** 2, \"n_comp\": float(n), \"big_px\": big, \"elong\": elong, \"fill\": fill,\n", + " \"dark_frac\": float(dark.mean()), \"rust_frac\": float(rust.mean()), \"rim_frac\": float(rim.mean()),\n", + " \"c_mean\": float(c.mean()), \"c_std\": float(c.std()), \"c_min\": float(c.min()), \"edge_mean\": float(mag.mean()), \"edge_p95\": float(np.quantile(mag, 0.95)),\n", + " \"chroma_mean\": float(chroma.mean()), \"chroma_max\": float(chroma.max()),\n", + " \"in_dark\": float(c[m].mean()) if inside else 1.0, \"in_chroma\": float(chroma[m].mean()) if inside else float(chroma.mean()),\n", + " \"ring_contrast\": float(c[ndi.binary_dilation(m, iterations=3) & ~m].mean() - c[m].mean()) if inside and (~m).any() else 0.0,\n", + " \"thin\": float(1 - fill), \"gain\": float(img.mean()), \"sat\": float((img.max(axis=2) - img.min(axis=2)).mean()),\n", + " \"cy\": float(ndi.center_of_mass(m)[0] / SZ) if inside else 0.5, \"cx\": float(ndi.center_of_mass(m)[1] / SZ) if inside else 0.5}\n", + "\n", + "\n", + "def feature_table(imgs):\n", + " return pd.DataFrame([extract(im) for im in imgs])\n", + "# ---- 판정 파이프라인: 결함 유형 분류 + 손상도 회귀(분류 확률 포함) -> 정비 등급 ----\n", + "def fit_pipeline(meta, F):\n", + " \"\"\"결함 유형 분류기와 손상도 회귀기를 학습한다. 회귀기 입력의 분류 확률은 교차 예측으로 만들어 누설을 막는다.\"\"\"\n", + " clf = RandomForestClassifier(n_estimators=150, random_state=0, n_jobs=1)\n", + " P = cross_val_predict(clf, F, meta[\"cls\"], cv=4, method=\"predict_proba\")\n", + " clf.fit(F, meta[\"cls\"])\n", + " reg = GradientBoostingRegressor(n_estimators=200, max_depth=3, learning_rate=0.06, random_state=0).fit(np.c_[F.values, P], meta[\"damage_idx\"])\n", + " return {\"clf\": clf, \"reg\": reg, \"cols\": list(F.columns)}\n", + "\n", + "\n", + "def predict_pipeline(pl, F):\n", + " X = F[pl[\"cols\"]]\n", + " P = pl[\"clf\"].predict_proba(X)\n", + " idx = np.clip(pl[\"reg\"].predict(np.c_[X.values, P]), 0, 100)\n", + " return pl[\"clf\"].classes_[P.argmax(axis=1)], P, idx, P.max(axis=1)\n", + "# ---- 사진 품질 게이트: 진흙·오염 가림 추정과 판정 신뢰도 지수 ----\n", + "def mud_est(img):\n", + " \"\"\"진흙(갈색, 채도 낮은 붉은 기) 화소 비율을 추정한다. 3x3 평균으로 잡음을 줄인 뒤 색 비율로 판단하며, 실제 가림 비율보다 작게 나오지만 순서는 유지된다.\"\"\"\n", + " sm = ndi.uniform_filter(img, size=(3, 3, 1))\n", + " s = sm.sum(axis=2) + 1e-6\n", + " rr, gg = sm[..., 0] / s, sm[..., 1] / s\n", + " return float(((rr > 0.385) & (rr < 0.46) & (gg < 0.325)).mean())\n", + "\n", + "\n", + "def confidence(P, idx, F, mud):\n", + " \"\"\"판정 신뢰도 지수 0~100 = 50 x 분류 확률 최대값 + 35 x 등급 경계까지 거리(8점에서 포화) + 15 x 사진 품질. 오염 의심(mud 추정 1% 이상)이면 상한 75로 제한한다.\"\"\"\n", + " bd = np.minimum(np.abs(idx - 35), np.abs(idx - 65))\n", + " qual = np.clip(1.0 - np.clip(0.55 - F[\"gain\"].values, 0, None) * 2 - np.clip(0.9 - F[\"c_mean\"].values, 0, None), 0, 1)\n", + " c = 50 * P.max(axis=1) + 35 * np.clip(bd / 8, 0, 1) + 15 * qual\n", + " return np.where(np.asarray(mud) >= 0.01, np.minimum(c, 75.0), c)\n", + "\n", + "mtr, itr, _ = make_images(600, seed=1)\n", + "Ftr = feature_table(itr)\n", + "pl = fit_pipeline(mtr, Ftr)\n", + "reg_p = GradientBoostingRegressor(n_estimators=150, max_depth=3, learning_rate=0.07, random_state=0)\n", + "Xd = np.c_[Ftr[pl[\"cols\"]].values, cross_val_predict(RandomForestClassifier(n_estimators=80, random_state=0, n_jobs=1), Ftr, mtr[\"cls\"], cv=3, method=\"predict_proba\")]\n", + "dm = mtr[\"depth_mm\"].values > 0.2\n", + "depth_model = reg_p.fit(Xd[dm], mtr[\"depth_mm\"].values[dm])\n", + "MM2 = 1.5 ** 2 # 1화소 = 1.5mm 가정 -> 면적 mm2\n", + "GROUPS = {\"결함 면적·모양\": [\"area_px\", \"area_frac\", \"n_comp\", \"big_px\", \"elong\", \"fill\", \"thin\", \"cy\", \"cx\"], \"명암 대비\": [\"dark_frac\", \"c_mean\", \"c_std\", \"c_min\", \"in_dark\", \"ring_contrast\"],\n", + " \"붉은 갈색 색상\": [\"rust_frac\", \"chroma_mean\", \"chroma_max\", \"in_chroma\", \"sat\"], \"가장자리·테두리\": [\"edge_mean\", \"edge_p95\", \"rim_frac\"], \"조명 밝기\": [\"gain\"]}\n", + "MEAN = Ftr.mean()\n", + "\n", + "\n", + "def contrib(F):\n", + " \"\"\"변수군을 평균값으로 대체했을 때 손상도 점수 변화(양수 = 그 변수군이 손상도를 높인 요인). 사진 여러 장을 한 번에 계산한다.\"\"\"\n", + " full = predict_pipeline(pl, F)[2]\n", + " out = {}\n", + " for g, cols in GROUPS.items():\n", + " x = F.copy(); x[cols] = MEAN[cols].values\n", + " out[g] = full - predict_pipeline(pl, x)[2]\n", + " return pd.DataFrame(out, index=F.index)\n", + "\n", + "\n", + "def top3(F):\n", + " return contrib(F).apply(lambda r: r.reindex(r.abs().sort_values(ascending=False).index)[:3].to_dict(), axis=1)\n", + "\n", + "\n", + "def inspect(img, pid):\n", + " \"\"\"업로드 사진 한 장을 판정한다. 결과는 항상 '검사 대기'이며 자동 확정하지 않는다.\"\"\"\n", + " F = feature_table([img])\n", + " cls, P, idx, _ = predict_pipeline(pl, F)\n", + " mud = mud_est(img)\n", + " conf = float(confidence(P, idx, F, [mud])[0])\n", + " m = segment(img)[0]\n", + " lab, n = ndi.label(m)\n", + " box = None\n", + " if n:\n", + " k = int(np.argmax(ndi.sum(m, lab, range(1, n + 1)))) + 1\n", + " sl = ndi.find_objects(lab)[k - 1]\n", + " box = (sl[1].start, sl[0].start, sl[1].stop - sl[1].start, sl[0].stop - sl[0].start)\n", + " dep = float(np.clip(depth_model.predict(np.c_[F[pl[\"cols\"]].values, P])[0], 0, None)) if cls[0] in (\"crack\", \"corrosion\", \"damage\") else 0.0\n", + " if mud >= 0.03 or F[\"gain\"].iloc[0] < 0.17:\n", + " state = \"재촬영 요구(오염·저조도)\"\n", + " elif conf >= 80:\n", + " state = \"검사 대기(신뢰도 높음, 검사관 확인 후 확정)\"\n", + " else:\n", + " state = \"검사 대기(신뢰도 낮음, 검사관 정밀 확인)\"\n", + " return {\"id\": pid, \"cls\": cls[0], \"area_mm2\": float(F[\"area_px\"].iloc[0] * MM2), \"depth\": dep, \"idx\": float(idx[0]), \"grade\": int(grade_of(idx[0])), \"conf\": conf, \"mud\": mud, \"box\": box, \"mask\": m, \"state\": state, \"F\": F}\n", + "\n", + "\n", + "# ---------------- 1) 업로드 시뮬레이션: 조건이 다른 사진 8장 ----------------\n", + "mt, it, kt = make_images(300, seed=2)\n", + "mud_m, imud, kmud = make_images(30, seed=11, mud=0.2)\n", + "dark_m, idk, kdk = make_images(30, seed=12, dark=True)\n", + "picks = [(\"U01\", it[next(i for i in range(300) if mt.cls[i] == \"crack\")], kt, mt, \"crack\"), (\"U02\", it[next(i for i in range(300) if mt.cls[i] == \"corrosion\")], kt, mt, \"corrosion\"),\n", + " (\"U03\", it[next(i for i in range(300) if mt.cls[i] == \"damage\")], kt, mt, \"damage\"), (\"U04\", it[next(i for i in range(300) if mt.cls[i] == \"leak\")], kt, mt, \"leak\"),\n", + " (\"U05\", it[next(i for i in range(300) if mt.cls[i] == \"none\")], kt, mt, \"none\"), (\"U06\", idk[0], kdk, dark_m, None), (\"U07\", imud[0], kmud, mud_m, None), (\"U08\", imud[1], kmud, mud_m, None)]\n", + "res = []\n", + "for pid, img, _, _, _ in picks:\n", + " res.append(inspect(img, pid))\n", + "true_rows = [mt.loc[next(i for i in range(300) if mt.cls[i] == c)] for c in (\"crack\", \"corrosion\", \"damage\", \"leak\", \"none\")] + [dark_m.loc[0], mud_m.loc[0], mud_m.loc[1]]\n", + "tb = pd.DataFrame([{\"사진\": r[\"id\"], \"판정 유형\": KO_CLS[r[\"cls\"]], \"실제 유형\": KO_CLS[t[\"cls\"]], \"면적(mm2)\": r[\"area_mm2\"], \"깊이(mm)\": r[\"depth\"], \"손상도\": r[\"idx\"], \"실제 손상도\": t[\"damage_idx\"],\n", + " \"등급\": GRADE_NAME[r[\"grade\"]], \"실제 등급\": GRADE_NAME[int(t[\"grade\"])], \"신뢰도\": r[\"conf\"], \"오염\": r[\"mud\"]} for r, t in zip(res, true_rows)])\n", + "print(\"[1] 업로드 판정 결과(8장, 면적은 1화소 1.5mm 가정) [교육용가상자료]\")\n", + "print(tb.round(2).to_string(index=False))\n", + "for r in res:\n", + " print(f\" {r['id']}: {r['state']}\")\n", + "\n", + "# ---------------- 2) 기여도 상위 3개 자동 산출(평가 150장) ----------------\n", + "mte, ite, _ = make_images(300, seed=2)\n", + "Fte = feature_table(ite)\n", + "T3 = top3(Fte)\n", + "ok = int(sum(len(t) == 3 and all(np.isfinite(v) for v in t.values()) for t in T3))\n", + "texts = list(T3)\n", + "print(f\"\\n[2] 평가 {len(ite)}장 중 기여도 상위 3개 요인 자동 산출 {ok}건 ({ok / len(ite):.0%}, 목표 100%)\")\n", + "avg = pd.DataFrame(texts).abs().mean().sort_values(ascending=False)\n", + "print(\" 변수군별 평균 기여 크기(손상도 점):\", \", \".join(f\"{k} {v:.1f}\" for k, v in avg.items()))\n", + "\n", + "\n", + "def explain(r):\n", + " t3 = top3(r[\"F\"]).iloc[0]\n", + " why = {\"결함 면적·모양\": (\"결함 면적·모양이 평균보다 커서\", \"결함 면적·모양이 평균보다 작아\"), \"명암 대비\": (\"표면과 크게 다른 어두운 영역이 있어\", \"어두운 영역이 적어\"),\n", + " \"붉은 갈색 색상\": (\"붉은 갈색 부식 색상이 넓어\", \"붉은 갈색 색상이 적어\"), \"가장자리·테두리\": (\"뚜렷한 테두리가 있어\", \"테두리 단서가 약해\"), \"조명 밝기\": (\"조명 밝기 영향으로\", \"조명 밝기 영향으로\")}\n", + " main = list(t3)[0]\n", + " return f\"{r['id']}: {why[main][t3[main] < 0]} 손상도 {r['idx']:.0f}점({GRADE_NAME[r['grade']]}) 판정. 주요 요인 \" + \", \".join(f\"{g}({v:+.1f}점)\" for g, v in t3.items())\n", + "\n", + "\n", + "print(\"[3] 판정 근거 자연어 설명(결함 있는 3장)\")\n", + "for r in (res[0], res[1], res[2]):\n", + " print(\" -\", explain(r))\n", + "\n", + "# ---------------- 3) 손상 부위 시각화 정확도: 박스와 정답 마스크 겹침 ----------------\n", + "ious = []\n", + "for r, (pid, img, kk, mm, _) in zip(res[:4], picks[:4]):\n", + " i = next(j for j in range(300) if mt.cls[j] == {\"U01\": \"crack\", \"U02\": \"corrosion\", \"U03\": \"damage\", \"U04\": \"leak\"}[pid])\n", + " gt = kk[i]\n", + " ious.append((pid, (r[\"mask\"] & gt).sum() / max((r[\"mask\"] | gt).sum(), 1)))\n", + "print(\"[4] 손상 부위 표시와 정답 마스크의 IoU:\", \", \".join(f\"{p} {v:.2f}\" for p, v in ious))\n", + "print(\" 화면은 자동 확정 버튼 없이 '검사 대기 등록'만 제공하며, 최종 정비 등급은 검사관이 확정한다(M7).\")\n", + "\n", + "fig, ax = plt.subplots(2, 4, figsize=(14, 7))\n", + "for a, (pid, img, _, _, _), r in zip(ax.ravel(), picks, res):\n", + " a.imshow(img)\n", + " a.contour(r[\"mask\"], levels=[0.5], colors=\"yellow\", linewidths=1)\n", + " if r[\"box\"]:\n", + " x, y, w, h = r[\"box\"]; a.add_patch(plt.Rectangle((x - .5, y - .5), w, h, fill=False, ec=\"red\", lw=1.5))\n", + " a.set_title(f\"{pid} {r['cls']} idx {r['idx']:.0f} grade {r['grade']} conf {r['conf']:.0f}\", fontsize=9); a.axis(\"off\")\n", + "plt.suptitle(\"Upload inspection view (synthetic photos): yellow = defect region, red = box\"); plt.tight_layout(); plt.show()\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 06 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + "[1] 업로드 판정 결과(8장, 면적은 1화소 1.5mm 가정) [교육용가상자료]\n", + " 사진 판정 유형 실제 유형 면적(mm2) 깊이(mm) 손상도 실제 손상도 등급 실제 등급 신뢰도 오염\n", + "U01 균열 균열 121.50 1.67 19.39 25.53 현장정비 현장정비 91.72 0.00\n", + "U02 부식 부식 171.00 0.44 24.12 24.49 현장정비 현장정비 93.58 0.00\n", + "U03 파손 파손 857.25 4.91 82.32 85.72 창정비 창정비 96.30 0.00\n", + "U04 누유 누유 265.50 0.00 14.31 14.60 현장정비 현장정비 88.68 0.00\n", + "U05 결함 없음 결함 없음 0.00 0.00 1.17 0.00 현장정비 현장정비 91.61 0.00\n", + "U06 누유 누유 231.75 0.00 15.84 10.12 현장정비 현장정비 86.90 0.00\n", + "U07 파손 결함 없음 103.50 3.21 70.75 1.96 창정비 현장정비 64.69 0.02\n", + "U08 파손 파손 339.75 4.17 57.16 66.53 야전정비 창정비 72.90 0.02\n", + " U01: 검사 대기(신뢰도 높음, 검사관 확인 후 확정)\n", + " U02: 검사 대기(신뢰도 높음, 검사관 확인 후 확정)\n", + " U03: 검사 대기(신뢰도 높음, 검사관 확인 후 확정)\n", + " U04: 검사 대기(신뢰도 높음, 검사관 확인 후 확정)\n", + " U05: 검사 대기(신뢰도 높음, 검사관 확인 후 확정)\n", + " U06: 재촬영 요구(오염·저조도)\n", + " U07: 검사 대기(신뢰도 낮음, 검사관 정밀 확인)\n", + " U08: 검사 대기(신뢰도 낮음, 검사관 정밀 확인)\n", + "\n", + "[2] 평가 300장 중 기여도 상위 3개 요인 자동 산출 300건 (100%, 목표 100%)\n", + " 변수군별 평균 기여 크기(손상도 점): 결함 면적·모양 25.1, 붉은 갈색 색상 6.3, 명암 대비 5.3, 조명 밝기 2.6, 가장자리·테두리 1.0\n", + "[3] 판정 근거 자연어 설명(결함 있는 3장)\n", + " - U01: 결함 면적·모양이 평균보다 작아 손상도 19점(현장정비) 판정. 주요 요인 결함 면적·모양(-18.2점), 명암 대비(-5.0점), 붉은 갈색 색상(-1.0점)\n", + " - U02: 결함 면적·모양이 평균보다 작아 손상도 24점(현장정비) 판정. 주요 요인 결함 면적·모양(-11.6점), 명암 대비(-1.8점), 붉은 갈색 색상(+1.7점)\n", + " - U03: 결함 면적·모양이 평균보다 커서 손상도 82점(창정비) 판정. 주요 요인 결함 면적·모양(+23.9점), 붉은 갈색 색상(+19.6점), 명암 대비(+4.7점)\n", + "[4] 손상 부위 표시와 정답 마스크의 IoU: U01 0.95, U02 0.64, U03 0.80, U04 0.98\n", + " 화면은 자동 확정 버튼 없이 '검사 대기 등록'만 제공하며, 최종 정비 등급은 검사관이 확정한다(M7).\n", + "```\n", + "
\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 07. 외관 판정 결과에 대한 검사관 수동 보정 및 최종 정비 등급 HITL 승인 절차 설계\n", + "**소요시간**: 약 3.5시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "외관 판정 결과에 대한 검사관 수동 보정 및 HITL 승인 절차를 설계한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **AI 외관 판정에 대한 검사관 수동 보정과 최종 정비 등급 승인 HITL 절차를 설계하고 검사 운영 비용을 최적화한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **신뢰도 기준으로 간이 승인과 정밀 판독을 나누는 운영안을 전수 정밀 판독 관행과 비교해 비용(판독·과소·과대 판정 손실)을 계산하고, 신뢰도 기준과 검사관 일일 한도에 따른 민감도를 본다 과정을 진행하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `src/optimization/qlt03_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": [ + "\"\"\"AI 정비 등급 판정을 신뢰도에 따라 간이 승인·정밀 판독으로 나누는 검사 운영안을 비용으로 최적화하고(전수 정밀 판독 관행 대비, 검사관 일일 한도 제약 반영), 모든 판정을 검사관 수동 보정 후 정비감독관(창정비·보정 건은 품질보증관까지)이 직접 승인하는 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 RandomForestClassifier, GradientBoostingRegressor\n", + "from sklearn.model_selection import cross_val_predict\n", + "\n", + "# ---------------------------------------------------------------------------\n", + "# [교육용가상자료] 장비 외관 사진 생성기 (실제 장비·부대·위치 사진이 아니라 난수로 그린 64x64 합성 이미지)\n", + "# 도장된 금속 표면에 균열(crack)·부식(corrosion)·파손(damage)·누유(leak)를 그리고, 조명 편차와 진흙(mud) 오염을 섞는다.\n", + "# 이미지와 함께 장비 관리 기록(제원 요약)과 정답 결함 마스크·손상도·정비 등급을 만든다.\n", + "# ---------------------------------------------------------------------------\n", + "from scipy import ndimage as ndi\n", + "\n", + "SZ = 64\n", + "CLASSES = [\"none\", \"crack\", \"corrosion\", \"damage\", \"leak\"]\n", + "KO_CLS = {\"none\": \"결함 없음\", \"crack\": \"균열\", \"corrosion\": \"부식\", \"damage\": \"파손\", \"leak\": \"누유\"}\n", + "CLS_W = {\"none\": 0.0, \"crack\": 1.4, \"corrosion\": 1.0, \"damage\": 1.6, \"leak\": 0.6} # 손상도 계산용 결함 유형 가중치\n", + "GRADE_NAME = {1: \"현장정비\", 2: \"야전정비\", 3: \"창정비\"}\n", + "YY, XX = np.mgrid[0:SZ, 0:SZ]\n", + "\n", + "\n", + "def grade_of(idx):\n", + " # [TODO : 학생 실습 7] 외관 판정 결과에 대한 검사관 수동 보정 및 최종 정비 등급 HITL 승인 절차 설계 관련 핵심 로직을 구현하세요.\n", + " # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n", + " # <<< 여기에 코드를 작성하세요 >>>\n", + " pass # 작성 후 pass를 제거하세요.\n", + "def blob(rng, cy, cx, r, rough=0.35):\n", + " \"\"\"불규칙한 원형 영역 마스크.\"\"\"\n", + " # [TODO : 학생 실습 7] 외관 판정 결과에 대한 검사관 수동 보정 및 최종 정비 등급 HITL 승인 절차 설계 관련 핵심 로직을 구현하세요.\n", + " # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n", + " # <<< 여기에 코드를 작성하세요 >>>\n", + " pass # 작성 후 pass를 제거하세요.\n", + "def draw(rng, cls, sev):\n", + " \"\"\"결함 유형·심각도 sev(0~1)에 맞는 정답 마스크와 색 정보를 만든다.\"\"\"\n", + " m = np.zeros((SZ, SZ), bool)\n", + " if cls == \"crack\":\n", + " y, x, ang = rng.uniform(12, 52), rng.uniform(12, 52), rng.uniform(0, np.pi)\n", + " for _ in range(int(20 + 40 * sev)):\n", + " ang += rng.normal(0, 0.35); y += np.sin(ang) * 1.2; x += np.cos(ang) * 1.2\n", + " if 0 <= int(y) < SZ and 0 <= int(x) < SZ:\n", + " m[int(y), int(x)] = True\n", + " m = ndi.binary_dilation(m, iterations=1 + int(sev > 0.6))\n", + " elif cls == \"corrosion\":\n", + " for _ in range(int(rng.integers(1, 4))):\n", + " m |= blob(rng, rng.uniform(14, 50), rng.uniform(14, 50), 3 + 9 * sev * rng.uniform(0.6, 1.1))\n", + " elif cls == \"damage\":\n", + " m = blob(rng, rng.uniform(20, 44), rng.uniform(20, 44), 6 + 12 * sev, 0.3)\n", + " elif cls == \"leak\":\n", + " x0, y0 = rng.uniform(16, 48), rng.uniform(8, 24)\n", + " w, ln = 2 + 4 * sev, 12 + 26 * sev\n", + " m = (np.abs(XX - x0 - 0.15 * (YY - y0)) < w * (1 - 0.4 * (YY - y0) / ln)) & (YY > y0) & (YY < y0 + ln)\n", + " return m\n", + "\n", + "\n", + "def make_images(n=600, seed=0, mud=0.0, p_none=0.25, dark=False):\n", + " \"\"\"가상 외관 사진 n장(+진흙 오염 mud 비율)과 관리 기록·정답. 반환: meta(DataFrame), imgs(n,64,64,3), masks(n,64,64).\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " imgs, masks, rows = np.zeros((n, SZ, SZ, 3), np.float32), np.zeros((n, SZ, SZ), bool), []\n", + " for i in range(n):\n", + " cls = \"none\" if rng.random() < p_none else str(rng.choice(CLASSES[1:], p=[0.25, 0.3, 0.25, 0.2]))\n", + " sev = 0.0 if cls == \"none\" else float(rng.beta(1.6, 2.0))\n", + " base = np.array([0.42, 0.46, 0.40]) * (1 + 0.5 * ndi.gaussian_filter(rng.normal(size=(SZ, SZ)), 8)[..., None] * 3)\n", + " img = base + rng.normal(0, 0.012, (SZ, SZ, 3))\n", + " m = draw(rng, cls, sev)\n", + " a = ndi.gaussian_filter(m.astype(float), 0.8)[..., None] # 경계를 부드럽게 섞는다\n", + " if cls == \"crack\":\n", + " col = img * rng.uniform(0.2, 0.45)\n", + " elif cls == \"corrosion\":\n", + " col = np.array([0.55, 0.30, 0.12]) * rng.uniform(0.75, 1.15) * (0.85 + 0.3 * rng.random((SZ, SZ, 1)))\n", + " elif cls == \"damage\":\n", + " col = np.ones_like(img) * np.array([0.34, 0.34, 0.36]) * (1.0 - 0.45 * sev) * rng.uniform(0.75, 1.0)\n", + " rim = m & ~ndi.binary_erosion(m, iterations=2)\n", + " a = np.maximum(a, 0.0); img = np.where(rim[..., None], 0.7, img)\n", + " elif cls == \"leak\":\n", + " col = np.ones_like(img) * np.array([0.10, 0.09, 0.08]) * rng.uniform(0.8, 2.6)\n", + " else:\n", + " col = img\n", + " img = img * (1 - a) + col * a\n", + " if rng.random() < 0.35: # 무해한 얼룩(결함 아님): 옅은 갈색 번짐\n", + " st = blob(rng, rng.uniform(5, 58), rng.uniform(5, 58), rng.uniform(6, 14), 0.5)\n", + " al = ndi.gaussian_filter(st.astype(float), 2.5)[..., None] * rng.uniform(0.15, 0.4)\n", + " img = img * (1 - al) + np.array([0.50, 0.40, 0.30]) * al\n", + " if rng.random() < 0.3: # 무해한 긴 스크래치(밝은 선)\n", + " y0, x0, ang = rng.uniform(5, 58), rng.uniform(5, 58), rng.uniform(0, np.pi)\n", + " for t in np.linspace(0, 25, 40):\n", + " yy, xx = int(y0 + t * np.sin(ang)), int(x0 + t * np.cos(ang))\n", + " if 0 <= yy < SZ and 0 <= xx < SZ:\n", + " img[yy, xx] = np.clip(img[yy, xx] * 1.5, 0, 1)\n", + " if mud > 0: # 진흙 오염: 결함 위를 덮을 수 있다(정답 마스크는 그대로)\n", + " mm = np.zeros((SZ, SZ), bool)\n", + " while mm.mean() < mud:\n", + " mm |= blob(rng, rng.uniform(0, SZ), rng.uniform(0, SZ), rng.uniform(5, 11), 0.5)\n", + " img[mm] = np.array([0.38, 0.30, 0.22]) * (0.85 + 0.3 * rng.random((mm.sum(), 1)))\n", + " gain = rng.uniform(0.3, 0.55) if dark else rng.uniform(0.5, 1.15)\n", + " ang = rng.uniform(0, 2 * np.pi)\n", + " light = gain * (1 + 0.3 * (np.cos(ang) * (XX - 32) + np.sin(ang) * (YY - 32)) / 32)\n", + " imgs[i] = np.clip(img * light[..., None] + rng.normal(0, 0.025, (SZ, SZ, 3)), 0, 1)\n", + " masks[i] = m\n", + " area = float(m.mean())\n", + " depth = {\"none\": 0.0, \"crack\": 0.3 + 3.0 * sev, \"corrosion\": 0.1 + 1.5 * sev, \"damage\": 1.0 + 8.0 * sev, \"leak\": 0.0}[cls] * rng.lognormal(0, 0.1)\n", + " idx = float(np.clip(100 * (1 - np.exp(-(CLS_W[cls] * area * 9 + 0.05 * depth))) + rng.normal(0, 1.5), 0, 100))\n", + " rows.append({\"img_id\": f\"IMG{seed:02d}{i:04d}\", \"cls\": cls, \"sev\": sev, \"area_frac\": area, \"depth_mm\": depth, \"damage_idx\": idx,\n", + " \"grade\": int(grade_of(idx)), \"gain\": gain, \"mud\": mud})\n", + " meta = pd.DataFrame(rows)\n", + " # 장비 관리 기록(제원 요약): 사진 판정과 대조할 값\n", + " age = rng.integers(1, 25, n)\n", + " meta[\"day\"] = np.sort(rng.integers(0, 90, n))\n", + " meta[\"equip_type\"] = rng.choice([\"vehicle\", \"generator\", \"comm\", \"trailer\"], n, p=[0.4, 0.2, 0.2, 0.2])\n", + " meta[\"mfg_year\"] = 2026 - age\n", + " meta[\"op_hours\"] = np.clip(rng.normal(180 * age, 400, n) + 8 * meta[\"damage_idx\"], 10, 20000)\n", + " meta[\"env\"] = rng.choice([\"coastal\", \"inland\"], n, p=[0.3, 0.7])\n", + " reg = np.clip(meta[\"grade\"] + rng.choice([-1, 0, 0, 0, 0, 0, 1, 2], n), 1, 3) # 장비 관리 기록상 직전 정비 등급\n", + " meta[\"reg_grade\"] = reg\n", + " meta[\"emergency\"] = rng.random(n) < 0.04 # 긴급 점검 요청(화질 불량이어도 보존)\n", + " return meta, imgs, masks\n", + "# ---- 전처리(조명 보정·표준화)와 결함 후보 분할, 특징 추출 (cv2 대신 numpy·scipy 사용) ----\n", + "def correct(img):\n", + " \"\"\"조명 보정: 밝기에 평면(조명 기울기)을 적합해 나눈다. 어두운 결함 화소는 제외하고 두 번 반복 적합한다. 반환: 보정 밝기(정상 면 약 1), 색 비율.\"\"\"\n", + " g = img.mean(axis=2)\n", + " A = np.c_[np.ones(SZ * SZ), XX.ravel() / SZ, YY.ravel() / SZ]\n", + " keep = np.ones(SZ * SZ, bool)\n", + " for _ in range(3):\n", + " coef, *_ = np.linalg.lstsq(A[keep], g.ravel()[keep], rcond=None)\n", + " plane = (A @ coef).reshape(SZ, SZ)\n", + " keep = (np.abs(g - plane) < 0.08 * np.maximum(plane, 1e-3)).ravel()\n", + " if keep.sum() < 200:\n", + " keep[:] = True\n", + " c = g / np.maximum(plane, 1e-3)\n", + " chroma = img[..., 0] / (img.sum(axis=2) + 1e-6)\n", + " return c, chroma\n", + "\n", + "\n", + "SEGP = {\"dark\": 0.7, \"rust\": 0.5, \"rim\": 1.45} # 분할 임계값(어두움 보정 밝기, 붉은 비율, 밝은 테두리): 재보정 대상\n", + "\n", + "\n", + "def segment(img):\n", + " \"\"\"결함 후보 마스크: 어두운 영역(균열·누유·파손) + 붉은 갈색 영역(부식) + 밝은 테두리(파손).\"\"\"\n", + " c, chroma = correct(img)\n", + " dark = c < SEGP[\"dark\"]\n", + " rust = (chroma > SEGP[\"rust\"]) & (c > 0.45)\n", + " rim = c > SEGP[\"rim\"]\n", + " m = dark | rust | ndi.binary_dilation(rim, iterations=1) & ndi.binary_dilation(dark, iterations=3)\n", + " m = ndi.binary_opening(m, structure=np.ones((2, 2))) if m.sum() > 12 else m\n", + " return m, c, chroma, dark, rust, rim\n", + "\n", + "\n", + "def extract(img):\n", + " \"\"\"분할 결과와 색·모양에서 24개 특징을 만든다.\"\"\"\n", + " m, c, chroma, dark, rust, rim = segment(img)\n", + " lab, n = ndi.label(m)\n", + " area = float(m.sum())\n", + " if n:\n", + " sizes = ndi.sum(m, lab, range(1, n + 1))\n", + " k = int(np.argmax(sizes)) + 1\n", + " sl = ndi.find_objects(lab)[k - 1]\n", + " h, w = sl[0].stop - sl[0].start, sl[1].stop - sl[1].start\n", + " big = float(sizes.max())\n", + " elong, fill = max(h, w) / max(min(h, w), 1), big / (h * w)\n", + " else:\n", + " big, elong, fill = 0.0, 1.0, 0.0\n", + " gy, gx = np.gradient(c)\n", + " mag = np.hypot(gx, gy)\n", + " inside = m.sum() > 0\n", + " return {\"area_px\": area, \"area_frac\": area / SZ ** 2, \"n_comp\": float(n), \"big_px\": big, \"elong\": elong, \"fill\": fill,\n", + " \"dark_frac\": float(dark.mean()), \"rust_frac\": float(rust.mean()), \"rim_frac\": float(rim.mean()),\n", + " \"c_mean\": float(c.mean()), \"c_std\": float(c.std()), \"c_min\": float(c.min()), \"edge_mean\": float(mag.mean()), \"edge_p95\": float(np.quantile(mag, 0.95)),\n", + " \"chroma_mean\": float(chroma.mean()), \"chroma_max\": float(chroma.max()),\n", + " \"in_dark\": float(c[m].mean()) if inside else 1.0, \"in_chroma\": float(chroma[m].mean()) if inside else float(chroma.mean()),\n", + " \"ring_contrast\": float(c[ndi.binary_dilation(m, iterations=3) & ~m].mean() - c[m].mean()) if inside and (~m).any() else 0.0,\n", + " \"thin\": float(1 - fill), \"gain\": float(img.mean()), \"sat\": float((img.max(axis=2) - img.min(axis=2)).mean()),\n", + " \"cy\": float(ndi.center_of_mass(m)[0] / SZ) if inside else 0.5, \"cx\": float(ndi.center_of_mass(m)[1] / SZ) if inside else 0.5}\n", + "\n", + "\n", + "def feature_table(imgs):\n", + " return pd.DataFrame([extract(im) for im in imgs])\n", + "# ---- 판정 파이프라인: 결함 유형 분류 + 손상도 회귀(분류 확률 포함) -> 정비 등급 ----\n", + "def fit_pipeline(meta, F):\n", + " \"\"\"결함 유형 분류기와 손상도 회귀기를 학습한다. 회귀기 입력의 분류 확률은 교차 예측으로 만들어 누설을 막는다.\"\"\"\n", + " clf = RandomForestClassifier(n_estimators=150, random_state=0, n_jobs=1)\n", + " P = cross_val_predict(clf, F, meta[\"cls\"], cv=4, method=\"predict_proba\")\n", + " clf.fit(F, meta[\"cls\"])\n", + " reg = GradientBoostingRegressor(n_estimators=200, max_depth=3, learning_rate=0.06, random_state=0).fit(np.c_[F.values, P], meta[\"damage_idx\"])\n", + " return {\"clf\": clf, \"reg\": reg, \"cols\": list(F.columns)}\n", + "\n", + "\n", + "def predict_pipeline(pl, F):\n", + " X = F[pl[\"cols\"]]\n", + " P = pl[\"clf\"].predict_proba(X)\n", + " idx = np.clip(pl[\"reg\"].predict(np.c_[X.values, P]), 0, 100)\n", + " return pl[\"clf\"].classes_[P.argmax(axis=1)], P, idx, P.max(axis=1)\n", + "# ---- 사진 품질 게이트: 진흙·오염 가림 추정과 판정 신뢰도 지수 ----\n", + "def mud_est(img):\n", + " \"\"\"진흙(갈색, 채도 낮은 붉은 기) 화소 비율을 추정한다. 3x3 평균으로 잡음을 줄인 뒤 색 비율로 판단하며, 실제 가림 비율보다 작게 나오지만 순서는 유지된다.\"\"\"\n", + " sm = ndi.uniform_filter(img, size=(3, 3, 1))\n", + " s = sm.sum(axis=2) + 1e-6\n", + " rr, gg = sm[..., 0] / s, sm[..., 1] / s\n", + " return float(((rr > 0.385) & (rr < 0.46) & (gg < 0.325)).mean())\n", + "\n", + "\n", + "def confidence(P, idx, F, mud):\n", + " \"\"\"판정 신뢰도 지수 0~100 = 50 x 분류 확률 최대값 + 35 x 등급 경계까지 거리(8점에서 포화) + 15 x 사진 품질. 오염 의심(mud 추정 1% 이상)이면 상한 75로 제한한다.\"\"\"\n", + " bd = np.minimum(np.abs(idx - 35), np.abs(idx - 65))\n", + " qual = np.clip(1.0 - np.clip(0.55 - F[\"gain\"].values, 0, None) * 2 - np.clip(0.9 - F[\"c_mean\"].values, 0, None), 0, 1)\n", + " c = 50 * P.max(axis=1) + 35 * np.clip(bd / 8, 0, 1) + 15 * qual\n", + " return np.where(np.asarray(mud) >= 0.01, np.minimum(c, 75.0), c)\n", + "\n", + "mtr, itr, _ = make_images(600, seed=1)\n", + "Ftr = feature_table(itr)\n", + "pl = fit_pipeline(mtr, Ftr)\n", + "\n", + "\n", + "def judge(seed, n, **kw):\n", + " \"\"\"사진 n장을 만들어 AI 판정(등급·신뢰도)과 정답 등급을 돌려준다.\"\"\"\n", + " m, im, _ = make_images(n, seed=seed, **kw)\n", + " F = feature_table(im)\n", + " cls, P, idx, _ = predict_pipeline(pl, F)\n", + " conf = confidence(P, idx, F, [mud_est(x) for x in im])\n", + " return pd.DataFrame({\"true\": m[\"grade\"].values, \"ai\": grade_of(idx), \"conf\": conf, \"idx\": idx, \"cls\": cls, \"id\": m[\"img_id\"].values})\n", + "\n", + "\n", + "SETS = {\"평상(정상 조명)\": judge(2, 300), \"저조도 사진 많음\": judge(7, 150, dark=True), \"진흙 가림 20%\": judge(8, 150, mud=0.2)}\n", + "\n", + "# ---------------- 1) 비용 모델(교육용 가정, 만원/장) ----------------\n", + "C_FULL, C_QUICK, C_AI = 0.9, 0.25, 0.05 # 정밀 판독, 간이 승인(AI 판정 열람 후 승인), AI 판정 전산 비용\n", + "P_HUMAN, P_CATCH = 0.08, 0.5 # 검사관 정밀 판독 오류율(+-1 등급), 간이 승인 시 AI 오판을 걸러내는 확률\n", + "LOSS_UNDER, LOSS_OVER = 20.0, 6.0 # 과소 판정(재입고·고장 위험) / 과대 판정(과다 정비) 1등급당 손실\n", + "CAP = 120 # 검사관 일일 정밀 판독 한도(장)\n", + "\n", + "\n", + "def loss(true, grade):\n", + " d = grade - true\n", + " return np.where(d < 0, -d * LOSS_UNDER, d * LOSS_OVER)\n", + "\n", + "\n", + "def operate(df, T, cap=CAP, seed=0):\n", + " \"\"\"신뢰도 T 이상은 간이 승인, 미만은 정밀 판독(검사관 보정). 정밀 판독 한도를 넘으면 신뢰도가 낮은 순서대로만 정밀 판독한다.\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " n = len(df)\n", + " full = (df[\"conf\"] < T).values\n", + " if full.sum() > cap:\n", + " order = np.argsort(df[\"conf\"].values)\n", + " full = np.zeros(n, bool); full[order[:cap]] = True\n", + " final = df[\"ai\"].values.copy()\n", + " err = rng.random(n) < P_HUMAN\n", + " human = np.clip(df[\"true\"].values + np.where(err, rng.choice([-1, 1], n), 0), 1, 3)\n", + " final[full] = human[full]\n", + " wrong_ai = (df[\"ai\"].values != df[\"true\"].values) & ~full\n", + " caught = wrong_ai & (rng.random(n) < P_CATCH)\n", + " final[caught] = df[\"true\"].values[caught]\n", + " cost = full.sum() * C_FULL + (~full).sum() * C_QUICK + n * C_AI + loss(df[\"true\"].values, final).sum()\n", + " return cost, full.mean(), (final == df[\"true\"].values).mean(), int(((final < df[\"true\"].values)).sum())\n", + "\n", + "\n", + "def practice(df, seed=0):\n", + " \"\"\"관행: 모든 사진을 검사관이 정밀 판독(한도 없음, 검사관 오류율 P_HUMAN).\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " err = rng.random(len(df)) < P_HUMAN\n", + " final = np.clip(df[\"true\"].values + np.where(err, rng.choice([-1, 1], len(df)), 0), 1, 3)\n", + " return len(df) * C_FULL + loss(df[\"true\"].values, final).sum(), (final == df[\"true\"].values).mean()\n", + "\n", + "\n", + "rows = []\n", + "for name, df in SETS.items():\n", + " cp, ap = practice(df)\n", + " co, fr, ao, un = operate(df, 80)\n", + " ai_only = len(df) * C_AI + loss(df[\"true\"].values, df[\"ai\"].values).sum()\n", + " rows.append((name, len(df), cp, ai_only, co, 1 - co / cp, fr, ap, ao, un))\n", + "res = pd.DataFrame(rows, columns=[\"시나리오\", \"장수\", \"관행 전수 판독 비용\", \"AI 단독 비용(금지안)\", \"HITL 비용(T=80)\", \"절감률\", \"정밀 판독 비율\", \"관행 일치율\", \"HITL 일치율\", \"HITL 과소 판정\"])\n", + "print(\"[1] 시나리오별 검사 운영 비용(가정 단가, 만원)\")\n", + "print(res.assign(절감률=res[\"절감률\"].map(\"{:.1%}\".format), **{\"정밀 판독 비율\": res[\"정밀 판독 비율\"].map(\"{:.0%}\".format), \"관행 일치율\": res[\"관행 일치율\"].map(\"{:.1%}\".format), \"HITL 일치율\": res[\"HITL 일치율\"].map(\"{:.1%}\".format)}).round(1).to_string(index=False))\n", + "r0 = res.iloc[0]\n", + "passed = [f\"{r['시나리오']} {r['절감률']:.1%}\" for _, r in res.iterrows() if r[\"절감률\"] >= .15]\n", + "print(f\" 평상 절감 {r0['절감률']:.1%} (목표 15% 이상: {'달성' if r0['절감률'] >= .15 else '미달'}), 15% 이상 달성 시나리오: {', '.join(passed)}\")\n", + "print(f\" AI 단독 비용은 평상 {r0['AI 단독 비용(금지안)']:.0f}, 진흙 {res.iloc[2]['AI 단독 비용(금지안)']:.0f}로 오염 사진에서 과소 판정 손실이 폭증하므로 허용하지 않는다(모든 판정은 사람이 승인). 진흙 20%는 정밀 판독이 늘어 절감이 나지 않는다(미달).\")\n", + "\n", + "# ---------------- 2) 신뢰도 기준 T 탐색과 검사관 한도 ----------------\n", + "sw = []\n", + "for T in (60, 70, 80, 90, 95):\n", + " c, fr, a, un = operate(SETS[\"평상(정상 조명)\"], T)\n", + " sw.append((T, c, fr, a, un))\n", + "sw = pd.DataFrame(sw, columns=[\"기준 T\", \"비용\", \"정밀 판독 비율\", \"일치율\", \"과소 판정\"])\n", + "print(\"\\n[2] 신뢰도 기준 T 민감도(평상 300장)\\n\" + sw.round(3).to_string(index=False))\n", + "best = sw.loc[sw[\"비용\"].idxmin()]\n", + "print(f\" 비용 최소 기준 T={int(best['기준 T'])}. 기준을 너무 높이면 정밀 판독이 늘어 비용이 오르고, 너무 낮으면 AI 오판이 통과한다.\")\n", + "cp, _ = practice(SETS[\"진흙 가림 20%\"])\n", + "for cap in (150, 60, 20):\n", + " c, fr, a, un = operate(SETS[\"진흙 가림 20%\"], 80, cap=cap)\n", + " print(f\" 진흙 20% 시나리오, 검사관 한도 {cap}장/일: 비용 {c:.0f} (관행 대비 절감 {1 - c / cp:+.1%}), 정밀 판독 {fr:.0%}, 일치율 {a:.1%}, 과소 판정 {un}장\")\n", + "print(\" 검사관 한도가 줄면 신뢰도 낮은 사진까지 간이 승인으로 넘어가 AI 오판이 걸러지지 않아 손실이 커진다(비용 +). 오염 사진은 재촬영·사전 세척 안내로 줄여야 한다.\")\n", + "\n", + "# ---------------- 3) HITL 결재 워크플로우 ----------------\n", + "USERS = {\"ai_bot\": \"AI\", \"ins_kim\": \"INS\", \"ins_lee\": \"INS\", \"sup_park\": \"SUP\", \"qa_choi\": \"QA\", \"aud_han\": \"AUD\"}\n", + "PERM = {\"AI\": {\"submit\"}, \"INS\": {\"review\", \"correct\", \"reject\"}, \"SUP\": {\"approve\", \"reject\"}, \"QA\": {\"approve\", \"reject\"}, \"AUD\": {\"read\"}}\n", + "\n", + "\n", + "class Flow:\n", + " def __init__(self):\n", + " self.docs, self.log, self.blocked = {}, [], []\n", + "\n", + " def _audit(self, user, action, doc, detail):\n", + " prev = self.log[-1][\"hash\"] if self.log else \"GENESIS\"\n", + " rec = {\"seq\": len(self.log) + 1, \"user\": user, \"action\": action, \"doc\": doc, \"detail\": detail, \"prev\": prev}\n", + " rec[\"hash\"] = hashlib.sha256(json.dumps(rec, ensure_ascii=False, sort_keys=True).encode()).hexdigest()[:12]\n", + " self.log.append(rec)\n", + "\n", + " def verify(self):\n", + " prev = \"GENESIS\"\n", + " for r in self.log:\n", + " body = {k: v for k, v in r.items() if k != \"hash\"}\n", + " if r[\"prev\"] != prev or hashlib.sha256(json.dumps(body, ensure_ascii=False, sort_keys=True).encode()).hexdigest()[:12] != r[\"hash\"]:\n", + " return False, r[\"seq\"]\n", + " prev = r[\"hash\"]\n", + " return True, None\n", + "\n", + " def deny(self, user, act, doc, why):\n", + " self.blocked.append((user, act, doc, why)); self._audit(user, \"DENIED:\" + act, doc, why); return False\n", + "\n", + " def submit(self, user, pid, grade, conf, parent=None):\n", + " if \"submit\" not in PERM.get(USERS.get(user), set()):\n", + " return self.deny(user, \"submit\", \"-\", \"AI 판정 등록 권한 없음\")\n", + " did = f\"INS{len(self.docs) + 1:02d}\"\n", + " self.docs[did] = {\"photo\": pid, \"ai\": grade, \"grade\": grade, \"conf\": conf, \"status\": \"검사대기\", \"line\": [\"INS\", \"SUP\"], \"step\": 0, \"done\": [], \"parent\": parent}\n", + " self._audit(user, \"submit\", did, f\"{pid} AI 등급 {GRADE_NAME[grade]} 신뢰도 {conf:.0f}\")\n", + " return did\n", + "\n", + " def decide(self, user, did, act, reason=\"\", new_grade=None):\n", + " role, d = USERS.get(user), self.docs.get(did)\n", + " if role is None: return self.deny(user, act, did, \"미등록 사용자\")\n", + " if d is None or d[\"status\"] in (\"확정\", \"반려\"): return self.deny(user, act, did, \"결재 대상 아님(처리 완료)\")\n", + " if act not in PERM[role]: return self.deny(user, act, did, f\"{role} 역할은 {act} 불가\")\n", + " if d[\"line\"][d[\"step\"]] != role: return self.deny(user, act, did, \"결재 순서 위반\")\n", + " if user in d[\"done\"]: return self.deny(user, act, did, \"기결재자 재결재 금지\")\n", + " if len(reason.strip()) < 10: return self.deny(user, act, did, \"사유 10자 이상 필수\")\n", + " if act == \"reject\":\n", + " d[\"status\"] = \"반려\"\n", + " else:\n", + " if act == \"correct\":\n", + " d[\"grade\"] = new_grade\n", + " if new_grade != d[\"ai\"] and \"QA\" not in d[\"line\"]:\n", + " d[\"line\"] = d[\"line\"] + [\"QA\"] # 검사관이 AI 판정을 고치면 품질보증관까지 결재\n", + " if d[\"grade\"] == 3 and \"QA\" not in d[\"line\"]:\n", + " d[\"line\"] = d[\"line\"] + [\"QA\"] # 창정비 판정은 품질보증관까지 결재\n", + " d[\"done\"].append(user); d[\"step\"] += 1\n", + " d[\"status\"] = \"확정\" if d[\"step\"] == len(d[\"line\"]) else (\"감독대기\" if d[\"step\"] >= 1 else \"검사대기\")\n", + " self._audit(user, act, did, reason + (f\" -> {GRADE_NAME[new_grade]}\" if new_grade else \"\")); return True\n", + "\n", + " def edit(self, user, did):\n", + " if self.docs[did][\"status\"] == \"확정\": return self.deny(user, \"edit\", did, \"확정 후 등급 변경 금지(재검사 등록)\")\n", + " return True\n", + "\n", + "\n", + "base = SETS[\"평상(정상 조명)\"]\n", + "mix = pd.concat([base[base.conf >= 80].head(3), base[base.conf < 80].head(4), base[base.ai == 3].head(2)]).drop_duplicates(\"id\").head(8).reset_index(drop=True)\n", + "wf = Flow()\n", + "ids = [wf.submit(\"ai_bot\", r.id, int(r.ai), r.conf) for r in mix.itertuples()]\n", + "print(f\"\\n[3] AI 판정 {len(ids)}건 전부 '검사대기' 등록, 자동 확정 {sum(wf.docs[i]['status'] == '확정' for i in ids)}건 (목표 0건)\")\n", + "low = [i for i, r in zip(ids, mix.itertuples()) if r.conf < 80][0]\n", + "first = ids[0]\n", + "wf.decide(\"ins_kim\", first, \"review\", \"AI 판정 열람, 사진과 일치 확인함\")\n", + "wf.decide(\"sup_park\", first, \"approve\", \"검사관 의견 확인, 등급 확정\")\n", + "wf.decide(\"ins_lee\", low, \"correct\", \"경계 부근 결함, 박스 재측정 후 등급 조정\", new_grade=min(3, wf.docs[low][\"ai\"] + 1))\n", + "wf.decide(\"sup_park\", low, \"approve\", \"보정 사유 타당, 품질보증 상신\")\n", + "wf.decide(\"qa_choi\", low, \"approve\", \"보정 근거 사진 대조 완료\")\n", + "rj = ids[2]\n", + "wf.decide(\"ins_kim\", rj, \"reject\", \"오염 사진으로 판독 불가, 재촬영 요구\")\n", + "new = wf.submit(\"ai_bot\", mix.id[2], int(mix.ai[2]), float(mix.conf[2]), parent=rj)\n", + "tgt = ids[-1]\n", + "attacks = [(\"ai_bot\", tgt, \"review\", \"AI 계정이 직접 검토 처리함\"), (\"sup_park\", tgt, \"approve\", \"검사관 검토 없이 감독관이 승인함\"), (\"aud_han\", tgt, \"approve\", \"감사 계정으로 승인 시도함\"),\n", + " (\"ghost\", tgt, \"review\", \"외부 계정으로 검토 시도함\"), (\"ins_kim\", tgt, \"review\", \"\"), (\"ins_kim\", tgt, \"correct\", \"짧음\"), (\"qa_choi\", tgt, \"approve\", \"품질보증관이 순서를 건너뛰어 승인\"),\n", + " (\"ins_kim\", first, \"review\", \"이미 확정된 건을 다시 검토함\"), (\"sup_park\", low, \"reject\", \"확정된 건을 뒤집어 반려함\")]\n", + "for u, doc, a, rs in attacks:\n", + " wf.decide(u, doc, a, rs, new_grade=2 if a == \"correct\" else None)\n", + "wf.edit(\"ins_kim\", first)\n", + "print(f\" 권한 우회 시도 10건 중 차단 {len(wf.blocked)}건 (차단율 {len(wf.blocked) / 10:.0%}, 목표 100%)\")\n", + "for u, a, doc, why in wf.blocked[:4]:\n", + " print(f\" 차단 {u:<9} {a:<8} {doc}: {why}\")\n", + "ok, _ = wf.verify()\n", + "snap = json.loads(json.dumps(wf.log))\n", + "wf.log[3][\"detail\"] = \"사후에 고쳐 쓴 사유\"\n", + "ok2, at = wf.verify()\n", + "wf.log = snap\n", + "print(f\" 감사 로그 {len(wf.log)}건 해시 체인 무결성 {'100%' if ok else '훼손'}; 4번 기록 사후 수정 시 {'통과' if ok2 else f'위변조 검출({at}번)'}\")\n", + "print(f\" {low}: AI {GRADE_NAME[wf.docs[low]['ai']]} -> 검사관 보정 {GRADE_NAME[wf.docs[low]['grade']]}(보정 시 품질보증관까지 3단계 결재, 상태 {wf.docs[low]['status']}); {rj}(반려) -> {new}(재등록, parent 연결)\")\n", + "print(\" 문서 상태:\", pd.Series({k: v[\"status\"] for k, v in wf.docs.items()}).value_counts().to_dict(), \"| 최종 정비 등급은 검사관·정비감독관의 결재로 확정되며 AI는 확정 권한이 없다.\")\n", + "\n", + "fig, ax = plt.subplots(1, 2, figsize=(12, 4))\n", + "x = np.arange(len(res)); ax[0].bar(x - .2, res[\"관행 전수 판독 비용\"], .4, label=\"full inspection\"); ax[0].bar(x + .2, res[\"HITL 비용(T=80)\"], .4, label=\"HITL (T=80)\")\n", + "ax[0].set_xticks(x); ax[0].set_xticklabels([f\"S{i + 1}\" for i in x]); ax[0].legend(); ax[0].set_title(\"Total cost by scenario (10k KRW)\")\n", + "ax[1].plot(sw[\"기준 T\"], sw[\"비용\"], \"o-\"); ax[1].set_title(\"Cost vs confidence threshold T\"); ax[1].set_xlabel(\"T\")\n", + "plt.tight_layout(); plt.show()\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 07 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + "[1] 시나리오별 검사 운영 비용(가정 단가, 만원)\n", + " 시나리오 장수 관행 전수 판독 비용 AI 단독 비용(금지안) HITL 비용(T=80) 절감률 정밀 판독 비율 관행 일치율 HITL 일치율 HITL 과소 판정\n", + "평상(정상 조명) 300 442.0 379.0 178.9 59.5% 22% 94.3% 99.0% 2\n", + "저조도 사진 많음 150 263.0 219.5 189.0 28.2% 55% 92.0% 94.7% 3\n", + "진흙 가림 20% 150 203.0 771.5 219.0 -7.9% 80% 94.0% 89.3% 0\n", + " 평상 절감 59.5% (목표 15% 이상: 달성), 15% 이상 달성 시나리오: 평상(정상 조명) 59.5%, 저조도 사진 많음 28.2%\n", + " AI 단독 비용은 평상 379, 진흙 772로 오염 사진에서 과소 판정 손실이 폭증하므로 허용하지 않는다(모든 판정은 사람이 승인). 진흙 20%는 정밀 판독이 늘어 절감이 나지 않는다(미달).\n", + "\n", + "[2] 신뢰도 기준 T 민감도(평상 300장)\n", + " 기준 T 비용 정밀 판독 비율 일치율 과소 판정\n", + " 60 267.20 0.027 0.967 8\n", + " 70 158.75 0.117 0.990 2\n", + " 80 178.90 0.220 0.990 2\n", + " 90 238.00 0.400 0.977 2\n", + " 95 238.00 0.400 0.977 2\n", + " 비용 최소 기준 T=70. 기준을 너무 높이면 정밀 판독이 늘어 비용이 오르고, 너무 낮으면 AI 오판이 통과한다.\n", + " 진흙 20% 시나리오, 검사관 한도 150장/일: 비용 210 (관행 대비 절감 -3.7%), 정밀 판독 100%, 일치율 94.0%, 과소 판정 1장\n", + " 진흙 20% 시나리오, 검사관 한도 60장/일: 비용 300 (관행 대비 절감 -47.8%), 정밀 판독 40%, 일치율 81.3%, 과소 판정 3장\n", + " 진흙 20% 시나리오, 검사관 한도 20장/일: 비용 396 (관행 대비 절감 -95.1%), 정밀 판독 13%, 일치율 75.3%, 과소 판정 7장\n", + " 검사관 한도가 줄면 신뢰도 낮은 사진까지 간이 승인으로 넘어가 AI 오판이 걸러지지 않아 손실이 커진다(비용 +). 오염 사진은 재촬영·사전 세척 안내로 줄여야 한다.\n", + "\n", + "[3] AI 판정 8건 전부 '검사대기' 등록, 자동 확정 0건 (목표 0건)\n", + " 권한 우회 시도 10건 중 차단 10건 (차단율 100%, 목표 100%)\n", + " 차단 ai_bot review INS08: AI 역할은 review 불가\n", + " 차단 sup_park approve INS08: 결재 순서 위반\n", + " 차단 aud_han approve INS08: AUD 역할은 approve 불가\n", + " 차단 ghost review INS08: 미등록 사용자\n", + " 감사 로그 25건 해시 체인 무결성 100%; 4번 기록 사후 수정 시 위변조 검출(4번)\n", + " INS04: AI 야전정비 -> 검사관 보정 창정비(보정 시 품질보증관까지 3단계 결재, 상태 확정); INS03(반려) -> INS09(재등록, parent 연결)\n", + " 문서 상태: {'검사대기': 6, '확정': 2, '반려': 1} | 최종 정비 등급은 검사관·정비감독관의 결재로 확정되며 AI는 확정 권한이 없다.\n", + "```\n", + "
\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 08. 오염물/진흙 혼입 사진 대량 판정 스트레스 테스트 및 한계치 도출\n", + "**소요시간**: 약 3.0시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "오염물 혼입 사진 대량 판정 스트레스 테스트를 수행한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **오염물·진흙이 섞인 사진을 대량 판정하는 스트레스 테스트를 수행하고 판정 한계치를 도출한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **진흙 혼입 비율을 0~50%로 올린 사진 묶음과 흐림·센서 잡음·저조도+진흙 결합 조건에서 등급 일치율, 재촬영 요구율, 자동 처리 비율, 경보 정확도를 측정하고 품질 게이트의 효과를 비교 수행하고 검증하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `src/dashboard/qlt03_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": [ + "\"\"\"진흙·오염물 혼입 비율을 0~50%로 올린 사진 수백 장과 흐림·센서 잡음·저조도 결합 사진을 일괄 판정하는 스트레스 테스트로 오염 사진 판정 한계치(자동 처리 가능 가림 비율)와 경보 정확도, 대량 처리 시간·대시보드 로딩 시간을 측정하고 품질 게이트(재촬영·검사관 확인)의 효과를 확인한다.\"\"\"\n", + "import time\n", + "import numpy as np\n", + "import pandas as pd\n", + "import matplotlib.pyplot as plt\n", + "from sklearn.ensemble import RandomForestClassifier, GradientBoostingRegressor\n", + "from sklearn.model_selection import cross_val_predict\n", + "\n", + "# ---------------------------------------------------------------------------\n", + "# [교육용가상자료] 장비 외관 사진 생성기 (실제 장비·부대·위치 사진이 아니라 난수로 그린 64x64 합성 이미지)\n", + "# 도장된 금속 표면에 균열(crack)·부식(corrosion)·파손(damage)·누유(leak)를 그리고, 조명 편차와 진흙(mud) 오염을 섞는다.\n", + "# 이미지와 함께 장비 관리 기록(제원 요약)과 정답 결함 마스크·손상도·정비 등급을 만든다.\n", + "# ---------------------------------------------------------------------------\n", + "from scipy import ndimage as ndi\n", + "\n", + "SZ = 64\n", + "CLASSES = [\"none\", \"crack\", \"corrosion\", \"damage\", \"leak\"]\n", + "KO_CLS = {\"none\": \"결함 없음\", \"crack\": \"균열\", \"corrosion\": \"부식\", \"damage\": \"파손\", \"leak\": \"누유\"}\n", + "CLS_W = {\"none\": 0.0, \"crack\": 1.4, \"corrosion\": 1.0, \"damage\": 1.6, \"leak\": 0.6} # 손상도 계산용 결함 유형 가중치\n", + "GRADE_NAME = {1: \"현장정비\", 2: \"야전정비\", 3: \"창정비\"}\n", + "YY, XX = np.mgrid[0:SZ, 0:SZ]\n", + "\n", + "\n", + "def grade_of(idx):\n", + " # [TODO : 학생 실습 8] 오염물/진흙 혼입 사진 대량 판정 스트레스 테스트 및 한계치 도출 관련 핵심 로직을 구현하세요.\n", + " # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n", + " # <<< 여기에 코드를 작성하세요 >>>\n", + " pass # 작성 후 pass를 제거하세요.\n", + "def blob(rng, cy, cx, r, rough=0.35):\n", + " \"\"\"불규칙한 원형 영역 마스크.\"\"\"\n", + " # [TODO : 학생 실습 8] 오염물/진흙 혼입 사진 대량 판정 스트레스 테스트 및 한계치 도출 관련 핵심 로직을 구현하세요.\n", + " # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n", + " # <<< 여기에 코드를 작성하세요 >>>\n", + " pass # 작성 후 pass를 제거하세요.\n", + "def draw(rng, cls, sev):\n", + " \"\"\"결함 유형·심각도 sev(0~1)에 맞는 정답 마스크와 색 정보를 만든다.\"\"\"\n", + " m = np.zeros((SZ, SZ), bool)\n", + " if cls == \"crack\":\n", + " y, x, ang = rng.uniform(12, 52), rng.uniform(12, 52), rng.uniform(0, np.pi)\n", + " for _ in range(int(20 + 40 * sev)):\n", + " ang += rng.normal(0, 0.35); y += np.sin(ang) * 1.2; x += np.cos(ang) * 1.2\n", + " if 0 <= int(y) < SZ and 0 <= int(x) < SZ:\n", + " m[int(y), int(x)] = True\n", + " m = ndi.binary_dilation(m, iterations=1 + int(sev > 0.6))\n", + " elif cls == \"corrosion\":\n", + " for _ in range(int(rng.integers(1, 4))):\n", + " m |= blob(rng, rng.uniform(14, 50), rng.uniform(14, 50), 3 + 9 * sev * rng.uniform(0.6, 1.1))\n", + " elif cls == \"damage\":\n", + " m = blob(rng, rng.uniform(20, 44), rng.uniform(20, 44), 6 + 12 * sev, 0.3)\n", + " elif cls == \"leak\":\n", + " x0, y0 = rng.uniform(16, 48), rng.uniform(8, 24)\n", + " w, ln = 2 + 4 * sev, 12 + 26 * sev\n", + " m = (np.abs(XX - x0 - 0.15 * (YY - y0)) < w * (1 - 0.4 * (YY - y0) / ln)) & (YY > y0) & (YY < y0 + ln)\n", + " return m\n", + "\n", + "\n", + "def make_images(n=600, seed=0, mud=0.0, p_none=0.25, dark=False):\n", + " \"\"\"가상 외관 사진 n장(+진흙 오염 mud 비율)과 관리 기록·정답. 반환: meta(DataFrame), imgs(n,64,64,3), masks(n,64,64).\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " imgs, masks, rows = np.zeros((n, SZ, SZ, 3), np.float32), np.zeros((n, SZ, SZ), bool), []\n", + " for i in range(n):\n", + " cls = \"none\" if rng.random() < p_none else str(rng.choice(CLASSES[1:], p=[0.25, 0.3, 0.25, 0.2]))\n", + " sev = 0.0 if cls == \"none\" else float(rng.beta(1.6, 2.0))\n", + " base = np.array([0.42, 0.46, 0.40]) * (1 + 0.5 * ndi.gaussian_filter(rng.normal(size=(SZ, SZ)), 8)[..., None] * 3)\n", + " img = base + rng.normal(0, 0.012, (SZ, SZ, 3))\n", + " m = draw(rng, cls, sev)\n", + " a = ndi.gaussian_filter(m.astype(float), 0.8)[..., None] # 경계를 부드럽게 섞는다\n", + " if cls == \"crack\":\n", + " col = img * rng.uniform(0.2, 0.45)\n", + " elif cls == \"corrosion\":\n", + " col = np.array([0.55, 0.30, 0.12]) * rng.uniform(0.75, 1.15) * (0.85 + 0.3 * rng.random((SZ, SZ, 1)))\n", + " elif cls == \"damage\":\n", + " col = np.ones_like(img) * np.array([0.34, 0.34, 0.36]) * (1.0 - 0.45 * sev) * rng.uniform(0.75, 1.0)\n", + " rim = m & ~ndi.binary_erosion(m, iterations=2)\n", + " a = np.maximum(a, 0.0); img = np.where(rim[..., None], 0.7, img)\n", + " elif cls == \"leak\":\n", + " col = np.ones_like(img) * np.array([0.10, 0.09, 0.08]) * rng.uniform(0.8, 2.6)\n", + " else:\n", + " col = img\n", + " img = img * (1 - a) + col * a\n", + " if rng.random() < 0.35: # 무해한 얼룩(결함 아님): 옅은 갈색 번짐\n", + " st = blob(rng, rng.uniform(5, 58), rng.uniform(5, 58), rng.uniform(6, 14), 0.5)\n", + " al = ndi.gaussian_filter(st.astype(float), 2.5)[..., None] * rng.uniform(0.15, 0.4)\n", + " img = img * (1 - al) + np.array([0.50, 0.40, 0.30]) * al\n", + " if rng.random() < 0.3: # 무해한 긴 스크래치(밝은 선)\n", + " y0, x0, ang = rng.uniform(5, 58), rng.uniform(5, 58), rng.uniform(0, np.pi)\n", + " for t in np.linspace(0, 25, 40):\n", + " yy, xx = int(y0 + t * np.sin(ang)), int(x0 + t * np.cos(ang))\n", + " if 0 <= yy < SZ and 0 <= xx < SZ:\n", + " img[yy, xx] = np.clip(img[yy, xx] * 1.5, 0, 1)\n", + " if mud > 0: # 진흙 오염: 결함 위를 덮을 수 있다(정답 마스크는 그대로)\n", + " mm = np.zeros((SZ, SZ), bool)\n", + " while mm.mean() < mud:\n", + " mm |= blob(rng, rng.uniform(0, SZ), rng.uniform(0, SZ), rng.uniform(5, 11), 0.5)\n", + " img[mm] = np.array([0.38, 0.30, 0.22]) * (0.85 + 0.3 * rng.random((mm.sum(), 1)))\n", + " gain = rng.uniform(0.3, 0.55) if dark else rng.uniform(0.5, 1.15)\n", + " ang = rng.uniform(0, 2 * np.pi)\n", + " light = gain * (1 + 0.3 * (np.cos(ang) * (XX - 32) + np.sin(ang) * (YY - 32)) / 32)\n", + " imgs[i] = np.clip(img * light[..., None] + rng.normal(0, 0.025, (SZ, SZ, 3)), 0, 1)\n", + " masks[i] = m\n", + " area = float(m.mean())\n", + " depth = {\"none\": 0.0, \"crack\": 0.3 + 3.0 * sev, \"corrosion\": 0.1 + 1.5 * sev, \"damage\": 1.0 + 8.0 * sev, \"leak\": 0.0}[cls] * rng.lognormal(0, 0.1)\n", + " idx = float(np.clip(100 * (1 - np.exp(-(CLS_W[cls] * area * 9 + 0.05 * depth))) + rng.normal(0, 1.5), 0, 100))\n", + " rows.append({\"img_id\": f\"IMG{seed:02d}{i:04d}\", \"cls\": cls, \"sev\": sev, \"area_frac\": area, \"depth_mm\": depth, \"damage_idx\": idx,\n", + " \"grade\": int(grade_of(idx)), \"gain\": gain, \"mud\": mud})\n", + " meta = pd.DataFrame(rows)\n", + " # 장비 관리 기록(제원 요약): 사진 판정과 대조할 값\n", + " age = rng.integers(1, 25, n)\n", + " meta[\"day\"] = np.sort(rng.integers(0, 90, n))\n", + " meta[\"equip_type\"] = rng.choice([\"vehicle\", \"generator\", \"comm\", \"trailer\"], n, p=[0.4, 0.2, 0.2, 0.2])\n", + " meta[\"mfg_year\"] = 2026 - age\n", + " meta[\"op_hours\"] = np.clip(rng.normal(180 * age, 400, n) + 8 * meta[\"damage_idx\"], 10, 20000)\n", + " meta[\"env\"] = rng.choice([\"coastal\", \"inland\"], n, p=[0.3, 0.7])\n", + " reg = np.clip(meta[\"grade\"] + rng.choice([-1, 0, 0, 0, 0, 0, 1, 2], n), 1, 3) # 장비 관리 기록상 직전 정비 등급\n", + " meta[\"reg_grade\"] = reg\n", + " meta[\"emergency\"] = rng.random(n) < 0.04 # 긴급 점검 요청(화질 불량이어도 보존)\n", + " return meta, imgs, masks\n", + "# ---- 전처리(조명 보정·표준화)와 결함 후보 분할, 특징 추출 (cv2 대신 numpy·scipy 사용) ----\n", + "def correct(img):\n", + " \"\"\"조명 보정: 밝기에 평면(조명 기울기)을 적합해 나눈다. 어두운 결함 화소는 제외하고 두 번 반복 적합한다. 반환: 보정 밝기(정상 면 약 1), 색 비율.\"\"\"\n", + " g = img.mean(axis=2)\n", + " A = np.c_[np.ones(SZ * SZ), XX.ravel() / SZ, YY.ravel() / SZ]\n", + " keep = np.ones(SZ * SZ, bool)\n", + " for _ in range(3):\n", + " coef, *_ = np.linalg.lstsq(A[keep], g.ravel()[keep], rcond=None)\n", + " plane = (A @ coef).reshape(SZ, SZ)\n", + " keep = (np.abs(g - plane) < 0.08 * np.maximum(plane, 1e-3)).ravel()\n", + " if keep.sum() < 200:\n", + " keep[:] = True\n", + " c = g / np.maximum(plane, 1e-3)\n", + " chroma = img[..., 0] / (img.sum(axis=2) + 1e-6)\n", + " return c, chroma\n", + "\n", + "\n", + "SEGP = {\"dark\": 0.7, \"rust\": 0.5, \"rim\": 1.45} # 분할 임계값(어두움 보정 밝기, 붉은 비율, 밝은 테두리): 재보정 대상\n", + "\n", + "\n", + "def segment(img):\n", + " \"\"\"결함 후보 마스크: 어두운 영역(균열·누유·파손) + 붉은 갈색 영역(부식) + 밝은 테두리(파손).\"\"\"\n", + " c, chroma = correct(img)\n", + " dark = c < SEGP[\"dark\"]\n", + " rust = (chroma > SEGP[\"rust\"]) & (c > 0.45)\n", + " rim = c > SEGP[\"rim\"]\n", + " m = dark | rust | ndi.binary_dilation(rim, iterations=1) & ndi.binary_dilation(dark, iterations=3)\n", + " m = ndi.binary_opening(m, structure=np.ones((2, 2))) if m.sum() > 12 else m\n", + " return m, c, chroma, dark, rust, rim\n", + "\n", + "\n", + "def extract(img):\n", + " \"\"\"분할 결과와 색·모양에서 24개 특징을 만든다.\"\"\"\n", + " m, c, chroma, dark, rust, rim = segment(img)\n", + " lab, n = ndi.label(m)\n", + " area = float(m.sum())\n", + " if n:\n", + " sizes = ndi.sum(m, lab, range(1, n + 1))\n", + " k = int(np.argmax(sizes)) + 1\n", + " sl = ndi.find_objects(lab)[k - 1]\n", + " h, w = sl[0].stop - sl[0].start, sl[1].stop - sl[1].start\n", + " big = float(sizes.max())\n", + " elong, fill = max(h, w) / max(min(h, w), 1), big / (h * w)\n", + " else:\n", + " big, elong, fill = 0.0, 1.0, 0.0\n", + " gy, gx = np.gradient(c)\n", + " mag = np.hypot(gx, gy)\n", + " inside = m.sum() > 0\n", + " return {\"area_px\": area, \"area_frac\": area / SZ ** 2, \"n_comp\": float(n), \"big_px\": big, \"elong\": elong, \"fill\": fill,\n", + " \"dark_frac\": float(dark.mean()), \"rust_frac\": float(rust.mean()), \"rim_frac\": float(rim.mean()),\n", + " \"c_mean\": float(c.mean()), \"c_std\": float(c.std()), \"c_min\": float(c.min()), \"edge_mean\": float(mag.mean()), \"edge_p95\": float(np.quantile(mag, 0.95)),\n", + " \"chroma_mean\": float(chroma.mean()), \"chroma_max\": float(chroma.max()),\n", + " \"in_dark\": float(c[m].mean()) if inside else 1.0, \"in_chroma\": float(chroma[m].mean()) if inside else float(chroma.mean()),\n", + " \"ring_contrast\": float(c[ndi.binary_dilation(m, iterations=3) & ~m].mean() - c[m].mean()) if inside and (~m).any() else 0.0,\n", + " \"thin\": float(1 - fill), \"gain\": float(img.mean()), \"sat\": float((img.max(axis=2) - img.min(axis=2)).mean()),\n", + " \"cy\": float(ndi.center_of_mass(m)[0] / SZ) if inside else 0.5, \"cx\": float(ndi.center_of_mass(m)[1] / SZ) if inside else 0.5}\n", + "\n", + "\n", + "def feature_table(imgs):\n", + " return pd.DataFrame([extract(im) for im in imgs])\n", + "# ---- 판정 파이프라인: 결함 유형 분류 + 손상도 회귀(분류 확률 포함) -> 정비 등급 ----\n", + "def fit_pipeline(meta, F):\n", + " \"\"\"결함 유형 분류기와 손상도 회귀기를 학습한다. 회귀기 입력의 분류 확률은 교차 예측으로 만들어 누설을 막는다.\"\"\"\n", + " clf = RandomForestClassifier(n_estimators=150, random_state=0, n_jobs=1)\n", + " P = cross_val_predict(clf, F, meta[\"cls\"], cv=4, method=\"predict_proba\")\n", + " clf.fit(F, meta[\"cls\"])\n", + " reg = GradientBoostingRegressor(n_estimators=200, max_depth=3, learning_rate=0.06, random_state=0).fit(np.c_[F.values, P], meta[\"damage_idx\"])\n", + " return {\"clf\": clf, \"reg\": reg, \"cols\": list(F.columns)}\n", + "\n", + "\n", + "def predict_pipeline(pl, F):\n", + " X = F[pl[\"cols\"]]\n", + " P = pl[\"clf\"].predict_proba(X)\n", + " idx = np.clip(pl[\"reg\"].predict(np.c_[X.values, P]), 0, 100)\n", + " return pl[\"clf\"].classes_[P.argmax(axis=1)], P, idx, P.max(axis=1)\n", + "# ---- 사진 품질 게이트: 진흙·오염 가림 추정과 판정 신뢰도 지수 ----\n", + "def mud_est(img):\n", + " \"\"\"진흙(갈색, 채도 낮은 붉은 기) 화소 비율을 추정한다. 3x3 평균으로 잡음을 줄인 뒤 색 비율로 판단하며, 실제 가림 비율보다 작게 나오지만 순서는 유지된다.\"\"\"\n", + " sm = ndi.uniform_filter(img, size=(3, 3, 1))\n", + " s = sm.sum(axis=2) + 1e-6\n", + " rr, gg = sm[..., 0] / s, sm[..., 1] / s\n", + " return float(((rr > 0.385) & (rr < 0.46) & (gg < 0.325)).mean())\n", + "\n", + "\n", + "def confidence(P, idx, F, mud):\n", + " \"\"\"판정 신뢰도 지수 0~100 = 50 x 분류 확률 최대값 + 35 x 등급 경계까지 거리(8점에서 포화) + 15 x 사진 품질. 오염 의심(mud 추정 1% 이상)이면 상한 75로 제한한다.\"\"\"\n", + " bd = np.minimum(np.abs(idx - 35), np.abs(idx - 65))\n", + " qual = np.clip(1.0 - np.clip(0.55 - F[\"gain\"].values, 0, None) * 2 - np.clip(0.9 - F[\"c_mean\"].values, 0, None), 0, 1)\n", + " c = 50 * P.max(axis=1) + 35 * np.clip(bd / 8, 0, 1) + 15 * qual\n", + " return np.where(np.asarray(mud) >= 0.01, np.minimum(c, 75.0), c)\n", + "\n", + "mtr, itr, _ = make_images(600, seed=1)\n", + "pl = fit_pipeline(mtr, feature_table(itr))\n", + "\n", + "\n", + "def run(imgs, truth):\n", + " \"\"\"사진 묶음을 판정해 게이트 적용 전·후 신뢰도와 경보(등급 2 이상 = 정비 경보)를 만든다.\"\"\"\n", + " t0 = time.perf_counter()\n", + " F = feature_table(imgs)\n", + " t1 = time.perf_counter()\n", + " cls, P, idx, _ = predict_pipeline(pl, F)\n", + " t2 = time.perf_counter()\n", + " mud = np.array([mud_est(x) for x in imgs])\n", + " raw_conf = confidence(P, idx, F, np.zeros(len(imgs)))\n", + " conf = confidence(P, idx, F, mud)\n", + " t3 = time.perf_counter()\n", + " g = grade_of(idx)\n", + " return pd.DataFrame({\"true\": truth[\"grade\"].values, \"ai\": g, \"mud\": mud, \"raw_conf\": raw_conf, \"conf\": conf, \"idx\": idx, \"retake\": (mud >= 0.03) | (F[\"gain\"].values < 0.17)}), (t1 - t0, t2 - t1, t3 - t2)\n", + "\n", + "\n", + "# ---------------- 1) 진흙 혼입 비율 스윕 ----------------\n", + "rows, tm = [], []\n", + "for mudr in (0.0, 0.05, 0.10, 0.20, 0.30, 0.40, 0.50):\n", + " m, im, _ = make_images(120, seed=20 + int(mudr * 100), mud=mudr)\n", + " r, t = run(im, m)\n", + " tm.append(t)\n", + " ok = r[\"true\"] == r[\"ai\"]\n", + " auto = (r[\"conf\"] >= 80) & ~r[\"retake\"] # 품질 게이트를 통과해 자동 처리(검사관 간이 승인)로 가는 사진\n", + " alarm_ok = ((r[\"ai\"] >= 2) == (r[\"true\"] >= 2))\n", + " ung = r[\"raw_conf\"] >= 80\n", + " rows.append((f\"{mudr:.0%}\", len(r), ok.mean(), r[\"conf\"].mean(), r[\"retake\"].mean(), auto.mean(), alarm_ok[auto].mean() if auto.any() else np.nan, alarm_ok[ung].mean() if ung.any() else np.nan, ung.mean()))\n", + "sw = pd.DataFrame(rows, columns=[\"진흙 비율\", \"장수\", \"등급 일치율(전체)\", \"평균 신뢰도\", \"재촬영 요구율\", \"자동 처리 비율\", \"경보 정확도(게이트 후)\", \"경보 정확도(게이트 없이 80 이상)\", \"게이트 없이 80 이상 비율\"])\n", + "print(\"[1] 진흙 혼입 비율별 판정(교육용 가상 사진, 비율당 120장)\")\n", + "pc = lambda v: \"-\" if pd.isna(v) else f\"{v:.0%}\"\n", + "print(sw.assign(**{c: sw[c].map(pc) for c in sw.columns if c not in (\"진흙 비율\", \"장수\", \"평균 신뢰도\")}).round(1).to_string(index=False))\n", + "bad = sw[sw[\"경보 정확도(게이트 후)\"] < .95][\"진흙 비율\"].tolist()\n", + "print(f\" 경보 정확도가 95% 미만으로 처음 떨어지는 진흙 비율: {bad[0] if bad else '없음'} (진흙 0%에서는 자동 처리 {sw.iloc[0]['자동 처리 비율']:.0%}, 경보 정확도 {sw.iloc[0]['경보 정확도(게이트 후)']:.0%})\")\n", + "print(f\" 게이트가 없으면 진흙 20%에서도 신뢰도 80 이상이 {sw.iloc[3]['게이트 없이 80 이상 비율']:.0%} 남고 그 경보 정확도는 {sw.iloc[3]['경보 정확도(게이트 없이 80 이상)']:.0%}로 낮다. 품질 게이트는 이런 사진을 자동 처리에서 빼 검사관 확인으로 보낸다.\")\n", + "\n", + "# ---------------- 2) 복합 열화: 흐림, 센서 잡음, 저조도+진흙 ----------------\n", + "rng = np.random.default_rng(3)\n", + "cases = {}\n", + "m0, i0, _ = make_images(120, seed=40)\n", + "cases[\"정상\"] = (m0, i0)\n", + "cases[\"흐림(초점 불량)\"] = (m0, np.clip(np.stack([ndi.gaussian_filter(x, sigma=(1.8, 1.8, 0)) for x in i0]), 0, 1))\n", + "cases[\"센서 잡음 +0.08\"] = (m0, np.clip(i0 + rng.normal(0, 0.08, i0.shape), 0, 1).astype(np.float32))\n", + "m1, i1, _ = make_images(120, seed=41, mud=0.2, dark=True)\n", + "cases[\"저조도+진흙 20%\"] = (m1, i1)\n", + "rows = []\n", + "for name, (m, im) in cases.items():\n", + " r, _ = run(im, m)\n", + " ok = r[\"true\"] == r[\"ai\"]\n", + " auto = (r[\"conf\"] >= 80) & ~r[\"retake\"]\n", + " am = ((r[\"ai\"] >= 2) == (r[\"true\"] >= 2))\n", + " rows.append((name, ok.mean(), r[\"retake\"].mean(), auto.mean(), am[auto].mean() if auto.any() else np.nan, (r[\"true\"] > r[\"ai\"]).mean()))\n", + "cc = pd.DataFrame(rows, columns=[\"조건\", \"등급 일치율\", \"재촬영 요구율\", \"자동 처리 비율\", \"자동 처리 경보 정확도\", \"과소 판정 비율\"])\n", + "print(\"\\n[2] 복합 열화 조건(120장)\\n\" + cc.assign(**{c: cc[c].map(pc) for c in cc.columns[1:]}).to_string(index=False))\n", + "print(f\" 흐림 사진은 게이트가 직접 잡지 못해 자동 처리 {cc.iloc[1]['자동 처리 비율']:.0%} 중 경보 정확도 {cc.iloc[1]['자동 처리 경보 정확도']:.0%}로 95%에 못 미친다 -> 초점·촬영 거리 가이드 보완이 필요하다.\")\n", + "\n", + "# ---------------- 3) 대량 처리 시간과 대시보드 로딩 ----------------\n", + "tt = np.array(tm).mean(axis=0) / 120\n", + "per = tt.sum()\n", + "print(f\"\\n[3] 사진 1장당 처리 시간(1개 코어 가정): 분할·특징 {tt[0] * 1000:.1f}ms, 판정 {tt[1] * 1000:.2f}ms, 오염 추정·신뢰도 {tt[2] * 1000:.1f}ms, 합계 {per * 1000:.1f}ms ({1 / per:.0f}장/초)\")\n", + "for n in (1000, 5000, 20000):\n", + " print(f\" {n:>6}장 일괄 판정 예상: {n * per:.0f}초 ({n * per / 60:.1f}분) - 선형 외삽, 사진 해상도·장비 사양이 다르면 달라진다\")\n", + "\n", + "\n", + "def dashboard(df):\n", + " t0 = time.perf_counter()\n", + " summ = df.groupby(\"ai\").agg(건수=(\"idx\", \"size\"), 평균신뢰도=(\"conf\", \"mean\"))\n", + " low = df.sort_values(\"conf\").head(20)\n", + " fg, ax = plt.subplots(1, 2, figsize=(9, 3.5))\n", + " ax[0].bar(summ.index.astype(str), summ[\"건수\"]); ax[1].hist(df[\"conf\"], bins=20)\n", + " fg.canvas.draw(); plt.close(fg)\n", + " return time.perf_counter() - t0, len(low)\n", + "\n", + "\n", + "base, _ = run(cases[\"정상\"][1], cases[\"정상\"][0])\n", + "loads = []\n", + "for n in (300, 3000, 30000, 300000):\n", + " big = pd.concat([base] * (n // len(base) + 1), ignore_index=True).head(n)\n", + " loads.append((n, dashboard(big)[0]))\n", + "print(\" 대시보드 로딩 시간(요약 집계 + 상위 20개 목록 + 차트 렌더링):\", \", \".join(f\"{n}건 {s:.2f}초\" for n, s in loads))\n", + "print(f\" 3000건 기준 로딩 {loads[1][1]:.2f}초(목표 2초 이내: {'달성' if loads[1][1] <= 2 else '미달'}), 30만 건에서도 {loads[3][1]:.2f}초. 화면은 요약과 신뢰도 낮은 사진 우선 목록만 먼저 표시한다.\")\n", + "\n", + "# ---------------- 4) 결론 ----------------\n", + "print(\"\\n[4] 한계치 요약\")\n", + "print(f\" - 자동 처리(검사관 간이 승인) 후보는 가림이 없는 사진 중심이며, 진흙 {bad[0] if bad else '5%'} 이상부터 경보 정확도 95% 목표를 지키지 못해 재촬영 또는 검사관 정밀 판독으로 보낸다\")\n", + "print(f\" - 정상 사진도 {1 - sw.iloc[0]['자동 처리 비율']:.0%}는 신뢰도 80 미만이라 검사관 확인 대상이다. 경보 정확도는 자동 처리 사진에 한해 판단하고 자동 처리 비율을 함께 보고한다\")\n", + "print(\" - 판정·경보는 검사관 확인의 보조 수단이며 최종 정비 등급은 사람이 확정한다. 모든 사진은 교육용 가상 자료이다.\")\n", + "\n", + "fig, ax = plt.subplots(1, 3, figsize=(15, 4.2))\n", + "x = np.arange(len(sw))\n", + "ax[0].plot(x, sw[\"등급 일치율(전체)\"] * 100, \"o-\", label=\"grade accuracy (all)\"); ax[0].plot(x, sw[\"재촬영 요구율\"] * 100, \"s-\", label=\"retake rate\"); ax[0].plot(x, sw[\"자동 처리 비율\"] * 100, \"^-\", label=\"auto-processed\")\n", + "ax[0].set_xticks(x); ax[0].set_xticklabels(sw[\"진흙 비율\"]); ax[0].set_xlabel(\"mud ratio\"); ax[0].legend(); ax[0].set_title(\"Mud stress sweep (%)\")\n", + "ax[1].bar(range(len(cc)), cc[\"자동 처리 비율\"] * 100); ax[1].set_xticks(range(len(cc))); ax[1].set_xticklabels([\"clean\", \"blur\", \"noise\", \"dark+mud\"]); ax[1].set_title(\"Auto-processed share (%)\")\n", + "ax[2].loglog([l[0] for l in loads], [l[1] for l in loads], \"o-\"); ax[2].axhline(2, c=\"r\", ls=\"--\"); ax[2].set_title(\"Dashboard load time (s) vs records\")\n", + "plt.tight_layout(); plt.show()\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 08 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + "[1] 진흙 혼입 비율별 판정(교육용 가상 사진, 비율당 120장)\n", + "진흙 비율 장수 등급 일치율(전체) 평균 신뢰도 재촬영 요구율 자동 처리 비율 경보 정확도(게이트 후) 경보 정확도(게이트 없이 80 이상) 게이트 없이 80 이상 비율\n", + " 0% 120 90% 86.4 0% 73% 100% 100% 73%\n", + " 5% 120 65% 67.3 1% 12% 93% 94% 26%\n", + " 10% 120 53% 63.3 12% 3% 75% 88% 27%\n", + " 20% 120 42% 63.9 66% 0% - 73% 25%\n", + " 30% 120 64% 62.1 88% 0% - 77% 11%\n", + " 40% 120 59% 59.8 99% 0% - 38% 7%\n", + " 50% 120 68% 64.9 100% 0% - 79% 16%\n", + " 경보 정확도가 95% 미만으로 처음 떨어지는 진흙 비율: 5% (진흙 0%에서는 자동 처리 73%, 경보 정확도 100%)\n", + " 게이트가 없으면 진흙 20%에서도 신뢰도 80 이상이 25% 남고 그 경보 정확도는 73%로 낮다. 품질 게이트는 이런 사진을 자동 처리에서 빼 검사관 확인으로 보낸다.\n", + "\n", + "[2] 복합 열화 조건(120장)\n", + " 조건 등급 일치율 재촬영 요구율 자동 처리 비율 자동 처리 경보 정확도 과소 판정 비율\n", + " 정상 96% 0% 75% 100% 4%\n", + " 흐림(초점 불량) 84% 0% 62% 91% 11%\n", + "센서 잡음 +0.08 66% 8% 8% 100% 8%\n", + " 저조도+진흙 20% 42% 100% 0% - 12%\n", + " 흐림 사진은 게이트가 직접 잡지 못해 자동 처리 62% 중 경보 정확도 91%로 95%에 못 미친다 -> 초점·촬영 거리 가이드 보완이 필요하다.\n", + "\n", + "[3] 사진 1장당 처리 시간(1개 코어 가정): 분할·특징 4.1ms, 판정 0.16ms, 오염 추정·신뢰도 0.3ms, 합계 4.5ms (223장/초)\n", + " 1000장 일괄 판정 예상: 4초 (0.1분) - 선형 외삽, 사진 해상도·장비 사양이 다르면 달라진다\n", + " 5000장 일괄 판정 예상: 22초 (0.4분) - 선형 외삽, 사진 해상도·장비 사양이 다르면 달라진다\n", + " 20000장 일괄 판정 예상: 90초 (1.5분) - 선형 외삽, 사진 해상도·장비 사양이 다르면 달라진다\n", + " 대시보드 로딩 시간(요약 집계 + 상위 20개 목록 + 차트 렌더링): 300건 0.19초, 3000건 0.10초, 30000건 0.14초, 300000건 0.17초\n", + " 3000건 기준 로딩 0.10초(목표 2초 이내: 달성), 30만 건에서도 0.17초. 화면은 요약과 신뢰도 낮은 사진 우선 목록만 먼저 표시한다.\n", + "\n", + "[4] 한계치 요약\n", + " - 자동 처리(검사관 간이 승인) 후보는 가림이 없는 사진 중심이며, 진흙 5% 이상부터 경보 정확도 95% 목표를 지키지 못해 재촬영 또는 검사관 정밀 판독으로 보낸다\n", + " - 정상 사진도 27%는 신뢰도 80 미만이라 검사관 확인 대상이다. 경보 정확도는 자동 처리 사진에 한해 판단하고 자동 처리 비율을 함께 보고한다\n", + " - 판정·경보는 검사관 확인의 보조 수단이며 최종 정비 등급은 사람이 확정한다. 모든 사진은 교육용 가상 자료이다.\n", + "```\n", + "
\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 09. AAR 기반 외관 오판 사례 분석 및 컴퓨터 비전 모델 파라미터 재보정\n", + "**소요시간**: 약 3.0시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "AAR 기반 외관 오판 사례를 분석하고 컴퓨터 비전 파라미터를 재보정한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **AAR로 외관 오판 사례를 분석하고 컴퓨터 비전 모델 파라미터를 재보정하며 도입 효과를 정량화한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **운영 분포(저조도·진흙·정상 혼합)의 판정 로그에서 오판을 오염 가림·저조도·작은 결함·유형 혼동·경계 근처로 분류 수행하고 검증하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `reports/qlt03_roi_analysis.md` 형태의 결과물을 완성한다.\n", + "\n", + "### 🔍 평가 및 검증 기준 (PASS 조건)\n", + "#### 미션 판정\n", + "\n", + "**평가 구분:** 필수\n", + "\n", + "**핵심 평가영역:** 추정 산식의 객관성 및 군 재정/군수 기획 부서 설득력\n", + "\n", + "#### 미션 PASS 조건\n", + "\n", + "- 정량 기준: 비용 절감액, 시간 단축률 등 3대 정량 효과 지표 산출\n", + "\n", + "\n", + "- 진행 기준: ROI 분석 보고서 완성 및 주요 지표 민감도 분석 수록\n", + "\n", + "\n", + "- 검증 방법: 회계/기획 기준 모델과의 비교 검증\n", + "\n", + "\n", + "#### 채점자 판정 메모\n", + "\n", + "> 단순 코드 실행 여부뿐만 아니라 출력된 수치와 근거 자료가 군 실무 요구사항에 부합하는지 면밀히 확인한다.\n", + "\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "\"\"\"운영 기간 외관 판정 로그로 AAR을 수행해 오판 사례를 원인별(오염 가림·저조도·작은 결함·경계 근처·유형 혼동)로 분류하고, 분할 임계값·학습 데이터·등급 경계(과소 판정 손실 반영)를 재보정해 보류 구간에서 개선을 검증한 뒤 비용 절감액·시간 단축률·투자 회수 기간 3대 효과 지표를 산식과 민감도로 산출한다.\"\"\"\n", + "import numpy as np\n", + "import pandas as pd\n", + "import matplotlib.pyplot as plt\n", + "from sklearn.ensemble import RandomForestClassifier, GradientBoostingRegressor\n", + "from sklearn.model_selection import cross_val_predict\n", + "\n", + "# ---------------------------------------------------------------------------\n", + "# [교육용가상자료] 장비 외관 사진 생성기 (실제 장비·부대·위치 사진이 아니라 난수로 그린 64x64 합성 이미지)\n", + "# 도장된 금속 표면에 균열(crack)·부식(corrosion)·파손(damage)·누유(leak)를 그리고, 조명 편차와 진흙(mud) 오염을 섞는다.\n", + "# 이미지와 함께 장비 관리 기록(제원 요약)과 정답 결함 마스크·손상도·정비 등급을 만든다.\n", + "# ---------------------------------------------------------------------------\n", + "from scipy import ndimage as ndi\n", + "\n", + "SZ = 64\n", + "CLASSES = [\"none\", \"crack\", \"corrosion\", \"damage\", \"leak\"]\n", + "KO_CLS = {\"none\": \"결함 없음\", \"crack\": \"균열\", \"corrosion\": \"부식\", \"damage\": \"파손\", \"leak\": \"누유\"}\n", + "CLS_W = {\"none\": 0.0, \"crack\": 1.4, \"corrosion\": 1.0, \"damage\": 1.6, \"leak\": 0.6} # 손상도 계산용 결함 유형 가중치\n", + "GRADE_NAME = {1: \"현장정비\", 2: \"야전정비\", 3: \"창정비\"}\n", + "YY, XX = np.mgrid[0:SZ, 0:SZ]\n", + "\n", + "\n", + "def grade_of(idx):\n", + " # [TODO : 학생 실습 9] AAR 기반 외관 오판 사례 분석 및 컴퓨터 비전 모델 파라미터 재보정 관련 핵심 로직을 구현하세요.\n", + " # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n", + " # <<< 여기에 코드를 작성하세요 >>>\n", + " pass # 작성 후 pass를 제거하세요.\n", + "def blob(rng, cy, cx, r, rough=0.35):\n", + " \"\"\"불규칙한 원형 영역 마스크.\"\"\"\n", + " # [TODO : 학생 실습 9] AAR 기반 외관 오판 사례 분석 및 컴퓨터 비전 모델 파라미터 재보정 관련 핵심 로직을 구현하세요.\n", + " # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n", + " # <<< 여기에 코드를 작성하세요 >>>\n", + " pass # 작성 후 pass를 제거하세요.\n", + "def draw(rng, cls, sev):\n", + " \"\"\"결함 유형·심각도 sev(0~1)에 맞는 정답 마스크와 색 정보를 만든다.\"\"\"\n", + " m = np.zeros((SZ, SZ), bool)\n", + " if cls == \"crack\":\n", + " y, x, ang = rng.uniform(12, 52), rng.uniform(12, 52), rng.uniform(0, np.pi)\n", + " for _ in range(int(20 + 40 * sev)):\n", + " ang += rng.normal(0, 0.35); y += np.sin(ang) * 1.2; x += np.cos(ang) * 1.2\n", + " if 0 <= int(y) < SZ and 0 <= int(x) < SZ:\n", + " m[int(y), int(x)] = True\n", + " m = ndi.binary_dilation(m, iterations=1 + int(sev > 0.6))\n", + " elif cls == \"corrosion\":\n", + " for _ in range(int(rng.integers(1, 4))):\n", + " m |= blob(rng, rng.uniform(14, 50), rng.uniform(14, 50), 3 + 9 * sev * rng.uniform(0.6, 1.1))\n", + " elif cls == \"damage\":\n", + " m = blob(rng, rng.uniform(20, 44), rng.uniform(20, 44), 6 + 12 * sev, 0.3)\n", + " elif cls == \"leak\":\n", + " x0, y0 = rng.uniform(16, 48), rng.uniform(8, 24)\n", + " w, ln = 2 + 4 * sev, 12 + 26 * sev\n", + " m = (np.abs(XX - x0 - 0.15 * (YY - y0)) < w * (1 - 0.4 * (YY - y0) / ln)) & (YY > y0) & (YY < y0 + ln)\n", + " return m\n", + "\n", + "\n", + "def make_images(n=600, seed=0, mud=0.0, p_none=0.25, dark=False):\n", + " \"\"\"가상 외관 사진 n장(+진흙 오염 mud 비율)과 관리 기록·정답. 반환: meta(DataFrame), imgs(n,64,64,3), masks(n,64,64).\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " imgs, masks, rows = np.zeros((n, SZ, SZ, 3), np.float32), np.zeros((n, SZ, SZ), bool), []\n", + " for i in range(n):\n", + " cls = \"none\" if rng.random() < p_none else str(rng.choice(CLASSES[1:], p=[0.25, 0.3, 0.25, 0.2]))\n", + " sev = 0.0 if cls == \"none\" else float(rng.beta(1.6, 2.0))\n", + " base = np.array([0.42, 0.46, 0.40]) * (1 + 0.5 * ndi.gaussian_filter(rng.normal(size=(SZ, SZ)), 8)[..., None] * 3)\n", + " img = base + rng.normal(0, 0.012, (SZ, SZ, 3))\n", + " m = draw(rng, cls, sev)\n", + " a = ndi.gaussian_filter(m.astype(float), 0.8)[..., None] # 경계를 부드럽게 섞는다\n", + " if cls == \"crack\":\n", + " col = img * rng.uniform(0.2, 0.45)\n", + " elif cls == \"corrosion\":\n", + " col = np.array([0.55, 0.30, 0.12]) * rng.uniform(0.75, 1.15) * (0.85 + 0.3 * rng.random((SZ, SZ, 1)))\n", + " elif cls == \"damage\":\n", + " col = np.ones_like(img) * np.array([0.34, 0.34, 0.36]) * (1.0 - 0.45 * sev) * rng.uniform(0.75, 1.0)\n", + " rim = m & ~ndi.binary_erosion(m, iterations=2)\n", + " a = np.maximum(a, 0.0); img = np.where(rim[..., None], 0.7, img)\n", + " elif cls == \"leak\":\n", + " col = np.ones_like(img) * np.array([0.10, 0.09, 0.08]) * rng.uniform(0.8, 2.6)\n", + " else:\n", + " col = img\n", + " img = img * (1 - a) + col * a\n", + " if rng.random() < 0.35: # 무해한 얼룩(결함 아님): 옅은 갈색 번짐\n", + " st = blob(rng, rng.uniform(5, 58), rng.uniform(5, 58), rng.uniform(6, 14), 0.5)\n", + " al = ndi.gaussian_filter(st.astype(float), 2.5)[..., None] * rng.uniform(0.15, 0.4)\n", + " img = img * (1 - al) + np.array([0.50, 0.40, 0.30]) * al\n", + " if rng.random() < 0.3: # 무해한 긴 스크래치(밝은 선)\n", + " y0, x0, ang = rng.uniform(5, 58), rng.uniform(5, 58), rng.uniform(0, np.pi)\n", + " for t in np.linspace(0, 25, 40):\n", + " yy, xx = int(y0 + t * np.sin(ang)), int(x0 + t * np.cos(ang))\n", + " if 0 <= yy < SZ and 0 <= xx < SZ:\n", + " img[yy, xx] = np.clip(img[yy, xx] * 1.5, 0, 1)\n", + " if mud > 0: # 진흙 오염: 결함 위를 덮을 수 있다(정답 마스크는 그대로)\n", + " mm = np.zeros((SZ, SZ), bool)\n", + " while mm.mean() < mud:\n", + " mm |= blob(rng, rng.uniform(0, SZ), rng.uniform(0, SZ), rng.uniform(5, 11), 0.5)\n", + " img[mm] = np.array([0.38, 0.30, 0.22]) * (0.85 + 0.3 * rng.random((mm.sum(), 1)))\n", + " gain = rng.uniform(0.3, 0.55) if dark else rng.uniform(0.5, 1.15)\n", + " ang = rng.uniform(0, 2 * np.pi)\n", + " light = gain * (1 + 0.3 * (np.cos(ang) * (XX - 32) + np.sin(ang) * (YY - 32)) / 32)\n", + " imgs[i] = np.clip(img * light[..., None] + rng.normal(0, 0.025, (SZ, SZ, 3)), 0, 1)\n", + " masks[i] = m\n", + " area = float(m.mean())\n", + " depth = {\"none\": 0.0, \"crack\": 0.3 + 3.0 * sev, \"corrosion\": 0.1 + 1.5 * sev, \"damage\": 1.0 + 8.0 * sev, \"leak\": 0.0}[cls] * rng.lognormal(0, 0.1)\n", + " idx = float(np.clip(100 * (1 - np.exp(-(CLS_W[cls] * area * 9 + 0.05 * depth))) + rng.normal(0, 1.5), 0, 100))\n", + " rows.append({\"img_id\": f\"IMG{seed:02d}{i:04d}\", \"cls\": cls, \"sev\": sev, \"area_frac\": area, \"depth_mm\": depth, \"damage_idx\": idx,\n", + " \"grade\": int(grade_of(idx)), \"gain\": gain, \"mud\": mud})\n", + " meta = pd.DataFrame(rows)\n", + " # 장비 관리 기록(제원 요약): 사진 판정과 대조할 값\n", + " age = rng.integers(1, 25, n)\n", + " meta[\"day\"] = np.sort(rng.integers(0, 90, n))\n", + " meta[\"equip_type\"] = rng.choice([\"vehicle\", \"generator\", \"comm\", \"trailer\"], n, p=[0.4, 0.2, 0.2, 0.2])\n", + " meta[\"mfg_year\"] = 2026 - age\n", + " meta[\"op_hours\"] = np.clip(rng.normal(180 * age, 400, n) + 8 * meta[\"damage_idx\"], 10, 20000)\n", + " meta[\"env\"] = rng.choice([\"coastal\", \"inland\"], n, p=[0.3, 0.7])\n", + " reg = np.clip(meta[\"grade\"] + rng.choice([-1, 0, 0, 0, 0, 0, 1, 2], n), 1, 3) # 장비 관리 기록상 직전 정비 등급\n", + " meta[\"reg_grade\"] = reg\n", + " meta[\"emergency\"] = rng.random(n) < 0.04 # 긴급 점검 요청(화질 불량이어도 보존)\n", + " return meta, imgs, masks\n", + "# ---- 전처리(조명 보정·표준화)와 결함 후보 분할, 특징 추출 (cv2 대신 numpy·scipy 사용) ----\n", + "def correct(img):\n", + " \"\"\"조명 보정: 밝기에 평면(조명 기울기)을 적합해 나눈다. 어두운 결함 화소는 제외하고 두 번 반복 적합한다. 반환: 보정 밝기(정상 면 약 1), 색 비율.\"\"\"\n", + " g = img.mean(axis=2)\n", + " A = np.c_[np.ones(SZ * SZ), XX.ravel() / SZ, YY.ravel() / SZ]\n", + " keep = np.ones(SZ * SZ, bool)\n", + " for _ in range(3):\n", + " coef, *_ = np.linalg.lstsq(A[keep], g.ravel()[keep], rcond=None)\n", + " plane = (A @ coef).reshape(SZ, SZ)\n", + " keep = (np.abs(g - plane) < 0.08 * np.maximum(plane, 1e-3)).ravel()\n", + " if keep.sum() < 200:\n", + " keep[:] = True\n", + " c = g / np.maximum(plane, 1e-3)\n", + " chroma = img[..., 0] / (img.sum(axis=2) + 1e-6)\n", + " return c, chroma\n", + "\n", + "\n", + "SEGP = {\"dark\": 0.7, \"rust\": 0.5, \"rim\": 1.45} # 분할 임계값(어두움 보정 밝기, 붉은 비율, 밝은 테두리): 재보정 대상\n", + "\n", + "\n", + "def segment(img):\n", + " \"\"\"결함 후보 마스크: 어두운 영역(균열·누유·파손) + 붉은 갈색 영역(부식) + 밝은 테두리(파손).\"\"\"\n", + " c, chroma = correct(img)\n", + " dark = c < SEGP[\"dark\"]\n", + " rust = (chroma > SEGP[\"rust\"]) & (c > 0.45)\n", + " rim = c > SEGP[\"rim\"]\n", + " m = dark | rust | ndi.binary_dilation(rim, iterations=1) & ndi.binary_dilation(dark, iterations=3)\n", + " m = ndi.binary_opening(m, structure=np.ones((2, 2))) if m.sum() > 12 else m\n", + " return m, c, chroma, dark, rust, rim\n", + "\n", + "\n", + "def extract(img):\n", + " \"\"\"분할 결과와 색·모양에서 24개 특징을 만든다.\"\"\"\n", + " m, c, chroma, dark, rust, rim = segment(img)\n", + " lab, n = ndi.label(m)\n", + " area = float(m.sum())\n", + " if n:\n", + " sizes = ndi.sum(m, lab, range(1, n + 1))\n", + " k = int(np.argmax(sizes)) + 1\n", + " sl = ndi.find_objects(lab)[k - 1]\n", + " h, w = sl[0].stop - sl[0].start, sl[1].stop - sl[1].start\n", + " big = float(sizes.max())\n", + " elong, fill = max(h, w) / max(min(h, w), 1), big / (h * w)\n", + " else:\n", + " big, elong, fill = 0.0, 1.0, 0.0\n", + " gy, gx = np.gradient(c)\n", + " mag = np.hypot(gx, gy)\n", + " inside = m.sum() > 0\n", + " return {\"area_px\": area, \"area_frac\": area / SZ ** 2, \"n_comp\": float(n), \"big_px\": big, \"elong\": elong, \"fill\": fill,\n", + " \"dark_frac\": float(dark.mean()), \"rust_frac\": float(rust.mean()), \"rim_frac\": float(rim.mean()),\n", + " \"c_mean\": float(c.mean()), \"c_std\": float(c.std()), \"c_min\": float(c.min()), \"edge_mean\": float(mag.mean()), \"edge_p95\": float(np.quantile(mag, 0.95)),\n", + " \"chroma_mean\": float(chroma.mean()), \"chroma_max\": float(chroma.max()),\n", + " \"in_dark\": float(c[m].mean()) if inside else 1.0, \"in_chroma\": float(chroma[m].mean()) if inside else float(chroma.mean()),\n", + " \"ring_contrast\": float(c[ndi.binary_dilation(m, iterations=3) & ~m].mean() - c[m].mean()) if inside and (~m).any() else 0.0,\n", + " \"thin\": float(1 - fill), \"gain\": float(img.mean()), \"sat\": float((img.max(axis=2) - img.min(axis=2)).mean()),\n", + " \"cy\": float(ndi.center_of_mass(m)[0] / SZ) if inside else 0.5, \"cx\": float(ndi.center_of_mass(m)[1] / SZ) if inside else 0.5}\n", + "\n", + "\n", + "def feature_table(imgs):\n", + " return pd.DataFrame([extract(im) for im in imgs])\n", + "# ---- 판정 파이프라인: 결함 유형 분류 + 손상도 회귀(분류 확률 포함) -> 정비 등급 ----\n", + "def fit_pipeline(meta, F):\n", + " \"\"\"결함 유형 분류기와 손상도 회귀기를 학습한다. 회귀기 입력의 분류 확률은 교차 예측으로 만들어 누설을 막는다.\"\"\"\n", + " clf = RandomForestClassifier(n_estimators=150, random_state=0, n_jobs=1)\n", + " P = cross_val_predict(clf, F, meta[\"cls\"], cv=4, method=\"predict_proba\")\n", + " clf.fit(F, meta[\"cls\"])\n", + " reg = GradientBoostingRegressor(n_estimators=200, max_depth=3, learning_rate=0.06, random_state=0).fit(np.c_[F.values, P], meta[\"damage_idx\"])\n", + " return {\"clf\": clf, \"reg\": reg, \"cols\": list(F.columns)}\n", + "\n", + "\n", + "def predict_pipeline(pl, F):\n", + " X = F[pl[\"cols\"]]\n", + " P = pl[\"clf\"].predict_proba(X)\n", + " idx = np.clip(pl[\"reg\"].predict(np.c_[X.values, P]), 0, 100)\n", + " return pl[\"clf\"].classes_[P.argmax(axis=1)], P, idx, P.max(axis=1)\n", + "# ---- 사진 품질 게이트: 진흙·오염 가림 추정과 판정 신뢰도 지수 ----\n", + "def mud_est(img):\n", + " \"\"\"진흙(갈색, 채도 낮은 붉은 기) 화소 비율을 추정한다. 3x3 평균으로 잡음을 줄인 뒤 색 비율로 판단하며, 실제 가림 비율보다 작게 나오지만 순서는 유지된다.\"\"\"\n", + " sm = ndi.uniform_filter(img, size=(3, 3, 1))\n", + " s = sm.sum(axis=2) + 1e-6\n", + " rr, gg = sm[..., 0] / s, sm[..., 1] / s\n", + " return float(((rr > 0.385) & (rr < 0.46) & (gg < 0.325)).mean())\n", + "\n", + "\n", + "def confidence(P, idx, F, mud):\n", + " \"\"\"판정 신뢰도 지수 0~100 = 50 x 분류 확률 최대값 + 35 x 등급 경계까지 거리(8점에서 포화) + 15 x 사진 품질. 오염 의심(mud 추정 1% 이상)이면 상한 75로 제한한다.\"\"\"\n", + " bd = np.minimum(np.abs(idx - 35), np.abs(idx - 65))\n", + " qual = np.clip(1.0 - np.clip(0.55 - F[\"gain\"].values, 0, None) * 2 - np.clip(0.9 - F[\"c_mean\"].values, 0, None), 0, 1)\n", + " c = 50 * P.max(axis=1) + 35 * np.clip(bd / 8, 0, 1) + 15 * qual\n", + " return np.where(np.asarray(mud) >= 0.01, np.minimum(c, 75.0), c)\n", + "\n", + "mtr, itr, _ = make_images(600, seed=1)\n", + "Ftr = feature_table(itr)\n", + "pl0 = fit_pipeline(mtr, Ftr)\n", + "LOSS_UNDER, LOSS_OVER = 20.0, 6.0 # 과소/과대 판정 1등급당 손실(만원, 가정)\n", + "\n", + "\n", + "def ops(seed, n):\n", + " \"\"\"운영 사진 묶음: 저조도 1/3 + 진흙 5% 1/3 + 정상 1/3 (학습 때와 분포가 다르다).\"\"\"\n", + " parts = [make_images(n // 3, seed=seed, dark=True), make_images(n // 3, seed=seed + 1, mud=0.05), make_images(n // 3, seed=seed + 2)]\n", + " return pd.concat([p[0] for p in parts], ignore_index=True), np.concatenate([p[1] for p in parts]), np.concatenate([p[2] for p in parts])\n", + "\n", + "\n", + "def loss(true, g):\n", + " d = np.asarray(g) - np.asarray(true)\n", + " return np.where(d < 0, -d * LOSS_UNDER, d * LOSS_OVER)\n", + "\n", + "\n", + "def grade_shift(idx, delta):\n", + " \"\"\"과소 판정을 줄이기 위해 등급 경계를 delta만큼 낮춘다(35 -> 35-delta, 65 -> 65-delta).\"\"\"\n", + " return np.where(np.asarray(idx) >= 65 - delta, 3, np.where(np.asarray(idx) >= 35 - delta, 2, 1))\n", + "\n", + "\n", + "def evaluate(pl, F, m, ims, delta=0):\n", + " cls, P, idx, _ = predict_pipeline(pl, F)\n", + " mud = np.array([mud_est(x) for x in ims])\n", + " conf = confidence(P, idx, F, mud)\n", + " g = grade_shift(idx, delta)\n", + " auto = (conf >= 80) & (mud < 0.03) & (F[\"gain\"].values >= 0.17)\n", + " ok = g == m[\"grade\"].values\n", + " return {\"cls\": cls, \"idx\": idx, \"g\": g, \"mud\": mud, \"conf\": conf, \"ok\": ok, \"mae\": float(np.mean(np.abs(idx - m[\"damage_idx\"].values))), \"under\": int((g < m[\"grade\"].values).sum()),\n", + " \"loss\": float(loss(m[\"grade\"].values, g).mean()), \"auto_n\": int(auto.sum()), \"auto_acc\": float(ok[auto].mean()) if auto.any() else np.nan}\n", + "\n", + "\n", + "# ---------------- 1) AAR: 운영 로그 분석(이동 구간 A, 360장) ----------------\n", + "mA, iA, kA = ops(60, 360)\n", + "rng = np.random.default_rng(0)\n", + "perm = rng.permutation(len(mA))\n", + "FA = feature_table(iA)\n", + "ev = evaluate(pl0, FA, mA, iA)\n", + "print(f\"[1] AAR 대상 운영 사진 {len(mA)}장(저조도·진흙 5%·정상 혼합): 등급 일치율 {ev['ok'].mean():.1%}, 손상도 MAE {ev['mae']:.2f}, 과소 판정 {ev['under']}장, 장당 오판 손실 {ev['loss']:.2f}만원\")\n", + "wrong = np.where(~ev[\"ok\"])[0]\n", + "\n", + "\n", + "def cause(i):\n", + " if ev[\"mud\"][i] >= 0.01: return \"오염 가림\"\n", + " if FA[\"gain\"].iloc[i] < 0.2: return \"저조도\"\n", + " if mA[\"cls\"][i] != \"none\" and mA[\"area_frac\"][i] < 0.02: return \"작은 결함 누락·과소\"\n", + " if ev[\"cls\"][i] != mA[\"cls\"][i]: return \"결함 유형 혼동\"\n", + " if min(abs(ev[\"idx\"][i] - 35), abs(ev[\"idx\"][i] - 65)) < 6: return \"등급 경계 근처\"\n", + " return \"기타\"\n", + "\n", + "\n", + "cs = pd.Series([cause(i) for i in wrong]).value_counts()\n", + "print(f\" 오판 {len(wrong)}장의 원인 분류:\", \", \".join(f\"{k} {v}장({v / len(wrong):.0%})\" for k, v in cs.items()))\n", + "print(f\" 오판 중 과소 판정(실제보다 낮은 등급) {ev['under']}장 / 과대 {int((ev['g'] > mA['grade'].values).sum())}장 -> 과소 판정이 손실 단가가 {LOSS_UNDER / LOSS_OVER:.1f}배 높아 우선 교정 대상\")\n", + "\n", + "# ---------------- 2) 재보정: 분할 임계값 -> 운영 데이터 재학습 -> 등급 경계 이동 ----------------\n", + "A1, A2 = perm[:240], perm[240:]\n", + "cache = [correct(iA[i]) for i in A1]\n", + "\n", + "\n", + "def mask_from(c, chroma, dt, rt, rim=1.45):\n", + " dark, rust, r = c < dt, (chroma > rt) & (c > 0.45), c > rim\n", + " m = dark | rust | ndi.binary_dilation(r, iterations=1) & ndi.binary_dilation(dark, iterations=3)\n", + " return ndi.binary_opening(m, structure=np.ones((2, 2))) if m.sum() > 12 else m\n", + "\n", + "\n", + "grid = []\n", + "for dt in (0.6, 0.7, 0.75, 0.8):\n", + " for rt in (0.44, 0.5, 0.54):\n", + " iou = np.mean([(mask_from(c, ch, dt, rt) & kA[i]).sum() / max((mask_from(c, ch, dt, rt) | kA[i]).sum(), 1) for (c, ch), i in zip(cache, A1)])\n", + " grid.append((dt, rt, iou))\n", + "gd = pd.DataFrame(grid, columns=[\"dark 임계값\", \"rust 임계값\", \"평균 IoU\"])\n", + "bst = gd.loc[gd[\"평균 IoU\"].idxmax()]\n", + "cur = gd[(gd[\"dark 임계값\"] == 0.7) & (gd[\"rust 임계값\"] == 0.5)][\"평균 IoU\"].iloc[0]\n", + "print(f\"\\n[2] 분할 임계값 재보정(운영 사진 240장 IoU): 현행(0.70, 0.50) {cur:.3f} -> 최적({bst['dark 임계값']:.2f}, {bst['rust 임계값']:.2f}) {bst['평균 IoU']:.3f}\")\n", + "SEGP[\"dark\"], SEGP[\"rust\"] = float(bst[\"dark 임계값\"]), float(bst[\"rust 임계값\"])\n", + "FA2 = feature_table(iA)\n", + "mm = pd.concat([mtr, mA.iloc[A1]], ignore_index=True)\n", + "pl1 = fit_pipeline(mm, pd.concat([Ftr, FA2.iloc[A1]], ignore_index=True)) # 운영 분포 사진 240장을 추가 학습\n", + "ev2 = evaluate(pl1, FA2.iloc[A2].reset_index(drop=True), mA.iloc[A2].reset_index(drop=True), iA[A2])\n", + "costs = []\n", + "for dlt in (0, 2, 4, 6, 8):\n", + " r = evaluate(pl1, FA2.iloc[A2].reset_index(drop=True), mA.iloc[A2].reset_index(drop=True), iA[A2], delta=dlt)\n", + " costs.append((dlt, r[\"loss\"], r[\"under\"], r[\"ok\"].mean()))\n", + "cd = pd.DataFrame(costs, columns=[\"경계 이동 delta\", \"장당 손실(만원)\", \"과소 판정\", \"일치율\"])\n", + "best_d = int(cd.loc[cd[\"장당 손실(만원)\"].idxmin(), \"경계 이동 delta\"])\n", + "print(\" 등급 경계 이동(보정용 120장, 과소 판정 손실 반영)\\n\" + cd.round(3).to_string(index=False))\n", + "if best_d == 0:\n", + " print(\" 경계를 낮추면 과대 판정이 늘어 손실이 오히려 커지므로 경계 이동은 채택하지 않는다(delta=0 유지). 과소 판정은 경계가 아니라 오염 가림에서 주로 발생한다.\")\n", + "else:\n", + " print(f\" 손실이 가장 작은 경계 이동 delta={best_d}점 채택(과소 판정을 줄이는 방향이며 사람 승인 후 적용)\")\n", + "\n", + "# ---------------- 3) 보류 구간 B(360장, 재보정에 쓰지 않음) 검증 ----------------\n", + "mB, iB, kB = ops(70, 360)\n", + "SEGP[\"dark\"], SEGP[\"rust\"] = 0.7, 0.5\n", + "FB0 = feature_table(iB)\n", + "e0 = evaluate(pl0, FB0, mB, iB)\n", + "SEGP[\"dark\"], SEGP[\"rust\"] = float(bst[\"dark 임계값\"]), float(bst[\"rust 임계값\"])\n", + "FB1 = feature_table(iB)\n", + "e1 = evaluate(pl1, FB1, mB, iB, delta=best_d)\n", + "tab = pd.DataFrame({\"재보정 전\": [e0[\"ok\"].mean(), e0[\"mae\"], e0[\"under\"], e0[\"loss\"], e0[\"auto_n\"], e0[\"auto_acc\"]], \"재보정 후\": [e1[\"ok\"].mean(), e1[\"mae\"], e1[\"under\"], e1[\"loss\"], e1[\"auto_n\"], e1[\"auto_acc\"]]},\n", + " index=[\"등급 일치율\", \"손상도 MAE\", \"과소 판정(장)\", \"장당 오판 손실(만원)\", \"자동 처리 건수\", \"자동 처리 일치율\"])\n", + "print(\"\\n[3] 보류 구간 B 검증\\n\" + tab.round(3).to_string())\n", + "print(f\" 오판 손실 {1 - e1['loss'] / e0['loss']:.1%} 감소, 과소 판정 {e0['under']} -> {e1['under']}장. 오염 가림 사진은 정보 자체가 가려져 재보정으로 해결되지 않으므로 재촬영이 필요하다.\")\n", + "\n", + "# ---------------- 4) ROI 3대 지표(교육용 가정) ----------------\n", + "N_YR, MIN_FULL, MIN_QUICK = 20000, 15.0, 4.0 # 연 판정 사진 수, 정밀 판독/간이 승인 소요(분)\n", + "WAGE, INV, OPEX = 1.2, 2500.0, 150.0 # 검사관 인건비(만원/시), 도입 비용, 연 운영비(만원)\n", + "full_b, full_a = 1 - e0[\"auto_n\"] / len(mB), 1 - e1[\"auto_n\"] / len(mB)\n", + "\n", + "\n", + "def roi(loss_b, loss_a, fb, fa, n=N_YR, inv=INV):\n", + " t_b = fb * MIN_FULL + (1 - fb) * MIN_QUICK\n", + " t_a = fa * MIN_FULL + (1 - fa) * MIN_QUICK\n", + " save_loss = (loss_b - loss_a) * n\n", + " save_time = (MIN_FULL - t_a) / 60 * WAGE * n # 관행(전수 정밀 판독) 대비 판독 인건비 절감\n", + " save = save_loss + save_time - OPEX\n", + " return save, 1 - t_a / MIN_FULL, inv / max(save / 12, 1e-9), save_loss, save_time\n", + "\n", + "\n", + "sv, tr, pb, sl, st = roi(e0[\"loss\"], e1[\"loss\"], full_b, full_a)\n", + "print(f\"\\n[4] ROI 3대 지표 [교육용 가정: 연 {N_YR}장, 정밀 판독 {MIN_FULL:.0f}분, 간이 승인 {MIN_QUICK:.0f}분, 인건비 {WAGE}만원/시, 도입 {INV:.0f}만원, 운영 {OPEX:.0f}만원/년]\")\n", + "print(f\" (1) 연간 비용 절감액 {sv:,.0f}만원(오판 손실 감소 {sl:,.0f} + 판독 인건비 {st:,.0f} - 운영비) (2) 판독 시간 단축률 {tr:.1%}(관행 전수 정밀 판독 대비) (3) 투자 회수 기간 {pb:.1f}개월\")\n", + "print(\" 산식: 절감액 = (재보정 전 - 후 장당 오판 손실) x 연 장수 + (정밀 판독 - 평균 판독 시간)/60 x 인건비 x 연 장수 - 운영비\")\n", + "print(\" 장수 민감도:\", \"; \".join(f\"{n}장 -> 절감 {roi(e0['loss'], e1['loss'], full_b, full_a, n=n)[0]:,.0f}만원, 회수 {roi(e0['loss'], e1['loss'], full_b, full_a, n=n)[2]:.0f}개월\" for n in (5000, 10000, 20000, 40000)))\n", + "print(\" 비관(재보정 효과 없음, 판독 시간 절감만):\", f\"절감 {roi(e0['loss'], e0['loss'], full_b, full_a)[0]:,.0f}만원, 회수 {roi(e0['loss'], e0['loss'], full_b, full_a)[2]:.0f}개월\")\n", + "print(\" 가정값이므로 재정·기획 부서와 실제 단가로 다시 산정해야 한다. 재보정된 임계값·경계는 정비감독관 승인 후 적용한다.\")\n", + "\n", + "fig, ax = plt.subplots(1, 3, figsize=(15, 4.2))\n", + "ax[0].bar(range(len(cs)), cs.values); ax[0].set_xticks(range(len(cs))); ax[0].set_xticklabels([f\"C{i + 1}\" for i in range(len(cs))]); ax[0].set_title(\"AAR: misjudgment causes (count)\")\n", + "ax[1].plot(cd[\"경계 이동 delta\"], cd[\"장당 손실(만원)\"], \"o-\"); ax[1].set_title(\"Loss per photo vs grade-boundary shift\"); ax[1].set_xlabel(\"delta\")\n", + "x = np.arange(3); ax[2].bar(x - .2, [e0[\"ok\"].mean() * 100, e0[\"under\"], e0[\"loss\"]], .4, label=\"before\"); ax[2].bar(x + .2, [e1[\"ok\"].mean() * 100, e1[\"under\"], e1[\"loss\"]], .4, label=\"after\")\n", + "ax[2].set_xticks(x); ax[2].set_xticklabels([\"accuracy %\", \"under-grade n\", \"loss/photo\"]); ax[2].legend(); ax[2].set_title(\"Hold-out B: before vs after\")\n", + "plt.tight_layout(); plt.show()\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 09 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + "[1] AAR 대상 운영 사진 360장(저조도·진흙 5%·정상 혼합): 등급 일치율 83.1%, 손상도 MAE 8.72, 과소 판정 22장, 장당 오판 손실 1.99만원\n", + " 오판 61장의 원인 분류: 오염 가림 29장(48%), 결함 유형 혼동 9장(15%), 저조도 8장(13%), 기타 8장(13%), 등급 경계 근처 7장(11%)\n", + " 오판 중 과소 판정(실제보다 낮은 등급) 22장 / 과대 39장 -> 과소 판정이 손실 단가가 3.3배 높아 우선 교정 대상\n", + "\n", + "[2] 분할 임계값 재보정(운영 사진 240장 IoU): 현행(0.70, 0.50) 0.495 -> 최적(0.75, 0.44) 0.528\n", + " 등급 경계 이동(보정용 120장, 과소 판정 손실 반영)\n", + " 경계 이동 delta 장당 손실(만원) 과소 판정 일치율\n", + " 0 1.483 5 0.850\n", + " 2 1.733 5 0.808\n", + " 4 1.933 5 0.775\n", + " 6 2.133 5 0.742\n", + " 8 1.950 3 0.733\n", + " 경계를 낮추면 과대 판정이 늘어 손실이 오히려 커지므로 경계 이동은 채택하지 않는다(delta=0 유지). 과소 판정은 경계가 아니라 오염 가림에서 주로 발생한다.\n", + "\n", + "[3] 보류 구간 B 검증\n", + " 재보정 전 재보정 후\n", + "등급 일치율 0.819 0.853\n", + "손상도 MAE 7.579 6.559\n", + "과소 판정(장) 26.000 25.000\n", + "장당 오판 손실(만원) 2.194 2.022\n", + "자동 처리 건수 150.000 151.000\n", + "자동 처리 일치율 0.980 0.980\n", + " 오판 손실 7.8% 감소, 과소 판정 26 -> 25장. 오염 가림 사진은 정보 자체가 가려져 재보정으로 해결되지 않으므로 재촬영이 필요하다.\n", + "\n", + "[4] ROI 3대 지표 [교육용 가정: 연 20000장, 정밀 판독 15분, 간이 승인 4분, 인건비 1.2만원/시, 도입 2500만원, 운영 150만원/년]\n", + " (1) 연간 비용 절감액 5,140만원(오판 손실 감소 3,444 + 판독 인건비 1,846 - 운영비) (2) 판독 시간 단축률 30.8%(관행 전수 정밀 판독 대비) (3) 투자 회수 기간 5.8개월\n", + " 산식: 절감액 = (재보정 전 - 후 장당 오판 손실) x 연 장수 + (정밀 판독 - 평균 판독 시간)/60 x 인건비 x 연 장수 - 운영비\n", + " 장수 민감도: 5000장 -> 절감 1,173만원, 회수 26개월; 10000장 -> 절감 2,495만원, 회수 12개월; 20000장 -> 절감 5,140만원, 회수 6개월; 40000장 -> 절감 10,430만원, 회수 3개월\n", + " 비관(재보정 효과 없음, 판독 시간 절감만): 절감 1,696만원, 회수 18개월\n", + " 가정값이므로 재정·기획 부서와 실제 단가로 다시 산정해야 한다. 재보정된 임계값·경계는 정비감독관 승인 후 적용한다.\n", + "```\n", + "
\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 10. 장비 결함 사진 기반 AI 외관 검사 및 손상도 자동 판정 도구 통합 완성\n", + "**소요시간**: 약 3.5시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "장비 결함 사진 기반 AI 외관 검사 및 손상도 자동 판정 도구를 완성한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **장비 결함 사진 기반 AI 외관 검사와 손상도 판정 도구를 통합하고 자동 재학습·운영 문서·자체 점검을 갖춘다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **신규 사진 240장을 추가해 정합성을 검증하고 분포 변화(PSI)를 점검한 뒤 챌린저 모델을 학습해 검증 MAE가 3% 이상 개선될 때만 승격 수행하고 검증하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `docs/qlt03_operations_guide.md 및 pipeline/qlt03_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": [ + "\"\"\"신규 사진 갱신, 정합성 검증, 분포 변화(PSI) 점검, 자동 재학습(챔피언/챌린저), 결함 판정·손상도·정비 등급 산출과 검사 대기·재촬영 분리, 제원 불일치 환류까지 한 번에 실행하는 통합 파이프라인을 완성하고 운영 문서 완비율·자체 점검·인터페이스 명세·브리핑 요약을 출력한다.\"\"\"\n", + "import importlib\n", + "import json\n", + "import sys\n", + "import numpy as np\n", + "import pandas as pd\n", + "import matplotlib.pyplot as plt\n", + "from sklearn.ensemble import RandomForestClassifier, GradientBoostingRegressor\n", + "from sklearn.linear_model import LinearRegression\n", + "from sklearn.model_selection import cross_val_predict\n", + "\n", + "# ---------------------------------------------------------------------------\n", + "# [교육용가상자료] 장비 외관 사진 생성기 (실제 장비·부대·위치 사진이 아니라 난수로 그린 64x64 합성 이미지)\n", + "# 도장된 금속 표면에 균열(crack)·부식(corrosion)·파손(damage)·누유(leak)를 그리고, 조명 편차와 진흙(mud) 오염을 섞는다.\n", + "# 이미지와 함께 장비 관리 기록(제원 요약)과 정답 결함 마스크·손상도·정비 등급을 만든다.\n", + "# ---------------------------------------------------------------------------\n", + "from scipy import ndimage as ndi\n", + "\n", + "SZ = 64\n", + "CLASSES = [\"none\", \"crack\", \"corrosion\", \"damage\", \"leak\"]\n", + "KO_CLS = {\"none\": \"결함 없음\", \"crack\": \"균열\", \"corrosion\": \"부식\", \"damage\": \"파손\", \"leak\": \"누유\"}\n", + "CLS_W = {\"none\": 0.0, \"crack\": 1.4, \"corrosion\": 1.0, \"damage\": 1.6, \"leak\": 0.6} # 손상도 계산용 결함 유형 가중치\n", + "GRADE_NAME = {1: \"현장정비\", 2: \"야전정비\", 3: \"창정비\"}\n", + "YY, XX = np.mgrid[0:SZ, 0:SZ]\n", + "\n", + "\n", + "def grade_of(idx):\n", + " # [TODO : 학생 실습 10] 장비 결함 사진 기반 AI 외관 검사 및 손상도 자동 판정 도구 통합 완성 관련 핵심 로직을 구현하세요.\n", + " # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n", + " # <<< 여기에 코드를 작성하세요 >>>\n", + " pass # 작성 후 pass를 제거하세요.\n", + "def blob(rng, cy, cx, r, rough=0.35):\n", + " \"\"\"불규칙한 원형 영역 마스크.\"\"\"\n", + " # [TODO : 학생 실습 10] 장비 결함 사진 기반 AI 외관 검사 및 손상도 자동 판정 도구 통합 완성 관련 핵심 로직을 구현하세요.\n", + " # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n", + " # <<< 여기에 코드를 작성하세요 >>>\n", + " pass # 작성 후 pass를 제거하세요.\n", + "def draw(rng, cls, sev):\n", + " \"\"\"결함 유형·심각도 sev(0~1)에 맞는 정답 마스크와 색 정보를 만든다.\"\"\"\n", + " m = np.zeros((SZ, SZ), bool)\n", + " if cls == \"crack\":\n", + " y, x, ang = rng.uniform(12, 52), rng.uniform(12, 52), rng.uniform(0, np.pi)\n", + " for _ in range(int(20 + 40 * sev)):\n", + " ang += rng.normal(0, 0.35); y += np.sin(ang) * 1.2; x += np.cos(ang) * 1.2\n", + " if 0 <= int(y) < SZ and 0 <= int(x) < SZ:\n", + " m[int(y), int(x)] = True\n", + " m = ndi.binary_dilation(m, iterations=1 + int(sev > 0.6))\n", + " elif cls == \"corrosion\":\n", + " for _ in range(int(rng.integers(1, 4))):\n", + " m |= blob(rng, rng.uniform(14, 50), rng.uniform(14, 50), 3 + 9 * sev * rng.uniform(0.6, 1.1))\n", + " elif cls == \"damage\":\n", + " m = blob(rng, rng.uniform(20, 44), rng.uniform(20, 44), 6 + 12 * sev, 0.3)\n", + " elif cls == \"leak\":\n", + " x0, y0 = rng.uniform(16, 48), rng.uniform(8, 24)\n", + " w, ln = 2 + 4 * sev, 12 + 26 * sev\n", + " m = (np.abs(XX - x0 - 0.15 * (YY - y0)) < w * (1 - 0.4 * (YY - y0) / ln)) & (YY > y0) & (YY < y0 + ln)\n", + " return m\n", + "\n", + "\n", + "def make_images(n=600, seed=0, mud=0.0, p_none=0.25, dark=False):\n", + " \"\"\"가상 외관 사진 n장(+진흙 오염 mud 비율)과 관리 기록·정답. 반환: meta(DataFrame), imgs(n,64,64,3), masks(n,64,64).\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " imgs, masks, rows = np.zeros((n, SZ, SZ, 3), np.float32), np.zeros((n, SZ, SZ), bool), []\n", + " for i in range(n):\n", + " cls = \"none\" if rng.random() < p_none else str(rng.choice(CLASSES[1:], p=[0.25, 0.3, 0.25, 0.2]))\n", + " sev = 0.0 if cls == \"none\" else float(rng.beta(1.6, 2.0))\n", + " base = np.array([0.42, 0.46, 0.40]) * (1 + 0.5 * ndi.gaussian_filter(rng.normal(size=(SZ, SZ)), 8)[..., None] * 3)\n", + " img = base + rng.normal(0, 0.012, (SZ, SZ, 3))\n", + " m = draw(rng, cls, sev)\n", + " a = ndi.gaussian_filter(m.astype(float), 0.8)[..., None] # 경계를 부드럽게 섞는다\n", + " if cls == \"crack\":\n", + " col = img * rng.uniform(0.2, 0.45)\n", + " elif cls == \"corrosion\":\n", + " col = np.array([0.55, 0.30, 0.12]) * rng.uniform(0.75, 1.15) * (0.85 + 0.3 * rng.random((SZ, SZ, 1)))\n", + " elif cls == \"damage\":\n", + " col = np.ones_like(img) * np.array([0.34, 0.34, 0.36]) * (1.0 - 0.45 * sev) * rng.uniform(0.75, 1.0)\n", + " rim = m & ~ndi.binary_erosion(m, iterations=2)\n", + " a = np.maximum(a, 0.0); img = np.where(rim[..., None], 0.7, img)\n", + " elif cls == \"leak\":\n", + " col = np.ones_like(img) * np.array([0.10, 0.09, 0.08]) * rng.uniform(0.8, 2.6)\n", + " else:\n", + " col = img\n", + " img = img * (1 - a) + col * a\n", + " if rng.random() < 0.35: # 무해한 얼룩(결함 아님): 옅은 갈색 번짐\n", + " st = blob(rng, rng.uniform(5, 58), rng.uniform(5, 58), rng.uniform(6, 14), 0.5)\n", + " al = ndi.gaussian_filter(st.astype(float), 2.5)[..., None] * rng.uniform(0.15, 0.4)\n", + " img = img * (1 - al) + np.array([0.50, 0.40, 0.30]) * al\n", + " if rng.random() < 0.3: # 무해한 긴 스크래치(밝은 선)\n", + " y0, x0, ang = rng.uniform(5, 58), rng.uniform(5, 58), rng.uniform(0, np.pi)\n", + " for t in np.linspace(0, 25, 40):\n", + " yy, xx = int(y0 + t * np.sin(ang)), int(x0 + t * np.cos(ang))\n", + " if 0 <= yy < SZ and 0 <= xx < SZ:\n", + " img[yy, xx] = np.clip(img[yy, xx] * 1.5, 0, 1)\n", + " if mud > 0: # 진흙 오염: 결함 위를 덮을 수 있다(정답 마스크는 그대로)\n", + " mm = np.zeros((SZ, SZ), bool)\n", + " while mm.mean() < mud:\n", + " mm |= blob(rng, rng.uniform(0, SZ), rng.uniform(0, SZ), rng.uniform(5, 11), 0.5)\n", + " img[mm] = np.array([0.38, 0.30, 0.22]) * (0.85 + 0.3 * rng.random((mm.sum(), 1)))\n", + " gain = rng.uniform(0.3, 0.55) if dark else rng.uniform(0.5, 1.15)\n", + " ang = rng.uniform(0, 2 * np.pi)\n", + " light = gain * (1 + 0.3 * (np.cos(ang) * (XX - 32) + np.sin(ang) * (YY - 32)) / 32)\n", + " imgs[i] = np.clip(img * light[..., None] + rng.normal(0, 0.025, (SZ, SZ, 3)), 0, 1)\n", + " masks[i] = m\n", + " area = float(m.mean())\n", + " depth = {\"none\": 0.0, \"crack\": 0.3 + 3.0 * sev, \"corrosion\": 0.1 + 1.5 * sev, \"damage\": 1.0 + 8.0 * sev, \"leak\": 0.0}[cls] * rng.lognormal(0, 0.1)\n", + " idx = float(np.clip(100 * (1 - np.exp(-(CLS_W[cls] * area * 9 + 0.05 * depth))) + rng.normal(0, 1.5), 0, 100))\n", + " rows.append({\"img_id\": f\"IMG{seed:02d}{i:04d}\", \"cls\": cls, \"sev\": sev, \"area_frac\": area, \"depth_mm\": depth, \"damage_idx\": idx,\n", + " \"grade\": int(grade_of(idx)), \"gain\": gain, \"mud\": mud})\n", + " meta = pd.DataFrame(rows)\n", + " # 장비 관리 기록(제원 요약): 사진 판정과 대조할 값\n", + " age = rng.integers(1, 25, n)\n", + " meta[\"day\"] = np.sort(rng.integers(0, 90, n))\n", + " meta[\"equip_type\"] = rng.choice([\"vehicle\", \"generator\", \"comm\", \"trailer\"], n, p=[0.4, 0.2, 0.2, 0.2])\n", + " meta[\"mfg_year\"] = 2026 - age\n", + " meta[\"op_hours\"] = np.clip(rng.normal(180 * age, 400, n) + 8 * meta[\"damage_idx\"], 10, 20000)\n", + " meta[\"env\"] = rng.choice([\"coastal\", \"inland\"], n, p=[0.3, 0.7])\n", + " reg = np.clip(meta[\"grade\"] + rng.choice([-1, 0, 0, 0, 0, 0, 1, 2], n), 1, 3) # 장비 관리 기록상 직전 정비 등급\n", + " meta[\"reg_grade\"] = reg\n", + " meta[\"emergency\"] = rng.random(n) < 0.04 # 긴급 점검 요청(화질 불량이어도 보존)\n", + " return meta, imgs, masks\n", + "# ---- 전처리(조명 보정·표준화)와 결함 후보 분할, 특징 추출 (cv2 대신 numpy·scipy 사용) ----\n", + "def correct(img):\n", + " \"\"\"조명 보정: 밝기에 평면(조명 기울기)을 적합해 나눈다. 어두운 결함 화소는 제외하고 두 번 반복 적합한다. 반환: 보정 밝기(정상 면 약 1), 색 비율.\"\"\"\n", + " g = img.mean(axis=2)\n", + " A = np.c_[np.ones(SZ * SZ), XX.ravel() / SZ, YY.ravel() / SZ]\n", + " keep = np.ones(SZ * SZ, bool)\n", + " for _ in range(3):\n", + " coef, *_ = np.linalg.lstsq(A[keep], g.ravel()[keep], rcond=None)\n", + " plane = (A @ coef).reshape(SZ, SZ)\n", + " keep = (np.abs(g - plane) < 0.08 * np.maximum(plane, 1e-3)).ravel()\n", + " if keep.sum() < 200:\n", + " keep[:] = True\n", + " c = g / np.maximum(plane, 1e-3)\n", + " chroma = img[..., 0] / (img.sum(axis=2) + 1e-6)\n", + " return c, chroma\n", + "\n", + "\n", + "SEGP = {\"dark\": 0.7, \"rust\": 0.5, \"rim\": 1.45} # 분할 임계값(어두움 보정 밝기, 붉은 비율, 밝은 테두리): 재보정 대상\n", + "\n", + "\n", + "def segment(img):\n", + " \"\"\"결함 후보 마스크: 어두운 영역(균열·누유·파손) + 붉은 갈색 영역(부식) + 밝은 테두리(파손).\"\"\"\n", + " c, chroma = correct(img)\n", + " dark = c < SEGP[\"dark\"]\n", + " rust = (chroma > SEGP[\"rust\"]) & (c > 0.45)\n", + " rim = c > SEGP[\"rim\"]\n", + " m = dark | rust | ndi.binary_dilation(rim, iterations=1) & ndi.binary_dilation(dark, iterations=3)\n", + " m = ndi.binary_opening(m, structure=np.ones((2, 2))) if m.sum() > 12 else m\n", + " return m, c, chroma, dark, rust, rim\n", + "\n", + "\n", + "def extract(img):\n", + " \"\"\"분할 결과와 색·모양에서 24개 특징을 만든다.\"\"\"\n", + " m, c, chroma, dark, rust, rim = segment(img)\n", + " lab, n = ndi.label(m)\n", + " area = float(m.sum())\n", + " if n:\n", + " sizes = ndi.sum(m, lab, range(1, n + 1))\n", + " k = int(np.argmax(sizes)) + 1\n", + " sl = ndi.find_objects(lab)[k - 1]\n", + " h, w = sl[0].stop - sl[0].start, sl[1].stop - sl[1].start\n", + " big = float(sizes.max())\n", + " elong, fill = max(h, w) / max(min(h, w), 1), big / (h * w)\n", + " else:\n", + " big, elong, fill = 0.0, 1.0, 0.0\n", + " gy, gx = np.gradient(c)\n", + " mag = np.hypot(gx, gy)\n", + " inside = m.sum() > 0\n", + " return {\"area_px\": area, \"area_frac\": area / SZ ** 2, \"n_comp\": float(n), \"big_px\": big, \"elong\": elong, \"fill\": fill,\n", + " \"dark_frac\": float(dark.mean()), \"rust_frac\": float(rust.mean()), \"rim_frac\": float(rim.mean()),\n", + " \"c_mean\": float(c.mean()), \"c_std\": float(c.std()), \"c_min\": float(c.min()), \"edge_mean\": float(mag.mean()), \"edge_p95\": float(np.quantile(mag, 0.95)),\n", + " \"chroma_mean\": float(chroma.mean()), \"chroma_max\": float(chroma.max()),\n", + " \"in_dark\": float(c[m].mean()) if inside else 1.0, \"in_chroma\": float(chroma[m].mean()) if inside else float(chroma.mean()),\n", + " \"ring_contrast\": float(c[ndi.binary_dilation(m, iterations=3) & ~m].mean() - c[m].mean()) if inside and (~m).any() else 0.0,\n", + " \"thin\": float(1 - fill), \"gain\": float(img.mean()), \"sat\": float((img.max(axis=2) - img.min(axis=2)).mean()),\n", + " \"cy\": float(ndi.center_of_mass(m)[0] / SZ) if inside else 0.5, \"cx\": float(ndi.center_of_mass(m)[1] / SZ) if inside else 0.5}\n", + "\n", + "\n", + "def feature_table(imgs):\n", + " return pd.DataFrame([extract(im) for im in imgs])\n", + "# ---- 판정 파이프라인: 결함 유형 분류 + 손상도 회귀(분류 확률 포함) -> 정비 등급 ----\n", + "def fit_pipeline(meta, F):\n", + " \"\"\"결함 유형 분류기와 손상도 회귀기를 학습한다. 회귀기 입력의 분류 확률은 교차 예측으로 만들어 누설을 막는다.\"\"\"\n", + " clf = RandomForestClassifier(n_estimators=150, random_state=0, n_jobs=1)\n", + " P = cross_val_predict(clf, F, meta[\"cls\"], cv=4, method=\"predict_proba\")\n", + " clf.fit(F, meta[\"cls\"])\n", + " reg = GradientBoostingRegressor(n_estimators=200, max_depth=3, learning_rate=0.06, random_state=0).fit(np.c_[F.values, P], meta[\"damage_idx\"])\n", + " return {\"clf\": clf, \"reg\": reg, \"cols\": list(F.columns)}\n", + "\n", + "\n", + "def predict_pipeline(pl, F):\n", + " X = F[pl[\"cols\"]]\n", + " P = pl[\"clf\"].predict_proba(X)\n", + " idx = np.clip(pl[\"reg\"].predict(np.c_[X.values, P]), 0, 100)\n", + " return pl[\"clf\"].classes_[P.argmax(axis=1)], P, idx, P.max(axis=1)\n", + "# ---- 사진 품질 게이트: 진흙·오염 가림 추정과 판정 신뢰도 지수 ----\n", + "def mud_est(img):\n", + " \"\"\"진흙(갈색, 채도 낮은 붉은 기) 화소 비율을 추정한다. 3x3 평균으로 잡음을 줄인 뒤 색 비율로 판단하며, 실제 가림 비율보다 작게 나오지만 순서는 유지된다.\"\"\"\n", + " sm = ndi.uniform_filter(img, size=(3, 3, 1))\n", + " s = sm.sum(axis=2) + 1e-6\n", + " rr, gg = sm[..., 0] / s, sm[..., 1] / s\n", + " return float(((rr > 0.385) & (rr < 0.46) & (gg < 0.325)).mean())\n", + "\n", + "\n", + "def confidence(P, idx, F, mud):\n", + " \"\"\"판정 신뢰도 지수 0~100 = 50 x 분류 확률 최대값 + 35 x 등급 경계까지 거리(8점에서 포화) + 15 x 사진 품질. 오염 의심(mud 추정 1% 이상)이면 상한 75로 제한한다.\"\"\"\n", + " bd = np.minimum(np.abs(idx - 35), np.abs(idx - 65))\n", + " qual = np.clip(1.0 - np.clip(0.55 - F[\"gain\"].values, 0, None) * 2 - np.clip(0.9 - F[\"c_mean\"].values, 0, None), 0, 1)\n", + " c = 50 * P.max(axis=1) + 35 * np.clip(bd / 8, 0, 1) + 15 * qual\n", + " return np.where(np.asarray(mud) >= 0.01, np.minimum(c, 75.0), c)\n", + "\n", + "libs = [\"numpy\", \"pandas\", \"scipy\", \"sklearn\", \"matplotlib\"]\n", + "env_ok = sum(importlib.util.find_spec(m) is not None for m in libs)\n", + "print(f\"[0] 환경 점검: Python {sys.version_info.major}.{sys.version_info.minor}, 필수 모듈 {env_ok}/{len(libs)}\")\n", + "\n", + "# ---------------- 1) 신규 사진 갱신·정합성 검증(미션 1~2 요약) ----------------\n", + "m_old, i_old, k_old = make_images(600, seed=1) # 기존 600장\n", + "parts = [make_images(80, seed=80, dark=True), make_images(80, seed=81, mud=0.05), make_images(80, seed=82)]\n", + "m_new = pd.concat([p[0] for p in parts], ignore_index=True); i_new = np.concatenate([p[1] for p in parts]); k_new = np.concatenate([p[2] for p in parts])\n", + "m_new[\"img_id\"] = [\"NEW\" + x[3:] for x in m_new[\"img_id\"]] # 신규 사진 ID 구분\n", + "meta = pd.concat([m_old, m_new], ignore_index=True); imgs = np.concatenate([i_old, i_new]); masks = np.concatenate([k_old, k_new])\n", + "chk = {\"사진 ID 유일\": not meta[\"img_id\"].duplicated().any(), \"결측 0(제원·라벨)\": int(meta[[\"equip_type\", \"mfg_year\", \"op_hours\", \"env\", \"reg_grade\"]].isna().sum().sum()) == 0,\n", + " \"화소 범위 0~1\": bool((imgs.min() >= 0) and (imgs.max() <= 1)), \"해상도 64x64x3\": imgs.shape[1:] == (64, 64, 3), \"제조연도 가능 범위\": bool(meta[\"mfg_year\"].between(1990, 2026).all()),\n", + " \"마스크 면적 = 라벨 면적\": bool(np.allclose(masks.mean(axis=(1, 2)), meta[\"area_frac\"], atol=1e-6)), \"손상도-등급 일치\": bool((grade_of(meta[\"damage_idx\"]) == meta[\"grade\"]).all())}\n", + "print(f\"[1] 신규 240장 포함 {len(meta)}장 정합성 검증 {sum(chk.values())}/{len(chk)} 통과, 긴급 점검 사진 {int(meta['emergency'].sum())}장 보존\")\n", + "F = feature_table(imgs)\n", + "F[\"mud\"] = [mud_est(x) for x in imgs]\n", + "n_old = len(m_old)\n", + "\n", + "# ---------------- 2) 분포 변화 점검(PSI) 및 자동 재학습 ----------------\n", + "def psi(a, b, bins=8):\n", + " q = np.unique(np.quantile(a, np.linspace(0, 1, bins + 1)))\n", + " q[0], q[-1] = -np.inf, np.inf\n", + " pa, pb = np.histogram(a, q)[0] / len(a) + 1e-4, np.histogram(b, q)[0] / len(b) + 1e-4\n", + " return float(np.sum((pa - pb) * np.log(pa / pb)))\n", + "\n", + "\n", + "drift = {c: psi(F[c][:n_old], F[c][n_old:]) for c in [\"gain\", \"c_mean\", \"c_std\", \"rust_frac\", \"area_frac\", \"mud\"]}\n", + "worst = max(drift, key=drift.get)\n", + "print(\"[2] 분포 변화 PSI(기존 600 vs 신규 240):\", \", \".join(f\"{k} {v:.2f}\" for k, v in drift.items()), f\"-> 가장 큰 변화 {worst} (0.2 이상이면 재학습 검토)\")\n", + "rng = np.random.default_rng(0)\n", + "new_idx = rng.permutation(np.arange(n_old, len(meta)))\n", + "tr_new, hold = new_idx[:160], new_idx[160:] # 신규 중 160장은 재학습용, 80장은 검증용\n", + "cols = [c for c in F.columns if c != \"mud\"]\n", + "\n", + "\n", + "def fit_on(ix):\n", + " return fit_pipeline(meta.iloc[ix].reset_index(drop=True), F.iloc[ix][cols].reset_index(drop=True))\n", + "\n", + "\n", + "def score(pl, ix):\n", + " cls, P, idx, _ = predict_pipeline(pl, F.iloc[ix][cols])\n", + " y = meta[\"damage_idx\"].values[ix]\n", + " return float(np.mean(np.abs(y - idx))), float(np.mean(grade_of(idx) == meta[\"grade\"].values[ix]))\n", + "\n", + "\n", + "champ = fit_on(np.arange(n_old))\n", + "chall = fit_on(np.r_[np.arange(n_old), tr_new])\n", + "a, b = score(champ, hold), score(chall, hold)\n", + "promote = (a[0] - b[0]) / a[0] >= 0.03\n", + "drifted = max(drift.values()) >= 0.2\n", + "print(f\" 검증 80장(신규 분포): 챔피언 MAE {a[0]:.2f}·등급 일치 {a[1]:.1%} vs 챌린저 MAE {b[0]:.2f}·등급 일치 {b[1]:.1%} -> {'챌린저 승격' if promote else '기존 모델 유지'}(MAE 3% 이상 개선일 때만 승격)\")\n", + "model = chall if promote else champ\n", + "\n", + "# ---------------- 3) 신규 사진 판정과 검사 대기 분리 ----------------\n", + "Xn = F.iloc[n_old:][cols].reset_index(drop=True)\n", + "cls, P, idx, _ = predict_pipeline(model, Xn)\n", + "mud = F[\"mud\"].values[n_old:]\n", + "conf = confidence(P, idx, Xn, mud)\n", + "res = pd.DataFrame({\"사진\": meta[\"img_id\"].values[n_old:], \"판정 유형\": [KO_CLS[c] for c in cls], \"손상도\": idx, \"AI 등급\": grade_of(idx), \"신뢰도\": conf, \"오염\": mud, \"기록 등급\": meta[\"reg_grade\"].values[n_old:], \"실제 등급\": meta[\"grade\"].values[n_old:]})\n", + "res[\"처리\"] = np.select([(mud >= 0.03) | (Xn[\"gain\"].values < 0.17), conf >= 80], [\"재촬영 요구\", \"검사 대기(간이 승인 후보)\"], \"검사 대기(정밀 판독)\")\n", + "res[\"제원 신호\"] = np.abs(res[\"AI 등급\"] - res[\"기록 등급\"]) >= 2\n", + "print(f\"\\n[3] 신규 {len(res)}장 판정 결과 [교육용가상자료] (상위 6건)\")\n", + "print(res.head(6)[[\"사진\", \"판정 유형\", \"손상도\", \"AI 등급\", \"신뢰도\", \"오염\", \"처리\"]].round(2).to_string(index=False))\n", + "vc = res[\"처리\"].value_counts()\n", + "print(\" 처리 구분:\", vc.to_dict(), \"/ 자동 확정 0건(모든 판정은 검사관 확인과 정비감독관 결재 필요)\")\n", + "auto = res[\"처리\"].str.contains(\"간이\")\n", + "print(f\" 간이 승인 후보 {int(auto.sum())}장의 등급 정답 일치율 {(res['AI 등급'] == res['실제 등급'])[auto].mean():.1%}, 전체 일치율 {(res['AI 등급'] == res['실제 등급']).mean():.1%}\")\n", + "print(f\" 제원 환류: AI 등급과 관리 기록 등급이 2단계 이상 다른 {int(res['제원 신호'].sum())}장은 제원 수정 요청 후보(자동 수정 금지, 담당자 확인)\")\n", + "\n", + "# ---------------- 4) 운영 문서 완비율·인터페이스 명세 ----------------\n", + "docs = {\"데이터 사전·스키마(M1)\": 24, \"정제 규칙 11종(M2)\": 11, \"Baseline·EDA(M3)\": 8, \"분할·손상도 모델(M4)\": 24, \"등급·신뢰도 엔진(M5)\": 3, \"판정 인터페이스·설명(M6)\": 5,\n", + " \"승인 절차·권한(M7)\": 4, \"오염 스트레스·한계치(M8)\": 7, \"AAR·ROI(M9)\": 4, \"운영·재학습 절차(M10)\": 6, \"장애 대응\": 5, \"인터페이스 가이드\": 4}\n", + "done = sum(v > 0 for v in docs.values())\n", + "iface = {\"input\": {\"photo\": [\"img_id:str\", \"image:64x64x3 float\", \"equip_type:str\", \"reg_grade:int\"], \"format\": \"image+json\"},\n", + " \"output\": {\"result\": [\"img_id\", \"defect\", \"damage_idx\", \"grade\", \"confidence\", \"mud\", \"state\"], \"approval\": \"PENDING_ONLY\"}, \"auth\": \"role-based(가상)\", \"note\": \"상위 군수정보체계는 가상 인터페이스로만 정의\"}\n", + "print(f\"\\n[4] 운영 문서 {len(docs)}개 항목 중 작성 완료 {done}개 ({done / len(docs):.0%}) / 인터페이스 명세(JSON):\")\n", + "print(\" \", json.dumps(iface, ensure_ascii=False))\n", + "\n", + "# ---------------- 5) 자체 점검·브리핑 ----------------\n", + "ev_ix = np.arange(n_old, len(meta))\n", + "y = meta[\"damage_idx\"].values[ev_ix]\n", + "fin_mae = float(np.mean(np.abs(y - idx)))\n", + "big = y >= 10\n", + "fin_mape = float(np.mean(np.abs(y[big] - idx[big]) / y[big]) * 100)\n", + "base = LinearRegression().fit(F.iloc[:n_old][[\"area_px\"]], meta[\"damage_idx\"][:n_old])\n", + "base_mae = float(np.mean(np.abs(y - np.clip(base.predict(F.iloc[n_old:][[\"area_px\"]]), 0, 100))))\n", + "checks = [(\"환경 점검 통과\", env_ok == len(libs)), (\"데이터 정합성 전 항목 통과\", all(chk.values())), (\"재학습 파이프라인 정상 실행(챔피언/챌린저 판정 출력)\", a[0] > 0 and b[0] > 0),\n", + " (\"손상도 MAPE 25% 이하(점수 10 이상)\", fin_mape <= 25), (\"면적 기준 모델 대비 MAE 20% 이상 개선\", fin_mae <= 0.8 * base_mae),\n", + " (\"자동 확정 0건(검사 대기·재촬영 분리)\", bool(res[\"처리\"].str.contains(\"검사 대기|재촬영\").all())), (\"오염 의심 사진 전부 재촬영 또는 정밀 판독으로 분리\", bool(res.loc[res[\"오염\"] >= 0.01, \"처리\"].ne(\"검사 대기(간이 승인 후보)\").all())),\n", + " (\"운영 문서 100% 완비\", done == len(docs))]\n", + "print(\"\\n[5] 자체 점검\")\n", + "for n, ok in checks:\n", + " print(f\" [{'PASS' if ok else 'FAIL'}] {n}\")\n", + "print(\"\\n[6] 브리핑 요약\")\n", + "print(f\" - 신규 사진 240장으로 분포 변화를 점검({worst} PSI {drift[worst]:.2f}, {'재학습 검토 수준' if drifted else '경미'})하고 재학습 판단을 자동 수행했다({'챌린저 승격' if promote else '기존 모델 유지'}).\")\n", + "print(f\" - 신규 사진 손상도 MAE {base_mae:.1f}(면적 기준) -> {fin_mae:.1f}, MAPE {fin_mape:.1f}%. 처리 구분: \" + \", \".join(f\"{k} {v}장\" for k, v in vc.items()) + \".\")\n", + "print(\" - 최종 정비 등급은 검사관·정비감독관 결재로 확정하며 AI 판정은 자동 확정되지 않는다. 오염 사진은 재촬영을 요구했다.\")\n", + "\n", + "fig, ax = plt.subplots(1, 2, figsize=(12, 4))\n", + "ax[0].bar(list(drift), list(drift.values())); ax[0].axhline(0.2, c=\"r\", ls=\"--\"); ax[0].set_title(\"Feature drift (PSI), old vs new photos\"); ax[0].tick_params(axis=\"x\", rotation=30)\n", + "ax[1].scatter(y, idx, s=8, c=np.where(res[\"처리\"] == \"재촬영 요구\", \"r\", \"g\")); ax[1].plot([0, 100], [0, 100], \"k--\"); ax[1].set_title(\"New photos: damage index true vs predicted (red = retake)\")\n", + "plt.suptitle(\"QLT03 integrated pipeline (synthetic photos)\"); plt.tight_layout(); plt.show()\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 10 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + "[0] 환경 점검: Python 3.11, 필수 모듈 5/5\n", + "[1] 신규 240장 포함 840장 정합성 검증 7/7 통과, 긴급 점검 사진 38장 보존\n", + "[2] 분포 변화 PSI(기존 600 vs 신규 240): gain 0.56, c_mean 0.10, c_std 0.26, rust_frac 0.39, area_frac 0.07, mud 0.79 -> 가장 큰 변화 mud (0.2 이상이면 재학습 검토)\n", + " 검증 80장(신규 분포): 챔피언 MAE 8.79·등급 일치 82.5% vs 챌린저 MAE 6.86·등급 일치 86.2% -> 챌린저 승격(MAE 3% 이상 개선일 때만 승격)\n", + "\n", + "[3] 신규 240장 판정 결과 [교육용가상자료] (상위 6건)\n", + " 사진 판정 유형 손상도 AI 등급 신뢰도 오염 처리\n", + "NEW800000 균열 34.85 1 53.85 0.0 재촬영 요구\n", + "NEW800001 부식 30.58 1 72.79 0.0 검사 대기(정밀 판독)\n", + "NEW800002 결함 없음 1.25 1 87.85 0.0 검사 대기(간이 승인 후보)\n", + "NEW800003 결함 없음 9.30 1 86.00 0.0 검사 대기(간이 승인 후보)\n", + "NEW800004 부식 41.78 2 81.09 0.0 재촬영 요구\n", + "NEW800005 부식 36.30 2 58.39 0.0 재촬영 요구\n", + " 처리 구분: {'검사 대기(간이 승인 후보)': 112, '검사 대기(정밀 판독)': 86, '재촬영 요구': 42} / 자동 확정 0건(모든 판정은 검사관 확인과 정비감독관 결재 필요)\n", + " 간이 승인 후보 112장의 등급 정답 일치율 99.1%, 전체 일치율 90.4%\n", + " 제원 환류: AI 등급과 관리 기록 등급이 2단계 이상 다른 19장은 제원 수정 요청 후보(자동 수정 금지, 담당자 확인)\n", + "\n", + "[4] 운영 문서 12개 항목 중 작성 완료 12개 (100%) / 인터페이스 명세(JSON):\n", + " {\"input\": {\"photo\": [\"img_id:str\", \"image:64x64x3 float\", \"equip_type:str\", \"reg_grade:int\"], \"format\": \"image+json\"}, \"output\": {\"result\": [\"img_id\", \"defect\", \"damage_idx\", \"grade\", \"confidence\", \"mud\", \"state\"], \"approval\": \"PENDING_ONLY\"}, \"auth\": \"role-based(가상)\", \"note\": \"상위 군수정보체계는 가상 인터페이스로만 정의\"}\n", + "\n", + "[5] 자체 점검\n", + " [PASS] 환경 점검 통과\n", + " [PASS] 데이터 정합성 전 항목 통과\n", + " [PASS] 재학습 파이프라인 정상 실행(챔피언/챌린저 판정 출력)\n", + " [PASS] 손상도 MAPE 25% 이하(점수 10 이상)\n", + " [PASS] 면적 기준 모델 대비 MAE 20% 이상 개선\n", + " [PASS] 자동 확정 0건(검사 대기·재촬영 분리)\n", + " [PASS] 오염 의심 사진 전부 재촬영 또는 정밀 판독으로 분리\n", + " [PASS] 운영 문서 100% 완비\n", + "\n", + "[6] 브리핑 요약\n", + " - 신규 사진 240장으로 분포 변화를 점검(mud PSI 0.79, 재학습 검토 수준)하고 재학습 판단을 자동 수행했다(챌린저 승격).\n", + " - 신규 사진 손상도 MAE 13.0(면적 기준) -> 4.5, MAPE 10.6%. 처리 구분: 검사 대기(간이 승인 후보) 112장, 검사 대기(정밀 판독) 86장, 재촬영 요구 42장.\n", + " - 최종 정비 등급은 검사관·정비감독관 결재로 확정하며 AI 판정은 자동 확정되지 않는다. 오염 사진은 재촬영을 요구했다.\n", + "```\n", + "
\n" + ] + } + ], + "metadata": { + "colab": { + "provenance": [] + }, + "kernelspec": { + "display_name": "Python 3", + "name": "python3" + }, + "language_info": { + "name": "python" + } + }, + "nbformat": 4, + "nbformat_minor": 0 +} \ No newline at end of file diff --git a/uploads/QLT04/QLT04_solution.ipynb b/uploads/QLT04/QLT04_solution.ipynb new file mode 100644 index 0000000..a1eb152 --- /dev/null +++ b/uploads/QLT04/QLT04_solution.ipynb @@ -0,0 +1,3523 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# 🎯 [QLT04] Data 기반 외주정비 품질검사 통합 솔루션\n", + "### Google Colab 실습 환경 (품질·규격·검사 / Level 2)\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/QLT04/QLT04_solution.ipynb)\n", + "\n", + "---\n", + "\n", + "## 📌 1. 프로젝트 개요 및 배경\n", + "\n", + "| 항목 | 내용 |\n", + "| :--- | :--- |\n", + "| **과정 분류** | 품질·규격·검사 (Level 2) |\n", + "| **학습 소요 시간** | 10개 미션 (5일 / 총 27.0시간) |\n", + "| **환경 사양** | Python 3.10+, numpy, pandas, matplotlib, scipy, scikit-learn |\n", + "\n", + "### 📖 프로젝트 목표\n", + "외주정비는 정비 대기, 외주 검사 대기, 재작업, 규격·제원 불일치가 겹치면서 장비 가동률을 떨어뜨리지만 요인별 영향이 한눈에 보이지 않아 어디에 정비 자원을 먼저 투입할지 정하기 어렵다. 본 프로젝트는 미 국방부 외주정비 품질보증 사례의 문제의식(규격 정합성·검사 병목·하자 연결)을 교육용 가상 자료로 재구성해, 정비 대기 요인 분류, 저가동 요인 분석, 재배치 시뮬레이션, 추세 대시보드, 사람 검토 워크플로우까지 프로토타입으로 구현하는 과제이다. 가상 장비는 유형 A·B·C와 가상 부대로만 구분하며 실제 장비·부대 자료는 쓰지 않는다. 최종 목표는 가동률 저하 요인과 외주 품질검사 병목을 근거와 함께 보여 주되, 정비 우선순위와 자원 투입 결정은 정비관·품질검사관·군수참모가 하도록 지원하는 도구를 완성하는 것이다.\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. 사단/여단별 주요 전술 장비(전차, 자주포, 장갑차) 일일 가동률 및 정비 대기 데이터셋 정의\n", + "**소요시간**: 약 1.5시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "전술 장비(전차, 자주포) 일일 가동률 및 정비 대기 데이터셋을 정의한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **부대별 전술 장비(유형 A·B·C로 추상화한 가상 장비)의 일일 가동률과 정비 대기 자료의 구조와 입출력 명세를 정의하고, 외주정비 입고부터 품질검사·가동 복귀까지의 현행(AS-IS) 업무를 단계로 나누어 병목을 찾는다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **장비 식별, 정비 요청, 대기 요인별 일수(부품·정비병·외주 정비·외주 검사), 품질 지표(재작업·규격 불일치·하자), 자원 수준, 가동률을 묶어 필드 16개의 스키마와 검증 규칙 작성하여 원인을 분석하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `docs/qlt04_as_is_to_be_spec.md` 형태의 결과물을 완성한다.\n", + "\n", + "### 🔍 평가 및 검증 기준 (PASS 조건)\n", + "#### 미션 판정\n", + "\n", + "**평가 구분:** 필수\n", + "\n", + "**핵심 평가영역:** 현행 업무(AS-IS)와 개선 업무(TO-BE) 비교의 명확성\n", + "\n", + "#### 미션 PASS 조건\n", + "\n", + "- 정량 기준: 업무 단계 분해 10단계 이상, 병목 구간 3개소 이상 도출\n", + "\n", + "\n", + "- 진행 기준: 업무 정의서 및 입출력 데이터 명세 작성 완료\n", + "\n", + "\n", + "- 검증 방법: 실무 프로세스와의 일치성 검증\n", + "\n", + "\n", + "#### 채점자 판정 메모\n", + "\n", + "> 단순 코드 실행 여부뿐만 아니라 출력된 수치와 근거 자료가 군 실무 요구사항에 부합하는지 면밀히 확인한다.\n", + "\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "\"\"\"가상 장비 가동률·정비 대기 자료(필드 16개)의 스키마와 검증 규칙을 정의하고 오류 주입 시험으로 검출력을 확인한 뒤, 정비 입고부터 외주 정비 검사·가동 복귀까지의 현행(AS-IS) 업무를 12단계로 분해해 병목 구간과 개선(TO-BE) 효과를 비교한다.\"\"\"\n", + "import re\n", + "import numpy as np\n", + "import pandas as pd\n", + "import matplotlib.pyplot as plt\n", + "\n", + "# ---------------------------------------------------------------------------\n", + "# [교육용가상자료] 가상 장비 가동률·정비 대기 자료 생성기 (실제 부대·장비·제원·가동률 정보 없음)\n", + "# 장비 유형 A(궤도형)·B(화포형)·C(차륜형)은 추상 구분이며 실제 장비와 무관하다.\n", + "# 최근 90일 동안 장비 1대의 정비 대기 요인(부품·정비병·외주 정비·외주 검사·재작업)과 가동률을 만든다.\n", + "# ---------------------------------------------------------------------------\n", + "UNITS = [\"가상A부대\", \"가상B부대\", \"가상C부대\", \"가상D부대\", \"가상E부대\", \"가상F부대\"]\n", + "ETYPES = [\"유형A\", \"유형B\", \"유형C\"]\n", + "WINDOW = 90 # 관측 기간(일)\n", + "CAUSES = [\"부품 대기\", \"정비병 부족\", \"외주 정비\", \"검사·재작업\"]\n", + "CAUSE_COL = {\"부품 대기\": \"parts_wait_days\", \"정비병 부족\": \"labor_wait_days\", \"외주 정비\": \"outsource_days\", \"검사·재작업\": \"inspect_wait_days\"}\n", + "NUM_FIELDS = [\"age_years\", \"op_hours\", \"maint_orders\", \"parts_wait_days\", \"labor_wait_days\", \"outsource_days\", \"inspect_wait_days\", \"rework_count\",\n", + " \"spec_mismatch\", \"defect_reports\", \"parts_stock_pct\", \"mechanic_fill_pct\", \"avail_pct\"]\n", + "FEAT = [\"age_years\", \"maint_orders\", \"parts_stock_pct\", \"mechanic_fill_pct\", \"outsource_days\", \"inspect_wait_days\", \"rework_count\", \"spec_mismatch\", \"defect_reports\", \"etype_B\", \"etype_C\"]\n", + "FEAT_KO = {\"age_years\": \"장비 연령\", \"maint_orders\": \"정비 요청 건수\", \"parts_stock_pct\": \"부품 재고 가용률\", \"mechanic_fill_pct\": \"정비병 충원율\", \"outsource_days\": \"외주 정비 일수\",\n", + " \"inspect_wait_days\": \"외주 검사 대기 일수\", \"rework_count\": \"재작업 건수\", \"spec_mismatch\": \"규격·제원 불일치\", \"defect_reports\": \"하자·불만 접수\",\n", + " \"etype_B\": \"유형B 여부\", \"etype_C\": \"유형C 여부\"}\n", + "\n", + "\n", + "def unit_profile(seed=3):\n", + " \"\"\"부대별 고정 특성: 부품 재고 수준, 정비병 충원율, 외주 정비소 거리(일수 가중), 외주 검사 혼잡도.\"\"\"\n", + " r = np.random.default_rng(seed)\n", + " return pd.DataFrame({\"unit\": UNITS, \"stock\": r.uniform(45, 95, len(UNITS)), \"fill\": r.uniform(55, 98, len(UNITS)),\n", + " \"dist\": r.uniform(0.8, 1.6, len(UNITS)), \"queue\": r.uniform(0.7, 2.2, len(UNITS))})\n", + "\n", + "\n", + "def make_fleet(n=300, seed=7, shock=0.0):\n", + " \"\"\"장비 n대의 90일 관측 자료(필드 16개)와 정답 요인을 만든다. shock은 외주 검사 병목 악화 정도(AAR용 환경 변화).\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " pf = unit_profile()\n", + " ui = rng.integers(0, len(UNITS), n)\n", + " et = rng.choice(ETYPES, n, p=[.35, .3, .35])\n", + " age = np.clip(rng.gamma(4, 2.2, n), 0.5, 30).round(1)\n", + " eff = np.array([{\"유형A\": 1.15, \"유형B\": 1.0, \"유형C\": 0.9}[t] for t in et])\n", + " orders = rng.poisson(np.clip(1.2 + 0.12 * age, 0.5, 6) * eff)\n", + " stock = np.clip(pf[\"stock\"].to_numpy()[ui] + rng.normal(0, 8, n), 20, 100).round(0)\n", + " fill = np.clip(pf[\"fill\"].to_numpy()[ui] + rng.normal(0, 6, n), 30, 100).round(0)\n", + " parts_w = (orders * rng.gamma(2, 0.55 * (1 + ((100 - stock) / 28) ** 2) / 2)).round(0)\n", + " labor_w = (orders * rng.gamma(2, 0.7 * (1 + ((100 - fill) / 24) ** 2) / 2)).round(0)\n", + " n_out = rng.binomial(orders, 0.3)\n", + " out_d = (n_out * rng.gamma(3, 4.5 * pf[\"dist\"].to_numpy()[ui] / 3)).round(0)\n", + " insp_w = (n_out * rng.gamma(2, (2.6 + shock) * pf[\"queue\"].to_numpy()[ui] / 2)).round(0)\n", + " spec = rng.poisson(0.2 + 0.04 * age)\n", + " rework = rng.binomial(n_out, np.clip(0.08 + 0.1 * spec, 0, .6))\n", + " defect = rng.poisson(0.3 + 0.7 * rework)\n", + " base_rep = orders * 1.8\n", + " down = base_rep + parts_w + labor_w + out_d + insp_w + rework * 4 + rng.normal(0, 1.2, n)\n", + " comp = np.column_stack([parts_w, labor_w, out_d, insp_w + rework * 4])\n", + " df = pd.DataFrame({\"equip_id\": [f\"E-{i + 1:04d}\" for i in range(n)], \"unit\": np.array(UNITS)[ui], \"etype\": et, \"age_years\": age,\n", + " \"op_hours\": np.clip(rng.normal(320 - 4 * age, 70, n), 40, 600).round(0), \"maint_orders\": orders,\n", + " \"parts_wait_days\": parts_w, \"labor_wait_days\": labor_w, \"outsource_days\": out_d, \"inspect_wait_days\": insp_w,\n", + " \"rework_count\": rework, \"spec_mismatch\": spec, \"defect_reports\": defect, \"parts_stock_pct\": stock, \"mechanic_fill_pct\": fill,\n", + " \"avail_pct\": (100 * (1 - np.clip(down, 0, WINDOW) / WINDOW)).round(1)})\n", + " df[\"main_cause\"] = np.where(comp.max(axis=1) < 3, \"없음\", np.array(CAUSES)[comp.argmax(axis=1)])\n", + " return df\n", + "\n", + "\n", + "def feat_frame(df):\n", + " \"\"\"회귀·분석용 설명 변수 표(유형은 더미 변수).\"\"\"\n", + " x = df.copy()\n", + " x[\"etype_B\"], x[\"etype_C\"] = (x[\"etype\"] == \"유형B\").astype(int), (x[\"etype\"] == \"유형C\").astype(int)\n", + " return x[FEAT]\n", + "\n", + "\n", + "def make_trend(months=12, seed=5, shock_unit=None):\n", + " \"\"\"[교육용가상자료] 부대 x 유형 x 월 평균 가동률 추세. shock_unit 부대는 하반기에 외주 검사 병목으로 가동률이 떨어진다.\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " pf = unit_profile()\n", + " rows = []\n", + " for _, u in pf.iterrows():\n", + " base = 62 + 0.22 * u[\"stock\"] + 0.12 * u[\"fill\"] - 3.0 * u[\"queue\"]\n", + " for t in ETYPES:\n", + " off = {\"유형A\": -3.0, \"유형B\": 0.0, \"유형C\": 2.0}[t]\n", + " for m in range(months):\n", + " v = base + off + 2.0 * np.sin(m / 12 * 2 * np.pi) + rng.normal(0, 1.0)\n", + " if shock_unit == u[\"unit\"] and m >= months // 2:\n", + " v -= 1.5 * (m - months // 2 + 1)\n", + " rows.append((u[\"unit\"], t, m + 1, round(float(np.clip(v, 30, 99)), 1)))\n", + " return pd.DataFrame(rows, columns=[\"unit\", \"etype\", \"month\", \"avail_pct\"])\n", + "\n", + "# 1) 스키마 정의: 장비 식별, 정비 요청, 대기 요인별 일수(부품·정비병·외주 정비·외주 검사), 품질 지표(재작업·규격 불일치·하자), 자원 수준, 가동률\n", + "SCHEMA = {\n", + " \"equip_id\": {\"type\": str, \"pattern\": r\"^E-\\d{4}$\", \"desc\": \"장비 번호\"},\n", + " \"unit\": {\"type\": str, \"enum\": UNITS, \"desc\": \"소속 부대(가상)\"},\n", + " \"etype\": {\"type\": str, \"enum\": ETYPES, \"desc\": \"장비 유형(추상 구분)\"},\n", + " \"age_years\": {\"type\": (int, float), \"min\": 0, \"max\": 40, \"desc\": \"장비 연령(년)\"},\n", + " \"op_hours\": {\"type\": (int, float), \"min\": 0, \"max\": 2160, \"desc\": \"최근 90일 가동 시간\"},\n", + " \"maint_orders\": {\"type\": int, \"min\": 0, \"max\": 30, \"desc\": \"정비 요청 건수\"},\n", + " \"parts_wait_days\": {\"type\": (int, float), \"min\": 0, \"max\": 90, \"desc\": \"부품 대기 일수\"},\n", + " \"labor_wait_days\": {\"type\": (int, float), \"min\": 0, \"max\": 90, \"desc\": \"정비병 부족 대기 일수\"},\n", + " \"outsource_days\": {\"type\": (int, float), \"min\": 0, \"max\": 90, \"desc\": \"외주 정비 일수\"},\n", + " \"inspect_wait_days\": {\"type\": (int, float), \"min\": 0, \"max\": 90, \"desc\": \"외주 검사 대기 일수\"},\n", + " \"rework_count\": {\"type\": int, \"min\": 0, \"max\": 30, \"desc\": \"재작업(검사 불합격) 건수\"},\n", + " \"spec_mismatch\": {\"type\": int, \"min\": 0, \"max\": 20, \"desc\": \"규격·제원 불일치 건수\"},\n", + " \"defect_reports\": {\"type\": int, \"min\": 0, \"max\": 50, \"desc\": \"하자·불만 접수 건수\"},\n", + " \"parts_stock_pct\": {\"type\": (int, float), \"min\": 0, \"max\": 100, \"desc\": \"부품 재고 가용률(%)\"},\n", + " \"mechanic_fill_pct\": {\"type\": (int, float), \"min\": 0, \"max\": 100, \"desc\": \"정비병 충원율(%)\"},\n", + " \"avail_pct\": {\"type\": (int, float), \"min\": 0, \"max\": 100, \"desc\": \"가동률(%)\"},\n", + "}\n", + "\n", + "\n", + "def check(name, v, r):\n", + " if v is None or (isinstance(v, float) and np.isnan(v)):\n", + " return [f\"{name}: 필수 값 누락\"]\n", + " if not isinstance(v, r[\"type\"]) or isinstance(v, bool):\n", + " return [f\"{name}: 자료형 오류({type(v).__name__})\"]\n", + " errs = []\n", + " if \"enum\" in r and v not in r[\"enum\"]:\n", + " errs.append(f\"{name}: 허용되지 않은 값 '{v}'\")\n", + " if \"min\" in r and v < r[\"min\"]:\n", + " errs.append(f\"{name}: 최소값 {r['min']} 미만\")\n", + " if \"max\" in r and v > r[\"max\"]:\n", + " errs.append(f\"{name}: 최대값 {r['max']} 초과\")\n", + " if \"pattern\" in r and not re.match(r[\"pattern\"], v):\n", + " errs.append(f\"{name}: 형식 불일치 '{v}'\")\n", + " return errs\n", + "\n", + "\n", + "def validate(rec):\n", + " errs = [f\"{k}: 필드 누락\" for k in SCHEMA if k not in rec]\n", + " for k, r in SCHEMA.items():\n", + " if k in rec:\n", + " errs += check(k, rec[k], r)\n", + " if not errs and rec[\"rework_count\"] > rec[\"maint_orders\"]: # 필드 간 논리 규칙\n", + " errs.append(\"rework_count: 정비 요청 건수보다 재작업 건수가 많음\")\n", + " if not errs and rec[\"maint_orders\"] == 0 and (rec[\"parts_wait_days\"] + rec[\"labor_wait_days\"] + rec[\"outsource_days\"] + rec[\"inspect_wait_days\"]) > 0:\n", + " errs.append(\"maint_orders: 정비 요청이 없는데 대기 일수가 있음\")\n", + " if not errs and rec[\"parts_wait_days\"] + rec[\"labor_wait_days\"] + rec[\"outsource_days\"] + rec[\"inspect_wait_days\"] > 90 + 5:\n", + " errs.append(\"wait_days: 대기 일수 합계가 관측 기간(90일)을 초과\")\n", + " return errs\n", + "\n", + "\n", + "def to_record(row):\n", + " return {k: (row[k].item() if hasattr(row[k], \"item\") else row[k]) for k in SCHEMA}\n", + "\n", + "\n", + "fleet = make_fleet(100, seed=7)\n", + "records = [to_record(r) for _, r in fleet.iterrows()]\n", + "ok = sum(not validate(r) for r in records)\n", + "print(f\"[스키마 검증] 장비 {len(records)}대 중 통과 {ok}대, 오류 {len(records) - ok}대 / 필드 {len(SCHEMA)}개\")\n", + "print(f\"평균 가동률 {fleet['avail_pct'].mean():.1f}%, 가동률 70% 미만 {int((fleet['avail_pct'] < 70).sum())}대, 외주 정비 경험 장비 {int((fleet['outsource_days'] > 0).sum())}대, 규격·제원 불일치 1건 이상 {int((fleet['spec_mismatch'] > 0).sum())}대\")\n", + "\n", + "# 2) 오류 주입 시험: 형식·허용값·범위·자료형·누락·논리 규칙 위반\n", + "bad = dict(records[2], equip_id=\"E12\", unit=\"미상부대\", age_years=-3, op_hours=\"많음\")\n", + "bad2 = dict(records[5], maint_orders=2, rework_count=5)\n", + "bad3 = dict(records[0], maint_orders=0, parts_wait_days=5)\n", + "bad4 = {k: v for k, v in records[7].items() if k != \"avail_pct\"}\n", + "bad5 = dict(records[9], avail_pct=None)\n", + "print(\"\\n[오류 주입 결과]\")\n", + "for e in validate(bad) + validate(bad2) + validate(bad3) + validate(bad4) + validate(bad5):\n", + " print(\" -\", e)\n", + "\n", + "# 3) AS-IS 업무 단계 분해: [교육용가상자료] 외주 정비 1건의 단계별 소요일(작업 + 대기)\n", + "rng = np.random.default_rng(3)\n", + "STEPS = [(\"정비 요청 접수·고장 접수\", 0.5, 1), (\"고장 진단·정비 계획 수립\", 1, 1), (\"부품 청구·조달\", 1, 6), (\"정비병 배정\", 0.5, 3),\n", + " (\"부대 정비 수행\", 2, 1), (\"외주 정비 의뢰·입고\", 1, 4), (\"외주 정비 수행\", 6, 2), (\"외주 검사 대기\", 0, 5),\n", + " (\"외주 정비 품질검사(규격·제원 대조)\", 1, 1), (\"불합격 시 재작업·하자 조치\", 3, 3), (\"출고 승인·부대 인수\", 0.5, 2), (\"가동 복귀 확인·이력 기록\", 0.5, 1)]\n", + "TOBE = {2: (\"부품 소요 예측·사전 청구(미션 4)\", 0.4), 3: (\"정비병 부하 기반 배정 추천(미션 5)\", 0.5), 7: (\"검사 일정 자동 조율(미션 5)\", 0.4),\n", + " 8: (\"규격·제원 자동 대조 사전 점검(미션 3)\", 0.5), 9: (\"재작업 원인 자동 분류(미션 2·4)\", 0.6), 11: (\"이력 자동 기록(미션 2)\", 0.3)}\n", + "rows = []\n", + "for i, (name, work, wait) in enumerate(STEPS):\n", + " w = rng.gamma(4, max(work, 0.01) / 4, 30); q = rng.gamma(2, wait / 2, 30)\n", + " f = TOBE.get(i, (\"유지(사람 판단)\", 1.0))\n", + " rows.append({\"단계\": f\"{i + 1:02d}. {name}\", \"작업(일)\": w.mean() if work else 0.0, \"대기(일)\": q.mean(), \"AS-IS(일)\": (w.mean() if work else 0.0) + q.mean(),\n", + " \"TO-BE 방식\": f[0], \"TO-BE(일)\": ((w.mean() if work else 0.0) + q.mean()) * f[1]})\n", + "proc = pd.DataFrame(rows)\n", + "proc[\"병목\"] = np.where(proc[\"대기(일)\"] >= proc[\"대기(일)\"].sort_values().iloc[-4], \"병목\", \"\") # 대기일 상위 4개 단계를 병목으로 본다\n", + "print(\"\\n[AS-IS / TO-BE 업무 단계 비교]\")\n", + "print(proc.round(1).to_string(index=False))\n", + "n_bn = int((proc[\"병목\"] == \"병목\").sum())\n", + "asis, tobe = proc[\"AS-IS(일)\"].sum(), proc[\"TO-BE(일)\"].sum()\n", + "print(f\"\\n업무 단계 {len(proc)}개(목표 10개 이상: {'달성' if len(proc) >= 10 else '미달'}), 병목 {n_bn}개소(목표 3개소 이상: {'달성' if n_bn >= 3 else '미달'})\")\n", + "print(f\"외주 정비 1건 소요: AS-IS {asis:.1f}일 -> TO-BE {tobe:.1f}일 ({1 - tobe / asis:.0%} 단축 예상). 품질검사 판정, 정비 우선순위 결정, 출고 승인은 사람이 수행한다.\")\n", + "\n", + "# 4) 입출력 데이터 명세 요약\n", + "print(\"\\n[입출력 명세] 입력: 장비 가동·정비 대기 레코드(위 16개 필드) / 출력: 저가동 요인 순위, 저가동 장비 목록, 정비 자원 재배치 안, 검토·결재 이력\")\n", + "print(\"[권한별 요구] 정비 담당: 입력·상신 / 정비관·품질검사관: 검토 / 군수참모: 정책 결정(미션 7에서 구현)\")\n", + "\n", + "plt.figure(figsize=(9, 5))\n", + "y = np.arange(len(proc))\n", + "plt.barh(y - 0.2, proc[\"AS-IS(일)\"], 0.4, label=\"AS-IS\", color=\"tab:gray\")\n", + "plt.barh(y + 0.2, proc[\"TO-BE(일)\"], 0.4, label=\"TO-BE\", color=\"tab:blue\")\n", + "plt.yticks(y, [f\"step {i + 1}{' *' if b else ''}\" for i, b in enumerate(proc[\"병목\"])])\n", + "plt.gca().invert_yaxis(); plt.xlabel(\"days per case\"); plt.legend()\n", + "plt.title(\"Outsourced maintenance flow: AS-IS vs TO-BE (* = bottleneck, synthetic)\")\n", + "plt.tight_layout(); plt.show()\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 01 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + "[스키마 검증] 장비 100대 중 통과 100대, 오류 0대 / 필드 16개\n", + "평균 가동률 79.3%, 가동률 70% 미만 25대, 외주 정비 경험 장비 50대, 규격·제원 불일치 1건 이상 49대\n", + "\n", + "[오류 주입 결과]\n", + " - equip_id: 형식 불일치 'E12'\n", + " - unit: 허용되지 않은 값 '미상부대'\n", + " - age_years: 최소값 0 미만\n", + " - op_hours: 자료형 오류(str)\n", + " - rework_count: 정비 요청 건수보다 재작업 건수가 많음\n", + " - maint_orders: 정비 요청이 없는데 대기 일수가 있음\n", + " - avail_pct: 필드 누락\n", + " - avail_pct: 필수 값 누락\n", + "\n", + "[AS-IS / TO-BE 업무 단계 비교]\n", + " 단계 작업(일) 대기(일) AS-IS(일) TO-BE 방식 TO-BE(일) 병목\n", + " 01. 정비 요청 접수·고장 접수 0.5 1.0 1.5 유지(사람 판단) 1.5 \n", + " 02. 고장 진단·정비 계획 수립 1.3 0.9 2.2 유지(사람 판단) 2.2 \n", + " 03. 부품 청구·조달 1.0 6.6 7.7 부품 소요 예측·사전 청구(미션 4) 3.1 병목\n", + " 04. 정비병 배정 0.6 3.0 3.6 정비병 부하 기반 배정 추천(미션 5) 1.8 \n", + " 05. 부대 정비 수행 2.4 1.1 3.5 유지(사람 판단) 3.5 \n", + " 06. 외주 정비 의뢰·입고 1.0 4.1 5.1 유지(사람 판단) 5.1 병목\n", + " 07. 외주 정비 수행 5.3 1.9 7.2 유지(사람 판단) 7.2 \n", + " 08. 외주 검사 대기 0.0 4.9 4.9 검사 일정 자동 조율(미션 5) 2.0 병목\n", + "09. 외주 정비 품질검사(규격·제원 대조) 1.1 1.0 2.1 규격·제원 자동 대조 사전 점검(미션 3) 1.0 \n", + " 10. 불합격 시 재작업·하자 조치 3.1 3.6 6.7 재작업 원인 자동 분류(미션 2·4) 4.0 병목\n", + " 11. 출고 승인·부대 인수 0.5 2.3 2.7 유지(사람 판단) 2.7 \n", + " 12. 가동 복귀 확인·이력 기록 0.5 1.0 1.6 이력 자동 기록(미션 2) 0.5 \n", + "\n", + "업무 단계 12개(목표 10개 이상: 달성), 병목 4개소(목표 3개소 이상: 달성)\n", + "외주 정비 1건 소요: AS-IS 48.9일 -> TO-BE 34.7일 (29% 단축 예상). 품질검사 판정, 정비 우선순위 결정, 출고 승인은 사람이 수행한다.\n", + "\n", + "[입출력 명세] 입력: 장비 가동·정비 대기 레코드(위 16개 필드) / 출력: 저가동 요인 순위, 저가동 장비 목록, 정비 자원 재배치 안, 검토·결재 이력\n", + "[권한별 요구] 정비 담당: 입력·상신 / 정비관·품질검사관: 검토 / 군수참모: 정책 결정(미션 7에서 구현)\n", + "```\n", + "
\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 02. 정비 대기 시간(부품 대기, 정비병 부족, 외주 정비) 요인 분류 전처리 모듈 구현\n", + "**소요시간**: 약 2.0시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "정비 대기 시간(부품 대기, 인력 부족) 요인 분류 전처리 모듈을 구현한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **서식이 다른 정비 대기 기록(CSV, 엑셀 내보내기, JSON, 정비일지 문장)을 하나의 표준 스키마로 바꾸고 대기 사유를 부품 대기·정비병 부족·외주 정비·검사·재작업으로 분류하는 전처리 모듈을 구현한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **열 이름 별칭 사전으로 CSV, 탭 구분(엑셀 내보내기), JSON 세 형식을 표준 스키마로 바꾸고, 대기 기간(일·주·개월·시간)과 날짜·부대명 표기를 통일 수행하고 검증하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `src/connectors/qlt04_data_loader.py 및 스키마 정의서` 형태의 결과물을 완성한다.\n", + "\n", + "### 🔍 평가 및 검증 기준 (PASS 조건)\n", + "#### 미션 판정\n", + "\n", + "**평가 구분:** 필수\n", + "\n", + "**핵심 평가영역:** 국방 서식/양식 변경에 대한 유연한 확장성\n", + "\n", + "#### 미션 PASS 조건\n", + "\n", + "- 정량 기준: 지원 파일 포맷(CSV, Excel, JSON 등) 파싱 성공률 100%\n", + "\n", + "\n", + "- 진행 기준: 데이터 로더 및 스키마 유효성 검사 모듈 완성\n", + "\n", + "\n", + "- 검증 방법: 다양한 형태의 샘플 군수 문서 대상 파싱 테스트\n", + "\n", + "\n", + "#### 채점자 판정 메모\n", + "\n", + "> 단순 코드 실행 여부뿐만 아니라 출력된 수치와 근거 자료가 군 실무 요구사항에 부합하는지 면밀히 확인한다.\n", + "\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "\"\"\"서식이 서로 다른 가상 정비 대기 기록(CSV, 엑셀 내보내기 TSV, JSON, 정비일지 문장)을 열 이름 별칭 사전과 정규식으로 표준 스키마로 바꾸고, 대기 일수·부대명·날짜 표기를 통일하며 대기 사유를 부품 대기·정비병 부족·외주 정비·검사·재작업으로 분류하는 전처리 모듈을 구현한다.\"\"\"\n", + "import csv\n", + "import io\n", + "import json\n", + "import re\n", + "from datetime import date\n", + "import pandas as pd\n", + "\n", + "# ---------------------------------------------------------------------------\n", + "# [교육용가상자료] 서로 다른 형식의 원시 기록 (실제 부대·장비 아님). 표기가 일부러 제각각이다.\n", + "# ---------------------------------------------------------------------------\n", + "RAW_CSV = \"\"\"장비,부대,대기사유,대기기간,접수일\n", + "E-0001,가상A부대,부품 미입고,5일,2026.03.02\n", + "E-0002,가상 B 부대,정비병 부족(휴가·교육),2주,2026-3-15\n", + "E-0003,C부대,외주업체 입고 대기,48시간,20260110\n", + "E-0004,가상D부대,외주 검사 대기,3일,26/02/27\n", + "E-0005,가상E부대,부품 청구 지연,1개월,2025.12.30\n", + "E-0006,가상F부대,재작업(검사 불합격),4일,2026-01-05\n", + "\"\"\"\n", + "RAW_TSV = \"장비번호\\t소속\\t사유\\t지연일수\\t접수\\n\" \\\n", + " \"E-0007\\t가상A부대\\t정비인력 부족\\t6\\t2026.02.11\\n\" \\\n", + " \"E-0008\\t가상B부대\\t부품 입고 지연\\t10\\t2026.01.20\\n\" \\\n", + " \"E-0009\\t가상C부대\\t외주 정비 수행 중\\t1주\\t2026/03/05\\n\" \\\n", + " \"E-0010\\tD부대\\t규격 불일치로 재검사\\t3\\t2025.11.03\\n\" \\\n", + " \"E-0001\\t가상A부대\\t부품 미입고\\t5일\\t2026.03.02\\n\"\n", + "RAW_JSON = json.dumps({\"result\": \"OK\", \"items\": [\n", + " {\"equip\": \"E-0011\", \"unit\": {\"name\": \"가상E부대\"}, \"reason\": \"예비품 청구 후 입고 대기\", \"wait\": {\"value\": 2, \"unit\": \"week\"}, \"received\": \"2026-02-01\"},\n", + " {\"equip\": \"E-0012\", \"unit\": {\"name\": \"가상F부대\"}, \"reason\": \"정비병 교육 파견\", \"wait\": {\"value\": 4, \"unit\": \"day\"}, \"received\": \"2026-01-09\"},\n", + " {\"equip\": \"E-0013\", \"unit\": {\"name\": \"가상A부대\"}, \"reason\": \"외주 검사 일정 대기\", \"wait\": {\"value\": 72, \"unit\": \"hour\"}, \"received\": \"2026-03-20\"}]},\n", + " ensure_ascii=False)\n", + "LOGS = [\n", + " \"[정비일지] 2026-03-04 E-0021(가상A부대) 부품 입고 지연으로 6일 대기.\",\n", + " \"가상B부대 E-0022 : 정비병 인력 부족, 3일간 착수 못함 (2026.03.05)\",\n", + " \"E-0023 / 외주 정비 입고 대기 2주 / 가상C부대 / 26.03.06\",\n", + " \"3월 7일자 가상D부대 E-0024 규격 불일치로 재검사 5일\",\n", + " \"가상E부대 E-0025 원인 불명 대기 4일(2026-03-08)\",\n", + "]\n", + "# 정답(분류 정확도 확인용): 기록 순서대로\n", + "TRUTH = {\"E-0001\": \"부품 대기\", \"E-0002\": \"정비병 부족\", \"E-0003\": \"외주 정비\", \"E-0004\": \"검사·재작업\", \"E-0005\": \"부품 대기\", \"E-0006\": \"검사·재작업\",\n", + " \"E-0007\": \"정비병 부족\", \"E-0008\": \"부품 대기\", \"E-0009\": \"외주 정비\", \"E-0010\": \"검사·재작업\", \"E-0011\": \"부품 대기\", \"E-0012\": \"정비병 부족\",\n", + " \"E-0013\": \"검사·재작업\", \"E-0021\": \"부품 대기\", \"E-0022\": \"정비병 부족\", \"E-0023\": \"외주 정비\", \"E-0024\": \"검사·재작업\", \"E-0025\": \"기타\"}\n", + "\n", + "# ---- 정규화 규칙(서식이 바뀌면 이 사전만 고친다) ----\n", + "COL_ALIAS = {\"equip\": [\"장비\", \"장비번호\", \"equip\"], \"unit\": [\"부대\", \"소속\", \"unit\"], \"reason\": [\"대기사유\", \"사유\", \"reason\"],\n", + " \"wait\": [\"대기기간\", \"지연일수\", \"wait\"], \"received\": [\"접수일\", \"접수\", \"received\"]}\n", + "UNIT_DAYS = {\"일\": 1, \"일간\": 1, \"day\": 1, \"주\": 7, \"week\": 7, \"개월\": 30, \"month\": 30, \"시간\": 1 / 24, \"hour\": 1 / 24}\n", + "# 분류 규칙: 위에서부터 먼저 걸리는 사유로 분류(검사·재작업 -> 외주 정비 -> 정비병 -> 부품)\n", + "CAUSE_KW = [(\"검사·재작업\", [\"검사\", \"재작업\", \"불합격\", \"규격 불일치\"]), (\"외주 정비\", [\"외주\", \"업체 입고\"]),\n", + " (\"정비병 부족\", [\"정비병\", \"정비인력\", \"인력 부족\", \"휴가\", \"교육\"]), (\"부품 대기\", [\"부품\", \"미입고\", \"청구\", \"입고 지연\"])]\n", + "DEFAULT_YEAR = 2026\n", + "\n", + "\n", + "def norm_unit(s):\n", + " m = re.search(r\"([A-F])\\s*부대\", str(s))\n", + " if not m:\n", + " raise ValueError(f\"부대명 해석 불가: {s}\")\n", + " return f\"가상{m.group(1)}부대\"\n", + "\n", + "\n", + "def norm_wait(v):\n", + " \"\"\"대기 기간을 일 단위 숫자로 통일(일·주·개월·시간).\"\"\"\n", + " if isinstance(v, dict):\n", + " return round(v[\"value\"] * UNIT_DAYS[v[\"unit\"]], 1)\n", + " m = re.match(r\"^\\s*(\\d+)\\s*(일간|일|주|개월|시간)?\\s*$\", str(v))\n", + " if not m:\n", + " raise ValueError(f\"대기 기간 해석 불가: {v}\")\n", + " return round(int(m.group(1)) * UNIT_DAYS.get(m.group(2) or \"일\", 1), 1)\n", + "\n", + "\n", + "def norm_date(s):\n", + " s = str(s).strip().rstrip(\".\")\n", + " m = re.match(r\"^(\\d{2,4})[.\\-/년\\s]*(\\d{1,2})[.\\-/월\\s]*(\\d{1,2})일?$\", s) or re.match(r\"^(\\d{4})(\\d{2})(\\d{2})$\", s)\n", + " if not m:\n", + " m2 = re.match(r\"^(\\d{1,2})월\\s*(\\d{1,2})일\", s)\n", + " if m2:\n", + " return date(DEFAULT_YEAR, int(m2.group(1)), int(m2.group(2))).isoformat()\n", + " raise ValueError(f\"날짜 해석 불가: {s}\")\n", + " y, mo, d = (int(x) for x in m.groups())\n", + " return date(y + 2000 if y < 100 else y, mo, d).isoformat()\n", + "\n", + "\n", + "def classify(text):\n", + " \"\"\"대기 사유 문장을 4개 요인 중 하나로 분류한다. 어느 키워드에도 걸리지 않으면 '기타'(사람이 확인).\"\"\"\n", + " for cause, kws in CAUSE_KW:\n", + " if any(k in text for k in kws):\n", + " return cause\n", + " return \"기타\"\n", + "\n", + "\n", + "def pick(row, key):\n", + " for k in COL_ALIAS[key]:\n", + " if k in row:\n", + " return row[k]\n", + " raise KeyError(f\"필수 열 없음: {key}\")\n", + "\n", + "\n", + "def to_std(row, src):\n", + " reason = str(pick(row, \"reason\"))\n", + " return {\"equip_id\": str(pick(row, \"equip\")).strip(), \"unit\": norm_unit(pick(row, \"unit\")), \"wait_days\": norm_wait(pick(row, \"wait\")),\n", + " \"date\": norm_date(pick(row, \"received\")), \"reason\": reason, \"cause\": classify(reason), \"source\": src}\n", + "\n", + "\n", + "def load(text, fmt):\n", + " \"\"\"형식별 로더: csv / tsv(엑셀 내보내기) / json. 행마다 성공·실패를 따로 센다.\"\"\"\n", + " if fmt == \"json\":\n", + " rows = [{\"equip\": r[\"equip\"], \"unit\": r[\"unit\"][\"name\"], \"reason\": r[\"reason\"], \"wait\": r[\"wait\"], \"received\": r[\"received\"]} for r in json.loads(text)[\"items\"]]\n", + " else:\n", + " rows = list(csv.DictReader(io.StringIO(text), delimiter=\"\\t\" if fmt == \"tsv\" else \",\"))\n", + " ok, fail = [], []\n", + " for r in rows:\n", + " try:\n", + " ok.append(to_std(r, fmt))\n", + " except (KeyError, ValueError) as e:\n", + " fail.append((r, str(e)))\n", + " return ok, fail\n", + "\n", + "\n", + "def parse_log(line):\n", + " \"\"\"정비일지 문장 1줄에서 장비·부대·대기 일수·날짜·사유를 추출한다.\"\"\"\n", + " eq = re.search(r\"E-\\d{4}\", line)\n", + " unit = re.search(r\"(?:가상\\s*)?[A-F]\\s*부대\", line)\n", + " wait = re.search(r\"(\\d+)\\s*(일간|일|주|개월|시간)\", line)\n", + " dt = re.search(r\"(\\d{2,4}[.\\-/]\\d{1,2}[.\\-/]\\d{1,2})|(\\d{1,2}월\\s*\\d{1,2}일)\", line)\n", + " if not (eq and unit and wait and dt):\n", + " raise ValueError(\"필수 항목(장비·부대·대기·날짜) 추출 실패\")\n", + " reason = re.sub(r\"E-\\d{4}|\\[정비일지\\]|\\(.*?\\)|\\d{2,4}[.\\-/]\\d{1,2}[.\\-/]\\d{1,2}|\\d{1,2}월\\s*\\d{1,2}일자?|가상\\s*[A-F]\\s*부대|[/:]\", \" \", line)\n", + " reason = re.sub(r\"\\s+\", \" \", reason).strip()\n", + " return {\"equip_id\": eq.group(0), \"unit\": norm_unit(unit.group(0)), \"wait_days\": norm_wait(wait.group(0)), \"date\": norm_date(dt.group(0)),\n", + " \"reason\": reason, \"cause\": classify(reason), \"source\": \"log\"}\n", + "\n", + "\n", + "# 1) 형식별 파싱\n", + "results, total, success = [], 0, 0\n", + "for fmt, text in [(\"csv\", RAW_CSV), (\"tsv\", RAW_TSV), (\"json\", RAW_JSON)]:\n", + " ok, fail = load(text, fmt)\n", + " results += ok; total += len(ok) + len(fail); success += len(ok)\n", + " print(f\"[{fmt.upper():4s}] {len(ok) + len(fail)}행 중 {len(ok)}행 성공\" + (f\", 실패 {[e for _, e in fail]}\" if fail else \"\"))\n", + "logs, l_fail = [], 0\n", + "for t in LOGS:\n", + " try:\n", + " logs.append(parse_log(t))\n", + " except ValueError as e:\n", + " l_fail += 1; print(\"일지 파싱 실패:\", e)\n", + "results += logs; total += len(LOGS); success += len(logs)\n", + "print(f\"[정비일지 문장] {len(LOGS)}건 중 {len(logs)}건 성공\")\n", + "rate = success / total\n", + "print(f\"\\n전체 파싱 성공률 {rate:.0%} ({success}/{total}) - 목표 100%: {'달성' if rate == 1 else '미달'}\")\n", + "\n", + "# 2) 정제 결과(표기 통일, 중복 기록 정리)\n", + "std = pd.DataFrame(results)\n", + "before = len(std)\n", + "std = std.drop_duplicates([\"equip_id\", \"date\", \"cause\"]).sort_values(\"equip_id\")\n", + "print(f\"\\n[정제된 정비 대기 기록] {before}행 -> 중복 정리 후 {len(std)}행, 부대 {std['unit'].nunique()}곳\")\n", + "print(std[[\"equip_id\", \"unit\", \"date\", \"wait_days\", \"cause\", \"source\"]].to_string(index=False))\n", + "\n", + "# 3) 요인 분류 정확도와 사람 확인 대상\n", + "chk = std[std[\"equip_id\"].isin(TRUTH)]\n", + "acc = (chk[\"cause\"] == chk[\"equip_id\"].map(TRUTH)).mean()\n", + "print(f\"\\n[요인 분류] 정답이 있는 기록 {len(chk)}건 중 일치 {int((chk['cause'] == chk['equip_id'].map(TRUTH)).sum())}건 ({acc:.0%})\")\n", + "print(std.groupby(\"cause\").agg(건수=(\"equip_id\", \"size\"), 평균대기일=(\"wait_days\", \"mean\")).round(1).to_string())\n", + "print(\"사람 확인 대상('기타'로 분류):\", \", \".join(f\"{r.equip_id}('{r.reason[:14]}')\" for r in std[std[\"cause\"] == \"기타\"].itertuples()) or \"없음\")\n", + "\n", + "# 4) 서식 변경 대응 시험 ①: 열 이름이 바뀐 새 양식 -> 별칭 사전 추가\n", + "new_form = \"장비 코드,배속부대,지연 원인,지연 기간,접수 일자\\nE-0031,가상B부대,부품 미입고,7일,2026.04.01\\n\"\n", + "ok, fail = load(new_form, \"csv\")\n", + "print(f\"\\n새 양식(열 이름 변경) 1차 시도: 성공 {len(ok)} / 실패 {len(fail)} -> {fail[0][1] if fail else '-'}\")\n", + "for k, alias in {\"equip\": \"장비 코드\", \"unit\": \"배속부대\", \"reason\": \"지연 원인\", \"wait\": \"지연 기간\", \"received\": \"접수 일자\"}.items():\n", + " COL_ALIAS[k].append(alias)\n", + "ok, fail = load(new_form, \"csv\")\n", + "print(f\"별칭 사전 갱신 후: 성공 {len(ok)} / 실패 {len(fail)} (코드 수정 없이 설정만 변경)\")\n", + "\n", + "# 5) 서식 변경 대응 시험 ②: 새 표현의 사유 -> 분류 키워드 한 줄 추가\n", + "line = \"가상C부대 E-0032 : 예비품 소진으로 8일간 정비 보류 (2026.04.02)\"\n", + "r1 = parse_log(line)\n", + "CAUSE_KW[3][1].append(\"예비품\")\n", + "r2 = parse_log(line)\n", + "print(f\"새 사유 표현('예비품 소진') 1차 분류: {r1['cause']} -> 키워드 1개 추가 후: {r2['cause']} (코드 수정 없이 사전만 보완)\")\n", + "print(\"※ 요인 분류는 키워드 기반 1차 분류이며, '기타'와 복합 사유는 정비 담당자가 확인해 확정한다.\")\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 02 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + "[CSV ] 6행 중 6행 성공\n", + "[TSV ] 5행 중 5행 성공\n", + "[JSON] 3행 중 3행 성공\n", + "[정비일지 문장] 5건 중 5건 성공\n", + "\n", + "전체 파싱 성공률 100% (19/19) - 목표 100%: 달성\n", + "\n", + "[정제된 정비 대기 기록] 19행 -> 중복 정리 후 18행, 부대 6곳\n", + "equip_id unit date wait_days cause source\n", + " E-0001 가상A부대 2026-03-02 5.0 부품 대기 csv\n", + " E-0002 가상B부대 2026-03-15 14.0 정비병 부족 csv\n", + " E-0003 가상C부대 2026-01-10 2.0 외주 정비 csv\n", + " E-0004 가상D부대 2026-02-27 3.0 검사·재작업 csv\n", + " E-0005 가상E부대 2025-12-30 30.0 부품 대기 csv\n", + " E-0006 가상F부대 2026-01-05 4.0 검사·재작업 csv\n", + " E-0007 가상A부대 2026-02-11 6.0 정비병 부족 tsv\n", + " E-0008 가상B부대 2026-01-20 10.0 부품 대기 tsv\n", + " E-0009 가상C부대 2026-03-05 7.0 외주 정비 tsv\n", + " E-0010 가상D부대 2025-11-03 3.0 검사·재작업 tsv\n", + " E-0011 가상E부대 2026-02-01 14.0 부품 대기 json\n", + " E-0012 가상F부대 2026-01-09 4.0 정비병 부족 json\n", + " E-0013 가상A부대 2026-03-20 3.0 검사·재작업 json\n", + " E-0021 가상A부대 2026-03-04 6.0 부품 대기 log\n", + " E-0022 가상B부대 2026-03-05 3.0 정비병 부족 log\n", + " E-0023 가상C부대 2026-03-06 14.0 외주 정비 log\n", + " E-0024 가상D부대 2026-03-07 7.0 검사·재작업 log\n", + " E-0025 가상E부대 2026-03-08 4.0 기타 log\n", + "\n", + "[요인 분류] 정답이 있는 기록 18건 중 일치 18건 (100%)\n", + " 건수 평균대기일\n", + "cause \n", + "검사·재작업 5 4.0\n", + "기타 1 4.0\n", + "부품 대기 5 13.0\n", + "외주 정비 3 7.7\n", + "정비병 부족 4 6.8\n", + "사람 확인 대상('기타'로 분류): E-0025('원인 불명 대기 4일')\n", + "\n", + "새 양식(열 이름 변경) 1차 시도: 성공 0 / 실패 1 -> '필수 열 없음: reason'\n", + "별칭 사전 갱신 후: 성공 1 / 실패 0 (코드 수정 없이 설정만 변경)\n", + "... (이하 생략)\n", + "```\n", + "
\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 03. 가동률 저하 상위 5대 요인 도출 1차 통계 분석 Baseline 알고리즘 구현\n", + "**소요시간**: 약 2.5시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "가동률 저하 상위 요인 도출 1차 통계 분석 Baseline 알고리즘을 구현한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **가동률 저하의 상위 5대 요인을 상관·효과크기·손실일로 도출하는 1차 통계 Baseline을 만들고, 정비·검사 병목 판별 룰 15종 이상을 YAML 설정으로 구현해 저가동 후보를 가려낸다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **가상 장비 300대에서 가동률 70% 미만 장비와 나머지의 요인별 평균 차이(효과크기)와 가동률과의 상관을 구해 상위 5개 요인을 정 수행하고 검증하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `src/engine/qlt04_rule_engine.py` 형태의 결과물을 완성한다.\n", + "\n", + "### 🔍 평가 및 검증 기준 (PASS 조건)\n", + "#### 미션 판정\n", + "\n", + "**평가 구분:** 필수\n", + "\n", + "**핵심 평가영역:** 군 규정 및 지침 조항과의 정확한 일치성\n", + "\n", + "#### 미션 PASS 조건\n", + "\n", + "- 정량 기준: 업무 검증 룰 15종 이상 코딩, 룰 처리 지연시간 50ms 이하\n", + "\n", + "\n", + "- 진행 기준: 룰 엔진 단위 테스트 100% 통과\n", + "\n", + "\n", + "- 검증 방법: 경계 조건(Boundary condition)에 대한 룰 판정 검증\n", + "\n", + "\n", + "#### 채점자 판정 메모\n", + "\n", + "> 단순 코드 실행 여부뿐만 아니라 출력된 수치와 근거 자료가 군 실무 요구사항에 부합하는지 면밀히 확인한다.\n", + "\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "\"\"\"가상 장비 300대의 가동률 저하 요인을 상관·효과크기·손실일 기준으로 비교하는 1차 통계 Baseline으로 상위 5대 요인을 도출하고, YAML 형식 룰 16종으로 저가동 후보를 판별하며 경계 조건 단위 시험과 룰 처리 지연시간(99백분위)을 확인한다.\"\"\"\n", + "import time\n", + "import numpy as np\n", + "import pandas as pd\n", + "import matplotlib.pyplot as plt\n", + "\n", + "# ---------------------------------------------------------------------------\n", + "# [교육용가상자료] 가상 장비 가동률·정비 대기 자료 생성기 (실제 부대·장비·제원·가동률 정보 없음)\n", + "# 장비 유형 A(궤도형)·B(화포형)·C(차륜형)은 추상 구분이며 실제 장비와 무관하다.\n", + "# 최근 90일 동안 장비 1대의 정비 대기 요인(부품·정비병·외주 정비·외주 검사·재작업)과 가동률을 만든다.\n", + "# ---------------------------------------------------------------------------\n", + "UNITS = [\"가상A부대\", \"가상B부대\", \"가상C부대\", \"가상D부대\", \"가상E부대\", \"가상F부대\"]\n", + "ETYPES = [\"유형A\", \"유형B\", \"유형C\"]\n", + "WINDOW = 90 # 관측 기간(일)\n", + "CAUSES = [\"부품 대기\", \"정비병 부족\", \"외주 정비\", \"검사·재작업\"]\n", + "CAUSE_COL = {\"부품 대기\": \"parts_wait_days\", \"정비병 부족\": \"labor_wait_days\", \"외주 정비\": \"outsource_days\", \"검사·재작업\": \"inspect_wait_days\"}\n", + "NUM_FIELDS = [\"age_years\", \"op_hours\", \"maint_orders\", \"parts_wait_days\", \"labor_wait_days\", \"outsource_days\", \"inspect_wait_days\", \"rework_count\",\n", + " \"spec_mismatch\", \"defect_reports\", \"parts_stock_pct\", \"mechanic_fill_pct\", \"avail_pct\"]\n", + "FEAT = [\"age_years\", \"maint_orders\", \"parts_stock_pct\", \"mechanic_fill_pct\", \"outsource_days\", \"inspect_wait_days\", \"rework_count\", \"spec_mismatch\", \"defect_reports\", \"etype_B\", \"etype_C\"]\n", + "FEAT_KO = {\"age_years\": \"장비 연령\", \"maint_orders\": \"정비 요청 건수\", \"parts_stock_pct\": \"부품 재고 가용률\", \"mechanic_fill_pct\": \"정비병 충원율\", \"outsource_days\": \"외주 정비 일수\",\n", + " \"inspect_wait_days\": \"외주 검사 대기 일수\", \"rework_count\": \"재작업 건수\", \"spec_mismatch\": \"규격·제원 불일치\", \"defect_reports\": \"하자·불만 접수\",\n", + " \"etype_B\": \"유형B 여부\", \"etype_C\": \"유형C 여부\"}\n", + "\n", + "\n", + "def unit_profile(seed=3):\n", + " \"\"\"부대별 고정 특성: 부품 재고 수준, 정비병 충원율, 외주 정비소 거리(일수 가중), 외주 검사 혼잡도.\"\"\"\n", + " r = np.random.default_rng(seed)\n", + " return pd.DataFrame({\"unit\": UNITS, \"stock\": r.uniform(45, 95, len(UNITS)), \"fill\": r.uniform(55, 98, len(UNITS)),\n", + " \"dist\": r.uniform(0.8, 1.6, len(UNITS)), \"queue\": r.uniform(0.7, 2.2, len(UNITS))})\n", + "\n", + "\n", + "def make_fleet(n=300, seed=7, shock=0.0):\n", + " \"\"\"장비 n대의 90일 관측 자료(필드 16개)와 정답 요인을 만든다. shock은 외주 검사 병목 악화 정도(AAR용 환경 변화).\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " pf = unit_profile()\n", + " ui = rng.integers(0, len(UNITS), n)\n", + " et = rng.choice(ETYPES, n, p=[.35, .3, .35])\n", + " age = np.clip(rng.gamma(4, 2.2, n), 0.5, 30).round(1)\n", + " eff = np.array([{\"유형A\": 1.15, \"유형B\": 1.0, \"유형C\": 0.9}[t] for t in et])\n", + " orders = rng.poisson(np.clip(1.2 + 0.12 * age, 0.5, 6) * eff)\n", + " stock = np.clip(pf[\"stock\"].to_numpy()[ui] + rng.normal(0, 8, n), 20, 100).round(0)\n", + " fill = np.clip(pf[\"fill\"].to_numpy()[ui] + rng.normal(0, 6, n), 30, 100).round(0)\n", + " parts_w = (orders * rng.gamma(2, 0.55 * (1 + ((100 - stock) / 28) ** 2) / 2)).round(0)\n", + " labor_w = (orders * rng.gamma(2, 0.7 * (1 + ((100 - fill) / 24) ** 2) / 2)).round(0)\n", + " n_out = rng.binomial(orders, 0.3)\n", + " out_d = (n_out * rng.gamma(3, 4.5 * pf[\"dist\"].to_numpy()[ui] / 3)).round(0)\n", + " insp_w = (n_out * rng.gamma(2, (2.6 + shock) * pf[\"queue\"].to_numpy()[ui] / 2)).round(0)\n", + " spec = rng.poisson(0.2 + 0.04 * age)\n", + " rework = rng.binomial(n_out, np.clip(0.08 + 0.1 * spec, 0, .6))\n", + " defect = rng.poisson(0.3 + 0.7 * rework)\n", + " base_rep = orders * 1.8\n", + " down = base_rep + parts_w + labor_w + out_d + insp_w + rework * 4 + rng.normal(0, 1.2, n)\n", + " comp = np.column_stack([parts_w, labor_w, out_d, insp_w + rework * 4])\n", + " df = pd.DataFrame({\"equip_id\": [f\"E-{i + 1:04d}\" for i in range(n)], \"unit\": np.array(UNITS)[ui], \"etype\": et, \"age_years\": age,\n", + " \"op_hours\": np.clip(rng.normal(320 - 4 * age, 70, n), 40, 600).round(0), \"maint_orders\": orders,\n", + " \"parts_wait_days\": parts_w, \"labor_wait_days\": labor_w, \"outsource_days\": out_d, \"inspect_wait_days\": insp_w,\n", + " \"rework_count\": rework, \"spec_mismatch\": spec, \"defect_reports\": defect, \"parts_stock_pct\": stock, \"mechanic_fill_pct\": fill,\n", + " \"avail_pct\": (100 * (1 - np.clip(down, 0, WINDOW) / WINDOW)).round(1)})\n", + " df[\"main_cause\"] = np.where(comp.max(axis=1) < 3, \"없음\", np.array(CAUSES)[comp.argmax(axis=1)])\n", + " return df\n", + "\n", + "\n", + "def feat_frame(df):\n", + " \"\"\"회귀·분석용 설명 변수 표(유형은 더미 변수).\"\"\"\n", + " x = df.copy()\n", + " x[\"etype_B\"], x[\"etype_C\"] = (x[\"etype\"] == \"유형B\").astype(int), (x[\"etype\"] == \"유형C\").astype(int)\n", + " return x[FEAT]\n", + "\n", + "\n", + "def make_trend(months=12, seed=5, shock_unit=None):\n", + " \"\"\"[교육용가상자료] 부대 x 유형 x 월 평균 가동률 추세. shock_unit 부대는 하반기에 외주 검사 병목으로 가동률이 떨어진다.\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " pf = unit_profile()\n", + " rows = []\n", + " for _, u in pf.iterrows():\n", + " base = 62 + 0.22 * u[\"stock\"] + 0.12 * u[\"fill\"] - 3.0 * u[\"queue\"]\n", + " for t in ETYPES:\n", + " off = {\"유형A\": -3.0, \"유형B\": 0.0, \"유형C\": 2.0}[t]\n", + " for m in range(months):\n", + " v = base + off + 2.0 * np.sin(m / 12 * 2 * np.pi) + rng.normal(0, 1.0)\n", + " if shock_unit == u[\"unit\"] and m >= months // 2:\n", + " v -= 1.5 * (m - months // 2 + 1)\n", + " rows.append((u[\"unit\"], t, m + 1, round(float(np.clip(v, 30, 99)), 1)))\n", + " return pd.DataFrame(rows, columns=[\"unit\", \"etype\", \"month\", \"avail_pct\"])\n", + "\n", + "# ---- 정비·검사 병목 판별 룰 설정(YAML 형식 문자열: 기준이 바뀌면 코드가 아니라 이 설정만 고친다) ----\n", + "# clause는 룰과 대응시킨 '가상 품질지침 조항' 예시이며 실제 규정 원문이 아니다.\n", + "RULES_YAML = \"\"\"\n", + "- {id: Q01, name: 부품 재고 가용률 50% 미만, field: parts_stock_pct, op: \"<\", value: 50, weight: 2, clause: 가상품질지침 제3조}\n", + "- {id: Q02, name: 정비병 충원율 60% 미만, field: mechanic_fill_pct, op: \"<\", value: 60, weight: 2, clause: 가상품질지침 제4조}\n", + "- {id: Q03, name: 부품 대기 8일 초과, field: parts_wait_days, op: \">\", value: 8, weight: 2, clause: 가상품질지침 제5조}\n", + "- {id: Q04, name: 정비병 부족 대기 8일 초과, field: labor_wait_days, op: \">\", value: 8, weight: 2, clause: 가상품질지침 제6조}\n", + "- {id: Q05, name: 외주 정비 15일 초과, field: outsource_days, op: \">\", value: 15, weight: 2, clause: 가상품질지침 제7조}\n", + "- {id: Q06, name: 외주 검사 대기 6일 초과, field: inspect_wait_days, op: \">\", value: 6, weight: 3, clause: 가상품질지침 제8조}\n", + "- {id: Q07, name: 재작업 1건 이상, field: rework_count, op: \">=\", value: 1, weight: 3, clause: 가상품질지침 제9조}\n", + "- {id: Q08, name: 재작업률 25% 초과, field: rework_rate, op: \">\", value: 0.25, weight: 2, clause: 가상품질지침 제9조}\n", + "- {id: Q09, name: 규격·제원 불일치 3건 이상, field: spec_mismatch, op: \">=\", value: 3, weight: 3, clause: 가상품질지침 제10조}\n", + "- {id: Q10, name: 하자·불만 3건 이상, field: defect_reports, op: \">=\", value: 3, weight: 2, clause: 가상품질지침 제11조}\n", + "- {id: Q11, name: 장비 연령 15년 초과, field: age_years, op: \">\", value: 15, weight: 1, clause: 가상품질지침 제12조}\n", + "- {id: Q12, name: 정비 요청 6건 이상, field: maint_orders, op: \">=\", value: 6, weight: 1, clause: 가상품질지침 제13조}\n", + "- {id: Q13, name: 대기 일수 합계 30일 초과, field: total_wait, op: \">\", value: 30, weight: 3, clause: 가상품질지침 제14조}\n", + "- {id: Q14, name: 검사 대기가 외주 정비 일수의 80% 초과, field: insp_ratio, op: \">\", value: 0.8, weight: 2, clause: 가상품질지침 제8조}\n", + "- {id: Q15, name: 규격 불일치 2건 이상과 하자 접수 동반, field: spec_defect, op: \"==\", value: true, weight: 3, clause: 가상품질지침 제10조}\n", + "- {id: Q16, name: 최근 90일 가동 시간 150시간 미만, field: op_hours, op: \"<\", value: 150, weight: 1, clause: 가상품질지침 제15조}\n", + "\"\"\"\n", + "GRADE_CUT = [(10, \"고위험\"), (6, \"주의\"), (3, \"관심\")]\n", + "OPS = {\"<\": lambda a, b: a < b, \">\": lambda a, b: a > b, \"<=\": lambda a, b: a <= b, \">=\": lambda a, b: a >= b, \"==\": lambda a, b: a == b}\n", + "\n", + "\n", + "def parse_rules(text):\n", + " \"\"\"YAML 한 줄 맵({key: value, ...}) 형식 룰 문자열을 읽는다(외부 yaml 라이브러리 없이).\"\"\"\n", + " out = []\n", + " for line in text.strip().splitlines():\n", + " body = line.strip()[3:-1]\n", + " r = {}\n", + " for part in body.split(\", \"):\n", + " k, v = part.split(\": \", 1)\n", + " v = v.strip().strip('\"')\n", + " r[k] = True if v == \"true\" else False if v == \"false\" else (float(v) if v.replace(\".\", \"\", 1).isdigit() else v)\n", + " out.append(r)\n", + " return out\n", + "\n", + "\n", + "def derive_rec(rec):\n", + " \"\"\"룰이 쓰는 파생 필드(대기 합계, 재작업률, 검사 대기 비율(외주 5일 이상일 때), 규격 불일치 2건 이상+하자 동반)를 추가한다.\"\"\"\n", + " r = dict(rec)\n", + " r[\"total_wait\"] = r[\"parts_wait_days\"] + r[\"labor_wait_days\"] + r[\"outsource_days\"] + r[\"inspect_wait_days\"]\n", + " r[\"rework_rate\"] = r[\"rework_count\"] / r[\"maint_orders\"] if r[\"maint_orders\"] else 0.0\n", + " r[\"insp_ratio\"] = r[\"inspect_wait_days\"] / r[\"outsource_days\"] if r[\"outsource_days\"] >= 5 else 0.0\n", + " r[\"spec_defect\"] = bool(r[\"spec_mismatch\"] >= 2 and r[\"defect_reports\"] >= 1)\n", + " return r\n", + "\n", + "\n", + "def apply_rules(rec, rules, derived=False):\n", + " \"\"\"룰 적용: (점수, 적중 룰 id 목록). derived=True면 이미 파생 필드가 있는 레코드(경계 시험용)로 본다.\"\"\"\n", + " r = rec if derived else derive_rec(rec)\n", + " hits = [x[\"id\"] for x in rules if OPS[x[\"op\"]](r[x[\"field\"]], x[\"value\"])]\n", + " w = {x[\"id\"]: x[\"weight\"] for x in rules}\n", + " return sum(w[h] for h in hits), hits\n", + "\n", + "\n", + "def grade_of(score):\n", + " for cut, g in GRADE_CUT:\n", + " if score >= cut:\n", + " return g\n", + " return \"정상\"\n", + "\n", + "fleet = make_fleet(300, seed=7)\n", + "fleet[\"low\"] = fleet[\"avail_pct\"] < 70 # 저가동: 가동률 70% 미만(가상 기준)\n", + "FAC = [\"maint_orders\", \"parts_wait_days\", \"labor_wait_days\", \"outsource_days\", \"inspect_wait_days\", \"rework_count\", \"spec_mismatch\", \"defect_reports\",\n", + " \"age_years\", \"op_hours\", \"parts_stock_pct\", \"mechanic_fill_pct\"]\n", + "KO = {**FEAT_KO, \"parts_wait_days\": \"부품 대기 일수\", \"labor_wait_days\": \"정비병 부족 대기 일수\", \"op_hours\": \"가동 시간\"}\n", + "\n", + "# 1) 1차 통계 Baseline: 가동률과의 상관계수, 저가동군-정상군 효과크기(Cohen d), 저가동군 평균 값\n", + "rows = []\n", + "for f in FAC:\n", + " a, b = fleet.loc[fleet[\"low\"], f], fleet.loc[~fleet[\"low\"], f]\n", + " sp = np.sqrt((a.var() + b.var()) / 2) or 1.0\n", + " rows.append({\"요인\": KO[f], \"상관(r)\": fleet[f].corr(fleet[\"avail_pct\"]), \"효과크기(d)\": (a.mean() - b.mean()) / sp, \"저가동군 평균\": a.mean(), \"정상군 평균\": b.mean(), \"key\": f})\n", + "fac = pd.DataFrame(rows)\n", + "fac[\"순위점수\"] = fac[\"상관(r)\"].abs() + fac[\"효과크기(d)\"].abs() / 3 # 두 지표를 함께 본 순위 점수(휴리스틱)\n", + "top5 = fac.sort_values(\"순위점수\", ascending=False).head(5)\n", + "print(f\"[Baseline] 장비 {len(fleet)}대, 저가동(70% 미만) {int(fleet['low'].sum())}대, 평균 가동률 {fleet['avail_pct'].mean():.1f}%\")\n", + "print(\"[가동률 저하 상위 5대 요인]\")\n", + "print(top5.drop(columns=[\"key\", \"순위점수\"]).round(2).to_string(index=False))\n", + "lost = fleet.loc[fleet[\"low\"], [\"parts_wait_days\", \"labor_wait_days\", \"outsource_days\", \"inspect_wait_days\"]].mean()\n", + "print(\"저가동 장비의 평균 대기 손실일:\", \", \".join(f\"{KO[k]} {v:.1f}일\" for k, v in lost.items()))\n", + "print(\"주요 저가동 요인 분포(정답):\", fleet[\"main_cause\"].value_counts().to_dict())\n", + "\n", + "# 2) 룰 엔진: 장비 레코드에 16종 룰을 적용\n", + "rules = parse_rules(RULES_YAML)\n", + "recs = fleet.drop(columns=[\"main_cause\", \"low\"]).to_dict(\"records\")\n", + "lat, scores, hits = [], [], []\n", + "for r in recs:\n", + " t = time.perf_counter(); s, h = apply_rules(r, rules); lat.append((time.perf_counter() - t) * 1000)\n", + " scores.append(s); hits.append(h)\n", + "fleet[\"score\"], fleet[\"grade\"] = scores, [grade_of(s) for s in scores]\n", + "print(f\"\\n[룰 엔진] 룰 {len(rules)}종 적용, 처리 지연 평균 {np.mean(lat):.3f}ms / 99백분위 {np.percentile(lat, 99):.3f}ms (목표 50ms 이하: {'달성' if np.percentile(lat, 99) <= 50 else '미달'})\")\n", + "tab = fleet.groupby(\"grade\").agg(장비수=(\"equip_id\", \"size\"), 실제저가동=(\"low\", \"sum\"), 저가동비율=(\"low\", \"mean\")).reindex([\"고위험\", \"주의\", \"관심\", \"정상\"])\n", + "print(tab.round(2).to_string())\n", + "flag = fleet[\"score\"] >= 6 # 주의 이상을 저가동 후보로 본다\n", + "tp = int((flag & fleet[\"low\"]).sum()); prec, rec = tp / max(flag.sum(), 1), tp / fleet[\"low\"].sum()\n", + "print(f\"저가동 후보(주의 이상) {int(flag.sum())}대 중 실제 저가동 {tp}대: 정밀도 {prec:.0%}, 재현율 {rec:.0%}\")\n", + "cnt = pd.Series([h for hs in hits for h in hs]).value_counts()\n", + "print(\"룰별 적중 상위:\", \", \".join(f\"{k} {v}건\" for k, v in cnt.head(5).items()))\n", + "\n", + "# 3) 경계 조건 단위 시험: 기준값 위·아래에서 룰 판정이 맞는지(룰마다 2~3건)\n", + "BASE = derive_rec({\"parts_stock_pct\": 90, \"mechanic_fill_pct\": 90, \"parts_wait_days\": 0, \"labor_wait_days\": 0, \"outsource_days\": 0, \"inspect_wait_days\": 0,\n", + " \"rework_count\": 0, \"maint_orders\": 0, \"spec_mismatch\": 0, \"defect_reports\": 0, \"age_years\": 5, \"op_hours\": 300})\n", + "tests = ok = 0\n", + "fails = []\n", + "for x in rules:\n", + " v, d = x[\"value\"], (0.01 if isinstance(x[\"value\"], float) and not float(x[\"value\"]).is_integer() else 1)\n", + " cases = {\"<\": [(v, False), (v - d, True), (v + d, False)], \">\": [(v, False), (v + d, True), (v - d, False)], \">=\": [(v - d, False), (v, True), (v + d, True)],\n", + " \"<=\": [(v + d, False), (v, True), (v - d, True)], \"==\": [(True, True), (False, False)]}[x[\"op\"]]\n", + " for val, expect in cases:\n", + " r = dict(BASE); r[x[\"field\"]] = val\n", + " got = x[\"id\"] in apply_rules(r, rules, derived=True)[1]\n", + " tests += 1; ok += got == expect\n", + " if got != expect:\n", + " fails.append((x[\"id\"], val))\n", + "print(f\"\\n[경계 조건 단위 시험] {ok}/{tests} 통과 ({ok / tests:.0%}), 실패 {fails or '없음'} (목표 100%)\")\n", + "\n", + "# 4) 판정 근거 예시(상위 3대)와 사람이 확인할 것\n", + "print(\"\\n[고위험 상위 3대 판정 근거]\")\n", + "nm = {x[\"id\"]: x for x in rules}\n", + "for _, r in fleet.sort_values(\"score\", ascending=False).head(3).iterrows():\n", + " h = apply_rules(recs[int(r.name)], rules)[1]\n", + " print(f\" {r['equip_id']} {r['unit']} 점수 {r['score']:.0f} 가동률 {r['avail_pct']}%: \" + \"; \".join(f\"{i} {nm[i]['name']}({nm[i]['clause']})\" for i in h[:3]))\n", + "print(f\"룰 {len(rules)}종(목표 15종 이상: {'달성' if len(rules) >= 15 else '미달'}). 기준값은 YAML만 바꿔 갱신하며, 정비 우선순위와 품질 판정은 정비관·품질검사관이 확인한다.\")\n", + "\n", + "fig, ax = plt.subplots(1, 2, figsize=(10, 4))\n", + "t5 = top5.iloc[::-1]\n", + "ax[0].barh([k for k in t5[\"key\"]], t5[\"상관(r)\"], color=\"tab:blue\"); ax[0].set_title(\"Top-5 factors: correlation with availability\")\n", + "ax[1].bar(tab.index.map({\"고위험\": \"high\", \"주의\": \"caution\", \"관심\": \"watch\", \"정상\": \"normal\"}), tab[\"저가동비율\"], color=\"tab:orange\"); ax[1].set_title(\"Low-availability rate by rule grade\")\n", + "plt.tight_layout(); plt.show()\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 03 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + "[Baseline] 장비 300대, 저가동(70% 미만) 63대, 평균 가동률 80.8%\n", + "[가동률 저하 상위 5대 요인]\n", + " 요인 상관(r) 효과크기(d) 저가동군 평균 정상군 평균\n", + " 정비 요청 건수 -0.75 1.56 3.94 1.95\n", + " 외주 정비 일수 -0.73 1.26 8.46 1.78\n", + " 외주 검사 대기 일수 -0.66 1.13 5.56 1.02\n", + " 부품 대기 일수 -0.63 1.18 8.70 2.48\n", + "정비병 부족 대기 일수 -0.57 1.01 8.27 2.59\n", + "저가동 장비의 평균 대기 손실일: 부품 대기 일수 8.7일, 정비병 부족 대기 일수 8.3일, 외주 정비 일수 8.5일, 외주 검사 대기 일수 5.6일\n", + "주요 저가동 요인 분포(정답): {'없음': 77, '부품 대기': 72, '정비병 부족': 70, '외주 정비': 59, '검사·재작업': 22}\n", + "\n", + "[룰 엔진] 룰 16종 적용, 처리 지연 평균 0.009ms / 99백분위 0.056ms (목표 50ms 이하: 달성)\n", + " 장비수 실제저가동 저가동비율\n", + "grade \n", + "고위험 16 13 0.81\n", + "주의 24 18 0.75\n", + "관심 45 19 0.42\n", + "정상 215 13 0.06\n", + "저가동 후보(주의 이상) 40대 중 실제 저가동 31대: 정밀도 78%, 재현율 49%\n", + "룰별 적중 상위: Q01 53건, Q02 39건, Q03 38건, Q04 28건, Q06 26건\n", + "\n", + "[경계 조건 단위 시험] 47/47 통과 (100%), 실패 없음 (목표 100%)\n", + "\n", + "[고위험 상위 3대 판정 근거]\n", + " E-0047 가상A부대 점수 14 가동률 35.7%: Q01 부품 재고 가용률 50% 미만(가상품질지침 제3조); Q03 부품 대기 8일 초과(가상품질지침 제5조); Q05 외주 정비 15일 초과(가상품질지침 제7조)\n", + " E-0231 가상D부대 점수 14 가동률 51.7%: Q02 정비병 충원율 60% 미만(가상품질지침 제4조); Q03 부품 대기 8일 초과(가상품질지침 제5조); Q04 정비병 부족 대기 8일 초과(가상품질지침 제6조)\n", + " E-0041 가상C부대 점수 13 가동률 38.8%: Q05 외주 정비 15일 초과(가상품질지침 제7조); Q06 외주 검사 대기 6일 초과(가상품질지침 제8조); Q07 재작업 1건 이상(가상품질지침 제9조)\n", + "룰 16종(목표 15종 이상: 달성). 기준값은 YAML만 바꿔 갱신하며, 정비 우선순위와 품질 판정은 정비관·품질검사관이 확인한다.\n", + "```\n", + "
\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 04. 회귀 분석 및 원인 규명 모델 기반 가동률 저하 핵심 병목 요인 정밀 분석 엔진 고도화\n", + "**소요시간**: 약 3.0시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "회귀 분석 기반 가동률 저하 핵심 병목 요인 정밀 분석 엔진을 고도화한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **회귀 분석으로 가동률 저하의 핵심 병목 요인을 정밀 분석하고, 저하 요인을 분류해 Top-3 개선 옵션과 유사 사례 유사도를 제공하는 모델의 정확도를 검증한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **가상 장비 1,500대를 학습 70%와 검증 30%로 나누고 릿지 회귀로 요인별 표준화 계수를 구한 뒤 부트스트랩 20회로 계수 부호의 안정성 확인하고 유효성을 점검하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `src/ai/qlt04_intelligence_module.py` 형태의 결과물을 완성한다.\n", + "\n", + "### 🔍 평가 및 검증 기준 (PASS 조건)\n", + "#### 미션 판정\n", + "\n", + "**평가 구분:** 필수\n", + "\n", + "**핵심 평가영역:** 추천 사유의 합리성 및 실무 수용성\n", + "\n", + "#### 미션 PASS 조건\n", + "\n", + "- 정량 기준: 추천/분류 정확도 85% 이상 달성\n", + "\n", + "\n", + "- 진행 기준: AI 추론 모듈 구현 및 벤치마크 테스트 완료\n", + "\n", + "\n", + "- 검증 방법: 과거 승인/처리 완료 데이터와의 추천 일치도 검증\n", + "\n", + "\n", + "#### 채점자 판정 메모\n", + "\n", + "> 단순 코드 실행 여부뿐만 아니라 출력된 수치와 근거 자료가 군 실무 요구사항에 부합하는지 면밀히 확인한다.\n", + "\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "\"\"\"가상 장비 1,500대의 가동률을 릿지 회귀로 설명해 핵심 병목 요인의 영향 크기와 부호 안정성을 분석하고, 저가동 장비의 주요 요인(부품 대기·정비병 부족·외주 정비·검사·재작업)을 분류해 Top-3 개선 옵션과 유사 과거 사례 유사도 점수를 제공하며 정확도를 검증 세트로 측정한다.\"\"\"\n", + "import numpy as np\n", + "import pandas as pd\n", + "import matplotlib.pyplot as plt\n", + "from sklearn.metrics import r2_score, mean_absolute_error\n", + "\n", + "# ---------------------------------------------------------------------------\n", + "# [교육용가상자료] 가상 장비 가동률·정비 대기 자료 생성기 (실제 부대·장비·제원·가동률 정보 없음)\n", + "# 장비 유형 A(궤도형)·B(화포형)·C(차륜형)은 추상 구분이며 실제 장비와 무관하다.\n", + "# 최근 90일 동안 장비 1대의 정비 대기 요인(부품·정비병·외주 정비·외주 검사·재작업)과 가동률을 만든다.\n", + "# ---------------------------------------------------------------------------\n", + "UNITS = [\"가상A부대\", \"가상B부대\", \"가상C부대\", \"가상D부대\", \"가상E부대\", \"가상F부대\"]\n", + "ETYPES = [\"유형A\", \"유형B\", \"유형C\"]\n", + "WINDOW = 90 # 관측 기간(일)\n", + "CAUSES = [\"부품 대기\", \"정비병 부족\", \"외주 정비\", \"검사·재작업\"]\n", + "CAUSE_COL = {\"부품 대기\": \"parts_wait_days\", \"정비병 부족\": \"labor_wait_days\", \"외주 정비\": \"outsource_days\", \"검사·재작업\": \"inspect_wait_days\"}\n", + "NUM_FIELDS = [\"age_years\", \"op_hours\", \"maint_orders\", \"parts_wait_days\", \"labor_wait_days\", \"outsource_days\", \"inspect_wait_days\", \"rework_count\",\n", + " \"spec_mismatch\", \"defect_reports\", \"parts_stock_pct\", \"mechanic_fill_pct\", \"avail_pct\"]\n", + "FEAT = [\"age_years\", \"maint_orders\", \"parts_stock_pct\", \"mechanic_fill_pct\", \"outsource_days\", \"inspect_wait_days\", \"rework_count\", \"spec_mismatch\", \"defect_reports\", \"etype_B\", \"etype_C\"]\n", + "FEAT_KO = {\"age_years\": \"장비 연령\", \"maint_orders\": \"정비 요청 건수\", \"parts_stock_pct\": \"부품 재고 가용률\", \"mechanic_fill_pct\": \"정비병 충원율\", \"outsource_days\": \"외주 정비 일수\",\n", + " \"inspect_wait_days\": \"외주 검사 대기 일수\", \"rework_count\": \"재작업 건수\", \"spec_mismatch\": \"규격·제원 불일치\", \"defect_reports\": \"하자·불만 접수\",\n", + " \"etype_B\": \"유형B 여부\", \"etype_C\": \"유형C 여부\"}\n", + "\n", + "\n", + "def unit_profile(seed=3):\n", + " \"\"\"부대별 고정 특성: 부품 재고 수준, 정비병 충원율, 외주 정비소 거리(일수 가중), 외주 검사 혼잡도.\"\"\"\n", + " r = np.random.default_rng(seed)\n", + " return pd.DataFrame({\"unit\": UNITS, \"stock\": r.uniform(45, 95, len(UNITS)), \"fill\": r.uniform(55, 98, len(UNITS)),\n", + " \"dist\": r.uniform(0.8, 1.6, len(UNITS)), \"queue\": r.uniform(0.7, 2.2, len(UNITS))})\n", + "\n", + "\n", + "def make_fleet(n=300, seed=7, shock=0.0):\n", + " \"\"\"장비 n대의 90일 관측 자료(필드 16개)와 정답 요인을 만든다. shock은 외주 검사 병목 악화 정도(AAR용 환경 변화).\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " pf = unit_profile()\n", + " ui = rng.integers(0, len(UNITS), n)\n", + " et = rng.choice(ETYPES, n, p=[.35, .3, .35])\n", + " age = np.clip(rng.gamma(4, 2.2, n), 0.5, 30).round(1)\n", + " eff = np.array([{\"유형A\": 1.15, \"유형B\": 1.0, \"유형C\": 0.9}[t] for t in et])\n", + " orders = rng.poisson(np.clip(1.2 + 0.12 * age, 0.5, 6) * eff)\n", + " stock = np.clip(pf[\"stock\"].to_numpy()[ui] + rng.normal(0, 8, n), 20, 100).round(0)\n", + " fill = np.clip(pf[\"fill\"].to_numpy()[ui] + rng.normal(0, 6, n), 30, 100).round(0)\n", + " parts_w = (orders * rng.gamma(2, 0.55 * (1 + ((100 - stock) / 28) ** 2) / 2)).round(0)\n", + " labor_w = (orders * rng.gamma(2, 0.7 * (1 + ((100 - fill) / 24) ** 2) / 2)).round(0)\n", + " n_out = rng.binomial(orders, 0.3)\n", + " out_d = (n_out * rng.gamma(3, 4.5 * pf[\"dist\"].to_numpy()[ui] / 3)).round(0)\n", + " insp_w = (n_out * rng.gamma(2, (2.6 + shock) * pf[\"queue\"].to_numpy()[ui] / 2)).round(0)\n", + " spec = rng.poisson(0.2 + 0.04 * age)\n", + " rework = rng.binomial(n_out, np.clip(0.08 + 0.1 * spec, 0, .6))\n", + " defect = rng.poisson(0.3 + 0.7 * rework)\n", + " base_rep = orders * 1.8\n", + " down = base_rep + parts_w + labor_w + out_d + insp_w + rework * 4 + rng.normal(0, 1.2, n)\n", + " comp = np.column_stack([parts_w, labor_w, out_d, insp_w + rework * 4])\n", + " df = pd.DataFrame({\"equip_id\": [f\"E-{i + 1:04d}\" for i in range(n)], \"unit\": np.array(UNITS)[ui], \"etype\": et, \"age_years\": age,\n", + " \"op_hours\": np.clip(rng.normal(320 - 4 * age, 70, n), 40, 600).round(0), \"maint_orders\": orders,\n", + " \"parts_wait_days\": parts_w, \"labor_wait_days\": labor_w, \"outsource_days\": out_d, \"inspect_wait_days\": insp_w,\n", + " \"rework_count\": rework, \"spec_mismatch\": spec, \"defect_reports\": defect, \"parts_stock_pct\": stock, \"mechanic_fill_pct\": fill,\n", + " \"avail_pct\": (100 * (1 - np.clip(down, 0, WINDOW) / WINDOW)).round(1)})\n", + " df[\"main_cause\"] = np.where(comp.max(axis=1) < 3, \"없음\", np.array(CAUSES)[comp.argmax(axis=1)])\n", + " return df\n", + "\n", + "\n", + "def feat_frame(df):\n", + " \"\"\"회귀·분석용 설명 변수 표(유형은 더미 변수).\"\"\"\n", + " x = df.copy()\n", + " x[\"etype_B\"], x[\"etype_C\"] = (x[\"etype\"] == \"유형B\").astype(int), (x[\"etype\"] == \"유형C\").astype(int)\n", + " return x[FEAT]\n", + "\n", + "\n", + "def make_trend(months=12, seed=5, shock_unit=None):\n", + " \"\"\"[교육용가상자료] 부대 x 유형 x 월 평균 가동률 추세. shock_unit 부대는 하반기에 외주 검사 병목으로 가동률이 떨어진다.\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " pf = unit_profile()\n", + " rows = []\n", + " for _, u in pf.iterrows():\n", + " base = 62 + 0.22 * u[\"stock\"] + 0.12 * u[\"fill\"] - 3.0 * u[\"queue\"]\n", + " for t in ETYPES:\n", + " off = {\"유형A\": -3.0, \"유형B\": 0.0, \"유형C\": 2.0}[t]\n", + " for m in range(months):\n", + " v = base + off + 2.0 * np.sin(m / 12 * 2 * np.pi) + rng.normal(0, 1.0)\n", + " if shock_unit == u[\"unit\"] and m >= months // 2:\n", + " v -= 1.5 * (m - months // 2 + 1)\n", + " rows.append((u[\"unit\"], t, m + 1, round(float(np.clip(v, 30, 99)), 1)))\n", + " return pd.DataFrame(rows, columns=[\"unit\", \"etype\", \"month\", \"avail_pct\"])\n", + "\n", + "# ---- 가동률 회귀 모델과 저가동 요인 분류·추천 모델(외부 AI 서비스 없이 scikit-learn 사용) ----\n", + "from sklearn.linear_model import Ridge, LogisticRegression\n", + "from sklearn.preprocessing import StandardScaler\n", + "from sklearn.pipeline import make_pipeline\n", + "\n", + "CAUSE_COLS = [\"age_years\", \"maint_orders\", \"parts_stock_pct\", \"mechanic_fill_pct\", \"outsource_days\", \"inspect_wait_days\", \"rework_count\", \"spec_mismatch\",\n", + " \"defect_reports\", \"parts_wait_days\", \"labor_wait_days\"]\n", + "ACTION = {\"부품 대기\": \"부품 사전 청구·재고 보강\", \"정비병 부족\": \"정비병 재배치·교육 일정 조정\", \"외주 정비\": \"외주 정비 일정 조율·내부 정비 확대\", \"검사·재작업\": \"검사 일정 조율·규격 사전 대조\"}\n", + "\n", + "\n", + "def cause_frame(df):\n", + " x = df[CAUSE_COLS].copy()\n", + " x[\"out_insp\"] = x[\"outsource_days\"] + x[\"inspect_wait_days\"] + 4 * x[\"rework_count\"]\n", + " x[\"wait_pm\"] = (x[\"parts_wait_days\"] + x[\"labor_wait_days\"]) / (x[\"maint_orders\"] + 1)\n", + " return x\n", + "\n", + "\n", + "def fit_avail(df, alpha=1.0):\n", + " \"\"\"가동률(%) 회귀: 표준화 + 릿지 회귀.\"\"\"\n", + " return make_pipeline(StandardScaler(), Ridge(alpha=alpha)).fit(feat_frame(df), df[\"avail_pct\"])\n", + "\n", + "\n", + "def fit_cause(df, cut=85, C=5):\n", + " \"\"\"가동률이 cut% 미만인 장비의 주요 저가동 요인(4종)을 분류한다.\"\"\"\n", + " low = df[(df[\"avail_pct\"] < cut) & (df[\"main_cause\"] != \"없음\")]\n", + " return make_pipeline(StandardScaler(), LogisticRegression(C=C, max_iter=3000)).fit(cause_frame(low), low[\"main_cause\"])\n", + "\n", + "\n", + "SIM_FEAT = [\"age_years\", \"maint_orders\", \"spec_mismatch\", \"stock_gap_x_orders\", \"fill_gap_x_orders\", \"parts_stock_pct\", \"mechanic_fill_pct\", \"etype_B\", \"etype_C\"]\n", + "\n", + "\n", + "def sim_frame(df):\n", + " \"\"\"자원 재배치 시뮬레이션용 설명 변수: 부족분이 클수록 대기가 더 빠르게 늘고 정비 요청 건수와 곱해져 커진다는 구조를 제곱·상호작용 항으로 둔다.\"\"\"\n", + " x = df.copy()\n", + " x[\"etype_B\"], x[\"etype_C\"] = (x[\"etype\"] == \"유형B\").astype(int), (x[\"etype\"] == \"유형C\").astype(int)\n", + " x[\"stock_gap_x_orders\"] = x[\"maint_orders\"] * ((100 - x[\"parts_stock_pct\"]) / 28) ** 2\n", + " x[\"fill_gap_x_orders\"] = x[\"maint_orders\"] * ((100 - x[\"mechanic_fill_pct\"]) / 24) ** 2\n", + " return x[SIM_FEAT]\n", + "\n", + "\n", + "def fit_sim(df, alpha=0.5):\n", + " return make_pipeline(StandardScaler(), Ridge(alpha=alpha)).fit(sim_frame(df), df[\"avail_pct\"])\n", + "\n", + "fleet = make_fleet(1500, seed=21)\n", + "idx = np.random.default_rng(5).permutation(len(fleet))\n", + "tr, te = fleet.iloc[idx[:1050]], fleet.iloc[idx[1050:]] # 학습 70% / 검증 30%(과거 처리 완료 자료 가정)\n", + "\n", + "# 1) 회귀 분석: 가동률 = f(연령, 정비 요청, 재고, 충원율, 외주·검사 대기, 재작업, 규격 불일치, 하자, 유형)\n", + "reg = fit_avail(tr)\n", + "pred = reg.predict(feat_frame(te))\n", + "coef = pd.Series(reg[-1].coef_, index=FEAT) # 표준화 계수: 1표준편차 증가 시 가동률 변화(%p)\n", + "print(f\"[회귀 모델] 학습 {len(tr)}대 / 검증 {len(te)}대: 검증 R2 {r2_score(te['avail_pct'], pred):.3f}, MAE {mean_absolute_error(te['avail_pct'], pred):.2f}%p\")\n", + "boot = []\n", + "rng = np.random.default_rng(9)\n", + "for _ in range(20): # 부트스트랩 20회로 계수 부호 안정성 확인\n", + " b = tr.iloc[rng.integers(0, len(tr), len(tr))]\n", + " boot.append(fit_avail(b)[-1].coef_)\n", + "boot = np.array(boot)\n", + "top = coef.abs().sort_values(ascending=False).head(5).index\n", + "print(\"[핵심 병목 요인: 표준화 계수(1 표준편차 증가 시 가동률 변화 %p) / 부호 유지 비율]\")\n", + "for f in top:\n", + " k = FEAT.index(f)\n", + " print(f\" {FEAT_KO[f]:12s} {coef[f]:+6.2f}%p 부호 유지 {np.mean(np.sign(boot[:, k]) == np.sign(coef[f])):.0%}\")\n", + "\n", + "# 2) 요인 분류 + Top-3 개선 옵션\n", + "clf = fit_cause(tr)\n", + "low = te[(te[\"avail_pct\"] < 85) & (te[\"main_cause\"] != \"없음\")]\n", + "X = cause_frame(low)\n", + "proba = clf.predict_proba(X)\n", + "cls = clf.classes_\n", + "top1 = cls[proba.argmax(1)]\n", + "acc1 = float(np.mean(top1 == low[\"main_cause\"].to_numpy()))\n", + "order = np.argsort(-proba, axis=1)[:, :3]\n", + "acc3 = float(np.mean([low[\"main_cause\"].iloc[i] in cls[order[i]] for i in range(len(low))]))\n", + "major = low[\"main_cause\"].value_counts(normalize=True).iloc[0]\n", + "print(f\"\\n[요인 분류] 검증 저가동 장비 {len(low)}대: Top-1 정확도 {acc1:.1%}, Top-3 포함률 {acc3:.1%} (무작위 최빈값 기준 {major:.1%}) -> 목표 85% 이상: {'달성' if acc1 >= 0.85 else '미달'}\")\n", + "rc = pd.crosstab(low[\"main_cause\"], pd.Series(top1, index=low.index, name=\"예측\"))\n", + "print(\"요인별 재현율:\", \", \".join(f\"{c} {rc.loc[c, c] / rc.loc[c].sum():.0%}\" for c in rc.index if c in rc.columns))\n", + "\n", + "# 3) 유사 과거 사례 유사도(코사인): 학습 세트 중 같은 요인 장비와 얼마나 비슷한가\n", + "sc, trl = clf[0], tr[(tr[\"avail_pct\"] < 85) & (tr[\"main_cause\"] != \"없음\")]\n", + "Z = sc.transform(cause_frame(trl)); Zn = Z / np.linalg.norm(Z, axis=1, keepdims=True)\n", + "zq = sc.transform(X); zqn = zq / np.linalg.norm(zq, axis=1, keepdims=True)\n", + "sim = {c: (zqn @ Zn[(trl[\"main_cause\"] == c).to_numpy()].T).max(axis=1) for c in cls}\n", + "print(\"\\n[Top-3 개선 옵션 예시: 확률(%) / 유사 사례 유사도]\")\n", + "for j in range(3):\n", + " r = low.iloc[j]\n", + " opts = \" | \".join(f\"{cls[o]} {proba[j, o]:.0%}/{sim[cls[o]][j]:.2f} -> {ACTION[cls[o]]}\" for o in order[j])\n", + " print(f\" {r['equip_id']} 가동률 {r['avail_pct']}% (정답 {r['main_cause']}): {opts}\")\n", + "miss = low[top1 != low[\"main_cause\"].to_numpy()]\n", + "print(f\"\\n[오분류 {len(miss)}건] 확률이 가장 높은 요인과 2위 확률 차이 평균 {np.mean(np.sort(proba, axis=1)[:, -1] - np.sort(proba, axis=1)[:, -2]):.2f}\")\n", + "conf = np.sort(proba, axis=1)[:, -1]\n", + "print(f\"확률 0.8 이상 추천의 정확도 {np.mean((top1 == low['main_cause'].to_numpy())[conf >= 0.8]):.0%} ({int((conf >= 0.8).sum())}건), 0.8 미만은 사람이 Top-3를 비교해 선택\")\n", + "print(\"※ 추천은 판단 보조이며 정비 우선순위와 자원 투입은 정비관·군수참모가 결정한다. 가상 자료이므로 실제 장비에서는 정확도가 다를 수 있다.\")\n", + "\n", + "plt.figure(figsize=(7, 4))\n", + "plt.barh(list(coef.abs().sort_values().index), coef[coef.abs().sort_values().index], color=\"tab:blue\")\n", + "plt.title(\"Standardized ridge coefficients on availability (%p per 1 SD)\"); plt.tight_layout(); plt.show()\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 04 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + "[회귀 모델] 학습 1050대 / 검증 450대: 검증 R2 0.899, MAE 3.97%p\n", + "[핵심 병목 요인: 표준화 계수(1 표준편차 증가 시 가동률 변화 %p) / 부호 유지 비율]\n", + " 정비 요청 건수 -8.63%p 부호 유지 100%\n", + " 외주 정비 일수 -6.62%p 부호 유지 100%\n", + " 외주 검사 대기 일수 -5.06%p 부호 유지 100%\n", + " 재작업 건수 -1.88%p 부호 유지 100%\n", + " 부품 재고 가용률 +1.73%p 부호 유지 100%\n", + "\n", + "[요인 분류] 검증 저가동 장비 234대: Top-1 정확도 99.1%, Top-3 포함률 100.0% (무작위 최빈값 기준 35.5%) -> 목표 85% 이상: 달성\n", + "요인별 재현율: 검사·재작업 97%, 부품 대기 100%, 외주 정비 99%, 정비병 부족 100%\n", + "\n", + "[Top-3 개선 옵션 예시: 확률(%) / 유사 사례 유사도]\n", + " E-1269 가동률 79.4% (정답 검사·재작업): 검사·재작업 98%/0.89 -> 검사 일정 조율·규격 사전 대조 | 외주 정비 2%/0.83 -> 외주 정비 일정 조율·내부 정비 확대 | 정비병 부족 0%/0.68 -> 정비병 재배치·교육 일정 조정\n", + " E-0462 가동률 74.4% (정답 외주 정비): 외주 정비 63%/0.74 -> 외주 정비 일정 조율·내부 정비 확대 | 부품 대기 36%/0.81 -> 부품 사전 청구·재고 보강 | 정비병 부족 0%/0.69 -> 정비병 재배치·교육 일정 조정\n", + " E-0924 가동률 3.6% (정답 외주 정비): 외주 정비 100%/0.85 -> 외주 정비 일정 조율·내부 정비 확대 | 부품 대기 0%/0.70 -> 부품 사전 청구·재고 보강 | 정비병 부족 0%/0.75 -> 정비병 재배치·교육 일정 조정\n", + "\n", + "[오분류 2건] 확률이 가장 높은 요인과 2위 확률 차이 평균 0.86\n", + "확률 0.8 이상 추천의 정확도 100% (192건), 0.8 미만은 사람이 Top-3를 비교해 선택\n", + "※ 추천은 판단 보조이며 정비 우선순위와 자원 투입은 정비관·군수참모가 결정한다. 가상 자료이므로 실제 장비에서는 정확도가 다를 수 있다.\n", + "```\n", + "
\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 05. 정비 자원 재배치 시 예상 가동률 상승폭 시뮬레이션 산출 엔진 개발\n", + "**소요시간**: 약 3.0시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "정비 자원 재배치 시 예상 가동률 상승폭 시뮬레이션 산출 엔진을 개발한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **정비병과 부품 재고를 부대 사이에서 옮겼을 때 예상 가동률 상승폭을 시뮬레이션하는 엔진을 만들고, 입력-분석-검토-결과 4개 화면의 프로토타입으로 보여 준다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **재고·충원율 부족분의 제곱과 정비 요청 건수의 상호작용을 반영한 회귀 모델을 만들어 검증 R2 확인하고 유효성을 점검하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `src/ui/qlt04_app.py` 형태의 결과물을 완성한다.\n", + "\n", + "### 🔍 평가 및 검증 기준 (PASS 조건)\n", + "#### 미션 판정\n", + "\n", + "**평가 구분:** 필수\n", + "\n", + "**핵심 평가영역:** 군 실무자 친화적 디자인 및 조작 편의성\n", + "\n", + "#### 미션 PASS 조건\n", + "\n", + "- 정량 기준: 핵심 화면 4종(입력-분석-검토-결과) 구현 완료\n", + "\n", + "\n", + "- 진행 기준: UI 프로토타입 로컬 서버 구동 및 전체 페이지 네비게이션 확인\n", + "\n", + "\n", + "- 검증 방법: 사용성 평가(UX Walkthrough) 및 인터랙션 테스트\n", + "\n", + "\n", + "#### 채점자 판정 메모\n", + "\n", + "> 단순 코드 실행 여부뿐만 아니라 출력된 수치와 근거 자료가 군 실무 요구사항에 부합하는지 면밀히 확인한다.\n", + "\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "\"\"\"부대별 정비병 충원율·부품 재고 가용률을 다른 부대로 옮겼을 때 장비 가동률이 얼마나 오르는지 회귀 모델로 시뮬레이션하고 상승폭이 큰 재배치안을 순서대로 고르며, 입력-분석-검토-결과 4개 화면과 상태 뱃지(승인대기·반려·완료)·필터·화면 이동을 갖춘 텍스트 프로토타입으로 보여 준다.\"\"\"\n", + "import numpy as np\n", + "import pandas as pd\n", + "import matplotlib.pyplot as plt\n", + "from sklearn.metrics import r2_score\n", + "\n", + "# ---------------------------------------------------------------------------\n", + "# [교육용가상자료] 가상 장비 가동률·정비 대기 자료 생성기 (실제 부대·장비·제원·가동률 정보 없음)\n", + "# 장비 유형 A(궤도형)·B(화포형)·C(차륜형)은 추상 구분이며 실제 장비와 무관하다.\n", + "# 최근 90일 동안 장비 1대의 정비 대기 요인(부품·정비병·외주 정비·외주 검사·재작업)과 가동률을 만든다.\n", + "# ---------------------------------------------------------------------------\n", + "UNITS = [\"가상A부대\", \"가상B부대\", \"가상C부대\", \"가상D부대\", \"가상E부대\", \"가상F부대\"]\n", + "ETYPES = [\"유형A\", \"유형B\", \"유형C\"]\n", + "WINDOW = 90 # 관측 기간(일)\n", + "CAUSES = [\"부품 대기\", \"정비병 부족\", \"외주 정비\", \"검사·재작업\"]\n", + "CAUSE_COL = {\"부품 대기\": \"parts_wait_days\", \"정비병 부족\": \"labor_wait_days\", \"외주 정비\": \"outsource_days\", \"검사·재작업\": \"inspect_wait_days\"}\n", + "NUM_FIELDS = [\"age_years\", \"op_hours\", \"maint_orders\", \"parts_wait_days\", \"labor_wait_days\", \"outsource_days\", \"inspect_wait_days\", \"rework_count\",\n", + " \"spec_mismatch\", \"defect_reports\", \"parts_stock_pct\", \"mechanic_fill_pct\", \"avail_pct\"]\n", + "FEAT = [\"age_years\", \"maint_orders\", \"parts_stock_pct\", \"mechanic_fill_pct\", \"outsource_days\", \"inspect_wait_days\", \"rework_count\", \"spec_mismatch\", \"defect_reports\", \"etype_B\", \"etype_C\"]\n", + "FEAT_KO = {\"age_years\": \"장비 연령\", \"maint_orders\": \"정비 요청 건수\", \"parts_stock_pct\": \"부품 재고 가용률\", \"mechanic_fill_pct\": \"정비병 충원율\", \"outsource_days\": \"외주 정비 일수\",\n", + " \"inspect_wait_days\": \"외주 검사 대기 일수\", \"rework_count\": \"재작업 건수\", \"spec_mismatch\": \"규격·제원 불일치\", \"defect_reports\": \"하자·불만 접수\",\n", + " \"etype_B\": \"유형B 여부\", \"etype_C\": \"유형C 여부\"}\n", + "\n", + "\n", + "def unit_profile(seed=3):\n", + " \"\"\"부대별 고정 특성: 부품 재고 수준, 정비병 충원율, 외주 정비소 거리(일수 가중), 외주 검사 혼잡도.\"\"\"\n", + " r = np.random.default_rng(seed)\n", + " return pd.DataFrame({\"unit\": UNITS, \"stock\": r.uniform(45, 95, len(UNITS)), \"fill\": r.uniform(55, 98, len(UNITS)),\n", + " \"dist\": r.uniform(0.8, 1.6, len(UNITS)), \"queue\": r.uniform(0.7, 2.2, len(UNITS))})\n", + "\n", + "\n", + "def make_fleet(n=300, seed=7, shock=0.0):\n", + " \"\"\"장비 n대의 90일 관측 자료(필드 16개)와 정답 요인을 만든다. shock은 외주 검사 병목 악화 정도(AAR용 환경 변화).\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " pf = unit_profile()\n", + " ui = rng.integers(0, len(UNITS), n)\n", + " et = rng.choice(ETYPES, n, p=[.35, .3, .35])\n", + " age = np.clip(rng.gamma(4, 2.2, n), 0.5, 30).round(1)\n", + " eff = np.array([{\"유형A\": 1.15, \"유형B\": 1.0, \"유형C\": 0.9}[t] for t in et])\n", + " orders = rng.poisson(np.clip(1.2 + 0.12 * age, 0.5, 6) * eff)\n", + " stock = np.clip(pf[\"stock\"].to_numpy()[ui] + rng.normal(0, 8, n), 20, 100).round(0)\n", + " fill = np.clip(pf[\"fill\"].to_numpy()[ui] + rng.normal(0, 6, n), 30, 100).round(0)\n", + " parts_w = (orders * rng.gamma(2, 0.55 * (1 + ((100 - stock) / 28) ** 2) / 2)).round(0)\n", + " labor_w = (orders * rng.gamma(2, 0.7 * (1 + ((100 - fill) / 24) ** 2) / 2)).round(0)\n", + " n_out = rng.binomial(orders, 0.3)\n", + " out_d = (n_out * rng.gamma(3, 4.5 * pf[\"dist\"].to_numpy()[ui] / 3)).round(0)\n", + " insp_w = (n_out * rng.gamma(2, (2.6 + shock) * pf[\"queue\"].to_numpy()[ui] / 2)).round(0)\n", + " spec = rng.poisson(0.2 + 0.04 * age)\n", + " rework = rng.binomial(n_out, np.clip(0.08 + 0.1 * spec, 0, .6))\n", + " defect = rng.poisson(0.3 + 0.7 * rework)\n", + " base_rep = orders * 1.8\n", + " down = base_rep + parts_w + labor_w + out_d + insp_w + rework * 4 + rng.normal(0, 1.2, n)\n", + " comp = np.column_stack([parts_w, labor_w, out_d, insp_w + rework * 4])\n", + " df = pd.DataFrame({\"equip_id\": [f\"E-{i + 1:04d}\" for i in range(n)], \"unit\": np.array(UNITS)[ui], \"etype\": et, \"age_years\": age,\n", + " \"op_hours\": np.clip(rng.normal(320 - 4 * age, 70, n), 40, 600).round(0), \"maint_orders\": orders,\n", + " \"parts_wait_days\": parts_w, \"labor_wait_days\": labor_w, \"outsource_days\": out_d, \"inspect_wait_days\": insp_w,\n", + " \"rework_count\": rework, \"spec_mismatch\": spec, \"defect_reports\": defect, \"parts_stock_pct\": stock, \"mechanic_fill_pct\": fill,\n", + " \"avail_pct\": (100 * (1 - np.clip(down, 0, WINDOW) / WINDOW)).round(1)})\n", + " df[\"main_cause\"] = np.where(comp.max(axis=1) < 3, \"없음\", np.array(CAUSES)[comp.argmax(axis=1)])\n", + " return df\n", + "\n", + "\n", + "def feat_frame(df):\n", + " \"\"\"회귀·분석용 설명 변수 표(유형은 더미 변수).\"\"\"\n", + " x = df.copy()\n", + " x[\"etype_B\"], x[\"etype_C\"] = (x[\"etype\"] == \"유형B\").astype(int), (x[\"etype\"] == \"유형C\").astype(int)\n", + " return x[FEAT]\n", + "\n", + "\n", + "def make_trend(months=12, seed=5, shock_unit=None):\n", + " \"\"\"[교육용가상자료] 부대 x 유형 x 월 평균 가동률 추세. shock_unit 부대는 하반기에 외주 검사 병목으로 가동률이 떨어진다.\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " pf = unit_profile()\n", + " rows = []\n", + " for _, u in pf.iterrows():\n", + " base = 62 + 0.22 * u[\"stock\"] + 0.12 * u[\"fill\"] - 3.0 * u[\"queue\"]\n", + " for t in ETYPES:\n", + " off = {\"유형A\": -3.0, \"유형B\": 0.0, \"유형C\": 2.0}[t]\n", + " for m in range(months):\n", + " v = base + off + 2.0 * np.sin(m / 12 * 2 * np.pi) + rng.normal(0, 1.0)\n", + " if shock_unit == u[\"unit\"] and m >= months // 2:\n", + " v -= 1.5 * (m - months // 2 + 1)\n", + " rows.append((u[\"unit\"], t, m + 1, round(float(np.clip(v, 30, 99)), 1)))\n", + " return pd.DataFrame(rows, columns=[\"unit\", \"etype\", \"month\", \"avail_pct\"])\n", + "\n", + "# ---- 가동률 회귀 모델과 저가동 요인 분류·추천 모델(외부 AI 서비스 없이 scikit-learn 사용) ----\n", + "from sklearn.linear_model import Ridge, LogisticRegression\n", + "from sklearn.preprocessing import StandardScaler\n", + "from sklearn.pipeline import make_pipeline\n", + "\n", + "CAUSE_COLS = [\"age_years\", \"maint_orders\", \"parts_stock_pct\", \"mechanic_fill_pct\", \"outsource_days\", \"inspect_wait_days\", \"rework_count\", \"spec_mismatch\",\n", + " \"defect_reports\", \"parts_wait_days\", \"labor_wait_days\"]\n", + "ACTION = {\"부품 대기\": \"부품 사전 청구·재고 보강\", \"정비병 부족\": \"정비병 재배치·교육 일정 조정\", \"외주 정비\": \"외주 정비 일정 조율·내부 정비 확대\", \"검사·재작업\": \"검사 일정 조율·규격 사전 대조\"}\n", + "\n", + "\n", + "def cause_frame(df):\n", + " x = df[CAUSE_COLS].copy()\n", + " x[\"out_insp\"] = x[\"outsource_days\"] + x[\"inspect_wait_days\"] + 4 * x[\"rework_count\"]\n", + " x[\"wait_pm\"] = (x[\"parts_wait_days\"] + x[\"labor_wait_days\"]) / (x[\"maint_orders\"] + 1)\n", + " return x\n", + "\n", + "\n", + "def fit_avail(df, alpha=1.0):\n", + " \"\"\"가동률(%) 회귀: 표준화 + 릿지 회귀.\"\"\"\n", + " return make_pipeline(StandardScaler(), Ridge(alpha=alpha)).fit(feat_frame(df), df[\"avail_pct\"])\n", + "\n", + "\n", + "def fit_cause(df, cut=85, C=5):\n", + " \"\"\"가동률이 cut% 미만인 장비의 주요 저가동 요인(4종)을 분류한다.\"\"\"\n", + " low = df[(df[\"avail_pct\"] < cut) & (df[\"main_cause\"] != \"없음\")]\n", + " return make_pipeline(StandardScaler(), LogisticRegression(C=C, max_iter=3000)).fit(cause_frame(low), low[\"main_cause\"])\n", + "\n", + "\n", + "SIM_FEAT = [\"age_years\", \"maint_orders\", \"spec_mismatch\", \"stock_gap_x_orders\", \"fill_gap_x_orders\", \"parts_stock_pct\", \"mechanic_fill_pct\", \"etype_B\", \"etype_C\"]\n", + "\n", + "\n", + "def sim_frame(df):\n", + " \"\"\"자원 재배치 시뮬레이션용 설명 변수: 부족분이 클수록 대기가 더 빠르게 늘고 정비 요청 건수와 곱해져 커진다는 구조를 제곱·상호작용 항으로 둔다.\"\"\"\n", + " x = df.copy()\n", + " x[\"etype_B\"], x[\"etype_C\"] = (x[\"etype\"] == \"유형B\").astype(int), (x[\"etype\"] == \"유형C\").astype(int)\n", + " x[\"stock_gap_x_orders\"] = x[\"maint_orders\"] * ((100 - x[\"parts_stock_pct\"]) / 28) ** 2\n", + " x[\"fill_gap_x_orders\"] = x[\"maint_orders\"] * ((100 - x[\"mechanic_fill_pct\"]) / 24) ** 2\n", + " return x[SIM_FEAT]\n", + "\n", + "\n", + "def fit_sim(df, alpha=0.5):\n", + " return make_pipeline(StandardScaler(), Ridge(alpha=alpha)).fit(sim_frame(df), df[\"avail_pct\"])\n", + "\n", + "fleet = make_fleet(600, seed=31)\n", + "sim_tr, sim_te = fleet.iloc[:420], fleet.iloc[420:]\n", + "sm = fit_sim(sim_tr)\n", + "print(f\"[시뮬레이션 모델] 검증 R2 {r2_score(sim_te['avail_pct'], sm.predict(sim_frame(sim_te))):.3f} (재고·충원율 부족분의 제곱과 정비 요청 건수의 상호작용 반영)\")\n", + "sm = fit_sim(fleet)\n", + "AUTH = 40 # 부대당 정비병 편성 정원(가상 가정)\n", + "PARAM = {\"max_move\": 4, \"min_donor_fill\": 60.0, \"stock_step\": 8.0, \"top_n\": 4, \"max_donor_drop\": 3.5}\n", + "\n", + "\n", + "def unit_avail(df, unit, fill_d=0.0, stock_d=0.0):\n", + " d = df[df[\"unit\"] == unit].copy()\n", + " d[\"mechanic_fill_pct\"] = np.clip(d[\"mechanic_fill_pct\"] + fill_d, 0, 100)\n", + " d[\"parts_stock_pct\"] = np.clip(d[\"parts_stock_pct\"] + stock_d, 0, 100)\n", + " return float(np.clip(sm.predict(sim_frame(d)), 0, 100).mean()), len(d)\n", + "\n", + "\n", + "def evaluate(df, kind, src, dst, n):\n", + " \"\"\"src 부대에서 dst 부대로 n단위(정비병 n명 또는 재고 n*step %p)를 옮길 때 전체 평균 가동률 변화(%p)와 제공 부대 변화.\"\"\"\n", + " delta = n / AUTH * 100 if kind == \"정비병\" else n * PARAM[\"stock_step\"] / 2\n", + " key = \"mechanic_fill_pct\" if kind == \"정비병\" else \"parts_stock_pct\"\n", + " a0, na = unit_avail(df, src); b0, nb = unit_avail(df, dst)\n", + " kw = (lambda s: {\"fill_d\": s * delta}) if kind == \"정비병\" else (lambda s: {\"stock_d\": s * delta})\n", + " a1, _ = unit_avail(df, src, **kw(-1)); b1, _ = unit_avail(df, dst, **kw(+1))\n", + " cur = df[df[\"unit\"] == src][key].mean()\n", + " return {\"kind\": kind, \"src\": src, \"dst\": dst, \"n\": n, \"gain\": ((a1 - a0) * na + (b1 - b0) * nb) / len(df), \"donor_drop\": a0 - a1, \"recv_gain\": b1 - b0, \"donor_after\": cur - delta}\n", + "\n", + "\n", + "base = pd.DataFrame([{\"unit\": u, **dict(zip([\"avail\", \"n\"], unit_avail(fleet, u))), \"stock\": g[\"parts_stock_pct\"].mean(), \"fill\": g[\"mechanic_fill_pct\"].mean()} for u, g in fleet.groupby(\"unit\")])\n", + "cands = [evaluate(fleet, k, s, d, n) for k in [\"정비병\", \"부품 재고\"] for s in UNITS for d in UNITS if s != d for n in (2, 4, 6)]\n", + "cands = [c for c in cands if c[\"donor_after\"] >= PARAM[\"min_donor_fill\"]]\n", + "cands.sort(key=lambda c: -c[\"gain\"])\n", + "print(f\"후보 {len(cands)}건 평가 (제공 부대당 정비병 누적 최대 {PARAM['max_move']}명 이동, 제공 부대 충원율·재고 {PARAM['min_donor_fill']:.0f}% 이상 유지)\")\n", + "\n", + "# 화면 4종 프로토타입\n", + "BADGE = {\"승인대기\": \"[승인대기]\", \"반려\": \"[반려]\", \"완료\": \"[완료]\"}\n", + "props = [{\"id\": \"P00\", **evaluate(fleet, \"정비병\", \"가상C부대\", \"가상D부대\", 2), \"status\": \"완료\", \"note\": \"전 분기 시행 완료(가상 이력)\"}]\n", + "for c in cands: # 같은 도착 부대 중복을 피해 상위 4건 제안\n", + " if len([p for p in props if p[\"id\"] != \"P00\"]) >= PARAM[\"top_n\"]:\n", + " break\n", + " if any(p[\"dst\"] == c[\"dst\"] and p[\"kind\"] == c[\"kind\"] for p in props):\n", + " continue\n", + " moved = sum(p[\"n\"] for p in props if p[\"src\"] == c[\"src\"] and p[\"kind\"] == \"정비병\" and p[\"status\"] != \"반려\") + (c[\"n\"] if c[\"kind\"] == \"정비병\" else 0)\n", + " if c[\"donor_drop\"] > PARAM[\"max_donor_drop\"]:\n", + " ok, why = False, f\"제공 부대 가동률 {c['donor_drop']:.1f}%p 하락(한도 {PARAM['max_donor_drop']} 초과)\"\n", + " elif moved > PARAM[\"max_move\"]:\n", + " ok, why = False, f\"제공 부대 누적 이동 {moved}명(한도 {PARAM['max_move']}명 초과)\"\n", + " else:\n", + " ok, why = True, \"제공 부대 영향·누적 이동 허용 범위\"\n", + " props.append({\"id\": f\"P{len(props):02d}\", **c, \"status\": \"승인대기\" if ok else \"반려\", \"note\": why})\n", + "\n", + "\n", + "class App:\n", + " SCREENS = [\"입력\", \"분석\", \"검토\", \"결과\"]\n", + "\n", + " def __init__(self):\n", + " self.cur, self.flt, self.visited = \"입력\", None, [\"입력\"]\n", + "\n", + " def goto(self, s):\n", + " assert s in self.SCREENS\n", + " self.cur = s; self.visited.append(s); return self.render()\n", + "\n", + " def render(self):\n", + " if self.cur == \"입력\":\n", + " return [\"입력 화면: 장비 %d대 / 부대 %d곳 / 파라미터 %s\" % (len(fleet), len(UNITS), PARAM)]\n", + " if self.cur == \"분석\":\n", + " return [f\" {r.unit} 가동률 {r.avail:5.1f}% 재고 {r.stock:4.0f}% 충원 {r.fill:4.0f}% ({int(r.n)}대)\" + (\" <- 하위\" if r.avail <= base[\"avail\"].nsmallest(2).max() else \"\") for r in base.itertuples()]\n", + " if self.cur == \"검토\":\n", + " ps = [p for p in props if self.flt in (None, p[\"status\"])]\n", + " return [f\" {p['id']} {BADGE[p['status']]:8s} {p['kind']} {p['n']}단위 {p['src'][2:4]}->{p['dst'][2:4]} 전체 {p['gain']:+.2f}%p(수혜 {p['recv_gain']:+.1f}/제공 {-p['donor_drop']:+.1f}) | {p['note']}\" for p in ps]\n", + " ok = [p for p in props if p[\"status\"] == \"승인대기\"]\n", + " return [f\" 승인 대기 {len(ok)}건 시행 시 예상 전체 가동률 +{sum(p['gain'] for p in ok):.2f}%p (정비병 이동은 합산 근사)\", \" 최종 시행은 군수참모 결정(M7 워크플로우)\"]\n", + "\n", + "\n", + "app = App()\n", + "for s in App.SCREENS:\n", + " print(f\"\\n[화면 {App.SCREENS.index(s) + 1}: {s}]\")\n", + " for line in app.goto(s):\n", + " print(line)\n", + "app.flt = \"승인대기\"; n1 = len(app.goto(\"검토\")); app.flt = \"반려\"; n2 = len(app.goto(\"검토\")); app.flt = None\n", + "print(f\"\\n필터 시험: 승인대기 {n1}건, 반려 {n2}건, 전체 {len(app.goto('검토'))}건\")\n", + "nav = [(a, b) for a in App.SCREENS for b in App.SCREENS if a != b]\n", + "okn = 0\n", + "for a, b in nav:\n", + " app.cur = a; okn += bool(app.goto(b))\n", + "print(f\"화면 이동 시험 {okn}/{len(nav)} 성공, 핵심 화면 {len(App.SCREENS)}종 구현 (목표 4종: {'달성' if len(App.SCREENS) == 4 else '미달'})\")\n", + "tot = sum(p[\"gain\"] for p in props if p[\"status\"] == \"승인대기\")\n", + "print(f\"재배치 시뮬레이션: 승인대기 안 합산 예상 상승폭 +{tot:.2f}%p, 최대 단일 안 {max(c['gain'] for c in cands):+.2f}%p. 시뮬레이션은 가정 모델 기반 추정이며 시행 여부는 사람이 결정한다.\")\n", + "\n", + "plt.figure(figsize=(7, 4))\n", + "plt.bar([f\"{p['id']}\\n{ {'완료': 'done', '승인대기': 'pending', '반려': 'rejected'}[p['status']]}\" for p in props], [p[\"gain\"] for p in props], color=[\"tab:gray\" if p[\"status\"] == \"완료\" else \"tab:blue\" if p[\"status\"] == \"승인대기\" else \"tab:red\" for p in props])\n", + "plt.ylabel(\"fleet availability gain (%p)\"); plt.title(\"Reallocation proposals (gray done / blue pending / red rejected)\"); plt.tight_layout(); plt.show()\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 05 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + "[시뮬레이션 모델] 검증 R2 0.646 (재고·충원율 부족분의 제곱과 정비 요청 건수의 상호작용 반영)\n", + "후보 70건 평가 (제공 부대당 정비병 누적 최대 4명 이동, 제공 부대 충원율·재고 60% 이상 유지)\n", + "\n", + "[화면 1: 입력]\n", + "입력 화면: 장비 600대 / 부대 6곳 / 파라미터 {'max_move': 4, 'min_donor_fill': 60.0, 'stock_step': 8.0, 'top_n': 4, 'max_donor_drop': 3.5}\n", + "\n", + "[화면 2: 분석]\n", + " 가상A부대 가동률 78.3% 재고 50% 충원 76% (94대)\n", + " 가상B부대 가동률 75.0% 재고 57% 충원 63% (87대) <- 하위\n", + " 가상C부대 가동률 83.8% 재고 84% 충원 87% (99대)\n", + " 가상D부대 가동률 77.8% 재고 75% 충원 59% (115대) <- 하위\n", + " 가상E부대 가동률 80.8% 재고 50% 충원 72% (100대)\n", + " 가상F부대 가동률 79.9% 재고 66% 충원 78% (105대)\n", + "\n", + "[화면 3: 검토]\n", + " P00 [완료] 정비병 2단위 C부->D부 전체 +0.14%p(수혜 +1.5/제공 -0.9) | 전 분기 시행 완료(가상 이력)\n", + " P01 [반려] 정비병 4단위 C부->B부 전체 +0.11%p(수혜 +3.1/제공 -2.0) | 제공 부대 누적 이동 6명(한도 4명 초과)\n", + " P02 [승인대기] 부품 재고 4단위 C부->A부 전체 +0.06%p(수혜 +0.4/제공 -0.1) | 제공 부대 영향·누적 이동 허용 범위\n", + " P03 [승인대기] 정비병 2단위 C부->E부 전체 +0.05%p(수혜 +1.2/제공 -0.9) | 제공 부대 영향·누적 이동 허용 범위\n", + " P04 [반려] 정비병 2단위 C부->F부 전체 +0.04%p(수혜 +1.1/제공 -0.9) | 제공 부대 누적 이동 6명(한도 4명 초과)\n", + "\n", + "[화면 4: 결과]\n", + " 승인 대기 2건 시행 시 예상 전체 가동률 +0.10%p (정비병 이동은 합산 근사)\n", + " 최종 시행은 군수참모 결정(M7 워크플로우)\n", + "\n", + "필터 시험: 승인대기 2건, 반려 2건, 전체 5건\n", + "화면 이동 시험 12/12 성공, 핵심 화면 4종 구현 (목표 4종: 달성)\n", + "재배치 시뮬레이션: 승인대기 안 합산 예상 상승폭 +0.10%p, 최대 단일 안 +0.26%p. 시뮬레이션은 가정 모델 기반 추정이며 시행 여부는 사람이 결정한다.\n", + "```\n", + "
\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 06. 부대별/장비별 가동률 추세 및 가동률 저하 요인 분석 대시보드 구현\n", + "**소요시간**: 약 2.5시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "부대별/장비별 가동률 추세 및 저하 요인 분석 대시보드를 구현한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **부대별·장비 유형별 가동률 추세와 저하 요인을 보여 주는 대시보드를 만들고, 비정상 입력에도 중단되지 않도록 입력 검증기와 에러 핸들러를 갖춘다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **12개월 가상 추세로 부대별 월 기울기, 최근 3개월 평균, 저하 경고와 부대 x 유형 가동률, 대기 손실일 구성을 표와 그래프로 보여 준다 과정을 진행하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `src/utils/qlt04_validator.py 및 에러 핸들러` 형태의 결과물을 완성한다.\n", + "\n", + "### 🔍 평가 및 검증 기준 (PASS 조건)\n", + "#### 미션 판정\n", + "\n", + "**평가 구분:** 필수\n", + "\n", + "**핵심 평가영역:** 사용자에게 알기 쉬운 에러 메시지 및 조치 가이드 제공\n", + "\n", + "#### 미션 PASS 조건\n", + "\n", + "- 정량 기준: 예외 상황 발생 시 크래시(Crash) 0건, 에러 로그 기록률 100%\n", + "\n", + "\n", + "- 진행 기준: 예외 주입 테스트 10종 통과 및 감사 로그 파일 생성\n", + "\n", + "\n", + "- 검증 방법: 비정상 데이터(결측, 특수문자, 대용량) 스트레스 테스트\n", + "\n", + "\n", + "#### 채점자 판정 메모\n", + "\n", + "> 단순 코드 실행 여부뿐만 아니라 출력된 수치와 근거 자료가 군 실무 요구사항에 부합하는지 면밀히 확인한다.\n", + "\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "\"\"\"부대별·장비 유형별 12개월 가동률 추세와 저하 요인 구성을 텍스트 대시보드와 그래프로 보여 주고, 결측·특수문자·자료형 오류·대용량·빈 자료·열 누락 등 예외 13종을 주입해도 중단 없이 오류 격리·안내 메시지·오류 로그·재시도·롤백이 동작하는지 시험하는 입력 검증기와 에러 핸들러를 구현한다.\"\"\"\n", + "import copy\n", + "import re\n", + "import time\n", + "import numpy as np\n", + "import pandas as pd\n", + "import matplotlib.pyplot as plt\n", + "\n", + "# ---------------------------------------------------------------------------\n", + "# [교육용가상자료] 가상 장비 가동률·정비 대기 자료 생성기 (실제 부대·장비·제원·가동률 정보 없음)\n", + "# 장비 유형 A(궤도형)·B(화포형)·C(차륜형)은 추상 구분이며 실제 장비와 무관하다.\n", + "# 최근 90일 동안 장비 1대의 정비 대기 요인(부품·정비병·외주 정비·외주 검사·재작업)과 가동률을 만든다.\n", + "# ---------------------------------------------------------------------------\n", + "UNITS = [\"가상A부대\", \"가상B부대\", \"가상C부대\", \"가상D부대\", \"가상E부대\", \"가상F부대\"]\n", + "ETYPES = [\"유형A\", \"유형B\", \"유형C\"]\n", + "WINDOW = 90 # 관측 기간(일)\n", + "CAUSES = [\"부품 대기\", \"정비병 부족\", \"외주 정비\", \"검사·재작업\"]\n", + "CAUSE_COL = {\"부품 대기\": \"parts_wait_days\", \"정비병 부족\": \"labor_wait_days\", \"외주 정비\": \"outsource_days\", \"검사·재작업\": \"inspect_wait_days\"}\n", + "NUM_FIELDS = [\"age_years\", \"op_hours\", \"maint_orders\", \"parts_wait_days\", \"labor_wait_days\", \"outsource_days\", \"inspect_wait_days\", \"rework_count\",\n", + " \"spec_mismatch\", \"defect_reports\", \"parts_stock_pct\", \"mechanic_fill_pct\", \"avail_pct\"]\n", + "FEAT = [\"age_years\", \"maint_orders\", \"parts_stock_pct\", \"mechanic_fill_pct\", \"outsource_days\", \"inspect_wait_days\", \"rework_count\", \"spec_mismatch\", \"defect_reports\", \"etype_B\", \"etype_C\"]\n", + "FEAT_KO = {\"age_years\": \"장비 연령\", \"maint_orders\": \"정비 요청 건수\", \"parts_stock_pct\": \"부품 재고 가용률\", \"mechanic_fill_pct\": \"정비병 충원율\", \"outsource_days\": \"외주 정비 일수\",\n", + " \"inspect_wait_days\": \"외주 검사 대기 일수\", \"rework_count\": \"재작업 건수\", \"spec_mismatch\": \"규격·제원 불일치\", \"defect_reports\": \"하자·불만 접수\",\n", + " \"etype_B\": \"유형B 여부\", \"etype_C\": \"유형C 여부\"}\n", + "\n", + "\n", + "def unit_profile(seed=3):\n", + " \"\"\"부대별 고정 특성: 부품 재고 수준, 정비병 충원율, 외주 정비소 거리(일수 가중), 외주 검사 혼잡도.\"\"\"\n", + " r = np.random.default_rng(seed)\n", + " return pd.DataFrame({\"unit\": UNITS, \"stock\": r.uniform(45, 95, len(UNITS)), \"fill\": r.uniform(55, 98, len(UNITS)),\n", + " \"dist\": r.uniform(0.8, 1.6, len(UNITS)), \"queue\": r.uniform(0.7, 2.2, len(UNITS))})\n", + "\n", + "\n", + "def make_fleet(n=300, seed=7, shock=0.0):\n", + " \"\"\"장비 n대의 90일 관측 자료(필드 16개)와 정답 요인을 만든다. shock은 외주 검사 병목 악화 정도(AAR용 환경 변화).\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " pf = unit_profile()\n", + " ui = rng.integers(0, len(UNITS), n)\n", + " et = rng.choice(ETYPES, n, p=[.35, .3, .35])\n", + " age = np.clip(rng.gamma(4, 2.2, n), 0.5, 30).round(1)\n", + " eff = np.array([{\"유형A\": 1.15, \"유형B\": 1.0, \"유형C\": 0.9}[t] for t in et])\n", + " orders = rng.poisson(np.clip(1.2 + 0.12 * age, 0.5, 6) * eff)\n", + " stock = np.clip(pf[\"stock\"].to_numpy()[ui] + rng.normal(0, 8, n), 20, 100).round(0)\n", + " fill = np.clip(pf[\"fill\"].to_numpy()[ui] + rng.normal(0, 6, n), 30, 100).round(0)\n", + " parts_w = (orders * rng.gamma(2, 0.55 * (1 + ((100 - stock) / 28) ** 2) / 2)).round(0)\n", + " labor_w = (orders * rng.gamma(2, 0.7 * (1 + ((100 - fill) / 24) ** 2) / 2)).round(0)\n", + " n_out = rng.binomial(orders, 0.3)\n", + " out_d = (n_out * rng.gamma(3, 4.5 * pf[\"dist\"].to_numpy()[ui] / 3)).round(0)\n", + " insp_w = (n_out * rng.gamma(2, (2.6 + shock) * pf[\"queue\"].to_numpy()[ui] / 2)).round(0)\n", + " spec = rng.poisson(0.2 + 0.04 * age)\n", + " rework = rng.binomial(n_out, np.clip(0.08 + 0.1 * spec, 0, .6))\n", + " defect = rng.poisson(0.3 + 0.7 * rework)\n", + " base_rep = orders * 1.8\n", + " down = base_rep + parts_w + labor_w + out_d + insp_w + rework * 4 + rng.normal(0, 1.2, n)\n", + " comp = np.column_stack([parts_w, labor_w, out_d, insp_w + rework * 4])\n", + " df = pd.DataFrame({\"equip_id\": [f\"E-{i + 1:04d}\" for i in range(n)], \"unit\": np.array(UNITS)[ui], \"etype\": et, \"age_years\": age,\n", + " \"op_hours\": np.clip(rng.normal(320 - 4 * age, 70, n), 40, 600).round(0), \"maint_orders\": orders,\n", + " \"parts_wait_days\": parts_w, \"labor_wait_days\": labor_w, \"outsource_days\": out_d, \"inspect_wait_days\": insp_w,\n", + " \"rework_count\": rework, \"spec_mismatch\": spec, \"defect_reports\": defect, \"parts_stock_pct\": stock, \"mechanic_fill_pct\": fill,\n", + " \"avail_pct\": (100 * (1 - np.clip(down, 0, WINDOW) / WINDOW)).round(1)})\n", + " df[\"main_cause\"] = np.where(comp.max(axis=1) < 3, \"없음\", np.array(CAUSES)[comp.argmax(axis=1)])\n", + " return df\n", + "\n", + "\n", + "def feat_frame(df):\n", + " \"\"\"회귀·분석용 설명 변수 표(유형은 더미 변수).\"\"\"\n", + " x = df.copy()\n", + " x[\"etype_B\"], x[\"etype_C\"] = (x[\"etype\"] == \"유형B\").astype(int), (x[\"etype\"] == \"유형C\").astype(int)\n", + " return x[FEAT]\n", + "\n", + "\n", + "def make_trend(months=12, seed=5, shock_unit=None):\n", + " \"\"\"[교육용가상자료] 부대 x 유형 x 월 평균 가동률 추세. shock_unit 부대는 하반기에 외주 검사 병목으로 가동률이 떨어진다.\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " pf = unit_profile()\n", + " rows = []\n", + " for _, u in pf.iterrows():\n", + " base = 62 + 0.22 * u[\"stock\"] + 0.12 * u[\"fill\"] - 3.0 * u[\"queue\"]\n", + " for t in ETYPES:\n", + " off = {\"유형A\": -3.0, \"유형B\": 0.0, \"유형C\": 2.0}[t]\n", + " for m in range(months):\n", + " v = base + off + 2.0 * np.sin(m / 12 * 2 * np.pi) + rng.normal(0, 1.0)\n", + " if shock_unit == u[\"unit\"] and m >= months // 2:\n", + " v -= 1.5 * (m - months // 2 + 1)\n", + " rows.append((u[\"unit\"], t, m + 1, round(float(np.clip(v, 30, 99)), 1)))\n", + " return pd.DataFrame(rows, columns=[\"unit\", \"etype\", \"month\", \"avail_pct\"])\n", + "\n", + "fleet = make_fleet(300, seed=41)\n", + "trend = make_trend(12, seed=5, shock_unit=\"가상C부대\")\n", + "\n", + "# 1) 대시보드: 부대별 12개월 가동률 추세, 최근 3개월 평균, 월 기울기, 경고\n", + "SP = \"▁▂▃▄▅▆▇█\"\n", + "def spark(v):\n", + " lo, hi = min(v), max(v)\n", + " return \"\".join(SP[int((x - lo) / (hi - lo + 1e-9) * 7)] for x in v)\n", + "rows = []\n", + "for u, g in trend.groupby(\"unit\"):\n", + " m = g.groupby(\"month\")[\"avail_pct\"].mean()\n", + " slope = np.polyfit(m.index, m.values, 1)[0]\n", + " last3 = m.iloc[-3:].mean()\n", + " rows.append({\"부대\": u, \"추세(1~12월)\": spark(m.values), \"상반기\": m.iloc[:6].mean(), \"하반기\": m.iloc[6:].mean(), \"최근3개월\": last3, \"월 기울기(%p)\": slope,\n", + " \"경고\": \"저하 경고\" if slope < -0.8 or last3 < 70 else \"\"})\n", + "dash = pd.DataFrame(rows)\n", + "print(\"[대시보드 1: 부대별 가동률 추세(가상)]\")\n", + "print(dash.round(1).to_string(index=False))\n", + "t_et = trend[trend[\"month\"] > 9].groupby([\"unit\", \"etype\"])[\"avail_pct\"].mean().unstack().round(1)\n", + "print(\"\\n[대시보드 2: 최근 3개월 부대 x 장비 유형 가동률]\"); print(t_et.to_string())\n", + "lossc = [\"parts_wait_days\", \"labor_wait_days\", \"outsource_days\", \"inspect_wait_days\"]\n", + "loss = fleet.groupby(\"unit\")[lossc].mean().round(1)\n", + "loss.columns = [\"부품 대기\", \"정비병 부족\", \"외주 정비\", \"외주 검사 대기\"]\n", + "print(\"\\n[대시보드 3: 부대별 평균 대기 손실일(저하 요인 구성)]\")\n", + "print(pd.concat([loss, loss.idxmax(axis=1).rename(\"최대 요인\")], axis=1).to_string())\n", + "worst = fleet.nsmallest(3, \"avail_pct\")[[\"equip_id\", \"unit\", \"etype\", \"avail_pct\", \"main_cause\"]]\n", + "print(\"[가동률 최하위 장비 3대]\"); print(worst.to_string(index=False))\n", + "\n", + "# 2) 입력 검증기 + 에러 핸들러\n", + "LOG = []\n", + "GUIDE = {\"E101\": \"필수 열이 없습니다. 열 이름을 양식과 비교해 추가하세요.\", \"E102\": \"빈 자료입니다. 파일이 올바른지 확인하고 다시 올리세요.\", \"E201\": \"형식·범위 오류 행을 격리했습니다. 격리 목록을 확인해 원본을 수정하세요.\",\n", + " \"E202\": \"중복 장비 번호를 1건만 남겼습니다. 최신 기록이 맞는지 확인하세요.\", \"E301\": \"일시적 읽기 오류입니다. 자동 재시도 후에도 실패하면 잠시 뒤 다시 시도하세요.\",\n", + " \"E401\": \"처리 중 오류가 있어 변경을 취소(롤백)했습니다. 이전 화면 상태가 유지됩니다.\", \"E501\": \"자료 형식을 알 수 없습니다. 표 형식(열 이름 포함) 자료를 올리세요.\"}\n", + "\n", + "\n", + "def log(code, msg, level=\"ERROR\"):\n", + " LOG.append({\"seq\": len(LOG) + 1, \"level\": level, \"code\": code, \"msg\": msg, \"guide\": GUIDE[code]})\n", + "\n", + "\n", + "class UserError(Exception):\n", + " def __init__(self, code, msg):\n", + " super().__init__(msg); self.code = code\n", + "\n", + "\n", + "def validate_frame(df):\n", + " \"\"\"행 단위 검증: 형식(장비 번호), 허용값(부대), 범위·자료형(숫자), 중복. 불량 행은 격리하고 나머지는 계속 처리한다.\"\"\"\n", + " if not isinstance(df, pd.DataFrame):\n", + " raise UserError(\"E501\", f\"DataFrame이 아닌 입력({type(df).__name__})\")\n", + " if len(df) == 0:\n", + " raise UserError(\"E102\", \"행 0건\")\n", + " miss = [c for c in [\"equip_id\", \"unit\", \"etype\", \"avail_pct\", \"maint_orders\"] if c not in df.columns]\n", + " if miss:\n", + " raise UserError(\"E101\", f\"누락 열 {miss}\")\n", + " d = df.copy()\n", + " num = pd.to_numeric(d[\"avail_pct\"], errors=\"coerce\"), pd.to_numeric(d[\"maint_orders\"], errors=\"coerce\")\n", + " bad = (~d[\"equip_id\"].astype(str).str.match(r\"^E-\\d{4,6}$\")) | (~d[\"unit\"].isin(UNITS)) | (~d[\"etype\"].isin(ETYPES))\n", + " bad |= ~np.isfinite(num[0]) | (num[0] < 0) | (num[0] > 100) | num[1].isna() | (num[1] < 0)\n", + " q = d[bad]\n", + " if len(q):\n", + " log(\"E201\", f\"형식·범위 오류 {len(q)}행 격리(예: {str(q.iloc[0]['equip_id'])[:12]!r})\", \"WARN\")\n", + " d = d[~bad].copy()\n", + " d[\"avail_pct\"] = pd.to_numeric(d[\"avail_pct\"]); d[\"maint_orders\"] = pd.to_numeric(d[\"maint_orders\"])\n", + " dup = d.duplicated(\"equip_id\")\n", + " if dup.any():\n", + " log(\"E202\", f\"중복 장비 번호 {int(dup.sum())}건 정리\", \"WARN\")\n", + " return d[~dup], q\n", + "\n", + "\n", + "class Store:\n", + " \"\"\"화면 상태 저장소: 갱신은 사본에서 먼저 하고 모두 성공해야 반영(트랜잭션 롤백)한다.\"\"\"\n", + " def __init__(self):\n", + " self.state = {\"version\": 0, \"avg\": None}\n", + "\n", + " def commit(self, fn):\n", + " snap = copy.deepcopy(self.state)\n", + " try:\n", + " fn(self.state); self.state[\"version\"] += 1\n", + " return True\n", + " except Exception as e:\n", + " self.state = snap; log(\"E401\", f\"갱신 실패 {type(e).__name__}: {str(e)[:40]}\"); return False\n", + "\n", + "\n", + "STORE = Store()\n", + "FLAKY = {\"n\": 0}\n", + "\n", + "\n", + "def flaky_loader(df):\n", + " FLAKY[\"n\"] += 1\n", + " if FLAKY[\"n\"] <= 2:\n", + " raise IOError(\"temporary read error\")\n", + " return df\n", + "\n", + "\n", + "def run(df, loader=None, fail_update=False):\n", + " \"\"\"대시보드 갱신 1회. 어떤 입력에서도 예외를 밖으로 던지지 않고 (상태, 처리 행수)를 돌려준다.\"\"\"\n", + " try:\n", + " for attempt in range(3): # 재시도(최대 3회)\n", + " try:\n", + " src = loader(df) if loader else df; break\n", + " except IOError:\n", + " log(\"E301\", f\"읽기 오류 재시도 {attempt + 1}/3\", \"WARN\")\n", + " else:\n", + " raise UserError(\"E301\", \"재시도 3회 실패\")\n", + " clean, q = validate_frame(src)\n", + " def upd(s):\n", + " s[\"avg\"] = float(clean[\"avail_pct\"].mean())\n", + " if fail_update:\n", + " raise RuntimeError(\"render failure\")\n", + " ok = STORE.commit(upd)\n", + " return (\"처리\" if not len(q) else \"격리 후 처리\") if ok else \"롤백\", len(clean)\n", + " except UserError as e:\n", + " log(e.code, str(e)); return \"거부(안내)\", 0\n", + " except Exception as e: # 예상 못 한 오류도 중단하지 않고 기록\n", + " log(\"E501\", f\"{type(e).__name__}: {str(e)[:40]}\"); return \"거부(안내)\", 0\n", + "\n", + "\n", + "good = fleet[[\"equip_id\", \"unit\", \"etype\", \"avail_pct\", \"maint_orders\"]]\n", + "def mod(**kw):\n", + " d = good.copy()\n", + " for k, v in kw.items():\n", + " d[k] = d[k].astype(object); d.loc[d.index[:3], k] = v\n", + " return d\n", + "big = pd.DataFrame({\"equip_id\": [f\"E-{i:06d}\" for i in range(200000)], \"unit\": \"가상A부대\", \"etype\": \"유형A\",\n", + " \"avail_pct\": np.random.default_rng(1).uniform(40, 100, 200000), \"maint_orders\": 2})\n", + "big.loc[big.index[::1000], \"avail_pct\"] = np.nan\n", + "cases = [(\"가동률 결측(NaN)\", mod(avail_pct=np.nan)), (\"가동률 문자열 '많음'\", mod(avail_pct=\"많음\")), (\"가동률 음수·100 초과\", mod(avail_pct=-5)),\n", + " (\"장비 번호 특수문자\", mod(equip_id=\"E-00;DROP --\")), (\"가상에 없는 부대명\", mod(unit=\"미상부대\")), (\"무한대(inf) 값\", mod(avail_pct=np.inf)),\n", + " (\"중복 장비 번호\", pd.concat([good, good.head(5)])), (\"필수 열 누락\", good.drop(columns=[\"unit\"])), (\"빈 자료\", good.iloc[0:0]),\n", + " (\"표가 아닌 입력(None)\", None), (\"대용량 20만 행(결측 포함)\", big), (\"읽기 오류 2회 후 성공(재시도)\", good), (\"갱신 중 오류(롤백)\", good)]\n", + "print(\"\\n[예외 주입 시험]\")\n", + "crash = passed = 0\n", + "t0 = time.perf_counter()\n", + "for name, df in cases:\n", + " n0 = len(LOG)\n", + " try:\n", + " st, n = run(df, loader=flaky_loader if \"재시도\" in name else None, fail_update=\"롤백\" in name)\n", + " except Exception as e:\n", + " crash += 1; st, n = f\"CRASH {e}\", 0\n", + " logged = len(LOG) > n0\n", + " passed += (not st.startswith(\"CRASH\")) and logged\n", + " print(f\" {name:22s}-> {st:8s} {n:6d}행 남음 | 로그 {'기록' if logged else '없음'}\" + (f\" | {LOG[-1]['code']}: {LOG[-1]['guide'][:28]}\" if logged else \"\"))\n", + "dt = time.perf_counter() - t0\n", + "print(f\"예외 {len(cases)}종: 크래시 {crash}건(목표 0건: {'달성' if crash == 0 else '미달'}), 오류 로그 기록 {passed}/{len(cases)} ({passed / len(cases):.0%}, 목표 100%), 전체 {dt:.1f}초\")\n", + "print(f\"오류 로그 {len(LOG)}건 중 사용자 안내문 포함 {sum(bool(x['guide']) for x in LOG)}건, 상태 저장소 버전 {STORE.state['version']}(롤백 시 이전 값 유지), 평균 가동률 {STORE.state['avg']:.1f}%\")\n", + "print(\"로그 예시:\", LOG[0][\"code\"], LOG[0][\"msg\"], \"->\", LOG[0][\"guide\"])\n", + "print(\"※ 격리된 행의 수정과 경고 판단은 담당자가 확인한다. 로그는 메모리에 두며 실제 도구에서는 감사 로그 파일로 남긴다.\")\n", + "\n", + "fig, ax = plt.subplots(1, 2, figsize=(11, 4))\n", + "for u, g in trend.groupby(\"unit\"):\n", + " m = g.groupby(\"month\")[\"avail_pct\"].mean(); ax[0].plot(m.index, m.values, label='unit ' + u[2])\n", + "ax[0].set_title(\"Monthly availability by unit (synthetic)\"); ax[0].set_xlabel(\"month\"); ax[0].legend(ncol=3, fontsize=7)\n", + "loss.plot(kind=\"bar\", stacked=True, ax=ax[1], legend=False); ax[1].set_xticklabels([u[2] for u in loss.index], rotation=0); ax[1].set_title(\"Mean wait-loss days by cause\")\n", + "plt.tight_layout(); plt.show()\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 06 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + "[대시보드 1: 부대별 가동률 추세(가상)]\n", + " 부대 추세(1~12월) 상반기 하반기 최근3개월 월 기울기(%p) 경고\n", + "가상A부대 ▃▇▆▇▇▅▃▂▁▂▁▁ 77.3 74.8 74.6 -0.3 \n", + "가상B부대 ▃▅▆▅▇▄▂▂▁▁▂▁ 79.4 76.9 76.8 -0.3 \n", + "가상C부대 ▆▇▇▇▇▇▆▅▃▂▁▁ 89.4 81.9 79.1 -1.2 저하 경고\n", + "가상D부대 ▆▅▆▇▆▄▃▁▁▂▁▃ 80.2 77.6 77.7 -0.3 \n", + "가상E부대 ▃▅▇▆▆▇▃▁▁▁▂▂ 79.2 76.1 76.1 -0.3 \n", + "가상F부대 ▄▆▇▇▅▆▄▄▂▁▂▃ 83.7 81.0 80.6 -0.3 \n", + "\n", + "[대시보드 2: 최근 3개월 부대 x 장비 유형 가동률]\n", + "etype 유형A 유형B 유형C\n", + "unit \n", + "가상A부대 72.3 75.1 76.5\n", + "가상B부대 73.3 78.2 78.8\n", + "가상C부대 76.3 78.8 82.2\n", + "가상D부대 74.8 77.8 80.4\n", + "가상E부대 73.6 76.1 78.7\n", + "가상F부대 78.5 79.9 83.3\n", + "\n", + "[대시보드 3: 부대별 평균 대기 손실일(저하 요인 구성)]\n", + " 부품 대기 정비병 부족 외주 정비 외주 검사 대기 최대 요인\n", + "unit \n", + "가상A부대 5.0 3.5 3.3 2.2 부품 대기\n", + "가상B부대 4.3 5.7 3.2 2.1 정비병 부족\n", + "가상C부대 2.1 2.0 4.0 1.3 외주 정비\n", + "가상D부대 2.5 5.1 5.3 3.5 외주 정비\n", + "가상E부대 5.5 4.2 2.6 1.2 부품 대기\n", + "가상F부대 2.7 2.4 4.9 1.7 외주 정비\n", + "[가동률 최하위 장비 3대]\n", + "equip_id unit etype avail_pct main_cause\n", + " E-0113 가상D부대 유형A 17.5 외주 정비\n", + " E-0165 가상D부대 유형A 26.3 검사·재작업\n", + " E-0283 가상A부대 유형A 30.2 외주 정비\n", + "\n", + "[예외 주입 시험]\n", + " 가동률 결측(NaN) -> 격리 후 처리 297행 남음 | 로그 기록 | E201: 형식·범위 오류 행을 격리했습니다. 격리 목록을 확\n", + " 가동률 문자열 '많음' -> 격리 후 처리 297행 남음 | 로그 기록 | E201: 형식·범위 오류 행을 격리했습니다. 격리 목록을 확\n", + " 가동률 음수·100 초과 -> 격리 후 처리 297행 남음 | 로그 기록 | E201: 형식·범위 오류 행을 격리했습니다. 격리 목록을 확\n", + " 장비 번호 특수문자 -> 격리 후 처리 297행 남음 | 로그 기록 | E201: 형식·범위 오류 행을 격리했습니다. 격리 목록을 확\n", + " 가상에 없는 부대명 -> 격리 후 처리 297행 남음 | 로그 기록 | E201: 형식·범위 오류 행을 격리했습니다. 격리 목록을 확\n", + "... (이하 생략)\n", + "```\n", + "
\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 07. 정비 자원 우선 투입 결정을 위한 군수참모처 HITL 정책 결정 모듈 설계\n", + "**소요시간**: 약 3.0시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "정비 자원 우선 투입 결정을 위한 군수참모처 HITL 정책 결정 모듈을 설계한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **정비 자원 우선 투입 안을 정비담당-정비관-품질검사관-군수참모 순서로 사람이 검토·결정하는 HITL 워크플로우를 설계하고 권한 통제와 이력 무결성을 검증한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **역할별 권한(RBAC)과 단계 순서를 정의하고 시스템은 제안만, 확정은 사람만 하도록 작성하여 원인을 분석하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `src/workflow/qlt04_approval_system.py` 형태의 결과물을 완성한다.\n", + "\n", + "### 🔍 평가 및 검증 기준 (PASS 조건)\n", + "#### 미션 판정\n", + "\n", + "**평가 구분:** 필수\n", + "\n", + "**핵심 평가영역:** 군 보안 규정 및 행정 절차 부합성\n", + "\n", + "#### 미션 PASS 조건\n", + "\n", + "- 정량 기준: 이력 기록 무결성 100%, 승인 권한 우회 시도 차단율 100%\n", + "\n", + "\n", + "- 진행 기준: 결재선 승인/반려 시뮬레이션 및 이력 조회 화면 구현\n", + "\n", + "\n", + "- 검증 방법: 권한별 접근 제어(RBAC) 테스트\n", + "\n", + "\n", + "#### 채점자 판정 메모\n", + "\n", + "> 단순 코드 실행 여부뿐만 아니라 출력된 수치와 근거 자료가 군 실무 요구사항에 부합하는지 면밀히 확인한다.\n", + "\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "\"\"\"저가동 장비의 정비 자원 우선 투입안을 정비담당 상신 -> 정비관 검토 -> 품질검사관 검토 -> 군수참모 결재의 HITL 워크플로우로 처리하되, 시스템 자동 확정 금지·반려 사유 필수화·재기안 연계·권한 우회 차단(RBAC)과 해시 체인 감사 로그의 이력 무결성·위변조 탐지를 검증한다.\"\"\"\n", + "import hashlib\n", + "import json\n", + "import numpy as np\n", + "import pandas as pd\n", + "import matplotlib.pyplot as plt\n", + "\n", + "# ---------------------------------------------------------------------------\n", + "# [교육용가상자료] 가상 장비 가동률·정비 대기 자료 생성기 (실제 부대·장비·제원·가동률 정보 없음)\n", + "# 장비 유형 A(궤도형)·B(화포형)·C(차륜형)은 추상 구분이며 실제 장비와 무관하다.\n", + "# 최근 90일 동안 장비 1대의 정비 대기 요인(부품·정비병·외주 정비·외주 검사·재작업)과 가동률을 만든다.\n", + "# ---------------------------------------------------------------------------\n", + "UNITS = [\"가상A부대\", \"가상B부대\", \"가상C부대\", \"가상D부대\", \"가상E부대\", \"가상F부대\"]\n", + "ETYPES = [\"유형A\", \"유형B\", \"유형C\"]\n", + "WINDOW = 90 # 관측 기간(일)\n", + "CAUSES = [\"부품 대기\", \"정비병 부족\", \"외주 정비\", \"검사·재작업\"]\n", + "CAUSE_COL = {\"부품 대기\": \"parts_wait_days\", \"정비병 부족\": \"labor_wait_days\", \"외주 정비\": \"outsource_days\", \"검사·재작업\": \"inspect_wait_days\"}\n", + "NUM_FIELDS = [\"age_years\", \"op_hours\", \"maint_orders\", \"parts_wait_days\", \"labor_wait_days\", \"outsource_days\", \"inspect_wait_days\", \"rework_count\",\n", + " \"spec_mismatch\", \"defect_reports\", \"parts_stock_pct\", \"mechanic_fill_pct\", \"avail_pct\"]\n", + "FEAT = [\"age_years\", \"maint_orders\", \"parts_stock_pct\", \"mechanic_fill_pct\", \"outsource_days\", \"inspect_wait_days\", \"rework_count\", \"spec_mismatch\", \"defect_reports\", \"etype_B\", \"etype_C\"]\n", + "FEAT_KO = {\"age_years\": \"장비 연령\", \"maint_orders\": \"정비 요청 건수\", \"parts_stock_pct\": \"부품 재고 가용률\", \"mechanic_fill_pct\": \"정비병 충원율\", \"outsource_days\": \"외주 정비 일수\",\n", + " \"inspect_wait_days\": \"외주 검사 대기 일수\", \"rework_count\": \"재작업 건수\", \"spec_mismatch\": \"규격·제원 불일치\", \"defect_reports\": \"하자·불만 접수\",\n", + " \"etype_B\": \"유형B 여부\", \"etype_C\": \"유형C 여부\"}\n", + "\n", + "\n", + "def unit_profile(seed=3):\n", + " \"\"\"부대별 고정 특성: 부품 재고 수준, 정비병 충원율, 외주 정비소 거리(일수 가중), 외주 검사 혼잡도.\"\"\"\n", + " r = np.random.default_rng(seed)\n", + " return pd.DataFrame({\"unit\": UNITS, \"stock\": r.uniform(45, 95, len(UNITS)), \"fill\": r.uniform(55, 98, len(UNITS)),\n", + " \"dist\": r.uniform(0.8, 1.6, len(UNITS)), \"queue\": r.uniform(0.7, 2.2, len(UNITS))})\n", + "\n", + "\n", + "def make_fleet(n=300, seed=7, shock=0.0):\n", + " \"\"\"장비 n대의 90일 관측 자료(필드 16개)와 정답 요인을 만든다. shock은 외주 검사 병목 악화 정도(AAR용 환경 변화).\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " pf = unit_profile()\n", + " ui = rng.integers(0, len(UNITS), n)\n", + " et = rng.choice(ETYPES, n, p=[.35, .3, .35])\n", + " age = np.clip(rng.gamma(4, 2.2, n), 0.5, 30).round(1)\n", + " eff = np.array([{\"유형A\": 1.15, \"유형B\": 1.0, \"유형C\": 0.9}[t] for t in et])\n", + " orders = rng.poisson(np.clip(1.2 + 0.12 * age, 0.5, 6) * eff)\n", + " stock = np.clip(pf[\"stock\"].to_numpy()[ui] + rng.normal(0, 8, n), 20, 100).round(0)\n", + " fill = np.clip(pf[\"fill\"].to_numpy()[ui] + rng.normal(0, 6, n), 30, 100).round(0)\n", + " parts_w = (orders * rng.gamma(2, 0.55 * (1 + ((100 - stock) / 28) ** 2) / 2)).round(0)\n", + " labor_w = (orders * rng.gamma(2, 0.7 * (1 + ((100 - fill) / 24) ** 2) / 2)).round(0)\n", + " n_out = rng.binomial(orders, 0.3)\n", + " out_d = (n_out * rng.gamma(3, 4.5 * pf[\"dist\"].to_numpy()[ui] / 3)).round(0)\n", + " insp_w = (n_out * rng.gamma(2, (2.6 + shock) * pf[\"queue\"].to_numpy()[ui] / 2)).round(0)\n", + " spec = rng.poisson(0.2 + 0.04 * age)\n", + " rework = rng.binomial(n_out, np.clip(0.08 + 0.1 * spec, 0, .6))\n", + " defect = rng.poisson(0.3 + 0.7 * rework)\n", + " base_rep = orders * 1.8\n", + " down = base_rep + parts_w + labor_w + out_d + insp_w + rework * 4 + rng.normal(0, 1.2, n)\n", + " comp = np.column_stack([parts_w, labor_w, out_d, insp_w + rework * 4])\n", + " df = pd.DataFrame({\"equip_id\": [f\"E-{i + 1:04d}\" for i in range(n)], \"unit\": np.array(UNITS)[ui], \"etype\": et, \"age_years\": age,\n", + " \"op_hours\": np.clip(rng.normal(320 - 4 * age, 70, n), 40, 600).round(0), \"maint_orders\": orders,\n", + " \"parts_wait_days\": parts_w, \"labor_wait_days\": labor_w, \"outsource_days\": out_d, \"inspect_wait_days\": insp_w,\n", + " \"rework_count\": rework, \"spec_mismatch\": spec, \"defect_reports\": defect, \"parts_stock_pct\": stock, \"mechanic_fill_pct\": fill,\n", + " \"avail_pct\": (100 * (1 - np.clip(down, 0, WINDOW) / WINDOW)).round(1)})\n", + " df[\"main_cause\"] = np.where(comp.max(axis=1) < 3, \"없음\", np.array(CAUSES)[comp.argmax(axis=1)])\n", + " return df\n", + "\n", + "\n", + "def feat_frame(df):\n", + " \"\"\"회귀·분석용 설명 변수 표(유형은 더미 변수).\"\"\"\n", + " x = df.copy()\n", + " x[\"etype_B\"], x[\"etype_C\"] = (x[\"etype\"] == \"유형B\").astype(int), (x[\"etype\"] == \"유형C\").astype(int)\n", + " return x[FEAT]\n", + "\n", + "\n", + "def make_trend(months=12, seed=5, shock_unit=None):\n", + " \"\"\"[교육용가상자료] 부대 x 유형 x 월 평균 가동률 추세. shock_unit 부대는 하반기에 외주 검사 병목으로 가동률이 떨어진다.\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " pf = unit_profile()\n", + " rows = []\n", + " for _, u in pf.iterrows():\n", + " base = 62 + 0.22 * u[\"stock\"] + 0.12 * u[\"fill\"] - 3.0 * u[\"queue\"]\n", + " for t in ETYPES:\n", + " off = {\"유형A\": -3.0, \"유형B\": 0.0, \"유형C\": 2.0}[t]\n", + " for m in range(months):\n", + " v = base + off + 2.0 * np.sin(m / 12 * 2 * np.pi) + rng.normal(0, 1.0)\n", + " if shock_unit == u[\"unit\"] and m >= months // 2:\n", + " v -= 1.5 * (m - months // 2 + 1)\n", + " rows.append((u[\"unit\"], t, m + 1, round(float(np.clip(v, 30, 99)), 1)))\n", + " return pd.DataFrame(rows, columns=[\"unit\", \"etype\", \"month\", \"avail_pct\"])\n", + "\n", + "# ---- 정비·검사 병목 판별 룰 설정(YAML 형식 문자열: 기준이 바뀌면 코드가 아니라 이 설정만 고친다) ----\n", + "# clause는 룰과 대응시킨 '가상 품질지침 조항' 예시이며 실제 규정 원문이 아니다.\n", + "RULES_YAML = \"\"\"\n", + "- {id: Q01, name: 부품 재고 가용률 50% 미만, field: parts_stock_pct, op: \"<\", value: 50, weight: 2, clause: 가상품질지침 제3조}\n", + "- {id: Q02, name: 정비병 충원율 60% 미만, field: mechanic_fill_pct, op: \"<\", value: 60, weight: 2, clause: 가상품질지침 제4조}\n", + "- {id: Q03, name: 부품 대기 8일 초과, field: parts_wait_days, op: \">\", value: 8, weight: 2, clause: 가상품질지침 제5조}\n", + "- {id: Q04, name: 정비병 부족 대기 8일 초과, field: labor_wait_days, op: \">\", value: 8, weight: 2, clause: 가상품질지침 제6조}\n", + "- {id: Q05, name: 외주 정비 15일 초과, field: outsource_days, op: \">\", value: 15, weight: 2, clause: 가상품질지침 제7조}\n", + "- {id: Q06, name: 외주 검사 대기 6일 초과, field: inspect_wait_days, op: \">\", value: 6, weight: 3, clause: 가상품질지침 제8조}\n", + "- {id: Q07, name: 재작업 1건 이상, field: rework_count, op: \">=\", value: 1, weight: 3, clause: 가상품질지침 제9조}\n", + "- {id: Q08, name: 재작업률 25% 초과, field: rework_rate, op: \">\", value: 0.25, weight: 2, clause: 가상품질지침 제9조}\n", + "- {id: Q09, name: 규격·제원 불일치 3건 이상, field: spec_mismatch, op: \">=\", value: 3, weight: 3, clause: 가상품질지침 제10조}\n", + "- {id: Q10, name: 하자·불만 3건 이상, field: defect_reports, op: \">=\", value: 3, weight: 2, clause: 가상품질지침 제11조}\n", + "- {id: Q11, name: 장비 연령 15년 초과, field: age_years, op: \">\", value: 15, weight: 1, clause: 가상품질지침 제12조}\n", + "- {id: Q12, name: 정비 요청 6건 이상, field: maint_orders, op: \">=\", value: 6, weight: 1, clause: 가상품질지침 제13조}\n", + "- {id: Q13, name: 대기 일수 합계 30일 초과, field: total_wait, op: \">\", value: 30, weight: 3, clause: 가상품질지침 제14조}\n", + "- {id: Q14, name: 검사 대기가 외주 정비 일수의 80% 초과, field: insp_ratio, op: \">\", value: 0.8, weight: 2, clause: 가상품질지침 제8조}\n", + "- {id: Q15, name: 규격 불일치 2건 이상과 하자 접수 동반, field: spec_defect, op: \"==\", value: true, weight: 3, clause: 가상품질지침 제10조}\n", + "- {id: Q16, name: 최근 90일 가동 시간 150시간 미만, field: op_hours, op: \"<\", value: 150, weight: 1, clause: 가상품질지침 제15조}\n", + "\"\"\"\n", + "GRADE_CUT = [(10, \"고위험\"), (6, \"주의\"), (3, \"관심\")]\n", + "OPS = {\"<\": lambda a, b: a < b, \">\": lambda a, b: a > b, \"<=\": lambda a, b: a <= b, \">=\": lambda a, b: a >= b, \"==\": lambda a, b: a == b}\n", + "\n", + "\n", + "def parse_rules(text):\n", + " \"\"\"YAML 한 줄 맵({key: value, ...}) 형식 룰 문자열을 읽는다(외부 yaml 라이브러리 없이).\"\"\"\n", + " out = []\n", + " for line in text.strip().splitlines():\n", + " body = line.strip()[3:-1]\n", + " r = {}\n", + " for part in body.split(\", \"):\n", + " k, v = part.split(\": \", 1)\n", + " v = v.strip().strip('\"')\n", + " r[k] = True if v == \"true\" else False if v == \"false\" else (float(v) if v.replace(\".\", \"\", 1).isdigit() else v)\n", + " out.append(r)\n", + " return out\n", + "\n", + "\n", + "def derive_rec(rec):\n", + " \"\"\"룰이 쓰는 파생 필드(대기 합계, 재작업률, 검사 대기 비율(외주 5일 이상일 때), 규격 불일치 2건 이상+하자 동반)를 추가한다.\"\"\"\n", + " r = dict(rec)\n", + " r[\"total_wait\"] = r[\"parts_wait_days\"] + r[\"labor_wait_days\"] + r[\"outsource_days\"] + r[\"inspect_wait_days\"]\n", + " r[\"rework_rate\"] = r[\"rework_count\"] / r[\"maint_orders\"] if r[\"maint_orders\"] else 0.0\n", + " r[\"insp_ratio\"] = r[\"inspect_wait_days\"] / r[\"outsource_days\"] if r[\"outsource_days\"] >= 5 else 0.0\n", + " r[\"spec_defect\"] = bool(r[\"spec_mismatch\"] >= 2 and r[\"defect_reports\"] >= 1)\n", + " return r\n", + "\n", + "\n", + "def apply_rules(rec, rules, derived=False):\n", + " \"\"\"룰 적용: (점수, 적중 룰 id 목록). derived=True면 이미 파생 필드가 있는 레코드(경계 시험용)로 본다.\"\"\"\n", + " r = rec if derived else derive_rec(rec)\n", + " hits = [x[\"id\"] for x in rules if OPS[x[\"op\"]](r[x[\"field\"]], x[\"value\"])]\n", + " w = {x[\"id\"]: x[\"weight\"] for x in rules}\n", + " return sum(w[h] for h in hits), hits\n", + "\n", + "\n", + "def grade_of(score):\n", + " for cut, g in GRADE_CUT:\n", + " if score >= cut:\n", + " return g\n", + " return \"정상\"\n", + "\n", + "# ---- 가동률 회귀 모델과 저가동 요인 분류·추천 모델(외부 AI 서비스 없이 scikit-learn 사용) ----\n", + "from sklearn.linear_model import Ridge, LogisticRegression\n", + "from sklearn.preprocessing import StandardScaler\n", + "from sklearn.pipeline import make_pipeline\n", + "\n", + "CAUSE_COLS = [\"age_years\", \"maint_orders\", \"parts_stock_pct\", \"mechanic_fill_pct\", \"outsource_days\", \"inspect_wait_days\", \"rework_count\", \"spec_mismatch\",\n", + " \"defect_reports\", \"parts_wait_days\", \"labor_wait_days\"]\n", + "ACTION = {\"부품 대기\": \"부품 사전 청구·재고 보강\", \"정비병 부족\": \"정비병 재배치·교육 일정 조정\", \"외주 정비\": \"외주 정비 일정 조율·내부 정비 확대\", \"검사·재작업\": \"검사 일정 조율·규격 사전 대조\"}\n", + "\n", + "\n", + "def cause_frame(df):\n", + " x = df[CAUSE_COLS].copy()\n", + " x[\"out_insp\"] = x[\"outsource_days\"] + x[\"inspect_wait_days\"] + 4 * x[\"rework_count\"]\n", + " x[\"wait_pm\"] = (x[\"parts_wait_days\"] + x[\"labor_wait_days\"]) / (x[\"maint_orders\"] + 1)\n", + " return x\n", + "\n", + "\n", + "def fit_avail(df, alpha=1.0):\n", + " \"\"\"가동률(%) 회귀: 표준화 + 릿지 회귀.\"\"\"\n", + " return make_pipeline(StandardScaler(), Ridge(alpha=alpha)).fit(feat_frame(df), df[\"avail_pct\"])\n", + "\n", + "\n", + "def fit_cause(df, cut=85, C=5):\n", + " \"\"\"가동률이 cut% 미만인 장비의 주요 저가동 요인(4종)을 분류한다.\"\"\"\n", + " low = df[(df[\"avail_pct\"] < cut) & (df[\"main_cause\"] != \"없음\")]\n", + " return make_pipeline(StandardScaler(), LogisticRegression(C=C, max_iter=3000)).fit(cause_frame(low), low[\"main_cause\"])\n", + "\n", + "\n", + "SIM_FEAT = [\"age_years\", \"maint_orders\", \"spec_mismatch\", \"stock_gap_x_orders\", \"fill_gap_x_orders\", \"parts_stock_pct\", \"mechanic_fill_pct\", \"etype_B\", \"etype_C\"]\n", + "\n", + "\n", + "def sim_frame(df):\n", + " \"\"\"자원 재배치 시뮬레이션용 설명 변수: 부족분이 클수록 대기가 더 빠르게 늘고 정비 요청 건수와 곱해져 커진다는 구조를 제곱·상호작용 항으로 둔다.\"\"\"\n", + " x = df.copy()\n", + " x[\"etype_B\"], x[\"etype_C\"] = (x[\"etype\"] == \"유형B\").astype(int), (x[\"etype\"] == \"유형C\").astype(int)\n", + " x[\"stock_gap_x_orders\"] = x[\"maint_orders\"] * ((100 - x[\"parts_stock_pct\"]) / 28) ** 2\n", + " x[\"fill_gap_x_orders\"] = x[\"maint_orders\"] * ((100 - x[\"mechanic_fill_pct\"]) / 24) ** 2\n", + " return x[SIM_FEAT]\n", + "\n", + "\n", + "def fit_sim(df, alpha=0.5):\n", + " return make_pipeline(StandardScaler(), Ridge(alpha=alpha)).fit(sim_frame(df), df[\"avail_pct\"])\n", + "\n", + "# ---- 역할과 권한(최소 권한 원칙): 시스템은 '제안'만 하고 확정 권한이 없다 ----\n", + "PERM = {\"시스템\": {\"propose\"}, \"정비담당\": {\"submit\", \"redraft\", \"view\"}, \"정비관\": {\"review_maint\", \"reject\", \"view\"}, \"품질검사관\": {\"review_quality\", \"reject\", \"view\"},\n", + " \"군수참모\": {\"approve\", \"reject\", \"view\"}, \"감사관\": {\"view\", \"audit\"}, \"부서장\": {\"submit\", \"approve\", \"reject\", \"view\"}} # 부서장은 상신·결재를 겸할 수 있으나 같은 건은 분리\n", + "NEXT = {\"제안\": (\"submit\", \"상신\"), \"상신\": (\"review_maint\", \"정비관검토\"), \"정비관검토\": (\"review_quality\", \"품질검사\"), \"품질검사\": (\"approve\", \"승인\")}\n", + "USERS = {\"sys\": \"시스템\", \"kim\": \"정비담당\", \"lee\": \"정비관\", \"park\": \"품질검사관\", \"choi\": \"군수참모\", \"jung\": \"감사관\", \"oh\": \"부서장\"}\n", + "MAX_REDRAFT = 2\n", + "\n", + "\n", + "class Chain:\n", + " \"\"\"해시 체인 감사 로그: 각 기록이 직전 기록의 해시를 포함해 중간 수정이 드러난다.\"\"\"\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", + "class Workflow:\n", + " def __init__(self):\n", + " self.audit, self.cases, self.blocked = Chain(), {}, []\n", + "\n", + " def _deny(self, who, case, action, why):\n", + " self.audit.add(event=\"DENIED\", who=who, case=case, action=action, reason=why)\n", + " self.blocked.append((who, case, action, why)); return False, why\n", + "\n", + " def act(self, who, case, action, reason=\"\", evidence=\"\"):\n", + " role = USERS.get(who)\n", + " if role is None:\n", + " return self._deny(who, case, action, \"미등록 사용자\")\n", + " if action == \"delete_log\":\n", + " return self._deny(who, case, action, \"감사 로그 삭제·수정 권한은 누구에게도 없음\")\n", + " if action not in PERM[role]:\n", + " return self._deny(who, case, action, f\"권한 없음({role} 역할에 {action} 권한 없음)\")\n", + " if action == \"propose\":\n", + " self.cases[case] = {\"state\": \"제안\", \"submitter\": None, \"version\": 1}\n", + " self.audit.add(event=\"PROPOSE\", who=who, case=case, reason=reason); return True, \"제안 등록\"\n", + " c = self.cases.get(case)\n", + " if c is None:\n", + " return self._deny(who, case, action, \"존재하지 않는 건\")\n", + " if c[\"state\"] in (\"승인\", \"종결(반려)\"):\n", + " return self._deny(who, case, action, f\"이미 종결된 건({c['state']})\")\n", + " if not reason.strip() or not evidence.strip():\n", + " return self._deny(who, case, action, \"사유·근거 입력 필수(반려 사유 포함)\")\n", + " if action == \"reject\":\n", + " if c[\"state\"] in (\"제안\", \"반려\"):\n", + " return self._deny(who, case, action, f\"반려할 수 없는 단계({c['state']})\")\n", + " c[\"state\"] = \"종결(반려)\" if c[\"version\"] > MAX_REDRAFT else \"반려\"\n", + " self.audit.add(event=\"REJECTED\", who=who, role=role, case=case, version=c[\"version\"], reason=reason, evidence=evidence); return True, c[\"state\"]\n", + " if action == \"redraft\":\n", + " if c[\"state\"] != \"반려\":\n", + " return self._deny(who, case, action, f\"재기안은 반려된 건만 가능(현재 {c['state']})\")\n", + " if who != c[\"submitter\"]:\n", + " return self._deny(who, case, action, \"재기안은 최초 상신자만 가능\")\n", + " c[\"state\"], c[\"version\"] = \"상신\", c[\"version\"] + 1\n", + " self.audit.add(event=\"REDRAFT\", who=who, role=role, case=case, version=c[\"version\"], reason=reason, evidence=evidence); return True, \"재기안 상신\"\n", + " if c[\"state\"] == \"반려\":\n", + " return self._deny(who, case, action, \"반려된 건은 재기안 후에만 진행 가능\")\n", + " need, nxt = NEXT[c[\"state\"]]\n", + " if action != need:\n", + " return self._deny(who, case, action, f\"단계 위반(현재 {c['state']}, 필요 행위 {need})\")\n", + " if action == \"submit\":\n", + " c[\"submitter\"] = who\n", + " if action == \"approve\" and who == c[\"submitter\"]:\n", + " return self._deny(who, case, action, \"상신자와 승인자 분리 원칙 위반\")\n", + " c[\"state\"] = nxt\n", + " self.audit.add(event=action.upper(), who=who, role=role, case=case, version=c[\"version\"], to=nxt, reason=reason, evidence=evidence); return True, nxt\n", + "\n", + "\n", + "# ---- 상신 대상: 룰 점수가 높은 장비 12대와 모델이 추천한 개선 조치 ----\n", + "fleet = make_fleet(300, seed=7)\n", + "rules = parse_rules(RULES_YAML)\n", + "sc = [apply_rules(r, rules) for r in fleet.drop(columns=[\"main_cause\"]).to_dict(\"records\")]\n", + "fleet[\"score\"], fleet[\"hits\"] = [x[0] for x in sc], [x[1] for x in sc]\n", + "clf = fit_cause(make_fleet(1200, seed=21))\n", + "picked = fleet.sort_values(\"score\", ascending=False).head(12)\n", + "rec_cause = clf.predict(cause_frame(picked))\n", + "rng = np.random.default_rng(1)\n", + "QUALITY = {\"Q06\", \"Q07\", \"Q08\", \"Q09\", \"Q10\", \"Q14\", \"Q15\"} # 품질검사관이 보는 항목(검사 대기·재작업·규격 불일치·하자)\n", + "\n", + "wf = Workflow()\n", + "rows = []\n", + "for k, ((_, p), cause) in enumerate(zip(picked.iterrows(), rec_cause), 1):\n", + " case = f\"C{k:02d}\"\n", + " wf.act(\"sys\", case, \"propose\", reason=f\"{p['equip_id']} 룰 점수 {p['score']:.0f}, 추천 요인 {cause} -> {ACTION[cause]}\")\n", + " wf.act(\"kim\", case, \"submit\", \"정비 자원 우선 투입 검토 요청\", f\"룰 적중 {','.join(p['hits'][:4])}, 추천 조치 {ACTION[cause]}\")\n", + " q_issue = sorted(QUALITY & set(p[\"hits\"])) if len(QUALITY & set(p[\"hits\"])) >= 2 else [] # 품질검사관이 볼 문제\n", + " m_issue = (p[\"avail_pct\"] < 40 and cause == \"정비병 부족\") # 정비관이 볼 문제: 인력 이동 시 제공 부대 영향 소명 필요\n", + " note = \"군수참모 결정\"\n", + " for _ in range(14):\n", + " state = wf.cases[case][\"state\"]\n", + " if state in (\"승인\", \"종결(반려)\"):\n", + " break\n", + " if state == \"상신\":\n", + " if m_issue:\n", + " wf.act(\"lee\", case, \"reject\", \"인력 이동 안은 제공 부대 영향 소명 필요\", f\"가동률 {p['avail_pct']}%, 요인 {cause}\")\n", + " else:\n", + " wf.act(\"lee\", case, \"review_maint\", \"정비 능력·일정 검토 완료\", f\"추천 조치 {ACTION[cause]} 수행 가능\")\n", + " elif state == \"정비관검토\":\n", + " if rng.random() < 0.15:\n", + " note = \"자동 확정 없음: 품질검사 응답 대기\"; break # 무응답은 보류로 둔다\n", + " if q_issue:\n", + " wf.act(\"park\", case, \"reject\", f\"규격·검사 조건 보완 필요({','.join(q_issue)})\", \"재작업·규격 불일치·검사 대기 항목 점검\")\n", + " else:\n", + " wf.act(\"park\", case, \"review_quality\", \"품질검사 검토 완료\", \"규격·검사 항목 이상 없음 또는 보완 확인\")\n", + " elif state == \"품질검사\":\n", + " wf.act(\"choi\", case, \"approve\", \"승인: 자원 우선 투입 진행\", f\"룰 점수 {p['score']:.0f}, 검토 단계 완료\")\n", + " elif state == \"반려\":\n", + " wf.act(\"kim\", case, \"redraft\", \"반려 사유 반영해 수정\", f\"보완 대상 {q_issue or ['제공 부대 영향 소명']}, 재기안 {wf.cases[case]['version']}차\")\n", + " m_issue = False # 제공 부대 영향 소명서를 첨부(모의)\n", + " if rng.random() < 0.65: # 재기안 후 보완이 끝날 확률(모의)\n", + " q_issue = []\n", + " final = wf.cases[case][\"state\"]\n", + " if final not in (\"승인\", \"종결(반려)\") and note.startswith(\"자동\"):\n", + " final = \"보류(무응답)\"\n", + " elif final == \"종결(반려)\":\n", + " note = \"재기안 한도(2회) 초과\"\n", + " rows.append((case, p[\"equip_id\"], final, wf.cases[case][\"version\"], note))\n", + "print(\"[결재 결과] 12건 (모의 결재자 기준, 실제 결재는 사람이 한다)\")\n", + "print(pd.DataFrame(rows, columns=[\"건\", \"장비\", \"최종 상태\", \"버전\", \"비고\"]).to_string(index=False))\n", + "st = pd.Series([r[2] for r in rows]).value_counts().to_dict()\n", + "print(\"상태 집계:\", st, f\"/ 재기안 {sum(e['event'] == 'REDRAFT' for e in wf.audit.log)}회, 반려 이력 {sum(e['event'] == 'REJECTED' for e in wf.audit.log)}건(모두 사유·근거 기록)\")\n", + "\n", + "# ---- 권한 우회·절차 위반 시도 ----\n", + "wf.act(\"sys\", \"T01\", \"propose\", reason=\"시험 건\"); wf.act(\"kim\", \"T01\", \"submit\", \"시험 상신\", \"근거\")\n", + "attempts = [\n", + " (\"시스템 자동 승인\", lambda: wf.act(\"sys\", \"T01\", \"approve\", \"자동\", \"자동\")),\n", + " (\"정비담당이 정비관 검토 대행\", lambda: wf.act(\"kim\", \"T01\", \"review_maint\", \"대행\", \"근거\")),\n", + " (\"검토 단계 건너뛰고 승인\", lambda: wf.act(\"choi\", \"T01\", \"approve\", \"바로 승인\", \"근거\")),\n", + " (\"미등록 사용자 접근\", lambda: wf.act(\"guest\", \"T01\", \"review_maint\", \"열람\", \"근거\")),\n", + " (\"사유 없는 검토\", lambda: wf.act(\"lee\", \"T01\", \"review_maint\", \"\", \"\")),\n", + " (\"사유 없는 반려\", lambda: wf.act(\"lee\", \"T01\", \"reject\", \"\", \"근거\")),\n", + " (\"감사 로그 삭제\", lambda: wf.act(\"kim\", \"T01\", \"delete_log\", \"정리\", \"근거\")),\n", + " (\"감사관이 승인 시도\", lambda: wf.act(\"jung\", \"T01\", \"approve\", \"승인\", \"근거\")),\n", + " (\"종결된 건 재승인\", lambda: wf.act(\"choi\", next(c for c, v in wf.cases.items() if v[\"state\"] == \"승인\"), \"approve\", \"재승인\", \"근거\")),\n", + " (\"존재하지 않는 건 승인\", lambda: wf.act(\"choi\", \"X99\", \"approve\", \"승인\", \"근거\")),\n", + "]\n", + "wf.act(\"sys\", \"T02\", \"propose\", reason=\"시험\"); wf.act(\"oh\", \"T02\", \"submit\", \"상신\", \"근거\")\n", + "wf.act(\"lee\", \"T02\", \"review_maint\", \"정비 검토\", \"이상 없음\"); wf.act(\"park\", \"T02\", \"review_quality\", \"품질 검토\", \"이상 없음\")\n", + "attempts.append((\"상신자가 본인 건 승인(분리 원칙)\", lambda: wf.act(\"oh\", \"T02\", \"approve\", \"본인 승인\", \"근거\")))\n", + "wf.act(\"sys\", \"T03\", \"propose\", reason=\"시험\"); wf.act(\"kim\", \"T03\", \"submit\", \"상신\", \"근거\"); wf.act(\"lee\", \"T03\", \"reject\", \"수정 필요\", \"근거\")\n", + "attempts.append((\"반려 건을 재기안 없이 검토 진행\", lambda: wf.act(\"lee\", \"T03\", \"review_maint\", \"그대로 진행\", \"근거\")))\n", + "attempts.append((\"타인이 대신 재기안\", lambda: wf.act(\"oh\", \"T03\", \"redraft\", \"대신 수정\", \"근거\")))\n", + "print(\"\\n[권한 우회·절차 위반 시도]\")\n", + "blocked = 0\n", + "for name, fn in attempts:\n", + " ok, why = fn(); blocked += (not ok)\n", + " print(f\" {name:22s}-> {'차단' if not ok else '통과(결함)'}: {why}\")\n", + "print(f\"우회·위반 시도 차단 {blocked}/{len(attempts)} ({blocked / len(attempts):.0%}) (목표 100%: {'달성' if blocked == len(attempts) else '미달'})\")\n", + "\n", + "# ---- RBAC 매트릭스와 이력 무결성 ----\n", + "acts = [\"propose\", \"submit\", \"review_maint\", \"review_quality\", \"approve\", \"reject\", \"redraft\", \"audit\"]\n", + "mx = pd.DataFrame([[(\"O\" if a in PERM[r] else \"-\") for a in acts] for r in PERM], index=list(PERM), columns=[\"제안\", \"상신\", \"정비검토\", \"품질검토\", \"승인\", \"반려\", \"재기안\", \"감사\"])\n", + "print(\"\\n[역할별 권한 매트릭스(O 허용 / - 불가)]\"); print(mx.to_string())\n", + "bad = wf.audit.verify()\n", + "print(f\"\\n감사 로그 {len(wf.audit.log)}건, 해시 체인 검증: {'정상' if bad is None else f'seq {bad} 불일치'} -> 이력 무결성 {'100%' if bad is None else '훼손'}(목표 100%: {'달성' if bad is None else '미달'})\")\n", + "idx = 10; saved = json.dumps(wf.audit.log[idx], ensure_ascii=False)\n", + "wf.audit.log[idx][\"reason\"] = \"사후 수정\"; v = wf.audit.verify()\n", + "print(f\"{idx}번째 기록 임의 수정 후 검증: {'위변조 탐지(seq ' + str(v) + ')' if v is not None else '탐지 실패'}\")\n", + "wf.audit.log[idx] = json.loads(saved)\n", + "print(\"\\n[이력 조회 화면] C02 건\")\n", + "for e in wf.audit.log:\n", + " if e.get(\"case\") == \"C02\":\n", + " print(f\" #{e['seq']:03d} {e['event']:14s} {e.get('role', e['who']):6s} v{e.get('version', 1)} | {e.get('reason', '')[:34]}\")\n", + "print(\"※ 정비 우선순위와 자원 투입 결정은 사람(정비관·품질검사관·군수참모)이 하며, 시스템은 위험 표시와 추천만 제공한다. 결재선은 소속 기관 규정을 따른다.\")\n", + "\n", + "plt.figure(figsize=(6, 3.5)); EN = {\"승인\": \"approved\", \"반려\": \"rejected\", \"종결(반려)\": \"closed-rejected\", \"보류(무응답)\": \"on hold\"}\n", + "plt.bar([EN.get(k, k) for k in st], list(st.values()), color=\"tab:blue\"); plt.title(\"HITL workflow outcomes (12 cases)\"); plt.ylabel(\"cases\"); plt.tight_layout(); plt.show()\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 07 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + "[결재 결과] 12건 (모의 결재자 기준, 실제 결재는 사람이 한다)\n", + " 건 장비 최종 상태 버전 비고\n", + "C01 E-0047 보류(무응답) 2 자동 확정 없음: 품질검사 응답 대기\n", + "C02 E-0231 승인 2 군수참모 결정\n", + "C03 E-0041 승인 2 군수참모 결정\n", + "C04 E-0050 보류(무응답) 1 자동 확정 없음: 품질검사 응답 대기\n", + "C05 E-0220 승인 2 군수참모 결정\n", + "C06 E-0230 승인 1 군수참모 결정\n", + "C07 E-0095 승인 1 군수참모 결정\n", + "C08 E-0109 승인 2 군수참모 결정\n", + "C09 E-0163 승인 2 군수참모 결정\n", + "C10 E-0076 승인 2 군수참모 결정\n", + "C11 E-0028 승인 3 군수참모 결정\n", + "C12 E-0233 승인 2 군수참모 결정\n", + "상태 집계: {'승인': 10, '보류(무응답)': 2} / 재기안 10회, 반려 이력 10건(모두 사유·근거 기록)\n", + "\n", + "[권한 우회·절차 위반 시도]\n", + " 시스템 자동 승인 -> 차단: 권한 없음(시스템 역할에 approve 권한 없음)\n", + " 정비담당이 정비관 검토 대행 -> 차단: 권한 없음(정비담당 역할에 review_maint 권한 없음)\n", + " 검토 단계 건너뛰고 승인 -> 차단: 단계 위반(현재 상신, 필요 행위 review_maint)\n", + " 미등록 사용자 접근 -> 차단: 미등록 사용자\n", + " 사유 없는 검토 -> 차단: 사유·근거 입력 필수(반려 사유 포함)\n", + " 사유 없는 반려 -> 차단: 사유·근거 입력 필수(반려 사유 포함)\n", + " 감사 로그 삭제 -> 차단: 감사 로그 삭제·수정 권한은 누구에게도 없음\n", + " 감사관이 승인 시도 -> 차단: 권한 없음(감사관 역할에 approve 권한 없음)\n", + " 종결된 건 재승인 -> 차단: 이미 종결된 건(승인)\n", + " 존재하지 않는 건 승인 -> 차단: 존재하지 않는 건\n", + " 상신자가 본인 건 승인(분리 원칙) -> 차단: 상신자와 승인자 분리 원칙 위반\n", + " 반려 건을 재기안 없이 검토 진행 -> 차단: 반려된 건은 재기안 후에만 진행 가능\n", + " 타인이 대신 재기안 -> 차단: 권한 없음(부서장 역할에 redraft 권한 없음)\n", + "우회·위반 시도 차단 13/13 (100%) (목표 100%: 달성)\n", + "\n", + "[역할별 권한 매트릭스(O 허용 / - 불가)]\n", + " 제안 상신 정비검토 품질검토 승인 반려 재기안 감사\n", + "시스템 O - - - - - - -\n", + "정비담당 - O - - - - O -\n", + "정비관 - - O - - O - -\n", + "품질검사관 - - - O - O - -\n", + "군수참모 - - - - O O - -\n", + "감사관 - - - - - - - O\n", + "... (이하 생략)\n", + "```\n", + "
\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 08. 전군 장비 가동률 데이터 일괄 분석 스트레스 테스트 및 성능 검증\n", + "**소요시간**: 약 3.5시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "전군 장비 가동률 데이터 일괄 분석 스트레스 테스트를 수행한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **12개월치 가상 장비 가동률 자료를 월 단위 배치로 일괄 분석하고, 오류·장애 주입과 자료량 500% 부하에서 성능과 안정성을 검증한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **월 배치를 적재-검증-분석-보고 4단계로 자동 실행하는 스케줄러 모사 작성하여 원인을 분석하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `src/pipeline/qlt04_batch_runner.py` 형태의 결과물을 완성한다.\n", + "\n", + "### 🔍 평가 및 검증 기준 (PASS 조건)\n", + "#### 미션 판정\n", + "\n", + "**평가 구분:** 필수\n", + "\n", + "**핵심 평가영역:** 수작업 대비 업무 소요 시간 80% 이상 절감 효과\n", + "\n", + "#### 미션 PASS 조건\n", + "\n", + "- 정량 기준: 전체 배치 파이프라인 실행 시간 5분 이내, 자동화 성공률 99% 이상\n", + "\n", + "\n", + "- 진행 기준: 스케줄러 연동 테스트 및 자동 실행 확인\n", + "\n", + "\n", + "- 검증 방법: 실제 월간 업무 데이터 1년치 일괄 배치 시뮬레이션\n", + "\n", + "\n", + "#### 채점자 판정 메모\n", + "\n", + "> 단순 코드 실행 여부뿐만 아니라 출력된 수치와 근거 자료가 군 실무 요구사항에 부합하는지 면밀히 확인한다.\n", + "\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "\"\"\"12개월치 가상 장비 가동률 자료를 월 단위 배치(적재 -> 검증·격리 -> 룰·모델 분석 -> 보고)로 자동 실행하는 스케줄러 모사를 만들고, 오류 레코드·일시 장애 주입과 자료량 100%·200%·500% 부하에서 실행 시간·성공률·자료 유실률·수작업 대비 절감을 측정한다.\"\"\"\n", + "import time\n", + "import numpy as np\n", + "import pandas as pd\n", + "import matplotlib.pyplot as plt\n", + "\n", + "# ---------------------------------------------------------------------------\n", + "# [교육용가상자료] 가상 장비 가동률·정비 대기 자료 생성기 (실제 부대·장비·제원·가동률 정보 없음)\n", + "# 장비 유형 A(궤도형)·B(화포형)·C(차륜형)은 추상 구분이며 실제 장비와 무관하다.\n", + "# 최근 90일 동안 장비 1대의 정비 대기 요인(부품·정비병·외주 정비·외주 검사·재작업)과 가동률을 만든다.\n", + "# ---------------------------------------------------------------------------\n", + "UNITS = [\"가상A부대\", \"가상B부대\", \"가상C부대\", \"가상D부대\", \"가상E부대\", \"가상F부대\"]\n", + "ETYPES = [\"유형A\", \"유형B\", \"유형C\"]\n", + "WINDOW = 90 # 관측 기간(일)\n", + "CAUSES = [\"부품 대기\", \"정비병 부족\", \"외주 정비\", \"검사·재작업\"]\n", + "CAUSE_COL = {\"부품 대기\": \"parts_wait_days\", \"정비병 부족\": \"labor_wait_days\", \"외주 정비\": \"outsource_days\", \"검사·재작업\": \"inspect_wait_days\"}\n", + "NUM_FIELDS = [\"age_years\", \"op_hours\", \"maint_orders\", \"parts_wait_days\", \"labor_wait_days\", \"outsource_days\", \"inspect_wait_days\", \"rework_count\",\n", + " \"spec_mismatch\", \"defect_reports\", \"parts_stock_pct\", \"mechanic_fill_pct\", \"avail_pct\"]\n", + "FEAT = [\"age_years\", \"maint_orders\", \"parts_stock_pct\", \"mechanic_fill_pct\", \"outsource_days\", \"inspect_wait_days\", \"rework_count\", \"spec_mismatch\", \"defect_reports\", \"etype_B\", \"etype_C\"]\n", + "FEAT_KO = {\"age_years\": \"장비 연령\", \"maint_orders\": \"정비 요청 건수\", \"parts_stock_pct\": \"부품 재고 가용률\", \"mechanic_fill_pct\": \"정비병 충원율\", \"outsource_days\": \"외주 정비 일수\",\n", + " \"inspect_wait_days\": \"외주 검사 대기 일수\", \"rework_count\": \"재작업 건수\", \"spec_mismatch\": \"규격·제원 불일치\", \"defect_reports\": \"하자·불만 접수\",\n", + " \"etype_B\": \"유형B 여부\", \"etype_C\": \"유형C 여부\"}\n", + "\n", + "\n", + "def unit_profile(seed=3):\n", + " \"\"\"부대별 고정 특성: 부품 재고 수준, 정비병 충원율, 외주 정비소 거리(일수 가중), 외주 검사 혼잡도.\"\"\"\n", + " r = np.random.default_rng(seed)\n", + " return pd.DataFrame({\"unit\": UNITS, \"stock\": r.uniform(45, 95, len(UNITS)), \"fill\": r.uniform(55, 98, len(UNITS)),\n", + " \"dist\": r.uniform(0.8, 1.6, len(UNITS)), \"queue\": r.uniform(0.7, 2.2, len(UNITS))})\n", + "\n", + "\n", + "def make_fleet(n=300, seed=7, shock=0.0):\n", + " \"\"\"장비 n대의 90일 관측 자료(필드 16개)와 정답 요인을 만든다. shock은 외주 검사 병목 악화 정도(AAR용 환경 변화).\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " pf = unit_profile()\n", + " ui = rng.integers(0, len(UNITS), n)\n", + " et = rng.choice(ETYPES, n, p=[.35, .3, .35])\n", + " age = np.clip(rng.gamma(4, 2.2, n), 0.5, 30).round(1)\n", + " eff = np.array([{\"유형A\": 1.15, \"유형B\": 1.0, \"유형C\": 0.9}[t] for t in et])\n", + " orders = rng.poisson(np.clip(1.2 + 0.12 * age, 0.5, 6) * eff)\n", + " stock = np.clip(pf[\"stock\"].to_numpy()[ui] + rng.normal(0, 8, n), 20, 100).round(0)\n", + " fill = np.clip(pf[\"fill\"].to_numpy()[ui] + rng.normal(0, 6, n), 30, 100).round(0)\n", + " parts_w = (orders * rng.gamma(2, 0.55 * (1 + ((100 - stock) / 28) ** 2) / 2)).round(0)\n", + " labor_w = (orders * rng.gamma(2, 0.7 * (1 + ((100 - fill) / 24) ** 2) / 2)).round(0)\n", + " n_out = rng.binomial(orders, 0.3)\n", + " out_d = (n_out * rng.gamma(3, 4.5 * pf[\"dist\"].to_numpy()[ui] / 3)).round(0)\n", + " insp_w = (n_out * rng.gamma(2, (2.6 + shock) * pf[\"queue\"].to_numpy()[ui] / 2)).round(0)\n", + " spec = rng.poisson(0.2 + 0.04 * age)\n", + " rework = rng.binomial(n_out, np.clip(0.08 + 0.1 * spec, 0, .6))\n", + " defect = rng.poisson(0.3 + 0.7 * rework)\n", + " base_rep = orders * 1.8\n", + " down = base_rep + parts_w + labor_w + out_d + insp_w + rework * 4 + rng.normal(0, 1.2, n)\n", + " comp = np.column_stack([parts_w, labor_w, out_d, insp_w + rework * 4])\n", + " df = pd.DataFrame({\"equip_id\": [f\"E-{i + 1:04d}\" for i in range(n)], \"unit\": np.array(UNITS)[ui], \"etype\": et, \"age_years\": age,\n", + " \"op_hours\": np.clip(rng.normal(320 - 4 * age, 70, n), 40, 600).round(0), \"maint_orders\": orders,\n", + " \"parts_wait_days\": parts_w, \"labor_wait_days\": labor_w, \"outsource_days\": out_d, \"inspect_wait_days\": insp_w,\n", + " \"rework_count\": rework, \"spec_mismatch\": spec, \"defect_reports\": defect, \"parts_stock_pct\": stock, \"mechanic_fill_pct\": fill,\n", + " \"avail_pct\": (100 * (1 - np.clip(down, 0, WINDOW) / WINDOW)).round(1)})\n", + " df[\"main_cause\"] = np.where(comp.max(axis=1) < 3, \"없음\", np.array(CAUSES)[comp.argmax(axis=1)])\n", + " return df\n", + "\n", + "\n", + "def feat_frame(df):\n", + " \"\"\"회귀·분석용 설명 변수 표(유형은 더미 변수).\"\"\"\n", + " x = df.copy()\n", + " x[\"etype_B\"], x[\"etype_C\"] = (x[\"etype\"] == \"유형B\").astype(int), (x[\"etype\"] == \"유형C\").astype(int)\n", + " return x[FEAT]\n", + "\n", + "\n", + "def make_trend(months=12, seed=5, shock_unit=None):\n", + " \"\"\"[교육용가상자료] 부대 x 유형 x 월 평균 가동률 추세. shock_unit 부대는 하반기에 외주 검사 병목으로 가동률이 떨어진다.\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " pf = unit_profile()\n", + " rows = []\n", + " for _, u in pf.iterrows():\n", + " base = 62 + 0.22 * u[\"stock\"] + 0.12 * u[\"fill\"] - 3.0 * u[\"queue\"]\n", + " for t in ETYPES:\n", + " off = {\"유형A\": -3.0, \"유형B\": 0.0, \"유형C\": 2.0}[t]\n", + " for m in range(months):\n", + " v = base + off + 2.0 * np.sin(m / 12 * 2 * np.pi) + rng.normal(0, 1.0)\n", + " if shock_unit == u[\"unit\"] and m >= months // 2:\n", + " v -= 1.5 * (m - months // 2 + 1)\n", + " rows.append((u[\"unit\"], t, m + 1, round(float(np.clip(v, 30, 99)), 1)))\n", + " return pd.DataFrame(rows, columns=[\"unit\", \"etype\", \"month\", \"avail_pct\"])\n", + "\n", + "# ---- 정비·검사 병목 판별 룰 설정(YAML 형식 문자열: 기준이 바뀌면 코드가 아니라 이 설정만 고친다) ----\n", + "# clause는 룰과 대응시킨 '가상 품질지침 조항' 예시이며 실제 규정 원문이 아니다.\n", + "RULES_YAML = \"\"\"\n", + "- {id: Q01, name: 부품 재고 가용률 50% 미만, field: parts_stock_pct, op: \"<\", value: 50, weight: 2, clause: 가상품질지침 제3조}\n", + "- {id: Q02, name: 정비병 충원율 60% 미만, field: mechanic_fill_pct, op: \"<\", value: 60, weight: 2, clause: 가상품질지침 제4조}\n", + "- {id: Q03, name: 부품 대기 8일 초과, field: parts_wait_days, op: \">\", value: 8, weight: 2, clause: 가상품질지침 제5조}\n", + "- {id: Q04, name: 정비병 부족 대기 8일 초과, field: labor_wait_days, op: \">\", value: 8, weight: 2, clause: 가상품질지침 제6조}\n", + "- {id: Q05, name: 외주 정비 15일 초과, field: outsource_days, op: \">\", value: 15, weight: 2, clause: 가상품질지침 제7조}\n", + "- {id: Q06, name: 외주 검사 대기 6일 초과, field: inspect_wait_days, op: \">\", value: 6, weight: 3, clause: 가상품질지침 제8조}\n", + "- {id: Q07, name: 재작업 1건 이상, field: rework_count, op: \">=\", value: 1, weight: 3, clause: 가상품질지침 제9조}\n", + "- {id: Q08, name: 재작업률 25% 초과, field: rework_rate, op: \">\", value: 0.25, weight: 2, clause: 가상품질지침 제9조}\n", + "- {id: Q09, name: 규격·제원 불일치 3건 이상, field: spec_mismatch, op: \">=\", value: 3, weight: 3, clause: 가상품질지침 제10조}\n", + "- {id: Q10, name: 하자·불만 3건 이상, field: defect_reports, op: \">=\", value: 3, weight: 2, clause: 가상품질지침 제11조}\n", + "- {id: Q11, name: 장비 연령 15년 초과, field: age_years, op: \">\", value: 15, weight: 1, clause: 가상품질지침 제12조}\n", + "- {id: Q12, name: 정비 요청 6건 이상, field: maint_orders, op: \">=\", value: 6, weight: 1, clause: 가상품질지침 제13조}\n", + "- {id: Q13, name: 대기 일수 합계 30일 초과, field: total_wait, op: \">\", value: 30, weight: 3, clause: 가상품질지침 제14조}\n", + "- {id: Q14, name: 검사 대기가 외주 정비 일수의 80% 초과, field: insp_ratio, op: \">\", value: 0.8, weight: 2, clause: 가상품질지침 제8조}\n", + "- {id: Q15, name: 규격 불일치 2건 이상과 하자 접수 동반, field: spec_defect, op: \"==\", value: true, weight: 3, clause: 가상품질지침 제10조}\n", + "- {id: Q16, name: 최근 90일 가동 시간 150시간 미만, field: op_hours, op: \"<\", value: 150, weight: 1, clause: 가상품질지침 제15조}\n", + "\"\"\"\n", + "GRADE_CUT = [(10, \"고위험\"), (6, \"주의\"), (3, \"관심\")]\n", + "OPS = {\"<\": lambda a, b: a < b, \">\": lambda a, b: a > b, \"<=\": lambda a, b: a <= b, \">=\": lambda a, b: a >= b, \"==\": lambda a, b: a == b}\n", + "\n", + "\n", + "def parse_rules(text):\n", + " \"\"\"YAML 한 줄 맵({key: value, ...}) 형식 룰 문자열을 읽는다(외부 yaml 라이브러리 없이).\"\"\"\n", + " out = []\n", + " for line in text.strip().splitlines():\n", + " body = line.strip()[3:-1]\n", + " r = {}\n", + " for part in body.split(\", \"):\n", + " k, v = part.split(\": \", 1)\n", + " v = v.strip().strip('\"')\n", + " r[k] = True if v == \"true\" else False if v == \"false\" else (float(v) if v.replace(\".\", \"\", 1).isdigit() else v)\n", + " out.append(r)\n", + " return out\n", + "\n", + "\n", + "def derive_rec(rec):\n", + " \"\"\"룰이 쓰는 파생 필드(대기 합계, 재작업률, 검사 대기 비율(외주 5일 이상일 때), 규격 불일치 2건 이상+하자 동반)를 추가한다.\"\"\"\n", + " r = dict(rec)\n", + " r[\"total_wait\"] = r[\"parts_wait_days\"] + r[\"labor_wait_days\"] + r[\"outsource_days\"] + r[\"inspect_wait_days\"]\n", + " r[\"rework_rate\"] = r[\"rework_count\"] / r[\"maint_orders\"] if r[\"maint_orders\"] else 0.0\n", + " r[\"insp_ratio\"] = r[\"inspect_wait_days\"] / r[\"outsource_days\"] if r[\"outsource_days\"] >= 5 else 0.0\n", + " r[\"spec_defect\"] = bool(r[\"spec_mismatch\"] >= 2 and r[\"defect_reports\"] >= 1)\n", + " return r\n", + "\n", + "\n", + "def apply_rules(rec, rules, derived=False):\n", + " \"\"\"룰 적용: (점수, 적중 룰 id 목록). derived=True면 이미 파생 필드가 있는 레코드(경계 시험용)로 본다.\"\"\"\n", + " r = rec if derived else derive_rec(rec)\n", + " hits = [x[\"id\"] for x in rules if OPS[x[\"op\"]](r[x[\"field\"]], x[\"value\"])]\n", + " w = {x[\"id\"]: x[\"weight\"] for x in rules}\n", + " return sum(w[h] for h in hits), hits\n", + "\n", + "\n", + "def grade_of(score):\n", + " for cut, g in GRADE_CUT:\n", + " if score >= cut:\n", + " return g\n", + " return \"정상\"\n", + "\n", + "# ---- 가동률 회귀 모델과 저가동 요인 분류·추천 모델(외부 AI 서비스 없이 scikit-learn 사용) ----\n", + "from sklearn.linear_model import Ridge, LogisticRegression\n", + "from sklearn.preprocessing import StandardScaler\n", + "from sklearn.pipeline import make_pipeline\n", + "\n", + "CAUSE_COLS = [\"age_years\", \"maint_orders\", \"parts_stock_pct\", \"mechanic_fill_pct\", \"outsource_days\", \"inspect_wait_days\", \"rework_count\", \"spec_mismatch\",\n", + " \"defect_reports\", \"parts_wait_days\", \"labor_wait_days\"]\n", + "ACTION = {\"부품 대기\": \"부품 사전 청구·재고 보강\", \"정비병 부족\": \"정비병 재배치·교육 일정 조정\", \"외주 정비\": \"외주 정비 일정 조율·내부 정비 확대\", \"검사·재작업\": \"검사 일정 조율·규격 사전 대조\"}\n", + "\n", + "\n", + "def cause_frame(df):\n", + " x = df[CAUSE_COLS].copy()\n", + " x[\"out_insp\"] = x[\"outsource_days\"] + x[\"inspect_wait_days\"] + 4 * x[\"rework_count\"]\n", + " x[\"wait_pm\"] = (x[\"parts_wait_days\"] + x[\"labor_wait_days\"]) / (x[\"maint_orders\"] + 1)\n", + " return x\n", + "\n", + "\n", + "def fit_avail(df, alpha=1.0):\n", + " \"\"\"가동률(%) 회귀: 표준화 + 릿지 회귀.\"\"\"\n", + " return make_pipeline(StandardScaler(), Ridge(alpha=alpha)).fit(feat_frame(df), df[\"avail_pct\"])\n", + "\n", + "\n", + "def fit_cause(df, cut=85, C=5):\n", + " \"\"\"가동률이 cut% 미만인 장비의 주요 저가동 요인(4종)을 분류한다.\"\"\"\n", + " low = df[(df[\"avail_pct\"] < cut) & (df[\"main_cause\"] != \"없음\")]\n", + " return make_pipeline(StandardScaler(), LogisticRegression(C=C, max_iter=3000)).fit(cause_frame(low), low[\"main_cause\"])\n", + "\n", + "\n", + "SIM_FEAT = [\"age_years\", \"maint_orders\", \"spec_mismatch\", \"stock_gap_x_orders\", \"fill_gap_x_orders\", \"parts_stock_pct\", \"mechanic_fill_pct\", \"etype_B\", \"etype_C\"]\n", + "\n", + "\n", + "def sim_frame(df):\n", + " \"\"\"자원 재배치 시뮬레이션용 설명 변수: 부족분이 클수록 대기가 더 빠르게 늘고 정비 요청 건수와 곱해져 커진다는 구조를 제곱·상호작용 항으로 둔다.\"\"\"\n", + " x = df.copy()\n", + " x[\"etype_B\"], x[\"etype_C\"] = (x[\"etype\"] == \"유형B\").astype(int), (x[\"etype\"] == \"유형C\").astype(int)\n", + " x[\"stock_gap_x_orders\"] = x[\"maint_orders\"] * ((100 - x[\"parts_stock_pct\"]) / 28) ** 2\n", + " x[\"fill_gap_x_orders\"] = x[\"maint_orders\"] * ((100 - x[\"mechanic_fill_pct\"]) / 24) ** 2\n", + " return x[SIM_FEAT]\n", + "\n", + "\n", + "def fit_sim(df, alpha=0.5):\n", + " return make_pipeline(StandardScaler(), Ridge(alpha=alpha)).fit(sim_frame(df), df[\"avail_pct\"])\n", + "\n", + "rules = parse_rules(RULES_YAML)\n", + "model = fit_avail(make_fleet(1200, seed=21))\n", + "BASE_N = 1000 # 월 기준 장비 수(가상)\n", + "MANUAL_MIN = {\"적재·정리\": 0.5, \"검증\": 0.5, \"분석\": 1.5, \"보고\": 0.5} # 장비 1대당 수작업 시간(분, 가정치)\n", + "CHECK_MIN = 6 # 자동 분석 후 정비관이 고위험 1대를 확인하는 시간(분, 가정치)\n", + "rng = np.random.default_rng(8)\n", + "LOGS = []\n", + "\n", + "\n", + "def load(month, n, bad_rate=0.01):\n", + " df = make_fleet(n, seed=100 + month)\n", + " k = max(1, int(n * bad_rate))\n", + " bad = rng.choice(n, k, replace=False)\n", + " df[\"avail_pct\"] = df[\"avail_pct\"].astype(float)\n", + " df.loc[bad[: k // 2], \"avail_pct\"] = np.nan # 결측\n", + " df.loc[bad[k // 2:], \"avail_pct\"] = 150.0 # 범위 오류\n", + " return df\n", + "\n", + "\n", + "def validate(df):\n", + " ok = df[\"avail_pct\"].between(0, 100)\n", + " return df[ok], df[~ok]\n", + "\n", + "\n", + "def analyze(df):\n", + " recs = df.drop(columns=[\"main_cause\"]).to_dict(\"records\")\n", + " sc = [apply_rules(r, rules)[0] for r in recs]\n", + " pred = np.clip(model.predict(feat_frame(df)), 0, 100)\n", + " return {\"n\": len(df), \"avail\": df[\"avail_pct\"].mean(), \"high\": sum(s >= 10 for s in sc), \"caution\": sum(6 <= s < 10 for s in sc), \"mae\": np.mean(np.abs(pred - df[\"avail_pct\"]))}\n", + "\n", + "\n", + "FAIL_ONCE = {(3, \"분석\"), (9, \"적재\")} # 일시 장애 주입(재시도 후 성공해야 함)\n", + "FAIL_ALWAYS = set() # 지속 장애는 아래 별도 시험에서 주입(격리·알림 후 다음 달 계속)\n", + "tried = set()\n", + "\n", + "\n", + "def step(month, name, fn):\n", + " for attempt in (1, 2):\n", + " try:\n", + " if (month, name) in FAIL_ONCE and (month, name) not in tried:\n", + " tried.add((month, name)); raise IOError(\"temporary failure\")\n", + " if (month, name) in FAIL_ALWAYS:\n", + " raise RuntimeError(\"persistent failure\")\n", + " return True, fn()\n", + " except Exception as e:\n", + " LOGS.append(f\"{month:02d}월 {name} 시도 {attempt} 실패: {e}\")\n", + " return False, None\n", + "\n", + "\n", + "def run_batch(n, label, months=range(1, 13), verbose=False):\n", + " t0 = time.perf_counter(); res, steps, ok_steps, quarantined, seen = [], 0, 0, 0, 0\n", + " for m in months:\n", + " ctx = {}\n", + " for name, fn in [(\"적재\", lambda: ctx.update(df=load(m, n))), (\"검증\", lambda: ctx.update(zip((\"clean\", \"bad\"), validate(ctx[\"df\"])))),\n", + " (\"분석\", lambda: ctx.update(r=analyze(ctx[\"clean\"]))), (\"보고\", lambda: ctx[\"r\"])]:\n", + " steps += 1\n", + " ok, _ = step(m, name, fn)\n", + " ok_steps += ok\n", + " if not ok:\n", + " break\n", + " if \"r\" in ctx and ok:\n", + " res.append({\"월\": m, **ctx[\"r\"], \"격리\": len(ctx[\"bad\"])})\n", + " if \"df\" in ctx:\n", + " seen += len(ctx[\"df\"]); quarantined += len(ctx.get(\"bad\", [])) if \"bad\" in ctx else 0\n", + " if verbose:\n", + " print(f\" 진행 [{'#' * m}{'.' * (12 - m)}] {m:2d}/12월 {'완료' if ok else '실패(알림 발송, 다음 달 계속)'}\")\n", + " return pd.DataFrame(res), time.perf_counter() - t0, ok_steps, steps\n", + "\n", + "\n", + "# 1) 12개월 배치(스케줄러 모사)\n", + "print(\"[월 배치 실행: 장애 주입 포함]\")\n", + "rs, dt, ok_s, steps = run_batch(BASE_N, \"x1\", verbose=True)\n", + "print(f\"소요 {dt:.1f}초(목표 5분=300초 이내: {'달성' if dt < 300 else '미달'}), 단계 {ok_s}/{steps} 성공(일시 장애는 재시도로 복구) -> 자동화 성공률 {ok_s / steps:.1%} (목표 99% 이상: {'달성' if ok_s / steps >= 0.99 else '미달'})\")\n", + "print(\"재시도·실패 로그:\", \" / \".join(LOGS[:3]), \"...\", f\"(총 {len(LOGS)}건)\")\n", + "print(\"[알림] 배치 완료: 월 12건 중 %d건 보고 생성, 실패 월 %s\" % (len(rs), sorted(set(range(1, 13)) - set(rs[\"월\"])) or \"없음\"))\n", + "print(rs.round(1).head(4).to_string(index=False), \"\\n ...\")\n", + "\n", + "# 2) 지속 장애 시험(11월 보고 단계가 계속 실패): 해당 월만 건너뛰고 알림, 나머지 월은 계속(기능 축소 작동)\n", + "FAIL_ONCE.clear(); FAIL_ALWAYS.add((11, \"보고\")); LOGS.clear()\n", + "rp, dp, okp, stp = run_batch(BASE_N, \"fault\")\n", + "print(f\"\\n[지속 장애 시험] 단계 {okp}/{stp} 성공({okp / stp:.1%}), 보고 생성 {len(rp)}/12월, 실패 월 {sorted(set(range(1, 13)) - set(rp['월']))} -> 알림 발송, 다른 달은 정상 처리(배치 전체 중단 없음)\")\n", + "\n", + "# 3) 장애 없는 배치의 자료 유실 점검\n", + "FAIL_ALWAYS.clear(); LOGS.clear()\n", + "rs0, dt0, ok0, st0 = run_batch(BASE_N, \"clean\")\n", + "tot_in = BASE_N * 12\n", + "tot_out = int(rs0[\"n\"].sum() + rs0[\"격리\"].sum())\n", + "print(f\"\\n[장애 없는 배치] 단계 {ok0}/{st0} 성공({ok0 / st0:.1%}), 입력 {tot_in}건 = 분석 {int(rs0['n'].sum())} + 격리 {int(rs0['격리'].sum())} -> 유실 {tot_in - tot_out}건({(tot_in - tot_out) / tot_in:.1%})\")\n", + "\n", + "# 4) 부하 시험: 자료량 100%·200%·500%\n", + "print(\"\\n[부하 시험: 월 자료량 증가 (12개월 전체)]\")\n", + "lt = []\n", + "for mult in (1, 2, 5):\n", + " r, d, o, s = run_batch(BASE_N * mult, f\"x{mult}\")\n", + " lost = BASE_N * mult * 12 - int(r[\"n\"].sum() + r[\"격리\"].sum())\n", + " lt.append((mult, d, o / s, lost))\n", + " print(f\" 자료 {mult * 100:3d}%: {BASE_N * mult * 12:6d}건, {d:5.1f}초, 성공 {o}/{s}, 유실 {lost}건, 초당 {BASE_N * mult * 12 / d:,.0f}건\")\n", + "print(f\"시스템 다운 0건, 데이터 유실 {sum(x[3] for x in lt)}건 (목표 0%: {'달성' if sum(x[3] for x in lt) == 0 else '미달'})\")\n", + "\n", + "# 5) 수작업 대비 절감(가정치 기반 상대 비교)\n", + "n_equip = BASE_N * 12\n", + "manual_h = n_equip * sum(MANUAL_MIN.values()) / 60\n", + "check_h = int(rs0[\"high\"].sum()) * CHECK_MIN / 60\n", + "auto_h = dt0 / 3600 + check_h\n", + "print(f\"\\n[업무 시간 절감] 수작업 {manual_h:.0f}시간(장비당 {sum(MANUAL_MIN.values()):.1f}분) vs 자동 {auto_h:.1f}시간(실행 {dt0:.1f}초 + 고위험 {int(rs0['high'].sum())}대 확인 {check_h:.1f}시간) -> 절감 {1 - auto_h / manual_h:.0%} (목표 80% 이상: {'달성' if 1 - auto_h / manual_h >= 0.8 else '미달'})\")\n", + "print(\"※ 시간 가정(장비당 수작업 분, 고위험 확인 6분)은 교육용 가정치이며 실제 업무 시간으로 바꿔 계산한다. 고위험 장비의 확인과 정비 우선순위 결정은 사람이 한다.\")\n", + "\n", + "fig, ax = plt.subplots(1, 2, figsize=(10, 4))\n", + "ax[0].plot(rs0[\"월\"], rs0[\"avail\"], marker=\"o\"); ax[0].set_title(\"Monthly mean availability (synthetic)\"); ax[0].set_xlabel(\"month\")\n", + "ax[1].bar([f\"x{x[0]}\" for x in lt], [x[1] for x in lt], color=\"tab:blue\"); ax[1].set_title(\"Batch time by data volume (sec)\")\n", + "plt.tight_layout(); plt.show()\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 08 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + "[월 배치 실행: 장애 주입 포함]\n", + " 진행 [#...........] 1/12월 완료\n", + " 진행 [##..........] 2/12월 완료\n", + " 진행 [###.........] 3/12월 완료\n", + " 진행 [####........] 4/12월 완료\n", + " 진행 [#####.......] 5/12월 완료\n", + " 진행 [######......] 6/12월 완료\n", + " 진행 [#######.....] 7/12월 완료\n", + " 진행 [########....] 8/12월 완료\n", + " 진행 [#########...] 9/12월 완료\n", + " 진행 [##########..] 10/12월 완료\n", + " 진행 [###########.] 11/12월 완료\n", + " 진행 [############] 12/12월 완료\n", + "소요 0.3초(목표 5분=300초 이내: 달성), 단계 48/48 성공(일시 장애는 재시도로 복구) -> 자동화 성공률 100.0% (목표 99% 이상: 달성)\n", + "재시도·실패 로그: 03월 분석 시도 1 실패: temporary failure / 09월 적재 시도 1 실패: temporary failure ... (총 2건)\n", + "[알림] 배치 완료: 월 12건 중 12건 보고 생성, 실패 월 없음\n", + " 월 n avail high caution mae 격리\n", + " 1 990 79.1 80 103 4.0 10\n", + " 2 990 79.9 85 79 4.0 10\n", + " 3 990 78.6 86 108 4.0 10\n", + " 4 990 79.7 79 84 4.1 10 \n", + " ...\n", + "\n", + "[지속 장애 시험] 단계 47/48 성공(97.9%), 보고 생성 11/12월, 실패 월 [11] -> 알림 발송, 다른 달은 정상 처리(배치 전체 중단 없음)\n", + "\n", + "[장애 없는 배치] 단계 48/48 성공(100.0%), 입력 12000건 = 분석 11880 + 격리 120 -> 유실 0건(0.0%)\n", + "\n", + "[부하 시험: 월 자료량 증가 (12개월 전체)]\n", + " 자료 100%: 12000건, 0.3초, 성공 48/48, 유실 0건, 초당 37,904건\n", + " 자료 200%: 24000건, 0.6초, 성공 48/48, 유실 0건, 초당 41,507건\n", + " 자료 500%: 60000건, 1.1초, 성공 48/48, 유실 0건, 초당 52,896건\n", + "시스템 다운 0건, 데이터 유실 0건 (목표 0%: 달성)\n", + "\n", + "[업무 시간 절감] 수작업 600시간(장비당 3.0분) vs 자동 98.3시간(실행 0.3초 + 고위험 983대 확인 98.3시간) -> 절감 84% (목표 80% 이상: 달성)\n", + "※ 시간 가정(장비당 수작업 분, 고위험 확인 6분)은 교육용 가정치이며 실제 업무 시간으로 바꿔 계산한다. 고위험 장비의 확인과 정비 우선순위 결정은 사람이 한다.\n", + "```\n", + "
\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 09. AAR 기반 가동률 예측치와 실제치 편차 분석 및 분석 파라미터 재보정\n", + "**소요시간**: 약 3.0시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "AAR 기반 가동률 예측치와 실제치 편차를 분석하고 파라미터를 재보정한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **AAR로 가동률 예측치와 실제치의 편차를 분석해 원인을 규명하고, 분석 파라미터를 재보정해 정확도 개선 효과와 UAT 점검 결과를 확인한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **평시 자료로 학습한 모델의 예측을 외주 검사 병목이 악화된 새 분기 실측과 부대·유형 셀 단위로 비교해 편향과 원인을 요인별로 분해 수행하고 검증하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `docs/qlt04_uat_feedback_report.md 및 패치 내역` 형태의 결과물을 완성한다.\n", + "\n", + "### 🔍 평가 및 검증 기준 (PASS 조건)\n", + "#### 미션 판정\n", + "\n", + "**평가 구분:** 필수\n", + "\n", + "**핵심 평가영역:** 실무 업무 생산성 기여도 및 현장 적응성\n", + "\n", + "#### 미션 PASS 조건\n", + "\n", + "- 정량 기준: UAT 체크리스트 만족도 90점 이상, 개선 요구 5건 반영 완료\n", + "\n", + "\n", + "- 진행 기준: UAT 결과 보고서 작성 및 최종 버그 픽스 완료\n", + "\n", + "\n", + "- 검증 방법: 실제 업무 시나리오 기반 최종 검수 테스트\n", + "\n", + "\n", + "#### 채점자 판정 메모\n", + "\n", + "> 단순 코드 실행 여부뿐만 아니라 출력된 수치와 근거 자료가 군 실무 요구사항에 부합하는지 면밀히 확인한다.\n", + "\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "\"\"\"가동률 예측치와 실제치의 편차를 부대·유형 셀 단위로 AAR 분석해 오차 원인(외주 검사 병목 악화)을 요인별로 분해하고, 편향 보정·병목 변수 추가·가중 재학습·정규화 강도 조정·예측 구간의 5개 재보정 패치를 단계별로 검증해 채택 여부를 정하며 UAT 체크리스트와 재보정 파라미터(YAML 문자열)를 출력한다.\"\"\"\n", + "import numpy as np\n", + "import pandas as pd\n", + "import matplotlib.pyplot as plt\n", + "from sklearn.model_selection import KFold\n", + "\n", + "# ---------------------------------------------------------------------------\n", + "# [교육용가상자료] 가상 장비 가동률·정비 대기 자료 생성기 (실제 부대·장비·제원·가동률 정보 없음)\n", + "# 장비 유형 A(궤도형)·B(화포형)·C(차륜형)은 추상 구분이며 실제 장비와 무관하다.\n", + "# 최근 90일 동안 장비 1대의 정비 대기 요인(부품·정비병·외주 정비·외주 검사·재작업)과 가동률을 만든다.\n", + "# ---------------------------------------------------------------------------\n", + "UNITS = [\"가상A부대\", \"가상B부대\", \"가상C부대\", \"가상D부대\", \"가상E부대\", \"가상F부대\"]\n", + "ETYPES = [\"유형A\", \"유형B\", \"유형C\"]\n", + "WINDOW = 90 # 관측 기간(일)\n", + "CAUSES = [\"부품 대기\", \"정비병 부족\", \"외주 정비\", \"검사·재작업\"]\n", + "CAUSE_COL = {\"부품 대기\": \"parts_wait_days\", \"정비병 부족\": \"labor_wait_days\", \"외주 정비\": \"outsource_days\", \"검사·재작업\": \"inspect_wait_days\"}\n", + "NUM_FIELDS = [\"age_years\", \"op_hours\", \"maint_orders\", \"parts_wait_days\", \"labor_wait_days\", \"outsource_days\", \"inspect_wait_days\", \"rework_count\",\n", + " \"spec_mismatch\", \"defect_reports\", \"parts_stock_pct\", \"mechanic_fill_pct\", \"avail_pct\"]\n", + "FEAT = [\"age_years\", \"maint_orders\", \"parts_stock_pct\", \"mechanic_fill_pct\", \"outsource_days\", \"inspect_wait_days\", \"rework_count\", \"spec_mismatch\", \"defect_reports\", \"etype_B\", \"etype_C\"]\n", + "FEAT_KO = {\"age_years\": \"장비 연령\", \"maint_orders\": \"정비 요청 건수\", \"parts_stock_pct\": \"부품 재고 가용률\", \"mechanic_fill_pct\": \"정비병 충원율\", \"outsource_days\": \"외주 정비 일수\",\n", + " \"inspect_wait_days\": \"외주 검사 대기 일수\", \"rework_count\": \"재작업 건수\", \"spec_mismatch\": \"규격·제원 불일치\", \"defect_reports\": \"하자·불만 접수\",\n", + " \"etype_B\": \"유형B 여부\", \"etype_C\": \"유형C 여부\"}\n", + "\n", + "\n", + "def unit_profile(seed=3):\n", + " \"\"\"부대별 고정 특성: 부품 재고 수준, 정비병 충원율, 외주 정비소 거리(일수 가중), 외주 검사 혼잡도.\"\"\"\n", + " r = np.random.default_rng(seed)\n", + " return pd.DataFrame({\"unit\": UNITS, \"stock\": r.uniform(45, 95, len(UNITS)), \"fill\": r.uniform(55, 98, len(UNITS)),\n", + " \"dist\": r.uniform(0.8, 1.6, len(UNITS)), \"queue\": r.uniform(0.7, 2.2, len(UNITS))})\n", + "\n", + "\n", + "def make_fleet(n=300, seed=7, shock=0.0):\n", + " \"\"\"장비 n대의 90일 관측 자료(필드 16개)와 정답 요인을 만든다. shock은 외주 검사 병목 악화 정도(AAR용 환경 변화).\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " pf = unit_profile()\n", + " ui = rng.integers(0, len(UNITS), n)\n", + " et = rng.choice(ETYPES, n, p=[.35, .3, .35])\n", + " age = np.clip(rng.gamma(4, 2.2, n), 0.5, 30).round(1)\n", + " eff = np.array([{\"유형A\": 1.15, \"유형B\": 1.0, \"유형C\": 0.9}[t] for t in et])\n", + " orders = rng.poisson(np.clip(1.2 + 0.12 * age, 0.5, 6) * eff)\n", + " stock = np.clip(pf[\"stock\"].to_numpy()[ui] + rng.normal(0, 8, n), 20, 100).round(0)\n", + " fill = np.clip(pf[\"fill\"].to_numpy()[ui] + rng.normal(0, 6, n), 30, 100).round(0)\n", + " parts_w = (orders * rng.gamma(2, 0.55 * (1 + ((100 - stock) / 28) ** 2) / 2)).round(0)\n", + " labor_w = (orders * rng.gamma(2, 0.7 * (1 + ((100 - fill) / 24) ** 2) / 2)).round(0)\n", + " n_out = rng.binomial(orders, 0.3)\n", + " out_d = (n_out * rng.gamma(3, 4.5 * pf[\"dist\"].to_numpy()[ui] / 3)).round(0)\n", + " insp_w = (n_out * rng.gamma(2, (2.6 + shock) * pf[\"queue\"].to_numpy()[ui] / 2)).round(0)\n", + " spec = rng.poisson(0.2 + 0.04 * age)\n", + " rework = rng.binomial(n_out, np.clip(0.08 + 0.1 * spec, 0, .6))\n", + " defect = rng.poisson(0.3 + 0.7 * rework)\n", + " base_rep = orders * 1.8\n", + " down = base_rep + parts_w + labor_w + out_d + insp_w + rework * 4 + rng.normal(0, 1.2, n)\n", + " comp = np.column_stack([parts_w, labor_w, out_d, insp_w + rework * 4])\n", + " df = pd.DataFrame({\"equip_id\": [f\"E-{i + 1:04d}\" for i in range(n)], \"unit\": np.array(UNITS)[ui], \"etype\": et, \"age_years\": age,\n", + " \"op_hours\": np.clip(rng.normal(320 - 4 * age, 70, n), 40, 600).round(0), \"maint_orders\": orders,\n", + " \"parts_wait_days\": parts_w, \"labor_wait_days\": labor_w, \"outsource_days\": out_d, \"inspect_wait_days\": insp_w,\n", + " \"rework_count\": rework, \"spec_mismatch\": spec, \"defect_reports\": defect, \"parts_stock_pct\": stock, \"mechanic_fill_pct\": fill,\n", + " \"avail_pct\": (100 * (1 - np.clip(down, 0, WINDOW) / WINDOW)).round(1)})\n", + " df[\"main_cause\"] = np.where(comp.max(axis=1) < 3, \"없음\", np.array(CAUSES)[comp.argmax(axis=1)])\n", + " return df\n", + "\n", + "\n", + "def feat_frame(df):\n", + " \"\"\"회귀·분석용 설명 변수 표(유형은 더미 변수).\"\"\"\n", + " x = df.copy()\n", + " x[\"etype_B\"], x[\"etype_C\"] = (x[\"etype\"] == \"유형B\").astype(int), (x[\"etype\"] == \"유형C\").astype(int)\n", + " return x[FEAT]\n", + "\n", + "\n", + "def make_trend(months=12, seed=5, shock_unit=None):\n", + " \"\"\"[교육용가상자료] 부대 x 유형 x 월 평균 가동률 추세. shock_unit 부대는 하반기에 외주 검사 병목으로 가동률이 떨어진다.\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " pf = unit_profile()\n", + " rows = []\n", + " for _, u in pf.iterrows():\n", + " base = 62 + 0.22 * u[\"stock\"] + 0.12 * u[\"fill\"] - 3.0 * u[\"queue\"]\n", + " for t in ETYPES:\n", + " off = {\"유형A\": -3.0, \"유형B\": 0.0, \"유형C\": 2.0}[t]\n", + " for m in range(months):\n", + " v = base + off + 2.0 * np.sin(m / 12 * 2 * np.pi) + rng.normal(0, 1.0)\n", + " if shock_unit == u[\"unit\"] and m >= months // 2:\n", + " v -= 1.5 * (m - months // 2 + 1)\n", + " rows.append((u[\"unit\"], t, m + 1, round(float(np.clip(v, 30, 99)), 1)))\n", + " return pd.DataFrame(rows, columns=[\"unit\", \"etype\", \"month\", \"avail_pct\"])\n", + "\n", + "# ---- 가동률 회귀 모델과 저가동 요인 분류·추천 모델(외부 AI 서비스 없이 scikit-learn 사용) ----\n", + "from sklearn.linear_model import Ridge, LogisticRegression\n", + "from sklearn.preprocessing import StandardScaler\n", + "from sklearn.pipeline import make_pipeline\n", + "\n", + "CAUSE_COLS = [\"age_years\", \"maint_orders\", \"parts_stock_pct\", \"mechanic_fill_pct\", \"outsource_days\", \"inspect_wait_days\", \"rework_count\", \"spec_mismatch\",\n", + " \"defect_reports\", \"parts_wait_days\", \"labor_wait_days\"]\n", + "ACTION = {\"부품 대기\": \"부품 사전 청구·재고 보강\", \"정비병 부족\": \"정비병 재배치·교육 일정 조정\", \"외주 정비\": \"외주 정비 일정 조율·내부 정비 확대\", \"검사·재작업\": \"검사 일정 조율·규격 사전 대조\"}\n", + "\n", + "\n", + "def cause_frame(df):\n", + " x = df[CAUSE_COLS].copy()\n", + " x[\"out_insp\"] = x[\"outsource_days\"] + x[\"inspect_wait_days\"] + 4 * x[\"rework_count\"]\n", + " x[\"wait_pm\"] = (x[\"parts_wait_days\"] + x[\"labor_wait_days\"]) / (x[\"maint_orders\"] + 1)\n", + " return x\n", + "\n", + "\n", + "def fit_avail(df, alpha=1.0):\n", + " \"\"\"가동률(%) 회귀: 표준화 + 릿지 회귀.\"\"\"\n", + " return make_pipeline(StandardScaler(), Ridge(alpha=alpha)).fit(feat_frame(df), df[\"avail_pct\"])\n", + "\n", + "\n", + "def fit_cause(df, cut=85, C=5):\n", + " \"\"\"가동률이 cut% 미만인 장비의 주요 저가동 요인(4종)을 분류한다.\"\"\"\n", + " low = df[(df[\"avail_pct\"] < cut) & (df[\"main_cause\"] != \"없음\")]\n", + " return make_pipeline(StandardScaler(), LogisticRegression(C=C, max_iter=3000)).fit(cause_frame(low), low[\"main_cause\"])\n", + "\n", + "\n", + "SIM_FEAT = [\"age_years\", \"maint_orders\", \"spec_mismatch\", \"stock_gap_x_orders\", \"fill_gap_x_orders\", \"parts_stock_pct\", \"mechanic_fill_pct\", \"etype_B\", \"etype_C\"]\n", + "\n", + "\n", + "def sim_frame(df):\n", + " \"\"\"자원 재배치 시뮬레이션용 설명 변수: 부족분이 클수록 대기가 더 빠르게 늘고 정비 요청 건수와 곱해져 커진다는 구조를 제곱·상호작용 항으로 둔다.\"\"\"\n", + " x = df.copy()\n", + " x[\"etype_B\"], x[\"etype_C\"] = (x[\"etype\"] == \"유형B\").astype(int), (x[\"etype\"] == \"유형C\").astype(int)\n", + " x[\"stock_gap_x_orders\"] = x[\"maint_orders\"] * ((100 - x[\"parts_stock_pct\"]) / 28) ** 2\n", + " x[\"fill_gap_x_orders\"] = x[\"maint_orders\"] * ((100 - x[\"mechanic_fill_pct\"]) / 24) ** 2\n", + " return x[SIM_FEAT]\n", + "\n", + "\n", + "def fit_sim(df, alpha=0.5):\n", + " return make_pipeline(StandardScaler(), Ridge(alpha=alpha)).fit(sim_frame(df), df[\"avail_pct\"])\n", + "\n", + "# ---- 평시 자료로 학습한 기존 예측 모델과, 외주 검사 병목이 악화된 새 분기(실제) ----\n", + "hist = make_fleet(900, seed=21)\n", + "m0 = fit_sim(hist)\n", + "now = make_fleet(1800, seed=51, shock=4.0) # 실제: 외주 검사 대기 병목 악화(shock) -> 기존 모델은 모름\n", + "aar, test = now.iloc[:600], now.iloc[600:] # AAR 표본(분기 초 실측) / 이후 검증 자료\n", + "CELL = [\"unit\", \"etype\"]\n", + "\n", + "\n", + "def cells(df, pred):\n", + " t = df[CELL].copy(); t[\"actual\"], t[\"pred\"] = df[\"avail_pct\"].to_numpy(), pred\n", + " g = t.groupby(CELL).mean(); g[\"err\"] = g[\"actual\"] - g[\"pred\"]; return g\n", + "\n", + "\n", + "def score(df, pred, tol=3.0):\n", + " c = cells(df, pred)\n", + " return {\"hit\": float((c[\"err\"].abs() <= tol).mean()), \"mae\": float(np.mean(np.abs(df[\"avail_pct\"] - pred))), \"bias\": float((df[\"avail_pct\"] - pred).mean()), \"cells\": c}\n", + "\n", + "\n", + "# 1) AAR: 예측치와 실제치의 편차\n", + "p_aar = m0.predict(sim_frame(aar))\n", + "s0 = score(aar, p_aar)\n", + "print(f\"[AAR 편차 분석] 예측 대비 실제: 평균 편향 {s0['bias']:+.2f}%p(실제가 낮음), 개별 MAE {s0['mae']:.2f}%p, 셀 18곳 중 오차 3%p 이내 {s0['hit']:.0%}\")\n", + "worst = s0[\"cells\"].sort_values(\"err\").head(3)\n", + "print(\"오차가 큰 셀:\", \", \".join(f\"{i[0]}/{i[1]} {r.err:+.1f}%p\" for i, r in worst.iterrows()))\n", + "comp = {\"부품 대기\": \"parts_wait_days\", \"정비병 부족\": \"labor_wait_days\", \"외주 정비\": \"outsource_days\", \"외주 검사 대기\": \"inspect_wait_days\", \"재작업\": \"rework_count\"}\n", + "contrib = {k: -(aar[c].mean() - hist[c].mean()) / WINDOW * 100 * (4 if c == \"rework_count\" else 1) for k, c in comp.items()}\n", + "print(\"오차 원인 분해(평시 대비 변화가 가동률에 미친 영향 %p):\", \", \".join(f\"{k} {v:+.2f}\" for k, v in contrib.items()))\n", + "cause = min(contrib, key=contrib.get)\n", + "print(f\"-> 가장 큰 원인: {cause} (예측 모델의 입력에 검사 병목 정보가 없었음). 재발 방지: 병목 변수 추가와 최근 자료 반영\")\n", + "\n", + "# 2) 재보정 패치 후보를 AAR 표본으로 만들고 검증 자료(test)로 평가\n", + "b_global = s0[\"bias\"]\n", + "b_cell = s0[\"cells\"][\"err\"]\n", + "unit_insp = aar.groupby(\"unit\")[\"inspect_wait_days\"].mean() # P2: 부대별 최근 외주 검사 대기(병목 변수)\n", + "\n", + "\n", + "def sim2(df):\n", + " x = sim_frame(df).copy(); x[\"insp_x_orders\"] = df[\"unit\"].map(unit_insp).to_numpy() * df[\"maint_orders\"].to_numpy(); return x\n", + "\n", + "\n", + "def fit2(alpha, w):\n", + " tr = pd.concat([hist.assign(_w=1.0), aar.assign(_w=w)]); x = sim2(tr)\n", + " sc = StandardScaler().fit(x); return sc, Ridge(alpha=alpha).fit(sc.transform(x), tr[\"avail_pct\"], sample_weight=tr[\"_w\"])\n", + "\n", + "\n", + "for_cv = {}\n", + "for a in (0.1, 0.5, 2.0, 10.0, 50.0): # P4: 정규화 강도를 AAR 표본 5분할 교차검증으로 선택\n", + " e = []\n", + " for tri, vai in KFold(5, shuffle=True, random_state=0).split(aar):\n", + " tr = pd.concat([hist.assign(_w=1.0), aar.iloc[tri].assign(_w=3.0)]); x = sim2(tr); sc = StandardScaler().fit(x)\n", + " r = Ridge(alpha=a).fit(sc.transform(x), tr[\"avail_pct\"], sample_weight=tr[\"_w\"])\n", + " e.append(np.mean(np.abs(r.predict(sc.transform(sim2(aar.iloc[vai]))) - aar.iloc[vai][\"avail_pct\"])))\n", + " for_cv[a] = float(np.mean(e))\n", + "alpha = min(for_cv, key=for_cv.get)\n", + "sc3, r3 = fit2(0.5, 3.0); sc4, r4 = fit2(alpha, 3.0)\n", + "cand = {\"원 모델\": m0.predict(sim_frame(test)),\n", + " \"P1 전체 편향 보정\": m0.predict(sim_frame(test)) + b_global,\n", + " \"P1' 셀별 편향 보정\": m0.predict(sim_frame(test)) + test[CELL].apply(tuple, axis=1).map(b_cell.to_dict()).to_numpy(),\n", + " \"P2+P3 병목 변수+최근 자료 가중\": r3.predict(sc3.transform(sim2(test))),\n", + " f\"P4 +정규화 강도 {alpha}\": r4.predict(sc4.transform(sim2(test)))}\n", + "print(\"\\n[재보정 패치 검증(검증 자료 1,200대, 셀 18곳)]\")\n", + "res = {}\n", + "for k, p in cand.items():\n", + " res[k] = score(test, p); print(f\" {k:26s} 셀 적중 {res[k]['hit']:4.0%} 개별 MAE {res[k]['mae']:5.2f} 편향 {res[k]['bias']:+5.2f}\")\n", + "best = min((k for k in cand if k != \"원 모델\"), key=lambda k: (-res[k][\"hit\"], res[k][\"mae\"]))\n", + "b = res[best]; o = res[\"원 모델\"]\n", + "print(f\"채택: {best} -> 셀 적중 {o['hit']:.0%} -> {b['hit']:.0%} ({(b['hit'] - o['hit']) * 100:+.0f}%p, 목표 +10%p 이상: {'달성' if b['hit'] - o['hit'] >= 0.10 else '미달'}), MAE {o['mae']:.2f} -> {b['mae']:.2f}\")\n", + "\n", + "# 3) P5 예측 구간(잔차 90% 분위수)과 재발 오류\n", + "pf = cand[best]; resid_aar = aar[\"avail_pct\"].to_numpy() - r4.predict(sc4.transform(sim2(aar)))\n", + "q = float(np.quantile(np.abs(resid_aar), 0.9))\n", + "cover = float(np.mean(np.abs(test[\"avail_pct\"].to_numpy() - pf) <= q))\n", + "bad0 = set(i for i, r in s0[\"cells\"].iterrows() if abs(r.err) > 3)\n", + "recur = [i for i in bad0 if abs(b[\"cells\"].loc[i, \"err\"]) > 3]\n", + "print(\"장비 1대 단위 예측은 외주 검사·재작업 같은 개별 사건을 알 수 없어 오차가 크므로, 부대·유형 셀 평균으로 계획에 쓴다.\")\n", + "print(f\"P5 예측 구간 ±{q:.1f}%p: 검증 포괄률 {cover:.0%}(목표 약 90%). 재보정 전 오차 3%p 초과 셀 {len(bad0)}곳 중 재발 {len(recur)}곳 {recur[:3] if recur else ''}\")\n", + "\n", + "# 4) UAT 체크리스트(측정값 기반 가상 점검) 10항목 x 10점\n", + "uat = {\"셀 적중률 80% 이상\": b[\"hit\"] >= 0.8, \"개별 MAE 12%p 이하(장비 단위는 참고용)\": b[\"mae\"] <= 12, \"평균 편향 절대값 1.0%p 이하\": abs(b[\"bias\"]) <= 1.0, \"예측 구간 포괄률 85~95%\": 0.85 <= cover <= 0.95,\n", + " \"재발 오류 0건\": len(recur) == 0, \"원인 분해표 제공\": len(contrib) == 5, \"재보정 전후 비교표 제공\": len(cand) == 5, \"파라미터 YAML 출력\": True,\n", + " \"이전 파라미터로 되돌리기 가능(원 모델 보존)\": m0 is not None, \"사람 확인 문구 포함(자동 적용 금지)\": True}\n", + "pts = sum(10 for v in uat.values() if v)\n", + "print(f\"\\n[UAT 체크리스트] {pts}점/100점 (목표 90점 이상: {'달성' if pts >= 90 else '미달'}), 미충족: {[k for k, v in uat.items() if not v] or '없음'}\")\n", + "print(\"개선 요구 반영 5건: P1 편향 보정, P2 외주 검사 병목 변수 추가, P3 최근 자료 가중(x3), P4 정규화 강도 교차검증 선택, P5 예측 구간 표시 -> 모두 반영\")\n", + "\n", + "YAML = f\"\"\"[tuned_params 제안: 담당자 확인 후 적용]\n", + "model: ridge\n", + "alpha: {alpha}\n", + "recent_weight: 3.0\n", + "extra_feature: insp_x_orders # 부대별 최근 외주 검사 대기 x 정비 요청 건수\n", + "bias_cells: {len(b_cell)}\n", + "interval_pct90: {q:.1f}\n", + "retrain_trigger: 셀 적중률 70% 미만 또는 편향 절대값 1.0%p 초과\"\"\"\n", + "print(\"\\n\" + YAML)\n", + "print(\"지속 학습 연계: 분기마다 AAR 표본을 수집해 위 절차를 반복하되, 재보정 적용은 정비관·군수참모 확인 후에 한다. 가상 자료 기준 결과이다.\")\n", + "\n", + "plt.figure(figsize=(8, 4))\n", + "ks = list(res); plt.bar([f\"S{i}\" for i in range(len(ks))], [res[k][\"hit\"] * 100 for k in ks], color=[\"tab:gray\"] + [\"tab:blue\"] * (len(ks) - 1))\n", + "plt.ylabel(\"cells within 3%p (%)\"); plt.title(\"Forecast hit rate: base (S0) vs recalibration patches (S1-S4)\"); plt.tight_layout(); plt.show()\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 09 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + "[AAR 편차 분석] 예측 대비 실제: 평균 편향 -4.60%p(실제가 낮음), 개별 MAE 11.44%p, 셀 18곳 중 오차 3%p 이내 33%\n", + "오차가 큰 셀: 가상D부대/유형A -11.0%p, 가상D부대/유형C -10.3%p, 가상A부대/유형B -9.1%p\n", + "오차 원인 분해(평시 대비 변화가 가동률에 미친 영향 %p): 부품 대기 -0.21, 정비병 부족 -0.45, 외주 정비 -0.01, 외주 검사 대기 -4.92, 재작업 +0.18\n", + "-> 가장 큰 원인: 외주 검사 대기 (예측 모델의 입력에 검사 병목 정보가 없었음). 재발 방지: 병목 변수 추가와 최근 자료 반영\n", + "\n", + "[재보정 패치 검증(검증 자료 1,200대, 셀 18곳)]\n", + " 원 모델 셀 적중 39% 개별 MAE 10.53 편향 -3.82\n", + " P1 전체 편향 보정 셀 적중 78% 개별 MAE 11.48 편향 +0.79\n", + " P1' 셀별 편향 보정 셀 적중 50% 개별 MAE 11.81 편향 +0.95\n", + " P2+P3 병목 변수+최근 자료 가중 셀 적중 83% 개별 MAE 10.62 편향 -0.78\n", + " P4 +정규화 강도 10.0 셀 적중 83% 개별 MAE 10.62 편향 -0.78\n", + "채택: P2+P3 병목 변수+최근 자료 가중 -> 셀 적중 39% -> 83% (+44%p, 목표 +10%p 이상: 달성), MAE 10.53 -> 10.62\n", + "장비 1대 단위 예측은 외주 검사·재작업 같은 개별 사건을 알 수 없어 오차가 크므로, 부대·유형 셀 평균으로 계획에 쓴다.\n", + "P5 예측 구간 ±25.2%p: 검증 포괄률 90%(목표 약 90%). 재보정 전 오차 3%p 초과 셀 12곳 중 재발 1곳 [('가상A부대', '유형B')]\n", + "\n", + "[UAT 체크리스트] 90점/100점 (목표 90점 이상: 달성), 미충족: ['재발 오류 0건']\n", + "개선 요구 반영 5건: P1 편향 보정, P2 외주 검사 병목 변수 추가, P3 최근 자료 가중(x3), P4 정규화 강도 교차검증 선택, P5 예측 구간 표시 -> 모두 반영\n", + "\n", + "[tuned_params 제안: 담당자 확인 후 적용]\n", + "model: ridge\n", + "alpha: 10.0\n", + "recent_weight: 3.0\n", + "extra_feature: insp_x_orders # 부대별 최근 외주 검사 대기 x 정비 요청 건수\n", + "bias_cells: 18\n", + "interval_pct90: 25.2\n", + "retrain_trigger: 셀 적중률 70% 미만 또는 편향 절대값 1.0%p 초과\n", + "지속 학습 연계: 분기마다 AAR 표본을 수집해 위 절차를 반복하되, 재보정 적용은 정비관·군수참모 확인 후에 한다. 가상 자료 기준 결과이다.\n", + "```\n", + "
\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 10. 전술 장비 가동률 저하 요인 분석 및 가동률 극대화 도구 최종 완성\n", + "**소요시간**: 약 3.0시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "전술 장비 가동률 저하 요인 분석 및 가동률 극대화 도구를 완성한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **입력 검증부터 룰 판별, 요인 추천, 사람 검토 큐, 감사 로그, 브리핑까지 하나로 묶은 가동률 저하 요인 분석·극대화 도구를 완성하고 배포 점검을 마친다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **앞 미션의 기능을 통합 파이프라인으로 묶어 설치 스크립트 5단계 점검, 서로 다른 시드 4종의 End-to-End 실행, 자체 점검 9항목과 함수 커버리지 수행하고 분석하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `dist/qlt04_package/, README.md, docs/qlt04_final_manual.md` 형태의 결과물을 완성한다.\n", + "\n", + "### 🔍 평가 및 검증 기준 (PASS 조건)\n", + "#### 미션 판정\n", + "\n", + "**평가 구분:** 필수\n", + "\n", + "**핵심 평가영역:** 상용 소프트웨어 수준의 완성도 및 조직 확산 가치\n", + "\n", + "#### 미션 PASS 조건\n", + "\n", + "- 정량 기준: 설치부터 실행까지 원클릭 설치 스크립트 완비, 매뉴얼 작성률 100%\n", + "\n", + "\n", + "- 진행 기준: 최종 배포본 생성 및 검수 완료\n", + "\n", + "\n", + "- 검증 방법: Clean 환경에서의 설치 및 초기화 테스트\n", + "\n", + "\n", + "#### 채점자 판정 메모\n", + "\n", + "> 단순 코드 실행 여부뿐만 아니라 출력된 수치와 근거 자료가 군 실무 요구사항에 부합하는지 면밀히 확인한다.\n", + "\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "\"\"\"입력 검증·격리, 16종 룰 판별, 가동률 회귀와 저하 요인 Top-3 추천, 사람 검토 큐와 해시 체인 감사 로그, 브리핑 요약까지 외주정비 품질검사·가동률 분석 도구를 한 파일로 통합 실행하고 설치 스크립트 점검·End-to-End 4종·자체 점검(함수 커버리지)·매뉴얼 작성률을 출력한다.\"\"\"\n", + "import hashlib\n", + "import json\n", + "import sys\n", + "import time\n", + "import numpy as np\n", + "import pandas as pd\n", + "import matplotlib.pyplot as plt\n", + "\n", + "# ---------------------------------------------------------------------------\n", + "# [교육용가상자료] 가상 장비 가동률·정비 대기 자료 생성기 (실제 부대·장비·제원·가동률 정보 없음)\n", + "# 장비 유형 A(궤도형)·B(화포형)·C(차륜형)은 추상 구분이며 실제 장비와 무관하다.\n", + "# 최근 90일 동안 장비 1대의 정비 대기 요인(부품·정비병·외주 정비·외주 검사·재작업)과 가동률을 만든다.\n", + "# ---------------------------------------------------------------------------\n", + "UNITS = [\"가상A부대\", \"가상B부대\", \"가상C부대\", \"가상D부대\", \"가상E부대\", \"가상F부대\"]\n", + "ETYPES = [\"유형A\", \"유형B\", \"유형C\"]\n", + "WINDOW = 90 # 관측 기간(일)\n", + "CAUSES = [\"부품 대기\", \"정비병 부족\", \"외주 정비\", \"검사·재작업\"]\n", + "CAUSE_COL = {\"부품 대기\": \"parts_wait_days\", \"정비병 부족\": \"labor_wait_days\", \"외주 정비\": \"outsource_days\", \"검사·재작업\": \"inspect_wait_days\"}\n", + "NUM_FIELDS = [\"age_years\", \"op_hours\", \"maint_orders\", \"parts_wait_days\", \"labor_wait_days\", \"outsource_days\", \"inspect_wait_days\", \"rework_count\",\n", + " \"spec_mismatch\", \"defect_reports\", \"parts_stock_pct\", \"mechanic_fill_pct\", \"avail_pct\"]\n", + "FEAT = [\"age_years\", \"maint_orders\", \"parts_stock_pct\", \"mechanic_fill_pct\", \"outsource_days\", \"inspect_wait_days\", \"rework_count\", \"spec_mismatch\", \"defect_reports\", \"etype_B\", \"etype_C\"]\n", + "FEAT_KO = {\"age_years\": \"장비 연령\", \"maint_orders\": \"정비 요청 건수\", \"parts_stock_pct\": \"부품 재고 가용률\", \"mechanic_fill_pct\": \"정비병 충원율\", \"outsource_days\": \"외주 정비 일수\",\n", + " \"inspect_wait_days\": \"외주 검사 대기 일수\", \"rework_count\": \"재작업 건수\", \"spec_mismatch\": \"규격·제원 불일치\", \"defect_reports\": \"하자·불만 접수\",\n", + " \"etype_B\": \"유형B 여부\", \"etype_C\": \"유형C 여부\"}\n", + "\n", + "\n", + "def unit_profile(seed=3):\n", + " \"\"\"부대별 고정 특성: 부품 재고 수준, 정비병 충원율, 외주 정비소 거리(일수 가중), 외주 검사 혼잡도.\"\"\"\n", + " r = np.random.default_rng(seed)\n", + " return pd.DataFrame({\"unit\": UNITS, \"stock\": r.uniform(45, 95, len(UNITS)), \"fill\": r.uniform(55, 98, len(UNITS)),\n", + " \"dist\": r.uniform(0.8, 1.6, len(UNITS)), \"queue\": r.uniform(0.7, 2.2, len(UNITS))})\n", + "\n", + "\n", + "def make_fleet(n=300, seed=7, shock=0.0):\n", + " \"\"\"장비 n대의 90일 관측 자료(필드 16개)와 정답 요인을 만든다. shock은 외주 검사 병목 악화 정도(AAR용 환경 변화).\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " pf = unit_profile()\n", + " ui = rng.integers(0, len(UNITS), n)\n", + " et = rng.choice(ETYPES, n, p=[.35, .3, .35])\n", + " age = np.clip(rng.gamma(4, 2.2, n), 0.5, 30).round(1)\n", + " eff = np.array([{\"유형A\": 1.15, \"유형B\": 1.0, \"유형C\": 0.9}[t] for t in et])\n", + " orders = rng.poisson(np.clip(1.2 + 0.12 * age, 0.5, 6) * eff)\n", + " stock = np.clip(pf[\"stock\"].to_numpy()[ui] + rng.normal(0, 8, n), 20, 100).round(0)\n", + " fill = np.clip(pf[\"fill\"].to_numpy()[ui] + rng.normal(0, 6, n), 30, 100).round(0)\n", + " parts_w = (orders * rng.gamma(2, 0.55 * (1 + ((100 - stock) / 28) ** 2) / 2)).round(0)\n", + " labor_w = (orders * rng.gamma(2, 0.7 * (1 + ((100 - fill) / 24) ** 2) / 2)).round(0)\n", + " n_out = rng.binomial(orders, 0.3)\n", + " out_d = (n_out * rng.gamma(3, 4.5 * pf[\"dist\"].to_numpy()[ui] / 3)).round(0)\n", + " insp_w = (n_out * rng.gamma(2, (2.6 + shock) * pf[\"queue\"].to_numpy()[ui] / 2)).round(0)\n", + " spec = rng.poisson(0.2 + 0.04 * age)\n", + " rework = rng.binomial(n_out, np.clip(0.08 + 0.1 * spec, 0, .6))\n", + " defect = rng.poisson(0.3 + 0.7 * rework)\n", + " base_rep = orders * 1.8\n", + " down = base_rep + parts_w + labor_w + out_d + insp_w + rework * 4 + rng.normal(0, 1.2, n)\n", + " comp = np.column_stack([parts_w, labor_w, out_d, insp_w + rework * 4])\n", + " df = pd.DataFrame({\"equip_id\": [f\"E-{i + 1:04d}\" for i in range(n)], \"unit\": np.array(UNITS)[ui], \"etype\": et, \"age_years\": age,\n", + " \"op_hours\": np.clip(rng.normal(320 - 4 * age, 70, n), 40, 600).round(0), \"maint_orders\": orders,\n", + " \"parts_wait_days\": parts_w, \"labor_wait_days\": labor_w, \"outsource_days\": out_d, \"inspect_wait_days\": insp_w,\n", + " \"rework_count\": rework, \"spec_mismatch\": spec, \"defect_reports\": defect, \"parts_stock_pct\": stock, \"mechanic_fill_pct\": fill,\n", + " \"avail_pct\": (100 * (1 - np.clip(down, 0, WINDOW) / WINDOW)).round(1)})\n", + " df[\"main_cause\"] = np.where(comp.max(axis=1) < 3, \"없음\", np.array(CAUSES)[comp.argmax(axis=1)])\n", + " return df\n", + "\n", + "\n", + "def feat_frame(df):\n", + " \"\"\"회귀·분석용 설명 변수 표(유형은 더미 변수).\"\"\"\n", + " x = df.copy()\n", + " x[\"etype_B\"], x[\"etype_C\"] = (x[\"etype\"] == \"유형B\").astype(int), (x[\"etype\"] == \"유형C\").astype(int)\n", + " return x[FEAT]\n", + "\n", + "\n", + "def make_trend(months=12, seed=5, shock_unit=None):\n", + " \"\"\"[교육용가상자료] 부대 x 유형 x 월 평균 가동률 추세. shock_unit 부대는 하반기에 외주 검사 병목으로 가동률이 떨어진다.\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " pf = unit_profile()\n", + " rows = []\n", + " for _, u in pf.iterrows():\n", + " base = 62 + 0.22 * u[\"stock\"] + 0.12 * u[\"fill\"] - 3.0 * u[\"queue\"]\n", + " for t in ETYPES:\n", + " off = {\"유형A\": -3.0, \"유형B\": 0.0, \"유형C\": 2.0}[t]\n", + " for m in range(months):\n", + " v = base + off + 2.0 * np.sin(m / 12 * 2 * np.pi) + rng.normal(0, 1.0)\n", + " if shock_unit == u[\"unit\"] and m >= months // 2:\n", + " v -= 1.5 * (m - months // 2 + 1)\n", + " rows.append((u[\"unit\"], t, m + 1, round(float(np.clip(v, 30, 99)), 1)))\n", + " return pd.DataFrame(rows, columns=[\"unit\", \"etype\", \"month\", \"avail_pct\"])\n", + "\n", + "# ---- 정비·검사 병목 판별 룰 설정(YAML 형식 문자열: 기준이 바뀌면 코드가 아니라 이 설정만 고친다) ----\n", + "# clause는 룰과 대응시킨 '가상 품질지침 조항' 예시이며 실제 규정 원문이 아니다.\n", + "RULES_YAML = \"\"\"\n", + "- {id: Q01, name: 부품 재고 가용률 50% 미만, field: parts_stock_pct, op: \"<\", value: 50, weight: 2, clause: 가상품질지침 제3조}\n", + "- {id: Q02, name: 정비병 충원율 60% 미만, field: mechanic_fill_pct, op: \"<\", value: 60, weight: 2, clause: 가상품질지침 제4조}\n", + "- {id: Q03, name: 부품 대기 8일 초과, field: parts_wait_days, op: \">\", value: 8, weight: 2, clause: 가상품질지침 제5조}\n", + "- {id: Q04, name: 정비병 부족 대기 8일 초과, field: labor_wait_days, op: \">\", value: 8, weight: 2, clause: 가상품질지침 제6조}\n", + "- {id: Q05, name: 외주 정비 15일 초과, field: outsource_days, op: \">\", value: 15, weight: 2, clause: 가상품질지침 제7조}\n", + "- {id: Q06, name: 외주 검사 대기 6일 초과, field: inspect_wait_days, op: \">\", value: 6, weight: 3, clause: 가상품질지침 제8조}\n", + "- {id: Q07, name: 재작업 1건 이상, field: rework_count, op: \">=\", value: 1, weight: 3, clause: 가상품질지침 제9조}\n", + "- {id: Q08, name: 재작업률 25% 초과, field: rework_rate, op: \">\", value: 0.25, weight: 2, clause: 가상품질지침 제9조}\n", + "- {id: Q09, name: 규격·제원 불일치 3건 이상, field: spec_mismatch, op: \">=\", value: 3, weight: 3, clause: 가상품질지침 제10조}\n", + "- {id: Q10, name: 하자·불만 3건 이상, field: defect_reports, op: \">=\", value: 3, weight: 2, clause: 가상품질지침 제11조}\n", + "- {id: Q11, name: 장비 연령 15년 초과, field: age_years, op: \">\", value: 15, weight: 1, clause: 가상품질지침 제12조}\n", + "- {id: Q12, name: 정비 요청 6건 이상, field: maint_orders, op: \">=\", value: 6, weight: 1, clause: 가상품질지침 제13조}\n", + "- {id: Q13, name: 대기 일수 합계 30일 초과, field: total_wait, op: \">\", value: 30, weight: 3, clause: 가상품질지침 제14조}\n", + "- {id: Q14, name: 검사 대기가 외주 정비 일수의 80% 초과, field: insp_ratio, op: \">\", value: 0.8, weight: 2, clause: 가상품질지침 제8조}\n", + "- {id: Q15, name: 규격 불일치 2건 이상과 하자 접수 동반, field: spec_defect, op: \"==\", value: true, weight: 3, clause: 가상품질지침 제10조}\n", + "- {id: Q16, name: 최근 90일 가동 시간 150시간 미만, field: op_hours, op: \"<\", value: 150, weight: 1, clause: 가상품질지침 제15조}\n", + "\"\"\"\n", + "GRADE_CUT = [(10, \"고위험\"), (6, \"주의\"), (3, \"관심\")]\n", + "OPS = {\"<\": lambda a, b: a < b, \">\": lambda a, b: a > b, \"<=\": lambda a, b: a <= b, \">=\": lambda a, b: a >= b, \"==\": lambda a, b: a == b}\n", + "\n", + "\n", + "def parse_rules(text):\n", + " \"\"\"YAML 한 줄 맵({key: value, ...}) 형식 룰 문자열을 읽는다(외부 yaml 라이브러리 없이).\"\"\"\n", + " out = []\n", + " for line in text.strip().splitlines():\n", + " body = line.strip()[3:-1]\n", + " r = {}\n", + " for part in body.split(\", \"):\n", + " k, v = part.split(\": \", 1)\n", + " v = v.strip().strip('\"')\n", + " r[k] = True if v == \"true\" else False if v == \"false\" else (float(v) if v.replace(\".\", \"\", 1).isdigit() else v)\n", + " out.append(r)\n", + " return out\n", + "\n", + "\n", + "def derive_rec(rec):\n", + " \"\"\"룰이 쓰는 파생 필드(대기 합계, 재작업률, 검사 대기 비율(외주 5일 이상일 때), 규격 불일치 2건 이상+하자 동반)를 추가한다.\"\"\"\n", + " r = dict(rec)\n", + " r[\"total_wait\"] = r[\"parts_wait_days\"] + r[\"labor_wait_days\"] + r[\"outsource_days\"] + r[\"inspect_wait_days\"]\n", + " r[\"rework_rate\"] = r[\"rework_count\"] / r[\"maint_orders\"] if r[\"maint_orders\"] else 0.0\n", + " r[\"insp_ratio\"] = r[\"inspect_wait_days\"] / r[\"outsource_days\"] if r[\"outsource_days\"] >= 5 else 0.0\n", + " r[\"spec_defect\"] = bool(r[\"spec_mismatch\"] >= 2 and r[\"defect_reports\"] >= 1)\n", + " return r\n", + "\n", + "\n", + "def apply_rules(rec, rules, derived=False):\n", + " \"\"\"룰 적용: (점수, 적중 룰 id 목록). derived=True면 이미 파생 필드가 있는 레코드(경계 시험용)로 본다.\"\"\"\n", + " r = rec if derived else derive_rec(rec)\n", + " hits = [x[\"id\"] for x in rules if OPS[x[\"op\"]](r[x[\"field\"]], x[\"value\"])]\n", + " w = {x[\"id\"]: x[\"weight\"] for x in rules}\n", + " return sum(w[h] for h in hits), hits\n", + "\n", + "\n", + "def grade_of(score):\n", + " for cut, g in GRADE_CUT:\n", + " if score >= cut:\n", + " return g\n", + " return \"정상\"\n", + "\n", + "# ---- 가동률 회귀 모델과 저가동 요인 분류·추천 모델(외부 AI 서비스 없이 scikit-learn 사용) ----\n", + "from sklearn.linear_model import Ridge, LogisticRegression\n", + "from sklearn.preprocessing import StandardScaler\n", + "from sklearn.pipeline import make_pipeline\n", + "\n", + "CAUSE_COLS = [\"age_years\", \"maint_orders\", \"parts_stock_pct\", \"mechanic_fill_pct\", \"outsource_days\", \"inspect_wait_days\", \"rework_count\", \"spec_mismatch\",\n", + " \"defect_reports\", \"parts_wait_days\", \"labor_wait_days\"]\n", + "ACTION = {\"부품 대기\": \"부품 사전 청구·재고 보강\", \"정비병 부족\": \"정비병 재배치·교육 일정 조정\", \"외주 정비\": \"외주 정비 일정 조율·내부 정비 확대\", \"검사·재작업\": \"검사 일정 조율·규격 사전 대조\"}\n", + "\n", + "\n", + "def cause_frame(df):\n", + " x = df[CAUSE_COLS].copy()\n", + " x[\"out_insp\"] = x[\"outsource_days\"] + x[\"inspect_wait_days\"] + 4 * x[\"rework_count\"]\n", + " x[\"wait_pm\"] = (x[\"parts_wait_days\"] + x[\"labor_wait_days\"]) / (x[\"maint_orders\"] + 1)\n", + " return x\n", + "\n", + "\n", + "def fit_avail(df, alpha=1.0):\n", + " \"\"\"가동률(%) 회귀: 표준화 + 릿지 회귀.\"\"\"\n", + " return make_pipeline(StandardScaler(), Ridge(alpha=alpha)).fit(feat_frame(df), df[\"avail_pct\"])\n", + "\n", + "\n", + "def fit_cause(df, cut=85, C=5):\n", + " \"\"\"가동률이 cut% 미만인 장비의 주요 저가동 요인(4종)을 분류한다.\"\"\"\n", + " low = df[(df[\"avail_pct\"] < cut) & (df[\"main_cause\"] != \"없음\")]\n", + " return make_pipeline(StandardScaler(), LogisticRegression(C=C, max_iter=3000)).fit(cause_frame(low), low[\"main_cause\"])\n", + "\n", + "\n", + "SIM_FEAT = [\"age_years\", \"maint_orders\", \"spec_mismatch\", \"stock_gap_x_orders\", \"fill_gap_x_orders\", \"parts_stock_pct\", \"mechanic_fill_pct\", \"etype_B\", \"etype_C\"]\n", + "\n", + "\n", + "def sim_frame(df):\n", + " \"\"\"자원 재배치 시뮬레이션용 설명 변수: 부족분이 클수록 대기가 더 빠르게 늘고 정비 요청 건수와 곱해져 커진다는 구조를 제곱·상호작용 항으로 둔다.\"\"\"\n", + " x = df.copy()\n", + " x[\"etype_B\"], x[\"etype_C\"] = (x[\"etype\"] == \"유형B\").astype(int), (x[\"etype\"] == \"유형C\").astype(int)\n", + " x[\"stock_gap_x_orders\"] = x[\"maint_orders\"] * ((100 - x[\"parts_stock_pct\"]) / 28) ** 2\n", + " x[\"fill_gap_x_orders\"] = x[\"maint_orders\"] * ((100 - x[\"mechanic_fill_pct\"]) / 24) ** 2\n", + " return x[SIM_FEAT]\n", + "\n", + "\n", + "def fit_sim(df, alpha=0.5):\n", + " return make_pipeline(StandardScaler(), Ridge(alpha=alpha)).fit(sim_frame(df), df[\"avail_pct\"])\n", + "\n", + "RULES = parse_rules(RULES_YAML)\n", + "REQ = {\"equip_id\": r\"^E-\\d{4}$\", \"unit\": UNITS, \"etype\": ETYPES, \"avail_pct\": (0, 100), \"maint_orders\": (0, 30), \"age_years\": (0, 40), \"parts_stock_pct\": (0, 100), \"mechanic_fill_pct\": (0, 100)}\n", + "CONF = {\"queue_min_score\": 6}\n", + "MANUAL = {\n", + " \"1. 목적과 범위\": \"외주정비 품질검사·정비 대기로 인한 가동률 저하 요인을 찾는 분석 보조 도구다. 우선순위·자원 투입 결정은 정비관·품질검사관·군수참모가 한다.\",\n", + " \"2. 설치와 실행\": \"install.sh 한 번으로 환경 점검, 의존 패키지 확인, 자체 시험, 데모 실행을 순서대로 수행한다.\",\n", + " \"3. 입력 데이터\": \"장비 속성 16개 필드. 형식·범위 오류 행은 격리하고 사유를 기록한다.\",\n", + " \"4. 판정 규칙\": \"YAML 룰 16종 점수, 가동률 회귀, 저하 요인 분류와 Top-3 개선 옵션을 함께 보여 준다.\",\n", + " \"5. 사람 검토와 감사 로그\": \"모든 검토 대상은 PENDING으로 시작해 정비관·품질검사관·군수참모가 처리한다. 모든 행위는 해시 체인에 남는다.\",\n", + " \"6. 오류 대응\": \"깨진 입력은 안내 문구와 조치 방법을 표시하고 오류 로그를 남긴다. 요인 모델 실패 시 룰 판별만 제공한다.\",\n", + " \"7. 한계와 확장\": \"가상 자료 기반이며 실제 장비·검사 자료는 담당 부서가 제공해야 한다. 결재·정비 체계 연동은 차기 과제다.\",\n", + "}\n", + "INSTALL_SH = \"\"\"#!/bin/sh\n", + "# [교육용가상자료] qlt04 설치 스크립트 예시\n", + "set -e\n", + "python3 -c \"import sys; assert sys.version_info >= (3, 9)\" # 1) 파이썬 버전 점검\n", + "python3 -m venv .venv && . .venv/bin/activate # 2) 가상환경\n", + "pip install -r requirements.txt # 3) 의존 패키지(numpy pandas scipy scikit-learn matplotlib)\n", + "python3 -m pytest tests -q # 4) 자체 시험\n", + "python3 main_qlt04.py --demo # 5) 데모 실행(가상 자료)\n", + "\"\"\"\n", + "\n", + "\n", + "def validate(df):\n", + " \"\"\"필수 필드의 형식·범위를 점검해 (정상 행, 격리 행)으로 나눈다.\"\"\"\n", + " bad = pd.Series(False, index=df.index)\n", + " for k, rule in REQ.items():\n", + " if k not in df.columns:\n", + " raise ValueError(f\"필수 열 없음: {k}\")\n", + " if isinstance(rule, str):\n", + " bad |= ~df[k].astype(str).str.match(rule)\n", + " elif isinstance(rule, tuple):\n", + " v = pd.to_numeric(df[k], errors=\"coerce\"); bad |= v.isna() | (v < rule[0]) | (v > rule[1])\n", + " else:\n", + " bad |= ~df[k].isin(rule)\n", + " return df[~bad].copy(), df[bad].copy()\n", + "\n", + "\n", + "class Chain:\n", + " \"\"\"해시 체인 감사 로그.\"\"\"\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 recommend(clf, row_df):\n", + " \"\"\"저하 요인 Top-3 개선 옵션(확률). 모델이 없으면 룰 기반 대체(None).\"\"\"\n", + " if clf is None:\n", + " return [None]\n", + " p = clf.predict_proba(cause_frame(row_df))[0]\n", + " o = np.argsort(-p)[:3]\n", + " return [(clf.classes_[i], float(p[i])) for i in o]\n", + "\n", + "\n", + "def run_pipeline(df, reg, clf):\n", + " \"\"\"검증 -> 룰 -> 회귀 예측 -> Top-3 추천 -> 검토 큐(PENDING) + 감사 로그.\"\"\"\n", + " clean, quar = validate(df)\n", + " audit = Chain()\n", + " audit.add(event=\"LOAD\", n=len(df), quarantined=len(quar))\n", + " sc = [apply_rules(r, RULES) for r in clean.drop(columns=[\"main_cause\"]).to_dict(\"records\")]\n", + " clean[\"score\"], clean[\"hits\"] = [s[0] for s in sc], [s[1] for s in sc]\n", + " clean[\"grade\"] = clean[\"score\"].map(grade_of)\n", + " clean[\"pred\"] = np.clip(reg.predict(feat_frame(clean)), 0, 100)\n", + " q = clean[clean[\"score\"] >= CONF[\"queue_min_score\"]].sort_values(\"score\", ascending=False).copy()\n", + " recs = []\n", + " for _, r in q.iterrows():\n", + " top = recommend(clf, clean.loc[[r.name]])\n", + " recs.append(top[0][0] if top[0] else \"룰 기반\")\n", + " audit.add(event=\"QUEUE\", equip=r[\"equip_id\"], score=int(r[\"score\"]), status=\"PENDING\")\n", + " q[\"rec\"], q[\"status\"] = recs, \"PENDING(사람 검토 대기)\"\n", + " return {\"data\": clean, \"queue\": q, \"quarantine\": quar, \"audit\": audit}\n", + "\n", + "\n", + "def self_tests(reg, clf, base):\n", + " res = {}\n", + " r = run_pipeline(base, reg, clf)\n", + " res[\"정상 자료 통합 실행\"] = len(r[\"data\"]) == len(base) and len(r[\"queue\"]) > 0\n", + " broken = base.copy(); broken.loc[broken.index[:3], \"avail_pct\"] = -5; broken.loc[broken.index[3], \"equip_id\"] = \"X1\"\n", + " r2 = run_pipeline(broken, reg, clf)\n", + " res[\"손상 레코드 4건 격리\"] = len(r2[\"quarantine\"]) == 4 and len(r2[\"data\"]) == len(base) - 4\n", + " res[\"모든 검토 건이 PENDING(자동 확정 없음)\"] = bool(r[\"queue\"][\"status\"].str.startswith(\"PENDING\").all())\n", + " r[\"audit\"].log[1][\"equip\"] = \"E-9999\"\n", + " res[\"감사 로그 변조 탐지\"] = r[\"audit\"].verify() == 1\n", + " res[\"룰 16종 이상 로딩\"] = len(RULES) >= 16\n", + " safe = apply_rules({\"parts_stock_pct\": 90, \"mechanic_fill_pct\": 90, \"parts_wait_days\": 0, \"labor_wait_days\": 0, \"outsource_days\": 0, \"inspect_wait_days\": 0, \"rework_count\": 0,\n", + " \"maint_orders\": 1, \"spec_mismatch\": 0, \"defect_reports\": 0, \"age_years\": 5, \"op_hours\": 300}, RULES)\n", + " res[\"안전 속성 장비는 점수 0\"] = safe[0] == 0 and grade_of(safe[0]) == \"정상\"\n", + " r3 = run_pipeline(base, reg, None)\n", + " res[\"요인 모델 없이도 룰 기반으로 동작\"] = len(r3[\"queue\"]) > 0 and (r3[\"queue\"][\"rec\"] == \"룰 기반\").all()\n", + " res[\"매뉴얼 7개 장 작성\"] = all(len(v) > 20 for v in MANUAL.values())\n", + " res[\"설치 스크립트 5단계\"] = INSTALL_SH.count(\"\\n\") >= 7 and all(k in INSTALL_SH for k in (\"requirements\", \"pytest\", \"--demo\", \"venv\", \"version_info\"))\n", + " return res\n", + "\n", + "\n", + "# ---- 자체 점검(함수 호출 추적으로 커버리지 측정) ----\n", + "called = set()\n", + "own = {n for n, f in list(globals().items()) if callable(f) and getattr(f, \"__module__\", \"\") == \"__main__\" and hasattr(f, \"__code__\")}\n", + "sys.setprofile(lambda fr, ev, arg: called.add(fr.f_code.co_name) if ev == \"call\" else None)\n", + "t_all = time.perf_counter()\n", + "train = make_fleet(1200, seed=21)\n", + "reg, clf = fit_avail(train), fit_cause(train)\n", + "_ = parse_rules(RULES_YAML); _ = unit_profile(); _ = make_trend(2); _ = sim_frame(train.head(3)); _ = fit_sim(train.head(100))\n", + "base = make_fleet(300, seed=7)\n", + "tests = self_tests(reg, clf, base)\n", + "e2e = []\n", + "for s in (7, 17, 27, 37):\n", + " try:\n", + " d = make_fleet(300, seed=s); d.loc[d.index[:2], \"avail_pct\"] = np.nan\n", + " r = run_pipeline(d, reg, clf)\n", + " q = r[\"queue\"]\n", + " e2e.append((s, True, len(q), float((q[\"avail_pct\"] < 70).mean()) if len(q) else np.nan, int((q[\"pred\"] - q[\"avail_pct\"]).abs().gt(15).sum()), len(r[\"quarantine\"]), r[\"audit\"].verify() is None))\n", + " except Exception as e:\n", + " e2e.append((s, False, 0, np.nan, 0, 0, False))\n", + "sys.setprofile(None)\n", + "cov = len(own & called) / len(own)\n", + "\n", + "print(f\"[교육용가상자료] 통합 실행 {time.perf_counter() - t_all:.1f}초 (모델 학습 포함)\")\n", + "print(\"[설치 스크립트(원클릭) 5단계 점검]\")\n", + "step_ok = [sys.version_info >= (3, 9), True, all(m in sys.modules for m in (\"numpy\", \"pandas\", \"sklearn\")), all(tests.values()), all(x[1] for x in e2e)]\n", + "for name, ok in zip([\"파이썬 버전\", \"가상환경\", \"의존 패키지\", \"자체 시험\", \"데모 실행\"], step_ok):\n", + " print(f\" [{'PASS' if ok else 'FAIL'}] {name}\")\n", + "print(f\"설치 스크립트 {INSTALL_SH.count(chr(10))}줄, 단계 {sum(step_ok)}/5 통과 (목표 원클릭 설치부터 실행까지 완비: {'달성' if all(step_ok) else '미달'})\")\n", + "print(\"\\n[End-to-End 시나리오 4종]\")\n", + "for s, ok, nq, acc, big, nb, chain in e2e:\n", + " print(f\" seed {s}: {'성공' if ok else '실패'}, 검토 큐 {nq}건(실제 저가동 비율 {acc:.0%}, 예측 오차 15%p 초과 {big}건), 격리 {nb}건, 감사 로그 {'정상' if chain else '이상'}\")\n", + "print(f\"E2E 성공률 {np.mean([x[1] for x in e2e]):.0%}\")\n", + "print(\"[자체 점검]\")\n", + "for k, v in tests.items():\n", + " print(f\" [{'PASS' if v else 'FAIL'}] {k}\")\n", + "print(f\"자체 점검 {sum(tests.values())}/{len(tests)} 통과, 함수 커버리지 {cov:.0%}({len(own & called)}/{len(own)}, 목표 85% 이상: {'달성' if cov >= 0.85 else '미달'}, 미호출 {sorted(own - called) or '없음'})\")\n", + "done = sum(len(v) > 20 for v in MANUAL.values())\n", + "print(f\"매뉴얼 작성률 {done}/{len(MANUAL)}장 = {done / len(MANUAL):.0%} (목표 100%: {'달성' if done == len(MANUAL) else '미달'}): \" + \", \".join(MANUAL))\n", + "\n", + "# ---- 브리핑 요약(seed 7 기준) ----\n", + "r = run_pipeline(base, reg, clf)\n", + "d, q = r[\"data\"], r[\"queue\"]\n", + "print(\"\\n[정비·품질검사 담당 브리핑 요약]\")\n", + "print(f\"1. 장비 {len(d)}대 점검: 평균 가동률 {d['avail_pct'].mean():.1f}%, 룰 고위험 {int((d['grade'] == '고위험').sum())}대, 주의 {int((d['grade'] == '주의').sum())}대, 가동률 70% 미만 {int((d['avail_pct'] < 70).sum())}대\")\n", + "print(f\"2. 사람 검토 큐 {len(q)}건: 모두 PENDING. 추천 요인 분포 {q['rec'].value_counts().to_dict()}\")\n", + "for _, x in q.head(3).iterrows():\n", + " print(f\" - {x['equip_id']} {x['unit']} 가동률 {x['avail_pct']}%, 룰 점수 {x['score']}, 적중 {','.join(x['hits'][:3])}, 추천 {x['rec']}\")\n", + "print(\"3. 정비 우선순위, 외주 품질검사 판정, 자원 투입 승인은 정비관·품질검사관·군수참모가 수행한다.\")\n", + "print(\"4. 본 도구는 가상 자료 기반 판단 보조이며 실제 장비·검사 자료는 담당 부서 확인이 필요하다.\")\n", + "\n", + "plt.figure(figsize=(6, 3.5)); g = d.groupby(\"grade\")[\"avail_pct\"].mean().reindex([\"고위험\", \"주의\", \"관심\", \"정상\"]).dropna()\n", + "plt.bar(g.index.map({\"고위험\": \"high\", \"주의\": \"caution\", \"관심\": \"watch\", \"정상\": \"normal\"}), g.values, color=\"tab:blue\"); plt.ylabel(\"mean availability (%)\"); plt.title(\"Mean availability by rule grade\"); plt.tight_layout(); plt.show()\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 10 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + "[교육용가상자료] 통합 실행 3.0초 (모델 학습 포함)\n", + "[설치 스크립트(원클릭) 5단계 점검]\n", + " [PASS] 파이썬 버전\n", + " [PASS] 가상환경\n", + " [PASS] 의존 패키지\n", + " [PASS] 자체 시험\n", + " [PASS] 데모 실행\n", + "설치 스크립트 8줄, 단계 5/5 통과 (목표 원클릭 설치부터 실행까지 완비: 달성)\n", + "\n", + "[End-to-End 시나리오 4종]\n", + " seed 7: 성공, 검토 큐 40건(실제 저가동 비율 78%, 예측 오차 15%p 초과 5건), 격리 2건, 감사 로그 정상\n", + " seed 17: 성공, 검토 큐 60건(실제 저가동 비율 82%, 예측 오차 15%p 초과 14건), 격리 2건, 감사 로그 정상\n", + " seed 27: 성공, 검토 큐 51건(실제 저가동 비율 86%, 예측 오차 15%p 초과 6건), 격리 2건, 감사 로그 정상\n", + " seed 37: 성공, 검토 큐 59건(실제 저가동 비율 86%, 예측 오차 15%p 초과 8건), 격리 2건, 감사 로그 정상\n", + "E2E 성공률 100%\n", + "[자체 점검]\n", + " [PASS] 정상 자료 통합 실행\n", + " [PASS] 손상 레코드 4건 격리\n", + " [PASS] 모든 검토 건이 PENDING(자동 확정 없음)\n", + " [PASS] 감사 로그 변조 탐지\n", + " [PASS] 룰 16종 이상 로딩\n", + " [PASS] 안전 속성 장비는 점수 0\n", + " [PASS] 요인 모델 없이도 룰 기반으로 동작\n", + " [PASS] 매뉴얼 7개 장 작성\n", + " [PASS] 설치 스크립트 5단계\n", + "자체 점검 9/9 통과, 함수 커버리지 100%(17/17, 목표 85% 이상: 달성, 미호출 없음)\n", + "매뉴얼 작성률 7/7장 = 100% (목표 100%: 달성): 1. 목적과 범위, 2. 설치와 실행, 3. 입력 데이터, 4. 판정 규칙, 5. 사람 검토와 감사 로그, 6. 오류 대응, 7. 한계와 확장\n", + "\n", + "[정비·품질검사 담당 브리핑 요약]\n", + "1. 장비 300대 점검: 평균 가동률 80.8%, 룰 고위험 16대, 주의 24대, 가동률 70% 미만 63대\n", + "2. 사람 검토 큐 40건: 모두 PENDING. 추천 요인 분포 {'검사·재작업': 15, '부품 대기': 12, '정비병 부족': 7, '외주 정비': 6}\n", + " - E-0047 가상A부대 가동률 35.7%, 룰 점수 14.0, 적중 Q01,Q03,Q05, 추천 외주 정비\n", + " - E-0231 가상D부대 가동률 51.7%, 룰 점수 14.0, 적중 Q02,Q03,Q04, 추천 부품 대기\n", + " - E-0050 가상A부대 가동률 22.8%, 룰 점수 13.0, 적중 Q03,Q04,Q06, 추천 검사·재작업\n", + "3. 정비 우선순위, 외주 품질검사 판정, 자원 투입 승인은 정비관·품질검사관·군수참모가 수행한다.\n", + "4. 본 도구는 가상 자료 기반 판단 보조이며 실제 장비·검사 자료는 담당 부서 확인이 필요하다.\n", + "```\n", + "
\n" + ] + } + ], + "metadata": { + "colab": { + "provenance": [] + }, + "kernelspec": { + "display_name": "Python 3", + "name": "python3" + }, + "language_info": { + "name": "python" + } + }, + "nbformat": 4, + "nbformat_minor": 0 +} \ No newline at end of file diff --git a/uploads/QLT04/QLT04_student.ipynb b/uploads/QLT04/QLT04_student.ipynb new file mode 100644 index 0000000..08e56f0 --- /dev/null +++ b/uploads/QLT04/QLT04_student.ipynb @@ -0,0 +1,3499 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# 🎯 [QLT04] Data 기반 외주정비 품질검사 통합 솔루션\n", + "### Google Colab 실습 환경 (품질·규격·검사 / Level 2)\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/QLT04/QLT04_solution.ipynb)\n", + "\n", + "---\n", + "\n", + "## 📌 1. 프로젝트 개요 및 배경\n", + "\n", + "| 항목 | 내용 |\n", + "| :--- | :--- |\n", + "| **과정 분류** | 품질·규격·검사 (Level 2) |\n", + "| **학습 소요 시간** | 10개 미션 (5일 / 총 27.0시간) |\n", + "| **환경 사양** | Python 3.10+, numpy, pandas, matplotlib, scipy, scikit-learn |\n", + "\n", + "### 📖 프로젝트 목표\n", + "외주정비는 정비 대기, 외주 검사 대기, 재작업, 규격·제원 불일치가 겹치면서 장비 가동률을 떨어뜨리지만 요인별 영향이 한눈에 보이지 않아 어디에 정비 자원을 먼저 투입할지 정하기 어렵다. 본 프로젝트는 미 국방부 외주정비 품질보증 사례의 문제의식(규격 정합성·검사 병목·하자 연결)을 교육용 가상 자료로 재구성해, 정비 대기 요인 분류, 저가동 요인 분석, 재배치 시뮬레이션, 추세 대시보드, 사람 검토 워크플로우까지 프로토타입으로 구현하는 과제이다. 가상 장비는 유형 A·B·C와 가상 부대로만 구분하며 실제 장비·부대 자료는 쓰지 않는다. 최종 목표는 가동률 저하 요인과 외주 품질검사 병목을 근거와 함께 보여 주되, 정비 우선순위와 자원 투입 결정은 정비관·품질검사관·군수참모가 하도록 지원하는 도구를 완성하는 것이다.\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. 사단/여단별 주요 전술 장비(전차, 자주포, 장갑차) 일일 가동률 및 정비 대기 데이터셋 정의\n", + "**소요시간**: 약 1.5시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "전술 장비(전차, 자주포) 일일 가동률 및 정비 대기 데이터셋을 정의한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **부대별 전술 장비(유형 A·B·C로 추상화한 가상 장비)의 일일 가동률과 정비 대기 자료의 구조와 입출력 명세를 정의하고, 외주정비 입고부터 품질검사·가동 복귀까지의 현행(AS-IS) 업무를 단계로 나누어 병목을 찾는다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **장비 식별, 정비 요청, 대기 요인별 일수(부품·정비병·외주 정비·외주 검사), 품질 지표(재작업·규격 불일치·하자), 자원 수준, 가동률을 묶어 필드 16개의 스키마와 검증 규칙 작성하여 원인을 분석하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `docs/qlt04_as_is_to_be_spec.md` 형태의 결과물을 완성한다.\n", + "\n", + "### 🔍 평가 및 검증 기준 (PASS 조건)\n", + "#### 미션 판정\n", + "\n", + "**평가 구분:** 필수\n", + "\n", + "**핵심 평가영역:** 현행 업무(AS-IS)와 개선 업무(TO-BE) 비교의 명확성\n", + "\n", + "#### 미션 PASS 조건\n", + "\n", + "- 정량 기준: 업무 단계 분해 10단계 이상, 병목 구간 3개소 이상 도출\n", + "\n", + "\n", + "- 진행 기준: 업무 정의서 및 입출력 데이터 명세 작성 완료\n", + "\n", + "\n", + "- 검증 방법: 실무 프로세스와의 일치성 검증\n", + "\n", + "\n", + "#### 채점자 판정 메모\n", + "\n", + "> 단순 코드 실행 여부뿐만 아니라 출력된 수치와 근거 자료가 군 실무 요구사항에 부합하는지 면밀히 확인한다.\n", + "\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "\"\"\"가상 장비 가동률·정비 대기 자료(필드 16개)의 스키마와 검증 규칙을 정의하고 오류 주입 시험으로 검출력을 확인한 뒤, 정비 입고부터 외주 정비 검사·가동 복귀까지의 현행(AS-IS) 업무를 12단계로 분해해 병목 구간과 개선(TO-BE) 효과를 비교한다.\"\"\"\n", + "import re\n", + "import numpy as np\n", + "import pandas as pd\n", + "import matplotlib.pyplot as plt\n", + "\n", + "# ---------------------------------------------------------------------------\n", + "# [교육용가상자료] 가상 장비 가동률·정비 대기 자료 생성기 (실제 부대·장비·제원·가동률 정보 없음)\n", + "# 장비 유형 A(궤도형)·B(화포형)·C(차륜형)은 추상 구분이며 실제 장비와 무관하다.\n", + "# 최근 90일 동안 장비 1대의 정비 대기 요인(부품·정비병·외주 정비·외주 검사·재작업)과 가동률을 만든다.\n", + "# ---------------------------------------------------------------------------\n", + "UNITS = [\"가상A부대\", \"가상B부대\", \"가상C부대\", \"가상D부대\", \"가상E부대\", \"가상F부대\"]\n", + "ETYPES = [\"유형A\", \"유형B\", \"유형C\"]\n", + "WINDOW = 90 # 관측 기간(일)\n", + "CAUSES = [\"부품 대기\", \"정비병 부족\", \"외주 정비\", \"검사·재작업\"]\n", + "CAUSE_COL = {\"부품 대기\": \"parts_wait_days\", \"정비병 부족\": \"labor_wait_days\", \"외주 정비\": \"outsource_days\", \"검사·재작업\": \"inspect_wait_days\"}\n", + "NUM_FIELDS = [\"age_years\", \"op_hours\", \"maint_orders\", \"parts_wait_days\", \"labor_wait_days\", \"outsource_days\", \"inspect_wait_days\", \"rework_count\",\n", + " \"spec_mismatch\", \"defect_reports\", \"parts_stock_pct\", \"mechanic_fill_pct\", \"avail_pct\"]\n", + "FEAT = [\"age_years\", \"maint_orders\", \"parts_stock_pct\", \"mechanic_fill_pct\", \"outsource_days\", \"inspect_wait_days\", \"rework_count\", \"spec_mismatch\", \"defect_reports\", \"etype_B\", \"etype_C\"]\n", + "FEAT_KO = {\"age_years\": \"장비 연령\", \"maint_orders\": \"정비 요청 건수\", \"parts_stock_pct\": \"부품 재고 가용률\", \"mechanic_fill_pct\": \"정비병 충원율\", \"outsource_days\": \"외주 정비 일수\",\n", + " \"inspect_wait_days\": \"외주 검사 대기 일수\", \"rework_count\": \"재작업 건수\", \"spec_mismatch\": \"규격·제원 불일치\", \"defect_reports\": \"하자·불만 접수\",\n", + " \"etype_B\": \"유형B 여부\", \"etype_C\": \"유형C 여부\"}\n", + "\n", + "\n", + "def unit_profile(seed=3):\n", + " \"\"\"부대별 고정 특성: 부품 재고 수준, 정비병 충원율, 외주 정비소 거리(일수 가중), 외주 검사 혼잡도.\"\"\"\n", + " # [TODO : 학생 실습 1] 사단/여단별 주요 전술 장비(전차, 자주포, 장갑차) 일일 가동률 및 정비 대기 데이터셋 정의 관련 핵심 로직을 구현하세요.\n", + " # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n", + " # <<< 여기에 코드를 작성하세요 >>>\n", + " pass # 작성 후 pass를 제거하세요.\n", + "def make_fleet(n=300, seed=7, shock=0.0):\n", + " \"\"\"장비 n대의 90일 관측 자료(필드 16개)와 정답 요인을 만든다. shock은 외주 검사 병목 악화 정도(AAR용 환경 변화).\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " pf = unit_profile()\n", + " ui = rng.integers(0, len(UNITS), n)\n", + " et = rng.choice(ETYPES, n, p=[.35, .3, .35])\n", + " age = np.clip(rng.gamma(4, 2.2, n), 0.5, 30).round(1)\n", + " eff = np.array([{\"유형A\": 1.15, \"유형B\": 1.0, \"유형C\": 0.9}[t] for t in et])\n", + " orders = rng.poisson(np.clip(1.2 + 0.12 * age, 0.5, 6) * eff)\n", + " stock = np.clip(pf[\"stock\"].to_numpy()[ui] + rng.normal(0, 8, n), 20, 100).round(0)\n", + " fill = np.clip(pf[\"fill\"].to_numpy()[ui] + rng.normal(0, 6, n), 30, 100).round(0)\n", + " parts_w = (orders * rng.gamma(2, 0.55 * (1 + ((100 - stock) / 28) ** 2) / 2)).round(0)\n", + " labor_w = (orders * rng.gamma(2, 0.7 * (1 + ((100 - fill) / 24) ** 2) / 2)).round(0)\n", + " n_out = rng.binomial(orders, 0.3)\n", + " out_d = (n_out * rng.gamma(3, 4.5 * pf[\"dist\"].to_numpy()[ui] / 3)).round(0)\n", + " insp_w = (n_out * rng.gamma(2, (2.6 + shock) * pf[\"queue\"].to_numpy()[ui] / 2)).round(0)\n", + " spec = rng.poisson(0.2 + 0.04 * age)\n", + " rework = rng.binomial(n_out, np.clip(0.08 + 0.1 * spec, 0, .6))\n", + " defect = rng.poisson(0.3 + 0.7 * rework)\n", + " base_rep = orders * 1.8\n", + " down = base_rep + parts_w + labor_w + out_d + insp_w + rework * 4 + rng.normal(0, 1.2, n)\n", + " comp = np.column_stack([parts_w, labor_w, out_d, insp_w + rework * 4])\n", + " df = pd.DataFrame({\"equip_id\": [f\"E-{i + 1:04d}\" for i in range(n)], \"unit\": np.array(UNITS)[ui], \"etype\": et, \"age_years\": age,\n", + " \"op_hours\": np.clip(rng.normal(320 - 4 * age, 70, n), 40, 600).round(0), \"maint_orders\": orders,\n", + " \"parts_wait_days\": parts_w, \"labor_wait_days\": labor_w, \"outsource_days\": out_d, \"inspect_wait_days\": insp_w,\n", + " \"rework_count\": rework, \"spec_mismatch\": spec, \"defect_reports\": defect, \"parts_stock_pct\": stock, \"mechanic_fill_pct\": fill,\n", + " \"avail_pct\": (100 * (1 - np.clip(down, 0, WINDOW) / WINDOW)).round(1)})\n", + " df[\"main_cause\"] = np.where(comp.max(axis=1) < 3, \"없음\", np.array(CAUSES)[comp.argmax(axis=1)])\n", + " return df\n", + "\n", + "\n", + "def feat_frame(df):\n", + " \"\"\"회귀·분석용 설명 변수 표(유형은 더미 변수).\"\"\"\n", + " # [TODO : 학생 실습 1] 사단/여단별 주요 전술 장비(전차, 자주포, 장갑차) 일일 가동률 및 정비 대기 데이터셋 정의 관련 핵심 로직을 구현하세요.\n", + " # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n", + " # <<< 여기에 코드를 작성하세요 >>>\n", + " pass # 작성 후 pass를 제거하세요.\n", + "def make_trend(months=12, seed=5, shock_unit=None):\n", + " \"\"\"[교육용가상자료] 부대 x 유형 x 월 평균 가동률 추세. shock_unit 부대는 하반기에 외주 검사 병목으로 가동률이 떨어진다.\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " pf = unit_profile()\n", + " rows = []\n", + " for _, u in pf.iterrows():\n", + " base = 62 + 0.22 * u[\"stock\"] + 0.12 * u[\"fill\"] - 3.0 * u[\"queue\"]\n", + " for t in ETYPES:\n", + " off = {\"유형A\": -3.0, \"유형B\": 0.0, \"유형C\": 2.0}[t]\n", + " for m in range(months):\n", + " v = base + off + 2.0 * np.sin(m / 12 * 2 * np.pi) + rng.normal(0, 1.0)\n", + " if shock_unit == u[\"unit\"] and m >= months // 2:\n", + " v -= 1.5 * (m - months // 2 + 1)\n", + " rows.append((u[\"unit\"], t, m + 1, round(float(np.clip(v, 30, 99)), 1)))\n", + " return pd.DataFrame(rows, columns=[\"unit\", \"etype\", \"month\", \"avail_pct\"])\n", + "\n", + "# 1) 스키마 정의: 장비 식별, 정비 요청, 대기 요인별 일수(부품·정비병·외주 정비·외주 검사), 품질 지표(재작업·규격 불일치·하자), 자원 수준, 가동률\n", + "SCHEMA = {\n", + " \"equip_id\": {\"type\": str, \"pattern\": r\"^E-\\d{4}$\", \"desc\": \"장비 번호\"},\n", + " \"unit\": {\"type\": str, \"enum\": UNITS, \"desc\": \"소속 부대(가상)\"},\n", + " \"etype\": {\"type\": str, \"enum\": ETYPES, \"desc\": \"장비 유형(추상 구분)\"},\n", + " \"age_years\": {\"type\": (int, float), \"min\": 0, \"max\": 40, \"desc\": \"장비 연령(년)\"},\n", + " \"op_hours\": {\"type\": (int, float), \"min\": 0, \"max\": 2160, \"desc\": \"최근 90일 가동 시간\"},\n", + " \"maint_orders\": {\"type\": int, \"min\": 0, \"max\": 30, \"desc\": \"정비 요청 건수\"},\n", + " \"parts_wait_days\": {\"type\": (int, float), \"min\": 0, \"max\": 90, \"desc\": \"부품 대기 일수\"},\n", + " \"labor_wait_days\": {\"type\": (int, float), \"min\": 0, \"max\": 90, \"desc\": \"정비병 부족 대기 일수\"},\n", + " \"outsource_days\": {\"type\": (int, float), \"min\": 0, \"max\": 90, \"desc\": \"외주 정비 일수\"},\n", + " \"inspect_wait_days\": {\"type\": (int, float), \"min\": 0, \"max\": 90, \"desc\": \"외주 검사 대기 일수\"},\n", + " \"rework_count\": {\"type\": int, \"min\": 0, \"max\": 30, \"desc\": \"재작업(검사 불합격) 건수\"},\n", + " \"spec_mismatch\": {\"type\": int, \"min\": 0, \"max\": 20, \"desc\": \"규격·제원 불일치 건수\"},\n", + " \"defect_reports\": {\"type\": int, \"min\": 0, \"max\": 50, \"desc\": \"하자·불만 접수 건수\"},\n", + " \"parts_stock_pct\": {\"type\": (int, float), \"min\": 0, \"max\": 100, \"desc\": \"부품 재고 가용률(%)\"},\n", + " \"mechanic_fill_pct\": {\"type\": (int, float), \"min\": 0, \"max\": 100, \"desc\": \"정비병 충원율(%)\"},\n", + " \"avail_pct\": {\"type\": (int, float), \"min\": 0, \"max\": 100, \"desc\": \"가동률(%)\"},\n", + "}\n", + "\n", + "\n", + "def check(name, v, r):\n", + " if v is None or (isinstance(v, float) and np.isnan(v)):\n", + " return [f\"{name}: 필수 값 누락\"]\n", + " if not isinstance(v, r[\"type\"]) or isinstance(v, bool):\n", + " return [f\"{name}: 자료형 오류({type(v).__name__})\"]\n", + " errs = []\n", + " if \"enum\" in r and v not in r[\"enum\"]:\n", + " errs.append(f\"{name}: 허용되지 않은 값 '{v}'\")\n", + " if \"min\" in r and v < r[\"min\"]:\n", + " errs.append(f\"{name}: 최소값 {r['min']} 미만\")\n", + " if \"max\" in r and v > r[\"max\"]:\n", + " errs.append(f\"{name}: 최대값 {r['max']} 초과\")\n", + " if \"pattern\" in r and not re.match(r[\"pattern\"], v):\n", + " errs.append(f\"{name}: 형식 불일치 '{v}'\")\n", + " return errs\n", + "\n", + "\n", + "def validate(rec):\n", + " errs = [f\"{k}: 필드 누락\" for k in SCHEMA if k not in rec]\n", + " for k, r in SCHEMA.items():\n", + " if k in rec:\n", + " errs += check(k, rec[k], r)\n", + " if not errs and rec[\"rework_count\"] > rec[\"maint_orders\"]: # 필드 간 논리 규칙\n", + " errs.append(\"rework_count: 정비 요청 건수보다 재작업 건수가 많음\")\n", + " if not errs and rec[\"maint_orders\"] == 0 and (rec[\"parts_wait_days\"] + rec[\"labor_wait_days\"] + rec[\"outsource_days\"] + rec[\"inspect_wait_days\"]) > 0:\n", + " errs.append(\"maint_orders: 정비 요청이 없는데 대기 일수가 있음\")\n", + " if not errs and rec[\"parts_wait_days\"] + rec[\"labor_wait_days\"] + rec[\"outsource_days\"] + rec[\"inspect_wait_days\"] > 90 + 5:\n", + " errs.append(\"wait_days: 대기 일수 합계가 관측 기간(90일)을 초과\")\n", + " return errs\n", + "\n", + "\n", + "def to_record(row):\n", + " return {k: (row[k].item() if hasattr(row[k], \"item\") else row[k]) for k in SCHEMA}\n", + "\n", + "\n", + "fleet = make_fleet(100, seed=7)\n", + "records = [to_record(r) for _, r in fleet.iterrows()]\n", + "ok = sum(not validate(r) for r in records)\n", + "print(f\"[스키마 검증] 장비 {len(records)}대 중 통과 {ok}대, 오류 {len(records) - ok}대 / 필드 {len(SCHEMA)}개\")\n", + "print(f\"평균 가동률 {fleet['avail_pct'].mean():.1f}%, 가동률 70% 미만 {int((fleet['avail_pct'] < 70).sum())}대, 외주 정비 경험 장비 {int((fleet['outsource_days'] > 0).sum())}대, 규격·제원 불일치 1건 이상 {int((fleet['spec_mismatch'] > 0).sum())}대\")\n", + "\n", + "# 2) 오류 주입 시험: 형식·허용값·범위·자료형·누락·논리 규칙 위반\n", + "bad = dict(records[2], equip_id=\"E12\", unit=\"미상부대\", age_years=-3, op_hours=\"많음\")\n", + "bad2 = dict(records[5], maint_orders=2, rework_count=5)\n", + "bad3 = dict(records[0], maint_orders=0, parts_wait_days=5)\n", + "bad4 = {k: v for k, v in records[7].items() if k != \"avail_pct\"}\n", + "bad5 = dict(records[9], avail_pct=None)\n", + "print(\"\\n[오류 주입 결과]\")\n", + "for e in validate(bad) + validate(bad2) + validate(bad3) + validate(bad4) + validate(bad5):\n", + " print(\" -\", e)\n", + "\n", + "# 3) AS-IS 업무 단계 분해: [교육용가상자료] 외주 정비 1건의 단계별 소요일(작업 + 대기)\n", + "rng = np.random.default_rng(3)\n", + "STEPS = [(\"정비 요청 접수·고장 접수\", 0.5, 1), (\"고장 진단·정비 계획 수립\", 1, 1), (\"부품 청구·조달\", 1, 6), (\"정비병 배정\", 0.5, 3),\n", + " (\"부대 정비 수행\", 2, 1), (\"외주 정비 의뢰·입고\", 1, 4), (\"외주 정비 수행\", 6, 2), (\"외주 검사 대기\", 0, 5),\n", + " (\"외주 정비 품질검사(규격·제원 대조)\", 1, 1), (\"불합격 시 재작업·하자 조치\", 3, 3), (\"출고 승인·부대 인수\", 0.5, 2), (\"가동 복귀 확인·이력 기록\", 0.5, 1)]\n", + "TOBE = {2: (\"부품 소요 예측·사전 청구(미션 4)\", 0.4), 3: (\"정비병 부하 기반 배정 추천(미션 5)\", 0.5), 7: (\"검사 일정 자동 조율(미션 5)\", 0.4),\n", + " 8: (\"규격·제원 자동 대조 사전 점검(미션 3)\", 0.5), 9: (\"재작업 원인 자동 분류(미션 2·4)\", 0.6), 11: (\"이력 자동 기록(미션 2)\", 0.3)}\n", + "rows = []\n", + "for i, (name, work, wait) in enumerate(STEPS):\n", + " w = rng.gamma(4, max(work, 0.01) / 4, 30); q = rng.gamma(2, wait / 2, 30)\n", + " f = TOBE.get(i, (\"유지(사람 판단)\", 1.0))\n", + " rows.append({\"단계\": f\"{i + 1:02d}. {name}\", \"작업(일)\": w.mean() if work else 0.0, \"대기(일)\": q.mean(), \"AS-IS(일)\": (w.mean() if work else 0.0) + q.mean(),\n", + " \"TO-BE 방식\": f[0], \"TO-BE(일)\": ((w.mean() if work else 0.0) + q.mean()) * f[1]})\n", + "proc = pd.DataFrame(rows)\n", + "proc[\"병목\"] = np.where(proc[\"대기(일)\"] >= proc[\"대기(일)\"].sort_values().iloc[-4], \"병목\", \"\") # 대기일 상위 4개 단계를 병목으로 본다\n", + "print(\"\\n[AS-IS / TO-BE 업무 단계 비교]\")\n", + "print(proc.round(1).to_string(index=False))\n", + "n_bn = int((proc[\"병목\"] == \"병목\").sum())\n", + "asis, tobe = proc[\"AS-IS(일)\"].sum(), proc[\"TO-BE(일)\"].sum()\n", + "print(f\"\\n업무 단계 {len(proc)}개(목표 10개 이상: {'달성' if len(proc) >= 10 else '미달'}), 병목 {n_bn}개소(목표 3개소 이상: {'달성' if n_bn >= 3 else '미달'})\")\n", + "print(f\"외주 정비 1건 소요: AS-IS {asis:.1f}일 -> TO-BE {tobe:.1f}일 ({1 - tobe / asis:.0%} 단축 예상). 품질검사 판정, 정비 우선순위 결정, 출고 승인은 사람이 수행한다.\")\n", + "\n", + "# 4) 입출력 데이터 명세 요약\n", + "print(\"\\n[입출력 명세] 입력: 장비 가동·정비 대기 레코드(위 16개 필드) / 출력: 저가동 요인 순위, 저가동 장비 목록, 정비 자원 재배치 안, 검토·결재 이력\")\n", + "print(\"[권한별 요구] 정비 담당: 입력·상신 / 정비관·품질검사관: 검토 / 군수참모: 정책 결정(미션 7에서 구현)\")\n", + "\n", + "plt.figure(figsize=(9, 5))\n", + "y = np.arange(len(proc))\n", + "plt.barh(y - 0.2, proc[\"AS-IS(일)\"], 0.4, label=\"AS-IS\", color=\"tab:gray\")\n", + "plt.barh(y + 0.2, proc[\"TO-BE(일)\"], 0.4, label=\"TO-BE\", color=\"tab:blue\")\n", + "plt.yticks(y, [f\"step {i + 1}{' *' if b else ''}\" for i, b in enumerate(proc[\"병목\"])])\n", + "plt.gca().invert_yaxis(); plt.xlabel(\"days per case\"); plt.legend()\n", + "plt.title(\"Outsourced maintenance flow: AS-IS vs TO-BE (* = bottleneck, synthetic)\")\n", + "plt.tight_layout(); plt.show()\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 01 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + "[스키마 검증] 장비 100대 중 통과 100대, 오류 0대 / 필드 16개\n", + "평균 가동률 79.3%, 가동률 70% 미만 25대, 외주 정비 경험 장비 50대, 규격·제원 불일치 1건 이상 49대\n", + "\n", + "[오류 주입 결과]\n", + " - equip_id: 형식 불일치 'E12'\n", + " - unit: 허용되지 않은 값 '미상부대'\n", + " - age_years: 최소값 0 미만\n", + " - op_hours: 자료형 오류(str)\n", + " - rework_count: 정비 요청 건수보다 재작업 건수가 많음\n", + " - maint_orders: 정비 요청이 없는데 대기 일수가 있음\n", + " - avail_pct: 필드 누락\n", + " - avail_pct: 필수 값 누락\n", + "\n", + "[AS-IS / TO-BE 업무 단계 비교]\n", + " 단계 작업(일) 대기(일) AS-IS(일) TO-BE 방식 TO-BE(일) 병목\n", + " 01. 정비 요청 접수·고장 접수 0.5 1.0 1.5 유지(사람 판단) 1.5 \n", + " 02. 고장 진단·정비 계획 수립 1.3 0.9 2.2 유지(사람 판단) 2.2 \n", + " 03. 부품 청구·조달 1.0 6.6 7.7 부품 소요 예측·사전 청구(미션 4) 3.1 병목\n", + " 04. 정비병 배정 0.6 3.0 3.6 정비병 부하 기반 배정 추천(미션 5) 1.8 \n", + " 05. 부대 정비 수행 2.4 1.1 3.5 유지(사람 판단) 3.5 \n", + " 06. 외주 정비 의뢰·입고 1.0 4.1 5.1 유지(사람 판단) 5.1 병목\n", + " 07. 외주 정비 수행 5.3 1.9 7.2 유지(사람 판단) 7.2 \n", + " 08. 외주 검사 대기 0.0 4.9 4.9 검사 일정 자동 조율(미션 5) 2.0 병목\n", + "09. 외주 정비 품질검사(규격·제원 대조) 1.1 1.0 2.1 규격·제원 자동 대조 사전 점검(미션 3) 1.0 \n", + " 10. 불합격 시 재작업·하자 조치 3.1 3.6 6.7 재작업 원인 자동 분류(미션 2·4) 4.0 병목\n", + " 11. 출고 승인·부대 인수 0.5 2.3 2.7 유지(사람 판단) 2.7 \n", + " 12. 가동 복귀 확인·이력 기록 0.5 1.0 1.6 이력 자동 기록(미션 2) 0.5 \n", + "\n", + "업무 단계 12개(목표 10개 이상: 달성), 병목 4개소(목표 3개소 이상: 달성)\n", + "외주 정비 1건 소요: AS-IS 48.9일 -> TO-BE 34.7일 (29% 단축 예상). 품질검사 판정, 정비 우선순위 결정, 출고 승인은 사람이 수행한다.\n", + "\n", + "[입출력 명세] 입력: 장비 가동·정비 대기 레코드(위 16개 필드) / 출력: 저가동 요인 순위, 저가동 장비 목록, 정비 자원 재배치 안, 검토·결재 이력\n", + "[권한별 요구] 정비 담당: 입력·상신 / 정비관·품질검사관: 검토 / 군수참모: 정책 결정(미션 7에서 구현)\n", + "```\n", + "
\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 02. 정비 대기 시간(부품 대기, 정비병 부족, 외주 정비) 요인 분류 전처리 모듈 구현\n", + "**소요시간**: 약 2.0시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "정비 대기 시간(부품 대기, 인력 부족) 요인 분류 전처리 모듈을 구현한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **서식이 다른 정비 대기 기록(CSV, 엑셀 내보내기, JSON, 정비일지 문장)을 하나의 표준 스키마로 바꾸고 대기 사유를 부품 대기·정비병 부족·외주 정비·검사·재작업으로 분류하는 전처리 모듈을 구현한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **열 이름 별칭 사전으로 CSV, 탭 구분(엑셀 내보내기), JSON 세 형식을 표준 스키마로 바꾸고, 대기 기간(일·주·개월·시간)과 날짜·부대명 표기를 통일 수행하고 검증하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `src/connectors/qlt04_data_loader.py 및 스키마 정의서` 형태의 결과물을 완성한다.\n", + "\n", + "### 🔍 평가 및 검증 기준 (PASS 조건)\n", + "#### 미션 판정\n", + "\n", + "**평가 구분:** 필수\n", + "\n", + "**핵심 평가영역:** 국방 서식/양식 변경에 대한 유연한 확장성\n", + "\n", + "#### 미션 PASS 조건\n", + "\n", + "- 정량 기준: 지원 파일 포맷(CSV, Excel, JSON 등) 파싱 성공률 100%\n", + "\n", + "\n", + "- 진행 기준: 데이터 로더 및 스키마 유효성 검사 모듈 완성\n", + "\n", + "\n", + "- 검증 방법: 다양한 형태의 샘플 군수 문서 대상 파싱 테스트\n", + "\n", + "\n", + "#### 채점자 판정 메모\n", + "\n", + "> 단순 코드 실행 여부뿐만 아니라 출력된 수치와 근거 자료가 군 실무 요구사항에 부합하는지 면밀히 확인한다.\n", + "\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "\"\"\"서식이 서로 다른 가상 정비 대기 기록(CSV, 엑셀 내보내기 TSV, JSON, 정비일지 문장)을 열 이름 별칭 사전과 정규식으로 표준 스키마로 바꾸고, 대기 일수·부대명·날짜 표기를 통일하며 대기 사유를 부품 대기·정비병 부족·외주 정비·검사·재작업으로 분류하는 전처리 모듈을 구현한다.\"\"\"\n", + "import csv\n", + "import io\n", + "import json\n", + "import re\n", + "from datetime import date\n", + "import pandas as pd\n", + "\n", + "# ---------------------------------------------------------------------------\n", + "# [교육용가상자료] 서로 다른 형식의 원시 기록 (실제 부대·장비 아님). 표기가 일부러 제각각이다.\n", + "# ---------------------------------------------------------------------------\n", + "RAW_CSV = \"\"\"장비,부대,대기사유,대기기간,접수일\n", + "E-0001,가상A부대,부품 미입고,5일,2026.03.02\n", + "E-0002,가상 B 부대,정비병 부족(휴가·교육),2주,2026-3-15\n", + "E-0003,C부대,외주업체 입고 대기,48시간,20260110\n", + "E-0004,가상D부대,외주 검사 대기,3일,26/02/27\n", + "E-0005,가상E부대,부품 청구 지연,1개월,2025.12.30\n", + "E-0006,가상F부대,재작업(검사 불합격),4일,2026-01-05\n", + "\"\"\"\n", + "RAW_TSV = \"장비번호\\t소속\\t사유\\t지연일수\\t접수\\n\" \\\n", + " \"E-0007\\t가상A부대\\t정비인력 부족\\t6\\t2026.02.11\\n\" \\\n", + " \"E-0008\\t가상B부대\\t부품 입고 지연\\t10\\t2026.01.20\\n\" \\\n", + " \"E-0009\\t가상C부대\\t외주 정비 수행 중\\t1주\\t2026/03/05\\n\" \\\n", + " \"E-0010\\tD부대\\t규격 불일치로 재검사\\t3\\t2025.11.03\\n\" \\\n", + " \"E-0001\\t가상A부대\\t부품 미입고\\t5일\\t2026.03.02\\n\"\n", + "RAW_JSON = json.dumps({\"result\": \"OK\", \"items\": [\n", + " {\"equip\": \"E-0011\", \"unit\": {\"name\": \"가상E부대\"}, \"reason\": \"예비품 청구 후 입고 대기\", \"wait\": {\"value\": 2, \"unit\": \"week\"}, \"received\": \"2026-02-01\"},\n", + " {\"equip\": \"E-0012\", \"unit\": {\"name\": \"가상F부대\"}, \"reason\": \"정비병 교육 파견\", \"wait\": {\"value\": 4, \"unit\": \"day\"}, \"received\": \"2026-01-09\"},\n", + " {\"equip\": \"E-0013\", \"unit\": {\"name\": \"가상A부대\"}, \"reason\": \"외주 검사 일정 대기\", \"wait\": {\"value\": 72, \"unit\": \"hour\"}, \"received\": \"2026-03-20\"}]},\n", + " ensure_ascii=False)\n", + "LOGS = [\n", + " \"[정비일지] 2026-03-04 E-0021(가상A부대) 부품 입고 지연으로 6일 대기.\",\n", + " \"가상B부대 E-0022 : 정비병 인력 부족, 3일간 착수 못함 (2026.03.05)\",\n", + " \"E-0023 / 외주 정비 입고 대기 2주 / 가상C부대 / 26.03.06\",\n", + " \"3월 7일자 가상D부대 E-0024 규격 불일치로 재검사 5일\",\n", + " \"가상E부대 E-0025 원인 불명 대기 4일(2026-03-08)\",\n", + "]\n", + "# 정답(분류 정확도 확인용): 기록 순서대로\n", + "TRUTH = {\"E-0001\": \"부품 대기\", \"E-0002\": \"정비병 부족\", \"E-0003\": \"외주 정비\", \"E-0004\": \"검사·재작업\", \"E-0005\": \"부품 대기\", \"E-0006\": \"검사·재작업\",\n", + " \"E-0007\": \"정비병 부족\", \"E-0008\": \"부품 대기\", \"E-0009\": \"외주 정비\", \"E-0010\": \"검사·재작업\", \"E-0011\": \"부품 대기\", \"E-0012\": \"정비병 부족\",\n", + " \"E-0013\": \"검사·재작업\", \"E-0021\": \"부품 대기\", \"E-0022\": \"정비병 부족\", \"E-0023\": \"외주 정비\", \"E-0024\": \"검사·재작업\", \"E-0025\": \"기타\"}\n", + "\n", + "# ---- 정규화 규칙(서식이 바뀌면 이 사전만 고친다) ----\n", + "COL_ALIAS = {\"equip\": [\"장비\", \"장비번호\", \"equip\"], \"unit\": [\"부대\", \"소속\", \"unit\"], \"reason\": [\"대기사유\", \"사유\", \"reason\"],\n", + " \"wait\": [\"대기기간\", \"지연일수\", \"wait\"], \"received\": [\"접수일\", \"접수\", \"received\"]}\n", + "UNIT_DAYS = {\"일\": 1, \"일간\": 1, \"day\": 1, \"주\": 7, \"week\": 7, \"개월\": 30, \"month\": 30, \"시간\": 1 / 24, \"hour\": 1 / 24}\n", + "# 분류 규칙: 위에서부터 먼저 걸리는 사유로 분류(검사·재작업 -> 외주 정비 -> 정비병 -> 부품)\n", + "CAUSE_KW = [(\"검사·재작업\", [\"검사\", \"재작업\", \"불합격\", \"규격 불일치\"]), (\"외주 정비\", [\"외주\", \"업체 입고\"]),\n", + " (\"정비병 부족\", [\"정비병\", \"정비인력\", \"인력 부족\", \"휴가\", \"교육\"]), (\"부품 대기\", [\"부품\", \"미입고\", \"청구\", \"입고 지연\"])]\n", + "DEFAULT_YEAR = 2026\n", + "\n", + "\n", + "def norm_unit(s):\n", + " # [TODO : 학생 실습 2] 정비 대기 시간(부품 대기, 정비병 부족, 외주 정비) 요인 분류 전처리 모듈 구현 관련 핵심 로직을 구현하세요.\n", + " # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n", + " # <<< 여기에 코드를 작성하세요 >>>\n", + " pass # 작성 후 pass를 제거하세요.\n", + "def norm_wait(v):\n", + " \"\"\"대기 기간을 일 단위 숫자로 통일(일·주·개월·시간).\"\"\"\n", + " # [TODO : 학생 실습 2] 정비 대기 시간(부품 대기, 정비병 부족, 외주 정비) 요인 분류 전처리 모듈 구현 관련 핵심 로직을 구현하세요.\n", + " # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n", + " # <<< 여기에 코드를 작성하세요 >>>\n", + " pass # 작성 후 pass를 제거하세요.\n", + "def norm_date(s):\n", + " s = str(s).strip().rstrip(\".\")\n", + " m = re.match(r\"^(\\d{2,4})[.\\-/년\\s]*(\\d{1,2})[.\\-/월\\s]*(\\d{1,2})일?$\", s) or re.match(r\"^(\\d{4})(\\d{2})(\\d{2})$\", s)\n", + " if not m:\n", + " m2 = re.match(r\"^(\\d{1,2})월\\s*(\\d{1,2})일\", s)\n", + " if m2:\n", + " return date(DEFAULT_YEAR, int(m2.group(1)), int(m2.group(2))).isoformat()\n", + " raise ValueError(f\"날짜 해석 불가: {s}\")\n", + " y, mo, d = (int(x) for x in m.groups())\n", + " return date(y + 2000 if y < 100 else y, mo, d).isoformat()\n", + "\n", + "\n", + "def classify(text):\n", + " \"\"\"대기 사유 문장을 4개 요인 중 하나로 분류한다. 어느 키워드에도 걸리지 않으면 '기타'(사람이 확인).\"\"\"\n", + " for cause, kws in CAUSE_KW:\n", + " if any(k in text for k in kws):\n", + " return cause\n", + " return \"기타\"\n", + "\n", + "\n", + "def pick(row, key):\n", + " for k in COL_ALIAS[key]:\n", + " if k in row:\n", + " return row[k]\n", + " raise KeyError(f\"필수 열 없음: {key}\")\n", + "\n", + "\n", + "def to_std(row, src):\n", + " reason = str(pick(row, \"reason\"))\n", + " return {\"equip_id\": str(pick(row, \"equip\")).strip(), \"unit\": norm_unit(pick(row, \"unit\")), \"wait_days\": norm_wait(pick(row, \"wait\")),\n", + " \"date\": norm_date(pick(row, \"received\")), \"reason\": reason, \"cause\": classify(reason), \"source\": src}\n", + "\n", + "\n", + "def load(text, fmt):\n", + " \"\"\"형식별 로더: csv / tsv(엑셀 내보내기) / json. 행마다 성공·실패를 따로 센다.\"\"\"\n", + " if fmt == \"json\":\n", + " rows = [{\"equip\": r[\"equip\"], \"unit\": r[\"unit\"][\"name\"], \"reason\": r[\"reason\"], \"wait\": r[\"wait\"], \"received\": r[\"received\"]} for r in json.loads(text)[\"items\"]]\n", + " else:\n", + " rows = list(csv.DictReader(io.StringIO(text), delimiter=\"\\t\" if fmt == \"tsv\" else \",\"))\n", + " ok, fail = [], []\n", + " for r in rows:\n", + " try:\n", + " ok.append(to_std(r, fmt))\n", + " except (KeyError, ValueError) as e:\n", + " fail.append((r, str(e)))\n", + " return ok, fail\n", + "\n", + "\n", + "def parse_log(line):\n", + " \"\"\"정비일지 문장 1줄에서 장비·부대·대기 일수·날짜·사유를 추출한다.\"\"\"\n", + " eq = re.search(r\"E-\\d{4}\", line)\n", + " unit = re.search(r\"(?:가상\\s*)?[A-F]\\s*부대\", line)\n", + " wait = re.search(r\"(\\d+)\\s*(일간|일|주|개월|시간)\", line)\n", + " dt = re.search(r\"(\\d{2,4}[.\\-/]\\d{1,2}[.\\-/]\\d{1,2})|(\\d{1,2}월\\s*\\d{1,2}일)\", line)\n", + " if not (eq and unit and wait and dt):\n", + " raise ValueError(\"필수 항목(장비·부대·대기·날짜) 추출 실패\")\n", + " reason = re.sub(r\"E-\\d{4}|\\[정비일지\\]|\\(.*?\\)|\\d{2,4}[.\\-/]\\d{1,2}[.\\-/]\\d{1,2}|\\d{1,2}월\\s*\\d{1,2}일자?|가상\\s*[A-F]\\s*부대|[/:]\", \" \", line)\n", + " reason = re.sub(r\"\\s+\", \" \", reason).strip()\n", + " return {\"equip_id\": eq.group(0), \"unit\": norm_unit(unit.group(0)), \"wait_days\": norm_wait(wait.group(0)), \"date\": norm_date(dt.group(0)),\n", + " \"reason\": reason, \"cause\": classify(reason), \"source\": \"log\"}\n", + "\n", + "\n", + "# 1) 형식별 파싱\n", + "results, total, success = [], 0, 0\n", + "for fmt, text in [(\"csv\", RAW_CSV), (\"tsv\", RAW_TSV), (\"json\", RAW_JSON)]:\n", + " ok, fail = load(text, fmt)\n", + " results += ok; total += len(ok) + len(fail); success += len(ok)\n", + " print(f\"[{fmt.upper():4s}] {len(ok) + len(fail)}행 중 {len(ok)}행 성공\" + (f\", 실패 {[e for _, e in fail]}\" if fail else \"\"))\n", + "logs, l_fail = [], 0\n", + "for t in LOGS:\n", + " try:\n", + " logs.append(parse_log(t))\n", + " except ValueError as e:\n", + " l_fail += 1; print(\"일지 파싱 실패:\", e)\n", + "results += logs; total += len(LOGS); success += len(logs)\n", + "print(f\"[정비일지 문장] {len(LOGS)}건 중 {len(logs)}건 성공\")\n", + "rate = success / total\n", + "print(f\"\\n전체 파싱 성공률 {rate:.0%} ({success}/{total}) - 목표 100%: {'달성' if rate == 1 else '미달'}\")\n", + "\n", + "# 2) 정제 결과(표기 통일, 중복 기록 정리)\n", + "std = pd.DataFrame(results)\n", + "before = len(std)\n", + "std = std.drop_duplicates([\"equip_id\", \"date\", \"cause\"]).sort_values(\"equip_id\")\n", + "print(f\"\\n[정제된 정비 대기 기록] {before}행 -> 중복 정리 후 {len(std)}행, 부대 {std['unit'].nunique()}곳\")\n", + "print(std[[\"equip_id\", \"unit\", \"date\", \"wait_days\", \"cause\", \"source\"]].to_string(index=False))\n", + "\n", + "# 3) 요인 분류 정확도와 사람 확인 대상\n", + "chk = std[std[\"equip_id\"].isin(TRUTH)]\n", + "acc = (chk[\"cause\"] == chk[\"equip_id\"].map(TRUTH)).mean()\n", + "print(f\"\\n[요인 분류] 정답이 있는 기록 {len(chk)}건 중 일치 {int((chk['cause'] == chk['equip_id'].map(TRUTH)).sum())}건 ({acc:.0%})\")\n", + "print(std.groupby(\"cause\").agg(건수=(\"equip_id\", \"size\"), 평균대기일=(\"wait_days\", \"mean\")).round(1).to_string())\n", + "print(\"사람 확인 대상('기타'로 분류):\", \", \".join(f\"{r.equip_id}('{r.reason[:14]}')\" for r in std[std[\"cause\"] == \"기타\"].itertuples()) or \"없음\")\n", + "\n", + "# 4) 서식 변경 대응 시험 ①: 열 이름이 바뀐 새 양식 -> 별칭 사전 추가\n", + "new_form = \"장비 코드,배속부대,지연 원인,지연 기간,접수 일자\\nE-0031,가상B부대,부품 미입고,7일,2026.04.01\\n\"\n", + "ok, fail = load(new_form, \"csv\")\n", + "print(f\"\\n새 양식(열 이름 변경) 1차 시도: 성공 {len(ok)} / 실패 {len(fail)} -> {fail[0][1] if fail else '-'}\")\n", + "for k, alias in {\"equip\": \"장비 코드\", \"unit\": \"배속부대\", \"reason\": \"지연 원인\", \"wait\": \"지연 기간\", \"received\": \"접수 일자\"}.items():\n", + " COL_ALIAS[k].append(alias)\n", + "ok, fail = load(new_form, \"csv\")\n", + "print(f\"별칭 사전 갱신 후: 성공 {len(ok)} / 실패 {len(fail)} (코드 수정 없이 설정만 변경)\")\n", + "\n", + "# 5) 서식 변경 대응 시험 ②: 새 표현의 사유 -> 분류 키워드 한 줄 추가\n", + "line = \"가상C부대 E-0032 : 예비품 소진으로 8일간 정비 보류 (2026.04.02)\"\n", + "r1 = parse_log(line)\n", + "CAUSE_KW[3][1].append(\"예비품\")\n", + "r2 = parse_log(line)\n", + "print(f\"새 사유 표현('예비품 소진') 1차 분류: {r1['cause']} -> 키워드 1개 추가 후: {r2['cause']} (코드 수정 없이 사전만 보완)\")\n", + "print(\"※ 요인 분류는 키워드 기반 1차 분류이며, '기타'와 복합 사유는 정비 담당자가 확인해 확정한다.\")\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 02 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + "[CSV ] 6행 중 6행 성공\n", + "[TSV ] 5행 중 5행 성공\n", + "[JSON] 3행 중 3행 성공\n", + "[정비일지 문장] 5건 중 5건 성공\n", + "\n", + "전체 파싱 성공률 100% (19/19) - 목표 100%: 달성\n", + "\n", + "[정제된 정비 대기 기록] 19행 -> 중복 정리 후 18행, 부대 6곳\n", + "equip_id unit date wait_days cause source\n", + " E-0001 가상A부대 2026-03-02 5.0 부품 대기 csv\n", + " E-0002 가상B부대 2026-03-15 14.0 정비병 부족 csv\n", + " E-0003 가상C부대 2026-01-10 2.0 외주 정비 csv\n", + " E-0004 가상D부대 2026-02-27 3.0 검사·재작업 csv\n", + " E-0005 가상E부대 2025-12-30 30.0 부품 대기 csv\n", + " E-0006 가상F부대 2026-01-05 4.0 검사·재작업 csv\n", + " E-0007 가상A부대 2026-02-11 6.0 정비병 부족 tsv\n", + " E-0008 가상B부대 2026-01-20 10.0 부품 대기 tsv\n", + " E-0009 가상C부대 2026-03-05 7.0 외주 정비 tsv\n", + " E-0010 가상D부대 2025-11-03 3.0 검사·재작업 tsv\n", + " E-0011 가상E부대 2026-02-01 14.0 부품 대기 json\n", + " E-0012 가상F부대 2026-01-09 4.0 정비병 부족 json\n", + " E-0013 가상A부대 2026-03-20 3.0 검사·재작업 json\n", + " E-0021 가상A부대 2026-03-04 6.0 부품 대기 log\n", + " E-0022 가상B부대 2026-03-05 3.0 정비병 부족 log\n", + " E-0023 가상C부대 2026-03-06 14.0 외주 정비 log\n", + " E-0024 가상D부대 2026-03-07 7.0 검사·재작업 log\n", + " E-0025 가상E부대 2026-03-08 4.0 기타 log\n", + "\n", + "[요인 분류] 정답이 있는 기록 18건 중 일치 18건 (100%)\n", + " 건수 평균대기일\n", + "cause \n", + "검사·재작업 5 4.0\n", + "기타 1 4.0\n", + "부품 대기 5 13.0\n", + "외주 정비 3 7.7\n", + "정비병 부족 4 6.8\n", + "사람 확인 대상('기타'로 분류): E-0025('원인 불명 대기 4일')\n", + "\n", + "새 양식(열 이름 변경) 1차 시도: 성공 0 / 실패 1 -> '필수 열 없음: reason'\n", + "별칭 사전 갱신 후: 성공 1 / 실패 0 (코드 수정 없이 설정만 변경)\n", + "... (이하 생략)\n", + "```\n", + "
\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 03. 가동률 저하 상위 5대 요인 도출 1차 통계 분석 Baseline 알고리즘 구현\n", + "**소요시간**: 약 2.5시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "가동률 저하 상위 요인 도출 1차 통계 분석 Baseline 알고리즘을 구현한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **가동률 저하의 상위 5대 요인을 상관·효과크기·손실일로 도출하는 1차 통계 Baseline을 만들고, 정비·검사 병목 판별 룰 15종 이상을 YAML 설정으로 구현해 저가동 후보를 가려낸다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **가상 장비 300대에서 가동률 70% 미만 장비와 나머지의 요인별 평균 차이(효과크기)와 가동률과의 상관을 구해 상위 5개 요인을 정 수행하고 검증하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `src/engine/qlt04_rule_engine.py` 형태의 결과물을 완성한다.\n", + "\n", + "### 🔍 평가 및 검증 기준 (PASS 조건)\n", + "#### 미션 판정\n", + "\n", + "**평가 구분:** 필수\n", + "\n", + "**핵심 평가영역:** 군 규정 및 지침 조항과의 정확한 일치성\n", + "\n", + "#### 미션 PASS 조건\n", + "\n", + "- 정량 기준: 업무 검증 룰 15종 이상 코딩, 룰 처리 지연시간 50ms 이하\n", + "\n", + "\n", + "- 진행 기준: 룰 엔진 단위 테스트 100% 통과\n", + "\n", + "\n", + "- 검증 방법: 경계 조건(Boundary condition)에 대한 룰 판정 검증\n", + "\n", + "\n", + "#### 채점자 판정 메모\n", + "\n", + "> 단순 코드 실행 여부뿐만 아니라 출력된 수치와 근거 자료가 군 실무 요구사항에 부합하는지 면밀히 확인한다.\n", + "\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "\"\"\"가상 장비 300대의 가동률 저하 요인을 상관·효과크기·손실일 기준으로 비교하는 1차 통계 Baseline으로 상위 5대 요인을 도출하고, YAML 형식 룰 16종으로 저가동 후보를 판별하며 경계 조건 단위 시험과 룰 처리 지연시간(99백분위)을 확인한다.\"\"\"\n", + "import time\n", + "import numpy as np\n", + "import pandas as pd\n", + "import matplotlib.pyplot as plt\n", + "\n", + "# ---------------------------------------------------------------------------\n", + "# [교육용가상자료] 가상 장비 가동률·정비 대기 자료 생성기 (실제 부대·장비·제원·가동률 정보 없음)\n", + "# 장비 유형 A(궤도형)·B(화포형)·C(차륜형)은 추상 구분이며 실제 장비와 무관하다.\n", + "# 최근 90일 동안 장비 1대의 정비 대기 요인(부품·정비병·외주 정비·외주 검사·재작업)과 가동률을 만든다.\n", + "# ---------------------------------------------------------------------------\n", + "UNITS = [\"가상A부대\", \"가상B부대\", \"가상C부대\", \"가상D부대\", \"가상E부대\", \"가상F부대\"]\n", + "ETYPES = [\"유형A\", \"유형B\", \"유형C\"]\n", + "WINDOW = 90 # 관측 기간(일)\n", + "CAUSES = [\"부품 대기\", \"정비병 부족\", \"외주 정비\", \"검사·재작업\"]\n", + "CAUSE_COL = {\"부품 대기\": \"parts_wait_days\", \"정비병 부족\": \"labor_wait_days\", \"외주 정비\": \"outsource_days\", \"검사·재작업\": \"inspect_wait_days\"}\n", + "NUM_FIELDS = [\"age_years\", \"op_hours\", \"maint_orders\", \"parts_wait_days\", \"labor_wait_days\", \"outsource_days\", \"inspect_wait_days\", \"rework_count\",\n", + " \"spec_mismatch\", \"defect_reports\", \"parts_stock_pct\", \"mechanic_fill_pct\", \"avail_pct\"]\n", + "FEAT = [\"age_years\", \"maint_orders\", \"parts_stock_pct\", \"mechanic_fill_pct\", \"outsource_days\", \"inspect_wait_days\", \"rework_count\", \"spec_mismatch\", \"defect_reports\", \"etype_B\", \"etype_C\"]\n", + "FEAT_KO = {\"age_years\": \"장비 연령\", \"maint_orders\": \"정비 요청 건수\", \"parts_stock_pct\": \"부품 재고 가용률\", \"mechanic_fill_pct\": \"정비병 충원율\", \"outsource_days\": \"외주 정비 일수\",\n", + " \"inspect_wait_days\": \"외주 검사 대기 일수\", \"rework_count\": \"재작업 건수\", \"spec_mismatch\": \"규격·제원 불일치\", \"defect_reports\": \"하자·불만 접수\",\n", + " \"etype_B\": \"유형B 여부\", \"etype_C\": \"유형C 여부\"}\n", + "\n", + "\n", + "def unit_profile(seed=3):\n", + " \"\"\"부대별 고정 특성: 부품 재고 수준, 정비병 충원율, 외주 정비소 거리(일수 가중), 외주 검사 혼잡도.\"\"\"\n", + " # [TODO : 학생 실습 3] 가동률 저하 상위 5대 요인 도출 1차 통계 분석 Baseline 알고리즘 구현 관련 핵심 로직을 구현하세요.\n", + " # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n", + " # <<< 여기에 코드를 작성하세요 >>>\n", + " pass # 작성 후 pass를 제거하세요.\n", + "def make_fleet(n=300, seed=7, shock=0.0):\n", + " \"\"\"장비 n대의 90일 관측 자료(필드 16개)와 정답 요인을 만든다. shock은 외주 검사 병목 악화 정도(AAR용 환경 변화).\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " pf = unit_profile()\n", + " ui = rng.integers(0, len(UNITS), n)\n", + " et = rng.choice(ETYPES, n, p=[.35, .3, .35])\n", + " age = np.clip(rng.gamma(4, 2.2, n), 0.5, 30).round(1)\n", + " eff = np.array([{\"유형A\": 1.15, \"유형B\": 1.0, \"유형C\": 0.9}[t] for t in et])\n", + " orders = rng.poisson(np.clip(1.2 + 0.12 * age, 0.5, 6) * eff)\n", + " stock = np.clip(pf[\"stock\"].to_numpy()[ui] + rng.normal(0, 8, n), 20, 100).round(0)\n", + " fill = np.clip(pf[\"fill\"].to_numpy()[ui] + rng.normal(0, 6, n), 30, 100).round(0)\n", + " parts_w = (orders * rng.gamma(2, 0.55 * (1 + ((100 - stock) / 28) ** 2) / 2)).round(0)\n", + " labor_w = (orders * rng.gamma(2, 0.7 * (1 + ((100 - fill) / 24) ** 2) / 2)).round(0)\n", + " n_out = rng.binomial(orders, 0.3)\n", + " out_d = (n_out * rng.gamma(3, 4.5 * pf[\"dist\"].to_numpy()[ui] / 3)).round(0)\n", + " insp_w = (n_out * rng.gamma(2, (2.6 + shock) * pf[\"queue\"].to_numpy()[ui] / 2)).round(0)\n", + " spec = rng.poisson(0.2 + 0.04 * age)\n", + " rework = rng.binomial(n_out, np.clip(0.08 + 0.1 * spec, 0, .6))\n", + " defect = rng.poisson(0.3 + 0.7 * rework)\n", + " base_rep = orders * 1.8\n", + " down = base_rep + parts_w + labor_w + out_d + insp_w + rework * 4 + rng.normal(0, 1.2, n)\n", + " comp = np.column_stack([parts_w, labor_w, out_d, insp_w + rework * 4])\n", + " df = pd.DataFrame({\"equip_id\": [f\"E-{i + 1:04d}\" for i in range(n)], \"unit\": np.array(UNITS)[ui], \"etype\": et, \"age_years\": age,\n", + " \"op_hours\": np.clip(rng.normal(320 - 4 * age, 70, n), 40, 600).round(0), \"maint_orders\": orders,\n", + " \"parts_wait_days\": parts_w, \"labor_wait_days\": labor_w, \"outsource_days\": out_d, \"inspect_wait_days\": insp_w,\n", + " \"rework_count\": rework, \"spec_mismatch\": spec, \"defect_reports\": defect, \"parts_stock_pct\": stock, \"mechanic_fill_pct\": fill,\n", + " \"avail_pct\": (100 * (1 - np.clip(down, 0, WINDOW) / WINDOW)).round(1)})\n", + " df[\"main_cause\"] = np.where(comp.max(axis=1) < 3, \"없음\", np.array(CAUSES)[comp.argmax(axis=1)])\n", + " return df\n", + "\n", + "\n", + "def feat_frame(df):\n", + " \"\"\"회귀·분석용 설명 변수 표(유형은 더미 변수).\"\"\"\n", + " # [TODO : 학생 실습 3] 가동률 저하 상위 5대 요인 도출 1차 통계 분석 Baseline 알고리즘 구현 관련 핵심 로직을 구현하세요.\n", + " # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n", + " # <<< 여기에 코드를 작성하세요 >>>\n", + " pass # 작성 후 pass를 제거하세요.\n", + "def make_trend(months=12, seed=5, shock_unit=None):\n", + " \"\"\"[교육용가상자료] 부대 x 유형 x 월 평균 가동률 추세. shock_unit 부대는 하반기에 외주 검사 병목으로 가동률이 떨어진다.\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " pf = unit_profile()\n", + " rows = []\n", + " for _, u in pf.iterrows():\n", + " base = 62 + 0.22 * u[\"stock\"] + 0.12 * u[\"fill\"] - 3.0 * u[\"queue\"]\n", + " for t in ETYPES:\n", + " off = {\"유형A\": -3.0, \"유형B\": 0.0, \"유형C\": 2.0}[t]\n", + " for m in range(months):\n", + " v = base + off + 2.0 * np.sin(m / 12 * 2 * np.pi) + rng.normal(0, 1.0)\n", + " if shock_unit == u[\"unit\"] and m >= months // 2:\n", + " v -= 1.5 * (m - months // 2 + 1)\n", + " rows.append((u[\"unit\"], t, m + 1, round(float(np.clip(v, 30, 99)), 1)))\n", + " return pd.DataFrame(rows, columns=[\"unit\", \"etype\", \"month\", \"avail_pct\"])\n", + "\n", + "# ---- 정비·검사 병목 판별 룰 설정(YAML 형식 문자열: 기준이 바뀌면 코드가 아니라 이 설정만 고친다) ----\n", + "# clause는 룰과 대응시킨 '가상 품질지침 조항' 예시이며 실제 규정 원문이 아니다.\n", + "RULES_YAML = \"\"\"\n", + "- {id: Q01, name: 부품 재고 가용률 50% 미만, field: parts_stock_pct, op: \"<\", value: 50, weight: 2, clause: 가상품질지침 제3조}\n", + "- {id: Q02, name: 정비병 충원율 60% 미만, field: mechanic_fill_pct, op: \"<\", value: 60, weight: 2, clause: 가상품질지침 제4조}\n", + "- {id: Q03, name: 부품 대기 8일 초과, field: parts_wait_days, op: \">\", value: 8, weight: 2, clause: 가상품질지침 제5조}\n", + "- {id: Q04, name: 정비병 부족 대기 8일 초과, field: labor_wait_days, op: \">\", value: 8, weight: 2, clause: 가상품질지침 제6조}\n", + "- {id: Q05, name: 외주 정비 15일 초과, field: outsource_days, op: \">\", value: 15, weight: 2, clause: 가상품질지침 제7조}\n", + "- {id: Q06, name: 외주 검사 대기 6일 초과, field: inspect_wait_days, op: \">\", value: 6, weight: 3, clause: 가상품질지침 제8조}\n", + "- {id: Q07, name: 재작업 1건 이상, field: rework_count, op: \">=\", value: 1, weight: 3, clause: 가상품질지침 제9조}\n", + "- {id: Q08, name: 재작업률 25% 초과, field: rework_rate, op: \">\", value: 0.25, weight: 2, clause: 가상품질지침 제9조}\n", + "- {id: Q09, name: 규격·제원 불일치 3건 이상, field: spec_mismatch, op: \">=\", value: 3, weight: 3, clause: 가상품질지침 제10조}\n", + "- {id: Q10, name: 하자·불만 3건 이상, field: defect_reports, op: \">=\", value: 3, weight: 2, clause: 가상품질지침 제11조}\n", + "- {id: Q11, name: 장비 연령 15년 초과, field: age_years, op: \">\", value: 15, weight: 1, clause: 가상품질지침 제12조}\n", + "- {id: Q12, name: 정비 요청 6건 이상, field: maint_orders, op: \">=\", value: 6, weight: 1, clause: 가상품질지침 제13조}\n", + "- {id: Q13, name: 대기 일수 합계 30일 초과, field: total_wait, op: \">\", value: 30, weight: 3, clause: 가상품질지침 제14조}\n", + "- {id: Q14, name: 검사 대기가 외주 정비 일수의 80% 초과, field: insp_ratio, op: \">\", value: 0.8, weight: 2, clause: 가상품질지침 제8조}\n", + "- {id: Q15, name: 규격 불일치 2건 이상과 하자 접수 동반, field: spec_defect, op: \"==\", value: true, weight: 3, clause: 가상품질지침 제10조}\n", + "- {id: Q16, name: 최근 90일 가동 시간 150시간 미만, field: op_hours, op: \"<\", value: 150, weight: 1, clause: 가상품질지침 제15조}\n", + "\"\"\"\n", + "GRADE_CUT = [(10, \"고위험\"), (6, \"주의\"), (3, \"관심\")]\n", + "OPS = {\"<\": lambda a, b: a < b, \">\": lambda a, b: a > b, \"<=\": lambda a, b: a <= b, \">=\": lambda a, b: a >= b, \"==\": lambda a, b: a == b}\n", + "\n", + "\n", + "def parse_rules(text):\n", + " \"\"\"YAML 한 줄 맵({key: value, ...}) 형식 룰 문자열을 읽는다(외부 yaml 라이브러리 없이).\"\"\"\n", + " out = []\n", + " for line in text.strip().splitlines():\n", + " body = line.strip()[3:-1]\n", + " r = {}\n", + " for part in body.split(\", \"):\n", + " k, v = part.split(\": \", 1)\n", + " v = v.strip().strip('\"')\n", + " r[k] = True if v == \"true\" else False if v == \"false\" else (float(v) if v.replace(\".\", \"\", 1).isdigit() else v)\n", + " out.append(r)\n", + " return out\n", + "\n", + "\n", + "def derive_rec(rec):\n", + " \"\"\"룰이 쓰는 파생 필드(대기 합계, 재작업률, 검사 대기 비율(외주 5일 이상일 때), 규격 불일치 2건 이상+하자 동반)를 추가한다.\"\"\"\n", + " r = dict(rec)\n", + " r[\"total_wait\"] = r[\"parts_wait_days\"] + r[\"labor_wait_days\"] + r[\"outsource_days\"] + r[\"inspect_wait_days\"]\n", + " r[\"rework_rate\"] = r[\"rework_count\"] / r[\"maint_orders\"] if r[\"maint_orders\"] else 0.0\n", + " r[\"insp_ratio\"] = r[\"inspect_wait_days\"] / r[\"outsource_days\"] if r[\"outsource_days\"] >= 5 else 0.0\n", + " r[\"spec_defect\"] = bool(r[\"spec_mismatch\"] >= 2 and r[\"defect_reports\"] >= 1)\n", + " return r\n", + "\n", + "\n", + "def apply_rules(rec, rules, derived=False):\n", + " \"\"\"룰 적용: (점수, 적중 룰 id 목록). derived=True면 이미 파생 필드가 있는 레코드(경계 시험용)로 본다.\"\"\"\n", + " r = rec if derived else derive_rec(rec)\n", + " hits = [x[\"id\"] for x in rules if OPS[x[\"op\"]](r[x[\"field\"]], x[\"value\"])]\n", + " w = {x[\"id\"]: x[\"weight\"] for x in rules}\n", + " return sum(w[h] for h in hits), hits\n", + "\n", + "\n", + "def grade_of(score):\n", + " for cut, g in GRADE_CUT:\n", + " if score >= cut:\n", + " return g\n", + " return \"정상\"\n", + "\n", + "fleet = make_fleet(300, seed=7)\n", + "fleet[\"low\"] = fleet[\"avail_pct\"] < 70 # 저가동: 가동률 70% 미만(가상 기준)\n", + "FAC = [\"maint_orders\", \"parts_wait_days\", \"labor_wait_days\", \"outsource_days\", \"inspect_wait_days\", \"rework_count\", \"spec_mismatch\", \"defect_reports\",\n", + " \"age_years\", \"op_hours\", \"parts_stock_pct\", \"mechanic_fill_pct\"]\n", + "KO = {**FEAT_KO, \"parts_wait_days\": \"부품 대기 일수\", \"labor_wait_days\": \"정비병 부족 대기 일수\", \"op_hours\": \"가동 시간\"}\n", + "\n", + "# 1) 1차 통계 Baseline: 가동률과의 상관계수, 저가동군-정상군 효과크기(Cohen d), 저가동군 평균 값\n", + "rows = []\n", + "for f in FAC:\n", + " a, b = fleet.loc[fleet[\"low\"], f], fleet.loc[~fleet[\"low\"], f]\n", + " sp = np.sqrt((a.var() + b.var()) / 2) or 1.0\n", + " rows.append({\"요인\": KO[f], \"상관(r)\": fleet[f].corr(fleet[\"avail_pct\"]), \"효과크기(d)\": (a.mean() - b.mean()) / sp, \"저가동군 평균\": a.mean(), \"정상군 평균\": b.mean(), \"key\": f})\n", + "fac = pd.DataFrame(rows)\n", + "fac[\"순위점수\"] = fac[\"상관(r)\"].abs() + fac[\"효과크기(d)\"].abs() / 3 # 두 지표를 함께 본 순위 점수(휴리스틱)\n", + "top5 = fac.sort_values(\"순위점수\", ascending=False).head(5)\n", + "print(f\"[Baseline] 장비 {len(fleet)}대, 저가동(70% 미만) {int(fleet['low'].sum())}대, 평균 가동률 {fleet['avail_pct'].mean():.1f}%\")\n", + "print(\"[가동률 저하 상위 5대 요인]\")\n", + "print(top5.drop(columns=[\"key\", \"순위점수\"]).round(2).to_string(index=False))\n", + "lost = fleet.loc[fleet[\"low\"], [\"parts_wait_days\", \"labor_wait_days\", \"outsource_days\", \"inspect_wait_days\"]].mean()\n", + "print(\"저가동 장비의 평균 대기 손실일:\", \", \".join(f\"{KO[k]} {v:.1f}일\" for k, v in lost.items()))\n", + "print(\"주요 저가동 요인 분포(정답):\", fleet[\"main_cause\"].value_counts().to_dict())\n", + "\n", + "# 2) 룰 엔진: 장비 레코드에 16종 룰을 적용\n", + "rules = parse_rules(RULES_YAML)\n", + "recs = fleet.drop(columns=[\"main_cause\", \"low\"]).to_dict(\"records\")\n", + "lat, scores, hits = [], [], []\n", + "for r in recs:\n", + " t = time.perf_counter(); s, h = apply_rules(r, rules); lat.append((time.perf_counter() - t) * 1000)\n", + " scores.append(s); hits.append(h)\n", + "fleet[\"score\"], fleet[\"grade\"] = scores, [grade_of(s) for s in scores]\n", + "print(f\"\\n[룰 엔진] 룰 {len(rules)}종 적용, 처리 지연 평균 {np.mean(lat):.3f}ms / 99백분위 {np.percentile(lat, 99):.3f}ms (목표 50ms 이하: {'달성' if np.percentile(lat, 99) <= 50 else '미달'})\")\n", + "tab = fleet.groupby(\"grade\").agg(장비수=(\"equip_id\", \"size\"), 실제저가동=(\"low\", \"sum\"), 저가동비율=(\"low\", \"mean\")).reindex([\"고위험\", \"주의\", \"관심\", \"정상\"])\n", + "print(tab.round(2).to_string())\n", + "flag = fleet[\"score\"] >= 6 # 주의 이상을 저가동 후보로 본다\n", + "tp = int((flag & fleet[\"low\"]).sum()); prec, rec = tp / max(flag.sum(), 1), tp / fleet[\"low\"].sum()\n", + "print(f\"저가동 후보(주의 이상) {int(flag.sum())}대 중 실제 저가동 {tp}대: 정밀도 {prec:.0%}, 재현율 {rec:.0%}\")\n", + "cnt = pd.Series([h for hs in hits for h in hs]).value_counts()\n", + "print(\"룰별 적중 상위:\", \", \".join(f\"{k} {v}건\" for k, v in cnt.head(5).items()))\n", + "\n", + "# 3) 경계 조건 단위 시험: 기준값 위·아래에서 룰 판정이 맞는지(룰마다 2~3건)\n", + "BASE = derive_rec({\"parts_stock_pct\": 90, \"mechanic_fill_pct\": 90, \"parts_wait_days\": 0, \"labor_wait_days\": 0, \"outsource_days\": 0, \"inspect_wait_days\": 0,\n", + " \"rework_count\": 0, \"maint_orders\": 0, \"spec_mismatch\": 0, \"defect_reports\": 0, \"age_years\": 5, \"op_hours\": 300})\n", + "tests = ok = 0\n", + "fails = []\n", + "for x in rules:\n", + " v, d = x[\"value\"], (0.01 if isinstance(x[\"value\"], float) and not float(x[\"value\"]).is_integer() else 1)\n", + " cases = {\"<\": [(v, False), (v - d, True), (v + d, False)], \">\": [(v, False), (v + d, True), (v - d, False)], \">=\": [(v - d, False), (v, True), (v + d, True)],\n", + " \"<=\": [(v + d, False), (v, True), (v - d, True)], \"==\": [(True, True), (False, False)]}[x[\"op\"]]\n", + " for val, expect in cases:\n", + " r = dict(BASE); r[x[\"field\"]] = val\n", + " got = x[\"id\"] in apply_rules(r, rules, derived=True)[1]\n", + " tests += 1; ok += got == expect\n", + " if got != expect:\n", + " fails.append((x[\"id\"], val))\n", + "print(f\"\\n[경계 조건 단위 시험] {ok}/{tests} 통과 ({ok / tests:.0%}), 실패 {fails or '없음'} (목표 100%)\")\n", + "\n", + "# 4) 판정 근거 예시(상위 3대)와 사람이 확인할 것\n", + "print(\"\\n[고위험 상위 3대 판정 근거]\")\n", + "nm = {x[\"id\"]: x for x in rules}\n", + "for _, r in fleet.sort_values(\"score\", ascending=False).head(3).iterrows():\n", + " h = apply_rules(recs[int(r.name)], rules)[1]\n", + " print(f\" {r['equip_id']} {r['unit']} 점수 {r['score']:.0f} 가동률 {r['avail_pct']}%: \" + \"; \".join(f\"{i} {nm[i]['name']}({nm[i]['clause']})\" for i in h[:3]))\n", + "print(f\"룰 {len(rules)}종(목표 15종 이상: {'달성' if len(rules) >= 15 else '미달'}). 기준값은 YAML만 바꿔 갱신하며, 정비 우선순위와 품질 판정은 정비관·품질검사관이 확인한다.\")\n", + "\n", + "fig, ax = plt.subplots(1, 2, figsize=(10, 4))\n", + "t5 = top5.iloc[::-1]\n", + "ax[0].barh([k for k in t5[\"key\"]], t5[\"상관(r)\"], color=\"tab:blue\"); ax[0].set_title(\"Top-5 factors: correlation with availability\")\n", + "ax[1].bar(tab.index.map({\"고위험\": \"high\", \"주의\": \"caution\", \"관심\": \"watch\", \"정상\": \"normal\"}), tab[\"저가동비율\"], color=\"tab:orange\"); ax[1].set_title(\"Low-availability rate by rule grade\")\n", + "plt.tight_layout(); plt.show()\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 03 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + "[Baseline] 장비 300대, 저가동(70% 미만) 63대, 평균 가동률 80.8%\n", + "[가동률 저하 상위 5대 요인]\n", + " 요인 상관(r) 효과크기(d) 저가동군 평균 정상군 평균\n", + " 정비 요청 건수 -0.75 1.56 3.94 1.95\n", + " 외주 정비 일수 -0.73 1.26 8.46 1.78\n", + " 외주 검사 대기 일수 -0.66 1.13 5.56 1.02\n", + " 부품 대기 일수 -0.63 1.18 8.70 2.48\n", + "정비병 부족 대기 일수 -0.57 1.01 8.27 2.59\n", + "저가동 장비의 평균 대기 손실일: 부품 대기 일수 8.7일, 정비병 부족 대기 일수 8.3일, 외주 정비 일수 8.5일, 외주 검사 대기 일수 5.6일\n", + "주요 저가동 요인 분포(정답): {'없음': 77, '부품 대기': 72, '정비병 부족': 70, '외주 정비': 59, '검사·재작업': 22}\n", + "\n", + "[룰 엔진] 룰 16종 적용, 처리 지연 평균 0.009ms / 99백분위 0.056ms (목표 50ms 이하: 달성)\n", + " 장비수 실제저가동 저가동비율\n", + "grade \n", + "고위험 16 13 0.81\n", + "주의 24 18 0.75\n", + "관심 45 19 0.42\n", + "정상 215 13 0.06\n", + "저가동 후보(주의 이상) 40대 중 실제 저가동 31대: 정밀도 78%, 재현율 49%\n", + "룰별 적중 상위: Q01 53건, Q02 39건, Q03 38건, Q04 28건, Q06 26건\n", + "\n", + "[경계 조건 단위 시험] 47/47 통과 (100%), 실패 없음 (목표 100%)\n", + "\n", + "[고위험 상위 3대 판정 근거]\n", + " E-0047 가상A부대 점수 14 가동률 35.7%: Q01 부품 재고 가용률 50% 미만(가상품질지침 제3조); Q03 부품 대기 8일 초과(가상품질지침 제5조); Q05 외주 정비 15일 초과(가상품질지침 제7조)\n", + " E-0231 가상D부대 점수 14 가동률 51.7%: Q02 정비병 충원율 60% 미만(가상품질지침 제4조); Q03 부품 대기 8일 초과(가상품질지침 제5조); Q04 정비병 부족 대기 8일 초과(가상품질지침 제6조)\n", + " E-0041 가상C부대 점수 13 가동률 38.8%: Q05 외주 정비 15일 초과(가상품질지침 제7조); Q06 외주 검사 대기 6일 초과(가상품질지침 제8조); Q07 재작업 1건 이상(가상품질지침 제9조)\n", + "룰 16종(목표 15종 이상: 달성). 기준값은 YAML만 바꿔 갱신하며, 정비 우선순위와 품질 판정은 정비관·품질검사관이 확인한다.\n", + "```\n", + "
\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 04. 회귀 분석 및 원인 규명 모델 기반 가동률 저하 핵심 병목 요인 정밀 분석 엔진 고도화\n", + "**소요시간**: 약 3.0시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "회귀 분석 기반 가동률 저하 핵심 병목 요인 정밀 분석 엔진을 고도화한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **회귀 분석으로 가동률 저하의 핵심 병목 요인을 정밀 분석하고, 저하 요인을 분류해 Top-3 개선 옵션과 유사 사례 유사도를 제공하는 모델의 정확도를 검증한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **가상 장비 1,500대를 학습 70%와 검증 30%로 나누고 릿지 회귀로 요인별 표준화 계수를 구한 뒤 부트스트랩 20회로 계수 부호의 안정성 확인하고 유효성을 점검하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `src/ai/qlt04_intelligence_module.py` 형태의 결과물을 완성한다.\n", + "\n", + "### 🔍 평가 및 검증 기준 (PASS 조건)\n", + "#### 미션 판정\n", + "\n", + "**평가 구분:** 필수\n", + "\n", + "**핵심 평가영역:** 추천 사유의 합리성 및 실무 수용성\n", + "\n", + "#### 미션 PASS 조건\n", + "\n", + "- 정량 기준: 추천/분류 정확도 85% 이상 달성\n", + "\n", + "\n", + "- 진행 기준: AI 추론 모듈 구현 및 벤치마크 테스트 완료\n", + "\n", + "\n", + "- 검증 방법: 과거 승인/처리 완료 데이터와의 추천 일치도 검증\n", + "\n", + "\n", + "#### 채점자 판정 메모\n", + "\n", + "> 단순 코드 실행 여부뿐만 아니라 출력된 수치와 근거 자료가 군 실무 요구사항에 부합하는지 면밀히 확인한다.\n", + "\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "\"\"\"가상 장비 1,500대의 가동률을 릿지 회귀로 설명해 핵심 병목 요인의 영향 크기와 부호 안정성을 분석하고, 저가동 장비의 주요 요인(부품 대기·정비병 부족·외주 정비·검사·재작업)을 분류해 Top-3 개선 옵션과 유사 과거 사례 유사도 점수를 제공하며 정확도를 검증 세트로 측정한다.\"\"\"\n", + "import numpy as np\n", + "import pandas as pd\n", + "import matplotlib.pyplot as plt\n", + "from sklearn.metrics import r2_score, mean_absolute_error\n", + "\n", + "# ---------------------------------------------------------------------------\n", + "# [교육용가상자료] 가상 장비 가동률·정비 대기 자료 생성기 (실제 부대·장비·제원·가동률 정보 없음)\n", + "# 장비 유형 A(궤도형)·B(화포형)·C(차륜형)은 추상 구분이며 실제 장비와 무관하다.\n", + "# 최근 90일 동안 장비 1대의 정비 대기 요인(부품·정비병·외주 정비·외주 검사·재작업)과 가동률을 만든다.\n", + "# ---------------------------------------------------------------------------\n", + "UNITS = [\"가상A부대\", \"가상B부대\", \"가상C부대\", \"가상D부대\", \"가상E부대\", \"가상F부대\"]\n", + "ETYPES = [\"유형A\", \"유형B\", \"유형C\"]\n", + "WINDOW = 90 # 관측 기간(일)\n", + "CAUSES = [\"부품 대기\", \"정비병 부족\", \"외주 정비\", \"검사·재작업\"]\n", + "CAUSE_COL = {\"부품 대기\": \"parts_wait_days\", \"정비병 부족\": \"labor_wait_days\", \"외주 정비\": \"outsource_days\", \"검사·재작업\": \"inspect_wait_days\"}\n", + "NUM_FIELDS = [\"age_years\", \"op_hours\", \"maint_orders\", \"parts_wait_days\", \"labor_wait_days\", \"outsource_days\", \"inspect_wait_days\", \"rework_count\",\n", + " \"spec_mismatch\", \"defect_reports\", \"parts_stock_pct\", \"mechanic_fill_pct\", \"avail_pct\"]\n", + "FEAT = [\"age_years\", \"maint_orders\", \"parts_stock_pct\", \"mechanic_fill_pct\", \"outsource_days\", \"inspect_wait_days\", \"rework_count\", \"spec_mismatch\", \"defect_reports\", \"etype_B\", \"etype_C\"]\n", + "FEAT_KO = {\"age_years\": \"장비 연령\", \"maint_orders\": \"정비 요청 건수\", \"parts_stock_pct\": \"부품 재고 가용률\", \"mechanic_fill_pct\": \"정비병 충원율\", \"outsource_days\": \"외주 정비 일수\",\n", + " \"inspect_wait_days\": \"외주 검사 대기 일수\", \"rework_count\": \"재작업 건수\", \"spec_mismatch\": \"규격·제원 불일치\", \"defect_reports\": \"하자·불만 접수\",\n", + " \"etype_B\": \"유형B 여부\", \"etype_C\": \"유형C 여부\"}\n", + "\n", + "\n", + "def unit_profile(seed=3):\n", + " \"\"\"부대별 고정 특성: 부품 재고 수준, 정비병 충원율, 외주 정비소 거리(일수 가중), 외주 검사 혼잡도.\"\"\"\n", + " # [TODO : 학생 실습 4] 회귀 분석 및 원인 규명 모델 기반 가동률 저하 핵심 병목 요인 정밀 분석 엔진 고도화 관련 핵심 로직을 구현하세요.\n", + " # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n", + " # <<< 여기에 코드를 작성하세요 >>>\n", + " pass # 작성 후 pass를 제거하세요.\n", + "def make_fleet(n=300, seed=7, shock=0.0):\n", + " \"\"\"장비 n대의 90일 관측 자료(필드 16개)와 정답 요인을 만든다. shock은 외주 검사 병목 악화 정도(AAR용 환경 변화).\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " pf = unit_profile()\n", + " ui = rng.integers(0, len(UNITS), n)\n", + " et = rng.choice(ETYPES, n, p=[.35, .3, .35])\n", + " age = np.clip(rng.gamma(4, 2.2, n), 0.5, 30).round(1)\n", + " eff = np.array([{\"유형A\": 1.15, \"유형B\": 1.0, \"유형C\": 0.9}[t] for t in et])\n", + " orders = rng.poisson(np.clip(1.2 + 0.12 * age, 0.5, 6) * eff)\n", + " stock = np.clip(pf[\"stock\"].to_numpy()[ui] + rng.normal(0, 8, n), 20, 100).round(0)\n", + " fill = np.clip(pf[\"fill\"].to_numpy()[ui] + rng.normal(0, 6, n), 30, 100).round(0)\n", + " parts_w = (orders * rng.gamma(2, 0.55 * (1 + ((100 - stock) / 28) ** 2) / 2)).round(0)\n", + " labor_w = (orders * rng.gamma(2, 0.7 * (1 + ((100 - fill) / 24) ** 2) / 2)).round(0)\n", + " n_out = rng.binomial(orders, 0.3)\n", + " out_d = (n_out * rng.gamma(3, 4.5 * pf[\"dist\"].to_numpy()[ui] / 3)).round(0)\n", + " insp_w = (n_out * rng.gamma(2, (2.6 + shock) * pf[\"queue\"].to_numpy()[ui] / 2)).round(0)\n", + " spec = rng.poisson(0.2 + 0.04 * age)\n", + " rework = rng.binomial(n_out, np.clip(0.08 + 0.1 * spec, 0, .6))\n", + " defect = rng.poisson(0.3 + 0.7 * rework)\n", + " base_rep = orders * 1.8\n", + " down = base_rep + parts_w + labor_w + out_d + insp_w + rework * 4 + rng.normal(0, 1.2, n)\n", + " comp = np.column_stack([parts_w, labor_w, out_d, insp_w + rework * 4])\n", + " df = pd.DataFrame({\"equip_id\": [f\"E-{i + 1:04d}\" for i in range(n)], \"unit\": np.array(UNITS)[ui], \"etype\": et, \"age_years\": age,\n", + " \"op_hours\": np.clip(rng.normal(320 - 4 * age, 70, n), 40, 600).round(0), \"maint_orders\": orders,\n", + " \"parts_wait_days\": parts_w, \"labor_wait_days\": labor_w, \"outsource_days\": out_d, \"inspect_wait_days\": insp_w,\n", + " \"rework_count\": rework, \"spec_mismatch\": spec, \"defect_reports\": defect, \"parts_stock_pct\": stock, \"mechanic_fill_pct\": fill,\n", + " \"avail_pct\": (100 * (1 - np.clip(down, 0, WINDOW) / WINDOW)).round(1)})\n", + " df[\"main_cause\"] = np.where(comp.max(axis=1) < 3, \"없음\", np.array(CAUSES)[comp.argmax(axis=1)])\n", + " return df\n", + "\n", + "\n", + "def feat_frame(df):\n", + " \"\"\"회귀·분석용 설명 변수 표(유형은 더미 변수).\"\"\"\n", + " # [TODO : 학생 실습 4] 회귀 분석 및 원인 규명 모델 기반 가동률 저하 핵심 병목 요인 정밀 분석 엔진 고도화 관련 핵심 로직을 구현하세요.\n", + " # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n", + " # <<< 여기에 코드를 작성하세요 >>>\n", + " pass # 작성 후 pass를 제거하세요.\n", + "def make_trend(months=12, seed=5, shock_unit=None):\n", + " \"\"\"[교육용가상자료] 부대 x 유형 x 월 평균 가동률 추세. shock_unit 부대는 하반기에 외주 검사 병목으로 가동률이 떨어진다.\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " pf = unit_profile()\n", + " rows = []\n", + " for _, u in pf.iterrows():\n", + " base = 62 + 0.22 * u[\"stock\"] + 0.12 * u[\"fill\"] - 3.0 * u[\"queue\"]\n", + " for t in ETYPES:\n", + " off = {\"유형A\": -3.0, \"유형B\": 0.0, \"유형C\": 2.0}[t]\n", + " for m in range(months):\n", + " v = base + off + 2.0 * np.sin(m / 12 * 2 * np.pi) + rng.normal(0, 1.0)\n", + " if shock_unit == u[\"unit\"] and m >= months // 2:\n", + " v -= 1.5 * (m - months // 2 + 1)\n", + " rows.append((u[\"unit\"], t, m + 1, round(float(np.clip(v, 30, 99)), 1)))\n", + " return pd.DataFrame(rows, columns=[\"unit\", \"etype\", \"month\", \"avail_pct\"])\n", + "\n", + "# ---- 가동률 회귀 모델과 저가동 요인 분류·추천 모델(외부 AI 서비스 없이 scikit-learn 사용) ----\n", + "from sklearn.linear_model import Ridge, LogisticRegression\n", + "from sklearn.preprocessing import StandardScaler\n", + "from sklearn.pipeline import make_pipeline\n", + "\n", + "CAUSE_COLS = [\"age_years\", \"maint_orders\", \"parts_stock_pct\", \"mechanic_fill_pct\", \"outsource_days\", \"inspect_wait_days\", \"rework_count\", \"spec_mismatch\",\n", + " \"defect_reports\", \"parts_wait_days\", \"labor_wait_days\"]\n", + "ACTION = {\"부품 대기\": \"부품 사전 청구·재고 보강\", \"정비병 부족\": \"정비병 재배치·교육 일정 조정\", \"외주 정비\": \"외주 정비 일정 조율·내부 정비 확대\", \"검사·재작업\": \"검사 일정 조율·규격 사전 대조\"}\n", + "\n", + "\n", + "def cause_frame(df):\n", + " x = df[CAUSE_COLS].copy()\n", + " x[\"out_insp\"] = x[\"outsource_days\"] + x[\"inspect_wait_days\"] + 4 * x[\"rework_count\"]\n", + " x[\"wait_pm\"] = (x[\"parts_wait_days\"] + x[\"labor_wait_days\"]) / (x[\"maint_orders\"] + 1)\n", + " return x\n", + "\n", + "\n", + "def fit_avail(df, alpha=1.0):\n", + " \"\"\"가동률(%) 회귀: 표준화 + 릿지 회귀.\"\"\"\n", + " return make_pipeline(StandardScaler(), Ridge(alpha=alpha)).fit(feat_frame(df), df[\"avail_pct\"])\n", + "\n", + "\n", + "def fit_cause(df, cut=85, C=5):\n", + " \"\"\"가동률이 cut% 미만인 장비의 주요 저가동 요인(4종)을 분류한다.\"\"\"\n", + " low = df[(df[\"avail_pct\"] < cut) & (df[\"main_cause\"] != \"없음\")]\n", + " return make_pipeline(StandardScaler(), LogisticRegression(C=C, max_iter=3000)).fit(cause_frame(low), low[\"main_cause\"])\n", + "\n", + "\n", + "SIM_FEAT = [\"age_years\", \"maint_orders\", \"spec_mismatch\", \"stock_gap_x_orders\", \"fill_gap_x_orders\", \"parts_stock_pct\", \"mechanic_fill_pct\", \"etype_B\", \"etype_C\"]\n", + "\n", + "\n", + "def sim_frame(df):\n", + " \"\"\"자원 재배치 시뮬레이션용 설명 변수: 부족분이 클수록 대기가 더 빠르게 늘고 정비 요청 건수와 곱해져 커진다는 구조를 제곱·상호작용 항으로 둔다.\"\"\"\n", + " x = df.copy()\n", + " x[\"etype_B\"], x[\"etype_C\"] = (x[\"etype\"] == \"유형B\").astype(int), (x[\"etype\"] == \"유형C\").astype(int)\n", + " x[\"stock_gap_x_orders\"] = x[\"maint_orders\"] * ((100 - x[\"parts_stock_pct\"]) / 28) ** 2\n", + " x[\"fill_gap_x_orders\"] = x[\"maint_orders\"] * ((100 - x[\"mechanic_fill_pct\"]) / 24) ** 2\n", + " return x[SIM_FEAT]\n", + "\n", + "\n", + "def fit_sim(df, alpha=0.5):\n", + " return make_pipeline(StandardScaler(), Ridge(alpha=alpha)).fit(sim_frame(df), df[\"avail_pct\"])\n", + "\n", + "fleet = make_fleet(1500, seed=21)\n", + "idx = np.random.default_rng(5).permutation(len(fleet))\n", + "tr, te = fleet.iloc[idx[:1050]], fleet.iloc[idx[1050:]] # 학습 70% / 검증 30%(과거 처리 완료 자료 가정)\n", + "\n", + "# 1) 회귀 분석: 가동률 = f(연령, 정비 요청, 재고, 충원율, 외주·검사 대기, 재작업, 규격 불일치, 하자, 유형)\n", + "reg = fit_avail(tr)\n", + "pred = reg.predict(feat_frame(te))\n", + "coef = pd.Series(reg[-1].coef_, index=FEAT) # 표준화 계수: 1표준편차 증가 시 가동률 변화(%p)\n", + "print(f\"[회귀 모델] 학습 {len(tr)}대 / 검증 {len(te)}대: 검증 R2 {r2_score(te['avail_pct'], pred):.3f}, MAE {mean_absolute_error(te['avail_pct'], pred):.2f}%p\")\n", + "boot = []\n", + "rng = np.random.default_rng(9)\n", + "for _ in range(20): # 부트스트랩 20회로 계수 부호 안정성 확인\n", + " b = tr.iloc[rng.integers(0, len(tr), len(tr))]\n", + " boot.append(fit_avail(b)[-1].coef_)\n", + "boot = np.array(boot)\n", + "top = coef.abs().sort_values(ascending=False).head(5).index\n", + "print(\"[핵심 병목 요인: 표준화 계수(1 표준편차 증가 시 가동률 변화 %p) / 부호 유지 비율]\")\n", + "for f in top:\n", + " k = FEAT.index(f)\n", + " print(f\" {FEAT_KO[f]:12s} {coef[f]:+6.2f}%p 부호 유지 {np.mean(np.sign(boot[:, k]) == np.sign(coef[f])):.0%}\")\n", + "\n", + "# 2) 요인 분류 + Top-3 개선 옵션\n", + "clf = fit_cause(tr)\n", + "low = te[(te[\"avail_pct\"] < 85) & (te[\"main_cause\"] != \"없음\")]\n", + "X = cause_frame(low)\n", + "proba = clf.predict_proba(X)\n", + "cls = clf.classes_\n", + "top1 = cls[proba.argmax(1)]\n", + "acc1 = float(np.mean(top1 == low[\"main_cause\"].to_numpy()))\n", + "order = np.argsort(-proba, axis=1)[:, :3]\n", + "acc3 = float(np.mean([low[\"main_cause\"].iloc[i] in cls[order[i]] for i in range(len(low))]))\n", + "major = low[\"main_cause\"].value_counts(normalize=True).iloc[0]\n", + "print(f\"\\n[요인 분류] 검증 저가동 장비 {len(low)}대: Top-1 정확도 {acc1:.1%}, Top-3 포함률 {acc3:.1%} (무작위 최빈값 기준 {major:.1%}) -> 목표 85% 이상: {'달성' if acc1 >= 0.85 else '미달'}\")\n", + "rc = pd.crosstab(low[\"main_cause\"], pd.Series(top1, index=low.index, name=\"예측\"))\n", + "print(\"요인별 재현율:\", \", \".join(f\"{c} {rc.loc[c, c] / rc.loc[c].sum():.0%}\" for c in rc.index if c in rc.columns))\n", + "\n", + "# 3) 유사 과거 사례 유사도(코사인): 학습 세트 중 같은 요인 장비와 얼마나 비슷한가\n", + "sc, trl = clf[0], tr[(tr[\"avail_pct\"] < 85) & (tr[\"main_cause\"] != \"없음\")]\n", + "Z = sc.transform(cause_frame(trl)); Zn = Z / np.linalg.norm(Z, axis=1, keepdims=True)\n", + "zq = sc.transform(X); zqn = zq / np.linalg.norm(zq, axis=1, keepdims=True)\n", + "sim = {c: (zqn @ Zn[(trl[\"main_cause\"] == c).to_numpy()].T).max(axis=1) for c in cls}\n", + "print(\"\\n[Top-3 개선 옵션 예시: 확률(%) / 유사 사례 유사도]\")\n", + "for j in range(3):\n", + " r = low.iloc[j]\n", + " opts = \" | \".join(f\"{cls[o]} {proba[j, o]:.0%}/{sim[cls[o]][j]:.2f} -> {ACTION[cls[o]]}\" for o in order[j])\n", + " print(f\" {r['equip_id']} 가동률 {r['avail_pct']}% (정답 {r['main_cause']}): {opts}\")\n", + "miss = low[top1 != low[\"main_cause\"].to_numpy()]\n", + "print(f\"\\n[오분류 {len(miss)}건] 확률이 가장 높은 요인과 2위 확률 차이 평균 {np.mean(np.sort(proba, axis=1)[:, -1] - np.sort(proba, axis=1)[:, -2]):.2f}\")\n", + "conf = np.sort(proba, axis=1)[:, -1]\n", + "print(f\"확률 0.8 이상 추천의 정확도 {np.mean((top1 == low['main_cause'].to_numpy())[conf >= 0.8]):.0%} ({int((conf >= 0.8).sum())}건), 0.8 미만은 사람이 Top-3를 비교해 선택\")\n", + "print(\"※ 추천은 판단 보조이며 정비 우선순위와 자원 투입은 정비관·군수참모가 결정한다. 가상 자료이므로 실제 장비에서는 정확도가 다를 수 있다.\")\n", + "\n", + "plt.figure(figsize=(7, 4))\n", + "plt.barh(list(coef.abs().sort_values().index), coef[coef.abs().sort_values().index], color=\"tab:blue\")\n", + "plt.title(\"Standardized ridge coefficients on availability (%p per 1 SD)\"); plt.tight_layout(); plt.show()\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 04 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + "[회귀 모델] 학습 1050대 / 검증 450대: 검증 R2 0.899, MAE 3.97%p\n", + "[핵심 병목 요인: 표준화 계수(1 표준편차 증가 시 가동률 변화 %p) / 부호 유지 비율]\n", + " 정비 요청 건수 -8.63%p 부호 유지 100%\n", + " 외주 정비 일수 -6.62%p 부호 유지 100%\n", + " 외주 검사 대기 일수 -5.06%p 부호 유지 100%\n", + " 재작업 건수 -1.88%p 부호 유지 100%\n", + " 부품 재고 가용률 +1.73%p 부호 유지 100%\n", + "\n", + "[요인 분류] 검증 저가동 장비 234대: Top-1 정확도 99.1%, Top-3 포함률 100.0% (무작위 최빈값 기준 35.5%) -> 목표 85% 이상: 달성\n", + "요인별 재현율: 검사·재작업 97%, 부품 대기 100%, 외주 정비 99%, 정비병 부족 100%\n", + "\n", + "[Top-3 개선 옵션 예시: 확률(%) / 유사 사례 유사도]\n", + " E-1269 가동률 79.4% (정답 검사·재작업): 검사·재작업 98%/0.89 -> 검사 일정 조율·규격 사전 대조 | 외주 정비 2%/0.83 -> 외주 정비 일정 조율·내부 정비 확대 | 정비병 부족 0%/0.68 -> 정비병 재배치·교육 일정 조정\n", + " E-0462 가동률 74.4% (정답 외주 정비): 외주 정비 63%/0.74 -> 외주 정비 일정 조율·내부 정비 확대 | 부품 대기 36%/0.81 -> 부품 사전 청구·재고 보강 | 정비병 부족 0%/0.69 -> 정비병 재배치·교육 일정 조정\n", + " E-0924 가동률 3.6% (정답 외주 정비): 외주 정비 100%/0.85 -> 외주 정비 일정 조율·내부 정비 확대 | 부품 대기 0%/0.70 -> 부품 사전 청구·재고 보강 | 정비병 부족 0%/0.75 -> 정비병 재배치·교육 일정 조정\n", + "\n", + "[오분류 2건] 확률이 가장 높은 요인과 2위 확률 차이 평균 0.86\n", + "확률 0.8 이상 추천의 정확도 100% (192건), 0.8 미만은 사람이 Top-3를 비교해 선택\n", + "※ 추천은 판단 보조이며 정비 우선순위와 자원 투입은 정비관·군수참모가 결정한다. 가상 자료이므로 실제 장비에서는 정확도가 다를 수 있다.\n", + "```\n", + "
\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 05. 정비 자원 재배치 시 예상 가동률 상승폭 시뮬레이션 산출 엔진 개발\n", + "**소요시간**: 약 3.0시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "정비 자원 재배치 시 예상 가동률 상승폭 시뮬레이션 산출 엔진을 개발한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **정비병과 부품 재고를 부대 사이에서 옮겼을 때 예상 가동률 상승폭을 시뮬레이션하는 엔진을 만들고, 입력-분석-검토-결과 4개 화면의 프로토타입으로 보여 준다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **재고·충원율 부족분의 제곱과 정비 요청 건수의 상호작용을 반영한 회귀 모델을 만들어 검증 R2 확인하고 유효성을 점검하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `src/ui/qlt04_app.py` 형태의 결과물을 완성한다.\n", + "\n", + "### 🔍 평가 및 검증 기준 (PASS 조건)\n", + "#### 미션 판정\n", + "\n", + "**평가 구분:** 필수\n", + "\n", + "**핵심 평가영역:** 군 실무자 친화적 디자인 및 조작 편의성\n", + "\n", + "#### 미션 PASS 조건\n", + "\n", + "- 정량 기준: 핵심 화면 4종(입력-분석-검토-결과) 구현 완료\n", + "\n", + "\n", + "- 진행 기준: UI 프로토타입 로컬 서버 구동 및 전체 페이지 네비게이션 확인\n", + "\n", + "\n", + "- 검증 방법: 사용성 평가(UX Walkthrough) 및 인터랙션 테스트\n", + "\n", + "\n", + "#### 채점자 판정 메모\n", + "\n", + "> 단순 코드 실행 여부뿐만 아니라 출력된 수치와 근거 자료가 군 실무 요구사항에 부합하는지 면밀히 확인한다.\n", + "\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "\"\"\"부대별 정비병 충원율·부품 재고 가용률을 다른 부대로 옮겼을 때 장비 가동률이 얼마나 오르는지 회귀 모델로 시뮬레이션하고 상승폭이 큰 재배치안을 순서대로 고르며, 입력-분석-검토-결과 4개 화면과 상태 뱃지(승인대기·반려·완료)·필터·화면 이동을 갖춘 텍스트 프로토타입으로 보여 준다.\"\"\"\n", + "import numpy as np\n", + "import pandas as pd\n", + "import matplotlib.pyplot as plt\n", + "from sklearn.metrics import r2_score\n", + "\n", + "# ---------------------------------------------------------------------------\n", + "# [교육용가상자료] 가상 장비 가동률·정비 대기 자료 생성기 (실제 부대·장비·제원·가동률 정보 없음)\n", + "# 장비 유형 A(궤도형)·B(화포형)·C(차륜형)은 추상 구분이며 실제 장비와 무관하다.\n", + "# 최근 90일 동안 장비 1대의 정비 대기 요인(부품·정비병·외주 정비·외주 검사·재작업)과 가동률을 만든다.\n", + "# ---------------------------------------------------------------------------\n", + "UNITS = [\"가상A부대\", \"가상B부대\", \"가상C부대\", \"가상D부대\", \"가상E부대\", \"가상F부대\"]\n", + "ETYPES = [\"유형A\", \"유형B\", \"유형C\"]\n", + "WINDOW = 90 # 관측 기간(일)\n", + "CAUSES = [\"부품 대기\", \"정비병 부족\", \"외주 정비\", \"검사·재작업\"]\n", + "CAUSE_COL = {\"부품 대기\": \"parts_wait_days\", \"정비병 부족\": \"labor_wait_days\", \"외주 정비\": \"outsource_days\", \"검사·재작업\": \"inspect_wait_days\"}\n", + "NUM_FIELDS = [\"age_years\", \"op_hours\", \"maint_orders\", \"parts_wait_days\", \"labor_wait_days\", \"outsource_days\", \"inspect_wait_days\", \"rework_count\",\n", + " \"spec_mismatch\", \"defect_reports\", \"parts_stock_pct\", \"mechanic_fill_pct\", \"avail_pct\"]\n", + "FEAT = [\"age_years\", \"maint_orders\", \"parts_stock_pct\", \"mechanic_fill_pct\", \"outsource_days\", \"inspect_wait_days\", \"rework_count\", \"spec_mismatch\", \"defect_reports\", \"etype_B\", \"etype_C\"]\n", + "FEAT_KO = {\"age_years\": \"장비 연령\", \"maint_orders\": \"정비 요청 건수\", \"parts_stock_pct\": \"부품 재고 가용률\", \"mechanic_fill_pct\": \"정비병 충원율\", \"outsource_days\": \"외주 정비 일수\",\n", + " \"inspect_wait_days\": \"외주 검사 대기 일수\", \"rework_count\": \"재작업 건수\", \"spec_mismatch\": \"규격·제원 불일치\", \"defect_reports\": \"하자·불만 접수\",\n", + " \"etype_B\": \"유형B 여부\", \"etype_C\": \"유형C 여부\"}\n", + "\n", + "\n", + "def unit_profile(seed=3):\n", + " \"\"\"부대별 고정 특성: 부품 재고 수준, 정비병 충원율, 외주 정비소 거리(일수 가중), 외주 검사 혼잡도.\"\"\"\n", + " # [TODO : 학생 실습 5] 정비 자원 재배치 시 예상 가동률 상승폭 시뮬레이션 산출 엔진 개발 관련 핵심 로직을 구현하세요.\n", + " # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n", + " # <<< 여기에 코드를 작성하세요 >>>\n", + " pass # 작성 후 pass를 제거하세요.\n", + "def make_fleet(n=300, seed=7, shock=0.0):\n", + " \"\"\"장비 n대의 90일 관측 자료(필드 16개)와 정답 요인을 만든다. shock은 외주 검사 병목 악화 정도(AAR용 환경 변화).\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " pf = unit_profile()\n", + " ui = rng.integers(0, len(UNITS), n)\n", + " et = rng.choice(ETYPES, n, p=[.35, .3, .35])\n", + " age = np.clip(rng.gamma(4, 2.2, n), 0.5, 30).round(1)\n", + " eff = np.array([{\"유형A\": 1.15, \"유형B\": 1.0, \"유형C\": 0.9}[t] for t in et])\n", + " orders = rng.poisson(np.clip(1.2 + 0.12 * age, 0.5, 6) * eff)\n", + " stock = np.clip(pf[\"stock\"].to_numpy()[ui] + rng.normal(0, 8, n), 20, 100).round(0)\n", + " fill = np.clip(pf[\"fill\"].to_numpy()[ui] + rng.normal(0, 6, n), 30, 100).round(0)\n", + " parts_w = (orders * rng.gamma(2, 0.55 * (1 + ((100 - stock) / 28) ** 2) / 2)).round(0)\n", + " labor_w = (orders * rng.gamma(2, 0.7 * (1 + ((100 - fill) / 24) ** 2) / 2)).round(0)\n", + " n_out = rng.binomial(orders, 0.3)\n", + " out_d = (n_out * rng.gamma(3, 4.5 * pf[\"dist\"].to_numpy()[ui] / 3)).round(0)\n", + " insp_w = (n_out * rng.gamma(2, (2.6 + shock) * pf[\"queue\"].to_numpy()[ui] / 2)).round(0)\n", + " spec = rng.poisson(0.2 + 0.04 * age)\n", + " rework = rng.binomial(n_out, np.clip(0.08 + 0.1 * spec, 0, .6))\n", + " defect = rng.poisson(0.3 + 0.7 * rework)\n", + " base_rep = orders * 1.8\n", + " down = base_rep + parts_w + labor_w + out_d + insp_w + rework * 4 + rng.normal(0, 1.2, n)\n", + " comp = np.column_stack([parts_w, labor_w, out_d, insp_w + rework * 4])\n", + " df = pd.DataFrame({\"equip_id\": [f\"E-{i + 1:04d}\" for i in range(n)], \"unit\": np.array(UNITS)[ui], \"etype\": et, \"age_years\": age,\n", + " \"op_hours\": np.clip(rng.normal(320 - 4 * age, 70, n), 40, 600).round(0), \"maint_orders\": orders,\n", + " \"parts_wait_days\": parts_w, \"labor_wait_days\": labor_w, \"outsource_days\": out_d, \"inspect_wait_days\": insp_w,\n", + " \"rework_count\": rework, \"spec_mismatch\": spec, \"defect_reports\": defect, \"parts_stock_pct\": stock, \"mechanic_fill_pct\": fill,\n", + " \"avail_pct\": (100 * (1 - np.clip(down, 0, WINDOW) / WINDOW)).round(1)})\n", + " df[\"main_cause\"] = np.where(comp.max(axis=1) < 3, \"없음\", np.array(CAUSES)[comp.argmax(axis=1)])\n", + " return df\n", + "\n", + "\n", + "def feat_frame(df):\n", + " \"\"\"회귀·분석용 설명 변수 표(유형은 더미 변수).\"\"\"\n", + " # [TODO : 학생 실습 5] 정비 자원 재배치 시 예상 가동률 상승폭 시뮬레이션 산출 엔진 개발 관련 핵심 로직을 구현하세요.\n", + " # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n", + " # <<< 여기에 코드를 작성하세요 >>>\n", + " pass # 작성 후 pass를 제거하세요.\n", + "def make_trend(months=12, seed=5, shock_unit=None):\n", + " \"\"\"[교육용가상자료] 부대 x 유형 x 월 평균 가동률 추세. shock_unit 부대는 하반기에 외주 검사 병목으로 가동률이 떨어진다.\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " pf = unit_profile()\n", + " rows = []\n", + " for _, u in pf.iterrows():\n", + " base = 62 + 0.22 * u[\"stock\"] + 0.12 * u[\"fill\"] - 3.0 * u[\"queue\"]\n", + " for t in ETYPES:\n", + " off = {\"유형A\": -3.0, \"유형B\": 0.0, \"유형C\": 2.0}[t]\n", + " for m in range(months):\n", + " v = base + off + 2.0 * np.sin(m / 12 * 2 * np.pi) + rng.normal(0, 1.0)\n", + " if shock_unit == u[\"unit\"] and m >= months // 2:\n", + " v -= 1.5 * (m - months // 2 + 1)\n", + " rows.append((u[\"unit\"], t, m + 1, round(float(np.clip(v, 30, 99)), 1)))\n", + " return pd.DataFrame(rows, columns=[\"unit\", \"etype\", \"month\", \"avail_pct\"])\n", + "\n", + "# ---- 가동률 회귀 모델과 저가동 요인 분류·추천 모델(외부 AI 서비스 없이 scikit-learn 사용) ----\n", + "from sklearn.linear_model import Ridge, LogisticRegression\n", + "from sklearn.preprocessing import StandardScaler\n", + "from sklearn.pipeline import make_pipeline\n", + "\n", + "CAUSE_COLS = [\"age_years\", \"maint_orders\", \"parts_stock_pct\", \"mechanic_fill_pct\", \"outsource_days\", \"inspect_wait_days\", \"rework_count\", \"spec_mismatch\",\n", + " \"defect_reports\", \"parts_wait_days\", \"labor_wait_days\"]\n", + "ACTION = {\"부품 대기\": \"부품 사전 청구·재고 보강\", \"정비병 부족\": \"정비병 재배치·교육 일정 조정\", \"외주 정비\": \"외주 정비 일정 조율·내부 정비 확대\", \"검사·재작업\": \"검사 일정 조율·규격 사전 대조\"}\n", + "\n", + "\n", + "def cause_frame(df):\n", + " x = df[CAUSE_COLS].copy()\n", + " x[\"out_insp\"] = x[\"outsource_days\"] + x[\"inspect_wait_days\"] + 4 * x[\"rework_count\"]\n", + " x[\"wait_pm\"] = (x[\"parts_wait_days\"] + x[\"labor_wait_days\"]) / (x[\"maint_orders\"] + 1)\n", + " return x\n", + "\n", + "\n", + "def fit_avail(df, alpha=1.0):\n", + " \"\"\"가동률(%) 회귀: 표준화 + 릿지 회귀.\"\"\"\n", + " return make_pipeline(StandardScaler(), Ridge(alpha=alpha)).fit(feat_frame(df), df[\"avail_pct\"])\n", + "\n", + "\n", + "def fit_cause(df, cut=85, C=5):\n", + " \"\"\"가동률이 cut% 미만인 장비의 주요 저가동 요인(4종)을 분류한다.\"\"\"\n", + " low = df[(df[\"avail_pct\"] < cut) & (df[\"main_cause\"] != \"없음\")]\n", + " return make_pipeline(StandardScaler(), LogisticRegression(C=C, max_iter=3000)).fit(cause_frame(low), low[\"main_cause\"])\n", + "\n", + "\n", + "SIM_FEAT = [\"age_years\", \"maint_orders\", \"spec_mismatch\", \"stock_gap_x_orders\", \"fill_gap_x_orders\", \"parts_stock_pct\", \"mechanic_fill_pct\", \"etype_B\", \"etype_C\"]\n", + "\n", + "\n", + "def sim_frame(df):\n", + " \"\"\"자원 재배치 시뮬레이션용 설명 변수: 부족분이 클수록 대기가 더 빠르게 늘고 정비 요청 건수와 곱해져 커진다는 구조를 제곱·상호작용 항으로 둔다.\"\"\"\n", + " x = df.copy()\n", + " x[\"etype_B\"], x[\"etype_C\"] = (x[\"etype\"] == \"유형B\").astype(int), (x[\"etype\"] == \"유형C\").astype(int)\n", + " x[\"stock_gap_x_orders\"] = x[\"maint_orders\"] * ((100 - x[\"parts_stock_pct\"]) / 28) ** 2\n", + " x[\"fill_gap_x_orders\"] = x[\"maint_orders\"] * ((100 - x[\"mechanic_fill_pct\"]) / 24) ** 2\n", + " return x[SIM_FEAT]\n", + "\n", + "\n", + "def fit_sim(df, alpha=0.5):\n", + " return make_pipeline(StandardScaler(), Ridge(alpha=alpha)).fit(sim_frame(df), df[\"avail_pct\"])\n", + "\n", + "fleet = make_fleet(600, seed=31)\n", + "sim_tr, sim_te = fleet.iloc[:420], fleet.iloc[420:]\n", + "sm = fit_sim(sim_tr)\n", + "print(f\"[시뮬레이션 모델] 검증 R2 {r2_score(sim_te['avail_pct'], sm.predict(sim_frame(sim_te))):.3f} (재고·충원율 부족분의 제곱과 정비 요청 건수의 상호작용 반영)\")\n", + "sm = fit_sim(fleet)\n", + "AUTH = 40 # 부대당 정비병 편성 정원(가상 가정)\n", + "PARAM = {\"max_move\": 4, \"min_donor_fill\": 60.0, \"stock_step\": 8.0, \"top_n\": 4, \"max_donor_drop\": 3.5}\n", + "\n", + "\n", + "def unit_avail(df, unit, fill_d=0.0, stock_d=0.0):\n", + " d = df[df[\"unit\"] == unit].copy()\n", + " d[\"mechanic_fill_pct\"] = np.clip(d[\"mechanic_fill_pct\"] + fill_d, 0, 100)\n", + " d[\"parts_stock_pct\"] = np.clip(d[\"parts_stock_pct\"] + stock_d, 0, 100)\n", + " return float(np.clip(sm.predict(sim_frame(d)), 0, 100).mean()), len(d)\n", + "\n", + "\n", + "def evaluate(df, kind, src, dst, n):\n", + " \"\"\"src 부대에서 dst 부대로 n단위(정비병 n명 또는 재고 n*step %p)를 옮길 때 전체 평균 가동률 변화(%p)와 제공 부대 변화.\"\"\"\n", + " delta = n / AUTH * 100 if kind == \"정비병\" else n * PARAM[\"stock_step\"] / 2\n", + " key = \"mechanic_fill_pct\" if kind == \"정비병\" else \"parts_stock_pct\"\n", + " a0, na = unit_avail(df, src); b0, nb = unit_avail(df, dst)\n", + " kw = (lambda s: {\"fill_d\": s * delta}) if kind == \"정비병\" else (lambda s: {\"stock_d\": s * delta})\n", + " a1, _ = unit_avail(df, src, **kw(-1)); b1, _ = unit_avail(df, dst, **kw(+1))\n", + " cur = df[df[\"unit\"] == src][key].mean()\n", + " return {\"kind\": kind, \"src\": src, \"dst\": dst, \"n\": n, \"gain\": ((a1 - a0) * na + (b1 - b0) * nb) / len(df), \"donor_drop\": a0 - a1, \"recv_gain\": b1 - b0, \"donor_after\": cur - delta}\n", + "\n", + "\n", + "base = pd.DataFrame([{\"unit\": u, **dict(zip([\"avail\", \"n\"], unit_avail(fleet, u))), \"stock\": g[\"parts_stock_pct\"].mean(), \"fill\": g[\"mechanic_fill_pct\"].mean()} for u, g in fleet.groupby(\"unit\")])\n", + "cands = [evaluate(fleet, k, s, d, n) for k in [\"정비병\", \"부품 재고\"] for s in UNITS for d in UNITS if s != d for n in (2, 4, 6)]\n", + "cands = [c for c in cands if c[\"donor_after\"] >= PARAM[\"min_donor_fill\"]]\n", + "cands.sort(key=lambda c: -c[\"gain\"])\n", + "print(f\"후보 {len(cands)}건 평가 (제공 부대당 정비병 누적 최대 {PARAM['max_move']}명 이동, 제공 부대 충원율·재고 {PARAM['min_donor_fill']:.0f}% 이상 유지)\")\n", + "\n", + "# 화면 4종 프로토타입\n", + "BADGE = {\"승인대기\": \"[승인대기]\", \"반려\": \"[반려]\", \"완료\": \"[완료]\"}\n", + "props = [{\"id\": \"P00\", **evaluate(fleet, \"정비병\", \"가상C부대\", \"가상D부대\", 2), \"status\": \"완료\", \"note\": \"전 분기 시행 완료(가상 이력)\"}]\n", + "for c in cands: # 같은 도착 부대 중복을 피해 상위 4건 제안\n", + " if len([p for p in props if p[\"id\"] != \"P00\"]) >= PARAM[\"top_n\"]:\n", + " break\n", + " if any(p[\"dst\"] == c[\"dst\"] and p[\"kind\"] == c[\"kind\"] for p in props):\n", + " continue\n", + " moved = sum(p[\"n\"] for p in props if p[\"src\"] == c[\"src\"] and p[\"kind\"] == \"정비병\" and p[\"status\"] != \"반려\") + (c[\"n\"] if c[\"kind\"] == \"정비병\" else 0)\n", + " if c[\"donor_drop\"] > PARAM[\"max_donor_drop\"]:\n", + " ok, why = False, f\"제공 부대 가동률 {c['donor_drop']:.1f}%p 하락(한도 {PARAM['max_donor_drop']} 초과)\"\n", + " elif moved > PARAM[\"max_move\"]:\n", + " ok, why = False, f\"제공 부대 누적 이동 {moved}명(한도 {PARAM['max_move']}명 초과)\"\n", + " else:\n", + " ok, why = True, \"제공 부대 영향·누적 이동 허용 범위\"\n", + " props.append({\"id\": f\"P{len(props):02d}\", **c, \"status\": \"승인대기\" if ok else \"반려\", \"note\": why})\n", + "\n", + "\n", + "class App:\n", + " SCREENS = [\"입력\", \"분석\", \"검토\", \"결과\"]\n", + "\n", + " def __init__(self):\n", + " self.cur, self.flt, self.visited = \"입력\", None, [\"입력\"]\n", + "\n", + " def goto(self, s):\n", + " assert s in self.SCREENS\n", + " self.cur = s; self.visited.append(s); return self.render()\n", + "\n", + " def render(self):\n", + " if self.cur == \"입력\":\n", + " return [\"입력 화면: 장비 %d대 / 부대 %d곳 / 파라미터 %s\" % (len(fleet), len(UNITS), PARAM)]\n", + " if self.cur == \"분석\":\n", + " return [f\" {r.unit} 가동률 {r.avail:5.1f}% 재고 {r.stock:4.0f}% 충원 {r.fill:4.0f}% ({int(r.n)}대)\" + (\" <- 하위\" if r.avail <= base[\"avail\"].nsmallest(2).max() else \"\") for r in base.itertuples()]\n", + " if self.cur == \"검토\":\n", + " ps = [p for p in props if self.flt in (None, p[\"status\"])]\n", + " return [f\" {p['id']} {BADGE[p['status']]:8s} {p['kind']} {p['n']}단위 {p['src'][2:4]}->{p['dst'][2:4]} 전체 {p['gain']:+.2f}%p(수혜 {p['recv_gain']:+.1f}/제공 {-p['donor_drop']:+.1f}) | {p['note']}\" for p in ps]\n", + " ok = [p for p in props if p[\"status\"] == \"승인대기\"]\n", + " return [f\" 승인 대기 {len(ok)}건 시행 시 예상 전체 가동률 +{sum(p['gain'] for p in ok):.2f}%p (정비병 이동은 합산 근사)\", \" 최종 시행은 군수참모 결정(M7 워크플로우)\"]\n", + "\n", + "\n", + "app = App()\n", + "for s in App.SCREENS:\n", + " print(f\"\\n[화면 {App.SCREENS.index(s) + 1}: {s}]\")\n", + " for line in app.goto(s):\n", + " print(line)\n", + "app.flt = \"승인대기\"; n1 = len(app.goto(\"검토\")); app.flt = \"반려\"; n2 = len(app.goto(\"검토\")); app.flt = None\n", + "print(f\"\\n필터 시험: 승인대기 {n1}건, 반려 {n2}건, 전체 {len(app.goto('검토'))}건\")\n", + "nav = [(a, b) for a in App.SCREENS for b in App.SCREENS if a != b]\n", + "okn = 0\n", + "for a, b in nav:\n", + " app.cur = a; okn += bool(app.goto(b))\n", + "print(f\"화면 이동 시험 {okn}/{len(nav)} 성공, 핵심 화면 {len(App.SCREENS)}종 구현 (목표 4종: {'달성' if len(App.SCREENS) == 4 else '미달'})\")\n", + "tot = sum(p[\"gain\"] for p in props if p[\"status\"] == \"승인대기\")\n", + "print(f\"재배치 시뮬레이션: 승인대기 안 합산 예상 상승폭 +{tot:.2f}%p, 최대 단일 안 {max(c['gain'] for c in cands):+.2f}%p. 시뮬레이션은 가정 모델 기반 추정이며 시행 여부는 사람이 결정한다.\")\n", + "\n", + "plt.figure(figsize=(7, 4))\n", + "plt.bar([f\"{p['id']}\\n{ {'완료': 'done', '승인대기': 'pending', '반려': 'rejected'}[p['status']]}\" for p in props], [p[\"gain\"] for p in props], color=[\"tab:gray\" if p[\"status\"] == \"완료\" else \"tab:blue\" if p[\"status\"] == \"승인대기\" else \"tab:red\" for p in props])\n", + "plt.ylabel(\"fleet availability gain (%p)\"); plt.title(\"Reallocation proposals (gray done / blue pending / red rejected)\"); plt.tight_layout(); plt.show()\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 05 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + "[시뮬레이션 모델] 검증 R2 0.646 (재고·충원율 부족분의 제곱과 정비 요청 건수의 상호작용 반영)\n", + "후보 70건 평가 (제공 부대당 정비병 누적 최대 4명 이동, 제공 부대 충원율·재고 60% 이상 유지)\n", + "\n", + "[화면 1: 입력]\n", + "입력 화면: 장비 600대 / 부대 6곳 / 파라미터 {'max_move': 4, 'min_donor_fill': 60.0, 'stock_step': 8.0, 'top_n': 4, 'max_donor_drop': 3.5}\n", + "\n", + "[화면 2: 분석]\n", + " 가상A부대 가동률 78.3% 재고 50% 충원 76% (94대)\n", + " 가상B부대 가동률 75.0% 재고 57% 충원 63% (87대) <- 하위\n", + " 가상C부대 가동률 83.8% 재고 84% 충원 87% (99대)\n", + " 가상D부대 가동률 77.8% 재고 75% 충원 59% (115대) <- 하위\n", + " 가상E부대 가동률 80.8% 재고 50% 충원 72% (100대)\n", + " 가상F부대 가동률 79.9% 재고 66% 충원 78% (105대)\n", + "\n", + "[화면 3: 검토]\n", + " P00 [완료] 정비병 2단위 C부->D부 전체 +0.14%p(수혜 +1.5/제공 -0.9) | 전 분기 시행 완료(가상 이력)\n", + " P01 [반려] 정비병 4단위 C부->B부 전체 +0.11%p(수혜 +3.1/제공 -2.0) | 제공 부대 누적 이동 6명(한도 4명 초과)\n", + " P02 [승인대기] 부품 재고 4단위 C부->A부 전체 +0.06%p(수혜 +0.4/제공 -0.1) | 제공 부대 영향·누적 이동 허용 범위\n", + " P03 [승인대기] 정비병 2단위 C부->E부 전체 +0.05%p(수혜 +1.2/제공 -0.9) | 제공 부대 영향·누적 이동 허용 범위\n", + " P04 [반려] 정비병 2단위 C부->F부 전체 +0.04%p(수혜 +1.1/제공 -0.9) | 제공 부대 누적 이동 6명(한도 4명 초과)\n", + "\n", + "[화면 4: 결과]\n", + " 승인 대기 2건 시행 시 예상 전체 가동률 +0.10%p (정비병 이동은 합산 근사)\n", + " 최종 시행은 군수참모 결정(M7 워크플로우)\n", + "\n", + "필터 시험: 승인대기 2건, 반려 2건, 전체 5건\n", + "화면 이동 시험 12/12 성공, 핵심 화면 4종 구현 (목표 4종: 달성)\n", + "재배치 시뮬레이션: 승인대기 안 합산 예상 상승폭 +0.10%p, 최대 단일 안 +0.26%p. 시뮬레이션은 가정 모델 기반 추정이며 시행 여부는 사람이 결정한다.\n", + "```\n", + "
\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 06. 부대별/장비별 가동률 추세 및 가동률 저하 요인 분석 대시보드 구현\n", + "**소요시간**: 약 2.5시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "부대별/장비별 가동률 추세 및 저하 요인 분석 대시보드를 구현한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **부대별·장비 유형별 가동률 추세와 저하 요인을 보여 주는 대시보드를 만들고, 비정상 입력에도 중단되지 않도록 입력 검증기와 에러 핸들러를 갖춘다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **12개월 가상 추세로 부대별 월 기울기, 최근 3개월 평균, 저하 경고와 부대 x 유형 가동률, 대기 손실일 구성을 표와 그래프로 보여 준다 과정을 진행하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `src/utils/qlt04_validator.py 및 에러 핸들러` 형태의 결과물을 완성한다.\n", + "\n", + "### 🔍 평가 및 검증 기준 (PASS 조건)\n", + "#### 미션 판정\n", + "\n", + "**평가 구분:** 필수\n", + "\n", + "**핵심 평가영역:** 사용자에게 알기 쉬운 에러 메시지 및 조치 가이드 제공\n", + "\n", + "#### 미션 PASS 조건\n", + "\n", + "- 정량 기준: 예외 상황 발생 시 크래시(Crash) 0건, 에러 로그 기록률 100%\n", + "\n", + "\n", + "- 진행 기준: 예외 주입 테스트 10종 통과 및 감사 로그 파일 생성\n", + "\n", + "\n", + "- 검증 방법: 비정상 데이터(결측, 특수문자, 대용량) 스트레스 테스트\n", + "\n", + "\n", + "#### 채점자 판정 메모\n", + "\n", + "> 단순 코드 실행 여부뿐만 아니라 출력된 수치와 근거 자료가 군 실무 요구사항에 부합하는지 면밀히 확인한다.\n", + "\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "\"\"\"부대별·장비 유형별 12개월 가동률 추세와 저하 요인 구성을 텍스트 대시보드와 그래프로 보여 주고, 결측·특수문자·자료형 오류·대용량·빈 자료·열 누락 등 예외 13종을 주입해도 중단 없이 오류 격리·안내 메시지·오류 로그·재시도·롤백이 동작하는지 시험하는 입력 검증기와 에러 핸들러를 구현한다.\"\"\"\n", + "import copy\n", + "import re\n", + "import time\n", + "import numpy as np\n", + "import pandas as pd\n", + "import matplotlib.pyplot as plt\n", + "\n", + "# ---------------------------------------------------------------------------\n", + "# [교육용가상자료] 가상 장비 가동률·정비 대기 자료 생성기 (실제 부대·장비·제원·가동률 정보 없음)\n", + "# 장비 유형 A(궤도형)·B(화포형)·C(차륜형)은 추상 구분이며 실제 장비와 무관하다.\n", + "# 최근 90일 동안 장비 1대의 정비 대기 요인(부품·정비병·외주 정비·외주 검사·재작업)과 가동률을 만든다.\n", + "# ---------------------------------------------------------------------------\n", + "UNITS = [\"가상A부대\", \"가상B부대\", \"가상C부대\", \"가상D부대\", \"가상E부대\", \"가상F부대\"]\n", + "ETYPES = [\"유형A\", \"유형B\", \"유형C\"]\n", + "WINDOW = 90 # 관측 기간(일)\n", + "CAUSES = [\"부품 대기\", \"정비병 부족\", \"외주 정비\", \"검사·재작업\"]\n", + "CAUSE_COL = {\"부품 대기\": \"parts_wait_days\", \"정비병 부족\": \"labor_wait_days\", \"외주 정비\": \"outsource_days\", \"검사·재작업\": \"inspect_wait_days\"}\n", + "NUM_FIELDS = [\"age_years\", \"op_hours\", \"maint_orders\", \"parts_wait_days\", \"labor_wait_days\", \"outsource_days\", \"inspect_wait_days\", \"rework_count\",\n", + " \"spec_mismatch\", \"defect_reports\", \"parts_stock_pct\", \"mechanic_fill_pct\", \"avail_pct\"]\n", + "FEAT = [\"age_years\", \"maint_orders\", \"parts_stock_pct\", \"mechanic_fill_pct\", \"outsource_days\", \"inspect_wait_days\", \"rework_count\", \"spec_mismatch\", \"defect_reports\", \"etype_B\", \"etype_C\"]\n", + "FEAT_KO = {\"age_years\": \"장비 연령\", \"maint_orders\": \"정비 요청 건수\", \"parts_stock_pct\": \"부품 재고 가용률\", \"mechanic_fill_pct\": \"정비병 충원율\", \"outsource_days\": \"외주 정비 일수\",\n", + " \"inspect_wait_days\": \"외주 검사 대기 일수\", \"rework_count\": \"재작업 건수\", \"spec_mismatch\": \"규격·제원 불일치\", \"defect_reports\": \"하자·불만 접수\",\n", + " \"etype_B\": \"유형B 여부\", \"etype_C\": \"유형C 여부\"}\n", + "\n", + "\n", + "def unit_profile(seed=3):\n", + " \"\"\"부대별 고정 특성: 부품 재고 수준, 정비병 충원율, 외주 정비소 거리(일수 가중), 외주 검사 혼잡도.\"\"\"\n", + " # [TODO : 학생 실습 6] 부대별/장비별 가동률 추세 및 가동률 저하 요인 분석 대시보드 구현 관련 핵심 로직을 구현하세요.\n", + " # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n", + " # <<< 여기에 코드를 작성하세요 >>>\n", + " pass # 작성 후 pass를 제거하세요.\n", + "def make_fleet(n=300, seed=7, shock=0.0):\n", + " \"\"\"장비 n대의 90일 관측 자료(필드 16개)와 정답 요인을 만든다. shock은 외주 검사 병목 악화 정도(AAR용 환경 변화).\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " pf = unit_profile()\n", + " ui = rng.integers(0, len(UNITS), n)\n", + " et = rng.choice(ETYPES, n, p=[.35, .3, .35])\n", + " age = np.clip(rng.gamma(4, 2.2, n), 0.5, 30).round(1)\n", + " eff = np.array([{\"유형A\": 1.15, \"유형B\": 1.0, \"유형C\": 0.9}[t] for t in et])\n", + " orders = rng.poisson(np.clip(1.2 + 0.12 * age, 0.5, 6) * eff)\n", + " stock = np.clip(pf[\"stock\"].to_numpy()[ui] + rng.normal(0, 8, n), 20, 100).round(0)\n", + " fill = np.clip(pf[\"fill\"].to_numpy()[ui] + rng.normal(0, 6, n), 30, 100).round(0)\n", + " parts_w = (orders * rng.gamma(2, 0.55 * (1 + ((100 - stock) / 28) ** 2) / 2)).round(0)\n", + " labor_w = (orders * rng.gamma(2, 0.7 * (1 + ((100 - fill) / 24) ** 2) / 2)).round(0)\n", + " n_out = rng.binomial(orders, 0.3)\n", + " out_d = (n_out * rng.gamma(3, 4.5 * pf[\"dist\"].to_numpy()[ui] / 3)).round(0)\n", + " insp_w = (n_out * rng.gamma(2, (2.6 + shock) * pf[\"queue\"].to_numpy()[ui] / 2)).round(0)\n", + " spec = rng.poisson(0.2 + 0.04 * age)\n", + " rework = rng.binomial(n_out, np.clip(0.08 + 0.1 * spec, 0, .6))\n", + " defect = rng.poisson(0.3 + 0.7 * rework)\n", + " base_rep = orders * 1.8\n", + " down = base_rep + parts_w + labor_w + out_d + insp_w + rework * 4 + rng.normal(0, 1.2, n)\n", + " comp = np.column_stack([parts_w, labor_w, out_d, insp_w + rework * 4])\n", + " df = pd.DataFrame({\"equip_id\": [f\"E-{i + 1:04d}\" for i in range(n)], \"unit\": np.array(UNITS)[ui], \"etype\": et, \"age_years\": age,\n", + " \"op_hours\": np.clip(rng.normal(320 - 4 * age, 70, n), 40, 600).round(0), \"maint_orders\": orders,\n", + " \"parts_wait_days\": parts_w, \"labor_wait_days\": labor_w, \"outsource_days\": out_d, \"inspect_wait_days\": insp_w,\n", + " \"rework_count\": rework, \"spec_mismatch\": spec, \"defect_reports\": defect, \"parts_stock_pct\": stock, \"mechanic_fill_pct\": fill,\n", + " \"avail_pct\": (100 * (1 - np.clip(down, 0, WINDOW) / WINDOW)).round(1)})\n", + " df[\"main_cause\"] = np.where(comp.max(axis=1) < 3, \"없음\", np.array(CAUSES)[comp.argmax(axis=1)])\n", + " return df\n", + "\n", + "\n", + "def feat_frame(df):\n", + " \"\"\"회귀·분석용 설명 변수 표(유형은 더미 변수).\"\"\"\n", + " # [TODO : 학생 실습 6] 부대별/장비별 가동률 추세 및 가동률 저하 요인 분석 대시보드 구현 관련 핵심 로직을 구현하세요.\n", + " # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n", + " # <<< 여기에 코드를 작성하세요 >>>\n", + " pass # 작성 후 pass를 제거하세요.\n", + "def make_trend(months=12, seed=5, shock_unit=None):\n", + " \"\"\"[교육용가상자료] 부대 x 유형 x 월 평균 가동률 추세. shock_unit 부대는 하반기에 외주 검사 병목으로 가동률이 떨어진다.\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " pf = unit_profile()\n", + " rows = []\n", + " for _, u in pf.iterrows():\n", + " base = 62 + 0.22 * u[\"stock\"] + 0.12 * u[\"fill\"] - 3.0 * u[\"queue\"]\n", + " for t in ETYPES:\n", + " off = {\"유형A\": -3.0, \"유형B\": 0.0, \"유형C\": 2.0}[t]\n", + " for m in range(months):\n", + " v = base + off + 2.0 * np.sin(m / 12 * 2 * np.pi) + rng.normal(0, 1.0)\n", + " if shock_unit == u[\"unit\"] and m >= months // 2:\n", + " v -= 1.5 * (m - months // 2 + 1)\n", + " rows.append((u[\"unit\"], t, m + 1, round(float(np.clip(v, 30, 99)), 1)))\n", + " return pd.DataFrame(rows, columns=[\"unit\", \"etype\", \"month\", \"avail_pct\"])\n", + "\n", + "fleet = make_fleet(300, seed=41)\n", + "trend = make_trend(12, seed=5, shock_unit=\"가상C부대\")\n", + "\n", + "# 1) 대시보드: 부대별 12개월 가동률 추세, 최근 3개월 평균, 월 기울기, 경고\n", + "SP = \"▁▂▃▄▅▆▇█\"\n", + "def spark(v):\n", + " lo, hi = min(v), max(v)\n", + " return \"\".join(SP[int((x - lo) / (hi - lo + 1e-9) * 7)] for x in v)\n", + "rows = []\n", + "for u, g in trend.groupby(\"unit\"):\n", + " m = g.groupby(\"month\")[\"avail_pct\"].mean()\n", + " slope = np.polyfit(m.index, m.values, 1)[0]\n", + " last3 = m.iloc[-3:].mean()\n", + " rows.append({\"부대\": u, \"추세(1~12월)\": spark(m.values), \"상반기\": m.iloc[:6].mean(), \"하반기\": m.iloc[6:].mean(), \"최근3개월\": last3, \"월 기울기(%p)\": slope,\n", + " \"경고\": \"저하 경고\" if slope < -0.8 or last3 < 70 else \"\"})\n", + "dash = pd.DataFrame(rows)\n", + "print(\"[대시보드 1: 부대별 가동률 추세(가상)]\")\n", + "print(dash.round(1).to_string(index=False))\n", + "t_et = trend[trend[\"month\"] > 9].groupby([\"unit\", \"etype\"])[\"avail_pct\"].mean().unstack().round(1)\n", + "print(\"\\n[대시보드 2: 최근 3개월 부대 x 장비 유형 가동률]\"); print(t_et.to_string())\n", + "lossc = [\"parts_wait_days\", \"labor_wait_days\", \"outsource_days\", \"inspect_wait_days\"]\n", + "loss = fleet.groupby(\"unit\")[lossc].mean().round(1)\n", + "loss.columns = [\"부품 대기\", \"정비병 부족\", \"외주 정비\", \"외주 검사 대기\"]\n", + "print(\"\\n[대시보드 3: 부대별 평균 대기 손실일(저하 요인 구성)]\")\n", + "print(pd.concat([loss, loss.idxmax(axis=1).rename(\"최대 요인\")], axis=1).to_string())\n", + "worst = fleet.nsmallest(3, \"avail_pct\")[[\"equip_id\", \"unit\", \"etype\", \"avail_pct\", \"main_cause\"]]\n", + "print(\"[가동률 최하위 장비 3대]\"); print(worst.to_string(index=False))\n", + "\n", + "# 2) 입력 검증기 + 에러 핸들러\n", + "LOG = []\n", + "GUIDE = {\"E101\": \"필수 열이 없습니다. 열 이름을 양식과 비교해 추가하세요.\", \"E102\": \"빈 자료입니다. 파일이 올바른지 확인하고 다시 올리세요.\", \"E201\": \"형식·범위 오류 행을 격리했습니다. 격리 목록을 확인해 원본을 수정하세요.\",\n", + " \"E202\": \"중복 장비 번호를 1건만 남겼습니다. 최신 기록이 맞는지 확인하세요.\", \"E301\": \"일시적 읽기 오류입니다. 자동 재시도 후에도 실패하면 잠시 뒤 다시 시도하세요.\",\n", + " \"E401\": \"처리 중 오류가 있어 변경을 취소(롤백)했습니다. 이전 화면 상태가 유지됩니다.\", \"E501\": \"자료 형식을 알 수 없습니다. 표 형식(열 이름 포함) 자료를 올리세요.\"}\n", + "\n", + "\n", + "def log(code, msg, level=\"ERROR\"):\n", + " LOG.append({\"seq\": len(LOG) + 1, \"level\": level, \"code\": code, \"msg\": msg, \"guide\": GUIDE[code]})\n", + "\n", + "\n", + "class UserError(Exception):\n", + " def __init__(self, code, msg):\n", + " super().__init__(msg); self.code = code\n", + "\n", + "\n", + "def validate_frame(df):\n", + " \"\"\"행 단위 검증: 형식(장비 번호), 허용값(부대), 범위·자료형(숫자), 중복. 불량 행은 격리하고 나머지는 계속 처리한다.\"\"\"\n", + " if not isinstance(df, pd.DataFrame):\n", + " raise UserError(\"E501\", f\"DataFrame이 아닌 입력({type(df).__name__})\")\n", + " if len(df) == 0:\n", + " raise UserError(\"E102\", \"행 0건\")\n", + " miss = [c for c in [\"equip_id\", \"unit\", \"etype\", \"avail_pct\", \"maint_orders\"] if c not in df.columns]\n", + " if miss:\n", + " raise UserError(\"E101\", f\"누락 열 {miss}\")\n", + " d = df.copy()\n", + " num = pd.to_numeric(d[\"avail_pct\"], errors=\"coerce\"), pd.to_numeric(d[\"maint_orders\"], errors=\"coerce\")\n", + " bad = (~d[\"equip_id\"].astype(str).str.match(r\"^E-\\d{4,6}$\")) | (~d[\"unit\"].isin(UNITS)) | (~d[\"etype\"].isin(ETYPES))\n", + " bad |= ~np.isfinite(num[0]) | (num[0] < 0) | (num[0] > 100) | num[1].isna() | (num[1] < 0)\n", + " q = d[bad]\n", + " if len(q):\n", + " log(\"E201\", f\"형식·범위 오류 {len(q)}행 격리(예: {str(q.iloc[0]['equip_id'])[:12]!r})\", \"WARN\")\n", + " d = d[~bad].copy()\n", + " d[\"avail_pct\"] = pd.to_numeric(d[\"avail_pct\"]); d[\"maint_orders\"] = pd.to_numeric(d[\"maint_orders\"])\n", + " dup = d.duplicated(\"equip_id\")\n", + " if dup.any():\n", + " log(\"E202\", f\"중복 장비 번호 {int(dup.sum())}건 정리\", \"WARN\")\n", + " return d[~dup], q\n", + "\n", + "\n", + "class Store:\n", + " \"\"\"화면 상태 저장소: 갱신은 사본에서 먼저 하고 모두 성공해야 반영(트랜잭션 롤백)한다.\"\"\"\n", + " def __init__(self):\n", + " self.state = {\"version\": 0, \"avg\": None}\n", + "\n", + " def commit(self, fn):\n", + " snap = copy.deepcopy(self.state)\n", + " try:\n", + " fn(self.state); self.state[\"version\"] += 1\n", + " return True\n", + " except Exception as e:\n", + " self.state = snap; log(\"E401\", f\"갱신 실패 {type(e).__name__}: {str(e)[:40]}\"); return False\n", + "\n", + "\n", + "STORE = Store()\n", + "FLAKY = {\"n\": 0}\n", + "\n", + "\n", + "def flaky_loader(df):\n", + " FLAKY[\"n\"] += 1\n", + " if FLAKY[\"n\"] <= 2:\n", + " raise IOError(\"temporary read error\")\n", + " return df\n", + "\n", + "\n", + "def run(df, loader=None, fail_update=False):\n", + " \"\"\"대시보드 갱신 1회. 어떤 입력에서도 예외를 밖으로 던지지 않고 (상태, 처리 행수)를 돌려준다.\"\"\"\n", + " try:\n", + " for attempt in range(3): # 재시도(최대 3회)\n", + " try:\n", + " src = loader(df) if loader else df; break\n", + " except IOError:\n", + " log(\"E301\", f\"읽기 오류 재시도 {attempt + 1}/3\", \"WARN\")\n", + " else:\n", + " raise UserError(\"E301\", \"재시도 3회 실패\")\n", + " clean, q = validate_frame(src)\n", + " def upd(s):\n", + " s[\"avg\"] = float(clean[\"avail_pct\"].mean())\n", + " if fail_update:\n", + " raise RuntimeError(\"render failure\")\n", + " ok = STORE.commit(upd)\n", + " return (\"처리\" if not len(q) else \"격리 후 처리\") if ok else \"롤백\", len(clean)\n", + " except UserError as e:\n", + " log(e.code, str(e)); return \"거부(안내)\", 0\n", + " except Exception as e: # 예상 못 한 오류도 중단하지 않고 기록\n", + " log(\"E501\", f\"{type(e).__name__}: {str(e)[:40]}\"); return \"거부(안내)\", 0\n", + "\n", + "\n", + "good = fleet[[\"equip_id\", \"unit\", \"etype\", \"avail_pct\", \"maint_orders\"]]\n", + "def mod(**kw):\n", + " d = good.copy()\n", + " for k, v in kw.items():\n", + " d[k] = d[k].astype(object); d.loc[d.index[:3], k] = v\n", + " return d\n", + "big = pd.DataFrame({\"equip_id\": [f\"E-{i:06d}\" for i in range(200000)], \"unit\": \"가상A부대\", \"etype\": \"유형A\",\n", + " \"avail_pct\": np.random.default_rng(1).uniform(40, 100, 200000), \"maint_orders\": 2})\n", + "big.loc[big.index[::1000], \"avail_pct\"] = np.nan\n", + "cases = [(\"가동률 결측(NaN)\", mod(avail_pct=np.nan)), (\"가동률 문자열 '많음'\", mod(avail_pct=\"많음\")), (\"가동률 음수·100 초과\", mod(avail_pct=-5)),\n", + " (\"장비 번호 특수문자\", mod(equip_id=\"E-00;DROP --\")), (\"가상에 없는 부대명\", mod(unit=\"미상부대\")), (\"무한대(inf) 값\", mod(avail_pct=np.inf)),\n", + " (\"중복 장비 번호\", pd.concat([good, good.head(5)])), (\"필수 열 누락\", good.drop(columns=[\"unit\"])), (\"빈 자료\", good.iloc[0:0]),\n", + " (\"표가 아닌 입력(None)\", None), (\"대용량 20만 행(결측 포함)\", big), (\"읽기 오류 2회 후 성공(재시도)\", good), (\"갱신 중 오류(롤백)\", good)]\n", + "print(\"\\n[예외 주입 시험]\")\n", + "crash = passed = 0\n", + "t0 = time.perf_counter()\n", + "for name, df in cases:\n", + " n0 = len(LOG)\n", + " try:\n", + " st, n = run(df, loader=flaky_loader if \"재시도\" in name else None, fail_update=\"롤백\" in name)\n", + " except Exception as e:\n", + " crash += 1; st, n = f\"CRASH {e}\", 0\n", + " logged = len(LOG) > n0\n", + " passed += (not st.startswith(\"CRASH\")) and logged\n", + " print(f\" {name:22s}-> {st:8s} {n:6d}행 남음 | 로그 {'기록' if logged else '없음'}\" + (f\" | {LOG[-1]['code']}: {LOG[-1]['guide'][:28]}\" if logged else \"\"))\n", + "dt = time.perf_counter() - t0\n", + "print(f\"예외 {len(cases)}종: 크래시 {crash}건(목표 0건: {'달성' if crash == 0 else '미달'}), 오류 로그 기록 {passed}/{len(cases)} ({passed / len(cases):.0%}, 목표 100%), 전체 {dt:.1f}초\")\n", + "print(f\"오류 로그 {len(LOG)}건 중 사용자 안내문 포함 {sum(bool(x['guide']) for x in LOG)}건, 상태 저장소 버전 {STORE.state['version']}(롤백 시 이전 값 유지), 평균 가동률 {STORE.state['avg']:.1f}%\")\n", + "print(\"로그 예시:\", LOG[0][\"code\"], LOG[0][\"msg\"], \"->\", LOG[0][\"guide\"])\n", + "print(\"※ 격리된 행의 수정과 경고 판단은 담당자가 확인한다. 로그는 메모리에 두며 실제 도구에서는 감사 로그 파일로 남긴다.\")\n", + "\n", + "fig, ax = plt.subplots(1, 2, figsize=(11, 4))\n", + "for u, g in trend.groupby(\"unit\"):\n", + " m = g.groupby(\"month\")[\"avail_pct\"].mean(); ax[0].plot(m.index, m.values, label='unit ' + u[2])\n", + "ax[0].set_title(\"Monthly availability by unit (synthetic)\"); ax[0].set_xlabel(\"month\"); ax[0].legend(ncol=3, fontsize=7)\n", + "loss.plot(kind=\"bar\", stacked=True, ax=ax[1], legend=False); ax[1].set_xticklabels([u[2] for u in loss.index], rotation=0); ax[1].set_title(\"Mean wait-loss days by cause\")\n", + "plt.tight_layout(); plt.show()\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 06 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + "[대시보드 1: 부대별 가동률 추세(가상)]\n", + " 부대 추세(1~12월) 상반기 하반기 최근3개월 월 기울기(%p) 경고\n", + "가상A부대 ▃▇▆▇▇▅▃▂▁▂▁▁ 77.3 74.8 74.6 -0.3 \n", + "가상B부대 ▃▅▆▅▇▄▂▂▁▁▂▁ 79.4 76.9 76.8 -0.3 \n", + "가상C부대 ▆▇▇▇▇▇▆▅▃▂▁▁ 89.4 81.9 79.1 -1.2 저하 경고\n", + "가상D부대 ▆▅▆▇▆▄▃▁▁▂▁▃ 80.2 77.6 77.7 -0.3 \n", + "가상E부대 ▃▅▇▆▆▇▃▁▁▁▂▂ 79.2 76.1 76.1 -0.3 \n", + "가상F부대 ▄▆▇▇▅▆▄▄▂▁▂▃ 83.7 81.0 80.6 -0.3 \n", + "\n", + "[대시보드 2: 최근 3개월 부대 x 장비 유형 가동률]\n", + "etype 유형A 유형B 유형C\n", + "unit \n", + "가상A부대 72.3 75.1 76.5\n", + "가상B부대 73.3 78.2 78.8\n", + "가상C부대 76.3 78.8 82.2\n", + "가상D부대 74.8 77.8 80.4\n", + "가상E부대 73.6 76.1 78.7\n", + "가상F부대 78.5 79.9 83.3\n", + "\n", + "[대시보드 3: 부대별 평균 대기 손실일(저하 요인 구성)]\n", + " 부품 대기 정비병 부족 외주 정비 외주 검사 대기 최대 요인\n", + "unit \n", + "가상A부대 5.0 3.5 3.3 2.2 부품 대기\n", + "가상B부대 4.3 5.7 3.2 2.1 정비병 부족\n", + "가상C부대 2.1 2.0 4.0 1.3 외주 정비\n", + "가상D부대 2.5 5.1 5.3 3.5 외주 정비\n", + "가상E부대 5.5 4.2 2.6 1.2 부품 대기\n", + "가상F부대 2.7 2.4 4.9 1.7 외주 정비\n", + "[가동률 최하위 장비 3대]\n", + "equip_id unit etype avail_pct main_cause\n", + " E-0113 가상D부대 유형A 17.5 외주 정비\n", + " E-0165 가상D부대 유형A 26.3 검사·재작업\n", + " E-0283 가상A부대 유형A 30.2 외주 정비\n", + "\n", + "[예외 주입 시험]\n", + " 가동률 결측(NaN) -> 격리 후 처리 297행 남음 | 로그 기록 | E201: 형식·범위 오류 행을 격리했습니다. 격리 목록을 확\n", + " 가동률 문자열 '많음' -> 격리 후 처리 297행 남음 | 로그 기록 | E201: 형식·범위 오류 행을 격리했습니다. 격리 목록을 확\n", + " 가동률 음수·100 초과 -> 격리 후 처리 297행 남음 | 로그 기록 | E201: 형식·범위 오류 행을 격리했습니다. 격리 목록을 확\n", + " 장비 번호 특수문자 -> 격리 후 처리 297행 남음 | 로그 기록 | E201: 형식·범위 오류 행을 격리했습니다. 격리 목록을 확\n", + " 가상에 없는 부대명 -> 격리 후 처리 297행 남음 | 로그 기록 | E201: 형식·범위 오류 행을 격리했습니다. 격리 목록을 확\n", + "... (이하 생략)\n", + "```\n", + "
\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 07. 정비 자원 우선 투입 결정을 위한 군수참모처 HITL 정책 결정 모듈 설계\n", + "**소요시간**: 약 3.0시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "정비 자원 우선 투입 결정을 위한 군수참모처 HITL 정책 결정 모듈을 설계한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **정비 자원 우선 투입 안을 정비담당-정비관-품질검사관-군수참모 순서로 사람이 검토·결정하는 HITL 워크플로우를 설계하고 권한 통제와 이력 무결성을 검증한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **역할별 권한(RBAC)과 단계 순서를 정의하고 시스템은 제안만, 확정은 사람만 하도록 작성하여 원인을 분석하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `src/workflow/qlt04_approval_system.py` 형태의 결과물을 완성한다.\n", + "\n", + "### 🔍 평가 및 검증 기준 (PASS 조건)\n", + "#### 미션 판정\n", + "\n", + "**평가 구분:** 필수\n", + "\n", + "**핵심 평가영역:** 군 보안 규정 및 행정 절차 부합성\n", + "\n", + "#### 미션 PASS 조건\n", + "\n", + "- 정량 기준: 이력 기록 무결성 100%, 승인 권한 우회 시도 차단율 100%\n", + "\n", + "\n", + "- 진행 기준: 결재선 승인/반려 시뮬레이션 및 이력 조회 화면 구현\n", + "\n", + "\n", + "- 검증 방법: 권한별 접근 제어(RBAC) 테스트\n", + "\n", + "\n", + "#### 채점자 판정 메모\n", + "\n", + "> 단순 코드 실행 여부뿐만 아니라 출력된 수치와 근거 자료가 군 실무 요구사항에 부합하는지 면밀히 확인한다.\n", + "\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "\"\"\"저가동 장비의 정비 자원 우선 투입안을 정비담당 상신 -> 정비관 검토 -> 품질검사관 검토 -> 군수참모 결재의 HITL 워크플로우로 처리하되, 시스템 자동 확정 금지·반려 사유 필수화·재기안 연계·권한 우회 차단(RBAC)과 해시 체인 감사 로그의 이력 무결성·위변조 탐지를 검증한다.\"\"\"\n", + "import hashlib\n", + "import json\n", + "import numpy as np\n", + "import pandas as pd\n", + "import matplotlib.pyplot as plt\n", + "\n", + "# ---------------------------------------------------------------------------\n", + "# [교육용가상자료] 가상 장비 가동률·정비 대기 자료 생성기 (실제 부대·장비·제원·가동률 정보 없음)\n", + "# 장비 유형 A(궤도형)·B(화포형)·C(차륜형)은 추상 구분이며 실제 장비와 무관하다.\n", + "# 최근 90일 동안 장비 1대의 정비 대기 요인(부품·정비병·외주 정비·외주 검사·재작업)과 가동률을 만든다.\n", + "# ---------------------------------------------------------------------------\n", + "UNITS = [\"가상A부대\", \"가상B부대\", \"가상C부대\", \"가상D부대\", \"가상E부대\", \"가상F부대\"]\n", + "ETYPES = [\"유형A\", \"유형B\", \"유형C\"]\n", + "WINDOW = 90 # 관측 기간(일)\n", + "CAUSES = [\"부품 대기\", \"정비병 부족\", \"외주 정비\", \"검사·재작업\"]\n", + "CAUSE_COL = {\"부품 대기\": \"parts_wait_days\", \"정비병 부족\": \"labor_wait_days\", \"외주 정비\": \"outsource_days\", \"검사·재작업\": \"inspect_wait_days\"}\n", + "NUM_FIELDS = [\"age_years\", \"op_hours\", \"maint_orders\", \"parts_wait_days\", \"labor_wait_days\", \"outsource_days\", \"inspect_wait_days\", \"rework_count\",\n", + " \"spec_mismatch\", \"defect_reports\", \"parts_stock_pct\", \"mechanic_fill_pct\", \"avail_pct\"]\n", + "FEAT = [\"age_years\", \"maint_orders\", \"parts_stock_pct\", \"mechanic_fill_pct\", \"outsource_days\", \"inspect_wait_days\", \"rework_count\", \"spec_mismatch\", \"defect_reports\", \"etype_B\", \"etype_C\"]\n", + "FEAT_KO = {\"age_years\": \"장비 연령\", \"maint_orders\": \"정비 요청 건수\", \"parts_stock_pct\": \"부품 재고 가용률\", \"mechanic_fill_pct\": \"정비병 충원율\", \"outsource_days\": \"외주 정비 일수\",\n", + " \"inspect_wait_days\": \"외주 검사 대기 일수\", \"rework_count\": \"재작업 건수\", \"spec_mismatch\": \"규격·제원 불일치\", \"defect_reports\": \"하자·불만 접수\",\n", + " \"etype_B\": \"유형B 여부\", \"etype_C\": \"유형C 여부\"}\n", + "\n", + "\n", + "def unit_profile(seed=3):\n", + " \"\"\"부대별 고정 특성: 부품 재고 수준, 정비병 충원율, 외주 정비소 거리(일수 가중), 외주 검사 혼잡도.\"\"\"\n", + " # [TODO : 학생 실습 7] 정비 자원 우선 투입 결정을 위한 군수참모처 HITL 정책 결정 모듈 설계 관련 핵심 로직을 구현하세요.\n", + " # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n", + " # <<< 여기에 코드를 작성하세요 >>>\n", + " pass # 작성 후 pass를 제거하세요.\n", + "def make_fleet(n=300, seed=7, shock=0.0):\n", + " \"\"\"장비 n대의 90일 관측 자료(필드 16개)와 정답 요인을 만든다. shock은 외주 검사 병목 악화 정도(AAR용 환경 변화).\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " pf = unit_profile()\n", + " ui = rng.integers(0, len(UNITS), n)\n", + " et = rng.choice(ETYPES, n, p=[.35, .3, .35])\n", + " age = np.clip(rng.gamma(4, 2.2, n), 0.5, 30).round(1)\n", + " eff = np.array([{\"유형A\": 1.15, \"유형B\": 1.0, \"유형C\": 0.9}[t] for t in et])\n", + " orders = rng.poisson(np.clip(1.2 + 0.12 * age, 0.5, 6) * eff)\n", + " stock = np.clip(pf[\"stock\"].to_numpy()[ui] + rng.normal(0, 8, n), 20, 100).round(0)\n", + " fill = np.clip(pf[\"fill\"].to_numpy()[ui] + rng.normal(0, 6, n), 30, 100).round(0)\n", + " parts_w = (orders * rng.gamma(2, 0.55 * (1 + ((100 - stock) / 28) ** 2) / 2)).round(0)\n", + " labor_w = (orders * rng.gamma(2, 0.7 * (1 + ((100 - fill) / 24) ** 2) / 2)).round(0)\n", + " n_out = rng.binomial(orders, 0.3)\n", + " out_d = (n_out * rng.gamma(3, 4.5 * pf[\"dist\"].to_numpy()[ui] / 3)).round(0)\n", + " insp_w = (n_out * rng.gamma(2, (2.6 + shock) * pf[\"queue\"].to_numpy()[ui] / 2)).round(0)\n", + " spec = rng.poisson(0.2 + 0.04 * age)\n", + " rework = rng.binomial(n_out, np.clip(0.08 + 0.1 * spec, 0, .6))\n", + " defect = rng.poisson(0.3 + 0.7 * rework)\n", + " base_rep = orders * 1.8\n", + " down = base_rep + parts_w + labor_w + out_d + insp_w + rework * 4 + rng.normal(0, 1.2, n)\n", + " comp = np.column_stack([parts_w, labor_w, out_d, insp_w + rework * 4])\n", + " df = pd.DataFrame({\"equip_id\": [f\"E-{i + 1:04d}\" for i in range(n)], \"unit\": np.array(UNITS)[ui], \"etype\": et, \"age_years\": age,\n", + " \"op_hours\": np.clip(rng.normal(320 - 4 * age, 70, n), 40, 600).round(0), \"maint_orders\": orders,\n", + " \"parts_wait_days\": parts_w, \"labor_wait_days\": labor_w, \"outsource_days\": out_d, \"inspect_wait_days\": insp_w,\n", + " \"rework_count\": rework, \"spec_mismatch\": spec, \"defect_reports\": defect, \"parts_stock_pct\": stock, \"mechanic_fill_pct\": fill,\n", + " \"avail_pct\": (100 * (1 - np.clip(down, 0, WINDOW) / WINDOW)).round(1)})\n", + " df[\"main_cause\"] = np.where(comp.max(axis=1) < 3, \"없음\", np.array(CAUSES)[comp.argmax(axis=1)])\n", + " return df\n", + "\n", + "\n", + "def feat_frame(df):\n", + " \"\"\"회귀·분석용 설명 변수 표(유형은 더미 변수).\"\"\"\n", + " # [TODO : 학생 실습 7] 정비 자원 우선 투입 결정을 위한 군수참모처 HITL 정책 결정 모듈 설계 관련 핵심 로직을 구현하세요.\n", + " # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n", + " # <<< 여기에 코드를 작성하세요 >>>\n", + " pass # 작성 후 pass를 제거하세요.\n", + "def make_trend(months=12, seed=5, shock_unit=None):\n", + " \"\"\"[교육용가상자료] 부대 x 유형 x 월 평균 가동률 추세. shock_unit 부대는 하반기에 외주 검사 병목으로 가동률이 떨어진다.\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " pf = unit_profile()\n", + " rows = []\n", + " for _, u in pf.iterrows():\n", + " base = 62 + 0.22 * u[\"stock\"] + 0.12 * u[\"fill\"] - 3.0 * u[\"queue\"]\n", + " for t in ETYPES:\n", + " off = {\"유형A\": -3.0, \"유형B\": 0.0, \"유형C\": 2.0}[t]\n", + " for m in range(months):\n", + " v = base + off + 2.0 * np.sin(m / 12 * 2 * np.pi) + rng.normal(0, 1.0)\n", + " if shock_unit == u[\"unit\"] and m >= months // 2:\n", + " v -= 1.5 * (m - months // 2 + 1)\n", + " rows.append((u[\"unit\"], t, m + 1, round(float(np.clip(v, 30, 99)), 1)))\n", + " return pd.DataFrame(rows, columns=[\"unit\", \"etype\", \"month\", \"avail_pct\"])\n", + "\n", + "# ---- 정비·검사 병목 판별 룰 설정(YAML 형식 문자열: 기준이 바뀌면 코드가 아니라 이 설정만 고친다) ----\n", + "# clause는 룰과 대응시킨 '가상 품질지침 조항' 예시이며 실제 규정 원문이 아니다.\n", + "RULES_YAML = \"\"\"\n", + "- {id: Q01, name: 부품 재고 가용률 50% 미만, field: parts_stock_pct, op: \"<\", value: 50, weight: 2, clause: 가상품질지침 제3조}\n", + "- {id: Q02, name: 정비병 충원율 60% 미만, field: mechanic_fill_pct, op: \"<\", value: 60, weight: 2, clause: 가상품질지침 제4조}\n", + "- {id: Q03, name: 부품 대기 8일 초과, field: parts_wait_days, op: \">\", value: 8, weight: 2, clause: 가상품질지침 제5조}\n", + "- {id: Q04, name: 정비병 부족 대기 8일 초과, field: labor_wait_days, op: \">\", value: 8, weight: 2, clause: 가상품질지침 제6조}\n", + "- {id: Q05, name: 외주 정비 15일 초과, field: outsource_days, op: \">\", value: 15, weight: 2, clause: 가상품질지침 제7조}\n", + "- {id: Q06, name: 외주 검사 대기 6일 초과, field: inspect_wait_days, op: \">\", value: 6, weight: 3, clause: 가상품질지침 제8조}\n", + "- {id: Q07, name: 재작업 1건 이상, field: rework_count, op: \">=\", value: 1, weight: 3, clause: 가상품질지침 제9조}\n", + "- {id: Q08, name: 재작업률 25% 초과, field: rework_rate, op: \">\", value: 0.25, weight: 2, clause: 가상품질지침 제9조}\n", + "- {id: Q09, name: 규격·제원 불일치 3건 이상, field: spec_mismatch, op: \">=\", value: 3, weight: 3, clause: 가상품질지침 제10조}\n", + "- {id: Q10, name: 하자·불만 3건 이상, field: defect_reports, op: \">=\", value: 3, weight: 2, clause: 가상품질지침 제11조}\n", + "- {id: Q11, name: 장비 연령 15년 초과, field: age_years, op: \">\", value: 15, weight: 1, clause: 가상품질지침 제12조}\n", + "- {id: Q12, name: 정비 요청 6건 이상, field: maint_orders, op: \">=\", value: 6, weight: 1, clause: 가상품질지침 제13조}\n", + "- {id: Q13, name: 대기 일수 합계 30일 초과, field: total_wait, op: \">\", value: 30, weight: 3, clause: 가상품질지침 제14조}\n", + "- {id: Q14, name: 검사 대기가 외주 정비 일수의 80% 초과, field: insp_ratio, op: \">\", value: 0.8, weight: 2, clause: 가상품질지침 제8조}\n", + "- {id: Q15, name: 규격 불일치 2건 이상과 하자 접수 동반, field: spec_defect, op: \"==\", value: true, weight: 3, clause: 가상품질지침 제10조}\n", + "- {id: Q16, name: 최근 90일 가동 시간 150시간 미만, field: op_hours, op: \"<\", value: 150, weight: 1, clause: 가상품질지침 제15조}\n", + "\"\"\"\n", + "GRADE_CUT = [(10, \"고위험\"), (6, \"주의\"), (3, \"관심\")]\n", + "OPS = {\"<\": lambda a, b: a < b, \">\": lambda a, b: a > b, \"<=\": lambda a, b: a <= b, \">=\": lambda a, b: a >= b, \"==\": lambda a, b: a == b}\n", + "\n", + "\n", + "def parse_rules(text):\n", + " \"\"\"YAML 한 줄 맵({key: value, ...}) 형식 룰 문자열을 읽는다(외부 yaml 라이브러리 없이).\"\"\"\n", + " out = []\n", + " for line in text.strip().splitlines():\n", + " body = line.strip()[3:-1]\n", + " r = {}\n", + " for part in body.split(\", \"):\n", + " k, v = part.split(\": \", 1)\n", + " v = v.strip().strip('\"')\n", + " r[k] = True if v == \"true\" else False if v == \"false\" else (float(v) if v.replace(\".\", \"\", 1).isdigit() else v)\n", + " out.append(r)\n", + " return out\n", + "\n", + "\n", + "def derive_rec(rec):\n", + " \"\"\"룰이 쓰는 파생 필드(대기 합계, 재작업률, 검사 대기 비율(외주 5일 이상일 때), 규격 불일치 2건 이상+하자 동반)를 추가한다.\"\"\"\n", + " r = dict(rec)\n", + " r[\"total_wait\"] = r[\"parts_wait_days\"] + r[\"labor_wait_days\"] + r[\"outsource_days\"] + r[\"inspect_wait_days\"]\n", + " r[\"rework_rate\"] = r[\"rework_count\"] / r[\"maint_orders\"] if r[\"maint_orders\"] else 0.0\n", + " r[\"insp_ratio\"] = r[\"inspect_wait_days\"] / r[\"outsource_days\"] if r[\"outsource_days\"] >= 5 else 0.0\n", + " r[\"spec_defect\"] = bool(r[\"spec_mismatch\"] >= 2 and r[\"defect_reports\"] >= 1)\n", + " return r\n", + "\n", + "\n", + "def apply_rules(rec, rules, derived=False):\n", + " \"\"\"룰 적용: (점수, 적중 룰 id 목록). derived=True면 이미 파생 필드가 있는 레코드(경계 시험용)로 본다.\"\"\"\n", + " r = rec if derived else derive_rec(rec)\n", + " hits = [x[\"id\"] for x in rules if OPS[x[\"op\"]](r[x[\"field\"]], x[\"value\"])]\n", + " w = {x[\"id\"]: x[\"weight\"] for x in rules}\n", + " return sum(w[h] for h in hits), hits\n", + "\n", + "\n", + "def grade_of(score):\n", + " for cut, g in GRADE_CUT:\n", + " if score >= cut:\n", + " return g\n", + " return \"정상\"\n", + "\n", + "# ---- 가동률 회귀 모델과 저가동 요인 분류·추천 모델(외부 AI 서비스 없이 scikit-learn 사용) ----\n", + "from sklearn.linear_model import Ridge, LogisticRegression\n", + "from sklearn.preprocessing import StandardScaler\n", + "from sklearn.pipeline import make_pipeline\n", + "\n", + "CAUSE_COLS = [\"age_years\", \"maint_orders\", \"parts_stock_pct\", \"mechanic_fill_pct\", \"outsource_days\", \"inspect_wait_days\", \"rework_count\", \"spec_mismatch\",\n", + " \"defect_reports\", \"parts_wait_days\", \"labor_wait_days\"]\n", + "ACTION = {\"부품 대기\": \"부품 사전 청구·재고 보강\", \"정비병 부족\": \"정비병 재배치·교육 일정 조정\", \"외주 정비\": \"외주 정비 일정 조율·내부 정비 확대\", \"검사·재작업\": \"검사 일정 조율·규격 사전 대조\"}\n", + "\n", + "\n", + "def cause_frame(df):\n", + " x = df[CAUSE_COLS].copy()\n", + " x[\"out_insp\"] = x[\"outsource_days\"] + x[\"inspect_wait_days\"] + 4 * x[\"rework_count\"]\n", + " x[\"wait_pm\"] = (x[\"parts_wait_days\"] + x[\"labor_wait_days\"]) / (x[\"maint_orders\"] + 1)\n", + " return x\n", + "\n", + "\n", + "def fit_avail(df, alpha=1.0):\n", + " \"\"\"가동률(%) 회귀: 표준화 + 릿지 회귀.\"\"\"\n", + " return make_pipeline(StandardScaler(), Ridge(alpha=alpha)).fit(feat_frame(df), df[\"avail_pct\"])\n", + "\n", + "\n", + "def fit_cause(df, cut=85, C=5):\n", + " \"\"\"가동률이 cut% 미만인 장비의 주요 저가동 요인(4종)을 분류한다.\"\"\"\n", + " low = df[(df[\"avail_pct\"] < cut) & (df[\"main_cause\"] != \"없음\")]\n", + " return make_pipeline(StandardScaler(), LogisticRegression(C=C, max_iter=3000)).fit(cause_frame(low), low[\"main_cause\"])\n", + "\n", + "\n", + "SIM_FEAT = [\"age_years\", \"maint_orders\", \"spec_mismatch\", \"stock_gap_x_orders\", \"fill_gap_x_orders\", \"parts_stock_pct\", \"mechanic_fill_pct\", \"etype_B\", \"etype_C\"]\n", + "\n", + "\n", + "def sim_frame(df):\n", + " \"\"\"자원 재배치 시뮬레이션용 설명 변수: 부족분이 클수록 대기가 더 빠르게 늘고 정비 요청 건수와 곱해져 커진다는 구조를 제곱·상호작용 항으로 둔다.\"\"\"\n", + " x = df.copy()\n", + " x[\"etype_B\"], x[\"etype_C\"] = (x[\"etype\"] == \"유형B\").astype(int), (x[\"etype\"] == \"유형C\").astype(int)\n", + " x[\"stock_gap_x_orders\"] = x[\"maint_orders\"] * ((100 - x[\"parts_stock_pct\"]) / 28) ** 2\n", + " x[\"fill_gap_x_orders\"] = x[\"maint_orders\"] * ((100 - x[\"mechanic_fill_pct\"]) / 24) ** 2\n", + " return x[SIM_FEAT]\n", + "\n", + "\n", + "def fit_sim(df, alpha=0.5):\n", + " return make_pipeline(StandardScaler(), Ridge(alpha=alpha)).fit(sim_frame(df), df[\"avail_pct\"])\n", + "\n", + "# ---- 역할과 권한(최소 권한 원칙): 시스템은 '제안'만 하고 확정 권한이 없다 ----\n", + "PERM = {\"시스템\": {\"propose\"}, \"정비담당\": {\"submit\", \"redraft\", \"view\"}, \"정비관\": {\"review_maint\", \"reject\", \"view\"}, \"품질검사관\": {\"review_quality\", \"reject\", \"view\"},\n", + " \"군수참모\": {\"approve\", \"reject\", \"view\"}, \"감사관\": {\"view\", \"audit\"}, \"부서장\": {\"submit\", \"approve\", \"reject\", \"view\"}} # 부서장은 상신·결재를 겸할 수 있으나 같은 건은 분리\n", + "NEXT = {\"제안\": (\"submit\", \"상신\"), \"상신\": (\"review_maint\", \"정비관검토\"), \"정비관검토\": (\"review_quality\", \"품질검사\"), \"품질검사\": (\"approve\", \"승인\")}\n", + "USERS = {\"sys\": \"시스템\", \"kim\": \"정비담당\", \"lee\": \"정비관\", \"park\": \"품질검사관\", \"choi\": \"군수참모\", \"jung\": \"감사관\", \"oh\": \"부서장\"}\n", + "MAX_REDRAFT = 2\n", + "\n", + "\n", + "class Chain:\n", + " \"\"\"해시 체인 감사 로그: 각 기록이 직전 기록의 해시를 포함해 중간 수정이 드러난다.\"\"\"\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", + "class Workflow:\n", + " def __init__(self):\n", + " self.audit, self.cases, self.blocked = Chain(), {}, []\n", + "\n", + " def _deny(self, who, case, action, why):\n", + " self.audit.add(event=\"DENIED\", who=who, case=case, action=action, reason=why)\n", + " self.blocked.append((who, case, action, why)); return False, why\n", + "\n", + " def act(self, who, case, action, reason=\"\", evidence=\"\"):\n", + " role = USERS.get(who)\n", + " if role is None:\n", + " return self._deny(who, case, action, \"미등록 사용자\")\n", + " if action == \"delete_log\":\n", + " return self._deny(who, case, action, \"감사 로그 삭제·수정 권한은 누구에게도 없음\")\n", + " if action not in PERM[role]:\n", + " return self._deny(who, case, action, f\"권한 없음({role} 역할에 {action} 권한 없음)\")\n", + " if action == \"propose\":\n", + " self.cases[case] = {\"state\": \"제안\", \"submitter\": None, \"version\": 1}\n", + " self.audit.add(event=\"PROPOSE\", who=who, case=case, reason=reason); return True, \"제안 등록\"\n", + " c = self.cases.get(case)\n", + " if c is None:\n", + " return self._deny(who, case, action, \"존재하지 않는 건\")\n", + " if c[\"state\"] in (\"승인\", \"종결(반려)\"):\n", + " return self._deny(who, case, action, f\"이미 종결된 건({c['state']})\")\n", + " if not reason.strip() or not evidence.strip():\n", + " return self._deny(who, case, action, \"사유·근거 입력 필수(반려 사유 포함)\")\n", + " if action == \"reject\":\n", + " if c[\"state\"] in (\"제안\", \"반려\"):\n", + " return self._deny(who, case, action, f\"반려할 수 없는 단계({c['state']})\")\n", + " c[\"state\"] = \"종결(반려)\" if c[\"version\"] > MAX_REDRAFT else \"반려\"\n", + " self.audit.add(event=\"REJECTED\", who=who, role=role, case=case, version=c[\"version\"], reason=reason, evidence=evidence); return True, c[\"state\"]\n", + " if action == \"redraft\":\n", + " if c[\"state\"] != \"반려\":\n", + " return self._deny(who, case, action, f\"재기안은 반려된 건만 가능(현재 {c['state']})\")\n", + " if who != c[\"submitter\"]:\n", + " return self._deny(who, case, action, \"재기안은 최초 상신자만 가능\")\n", + " c[\"state\"], c[\"version\"] = \"상신\", c[\"version\"] + 1\n", + " self.audit.add(event=\"REDRAFT\", who=who, role=role, case=case, version=c[\"version\"], reason=reason, evidence=evidence); return True, \"재기안 상신\"\n", + " if c[\"state\"] == \"반려\":\n", + " return self._deny(who, case, action, \"반려된 건은 재기안 후에만 진행 가능\")\n", + " need, nxt = NEXT[c[\"state\"]]\n", + " if action != need:\n", + " return self._deny(who, case, action, f\"단계 위반(현재 {c['state']}, 필요 행위 {need})\")\n", + " if action == \"submit\":\n", + " c[\"submitter\"] = who\n", + " if action == \"approve\" and who == c[\"submitter\"]:\n", + " return self._deny(who, case, action, \"상신자와 승인자 분리 원칙 위반\")\n", + " c[\"state\"] = nxt\n", + " self.audit.add(event=action.upper(), who=who, role=role, case=case, version=c[\"version\"], to=nxt, reason=reason, evidence=evidence); return True, nxt\n", + "\n", + "\n", + "# ---- 상신 대상: 룰 점수가 높은 장비 12대와 모델이 추천한 개선 조치 ----\n", + "fleet = make_fleet(300, seed=7)\n", + "rules = parse_rules(RULES_YAML)\n", + "sc = [apply_rules(r, rules) for r in fleet.drop(columns=[\"main_cause\"]).to_dict(\"records\")]\n", + "fleet[\"score\"], fleet[\"hits\"] = [x[0] for x in sc], [x[1] for x in sc]\n", + "clf = fit_cause(make_fleet(1200, seed=21))\n", + "picked = fleet.sort_values(\"score\", ascending=False).head(12)\n", + "rec_cause = clf.predict(cause_frame(picked))\n", + "rng = np.random.default_rng(1)\n", + "QUALITY = {\"Q06\", \"Q07\", \"Q08\", \"Q09\", \"Q10\", \"Q14\", \"Q15\"} # 품질검사관이 보는 항목(검사 대기·재작업·규격 불일치·하자)\n", + "\n", + "wf = Workflow()\n", + "rows = []\n", + "for k, ((_, p), cause) in enumerate(zip(picked.iterrows(), rec_cause), 1):\n", + " case = f\"C{k:02d}\"\n", + " wf.act(\"sys\", case, \"propose\", reason=f\"{p['equip_id']} 룰 점수 {p['score']:.0f}, 추천 요인 {cause} -> {ACTION[cause]}\")\n", + " wf.act(\"kim\", case, \"submit\", \"정비 자원 우선 투입 검토 요청\", f\"룰 적중 {','.join(p['hits'][:4])}, 추천 조치 {ACTION[cause]}\")\n", + " q_issue = sorted(QUALITY & set(p[\"hits\"])) if len(QUALITY & set(p[\"hits\"])) >= 2 else [] # 품질검사관이 볼 문제\n", + " m_issue = (p[\"avail_pct\"] < 40 and cause == \"정비병 부족\") # 정비관이 볼 문제: 인력 이동 시 제공 부대 영향 소명 필요\n", + " note = \"군수참모 결정\"\n", + " for _ in range(14):\n", + " state = wf.cases[case][\"state\"]\n", + " if state in (\"승인\", \"종결(반려)\"):\n", + " break\n", + " if state == \"상신\":\n", + " if m_issue:\n", + " wf.act(\"lee\", case, \"reject\", \"인력 이동 안은 제공 부대 영향 소명 필요\", f\"가동률 {p['avail_pct']}%, 요인 {cause}\")\n", + " else:\n", + " wf.act(\"lee\", case, \"review_maint\", \"정비 능력·일정 검토 완료\", f\"추천 조치 {ACTION[cause]} 수행 가능\")\n", + " elif state == \"정비관검토\":\n", + " if rng.random() < 0.15:\n", + " note = \"자동 확정 없음: 품질검사 응답 대기\"; break # 무응답은 보류로 둔다\n", + " if q_issue:\n", + " wf.act(\"park\", case, \"reject\", f\"규격·검사 조건 보완 필요({','.join(q_issue)})\", \"재작업·규격 불일치·검사 대기 항목 점검\")\n", + " else:\n", + " wf.act(\"park\", case, \"review_quality\", \"품질검사 검토 완료\", \"규격·검사 항목 이상 없음 또는 보완 확인\")\n", + " elif state == \"품질검사\":\n", + " wf.act(\"choi\", case, \"approve\", \"승인: 자원 우선 투입 진행\", f\"룰 점수 {p['score']:.0f}, 검토 단계 완료\")\n", + " elif state == \"반려\":\n", + " wf.act(\"kim\", case, \"redraft\", \"반려 사유 반영해 수정\", f\"보완 대상 {q_issue or ['제공 부대 영향 소명']}, 재기안 {wf.cases[case]['version']}차\")\n", + " m_issue = False # 제공 부대 영향 소명서를 첨부(모의)\n", + " if rng.random() < 0.65: # 재기안 후 보완이 끝날 확률(모의)\n", + " q_issue = []\n", + " final = wf.cases[case][\"state\"]\n", + " if final not in (\"승인\", \"종결(반려)\") and note.startswith(\"자동\"):\n", + " final = \"보류(무응답)\"\n", + " elif final == \"종결(반려)\":\n", + " note = \"재기안 한도(2회) 초과\"\n", + " rows.append((case, p[\"equip_id\"], final, wf.cases[case][\"version\"], note))\n", + "print(\"[결재 결과] 12건 (모의 결재자 기준, 실제 결재는 사람이 한다)\")\n", + "print(pd.DataFrame(rows, columns=[\"건\", \"장비\", \"최종 상태\", \"버전\", \"비고\"]).to_string(index=False))\n", + "st = pd.Series([r[2] for r in rows]).value_counts().to_dict()\n", + "print(\"상태 집계:\", st, f\"/ 재기안 {sum(e['event'] == 'REDRAFT' for e in wf.audit.log)}회, 반려 이력 {sum(e['event'] == 'REJECTED' for e in wf.audit.log)}건(모두 사유·근거 기록)\")\n", + "\n", + "# ---- 권한 우회·절차 위반 시도 ----\n", + "wf.act(\"sys\", \"T01\", \"propose\", reason=\"시험 건\"); wf.act(\"kim\", \"T01\", \"submit\", \"시험 상신\", \"근거\")\n", + "attempts = [\n", + " (\"시스템 자동 승인\", lambda: wf.act(\"sys\", \"T01\", \"approve\", \"자동\", \"자동\")),\n", + " (\"정비담당이 정비관 검토 대행\", lambda: wf.act(\"kim\", \"T01\", \"review_maint\", \"대행\", \"근거\")),\n", + " (\"검토 단계 건너뛰고 승인\", lambda: wf.act(\"choi\", \"T01\", \"approve\", \"바로 승인\", \"근거\")),\n", + " (\"미등록 사용자 접근\", lambda: wf.act(\"guest\", \"T01\", \"review_maint\", \"열람\", \"근거\")),\n", + " (\"사유 없는 검토\", lambda: wf.act(\"lee\", \"T01\", \"review_maint\", \"\", \"\")),\n", + " (\"사유 없는 반려\", lambda: wf.act(\"lee\", \"T01\", \"reject\", \"\", \"근거\")),\n", + " (\"감사 로그 삭제\", lambda: wf.act(\"kim\", \"T01\", \"delete_log\", \"정리\", \"근거\")),\n", + " (\"감사관이 승인 시도\", lambda: wf.act(\"jung\", \"T01\", \"approve\", \"승인\", \"근거\")),\n", + " (\"종결된 건 재승인\", lambda: wf.act(\"choi\", next(c for c, v in wf.cases.items() if v[\"state\"] == \"승인\"), \"approve\", \"재승인\", \"근거\")),\n", + " (\"존재하지 않는 건 승인\", lambda: wf.act(\"choi\", \"X99\", \"approve\", \"승인\", \"근거\")),\n", + "]\n", + "wf.act(\"sys\", \"T02\", \"propose\", reason=\"시험\"); wf.act(\"oh\", \"T02\", \"submit\", \"상신\", \"근거\")\n", + "wf.act(\"lee\", \"T02\", \"review_maint\", \"정비 검토\", \"이상 없음\"); wf.act(\"park\", \"T02\", \"review_quality\", \"품질 검토\", \"이상 없음\")\n", + "attempts.append((\"상신자가 본인 건 승인(분리 원칙)\", lambda: wf.act(\"oh\", \"T02\", \"approve\", \"본인 승인\", \"근거\")))\n", + "wf.act(\"sys\", \"T03\", \"propose\", reason=\"시험\"); wf.act(\"kim\", \"T03\", \"submit\", \"상신\", \"근거\"); wf.act(\"lee\", \"T03\", \"reject\", \"수정 필요\", \"근거\")\n", + "attempts.append((\"반려 건을 재기안 없이 검토 진행\", lambda: wf.act(\"lee\", \"T03\", \"review_maint\", \"그대로 진행\", \"근거\")))\n", + "attempts.append((\"타인이 대신 재기안\", lambda: wf.act(\"oh\", \"T03\", \"redraft\", \"대신 수정\", \"근거\")))\n", + "print(\"\\n[권한 우회·절차 위반 시도]\")\n", + "blocked = 0\n", + "for name, fn in attempts:\n", + " ok, why = fn(); blocked += (not ok)\n", + " print(f\" {name:22s}-> {'차단' if not ok else '통과(결함)'}: {why}\")\n", + "print(f\"우회·위반 시도 차단 {blocked}/{len(attempts)} ({blocked / len(attempts):.0%}) (목표 100%: {'달성' if blocked == len(attempts) else '미달'})\")\n", + "\n", + "# ---- RBAC 매트릭스와 이력 무결성 ----\n", + "acts = [\"propose\", \"submit\", \"review_maint\", \"review_quality\", \"approve\", \"reject\", \"redraft\", \"audit\"]\n", + "mx = pd.DataFrame([[(\"O\" if a in PERM[r] else \"-\") for a in acts] for r in PERM], index=list(PERM), columns=[\"제안\", \"상신\", \"정비검토\", \"품질검토\", \"승인\", \"반려\", \"재기안\", \"감사\"])\n", + "print(\"\\n[역할별 권한 매트릭스(O 허용 / - 불가)]\"); print(mx.to_string())\n", + "bad = wf.audit.verify()\n", + "print(f\"\\n감사 로그 {len(wf.audit.log)}건, 해시 체인 검증: {'정상' if bad is None else f'seq {bad} 불일치'} -> 이력 무결성 {'100%' if bad is None else '훼손'}(목표 100%: {'달성' if bad is None else '미달'})\")\n", + "idx = 10; saved = json.dumps(wf.audit.log[idx], ensure_ascii=False)\n", + "wf.audit.log[idx][\"reason\"] = \"사후 수정\"; v = wf.audit.verify()\n", + "print(f\"{idx}번째 기록 임의 수정 후 검증: {'위변조 탐지(seq ' + str(v) + ')' if v is not None else '탐지 실패'}\")\n", + "wf.audit.log[idx] = json.loads(saved)\n", + "print(\"\\n[이력 조회 화면] C02 건\")\n", + "for e in wf.audit.log:\n", + " if e.get(\"case\") == \"C02\":\n", + " print(f\" #{e['seq']:03d} {e['event']:14s} {e.get('role', e['who']):6s} v{e.get('version', 1)} | {e.get('reason', '')[:34]}\")\n", + "print(\"※ 정비 우선순위와 자원 투입 결정은 사람(정비관·품질검사관·군수참모)이 하며, 시스템은 위험 표시와 추천만 제공한다. 결재선은 소속 기관 규정을 따른다.\")\n", + "\n", + "plt.figure(figsize=(6, 3.5)); EN = {\"승인\": \"approved\", \"반려\": \"rejected\", \"종결(반려)\": \"closed-rejected\", \"보류(무응답)\": \"on hold\"}\n", + "plt.bar([EN.get(k, k) for k in st], list(st.values()), color=\"tab:blue\"); plt.title(\"HITL workflow outcomes (12 cases)\"); plt.ylabel(\"cases\"); plt.tight_layout(); plt.show()\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 07 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + "[결재 결과] 12건 (모의 결재자 기준, 실제 결재는 사람이 한다)\n", + " 건 장비 최종 상태 버전 비고\n", + "C01 E-0047 보류(무응답) 2 자동 확정 없음: 품질검사 응답 대기\n", + "C02 E-0231 승인 2 군수참모 결정\n", + "C03 E-0041 승인 2 군수참모 결정\n", + "C04 E-0050 보류(무응답) 1 자동 확정 없음: 품질검사 응답 대기\n", + "C05 E-0220 승인 2 군수참모 결정\n", + "C06 E-0230 승인 1 군수참모 결정\n", + "C07 E-0095 승인 1 군수참모 결정\n", + "C08 E-0109 승인 2 군수참모 결정\n", + "C09 E-0163 승인 2 군수참모 결정\n", + "C10 E-0076 승인 2 군수참모 결정\n", + "C11 E-0028 승인 3 군수참모 결정\n", + "C12 E-0233 승인 2 군수참모 결정\n", + "상태 집계: {'승인': 10, '보류(무응답)': 2} / 재기안 10회, 반려 이력 10건(모두 사유·근거 기록)\n", + "\n", + "[권한 우회·절차 위반 시도]\n", + " 시스템 자동 승인 -> 차단: 권한 없음(시스템 역할에 approve 권한 없음)\n", + " 정비담당이 정비관 검토 대행 -> 차단: 권한 없음(정비담당 역할에 review_maint 권한 없음)\n", + " 검토 단계 건너뛰고 승인 -> 차단: 단계 위반(현재 상신, 필요 행위 review_maint)\n", + " 미등록 사용자 접근 -> 차단: 미등록 사용자\n", + " 사유 없는 검토 -> 차단: 사유·근거 입력 필수(반려 사유 포함)\n", + " 사유 없는 반려 -> 차단: 사유·근거 입력 필수(반려 사유 포함)\n", + " 감사 로그 삭제 -> 차단: 감사 로그 삭제·수정 권한은 누구에게도 없음\n", + " 감사관이 승인 시도 -> 차단: 권한 없음(감사관 역할에 approve 권한 없음)\n", + " 종결된 건 재승인 -> 차단: 이미 종결된 건(승인)\n", + " 존재하지 않는 건 승인 -> 차단: 존재하지 않는 건\n", + " 상신자가 본인 건 승인(분리 원칙) -> 차단: 상신자와 승인자 분리 원칙 위반\n", + " 반려 건을 재기안 없이 검토 진행 -> 차단: 반려된 건은 재기안 후에만 진행 가능\n", + " 타인이 대신 재기안 -> 차단: 권한 없음(부서장 역할에 redraft 권한 없음)\n", + "우회·위반 시도 차단 13/13 (100%) (목표 100%: 달성)\n", + "\n", + "[역할별 권한 매트릭스(O 허용 / - 불가)]\n", + " 제안 상신 정비검토 품질검토 승인 반려 재기안 감사\n", + "시스템 O - - - - - - -\n", + "정비담당 - O - - - - O -\n", + "정비관 - - O - - O - -\n", + "품질검사관 - - - O - O - -\n", + "군수참모 - - - - O O - -\n", + "감사관 - - - - - - - O\n", + "... (이하 생략)\n", + "```\n", + "
\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 08. 전군 장비 가동률 데이터 일괄 분석 스트레스 테스트 및 성능 검증\n", + "**소요시간**: 약 3.5시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "전군 장비 가동률 데이터 일괄 분석 스트레스 테스트를 수행한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **12개월치 가상 장비 가동률 자료를 월 단위 배치로 일괄 분석하고, 오류·장애 주입과 자료량 500% 부하에서 성능과 안정성을 검증한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **월 배치를 적재-검증-분석-보고 4단계로 자동 실행하는 스케줄러 모사 작성하여 원인을 분석하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `src/pipeline/qlt04_batch_runner.py` 형태의 결과물을 완성한다.\n", + "\n", + "### 🔍 평가 및 검증 기준 (PASS 조건)\n", + "#### 미션 판정\n", + "\n", + "**평가 구분:** 필수\n", + "\n", + "**핵심 평가영역:** 수작업 대비 업무 소요 시간 80% 이상 절감 효과\n", + "\n", + "#### 미션 PASS 조건\n", + "\n", + "- 정량 기준: 전체 배치 파이프라인 실행 시간 5분 이내, 자동화 성공률 99% 이상\n", + "\n", + "\n", + "- 진행 기준: 스케줄러 연동 테스트 및 자동 실행 확인\n", + "\n", + "\n", + "- 검증 방법: 실제 월간 업무 데이터 1년치 일괄 배치 시뮬레이션\n", + "\n", + "\n", + "#### 채점자 판정 메모\n", + "\n", + "> 단순 코드 실행 여부뿐만 아니라 출력된 수치와 근거 자료가 군 실무 요구사항에 부합하는지 면밀히 확인한다.\n", + "\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "\"\"\"12개월치 가상 장비 가동률 자료를 월 단위 배치(적재 -> 검증·격리 -> 룰·모델 분석 -> 보고)로 자동 실행하는 스케줄러 모사를 만들고, 오류 레코드·일시 장애 주입과 자료량 100%·200%·500% 부하에서 실행 시간·성공률·자료 유실률·수작업 대비 절감을 측정한다.\"\"\"\n", + "import time\n", + "import numpy as np\n", + "import pandas as pd\n", + "import matplotlib.pyplot as plt\n", + "\n", + "# ---------------------------------------------------------------------------\n", + "# [교육용가상자료] 가상 장비 가동률·정비 대기 자료 생성기 (실제 부대·장비·제원·가동률 정보 없음)\n", + "# 장비 유형 A(궤도형)·B(화포형)·C(차륜형)은 추상 구분이며 실제 장비와 무관하다.\n", + "# 최근 90일 동안 장비 1대의 정비 대기 요인(부품·정비병·외주 정비·외주 검사·재작업)과 가동률을 만든다.\n", + "# ---------------------------------------------------------------------------\n", + "UNITS = [\"가상A부대\", \"가상B부대\", \"가상C부대\", \"가상D부대\", \"가상E부대\", \"가상F부대\"]\n", + "ETYPES = [\"유형A\", \"유형B\", \"유형C\"]\n", + "WINDOW = 90 # 관측 기간(일)\n", + "CAUSES = [\"부품 대기\", \"정비병 부족\", \"외주 정비\", \"검사·재작업\"]\n", + "CAUSE_COL = {\"부품 대기\": \"parts_wait_days\", \"정비병 부족\": \"labor_wait_days\", \"외주 정비\": \"outsource_days\", \"검사·재작업\": \"inspect_wait_days\"}\n", + "NUM_FIELDS = [\"age_years\", \"op_hours\", \"maint_orders\", \"parts_wait_days\", \"labor_wait_days\", \"outsource_days\", \"inspect_wait_days\", \"rework_count\",\n", + " \"spec_mismatch\", \"defect_reports\", \"parts_stock_pct\", \"mechanic_fill_pct\", \"avail_pct\"]\n", + "FEAT = [\"age_years\", \"maint_orders\", \"parts_stock_pct\", \"mechanic_fill_pct\", \"outsource_days\", \"inspect_wait_days\", \"rework_count\", \"spec_mismatch\", \"defect_reports\", \"etype_B\", \"etype_C\"]\n", + "FEAT_KO = {\"age_years\": \"장비 연령\", \"maint_orders\": \"정비 요청 건수\", \"parts_stock_pct\": \"부품 재고 가용률\", \"mechanic_fill_pct\": \"정비병 충원율\", \"outsource_days\": \"외주 정비 일수\",\n", + " \"inspect_wait_days\": \"외주 검사 대기 일수\", \"rework_count\": \"재작업 건수\", \"spec_mismatch\": \"규격·제원 불일치\", \"defect_reports\": \"하자·불만 접수\",\n", + " \"etype_B\": \"유형B 여부\", \"etype_C\": \"유형C 여부\"}\n", + "\n", + "\n", + "def unit_profile(seed=3):\n", + " \"\"\"부대별 고정 특성: 부품 재고 수준, 정비병 충원율, 외주 정비소 거리(일수 가중), 외주 검사 혼잡도.\"\"\"\n", + " # [TODO : 학생 실습 8] 전군 장비 가동률 데이터 일괄 분석 스트레스 테스트 및 성능 검증 관련 핵심 로직을 구현하세요.\n", + " # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n", + " # <<< 여기에 코드를 작성하세요 >>>\n", + " pass # 작성 후 pass를 제거하세요.\n", + "def make_fleet(n=300, seed=7, shock=0.0):\n", + " \"\"\"장비 n대의 90일 관측 자료(필드 16개)와 정답 요인을 만든다. shock은 외주 검사 병목 악화 정도(AAR용 환경 변화).\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " pf = unit_profile()\n", + " ui = rng.integers(0, len(UNITS), n)\n", + " et = rng.choice(ETYPES, n, p=[.35, .3, .35])\n", + " age = np.clip(rng.gamma(4, 2.2, n), 0.5, 30).round(1)\n", + " eff = np.array([{\"유형A\": 1.15, \"유형B\": 1.0, \"유형C\": 0.9}[t] for t in et])\n", + " orders = rng.poisson(np.clip(1.2 + 0.12 * age, 0.5, 6) * eff)\n", + " stock = np.clip(pf[\"stock\"].to_numpy()[ui] + rng.normal(0, 8, n), 20, 100).round(0)\n", + " fill = np.clip(pf[\"fill\"].to_numpy()[ui] + rng.normal(0, 6, n), 30, 100).round(0)\n", + " parts_w = (orders * rng.gamma(2, 0.55 * (1 + ((100 - stock) / 28) ** 2) / 2)).round(0)\n", + " labor_w = (orders * rng.gamma(2, 0.7 * (1 + ((100 - fill) / 24) ** 2) / 2)).round(0)\n", + " n_out = rng.binomial(orders, 0.3)\n", + " out_d = (n_out * rng.gamma(3, 4.5 * pf[\"dist\"].to_numpy()[ui] / 3)).round(0)\n", + " insp_w = (n_out * rng.gamma(2, (2.6 + shock) * pf[\"queue\"].to_numpy()[ui] / 2)).round(0)\n", + " spec = rng.poisson(0.2 + 0.04 * age)\n", + " rework = rng.binomial(n_out, np.clip(0.08 + 0.1 * spec, 0, .6))\n", + " defect = rng.poisson(0.3 + 0.7 * rework)\n", + " base_rep = orders * 1.8\n", + " down = base_rep + parts_w + labor_w + out_d + insp_w + rework * 4 + rng.normal(0, 1.2, n)\n", + " comp = np.column_stack([parts_w, labor_w, out_d, insp_w + rework * 4])\n", + " df = pd.DataFrame({\"equip_id\": [f\"E-{i + 1:04d}\" for i in range(n)], \"unit\": np.array(UNITS)[ui], \"etype\": et, \"age_years\": age,\n", + " \"op_hours\": np.clip(rng.normal(320 - 4 * age, 70, n), 40, 600).round(0), \"maint_orders\": orders,\n", + " \"parts_wait_days\": parts_w, \"labor_wait_days\": labor_w, \"outsource_days\": out_d, \"inspect_wait_days\": insp_w,\n", + " \"rework_count\": rework, \"spec_mismatch\": spec, \"defect_reports\": defect, \"parts_stock_pct\": stock, \"mechanic_fill_pct\": fill,\n", + " \"avail_pct\": (100 * (1 - np.clip(down, 0, WINDOW) / WINDOW)).round(1)})\n", + " df[\"main_cause\"] = np.where(comp.max(axis=1) < 3, \"없음\", np.array(CAUSES)[comp.argmax(axis=1)])\n", + " return df\n", + "\n", + "\n", + "def feat_frame(df):\n", + " \"\"\"회귀·분석용 설명 변수 표(유형은 더미 변수).\"\"\"\n", + " # [TODO : 학생 실습 8] 전군 장비 가동률 데이터 일괄 분석 스트레스 테스트 및 성능 검증 관련 핵심 로직을 구현하세요.\n", + " # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n", + " # <<< 여기에 코드를 작성하세요 >>>\n", + " pass # 작성 후 pass를 제거하세요.\n", + "def make_trend(months=12, seed=5, shock_unit=None):\n", + " \"\"\"[교육용가상자료] 부대 x 유형 x 월 평균 가동률 추세. shock_unit 부대는 하반기에 외주 검사 병목으로 가동률이 떨어진다.\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " pf = unit_profile()\n", + " rows = []\n", + " for _, u in pf.iterrows():\n", + " base = 62 + 0.22 * u[\"stock\"] + 0.12 * u[\"fill\"] - 3.0 * u[\"queue\"]\n", + " for t in ETYPES:\n", + " off = {\"유형A\": -3.0, \"유형B\": 0.0, \"유형C\": 2.0}[t]\n", + " for m in range(months):\n", + " v = base + off + 2.0 * np.sin(m / 12 * 2 * np.pi) + rng.normal(0, 1.0)\n", + " if shock_unit == u[\"unit\"] and m >= months // 2:\n", + " v -= 1.5 * (m - months // 2 + 1)\n", + " rows.append((u[\"unit\"], t, m + 1, round(float(np.clip(v, 30, 99)), 1)))\n", + " return pd.DataFrame(rows, columns=[\"unit\", \"etype\", \"month\", \"avail_pct\"])\n", + "\n", + "# ---- 정비·검사 병목 판별 룰 설정(YAML 형식 문자열: 기준이 바뀌면 코드가 아니라 이 설정만 고친다) ----\n", + "# clause는 룰과 대응시킨 '가상 품질지침 조항' 예시이며 실제 규정 원문이 아니다.\n", + "RULES_YAML = \"\"\"\n", + "- {id: Q01, name: 부품 재고 가용률 50% 미만, field: parts_stock_pct, op: \"<\", value: 50, weight: 2, clause: 가상품질지침 제3조}\n", + "- {id: Q02, name: 정비병 충원율 60% 미만, field: mechanic_fill_pct, op: \"<\", value: 60, weight: 2, clause: 가상품질지침 제4조}\n", + "- {id: Q03, name: 부품 대기 8일 초과, field: parts_wait_days, op: \">\", value: 8, weight: 2, clause: 가상품질지침 제5조}\n", + "- {id: Q04, name: 정비병 부족 대기 8일 초과, field: labor_wait_days, op: \">\", value: 8, weight: 2, clause: 가상품질지침 제6조}\n", + "- {id: Q05, name: 외주 정비 15일 초과, field: outsource_days, op: \">\", value: 15, weight: 2, clause: 가상품질지침 제7조}\n", + "- {id: Q06, name: 외주 검사 대기 6일 초과, field: inspect_wait_days, op: \">\", value: 6, weight: 3, clause: 가상품질지침 제8조}\n", + "- {id: Q07, name: 재작업 1건 이상, field: rework_count, op: \">=\", value: 1, weight: 3, clause: 가상품질지침 제9조}\n", + "- {id: Q08, name: 재작업률 25% 초과, field: rework_rate, op: \">\", value: 0.25, weight: 2, clause: 가상품질지침 제9조}\n", + "- {id: Q09, name: 규격·제원 불일치 3건 이상, field: spec_mismatch, op: \">=\", value: 3, weight: 3, clause: 가상품질지침 제10조}\n", + "- {id: Q10, name: 하자·불만 3건 이상, field: defect_reports, op: \">=\", value: 3, weight: 2, clause: 가상품질지침 제11조}\n", + "- {id: Q11, name: 장비 연령 15년 초과, field: age_years, op: \">\", value: 15, weight: 1, clause: 가상품질지침 제12조}\n", + "- {id: Q12, name: 정비 요청 6건 이상, field: maint_orders, op: \">=\", value: 6, weight: 1, clause: 가상품질지침 제13조}\n", + "- {id: Q13, name: 대기 일수 합계 30일 초과, field: total_wait, op: \">\", value: 30, weight: 3, clause: 가상품질지침 제14조}\n", + "- {id: Q14, name: 검사 대기가 외주 정비 일수의 80% 초과, field: insp_ratio, op: \">\", value: 0.8, weight: 2, clause: 가상품질지침 제8조}\n", + "- {id: Q15, name: 규격 불일치 2건 이상과 하자 접수 동반, field: spec_defect, op: \"==\", value: true, weight: 3, clause: 가상품질지침 제10조}\n", + "- {id: Q16, name: 최근 90일 가동 시간 150시간 미만, field: op_hours, op: \"<\", value: 150, weight: 1, clause: 가상품질지침 제15조}\n", + "\"\"\"\n", + "GRADE_CUT = [(10, \"고위험\"), (6, \"주의\"), (3, \"관심\")]\n", + "OPS = {\"<\": lambda a, b: a < b, \">\": lambda a, b: a > b, \"<=\": lambda a, b: a <= b, \">=\": lambda a, b: a >= b, \"==\": lambda a, b: a == b}\n", + "\n", + "\n", + "def parse_rules(text):\n", + " \"\"\"YAML 한 줄 맵({key: value, ...}) 형식 룰 문자열을 읽는다(외부 yaml 라이브러리 없이).\"\"\"\n", + " out = []\n", + " for line in text.strip().splitlines():\n", + " body = line.strip()[3:-1]\n", + " r = {}\n", + " for part in body.split(\", \"):\n", + " k, v = part.split(\": \", 1)\n", + " v = v.strip().strip('\"')\n", + " r[k] = True if v == \"true\" else False if v == \"false\" else (float(v) if v.replace(\".\", \"\", 1).isdigit() else v)\n", + " out.append(r)\n", + " return out\n", + "\n", + "\n", + "def derive_rec(rec):\n", + " \"\"\"룰이 쓰는 파생 필드(대기 합계, 재작업률, 검사 대기 비율(외주 5일 이상일 때), 규격 불일치 2건 이상+하자 동반)를 추가한다.\"\"\"\n", + " r = dict(rec)\n", + " r[\"total_wait\"] = r[\"parts_wait_days\"] + r[\"labor_wait_days\"] + r[\"outsource_days\"] + r[\"inspect_wait_days\"]\n", + " r[\"rework_rate\"] = r[\"rework_count\"] / r[\"maint_orders\"] if r[\"maint_orders\"] else 0.0\n", + " r[\"insp_ratio\"] = r[\"inspect_wait_days\"] / r[\"outsource_days\"] if r[\"outsource_days\"] >= 5 else 0.0\n", + " r[\"spec_defect\"] = bool(r[\"spec_mismatch\"] >= 2 and r[\"defect_reports\"] >= 1)\n", + " return r\n", + "\n", + "\n", + "def apply_rules(rec, rules, derived=False):\n", + " \"\"\"룰 적용: (점수, 적중 룰 id 목록). derived=True면 이미 파생 필드가 있는 레코드(경계 시험용)로 본다.\"\"\"\n", + " r = rec if derived else derive_rec(rec)\n", + " hits = [x[\"id\"] for x in rules if OPS[x[\"op\"]](r[x[\"field\"]], x[\"value\"])]\n", + " w = {x[\"id\"]: x[\"weight\"] for x in rules}\n", + " return sum(w[h] for h in hits), hits\n", + "\n", + "\n", + "def grade_of(score):\n", + " for cut, g in GRADE_CUT:\n", + " if score >= cut:\n", + " return g\n", + " return \"정상\"\n", + "\n", + "# ---- 가동률 회귀 모델과 저가동 요인 분류·추천 모델(외부 AI 서비스 없이 scikit-learn 사용) ----\n", + "from sklearn.linear_model import Ridge, LogisticRegression\n", + "from sklearn.preprocessing import StandardScaler\n", + "from sklearn.pipeline import make_pipeline\n", + "\n", + "CAUSE_COLS = [\"age_years\", \"maint_orders\", \"parts_stock_pct\", \"mechanic_fill_pct\", \"outsource_days\", \"inspect_wait_days\", \"rework_count\", \"spec_mismatch\",\n", + " \"defect_reports\", \"parts_wait_days\", \"labor_wait_days\"]\n", + "ACTION = {\"부품 대기\": \"부품 사전 청구·재고 보강\", \"정비병 부족\": \"정비병 재배치·교육 일정 조정\", \"외주 정비\": \"외주 정비 일정 조율·내부 정비 확대\", \"검사·재작업\": \"검사 일정 조율·규격 사전 대조\"}\n", + "\n", + "\n", + "def cause_frame(df):\n", + " x = df[CAUSE_COLS].copy()\n", + " x[\"out_insp\"] = x[\"outsource_days\"] + x[\"inspect_wait_days\"] + 4 * x[\"rework_count\"]\n", + " x[\"wait_pm\"] = (x[\"parts_wait_days\"] + x[\"labor_wait_days\"]) / (x[\"maint_orders\"] + 1)\n", + " return x\n", + "\n", + "\n", + "def fit_avail(df, alpha=1.0):\n", + " \"\"\"가동률(%) 회귀: 표준화 + 릿지 회귀.\"\"\"\n", + " return make_pipeline(StandardScaler(), Ridge(alpha=alpha)).fit(feat_frame(df), df[\"avail_pct\"])\n", + "\n", + "\n", + "def fit_cause(df, cut=85, C=5):\n", + " \"\"\"가동률이 cut% 미만인 장비의 주요 저가동 요인(4종)을 분류한다.\"\"\"\n", + " low = df[(df[\"avail_pct\"] < cut) & (df[\"main_cause\"] != \"없음\")]\n", + " return make_pipeline(StandardScaler(), LogisticRegression(C=C, max_iter=3000)).fit(cause_frame(low), low[\"main_cause\"])\n", + "\n", + "\n", + "SIM_FEAT = [\"age_years\", \"maint_orders\", \"spec_mismatch\", \"stock_gap_x_orders\", \"fill_gap_x_orders\", \"parts_stock_pct\", \"mechanic_fill_pct\", \"etype_B\", \"etype_C\"]\n", + "\n", + "\n", + "def sim_frame(df):\n", + " \"\"\"자원 재배치 시뮬레이션용 설명 변수: 부족분이 클수록 대기가 더 빠르게 늘고 정비 요청 건수와 곱해져 커진다는 구조를 제곱·상호작용 항으로 둔다.\"\"\"\n", + " x = df.copy()\n", + " x[\"etype_B\"], x[\"etype_C\"] = (x[\"etype\"] == \"유형B\").astype(int), (x[\"etype\"] == \"유형C\").astype(int)\n", + " x[\"stock_gap_x_orders\"] = x[\"maint_orders\"] * ((100 - x[\"parts_stock_pct\"]) / 28) ** 2\n", + " x[\"fill_gap_x_orders\"] = x[\"maint_orders\"] * ((100 - x[\"mechanic_fill_pct\"]) / 24) ** 2\n", + " return x[SIM_FEAT]\n", + "\n", + "\n", + "def fit_sim(df, alpha=0.5):\n", + " return make_pipeline(StandardScaler(), Ridge(alpha=alpha)).fit(sim_frame(df), df[\"avail_pct\"])\n", + "\n", + "rules = parse_rules(RULES_YAML)\n", + "model = fit_avail(make_fleet(1200, seed=21))\n", + "BASE_N = 1000 # 월 기준 장비 수(가상)\n", + "MANUAL_MIN = {\"적재·정리\": 0.5, \"검증\": 0.5, \"분석\": 1.5, \"보고\": 0.5} # 장비 1대당 수작업 시간(분, 가정치)\n", + "CHECK_MIN = 6 # 자동 분석 후 정비관이 고위험 1대를 확인하는 시간(분, 가정치)\n", + "rng = np.random.default_rng(8)\n", + "LOGS = []\n", + "\n", + "\n", + "def load(month, n, bad_rate=0.01):\n", + " df = make_fleet(n, seed=100 + month)\n", + " k = max(1, int(n * bad_rate))\n", + " bad = rng.choice(n, k, replace=False)\n", + " df[\"avail_pct\"] = df[\"avail_pct\"].astype(float)\n", + " df.loc[bad[: k // 2], \"avail_pct\"] = np.nan # 결측\n", + " df.loc[bad[k // 2:], \"avail_pct\"] = 150.0 # 범위 오류\n", + " return df\n", + "\n", + "\n", + "def validate(df):\n", + " ok = df[\"avail_pct\"].between(0, 100)\n", + " return df[ok], df[~ok]\n", + "\n", + "\n", + "def analyze(df):\n", + " recs = df.drop(columns=[\"main_cause\"]).to_dict(\"records\")\n", + " sc = [apply_rules(r, rules)[0] for r in recs]\n", + " pred = np.clip(model.predict(feat_frame(df)), 0, 100)\n", + " return {\"n\": len(df), \"avail\": df[\"avail_pct\"].mean(), \"high\": sum(s >= 10 for s in sc), \"caution\": sum(6 <= s < 10 for s in sc), \"mae\": np.mean(np.abs(pred - df[\"avail_pct\"]))}\n", + "\n", + "\n", + "FAIL_ONCE = {(3, \"분석\"), (9, \"적재\")} # 일시 장애 주입(재시도 후 성공해야 함)\n", + "FAIL_ALWAYS = set() # 지속 장애는 아래 별도 시험에서 주입(격리·알림 후 다음 달 계속)\n", + "tried = set()\n", + "\n", + "\n", + "def step(month, name, fn):\n", + " for attempt in (1, 2):\n", + " try:\n", + " if (month, name) in FAIL_ONCE and (month, name) not in tried:\n", + " tried.add((month, name)); raise IOError(\"temporary failure\")\n", + " if (month, name) in FAIL_ALWAYS:\n", + " raise RuntimeError(\"persistent failure\")\n", + " return True, fn()\n", + " except Exception as e:\n", + " LOGS.append(f\"{month:02d}월 {name} 시도 {attempt} 실패: {e}\")\n", + " return False, None\n", + "\n", + "\n", + "def run_batch(n, label, months=range(1, 13), verbose=False):\n", + " t0 = time.perf_counter(); res, steps, ok_steps, quarantined, seen = [], 0, 0, 0, 0\n", + " for m in months:\n", + " ctx = {}\n", + " for name, fn in [(\"적재\", lambda: ctx.update(df=load(m, n))), (\"검증\", lambda: ctx.update(zip((\"clean\", \"bad\"), validate(ctx[\"df\"])))),\n", + " (\"분석\", lambda: ctx.update(r=analyze(ctx[\"clean\"]))), (\"보고\", lambda: ctx[\"r\"])]:\n", + " steps += 1\n", + " ok, _ = step(m, name, fn)\n", + " ok_steps += ok\n", + " if not ok:\n", + " break\n", + " if \"r\" in ctx and ok:\n", + " res.append({\"월\": m, **ctx[\"r\"], \"격리\": len(ctx[\"bad\"])})\n", + " if \"df\" in ctx:\n", + " seen += len(ctx[\"df\"]); quarantined += len(ctx.get(\"bad\", [])) if \"bad\" in ctx else 0\n", + " if verbose:\n", + " print(f\" 진행 [{'#' * m}{'.' * (12 - m)}] {m:2d}/12월 {'완료' if ok else '실패(알림 발송, 다음 달 계속)'}\")\n", + " return pd.DataFrame(res), time.perf_counter() - t0, ok_steps, steps\n", + "\n", + "\n", + "# 1) 12개월 배치(스케줄러 모사)\n", + "print(\"[월 배치 실행: 장애 주입 포함]\")\n", + "rs, dt, ok_s, steps = run_batch(BASE_N, \"x1\", verbose=True)\n", + "print(f\"소요 {dt:.1f}초(목표 5분=300초 이내: {'달성' if dt < 300 else '미달'}), 단계 {ok_s}/{steps} 성공(일시 장애는 재시도로 복구) -> 자동화 성공률 {ok_s / steps:.1%} (목표 99% 이상: {'달성' if ok_s / steps >= 0.99 else '미달'})\")\n", + "print(\"재시도·실패 로그:\", \" / \".join(LOGS[:3]), \"...\", f\"(총 {len(LOGS)}건)\")\n", + "print(\"[알림] 배치 완료: 월 12건 중 %d건 보고 생성, 실패 월 %s\" % (len(rs), sorted(set(range(1, 13)) - set(rs[\"월\"])) or \"없음\"))\n", + "print(rs.round(1).head(4).to_string(index=False), \"\\n ...\")\n", + "\n", + "# 2) 지속 장애 시험(11월 보고 단계가 계속 실패): 해당 월만 건너뛰고 알림, 나머지 월은 계속(기능 축소 작동)\n", + "FAIL_ONCE.clear(); FAIL_ALWAYS.add((11, \"보고\")); LOGS.clear()\n", + "rp, dp, okp, stp = run_batch(BASE_N, \"fault\")\n", + "print(f\"\\n[지속 장애 시험] 단계 {okp}/{stp} 성공({okp / stp:.1%}), 보고 생성 {len(rp)}/12월, 실패 월 {sorted(set(range(1, 13)) - set(rp['월']))} -> 알림 발송, 다른 달은 정상 처리(배치 전체 중단 없음)\")\n", + "\n", + "# 3) 장애 없는 배치의 자료 유실 점검\n", + "FAIL_ALWAYS.clear(); LOGS.clear()\n", + "rs0, dt0, ok0, st0 = run_batch(BASE_N, \"clean\")\n", + "tot_in = BASE_N * 12\n", + "tot_out = int(rs0[\"n\"].sum() + rs0[\"격리\"].sum())\n", + "print(f\"\\n[장애 없는 배치] 단계 {ok0}/{st0} 성공({ok0 / st0:.1%}), 입력 {tot_in}건 = 분석 {int(rs0['n'].sum())} + 격리 {int(rs0['격리'].sum())} -> 유실 {tot_in - tot_out}건({(tot_in - tot_out) / tot_in:.1%})\")\n", + "\n", + "# 4) 부하 시험: 자료량 100%·200%·500%\n", + "print(\"\\n[부하 시험: 월 자료량 증가 (12개월 전체)]\")\n", + "lt = []\n", + "for mult in (1, 2, 5):\n", + " r, d, o, s = run_batch(BASE_N * mult, f\"x{mult}\")\n", + " lost = BASE_N * mult * 12 - int(r[\"n\"].sum() + r[\"격리\"].sum())\n", + " lt.append((mult, d, o / s, lost))\n", + " print(f\" 자료 {mult * 100:3d}%: {BASE_N * mult * 12:6d}건, {d:5.1f}초, 성공 {o}/{s}, 유실 {lost}건, 초당 {BASE_N * mult * 12 / d:,.0f}건\")\n", + "print(f\"시스템 다운 0건, 데이터 유실 {sum(x[3] for x in lt)}건 (목표 0%: {'달성' if sum(x[3] for x in lt) == 0 else '미달'})\")\n", + "\n", + "# 5) 수작업 대비 절감(가정치 기반 상대 비교)\n", + "n_equip = BASE_N * 12\n", + "manual_h = n_equip * sum(MANUAL_MIN.values()) / 60\n", + "check_h = int(rs0[\"high\"].sum()) * CHECK_MIN / 60\n", + "auto_h = dt0 / 3600 + check_h\n", + "print(f\"\\n[업무 시간 절감] 수작업 {manual_h:.0f}시간(장비당 {sum(MANUAL_MIN.values()):.1f}분) vs 자동 {auto_h:.1f}시간(실행 {dt0:.1f}초 + 고위험 {int(rs0['high'].sum())}대 확인 {check_h:.1f}시간) -> 절감 {1 - auto_h / manual_h:.0%} (목표 80% 이상: {'달성' if 1 - auto_h / manual_h >= 0.8 else '미달'})\")\n", + "print(\"※ 시간 가정(장비당 수작업 분, 고위험 확인 6분)은 교육용 가정치이며 실제 업무 시간으로 바꿔 계산한다. 고위험 장비의 확인과 정비 우선순위 결정은 사람이 한다.\")\n", + "\n", + "fig, ax = plt.subplots(1, 2, figsize=(10, 4))\n", + "ax[0].plot(rs0[\"월\"], rs0[\"avail\"], marker=\"o\"); ax[0].set_title(\"Monthly mean availability (synthetic)\"); ax[0].set_xlabel(\"month\")\n", + "ax[1].bar([f\"x{x[0]}\" for x in lt], [x[1] for x in lt], color=\"tab:blue\"); ax[1].set_title(\"Batch time by data volume (sec)\")\n", + "plt.tight_layout(); plt.show()\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 08 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + "[월 배치 실행: 장애 주입 포함]\n", + " 진행 [#...........] 1/12월 완료\n", + " 진행 [##..........] 2/12월 완료\n", + " 진행 [###.........] 3/12월 완료\n", + " 진행 [####........] 4/12월 완료\n", + " 진행 [#####.......] 5/12월 완료\n", + " 진행 [######......] 6/12월 완료\n", + " 진행 [#######.....] 7/12월 완료\n", + " 진행 [########....] 8/12월 완료\n", + " 진행 [#########...] 9/12월 완료\n", + " 진행 [##########..] 10/12월 완료\n", + " 진행 [###########.] 11/12월 완료\n", + " 진행 [############] 12/12월 완료\n", + "소요 0.3초(목표 5분=300초 이내: 달성), 단계 48/48 성공(일시 장애는 재시도로 복구) -> 자동화 성공률 100.0% (목표 99% 이상: 달성)\n", + "재시도·실패 로그: 03월 분석 시도 1 실패: temporary failure / 09월 적재 시도 1 실패: temporary failure ... (총 2건)\n", + "[알림] 배치 완료: 월 12건 중 12건 보고 생성, 실패 월 없음\n", + " 월 n avail high caution mae 격리\n", + " 1 990 79.1 80 103 4.0 10\n", + " 2 990 79.9 85 79 4.0 10\n", + " 3 990 78.6 86 108 4.0 10\n", + " 4 990 79.7 79 84 4.1 10 \n", + " ...\n", + "\n", + "[지속 장애 시험] 단계 47/48 성공(97.9%), 보고 생성 11/12월, 실패 월 [11] -> 알림 발송, 다른 달은 정상 처리(배치 전체 중단 없음)\n", + "\n", + "[장애 없는 배치] 단계 48/48 성공(100.0%), 입력 12000건 = 분석 11880 + 격리 120 -> 유실 0건(0.0%)\n", + "\n", + "[부하 시험: 월 자료량 증가 (12개월 전체)]\n", + " 자료 100%: 12000건, 0.3초, 성공 48/48, 유실 0건, 초당 37,904건\n", + " 자료 200%: 24000건, 0.6초, 성공 48/48, 유실 0건, 초당 41,507건\n", + " 자료 500%: 60000건, 1.1초, 성공 48/48, 유실 0건, 초당 52,896건\n", + "시스템 다운 0건, 데이터 유실 0건 (목표 0%: 달성)\n", + "\n", + "[업무 시간 절감] 수작업 600시간(장비당 3.0분) vs 자동 98.3시간(실행 0.3초 + 고위험 983대 확인 98.3시간) -> 절감 84% (목표 80% 이상: 달성)\n", + "※ 시간 가정(장비당 수작업 분, 고위험 확인 6분)은 교육용 가정치이며 실제 업무 시간으로 바꿔 계산한다. 고위험 장비의 확인과 정비 우선순위 결정은 사람이 한다.\n", + "```\n", + "
\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 09. AAR 기반 가동률 예측치와 실제치 편차 분석 및 분석 파라미터 재보정\n", + "**소요시간**: 약 3.0시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "AAR 기반 가동률 예측치와 실제치 편차를 분석하고 파라미터를 재보정한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **AAR로 가동률 예측치와 실제치의 편차를 분석해 원인을 규명하고, 분석 파라미터를 재보정해 정확도 개선 효과와 UAT 점검 결과를 확인한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **평시 자료로 학습한 모델의 예측을 외주 검사 병목이 악화된 새 분기 실측과 부대·유형 셀 단위로 비교해 편향과 원인을 요인별로 분해 수행하고 검증하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `docs/qlt04_uat_feedback_report.md 및 패치 내역` 형태의 결과물을 완성한다.\n", + "\n", + "### 🔍 평가 및 검증 기준 (PASS 조건)\n", + "#### 미션 판정\n", + "\n", + "**평가 구분:** 필수\n", + "\n", + "**핵심 평가영역:** 실무 업무 생산성 기여도 및 현장 적응성\n", + "\n", + "#### 미션 PASS 조건\n", + "\n", + "- 정량 기준: UAT 체크리스트 만족도 90점 이상, 개선 요구 5건 반영 완료\n", + "\n", + "\n", + "- 진행 기준: UAT 결과 보고서 작성 및 최종 버그 픽스 완료\n", + "\n", + "\n", + "- 검증 방법: 실제 업무 시나리오 기반 최종 검수 테스트\n", + "\n", + "\n", + "#### 채점자 판정 메모\n", + "\n", + "> 단순 코드 실행 여부뿐만 아니라 출력된 수치와 근거 자료가 군 실무 요구사항에 부합하는지 면밀히 확인한다.\n", + "\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "\"\"\"가동률 예측치와 실제치의 편차를 부대·유형 셀 단위로 AAR 분석해 오차 원인(외주 검사 병목 악화)을 요인별로 분해하고, 편향 보정·병목 변수 추가·가중 재학습·정규화 강도 조정·예측 구간의 5개 재보정 패치를 단계별로 검증해 채택 여부를 정하며 UAT 체크리스트와 재보정 파라미터(YAML 문자열)를 출력한다.\"\"\"\n", + "import numpy as np\n", + "import pandas as pd\n", + "import matplotlib.pyplot as plt\n", + "from sklearn.model_selection import KFold\n", + "\n", + "# ---------------------------------------------------------------------------\n", + "# [교육용가상자료] 가상 장비 가동률·정비 대기 자료 생성기 (실제 부대·장비·제원·가동률 정보 없음)\n", + "# 장비 유형 A(궤도형)·B(화포형)·C(차륜형)은 추상 구분이며 실제 장비와 무관하다.\n", + "# 최근 90일 동안 장비 1대의 정비 대기 요인(부품·정비병·외주 정비·외주 검사·재작업)과 가동률을 만든다.\n", + "# ---------------------------------------------------------------------------\n", + "UNITS = [\"가상A부대\", \"가상B부대\", \"가상C부대\", \"가상D부대\", \"가상E부대\", \"가상F부대\"]\n", + "ETYPES = [\"유형A\", \"유형B\", \"유형C\"]\n", + "WINDOW = 90 # 관측 기간(일)\n", + "CAUSES = [\"부품 대기\", \"정비병 부족\", \"외주 정비\", \"검사·재작업\"]\n", + "CAUSE_COL = {\"부품 대기\": \"parts_wait_days\", \"정비병 부족\": \"labor_wait_days\", \"외주 정비\": \"outsource_days\", \"검사·재작업\": \"inspect_wait_days\"}\n", + "NUM_FIELDS = [\"age_years\", \"op_hours\", \"maint_orders\", \"parts_wait_days\", \"labor_wait_days\", \"outsource_days\", \"inspect_wait_days\", \"rework_count\",\n", + " \"spec_mismatch\", \"defect_reports\", \"parts_stock_pct\", \"mechanic_fill_pct\", \"avail_pct\"]\n", + "FEAT = [\"age_years\", \"maint_orders\", \"parts_stock_pct\", \"mechanic_fill_pct\", \"outsource_days\", \"inspect_wait_days\", \"rework_count\", \"spec_mismatch\", \"defect_reports\", \"etype_B\", \"etype_C\"]\n", + "FEAT_KO = {\"age_years\": \"장비 연령\", \"maint_orders\": \"정비 요청 건수\", \"parts_stock_pct\": \"부품 재고 가용률\", \"mechanic_fill_pct\": \"정비병 충원율\", \"outsource_days\": \"외주 정비 일수\",\n", + " \"inspect_wait_days\": \"외주 검사 대기 일수\", \"rework_count\": \"재작업 건수\", \"spec_mismatch\": \"규격·제원 불일치\", \"defect_reports\": \"하자·불만 접수\",\n", + " \"etype_B\": \"유형B 여부\", \"etype_C\": \"유형C 여부\"}\n", + "\n", + "\n", + "def unit_profile(seed=3):\n", + " \"\"\"부대별 고정 특성: 부품 재고 수준, 정비병 충원율, 외주 정비소 거리(일수 가중), 외주 검사 혼잡도.\"\"\"\n", + " # [TODO : 학생 실습 9] AAR 기반 가동률 예측치와 실제치 편차 분석 및 분석 파라미터 재보정 관련 핵심 로직을 구현하세요.\n", + " # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n", + " # <<< 여기에 코드를 작성하세요 >>>\n", + " pass # 작성 후 pass를 제거하세요.\n", + "def make_fleet(n=300, seed=7, shock=0.0):\n", + " \"\"\"장비 n대의 90일 관측 자료(필드 16개)와 정답 요인을 만든다. shock은 외주 검사 병목 악화 정도(AAR용 환경 변화).\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " pf = unit_profile()\n", + " ui = rng.integers(0, len(UNITS), n)\n", + " et = rng.choice(ETYPES, n, p=[.35, .3, .35])\n", + " age = np.clip(rng.gamma(4, 2.2, n), 0.5, 30).round(1)\n", + " eff = np.array([{\"유형A\": 1.15, \"유형B\": 1.0, \"유형C\": 0.9}[t] for t in et])\n", + " orders = rng.poisson(np.clip(1.2 + 0.12 * age, 0.5, 6) * eff)\n", + " stock = np.clip(pf[\"stock\"].to_numpy()[ui] + rng.normal(0, 8, n), 20, 100).round(0)\n", + " fill = np.clip(pf[\"fill\"].to_numpy()[ui] + rng.normal(0, 6, n), 30, 100).round(0)\n", + " parts_w = (orders * rng.gamma(2, 0.55 * (1 + ((100 - stock) / 28) ** 2) / 2)).round(0)\n", + " labor_w = (orders * rng.gamma(2, 0.7 * (1 + ((100 - fill) / 24) ** 2) / 2)).round(0)\n", + " n_out = rng.binomial(orders, 0.3)\n", + " out_d = (n_out * rng.gamma(3, 4.5 * pf[\"dist\"].to_numpy()[ui] / 3)).round(0)\n", + " insp_w = (n_out * rng.gamma(2, (2.6 + shock) * pf[\"queue\"].to_numpy()[ui] / 2)).round(0)\n", + " spec = rng.poisson(0.2 + 0.04 * age)\n", + " rework = rng.binomial(n_out, np.clip(0.08 + 0.1 * spec, 0, .6))\n", + " defect = rng.poisson(0.3 + 0.7 * rework)\n", + " base_rep = orders * 1.8\n", + " down = base_rep + parts_w + labor_w + out_d + insp_w + rework * 4 + rng.normal(0, 1.2, n)\n", + " comp = np.column_stack([parts_w, labor_w, out_d, insp_w + rework * 4])\n", + " df = pd.DataFrame({\"equip_id\": [f\"E-{i + 1:04d}\" for i in range(n)], \"unit\": np.array(UNITS)[ui], \"etype\": et, \"age_years\": age,\n", + " \"op_hours\": np.clip(rng.normal(320 - 4 * age, 70, n), 40, 600).round(0), \"maint_orders\": orders,\n", + " \"parts_wait_days\": parts_w, \"labor_wait_days\": labor_w, \"outsource_days\": out_d, \"inspect_wait_days\": insp_w,\n", + " \"rework_count\": rework, \"spec_mismatch\": spec, \"defect_reports\": defect, \"parts_stock_pct\": stock, \"mechanic_fill_pct\": fill,\n", + " \"avail_pct\": (100 * (1 - np.clip(down, 0, WINDOW) / WINDOW)).round(1)})\n", + " df[\"main_cause\"] = np.where(comp.max(axis=1) < 3, \"없음\", np.array(CAUSES)[comp.argmax(axis=1)])\n", + " return df\n", + "\n", + "\n", + "def feat_frame(df):\n", + " \"\"\"회귀·분석용 설명 변수 표(유형은 더미 변수).\"\"\"\n", + " # [TODO : 학생 실습 9] AAR 기반 가동률 예측치와 실제치 편차 분석 및 분석 파라미터 재보정 관련 핵심 로직을 구현하세요.\n", + " # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n", + " # <<< 여기에 코드를 작성하세요 >>>\n", + " pass # 작성 후 pass를 제거하세요.\n", + "def make_trend(months=12, seed=5, shock_unit=None):\n", + " \"\"\"[교육용가상자료] 부대 x 유형 x 월 평균 가동률 추세. shock_unit 부대는 하반기에 외주 검사 병목으로 가동률이 떨어진다.\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " pf = unit_profile()\n", + " rows = []\n", + " for _, u in pf.iterrows():\n", + " base = 62 + 0.22 * u[\"stock\"] + 0.12 * u[\"fill\"] - 3.0 * u[\"queue\"]\n", + " for t in ETYPES:\n", + " off = {\"유형A\": -3.0, \"유형B\": 0.0, \"유형C\": 2.0}[t]\n", + " for m in range(months):\n", + " v = base + off + 2.0 * np.sin(m / 12 * 2 * np.pi) + rng.normal(0, 1.0)\n", + " if shock_unit == u[\"unit\"] and m >= months // 2:\n", + " v -= 1.5 * (m - months // 2 + 1)\n", + " rows.append((u[\"unit\"], t, m + 1, round(float(np.clip(v, 30, 99)), 1)))\n", + " return pd.DataFrame(rows, columns=[\"unit\", \"etype\", \"month\", \"avail_pct\"])\n", + "\n", + "# ---- 가동률 회귀 모델과 저가동 요인 분류·추천 모델(외부 AI 서비스 없이 scikit-learn 사용) ----\n", + "from sklearn.linear_model import Ridge, LogisticRegression\n", + "from sklearn.preprocessing import StandardScaler\n", + "from sklearn.pipeline import make_pipeline\n", + "\n", + "CAUSE_COLS = [\"age_years\", \"maint_orders\", \"parts_stock_pct\", \"mechanic_fill_pct\", \"outsource_days\", \"inspect_wait_days\", \"rework_count\", \"spec_mismatch\",\n", + " \"defect_reports\", \"parts_wait_days\", \"labor_wait_days\"]\n", + "ACTION = {\"부품 대기\": \"부품 사전 청구·재고 보강\", \"정비병 부족\": \"정비병 재배치·교육 일정 조정\", \"외주 정비\": \"외주 정비 일정 조율·내부 정비 확대\", \"검사·재작업\": \"검사 일정 조율·규격 사전 대조\"}\n", + "\n", + "\n", + "def cause_frame(df):\n", + " x = df[CAUSE_COLS].copy()\n", + " x[\"out_insp\"] = x[\"outsource_days\"] + x[\"inspect_wait_days\"] + 4 * x[\"rework_count\"]\n", + " x[\"wait_pm\"] = (x[\"parts_wait_days\"] + x[\"labor_wait_days\"]) / (x[\"maint_orders\"] + 1)\n", + " return x\n", + "\n", + "\n", + "def fit_avail(df, alpha=1.0):\n", + " \"\"\"가동률(%) 회귀: 표준화 + 릿지 회귀.\"\"\"\n", + " return make_pipeline(StandardScaler(), Ridge(alpha=alpha)).fit(feat_frame(df), df[\"avail_pct\"])\n", + "\n", + "\n", + "def fit_cause(df, cut=85, C=5):\n", + " \"\"\"가동률이 cut% 미만인 장비의 주요 저가동 요인(4종)을 분류한다.\"\"\"\n", + " low = df[(df[\"avail_pct\"] < cut) & (df[\"main_cause\"] != \"없음\")]\n", + " return make_pipeline(StandardScaler(), LogisticRegression(C=C, max_iter=3000)).fit(cause_frame(low), low[\"main_cause\"])\n", + "\n", + "\n", + "SIM_FEAT = [\"age_years\", \"maint_orders\", \"spec_mismatch\", \"stock_gap_x_orders\", \"fill_gap_x_orders\", \"parts_stock_pct\", \"mechanic_fill_pct\", \"etype_B\", \"etype_C\"]\n", + "\n", + "\n", + "def sim_frame(df):\n", + " \"\"\"자원 재배치 시뮬레이션용 설명 변수: 부족분이 클수록 대기가 더 빠르게 늘고 정비 요청 건수와 곱해져 커진다는 구조를 제곱·상호작용 항으로 둔다.\"\"\"\n", + " x = df.copy()\n", + " x[\"etype_B\"], x[\"etype_C\"] = (x[\"etype\"] == \"유형B\").astype(int), (x[\"etype\"] == \"유형C\").astype(int)\n", + " x[\"stock_gap_x_orders\"] = x[\"maint_orders\"] * ((100 - x[\"parts_stock_pct\"]) / 28) ** 2\n", + " x[\"fill_gap_x_orders\"] = x[\"maint_orders\"] * ((100 - x[\"mechanic_fill_pct\"]) / 24) ** 2\n", + " return x[SIM_FEAT]\n", + "\n", + "\n", + "def fit_sim(df, alpha=0.5):\n", + " return make_pipeline(StandardScaler(), Ridge(alpha=alpha)).fit(sim_frame(df), df[\"avail_pct\"])\n", + "\n", + "# ---- 평시 자료로 학습한 기존 예측 모델과, 외주 검사 병목이 악화된 새 분기(실제) ----\n", + "hist = make_fleet(900, seed=21)\n", + "m0 = fit_sim(hist)\n", + "now = make_fleet(1800, seed=51, shock=4.0) # 실제: 외주 검사 대기 병목 악화(shock) -> 기존 모델은 모름\n", + "aar, test = now.iloc[:600], now.iloc[600:] # AAR 표본(분기 초 실측) / 이후 검증 자료\n", + "CELL = [\"unit\", \"etype\"]\n", + "\n", + "\n", + "def cells(df, pred):\n", + " t = df[CELL].copy(); t[\"actual\"], t[\"pred\"] = df[\"avail_pct\"].to_numpy(), pred\n", + " g = t.groupby(CELL).mean(); g[\"err\"] = g[\"actual\"] - g[\"pred\"]; return g\n", + "\n", + "\n", + "def score(df, pred, tol=3.0):\n", + " c = cells(df, pred)\n", + " return {\"hit\": float((c[\"err\"].abs() <= tol).mean()), \"mae\": float(np.mean(np.abs(df[\"avail_pct\"] - pred))), \"bias\": float((df[\"avail_pct\"] - pred).mean()), \"cells\": c}\n", + "\n", + "\n", + "# 1) AAR: 예측치와 실제치의 편차\n", + "p_aar = m0.predict(sim_frame(aar))\n", + "s0 = score(aar, p_aar)\n", + "print(f\"[AAR 편차 분석] 예측 대비 실제: 평균 편향 {s0['bias']:+.2f}%p(실제가 낮음), 개별 MAE {s0['mae']:.2f}%p, 셀 18곳 중 오차 3%p 이내 {s0['hit']:.0%}\")\n", + "worst = s0[\"cells\"].sort_values(\"err\").head(3)\n", + "print(\"오차가 큰 셀:\", \", \".join(f\"{i[0]}/{i[1]} {r.err:+.1f}%p\" for i, r in worst.iterrows()))\n", + "comp = {\"부품 대기\": \"parts_wait_days\", \"정비병 부족\": \"labor_wait_days\", \"외주 정비\": \"outsource_days\", \"외주 검사 대기\": \"inspect_wait_days\", \"재작업\": \"rework_count\"}\n", + "contrib = {k: -(aar[c].mean() - hist[c].mean()) / WINDOW * 100 * (4 if c == \"rework_count\" else 1) for k, c in comp.items()}\n", + "print(\"오차 원인 분해(평시 대비 변화가 가동률에 미친 영향 %p):\", \", \".join(f\"{k} {v:+.2f}\" for k, v in contrib.items()))\n", + "cause = min(contrib, key=contrib.get)\n", + "print(f\"-> 가장 큰 원인: {cause} (예측 모델의 입력에 검사 병목 정보가 없었음). 재발 방지: 병목 변수 추가와 최근 자료 반영\")\n", + "\n", + "# 2) 재보정 패치 후보를 AAR 표본으로 만들고 검증 자료(test)로 평가\n", + "b_global = s0[\"bias\"]\n", + "b_cell = s0[\"cells\"][\"err\"]\n", + "unit_insp = aar.groupby(\"unit\")[\"inspect_wait_days\"].mean() # P2: 부대별 최근 외주 검사 대기(병목 변수)\n", + "\n", + "\n", + "def sim2(df):\n", + " x = sim_frame(df).copy(); x[\"insp_x_orders\"] = df[\"unit\"].map(unit_insp).to_numpy() * df[\"maint_orders\"].to_numpy(); return x\n", + "\n", + "\n", + "def fit2(alpha, w):\n", + " tr = pd.concat([hist.assign(_w=1.0), aar.assign(_w=w)]); x = sim2(tr)\n", + " sc = StandardScaler().fit(x); return sc, Ridge(alpha=alpha).fit(sc.transform(x), tr[\"avail_pct\"], sample_weight=tr[\"_w\"])\n", + "\n", + "\n", + "for_cv = {}\n", + "for a in (0.1, 0.5, 2.0, 10.0, 50.0): # P4: 정규화 강도를 AAR 표본 5분할 교차검증으로 선택\n", + " e = []\n", + " for tri, vai in KFold(5, shuffle=True, random_state=0).split(aar):\n", + " tr = pd.concat([hist.assign(_w=1.0), aar.iloc[tri].assign(_w=3.0)]); x = sim2(tr); sc = StandardScaler().fit(x)\n", + " r = Ridge(alpha=a).fit(sc.transform(x), tr[\"avail_pct\"], sample_weight=tr[\"_w\"])\n", + " e.append(np.mean(np.abs(r.predict(sc.transform(sim2(aar.iloc[vai]))) - aar.iloc[vai][\"avail_pct\"])))\n", + " for_cv[a] = float(np.mean(e))\n", + "alpha = min(for_cv, key=for_cv.get)\n", + "sc3, r3 = fit2(0.5, 3.0); sc4, r4 = fit2(alpha, 3.0)\n", + "cand = {\"원 모델\": m0.predict(sim_frame(test)),\n", + " \"P1 전체 편향 보정\": m0.predict(sim_frame(test)) + b_global,\n", + " \"P1' 셀별 편향 보정\": m0.predict(sim_frame(test)) + test[CELL].apply(tuple, axis=1).map(b_cell.to_dict()).to_numpy(),\n", + " \"P2+P3 병목 변수+최근 자료 가중\": r3.predict(sc3.transform(sim2(test))),\n", + " f\"P4 +정규화 강도 {alpha}\": r4.predict(sc4.transform(sim2(test)))}\n", + "print(\"\\n[재보정 패치 검증(검증 자료 1,200대, 셀 18곳)]\")\n", + "res = {}\n", + "for k, p in cand.items():\n", + " res[k] = score(test, p); print(f\" {k:26s} 셀 적중 {res[k]['hit']:4.0%} 개별 MAE {res[k]['mae']:5.2f} 편향 {res[k]['bias']:+5.2f}\")\n", + "best = min((k for k in cand if k != \"원 모델\"), key=lambda k: (-res[k][\"hit\"], res[k][\"mae\"]))\n", + "b = res[best]; o = res[\"원 모델\"]\n", + "print(f\"채택: {best} -> 셀 적중 {o['hit']:.0%} -> {b['hit']:.0%} ({(b['hit'] - o['hit']) * 100:+.0f}%p, 목표 +10%p 이상: {'달성' if b['hit'] - o['hit'] >= 0.10 else '미달'}), MAE {o['mae']:.2f} -> {b['mae']:.2f}\")\n", + "\n", + "# 3) P5 예측 구간(잔차 90% 분위수)과 재발 오류\n", + "pf = cand[best]; resid_aar = aar[\"avail_pct\"].to_numpy() - r4.predict(sc4.transform(sim2(aar)))\n", + "q = float(np.quantile(np.abs(resid_aar), 0.9))\n", + "cover = float(np.mean(np.abs(test[\"avail_pct\"].to_numpy() - pf) <= q))\n", + "bad0 = set(i for i, r in s0[\"cells\"].iterrows() if abs(r.err) > 3)\n", + "recur = [i for i in bad0 if abs(b[\"cells\"].loc[i, \"err\"]) > 3]\n", + "print(\"장비 1대 단위 예측은 외주 검사·재작업 같은 개별 사건을 알 수 없어 오차가 크므로, 부대·유형 셀 평균으로 계획에 쓴다.\")\n", + "print(f\"P5 예측 구간 ±{q:.1f}%p: 검증 포괄률 {cover:.0%}(목표 약 90%). 재보정 전 오차 3%p 초과 셀 {len(bad0)}곳 중 재발 {len(recur)}곳 {recur[:3] if recur else ''}\")\n", + "\n", + "# 4) UAT 체크리스트(측정값 기반 가상 점검) 10항목 x 10점\n", + "uat = {\"셀 적중률 80% 이상\": b[\"hit\"] >= 0.8, \"개별 MAE 12%p 이하(장비 단위는 참고용)\": b[\"mae\"] <= 12, \"평균 편향 절대값 1.0%p 이하\": abs(b[\"bias\"]) <= 1.0, \"예측 구간 포괄률 85~95%\": 0.85 <= cover <= 0.95,\n", + " \"재발 오류 0건\": len(recur) == 0, \"원인 분해표 제공\": len(contrib) == 5, \"재보정 전후 비교표 제공\": len(cand) == 5, \"파라미터 YAML 출력\": True,\n", + " \"이전 파라미터로 되돌리기 가능(원 모델 보존)\": m0 is not None, \"사람 확인 문구 포함(자동 적용 금지)\": True}\n", + "pts = sum(10 for v in uat.values() if v)\n", + "print(f\"\\n[UAT 체크리스트] {pts}점/100점 (목표 90점 이상: {'달성' if pts >= 90 else '미달'}), 미충족: {[k for k, v in uat.items() if not v] or '없음'}\")\n", + "print(\"개선 요구 반영 5건: P1 편향 보정, P2 외주 검사 병목 변수 추가, P3 최근 자료 가중(x3), P4 정규화 강도 교차검증 선택, P5 예측 구간 표시 -> 모두 반영\")\n", + "\n", + "YAML = f\"\"\"[tuned_params 제안: 담당자 확인 후 적용]\n", + "model: ridge\n", + "alpha: {alpha}\n", + "recent_weight: 3.0\n", + "extra_feature: insp_x_orders # 부대별 최근 외주 검사 대기 x 정비 요청 건수\n", + "bias_cells: {len(b_cell)}\n", + "interval_pct90: {q:.1f}\n", + "retrain_trigger: 셀 적중률 70% 미만 또는 편향 절대값 1.0%p 초과\"\"\"\n", + "print(\"\\n\" + YAML)\n", + "print(\"지속 학습 연계: 분기마다 AAR 표본을 수집해 위 절차를 반복하되, 재보정 적용은 정비관·군수참모 확인 후에 한다. 가상 자료 기준 결과이다.\")\n", + "\n", + "plt.figure(figsize=(8, 4))\n", + "ks = list(res); plt.bar([f\"S{i}\" for i in range(len(ks))], [res[k][\"hit\"] * 100 for k in ks], color=[\"tab:gray\"] + [\"tab:blue\"] * (len(ks) - 1))\n", + "plt.ylabel(\"cells within 3%p (%)\"); plt.title(\"Forecast hit rate: base (S0) vs recalibration patches (S1-S4)\"); plt.tight_layout(); plt.show()\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 09 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + "[AAR 편차 분석] 예측 대비 실제: 평균 편향 -4.60%p(실제가 낮음), 개별 MAE 11.44%p, 셀 18곳 중 오차 3%p 이내 33%\n", + "오차가 큰 셀: 가상D부대/유형A -11.0%p, 가상D부대/유형C -10.3%p, 가상A부대/유형B -9.1%p\n", + "오차 원인 분해(평시 대비 변화가 가동률에 미친 영향 %p): 부품 대기 -0.21, 정비병 부족 -0.45, 외주 정비 -0.01, 외주 검사 대기 -4.92, 재작업 +0.18\n", + "-> 가장 큰 원인: 외주 검사 대기 (예측 모델의 입력에 검사 병목 정보가 없었음). 재발 방지: 병목 변수 추가와 최근 자료 반영\n", + "\n", + "[재보정 패치 검증(검증 자료 1,200대, 셀 18곳)]\n", + " 원 모델 셀 적중 39% 개별 MAE 10.53 편향 -3.82\n", + " P1 전체 편향 보정 셀 적중 78% 개별 MAE 11.48 편향 +0.79\n", + " P1' 셀별 편향 보정 셀 적중 50% 개별 MAE 11.81 편향 +0.95\n", + " P2+P3 병목 변수+최근 자료 가중 셀 적중 83% 개별 MAE 10.62 편향 -0.78\n", + " P4 +정규화 강도 10.0 셀 적중 83% 개별 MAE 10.62 편향 -0.78\n", + "채택: P2+P3 병목 변수+최근 자료 가중 -> 셀 적중 39% -> 83% (+44%p, 목표 +10%p 이상: 달성), MAE 10.53 -> 10.62\n", + "장비 1대 단위 예측은 외주 검사·재작업 같은 개별 사건을 알 수 없어 오차가 크므로, 부대·유형 셀 평균으로 계획에 쓴다.\n", + "P5 예측 구간 ±25.2%p: 검증 포괄률 90%(목표 약 90%). 재보정 전 오차 3%p 초과 셀 12곳 중 재발 1곳 [('가상A부대', '유형B')]\n", + "\n", + "[UAT 체크리스트] 90점/100점 (목표 90점 이상: 달성), 미충족: ['재발 오류 0건']\n", + "개선 요구 반영 5건: P1 편향 보정, P2 외주 검사 병목 변수 추가, P3 최근 자료 가중(x3), P4 정규화 강도 교차검증 선택, P5 예측 구간 표시 -> 모두 반영\n", + "\n", + "[tuned_params 제안: 담당자 확인 후 적용]\n", + "model: ridge\n", + "alpha: 10.0\n", + "recent_weight: 3.0\n", + "extra_feature: insp_x_orders # 부대별 최근 외주 검사 대기 x 정비 요청 건수\n", + "bias_cells: 18\n", + "interval_pct90: 25.2\n", + "retrain_trigger: 셀 적중률 70% 미만 또는 편향 절대값 1.0%p 초과\n", + "지속 학습 연계: 분기마다 AAR 표본을 수집해 위 절차를 반복하되, 재보정 적용은 정비관·군수참모 확인 후에 한다. 가상 자료 기준 결과이다.\n", + "```\n", + "
\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 10. 전술 장비 가동률 저하 요인 분석 및 가동률 극대화 도구 최종 완성\n", + "**소요시간**: 약 3.0시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "전술 장비 가동률 저하 요인 분석 및 가동률 극대화 도구를 완성한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **입력 검증부터 룰 판별, 요인 추천, 사람 검토 큐, 감사 로그, 브리핑까지 하나로 묶은 가동률 저하 요인 분석·극대화 도구를 완성하고 배포 점검을 마친다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **앞 미션의 기능을 통합 파이프라인으로 묶어 설치 스크립트 5단계 점검, 서로 다른 시드 4종의 End-to-End 실행, 자체 점검 9항목과 함수 커버리지 수행하고 분석하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `dist/qlt04_package/, README.md, docs/qlt04_final_manual.md` 형태의 결과물을 완성한다.\n", + "\n", + "### 🔍 평가 및 검증 기준 (PASS 조건)\n", + "#### 미션 판정\n", + "\n", + "**평가 구분:** 필수\n", + "\n", + "**핵심 평가영역:** 상용 소프트웨어 수준의 완성도 및 조직 확산 가치\n", + "\n", + "#### 미션 PASS 조건\n", + "\n", + "- 정량 기준: 설치부터 실행까지 원클릭 설치 스크립트 완비, 매뉴얼 작성률 100%\n", + "\n", + "\n", + "- 진행 기준: 최종 배포본 생성 및 검수 완료\n", + "\n", + "\n", + "- 검증 방법: Clean 환경에서의 설치 및 초기화 테스트\n", + "\n", + "\n", + "#### 채점자 판정 메모\n", + "\n", + "> 단순 코드 실행 여부뿐만 아니라 출력된 수치와 근거 자료가 군 실무 요구사항에 부합하는지 면밀히 확인한다.\n", + "\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "\"\"\"입력 검증·격리, 16종 룰 판별, 가동률 회귀와 저하 요인 Top-3 추천, 사람 검토 큐와 해시 체인 감사 로그, 브리핑 요약까지 외주정비 품질검사·가동률 분석 도구를 한 파일로 통합 실행하고 설치 스크립트 점검·End-to-End 4종·자체 점검(함수 커버리지)·매뉴얼 작성률을 출력한다.\"\"\"\n", + "import hashlib\n", + "import json\n", + "import sys\n", + "import time\n", + "import numpy as np\n", + "import pandas as pd\n", + "import matplotlib.pyplot as plt\n", + "\n", + "# ---------------------------------------------------------------------------\n", + "# [교육용가상자료] 가상 장비 가동률·정비 대기 자료 생성기 (실제 부대·장비·제원·가동률 정보 없음)\n", + "# 장비 유형 A(궤도형)·B(화포형)·C(차륜형)은 추상 구분이며 실제 장비와 무관하다.\n", + "# 최근 90일 동안 장비 1대의 정비 대기 요인(부품·정비병·외주 정비·외주 검사·재작업)과 가동률을 만든다.\n", + "# ---------------------------------------------------------------------------\n", + "UNITS = [\"가상A부대\", \"가상B부대\", \"가상C부대\", \"가상D부대\", \"가상E부대\", \"가상F부대\"]\n", + "ETYPES = [\"유형A\", \"유형B\", \"유형C\"]\n", + "WINDOW = 90 # 관측 기간(일)\n", + "CAUSES = [\"부품 대기\", \"정비병 부족\", \"외주 정비\", \"검사·재작업\"]\n", + "CAUSE_COL = {\"부품 대기\": \"parts_wait_days\", \"정비병 부족\": \"labor_wait_days\", \"외주 정비\": \"outsource_days\", \"검사·재작업\": \"inspect_wait_days\"}\n", + "NUM_FIELDS = [\"age_years\", \"op_hours\", \"maint_orders\", \"parts_wait_days\", \"labor_wait_days\", \"outsource_days\", \"inspect_wait_days\", \"rework_count\",\n", + " \"spec_mismatch\", \"defect_reports\", \"parts_stock_pct\", \"mechanic_fill_pct\", \"avail_pct\"]\n", + "FEAT = [\"age_years\", \"maint_orders\", \"parts_stock_pct\", \"mechanic_fill_pct\", \"outsource_days\", \"inspect_wait_days\", \"rework_count\", \"spec_mismatch\", \"defect_reports\", \"etype_B\", \"etype_C\"]\n", + "FEAT_KO = {\"age_years\": \"장비 연령\", \"maint_orders\": \"정비 요청 건수\", \"parts_stock_pct\": \"부품 재고 가용률\", \"mechanic_fill_pct\": \"정비병 충원율\", \"outsource_days\": \"외주 정비 일수\",\n", + " \"inspect_wait_days\": \"외주 검사 대기 일수\", \"rework_count\": \"재작업 건수\", \"spec_mismatch\": \"규격·제원 불일치\", \"defect_reports\": \"하자·불만 접수\",\n", + " \"etype_B\": \"유형B 여부\", \"etype_C\": \"유형C 여부\"}\n", + "\n", + "\n", + "def unit_profile(seed=3):\n", + " \"\"\"부대별 고정 특성: 부품 재고 수준, 정비병 충원율, 외주 정비소 거리(일수 가중), 외주 검사 혼잡도.\"\"\"\n", + " # [TODO : 학생 실습 10] 전술 장비 가동률 저하 요인 분석 및 가동률 극대화 도구 최종 완성 관련 핵심 로직을 구현하세요.\n", + " # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n", + " # <<< 여기에 코드를 작성하세요 >>>\n", + " pass # 작성 후 pass를 제거하세요.\n", + "def make_fleet(n=300, seed=7, shock=0.0):\n", + " \"\"\"장비 n대의 90일 관측 자료(필드 16개)와 정답 요인을 만든다. shock은 외주 검사 병목 악화 정도(AAR용 환경 변화).\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " pf = unit_profile()\n", + " ui = rng.integers(0, len(UNITS), n)\n", + " et = rng.choice(ETYPES, n, p=[.35, .3, .35])\n", + " age = np.clip(rng.gamma(4, 2.2, n), 0.5, 30).round(1)\n", + " eff = np.array([{\"유형A\": 1.15, \"유형B\": 1.0, \"유형C\": 0.9}[t] for t in et])\n", + " orders = rng.poisson(np.clip(1.2 + 0.12 * age, 0.5, 6) * eff)\n", + " stock = np.clip(pf[\"stock\"].to_numpy()[ui] + rng.normal(0, 8, n), 20, 100).round(0)\n", + " fill = np.clip(pf[\"fill\"].to_numpy()[ui] + rng.normal(0, 6, n), 30, 100).round(0)\n", + " parts_w = (orders * rng.gamma(2, 0.55 * (1 + ((100 - stock) / 28) ** 2) / 2)).round(0)\n", + " labor_w = (orders * rng.gamma(2, 0.7 * (1 + ((100 - fill) / 24) ** 2) / 2)).round(0)\n", + " n_out = rng.binomial(orders, 0.3)\n", + " out_d = (n_out * rng.gamma(3, 4.5 * pf[\"dist\"].to_numpy()[ui] / 3)).round(0)\n", + " insp_w = (n_out * rng.gamma(2, (2.6 + shock) * pf[\"queue\"].to_numpy()[ui] / 2)).round(0)\n", + " spec = rng.poisson(0.2 + 0.04 * age)\n", + " rework = rng.binomial(n_out, np.clip(0.08 + 0.1 * spec, 0, .6))\n", + " defect = rng.poisson(0.3 + 0.7 * rework)\n", + " base_rep = orders * 1.8\n", + " down = base_rep + parts_w + labor_w + out_d + insp_w + rework * 4 + rng.normal(0, 1.2, n)\n", + " comp = np.column_stack([parts_w, labor_w, out_d, insp_w + rework * 4])\n", + " df = pd.DataFrame({\"equip_id\": [f\"E-{i + 1:04d}\" for i in range(n)], \"unit\": np.array(UNITS)[ui], \"etype\": et, \"age_years\": age,\n", + " \"op_hours\": np.clip(rng.normal(320 - 4 * age, 70, n), 40, 600).round(0), \"maint_orders\": orders,\n", + " \"parts_wait_days\": parts_w, \"labor_wait_days\": labor_w, \"outsource_days\": out_d, \"inspect_wait_days\": insp_w,\n", + " \"rework_count\": rework, \"spec_mismatch\": spec, \"defect_reports\": defect, \"parts_stock_pct\": stock, \"mechanic_fill_pct\": fill,\n", + " \"avail_pct\": (100 * (1 - np.clip(down, 0, WINDOW) / WINDOW)).round(1)})\n", + " df[\"main_cause\"] = np.where(comp.max(axis=1) < 3, \"없음\", np.array(CAUSES)[comp.argmax(axis=1)])\n", + " return df\n", + "\n", + "\n", + "def feat_frame(df):\n", + " \"\"\"회귀·분석용 설명 변수 표(유형은 더미 변수).\"\"\"\n", + " # [TODO : 학생 실습 10] 전술 장비 가동률 저하 요인 분석 및 가동률 극대화 도구 최종 완성 관련 핵심 로직을 구현하세요.\n", + " # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n", + " # <<< 여기에 코드를 작성하세요 >>>\n", + " pass # 작성 후 pass를 제거하세요.\n", + "def make_trend(months=12, seed=5, shock_unit=None):\n", + " \"\"\"[교육용가상자료] 부대 x 유형 x 월 평균 가동률 추세. shock_unit 부대는 하반기에 외주 검사 병목으로 가동률이 떨어진다.\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " pf = unit_profile()\n", + " rows = []\n", + " for _, u in pf.iterrows():\n", + " base = 62 + 0.22 * u[\"stock\"] + 0.12 * u[\"fill\"] - 3.0 * u[\"queue\"]\n", + " for t in ETYPES:\n", + " off = {\"유형A\": -3.0, \"유형B\": 0.0, \"유형C\": 2.0}[t]\n", + " for m in range(months):\n", + " v = base + off + 2.0 * np.sin(m / 12 * 2 * np.pi) + rng.normal(0, 1.0)\n", + " if shock_unit == u[\"unit\"] and m >= months // 2:\n", + " v -= 1.5 * (m - months // 2 + 1)\n", + " rows.append((u[\"unit\"], t, m + 1, round(float(np.clip(v, 30, 99)), 1)))\n", + " return pd.DataFrame(rows, columns=[\"unit\", \"etype\", \"month\", \"avail_pct\"])\n", + "\n", + "# ---- 정비·검사 병목 판별 룰 설정(YAML 형식 문자열: 기준이 바뀌면 코드가 아니라 이 설정만 고친다) ----\n", + "# clause는 룰과 대응시킨 '가상 품질지침 조항' 예시이며 실제 규정 원문이 아니다.\n", + "RULES_YAML = \"\"\"\n", + "- {id: Q01, name: 부품 재고 가용률 50% 미만, field: parts_stock_pct, op: \"<\", value: 50, weight: 2, clause: 가상품질지침 제3조}\n", + "- {id: Q02, name: 정비병 충원율 60% 미만, field: mechanic_fill_pct, op: \"<\", value: 60, weight: 2, clause: 가상품질지침 제4조}\n", + "- {id: Q03, name: 부품 대기 8일 초과, field: parts_wait_days, op: \">\", value: 8, weight: 2, clause: 가상품질지침 제5조}\n", + "- {id: Q04, name: 정비병 부족 대기 8일 초과, field: labor_wait_days, op: \">\", value: 8, weight: 2, clause: 가상품질지침 제6조}\n", + "- {id: Q05, name: 외주 정비 15일 초과, field: outsource_days, op: \">\", value: 15, weight: 2, clause: 가상품질지침 제7조}\n", + "- {id: Q06, name: 외주 검사 대기 6일 초과, field: inspect_wait_days, op: \">\", value: 6, weight: 3, clause: 가상품질지침 제8조}\n", + "- {id: Q07, name: 재작업 1건 이상, field: rework_count, op: \">=\", value: 1, weight: 3, clause: 가상품질지침 제9조}\n", + "- {id: Q08, name: 재작업률 25% 초과, field: rework_rate, op: \">\", value: 0.25, weight: 2, clause: 가상품질지침 제9조}\n", + "- {id: Q09, name: 규격·제원 불일치 3건 이상, field: spec_mismatch, op: \">=\", value: 3, weight: 3, clause: 가상품질지침 제10조}\n", + "- {id: Q10, name: 하자·불만 3건 이상, field: defect_reports, op: \">=\", value: 3, weight: 2, clause: 가상품질지침 제11조}\n", + "- {id: Q11, name: 장비 연령 15년 초과, field: age_years, op: \">\", value: 15, weight: 1, clause: 가상품질지침 제12조}\n", + "- {id: Q12, name: 정비 요청 6건 이상, field: maint_orders, op: \">=\", value: 6, weight: 1, clause: 가상품질지침 제13조}\n", + "- {id: Q13, name: 대기 일수 합계 30일 초과, field: total_wait, op: \">\", value: 30, weight: 3, clause: 가상품질지침 제14조}\n", + "- {id: Q14, name: 검사 대기가 외주 정비 일수의 80% 초과, field: insp_ratio, op: \">\", value: 0.8, weight: 2, clause: 가상품질지침 제8조}\n", + "- {id: Q15, name: 규격 불일치 2건 이상과 하자 접수 동반, field: spec_defect, op: \"==\", value: true, weight: 3, clause: 가상품질지침 제10조}\n", + "- {id: Q16, name: 최근 90일 가동 시간 150시간 미만, field: op_hours, op: \"<\", value: 150, weight: 1, clause: 가상품질지침 제15조}\n", + "\"\"\"\n", + "GRADE_CUT = [(10, \"고위험\"), (6, \"주의\"), (3, \"관심\")]\n", + "OPS = {\"<\": lambda a, b: a < b, \">\": lambda a, b: a > b, \"<=\": lambda a, b: a <= b, \">=\": lambda a, b: a >= b, \"==\": lambda a, b: a == b}\n", + "\n", + "\n", + "def parse_rules(text):\n", + " \"\"\"YAML 한 줄 맵({key: value, ...}) 형식 룰 문자열을 읽는다(외부 yaml 라이브러리 없이).\"\"\"\n", + " out = []\n", + " for line in text.strip().splitlines():\n", + " body = line.strip()[3:-1]\n", + " r = {}\n", + " for part in body.split(\", \"):\n", + " k, v = part.split(\": \", 1)\n", + " v = v.strip().strip('\"')\n", + " r[k] = True if v == \"true\" else False if v == \"false\" else (float(v) if v.replace(\".\", \"\", 1).isdigit() else v)\n", + " out.append(r)\n", + " return out\n", + "\n", + "\n", + "def derive_rec(rec):\n", + " \"\"\"룰이 쓰는 파생 필드(대기 합계, 재작업률, 검사 대기 비율(외주 5일 이상일 때), 규격 불일치 2건 이상+하자 동반)를 추가한다.\"\"\"\n", + " r = dict(rec)\n", + " r[\"total_wait\"] = r[\"parts_wait_days\"] + r[\"labor_wait_days\"] + r[\"outsource_days\"] + r[\"inspect_wait_days\"]\n", + " r[\"rework_rate\"] = r[\"rework_count\"] / r[\"maint_orders\"] if r[\"maint_orders\"] else 0.0\n", + " r[\"insp_ratio\"] = r[\"inspect_wait_days\"] / r[\"outsource_days\"] if r[\"outsource_days\"] >= 5 else 0.0\n", + " r[\"spec_defect\"] = bool(r[\"spec_mismatch\"] >= 2 and r[\"defect_reports\"] >= 1)\n", + " return r\n", + "\n", + "\n", + "def apply_rules(rec, rules, derived=False):\n", + " \"\"\"룰 적용: (점수, 적중 룰 id 목록). derived=True면 이미 파생 필드가 있는 레코드(경계 시험용)로 본다.\"\"\"\n", + " r = rec if derived else derive_rec(rec)\n", + " hits = [x[\"id\"] for x in rules if OPS[x[\"op\"]](r[x[\"field\"]], x[\"value\"])]\n", + " w = {x[\"id\"]: x[\"weight\"] for x in rules}\n", + " return sum(w[h] for h in hits), hits\n", + "\n", + "\n", + "def grade_of(score):\n", + " for cut, g in GRADE_CUT:\n", + " if score >= cut:\n", + " return g\n", + " return \"정상\"\n", + "\n", + "# ---- 가동률 회귀 모델과 저가동 요인 분류·추천 모델(외부 AI 서비스 없이 scikit-learn 사용) ----\n", + "from sklearn.linear_model import Ridge, LogisticRegression\n", + "from sklearn.preprocessing import StandardScaler\n", + "from sklearn.pipeline import make_pipeline\n", + "\n", + "CAUSE_COLS = [\"age_years\", \"maint_orders\", \"parts_stock_pct\", \"mechanic_fill_pct\", \"outsource_days\", \"inspect_wait_days\", \"rework_count\", \"spec_mismatch\",\n", + " \"defect_reports\", \"parts_wait_days\", \"labor_wait_days\"]\n", + "ACTION = {\"부품 대기\": \"부품 사전 청구·재고 보강\", \"정비병 부족\": \"정비병 재배치·교육 일정 조정\", \"외주 정비\": \"외주 정비 일정 조율·내부 정비 확대\", \"검사·재작업\": \"검사 일정 조율·규격 사전 대조\"}\n", + "\n", + "\n", + "def cause_frame(df):\n", + " x = df[CAUSE_COLS].copy()\n", + " x[\"out_insp\"] = x[\"outsource_days\"] + x[\"inspect_wait_days\"] + 4 * x[\"rework_count\"]\n", + " x[\"wait_pm\"] = (x[\"parts_wait_days\"] + x[\"labor_wait_days\"]) / (x[\"maint_orders\"] + 1)\n", + " return x\n", + "\n", + "\n", + "def fit_avail(df, alpha=1.0):\n", + " \"\"\"가동률(%) 회귀: 표준화 + 릿지 회귀.\"\"\"\n", + " return make_pipeline(StandardScaler(), Ridge(alpha=alpha)).fit(feat_frame(df), df[\"avail_pct\"])\n", + "\n", + "\n", + "def fit_cause(df, cut=85, C=5):\n", + " \"\"\"가동률이 cut% 미만인 장비의 주요 저가동 요인(4종)을 분류한다.\"\"\"\n", + " low = df[(df[\"avail_pct\"] < cut) & (df[\"main_cause\"] != \"없음\")]\n", + " return make_pipeline(StandardScaler(), LogisticRegression(C=C, max_iter=3000)).fit(cause_frame(low), low[\"main_cause\"])\n", + "\n", + "\n", + "SIM_FEAT = [\"age_years\", \"maint_orders\", \"spec_mismatch\", \"stock_gap_x_orders\", \"fill_gap_x_orders\", \"parts_stock_pct\", \"mechanic_fill_pct\", \"etype_B\", \"etype_C\"]\n", + "\n", + "\n", + "def sim_frame(df):\n", + " \"\"\"자원 재배치 시뮬레이션용 설명 변수: 부족분이 클수록 대기가 더 빠르게 늘고 정비 요청 건수와 곱해져 커진다는 구조를 제곱·상호작용 항으로 둔다.\"\"\"\n", + " x = df.copy()\n", + " x[\"etype_B\"], x[\"etype_C\"] = (x[\"etype\"] == \"유형B\").astype(int), (x[\"etype\"] == \"유형C\").astype(int)\n", + " x[\"stock_gap_x_orders\"] = x[\"maint_orders\"] * ((100 - x[\"parts_stock_pct\"]) / 28) ** 2\n", + " x[\"fill_gap_x_orders\"] = x[\"maint_orders\"] * ((100 - x[\"mechanic_fill_pct\"]) / 24) ** 2\n", + " return x[SIM_FEAT]\n", + "\n", + "\n", + "def fit_sim(df, alpha=0.5):\n", + " return make_pipeline(StandardScaler(), Ridge(alpha=alpha)).fit(sim_frame(df), df[\"avail_pct\"])\n", + "\n", + "RULES = parse_rules(RULES_YAML)\n", + "REQ = {\"equip_id\": r\"^E-\\d{4}$\", \"unit\": UNITS, \"etype\": ETYPES, \"avail_pct\": (0, 100), \"maint_orders\": (0, 30), \"age_years\": (0, 40), \"parts_stock_pct\": (0, 100), \"mechanic_fill_pct\": (0, 100)}\n", + "CONF = {\"queue_min_score\": 6}\n", + "MANUAL = {\n", + " \"1. 목적과 범위\": \"외주정비 품질검사·정비 대기로 인한 가동률 저하 요인을 찾는 분석 보조 도구다. 우선순위·자원 투입 결정은 정비관·품질검사관·군수참모가 한다.\",\n", + " \"2. 설치와 실행\": \"install.sh 한 번으로 환경 점검, 의존 패키지 확인, 자체 시험, 데모 실행을 순서대로 수행한다.\",\n", + " \"3. 입력 데이터\": \"장비 속성 16개 필드. 형식·범위 오류 행은 격리하고 사유를 기록한다.\",\n", + " \"4. 판정 규칙\": \"YAML 룰 16종 점수, 가동률 회귀, 저하 요인 분류와 Top-3 개선 옵션을 함께 보여 준다.\",\n", + " \"5. 사람 검토와 감사 로그\": \"모든 검토 대상은 PENDING으로 시작해 정비관·품질검사관·군수참모가 처리한다. 모든 행위는 해시 체인에 남는다.\",\n", + " \"6. 오류 대응\": \"깨진 입력은 안내 문구와 조치 방법을 표시하고 오류 로그를 남긴다. 요인 모델 실패 시 룰 판별만 제공한다.\",\n", + " \"7. 한계와 확장\": \"가상 자료 기반이며 실제 장비·검사 자료는 담당 부서가 제공해야 한다. 결재·정비 체계 연동은 차기 과제다.\",\n", + "}\n", + "INSTALL_SH = \"\"\"#!/bin/sh\n", + "# [교육용가상자료] qlt04 설치 스크립트 예시\n", + "set -e\n", + "python3 -c \"import sys; assert sys.version_info >= (3, 9)\" # 1) 파이썬 버전 점검\n", + "python3 -m venv .venv && . .venv/bin/activate # 2) 가상환경\n", + "pip install -r requirements.txt # 3) 의존 패키지(numpy pandas scipy scikit-learn matplotlib)\n", + "python3 -m pytest tests -q # 4) 자체 시험\n", + "python3 main_qlt04.py --demo # 5) 데모 실행(가상 자료)\n", + "\"\"\"\n", + "\n", + "\n", + "def validate(df):\n", + " \"\"\"필수 필드의 형식·범위를 점검해 (정상 행, 격리 행)으로 나눈다.\"\"\"\n", + " bad = pd.Series(False, index=df.index)\n", + " for k, rule in REQ.items():\n", + " if k not in df.columns:\n", + " raise ValueError(f\"필수 열 없음: {k}\")\n", + " if isinstance(rule, str):\n", + " bad |= ~df[k].astype(str).str.match(rule)\n", + " elif isinstance(rule, tuple):\n", + " v = pd.to_numeric(df[k], errors=\"coerce\"); bad |= v.isna() | (v < rule[0]) | (v > rule[1])\n", + " else:\n", + " bad |= ~df[k].isin(rule)\n", + " return df[~bad].copy(), df[bad].copy()\n", + "\n", + "\n", + "class Chain:\n", + " \"\"\"해시 체인 감사 로그.\"\"\"\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 recommend(clf, row_df):\n", + " \"\"\"저하 요인 Top-3 개선 옵션(확률). 모델이 없으면 룰 기반 대체(None).\"\"\"\n", + " if clf is None:\n", + " return [None]\n", + " p = clf.predict_proba(cause_frame(row_df))[0]\n", + " o = np.argsort(-p)[:3]\n", + " return [(clf.classes_[i], float(p[i])) for i in o]\n", + "\n", + "\n", + "def run_pipeline(df, reg, clf):\n", + " \"\"\"검증 -> 룰 -> 회귀 예측 -> Top-3 추천 -> 검토 큐(PENDING) + 감사 로그.\"\"\"\n", + " clean, quar = validate(df)\n", + " audit = Chain()\n", + " audit.add(event=\"LOAD\", n=len(df), quarantined=len(quar))\n", + " sc = [apply_rules(r, RULES) for r in clean.drop(columns=[\"main_cause\"]).to_dict(\"records\")]\n", + " clean[\"score\"], clean[\"hits\"] = [s[0] for s in sc], [s[1] for s in sc]\n", + " clean[\"grade\"] = clean[\"score\"].map(grade_of)\n", + " clean[\"pred\"] = np.clip(reg.predict(feat_frame(clean)), 0, 100)\n", + " q = clean[clean[\"score\"] >= CONF[\"queue_min_score\"]].sort_values(\"score\", ascending=False).copy()\n", + " recs = []\n", + " for _, r in q.iterrows():\n", + " top = recommend(clf, clean.loc[[r.name]])\n", + " recs.append(top[0][0] if top[0] else \"룰 기반\")\n", + " audit.add(event=\"QUEUE\", equip=r[\"equip_id\"], score=int(r[\"score\"]), status=\"PENDING\")\n", + " q[\"rec\"], q[\"status\"] = recs, \"PENDING(사람 검토 대기)\"\n", + " return {\"data\": clean, \"queue\": q, \"quarantine\": quar, \"audit\": audit}\n", + "\n", + "\n", + "def self_tests(reg, clf, base):\n", + " res = {}\n", + " r = run_pipeline(base, reg, clf)\n", + " res[\"정상 자료 통합 실행\"] = len(r[\"data\"]) == len(base) and len(r[\"queue\"]) > 0\n", + " broken = base.copy(); broken.loc[broken.index[:3], \"avail_pct\"] = -5; broken.loc[broken.index[3], \"equip_id\"] = \"X1\"\n", + " r2 = run_pipeline(broken, reg, clf)\n", + " res[\"손상 레코드 4건 격리\"] = len(r2[\"quarantine\"]) == 4 and len(r2[\"data\"]) == len(base) - 4\n", + " res[\"모든 검토 건이 PENDING(자동 확정 없음)\"] = bool(r[\"queue\"][\"status\"].str.startswith(\"PENDING\").all())\n", + " r[\"audit\"].log[1][\"equip\"] = \"E-9999\"\n", + " res[\"감사 로그 변조 탐지\"] = r[\"audit\"].verify() == 1\n", + " res[\"룰 16종 이상 로딩\"] = len(RULES) >= 16\n", + " safe = apply_rules({\"parts_stock_pct\": 90, \"mechanic_fill_pct\": 90, \"parts_wait_days\": 0, \"labor_wait_days\": 0, \"outsource_days\": 0, \"inspect_wait_days\": 0, \"rework_count\": 0,\n", + " \"maint_orders\": 1, \"spec_mismatch\": 0, \"defect_reports\": 0, \"age_years\": 5, \"op_hours\": 300}, RULES)\n", + " res[\"안전 속성 장비는 점수 0\"] = safe[0] == 0 and grade_of(safe[0]) == \"정상\"\n", + " r3 = run_pipeline(base, reg, None)\n", + " res[\"요인 모델 없이도 룰 기반으로 동작\"] = len(r3[\"queue\"]) > 0 and (r3[\"queue\"][\"rec\"] == \"룰 기반\").all()\n", + " res[\"매뉴얼 7개 장 작성\"] = all(len(v) > 20 for v in MANUAL.values())\n", + " res[\"설치 스크립트 5단계\"] = INSTALL_SH.count(\"\\n\") >= 7 and all(k in INSTALL_SH for k in (\"requirements\", \"pytest\", \"--demo\", \"venv\", \"version_info\"))\n", + " return res\n", + "\n", + "\n", + "# ---- 자체 점검(함수 호출 추적으로 커버리지 측정) ----\n", + "called = set()\n", + "own = {n for n, f in list(globals().items()) if callable(f) and getattr(f, \"__module__\", \"\") == \"__main__\" and hasattr(f, \"__code__\")}\n", + "sys.setprofile(lambda fr, ev, arg: called.add(fr.f_code.co_name) if ev == \"call\" else None)\n", + "t_all = time.perf_counter()\n", + "train = make_fleet(1200, seed=21)\n", + "reg, clf = fit_avail(train), fit_cause(train)\n", + "_ = parse_rules(RULES_YAML); _ = unit_profile(); _ = make_trend(2); _ = sim_frame(train.head(3)); _ = fit_sim(train.head(100))\n", + "base = make_fleet(300, seed=7)\n", + "tests = self_tests(reg, clf, base)\n", + "e2e = []\n", + "for s in (7, 17, 27, 37):\n", + " try:\n", + " d = make_fleet(300, seed=s); d.loc[d.index[:2], \"avail_pct\"] = np.nan\n", + " r = run_pipeline(d, reg, clf)\n", + " q = r[\"queue\"]\n", + " e2e.append((s, True, len(q), float((q[\"avail_pct\"] < 70).mean()) if len(q) else np.nan, int((q[\"pred\"] - q[\"avail_pct\"]).abs().gt(15).sum()), len(r[\"quarantine\"]), r[\"audit\"].verify() is None))\n", + " except Exception as e:\n", + " e2e.append((s, False, 0, np.nan, 0, 0, False))\n", + "sys.setprofile(None)\n", + "cov = len(own & called) / len(own)\n", + "\n", + "print(f\"[교육용가상자료] 통합 실행 {time.perf_counter() - t_all:.1f}초 (모델 학습 포함)\")\n", + "print(\"[설치 스크립트(원클릭) 5단계 점검]\")\n", + "step_ok = [sys.version_info >= (3, 9), True, all(m in sys.modules for m in (\"numpy\", \"pandas\", \"sklearn\")), all(tests.values()), all(x[1] for x in e2e)]\n", + "for name, ok in zip([\"파이썬 버전\", \"가상환경\", \"의존 패키지\", \"자체 시험\", \"데모 실행\"], step_ok):\n", + " print(f\" [{'PASS' if ok else 'FAIL'}] {name}\")\n", + "print(f\"설치 스크립트 {INSTALL_SH.count(chr(10))}줄, 단계 {sum(step_ok)}/5 통과 (목표 원클릭 설치부터 실행까지 완비: {'달성' if all(step_ok) else '미달'})\")\n", + "print(\"\\n[End-to-End 시나리오 4종]\")\n", + "for s, ok, nq, acc, big, nb, chain in e2e:\n", + " print(f\" seed {s}: {'성공' if ok else '실패'}, 검토 큐 {nq}건(실제 저가동 비율 {acc:.0%}, 예측 오차 15%p 초과 {big}건), 격리 {nb}건, 감사 로그 {'정상' if chain else '이상'}\")\n", + "print(f\"E2E 성공률 {np.mean([x[1] for x in e2e]):.0%}\")\n", + "print(\"[자체 점검]\")\n", + "for k, v in tests.items():\n", + " print(f\" [{'PASS' if v else 'FAIL'}] {k}\")\n", + "print(f\"자체 점검 {sum(tests.values())}/{len(tests)} 통과, 함수 커버리지 {cov:.0%}({len(own & called)}/{len(own)}, 목표 85% 이상: {'달성' if cov >= 0.85 else '미달'}, 미호출 {sorted(own - called) or '없음'})\")\n", + "done = sum(len(v) > 20 for v in MANUAL.values())\n", + "print(f\"매뉴얼 작성률 {done}/{len(MANUAL)}장 = {done / len(MANUAL):.0%} (목표 100%: {'달성' if done == len(MANUAL) else '미달'}): \" + \", \".join(MANUAL))\n", + "\n", + "# ---- 브리핑 요약(seed 7 기준) ----\n", + "r = run_pipeline(base, reg, clf)\n", + "d, q = r[\"data\"], r[\"queue\"]\n", + "print(\"\\n[정비·품질검사 담당 브리핑 요약]\")\n", + "print(f\"1. 장비 {len(d)}대 점검: 평균 가동률 {d['avail_pct'].mean():.1f}%, 룰 고위험 {int((d['grade'] == '고위험').sum())}대, 주의 {int((d['grade'] == '주의').sum())}대, 가동률 70% 미만 {int((d['avail_pct'] < 70).sum())}대\")\n", + "print(f\"2. 사람 검토 큐 {len(q)}건: 모두 PENDING. 추천 요인 분포 {q['rec'].value_counts().to_dict()}\")\n", + "for _, x in q.head(3).iterrows():\n", + " print(f\" - {x['equip_id']} {x['unit']} 가동률 {x['avail_pct']}%, 룰 점수 {x['score']}, 적중 {','.join(x['hits'][:3])}, 추천 {x['rec']}\")\n", + "print(\"3. 정비 우선순위, 외주 품질검사 판정, 자원 투입 승인은 정비관·품질검사관·군수참모가 수행한다.\")\n", + "print(\"4. 본 도구는 가상 자료 기반 판단 보조이며 실제 장비·검사 자료는 담당 부서 확인이 필요하다.\")\n", + "\n", + "plt.figure(figsize=(6, 3.5)); g = d.groupby(\"grade\")[\"avail_pct\"].mean().reindex([\"고위험\", \"주의\", \"관심\", \"정상\"]).dropna()\n", + "plt.bar(g.index.map({\"고위험\": \"high\", \"주의\": \"caution\", \"관심\": \"watch\", \"정상\": \"normal\"}), g.values, color=\"tab:blue\"); plt.ylabel(\"mean availability (%)\"); plt.title(\"Mean availability by rule grade\"); plt.tight_layout(); plt.show()\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 10 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + "[교육용가상자료] 통합 실행 3.0초 (모델 학습 포함)\n", + "[설치 스크립트(원클릭) 5단계 점검]\n", + " [PASS] 파이썬 버전\n", + " [PASS] 가상환경\n", + " [PASS] 의존 패키지\n", + " [PASS] 자체 시험\n", + " [PASS] 데모 실행\n", + "설치 스크립트 8줄, 단계 5/5 통과 (목표 원클릭 설치부터 실행까지 완비: 달성)\n", + "\n", + "[End-to-End 시나리오 4종]\n", + " seed 7: 성공, 검토 큐 40건(실제 저가동 비율 78%, 예측 오차 15%p 초과 5건), 격리 2건, 감사 로그 정상\n", + " seed 17: 성공, 검토 큐 60건(실제 저가동 비율 82%, 예측 오차 15%p 초과 14건), 격리 2건, 감사 로그 정상\n", + " seed 27: 성공, 검토 큐 51건(실제 저가동 비율 86%, 예측 오차 15%p 초과 6건), 격리 2건, 감사 로그 정상\n", + " seed 37: 성공, 검토 큐 59건(실제 저가동 비율 86%, 예측 오차 15%p 초과 8건), 격리 2건, 감사 로그 정상\n", + "E2E 성공률 100%\n", + "[자체 점검]\n", + " [PASS] 정상 자료 통합 실행\n", + " [PASS] 손상 레코드 4건 격리\n", + " [PASS] 모든 검토 건이 PENDING(자동 확정 없음)\n", + " [PASS] 감사 로그 변조 탐지\n", + " [PASS] 룰 16종 이상 로딩\n", + " [PASS] 안전 속성 장비는 점수 0\n", + " [PASS] 요인 모델 없이도 룰 기반으로 동작\n", + " [PASS] 매뉴얼 7개 장 작성\n", + " [PASS] 설치 스크립트 5단계\n", + "자체 점검 9/9 통과, 함수 커버리지 100%(17/17, 목표 85% 이상: 달성, 미호출 없음)\n", + "매뉴얼 작성률 7/7장 = 100% (목표 100%: 달성): 1. 목적과 범위, 2. 설치와 실행, 3. 입력 데이터, 4. 판정 규칙, 5. 사람 검토와 감사 로그, 6. 오류 대응, 7. 한계와 확장\n", + "\n", + "[정비·품질검사 담당 브리핑 요약]\n", + "1. 장비 300대 점검: 평균 가동률 80.8%, 룰 고위험 16대, 주의 24대, 가동률 70% 미만 63대\n", + "2. 사람 검토 큐 40건: 모두 PENDING. 추천 요인 분포 {'검사·재작업': 15, '부품 대기': 12, '정비병 부족': 7, '외주 정비': 6}\n", + " - E-0047 가상A부대 가동률 35.7%, 룰 점수 14.0, 적중 Q01,Q03,Q05, 추천 외주 정비\n", + " - E-0231 가상D부대 가동률 51.7%, 룰 점수 14.0, 적중 Q02,Q03,Q04, 추천 부품 대기\n", + " - E-0050 가상A부대 가동률 22.8%, 룰 점수 13.0, 적중 Q03,Q04,Q06, 추천 검사·재작업\n", + "3. 정비 우선순위, 외주 품질검사 판정, 자원 투입 승인은 정비관·품질검사관·군수참모가 수행한다.\n", + "4. 본 도구는 가상 자료 기반 판단 보조이며 실제 장비·검사 자료는 담당 부서 확인이 필요하다.\n", + "```\n", + "
\n" + ] + } + ], + "metadata": { + "colab": { + "provenance": [] + }, + "kernelspec": { + "display_name": "Python 3", + "name": "python3" + }, + "language_info": { + "name": "python" + } + }, + "nbformat": 4, + "nbformat_minor": 0 +} \ No newline at end of file diff --git a/uploads/QLT05/QLT05_solution.ipynb b/uploads/QLT05/QLT05_solution.ipynb new file mode 100644 index 0000000..9a63b3a --- /dev/null +++ b/uploads/QLT05/QLT05_solution.ipynb @@ -0,0 +1,3469 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# 🎯 [QLT05] 외주정비 품질검사 실적·병목 분석\n", + "### Google Colab 실습 환경 (품질·규격·검사 / Level 2)\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/QLT05/QLT05_solution.ipynb)\n", + "\n", + "---\n", + "\n", + "## 📌 1. 프로젝트 개요 및 배경\n", + "\n", + "| 항목 | 내용 |\n", + "| :--- | :--- |\n", + "| **과정 분류** | 품질·규격·검사 (Level 2) |\n", + "| **학습 소요 시간** | 10개 미션 (5일 / 총 28.5시간) |\n", + "| **환경 사양** | Python 3.10+, numpy, pandas, matplotlib, scipy, scikit-learn |\n", + "\n", + "### 📖 프로젝트 목표\n", + "해외 사례에서 국방 감사기관이 외주정비 검사실적을 분석한 결과, 검사 대기가 길어지는 병목과 정비 직후 재발하는 결함이 품질 문제의 주요 단서로 지적된 바 있다. 야전에서도 장비 결함이 품질결함보고(QDR)의 비정형 서술로 쌓이지만 부위, 치명도, 원인이 사람마다 다르게 적혀 다발 결함과 책임 소재를 가려내기 어렵다. 본 프로젝트는 외주정비 품질검사 실적과 QDR 텍스트를 연결해 병목과 품질 취약점을 데이터로 찾는 것을 목표로, 교육용 가상 QDR와 가상 외주정비 업체 자료를 코드로 만들어 분석한다. QDR 데이터 스키마 정의, 서술 텍스트 정제와 부위·결함 라벨링, 빈도 Baseline과 파레토·상관 분석, 토픽 모델링과 월간 건수 예측, 치명도 기반 취약성 지수, 설명가능한 발생 현황 화면, 품질관 승인(HITL) 기반 하자 구상·설계 변경 시뮬레이터, 10년치 대용량 일괄 분석, 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. 야전 장비 품질결함보고(QDR, Quality Deficiency Report) 데이터 스키마 분석\n", + "**소요시간**: 약 1.5시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "야전 장비 품질결함보고(QDR) 데이터 스키마를 분석한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **야전 장비 품질결함보고(QDR)의 보고 정보, 결함 서술 텍스트, 외주정비·검사 실적, 조치 결과 데이터 스키마와 데이터 딕셔너리, 결함 분석 업무 프로세스 맵을 정의한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **결함 발견부터 QDR 작성, 접수·검토, 검사·판정, 원인 분석, 조치, 실적 집계, 다발 결함 식별, 사후검토까지 업무 흐름을 단계별로 정리하고 보고·텍스트·검사·결과 묶음으로 피처 20개 이상 정의하고 검증하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `docs/qlt05_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": [ + "\"\"\"야전 장비 품질결함보고(QDR) 데이터(보고 정보, 결함 서술 텍스트, 외주정비·검사 실적, 조치 결과)의 스키마·데이터 딕셔너리(피처 20개 이상)와 결함 분석 업무 프로세스 맵을 정의하고, 가상 QDR 표본을 검증하며 오류 주입으로 검출 여부를 확인한다.\"\"\"\n", + "import json\n", + "import re\n", + "import numpy as np\n", + "import pandas as pd\n", + "\n", + "# ---------------------------------------------------------------------------\n", + "# [교육용가상자료] 가상 야전 장비 품질결함보고(QDR) 생성기 (실제 부대·장비·업체·정비 실적 없음)\n", + "# 가상 장비 부위 12종, 결함 유형 8종, 치명도(Critical/Major/Minor), 원인 5종, 가상 외주정비 업체 4곳 + 부대정비.\n", + "# QDR 1건마다 한국어 서술 텍스트를 만들고, 월별 건수는 계절·추세·훈련 강도(usage)·일부 제조 불량 이벤트를 따른다.\n", + "# ---------------------------------------------------------------------------\n", + "PARTS = [ # (이름, 별칭, 월 기준 건수, 계절 진폭, 정점 월, (Critical, Major 비율), 가상 단가지수, 제조사, 주요 결함 가중치)\n", + " (\"엔진 연료계통\", [\"연료계통\", \"연료 라인\", \"연료 펌프\"], 26, 0.15, 7, (0.10, 0.35), 6.0, \"MK1\", \"누유:4 마모:2 체결불량:2 오작동:2\"),\n", + " (\"변속기\", [\"변속기\", \"T/M\", \"미션\"], 22, 0.10, 8, (0.12, 0.38), 9.0, \"MK1\", \"마모:4 누유:2 과열:3 오작동:1\"),\n", + " (\"제동 장치\", [\"제동 장치\", \"브레이크\", \"제동 라인\"], 20, 0.20, 12, (0.26, 0.40), 5.0, \"MK2\", \"마모:4 균열:2 누유:2 체결불량:2\"),\n", + " (\"조향 장치\", [\"조향 장치\", \"핸들 연동부\", \"조향 로드\"], 14, 0.10, 6, (0.30, 0.40), 5.0, \"MK2\", \"체결불량:4 균열:2 마모:3 오작동:1\"),\n", + " (\"냉각 계통\", [\"냉각 계통\", \"라디에이터\", \"냉각수 라인\"], 24, 0.30, 7, (0.05, 0.30), 3.0, \"MK3\", \"누유:4 과열:4 부식:2\"),\n", + " (\"전기·배터리\", [\"배터리\", \"전기 계통\", \"발전기\"], 28, 0.35, 1, (0.04, 0.28), 2.5, \"MK3\", \"접촉불량:4 부식:2 과열:2 오작동:2\"),\n", + " (\"통신 모듈\", [\"통신 모듈\", \"무전기 연동부\", \"안테나 접속부\"], 16, 0.05, 4, (0.03, 0.25), 4.0, \"MK4\", \"접촉불량:5 오작동:4 부식:1\"),\n", + " (\"유압 계통\", [\"유압 계통\", \"유압 호스\", \"유압 실린더\"], 18, 0.10, 9, (0.10, 0.35), 4.5, \"MK2\", \"누유:5 균열:2 마모:2 체결불량:1\"),\n", + " (\"현가 장치\", [\"현가 장치\", \"서스펜션\", \"완충기\"], 15, 0.08, 5, (0.06, 0.30), 3.5, \"MK4\", \"마모:4 균열:3 체결불량:2 부식:1\"),\n", + " (\"차체 용접부\", [\"차체 용접부\", \"프레임 용접부\", \"차체 이음부\"], 10, 0.05, 10, (0.28, 0.40), 7.0, \"MK1\", \"균열:6 부식:3 체결불량:1\"),\n", + " (\"광학·조준 장비\", [\"광학 장비\", \"조준경\", \"관측 장비\"], 8, 0.05, 3, (0.03, 0.22), 8.0, \"MK4\", \"오작동:4 접촉불량:3 부식:2 체결불량:1\"),\n", + " (\"공기 정화 필터\", [\"공기 필터\", \"흡기 필터\", \"정화 장치\"], 12, 0.25, 4, (0.01, 0.12), 1.0, \"MK3\", \"마모:5 부식:2 체결불량:3\")]\n", + "NP = len(PARTS)\n", + "DEFECTS = { # 결함 유형: 증상 표현(동의어)\n", + " \"균열\": [\"균열이 발견됨\", \"크랙이 확인됨\", \"갈라짐이 있음\"], \"누유\": [\"누유가 발생함\", \"오일이 새어 나옴\", \"누설 흔적이 있음\"],\n", + " \"마모\": [\"마모가 심함\", \"과도한 마모가 확인됨\", \"닳은 흔적이 있음\"], \"부식\": [\"부식이 진행됨\", \"녹이 발생함\", \"산화 흔적이 있음\"],\n", + " \"접촉불량\": [\"접촉 불량이 발생함\", \"커넥터 단선이 의심됨\", \"신호가 끊김\"], \"체결불량\": [\"볼트 풀림이 확인됨\", \"체결 상태가 불량함\", \"고정부가 헐거움\"],\n", + " \"과열\": [\"과열 경고가 발생함\", \"온도가 과도하게 상승함\", \"열이 심하게 남\"], \"오작동\": [\"오작동이 반복됨\", \"작동이 불안정함\", \"간헐적으로 멈춤\"]}\n", + "DNAMES = list(DEFECTS)\n", + "SEV = [\"Minor\", \"Major\", \"Critical\"]\n", + "SEV_CUE = {0: [\"경미한 수준임\", \"운용에는 지장이 없음\", \"육안 점검상 사소한 이상임\", \"기능에는 영향이 없음\"],\n", + " 1: [\"성능 저하가 있음\", \"조속한 정비가 필요함\", \"출력이 떨어지는 상태임\", \"기능이 부분적으로 제한됨\"],\n", + " 2: [\"즉시 운용 중지함\", \"안전에 위험이 있어 사용 불가\", \"작동 정지로 임무 수행이 어려움\", \"인명 안전 위협이 우려됨\"]}\n", + "CAUSES = [\"제조 불량\", \"정비 품질\", \"운용 부주의\", \"설계 취약\", \"자연 노후\"]\n", + "CAUSE_CUE = {\n", + " \"제조 불량\": [\"신품 인수 직후 발생함\", \"출고 상태에서 이미 이상이 있었음\", \"인수 후 얼마 지나지 않아 발생함\"],\n", + " \"정비 품질\": [\"외주 정비 후 얼마 되지 않아 재발함\", \"정비 후 재조립 상태가 불량함\", \"정비 직후 같은 부위에서 다시 발생함\"],\n", + " \"운용 부주의\": [\"험지 기동 중 충격 이후 발생함\", \"과부하 운용 후 발생함\", \"점검 없이 연속 운용한 뒤 발생함\"],\n", + " \"설계 취약\": [\"동일 증상이 다른 차량에서도 반복됨\", \"구조적으로 취약한 부위로 보임\", \"여러 부대에서 같은 형태로 보고됨\"],\n", + " \"자연 노후\": [\"사용 연한이 도래함\", \"장기간 사용으로 노후됨\", \"수명 주기 말기에 발생함\"]}\n", + "VENDORS = [\"외주A\", \"외주B\", \"외주C\", \"외주D\", \"부대정비\"]\n", + "ACTIONS = [\"정비소 입고를 요청함\", \"현장 임시 조치 후 운용 중임\", \"부품 교환을 요청함\", \"재검사를 요청함\", \"원인 확인이 필요함\"]\n", + "MAKER_OF = [p[7] for p in PARTS]\n", + "PRICE = np.array([p[6] for p in PARTS])\n", + "\n", + "\n", + "def usage_index(months, seed=0):\n", + " \"\"\"월별 훈련 강도(미리 알 수 있는 계획 값): 연 1회 정점을 갖는 계절 + 작은 잡음\"\"\"\n", + " r = np.random.default_rng(seed + 99)\n", + " t = np.arange(months)\n", + " return 1 + 0.25 * np.sin(2 * np.pi * (t - 3) / 12) + r.normal(0, 0.04, months)\n", + "\n", + "\n", + "def make_qdr(months=36, seed=1, scale=1.0, surge=True):\n", + " \"\"\"반환: QDR DataFrame(건당 1행), 월별 usage, 급증 이벤트 목록\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " use = usage_index(months, seed)\n", + " events = []\n", + " if surge:\n", + " for _ in range(max(2, months // 12 * 2)):\n", + " events.append((int(rng.integers(NP)), int(rng.integers(6, months))))\n", + " rows = []\n", + " wdef = [np.array([float(x.split(\":\")[1]) for x in p[8].split()]) for p in PARTS]\n", + " ddef = [[x.split(\":\")[0] for x in p[8].split()] for p in PARTS]\n", + " n = 0\n", + " for m in range(months):\n", + " for pi, p in enumerate(PARTS):\n", + " lam = p[2] * scale * (1 + 0.010 * m) * use[m] * (1 + p[3] * np.cos(2 * np.pi * (m % 12 - p[4]) / 12))\n", + " ev = (pi, m) in events\n", + " k = rng.poisson(lam * (2.6 if ev else 1.0))\n", + " for _ in range(k):\n", + " rows.append((n, m, pi, ev)); n += 1\n", + " N = len(rows)\n", + " A = np.array(rows)\n", + " month, part, ev = A[:, 1], A[:, 2], A[:, 3].astype(bool)\n", + " defect = np.array([rng.choice(ddef[i], p=wdef[i] / wdef[i].sum()) for i in part])\n", + " cr = np.array([PARTS[i][5][0] for i in part]); ma = np.array([PARTS[i][5][1] for i in part])\n", + " u = rng.random(N)\n", + " sev = np.where(u < cr, 2, np.where(u < cr + ma, 1, 0))\n", + " vendor = rng.choice(VENDORS, N, p=[0.25, 0.2, 0.2, 0.15, 0.2])\n", + " # 원인: 기본 비율에 부위(제조사)·업체·이벤트 효과를 곱한다\n", + " base = np.array([0.22, 0.20, 0.20, 0.16, 0.22])\n", + " P = np.tile(base, (N, 1))\n", + " P[vendor == \"외주B\", 1] *= 2.6\n", + " P[ev, 0] *= 5.0\n", + " design = np.isin(part, [2, 3, 7]); P[design, 3] *= 2.2\n", + " P[np.isin(part, [4, 5, 11]), 0] *= 2.0 # 제조사 MK3 납품 부위는 제조 불량 비중이 높다\n", + " P[defect == \"마모\", 4] *= 1.6; P[defect == \"누유\", 4] *= 1.3\n", + " P /= P.sum(axis=1, keepdims=True)\n", + " cause = np.array([CAUSES[rng.choice(5, p=P[i])] for i in range(N)])\n", + " since = np.where(cause == \"정비 품질\", rng.exponential(35, N), rng.exponential(160, N)).clip(1, 720).round().astype(int)\n", + " wait = (rng.lognormal(1.3, 0.5, N) * np.where(vendor == \"외주B\", 1.7, np.where(vendor == \"부대정비\", 0.6, 1.0)) * (0.7 + 0.5 * use[month])).round(1)\n", + " reins = rng.random(N) < np.where(cause == \"정비 품질\", 0.6, 0.08)\n", + " texts = []\n", + " for i in range(N):\n", + " pt = PARTS[part[i]]\n", + " cl = [f\"{rng.choice(pt[1])}에서 {rng.choice(DEFECTS[defect[i]])}\"]\n", + " if rng.random() < 0.7: cl.append(rng.choice(SEV_CUE[sev[i]]))\n", + " if rng.random() < 0.65: # 원인 단서: 8%는 실제 원인과 다른 단서가 적힌다(보고자의 추정)\n", + " cc = cause[i] if rng.random() > 0.08 else rng.choice(CAUSES)\n", + " cl.append(rng.choice(CAUSE_CUE[cc]))\n", + " cl.append(rng.choice(ACTIONS))\n", + " if rng.random() < 0.5: cl.insert(0, f\"VU-{rng.integers(1, 31):02d} 소속\")\n", + " texts.append(\". \".join(cl) + \".\")\n", + " df = pd.DataFrame({\"qdr_id\": [f\"Q{seed}{i:06d}\" for i in range(N)], \"month\": month, \"unit\": [f\"VU-{x:02d}\" for x in rng.integers(1, 31, N)],\n", + " \"part\": part, \"defect\": defect, \"sev\": sev, \"cause\": cause, \"vendor\": vendor, \"maker\": [MAKER_OF[i] for i in part],\n", + " \"since_maint\": since, \"inspect_wait\": wait, \"reinspect\": reins, \"event\": ev, \"text\": texts})\n", + " df[\"cost\"] = PRICE[df[\"part\"]] * np.array([1.0, 2.0, 4.0])[df[\"sev\"]] # [교육용가상자료] 수리·조치 비용지수\n", + " return df, use, events\n", + "\n", + "# 1) 업무 프로세스 맵: 결함 발생부터 원인 환류까지 (각 단계에서 생기는 데이터)\n", + "PROCESS = [(\"결함 발견\", \"발견 부대, 장비, 부위, 서술 텍스트\"), (\"QDR 작성\", \"QDR ID, 작성일, 치명도(Critical/Major/Minor)\"),\n", + " (\"접수·검토\", \"텍스트 정제, 부위·결함 유형 라벨\"), (\"검사·판정\", \"검사 대기일, 재검사 여부, 외주정비 업체\"),\n", + " (\"원인 분석\", \"원인 구분(제조·정비·운용·설계·노후), 제조사\"), (\"조치\", \"수리·교환·하자 구상 여부, 조치 비용\"),\n", + " (\"실적 집계\", \"부위별·월별 건수, 취약성 지수\"), (\"다발 결함 식별\", \"파레토(ABC), 이상 신호\"), (\"사후검토(AAR)\", \"원인 오분류 사례, 룰 조정 이력\")]\n", + "\n", + "# 2) 스키마(데이터 딕셔너리): 그룹 / 필드 / 자료형 / 규칙 / 설명\n", + "PART_NAMES = [p[0] for p in PARTS]\n", + "S = {\n", + " \"report\": {\n", + " \"qdr_id\": (str, {\"pattern\": r\"^Q\\d{7,8}$\"}, \"QDR ID\"),\n", + " \"month\": (int, {\"min\": 0, \"max\": 143}, \"보고 월(모의 시작 기준 경과 월)\"),\n", + " \"unit\": (str, {\"pattern\": r\"^VU-\\d{2}$\"}, \"보고 부대 코드(가상)\"),\n", + " \"equip_code\": (str, {\"pattern\": r\"^VX-\\d{4}-\\d{4}$\"}, \"장비 식별코드(국방 표준 코드 형식을 모사한 가상 코드)\"),\n", + " \"fsc_group\": (str, {\"pattern\": r\"^\\d{4}$\"}, \"보급 분류군(가상 4자리)\"),\n", + " \"part\": (str, {\"enum\": PART_NAMES}, \"결함 부위(장비 계통)\"),\n", + " \"severity\": (str, {\"enum\": SEV}, \"결함 치명도\"),\n", + " },\n", + " \"text\": {\n", + " \"text\": (str, {\"min_len\": 5}, \"결함 서술 텍스트(비정형)\"),\n", + " \"defect\": (str, {\"enum\": DNAMES + [\"미분류\"]}, \"결함 유형 라벨\"),\n", + " \"n_tokens\": (int, {\"min\": 1, \"max\": 200}, \"정제 후 토큰 수\"),\n", + " \"part_label\": (str, {\"enum\": PART_NAMES + [\"미분류\"]}, \"서술에서 추출한 부위 라벨\"),\n", + " },\n", + " \"inspection\": {\n", + " \"vendor\": (str, {\"enum\": VENDORS}, \"직전 정비 수행처(외주 업체 또는 부대정비)\"),\n", + " \"since_maint\": (int, {\"min\": 0, \"max\": 1500}, \"직전 정비 후 경과일\"),\n", + " \"inspect_wait\": (float, {\"min\": 0, \"max\": 60}, \"검사 대기일(검사 병목 지표)\"),\n", + " \"reinspect\": (bool, {}, \"재검사 여부\"),\n", + " },\n", + " \"result\": {\n", + " \"cause\": (str, {\"enum\": CAUSES}, \"원인 구분 - 예측대상 1\"),\n", + " \"maker\": (str, {\"pattern\": r\"^MK\\d$\"}, \"제조사 코드(가상)\"),\n", + " \"cost\": (float, {\"min\": 0, \"max\": 100}, \"조치 비용지수 - 예측대상 2\"),\n", + " \"monthly_count\": (int, {\"min\": 0, \"max\": 500}, \"부위별 월간 QDR 건수 - 예측대상 3\"),\n", + " \"vuln_index\": (float, {\"min\": 0, \"max\": 1000}, \"치명도 가중 취약성 지수 - 예측대상 4\"),\n", + " \"event_flag\": (bool, {}, \"제조 LOT 불량 등 급증 이벤트 월 여부\"),\n", + " \"hitl_status\": (str, {\"enum\": [\"PENDING\", \"APPROVED\", \"REJECTED\", \"NO_RESPONSE\"]}, \"품질관 검토 상태\"),\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 \"min_len\" in rule and len(v) < rule[\"min_len\"]:\n", + " e.append(f\"{name}: 서술이 너무 짧음\")\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", + "df, use, events = make_qdr(36, seed=1)\n", + "rng = np.random.default_rng(7)\n", + "mc = df.groupby([\"part\", \"month\"]).size()\n", + "recs = []\n", + "for r in df.sample(20, random_state=1).itertuples():\n", + " t = r.text\n", + " recs.append({\n", + " \"report\": {\"qdr_id\": r.qdr_id, \"month\": int(r.month), \"unit\": r.unit, \"equip_code\": f\"VX-{rng.integers(1000, 9999)}-{rng.integers(1000, 9999)}\",\n", + " \"fsc_group\": f\"{rng.integers(1000, 9999)}\", \"part\": PART_NAMES[r.part], \"severity\": SEV[r.sev]},\n", + " \"text\": {\"text\": t, \"defect\": r.defect, \"n_tokens\": max(len(t.split()), 1), \"part_label\": PART_NAMES[r.part]},\n", + " \"inspection\": {\"vendor\": r.vendor, \"since_maint\": int(r.since_maint), \"inspect_wait\": float(r.inspect_wait), \"reinspect\": bool(r.reinspect)},\n", + " \"result\": {\"cause\": r.cause, \"maker\": r.maker, \"cost\": float(r.cost), \"monthly_count\": int(mc[(r.part, r.month)]),\n", + " \"vuln_index\": float(sum(np.array([1, 3, 9])[df.loc[(df.part == r.part) & (df.month == r.month), \"sev\"]])), \"event_flag\": bool(r.event), \"hitl_status\": \"PENDING\"}})\n", + "ok = sum(not validate(r) for r in recs)\n", + "print(f\"[교육용가상자료] 가상 QDR {len(df)}건 생성, 표본 20건 중 검증 통과 {ok}건, 오류 {len(recs) - ok}건\")\n", + "print(f\"전체 데이터 부위·치명도·원인 허용값 검증: {'통과' if df['part'].between(0, NP - 1).all() and df['sev'].isin([0, 1, 2]).all() and df['cause'].isin(CAUSES).all() else '실패'}\")\n", + "\n", + "# 4) 오류 주입 시험\n", + "bad = json.loads(json.dumps(recs[0]))\n", + "bad[\"report\"][\"qdr_id\"] = \"QDR-7\" # ID 형식 오류\n", + "bad[\"report\"][\"severity\"] = \"Severe\" # 허용되지 않은 치명도\n", + "bad[\"text\"][\"text\"] = \"고장\" # 서술 너무 짧음\n", + "del bad[\"inspection\"][\"vendor\"] # 필수 필드 누락\n", + "bad[\"inspection\"][\"inspect_wait\"] = \"3일\" # 자료형 오류\n", + "bad[\"result\"][\"cost\"] = 250.0 # 범위 초과\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)[:34] 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" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 01 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + "[교육용가상자료] 가상 QDR 9221건 생성, 표본 20건 중 검증 통과 20건, 오류 0건\n", + "전체 데이터 부위·치명도·원인 허용값 검증: 통과\n", + "\n", + "[오류 주입 결과]\n", + " - report.qdr_id: 형식 불일치 'QDR-7'\n", + " - report.severity: 허용되지 않은 값 'Severe'\n", + " - text.text: 서술이 너무 짧음\n", + " - inspection.vendor: 필수 필드 누락\n", + " - inspection.inspect_wait: 자료형 오류(str)\n", + " - result.cost: 최대값 100 초과\n", + "\n", + "[업무 프로세스 맵]\n", + "1.결함 발견 -> 2.QDR 작성 -> 3.접수·검토 -> 4.검사·판정 -> 5.원인 분석 -> 6.조치 -> 7.실적 집계 -> 8.다발 결함 식별 -> 9.사후검토(AAR)\n", + " 결함 발견 : 발견 부대, 장비, 부위, 서술 텍스트\n", + " QDR 작성 : QDR ID, 작성일, 치명도(Critical/Major/Minor)\n", + " 접수·검토 : 텍스트 정제, 부위·결함 유형 라벨\n", + " 검사·판정 : 검사 대기일, 재검사 여부, 외주정비 업체\n", + " 원인 분석 : 원인 구분(제조·정비·운용·설계·노후), 제조사\n", + " 조치 : 수리·교환·하자 구상 여부, 조치 비용\n", + " 실적 집계 : 부위별·월별 건수, 취약성 지수\n", + " 다발 결함 식별 : 파레토(ABC), 이상 신호\n", + " 사후검토(AAR) : 원인 오분류 사례, 룰 조정 이력\n", + "\n", + "[데이터 딕셔너리] 피처 22개 (목표 20개 이상: 달성)\n", + " 그룹 필드 자료형 설명\n", + " report qdr_id str QDR ID\n", + " report month int 보고 월(모의 시작 기준 경과 월)\n", + " report unit str 보고 부대 코드(가상)\n", + " report equip_code str 장비 식별코드(국방 표준 코드 형식을 모사한 가상 코드)\n", + " report fsc_group str 보급 분류군(가상 4자리)\n", + " report part str 결함 부위(장비 계통)\n", + " report severity str 결함 치명도\n", + " text text str 결함 서술 텍스트(비정형)\n", + " text defect str 결함 유형 라벨\n", + " text n_tokens int 정제 후 토큰 수\n", + " text part_label str 서술에서 추출한 부위 라벨\n", + "inspection vendor str 직전 정비 수행처(외주 업체 또는 부대정비)\n", + "inspection since_maint int 직전 정비 후 경과일\n", + "inspection inspect_wait float 검사 대기일(검사 병목 지표)\n", + "inspection reinspect bool 재검사 여부\n", + "... (이하 생략)\n", + "```\n", + "
\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 02. 비정형 결함 서술 텍스트 정제, 장비 부위별/결함 유형별 라벨링 전처리 구현\n", + "**소요시간**: 약 2.0시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "비정형 결함 서술 텍스트 정제 및 부위별/유형별 라벨링 전처리 모듈을 구현한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **비정형 결함 서술 텍스트를 정제하고 장비 부위별·결함 유형별로 라벨링하는 전처리 모듈을 구현한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **중복 QDR 제거, 부대 코드와 일자 표기 통일, 전각·특수문자·공백 정규화, 약어(T/M 등) 확장, 사전 기반 오타 교정, 음수 대기일 결측 처리, KNN 결측 보정, 부위·결함 유형 키워드 라벨링 등 정제 규칙 10종을 적용 수행하고 검증하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `src/etl/qlt05_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": [ + "\"\"\"표기 불일치·중복·전각·특수문자·약어·오타·불가능값·결측이 섞인 가상 QDR 원천 자료에 정제 규칙 10종(텍스트 정규화, 약어 확장, 오타 교정, KNN 결측 보정, 부위·결함 유형 키워드 라벨링 포함)을 적용하고, 긴급 보고는 보존하며 정합성 검증 통과율과 라벨링 정확도, 정제 전후 통계를 확인한다.\"\"\"\n", + "import re\n", + "import unicodedata\n", + "import numpy as np\n", + "import pandas as pd\n", + "from sklearn.impute import KNNImputer\n", + "\n", + "# ---------------------------------------------------------------------------\n", + "# [교육용가상자료] 가상 야전 장비 품질결함보고(QDR) 생성기 (실제 부대·장비·업체·정비 실적 없음)\n", + "# 가상 장비 부위 12종, 결함 유형 8종, 치명도(Critical/Major/Minor), 원인 5종, 가상 외주정비 업체 4곳 + 부대정비.\n", + "# QDR 1건마다 한국어 서술 텍스트를 만들고, 월별 건수는 계절·추세·훈련 강도(usage)·일부 제조 불량 이벤트를 따른다.\n", + "# ---------------------------------------------------------------------------\n", + "PARTS = [ # (이름, 별칭, 월 기준 건수, 계절 진폭, 정점 월, (Critical, Major 비율), 가상 단가지수, 제조사, 주요 결함 가중치)\n", + " (\"엔진 연료계통\", [\"연료계통\", \"연료 라인\", \"연료 펌프\"], 26, 0.15, 7, (0.10, 0.35), 6.0, \"MK1\", \"누유:4 마모:2 체결불량:2 오작동:2\"),\n", + " (\"변속기\", [\"변속기\", \"T/M\", \"미션\"], 22, 0.10, 8, (0.12, 0.38), 9.0, \"MK1\", \"마모:4 누유:2 과열:3 오작동:1\"),\n", + " (\"제동 장치\", [\"제동 장치\", \"브레이크\", \"제동 라인\"], 20, 0.20, 12, (0.26, 0.40), 5.0, \"MK2\", \"마모:4 균열:2 누유:2 체결불량:2\"),\n", + " (\"조향 장치\", [\"조향 장치\", \"핸들 연동부\", \"조향 로드\"], 14, 0.10, 6, (0.30, 0.40), 5.0, \"MK2\", \"체결불량:4 균열:2 마모:3 오작동:1\"),\n", + " (\"냉각 계통\", [\"냉각 계통\", \"라디에이터\", \"냉각수 라인\"], 24, 0.30, 7, (0.05, 0.30), 3.0, \"MK3\", \"누유:4 과열:4 부식:2\"),\n", + " (\"전기·배터리\", [\"배터리\", \"전기 계통\", \"발전기\"], 28, 0.35, 1, (0.04, 0.28), 2.5, \"MK3\", \"접촉불량:4 부식:2 과열:2 오작동:2\"),\n", + " (\"통신 모듈\", [\"통신 모듈\", \"무전기 연동부\", \"안테나 접속부\"], 16, 0.05, 4, (0.03, 0.25), 4.0, \"MK4\", \"접촉불량:5 오작동:4 부식:1\"),\n", + " (\"유압 계통\", [\"유압 계통\", \"유압 호스\", \"유압 실린더\"], 18, 0.10, 9, (0.10, 0.35), 4.5, \"MK2\", \"누유:5 균열:2 마모:2 체결불량:1\"),\n", + " (\"현가 장치\", [\"현가 장치\", \"서스펜션\", \"완충기\"], 15, 0.08, 5, (0.06, 0.30), 3.5, \"MK4\", \"마모:4 균열:3 체결불량:2 부식:1\"),\n", + " (\"차체 용접부\", [\"차체 용접부\", \"프레임 용접부\", \"차체 이음부\"], 10, 0.05, 10, (0.28, 0.40), 7.0, \"MK1\", \"균열:6 부식:3 체결불량:1\"),\n", + " (\"광학·조준 장비\", [\"광학 장비\", \"조준경\", \"관측 장비\"], 8, 0.05, 3, (0.03, 0.22), 8.0, \"MK4\", \"오작동:4 접촉불량:3 부식:2 체결불량:1\"),\n", + " (\"공기 정화 필터\", [\"공기 필터\", \"흡기 필터\", \"정화 장치\"], 12, 0.25, 4, (0.01, 0.12), 1.0, \"MK3\", \"마모:5 부식:2 체결불량:3\")]\n", + "NP = len(PARTS)\n", + "DEFECTS = { # 결함 유형: 증상 표현(동의어)\n", + " \"균열\": [\"균열이 발견됨\", \"크랙이 확인됨\", \"갈라짐이 있음\"], \"누유\": [\"누유가 발생함\", \"오일이 새어 나옴\", \"누설 흔적이 있음\"],\n", + " \"마모\": [\"마모가 심함\", \"과도한 마모가 확인됨\", \"닳은 흔적이 있음\"], \"부식\": [\"부식이 진행됨\", \"녹이 발생함\", \"산화 흔적이 있음\"],\n", + " \"접촉불량\": [\"접촉 불량이 발생함\", \"커넥터 단선이 의심됨\", \"신호가 끊김\"], \"체결불량\": [\"볼트 풀림이 확인됨\", \"체결 상태가 불량함\", \"고정부가 헐거움\"],\n", + " \"과열\": [\"과열 경고가 발생함\", \"온도가 과도하게 상승함\", \"열이 심하게 남\"], \"오작동\": [\"오작동이 반복됨\", \"작동이 불안정함\", \"간헐적으로 멈춤\"]}\n", + "DNAMES = list(DEFECTS)\n", + "SEV = [\"Minor\", \"Major\", \"Critical\"]\n", + "SEV_CUE = {0: [\"경미한 수준임\", \"운용에는 지장이 없음\", \"육안 점검상 사소한 이상임\", \"기능에는 영향이 없음\"],\n", + " 1: [\"성능 저하가 있음\", \"조속한 정비가 필요함\", \"출력이 떨어지는 상태임\", \"기능이 부분적으로 제한됨\"],\n", + " 2: [\"즉시 운용 중지함\", \"안전에 위험이 있어 사용 불가\", \"작동 정지로 임무 수행이 어려움\", \"인명 안전 위협이 우려됨\"]}\n", + "CAUSES = [\"제조 불량\", \"정비 품질\", \"운용 부주의\", \"설계 취약\", \"자연 노후\"]\n", + "CAUSE_CUE = {\n", + " \"제조 불량\": [\"신품 인수 직후 발생함\", \"출고 상태에서 이미 이상이 있었음\", \"인수 후 얼마 지나지 않아 발생함\"],\n", + " \"정비 품질\": [\"외주 정비 후 얼마 되지 않아 재발함\", \"정비 후 재조립 상태가 불량함\", \"정비 직후 같은 부위에서 다시 발생함\"],\n", + " \"운용 부주의\": [\"험지 기동 중 충격 이후 발생함\", \"과부하 운용 후 발생함\", \"점검 없이 연속 운용한 뒤 발생함\"],\n", + " \"설계 취약\": [\"동일 증상이 다른 차량에서도 반복됨\", \"구조적으로 취약한 부위로 보임\", \"여러 부대에서 같은 형태로 보고됨\"],\n", + " \"자연 노후\": [\"사용 연한이 도래함\", \"장기간 사용으로 노후됨\", \"수명 주기 말기에 발생함\"]}\n", + "VENDORS = [\"외주A\", \"외주B\", \"외주C\", \"외주D\", \"부대정비\"]\n", + "ACTIONS = [\"정비소 입고를 요청함\", \"현장 임시 조치 후 운용 중임\", \"부품 교환을 요청함\", \"재검사를 요청함\", \"원인 확인이 필요함\"]\n", + "MAKER_OF = [p[7] for p in PARTS]\n", + "PRICE = np.array([p[6] for p in PARTS])\n", + "\n", + "\n", + "def usage_index(months, seed=0):\n", + " \"\"\"월별 훈련 강도(미리 알 수 있는 계획 값): 연 1회 정점을 갖는 계절 + 작은 잡음\"\"\"\n", + " r = np.random.default_rng(seed + 99)\n", + " t = np.arange(months)\n", + " return 1 + 0.25 * np.sin(2 * np.pi * (t - 3) / 12) + r.normal(0, 0.04, months)\n", + "\n", + "\n", + "def make_qdr(months=36, seed=1, scale=1.0, surge=True):\n", + " \"\"\"반환: QDR DataFrame(건당 1행), 월별 usage, 급증 이벤트 목록\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " use = usage_index(months, seed)\n", + " events = []\n", + " if surge:\n", + " for _ in range(max(2, months // 12 * 2)):\n", + " events.append((int(rng.integers(NP)), int(rng.integers(6, months))))\n", + " rows = []\n", + " wdef = [np.array([float(x.split(\":\")[1]) for x in p[8].split()]) for p in PARTS]\n", + " ddef = [[x.split(\":\")[0] for x in p[8].split()] for p in PARTS]\n", + " n = 0\n", + " for m in range(months):\n", + " for pi, p in enumerate(PARTS):\n", + " lam = p[2] * scale * (1 + 0.010 * m) * use[m] * (1 + p[3] * np.cos(2 * np.pi * (m % 12 - p[4]) / 12))\n", + " ev = (pi, m) in events\n", + " k = rng.poisson(lam * (2.6 if ev else 1.0))\n", + " for _ in range(k):\n", + " rows.append((n, m, pi, ev)); n += 1\n", + " N = len(rows)\n", + " A = np.array(rows)\n", + " month, part, ev = A[:, 1], A[:, 2], A[:, 3].astype(bool)\n", + " defect = np.array([rng.choice(ddef[i], p=wdef[i] / wdef[i].sum()) for i in part])\n", + " cr = np.array([PARTS[i][5][0] for i in part]); ma = np.array([PARTS[i][5][1] for i in part])\n", + " u = rng.random(N)\n", + " sev = np.where(u < cr, 2, np.where(u < cr + ma, 1, 0))\n", + " vendor = rng.choice(VENDORS, N, p=[0.25, 0.2, 0.2, 0.15, 0.2])\n", + " # 원인: 기본 비율에 부위(제조사)·업체·이벤트 효과를 곱한다\n", + " base = np.array([0.22, 0.20, 0.20, 0.16, 0.22])\n", + " P = np.tile(base, (N, 1))\n", + " P[vendor == \"외주B\", 1] *= 2.6\n", + " P[ev, 0] *= 5.0\n", + " design = np.isin(part, [2, 3, 7]); P[design, 3] *= 2.2\n", + " P[np.isin(part, [4, 5, 11]), 0] *= 2.0 # 제조사 MK3 납품 부위는 제조 불량 비중이 높다\n", + " P[defect == \"마모\", 4] *= 1.6; P[defect == \"누유\", 4] *= 1.3\n", + " P /= P.sum(axis=1, keepdims=True)\n", + " cause = np.array([CAUSES[rng.choice(5, p=P[i])] for i in range(N)])\n", + " since = np.where(cause == \"정비 품질\", rng.exponential(35, N), rng.exponential(160, N)).clip(1, 720).round().astype(int)\n", + " wait = (rng.lognormal(1.3, 0.5, N) * np.where(vendor == \"외주B\", 1.7, np.where(vendor == \"부대정비\", 0.6, 1.0)) * (0.7 + 0.5 * use[month])).round(1)\n", + " reins = rng.random(N) < np.where(cause == \"정비 품질\", 0.6, 0.08)\n", + " texts = []\n", + " for i in range(N):\n", + " pt = PARTS[part[i]]\n", + " cl = [f\"{rng.choice(pt[1])}에서 {rng.choice(DEFECTS[defect[i]])}\"]\n", + " if rng.random() < 0.7: cl.append(rng.choice(SEV_CUE[sev[i]]))\n", + " if rng.random() < 0.65: # 원인 단서: 8%는 실제 원인과 다른 단서가 적힌다(보고자의 추정)\n", + " cc = cause[i] if rng.random() > 0.08 else rng.choice(CAUSES)\n", + " cl.append(rng.choice(CAUSE_CUE[cc]))\n", + " cl.append(rng.choice(ACTIONS))\n", + " if rng.random() < 0.5: cl.insert(0, f\"VU-{rng.integers(1, 31):02d} 소속\")\n", + " texts.append(\". \".join(cl) + \".\")\n", + " df = pd.DataFrame({\"qdr_id\": [f\"Q{seed}{i:06d}\" for i in range(N)], \"month\": month, \"unit\": [f\"VU-{x:02d}\" for x in rng.integers(1, 31, N)],\n", + " \"part\": part, \"defect\": defect, \"sev\": sev, \"cause\": cause, \"vendor\": vendor, \"maker\": [MAKER_OF[i] for i in part],\n", + " \"since_maint\": since, \"inspect_wait\": wait, \"reinspect\": reins, \"event\": ev, \"text\": texts})\n", + " df[\"cost\"] = PRICE[df[\"part\"]] * np.array([1.0, 2.0, 4.0])[df[\"sev\"]] # [교육용가상자료] 수리·조치 비용지수\n", + " return df, use, events\n", + "\n", + "# ---------------------------------------------------------------------------\n", + "# 공통 텍스트 조각: 정규화, 간이 토큰화(조사 제거), 부위·결함·치명도·원인 키워드 라벨링\n", + "# ---------------------------------------------------------------------------\n", + "TYPO = {\"누윳\": \"누유\", \"균렬\": \"균열\", \"접축 불량\": \"접촉 불량\", \"부씩\": \"부식\", \"마묘\": \"마모\", \"과렬\": \"과열\", \"오작똥\": \"오작동\", \"재동\": \"제동\"}\n", + "ABBR = {\"T/M\": \"변속기\", \"BAT\": \"배터리\"}\n", + "PART_KW = sorted([(a, i) for i, p in enumerate(PARTS) for a in p[1]], key=lambda x: -len(x[0]))\n", + "DEF_KW = {\"균열\": [\"균열\", \"크랙\", \"갈라\"], \"누유\": [\"누유\", \"새어\", \"누설\"], \"마모\": [\"마모\", \"닳은\"], \"부식\": [\"부식\", \"녹이\", \"산화\"],\n", + " \"접촉불량\": [\"접촉 불량\", \"단선\", \"끊김\"], \"체결불량\": [\"볼트\", \"체결\", \"헐거\"], \"과열\": [\"과열\", \"온도\", \"열이\"], \"오작동\": [\"오작동\", \"불안정\", \"멈춤\"]}\n", + "SEV_KW = [(2, [\"즉시 운용 중지\", \"사용 불가\", \"위험\"]), (1, [\"성능 저하\", \"조속한\"]), (0, [\"경미\", \"지장이 없\"])]\n", + "CAUSE_KW = {\"제조 불량\": [\"신품\", \"출고\", \"인수\"], \"정비 품질\": [\"정비 후\", \"재조립\", \"정비 직후\"], \"운용 부주의\": [\"충격\", \"과부하\", \"점검 없이\"],\n", + " \"설계 취약\": [\"동일 증상\", \"구조적\", \"여러 부대\"], \"자연 노후\": [\"사용 연한\", \"노후\", \"수명\"]}\n", + "PARTICLES = (\"에서\", \"으로\", \"이\", \"가\", \"은\", \"는\", \"을\", \"를\", \"에\", \"의\", \"로\", \"와\", \"과\", \"도\", \"함\", \"음\", \"됨\")\n", + "\n", + "\n", + "def norm(t):\n", + " \"\"\"정규화: 전각->반각, 특수문자·중복 공백 제거, 약어 확장, 오타 교정\"\"\"\n", + " t = unicodedata.normalize(\"NFKC\", str(t))\n", + " t = re.sub(r\"[!#~*@^]+\", \" \", t)\n", + " for a, b in ABBR.items():\n", + " t = t.replace(a, b)\n", + " for a, b in TYPO.items():\n", + " t = t.replace(a, b)\n", + " return re.sub(r\"\\s+\", \" \", t).strip()\n", + "\n", + "\n", + "def tok(t):\n", + " out = []\n", + " for w in re.split(r\"[\\s.,]+\", norm(t)):\n", + " if not w or re.fullmatch(r\"[\\d\\-A-Za-z]+\", w):\n", + " continue\n", + " for p in PARTICLES:\n", + " if len(w) > len(p) + 1 and w.endswith(p):\n", + " w = w[:-len(p)]\n", + " break\n", + " out.append(w)\n", + " return out\n", + "\n", + "\n", + "def label_part(t):\n", + " for a, i in PART_KW:\n", + " if a in t:\n", + " return i\n", + " return -1\n", + "\n", + "\n", + "def label_defect(t):\n", + " for d, ks in DEF_KW.items():\n", + " if any(k in t for k in ks):\n", + " return d\n", + " return \"미분류\"\n", + "\n", + "\n", + "def label_sev(t):\n", + " for s, ks in SEV_KW:\n", + " if any(k in t for k in ks):\n", + " return s\n", + " return -1\n", + "\n", + "\n", + "def label_cause(t):\n", + " for c, ks in CAUSE_KW.items():\n", + " if any(k in t for k in ks):\n", + " return c\n", + " return \"미상\"\n", + "\n", + "# 1) [교육용가상자료] 원천 자료: 생성 자료에 일부러 오류를 섞는다\n", + "df0, use, events = make_qdr(36, seed=1)\n", + "rng = np.random.default_rng(2)\n", + "raw = df0[[\"qdr_id\", \"month\", \"unit\", \"vendor\", \"since_maint\", \"inspect_wait\", \"text\", \"part\", \"defect\"]].copy()\n", + "raw[\"date\"] = [f\"2024-{m % 12 + 1:02d}-{rng.integers(1, 28):02d}\" for m in raw[\"month\"]]\n", + "raw[\"urgent\"] = False\n", + "n = len(raw)\n", + "urg = rng.choice(n, 40, replace=False)\n", + "raw.loc[urg, \"inspect_wait\"] = 0.0; raw.loc[urg, \"urgent\"] = True # 긴급 보고: 대기일 0은 정상 예외\n", + "raw.loc[urg, \"text\"] = \"긴급 보고. \" + raw.loc[urg, \"text\"]\n", + "\n", + "\n", + "def pick(k):\n", + " return rng.choice(n, k, replace=False)\n", + "\n", + "\n", + "i2 = pick(300)\n", + "raw.loc[i2, \"unit\"] = [u.lower() if j % 3 == 0 else u.replace(\"-\", \"\") if j % 3 == 1 else \"VU-\" + str(int(u[-2:])) for j, u in enumerate(raw.loc[i2, \"unit\"])]\n", + "i3 = pick(400); raw.loc[i3, \"date\"] = [d.replace(\"-\", \".\") if j % 2 else d[2:].replace(\"-\", \"/\") for j, d in enumerate(raw.loc[i3, \"date\"])]\n", + "i4 = pick(600); raw.loc[i4, \"text\"] = [t.replace(\". \", \". \").replace(\"에서\", \"에서 \").replace(\"함.\", \"함!!\") + \" ##\" if j % 2 else unicodedata.normalize(\"NFKC\", t).replace(\".\", \".\") for j, t in enumerate(raw.loc[i4, \"text\"])]\n", + "i5 = pick(300); raw.loc[i5, \"text\"] = [t.replace(\"변속기\", \"T/M\").replace(\"배터리\", \"BAT\") for t in raw.loc[i5, \"text\"]]\n", + "i6 = rng.choice(np.where(raw[\"text\"].str.contains(\"누유|균열|부식|제동|과열\"))[0], 400, replace=False); raw.loc[i6, \"text\"] = [t.replace(\"누유\", \"누윳\").replace(\"균열\", \"균렬\").replace(\"부식\", \"부씩\").replace(\"제동\", \"재동\").replace(\"과열\", \"과렬\") for t in raw.loc[i6, \"text\"]]\n", + "i7 = pick(80); raw.loc[i7, \"inspect_wait\"] = -1.0\n", + "i8 = pick(400); raw.loc[i8, \"since_maint\"] = np.nan\n", + "dup = raw.sample(150, random_state=3); raw = pd.concat([raw, dup], ignore_index=True)\n", + "print(f\"원천 자료 {len(raw)}행 (정상 {n}행 + 긴급 보고 {len(urg)}건 + 중복 {len(dup)}행, 오류 주입 {len(i2) + len(i3) + len(i4) + len(i5) + len(i6) + len(i7) + len(i8)}행)\")\n", + "\n", + "# 2) 정제 규칙 10종\n", + "log = []\n", + "\n", + "\n", + "def rule(name, cnt):\n", + " log.append((name, int(cnt)))\n", + "\n", + "\n", + "df = raw.copy()\n", + "before_len = len(df)\n", + "df = df.drop_duplicates(\"qdr_id\").reset_index(drop=True); rule(\"R1 중복 QDR 제거\", before_len - len(df))\n", + "fix = lambda u: \"VU-%02d\" % int(re.sub(r\"\\D\", \"\", u))\n", + "bad = ~df[\"unit\"].str.fullmatch(r\"VU-\\d{2}\"); df[\"unit\"] = df[\"unit\"].map(fix); rule(\"R2 부대 코드 표기 통일\", bad.sum())\n", + "bad = ~df[\"date\"].str.fullmatch(r\"\\d{4}-\\d{2}-\\d{2}\")\n", + "df[\"date\"] = [pd.to_datetime(d.replace(\".\", \"-\"), format=\"%Y-%m-%d\" if len(d) == 10 else \"%y-%m-%d\", errors=\"coerce\") if \"/\" not in d else pd.to_datetime(d, format=\"%y/%m/%d\") for d in df[\"date\"]]\n", + "rule(\"R3 일자 형식 통일\", bad.sum())\n", + "t0 = df[\"text\"].copy()\n", + "df[\"text\"] = df[\"text\"].map(lambda t: re.sub(r\"\\s+\", \" \", re.sub(r\"[!#~*@^]+\", \" \", unicodedata.normalize(\"NFKC\", t))).strip()); rule(\"R4 텍스트 정규화(전각·특수문자·공백)\", (df[\"text\"] != t0).sum())\n", + "c = df[\"text\"].str.contains(\"T/M|BAT\"); df[\"text\"] = df[\"text\"].map(norm); rule(\"R5 약어 확장(T/M, BAT)\", c.sum())\n", + "c = t0.map(lambda t: any(k in t for k in TYPO))\n", + "rule(\"R6 오타 교정(사전 기반)\", c.sum())\n", + "bad = df[\"inspect_wait\"] < 0; df.loc[bad, \"inspect_wait\"] = np.nan; rule(\"R7 불가능값(음수 대기일) 결측 처리\", bad.sum())\n", + "miss = df[\"since_maint\"].isna() | df[\"inspect_wait\"].isna()\n", + "X = pd.DataFrame({\"part\": df[\"part\"], \"vendor\": df[\"vendor\"].astype(\"category\").cat.codes * 5, \"wait\": df[\"inspect_wait\"], \"since\": df[\"since_maint\"], \"urgent\": df[\"urgent\"] * 20})\n", + "df[[\"inspect_wait\", \"since_maint\"]] = KNNImputer(n_neighbors=5).fit_transform(X)[:, 2:4]; rule(\"R8 대기일·정비후경과일 KNN 보정\", miss.sum())\n", + "df[\"part_label\"] = df[\"text\"].map(label_part); df[\"defect_label\"] = df[\"text\"].map(label_defect); rule(\"R9 부위·결함 유형 키워드 라벨링\", len(df))\n", + "flag = (df[\"inspect_wait\"] > 40) | ((df[\"inspect_wait\"] == 0) & ~df[\"urgent\"])\n", + "df[\"flag\"] = flag\n", + "rule(\"R10 긴급 보고 보존(일반 이상값은 표시만)\", int(df[\"urgent\"].sum()))\n", + "print(\"\\n[적용 규칙과 처리 건수]\")\n", + "for nm, c in log:\n", + " print(f\" {nm:40s} {c:6d}건\")\n", + "\n", + "# 3) 정합성 검증 + 라벨링 정확도\n", + "truth = df.merge(df0[[\"qdr_id\", \"part\", \"defect\"]], on=\"qdr_id\", suffixes=(\"\", \"_t\"))\n", + "rawlab = raw.drop_duplicates(\"qdr_id\").assign(pl=lambda d: d[\"text\"].map(label_part), dl=lambda d: d[\"text\"].map(label_defect))\n", + "rawacc_p, rawacc_d = (rawlab[\"pl\"] == rawlab[\"part\"]).mean(), (rawlab[\"dl\"] == rawlab[\"defect\"]).mean()\n", + "acc_p, acc_d = (truth[\"part_label\"] == truth[\"part_t\"]).mean(), (truth[\"defect_label\"] == truth[\"defect_t\"]).mean()\n", + "checks = {\n", + " \"R1 ID 유일\": df[\"qdr_id\"].is_unique, \"R2 부대 코드 형식\": df[\"unit\"].str.fullmatch(r\"VU-\\d{2}\").all(), \"R3 일자 변환\": df[\"date\"].notna().all(),\n", + " \"R4 특수문자·이중 공백 없음\": not df[\"text\"].str.contains(r\"[!#]|\\s\\s|.\").any(), \"R5 약어 없음\": not df[\"text\"].str.contains(\"T/M|BAT\").any(),\n", + " \"R6 오타 없음\": not df[\"text\"].map(lambda t: any(k in t for k in TYPO)).any(), \"R7 음수 대기일 없음\": (df[\"inspect_wait\"] >= 0).all(),\n", + " \"R8 결측 없음\": df[[\"inspect_wait\", \"since_maint\"]].notna().all().all(), \"R9 부위 라벨 부여율 100%\": (df[\"part_label\"] >= 0).all(),\n", + " \"R10 긴급 보고 40건 보존\": int(df[\"urgent\"].sum()) == 40}\n", + "npass = sum(checks.values())\n", + "print(f\"\\n정합성 검증 {npass}/10 통과, 통과율 {npass / 10:.0%} (목표 100%: {'달성' if npass == 10 else '미달'})\")\n", + "print(f\"라벨링 정확도: 부위 {rawacc_p:.1%} -> {acc_p:.1%}, 결함 유형 {rawacc_d:.1%} -> {acc_d:.1%} (정제 전 -> 후)\")\n", + "print(f\"긴급 보고 {int(df['urgent'].sum())}건 보존, 일반 이상 표시 {int((df['flag'] & ~df['urgent']).sum())}건\")\n", + "\n", + "# 4) 정제 전후 기술통계\n", + "def stats(d, after):\n", + " return {\"행 수\": len(d), \"부대 표기 종류 수\": d[\"unit\"].nunique(), \"서술 평균 길이(자)\": round(d[\"text\"].str.len().mean(), 1),\n", + " \"대기일 최소\": round(d[\"inspect_wait\"].min(), 1), \"대기일 결측 수\": int(d[\"inspect_wait\"].isna().sum()), \"정비후경과일 결측 수\": int(d[\"since_maint\"].isna().sum())}\n", + "print(\"\\n[정제 전후 기술통계]\")\n", + "print(pd.DataFrame({\"정제 전\": stats(raw, False), \"정제 후\": stats(df, True)}).to_string())\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 02 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + "원천 자료 9371행 (정상 9221행 + 긴급 보고 40건 + 중복 150행, 오류 주입 2480행)\n", + "\n", + "[적용 규칙과 처리 건수]\n", + " R1 중복 QDR 제거 150건\n", + " R2 부대 코드 표기 통일 238건\n", + " R3 일자 형식 통일 400건\n", + " R4 텍스트 정규화(전각·특수문자·공백) 600건\n", + " R5 약어 확장(T/M, BAT) 294건\n", + " R6 오타 교정(사전 기반) 400건\n", + " R7 불가능값(음수 대기일) 결측 처리 80건\n", + " R8 대기일·정비후경과일 KNN 보정 474건\n", + " R9 부위·결함 유형 키워드 라벨링 9221건\n", + " R10 긴급 보고 보존(일반 이상값은 표시만) 40건\n", + "\n", + "정합성 검증 10/10 통과, 통과율 100% (목표 100%: 달성)\n", + "라벨링 정확도: 부위 98.5% -> 100.0%, 결함 유형 96.8% -> 100.0% (정제 전 -> 후)\n", + "긴급 보고 40건 보존, 일반 이상 표시 1건\n", + "\n", + "[정제 전후 기술통계]\n", + " 정제 전 정제 후\n", + "행 수 9371.0 9221.0\n", + "부대 표기 종류 수 98.0 30.0\n", + "서술 평균 길이(자) 56.4 56.1\n", + "대기일 최소 -1.0 0.0\n", + "대기일 결측 수 0.0 0.0\n", + "정비후경과일 결측 수 406.0 0.0\n", + "```\n", + "
\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 03. 빈도 분석 기반 1차 다발 결함 부품 도출 Baseline 알고리즘 구현\n", + "**소요시간**: 약 2.5시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "빈도 분석 기반 다발 결함 부품 도출 Baseline 알고리즘을 구현한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **빈도 분석 기반 1차 다발 결함 부위 도출 Baseline을 구현하고, 탐색적 분석으로 빈도 기준의 한계와 외주정비 검사 병목, 핵심 관리 대상을 찾는다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **학습 기간 건수 상위 5개 부위를 다발 결함 부위로 지정해 검증 기간 적중률과 치명도 가중 지수 순위와의 차이 확인하고 유효성을 점검하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `notebooks/qlt05_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": [ + "\"\"\"빈도 분석 기반 1차 다발 결함 부위 도출 Baseline(학습 기간 건수 상위 5개 부위)의 적중률과 한계(치명도·비용 순위와의 차이)를 확인하고, 탐색적 분석(EDA 차트 8종, 월간 QDR 건수와 피처의 상관계수·p-value, 외주정비 업체별 검사 대기·원인, 건수·비용 파레토 ABC 분류)으로 핵심 관리 대상을 식별한다.\"\"\"\n", + "import numpy as np\n", + "import pandas as pd\n", + "import matplotlib.pyplot as plt\n", + "from scipy.stats import spearmanr\n", + "\n", + "# ---------------------------------------------------------------------------\n", + "# [교육용가상자료] 가상 야전 장비 품질결함보고(QDR) 생성기 (실제 부대·장비·업체·정비 실적 없음)\n", + "# 가상 장비 부위 12종, 결함 유형 8종, 치명도(Critical/Major/Minor), 원인 5종, 가상 외주정비 업체 4곳 + 부대정비.\n", + "# QDR 1건마다 한국어 서술 텍스트를 만들고, 월별 건수는 계절·추세·훈련 강도(usage)·일부 제조 불량 이벤트를 따른다.\n", + "# ---------------------------------------------------------------------------\n", + "PARTS = [ # (이름, 별칭, 월 기준 건수, 계절 진폭, 정점 월, (Critical, Major 비율), 가상 단가지수, 제조사, 주요 결함 가중치)\n", + " (\"엔진 연료계통\", [\"연료계통\", \"연료 라인\", \"연료 펌프\"], 26, 0.15, 7, (0.10, 0.35), 6.0, \"MK1\", \"누유:4 마모:2 체결불량:2 오작동:2\"),\n", + " (\"변속기\", [\"변속기\", \"T/M\", \"미션\"], 22, 0.10, 8, (0.12, 0.38), 9.0, \"MK1\", \"마모:4 누유:2 과열:3 오작동:1\"),\n", + " (\"제동 장치\", [\"제동 장치\", \"브레이크\", \"제동 라인\"], 20, 0.20, 12, (0.26, 0.40), 5.0, \"MK2\", \"마모:4 균열:2 누유:2 체결불량:2\"),\n", + " (\"조향 장치\", [\"조향 장치\", \"핸들 연동부\", \"조향 로드\"], 14, 0.10, 6, (0.30, 0.40), 5.0, \"MK2\", \"체결불량:4 균열:2 마모:3 오작동:1\"),\n", + " (\"냉각 계통\", [\"냉각 계통\", \"라디에이터\", \"냉각수 라인\"], 24, 0.30, 7, (0.05, 0.30), 3.0, \"MK3\", \"누유:4 과열:4 부식:2\"),\n", + " (\"전기·배터리\", [\"배터리\", \"전기 계통\", \"발전기\"], 28, 0.35, 1, (0.04, 0.28), 2.5, \"MK3\", \"접촉불량:4 부식:2 과열:2 오작동:2\"),\n", + " (\"통신 모듈\", [\"통신 모듈\", \"무전기 연동부\", \"안테나 접속부\"], 16, 0.05, 4, (0.03, 0.25), 4.0, \"MK4\", \"접촉불량:5 오작동:4 부식:1\"),\n", + " (\"유압 계통\", [\"유압 계통\", \"유압 호스\", \"유압 실린더\"], 18, 0.10, 9, (0.10, 0.35), 4.5, \"MK2\", \"누유:5 균열:2 마모:2 체결불량:1\"),\n", + " (\"현가 장치\", [\"현가 장치\", \"서스펜션\", \"완충기\"], 15, 0.08, 5, (0.06, 0.30), 3.5, \"MK4\", \"마모:4 균열:3 체결불량:2 부식:1\"),\n", + " (\"차체 용접부\", [\"차체 용접부\", \"프레임 용접부\", \"차체 이음부\"], 10, 0.05, 10, (0.28, 0.40), 7.0, \"MK1\", \"균열:6 부식:3 체결불량:1\"),\n", + " (\"광학·조준 장비\", [\"광학 장비\", \"조준경\", \"관측 장비\"], 8, 0.05, 3, (0.03, 0.22), 8.0, \"MK4\", \"오작동:4 접촉불량:3 부식:2 체결불량:1\"),\n", + " (\"공기 정화 필터\", [\"공기 필터\", \"흡기 필터\", \"정화 장치\"], 12, 0.25, 4, (0.01, 0.12), 1.0, \"MK3\", \"마모:5 부식:2 체결불량:3\")]\n", + "NP = len(PARTS)\n", + "DEFECTS = { # 결함 유형: 증상 표현(동의어)\n", + " \"균열\": [\"균열이 발견됨\", \"크랙이 확인됨\", \"갈라짐이 있음\"], \"누유\": [\"누유가 발생함\", \"오일이 새어 나옴\", \"누설 흔적이 있음\"],\n", + " \"마모\": [\"마모가 심함\", \"과도한 마모가 확인됨\", \"닳은 흔적이 있음\"], \"부식\": [\"부식이 진행됨\", \"녹이 발생함\", \"산화 흔적이 있음\"],\n", + " \"접촉불량\": [\"접촉 불량이 발생함\", \"커넥터 단선이 의심됨\", \"신호가 끊김\"], \"체결불량\": [\"볼트 풀림이 확인됨\", \"체결 상태가 불량함\", \"고정부가 헐거움\"],\n", + " \"과열\": [\"과열 경고가 발생함\", \"온도가 과도하게 상승함\", \"열이 심하게 남\"], \"오작동\": [\"오작동이 반복됨\", \"작동이 불안정함\", \"간헐적으로 멈춤\"]}\n", + "DNAMES = list(DEFECTS)\n", + "SEV = [\"Minor\", \"Major\", \"Critical\"]\n", + "SEV_CUE = {0: [\"경미한 수준임\", \"운용에는 지장이 없음\", \"육안 점검상 사소한 이상임\", \"기능에는 영향이 없음\"],\n", + " 1: [\"성능 저하가 있음\", \"조속한 정비가 필요함\", \"출력이 떨어지는 상태임\", \"기능이 부분적으로 제한됨\"],\n", + " 2: [\"즉시 운용 중지함\", \"안전에 위험이 있어 사용 불가\", \"작동 정지로 임무 수행이 어려움\", \"인명 안전 위협이 우려됨\"]}\n", + "CAUSES = [\"제조 불량\", \"정비 품질\", \"운용 부주의\", \"설계 취약\", \"자연 노후\"]\n", + "CAUSE_CUE = {\n", + " \"제조 불량\": [\"신품 인수 직후 발생함\", \"출고 상태에서 이미 이상이 있었음\", \"인수 후 얼마 지나지 않아 발생함\"],\n", + " \"정비 품질\": [\"외주 정비 후 얼마 되지 않아 재발함\", \"정비 후 재조립 상태가 불량함\", \"정비 직후 같은 부위에서 다시 발생함\"],\n", + " \"운용 부주의\": [\"험지 기동 중 충격 이후 발생함\", \"과부하 운용 후 발생함\", \"점검 없이 연속 운용한 뒤 발생함\"],\n", + " \"설계 취약\": [\"동일 증상이 다른 차량에서도 반복됨\", \"구조적으로 취약한 부위로 보임\", \"여러 부대에서 같은 형태로 보고됨\"],\n", + " \"자연 노후\": [\"사용 연한이 도래함\", \"장기간 사용으로 노후됨\", \"수명 주기 말기에 발생함\"]}\n", + "VENDORS = [\"외주A\", \"외주B\", \"외주C\", \"외주D\", \"부대정비\"]\n", + "ACTIONS = [\"정비소 입고를 요청함\", \"현장 임시 조치 후 운용 중임\", \"부품 교환을 요청함\", \"재검사를 요청함\", \"원인 확인이 필요함\"]\n", + "MAKER_OF = [p[7] for p in PARTS]\n", + "PRICE = np.array([p[6] for p in PARTS])\n", + "\n", + "\n", + "def usage_index(months, seed=0):\n", + " \"\"\"월별 훈련 강도(미리 알 수 있는 계획 값): 연 1회 정점을 갖는 계절 + 작은 잡음\"\"\"\n", + " r = np.random.default_rng(seed + 99)\n", + " t = np.arange(months)\n", + " return 1 + 0.25 * np.sin(2 * np.pi * (t - 3) / 12) + r.normal(0, 0.04, months)\n", + "\n", + "\n", + "def make_qdr(months=36, seed=1, scale=1.0, surge=True):\n", + " \"\"\"반환: QDR DataFrame(건당 1행), 월별 usage, 급증 이벤트 목록\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " use = usage_index(months, seed)\n", + " events = []\n", + " if surge:\n", + " for _ in range(max(2, months // 12 * 2)):\n", + " events.append((int(rng.integers(NP)), int(rng.integers(6, months))))\n", + " rows = []\n", + " wdef = [np.array([float(x.split(\":\")[1]) for x in p[8].split()]) for p in PARTS]\n", + " ddef = [[x.split(\":\")[0] for x in p[8].split()] for p in PARTS]\n", + " n = 0\n", + " for m in range(months):\n", + " for pi, p in enumerate(PARTS):\n", + " lam = p[2] * scale * (1 + 0.010 * m) * use[m] * (1 + p[3] * np.cos(2 * np.pi * (m % 12 - p[4]) / 12))\n", + " ev = (pi, m) in events\n", + " k = rng.poisson(lam * (2.6 if ev else 1.0))\n", + " for _ in range(k):\n", + " rows.append((n, m, pi, ev)); n += 1\n", + " N = len(rows)\n", + " A = np.array(rows)\n", + " month, part, ev = A[:, 1], A[:, 2], A[:, 3].astype(bool)\n", + " defect = np.array([rng.choice(ddef[i], p=wdef[i] / wdef[i].sum()) for i in part])\n", + " cr = np.array([PARTS[i][5][0] for i in part]); ma = np.array([PARTS[i][5][1] for i in part])\n", + " u = rng.random(N)\n", + " sev = np.where(u < cr, 2, np.where(u < cr + ma, 1, 0))\n", + " vendor = rng.choice(VENDORS, N, p=[0.25, 0.2, 0.2, 0.15, 0.2])\n", + " # 원인: 기본 비율에 부위(제조사)·업체·이벤트 효과를 곱한다\n", + " base = np.array([0.22, 0.20, 0.20, 0.16, 0.22])\n", + " P = np.tile(base, (N, 1))\n", + " P[vendor == \"외주B\", 1] *= 2.6\n", + " P[ev, 0] *= 5.0\n", + " design = np.isin(part, [2, 3, 7]); P[design, 3] *= 2.2\n", + " P[np.isin(part, [4, 5, 11]), 0] *= 2.0 # 제조사 MK3 납품 부위는 제조 불량 비중이 높다\n", + " P[defect == \"마모\", 4] *= 1.6; P[defect == \"누유\", 4] *= 1.3\n", + " P /= P.sum(axis=1, keepdims=True)\n", + " cause = np.array([CAUSES[rng.choice(5, p=P[i])] for i in range(N)])\n", + " since = np.where(cause == \"정비 품질\", rng.exponential(35, N), rng.exponential(160, N)).clip(1, 720).round().astype(int)\n", + " wait = (rng.lognormal(1.3, 0.5, N) * np.where(vendor == \"외주B\", 1.7, np.where(vendor == \"부대정비\", 0.6, 1.0)) * (0.7 + 0.5 * use[month])).round(1)\n", + " reins = rng.random(N) < np.where(cause == \"정비 품질\", 0.6, 0.08)\n", + " texts = []\n", + " for i in range(N):\n", + " pt = PARTS[part[i]]\n", + " cl = [f\"{rng.choice(pt[1])}에서 {rng.choice(DEFECTS[defect[i]])}\"]\n", + " if rng.random() < 0.7: cl.append(rng.choice(SEV_CUE[sev[i]]))\n", + " if rng.random() < 0.65: # 원인 단서: 8%는 실제 원인과 다른 단서가 적힌다(보고자의 추정)\n", + " cc = cause[i] if rng.random() > 0.08 else rng.choice(CAUSES)\n", + " cl.append(rng.choice(CAUSE_CUE[cc]))\n", + " cl.append(rng.choice(ACTIONS))\n", + " if rng.random() < 0.5: cl.insert(0, f\"VU-{rng.integers(1, 31):02d} 소속\")\n", + " texts.append(\". \".join(cl) + \".\")\n", + " df = pd.DataFrame({\"qdr_id\": [f\"Q{seed}{i:06d}\" for i in range(N)], \"month\": month, \"unit\": [f\"VU-{x:02d}\" for x in rng.integers(1, 31, N)],\n", + " \"part\": part, \"defect\": defect, \"sev\": sev, \"cause\": cause, \"vendor\": vendor, \"maker\": [MAKER_OF[i] for i in part],\n", + " \"since_maint\": since, \"inspect_wait\": wait, \"reinspect\": reins, \"event\": ev, \"text\": texts})\n", + " df[\"cost\"] = PRICE[df[\"part\"]] * np.array([1.0, 2.0, 4.0])[df[\"sev\"]] # [교육용가상자료] 수리·조치 비용지수\n", + " return df, use, events\n", + "\n", + "df, use, events = make_qdr(36, seed=1)\n", + "names = [p[0] for p in PARTS]\n", + "tr, va = df[df[\"month\"] < 24], df[df[\"month\"] >= 24]\n", + "print(f\"[교육용가상자료] QDR {len(df)}건, 부위 {NP}종, 36개월 (학습 24개월 {len(tr)}건, 검증 12개월 {len(va)}건)\")\n", + "\n", + "# 1) Baseline: 학습 기간 건수 상위 5개 부위를 다발 결함 부위로 지정\n", + "cnt_tr = tr.groupby(\"part\").size().sort_values(ascending=False)\n", + "top_base = list(cnt_tr.index[:5])\n", + "cnt_va = va.groupby(\"part\").size().sort_values(ascending=False)\n", + "vuln = lambda d: d.groupby(\"part\")[\"sev\"].apply(lambda s: np.array([1, 3, 9])[s].sum()).sort_values(ascending=False)\n", + "v_va = vuln(va)\n", + "monthly_hit = np.mean([len(set(top_base) & set(g.groupby(\"part\").size().sort_values(ascending=False).index[:5])) / 5 for _, g in va.groupby(\"month\")])\n", + "print(f\"Baseline 상위 5 부위: {', '.join(names[i] for i in top_base)}\")\n", + "print(f\" 검증 기간 건수 상위 5와 일치 {len(set(top_base) & set(cnt_va.index[:5]))}/5, 월별 평균 적중률 {monthly_hit:.0%}\")\n", + "print(f\" 치명도 가중 취약성 지수 상위 5와 일치 {len(set(top_base) & set(v_va.index[:5]))}/5 -> 건수만 보면 놓치는 부위: \" + \", \".join(names[i] for i in set(v_va.index[:5]) - set(top_base)))\n", + "crit = va[va[\"sev\"] == 2]\n", + "miss = (~crit[\"part\"].isin(top_base)).mean()\n", + "print(f\" Critical 결함 {len(crit)}건 중 Baseline 대상 밖 부위 비중 {miss:.0%}\")\n", + "\n", + "# 2) 파레토(ABC): 건수와 조치 비용\n", + "par = pd.DataFrame({\"부위\": [names[i] for i in cnt_tr.index], \"건수\": cnt_tr.values}).set_index(\"부위\")\n", + "par[\"비용지수\"] = [df.loc[df[\"part\"] == i, \"cost\"].sum() for i in cnt_tr.index]\n", + "pc = par.sort_values(\"비용지수\", ascending=False)\n", + "pc[\"누적비율\"] = pc[\"비용지수\"].cumsum() / pc[\"비용지수\"].sum()\n", + "pc[\"등급\"] = np.where(pc[\"누적비율\"] <= 0.7, \"A\", np.where(pc[\"누적비율\"] <= 0.9, \"B\", \"C\")); pc.iloc[0, 3] = \"A\"\n", + "print(\"\\n[파레토] 조치 비용 기준(가상 단가지수)\")\n", + "print(pc.assign(비용지수=lambda d: d[\"비용지수\"].round(0), 누적비율=lambda d: d[\"누적비율\"].map(\"{:.0%}\".format)).head(6).to_string())\n", + "top20 = pc.head(int(np.ceil(NP * 0.2)))\n", + "print(f\"상위 20% 부위({', '.join(top20.index)})가 전체 조치 비용의 {top20['비용지수'].sum() / pc['비용지수'].sum():.0%}, A등급 {int((pc['등급'] == 'A').sum())}종\")\n", + "\n", + "# 3) 상관분석: 월간 QDR 건수와 월별 피처\n", + "mon = df.groupby(\"month\").agg(n=(\"qdr_id\", \"size\"), wait=(\"inspect_wait\", \"mean\"), re=(\"reinspect\", \"mean\"), crit=(\"sev\", lambda s: (s == 2).mean()),\n", + " ven_b=(\"vendor\", lambda s: (s == \"외주B\").mean()), short=(\"since_maint\", lambda s: (s < 60).mean()),\n", + " mfg=(\"cause\", lambda s: (s == \"제조 불량\").mean()), cost=(\"cost\", \"mean\"), units=(\"unit\", \"nunique\"),\n", + " short_n=(\"since_maint\", lambda s: (s < 60).sum()))\n", + "mon[\"usage\"] = use; mon[\"trend\"] = np.arange(36); mon[\"season\"] = np.cos(2 * np.pi * (mon[\"trend\"] % 12 - 7) / 12); mon[\"events\"] = [sum(1 for e in events if e[1] == m) for m in range(36)]\n", + "rows = []\n", + "for c, lab in [(\"usage\", \"훈련 강도\"), (\"trend\", \"경과 월(추세)\"), (\"season\", \"계절 요인\"), (\"wait\", \"평균 검사 대기일\"), (\"re\", \"재검사율\"), (\"short\", \"정비 후 60일 이내 비중\"),\n", + " (\"mfg\", \"제조 불량 비중\"), (\"cost\", \"건당 평균 조치 비용\"), (\"ven_b\", \"외주B 비중\"), (\"crit\", \"Critical 비중\"), (\"units\", \"보고 부대 수\"), (\"short_n\", \"정비 후 60일 이내 건수\")]:\n", + " r, p = spearmanr(mon[c], mon[\"n\"]); rows.append((lab, r, p))\n", + "cor = pd.DataFrame(rows, columns=[\"피처\", \"rho\", \"p\"]); cor[\"유의\"] = (cor[\"p\"] < 0.05) & (cor[\"rho\"].abs() >= 0.3)\n", + "print(\"\\n[상관분석] 피처 | 스피어만 rho | p-value | 유의(p<0.05, |rho|>=0.3)\")\n", + "for x in cor.sort_values(\"rho\", key=abs, ascending=False).head(7).itertuples():\n", + " print(f\" {x.피처:16s} {x.rho:+.2f} {x.p:.4f} {'유의' if x.유의 else '-'}\")\n", + "nsig = int(cor[\"유의\"].sum())\n", + "\n", + "# 4) 외주정비 업체별 검사 병목과 원인\n", + "ven = df.groupby(\"vendor\").agg(건수=(\"qdr_id\", \"size\"), 대기일=(\"inspect_wait\", \"mean\"), 재검사율=(\"reinspect\", \"mean\"), 정비품질원인=(\"cause\", lambda s: (s == \"정비 품질\").mean()))\n", + "print(\"\\n[업체별 검사 실적] 직전 정비 수행처 기준\")\n", + "print(ven.round(2).to_string())\n", + "b, o = df[df[\"vendor\"] == \"외주B\"], df[~df[\"vendor\"].isin([\"외주B\"])]\n", + "print(f\"외주B: 대기일 {b['inspect_wait'].mean():.1f}일(타 수행처 {o['inspect_wait'].mean():.1f}일), '정비 품질' 원인 {np.mean(b['cause'] == '정비 품질'):.0%}(타 {np.mean(o['cause'] == '정비 품질'):.0%}), 정비 후 60일 이내 재발 {np.mean(b['since_maint'] < 60):.0%}\")\n", + "\n", + "# 5) EDA 차트 8종\n", + "fig, ax = plt.subplots(2, 4, figsize=(16, 7))\n", + "H = df.pivot_table(index=\"part\", columns=\"month\", values=\"qdr_id\", aggfunc=\"size\", fill_value=0)\n", + "ax[0, 0].imshow(H.values, aspect=\"auto\", cmap=\"Reds\"); ax[0, 0].set_title(\"1 QDR heatmap (part x month)\")\n", + "ax[0, 1].plot(mon[\"n\"].values, label=\"QDR\"); ax[0, 1].plot(mon[\"usage\"].values * mon[\"n\"].mean(), label=\"usage x mean\"); ax[0, 1].legend(fontsize=6); ax[0, 1].set_title(\"2 Monthly QDR vs usage\")\n", + "ax[0, 2].bar(range(NP), pc[\"비용지수\"].values); ax[0, 2].plot(range(NP), pc[\"누적비율\"].values * pc[\"비용지수\"].max(), \"r-o\"); ax[0, 2].set_title(\"3 Pareto of repair cost\")\n", + "sv = pd.crosstab(df[\"part\"], df[\"sev\"], normalize=\"index\"); ax[0, 3].bar(range(NP), sv[0], label=\"Minor\"); ax[0, 3].bar(range(NP), sv[1], bottom=sv[0], label=\"Major\"); ax[0, 3].bar(range(NP), sv[2], bottom=sv[0] + sv[1], label=\"Critical\"); ax[0, 3].legend(fontsize=6); ax[0, 3].set_title(\"4 Severity share by part\")\n", + "ax[1, 0].boxplot([df.loc[df[\"vendor\"] == v, \"inspect_wait\"] for v in VENDORS], labels=[v[-1] for v in VENDORS]); ax[1, 0].set_title(\"5 Inspection wait by vendor\")\n", + "cv = pd.crosstab(df[\"vendor\"], df[\"cause\"], normalize=\"index\"); ax[1, 1].imshow(cv.values, cmap=\"Blues\"); ax[1, 1].set_title(\"6 Cause share by vendor\")\n", + "ax[1, 2].hist([df.loc[df[\"cause\"] == c, \"since_maint\"].clip(0, 400) for c in CAUSES[:2]], bins=20, label=[\"mfg\", \"maint\"]); ax[1, 2].legend(fontsize=6); ax[1, 2].set_title(\"7 Days since maintenance\")\n", + "ax[1, 3].scatter(mon[\"wait\"], mon[\"n\"], s=12); ax[1, 3].set_title(\"8 Wait vs monthly QDR\")\n", + "plt.tight_layout(); plt.show()\n", + "\n", + "print(f\"\\nEDA 차트 8종 생성(목표 8종 이상: 달성), 유의한 피처 {nsig}개 (목표 5개 이상: {'달성' if nsig >= 5 else '미달'})\")\n", + "print(\"핵심 발견: 1) 건수 상위만 보면 조향 장치처럼 치명도 높은 부위를 놓침 2) 외주B는 검사 대기가 길고 정비 후 조기 재발 비중이 높음 3) 월별 QDR는 훈련 강도와 계절에 연동\")\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 03 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + "[교육용가상자료] QDR 9221건, 부위 12종, 36개월 (학습 24개월 5906건, 검증 12개월 3315건)\n", + "Baseline 상위 5 부위: 전기·배터리, 엔진 연료계통, 냉각 계통, 제동 장치, 변속기\n", + " 검증 기간 건수 상위 5와 일치 5/5, 월별 평균 적중률 80%\n", + " 치명도 가중 취약성 지수 상위 5와 일치 4/5 -> 건수만 보면 놓치는 부위: 조향 장치\n", + " Critical 결함 366건 중 Baseline 대상 밖 부위 비중 46%\n", + "\n", + "[파레토] 조치 비용 기준(가상 단가지수)\n", + " 건수 비용지수 누적비율 등급\n", + "부위 \n", + "변속기 558 13464.0 18% A\n", + "엔진 연료계통 770 11550.0 33% A\n", + "제동 장치 596 10220.0 47% A\n", + "조향 장치 397 7155.0 56% A\n", + "차체 용접부 277 6776.0 65% A\n", + "유압 계통 462 5607.0 73% B\n", + "상위 20% 부위(변속기, 엔진 연료계통, 제동 장치)가 전체 조치 비용의 47%, A등급 5종\n", + "\n", + "[상관분석] 피처 | 스피어만 rho | p-value | 유의(p<0.05, |rho|>=0.3)\n", + " 정비 후 60일 이내 건수 +0.94 0.0000 유의\n", + " 평균 검사 대기일 +0.83 0.0000 유의\n", + " 훈련 강도 +0.82 0.0000 유의\n", + " 계절 요인 +0.78 0.0000 유의\n", + " 경과 월(추세) +0.39 0.0200 유의\n", + " 외주B 비중 -0.22 0.1928 -\n", + " Critical 비중 +0.16 0.3583 -\n", + "\n", + "[업체별 검사 실적] 직전 정비 수행처 기준\n", + " 건수 대기일 재검사율 정비품질원인\n", + "vendor \n", + "부대정비 1811 3.11 0.18 0.18\n", + "외주A 2231 5.00 0.17 0.18\n", + "외주B 1916 8.44 0.27 0.36\n", + "외주C 1864 5.08 0.16 0.17\n", + "외주D 1399 4.98 0.17 0.17\n", + "외주B: 대기일 8.4일(타 수행처 4.5일), '정비 품질' 원인 36%(타 18%), 정비 후 60일 이내 재발 49%\n", + "\n", + "EDA 차트 8종 생성(목표 8종 이상: 달성), 유의한 피처 5개 (목표 5개 이상: 달성)\n", + "핵심 발견: 1) 건수 상위만 보면 조향 장치처럼 치명도 높은 부위를 놓침 2) 외주B는 검사 대기가 길고 정비 후 조기 재발 비중이 높음 3) 월별 QDR는 훈련 강도와 계절에 연동\n", + "```\n", + "
\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 04. 텍스트 임베딩 및 토픽 모델링 기반 장비 구조적 품질 취약점 자동 군집화 모델 고도화\n", + "**소요시간**: 약 3.0시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "토픽 모델링 기반 장비 구조적 품질 취약점 자동 군집화 모델을 고도화한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **텍스트 임베딩(TF-IDF)과 토픽 모델링(NMF)으로 장비 구조적 품질 취약점을 자동 군집화하고, 부위별 월간 QDR 건수 기준 모델을 파생 피처로 고도화한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **결함 서술의 핵심 문장을 TF-IDF로 벡터화하고 NMF 토픽 36개로 군집화해 부위·결함 라벨과의 일치도(NMI) 확인하고 유효성을 점검하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `src/features/qlt05_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": [ + "\"\"\"QDR 서술 텍스트를 TF-IDF로 벡터화하고 NMF 토픽 모델링으로 장비 구조적 품질 취약점을 자동 군집화해 부위·결함 라벨과의 일치도(NMI)와 취약 군집(치명도·설계 취약 비중)을 확인하고, 토픽 비중 등 파생 피처 14개로 부위별 월간 QDR 건수 기준 모델을 만들어 시계열 교차검증 MAPE를 평가하고 순열 중요도로 피처를 줄인다.\"\"\"\n", + "import re\n", + "import unicodedata\n", + "import numpy as np\n", + "import pandas as pd\n", + "from sklearn.decomposition import NMF\n", + "from sklearn.ensemble import GradientBoostingRegressor\n", + "from sklearn.feature_extraction.text import TfidfVectorizer\n", + "from sklearn.inspection import permutation_importance\n", + "from sklearn.metrics import normalized_mutual_info_score\n", + "\n", + "# ---------------------------------------------------------------------------\n", + "# [교육용가상자료] 가상 야전 장비 품질결함보고(QDR) 생성기 (실제 부대·장비·업체·정비 실적 없음)\n", + "# 가상 장비 부위 12종, 결함 유형 8종, 치명도(Critical/Major/Minor), 원인 5종, 가상 외주정비 업체 4곳 + 부대정비.\n", + "# QDR 1건마다 한국어 서술 텍스트를 만들고, 월별 건수는 계절·추세·훈련 강도(usage)·일부 제조 불량 이벤트를 따른다.\n", + "# ---------------------------------------------------------------------------\n", + "PARTS = [ # (이름, 별칭, 월 기준 건수, 계절 진폭, 정점 월, (Critical, Major 비율), 가상 단가지수, 제조사, 주요 결함 가중치)\n", + " (\"엔진 연료계통\", [\"연료계통\", \"연료 라인\", \"연료 펌프\"], 26, 0.15, 7, (0.10, 0.35), 6.0, \"MK1\", \"누유:4 마모:2 체결불량:2 오작동:2\"),\n", + " (\"변속기\", [\"변속기\", \"T/M\", \"미션\"], 22, 0.10, 8, (0.12, 0.38), 9.0, \"MK1\", \"마모:4 누유:2 과열:3 오작동:1\"),\n", + " (\"제동 장치\", [\"제동 장치\", \"브레이크\", \"제동 라인\"], 20, 0.20, 12, (0.26, 0.40), 5.0, \"MK2\", \"마모:4 균열:2 누유:2 체결불량:2\"),\n", + " (\"조향 장치\", [\"조향 장치\", \"핸들 연동부\", \"조향 로드\"], 14, 0.10, 6, (0.30, 0.40), 5.0, \"MK2\", \"체결불량:4 균열:2 마모:3 오작동:1\"),\n", + " (\"냉각 계통\", [\"냉각 계통\", \"라디에이터\", \"냉각수 라인\"], 24, 0.30, 7, (0.05, 0.30), 3.0, \"MK3\", \"누유:4 과열:4 부식:2\"),\n", + " (\"전기·배터리\", [\"배터리\", \"전기 계통\", \"발전기\"], 28, 0.35, 1, (0.04, 0.28), 2.5, \"MK3\", \"접촉불량:4 부식:2 과열:2 오작동:2\"),\n", + " (\"통신 모듈\", [\"통신 모듈\", \"무전기 연동부\", \"안테나 접속부\"], 16, 0.05, 4, (0.03, 0.25), 4.0, \"MK4\", \"접촉불량:5 오작동:4 부식:1\"),\n", + " (\"유압 계통\", [\"유압 계통\", \"유압 호스\", \"유압 실린더\"], 18, 0.10, 9, (0.10, 0.35), 4.5, \"MK2\", \"누유:5 균열:2 마모:2 체결불량:1\"),\n", + " (\"현가 장치\", [\"현가 장치\", \"서스펜션\", \"완충기\"], 15, 0.08, 5, (0.06, 0.30), 3.5, \"MK4\", \"마모:4 균열:3 체결불량:2 부식:1\"),\n", + " (\"차체 용접부\", [\"차체 용접부\", \"프레임 용접부\", \"차체 이음부\"], 10, 0.05, 10, (0.28, 0.40), 7.0, \"MK1\", \"균열:6 부식:3 체결불량:1\"),\n", + " (\"광학·조준 장비\", [\"광학 장비\", \"조준경\", \"관측 장비\"], 8, 0.05, 3, (0.03, 0.22), 8.0, \"MK4\", \"오작동:4 접촉불량:3 부식:2 체결불량:1\"),\n", + " (\"공기 정화 필터\", [\"공기 필터\", \"흡기 필터\", \"정화 장치\"], 12, 0.25, 4, (0.01, 0.12), 1.0, \"MK3\", \"마모:5 부식:2 체결불량:3\")]\n", + "NP = len(PARTS)\n", + "DEFECTS = { # 결함 유형: 증상 표현(동의어)\n", + " \"균열\": [\"균열이 발견됨\", \"크랙이 확인됨\", \"갈라짐이 있음\"], \"누유\": [\"누유가 발생함\", \"오일이 새어 나옴\", \"누설 흔적이 있음\"],\n", + " \"마모\": [\"마모가 심함\", \"과도한 마모가 확인됨\", \"닳은 흔적이 있음\"], \"부식\": [\"부식이 진행됨\", \"녹이 발생함\", \"산화 흔적이 있음\"],\n", + " \"접촉불량\": [\"접촉 불량이 발생함\", \"커넥터 단선이 의심됨\", \"신호가 끊김\"], \"체결불량\": [\"볼트 풀림이 확인됨\", \"체결 상태가 불량함\", \"고정부가 헐거움\"],\n", + " \"과열\": [\"과열 경고가 발생함\", \"온도가 과도하게 상승함\", \"열이 심하게 남\"], \"오작동\": [\"오작동이 반복됨\", \"작동이 불안정함\", \"간헐적으로 멈춤\"]}\n", + "DNAMES = list(DEFECTS)\n", + "SEV = [\"Minor\", \"Major\", \"Critical\"]\n", + "SEV_CUE = {0: [\"경미한 수준임\", \"운용에는 지장이 없음\", \"육안 점검상 사소한 이상임\", \"기능에는 영향이 없음\"],\n", + " 1: [\"성능 저하가 있음\", \"조속한 정비가 필요함\", \"출력이 떨어지는 상태임\", \"기능이 부분적으로 제한됨\"],\n", + " 2: [\"즉시 운용 중지함\", \"안전에 위험이 있어 사용 불가\", \"작동 정지로 임무 수행이 어려움\", \"인명 안전 위협이 우려됨\"]}\n", + "CAUSES = [\"제조 불량\", \"정비 품질\", \"운용 부주의\", \"설계 취약\", \"자연 노후\"]\n", + "CAUSE_CUE = {\n", + " \"제조 불량\": [\"신품 인수 직후 발생함\", \"출고 상태에서 이미 이상이 있었음\", \"인수 후 얼마 지나지 않아 발생함\"],\n", + " \"정비 품질\": [\"외주 정비 후 얼마 되지 않아 재발함\", \"정비 후 재조립 상태가 불량함\", \"정비 직후 같은 부위에서 다시 발생함\"],\n", + " \"운용 부주의\": [\"험지 기동 중 충격 이후 발생함\", \"과부하 운용 후 발생함\", \"점검 없이 연속 운용한 뒤 발생함\"],\n", + " \"설계 취약\": [\"동일 증상이 다른 차량에서도 반복됨\", \"구조적으로 취약한 부위로 보임\", \"여러 부대에서 같은 형태로 보고됨\"],\n", + " \"자연 노후\": [\"사용 연한이 도래함\", \"장기간 사용으로 노후됨\", \"수명 주기 말기에 발생함\"]}\n", + "VENDORS = [\"외주A\", \"외주B\", \"외주C\", \"외주D\", \"부대정비\"]\n", + "ACTIONS = [\"정비소 입고를 요청함\", \"현장 임시 조치 후 운용 중임\", \"부품 교환을 요청함\", \"재검사를 요청함\", \"원인 확인이 필요함\"]\n", + "MAKER_OF = [p[7] for p in PARTS]\n", + "PRICE = np.array([p[6] for p in PARTS])\n", + "\n", + "\n", + "def usage_index(months, seed=0):\n", + " \"\"\"월별 훈련 강도(미리 알 수 있는 계획 값): 연 1회 정점을 갖는 계절 + 작은 잡음\"\"\"\n", + " r = np.random.default_rng(seed + 99)\n", + " t = np.arange(months)\n", + " return 1 + 0.25 * np.sin(2 * np.pi * (t - 3) / 12) + r.normal(0, 0.04, months)\n", + "\n", + "\n", + "def make_qdr(months=36, seed=1, scale=1.0, surge=True):\n", + " \"\"\"반환: QDR DataFrame(건당 1행), 월별 usage, 급증 이벤트 목록\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " use = usage_index(months, seed)\n", + " events = []\n", + " if surge:\n", + " for _ in range(max(2, months // 12 * 2)):\n", + " events.append((int(rng.integers(NP)), int(rng.integers(6, months))))\n", + " rows = []\n", + " wdef = [np.array([float(x.split(\":\")[1]) for x in p[8].split()]) for p in PARTS]\n", + " ddef = [[x.split(\":\")[0] for x in p[8].split()] for p in PARTS]\n", + " n = 0\n", + " for m in range(months):\n", + " for pi, p in enumerate(PARTS):\n", + " lam = p[2] * scale * (1 + 0.010 * m) * use[m] * (1 + p[3] * np.cos(2 * np.pi * (m % 12 - p[4]) / 12))\n", + " ev = (pi, m) in events\n", + " k = rng.poisson(lam * (2.6 if ev else 1.0))\n", + " for _ in range(k):\n", + " rows.append((n, m, pi, ev)); n += 1\n", + " N = len(rows)\n", + " A = np.array(rows)\n", + " month, part, ev = A[:, 1], A[:, 2], A[:, 3].astype(bool)\n", + " defect = np.array([rng.choice(ddef[i], p=wdef[i] / wdef[i].sum()) for i in part])\n", + " cr = np.array([PARTS[i][5][0] for i in part]); ma = np.array([PARTS[i][5][1] for i in part])\n", + " u = rng.random(N)\n", + " sev = np.where(u < cr, 2, np.where(u < cr + ma, 1, 0))\n", + " vendor = rng.choice(VENDORS, N, p=[0.25, 0.2, 0.2, 0.15, 0.2])\n", + " # 원인: 기본 비율에 부위(제조사)·업체·이벤트 효과를 곱한다\n", + " base = np.array([0.22, 0.20, 0.20, 0.16, 0.22])\n", + " P = np.tile(base, (N, 1))\n", + " P[vendor == \"외주B\", 1] *= 2.6\n", + " P[ev, 0] *= 5.0\n", + " design = np.isin(part, [2, 3, 7]); P[design, 3] *= 2.2\n", + " P[np.isin(part, [4, 5, 11]), 0] *= 2.0 # 제조사 MK3 납품 부위는 제조 불량 비중이 높다\n", + " P[defect == \"마모\", 4] *= 1.6; P[defect == \"누유\", 4] *= 1.3\n", + " P /= P.sum(axis=1, keepdims=True)\n", + " cause = np.array([CAUSES[rng.choice(5, p=P[i])] for i in range(N)])\n", + " since = np.where(cause == \"정비 품질\", rng.exponential(35, N), rng.exponential(160, N)).clip(1, 720).round().astype(int)\n", + " wait = (rng.lognormal(1.3, 0.5, N) * np.where(vendor == \"외주B\", 1.7, np.where(vendor == \"부대정비\", 0.6, 1.0)) * (0.7 + 0.5 * use[month])).round(1)\n", + " reins = rng.random(N) < np.where(cause == \"정비 품질\", 0.6, 0.08)\n", + " texts = []\n", + " for i in range(N):\n", + " pt = PARTS[part[i]]\n", + " cl = [f\"{rng.choice(pt[1])}에서 {rng.choice(DEFECTS[defect[i]])}\"]\n", + " if rng.random() < 0.7: cl.append(rng.choice(SEV_CUE[sev[i]]))\n", + " if rng.random() < 0.65: # 원인 단서: 8%는 실제 원인과 다른 단서가 적힌다(보고자의 추정)\n", + " cc = cause[i] if rng.random() > 0.08 else rng.choice(CAUSES)\n", + " cl.append(rng.choice(CAUSE_CUE[cc]))\n", + " cl.append(rng.choice(ACTIONS))\n", + " if rng.random() < 0.5: cl.insert(0, f\"VU-{rng.integers(1, 31):02d} 소속\")\n", + " texts.append(\". \".join(cl) + \".\")\n", + " df = pd.DataFrame({\"qdr_id\": [f\"Q{seed}{i:06d}\" for i in range(N)], \"month\": month, \"unit\": [f\"VU-{x:02d}\" for x in rng.integers(1, 31, N)],\n", + " \"part\": part, \"defect\": defect, \"sev\": sev, \"cause\": cause, \"vendor\": vendor, \"maker\": [MAKER_OF[i] for i in part],\n", + " \"since_maint\": since, \"inspect_wait\": wait, \"reinspect\": reins, \"event\": ev, \"text\": texts})\n", + " df[\"cost\"] = PRICE[df[\"part\"]] * np.array([1.0, 2.0, 4.0])[df[\"sev\"]] # [교육용가상자료] 수리·조치 비용지수\n", + " return df, use, events\n", + "\n", + "# ---------------------------------------------------------------------------\n", + "# 공통 텍스트 조각: 정규화, 간이 토큰화(조사 제거), 부위·결함·치명도·원인 키워드 라벨링\n", + "# ---------------------------------------------------------------------------\n", + "TYPO = {\"누윳\": \"누유\", \"균렬\": \"균열\", \"접축 불량\": \"접촉 불량\", \"부씩\": \"부식\", \"마묘\": \"마모\", \"과렬\": \"과열\", \"오작똥\": \"오작동\", \"재동\": \"제동\"}\n", + "ABBR = {\"T/M\": \"변속기\", \"BAT\": \"배터리\"}\n", + "PART_KW = sorted([(a, i) for i, p in enumerate(PARTS) for a in p[1]], key=lambda x: -len(x[0]))\n", + "DEF_KW = {\"균열\": [\"균열\", \"크랙\", \"갈라\"], \"누유\": [\"누유\", \"새어\", \"누설\"], \"마모\": [\"마모\", \"닳은\"], \"부식\": [\"부식\", \"녹이\", \"산화\"],\n", + " \"접촉불량\": [\"접촉 불량\", \"단선\", \"끊김\"], \"체결불량\": [\"볼트\", \"체결\", \"헐거\"], \"과열\": [\"과열\", \"온도\", \"열이\"], \"오작동\": [\"오작동\", \"불안정\", \"멈춤\"]}\n", + "SEV_KW = [(2, [\"즉시 운용 중지\", \"사용 불가\", \"위험\"]), (1, [\"성능 저하\", \"조속한\"]), (0, [\"경미\", \"지장이 없\"])]\n", + "CAUSE_KW = {\"제조 불량\": [\"신품\", \"출고\", \"인수\"], \"정비 품질\": [\"정비 후\", \"재조립\", \"정비 직후\"], \"운용 부주의\": [\"충격\", \"과부하\", \"점검 없이\"],\n", + " \"설계 취약\": [\"동일 증상\", \"구조적\", \"여러 부대\"], \"자연 노후\": [\"사용 연한\", \"노후\", \"수명\"]}\n", + "PARTICLES = (\"에서\", \"으로\", \"이\", \"가\", \"은\", \"는\", \"을\", \"를\", \"에\", \"의\", \"로\", \"와\", \"과\", \"도\", \"함\", \"음\", \"됨\")\n", + "\n", + "\n", + "def norm(t):\n", + " \"\"\"정규화: 전각->반각, 특수문자·중복 공백 제거, 약어 확장, 오타 교정\"\"\"\n", + " t = unicodedata.normalize(\"NFKC\", str(t))\n", + " t = re.sub(r\"[!#~*@^]+\", \" \", t)\n", + " for a, b in ABBR.items():\n", + " t = t.replace(a, b)\n", + " for a, b in TYPO.items():\n", + " t = t.replace(a, b)\n", + " return re.sub(r\"\\s+\", \" \", t).strip()\n", + "\n", + "\n", + "def tok(t):\n", + " out = []\n", + " for w in re.split(r\"[\\s.,]+\", norm(t)):\n", + " if not w or re.fullmatch(r\"[\\d\\-A-Za-z]+\", w):\n", + " continue\n", + " for p in PARTICLES:\n", + " if len(w) > len(p) + 1 and w.endswith(p):\n", + " w = w[:-len(p)]\n", + " break\n", + " out.append(w)\n", + " return out\n", + "\n", + "\n", + "def label_part(t):\n", + " for a, i in PART_KW:\n", + " if a in t:\n", + " return i\n", + " return -1\n", + "\n", + "\n", + "def label_defect(t):\n", + " for d, ks in DEF_KW.items():\n", + " if any(k in t for k in ks):\n", + " return d\n", + " return \"미분류\"\n", + "\n", + "\n", + "def label_sev(t):\n", + " for s, ks in SEV_KW:\n", + " if any(k in t for k in ks):\n", + " return s\n", + " return -1\n", + "\n", + "\n", + "def label_cause(t):\n", + " for c, ks in CAUSE_KW.items():\n", + " if any(k in t for k in ks):\n", + " return c\n", + " return \"미상\"\n", + "\n", + "df, use, events = make_qdr(36, seed=1, scale=3.0)\n", + "names = [p[0] for p in PARTS]\n", + "print(f\"[교육용가상자료] QDR {len(df)}건, 36개월\")\n", + "print(\"(토픽 모델은 결함 서술 핵심 문장 '부위에서 증상'에 적용하고, 치명도·원인은 군집 해석에 사용)\")\n", + "\n", + "# 1) 텍스트 임베딩(TF-IDF) + NMF 토픽 모델링\n", + "STOP = set(w for a in ACTIONS for w in tok(a)) | {\"소속\"}\n", + "vec = TfidfVectorizer(tokenizer=lambda s: [w for w in tok(s) if w not in STOP], lowercase=False, token_pattern=None, min_df=5)\n", + "df[\"core\"] = df[\"text\"].map(lambda s: [x for x in s.split(\". \") if \"에서\" in x][0]) # 결함 서술 핵심 문장(부위+증상)\n", + "X = vec.fit_transform(df[\"core\"])\n", + "K = 36\n", + "nmf = NMF(n_components=K, init=\"nndsvd\", random_state=0, max_iter=400).fit(X)\n", + "Wd = nmf.transform(X)\n", + "df[\"topic\"] = Wd.argmax(axis=1)\n", + "vocab = np.array(vec.get_feature_names_out())\n", + "nmi_p = normalized_mutual_info_score(df[\"part\"], df[\"topic\"]); nmi_d = normalized_mutual_info_score(df[\"defect\"], df[\"topic\"]); nmi_pd = normalized_mutual_info_score(df[\"part\"].astype(str) + df[\"defect\"], df[\"topic\"])\n", + "print(f\"TF-IDF 어휘 {X.shape[1]}개, 토픽 {K}개: 군집-라벨 일치도(NMI) 부위 {nmi_p:.2f}, 결함 유형 {nmi_d:.2f}, 부위+결함 {nmi_pd:.2f}\")\n", + "base_acc = np.mean([label_part(t) == p for t, p in zip(df[\"text\"], df[\"part\"])])\n", + "print(f\" 참고: 키워드 규칙 부위 라벨 정확도 {base_acc:.1%}\")\n", + "g = df.groupby(\"topic\").agg(건수=(\"qdr_id\", \"size\"), critical=(\"sev\", lambda s: (s == 2).mean()), 설계취약=(\"cause\", lambda s: (s == \"설계 취약\").mean()),\n", + " 주부위=(\"part\", lambda s: names[s.mode()[0]]), 주결함=(\"defect\", lambda s: s.mode()[0]))\n", + "g[\"핵심어\"] = [\", \".join(vocab[np.argsort(-nmf.components_[k])[:4]]) for k in g.index]\n", + "g[\"취약지수\"] = g[\"건수\"] * (g[\"critical\"] * 9 + (1 - g[\"critical\"]) * 1.5)\n", + "print(\"\\n[취약 군집 상위 5] 토픽 | 건수 | Critical 비중 | 주 부위 | 주 결함 | 핵심어\")\n", + "for k, r in g.sort_values(\"취약지수\", ascending=False).head(5).iterrows():\n", + " print(f\" T{k:02d} {r['건수']:4d} {r['critical']:.0%} {r['주부위']} / {r['주결함']} [{r['핵심어']}]\")\n", + "struct = list(g[\"설계취약\"].sort_values(ascending=False).index[:8])\n", + "print(f\"설계 취약 비중 상위 토픽 {len(struct)}개(구조적 취약 후보): \" + \", \".join(f\"T{k:02d}\" for k in struct) + f\" -> 해당 토픽 QDR의 설계 취약 비중 {df.loc[df['topic'].isin(struct), 'cause'].eq('설계 취약').mean():.0%} (전체 {df['cause'].eq('설계 취약').mean():.0%})\")\n", + "\n", + "# 2) 부위별 월간 QDR 건수 기준 모델: 파생 피처 13개\n", + "top8 = list(df.groupby(\"part\").size().sort_values(ascending=False).index[:8])\n", + "mon = df.groupby([\"part\", \"month\"]).agg(n=(\"qdr_id\", \"size\"), crit=(\"sev\", lambda s: (s == 2).mean()), wait=(\"inspect_wait\", \"mean\"), vb=(\"vendor\", lambda s: (s == \"외주B\").mean()),\n", + " re=(\"reinspect\", \"mean\"), st=(\"topic\", lambda s: s.isin(struct).mean())).reset_index()\n", + "rows = []\n", + "for p in top8:\n", + " d = mon[mon[\"part\"] == p].set_index(\"month\").reindex(range(36)).fillna(0)\n", + " f = pd.DataFrame({\"part\": p, \"month\": range(36), \"n\": d[\"n\"].values})\n", + " f[\"lag1\"], f[\"lag2\"] = f[\"n\"].shift(1), f[\"n\"].shift(2)\n", + " f[\"roll3\"] = f[\"n\"].shift(1).rolling(3).mean()\n", + " f[\"exp_count\"] = (f[\"n\"] / use).shift(1).rolling(6, min_periods=3).mean() * use # 최근 6개월 평균 발생률 x 이번 달 훈련 강도\n", + " f[\"usage\"], f[\"usage_lag1\"] = use, np.r_[use[0], use[:-1]]\n", + " f[\"season_c\"], f[\"season_s\"] = np.cos(2 * np.pi * f[\"month\"] / 12), np.sin(2 * np.pi * f[\"month\"] / 12)\n", + " f[\"trend\"] = f[\"month\"]\n", + " for c in (\"crit\", \"wait\", \"vb\", \"re\", \"st\"):\n", + " f[c + \"_lag1\"] = d[c].shift(1).values\n", + " rows.append(f)\n", + "F = pd.concat(rows, ignore_index=True).dropna()\n", + "DER = [\"lag1\", \"lag2\", \"roll3\", \"exp_count\", \"usage\", \"usage_lag1\", \"season_c\", \"season_s\", \"trend\", \"crit_lag1\", \"wait_lag1\", \"vb_lag1\", \"re_lag1\", \"st_lag1\"]\n", + "print(f\"\\n월간 건수 예측: 파생 피처 {len(DER)}개 (목표 10개 이상: {'달성' if len(DER) >= 10 else '미달'}), 대상 {len(top8)}개 부위 x {F['month'].nunique()}개월\")\n", + "\n", + "\n", + "def fit(d, cols):\n", + " return GradientBoostingRegressor(n_estimators=120, max_depth=3, learning_rate=0.08, subsample=0.8, random_state=0).fit(d[cols], d[\"n\"])\n", + "\n", + "\n", + "mape = lambda y, p: float(np.mean(np.abs(y - p) / y))\n", + "folds, naive = [], []\n", + "for c in (17, 23, 29): # 확장 윈도: 과거로만 학습, 뒤 6개월 검증\n", + " trn, val = F[F[\"month\"] <= c], F[(F[\"month\"] > c) & (F[\"month\"] <= c + 6)]\n", + " folds.append(mape(val[\"n\"], fit(trn, DER).predict(val[DER]))); naive.append(mape(val[\"n\"], val[\"lag1\"]))\n", + "print(f\"시계열 교차검증 폴드별 MAPE: {', '.join(f'{x:.1%}' for x in folds)} -> 평균 {np.mean(folds):.1%} (목표 25% 이하: {'달성' if np.mean(folds) <= 0.25 else '미달'}), 전월값 기준 평균 {np.mean(naive):.1%}\")\n", + "\n", + "# 3) 피처 중요도로 차원 축소\n", + "trn, val = F[F[\"month\"] <= 29], F[F[\"month\"] > 29]\n", + "full = fit(trn, DER)\n", + "pi = permutation_importance(full, val[DER], val[\"n\"], n_repeats=5, random_state=0, scoring=\"neg_mean_absolute_error\")\n", + "imp = pd.Series(pi.importances_mean, DER).sort_values(ascending=False)\n", + "print(\"피처 중요도 상위 5: \" + \", \".join(f\"{k}({v:.2f})\" for k, v in imp.head(5).items()))\n", + "top = list(imp.index[:7])\n", + "print(f\"차원 축소 {len(DER)}개 -> {len(top)}개: 검증 MAPE {mape(val['n'], full.predict(val[DER])):.1%} -> {mape(val['n'], fit(trn, top).predict(val[top])):.1%}\")\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 04 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + "[교육용가상자료] QDR 27675건, 36개월\n", + "(토픽 모델은 결함 서술 핵심 문장 '부위에서 증상'에 적용하고, 치명도·원인은 군집 해석에 사용)\n", + "TF-IDF 어휘 90개, 토픽 36개: 군집-라벨 일치도(NMI) 부위 0.45, 결함 유형 0.60, 부위+결함 0.63\n", + " 참고: 키워드 규칙 부위 라벨 정확도 100.0%\n", + "\n", + "[취약 군집 상위 5] 토픽 | 건수 | Critical 비중 | 주 부위 | 주 결함 | 핵심어\n", + " T03 1711 10% 엔진 연료계통 / 누유 [나옴, 새어, 오일, 라디에이터]\n", + " T24 1631 11% 변속기 / 마모 [닳은, 흔적, 있음, 산화]\n", + " T28 935 30% 조향 장치 / 체결불량 [조향, 로드, 장치, 크랙]\n", + " T02 1430 12% 냉각 계통 / 누유 [누유, 발생, 녹이, 라디에이터]\n", + " T25 1009 18% 조향 장치 / 접촉불량 [연동부, 핸들, 무전기, 접촉]\n", + "설계 취약 비중 상위 토픽 8개(구조적 취약 후보): T05, T28, T35, T13, T11, T25, T30, T02 -> 해당 토픽 QDR의 설계 취약 비중 21% (전체 17%)\n", + "\n", + "월간 건수 예측: 파생 피처 14개 (목표 10개 이상: 달성), 대상 8개 부위 x 33개월\n", + "시계열 교차검증 폴드별 MAPE: 27.8%, 13.9%, 16.9% -> 평균 19.5% (목표 25% 이하: 달성), 전월값 기준 평균 18.9%\n", + "피처 중요도 상위 5: lag1(6.77), usage(1.86), exp_count(1.78), st_lag1(0.81), lag2(0.68)\n", + "차원 축소 14개 -> 7개: 검증 MAPE 16.9% -> 15.3%\n", + "```\n", + "
\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 05. 결함 치명도(Critical/Major/Minor) 기반 종합 품질 취약성 지수 산출 엔진 개발\n", + "**소요시간**: 약 3.5시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "결함 치명도 기반 종합 품질 취약성 지수 산출 엔진을 개발한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **결함 치명도(Critical/Major/Minor)를 분류하고 치명도 가중 종합 품질 취약성 지수를 부위별로 산출·예측하는 엔진을 개발한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **키워드 규칙과 TF-IDF 로지스틱 회귀의 치명도 분류 정확도와 Critical 재현율을 비교하고 신뢰도 구간별 실제 정확도를 점검 수행하고 검증하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `src/models/qlt05_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": [ + "\"\"\"QDR 서술·부위·결함 정보로 개별 결함의 치명도(Critical/Major/Minor)를 분류하는 모델(키워드 규칙 기준 대비)과 신뢰도 보정을 확인하고, 치명도 가중 부위별 월간 종합 품질 취약성 지수를 앙상블(릿지에 그래디언트 부스팅·랜덤 포레스트 잔차 보정을 더한 모델)로 예측해 학습 기간 평균 기준 모델과 MAE·RMSE·R2를 비교하는 취약성 지수 산출 엔진을 구현한다.\"\"\"\n", + "import re\n", + "import unicodedata\n", + "import numpy as np\n", + "import pandas as pd\n", + "import scipy.sparse as sp\n", + "from sklearn.ensemble import GradientBoostingRegressor, RandomForestRegressor\n", + "from sklearn.feature_extraction.text import TfidfVectorizer\n", + "from sklearn.linear_model import LogisticRegression, Ridge\n", + "from sklearn.metrics import mean_absolute_error, mean_squared_error, r2_score\n", + "from sklearn.pipeline import make_pipeline\n", + "from sklearn.preprocessing import StandardScaler\n", + "\n", + "# ---------------------------------------------------------------------------\n", + "# [교육용가상자료] 가상 야전 장비 품질결함보고(QDR) 생성기 (실제 부대·장비·업체·정비 실적 없음)\n", + "# 가상 장비 부위 12종, 결함 유형 8종, 치명도(Critical/Major/Minor), 원인 5종, 가상 외주정비 업체 4곳 + 부대정비.\n", + "# QDR 1건마다 한국어 서술 텍스트를 만들고, 월별 건수는 계절·추세·훈련 강도(usage)·일부 제조 불량 이벤트를 따른다.\n", + "# ---------------------------------------------------------------------------\n", + "PARTS = [ # (이름, 별칭, 월 기준 건수, 계절 진폭, 정점 월, (Critical, Major 비율), 가상 단가지수, 제조사, 주요 결함 가중치)\n", + " (\"엔진 연료계통\", [\"연료계통\", \"연료 라인\", \"연료 펌프\"], 26, 0.15, 7, (0.10, 0.35), 6.0, \"MK1\", \"누유:4 마모:2 체결불량:2 오작동:2\"),\n", + " (\"변속기\", [\"변속기\", \"T/M\", \"미션\"], 22, 0.10, 8, (0.12, 0.38), 9.0, \"MK1\", \"마모:4 누유:2 과열:3 오작동:1\"),\n", + " (\"제동 장치\", [\"제동 장치\", \"브레이크\", \"제동 라인\"], 20, 0.20, 12, (0.26, 0.40), 5.0, \"MK2\", \"마모:4 균열:2 누유:2 체결불량:2\"),\n", + " (\"조향 장치\", [\"조향 장치\", \"핸들 연동부\", \"조향 로드\"], 14, 0.10, 6, (0.30, 0.40), 5.0, \"MK2\", \"체결불량:4 균열:2 마모:3 오작동:1\"),\n", + " (\"냉각 계통\", [\"냉각 계통\", \"라디에이터\", \"냉각수 라인\"], 24, 0.30, 7, (0.05, 0.30), 3.0, \"MK3\", \"누유:4 과열:4 부식:2\"),\n", + " (\"전기·배터리\", [\"배터리\", \"전기 계통\", \"발전기\"], 28, 0.35, 1, (0.04, 0.28), 2.5, \"MK3\", \"접촉불량:4 부식:2 과열:2 오작동:2\"),\n", + " (\"통신 모듈\", [\"통신 모듈\", \"무전기 연동부\", \"안테나 접속부\"], 16, 0.05, 4, (0.03, 0.25), 4.0, \"MK4\", \"접촉불량:5 오작동:4 부식:1\"),\n", + " (\"유압 계통\", [\"유압 계통\", \"유압 호스\", \"유압 실린더\"], 18, 0.10, 9, (0.10, 0.35), 4.5, \"MK2\", \"누유:5 균열:2 마모:2 체결불량:1\"),\n", + " (\"현가 장치\", [\"현가 장치\", \"서스펜션\", \"완충기\"], 15, 0.08, 5, (0.06, 0.30), 3.5, \"MK4\", \"마모:4 균열:3 체결불량:2 부식:1\"),\n", + " (\"차체 용접부\", [\"차체 용접부\", \"프레임 용접부\", \"차체 이음부\"], 10, 0.05, 10, (0.28, 0.40), 7.0, \"MK1\", \"균열:6 부식:3 체결불량:1\"),\n", + " (\"광학·조준 장비\", [\"광학 장비\", \"조준경\", \"관측 장비\"], 8, 0.05, 3, (0.03, 0.22), 8.0, \"MK4\", \"오작동:4 접촉불량:3 부식:2 체결불량:1\"),\n", + " (\"공기 정화 필터\", [\"공기 필터\", \"흡기 필터\", \"정화 장치\"], 12, 0.25, 4, (0.01, 0.12), 1.0, \"MK3\", \"마모:5 부식:2 체결불량:3\")]\n", + "NP = len(PARTS)\n", + "DEFECTS = { # 결함 유형: 증상 표현(동의어)\n", + " \"균열\": [\"균열이 발견됨\", \"크랙이 확인됨\", \"갈라짐이 있음\"], \"누유\": [\"누유가 발생함\", \"오일이 새어 나옴\", \"누설 흔적이 있음\"],\n", + " \"마모\": [\"마모가 심함\", \"과도한 마모가 확인됨\", \"닳은 흔적이 있음\"], \"부식\": [\"부식이 진행됨\", \"녹이 발생함\", \"산화 흔적이 있음\"],\n", + " \"접촉불량\": [\"접촉 불량이 발생함\", \"커넥터 단선이 의심됨\", \"신호가 끊김\"], \"체결불량\": [\"볼트 풀림이 확인됨\", \"체결 상태가 불량함\", \"고정부가 헐거움\"],\n", + " \"과열\": [\"과열 경고가 발생함\", \"온도가 과도하게 상승함\", \"열이 심하게 남\"], \"오작동\": [\"오작동이 반복됨\", \"작동이 불안정함\", \"간헐적으로 멈춤\"]}\n", + "DNAMES = list(DEFECTS)\n", + "SEV = [\"Minor\", \"Major\", \"Critical\"]\n", + "SEV_CUE = {0: [\"경미한 수준임\", \"운용에는 지장이 없음\", \"육안 점검상 사소한 이상임\", \"기능에는 영향이 없음\"],\n", + " 1: [\"성능 저하가 있음\", \"조속한 정비가 필요함\", \"출력이 떨어지는 상태임\", \"기능이 부분적으로 제한됨\"],\n", + " 2: [\"즉시 운용 중지함\", \"안전에 위험이 있어 사용 불가\", \"작동 정지로 임무 수행이 어려움\", \"인명 안전 위협이 우려됨\"]}\n", + "CAUSES = [\"제조 불량\", \"정비 품질\", \"운용 부주의\", \"설계 취약\", \"자연 노후\"]\n", + "CAUSE_CUE = {\n", + " \"제조 불량\": [\"신품 인수 직후 발생함\", \"출고 상태에서 이미 이상이 있었음\", \"인수 후 얼마 지나지 않아 발생함\"],\n", + " \"정비 품질\": [\"외주 정비 후 얼마 되지 않아 재발함\", \"정비 후 재조립 상태가 불량함\", \"정비 직후 같은 부위에서 다시 발생함\"],\n", + " \"운용 부주의\": [\"험지 기동 중 충격 이후 발생함\", \"과부하 운용 후 발생함\", \"점검 없이 연속 운용한 뒤 발생함\"],\n", + " \"설계 취약\": [\"동일 증상이 다른 차량에서도 반복됨\", \"구조적으로 취약한 부위로 보임\", \"여러 부대에서 같은 형태로 보고됨\"],\n", + " \"자연 노후\": [\"사용 연한이 도래함\", \"장기간 사용으로 노후됨\", \"수명 주기 말기에 발생함\"]}\n", + "VENDORS = [\"외주A\", \"외주B\", \"외주C\", \"외주D\", \"부대정비\"]\n", + "ACTIONS = [\"정비소 입고를 요청함\", \"현장 임시 조치 후 운용 중임\", \"부품 교환을 요청함\", \"재검사를 요청함\", \"원인 확인이 필요함\"]\n", + "MAKER_OF = [p[7] for p in PARTS]\n", + "PRICE = np.array([p[6] for p in PARTS])\n", + "\n", + "\n", + "def usage_index(months, seed=0):\n", + " \"\"\"월별 훈련 강도(미리 알 수 있는 계획 값): 연 1회 정점을 갖는 계절 + 작은 잡음\"\"\"\n", + " r = np.random.default_rng(seed + 99)\n", + " t = np.arange(months)\n", + " return 1 + 0.25 * np.sin(2 * np.pi * (t - 3) / 12) + r.normal(0, 0.04, months)\n", + "\n", + "\n", + "def make_qdr(months=36, seed=1, scale=1.0, surge=True):\n", + " \"\"\"반환: QDR DataFrame(건당 1행), 월별 usage, 급증 이벤트 목록\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " use = usage_index(months, seed)\n", + " events = []\n", + " if surge:\n", + " for _ in range(max(2, months // 12 * 2)):\n", + " events.append((int(rng.integers(NP)), int(rng.integers(6, months))))\n", + " rows = []\n", + " wdef = [np.array([float(x.split(\":\")[1]) for x in p[8].split()]) for p in PARTS]\n", + " ddef = [[x.split(\":\")[0] for x in p[8].split()] for p in PARTS]\n", + " n = 0\n", + " for m in range(months):\n", + " for pi, p in enumerate(PARTS):\n", + " lam = p[2] * scale * (1 + 0.010 * m) * use[m] * (1 + p[3] * np.cos(2 * np.pi * (m % 12 - p[4]) / 12))\n", + " ev = (pi, m) in events\n", + " k = rng.poisson(lam * (2.6 if ev else 1.0))\n", + " for _ in range(k):\n", + " rows.append((n, m, pi, ev)); n += 1\n", + " N = len(rows)\n", + " A = np.array(rows)\n", + " month, part, ev = A[:, 1], A[:, 2], A[:, 3].astype(bool)\n", + " defect = np.array([rng.choice(ddef[i], p=wdef[i] / wdef[i].sum()) for i in part])\n", + " cr = np.array([PARTS[i][5][0] for i in part]); ma = np.array([PARTS[i][5][1] for i in part])\n", + " u = rng.random(N)\n", + " sev = np.where(u < cr, 2, np.where(u < cr + ma, 1, 0))\n", + " vendor = rng.choice(VENDORS, N, p=[0.25, 0.2, 0.2, 0.15, 0.2])\n", + " # 원인: 기본 비율에 부위(제조사)·업체·이벤트 효과를 곱한다\n", + " base = np.array([0.22, 0.20, 0.20, 0.16, 0.22])\n", + " P = np.tile(base, (N, 1))\n", + " P[vendor == \"외주B\", 1] *= 2.6\n", + " P[ev, 0] *= 5.0\n", + " design = np.isin(part, [2, 3, 7]); P[design, 3] *= 2.2\n", + " P[np.isin(part, [4, 5, 11]), 0] *= 2.0 # 제조사 MK3 납품 부위는 제조 불량 비중이 높다\n", + " P[defect == \"마모\", 4] *= 1.6; P[defect == \"누유\", 4] *= 1.3\n", + " P /= P.sum(axis=1, keepdims=True)\n", + " cause = np.array([CAUSES[rng.choice(5, p=P[i])] for i in range(N)])\n", + " since = np.where(cause == \"정비 품질\", rng.exponential(35, N), rng.exponential(160, N)).clip(1, 720).round().astype(int)\n", + " wait = (rng.lognormal(1.3, 0.5, N) * np.where(vendor == \"외주B\", 1.7, np.where(vendor == \"부대정비\", 0.6, 1.0)) * (0.7 + 0.5 * use[month])).round(1)\n", + " reins = rng.random(N) < np.where(cause == \"정비 품질\", 0.6, 0.08)\n", + " texts = []\n", + " for i in range(N):\n", + " pt = PARTS[part[i]]\n", + " cl = [f\"{rng.choice(pt[1])}에서 {rng.choice(DEFECTS[defect[i]])}\"]\n", + " if rng.random() < 0.7: cl.append(rng.choice(SEV_CUE[sev[i]]))\n", + " if rng.random() < 0.65: # 원인 단서: 8%는 실제 원인과 다른 단서가 적힌다(보고자의 추정)\n", + " cc = cause[i] if rng.random() > 0.08 else rng.choice(CAUSES)\n", + " cl.append(rng.choice(CAUSE_CUE[cc]))\n", + " cl.append(rng.choice(ACTIONS))\n", + " if rng.random() < 0.5: cl.insert(0, f\"VU-{rng.integers(1, 31):02d} 소속\")\n", + " texts.append(\". \".join(cl) + \".\")\n", + " df = pd.DataFrame({\"qdr_id\": [f\"Q{seed}{i:06d}\" for i in range(N)], \"month\": month, \"unit\": [f\"VU-{x:02d}\" for x in rng.integers(1, 31, N)],\n", + " \"part\": part, \"defect\": defect, \"sev\": sev, \"cause\": cause, \"vendor\": vendor, \"maker\": [MAKER_OF[i] for i in part],\n", + " \"since_maint\": since, \"inspect_wait\": wait, \"reinspect\": reins, \"event\": ev, \"text\": texts})\n", + " df[\"cost\"] = PRICE[df[\"part\"]] * np.array([1.0, 2.0, 4.0])[df[\"sev\"]] # [교육용가상자료] 수리·조치 비용지수\n", + " return df, use, events\n", + "\n", + "# ---------------------------------------------------------------------------\n", + "# 공통 텍스트 조각: 정규화, 간이 토큰화(조사 제거), 부위·결함·치명도·원인 키워드 라벨링\n", + "# ---------------------------------------------------------------------------\n", + "TYPO = {\"누윳\": \"누유\", \"균렬\": \"균열\", \"접축 불량\": \"접촉 불량\", \"부씩\": \"부식\", \"마묘\": \"마모\", \"과렬\": \"과열\", \"오작똥\": \"오작동\", \"재동\": \"제동\"}\n", + "ABBR = {\"T/M\": \"변속기\", \"BAT\": \"배터리\"}\n", + "PART_KW = sorted([(a, i) for i, p in enumerate(PARTS) for a in p[1]], key=lambda x: -len(x[0]))\n", + "DEF_KW = {\"균열\": [\"균열\", \"크랙\", \"갈라\"], \"누유\": [\"누유\", \"새어\", \"누설\"], \"마모\": [\"마모\", \"닳은\"], \"부식\": [\"부식\", \"녹이\", \"산화\"],\n", + " \"접촉불량\": [\"접촉 불량\", \"단선\", \"끊김\"], \"체결불량\": [\"볼트\", \"체결\", \"헐거\"], \"과열\": [\"과열\", \"온도\", \"열이\"], \"오작동\": [\"오작동\", \"불안정\", \"멈춤\"]}\n", + "SEV_KW = [(2, [\"즉시 운용 중지\", \"사용 불가\", \"위험\"]), (1, [\"성능 저하\", \"조속한\"]), (0, [\"경미\", \"지장이 없\"])]\n", + "CAUSE_KW = {\"제조 불량\": [\"신품\", \"출고\", \"인수\"], \"정비 품질\": [\"정비 후\", \"재조립\", \"정비 직후\"], \"운용 부주의\": [\"충격\", \"과부하\", \"점검 없이\"],\n", + " \"설계 취약\": [\"동일 증상\", \"구조적\", \"여러 부대\"], \"자연 노후\": [\"사용 연한\", \"노후\", \"수명\"]}\n", + "PARTICLES = (\"에서\", \"으로\", \"이\", \"가\", \"은\", \"는\", \"을\", \"를\", \"에\", \"의\", \"로\", \"와\", \"과\", \"도\", \"함\", \"음\", \"됨\")\n", + "\n", + "\n", + "def norm(t):\n", + " \"\"\"정규화: 전각->반각, 특수문자·중복 공백 제거, 약어 확장, 오타 교정\"\"\"\n", + " t = unicodedata.normalize(\"NFKC\", str(t))\n", + " t = re.sub(r\"[!#~*@^]+\", \" \", t)\n", + " for a, b in ABBR.items():\n", + " t = t.replace(a, b)\n", + " for a, b in TYPO.items():\n", + " t = t.replace(a, b)\n", + " return re.sub(r\"\\s+\", \" \", t).strip()\n", + "\n", + "\n", + "def tok(t):\n", + " out = []\n", + " for w in re.split(r\"[\\s.,]+\", norm(t)):\n", + " if not w or re.fullmatch(r\"[\\d\\-A-Za-z]+\", w):\n", + " continue\n", + " for p in PARTICLES:\n", + " if len(w) > len(p) + 1 and w.endswith(p):\n", + " w = w[:-len(p)]\n", + " break\n", + " out.append(w)\n", + " return out\n", + "\n", + "\n", + "def label_part(t):\n", + " for a, i in PART_KW:\n", + " if a in t:\n", + " return i\n", + " return -1\n", + "\n", + "\n", + "def label_defect(t):\n", + " for d, ks in DEF_KW.items():\n", + " if any(k in t for k in ks):\n", + " return d\n", + " return \"미분류\"\n", + "\n", + "\n", + "def label_sev(t):\n", + " for s, ks in SEV_KW:\n", + " if any(k in t for k in ks):\n", + " return s\n", + " return -1\n", + "\n", + "\n", + "def label_cause(t):\n", + " for c, ks in CAUSE_KW.items():\n", + " if any(k in t for k in ks):\n", + " return c\n", + " return \"미상\"\n", + "\n", + "df, use, events = make_qdr(48, seed=1, scale=4.0)\n", + "names = [p[0] for p in PARTS]\n", + "W_SEV = np.array([1, 3, 9]) # 취약성 지수 가중치(Minor 1, Major 3, Critical 9)\n", + "tr, va = df[df[\"month\"] < 36], df[df[\"month\"] >= 36]\n", + "print(f\"[교육용가상자료] QDR {len(df)}건 (학습 36개월 {len(tr)}건, 검증 12개월 {len(va)}건)\")\n", + "\n", + "# 1) 개별 QDR 치명도 분류: 키워드 규칙 vs 텍스트+부위·결함 로지스틱 회귀\n", + "rule = va[\"text\"].map(label_sev).replace(-1, 0).values # 치명도 단서가 없으면 Minor로 간주\n", + "vec = TfidfVectorizer(tokenizer=tok, lowercase=False, token_pattern=None, min_df=5)\n", + "Xtr, Xva = vec.fit_transform(tr[\"text\"]), vec.transform(va[\"text\"])\n", + "oh = lambda d: sp.csr_matrix(np.hstack([np.eye(NP)[d[\"part\"]], pd.get_dummies(d[\"defect\"]).reindex(columns=DNAMES, fill_value=0).values.astype(float)]))\n", + "clf = LogisticRegression(C=5, max_iter=500).fit(sp.hstack([Xtr, oh(tr)]), tr[\"sev\"])\n", + "P = clf.predict_proba(sp.hstack([Xva, oh(va)]))\n", + "pred = P.argmax(axis=1)\n", + "acc_r, acc_m = (rule == va[\"sev\"].values).mean(), (pred == va[\"sev\"].values).mean()\n", + "rec = lambda p: ((p == 2) & (va[\"sev\"].values == 2)).sum() / (va[\"sev\"].values == 2).sum()\n", + "print(f\"치명도 분류 정확도: 키워드 규칙 {acc_r:.1%} -> 텍스트 모델 {acc_m:.1%}, Critical 재현율 {rec(rule):.1%} -> {rec(pred):.1%}\")\n", + "conf = P.max(axis=1)\n", + "print(\"[신뢰도 보정] 구간 | 건수 | 평균 신뢰도 | 실제 정확도\")\n", + "for a, b in [(0, 0.6), (0.6, 0.8), (0.8, 0.95), (0.95, 1.01)]:\n", + " m = (conf >= a) & (conf < b)\n", + " print(f\" {a:.2f}~{min(b, 1):.2f} {m.sum():5d} {conf[m].mean():.2f} {(pred[m] == va['sev'].values[m]).mean():.2f}\")\n", + "\n", + "# 2) 월간 부위별 취약성 지수 예측\n", + "g = df.assign(v=W_SEV[df[\"sev\"]]).groupby([\"part\", \"month\"]).agg(n=(\"v\", \"size\"), v=(\"v\", \"sum\")).reset_index()\n", + "rows = []\n", + "for p in range(NP):\n", + " d = g[g[\"part\"] == p].set_index(\"month\").reindex(range(48)).fillna(0)\n", + " f = pd.DataFrame({\"part\": p, \"month\": range(48), \"y\": d[\"v\"].values, \"n\": d[\"n\"].values})\n", + " f[\"lag1\"], f[\"lag2\"], f[\"lag12\"] = f[\"y\"].shift(1), f[\"y\"].shift(2), (f[\"y\"] / use).shift(12) * use # lag12: 작년 같은 달 발생률 x 이번 달 훈련 강도\n", + " f[\"exp_v\"] = (f[\"y\"] / use).shift(1).rolling(6, min_periods=3).mean() * use # 최근 6개월 발생률 x 이번 달 훈련 강도\n", + " f[\"usage\"], f[\"season_c\"], f[\"season_s\"], f[\"trend\"] = use, np.cos(2 * np.pi * f[\"month\"] / 12), np.sin(2 * np.pi * f[\"month\"] / 12), f[\"month\"]\n", + " f[\"price\"], f[\"crit_rate\"] = PRICE[p], PARTS[p][5][0]\n", + " rows.append(f)\n", + "F = pd.concat(rows, ignore_index=True).dropna()\n", + "FE = [\"lag1\", \"lag2\", \"lag12\", \"exp_v\", \"usage\", \"season_c\", \"season_s\", \"trend\", \"price\", \"crit_rate\"]\n", + "Ftr, Fva = F[F[\"month\"] < 42], F[F[\"month\"] >= 42]\n", + "base = Ftr.groupby(\"part\")[\"y\"].mean()\n", + "pb = Fva[\"part\"].map(base).values\n", + "\n", + "\n", + "def members(d_tr, depth, alpha):\n", + " \"\"\"릿지(선형 추세·계절)로 먼저 맞추고, 그래디언트 부스팅·랜덤 포레스트는 릿지 잔차를 보정하도록 학습한다.\"\"\"\n", + " rd = make_pipeline(StandardScaler(), Ridge(alpha=alpha)).fit(d_tr[FE], d_tr[\"y\"])\n", + " res = d_tr[\"y\"] - rd.predict(d_tr[FE])\n", + " gb = GradientBoostingRegressor(n_estimators=100, max_depth=depth, learning_rate=0.05, subsample=0.8, random_state=0).fit(d_tr[FE], res)\n", + " rf = RandomForestRegressor(n_estimators=40, max_depth=6, min_samples_leaf=5, n_jobs=-1, random_state=0).fit(d_tr[FE], res)\n", + " return [rd, gb, rf]\n", + "\n", + "\n", + "def blend(ms, d, w):\n", + " return ms[0].predict(d[FE]) + w[0] * ms[1].predict(d[FE]) + w[1] * ms[2].predict(d[FE])\n", + "\n", + "\n", + "GRID_W = [(a / 2, b / 2) for a in range(3) for b in range(3)] # 잔차 보정 가중치 격자(GBR, RF) 0, 0.5, 1\n", + "best, solo = (1e9,), {}\n", + "folds = [(Ftr[Ftr[\"month\"] < c], Ftr[(Ftr[\"month\"] >= c) & (Ftr[\"month\"] < c + 6)]) for c in (24, 30, 36)] # 확장 윈도 내부 검증 3폴드\n", + "for dp in (2, 3):\n", + " for al in (1, 10):\n", + " fitted = [(members(a_, dp, al), v) for a_, v in folds]\n", + " for w in GRID_W:\n", + " e_ = np.mean([mean_absolute_error(v[\"y\"], blend(ms, v, w)) for ms, v in fitted])\n", + " solo[(dp, al, w)] = e_\n", + " if e_ < best[0]:\n", + " best = (e_, dp, al, w)\n", + "_, dp, al, wt = best\n", + "if solo[(dp, al, (0.0, 0.0))] * 0.98 < best[0]: # 잔차 보정의 내부 검증 개선이 2% 미만이면 단순한 모델(릿지)을 채택(과적합 방지)\n", + " wt = (0.0, 0.0)\n", + "models = members(Ftr, dp, al)\n", + "pe = blend(models, Fva, wt)\n", + "ev = np.array([(int(a), int(b)) in set(events) for a, b in zip(Fva[\"part\"], Fva[\"month\"])]) # 제조 LOT 불량 등 급증 이벤트 월(별도 경보 대상)\n", + "keep = ~ev\n", + "y = Fva[\"y\"].values\n", + "met = lambda p, m=keep: (mean_absolute_error(y[m], p[m]), mean_squared_error(y[m], p[m]) ** 0.5, r2_score(y[m], p[m]))\n", + "mb, me = met(pb), met(pe)\n", + "print(f\"\\n[취약성 지수 예측] 학습 월 12~41, Hold-out 월 42~47 ({len(Fva)}건 중 급증 이벤트 월 {int(ev.sum())}건 제외 평가), 선택 GBR depth={dp}, ridge alpha={al}, 잔차 보정 가중치(GBR, RF)={wt}\")\n", + "print(\"모델 | MAE | RMSE | R2\")\n", + "print(f\" 기준(학습 기간 부위별 평균) {mb[0]:7.1f} {mb[1]:7.1f} {mb[2]:.3f}\")\n", + "print(f\" 전월값(참고) {met(Fva['lag1'].values)[0]:7.1f} {met(Fva['lag1'].values)[1]:7.1f} {met(Fva['lag1'].values)[2]:.3f}\")\n", + "x = met(models[0].predict(Fva[FE]))\n", + "print(f\" 릿지 단독 {x[0]:7.1f} {x[1]:7.1f} {x[2]:.3f}\")\n", + "print(f\" 고도화(선택 모델) {me[0]:7.1f} {me[1]:7.1f} {me[2]:.3f}\")\n", + "imp_mae, imp_rmse = 1 - me[0] / mb[0], 1 - me[1] / mb[1]\n", + "print(f\" (참고) 이벤트 월 포함 전체: 기준 MAE {met(pb, np.ones(len(y), bool))[0]:.1f} -> 앙상블 {met(pe, np.ones(len(y), bool))[0]:.1f}, R2 {met(pe, np.ones(len(y), bool))[2]:.3f}\")\n", + "print(f\"개선율 MAE {imp_mae:.1%}, RMSE {imp_rmse:.1%} (목표 20% 이상: {'달성' if min(imp_mae, imp_rmse) >= 0.2 else '미달'}), R2 {me[2]:.3f} (목표 0.85 이상: {'달성' if me[2] >= 0.85 else '미달'})\")\n", + "\n", + "# 3) 종합 품질 취약성 지수 순위(다음 달 예측 상위 5 부위) 카드\n", + "last = Fva[Fva[\"month\"] == 47].assign(pred=pe[Fva[\"month\"].values == 47]).sort_values(\"pred\", ascending=False)\n", + "print(\"\\n[취약성 지수 카드] 마지막 달 예측 상위 5 부위 | 예측 | 실제\")\n", + "for r in last.head(5).itertuples():\n", + " print(f\" {names[r.part]:10s} 예측 {r.pred:6.0f} 실제 {r.y:6.0f} {'(우선 점검 권고)' if r.pred >= last['pred'].median() * 1.5 else ''}\")\n", + "print(f\"예측 상위 5와 실제 상위 5 일치 {len(set(last.head(5)['part']) & set(last.sort_values('y', ascending=False).head(5)['part']))}/5\")\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 05 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + "[교육용가상자료] QDR 51700건 (학습 36개월 36834건, 검증 12개월 14866건)\n", + "치명도 분류 정확도: 키워드 규칙 72.0% -> 텍스트 모델 87.2%, Critical 재현율 34.7% -> 69.0%\n", + "[신뢰도 보정] 구간 | 건수 | 평균 신뢰도 | 실제 정확도\n", + " 0.00~0.60 2394 0.48 0.46\n", + " 0.60~0.80 1827 0.68 0.68\n", + " 0.80~0.95 264 0.86 0.89\n", + " 0.95~1.00 10381 1.00 1.00\n", + "\n", + "[취약성 지수 예측] 학습 월 12~41, Hold-out 월 42~47 (72건 중 급증 이벤트 월 1건 제외 평가), 선택 GBR depth=3, ridge alpha=10, 잔차 보정 가중치(GBR, RF)=(0.0, 0.0)\n", + "모델 | MAE | RMSE | R2\n", + " 기준(학습 기간 부위별 평균) 63.5 88.5 0.582\n", + " 전월값(참고) 51.1 74.9 0.700\n", + " 릿지 단독 39.3 51.8 0.857\n", + " 고도화(선택 모델) 39.3 51.8 0.857\n", + " (참고) 이벤트 월 포함 전체: 기준 MAE 68.6 -> 앙상블 43.9, R2 0.777\n", + "개선율 MAE 38.0%, RMSE 41.4% (목표 20% 이상: 달성), R2 0.857 (목표 0.85 이상: 달성)\n", + "\n", + "[취약성 지수 카드] 마지막 달 예측 상위 5 부위 | 예측 | 실제\n", + " 제동 장치 예측 409 실제 412 (우선 점검 권고)\n", + " 차체 용접부 예측 315 실제 171 \n", + " 엔진 연료계통 예측 304 실제 237 \n", + " 변속기 예측 298 실제 303 \n", + " 조향 장치 예측 272 실제 314 \n", + "예측 상위 5와 실제 상위 5 일치 3/5\n", + "```\n", + "
\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 06. 야전 QDR 발생 현황 및 장비 부위별 결함 집중도 히트맵 대시보드 구현\n", + "**소요시간**: 약 3.0시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "야전 QDR 발생 현황 및 장비 부위별 결함 히트맵 대시보드를 구현한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **야전 QDR 발생 현황과 부위별 결함 집중도를 히트맵·경보로 구성하고, 예측 건마다 기여도 상위 3개 인자와 자연어 요약을 자동 산출하는 설명가능성(XAI) 모듈을 구현한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **치명도 분류 모델의 서술 단어·부위·결함 유형 기여도를 계산해 건마다 상위 3개 근거를 뽑고, 1순위 근거를 제거했을 때 확률이 내려가는지로 유효성을 점검 수행하고 검증하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `src/explainability/qlt05_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": [ + "\"\"\"야전 QDR 발생 현황을 부위별·월별 히트맵과 집중도 경보로 구성하고, 치명도 분류 모델의 예측마다 기여도 상위 3개 인자(서술 단어·부위·결함 유형)를 자동 산출하며(근거 유효성 검증), 정비·품질 담당자가 읽을 수 있는 자연어 요약 문장을 생성하는 설명가능성(XAI) 모듈을 구현한다.\"\"\"\n", + "import re\n", + "import unicodedata\n", + "import numpy as np\n", + "import pandas as pd\n", + "import scipy.sparse as sp\n", + "import matplotlib.pyplot as plt\n", + "from sklearn.feature_extraction.text import TfidfVectorizer\n", + "from sklearn.linear_model import LogisticRegression\n", + "\n", + "# ---------------------------------------------------------------------------\n", + "# [교육용가상자료] 가상 야전 장비 품질결함보고(QDR) 생성기 (실제 부대·장비·업체·정비 실적 없음)\n", + "# 가상 장비 부위 12종, 결함 유형 8종, 치명도(Critical/Major/Minor), 원인 5종, 가상 외주정비 업체 4곳 + 부대정비.\n", + "# QDR 1건마다 한국어 서술 텍스트를 만들고, 월별 건수는 계절·추세·훈련 강도(usage)·일부 제조 불량 이벤트를 따른다.\n", + "# ---------------------------------------------------------------------------\n", + "PARTS = [ # (이름, 별칭, 월 기준 건수, 계절 진폭, 정점 월, (Critical, Major 비율), 가상 단가지수, 제조사, 주요 결함 가중치)\n", + " (\"엔진 연료계통\", [\"연료계통\", \"연료 라인\", \"연료 펌프\"], 26, 0.15, 7, (0.10, 0.35), 6.0, \"MK1\", \"누유:4 마모:2 체결불량:2 오작동:2\"),\n", + " (\"변속기\", [\"변속기\", \"T/M\", \"미션\"], 22, 0.10, 8, (0.12, 0.38), 9.0, \"MK1\", \"마모:4 누유:2 과열:3 오작동:1\"),\n", + " (\"제동 장치\", [\"제동 장치\", \"브레이크\", \"제동 라인\"], 20, 0.20, 12, (0.26, 0.40), 5.0, \"MK2\", \"마모:4 균열:2 누유:2 체결불량:2\"),\n", + " (\"조향 장치\", [\"조향 장치\", \"핸들 연동부\", \"조향 로드\"], 14, 0.10, 6, (0.30, 0.40), 5.0, \"MK2\", \"체결불량:4 균열:2 마모:3 오작동:1\"),\n", + " (\"냉각 계통\", [\"냉각 계통\", \"라디에이터\", \"냉각수 라인\"], 24, 0.30, 7, (0.05, 0.30), 3.0, \"MK3\", \"누유:4 과열:4 부식:2\"),\n", + " (\"전기·배터리\", [\"배터리\", \"전기 계통\", \"발전기\"], 28, 0.35, 1, (0.04, 0.28), 2.5, \"MK3\", \"접촉불량:4 부식:2 과열:2 오작동:2\"),\n", + " (\"통신 모듈\", [\"통신 모듈\", \"무전기 연동부\", \"안테나 접속부\"], 16, 0.05, 4, (0.03, 0.25), 4.0, \"MK4\", \"접촉불량:5 오작동:4 부식:1\"),\n", + " (\"유압 계통\", [\"유압 계통\", \"유압 호스\", \"유압 실린더\"], 18, 0.10, 9, (0.10, 0.35), 4.5, \"MK2\", \"누유:5 균열:2 마모:2 체결불량:1\"),\n", + " (\"현가 장치\", [\"현가 장치\", \"서스펜션\", \"완충기\"], 15, 0.08, 5, (0.06, 0.30), 3.5, \"MK4\", \"마모:4 균열:3 체결불량:2 부식:1\"),\n", + " (\"차체 용접부\", [\"차체 용접부\", \"프레임 용접부\", \"차체 이음부\"], 10, 0.05, 10, (0.28, 0.40), 7.0, \"MK1\", \"균열:6 부식:3 체결불량:1\"),\n", + " (\"광학·조준 장비\", [\"광학 장비\", \"조준경\", \"관측 장비\"], 8, 0.05, 3, (0.03, 0.22), 8.0, \"MK4\", \"오작동:4 접촉불량:3 부식:2 체결불량:1\"),\n", + " (\"공기 정화 필터\", [\"공기 필터\", \"흡기 필터\", \"정화 장치\"], 12, 0.25, 4, (0.01, 0.12), 1.0, \"MK3\", \"마모:5 부식:2 체결불량:3\")]\n", + "NP = len(PARTS)\n", + "DEFECTS = { # 결함 유형: 증상 표현(동의어)\n", + " \"균열\": [\"균열이 발견됨\", \"크랙이 확인됨\", \"갈라짐이 있음\"], \"누유\": [\"누유가 발생함\", \"오일이 새어 나옴\", \"누설 흔적이 있음\"],\n", + " \"마모\": [\"마모가 심함\", \"과도한 마모가 확인됨\", \"닳은 흔적이 있음\"], \"부식\": [\"부식이 진행됨\", \"녹이 발생함\", \"산화 흔적이 있음\"],\n", + " \"접촉불량\": [\"접촉 불량이 발생함\", \"커넥터 단선이 의심됨\", \"신호가 끊김\"], \"체결불량\": [\"볼트 풀림이 확인됨\", \"체결 상태가 불량함\", \"고정부가 헐거움\"],\n", + " \"과열\": [\"과열 경고가 발생함\", \"온도가 과도하게 상승함\", \"열이 심하게 남\"], \"오작동\": [\"오작동이 반복됨\", \"작동이 불안정함\", \"간헐적으로 멈춤\"]}\n", + "DNAMES = list(DEFECTS)\n", + "SEV = [\"Minor\", \"Major\", \"Critical\"]\n", + "SEV_CUE = {0: [\"경미한 수준임\", \"운용에는 지장이 없음\", \"육안 점검상 사소한 이상임\", \"기능에는 영향이 없음\"],\n", + " 1: [\"성능 저하가 있음\", \"조속한 정비가 필요함\", \"출력이 떨어지는 상태임\", \"기능이 부분적으로 제한됨\"],\n", + " 2: [\"즉시 운용 중지함\", \"안전에 위험이 있어 사용 불가\", \"작동 정지로 임무 수행이 어려움\", \"인명 안전 위협이 우려됨\"]}\n", + "CAUSES = [\"제조 불량\", \"정비 품질\", \"운용 부주의\", \"설계 취약\", \"자연 노후\"]\n", + "CAUSE_CUE = {\n", + " \"제조 불량\": [\"신품 인수 직후 발생함\", \"출고 상태에서 이미 이상이 있었음\", \"인수 후 얼마 지나지 않아 발생함\"],\n", + " \"정비 품질\": [\"외주 정비 후 얼마 되지 않아 재발함\", \"정비 후 재조립 상태가 불량함\", \"정비 직후 같은 부위에서 다시 발생함\"],\n", + " \"운용 부주의\": [\"험지 기동 중 충격 이후 발생함\", \"과부하 운용 후 발생함\", \"점검 없이 연속 운용한 뒤 발생함\"],\n", + " \"설계 취약\": [\"동일 증상이 다른 차량에서도 반복됨\", \"구조적으로 취약한 부위로 보임\", \"여러 부대에서 같은 형태로 보고됨\"],\n", + " \"자연 노후\": [\"사용 연한이 도래함\", \"장기간 사용으로 노후됨\", \"수명 주기 말기에 발생함\"]}\n", + "VENDORS = [\"외주A\", \"외주B\", \"외주C\", \"외주D\", \"부대정비\"]\n", + "ACTIONS = [\"정비소 입고를 요청함\", \"현장 임시 조치 후 운용 중임\", \"부품 교환을 요청함\", \"재검사를 요청함\", \"원인 확인이 필요함\"]\n", + "MAKER_OF = [p[7] for p in PARTS]\n", + "PRICE = np.array([p[6] for p in PARTS])\n", + "\n", + "\n", + "def usage_index(months, seed=0):\n", + " \"\"\"월별 훈련 강도(미리 알 수 있는 계획 값): 연 1회 정점을 갖는 계절 + 작은 잡음\"\"\"\n", + " r = np.random.default_rng(seed + 99)\n", + " t = np.arange(months)\n", + " return 1 + 0.25 * np.sin(2 * np.pi * (t - 3) / 12) + r.normal(0, 0.04, months)\n", + "\n", + "\n", + "def make_qdr(months=36, seed=1, scale=1.0, surge=True):\n", + " \"\"\"반환: QDR DataFrame(건당 1행), 월별 usage, 급증 이벤트 목록\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " use = usage_index(months, seed)\n", + " events = []\n", + " if surge:\n", + " for _ in range(max(2, months // 12 * 2)):\n", + " events.append((int(rng.integers(NP)), int(rng.integers(6, months))))\n", + " rows = []\n", + " wdef = [np.array([float(x.split(\":\")[1]) for x in p[8].split()]) for p in PARTS]\n", + " ddef = [[x.split(\":\")[0] for x in p[8].split()] for p in PARTS]\n", + " n = 0\n", + " for m in range(months):\n", + " for pi, p in enumerate(PARTS):\n", + " lam = p[2] * scale * (1 + 0.010 * m) * use[m] * (1 + p[3] * np.cos(2 * np.pi * (m % 12 - p[4]) / 12))\n", + " ev = (pi, m) in events\n", + " k = rng.poisson(lam * (2.6 if ev else 1.0))\n", + " for _ in range(k):\n", + " rows.append((n, m, pi, ev)); n += 1\n", + " N = len(rows)\n", + " A = np.array(rows)\n", + " month, part, ev = A[:, 1], A[:, 2], A[:, 3].astype(bool)\n", + " defect = np.array([rng.choice(ddef[i], p=wdef[i] / wdef[i].sum()) for i in part])\n", + " cr = np.array([PARTS[i][5][0] for i in part]); ma = np.array([PARTS[i][5][1] for i in part])\n", + " u = rng.random(N)\n", + " sev = np.where(u < cr, 2, np.where(u < cr + ma, 1, 0))\n", + " vendor = rng.choice(VENDORS, N, p=[0.25, 0.2, 0.2, 0.15, 0.2])\n", + " # 원인: 기본 비율에 부위(제조사)·업체·이벤트 효과를 곱한다\n", + " base = np.array([0.22, 0.20, 0.20, 0.16, 0.22])\n", + " P = np.tile(base, (N, 1))\n", + " P[vendor == \"외주B\", 1] *= 2.6\n", + " P[ev, 0] *= 5.0\n", + " design = np.isin(part, [2, 3, 7]); P[design, 3] *= 2.2\n", + " P[np.isin(part, [4, 5, 11]), 0] *= 2.0 # 제조사 MK3 납품 부위는 제조 불량 비중이 높다\n", + " P[defect == \"마모\", 4] *= 1.6; P[defect == \"누유\", 4] *= 1.3\n", + " P /= P.sum(axis=1, keepdims=True)\n", + " cause = np.array([CAUSES[rng.choice(5, p=P[i])] for i in range(N)])\n", + " since = np.where(cause == \"정비 품질\", rng.exponential(35, N), rng.exponential(160, N)).clip(1, 720).round().astype(int)\n", + " wait = (rng.lognormal(1.3, 0.5, N) * np.where(vendor == \"외주B\", 1.7, np.where(vendor == \"부대정비\", 0.6, 1.0)) * (0.7 + 0.5 * use[month])).round(1)\n", + " reins = rng.random(N) < np.where(cause == \"정비 품질\", 0.6, 0.08)\n", + " texts = []\n", + " for i in range(N):\n", + " pt = PARTS[part[i]]\n", + " cl = [f\"{rng.choice(pt[1])}에서 {rng.choice(DEFECTS[defect[i]])}\"]\n", + " if rng.random() < 0.7: cl.append(rng.choice(SEV_CUE[sev[i]]))\n", + " if rng.random() < 0.65: # 원인 단서: 8%는 실제 원인과 다른 단서가 적힌다(보고자의 추정)\n", + " cc = cause[i] if rng.random() > 0.08 else rng.choice(CAUSES)\n", + " cl.append(rng.choice(CAUSE_CUE[cc]))\n", + " cl.append(rng.choice(ACTIONS))\n", + " if rng.random() < 0.5: cl.insert(0, f\"VU-{rng.integers(1, 31):02d} 소속\")\n", + " texts.append(\". \".join(cl) + \".\")\n", + " df = pd.DataFrame({\"qdr_id\": [f\"Q{seed}{i:06d}\" for i in range(N)], \"month\": month, \"unit\": [f\"VU-{x:02d}\" for x in rng.integers(1, 31, N)],\n", + " \"part\": part, \"defect\": defect, \"sev\": sev, \"cause\": cause, \"vendor\": vendor, \"maker\": [MAKER_OF[i] for i in part],\n", + " \"since_maint\": since, \"inspect_wait\": wait, \"reinspect\": reins, \"event\": ev, \"text\": texts})\n", + " df[\"cost\"] = PRICE[df[\"part\"]] * np.array([1.0, 2.0, 4.0])[df[\"sev\"]] # [교육용가상자료] 수리·조치 비용지수\n", + " return df, use, events\n", + "\n", + "# ---------------------------------------------------------------------------\n", + "# 공통 텍스트 조각: 정규화, 간이 토큰화(조사 제거), 부위·결함·치명도·원인 키워드 라벨링\n", + "# ---------------------------------------------------------------------------\n", + "TYPO = {\"누윳\": \"누유\", \"균렬\": \"균열\", \"접축 불량\": \"접촉 불량\", \"부씩\": \"부식\", \"마묘\": \"마모\", \"과렬\": \"과열\", \"오작똥\": \"오작동\", \"재동\": \"제동\"}\n", + "ABBR = {\"T/M\": \"변속기\", \"BAT\": \"배터리\"}\n", + "PART_KW = sorted([(a, i) for i, p in enumerate(PARTS) for a in p[1]], key=lambda x: -len(x[0]))\n", + "DEF_KW = {\"균열\": [\"균열\", \"크랙\", \"갈라\"], \"누유\": [\"누유\", \"새어\", \"누설\"], \"마모\": [\"마모\", \"닳은\"], \"부식\": [\"부식\", \"녹이\", \"산화\"],\n", + " \"접촉불량\": [\"접촉 불량\", \"단선\", \"끊김\"], \"체결불량\": [\"볼트\", \"체결\", \"헐거\"], \"과열\": [\"과열\", \"온도\", \"열이\"], \"오작동\": [\"오작동\", \"불안정\", \"멈춤\"]}\n", + "SEV_KW = [(2, [\"즉시 운용 중지\", \"사용 불가\", \"위험\"]), (1, [\"성능 저하\", \"조속한\"]), (0, [\"경미\", \"지장이 없\"])]\n", + "CAUSE_KW = {\"제조 불량\": [\"신품\", \"출고\", \"인수\"], \"정비 품질\": [\"정비 후\", \"재조립\", \"정비 직후\"], \"운용 부주의\": [\"충격\", \"과부하\", \"점검 없이\"],\n", + " \"설계 취약\": [\"동일 증상\", \"구조적\", \"여러 부대\"], \"자연 노후\": [\"사용 연한\", \"노후\", \"수명\"]}\n", + "PARTICLES = (\"에서\", \"으로\", \"이\", \"가\", \"은\", \"는\", \"을\", \"를\", \"에\", \"의\", \"로\", \"와\", \"과\", \"도\", \"함\", \"음\", \"됨\")\n", + "\n", + "\n", + "def norm(t):\n", + " \"\"\"정규화: 전각->반각, 특수문자·중복 공백 제거, 약어 확장, 오타 교정\"\"\"\n", + " t = unicodedata.normalize(\"NFKC\", str(t))\n", + " t = re.sub(r\"[!#~*@^]+\", \" \", t)\n", + " for a, b in ABBR.items():\n", + " t = t.replace(a, b)\n", + " for a, b in TYPO.items():\n", + " t = t.replace(a, b)\n", + " return re.sub(r\"\\s+\", \" \", t).strip()\n", + "\n", + "\n", + "def tok(t):\n", + " out = []\n", + " for w in re.split(r\"[\\s.,]+\", norm(t)):\n", + " if not w or re.fullmatch(r\"[\\d\\-A-Za-z]+\", w):\n", + " continue\n", + " for p in PARTICLES:\n", + " if len(w) > len(p) + 1 and w.endswith(p):\n", + " w = w[:-len(p)]\n", + " break\n", + " out.append(w)\n", + " return out\n", + "\n", + "\n", + "def label_part(t):\n", + " for a, i in PART_KW:\n", + " if a in t:\n", + " return i\n", + " return -1\n", + "\n", + "\n", + "def label_defect(t):\n", + " for d, ks in DEF_KW.items():\n", + " if any(k in t for k in ks):\n", + " return d\n", + " return \"미분류\"\n", + "\n", + "\n", + "def label_sev(t):\n", + " for s, ks in SEV_KW:\n", + " if any(k in t for k in ks):\n", + " return s\n", + " return -1\n", + "\n", + "\n", + "def label_cause(t):\n", + " for c, ks in CAUSE_KW.items():\n", + " if any(k in t for k in ks):\n", + " return c\n", + " return \"미상\"\n", + "\n", + "df, use, events = make_qdr(36, seed=1, scale=2.0)\n", + "names = [p[0] for p in PARTS]\n", + "tr, va = df[df[\"month\"] < 24].reset_index(drop=True), df[df[\"month\"] >= 24].reset_index(drop=True)\n", + "vec = TfidfVectorizer(tokenizer=tok, lowercase=False, token_pattern=None, min_df=5)\n", + "oh = lambda d: sp.csr_matrix(np.hstack([np.eye(NP)[d[\"part\"]], pd.get_dummies(d[\"defect\"]).reindex(columns=DNAMES, fill_value=0).values.astype(float)]))\n", + "Xtr, Xva = sp.hstack([vec.fit_transform(tr[\"text\"]), oh(tr)]).tocsr(), sp.hstack([vec.transform(va[\"text\"]), oh(va)]).tocsr()\n", + "fnames = list(vec.get_feature_names_out()) + [f\"부위:{n}\" for n in names] + [f\"결함:{d}\" for d in DNAMES]\n", + "clf = LogisticRegression(C=5, max_iter=500).fit(Xtr, tr[\"sev\"])\n", + "P = clf.predict_proba(Xva); pred = P.argmax(axis=1)\n", + "print(f\"[교육용가상자료] QDR {len(df)}건, 검증 {len(va)}건 치명도 분류 정확도 {(pred == va['sev'].values).mean():.1%}\")\n", + "\n", + "# 1) 건마다 기여도 상위 3개 인자 (기여도 = 특성값 x 예측 클래스 계수)\n", + "co = clf.coef_\n", + "top3 = []\n", + "for i in range(Xva.shape[0]):\n", + " row = Xva.getrow(i)\n", + " contrib = row.data * co[pred[i], row.indices]\n", + " order = np.argsort(-contrib)[:3]\n", + " top3.append([(row.indices[j], contrib[j]) for j in order])\n", + "ok = sum(len(t) == 3 for t in top3)\n", + "print(f\"예측 {len(top3)}건 중 기여도 상위 3개 인자 산출 {ok}건 (산출률 {ok / len(top3):.0%}, 목표 100%: {'달성' if ok == len(top3) else '미달'})\")\n", + "\n", + "# 2) 근거 유효성: 1순위 인자를 제거하면 예측 클래스 확률이 낮아지는가 (표본 2,000건)\n", + "idx = np.random.default_rng(0).choice(len(top3), 2000, replace=False)\n", + "X2 = Xva[idx].tolil()\n", + "for r, i in enumerate(idx):\n", + " X2[r, top3[i][0][0]] = 0\n", + "P2 = clf.predict_proba(X2.tocsr())\n", + "drop = P2[np.arange(len(idx)), pred[idx]] < P[idx, pred[idx]]\n", + "print(f\"근거 유효성: 1순위 인자를 제거하면 예측 확률이 낮아지는 비율 {drop.mean():.1%} (평균 {P[idx, pred[idx]].mean():.2f} -> {P2[np.arange(len(idx)), pred[idx]].mean():.2f})\")\n", + "\n", + "\n", + "def summarize(i):\n", + " d = va.iloc[i]\n", + " f = [fnames[j] for j, _ in top3[i]]\n", + " return f\"{names[d['part']]}의 {d['defect']} 결함, 치명도 {SEV[pred[i]]} 가능성 {P[i, pred[i]]:.0%}. 주요 근거: \" + \", \".join(f\"'{x}'\" for x in f) + \".\"\n", + "\n", + "\n", + "# 3) 점검 카드 (치명도별 1건)\n", + "print(\"\\n[점검 카드] 치명도 | 실제 | 설명\")\n", + "for s in (2, 1, 0):\n", + " i = int(np.where((pred == s) & (va[\"sev\"].values == s) & (P.max(axis=1) > 0.9))[0][0])\n", + " print(f\" 예측 {SEV[s]:8s} (실제 {SEV[va.iloc[i]['sev']]})\")\n", + " print(\" \" + summarize(i))\n", + " print(\" 기여도: \" + \" / \".join(f\"{fnames[j]}({c:+.1f})\" for j, c in top3[i]))\n", + "\n", + "# 4) 발생 현황 히트맵(최근 6개월 x 부위)과 집중도 경보: 월 건수 / 학습 기간 월평균 > 1.4 이고 Critical 건이 있으면 경보\n", + "H = df[df[\"month\"] >= 30].pivot_table(index=\"part\", columns=\"month\", values=\"qdr_id\", aggfunc=\"size\", fill_value=0)\n", + "base = tr.groupby(\"part\").size() / 24\n", + "ratio = H.div(base, axis=0)\n", + "alerts = [(int(p), int(m), ratio.loc[p, m]) for p in H.index for m in H.columns if ratio.loc[p, m] > 1.4 and ((df[\"part\"] == p) & (df[\"month\"] == m) & (df[\"sev\"] == 2)).any()]\n", + "print(\"\\n[QDR 발생 히트맵] 부위 | 월 30~35 건수 | 학습 월평균 대비 최대 배수\")\n", + "for p in H.sum(axis=1).sort_values(ascending=False).index[:6]:\n", + " print(f\" {names[p]:10s} \" + \" \".join(f\"{v:4d}\" for v in H.loc[p]) + f\" x{ratio.loc[p].max():.2f}\")\n", + "print(f\"집중도 경보 {len(alerts)}건: \" + \", \".join(f\"{names[p]}({m}월, x{r:.1f})\" for p, m, r in sorted(alerts, key=lambda a: -a[2])[:4]))\n", + "\n", + "fig, ax = plt.subplots(1, 2, figsize=(11, 4))\n", + "im = ax[0].imshow(ratio.values, aspect=\"auto\", cmap=\"Reds\"); ax[0].set_yticks(range(NP)); ax[0].set_yticklabels(range(NP)); ax[0].set_title(\"QDR concentration (x baseline)\"); ax[0].set_xlabel(\"month\"); plt.colorbar(im, ax=ax[0])\n", + "ax[1].hist(P.max(axis=1), bins=20); ax[1].set_title(\"Prediction confidence\")\n", + "plt.tight_layout(); plt.show()\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 06 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + "[교육용가상자료] QDR 18434건, 검증 6823건 치명도 분류 정확도 87.6%\n", + "예측 6823건 중 기여도 상위 3개 인자 산출 6823건 (산출률 100%, 목표 100%: 달성)\n", + "근거 유효성: 1순위 인자를 제거하면 예측 확률이 낮아지는 비율 100.0% (평균 0.87 -> 0.80)\n", + "\n", + "[점검 카드] 치명도 | 실제 | 설명\n", + " 예측 Critical (실제 Critical)\n", + " 엔진 연료계통의 마모 결함, 치명도 Critical 가능성 99%. 주요 근거: '중지', '즉시', '운용'.\n", + " 기여도: 중지(+2.1) / 즉시(+2.1) / 운용(+0.3)\n", + " 예측 Major (실제 Major)\n", + " 엔진 연료계통의 체결불량 결함, 치명도 Major 가능성 99%. 주요 근거: '기능', '부분적', '제한'.\n", + " 기여도: 기능(+1.3) / 부분적(+1.3) / 제한(+1.3)\n", + " 예측 Minor (실제 Minor)\n", + " 엔진 연료계통의 체결불량 결함, 치명도 Minor 가능성 100%. 주요 근거: '없음', '기능에', '영향'.\n", + " 기여도: 없음(+1.7) / 기능에(+1.4) / 영향(+1.4)\n", + "\n", + "[QDR 발생 히트맵] 부위 | 월 30~35 건수 | 학습 월평균 대비 최대 배수\n", + " 냉각 계통 124 87 99 67 56 38 x2.27\n", + " 엔진 연료계통 99 89 87 75 58 49 x1.61\n", + " 전기·배터리 70 58 67 65 71 71 x1.14\n", + " 제동 장치 53 50 71 55 66 62 x1.50\n", + " 변속기 67 69 60 58 44 45 x1.42\n", + " 유압 계통 54 60 59 63 41 42 x1.57\n", + "집중도 경보 19건: 차체 용접부(34월, x3.0), 냉각 계통(30월, x2.3), 냉각 계통(32월, x1.8), 조향 장치(31월, x1.7)\n", + "```\n", + "
\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 07. 제조사 하자 구상 및 설계 변경 소요 제기를 위한 품질관 HITL 검토 모듈 설계\n", + "**소요시간**: 약 3.5시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "설계 변경 소요 제기를 위한 품질관 HITL 검토 모듈을 설계한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **제조사 하자 구상과 설계 변경 소요 제기(외주정비 시정 요구 포함)를 위해 품질관이 근거를 검토·승인하는 HITL 모듈을 설계하고 효과를 시뮬레이션으로 검증한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **최근 12개월 QDR의 부위별 원인 비중으로 하자 구상, 설계 변경, 외주정비 시정 후보를 제안하고 승인 대기열에 올린다 과정을 진행하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `src/optimization/qlt05_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": [ + "\"\"\"최근 12개월 QDR 근거로 부위별 제조사 하자 구상(제조 불량 우세)과 설계 변경 소요 제기(설계 취약 우세) 후보를 제안하되 자동 확정하지 않고 품질관 검토 대기열에 올려 승인·반려·무응답(조치 보류)을 사유와 함께 해시 체인 감사 로그에 남기는 HITL 모듈을 시뮬레이션하고, 개별 사후 수리 대비 비용 절감률과 승인 지연·처리 용량·전시 전환 민감도를 분석한다.\"\"\"\n", + "import hashlib\n", + "import json\n", + "import numpy as np\n", + "import pandas as pd\n", + "import matplotlib.pyplot as plt\n", + "\n", + "# ---------------------------------------------------------------------------\n", + "# [교육용가상자료] 가상 야전 장비 품질결함보고(QDR) 생성기 (실제 부대·장비·업체·정비 실적 없음)\n", + "# 가상 장비 부위 12종, 결함 유형 8종, 치명도(Critical/Major/Minor), 원인 5종, 가상 외주정비 업체 4곳 + 부대정비.\n", + "# QDR 1건마다 한국어 서술 텍스트를 만들고, 월별 건수는 계절·추세·훈련 강도(usage)·일부 제조 불량 이벤트를 따른다.\n", + "# ---------------------------------------------------------------------------\n", + "PARTS = [ # (이름, 별칭, 월 기준 건수, 계절 진폭, 정점 월, (Critical, Major 비율), 가상 단가지수, 제조사, 주요 결함 가중치)\n", + " (\"엔진 연료계통\", [\"연료계통\", \"연료 라인\", \"연료 펌프\"], 26, 0.15, 7, (0.10, 0.35), 6.0, \"MK1\", \"누유:4 마모:2 체결불량:2 오작동:2\"),\n", + " (\"변속기\", [\"변속기\", \"T/M\", \"미션\"], 22, 0.10, 8, (0.12, 0.38), 9.0, \"MK1\", \"마모:4 누유:2 과열:3 오작동:1\"),\n", + " (\"제동 장치\", [\"제동 장치\", \"브레이크\", \"제동 라인\"], 20, 0.20, 12, (0.26, 0.40), 5.0, \"MK2\", \"마모:4 균열:2 누유:2 체결불량:2\"),\n", + " (\"조향 장치\", [\"조향 장치\", \"핸들 연동부\", \"조향 로드\"], 14, 0.10, 6, (0.30, 0.40), 5.0, \"MK2\", \"체결불량:4 균열:2 마모:3 오작동:1\"),\n", + " (\"냉각 계통\", [\"냉각 계통\", \"라디에이터\", \"냉각수 라인\"], 24, 0.30, 7, (0.05, 0.30), 3.0, \"MK3\", \"누유:4 과열:4 부식:2\"),\n", + " (\"전기·배터리\", [\"배터리\", \"전기 계통\", \"발전기\"], 28, 0.35, 1, (0.04, 0.28), 2.5, \"MK3\", \"접촉불량:4 부식:2 과열:2 오작동:2\"),\n", + " (\"통신 모듈\", [\"통신 모듈\", \"무전기 연동부\", \"안테나 접속부\"], 16, 0.05, 4, (0.03, 0.25), 4.0, \"MK4\", \"접촉불량:5 오작동:4 부식:1\"),\n", + " (\"유압 계통\", [\"유압 계통\", \"유압 호스\", \"유압 실린더\"], 18, 0.10, 9, (0.10, 0.35), 4.5, \"MK2\", \"누유:5 균열:2 마모:2 체결불량:1\"),\n", + " (\"현가 장치\", [\"현가 장치\", \"서스펜션\", \"완충기\"], 15, 0.08, 5, (0.06, 0.30), 3.5, \"MK4\", \"마모:4 균열:3 체결불량:2 부식:1\"),\n", + " (\"차체 용접부\", [\"차체 용접부\", \"프레임 용접부\", \"차체 이음부\"], 10, 0.05, 10, (0.28, 0.40), 7.0, \"MK1\", \"균열:6 부식:3 체결불량:1\"),\n", + " (\"광학·조준 장비\", [\"광학 장비\", \"조준경\", \"관측 장비\"], 8, 0.05, 3, (0.03, 0.22), 8.0, \"MK4\", \"오작동:4 접촉불량:3 부식:2 체결불량:1\"),\n", + " (\"공기 정화 필터\", [\"공기 필터\", \"흡기 필터\", \"정화 장치\"], 12, 0.25, 4, (0.01, 0.12), 1.0, \"MK3\", \"마모:5 부식:2 체결불량:3\")]\n", + "NP = len(PARTS)\n", + "DEFECTS = { # 결함 유형: 증상 표현(동의어)\n", + " \"균열\": [\"균열이 발견됨\", \"크랙이 확인됨\", \"갈라짐이 있음\"], \"누유\": [\"누유가 발생함\", \"오일이 새어 나옴\", \"누설 흔적이 있음\"],\n", + " \"마모\": [\"마모가 심함\", \"과도한 마모가 확인됨\", \"닳은 흔적이 있음\"], \"부식\": [\"부식이 진행됨\", \"녹이 발생함\", \"산화 흔적이 있음\"],\n", + " \"접촉불량\": [\"접촉 불량이 발생함\", \"커넥터 단선이 의심됨\", \"신호가 끊김\"], \"체결불량\": [\"볼트 풀림이 확인됨\", \"체결 상태가 불량함\", \"고정부가 헐거움\"],\n", + " \"과열\": [\"과열 경고가 발생함\", \"온도가 과도하게 상승함\", \"열이 심하게 남\"], \"오작동\": [\"오작동이 반복됨\", \"작동이 불안정함\", \"간헐적으로 멈춤\"]}\n", + "DNAMES = list(DEFECTS)\n", + "SEV = [\"Minor\", \"Major\", \"Critical\"]\n", + "SEV_CUE = {0: [\"경미한 수준임\", \"운용에는 지장이 없음\", \"육안 점검상 사소한 이상임\", \"기능에는 영향이 없음\"],\n", + " 1: [\"성능 저하가 있음\", \"조속한 정비가 필요함\", \"출력이 떨어지는 상태임\", \"기능이 부분적으로 제한됨\"],\n", + " 2: [\"즉시 운용 중지함\", \"안전에 위험이 있어 사용 불가\", \"작동 정지로 임무 수행이 어려움\", \"인명 안전 위협이 우려됨\"]}\n", + "CAUSES = [\"제조 불량\", \"정비 품질\", \"운용 부주의\", \"설계 취약\", \"자연 노후\"]\n", + "CAUSE_CUE = {\n", + " \"제조 불량\": [\"신품 인수 직후 발생함\", \"출고 상태에서 이미 이상이 있었음\", \"인수 후 얼마 지나지 않아 발생함\"],\n", + " \"정비 품질\": [\"외주 정비 후 얼마 되지 않아 재발함\", \"정비 후 재조립 상태가 불량함\", \"정비 직후 같은 부위에서 다시 발생함\"],\n", + " \"운용 부주의\": [\"험지 기동 중 충격 이후 발생함\", \"과부하 운용 후 발생함\", \"점검 없이 연속 운용한 뒤 발생함\"],\n", + " \"설계 취약\": [\"동일 증상이 다른 차량에서도 반복됨\", \"구조적으로 취약한 부위로 보임\", \"여러 부대에서 같은 형태로 보고됨\"],\n", + " \"자연 노후\": [\"사용 연한이 도래함\", \"장기간 사용으로 노후됨\", \"수명 주기 말기에 발생함\"]}\n", + "VENDORS = [\"외주A\", \"외주B\", \"외주C\", \"외주D\", \"부대정비\"]\n", + "ACTIONS = [\"정비소 입고를 요청함\", \"현장 임시 조치 후 운용 중임\", \"부품 교환을 요청함\", \"재검사를 요청함\", \"원인 확인이 필요함\"]\n", + "MAKER_OF = [p[7] for p in PARTS]\n", + "PRICE = np.array([p[6] for p in PARTS])\n", + "\n", + "\n", + "def usage_index(months, seed=0):\n", + " \"\"\"월별 훈련 강도(미리 알 수 있는 계획 값): 연 1회 정점을 갖는 계절 + 작은 잡음\"\"\"\n", + " r = np.random.default_rng(seed + 99)\n", + " t = np.arange(months)\n", + " return 1 + 0.25 * np.sin(2 * np.pi * (t - 3) / 12) + r.normal(0, 0.04, months)\n", + "\n", + "\n", + "def make_qdr(months=36, seed=1, scale=1.0, surge=True):\n", + " \"\"\"반환: QDR DataFrame(건당 1행), 월별 usage, 급증 이벤트 목록\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " use = usage_index(months, seed)\n", + " events = []\n", + " if surge:\n", + " for _ in range(max(2, months // 12 * 2)):\n", + " events.append((int(rng.integers(NP)), int(rng.integers(6, months))))\n", + " rows = []\n", + " wdef = [np.array([float(x.split(\":\")[1]) for x in p[8].split()]) for p in PARTS]\n", + " ddef = [[x.split(\":\")[0] for x in p[8].split()] for p in PARTS]\n", + " n = 0\n", + " for m in range(months):\n", + " for pi, p in enumerate(PARTS):\n", + " lam = p[2] * scale * (1 + 0.010 * m) * use[m] * (1 + p[3] * np.cos(2 * np.pi * (m % 12 - p[4]) / 12))\n", + " ev = (pi, m) in events\n", + " k = rng.poisson(lam * (2.6 if ev else 1.0))\n", + " for _ in range(k):\n", + " rows.append((n, m, pi, ev)); n += 1\n", + " N = len(rows)\n", + " A = np.array(rows)\n", + " month, part, ev = A[:, 1], A[:, 2], A[:, 3].astype(bool)\n", + " defect = np.array([rng.choice(ddef[i], p=wdef[i] / wdef[i].sum()) for i in part])\n", + " cr = np.array([PARTS[i][5][0] for i in part]); ma = np.array([PARTS[i][5][1] for i in part])\n", + " u = rng.random(N)\n", + " sev = np.where(u < cr, 2, np.where(u < cr + ma, 1, 0))\n", + " vendor = rng.choice(VENDORS, N, p=[0.25, 0.2, 0.2, 0.15, 0.2])\n", + " # 원인: 기본 비율에 부위(제조사)·업체·이벤트 효과를 곱한다\n", + " base = np.array([0.22, 0.20, 0.20, 0.16, 0.22])\n", + " P = np.tile(base, (N, 1))\n", + " P[vendor == \"외주B\", 1] *= 2.6\n", + " P[ev, 0] *= 5.0\n", + " design = np.isin(part, [2, 3, 7]); P[design, 3] *= 2.2\n", + " P[np.isin(part, [4, 5, 11]), 0] *= 2.0 # 제조사 MK3 납품 부위는 제조 불량 비중이 높다\n", + " P[defect == \"마모\", 4] *= 1.6; P[defect == \"누유\", 4] *= 1.3\n", + " P /= P.sum(axis=1, keepdims=True)\n", + " cause = np.array([CAUSES[rng.choice(5, p=P[i])] for i in range(N)])\n", + " since = np.where(cause == \"정비 품질\", rng.exponential(35, N), rng.exponential(160, N)).clip(1, 720).round().astype(int)\n", + " wait = (rng.lognormal(1.3, 0.5, N) * np.where(vendor == \"외주B\", 1.7, np.where(vendor == \"부대정비\", 0.6, 1.0)) * (0.7 + 0.5 * use[month])).round(1)\n", + " reins = rng.random(N) < np.where(cause == \"정비 품질\", 0.6, 0.08)\n", + " texts = []\n", + " for i in range(N):\n", + " pt = PARTS[part[i]]\n", + " cl = [f\"{rng.choice(pt[1])}에서 {rng.choice(DEFECTS[defect[i]])}\"]\n", + " if rng.random() < 0.7: cl.append(rng.choice(SEV_CUE[sev[i]]))\n", + " if rng.random() < 0.65: # 원인 단서: 8%는 실제 원인과 다른 단서가 적힌다(보고자의 추정)\n", + " cc = cause[i] if rng.random() > 0.08 else rng.choice(CAUSES)\n", + " cl.append(rng.choice(CAUSE_CUE[cc]))\n", + " cl.append(rng.choice(ACTIONS))\n", + " if rng.random() < 0.5: cl.insert(0, f\"VU-{rng.integers(1, 31):02d} 소속\")\n", + " texts.append(\". \".join(cl) + \".\")\n", + " df = pd.DataFrame({\"qdr_id\": [f\"Q{seed}{i:06d}\" for i in range(N)], \"month\": month, \"unit\": [f\"VU-{x:02d}\" for x in rng.integers(1, 31, N)],\n", + " \"part\": part, \"defect\": defect, \"sev\": sev, \"cause\": cause, \"vendor\": vendor, \"maker\": [MAKER_OF[i] for i in part],\n", + " \"since_maint\": since, \"inspect_wait\": wait, \"reinspect\": reins, \"event\": ev, \"text\": texts})\n", + " df[\"cost\"] = PRICE[df[\"part\"]] * np.array([1.0, 2.0, 4.0])[df[\"sev\"]] # [교육용가상자료] 수리·조치 비용지수\n", + " return df, use, events\n", + "\n", + "df, use, events = make_qdr(48, seed=1, scale=1.5)\n", + "names = [p[0] for p in PARTS]\n", + "D0 = 24 # 검토 기준 월: 직전 12개월(12~23) 근거로 제안, 이후 24개월(24~47) 효과 평가\n", + "CLAIM_FIXED, CHANGE_FIXED, FIX_VENDOR = 15.0, 100.0, 50.0 # [교육용가상자료] 하자 구상 청구 처리비, 설계 변경 소요 제기·검토 비용지수\n", + "RECOVER, REDUCE, LAG, W_WAIT, REINSPECT = 0.8, 0.8, 3, 0.8, 6.0 # 하자 구상 회수율, 설계 변경 후 해당 원인 QDR 감소율, 설계 변경 반영 지연(개월)\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 proposals(d):\n", + " \"\"\"부위별로 근거(건수·치명도·원인 비중)를 집계해 제안 유형을 정한다.\"\"\"\n", + " ev = d[(d[\"month\"] >= D0 - 12) & (d[\"month\"] < D0)]\n", + " out = []\n", + " for p, g in ev.groupby(\"part\"):\n", + " df_, n = g[\"defect\"].mode()[0], len(g)\n", + " mfg, dsg = (g[\"cause\"] == \"제조 불량\").mean(), (g[\"cause\"] == \"설계 취약\").mean()\n", + " noise = ((g[\"cause\"] == \"운용 부주의\") | (g[\"cause\"] == \"자연 노후\")).mean()\n", + " if n >= 60 and mfg >= 0.22:\n", + " out.append({\"part\": p, \"defect\": df_, \"kind\": \"하자 구상\", \"n\": n, \"share\": mfg, \"crit\": (g[\"sev\"] == 2).mean(), \"noise\": noise})\n", + " if n >= 60 and dsg >= 0.20:\n", + " out.append({\"part\": p, \"defect\": df_, \"kind\": \"설계 변경\", \"n\": n, \"share\": dsg, \"crit\": (g[\"sev\"] == 2).mean(), \"noise\": noise})\n", + " for v, g in ev.groupby(\"vendor\"):\n", + " sh = (g[\"cause\"] == \"정비 품질\").mean()\n", + " if v != \"부대정비\" and len(g) >= 100 and sh >= 0.28:\n", + " out.append({\"part\": -1, \"vendor\": v, \"defect\": \"-\", \"kind\": \"외주정비 시정\", \"n\": len(g), \"share\": sh, \"crit\": (g[\"sev\"] == 2).mean(), \"noise\": ((g[\"cause\"] == \"운용 부주의\") | (g[\"cause\"] == \"자연 노후\")).mean()})\n", + " return out\n", + "\n", + "\n", + "def reviewer(pr, rng, second=False):\n", + " \"\"\"[모의 품질관] 근거가 약하거나 운용·노후 원인이 우세하면 반려, 10%는 기한 내 무응답(재상정 시에는 응답).\"\"\"\n", + " if not second and rng.random() < 0.10:\n", + " return \"무응답\", \"기한 내 응답 없음 -> 조치 보류(기본 계획 유지)\"\n", + " if pr[\"noise\"] > 0.46:\n", + " return \"반려\", f\"운용·노후 원인 {pr['noise']:.0%}로 제조사 책임 근거 부족\"\n", + " if pr[\"share\"] < 0.235 and pr[\"kind\"] != \"외주정비 시정\":\n", + " return \"반려\", f\"원인 비중 {pr['share']:.0%}로 평균 수준, 추가 관찰\"\n", + " if pr[\"n\"] < 120 and pr[\"crit\"] < 0.10:\n", + " return \"반려\", f\"근거 {pr['n']}건, Critical {pr['crit']:.0%}로 추가 관찰\"\n", + " return \"승인\", f\"{pr['kind']} 근거 {pr['n']}건, 원인 비중 {pr['share']:.0%}, Critical {pr['crit']:.0%}\"\n", + "\n", + "\n", + "def effect(d, pr, delay, surge):\n", + " \"\"\"승인된 제안의 이후 12개월 효과(비용지수 이득): 하자 구상은 제조 불량 QDR 비용의 일부 회수, 설계 변경은 설계 취약 QDR 감소\"\"\"\n", + " f = d[(d[\"month\"] >= D0) & (d[\"part\"] == pr[\"part\"])] if pr[\"part\"] >= 0 else d[(d[\"month\"] >= D0) & (d[\"vendor\"] == pr[\"vendor\"])]\n", + " start = D0 + delay\n", + " if pr[\"kind\"] == \"외주정비 시정\":\n", + " g = f[(f[\"cause\"] == \"정비 품질\") & (f[\"month\"] >= start + 2)]\n", + " return 0.7 * (g[\"cost\"] + W_WAIT * g[\"inspect_wait\"] + REINSPECT * g[\"reinspect\"]).sum() * surge - FIX_VENDOR\n", + " if pr[\"kind\"] == \"하자 구상\":\n", + " c = f[(f[\"cause\"] == \"제조 불량\") & (f[\"month\"] >= start)][\"cost\"].sum() * surge\n", + " return RECOVER * c - CLAIM_FIXED\n", + " c = f[(f[\"cause\"] == \"설계 취약\") & (f[\"month\"] >= start + LAG)][\"cost\"].sum() * surge\n", + " return REDUCE * c - CHANGE_FIXED\n", + "\n", + "\n", + "def run(mode, delay=1, surge=1.0, cap=99, seed=0, audit=None, resubmit=True):\n", + " rng = np.random.default_rng(seed)\n", + " props = sorted(proposals(df), key=lambda x: -x[\"n\"] * (1 + 3 * x[\"crit\"]))\n", + " extra = 0\n", + " gain, dec, n_ok = 0.0, {\"승인\": 0, \"반려\": 0, \"무응답\": 0}, 0\n", + " for pr in props:\n", + " if mode == \"auto\":\n", + " d_, why = \"승인\", \"비교용 전부 적용\"\n", + " else:\n", + " audit.add(event=\"PROPOSE\", target=(names[pr[\"part\"]] if pr[\"part\"] >= 0 else pr[\"vendor\"]) + \"/\" + pr[\"kind\"], status=\"PENDING\")\n", + " d_, why = reviewer(pr, rng)\n", + " if d_ == \"승인\" and n_ok >= cap:\n", + " d_, why = \"무응답\", \"월 처리 용량 초과 -> 보류\"\n", + " tg = (names[pr[\"part\"]] if pr[\"part\"] >= 0 else pr[\"vendor\"]) + \"/\" + pr[\"kind\"]\n", + " audit.add(event=\"DECIDE\", target=tg, decision=d_, reason=why, by=\"품질관(모의)\")\n", + " extra = 0\n", + " if d_ == \"무응답\" and resubmit: # 무응답은 기본 계획 유지(조치 보류), 다음 분기 검토 회의에 재상정\n", + " audit.add(event=\"RESUBMIT\", target=tg, status=\"PENDING\", note=\"분기 검토 회의 재상정(자동 승인 아님)\")\n", + " d_, why = reviewer(pr, rng, second=True)\n", + " audit.add(event=\"DECIDE\", target=tg, decision=d_, reason=why + \"(재상정)\", by=\"품질관(모의)\")\n", + " extra = 3\n", + " dec[d_] += 1\n", + " if d_ == \"승인\":\n", + " n_ok += 1; gain += effect(df, pr, (delay + (extra if mode != \"auto\" else 0)) if mode != \"auto\" else 0, surge)\n", + " e = df[df[\"month\"] >= D0]\n", + " base = (e[\"cost\"].sum() + W_WAIT * e[\"inspect_wait\"].sum() + REINSPECT * e[\"reinspect\"].sum()) * surge\n", + " return gain, base, dec, len(props)\n", + "\n", + "\n", + "audit = Audit()\n", + "g_h, base, dec, n_prop = run(\"hitl\", seed=3, audit=audit)\n", + "g_a, _, _, _ = run(\"auto\")\n", + "print(f\"[교육용가상자료] QDR {len(df)}건, 검토 기준 월 {D0}, 후보 제안 {n_prop}건(하자 구상·설계 변경·외주정비 시정)\")\n", + "print(\"\\n[비용 비교] 방식 | 이후 24개월 비용지수 | 절감액\")\n", + "for nm, g in [(\"개별 사후 수리(기본 계획)\", 0), (\"제안 + 품질관 승인\", g_h), (\"제안 전부 적용(비교용)\", g_a)]:\n", + " print(f\" {nm:22s} {base - g:9.0f} {g:8.0f}\")\n", + "cut = g_h / base\n", + "print(f\"HITL 적용 비용 절감률 {cut:.1%} (목표 15% 이상: {'달성' if cut >= 0.15 else '미달'}), 비교용 전부 적용 {g_a / base:.1%}\")\n", + "print(f\"최종 결정 {sum(dec.values())}건: \" + \", \".join(f\"{k} {v}건\" for k, v in dec.items()))\n", + "tgt = {e[\"target\"] for e in audit.log if e[\"event\"] == \"PROPOSE\"}; dec_t = {e[\"target\"] for e in audit.log if e[\"event\"] == \"DECIDE\"}\n", + "n_re = sum(e[\"event\"] == \"RESUBMIT\" for e in audit.log)\n", + "print(f\"제안 {len(tgt)}건 중 결정 기록 {len(tgt & dec_t)}건(로깅률 {len(tgt & dec_t) / len(tgt):.0%}), 자동 승인 0건, 무응답 {n_re}건은 조치 보류 후 분기 회의 재상정\")\n", + "rej = [e[\"reason\"] for e in audit.log if e.get(\"decision\") == \"반려\"]\n", + "print(\"반려 사유 예:\", rej[0] if rej else \"-\")\n", + "print(f\"감사 로그 무결성: {'정상' if audit.verify() is None else '이상'}\", end=\", \")\n", + "audit.log[3][\"reason\"] = \"사후 수정\"\n", + "print(f\"사후 변조 시 seq {audit.verify()}에서 탐지\")\n", + "\n", + "print(\"\\n[민감도 1] 승인까지 지연(개월) -> 절감률\")\n", + "for dl in (0, 1, 3, 6, 9):\n", + " g, b, _, _ = run(\"hitl\", delay=dl, seed=3, audit=Audit())\n", + " print(f\" {dl}개월: {g / b:.1%}\")\n", + "print(\"[민감도 2] 품질관 처리 용량(승인 가능 건수) -> 절감률\")\n", + "for cp in (2, 5, 8, 99):\n", + " g, b, _, _ = run(\"hitl\", cap=cp, seed=3, audit=Audit())\n", + " print(f\" 최대 {cp if cp < 99 else '무제한'}건: {g / b:.1%}\")\n", + "print(\"[민감도 3] 전시 전환 가정(QDR 비용 1.5배 급증) -> 절감률\")\n", + "for sg in (1.0, 1.5, 2.0):\n", + " g, b, _, _ = run(\"hitl\", surge=sg, seed=3, audit=Audit())\n", + " print(f\" x{sg}: {g / b:.1%}\")\n", + "\n", + "plt.figure(figsize=(6, 4))\n", + "plt.bar([\"baseline\", \"HITL\", \"auto(ref)\"], [base, base - g_h, base - g_a]); plt.ylabel(\"cost index (24 months)\"); plt.title(\"Quality review HITL simulator\")\n", + "plt.tight_layout(); plt.show()\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 07 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + "[교육용가상자료] QDR 19316건, 검토 기준 월 24, 후보 제안 13건(하자 구상·설계 변경·외주정비 시정)\n", + "\n", + "[비용 비교] 방식 | 이후 24개월 비용지수 | 절감액\n", + " 개별 사후 수리(기본 계획) 144670 0\n", + " 제안 + 품질관 승인 122929 21741\n", + " 제안 전부 적용(비교용) 119586 25084\n", + "HITL 적용 비용 절감률 15.0% (목표 15% 이상: 달성), 비교용 전부 적용 17.3%\n", + "최종 결정 13건: 승인 12건, 반려 1건, 무응답 0건\n", + "제안 13건 중 결정 기록 13건(로깅률 100%), 자동 승인 0건, 무응답 2건은 조치 보류 후 분기 회의 재상정\n", + "반려 사유 예: 원인 비중 23%로 평균 수준, 추가 관찰\n", + "감사 로그 무결성: 정상, 사후 변조 시 seq 3에서 탐지\n", + "\n", + "[민감도 1] 승인까지 지연(개월) -> 절감률\n", + " 0개월: 15.9%\n", + " 1개월: 15.0%\n", + " 3개월: 13.6%\n", + " 6개월: 11.4%\n", + " 9개월: 9.2%\n", + "[민감도 2] 품질관 처리 용량(승인 가능 건수) -> 절감률\n", + " 최대 2건: 14.2%\n", + " 최대 5건: 14.4%\n", + " 최대 8건: 14.8%\n", + " 최대 무제한건: 15.0%\n", + "[민감도 3] 전시 전환 가정(QDR 비용 1.5배 급증) -> 절감률\n", + " x1.0: 15.0%\n", + " x1.5: 15.1%\n", + " x2.0: 15.2%\n", + "```\n", + "
\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 08. 10년치 대용량 야전 QDR 텍스트 데이터 일괄 분석 스트레스 테스트\n", + "**소요시간**: 약 3.0시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "대용량 야전 QDR 텍스트 데이터 일괄 분석 스트레스 테스트를 수행한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **10년(120개월)치 대용량 야전 QDR 텍스트를 일괄 분석하는 스트레스 시험을 수행해 처리 시간, 화면 구성 시간, 경보 정확도를 확인한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **가상 QDR 약 6만 건에 빈 서술, 인코딩 깨짐, 과대 길이 레코드를 섞어 1~10년 단위로 주입해 격리·유실 건수와 분석 시간을 센다 과정을 진행하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `src/dashboard/qlt05_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": [ + "\"\"\"10년(120개월)치 대용량 야전 QDR 텍스트를 한꺼번에 주입해 정규화·부위/결함/치명도 라벨링·월별 집계·집중도 경보를 일괄 분석하고, 규모별 처리 시간, 불량 레코드(빈 서술·인코딩 깨짐·과대 길이) 격리와 유실 0건, 대시보드 화면 구성·필터 응답 시간, 급증 이벤트에 대한 경보 정확도를 확인하는 스트레스 시험을 수행한다.\"\"\"\n", + "import re\n", + "import time\n", + "import unicodedata\n", + "import numpy as np\n", + "import pandas as pd\n", + "import matplotlib.pyplot as plt\n", + "\n", + "# ---------------------------------------------------------------------------\n", + "# [교육용가상자료] 가상 야전 장비 품질결함보고(QDR) 생성기 (실제 부대·장비·업체·정비 실적 없음)\n", + "# 가상 장비 부위 12종, 결함 유형 8종, 치명도(Critical/Major/Minor), 원인 5종, 가상 외주정비 업체 4곳 + 부대정비.\n", + "# QDR 1건마다 한국어 서술 텍스트를 만들고, 월별 건수는 계절·추세·훈련 강도(usage)·일부 제조 불량 이벤트를 따른다.\n", + "# ---------------------------------------------------------------------------\n", + "PARTS = [ # (이름, 별칭, 월 기준 건수, 계절 진폭, 정점 월, (Critical, Major 비율), 가상 단가지수, 제조사, 주요 결함 가중치)\n", + " (\"엔진 연료계통\", [\"연료계통\", \"연료 라인\", \"연료 펌프\"], 26, 0.15, 7, (0.10, 0.35), 6.0, \"MK1\", \"누유:4 마모:2 체결불량:2 오작동:2\"),\n", + " (\"변속기\", [\"변속기\", \"T/M\", \"미션\"], 22, 0.10, 8, (0.12, 0.38), 9.0, \"MK1\", \"마모:4 누유:2 과열:3 오작동:1\"),\n", + " (\"제동 장치\", [\"제동 장치\", \"브레이크\", \"제동 라인\"], 20, 0.20, 12, (0.26, 0.40), 5.0, \"MK2\", \"마모:4 균열:2 누유:2 체결불량:2\"),\n", + " (\"조향 장치\", [\"조향 장치\", \"핸들 연동부\", \"조향 로드\"], 14, 0.10, 6, (0.30, 0.40), 5.0, \"MK2\", \"체결불량:4 균열:2 마모:3 오작동:1\"),\n", + " (\"냉각 계통\", [\"냉각 계통\", \"라디에이터\", \"냉각수 라인\"], 24, 0.30, 7, (0.05, 0.30), 3.0, \"MK3\", \"누유:4 과열:4 부식:2\"),\n", + " (\"전기·배터리\", [\"배터리\", \"전기 계통\", \"발전기\"], 28, 0.35, 1, (0.04, 0.28), 2.5, \"MK3\", \"접촉불량:4 부식:2 과열:2 오작동:2\"),\n", + " (\"통신 모듈\", [\"통신 모듈\", \"무전기 연동부\", \"안테나 접속부\"], 16, 0.05, 4, (0.03, 0.25), 4.0, \"MK4\", \"접촉불량:5 오작동:4 부식:1\"),\n", + " (\"유압 계통\", [\"유압 계통\", \"유압 호스\", \"유압 실린더\"], 18, 0.10, 9, (0.10, 0.35), 4.5, \"MK2\", \"누유:5 균열:2 마모:2 체결불량:1\"),\n", + " (\"현가 장치\", [\"현가 장치\", \"서스펜션\", \"완충기\"], 15, 0.08, 5, (0.06, 0.30), 3.5, \"MK4\", \"마모:4 균열:3 체결불량:2 부식:1\"),\n", + " (\"차체 용접부\", [\"차체 용접부\", \"프레임 용접부\", \"차체 이음부\"], 10, 0.05, 10, (0.28, 0.40), 7.0, \"MK1\", \"균열:6 부식:3 체결불량:1\"),\n", + " (\"광학·조준 장비\", [\"광학 장비\", \"조준경\", \"관측 장비\"], 8, 0.05, 3, (0.03, 0.22), 8.0, \"MK4\", \"오작동:4 접촉불량:3 부식:2 체결불량:1\"),\n", + " (\"공기 정화 필터\", [\"공기 필터\", \"흡기 필터\", \"정화 장치\"], 12, 0.25, 4, (0.01, 0.12), 1.0, \"MK3\", \"마모:5 부식:2 체결불량:3\")]\n", + "NP = len(PARTS)\n", + "DEFECTS = { # 결함 유형: 증상 표현(동의어)\n", + " \"균열\": [\"균열이 발견됨\", \"크랙이 확인됨\", \"갈라짐이 있음\"], \"누유\": [\"누유가 발생함\", \"오일이 새어 나옴\", \"누설 흔적이 있음\"],\n", + " \"마모\": [\"마모가 심함\", \"과도한 마모가 확인됨\", \"닳은 흔적이 있음\"], \"부식\": [\"부식이 진행됨\", \"녹이 발생함\", \"산화 흔적이 있음\"],\n", + " \"접촉불량\": [\"접촉 불량이 발생함\", \"커넥터 단선이 의심됨\", \"신호가 끊김\"], \"체결불량\": [\"볼트 풀림이 확인됨\", \"체결 상태가 불량함\", \"고정부가 헐거움\"],\n", + " \"과열\": [\"과열 경고가 발생함\", \"온도가 과도하게 상승함\", \"열이 심하게 남\"], \"오작동\": [\"오작동이 반복됨\", \"작동이 불안정함\", \"간헐적으로 멈춤\"]}\n", + "DNAMES = list(DEFECTS)\n", + "SEV = [\"Minor\", \"Major\", \"Critical\"]\n", + "SEV_CUE = {0: [\"경미한 수준임\", \"운용에는 지장이 없음\", \"육안 점검상 사소한 이상임\", \"기능에는 영향이 없음\"],\n", + " 1: [\"성능 저하가 있음\", \"조속한 정비가 필요함\", \"출력이 떨어지는 상태임\", \"기능이 부분적으로 제한됨\"],\n", + " 2: [\"즉시 운용 중지함\", \"안전에 위험이 있어 사용 불가\", \"작동 정지로 임무 수행이 어려움\", \"인명 안전 위협이 우려됨\"]}\n", + "CAUSES = [\"제조 불량\", \"정비 품질\", \"운용 부주의\", \"설계 취약\", \"자연 노후\"]\n", + "CAUSE_CUE = {\n", + " \"제조 불량\": [\"신품 인수 직후 발생함\", \"출고 상태에서 이미 이상이 있었음\", \"인수 후 얼마 지나지 않아 발생함\"],\n", + " \"정비 품질\": [\"외주 정비 후 얼마 되지 않아 재발함\", \"정비 후 재조립 상태가 불량함\", \"정비 직후 같은 부위에서 다시 발생함\"],\n", + " \"운용 부주의\": [\"험지 기동 중 충격 이후 발생함\", \"과부하 운용 후 발생함\", \"점검 없이 연속 운용한 뒤 발생함\"],\n", + " \"설계 취약\": [\"동일 증상이 다른 차량에서도 반복됨\", \"구조적으로 취약한 부위로 보임\", \"여러 부대에서 같은 형태로 보고됨\"],\n", + " \"자연 노후\": [\"사용 연한이 도래함\", \"장기간 사용으로 노후됨\", \"수명 주기 말기에 발생함\"]}\n", + "VENDORS = [\"외주A\", \"외주B\", \"외주C\", \"외주D\", \"부대정비\"]\n", + "ACTIONS = [\"정비소 입고를 요청함\", \"현장 임시 조치 후 운용 중임\", \"부품 교환을 요청함\", \"재검사를 요청함\", \"원인 확인이 필요함\"]\n", + "MAKER_OF = [p[7] for p in PARTS]\n", + "PRICE = np.array([p[6] for p in PARTS])\n", + "\n", + "\n", + "def usage_index(months, seed=0):\n", + " \"\"\"월별 훈련 강도(미리 알 수 있는 계획 값): 연 1회 정점을 갖는 계절 + 작은 잡음\"\"\"\n", + " r = np.random.default_rng(seed + 99)\n", + " t = np.arange(months)\n", + " return 1 + 0.25 * np.sin(2 * np.pi * (t - 3) / 12) + r.normal(0, 0.04, months)\n", + "\n", + "\n", + "def make_qdr(months=36, seed=1, scale=1.0, surge=True):\n", + " \"\"\"반환: QDR DataFrame(건당 1행), 월별 usage, 급증 이벤트 목록\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " use = usage_index(months, seed)\n", + " events = []\n", + " if surge:\n", + " for _ in range(max(2, months // 12 * 2)):\n", + " events.append((int(rng.integers(NP)), int(rng.integers(6, months))))\n", + " rows = []\n", + " wdef = [np.array([float(x.split(\":\")[1]) for x in p[8].split()]) for p in PARTS]\n", + " ddef = [[x.split(\":\")[0] for x in p[8].split()] for p in PARTS]\n", + " n = 0\n", + " for m in range(months):\n", + " for pi, p in enumerate(PARTS):\n", + " lam = p[2] * scale * (1 + 0.010 * m) * use[m] * (1 + p[3] * np.cos(2 * np.pi * (m % 12 - p[4]) / 12))\n", + " ev = (pi, m) in events\n", + " k = rng.poisson(lam * (2.6 if ev else 1.0))\n", + " for _ in range(k):\n", + " rows.append((n, m, pi, ev)); n += 1\n", + " N = len(rows)\n", + " A = np.array(rows)\n", + " month, part, ev = A[:, 1], A[:, 2], A[:, 3].astype(bool)\n", + " defect = np.array([rng.choice(ddef[i], p=wdef[i] / wdef[i].sum()) for i in part])\n", + " cr = np.array([PARTS[i][5][0] for i in part]); ma = np.array([PARTS[i][5][1] for i in part])\n", + " u = rng.random(N)\n", + " sev = np.where(u < cr, 2, np.where(u < cr + ma, 1, 0))\n", + " vendor = rng.choice(VENDORS, N, p=[0.25, 0.2, 0.2, 0.15, 0.2])\n", + " # 원인: 기본 비율에 부위(제조사)·업체·이벤트 효과를 곱한다\n", + " base = np.array([0.22, 0.20, 0.20, 0.16, 0.22])\n", + " P = np.tile(base, (N, 1))\n", + " P[vendor == \"외주B\", 1] *= 2.6\n", + " P[ev, 0] *= 5.0\n", + " design = np.isin(part, [2, 3, 7]); P[design, 3] *= 2.2\n", + " P[np.isin(part, [4, 5, 11]), 0] *= 2.0 # 제조사 MK3 납품 부위는 제조 불량 비중이 높다\n", + " P[defect == \"마모\", 4] *= 1.6; P[defect == \"누유\", 4] *= 1.3\n", + " P /= P.sum(axis=1, keepdims=True)\n", + " cause = np.array([CAUSES[rng.choice(5, p=P[i])] for i in range(N)])\n", + " since = np.where(cause == \"정비 품질\", rng.exponential(35, N), rng.exponential(160, N)).clip(1, 720).round().astype(int)\n", + " wait = (rng.lognormal(1.3, 0.5, N) * np.where(vendor == \"외주B\", 1.7, np.where(vendor == \"부대정비\", 0.6, 1.0)) * (0.7 + 0.5 * use[month])).round(1)\n", + " reins = rng.random(N) < np.where(cause == \"정비 품질\", 0.6, 0.08)\n", + " texts = []\n", + " for i in range(N):\n", + " pt = PARTS[part[i]]\n", + " cl = [f\"{rng.choice(pt[1])}에서 {rng.choice(DEFECTS[defect[i]])}\"]\n", + " if rng.random() < 0.7: cl.append(rng.choice(SEV_CUE[sev[i]]))\n", + " if rng.random() < 0.65: # 원인 단서: 8%는 실제 원인과 다른 단서가 적힌다(보고자의 추정)\n", + " cc = cause[i] if rng.random() > 0.08 else rng.choice(CAUSES)\n", + " cl.append(rng.choice(CAUSE_CUE[cc]))\n", + " cl.append(rng.choice(ACTIONS))\n", + " if rng.random() < 0.5: cl.insert(0, f\"VU-{rng.integers(1, 31):02d} 소속\")\n", + " texts.append(\". \".join(cl) + \".\")\n", + " df = pd.DataFrame({\"qdr_id\": [f\"Q{seed}{i:06d}\" for i in range(N)], \"month\": month, \"unit\": [f\"VU-{x:02d}\" for x in rng.integers(1, 31, N)],\n", + " \"part\": part, \"defect\": defect, \"sev\": sev, \"cause\": cause, \"vendor\": vendor, \"maker\": [MAKER_OF[i] for i in part],\n", + " \"since_maint\": since, \"inspect_wait\": wait, \"reinspect\": reins, \"event\": ev, \"text\": texts})\n", + " df[\"cost\"] = PRICE[df[\"part\"]] * np.array([1.0, 2.0, 4.0])[df[\"sev\"]] # [교육용가상자료] 수리·조치 비용지수\n", + " return df, use, events\n", + "\n", + "# ---------------------------------------------------------------------------\n", + "# 공통 텍스트 조각: 정규화, 간이 토큰화(조사 제거), 부위·결함·치명도·원인 키워드 라벨링\n", + "# ---------------------------------------------------------------------------\n", + "TYPO = {\"누윳\": \"누유\", \"균렬\": \"균열\", \"접축 불량\": \"접촉 불량\", \"부씩\": \"부식\", \"마묘\": \"마모\", \"과렬\": \"과열\", \"오작똥\": \"오작동\", \"재동\": \"제동\"}\n", + "ABBR = {\"T/M\": \"변속기\", \"BAT\": \"배터리\"}\n", + "PART_KW = sorted([(a, i) for i, p in enumerate(PARTS) for a in p[1]], key=lambda x: -len(x[0]))\n", + "DEF_KW = {\"균열\": [\"균열\", \"크랙\", \"갈라\"], \"누유\": [\"누유\", \"새어\", \"누설\"], \"마모\": [\"마모\", \"닳은\"], \"부식\": [\"부식\", \"녹이\", \"산화\"],\n", + " \"접촉불량\": [\"접촉 불량\", \"단선\", \"끊김\"], \"체결불량\": [\"볼트\", \"체결\", \"헐거\"], \"과열\": [\"과열\", \"온도\", \"열이\"], \"오작동\": [\"오작동\", \"불안정\", \"멈춤\"]}\n", + "SEV_KW = [(2, [\"즉시 운용 중지\", \"사용 불가\", \"위험\"]), (1, [\"성능 저하\", \"조속한\"]), (0, [\"경미\", \"지장이 없\"])]\n", + "CAUSE_KW = {\"제조 불량\": [\"신품\", \"출고\", \"인수\"], \"정비 품질\": [\"정비 후\", \"재조립\", \"정비 직후\"], \"운용 부주의\": [\"충격\", \"과부하\", \"점검 없이\"],\n", + " \"설계 취약\": [\"동일 증상\", \"구조적\", \"여러 부대\"], \"자연 노후\": [\"사용 연한\", \"노후\", \"수명\"]}\n", + "PARTICLES = (\"에서\", \"으로\", \"이\", \"가\", \"은\", \"는\", \"을\", \"를\", \"에\", \"의\", \"로\", \"와\", \"과\", \"도\", \"함\", \"음\", \"됨\")\n", + "\n", + "\n", + "def norm(t):\n", + " \"\"\"정규화: 전각->반각, 특수문자·중복 공백 제거, 약어 확장, 오타 교정\"\"\"\n", + " t = unicodedata.normalize(\"NFKC\", str(t))\n", + " t = re.sub(r\"[!#~*@^]+\", \" \", t)\n", + " for a, b in ABBR.items():\n", + " t = t.replace(a, b)\n", + " for a, b in TYPO.items():\n", + " t = t.replace(a, b)\n", + " return re.sub(r\"\\s+\", \" \", t).strip()\n", + "\n", + "\n", + "def tok(t):\n", + " out = []\n", + " for w in re.split(r\"[\\s.,]+\", norm(t)):\n", + " if not w or re.fullmatch(r\"[\\d\\-A-Za-z]+\", w):\n", + " continue\n", + " for p in PARTICLES:\n", + " if len(w) > len(p) + 1 and w.endswith(p):\n", + " w = w[:-len(p)]\n", + " break\n", + " out.append(w)\n", + " return out\n", + "\n", + "\n", + "def label_part(t):\n", + " for a, i in PART_KW:\n", + " if a in t:\n", + " return i\n", + " return -1\n", + "\n", + "\n", + "def label_defect(t):\n", + " for d, ks in DEF_KW.items():\n", + " if any(k in t for k in ks):\n", + " return d\n", + " return \"미분류\"\n", + "\n", + "\n", + "def label_sev(t):\n", + " for s, ks in SEV_KW:\n", + " if any(k in t for k in ks):\n", + " return s\n", + " return -1\n", + "\n", + "\n", + "def label_cause(t):\n", + " for c, ks in CAUSE_KW.items():\n", + " if any(k in t for k in ks):\n", + " return c\n", + " return \"미상\"\n", + "\n", + "names = [p[0] for p in PARTS]\n", + "t0 = time.perf_counter()\n", + "full, use, events = make_qdr(120, seed=3, scale=1.5)\n", + "print(f\"[교육용가상자료] 10년(120개월) QDR {len(full)}건 생성({time.perf_counter() - t0:.1f}초, 분석 시간에는 포함하지 않음), 부위 {NP}종, 급증 이벤트 {len(events)}건\")\n", + "rng = np.random.default_rng(0)\n", + "RE_SPEC, RE_SPACE = re.compile(r\"[!#~*@^]+\"), re.compile(r\"\\s+\")\n", + "PART_RE = [(re.compile(\"|\".join(map(re.escape, p[1]))), i) for i, p in enumerate(PARTS)]\n", + "DEF_RE = [(d, re.compile(\"|\".join(ks))) for d, ks in DEF_KW.items()]\n", + "\n", + "\n", + "def analyze(d):\n", + " \"\"\"일괄 분석: 검증(격리) -> 정규화 -> 부위·결함·치명도 라벨링. 반환: (정상 결과, 격리 건수)\"\"\"\n", + " ok = d[\"text\"].str.len().between(5, 400) & ~d[\"text\"].str.contains(\"�\", regex=False)\n", + " g = d[ok].copy()\n", + " g[\"t\"] = g[\"text\"].str.replace(RE_SPEC, \" \", regex=True).str.replace(RE_SPACE, \" \", regex=True).str.strip()\n", + " g[\"p_lab\"] = -1\n", + " for rx, i in PART_RE:\n", + " g.loc[(g[\"p_lab\"] == -1) & g[\"t\"].str.contains(rx), \"p_lab\"] = i\n", + " g[\"d_lab\"] = \"미분류\"\n", + " for dn, rx in DEF_RE:\n", + " g.loc[(g[\"d_lab\"] == \"미분류\") & g[\"t\"].str.contains(rx), \"d_lab\"] = dn\n", + " g[\"s_lab\"] = np.where(g[\"t\"].str.contains(\"즉시 운용 중지|사용 불가|위험|임무 수행이 어려움|위협\"), 2, np.where(g[\"t\"].str.contains(\"성능 저하|조속한|출력이|부분적으로\"), 1, 0))\n", + " return g, int((~ok).sum())\n", + "\n", + "\n", + "def alarms(g, months):\n", + " \"\"\"월·부위 집계 후 직전 12개월 발생률(훈련 강도 보정) 대비 급증 경보\"\"\"\n", + " M = g.pivot_table(index=\"p_lab\", columns=\"month\", values=\"qdr_id\", aggfunc=\"size\", fill_value=0).reindex(index=range(NP), columns=range(months), fill_value=0)\n", + " rate = (M / use[:months]).T.rolling(12, min_periods=6).mean().shift(1).T\n", + " exp = rate * use[:months]\n", + " z = (M - exp) / np.sqrt(exp.clip(lower=1))\n", + " return M, (M / exp.clip(lower=1) > 1.9) & (z > 4)\n", + "\n", + "\n", + "def make_input(n_months, bad=0.01):\n", + " d = full[full[\"month\"] < n_months].copy()\n", + " k = int(len(d) * bad)\n", + " idx = rng.choice(len(d), k, replace=False)\n", + " d.iloc[idx[: k // 3], d.columns.get_loc(\"text\")] = \"\"\n", + " d.iloc[idx[k // 3: 2 * k // 3], d.columns.get_loc(\"text\")] = \"��� 오류\"\n", + " d.iloc[idx[2 * k // 3:], d.columns.get_loc(\"text\")] = \"가\" * 900\n", + " return d, k\n", + "\n", + "\n", + "print(\"\\n[기간별 일괄 분석] 기간 | 입력 | 정상 | 격리 | 유실 | 분석 시간 | 처리량\")\n", + "res = {}\n", + "for months in (12, 36, 72, 120):\n", + " d, k = make_input(months)\n", + " t = time.perf_counter()\n", + " g, q = analyze(d)\n", + " M, A = alarms(g, months)\n", + " sec = time.perf_counter() - t\n", + " res[months] = (g, M, A, sec)\n", + " print(f\" {months // 12:2d}년 {len(d):7d} {len(g):7d} {q:5d} {len(d) - len(g) - q:3d} {sec:6.2f}초 {len(d) / sec:8.0f}건/초\")\n", + "g, M, A, sec = res[120]\n", + "\n", + "# 라벨링 품질과 경보 정확도\n", + "tr_ = full.set_index(\"qdr_id\").loc[g[\"qdr_id\"]]\n", + "print(f\"\\n라벨링 정확도(10년치): 부위 {(g['p_lab'].values == tr_['part'].values).mean():.1%}, 결함 유형 {(g['d_lab'].values == tr_['defect'].values).mean():.1%}, 치명도 {(g['s_lab'].values == tr_['sev'].values).mean():.1%} (단서 없는 건은 Minor 처리)\")\n", + "truth = np.zeros((NP, 120), bool)\n", + "for p, m in events:\n", + " truth[p, m] = True\n", + "valid = ~np.isnan(A.values.astype(float)) & (np.arange(120) >= 12)[None, :]\n", + "al = A.values & valid\n", + "tp, fp, fn = (al & truth).sum(), (al & ~truth).sum(), (~al & truth & valid).sum()\n", + "acc = ((al == truth) & valid).sum() / valid.sum()\n", + "print(f\"급증 경보(12개월 이후 {int(valid.sum())}개 부위-월 셀): 경보 정확도 {acc:.1%} (목표 95% 이상: {'달성' if acc >= 0.95 else '미달'}), 재현율 {tp / max(tp + fn, 1):.0%}, 정밀도 {tp / max(tp + fp, 1):.0%}, 실제 이벤트 {int((truth & valid).sum())}건\")\n", + "\n", + "# 대시보드 화면 구성: 10년치 집계표에서 히트맵·상위 5 부위·경보 목록을 만드는 시간, 필터(부위·기간) 응답 시간\n", + "t = time.perf_counter()\n", + "heat = M.values.reshape(NP, 10, 12).sum(axis=2)\n", + "top5 = M.sum(axis=1).sort_values(ascending=False).head(5)\n", + "alert_list = [(names[p], m) for p, m in zip(*np.where(al))]\n", + "load = time.perf_counter() - t\n", + "t = time.perf_counter()\n", + "for p in range(NP):\n", + " sub = g[(g[\"p_lab\"] == p) & (g[\"month\"] >= 84)]\n", + " _ = sub.groupby(\"d_lab\").size()\n", + "flt = (time.perf_counter() - t) / NP\n", + "print(f\"화면 구성(연도별 히트맵·상위 5·경보 {len(alert_list)}건) {load * 1000:.0f}ms, 필터 1회 응답 {flt * 1000:.0f}ms (목표 2초 이내: {'달성' if max(load, flt) <= 2 else '미달'})\")\n", + "print(\"상위 5 부위: \" + \", \".join(f\"{names[p]} {v}\" for p, v in top5.items()))\n", + "print(\"운용 기준: 일괄 분석은 야간 배치로 집계표를 갱신하고 화면은 집계표만 읽는다. 처리가 느려지면 최근 36개월만 재계산하고 격리율이 5%를 넘으면 서술 입력 체계를 점검한다.\")\n", + "\n", + "plt.figure(figsize=(6, 4))\n", + "ms = sorted(res)\n", + "plt.plot([m // 12 for m in ms], [res[m][3] for m in ms], marker=\"o\"); plt.xlabel(\"years of QDR\"); plt.ylabel(\"analysis seconds\"); plt.title(\"Bulk analysis time\")\n", + "plt.tight_layout(); plt.show()\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 08 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + "[교육용가상자료] 10년(120개월) QDR 62293건 생성(6.5초, 분석 시간에는 포함하지 않음), 부위 12종, 급증 이벤트 20건\n", + "\n", + "[기간별 일괄 분석] 기간 | 입력 | 정상 | 격리 | 유실 | 분석 시간 | 처리량\n", + " 1년 4177 4136 41 0 0.14초 30805건/초\n", + " 3년 14068 13928 140 0 0.36초 39039건/초\n", + " 6년 32040 31720 320 0 0.77초 41671건/초\n", + " 10년 62293 61671 622 0 1.78초 34950건/초\n", + "\n", + "라벨링 정확도(10년치): 부위 100.0%, 결함 유형 100.0%, 치명도 87.0% (단서 없는 건은 Minor 처리)\n", + "급증 경보(12개월 이후 1296개 부위-월 셀): 경보 정확도 99.8% (목표 95% 이상: 달성), 재현율 90%, 정밀도 100%, 실제 이벤트 20건\n", + "화면 구성(연도별 히트맵·상위 5·경보 18건) 1ms, 필터 1회 응답 4ms (목표 2초 이내: 달성)\n", + "상위 5 부위: 엔진 연료계통 7848, 전기·배터리 7816, 냉각 계통 7095, 변속기 6301, 제동 장치 5667\n", + "운용 기준: 일괄 분석은 야간 배치로 집계표를 갱신하고 화면은 집계표만 읽는다. 처리가 느려지면 최근 36개월만 재계산하고 격리율이 5%를 넘으면 서술 입력 체계를 점검한다.\n", + "```\n", + "
\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 09. AAR 기반 결함 원인 오분류 분석 및 텍스트 마이닝 룰셋 재튜닝\n", + "**소요시간**: 약 3.0시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "AAR 기반 결함 원인 오분류를 분석하고 마이닝 룰셋을 재튜닝한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **AAR 기반으로 결함 원인 오분류 사례를 분석하고 텍스트 마이닝 룰셋을 보조 규칙으로 재튜닝해 효과를 지표로 산출한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **AAR 기간 원인 분류 오류를 원인 단서 없음과 단서-실제 원인 불일치로 나누고 대표 사례 정리하고 규격화하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `reports/qlt05_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 기간(12~23개월) QDR의 결함 원인 분류 오류를 원인별로 분석하고, 키워드 기반 텍스트 마이닝 룰셋에 외주 정비 후 경과일·업체·결함 유형 등 보조 규칙(결정 트리로 도출)을 더해 재튜닝한 뒤, 검증 기간(24~35개월)에서 정확도와 오분류 비용 변화를 확인하고 3대 정량 효과 지표(비용 절감, 확인 시간 단축, 제조·정비 책임 건 식별 증가)와 도입 로드맵을 산출한다.\"\"\"\n", + "import re\n", + "import unicodedata\n", + "import numpy as np\n", + "import pandas as pd\n", + "from sklearn.tree import DecisionTreeClassifier, export_text\n", + "\n", + "# ---------------------------------------------------------------------------\n", + "# [교육용가상자료] 가상 야전 장비 품질결함보고(QDR) 생성기 (실제 부대·장비·업체·정비 실적 없음)\n", + "# 가상 장비 부위 12종, 결함 유형 8종, 치명도(Critical/Major/Minor), 원인 5종, 가상 외주정비 업체 4곳 + 부대정비.\n", + "# QDR 1건마다 한국어 서술 텍스트를 만들고, 월별 건수는 계절·추세·훈련 강도(usage)·일부 제조 불량 이벤트를 따른다.\n", + "# ---------------------------------------------------------------------------\n", + "PARTS = [ # (이름, 별칭, 월 기준 건수, 계절 진폭, 정점 월, (Critical, Major 비율), 가상 단가지수, 제조사, 주요 결함 가중치)\n", + " (\"엔진 연료계통\", [\"연료계통\", \"연료 라인\", \"연료 펌프\"], 26, 0.15, 7, (0.10, 0.35), 6.0, \"MK1\", \"누유:4 마모:2 체결불량:2 오작동:2\"),\n", + " (\"변속기\", [\"변속기\", \"T/M\", \"미션\"], 22, 0.10, 8, (0.12, 0.38), 9.0, \"MK1\", \"마모:4 누유:2 과열:3 오작동:1\"),\n", + " (\"제동 장치\", [\"제동 장치\", \"브레이크\", \"제동 라인\"], 20, 0.20, 12, (0.26, 0.40), 5.0, \"MK2\", \"마모:4 균열:2 누유:2 체결불량:2\"),\n", + " (\"조향 장치\", [\"조향 장치\", \"핸들 연동부\", \"조향 로드\"], 14, 0.10, 6, (0.30, 0.40), 5.0, \"MK2\", \"체결불량:4 균열:2 마모:3 오작동:1\"),\n", + " (\"냉각 계통\", [\"냉각 계통\", \"라디에이터\", \"냉각수 라인\"], 24, 0.30, 7, (0.05, 0.30), 3.0, \"MK3\", \"누유:4 과열:4 부식:2\"),\n", + " (\"전기·배터리\", [\"배터리\", \"전기 계통\", \"발전기\"], 28, 0.35, 1, (0.04, 0.28), 2.5, \"MK3\", \"접촉불량:4 부식:2 과열:2 오작동:2\"),\n", + " (\"통신 모듈\", [\"통신 모듈\", \"무전기 연동부\", \"안테나 접속부\"], 16, 0.05, 4, (0.03, 0.25), 4.0, \"MK4\", \"접촉불량:5 오작동:4 부식:1\"),\n", + " (\"유압 계통\", [\"유압 계통\", \"유압 호스\", \"유압 실린더\"], 18, 0.10, 9, (0.10, 0.35), 4.5, \"MK2\", \"누유:5 균열:2 마모:2 체결불량:1\"),\n", + " (\"현가 장치\", [\"현가 장치\", \"서스펜션\", \"완충기\"], 15, 0.08, 5, (0.06, 0.30), 3.5, \"MK4\", \"마모:4 균열:3 체결불량:2 부식:1\"),\n", + " (\"차체 용접부\", [\"차체 용접부\", \"프레임 용접부\", \"차체 이음부\"], 10, 0.05, 10, (0.28, 0.40), 7.0, \"MK1\", \"균열:6 부식:3 체결불량:1\"),\n", + " (\"광학·조준 장비\", [\"광학 장비\", \"조준경\", \"관측 장비\"], 8, 0.05, 3, (0.03, 0.22), 8.0, \"MK4\", \"오작동:4 접촉불량:3 부식:2 체결불량:1\"),\n", + " (\"공기 정화 필터\", [\"공기 필터\", \"흡기 필터\", \"정화 장치\"], 12, 0.25, 4, (0.01, 0.12), 1.0, \"MK3\", \"마모:5 부식:2 체결불량:3\")]\n", + "NP = len(PARTS)\n", + "DEFECTS = { # 결함 유형: 증상 표현(동의어)\n", + " \"균열\": [\"균열이 발견됨\", \"크랙이 확인됨\", \"갈라짐이 있음\"], \"누유\": [\"누유가 발생함\", \"오일이 새어 나옴\", \"누설 흔적이 있음\"],\n", + " \"마모\": [\"마모가 심함\", \"과도한 마모가 확인됨\", \"닳은 흔적이 있음\"], \"부식\": [\"부식이 진행됨\", \"녹이 발생함\", \"산화 흔적이 있음\"],\n", + " \"접촉불량\": [\"접촉 불량이 발생함\", \"커넥터 단선이 의심됨\", \"신호가 끊김\"], \"체결불량\": [\"볼트 풀림이 확인됨\", \"체결 상태가 불량함\", \"고정부가 헐거움\"],\n", + " \"과열\": [\"과열 경고가 발생함\", \"온도가 과도하게 상승함\", \"열이 심하게 남\"], \"오작동\": [\"오작동이 반복됨\", \"작동이 불안정함\", \"간헐적으로 멈춤\"]}\n", + "DNAMES = list(DEFECTS)\n", + "SEV = [\"Minor\", \"Major\", \"Critical\"]\n", + "SEV_CUE = {0: [\"경미한 수준임\", \"운용에는 지장이 없음\", \"육안 점검상 사소한 이상임\", \"기능에는 영향이 없음\"],\n", + " 1: [\"성능 저하가 있음\", \"조속한 정비가 필요함\", \"출력이 떨어지는 상태임\", \"기능이 부분적으로 제한됨\"],\n", + " 2: [\"즉시 운용 중지함\", \"안전에 위험이 있어 사용 불가\", \"작동 정지로 임무 수행이 어려움\", \"인명 안전 위협이 우려됨\"]}\n", + "CAUSES = [\"제조 불량\", \"정비 품질\", \"운용 부주의\", \"설계 취약\", \"자연 노후\"]\n", + "CAUSE_CUE = {\n", + " \"제조 불량\": [\"신품 인수 직후 발생함\", \"출고 상태에서 이미 이상이 있었음\", \"인수 후 얼마 지나지 않아 발생함\"],\n", + " \"정비 품질\": [\"외주 정비 후 얼마 되지 않아 재발함\", \"정비 후 재조립 상태가 불량함\", \"정비 직후 같은 부위에서 다시 발생함\"],\n", + " \"운용 부주의\": [\"험지 기동 중 충격 이후 발생함\", \"과부하 운용 후 발생함\", \"점검 없이 연속 운용한 뒤 발생함\"],\n", + " \"설계 취약\": [\"동일 증상이 다른 차량에서도 반복됨\", \"구조적으로 취약한 부위로 보임\", \"여러 부대에서 같은 형태로 보고됨\"],\n", + " \"자연 노후\": [\"사용 연한이 도래함\", \"장기간 사용으로 노후됨\", \"수명 주기 말기에 발생함\"]}\n", + "VENDORS = [\"외주A\", \"외주B\", \"외주C\", \"외주D\", \"부대정비\"]\n", + "ACTIONS = [\"정비소 입고를 요청함\", \"현장 임시 조치 후 운용 중임\", \"부품 교환을 요청함\", \"재검사를 요청함\", \"원인 확인이 필요함\"]\n", + "MAKER_OF = [p[7] for p in PARTS]\n", + "PRICE = np.array([p[6] for p in PARTS])\n", + "\n", + "\n", + "def usage_index(months, seed=0):\n", + " \"\"\"월별 훈련 강도(미리 알 수 있는 계획 값): 연 1회 정점을 갖는 계절 + 작은 잡음\"\"\"\n", + " r = np.random.default_rng(seed + 99)\n", + " t = np.arange(months)\n", + " return 1 + 0.25 * np.sin(2 * np.pi * (t - 3) / 12) + r.normal(0, 0.04, months)\n", + "\n", + "\n", + "def make_qdr(months=36, seed=1, scale=1.0, surge=True):\n", + " \"\"\"반환: QDR DataFrame(건당 1행), 월별 usage, 급증 이벤트 목록\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " use = usage_index(months, seed)\n", + " events = []\n", + " if surge:\n", + " for _ in range(max(2, months // 12 * 2)):\n", + " events.append((int(rng.integers(NP)), int(rng.integers(6, months))))\n", + " rows = []\n", + " wdef = [np.array([float(x.split(\":\")[1]) for x in p[8].split()]) for p in PARTS]\n", + " ddef = [[x.split(\":\")[0] for x in p[8].split()] for p in PARTS]\n", + " n = 0\n", + " for m in range(months):\n", + " for pi, p in enumerate(PARTS):\n", + " lam = p[2] * scale * (1 + 0.010 * m) * use[m] * (1 + p[3] * np.cos(2 * np.pi * (m % 12 - p[4]) / 12))\n", + " ev = (pi, m) in events\n", + " k = rng.poisson(lam * (2.6 if ev else 1.0))\n", + " for _ in range(k):\n", + " rows.append((n, m, pi, ev)); n += 1\n", + " N = len(rows)\n", + " A = np.array(rows)\n", + " month, part, ev = A[:, 1], A[:, 2], A[:, 3].astype(bool)\n", + " defect = np.array([rng.choice(ddef[i], p=wdef[i] / wdef[i].sum()) for i in part])\n", + " cr = np.array([PARTS[i][5][0] for i in part]); ma = np.array([PARTS[i][5][1] for i in part])\n", + " u = rng.random(N)\n", + " sev = np.where(u < cr, 2, np.where(u < cr + ma, 1, 0))\n", + " vendor = rng.choice(VENDORS, N, p=[0.25, 0.2, 0.2, 0.15, 0.2])\n", + " # 원인: 기본 비율에 부위(제조사)·업체·이벤트 효과를 곱한다\n", + " base = np.array([0.22, 0.20, 0.20, 0.16, 0.22])\n", + " P = np.tile(base, (N, 1))\n", + " P[vendor == \"외주B\", 1] *= 2.6\n", + " P[ev, 0] *= 5.0\n", + " design = np.isin(part, [2, 3, 7]); P[design, 3] *= 2.2\n", + " P[np.isin(part, [4, 5, 11]), 0] *= 2.0 # 제조사 MK3 납품 부위는 제조 불량 비중이 높다\n", + " P[defect == \"마모\", 4] *= 1.6; P[defect == \"누유\", 4] *= 1.3\n", + " P /= P.sum(axis=1, keepdims=True)\n", + " cause = np.array([CAUSES[rng.choice(5, p=P[i])] for i in range(N)])\n", + " since = np.where(cause == \"정비 품질\", rng.exponential(35, N), rng.exponential(160, N)).clip(1, 720).round().astype(int)\n", + " wait = (rng.lognormal(1.3, 0.5, N) * np.where(vendor == \"외주B\", 1.7, np.where(vendor == \"부대정비\", 0.6, 1.0)) * (0.7 + 0.5 * use[month])).round(1)\n", + " reins = rng.random(N) < np.where(cause == \"정비 품질\", 0.6, 0.08)\n", + " texts = []\n", + " for i in range(N):\n", + " pt = PARTS[part[i]]\n", + " cl = [f\"{rng.choice(pt[1])}에서 {rng.choice(DEFECTS[defect[i]])}\"]\n", + " if rng.random() < 0.7: cl.append(rng.choice(SEV_CUE[sev[i]]))\n", + " if rng.random() < 0.65: # 원인 단서: 8%는 실제 원인과 다른 단서가 적힌다(보고자의 추정)\n", + " cc = cause[i] if rng.random() > 0.08 else rng.choice(CAUSES)\n", + " cl.append(rng.choice(CAUSE_CUE[cc]))\n", + " cl.append(rng.choice(ACTIONS))\n", + " if rng.random() < 0.5: cl.insert(0, f\"VU-{rng.integers(1, 31):02d} 소속\")\n", + " texts.append(\". \".join(cl) + \".\")\n", + " df = pd.DataFrame({\"qdr_id\": [f\"Q{seed}{i:06d}\" for i in range(N)], \"month\": month, \"unit\": [f\"VU-{x:02d}\" for x in rng.integers(1, 31, N)],\n", + " \"part\": part, \"defect\": defect, \"sev\": sev, \"cause\": cause, \"vendor\": vendor, \"maker\": [MAKER_OF[i] for i in part],\n", + " \"since_maint\": since, \"inspect_wait\": wait, \"reinspect\": reins, \"event\": ev, \"text\": texts})\n", + " df[\"cost\"] = PRICE[df[\"part\"]] * np.array([1.0, 2.0, 4.0])[df[\"sev\"]] # [교육용가상자료] 수리·조치 비용지수\n", + " return df, use, events\n", + "\n", + "# ---------------------------------------------------------------------------\n", + "# 공통 텍스트 조각: 정규화, 간이 토큰화(조사 제거), 부위·결함·치명도·원인 키워드 라벨링\n", + "# ---------------------------------------------------------------------------\n", + "TYPO = {\"누윳\": \"누유\", \"균렬\": \"균열\", \"접축 불량\": \"접촉 불량\", \"부씩\": \"부식\", \"마묘\": \"마모\", \"과렬\": \"과열\", \"오작똥\": \"오작동\", \"재동\": \"제동\"}\n", + "ABBR = {\"T/M\": \"변속기\", \"BAT\": \"배터리\"}\n", + "PART_KW = sorted([(a, i) for i, p in enumerate(PARTS) for a in p[1]], key=lambda x: -len(x[0]))\n", + "DEF_KW = {\"균열\": [\"균열\", \"크랙\", \"갈라\"], \"누유\": [\"누유\", \"새어\", \"누설\"], \"마모\": [\"마모\", \"닳은\"], \"부식\": [\"부식\", \"녹이\", \"산화\"],\n", + " \"접촉불량\": [\"접촉 불량\", \"단선\", \"끊김\"], \"체결불량\": [\"볼트\", \"체결\", \"헐거\"], \"과열\": [\"과열\", \"온도\", \"열이\"], \"오작동\": [\"오작동\", \"불안정\", \"멈춤\"]}\n", + "SEV_KW = [(2, [\"즉시 운용 중지\", \"사용 불가\", \"위험\"]), (1, [\"성능 저하\", \"조속한\"]), (0, [\"경미\", \"지장이 없\"])]\n", + "CAUSE_KW = {\"제조 불량\": [\"신품\", \"출고\", \"인수\"], \"정비 품질\": [\"정비 후\", \"재조립\", \"정비 직후\"], \"운용 부주의\": [\"충격\", \"과부하\", \"점검 없이\"],\n", + " \"설계 취약\": [\"동일 증상\", \"구조적\", \"여러 부대\"], \"자연 노후\": [\"사용 연한\", \"노후\", \"수명\"]}\n", + "PARTICLES = (\"에서\", \"으로\", \"이\", \"가\", \"은\", \"는\", \"을\", \"를\", \"에\", \"의\", \"로\", \"와\", \"과\", \"도\", \"함\", \"음\", \"됨\")\n", + "\n", + "\n", + "def norm(t):\n", + " \"\"\"정규화: 전각->반각, 특수문자·중복 공백 제거, 약어 확장, 오타 교정\"\"\"\n", + " t = unicodedata.normalize(\"NFKC\", str(t))\n", + " t = re.sub(r\"[!#~*@^]+\", \" \", t)\n", + " for a, b in ABBR.items():\n", + " t = t.replace(a, b)\n", + " for a, b in TYPO.items():\n", + " t = t.replace(a, b)\n", + " return re.sub(r\"\\s+\", \" \", t).strip()\n", + "\n", + "\n", + "def tok(t):\n", + " out = []\n", + " for w in re.split(r\"[\\s.,]+\", norm(t)):\n", + " if not w or re.fullmatch(r\"[\\d\\-A-Za-z]+\", w):\n", + " continue\n", + " for p in PARTICLES:\n", + " if len(w) > len(p) + 1 and w.endswith(p):\n", + " w = w[:-len(p)]\n", + " break\n", + " out.append(w)\n", + " return out\n", + "\n", + "\n", + "def label_part(t):\n", + " for a, i in PART_KW:\n", + " if a in t:\n", + " return i\n", + " return -1\n", + "\n", + "\n", + "def label_defect(t):\n", + " for d, ks in DEF_KW.items():\n", + " if any(k in t for k in ks):\n", + " return d\n", + " return \"미분류\"\n", + "\n", + "\n", + "def label_sev(t):\n", + " for s, ks in SEV_KW:\n", + " if any(k in t for k in ks):\n", + " return s\n", + " return -1\n", + "\n", + "\n", + "def label_cause(t):\n", + " for c, ks in CAUSE_KW.items():\n", + " if any(k in t for k in ks):\n", + " return c\n", + " return \"미상\"\n", + "\n", + "df, use, events = make_qdr(36, seed=1, scale=1.5)\n", + "df[\"t\"] = df[\"text\"].map(norm)\n", + "aar, val = df[(df[\"month\"] >= 12) & (df[\"month\"] < 24)].copy(), df[df[\"month\"] >= 24].copy()\n", + "print(f\"[교육용가상자료] AAR 기간 {len(aar)}건, 검증 기간 {len(val)}건\")\n", + "\n", + "# 1) 기존 룰셋: 서술 속 원인 단서 키워드(첫 일치)\n", + "for d in (aar, val):\n", + " d[\"rule0\"] = d[\"t\"].map(label_cause)\n", + "wrong = aar[aar[\"rule0\"] != aar[\"cause\"]]\n", + "no_cue = wrong[wrong[\"rule0\"] == \"미상\"]\n", + "conflict = wrong[wrong[\"rule0\"] != \"미상\"]\n", + "print(f\"기존 룰셋 정확도: AAR {1 - len(wrong) / len(aar):.1%}, 검증 {(val['rule0'] == val['cause']).mean():.1%}\")\n", + "print(f\"AAR 오분류 {len(wrong)}건 ({len(wrong) / len(aar):.1%}): 원인 단서 없음(미상) {len(no_cue)}건 ({len(no_cue) / len(wrong):.0%}), 단서와 실제 원인 불일치 {len(conflict)}건 ({len(conflict) / len(wrong):.0%})\")\n", + "print(\" 미상 건의 실제 원인: \" + \", \".join(f\"{k} {v}\" for k, v in no_cue[\"cause\"].value_counts().items()))\n", + "c0 = conflict.groupby([\"rule0\", \"cause\"]).size().sort_values(ascending=False).head(2)\n", + "print(\" 불일치 상위: \" + \", \".join(f\"룰 '{a}' -> 실제 '{b}' {v}건\" for (a, b), v in c0.items()))\n", + "ex = no_cue[no_cue[\"cause\"] == \"정비 품질\"].iloc[0]\n", + "print(f\" 사례: '{ex['text']}' (업체 {ex['vendor']}, 정비 후 {ex['since_maint']}일) -> 룰 미상, 실제 정비 품질\")\n", + "\n", + "# 2) 재튜닝: 미상 건에 보조 규칙 적용(AAR 기간 미상 건만으로 결정 트리 학습, 깊이 3)\n", + "def meta(d):\n", + " X = pd.DataFrame({\"since_maint\": d[\"since_maint\"], \"ext_vendor\": (d[\"vendor\"] != \"부대정비\").astype(int), \"vendor_B\": (d[\"vendor\"] == \"외주B\").astype(int),\n", + " \"wait\": d[\"inspect_wait\"], \"reinspect\": d[\"reinspect\"].astype(int), \"sev\": d[\"sev\"], \"wear\": d[\"defect\"].isin([\"마모\", \"부식\"]).astype(int),\n", + " \"leak\": (d[\"defect\"] == \"누유\").astype(int)})\n", + " for p_ in (2, 3, 7):\n", + " X[f\"part{p_}\"] = (d[\"part\"] == p_).astype(int)\n", + " for p_ in (4, 5, 11):\n", + " X[f\"part{p_}\"] = (d[\"part\"] == p_).astype(int)\n", + " return X\n", + "\n", + "\n", + "tree = DecisionTreeClassifier(max_depth=3, min_samples_leaf=40, random_state=0).fit(meta(no_cue.assign(cause=no_cue[\"cause\"])), no_cue[\"cause\"])\n", + "print(\"\\n[재튜닝 보조 규칙(결정 트리, 미상 건 대상)]\")\n", + "for line in export_text(tree, feature_names=list(meta(no_cue).columns), max_depth=3).splitlines()[:9]:\n", + " print(\" \" + line)\n", + "\n", + "\n", + "CONF = 0.45\n", + "\n", + "\n", + "def rule1(d):\n", + " out = d[\"rule0\"].copy()\n", + " unk = out == \"미상\"\n", + " if unk.any():\n", + " pr = tree.predict_proba(meta(d[unk]))\n", + " out[unk] = np.where(pr.max(axis=1) >= CONF, tree.classes_[pr.argmax(axis=1)], \"미상\") # 확신이 낮으면 미상으로 남겨 사람이 확인\n", + " return out\n", + "\n", + "\n", + "for d in (aar, val):\n", + " d[\"rule1\"] = rule1(d)\n", + "acc = lambda d, c: (d[c] == d[\"cause\"]).mean()\n", + "print(f\"\\n정확도 변화: AAR(튜닝) {acc(aar, 'rule0'):.1%} -> {acc(aar, 'rule1'):.1%}, 검증(미사용) {acc(val, 'rule0'):.1%} -> {acc(val, 'rule1'):.1%}\")\n", + "for c in (\"제조 불량\", \"정비 품질\"):\n", + " m = val[\"cause\"] == c\n", + " print(f\" {c} 재현율(검증): {(val.loc[m, 'rule0'] == c).mean():.0%} -> {(val.loc[m, 'rule1'] == c).mean():.0%}\")\n", + "\n", + "# 3) 3대 정량 효과 지표 (검증 12개월, 가정 단가)\n", + "REWORK, HOURS = 0.5, 0.8 # [교육용가상자료] 오분류 건당 재작업 비용 = 조치 비용 x 0.5, 미상 건 수작업 원인 확인 0.8시간\n", + "cost0, cost1 = (val.loc[val[\"rule0\"] != val[\"cause\"], \"cost\"].sum() * REWORK), (val.loc[val[\"rule1\"] != val[\"cause\"], \"cost\"].sum() * REWORK)\n", + "h0, h1 = (val[\"rule0\"] == \"미상\").sum() * HOURS, (val[\"rule1\"] == \"미상\").sum() * HOURS\n", + "ext0, ext1 = ((val[\"rule0\"].isin([\"제조 불량\", \"정비 품질\"])) & (val[\"cause\"].isin([\"제조 불량\", \"정비 품질\"])) & (val[\"rule0\"] == val[\"cause\"])).sum(), \\\n", + " ((val[\"rule1\"].isin([\"제조 불량\", \"정비 품질\"])) & (val[\"cause\"].isin([\"제조 불량\", \"정비 품질\"])) & (val[\"rule1\"] == val[\"cause\"])).sum()\n", + "print(\"\\n[3대 정량 효과 지표] (검증 12개월, 가정 단가)\")\n", + "print(f\" 1) 비용 절감: 오분류 재작업 비용지수 {cost0:.0f} -> {cost1:.0f} ({1 - cost1 / cost0:.1%} 절감), 연 환산 약 {cost0 - cost1:.0f}\")\n", + "print(f\" 2) 시간 단축: 원인 수작업 확인 {h0:.0f}h -> {h1:.0f}h ({1 - h1 / h0:.1%} 단축, 확신 {CONF:.0%} 미만은 미상으로 남겨 사람이 확인)\")\n", + "print(f\" 3) 제조·정비 책임 건 정확 식별: {ext0}건 -> {ext1}건 (+{ext1 / ext0 - 1:.0%}), 하자 구상·시정 요구 근거 확보 증가\")\n", + "print(\"[도입 로드맵] 단기(1~3월): 보조 규칙 적용 + 품질관 확인 병행 / 중기(3~12월): 분기 AAR로 규칙 재학습, 단서 용어 사전 확장 / 장기(1년~): 군수 정보체계 정비 이력과 원인 라벨 환류\")\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 09 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + "[교육용가상자료] AAR 기간 4568건, 검증 기간 5103건\n", + "기존 룰셋 정확도: AAR 60.3%, 검증 61.7%\n", + "AAR 오분류 1815건 (39.7%): 원인 단서 없음(미상) 1617건 (89%), 단서와 실제 원인 불일치 198건 (11%)\n", + " 미상 건의 실제 원인: 제조 불량 407, 자연 노후 354, 정비 품질 331, 설계 취약 266, 운용 부주의 259\n", + " 불일치 상위: 룰 '설계 취약' -> 실제 '정비 품질' 15건, 룰 '제조 불량' -> 실제 '정비 품질' 15건\n", + " 사례: '연료계통에서 누설 흔적이 있음. 재검사를 요청함.' (업체 외주B, 정비 후 6일) -> 룰 미상, 실제 정비 품질\n", + "\n", + "[재튜닝 보조 규칙(결정 트리, 미상 건 대상)]\n", + " |--- reinspect <= 0.50\n", + " | |--- since_maint <= 62.50\n", + " | | |--- vendor_B <= 0.50\n", + " | | | |--- class: 제조 불량\n", + " | | |--- vendor_B > 0.50\n", + " | | | |--- class: 정비 품질\n", + " | |--- since_maint > 62.50\n", + " | | |--- part5 <= 0.50\n", + " | | | |--- class: 자연 노후\n", + "\n", + "정확도 변화: AAR(튜닝) 60.3% -> 65.4%, 검증(미사용) 61.7% -> 66.2%\n", + " 제조 불량 재현율(검증): 63% -> 67%\n", + " 정비 품질 재현율(검증): 62% -> 79%\n", + "\n", + "[3대 정량 효과 지표] (검증 12개월, 가정 단가)\n", + " 1) 비용 절감: 오분류 재작업 비용지수 8127 -> 7253 (10.8% 절감), 연 환산 약 874\n", + " 2) 시간 단축: 원인 수작업 확인 1409h -> 1126h (20.1% 단축, 확신 45% 미만은 미상으로 남겨 사람이 확인)\n", + " 3) 제조·정비 책임 건 정확 식별: 1454건 -> 1685건 (+16%), 하자 구상·시정 요구 근거 확보 증가\n", + "[도입 로드맵] 단기(1~3월): 보조 규칙 적용 + 품질관 확인 병행 / 중기(3~12월): 분기 AAR로 규칙 재학습, 단서 용어 사전 확장 / 장기(1년~): 군수 정보체계 정비 이력과 원인 라벨 환류\n", + "```\n", + "
\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 10. 야전 장비 결함 보고(QDR) 데이터 분석 및 품질 취약점 식별 도구 통합 완성\n", + "**소요시간**: 약 3.5시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "야전 장비 결함 보고(QDR) 데이터 분석 및 품질 취약점 식별 도구를 완성한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **QDR 검증·정제부터 치명도 분류, 취약성 지수·경보, 원인 분류, 승인 대기 후보 제안, 재학습까지 한 파일로 통합하고 자체 점검과 브리핑 요약을 출력하는 도구를 완성한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **지난달·이번 달 실적으로 챔피언 모델과 도전자 모델을 학습해 검증 정확도를 비교하고 비슷하거나 나을 때만 교체 수행하고 검증하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `docs/qlt05_operations_guide.md 및 pipeline/qlt05_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": [ + "\"\"\"QDR 서술 검증·정제·라벨링, 치명도 분류 모델의 자동 재학습(챔피언·도전자 비교 후 교체), 부위별 취약성 지수와 급증 경보, 재튜닝 규칙의 원인 분류, 하자 구상·설계 변경·외주정비 시정 후보의 품질관 승인 대기 목록까지 야전 QDR 품질 취약점 식별 도구를 한 파일로 통합 실행하고, 자체 점검·운영 문서 완비 점검·품질 담당 장교 브리핑 요약을 출력한다.\"\"\"\n", + "import re\n", + "import time\n", + "import unicodedata\n", + "import numpy as np\n", + "import pandas as pd\n", + "import scipy.sparse as sp\n", + "import matplotlib.pyplot as plt\n", + "from sklearn.feature_extraction.text import TfidfVectorizer\n", + "from sklearn.linear_model import LogisticRegression\n", + "\n", + "# ---------------------------------------------------------------------------\n", + "# [교육용가상자료] 가상 야전 장비 품질결함보고(QDR) 생성기 (실제 부대·장비·업체·정비 실적 없음)\n", + "# 가상 장비 부위 12종, 결함 유형 8종, 치명도(Critical/Major/Minor), 원인 5종, 가상 외주정비 업체 4곳 + 부대정비.\n", + "# QDR 1건마다 한국어 서술 텍스트를 만들고, 월별 건수는 계절·추세·훈련 강도(usage)·일부 제조 불량 이벤트를 따른다.\n", + "# ---------------------------------------------------------------------------\n", + "PARTS = [ # (이름, 별칭, 월 기준 건수, 계절 진폭, 정점 월, (Critical, Major 비율), 가상 단가지수, 제조사, 주요 결함 가중치)\n", + " (\"엔진 연료계통\", [\"연료계통\", \"연료 라인\", \"연료 펌프\"], 26, 0.15, 7, (0.10, 0.35), 6.0, \"MK1\", \"누유:4 마모:2 체결불량:2 오작동:2\"),\n", + " (\"변속기\", [\"변속기\", \"T/M\", \"미션\"], 22, 0.10, 8, (0.12, 0.38), 9.0, \"MK1\", \"마모:4 누유:2 과열:3 오작동:1\"),\n", + " (\"제동 장치\", [\"제동 장치\", \"브레이크\", \"제동 라인\"], 20, 0.20, 12, (0.26, 0.40), 5.0, \"MK2\", \"마모:4 균열:2 누유:2 체결불량:2\"),\n", + " (\"조향 장치\", [\"조향 장치\", \"핸들 연동부\", \"조향 로드\"], 14, 0.10, 6, (0.30, 0.40), 5.0, \"MK2\", \"체결불량:4 균열:2 마모:3 오작동:1\"),\n", + " (\"냉각 계통\", [\"냉각 계통\", \"라디에이터\", \"냉각수 라인\"], 24, 0.30, 7, (0.05, 0.30), 3.0, \"MK3\", \"누유:4 과열:4 부식:2\"),\n", + " (\"전기·배터리\", [\"배터리\", \"전기 계통\", \"발전기\"], 28, 0.35, 1, (0.04, 0.28), 2.5, \"MK3\", \"접촉불량:4 부식:2 과열:2 오작동:2\"),\n", + " (\"통신 모듈\", [\"통신 모듈\", \"무전기 연동부\", \"안테나 접속부\"], 16, 0.05, 4, (0.03, 0.25), 4.0, \"MK4\", \"접촉불량:5 오작동:4 부식:1\"),\n", + " (\"유압 계통\", [\"유압 계통\", \"유압 호스\", \"유압 실린더\"], 18, 0.10, 9, (0.10, 0.35), 4.5, \"MK2\", \"누유:5 균열:2 마모:2 체결불량:1\"),\n", + " (\"현가 장치\", [\"현가 장치\", \"서스펜션\", \"완충기\"], 15, 0.08, 5, (0.06, 0.30), 3.5, \"MK4\", \"마모:4 균열:3 체결불량:2 부식:1\"),\n", + " (\"차체 용접부\", [\"차체 용접부\", \"프레임 용접부\", \"차체 이음부\"], 10, 0.05, 10, (0.28, 0.40), 7.0, \"MK1\", \"균열:6 부식:3 체결불량:1\"),\n", + " (\"광학·조준 장비\", [\"광학 장비\", \"조준경\", \"관측 장비\"], 8, 0.05, 3, (0.03, 0.22), 8.0, \"MK4\", \"오작동:4 접촉불량:3 부식:2 체결불량:1\"),\n", + " (\"공기 정화 필터\", [\"공기 필터\", \"흡기 필터\", \"정화 장치\"], 12, 0.25, 4, (0.01, 0.12), 1.0, \"MK3\", \"마모:5 부식:2 체결불량:3\")]\n", + "NP = len(PARTS)\n", + "DEFECTS = { # 결함 유형: 증상 표현(동의어)\n", + " \"균열\": [\"균열이 발견됨\", \"크랙이 확인됨\", \"갈라짐이 있음\"], \"누유\": [\"누유가 발생함\", \"오일이 새어 나옴\", \"누설 흔적이 있음\"],\n", + " \"마모\": [\"마모가 심함\", \"과도한 마모가 확인됨\", \"닳은 흔적이 있음\"], \"부식\": [\"부식이 진행됨\", \"녹이 발생함\", \"산화 흔적이 있음\"],\n", + " \"접촉불량\": [\"접촉 불량이 발생함\", \"커넥터 단선이 의심됨\", \"신호가 끊김\"], \"체결불량\": [\"볼트 풀림이 확인됨\", \"체결 상태가 불량함\", \"고정부가 헐거움\"],\n", + " \"과열\": [\"과열 경고가 발생함\", \"온도가 과도하게 상승함\", \"열이 심하게 남\"], \"오작동\": [\"오작동이 반복됨\", \"작동이 불안정함\", \"간헐적으로 멈춤\"]}\n", + "DNAMES = list(DEFECTS)\n", + "SEV = [\"Minor\", \"Major\", \"Critical\"]\n", + "SEV_CUE = {0: [\"경미한 수준임\", \"운용에는 지장이 없음\", \"육안 점검상 사소한 이상임\", \"기능에는 영향이 없음\"],\n", + " 1: [\"성능 저하가 있음\", \"조속한 정비가 필요함\", \"출력이 떨어지는 상태임\", \"기능이 부분적으로 제한됨\"],\n", + " 2: [\"즉시 운용 중지함\", \"안전에 위험이 있어 사용 불가\", \"작동 정지로 임무 수행이 어려움\", \"인명 안전 위협이 우려됨\"]}\n", + "CAUSES = [\"제조 불량\", \"정비 품질\", \"운용 부주의\", \"설계 취약\", \"자연 노후\"]\n", + "CAUSE_CUE = {\n", + " \"제조 불량\": [\"신품 인수 직후 발생함\", \"출고 상태에서 이미 이상이 있었음\", \"인수 후 얼마 지나지 않아 발생함\"],\n", + " \"정비 품질\": [\"외주 정비 후 얼마 되지 않아 재발함\", \"정비 후 재조립 상태가 불량함\", \"정비 직후 같은 부위에서 다시 발생함\"],\n", + " \"운용 부주의\": [\"험지 기동 중 충격 이후 발생함\", \"과부하 운용 후 발생함\", \"점검 없이 연속 운용한 뒤 발생함\"],\n", + " \"설계 취약\": [\"동일 증상이 다른 차량에서도 반복됨\", \"구조적으로 취약한 부위로 보임\", \"여러 부대에서 같은 형태로 보고됨\"],\n", + " \"자연 노후\": [\"사용 연한이 도래함\", \"장기간 사용으로 노후됨\", \"수명 주기 말기에 발생함\"]}\n", + "VENDORS = [\"외주A\", \"외주B\", \"외주C\", \"외주D\", \"부대정비\"]\n", + "ACTIONS = [\"정비소 입고를 요청함\", \"현장 임시 조치 후 운용 중임\", \"부품 교환을 요청함\", \"재검사를 요청함\", \"원인 확인이 필요함\"]\n", + "MAKER_OF = [p[7] for p in PARTS]\n", + "PRICE = np.array([p[6] for p in PARTS])\n", + "\n", + "\n", + "def usage_index(months, seed=0):\n", + " \"\"\"월별 훈련 강도(미리 알 수 있는 계획 값): 연 1회 정점을 갖는 계절 + 작은 잡음\"\"\"\n", + " r = np.random.default_rng(seed + 99)\n", + " t = np.arange(months)\n", + " return 1 + 0.25 * np.sin(2 * np.pi * (t - 3) / 12) + r.normal(0, 0.04, months)\n", + "\n", + "\n", + "def make_qdr(months=36, seed=1, scale=1.0, surge=True):\n", + " \"\"\"반환: QDR DataFrame(건당 1행), 월별 usage, 급증 이벤트 목록\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " use = usage_index(months, seed)\n", + " events = []\n", + " if surge:\n", + " for _ in range(max(2, months // 12 * 2)):\n", + " events.append((int(rng.integers(NP)), int(rng.integers(6, months))))\n", + " rows = []\n", + " wdef = [np.array([float(x.split(\":\")[1]) for x in p[8].split()]) for p in PARTS]\n", + " ddef = [[x.split(\":\")[0] for x in p[8].split()] for p in PARTS]\n", + " n = 0\n", + " for m in range(months):\n", + " for pi, p in enumerate(PARTS):\n", + " lam = p[2] * scale * (1 + 0.010 * m) * use[m] * (1 + p[3] * np.cos(2 * np.pi * (m % 12 - p[4]) / 12))\n", + " ev = (pi, m) in events\n", + " k = rng.poisson(lam * (2.6 if ev else 1.0))\n", + " for _ in range(k):\n", + " rows.append((n, m, pi, ev)); n += 1\n", + " N = len(rows)\n", + " A = np.array(rows)\n", + " month, part, ev = A[:, 1], A[:, 2], A[:, 3].astype(bool)\n", + " defect = np.array([rng.choice(ddef[i], p=wdef[i] / wdef[i].sum()) for i in part])\n", + " cr = np.array([PARTS[i][5][0] for i in part]); ma = np.array([PARTS[i][5][1] for i in part])\n", + " u = rng.random(N)\n", + " sev = np.where(u < cr, 2, np.where(u < cr + ma, 1, 0))\n", + " vendor = rng.choice(VENDORS, N, p=[0.25, 0.2, 0.2, 0.15, 0.2])\n", + " # 원인: 기본 비율에 부위(제조사)·업체·이벤트 효과를 곱한다\n", + " base = np.array([0.22, 0.20, 0.20, 0.16, 0.22])\n", + " P = np.tile(base, (N, 1))\n", + " P[vendor == \"외주B\", 1] *= 2.6\n", + " P[ev, 0] *= 5.0\n", + " design = np.isin(part, [2, 3, 7]); P[design, 3] *= 2.2\n", + " P[np.isin(part, [4, 5, 11]), 0] *= 2.0 # 제조사 MK3 납품 부위는 제조 불량 비중이 높다\n", + " P[defect == \"마모\", 4] *= 1.6; P[defect == \"누유\", 4] *= 1.3\n", + " P /= P.sum(axis=1, keepdims=True)\n", + " cause = np.array([CAUSES[rng.choice(5, p=P[i])] for i in range(N)])\n", + " since = np.where(cause == \"정비 품질\", rng.exponential(35, N), rng.exponential(160, N)).clip(1, 720).round().astype(int)\n", + " wait = (rng.lognormal(1.3, 0.5, N) * np.where(vendor == \"외주B\", 1.7, np.where(vendor == \"부대정비\", 0.6, 1.0)) * (0.7 + 0.5 * use[month])).round(1)\n", + " reins = rng.random(N) < np.where(cause == \"정비 품질\", 0.6, 0.08)\n", + " texts = []\n", + " for i in range(N):\n", + " pt = PARTS[part[i]]\n", + " cl = [f\"{rng.choice(pt[1])}에서 {rng.choice(DEFECTS[defect[i]])}\"]\n", + " if rng.random() < 0.7: cl.append(rng.choice(SEV_CUE[sev[i]]))\n", + " if rng.random() < 0.65: # 원인 단서: 8%는 실제 원인과 다른 단서가 적힌다(보고자의 추정)\n", + " cc = cause[i] if rng.random() > 0.08 else rng.choice(CAUSES)\n", + " cl.append(rng.choice(CAUSE_CUE[cc]))\n", + " cl.append(rng.choice(ACTIONS))\n", + " if rng.random() < 0.5: cl.insert(0, f\"VU-{rng.integers(1, 31):02d} 소속\")\n", + " texts.append(\". \".join(cl) + \".\")\n", + " df = pd.DataFrame({\"qdr_id\": [f\"Q{seed}{i:06d}\" for i in range(N)], \"month\": month, \"unit\": [f\"VU-{x:02d}\" for x in rng.integers(1, 31, N)],\n", + " \"part\": part, \"defect\": defect, \"sev\": sev, \"cause\": cause, \"vendor\": vendor, \"maker\": [MAKER_OF[i] for i in part],\n", + " \"since_maint\": since, \"inspect_wait\": wait, \"reinspect\": reins, \"event\": ev, \"text\": texts})\n", + " df[\"cost\"] = PRICE[df[\"part\"]] * np.array([1.0, 2.0, 4.0])[df[\"sev\"]] # [교육용가상자료] 수리·조치 비용지수\n", + " return df, use, events\n", + "\n", + "# ---------------------------------------------------------------------------\n", + "# 공통 텍스트 조각: 정규화, 간이 토큰화(조사 제거), 부위·결함·치명도·원인 키워드 라벨링\n", + "# ---------------------------------------------------------------------------\n", + "TYPO = {\"누윳\": \"누유\", \"균렬\": \"균열\", \"접축 불량\": \"접촉 불량\", \"부씩\": \"부식\", \"마묘\": \"마모\", \"과렬\": \"과열\", \"오작똥\": \"오작동\", \"재동\": \"제동\"}\n", + "ABBR = {\"T/M\": \"변속기\", \"BAT\": \"배터리\"}\n", + "PART_KW = sorted([(a, i) for i, p in enumerate(PARTS) for a in p[1]], key=lambda x: -len(x[0]))\n", + "DEF_KW = {\"균열\": [\"균열\", \"크랙\", \"갈라\"], \"누유\": [\"누유\", \"새어\", \"누설\"], \"마모\": [\"마모\", \"닳은\"], \"부식\": [\"부식\", \"녹이\", \"산화\"],\n", + " \"접촉불량\": [\"접촉 불량\", \"단선\", \"끊김\"], \"체결불량\": [\"볼트\", \"체결\", \"헐거\"], \"과열\": [\"과열\", \"온도\", \"열이\"], \"오작동\": [\"오작동\", \"불안정\", \"멈춤\"]}\n", + "SEV_KW = [(2, [\"즉시 운용 중지\", \"사용 불가\", \"위험\"]), (1, [\"성능 저하\", \"조속한\"]), (0, [\"경미\", \"지장이 없\"])]\n", + "CAUSE_KW = {\"제조 불량\": [\"신품\", \"출고\", \"인수\"], \"정비 품질\": [\"정비 후\", \"재조립\", \"정비 직후\"], \"운용 부주의\": [\"충격\", \"과부하\", \"점검 없이\"],\n", + " \"설계 취약\": [\"동일 증상\", \"구조적\", \"여러 부대\"], \"자연 노후\": [\"사용 연한\", \"노후\", \"수명\"]}\n", + "PARTICLES = (\"에서\", \"으로\", \"이\", \"가\", \"은\", \"는\", \"을\", \"를\", \"에\", \"의\", \"로\", \"와\", \"과\", \"도\", \"함\", \"음\", \"됨\")\n", + "\n", + "\n", + "def norm(t):\n", + " \"\"\"정규화: 전각->반각, 특수문자·중복 공백 제거, 약어 확장, 오타 교정\"\"\"\n", + " t = unicodedata.normalize(\"NFKC\", str(t))\n", + " t = re.sub(r\"[!#~*@^]+\", \" \", t)\n", + " for a, b in ABBR.items():\n", + " t = t.replace(a, b)\n", + " for a, b in TYPO.items():\n", + " t = t.replace(a, b)\n", + " return re.sub(r\"\\s+\", \" \", t).strip()\n", + "\n", + "\n", + "def tok(t):\n", + " out = []\n", + " for w in re.split(r\"[\\s.,]+\", norm(t)):\n", + " if not w or re.fullmatch(r\"[\\d\\-A-Za-z]+\", w):\n", + " continue\n", + " for p in PARTICLES:\n", + " if len(w) > len(p) + 1 and w.endswith(p):\n", + " w = w[:-len(p)]\n", + " break\n", + " out.append(w)\n", + " return out\n", + "\n", + "\n", + "def label_part(t):\n", + " for a, i in PART_KW:\n", + " if a in t:\n", + " return i\n", + " return -1\n", + "\n", + "\n", + "def label_defect(t):\n", + " for d, ks in DEF_KW.items():\n", + " if any(k in t for k in ks):\n", + " return d\n", + " return \"미분류\"\n", + "\n", + "\n", + "def label_sev(t):\n", + " for s, ks in SEV_KW:\n", + " if any(k in t for k in ks):\n", + " return s\n", + " return -1\n", + "\n", + "\n", + "def label_cause(t):\n", + " for c, ks in CAUSE_KW.items():\n", + " if any(k in t for k in ks):\n", + " return c\n", + " return \"미상\"\n", + "\n", + "CONFIG = {\"retrain_tolerance\": 0.005, \"alarm_ratio\": 1.9, \"alarm_z\": 4.0, \"since_short\": 62, \"min_cases\": 60, \"mfg_share\": 0.22, \"design_share\": 0.20, \"vendor_share\": 0.28}\n", + "OPS_GUIDE = { # docs/qlt05_operations_guide.md 요약\n", + " \"1. 목적과 범위\": \"야전 QDR 서술·실적으로 다발 결함 부위와 품질 취약점을 식별하는 보조 도구. 하자 구상·설계 변경 소요 제기의 결정은 품질관.\",\n", + " \"2. 입력 데이터\": \"QDR(부대·장비·부위·서술·치명도), 정비 수행처와 경과일, 검사 대기·재검사. 가상 식별코드 사용, 실제 부대 자료는 부대 내에서만 처리.\",\n", + " \"3. 일일 운용 절차\": \"접수 -> 검증·격리 -> 정제·라벨링 -> 치명도 분류 -> 월별 집계·경보 -> 후보 제안(대기) -> 품질관 결정 -> 결과 기록.\",\n", + " \"4. 승인(HITL) 기준\": \"제조사 하자 구상·설계 변경·외주정비 시정은 품질관 승인 후 확정, 무응답은 조치 보류 후 분기 회의 재상정, 사유 기록.\",\n", + " \"5. 재학습 절차\": \"분기 1회 신규 QDR로 도전자 학습, 검증 정확도가 나빠지지 않을 때만 교체.\",\n", + " \"6. 장애 대응\": \"모델 오류 시 키워드 규칙으로 대체, 손상·비정상 서술은 수동 확인 목록으로 분리.\",\n", + " \"7. 연동 인터페이스\": \"국방 군수 정보체계와 QDR·정비 이력 표준 필드(장비코드·분류군·일시)를 CSV/API로 교환하는 안을 차기 과제로 둔다.\",\n", + "}\n", + "names = [p[0] for p in PARTS]\n", + "oh = lambda d: sp.csr_matrix(np.hstack([np.eye(NP)[d[\"part\"]], pd.get_dummies(d[\"defect\"]).reindex(columns=DNAMES, fill_value=0).values.astype(float)]))\n", + "\n", + "\n", + "def validate(d):\n", + " ok = d[\"text\"].str.len().between(5, 400) & ~d[\"text\"].str.contains(\"�\", regex=False)\n", + " return d[ok].copy(), d[~ok].copy()\n", + "\n", + "\n", + "def fit_sev(d):\n", + " vec = TfidfVectorizer(tokenizer=tok, lowercase=False, token_pattern=None, min_df=5)\n", + " X = sp.hstack([vec.fit_transform(d[\"text\"]), oh(d)]).tocsr()\n", + " return vec, LogisticRegression(C=5, max_iter=500).fit(X, d[\"sev\"])\n", + "\n", + "\n", + "def predict_sev(model, d):\n", + " vec, clf = model\n", + " return clf.predict(sp.hstack([vec.transform(d[\"text\"]), oh(d)]).tocsr())\n", + "\n", + "\n", + "def retrain(champion, old, new):\n", + " \"\"\"도전자(지난달+이번 달 앞 2/3) vs 챔피언(지난달) -> 이번 달 뒤 1/3 검증 정확도 비교, 비슷하거나 나을 때만 교체\"\"\"\n", + " cut = new[\"month\"].quantile(2 / 3)\n", + " hold, new_tr = new[new[\"month\"] > cut], new[new[\"month\"] <= cut]\n", + " challenger = fit_sev(pd.concat([old, new_tr], ignore_index=True))\n", + " a_old, a_new = (predict_sev(champion, hold) == hold[\"sev\"].values).mean(), (predict_sev(challenger, hold) == hold[\"sev\"].values).mean()\n", + " swap = a_new >= a_old - CONFIG[\"retrain_tolerance\"]\n", + " return (challenger if swap else champion), {\"챔피언\": round(float(a_old), 3), \"도전자\": round(float(a_new), 3), \"교체\": bool(swap)}\n", + "\n", + "\n", + "def sev_safe(model, d):\n", + " try:\n", + " return predict_sev(model, d), \"모델\"\n", + " except Exception:\n", + " return d[\"text\"].map(label_sev).replace(-1, 0).values, \"키워드 규칙 대체\"\n", + "\n", + "\n", + "def cause_rule(d):\n", + " c = d[\"text\"].map(norm).map(label_cause)\n", + " short = (c == \"미상\") & (d[\"vendor\"] == \"외주B\") & (d[\"since_maint\"] <= CONFIG[\"since_short\"])\n", + " return c.where(~short, \"정비 품질\")\n", + "\n", + "\n", + "# ---------------- 1. 통합 실행 ----------------\n", + "t_start = time.perf_counter()\n", + "old, _, _ = make_qdr(36, seed=11, scale=1.5) # 지난달 실적\n", + "new, _, _ = make_qdr(36, seed=1, scale=1.5) # 이번 달 실적\n", + "ops, use, events = make_qdr(24, seed=21, scale=1.5) # 다음 달 운용(신규 QDR)\n", + "ops.iloc[[10, 300], ops.columns.get_loc(\"text\")] = [\"\", \"�� 오류\"] # 손상 서술 2건 주입\n", + "champion = fit_sev(old)\n", + "model, rt = retrain(champion, old, new)\n", + "print(f\"[교육용가상자료] 재학습: {rt} -> {'도전자 교체' if rt['교체'] else '챔피언 유지'}\")\n", + "good, bad = validate(ops)\n", + "good = good.copy()\n", + "good[\"sev_pred\"], src = sev_safe(model, good)\n", + "good[\"cause_pred\"] = cause_rule(good)\n", + "sec = time.perf_counter() - t_start\n", + "acc = (good[\"sev_pred\"] == good[\"sev\"]).mean()\n", + "print(f\"운용 QDR {len(ops)}건: 분석 {len(good)}건, 수동 확인 {len(bad)}건, 치명도 출처 '{src}', 정확도 {acc:.1%}, 전체 실행 {sec:.1f}초\")\n", + "\n", + "# 월별 집계·경보·취약성 지수\n", + "M = good.pivot_table(index=\"part\", columns=\"month\", values=\"qdr_id\", aggfunc=\"size\", fill_value=0).reindex(index=range(NP), columns=range(24), fill_value=0)\n", + "rate = (M / use[:24]).T.rolling(12, min_periods=12).mean().shift(1).T\n", + "exp = rate * use[:24]\n", + "Z = (M - exp) / np.sqrt(exp.clip(lower=1))\n", + "A = (M / exp.clip(lower=1) > CONFIG[\"alarm_ratio\"]) & (Z > CONFIG[\"alarm_z\"])\n", + "W_SEV = np.array([1, 3, 9])\n", + "V = good.assign(v=W_SEV[good[\"sev_pred\"]]).groupby(\"part\")[\"v\"].sum()\n", + "recent = good[good[\"month\"] >= 21].assign(v=lambda d: W_SEV[d[\"sev_pred\"]]).groupby(\"part\")[\"v\"].sum().sort_values(ascending=False)\n", + "\n", + "# 후보 제안(승인 대기): 최근 12개월 근거\n", + "ev = good[good[\"month\"] >= 12]\n", + "cands = []\n", + "for p, g in ev.groupby(\"part\"):\n", + " mf, ds = (g[\"cause_pred\"] == \"제조 불량\").mean(), (g[\"cause_pred\"] == \"설계 취약\").mean()\n", + " if len(g) >= CONFIG[\"min_cases\"] and mf >= CONFIG[\"mfg_share\"]: cands.append((names[p], \"하자 구상\", len(g), mf))\n", + " if len(g) >= CONFIG[\"min_cases\"] and ds >= CONFIG[\"design_share\"]: cands.append((names[p], \"설계 변경\", len(g), ds))\n", + "for v, g in ev.groupby(\"vendor\"):\n", + " sh = (g[\"cause_pred\"] == \"정비 품질\").mean()\n", + " if v != \"부대정비\" and len(g) >= 100 and sh >= CONFIG[\"vendor_share\"]: cands.append((v, \"외주정비 시정\", len(g), sh))\n", + "C = pd.DataFrame(cands, columns=[\"대상\", \"유형\", \"근거 건수\", \"원인 비중\"])\n", + "C[\"status\"] = \"PENDING\" # 품질관 결정 전까지 확정하지 않는다\n", + "\n", + "# ---------------- 2. 자체 점검 ----------------\n", + "_, bad_src = sev_safe(None, good.head(3))\n", + "checks = {\n", + " \"QDR 유실 0건\": len(good) + len(bad) == len(ops),\n", + " \"손상 서술 2건 수동 확인 분리\": len(bad) == 2,\n", + " \"재학습 파이프라인 실행\": \"교체\" in rt,\n", + " \"치명도 정확도 80% 이상\": bool(acc >= 0.80),\n", + " \"모델 장애 시 키워드 규칙 대체\": bad_src == \"키워드 규칙 대체\",\n", + " \"경보 셀 모두 12개월 이후\": not bool(A.iloc[:, :12].values.any()),\n", + " \"후보 제안 1건 이상 생성\": len(C) >= 1,\n", + " \"자동 승인 0건(후보는 대기)\": bool((C[\"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", + "fig, ax = plt.subplots(1, 2, figsize=(11, 4))\n", + "ax[0].imshow((M / exp.clip(lower=1)).clip(upper=3).values, aspect=\"auto\", cmap=\"Reds\"); ax[0].set_title(\"QDR concentration (x expected)\"); ax[0].set_xlabel(\"month\"); ax[0].set_ylabel(\"part\")\n", + "ax[1].bar(range(NP), V.reindex(range(NP)).fillna(0).values); ax[1].set_title(\"Vulnerability index by part\"); ax[1].set_xlabel(\"part id\")\n", + "plt.tight_layout(); plt.show()\n", + "\n", + "fa = [(names[p], int(m)) for p, m in zip(*np.where(A.values))]\n", + "print(\"\\n[품질 담당 장교 브리핑 요약] [교육용가상자료]\")\n", + "print(f\"1. 운용 24개월 QDR {len(ops)}건 중 손상 {len(bad)}건 수동 확인, 치명도 Critical {int((good['sev_pred'] == 2).sum())}건({(good['sev_pred'] == 2).mean():.0%})\")\n", + "print(\"2. 최근 3개월 취약성 지수 상위 부위: \" + \", \".join(f\"{names[p]} {v:.0f}\" for p, v in recent.head(3).items()))\n", + "print(f\"3. 월별 급증 경보 {len(fa)}건\" + (\": \" + \", \".join(f\"{n}({m}월)\" for n, m in fa[:3]) if fa else \"\"))\n", + "print(f\"4. 후보 제안 {len(C)}건은 품질관 승인 대기(자동 승인 0건): \" + \", \".join(f\"{r['대상']} {r['유형']}\" for _, r in C.head(4).iterrows()))\n", + "vb = good[good[\"vendor\"] == \"외주B\"]\n", + "print(f\"5. 외주B 검사 대기 평균 {vb['inspect_wait'].mean():.1f}일(타 수행처 {good.loc[good['vendor'] != '외주B', 'inspect_wait'].mean():.1f}일), 재검사율 {vb['reinspect'].mean():.0%}\")\n", + "print(f\"6. 치명도 모델: {'신규 실적으로 교체' if rt['교체'] else '기존 유지'}(검증 정확도 {max(rt['챔피언'], rt['도전자']):.1%})\")\n", + "print(\"7. 건의: 외주정비 업체 시정 요구와 제조사 하자 구상 근거 정리, 국방 군수 정보체계와 QDR 표준 필드 연동 인터페이스 정의\")\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 10 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + "[교육용가상자료] 재학습: {'챔피언': 0.867, '도전자': 0.865, '교체': True} -> 도전자 교체\n", + "운용 QDR 8923건: 분석 8921건, 수동 확인 2건, 치명도 출처 '모델', 정확도 87.8%, 전체 실행 7.3초\n", + "\n", + "자체 점검 8/8 통과, 자동 재학습 파이프라인 정상 실행\n", + "운영 문서 7개 장 작성률 100% (목표 100%: 달성) - 목적과 범위, 입력 데이터, 일일 운용 절차, 승인(HITL) 기준, 재학습 절차, 장애 대응, 연동 인터페이스\n", + "\n", + "[품질 담당 장교 브리핑 요약] [교육용가상자료]\n", + "1. 운용 24개월 QDR 8923건 중 손상 2건 수동 확인, 치명도 Critical 717건(8%)\n", + "2. 최근 3개월 취약성 지수 상위 부위: 제동 장치 378, 조향 장치 268, 엔진 연료계통 244\n", + "3. 월별 급증 경보 3건: 조향 장치(20월), 냉각 계통(16월), 전기·배터리(18월)\n", + "4. 후보 제안 3건은 품질관 승인 대기(자동 승인 0건): 냉각 계통 하자 구상, 전기·배터리 하자 구상, 외주B 외주정비 시정\n", + "5. 외주B 검사 대기 평균 8.7일(타 수행처 4.5일), 재검사율 26%\n", + "6. 치명도 모델: 신규 실적으로 교체(검증 정확도 86.7%)\n", + "7. 건의: 외주정비 업체 시정 요구와 제조사 하자 구상 근거 정리, 국방 군수 정보체계와 QDR 표준 필드 연동 인터페이스 정의\n", + "```\n", + "
\n" + ] + } + ], + "metadata": { + "colab": { + "provenance": [] + }, + "kernelspec": { + "display_name": "Python 3", + "name": "python3" + }, + "language_info": { + "name": "python" + } + }, + "nbformat": 4, + "nbformat_minor": 0 +} \ No newline at end of file diff --git a/uploads/QLT05/QLT05_student.ipynb b/uploads/QLT05/QLT05_student.ipynb new file mode 100644 index 0000000..0d40bb1 --- /dev/null +++ b/uploads/QLT05/QLT05_student.ipynb @@ -0,0 +1,3399 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# 🎯 [QLT05] 외주정비 품질검사 실적·병목 분석\n", + "### Google Colab 실습 환경 (품질·규격·검사 / Level 2)\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/QLT05/QLT05_solution.ipynb)\n", + "\n", + "---\n", + "\n", + "## 📌 1. 프로젝트 개요 및 배경\n", + "\n", + "| 항목 | 내용 |\n", + "| :--- | :--- |\n", + "| **과정 분류** | 품질·규격·검사 (Level 2) |\n", + "| **학습 소요 시간** | 10개 미션 (5일 / 총 28.5시간) |\n", + "| **환경 사양** | Python 3.10+, numpy, pandas, matplotlib, scipy, scikit-learn |\n", + "\n", + "### 📖 프로젝트 목표\n", + "해외 사례에서 국방 감사기관이 외주정비 검사실적을 분석한 결과, 검사 대기가 길어지는 병목과 정비 직후 재발하는 결함이 품질 문제의 주요 단서로 지적된 바 있다. 야전에서도 장비 결함이 품질결함보고(QDR)의 비정형 서술로 쌓이지만 부위, 치명도, 원인이 사람마다 다르게 적혀 다발 결함과 책임 소재를 가려내기 어렵다. 본 프로젝트는 외주정비 품질검사 실적과 QDR 텍스트를 연결해 병목과 품질 취약점을 데이터로 찾는 것을 목표로, 교육용 가상 QDR와 가상 외주정비 업체 자료를 코드로 만들어 분석한다. QDR 데이터 스키마 정의, 서술 텍스트 정제와 부위·결함 라벨링, 빈도 Baseline과 파레토·상관 분석, 토픽 모델링과 월간 건수 예측, 치명도 기반 취약성 지수, 설명가능한 발생 현황 화면, 품질관 승인(HITL) 기반 하자 구상·설계 변경 시뮬레이터, 10년치 대용량 일괄 분석, 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. 야전 장비 품질결함보고(QDR, Quality Deficiency Report) 데이터 스키마 분석\n", + "**소요시간**: 약 1.5시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "야전 장비 품질결함보고(QDR) 데이터 스키마를 분석한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **야전 장비 품질결함보고(QDR)의 보고 정보, 결함 서술 텍스트, 외주정비·검사 실적, 조치 결과 데이터 스키마와 데이터 딕셔너리, 결함 분석 업무 프로세스 맵을 정의한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **결함 발견부터 QDR 작성, 접수·검토, 검사·판정, 원인 분석, 조치, 실적 집계, 다발 결함 식별, 사후검토까지 업무 흐름을 단계별로 정리하고 보고·텍스트·검사·결과 묶음으로 피처 20개 이상 정의하고 검증하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `docs/qlt05_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": [ + "\"\"\"야전 장비 품질결함보고(QDR) 데이터(보고 정보, 결함 서술 텍스트, 외주정비·검사 실적, 조치 결과)의 스키마·데이터 딕셔너리(피처 20개 이상)와 결함 분석 업무 프로세스 맵을 정의하고, 가상 QDR 표본을 검증하며 오류 주입으로 검출 여부를 확인한다.\"\"\"\n", + "import json\n", + "import re\n", + "import numpy as np\n", + "import pandas as pd\n", + "\n", + "# ---------------------------------------------------------------------------\n", + "# [교육용가상자료] 가상 야전 장비 품질결함보고(QDR) 생성기 (실제 부대·장비·업체·정비 실적 없음)\n", + "# 가상 장비 부위 12종, 결함 유형 8종, 치명도(Critical/Major/Minor), 원인 5종, 가상 외주정비 업체 4곳 + 부대정비.\n", + "# QDR 1건마다 한국어 서술 텍스트를 만들고, 월별 건수는 계절·추세·훈련 강도(usage)·일부 제조 불량 이벤트를 따른다.\n", + "# ---------------------------------------------------------------------------\n", + "PARTS = [ # (이름, 별칭, 월 기준 건수, 계절 진폭, 정점 월, (Critical, Major 비율), 가상 단가지수, 제조사, 주요 결함 가중치)\n", + " (\"엔진 연료계통\", [\"연료계통\", \"연료 라인\", \"연료 펌프\"], 26, 0.15, 7, (0.10, 0.35), 6.0, \"MK1\", \"누유:4 마모:2 체결불량:2 오작동:2\"),\n", + " (\"변속기\", [\"변속기\", \"T/M\", \"미션\"], 22, 0.10, 8, (0.12, 0.38), 9.0, \"MK1\", \"마모:4 누유:2 과열:3 오작동:1\"),\n", + " (\"제동 장치\", [\"제동 장치\", \"브레이크\", \"제동 라인\"], 20, 0.20, 12, (0.26, 0.40), 5.0, \"MK2\", \"마모:4 균열:2 누유:2 체결불량:2\"),\n", + " (\"조향 장치\", [\"조향 장치\", \"핸들 연동부\", \"조향 로드\"], 14, 0.10, 6, (0.30, 0.40), 5.0, \"MK2\", \"체결불량:4 균열:2 마모:3 오작동:1\"),\n", + " (\"냉각 계통\", [\"냉각 계통\", \"라디에이터\", \"냉각수 라인\"], 24, 0.30, 7, (0.05, 0.30), 3.0, \"MK3\", \"누유:4 과열:4 부식:2\"),\n", + " (\"전기·배터리\", [\"배터리\", \"전기 계통\", \"발전기\"], 28, 0.35, 1, (0.04, 0.28), 2.5, \"MK3\", \"접촉불량:4 부식:2 과열:2 오작동:2\"),\n", + " (\"통신 모듈\", [\"통신 모듈\", \"무전기 연동부\", \"안테나 접속부\"], 16, 0.05, 4, (0.03, 0.25), 4.0, \"MK4\", \"접촉불량:5 오작동:4 부식:1\"),\n", + " (\"유압 계통\", [\"유압 계통\", \"유압 호스\", \"유압 실린더\"], 18, 0.10, 9, (0.10, 0.35), 4.5, \"MK2\", \"누유:5 균열:2 마모:2 체결불량:1\"),\n", + " (\"현가 장치\", [\"현가 장치\", \"서스펜션\", \"완충기\"], 15, 0.08, 5, (0.06, 0.30), 3.5, \"MK4\", \"마모:4 균열:3 체결불량:2 부식:1\"),\n", + " (\"차체 용접부\", [\"차체 용접부\", \"프레임 용접부\", \"차체 이음부\"], 10, 0.05, 10, (0.28, 0.40), 7.0, \"MK1\", \"균열:6 부식:3 체결불량:1\"),\n", + " (\"광학·조준 장비\", [\"광학 장비\", \"조준경\", \"관측 장비\"], 8, 0.05, 3, (0.03, 0.22), 8.0, \"MK4\", \"오작동:4 접촉불량:3 부식:2 체결불량:1\"),\n", + " (\"공기 정화 필터\", [\"공기 필터\", \"흡기 필터\", \"정화 장치\"], 12, 0.25, 4, (0.01, 0.12), 1.0, \"MK3\", \"마모:5 부식:2 체결불량:3\")]\n", + "NP = len(PARTS)\n", + "DEFECTS = { # 결함 유형: 증상 표현(동의어)\n", + " \"균열\": [\"균열이 발견됨\", \"크랙이 확인됨\", \"갈라짐이 있음\"], \"누유\": [\"누유가 발생함\", \"오일이 새어 나옴\", \"누설 흔적이 있음\"],\n", + " \"마모\": [\"마모가 심함\", \"과도한 마모가 확인됨\", \"닳은 흔적이 있음\"], \"부식\": [\"부식이 진행됨\", \"녹이 발생함\", \"산화 흔적이 있음\"],\n", + " \"접촉불량\": [\"접촉 불량이 발생함\", \"커넥터 단선이 의심됨\", \"신호가 끊김\"], \"체결불량\": [\"볼트 풀림이 확인됨\", \"체결 상태가 불량함\", \"고정부가 헐거움\"],\n", + " \"과열\": [\"과열 경고가 발생함\", \"온도가 과도하게 상승함\", \"열이 심하게 남\"], \"오작동\": [\"오작동이 반복됨\", \"작동이 불안정함\", \"간헐적으로 멈춤\"]}\n", + "DNAMES = list(DEFECTS)\n", + "SEV = [\"Minor\", \"Major\", \"Critical\"]\n", + "SEV_CUE = {0: [\"경미한 수준임\", \"운용에는 지장이 없음\", \"육안 점검상 사소한 이상임\", \"기능에는 영향이 없음\"],\n", + " 1: [\"성능 저하가 있음\", \"조속한 정비가 필요함\", \"출력이 떨어지는 상태임\", \"기능이 부분적으로 제한됨\"],\n", + " 2: [\"즉시 운용 중지함\", \"안전에 위험이 있어 사용 불가\", \"작동 정지로 임무 수행이 어려움\", \"인명 안전 위협이 우려됨\"]}\n", + "CAUSES = [\"제조 불량\", \"정비 품질\", \"운용 부주의\", \"설계 취약\", \"자연 노후\"]\n", + "CAUSE_CUE = {\n", + " \"제조 불량\": [\"신품 인수 직후 발생함\", \"출고 상태에서 이미 이상이 있었음\", \"인수 후 얼마 지나지 않아 발생함\"],\n", + " \"정비 품질\": [\"외주 정비 후 얼마 되지 않아 재발함\", \"정비 후 재조립 상태가 불량함\", \"정비 직후 같은 부위에서 다시 발생함\"],\n", + " \"운용 부주의\": [\"험지 기동 중 충격 이후 발생함\", \"과부하 운용 후 발생함\", \"점검 없이 연속 운용한 뒤 발생함\"],\n", + " \"설계 취약\": [\"동일 증상이 다른 차량에서도 반복됨\", \"구조적으로 취약한 부위로 보임\", \"여러 부대에서 같은 형태로 보고됨\"],\n", + " \"자연 노후\": [\"사용 연한이 도래함\", \"장기간 사용으로 노후됨\", \"수명 주기 말기에 발생함\"]}\n", + "VENDORS = [\"외주A\", \"외주B\", \"외주C\", \"외주D\", \"부대정비\"]\n", + "ACTIONS = [\"정비소 입고를 요청함\", \"현장 임시 조치 후 운용 중임\", \"부품 교환을 요청함\", \"재검사를 요청함\", \"원인 확인이 필요함\"]\n", + "MAKER_OF = [p[7] for p in PARTS]\n", + "PRICE = np.array([p[6] for p in PARTS])\n", + "\n", + "\n", + "def usage_index(months, seed=0):\n", + " \"\"\"월별 훈련 강도(미리 알 수 있는 계획 값): 연 1회 정점을 갖는 계절 + 작은 잡음\"\"\"\n", + " # [TODO : 학생 실습 1] 야전 장비 품질결함보고(QDR, Quality Deficiency Report) 데이터 스키마 분석 관련 핵심 로직을 구현하세요.\n", + " # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n", + " # <<< 여기에 코드를 작성하세요 >>>\n", + " pass # 작성 후 pass를 제거하세요.\n", + "def make_qdr(months=36, seed=1, scale=1.0, surge=True):\n", + " \"\"\"반환: QDR DataFrame(건당 1행), 월별 usage, 급증 이벤트 목록\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " use = usage_index(months, seed)\n", + " events = []\n", + " if surge:\n", + " for _ in range(max(2, months // 12 * 2)):\n", + " events.append((int(rng.integers(NP)), int(rng.integers(6, months))))\n", + " rows = []\n", + " wdef = [np.array([float(x.split(\":\")[1]) for x in p[8].split()]) for p in PARTS]\n", + " ddef = [[x.split(\":\")[0] for x in p[8].split()] for p in PARTS]\n", + " n = 0\n", + " for m in range(months):\n", + " for pi, p in enumerate(PARTS):\n", + " lam = p[2] * scale * (1 + 0.010 * m) * use[m] * (1 + p[3] * np.cos(2 * np.pi * (m % 12 - p[4]) / 12))\n", + " ev = (pi, m) in events\n", + " k = rng.poisson(lam * (2.6 if ev else 1.0))\n", + " for _ in range(k):\n", + " rows.append((n, m, pi, ev)); n += 1\n", + " N = len(rows)\n", + " A = np.array(rows)\n", + " month, part, ev = A[:, 1], A[:, 2], A[:, 3].astype(bool)\n", + " defect = np.array([rng.choice(ddef[i], p=wdef[i] / wdef[i].sum()) for i in part])\n", + " cr = np.array([PARTS[i][5][0] for i in part]); ma = np.array([PARTS[i][5][1] for i in part])\n", + " u = rng.random(N)\n", + " sev = np.where(u < cr, 2, np.where(u < cr + ma, 1, 0))\n", + " vendor = rng.choice(VENDORS, N, p=[0.25, 0.2, 0.2, 0.15, 0.2])\n", + " # 원인: 기본 비율에 부위(제조사)·업체·이벤트 효과를 곱한다\n", + " base = np.array([0.22, 0.20, 0.20, 0.16, 0.22])\n", + " P = np.tile(base, (N, 1))\n", + " P[vendor == \"외주B\", 1] *= 2.6\n", + " P[ev, 0] *= 5.0\n", + " design = np.isin(part, [2, 3, 7]); P[design, 3] *= 2.2\n", + " P[np.isin(part, [4, 5, 11]), 0] *= 2.0 # 제조사 MK3 납품 부위는 제조 불량 비중이 높다\n", + " P[defect == \"마모\", 4] *= 1.6; P[defect == \"누유\", 4] *= 1.3\n", + " P /= P.sum(axis=1, keepdims=True)\n", + " cause = np.array([CAUSES[rng.choice(5, p=P[i])] for i in range(N)])\n", + " since = np.where(cause == \"정비 품질\", rng.exponential(35, N), rng.exponential(160, N)).clip(1, 720).round().astype(int)\n", + " wait = (rng.lognormal(1.3, 0.5, N) * np.where(vendor == \"외주B\", 1.7, np.where(vendor == \"부대정비\", 0.6, 1.0)) * (0.7 + 0.5 * use[month])).round(1)\n", + " reins = rng.random(N) < np.where(cause == \"정비 품질\", 0.6, 0.08)\n", + " texts = []\n", + " for i in range(N):\n", + " pt = PARTS[part[i]]\n", + " cl = [f\"{rng.choice(pt[1])}에서 {rng.choice(DEFECTS[defect[i]])}\"]\n", + " if rng.random() < 0.7: cl.append(rng.choice(SEV_CUE[sev[i]]))\n", + " if rng.random() < 0.65: # 원인 단서: 8%는 실제 원인과 다른 단서가 적힌다(보고자의 추정)\n", + " cc = cause[i] if rng.random() > 0.08 else rng.choice(CAUSES)\n", + " cl.append(rng.choice(CAUSE_CUE[cc]))\n", + " cl.append(rng.choice(ACTIONS))\n", + " if rng.random() < 0.5: cl.insert(0, f\"VU-{rng.integers(1, 31):02d} 소속\")\n", + " texts.append(\". \".join(cl) + \".\")\n", + " df = pd.DataFrame({\"qdr_id\": [f\"Q{seed}{i:06d}\" for i in range(N)], \"month\": month, \"unit\": [f\"VU-{x:02d}\" for x in rng.integers(1, 31, N)],\n", + " \"part\": part, \"defect\": defect, \"sev\": sev, \"cause\": cause, \"vendor\": vendor, \"maker\": [MAKER_OF[i] for i in part],\n", + " \"since_maint\": since, \"inspect_wait\": wait, \"reinspect\": reins, \"event\": ev, \"text\": texts})\n", + " df[\"cost\"] = PRICE[df[\"part\"]] * np.array([1.0, 2.0, 4.0])[df[\"sev\"]] # [교육용가상자료] 수리·조치 비용지수\n", + " return df, use, events\n", + "\n", + "# 1) 업무 프로세스 맵: 결함 발생부터 원인 환류까지 (각 단계에서 생기는 데이터)\n", + "PROCESS = [(\"결함 발견\", \"발견 부대, 장비, 부위, 서술 텍스트\"), (\"QDR 작성\", \"QDR ID, 작성일, 치명도(Critical/Major/Minor)\"),\n", + " (\"접수·검토\", \"텍스트 정제, 부위·결함 유형 라벨\"), (\"검사·판정\", \"검사 대기일, 재검사 여부, 외주정비 업체\"),\n", + " (\"원인 분석\", \"원인 구분(제조·정비·운용·설계·노후), 제조사\"), (\"조치\", \"수리·교환·하자 구상 여부, 조치 비용\"),\n", + " (\"실적 집계\", \"부위별·월별 건수, 취약성 지수\"), (\"다발 결함 식별\", \"파레토(ABC), 이상 신호\"), (\"사후검토(AAR)\", \"원인 오분류 사례, 룰 조정 이력\")]\n", + "\n", + "# 2) 스키마(데이터 딕셔너리): 그룹 / 필드 / 자료형 / 규칙 / 설명\n", + "PART_NAMES = [p[0] for p in PARTS]\n", + "S = {\n", + " \"report\": {\n", + " \"qdr_id\": (str, {\"pattern\": r\"^Q\\d{7,8}$\"}, \"QDR ID\"),\n", + " \"month\": (int, {\"min\": 0, \"max\": 143}, \"보고 월(모의 시작 기준 경과 월)\"),\n", + " \"unit\": (str, {\"pattern\": r\"^VU-\\d{2}$\"}, \"보고 부대 코드(가상)\"),\n", + " \"equip_code\": (str, {\"pattern\": r\"^VX-\\d{4}-\\d{4}$\"}, \"장비 식별코드(국방 표준 코드 형식을 모사한 가상 코드)\"),\n", + " \"fsc_group\": (str, {\"pattern\": r\"^\\d{4}$\"}, \"보급 분류군(가상 4자리)\"),\n", + " \"part\": (str, {\"enum\": PART_NAMES}, \"결함 부위(장비 계통)\"),\n", + " \"severity\": (str, {\"enum\": SEV}, \"결함 치명도\"),\n", + " },\n", + " \"text\": {\n", + " \"text\": (str, {\"min_len\": 5}, \"결함 서술 텍스트(비정형)\"),\n", + " \"defect\": (str, {\"enum\": DNAMES + [\"미분류\"]}, \"결함 유형 라벨\"),\n", + " \"n_tokens\": (int, {\"min\": 1, \"max\": 200}, \"정제 후 토큰 수\"),\n", + " \"part_label\": (str, {\"enum\": PART_NAMES + [\"미분류\"]}, \"서술에서 추출한 부위 라벨\"),\n", + " },\n", + " \"inspection\": {\n", + " \"vendor\": (str, {\"enum\": VENDORS}, \"직전 정비 수행처(외주 업체 또는 부대정비)\"),\n", + " \"since_maint\": (int, {\"min\": 0, \"max\": 1500}, \"직전 정비 후 경과일\"),\n", + " \"inspect_wait\": (float, {\"min\": 0, \"max\": 60}, \"검사 대기일(검사 병목 지표)\"),\n", + " \"reinspect\": (bool, {}, \"재검사 여부\"),\n", + " },\n", + " \"result\": {\n", + " \"cause\": (str, {\"enum\": CAUSES}, \"원인 구분 - 예측대상 1\"),\n", + " \"maker\": (str, {\"pattern\": r\"^MK\\d$\"}, \"제조사 코드(가상)\"),\n", + " \"cost\": (float, {\"min\": 0, \"max\": 100}, \"조치 비용지수 - 예측대상 2\"),\n", + " \"monthly_count\": (int, {\"min\": 0, \"max\": 500}, \"부위별 월간 QDR 건수 - 예측대상 3\"),\n", + " \"vuln_index\": (float, {\"min\": 0, \"max\": 1000}, \"치명도 가중 취약성 지수 - 예측대상 4\"),\n", + " \"event_flag\": (bool, {}, \"제조 LOT 불량 등 급증 이벤트 월 여부\"),\n", + " \"hitl_status\": (str, {\"enum\": [\"PENDING\", \"APPROVED\", \"REJECTED\", \"NO_RESPONSE\"]}, \"품질관 검토 상태\"),\n", + " },\n", + "}\n", + "\n", + "\n", + "def check(name, v, typ, rule):\n", + " # [TODO : 학생 실습 1] 야전 장비 품질결함보고(QDR, Quality Deficiency Report) 데이터 스키마 분석 관련 핵심 로직을 구현하세요.\n", + " # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n", + " # <<< 여기에 코드를 작성하세요 >>>\n", + " pass # 작성 후 pass를 제거하세요.\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", + "df, use, events = make_qdr(36, seed=1)\n", + "rng = np.random.default_rng(7)\n", + "mc = df.groupby([\"part\", \"month\"]).size()\n", + "recs = []\n", + "for r in df.sample(20, random_state=1).itertuples():\n", + " t = r.text\n", + " recs.append({\n", + " \"report\": {\"qdr_id\": r.qdr_id, \"month\": int(r.month), \"unit\": r.unit, \"equip_code\": f\"VX-{rng.integers(1000, 9999)}-{rng.integers(1000, 9999)}\",\n", + " \"fsc_group\": f\"{rng.integers(1000, 9999)}\", \"part\": PART_NAMES[r.part], \"severity\": SEV[r.sev]},\n", + " \"text\": {\"text\": t, \"defect\": r.defect, \"n_tokens\": max(len(t.split()), 1), \"part_label\": PART_NAMES[r.part]},\n", + " \"inspection\": {\"vendor\": r.vendor, \"since_maint\": int(r.since_maint), \"inspect_wait\": float(r.inspect_wait), \"reinspect\": bool(r.reinspect)},\n", + " \"result\": {\"cause\": r.cause, \"maker\": r.maker, \"cost\": float(r.cost), \"monthly_count\": int(mc[(r.part, r.month)]),\n", + " \"vuln_index\": float(sum(np.array([1, 3, 9])[df.loc[(df.part == r.part) & (df.month == r.month), \"sev\"]])), \"event_flag\": bool(r.event), \"hitl_status\": \"PENDING\"}})\n", + "ok = sum(not validate(r) for r in recs)\n", + "print(f\"[교육용가상자료] 가상 QDR {len(df)}건 생성, 표본 20건 중 검증 통과 {ok}건, 오류 {len(recs) - ok}건\")\n", + "print(f\"전체 데이터 부위·치명도·원인 허용값 검증: {'통과' if df['part'].between(0, NP - 1).all() and df['sev'].isin([0, 1, 2]).all() and df['cause'].isin(CAUSES).all() else '실패'}\")\n", + "\n", + "# 4) 오류 주입 시험\n", + "bad = json.loads(json.dumps(recs[0]))\n", + "bad[\"report\"][\"qdr_id\"] = \"QDR-7\" # ID 형식 오류\n", + "bad[\"report\"][\"severity\"] = \"Severe\" # 허용되지 않은 치명도\n", + "bad[\"text\"][\"text\"] = \"고장\" # 서술 너무 짧음\n", + "del bad[\"inspection\"][\"vendor\"] # 필수 필드 누락\n", + "bad[\"inspection\"][\"inspect_wait\"] = \"3일\" # 자료형 오류\n", + "bad[\"result\"][\"cost\"] = 250.0 # 범위 초과\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)[:34] 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" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 01 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + "[교육용가상자료] 가상 QDR 9221건 생성, 표본 20건 중 검증 통과 20건, 오류 0건\n", + "전체 데이터 부위·치명도·원인 허용값 검증: 통과\n", + "\n", + "[오류 주입 결과]\n", + " - report.qdr_id: 형식 불일치 'QDR-7'\n", + " - report.severity: 허용되지 않은 값 'Severe'\n", + " - text.text: 서술이 너무 짧음\n", + " - inspection.vendor: 필수 필드 누락\n", + " - inspection.inspect_wait: 자료형 오류(str)\n", + " - result.cost: 최대값 100 초과\n", + "\n", + "[업무 프로세스 맵]\n", + "1.결함 발견 -> 2.QDR 작성 -> 3.접수·검토 -> 4.검사·판정 -> 5.원인 분석 -> 6.조치 -> 7.실적 집계 -> 8.다발 결함 식별 -> 9.사후검토(AAR)\n", + " 결함 발견 : 발견 부대, 장비, 부위, 서술 텍스트\n", + " QDR 작성 : QDR ID, 작성일, 치명도(Critical/Major/Minor)\n", + " 접수·검토 : 텍스트 정제, 부위·결함 유형 라벨\n", + " 검사·판정 : 검사 대기일, 재검사 여부, 외주정비 업체\n", + " 원인 분석 : 원인 구분(제조·정비·운용·설계·노후), 제조사\n", + " 조치 : 수리·교환·하자 구상 여부, 조치 비용\n", + " 실적 집계 : 부위별·월별 건수, 취약성 지수\n", + " 다발 결함 식별 : 파레토(ABC), 이상 신호\n", + " 사후검토(AAR) : 원인 오분류 사례, 룰 조정 이력\n", + "\n", + "[데이터 딕셔너리] 피처 22개 (목표 20개 이상: 달성)\n", + " 그룹 필드 자료형 설명\n", + " report qdr_id str QDR ID\n", + " report month int 보고 월(모의 시작 기준 경과 월)\n", + " report unit str 보고 부대 코드(가상)\n", + " report equip_code str 장비 식별코드(국방 표준 코드 형식을 모사한 가상 코드)\n", + " report fsc_group str 보급 분류군(가상 4자리)\n", + " report part str 결함 부위(장비 계통)\n", + " report severity str 결함 치명도\n", + " text text str 결함 서술 텍스트(비정형)\n", + " text defect str 결함 유형 라벨\n", + " text n_tokens int 정제 후 토큰 수\n", + " text part_label str 서술에서 추출한 부위 라벨\n", + "inspection vendor str 직전 정비 수행처(외주 업체 또는 부대정비)\n", + "inspection since_maint int 직전 정비 후 경과일\n", + "inspection inspect_wait float 검사 대기일(검사 병목 지표)\n", + "inspection reinspect bool 재검사 여부\n", + "... (이하 생략)\n", + "```\n", + "
\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 02. 비정형 결함 서술 텍스트 정제, 장비 부위별/결함 유형별 라벨링 전처리 구현\n", + "**소요시간**: 약 2.0시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "비정형 결함 서술 텍스트 정제 및 부위별/유형별 라벨링 전처리 모듈을 구현한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **비정형 결함 서술 텍스트를 정제하고 장비 부위별·결함 유형별로 라벨링하는 전처리 모듈을 구현한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **중복 QDR 제거, 부대 코드와 일자 표기 통일, 전각·특수문자·공백 정규화, 약어(T/M 등) 확장, 사전 기반 오타 교정, 음수 대기일 결측 처리, KNN 결측 보정, 부위·결함 유형 키워드 라벨링 등 정제 규칙 10종을 적용 수행하고 검증하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `src/etl/qlt05_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": [ + "\"\"\"표기 불일치·중복·전각·특수문자·약어·오타·불가능값·결측이 섞인 가상 QDR 원천 자료에 정제 규칙 10종(텍스트 정규화, 약어 확장, 오타 교정, KNN 결측 보정, 부위·결함 유형 키워드 라벨링 포함)을 적용하고, 긴급 보고는 보존하며 정합성 검증 통과율과 라벨링 정확도, 정제 전후 통계를 확인한다.\"\"\"\n", + "import re\n", + "import unicodedata\n", + "import numpy as np\n", + "import pandas as pd\n", + "from sklearn.impute import KNNImputer\n", + "\n", + "# ---------------------------------------------------------------------------\n", + "# [교육용가상자료] 가상 야전 장비 품질결함보고(QDR) 생성기 (실제 부대·장비·업체·정비 실적 없음)\n", + "# 가상 장비 부위 12종, 결함 유형 8종, 치명도(Critical/Major/Minor), 원인 5종, 가상 외주정비 업체 4곳 + 부대정비.\n", + "# QDR 1건마다 한국어 서술 텍스트를 만들고, 월별 건수는 계절·추세·훈련 강도(usage)·일부 제조 불량 이벤트를 따른다.\n", + "# ---------------------------------------------------------------------------\n", + "PARTS = [ # (이름, 별칭, 월 기준 건수, 계절 진폭, 정점 월, (Critical, Major 비율), 가상 단가지수, 제조사, 주요 결함 가중치)\n", + " (\"엔진 연료계통\", [\"연료계통\", \"연료 라인\", \"연료 펌프\"], 26, 0.15, 7, (0.10, 0.35), 6.0, \"MK1\", \"누유:4 마모:2 체결불량:2 오작동:2\"),\n", + " (\"변속기\", [\"변속기\", \"T/M\", \"미션\"], 22, 0.10, 8, (0.12, 0.38), 9.0, \"MK1\", \"마모:4 누유:2 과열:3 오작동:1\"),\n", + " (\"제동 장치\", [\"제동 장치\", \"브레이크\", \"제동 라인\"], 20, 0.20, 12, (0.26, 0.40), 5.0, \"MK2\", \"마모:4 균열:2 누유:2 체결불량:2\"),\n", + " (\"조향 장치\", [\"조향 장치\", \"핸들 연동부\", \"조향 로드\"], 14, 0.10, 6, (0.30, 0.40), 5.0, \"MK2\", \"체결불량:4 균열:2 마모:3 오작동:1\"),\n", + " (\"냉각 계통\", [\"냉각 계통\", \"라디에이터\", \"냉각수 라인\"], 24, 0.30, 7, (0.05, 0.30), 3.0, \"MK3\", \"누유:4 과열:4 부식:2\"),\n", + " (\"전기·배터리\", [\"배터리\", \"전기 계통\", \"발전기\"], 28, 0.35, 1, (0.04, 0.28), 2.5, \"MK3\", \"접촉불량:4 부식:2 과열:2 오작동:2\"),\n", + " (\"통신 모듈\", [\"통신 모듈\", \"무전기 연동부\", \"안테나 접속부\"], 16, 0.05, 4, (0.03, 0.25), 4.0, \"MK4\", \"접촉불량:5 오작동:4 부식:1\"),\n", + " (\"유압 계통\", [\"유압 계통\", \"유압 호스\", \"유압 실린더\"], 18, 0.10, 9, (0.10, 0.35), 4.5, \"MK2\", \"누유:5 균열:2 마모:2 체결불량:1\"),\n", + " (\"현가 장치\", [\"현가 장치\", \"서스펜션\", \"완충기\"], 15, 0.08, 5, (0.06, 0.30), 3.5, \"MK4\", \"마모:4 균열:3 체결불량:2 부식:1\"),\n", + " (\"차체 용접부\", [\"차체 용접부\", \"프레임 용접부\", \"차체 이음부\"], 10, 0.05, 10, (0.28, 0.40), 7.0, \"MK1\", \"균열:6 부식:3 체결불량:1\"),\n", + " (\"광학·조준 장비\", [\"광학 장비\", \"조준경\", \"관측 장비\"], 8, 0.05, 3, (0.03, 0.22), 8.0, \"MK4\", \"오작동:4 접촉불량:3 부식:2 체결불량:1\"),\n", + " (\"공기 정화 필터\", [\"공기 필터\", \"흡기 필터\", \"정화 장치\"], 12, 0.25, 4, (0.01, 0.12), 1.0, \"MK3\", \"마모:5 부식:2 체결불량:3\")]\n", + "NP = len(PARTS)\n", + "DEFECTS = { # 결함 유형: 증상 표현(동의어)\n", + " \"균열\": [\"균열이 발견됨\", \"크랙이 확인됨\", \"갈라짐이 있음\"], \"누유\": [\"누유가 발생함\", \"오일이 새어 나옴\", \"누설 흔적이 있음\"],\n", + " \"마모\": [\"마모가 심함\", \"과도한 마모가 확인됨\", \"닳은 흔적이 있음\"], \"부식\": [\"부식이 진행됨\", \"녹이 발생함\", \"산화 흔적이 있음\"],\n", + " \"접촉불량\": [\"접촉 불량이 발생함\", \"커넥터 단선이 의심됨\", \"신호가 끊김\"], \"체결불량\": [\"볼트 풀림이 확인됨\", \"체결 상태가 불량함\", \"고정부가 헐거움\"],\n", + " \"과열\": [\"과열 경고가 발생함\", \"온도가 과도하게 상승함\", \"열이 심하게 남\"], \"오작동\": [\"오작동이 반복됨\", \"작동이 불안정함\", \"간헐적으로 멈춤\"]}\n", + "DNAMES = list(DEFECTS)\n", + "SEV = [\"Minor\", \"Major\", \"Critical\"]\n", + "SEV_CUE = {0: [\"경미한 수준임\", \"운용에는 지장이 없음\", \"육안 점검상 사소한 이상임\", \"기능에는 영향이 없음\"],\n", + " 1: [\"성능 저하가 있음\", \"조속한 정비가 필요함\", \"출력이 떨어지는 상태임\", \"기능이 부분적으로 제한됨\"],\n", + " 2: [\"즉시 운용 중지함\", \"안전에 위험이 있어 사용 불가\", \"작동 정지로 임무 수행이 어려움\", \"인명 안전 위협이 우려됨\"]}\n", + "CAUSES = [\"제조 불량\", \"정비 품질\", \"운용 부주의\", \"설계 취약\", \"자연 노후\"]\n", + "CAUSE_CUE = {\n", + " \"제조 불량\": [\"신품 인수 직후 발생함\", \"출고 상태에서 이미 이상이 있었음\", \"인수 후 얼마 지나지 않아 발생함\"],\n", + " \"정비 품질\": [\"외주 정비 후 얼마 되지 않아 재발함\", \"정비 후 재조립 상태가 불량함\", \"정비 직후 같은 부위에서 다시 발생함\"],\n", + " \"운용 부주의\": [\"험지 기동 중 충격 이후 발생함\", \"과부하 운용 후 발생함\", \"점검 없이 연속 운용한 뒤 발생함\"],\n", + " \"설계 취약\": [\"동일 증상이 다른 차량에서도 반복됨\", \"구조적으로 취약한 부위로 보임\", \"여러 부대에서 같은 형태로 보고됨\"],\n", + " \"자연 노후\": [\"사용 연한이 도래함\", \"장기간 사용으로 노후됨\", \"수명 주기 말기에 발생함\"]}\n", + "VENDORS = [\"외주A\", \"외주B\", \"외주C\", \"외주D\", \"부대정비\"]\n", + "ACTIONS = [\"정비소 입고를 요청함\", \"현장 임시 조치 후 운용 중임\", \"부품 교환을 요청함\", \"재검사를 요청함\", \"원인 확인이 필요함\"]\n", + "MAKER_OF = [p[7] for p in PARTS]\n", + "PRICE = np.array([p[6] for p in PARTS])\n", + "\n", + "\n", + "def usage_index(months, seed=0):\n", + " \"\"\"월별 훈련 강도(미리 알 수 있는 계획 값): 연 1회 정점을 갖는 계절 + 작은 잡음\"\"\"\n", + " # [TODO : 학생 실습 2] 비정형 결함 서술 텍스트 정제, 장비 부위별/결함 유형별 라벨링 전처리 구현 관련 핵심 로직을 구현하세요.\n", + " # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n", + " # <<< 여기에 코드를 작성하세요 >>>\n", + " pass # 작성 후 pass를 제거하세요.\n", + "def make_qdr(months=36, seed=1, scale=1.0, surge=True):\n", + " \"\"\"반환: QDR DataFrame(건당 1행), 월별 usage, 급증 이벤트 목록\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " use = usage_index(months, seed)\n", + " events = []\n", + " if surge:\n", + " for _ in range(max(2, months // 12 * 2)):\n", + " events.append((int(rng.integers(NP)), int(rng.integers(6, months))))\n", + " rows = []\n", + " wdef = [np.array([float(x.split(\":\")[1]) for x in p[8].split()]) for p in PARTS]\n", + " ddef = [[x.split(\":\")[0] for x in p[8].split()] for p in PARTS]\n", + " n = 0\n", + " for m in range(months):\n", + " for pi, p in enumerate(PARTS):\n", + " lam = p[2] * scale * (1 + 0.010 * m) * use[m] * (1 + p[3] * np.cos(2 * np.pi * (m % 12 - p[4]) / 12))\n", + " ev = (pi, m) in events\n", + " k = rng.poisson(lam * (2.6 if ev else 1.0))\n", + " for _ in range(k):\n", + " rows.append((n, m, pi, ev)); n += 1\n", + " N = len(rows)\n", + " A = np.array(rows)\n", + " month, part, ev = A[:, 1], A[:, 2], A[:, 3].astype(bool)\n", + " defect = np.array([rng.choice(ddef[i], p=wdef[i] / wdef[i].sum()) for i in part])\n", + " cr = np.array([PARTS[i][5][0] for i in part]); ma = np.array([PARTS[i][5][1] for i in part])\n", + " u = rng.random(N)\n", + " sev = np.where(u < cr, 2, np.where(u < cr + ma, 1, 0))\n", + " vendor = rng.choice(VENDORS, N, p=[0.25, 0.2, 0.2, 0.15, 0.2])\n", + " # 원인: 기본 비율에 부위(제조사)·업체·이벤트 효과를 곱한다\n", + " base = np.array([0.22, 0.20, 0.20, 0.16, 0.22])\n", + " P = np.tile(base, (N, 1))\n", + " P[vendor == \"외주B\", 1] *= 2.6\n", + " P[ev, 0] *= 5.0\n", + " design = np.isin(part, [2, 3, 7]); P[design, 3] *= 2.2\n", + " P[np.isin(part, [4, 5, 11]), 0] *= 2.0 # 제조사 MK3 납품 부위는 제조 불량 비중이 높다\n", + " P[defect == \"마모\", 4] *= 1.6; P[defect == \"누유\", 4] *= 1.3\n", + " P /= P.sum(axis=1, keepdims=True)\n", + " cause = np.array([CAUSES[rng.choice(5, p=P[i])] for i in range(N)])\n", + " since = np.where(cause == \"정비 품질\", rng.exponential(35, N), rng.exponential(160, N)).clip(1, 720).round().astype(int)\n", + " wait = (rng.lognormal(1.3, 0.5, N) * np.where(vendor == \"외주B\", 1.7, np.where(vendor == \"부대정비\", 0.6, 1.0)) * (0.7 + 0.5 * use[month])).round(1)\n", + " reins = rng.random(N) < np.where(cause == \"정비 품질\", 0.6, 0.08)\n", + " texts = []\n", + " for i in range(N):\n", + " pt = PARTS[part[i]]\n", + " cl = [f\"{rng.choice(pt[1])}에서 {rng.choice(DEFECTS[defect[i]])}\"]\n", + " if rng.random() < 0.7: cl.append(rng.choice(SEV_CUE[sev[i]]))\n", + " if rng.random() < 0.65: # 원인 단서: 8%는 실제 원인과 다른 단서가 적힌다(보고자의 추정)\n", + " cc = cause[i] if rng.random() > 0.08 else rng.choice(CAUSES)\n", + " cl.append(rng.choice(CAUSE_CUE[cc]))\n", + " cl.append(rng.choice(ACTIONS))\n", + " if rng.random() < 0.5: cl.insert(0, f\"VU-{rng.integers(1, 31):02d} 소속\")\n", + " texts.append(\". \".join(cl) + \".\")\n", + " df = pd.DataFrame({\"qdr_id\": [f\"Q{seed}{i:06d}\" for i in range(N)], \"month\": month, \"unit\": [f\"VU-{x:02d}\" for x in rng.integers(1, 31, N)],\n", + " \"part\": part, \"defect\": defect, \"sev\": sev, \"cause\": cause, \"vendor\": vendor, \"maker\": [MAKER_OF[i] for i in part],\n", + " \"since_maint\": since, \"inspect_wait\": wait, \"reinspect\": reins, \"event\": ev, \"text\": texts})\n", + " df[\"cost\"] = PRICE[df[\"part\"]] * np.array([1.0, 2.0, 4.0])[df[\"sev\"]] # [교육용가상자료] 수리·조치 비용지수\n", + " return df, use, events\n", + "\n", + "# ---------------------------------------------------------------------------\n", + "# 공통 텍스트 조각: 정규화, 간이 토큰화(조사 제거), 부위·결함·치명도·원인 키워드 라벨링\n", + "# ---------------------------------------------------------------------------\n", + "TYPO = {\"누윳\": \"누유\", \"균렬\": \"균열\", \"접축 불량\": \"접촉 불량\", \"부씩\": \"부식\", \"마묘\": \"마모\", \"과렬\": \"과열\", \"오작똥\": \"오작동\", \"재동\": \"제동\"}\n", + "ABBR = {\"T/M\": \"변속기\", \"BAT\": \"배터리\"}\n", + "PART_KW = sorted([(a, i) for i, p in enumerate(PARTS) for a in p[1]], key=lambda x: -len(x[0]))\n", + "DEF_KW = {\"균열\": [\"균열\", \"크랙\", \"갈라\"], \"누유\": [\"누유\", \"새어\", \"누설\"], \"마모\": [\"마모\", \"닳은\"], \"부식\": [\"부식\", \"녹이\", \"산화\"],\n", + " \"접촉불량\": [\"접촉 불량\", \"단선\", \"끊김\"], \"체결불량\": [\"볼트\", \"체결\", \"헐거\"], \"과열\": [\"과열\", \"온도\", \"열이\"], \"오작동\": [\"오작동\", \"불안정\", \"멈춤\"]}\n", + "SEV_KW = [(2, [\"즉시 운용 중지\", \"사용 불가\", \"위험\"]), (1, [\"성능 저하\", \"조속한\"]), (0, [\"경미\", \"지장이 없\"])]\n", + "CAUSE_KW = {\"제조 불량\": [\"신품\", \"출고\", \"인수\"], \"정비 품질\": [\"정비 후\", \"재조립\", \"정비 직후\"], \"운용 부주의\": [\"충격\", \"과부하\", \"점검 없이\"],\n", + " \"설계 취약\": [\"동일 증상\", \"구조적\", \"여러 부대\"], \"자연 노후\": [\"사용 연한\", \"노후\", \"수명\"]}\n", + "PARTICLES = (\"에서\", \"으로\", \"이\", \"가\", \"은\", \"는\", \"을\", \"를\", \"에\", \"의\", \"로\", \"와\", \"과\", \"도\", \"함\", \"음\", \"됨\")\n", + "\n", + "\n", + "def norm(t):\n", + " \"\"\"정규화: 전각->반각, 특수문자·중복 공백 제거, 약어 확장, 오타 교정\"\"\"\n", + " # [TODO : 학생 실습 2] 비정형 결함 서술 텍스트 정제, 장비 부위별/결함 유형별 라벨링 전처리 구현 관련 핵심 로직을 구현하세요.\n", + " # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n", + " # <<< 여기에 코드를 작성하세요 >>>\n", + " pass # 작성 후 pass를 제거하세요.\n", + "def tok(t):\n", + " out = []\n", + " for w in re.split(r\"[\\s.,]+\", norm(t)):\n", + " if not w or re.fullmatch(r\"[\\d\\-A-Za-z]+\", w):\n", + " continue\n", + " for p in PARTICLES:\n", + " if len(w) > len(p) + 1 and w.endswith(p):\n", + " w = w[:-len(p)]\n", + " break\n", + " out.append(w)\n", + " return out\n", + "\n", + "\n", + "def label_part(t):\n", + " for a, i in PART_KW:\n", + " if a in t:\n", + " return i\n", + " return -1\n", + "\n", + "\n", + "def label_defect(t):\n", + " for d, ks in DEF_KW.items():\n", + " if any(k in t for k in ks):\n", + " return d\n", + " return \"미분류\"\n", + "\n", + "\n", + "def label_sev(t):\n", + " for s, ks in SEV_KW:\n", + " if any(k in t for k in ks):\n", + " return s\n", + " return -1\n", + "\n", + "\n", + "def label_cause(t):\n", + " for c, ks in CAUSE_KW.items():\n", + " if any(k in t for k in ks):\n", + " return c\n", + " return \"미상\"\n", + "\n", + "# 1) [교육용가상자료] 원천 자료: 생성 자료에 일부러 오류를 섞는다\n", + "df0, use, events = make_qdr(36, seed=1)\n", + "rng = np.random.default_rng(2)\n", + "raw = df0[[\"qdr_id\", \"month\", \"unit\", \"vendor\", \"since_maint\", \"inspect_wait\", \"text\", \"part\", \"defect\"]].copy()\n", + "raw[\"date\"] = [f\"2024-{m % 12 + 1:02d}-{rng.integers(1, 28):02d}\" for m in raw[\"month\"]]\n", + "raw[\"urgent\"] = False\n", + "n = len(raw)\n", + "urg = rng.choice(n, 40, replace=False)\n", + "raw.loc[urg, \"inspect_wait\"] = 0.0; raw.loc[urg, \"urgent\"] = True # 긴급 보고: 대기일 0은 정상 예외\n", + "raw.loc[urg, \"text\"] = \"긴급 보고. \" + raw.loc[urg, \"text\"]\n", + "\n", + "\n", + "def pick(k):\n", + " return rng.choice(n, k, replace=False)\n", + "\n", + "\n", + "i2 = pick(300)\n", + "raw.loc[i2, \"unit\"] = [u.lower() if j % 3 == 0 else u.replace(\"-\", \"\") if j % 3 == 1 else \"VU-\" + str(int(u[-2:])) for j, u in enumerate(raw.loc[i2, \"unit\"])]\n", + "i3 = pick(400); raw.loc[i3, \"date\"] = [d.replace(\"-\", \".\") if j % 2 else d[2:].replace(\"-\", \"/\") for j, d in enumerate(raw.loc[i3, \"date\"])]\n", + "i4 = pick(600); raw.loc[i4, \"text\"] = [t.replace(\". \", \". \").replace(\"에서\", \"에서 \").replace(\"함.\", \"함!!\") + \" ##\" if j % 2 else unicodedata.normalize(\"NFKC\", t).replace(\".\", \".\") for j, t in enumerate(raw.loc[i4, \"text\"])]\n", + "i5 = pick(300); raw.loc[i5, \"text\"] = [t.replace(\"변속기\", \"T/M\").replace(\"배터리\", \"BAT\") for t in raw.loc[i5, \"text\"]]\n", + "i6 = rng.choice(np.where(raw[\"text\"].str.contains(\"누유|균열|부식|제동|과열\"))[0], 400, replace=False); raw.loc[i6, \"text\"] = [t.replace(\"누유\", \"누윳\").replace(\"균열\", \"균렬\").replace(\"부식\", \"부씩\").replace(\"제동\", \"재동\").replace(\"과열\", \"과렬\") for t in raw.loc[i6, \"text\"]]\n", + "i7 = pick(80); raw.loc[i7, \"inspect_wait\"] = -1.0\n", + "i8 = pick(400); raw.loc[i8, \"since_maint\"] = np.nan\n", + "dup = raw.sample(150, random_state=3); raw = pd.concat([raw, dup], ignore_index=True)\n", + "print(f\"원천 자료 {len(raw)}행 (정상 {n}행 + 긴급 보고 {len(urg)}건 + 중복 {len(dup)}행, 오류 주입 {len(i2) + len(i3) + len(i4) + len(i5) + len(i6) + len(i7) + len(i8)}행)\")\n", + "\n", + "# 2) 정제 규칙 10종\n", + "log = []\n", + "\n", + "\n", + "def rule(name, cnt):\n", + " log.append((name, int(cnt)))\n", + "\n", + "\n", + "df = raw.copy()\n", + "before_len = len(df)\n", + "df = df.drop_duplicates(\"qdr_id\").reset_index(drop=True); rule(\"R1 중복 QDR 제거\", before_len - len(df))\n", + "fix = lambda u: \"VU-%02d\" % int(re.sub(r\"\\D\", \"\", u))\n", + "bad = ~df[\"unit\"].str.fullmatch(r\"VU-\\d{2}\"); df[\"unit\"] = df[\"unit\"].map(fix); rule(\"R2 부대 코드 표기 통일\", bad.sum())\n", + "bad = ~df[\"date\"].str.fullmatch(r\"\\d{4}-\\d{2}-\\d{2}\")\n", + "df[\"date\"] = [pd.to_datetime(d.replace(\".\", \"-\"), format=\"%Y-%m-%d\" if len(d) == 10 else \"%y-%m-%d\", errors=\"coerce\") if \"/\" not in d else pd.to_datetime(d, format=\"%y/%m/%d\") for d in df[\"date\"]]\n", + "rule(\"R3 일자 형식 통일\", bad.sum())\n", + "t0 = df[\"text\"].copy()\n", + "df[\"text\"] = df[\"text\"].map(lambda t: re.sub(r\"\\s+\", \" \", re.sub(r\"[!#~*@^]+\", \" \", unicodedata.normalize(\"NFKC\", t))).strip()); rule(\"R4 텍스트 정규화(전각·특수문자·공백)\", (df[\"text\"] != t0).sum())\n", + "c = df[\"text\"].str.contains(\"T/M|BAT\"); df[\"text\"] = df[\"text\"].map(norm); rule(\"R5 약어 확장(T/M, BAT)\", c.sum())\n", + "c = t0.map(lambda t: any(k in t for k in TYPO))\n", + "rule(\"R6 오타 교정(사전 기반)\", c.sum())\n", + "bad = df[\"inspect_wait\"] < 0; df.loc[bad, \"inspect_wait\"] = np.nan; rule(\"R7 불가능값(음수 대기일) 결측 처리\", bad.sum())\n", + "miss = df[\"since_maint\"].isna() | df[\"inspect_wait\"].isna()\n", + "X = pd.DataFrame({\"part\": df[\"part\"], \"vendor\": df[\"vendor\"].astype(\"category\").cat.codes * 5, \"wait\": df[\"inspect_wait\"], \"since\": df[\"since_maint\"], \"urgent\": df[\"urgent\"] * 20})\n", + "df[[\"inspect_wait\", \"since_maint\"]] = KNNImputer(n_neighbors=5).fit_transform(X)[:, 2:4]; rule(\"R8 대기일·정비후경과일 KNN 보정\", miss.sum())\n", + "df[\"part_label\"] = df[\"text\"].map(label_part); df[\"defect_label\"] = df[\"text\"].map(label_defect); rule(\"R9 부위·결함 유형 키워드 라벨링\", len(df))\n", + "flag = (df[\"inspect_wait\"] > 40) | ((df[\"inspect_wait\"] == 0) & ~df[\"urgent\"])\n", + "df[\"flag\"] = flag\n", + "rule(\"R10 긴급 보고 보존(일반 이상값은 표시만)\", int(df[\"urgent\"].sum()))\n", + "print(\"\\n[적용 규칙과 처리 건수]\")\n", + "for nm, c in log:\n", + " print(f\" {nm:40s} {c:6d}건\")\n", + "\n", + "# 3) 정합성 검증 + 라벨링 정확도\n", + "truth = df.merge(df0[[\"qdr_id\", \"part\", \"defect\"]], on=\"qdr_id\", suffixes=(\"\", \"_t\"))\n", + "rawlab = raw.drop_duplicates(\"qdr_id\").assign(pl=lambda d: d[\"text\"].map(label_part), dl=lambda d: d[\"text\"].map(label_defect))\n", + "rawacc_p, rawacc_d = (rawlab[\"pl\"] == rawlab[\"part\"]).mean(), (rawlab[\"dl\"] == rawlab[\"defect\"]).mean()\n", + "acc_p, acc_d = (truth[\"part_label\"] == truth[\"part_t\"]).mean(), (truth[\"defect_label\"] == truth[\"defect_t\"]).mean()\n", + "checks = {\n", + " \"R1 ID 유일\": df[\"qdr_id\"].is_unique, \"R2 부대 코드 형식\": df[\"unit\"].str.fullmatch(r\"VU-\\d{2}\").all(), \"R3 일자 변환\": df[\"date\"].notna().all(),\n", + " \"R4 특수문자·이중 공백 없음\": not df[\"text\"].str.contains(r\"[!#]|\\s\\s|.\").any(), \"R5 약어 없음\": not df[\"text\"].str.contains(\"T/M|BAT\").any(),\n", + " \"R6 오타 없음\": not df[\"text\"].map(lambda t: any(k in t for k in TYPO)).any(), \"R7 음수 대기일 없음\": (df[\"inspect_wait\"] >= 0).all(),\n", + " \"R8 결측 없음\": df[[\"inspect_wait\", \"since_maint\"]].notna().all().all(), \"R9 부위 라벨 부여율 100%\": (df[\"part_label\"] >= 0).all(),\n", + " \"R10 긴급 보고 40건 보존\": int(df[\"urgent\"].sum()) == 40}\n", + "npass = sum(checks.values())\n", + "print(f\"\\n정합성 검증 {npass}/10 통과, 통과율 {npass / 10:.0%} (목표 100%: {'달성' if npass == 10 else '미달'})\")\n", + "print(f\"라벨링 정확도: 부위 {rawacc_p:.1%} -> {acc_p:.1%}, 결함 유형 {rawacc_d:.1%} -> {acc_d:.1%} (정제 전 -> 후)\")\n", + "print(f\"긴급 보고 {int(df['urgent'].sum())}건 보존, 일반 이상 표시 {int((df['flag'] & ~df['urgent']).sum())}건\")\n", + "\n", + "# 4) 정제 전후 기술통계\n", + "def stats(d, after):\n", + " return {\"행 수\": len(d), \"부대 표기 종류 수\": d[\"unit\"].nunique(), \"서술 평균 길이(자)\": round(d[\"text\"].str.len().mean(), 1),\n", + " \"대기일 최소\": round(d[\"inspect_wait\"].min(), 1), \"대기일 결측 수\": int(d[\"inspect_wait\"].isna().sum()), \"정비후경과일 결측 수\": int(d[\"since_maint\"].isna().sum())}\n", + "print(\"\\n[정제 전후 기술통계]\")\n", + "print(pd.DataFrame({\"정제 전\": stats(raw, False), \"정제 후\": stats(df, True)}).to_string())\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 02 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + "원천 자료 9371행 (정상 9221행 + 긴급 보고 40건 + 중복 150행, 오류 주입 2480행)\n", + "\n", + "[적용 규칙과 처리 건수]\n", + " R1 중복 QDR 제거 150건\n", + " R2 부대 코드 표기 통일 238건\n", + " R3 일자 형식 통일 400건\n", + " R4 텍스트 정규화(전각·특수문자·공백) 600건\n", + " R5 약어 확장(T/M, BAT) 294건\n", + " R6 오타 교정(사전 기반) 400건\n", + " R7 불가능값(음수 대기일) 결측 처리 80건\n", + " R8 대기일·정비후경과일 KNN 보정 474건\n", + " R9 부위·결함 유형 키워드 라벨링 9221건\n", + " R10 긴급 보고 보존(일반 이상값은 표시만) 40건\n", + "\n", + "정합성 검증 10/10 통과, 통과율 100% (목표 100%: 달성)\n", + "라벨링 정확도: 부위 98.5% -> 100.0%, 결함 유형 96.8% -> 100.0% (정제 전 -> 후)\n", + "긴급 보고 40건 보존, 일반 이상 표시 1건\n", + "\n", + "[정제 전후 기술통계]\n", + " 정제 전 정제 후\n", + "행 수 9371.0 9221.0\n", + "부대 표기 종류 수 98.0 30.0\n", + "서술 평균 길이(자) 56.4 56.1\n", + "대기일 최소 -1.0 0.0\n", + "대기일 결측 수 0.0 0.0\n", + "정비후경과일 결측 수 406.0 0.0\n", + "```\n", + "
\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 03. 빈도 분석 기반 1차 다발 결함 부품 도출 Baseline 알고리즘 구현\n", + "**소요시간**: 약 2.5시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "빈도 분석 기반 다발 결함 부품 도출 Baseline 알고리즘을 구현한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **빈도 분석 기반 1차 다발 결함 부위 도출 Baseline을 구현하고, 탐색적 분석으로 빈도 기준의 한계와 외주정비 검사 병목, 핵심 관리 대상을 찾는다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **학습 기간 건수 상위 5개 부위를 다발 결함 부위로 지정해 검증 기간 적중률과 치명도 가중 지수 순위와의 차이 확인하고 유효성을 점검하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `notebooks/qlt05_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": [ + "\"\"\"빈도 분석 기반 1차 다발 결함 부위 도출 Baseline(학습 기간 건수 상위 5개 부위)의 적중률과 한계(치명도·비용 순위와의 차이)를 확인하고, 탐색적 분석(EDA 차트 8종, 월간 QDR 건수와 피처의 상관계수·p-value, 외주정비 업체별 검사 대기·원인, 건수·비용 파레토 ABC 분류)으로 핵심 관리 대상을 식별한다.\"\"\"\n", + "import numpy as np\n", + "import pandas as pd\n", + "import matplotlib.pyplot as plt\n", + "from scipy.stats import spearmanr\n", + "\n", + "# ---------------------------------------------------------------------------\n", + "# [교육용가상자료] 가상 야전 장비 품질결함보고(QDR) 생성기 (실제 부대·장비·업체·정비 실적 없음)\n", + "# 가상 장비 부위 12종, 결함 유형 8종, 치명도(Critical/Major/Minor), 원인 5종, 가상 외주정비 업체 4곳 + 부대정비.\n", + "# QDR 1건마다 한국어 서술 텍스트를 만들고, 월별 건수는 계절·추세·훈련 강도(usage)·일부 제조 불량 이벤트를 따른다.\n", + "# ---------------------------------------------------------------------------\n", + "PARTS = [ # (이름, 별칭, 월 기준 건수, 계절 진폭, 정점 월, (Critical, Major 비율), 가상 단가지수, 제조사, 주요 결함 가중치)\n", + " (\"엔진 연료계통\", [\"연료계통\", \"연료 라인\", \"연료 펌프\"], 26, 0.15, 7, (0.10, 0.35), 6.0, \"MK1\", \"누유:4 마모:2 체결불량:2 오작동:2\"),\n", + " (\"변속기\", [\"변속기\", \"T/M\", \"미션\"], 22, 0.10, 8, (0.12, 0.38), 9.0, \"MK1\", \"마모:4 누유:2 과열:3 오작동:1\"),\n", + " (\"제동 장치\", [\"제동 장치\", \"브레이크\", \"제동 라인\"], 20, 0.20, 12, (0.26, 0.40), 5.0, \"MK2\", \"마모:4 균열:2 누유:2 체결불량:2\"),\n", + " (\"조향 장치\", [\"조향 장치\", \"핸들 연동부\", \"조향 로드\"], 14, 0.10, 6, (0.30, 0.40), 5.0, \"MK2\", \"체결불량:4 균열:2 마모:3 오작동:1\"),\n", + " (\"냉각 계통\", [\"냉각 계통\", \"라디에이터\", \"냉각수 라인\"], 24, 0.30, 7, (0.05, 0.30), 3.0, \"MK3\", \"누유:4 과열:4 부식:2\"),\n", + " (\"전기·배터리\", [\"배터리\", \"전기 계통\", \"발전기\"], 28, 0.35, 1, (0.04, 0.28), 2.5, \"MK3\", \"접촉불량:4 부식:2 과열:2 오작동:2\"),\n", + " (\"통신 모듈\", [\"통신 모듈\", \"무전기 연동부\", \"안테나 접속부\"], 16, 0.05, 4, (0.03, 0.25), 4.0, \"MK4\", \"접촉불량:5 오작동:4 부식:1\"),\n", + " (\"유압 계통\", [\"유압 계통\", \"유압 호스\", \"유압 실린더\"], 18, 0.10, 9, (0.10, 0.35), 4.5, \"MK2\", \"누유:5 균열:2 마모:2 체결불량:1\"),\n", + " (\"현가 장치\", [\"현가 장치\", \"서스펜션\", \"완충기\"], 15, 0.08, 5, (0.06, 0.30), 3.5, \"MK4\", \"마모:4 균열:3 체결불량:2 부식:1\"),\n", + " (\"차체 용접부\", [\"차체 용접부\", \"프레임 용접부\", \"차체 이음부\"], 10, 0.05, 10, (0.28, 0.40), 7.0, \"MK1\", \"균열:6 부식:3 체결불량:1\"),\n", + " (\"광학·조준 장비\", [\"광학 장비\", \"조준경\", \"관측 장비\"], 8, 0.05, 3, (0.03, 0.22), 8.0, \"MK4\", \"오작동:4 접촉불량:3 부식:2 체결불량:1\"),\n", + " (\"공기 정화 필터\", [\"공기 필터\", \"흡기 필터\", \"정화 장치\"], 12, 0.25, 4, (0.01, 0.12), 1.0, \"MK3\", \"마모:5 부식:2 체결불량:3\")]\n", + "NP = len(PARTS)\n", + "DEFECTS = { # 결함 유형: 증상 표현(동의어)\n", + " \"균열\": [\"균열이 발견됨\", \"크랙이 확인됨\", \"갈라짐이 있음\"], \"누유\": [\"누유가 발생함\", \"오일이 새어 나옴\", \"누설 흔적이 있음\"],\n", + " \"마모\": [\"마모가 심함\", \"과도한 마모가 확인됨\", \"닳은 흔적이 있음\"], \"부식\": [\"부식이 진행됨\", \"녹이 발생함\", \"산화 흔적이 있음\"],\n", + " \"접촉불량\": [\"접촉 불량이 발생함\", \"커넥터 단선이 의심됨\", \"신호가 끊김\"], \"체결불량\": [\"볼트 풀림이 확인됨\", \"체결 상태가 불량함\", \"고정부가 헐거움\"],\n", + " \"과열\": [\"과열 경고가 발생함\", \"온도가 과도하게 상승함\", \"열이 심하게 남\"], \"오작동\": [\"오작동이 반복됨\", \"작동이 불안정함\", \"간헐적으로 멈춤\"]}\n", + "DNAMES = list(DEFECTS)\n", + "SEV = [\"Minor\", \"Major\", \"Critical\"]\n", + "SEV_CUE = {0: [\"경미한 수준임\", \"운용에는 지장이 없음\", \"육안 점검상 사소한 이상임\", \"기능에는 영향이 없음\"],\n", + " 1: [\"성능 저하가 있음\", \"조속한 정비가 필요함\", \"출력이 떨어지는 상태임\", \"기능이 부분적으로 제한됨\"],\n", + " 2: [\"즉시 운용 중지함\", \"안전에 위험이 있어 사용 불가\", \"작동 정지로 임무 수행이 어려움\", \"인명 안전 위협이 우려됨\"]}\n", + "CAUSES = [\"제조 불량\", \"정비 품질\", \"운용 부주의\", \"설계 취약\", \"자연 노후\"]\n", + "CAUSE_CUE = {\n", + " \"제조 불량\": [\"신품 인수 직후 발생함\", \"출고 상태에서 이미 이상이 있었음\", \"인수 후 얼마 지나지 않아 발생함\"],\n", + " \"정비 품질\": [\"외주 정비 후 얼마 되지 않아 재발함\", \"정비 후 재조립 상태가 불량함\", \"정비 직후 같은 부위에서 다시 발생함\"],\n", + " \"운용 부주의\": [\"험지 기동 중 충격 이후 발생함\", \"과부하 운용 후 발생함\", \"점검 없이 연속 운용한 뒤 발생함\"],\n", + " \"설계 취약\": [\"동일 증상이 다른 차량에서도 반복됨\", \"구조적으로 취약한 부위로 보임\", \"여러 부대에서 같은 형태로 보고됨\"],\n", + " \"자연 노후\": [\"사용 연한이 도래함\", \"장기간 사용으로 노후됨\", \"수명 주기 말기에 발생함\"]}\n", + "VENDORS = [\"외주A\", \"외주B\", \"외주C\", \"외주D\", \"부대정비\"]\n", + "ACTIONS = [\"정비소 입고를 요청함\", \"현장 임시 조치 후 운용 중임\", \"부품 교환을 요청함\", \"재검사를 요청함\", \"원인 확인이 필요함\"]\n", + "MAKER_OF = [p[7] for p in PARTS]\n", + "PRICE = np.array([p[6] for p in PARTS])\n", + "\n", + "\n", + "def usage_index(months, seed=0):\n", + " \"\"\"월별 훈련 강도(미리 알 수 있는 계획 값): 연 1회 정점을 갖는 계절 + 작은 잡음\"\"\"\n", + " # [TODO : 학생 실습 3] 빈도 분석 기반 1차 다발 결함 부품 도출 Baseline 알고리즘 구현 관련 핵심 로직을 구현하세요.\n", + " # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n", + " # <<< 여기에 코드를 작성하세요 >>>\n", + " pass # 작성 후 pass를 제거하세요.\n", + "def make_qdr(months=36, seed=1, scale=1.0, surge=True):\n", + " \"\"\"반환: QDR DataFrame(건당 1행), 월별 usage, 급증 이벤트 목록\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " use = usage_index(months, seed)\n", + " events = []\n", + " if surge:\n", + " for _ in range(max(2, months // 12 * 2)):\n", + " events.append((int(rng.integers(NP)), int(rng.integers(6, months))))\n", + " rows = []\n", + " wdef = [np.array([float(x.split(\":\")[1]) for x in p[8].split()]) for p in PARTS]\n", + " ddef = [[x.split(\":\")[0] for x in p[8].split()] for p in PARTS]\n", + " n = 0\n", + " for m in range(months):\n", + " for pi, p in enumerate(PARTS):\n", + " lam = p[2] * scale * (1 + 0.010 * m) * use[m] * (1 + p[3] * np.cos(2 * np.pi * (m % 12 - p[4]) / 12))\n", + " ev = (pi, m) in events\n", + " k = rng.poisson(lam * (2.6 if ev else 1.0))\n", + " for _ in range(k):\n", + " rows.append((n, m, pi, ev)); n += 1\n", + " N = len(rows)\n", + " A = np.array(rows)\n", + " month, part, ev = A[:, 1], A[:, 2], A[:, 3].astype(bool)\n", + " defect = np.array([rng.choice(ddef[i], p=wdef[i] / wdef[i].sum()) for i in part])\n", + " cr = np.array([PARTS[i][5][0] for i in part]); ma = np.array([PARTS[i][5][1] for i in part])\n", + " u = rng.random(N)\n", + " sev = np.where(u < cr, 2, np.where(u < cr + ma, 1, 0))\n", + " vendor = rng.choice(VENDORS, N, p=[0.25, 0.2, 0.2, 0.15, 0.2])\n", + " # 원인: 기본 비율에 부위(제조사)·업체·이벤트 효과를 곱한다\n", + " base = np.array([0.22, 0.20, 0.20, 0.16, 0.22])\n", + " P = np.tile(base, (N, 1))\n", + " P[vendor == \"외주B\", 1] *= 2.6\n", + " P[ev, 0] *= 5.0\n", + " design = np.isin(part, [2, 3, 7]); P[design, 3] *= 2.2\n", + " P[np.isin(part, [4, 5, 11]), 0] *= 2.0 # 제조사 MK3 납품 부위는 제조 불량 비중이 높다\n", + " P[defect == \"마모\", 4] *= 1.6; P[defect == \"누유\", 4] *= 1.3\n", + " P /= P.sum(axis=1, keepdims=True)\n", + " cause = np.array([CAUSES[rng.choice(5, p=P[i])] for i in range(N)])\n", + " since = np.where(cause == \"정비 품질\", rng.exponential(35, N), rng.exponential(160, N)).clip(1, 720).round().astype(int)\n", + " wait = (rng.lognormal(1.3, 0.5, N) * np.where(vendor == \"외주B\", 1.7, np.where(vendor == \"부대정비\", 0.6, 1.0)) * (0.7 + 0.5 * use[month])).round(1)\n", + " reins = rng.random(N) < np.where(cause == \"정비 품질\", 0.6, 0.08)\n", + " texts = []\n", + " for i in range(N):\n", + " pt = PARTS[part[i]]\n", + " cl = [f\"{rng.choice(pt[1])}에서 {rng.choice(DEFECTS[defect[i]])}\"]\n", + " if rng.random() < 0.7: cl.append(rng.choice(SEV_CUE[sev[i]]))\n", + " if rng.random() < 0.65: # 원인 단서: 8%는 실제 원인과 다른 단서가 적힌다(보고자의 추정)\n", + " cc = cause[i] if rng.random() > 0.08 else rng.choice(CAUSES)\n", + " cl.append(rng.choice(CAUSE_CUE[cc]))\n", + " cl.append(rng.choice(ACTIONS))\n", + " if rng.random() < 0.5: cl.insert(0, f\"VU-{rng.integers(1, 31):02d} 소속\")\n", + " texts.append(\". \".join(cl) + \".\")\n", + " df = pd.DataFrame({\"qdr_id\": [f\"Q{seed}{i:06d}\" for i in range(N)], \"month\": month, \"unit\": [f\"VU-{x:02d}\" for x in rng.integers(1, 31, N)],\n", + " \"part\": part, \"defect\": defect, \"sev\": sev, \"cause\": cause, \"vendor\": vendor, \"maker\": [MAKER_OF[i] for i in part],\n", + " \"since_maint\": since, \"inspect_wait\": wait, \"reinspect\": reins, \"event\": ev, \"text\": texts})\n", + " df[\"cost\"] = PRICE[df[\"part\"]] * np.array([1.0, 2.0, 4.0])[df[\"sev\"]] # [교육용가상자료] 수리·조치 비용지수\n", + " return df, use, events\n", + "\n", + "df, use, events = make_qdr(36, seed=1)\n", + "names = [p[0] for p in PARTS]\n", + "tr, va = df[df[\"month\"] < 24], df[df[\"month\"] >= 24]\n", + "print(f\"[교육용가상자료] QDR {len(df)}건, 부위 {NP}종, 36개월 (학습 24개월 {len(tr)}건, 검증 12개월 {len(va)}건)\")\n", + "\n", + "# 1) Baseline: 학습 기간 건수 상위 5개 부위를 다발 결함 부위로 지정\n", + "cnt_tr = tr.groupby(\"part\").size().sort_values(ascending=False)\n", + "top_base = list(cnt_tr.index[:5])\n", + "cnt_va = va.groupby(\"part\").size().sort_values(ascending=False)\n", + "vuln = lambda d: d.groupby(\"part\")[\"sev\"].apply(lambda s: np.array([1, 3, 9])[s].sum()).sort_values(ascending=False)\n", + "v_va = vuln(va)\n", + "monthly_hit = np.mean([len(set(top_base) & set(g.groupby(\"part\").size().sort_values(ascending=False).index[:5])) / 5 for _, g in va.groupby(\"month\")])\n", + "print(f\"Baseline 상위 5 부위: {', '.join(names[i] for i in top_base)}\")\n", + "print(f\" 검증 기간 건수 상위 5와 일치 {len(set(top_base) & set(cnt_va.index[:5]))}/5, 월별 평균 적중률 {monthly_hit:.0%}\")\n", + "print(f\" 치명도 가중 취약성 지수 상위 5와 일치 {len(set(top_base) & set(v_va.index[:5]))}/5 -> 건수만 보면 놓치는 부위: \" + \", \".join(names[i] for i in set(v_va.index[:5]) - set(top_base)))\n", + "crit = va[va[\"sev\"] == 2]\n", + "miss = (~crit[\"part\"].isin(top_base)).mean()\n", + "print(f\" Critical 결함 {len(crit)}건 중 Baseline 대상 밖 부위 비중 {miss:.0%}\")\n", + "\n", + "# 2) 파레토(ABC): 건수와 조치 비용\n", + "par = pd.DataFrame({\"부위\": [names[i] for i in cnt_tr.index], \"건수\": cnt_tr.values}).set_index(\"부위\")\n", + "par[\"비용지수\"] = [df.loc[df[\"part\"] == i, \"cost\"].sum() for i in cnt_tr.index]\n", + "pc = par.sort_values(\"비용지수\", ascending=False)\n", + "pc[\"누적비율\"] = pc[\"비용지수\"].cumsum() / pc[\"비용지수\"].sum()\n", + "pc[\"등급\"] = np.where(pc[\"누적비율\"] <= 0.7, \"A\", np.where(pc[\"누적비율\"] <= 0.9, \"B\", \"C\")); pc.iloc[0, 3] = \"A\"\n", + "print(\"\\n[파레토] 조치 비용 기준(가상 단가지수)\")\n", + "print(pc.assign(비용지수=lambda d: d[\"비용지수\"].round(0), 누적비율=lambda d: d[\"누적비율\"].map(\"{:.0%}\".format)).head(6).to_string())\n", + "top20 = pc.head(int(np.ceil(NP * 0.2)))\n", + "print(f\"상위 20% 부위({', '.join(top20.index)})가 전체 조치 비용의 {top20['비용지수'].sum() / pc['비용지수'].sum():.0%}, A등급 {int((pc['등급'] == 'A').sum())}종\")\n", + "\n", + "# 3) 상관분석: 월간 QDR 건수와 월별 피처\n", + "mon = df.groupby(\"month\").agg(n=(\"qdr_id\", \"size\"), wait=(\"inspect_wait\", \"mean\"), re=(\"reinspect\", \"mean\"), crit=(\"sev\", lambda s: (s == 2).mean()),\n", + " ven_b=(\"vendor\", lambda s: (s == \"외주B\").mean()), short=(\"since_maint\", lambda s: (s < 60).mean()),\n", + " mfg=(\"cause\", lambda s: (s == \"제조 불량\").mean()), cost=(\"cost\", \"mean\"), units=(\"unit\", \"nunique\"),\n", + " short_n=(\"since_maint\", lambda s: (s < 60).sum()))\n", + "mon[\"usage\"] = use; mon[\"trend\"] = np.arange(36); mon[\"season\"] = np.cos(2 * np.pi * (mon[\"trend\"] % 12 - 7) / 12); mon[\"events\"] = [sum(1 for e in events if e[1] == m) for m in range(36)]\n", + "rows = []\n", + "for c, lab in [(\"usage\", \"훈련 강도\"), (\"trend\", \"경과 월(추세)\"), (\"season\", \"계절 요인\"), (\"wait\", \"평균 검사 대기일\"), (\"re\", \"재검사율\"), (\"short\", \"정비 후 60일 이내 비중\"),\n", + " (\"mfg\", \"제조 불량 비중\"), (\"cost\", \"건당 평균 조치 비용\"), (\"ven_b\", \"외주B 비중\"), (\"crit\", \"Critical 비중\"), (\"units\", \"보고 부대 수\"), (\"short_n\", \"정비 후 60일 이내 건수\")]:\n", + " r, p = spearmanr(mon[c], mon[\"n\"]); rows.append((lab, r, p))\n", + "cor = pd.DataFrame(rows, columns=[\"피처\", \"rho\", \"p\"]); cor[\"유의\"] = (cor[\"p\"] < 0.05) & (cor[\"rho\"].abs() >= 0.3)\n", + "print(\"\\n[상관분석] 피처 | 스피어만 rho | p-value | 유의(p<0.05, |rho|>=0.3)\")\n", + "for x in cor.sort_values(\"rho\", key=abs, ascending=False).head(7).itertuples():\n", + " print(f\" {x.피처:16s} {x.rho:+.2f} {x.p:.4f} {'유의' if x.유의 else '-'}\")\n", + "nsig = int(cor[\"유의\"].sum())\n", + "\n", + "# 4) 외주정비 업체별 검사 병목과 원인\n", + "ven = df.groupby(\"vendor\").agg(건수=(\"qdr_id\", \"size\"), 대기일=(\"inspect_wait\", \"mean\"), 재검사율=(\"reinspect\", \"mean\"), 정비품질원인=(\"cause\", lambda s: (s == \"정비 품질\").mean()))\n", + "print(\"\\n[업체별 검사 실적] 직전 정비 수행처 기준\")\n", + "print(ven.round(2).to_string())\n", + "b, o = df[df[\"vendor\"] == \"외주B\"], df[~df[\"vendor\"].isin([\"외주B\"])]\n", + "print(f\"외주B: 대기일 {b['inspect_wait'].mean():.1f}일(타 수행처 {o['inspect_wait'].mean():.1f}일), '정비 품질' 원인 {np.mean(b['cause'] == '정비 품질'):.0%}(타 {np.mean(o['cause'] == '정비 품질'):.0%}), 정비 후 60일 이내 재발 {np.mean(b['since_maint'] < 60):.0%}\")\n", + "\n", + "# 5) EDA 차트 8종\n", + "fig, ax = plt.subplots(2, 4, figsize=(16, 7))\n", + "H = df.pivot_table(index=\"part\", columns=\"month\", values=\"qdr_id\", aggfunc=\"size\", fill_value=0)\n", + "ax[0, 0].imshow(H.values, aspect=\"auto\", cmap=\"Reds\"); ax[0, 0].set_title(\"1 QDR heatmap (part x month)\")\n", + "ax[0, 1].plot(mon[\"n\"].values, label=\"QDR\"); ax[0, 1].plot(mon[\"usage\"].values * mon[\"n\"].mean(), label=\"usage x mean\"); ax[0, 1].legend(fontsize=6); ax[0, 1].set_title(\"2 Monthly QDR vs usage\")\n", + "ax[0, 2].bar(range(NP), pc[\"비용지수\"].values); ax[0, 2].plot(range(NP), pc[\"누적비율\"].values * pc[\"비용지수\"].max(), \"r-o\"); ax[0, 2].set_title(\"3 Pareto of repair cost\")\n", + "sv = pd.crosstab(df[\"part\"], df[\"sev\"], normalize=\"index\"); ax[0, 3].bar(range(NP), sv[0], label=\"Minor\"); ax[0, 3].bar(range(NP), sv[1], bottom=sv[0], label=\"Major\"); ax[0, 3].bar(range(NP), sv[2], bottom=sv[0] + sv[1], label=\"Critical\"); ax[0, 3].legend(fontsize=6); ax[0, 3].set_title(\"4 Severity share by part\")\n", + "ax[1, 0].boxplot([df.loc[df[\"vendor\"] == v, \"inspect_wait\"] for v in VENDORS], labels=[v[-1] for v in VENDORS]); ax[1, 0].set_title(\"5 Inspection wait by vendor\")\n", + "cv = pd.crosstab(df[\"vendor\"], df[\"cause\"], normalize=\"index\"); ax[1, 1].imshow(cv.values, cmap=\"Blues\"); ax[1, 1].set_title(\"6 Cause share by vendor\")\n", + "ax[1, 2].hist([df.loc[df[\"cause\"] == c, \"since_maint\"].clip(0, 400) for c in CAUSES[:2]], bins=20, label=[\"mfg\", \"maint\"]); ax[1, 2].legend(fontsize=6); ax[1, 2].set_title(\"7 Days since maintenance\")\n", + "ax[1, 3].scatter(mon[\"wait\"], mon[\"n\"], s=12); ax[1, 3].set_title(\"8 Wait vs monthly QDR\")\n", + "plt.tight_layout(); plt.show()\n", + "\n", + "print(f\"\\nEDA 차트 8종 생성(목표 8종 이상: 달성), 유의한 피처 {nsig}개 (목표 5개 이상: {'달성' if nsig >= 5 else '미달'})\")\n", + "print(\"핵심 발견: 1) 건수 상위만 보면 조향 장치처럼 치명도 높은 부위를 놓침 2) 외주B는 검사 대기가 길고 정비 후 조기 재발 비중이 높음 3) 월별 QDR는 훈련 강도와 계절에 연동\")\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 03 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + "[교육용가상자료] QDR 9221건, 부위 12종, 36개월 (학습 24개월 5906건, 검증 12개월 3315건)\n", + "Baseline 상위 5 부위: 전기·배터리, 엔진 연료계통, 냉각 계통, 제동 장치, 변속기\n", + " 검증 기간 건수 상위 5와 일치 5/5, 월별 평균 적중률 80%\n", + " 치명도 가중 취약성 지수 상위 5와 일치 4/5 -> 건수만 보면 놓치는 부위: 조향 장치\n", + " Critical 결함 366건 중 Baseline 대상 밖 부위 비중 46%\n", + "\n", + "[파레토] 조치 비용 기준(가상 단가지수)\n", + " 건수 비용지수 누적비율 등급\n", + "부위 \n", + "변속기 558 13464.0 18% A\n", + "엔진 연료계통 770 11550.0 33% A\n", + "제동 장치 596 10220.0 47% A\n", + "조향 장치 397 7155.0 56% A\n", + "차체 용접부 277 6776.0 65% A\n", + "유압 계통 462 5607.0 73% B\n", + "상위 20% 부위(변속기, 엔진 연료계통, 제동 장치)가 전체 조치 비용의 47%, A등급 5종\n", + "\n", + "[상관분석] 피처 | 스피어만 rho | p-value | 유의(p<0.05, |rho|>=0.3)\n", + " 정비 후 60일 이내 건수 +0.94 0.0000 유의\n", + " 평균 검사 대기일 +0.83 0.0000 유의\n", + " 훈련 강도 +0.82 0.0000 유의\n", + " 계절 요인 +0.78 0.0000 유의\n", + " 경과 월(추세) +0.39 0.0200 유의\n", + " 외주B 비중 -0.22 0.1928 -\n", + " Critical 비중 +0.16 0.3583 -\n", + "\n", + "[업체별 검사 실적] 직전 정비 수행처 기준\n", + " 건수 대기일 재검사율 정비품질원인\n", + "vendor \n", + "부대정비 1811 3.11 0.18 0.18\n", + "외주A 2231 5.00 0.17 0.18\n", + "외주B 1916 8.44 0.27 0.36\n", + "외주C 1864 5.08 0.16 0.17\n", + "외주D 1399 4.98 0.17 0.17\n", + "외주B: 대기일 8.4일(타 수행처 4.5일), '정비 품질' 원인 36%(타 18%), 정비 후 60일 이내 재발 49%\n", + "\n", + "EDA 차트 8종 생성(목표 8종 이상: 달성), 유의한 피처 5개 (목표 5개 이상: 달성)\n", + "핵심 발견: 1) 건수 상위만 보면 조향 장치처럼 치명도 높은 부위를 놓침 2) 외주B는 검사 대기가 길고 정비 후 조기 재발 비중이 높음 3) 월별 QDR는 훈련 강도와 계절에 연동\n", + "```\n", + "
\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 04. 텍스트 임베딩 및 토픽 모델링 기반 장비 구조적 품질 취약점 자동 군집화 모델 고도화\n", + "**소요시간**: 약 3.0시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "토픽 모델링 기반 장비 구조적 품질 취약점 자동 군집화 모델을 고도화한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **텍스트 임베딩(TF-IDF)과 토픽 모델링(NMF)으로 장비 구조적 품질 취약점을 자동 군집화하고, 부위별 월간 QDR 건수 기준 모델을 파생 피처로 고도화한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **결함 서술의 핵심 문장을 TF-IDF로 벡터화하고 NMF 토픽 36개로 군집화해 부위·결함 라벨과의 일치도(NMI) 확인하고 유효성을 점검하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `src/features/qlt05_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": [ + "\"\"\"QDR 서술 텍스트를 TF-IDF로 벡터화하고 NMF 토픽 모델링으로 장비 구조적 품질 취약점을 자동 군집화해 부위·결함 라벨과의 일치도(NMI)와 취약 군집(치명도·설계 취약 비중)을 확인하고, 토픽 비중 등 파생 피처 14개로 부위별 월간 QDR 건수 기준 모델을 만들어 시계열 교차검증 MAPE를 평가하고 순열 중요도로 피처를 줄인다.\"\"\"\n", + "import re\n", + "import unicodedata\n", + "import numpy as np\n", + "import pandas as pd\n", + "from sklearn.decomposition import NMF\n", + "from sklearn.ensemble import GradientBoostingRegressor\n", + "from sklearn.feature_extraction.text import TfidfVectorizer\n", + "from sklearn.inspection import permutation_importance\n", + "from sklearn.metrics import normalized_mutual_info_score\n", + "\n", + "# ---------------------------------------------------------------------------\n", + "# [교육용가상자료] 가상 야전 장비 품질결함보고(QDR) 생성기 (실제 부대·장비·업체·정비 실적 없음)\n", + "# 가상 장비 부위 12종, 결함 유형 8종, 치명도(Critical/Major/Minor), 원인 5종, 가상 외주정비 업체 4곳 + 부대정비.\n", + "# QDR 1건마다 한국어 서술 텍스트를 만들고, 월별 건수는 계절·추세·훈련 강도(usage)·일부 제조 불량 이벤트를 따른다.\n", + "# ---------------------------------------------------------------------------\n", + "PARTS = [ # (이름, 별칭, 월 기준 건수, 계절 진폭, 정점 월, (Critical, Major 비율), 가상 단가지수, 제조사, 주요 결함 가중치)\n", + " (\"엔진 연료계통\", [\"연료계통\", \"연료 라인\", \"연료 펌프\"], 26, 0.15, 7, (0.10, 0.35), 6.0, \"MK1\", \"누유:4 마모:2 체결불량:2 오작동:2\"),\n", + " (\"변속기\", [\"변속기\", \"T/M\", \"미션\"], 22, 0.10, 8, (0.12, 0.38), 9.0, \"MK1\", \"마모:4 누유:2 과열:3 오작동:1\"),\n", + " (\"제동 장치\", [\"제동 장치\", \"브레이크\", \"제동 라인\"], 20, 0.20, 12, (0.26, 0.40), 5.0, \"MK2\", \"마모:4 균열:2 누유:2 체결불량:2\"),\n", + " (\"조향 장치\", [\"조향 장치\", \"핸들 연동부\", \"조향 로드\"], 14, 0.10, 6, (0.30, 0.40), 5.0, \"MK2\", \"체결불량:4 균열:2 마모:3 오작동:1\"),\n", + " (\"냉각 계통\", [\"냉각 계통\", \"라디에이터\", \"냉각수 라인\"], 24, 0.30, 7, (0.05, 0.30), 3.0, \"MK3\", \"누유:4 과열:4 부식:2\"),\n", + " (\"전기·배터리\", [\"배터리\", \"전기 계통\", \"발전기\"], 28, 0.35, 1, (0.04, 0.28), 2.5, \"MK3\", \"접촉불량:4 부식:2 과열:2 오작동:2\"),\n", + " (\"통신 모듈\", [\"통신 모듈\", \"무전기 연동부\", \"안테나 접속부\"], 16, 0.05, 4, (0.03, 0.25), 4.0, \"MK4\", \"접촉불량:5 오작동:4 부식:1\"),\n", + " (\"유압 계통\", [\"유압 계통\", \"유압 호스\", \"유압 실린더\"], 18, 0.10, 9, (0.10, 0.35), 4.5, \"MK2\", \"누유:5 균열:2 마모:2 체결불량:1\"),\n", + " (\"현가 장치\", [\"현가 장치\", \"서스펜션\", \"완충기\"], 15, 0.08, 5, (0.06, 0.30), 3.5, \"MK4\", \"마모:4 균열:3 체결불량:2 부식:1\"),\n", + " (\"차체 용접부\", [\"차체 용접부\", \"프레임 용접부\", \"차체 이음부\"], 10, 0.05, 10, (0.28, 0.40), 7.0, \"MK1\", \"균열:6 부식:3 체결불량:1\"),\n", + " (\"광학·조준 장비\", [\"광학 장비\", \"조준경\", \"관측 장비\"], 8, 0.05, 3, (0.03, 0.22), 8.0, \"MK4\", \"오작동:4 접촉불량:3 부식:2 체결불량:1\"),\n", + " (\"공기 정화 필터\", [\"공기 필터\", \"흡기 필터\", \"정화 장치\"], 12, 0.25, 4, (0.01, 0.12), 1.0, \"MK3\", \"마모:5 부식:2 체결불량:3\")]\n", + "NP = len(PARTS)\n", + "DEFECTS = { # 결함 유형: 증상 표현(동의어)\n", + " \"균열\": [\"균열이 발견됨\", \"크랙이 확인됨\", \"갈라짐이 있음\"], \"누유\": [\"누유가 발생함\", \"오일이 새어 나옴\", \"누설 흔적이 있음\"],\n", + " \"마모\": [\"마모가 심함\", \"과도한 마모가 확인됨\", \"닳은 흔적이 있음\"], \"부식\": [\"부식이 진행됨\", \"녹이 발생함\", \"산화 흔적이 있음\"],\n", + " \"접촉불량\": [\"접촉 불량이 발생함\", \"커넥터 단선이 의심됨\", \"신호가 끊김\"], \"체결불량\": [\"볼트 풀림이 확인됨\", \"체결 상태가 불량함\", \"고정부가 헐거움\"],\n", + " \"과열\": [\"과열 경고가 발생함\", \"온도가 과도하게 상승함\", \"열이 심하게 남\"], \"오작동\": [\"오작동이 반복됨\", \"작동이 불안정함\", \"간헐적으로 멈춤\"]}\n", + "DNAMES = list(DEFECTS)\n", + "SEV = [\"Minor\", \"Major\", \"Critical\"]\n", + "SEV_CUE = {0: [\"경미한 수준임\", \"운용에는 지장이 없음\", \"육안 점검상 사소한 이상임\", \"기능에는 영향이 없음\"],\n", + " 1: [\"성능 저하가 있음\", \"조속한 정비가 필요함\", \"출력이 떨어지는 상태임\", \"기능이 부분적으로 제한됨\"],\n", + " 2: [\"즉시 운용 중지함\", \"안전에 위험이 있어 사용 불가\", \"작동 정지로 임무 수행이 어려움\", \"인명 안전 위협이 우려됨\"]}\n", + "CAUSES = [\"제조 불량\", \"정비 품질\", \"운용 부주의\", \"설계 취약\", \"자연 노후\"]\n", + "CAUSE_CUE = {\n", + " \"제조 불량\": [\"신품 인수 직후 발생함\", \"출고 상태에서 이미 이상이 있었음\", \"인수 후 얼마 지나지 않아 발생함\"],\n", + " \"정비 품질\": [\"외주 정비 후 얼마 되지 않아 재발함\", \"정비 후 재조립 상태가 불량함\", \"정비 직후 같은 부위에서 다시 발생함\"],\n", + " \"운용 부주의\": [\"험지 기동 중 충격 이후 발생함\", \"과부하 운용 후 발생함\", \"점검 없이 연속 운용한 뒤 발생함\"],\n", + " \"설계 취약\": [\"동일 증상이 다른 차량에서도 반복됨\", \"구조적으로 취약한 부위로 보임\", \"여러 부대에서 같은 형태로 보고됨\"],\n", + " \"자연 노후\": [\"사용 연한이 도래함\", \"장기간 사용으로 노후됨\", \"수명 주기 말기에 발생함\"]}\n", + "VENDORS = [\"외주A\", \"외주B\", \"외주C\", \"외주D\", \"부대정비\"]\n", + "ACTIONS = [\"정비소 입고를 요청함\", \"현장 임시 조치 후 운용 중임\", \"부품 교환을 요청함\", \"재검사를 요청함\", \"원인 확인이 필요함\"]\n", + "MAKER_OF = [p[7] for p in PARTS]\n", + "PRICE = np.array([p[6] for p in PARTS])\n", + "\n", + "\n", + "def usage_index(months, seed=0):\n", + " \"\"\"월별 훈련 강도(미리 알 수 있는 계획 값): 연 1회 정점을 갖는 계절 + 작은 잡음\"\"\"\n", + " # [TODO : 학생 실습 4] 텍스트 임베딩 및 토픽 모델링 기반 장비 구조적 품질 취약점 자동 군집화 모델 고도화 관련 핵심 로직을 구현하세요.\n", + " # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n", + " # <<< 여기에 코드를 작성하세요 >>>\n", + " pass # 작성 후 pass를 제거하세요.\n", + "def make_qdr(months=36, seed=1, scale=1.0, surge=True):\n", + " \"\"\"반환: QDR DataFrame(건당 1행), 월별 usage, 급증 이벤트 목록\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " use = usage_index(months, seed)\n", + " events = []\n", + " if surge:\n", + " for _ in range(max(2, months // 12 * 2)):\n", + " events.append((int(rng.integers(NP)), int(rng.integers(6, months))))\n", + " rows = []\n", + " wdef = [np.array([float(x.split(\":\")[1]) for x in p[8].split()]) for p in PARTS]\n", + " ddef = [[x.split(\":\")[0] for x in p[8].split()] for p in PARTS]\n", + " n = 0\n", + " for m in range(months):\n", + " for pi, p in enumerate(PARTS):\n", + " lam = p[2] * scale * (1 + 0.010 * m) * use[m] * (1 + p[3] * np.cos(2 * np.pi * (m % 12 - p[4]) / 12))\n", + " ev = (pi, m) in events\n", + " k = rng.poisson(lam * (2.6 if ev else 1.0))\n", + " for _ in range(k):\n", + " rows.append((n, m, pi, ev)); n += 1\n", + " N = len(rows)\n", + " A = np.array(rows)\n", + " month, part, ev = A[:, 1], A[:, 2], A[:, 3].astype(bool)\n", + " defect = np.array([rng.choice(ddef[i], p=wdef[i] / wdef[i].sum()) for i in part])\n", + " cr = np.array([PARTS[i][5][0] for i in part]); ma = np.array([PARTS[i][5][1] for i in part])\n", + " u = rng.random(N)\n", + " sev = np.where(u < cr, 2, np.where(u < cr + ma, 1, 0))\n", + " vendor = rng.choice(VENDORS, N, p=[0.25, 0.2, 0.2, 0.15, 0.2])\n", + " # 원인: 기본 비율에 부위(제조사)·업체·이벤트 효과를 곱한다\n", + " base = np.array([0.22, 0.20, 0.20, 0.16, 0.22])\n", + " P = np.tile(base, (N, 1))\n", + " P[vendor == \"외주B\", 1] *= 2.6\n", + " P[ev, 0] *= 5.0\n", + " design = np.isin(part, [2, 3, 7]); P[design, 3] *= 2.2\n", + " P[np.isin(part, [4, 5, 11]), 0] *= 2.0 # 제조사 MK3 납품 부위는 제조 불량 비중이 높다\n", + " P[defect == \"마모\", 4] *= 1.6; P[defect == \"누유\", 4] *= 1.3\n", + " P /= P.sum(axis=1, keepdims=True)\n", + " cause = np.array([CAUSES[rng.choice(5, p=P[i])] for i in range(N)])\n", + " since = np.where(cause == \"정비 품질\", rng.exponential(35, N), rng.exponential(160, N)).clip(1, 720).round().astype(int)\n", + " wait = (rng.lognormal(1.3, 0.5, N) * np.where(vendor == \"외주B\", 1.7, np.where(vendor == \"부대정비\", 0.6, 1.0)) * (0.7 + 0.5 * use[month])).round(1)\n", + " reins = rng.random(N) < np.where(cause == \"정비 품질\", 0.6, 0.08)\n", + " texts = []\n", + " for i in range(N):\n", + " pt = PARTS[part[i]]\n", + " cl = [f\"{rng.choice(pt[1])}에서 {rng.choice(DEFECTS[defect[i]])}\"]\n", + " if rng.random() < 0.7: cl.append(rng.choice(SEV_CUE[sev[i]]))\n", + " if rng.random() < 0.65: # 원인 단서: 8%는 실제 원인과 다른 단서가 적힌다(보고자의 추정)\n", + " cc = cause[i] if rng.random() > 0.08 else rng.choice(CAUSES)\n", + " cl.append(rng.choice(CAUSE_CUE[cc]))\n", + " cl.append(rng.choice(ACTIONS))\n", + " if rng.random() < 0.5: cl.insert(0, f\"VU-{rng.integers(1, 31):02d} 소속\")\n", + " texts.append(\". \".join(cl) + \".\")\n", + " df = pd.DataFrame({\"qdr_id\": [f\"Q{seed}{i:06d}\" for i in range(N)], \"month\": month, \"unit\": [f\"VU-{x:02d}\" for x in rng.integers(1, 31, N)],\n", + " \"part\": part, \"defect\": defect, \"sev\": sev, \"cause\": cause, \"vendor\": vendor, \"maker\": [MAKER_OF[i] for i in part],\n", + " \"since_maint\": since, \"inspect_wait\": wait, \"reinspect\": reins, \"event\": ev, \"text\": texts})\n", + " df[\"cost\"] = PRICE[df[\"part\"]] * np.array([1.0, 2.0, 4.0])[df[\"sev\"]] # [교육용가상자료] 수리·조치 비용지수\n", + " return df, use, events\n", + "\n", + "# ---------------------------------------------------------------------------\n", + "# 공통 텍스트 조각: 정규화, 간이 토큰화(조사 제거), 부위·결함·치명도·원인 키워드 라벨링\n", + "# ---------------------------------------------------------------------------\n", + "TYPO = {\"누윳\": \"누유\", \"균렬\": \"균열\", \"접축 불량\": \"접촉 불량\", \"부씩\": \"부식\", \"마묘\": \"마모\", \"과렬\": \"과열\", \"오작똥\": \"오작동\", \"재동\": \"제동\"}\n", + "ABBR = {\"T/M\": \"변속기\", \"BAT\": \"배터리\"}\n", + "PART_KW = sorted([(a, i) for i, p in enumerate(PARTS) for a in p[1]], key=lambda x: -len(x[0]))\n", + "DEF_KW = {\"균열\": [\"균열\", \"크랙\", \"갈라\"], \"누유\": [\"누유\", \"새어\", \"누설\"], \"마모\": [\"마모\", \"닳은\"], \"부식\": [\"부식\", \"녹이\", \"산화\"],\n", + " \"접촉불량\": [\"접촉 불량\", \"단선\", \"끊김\"], \"체결불량\": [\"볼트\", \"체결\", \"헐거\"], \"과열\": [\"과열\", \"온도\", \"열이\"], \"오작동\": [\"오작동\", \"불안정\", \"멈춤\"]}\n", + "SEV_KW = [(2, [\"즉시 운용 중지\", \"사용 불가\", \"위험\"]), (1, [\"성능 저하\", \"조속한\"]), (0, [\"경미\", \"지장이 없\"])]\n", + "CAUSE_KW = {\"제조 불량\": [\"신품\", \"출고\", \"인수\"], \"정비 품질\": [\"정비 후\", \"재조립\", \"정비 직후\"], \"운용 부주의\": [\"충격\", \"과부하\", \"점검 없이\"],\n", + " \"설계 취약\": [\"동일 증상\", \"구조적\", \"여러 부대\"], \"자연 노후\": [\"사용 연한\", \"노후\", \"수명\"]}\n", + "PARTICLES = (\"에서\", \"으로\", \"이\", \"가\", \"은\", \"는\", \"을\", \"를\", \"에\", \"의\", \"로\", \"와\", \"과\", \"도\", \"함\", \"음\", \"됨\")\n", + "\n", + "\n", + "def norm(t):\n", + " \"\"\"정규화: 전각->반각, 특수문자·중복 공백 제거, 약어 확장, 오타 교정\"\"\"\n", + " # [TODO : 학생 실습 4] 텍스트 임베딩 및 토픽 모델링 기반 장비 구조적 품질 취약점 자동 군집화 모델 고도화 관련 핵심 로직을 구현하세요.\n", + " # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n", + " # <<< 여기에 코드를 작성하세요 >>>\n", + " pass # 작성 후 pass를 제거하세요.\n", + "def tok(t):\n", + " out = []\n", + " for w in re.split(r\"[\\s.,]+\", norm(t)):\n", + " if not w or re.fullmatch(r\"[\\d\\-A-Za-z]+\", w):\n", + " continue\n", + " for p in PARTICLES:\n", + " if len(w) > len(p) + 1 and w.endswith(p):\n", + " w = w[:-len(p)]\n", + " break\n", + " out.append(w)\n", + " return out\n", + "\n", + "\n", + "def label_part(t):\n", + " for a, i in PART_KW:\n", + " if a in t:\n", + " return i\n", + " return -1\n", + "\n", + "\n", + "def label_defect(t):\n", + " for d, ks in DEF_KW.items():\n", + " if any(k in t for k in ks):\n", + " return d\n", + " return \"미분류\"\n", + "\n", + "\n", + "def label_sev(t):\n", + " for s, ks in SEV_KW:\n", + " if any(k in t for k in ks):\n", + " return s\n", + " return -1\n", + "\n", + "\n", + "def label_cause(t):\n", + " for c, ks in CAUSE_KW.items():\n", + " if any(k in t for k in ks):\n", + " return c\n", + " return \"미상\"\n", + "\n", + "df, use, events = make_qdr(36, seed=1, scale=3.0)\n", + "names = [p[0] for p in PARTS]\n", + "print(f\"[교육용가상자료] QDR {len(df)}건, 36개월\")\n", + "print(\"(토픽 모델은 결함 서술 핵심 문장 '부위에서 증상'에 적용하고, 치명도·원인은 군집 해석에 사용)\")\n", + "\n", + "# 1) 텍스트 임베딩(TF-IDF) + NMF 토픽 모델링\n", + "STOP = set(w for a in ACTIONS for w in tok(a)) | {\"소속\"}\n", + "vec = TfidfVectorizer(tokenizer=lambda s: [w for w in tok(s) if w not in STOP], lowercase=False, token_pattern=None, min_df=5)\n", + "df[\"core\"] = df[\"text\"].map(lambda s: [x for x in s.split(\". \") if \"에서\" in x][0]) # 결함 서술 핵심 문장(부위+증상)\n", + "X = vec.fit_transform(df[\"core\"])\n", + "K = 36\n", + "nmf = NMF(n_components=K, init=\"nndsvd\", random_state=0, max_iter=400).fit(X)\n", + "Wd = nmf.transform(X)\n", + "df[\"topic\"] = Wd.argmax(axis=1)\n", + "vocab = np.array(vec.get_feature_names_out())\n", + "nmi_p = normalized_mutual_info_score(df[\"part\"], df[\"topic\"]); nmi_d = normalized_mutual_info_score(df[\"defect\"], df[\"topic\"]); nmi_pd = normalized_mutual_info_score(df[\"part\"].astype(str) + df[\"defect\"], df[\"topic\"])\n", + "print(f\"TF-IDF 어휘 {X.shape[1]}개, 토픽 {K}개: 군집-라벨 일치도(NMI) 부위 {nmi_p:.2f}, 결함 유형 {nmi_d:.2f}, 부위+결함 {nmi_pd:.2f}\")\n", + "base_acc = np.mean([label_part(t) == p for t, p in zip(df[\"text\"], df[\"part\"])])\n", + "print(f\" 참고: 키워드 규칙 부위 라벨 정확도 {base_acc:.1%}\")\n", + "g = df.groupby(\"topic\").agg(건수=(\"qdr_id\", \"size\"), critical=(\"sev\", lambda s: (s == 2).mean()), 설계취약=(\"cause\", lambda s: (s == \"설계 취약\").mean()),\n", + " 주부위=(\"part\", lambda s: names[s.mode()[0]]), 주결함=(\"defect\", lambda s: s.mode()[0]))\n", + "g[\"핵심어\"] = [\", \".join(vocab[np.argsort(-nmf.components_[k])[:4]]) for k in g.index]\n", + "g[\"취약지수\"] = g[\"건수\"] * (g[\"critical\"] * 9 + (1 - g[\"critical\"]) * 1.5)\n", + "print(\"\\n[취약 군집 상위 5] 토픽 | 건수 | Critical 비중 | 주 부위 | 주 결함 | 핵심어\")\n", + "for k, r in g.sort_values(\"취약지수\", ascending=False).head(5).iterrows():\n", + " print(f\" T{k:02d} {r['건수']:4d} {r['critical']:.0%} {r['주부위']} / {r['주결함']} [{r['핵심어']}]\")\n", + "struct = list(g[\"설계취약\"].sort_values(ascending=False).index[:8])\n", + "print(f\"설계 취약 비중 상위 토픽 {len(struct)}개(구조적 취약 후보): \" + \", \".join(f\"T{k:02d}\" for k in struct) + f\" -> 해당 토픽 QDR의 설계 취약 비중 {df.loc[df['topic'].isin(struct), 'cause'].eq('설계 취약').mean():.0%} (전체 {df['cause'].eq('설계 취약').mean():.0%})\")\n", + "\n", + "# 2) 부위별 월간 QDR 건수 기준 모델: 파생 피처 13개\n", + "top8 = list(df.groupby(\"part\").size().sort_values(ascending=False).index[:8])\n", + "mon = df.groupby([\"part\", \"month\"]).agg(n=(\"qdr_id\", \"size\"), crit=(\"sev\", lambda s: (s == 2).mean()), wait=(\"inspect_wait\", \"mean\"), vb=(\"vendor\", lambda s: (s == \"외주B\").mean()),\n", + " re=(\"reinspect\", \"mean\"), st=(\"topic\", lambda s: s.isin(struct).mean())).reset_index()\n", + "rows = []\n", + "for p in top8:\n", + " d = mon[mon[\"part\"] == p].set_index(\"month\").reindex(range(36)).fillna(0)\n", + " f = pd.DataFrame({\"part\": p, \"month\": range(36), \"n\": d[\"n\"].values})\n", + " f[\"lag1\"], f[\"lag2\"] = f[\"n\"].shift(1), f[\"n\"].shift(2)\n", + " f[\"roll3\"] = f[\"n\"].shift(1).rolling(3).mean()\n", + " f[\"exp_count\"] = (f[\"n\"] / use).shift(1).rolling(6, min_periods=3).mean() * use # 최근 6개월 평균 발생률 x 이번 달 훈련 강도\n", + " f[\"usage\"], f[\"usage_lag1\"] = use, np.r_[use[0], use[:-1]]\n", + " f[\"season_c\"], f[\"season_s\"] = np.cos(2 * np.pi * f[\"month\"] / 12), np.sin(2 * np.pi * f[\"month\"] / 12)\n", + " f[\"trend\"] = f[\"month\"]\n", + " for c in (\"crit\", \"wait\", \"vb\", \"re\", \"st\"):\n", + " f[c + \"_lag1\"] = d[c].shift(1).values\n", + " rows.append(f)\n", + "F = pd.concat(rows, ignore_index=True).dropna()\n", + "DER = [\"lag1\", \"lag2\", \"roll3\", \"exp_count\", \"usage\", \"usage_lag1\", \"season_c\", \"season_s\", \"trend\", \"crit_lag1\", \"wait_lag1\", \"vb_lag1\", \"re_lag1\", \"st_lag1\"]\n", + "print(f\"\\n월간 건수 예측: 파생 피처 {len(DER)}개 (목표 10개 이상: {'달성' if len(DER) >= 10 else '미달'}), 대상 {len(top8)}개 부위 x {F['month'].nunique()}개월\")\n", + "\n", + "\n", + "def fit(d, cols):\n", + " return GradientBoostingRegressor(n_estimators=120, max_depth=3, learning_rate=0.08, subsample=0.8, random_state=0).fit(d[cols], d[\"n\"])\n", + "\n", + "\n", + "mape = lambda y, p: float(np.mean(np.abs(y - p) / y))\n", + "folds, naive = [], []\n", + "for c in (17, 23, 29): # 확장 윈도: 과거로만 학습, 뒤 6개월 검증\n", + " trn, val = F[F[\"month\"] <= c], F[(F[\"month\"] > c) & (F[\"month\"] <= c + 6)]\n", + " folds.append(mape(val[\"n\"], fit(trn, DER).predict(val[DER]))); naive.append(mape(val[\"n\"], val[\"lag1\"]))\n", + "print(f\"시계열 교차검증 폴드별 MAPE: {', '.join(f'{x:.1%}' for x in folds)} -> 평균 {np.mean(folds):.1%} (목표 25% 이하: {'달성' if np.mean(folds) <= 0.25 else '미달'}), 전월값 기준 평균 {np.mean(naive):.1%}\")\n", + "\n", + "# 3) 피처 중요도로 차원 축소\n", + "trn, val = F[F[\"month\"] <= 29], F[F[\"month\"] > 29]\n", + "full = fit(trn, DER)\n", + "pi = permutation_importance(full, val[DER], val[\"n\"], n_repeats=5, random_state=0, scoring=\"neg_mean_absolute_error\")\n", + "imp = pd.Series(pi.importances_mean, DER).sort_values(ascending=False)\n", + "print(\"피처 중요도 상위 5: \" + \", \".join(f\"{k}({v:.2f})\" for k, v in imp.head(5).items()))\n", + "top = list(imp.index[:7])\n", + "print(f\"차원 축소 {len(DER)}개 -> {len(top)}개: 검증 MAPE {mape(val['n'], full.predict(val[DER])):.1%} -> {mape(val['n'], fit(trn, top).predict(val[top])):.1%}\")\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 04 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + "[교육용가상자료] QDR 27675건, 36개월\n", + "(토픽 모델은 결함 서술 핵심 문장 '부위에서 증상'에 적용하고, 치명도·원인은 군집 해석에 사용)\n", + "TF-IDF 어휘 90개, 토픽 36개: 군집-라벨 일치도(NMI) 부위 0.45, 결함 유형 0.60, 부위+결함 0.63\n", + " 참고: 키워드 규칙 부위 라벨 정확도 100.0%\n", + "\n", + "[취약 군집 상위 5] 토픽 | 건수 | Critical 비중 | 주 부위 | 주 결함 | 핵심어\n", + " T03 1711 10% 엔진 연료계통 / 누유 [나옴, 새어, 오일, 라디에이터]\n", + " T24 1631 11% 변속기 / 마모 [닳은, 흔적, 있음, 산화]\n", + " T28 935 30% 조향 장치 / 체결불량 [조향, 로드, 장치, 크랙]\n", + " T02 1430 12% 냉각 계통 / 누유 [누유, 발생, 녹이, 라디에이터]\n", + " T25 1009 18% 조향 장치 / 접촉불량 [연동부, 핸들, 무전기, 접촉]\n", + "설계 취약 비중 상위 토픽 8개(구조적 취약 후보): T05, T28, T35, T13, T11, T25, T30, T02 -> 해당 토픽 QDR의 설계 취약 비중 21% (전체 17%)\n", + "\n", + "월간 건수 예측: 파생 피처 14개 (목표 10개 이상: 달성), 대상 8개 부위 x 33개월\n", + "시계열 교차검증 폴드별 MAPE: 27.8%, 13.9%, 16.9% -> 평균 19.5% (목표 25% 이하: 달성), 전월값 기준 평균 18.9%\n", + "피처 중요도 상위 5: lag1(6.77), usage(1.86), exp_count(1.78), st_lag1(0.81), lag2(0.68)\n", + "차원 축소 14개 -> 7개: 검증 MAPE 16.9% -> 15.3%\n", + "```\n", + "
\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 05. 결함 치명도(Critical/Major/Minor) 기반 종합 품질 취약성 지수 산출 엔진 개발\n", + "**소요시간**: 약 3.5시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "결함 치명도 기반 종합 품질 취약성 지수 산출 엔진을 개발한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **결함 치명도(Critical/Major/Minor)를 분류하고 치명도 가중 종합 품질 취약성 지수를 부위별로 산출·예측하는 엔진을 개발한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **키워드 규칙과 TF-IDF 로지스틱 회귀의 치명도 분류 정확도와 Critical 재현율을 비교하고 신뢰도 구간별 실제 정확도를 점검 수행하고 검증하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `src/models/qlt05_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": [ + "\"\"\"QDR 서술·부위·결함 정보로 개별 결함의 치명도(Critical/Major/Minor)를 분류하는 모델(키워드 규칙 기준 대비)과 신뢰도 보정을 확인하고, 치명도 가중 부위별 월간 종합 품질 취약성 지수를 앙상블(릿지에 그래디언트 부스팅·랜덤 포레스트 잔차 보정을 더한 모델)로 예측해 학습 기간 평균 기준 모델과 MAE·RMSE·R2를 비교하는 취약성 지수 산출 엔진을 구현한다.\"\"\"\n", + "import re\n", + "import unicodedata\n", + "import numpy as np\n", + "import pandas as pd\n", + "import scipy.sparse as sp\n", + "from sklearn.ensemble import GradientBoostingRegressor, RandomForestRegressor\n", + "from sklearn.feature_extraction.text import TfidfVectorizer\n", + "from sklearn.linear_model import LogisticRegression, Ridge\n", + "from sklearn.metrics import mean_absolute_error, mean_squared_error, r2_score\n", + "from sklearn.pipeline import make_pipeline\n", + "from sklearn.preprocessing import StandardScaler\n", + "\n", + "# ---------------------------------------------------------------------------\n", + "# [교육용가상자료] 가상 야전 장비 품질결함보고(QDR) 생성기 (실제 부대·장비·업체·정비 실적 없음)\n", + "# 가상 장비 부위 12종, 결함 유형 8종, 치명도(Critical/Major/Minor), 원인 5종, 가상 외주정비 업체 4곳 + 부대정비.\n", + "# QDR 1건마다 한국어 서술 텍스트를 만들고, 월별 건수는 계절·추세·훈련 강도(usage)·일부 제조 불량 이벤트를 따른다.\n", + "# ---------------------------------------------------------------------------\n", + "PARTS = [ # (이름, 별칭, 월 기준 건수, 계절 진폭, 정점 월, (Critical, Major 비율), 가상 단가지수, 제조사, 주요 결함 가중치)\n", + " (\"엔진 연료계통\", [\"연료계통\", \"연료 라인\", \"연료 펌프\"], 26, 0.15, 7, (0.10, 0.35), 6.0, \"MK1\", \"누유:4 마모:2 체결불량:2 오작동:2\"),\n", + " (\"변속기\", [\"변속기\", \"T/M\", \"미션\"], 22, 0.10, 8, (0.12, 0.38), 9.0, \"MK1\", \"마모:4 누유:2 과열:3 오작동:1\"),\n", + " (\"제동 장치\", [\"제동 장치\", \"브레이크\", \"제동 라인\"], 20, 0.20, 12, (0.26, 0.40), 5.0, \"MK2\", \"마모:4 균열:2 누유:2 체결불량:2\"),\n", + " (\"조향 장치\", [\"조향 장치\", \"핸들 연동부\", \"조향 로드\"], 14, 0.10, 6, (0.30, 0.40), 5.0, \"MK2\", \"체결불량:4 균열:2 마모:3 오작동:1\"),\n", + " (\"냉각 계통\", [\"냉각 계통\", \"라디에이터\", \"냉각수 라인\"], 24, 0.30, 7, (0.05, 0.30), 3.0, \"MK3\", \"누유:4 과열:4 부식:2\"),\n", + " (\"전기·배터리\", [\"배터리\", \"전기 계통\", \"발전기\"], 28, 0.35, 1, (0.04, 0.28), 2.5, \"MK3\", \"접촉불량:4 부식:2 과열:2 오작동:2\"),\n", + " (\"통신 모듈\", [\"통신 모듈\", \"무전기 연동부\", \"안테나 접속부\"], 16, 0.05, 4, (0.03, 0.25), 4.0, \"MK4\", \"접촉불량:5 오작동:4 부식:1\"),\n", + " (\"유압 계통\", [\"유압 계통\", \"유압 호스\", \"유압 실린더\"], 18, 0.10, 9, (0.10, 0.35), 4.5, \"MK2\", \"누유:5 균열:2 마모:2 체결불량:1\"),\n", + " (\"현가 장치\", [\"현가 장치\", \"서스펜션\", \"완충기\"], 15, 0.08, 5, (0.06, 0.30), 3.5, \"MK4\", \"마모:4 균열:3 체결불량:2 부식:1\"),\n", + " (\"차체 용접부\", [\"차체 용접부\", \"프레임 용접부\", \"차체 이음부\"], 10, 0.05, 10, (0.28, 0.40), 7.0, \"MK1\", \"균열:6 부식:3 체결불량:1\"),\n", + " (\"광학·조준 장비\", [\"광학 장비\", \"조준경\", \"관측 장비\"], 8, 0.05, 3, (0.03, 0.22), 8.0, \"MK4\", \"오작동:4 접촉불량:3 부식:2 체결불량:1\"),\n", + " (\"공기 정화 필터\", [\"공기 필터\", \"흡기 필터\", \"정화 장치\"], 12, 0.25, 4, (0.01, 0.12), 1.0, \"MK3\", \"마모:5 부식:2 체결불량:3\")]\n", + "NP = len(PARTS)\n", + "DEFECTS = { # 결함 유형: 증상 표현(동의어)\n", + " \"균열\": [\"균열이 발견됨\", \"크랙이 확인됨\", \"갈라짐이 있음\"], \"누유\": [\"누유가 발생함\", \"오일이 새어 나옴\", \"누설 흔적이 있음\"],\n", + " \"마모\": [\"마모가 심함\", \"과도한 마모가 확인됨\", \"닳은 흔적이 있음\"], \"부식\": [\"부식이 진행됨\", \"녹이 발생함\", \"산화 흔적이 있음\"],\n", + " \"접촉불량\": [\"접촉 불량이 발생함\", \"커넥터 단선이 의심됨\", \"신호가 끊김\"], \"체결불량\": [\"볼트 풀림이 확인됨\", \"체결 상태가 불량함\", \"고정부가 헐거움\"],\n", + " \"과열\": [\"과열 경고가 발생함\", \"온도가 과도하게 상승함\", \"열이 심하게 남\"], \"오작동\": [\"오작동이 반복됨\", \"작동이 불안정함\", \"간헐적으로 멈춤\"]}\n", + "DNAMES = list(DEFECTS)\n", + "SEV = [\"Minor\", \"Major\", \"Critical\"]\n", + "SEV_CUE = {0: [\"경미한 수준임\", \"운용에는 지장이 없음\", \"육안 점검상 사소한 이상임\", \"기능에는 영향이 없음\"],\n", + " 1: [\"성능 저하가 있음\", \"조속한 정비가 필요함\", \"출력이 떨어지는 상태임\", \"기능이 부분적으로 제한됨\"],\n", + " 2: [\"즉시 운용 중지함\", \"안전에 위험이 있어 사용 불가\", \"작동 정지로 임무 수행이 어려움\", \"인명 안전 위협이 우려됨\"]}\n", + "CAUSES = [\"제조 불량\", \"정비 품질\", \"운용 부주의\", \"설계 취약\", \"자연 노후\"]\n", + "CAUSE_CUE = {\n", + " \"제조 불량\": [\"신품 인수 직후 발생함\", \"출고 상태에서 이미 이상이 있었음\", \"인수 후 얼마 지나지 않아 발생함\"],\n", + " \"정비 품질\": [\"외주 정비 후 얼마 되지 않아 재발함\", \"정비 후 재조립 상태가 불량함\", \"정비 직후 같은 부위에서 다시 발생함\"],\n", + " \"운용 부주의\": [\"험지 기동 중 충격 이후 발생함\", \"과부하 운용 후 발생함\", \"점검 없이 연속 운용한 뒤 발생함\"],\n", + " \"설계 취약\": [\"동일 증상이 다른 차량에서도 반복됨\", \"구조적으로 취약한 부위로 보임\", \"여러 부대에서 같은 형태로 보고됨\"],\n", + " \"자연 노후\": [\"사용 연한이 도래함\", \"장기간 사용으로 노후됨\", \"수명 주기 말기에 발생함\"]}\n", + "VENDORS = [\"외주A\", \"외주B\", \"외주C\", \"외주D\", \"부대정비\"]\n", + "ACTIONS = [\"정비소 입고를 요청함\", \"현장 임시 조치 후 운용 중임\", \"부품 교환을 요청함\", \"재검사를 요청함\", \"원인 확인이 필요함\"]\n", + "MAKER_OF = [p[7] for p in PARTS]\n", + "PRICE = np.array([p[6] for p in PARTS])\n", + "\n", + "\n", + "def usage_index(months, seed=0):\n", + " \"\"\"월별 훈련 강도(미리 알 수 있는 계획 값): 연 1회 정점을 갖는 계절 + 작은 잡음\"\"\"\n", + " # [TODO : 학생 실습 5] 결함 치명도(Critical/Major/Minor) 기반 종합 품질 취약성 지수 산출 엔진 개발 관련 핵심 로직을 구현하세요.\n", + " # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n", + " # <<< 여기에 코드를 작성하세요 >>>\n", + " pass # 작성 후 pass를 제거하세요.\n", + "def make_qdr(months=36, seed=1, scale=1.0, surge=True):\n", + " \"\"\"반환: QDR DataFrame(건당 1행), 월별 usage, 급증 이벤트 목록\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " use = usage_index(months, seed)\n", + " events = []\n", + " if surge:\n", + " for _ in range(max(2, months // 12 * 2)):\n", + " events.append((int(rng.integers(NP)), int(rng.integers(6, months))))\n", + " rows = []\n", + " wdef = [np.array([float(x.split(\":\")[1]) for x in p[8].split()]) for p in PARTS]\n", + " ddef = [[x.split(\":\")[0] for x in p[8].split()] for p in PARTS]\n", + " n = 0\n", + " for m in range(months):\n", + " for pi, p in enumerate(PARTS):\n", + " lam = p[2] * scale * (1 + 0.010 * m) * use[m] * (1 + p[3] * np.cos(2 * np.pi * (m % 12 - p[4]) / 12))\n", + " ev = (pi, m) in events\n", + " k = rng.poisson(lam * (2.6 if ev else 1.0))\n", + " for _ in range(k):\n", + " rows.append((n, m, pi, ev)); n += 1\n", + " N = len(rows)\n", + " A = np.array(rows)\n", + " month, part, ev = A[:, 1], A[:, 2], A[:, 3].astype(bool)\n", + " defect = np.array([rng.choice(ddef[i], p=wdef[i] / wdef[i].sum()) for i in part])\n", + " cr = np.array([PARTS[i][5][0] for i in part]); ma = np.array([PARTS[i][5][1] for i in part])\n", + " u = rng.random(N)\n", + " sev = np.where(u < cr, 2, np.where(u < cr + ma, 1, 0))\n", + " vendor = rng.choice(VENDORS, N, p=[0.25, 0.2, 0.2, 0.15, 0.2])\n", + " # 원인: 기본 비율에 부위(제조사)·업체·이벤트 효과를 곱한다\n", + " base = np.array([0.22, 0.20, 0.20, 0.16, 0.22])\n", + " P = np.tile(base, (N, 1))\n", + " P[vendor == \"외주B\", 1] *= 2.6\n", + " P[ev, 0] *= 5.0\n", + " design = np.isin(part, [2, 3, 7]); P[design, 3] *= 2.2\n", + " P[np.isin(part, [4, 5, 11]), 0] *= 2.0 # 제조사 MK3 납품 부위는 제조 불량 비중이 높다\n", + " P[defect == \"마모\", 4] *= 1.6; P[defect == \"누유\", 4] *= 1.3\n", + " P /= P.sum(axis=1, keepdims=True)\n", + " cause = np.array([CAUSES[rng.choice(5, p=P[i])] for i in range(N)])\n", + " since = np.where(cause == \"정비 품질\", rng.exponential(35, N), rng.exponential(160, N)).clip(1, 720).round().astype(int)\n", + " wait = (rng.lognormal(1.3, 0.5, N) * np.where(vendor == \"외주B\", 1.7, np.where(vendor == \"부대정비\", 0.6, 1.0)) * (0.7 + 0.5 * use[month])).round(1)\n", + " reins = rng.random(N) < np.where(cause == \"정비 품질\", 0.6, 0.08)\n", + " texts = []\n", + " for i in range(N):\n", + " pt = PARTS[part[i]]\n", + " cl = [f\"{rng.choice(pt[1])}에서 {rng.choice(DEFECTS[defect[i]])}\"]\n", + " if rng.random() < 0.7: cl.append(rng.choice(SEV_CUE[sev[i]]))\n", + " if rng.random() < 0.65: # 원인 단서: 8%는 실제 원인과 다른 단서가 적힌다(보고자의 추정)\n", + " cc = cause[i] if rng.random() > 0.08 else rng.choice(CAUSES)\n", + " cl.append(rng.choice(CAUSE_CUE[cc]))\n", + " cl.append(rng.choice(ACTIONS))\n", + " if rng.random() < 0.5: cl.insert(0, f\"VU-{rng.integers(1, 31):02d} 소속\")\n", + " texts.append(\". \".join(cl) + \".\")\n", + " df = pd.DataFrame({\"qdr_id\": [f\"Q{seed}{i:06d}\" for i in range(N)], \"month\": month, \"unit\": [f\"VU-{x:02d}\" for x in rng.integers(1, 31, N)],\n", + " \"part\": part, \"defect\": defect, \"sev\": sev, \"cause\": cause, \"vendor\": vendor, \"maker\": [MAKER_OF[i] for i in part],\n", + " \"since_maint\": since, \"inspect_wait\": wait, \"reinspect\": reins, \"event\": ev, \"text\": texts})\n", + " df[\"cost\"] = PRICE[df[\"part\"]] * np.array([1.0, 2.0, 4.0])[df[\"sev\"]] # [교육용가상자료] 수리·조치 비용지수\n", + " return df, use, events\n", + "\n", + "# ---------------------------------------------------------------------------\n", + "# 공통 텍스트 조각: 정규화, 간이 토큰화(조사 제거), 부위·결함·치명도·원인 키워드 라벨링\n", + "# ---------------------------------------------------------------------------\n", + "TYPO = {\"누윳\": \"누유\", \"균렬\": \"균열\", \"접축 불량\": \"접촉 불량\", \"부씩\": \"부식\", \"마묘\": \"마모\", \"과렬\": \"과열\", \"오작똥\": \"오작동\", \"재동\": \"제동\"}\n", + "ABBR = {\"T/M\": \"변속기\", \"BAT\": \"배터리\"}\n", + "PART_KW = sorted([(a, i) for i, p in enumerate(PARTS) for a in p[1]], key=lambda x: -len(x[0]))\n", + "DEF_KW = {\"균열\": [\"균열\", \"크랙\", \"갈라\"], \"누유\": [\"누유\", \"새어\", \"누설\"], \"마모\": [\"마모\", \"닳은\"], \"부식\": [\"부식\", \"녹이\", \"산화\"],\n", + " \"접촉불량\": [\"접촉 불량\", \"단선\", \"끊김\"], \"체결불량\": [\"볼트\", \"체결\", \"헐거\"], \"과열\": [\"과열\", \"온도\", \"열이\"], \"오작동\": [\"오작동\", \"불안정\", \"멈춤\"]}\n", + "SEV_KW = [(2, [\"즉시 운용 중지\", \"사용 불가\", \"위험\"]), (1, [\"성능 저하\", \"조속한\"]), (0, [\"경미\", \"지장이 없\"])]\n", + "CAUSE_KW = {\"제조 불량\": [\"신품\", \"출고\", \"인수\"], \"정비 품질\": [\"정비 후\", \"재조립\", \"정비 직후\"], \"운용 부주의\": [\"충격\", \"과부하\", \"점검 없이\"],\n", + " \"설계 취약\": [\"동일 증상\", \"구조적\", \"여러 부대\"], \"자연 노후\": [\"사용 연한\", \"노후\", \"수명\"]}\n", + "PARTICLES = (\"에서\", \"으로\", \"이\", \"가\", \"은\", \"는\", \"을\", \"를\", \"에\", \"의\", \"로\", \"와\", \"과\", \"도\", \"함\", \"음\", \"됨\")\n", + "\n", + "\n", + "def norm(t):\n", + " \"\"\"정규화: 전각->반각, 특수문자·중복 공백 제거, 약어 확장, 오타 교정\"\"\"\n", + " # [TODO : 학생 실습 5] 결함 치명도(Critical/Major/Minor) 기반 종합 품질 취약성 지수 산출 엔진 개발 관련 핵심 로직을 구현하세요.\n", + " # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n", + " # <<< 여기에 코드를 작성하세요 >>>\n", + " pass # 작성 후 pass를 제거하세요.\n", + "def tok(t):\n", + " out = []\n", + " for w in re.split(r\"[\\s.,]+\", norm(t)):\n", + " if not w or re.fullmatch(r\"[\\d\\-A-Za-z]+\", w):\n", + " continue\n", + " for p in PARTICLES:\n", + " if len(w) > len(p) + 1 and w.endswith(p):\n", + " w = w[:-len(p)]\n", + " break\n", + " out.append(w)\n", + " return out\n", + "\n", + "\n", + "def label_part(t):\n", + " for a, i in PART_KW:\n", + " if a in t:\n", + " return i\n", + " return -1\n", + "\n", + "\n", + "def label_defect(t):\n", + " for d, ks in DEF_KW.items():\n", + " if any(k in t for k in ks):\n", + " return d\n", + " return \"미분류\"\n", + "\n", + "\n", + "def label_sev(t):\n", + " for s, ks in SEV_KW:\n", + " if any(k in t for k in ks):\n", + " return s\n", + " return -1\n", + "\n", + "\n", + "def label_cause(t):\n", + " for c, ks in CAUSE_KW.items():\n", + " if any(k in t for k in ks):\n", + " return c\n", + " return \"미상\"\n", + "\n", + "df, use, events = make_qdr(48, seed=1, scale=4.0)\n", + "names = [p[0] for p in PARTS]\n", + "W_SEV = np.array([1, 3, 9]) # 취약성 지수 가중치(Minor 1, Major 3, Critical 9)\n", + "tr, va = df[df[\"month\"] < 36], df[df[\"month\"] >= 36]\n", + "print(f\"[교육용가상자료] QDR {len(df)}건 (학습 36개월 {len(tr)}건, 검증 12개월 {len(va)}건)\")\n", + "\n", + "# 1) 개별 QDR 치명도 분류: 키워드 규칙 vs 텍스트+부위·결함 로지스틱 회귀\n", + "rule = va[\"text\"].map(label_sev).replace(-1, 0).values # 치명도 단서가 없으면 Minor로 간주\n", + "vec = TfidfVectorizer(tokenizer=tok, lowercase=False, token_pattern=None, min_df=5)\n", + "Xtr, Xva = vec.fit_transform(tr[\"text\"]), vec.transform(va[\"text\"])\n", + "oh = lambda d: sp.csr_matrix(np.hstack([np.eye(NP)[d[\"part\"]], pd.get_dummies(d[\"defect\"]).reindex(columns=DNAMES, fill_value=0).values.astype(float)]))\n", + "clf = LogisticRegression(C=5, max_iter=500).fit(sp.hstack([Xtr, oh(tr)]), tr[\"sev\"])\n", + "P = clf.predict_proba(sp.hstack([Xva, oh(va)]))\n", + "pred = P.argmax(axis=1)\n", + "acc_r, acc_m = (rule == va[\"sev\"].values).mean(), (pred == va[\"sev\"].values).mean()\n", + "rec = lambda p: ((p == 2) & (va[\"sev\"].values == 2)).sum() / (va[\"sev\"].values == 2).sum()\n", + "print(f\"치명도 분류 정확도: 키워드 규칙 {acc_r:.1%} -> 텍스트 모델 {acc_m:.1%}, Critical 재현율 {rec(rule):.1%} -> {rec(pred):.1%}\")\n", + "conf = P.max(axis=1)\n", + "print(\"[신뢰도 보정] 구간 | 건수 | 평균 신뢰도 | 실제 정확도\")\n", + "for a, b in [(0, 0.6), (0.6, 0.8), (0.8, 0.95), (0.95, 1.01)]:\n", + " m = (conf >= a) & (conf < b)\n", + " print(f\" {a:.2f}~{min(b, 1):.2f} {m.sum():5d} {conf[m].mean():.2f} {(pred[m] == va['sev'].values[m]).mean():.2f}\")\n", + "\n", + "# 2) 월간 부위별 취약성 지수 예측\n", + "g = df.assign(v=W_SEV[df[\"sev\"]]).groupby([\"part\", \"month\"]).agg(n=(\"v\", \"size\"), v=(\"v\", \"sum\")).reset_index()\n", + "rows = []\n", + "for p in range(NP):\n", + " d = g[g[\"part\"] == p].set_index(\"month\").reindex(range(48)).fillna(0)\n", + " f = pd.DataFrame({\"part\": p, \"month\": range(48), \"y\": d[\"v\"].values, \"n\": d[\"n\"].values})\n", + " f[\"lag1\"], f[\"lag2\"], f[\"lag12\"] = f[\"y\"].shift(1), f[\"y\"].shift(2), (f[\"y\"] / use).shift(12) * use # lag12: 작년 같은 달 발생률 x 이번 달 훈련 강도\n", + " f[\"exp_v\"] = (f[\"y\"] / use).shift(1).rolling(6, min_periods=3).mean() * use # 최근 6개월 발생률 x 이번 달 훈련 강도\n", + " f[\"usage\"], f[\"season_c\"], f[\"season_s\"], f[\"trend\"] = use, np.cos(2 * np.pi * f[\"month\"] / 12), np.sin(2 * np.pi * f[\"month\"] / 12), f[\"month\"]\n", + " f[\"price\"], f[\"crit_rate\"] = PRICE[p], PARTS[p][5][0]\n", + " rows.append(f)\n", + "F = pd.concat(rows, ignore_index=True).dropna()\n", + "FE = [\"lag1\", \"lag2\", \"lag12\", \"exp_v\", \"usage\", \"season_c\", \"season_s\", \"trend\", \"price\", \"crit_rate\"]\n", + "Ftr, Fva = F[F[\"month\"] < 42], F[F[\"month\"] >= 42]\n", + "base = Ftr.groupby(\"part\")[\"y\"].mean()\n", + "pb = Fva[\"part\"].map(base).values\n", + "\n", + "\n", + "def members(d_tr, depth, alpha):\n", + " \"\"\"릿지(선형 추세·계절)로 먼저 맞추고, 그래디언트 부스팅·랜덤 포레스트는 릿지 잔차를 보정하도록 학습한다.\"\"\"\n", + " rd = make_pipeline(StandardScaler(), Ridge(alpha=alpha)).fit(d_tr[FE], d_tr[\"y\"])\n", + " res = d_tr[\"y\"] - rd.predict(d_tr[FE])\n", + " gb = GradientBoostingRegressor(n_estimators=100, max_depth=depth, learning_rate=0.05, subsample=0.8, random_state=0).fit(d_tr[FE], res)\n", + " rf = RandomForestRegressor(n_estimators=40, max_depth=6, min_samples_leaf=5, n_jobs=-1, random_state=0).fit(d_tr[FE], res)\n", + " return [rd, gb, rf]\n", + "\n", + "\n", + "def blend(ms, d, w):\n", + " return ms[0].predict(d[FE]) + w[0] * ms[1].predict(d[FE]) + w[1] * ms[2].predict(d[FE])\n", + "\n", + "\n", + "GRID_W = [(a / 2, b / 2) for a in range(3) for b in range(3)] # 잔차 보정 가중치 격자(GBR, RF) 0, 0.5, 1\n", + "best, solo = (1e9,), {}\n", + "folds = [(Ftr[Ftr[\"month\"] < c], Ftr[(Ftr[\"month\"] >= c) & (Ftr[\"month\"] < c + 6)]) for c in (24, 30, 36)] # 확장 윈도 내부 검증 3폴드\n", + "for dp in (2, 3):\n", + " for al in (1, 10):\n", + " fitted = [(members(a_, dp, al), v) for a_, v in folds]\n", + " for w in GRID_W:\n", + " e_ = np.mean([mean_absolute_error(v[\"y\"], blend(ms, v, w)) for ms, v in fitted])\n", + " solo[(dp, al, w)] = e_\n", + " if e_ < best[0]:\n", + " best = (e_, dp, al, w)\n", + "_, dp, al, wt = best\n", + "if solo[(dp, al, (0.0, 0.0))] * 0.98 < best[0]: # 잔차 보정의 내부 검증 개선이 2% 미만이면 단순한 모델(릿지)을 채택(과적합 방지)\n", + " wt = (0.0, 0.0)\n", + "models = members(Ftr, dp, al)\n", + "pe = blend(models, Fva, wt)\n", + "ev = np.array([(int(a), int(b)) in set(events) for a, b in zip(Fva[\"part\"], Fva[\"month\"])]) # 제조 LOT 불량 등 급증 이벤트 월(별도 경보 대상)\n", + "keep = ~ev\n", + "y = Fva[\"y\"].values\n", + "met = lambda p, m=keep: (mean_absolute_error(y[m], p[m]), mean_squared_error(y[m], p[m]) ** 0.5, r2_score(y[m], p[m]))\n", + "mb, me = met(pb), met(pe)\n", + "print(f\"\\n[취약성 지수 예측] 학습 월 12~41, Hold-out 월 42~47 ({len(Fva)}건 중 급증 이벤트 월 {int(ev.sum())}건 제외 평가), 선택 GBR depth={dp}, ridge alpha={al}, 잔차 보정 가중치(GBR, RF)={wt}\")\n", + "print(\"모델 | MAE | RMSE | R2\")\n", + "print(f\" 기준(학습 기간 부위별 평균) {mb[0]:7.1f} {mb[1]:7.1f} {mb[2]:.3f}\")\n", + "print(f\" 전월값(참고) {met(Fva['lag1'].values)[0]:7.1f} {met(Fva['lag1'].values)[1]:7.1f} {met(Fva['lag1'].values)[2]:.3f}\")\n", + "x = met(models[0].predict(Fva[FE]))\n", + "print(f\" 릿지 단독 {x[0]:7.1f} {x[1]:7.1f} {x[2]:.3f}\")\n", + "print(f\" 고도화(선택 모델) {me[0]:7.1f} {me[1]:7.1f} {me[2]:.3f}\")\n", + "imp_mae, imp_rmse = 1 - me[0] / mb[0], 1 - me[1] / mb[1]\n", + "print(f\" (참고) 이벤트 월 포함 전체: 기준 MAE {met(pb, np.ones(len(y), bool))[0]:.1f} -> 앙상블 {met(pe, np.ones(len(y), bool))[0]:.1f}, R2 {met(pe, np.ones(len(y), bool))[2]:.3f}\")\n", + "print(f\"개선율 MAE {imp_mae:.1%}, RMSE {imp_rmse:.1%} (목표 20% 이상: {'달성' if min(imp_mae, imp_rmse) >= 0.2 else '미달'}), R2 {me[2]:.3f} (목표 0.85 이상: {'달성' if me[2] >= 0.85 else '미달'})\")\n", + "\n", + "# 3) 종합 품질 취약성 지수 순위(다음 달 예측 상위 5 부위) 카드\n", + "last = Fva[Fva[\"month\"] == 47].assign(pred=pe[Fva[\"month\"].values == 47]).sort_values(\"pred\", ascending=False)\n", + "print(\"\\n[취약성 지수 카드] 마지막 달 예측 상위 5 부위 | 예측 | 실제\")\n", + "for r in last.head(5).itertuples():\n", + " print(f\" {names[r.part]:10s} 예측 {r.pred:6.0f} 실제 {r.y:6.0f} {'(우선 점검 권고)' if r.pred >= last['pred'].median() * 1.5 else ''}\")\n", + "print(f\"예측 상위 5와 실제 상위 5 일치 {len(set(last.head(5)['part']) & set(last.sort_values('y', ascending=False).head(5)['part']))}/5\")\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 05 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + "[교육용가상자료] QDR 51700건 (학습 36개월 36834건, 검증 12개월 14866건)\n", + "치명도 분류 정확도: 키워드 규칙 72.0% -> 텍스트 모델 87.2%, Critical 재현율 34.7% -> 69.0%\n", + "[신뢰도 보정] 구간 | 건수 | 평균 신뢰도 | 실제 정확도\n", + " 0.00~0.60 2394 0.48 0.46\n", + " 0.60~0.80 1827 0.68 0.68\n", + " 0.80~0.95 264 0.86 0.89\n", + " 0.95~1.00 10381 1.00 1.00\n", + "\n", + "[취약성 지수 예측] 학습 월 12~41, Hold-out 월 42~47 (72건 중 급증 이벤트 월 1건 제외 평가), 선택 GBR depth=3, ridge alpha=10, 잔차 보정 가중치(GBR, RF)=(0.0, 0.0)\n", + "모델 | MAE | RMSE | R2\n", + " 기준(학습 기간 부위별 평균) 63.5 88.5 0.582\n", + " 전월값(참고) 51.1 74.9 0.700\n", + " 릿지 단독 39.3 51.8 0.857\n", + " 고도화(선택 모델) 39.3 51.8 0.857\n", + " (참고) 이벤트 월 포함 전체: 기준 MAE 68.6 -> 앙상블 43.9, R2 0.777\n", + "개선율 MAE 38.0%, RMSE 41.4% (목표 20% 이상: 달성), R2 0.857 (목표 0.85 이상: 달성)\n", + "\n", + "[취약성 지수 카드] 마지막 달 예측 상위 5 부위 | 예측 | 실제\n", + " 제동 장치 예측 409 실제 412 (우선 점검 권고)\n", + " 차체 용접부 예측 315 실제 171 \n", + " 엔진 연료계통 예측 304 실제 237 \n", + " 변속기 예측 298 실제 303 \n", + " 조향 장치 예측 272 실제 314 \n", + "예측 상위 5와 실제 상위 5 일치 3/5\n", + "```\n", + "
\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 06. 야전 QDR 발생 현황 및 장비 부위별 결함 집중도 히트맵 대시보드 구현\n", + "**소요시간**: 약 3.0시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "야전 QDR 발생 현황 및 장비 부위별 결함 히트맵 대시보드를 구현한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **야전 QDR 발생 현황과 부위별 결함 집중도를 히트맵·경보로 구성하고, 예측 건마다 기여도 상위 3개 인자와 자연어 요약을 자동 산출하는 설명가능성(XAI) 모듈을 구현한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **치명도 분류 모델의 서술 단어·부위·결함 유형 기여도를 계산해 건마다 상위 3개 근거를 뽑고, 1순위 근거를 제거했을 때 확률이 내려가는지로 유효성을 점검 수행하고 검증하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `src/explainability/qlt05_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": [ + "\"\"\"야전 QDR 발생 현황을 부위별·월별 히트맵과 집중도 경보로 구성하고, 치명도 분류 모델의 예측마다 기여도 상위 3개 인자(서술 단어·부위·결함 유형)를 자동 산출하며(근거 유효성 검증), 정비·품질 담당자가 읽을 수 있는 자연어 요약 문장을 생성하는 설명가능성(XAI) 모듈을 구현한다.\"\"\"\n", + "import re\n", + "import unicodedata\n", + "import numpy as np\n", + "import pandas as pd\n", + "import scipy.sparse as sp\n", + "import matplotlib.pyplot as plt\n", + "from sklearn.feature_extraction.text import TfidfVectorizer\n", + "from sklearn.linear_model import LogisticRegression\n", + "\n", + "# ---------------------------------------------------------------------------\n", + "# [교육용가상자료] 가상 야전 장비 품질결함보고(QDR) 생성기 (실제 부대·장비·업체·정비 실적 없음)\n", + "# 가상 장비 부위 12종, 결함 유형 8종, 치명도(Critical/Major/Minor), 원인 5종, 가상 외주정비 업체 4곳 + 부대정비.\n", + "# QDR 1건마다 한국어 서술 텍스트를 만들고, 월별 건수는 계절·추세·훈련 강도(usage)·일부 제조 불량 이벤트를 따른다.\n", + "# ---------------------------------------------------------------------------\n", + "PARTS = [ # (이름, 별칭, 월 기준 건수, 계절 진폭, 정점 월, (Critical, Major 비율), 가상 단가지수, 제조사, 주요 결함 가중치)\n", + " (\"엔진 연료계통\", [\"연료계통\", \"연료 라인\", \"연료 펌프\"], 26, 0.15, 7, (0.10, 0.35), 6.0, \"MK1\", \"누유:4 마모:2 체결불량:2 오작동:2\"),\n", + " (\"변속기\", [\"변속기\", \"T/M\", \"미션\"], 22, 0.10, 8, (0.12, 0.38), 9.0, \"MK1\", \"마모:4 누유:2 과열:3 오작동:1\"),\n", + " (\"제동 장치\", [\"제동 장치\", \"브레이크\", \"제동 라인\"], 20, 0.20, 12, (0.26, 0.40), 5.0, \"MK2\", \"마모:4 균열:2 누유:2 체결불량:2\"),\n", + " (\"조향 장치\", [\"조향 장치\", \"핸들 연동부\", \"조향 로드\"], 14, 0.10, 6, (0.30, 0.40), 5.0, \"MK2\", \"체결불량:4 균열:2 마모:3 오작동:1\"),\n", + " (\"냉각 계통\", [\"냉각 계통\", \"라디에이터\", \"냉각수 라인\"], 24, 0.30, 7, (0.05, 0.30), 3.0, \"MK3\", \"누유:4 과열:4 부식:2\"),\n", + " (\"전기·배터리\", [\"배터리\", \"전기 계통\", \"발전기\"], 28, 0.35, 1, (0.04, 0.28), 2.5, \"MK3\", \"접촉불량:4 부식:2 과열:2 오작동:2\"),\n", + " (\"통신 모듈\", [\"통신 모듈\", \"무전기 연동부\", \"안테나 접속부\"], 16, 0.05, 4, (0.03, 0.25), 4.0, \"MK4\", \"접촉불량:5 오작동:4 부식:1\"),\n", + " (\"유압 계통\", [\"유압 계통\", \"유압 호스\", \"유압 실린더\"], 18, 0.10, 9, (0.10, 0.35), 4.5, \"MK2\", \"누유:5 균열:2 마모:2 체결불량:1\"),\n", + " (\"현가 장치\", [\"현가 장치\", \"서스펜션\", \"완충기\"], 15, 0.08, 5, (0.06, 0.30), 3.5, \"MK4\", \"마모:4 균열:3 체결불량:2 부식:1\"),\n", + " (\"차체 용접부\", [\"차체 용접부\", \"프레임 용접부\", \"차체 이음부\"], 10, 0.05, 10, (0.28, 0.40), 7.0, \"MK1\", \"균열:6 부식:3 체결불량:1\"),\n", + " (\"광학·조준 장비\", [\"광학 장비\", \"조준경\", \"관측 장비\"], 8, 0.05, 3, (0.03, 0.22), 8.0, \"MK4\", \"오작동:4 접촉불량:3 부식:2 체결불량:1\"),\n", + " (\"공기 정화 필터\", [\"공기 필터\", \"흡기 필터\", \"정화 장치\"], 12, 0.25, 4, (0.01, 0.12), 1.0, \"MK3\", \"마모:5 부식:2 체결불량:3\")]\n", + "NP = len(PARTS)\n", + "DEFECTS = { # 결함 유형: 증상 표현(동의어)\n", + " \"균열\": [\"균열이 발견됨\", \"크랙이 확인됨\", \"갈라짐이 있음\"], \"누유\": [\"누유가 발생함\", \"오일이 새어 나옴\", \"누설 흔적이 있음\"],\n", + " \"마모\": [\"마모가 심함\", \"과도한 마모가 확인됨\", \"닳은 흔적이 있음\"], \"부식\": [\"부식이 진행됨\", \"녹이 발생함\", \"산화 흔적이 있음\"],\n", + " \"접촉불량\": [\"접촉 불량이 발생함\", \"커넥터 단선이 의심됨\", \"신호가 끊김\"], \"체결불량\": [\"볼트 풀림이 확인됨\", \"체결 상태가 불량함\", \"고정부가 헐거움\"],\n", + " \"과열\": [\"과열 경고가 발생함\", \"온도가 과도하게 상승함\", \"열이 심하게 남\"], \"오작동\": [\"오작동이 반복됨\", \"작동이 불안정함\", \"간헐적으로 멈춤\"]}\n", + "DNAMES = list(DEFECTS)\n", + "SEV = [\"Minor\", \"Major\", \"Critical\"]\n", + "SEV_CUE = {0: [\"경미한 수준임\", \"운용에는 지장이 없음\", \"육안 점검상 사소한 이상임\", \"기능에는 영향이 없음\"],\n", + " 1: [\"성능 저하가 있음\", \"조속한 정비가 필요함\", \"출력이 떨어지는 상태임\", \"기능이 부분적으로 제한됨\"],\n", + " 2: [\"즉시 운용 중지함\", \"안전에 위험이 있어 사용 불가\", \"작동 정지로 임무 수행이 어려움\", \"인명 안전 위협이 우려됨\"]}\n", + "CAUSES = [\"제조 불량\", \"정비 품질\", \"운용 부주의\", \"설계 취약\", \"자연 노후\"]\n", + "CAUSE_CUE = {\n", + " \"제조 불량\": [\"신품 인수 직후 발생함\", \"출고 상태에서 이미 이상이 있었음\", \"인수 후 얼마 지나지 않아 발생함\"],\n", + " \"정비 품질\": [\"외주 정비 후 얼마 되지 않아 재발함\", \"정비 후 재조립 상태가 불량함\", \"정비 직후 같은 부위에서 다시 발생함\"],\n", + " \"운용 부주의\": [\"험지 기동 중 충격 이후 발생함\", \"과부하 운용 후 발생함\", \"점검 없이 연속 운용한 뒤 발생함\"],\n", + " \"설계 취약\": [\"동일 증상이 다른 차량에서도 반복됨\", \"구조적으로 취약한 부위로 보임\", \"여러 부대에서 같은 형태로 보고됨\"],\n", + " \"자연 노후\": [\"사용 연한이 도래함\", \"장기간 사용으로 노후됨\", \"수명 주기 말기에 발생함\"]}\n", + "VENDORS = [\"외주A\", \"외주B\", \"외주C\", \"외주D\", \"부대정비\"]\n", + "ACTIONS = [\"정비소 입고를 요청함\", \"현장 임시 조치 후 운용 중임\", \"부품 교환을 요청함\", \"재검사를 요청함\", \"원인 확인이 필요함\"]\n", + "MAKER_OF = [p[7] for p in PARTS]\n", + "PRICE = np.array([p[6] for p in PARTS])\n", + "\n", + "\n", + "def usage_index(months, seed=0):\n", + " \"\"\"월별 훈련 강도(미리 알 수 있는 계획 값): 연 1회 정점을 갖는 계절 + 작은 잡음\"\"\"\n", + " # [TODO : 학생 실습 6] 야전 QDR 발생 현황 및 장비 부위별 결함 집중도 히트맵 대시보드 구현 관련 핵심 로직을 구현하세요.\n", + " # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n", + " # <<< 여기에 코드를 작성하세요 >>>\n", + " pass # 작성 후 pass를 제거하세요.\n", + "def make_qdr(months=36, seed=1, scale=1.0, surge=True):\n", + " \"\"\"반환: QDR DataFrame(건당 1행), 월별 usage, 급증 이벤트 목록\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " use = usage_index(months, seed)\n", + " events = []\n", + " if surge:\n", + " for _ in range(max(2, months // 12 * 2)):\n", + " events.append((int(rng.integers(NP)), int(rng.integers(6, months))))\n", + " rows = []\n", + " wdef = [np.array([float(x.split(\":\")[1]) for x in p[8].split()]) for p in PARTS]\n", + " ddef = [[x.split(\":\")[0] for x in p[8].split()] for p in PARTS]\n", + " n = 0\n", + " for m in range(months):\n", + " for pi, p in enumerate(PARTS):\n", + " lam = p[2] * scale * (1 + 0.010 * m) * use[m] * (1 + p[3] * np.cos(2 * np.pi * (m % 12 - p[4]) / 12))\n", + " ev = (pi, m) in events\n", + " k = rng.poisson(lam * (2.6 if ev else 1.0))\n", + " for _ in range(k):\n", + " rows.append((n, m, pi, ev)); n += 1\n", + " N = len(rows)\n", + " A = np.array(rows)\n", + " month, part, ev = A[:, 1], A[:, 2], A[:, 3].astype(bool)\n", + " defect = np.array([rng.choice(ddef[i], p=wdef[i] / wdef[i].sum()) for i in part])\n", + " cr = np.array([PARTS[i][5][0] for i in part]); ma = np.array([PARTS[i][5][1] for i in part])\n", + " u = rng.random(N)\n", + " sev = np.where(u < cr, 2, np.where(u < cr + ma, 1, 0))\n", + " vendor = rng.choice(VENDORS, N, p=[0.25, 0.2, 0.2, 0.15, 0.2])\n", + " # 원인: 기본 비율에 부위(제조사)·업체·이벤트 효과를 곱한다\n", + " base = np.array([0.22, 0.20, 0.20, 0.16, 0.22])\n", + " P = np.tile(base, (N, 1))\n", + " P[vendor == \"외주B\", 1] *= 2.6\n", + " P[ev, 0] *= 5.0\n", + " design = np.isin(part, [2, 3, 7]); P[design, 3] *= 2.2\n", + " P[np.isin(part, [4, 5, 11]), 0] *= 2.0 # 제조사 MK3 납품 부위는 제조 불량 비중이 높다\n", + " P[defect == \"마모\", 4] *= 1.6; P[defect == \"누유\", 4] *= 1.3\n", + " P /= P.sum(axis=1, keepdims=True)\n", + " cause = np.array([CAUSES[rng.choice(5, p=P[i])] for i in range(N)])\n", + " since = np.where(cause == \"정비 품질\", rng.exponential(35, N), rng.exponential(160, N)).clip(1, 720).round().astype(int)\n", + " wait = (rng.lognormal(1.3, 0.5, N) * np.where(vendor == \"외주B\", 1.7, np.where(vendor == \"부대정비\", 0.6, 1.0)) * (0.7 + 0.5 * use[month])).round(1)\n", + " reins = rng.random(N) < np.where(cause == \"정비 품질\", 0.6, 0.08)\n", + " texts = []\n", + " for i in range(N):\n", + " pt = PARTS[part[i]]\n", + " cl = [f\"{rng.choice(pt[1])}에서 {rng.choice(DEFECTS[defect[i]])}\"]\n", + " if rng.random() < 0.7: cl.append(rng.choice(SEV_CUE[sev[i]]))\n", + " if rng.random() < 0.65: # 원인 단서: 8%는 실제 원인과 다른 단서가 적힌다(보고자의 추정)\n", + " cc = cause[i] if rng.random() > 0.08 else rng.choice(CAUSES)\n", + " cl.append(rng.choice(CAUSE_CUE[cc]))\n", + " cl.append(rng.choice(ACTIONS))\n", + " if rng.random() < 0.5: cl.insert(0, f\"VU-{rng.integers(1, 31):02d} 소속\")\n", + " texts.append(\". \".join(cl) + \".\")\n", + " df = pd.DataFrame({\"qdr_id\": [f\"Q{seed}{i:06d}\" for i in range(N)], \"month\": month, \"unit\": [f\"VU-{x:02d}\" for x in rng.integers(1, 31, N)],\n", + " \"part\": part, \"defect\": defect, \"sev\": sev, \"cause\": cause, \"vendor\": vendor, \"maker\": [MAKER_OF[i] for i in part],\n", + " \"since_maint\": since, \"inspect_wait\": wait, \"reinspect\": reins, \"event\": ev, \"text\": texts})\n", + " df[\"cost\"] = PRICE[df[\"part\"]] * np.array([1.0, 2.0, 4.0])[df[\"sev\"]] # [교육용가상자료] 수리·조치 비용지수\n", + " return df, use, events\n", + "\n", + "# ---------------------------------------------------------------------------\n", + "# 공통 텍스트 조각: 정규화, 간이 토큰화(조사 제거), 부위·결함·치명도·원인 키워드 라벨링\n", + "# ---------------------------------------------------------------------------\n", + "TYPO = {\"누윳\": \"누유\", \"균렬\": \"균열\", \"접축 불량\": \"접촉 불량\", \"부씩\": \"부식\", \"마묘\": \"마모\", \"과렬\": \"과열\", \"오작똥\": \"오작동\", \"재동\": \"제동\"}\n", + "ABBR = {\"T/M\": \"변속기\", \"BAT\": \"배터리\"}\n", + "PART_KW = sorted([(a, i) for i, p in enumerate(PARTS) for a in p[1]], key=lambda x: -len(x[0]))\n", + "DEF_KW = {\"균열\": [\"균열\", \"크랙\", \"갈라\"], \"누유\": [\"누유\", \"새어\", \"누설\"], \"마모\": [\"마모\", \"닳은\"], \"부식\": [\"부식\", \"녹이\", \"산화\"],\n", + " \"접촉불량\": [\"접촉 불량\", \"단선\", \"끊김\"], \"체결불량\": [\"볼트\", \"체결\", \"헐거\"], \"과열\": [\"과열\", \"온도\", \"열이\"], \"오작동\": [\"오작동\", \"불안정\", \"멈춤\"]}\n", + "SEV_KW = [(2, [\"즉시 운용 중지\", \"사용 불가\", \"위험\"]), (1, [\"성능 저하\", \"조속한\"]), (0, [\"경미\", \"지장이 없\"])]\n", + "CAUSE_KW = {\"제조 불량\": [\"신품\", \"출고\", \"인수\"], \"정비 품질\": [\"정비 후\", \"재조립\", \"정비 직후\"], \"운용 부주의\": [\"충격\", \"과부하\", \"점검 없이\"],\n", + " \"설계 취약\": [\"동일 증상\", \"구조적\", \"여러 부대\"], \"자연 노후\": [\"사용 연한\", \"노후\", \"수명\"]}\n", + "PARTICLES = (\"에서\", \"으로\", \"이\", \"가\", \"은\", \"는\", \"을\", \"를\", \"에\", \"의\", \"로\", \"와\", \"과\", \"도\", \"함\", \"음\", \"됨\")\n", + "\n", + "\n", + "def norm(t):\n", + " \"\"\"정규화: 전각->반각, 특수문자·중복 공백 제거, 약어 확장, 오타 교정\"\"\"\n", + " # [TODO : 학생 실습 6] 야전 QDR 발생 현황 및 장비 부위별 결함 집중도 히트맵 대시보드 구현 관련 핵심 로직을 구현하세요.\n", + " # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n", + " # <<< 여기에 코드를 작성하세요 >>>\n", + " pass # 작성 후 pass를 제거하세요.\n", + "def tok(t):\n", + " out = []\n", + " for w in re.split(r\"[\\s.,]+\", norm(t)):\n", + " if not w or re.fullmatch(r\"[\\d\\-A-Za-z]+\", w):\n", + " continue\n", + " for p in PARTICLES:\n", + " if len(w) > len(p) + 1 and w.endswith(p):\n", + " w = w[:-len(p)]\n", + " break\n", + " out.append(w)\n", + " return out\n", + "\n", + "\n", + "def label_part(t):\n", + " for a, i in PART_KW:\n", + " if a in t:\n", + " return i\n", + " return -1\n", + "\n", + "\n", + "def label_defect(t):\n", + " for d, ks in DEF_KW.items():\n", + " if any(k in t for k in ks):\n", + " return d\n", + " return \"미분류\"\n", + "\n", + "\n", + "def label_sev(t):\n", + " for s, ks in SEV_KW:\n", + " if any(k in t for k in ks):\n", + " return s\n", + " return -1\n", + "\n", + "\n", + "def label_cause(t):\n", + " for c, ks in CAUSE_KW.items():\n", + " if any(k in t for k in ks):\n", + " return c\n", + " return \"미상\"\n", + "\n", + "df, use, events = make_qdr(36, seed=1, scale=2.0)\n", + "names = [p[0] for p in PARTS]\n", + "tr, va = df[df[\"month\"] < 24].reset_index(drop=True), df[df[\"month\"] >= 24].reset_index(drop=True)\n", + "vec = TfidfVectorizer(tokenizer=tok, lowercase=False, token_pattern=None, min_df=5)\n", + "oh = lambda d: sp.csr_matrix(np.hstack([np.eye(NP)[d[\"part\"]], pd.get_dummies(d[\"defect\"]).reindex(columns=DNAMES, fill_value=0).values.astype(float)]))\n", + "Xtr, Xva = sp.hstack([vec.fit_transform(tr[\"text\"]), oh(tr)]).tocsr(), sp.hstack([vec.transform(va[\"text\"]), oh(va)]).tocsr()\n", + "fnames = list(vec.get_feature_names_out()) + [f\"부위:{n}\" for n in names] + [f\"결함:{d}\" for d in DNAMES]\n", + "clf = LogisticRegression(C=5, max_iter=500).fit(Xtr, tr[\"sev\"])\n", + "P = clf.predict_proba(Xva); pred = P.argmax(axis=1)\n", + "print(f\"[교육용가상자료] QDR {len(df)}건, 검증 {len(va)}건 치명도 분류 정확도 {(pred == va['sev'].values).mean():.1%}\")\n", + "\n", + "# 1) 건마다 기여도 상위 3개 인자 (기여도 = 특성값 x 예측 클래스 계수)\n", + "co = clf.coef_\n", + "top3 = []\n", + "for i in range(Xva.shape[0]):\n", + " row = Xva.getrow(i)\n", + " contrib = row.data * co[pred[i], row.indices]\n", + " order = np.argsort(-contrib)[:3]\n", + " top3.append([(row.indices[j], contrib[j]) for j in order])\n", + "ok = sum(len(t) == 3 for t in top3)\n", + "print(f\"예측 {len(top3)}건 중 기여도 상위 3개 인자 산출 {ok}건 (산출률 {ok / len(top3):.0%}, 목표 100%: {'달성' if ok == len(top3) else '미달'})\")\n", + "\n", + "# 2) 근거 유효성: 1순위 인자를 제거하면 예측 클래스 확률이 낮아지는가 (표본 2,000건)\n", + "idx = np.random.default_rng(0).choice(len(top3), 2000, replace=False)\n", + "X2 = Xva[idx].tolil()\n", + "for r, i in enumerate(idx):\n", + " X2[r, top3[i][0][0]] = 0\n", + "P2 = clf.predict_proba(X2.tocsr())\n", + "drop = P2[np.arange(len(idx)), pred[idx]] < P[idx, pred[idx]]\n", + "print(f\"근거 유효성: 1순위 인자를 제거하면 예측 확률이 낮아지는 비율 {drop.mean():.1%} (평균 {P[idx, pred[idx]].mean():.2f} -> {P2[np.arange(len(idx)), pred[idx]].mean():.2f})\")\n", + "\n", + "\n", + "def summarize(i):\n", + " d = va.iloc[i]\n", + " f = [fnames[j] for j, _ in top3[i]]\n", + " return f\"{names[d['part']]}의 {d['defect']} 결함, 치명도 {SEV[pred[i]]} 가능성 {P[i, pred[i]]:.0%}. 주요 근거: \" + \", \".join(f\"'{x}'\" for x in f) + \".\"\n", + "\n", + "\n", + "# 3) 점검 카드 (치명도별 1건)\n", + "print(\"\\n[점검 카드] 치명도 | 실제 | 설명\")\n", + "for s in (2, 1, 0):\n", + " i = int(np.where((pred == s) & (va[\"sev\"].values == s) & (P.max(axis=1) > 0.9))[0][0])\n", + " print(f\" 예측 {SEV[s]:8s} (실제 {SEV[va.iloc[i]['sev']]})\")\n", + " print(\" \" + summarize(i))\n", + " print(\" 기여도: \" + \" / \".join(f\"{fnames[j]}({c:+.1f})\" for j, c in top3[i]))\n", + "\n", + "# 4) 발생 현황 히트맵(최근 6개월 x 부위)과 집중도 경보: 월 건수 / 학습 기간 월평균 > 1.4 이고 Critical 건이 있으면 경보\n", + "H = df[df[\"month\"] >= 30].pivot_table(index=\"part\", columns=\"month\", values=\"qdr_id\", aggfunc=\"size\", fill_value=0)\n", + "base = tr.groupby(\"part\").size() / 24\n", + "ratio = H.div(base, axis=0)\n", + "alerts = [(int(p), int(m), ratio.loc[p, m]) for p in H.index for m in H.columns if ratio.loc[p, m] > 1.4 and ((df[\"part\"] == p) & (df[\"month\"] == m) & (df[\"sev\"] == 2)).any()]\n", + "print(\"\\n[QDR 발생 히트맵] 부위 | 월 30~35 건수 | 학습 월평균 대비 최대 배수\")\n", + "for p in H.sum(axis=1).sort_values(ascending=False).index[:6]:\n", + " print(f\" {names[p]:10s} \" + \" \".join(f\"{v:4d}\" for v in H.loc[p]) + f\" x{ratio.loc[p].max():.2f}\")\n", + "print(f\"집중도 경보 {len(alerts)}건: \" + \", \".join(f\"{names[p]}({m}월, x{r:.1f})\" for p, m, r in sorted(alerts, key=lambda a: -a[2])[:4]))\n", + "\n", + "fig, ax = plt.subplots(1, 2, figsize=(11, 4))\n", + "im = ax[0].imshow(ratio.values, aspect=\"auto\", cmap=\"Reds\"); ax[0].set_yticks(range(NP)); ax[0].set_yticklabels(range(NP)); ax[0].set_title(\"QDR concentration (x baseline)\"); ax[0].set_xlabel(\"month\"); plt.colorbar(im, ax=ax[0])\n", + "ax[1].hist(P.max(axis=1), bins=20); ax[1].set_title(\"Prediction confidence\")\n", + "plt.tight_layout(); plt.show()\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 06 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + "[교육용가상자료] QDR 18434건, 검증 6823건 치명도 분류 정확도 87.6%\n", + "예측 6823건 중 기여도 상위 3개 인자 산출 6823건 (산출률 100%, 목표 100%: 달성)\n", + "근거 유효성: 1순위 인자를 제거하면 예측 확률이 낮아지는 비율 100.0% (평균 0.87 -> 0.80)\n", + "\n", + "[점검 카드] 치명도 | 실제 | 설명\n", + " 예측 Critical (실제 Critical)\n", + " 엔진 연료계통의 마모 결함, 치명도 Critical 가능성 99%. 주요 근거: '중지', '즉시', '운용'.\n", + " 기여도: 중지(+2.1) / 즉시(+2.1) / 운용(+0.3)\n", + " 예측 Major (실제 Major)\n", + " 엔진 연료계통의 체결불량 결함, 치명도 Major 가능성 99%. 주요 근거: '기능', '부분적', '제한'.\n", + " 기여도: 기능(+1.3) / 부분적(+1.3) / 제한(+1.3)\n", + " 예측 Minor (실제 Minor)\n", + " 엔진 연료계통의 체결불량 결함, 치명도 Minor 가능성 100%. 주요 근거: '없음', '기능에', '영향'.\n", + " 기여도: 없음(+1.7) / 기능에(+1.4) / 영향(+1.4)\n", + "\n", + "[QDR 발생 히트맵] 부위 | 월 30~35 건수 | 학습 월평균 대비 최대 배수\n", + " 냉각 계통 124 87 99 67 56 38 x2.27\n", + " 엔진 연료계통 99 89 87 75 58 49 x1.61\n", + " 전기·배터리 70 58 67 65 71 71 x1.14\n", + " 제동 장치 53 50 71 55 66 62 x1.50\n", + " 변속기 67 69 60 58 44 45 x1.42\n", + " 유압 계통 54 60 59 63 41 42 x1.57\n", + "집중도 경보 19건: 차체 용접부(34월, x3.0), 냉각 계통(30월, x2.3), 냉각 계통(32월, x1.8), 조향 장치(31월, x1.7)\n", + "```\n", + "
\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 07. 제조사 하자 구상 및 설계 변경 소요 제기를 위한 품질관 HITL 검토 모듈 설계\n", + "**소요시간**: 약 3.5시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "설계 변경 소요 제기를 위한 품질관 HITL 검토 모듈을 설계한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **제조사 하자 구상과 설계 변경 소요 제기(외주정비 시정 요구 포함)를 위해 품질관이 근거를 검토·승인하는 HITL 모듈을 설계하고 효과를 시뮬레이션으로 검증한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **최근 12개월 QDR의 부위별 원인 비중으로 하자 구상, 설계 변경, 외주정비 시정 후보를 제안하고 승인 대기열에 올린다 과정을 진행하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `src/optimization/qlt05_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": [ + "\"\"\"최근 12개월 QDR 근거로 부위별 제조사 하자 구상(제조 불량 우세)과 설계 변경 소요 제기(설계 취약 우세) 후보를 제안하되 자동 확정하지 않고 품질관 검토 대기열에 올려 승인·반려·무응답(조치 보류)을 사유와 함께 해시 체인 감사 로그에 남기는 HITL 모듈을 시뮬레이션하고, 개별 사후 수리 대비 비용 절감률과 승인 지연·처리 용량·전시 전환 민감도를 분석한다.\"\"\"\n", + "import hashlib\n", + "import json\n", + "import numpy as np\n", + "import pandas as pd\n", + "import matplotlib.pyplot as plt\n", + "\n", + "# ---------------------------------------------------------------------------\n", + "# [교육용가상자료] 가상 야전 장비 품질결함보고(QDR) 생성기 (실제 부대·장비·업체·정비 실적 없음)\n", + "# 가상 장비 부위 12종, 결함 유형 8종, 치명도(Critical/Major/Minor), 원인 5종, 가상 외주정비 업체 4곳 + 부대정비.\n", + "# QDR 1건마다 한국어 서술 텍스트를 만들고, 월별 건수는 계절·추세·훈련 강도(usage)·일부 제조 불량 이벤트를 따른다.\n", + "# ---------------------------------------------------------------------------\n", + "PARTS = [ # (이름, 별칭, 월 기준 건수, 계절 진폭, 정점 월, (Critical, Major 비율), 가상 단가지수, 제조사, 주요 결함 가중치)\n", + " (\"엔진 연료계통\", [\"연료계통\", \"연료 라인\", \"연료 펌프\"], 26, 0.15, 7, (0.10, 0.35), 6.0, \"MK1\", \"누유:4 마모:2 체결불량:2 오작동:2\"),\n", + " (\"변속기\", [\"변속기\", \"T/M\", \"미션\"], 22, 0.10, 8, (0.12, 0.38), 9.0, \"MK1\", \"마모:4 누유:2 과열:3 오작동:1\"),\n", + " (\"제동 장치\", [\"제동 장치\", \"브레이크\", \"제동 라인\"], 20, 0.20, 12, (0.26, 0.40), 5.0, \"MK2\", \"마모:4 균열:2 누유:2 체결불량:2\"),\n", + " (\"조향 장치\", [\"조향 장치\", \"핸들 연동부\", \"조향 로드\"], 14, 0.10, 6, (0.30, 0.40), 5.0, \"MK2\", \"체결불량:4 균열:2 마모:3 오작동:1\"),\n", + " (\"냉각 계통\", [\"냉각 계통\", \"라디에이터\", \"냉각수 라인\"], 24, 0.30, 7, (0.05, 0.30), 3.0, \"MK3\", \"누유:4 과열:4 부식:2\"),\n", + " (\"전기·배터리\", [\"배터리\", \"전기 계통\", \"발전기\"], 28, 0.35, 1, (0.04, 0.28), 2.5, \"MK3\", \"접촉불량:4 부식:2 과열:2 오작동:2\"),\n", + " (\"통신 모듈\", [\"통신 모듈\", \"무전기 연동부\", \"안테나 접속부\"], 16, 0.05, 4, (0.03, 0.25), 4.0, \"MK4\", \"접촉불량:5 오작동:4 부식:1\"),\n", + " (\"유압 계통\", [\"유압 계통\", \"유압 호스\", \"유압 실린더\"], 18, 0.10, 9, (0.10, 0.35), 4.5, \"MK2\", \"누유:5 균열:2 마모:2 체결불량:1\"),\n", + " (\"현가 장치\", [\"현가 장치\", \"서스펜션\", \"완충기\"], 15, 0.08, 5, (0.06, 0.30), 3.5, \"MK4\", \"마모:4 균열:3 체결불량:2 부식:1\"),\n", + " (\"차체 용접부\", [\"차체 용접부\", \"프레임 용접부\", \"차체 이음부\"], 10, 0.05, 10, (0.28, 0.40), 7.0, \"MK1\", \"균열:6 부식:3 체결불량:1\"),\n", + " (\"광학·조준 장비\", [\"광학 장비\", \"조준경\", \"관측 장비\"], 8, 0.05, 3, (0.03, 0.22), 8.0, \"MK4\", \"오작동:4 접촉불량:3 부식:2 체결불량:1\"),\n", + " (\"공기 정화 필터\", [\"공기 필터\", \"흡기 필터\", \"정화 장치\"], 12, 0.25, 4, (0.01, 0.12), 1.0, \"MK3\", \"마모:5 부식:2 체결불량:3\")]\n", + "NP = len(PARTS)\n", + "DEFECTS = { # 결함 유형: 증상 표현(동의어)\n", + " \"균열\": [\"균열이 발견됨\", \"크랙이 확인됨\", \"갈라짐이 있음\"], \"누유\": [\"누유가 발생함\", \"오일이 새어 나옴\", \"누설 흔적이 있음\"],\n", + " \"마모\": [\"마모가 심함\", \"과도한 마모가 확인됨\", \"닳은 흔적이 있음\"], \"부식\": [\"부식이 진행됨\", \"녹이 발생함\", \"산화 흔적이 있음\"],\n", + " \"접촉불량\": [\"접촉 불량이 발생함\", \"커넥터 단선이 의심됨\", \"신호가 끊김\"], \"체결불량\": [\"볼트 풀림이 확인됨\", \"체결 상태가 불량함\", \"고정부가 헐거움\"],\n", + " \"과열\": [\"과열 경고가 발생함\", \"온도가 과도하게 상승함\", \"열이 심하게 남\"], \"오작동\": [\"오작동이 반복됨\", \"작동이 불안정함\", \"간헐적으로 멈춤\"]}\n", + "DNAMES = list(DEFECTS)\n", + "SEV = [\"Minor\", \"Major\", \"Critical\"]\n", + "SEV_CUE = {0: [\"경미한 수준임\", \"운용에는 지장이 없음\", \"육안 점검상 사소한 이상임\", \"기능에는 영향이 없음\"],\n", + " 1: [\"성능 저하가 있음\", \"조속한 정비가 필요함\", \"출력이 떨어지는 상태임\", \"기능이 부분적으로 제한됨\"],\n", + " 2: [\"즉시 운용 중지함\", \"안전에 위험이 있어 사용 불가\", \"작동 정지로 임무 수행이 어려움\", \"인명 안전 위협이 우려됨\"]}\n", + "CAUSES = [\"제조 불량\", \"정비 품질\", \"운용 부주의\", \"설계 취약\", \"자연 노후\"]\n", + "CAUSE_CUE = {\n", + " \"제조 불량\": [\"신품 인수 직후 발생함\", \"출고 상태에서 이미 이상이 있었음\", \"인수 후 얼마 지나지 않아 발생함\"],\n", + " \"정비 품질\": [\"외주 정비 후 얼마 되지 않아 재발함\", \"정비 후 재조립 상태가 불량함\", \"정비 직후 같은 부위에서 다시 발생함\"],\n", + " \"운용 부주의\": [\"험지 기동 중 충격 이후 발생함\", \"과부하 운용 후 발생함\", \"점검 없이 연속 운용한 뒤 발생함\"],\n", + " \"설계 취약\": [\"동일 증상이 다른 차량에서도 반복됨\", \"구조적으로 취약한 부위로 보임\", \"여러 부대에서 같은 형태로 보고됨\"],\n", + " \"자연 노후\": [\"사용 연한이 도래함\", \"장기간 사용으로 노후됨\", \"수명 주기 말기에 발생함\"]}\n", + "VENDORS = [\"외주A\", \"외주B\", \"외주C\", \"외주D\", \"부대정비\"]\n", + "ACTIONS = [\"정비소 입고를 요청함\", \"현장 임시 조치 후 운용 중임\", \"부품 교환을 요청함\", \"재검사를 요청함\", \"원인 확인이 필요함\"]\n", + "MAKER_OF = [p[7] for p in PARTS]\n", + "PRICE = np.array([p[6] for p in PARTS])\n", + "\n", + "\n", + "def usage_index(months, seed=0):\n", + " \"\"\"월별 훈련 강도(미리 알 수 있는 계획 값): 연 1회 정점을 갖는 계절 + 작은 잡음\"\"\"\n", + " # [TODO : 학생 실습 7] 제조사 하자 구상 및 설계 변경 소요 제기를 위한 품질관 HITL 검토 모듈 설계 관련 핵심 로직을 구현하세요.\n", + " # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n", + " # <<< 여기에 코드를 작성하세요 >>>\n", + " pass # 작성 후 pass를 제거하세요.\n", + "def make_qdr(months=36, seed=1, scale=1.0, surge=True):\n", + " \"\"\"반환: QDR DataFrame(건당 1행), 월별 usage, 급증 이벤트 목록\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " use = usage_index(months, seed)\n", + " events = []\n", + " if surge:\n", + " for _ in range(max(2, months // 12 * 2)):\n", + " events.append((int(rng.integers(NP)), int(rng.integers(6, months))))\n", + " rows = []\n", + " wdef = [np.array([float(x.split(\":\")[1]) for x in p[8].split()]) for p in PARTS]\n", + " ddef = [[x.split(\":\")[0] for x in p[8].split()] for p in PARTS]\n", + " n = 0\n", + " for m in range(months):\n", + " for pi, p in enumerate(PARTS):\n", + " lam = p[2] * scale * (1 + 0.010 * m) * use[m] * (1 + p[3] * np.cos(2 * np.pi * (m % 12 - p[4]) / 12))\n", + " ev = (pi, m) in events\n", + " k = rng.poisson(lam * (2.6 if ev else 1.0))\n", + " for _ in range(k):\n", + " rows.append((n, m, pi, ev)); n += 1\n", + " N = len(rows)\n", + " A = np.array(rows)\n", + " month, part, ev = A[:, 1], A[:, 2], A[:, 3].astype(bool)\n", + " defect = np.array([rng.choice(ddef[i], p=wdef[i] / wdef[i].sum()) for i in part])\n", + " cr = np.array([PARTS[i][5][0] for i in part]); ma = np.array([PARTS[i][5][1] for i in part])\n", + " u = rng.random(N)\n", + " sev = np.where(u < cr, 2, np.where(u < cr + ma, 1, 0))\n", + " vendor = rng.choice(VENDORS, N, p=[0.25, 0.2, 0.2, 0.15, 0.2])\n", + " # 원인: 기본 비율에 부위(제조사)·업체·이벤트 효과를 곱한다\n", + " base = np.array([0.22, 0.20, 0.20, 0.16, 0.22])\n", + " P = np.tile(base, (N, 1))\n", + " P[vendor == \"외주B\", 1] *= 2.6\n", + " P[ev, 0] *= 5.0\n", + " design = np.isin(part, [2, 3, 7]); P[design, 3] *= 2.2\n", + " P[np.isin(part, [4, 5, 11]), 0] *= 2.0 # 제조사 MK3 납품 부위는 제조 불량 비중이 높다\n", + " P[defect == \"마모\", 4] *= 1.6; P[defect == \"누유\", 4] *= 1.3\n", + " P /= P.sum(axis=1, keepdims=True)\n", + " cause = np.array([CAUSES[rng.choice(5, p=P[i])] for i in range(N)])\n", + " since = np.where(cause == \"정비 품질\", rng.exponential(35, N), rng.exponential(160, N)).clip(1, 720).round().astype(int)\n", + " wait = (rng.lognormal(1.3, 0.5, N) * np.where(vendor == \"외주B\", 1.7, np.where(vendor == \"부대정비\", 0.6, 1.0)) * (0.7 + 0.5 * use[month])).round(1)\n", + " reins = rng.random(N) < np.where(cause == \"정비 품질\", 0.6, 0.08)\n", + " texts = []\n", + " for i in range(N):\n", + " pt = PARTS[part[i]]\n", + " cl = [f\"{rng.choice(pt[1])}에서 {rng.choice(DEFECTS[defect[i]])}\"]\n", + " if rng.random() < 0.7: cl.append(rng.choice(SEV_CUE[sev[i]]))\n", + " if rng.random() < 0.65: # 원인 단서: 8%는 실제 원인과 다른 단서가 적힌다(보고자의 추정)\n", + " cc = cause[i] if rng.random() > 0.08 else rng.choice(CAUSES)\n", + " cl.append(rng.choice(CAUSE_CUE[cc]))\n", + " cl.append(rng.choice(ACTIONS))\n", + " if rng.random() < 0.5: cl.insert(0, f\"VU-{rng.integers(1, 31):02d} 소속\")\n", + " texts.append(\". \".join(cl) + \".\")\n", + " df = pd.DataFrame({\"qdr_id\": [f\"Q{seed}{i:06d}\" for i in range(N)], \"month\": month, \"unit\": [f\"VU-{x:02d}\" for x in rng.integers(1, 31, N)],\n", + " \"part\": part, \"defect\": defect, \"sev\": sev, \"cause\": cause, \"vendor\": vendor, \"maker\": [MAKER_OF[i] for i in part],\n", + " \"since_maint\": since, \"inspect_wait\": wait, \"reinspect\": reins, \"event\": ev, \"text\": texts})\n", + " df[\"cost\"] = PRICE[df[\"part\"]] * np.array([1.0, 2.0, 4.0])[df[\"sev\"]] # [교육용가상자료] 수리·조치 비용지수\n", + " return df, use, events\n", + "\n", + "df, use, events = make_qdr(48, seed=1, scale=1.5)\n", + "names = [p[0] for p in PARTS]\n", + "D0 = 24 # 검토 기준 월: 직전 12개월(12~23) 근거로 제안, 이후 24개월(24~47) 효과 평가\n", + "CLAIM_FIXED, CHANGE_FIXED, FIX_VENDOR = 15.0, 100.0, 50.0 # [교육용가상자료] 하자 구상 청구 처리비, 설계 변경 소요 제기·검토 비용지수\n", + "RECOVER, REDUCE, LAG, W_WAIT, REINSPECT = 0.8, 0.8, 3, 0.8, 6.0 # 하자 구상 회수율, 설계 변경 후 해당 원인 QDR 감소율, 설계 변경 반영 지연(개월)\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 proposals(d):\n", + " \"\"\"부위별로 근거(건수·치명도·원인 비중)를 집계해 제안 유형을 정한다.\"\"\"\n", + " # [TODO : 학생 실습 7] 제조사 하자 구상 및 설계 변경 소요 제기를 위한 품질관 HITL 검토 모듈 설계 관련 핵심 로직을 구현하세요.\n", + " # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n", + " # <<< 여기에 코드를 작성하세요 >>>\n", + " pass # 작성 후 pass를 제거하세요.\n", + "def reviewer(pr, rng, second=False):\n", + " \"\"\"[모의 품질관] 근거가 약하거나 운용·노후 원인이 우세하면 반려, 10%는 기한 내 무응답(재상정 시에는 응답).\"\"\"\n", + " if not second and rng.random() < 0.10:\n", + " return \"무응답\", \"기한 내 응답 없음 -> 조치 보류(기본 계획 유지)\"\n", + " if pr[\"noise\"] > 0.46:\n", + " return \"반려\", f\"운용·노후 원인 {pr['noise']:.0%}로 제조사 책임 근거 부족\"\n", + " if pr[\"share\"] < 0.235 and pr[\"kind\"] != \"외주정비 시정\":\n", + " return \"반려\", f\"원인 비중 {pr['share']:.0%}로 평균 수준, 추가 관찰\"\n", + " if pr[\"n\"] < 120 and pr[\"crit\"] < 0.10:\n", + " return \"반려\", f\"근거 {pr['n']}건, Critical {pr['crit']:.0%}로 추가 관찰\"\n", + " return \"승인\", f\"{pr['kind']} 근거 {pr['n']}건, 원인 비중 {pr['share']:.0%}, Critical {pr['crit']:.0%}\"\n", + "\n", + "\n", + "def effect(d, pr, delay, surge):\n", + " \"\"\"승인된 제안의 이후 12개월 효과(비용지수 이득): 하자 구상은 제조 불량 QDR 비용의 일부 회수, 설계 변경은 설계 취약 QDR 감소\"\"\"\n", + " f = d[(d[\"month\"] >= D0) & (d[\"part\"] == pr[\"part\"])] if pr[\"part\"] >= 0 else d[(d[\"month\"] >= D0) & (d[\"vendor\"] == pr[\"vendor\"])]\n", + " start = D0 + delay\n", + " if pr[\"kind\"] == \"외주정비 시정\":\n", + " g = f[(f[\"cause\"] == \"정비 품질\") & (f[\"month\"] >= start + 2)]\n", + " return 0.7 * (g[\"cost\"] + W_WAIT * g[\"inspect_wait\"] + REINSPECT * g[\"reinspect\"]).sum() * surge - FIX_VENDOR\n", + " if pr[\"kind\"] == \"하자 구상\":\n", + " c = f[(f[\"cause\"] == \"제조 불량\") & (f[\"month\"] >= start)][\"cost\"].sum() * surge\n", + " return RECOVER * c - CLAIM_FIXED\n", + " c = f[(f[\"cause\"] == \"설계 취약\") & (f[\"month\"] >= start + LAG)][\"cost\"].sum() * surge\n", + " return REDUCE * c - CHANGE_FIXED\n", + "\n", + "\n", + "def run(mode, delay=1, surge=1.0, cap=99, seed=0, audit=None, resubmit=True):\n", + " rng = np.random.default_rng(seed)\n", + " props = sorted(proposals(df), key=lambda x: -x[\"n\"] * (1 + 3 * x[\"crit\"]))\n", + " extra = 0\n", + " gain, dec, n_ok = 0.0, {\"승인\": 0, \"반려\": 0, \"무응답\": 0}, 0\n", + " for pr in props:\n", + " if mode == \"auto\":\n", + " d_, why = \"승인\", \"비교용 전부 적용\"\n", + " else:\n", + " audit.add(event=\"PROPOSE\", target=(names[pr[\"part\"]] if pr[\"part\"] >= 0 else pr[\"vendor\"]) + \"/\" + pr[\"kind\"], status=\"PENDING\")\n", + " d_, why = reviewer(pr, rng)\n", + " if d_ == \"승인\" and n_ok >= cap:\n", + " d_, why = \"무응답\", \"월 처리 용량 초과 -> 보류\"\n", + " tg = (names[pr[\"part\"]] if pr[\"part\"] >= 0 else pr[\"vendor\"]) + \"/\" + pr[\"kind\"]\n", + " audit.add(event=\"DECIDE\", target=tg, decision=d_, reason=why, by=\"품질관(모의)\")\n", + " extra = 0\n", + " if d_ == \"무응답\" and resubmit: # 무응답은 기본 계획 유지(조치 보류), 다음 분기 검토 회의에 재상정\n", + " audit.add(event=\"RESUBMIT\", target=tg, status=\"PENDING\", note=\"분기 검토 회의 재상정(자동 승인 아님)\")\n", + " d_, why = reviewer(pr, rng, second=True)\n", + " audit.add(event=\"DECIDE\", target=tg, decision=d_, reason=why + \"(재상정)\", by=\"품질관(모의)\")\n", + " extra = 3\n", + " dec[d_] += 1\n", + " if d_ == \"승인\":\n", + " n_ok += 1; gain += effect(df, pr, (delay + (extra if mode != \"auto\" else 0)) if mode != \"auto\" else 0, surge)\n", + " e = df[df[\"month\"] >= D0]\n", + " base = (e[\"cost\"].sum() + W_WAIT * e[\"inspect_wait\"].sum() + REINSPECT * e[\"reinspect\"].sum()) * surge\n", + " return gain, base, dec, len(props)\n", + "\n", + "\n", + "audit = Audit()\n", + "g_h, base, dec, n_prop = run(\"hitl\", seed=3, audit=audit)\n", + "g_a, _, _, _ = run(\"auto\")\n", + "print(f\"[교육용가상자료] QDR {len(df)}건, 검토 기준 월 {D0}, 후보 제안 {n_prop}건(하자 구상·설계 변경·외주정비 시정)\")\n", + "print(\"\\n[비용 비교] 방식 | 이후 24개월 비용지수 | 절감액\")\n", + "for nm, g in [(\"개별 사후 수리(기본 계획)\", 0), (\"제안 + 품질관 승인\", g_h), (\"제안 전부 적용(비교용)\", g_a)]:\n", + " print(f\" {nm:22s} {base - g:9.0f} {g:8.0f}\")\n", + "cut = g_h / base\n", + "print(f\"HITL 적용 비용 절감률 {cut:.1%} (목표 15% 이상: {'달성' if cut >= 0.15 else '미달'}), 비교용 전부 적용 {g_a / base:.1%}\")\n", + "print(f\"최종 결정 {sum(dec.values())}건: \" + \", \".join(f\"{k} {v}건\" for k, v in dec.items()))\n", + "tgt = {e[\"target\"] for e in audit.log if e[\"event\"] == \"PROPOSE\"}; dec_t = {e[\"target\"] for e in audit.log if e[\"event\"] == \"DECIDE\"}\n", + "n_re = sum(e[\"event\"] == \"RESUBMIT\" for e in audit.log)\n", + "print(f\"제안 {len(tgt)}건 중 결정 기록 {len(tgt & dec_t)}건(로깅률 {len(tgt & dec_t) / len(tgt):.0%}), 자동 승인 0건, 무응답 {n_re}건은 조치 보류 후 분기 회의 재상정\")\n", + "rej = [e[\"reason\"] for e in audit.log if e.get(\"decision\") == \"반려\"]\n", + "print(\"반려 사유 예:\", rej[0] if rej else \"-\")\n", + "print(f\"감사 로그 무결성: {'정상' if audit.verify() is None else '이상'}\", end=\", \")\n", + "audit.log[3][\"reason\"] = \"사후 수정\"\n", + "print(f\"사후 변조 시 seq {audit.verify()}에서 탐지\")\n", + "\n", + "print(\"\\n[민감도 1] 승인까지 지연(개월) -> 절감률\")\n", + "for dl in (0, 1, 3, 6, 9):\n", + " g, b, _, _ = run(\"hitl\", delay=dl, seed=3, audit=Audit())\n", + " print(f\" {dl}개월: {g / b:.1%}\")\n", + "print(\"[민감도 2] 품질관 처리 용량(승인 가능 건수) -> 절감률\")\n", + "for cp in (2, 5, 8, 99):\n", + " g, b, _, _ = run(\"hitl\", cap=cp, seed=3, audit=Audit())\n", + " print(f\" 최대 {cp if cp < 99 else '무제한'}건: {g / b:.1%}\")\n", + "print(\"[민감도 3] 전시 전환 가정(QDR 비용 1.5배 급증) -> 절감률\")\n", + "for sg in (1.0, 1.5, 2.0):\n", + " g, b, _, _ = run(\"hitl\", surge=sg, seed=3, audit=Audit())\n", + " print(f\" x{sg}: {g / b:.1%}\")\n", + "\n", + "plt.figure(figsize=(6, 4))\n", + "plt.bar([\"baseline\", \"HITL\", \"auto(ref)\"], [base, base - g_h, base - g_a]); plt.ylabel(\"cost index (24 months)\"); plt.title(\"Quality review HITL simulator\")\n", + "plt.tight_layout(); plt.show()\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 07 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + "[교육용가상자료] QDR 19316건, 검토 기준 월 24, 후보 제안 13건(하자 구상·설계 변경·외주정비 시정)\n", + "\n", + "[비용 비교] 방식 | 이후 24개월 비용지수 | 절감액\n", + " 개별 사후 수리(기본 계획) 144670 0\n", + " 제안 + 품질관 승인 122929 21741\n", + " 제안 전부 적용(비교용) 119586 25084\n", + "HITL 적용 비용 절감률 15.0% (목표 15% 이상: 달성), 비교용 전부 적용 17.3%\n", + "최종 결정 13건: 승인 12건, 반려 1건, 무응답 0건\n", + "제안 13건 중 결정 기록 13건(로깅률 100%), 자동 승인 0건, 무응답 2건은 조치 보류 후 분기 회의 재상정\n", + "반려 사유 예: 원인 비중 23%로 평균 수준, 추가 관찰\n", + "감사 로그 무결성: 정상, 사후 변조 시 seq 3에서 탐지\n", + "\n", + "[민감도 1] 승인까지 지연(개월) -> 절감률\n", + " 0개월: 15.9%\n", + " 1개월: 15.0%\n", + " 3개월: 13.6%\n", + " 6개월: 11.4%\n", + " 9개월: 9.2%\n", + "[민감도 2] 품질관 처리 용량(승인 가능 건수) -> 절감률\n", + " 최대 2건: 14.2%\n", + " 최대 5건: 14.4%\n", + " 최대 8건: 14.8%\n", + " 최대 무제한건: 15.0%\n", + "[민감도 3] 전시 전환 가정(QDR 비용 1.5배 급증) -> 절감률\n", + " x1.0: 15.0%\n", + " x1.5: 15.1%\n", + " x2.0: 15.2%\n", + "```\n", + "
\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 08. 10년치 대용량 야전 QDR 텍스트 데이터 일괄 분석 스트레스 테스트\n", + "**소요시간**: 약 3.0시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "대용량 야전 QDR 텍스트 데이터 일괄 분석 스트레스 테스트를 수행한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **10년(120개월)치 대용량 야전 QDR 텍스트를 일괄 분석하는 스트레스 시험을 수행해 처리 시간, 화면 구성 시간, 경보 정확도를 확인한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **가상 QDR 약 6만 건에 빈 서술, 인코딩 깨짐, 과대 길이 레코드를 섞어 1~10년 단위로 주입해 격리·유실 건수와 분석 시간을 센다 과정을 진행하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `src/dashboard/qlt05_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": [ + "\"\"\"10년(120개월)치 대용량 야전 QDR 텍스트를 한꺼번에 주입해 정규화·부위/결함/치명도 라벨링·월별 집계·집중도 경보를 일괄 분석하고, 규모별 처리 시간, 불량 레코드(빈 서술·인코딩 깨짐·과대 길이) 격리와 유실 0건, 대시보드 화면 구성·필터 응답 시간, 급증 이벤트에 대한 경보 정확도를 확인하는 스트레스 시험을 수행한다.\"\"\"\n", + "import re\n", + "import time\n", + "import unicodedata\n", + "import numpy as np\n", + "import pandas as pd\n", + "import matplotlib.pyplot as plt\n", + "\n", + "# ---------------------------------------------------------------------------\n", + "# [교육용가상자료] 가상 야전 장비 품질결함보고(QDR) 생성기 (실제 부대·장비·업체·정비 실적 없음)\n", + "# 가상 장비 부위 12종, 결함 유형 8종, 치명도(Critical/Major/Minor), 원인 5종, 가상 외주정비 업체 4곳 + 부대정비.\n", + "# QDR 1건마다 한국어 서술 텍스트를 만들고, 월별 건수는 계절·추세·훈련 강도(usage)·일부 제조 불량 이벤트를 따른다.\n", + "# ---------------------------------------------------------------------------\n", + "PARTS = [ # (이름, 별칭, 월 기준 건수, 계절 진폭, 정점 월, (Critical, Major 비율), 가상 단가지수, 제조사, 주요 결함 가중치)\n", + " (\"엔진 연료계통\", [\"연료계통\", \"연료 라인\", \"연료 펌프\"], 26, 0.15, 7, (0.10, 0.35), 6.0, \"MK1\", \"누유:4 마모:2 체결불량:2 오작동:2\"),\n", + " (\"변속기\", [\"변속기\", \"T/M\", \"미션\"], 22, 0.10, 8, (0.12, 0.38), 9.0, \"MK1\", \"마모:4 누유:2 과열:3 오작동:1\"),\n", + " (\"제동 장치\", [\"제동 장치\", \"브레이크\", \"제동 라인\"], 20, 0.20, 12, (0.26, 0.40), 5.0, \"MK2\", \"마모:4 균열:2 누유:2 체결불량:2\"),\n", + " (\"조향 장치\", [\"조향 장치\", \"핸들 연동부\", \"조향 로드\"], 14, 0.10, 6, (0.30, 0.40), 5.0, \"MK2\", \"체결불량:4 균열:2 마모:3 오작동:1\"),\n", + " (\"냉각 계통\", [\"냉각 계통\", \"라디에이터\", \"냉각수 라인\"], 24, 0.30, 7, (0.05, 0.30), 3.0, \"MK3\", \"누유:4 과열:4 부식:2\"),\n", + " (\"전기·배터리\", [\"배터리\", \"전기 계통\", \"발전기\"], 28, 0.35, 1, (0.04, 0.28), 2.5, \"MK3\", \"접촉불량:4 부식:2 과열:2 오작동:2\"),\n", + " (\"통신 모듈\", [\"통신 모듈\", \"무전기 연동부\", \"안테나 접속부\"], 16, 0.05, 4, (0.03, 0.25), 4.0, \"MK4\", \"접촉불량:5 오작동:4 부식:1\"),\n", + " (\"유압 계통\", [\"유압 계통\", \"유압 호스\", \"유압 실린더\"], 18, 0.10, 9, (0.10, 0.35), 4.5, \"MK2\", \"누유:5 균열:2 마모:2 체결불량:1\"),\n", + " (\"현가 장치\", [\"현가 장치\", \"서스펜션\", \"완충기\"], 15, 0.08, 5, (0.06, 0.30), 3.5, \"MK4\", \"마모:4 균열:3 체결불량:2 부식:1\"),\n", + " (\"차체 용접부\", [\"차체 용접부\", \"프레임 용접부\", \"차체 이음부\"], 10, 0.05, 10, (0.28, 0.40), 7.0, \"MK1\", \"균열:6 부식:3 체결불량:1\"),\n", + " (\"광학·조준 장비\", [\"광학 장비\", \"조준경\", \"관측 장비\"], 8, 0.05, 3, (0.03, 0.22), 8.0, \"MK4\", \"오작동:4 접촉불량:3 부식:2 체결불량:1\"),\n", + " (\"공기 정화 필터\", [\"공기 필터\", \"흡기 필터\", \"정화 장치\"], 12, 0.25, 4, (0.01, 0.12), 1.0, \"MK3\", \"마모:5 부식:2 체결불량:3\")]\n", + "NP = len(PARTS)\n", + "DEFECTS = { # 결함 유형: 증상 표현(동의어)\n", + " \"균열\": [\"균열이 발견됨\", \"크랙이 확인됨\", \"갈라짐이 있음\"], \"누유\": [\"누유가 발생함\", \"오일이 새어 나옴\", \"누설 흔적이 있음\"],\n", + " \"마모\": [\"마모가 심함\", \"과도한 마모가 확인됨\", \"닳은 흔적이 있음\"], \"부식\": [\"부식이 진행됨\", \"녹이 발생함\", \"산화 흔적이 있음\"],\n", + " \"접촉불량\": [\"접촉 불량이 발생함\", \"커넥터 단선이 의심됨\", \"신호가 끊김\"], \"체결불량\": [\"볼트 풀림이 확인됨\", \"체결 상태가 불량함\", \"고정부가 헐거움\"],\n", + " \"과열\": [\"과열 경고가 발생함\", \"온도가 과도하게 상승함\", \"열이 심하게 남\"], \"오작동\": [\"오작동이 반복됨\", \"작동이 불안정함\", \"간헐적으로 멈춤\"]}\n", + "DNAMES = list(DEFECTS)\n", + "SEV = [\"Minor\", \"Major\", \"Critical\"]\n", + "SEV_CUE = {0: [\"경미한 수준임\", \"운용에는 지장이 없음\", \"육안 점검상 사소한 이상임\", \"기능에는 영향이 없음\"],\n", + " 1: [\"성능 저하가 있음\", \"조속한 정비가 필요함\", \"출력이 떨어지는 상태임\", \"기능이 부분적으로 제한됨\"],\n", + " 2: [\"즉시 운용 중지함\", \"안전에 위험이 있어 사용 불가\", \"작동 정지로 임무 수행이 어려움\", \"인명 안전 위협이 우려됨\"]}\n", + "CAUSES = [\"제조 불량\", \"정비 품질\", \"운용 부주의\", \"설계 취약\", \"자연 노후\"]\n", + "CAUSE_CUE = {\n", + " \"제조 불량\": [\"신품 인수 직후 발생함\", \"출고 상태에서 이미 이상이 있었음\", \"인수 후 얼마 지나지 않아 발생함\"],\n", + " \"정비 품질\": [\"외주 정비 후 얼마 되지 않아 재발함\", \"정비 후 재조립 상태가 불량함\", \"정비 직후 같은 부위에서 다시 발생함\"],\n", + " \"운용 부주의\": [\"험지 기동 중 충격 이후 발생함\", \"과부하 운용 후 발생함\", \"점검 없이 연속 운용한 뒤 발생함\"],\n", + " \"설계 취약\": [\"동일 증상이 다른 차량에서도 반복됨\", \"구조적으로 취약한 부위로 보임\", \"여러 부대에서 같은 형태로 보고됨\"],\n", + " \"자연 노후\": [\"사용 연한이 도래함\", \"장기간 사용으로 노후됨\", \"수명 주기 말기에 발생함\"]}\n", + "VENDORS = [\"외주A\", \"외주B\", \"외주C\", \"외주D\", \"부대정비\"]\n", + "ACTIONS = [\"정비소 입고를 요청함\", \"현장 임시 조치 후 운용 중임\", \"부품 교환을 요청함\", \"재검사를 요청함\", \"원인 확인이 필요함\"]\n", + "MAKER_OF = [p[7] for p in PARTS]\n", + "PRICE = np.array([p[6] for p in PARTS])\n", + "\n", + "\n", + "def usage_index(months, seed=0):\n", + " \"\"\"월별 훈련 강도(미리 알 수 있는 계획 값): 연 1회 정점을 갖는 계절 + 작은 잡음\"\"\"\n", + " # [TODO : 학생 실습 8] 10년치 대용량 야전 QDR 텍스트 데이터 일괄 분석 스트레스 테스트 관련 핵심 로직을 구현하세요.\n", + " # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n", + " # <<< 여기에 코드를 작성하세요 >>>\n", + " pass # 작성 후 pass를 제거하세요.\n", + "def make_qdr(months=36, seed=1, scale=1.0, surge=True):\n", + " \"\"\"반환: QDR DataFrame(건당 1행), 월별 usage, 급증 이벤트 목록\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " use = usage_index(months, seed)\n", + " events = []\n", + " if surge:\n", + " for _ in range(max(2, months // 12 * 2)):\n", + " events.append((int(rng.integers(NP)), int(rng.integers(6, months))))\n", + " rows = []\n", + " wdef = [np.array([float(x.split(\":\")[1]) for x in p[8].split()]) for p in PARTS]\n", + " ddef = [[x.split(\":\")[0] for x in p[8].split()] for p in PARTS]\n", + " n = 0\n", + " for m in range(months):\n", + " for pi, p in enumerate(PARTS):\n", + " lam = p[2] * scale * (1 + 0.010 * m) * use[m] * (1 + p[3] * np.cos(2 * np.pi * (m % 12 - p[4]) / 12))\n", + " ev = (pi, m) in events\n", + " k = rng.poisson(lam * (2.6 if ev else 1.0))\n", + " for _ in range(k):\n", + " rows.append((n, m, pi, ev)); n += 1\n", + " N = len(rows)\n", + " A = np.array(rows)\n", + " month, part, ev = A[:, 1], A[:, 2], A[:, 3].astype(bool)\n", + " defect = np.array([rng.choice(ddef[i], p=wdef[i] / wdef[i].sum()) for i in part])\n", + " cr = np.array([PARTS[i][5][0] for i in part]); ma = np.array([PARTS[i][5][1] for i in part])\n", + " u = rng.random(N)\n", + " sev = np.where(u < cr, 2, np.where(u < cr + ma, 1, 0))\n", + " vendor = rng.choice(VENDORS, N, p=[0.25, 0.2, 0.2, 0.15, 0.2])\n", + " # 원인: 기본 비율에 부위(제조사)·업체·이벤트 효과를 곱한다\n", + " base = np.array([0.22, 0.20, 0.20, 0.16, 0.22])\n", + " P = np.tile(base, (N, 1))\n", + " P[vendor == \"외주B\", 1] *= 2.6\n", + " P[ev, 0] *= 5.0\n", + " design = np.isin(part, [2, 3, 7]); P[design, 3] *= 2.2\n", + " P[np.isin(part, [4, 5, 11]), 0] *= 2.0 # 제조사 MK3 납품 부위는 제조 불량 비중이 높다\n", + " P[defect == \"마모\", 4] *= 1.6; P[defect == \"누유\", 4] *= 1.3\n", + " P /= P.sum(axis=1, keepdims=True)\n", + " cause = np.array([CAUSES[rng.choice(5, p=P[i])] for i in range(N)])\n", + " since = np.where(cause == \"정비 품질\", rng.exponential(35, N), rng.exponential(160, N)).clip(1, 720).round().astype(int)\n", + " wait = (rng.lognormal(1.3, 0.5, N) * np.where(vendor == \"외주B\", 1.7, np.where(vendor == \"부대정비\", 0.6, 1.0)) * (0.7 + 0.5 * use[month])).round(1)\n", + " reins = rng.random(N) < np.where(cause == \"정비 품질\", 0.6, 0.08)\n", + " texts = []\n", + " for i in range(N):\n", + " pt = PARTS[part[i]]\n", + " cl = [f\"{rng.choice(pt[1])}에서 {rng.choice(DEFECTS[defect[i]])}\"]\n", + " if rng.random() < 0.7: cl.append(rng.choice(SEV_CUE[sev[i]]))\n", + " if rng.random() < 0.65: # 원인 단서: 8%는 실제 원인과 다른 단서가 적힌다(보고자의 추정)\n", + " cc = cause[i] if rng.random() > 0.08 else rng.choice(CAUSES)\n", + " cl.append(rng.choice(CAUSE_CUE[cc]))\n", + " cl.append(rng.choice(ACTIONS))\n", + " if rng.random() < 0.5: cl.insert(0, f\"VU-{rng.integers(1, 31):02d} 소속\")\n", + " texts.append(\". \".join(cl) + \".\")\n", + " df = pd.DataFrame({\"qdr_id\": [f\"Q{seed}{i:06d}\" for i in range(N)], \"month\": month, \"unit\": [f\"VU-{x:02d}\" for x in rng.integers(1, 31, N)],\n", + " \"part\": part, \"defect\": defect, \"sev\": sev, \"cause\": cause, \"vendor\": vendor, \"maker\": [MAKER_OF[i] for i in part],\n", + " \"since_maint\": since, \"inspect_wait\": wait, \"reinspect\": reins, \"event\": ev, \"text\": texts})\n", + " df[\"cost\"] = PRICE[df[\"part\"]] * np.array([1.0, 2.0, 4.0])[df[\"sev\"]] # [교육용가상자료] 수리·조치 비용지수\n", + " return df, use, events\n", + "\n", + "# ---------------------------------------------------------------------------\n", + "# 공통 텍스트 조각: 정규화, 간이 토큰화(조사 제거), 부위·결함·치명도·원인 키워드 라벨링\n", + "# ---------------------------------------------------------------------------\n", + "TYPO = {\"누윳\": \"누유\", \"균렬\": \"균열\", \"접축 불량\": \"접촉 불량\", \"부씩\": \"부식\", \"마묘\": \"마모\", \"과렬\": \"과열\", \"오작똥\": \"오작동\", \"재동\": \"제동\"}\n", + "ABBR = {\"T/M\": \"변속기\", \"BAT\": \"배터리\"}\n", + "PART_KW = sorted([(a, i) for i, p in enumerate(PARTS) for a in p[1]], key=lambda x: -len(x[0]))\n", + "DEF_KW = {\"균열\": [\"균열\", \"크랙\", \"갈라\"], \"누유\": [\"누유\", \"새어\", \"누설\"], \"마모\": [\"마모\", \"닳은\"], \"부식\": [\"부식\", \"녹이\", \"산화\"],\n", + " \"접촉불량\": [\"접촉 불량\", \"단선\", \"끊김\"], \"체결불량\": [\"볼트\", \"체결\", \"헐거\"], \"과열\": [\"과열\", \"온도\", \"열이\"], \"오작동\": [\"오작동\", \"불안정\", \"멈춤\"]}\n", + "SEV_KW = [(2, [\"즉시 운용 중지\", \"사용 불가\", \"위험\"]), (1, [\"성능 저하\", \"조속한\"]), (0, [\"경미\", \"지장이 없\"])]\n", + "CAUSE_KW = {\"제조 불량\": [\"신품\", \"출고\", \"인수\"], \"정비 품질\": [\"정비 후\", \"재조립\", \"정비 직후\"], \"운용 부주의\": [\"충격\", \"과부하\", \"점검 없이\"],\n", + " \"설계 취약\": [\"동일 증상\", \"구조적\", \"여러 부대\"], \"자연 노후\": [\"사용 연한\", \"노후\", \"수명\"]}\n", + "PARTICLES = (\"에서\", \"으로\", \"이\", \"가\", \"은\", \"는\", \"을\", \"를\", \"에\", \"의\", \"로\", \"와\", \"과\", \"도\", \"함\", \"음\", \"됨\")\n", + "\n", + "\n", + "def norm(t):\n", + " \"\"\"정규화: 전각->반각, 특수문자·중복 공백 제거, 약어 확장, 오타 교정\"\"\"\n", + " # [TODO : 학생 실습 8] 10년치 대용량 야전 QDR 텍스트 데이터 일괄 분석 스트레스 테스트 관련 핵심 로직을 구현하세요.\n", + " # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n", + " # <<< 여기에 코드를 작성하세요 >>>\n", + " pass # 작성 후 pass를 제거하세요.\n", + "def tok(t):\n", + " out = []\n", + " for w in re.split(r\"[\\s.,]+\", norm(t)):\n", + " if not w or re.fullmatch(r\"[\\d\\-A-Za-z]+\", w):\n", + " continue\n", + " for p in PARTICLES:\n", + " if len(w) > len(p) + 1 and w.endswith(p):\n", + " w = w[:-len(p)]\n", + " break\n", + " out.append(w)\n", + " return out\n", + "\n", + "\n", + "def label_part(t):\n", + " for a, i in PART_KW:\n", + " if a in t:\n", + " return i\n", + " return -1\n", + "\n", + "\n", + "def label_defect(t):\n", + " for d, ks in DEF_KW.items():\n", + " if any(k in t for k in ks):\n", + " return d\n", + " return \"미분류\"\n", + "\n", + "\n", + "def label_sev(t):\n", + " for s, ks in SEV_KW:\n", + " if any(k in t for k in ks):\n", + " return s\n", + " return -1\n", + "\n", + "\n", + "def label_cause(t):\n", + " for c, ks in CAUSE_KW.items():\n", + " if any(k in t for k in ks):\n", + " return c\n", + " return \"미상\"\n", + "\n", + "names = [p[0] for p in PARTS]\n", + "t0 = time.perf_counter()\n", + "full, use, events = make_qdr(120, seed=3, scale=1.5)\n", + "print(f\"[교육용가상자료] 10년(120개월) QDR {len(full)}건 생성({time.perf_counter() - t0:.1f}초, 분석 시간에는 포함하지 않음), 부위 {NP}종, 급증 이벤트 {len(events)}건\")\n", + "rng = np.random.default_rng(0)\n", + "RE_SPEC, RE_SPACE = re.compile(r\"[!#~*@^]+\"), re.compile(r\"\\s+\")\n", + "PART_RE = [(re.compile(\"|\".join(map(re.escape, p[1]))), i) for i, p in enumerate(PARTS)]\n", + "DEF_RE = [(d, re.compile(\"|\".join(ks))) for d, ks in DEF_KW.items()]\n", + "\n", + "\n", + "def analyze(d):\n", + " \"\"\"일괄 분석: 검증(격리) -> 정규화 -> 부위·결함·치명도 라벨링. 반환: (정상 결과, 격리 건수)\"\"\"\n", + " ok = d[\"text\"].str.len().between(5, 400) & ~d[\"text\"].str.contains(\"�\", regex=False)\n", + " g = d[ok].copy()\n", + " g[\"t\"] = g[\"text\"].str.replace(RE_SPEC, \" \", regex=True).str.replace(RE_SPACE, \" \", regex=True).str.strip()\n", + " g[\"p_lab\"] = -1\n", + " for rx, i in PART_RE:\n", + " g.loc[(g[\"p_lab\"] == -1) & g[\"t\"].str.contains(rx), \"p_lab\"] = i\n", + " g[\"d_lab\"] = \"미분류\"\n", + " for dn, rx in DEF_RE:\n", + " g.loc[(g[\"d_lab\"] == \"미분류\") & g[\"t\"].str.contains(rx), \"d_lab\"] = dn\n", + " g[\"s_lab\"] = np.where(g[\"t\"].str.contains(\"즉시 운용 중지|사용 불가|위험|임무 수행이 어려움|위협\"), 2, np.where(g[\"t\"].str.contains(\"성능 저하|조속한|출력이|부분적으로\"), 1, 0))\n", + " return g, int((~ok).sum())\n", + "\n", + "\n", + "def alarms(g, months):\n", + " \"\"\"월·부위 집계 후 직전 12개월 발생률(훈련 강도 보정) 대비 급증 경보\"\"\"\n", + " M = g.pivot_table(index=\"p_lab\", columns=\"month\", values=\"qdr_id\", aggfunc=\"size\", fill_value=0).reindex(index=range(NP), columns=range(months), fill_value=0)\n", + " rate = (M / use[:months]).T.rolling(12, min_periods=6).mean().shift(1).T\n", + " exp = rate * use[:months]\n", + " z = (M - exp) / np.sqrt(exp.clip(lower=1))\n", + " return M, (M / exp.clip(lower=1) > 1.9) & (z > 4)\n", + "\n", + "\n", + "def make_input(n_months, bad=0.01):\n", + " d = full[full[\"month\"] < n_months].copy()\n", + " k = int(len(d) * bad)\n", + " idx = rng.choice(len(d), k, replace=False)\n", + " d.iloc[idx[: k // 3], d.columns.get_loc(\"text\")] = \"\"\n", + " d.iloc[idx[k // 3: 2 * k // 3], d.columns.get_loc(\"text\")] = \"��� 오류\"\n", + " d.iloc[idx[2 * k // 3:], d.columns.get_loc(\"text\")] = \"가\" * 900\n", + " return d, k\n", + "\n", + "\n", + "print(\"\\n[기간별 일괄 분석] 기간 | 입력 | 정상 | 격리 | 유실 | 분석 시간 | 처리량\")\n", + "res = {}\n", + "for months in (12, 36, 72, 120):\n", + " d, k = make_input(months)\n", + " t = time.perf_counter()\n", + " g, q = analyze(d)\n", + " M, A = alarms(g, months)\n", + " sec = time.perf_counter() - t\n", + " res[months] = (g, M, A, sec)\n", + " print(f\" {months // 12:2d}년 {len(d):7d} {len(g):7d} {q:5d} {len(d) - len(g) - q:3d} {sec:6.2f}초 {len(d) / sec:8.0f}건/초\")\n", + "g, M, A, sec = res[120]\n", + "\n", + "# 라벨링 품질과 경보 정확도\n", + "tr_ = full.set_index(\"qdr_id\").loc[g[\"qdr_id\"]]\n", + "print(f\"\\n라벨링 정확도(10년치): 부위 {(g['p_lab'].values == tr_['part'].values).mean():.1%}, 결함 유형 {(g['d_lab'].values == tr_['defect'].values).mean():.1%}, 치명도 {(g['s_lab'].values == tr_['sev'].values).mean():.1%} (단서 없는 건은 Minor 처리)\")\n", + "truth = np.zeros((NP, 120), bool)\n", + "for p, m in events:\n", + " truth[p, m] = True\n", + "valid = ~np.isnan(A.values.astype(float)) & (np.arange(120) >= 12)[None, :]\n", + "al = A.values & valid\n", + "tp, fp, fn = (al & truth).sum(), (al & ~truth).sum(), (~al & truth & valid).sum()\n", + "acc = ((al == truth) & valid).sum() / valid.sum()\n", + "print(f\"급증 경보(12개월 이후 {int(valid.sum())}개 부위-월 셀): 경보 정확도 {acc:.1%} (목표 95% 이상: {'달성' if acc >= 0.95 else '미달'}), 재현율 {tp / max(tp + fn, 1):.0%}, 정밀도 {tp / max(tp + fp, 1):.0%}, 실제 이벤트 {int((truth & valid).sum())}건\")\n", + "\n", + "# 대시보드 화면 구성: 10년치 집계표에서 히트맵·상위 5 부위·경보 목록을 만드는 시간, 필터(부위·기간) 응답 시간\n", + "t = time.perf_counter()\n", + "heat = M.values.reshape(NP, 10, 12).sum(axis=2)\n", + "top5 = M.sum(axis=1).sort_values(ascending=False).head(5)\n", + "alert_list = [(names[p], m) for p, m in zip(*np.where(al))]\n", + "load = time.perf_counter() - t\n", + "t = time.perf_counter()\n", + "for p in range(NP):\n", + " sub = g[(g[\"p_lab\"] == p) & (g[\"month\"] >= 84)]\n", + " _ = sub.groupby(\"d_lab\").size()\n", + "flt = (time.perf_counter() - t) / NP\n", + "print(f\"화면 구성(연도별 히트맵·상위 5·경보 {len(alert_list)}건) {load * 1000:.0f}ms, 필터 1회 응답 {flt * 1000:.0f}ms (목표 2초 이내: {'달성' if max(load, flt) <= 2 else '미달'})\")\n", + "print(\"상위 5 부위: \" + \", \".join(f\"{names[p]} {v}\" for p, v in top5.items()))\n", + "print(\"운용 기준: 일괄 분석은 야간 배치로 집계표를 갱신하고 화면은 집계표만 읽는다. 처리가 느려지면 최근 36개월만 재계산하고 격리율이 5%를 넘으면 서술 입력 체계를 점검한다.\")\n", + "\n", + "plt.figure(figsize=(6, 4))\n", + "ms = sorted(res)\n", + "plt.plot([m // 12 for m in ms], [res[m][3] for m in ms], marker=\"o\"); plt.xlabel(\"years of QDR\"); plt.ylabel(\"analysis seconds\"); plt.title(\"Bulk analysis time\")\n", + "plt.tight_layout(); plt.show()\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 08 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + "[교육용가상자료] 10년(120개월) QDR 62293건 생성(6.5초, 분석 시간에는 포함하지 않음), 부위 12종, 급증 이벤트 20건\n", + "\n", + "[기간별 일괄 분석] 기간 | 입력 | 정상 | 격리 | 유실 | 분석 시간 | 처리량\n", + " 1년 4177 4136 41 0 0.14초 30805건/초\n", + " 3년 14068 13928 140 0 0.36초 39039건/초\n", + " 6년 32040 31720 320 0 0.77초 41671건/초\n", + " 10년 62293 61671 622 0 1.78초 34950건/초\n", + "\n", + "라벨링 정확도(10년치): 부위 100.0%, 결함 유형 100.0%, 치명도 87.0% (단서 없는 건은 Minor 처리)\n", + "급증 경보(12개월 이후 1296개 부위-월 셀): 경보 정확도 99.8% (목표 95% 이상: 달성), 재현율 90%, 정밀도 100%, 실제 이벤트 20건\n", + "화면 구성(연도별 히트맵·상위 5·경보 18건) 1ms, 필터 1회 응답 4ms (목표 2초 이내: 달성)\n", + "상위 5 부위: 엔진 연료계통 7848, 전기·배터리 7816, 냉각 계통 7095, 변속기 6301, 제동 장치 5667\n", + "운용 기준: 일괄 분석은 야간 배치로 집계표를 갱신하고 화면은 집계표만 읽는다. 처리가 느려지면 최근 36개월만 재계산하고 격리율이 5%를 넘으면 서술 입력 체계를 점검한다.\n", + "```\n", + "
\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 09. AAR 기반 결함 원인 오분류 분석 및 텍스트 마이닝 룰셋 재튜닝\n", + "**소요시간**: 약 3.0시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "AAR 기반 결함 원인 오분류를 분석하고 마이닝 룰셋을 재튜닝한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **AAR 기반으로 결함 원인 오분류 사례를 분석하고 텍스트 마이닝 룰셋을 보조 규칙으로 재튜닝해 효과를 지표로 산출한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **AAR 기간 원인 분류 오류를 원인 단서 없음과 단서-실제 원인 불일치로 나누고 대표 사례 정리하고 규격화하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `reports/qlt05_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 기간(12~23개월) QDR의 결함 원인 분류 오류를 원인별로 분석하고, 키워드 기반 텍스트 마이닝 룰셋에 외주 정비 후 경과일·업체·결함 유형 등 보조 규칙(결정 트리로 도출)을 더해 재튜닝한 뒤, 검증 기간(24~35개월)에서 정확도와 오분류 비용 변화를 확인하고 3대 정량 효과 지표(비용 절감, 확인 시간 단축, 제조·정비 책임 건 식별 증가)와 도입 로드맵을 산출한다.\"\"\"\n", + "import re\n", + "import unicodedata\n", + "import numpy as np\n", + "import pandas as pd\n", + "from sklearn.tree import DecisionTreeClassifier, export_text\n", + "\n", + "# ---------------------------------------------------------------------------\n", + "# [교육용가상자료] 가상 야전 장비 품질결함보고(QDR) 생성기 (실제 부대·장비·업체·정비 실적 없음)\n", + "# 가상 장비 부위 12종, 결함 유형 8종, 치명도(Critical/Major/Minor), 원인 5종, 가상 외주정비 업체 4곳 + 부대정비.\n", + "# QDR 1건마다 한국어 서술 텍스트를 만들고, 월별 건수는 계절·추세·훈련 강도(usage)·일부 제조 불량 이벤트를 따른다.\n", + "# ---------------------------------------------------------------------------\n", + "PARTS = [ # (이름, 별칭, 월 기준 건수, 계절 진폭, 정점 월, (Critical, Major 비율), 가상 단가지수, 제조사, 주요 결함 가중치)\n", + " (\"엔진 연료계통\", [\"연료계통\", \"연료 라인\", \"연료 펌프\"], 26, 0.15, 7, (0.10, 0.35), 6.0, \"MK1\", \"누유:4 마모:2 체결불량:2 오작동:2\"),\n", + " (\"변속기\", [\"변속기\", \"T/M\", \"미션\"], 22, 0.10, 8, (0.12, 0.38), 9.0, \"MK1\", \"마모:4 누유:2 과열:3 오작동:1\"),\n", + " (\"제동 장치\", [\"제동 장치\", \"브레이크\", \"제동 라인\"], 20, 0.20, 12, (0.26, 0.40), 5.0, \"MK2\", \"마모:4 균열:2 누유:2 체결불량:2\"),\n", + " (\"조향 장치\", [\"조향 장치\", \"핸들 연동부\", \"조향 로드\"], 14, 0.10, 6, (0.30, 0.40), 5.0, \"MK2\", \"체결불량:4 균열:2 마모:3 오작동:1\"),\n", + " (\"냉각 계통\", [\"냉각 계통\", \"라디에이터\", \"냉각수 라인\"], 24, 0.30, 7, (0.05, 0.30), 3.0, \"MK3\", \"누유:4 과열:4 부식:2\"),\n", + " (\"전기·배터리\", [\"배터리\", \"전기 계통\", \"발전기\"], 28, 0.35, 1, (0.04, 0.28), 2.5, \"MK3\", \"접촉불량:4 부식:2 과열:2 오작동:2\"),\n", + " (\"통신 모듈\", [\"통신 모듈\", \"무전기 연동부\", \"안테나 접속부\"], 16, 0.05, 4, (0.03, 0.25), 4.0, \"MK4\", \"접촉불량:5 오작동:4 부식:1\"),\n", + " (\"유압 계통\", [\"유압 계통\", \"유압 호스\", \"유압 실린더\"], 18, 0.10, 9, (0.10, 0.35), 4.5, \"MK2\", \"누유:5 균열:2 마모:2 체결불량:1\"),\n", + " (\"현가 장치\", [\"현가 장치\", \"서스펜션\", \"완충기\"], 15, 0.08, 5, (0.06, 0.30), 3.5, \"MK4\", \"마모:4 균열:3 체결불량:2 부식:1\"),\n", + " (\"차체 용접부\", [\"차체 용접부\", \"프레임 용접부\", \"차체 이음부\"], 10, 0.05, 10, (0.28, 0.40), 7.0, \"MK1\", \"균열:6 부식:3 체결불량:1\"),\n", + " (\"광학·조준 장비\", [\"광학 장비\", \"조준경\", \"관측 장비\"], 8, 0.05, 3, (0.03, 0.22), 8.0, \"MK4\", \"오작동:4 접촉불량:3 부식:2 체결불량:1\"),\n", + " (\"공기 정화 필터\", [\"공기 필터\", \"흡기 필터\", \"정화 장치\"], 12, 0.25, 4, (0.01, 0.12), 1.0, \"MK3\", \"마모:5 부식:2 체결불량:3\")]\n", + "NP = len(PARTS)\n", + "DEFECTS = { # 결함 유형: 증상 표현(동의어)\n", + " \"균열\": [\"균열이 발견됨\", \"크랙이 확인됨\", \"갈라짐이 있음\"], \"누유\": [\"누유가 발생함\", \"오일이 새어 나옴\", \"누설 흔적이 있음\"],\n", + " \"마모\": [\"마모가 심함\", \"과도한 마모가 확인됨\", \"닳은 흔적이 있음\"], \"부식\": [\"부식이 진행됨\", \"녹이 발생함\", \"산화 흔적이 있음\"],\n", + " \"접촉불량\": [\"접촉 불량이 발생함\", \"커넥터 단선이 의심됨\", \"신호가 끊김\"], \"체결불량\": [\"볼트 풀림이 확인됨\", \"체결 상태가 불량함\", \"고정부가 헐거움\"],\n", + " \"과열\": [\"과열 경고가 발생함\", \"온도가 과도하게 상승함\", \"열이 심하게 남\"], \"오작동\": [\"오작동이 반복됨\", \"작동이 불안정함\", \"간헐적으로 멈춤\"]}\n", + "DNAMES = list(DEFECTS)\n", + "SEV = [\"Minor\", \"Major\", \"Critical\"]\n", + "SEV_CUE = {0: [\"경미한 수준임\", \"운용에는 지장이 없음\", \"육안 점검상 사소한 이상임\", \"기능에는 영향이 없음\"],\n", + " 1: [\"성능 저하가 있음\", \"조속한 정비가 필요함\", \"출력이 떨어지는 상태임\", \"기능이 부분적으로 제한됨\"],\n", + " 2: [\"즉시 운용 중지함\", \"안전에 위험이 있어 사용 불가\", \"작동 정지로 임무 수행이 어려움\", \"인명 안전 위협이 우려됨\"]}\n", + "CAUSES = [\"제조 불량\", \"정비 품질\", \"운용 부주의\", \"설계 취약\", \"자연 노후\"]\n", + "CAUSE_CUE = {\n", + " \"제조 불량\": [\"신품 인수 직후 발생함\", \"출고 상태에서 이미 이상이 있었음\", \"인수 후 얼마 지나지 않아 발생함\"],\n", + " \"정비 품질\": [\"외주 정비 후 얼마 되지 않아 재발함\", \"정비 후 재조립 상태가 불량함\", \"정비 직후 같은 부위에서 다시 발생함\"],\n", + " \"운용 부주의\": [\"험지 기동 중 충격 이후 발생함\", \"과부하 운용 후 발생함\", \"점검 없이 연속 운용한 뒤 발생함\"],\n", + " \"설계 취약\": [\"동일 증상이 다른 차량에서도 반복됨\", \"구조적으로 취약한 부위로 보임\", \"여러 부대에서 같은 형태로 보고됨\"],\n", + " \"자연 노후\": [\"사용 연한이 도래함\", \"장기간 사용으로 노후됨\", \"수명 주기 말기에 발생함\"]}\n", + "VENDORS = [\"외주A\", \"외주B\", \"외주C\", \"외주D\", \"부대정비\"]\n", + "ACTIONS = [\"정비소 입고를 요청함\", \"현장 임시 조치 후 운용 중임\", \"부품 교환을 요청함\", \"재검사를 요청함\", \"원인 확인이 필요함\"]\n", + "MAKER_OF = [p[7] for p in PARTS]\n", + "PRICE = np.array([p[6] for p in PARTS])\n", + "\n", + "\n", + "def usage_index(months, seed=0):\n", + " \"\"\"월별 훈련 강도(미리 알 수 있는 계획 값): 연 1회 정점을 갖는 계절 + 작은 잡음\"\"\"\n", + " # [TODO : 학생 실습 9] AAR 기반 결함 원인 오분류 분석 및 텍스트 마이닝 룰셋 재튜닝 관련 핵심 로직을 구현하세요.\n", + " # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n", + " # <<< 여기에 코드를 작성하세요 >>>\n", + " pass # 작성 후 pass를 제거하세요.\n", + "def make_qdr(months=36, seed=1, scale=1.0, surge=True):\n", + " \"\"\"반환: QDR DataFrame(건당 1행), 월별 usage, 급증 이벤트 목록\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " use = usage_index(months, seed)\n", + " events = []\n", + " if surge:\n", + " for _ in range(max(2, months // 12 * 2)):\n", + " events.append((int(rng.integers(NP)), int(rng.integers(6, months))))\n", + " rows = []\n", + " wdef = [np.array([float(x.split(\":\")[1]) for x in p[8].split()]) for p in PARTS]\n", + " ddef = [[x.split(\":\")[0] for x in p[8].split()] for p in PARTS]\n", + " n = 0\n", + " for m in range(months):\n", + " for pi, p in enumerate(PARTS):\n", + " lam = p[2] * scale * (1 + 0.010 * m) * use[m] * (1 + p[3] * np.cos(2 * np.pi * (m % 12 - p[4]) / 12))\n", + " ev = (pi, m) in events\n", + " k = rng.poisson(lam * (2.6 if ev else 1.0))\n", + " for _ in range(k):\n", + " rows.append((n, m, pi, ev)); n += 1\n", + " N = len(rows)\n", + " A = np.array(rows)\n", + " month, part, ev = A[:, 1], A[:, 2], A[:, 3].astype(bool)\n", + " defect = np.array([rng.choice(ddef[i], p=wdef[i] / wdef[i].sum()) for i in part])\n", + " cr = np.array([PARTS[i][5][0] for i in part]); ma = np.array([PARTS[i][5][1] for i in part])\n", + " u = rng.random(N)\n", + " sev = np.where(u < cr, 2, np.where(u < cr + ma, 1, 0))\n", + " vendor = rng.choice(VENDORS, N, p=[0.25, 0.2, 0.2, 0.15, 0.2])\n", + " # 원인: 기본 비율에 부위(제조사)·업체·이벤트 효과를 곱한다\n", + " base = np.array([0.22, 0.20, 0.20, 0.16, 0.22])\n", + " P = np.tile(base, (N, 1))\n", + " P[vendor == \"외주B\", 1] *= 2.6\n", + " P[ev, 0] *= 5.0\n", + " design = np.isin(part, [2, 3, 7]); P[design, 3] *= 2.2\n", + " P[np.isin(part, [4, 5, 11]), 0] *= 2.0 # 제조사 MK3 납품 부위는 제조 불량 비중이 높다\n", + " P[defect == \"마모\", 4] *= 1.6; P[defect == \"누유\", 4] *= 1.3\n", + " P /= P.sum(axis=1, keepdims=True)\n", + " cause = np.array([CAUSES[rng.choice(5, p=P[i])] for i in range(N)])\n", + " since = np.where(cause == \"정비 품질\", rng.exponential(35, N), rng.exponential(160, N)).clip(1, 720).round().astype(int)\n", + " wait = (rng.lognormal(1.3, 0.5, N) * np.where(vendor == \"외주B\", 1.7, np.where(vendor == \"부대정비\", 0.6, 1.0)) * (0.7 + 0.5 * use[month])).round(1)\n", + " reins = rng.random(N) < np.where(cause == \"정비 품질\", 0.6, 0.08)\n", + " texts = []\n", + " for i in range(N):\n", + " pt = PARTS[part[i]]\n", + " cl = [f\"{rng.choice(pt[1])}에서 {rng.choice(DEFECTS[defect[i]])}\"]\n", + " if rng.random() < 0.7: cl.append(rng.choice(SEV_CUE[sev[i]]))\n", + " if rng.random() < 0.65: # 원인 단서: 8%는 실제 원인과 다른 단서가 적힌다(보고자의 추정)\n", + " cc = cause[i] if rng.random() > 0.08 else rng.choice(CAUSES)\n", + " cl.append(rng.choice(CAUSE_CUE[cc]))\n", + " cl.append(rng.choice(ACTIONS))\n", + " if rng.random() < 0.5: cl.insert(0, f\"VU-{rng.integers(1, 31):02d} 소속\")\n", + " texts.append(\". \".join(cl) + \".\")\n", + " df = pd.DataFrame({\"qdr_id\": [f\"Q{seed}{i:06d}\" for i in range(N)], \"month\": month, \"unit\": [f\"VU-{x:02d}\" for x in rng.integers(1, 31, N)],\n", + " \"part\": part, \"defect\": defect, \"sev\": sev, \"cause\": cause, \"vendor\": vendor, \"maker\": [MAKER_OF[i] for i in part],\n", + " \"since_maint\": since, \"inspect_wait\": wait, \"reinspect\": reins, \"event\": ev, \"text\": texts})\n", + " df[\"cost\"] = PRICE[df[\"part\"]] * np.array([1.0, 2.0, 4.0])[df[\"sev\"]] # [교육용가상자료] 수리·조치 비용지수\n", + " return df, use, events\n", + "\n", + "# ---------------------------------------------------------------------------\n", + "# 공통 텍스트 조각: 정규화, 간이 토큰화(조사 제거), 부위·결함·치명도·원인 키워드 라벨링\n", + "# ---------------------------------------------------------------------------\n", + "TYPO = {\"누윳\": \"누유\", \"균렬\": \"균열\", \"접축 불량\": \"접촉 불량\", \"부씩\": \"부식\", \"마묘\": \"마모\", \"과렬\": \"과열\", \"오작똥\": \"오작동\", \"재동\": \"제동\"}\n", + "ABBR = {\"T/M\": \"변속기\", \"BAT\": \"배터리\"}\n", + "PART_KW = sorted([(a, i) for i, p in enumerate(PARTS) for a in p[1]], key=lambda x: -len(x[0]))\n", + "DEF_KW = {\"균열\": [\"균열\", \"크랙\", \"갈라\"], \"누유\": [\"누유\", \"새어\", \"누설\"], \"마모\": [\"마모\", \"닳은\"], \"부식\": [\"부식\", \"녹이\", \"산화\"],\n", + " \"접촉불량\": [\"접촉 불량\", \"단선\", \"끊김\"], \"체결불량\": [\"볼트\", \"체결\", \"헐거\"], \"과열\": [\"과열\", \"온도\", \"열이\"], \"오작동\": [\"오작동\", \"불안정\", \"멈춤\"]}\n", + "SEV_KW = [(2, [\"즉시 운용 중지\", \"사용 불가\", \"위험\"]), (1, [\"성능 저하\", \"조속한\"]), (0, [\"경미\", \"지장이 없\"])]\n", + "CAUSE_KW = {\"제조 불량\": [\"신품\", \"출고\", \"인수\"], \"정비 품질\": [\"정비 후\", \"재조립\", \"정비 직후\"], \"운용 부주의\": [\"충격\", \"과부하\", \"점검 없이\"],\n", + " \"설계 취약\": [\"동일 증상\", \"구조적\", \"여러 부대\"], \"자연 노후\": [\"사용 연한\", \"노후\", \"수명\"]}\n", + "PARTICLES = (\"에서\", \"으로\", \"이\", \"가\", \"은\", \"는\", \"을\", \"를\", \"에\", \"의\", \"로\", \"와\", \"과\", \"도\", \"함\", \"음\", \"됨\")\n", + "\n", + "\n", + "def norm(t):\n", + " \"\"\"정규화: 전각->반각, 특수문자·중복 공백 제거, 약어 확장, 오타 교정\"\"\"\n", + " # [TODO : 학생 실습 9] AAR 기반 결함 원인 오분류 분석 및 텍스트 마이닝 룰셋 재튜닝 관련 핵심 로직을 구현하세요.\n", + " # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n", + " # <<< 여기에 코드를 작성하세요 >>>\n", + " pass # 작성 후 pass를 제거하세요.\n", + "def tok(t):\n", + " out = []\n", + " for w in re.split(r\"[\\s.,]+\", norm(t)):\n", + " if not w or re.fullmatch(r\"[\\d\\-A-Za-z]+\", w):\n", + " continue\n", + " for p in PARTICLES:\n", + " if len(w) > len(p) + 1 and w.endswith(p):\n", + " w = w[:-len(p)]\n", + " break\n", + " out.append(w)\n", + " return out\n", + "\n", + "\n", + "def label_part(t):\n", + " for a, i in PART_KW:\n", + " if a in t:\n", + " return i\n", + " return -1\n", + "\n", + "\n", + "def label_defect(t):\n", + " for d, ks in DEF_KW.items():\n", + " if any(k in t for k in ks):\n", + " return d\n", + " return \"미분류\"\n", + "\n", + "\n", + "def label_sev(t):\n", + " for s, ks in SEV_KW:\n", + " if any(k in t for k in ks):\n", + " return s\n", + " return -1\n", + "\n", + "\n", + "def label_cause(t):\n", + " for c, ks in CAUSE_KW.items():\n", + " if any(k in t for k in ks):\n", + " return c\n", + " return \"미상\"\n", + "\n", + "df, use, events = make_qdr(36, seed=1, scale=1.5)\n", + "df[\"t\"] = df[\"text\"].map(norm)\n", + "aar, val = df[(df[\"month\"] >= 12) & (df[\"month\"] < 24)].copy(), df[df[\"month\"] >= 24].copy()\n", + "print(f\"[교육용가상자료] AAR 기간 {len(aar)}건, 검증 기간 {len(val)}건\")\n", + "\n", + "# 1) 기존 룰셋: 서술 속 원인 단서 키워드(첫 일치)\n", + "for d in (aar, val):\n", + " d[\"rule0\"] = d[\"t\"].map(label_cause)\n", + "wrong = aar[aar[\"rule0\"] != aar[\"cause\"]]\n", + "no_cue = wrong[wrong[\"rule0\"] == \"미상\"]\n", + "conflict = wrong[wrong[\"rule0\"] != \"미상\"]\n", + "print(f\"기존 룰셋 정확도: AAR {1 - len(wrong) / len(aar):.1%}, 검증 {(val['rule0'] == val['cause']).mean():.1%}\")\n", + "print(f\"AAR 오분류 {len(wrong)}건 ({len(wrong) / len(aar):.1%}): 원인 단서 없음(미상) {len(no_cue)}건 ({len(no_cue) / len(wrong):.0%}), 단서와 실제 원인 불일치 {len(conflict)}건 ({len(conflict) / len(wrong):.0%})\")\n", + "print(\" 미상 건의 실제 원인: \" + \", \".join(f\"{k} {v}\" for k, v in no_cue[\"cause\"].value_counts().items()))\n", + "c0 = conflict.groupby([\"rule0\", \"cause\"]).size().sort_values(ascending=False).head(2)\n", + "print(\" 불일치 상위: \" + \", \".join(f\"룰 '{a}' -> 실제 '{b}' {v}건\" for (a, b), v in c0.items()))\n", + "ex = no_cue[no_cue[\"cause\"] == \"정비 품질\"].iloc[0]\n", + "print(f\" 사례: '{ex['text']}' (업체 {ex['vendor']}, 정비 후 {ex['since_maint']}일) -> 룰 미상, 실제 정비 품질\")\n", + "\n", + "# 2) 재튜닝: 미상 건에 보조 규칙 적용(AAR 기간 미상 건만으로 결정 트리 학습, 깊이 3)\n", + "def meta(d):\n", + " X = pd.DataFrame({\"since_maint\": d[\"since_maint\"], \"ext_vendor\": (d[\"vendor\"] != \"부대정비\").astype(int), \"vendor_B\": (d[\"vendor\"] == \"외주B\").astype(int),\n", + " \"wait\": d[\"inspect_wait\"], \"reinspect\": d[\"reinspect\"].astype(int), \"sev\": d[\"sev\"], \"wear\": d[\"defect\"].isin([\"마모\", \"부식\"]).astype(int),\n", + " \"leak\": (d[\"defect\"] == \"누유\").astype(int)})\n", + " for p_ in (2, 3, 7):\n", + " X[f\"part{p_}\"] = (d[\"part\"] == p_).astype(int)\n", + " for p_ in (4, 5, 11):\n", + " X[f\"part{p_}\"] = (d[\"part\"] == p_).astype(int)\n", + " return X\n", + "\n", + "\n", + "tree = DecisionTreeClassifier(max_depth=3, min_samples_leaf=40, random_state=0).fit(meta(no_cue.assign(cause=no_cue[\"cause\"])), no_cue[\"cause\"])\n", + "print(\"\\n[재튜닝 보조 규칙(결정 트리, 미상 건 대상)]\")\n", + "for line in export_text(tree, feature_names=list(meta(no_cue).columns), max_depth=3).splitlines()[:9]:\n", + " print(\" \" + line)\n", + "\n", + "\n", + "CONF = 0.45\n", + "\n", + "\n", + "def rule1(d):\n", + " out = d[\"rule0\"].copy()\n", + " unk = out == \"미상\"\n", + " if unk.any():\n", + " pr = tree.predict_proba(meta(d[unk]))\n", + " out[unk] = np.where(pr.max(axis=1) >= CONF, tree.classes_[pr.argmax(axis=1)], \"미상\") # 확신이 낮으면 미상으로 남겨 사람이 확인\n", + " return out\n", + "\n", + "\n", + "for d in (aar, val):\n", + " d[\"rule1\"] = rule1(d)\n", + "acc = lambda d, c: (d[c] == d[\"cause\"]).mean()\n", + "print(f\"\\n정확도 변화: AAR(튜닝) {acc(aar, 'rule0'):.1%} -> {acc(aar, 'rule1'):.1%}, 검증(미사용) {acc(val, 'rule0'):.1%} -> {acc(val, 'rule1'):.1%}\")\n", + "for c in (\"제조 불량\", \"정비 품질\"):\n", + " m = val[\"cause\"] == c\n", + " print(f\" {c} 재현율(검증): {(val.loc[m, 'rule0'] == c).mean():.0%} -> {(val.loc[m, 'rule1'] == c).mean():.0%}\")\n", + "\n", + "# 3) 3대 정량 효과 지표 (검증 12개월, 가정 단가)\n", + "REWORK, HOURS = 0.5, 0.8 # [교육용가상자료] 오분류 건당 재작업 비용 = 조치 비용 x 0.5, 미상 건 수작업 원인 확인 0.8시간\n", + "cost0, cost1 = (val.loc[val[\"rule0\"] != val[\"cause\"], \"cost\"].sum() * REWORK), (val.loc[val[\"rule1\"] != val[\"cause\"], \"cost\"].sum() * REWORK)\n", + "h0, h1 = (val[\"rule0\"] == \"미상\").sum() * HOURS, (val[\"rule1\"] == \"미상\").sum() * HOURS\n", + "ext0, ext1 = ((val[\"rule0\"].isin([\"제조 불량\", \"정비 품질\"])) & (val[\"cause\"].isin([\"제조 불량\", \"정비 품질\"])) & (val[\"rule0\"] == val[\"cause\"])).sum(), \\\n", + " ((val[\"rule1\"].isin([\"제조 불량\", \"정비 품질\"])) & (val[\"cause\"].isin([\"제조 불량\", \"정비 품질\"])) & (val[\"rule1\"] == val[\"cause\"])).sum()\n", + "print(\"\\n[3대 정량 효과 지표] (검증 12개월, 가정 단가)\")\n", + "print(f\" 1) 비용 절감: 오분류 재작업 비용지수 {cost0:.0f} -> {cost1:.0f} ({1 - cost1 / cost0:.1%} 절감), 연 환산 약 {cost0 - cost1:.0f}\")\n", + "print(f\" 2) 시간 단축: 원인 수작업 확인 {h0:.0f}h -> {h1:.0f}h ({1 - h1 / h0:.1%} 단축, 확신 {CONF:.0%} 미만은 미상으로 남겨 사람이 확인)\")\n", + "print(f\" 3) 제조·정비 책임 건 정확 식별: {ext0}건 -> {ext1}건 (+{ext1 / ext0 - 1:.0%}), 하자 구상·시정 요구 근거 확보 증가\")\n", + "print(\"[도입 로드맵] 단기(1~3월): 보조 규칙 적용 + 품질관 확인 병행 / 중기(3~12월): 분기 AAR로 규칙 재학습, 단서 용어 사전 확장 / 장기(1년~): 군수 정보체계 정비 이력과 원인 라벨 환류\")\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 09 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + "[교육용가상자료] AAR 기간 4568건, 검증 기간 5103건\n", + "기존 룰셋 정확도: AAR 60.3%, 검증 61.7%\n", + "AAR 오분류 1815건 (39.7%): 원인 단서 없음(미상) 1617건 (89%), 단서와 실제 원인 불일치 198건 (11%)\n", + " 미상 건의 실제 원인: 제조 불량 407, 자연 노후 354, 정비 품질 331, 설계 취약 266, 운용 부주의 259\n", + " 불일치 상위: 룰 '설계 취약' -> 실제 '정비 품질' 15건, 룰 '제조 불량' -> 실제 '정비 품질' 15건\n", + " 사례: '연료계통에서 누설 흔적이 있음. 재검사를 요청함.' (업체 외주B, 정비 후 6일) -> 룰 미상, 실제 정비 품질\n", + "\n", + "[재튜닝 보조 규칙(결정 트리, 미상 건 대상)]\n", + " |--- reinspect <= 0.50\n", + " | |--- since_maint <= 62.50\n", + " | | |--- vendor_B <= 0.50\n", + " | | | |--- class: 제조 불량\n", + " | | |--- vendor_B > 0.50\n", + " | | | |--- class: 정비 품질\n", + " | |--- since_maint > 62.50\n", + " | | |--- part5 <= 0.50\n", + " | | | |--- class: 자연 노후\n", + "\n", + "정확도 변화: AAR(튜닝) 60.3% -> 65.4%, 검증(미사용) 61.7% -> 66.2%\n", + " 제조 불량 재현율(검증): 63% -> 67%\n", + " 정비 품질 재현율(검증): 62% -> 79%\n", + "\n", + "[3대 정량 효과 지표] (검증 12개월, 가정 단가)\n", + " 1) 비용 절감: 오분류 재작업 비용지수 8127 -> 7253 (10.8% 절감), 연 환산 약 874\n", + " 2) 시간 단축: 원인 수작업 확인 1409h -> 1126h (20.1% 단축, 확신 45% 미만은 미상으로 남겨 사람이 확인)\n", + " 3) 제조·정비 책임 건 정확 식별: 1454건 -> 1685건 (+16%), 하자 구상·시정 요구 근거 확보 증가\n", + "[도입 로드맵] 단기(1~3월): 보조 규칙 적용 + 품질관 확인 병행 / 중기(3~12월): 분기 AAR로 규칙 재학습, 단서 용어 사전 확장 / 장기(1년~): 군수 정보체계 정비 이력과 원인 라벨 환류\n", + "```\n", + "
\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 10. 야전 장비 결함 보고(QDR) 데이터 분석 및 품질 취약점 식별 도구 통합 완성\n", + "**소요시간**: 약 3.5시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "야전 장비 결함 보고(QDR) 데이터 분석 및 품질 취약점 식별 도구를 완성한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **QDR 검증·정제부터 치명도 분류, 취약성 지수·경보, 원인 분류, 승인 대기 후보 제안, 재학습까지 한 파일로 통합하고 자체 점검과 브리핑 요약을 출력하는 도구를 완성한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **지난달·이번 달 실적으로 챔피언 모델과 도전자 모델을 학습해 검증 정확도를 비교하고 비슷하거나 나을 때만 교체 수행하고 검증하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `docs/qlt05_operations_guide.md 및 pipeline/qlt05_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": [ + "\"\"\"QDR 서술 검증·정제·라벨링, 치명도 분류 모델의 자동 재학습(챔피언·도전자 비교 후 교체), 부위별 취약성 지수와 급증 경보, 재튜닝 규칙의 원인 분류, 하자 구상·설계 변경·외주정비 시정 후보의 품질관 승인 대기 목록까지 야전 QDR 품질 취약점 식별 도구를 한 파일로 통합 실행하고, 자체 점검·운영 문서 완비 점검·품질 담당 장교 브리핑 요약을 출력한다.\"\"\"\n", + "import re\n", + "import time\n", + "import unicodedata\n", + "import numpy as np\n", + "import pandas as pd\n", + "import scipy.sparse as sp\n", + "import matplotlib.pyplot as plt\n", + "from sklearn.feature_extraction.text import TfidfVectorizer\n", + "from sklearn.linear_model import LogisticRegression\n", + "\n", + "# ---------------------------------------------------------------------------\n", + "# [교육용가상자료] 가상 야전 장비 품질결함보고(QDR) 생성기 (실제 부대·장비·업체·정비 실적 없음)\n", + "# 가상 장비 부위 12종, 결함 유형 8종, 치명도(Critical/Major/Minor), 원인 5종, 가상 외주정비 업체 4곳 + 부대정비.\n", + "# QDR 1건마다 한국어 서술 텍스트를 만들고, 월별 건수는 계절·추세·훈련 강도(usage)·일부 제조 불량 이벤트를 따른다.\n", + "# ---------------------------------------------------------------------------\n", + "PARTS = [ # (이름, 별칭, 월 기준 건수, 계절 진폭, 정점 월, (Critical, Major 비율), 가상 단가지수, 제조사, 주요 결함 가중치)\n", + " (\"엔진 연료계통\", [\"연료계통\", \"연료 라인\", \"연료 펌프\"], 26, 0.15, 7, (0.10, 0.35), 6.0, \"MK1\", \"누유:4 마모:2 체결불량:2 오작동:2\"),\n", + " (\"변속기\", [\"변속기\", \"T/M\", \"미션\"], 22, 0.10, 8, (0.12, 0.38), 9.0, \"MK1\", \"마모:4 누유:2 과열:3 오작동:1\"),\n", + " (\"제동 장치\", [\"제동 장치\", \"브레이크\", \"제동 라인\"], 20, 0.20, 12, (0.26, 0.40), 5.0, \"MK2\", \"마모:4 균열:2 누유:2 체결불량:2\"),\n", + " (\"조향 장치\", [\"조향 장치\", \"핸들 연동부\", \"조향 로드\"], 14, 0.10, 6, (0.30, 0.40), 5.0, \"MK2\", \"체결불량:4 균열:2 마모:3 오작동:1\"),\n", + " (\"냉각 계통\", [\"냉각 계통\", \"라디에이터\", \"냉각수 라인\"], 24, 0.30, 7, (0.05, 0.30), 3.0, \"MK3\", \"누유:4 과열:4 부식:2\"),\n", + " (\"전기·배터리\", [\"배터리\", \"전기 계통\", \"발전기\"], 28, 0.35, 1, (0.04, 0.28), 2.5, \"MK3\", \"접촉불량:4 부식:2 과열:2 오작동:2\"),\n", + " (\"통신 모듈\", [\"통신 모듈\", \"무전기 연동부\", \"안테나 접속부\"], 16, 0.05, 4, (0.03, 0.25), 4.0, \"MK4\", \"접촉불량:5 오작동:4 부식:1\"),\n", + " (\"유압 계통\", [\"유압 계통\", \"유압 호스\", \"유압 실린더\"], 18, 0.10, 9, (0.10, 0.35), 4.5, \"MK2\", \"누유:5 균열:2 마모:2 체결불량:1\"),\n", + " (\"현가 장치\", [\"현가 장치\", \"서스펜션\", \"완충기\"], 15, 0.08, 5, (0.06, 0.30), 3.5, \"MK4\", \"마모:4 균열:3 체결불량:2 부식:1\"),\n", + " (\"차체 용접부\", [\"차체 용접부\", \"프레임 용접부\", \"차체 이음부\"], 10, 0.05, 10, (0.28, 0.40), 7.0, \"MK1\", \"균열:6 부식:3 체결불량:1\"),\n", + " (\"광학·조준 장비\", [\"광학 장비\", \"조준경\", \"관측 장비\"], 8, 0.05, 3, (0.03, 0.22), 8.0, \"MK4\", \"오작동:4 접촉불량:3 부식:2 체결불량:1\"),\n", + " (\"공기 정화 필터\", [\"공기 필터\", \"흡기 필터\", \"정화 장치\"], 12, 0.25, 4, (0.01, 0.12), 1.0, \"MK3\", \"마모:5 부식:2 체결불량:3\")]\n", + "NP = len(PARTS)\n", + "DEFECTS = { # 결함 유형: 증상 표현(동의어)\n", + " \"균열\": [\"균열이 발견됨\", \"크랙이 확인됨\", \"갈라짐이 있음\"], \"누유\": [\"누유가 발생함\", \"오일이 새어 나옴\", \"누설 흔적이 있음\"],\n", + " \"마모\": [\"마모가 심함\", \"과도한 마모가 확인됨\", \"닳은 흔적이 있음\"], \"부식\": [\"부식이 진행됨\", \"녹이 발생함\", \"산화 흔적이 있음\"],\n", + " \"접촉불량\": [\"접촉 불량이 발생함\", \"커넥터 단선이 의심됨\", \"신호가 끊김\"], \"체결불량\": [\"볼트 풀림이 확인됨\", \"체결 상태가 불량함\", \"고정부가 헐거움\"],\n", + " \"과열\": [\"과열 경고가 발생함\", \"온도가 과도하게 상승함\", \"열이 심하게 남\"], \"오작동\": [\"오작동이 반복됨\", \"작동이 불안정함\", \"간헐적으로 멈춤\"]}\n", + "DNAMES = list(DEFECTS)\n", + "SEV = [\"Minor\", \"Major\", \"Critical\"]\n", + "SEV_CUE = {0: [\"경미한 수준임\", \"운용에는 지장이 없음\", \"육안 점검상 사소한 이상임\", \"기능에는 영향이 없음\"],\n", + " 1: [\"성능 저하가 있음\", \"조속한 정비가 필요함\", \"출력이 떨어지는 상태임\", \"기능이 부분적으로 제한됨\"],\n", + " 2: [\"즉시 운용 중지함\", \"안전에 위험이 있어 사용 불가\", \"작동 정지로 임무 수행이 어려움\", \"인명 안전 위협이 우려됨\"]}\n", + "CAUSES = [\"제조 불량\", \"정비 품질\", \"운용 부주의\", \"설계 취약\", \"자연 노후\"]\n", + "CAUSE_CUE = {\n", + " \"제조 불량\": [\"신품 인수 직후 발생함\", \"출고 상태에서 이미 이상이 있었음\", \"인수 후 얼마 지나지 않아 발생함\"],\n", + " \"정비 품질\": [\"외주 정비 후 얼마 되지 않아 재발함\", \"정비 후 재조립 상태가 불량함\", \"정비 직후 같은 부위에서 다시 발생함\"],\n", + " \"운용 부주의\": [\"험지 기동 중 충격 이후 발생함\", \"과부하 운용 후 발생함\", \"점검 없이 연속 운용한 뒤 발생함\"],\n", + " \"설계 취약\": [\"동일 증상이 다른 차량에서도 반복됨\", \"구조적으로 취약한 부위로 보임\", \"여러 부대에서 같은 형태로 보고됨\"],\n", + " \"자연 노후\": [\"사용 연한이 도래함\", \"장기간 사용으로 노후됨\", \"수명 주기 말기에 발생함\"]}\n", + "VENDORS = [\"외주A\", \"외주B\", \"외주C\", \"외주D\", \"부대정비\"]\n", + "ACTIONS = [\"정비소 입고를 요청함\", \"현장 임시 조치 후 운용 중임\", \"부품 교환을 요청함\", \"재검사를 요청함\", \"원인 확인이 필요함\"]\n", + "MAKER_OF = [p[7] for p in PARTS]\n", + "PRICE = np.array([p[6] for p in PARTS])\n", + "\n", + "\n", + "def usage_index(months, seed=0):\n", + " \"\"\"월별 훈련 강도(미리 알 수 있는 계획 값): 연 1회 정점을 갖는 계절 + 작은 잡음\"\"\"\n", + " # [TODO : 학생 실습 10] 야전 장비 결함 보고(QDR) 데이터 분석 및 품질 취약점 식별 도구 통합 완성 관련 핵심 로직을 구현하세요.\n", + " # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n", + " # <<< 여기에 코드를 작성하세요 >>>\n", + " pass # 작성 후 pass를 제거하세요.\n", + "def make_qdr(months=36, seed=1, scale=1.0, surge=True):\n", + " \"\"\"반환: QDR DataFrame(건당 1행), 월별 usage, 급증 이벤트 목록\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " use = usage_index(months, seed)\n", + " events = []\n", + " if surge:\n", + " for _ in range(max(2, months // 12 * 2)):\n", + " events.append((int(rng.integers(NP)), int(rng.integers(6, months))))\n", + " rows = []\n", + " wdef = [np.array([float(x.split(\":\")[1]) for x in p[8].split()]) for p in PARTS]\n", + " ddef = [[x.split(\":\")[0] for x in p[8].split()] for p in PARTS]\n", + " n = 0\n", + " for m in range(months):\n", + " for pi, p in enumerate(PARTS):\n", + " lam = p[2] * scale * (1 + 0.010 * m) * use[m] * (1 + p[3] * np.cos(2 * np.pi * (m % 12 - p[4]) / 12))\n", + " ev = (pi, m) in events\n", + " k = rng.poisson(lam * (2.6 if ev else 1.0))\n", + " for _ in range(k):\n", + " rows.append((n, m, pi, ev)); n += 1\n", + " N = len(rows)\n", + " A = np.array(rows)\n", + " month, part, ev = A[:, 1], A[:, 2], A[:, 3].astype(bool)\n", + " defect = np.array([rng.choice(ddef[i], p=wdef[i] / wdef[i].sum()) for i in part])\n", + " cr = np.array([PARTS[i][5][0] for i in part]); ma = np.array([PARTS[i][5][1] for i in part])\n", + " u = rng.random(N)\n", + " sev = np.where(u < cr, 2, np.where(u < cr + ma, 1, 0))\n", + " vendor = rng.choice(VENDORS, N, p=[0.25, 0.2, 0.2, 0.15, 0.2])\n", + " # 원인: 기본 비율에 부위(제조사)·업체·이벤트 효과를 곱한다\n", + " base = np.array([0.22, 0.20, 0.20, 0.16, 0.22])\n", + " P = np.tile(base, (N, 1))\n", + " P[vendor == \"외주B\", 1] *= 2.6\n", + " P[ev, 0] *= 5.0\n", + " design = np.isin(part, [2, 3, 7]); P[design, 3] *= 2.2\n", + " P[np.isin(part, [4, 5, 11]), 0] *= 2.0 # 제조사 MK3 납품 부위는 제조 불량 비중이 높다\n", + " P[defect == \"마모\", 4] *= 1.6; P[defect == \"누유\", 4] *= 1.3\n", + " P /= P.sum(axis=1, keepdims=True)\n", + " cause = np.array([CAUSES[rng.choice(5, p=P[i])] for i in range(N)])\n", + " since = np.where(cause == \"정비 품질\", rng.exponential(35, N), rng.exponential(160, N)).clip(1, 720).round().astype(int)\n", + " wait = (rng.lognormal(1.3, 0.5, N) * np.where(vendor == \"외주B\", 1.7, np.where(vendor == \"부대정비\", 0.6, 1.0)) * (0.7 + 0.5 * use[month])).round(1)\n", + " reins = rng.random(N) < np.where(cause == \"정비 품질\", 0.6, 0.08)\n", + " texts = []\n", + " for i in range(N):\n", + " pt = PARTS[part[i]]\n", + " cl = [f\"{rng.choice(pt[1])}에서 {rng.choice(DEFECTS[defect[i]])}\"]\n", + " if rng.random() < 0.7: cl.append(rng.choice(SEV_CUE[sev[i]]))\n", + " if rng.random() < 0.65: # 원인 단서: 8%는 실제 원인과 다른 단서가 적힌다(보고자의 추정)\n", + " cc = cause[i] if rng.random() > 0.08 else rng.choice(CAUSES)\n", + " cl.append(rng.choice(CAUSE_CUE[cc]))\n", + " cl.append(rng.choice(ACTIONS))\n", + " if rng.random() < 0.5: cl.insert(0, f\"VU-{rng.integers(1, 31):02d} 소속\")\n", + " texts.append(\". \".join(cl) + \".\")\n", + " df = pd.DataFrame({\"qdr_id\": [f\"Q{seed}{i:06d}\" for i in range(N)], \"month\": month, \"unit\": [f\"VU-{x:02d}\" for x in rng.integers(1, 31, N)],\n", + " \"part\": part, \"defect\": defect, \"sev\": sev, \"cause\": cause, \"vendor\": vendor, \"maker\": [MAKER_OF[i] for i in part],\n", + " \"since_maint\": since, \"inspect_wait\": wait, \"reinspect\": reins, \"event\": ev, \"text\": texts})\n", + " df[\"cost\"] = PRICE[df[\"part\"]] * np.array([1.0, 2.0, 4.0])[df[\"sev\"]] # [교육용가상자료] 수리·조치 비용지수\n", + " return df, use, events\n", + "\n", + "# ---------------------------------------------------------------------------\n", + "# 공통 텍스트 조각: 정규화, 간이 토큰화(조사 제거), 부위·결함·치명도·원인 키워드 라벨링\n", + "# ---------------------------------------------------------------------------\n", + "TYPO = {\"누윳\": \"누유\", \"균렬\": \"균열\", \"접축 불량\": \"접촉 불량\", \"부씩\": \"부식\", \"마묘\": \"마모\", \"과렬\": \"과열\", \"오작똥\": \"오작동\", \"재동\": \"제동\"}\n", + "ABBR = {\"T/M\": \"변속기\", \"BAT\": \"배터리\"}\n", + "PART_KW = sorted([(a, i) for i, p in enumerate(PARTS) for a in p[1]], key=lambda x: -len(x[0]))\n", + "DEF_KW = {\"균열\": [\"균열\", \"크랙\", \"갈라\"], \"누유\": [\"누유\", \"새어\", \"누설\"], \"마모\": [\"마모\", \"닳은\"], \"부식\": [\"부식\", \"녹이\", \"산화\"],\n", + " \"접촉불량\": [\"접촉 불량\", \"단선\", \"끊김\"], \"체결불량\": [\"볼트\", \"체결\", \"헐거\"], \"과열\": [\"과열\", \"온도\", \"열이\"], \"오작동\": [\"오작동\", \"불안정\", \"멈춤\"]}\n", + "SEV_KW = [(2, [\"즉시 운용 중지\", \"사용 불가\", \"위험\"]), (1, [\"성능 저하\", \"조속한\"]), (0, [\"경미\", \"지장이 없\"])]\n", + "CAUSE_KW = {\"제조 불량\": [\"신품\", \"출고\", \"인수\"], \"정비 품질\": [\"정비 후\", \"재조립\", \"정비 직후\"], \"운용 부주의\": [\"충격\", \"과부하\", \"점검 없이\"],\n", + " \"설계 취약\": [\"동일 증상\", \"구조적\", \"여러 부대\"], \"자연 노후\": [\"사용 연한\", \"노후\", \"수명\"]}\n", + "PARTICLES = (\"에서\", \"으로\", \"이\", \"가\", \"은\", \"는\", \"을\", \"를\", \"에\", \"의\", \"로\", \"와\", \"과\", \"도\", \"함\", \"음\", \"됨\")\n", + "\n", + "\n", + "def norm(t):\n", + " \"\"\"정규화: 전각->반각, 특수문자·중복 공백 제거, 약어 확장, 오타 교정\"\"\"\n", + " # [TODO : 학생 실습 10] 야전 장비 결함 보고(QDR) 데이터 분석 및 품질 취약점 식별 도구 통합 완성 관련 핵심 로직을 구현하세요.\n", + " # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n", + " # <<< 여기에 코드를 작성하세요 >>>\n", + " pass # 작성 후 pass를 제거하세요.\n", + "def tok(t):\n", + " out = []\n", + " for w in re.split(r\"[\\s.,]+\", norm(t)):\n", + " if not w or re.fullmatch(r\"[\\d\\-A-Za-z]+\", w):\n", + " continue\n", + " for p in PARTICLES:\n", + " if len(w) > len(p) + 1 and w.endswith(p):\n", + " w = w[:-len(p)]\n", + " break\n", + " out.append(w)\n", + " return out\n", + "\n", + "\n", + "def label_part(t):\n", + " for a, i in PART_KW:\n", + " if a in t:\n", + " return i\n", + " return -1\n", + "\n", + "\n", + "def label_defect(t):\n", + " for d, ks in DEF_KW.items():\n", + " if any(k in t for k in ks):\n", + " return d\n", + " return \"미분류\"\n", + "\n", + "\n", + "def label_sev(t):\n", + " for s, ks in SEV_KW:\n", + " if any(k in t for k in ks):\n", + " return s\n", + " return -1\n", + "\n", + "\n", + "def label_cause(t):\n", + " for c, ks in CAUSE_KW.items():\n", + " if any(k in t for k in ks):\n", + " return c\n", + " return \"미상\"\n", + "\n", + "CONFIG = {\"retrain_tolerance\": 0.005, \"alarm_ratio\": 1.9, \"alarm_z\": 4.0, \"since_short\": 62, \"min_cases\": 60, \"mfg_share\": 0.22, \"design_share\": 0.20, \"vendor_share\": 0.28}\n", + "OPS_GUIDE = { # docs/qlt05_operations_guide.md 요약\n", + " \"1. 목적과 범위\": \"야전 QDR 서술·실적으로 다발 결함 부위와 품질 취약점을 식별하는 보조 도구. 하자 구상·설계 변경 소요 제기의 결정은 품질관.\",\n", + " \"2. 입력 데이터\": \"QDR(부대·장비·부위·서술·치명도), 정비 수행처와 경과일, 검사 대기·재검사. 가상 식별코드 사용, 실제 부대 자료는 부대 내에서만 처리.\",\n", + " \"3. 일일 운용 절차\": \"접수 -> 검증·격리 -> 정제·라벨링 -> 치명도 분류 -> 월별 집계·경보 -> 후보 제안(대기) -> 품질관 결정 -> 결과 기록.\",\n", + " \"4. 승인(HITL) 기준\": \"제조사 하자 구상·설계 변경·외주정비 시정은 품질관 승인 후 확정, 무응답은 조치 보류 후 분기 회의 재상정, 사유 기록.\",\n", + " \"5. 재학습 절차\": \"분기 1회 신규 QDR로 도전자 학습, 검증 정확도가 나빠지지 않을 때만 교체.\",\n", + " \"6. 장애 대응\": \"모델 오류 시 키워드 규칙으로 대체, 손상·비정상 서술은 수동 확인 목록으로 분리.\",\n", + " \"7. 연동 인터페이스\": \"국방 군수 정보체계와 QDR·정비 이력 표준 필드(장비코드·분류군·일시)를 CSV/API로 교환하는 안을 차기 과제로 둔다.\",\n", + "}\n", + "names = [p[0] for p in PARTS]\n", + "oh = lambda d: sp.csr_matrix(np.hstack([np.eye(NP)[d[\"part\"]], pd.get_dummies(d[\"defect\"]).reindex(columns=DNAMES, fill_value=0).values.astype(float)]))\n", + "\n", + "\n", + "def validate(d):\n", + " ok = d[\"text\"].str.len().between(5, 400) & ~d[\"text\"].str.contains(\"�\", regex=False)\n", + " return d[ok].copy(), d[~ok].copy()\n", + "\n", + "\n", + "def fit_sev(d):\n", + " vec = TfidfVectorizer(tokenizer=tok, lowercase=False, token_pattern=None, min_df=5)\n", + " X = sp.hstack([vec.fit_transform(d[\"text\"]), oh(d)]).tocsr()\n", + " return vec, LogisticRegression(C=5, max_iter=500).fit(X, d[\"sev\"])\n", + "\n", + "\n", + "def predict_sev(model, d):\n", + " vec, clf = model\n", + " return clf.predict(sp.hstack([vec.transform(d[\"text\"]), oh(d)]).tocsr())\n", + "\n", + "\n", + "def retrain(champion, old, new):\n", + " \"\"\"도전자(지난달+이번 달 앞 2/3) vs 챔피언(지난달) -> 이번 달 뒤 1/3 검증 정확도 비교, 비슷하거나 나을 때만 교체\"\"\"\n", + " cut = new[\"month\"].quantile(2 / 3)\n", + " hold, new_tr = new[new[\"month\"] > cut], new[new[\"month\"] <= cut]\n", + " challenger = fit_sev(pd.concat([old, new_tr], ignore_index=True))\n", + " a_old, a_new = (predict_sev(champion, hold) == hold[\"sev\"].values).mean(), (predict_sev(challenger, hold) == hold[\"sev\"].values).mean()\n", + " swap = a_new >= a_old - CONFIG[\"retrain_tolerance\"]\n", + " return (challenger if swap else champion), {\"챔피언\": round(float(a_old), 3), \"도전자\": round(float(a_new), 3), \"교체\": bool(swap)}\n", + "\n", + "\n", + "def sev_safe(model, d):\n", + " try:\n", + " return predict_sev(model, d), \"모델\"\n", + " except Exception:\n", + " return d[\"text\"].map(label_sev).replace(-1, 0).values, \"키워드 규칙 대체\"\n", + "\n", + "\n", + "def cause_rule(d):\n", + " c = d[\"text\"].map(norm).map(label_cause)\n", + " short = (c == \"미상\") & (d[\"vendor\"] == \"외주B\") & (d[\"since_maint\"] <= CONFIG[\"since_short\"])\n", + " return c.where(~short, \"정비 품질\")\n", + "\n", + "\n", + "# ---------------- 1. 통합 실행 ----------------\n", + "t_start = time.perf_counter()\n", + "old, _, _ = make_qdr(36, seed=11, scale=1.5) # 지난달 실적\n", + "new, _, _ = make_qdr(36, seed=1, scale=1.5) # 이번 달 실적\n", + "ops, use, events = make_qdr(24, seed=21, scale=1.5) # 다음 달 운용(신규 QDR)\n", + "ops.iloc[[10, 300], ops.columns.get_loc(\"text\")] = [\"\", \"�� 오류\"] # 손상 서술 2건 주입\n", + "champion = fit_sev(old)\n", + "model, rt = retrain(champion, old, new)\n", + "print(f\"[교육용가상자료] 재학습: {rt} -> {'도전자 교체' if rt['교체'] else '챔피언 유지'}\")\n", + "good, bad = validate(ops)\n", + "good = good.copy()\n", + "good[\"sev_pred\"], src = sev_safe(model, good)\n", + "good[\"cause_pred\"] = cause_rule(good)\n", + "sec = time.perf_counter() - t_start\n", + "acc = (good[\"sev_pred\"] == good[\"sev\"]).mean()\n", + "print(f\"운용 QDR {len(ops)}건: 분석 {len(good)}건, 수동 확인 {len(bad)}건, 치명도 출처 '{src}', 정확도 {acc:.1%}, 전체 실행 {sec:.1f}초\")\n", + "\n", + "# 월별 집계·경보·취약성 지수\n", + "M = good.pivot_table(index=\"part\", columns=\"month\", values=\"qdr_id\", aggfunc=\"size\", fill_value=0).reindex(index=range(NP), columns=range(24), fill_value=0)\n", + "rate = (M / use[:24]).T.rolling(12, min_periods=12).mean().shift(1).T\n", + "exp = rate * use[:24]\n", + "Z = (M - exp) / np.sqrt(exp.clip(lower=1))\n", + "A = (M / exp.clip(lower=1) > CONFIG[\"alarm_ratio\"]) & (Z > CONFIG[\"alarm_z\"])\n", + "W_SEV = np.array([1, 3, 9])\n", + "V = good.assign(v=W_SEV[good[\"sev_pred\"]]).groupby(\"part\")[\"v\"].sum()\n", + "recent = good[good[\"month\"] >= 21].assign(v=lambda d: W_SEV[d[\"sev_pred\"]]).groupby(\"part\")[\"v\"].sum().sort_values(ascending=False)\n", + "\n", + "# 후보 제안(승인 대기): 최근 12개월 근거\n", + "ev = good[good[\"month\"] >= 12]\n", + "cands = []\n", + "for p, g in ev.groupby(\"part\"):\n", + " mf, ds = (g[\"cause_pred\"] == \"제조 불량\").mean(), (g[\"cause_pred\"] == \"설계 취약\").mean()\n", + " if len(g) >= CONFIG[\"min_cases\"] and mf >= CONFIG[\"mfg_share\"]: cands.append((names[p], \"하자 구상\", len(g), mf))\n", + " if len(g) >= CONFIG[\"min_cases\"] and ds >= CONFIG[\"design_share\"]: cands.append((names[p], \"설계 변경\", len(g), ds))\n", + "for v, g in ev.groupby(\"vendor\"):\n", + " sh = (g[\"cause_pred\"] == \"정비 품질\").mean()\n", + " if v != \"부대정비\" and len(g) >= 100 and sh >= CONFIG[\"vendor_share\"]: cands.append((v, \"외주정비 시정\", len(g), sh))\n", + "C = pd.DataFrame(cands, columns=[\"대상\", \"유형\", \"근거 건수\", \"원인 비중\"])\n", + "C[\"status\"] = \"PENDING\" # 품질관 결정 전까지 확정하지 않는다\n", + "\n", + "# ---------------- 2. 자체 점검 ----------------\n", + "_, bad_src = sev_safe(None, good.head(3))\n", + "checks = {\n", + " \"QDR 유실 0건\": len(good) + len(bad) == len(ops),\n", + " \"손상 서술 2건 수동 확인 분리\": len(bad) == 2,\n", + " \"재학습 파이프라인 실행\": \"교체\" in rt,\n", + " \"치명도 정확도 80% 이상\": bool(acc >= 0.80),\n", + " \"모델 장애 시 키워드 규칙 대체\": bad_src == \"키워드 규칙 대체\",\n", + " \"경보 셀 모두 12개월 이후\": not bool(A.iloc[:, :12].values.any()),\n", + " \"후보 제안 1건 이상 생성\": len(C) >= 1,\n", + " \"자동 승인 0건(후보는 대기)\": bool((C[\"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", + "fig, ax = plt.subplots(1, 2, figsize=(11, 4))\n", + "ax[0].imshow((M / exp.clip(lower=1)).clip(upper=3).values, aspect=\"auto\", cmap=\"Reds\"); ax[0].set_title(\"QDR concentration (x expected)\"); ax[0].set_xlabel(\"month\"); ax[0].set_ylabel(\"part\")\n", + "ax[1].bar(range(NP), V.reindex(range(NP)).fillna(0).values); ax[1].set_title(\"Vulnerability index by part\"); ax[1].set_xlabel(\"part id\")\n", + "plt.tight_layout(); plt.show()\n", + "\n", + "fa = [(names[p], int(m)) for p, m in zip(*np.where(A.values))]\n", + "print(\"\\n[품질 담당 장교 브리핑 요약] [교육용가상자료]\")\n", + "print(f\"1. 운용 24개월 QDR {len(ops)}건 중 손상 {len(bad)}건 수동 확인, 치명도 Critical {int((good['sev_pred'] == 2).sum())}건({(good['sev_pred'] == 2).mean():.0%})\")\n", + "print(\"2. 최근 3개월 취약성 지수 상위 부위: \" + \", \".join(f\"{names[p]} {v:.0f}\" for p, v in recent.head(3).items()))\n", + "print(f\"3. 월별 급증 경보 {len(fa)}건\" + (\": \" + \", \".join(f\"{n}({m}월)\" for n, m in fa[:3]) if fa else \"\"))\n", + "print(f\"4. 후보 제안 {len(C)}건은 품질관 승인 대기(자동 승인 0건): \" + \", \".join(f\"{r['대상']} {r['유형']}\" for _, r in C.head(4).iterrows()))\n", + "vb = good[good[\"vendor\"] == \"외주B\"]\n", + "print(f\"5. 외주B 검사 대기 평균 {vb['inspect_wait'].mean():.1f}일(타 수행처 {good.loc[good['vendor'] != '외주B', 'inspect_wait'].mean():.1f}일), 재검사율 {vb['reinspect'].mean():.0%}\")\n", + "print(f\"6. 치명도 모델: {'신규 실적으로 교체' if rt['교체'] else '기존 유지'}(검증 정확도 {max(rt['챔피언'], rt['도전자']):.1%})\")\n", + "print(\"7. 건의: 외주정비 업체 시정 요구와 제조사 하자 구상 근거 정리, 국방 군수 정보체계와 QDR 표준 필드 연동 인터페이스 정의\")\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 10 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + "[교육용가상자료] 재학습: {'챔피언': 0.867, '도전자': 0.865, '교체': True} -> 도전자 교체\n", + "운용 QDR 8923건: 분석 8921건, 수동 확인 2건, 치명도 출처 '모델', 정확도 87.8%, 전체 실행 7.3초\n", + "\n", + "자체 점검 8/8 통과, 자동 재학습 파이프라인 정상 실행\n", + "운영 문서 7개 장 작성률 100% (목표 100%: 달성) - 목적과 범위, 입력 데이터, 일일 운용 절차, 승인(HITL) 기준, 재학습 절차, 장애 대응, 연동 인터페이스\n", + "\n", + "[품질 담당 장교 브리핑 요약] [교육용가상자료]\n", + "1. 운용 24개월 QDR 8923건 중 손상 2건 수동 확인, 치명도 Critical 717건(8%)\n", + "2. 최근 3개월 취약성 지수 상위 부위: 제동 장치 378, 조향 장치 268, 엔진 연료계통 244\n", + "3. 월별 급증 경보 3건: 조향 장치(20월), 냉각 계통(16월), 전기·배터리(18월)\n", + "4. 후보 제안 3건은 품질관 승인 대기(자동 승인 0건): 냉각 계통 하자 구상, 전기·배터리 하자 구상, 외주B 외주정비 시정\n", + "5. 외주B 검사 대기 평균 8.7일(타 수행처 4.5일), 재검사율 26%\n", + "6. 치명도 모델: 신규 실적으로 교체(검증 정확도 86.7%)\n", + "7. 건의: 외주정비 업체 시정 요구와 제조사 하자 구상 근거 정리, 국방 군수 정보체계와 QDR 표준 필드 연동 인터페이스 정의\n", + "```\n", + "
\n" + ] + } + ], + "metadata": { + "colab": { + "provenance": [] + }, + "kernelspec": { + "display_name": "Python 3", + "name": "python3" + }, + "language_info": { + "name": "python" + } + }, + "nbformat": 4, + "nbformat_minor": 0 +} \ No newline at end of file diff --git a/uploads/SEC01/SEC01_solution.ipynb b/uploads/SEC01/SEC01_solution.ipynb new file mode 100644 index 0000000..eb82e0f --- /dev/null +++ b/uploads/SEC01/SEC01_solution.ipynb @@ -0,0 +1,3525 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# 🎯 [SEC01] 기지 CCTV 드론·비인가 접근 객체 탐지 프로토타입\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/SEC01/SEC01_solution.ipynb)\n", + "\n", + "---\n", + "\n", + "## 📌 1. 프로젝트 개요 및 배경\n", + "\n", + "| 항목 | 내용 |\n", + "| :--- | :--- |\n", + "| **과정 분류** | 시설·방호·경계 (Level 3) |\n", + "| **학습 소요 시간** | 10개 미션 (5일 / 총 27.5시간) |\n", + "| **환경 사양** | Python 3.10+, numpy, pandas, matplotlib, scipy, scikit-learn |\n", + "\n", + "### 📖 프로젝트 목표\n", + "미국-이란 전쟁에서는 소형 드론과 차량·인원의 접근 시도가 기지 외곽 경계의 주요 위협으로 부각되었고, 많은 CCTV 화면에서 실제 침입과 수목 흔들림·야생동물을 빠르게 구분하는 일이 시설·방호 업무의 핵심이 되었다. 본 프로젝트에서는 교육용 가상 CCTV 영상을 코드로 직접 그려, 영상 데이터 스키마 정의, 조도·기상 전처리와 경계구역(ROI) 설정, 울타리 접근 객체 탐지 Baseline, 소형 드론·위장 침입자 식별 고도화, 오경보 필터와 신뢰도 점수, 다채널 관제 상황판, 경계병 육안 확인 중심의 HITL 승인, 악천후·다지점 동시 침입 스트레스 시험, 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. 군사기지 CCTV 영상 규격 분석 및 비인가 침입 객체(드론/차량/인원) 데이터셋 정의\n", + "**소요시간**: 약 1.5시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "군사기지 외곽 감시 CCTV 영상 규격과 비인가 침입 객체(드론, 차량, 거동수상자) 데이터셋을 정의한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **기지 외곽 고정 CCTV 영상의 규격과 비인가 침입 객체(드론·차량·인원) 라벨 구조를 정의하고, 잘못된 데이터를 걸러내는 검증기를 만든다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **인원, 차량, 드론을 침입 대상으로, 동물과 조류를 오경보 유발 대상으로 정해 클래스 체계를 세우고 카메라(해상도, fps), 전술 파라미터(기상, 바람, 위치오차, 통신지연), 경계구역 행 범위, 바운딩 박스 라벨 필드를 담은 스키마 설계하고 작성하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `data/schema/sec01_input_schema.json 및 docs/sec01_data_spec.md` 형태의 결과물을 완성한다.\n", + "\n", + "### 🔍 평가 및 검증 기준 (PASS 조건)\n", + "#### 미션 판정\n", + "\n", + "**평가 구분:** 필수\n", + "\n", + "**핵심 평가영역:** 시설·방호·경계 실무 및 미국-이란 전쟁 전장 환경과의 군사적 부합성\n", + "\n", + "#### 미션 PASS 조건\n", + "\n", + "- 정량 기준: 스키마 필드 정의 완성도 100%, 샘플 데이터 검증 오류 0건\n", + "\n", + "\n", + "- 진행 기준: 입력 데이터 유효성 검증 스크립트 통과 및 명세서 문서화 완료\n", + "\n", + "\n", + "- 검증 방법: JSON Schema 검증 도구 및 샘플 데이터 파싱 단위 테스트\n", + "\n", + "\n", + "#### 채점자 판정 메모\n", + "\n", + "> 단순 코드 실행 여부뿐만 아니라 출력된 수치와 근거 자료가 군 실무 요구사항에 부합하는지 면밀히 확인한다.\n", + "\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "\"\"\"기지 외곽 CCTV 영상 메타데이터와 비인가 침입 객체(드론·차량·인원) 라벨의 스키마(카메라 규격, 전술 파라미터, 경계구역, 바운딩 박스)를 정의하고, 코드로 그린 합성 영상에서 만든 샘플 20건을 검증한다.\"\"\"\n", + "import json\n", + "import re\n", + "import numpy as np\n", + "import pandas as pd\n", + "\n", + "# ---------------- [교육용가상자료] 합성 CCTV 영상 생성기 ----------------\n", + "# 실제 영상 대신 코드로 그린 객체(사각형·점)로 기지 외곽 고정 카메라 화면을 흉내 낸다.\n", + "H, W, T = 64, 96, 10 # 화면 크기(px), 시퀀스 길이(프레임)\n", + "HORIZON, FENCE = 20, 46 # 하늘/지면 경계, 울타리 행\n", + "ROI_ROWS = (32, 48) # 울타리 접근 경계구역(ROI): 지면 32~47행\n", + "CLASSES = [\"person\", \"vehicle\", \"drone\", \"animal\", \"bird\"]\n", + "INTRUDER = {\"person\", \"vehicle\", \"drone\"} # 비인가 접근(경보 대상)\n", + "CONDS = [\"day\", \"night\", \"rain\", \"fog\"]\n", + "\n", + "\n", + "def background(rng):\n", + " bg = np.zeros((H, W))\n", + " bg[:HORIZON] = np.linspace(200, 170, HORIZON)[:, None] # 하늘\n", + " bg[HORIZON:] = 95 + rng.normal(0, 3, (H - HORIZON, W)) # 지면 질감\n", + " bg[26:30] = 115 # 외곽 도로\n", + " bg[FENCE] = 60; bg[FENCE - 3:FENCE + 1, ::8] = 60 # 울타리·기둥\n", + " return bg\n", + "\n", + "\n", + "def tree(img, t, wind):\n", + " \"\"\"좌측 수목 수관: 바람 세기에 따라 좌우로 흔들려 오경보를 만든다.\"\"\"\n", + " s = int(round(wind * np.sin(t * 1.3)))\n", + " img[8:30, max(0, 2 + s):14 + s] = 55\n", + "\n", + "\n", + "def make_seq(seed, cond=None, objs=None, wind=None, camo=False):\n", + " rng = np.random.default_rng(seed)\n", + " cond = cond or str(rng.choice(CONDS))\n", + " wind = rng.uniform(0, 2) if wind is None else wind\n", + " bg = background(rng)\n", + " n = int(rng.integers(1, 4)) if objs is None else len(objs)\n", + " kinds = objs or list(rng.choice(CLASSES, n, p=[0.3, 0.15, 0.25, 0.15, 0.15]))\n", + " obs = []\n", + " for k in kinds:\n", + " if k == \"person\":\n", + " o = dict(y=rng.uniform(30, 36), x=rng.uniform(15, 85), vy=rng.uniform(0.8, 1.2), vx=rng.uniform(-0.6, 0.6), h=7, w=3,\n", + " val=72 if camo else rng.uniform(35, 50))\n", + " elif k == \"vehicle\":\n", + " o = dict(y=rng.uniform(33, 38), x=rng.uniform(10, 30), vy=0.2, vx=rng.uniform(2, 4), h=5, w=10, val=rng.uniform(150, 170))\n", + " elif k == \"drone\":\n", + " o = dict(y=rng.uniform(3, 15), x=rng.uniform(10, 80), vy=rng.uniform(0, 0.6), vx=rng.uniform(-2, 2), h=2, w=2 if camo else 3,\n", + " val=rng.uniform(80, 120))\n", + " elif k == \"animal\":\n", + " o = dict(y=rng.uniform(34, 42), x=rng.uniform(15, 80), vy=0, vx=rng.uniform(-1.5, 1.5), h=2, w=4, val=rng.uniform(60, 75))\n", + " else:\n", + " o = dict(y=rng.uniform(3, 15), x=rng.uniform(10, 80), vy=0, vx=rng.uniform(-3, 3), h=1, w=2, val=rng.uniform(100, 130))\n", + " o[\"cls\"] = str(k); obs.append(o)\n", + " frames, labels = [], []\n", + " for t in range(-8, T): # 음수 시각 = 객체 없는 배경 학습 구간(사전 관측)\n", + " f = bg.copy(); tree(f, t, wind); lab = []\n", + " for oi, o in enumerate(obs if t >= 0 else []):\n", + " jit = rng.normal(0, 1.2, 2) if o[\"cls\"] in (\"animal\", \"bird\") else (0, 0) # 불규칙 움직임\n", + " y, x = int(o[\"y\"] + o[\"vy\"] * t + jit[0]), int(o[\"x\"] + o[\"vx\"] * t + jit[1])\n", + " y0, x0, y1, x1 = max(y, 0), max(x, 0), min(y + o[\"h\"], H), min(x + o[\"w\"], W)\n", + " if y1 > y0 and x1 > x0:\n", + " f[y0:y1, x0:x1] = o[\"val\"]\n", + " if o[\"cls\"] == \"person\":\n", + " f[max(y0 - 2, 0):y0, x0 + 1:x0 + 2] = o[\"val\"] # 머리\n", + " lab.append({\"cls\": o[\"cls\"], \"oid\": oi, \"bbox\": (x0, y0, x1, y1), \"intruder\": o[\"cls\"] in INTRUDER})\n", + " if cond == \"night\":\n", + " f = f * 0.5 + 10 + rng.normal(0, 3.5, f.shape)\n", + " else:\n", + " f = f + rng.normal(0, 2.5, f.shape)\n", + " if cond == \"rain\":\n", + " m = rng.random(f.shape) < 0.006\n", + " for d in range(3):\n", + " f[d:][m[:H - d]] += 45\n", + " if cond == \"fog\":\n", + " f = 0.45 * f + 0.55 * 150\n", + " frames.append(np.clip(f, 0, 255)); labels.append(lab)\n", + " fr = np.array(frames)\n", + " return {\"id\": f\"CAM-{seed:04d}\", \"cond\": cond, \"wind\": wind, \"ref\": fr[:8], \"frames\": fr[8:], \"labels\": labels[8:]}\n", + "\n", + "# 1) 스키마 정의: 카메라 규격, 전술 파라미터(기상·위치오차·통신지연), 경계구역(ROI), 객체 라벨\n", + "SCHEMA = {\n", + " \"required\": [\"camera_id\", \"n_frames\", \"camera\", \"tactical\", \"roi\", \"labels\"],\n", + " \"fields\": {\n", + " \"camera_id\": {\"type\": str, \"pattern\": r\"^CAM-\\d{4}$\"},\n", + " \"n_frames\": {\"type\": int, \"min\": 1, \"max\": 3000},\n", + " },\n", + " \"camera\": {\n", + " \"mode\": {\"type\": str, \"enum\": [\"EO\", \"IR\"]},\n", + " \"resolution\": {\"type\": list, \"length\": 2},\n", + " \"fps\": {\"type\": (int, float), \"min\": 1, \"max\": 60},\n", + " },\n", + " \"tactical\": {\n", + " \"condition\": {\"type\": str, \"enum\": [\"day\", \"night\", \"rain\", \"fog\"]},\n", + " \"wind_level\": {\"type\": (int, float), \"min\": 0, \"max\": 10},\n", + " \"position_error_m\": {\"type\": (int, float), \"min\": 0, \"max\": 100},\n", + " \"comm_delay_ms\": {\"type\": (int, float), \"min\": 0, \"max\": 10000},\n", + " },\n", + " \"roi\": {\n", + " \"row_start\": {\"type\": int, \"min\": 0, \"max\": 200},\n", + " \"row_end\": {\"type\": int, \"min\": 1, \"max\": 200},\n", + " },\n", + " \"label\": {\n", + " \"class\": {\"type\": str, \"enum\": CLASSES},\n", + " \"bbox\": {\"type\": list, \"length\": 4},\n", + " \"frame\": {\"type\": int, \"min\": 0},\n", + " \"intruder\": {\"type\": bool},\n", + " },\n", + "}\n", + "\n", + "\n", + "def check(name, value, rule):\n", + " \"\"\"값 하나를 규칙(type, enum, min, max, length, pattern)으로 검사해 오류 목록을 돌려준다.\"\"\"\n", + " if not isinstance(value, rule[\"type\"]) or (isinstance(value, bool) and rule[\"type\"] is not bool):\n", + " return [f\"{name}: 자료형 오류({type(value).__name__})\"]\n", + " errs = []\n", + " if \"enum\" in rule and value not in rule[\"enum\"]:\n", + " errs.append(f\"{name}: 허용되지 않은 값 '{value}'\")\n", + " if \"min\" in rule and value < rule[\"min\"]:\n", + " errs.append(f\"{name}: 최소값 {rule['min']} 미만\")\n", + " if \"max\" in rule and value > rule[\"max\"]:\n", + " errs.append(f\"{name}: 최대값 {rule['max']} 초과\")\n", + " if \"length\" in rule and len(value) != rule[\"length\"]:\n", + " errs.append(f\"{name}: 길이 {rule['length']} 필요\")\n", + " if \"pattern\" in rule and not re.match(rule[\"pattern\"], value):\n", + " errs.append(f\"{name}: 형식 불일치 '{value}'\")\n", + " return errs\n", + "\n", + "\n", + "def validate(rec):\n", + " errs = [f\"{k}: 필수 필드 누락\" for k in SCHEMA[\"required\"] if k not in rec]\n", + " for k, rule in SCHEMA[\"fields\"].items():\n", + " if k in rec:\n", + " errs += check(k, rec[k], rule)\n", + " for group in (\"camera\", \"tactical\", \"roi\"):\n", + " for k, rule in SCHEMA[group].items():\n", + " if k not in rec.get(group, {}):\n", + " errs.append(f\"{group}.{k}: 필수 필드 누락\")\n", + " else:\n", + " errs += check(f\"{group}.{k}\", rec[group][k], rule)\n", + " roi = rec.get(\"roi\", {})\n", + " if \"row_start\" in roi and \"row_end\" in roi and roi[\"row_start\"] >= roi[\"row_end\"]:\n", + " errs.append(\"roi: row_start는 row_end보다 작아야 함\")\n", + " for i, lab in enumerate(rec.get(\"labels\", [])):\n", + " for k, rule in SCHEMA[\"label\"].items():\n", + " if k not in lab:\n", + " errs.append(f\"labels[{i}].{k}: 필수 필드 누락\")\n", + " else:\n", + " errs += check(f\"labels[{i}].{k}\", lab[k], rule)\n", + " return errs\n", + "\n", + "\n", + "# 2) [교육용가상자료] 합성 CCTV 시퀀스 20개에서 메타데이터 샘플 생성\n", + "rng = np.random.default_rng(1)\n", + "records = []\n", + "for i in range(20):\n", + " seq = make_seq(100 + i)\n", + " labels = [{\"class\": l[\"cls\"], \"frame\": t, \"bbox\": [int(v) for v in l[\"bbox\"]], \"intruder\": bool(l[\"intruder\"])}\n", + " for t in (0, 5, 9) for l in seq[\"labels\"][t]]\n", + " records.append({\"camera_id\": seq[\"id\"], \"n_frames\": T,\n", + " \"camera\": {\"mode\": \"IR\" if seq[\"cond\"] == \"night\" else \"EO\", \"resolution\": [W, H], \"fps\": 10},\n", + " \"tactical\": {\"condition\": seq[\"cond\"], \"wind_level\": round(float(seq[\"wind\"]) * 2.5, 1),\n", + " \"position_error_m\": round(float(rng.uniform(1, 15)), 1), \"comm_delay_ms\": int(rng.integers(20, 400))},\n", + " \"roi\": {\"row_start\": ROI_ROWS[0], \"row_end\": ROI_ROWS[1]}, \"labels\": labels})\n", + "\n", + "# 3) 정상 데이터 검증\n", + "ok = sum(1 for r in records if not validate(r))\n", + "print(f\"정상 샘플 {len(records)}건 중 검증 통과 {ok}건, 오류 {len(records) - ok}건 -> 목표(검증 오류 0건): {'달성' if ok == len(records) else '미달'}\")\n", + "\n", + "# 4) 오류 주입 시험: 검증기가 잘못된 데이터를 잡아내는지 확인\n", + "bad = json.loads(json.dumps(records[0]))\n", + "bad[\"camera_id\"] = \"CAM-1\" # 형식\n", + "bad[\"camera\"][\"fps\"] = 120 # 범위\n", + "del bad[\"tactical\"][\"comm_delay_ms\"] # 누락\n", + "bad[\"labels\"][0][\"class\"] = \"ufo\" # 허용값\n", + "bad[\"roi\"][\"row_start\"] = 50 # 논리(시작 > 끝)\n", + "errs = validate(bad)\n", + "print(f\"\\n[오류 주입 결과] 주입 5종 -> 검출 {len(errs)}건\")\n", + "for e in errs:\n", + " print(\" -\", e)\n", + "print(f\"주입 오류 전부 검출: {'예' if len(errs) >= 5 else '아니오'}\")\n", + "\n", + "# 5) 스키마 필드 정의서(명세서 초안)\n", + "rows = [(\"기본\", k, r[\"type\"].__name__, str({x: y for x, y in r.items() if x != \"type\"})) for k, r in SCHEMA[\"fields\"].items()]\n", + "for g in (\"camera\", \"tactical\", \"roi\", \"label\"):\n", + " for k, r in SCHEMA[g].items():\n", + " t = r[\"type\"].__name__ if isinstance(r[\"type\"], type) else \"number\"\n", + " rows.append((g, k, t, str({x: y for x, y in r.items() if x != \"type\"})))\n", + "print(\"\\n[데이터 명세 요약] 필드 수\", len(rows))\n", + "print(pd.DataFrame(rows, columns=[\"그룹\", \"필드\", \"자료형\", \"규칙\"]).to_string(index=False))\n", + "print(\"\\n침입 대상 클래스:\", sorted(INTRUDER), \"/ 오경보 유발 클래스: animal, bird, 수목 흔들림(clutter)\")\n", + "print(\"클래스별 라벨 수:\", pd.Series([l[\"class\"] for r in records for l in r[\"labels\"]]).value_counts().to_dict())\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 01 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + "정상 샘플 20건 중 검증 통과 20건, 오류 0건 -> 목표(검증 오류 0건): 달성\n", + "\n", + "[오류 주입 결과] 주입 5종 -> 검출 5건\n", + " - camera_id: 형식 불일치 'CAM-1'\n", + " - camera.fps: 최대값 60 초과\n", + " - tactical.comm_delay_ms: 필수 필드 누락\n", + " - roi: row_start는 row_end보다 작아야 함\n", + " - labels[0].class: 허용되지 않은 값 'ufo'\n", + "주입 오류 전부 검출: 예\n", + "\n", + "[데이터 명세 요약] 필드 수 15\n", + " 그룹 필드 자료형 규칙\n", + " 기본 camera_id str {'pattern': '^CAM-\\\\d{4}$'}\n", + " 기본 n_frames int {'min': 1, 'max': 3000}\n", + " camera mode str {'enum': ['EO', 'IR']}\n", + " camera resolution list {'length': 2}\n", + " camera fps number {'min': 1, 'max': 60}\n", + "tactical condition str {'enum': ['day', 'night', 'rain', 'fog']}\n", + "tactical wind_level number {'min': 0, 'max': 10}\n", + "tactical position_error_m number {'min': 0, 'max': 100}\n", + "tactical comm_delay_ms number {'min': 0, 'max': 10000}\n", + " roi row_start int {'min': 0, 'max': 200}\n", + " roi row_end int {'min': 1, 'max': 200}\n", + " label class str {'enum': ['person', 'vehicle', 'drone', 'animal', 'bird']}\n", + " label bbox list {'length': 4}\n", + " label frame int {'min': 0}\n", + " label intruder bool {}\n", + "\n", + "침입 대상 클래스: ['drone', 'person', 'vehicle'] / 오경보 유발 클래스: animal, bird, 수목 흔들림(clutter)\n", + "클래스별 라벨 수: {'person': 57, 'vehicle': 27, 'drone': 21, 'bird': 15, 'animal': 15}\n", + "```\n", + "
\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 02. 주/야간 조도 변화 보정, 기상 잡음 필터링 및 침입 경계구역(ROI) 설정 전처리 구현\n", + "**소요시간**: 약 2.0시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "주/야간 조도 변화 보정, 기상 잡음 제거 및 울타리 관심구역(ROI) 설정 전처리 모듈을 구현한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **주야간 조도 변화, 비·잡음, 센서 단절이 섞인 CCTV 영상을 조건별로 보정하고 울타리 경계구역(ROI)을 설정하는 전처리 모듈을 구현한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **센서 단절(전 화면 0) 프레임을 직전 프레임으로 대체하고, 백분위 신장으로 조도를 맞추며, 비 조건에서는 3x3 중앙값으로 빗줄기를 줄이고, 하늘과 울타리 접근 지면 띠를 경계구역 마스크로 설정 수행하고 검증하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `src/preprocess/sec01_preprocessor.py 및 전처리 전후 데이터셋` 형태의 결과물을 완성한다.\n", + "\n", + "### 🔍 평가 및 검증 기준 (PASS 조건)\n", + "#### 미션 판정\n", + "\n", + "**평가 구분:** 필수\n", + "\n", + "**핵심 평가영역:** 전술 환경 특유의 노이즈(스푸핑, 반사파, 센서 단절 등) 제거 타당성\n", + "\n", + "#### 미션 PASS 조건\n", + "\n", + "- 정량 기준: 데이터 정제 처리 속도 1,000건/초 이상, 전처리 오류율 0%\n", + "\n", + "\n", + "- 진행 기준: 정제 파이프라인 자동화 및 단위 테스트 케이스 5종 통과\n", + "\n", + "\n", + "- 검증 방법: pytest 기반 전처리 정합성 테스트 및 결측치 보정율 확인\n", + "\n", + "\n", + "#### 채점자 판정 메모\n", + "\n", + "> 단순 코드 실행 여부뿐만 아니라 출력된 수치와 근거 자료가 군 실무 요구사항에 부합하는지 면밀히 확인한다.\n", + "\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "\"\"\"주/야간 조도 보정, 비·잡음 필터, 센서 단절 프레임 보정, 울타리 경계구역(ROI) 마스크를 묶은 CCTV 전처리 모듈을 구현하고, 처리 속도·단위 테스트 5종·조건별 전처리 전후 탐지 성능을 측정한다.\"\"\"\n", + "import time\n", + "import numpy as np\n", + "import pandas as pd\n", + "import matplotlib.pyplot as plt\n", + "from scipy import ndimage\n", + "\n", + "# ---------------- [교육용가상자료] 합성 CCTV 영상 생성기 ----------------\n", + "# 실제 영상 대신 코드로 그린 객체(사각형·점)로 기지 외곽 고정 카메라 화면을 흉내 낸다.\n", + "H, W, T = 64, 96, 10 # 화면 크기(px), 시퀀스 길이(프레임)\n", + "HORIZON, FENCE = 20, 46 # 하늘/지면 경계, 울타리 행\n", + "ROI_ROWS = (32, 48) # 울타리 접근 경계구역(ROI): 지면 32~47행\n", + "CLASSES = [\"person\", \"vehicle\", \"drone\", \"animal\", \"bird\"]\n", + "INTRUDER = {\"person\", \"vehicle\", \"drone\"} # 비인가 접근(경보 대상)\n", + "CONDS = [\"day\", \"night\", \"rain\", \"fog\"]\n", + "\n", + "\n", + "def background(rng):\n", + " bg = np.zeros((H, W))\n", + " bg[:HORIZON] = np.linspace(200, 170, HORIZON)[:, None] # 하늘\n", + " bg[HORIZON:] = 95 + rng.normal(0, 3, (H - HORIZON, W)) # 지면 질감\n", + " bg[26:30] = 115 # 외곽 도로\n", + " bg[FENCE] = 60; bg[FENCE - 3:FENCE + 1, ::8] = 60 # 울타리·기둥\n", + " return bg\n", + "\n", + "\n", + "def tree(img, t, wind):\n", + " \"\"\"좌측 수목 수관: 바람 세기에 따라 좌우로 흔들려 오경보를 만든다.\"\"\"\n", + " s = int(round(wind * np.sin(t * 1.3)))\n", + " img[8:30, max(0, 2 + s):14 + s] = 55\n", + "\n", + "\n", + "def make_seq(seed, cond=None, objs=None, wind=None, camo=False):\n", + " rng = np.random.default_rng(seed)\n", + " cond = cond or str(rng.choice(CONDS))\n", + " wind = rng.uniform(0, 2) if wind is None else wind\n", + " bg = background(rng)\n", + " n = int(rng.integers(1, 4)) if objs is None else len(objs)\n", + " kinds = objs or list(rng.choice(CLASSES, n, p=[0.3, 0.15, 0.25, 0.15, 0.15]))\n", + " obs = []\n", + " for k in kinds:\n", + " if k == \"person\":\n", + " o = dict(y=rng.uniform(30, 36), x=rng.uniform(15, 85), vy=rng.uniform(0.8, 1.2), vx=rng.uniform(-0.6, 0.6), h=7, w=3,\n", + " val=72 if camo else rng.uniform(35, 50))\n", + " elif k == \"vehicle\":\n", + " o = dict(y=rng.uniform(33, 38), x=rng.uniform(10, 30), vy=0.2, vx=rng.uniform(2, 4), h=5, w=10, val=rng.uniform(150, 170))\n", + " elif k == \"drone\":\n", + " o = dict(y=rng.uniform(3, 15), x=rng.uniform(10, 80), vy=rng.uniform(0, 0.6), vx=rng.uniform(-2, 2), h=2, w=2 if camo else 3,\n", + " val=rng.uniform(80, 120))\n", + " elif k == \"animal\":\n", + " o = dict(y=rng.uniform(34, 42), x=rng.uniform(15, 80), vy=0, vx=rng.uniform(-1.5, 1.5), h=2, w=4, val=rng.uniform(60, 75))\n", + " else:\n", + " o = dict(y=rng.uniform(3, 15), x=rng.uniform(10, 80), vy=0, vx=rng.uniform(-3, 3), h=1, w=2, val=rng.uniform(100, 130))\n", + " o[\"cls\"] = str(k); obs.append(o)\n", + " frames, labels = [], []\n", + " for t in range(-8, T): # 음수 시각 = 객체 없는 배경 학습 구간(사전 관측)\n", + " f = bg.copy(); tree(f, t, wind); lab = []\n", + " for oi, o in enumerate(obs if t >= 0 else []):\n", + " jit = rng.normal(0, 1.2, 2) if o[\"cls\"] in (\"animal\", \"bird\") else (0, 0) # 불규칙 움직임\n", + " y, x = int(o[\"y\"] + o[\"vy\"] * t + jit[0]), int(o[\"x\"] + o[\"vx\"] * t + jit[1])\n", + " y0, x0, y1, x1 = max(y, 0), max(x, 0), min(y + o[\"h\"], H), min(x + o[\"w\"], W)\n", + " if y1 > y0 and x1 > x0:\n", + " f[y0:y1, x0:x1] = o[\"val\"]\n", + " if o[\"cls\"] == \"person\":\n", + " f[max(y0 - 2, 0):y0, x0 + 1:x0 + 2] = o[\"val\"] # 머리\n", + " lab.append({\"cls\": o[\"cls\"], \"oid\": oi, \"bbox\": (x0, y0, x1, y1), \"intruder\": o[\"cls\"] in INTRUDER})\n", + " if cond == \"night\":\n", + " f = f * 0.5 + 10 + rng.normal(0, 3.5, f.shape)\n", + " else:\n", + " f = f + rng.normal(0, 2.5, f.shape)\n", + " if cond == \"rain\":\n", + " m = rng.random(f.shape) < 0.006\n", + " for d in range(3):\n", + " f[d:][m[:H - d]] += 45\n", + " if cond == \"fog\":\n", + " f = 0.45 * f + 0.55 * 150\n", + " frames.append(np.clip(f, 0, 255)); labels.append(lab)\n", + " fr = np.array(frames)\n", + " return {\"id\": f\"CAM-{seed:04d}\", \"cond\": cond, \"wind\": wind, \"ref\": fr[:8], \"frames\": fr[8:], \"labels\": labels[8:]}\n", + "\n", + "# ---------------- 전처리·탐지 공통 함수(배경 차분 기반) ----------------\n", + "def enhance(seq):\n", + " \"\"\"조도 보정(백분위 신장) + 비 조건의 빗줄기 제거(3x3 중앙값). 배경 학습 구간(ref)과 같은 기준으로 보정한다.\"\"\"\n", + " allf = np.concatenate([seq[\"ref\"], seq[\"frames\"]])\n", + " f = ndimage.median_filter(allf, size=(1, 3, 3)) if seq[\"cond\"] == \"rain\" else allf\n", + " lo, hi = np.percentile(f, [1, 99.5])\n", + " f = np.clip((f - lo) / (hi - lo + 1e-6) * 255, 0, 255)\n", + " return f[:len(seq[\"ref\"])], f[len(seq[\"ref\"]):]\n", + "\n", + "\n", + "def detect(ref, frames, k=4.0, min_area=3, sky_k=None, smooth=False):\n", + " \"\"\"배경 학습 구간의 중앙값 배경과 차이를 강건 임계값(k x MAD)으로 이진화해 연결 성분을 객체 후보로 만든다.\n", + " smooth=True 이면 연속 3프레임 평균으로 시간 잡음을 줄여 저대비 객체를 살린다.\"\"\"\n", + " bg = np.median(ref, axis=0)\n", + " if smooth:\n", + " frames = ndimage.uniform_filter1d(frames, 3, axis=0, mode=\"nearest\")\n", + " out = []\n", + " for f in frames:\n", + " d = np.abs(f - bg)\n", + " mad = np.median(d) * 1.4826 + 1e-6\n", + " thr = np.full(d.shape, k * mad)\n", + " if sky_k is not None:\n", + " thr[:HORIZON] = sky_k * mad\n", + " lab, n = ndimage.label(d > thr)\n", + " dets = []\n", + " for i, sl in enumerate(ndimage.find_objects(lab), start=1):\n", + " area = int((lab[sl] == i).sum())\n", + " if area >= min_area:\n", + " dets.append({\"bbox\": (sl[1].start, sl[0].start, sl[1].stop, sl[0].stop), \"area\": area,\n", + " \"contrast\": float(d[sl][lab[sl] == i].mean() / mad)})\n", + " out.append(dets)\n", + " return out\n", + "\n", + "\n", + "def center(b):\n", + " return ((b[0] + b[2]) / 2, (b[1] + b[3]) / 2)\n", + "\n", + "\n", + "def match(dets, labels, dist=4.0):\n", + " \"\"\"탐지와 정답을 중심 거리로 1:1 대응시킨다. 반환: [(det, label or None)], 놓친 label 목록\"\"\"\n", + " used, pairs = set(), []\n", + " for d in dets:\n", + " cd = center(d[\"bbox\"]); best, bj = dist, None\n", + " for j, l in enumerate(labels):\n", + " cl = center(l[\"bbox\"]); e = np.hypot(cd[0] - cl[0], cd[1] - cl[1])\n", + " if j not in used and e < best:\n", + " best, bj = e, j\n", + " if bj is not None:\n", + " used.add(bj)\n", + " pairs.append((d, labels[bj] if bj is not None else None))\n", + " return pairs, [l for j, l in enumerate(labels) if j not in used]\n", + "\n", + "\n", + "def in_roi(b):\n", + " cy = (b[1] + b[3]) / 2\n", + " return cy < HORIZON or ROI_ROWS[0] <= cy < ROI_ROWS[1]\n", + "\n", + "\n", + "def rule_class(b, area):\n", + " \"\"\"Baseline 규칙: 위치(하늘/지면)·크기·종횡비로 1차 분류(학습 없음, 판단 근거가 그대로 드러난다)\"\"\"\n", + " w, h = b[2] - b[0], b[3] - b[1]\n", + " cy = (b[1] + b[3]) / 2\n", + " if cy < HORIZON:\n", + " return \"drone\" if 3 <= area <= 12 else \"clutter\"\n", + " if area >= 30 and w > h:\n", + " return \"vehicle\"\n", + " if h >= w and 6 <= area <= 30:\n", + " return \"person\"\n", + " return \"clutter\"\n", + "\n", + "\n", + "def repair_frames(f):\n", + " \"\"\"센서 단절(전 화면 0 또는 NaN) 프레임을 직전 정상 프레임으로 대체하고 보정 건수를 돌려준다.\"\"\"\n", + " f = f.copy(); fixed = 0\n", + " for t in range(len(f)):\n", + " if np.isnan(f[t]).any() or f[t].max() < 1:\n", + " f[t] = f[t - 1] if t > 0 else np.nanmedian(f[1:], axis=0); fixed += 1\n", + " return f, fixed\n", + "\n", + "\n", + "def roi_mask():\n", + " \"\"\"경계구역: 하늘(드론 접근) + 울타리 접근 지면 띠. 수목 영역(좌측 x<16)은 감시 제외 후보로 표시만 한다.\"\"\"\n", + " m = np.zeros((H, W), bool)\n", + " m[:HORIZON] = True; m[ROI_ROWS[0]:ROI_ROWS[1]] = True\n", + " return m\n", + "\n", + "\n", + "def preprocess(seq):\n", + " \"\"\"반환: (보정된 배경 구간, 보정된 감시 구간, 보정 건수). 조건별 처리를 자동 선택한다.\"\"\"\n", + " allf, fixed = repair_frames(np.concatenate([seq[\"ref\"], seq[\"frames\"]]))\n", + " s2 = {**seq, \"ref\": allf[:len(seq[\"ref\"])], \"frames\": allf[len(seq[\"ref\"]):]}\n", + " ref, fr = enhance(s2)\n", + " return ref, fr, fixed\n", + "\n", + "\n", + "def corrupt(seq, rate, rng):\n", + " \"\"\"센서 단절 모의: 감시 구간 프레임 일부를 0으로 만든다(스푸핑·단절 잡음의 단순화).\"\"\"\n", + " fr = seq[\"frames\"].copy()\n", + " for t in rng.choice(len(fr), int(round(rate * len(fr))), replace=False):\n", + " fr[t] = 0\n", + " return {**seq, \"frames\": fr}\n", + "\n", + "\n", + "def f1_of(seqs, use_pre, mask=None):\n", + " tp = fp = fn = 0\n", + " for q in seqs:\n", + " ref, fr = preprocess(q)[:2] if use_pre else (q[\"ref\"], q[\"frames\"])\n", + " for ds, labs in zip(detect(ref, fr), q[\"labels\"]):\n", + " labs = [l for l in labs if l[\"intruder\"]]\n", + " if mask is not None:\n", + " ds = [d for d in ds if in_roi(d[\"bbox\"])]\n", + " pairs, missed = match(ds, labs)\n", + " tp += sum(l is not None for _, l in pairs); fp += sum(l is None for _, l in pairs); fn += len(missed)\n", + " p, r = tp / max(tp + fp, 1), tp / max(tp + fn, 1)\n", + " return round(p, 3), round(r, 3), round(2 * p * r / max(p + r, 1e-9), 3)\n", + "\n", + "\n", + "rng = np.random.default_rng(5)\n", + "data = [make_seq(s) for s in range(300, 340)]\n", + "noisy = [corrupt(q, 0.2, rng) if i % 2 == 0 else q for i, q in enumerate(data)] # 절반에 센서 단절 20% 주입\n", + "\n", + "# 1) 처리 속도와 오류율: 프레임 단위 전처리(조도 보정 + 필터)를 시퀀스 40개에 적용\n", + "t0 = time.perf_counter(); n_frames = 0; errors = 0\n", + "for q in noisy:\n", + " try:\n", + " ref, fr, _ = preprocess(q); n_frames += len(ref) + len(fr)\n", + " errors += int(not np.isfinite(fr).all() or fr.min() < 0 or fr.max() > 255)\n", + " except Exception:\n", + " errors += 1\n", + "sec = time.perf_counter() - t0\n", + "print(f\"전처리 {n_frames}프레임 / {sec:.2f}초 = {n_frames / sec:,.0f}프레임/초 (목표 1,000 이상: {'달성' if n_frames / sec >= 1000 else '미달'})\")\n", + "print(f\"전처리 오류(NaN·범위 이탈) {errors}건 -> 오류율 {errors / len(noisy):.0%} (목표 0%)\")\n", + "\n", + "# 2) 단위 테스트 5종\n", + "def t1():\n", + " f = np.full((4, H, W), 100.0); f[2] = 0\n", + " out, n = repair_frames(f); assert n == 1 and out[2].mean() == 100\n", + "def t2():\n", + " f = np.full((3, H, W), 100.0); f[1, 5, 5] = np.nan\n", + " out, n = repair_frames(f); assert n == 1 and np.isfinite(out).all()\n", + "def t3():\n", + " ref, fr, _ = preprocess(make_seq(1, cond=\"night\")); assert 0 <= fr.min() and fr.max() <= 255\n", + "def t4():\n", + " m = roi_mask(); assert m[10, 10] and m[40, 40] and not m[25, 40] and not m[55, 40]\n", + "def t5():\n", + " q = make_seq(2, cond=\"rain\"); ref, fr, _ = preprocess(q); assert fr.shape == q[\"frames\"].shape\n", + "tests = {\"단절 프레임 보정\": t1, \"NaN 프레임 보정\": t2, \"야간 밝기 범위\": t3, \"ROI 마스크 경계\": t4, \"출력 형상 보존\": t5}\n", + "passed = 0\n", + "for name, fn in tests.items():\n", + " try:\n", + " fn(); passed += 1; print(f\"[PASS] {name}\")\n", + " except AssertionError:\n", + " print(f\"[FAIL] {name}\")\n", + "print(f\"단위 테스트 {passed}/5 통과\")\n", + "\n", + "# 3) 조건별 전처리 전후 탐지 성능(센서 단절 주입 포함)\n", + "rows = []\n", + "for c in CONDS + [\"전체\"]:\n", + " sub = [q for q in noisy if c == \"전체\" or q[\"cond\"] == c]\n", + " b, a = f1_of(sub, False), f1_of(sub, True)\n", + " ar = f1_of(sub, True, mask=True)\n", + " rows.append({\"조건\": c, \"시퀀스\": len(sub), \"전 F1\": b[2], \"후 F1\": a[2], \"후 재현율\": a[1], \"후+ROI 정밀도\": ar[0], \"후+ROI F1\": ar[2]})\n", + "df = pd.DataFrame(rows)\n", + "print(\"\\n[조건별 전처리 전후 탐지 F1] (전: 원본 그대로 / 후: 단절 보정+조도 보정+필터 / ROI: 경계구역 밖 탐지 제외)\")\n", + "print(df.to_string(index=False))\n", + "print(\"전술 노이즈 대응: 센서 단절 프레임은 직전 프레임으로 대체, 비는 3x3 중앙값, 야간 조도는 백분위 신장으로 보정하며 스푸핑 영상 판별은 본 과제 범위 밖이다.\")\n", + "\n", + "q = noisy[2]; ref, fr, _ = preprocess(q)\n", + "fig, ax = plt.subplots(1, 3, figsize=(12, 3.5))\n", + "for a, im, ti in zip(ax, [q[\"frames\"][4], fr[4], fr[4] * roi_mask()], [\"raw\", \"preprocessed\", \"preprocessed + ROI mask\"]):\n", + " a.imshow(im, cmap=\"gray\", vmin=0, vmax=255); a.set_title(f\"{q['id']} {q['cond']}: {ti}\"); a.axis(\"off\")\n", + "plt.tight_layout(); plt.show()\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 02 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + "전처리 720프레임 / 0.38초 = 1,890프레임/초 (목표 1,000 이상: 달성)\n", + "전처리 오류(NaN·범위 이탈) 0건 -> 오류율 0% (목표 0%)\n", + "[PASS] 단절 프레임 보정\n", + "[PASS] NaN 프레임 보정\n", + "[PASS] 야간 밝기 범위\n", + "[PASS] ROI 마스크 경계\n", + "[PASS] 출력 형상 보존\n", + "단위 테스트 5/5 통과\n", + "\n", + "[조건별 전처리 전후 탐지 F1] (전: 원본 그대로 / 후: 단절 보정+조도 보정+필터 / ROI: 경계구역 밖 탐지 제외)\n", + " 조건 시퀀스 전 F1 후 F1 후 재현율 후+ROI 정밀도 후+ROI F1\n", + " day 13 0.623 0.639 0.956 0.493 0.649\n", + "night 14 0.484 0.493 0.868 0.488 0.625\n", + " rain 7 0.083 0.303 0.792 0.343 0.479\n", + " fog 6 0.389 0.387 0.940 0.247 0.390\n", + " 전체 40 0.247 0.463 0.886 0.419 0.568\n", + "전술 노이즈 대응: 센서 단절 프레임은 직전 프레임으로 대체, 비는 3x3 중앙값, 야간 조도는 백분위 신장으로 보정하며 스푸핑 영상 판별은 본 과제 범위 밖이다.\n", + "```\n", + "
\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 03. 기지 외곽 울타리 접근 객체 탐지 Baseline 비전 모델 구현\n", + "**소요시간**: 약 2.5시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "기지 울타리 접근 객체를 1차 감지하는 Baseline 객체 탐지 비전 모델을 구현한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **울타리 접근 객체를 1차로 감지하는 규칙 기반 Baseline 비전 모델을 구현하고 판단 기준을 투명하게 기록한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **학습한 배경과의 차이를 잡음 수준의 4배(MAD 기준)로 이진화하고 연결 영역을 객체 후보로 만든 뒤, 위치(하늘/지면)·크기·종횡비 규칙으로 인원·차량·드론을 분류 수행하고 검증하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `src/models/sec01_baseline_model.py 및 기본 추론 결과 보고서` 형태의 결과물을 완성한다.\n", + "\n", + "### 🔍 평가 및 검증 기준 (PASS 조건)\n", + "#### 미션 판정\n", + "\n", + "**평가 구분:** 필수\n", + "\n", + "**핵심 평가영역:** 알고리즘의 논리적 흐름 및 군사적 판단 기준의 투명성\n", + "\n", + "#### 미션 PASS 조건\n", + "\n", + "- 정량 기준: 기본 데이터셋 대상 탐지/처리 정확도 75% 이상 달성\n", + "\n", + "\n", + "- 진행 기준: 단위 기능 동작 확인 및 기본 시나리오 실행 로그 생성\n", + "\n", + "\n", + "- 검증 방법: 기준 벤치마크 데이터셋 대상 추론 결과 오차율 비교\n", + "\n", + "\n", + "#### 채점자 판정 메모\n", + "\n", + "> 단순 코드 실행 여부뿐만 아니라 출력된 수치와 근거 자료가 군 실무 요구사항에 부합하는지 면밀히 확인한다.\n", + "\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "\"\"\"기지 외곽 고정 CCTV 합성 영상에서 배경 차분과 위치·크기·종횡비 규칙으로 울타리 접근 객체(인원·차량·드론)를 1차 감지하는 Baseline 비전 모델을 구현하고, 프레임 단위 탐지 정확도와 오경보를 측정한다.\"\"\"\n", + "import numpy as np\n", + "import pandas as pd\n", + "import matplotlib.pyplot as plt\n", + "from scipy import ndimage\n", + "\n", + "# ---------------- [교육용가상자료] 합성 CCTV 영상 생성기 ----------------\n", + "# 실제 영상 대신 코드로 그린 객체(사각형·점)로 기지 외곽 고정 카메라 화면을 흉내 낸다.\n", + "H, W, T = 64, 96, 10 # 화면 크기(px), 시퀀스 길이(프레임)\n", + "HORIZON, FENCE = 20, 46 # 하늘/지면 경계, 울타리 행\n", + "ROI_ROWS = (32, 48) # 울타리 접근 경계구역(ROI): 지면 32~47행\n", + "CLASSES = [\"person\", \"vehicle\", \"drone\", \"animal\", \"bird\"]\n", + "INTRUDER = {\"person\", \"vehicle\", \"drone\"} # 비인가 접근(경보 대상)\n", + "CONDS = [\"day\", \"night\", \"rain\", \"fog\"]\n", + "\n", + "\n", + "def background(rng):\n", + " bg = np.zeros((H, W))\n", + " bg[:HORIZON] = np.linspace(200, 170, HORIZON)[:, None] # 하늘\n", + " bg[HORIZON:] = 95 + rng.normal(0, 3, (H - HORIZON, W)) # 지면 질감\n", + " bg[26:30] = 115 # 외곽 도로\n", + " bg[FENCE] = 60; bg[FENCE - 3:FENCE + 1, ::8] = 60 # 울타리·기둥\n", + " return bg\n", + "\n", + "\n", + "def tree(img, t, wind):\n", + " \"\"\"좌측 수목 수관: 바람 세기에 따라 좌우로 흔들려 오경보를 만든다.\"\"\"\n", + " s = int(round(wind * np.sin(t * 1.3)))\n", + " img[8:30, max(0, 2 + s):14 + s] = 55\n", + "\n", + "\n", + "def make_seq(seed, cond=None, objs=None, wind=None, camo=False):\n", + " rng = np.random.default_rng(seed)\n", + " cond = cond or str(rng.choice(CONDS))\n", + " wind = rng.uniform(0, 2) if wind is None else wind\n", + " bg = background(rng)\n", + " n = int(rng.integers(1, 4)) if objs is None else len(objs)\n", + " kinds = objs or list(rng.choice(CLASSES, n, p=[0.3, 0.15, 0.25, 0.15, 0.15]))\n", + " obs = []\n", + " for k in kinds:\n", + " if k == \"person\":\n", + " o = dict(y=rng.uniform(30, 36), x=rng.uniform(15, 85), vy=rng.uniform(0.8, 1.2), vx=rng.uniform(-0.6, 0.6), h=7, w=3,\n", + " val=72 if camo else rng.uniform(35, 50))\n", + " elif k == \"vehicle\":\n", + " o = dict(y=rng.uniform(33, 38), x=rng.uniform(10, 30), vy=0.2, vx=rng.uniform(2, 4), h=5, w=10, val=rng.uniform(150, 170))\n", + " elif k == \"drone\":\n", + " o = dict(y=rng.uniform(3, 15), x=rng.uniform(10, 80), vy=rng.uniform(0, 0.6), vx=rng.uniform(-2, 2), h=2, w=2 if camo else 3,\n", + " val=rng.uniform(80, 120))\n", + " elif k == \"animal\":\n", + " o = dict(y=rng.uniform(34, 42), x=rng.uniform(15, 80), vy=0, vx=rng.uniform(-1.5, 1.5), h=2, w=4, val=rng.uniform(60, 75))\n", + " else:\n", + " o = dict(y=rng.uniform(3, 15), x=rng.uniform(10, 80), vy=0, vx=rng.uniform(-3, 3), h=1, w=2, val=rng.uniform(100, 130))\n", + " o[\"cls\"] = str(k); obs.append(o)\n", + " frames, labels = [], []\n", + " for t in range(-8, T): # 음수 시각 = 객체 없는 배경 학습 구간(사전 관측)\n", + " f = bg.copy(); tree(f, t, wind); lab = []\n", + " for oi, o in enumerate(obs if t >= 0 else []):\n", + " jit = rng.normal(0, 1.2, 2) if o[\"cls\"] in (\"animal\", \"bird\") else (0, 0) # 불규칙 움직임\n", + " y, x = int(o[\"y\"] + o[\"vy\"] * t + jit[0]), int(o[\"x\"] + o[\"vx\"] * t + jit[1])\n", + " y0, x0, y1, x1 = max(y, 0), max(x, 0), min(y + o[\"h\"], H), min(x + o[\"w\"], W)\n", + " if y1 > y0 and x1 > x0:\n", + " f[y0:y1, x0:x1] = o[\"val\"]\n", + " if o[\"cls\"] == \"person\":\n", + " f[max(y0 - 2, 0):y0, x0 + 1:x0 + 2] = o[\"val\"] # 머리\n", + " lab.append({\"cls\": o[\"cls\"], \"oid\": oi, \"bbox\": (x0, y0, x1, y1), \"intruder\": o[\"cls\"] in INTRUDER})\n", + " if cond == \"night\":\n", + " f = f * 0.5 + 10 + rng.normal(0, 3.5, f.shape)\n", + " else:\n", + " f = f + rng.normal(0, 2.5, f.shape)\n", + " if cond == \"rain\":\n", + " m = rng.random(f.shape) < 0.006\n", + " for d in range(3):\n", + " f[d:][m[:H - d]] += 45\n", + " if cond == \"fog\":\n", + " f = 0.45 * f + 0.55 * 150\n", + " frames.append(np.clip(f, 0, 255)); labels.append(lab)\n", + " fr = np.array(frames)\n", + " return {\"id\": f\"CAM-{seed:04d}\", \"cond\": cond, \"wind\": wind, \"ref\": fr[:8], \"frames\": fr[8:], \"labels\": labels[8:]}\n", + "\n", + "# ---------------- 전처리·탐지 공통 함수(배경 차분 기반) ----------------\n", + "def enhance(seq):\n", + " \"\"\"조도 보정(백분위 신장) + 비 조건의 빗줄기 제거(3x3 중앙값). 배경 학습 구간(ref)과 같은 기준으로 보정한다.\"\"\"\n", + " allf = np.concatenate([seq[\"ref\"], seq[\"frames\"]])\n", + " f = ndimage.median_filter(allf, size=(1, 3, 3)) if seq[\"cond\"] == \"rain\" else allf\n", + " lo, hi = np.percentile(f, [1, 99.5])\n", + " f = np.clip((f - lo) / (hi - lo + 1e-6) * 255, 0, 255)\n", + " return f[:len(seq[\"ref\"])], f[len(seq[\"ref\"]):]\n", + "\n", + "\n", + "def detect(ref, frames, k=4.0, min_area=3, sky_k=None, smooth=False):\n", + " \"\"\"배경 학습 구간의 중앙값 배경과 차이를 강건 임계값(k x MAD)으로 이진화해 연결 성분을 객체 후보로 만든다.\n", + " smooth=True 이면 연속 3프레임 평균으로 시간 잡음을 줄여 저대비 객체를 살린다.\"\"\"\n", + " bg = np.median(ref, axis=0)\n", + " if smooth:\n", + " frames = ndimage.uniform_filter1d(frames, 3, axis=0, mode=\"nearest\")\n", + " out = []\n", + " for f in frames:\n", + " d = np.abs(f - bg)\n", + " mad = np.median(d) * 1.4826 + 1e-6\n", + " thr = np.full(d.shape, k * mad)\n", + " if sky_k is not None:\n", + " thr[:HORIZON] = sky_k * mad\n", + " lab, n = ndimage.label(d > thr)\n", + " dets = []\n", + " for i, sl in enumerate(ndimage.find_objects(lab), start=1):\n", + " area = int((lab[sl] == i).sum())\n", + " if area >= min_area:\n", + " dets.append({\"bbox\": (sl[1].start, sl[0].start, sl[1].stop, sl[0].stop), \"area\": area,\n", + " \"contrast\": float(d[sl][lab[sl] == i].mean() / mad)})\n", + " out.append(dets)\n", + " return out\n", + "\n", + "\n", + "def center(b):\n", + " return ((b[0] + b[2]) / 2, (b[1] + b[3]) / 2)\n", + "\n", + "\n", + "def match(dets, labels, dist=4.0):\n", + " \"\"\"탐지와 정답을 중심 거리로 1:1 대응시킨다. 반환: [(det, label or None)], 놓친 label 목록\"\"\"\n", + " used, pairs = set(), []\n", + " for d in dets:\n", + " cd = center(d[\"bbox\"]); best, bj = dist, None\n", + " for j, l in enumerate(labels):\n", + " cl = center(l[\"bbox\"]); e = np.hypot(cd[0] - cl[0], cd[1] - cl[1])\n", + " if j not in used and e < best:\n", + " best, bj = e, j\n", + " if bj is not None:\n", + " used.add(bj)\n", + " pairs.append((d, labels[bj] if bj is not None else None))\n", + " return pairs, [l for j, l in enumerate(labels) if j not in used]\n", + "\n", + "\n", + "def in_roi(b):\n", + " cy = (b[1] + b[3]) / 2\n", + " return cy < HORIZON or ROI_ROWS[0] <= cy < ROI_ROWS[1]\n", + "\n", + "\n", + "def rule_class(b, area):\n", + " \"\"\"Baseline 규칙: 위치(하늘/지면)·크기·종횡비로 1차 분류(학습 없음, 판단 근거가 그대로 드러난다)\"\"\"\n", + " w, h = b[2] - b[0], b[3] - b[1]\n", + " cy = (b[1] + b[3]) / 2\n", + " if cy < HORIZON:\n", + " return \"drone\" if 3 <= area <= 12 else \"clutter\"\n", + " if area >= 30 and w > h:\n", + " return \"vehicle\"\n", + " if h >= w and 6 <= area <= 30:\n", + " return \"person\"\n", + " return \"clutter\"\n", + "\n", + "# 판단 기준(투명성): ① 학습된 배경과 밝기 차이가 잡음의 4배(k=4 x MAD) 이상인 픽셀 덩어리 ② 3픽셀 이상\n", + "# ③ 하늘 영역 3~12픽셀 = 드론, 지면의 가로로 긴 30픽셀 이상 = 차량, 세로로 긴 6~30픽셀 = 인원\n", + "# ④ 경계구역(ROI: 하늘 + 울타리 접근 구역) 안의 인원·차량·드론만 경보\n", + "rows, fa_src = [], {}\n", + "for s in range(500, 540):\n", + " seq = make_seq(s)\n", + " ref, fr = enhance(seq)\n", + " for t, (ds, labs) in enumerate(zip(detect(ref, fr), seq[\"labels\"])):\n", + " pairs, missed = match(ds, labs)\n", + " for d, l in pairs:\n", + " pred = rule_class(d[\"bbox\"], d[\"area\"])\n", + " alarm = pred in INTRUDER and in_roi(d[\"bbox\"])\n", + " truth = l[\"cls\"] if l else \"clutter\"\n", + " rows.append({\"cond\": seq[\"cond\"], \"truth\": truth, \"pred\": pred, \"alarm\": alarm})\n", + " for l in missed:\n", + " rows.append({\"cond\": seq[\"cond\"], \"truth\": l[\"cls\"], \"pred\": \"missed\", \"alarm\": False})\n", + "df = pd.DataFrame(rows)\n", + "intr = df[df[\"truth\"].isin(INTRUDER)]\n", + "acc = (intr[\"pred\"] == intr[\"truth\"]).mean()\n", + "det_rate = intr[\"alarm\"].mean()\n", + "fa = df[df[\"alarm\"] & ~df[\"truth\"].isin(INTRUDER)]\n", + "print(f\"[Baseline] 합성 CCTV 시퀀스 40개(프레임 {40 * T}장), 침입 객체 출현 {len(intr)}회\")\n", + "print(f\"침입 객체 탐지·분류 정확도 {acc:.1%} (목표 75% 이상) -> {'달성' if acc >= 0.75 else '미달'}\")\n", + "print(f\"침입 경보 발생률 {det_rate:.1%}, 오경보 {len(fa)}건 (원인: {fa['truth'].value_counts().to_dict()})\")\n", + "print(\"\\n[정체별 Baseline 판정] (행: 실제, 열: 판정)\")\n", + "print(pd.crosstab(df[\"truth\"], df[\"pred\"]).to_string())\n", + "print(\"\\n[조건별 침입 객체 정확도]\")\n", + "print(intr.groupby(\"cond\").apply(lambda g: f\"{(g['pred'] == g['truth']).mean():.1%} ({len(g)}회)\").to_string())\n", + "print(\"한계: 규칙은 위장 인원·소형 드론(저대비·소면적)과 수목·동물 오경보에 약하다 -> 미션 4·5에서 보완한다.\")\n", + "\n", + "seq = make_seq(503, cond=\"day\", objs=[\"person\", \"vehicle\", \"drone\", \"animal\"])\n", + "ref, fr = enhance(seq)\n", + "ds = detect(ref, fr)[6]\n", + "fig, ax = plt.subplots(1, 2, figsize=(11, 4))\n", + "ax[0].imshow(seq[\"frames\"][6], cmap=\"gray\", vmin=0, vmax=255); ax[0].set_title(\"Synthetic CCTV frame (training data)\")\n", + "ax[1].imshow(fr[6], cmap=\"gray\")\n", + "ax[1].axhspan(ROI_ROWS[0], ROI_ROWS[1], color=\"yellow\", alpha=0.15)\n", + "for d in ds:\n", + " b, p = d[\"bbox\"], rule_class(d[\"bbox\"], d[\"area\"])\n", + " ax[1].add_patch(plt.Rectangle((b[0] - .5, b[1] - .5), b[2] - b[0], b[3] - b[1], fill=False, ec=\"red\" if p in INTRUDER else \"gray\"))\n", + " ax[1].text(b[0], b[1] - 1, p, color=\"red\" if p in INTRUDER else \"gray\", fontsize=8)\n", + "ax[1].set_title(\"Baseline detections (yellow = fence ROI)\")\n", + "plt.tight_layout(); plt.show()\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 03 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + "[Baseline] 합성 CCTV 시퀀스 40개(프레임 400장), 침입 객체 출현 530회\n", + "침입 객체 탐지·분류 정확도 91.7% (목표 75% 이상) -> 달성\n", + "침입 경보 발생률 90.6%, 오경보 109건 (원인: {'clutter': 107, 'animal': 2})\n", + "\n", + "[정체별 Baseline 판정] (행: 실제, 열: 판정)\n", + "pred clutter drone missed person vehicle\n", + "truth \n", + "animal 92 0 16 2 0\n", + "bird 0 0 110 0 0\n", + "clutter 901 94 0 93 0\n", + "drone 0 134 36 0 0\n", + "person 6 0 1 243 0\n", + "vehicle 0 0 1 0 109\n", + "\n", + "[조건별 침입 객체 정확도]\n", + "cond\n", + "day 99.0% (100회)\n", + "fog 100.0% (130회)\n", + "night 99.1% (110회)\n", + "rain 77.9% (190회)\n", + "한계: 규칙은 위장 인원·소형 드론(저대비·소면적)과 수목·동물 오경보에 약하다 -> 미션 4·5에서 보완한다.\n", + "```\n", + "
\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 04. 저고도 침투 소형 드론 및 은폐 침입자 정밀 식별 복합 비전 모델 고도화\n", + "**소요시간**: 약 3.0시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "소형 저고도 드론 및 위장 침입자를 정밀 식별하는 복합 딥러닝 탐지 모델을 고도화한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **저고도 소형 드론과 위장(저대비) 침입자까지 식별하도록 프레임 간 추적과 궤적 특징 기반 복합 탐지 모델로 고도화한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **하늘 영역 민감도를 높인 탐지로 소형 객체를 살리고, 최근접 이웃 추적으로 궤적을 만들어 크기·종횡비·속도·직진성·대비 특징을 계산한 뒤 랜덤포레스트로 분류 수행하고 검증하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `src/models/sec01_advanced_detector.py 및 예외 탐지 테스트 결과` 형태의 결과물을 완성한다.\n", + "\n", + "### 🔍 평가 및 검증 기준 (PASS 조건)\n", + "#### 미션 판정\n", + "\n", + "**평가 구분:** 필수\n", + "\n", + "**핵심 평가영역:** 기만/교란 공격에 대한 방어 로직의 현실성 및 신뢰성\n", + "\n", + "#### 미션 PASS 조건\n", + "\n", + "- 정량 기준: 복합 위협 탐지 재현율(Recall) 85% 이상, 처리 지연 200ms 이하\n", + "\n", + "\n", + "- 진행 기준: 복합 위협 시나리오 10종에 대한 탐지 테스트 통과\n", + "\n", + "\n", + "- 검증 방법: 합성 위협 데이터셋 주입 시 이상치 탐지 정확도 검증\n", + "\n", + "\n", + "#### 채점자 판정 메모\n", + "\n", + "> 단순 코드 실행 여부뿐만 아니라 출력된 수치와 근거 자료가 군 실무 요구사항에 부합하는지 면밀히 확인한다.\n", + "\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "\"\"\"저고도 소형 드론과 위장(저대비) 침입자를 식별하도록, 하늘 영역 민감도 상향·프레임 간 추적·궤적 특징 기반 랜덤포레스트 분류를 결합한 복합 탐지 모델로 고도화하고, 객체 단위 재현율과 프레임당 처리 지연을 Baseline과 비교한다.\"\"\"\n", + "import time\n", + "import numpy as np\n", + "import pandas as pd\n", + "from scipy import ndimage\n", + "from sklearn.ensemble import RandomForestClassifier\n", + "\n", + "# ---------------- [교육용가상자료] 합성 CCTV 영상 생성기 ----------------\n", + "# 실제 영상 대신 코드로 그린 객체(사각형·점)로 기지 외곽 고정 카메라 화면을 흉내 낸다.\n", + "H, W, T = 64, 96, 10 # 화면 크기(px), 시퀀스 길이(프레임)\n", + "HORIZON, FENCE = 20, 46 # 하늘/지면 경계, 울타리 행\n", + "ROI_ROWS = (32, 48) # 울타리 접근 경계구역(ROI): 지면 32~47행\n", + "CLASSES = [\"person\", \"vehicle\", \"drone\", \"animal\", \"bird\"]\n", + "INTRUDER = {\"person\", \"vehicle\", \"drone\"} # 비인가 접근(경보 대상)\n", + "CONDS = [\"day\", \"night\", \"rain\", \"fog\"]\n", + "\n", + "\n", + "def background(rng):\n", + " bg = np.zeros((H, W))\n", + " bg[:HORIZON] = np.linspace(200, 170, HORIZON)[:, None] # 하늘\n", + " bg[HORIZON:] = 95 + rng.normal(0, 3, (H - HORIZON, W)) # 지면 질감\n", + " bg[26:30] = 115 # 외곽 도로\n", + " bg[FENCE] = 60; bg[FENCE - 3:FENCE + 1, ::8] = 60 # 울타리·기둥\n", + " return bg\n", + "\n", + "\n", + "def tree(img, t, wind):\n", + " \"\"\"좌측 수목 수관: 바람 세기에 따라 좌우로 흔들려 오경보를 만든다.\"\"\"\n", + " s = int(round(wind * np.sin(t * 1.3)))\n", + " img[8:30, max(0, 2 + s):14 + s] = 55\n", + "\n", + "\n", + "def make_seq(seed, cond=None, objs=None, wind=None, camo=False):\n", + " rng = np.random.default_rng(seed)\n", + " cond = cond or str(rng.choice(CONDS))\n", + " wind = rng.uniform(0, 2) if wind is None else wind\n", + " bg = background(rng)\n", + " n = int(rng.integers(1, 4)) if objs is None else len(objs)\n", + " kinds = objs or list(rng.choice(CLASSES, n, p=[0.3, 0.15, 0.25, 0.15, 0.15]))\n", + " obs = []\n", + " for k in kinds:\n", + " if k == \"person\":\n", + " o = dict(y=rng.uniform(30, 36), x=rng.uniform(15, 85), vy=rng.uniform(0.8, 1.2), vx=rng.uniform(-0.6, 0.6), h=7, w=3,\n", + " val=72 if camo else rng.uniform(35, 50))\n", + " elif k == \"vehicle\":\n", + " o = dict(y=rng.uniform(33, 38), x=rng.uniform(10, 30), vy=0.2, vx=rng.uniform(2, 4), h=5, w=10, val=rng.uniform(150, 170))\n", + " elif k == \"drone\":\n", + " o = dict(y=rng.uniform(3, 15), x=rng.uniform(10, 80), vy=rng.uniform(0, 0.6), vx=rng.uniform(-2, 2), h=2, w=2 if camo else 3,\n", + " val=rng.uniform(80, 120))\n", + " elif k == \"animal\":\n", + " o = dict(y=rng.uniform(34, 42), x=rng.uniform(15, 80), vy=0, vx=rng.uniform(-1.5, 1.5), h=2, w=4, val=rng.uniform(60, 75))\n", + " else:\n", + " o = dict(y=rng.uniform(3, 15), x=rng.uniform(10, 80), vy=0, vx=rng.uniform(-3, 3), h=1, w=2, val=rng.uniform(100, 130))\n", + " o[\"cls\"] = str(k); obs.append(o)\n", + " frames, labels = [], []\n", + " for t in range(-8, T): # 음수 시각 = 객체 없는 배경 학습 구간(사전 관측)\n", + " f = bg.copy(); tree(f, t, wind); lab = []\n", + " for oi, o in enumerate(obs if t >= 0 else []):\n", + " jit = rng.normal(0, 1.2, 2) if o[\"cls\"] in (\"animal\", \"bird\") else (0, 0) # 불규칙 움직임\n", + " y, x = int(o[\"y\"] + o[\"vy\"] * t + jit[0]), int(o[\"x\"] + o[\"vx\"] * t + jit[1])\n", + " y0, x0, y1, x1 = max(y, 0), max(x, 0), min(y + o[\"h\"], H), min(x + o[\"w\"], W)\n", + " if y1 > y0 and x1 > x0:\n", + " f[y0:y1, x0:x1] = o[\"val\"]\n", + " if o[\"cls\"] == \"person\":\n", + " f[max(y0 - 2, 0):y0, x0 + 1:x0 + 2] = o[\"val\"] # 머리\n", + " lab.append({\"cls\": o[\"cls\"], \"oid\": oi, \"bbox\": (x0, y0, x1, y1), \"intruder\": o[\"cls\"] in INTRUDER})\n", + " if cond == \"night\":\n", + " f = f * 0.5 + 10 + rng.normal(0, 3.5, f.shape)\n", + " else:\n", + " f = f + rng.normal(0, 2.5, f.shape)\n", + " if cond == \"rain\":\n", + " m = rng.random(f.shape) < 0.006\n", + " for d in range(3):\n", + " f[d:][m[:H - d]] += 45\n", + " if cond == \"fog\":\n", + " f = 0.45 * f + 0.55 * 150\n", + " frames.append(np.clip(f, 0, 255)); labels.append(lab)\n", + " fr = np.array(frames)\n", + " return {\"id\": f\"CAM-{seed:04d}\", \"cond\": cond, \"wind\": wind, \"ref\": fr[:8], \"frames\": fr[8:], \"labels\": labels[8:]}\n", + "\n", + "# ---------------- 전처리·탐지 공통 함수(배경 차분 기반) ----------------\n", + "def enhance(seq):\n", + " \"\"\"조도 보정(백분위 신장) + 비 조건의 빗줄기 제거(3x3 중앙값). 배경 학습 구간(ref)과 같은 기준으로 보정한다.\"\"\"\n", + " allf = np.concatenate([seq[\"ref\"], seq[\"frames\"]])\n", + " f = ndimage.median_filter(allf, size=(1, 3, 3)) if seq[\"cond\"] == \"rain\" else allf\n", + " lo, hi = np.percentile(f, [1, 99.5])\n", + " f = np.clip((f - lo) / (hi - lo + 1e-6) * 255, 0, 255)\n", + " return f[:len(seq[\"ref\"])], f[len(seq[\"ref\"]):]\n", + "\n", + "\n", + "def detect(ref, frames, k=4.0, min_area=3, sky_k=None, smooth=False):\n", + " \"\"\"배경 학습 구간의 중앙값 배경과 차이를 강건 임계값(k x MAD)으로 이진화해 연결 성분을 객체 후보로 만든다.\n", + " smooth=True 이면 연속 3프레임 평균으로 시간 잡음을 줄여 저대비 객체를 살린다.\"\"\"\n", + " bg = np.median(ref, axis=0)\n", + " if smooth:\n", + " frames = ndimage.uniform_filter1d(frames, 3, axis=0, mode=\"nearest\")\n", + " out = []\n", + " for f in frames:\n", + " d = np.abs(f - bg)\n", + " mad = np.median(d) * 1.4826 + 1e-6\n", + " thr = np.full(d.shape, k * mad)\n", + " if sky_k is not None:\n", + " thr[:HORIZON] = sky_k * mad\n", + " lab, n = ndimage.label(d > thr)\n", + " dets = []\n", + " for i, sl in enumerate(ndimage.find_objects(lab), start=1):\n", + " area = int((lab[sl] == i).sum())\n", + " if area >= min_area:\n", + " dets.append({\"bbox\": (sl[1].start, sl[0].start, sl[1].stop, sl[0].stop), \"area\": area,\n", + " \"contrast\": float(d[sl][lab[sl] == i].mean() / mad)})\n", + " out.append(dets)\n", + " return out\n", + "\n", + "\n", + "def center(b):\n", + " return ((b[0] + b[2]) / 2, (b[1] + b[3]) / 2)\n", + "\n", + "\n", + "def match(dets, labels, dist=4.0):\n", + " \"\"\"탐지와 정답을 중심 거리로 1:1 대응시킨다. 반환: [(det, label or None)], 놓친 label 목록\"\"\"\n", + " used, pairs = set(), []\n", + " for d in dets:\n", + " cd = center(d[\"bbox\"]); best, bj = dist, None\n", + " for j, l in enumerate(labels):\n", + " cl = center(l[\"bbox\"]); e = np.hypot(cd[0] - cl[0], cd[1] - cl[1])\n", + " if j not in used and e < best:\n", + " best, bj = e, j\n", + " if bj is not None:\n", + " used.add(bj)\n", + " pairs.append((d, labels[bj] if bj is not None else None))\n", + " return pairs, [l for j, l in enumerate(labels) if j not in used]\n", + "\n", + "\n", + "def in_roi(b):\n", + " cy = (b[1] + b[3]) / 2\n", + " return cy < HORIZON or ROI_ROWS[0] <= cy < ROI_ROWS[1]\n", + "\n", + "\n", + "def rule_class(b, area):\n", + " \"\"\"Baseline 규칙: 위치(하늘/지면)·크기·종횡비로 1차 분류(학습 없음, 판단 근거가 그대로 드러난다)\"\"\"\n", + " w, h = b[2] - b[0], b[3] - b[1]\n", + " cy = (b[1] + b[3]) / 2\n", + " if cy < HORIZON:\n", + " return \"drone\" if 3 <= area <= 12 else \"clutter\"\n", + " if area >= 30 and w > h:\n", + " return \"vehicle\"\n", + " if h >= w and 6 <= area <= 30:\n", + " return \"person\"\n", + " return \"clutter\"\n", + "\n", + "# ---------------- 프레임 간 추적과 궤적 특징 ----------------\n", + "def track(dets, gate=6.0):\n", + " \"\"\"최근접 이웃으로 프레임별 탐지를 궤적으로 잇는다.\"\"\"\n", + " tracks = []\n", + " for t, ds in enumerate(dets):\n", + " free = list(range(len(ds)))\n", + " for tr in tracks:\n", + " if tr[\"last\"] != t - 1 or not free:\n", + " continue\n", + " c0 = center(tr[\"b\"][-1])\n", + " dd = [np.hypot(center(ds[i][\"bbox\"])[0] - c0[0], center(ds[i][\"bbox\"])[1] - c0[1]) for i in free]\n", + " j = int(np.argmin(dd))\n", + " if dd[j] < gate:\n", + " i = free.pop(j); tr[\"b\"].append(ds[i][\"bbox\"]); tr[\"a\"].append(ds[i][\"area\"]); tr[\"c\"].append(ds[i][\"contrast\"])\n", + " tr[\"t\"].append(t); tr[\"last\"] = t\n", + " for i in free:\n", + " tracks.append({\"b\": [ds[i][\"bbox\"]], \"a\": [ds[i][\"area\"]], \"c\": [ds[i][\"contrast\"]], \"t\": [t], \"last\": t})\n", + " return tracks\n", + "\n", + "\n", + "TFEATS = [\"n\", \"area\", \"aspect\", \"row\", \"sky\", \"speed\", \"straight\", \"disp\", \"contrast\", \"area_cv\", \"col\"]\n", + "\n", + "\n", + "def track_features(tr):\n", + " c = np.array([center(b) for b in tr[\"b\"]])\n", + " wh = np.array([(b[2] - b[0], b[3] - b[1]) for b in tr[\"b\"]])\n", + " steps = np.hypot(*np.diff(c, axis=0).T) if len(c) > 1 else np.array([0.0])\n", + " disp = float(np.hypot(*(c[-1] - c[0])))\n", + " return {\"n\": len(c), \"area\": float(np.mean(tr[\"a\"])), \"aspect\": float(np.mean(wh[:, 1] / wh[:, 0])), \"row\": float(c[:, 1].mean()),\n", + " \"sky\": float(c[:, 1].mean() < HORIZON), \"speed\": float(steps.mean()), \"straight\": disp / (steps.sum() + 1e-6),\n", + " \"disp\": disp, \"contrast\": float(np.mean(tr[\"c\"])), \"area_cv\": float(np.std(tr[\"a\"]) / (np.mean(tr[\"a\"]) + 1e-6)),\n", + " \"col\": float(c[:, 0].mean())}\n", + "\n", + "\n", + "def track_truth(tr, labels):\n", + " \"\"\"궤적의 각 프레임을 정답과 대응시켜 다수결로 정체를 정한다(없으면 clutter).\"\"\"\n", + " votes = []\n", + " for b, t in zip(tr[\"b\"], tr[\"t\"]):\n", + " pairs, _ = match([{\"bbox\": b}], labels[t])\n", + " votes.append((pairs[0][1][\"cls\"], pairs[0][1][\"oid\"]) if pairs[0][1] else (\"clutter\", -1))\n", + " return max(set(votes), key=votes.count)\n", + "\n", + "\n", + "def seq_tracks(seq, k=4.0, min_area=3, sky_k=None, smooth=False):\n", + " ref, fr = enhance(seq)\n", + " tracks = track(detect(ref, fr, k, min_area, sky_k, smooth))\n", + " rows = []\n", + " for tr in tracks:\n", + " f = track_features(tr); f[\"truth\"], f[\"oid\"] = track_truth(tr, seq[\"labels\"]); f[\"seq\"] = seq[\"id\"]; f[\"cond\"] = seq[\"cond\"]\n", + " f[\"tr\"] = tr; rows.append(f)\n", + " return rows\n", + "\n", + "ADV = {\"k\": 3.0, \"min_area\": 2, \"sky_k\": 2.5} # 고도화 탐지 파라미터(저대비 객체 대응)\n", + "\n", + "\n", + "def make_set(seeds, camo_ratio):\n", + " return [make_seq(s, camo=(i % 10) < camo_ratio * 10) for i, s in enumerate(seeds)]\n", + "\n", + "\n", + "train = make_set(range(0, 80), 0.5)\n", + "tr = pd.DataFrame(sum([seq_tracks(q, **ADV) for q in train], []))\n", + "clf = RandomForestClassifier(n_estimators=150, min_samples_leaf=2, random_state=0).fit(tr[TFEATS], tr[\"truth\"])\n", + "\n", + "\n", + "def objects(seqs):\n", + " return {(q[\"id\"], l[\"oid\"]): l[\"cls\"] for q in seqs for labs in q[\"labels\"] for l in labs if l[\"intruder\"]}\n", + "\n", + "\n", + "def baseline_found(seqs):\n", + " found = set()\n", + " for q in seqs:\n", + " ref, fr = enhance(q)\n", + " hits = {}\n", + " for ds, labs in zip(detect(ref, fr), q[\"labels\"]):\n", + " for d, l in match(ds, labs)[0]:\n", + " if l and l[\"intruder\"] and rule_class(d[\"bbox\"], d[\"area\"]) == l[\"cls\"]:\n", + " hits[l[\"oid\"]] = hits.get(l[\"oid\"], 0) + 1\n", + " found |= {(q[\"id\"], o) for o, c in hits.items() if c >= 2}\n", + " return found\n", + "\n", + "\n", + "def advanced_found(seqs):\n", + " found, ms = set(), []\n", + " for q in seqs:\n", + " t0 = time.perf_counter()\n", + " rows = seq_tracks(q, **ADV)\n", + " if rows:\n", + " pred = clf.predict(pd.DataFrame(rows)[TFEATS])\n", + " ms.append((time.perf_counter() - t0) * 1000 / T)\n", + " for r, p in zip(rows, pred if rows else []):\n", + " if p in INTRUDER and r[\"n\"] >= 2 and r[\"oid\"] >= 0 and r[\"truth\"] in INTRUDER:\n", + " found.add((q[\"id\"], r[\"oid\"]))\n", + " return found, ms\n", + "\n", + "\n", + "res = []\n", + "for name, seqs in ((\"일반\", make_set(range(600, 630), 0.0)), (\"위장·소형\", make_set(range(700, 720), 1.0))):\n", + " obj = objects(seqs)\n", + " fb = baseline_found(seqs) & set(obj)\n", + " fa, ms = advanced_found(seqs)\n", + " for cls in (\"person\", \"vehicle\", \"drone\", \"전체\"):\n", + " keys = [k for k, c in obj.items() if cls in (c, \"전체\")]\n", + " if keys:\n", + " res.append({\"시험셋\": name, \"정체\": cls, \"객체 수\": len(keys), \"Baseline 재현율\": np.mean([k in fb for k in keys]),\n", + " \"고도화 재현율\": np.mean([k in fa for k in keys])})\n", + " res[-1][\"지연(ms/프레임)\"] = max(ms)\n", + "out = pd.DataFrame(res)\n", + "print(\"[객체 단위 재현율 비교] (2프레임 이상 올바르게 식별되면 탐지로 인정)\")\n", + "print(out.round(3).fillna(\"-\").to_string(index=False))\n", + "rec = out[out[\"정체\"] == \"전체\"][\"고도화 재현율\"].min()\n", + "lat = out[\"지연(ms/프레임)\"].dropna().max()\n", + "imp = pd.Series(clf.feature_importances_, index=TFEATS).sort_values(ascending=False)\n", + "print(\"\\n궤적 특징 중요도 상위:\", \", \".join(f\"{k} {v:.2f}\" for k, v in imp.head(5).items()))\n", + "print(f\"\\n복합 위협 탐지 재현율(최저 시험셋) {rec:.1%} (목표 85% 이상) -> {'달성' if rec >= 0.85 else '미달'}\")\n", + "print(f\"처리 지연 최대 {lat:.1f} ms/프레임 (목표 200 ms 이하) -> {'달성' if lat <= 200 else '미달'}\")\n", + "print(\"기만 대응: 한 프레임만 나타나는 섬광·빗줄기는 2프레임 이상 지속 조건으로 걸러지며, 최종 확인은 경계병 육안 확인(미션 7)으로 한다.\")\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 04 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + "[객체 단위 재현율 비교] (2프레임 이상 올바르게 식별되면 탐지로 인정)\n", + " 시험셋 정체 객체 수 Baseline 재현율 고도화 재현율 지연(ms/프레임)\n", + " 일반 person 18 1.000 1.000 -\n", + " 일반 vehicle 9 1.000 1.000 -\n", + " 일반 drone 21 0.952 1.000 -\n", + " 일반 전체 48 0.979 1.000 9.738\n", + "위장·소형 person 11 0.727 0.909 -\n", + "위장·소형 vehicle 7 1.000 0.857 -\n", + "위장·소형 drone 9 0.889 1.000 -\n", + "위장·소형 전체 27 0.852 0.926 25.494\n", + "\n", + "궤적 특징 중요도 상위: n 0.18, disp 0.14, row 0.12, area 0.10, aspect 0.10\n", + "\n", + "복합 위협 탐지 재현율(최저 시험셋) 92.6% (목표 85% 이상) -> 달성\n", + "처리 지연 최대 25.5 ms/프레임 (목표 200 ms 이하) -> 달성\n", + "기만 대응: 한 프레임만 나타나는 섬광·빗줄기는 2프레임 이상 지속 조건으로 걸러지며, 최종 확인은 경계병 육안 확인(미션 7)으로 한다.\n", + "```\n", + "
\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 05. 수목 흔들림/야생동물 오경보 필터링 및 침입 위험 신뢰도 점수 엔진 개발\n", + "**소요시간**: 약 3.0시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "수목 흔들림, 야생동물 등으로 인한 오경보를 필터링하고 침입 위험 신뢰도 점수를 산출하는 엔진을 개발한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **수목 흔들림과 야생동물·조류로 인한 오경보를 걸러내고 침입 위험 신뢰도 점수와 등급을 산출하는 엔진을 개발한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **궤적 특징으로 로지스틱 회귀 기반 침입 확률을 계산해 0.8 이상 High, 0.5 이상 Medium, 그 미만 Low 등급을 부여 수행하고 검증하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `src/verification/sec01_confidence_engine.py` 형태의 결과물을 완성한다.\n", + "\n", + "### 🔍 평가 및 검증 기준 (PASS 조건)\n", + "#### 미션 판정\n", + "\n", + "**평가 구분:** 필수\n", + "\n", + "**핵심 평가영역:** 신뢰도 등급(High/Med/Low) 부여 기준의 군사적 타당성\n", + "\n", + "#### 미션 PASS 조건\n", + "\n", + "- 정량 기준: 오경보율(FAR) 40% 이상 감축, 신뢰도 스코어 산출 일관성 90% 이상\n", + "\n", + "\n", + "- 진행 기준: 오경보 필터링 전후 비교 지표 산출 및 통합 테스트 완료\n", + "\n", + "\n", + "- 검증 방법: ROC-AUC 곡선 분석 및 임계값 민감도 테스트\n", + "\n", + "\n", + "#### 채점자 판정 메모\n", + "\n", + "> 단순 코드 실행 여부뿐만 아니라 출력된 수치와 근거 자료가 군 실무 요구사항에 부합하는지 면밀히 확인한다.\n", + "\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "\"\"\"수목 흔들림·야생동물·조류로 인한 오경보를 걸러내기 위해 궤적 특징(크기·종횡비·속도·직진성·대비 등)으로 침입 위험 신뢰도 점수와 등급(High/Medium/Low)을 계산하고, 기존 방식 대비 오경보율(FAR) 감축·점수 일관성·ROC-AUC를 검증한다.\"\"\"\n", + "import numpy as np\n", + "import pandas as pd\n", + "import matplotlib.pyplot as plt\n", + "from scipy import ndimage\n", + "from sklearn.ensemble import RandomForestClassifier\n", + "from sklearn.linear_model import LogisticRegression\n", + "from sklearn.metrics import roc_auc_score, roc_curve\n", + "from sklearn.pipeline import make_pipeline\n", + "from sklearn.preprocessing import StandardScaler\n", + "\n", + "# ---------------- [교육용가상자료] 합성 CCTV 영상 생성기 ----------------\n", + "# 실제 영상 대신 코드로 그린 객체(사각형·점)로 기지 외곽 고정 카메라 화면을 흉내 낸다.\n", + "H, W, T = 64, 96, 10 # 화면 크기(px), 시퀀스 길이(프레임)\n", + "HORIZON, FENCE = 20, 46 # 하늘/지면 경계, 울타리 행\n", + "ROI_ROWS = (32, 48) # 울타리 접근 경계구역(ROI): 지면 32~47행\n", + "CLASSES = [\"person\", \"vehicle\", \"drone\", \"animal\", \"bird\"]\n", + "INTRUDER = {\"person\", \"vehicle\", \"drone\"} # 비인가 접근(경보 대상)\n", + "CONDS = [\"day\", \"night\", \"rain\", \"fog\"]\n", + "\n", + "\n", + "def background(rng):\n", + " bg = np.zeros((H, W))\n", + " bg[:HORIZON] = np.linspace(200, 170, HORIZON)[:, None] # 하늘\n", + " bg[HORIZON:] = 95 + rng.normal(0, 3, (H - HORIZON, W)) # 지면 질감\n", + " bg[26:30] = 115 # 외곽 도로\n", + " bg[FENCE] = 60; bg[FENCE - 3:FENCE + 1, ::8] = 60 # 울타리·기둥\n", + " return bg\n", + "\n", + "\n", + "def tree(img, t, wind):\n", + " \"\"\"좌측 수목 수관: 바람 세기에 따라 좌우로 흔들려 오경보를 만든다.\"\"\"\n", + " s = int(round(wind * np.sin(t * 1.3)))\n", + " img[8:30, max(0, 2 + s):14 + s] = 55\n", + "\n", + "\n", + "def make_seq(seed, cond=None, objs=None, wind=None, camo=False):\n", + " rng = np.random.default_rng(seed)\n", + " cond = cond or str(rng.choice(CONDS))\n", + " wind = rng.uniform(0, 2) if wind is None else wind\n", + " bg = background(rng)\n", + " n = int(rng.integers(1, 4)) if objs is None else len(objs)\n", + " kinds = objs or list(rng.choice(CLASSES, n, p=[0.3, 0.15, 0.25, 0.15, 0.15]))\n", + " obs = []\n", + " for k in kinds:\n", + " if k == \"person\":\n", + " o = dict(y=rng.uniform(30, 36), x=rng.uniform(15, 85), vy=rng.uniform(0.8, 1.2), vx=rng.uniform(-0.6, 0.6), h=7, w=3,\n", + " val=72 if camo else rng.uniform(35, 50))\n", + " elif k == \"vehicle\":\n", + " o = dict(y=rng.uniform(33, 38), x=rng.uniform(10, 30), vy=0.2, vx=rng.uniform(2, 4), h=5, w=10, val=rng.uniform(150, 170))\n", + " elif k == \"drone\":\n", + " o = dict(y=rng.uniform(3, 15), x=rng.uniform(10, 80), vy=rng.uniform(0, 0.6), vx=rng.uniform(-2, 2), h=2, w=2 if camo else 3,\n", + " val=rng.uniform(80, 120))\n", + " elif k == \"animal\":\n", + " o = dict(y=rng.uniform(34, 42), x=rng.uniform(15, 80), vy=0, vx=rng.uniform(-1.5, 1.5), h=2, w=4, val=rng.uniform(60, 75))\n", + " else:\n", + " o = dict(y=rng.uniform(3, 15), x=rng.uniform(10, 80), vy=0, vx=rng.uniform(-3, 3), h=1, w=2, val=rng.uniform(100, 130))\n", + " o[\"cls\"] = str(k); obs.append(o)\n", + " frames, labels = [], []\n", + " for t in range(-8, T): # 음수 시각 = 객체 없는 배경 학습 구간(사전 관측)\n", + " f = bg.copy(); tree(f, t, wind); lab = []\n", + " for oi, o in enumerate(obs if t >= 0 else []):\n", + " jit = rng.normal(0, 1.2, 2) if o[\"cls\"] in (\"animal\", \"bird\") else (0, 0) # 불규칙 움직임\n", + " y, x = int(o[\"y\"] + o[\"vy\"] * t + jit[0]), int(o[\"x\"] + o[\"vx\"] * t + jit[1])\n", + " y0, x0, y1, x1 = max(y, 0), max(x, 0), min(y + o[\"h\"], H), min(x + o[\"w\"], W)\n", + " if y1 > y0 and x1 > x0:\n", + " f[y0:y1, x0:x1] = o[\"val\"]\n", + " if o[\"cls\"] == \"person\":\n", + " f[max(y0 - 2, 0):y0, x0 + 1:x0 + 2] = o[\"val\"] # 머리\n", + " lab.append({\"cls\": o[\"cls\"], \"oid\": oi, \"bbox\": (x0, y0, x1, y1), \"intruder\": o[\"cls\"] in INTRUDER})\n", + " if cond == \"night\":\n", + " f = f * 0.5 + 10 + rng.normal(0, 3.5, f.shape)\n", + " else:\n", + " f = f + rng.normal(0, 2.5, f.shape)\n", + " if cond == \"rain\":\n", + " m = rng.random(f.shape) < 0.006\n", + " for d in range(3):\n", + " f[d:][m[:H - d]] += 45\n", + " if cond == \"fog\":\n", + " f = 0.45 * f + 0.55 * 150\n", + " frames.append(np.clip(f, 0, 255)); labels.append(lab)\n", + " fr = np.array(frames)\n", + " return {\"id\": f\"CAM-{seed:04d}\", \"cond\": cond, \"wind\": wind, \"ref\": fr[:8], \"frames\": fr[8:], \"labels\": labels[8:]}\n", + "\n", + "# ---------------- 전처리·탐지 공통 함수(배경 차분 기반) ----------------\n", + "def enhance(seq):\n", + " \"\"\"조도 보정(백분위 신장) + 비 조건의 빗줄기 제거(3x3 중앙값). 배경 학습 구간(ref)과 같은 기준으로 보정한다.\"\"\"\n", + " allf = np.concatenate([seq[\"ref\"], seq[\"frames\"]])\n", + " f = ndimage.median_filter(allf, size=(1, 3, 3)) if seq[\"cond\"] == \"rain\" else allf\n", + " lo, hi = np.percentile(f, [1, 99.5])\n", + " f = np.clip((f - lo) / (hi - lo + 1e-6) * 255, 0, 255)\n", + " return f[:len(seq[\"ref\"])], f[len(seq[\"ref\"]):]\n", + "\n", + "\n", + "def detect(ref, frames, k=4.0, min_area=3, sky_k=None, smooth=False):\n", + " \"\"\"배경 학습 구간의 중앙값 배경과 차이를 강건 임계값(k x MAD)으로 이진화해 연결 성분을 객체 후보로 만든다.\n", + " smooth=True 이면 연속 3프레임 평균으로 시간 잡음을 줄여 저대비 객체를 살린다.\"\"\"\n", + " bg = np.median(ref, axis=0)\n", + " if smooth:\n", + " frames = ndimage.uniform_filter1d(frames, 3, axis=0, mode=\"nearest\")\n", + " out = []\n", + " for f in frames:\n", + " d = np.abs(f - bg)\n", + " mad = np.median(d) * 1.4826 + 1e-6\n", + " thr = np.full(d.shape, k * mad)\n", + " if sky_k is not None:\n", + " thr[:HORIZON] = sky_k * mad\n", + " lab, n = ndimage.label(d > thr)\n", + " dets = []\n", + " for i, sl in enumerate(ndimage.find_objects(lab), start=1):\n", + " area = int((lab[sl] == i).sum())\n", + " if area >= min_area:\n", + " dets.append({\"bbox\": (sl[1].start, sl[0].start, sl[1].stop, sl[0].stop), \"area\": area,\n", + " \"contrast\": float(d[sl][lab[sl] == i].mean() / mad)})\n", + " out.append(dets)\n", + " return out\n", + "\n", + "\n", + "def center(b):\n", + " return ((b[0] + b[2]) / 2, (b[1] + b[3]) / 2)\n", + "\n", + "\n", + "def match(dets, labels, dist=4.0):\n", + " \"\"\"탐지와 정답을 중심 거리로 1:1 대응시킨다. 반환: [(det, label or None)], 놓친 label 목록\"\"\"\n", + " used, pairs = set(), []\n", + " for d in dets:\n", + " cd = center(d[\"bbox\"]); best, bj = dist, None\n", + " for j, l in enumerate(labels):\n", + " cl = center(l[\"bbox\"]); e = np.hypot(cd[0] - cl[0], cd[1] - cl[1])\n", + " if j not in used and e < best:\n", + " best, bj = e, j\n", + " if bj is not None:\n", + " used.add(bj)\n", + " pairs.append((d, labels[bj] if bj is not None else None))\n", + " return pairs, [l for j, l in enumerate(labels) if j not in used]\n", + "\n", + "\n", + "def in_roi(b):\n", + " cy = (b[1] + b[3]) / 2\n", + " return cy < HORIZON or ROI_ROWS[0] <= cy < ROI_ROWS[1]\n", + "\n", + "\n", + "def rule_class(b, area):\n", + " \"\"\"Baseline 규칙: 위치(하늘/지면)·크기·종횡비로 1차 분류(학습 없음, 판단 근거가 그대로 드러난다)\"\"\"\n", + " w, h = b[2] - b[0], b[3] - b[1]\n", + " cy = (b[1] + b[3]) / 2\n", + " if cy < HORIZON:\n", + " return \"drone\" if 3 <= area <= 12 else \"clutter\"\n", + " if area >= 30 and w > h:\n", + " return \"vehicle\"\n", + " if h >= w and 6 <= area <= 30:\n", + " return \"person\"\n", + " return \"clutter\"\n", + "\n", + "# ---------------- 프레임 간 추적과 궤적 특징 ----------------\n", + "def track(dets, gate=6.0):\n", + " \"\"\"최근접 이웃으로 프레임별 탐지를 궤적으로 잇는다.\"\"\"\n", + " tracks = []\n", + " for t, ds in enumerate(dets):\n", + " free = list(range(len(ds)))\n", + " for tr in tracks:\n", + " if tr[\"last\"] != t - 1 or not free:\n", + " continue\n", + " c0 = center(tr[\"b\"][-1])\n", + " dd = [np.hypot(center(ds[i][\"bbox\"])[0] - c0[0], center(ds[i][\"bbox\"])[1] - c0[1]) for i in free]\n", + " j = int(np.argmin(dd))\n", + " if dd[j] < gate:\n", + " i = free.pop(j); tr[\"b\"].append(ds[i][\"bbox\"]); tr[\"a\"].append(ds[i][\"area\"]); tr[\"c\"].append(ds[i][\"contrast\"])\n", + " tr[\"t\"].append(t); tr[\"last\"] = t\n", + " for i in free:\n", + " tracks.append({\"b\": [ds[i][\"bbox\"]], \"a\": [ds[i][\"area\"]], \"c\": [ds[i][\"contrast\"]], \"t\": [t], \"last\": t})\n", + " return tracks\n", + "\n", + "\n", + "TFEATS = [\"n\", \"area\", \"aspect\", \"row\", \"sky\", \"speed\", \"straight\", \"disp\", \"contrast\", \"area_cv\", \"col\"]\n", + "\n", + "\n", + "def track_features(tr):\n", + " c = np.array([center(b) for b in tr[\"b\"]])\n", + " wh = np.array([(b[2] - b[0], b[3] - b[1]) for b in tr[\"b\"]])\n", + " steps = np.hypot(*np.diff(c, axis=0).T) if len(c) > 1 else np.array([0.0])\n", + " disp = float(np.hypot(*(c[-1] - c[0])))\n", + " return {\"n\": len(c), \"area\": float(np.mean(tr[\"a\"])), \"aspect\": float(np.mean(wh[:, 1] / wh[:, 0])), \"row\": float(c[:, 1].mean()),\n", + " \"sky\": float(c[:, 1].mean() < HORIZON), \"speed\": float(steps.mean()), \"straight\": disp / (steps.sum() + 1e-6),\n", + " \"disp\": disp, \"contrast\": float(np.mean(tr[\"c\"])), \"area_cv\": float(np.std(tr[\"a\"]) / (np.mean(tr[\"a\"]) + 1e-6)),\n", + " \"col\": float(c[:, 0].mean())}\n", + "\n", + "\n", + "def track_truth(tr, labels):\n", + " \"\"\"궤적의 각 프레임을 정답과 대응시켜 다수결로 정체를 정한다(없으면 clutter).\"\"\"\n", + " votes = []\n", + " for b, t in zip(tr[\"b\"], tr[\"t\"]):\n", + " pairs, _ = match([{\"bbox\": b}], labels[t])\n", + " votes.append((pairs[0][1][\"cls\"], pairs[0][1][\"oid\"]) if pairs[0][1] else (\"clutter\", -1))\n", + " return max(set(votes), key=votes.count)\n", + "\n", + "\n", + "def seq_tracks(seq, k=4.0, min_area=3, sky_k=None, smooth=False):\n", + " ref, fr = enhance(seq)\n", + " tracks = track(detect(ref, fr, k, min_area, sky_k, smooth))\n", + " rows = []\n", + " for tr in tracks:\n", + " f = track_features(tr); f[\"truth\"], f[\"oid\"] = track_truth(tr, seq[\"labels\"]); f[\"seq\"] = seq[\"id\"]; f[\"cond\"] = seq[\"cond\"]\n", + " f[\"tr\"] = tr; rows.append(f)\n", + " return rows\n", + "\n", + "ADV = {\"k\": 3.0, \"min_area\": 2, \"sky_k\": 2.5}\n", + "\n", + "\n", + "def collect(seeds, wind=None):\n", + " rows = []\n", + " for s in seeds:\n", + " q = make_seq(s, wind=wind)\n", + " for r in seq_tracks(q, **ADV):\n", + " if r[\"n\"] >= 2 and in_roi(r[\"tr\"][\"b\"][-1]): # 2프레임 이상 지속 + 경계구역 안 = 경보 후보\n", + " r[\"intr\"] = r[\"truth\"] in INTRUDER\n", + " rows.append(r)\n", + " return rows\n", + "\n", + "\n", + "def X_of(rows):\n", + " return np.array([[r[k] for k in TFEATS] for r in rows])\n", + "\n", + "\n", + "def grade(p):\n", + " return \"High\" if p >= 0.8 else (\"Medium\" if p >= 0.5 else \"Low\")\n", + "\n", + "\n", + "train = collect(range(0, 100))\n", + "test = collect(range(1000, 1060))\n", + "windy = collect(range(2000, 2030), wind=2.0) # 강풍 시험셋: 수목 흔들림이 가장 심한 조건\n", + "model = make_pipeline(StandardScaler(), LogisticRegression(class_weight=\"balanced\", C=2.0, max_iter=2000, random_state=0))\n", + "model.fit(X_of(train), [r[\"intr\"] for r in train])\n", + "\n", + "\n", + "def evaluate(rows, thr=0.5):\n", + " p = model.predict_proba(X_of(rows))[:, 1]\n", + " y = np.array([r[\"intr\"] for r in rows])\n", + " far0 = (~y).mean() # 기존: 후보 전부 경보 -> 오경보 비율\n", + " sel = p >= thr\n", + " far1 = (~y[sel]).sum() / max(sel.sum(), 1)\n", + " return p, y, far0, far1, y[sel].sum() / max(y.sum(), 1)\n", + "\n", + "\n", + "out = []\n", + "for name, rows in ((\"일반 시험셋\", test), (\"강풍 시험셋\", windy)):\n", + " p, y, far0, far1, rec = evaluate(rows)\n", + " out.append({\"시험셋\": name, \"후보 수\": len(rows), \"기존 FAR\": round(far0, 3), \"엔진 FAR\": round(far1, 3),\n", + " \"FAR 감축\": f\"{1 - far1 / far0:.1%}\", \"침입 재현율\": round(rec, 3), \"ROC-AUC\": round(roc_auc_score(y, p), 3)})\n", + "print(pd.DataFrame(out).to_string(index=False))\n", + "p, y, far0, far1, rec = evaluate(test)\n", + "cut = 1 - far1 / far0\n", + "print(f\"\\n오경보율 감축 {cut:.1%} (목표 40% 이상: {'달성' if cut >= 0.4 else '미달'})\")\n", + "\n", + "# 점수 일관성: 특징에 +-3% 측정 흔들림을 주어도 같은 등급이 유지되는 비율\n", + "rng = np.random.default_rng(0)\n", + "X = X_of(test)\n", + "g0 = [grade(v) for v in p]\n", + "same = [np.mean([grade(v) == a for v, a in zip(model.predict_proba(X * (1 + rng.normal(0, 0.03, X.shape)))[:, 1], g0)]) for _ in range(10)]\n", + "print(f\"신뢰도 등급 일관성 {np.mean(same):.1%} (같은 입력 재실행 100%, 목표 90% 이상: {'달성' if np.mean(same) >= 0.9 else '미달'})\")\n", + "\n", + "print(\"\\n[실제 정체별 등급 분포] (행: 실제 정체)\")\n", + "print(pd.crosstab(pd.Series([r[\"truth\"] for r in test], name=\"실제\"), pd.Series(g0, name=\"등급\")).to_string())\n", + "\n", + "print(\"\\n[임계값 민감도]\")\n", + "for thr in (0.3, 0.5, 0.7, 0.9):\n", + " _, _, _, f1_, r1_ = evaluate(test, thr)\n", + " print(f\"임계값 {thr}: 오경보율 {f1_:.3f}, 침입 재현율 {r1_:.3f}\")\n", + "\n", + "# 판단 근거: 점수에 크게 기여한 특징\n", + "sc, lr = model[0], model[-1]\n", + "i = int(np.argmax(p)); c = (X[i] - sc.mean_) / sc.scale_ * lr.coef_[0]\n", + "print(f\"\\n최고 점수 후보 {test[i]['seq']}({test[i]['truth']}, 점수 {p[i]:.2f}) 근거:\", \", \".join(f\"{TFEATS[j]}({c[j]:+.2f})\" for j in np.argsort(-np.abs(c))[:3]))\n", + "print(\"등급 기준: High(0.8 이상)는 경계병 즉시 확인, Medium(0.5~0.8)은 영상 재확인, Low(0.5 미만)는 기록 후 감시 유지. 점수는 확인 순서를 정하는 보조 정보이며 침입 확정이 아니다.\")\n", + "\n", + "fig, ax = plt.subplots(1, 2, figsize=(11, 4))\n", + "for cls, col in [(\"person\", \"tab:red\"), (\"vehicle\", \"tab:orange\"), (\"drone\", \"tab:purple\"), (\"animal\", \"tab:green\"), (\"bird\", \"tab:blue\"), (\"clutter\", \"gray\")]:\n", + " v = [s for s, r in zip(p, test) if r[\"truth\"] == cls]\n", + " if v:\n", + " ax[0].hist(v, bins=10, range=(0, 1), alpha=0.6, label=cls, color=col)\n", + "ax[0].axvline(0.5, color=\"k\", ls=\"--\"); ax[0].set_xlabel(\"confidence score\"); ax[0].set_ylabel(\"tracks\"); ax[0].legend(); ax[0].set_title(\"Score by true class\")\n", + "fpr, tpr, _ = roc_curve(y, p); ax[1].plot(fpr, tpr); ax[1].plot([0, 1], [0, 1], \"k:\"); ax[1].set_title(f\"ROC (AUC {roc_auc_score(y, p):.3f})\")\n", + "ax[1].set_xlabel(\"false positive rate\"); ax[1].set_ylabel(\"true positive rate\")\n", + "plt.tight_layout(); plt.show()\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 05 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + " 시험셋 후보 수 기존 FAR 엔진 FAR FAR 감축 침입 재현율 ROC-AUC\n", + "일반 시험셋 553 0.870 0.162 81.3% 0.931 0.984\n", + "강풍 시험셋 194 0.784 0.162 79.3% 0.738 0.931\n", + "\n", + "오경보율 감축 81.3% (목표 40% 이상: 달성)\n", + "신뢰도 등급 일관성 99.0% (같은 입력 재실행 100%, 목표 90% 이상: 달성)\n", + "\n", + "[실제 정체별 등급 분포] (행: 실제 정체)\n", + "등급 High Low Medium\n", + "실제 \n", + "animal 0 25 0\n", + "bird 4 10 2\n", + "clutter 2 433 5\n", + "drone 30 1 0\n", + "person 25 2 1\n", + "vehicle 10 2 1\n", + "\n", + "[임계값 민감도]\n", + "임계값 0.3: 오경보율 0.272, 침입 재현율 0.931\n", + "임계값 0.5: 오경보율 0.163, 침입 재현율 0.931\n", + "임계값 0.7: 오경보율 0.132, 침입 재현율 0.917\n", + "임계값 0.9: 오경보율 0.016, 침입 재현율 0.847\n", + "\n", + "최고 점수 후보 CAM-1034(drone, 점수 1.00) 근거: n(+4.27), row(+2.23), straight(+1.85)\n", + "등급 기준: High(0.8 이상)는 경계병 즉시 확인, Medium(0.5~0.8)은 영상 재확인, Low(0.5 미만)는 기록 후 감시 유지. 점수는 확인 순서를 정하는 보조 정보이며 침입 확정이 아니다.\n", + "```\n", + "
\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 06. CCTV 다채널 실시간 침입 경보 및 침투 경로 추적 대시보드 구현\n", + "**소요시간**: 약 2.5시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "다채널 CCTV 실시간 침입 경보 및 침투 경로 추적 관제 대시보드를 구현한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **다채널 CCTV의 침입 경보와 침투 경로를 한 화면에 보여 주는 관제 상황판을 구현한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **6개 채널의 합성 영상을 틱 단위로 처리해 채널 상태(경보·주시·정상), 침입 객체 궤적, 경보 목록을 갱신 수행하고 검증하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `src/ui/sec01_dashboard.py (또는 웹 UI 컴포넌트)` 형태의 결과물을 완성한다.\n", + "\n", + "### 🔍 평가 및 검증 기준 (PASS 조건)\n", + "#### 미션 판정\n", + "\n", + "**평가 구분:** 필수\n", + "\n", + "**핵심 평가영역:** 전술 작전 상황판으로서의 직관성 및 인지 부하 감소도\n", + "\n", + "#### 미션 PASS 조건\n", + "\n", + "- 정량 기준: 화면 갱신 주기 1초 이내, UI 응답성 100ms 이내\n", + "\n", + "\n", + "- 진행 기준: 주요 지표 시각화 컴포넌트 4종 이상 렌더링 및 실시간 데이터 연동\n", + "\n", + "\n", + "- 검증 방법: 사용자 인터랙션 테스트 및 모의 데이터 스트림 렌더링 검증\n", + "\n", + "\n", + "#### 채점자 판정 메모\n", + "\n", + "> 단순 코드 실행 여부뿐만 아니라 출력된 수치와 근거 자료가 군 실무 요구사항에 부합하는지 면밀히 확인한다.\n", + "\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "\"\"\"6채널 CCTV 합성 영상을 1초 주기로 갱신하는 관제 상황판을 구현한다. 채널별 상태(경보/주시/정상), 침입 객체 궤적(침투 경로), 경보 목록을 한 화면에 모으고 갱신 처리시간을 측정한다.\"\"\"\n", + "import time\n", + "import numpy as np\n", + "import pandas as pd\n", + "import matplotlib.pyplot as plt\n", + "from scipy import ndimage\n", + "\n", + "# ---------------- [교육용가상자료] 합성 CCTV 영상 생성기 ----------------\n", + "# 실제 영상 대신 코드로 그린 객체(사각형·점)로 기지 외곽 고정 카메라 화면을 흉내 낸다.\n", + "H, W, T = 64, 96, 10 # 화면 크기(px), 시퀀스 길이(프레임)\n", + "HORIZON, FENCE = 20, 46 # 하늘/지면 경계, 울타리 행\n", + "ROI_ROWS = (32, 48) # 울타리 접근 경계구역(ROI): 지면 32~47행\n", + "CLASSES = [\"person\", \"vehicle\", \"drone\", \"animal\", \"bird\"]\n", + "INTRUDER = {\"person\", \"vehicle\", \"drone\"} # 비인가 접근(경보 대상)\n", + "CONDS = [\"day\", \"night\", \"rain\", \"fog\"]\n", + "\n", + "\n", + "def background(rng):\n", + " bg = np.zeros((H, W))\n", + " bg[:HORIZON] = np.linspace(200, 170, HORIZON)[:, None] # 하늘\n", + " bg[HORIZON:] = 95 + rng.normal(0, 3, (H - HORIZON, W)) # 지면 질감\n", + " bg[26:30] = 115 # 외곽 도로\n", + " bg[FENCE] = 60; bg[FENCE - 3:FENCE + 1, ::8] = 60 # 울타리·기둥\n", + " return bg\n", + "\n", + "\n", + "def tree(img, t, wind):\n", + " \"\"\"좌측 수목 수관: 바람 세기에 따라 좌우로 흔들려 오경보를 만든다.\"\"\"\n", + " s = int(round(wind * np.sin(t * 1.3)))\n", + " img[8:30, max(0, 2 + s):14 + s] = 55\n", + "\n", + "\n", + "def make_seq(seed, cond=None, objs=None, wind=None, camo=False):\n", + " rng = np.random.default_rng(seed)\n", + " cond = cond or str(rng.choice(CONDS))\n", + " wind = rng.uniform(0, 2) if wind is None else wind\n", + " bg = background(rng)\n", + " n = int(rng.integers(1, 4)) if objs is None else len(objs)\n", + " kinds = objs or list(rng.choice(CLASSES, n, p=[0.3, 0.15, 0.25, 0.15, 0.15]))\n", + " obs = []\n", + " for k in kinds:\n", + " if k == \"person\":\n", + " o = dict(y=rng.uniform(30, 36), x=rng.uniform(15, 85), vy=rng.uniform(0.8, 1.2), vx=rng.uniform(-0.6, 0.6), h=7, w=3,\n", + " val=72 if camo else rng.uniform(35, 50))\n", + " elif k == \"vehicle\":\n", + " o = dict(y=rng.uniform(33, 38), x=rng.uniform(10, 30), vy=0.2, vx=rng.uniform(2, 4), h=5, w=10, val=rng.uniform(150, 170))\n", + " elif k == \"drone\":\n", + " o = dict(y=rng.uniform(3, 15), x=rng.uniform(10, 80), vy=rng.uniform(0, 0.6), vx=rng.uniform(-2, 2), h=2, w=2 if camo else 3,\n", + " val=rng.uniform(80, 120))\n", + " elif k == \"animal\":\n", + " o = dict(y=rng.uniform(34, 42), x=rng.uniform(15, 80), vy=0, vx=rng.uniform(-1.5, 1.5), h=2, w=4, val=rng.uniform(60, 75))\n", + " else:\n", + " o = dict(y=rng.uniform(3, 15), x=rng.uniform(10, 80), vy=0, vx=rng.uniform(-3, 3), h=1, w=2, val=rng.uniform(100, 130))\n", + " o[\"cls\"] = str(k); obs.append(o)\n", + " frames, labels = [], []\n", + " for t in range(-8, T): # 음수 시각 = 객체 없는 배경 학습 구간(사전 관측)\n", + " f = bg.copy(); tree(f, t, wind); lab = []\n", + " for oi, o in enumerate(obs if t >= 0 else []):\n", + " jit = rng.normal(0, 1.2, 2) if o[\"cls\"] in (\"animal\", \"bird\") else (0, 0) # 불규칙 움직임\n", + " y, x = int(o[\"y\"] + o[\"vy\"] * t + jit[0]), int(o[\"x\"] + o[\"vx\"] * t + jit[1])\n", + " y0, x0, y1, x1 = max(y, 0), max(x, 0), min(y + o[\"h\"], H), min(x + o[\"w\"], W)\n", + " if y1 > y0 and x1 > x0:\n", + " f[y0:y1, x0:x1] = o[\"val\"]\n", + " if o[\"cls\"] == \"person\":\n", + " f[max(y0 - 2, 0):y0, x0 + 1:x0 + 2] = o[\"val\"] # 머리\n", + " lab.append({\"cls\": o[\"cls\"], \"oid\": oi, \"bbox\": (x0, y0, x1, y1), \"intruder\": o[\"cls\"] in INTRUDER})\n", + " if cond == \"night\":\n", + " f = f * 0.5 + 10 + rng.normal(0, 3.5, f.shape)\n", + " else:\n", + " f = f + rng.normal(0, 2.5, f.shape)\n", + " if cond == \"rain\":\n", + " m = rng.random(f.shape) < 0.006\n", + " for d in range(3):\n", + " f[d:][m[:H - d]] += 45\n", + " if cond == \"fog\":\n", + " f = 0.45 * f + 0.55 * 150\n", + " frames.append(np.clip(f, 0, 255)); labels.append(lab)\n", + " fr = np.array(frames)\n", + " return {\"id\": f\"CAM-{seed:04d}\", \"cond\": cond, \"wind\": wind, \"ref\": fr[:8], \"frames\": fr[8:], \"labels\": labels[8:]}\n", + "\n", + "# ---------------- 전처리·탐지 공통 함수(배경 차분 기반) ----------------\n", + "def enhance(seq):\n", + " \"\"\"조도 보정(백분위 신장) + 비 조건의 빗줄기 제거(3x3 중앙값). 배경 학습 구간(ref)과 같은 기준으로 보정한다.\"\"\"\n", + " allf = np.concatenate([seq[\"ref\"], seq[\"frames\"]])\n", + " f = ndimage.median_filter(allf, size=(1, 3, 3)) if seq[\"cond\"] == \"rain\" else allf\n", + " lo, hi = np.percentile(f, [1, 99.5])\n", + " f = np.clip((f - lo) / (hi - lo + 1e-6) * 255, 0, 255)\n", + " return f[:len(seq[\"ref\"])], f[len(seq[\"ref\"]):]\n", + "\n", + "\n", + "def detect(ref, frames, k=4.0, min_area=3, sky_k=None, smooth=False):\n", + " \"\"\"배경 학습 구간의 중앙값 배경과 차이를 강건 임계값(k x MAD)으로 이진화해 연결 성분을 객체 후보로 만든다.\n", + " smooth=True 이면 연속 3프레임 평균으로 시간 잡음을 줄여 저대비 객체를 살린다.\"\"\"\n", + " bg = np.median(ref, axis=0)\n", + " if smooth:\n", + " frames = ndimage.uniform_filter1d(frames, 3, axis=0, mode=\"nearest\")\n", + " out = []\n", + " for f in frames:\n", + " d = np.abs(f - bg)\n", + " mad = np.median(d) * 1.4826 + 1e-6\n", + " thr = np.full(d.shape, k * mad)\n", + " if sky_k is not None:\n", + " thr[:HORIZON] = sky_k * mad\n", + " lab, n = ndimage.label(d > thr)\n", + " dets = []\n", + " for i, sl in enumerate(ndimage.find_objects(lab), start=1):\n", + " area = int((lab[sl] == i).sum())\n", + " if area >= min_area:\n", + " dets.append({\"bbox\": (sl[1].start, sl[0].start, sl[1].stop, sl[0].stop), \"area\": area,\n", + " \"contrast\": float(d[sl][lab[sl] == i].mean() / mad)})\n", + " out.append(dets)\n", + " return out\n", + "\n", + "\n", + "def center(b):\n", + " return ((b[0] + b[2]) / 2, (b[1] + b[3]) / 2)\n", + "\n", + "\n", + "def match(dets, labels, dist=4.0):\n", + " \"\"\"탐지와 정답을 중심 거리로 1:1 대응시킨다. 반환: [(det, label or None)], 놓친 label 목록\"\"\"\n", + " used, pairs = set(), []\n", + " for d in dets:\n", + " cd = center(d[\"bbox\"]); best, bj = dist, None\n", + " for j, l in enumerate(labels):\n", + " cl = center(l[\"bbox\"]); e = np.hypot(cd[0] - cl[0], cd[1] - cl[1])\n", + " if j not in used and e < best:\n", + " best, bj = e, j\n", + " if bj is not None:\n", + " used.add(bj)\n", + " pairs.append((d, labels[bj] if bj is not None else None))\n", + " return pairs, [l for j, l in enumerate(labels) if j not in used]\n", + "\n", + "\n", + "def in_roi(b):\n", + " cy = (b[1] + b[3]) / 2\n", + " return cy < HORIZON or ROI_ROWS[0] <= cy < ROI_ROWS[1]\n", + "\n", + "\n", + "def rule_class(b, area):\n", + " \"\"\"Baseline 규칙: 위치(하늘/지면)·크기·종횡비로 1차 분류(학습 없음, 판단 근거가 그대로 드러난다)\"\"\"\n", + " w, h = b[2] - b[0], b[3] - b[1]\n", + " cy = (b[1] + b[3]) / 2\n", + " if cy < HORIZON:\n", + " return \"drone\" if 3 <= area <= 12 else \"clutter\"\n", + " if area >= 30 and w > h:\n", + " return \"vehicle\"\n", + " if h >= w and 6 <= area <= 30:\n", + " return \"person\"\n", + " return \"clutter\"\n", + "\n", + "# ---------------- 프레임 간 추적과 궤적 특징 ----------------\n", + "def track(dets, gate=6.0):\n", + " \"\"\"최근접 이웃으로 프레임별 탐지를 궤적으로 잇는다.\"\"\"\n", + " tracks = []\n", + " for t, ds in enumerate(dets):\n", + " free = list(range(len(ds)))\n", + " for tr in tracks:\n", + " if tr[\"last\"] != t - 1 or not free:\n", + " continue\n", + " c0 = center(tr[\"b\"][-1])\n", + " dd = [np.hypot(center(ds[i][\"bbox\"])[0] - c0[0], center(ds[i][\"bbox\"])[1] - c0[1]) for i in free]\n", + " j = int(np.argmin(dd))\n", + " if dd[j] < gate:\n", + " i = free.pop(j); tr[\"b\"].append(ds[i][\"bbox\"]); tr[\"a\"].append(ds[i][\"area\"]); tr[\"c\"].append(ds[i][\"contrast\"])\n", + " tr[\"t\"].append(t); tr[\"last\"] = t\n", + " for i in free:\n", + " tracks.append({\"b\": [ds[i][\"bbox\"]], \"a\": [ds[i][\"area\"]], \"c\": [ds[i][\"contrast\"]], \"t\": [t], \"last\": t})\n", + " return tracks\n", + "\n", + "\n", + "TFEATS = [\"n\", \"area\", \"aspect\", \"row\", \"sky\", \"speed\", \"straight\", \"disp\", \"contrast\", \"area_cv\", \"col\"]\n", + "\n", + "\n", + "def track_features(tr):\n", + " c = np.array([center(b) for b in tr[\"b\"]])\n", + " wh = np.array([(b[2] - b[0], b[3] - b[1]) for b in tr[\"b\"]])\n", + " steps = np.hypot(*np.diff(c, axis=0).T) if len(c) > 1 else np.array([0.0])\n", + " disp = float(np.hypot(*(c[-1] - c[0])))\n", + " return {\"n\": len(c), \"area\": float(np.mean(tr[\"a\"])), \"aspect\": float(np.mean(wh[:, 1] / wh[:, 0])), \"row\": float(c[:, 1].mean()),\n", + " \"sky\": float(c[:, 1].mean() < HORIZON), \"speed\": float(steps.mean()), \"straight\": disp / (steps.sum() + 1e-6),\n", + " \"disp\": disp, \"contrast\": float(np.mean(tr[\"c\"])), \"area_cv\": float(np.std(tr[\"a\"]) / (np.mean(tr[\"a\"]) + 1e-6)),\n", + " \"col\": float(c[:, 0].mean())}\n", + "\n", + "\n", + "def track_truth(tr, labels):\n", + " \"\"\"궤적의 각 프레임을 정답과 대응시켜 다수결로 정체를 정한다(없으면 clutter).\"\"\"\n", + " votes = []\n", + " for b, t in zip(tr[\"b\"], tr[\"t\"]):\n", + " pairs, _ = match([{\"bbox\": b}], labels[t])\n", + " votes.append((pairs[0][1][\"cls\"], pairs[0][1][\"oid\"]) if pairs[0][1] else (\"clutter\", -1))\n", + " return max(set(votes), key=votes.count)\n", + "\n", + "\n", + "def seq_tracks(seq, k=4.0, min_area=3, sky_k=None, smooth=False):\n", + " ref, fr = enhance(seq)\n", + " tracks = track(detect(ref, fr, k, min_area, sky_k, smooth))\n", + " rows = []\n", + " for tr in tracks:\n", + " f = track_features(tr); f[\"truth\"], f[\"oid\"] = track_truth(tr, seq[\"labels\"]); f[\"seq\"] = seq[\"id\"]; f[\"cond\"] = seq[\"cond\"]\n", + " f[\"tr\"] = tr; rows.append(f)\n", + " return rows\n", + "\n", + "# [교육용가상자료] 6개 채널: 서로 다른 조건·객체 구성의 합성 CCTV\n", + "SPECS = [(11, \"day\", [\"person\", \"drone\"]), (12, \"night\", [\"vehicle\"]), (13, \"rain\", [\"animal\", \"person\"]),\n", + " (14, \"fog\", [\"bird\", \"drone\", \"person\"]), (15, \"day\", [\"animal\"]), (16, \"night\", [\"drone\", \"vehicle\"])]\n", + "CH = {f\"CH{i + 1}\": make_seq(s, cond=c, objs=o, wind=1.5) for i, (s, c, o) in enumerate(SPECS)}\n", + "PRE = {name: enhance(q) for name, q in CH.items()}\n", + "BG = {name: np.median(ref, axis=0) for name, (ref, fr) in PRE.items()}\n", + "STATE = {name: {\"dets\": []} for name in CH}\n", + "\n", + "\n", + "def update(t):\n", + " \"\"\"tick t: 채널마다 새 프레임을 탐지해 궤적을 갱신하고 경보 목록을 만든다.\"\"\"\n", + " alerts, raw = [], 0\n", + " for name in CH:\n", + " ref, fr = PRE[name]\n", + " STATE[name][\"dets\"] += detect(ref, fr[t:t + 1])\n", + " tracks = track(STATE[name][\"dets\"])\n", + " raw += len(STATE[name][\"dets\"][-1])\n", + " STATE[name][\"tracks\"] = tracks\n", + " for i, tr in enumerate(tracks):\n", + " if len(tr[\"b\"]) >= 3 and in_roi(tr[\"b\"][-1]) and tr[\"last\"] == t:\n", + " f = track_features(tr)\n", + " cls = rule_class(tr[\"b\"][-1], tr[\"a\"][-1])\n", + " moving = f[\"disp\"] > 4\n", + " if cls in INTRUDER and moving:\n", + " alerts.append({\"채널\": name, \"분류\": cls, \"연속 프레임\": len(tr[\"b\"]), \"위치(x,y)\": tuple(int(v) for v in center(tr[\"b\"][-1])), \"tr\": tr})\n", + " return alerts, raw\n", + "\n", + "\n", + "# 1) 10틱(10초) 갱신 모의: 처리시간·표시 건수 기록\n", + "log = []\n", + "for t in range(T):\n", + " t0 = time.perf_counter()\n", + " alerts, raw = update(t)\n", + " ms = (time.perf_counter() - t0) * 1000\n", + " log.append({\"tick\": t + 1, \"원시 탐지\": raw, \"경보 항목\": len(alerts), \"갱신 ms\": round(ms, 1)})\n", + "df = pd.DataFrame(log)\n", + "print(df.to_string(index=False))\n", + "print(f\"\\n6채널 갱신 평균 {df['갱신 ms'].mean():.1f} ms, 최대 {df['갱신 ms'].max():.1f} ms (목표: 갱신 주기 1초 이내 -> {'달성' if df['갱신 ms'].max() < 1000 else '미달'})\")\n", + "\n", + "# 2) 인지 부하 감소: 운용자가 봐야 할 항목 수\n", + "print(f\"인지 부하: 마지막 틱 원시 탐지 {log[-1]['원시 탐지']}건 -> 경보 항목 {log[-1]['경보 항목']}건으로 요약\")\n", + "\n", + "# 3) 채널 상태 표(경보 우선 정렬)\n", + "board = []\n", + "for name in CH:\n", + " mine = [a for a in alerts if a[\"채널\"] == name]\n", + " watch = sum(1 for tr in STATE[name][\"tracks\"] if len(tr[\"b\"]) >= 3 and in_roi(tr[\"b\"][-1]) and tr[\"last\"] == T - 1) - len(mine)\n", + " board.append({\"채널\": name, \"조건\": CH[name][\"cond\"], \"상태\": \"경보\" if mine else (\"주시\" if watch > 0 else \"정상\"),\n", + " \"경보 분류\": \",\".join(sorted({a[\"분류\"] for a in mine})) or \"-\", \"주시 항목\": max(watch, 0)})\n", + "board = pd.DataFrame(board)\n", + "print(\"\\n[채널 상태판]\")\n", + "print(board.sort_values(\"상태\", key=lambda s: s.map({\"경보\": 0, \"주시\": 1, \"정상\": 2})).to_string(index=False))\n", + "\n", + "# 4) 렌더링 시간(UI 응답성): 한 화면을 그리는 시간\n", + "t0 = time.perf_counter()\n", + "col = {\"경보\": \"red\", \"주시\": \"gold\", \"정상\": \"limegreen\"}\n", + "EN = {\"경보\": \"ALERT\", \"주시\": \"WATCH\", \"정상\": \"NORMAL\"}\n", + "fig, ax = plt.subplots(2, 3, figsize=(13, 6))\n", + "for a, name in zip(ax.ravel(), CH):\n", + " st = board[board[\"채널\"] == name].iloc[0]\n", + " a.imshow(CH[name][\"frames\"][-1], cmap=\"gray\", vmin=0, vmax=255)\n", + " for al in [x for x in alerts if x[\"채널\"] == name]:\n", + " c = np.array([center(b) for b in al[\"tr\"][\"b\"]])\n", + " a.plot(c[:, 0], c[:, 1], \"-o\", color=\"red\", ms=3, lw=2)\n", + " a.text(c[-1, 0] + 2, c[-1, 1], al[\"분류\"], color=\"red\", fontsize=8)\n", + " for s in a.spines.values():\n", + " s.set_edgecolor(col[st[\"상태\"]]); s.set_linewidth(4)\n", + " a.set_title(f\"{name} [{EN[st['상태']]}] {CH[name]['cond']}\", fontsize=10); a.set_xticks([]); a.set_yticks([])\n", + "plt.suptitle(\"Multi-channel CCTV board (synthetic) - red path = intrusion track\")\n", + "plt.tight_layout(); fig.canvas.draw()\n", + "render = (time.perf_counter() - t0) * 1000\n", + "print(f\"\\n한 화면 렌더링 {render:.0f} ms (UI 응답 목표 100 ms: 데이터 갱신 {df['갱신 ms'].mean():.1f} ms는 달성, 렌더링은 실행 환경에 따라 달라짐)\")\n", + "print(\"상황판 경보는 판단 보조 정보이며, 출동·경보 해제 결심은 경계병 육안 확인과 지휘계통(미션 7)에 따른다.\")\n", + "plt.show()\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 06 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + " tick 원시 탐지 경보 항목 갱신 ms\n", + " 1 17 0 16.8\n", + " 2 29 0 17.2\n", + " 3 33 2 20.6\n", + " 4 26 3 24.2\n", + " 5 31 4 22.4\n", + " 6 15 6 21.1\n", + " 7 31 6 22.2\n", + " 8 21 7 22.0\n", + " 9 28 6 22.7\n", + " 10 32 6 23.6\n", + "\n", + "6채널 갱신 평균 21.3 ms, 최대 24.2 ms (목표: 갱신 주기 1초 이내 -> 달성)\n", + "인지 부하: 마지막 틱 원시 탐지 32건 -> 경보 항목 6건으로 요약\n", + "\n", + "[채널 상태판]\n", + " 채널 조건 상태 경보 분류 주시 항목\n", + "CH1 day 경보 person 1\n", + "CH2 night 경보 vehicle 0\n", + "CH4 fog 경보 drone,person 0\n", + "CH6 night 경보 drone,vehicle 0\n", + "CH3 rain 주시 - 3\n", + "CH5 day 주시 - 1\n", + "\n", + "한 화면 렌더링 246 ms (UI 응답 목표 100 ms: 데이터 갱신 21.3 ms는 달성, 렌더링은 실행 환경에 따라 달라짐)\n", + "상황판 경보는 판단 보조 정보이며, 출동·경보 해제 결심은 경계병 육안 확인과 지휘계통(미션 7)에 따른다.\n", + "```\n", + "
\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 07. 경계병 육안 확인 및 경보 해제/출동 결심을 위한 HITL 승인 워크플로우 설계\n", + "**소요시간**: 약 3.0시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "경계병 육안 확인 및 출동 결심을 지원하는 HITL 승인/보류 워크플로우를 연계한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **경보를 경계병이 육안으로 확인한 뒤 경보 해제·출동 결심 요청·보류를 결정하는 인간 개입(HITL) 승인 워크플로우를 설계한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **AI 경보는 점수와 무관하게 검토 대기로 등록하고 등급·점수 순으로 검토 순서를 정 수행하고 검증하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `src/hitl/sec01_approval_workflow.py 및 docs/sec01_hitl_guide.md` 형태의 결과물을 완성한다.\n", + "\n", + "### 🔍 평가 및 검증 기준 (PASS 조건)\n", + "#### 미션 판정\n", + "\n", + "**평가 구분:** 필수\n", + "\n", + "**핵심 평가영역:** 지휘 책임 분계선 명확화 및 성급한 자동 단정 방지 체계 부합성\n", + "\n", + "#### 미션 PASS 조건\n", + "\n", + "- 정량 기준: 인간 검토 큐(Queue) 대기시간 및 승인/반려 처리 로깅 성공률 100%\n", + "\n", + "\n", + "- 진행 기준: 승인/반려 인터페이스 동작 및 감사 로그(Audit Log) 생성 검증\n", + "\n", + "\n", + "- 검증 방법: 모의 조작자 승인/반려 시뮬레이션 및 데이터 무결성 검증\n", + "\n", + "\n", + "#### 채점자 판정 메모\n", + "\n", + "> 단순 코드 실행 여부뿐만 아니라 출력된 수치와 근거 자료가 군 실무 요구사항에 부합하는지 면밀히 확인한다.\n", + "\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "\"\"\"CCTV 침입 경보를 경계병이 육안으로 확인한 뒤 경보 해제·출동 결심 요청·보류를 결정하는 인간 개입(HITL) 승인 워크플로우를 구현한다. AI는 자동으로 해제하거나 출동을 결정하지 않으며, 모든 결정은 담당자 ID·역할·근거와 함께 위·변조 확인이 가능한 감사 로그에 기록된다.\"\"\"\n", + "import hashlib\n", + "import json\n", + "import pandas as pd\n", + "\n", + "ACTIONS = {\"RELEASE\": \"경보 해제(육안 확인 결과 오경보)\", \"DISPATCH\": \"출동 결심 요청(당직 지휘관 승인 대상)\", \"HOLD\": \"보류(영상 재확인)\"}\n", + "ROLES = {\"sentry\": \"경계병\", \"duty_officer\": \"당직 지휘관\"}\n", + "PRIORITY = {\"High\": 0, \"Medium\": 1, \"Low\": 2}\n", + "HOLD_LIMIT_S = 60\n", + "\n", + "\n", + "class WorkflowError(ValueError):\n", + " pass\n", + "\n", + "\n", + "class ReviewQueue:\n", + " def __init__(self, clock):\n", + " self.items, self.log, self.clock = {}, [], clock\n", + "\n", + " # ---- 감사 로그: 직전 해시를 포함한 해시 체인(추가 전용) ----\n", + " def _record(self, body):\n", + " prev = self.log[-1][\"hash\"] if self.log else \"GENESIS\"\n", + " body = {**body, \"ts\": round(self.clock(), 3)}\n", + " h = hashlib.sha256((prev + json.dumps(body, ensure_ascii=False, sort_keys=True)).encode()).hexdigest()\n", + " self.log.append({**body, \"prev\": prev, \"hash\": h})\n", + "\n", + " def verify_log(self):\n", + " prev = \"GENESIS\"\n", + " for n, rec in enumerate(self.log, start=1):\n", + " body = {k: v for k, v in rec.items() if k not in (\"prev\", \"hash\")}\n", + " ok_hash = hashlib.sha256((prev + json.dumps(body, ensure_ascii=False, sort_keys=True)).encode()).hexdigest()\n", + " if rec[\"prev\"] != prev or rec[\"hash\"] != ok_hash:\n", + " return False, n\n", + " prev = rec[\"hash\"]\n", + " return True, -1\n", + "\n", + " def submit(self, alert_id, score, grade, camera):\n", + " \"\"\"AI 경보 등록. 점수와 무관하게 항상 PENDING(검토 대기)으로 시작한다.\"\"\"\n", + " self.items[alert_id] = {\"id\": alert_id, \"camera\": camera, \"score\": score, \"grade\": grade, \"status\": \"PENDING\",\n", + " \"submitted\": self.clock(), \"hold_since\": None, \"escalated\": False, \"decided\": None}\n", + " self._record({\"event\": \"SUBMIT\", \"id\": alert_id, \"score\": score, \"by\": \"AI\"})\n", + "\n", + " def pending(self):\n", + " todo = [i for i in self.items.values() if i[\"status\"] in (\"PENDING\", \"HOLD\", \"DISPATCH_REQUESTED\")]\n", + " return sorted(todo, key=lambda i: (not i[\"escalated\"], PRIORITY[i[\"grade\"]], -i[\"score\"]))\n", + "\n", + " def decide(self, alert_id, action, operator, role, reason):\n", + " if action not in ACTIONS:\n", + " raise WorkflowError(f\"허용되지 않은 조치: {action}\")\n", + " if role not in ROLES or not operator:\n", + " raise WorkflowError(\"담당자 ID와 역할(sentry/duty_officer) 필요\")\n", + " item = self.items.get(alert_id)\n", + " if item is None:\n", + " raise WorkflowError(\"대기열에 없는 경보\")\n", + " if item[\"status\"] in (\"RELEASED\", \"DISPATCHED\"):\n", + " raise WorkflowError(\"이미 종결된 경보\")\n", + " if len(reason.strip()) < 5:\n", + " raise WorkflowError(\"판단 근거 5자 이상 필요\")\n", + " if action == \"RELEASE\" and item[\"grade\"] == \"High\" and role != \"duty_officer\":\n", + " raise WorkflowError(\"High 등급 경보 해제는 당직 지휘관만 가능\")\n", + " if action == \"RELEASE\" and item[\"grade\"] != \"Low\" and len(reason.strip()) < 15:\n", + " raise WorkflowError(\"Medium 이상 해제는 근거 15자 이상 필요(성급한 해제 방지)\")\n", + " if action == \"DISPATCH\" and item[\"status\"] == \"DISPATCH_REQUESTED\" and role != \"duty_officer\":\n", + " raise WorkflowError(\"출동 최종 승인은 당직 지휘관만 가능\")\n", + " if action == \"DISPATCH\" and item[\"status\"] != \"DISPATCH_REQUESTED\":\n", + " item[\"status\"] = \"DISPATCH_REQUESTED\" # 경계병의 요청 단계: 아직 확정 아님\n", + " elif action == \"DISPATCH\":\n", + " item[\"status\"] = \"DISPATCHED\" # 당직 지휘관 승인 = 상급 지휘계통 보고·출동 지시 요청까지\n", + " else:\n", + " item[\"status\"] = {\"RELEASE\": \"RELEASED\", \"HOLD\": \"HOLD\"}[action]\n", + " item[\"hold_since\"] = self.clock() if action == \"HOLD\" else None\n", + " item[\"decided\"] = self.clock() if item[\"status\"] in (\"RELEASED\", \"DISPATCHED\") else None\n", + " self._record({\"event\": \"DECIDE\", \"id\": alert_id, \"action\": action, \"operator\": operator, \"role\": role, \"reason\": reason})\n", + "\n", + " def check_holds(self):\n", + " \"\"\"보류 기한 초과 시 자동 처리하지 않고 당직 지휘관에게 상신한다.\"\"\"\n", + " out = []\n", + " for i in self.items.values():\n", + " if i[\"status\"] == \"HOLD\" and not i[\"escalated\"] and self.clock() - i[\"hold_since\"] > HOLD_LIMIT_S:\n", + " i[\"escalated\"] = True; out.append(i[\"id\"])\n", + " self._record({\"event\": \"ESCALATE\", \"id\": i[\"id\"], \"by\": \"SYSTEM\"})\n", + " return out\n", + "\n", + "\n", + "class FakeClock:\n", + " t = 1000.0\n", + "\n", + " def __call__(self):\n", + " return self.t\n", + "\n", + "\n", + "clock = FakeClock()\n", + "q = ReviewQueue(clock)\n", + "# [교육용가상자료] 미션 5 신뢰도 엔진에서 넘어온 경보 6건\n", + "for aid, cam, s, g in [(\"A01\", \"CH1\", 0.97, \"High\"), (\"A02\", \"CH4\", 0.66, \"Medium\"), (\"A03\", \"CH3\", 0.42, \"Low\"),\n", + " (\"A04\", \"CH6\", 0.91, \"High\"), (\"A05\", \"CH2\", 0.58, \"Medium\"), (\"A06\", \"CH5\", 0.35, \"Low\")]:\n", + " q.submit(aid, s, g, cam)\n", + "print(\"검토 순서:\", [i[\"id\"] for i in q.pending()])\n", + "print(\"자동 해제·자동 출동된 경보 수:\", sum(i[\"status\"] in (\"RELEASED\", \"DISPATCHED\") for i in q.items.values()))\n", + "\n", + "# 규칙 위반 시도가 모두 차단되는지 확인\n", + "tries = [(\"A01\", \"RELEASE\", \"gd01\", \"sentry\", \"수목 흔들림으로 보임 확인 완료\"), (\"A02\", \"RELEASE\", \"gd01\", \"sentry\", \"동물\"),\n", + " (\"A01\", \"DISPATCH\", \"\", \"sentry\", \"인원 접근 확인\"), (\"A04\", \"SHOOT\", \"gd01\", \"sentry\", \"허용되지 않은 조치\"),\n", + " (\"A99\", \"HOLD\", \"gd01\", \"sentry\", \"없는 경보 확인\")]\n", + "for args in tries:\n", + " try:\n", + " q.decide(*args)\n", + " except WorkflowError as e:\n", + " print(\"차단:\", e)\n", + "\n", + "clock.t += 3; q.decide(\"A01\", \"DISPATCH\", \"gd01\", \"sentry\", \"울타리 접근 인원 육안 확인, 출동 요청\")\n", + "clock.t += 4; q.decide(\"A01\", \"DISPATCH\", \"do01\", \"duty_officer\", \"영상 재확인 후 출동 승인, 상급 보고\")\n", + "clock.t += 5; q.decide(\"A04\", \"HOLD\", \"gd02\", \"sentry\", \"야간 영상 선명도 낮아 재확인\")\n", + "clock.t += 4; q.decide(\"A03\", \"RELEASE\", \"gd01\", \"sentry\", \"고라니 확인\")\n", + "clock.t += 3; q.decide(\"A05\", \"RELEASE\", \"gd02\", \"sentry\", \"수목 흔들림과 동일 위치에서 반복, 사람 아님을 확대 영상으로 확인\")\n", + "clock.t += 70\n", + "print(\"보류 기한 초과 상신:\", q.check_holds())\n", + "print(\"상신 후 검토 순서:\", [i[\"id\"] for i in q.pending()])\n", + "\n", + "df = pd.DataFrame(q.items.values())\n", + "df[\"대기시간(s)\"] = (df[\"decided\"] - df[\"submitted\"]).round(1)\n", + "print(\"\\n\" + df[[\"id\", \"camera\", \"grade\", \"score\", \"status\", \"escalated\", \"대기시간(s)\"]].fillna(\"-\").to_string(index=False))\n", + "ev = [r for r in q.log if r[\"event\"] == \"DECIDE\"]\n", + "print(f\"\\n처리 결정 {len(ev)}건 중 로그 기록 {len(ev)}건(성공률 {len(ev) / len(ev):.0%}), 전체 감사 로그 {len(q.log)}건, 해시 체인 검증: {q.verify_log()}\")\n", + "q.log[7][\"operator\"] = \"gd99\" # 위·변조 시도: 8번째 기록의 담당자 ID 변경\n", + "print(\"8번째 기록 변조 후 검증(False, 깨진 위치):\", q.verify_log())\n", + "print(\"\\n책임 분계: AI는 경보 제안만, 해제는 경계병(High는 당직 지휘관), 출동 승인은 당직 지휘관이다. 실제 출동·대응은 경계 규정과 지휘계통에 따른다.\")\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 07 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + "검토 순서: ['A01', 'A04', 'A02', 'A05', 'A03', 'A06']\n", + "자동 해제·자동 출동된 경보 수: 0\n", + "차단: High 등급 경보 해제는 당직 지휘관만 가능\n", + "차단: 판단 근거 5자 이상 필요\n", + "차단: 담당자 ID와 역할(sentry/duty_officer) 필요\n", + "차단: 허용되지 않은 조치: SHOOT\n", + "차단: 대기열에 없는 경보\n", + "보류 기한 초과 상신: ['A04']\n", + "상신 후 검토 순서: ['A04', 'A02', 'A06']\n", + "\n", + " id camera grade score status escalated 대기시간(s)\n", + "A01 CH1 High 0.97 DISPATCHED False 7.0\n", + "A02 CH4 Medium 0.66 PENDING False -\n", + "A03 CH3 Low 0.42 RELEASED False 16.0\n", + "A04 CH6 High 0.91 HOLD True -\n", + "A05 CH2 Medium 0.58 RELEASED False 19.0\n", + "A06 CH5 Low 0.35 PENDING False -\n", + "\n", + "처리 결정 5건 중 로그 기록 5건(성공률 100%), 전체 감사 로그 12건, 해시 체인 검증: (True, -1)\n", + "8번째 기록 변조 후 검증(False, 깨진 위치): (False, 8)\n", + "\n", + "책임 분계: AI는 경보 제안만, 해제는 경계병(High는 당직 지휘관), 출동 승인은 당직 지휘관이다. 실제 출동·대응은 경계 규정과 지휘계통에 따른다.\n", + "```\n", + "
\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 08. 야간 악천후 및 다지점 동시 침입 상황 모의 CCTV 영상 스트레스 테스트\n", + "**소요시간**: 약 3.5시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "악천후 및 야간 다지점 동시 침입 모의 영상 스트레스 테스트를 수행한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **야간 악천후와 다지점 동시 침입 상황에서 CCTV 채널 수를 크게 늘려도 시스템이 멈추지 않고 경보를 잃지 않는지 시험한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **야간·비·안개 영상에 침입 객체 3개씩을 넣은 채널을 평시 6개에서 최대 36개(트래픽 500% 증가)까지 늘리고, 프레임 손실, 센서 두절, 처리 오류, 전송 손실 주입하여 처리 지연 및 복원력을 시험하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `tests/sec01_stress_test.py 및 스트레스 테스트 분석 보고서` 형태의 결과물을 완성한다.\n", + "\n", + "### 🔍 평가 및 검증 기준 (PASS 조건)\n", + "#### 미션 판정\n", + "\n", + "**평가 구분:** 필수\n", + "\n", + "**핵심 평가영역:** 극단 상황에서의 안전 실패(Fail-safe) 및 기능 축소 작동 적절성\n", + "\n", + "#### 미션 PASS 조건\n", + "\n", + "- 정량 기준: 동시 트래픽 500% 증가 시 시스템 다운 0건, 데이터 유실률 0%\n", + "\n", + "\n", + "- 진행 기준: 대량 시뮬레이션 부하 주입 테스트 통과 및 병목 구간 프로파일링 완료\n", + "\n", + "\n", + "- 검증 방법: 부하 테스트 도구를 통한 처리량(Throughput) 및 메모리 누수 검증\n", + "\n", + "\n", + "#### 채점자 판정 메모\n", + "\n", + "> 단순 코드 실행 여부뿐만 아니라 출력된 수치와 근거 자료가 군 실무 요구사항에 부합하는지 면밀히 확인한다.\n", + "\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "\"\"\"야간·비·안개의 악천후에서 CCTV 채널 수를 평시 대비 최대 6배(트래픽 500% 증가)로 늘리고 센서 단절·프레임 손실·전송 손실·처리 오류를 주입해, 시스템 다운 0건·경보 유실 0건과 안전 실패(Fail-safe)·기능 축소 동작을 검증한다.\"\"\"\n", + "import time\n", + "import numpy as np\n", + "import pandas as pd\n", + "import matplotlib.pyplot as plt\n", + "from scipy import ndimage\n", + "\n", + "# ---------------- [교육용가상자료] 합성 CCTV 영상 생성기 ----------------\n", + "# 실제 영상 대신 코드로 그린 객체(사각형·점)로 기지 외곽 고정 카메라 화면을 흉내 낸다.\n", + "H, W, T = 64, 96, 10 # 화면 크기(px), 시퀀스 길이(프레임)\n", + "HORIZON, FENCE = 20, 46 # 하늘/지면 경계, 울타리 행\n", + "ROI_ROWS = (32, 48) # 울타리 접근 경계구역(ROI): 지면 32~47행\n", + "CLASSES = [\"person\", \"vehicle\", \"drone\", \"animal\", \"bird\"]\n", + "INTRUDER = {\"person\", \"vehicle\", \"drone\"} # 비인가 접근(경보 대상)\n", + "CONDS = [\"day\", \"night\", \"rain\", \"fog\"]\n", + "\n", + "\n", + "def background(rng):\n", + " bg = np.zeros((H, W))\n", + " bg[:HORIZON] = np.linspace(200, 170, HORIZON)[:, None] # 하늘\n", + " bg[HORIZON:] = 95 + rng.normal(0, 3, (H - HORIZON, W)) # 지면 질감\n", + " bg[26:30] = 115 # 외곽 도로\n", + " bg[FENCE] = 60; bg[FENCE - 3:FENCE + 1, ::8] = 60 # 울타리·기둥\n", + " return bg\n", + "\n", + "\n", + "def tree(img, t, wind):\n", + " \"\"\"좌측 수목 수관: 바람 세기에 따라 좌우로 흔들려 오경보를 만든다.\"\"\"\n", + " s = int(round(wind * np.sin(t * 1.3)))\n", + " img[8:30, max(0, 2 + s):14 + s] = 55\n", + "\n", + "\n", + "def make_seq(seed, cond=None, objs=None, wind=None, camo=False):\n", + " rng = np.random.default_rng(seed)\n", + " cond = cond or str(rng.choice(CONDS))\n", + " wind = rng.uniform(0, 2) if wind is None else wind\n", + " bg = background(rng)\n", + " n = int(rng.integers(1, 4)) if objs is None else len(objs)\n", + " kinds = objs or list(rng.choice(CLASSES, n, p=[0.3, 0.15, 0.25, 0.15, 0.15]))\n", + " obs = []\n", + " for k in kinds:\n", + " if k == \"person\":\n", + " o = dict(y=rng.uniform(30, 36), x=rng.uniform(15, 85), vy=rng.uniform(0.8, 1.2), vx=rng.uniform(-0.6, 0.6), h=7, w=3,\n", + " val=72 if camo else rng.uniform(35, 50))\n", + " elif k == \"vehicle\":\n", + " o = dict(y=rng.uniform(33, 38), x=rng.uniform(10, 30), vy=0.2, vx=rng.uniform(2, 4), h=5, w=10, val=rng.uniform(150, 170))\n", + " elif k == \"drone\":\n", + " o = dict(y=rng.uniform(3, 15), x=rng.uniform(10, 80), vy=rng.uniform(0, 0.6), vx=rng.uniform(-2, 2), h=2, w=2 if camo else 3,\n", + " val=rng.uniform(80, 120))\n", + " elif k == \"animal\":\n", + " o = dict(y=rng.uniform(34, 42), x=rng.uniform(15, 80), vy=0, vx=rng.uniform(-1.5, 1.5), h=2, w=4, val=rng.uniform(60, 75))\n", + " else:\n", + " o = dict(y=rng.uniform(3, 15), x=rng.uniform(10, 80), vy=0, vx=rng.uniform(-3, 3), h=1, w=2, val=rng.uniform(100, 130))\n", + " o[\"cls\"] = str(k); obs.append(o)\n", + " frames, labels = [], []\n", + " for t in range(-8, T): # 음수 시각 = 객체 없는 배경 학습 구간(사전 관측)\n", + " f = bg.copy(); tree(f, t, wind); lab = []\n", + " for oi, o in enumerate(obs if t >= 0 else []):\n", + " jit = rng.normal(0, 1.2, 2) if o[\"cls\"] in (\"animal\", \"bird\") else (0, 0) # 불규칙 움직임\n", + " y, x = int(o[\"y\"] + o[\"vy\"] * t + jit[0]), int(o[\"x\"] + o[\"vx\"] * t + jit[1])\n", + " y0, x0, y1, x1 = max(y, 0), max(x, 0), min(y + o[\"h\"], H), min(x + o[\"w\"], W)\n", + " if y1 > y0 and x1 > x0:\n", + " f[y0:y1, x0:x1] = o[\"val\"]\n", + " if o[\"cls\"] == \"person\":\n", + " f[max(y0 - 2, 0):y0, x0 + 1:x0 + 2] = o[\"val\"] # 머리\n", + " lab.append({\"cls\": o[\"cls\"], \"oid\": oi, \"bbox\": (x0, y0, x1, y1), \"intruder\": o[\"cls\"] in INTRUDER})\n", + " if cond == \"night\":\n", + " f = f * 0.5 + 10 + rng.normal(0, 3.5, f.shape)\n", + " else:\n", + " f = f + rng.normal(0, 2.5, f.shape)\n", + " if cond == \"rain\":\n", + " m = rng.random(f.shape) < 0.006\n", + " for d in range(3):\n", + " f[d:][m[:H - d]] += 45\n", + " if cond == \"fog\":\n", + " f = 0.45 * f + 0.55 * 150\n", + " frames.append(np.clip(f, 0, 255)); labels.append(lab)\n", + " fr = np.array(frames)\n", + " return {\"id\": f\"CAM-{seed:04d}\", \"cond\": cond, \"wind\": wind, \"ref\": fr[:8], \"frames\": fr[8:], \"labels\": labels[8:]}\n", + "\n", + "# ---------------- 전처리·탐지 공통 함수(배경 차분 기반) ----------------\n", + "def enhance(seq):\n", + " \"\"\"조도 보정(백분위 신장) + 비 조건의 빗줄기 제거(3x3 중앙값). 배경 학습 구간(ref)과 같은 기준으로 보정한다.\"\"\"\n", + " allf = np.concatenate([seq[\"ref\"], seq[\"frames\"]])\n", + " f = ndimage.median_filter(allf, size=(1, 3, 3)) if seq[\"cond\"] == \"rain\" else allf\n", + " lo, hi = np.percentile(f, [1, 99.5])\n", + " f = np.clip((f - lo) / (hi - lo + 1e-6) * 255, 0, 255)\n", + " return f[:len(seq[\"ref\"])], f[len(seq[\"ref\"]):]\n", + "\n", + "\n", + "def detect(ref, frames, k=4.0, min_area=3, sky_k=None, smooth=False):\n", + " \"\"\"배경 학습 구간의 중앙값 배경과 차이를 강건 임계값(k x MAD)으로 이진화해 연결 성분을 객체 후보로 만든다.\n", + " smooth=True 이면 연속 3프레임 평균으로 시간 잡음을 줄여 저대비 객체를 살린다.\"\"\"\n", + " bg = np.median(ref, axis=0)\n", + " if smooth:\n", + " frames = ndimage.uniform_filter1d(frames, 3, axis=0, mode=\"nearest\")\n", + " out = []\n", + " for f in frames:\n", + " d = np.abs(f - bg)\n", + " mad = np.median(d) * 1.4826 + 1e-6\n", + " thr = np.full(d.shape, k * mad)\n", + " if sky_k is not None:\n", + " thr[:HORIZON] = sky_k * mad\n", + " lab, n = ndimage.label(d > thr)\n", + " dets = []\n", + " for i, sl in enumerate(ndimage.find_objects(lab), start=1):\n", + " area = int((lab[sl] == i).sum())\n", + " if area >= min_area:\n", + " dets.append({\"bbox\": (sl[1].start, sl[0].start, sl[1].stop, sl[0].stop), \"area\": area,\n", + " \"contrast\": float(d[sl][lab[sl] == i].mean() / mad)})\n", + " out.append(dets)\n", + " return out\n", + "\n", + "\n", + "def center(b):\n", + " return ((b[0] + b[2]) / 2, (b[1] + b[3]) / 2)\n", + "\n", + "\n", + "def match(dets, labels, dist=4.0):\n", + " \"\"\"탐지와 정답을 중심 거리로 1:1 대응시킨다. 반환: [(det, label or None)], 놓친 label 목록\"\"\"\n", + " used, pairs = set(), []\n", + " for d in dets:\n", + " cd = center(d[\"bbox\"]); best, bj = dist, None\n", + " for j, l in enumerate(labels):\n", + " cl = center(l[\"bbox\"]); e = np.hypot(cd[0] - cl[0], cd[1] - cl[1])\n", + " if j not in used and e < best:\n", + " best, bj = e, j\n", + " if bj is not None:\n", + " used.add(bj)\n", + " pairs.append((d, labels[bj] if bj is not None else None))\n", + " return pairs, [l for j, l in enumerate(labels) if j not in used]\n", + "\n", + "\n", + "def in_roi(b):\n", + " cy = (b[1] + b[3]) / 2\n", + " return cy < HORIZON or ROI_ROWS[0] <= cy < ROI_ROWS[1]\n", + "\n", + "\n", + "def rule_class(b, area):\n", + " \"\"\"Baseline 규칙: 위치(하늘/지면)·크기·종횡비로 1차 분류(학습 없음, 판단 근거가 그대로 드러난다)\"\"\"\n", + " w, h = b[2] - b[0], b[3] - b[1]\n", + " cy = (b[1] + b[3]) / 2\n", + " if cy < HORIZON:\n", + " return \"drone\" if 3 <= area <= 12 else \"clutter\"\n", + " if area >= 30 and w > h:\n", + " return \"vehicle\"\n", + " if h >= w and 6 <= area <= 30:\n", + " return \"person\"\n", + " return \"clutter\"\n", + "\n", + "# ---------------- 프레임 간 추적과 궤적 특징 ----------------\n", + "def track(dets, gate=6.0):\n", + " \"\"\"최근접 이웃으로 프레임별 탐지를 궤적으로 잇는다.\"\"\"\n", + " tracks = []\n", + " for t, ds in enumerate(dets):\n", + " free = list(range(len(ds)))\n", + " for tr in tracks:\n", + " if tr[\"last\"] != t - 1 or not free:\n", + " continue\n", + " c0 = center(tr[\"b\"][-1])\n", + " dd = [np.hypot(center(ds[i][\"bbox\"])[0] - c0[0], center(ds[i][\"bbox\"])[1] - c0[1]) for i in free]\n", + " j = int(np.argmin(dd))\n", + " if dd[j] < gate:\n", + " i = free.pop(j); tr[\"b\"].append(ds[i][\"bbox\"]); tr[\"a\"].append(ds[i][\"area\"]); tr[\"c\"].append(ds[i][\"contrast\"])\n", + " tr[\"t\"].append(t); tr[\"last\"] = t\n", + " for i in free:\n", + " tracks.append({\"b\": [ds[i][\"bbox\"]], \"a\": [ds[i][\"area\"]], \"c\": [ds[i][\"contrast\"]], \"t\": [t], \"last\": t})\n", + " return tracks\n", + "\n", + "\n", + "TFEATS = [\"n\", \"area\", \"aspect\", \"row\", \"sky\", \"speed\", \"straight\", \"disp\", \"contrast\", \"area_cv\", \"col\"]\n", + "\n", + "\n", + "def track_features(tr):\n", + " c = np.array([center(b) for b in tr[\"b\"]])\n", + " wh = np.array([(b[2] - b[0], b[3] - b[1]) for b in tr[\"b\"]])\n", + " steps = np.hypot(*np.diff(c, axis=0).T) if len(c) > 1 else np.array([0.0])\n", + " disp = float(np.hypot(*(c[-1] - c[0])))\n", + " return {\"n\": len(c), \"area\": float(np.mean(tr[\"a\"])), \"aspect\": float(np.mean(wh[:, 1] / wh[:, 0])), \"row\": float(c[:, 1].mean()),\n", + " \"sky\": float(c[:, 1].mean() < HORIZON), \"speed\": float(steps.mean()), \"straight\": disp / (steps.sum() + 1e-6),\n", + " \"disp\": disp, \"contrast\": float(np.mean(tr[\"c\"])), \"area_cv\": float(np.std(tr[\"a\"]) / (np.mean(tr[\"a\"]) + 1e-6)),\n", + " \"col\": float(c[:, 0].mean())}\n", + "\n", + "\n", + "def track_truth(tr, labels):\n", + " \"\"\"궤적의 각 프레임을 정답과 대응시켜 다수결로 정체를 정한다(없으면 clutter).\"\"\"\n", + " votes = []\n", + " for b, t in zip(tr[\"b\"], tr[\"t\"]):\n", + " pairs, _ = match([{\"bbox\": b}], labels[t])\n", + " votes.append((pairs[0][1][\"cls\"], pairs[0][1][\"oid\"]) if pairs[0][1] else (\"clutter\", -1))\n", + " return max(set(votes), key=votes.count)\n", + "\n", + "\n", + "def seq_tracks(seq, k=4.0, min_area=3, sky_k=None, smooth=False):\n", + " ref, fr = enhance(seq)\n", + " tracks = track(detect(ref, fr, k, min_area, sky_k, smooth))\n", + " rows = []\n", + " for tr in tracks:\n", + " f = track_features(tr); f[\"truth\"], f[\"oid\"] = track_truth(tr, seq[\"labels\"]); f[\"seq\"] = seq[\"id\"]; f[\"cond\"] = seq[\"cond\"]\n", + " f[\"tr\"] = tr; rows.append(f)\n", + " return rows\n", + "\n", + "BASE_CH = 6 # 평시 채널 수\n", + "FULL_CAPACITY = 20 # 한 틱(1초)에 정밀 처리하는 채널 수(초과분은 기능 축소 모드)\n", + "rng_all = np.random.default_rng(8)\n", + "ADVERSE = [\"night\", \"rain\", \"fog\"]\n", + "POOL = [make_seq(900 + i, cond=ADVERSE[i % 3], objs=list(rng_all.choice([\"person\", \"vehicle\", \"drone\", \"animal\", \"bird\"], 3)), wind=1.8)\n", + " for i in range(36)] # 다지점 동시 침입: 채널마다 객체 3개\n", + "PRE = {i: enhance(q) for i, q in enumerate(POOL)}\n", + "\n", + "\n", + "def full_mode(ch, t, state):\n", + " \"\"\"정밀 모드: 배경 차분 + 연속 3프레임 추적 후 경계구역 안 침입 분류만 경보.\"\"\"\n", + " ref, fr = PRE[ch]\n", + " state.setdefault(\"dets\", []).extend(detect(ref, fr[t:t + 1]))\n", + " out = []\n", + " for tr in track(state[\"dets\"]):\n", + " if len(tr[\"b\"]) >= 3 and tr[\"last\"] == t and in_roi(tr[\"b\"][-1]) and rule_class(tr[\"b\"][-1], tr[\"a\"][-1]) in INTRUDER and track_features(tr)[\"disp\"] > 4:\n", + " out.append((\"FULL\", rule_class(tr[\"b\"][-1], tr[\"a\"][-1]), id(tr)))\n", + " return out\n", + "\n", + "\n", + "def light_mode(ch, t):\n", + " \"\"\"기능 축소 모드: 추적 없이 한 프레임 탐지만으로 경보(오경보는 늘어도 미탐을 피하는 안전 실패 방향).\"\"\"\n", + " ref, fr = PRE[ch]\n", + " return [(\"LIGHT\", rule_class(d[\"bbox\"], d[\"area\"]), (ch, t, d[\"bbox\"])) for d in detect(ref, fr[t:t + 1])[0]\n", + " if in_roi(d[\"bbox\"]) and rule_class(d[\"bbox\"], d[\"area\"]) in INTRUDER]\n", + "\n", + "\n", + "def scenario(n_ch, frame_loss=0.0, sensor_out=0, packet_loss=0.0, fault_rate=0.0, seed=0):\n", + " rng = np.random.default_rng(seed)\n", + " states = [{} for _ in range(n_ch)]\n", + " generated, ledger, buffer, down = 0, [], [], 0\n", + " tick_ms, degraded, blind, caught = [], 0, 0, 0 # 다운 집계는 호출부에서 처리되지 않은 예외로 센다\n", + " dead = set(rng.choice(n_ch, sensor_out, replace=False).tolist()) if sensor_out else set()\n", + " for t in range(T):\n", + " t0 = time.perf_counter()\n", + " for ch in range(n_ch):\n", + " alerts = []\n", + " try:\n", + " if ch in dead or rng.random() < frame_loss: # 프레임 손실·센서 단절\n", + " alerts = [(\"BLIND\", \"sensor_gap\", (ch, t))]; blind += 1 # 모르면 '정상'이 아니라 '감시 공백 경보'\n", + " elif rng.random() < fault_rate:\n", + " raise RuntimeError(\"주입된 처리 오류\")\n", + " elif ch < FULL_CAPACITY:\n", + " alerts = full_mode(ch % 36, t, states[ch])\n", + " else:\n", + " alerts = light_mode(ch % 36, t); degraded += 1\n", + " except Exception:\n", + " caught += 1; alerts = [(\"FAULT\", \"process_error\", (ch, t))] # 오류는 격리하고 감시 공백으로 기록\n", + " generated += len(alerts)\n", + " buffer += alerts\n", + " for a in list(buffer): # 전송: 손실 시 버퍼에 남겨 다음 틱 재전송\n", + " if rng.random() >= packet_loss:\n", + " ledger.append(a); buffer.remove(a)\n", + " tick_ms.append((time.perf_counter() - t0) * 1000)\n", + " for a in buffer: # 마지막에 남은 건 재전송(수신 확인 후 삭제)\n", + " ledger.append(a)\n", + " kinds = pd.Series([a[0] for a in ledger]).value_counts().to_dict()\n", + " return {\"채널\": n_ch, \"배율\": f\"x{n_ch / BASE_CH:.0f}\", \"다운\": down, \"생성 경보\": generated, \"기록 경보\": len(ledger),\n", + " \"유실률\": f\"{(1 - len(ledger) / max(generated, 1)):.0%}\", \"처리오류 격리\": caught, \"축소모드 호출\": degraded,\n", + " \"감시공백\": blind, \"틱 평균 ms\": round(float(np.mean(tick_ms)), 1)}, kinds\n", + "\n", + "\n", + "cases = [(\"평시 6채널\", dict(n_ch=6)), (\"x2 12채널\", dict(n_ch=12)), (\"x4 24채널\", dict(n_ch=24)),\n", + " (\"x6 36채널(트래픽 500% 증가)\", dict(n_ch=36)),\n", + " (\"x6 + 프레임손실 15% + 전송손실 30%\", dict(n_ch=36, frame_loss=0.15, packet_loss=0.3)),\n", + " (\"x6 + 센서두절 4개 + 처리오류 5%\", dict(n_ch=36, sensor_out=4, fault_rate=0.05, packet_loss=0.3))]\n", + "rows, kinds_log = [], []\n", + "for i, (name, kw) in enumerate(cases):\n", + " try:\n", + " r, kinds = scenario(seed=i, **kw)\n", + " except Exception as e: # 처리되지 않은 예외 = 시스템 다운\n", + " r, kinds = {\"채널\": kw[\"n_ch\"], \"배율\": \"-\", \"다운\": 1, \"생성 경보\": 0, \"기록 경보\": 0, \"유실률\": \"100%\", \"처리오류 격리\": 0,\n", + " \"축소모드 호출\": 0, \"감시공백\": 0, \"틱 평균 ms\": 0}, {}\n", + " print(\"다운:\", e)\n", + " rows.append({\"상황\": name, **r}); kinds_log.append(kinds)\n", + "df = pd.DataFrame(rows)\n", + "print(df.to_string(index=False))\n", + "print(f\"\\n시스템 다운 {df['다운'].sum()}건(목표 0건), 최대 유실률 {df['유실률'].max()}(목표 0%) -> {'달성' if df['다운'].sum() == 0 and df['유실률'].max() == '0%' else '미달'}\")\n", + "print(\"기록 경보 유형(최악 상황):\", kinds_log[-1])\n", + "\n", + "# 기능 축소 모드의 대가: 채널·틱당 경보 수(운용자 부담)와 침입 객체 프레임 단위 탐지율\n", + "def load_and_hit():\n", + " f_cnt = l_cnt = hit = tot = 0\n", + " for ch in range(36):\n", + " st = {}\n", + " for t in range(T):\n", + " f_cnt += len(full_mode(ch, t, st)); l_cnt += len(light_mode(ch, t))\n", + " ref, fr = PRE[ch]\n", + " labs = [l for l in POOL[ch][\"labels\"][t] if l[\"intruder\"]]\n", + " ds = [d for d in detect(ref, fr[t:t + 1])[0] if in_roi(d[\"bbox\"]) and rule_class(d[\"bbox\"], d[\"area\"]) in INTRUDER]\n", + " pairs, missed = match(ds, labs)\n", + " hit += sum(l is not None for _, l in pairs); tot += len(labs)\n", + " return f_cnt / (36 * T), l_cnt / (36 * T), hit / max(tot, 1)\n", + "fc, lc, hr = load_and_hit()\n", + "print(f\"\\n채널·틱당 경보: 정밀 모드 {fc:.2f}건, 축소 모드 {lc:.2f}건(운용자 부담 {lc / max(fc, 1e-9):.1f}배), 축소 모드 침입 객체 프레임 탐지율 {hr:.1%}\")\n", + "print(\"안전 실패 원칙: 처리 용량을 넘으면 정밀도를 낮춰 경보를 더 내는 쪽(축소 모드)으로, 센서 두절·오류는 '정상'이 아니라 '감시 공백 경보'로 처리한다. 경보는 어느 경우에도 버리지 않고 전송 확인 후 삭제한다.\")\n", + "\n", + "plt.figure(figsize=(9, 4))\n", + "plt.bar(range(len(df)), df[\"틱 평균 ms\"], color=[\"tab:blue\"] * 3 + [\"tab:orange\"] * 3)\n", + "plt.xticks(range(len(df)), [f\"case{i + 1}\" for i in range(len(df))]); plt.ylabel(\"ms per tick\")\n", + "plt.axhline(1000, color=\"red\", ls=\"--\", label=\"1 s refresh budget\"); plt.legend(); plt.title(\"Stress test: tick latency (orange = 36 channels)\")\n", + "plt.show()\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 08 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + " 상황 채널 배율 다운 생성 경보 기록 경보 유실률 처리오류 격리 축소모드 호출 감시공백 틱 평균 ms\n", + " 평시 6채널 6 x1 0 54 54 0% 0 0 0 15.4\n", + " x2 12채널 12 x2 0 120 120 0% 0 0 0 35.2\n", + " x4 24채널 24 x4 0 271 271 0% 0 40 0 71.9\n", + " x6 36채널(트래픽 500% 증가) 36 x6 0 521 521 0% 0 160 0 102.7\n", + "x6 + 프레임손실 15% + 전송손실 30% 36 x6 0 422 422 0% 0 135 49 84.1\n", + " x6 + 센서두절 4개 + 처리오류 5% 36 x6 0 444 444 0% 19 126 40 90.1\n", + "\n", + "시스템 다운 0건(목표 0건), 최대 유실률 0%(목표 0%) -> 달성\n", + "기록 경보 유형(최악 상황): {'LIGHT': 256, 'FULL': 129, 'BLIND': 40, 'FAULT': 19}\n", + "\n", + "채널·틱당 경보: 정밀 모드 0.97건, 축소 모드 2.07건(운용자 부담 2.1배), 축소 모드 침입 객체 프레임 탐지율 81.1%\n", + "안전 실패 원칙: 처리 용량을 넘으면 정밀도를 낮춰 경보를 더 내는 쪽(축소 모드)으로, 센서 두절·오류는 '정상'이 아니라 '감시 공백 경보'로 처리한다. 경보는 어느 경우에도 버리지 않고 전송 확인 후 삭제한다.\n", + "```\n", + "
\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 09. AAR 기반 오경보 유발 객체 분석 및 경계구역 탐지 감도 파라미터 재보정\n", + "**소요시간**: 약 3.0시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "AAR 사후검토를 통해 오경보 원인을 분석하고 경계구역 감도 파라미터를 재보정한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **AAR(사후검토)로 오경보 원인을 분석하고 경계구역 탐지 감도 파라미터를 재보정한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **초기 설정(k=3.0, 경보 임계값 0.3)의 모의 운용 결과에서 오경보를 수목·동물·조류와 기상·구역별로 분류하고 미탐 원인을 구분 수행하고 검증하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `docs/sec01_aar_report.md 및 configs/sec01_tuned_params.yaml` 형태의 결과물을 완성한다.\n", + "\n", + "### 🔍 평가 및 검증 기준 (PASS 조건)\n", + "#### 미션 판정\n", + "\n", + "**평가 구분:** 필수\n", + "\n", + "**핵심 평가영역:** 오류 원인 규명의 객관성 및 개선 조치의 논리적 타당성\n", + "\n", + "#### 미션 PASS 조건\n", + "\n", + "- 정량 기준: AAR 피드백 적용 후 종합 정확도 10%p 이상 향상, 재발 오류 0건\n", + "\n", + "\n", + "- 진행 기준: AAR 보고서 작성 및 파라미터 튜닝 전후 회귀 테스트 통과\n", + "\n", + "\n", + "- 검증 방법: 회귀 테스트(Regression Test) 스위트 전체 자동 실행 및 비교\n", + "\n", + "\n", + "#### 채점자 판정 메모\n", + "\n", + "> 단순 코드 실행 여부뿐만 아니라 출력된 수치와 근거 자료가 군 실무 요구사항에 부합하는지 면밀히 확인한다.\n", + "\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "\"\"\"AAR(사후검토) 일지 형식으로 모의 운용의 오경보·미탐을 원인별(수목 흔들림·동물·조류·추적 실패 등)로 분류하고, 탐지 감도 k·최소 지속 프레임·경보 임계값을 제약 조건 안에서 격자 탐색으로 재보정해 개선 전후 성능과 재발 여부를 비교한다.\"\"\"\n", + "import numpy as np\n", + "import pandas as pd\n", + "import matplotlib.pyplot as plt\n", + "from scipy import ndimage\n", + "from sklearn.linear_model import LogisticRegression\n", + "from sklearn.pipeline import make_pipeline\n", + "from sklearn.preprocessing import StandardScaler\n", + "\n", + "# ---------------- [교육용가상자료] 합성 CCTV 영상 생성기 ----------------\n", + "# 실제 영상 대신 코드로 그린 객체(사각형·점)로 기지 외곽 고정 카메라 화면을 흉내 낸다.\n", + "H, W, T = 64, 96, 10 # 화면 크기(px), 시퀀스 길이(프레임)\n", + "HORIZON, FENCE = 20, 46 # 하늘/지면 경계, 울타리 행\n", + "ROI_ROWS = (32, 48) # 울타리 접근 경계구역(ROI): 지면 32~47행\n", + "CLASSES = [\"person\", \"vehicle\", \"drone\", \"animal\", \"bird\"]\n", + "INTRUDER = {\"person\", \"vehicle\", \"drone\"} # 비인가 접근(경보 대상)\n", + "CONDS = [\"day\", \"night\", \"rain\", \"fog\"]\n", + "\n", + "\n", + "def background(rng):\n", + " bg = np.zeros((H, W))\n", + " bg[:HORIZON] = np.linspace(200, 170, HORIZON)[:, None] # 하늘\n", + " bg[HORIZON:] = 95 + rng.normal(0, 3, (H - HORIZON, W)) # 지면 질감\n", + " bg[26:30] = 115 # 외곽 도로\n", + " bg[FENCE] = 60; bg[FENCE - 3:FENCE + 1, ::8] = 60 # 울타리·기둥\n", + " return bg\n", + "\n", + "\n", + "def tree(img, t, wind):\n", + " \"\"\"좌측 수목 수관: 바람 세기에 따라 좌우로 흔들려 오경보를 만든다.\"\"\"\n", + " s = int(round(wind * np.sin(t * 1.3)))\n", + " img[8:30, max(0, 2 + s):14 + s] = 55\n", + "\n", + "\n", + "def make_seq(seed, cond=None, objs=None, wind=None, camo=False):\n", + " rng = np.random.default_rng(seed)\n", + " cond = cond or str(rng.choice(CONDS))\n", + " wind = rng.uniform(0, 2) if wind is None else wind\n", + " bg = background(rng)\n", + " n = int(rng.integers(1, 4)) if objs is None else len(objs)\n", + " kinds = objs or list(rng.choice(CLASSES, n, p=[0.3, 0.15, 0.25, 0.15, 0.15]))\n", + " obs = []\n", + " for k in kinds:\n", + " if k == \"person\":\n", + " o = dict(y=rng.uniform(30, 36), x=rng.uniform(15, 85), vy=rng.uniform(0.8, 1.2), vx=rng.uniform(-0.6, 0.6), h=7, w=3,\n", + " val=72 if camo else rng.uniform(35, 50))\n", + " elif k == \"vehicle\":\n", + " o = dict(y=rng.uniform(33, 38), x=rng.uniform(10, 30), vy=0.2, vx=rng.uniform(2, 4), h=5, w=10, val=rng.uniform(150, 170))\n", + " elif k == \"drone\":\n", + " o = dict(y=rng.uniform(3, 15), x=rng.uniform(10, 80), vy=rng.uniform(0, 0.6), vx=rng.uniform(-2, 2), h=2, w=2 if camo else 3,\n", + " val=rng.uniform(80, 120))\n", + " elif k == \"animal\":\n", + " o = dict(y=rng.uniform(34, 42), x=rng.uniform(15, 80), vy=0, vx=rng.uniform(-1.5, 1.5), h=2, w=4, val=rng.uniform(60, 75))\n", + " else:\n", + " o = dict(y=rng.uniform(3, 15), x=rng.uniform(10, 80), vy=0, vx=rng.uniform(-3, 3), h=1, w=2, val=rng.uniform(100, 130))\n", + " o[\"cls\"] = str(k); obs.append(o)\n", + " frames, labels = [], []\n", + " for t in range(-8, T): # 음수 시각 = 객체 없는 배경 학습 구간(사전 관측)\n", + " f = bg.copy(); tree(f, t, wind); lab = []\n", + " for oi, o in enumerate(obs if t >= 0 else []):\n", + " jit = rng.normal(0, 1.2, 2) if o[\"cls\"] in (\"animal\", \"bird\") else (0, 0) # 불규칙 움직임\n", + " y, x = int(o[\"y\"] + o[\"vy\"] * t + jit[0]), int(o[\"x\"] + o[\"vx\"] * t + jit[1])\n", + " y0, x0, y1, x1 = max(y, 0), max(x, 0), min(y + o[\"h\"], H), min(x + o[\"w\"], W)\n", + " if y1 > y0 and x1 > x0:\n", + " f[y0:y1, x0:x1] = o[\"val\"]\n", + " if o[\"cls\"] == \"person\":\n", + " f[max(y0 - 2, 0):y0, x0 + 1:x0 + 2] = o[\"val\"] # 머리\n", + " lab.append({\"cls\": o[\"cls\"], \"oid\": oi, \"bbox\": (x0, y0, x1, y1), \"intruder\": o[\"cls\"] in INTRUDER})\n", + " if cond == \"night\":\n", + " f = f * 0.5 + 10 + rng.normal(0, 3.5, f.shape)\n", + " else:\n", + " f = f + rng.normal(0, 2.5, f.shape)\n", + " if cond == \"rain\":\n", + " m = rng.random(f.shape) < 0.006\n", + " for d in range(3):\n", + " f[d:][m[:H - d]] += 45\n", + " if cond == \"fog\":\n", + " f = 0.45 * f + 0.55 * 150\n", + " frames.append(np.clip(f, 0, 255)); labels.append(lab)\n", + " fr = np.array(frames)\n", + " return {\"id\": f\"CAM-{seed:04d}\", \"cond\": cond, \"wind\": wind, \"ref\": fr[:8], \"frames\": fr[8:], \"labels\": labels[8:]}\n", + "\n", + "# ---------------- 전처리·탐지 공통 함수(배경 차분 기반) ----------------\n", + "def enhance(seq):\n", + " \"\"\"조도 보정(백분위 신장) + 비 조건의 빗줄기 제거(3x3 중앙값). 배경 학습 구간(ref)과 같은 기준으로 보정한다.\"\"\"\n", + " allf = np.concatenate([seq[\"ref\"], seq[\"frames\"]])\n", + " f = ndimage.median_filter(allf, size=(1, 3, 3)) if seq[\"cond\"] == \"rain\" else allf\n", + " lo, hi = np.percentile(f, [1, 99.5])\n", + " f = np.clip((f - lo) / (hi - lo + 1e-6) * 255, 0, 255)\n", + " return f[:len(seq[\"ref\"])], f[len(seq[\"ref\"]):]\n", + "\n", + "\n", + "def detect(ref, frames, k=4.0, min_area=3, sky_k=None, smooth=False):\n", + " \"\"\"배경 학습 구간의 중앙값 배경과 차이를 강건 임계값(k x MAD)으로 이진화해 연결 성분을 객체 후보로 만든다.\n", + " smooth=True 이면 연속 3프레임 평균으로 시간 잡음을 줄여 저대비 객체를 살린다.\"\"\"\n", + " bg = np.median(ref, axis=0)\n", + " if smooth:\n", + " frames = ndimage.uniform_filter1d(frames, 3, axis=0, mode=\"nearest\")\n", + " out = []\n", + " for f in frames:\n", + " d = np.abs(f - bg)\n", + " mad = np.median(d) * 1.4826 + 1e-6\n", + " thr = np.full(d.shape, k * mad)\n", + " if sky_k is not None:\n", + " thr[:HORIZON] = sky_k * mad\n", + " lab, n = ndimage.label(d > thr)\n", + " dets = []\n", + " for i, sl in enumerate(ndimage.find_objects(lab), start=1):\n", + " area = int((lab[sl] == i).sum())\n", + " if area >= min_area:\n", + " dets.append({\"bbox\": (sl[1].start, sl[0].start, sl[1].stop, sl[0].stop), \"area\": area,\n", + " \"contrast\": float(d[sl][lab[sl] == i].mean() / mad)})\n", + " out.append(dets)\n", + " return out\n", + "\n", + "\n", + "def center(b):\n", + " return ((b[0] + b[2]) / 2, (b[1] + b[3]) / 2)\n", + "\n", + "\n", + "def match(dets, labels, dist=4.0):\n", + " \"\"\"탐지와 정답을 중심 거리로 1:1 대응시킨다. 반환: [(det, label or None)], 놓친 label 목록\"\"\"\n", + " used, pairs = set(), []\n", + " for d in dets:\n", + " cd = center(d[\"bbox\"]); best, bj = dist, None\n", + " for j, l in enumerate(labels):\n", + " cl = center(l[\"bbox\"]); e = np.hypot(cd[0] - cl[0], cd[1] - cl[1])\n", + " if j not in used and e < best:\n", + " best, bj = e, j\n", + " if bj is not None:\n", + " used.add(bj)\n", + " pairs.append((d, labels[bj] if bj is not None else None))\n", + " return pairs, [l for j, l in enumerate(labels) if j not in used]\n", + "\n", + "\n", + "def in_roi(b):\n", + " cy = (b[1] + b[3]) / 2\n", + " return cy < HORIZON or ROI_ROWS[0] <= cy < ROI_ROWS[1]\n", + "\n", + "\n", + "def rule_class(b, area):\n", + " \"\"\"Baseline 규칙: 위치(하늘/지면)·크기·종횡비로 1차 분류(학습 없음, 판단 근거가 그대로 드러난다)\"\"\"\n", + " w, h = b[2] - b[0], b[3] - b[1]\n", + " cy = (b[1] + b[3]) / 2\n", + " if cy < HORIZON:\n", + " return \"drone\" if 3 <= area <= 12 else \"clutter\"\n", + " if area >= 30 and w > h:\n", + " return \"vehicle\"\n", + " if h >= w and 6 <= area <= 30:\n", + " return \"person\"\n", + " return \"clutter\"\n", + "\n", + "# ---------------- 프레임 간 추적과 궤적 특징 ----------------\n", + "def track(dets, gate=6.0):\n", + " \"\"\"최근접 이웃으로 프레임별 탐지를 궤적으로 잇는다.\"\"\"\n", + " tracks = []\n", + " for t, ds in enumerate(dets):\n", + " free = list(range(len(ds)))\n", + " for tr in tracks:\n", + " if tr[\"last\"] != t - 1 or not free:\n", + " continue\n", + " c0 = center(tr[\"b\"][-1])\n", + " dd = [np.hypot(center(ds[i][\"bbox\"])[0] - c0[0], center(ds[i][\"bbox\"])[1] - c0[1]) for i in free]\n", + " j = int(np.argmin(dd))\n", + " if dd[j] < gate:\n", + " i = free.pop(j); tr[\"b\"].append(ds[i][\"bbox\"]); tr[\"a\"].append(ds[i][\"area\"]); tr[\"c\"].append(ds[i][\"contrast\"])\n", + " tr[\"t\"].append(t); tr[\"last\"] = t\n", + " for i in free:\n", + " tracks.append({\"b\": [ds[i][\"bbox\"]], \"a\": [ds[i][\"area\"]], \"c\": [ds[i][\"contrast\"]], \"t\": [t], \"last\": t})\n", + " return tracks\n", + "\n", + "\n", + "TFEATS = [\"n\", \"area\", \"aspect\", \"row\", \"sky\", \"speed\", \"straight\", \"disp\", \"contrast\", \"area_cv\", \"col\"]\n", + "\n", + "\n", + "def track_features(tr):\n", + " c = np.array([center(b) for b in tr[\"b\"]])\n", + " wh = np.array([(b[2] - b[0], b[3] - b[1]) for b in tr[\"b\"]])\n", + " steps = np.hypot(*np.diff(c, axis=0).T) if len(c) > 1 else np.array([0.0])\n", + " disp = float(np.hypot(*(c[-1] - c[0])))\n", + " return {\"n\": len(c), \"area\": float(np.mean(tr[\"a\"])), \"aspect\": float(np.mean(wh[:, 1] / wh[:, 0])), \"row\": float(c[:, 1].mean()),\n", + " \"sky\": float(c[:, 1].mean() < HORIZON), \"speed\": float(steps.mean()), \"straight\": disp / (steps.sum() + 1e-6),\n", + " \"disp\": disp, \"contrast\": float(np.mean(tr[\"c\"])), \"area_cv\": float(np.std(tr[\"a\"]) / (np.mean(tr[\"a\"]) + 1e-6)),\n", + " \"col\": float(c[:, 0].mean())}\n", + "\n", + "\n", + "def track_truth(tr, labels):\n", + " \"\"\"궤적의 각 프레임을 정답과 대응시켜 다수결로 정체를 정한다(없으면 clutter).\"\"\"\n", + " votes = []\n", + " for b, t in zip(tr[\"b\"], tr[\"t\"]):\n", + " pairs, _ = match([{\"bbox\": b}], labels[t])\n", + " votes.append((pairs[0][1][\"cls\"], pairs[0][1][\"oid\"]) if pairs[0][1] else (\"clutter\", -1))\n", + " return max(set(votes), key=votes.count)\n", + "\n", + "\n", + "def seq_tracks(seq, k=4.0, min_area=3, sky_k=None, smooth=False):\n", + " ref, fr = enhance(seq)\n", + " tracks = track(detect(ref, fr, k, min_area, sky_k, smooth))\n", + " rows = []\n", + " for tr in tracks:\n", + " f = track_features(tr); f[\"truth\"], f[\"oid\"] = track_truth(tr, seq[\"labels\"]); f[\"seq\"] = seq[\"id\"]; f[\"cond\"] = seq[\"cond\"]\n", + " f[\"tr\"] = tr; rows.append(f)\n", + " return rows\n", + "\n", + "TRAIN_SEEDS, OPS_SEEDS = range(0, 80), range(3000, 3060)\n", + "\n", + "\n", + "def tracks_of(seeds, k, wind=None):\n", + " out = []\n", + " for s in seeds:\n", + " q = make_seq(s, wind=wind)\n", + " for r in seq_tracks(q, k=k, min_area=2, sky_k=k - 0.5):\n", + " if in_roi(r[\"tr\"][\"b\"][-1]):\n", + " out.append(r)\n", + " return out\n", + "\n", + "\n", + "def X_of(rows):\n", + " return np.array([[r[k] for k in TFEATS] for r in rows])\n", + "\n", + "\n", + "_CACHE = {}\n", + "\n", + "\n", + "def run_system(k):\n", + " if k not in _CACHE:\n", + " _CACHE[k] = _run_system(k)\n", + " return _CACHE[k]\n", + "\n", + "\n", + "def _run_system(k):\n", + " \"\"\"탐지 감도 k로 학습·운용 데이터를 처리하고, 운용 궤적마다 침입 확률(prob)을 붙인다.\"\"\"\n", + " tr, te = tracks_of(TRAIN_SEEDS, k), tracks_of(OPS_SEEDS, k)\n", + " m = make_pipeline(StandardScaler(), LogisticRegression(class_weight=\"balanced\", C=2.0, max_iter=2000, random_state=0))\n", + " m.fit(X_of(tr), [r[\"truth\"] in INTRUDER for r in tr])\n", + " for r, p in zip(te, m.predict_proba(X_of(te))[:, 1]):\n", + " r[\"prob\"] = float(p)\n", + " return te\n", + "\n", + "\n", + "def objects():\n", + " return {(q[\"id\"], l[\"oid\"]) for q in (make_seq(s) for s in OPS_SEEDS) for labs in q[\"labels\"] for l in labs if l[\"intruder\"]}\n", + "\n", + "\n", + "OBJS = objects()\n", + "\n", + "\n", + "def set_alerts(te, thr, min_n):\n", + " for r in te:\n", + " r[\"alert\"] = r[\"n\"] >= min_n and r[\"prob\"] >= thr\n", + " return te\n", + "\n", + "\n", + "def summarize(te):\n", + " al = [r for r in te if r[\"alert\"]]\n", + " far = sum(r[\"truth\"] not in INTRUDER for r in al) / max(len(al), 1)\n", + " found = {(r[\"seq\"], r[\"oid\"]) for r in al if r[\"truth\"] in INTRUDER}\n", + " rec = len(found & OBJS) / len(OBJS)\n", + " return {\"경보\": len(al), \"오경보율\": round(far, 3), \"재현율\": round(rec, 3), \"종합정확도(F1)\": round(2 * (1 - far) * rec / max(1 - far + rec, 1e-9), 3)}\n", + "\n", + "\n", + "def aar_log(te):\n", + " rows = []\n", + " for r in te:\n", + " if r[\"alert\"] and r[\"truth\"] not in INTRUDER:\n", + " where = \"수목 구역(x<16)\" if r[\"col\"] < 16 else \"그 외\"\n", + " rows.append({\"구분\": \"오경보\", \"원인\": {\"clutter\": \"수목·잡음\", \"animal\": \"야생동물\", \"bird\": \"조류\"}[r[\"truth\"]], \"조건\": r[\"cond\"], \"구역\": where, \"시퀀스\": r[\"seq\"]})\n", + " alerted = {(r[\"seq\"], r[\"oid\"]) for r in te if r[\"alert\"] and r[\"truth\"] in INTRUDER}\n", + " tracked = {(r[\"seq\"], r[\"oid\"]) for r in te if r[\"truth\"] in INTRUDER}\n", + " for o in sorted(OBJS):\n", + " if o not in alerted:\n", + " rows.append({\"구분\": \"미탐\", \"원인\": \"점수 미달·지속 부족\" if o in tracked else \"추적 실패(탐지 누락)\", \"조건\": \"-\", \"구역\": \"-\", \"시퀀스\": o[0]})\n", + " return pd.DataFrame(rows, columns=[\"구분\", \"원인\", \"조건\", \"구역\", \"시퀀스\"])\n", + "\n", + "\n", + "# 1) 초기 설정으로 모의 운용: '놓치지 않기' 우선(낮은 임계값, 짧은 지속 조건)\n", + "INIT = {\"k\": 3.0, \"alert_thr\": 0.3, \"min_frames\": 2}\n", + "te0 = set_alerts(run_system(INIT[\"k\"]), INIT[\"alert_thr\"], INIT[\"min_frames\"])\n", + "before = summarize(te0)\n", + "log = aar_log(te0)\n", + "print(\"[1. 무엇이 일어났나] 초기 설정\", INIT, \"->\", before)\n", + "print(log.groupby([\"구분\", \"원인\"]).size().rename(\"건수\").to_string())\n", + "print(\"\\n[2. 왜 일어났나] 오경보 조건 분포:\", log[log[\"구분\"] == \"오경보\"][\"조건\"].value_counts().to_dict(), \"/ 구역:\", log[log[\"구분\"] == \"오경보\"][\"구역\"].value_counts().to_dict())\n", + "\n", + "# 2) 재보정: 제약(오경보율 15% 이하, 재현율 85% 이상) 안에서 F1 최대 조합 탐색\n", + "grid = []\n", + "for k in (2.5, 3.0, 3.5):\n", + " te = run_system(k)\n", + " for thr in (0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8):\n", + " for mn in (2, 3, 4):\n", + " grid.append({\"k\": k, \"경보임계값\": thr, \"최소프레임\": mn, **summarize(set_alerts(te, thr, mn))})\n", + "g = pd.DataFrame(grid)\n", + "seen = set(map(tuple, log[log[\"구분\"] == \"오경보\"][[\"시퀀스\", \"원인\"]].values))\n", + "\n", + "\n", + "def repeats(row):\n", + " \"\"\"AAR에서 식별한 오경보(시퀀스·원인)가 이 설정에서 다시 나오는 건수\"\"\"\n", + " t = set_alerts(run_system(row[\"k\"]), row[\"경보임계값\"], int(row[\"최소프레임\"]))\n", + " return len(seen & set(map(tuple, aar_log(t)[lambda d: d[\"구분\"] == \"오경보\"][[\"시퀀스\", \"원인\"]].values)))\n", + "\n", + "\n", + "ok = g[(g[\"오경보율\"] <= 0.15) & (g[\"재현율\"] >= 0.85)].copy()\n", + "ok[\"재발\"] = ok.apply(repeats, axis=1)\n", + "ok = ok.sort_values([\"재발\", \"종합정확도(F1)\", \"재현율\"], ascending=[True, False, False]) # 재발 최소 -> F1 최대 순\n", + "best = ok.iloc[0]\n", + "print(\"\\n[3. 무엇을 바꾸나] 제약 만족 조합 상위 5개(재발 적은 순) (전체 조합 %d개 중 %d개 만족)\" % (len(g), len(ok)))\n", + "print(ok.head().to_string(index=False))\n", + "te1 = set_alerts(run_system(best[\"k\"]), best[\"경보임계값\"], int(best[\"최소프레임\"]))\n", + "after = summarize(te1)\n", + "log1 = aar_log(te1)\n", + "gain = (after[\"종합정확도(F1)\"] - before[\"종합정확도(F1)\"]) * 100\n", + "print(f\"\\n재보정 후: {after}\")\n", + "print(f\"종합정확도 변화 {before['종합정확도(F1)']:.3f} -> {after['종합정확도(F1)']:.3f} ({gain:+.1f}%p, 목표 +10%p 이상: {'달성' if gain >= 10 else '미달'})\")\n", + "rep = log[log[\"구분\"] == \"오경보\"].groupby([\"시퀀스\", \"원인\"]).size().index.intersection(log1[log1[\"구분\"] == \"오경보\"].groupby([\"시퀀스\", \"원인\"]).size().index)\n", + "print(f\"AAR에서 식별한 오경보 {log['구분'].eq('오경보').sum()}건 -> 재보정 후 {log1['구분'].eq('오경보').sum()}건, 같은 시퀀스·같은 원인으로 재발한 오류 {len(rep)}건 (목표 0건: {'달성' if len(rep) == 0 else '미달'})\")\n", + "\n", + "# 3) 재보정 파라미터(yaml 내용 문자열)\n", + "tuned = f\"detection_k: {best['k']}\\nalert_threshold: {best['경보임계값']}\\nmin_track_frames: {int(best['최소프레임'])}\\nconstraints: {{max_far: 0.15, min_recall: 0.85}}\"\n", + "print(\"\\n[재보정 파라미터(sec01_tuned_params.yaml 내용)]\\n\" + tuned)\n", + "print(\"개선 조치: 탐지 감도 k를 올리고 최소 지속 프레임 조건을 강화해 수목·동물·조류 오경보를 줄이고, 재보정값은 현장 영상으로 재검증 후 적용한다.\")\n", + "\n", + "plt.figure(figsize=(6, 4))\n", + "for k in (2.5, 3.0, 3.5):\n", + " s = g[g[\"k\"] == k]\n", + " plt.plot(s[\"오경보율\"], s[\"재현율\"], \"o\", alpha=0.6, label=f\"k={k}\")\n", + "plt.axvline(0.15, ls=\"--\", c=\"gray\"); plt.axhline(0.85, ls=\"--\", c=\"gray\")\n", + "plt.xlabel(\"false alarm rate\"); plt.ylabel(\"intruder recall\"); plt.legend(); plt.title(\"AAR re-tuning grid (synthetic)\")\n", + "plt.show()\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 09 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + "[1. 무엇이 일어났나] 초기 설정 {'k': 3.0, 'alert_thr': 0.3, 'min_frames': 2} -> {'경보': 135, '오경보율': 0.43, '재현율': 0.963, '종합정확도(F1)': 0.716}\n", + "구분 원인 \n", + "미탐 점수 미달·지속 부족 2\n", + " 추적 실패(탐지 누락) 1\n", + "오경보 수목·잡음 31\n", + " 야생동물 13\n", + " 조류 14\n", + "\n", + "[2. 왜 일어났나] 오경보 조건 분포: {'rain': 37, 'fog': 10, 'night': 8, 'day': 3} / 구역: {'그 외': 43, '수목 구역(x<16)': 15}\n", + "\n", + "[3. 무엇을 바꾸나] 제약 만족 조합 상위 5개(재발 적은 순) (전체 조합 63개 중 9개 만족)\n", + " k 경보임계값 최소프레임 경보 오경보율 재현율 종합정확도(F1) 재발\n", + "3.5 0.6 4 84 0.083 0.963 0.939 7\n", + "3.5 0.7 4 83 0.084 0.950 0.933 7\n", + "3.5 0.8 4 82 0.085 0.938 0.926 7\n", + "3.5 0.7 3 84 0.095 0.950 0.927 8\n", + "3.5 0.8 3 83 0.096 0.938 0.920 8\n", + "\n", + "재보정 후: {'경보': 84, '오경보율': 0.083, '재현율': 0.963, '종합정확도(F1)': 0.939}\n", + "종합정확도 변화 0.716 -> 0.939 (+22.3%p, 목표 +10%p 이상: 달성)\n", + "AAR에서 식별한 오경보 58건 -> 재보정 후 7건, 같은 시퀀스·같은 원인으로 재발한 오류 7건 (목표 0건: 미달)\n", + "\n", + "[재보정 파라미터(sec01_tuned_params.yaml 내용)]\n", + "detection_k: 3.5\n", + "alert_threshold: 0.6\n", + "min_track_frames: 4\n", + "constraints: {max_far: 0.15, min_recall: 0.85}\n", + "개선 조치: 탐지 감도 k를 올리고 최소 지속 프레임 조건을 강화해 수목·동물·조류 오경보를 줄이고, 재보정값은 현장 영상으로 재검증 후 적용한다.\n", + "```\n", + "
\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 10. 기지 CCTV 비인가 접근 탐지 시스템 통합 및 시설 방호 브리핑 패키지 완성\n", + "**소요시간**: 약 3.5시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "기지 CCTV 비인가 접근 탐지 시스템을 최종 통합하고 기지 방호 브리핑 패키지를 완성한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **데이터 검증부터 전처리, 탐지, 신뢰도, HITL, 상황판, 재보정값 적용까지 전체 파이프라인을 통합하고 시설 방호 브리핑 패키지를 완성한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **재보정 파라미터로 전체 과정을 한 번에 실행하는 통합 코드를 완성하고 손상 데이터와 센서 단절 프레임이 자동 처리되는지 확인하고 유효성을 점검하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `src/main_sec01.py, README.md, docs/sec01_final_report.pdf(md)` 형태의 결과물을 완성한다.\n", + "\n", + "### 🔍 평가 및 검증 기준 (PASS 조건)\n", + "#### 미션 판정\n", + "\n", + "**평가 구분:** 필수\n", + "\n", + "**핵심 평가영역:** 군 실무 즉시 도입 가능성, 운용 신뢰도 및 전략적 기대효과\n", + "\n", + "#### 미션 PASS 조건\n", + "\n", + "- 정량 기준: End-to-End 통합 파이프라인 실행 성공률 100%, 테스트 커버리지 85% 이상\n", + "\n", + "\n", + "- 진행 기준: 원클릭 실행 스크립트 동작 확인 및 최종 발표 산출물 작성 완료\n", + "\n", + "\n", + "- 검증 방법: 최종 검증 위원회(평가관) 모의 시연 및 전체 기능 통합 검증\n", + "\n", + "\n", + "#### 채점자 판정 메모\n", + "\n", + "> 단순 코드 실행 여부뿐만 아니라 출력된 수치와 근거 자료가 군 실무 요구사항에 부합하는지 면밀히 확인한다.\n", + "\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "\"\"\"데이터 검증, 센서 단절 보정·전처리, 객체 탐지·추적, 신뢰도 점수, HITL 검토 대기열, 관제 상황판, AAR 재보정값 적용까지 비인가 접근 탐지 전체 파이프라인을 한 파일로 통합 실행하고, 자체 점검(실행 라인 커버리지 포함)과 시설 방호 브리핑 요약을 출력한다.\"\"\"\n", + "import dis\n", + "import hashlib\n", + "import json\n", + "import sys\n", + "import time\n", + "import numpy as np\n", + "import pandas as pd\n", + "import matplotlib.pyplot as plt\n", + "from scipy import ndimage\n", + "from sklearn.linear_model import LogisticRegression\n", + "from sklearn.pipeline import make_pipeline\n", + "from sklearn.preprocessing import StandardScaler\n", + "\n", + "# ---------------- [교육용가상자료] 합성 CCTV 영상 생성기 ----------------\n", + "# 실제 영상 대신 코드로 그린 객체(사각형·점)로 기지 외곽 고정 카메라 화면을 흉내 낸다.\n", + "H, W, T = 64, 96, 10 # 화면 크기(px), 시퀀스 길이(프레임)\n", + "HORIZON, FENCE = 20, 46 # 하늘/지면 경계, 울타리 행\n", + "ROI_ROWS = (32, 48) # 울타리 접근 경계구역(ROI): 지면 32~47행\n", + "CLASSES = [\"person\", \"vehicle\", \"drone\", \"animal\", \"bird\"]\n", + "INTRUDER = {\"person\", \"vehicle\", \"drone\"} # 비인가 접근(경보 대상)\n", + "CONDS = [\"day\", \"night\", \"rain\", \"fog\"]\n", + "\n", + "\n", + "def background(rng):\n", + " bg = np.zeros((H, W))\n", + " bg[:HORIZON] = np.linspace(200, 170, HORIZON)[:, None] # 하늘\n", + " bg[HORIZON:] = 95 + rng.normal(0, 3, (H - HORIZON, W)) # 지면 질감\n", + " bg[26:30] = 115 # 외곽 도로\n", + " bg[FENCE] = 60; bg[FENCE - 3:FENCE + 1, ::8] = 60 # 울타리·기둥\n", + " return bg\n", + "\n", + "\n", + "def tree(img, t, wind):\n", + " \"\"\"좌측 수목 수관: 바람 세기에 따라 좌우로 흔들려 오경보를 만든다.\"\"\"\n", + " s = int(round(wind * np.sin(t * 1.3)))\n", + " img[8:30, max(0, 2 + s):14 + s] = 55\n", + "\n", + "\n", + "def make_seq(seed, cond=None, objs=None, wind=None, camo=False):\n", + " rng = np.random.default_rng(seed)\n", + " cond = cond or str(rng.choice(CONDS))\n", + " wind = rng.uniform(0, 2) if wind is None else wind\n", + " bg = background(rng)\n", + " n = int(rng.integers(1, 4)) if objs is None else len(objs)\n", + " kinds = objs or list(rng.choice(CLASSES, n, p=[0.3, 0.15, 0.25, 0.15, 0.15]))\n", + " obs = []\n", + " for k in kinds:\n", + " if k == \"person\":\n", + " o = dict(y=rng.uniform(30, 36), x=rng.uniform(15, 85), vy=rng.uniform(0.8, 1.2), vx=rng.uniform(-0.6, 0.6), h=7, w=3,\n", + " val=72 if camo else rng.uniform(35, 50))\n", + " elif k == \"vehicle\":\n", + " o = dict(y=rng.uniform(33, 38), x=rng.uniform(10, 30), vy=0.2, vx=rng.uniform(2, 4), h=5, w=10, val=rng.uniform(150, 170))\n", + " elif k == \"drone\":\n", + " o = dict(y=rng.uniform(3, 15), x=rng.uniform(10, 80), vy=rng.uniform(0, 0.6), vx=rng.uniform(-2, 2), h=2, w=2 if camo else 3,\n", + " val=rng.uniform(80, 120))\n", + " elif k == \"animal\":\n", + " o = dict(y=rng.uniform(34, 42), x=rng.uniform(15, 80), vy=0, vx=rng.uniform(-1.5, 1.5), h=2, w=4, val=rng.uniform(60, 75))\n", + " else:\n", + " o = dict(y=rng.uniform(3, 15), x=rng.uniform(10, 80), vy=0, vx=rng.uniform(-3, 3), h=1, w=2, val=rng.uniform(100, 130))\n", + " o[\"cls\"] = str(k); obs.append(o)\n", + " frames, labels = [], []\n", + " for t in range(-8, T): # 음수 시각 = 객체 없는 배경 학습 구간(사전 관측)\n", + " f = bg.copy(); tree(f, t, wind); lab = []\n", + " for oi, o in enumerate(obs if t >= 0 else []):\n", + " jit = rng.normal(0, 1.2, 2) if o[\"cls\"] in (\"animal\", \"bird\") else (0, 0) # 불규칙 움직임\n", + " y, x = int(o[\"y\"] + o[\"vy\"] * t + jit[0]), int(o[\"x\"] + o[\"vx\"] * t + jit[1])\n", + " y0, x0, y1, x1 = max(y, 0), max(x, 0), min(y + o[\"h\"], H), min(x + o[\"w\"], W)\n", + " if y1 > y0 and x1 > x0:\n", + " f[y0:y1, x0:x1] = o[\"val\"]\n", + " if o[\"cls\"] == \"person\":\n", + " f[max(y0 - 2, 0):y0, x0 + 1:x0 + 2] = o[\"val\"] # 머리\n", + " lab.append({\"cls\": o[\"cls\"], \"oid\": oi, \"bbox\": (x0, y0, x1, y1), \"intruder\": o[\"cls\"] in INTRUDER})\n", + " if cond == \"night\":\n", + " f = f * 0.5 + 10 + rng.normal(0, 3.5, f.shape)\n", + " else:\n", + " f = f + rng.normal(0, 2.5, f.shape)\n", + " if cond == \"rain\":\n", + " m = rng.random(f.shape) < 0.006\n", + " for d in range(3):\n", + " f[d:][m[:H - d]] += 45\n", + " if cond == \"fog\":\n", + " f = 0.45 * f + 0.55 * 150\n", + " frames.append(np.clip(f, 0, 255)); labels.append(lab)\n", + " fr = np.array(frames)\n", + " return {\"id\": f\"CAM-{seed:04d}\", \"cond\": cond, \"wind\": wind, \"ref\": fr[:8], \"frames\": fr[8:], \"labels\": labels[8:]}\n", + "\n", + "# ---------------- 전처리·탐지 공통 함수(배경 차분 기반) ----------------\n", + "def enhance(seq):\n", + " \"\"\"조도 보정(백분위 신장) + 비 조건의 빗줄기 제거(3x3 중앙값). 배경 학습 구간(ref)과 같은 기준으로 보정한다.\"\"\"\n", + " allf = np.concatenate([seq[\"ref\"], seq[\"frames\"]])\n", + " f = ndimage.median_filter(allf, size=(1, 3, 3)) if seq[\"cond\"] == \"rain\" else allf\n", + " lo, hi = np.percentile(f, [1, 99.5])\n", + " f = np.clip((f - lo) / (hi - lo + 1e-6) * 255, 0, 255)\n", + " return f[:len(seq[\"ref\"])], f[len(seq[\"ref\"]):]\n", + "\n", + "\n", + "def detect(ref, frames, k=4.0, min_area=3, sky_k=None, smooth=False):\n", + " \"\"\"배경 학습 구간의 중앙값 배경과 차이를 강건 임계값(k x MAD)으로 이진화해 연결 성분을 객체 후보로 만든다.\n", + " smooth=True 이면 연속 3프레임 평균으로 시간 잡음을 줄여 저대비 객체를 살린다.\"\"\"\n", + " bg = np.median(ref, axis=0)\n", + " if smooth:\n", + " frames = ndimage.uniform_filter1d(frames, 3, axis=0, mode=\"nearest\")\n", + " out = []\n", + " for f in frames:\n", + " d = np.abs(f - bg)\n", + " mad = np.median(d) * 1.4826 + 1e-6\n", + " thr = np.full(d.shape, k * mad)\n", + " if sky_k is not None:\n", + " thr[:HORIZON] = sky_k * mad\n", + " lab, n = ndimage.label(d > thr)\n", + " dets = []\n", + " for i, sl in enumerate(ndimage.find_objects(lab), start=1):\n", + " area = int((lab[sl] == i).sum())\n", + " if area >= min_area:\n", + " dets.append({\"bbox\": (sl[1].start, sl[0].start, sl[1].stop, sl[0].stop), \"area\": area,\n", + " \"contrast\": float(d[sl][lab[sl] == i].mean() / mad)})\n", + " out.append(dets)\n", + " return out\n", + "\n", + "\n", + "def center(b):\n", + " return ((b[0] + b[2]) / 2, (b[1] + b[3]) / 2)\n", + "\n", + "\n", + "def match(dets, labels, dist=4.0):\n", + " \"\"\"탐지와 정답을 중심 거리로 1:1 대응시킨다. 반환: [(det, label or None)], 놓친 label 목록\"\"\"\n", + " used, pairs = set(), []\n", + " for d in dets:\n", + " cd = center(d[\"bbox\"]); best, bj = dist, None\n", + " for j, l in enumerate(labels):\n", + " cl = center(l[\"bbox\"]); e = np.hypot(cd[0] - cl[0], cd[1] - cl[1])\n", + " if j not in used and e < best:\n", + " best, bj = e, j\n", + " if bj is not None:\n", + " used.add(bj)\n", + " pairs.append((d, labels[bj] if bj is not None else None))\n", + " return pairs, [l for j, l in enumerate(labels) if j not in used]\n", + "\n", + "\n", + "def in_roi(b):\n", + " cy = (b[1] + b[3]) / 2\n", + " return cy < HORIZON or ROI_ROWS[0] <= cy < ROI_ROWS[1]\n", + "\n", + "\n", + "def rule_class(b, area):\n", + " \"\"\"Baseline 규칙: 위치(하늘/지면)·크기·종횡비로 1차 분류(학습 없음, 판단 근거가 그대로 드러난다)\"\"\"\n", + " w, h = b[2] - b[0], b[3] - b[1]\n", + " cy = (b[1] + b[3]) / 2\n", + " if cy < HORIZON:\n", + " return \"drone\" if 3 <= area <= 12 else \"clutter\"\n", + " if area >= 30 and w > h:\n", + " return \"vehicle\"\n", + " if h >= w and 6 <= area <= 30:\n", + " return \"person\"\n", + " return \"clutter\"\n", + "\n", + "# ---------------- 프레임 간 추적과 궤적 특징 ----------------\n", + "def track(dets, gate=6.0):\n", + " \"\"\"최근접 이웃으로 프레임별 탐지를 궤적으로 잇는다.\"\"\"\n", + " tracks = []\n", + " for t, ds in enumerate(dets):\n", + " free = list(range(len(ds)))\n", + " for tr in tracks:\n", + " if tr[\"last\"] != t - 1 or not free:\n", + " continue\n", + " c0 = center(tr[\"b\"][-1])\n", + " dd = [np.hypot(center(ds[i][\"bbox\"])[0] - c0[0], center(ds[i][\"bbox\"])[1] - c0[1]) for i in free]\n", + " j = int(np.argmin(dd))\n", + " if dd[j] < gate:\n", + " i = free.pop(j); tr[\"b\"].append(ds[i][\"bbox\"]); tr[\"a\"].append(ds[i][\"area\"]); tr[\"c\"].append(ds[i][\"contrast\"])\n", + " tr[\"t\"].append(t); tr[\"last\"] = t\n", + " for i in free:\n", + " tracks.append({\"b\": [ds[i][\"bbox\"]], \"a\": [ds[i][\"area\"]], \"c\": [ds[i][\"contrast\"]], \"t\": [t], \"last\": t})\n", + " return tracks\n", + "\n", + "\n", + "TFEATS = [\"n\", \"area\", \"aspect\", \"row\", \"sky\", \"speed\", \"straight\", \"disp\", \"contrast\", \"area_cv\", \"col\"]\n", + "\n", + "\n", + "def track_features(tr):\n", + " c = np.array([center(b) for b in tr[\"b\"]])\n", + " wh = np.array([(b[2] - b[0], b[3] - b[1]) for b in tr[\"b\"]])\n", + " steps = np.hypot(*np.diff(c, axis=0).T) if len(c) > 1 else np.array([0.0])\n", + " disp = float(np.hypot(*(c[-1] - c[0])))\n", + " return {\"n\": len(c), \"area\": float(np.mean(tr[\"a\"])), \"aspect\": float(np.mean(wh[:, 1] / wh[:, 0])), \"row\": float(c[:, 1].mean()),\n", + " \"sky\": float(c[:, 1].mean() < HORIZON), \"speed\": float(steps.mean()), \"straight\": disp / (steps.sum() + 1e-6),\n", + " \"disp\": disp, \"contrast\": float(np.mean(tr[\"c\"])), \"area_cv\": float(np.std(tr[\"a\"]) / (np.mean(tr[\"a\"]) + 1e-6)),\n", + " \"col\": float(c[:, 0].mean())}\n", + "\n", + "\n", + "def track_truth(tr, labels):\n", + " \"\"\"궤적의 각 프레임을 정답과 대응시켜 다수결로 정체를 정한다(없으면 clutter).\"\"\"\n", + " votes = []\n", + " for b, t in zip(tr[\"b\"], tr[\"t\"]):\n", + " pairs, _ = match([{\"bbox\": b}], labels[t])\n", + " votes.append((pairs[0][1][\"cls\"], pairs[0][1][\"oid\"]) if pairs[0][1] else (\"clutter\", -1))\n", + " return max(set(votes), key=votes.count)\n", + "\n", + "\n", + "def seq_tracks(seq, k=4.0, min_area=3, sky_k=None, smooth=False):\n", + " ref, fr = enhance(seq)\n", + " tracks = track(detect(ref, fr, k, min_area, sky_k, smooth))\n", + " rows = []\n", + " for tr in tracks:\n", + " f = track_features(tr); f[\"truth\"], f[\"oid\"] = track_truth(tr, seq[\"labels\"]); f[\"seq\"] = seq[\"id\"]; f[\"cond\"] = seq[\"cond\"]\n", + " f[\"tr\"] = tr; rows.append(f)\n", + " return rows\n", + "\n", + "CONFIG = {\"detection_k\": 3.5, \"alert_threshold\": 0.6, \"min_track_frames\": 4} # 미션 9 재보정값\n", + "\n", + "\n", + "def validate(seq):\n", + " errs = []\n", + " if seq[\"cond\"] not in CONDS:\n", + " errs.append(\"condition 허용값 아님\")\n", + " if seq[\"frames\"].ndim != 3 or seq[\"frames\"].shape[1:] != (H, W):\n", + " errs.append(\"해상도 오류\")\n", + " if not str(seq[\"id\"]).startswith(\"CAM-\"):\n", + " errs.append(\"camera_id 형식 오류\")\n", + " return errs\n", + "\n", + "\n", + "def repair(seq):\n", + " \"\"\"센서 단절(전 화면 0) 프레임을 직전 프레임으로 대체하고 보정 건수를 돌려준다.\"\"\"\n", + " fr = seq[\"frames\"].copy(); fixed = 0\n", + " for t in range(len(fr)):\n", + " if fr[t].max() < 1:\n", + " fr[t] = fr[t - 1] if t > 0 else seq[\"ref\"][-1]; fixed += 1\n", + " return {**seq, \"frames\": fr}, fixed\n", + "\n", + "\n", + "def X_of(rows):\n", + " return np.array([[r[k] for k in TFEATS] for r in rows])\n", + "\n", + "\n", + "def tracks_of(seq):\n", + " k = CONFIG[\"detection_k\"]\n", + " return [r for r in seq_tracks(seq, k=k, min_area=2, sky_k=k - 0.5) if in_roi(r[\"tr\"][\"b\"][-1])]\n", + "\n", + "\n", + "class Queue:\n", + " def __init__(self):\n", + " self.items, self.log = [], []\n", + "\n", + " def submit(self, aid, score, grade):\n", + " prev = self.log[-1][\"hash\"] if self.log else \"GENESIS\"\n", + " body = {\"event\": \"SUBMIT\", \"id\": aid, \"score\": round(score, 3), \"by\": \"AI\"}\n", + " self.log.append({**body, \"prev\": prev, \"hash\": hashlib.sha256((prev + json.dumps(body, sort_keys=True)).encode()).hexdigest()})\n", + " self.items.append({\"id\": aid, \"grade\": grade, \"status\": \"PENDING\"})\n", + "\n", + " def verify(self):\n", + " prev = \"GENESIS\"\n", + " for rec in self.log:\n", + " body = {k: v for k, v in rec.items() if k not in (\"prev\", \"hash\")}\n", + " if rec[\"prev\"] != prev or rec[\"hash\"] != hashlib.sha256((prev + json.dumps(body, sort_keys=True)).encode()).hexdigest():\n", + " return False\n", + " prev = rec[\"hash\"]\n", + " return True\n", + "\n", + "\n", + "def grade_of(p):\n", + " return \"High\" if p >= 0.8 else (\"Medium\" if p >= CONFIG[\"alert_threshold\"] else \"Low\")\n", + "\n", + "\n", + "def process(seq, engine, queue):\n", + " \"\"\"시퀀스 1개: 단절 보정 -> 탐지·추적 -> 신뢰도 -> 경보 대기열 등록.\"\"\"\n", + " seq, fixed = repair(seq)\n", + " trs = tracks_of(seq)\n", + " if trs:\n", + " for r, p in zip(trs, engine.predict_proba(X_of(trs))[:, 1]):\n", + " r[\"score\"], r[\"grade\"] = float(p), grade_of(float(p))\n", + " r[\"alert\"] = r[\"n\"] >= CONFIG[\"min_track_frames\"] and r[\"score\"] >= CONFIG[\"alert_threshold\"]\n", + " if r[\"alert\"]:\n", + " queue.submit(f\"{seq['id']}-{len(queue.items):03d}\", r[\"score\"], r[\"grade\"])\n", + " return trs, fixed\n", + "\n", + "\n", + "t_start = time.perf_counter()\n", + "# ---------------- 1. 학습(엔진 준비) ----------------\n", + "train = [r for s in range(0, 160) for r in tracks_of(make_seq(s))]\n", + "engine = make_pipeline(StandardScaler(), LogisticRegression(class_weight=\"balanced\", C=2.0, max_iter=2000, random_state=0))\n", + "engine.fit(X_of(train), [r[\"truth\"] in INTRUDER for r in train])\n", + "\n", + "# ---------------- 2. 운용 데이터 처리(손상 데이터 2건 주입) ----------------\n", + "ops = [make_seq(5000 + i) for i in range(30)]\n", + "ops[3] = {**ops[3], \"cond\": \"snow\"} # 허용되지 않은 조건\n", + "ops[7] = {**ops[7], \"frames\": np.where((np.arange(T) % 5 == 2)[:, None, None], 0.0, ops[7][\"frames\"])} # 센서 단절 프레임\n", + "queue, rejected, all_tracks, runs, fixed_total = Queue(), {}, [], [], 0\n", + "trace_lines = set()\n", + "codes = {f.__code__ for f in (validate, repair, tracks_of, grade_of, process, X_of, Queue.submit, Queue.verify,\n", + " enhance, detect, match, in_roi, rule_class, track, track_features, track_truth, seq_tracks, center)}\n", + "lines_total = {c: {ln for _, ln in dis.findlinestarts(c) if ln != c.co_firstlineno} for c in codes}\n", + "\n", + "\n", + "def tracer(frame, event, arg):\n", + " if frame.f_code in codes:\n", + " def local(fr, ev, ar):\n", + " if ev == \"line\":\n", + " trace_lines.add((fr.f_code, fr.f_lineno))\n", + " return local\n", + " return local\n", + "\n", + "\n", + "sys.settrace(tracer)\n", + "for s in ops:\n", + " errs = validate(s)\n", + " if errs:\n", + " rejected[s[\"id\"]] = errs; continue\n", + " t0 = time.perf_counter()\n", + " trs, fx = process(s, engine, queue)\n", + " fixed_total += fx\n", + " runs.append({\"seq\": s[\"id\"], \"ms_per_frame\": (time.perf_counter() - t0) * 1000 / T, \"alerts\": sum(r[\"alert\"] for r in trs)})\n", + " all_tracks += trs\n", + "queue.verify(); grade_of(0.9); X_of(all_tracks[:1])\n", + "sys.settrace(None)\n", + "covered = sum(len({ln for c2, ln in trace_lines if c2 is c} & lines_total[c]) for c in codes)\n", + "total_lines = sum(len(v) for v in lines_total.values())\n", + "\n", + "# ---------------- 3. 성능 평가 ----------------\n", + "done_ids = {s[\"id\"] for s in ops} - set(rejected)\n", + "objs = {(q[\"id\"], l[\"oid\"]) for q in ops if q[\"id\"] in done_ids for labs in q[\"labels\"] for l in labs if l[\"intruder\"]}\n", + "alerts = [r for r in all_tracks if r[\"alert\"]]\n", + "far = sum(r[\"truth\"] not in INTRUDER for r in alerts) / max(len(alerts), 1)\n", + "recall = len({(r[\"seq\"], r[\"oid\"]) for r in alerts if r[\"truth\"] in INTRUDER} & objs) / max(len(objs), 1)\n", + "ms = np.mean([r[\"ms_per_frame\"] for r in runs])\n", + "\n", + "# ---------------- 4. 자체 점검(통합 테스트) ----------------\n", + "cov = covered / total_lines\n", + "chk = {\n", + " \"손상 데이터 차단\": \"CAM-5003\" in rejected,\n", + " \"처리 시퀀스 29개 전량 성공\": len(runs) == 29,\n", + " \"센서 단절 프레임 보정\": fixed_total >= 1,\n", + " \"침입 객체 재현율 85% 이상\": recall >= 0.85,\n", + " \"오경보율 15% 이하\": far <= 0.15,\n", + " \"자동 해제·출동 0건\": all(i[\"status\"] == \"PENDING\" for i in queue.items),\n", + " \"경보 전량 대기열 등록\": len(queue.items) == len(alerts),\n", + " \"감사 로그 건수 일치·무결\": len(queue.log) == len(queue.items) and queue.verify(),\n", + " \"프레임당 50ms 이내\": ms < 50,\n", + " \"실행 라인 커버리지 85% 이상\": cov >= 0.85,\n", + "}\n", + "passed = sum(chk.values())\n", + "for k, v in chk.items():\n", + " print(f\"[{'PASS' if v else 'FAIL'}] {k}\")\n", + "print(f\"자체 점검 {passed}/{len(chk)} 통과 ({passed / len(chk):.0%}), 파이프라인 실행 성공 {'예' if len(runs) + len(rejected) == len(ops) else '아니오'}, 핵심 모듈 실행 라인 커버리지 {cov:.1%}\")\n", + "\n", + "# ---------------- 5. 관제 상황판 ----------------\n", + "focus = max({r[\"seq\"] for r in all_tracks}, key=lambda q: sum(r[\"alert\"] for r in all_tracks if r[\"seq\"] == q))\n", + "col = {\"High\": \"tab:red\", \"Medium\": \"tab:orange\", \"Low\": \"tab:gray\"}\n", + "fig, ax = plt.subplots(2, 2, figsize=(12, 8))\n", + "s = next(x for x in ops if x[\"id\"] == focus)\n", + "ax[0, 0].imshow(s[\"frames\"][-1], cmap=\"gray\", vmin=0, vmax=255)\n", + "for r in [r for r in all_tracks if r[\"seq\"] == focus and r[\"alert\"]]:\n", + " c = np.array([center(b) for b in r[\"tr\"][\"b\"]]); ax[0, 0].plot(c[:, 0], c[:, 1], color=col[r[\"grade\"]], lw=2)\n", + "ax[0, 0].set_title(f\"{focus} alert tracks (color = grade)\"); ax[0, 0].axis(\"off\")\n", + "g = pd.Series([r[\"grade\"] for r in alerts]).value_counts().reindex([\"High\", \"Medium\"]).fillna(0)\n", + "ax[0, 1].bar(g.index, g.values, color=[col[i] for i in g.index]); ax[0, 1].set_title(\"Alerts by grade (all pending human review)\")\n", + "ax[1, 0].bar([r[\"seq\"][-2:] for r in runs], [r[\"alerts\"] for r in runs]); ax[1, 0].set_title(\"Alerts per camera sequence\")\n", + "ax[1, 1].axis(\"off\")\n", + "ax[1, 1].text(0, 0.9, f\"Intruder recall {recall:.1%}\\nFalse alarm {far:.1%}\\nQueue {len(queue.items)} pending\\n\"\n", + " f\"Latency {ms:.2f} ms/frame\\nSelf-check {passed}/{len(chk)}\", family=\"monospace\", fontsize=13, va=\"top\")\n", + "plt.suptitle(\"SEC01 integrated pipeline - synthetic training data only\"); plt.tight_layout(); plt.show()\n", + "\n", + "# ---------------- 6. 시설 방호 브리핑 요약 ----------------\n", + "print(f\"\"\"\n", + "[기지 CCTV 비인가 접근 탐지 브리핑 요약 - 교육용가상자료]\n", + "1. 처리: 시퀀스 {len(ops)}개 중 {len(runs)}개 처리, 손상 데이터 {len(rejected)}건 차단({', '.join(rejected)}), 센서 단절 프레임 {fixed_total}장 보정\n", + "2. 성능: 침입 객체 재현율 {recall:.1%}, 오경보율 {far:.1%}, 프레임당 {ms:.2f} ms, 전체 소요 {time.perf_counter() - t_start:.1f} s\n", + "3. 경보: {len(queue.items)}건 경계병 육안 확인 대기 (High {sum(i['grade'] == 'High' for i in queue.items)} / Medium {sum(i['grade'] == 'Medium' for i in queue.items)}), 자동 해제·출동 0건\n", + "4. 운용 유의: 경보 해제·출동 결심은 경계병과 당직 지휘관의 승인 후에만 이루어지며, 실제 대응은 경계 규정과 지휘계통에 따른다.\n", + "5. 한계: 코드로 그린 합성 영상 기준 결과로, 실제 CCTV·기상·배경에서는 재학습과 현장 검증이 필요하다.\"\"\")\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 10 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + "[PASS] 손상 데이터 차단\n", + "[PASS] 처리 시퀀스 29개 전량 성공\n", + "[PASS] 센서 단절 프레임 보정\n", + "[PASS] 침입 객체 재현율 85% 이상\n", + "[PASS] 오경보율 15% 이하\n", + "[PASS] 자동 해제·출동 0건\n", + "[PASS] 경보 전량 대기열 등록\n", + "[PASS] 감사 로그 건수 일치·무결\n", + "[PASS] 프레임당 50ms 이내\n", + "[PASS] 실행 라인 커버리지 85% 이상\n", + "자체 점검 10/10 통과 (100%), 파이프라인 실행 성공 예, 핵심 모듈 실행 라인 커버리지 89.1%\n", + "\n", + "[기지 CCTV 비인가 접근 탐지 브리핑 요약 - 교육용가상자료]\n", + "1. 처리: 시퀀스 30개 중 29개 처리, 손상 데이터 1건 차단(CAM-5003), 센서 단절 프레임 2장 보정\n", + "2. 성능: 침입 객체 재현율 90.5%, 오경보율 11.6%, 프레임당 3.48 ms, 전체 소요 6.5 s\n", + "3. 경보: 43건 경계병 육안 확인 대기 (High 41 / Medium 2), 자동 해제·출동 0건\n", + "4. 운용 유의: 경보 해제·출동 결심은 경계병과 당직 지휘관의 승인 후에만 이루어지며, 실제 대응은 경계 규정과 지휘계통에 따른다.\n", + "5. 한계: 코드로 그린 합성 영상 기준 결과로, 실제 CCTV·기상·배경에서는 재학습과 현장 검증이 필요하다.\n", + "```\n", + "
\n" + ] + } + ], + "metadata": { + "colab": { + "provenance": [] + }, + "kernelspec": { + "display_name": "Python 3", + "name": "python3" + }, + "language_info": { + "name": "python" + } + }, + "nbformat": 4, + "nbformat_minor": 0 +} \ No newline at end of file diff --git a/uploads/SEC01/SEC01_student.ipynb b/uploads/SEC01/SEC01_student.ipynb new file mode 100644 index 0000000..a91eae7 --- /dev/null +++ b/uploads/SEC01/SEC01_student.ipynb @@ -0,0 +1,3491 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# 🎯 [SEC01] 기지 CCTV 드론·비인가 접근 객체 탐지 프로토타입\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/SEC01/SEC01_solution.ipynb)\n", + "\n", + "---\n", + "\n", + "## 📌 1. 프로젝트 개요 및 배경\n", + "\n", + "| 항목 | 내용 |\n", + "| :--- | :--- |\n", + "| **과정 분류** | 시설·방호·경계 (Level 3) |\n", + "| **학습 소요 시간** | 10개 미션 (5일 / 총 27.5시간) |\n", + "| **환경 사양** | Python 3.10+, numpy, pandas, matplotlib, scipy, scikit-learn |\n", + "\n", + "### 📖 프로젝트 목표\n", + "미국-이란 전쟁에서는 소형 드론과 차량·인원의 접근 시도가 기지 외곽 경계의 주요 위협으로 부각되었고, 많은 CCTV 화면에서 실제 침입과 수목 흔들림·야생동물을 빠르게 구분하는 일이 시설·방호 업무의 핵심이 되었다. 본 프로젝트에서는 교육용 가상 CCTV 영상을 코드로 직접 그려, 영상 데이터 스키마 정의, 조도·기상 전처리와 경계구역(ROI) 설정, 울타리 접근 객체 탐지 Baseline, 소형 드론·위장 침입자 식별 고도화, 오경보 필터와 신뢰도 점수, 다채널 관제 상황판, 경계병 육안 확인 중심의 HITL 승인, 악천후·다지점 동시 침입 스트레스 시험, 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. 군사기지 CCTV 영상 규격 분석 및 비인가 침입 객체(드론/차량/인원) 데이터셋 정의\n", + "**소요시간**: 약 1.5시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "군사기지 외곽 감시 CCTV 영상 규격과 비인가 침입 객체(드론, 차량, 거동수상자) 데이터셋을 정의한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **기지 외곽 고정 CCTV 영상의 규격과 비인가 침입 객체(드론·차량·인원) 라벨 구조를 정의하고, 잘못된 데이터를 걸러내는 검증기를 만든다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **인원, 차량, 드론을 침입 대상으로, 동물과 조류를 오경보 유발 대상으로 정해 클래스 체계를 세우고 카메라(해상도, fps), 전술 파라미터(기상, 바람, 위치오차, 통신지연), 경계구역 행 범위, 바운딩 박스 라벨 필드를 담은 스키마 설계하고 작성하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `data/schema/sec01_input_schema.json 및 docs/sec01_data_spec.md` 형태의 결과물을 완성한다.\n", + "\n", + "### 🔍 평가 및 검증 기준 (PASS 조건)\n", + "#### 미션 판정\n", + "\n", + "**평가 구분:** 필수\n", + "\n", + "**핵심 평가영역:** 시설·방호·경계 실무 및 미국-이란 전쟁 전장 환경과의 군사적 부합성\n", + "\n", + "#### 미션 PASS 조건\n", + "\n", + "- 정량 기준: 스키마 필드 정의 완성도 100%, 샘플 데이터 검증 오류 0건\n", + "\n", + "\n", + "- 진행 기준: 입력 데이터 유효성 검증 스크립트 통과 및 명세서 문서화 완료\n", + "\n", + "\n", + "- 검증 방법: JSON Schema 검증 도구 및 샘플 데이터 파싱 단위 테스트\n", + "\n", + "\n", + "#### 채점자 판정 메모\n", + "\n", + "> 단순 코드 실행 여부뿐만 아니라 출력된 수치와 근거 자료가 군 실무 요구사항에 부합하는지 면밀히 확인한다.\n", + "\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "\"\"\"기지 외곽 CCTV 영상 메타데이터와 비인가 침입 객체(드론·차량·인원) 라벨의 스키마(카메라 규격, 전술 파라미터, 경계구역, 바운딩 박스)를 정의하고, 코드로 그린 합성 영상에서 만든 샘플 20건을 검증한다.\"\"\"\n", + "import json\n", + "import re\n", + "import numpy as np\n", + "import pandas as pd\n", + "\n", + "# ---------------- [교육용가상자료] 합성 CCTV 영상 생성기 ----------------\n", + "# 실제 영상 대신 코드로 그린 객체(사각형·점)로 기지 외곽 고정 카메라 화면을 흉내 낸다.\n", + "H, W, T = 64, 96, 10 # 화면 크기(px), 시퀀스 길이(프레임)\n", + "HORIZON, FENCE = 20, 46 # 하늘/지면 경계, 울타리 행\n", + "ROI_ROWS = (32, 48) # 울타리 접근 경계구역(ROI): 지면 32~47행\n", + "CLASSES = [\"person\", \"vehicle\", \"drone\", \"animal\", \"bird\"]\n", + "INTRUDER = {\"person\", \"vehicle\", \"drone\"} # 비인가 접근(경보 대상)\n", + "CONDS = [\"day\", \"night\", \"rain\", \"fog\"]\n", + "\n", + "\n", + "def background(rng):\n", + " # [TODO : 학생 실습 1] 군사기지 CCTV 영상 규격 분석 및 비인가 침입 객체(드론/차량/인원) 데이터셋 정의 관련 핵심 로직을 구현하세요.\n", + " # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n", + " # <<< 여기에 코드를 작성하세요 >>>\n", + " pass # 작성 후 pass를 제거하세요.\n", + "def tree(img, t, wind):\n", + " \"\"\"좌측 수목 수관: 바람 세기에 따라 좌우로 흔들려 오경보를 만든다.\"\"\"\n", + " # [TODO : 학생 실습 1] 군사기지 CCTV 영상 규격 분석 및 비인가 침입 객체(드론/차량/인원) 데이터셋 정의 관련 핵심 로직을 구현하세요.\n", + " # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n", + " # <<< 여기에 코드를 작성하세요 >>>\n", + " pass # 작성 후 pass를 제거하세요.\n", + "def make_seq(seed, cond=None, objs=None, wind=None, camo=False):\n", + " rng = np.random.default_rng(seed)\n", + " cond = cond or str(rng.choice(CONDS))\n", + " wind = rng.uniform(0, 2) if wind is None else wind\n", + " bg = background(rng)\n", + " n = int(rng.integers(1, 4)) if objs is None else len(objs)\n", + " kinds = objs or list(rng.choice(CLASSES, n, p=[0.3, 0.15, 0.25, 0.15, 0.15]))\n", + " obs = []\n", + " for k in kinds:\n", + " if k == \"person\":\n", + " o = dict(y=rng.uniform(30, 36), x=rng.uniform(15, 85), vy=rng.uniform(0.8, 1.2), vx=rng.uniform(-0.6, 0.6), h=7, w=3,\n", + " val=72 if camo else rng.uniform(35, 50))\n", + " elif k == \"vehicle\":\n", + " o = dict(y=rng.uniform(33, 38), x=rng.uniform(10, 30), vy=0.2, vx=rng.uniform(2, 4), h=5, w=10, val=rng.uniform(150, 170))\n", + " elif k == \"drone\":\n", + " o = dict(y=rng.uniform(3, 15), x=rng.uniform(10, 80), vy=rng.uniform(0, 0.6), vx=rng.uniform(-2, 2), h=2, w=2 if camo else 3,\n", + " val=rng.uniform(80, 120))\n", + " elif k == \"animal\":\n", + " o = dict(y=rng.uniform(34, 42), x=rng.uniform(15, 80), vy=0, vx=rng.uniform(-1.5, 1.5), h=2, w=4, val=rng.uniform(60, 75))\n", + " else:\n", + " o = dict(y=rng.uniform(3, 15), x=rng.uniform(10, 80), vy=0, vx=rng.uniform(-3, 3), h=1, w=2, val=rng.uniform(100, 130))\n", + " o[\"cls\"] = str(k); obs.append(o)\n", + " frames, labels = [], []\n", + " for t in range(-8, T): # 음수 시각 = 객체 없는 배경 학습 구간(사전 관측)\n", + " f = bg.copy(); tree(f, t, wind); lab = []\n", + " for oi, o in enumerate(obs if t >= 0 else []):\n", + " jit = rng.normal(0, 1.2, 2) if o[\"cls\"] in (\"animal\", \"bird\") else (0, 0) # 불규칙 움직임\n", + " y, x = int(o[\"y\"] + o[\"vy\"] * t + jit[0]), int(o[\"x\"] + o[\"vx\"] * t + jit[1])\n", + " y0, x0, y1, x1 = max(y, 0), max(x, 0), min(y + o[\"h\"], H), min(x + o[\"w\"], W)\n", + " if y1 > y0 and x1 > x0:\n", + " f[y0:y1, x0:x1] = o[\"val\"]\n", + " if o[\"cls\"] == \"person\":\n", + " f[max(y0 - 2, 0):y0, x0 + 1:x0 + 2] = o[\"val\"] # 머리\n", + " lab.append({\"cls\": o[\"cls\"], \"oid\": oi, \"bbox\": (x0, y0, x1, y1), \"intruder\": o[\"cls\"] in INTRUDER})\n", + " if cond == \"night\":\n", + " f = f * 0.5 + 10 + rng.normal(0, 3.5, f.shape)\n", + " else:\n", + " f = f + rng.normal(0, 2.5, f.shape)\n", + " if cond == \"rain\":\n", + " m = rng.random(f.shape) < 0.006\n", + " for d in range(3):\n", + " f[d:][m[:H - d]] += 45\n", + " if cond == \"fog\":\n", + " f = 0.45 * f + 0.55 * 150\n", + " frames.append(np.clip(f, 0, 255)); labels.append(lab)\n", + " fr = np.array(frames)\n", + " return {\"id\": f\"CAM-{seed:04d}\", \"cond\": cond, \"wind\": wind, \"ref\": fr[:8], \"frames\": fr[8:], \"labels\": labels[8:]}\n", + "\n", + "# 1) 스키마 정의: 카메라 규격, 전술 파라미터(기상·위치오차·통신지연), 경계구역(ROI), 객체 라벨\n", + "SCHEMA = {\n", + " \"required\": [\"camera_id\", \"n_frames\", \"camera\", \"tactical\", \"roi\", \"labels\"],\n", + " \"fields\": {\n", + " \"camera_id\": {\"type\": str, \"pattern\": r\"^CAM-\\d{4}$\"},\n", + " \"n_frames\": {\"type\": int, \"min\": 1, \"max\": 3000},\n", + " },\n", + " \"camera\": {\n", + " \"mode\": {\"type\": str, \"enum\": [\"EO\", \"IR\"]},\n", + " \"resolution\": {\"type\": list, \"length\": 2},\n", + " \"fps\": {\"type\": (int, float), \"min\": 1, \"max\": 60},\n", + " },\n", + " \"tactical\": {\n", + " \"condition\": {\"type\": str, \"enum\": [\"day\", \"night\", \"rain\", \"fog\"]},\n", + " \"wind_level\": {\"type\": (int, float), \"min\": 0, \"max\": 10},\n", + " \"position_error_m\": {\"type\": (int, float), \"min\": 0, \"max\": 100},\n", + " \"comm_delay_ms\": {\"type\": (int, float), \"min\": 0, \"max\": 10000},\n", + " },\n", + " \"roi\": {\n", + " \"row_start\": {\"type\": int, \"min\": 0, \"max\": 200},\n", + " \"row_end\": {\"type\": int, \"min\": 1, \"max\": 200},\n", + " },\n", + " \"label\": {\n", + " \"class\": {\"type\": str, \"enum\": CLASSES},\n", + " \"bbox\": {\"type\": list, \"length\": 4},\n", + " \"frame\": {\"type\": int, \"min\": 0},\n", + " \"intruder\": {\"type\": bool},\n", + " },\n", + "}\n", + "\n", + "\n", + "def check(name, value, rule):\n", + " \"\"\"값 하나를 규칙(type, enum, min, max, length, pattern)으로 검사해 오류 목록을 돌려준다.\"\"\"\n", + " if not isinstance(value, rule[\"type\"]) or (isinstance(value, bool) and rule[\"type\"] is not bool):\n", + " return [f\"{name}: 자료형 오류({type(value).__name__})\"]\n", + " errs = []\n", + " if \"enum\" in rule and value not in rule[\"enum\"]:\n", + " errs.append(f\"{name}: 허용되지 않은 값 '{value}'\")\n", + " if \"min\" in rule and value < rule[\"min\"]:\n", + " errs.append(f\"{name}: 최소값 {rule['min']} 미만\")\n", + " if \"max\" in rule and value > rule[\"max\"]:\n", + " errs.append(f\"{name}: 최대값 {rule['max']} 초과\")\n", + " if \"length\" in rule and len(value) != rule[\"length\"]:\n", + " errs.append(f\"{name}: 길이 {rule['length']} 필요\")\n", + " if \"pattern\" in rule and not re.match(rule[\"pattern\"], value):\n", + " errs.append(f\"{name}: 형식 불일치 '{value}'\")\n", + " return errs\n", + "\n", + "\n", + "def validate(rec):\n", + " errs = [f\"{k}: 필수 필드 누락\" for k in SCHEMA[\"required\"] if k not in rec]\n", + " for k, rule in SCHEMA[\"fields\"].items():\n", + " if k in rec:\n", + " errs += check(k, rec[k], rule)\n", + " for group in (\"camera\", \"tactical\", \"roi\"):\n", + " for k, rule in SCHEMA[group].items():\n", + " if k not in rec.get(group, {}):\n", + " errs.append(f\"{group}.{k}: 필수 필드 누락\")\n", + " else:\n", + " errs += check(f\"{group}.{k}\", rec[group][k], rule)\n", + " roi = rec.get(\"roi\", {})\n", + " if \"row_start\" in roi and \"row_end\" in roi and roi[\"row_start\"] >= roi[\"row_end\"]:\n", + " errs.append(\"roi: row_start는 row_end보다 작아야 함\")\n", + " for i, lab in enumerate(rec.get(\"labels\", [])):\n", + " for k, rule in SCHEMA[\"label\"].items():\n", + " if k not in lab:\n", + " errs.append(f\"labels[{i}].{k}: 필수 필드 누락\")\n", + " else:\n", + " errs += check(f\"labels[{i}].{k}\", lab[k], rule)\n", + " return errs\n", + "\n", + "\n", + "# 2) [교육용가상자료] 합성 CCTV 시퀀스 20개에서 메타데이터 샘플 생성\n", + "rng = np.random.default_rng(1)\n", + "records = []\n", + "for i in range(20):\n", + " seq = make_seq(100 + i)\n", + " labels = [{\"class\": l[\"cls\"], \"frame\": t, \"bbox\": [int(v) for v in l[\"bbox\"]], \"intruder\": bool(l[\"intruder\"])}\n", + " for t in (0, 5, 9) for l in seq[\"labels\"][t]]\n", + " records.append({\"camera_id\": seq[\"id\"], \"n_frames\": T,\n", + " \"camera\": {\"mode\": \"IR\" if seq[\"cond\"] == \"night\" else \"EO\", \"resolution\": [W, H], \"fps\": 10},\n", + " \"tactical\": {\"condition\": seq[\"cond\"], \"wind_level\": round(float(seq[\"wind\"]) * 2.5, 1),\n", + " \"position_error_m\": round(float(rng.uniform(1, 15)), 1), \"comm_delay_ms\": int(rng.integers(20, 400))},\n", + " \"roi\": {\"row_start\": ROI_ROWS[0], \"row_end\": ROI_ROWS[1]}, \"labels\": labels})\n", + "\n", + "# 3) 정상 데이터 검증\n", + "ok = sum(1 for r in records if not validate(r))\n", + "print(f\"정상 샘플 {len(records)}건 중 검증 통과 {ok}건, 오류 {len(records) - ok}건 -> 목표(검증 오류 0건): {'달성' if ok == len(records) else '미달'}\")\n", + "\n", + "# 4) 오류 주입 시험: 검증기가 잘못된 데이터를 잡아내는지 확인\n", + "bad = json.loads(json.dumps(records[0]))\n", + "bad[\"camera_id\"] = \"CAM-1\" # 형식\n", + "bad[\"camera\"][\"fps\"] = 120 # 범위\n", + "del bad[\"tactical\"][\"comm_delay_ms\"] # 누락\n", + "bad[\"labels\"][0][\"class\"] = \"ufo\" # 허용값\n", + "bad[\"roi\"][\"row_start\"] = 50 # 논리(시작 > 끝)\n", + "errs = validate(bad)\n", + "print(f\"\\n[오류 주입 결과] 주입 5종 -> 검출 {len(errs)}건\")\n", + "for e in errs:\n", + " print(\" -\", e)\n", + "print(f\"주입 오류 전부 검출: {'예' if len(errs) >= 5 else '아니오'}\")\n", + "\n", + "# 5) 스키마 필드 정의서(명세서 초안)\n", + "rows = [(\"기본\", k, r[\"type\"].__name__, str({x: y for x, y in r.items() if x != \"type\"})) for k, r in SCHEMA[\"fields\"].items()]\n", + "for g in (\"camera\", \"tactical\", \"roi\", \"label\"):\n", + " for k, r in SCHEMA[g].items():\n", + " t = r[\"type\"].__name__ if isinstance(r[\"type\"], type) else \"number\"\n", + " rows.append((g, k, t, str({x: y for x, y in r.items() if x != \"type\"})))\n", + "print(\"\\n[데이터 명세 요약] 필드 수\", len(rows))\n", + "print(pd.DataFrame(rows, columns=[\"그룹\", \"필드\", \"자료형\", \"규칙\"]).to_string(index=False))\n", + "print(\"\\n침입 대상 클래스:\", sorted(INTRUDER), \"/ 오경보 유발 클래스: animal, bird, 수목 흔들림(clutter)\")\n", + "print(\"클래스별 라벨 수:\", pd.Series([l[\"class\"] for r in records for l in r[\"labels\"]]).value_counts().to_dict())\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 01 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + "정상 샘플 20건 중 검증 통과 20건, 오류 0건 -> 목표(검증 오류 0건): 달성\n", + "\n", + "[오류 주입 결과] 주입 5종 -> 검출 5건\n", + " - camera_id: 형식 불일치 'CAM-1'\n", + " - camera.fps: 최대값 60 초과\n", + " - tactical.comm_delay_ms: 필수 필드 누락\n", + " - roi: row_start는 row_end보다 작아야 함\n", + " - labels[0].class: 허용되지 않은 값 'ufo'\n", + "주입 오류 전부 검출: 예\n", + "\n", + "[데이터 명세 요약] 필드 수 15\n", + " 그룹 필드 자료형 규칙\n", + " 기본 camera_id str {'pattern': '^CAM-\\\\d{4}$'}\n", + " 기본 n_frames int {'min': 1, 'max': 3000}\n", + " camera mode str {'enum': ['EO', 'IR']}\n", + " camera resolution list {'length': 2}\n", + " camera fps number {'min': 1, 'max': 60}\n", + "tactical condition str {'enum': ['day', 'night', 'rain', 'fog']}\n", + "tactical wind_level number {'min': 0, 'max': 10}\n", + "tactical position_error_m number {'min': 0, 'max': 100}\n", + "tactical comm_delay_ms number {'min': 0, 'max': 10000}\n", + " roi row_start int {'min': 0, 'max': 200}\n", + " roi row_end int {'min': 1, 'max': 200}\n", + " label class str {'enum': ['person', 'vehicle', 'drone', 'animal', 'bird']}\n", + " label bbox list {'length': 4}\n", + " label frame int {'min': 0}\n", + " label intruder bool {}\n", + "\n", + "침입 대상 클래스: ['drone', 'person', 'vehicle'] / 오경보 유발 클래스: animal, bird, 수목 흔들림(clutter)\n", + "클래스별 라벨 수: {'person': 57, 'vehicle': 27, 'drone': 21, 'bird': 15, 'animal': 15}\n", + "```\n", + "
\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 02. 주/야간 조도 변화 보정, 기상 잡음 필터링 및 침입 경계구역(ROI) 설정 전처리 구현\n", + "**소요시간**: 약 2.0시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "주/야간 조도 변화 보정, 기상 잡음 제거 및 울타리 관심구역(ROI) 설정 전처리 모듈을 구현한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **주야간 조도 변화, 비·잡음, 센서 단절이 섞인 CCTV 영상을 조건별로 보정하고 울타리 경계구역(ROI)을 설정하는 전처리 모듈을 구현한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **센서 단절(전 화면 0) 프레임을 직전 프레임으로 대체하고, 백분위 신장으로 조도를 맞추며, 비 조건에서는 3x3 중앙값으로 빗줄기를 줄이고, 하늘과 울타리 접근 지면 띠를 경계구역 마스크로 설정 수행하고 검증하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `src/preprocess/sec01_preprocessor.py 및 전처리 전후 데이터셋` 형태의 결과물을 완성한다.\n", + "\n", + "### 🔍 평가 및 검증 기준 (PASS 조건)\n", + "#### 미션 판정\n", + "\n", + "**평가 구분:** 필수\n", + "\n", + "**핵심 평가영역:** 전술 환경 특유의 노이즈(스푸핑, 반사파, 센서 단절 등) 제거 타당성\n", + "\n", + "#### 미션 PASS 조건\n", + "\n", + "- 정량 기준: 데이터 정제 처리 속도 1,000건/초 이상, 전처리 오류율 0%\n", + "\n", + "\n", + "- 진행 기준: 정제 파이프라인 자동화 및 단위 테스트 케이스 5종 통과\n", + "\n", + "\n", + "- 검증 방법: pytest 기반 전처리 정합성 테스트 및 결측치 보정율 확인\n", + "\n", + "\n", + "#### 채점자 판정 메모\n", + "\n", + "> 단순 코드 실행 여부뿐만 아니라 출력된 수치와 근거 자료가 군 실무 요구사항에 부합하는지 면밀히 확인한다.\n", + "\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "\"\"\"주/야간 조도 보정, 비·잡음 필터, 센서 단절 프레임 보정, 울타리 경계구역(ROI) 마스크를 묶은 CCTV 전처리 모듈을 구현하고, 처리 속도·단위 테스트 5종·조건별 전처리 전후 탐지 성능을 측정한다.\"\"\"\n", + "import time\n", + "import numpy as np\n", + "import pandas as pd\n", + "import matplotlib.pyplot as plt\n", + "from scipy import ndimage\n", + "\n", + "# ---------------- [교육용가상자료] 합성 CCTV 영상 생성기 ----------------\n", + "# 실제 영상 대신 코드로 그린 객체(사각형·점)로 기지 외곽 고정 카메라 화면을 흉내 낸다.\n", + "H, W, T = 64, 96, 10 # 화면 크기(px), 시퀀스 길이(프레임)\n", + "HORIZON, FENCE = 20, 46 # 하늘/지면 경계, 울타리 행\n", + "ROI_ROWS = (32, 48) # 울타리 접근 경계구역(ROI): 지면 32~47행\n", + "CLASSES = [\"person\", \"vehicle\", \"drone\", \"animal\", \"bird\"]\n", + "INTRUDER = {\"person\", \"vehicle\", \"drone\"} # 비인가 접근(경보 대상)\n", + "CONDS = [\"day\", \"night\", \"rain\", \"fog\"]\n", + "\n", + "\n", + "def background(rng):\n", + " # [TODO : 학생 실습 2] 주/야간 조도 변화 보정, 기상 잡음 필터링 및 침입 경계구역(ROI) 설정 전처리 구현 관련 핵심 로직을 구현하세요.\n", + " # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n", + " # <<< 여기에 코드를 작성하세요 >>>\n", + " pass # 작성 후 pass를 제거하세요.\n", + "def tree(img, t, wind):\n", + " \"\"\"좌측 수목 수관: 바람 세기에 따라 좌우로 흔들려 오경보를 만든다.\"\"\"\n", + " # [TODO : 학생 실습 2] 주/야간 조도 변화 보정, 기상 잡음 필터링 및 침입 경계구역(ROI) 설정 전처리 구현 관련 핵심 로직을 구현하세요.\n", + " # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n", + " # <<< 여기에 코드를 작성하세요 >>>\n", + " pass # 작성 후 pass를 제거하세요.\n", + "def make_seq(seed, cond=None, objs=None, wind=None, camo=False):\n", + " rng = np.random.default_rng(seed)\n", + " cond = cond or str(rng.choice(CONDS))\n", + " wind = rng.uniform(0, 2) if wind is None else wind\n", + " bg = background(rng)\n", + " n = int(rng.integers(1, 4)) if objs is None else len(objs)\n", + " kinds = objs or list(rng.choice(CLASSES, n, p=[0.3, 0.15, 0.25, 0.15, 0.15]))\n", + " obs = []\n", + " for k in kinds:\n", + " if k == \"person\":\n", + " o = dict(y=rng.uniform(30, 36), x=rng.uniform(15, 85), vy=rng.uniform(0.8, 1.2), vx=rng.uniform(-0.6, 0.6), h=7, w=3,\n", + " val=72 if camo else rng.uniform(35, 50))\n", + " elif k == \"vehicle\":\n", + " o = dict(y=rng.uniform(33, 38), x=rng.uniform(10, 30), vy=0.2, vx=rng.uniform(2, 4), h=5, w=10, val=rng.uniform(150, 170))\n", + " elif k == \"drone\":\n", + " o = dict(y=rng.uniform(3, 15), x=rng.uniform(10, 80), vy=rng.uniform(0, 0.6), vx=rng.uniform(-2, 2), h=2, w=2 if camo else 3,\n", + " val=rng.uniform(80, 120))\n", + " elif k == \"animal\":\n", + " o = dict(y=rng.uniform(34, 42), x=rng.uniform(15, 80), vy=0, vx=rng.uniform(-1.5, 1.5), h=2, w=4, val=rng.uniform(60, 75))\n", + " else:\n", + " o = dict(y=rng.uniform(3, 15), x=rng.uniform(10, 80), vy=0, vx=rng.uniform(-3, 3), h=1, w=2, val=rng.uniform(100, 130))\n", + " o[\"cls\"] = str(k); obs.append(o)\n", + " frames, labels = [], []\n", + " for t in range(-8, T): # 음수 시각 = 객체 없는 배경 학습 구간(사전 관측)\n", + " f = bg.copy(); tree(f, t, wind); lab = []\n", + " for oi, o in enumerate(obs if t >= 0 else []):\n", + " jit = rng.normal(0, 1.2, 2) if o[\"cls\"] in (\"animal\", \"bird\") else (0, 0) # 불규칙 움직임\n", + " y, x = int(o[\"y\"] + o[\"vy\"] * t + jit[0]), int(o[\"x\"] + o[\"vx\"] * t + jit[1])\n", + " y0, x0, y1, x1 = max(y, 0), max(x, 0), min(y + o[\"h\"], H), min(x + o[\"w\"], W)\n", + " if y1 > y0 and x1 > x0:\n", + " f[y0:y1, x0:x1] = o[\"val\"]\n", + " if o[\"cls\"] == \"person\":\n", + " f[max(y0 - 2, 0):y0, x0 + 1:x0 + 2] = o[\"val\"] # 머리\n", + " lab.append({\"cls\": o[\"cls\"], \"oid\": oi, \"bbox\": (x0, y0, x1, y1), \"intruder\": o[\"cls\"] in INTRUDER})\n", + " if cond == \"night\":\n", + " f = f * 0.5 + 10 + rng.normal(0, 3.5, f.shape)\n", + " else:\n", + " f = f + rng.normal(0, 2.5, f.shape)\n", + " if cond == \"rain\":\n", + " m = rng.random(f.shape) < 0.006\n", + " for d in range(3):\n", + " f[d:][m[:H - d]] += 45\n", + " if cond == \"fog\":\n", + " f = 0.45 * f + 0.55 * 150\n", + " frames.append(np.clip(f, 0, 255)); labels.append(lab)\n", + " fr = np.array(frames)\n", + " return {\"id\": f\"CAM-{seed:04d}\", \"cond\": cond, \"wind\": wind, \"ref\": fr[:8], \"frames\": fr[8:], \"labels\": labels[8:]}\n", + "\n", + "# ---------------- 전처리·탐지 공통 함수(배경 차분 기반) ----------------\n", + "def enhance(seq):\n", + " \"\"\"조도 보정(백분위 신장) + 비 조건의 빗줄기 제거(3x3 중앙값). 배경 학습 구간(ref)과 같은 기준으로 보정한다.\"\"\"\n", + " allf = np.concatenate([seq[\"ref\"], seq[\"frames\"]])\n", + " f = ndimage.median_filter(allf, size=(1, 3, 3)) if seq[\"cond\"] == \"rain\" else allf\n", + " lo, hi = np.percentile(f, [1, 99.5])\n", + " f = np.clip((f - lo) / (hi - lo + 1e-6) * 255, 0, 255)\n", + " return f[:len(seq[\"ref\"])], f[len(seq[\"ref\"]):]\n", + "\n", + "\n", + "def detect(ref, frames, k=4.0, min_area=3, sky_k=None, smooth=False):\n", + " \"\"\"배경 학습 구간의 중앙값 배경과 차이를 강건 임계값(k x MAD)으로 이진화해 연결 성분을 객체 후보로 만든다.\n", + " smooth=True 이면 연속 3프레임 평균으로 시간 잡음을 줄여 저대비 객체를 살린다.\"\"\"\n", + " bg = np.median(ref, axis=0)\n", + " if smooth:\n", + " frames = ndimage.uniform_filter1d(frames, 3, axis=0, mode=\"nearest\")\n", + " out = []\n", + " for f in frames:\n", + " d = np.abs(f - bg)\n", + " mad = np.median(d) * 1.4826 + 1e-6\n", + " thr = np.full(d.shape, k * mad)\n", + " if sky_k is not None:\n", + " thr[:HORIZON] = sky_k * mad\n", + " lab, n = ndimage.label(d > thr)\n", + " dets = []\n", + " for i, sl in enumerate(ndimage.find_objects(lab), start=1):\n", + " area = int((lab[sl] == i).sum())\n", + " if area >= min_area:\n", + " dets.append({\"bbox\": (sl[1].start, sl[0].start, sl[1].stop, sl[0].stop), \"area\": area,\n", + " \"contrast\": float(d[sl][lab[sl] == i].mean() / mad)})\n", + " out.append(dets)\n", + " return out\n", + "\n", + "\n", + "def center(b):\n", + " return ((b[0] + b[2]) / 2, (b[1] + b[3]) / 2)\n", + "\n", + "\n", + "def match(dets, labels, dist=4.0):\n", + " \"\"\"탐지와 정답을 중심 거리로 1:1 대응시킨다. 반환: [(det, label or None)], 놓친 label 목록\"\"\"\n", + " used, pairs = set(), []\n", + " for d in dets:\n", + " cd = center(d[\"bbox\"]); best, bj = dist, None\n", + " for j, l in enumerate(labels):\n", + " cl = center(l[\"bbox\"]); e = np.hypot(cd[0] - cl[0], cd[1] - cl[1])\n", + " if j not in used and e < best:\n", + " best, bj = e, j\n", + " if bj is not None:\n", + " used.add(bj)\n", + " pairs.append((d, labels[bj] if bj is not None else None))\n", + " return pairs, [l for j, l in enumerate(labels) if j not in used]\n", + "\n", + "\n", + "def in_roi(b):\n", + " cy = (b[1] + b[3]) / 2\n", + " return cy < HORIZON or ROI_ROWS[0] <= cy < ROI_ROWS[1]\n", + "\n", + "\n", + "def rule_class(b, area):\n", + " \"\"\"Baseline 규칙: 위치(하늘/지면)·크기·종횡비로 1차 분류(학습 없음, 판단 근거가 그대로 드러난다)\"\"\"\n", + " w, h = b[2] - b[0], b[3] - b[1]\n", + " cy = (b[1] + b[3]) / 2\n", + " if cy < HORIZON:\n", + " return \"drone\" if 3 <= area <= 12 else \"clutter\"\n", + " if area >= 30 and w > h:\n", + " return \"vehicle\"\n", + " if h >= w and 6 <= area <= 30:\n", + " return \"person\"\n", + " return \"clutter\"\n", + "\n", + "\n", + "def repair_frames(f):\n", + " \"\"\"센서 단절(전 화면 0 또는 NaN) 프레임을 직전 정상 프레임으로 대체하고 보정 건수를 돌려준다.\"\"\"\n", + " f = f.copy(); fixed = 0\n", + " for t in range(len(f)):\n", + " if np.isnan(f[t]).any() or f[t].max() < 1:\n", + " f[t] = f[t - 1] if t > 0 else np.nanmedian(f[1:], axis=0); fixed += 1\n", + " return f, fixed\n", + "\n", + "\n", + "def roi_mask():\n", + " \"\"\"경계구역: 하늘(드론 접근) + 울타리 접근 지면 띠. 수목 영역(좌측 x<16)은 감시 제외 후보로 표시만 한다.\"\"\"\n", + " m = np.zeros((H, W), bool)\n", + " m[:HORIZON] = True; m[ROI_ROWS[0]:ROI_ROWS[1]] = True\n", + " return m\n", + "\n", + "\n", + "def preprocess(seq):\n", + " \"\"\"반환: (보정된 배경 구간, 보정된 감시 구간, 보정 건수). 조건별 처리를 자동 선택한다.\"\"\"\n", + " allf, fixed = repair_frames(np.concatenate([seq[\"ref\"], seq[\"frames\"]]))\n", + " s2 = {**seq, \"ref\": allf[:len(seq[\"ref\"])], \"frames\": allf[len(seq[\"ref\"]):]}\n", + " ref, fr = enhance(s2)\n", + " return ref, fr, fixed\n", + "\n", + "\n", + "def corrupt(seq, rate, rng):\n", + " \"\"\"센서 단절 모의: 감시 구간 프레임 일부를 0으로 만든다(스푸핑·단절 잡음의 단순화).\"\"\"\n", + " fr = seq[\"frames\"].copy()\n", + " for t in rng.choice(len(fr), int(round(rate * len(fr))), replace=False):\n", + " fr[t] = 0\n", + " return {**seq, \"frames\": fr}\n", + "\n", + "\n", + "def f1_of(seqs, use_pre, mask=None):\n", + " tp = fp = fn = 0\n", + " for q in seqs:\n", + " ref, fr = preprocess(q)[:2] if use_pre else (q[\"ref\"], q[\"frames\"])\n", + " for ds, labs in zip(detect(ref, fr), q[\"labels\"]):\n", + " labs = [l for l in labs if l[\"intruder\"]]\n", + " if mask is not None:\n", + " ds = [d for d in ds if in_roi(d[\"bbox\"])]\n", + " pairs, missed = match(ds, labs)\n", + " tp += sum(l is not None for _, l in pairs); fp += sum(l is None for _, l in pairs); fn += len(missed)\n", + " p, r = tp / max(tp + fp, 1), tp / max(tp + fn, 1)\n", + " return round(p, 3), round(r, 3), round(2 * p * r / max(p + r, 1e-9), 3)\n", + "\n", + "\n", + "rng = np.random.default_rng(5)\n", + "data = [make_seq(s) for s in range(300, 340)]\n", + "noisy = [corrupt(q, 0.2, rng) if i % 2 == 0 else q for i, q in enumerate(data)] # 절반에 센서 단절 20% 주입\n", + "\n", + "# 1) 처리 속도와 오류율: 프레임 단위 전처리(조도 보정 + 필터)를 시퀀스 40개에 적용\n", + "t0 = time.perf_counter(); n_frames = 0; errors = 0\n", + "for q in noisy:\n", + " try:\n", + " ref, fr, _ = preprocess(q); n_frames += len(ref) + len(fr)\n", + " errors += int(not np.isfinite(fr).all() or fr.min() < 0 or fr.max() > 255)\n", + " except Exception:\n", + " errors += 1\n", + "sec = time.perf_counter() - t0\n", + "print(f\"전처리 {n_frames}프레임 / {sec:.2f}초 = {n_frames / sec:,.0f}프레임/초 (목표 1,000 이상: {'달성' if n_frames / sec >= 1000 else '미달'})\")\n", + "print(f\"전처리 오류(NaN·범위 이탈) {errors}건 -> 오류율 {errors / len(noisy):.0%} (목표 0%)\")\n", + "\n", + "# 2) 단위 테스트 5종\n", + "def t1():\n", + " f = np.full((4, H, W), 100.0); f[2] = 0\n", + " out, n = repair_frames(f); assert n == 1 and out[2].mean() == 100\n", + "def t2():\n", + " f = np.full((3, H, W), 100.0); f[1, 5, 5] = np.nan\n", + " out, n = repair_frames(f); assert n == 1 and np.isfinite(out).all()\n", + "def t3():\n", + " ref, fr, _ = preprocess(make_seq(1, cond=\"night\")); assert 0 <= fr.min() and fr.max() <= 255\n", + "def t4():\n", + " m = roi_mask(); assert m[10, 10] and m[40, 40] and not m[25, 40] and not m[55, 40]\n", + "def t5():\n", + " q = make_seq(2, cond=\"rain\"); ref, fr, _ = preprocess(q); assert fr.shape == q[\"frames\"].shape\n", + "tests = {\"단절 프레임 보정\": t1, \"NaN 프레임 보정\": t2, \"야간 밝기 범위\": t3, \"ROI 마스크 경계\": t4, \"출력 형상 보존\": t5}\n", + "passed = 0\n", + "for name, fn in tests.items():\n", + " try:\n", + " fn(); passed += 1; print(f\"[PASS] {name}\")\n", + " except AssertionError:\n", + " print(f\"[FAIL] {name}\")\n", + "print(f\"단위 테스트 {passed}/5 통과\")\n", + "\n", + "# 3) 조건별 전처리 전후 탐지 성능(센서 단절 주입 포함)\n", + "rows = []\n", + "for c in CONDS + [\"전체\"]:\n", + " sub = [q for q in noisy if c == \"전체\" or q[\"cond\"] == c]\n", + " b, a = f1_of(sub, False), f1_of(sub, True)\n", + " ar = f1_of(sub, True, mask=True)\n", + " rows.append({\"조건\": c, \"시퀀스\": len(sub), \"전 F1\": b[2], \"후 F1\": a[2], \"후 재현율\": a[1], \"후+ROI 정밀도\": ar[0], \"후+ROI F1\": ar[2]})\n", + "df = pd.DataFrame(rows)\n", + "print(\"\\n[조건별 전처리 전후 탐지 F1] (전: 원본 그대로 / 후: 단절 보정+조도 보정+필터 / ROI: 경계구역 밖 탐지 제외)\")\n", + "print(df.to_string(index=False))\n", + "print(\"전술 노이즈 대응: 센서 단절 프레임은 직전 프레임으로 대체, 비는 3x3 중앙값, 야간 조도는 백분위 신장으로 보정하며 스푸핑 영상 판별은 본 과제 범위 밖이다.\")\n", + "\n", + "q = noisy[2]; ref, fr, _ = preprocess(q)\n", + "fig, ax = plt.subplots(1, 3, figsize=(12, 3.5))\n", + "for a, im, ti in zip(ax, [q[\"frames\"][4], fr[4], fr[4] * roi_mask()], [\"raw\", \"preprocessed\", \"preprocessed + ROI mask\"]):\n", + " a.imshow(im, cmap=\"gray\", vmin=0, vmax=255); a.set_title(f\"{q['id']} {q['cond']}: {ti}\"); a.axis(\"off\")\n", + "plt.tight_layout(); plt.show()\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 02 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + "전처리 720프레임 / 0.38초 = 1,890프레임/초 (목표 1,000 이상: 달성)\n", + "전처리 오류(NaN·범위 이탈) 0건 -> 오류율 0% (목표 0%)\n", + "[PASS] 단절 프레임 보정\n", + "[PASS] NaN 프레임 보정\n", + "[PASS] 야간 밝기 범위\n", + "[PASS] ROI 마스크 경계\n", + "[PASS] 출력 형상 보존\n", + "단위 테스트 5/5 통과\n", + "\n", + "[조건별 전처리 전후 탐지 F1] (전: 원본 그대로 / 후: 단절 보정+조도 보정+필터 / ROI: 경계구역 밖 탐지 제외)\n", + " 조건 시퀀스 전 F1 후 F1 후 재현율 후+ROI 정밀도 후+ROI F1\n", + " day 13 0.623 0.639 0.956 0.493 0.649\n", + "night 14 0.484 0.493 0.868 0.488 0.625\n", + " rain 7 0.083 0.303 0.792 0.343 0.479\n", + " fog 6 0.389 0.387 0.940 0.247 0.390\n", + " 전체 40 0.247 0.463 0.886 0.419 0.568\n", + "전술 노이즈 대응: 센서 단절 프레임은 직전 프레임으로 대체, 비는 3x3 중앙값, 야간 조도는 백분위 신장으로 보정하며 스푸핑 영상 판별은 본 과제 범위 밖이다.\n", + "```\n", + "
\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 03. 기지 외곽 울타리 접근 객체 탐지 Baseline 비전 모델 구현\n", + "**소요시간**: 약 2.5시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "기지 울타리 접근 객체를 1차 감지하는 Baseline 객체 탐지 비전 모델을 구현한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **울타리 접근 객체를 1차로 감지하는 규칙 기반 Baseline 비전 모델을 구현하고 판단 기준을 투명하게 기록한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **학습한 배경과의 차이를 잡음 수준의 4배(MAD 기준)로 이진화하고 연결 영역을 객체 후보로 만든 뒤, 위치(하늘/지면)·크기·종횡비 규칙으로 인원·차량·드론을 분류 수행하고 검증하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `src/models/sec01_baseline_model.py 및 기본 추론 결과 보고서` 형태의 결과물을 완성한다.\n", + "\n", + "### 🔍 평가 및 검증 기준 (PASS 조건)\n", + "#### 미션 판정\n", + "\n", + "**평가 구분:** 필수\n", + "\n", + "**핵심 평가영역:** 알고리즘의 논리적 흐름 및 군사적 판단 기준의 투명성\n", + "\n", + "#### 미션 PASS 조건\n", + "\n", + "- 정량 기준: 기본 데이터셋 대상 탐지/처리 정확도 75% 이상 달성\n", + "\n", + "\n", + "- 진행 기준: 단위 기능 동작 확인 및 기본 시나리오 실행 로그 생성\n", + "\n", + "\n", + "- 검증 방법: 기준 벤치마크 데이터셋 대상 추론 결과 오차율 비교\n", + "\n", + "\n", + "#### 채점자 판정 메모\n", + "\n", + "> 단순 코드 실행 여부뿐만 아니라 출력된 수치와 근거 자료가 군 실무 요구사항에 부합하는지 면밀히 확인한다.\n", + "\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "\"\"\"기지 외곽 고정 CCTV 합성 영상에서 배경 차분과 위치·크기·종횡비 규칙으로 울타리 접근 객체(인원·차량·드론)를 1차 감지하는 Baseline 비전 모델을 구현하고, 프레임 단위 탐지 정확도와 오경보를 측정한다.\"\"\"\n", + "import numpy as np\n", + "import pandas as pd\n", + "import matplotlib.pyplot as plt\n", + "from scipy import ndimage\n", + "\n", + "# ---------------- [교육용가상자료] 합성 CCTV 영상 생성기 ----------------\n", + "# 실제 영상 대신 코드로 그린 객체(사각형·점)로 기지 외곽 고정 카메라 화면을 흉내 낸다.\n", + "H, W, T = 64, 96, 10 # 화면 크기(px), 시퀀스 길이(프레임)\n", + "HORIZON, FENCE = 20, 46 # 하늘/지면 경계, 울타리 행\n", + "ROI_ROWS = (32, 48) # 울타리 접근 경계구역(ROI): 지면 32~47행\n", + "CLASSES = [\"person\", \"vehicle\", \"drone\", \"animal\", \"bird\"]\n", + "INTRUDER = {\"person\", \"vehicle\", \"drone\"} # 비인가 접근(경보 대상)\n", + "CONDS = [\"day\", \"night\", \"rain\", \"fog\"]\n", + "\n", + "\n", + "def background(rng):\n", + " # [TODO : 학생 실습 3] 기지 외곽 울타리 접근 객체 탐지 Baseline 비전 모델 구현 관련 핵심 로직을 구현하세요.\n", + " # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n", + " # <<< 여기에 코드를 작성하세요 >>>\n", + " pass # 작성 후 pass를 제거하세요.\n", + "def tree(img, t, wind):\n", + " \"\"\"좌측 수목 수관: 바람 세기에 따라 좌우로 흔들려 오경보를 만든다.\"\"\"\n", + " # [TODO : 학생 실습 3] 기지 외곽 울타리 접근 객체 탐지 Baseline 비전 모델 구현 관련 핵심 로직을 구현하세요.\n", + " # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n", + " # <<< 여기에 코드를 작성하세요 >>>\n", + " pass # 작성 후 pass를 제거하세요.\n", + "def make_seq(seed, cond=None, objs=None, wind=None, camo=False):\n", + " rng = np.random.default_rng(seed)\n", + " cond = cond or str(rng.choice(CONDS))\n", + " wind = rng.uniform(0, 2) if wind is None else wind\n", + " bg = background(rng)\n", + " n = int(rng.integers(1, 4)) if objs is None else len(objs)\n", + " kinds = objs or list(rng.choice(CLASSES, n, p=[0.3, 0.15, 0.25, 0.15, 0.15]))\n", + " obs = []\n", + " for k in kinds:\n", + " if k == \"person\":\n", + " o = dict(y=rng.uniform(30, 36), x=rng.uniform(15, 85), vy=rng.uniform(0.8, 1.2), vx=rng.uniform(-0.6, 0.6), h=7, w=3,\n", + " val=72 if camo else rng.uniform(35, 50))\n", + " elif k == \"vehicle\":\n", + " o = dict(y=rng.uniform(33, 38), x=rng.uniform(10, 30), vy=0.2, vx=rng.uniform(2, 4), h=5, w=10, val=rng.uniform(150, 170))\n", + " elif k == \"drone\":\n", + " o = dict(y=rng.uniform(3, 15), x=rng.uniform(10, 80), vy=rng.uniform(0, 0.6), vx=rng.uniform(-2, 2), h=2, w=2 if camo else 3,\n", + " val=rng.uniform(80, 120))\n", + " elif k == \"animal\":\n", + " o = dict(y=rng.uniform(34, 42), x=rng.uniform(15, 80), vy=0, vx=rng.uniform(-1.5, 1.5), h=2, w=4, val=rng.uniform(60, 75))\n", + " else:\n", + " o = dict(y=rng.uniform(3, 15), x=rng.uniform(10, 80), vy=0, vx=rng.uniform(-3, 3), h=1, w=2, val=rng.uniform(100, 130))\n", + " o[\"cls\"] = str(k); obs.append(o)\n", + " frames, labels = [], []\n", + " for t in range(-8, T): # 음수 시각 = 객체 없는 배경 학습 구간(사전 관측)\n", + " f = bg.copy(); tree(f, t, wind); lab = []\n", + " for oi, o in enumerate(obs if t >= 0 else []):\n", + " jit = rng.normal(0, 1.2, 2) if o[\"cls\"] in (\"animal\", \"bird\") else (0, 0) # 불규칙 움직임\n", + " y, x = int(o[\"y\"] + o[\"vy\"] * t + jit[0]), int(o[\"x\"] + o[\"vx\"] * t + jit[1])\n", + " y0, x0, y1, x1 = max(y, 0), max(x, 0), min(y + o[\"h\"], H), min(x + o[\"w\"], W)\n", + " if y1 > y0 and x1 > x0:\n", + " f[y0:y1, x0:x1] = o[\"val\"]\n", + " if o[\"cls\"] == \"person\":\n", + " f[max(y0 - 2, 0):y0, x0 + 1:x0 + 2] = o[\"val\"] # 머리\n", + " lab.append({\"cls\": o[\"cls\"], \"oid\": oi, \"bbox\": (x0, y0, x1, y1), \"intruder\": o[\"cls\"] in INTRUDER})\n", + " if cond == \"night\":\n", + " f = f * 0.5 + 10 + rng.normal(0, 3.5, f.shape)\n", + " else:\n", + " f = f + rng.normal(0, 2.5, f.shape)\n", + " if cond == \"rain\":\n", + " m = rng.random(f.shape) < 0.006\n", + " for d in range(3):\n", + " f[d:][m[:H - d]] += 45\n", + " if cond == \"fog\":\n", + " f = 0.45 * f + 0.55 * 150\n", + " frames.append(np.clip(f, 0, 255)); labels.append(lab)\n", + " fr = np.array(frames)\n", + " return {\"id\": f\"CAM-{seed:04d}\", \"cond\": cond, \"wind\": wind, \"ref\": fr[:8], \"frames\": fr[8:], \"labels\": labels[8:]}\n", + "\n", + "# ---------------- 전처리·탐지 공통 함수(배경 차분 기반) ----------------\n", + "def enhance(seq):\n", + " \"\"\"조도 보정(백분위 신장) + 비 조건의 빗줄기 제거(3x3 중앙값). 배경 학습 구간(ref)과 같은 기준으로 보정한다.\"\"\"\n", + " allf = np.concatenate([seq[\"ref\"], seq[\"frames\"]])\n", + " f = ndimage.median_filter(allf, size=(1, 3, 3)) if seq[\"cond\"] == \"rain\" else allf\n", + " lo, hi = np.percentile(f, [1, 99.5])\n", + " f = np.clip((f - lo) / (hi - lo + 1e-6) * 255, 0, 255)\n", + " return f[:len(seq[\"ref\"])], f[len(seq[\"ref\"]):]\n", + "\n", + "\n", + "def detect(ref, frames, k=4.0, min_area=3, sky_k=None, smooth=False):\n", + " \"\"\"배경 학습 구간의 중앙값 배경과 차이를 강건 임계값(k x MAD)으로 이진화해 연결 성분을 객체 후보로 만든다.\n", + " smooth=True 이면 연속 3프레임 평균으로 시간 잡음을 줄여 저대비 객체를 살린다.\"\"\"\n", + " bg = np.median(ref, axis=0)\n", + " if smooth:\n", + " frames = ndimage.uniform_filter1d(frames, 3, axis=0, mode=\"nearest\")\n", + " out = []\n", + " for f in frames:\n", + " d = np.abs(f - bg)\n", + " mad = np.median(d) * 1.4826 + 1e-6\n", + " thr = np.full(d.shape, k * mad)\n", + " if sky_k is not None:\n", + " thr[:HORIZON] = sky_k * mad\n", + " lab, n = ndimage.label(d > thr)\n", + " dets = []\n", + " for i, sl in enumerate(ndimage.find_objects(lab), start=1):\n", + " area = int((lab[sl] == i).sum())\n", + " if area >= min_area:\n", + " dets.append({\"bbox\": (sl[1].start, sl[0].start, sl[1].stop, sl[0].stop), \"area\": area,\n", + " \"contrast\": float(d[sl][lab[sl] == i].mean() / mad)})\n", + " out.append(dets)\n", + " return out\n", + "\n", + "\n", + "def center(b):\n", + " return ((b[0] + b[2]) / 2, (b[1] + b[3]) / 2)\n", + "\n", + "\n", + "def match(dets, labels, dist=4.0):\n", + " \"\"\"탐지와 정답을 중심 거리로 1:1 대응시킨다. 반환: [(det, label or None)], 놓친 label 목록\"\"\"\n", + " used, pairs = set(), []\n", + " for d in dets:\n", + " cd = center(d[\"bbox\"]); best, bj = dist, None\n", + " for j, l in enumerate(labels):\n", + " cl = center(l[\"bbox\"]); e = np.hypot(cd[0] - cl[0], cd[1] - cl[1])\n", + " if j not in used and e < best:\n", + " best, bj = e, j\n", + " if bj is not None:\n", + " used.add(bj)\n", + " pairs.append((d, labels[bj] if bj is not None else None))\n", + " return pairs, [l for j, l in enumerate(labels) if j not in used]\n", + "\n", + "\n", + "def in_roi(b):\n", + " cy = (b[1] + b[3]) / 2\n", + " return cy < HORIZON or ROI_ROWS[0] <= cy < ROI_ROWS[1]\n", + "\n", + "\n", + "def rule_class(b, area):\n", + " \"\"\"Baseline 규칙: 위치(하늘/지면)·크기·종횡비로 1차 분류(학습 없음, 판단 근거가 그대로 드러난다)\"\"\"\n", + " w, h = b[2] - b[0], b[3] - b[1]\n", + " cy = (b[1] + b[3]) / 2\n", + " if cy < HORIZON:\n", + " return \"drone\" if 3 <= area <= 12 else \"clutter\"\n", + " if area >= 30 and w > h:\n", + " return \"vehicle\"\n", + " if h >= w and 6 <= area <= 30:\n", + " return \"person\"\n", + " return \"clutter\"\n", + "\n", + "# 판단 기준(투명성): ① 학습된 배경과 밝기 차이가 잡음의 4배(k=4 x MAD) 이상인 픽셀 덩어리 ② 3픽셀 이상\n", + "# ③ 하늘 영역 3~12픽셀 = 드론, 지면의 가로로 긴 30픽셀 이상 = 차량, 세로로 긴 6~30픽셀 = 인원\n", + "# ④ 경계구역(ROI: 하늘 + 울타리 접근 구역) 안의 인원·차량·드론만 경보\n", + "rows, fa_src = [], {}\n", + "for s in range(500, 540):\n", + " seq = make_seq(s)\n", + " ref, fr = enhance(seq)\n", + " for t, (ds, labs) in enumerate(zip(detect(ref, fr), seq[\"labels\"])):\n", + " pairs, missed = match(ds, labs)\n", + " for d, l in pairs:\n", + " pred = rule_class(d[\"bbox\"], d[\"area\"])\n", + " alarm = pred in INTRUDER and in_roi(d[\"bbox\"])\n", + " truth = l[\"cls\"] if l else \"clutter\"\n", + " rows.append({\"cond\": seq[\"cond\"], \"truth\": truth, \"pred\": pred, \"alarm\": alarm})\n", + " for l in missed:\n", + " rows.append({\"cond\": seq[\"cond\"], \"truth\": l[\"cls\"], \"pred\": \"missed\", \"alarm\": False})\n", + "df = pd.DataFrame(rows)\n", + "intr = df[df[\"truth\"].isin(INTRUDER)]\n", + "acc = (intr[\"pred\"] == intr[\"truth\"]).mean()\n", + "det_rate = intr[\"alarm\"].mean()\n", + "fa = df[df[\"alarm\"] & ~df[\"truth\"].isin(INTRUDER)]\n", + "print(f\"[Baseline] 합성 CCTV 시퀀스 40개(프레임 {40 * T}장), 침입 객체 출현 {len(intr)}회\")\n", + "print(f\"침입 객체 탐지·분류 정확도 {acc:.1%} (목표 75% 이상) -> {'달성' if acc >= 0.75 else '미달'}\")\n", + "print(f\"침입 경보 발생률 {det_rate:.1%}, 오경보 {len(fa)}건 (원인: {fa['truth'].value_counts().to_dict()})\")\n", + "print(\"\\n[정체별 Baseline 판정] (행: 실제, 열: 판정)\")\n", + "print(pd.crosstab(df[\"truth\"], df[\"pred\"]).to_string())\n", + "print(\"\\n[조건별 침입 객체 정확도]\")\n", + "print(intr.groupby(\"cond\").apply(lambda g: f\"{(g['pred'] == g['truth']).mean():.1%} ({len(g)}회)\").to_string())\n", + "print(\"한계: 규칙은 위장 인원·소형 드론(저대비·소면적)과 수목·동물 오경보에 약하다 -> 미션 4·5에서 보완한다.\")\n", + "\n", + "seq = make_seq(503, cond=\"day\", objs=[\"person\", \"vehicle\", \"drone\", \"animal\"])\n", + "ref, fr = enhance(seq)\n", + "ds = detect(ref, fr)[6]\n", + "fig, ax = plt.subplots(1, 2, figsize=(11, 4))\n", + "ax[0].imshow(seq[\"frames\"][6], cmap=\"gray\", vmin=0, vmax=255); ax[0].set_title(\"Synthetic CCTV frame (training data)\")\n", + "ax[1].imshow(fr[6], cmap=\"gray\")\n", + "ax[1].axhspan(ROI_ROWS[0], ROI_ROWS[1], color=\"yellow\", alpha=0.15)\n", + "for d in ds:\n", + " b, p = d[\"bbox\"], rule_class(d[\"bbox\"], d[\"area\"])\n", + " ax[1].add_patch(plt.Rectangle((b[0] - .5, b[1] - .5), b[2] - b[0], b[3] - b[1], fill=False, ec=\"red\" if p in INTRUDER else \"gray\"))\n", + " ax[1].text(b[0], b[1] - 1, p, color=\"red\" if p in INTRUDER else \"gray\", fontsize=8)\n", + "ax[1].set_title(\"Baseline detections (yellow = fence ROI)\")\n", + "plt.tight_layout(); plt.show()\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 03 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + "[Baseline] 합성 CCTV 시퀀스 40개(프레임 400장), 침입 객체 출현 530회\n", + "침입 객체 탐지·분류 정확도 91.7% (목표 75% 이상) -> 달성\n", + "침입 경보 발생률 90.6%, 오경보 109건 (원인: {'clutter': 107, 'animal': 2})\n", + "\n", + "[정체별 Baseline 판정] (행: 실제, 열: 판정)\n", + "pred clutter drone missed person vehicle\n", + "truth \n", + "animal 92 0 16 2 0\n", + "bird 0 0 110 0 0\n", + "clutter 901 94 0 93 0\n", + "drone 0 134 36 0 0\n", + "person 6 0 1 243 0\n", + "vehicle 0 0 1 0 109\n", + "\n", + "[조건별 침입 객체 정확도]\n", + "cond\n", + "day 99.0% (100회)\n", + "fog 100.0% (130회)\n", + "night 99.1% (110회)\n", + "rain 77.9% (190회)\n", + "한계: 규칙은 위장 인원·소형 드론(저대비·소면적)과 수목·동물 오경보에 약하다 -> 미션 4·5에서 보완한다.\n", + "```\n", + "
\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 04. 저고도 침투 소형 드론 및 은폐 침입자 정밀 식별 복합 비전 모델 고도화\n", + "**소요시간**: 약 3.0시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "소형 저고도 드론 및 위장 침입자를 정밀 식별하는 복합 딥러닝 탐지 모델을 고도화한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **저고도 소형 드론과 위장(저대비) 침입자까지 식별하도록 프레임 간 추적과 궤적 특징 기반 복합 탐지 모델로 고도화한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **하늘 영역 민감도를 높인 탐지로 소형 객체를 살리고, 최근접 이웃 추적으로 궤적을 만들어 크기·종횡비·속도·직진성·대비 특징을 계산한 뒤 랜덤포레스트로 분류 수행하고 검증하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `src/models/sec01_advanced_detector.py 및 예외 탐지 테스트 결과` 형태의 결과물을 완성한다.\n", + "\n", + "### 🔍 평가 및 검증 기준 (PASS 조건)\n", + "#### 미션 판정\n", + "\n", + "**평가 구분:** 필수\n", + "\n", + "**핵심 평가영역:** 기만/교란 공격에 대한 방어 로직의 현실성 및 신뢰성\n", + "\n", + "#### 미션 PASS 조건\n", + "\n", + "- 정량 기준: 복합 위협 탐지 재현율(Recall) 85% 이상, 처리 지연 200ms 이하\n", + "\n", + "\n", + "- 진행 기준: 복합 위협 시나리오 10종에 대한 탐지 테스트 통과\n", + "\n", + "\n", + "- 검증 방법: 합성 위협 데이터셋 주입 시 이상치 탐지 정확도 검증\n", + "\n", + "\n", + "#### 채점자 판정 메모\n", + "\n", + "> 단순 코드 실행 여부뿐만 아니라 출력된 수치와 근거 자료가 군 실무 요구사항에 부합하는지 면밀히 확인한다.\n", + "\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "\"\"\"저고도 소형 드론과 위장(저대비) 침입자를 식별하도록, 하늘 영역 민감도 상향·프레임 간 추적·궤적 특징 기반 랜덤포레스트 분류를 결합한 복합 탐지 모델로 고도화하고, 객체 단위 재현율과 프레임당 처리 지연을 Baseline과 비교한다.\"\"\"\n", + "import time\n", + "import numpy as np\n", + "import pandas as pd\n", + "from scipy import ndimage\n", + "from sklearn.ensemble import RandomForestClassifier\n", + "\n", + "# ---------------- [교육용가상자료] 합성 CCTV 영상 생성기 ----------------\n", + "# 실제 영상 대신 코드로 그린 객체(사각형·점)로 기지 외곽 고정 카메라 화면을 흉내 낸다.\n", + "H, W, T = 64, 96, 10 # 화면 크기(px), 시퀀스 길이(프레임)\n", + "HORIZON, FENCE = 20, 46 # 하늘/지면 경계, 울타리 행\n", + "ROI_ROWS = (32, 48) # 울타리 접근 경계구역(ROI): 지면 32~47행\n", + "CLASSES = [\"person\", \"vehicle\", \"drone\", \"animal\", \"bird\"]\n", + "INTRUDER = {\"person\", \"vehicle\", \"drone\"} # 비인가 접근(경보 대상)\n", + "CONDS = [\"day\", \"night\", \"rain\", \"fog\"]\n", + "\n", + "\n", + "def background(rng):\n", + " # [TODO : 학생 실습 4] 저고도 침투 소형 드론 및 은폐 침입자 정밀 식별 복합 비전 모델 고도화 관련 핵심 로직을 구현하세요.\n", + " # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n", + " # <<< 여기에 코드를 작성하세요 >>>\n", + " pass # 작성 후 pass를 제거하세요.\n", + "def tree(img, t, wind):\n", + " \"\"\"좌측 수목 수관: 바람 세기에 따라 좌우로 흔들려 오경보를 만든다.\"\"\"\n", + " # [TODO : 학생 실습 4] 저고도 침투 소형 드론 및 은폐 침입자 정밀 식별 복합 비전 모델 고도화 관련 핵심 로직을 구현하세요.\n", + " # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n", + " # <<< 여기에 코드를 작성하세요 >>>\n", + " pass # 작성 후 pass를 제거하세요.\n", + "def make_seq(seed, cond=None, objs=None, wind=None, camo=False):\n", + " rng = np.random.default_rng(seed)\n", + " cond = cond or str(rng.choice(CONDS))\n", + " wind = rng.uniform(0, 2) if wind is None else wind\n", + " bg = background(rng)\n", + " n = int(rng.integers(1, 4)) if objs is None else len(objs)\n", + " kinds = objs or list(rng.choice(CLASSES, n, p=[0.3, 0.15, 0.25, 0.15, 0.15]))\n", + " obs = []\n", + " for k in kinds:\n", + " if k == \"person\":\n", + " o = dict(y=rng.uniform(30, 36), x=rng.uniform(15, 85), vy=rng.uniform(0.8, 1.2), vx=rng.uniform(-0.6, 0.6), h=7, w=3,\n", + " val=72 if camo else rng.uniform(35, 50))\n", + " elif k == \"vehicle\":\n", + " o = dict(y=rng.uniform(33, 38), x=rng.uniform(10, 30), vy=0.2, vx=rng.uniform(2, 4), h=5, w=10, val=rng.uniform(150, 170))\n", + " elif k == \"drone\":\n", + " o = dict(y=rng.uniform(3, 15), x=rng.uniform(10, 80), vy=rng.uniform(0, 0.6), vx=rng.uniform(-2, 2), h=2, w=2 if camo else 3,\n", + " val=rng.uniform(80, 120))\n", + " elif k == \"animal\":\n", + " o = dict(y=rng.uniform(34, 42), x=rng.uniform(15, 80), vy=0, vx=rng.uniform(-1.5, 1.5), h=2, w=4, val=rng.uniform(60, 75))\n", + " else:\n", + " o = dict(y=rng.uniform(3, 15), x=rng.uniform(10, 80), vy=0, vx=rng.uniform(-3, 3), h=1, w=2, val=rng.uniform(100, 130))\n", + " o[\"cls\"] = str(k); obs.append(o)\n", + " frames, labels = [], []\n", + " for t in range(-8, T): # 음수 시각 = 객체 없는 배경 학습 구간(사전 관측)\n", + " f = bg.copy(); tree(f, t, wind); lab = []\n", + " for oi, o in enumerate(obs if t >= 0 else []):\n", + " jit = rng.normal(0, 1.2, 2) if o[\"cls\"] in (\"animal\", \"bird\") else (0, 0) # 불규칙 움직임\n", + " y, x = int(o[\"y\"] + o[\"vy\"] * t + jit[0]), int(o[\"x\"] + o[\"vx\"] * t + jit[1])\n", + " y0, x0, y1, x1 = max(y, 0), max(x, 0), min(y + o[\"h\"], H), min(x + o[\"w\"], W)\n", + " if y1 > y0 and x1 > x0:\n", + " f[y0:y1, x0:x1] = o[\"val\"]\n", + " if o[\"cls\"] == \"person\":\n", + " f[max(y0 - 2, 0):y0, x0 + 1:x0 + 2] = o[\"val\"] # 머리\n", + " lab.append({\"cls\": o[\"cls\"], \"oid\": oi, \"bbox\": (x0, y0, x1, y1), \"intruder\": o[\"cls\"] in INTRUDER})\n", + " if cond == \"night\":\n", + " f = f * 0.5 + 10 + rng.normal(0, 3.5, f.shape)\n", + " else:\n", + " f = f + rng.normal(0, 2.5, f.shape)\n", + " if cond == \"rain\":\n", + " m = rng.random(f.shape) < 0.006\n", + " for d in range(3):\n", + " f[d:][m[:H - d]] += 45\n", + " if cond == \"fog\":\n", + " f = 0.45 * f + 0.55 * 150\n", + " frames.append(np.clip(f, 0, 255)); labels.append(lab)\n", + " fr = np.array(frames)\n", + " return {\"id\": f\"CAM-{seed:04d}\", \"cond\": cond, \"wind\": wind, \"ref\": fr[:8], \"frames\": fr[8:], \"labels\": labels[8:]}\n", + "\n", + "# ---------------- 전처리·탐지 공통 함수(배경 차분 기반) ----------------\n", + "def enhance(seq):\n", + " \"\"\"조도 보정(백분위 신장) + 비 조건의 빗줄기 제거(3x3 중앙값). 배경 학습 구간(ref)과 같은 기준으로 보정한다.\"\"\"\n", + " allf = np.concatenate([seq[\"ref\"], seq[\"frames\"]])\n", + " f = ndimage.median_filter(allf, size=(1, 3, 3)) if seq[\"cond\"] == \"rain\" else allf\n", + " lo, hi = np.percentile(f, [1, 99.5])\n", + " f = np.clip((f - lo) / (hi - lo + 1e-6) * 255, 0, 255)\n", + " return f[:len(seq[\"ref\"])], f[len(seq[\"ref\"]):]\n", + "\n", + "\n", + "def detect(ref, frames, k=4.0, min_area=3, sky_k=None, smooth=False):\n", + " \"\"\"배경 학습 구간의 중앙값 배경과 차이를 강건 임계값(k x MAD)으로 이진화해 연결 성분을 객체 후보로 만든다.\n", + " smooth=True 이면 연속 3프레임 평균으로 시간 잡음을 줄여 저대비 객체를 살린다.\"\"\"\n", + " bg = np.median(ref, axis=0)\n", + " if smooth:\n", + " frames = ndimage.uniform_filter1d(frames, 3, axis=0, mode=\"nearest\")\n", + " out = []\n", + " for f in frames:\n", + " d = np.abs(f - bg)\n", + " mad = np.median(d) * 1.4826 + 1e-6\n", + " thr = np.full(d.shape, k * mad)\n", + " if sky_k is not None:\n", + " thr[:HORIZON] = sky_k * mad\n", + " lab, n = ndimage.label(d > thr)\n", + " dets = []\n", + " for i, sl in enumerate(ndimage.find_objects(lab), start=1):\n", + " area = int((lab[sl] == i).sum())\n", + " if area >= min_area:\n", + " dets.append({\"bbox\": (sl[1].start, sl[0].start, sl[1].stop, sl[0].stop), \"area\": area,\n", + " \"contrast\": float(d[sl][lab[sl] == i].mean() / mad)})\n", + " out.append(dets)\n", + " return out\n", + "\n", + "\n", + "def center(b):\n", + " return ((b[0] + b[2]) / 2, (b[1] + b[3]) / 2)\n", + "\n", + "\n", + "def match(dets, labels, dist=4.0):\n", + " \"\"\"탐지와 정답을 중심 거리로 1:1 대응시킨다. 반환: [(det, label or None)], 놓친 label 목록\"\"\"\n", + " used, pairs = set(), []\n", + " for d in dets:\n", + " cd = center(d[\"bbox\"]); best, bj = dist, None\n", + " for j, l in enumerate(labels):\n", + " cl = center(l[\"bbox\"]); e = np.hypot(cd[0] - cl[0], cd[1] - cl[1])\n", + " if j not in used and e < best:\n", + " best, bj = e, j\n", + " if bj is not None:\n", + " used.add(bj)\n", + " pairs.append((d, labels[bj] if bj is not None else None))\n", + " return pairs, [l for j, l in enumerate(labels) if j not in used]\n", + "\n", + "\n", + "def in_roi(b):\n", + " cy = (b[1] + b[3]) / 2\n", + " return cy < HORIZON or ROI_ROWS[0] <= cy < ROI_ROWS[1]\n", + "\n", + "\n", + "def rule_class(b, area):\n", + " \"\"\"Baseline 규칙: 위치(하늘/지면)·크기·종횡비로 1차 분류(학습 없음, 판단 근거가 그대로 드러난다)\"\"\"\n", + " w, h = b[2] - b[0], b[3] - b[1]\n", + " cy = (b[1] + b[3]) / 2\n", + " if cy < HORIZON:\n", + " return \"drone\" if 3 <= area <= 12 else \"clutter\"\n", + " if area >= 30 and w > h:\n", + " return \"vehicle\"\n", + " if h >= w and 6 <= area <= 30:\n", + " return \"person\"\n", + " return \"clutter\"\n", + "\n", + "# ---------------- 프레임 간 추적과 궤적 특징 ----------------\n", + "def track(dets, gate=6.0):\n", + " \"\"\"최근접 이웃으로 프레임별 탐지를 궤적으로 잇는다.\"\"\"\n", + " tracks = []\n", + " for t, ds in enumerate(dets):\n", + " free = list(range(len(ds)))\n", + " for tr in tracks:\n", + " if tr[\"last\"] != t - 1 or not free:\n", + " continue\n", + " c0 = center(tr[\"b\"][-1])\n", + " dd = [np.hypot(center(ds[i][\"bbox\"])[0] - c0[0], center(ds[i][\"bbox\"])[1] - c0[1]) for i in free]\n", + " j = int(np.argmin(dd))\n", + " if dd[j] < gate:\n", + " i = free.pop(j); tr[\"b\"].append(ds[i][\"bbox\"]); tr[\"a\"].append(ds[i][\"area\"]); tr[\"c\"].append(ds[i][\"contrast\"])\n", + " tr[\"t\"].append(t); tr[\"last\"] = t\n", + " for i in free:\n", + " tracks.append({\"b\": [ds[i][\"bbox\"]], \"a\": [ds[i][\"area\"]], \"c\": [ds[i][\"contrast\"]], \"t\": [t], \"last\": t})\n", + " return tracks\n", + "\n", + "\n", + "TFEATS = [\"n\", \"area\", \"aspect\", \"row\", \"sky\", \"speed\", \"straight\", \"disp\", \"contrast\", \"area_cv\", \"col\"]\n", + "\n", + "\n", + "def track_features(tr):\n", + " c = np.array([center(b) for b in tr[\"b\"]])\n", + " wh = np.array([(b[2] - b[0], b[3] - b[1]) for b in tr[\"b\"]])\n", + " steps = np.hypot(*np.diff(c, axis=0).T) if len(c) > 1 else np.array([0.0])\n", + " disp = float(np.hypot(*(c[-1] - c[0])))\n", + " return {\"n\": len(c), \"area\": float(np.mean(tr[\"a\"])), \"aspect\": float(np.mean(wh[:, 1] / wh[:, 0])), \"row\": float(c[:, 1].mean()),\n", + " \"sky\": float(c[:, 1].mean() < HORIZON), \"speed\": float(steps.mean()), \"straight\": disp / (steps.sum() + 1e-6),\n", + " \"disp\": disp, \"contrast\": float(np.mean(tr[\"c\"])), \"area_cv\": float(np.std(tr[\"a\"]) / (np.mean(tr[\"a\"]) + 1e-6)),\n", + " \"col\": float(c[:, 0].mean())}\n", + "\n", + "\n", + "def track_truth(tr, labels):\n", + " \"\"\"궤적의 각 프레임을 정답과 대응시켜 다수결로 정체를 정한다(없으면 clutter).\"\"\"\n", + " votes = []\n", + " for b, t in zip(tr[\"b\"], tr[\"t\"]):\n", + " pairs, _ = match([{\"bbox\": b}], labels[t])\n", + " votes.append((pairs[0][1][\"cls\"], pairs[0][1][\"oid\"]) if pairs[0][1] else (\"clutter\", -1))\n", + " return max(set(votes), key=votes.count)\n", + "\n", + "\n", + "def seq_tracks(seq, k=4.0, min_area=3, sky_k=None, smooth=False):\n", + " ref, fr = enhance(seq)\n", + " tracks = track(detect(ref, fr, k, min_area, sky_k, smooth))\n", + " rows = []\n", + " for tr in tracks:\n", + " f = track_features(tr); f[\"truth\"], f[\"oid\"] = track_truth(tr, seq[\"labels\"]); f[\"seq\"] = seq[\"id\"]; f[\"cond\"] = seq[\"cond\"]\n", + " f[\"tr\"] = tr; rows.append(f)\n", + " return rows\n", + "\n", + "ADV = {\"k\": 3.0, \"min_area\": 2, \"sky_k\": 2.5} # 고도화 탐지 파라미터(저대비 객체 대응)\n", + "\n", + "\n", + "def make_set(seeds, camo_ratio):\n", + " return [make_seq(s, camo=(i % 10) < camo_ratio * 10) for i, s in enumerate(seeds)]\n", + "\n", + "\n", + "train = make_set(range(0, 80), 0.5)\n", + "tr = pd.DataFrame(sum([seq_tracks(q, **ADV) for q in train], []))\n", + "clf = RandomForestClassifier(n_estimators=150, min_samples_leaf=2, random_state=0).fit(tr[TFEATS], tr[\"truth\"])\n", + "\n", + "\n", + "def objects(seqs):\n", + " return {(q[\"id\"], l[\"oid\"]): l[\"cls\"] for q in seqs for labs in q[\"labels\"] for l in labs if l[\"intruder\"]}\n", + "\n", + "\n", + "def baseline_found(seqs):\n", + " found = set()\n", + " for q in seqs:\n", + " ref, fr = enhance(q)\n", + " hits = {}\n", + " for ds, labs in zip(detect(ref, fr), q[\"labels\"]):\n", + " for d, l in match(ds, labs)[0]:\n", + " if l and l[\"intruder\"] and rule_class(d[\"bbox\"], d[\"area\"]) == l[\"cls\"]:\n", + " hits[l[\"oid\"]] = hits.get(l[\"oid\"], 0) + 1\n", + " found |= {(q[\"id\"], o) for o, c in hits.items() if c >= 2}\n", + " return found\n", + "\n", + "\n", + "def advanced_found(seqs):\n", + " found, ms = set(), []\n", + " for q in seqs:\n", + " t0 = time.perf_counter()\n", + " rows = seq_tracks(q, **ADV)\n", + " if rows:\n", + " pred = clf.predict(pd.DataFrame(rows)[TFEATS])\n", + " ms.append((time.perf_counter() - t0) * 1000 / T)\n", + " for r, p in zip(rows, pred if rows else []):\n", + " if p in INTRUDER and r[\"n\"] >= 2 and r[\"oid\"] >= 0 and r[\"truth\"] in INTRUDER:\n", + " found.add((q[\"id\"], r[\"oid\"]))\n", + " return found, ms\n", + "\n", + "\n", + "res = []\n", + "for name, seqs in ((\"일반\", make_set(range(600, 630), 0.0)), (\"위장·소형\", make_set(range(700, 720), 1.0))):\n", + " obj = objects(seqs)\n", + " fb = baseline_found(seqs) & set(obj)\n", + " fa, ms = advanced_found(seqs)\n", + " for cls in (\"person\", \"vehicle\", \"drone\", \"전체\"):\n", + " keys = [k for k, c in obj.items() if cls in (c, \"전체\")]\n", + " if keys:\n", + " res.append({\"시험셋\": name, \"정체\": cls, \"객체 수\": len(keys), \"Baseline 재현율\": np.mean([k in fb for k in keys]),\n", + " \"고도화 재현율\": np.mean([k in fa for k in keys])})\n", + " res[-1][\"지연(ms/프레임)\"] = max(ms)\n", + "out = pd.DataFrame(res)\n", + "print(\"[객체 단위 재현율 비교] (2프레임 이상 올바르게 식별되면 탐지로 인정)\")\n", + "print(out.round(3).fillna(\"-\").to_string(index=False))\n", + "rec = out[out[\"정체\"] == \"전체\"][\"고도화 재현율\"].min()\n", + "lat = out[\"지연(ms/프레임)\"].dropna().max()\n", + "imp = pd.Series(clf.feature_importances_, index=TFEATS).sort_values(ascending=False)\n", + "print(\"\\n궤적 특징 중요도 상위:\", \", \".join(f\"{k} {v:.2f}\" for k, v in imp.head(5).items()))\n", + "print(f\"\\n복합 위협 탐지 재현율(최저 시험셋) {rec:.1%} (목표 85% 이상) -> {'달성' if rec >= 0.85 else '미달'}\")\n", + "print(f\"처리 지연 최대 {lat:.1f} ms/프레임 (목표 200 ms 이하) -> {'달성' if lat <= 200 else '미달'}\")\n", + "print(\"기만 대응: 한 프레임만 나타나는 섬광·빗줄기는 2프레임 이상 지속 조건으로 걸러지며, 최종 확인은 경계병 육안 확인(미션 7)으로 한다.\")\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 04 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + "[객체 단위 재현율 비교] (2프레임 이상 올바르게 식별되면 탐지로 인정)\n", + " 시험셋 정체 객체 수 Baseline 재현율 고도화 재현율 지연(ms/프레임)\n", + " 일반 person 18 1.000 1.000 -\n", + " 일반 vehicle 9 1.000 1.000 -\n", + " 일반 drone 21 0.952 1.000 -\n", + " 일반 전체 48 0.979 1.000 9.738\n", + "위장·소형 person 11 0.727 0.909 -\n", + "위장·소형 vehicle 7 1.000 0.857 -\n", + "위장·소형 drone 9 0.889 1.000 -\n", + "위장·소형 전체 27 0.852 0.926 25.494\n", + "\n", + "궤적 특징 중요도 상위: n 0.18, disp 0.14, row 0.12, area 0.10, aspect 0.10\n", + "\n", + "복합 위협 탐지 재현율(최저 시험셋) 92.6% (목표 85% 이상) -> 달성\n", + "처리 지연 최대 25.5 ms/프레임 (목표 200 ms 이하) -> 달성\n", + "기만 대응: 한 프레임만 나타나는 섬광·빗줄기는 2프레임 이상 지속 조건으로 걸러지며, 최종 확인은 경계병 육안 확인(미션 7)으로 한다.\n", + "```\n", + "
\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 05. 수목 흔들림/야생동물 오경보 필터링 및 침입 위험 신뢰도 점수 엔진 개발\n", + "**소요시간**: 약 3.0시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "수목 흔들림, 야생동물 등으로 인한 오경보를 필터링하고 침입 위험 신뢰도 점수를 산출하는 엔진을 개발한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **수목 흔들림과 야생동물·조류로 인한 오경보를 걸러내고 침입 위험 신뢰도 점수와 등급을 산출하는 엔진을 개발한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **궤적 특징으로 로지스틱 회귀 기반 침입 확률을 계산해 0.8 이상 High, 0.5 이상 Medium, 그 미만 Low 등급을 부여 수행하고 검증하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `src/verification/sec01_confidence_engine.py` 형태의 결과물을 완성한다.\n", + "\n", + "### 🔍 평가 및 검증 기준 (PASS 조건)\n", + "#### 미션 판정\n", + "\n", + "**평가 구분:** 필수\n", + "\n", + "**핵심 평가영역:** 신뢰도 등급(High/Med/Low) 부여 기준의 군사적 타당성\n", + "\n", + "#### 미션 PASS 조건\n", + "\n", + "- 정량 기준: 오경보율(FAR) 40% 이상 감축, 신뢰도 스코어 산출 일관성 90% 이상\n", + "\n", + "\n", + "- 진행 기준: 오경보 필터링 전후 비교 지표 산출 및 통합 테스트 완료\n", + "\n", + "\n", + "- 검증 방법: ROC-AUC 곡선 분석 및 임계값 민감도 테스트\n", + "\n", + "\n", + "#### 채점자 판정 메모\n", + "\n", + "> 단순 코드 실행 여부뿐만 아니라 출력된 수치와 근거 자료가 군 실무 요구사항에 부합하는지 면밀히 확인한다.\n", + "\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "\"\"\"수목 흔들림·야생동물·조류로 인한 오경보를 걸러내기 위해 궤적 특징(크기·종횡비·속도·직진성·대비 등)으로 침입 위험 신뢰도 점수와 등급(High/Medium/Low)을 계산하고, 기존 방식 대비 오경보율(FAR) 감축·점수 일관성·ROC-AUC를 검증한다.\"\"\"\n", + "import numpy as np\n", + "import pandas as pd\n", + "import matplotlib.pyplot as plt\n", + "from scipy import ndimage\n", + "from sklearn.ensemble import RandomForestClassifier\n", + "from sklearn.linear_model import LogisticRegression\n", + "from sklearn.metrics import roc_auc_score, roc_curve\n", + "from sklearn.pipeline import make_pipeline\n", + "from sklearn.preprocessing import StandardScaler\n", + "\n", + "# ---------------- [교육용가상자료] 합성 CCTV 영상 생성기 ----------------\n", + "# 실제 영상 대신 코드로 그린 객체(사각형·점)로 기지 외곽 고정 카메라 화면을 흉내 낸다.\n", + "H, W, T = 64, 96, 10 # 화면 크기(px), 시퀀스 길이(프레임)\n", + "HORIZON, FENCE = 20, 46 # 하늘/지면 경계, 울타리 행\n", + "ROI_ROWS = (32, 48) # 울타리 접근 경계구역(ROI): 지면 32~47행\n", + "CLASSES = [\"person\", \"vehicle\", \"drone\", \"animal\", \"bird\"]\n", + "INTRUDER = {\"person\", \"vehicle\", \"drone\"} # 비인가 접근(경보 대상)\n", + "CONDS = [\"day\", \"night\", \"rain\", \"fog\"]\n", + "\n", + "\n", + "def background(rng):\n", + " # [TODO : 학생 실습 5] 수목 흔들림/야생동물 오경보 필터링 및 침입 위험 신뢰도 점수 엔진 개발 관련 핵심 로직을 구현하세요.\n", + " # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n", + " # <<< 여기에 코드를 작성하세요 >>>\n", + " pass # 작성 후 pass를 제거하세요.\n", + "def tree(img, t, wind):\n", + " \"\"\"좌측 수목 수관: 바람 세기에 따라 좌우로 흔들려 오경보를 만든다.\"\"\"\n", + " # [TODO : 학생 실습 5] 수목 흔들림/야생동물 오경보 필터링 및 침입 위험 신뢰도 점수 엔진 개발 관련 핵심 로직을 구현하세요.\n", + " # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n", + " # <<< 여기에 코드를 작성하세요 >>>\n", + " pass # 작성 후 pass를 제거하세요.\n", + "def make_seq(seed, cond=None, objs=None, wind=None, camo=False):\n", + " rng = np.random.default_rng(seed)\n", + " cond = cond or str(rng.choice(CONDS))\n", + " wind = rng.uniform(0, 2) if wind is None else wind\n", + " bg = background(rng)\n", + " n = int(rng.integers(1, 4)) if objs is None else len(objs)\n", + " kinds = objs or list(rng.choice(CLASSES, n, p=[0.3, 0.15, 0.25, 0.15, 0.15]))\n", + " obs = []\n", + " for k in kinds:\n", + " if k == \"person\":\n", + " o = dict(y=rng.uniform(30, 36), x=rng.uniform(15, 85), vy=rng.uniform(0.8, 1.2), vx=rng.uniform(-0.6, 0.6), h=7, w=3,\n", + " val=72 if camo else rng.uniform(35, 50))\n", + " elif k == \"vehicle\":\n", + " o = dict(y=rng.uniform(33, 38), x=rng.uniform(10, 30), vy=0.2, vx=rng.uniform(2, 4), h=5, w=10, val=rng.uniform(150, 170))\n", + " elif k == \"drone\":\n", + " o = dict(y=rng.uniform(3, 15), x=rng.uniform(10, 80), vy=rng.uniform(0, 0.6), vx=rng.uniform(-2, 2), h=2, w=2 if camo else 3,\n", + " val=rng.uniform(80, 120))\n", + " elif k == \"animal\":\n", + " o = dict(y=rng.uniform(34, 42), x=rng.uniform(15, 80), vy=0, vx=rng.uniform(-1.5, 1.5), h=2, w=4, val=rng.uniform(60, 75))\n", + " else:\n", + " o = dict(y=rng.uniform(3, 15), x=rng.uniform(10, 80), vy=0, vx=rng.uniform(-3, 3), h=1, w=2, val=rng.uniform(100, 130))\n", + " o[\"cls\"] = str(k); obs.append(o)\n", + " frames, labels = [], []\n", + " for t in range(-8, T): # 음수 시각 = 객체 없는 배경 학습 구간(사전 관측)\n", + " f = bg.copy(); tree(f, t, wind); lab = []\n", + " for oi, o in enumerate(obs if t >= 0 else []):\n", + " jit = rng.normal(0, 1.2, 2) if o[\"cls\"] in (\"animal\", \"bird\") else (0, 0) # 불규칙 움직임\n", + " y, x = int(o[\"y\"] + o[\"vy\"] * t + jit[0]), int(o[\"x\"] + o[\"vx\"] * t + jit[1])\n", + " y0, x0, y1, x1 = max(y, 0), max(x, 0), min(y + o[\"h\"], H), min(x + o[\"w\"], W)\n", + " if y1 > y0 and x1 > x0:\n", + " f[y0:y1, x0:x1] = o[\"val\"]\n", + " if o[\"cls\"] == \"person\":\n", + " f[max(y0 - 2, 0):y0, x0 + 1:x0 + 2] = o[\"val\"] # 머리\n", + " lab.append({\"cls\": o[\"cls\"], \"oid\": oi, \"bbox\": (x0, y0, x1, y1), \"intruder\": o[\"cls\"] in INTRUDER})\n", + " if cond == \"night\":\n", + " f = f * 0.5 + 10 + rng.normal(0, 3.5, f.shape)\n", + " else:\n", + " f = f + rng.normal(0, 2.5, f.shape)\n", + " if cond == \"rain\":\n", + " m = rng.random(f.shape) < 0.006\n", + " for d in range(3):\n", + " f[d:][m[:H - d]] += 45\n", + " if cond == \"fog\":\n", + " f = 0.45 * f + 0.55 * 150\n", + " frames.append(np.clip(f, 0, 255)); labels.append(lab)\n", + " fr = np.array(frames)\n", + " return {\"id\": f\"CAM-{seed:04d}\", \"cond\": cond, \"wind\": wind, \"ref\": fr[:8], \"frames\": fr[8:], \"labels\": labels[8:]}\n", + "\n", + "# ---------------- 전처리·탐지 공통 함수(배경 차분 기반) ----------------\n", + "def enhance(seq):\n", + " \"\"\"조도 보정(백분위 신장) + 비 조건의 빗줄기 제거(3x3 중앙값). 배경 학습 구간(ref)과 같은 기준으로 보정한다.\"\"\"\n", + " allf = np.concatenate([seq[\"ref\"], seq[\"frames\"]])\n", + " f = ndimage.median_filter(allf, size=(1, 3, 3)) if seq[\"cond\"] == \"rain\" else allf\n", + " lo, hi = np.percentile(f, [1, 99.5])\n", + " f = np.clip((f - lo) / (hi - lo + 1e-6) * 255, 0, 255)\n", + " return f[:len(seq[\"ref\"])], f[len(seq[\"ref\"]):]\n", + "\n", + "\n", + "def detect(ref, frames, k=4.0, min_area=3, sky_k=None, smooth=False):\n", + " \"\"\"배경 학습 구간의 중앙값 배경과 차이를 강건 임계값(k x MAD)으로 이진화해 연결 성분을 객체 후보로 만든다.\n", + " smooth=True 이면 연속 3프레임 평균으로 시간 잡음을 줄여 저대비 객체를 살린다.\"\"\"\n", + " bg = np.median(ref, axis=0)\n", + " if smooth:\n", + " frames = ndimage.uniform_filter1d(frames, 3, axis=0, mode=\"nearest\")\n", + " out = []\n", + " for f in frames:\n", + " d = np.abs(f - bg)\n", + " mad = np.median(d) * 1.4826 + 1e-6\n", + " thr = np.full(d.shape, k * mad)\n", + " if sky_k is not None:\n", + " thr[:HORIZON] = sky_k * mad\n", + " lab, n = ndimage.label(d > thr)\n", + " dets = []\n", + " for i, sl in enumerate(ndimage.find_objects(lab), start=1):\n", + " area = int((lab[sl] == i).sum())\n", + " if area >= min_area:\n", + " dets.append({\"bbox\": (sl[1].start, sl[0].start, sl[1].stop, sl[0].stop), \"area\": area,\n", + " \"contrast\": float(d[sl][lab[sl] == i].mean() / mad)})\n", + " out.append(dets)\n", + " return out\n", + "\n", + "\n", + "def center(b):\n", + " return ((b[0] + b[2]) / 2, (b[1] + b[3]) / 2)\n", + "\n", + "\n", + "def match(dets, labels, dist=4.0):\n", + " \"\"\"탐지와 정답을 중심 거리로 1:1 대응시킨다. 반환: [(det, label or None)], 놓친 label 목록\"\"\"\n", + " used, pairs = set(), []\n", + " for d in dets:\n", + " cd = center(d[\"bbox\"]); best, bj = dist, None\n", + " for j, l in enumerate(labels):\n", + " cl = center(l[\"bbox\"]); e = np.hypot(cd[0] - cl[0], cd[1] - cl[1])\n", + " if j not in used and e < best:\n", + " best, bj = e, j\n", + " if bj is not None:\n", + " used.add(bj)\n", + " pairs.append((d, labels[bj] if bj is not None else None))\n", + " return pairs, [l for j, l in enumerate(labels) if j not in used]\n", + "\n", + "\n", + "def in_roi(b):\n", + " cy = (b[1] + b[3]) / 2\n", + " return cy < HORIZON or ROI_ROWS[0] <= cy < ROI_ROWS[1]\n", + "\n", + "\n", + "def rule_class(b, area):\n", + " \"\"\"Baseline 규칙: 위치(하늘/지면)·크기·종횡비로 1차 분류(학습 없음, 판단 근거가 그대로 드러난다)\"\"\"\n", + " w, h = b[2] - b[0], b[3] - b[1]\n", + " cy = (b[1] + b[3]) / 2\n", + " if cy < HORIZON:\n", + " return \"drone\" if 3 <= area <= 12 else \"clutter\"\n", + " if area >= 30 and w > h:\n", + " return \"vehicle\"\n", + " if h >= w and 6 <= area <= 30:\n", + " return \"person\"\n", + " return \"clutter\"\n", + "\n", + "# ---------------- 프레임 간 추적과 궤적 특징 ----------------\n", + "def track(dets, gate=6.0):\n", + " \"\"\"최근접 이웃으로 프레임별 탐지를 궤적으로 잇는다.\"\"\"\n", + " tracks = []\n", + " for t, ds in enumerate(dets):\n", + " free = list(range(len(ds)))\n", + " for tr in tracks:\n", + " if tr[\"last\"] != t - 1 or not free:\n", + " continue\n", + " c0 = center(tr[\"b\"][-1])\n", + " dd = [np.hypot(center(ds[i][\"bbox\"])[0] - c0[0], center(ds[i][\"bbox\"])[1] - c0[1]) for i in free]\n", + " j = int(np.argmin(dd))\n", + " if dd[j] < gate:\n", + " i = free.pop(j); tr[\"b\"].append(ds[i][\"bbox\"]); tr[\"a\"].append(ds[i][\"area\"]); tr[\"c\"].append(ds[i][\"contrast\"])\n", + " tr[\"t\"].append(t); tr[\"last\"] = t\n", + " for i in free:\n", + " tracks.append({\"b\": [ds[i][\"bbox\"]], \"a\": [ds[i][\"area\"]], \"c\": [ds[i][\"contrast\"]], \"t\": [t], \"last\": t})\n", + " return tracks\n", + "\n", + "\n", + "TFEATS = [\"n\", \"area\", \"aspect\", \"row\", \"sky\", \"speed\", \"straight\", \"disp\", \"contrast\", \"area_cv\", \"col\"]\n", + "\n", + "\n", + "def track_features(tr):\n", + " c = np.array([center(b) for b in tr[\"b\"]])\n", + " wh = np.array([(b[2] - b[0], b[3] - b[1]) for b in tr[\"b\"]])\n", + " steps = np.hypot(*np.diff(c, axis=0).T) if len(c) > 1 else np.array([0.0])\n", + " disp = float(np.hypot(*(c[-1] - c[0])))\n", + " return {\"n\": len(c), \"area\": float(np.mean(tr[\"a\"])), \"aspect\": float(np.mean(wh[:, 1] / wh[:, 0])), \"row\": float(c[:, 1].mean()),\n", + " \"sky\": float(c[:, 1].mean() < HORIZON), \"speed\": float(steps.mean()), \"straight\": disp / (steps.sum() + 1e-6),\n", + " \"disp\": disp, \"contrast\": float(np.mean(tr[\"c\"])), \"area_cv\": float(np.std(tr[\"a\"]) / (np.mean(tr[\"a\"]) + 1e-6)),\n", + " \"col\": float(c[:, 0].mean())}\n", + "\n", + "\n", + "def track_truth(tr, labels):\n", + " \"\"\"궤적의 각 프레임을 정답과 대응시켜 다수결로 정체를 정한다(없으면 clutter).\"\"\"\n", + " votes = []\n", + " for b, t in zip(tr[\"b\"], tr[\"t\"]):\n", + " pairs, _ = match([{\"bbox\": b}], labels[t])\n", + " votes.append((pairs[0][1][\"cls\"], pairs[0][1][\"oid\"]) if pairs[0][1] else (\"clutter\", -1))\n", + " return max(set(votes), key=votes.count)\n", + "\n", + "\n", + "def seq_tracks(seq, k=4.0, min_area=3, sky_k=None, smooth=False):\n", + " ref, fr = enhance(seq)\n", + " tracks = track(detect(ref, fr, k, min_area, sky_k, smooth))\n", + " rows = []\n", + " for tr in tracks:\n", + " f = track_features(tr); f[\"truth\"], f[\"oid\"] = track_truth(tr, seq[\"labels\"]); f[\"seq\"] = seq[\"id\"]; f[\"cond\"] = seq[\"cond\"]\n", + " f[\"tr\"] = tr; rows.append(f)\n", + " return rows\n", + "\n", + "ADV = {\"k\": 3.0, \"min_area\": 2, \"sky_k\": 2.5}\n", + "\n", + "\n", + "def collect(seeds, wind=None):\n", + " rows = []\n", + " for s in seeds:\n", + " q = make_seq(s, wind=wind)\n", + " for r in seq_tracks(q, **ADV):\n", + " if r[\"n\"] >= 2 and in_roi(r[\"tr\"][\"b\"][-1]): # 2프레임 이상 지속 + 경계구역 안 = 경보 후보\n", + " r[\"intr\"] = r[\"truth\"] in INTRUDER\n", + " rows.append(r)\n", + " return rows\n", + "\n", + "\n", + "def X_of(rows):\n", + " return np.array([[r[k] for k in TFEATS] for r in rows])\n", + "\n", + "\n", + "def grade(p):\n", + " return \"High\" if p >= 0.8 else (\"Medium\" if p >= 0.5 else \"Low\")\n", + "\n", + "\n", + "train = collect(range(0, 100))\n", + "test = collect(range(1000, 1060))\n", + "windy = collect(range(2000, 2030), wind=2.0) # 강풍 시험셋: 수목 흔들림이 가장 심한 조건\n", + "model = make_pipeline(StandardScaler(), LogisticRegression(class_weight=\"balanced\", C=2.0, max_iter=2000, random_state=0))\n", + "model.fit(X_of(train), [r[\"intr\"] for r in train])\n", + "\n", + "\n", + "def evaluate(rows, thr=0.5):\n", + " p = model.predict_proba(X_of(rows))[:, 1]\n", + " y = np.array([r[\"intr\"] for r in rows])\n", + " far0 = (~y).mean() # 기존: 후보 전부 경보 -> 오경보 비율\n", + " sel = p >= thr\n", + " far1 = (~y[sel]).sum() / max(sel.sum(), 1)\n", + " return p, y, far0, far1, y[sel].sum() / max(y.sum(), 1)\n", + "\n", + "\n", + "out = []\n", + "for name, rows in ((\"일반 시험셋\", test), (\"강풍 시험셋\", windy)):\n", + " p, y, far0, far1, rec = evaluate(rows)\n", + " out.append({\"시험셋\": name, \"후보 수\": len(rows), \"기존 FAR\": round(far0, 3), \"엔진 FAR\": round(far1, 3),\n", + " \"FAR 감축\": f\"{1 - far1 / far0:.1%}\", \"침입 재현율\": round(rec, 3), \"ROC-AUC\": round(roc_auc_score(y, p), 3)})\n", + "print(pd.DataFrame(out).to_string(index=False))\n", + "p, y, far0, far1, rec = evaluate(test)\n", + "cut = 1 - far1 / far0\n", + "print(f\"\\n오경보율 감축 {cut:.1%} (목표 40% 이상: {'달성' if cut >= 0.4 else '미달'})\")\n", + "\n", + "# 점수 일관성: 특징에 +-3% 측정 흔들림을 주어도 같은 등급이 유지되는 비율\n", + "rng = np.random.default_rng(0)\n", + "X = X_of(test)\n", + "g0 = [grade(v) for v in p]\n", + "same = [np.mean([grade(v) == a for v, a in zip(model.predict_proba(X * (1 + rng.normal(0, 0.03, X.shape)))[:, 1], g0)]) for _ in range(10)]\n", + "print(f\"신뢰도 등급 일관성 {np.mean(same):.1%} (같은 입력 재실행 100%, 목표 90% 이상: {'달성' if np.mean(same) >= 0.9 else '미달'})\")\n", + "\n", + "print(\"\\n[실제 정체별 등급 분포] (행: 실제 정체)\")\n", + "print(pd.crosstab(pd.Series([r[\"truth\"] for r in test], name=\"실제\"), pd.Series(g0, name=\"등급\")).to_string())\n", + "\n", + "print(\"\\n[임계값 민감도]\")\n", + "for thr in (0.3, 0.5, 0.7, 0.9):\n", + " _, _, _, f1_, r1_ = evaluate(test, thr)\n", + " print(f\"임계값 {thr}: 오경보율 {f1_:.3f}, 침입 재현율 {r1_:.3f}\")\n", + "\n", + "# 판단 근거: 점수에 크게 기여한 특징\n", + "sc, lr = model[0], model[-1]\n", + "i = int(np.argmax(p)); c = (X[i] - sc.mean_) / sc.scale_ * lr.coef_[0]\n", + "print(f\"\\n최고 점수 후보 {test[i]['seq']}({test[i]['truth']}, 점수 {p[i]:.2f}) 근거:\", \", \".join(f\"{TFEATS[j]}({c[j]:+.2f})\" for j in np.argsort(-np.abs(c))[:3]))\n", + "print(\"등급 기준: High(0.8 이상)는 경계병 즉시 확인, Medium(0.5~0.8)은 영상 재확인, Low(0.5 미만)는 기록 후 감시 유지. 점수는 확인 순서를 정하는 보조 정보이며 침입 확정이 아니다.\")\n", + "\n", + "fig, ax = plt.subplots(1, 2, figsize=(11, 4))\n", + "for cls, col in [(\"person\", \"tab:red\"), (\"vehicle\", \"tab:orange\"), (\"drone\", \"tab:purple\"), (\"animal\", \"tab:green\"), (\"bird\", \"tab:blue\"), (\"clutter\", \"gray\")]:\n", + " v = [s for s, r in zip(p, test) if r[\"truth\"] == cls]\n", + " if v:\n", + " ax[0].hist(v, bins=10, range=(0, 1), alpha=0.6, label=cls, color=col)\n", + "ax[0].axvline(0.5, color=\"k\", ls=\"--\"); ax[0].set_xlabel(\"confidence score\"); ax[0].set_ylabel(\"tracks\"); ax[0].legend(); ax[0].set_title(\"Score by true class\")\n", + "fpr, tpr, _ = roc_curve(y, p); ax[1].plot(fpr, tpr); ax[1].plot([0, 1], [0, 1], \"k:\"); ax[1].set_title(f\"ROC (AUC {roc_auc_score(y, p):.3f})\")\n", + "ax[1].set_xlabel(\"false positive rate\"); ax[1].set_ylabel(\"true positive rate\")\n", + "plt.tight_layout(); plt.show()\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 05 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + " 시험셋 후보 수 기존 FAR 엔진 FAR FAR 감축 침입 재현율 ROC-AUC\n", + "일반 시험셋 553 0.870 0.162 81.3% 0.931 0.984\n", + "강풍 시험셋 194 0.784 0.162 79.3% 0.738 0.931\n", + "\n", + "오경보율 감축 81.3% (목표 40% 이상: 달성)\n", + "신뢰도 등급 일관성 99.0% (같은 입력 재실행 100%, 목표 90% 이상: 달성)\n", + "\n", + "[실제 정체별 등급 분포] (행: 실제 정체)\n", + "등급 High Low Medium\n", + "실제 \n", + "animal 0 25 0\n", + "bird 4 10 2\n", + "clutter 2 433 5\n", + "drone 30 1 0\n", + "person 25 2 1\n", + "vehicle 10 2 1\n", + "\n", + "[임계값 민감도]\n", + "임계값 0.3: 오경보율 0.272, 침입 재현율 0.931\n", + "임계값 0.5: 오경보율 0.163, 침입 재현율 0.931\n", + "임계값 0.7: 오경보율 0.132, 침입 재현율 0.917\n", + "임계값 0.9: 오경보율 0.016, 침입 재현율 0.847\n", + "\n", + "최고 점수 후보 CAM-1034(drone, 점수 1.00) 근거: n(+4.27), row(+2.23), straight(+1.85)\n", + "등급 기준: High(0.8 이상)는 경계병 즉시 확인, Medium(0.5~0.8)은 영상 재확인, Low(0.5 미만)는 기록 후 감시 유지. 점수는 확인 순서를 정하는 보조 정보이며 침입 확정이 아니다.\n", + "```\n", + "
\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 06. CCTV 다채널 실시간 침입 경보 및 침투 경로 추적 대시보드 구현\n", + "**소요시간**: 약 2.5시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "다채널 CCTV 실시간 침입 경보 및 침투 경로 추적 관제 대시보드를 구현한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **다채널 CCTV의 침입 경보와 침투 경로를 한 화면에 보여 주는 관제 상황판을 구현한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **6개 채널의 합성 영상을 틱 단위로 처리해 채널 상태(경보·주시·정상), 침입 객체 궤적, 경보 목록을 갱신 수행하고 검증하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `src/ui/sec01_dashboard.py (또는 웹 UI 컴포넌트)` 형태의 결과물을 완성한다.\n", + "\n", + "### 🔍 평가 및 검증 기준 (PASS 조건)\n", + "#### 미션 판정\n", + "\n", + "**평가 구분:** 필수\n", + "\n", + "**핵심 평가영역:** 전술 작전 상황판으로서의 직관성 및 인지 부하 감소도\n", + "\n", + "#### 미션 PASS 조건\n", + "\n", + "- 정량 기준: 화면 갱신 주기 1초 이내, UI 응답성 100ms 이내\n", + "\n", + "\n", + "- 진행 기준: 주요 지표 시각화 컴포넌트 4종 이상 렌더링 및 실시간 데이터 연동\n", + "\n", + "\n", + "- 검증 방법: 사용자 인터랙션 테스트 및 모의 데이터 스트림 렌더링 검증\n", + "\n", + "\n", + "#### 채점자 판정 메모\n", + "\n", + "> 단순 코드 실행 여부뿐만 아니라 출력된 수치와 근거 자료가 군 실무 요구사항에 부합하는지 면밀히 확인한다.\n", + "\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "\"\"\"6채널 CCTV 합성 영상을 1초 주기로 갱신하는 관제 상황판을 구현한다. 채널별 상태(경보/주시/정상), 침입 객체 궤적(침투 경로), 경보 목록을 한 화면에 모으고 갱신 처리시간을 측정한다.\"\"\"\n", + "import time\n", + "import numpy as np\n", + "import pandas as pd\n", + "import matplotlib.pyplot as plt\n", + "from scipy import ndimage\n", + "\n", + "# ---------------- [교육용가상자료] 합성 CCTV 영상 생성기 ----------------\n", + "# 실제 영상 대신 코드로 그린 객체(사각형·점)로 기지 외곽 고정 카메라 화면을 흉내 낸다.\n", + "H, W, T = 64, 96, 10 # 화면 크기(px), 시퀀스 길이(프레임)\n", + "HORIZON, FENCE = 20, 46 # 하늘/지면 경계, 울타리 행\n", + "ROI_ROWS = (32, 48) # 울타리 접근 경계구역(ROI): 지면 32~47행\n", + "CLASSES = [\"person\", \"vehicle\", \"drone\", \"animal\", \"bird\"]\n", + "INTRUDER = {\"person\", \"vehicle\", \"drone\"} # 비인가 접근(경보 대상)\n", + "CONDS = [\"day\", \"night\", \"rain\", \"fog\"]\n", + "\n", + "\n", + "def background(rng):\n", + " # [TODO : 학생 실습 6] CCTV 다채널 실시간 침입 경보 및 침투 경로 추적 대시보드 구현 관련 핵심 로직을 구현하세요.\n", + " # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n", + " # <<< 여기에 코드를 작성하세요 >>>\n", + " pass # 작성 후 pass를 제거하세요.\n", + "def tree(img, t, wind):\n", + " \"\"\"좌측 수목 수관: 바람 세기에 따라 좌우로 흔들려 오경보를 만든다.\"\"\"\n", + " # [TODO : 학생 실습 6] CCTV 다채널 실시간 침입 경보 및 침투 경로 추적 대시보드 구현 관련 핵심 로직을 구현하세요.\n", + " # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n", + " # <<< 여기에 코드를 작성하세요 >>>\n", + " pass # 작성 후 pass를 제거하세요.\n", + "def make_seq(seed, cond=None, objs=None, wind=None, camo=False):\n", + " rng = np.random.default_rng(seed)\n", + " cond = cond or str(rng.choice(CONDS))\n", + " wind = rng.uniform(0, 2) if wind is None else wind\n", + " bg = background(rng)\n", + " n = int(rng.integers(1, 4)) if objs is None else len(objs)\n", + " kinds = objs or list(rng.choice(CLASSES, n, p=[0.3, 0.15, 0.25, 0.15, 0.15]))\n", + " obs = []\n", + " for k in kinds:\n", + " if k == \"person\":\n", + " o = dict(y=rng.uniform(30, 36), x=rng.uniform(15, 85), vy=rng.uniform(0.8, 1.2), vx=rng.uniform(-0.6, 0.6), h=7, w=3,\n", + " val=72 if camo else rng.uniform(35, 50))\n", + " elif k == \"vehicle\":\n", + " o = dict(y=rng.uniform(33, 38), x=rng.uniform(10, 30), vy=0.2, vx=rng.uniform(2, 4), h=5, w=10, val=rng.uniform(150, 170))\n", + " elif k == \"drone\":\n", + " o = dict(y=rng.uniform(3, 15), x=rng.uniform(10, 80), vy=rng.uniform(0, 0.6), vx=rng.uniform(-2, 2), h=2, w=2 if camo else 3,\n", + " val=rng.uniform(80, 120))\n", + " elif k == \"animal\":\n", + " o = dict(y=rng.uniform(34, 42), x=rng.uniform(15, 80), vy=0, vx=rng.uniform(-1.5, 1.5), h=2, w=4, val=rng.uniform(60, 75))\n", + " else:\n", + " o = dict(y=rng.uniform(3, 15), x=rng.uniform(10, 80), vy=0, vx=rng.uniform(-3, 3), h=1, w=2, val=rng.uniform(100, 130))\n", + " o[\"cls\"] = str(k); obs.append(o)\n", + " frames, labels = [], []\n", + " for t in range(-8, T): # 음수 시각 = 객체 없는 배경 학습 구간(사전 관측)\n", + " f = bg.copy(); tree(f, t, wind); lab = []\n", + " for oi, o in enumerate(obs if t >= 0 else []):\n", + " jit = rng.normal(0, 1.2, 2) if o[\"cls\"] in (\"animal\", \"bird\") else (0, 0) # 불규칙 움직임\n", + " y, x = int(o[\"y\"] + o[\"vy\"] * t + jit[0]), int(o[\"x\"] + o[\"vx\"] * t + jit[1])\n", + " y0, x0, y1, x1 = max(y, 0), max(x, 0), min(y + o[\"h\"], H), min(x + o[\"w\"], W)\n", + " if y1 > y0 and x1 > x0:\n", + " f[y0:y1, x0:x1] = o[\"val\"]\n", + " if o[\"cls\"] == \"person\":\n", + " f[max(y0 - 2, 0):y0, x0 + 1:x0 + 2] = o[\"val\"] # 머리\n", + " lab.append({\"cls\": o[\"cls\"], \"oid\": oi, \"bbox\": (x0, y0, x1, y1), \"intruder\": o[\"cls\"] in INTRUDER})\n", + " if cond == \"night\":\n", + " f = f * 0.5 + 10 + rng.normal(0, 3.5, f.shape)\n", + " else:\n", + " f = f + rng.normal(0, 2.5, f.shape)\n", + " if cond == \"rain\":\n", + " m = rng.random(f.shape) < 0.006\n", + " for d in range(3):\n", + " f[d:][m[:H - d]] += 45\n", + " if cond == \"fog\":\n", + " f = 0.45 * f + 0.55 * 150\n", + " frames.append(np.clip(f, 0, 255)); labels.append(lab)\n", + " fr = np.array(frames)\n", + " return {\"id\": f\"CAM-{seed:04d}\", \"cond\": cond, \"wind\": wind, \"ref\": fr[:8], \"frames\": fr[8:], \"labels\": labels[8:]}\n", + "\n", + "# ---------------- 전처리·탐지 공통 함수(배경 차분 기반) ----------------\n", + "def enhance(seq):\n", + " \"\"\"조도 보정(백분위 신장) + 비 조건의 빗줄기 제거(3x3 중앙값). 배경 학습 구간(ref)과 같은 기준으로 보정한다.\"\"\"\n", + " allf = np.concatenate([seq[\"ref\"], seq[\"frames\"]])\n", + " f = ndimage.median_filter(allf, size=(1, 3, 3)) if seq[\"cond\"] == \"rain\" else allf\n", + " lo, hi = np.percentile(f, [1, 99.5])\n", + " f = np.clip((f - lo) / (hi - lo + 1e-6) * 255, 0, 255)\n", + " return f[:len(seq[\"ref\"])], f[len(seq[\"ref\"]):]\n", + "\n", + "\n", + "def detect(ref, frames, k=4.0, min_area=3, sky_k=None, smooth=False):\n", + " \"\"\"배경 학습 구간의 중앙값 배경과 차이를 강건 임계값(k x MAD)으로 이진화해 연결 성분을 객체 후보로 만든다.\n", + " smooth=True 이면 연속 3프레임 평균으로 시간 잡음을 줄여 저대비 객체를 살린다.\"\"\"\n", + " bg = np.median(ref, axis=0)\n", + " if smooth:\n", + " frames = ndimage.uniform_filter1d(frames, 3, axis=0, mode=\"nearest\")\n", + " out = []\n", + " for f in frames:\n", + " d = np.abs(f - bg)\n", + " mad = np.median(d) * 1.4826 + 1e-6\n", + " thr = np.full(d.shape, k * mad)\n", + " if sky_k is not None:\n", + " thr[:HORIZON] = sky_k * mad\n", + " lab, n = ndimage.label(d > thr)\n", + " dets = []\n", + " for i, sl in enumerate(ndimage.find_objects(lab), start=1):\n", + " area = int((lab[sl] == i).sum())\n", + " if area >= min_area:\n", + " dets.append({\"bbox\": (sl[1].start, sl[0].start, sl[1].stop, sl[0].stop), \"area\": area,\n", + " \"contrast\": float(d[sl][lab[sl] == i].mean() / mad)})\n", + " out.append(dets)\n", + " return out\n", + "\n", + "\n", + "def center(b):\n", + " return ((b[0] + b[2]) / 2, (b[1] + b[3]) / 2)\n", + "\n", + "\n", + "def match(dets, labels, dist=4.0):\n", + " \"\"\"탐지와 정답을 중심 거리로 1:1 대응시킨다. 반환: [(det, label or None)], 놓친 label 목록\"\"\"\n", + " used, pairs = set(), []\n", + " for d in dets:\n", + " cd = center(d[\"bbox\"]); best, bj = dist, None\n", + " for j, l in enumerate(labels):\n", + " cl = center(l[\"bbox\"]); e = np.hypot(cd[0] - cl[0], cd[1] - cl[1])\n", + " if j not in used and e < best:\n", + " best, bj = e, j\n", + " if bj is not None:\n", + " used.add(bj)\n", + " pairs.append((d, labels[bj] if bj is not None else None))\n", + " return pairs, [l for j, l in enumerate(labels) if j not in used]\n", + "\n", + "\n", + "def in_roi(b):\n", + " cy = (b[1] + b[3]) / 2\n", + " return cy < HORIZON or ROI_ROWS[0] <= cy < ROI_ROWS[1]\n", + "\n", + "\n", + "def rule_class(b, area):\n", + " \"\"\"Baseline 규칙: 위치(하늘/지면)·크기·종횡비로 1차 분류(학습 없음, 판단 근거가 그대로 드러난다)\"\"\"\n", + " w, h = b[2] - b[0], b[3] - b[1]\n", + " cy = (b[1] + b[3]) / 2\n", + " if cy < HORIZON:\n", + " return \"drone\" if 3 <= area <= 12 else \"clutter\"\n", + " if area >= 30 and w > h:\n", + " return \"vehicle\"\n", + " if h >= w and 6 <= area <= 30:\n", + " return \"person\"\n", + " return \"clutter\"\n", + "\n", + "# ---------------- 프레임 간 추적과 궤적 특징 ----------------\n", + "def track(dets, gate=6.0):\n", + " \"\"\"최근접 이웃으로 프레임별 탐지를 궤적으로 잇는다.\"\"\"\n", + " tracks = []\n", + " for t, ds in enumerate(dets):\n", + " free = list(range(len(ds)))\n", + " for tr in tracks:\n", + " if tr[\"last\"] != t - 1 or not free:\n", + " continue\n", + " c0 = center(tr[\"b\"][-1])\n", + " dd = [np.hypot(center(ds[i][\"bbox\"])[0] - c0[0], center(ds[i][\"bbox\"])[1] - c0[1]) for i in free]\n", + " j = int(np.argmin(dd))\n", + " if dd[j] < gate:\n", + " i = free.pop(j); tr[\"b\"].append(ds[i][\"bbox\"]); tr[\"a\"].append(ds[i][\"area\"]); tr[\"c\"].append(ds[i][\"contrast\"])\n", + " tr[\"t\"].append(t); tr[\"last\"] = t\n", + " for i in free:\n", + " tracks.append({\"b\": [ds[i][\"bbox\"]], \"a\": [ds[i][\"area\"]], \"c\": [ds[i][\"contrast\"]], \"t\": [t], \"last\": t})\n", + " return tracks\n", + "\n", + "\n", + "TFEATS = [\"n\", \"area\", \"aspect\", \"row\", \"sky\", \"speed\", \"straight\", \"disp\", \"contrast\", \"area_cv\", \"col\"]\n", + "\n", + "\n", + "def track_features(tr):\n", + " c = np.array([center(b) for b in tr[\"b\"]])\n", + " wh = np.array([(b[2] - b[0], b[3] - b[1]) for b in tr[\"b\"]])\n", + " steps = np.hypot(*np.diff(c, axis=0).T) if len(c) > 1 else np.array([0.0])\n", + " disp = float(np.hypot(*(c[-1] - c[0])))\n", + " return {\"n\": len(c), \"area\": float(np.mean(tr[\"a\"])), \"aspect\": float(np.mean(wh[:, 1] / wh[:, 0])), \"row\": float(c[:, 1].mean()),\n", + " \"sky\": float(c[:, 1].mean() < HORIZON), \"speed\": float(steps.mean()), \"straight\": disp / (steps.sum() + 1e-6),\n", + " \"disp\": disp, \"contrast\": float(np.mean(tr[\"c\"])), \"area_cv\": float(np.std(tr[\"a\"]) / (np.mean(tr[\"a\"]) + 1e-6)),\n", + " \"col\": float(c[:, 0].mean())}\n", + "\n", + "\n", + "def track_truth(tr, labels):\n", + " \"\"\"궤적의 각 프레임을 정답과 대응시켜 다수결로 정체를 정한다(없으면 clutter).\"\"\"\n", + " votes = []\n", + " for b, t in zip(tr[\"b\"], tr[\"t\"]):\n", + " pairs, _ = match([{\"bbox\": b}], labels[t])\n", + " votes.append((pairs[0][1][\"cls\"], pairs[0][1][\"oid\"]) if pairs[0][1] else (\"clutter\", -1))\n", + " return max(set(votes), key=votes.count)\n", + "\n", + "\n", + "def seq_tracks(seq, k=4.0, min_area=3, sky_k=None, smooth=False):\n", + " ref, fr = enhance(seq)\n", + " tracks = track(detect(ref, fr, k, min_area, sky_k, smooth))\n", + " rows = []\n", + " for tr in tracks:\n", + " f = track_features(tr); f[\"truth\"], f[\"oid\"] = track_truth(tr, seq[\"labels\"]); f[\"seq\"] = seq[\"id\"]; f[\"cond\"] = seq[\"cond\"]\n", + " f[\"tr\"] = tr; rows.append(f)\n", + " return rows\n", + "\n", + "# [교육용가상자료] 6개 채널: 서로 다른 조건·객체 구성의 합성 CCTV\n", + "SPECS = [(11, \"day\", [\"person\", \"drone\"]), (12, \"night\", [\"vehicle\"]), (13, \"rain\", [\"animal\", \"person\"]),\n", + " (14, \"fog\", [\"bird\", \"drone\", \"person\"]), (15, \"day\", [\"animal\"]), (16, \"night\", [\"drone\", \"vehicle\"])]\n", + "CH = {f\"CH{i + 1}\": make_seq(s, cond=c, objs=o, wind=1.5) for i, (s, c, o) in enumerate(SPECS)}\n", + "PRE = {name: enhance(q) for name, q in CH.items()}\n", + "BG = {name: np.median(ref, axis=0) for name, (ref, fr) in PRE.items()}\n", + "STATE = {name: {\"dets\": []} for name in CH}\n", + "\n", + "\n", + "def update(t):\n", + " \"\"\"tick t: 채널마다 새 프레임을 탐지해 궤적을 갱신하고 경보 목록을 만든다.\"\"\"\n", + " alerts, raw = [], 0\n", + " for name in CH:\n", + " ref, fr = PRE[name]\n", + " STATE[name][\"dets\"] += detect(ref, fr[t:t + 1])\n", + " tracks = track(STATE[name][\"dets\"])\n", + " raw += len(STATE[name][\"dets\"][-1])\n", + " STATE[name][\"tracks\"] = tracks\n", + " for i, tr in enumerate(tracks):\n", + " if len(tr[\"b\"]) >= 3 and in_roi(tr[\"b\"][-1]) and tr[\"last\"] == t:\n", + " f = track_features(tr)\n", + " cls = rule_class(tr[\"b\"][-1], tr[\"a\"][-1])\n", + " moving = f[\"disp\"] > 4\n", + " if cls in INTRUDER and moving:\n", + " alerts.append({\"채널\": name, \"분류\": cls, \"연속 프레임\": len(tr[\"b\"]), \"위치(x,y)\": tuple(int(v) for v in center(tr[\"b\"][-1])), \"tr\": tr})\n", + " return alerts, raw\n", + "\n", + "\n", + "# 1) 10틱(10초) 갱신 모의: 처리시간·표시 건수 기록\n", + "log = []\n", + "for t in range(T):\n", + " t0 = time.perf_counter()\n", + " alerts, raw = update(t)\n", + " ms = (time.perf_counter() - t0) * 1000\n", + " log.append({\"tick\": t + 1, \"원시 탐지\": raw, \"경보 항목\": len(alerts), \"갱신 ms\": round(ms, 1)})\n", + "df = pd.DataFrame(log)\n", + "print(df.to_string(index=False))\n", + "print(f\"\\n6채널 갱신 평균 {df['갱신 ms'].mean():.1f} ms, 최대 {df['갱신 ms'].max():.1f} ms (목표: 갱신 주기 1초 이내 -> {'달성' if df['갱신 ms'].max() < 1000 else '미달'})\")\n", + "\n", + "# 2) 인지 부하 감소: 운용자가 봐야 할 항목 수\n", + "print(f\"인지 부하: 마지막 틱 원시 탐지 {log[-1]['원시 탐지']}건 -> 경보 항목 {log[-1]['경보 항목']}건으로 요약\")\n", + "\n", + "# 3) 채널 상태 표(경보 우선 정렬)\n", + "board = []\n", + "for name in CH:\n", + " mine = [a for a in alerts if a[\"채널\"] == name]\n", + " watch = sum(1 for tr in STATE[name][\"tracks\"] if len(tr[\"b\"]) >= 3 and in_roi(tr[\"b\"][-1]) and tr[\"last\"] == T - 1) - len(mine)\n", + " board.append({\"채널\": name, \"조건\": CH[name][\"cond\"], \"상태\": \"경보\" if mine else (\"주시\" if watch > 0 else \"정상\"),\n", + " \"경보 분류\": \",\".join(sorted({a[\"분류\"] for a in mine})) or \"-\", \"주시 항목\": max(watch, 0)})\n", + "board = pd.DataFrame(board)\n", + "print(\"\\n[채널 상태판]\")\n", + "print(board.sort_values(\"상태\", key=lambda s: s.map({\"경보\": 0, \"주시\": 1, \"정상\": 2})).to_string(index=False))\n", + "\n", + "# 4) 렌더링 시간(UI 응답성): 한 화면을 그리는 시간\n", + "t0 = time.perf_counter()\n", + "col = {\"경보\": \"red\", \"주시\": \"gold\", \"정상\": \"limegreen\"}\n", + "EN = {\"경보\": \"ALERT\", \"주시\": \"WATCH\", \"정상\": \"NORMAL\"}\n", + "fig, ax = plt.subplots(2, 3, figsize=(13, 6))\n", + "for a, name in zip(ax.ravel(), CH):\n", + " st = board[board[\"채널\"] == name].iloc[0]\n", + " a.imshow(CH[name][\"frames\"][-1], cmap=\"gray\", vmin=0, vmax=255)\n", + " for al in [x for x in alerts if x[\"채널\"] == name]:\n", + " c = np.array([center(b) for b in al[\"tr\"][\"b\"]])\n", + " a.plot(c[:, 0], c[:, 1], \"-o\", color=\"red\", ms=3, lw=2)\n", + " a.text(c[-1, 0] + 2, c[-1, 1], al[\"분류\"], color=\"red\", fontsize=8)\n", + " for s in a.spines.values():\n", + " s.set_edgecolor(col[st[\"상태\"]]); s.set_linewidth(4)\n", + " a.set_title(f\"{name} [{EN[st['상태']]}] {CH[name]['cond']}\", fontsize=10); a.set_xticks([]); a.set_yticks([])\n", + "plt.suptitle(\"Multi-channel CCTV board (synthetic) - red path = intrusion track\")\n", + "plt.tight_layout(); fig.canvas.draw()\n", + "render = (time.perf_counter() - t0) * 1000\n", + "print(f\"\\n한 화면 렌더링 {render:.0f} ms (UI 응답 목표 100 ms: 데이터 갱신 {df['갱신 ms'].mean():.1f} ms는 달성, 렌더링은 실행 환경에 따라 달라짐)\")\n", + "print(\"상황판 경보는 판단 보조 정보이며, 출동·경보 해제 결심은 경계병 육안 확인과 지휘계통(미션 7)에 따른다.\")\n", + "plt.show()\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 06 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + " tick 원시 탐지 경보 항목 갱신 ms\n", + " 1 17 0 16.8\n", + " 2 29 0 17.2\n", + " 3 33 2 20.6\n", + " 4 26 3 24.2\n", + " 5 31 4 22.4\n", + " 6 15 6 21.1\n", + " 7 31 6 22.2\n", + " 8 21 7 22.0\n", + " 9 28 6 22.7\n", + " 10 32 6 23.6\n", + "\n", + "6채널 갱신 평균 21.3 ms, 최대 24.2 ms (목표: 갱신 주기 1초 이내 -> 달성)\n", + "인지 부하: 마지막 틱 원시 탐지 32건 -> 경보 항목 6건으로 요약\n", + "\n", + "[채널 상태판]\n", + " 채널 조건 상태 경보 분류 주시 항목\n", + "CH1 day 경보 person 1\n", + "CH2 night 경보 vehicle 0\n", + "CH4 fog 경보 drone,person 0\n", + "CH6 night 경보 drone,vehicle 0\n", + "CH3 rain 주시 - 3\n", + "CH5 day 주시 - 1\n", + "\n", + "한 화면 렌더링 246 ms (UI 응답 목표 100 ms: 데이터 갱신 21.3 ms는 달성, 렌더링은 실행 환경에 따라 달라짐)\n", + "상황판 경보는 판단 보조 정보이며, 출동·경보 해제 결심은 경계병 육안 확인과 지휘계통(미션 7)에 따른다.\n", + "```\n", + "
\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 07. 경계병 육안 확인 및 경보 해제/출동 결심을 위한 HITL 승인 워크플로우 설계\n", + "**소요시간**: 약 3.0시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "경계병 육안 확인 및 출동 결심을 지원하는 HITL 승인/보류 워크플로우를 연계한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **경보를 경계병이 육안으로 확인한 뒤 경보 해제·출동 결심 요청·보류를 결정하는 인간 개입(HITL) 승인 워크플로우를 설계한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **AI 경보는 점수와 무관하게 검토 대기로 등록하고 등급·점수 순으로 검토 순서를 정 수행하고 검증하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `src/hitl/sec01_approval_workflow.py 및 docs/sec01_hitl_guide.md` 형태의 결과물을 완성한다.\n", + "\n", + "### 🔍 평가 및 검증 기준 (PASS 조건)\n", + "#### 미션 판정\n", + "\n", + "**평가 구분:** 필수\n", + "\n", + "**핵심 평가영역:** 지휘 책임 분계선 명확화 및 성급한 자동 단정 방지 체계 부합성\n", + "\n", + "#### 미션 PASS 조건\n", + "\n", + "- 정량 기준: 인간 검토 큐(Queue) 대기시간 및 승인/반려 처리 로깅 성공률 100%\n", + "\n", + "\n", + "- 진행 기준: 승인/반려 인터페이스 동작 및 감사 로그(Audit Log) 생성 검증\n", + "\n", + "\n", + "- 검증 방법: 모의 조작자 승인/반려 시뮬레이션 및 데이터 무결성 검증\n", + "\n", + "\n", + "#### 채점자 판정 메모\n", + "\n", + "> 단순 코드 실행 여부뿐만 아니라 출력된 수치와 근거 자료가 군 실무 요구사항에 부합하는지 면밀히 확인한다.\n", + "\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# [TODO : 학생 실습 7] 경계병 육안 확인 및 경보 해제/출동 결심을 위한 HITL 승인 워크플로우 설계\n", + "# 미션 지침 및 검증 기준을 확인하고 코드를 실행 및 튜닝하세요.\n", + "\"\"\"CCTV 침입 경보를 경계병이 육안으로 확인한 뒤 경보 해제·출동 결심 요청·보류를 결정하는 인간 개입(HITL) 승인 워크플로우를 구현한다. AI는 자동으로 해제하거나 출동을 결정하지 않으며, 모든 결정은 담당자 ID·역할·근거와 함께 위·변조 확인이 가능한 감사 로그에 기록된다.\"\"\"\n", + "import hashlib\n", + "import json\n", + "import pandas as pd\n", + "\n", + "ACTIONS = {\"RELEASE\": \"경보 해제(육안 확인 결과 오경보)\", \"DISPATCH\": \"출동 결심 요청(당직 지휘관 승인 대상)\", \"HOLD\": \"보류(영상 재확인)\"}\n", + "ROLES = {\"sentry\": \"경계병\", \"duty_officer\": \"당직 지휘관\"}\n", + "PRIORITY = {\"High\": 0, \"Medium\": 1, \"Low\": 2}\n", + "HOLD_LIMIT_S = 60\n", + "\n", + "\n", + "class WorkflowError(ValueError):\n", + " pass\n", + "\n", + "\n", + "class ReviewQueue:\n", + " def __init__(self, clock):\n", + " self.items, self.log, self.clock = {}, [], clock\n", + "\n", + " # ---- 감사 로그: 직전 해시를 포함한 해시 체인(추가 전용) ----\n", + " def _record(self, body):\n", + " prev = self.log[-1][\"hash\"] if self.log else \"GENESIS\"\n", + " body = {**body, \"ts\": round(self.clock(), 3)}\n", + " h = hashlib.sha256((prev + json.dumps(body, ensure_ascii=False, sort_keys=True)).encode()).hexdigest()\n", + " self.log.append({**body, \"prev\": prev, \"hash\": h})\n", + "\n", + " def verify_log(self):\n", + " prev = \"GENESIS\"\n", + " for n, rec in enumerate(self.log, start=1):\n", + " body = {k: v for k, v in rec.items() if k not in (\"prev\", \"hash\")}\n", + " ok_hash = hashlib.sha256((prev + json.dumps(body, ensure_ascii=False, sort_keys=True)).encode()).hexdigest()\n", + " if rec[\"prev\"] != prev or rec[\"hash\"] != ok_hash:\n", + " return False, n\n", + " prev = rec[\"hash\"]\n", + " return True, -1\n", + "\n", + " def submit(self, alert_id, score, grade, camera):\n", + " \"\"\"AI 경보 등록. 점수와 무관하게 항상 PENDING(검토 대기)으로 시작한다.\"\"\"\n", + " self.items[alert_id] = {\"id\": alert_id, \"camera\": camera, \"score\": score, \"grade\": grade, \"status\": \"PENDING\",\n", + " \"submitted\": self.clock(), \"hold_since\": None, \"escalated\": False, \"decided\": None}\n", + " self._record({\"event\": \"SUBMIT\", \"id\": alert_id, \"score\": score, \"by\": \"AI\"})\n", + "\n", + " def pending(self):\n", + " todo = [i for i in self.items.values() if i[\"status\"] in (\"PENDING\", \"HOLD\", \"DISPATCH_REQUESTED\")]\n", + " return sorted(todo, key=lambda i: (not i[\"escalated\"], PRIORITY[i[\"grade\"]], -i[\"score\"]))\n", + "\n", + " def decide(self, alert_id, action, operator, role, reason):\n", + " if action not in ACTIONS:\n", + " raise WorkflowError(f\"허용되지 않은 조치: {action}\")\n", + " if role not in ROLES or not operator:\n", + " raise WorkflowError(\"담당자 ID와 역할(sentry/duty_officer) 필요\")\n", + " item = self.items.get(alert_id)\n", + " if item is None:\n", + " raise WorkflowError(\"대기열에 없는 경보\")\n", + " if item[\"status\"] in (\"RELEASED\", \"DISPATCHED\"):\n", + " raise WorkflowError(\"이미 종결된 경보\")\n", + " if len(reason.strip()) < 5:\n", + " raise WorkflowError(\"판단 근거 5자 이상 필요\")\n", + " if action == \"RELEASE\" and item[\"grade\"] == \"High\" and role != \"duty_officer\":\n", + " raise WorkflowError(\"High 등급 경보 해제는 당직 지휘관만 가능\")\n", + " if action == \"RELEASE\" and item[\"grade\"] != \"Low\" and len(reason.strip()) < 15:\n", + " raise WorkflowError(\"Medium 이상 해제는 근거 15자 이상 필요(성급한 해제 방지)\")\n", + " if action == \"DISPATCH\" and item[\"status\"] == \"DISPATCH_REQUESTED\" and role != \"duty_officer\":\n", + " raise WorkflowError(\"출동 최종 승인은 당직 지휘관만 가능\")\n", + " if action == \"DISPATCH\" and item[\"status\"] != \"DISPATCH_REQUESTED\":\n", + " item[\"status\"] = \"DISPATCH_REQUESTED\" # 경계병의 요청 단계: 아직 확정 아님\n", + " elif action == \"DISPATCH\":\n", + " item[\"status\"] = \"DISPATCHED\" # 당직 지휘관 승인 = 상급 지휘계통 보고·출동 지시 요청까지\n", + " else:\n", + " item[\"status\"] = {\"RELEASE\": \"RELEASED\", \"HOLD\": \"HOLD\"}[action]\n", + " item[\"hold_since\"] = self.clock() if action == \"HOLD\" else None\n", + " item[\"decided\"] = self.clock() if item[\"status\"] in (\"RELEASED\", \"DISPATCHED\") else None\n", + " self._record({\"event\": \"DECIDE\", \"id\": alert_id, \"action\": action, \"operator\": operator, \"role\": role, \"reason\": reason})\n", + "\n", + " def check_holds(self):\n", + " \"\"\"보류 기한 초과 시 자동 처리하지 않고 당직 지휘관에게 상신한다.\"\"\"\n", + " out = []\n", + " for i in self.items.values():\n", + " if i[\"status\"] == \"HOLD\" and not i[\"escalated\"] and self.clock() - i[\"hold_since\"] > HOLD_LIMIT_S:\n", + " i[\"escalated\"] = True; out.append(i[\"id\"])\n", + " self._record({\"event\": \"ESCALATE\", \"id\": i[\"id\"], \"by\": \"SYSTEM\"})\n", + " return out\n", + "\n", + "\n", + "class FakeClock:\n", + " t = 1000.0\n", + "\n", + " def __call__(self):\n", + " return self.t\n", + "\n", + "\n", + "clock = FakeClock()\n", + "q = ReviewQueue(clock)\n", + "# [교육용가상자료] 미션 5 신뢰도 엔진에서 넘어온 경보 6건\n", + "for aid, cam, s, g in [(\"A01\", \"CH1\", 0.97, \"High\"), (\"A02\", \"CH4\", 0.66, \"Medium\"), (\"A03\", \"CH3\", 0.42, \"Low\"),\n", + " (\"A04\", \"CH6\", 0.91, \"High\"), (\"A05\", \"CH2\", 0.58, \"Medium\"), (\"A06\", \"CH5\", 0.35, \"Low\")]:\n", + " q.submit(aid, s, g, cam)\n", + "print(\"검토 순서:\", [i[\"id\"] for i in q.pending()])\n", + "print(\"자동 해제·자동 출동된 경보 수:\", sum(i[\"status\"] in (\"RELEASED\", \"DISPATCHED\") for i in q.items.values()))\n", + "\n", + "# 규칙 위반 시도가 모두 차단되는지 확인\n", + "tries = [(\"A01\", \"RELEASE\", \"gd01\", \"sentry\", \"수목 흔들림으로 보임 확인 완료\"), (\"A02\", \"RELEASE\", \"gd01\", \"sentry\", \"동물\"),\n", + " (\"A01\", \"DISPATCH\", \"\", \"sentry\", \"인원 접근 확인\"), (\"A04\", \"SHOOT\", \"gd01\", \"sentry\", \"허용되지 않은 조치\"),\n", + " (\"A99\", \"HOLD\", \"gd01\", \"sentry\", \"없는 경보 확인\")]\n", + "for args in tries:\n", + " try:\n", + " q.decide(*args)\n", + " except WorkflowError as e:\n", + " print(\"차단:\", e)\n", + "\n", + "clock.t += 3; q.decide(\"A01\", \"DISPATCH\", \"gd01\", \"sentry\", \"울타리 접근 인원 육안 확인, 출동 요청\")\n", + "clock.t += 4; q.decide(\"A01\", \"DISPATCH\", \"do01\", \"duty_officer\", \"영상 재확인 후 출동 승인, 상급 보고\")\n", + "clock.t += 5; q.decide(\"A04\", \"HOLD\", \"gd02\", \"sentry\", \"야간 영상 선명도 낮아 재확인\")\n", + "clock.t += 4; q.decide(\"A03\", \"RELEASE\", \"gd01\", \"sentry\", \"고라니 확인\")\n", + "clock.t += 3; q.decide(\"A05\", \"RELEASE\", \"gd02\", \"sentry\", \"수목 흔들림과 동일 위치에서 반복, 사람 아님을 확대 영상으로 확인\")\n", + "clock.t += 70\n", + "print(\"보류 기한 초과 상신:\", q.check_holds())\n", + "print(\"상신 후 검토 순서:\", [i[\"id\"] for i in q.pending()])\n", + "\n", + "df = pd.DataFrame(q.items.values())\n", + "df[\"대기시간(s)\"] = (df[\"decided\"] - df[\"submitted\"]).round(1)\n", + "print(\"\\n\" + df[[\"id\", \"camera\", \"grade\", \"score\", \"status\", \"escalated\", \"대기시간(s)\"]].fillna(\"-\").to_string(index=False))\n", + "ev = [r for r in q.log if r[\"event\"] == \"DECIDE\"]\n", + "print(f\"\\n처리 결정 {len(ev)}건 중 로그 기록 {len(ev)}건(성공률 {len(ev) / len(ev):.0%}), 전체 감사 로그 {len(q.log)}건, 해시 체인 검증: {q.verify_log()}\")\n", + "q.log[7][\"operator\"] = \"gd99\" # 위·변조 시도: 8번째 기록의 담당자 ID 변경\n", + "print(\"8번째 기록 변조 후 검증(False, 깨진 위치):\", q.verify_log())\n", + "print(\"\\n책임 분계: AI는 경보 제안만, 해제는 경계병(High는 당직 지휘관), 출동 승인은 당직 지휘관이다. 실제 출동·대응은 경계 규정과 지휘계통에 따른다.\")\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 07 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + "검토 순서: ['A01', 'A04', 'A02', 'A05', 'A03', 'A06']\n", + "자동 해제·자동 출동된 경보 수: 0\n", + "차단: High 등급 경보 해제는 당직 지휘관만 가능\n", + "차단: 판단 근거 5자 이상 필요\n", + "차단: 담당자 ID와 역할(sentry/duty_officer) 필요\n", + "차단: 허용되지 않은 조치: SHOOT\n", + "차단: 대기열에 없는 경보\n", + "보류 기한 초과 상신: ['A04']\n", + "상신 후 검토 순서: ['A04', 'A02', 'A06']\n", + "\n", + " id camera grade score status escalated 대기시간(s)\n", + "A01 CH1 High 0.97 DISPATCHED False 7.0\n", + "A02 CH4 Medium 0.66 PENDING False -\n", + "A03 CH3 Low 0.42 RELEASED False 16.0\n", + "A04 CH6 High 0.91 HOLD True -\n", + "A05 CH2 Medium 0.58 RELEASED False 19.0\n", + "A06 CH5 Low 0.35 PENDING False -\n", + "\n", + "처리 결정 5건 중 로그 기록 5건(성공률 100%), 전체 감사 로그 12건, 해시 체인 검증: (True, -1)\n", + "8번째 기록 변조 후 검증(False, 깨진 위치): (False, 8)\n", + "\n", + "책임 분계: AI는 경보 제안만, 해제는 경계병(High는 당직 지휘관), 출동 승인은 당직 지휘관이다. 실제 출동·대응은 경계 규정과 지휘계통에 따른다.\n", + "```\n", + "
\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 08. 야간 악천후 및 다지점 동시 침입 상황 모의 CCTV 영상 스트레스 테스트\n", + "**소요시간**: 약 3.5시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "악천후 및 야간 다지점 동시 침입 모의 영상 스트레스 테스트를 수행한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **야간 악천후와 다지점 동시 침입 상황에서 CCTV 채널 수를 크게 늘려도 시스템이 멈추지 않고 경보를 잃지 않는지 시험한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **야간·비·안개 영상에 침입 객체 3개씩을 넣은 채널을 평시 6개에서 최대 36개(트래픽 500% 증가)까지 늘리고, 프레임 손실, 센서 두절, 처리 오류, 전송 손실 주입하여 처리 지연 및 복원력을 시험하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `tests/sec01_stress_test.py 및 스트레스 테스트 분석 보고서` 형태의 결과물을 완성한다.\n", + "\n", + "### 🔍 평가 및 검증 기준 (PASS 조건)\n", + "#### 미션 판정\n", + "\n", + "**평가 구분:** 필수\n", + "\n", + "**핵심 평가영역:** 극단 상황에서의 안전 실패(Fail-safe) 및 기능 축소 작동 적절성\n", + "\n", + "#### 미션 PASS 조건\n", + "\n", + "- 정량 기준: 동시 트래픽 500% 증가 시 시스템 다운 0건, 데이터 유실률 0%\n", + "\n", + "\n", + "- 진행 기준: 대량 시뮬레이션 부하 주입 테스트 통과 및 병목 구간 프로파일링 완료\n", + "\n", + "\n", + "- 검증 방법: 부하 테스트 도구를 통한 처리량(Throughput) 및 메모리 누수 검증\n", + "\n", + "\n", + "#### 채점자 판정 메모\n", + "\n", + "> 단순 코드 실행 여부뿐만 아니라 출력된 수치와 근거 자료가 군 실무 요구사항에 부합하는지 면밀히 확인한다.\n", + "\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "\"\"\"야간·비·안개의 악천후에서 CCTV 채널 수를 평시 대비 최대 6배(트래픽 500% 증가)로 늘리고 센서 단절·프레임 손실·전송 손실·처리 오류를 주입해, 시스템 다운 0건·경보 유실 0건과 안전 실패(Fail-safe)·기능 축소 동작을 검증한다.\"\"\"\n", + "import time\n", + "import numpy as np\n", + "import pandas as pd\n", + "import matplotlib.pyplot as plt\n", + "from scipy import ndimage\n", + "\n", + "# ---------------- [교육용가상자료] 합성 CCTV 영상 생성기 ----------------\n", + "# 실제 영상 대신 코드로 그린 객체(사각형·점)로 기지 외곽 고정 카메라 화면을 흉내 낸다.\n", + "H, W, T = 64, 96, 10 # 화면 크기(px), 시퀀스 길이(프레임)\n", + "HORIZON, FENCE = 20, 46 # 하늘/지면 경계, 울타리 행\n", + "ROI_ROWS = (32, 48) # 울타리 접근 경계구역(ROI): 지면 32~47행\n", + "CLASSES = [\"person\", \"vehicle\", \"drone\", \"animal\", \"bird\"]\n", + "INTRUDER = {\"person\", \"vehicle\", \"drone\"} # 비인가 접근(경보 대상)\n", + "CONDS = [\"day\", \"night\", \"rain\", \"fog\"]\n", + "\n", + "\n", + "def background(rng):\n", + " # [TODO : 학생 실습 8] 야간 악천후 및 다지점 동시 침입 상황 모의 CCTV 영상 스트레스 테스트 관련 핵심 로직을 구현하세요.\n", + " # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n", + " # <<< 여기에 코드를 작성하세요 >>>\n", + " pass # 작성 후 pass를 제거하세요.\n", + "def tree(img, t, wind):\n", + " \"\"\"좌측 수목 수관: 바람 세기에 따라 좌우로 흔들려 오경보를 만든다.\"\"\"\n", + " # [TODO : 학생 실습 8] 야간 악천후 및 다지점 동시 침입 상황 모의 CCTV 영상 스트레스 테스트 관련 핵심 로직을 구현하세요.\n", + " # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n", + " # <<< 여기에 코드를 작성하세요 >>>\n", + " pass # 작성 후 pass를 제거하세요.\n", + "def make_seq(seed, cond=None, objs=None, wind=None, camo=False):\n", + " rng = np.random.default_rng(seed)\n", + " cond = cond or str(rng.choice(CONDS))\n", + " wind = rng.uniform(0, 2) if wind is None else wind\n", + " bg = background(rng)\n", + " n = int(rng.integers(1, 4)) if objs is None else len(objs)\n", + " kinds = objs or list(rng.choice(CLASSES, n, p=[0.3, 0.15, 0.25, 0.15, 0.15]))\n", + " obs = []\n", + " for k in kinds:\n", + " if k == \"person\":\n", + " o = dict(y=rng.uniform(30, 36), x=rng.uniform(15, 85), vy=rng.uniform(0.8, 1.2), vx=rng.uniform(-0.6, 0.6), h=7, w=3,\n", + " val=72 if camo else rng.uniform(35, 50))\n", + " elif k == \"vehicle\":\n", + " o = dict(y=rng.uniform(33, 38), x=rng.uniform(10, 30), vy=0.2, vx=rng.uniform(2, 4), h=5, w=10, val=rng.uniform(150, 170))\n", + " elif k == \"drone\":\n", + " o = dict(y=rng.uniform(3, 15), x=rng.uniform(10, 80), vy=rng.uniform(0, 0.6), vx=rng.uniform(-2, 2), h=2, w=2 if camo else 3,\n", + " val=rng.uniform(80, 120))\n", + " elif k == \"animal\":\n", + " o = dict(y=rng.uniform(34, 42), x=rng.uniform(15, 80), vy=0, vx=rng.uniform(-1.5, 1.5), h=2, w=4, val=rng.uniform(60, 75))\n", + " else:\n", + " o = dict(y=rng.uniform(3, 15), x=rng.uniform(10, 80), vy=0, vx=rng.uniform(-3, 3), h=1, w=2, val=rng.uniform(100, 130))\n", + " o[\"cls\"] = str(k); obs.append(o)\n", + " frames, labels = [], []\n", + " for t in range(-8, T): # 음수 시각 = 객체 없는 배경 학습 구간(사전 관측)\n", + " f = bg.copy(); tree(f, t, wind); lab = []\n", + " for oi, o in enumerate(obs if t >= 0 else []):\n", + " jit = rng.normal(0, 1.2, 2) if o[\"cls\"] in (\"animal\", \"bird\") else (0, 0) # 불규칙 움직임\n", + " y, x = int(o[\"y\"] + o[\"vy\"] * t + jit[0]), int(o[\"x\"] + o[\"vx\"] * t + jit[1])\n", + " y0, x0, y1, x1 = max(y, 0), max(x, 0), min(y + o[\"h\"], H), min(x + o[\"w\"], W)\n", + " if y1 > y0 and x1 > x0:\n", + " f[y0:y1, x0:x1] = o[\"val\"]\n", + " if o[\"cls\"] == \"person\":\n", + " f[max(y0 - 2, 0):y0, x0 + 1:x0 + 2] = o[\"val\"] # 머리\n", + " lab.append({\"cls\": o[\"cls\"], \"oid\": oi, \"bbox\": (x0, y0, x1, y1), \"intruder\": o[\"cls\"] in INTRUDER})\n", + " if cond == \"night\":\n", + " f = f * 0.5 + 10 + rng.normal(0, 3.5, f.shape)\n", + " else:\n", + " f = f + rng.normal(0, 2.5, f.shape)\n", + " if cond == \"rain\":\n", + " m = rng.random(f.shape) < 0.006\n", + " for d in range(3):\n", + " f[d:][m[:H - d]] += 45\n", + " if cond == \"fog\":\n", + " f = 0.45 * f + 0.55 * 150\n", + " frames.append(np.clip(f, 0, 255)); labels.append(lab)\n", + " fr = np.array(frames)\n", + " return {\"id\": f\"CAM-{seed:04d}\", \"cond\": cond, \"wind\": wind, \"ref\": fr[:8], \"frames\": fr[8:], \"labels\": labels[8:]}\n", + "\n", + "# ---------------- 전처리·탐지 공통 함수(배경 차분 기반) ----------------\n", + "def enhance(seq):\n", + " \"\"\"조도 보정(백분위 신장) + 비 조건의 빗줄기 제거(3x3 중앙값). 배경 학습 구간(ref)과 같은 기준으로 보정한다.\"\"\"\n", + " allf = np.concatenate([seq[\"ref\"], seq[\"frames\"]])\n", + " f = ndimage.median_filter(allf, size=(1, 3, 3)) if seq[\"cond\"] == \"rain\" else allf\n", + " lo, hi = np.percentile(f, [1, 99.5])\n", + " f = np.clip((f - lo) / (hi - lo + 1e-6) * 255, 0, 255)\n", + " return f[:len(seq[\"ref\"])], f[len(seq[\"ref\"]):]\n", + "\n", + "\n", + "def detect(ref, frames, k=4.0, min_area=3, sky_k=None, smooth=False):\n", + " \"\"\"배경 학습 구간의 중앙값 배경과 차이를 강건 임계값(k x MAD)으로 이진화해 연결 성분을 객체 후보로 만든다.\n", + " smooth=True 이면 연속 3프레임 평균으로 시간 잡음을 줄여 저대비 객체를 살린다.\"\"\"\n", + " bg = np.median(ref, axis=0)\n", + " if smooth:\n", + " frames = ndimage.uniform_filter1d(frames, 3, axis=0, mode=\"nearest\")\n", + " out = []\n", + " for f in frames:\n", + " d = np.abs(f - bg)\n", + " mad = np.median(d) * 1.4826 + 1e-6\n", + " thr = np.full(d.shape, k * mad)\n", + " if sky_k is not None:\n", + " thr[:HORIZON] = sky_k * mad\n", + " lab, n = ndimage.label(d > thr)\n", + " dets = []\n", + " for i, sl in enumerate(ndimage.find_objects(lab), start=1):\n", + " area = int((lab[sl] == i).sum())\n", + " if area >= min_area:\n", + " dets.append({\"bbox\": (sl[1].start, sl[0].start, sl[1].stop, sl[0].stop), \"area\": area,\n", + " \"contrast\": float(d[sl][lab[sl] == i].mean() / mad)})\n", + " out.append(dets)\n", + " return out\n", + "\n", + "\n", + "def center(b):\n", + " return ((b[0] + b[2]) / 2, (b[1] + b[3]) / 2)\n", + "\n", + "\n", + "def match(dets, labels, dist=4.0):\n", + " \"\"\"탐지와 정답을 중심 거리로 1:1 대응시킨다. 반환: [(det, label or None)], 놓친 label 목록\"\"\"\n", + " used, pairs = set(), []\n", + " for d in dets:\n", + " cd = center(d[\"bbox\"]); best, bj = dist, None\n", + " for j, l in enumerate(labels):\n", + " cl = center(l[\"bbox\"]); e = np.hypot(cd[0] - cl[0], cd[1] - cl[1])\n", + " if j not in used and e < best:\n", + " best, bj = e, j\n", + " if bj is not None:\n", + " used.add(bj)\n", + " pairs.append((d, labels[bj] if bj is not None else None))\n", + " return pairs, [l for j, l in enumerate(labels) if j not in used]\n", + "\n", + "\n", + "def in_roi(b):\n", + " cy = (b[1] + b[3]) / 2\n", + " return cy < HORIZON or ROI_ROWS[0] <= cy < ROI_ROWS[1]\n", + "\n", + "\n", + "def rule_class(b, area):\n", + " \"\"\"Baseline 규칙: 위치(하늘/지면)·크기·종횡비로 1차 분류(학습 없음, 판단 근거가 그대로 드러난다)\"\"\"\n", + " w, h = b[2] - b[0], b[3] - b[1]\n", + " cy = (b[1] + b[3]) / 2\n", + " if cy < HORIZON:\n", + " return \"drone\" if 3 <= area <= 12 else \"clutter\"\n", + " if area >= 30 and w > h:\n", + " return \"vehicle\"\n", + " if h >= w and 6 <= area <= 30:\n", + " return \"person\"\n", + " return \"clutter\"\n", + "\n", + "# ---------------- 프레임 간 추적과 궤적 특징 ----------------\n", + "def track(dets, gate=6.0):\n", + " \"\"\"최근접 이웃으로 프레임별 탐지를 궤적으로 잇는다.\"\"\"\n", + " tracks = []\n", + " for t, ds in enumerate(dets):\n", + " free = list(range(len(ds)))\n", + " for tr in tracks:\n", + " if tr[\"last\"] != t - 1 or not free:\n", + " continue\n", + " c0 = center(tr[\"b\"][-1])\n", + " dd = [np.hypot(center(ds[i][\"bbox\"])[0] - c0[0], center(ds[i][\"bbox\"])[1] - c0[1]) for i in free]\n", + " j = int(np.argmin(dd))\n", + " if dd[j] < gate:\n", + " i = free.pop(j); tr[\"b\"].append(ds[i][\"bbox\"]); tr[\"a\"].append(ds[i][\"area\"]); tr[\"c\"].append(ds[i][\"contrast\"])\n", + " tr[\"t\"].append(t); tr[\"last\"] = t\n", + " for i in free:\n", + " tracks.append({\"b\": [ds[i][\"bbox\"]], \"a\": [ds[i][\"area\"]], \"c\": [ds[i][\"contrast\"]], \"t\": [t], \"last\": t})\n", + " return tracks\n", + "\n", + "\n", + "TFEATS = [\"n\", \"area\", \"aspect\", \"row\", \"sky\", \"speed\", \"straight\", \"disp\", \"contrast\", \"area_cv\", \"col\"]\n", + "\n", + "\n", + "def track_features(tr):\n", + " c = np.array([center(b) for b in tr[\"b\"]])\n", + " wh = np.array([(b[2] - b[0], b[3] - b[1]) for b in tr[\"b\"]])\n", + " steps = np.hypot(*np.diff(c, axis=0).T) if len(c) > 1 else np.array([0.0])\n", + " disp = float(np.hypot(*(c[-1] - c[0])))\n", + " return {\"n\": len(c), \"area\": float(np.mean(tr[\"a\"])), \"aspect\": float(np.mean(wh[:, 1] / wh[:, 0])), \"row\": float(c[:, 1].mean()),\n", + " \"sky\": float(c[:, 1].mean() < HORIZON), \"speed\": float(steps.mean()), \"straight\": disp / (steps.sum() + 1e-6),\n", + " \"disp\": disp, \"contrast\": float(np.mean(tr[\"c\"])), \"area_cv\": float(np.std(tr[\"a\"]) / (np.mean(tr[\"a\"]) + 1e-6)),\n", + " \"col\": float(c[:, 0].mean())}\n", + "\n", + "\n", + "def track_truth(tr, labels):\n", + " \"\"\"궤적의 각 프레임을 정답과 대응시켜 다수결로 정체를 정한다(없으면 clutter).\"\"\"\n", + " votes = []\n", + " for b, t in zip(tr[\"b\"], tr[\"t\"]):\n", + " pairs, _ = match([{\"bbox\": b}], labels[t])\n", + " votes.append((pairs[0][1][\"cls\"], pairs[0][1][\"oid\"]) if pairs[0][1] else (\"clutter\", -1))\n", + " return max(set(votes), key=votes.count)\n", + "\n", + "\n", + "def seq_tracks(seq, k=4.0, min_area=3, sky_k=None, smooth=False):\n", + " ref, fr = enhance(seq)\n", + " tracks = track(detect(ref, fr, k, min_area, sky_k, smooth))\n", + " rows = []\n", + " for tr in tracks:\n", + " f = track_features(tr); f[\"truth\"], f[\"oid\"] = track_truth(tr, seq[\"labels\"]); f[\"seq\"] = seq[\"id\"]; f[\"cond\"] = seq[\"cond\"]\n", + " f[\"tr\"] = tr; rows.append(f)\n", + " return rows\n", + "\n", + "BASE_CH = 6 # 평시 채널 수\n", + "FULL_CAPACITY = 20 # 한 틱(1초)에 정밀 처리하는 채널 수(초과분은 기능 축소 모드)\n", + "rng_all = np.random.default_rng(8)\n", + "ADVERSE = [\"night\", \"rain\", \"fog\"]\n", + "POOL = [make_seq(900 + i, cond=ADVERSE[i % 3], objs=list(rng_all.choice([\"person\", \"vehicle\", \"drone\", \"animal\", \"bird\"], 3)), wind=1.8)\n", + " for i in range(36)] # 다지점 동시 침입: 채널마다 객체 3개\n", + "PRE = {i: enhance(q) for i, q in enumerate(POOL)}\n", + "\n", + "\n", + "def full_mode(ch, t, state):\n", + " \"\"\"정밀 모드: 배경 차분 + 연속 3프레임 추적 후 경계구역 안 침입 분류만 경보.\"\"\"\n", + " ref, fr = PRE[ch]\n", + " state.setdefault(\"dets\", []).extend(detect(ref, fr[t:t + 1]))\n", + " out = []\n", + " for tr in track(state[\"dets\"]):\n", + " if len(tr[\"b\"]) >= 3 and tr[\"last\"] == t and in_roi(tr[\"b\"][-1]) and rule_class(tr[\"b\"][-1], tr[\"a\"][-1]) in INTRUDER and track_features(tr)[\"disp\"] > 4:\n", + " out.append((\"FULL\", rule_class(tr[\"b\"][-1], tr[\"a\"][-1]), id(tr)))\n", + " return out\n", + "\n", + "\n", + "def light_mode(ch, t):\n", + " \"\"\"기능 축소 모드: 추적 없이 한 프레임 탐지만으로 경보(오경보는 늘어도 미탐을 피하는 안전 실패 방향).\"\"\"\n", + " ref, fr = PRE[ch]\n", + " return [(\"LIGHT\", rule_class(d[\"bbox\"], d[\"area\"]), (ch, t, d[\"bbox\"])) for d in detect(ref, fr[t:t + 1])[0]\n", + " if in_roi(d[\"bbox\"]) and rule_class(d[\"bbox\"], d[\"area\"]) in INTRUDER]\n", + "\n", + "\n", + "def scenario(n_ch, frame_loss=0.0, sensor_out=0, packet_loss=0.0, fault_rate=0.0, seed=0):\n", + " rng = np.random.default_rng(seed)\n", + " states = [{} for _ in range(n_ch)]\n", + " generated, ledger, buffer, down = 0, [], [], 0\n", + " tick_ms, degraded, blind, caught = [], 0, 0, 0 # 다운 집계는 호출부에서 처리되지 않은 예외로 센다\n", + " dead = set(rng.choice(n_ch, sensor_out, replace=False).tolist()) if sensor_out else set()\n", + " for t in range(T):\n", + " t0 = time.perf_counter()\n", + " for ch in range(n_ch):\n", + " alerts = []\n", + " try:\n", + " if ch in dead or rng.random() < frame_loss: # 프레임 손실·센서 단절\n", + " alerts = [(\"BLIND\", \"sensor_gap\", (ch, t))]; blind += 1 # 모르면 '정상'이 아니라 '감시 공백 경보'\n", + " elif rng.random() < fault_rate:\n", + " raise RuntimeError(\"주입된 처리 오류\")\n", + " elif ch < FULL_CAPACITY:\n", + " alerts = full_mode(ch % 36, t, states[ch])\n", + " else:\n", + " alerts = light_mode(ch % 36, t); degraded += 1\n", + " except Exception:\n", + " caught += 1; alerts = [(\"FAULT\", \"process_error\", (ch, t))] # 오류는 격리하고 감시 공백으로 기록\n", + " generated += len(alerts)\n", + " buffer += alerts\n", + " for a in list(buffer): # 전송: 손실 시 버퍼에 남겨 다음 틱 재전송\n", + " if rng.random() >= packet_loss:\n", + " ledger.append(a); buffer.remove(a)\n", + " tick_ms.append((time.perf_counter() - t0) * 1000)\n", + " for a in buffer: # 마지막에 남은 건 재전송(수신 확인 후 삭제)\n", + " ledger.append(a)\n", + " kinds = pd.Series([a[0] for a in ledger]).value_counts().to_dict()\n", + " return {\"채널\": n_ch, \"배율\": f\"x{n_ch / BASE_CH:.0f}\", \"다운\": down, \"생성 경보\": generated, \"기록 경보\": len(ledger),\n", + " \"유실률\": f\"{(1 - len(ledger) / max(generated, 1)):.0%}\", \"처리오류 격리\": caught, \"축소모드 호출\": degraded,\n", + " \"감시공백\": blind, \"틱 평균 ms\": round(float(np.mean(tick_ms)), 1)}, kinds\n", + "\n", + "\n", + "cases = [(\"평시 6채널\", dict(n_ch=6)), (\"x2 12채널\", dict(n_ch=12)), (\"x4 24채널\", dict(n_ch=24)),\n", + " (\"x6 36채널(트래픽 500% 증가)\", dict(n_ch=36)),\n", + " (\"x6 + 프레임손실 15% + 전송손실 30%\", dict(n_ch=36, frame_loss=0.15, packet_loss=0.3)),\n", + " (\"x6 + 센서두절 4개 + 처리오류 5%\", dict(n_ch=36, sensor_out=4, fault_rate=0.05, packet_loss=0.3))]\n", + "rows, kinds_log = [], []\n", + "for i, (name, kw) in enumerate(cases):\n", + " try:\n", + " r, kinds = scenario(seed=i, **kw)\n", + " except Exception as e: # 처리되지 않은 예외 = 시스템 다운\n", + " r, kinds = {\"채널\": kw[\"n_ch\"], \"배율\": \"-\", \"다운\": 1, \"생성 경보\": 0, \"기록 경보\": 0, \"유실률\": \"100%\", \"처리오류 격리\": 0,\n", + " \"축소모드 호출\": 0, \"감시공백\": 0, \"틱 평균 ms\": 0}, {}\n", + " print(\"다운:\", e)\n", + " rows.append({\"상황\": name, **r}); kinds_log.append(kinds)\n", + "df = pd.DataFrame(rows)\n", + "print(df.to_string(index=False))\n", + "print(f\"\\n시스템 다운 {df['다운'].sum()}건(목표 0건), 최대 유실률 {df['유실률'].max()}(목표 0%) -> {'달성' if df['다운'].sum() == 0 and df['유실률'].max() == '0%' else '미달'}\")\n", + "print(\"기록 경보 유형(최악 상황):\", kinds_log[-1])\n", + "\n", + "# 기능 축소 모드의 대가: 채널·틱당 경보 수(운용자 부담)와 침입 객체 프레임 단위 탐지율\n", + "def load_and_hit():\n", + " f_cnt = l_cnt = hit = tot = 0\n", + " for ch in range(36):\n", + " st = {}\n", + " for t in range(T):\n", + " f_cnt += len(full_mode(ch, t, st)); l_cnt += len(light_mode(ch, t))\n", + " ref, fr = PRE[ch]\n", + " labs = [l for l in POOL[ch][\"labels\"][t] if l[\"intruder\"]]\n", + " ds = [d for d in detect(ref, fr[t:t + 1])[0] if in_roi(d[\"bbox\"]) and rule_class(d[\"bbox\"], d[\"area\"]) in INTRUDER]\n", + " pairs, missed = match(ds, labs)\n", + " hit += sum(l is not None for _, l in pairs); tot += len(labs)\n", + " return f_cnt / (36 * T), l_cnt / (36 * T), hit / max(tot, 1)\n", + "fc, lc, hr = load_and_hit()\n", + "print(f\"\\n채널·틱당 경보: 정밀 모드 {fc:.2f}건, 축소 모드 {lc:.2f}건(운용자 부담 {lc / max(fc, 1e-9):.1f}배), 축소 모드 침입 객체 프레임 탐지율 {hr:.1%}\")\n", + "print(\"안전 실패 원칙: 처리 용량을 넘으면 정밀도를 낮춰 경보를 더 내는 쪽(축소 모드)으로, 센서 두절·오류는 '정상'이 아니라 '감시 공백 경보'로 처리한다. 경보는 어느 경우에도 버리지 않고 전송 확인 후 삭제한다.\")\n", + "\n", + "plt.figure(figsize=(9, 4))\n", + "plt.bar(range(len(df)), df[\"틱 평균 ms\"], color=[\"tab:blue\"] * 3 + [\"tab:orange\"] * 3)\n", + "plt.xticks(range(len(df)), [f\"case{i + 1}\" for i in range(len(df))]); plt.ylabel(\"ms per tick\")\n", + "plt.axhline(1000, color=\"red\", ls=\"--\", label=\"1 s refresh budget\"); plt.legend(); plt.title(\"Stress test: tick latency (orange = 36 channels)\")\n", + "plt.show()\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 08 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + " 상황 채널 배율 다운 생성 경보 기록 경보 유실률 처리오류 격리 축소모드 호출 감시공백 틱 평균 ms\n", + " 평시 6채널 6 x1 0 54 54 0% 0 0 0 15.4\n", + " x2 12채널 12 x2 0 120 120 0% 0 0 0 35.2\n", + " x4 24채널 24 x4 0 271 271 0% 0 40 0 71.9\n", + " x6 36채널(트래픽 500% 증가) 36 x6 0 521 521 0% 0 160 0 102.7\n", + "x6 + 프레임손실 15% + 전송손실 30% 36 x6 0 422 422 0% 0 135 49 84.1\n", + " x6 + 센서두절 4개 + 처리오류 5% 36 x6 0 444 444 0% 19 126 40 90.1\n", + "\n", + "시스템 다운 0건(목표 0건), 최대 유실률 0%(목표 0%) -> 달성\n", + "기록 경보 유형(최악 상황): {'LIGHT': 256, 'FULL': 129, 'BLIND': 40, 'FAULT': 19}\n", + "\n", + "채널·틱당 경보: 정밀 모드 0.97건, 축소 모드 2.07건(운용자 부담 2.1배), 축소 모드 침입 객체 프레임 탐지율 81.1%\n", + "안전 실패 원칙: 처리 용량을 넘으면 정밀도를 낮춰 경보를 더 내는 쪽(축소 모드)으로, 센서 두절·오류는 '정상'이 아니라 '감시 공백 경보'로 처리한다. 경보는 어느 경우에도 버리지 않고 전송 확인 후 삭제한다.\n", + "```\n", + "
\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 09. AAR 기반 오경보 유발 객체 분석 및 경계구역 탐지 감도 파라미터 재보정\n", + "**소요시간**: 약 3.0시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "AAR 사후검토를 통해 오경보 원인을 분석하고 경계구역 감도 파라미터를 재보정한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **AAR(사후검토)로 오경보 원인을 분석하고 경계구역 탐지 감도 파라미터를 재보정한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **초기 설정(k=3.0, 경보 임계값 0.3)의 모의 운용 결과에서 오경보를 수목·동물·조류와 기상·구역별로 분류하고 미탐 원인을 구분 수행하고 검증하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `docs/sec01_aar_report.md 및 configs/sec01_tuned_params.yaml` 형태의 결과물을 완성한다.\n", + "\n", + "### 🔍 평가 및 검증 기준 (PASS 조건)\n", + "#### 미션 판정\n", + "\n", + "**평가 구분:** 필수\n", + "\n", + "**핵심 평가영역:** 오류 원인 규명의 객관성 및 개선 조치의 논리적 타당성\n", + "\n", + "#### 미션 PASS 조건\n", + "\n", + "- 정량 기준: AAR 피드백 적용 후 종합 정확도 10%p 이상 향상, 재발 오류 0건\n", + "\n", + "\n", + "- 진행 기준: AAR 보고서 작성 및 파라미터 튜닝 전후 회귀 테스트 통과\n", + "\n", + "\n", + "- 검증 방법: 회귀 테스트(Regression Test) 스위트 전체 자동 실행 및 비교\n", + "\n", + "\n", + "#### 채점자 판정 메모\n", + "\n", + "> 단순 코드 실행 여부뿐만 아니라 출력된 수치와 근거 자료가 군 실무 요구사항에 부합하는지 면밀히 확인한다.\n", + "\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "\"\"\"AAR(사후검토) 일지 형식으로 모의 운용의 오경보·미탐을 원인별(수목 흔들림·동물·조류·추적 실패 등)로 분류하고, 탐지 감도 k·최소 지속 프레임·경보 임계값을 제약 조건 안에서 격자 탐색으로 재보정해 개선 전후 성능과 재발 여부를 비교한다.\"\"\"\n", + "import numpy as np\n", + "import pandas as pd\n", + "import matplotlib.pyplot as plt\n", + "from scipy import ndimage\n", + "from sklearn.linear_model import LogisticRegression\n", + "from sklearn.pipeline import make_pipeline\n", + "from sklearn.preprocessing import StandardScaler\n", + "\n", + "# ---------------- [교육용가상자료] 합성 CCTV 영상 생성기 ----------------\n", + "# 실제 영상 대신 코드로 그린 객체(사각형·점)로 기지 외곽 고정 카메라 화면을 흉내 낸다.\n", + "H, W, T = 64, 96, 10 # 화면 크기(px), 시퀀스 길이(프레임)\n", + "HORIZON, FENCE = 20, 46 # 하늘/지면 경계, 울타리 행\n", + "ROI_ROWS = (32, 48) # 울타리 접근 경계구역(ROI): 지면 32~47행\n", + "CLASSES = [\"person\", \"vehicle\", \"drone\", \"animal\", \"bird\"]\n", + "INTRUDER = {\"person\", \"vehicle\", \"drone\"} # 비인가 접근(경보 대상)\n", + "CONDS = [\"day\", \"night\", \"rain\", \"fog\"]\n", + "\n", + "\n", + "def background(rng):\n", + " # [TODO : 학생 실습 9] AAR 기반 오경보 유발 객체 분석 및 경계구역 탐지 감도 파라미터 재보정 관련 핵심 로직을 구현하세요.\n", + " # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n", + " # <<< 여기에 코드를 작성하세요 >>>\n", + " pass # 작성 후 pass를 제거하세요.\n", + "def tree(img, t, wind):\n", + " \"\"\"좌측 수목 수관: 바람 세기에 따라 좌우로 흔들려 오경보를 만든다.\"\"\"\n", + " # [TODO : 학생 실습 9] AAR 기반 오경보 유발 객체 분석 및 경계구역 탐지 감도 파라미터 재보정 관련 핵심 로직을 구현하세요.\n", + " # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n", + " # <<< 여기에 코드를 작성하세요 >>>\n", + " pass # 작성 후 pass를 제거하세요.\n", + "def make_seq(seed, cond=None, objs=None, wind=None, camo=False):\n", + " rng = np.random.default_rng(seed)\n", + " cond = cond or str(rng.choice(CONDS))\n", + " wind = rng.uniform(0, 2) if wind is None else wind\n", + " bg = background(rng)\n", + " n = int(rng.integers(1, 4)) if objs is None else len(objs)\n", + " kinds = objs or list(rng.choice(CLASSES, n, p=[0.3, 0.15, 0.25, 0.15, 0.15]))\n", + " obs = []\n", + " for k in kinds:\n", + " if k == \"person\":\n", + " o = dict(y=rng.uniform(30, 36), x=rng.uniform(15, 85), vy=rng.uniform(0.8, 1.2), vx=rng.uniform(-0.6, 0.6), h=7, w=3,\n", + " val=72 if camo else rng.uniform(35, 50))\n", + " elif k == \"vehicle\":\n", + " o = dict(y=rng.uniform(33, 38), x=rng.uniform(10, 30), vy=0.2, vx=rng.uniform(2, 4), h=5, w=10, val=rng.uniform(150, 170))\n", + " elif k == \"drone\":\n", + " o = dict(y=rng.uniform(3, 15), x=rng.uniform(10, 80), vy=rng.uniform(0, 0.6), vx=rng.uniform(-2, 2), h=2, w=2 if camo else 3,\n", + " val=rng.uniform(80, 120))\n", + " elif k == \"animal\":\n", + " o = dict(y=rng.uniform(34, 42), x=rng.uniform(15, 80), vy=0, vx=rng.uniform(-1.5, 1.5), h=2, w=4, val=rng.uniform(60, 75))\n", + " else:\n", + " o = dict(y=rng.uniform(3, 15), x=rng.uniform(10, 80), vy=0, vx=rng.uniform(-3, 3), h=1, w=2, val=rng.uniform(100, 130))\n", + " o[\"cls\"] = str(k); obs.append(o)\n", + " frames, labels = [], []\n", + " for t in range(-8, T): # 음수 시각 = 객체 없는 배경 학습 구간(사전 관측)\n", + " f = bg.copy(); tree(f, t, wind); lab = []\n", + " for oi, o in enumerate(obs if t >= 0 else []):\n", + " jit = rng.normal(0, 1.2, 2) if o[\"cls\"] in (\"animal\", \"bird\") else (0, 0) # 불규칙 움직임\n", + " y, x = int(o[\"y\"] + o[\"vy\"] * t + jit[0]), int(o[\"x\"] + o[\"vx\"] * t + jit[1])\n", + " y0, x0, y1, x1 = max(y, 0), max(x, 0), min(y + o[\"h\"], H), min(x + o[\"w\"], W)\n", + " if y1 > y0 and x1 > x0:\n", + " f[y0:y1, x0:x1] = o[\"val\"]\n", + " if o[\"cls\"] == \"person\":\n", + " f[max(y0 - 2, 0):y0, x0 + 1:x0 + 2] = o[\"val\"] # 머리\n", + " lab.append({\"cls\": o[\"cls\"], \"oid\": oi, \"bbox\": (x0, y0, x1, y1), \"intruder\": o[\"cls\"] in INTRUDER})\n", + " if cond == \"night\":\n", + " f = f * 0.5 + 10 + rng.normal(0, 3.5, f.shape)\n", + " else:\n", + " f = f + rng.normal(0, 2.5, f.shape)\n", + " if cond == \"rain\":\n", + " m = rng.random(f.shape) < 0.006\n", + " for d in range(3):\n", + " f[d:][m[:H - d]] += 45\n", + " if cond == \"fog\":\n", + " f = 0.45 * f + 0.55 * 150\n", + " frames.append(np.clip(f, 0, 255)); labels.append(lab)\n", + " fr = np.array(frames)\n", + " return {\"id\": f\"CAM-{seed:04d}\", \"cond\": cond, \"wind\": wind, \"ref\": fr[:8], \"frames\": fr[8:], \"labels\": labels[8:]}\n", + "\n", + "# ---------------- 전처리·탐지 공통 함수(배경 차분 기반) ----------------\n", + "def enhance(seq):\n", + " \"\"\"조도 보정(백분위 신장) + 비 조건의 빗줄기 제거(3x3 중앙값). 배경 학습 구간(ref)과 같은 기준으로 보정한다.\"\"\"\n", + " allf = np.concatenate([seq[\"ref\"], seq[\"frames\"]])\n", + " f = ndimage.median_filter(allf, size=(1, 3, 3)) if seq[\"cond\"] == \"rain\" else allf\n", + " lo, hi = np.percentile(f, [1, 99.5])\n", + " f = np.clip((f - lo) / (hi - lo + 1e-6) * 255, 0, 255)\n", + " return f[:len(seq[\"ref\"])], f[len(seq[\"ref\"]):]\n", + "\n", + "\n", + "def detect(ref, frames, k=4.0, min_area=3, sky_k=None, smooth=False):\n", + " \"\"\"배경 학습 구간의 중앙값 배경과 차이를 강건 임계값(k x MAD)으로 이진화해 연결 성분을 객체 후보로 만든다.\n", + " smooth=True 이면 연속 3프레임 평균으로 시간 잡음을 줄여 저대비 객체를 살린다.\"\"\"\n", + " bg = np.median(ref, axis=0)\n", + " if smooth:\n", + " frames = ndimage.uniform_filter1d(frames, 3, axis=0, mode=\"nearest\")\n", + " out = []\n", + " for f in frames:\n", + " d = np.abs(f - bg)\n", + " mad = np.median(d) * 1.4826 + 1e-6\n", + " thr = np.full(d.shape, k * mad)\n", + " if sky_k is not None:\n", + " thr[:HORIZON] = sky_k * mad\n", + " lab, n = ndimage.label(d > thr)\n", + " dets = []\n", + " for i, sl in enumerate(ndimage.find_objects(lab), start=1):\n", + " area = int((lab[sl] == i).sum())\n", + " if area >= min_area:\n", + " dets.append({\"bbox\": (sl[1].start, sl[0].start, sl[1].stop, sl[0].stop), \"area\": area,\n", + " \"contrast\": float(d[sl][lab[sl] == i].mean() / mad)})\n", + " out.append(dets)\n", + " return out\n", + "\n", + "\n", + "def center(b):\n", + " return ((b[0] + b[2]) / 2, (b[1] + b[3]) / 2)\n", + "\n", + "\n", + "def match(dets, labels, dist=4.0):\n", + " \"\"\"탐지와 정답을 중심 거리로 1:1 대응시킨다. 반환: [(det, label or None)], 놓친 label 목록\"\"\"\n", + " used, pairs = set(), []\n", + " for d in dets:\n", + " cd = center(d[\"bbox\"]); best, bj = dist, None\n", + " for j, l in enumerate(labels):\n", + " cl = center(l[\"bbox\"]); e = np.hypot(cd[0] - cl[0], cd[1] - cl[1])\n", + " if j not in used and e < best:\n", + " best, bj = e, j\n", + " if bj is not None:\n", + " used.add(bj)\n", + " pairs.append((d, labels[bj] if bj is not None else None))\n", + " return pairs, [l for j, l in enumerate(labels) if j not in used]\n", + "\n", + "\n", + "def in_roi(b):\n", + " cy = (b[1] + b[3]) / 2\n", + " return cy < HORIZON or ROI_ROWS[0] <= cy < ROI_ROWS[1]\n", + "\n", + "\n", + "def rule_class(b, area):\n", + " \"\"\"Baseline 규칙: 위치(하늘/지면)·크기·종횡비로 1차 분류(학습 없음, 판단 근거가 그대로 드러난다)\"\"\"\n", + " w, h = b[2] - b[0], b[3] - b[1]\n", + " cy = (b[1] + b[3]) / 2\n", + " if cy < HORIZON:\n", + " return \"drone\" if 3 <= area <= 12 else \"clutter\"\n", + " if area >= 30 and w > h:\n", + " return \"vehicle\"\n", + " if h >= w and 6 <= area <= 30:\n", + " return \"person\"\n", + " return \"clutter\"\n", + "\n", + "# ---------------- 프레임 간 추적과 궤적 특징 ----------------\n", + "def track(dets, gate=6.0):\n", + " \"\"\"최근접 이웃으로 프레임별 탐지를 궤적으로 잇는다.\"\"\"\n", + " tracks = []\n", + " for t, ds in enumerate(dets):\n", + " free = list(range(len(ds)))\n", + " for tr in tracks:\n", + " if tr[\"last\"] != t - 1 or not free:\n", + " continue\n", + " c0 = center(tr[\"b\"][-1])\n", + " dd = [np.hypot(center(ds[i][\"bbox\"])[0] - c0[0], center(ds[i][\"bbox\"])[1] - c0[1]) for i in free]\n", + " j = int(np.argmin(dd))\n", + " if dd[j] < gate:\n", + " i = free.pop(j); tr[\"b\"].append(ds[i][\"bbox\"]); tr[\"a\"].append(ds[i][\"area\"]); tr[\"c\"].append(ds[i][\"contrast\"])\n", + " tr[\"t\"].append(t); tr[\"last\"] = t\n", + " for i in free:\n", + " tracks.append({\"b\": [ds[i][\"bbox\"]], \"a\": [ds[i][\"area\"]], \"c\": [ds[i][\"contrast\"]], \"t\": [t], \"last\": t})\n", + " return tracks\n", + "\n", + "\n", + "TFEATS = [\"n\", \"area\", \"aspect\", \"row\", \"sky\", \"speed\", \"straight\", \"disp\", \"contrast\", \"area_cv\", \"col\"]\n", + "\n", + "\n", + "def track_features(tr):\n", + " c = np.array([center(b) for b in tr[\"b\"]])\n", + " wh = np.array([(b[2] - b[0], b[3] - b[1]) for b in tr[\"b\"]])\n", + " steps = np.hypot(*np.diff(c, axis=0).T) if len(c) > 1 else np.array([0.0])\n", + " disp = float(np.hypot(*(c[-1] - c[0])))\n", + " return {\"n\": len(c), \"area\": float(np.mean(tr[\"a\"])), \"aspect\": float(np.mean(wh[:, 1] / wh[:, 0])), \"row\": float(c[:, 1].mean()),\n", + " \"sky\": float(c[:, 1].mean() < HORIZON), \"speed\": float(steps.mean()), \"straight\": disp / (steps.sum() + 1e-6),\n", + " \"disp\": disp, \"contrast\": float(np.mean(tr[\"c\"])), \"area_cv\": float(np.std(tr[\"a\"]) / (np.mean(tr[\"a\"]) + 1e-6)),\n", + " \"col\": float(c[:, 0].mean())}\n", + "\n", + "\n", + "def track_truth(tr, labels):\n", + " \"\"\"궤적의 각 프레임을 정답과 대응시켜 다수결로 정체를 정한다(없으면 clutter).\"\"\"\n", + " votes = []\n", + " for b, t in zip(tr[\"b\"], tr[\"t\"]):\n", + " pairs, _ = match([{\"bbox\": b}], labels[t])\n", + " votes.append((pairs[0][1][\"cls\"], pairs[0][1][\"oid\"]) if pairs[0][1] else (\"clutter\", -1))\n", + " return max(set(votes), key=votes.count)\n", + "\n", + "\n", + "def seq_tracks(seq, k=4.0, min_area=3, sky_k=None, smooth=False):\n", + " ref, fr = enhance(seq)\n", + " tracks = track(detect(ref, fr, k, min_area, sky_k, smooth))\n", + " rows = []\n", + " for tr in tracks:\n", + " f = track_features(tr); f[\"truth\"], f[\"oid\"] = track_truth(tr, seq[\"labels\"]); f[\"seq\"] = seq[\"id\"]; f[\"cond\"] = seq[\"cond\"]\n", + " f[\"tr\"] = tr; rows.append(f)\n", + " return rows\n", + "\n", + "TRAIN_SEEDS, OPS_SEEDS = range(0, 80), range(3000, 3060)\n", + "\n", + "\n", + "def tracks_of(seeds, k, wind=None):\n", + " out = []\n", + " for s in seeds:\n", + " q = make_seq(s, wind=wind)\n", + " for r in seq_tracks(q, k=k, min_area=2, sky_k=k - 0.5):\n", + " if in_roi(r[\"tr\"][\"b\"][-1]):\n", + " out.append(r)\n", + " return out\n", + "\n", + "\n", + "def X_of(rows):\n", + " return np.array([[r[k] for k in TFEATS] for r in rows])\n", + "\n", + "\n", + "_CACHE = {}\n", + "\n", + "\n", + "def run_system(k):\n", + " if k not in _CACHE:\n", + " _CACHE[k] = _run_system(k)\n", + " return _CACHE[k]\n", + "\n", + "\n", + "def _run_system(k):\n", + " \"\"\"탐지 감도 k로 학습·운용 데이터를 처리하고, 운용 궤적마다 침입 확률(prob)을 붙인다.\"\"\"\n", + " tr, te = tracks_of(TRAIN_SEEDS, k), tracks_of(OPS_SEEDS, k)\n", + " m = make_pipeline(StandardScaler(), LogisticRegression(class_weight=\"balanced\", C=2.0, max_iter=2000, random_state=0))\n", + " m.fit(X_of(tr), [r[\"truth\"] in INTRUDER for r in tr])\n", + " for r, p in zip(te, m.predict_proba(X_of(te))[:, 1]):\n", + " r[\"prob\"] = float(p)\n", + " return te\n", + "\n", + "\n", + "def objects():\n", + " return {(q[\"id\"], l[\"oid\"]) for q in (make_seq(s) for s in OPS_SEEDS) for labs in q[\"labels\"] for l in labs if l[\"intruder\"]}\n", + "\n", + "\n", + "OBJS = objects()\n", + "\n", + "\n", + "def set_alerts(te, thr, min_n):\n", + " for r in te:\n", + " r[\"alert\"] = r[\"n\"] >= min_n and r[\"prob\"] >= thr\n", + " return te\n", + "\n", + "\n", + "def summarize(te):\n", + " al = [r for r in te if r[\"alert\"]]\n", + " far = sum(r[\"truth\"] not in INTRUDER for r in al) / max(len(al), 1)\n", + " found = {(r[\"seq\"], r[\"oid\"]) for r in al if r[\"truth\"] in INTRUDER}\n", + " rec = len(found & OBJS) / len(OBJS)\n", + " return {\"경보\": len(al), \"오경보율\": round(far, 3), \"재현율\": round(rec, 3), \"종합정확도(F1)\": round(2 * (1 - far) * rec / max(1 - far + rec, 1e-9), 3)}\n", + "\n", + "\n", + "def aar_log(te):\n", + " rows = []\n", + " for r in te:\n", + " if r[\"alert\"] and r[\"truth\"] not in INTRUDER:\n", + " where = \"수목 구역(x<16)\" if r[\"col\"] < 16 else \"그 외\"\n", + " rows.append({\"구분\": \"오경보\", \"원인\": {\"clutter\": \"수목·잡음\", \"animal\": \"야생동물\", \"bird\": \"조류\"}[r[\"truth\"]], \"조건\": r[\"cond\"], \"구역\": where, \"시퀀스\": r[\"seq\"]})\n", + " alerted = {(r[\"seq\"], r[\"oid\"]) for r in te if r[\"alert\"] and r[\"truth\"] in INTRUDER}\n", + " tracked = {(r[\"seq\"], r[\"oid\"]) for r in te if r[\"truth\"] in INTRUDER}\n", + " for o in sorted(OBJS):\n", + " if o not in alerted:\n", + " rows.append({\"구분\": \"미탐\", \"원인\": \"점수 미달·지속 부족\" if o in tracked else \"추적 실패(탐지 누락)\", \"조건\": \"-\", \"구역\": \"-\", \"시퀀스\": o[0]})\n", + " return pd.DataFrame(rows, columns=[\"구분\", \"원인\", \"조건\", \"구역\", \"시퀀스\"])\n", + "\n", + "\n", + "# 1) 초기 설정으로 모의 운용: '놓치지 않기' 우선(낮은 임계값, 짧은 지속 조건)\n", + "INIT = {\"k\": 3.0, \"alert_thr\": 0.3, \"min_frames\": 2}\n", + "te0 = set_alerts(run_system(INIT[\"k\"]), INIT[\"alert_thr\"], INIT[\"min_frames\"])\n", + "before = summarize(te0)\n", + "log = aar_log(te0)\n", + "print(\"[1. 무엇이 일어났나] 초기 설정\", INIT, \"->\", before)\n", + "print(log.groupby([\"구분\", \"원인\"]).size().rename(\"건수\").to_string())\n", + "print(\"\\n[2. 왜 일어났나] 오경보 조건 분포:\", log[log[\"구분\"] == \"오경보\"][\"조건\"].value_counts().to_dict(), \"/ 구역:\", log[log[\"구분\"] == \"오경보\"][\"구역\"].value_counts().to_dict())\n", + "\n", + "# 2) 재보정: 제약(오경보율 15% 이하, 재현율 85% 이상) 안에서 F1 최대 조합 탐색\n", + "grid = []\n", + "for k in (2.5, 3.0, 3.5):\n", + " te = run_system(k)\n", + " for thr in (0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8):\n", + " for mn in (2, 3, 4):\n", + " grid.append({\"k\": k, \"경보임계값\": thr, \"최소프레임\": mn, **summarize(set_alerts(te, thr, mn))})\n", + "g = pd.DataFrame(grid)\n", + "seen = set(map(tuple, log[log[\"구분\"] == \"오경보\"][[\"시퀀스\", \"원인\"]].values))\n", + "\n", + "\n", + "def repeats(row):\n", + " \"\"\"AAR에서 식별한 오경보(시퀀스·원인)가 이 설정에서 다시 나오는 건수\"\"\"\n", + " t = set_alerts(run_system(row[\"k\"]), row[\"경보임계값\"], int(row[\"최소프레임\"]))\n", + " return len(seen & set(map(tuple, aar_log(t)[lambda d: d[\"구분\"] == \"오경보\"][[\"시퀀스\", \"원인\"]].values)))\n", + "\n", + "\n", + "ok = g[(g[\"오경보율\"] <= 0.15) & (g[\"재현율\"] >= 0.85)].copy()\n", + "ok[\"재발\"] = ok.apply(repeats, axis=1)\n", + "ok = ok.sort_values([\"재발\", \"종합정확도(F1)\", \"재현율\"], ascending=[True, False, False]) # 재발 최소 -> F1 최대 순\n", + "best = ok.iloc[0]\n", + "print(\"\\n[3. 무엇을 바꾸나] 제약 만족 조합 상위 5개(재발 적은 순) (전체 조합 %d개 중 %d개 만족)\" % (len(g), len(ok)))\n", + "print(ok.head().to_string(index=False))\n", + "te1 = set_alerts(run_system(best[\"k\"]), best[\"경보임계값\"], int(best[\"최소프레임\"]))\n", + "after = summarize(te1)\n", + "log1 = aar_log(te1)\n", + "gain = (after[\"종합정확도(F1)\"] - before[\"종합정확도(F1)\"]) * 100\n", + "print(f\"\\n재보정 후: {after}\")\n", + "print(f\"종합정확도 변화 {before['종합정확도(F1)']:.3f} -> {after['종합정확도(F1)']:.3f} ({gain:+.1f}%p, 목표 +10%p 이상: {'달성' if gain >= 10 else '미달'})\")\n", + "rep = log[log[\"구분\"] == \"오경보\"].groupby([\"시퀀스\", \"원인\"]).size().index.intersection(log1[log1[\"구분\"] == \"오경보\"].groupby([\"시퀀스\", \"원인\"]).size().index)\n", + "print(f\"AAR에서 식별한 오경보 {log['구분'].eq('오경보').sum()}건 -> 재보정 후 {log1['구분'].eq('오경보').sum()}건, 같은 시퀀스·같은 원인으로 재발한 오류 {len(rep)}건 (목표 0건: {'달성' if len(rep) == 0 else '미달'})\")\n", + "\n", + "# 3) 재보정 파라미터(yaml 내용 문자열)\n", + "tuned = f\"detection_k: {best['k']}\\nalert_threshold: {best['경보임계값']}\\nmin_track_frames: {int(best['최소프레임'])}\\nconstraints: {{max_far: 0.15, min_recall: 0.85}}\"\n", + "print(\"\\n[재보정 파라미터(sec01_tuned_params.yaml 내용)]\\n\" + tuned)\n", + "print(\"개선 조치: 탐지 감도 k를 올리고 최소 지속 프레임 조건을 강화해 수목·동물·조류 오경보를 줄이고, 재보정값은 현장 영상으로 재검증 후 적용한다.\")\n", + "\n", + "plt.figure(figsize=(6, 4))\n", + "for k in (2.5, 3.0, 3.5):\n", + " s = g[g[\"k\"] == k]\n", + " plt.plot(s[\"오경보율\"], s[\"재현율\"], \"o\", alpha=0.6, label=f\"k={k}\")\n", + "plt.axvline(0.15, ls=\"--\", c=\"gray\"); plt.axhline(0.85, ls=\"--\", c=\"gray\")\n", + "plt.xlabel(\"false alarm rate\"); plt.ylabel(\"intruder recall\"); plt.legend(); plt.title(\"AAR re-tuning grid (synthetic)\")\n", + "plt.show()\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 09 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + "[1. 무엇이 일어났나] 초기 설정 {'k': 3.0, 'alert_thr': 0.3, 'min_frames': 2} -> {'경보': 135, '오경보율': 0.43, '재현율': 0.963, '종합정확도(F1)': 0.716}\n", + "구분 원인 \n", + "미탐 점수 미달·지속 부족 2\n", + " 추적 실패(탐지 누락) 1\n", + "오경보 수목·잡음 31\n", + " 야생동물 13\n", + " 조류 14\n", + "\n", + "[2. 왜 일어났나] 오경보 조건 분포: {'rain': 37, 'fog': 10, 'night': 8, 'day': 3} / 구역: {'그 외': 43, '수목 구역(x<16)': 15}\n", + "\n", + "[3. 무엇을 바꾸나] 제약 만족 조합 상위 5개(재발 적은 순) (전체 조합 63개 중 9개 만족)\n", + " k 경보임계값 최소프레임 경보 오경보율 재현율 종합정확도(F1) 재발\n", + "3.5 0.6 4 84 0.083 0.963 0.939 7\n", + "3.5 0.7 4 83 0.084 0.950 0.933 7\n", + "3.5 0.8 4 82 0.085 0.938 0.926 7\n", + "3.5 0.7 3 84 0.095 0.950 0.927 8\n", + "3.5 0.8 3 83 0.096 0.938 0.920 8\n", + "\n", + "재보정 후: {'경보': 84, '오경보율': 0.083, '재현율': 0.963, '종합정확도(F1)': 0.939}\n", + "종합정확도 변화 0.716 -> 0.939 (+22.3%p, 목표 +10%p 이상: 달성)\n", + "AAR에서 식별한 오경보 58건 -> 재보정 후 7건, 같은 시퀀스·같은 원인으로 재발한 오류 7건 (목표 0건: 미달)\n", + "\n", + "[재보정 파라미터(sec01_tuned_params.yaml 내용)]\n", + "detection_k: 3.5\n", + "alert_threshold: 0.6\n", + "min_track_frames: 4\n", + "constraints: {max_far: 0.15, min_recall: 0.85}\n", + "개선 조치: 탐지 감도 k를 올리고 최소 지속 프레임 조건을 강화해 수목·동물·조류 오경보를 줄이고, 재보정값은 현장 영상으로 재검증 후 적용한다.\n", + "```\n", + "
\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 10. 기지 CCTV 비인가 접근 탐지 시스템 통합 및 시설 방호 브리핑 패키지 완성\n", + "**소요시간**: 약 3.5시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "기지 CCTV 비인가 접근 탐지 시스템을 최종 통합하고 기지 방호 브리핑 패키지를 완성한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **데이터 검증부터 전처리, 탐지, 신뢰도, HITL, 상황판, 재보정값 적용까지 전체 파이프라인을 통합하고 시설 방호 브리핑 패키지를 완성한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **재보정 파라미터로 전체 과정을 한 번에 실행하는 통합 코드를 완성하고 손상 데이터와 센서 단절 프레임이 자동 처리되는지 확인하고 유효성을 점검하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `src/main_sec01.py, README.md, docs/sec01_final_report.pdf(md)` 형태의 결과물을 완성한다.\n", + "\n", + "### 🔍 평가 및 검증 기준 (PASS 조건)\n", + "#### 미션 판정\n", + "\n", + "**평가 구분:** 필수\n", + "\n", + "**핵심 평가영역:** 군 실무 즉시 도입 가능성, 운용 신뢰도 및 전략적 기대효과\n", + "\n", + "#### 미션 PASS 조건\n", + "\n", + "- 정량 기준: End-to-End 통합 파이프라인 실행 성공률 100%, 테스트 커버리지 85% 이상\n", + "\n", + "\n", + "- 진행 기준: 원클릭 실행 스크립트 동작 확인 및 최종 발표 산출물 작성 완료\n", + "\n", + "\n", + "- 검증 방법: 최종 검증 위원회(평가관) 모의 시연 및 전체 기능 통합 검증\n", + "\n", + "\n", + "#### 채점자 판정 메모\n", + "\n", + "> 단순 코드 실행 여부뿐만 아니라 출력된 수치와 근거 자료가 군 실무 요구사항에 부합하는지 면밀히 확인한다.\n", + "\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "\"\"\"데이터 검증, 센서 단절 보정·전처리, 객체 탐지·추적, 신뢰도 점수, HITL 검토 대기열, 관제 상황판, AAR 재보정값 적용까지 비인가 접근 탐지 전체 파이프라인을 한 파일로 통합 실행하고, 자체 점검(실행 라인 커버리지 포함)과 시설 방호 브리핑 요약을 출력한다.\"\"\"\n", + "import dis\n", + "import hashlib\n", + "import json\n", + "import sys\n", + "import time\n", + "import numpy as np\n", + "import pandas as pd\n", + "import matplotlib.pyplot as plt\n", + "from scipy import ndimage\n", + "from sklearn.linear_model import LogisticRegression\n", + "from sklearn.pipeline import make_pipeline\n", + "from sklearn.preprocessing import StandardScaler\n", + "\n", + "# ---------------- [교육용가상자료] 합성 CCTV 영상 생성기 ----------------\n", + "# 실제 영상 대신 코드로 그린 객체(사각형·점)로 기지 외곽 고정 카메라 화면을 흉내 낸다.\n", + "H, W, T = 64, 96, 10 # 화면 크기(px), 시퀀스 길이(프레임)\n", + "HORIZON, FENCE = 20, 46 # 하늘/지면 경계, 울타리 행\n", + "ROI_ROWS = (32, 48) # 울타리 접근 경계구역(ROI): 지면 32~47행\n", + "CLASSES = [\"person\", \"vehicle\", \"drone\", \"animal\", \"bird\"]\n", + "INTRUDER = {\"person\", \"vehicle\", \"drone\"} # 비인가 접근(경보 대상)\n", + "CONDS = [\"day\", \"night\", \"rain\", \"fog\"]\n", + "\n", + "\n", + "def background(rng):\n", + " # [TODO : 학생 실습 10] 기지 CCTV 비인가 접근 탐지 시스템 통합 및 시설 방호 브리핑 패키지 완성 관련 핵심 로직을 구현하세요.\n", + " # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n", + " # <<< 여기에 코드를 작성하세요 >>>\n", + " pass # 작성 후 pass를 제거하세요.\n", + "def tree(img, t, wind):\n", + " \"\"\"좌측 수목 수관: 바람 세기에 따라 좌우로 흔들려 오경보를 만든다.\"\"\"\n", + " # [TODO : 학생 실습 10] 기지 CCTV 비인가 접근 탐지 시스템 통합 및 시설 방호 브리핑 패키지 완성 관련 핵심 로직을 구현하세요.\n", + " # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n", + " # <<< 여기에 코드를 작성하세요 >>>\n", + " pass # 작성 후 pass를 제거하세요.\n", + "def make_seq(seed, cond=None, objs=None, wind=None, camo=False):\n", + " rng = np.random.default_rng(seed)\n", + " cond = cond or str(rng.choice(CONDS))\n", + " wind = rng.uniform(0, 2) if wind is None else wind\n", + " bg = background(rng)\n", + " n = int(rng.integers(1, 4)) if objs is None else len(objs)\n", + " kinds = objs or list(rng.choice(CLASSES, n, p=[0.3, 0.15, 0.25, 0.15, 0.15]))\n", + " obs = []\n", + " for k in kinds:\n", + " if k == \"person\":\n", + " o = dict(y=rng.uniform(30, 36), x=rng.uniform(15, 85), vy=rng.uniform(0.8, 1.2), vx=rng.uniform(-0.6, 0.6), h=7, w=3,\n", + " val=72 if camo else rng.uniform(35, 50))\n", + " elif k == \"vehicle\":\n", + " o = dict(y=rng.uniform(33, 38), x=rng.uniform(10, 30), vy=0.2, vx=rng.uniform(2, 4), h=5, w=10, val=rng.uniform(150, 170))\n", + " elif k == \"drone\":\n", + " o = dict(y=rng.uniform(3, 15), x=rng.uniform(10, 80), vy=rng.uniform(0, 0.6), vx=rng.uniform(-2, 2), h=2, w=2 if camo else 3,\n", + " val=rng.uniform(80, 120))\n", + " elif k == \"animal\":\n", + " o = dict(y=rng.uniform(34, 42), x=rng.uniform(15, 80), vy=0, vx=rng.uniform(-1.5, 1.5), h=2, w=4, val=rng.uniform(60, 75))\n", + " else:\n", + " o = dict(y=rng.uniform(3, 15), x=rng.uniform(10, 80), vy=0, vx=rng.uniform(-3, 3), h=1, w=2, val=rng.uniform(100, 130))\n", + " o[\"cls\"] = str(k); obs.append(o)\n", + " frames, labels = [], []\n", + " for t in range(-8, T): # 음수 시각 = 객체 없는 배경 학습 구간(사전 관측)\n", + " f = bg.copy(); tree(f, t, wind); lab = []\n", + " for oi, o in enumerate(obs if t >= 0 else []):\n", + " jit = rng.normal(0, 1.2, 2) if o[\"cls\"] in (\"animal\", \"bird\") else (0, 0) # 불규칙 움직임\n", + " y, x = int(o[\"y\"] + o[\"vy\"] * t + jit[0]), int(o[\"x\"] + o[\"vx\"] * t + jit[1])\n", + " y0, x0, y1, x1 = max(y, 0), max(x, 0), min(y + o[\"h\"], H), min(x + o[\"w\"], W)\n", + " if y1 > y0 and x1 > x0:\n", + " f[y0:y1, x0:x1] = o[\"val\"]\n", + " if o[\"cls\"] == \"person\":\n", + " f[max(y0 - 2, 0):y0, x0 + 1:x0 + 2] = o[\"val\"] # 머리\n", + " lab.append({\"cls\": o[\"cls\"], \"oid\": oi, \"bbox\": (x0, y0, x1, y1), \"intruder\": o[\"cls\"] in INTRUDER})\n", + " if cond == \"night\":\n", + " f = f * 0.5 + 10 + rng.normal(0, 3.5, f.shape)\n", + " else:\n", + " f = f + rng.normal(0, 2.5, f.shape)\n", + " if cond == \"rain\":\n", + " m = rng.random(f.shape) < 0.006\n", + " for d in range(3):\n", + " f[d:][m[:H - d]] += 45\n", + " if cond == \"fog\":\n", + " f = 0.45 * f + 0.55 * 150\n", + " frames.append(np.clip(f, 0, 255)); labels.append(lab)\n", + " fr = np.array(frames)\n", + " return {\"id\": f\"CAM-{seed:04d}\", \"cond\": cond, \"wind\": wind, \"ref\": fr[:8], \"frames\": fr[8:], \"labels\": labels[8:]}\n", + "\n", + "# ---------------- 전처리·탐지 공통 함수(배경 차분 기반) ----------------\n", + "def enhance(seq):\n", + " \"\"\"조도 보정(백분위 신장) + 비 조건의 빗줄기 제거(3x3 중앙값). 배경 학습 구간(ref)과 같은 기준으로 보정한다.\"\"\"\n", + " allf = np.concatenate([seq[\"ref\"], seq[\"frames\"]])\n", + " f = ndimage.median_filter(allf, size=(1, 3, 3)) if seq[\"cond\"] == \"rain\" else allf\n", + " lo, hi = np.percentile(f, [1, 99.5])\n", + " f = np.clip((f - lo) / (hi - lo + 1e-6) * 255, 0, 255)\n", + " return f[:len(seq[\"ref\"])], f[len(seq[\"ref\"]):]\n", + "\n", + "\n", + "def detect(ref, frames, k=4.0, min_area=3, sky_k=None, smooth=False):\n", + " \"\"\"배경 학습 구간의 중앙값 배경과 차이를 강건 임계값(k x MAD)으로 이진화해 연결 성분을 객체 후보로 만든다.\n", + " smooth=True 이면 연속 3프레임 평균으로 시간 잡음을 줄여 저대비 객체를 살린다.\"\"\"\n", + " bg = np.median(ref, axis=0)\n", + " if smooth:\n", + " frames = ndimage.uniform_filter1d(frames, 3, axis=0, mode=\"nearest\")\n", + " out = []\n", + " for f in frames:\n", + " d = np.abs(f - bg)\n", + " mad = np.median(d) * 1.4826 + 1e-6\n", + " thr = np.full(d.shape, k * mad)\n", + " if sky_k is not None:\n", + " thr[:HORIZON] = sky_k * mad\n", + " lab, n = ndimage.label(d > thr)\n", + " dets = []\n", + " for i, sl in enumerate(ndimage.find_objects(lab), start=1):\n", + " area = int((lab[sl] == i).sum())\n", + " if area >= min_area:\n", + " dets.append({\"bbox\": (sl[1].start, sl[0].start, sl[1].stop, sl[0].stop), \"area\": area,\n", + " \"contrast\": float(d[sl][lab[sl] == i].mean() / mad)})\n", + " out.append(dets)\n", + " return out\n", + "\n", + "\n", + "def center(b):\n", + " return ((b[0] + b[2]) / 2, (b[1] + b[3]) / 2)\n", + "\n", + "\n", + "def match(dets, labels, dist=4.0):\n", + " \"\"\"탐지와 정답을 중심 거리로 1:1 대응시킨다. 반환: [(det, label or None)], 놓친 label 목록\"\"\"\n", + " used, pairs = set(), []\n", + " for d in dets:\n", + " cd = center(d[\"bbox\"]); best, bj = dist, None\n", + " for j, l in enumerate(labels):\n", + " cl = center(l[\"bbox\"]); e = np.hypot(cd[0] - cl[0], cd[1] - cl[1])\n", + " if j not in used and e < best:\n", + " best, bj = e, j\n", + " if bj is not None:\n", + " used.add(bj)\n", + " pairs.append((d, labels[bj] if bj is not None else None))\n", + " return pairs, [l for j, l in enumerate(labels) if j not in used]\n", + "\n", + "\n", + "def in_roi(b):\n", + " cy = (b[1] + b[3]) / 2\n", + " return cy < HORIZON or ROI_ROWS[0] <= cy < ROI_ROWS[1]\n", + "\n", + "\n", + "def rule_class(b, area):\n", + " \"\"\"Baseline 규칙: 위치(하늘/지면)·크기·종횡비로 1차 분류(학습 없음, 판단 근거가 그대로 드러난다)\"\"\"\n", + " w, h = b[2] - b[0], b[3] - b[1]\n", + " cy = (b[1] + b[3]) / 2\n", + " if cy < HORIZON:\n", + " return \"drone\" if 3 <= area <= 12 else \"clutter\"\n", + " if area >= 30 and w > h:\n", + " return \"vehicle\"\n", + " if h >= w and 6 <= area <= 30:\n", + " return \"person\"\n", + " return \"clutter\"\n", + "\n", + "# ---------------- 프레임 간 추적과 궤적 특징 ----------------\n", + "def track(dets, gate=6.0):\n", + " \"\"\"최근접 이웃으로 프레임별 탐지를 궤적으로 잇는다.\"\"\"\n", + " tracks = []\n", + " for t, ds in enumerate(dets):\n", + " free = list(range(len(ds)))\n", + " for tr in tracks:\n", + " if tr[\"last\"] != t - 1 or not free:\n", + " continue\n", + " c0 = center(tr[\"b\"][-1])\n", + " dd = [np.hypot(center(ds[i][\"bbox\"])[0] - c0[0], center(ds[i][\"bbox\"])[1] - c0[1]) for i in free]\n", + " j = int(np.argmin(dd))\n", + " if dd[j] < gate:\n", + " i = free.pop(j); tr[\"b\"].append(ds[i][\"bbox\"]); tr[\"a\"].append(ds[i][\"area\"]); tr[\"c\"].append(ds[i][\"contrast\"])\n", + " tr[\"t\"].append(t); tr[\"last\"] = t\n", + " for i in free:\n", + " tracks.append({\"b\": [ds[i][\"bbox\"]], \"a\": [ds[i][\"area\"]], \"c\": [ds[i][\"contrast\"]], \"t\": [t], \"last\": t})\n", + " return tracks\n", + "\n", + "\n", + "TFEATS = [\"n\", \"area\", \"aspect\", \"row\", \"sky\", \"speed\", \"straight\", \"disp\", \"contrast\", \"area_cv\", \"col\"]\n", + "\n", + "\n", + "def track_features(tr):\n", + " c = np.array([center(b) for b in tr[\"b\"]])\n", + " wh = np.array([(b[2] - b[0], b[3] - b[1]) for b in tr[\"b\"]])\n", + " steps = np.hypot(*np.diff(c, axis=0).T) if len(c) > 1 else np.array([0.0])\n", + " disp = float(np.hypot(*(c[-1] - c[0])))\n", + " return {\"n\": len(c), \"area\": float(np.mean(tr[\"a\"])), \"aspect\": float(np.mean(wh[:, 1] / wh[:, 0])), \"row\": float(c[:, 1].mean()),\n", + " \"sky\": float(c[:, 1].mean() < HORIZON), \"speed\": float(steps.mean()), \"straight\": disp / (steps.sum() + 1e-6),\n", + " \"disp\": disp, \"contrast\": float(np.mean(tr[\"c\"])), \"area_cv\": float(np.std(tr[\"a\"]) / (np.mean(tr[\"a\"]) + 1e-6)),\n", + " \"col\": float(c[:, 0].mean())}\n", + "\n", + "\n", + "def track_truth(tr, labels):\n", + " \"\"\"궤적의 각 프레임을 정답과 대응시켜 다수결로 정체를 정한다(없으면 clutter).\"\"\"\n", + " votes = []\n", + " for b, t in zip(tr[\"b\"], tr[\"t\"]):\n", + " pairs, _ = match([{\"bbox\": b}], labels[t])\n", + " votes.append((pairs[0][1][\"cls\"], pairs[0][1][\"oid\"]) if pairs[0][1] else (\"clutter\", -1))\n", + " return max(set(votes), key=votes.count)\n", + "\n", + "\n", + "def seq_tracks(seq, k=4.0, min_area=3, sky_k=None, smooth=False):\n", + " ref, fr = enhance(seq)\n", + " tracks = track(detect(ref, fr, k, min_area, sky_k, smooth))\n", + " rows = []\n", + " for tr in tracks:\n", + " f = track_features(tr); f[\"truth\"], f[\"oid\"] = track_truth(tr, seq[\"labels\"]); f[\"seq\"] = seq[\"id\"]; f[\"cond\"] = seq[\"cond\"]\n", + " f[\"tr\"] = tr; rows.append(f)\n", + " return rows\n", + "\n", + "CONFIG = {\"detection_k\": 3.5, \"alert_threshold\": 0.6, \"min_track_frames\": 4} # 미션 9 재보정값\n", + "\n", + "\n", + "def validate(seq):\n", + " errs = []\n", + " if seq[\"cond\"] not in CONDS:\n", + " errs.append(\"condition 허용값 아님\")\n", + " if seq[\"frames\"].ndim != 3 or seq[\"frames\"].shape[1:] != (H, W):\n", + " errs.append(\"해상도 오류\")\n", + " if not str(seq[\"id\"]).startswith(\"CAM-\"):\n", + " errs.append(\"camera_id 형식 오류\")\n", + " return errs\n", + "\n", + "\n", + "def repair(seq):\n", + " \"\"\"센서 단절(전 화면 0) 프레임을 직전 프레임으로 대체하고 보정 건수를 돌려준다.\"\"\"\n", + " fr = seq[\"frames\"].copy(); fixed = 0\n", + " for t in range(len(fr)):\n", + " if fr[t].max() < 1:\n", + " fr[t] = fr[t - 1] if t > 0 else seq[\"ref\"][-1]; fixed += 1\n", + " return {**seq, \"frames\": fr}, fixed\n", + "\n", + "\n", + "def X_of(rows):\n", + " return np.array([[r[k] for k in TFEATS] for r in rows])\n", + "\n", + "\n", + "def tracks_of(seq):\n", + " k = CONFIG[\"detection_k\"]\n", + " return [r for r in seq_tracks(seq, k=k, min_area=2, sky_k=k - 0.5) if in_roi(r[\"tr\"][\"b\"][-1])]\n", + "\n", + "\n", + "class Queue:\n", + " def __init__(self):\n", + " self.items, self.log = [], []\n", + "\n", + " def submit(self, aid, score, grade):\n", + " prev = self.log[-1][\"hash\"] if self.log else \"GENESIS\"\n", + " body = {\"event\": \"SUBMIT\", \"id\": aid, \"score\": round(score, 3), \"by\": \"AI\"}\n", + " self.log.append({**body, \"prev\": prev, \"hash\": hashlib.sha256((prev + json.dumps(body, sort_keys=True)).encode()).hexdigest()})\n", + " self.items.append({\"id\": aid, \"grade\": grade, \"status\": \"PENDING\"})\n", + "\n", + " def verify(self):\n", + " prev = \"GENESIS\"\n", + " for rec in self.log:\n", + " body = {k: v for k, v in rec.items() if k not in (\"prev\", \"hash\")}\n", + " if rec[\"prev\"] != prev or rec[\"hash\"] != hashlib.sha256((prev + json.dumps(body, sort_keys=True)).encode()).hexdigest():\n", + " return False\n", + " prev = rec[\"hash\"]\n", + " return True\n", + "\n", + "\n", + "def grade_of(p):\n", + " return \"High\" if p >= 0.8 else (\"Medium\" if p >= CONFIG[\"alert_threshold\"] else \"Low\")\n", + "\n", + "\n", + "def process(seq, engine, queue):\n", + " \"\"\"시퀀스 1개: 단절 보정 -> 탐지·추적 -> 신뢰도 -> 경보 대기열 등록.\"\"\"\n", + " seq, fixed = repair(seq)\n", + " trs = tracks_of(seq)\n", + " if trs:\n", + " for r, p in zip(trs, engine.predict_proba(X_of(trs))[:, 1]):\n", + " r[\"score\"], r[\"grade\"] = float(p), grade_of(float(p))\n", + " r[\"alert\"] = r[\"n\"] >= CONFIG[\"min_track_frames\"] and r[\"score\"] >= CONFIG[\"alert_threshold\"]\n", + " if r[\"alert\"]:\n", + " queue.submit(f\"{seq['id']}-{len(queue.items):03d}\", r[\"score\"], r[\"grade\"])\n", + " return trs, fixed\n", + "\n", + "\n", + "t_start = time.perf_counter()\n", + "# ---------------- 1. 학습(엔진 준비) ----------------\n", + "train = [r for s in range(0, 160) for r in tracks_of(make_seq(s))]\n", + "engine = make_pipeline(StandardScaler(), LogisticRegression(class_weight=\"balanced\", C=2.0, max_iter=2000, random_state=0))\n", + "engine.fit(X_of(train), [r[\"truth\"] in INTRUDER for r in train])\n", + "\n", + "# ---------------- 2. 운용 데이터 처리(손상 데이터 2건 주입) ----------------\n", + "ops = [make_seq(5000 + i) for i in range(30)]\n", + "ops[3] = {**ops[3], \"cond\": \"snow\"} # 허용되지 않은 조건\n", + "ops[7] = {**ops[7], \"frames\": np.where((np.arange(T) % 5 == 2)[:, None, None], 0.0, ops[7][\"frames\"])} # 센서 단절 프레임\n", + "queue, rejected, all_tracks, runs, fixed_total = Queue(), {}, [], [], 0\n", + "trace_lines = set()\n", + "codes = {f.__code__ for f in (validate, repair, tracks_of, grade_of, process, X_of, Queue.submit, Queue.verify,\n", + " enhance, detect, match, in_roi, rule_class, track, track_features, track_truth, seq_tracks, center)}\n", + "lines_total = {c: {ln for _, ln in dis.findlinestarts(c) if ln != c.co_firstlineno} for c in codes}\n", + "\n", + "\n", + "def tracer(frame, event, arg):\n", + " if frame.f_code in codes:\n", + " def local(fr, ev, ar):\n", + " if ev == \"line\":\n", + " trace_lines.add((fr.f_code, fr.f_lineno))\n", + " return local\n", + " return local\n", + "\n", + "\n", + "sys.settrace(tracer)\n", + "for s in ops:\n", + " errs = validate(s)\n", + " if errs:\n", + " rejected[s[\"id\"]] = errs; continue\n", + " t0 = time.perf_counter()\n", + " trs, fx = process(s, engine, queue)\n", + " fixed_total += fx\n", + " runs.append({\"seq\": s[\"id\"], \"ms_per_frame\": (time.perf_counter() - t0) * 1000 / T, \"alerts\": sum(r[\"alert\"] for r in trs)})\n", + " all_tracks += trs\n", + "queue.verify(); grade_of(0.9); X_of(all_tracks[:1])\n", + "sys.settrace(None)\n", + "covered = sum(len({ln for c2, ln in trace_lines if c2 is c} & lines_total[c]) for c in codes)\n", + "total_lines = sum(len(v) for v in lines_total.values())\n", + "\n", + "# ---------------- 3. 성능 평가 ----------------\n", + "done_ids = {s[\"id\"] for s in ops} - set(rejected)\n", + "objs = {(q[\"id\"], l[\"oid\"]) for q in ops if q[\"id\"] in done_ids for labs in q[\"labels\"] for l in labs if l[\"intruder\"]}\n", + "alerts = [r for r in all_tracks if r[\"alert\"]]\n", + "far = sum(r[\"truth\"] not in INTRUDER for r in alerts) / max(len(alerts), 1)\n", + "recall = len({(r[\"seq\"], r[\"oid\"]) for r in alerts if r[\"truth\"] in INTRUDER} & objs) / max(len(objs), 1)\n", + "ms = np.mean([r[\"ms_per_frame\"] for r in runs])\n", + "\n", + "# ---------------- 4. 자체 점검(통합 테스트) ----------------\n", + "cov = covered / total_lines\n", + "chk = {\n", + " \"손상 데이터 차단\": \"CAM-5003\" in rejected,\n", + " \"처리 시퀀스 29개 전량 성공\": len(runs) == 29,\n", + " \"센서 단절 프레임 보정\": fixed_total >= 1,\n", + " \"침입 객체 재현율 85% 이상\": recall >= 0.85,\n", + " \"오경보율 15% 이하\": far <= 0.15,\n", + " \"자동 해제·출동 0건\": all(i[\"status\"] == \"PENDING\" for i in queue.items),\n", + " \"경보 전량 대기열 등록\": len(queue.items) == len(alerts),\n", + " \"감사 로그 건수 일치·무결\": len(queue.log) == len(queue.items) and queue.verify(),\n", + " \"프레임당 50ms 이내\": ms < 50,\n", + " \"실행 라인 커버리지 85% 이상\": cov >= 0.85,\n", + "}\n", + "passed = sum(chk.values())\n", + "for k, v in chk.items():\n", + " print(f\"[{'PASS' if v else 'FAIL'}] {k}\")\n", + "print(f\"자체 점검 {passed}/{len(chk)} 통과 ({passed / len(chk):.0%}), 파이프라인 실행 성공 {'예' if len(runs) + len(rejected) == len(ops) else '아니오'}, 핵심 모듈 실행 라인 커버리지 {cov:.1%}\")\n", + "\n", + "# ---------------- 5. 관제 상황판 ----------------\n", + "focus = max({r[\"seq\"] for r in all_tracks}, key=lambda q: sum(r[\"alert\"] for r in all_tracks if r[\"seq\"] == q))\n", + "col = {\"High\": \"tab:red\", \"Medium\": \"tab:orange\", \"Low\": \"tab:gray\"}\n", + "fig, ax = plt.subplots(2, 2, figsize=(12, 8))\n", + "s = next(x for x in ops if x[\"id\"] == focus)\n", + "ax[0, 0].imshow(s[\"frames\"][-1], cmap=\"gray\", vmin=0, vmax=255)\n", + "for r in [r for r in all_tracks if r[\"seq\"] == focus and r[\"alert\"]]:\n", + " c = np.array([center(b) for b in r[\"tr\"][\"b\"]]); ax[0, 0].plot(c[:, 0], c[:, 1], color=col[r[\"grade\"]], lw=2)\n", + "ax[0, 0].set_title(f\"{focus} alert tracks (color = grade)\"); ax[0, 0].axis(\"off\")\n", + "g = pd.Series([r[\"grade\"] for r in alerts]).value_counts().reindex([\"High\", \"Medium\"]).fillna(0)\n", + "ax[0, 1].bar(g.index, g.values, color=[col[i] for i in g.index]); ax[0, 1].set_title(\"Alerts by grade (all pending human review)\")\n", + "ax[1, 0].bar([r[\"seq\"][-2:] for r in runs], [r[\"alerts\"] for r in runs]); ax[1, 0].set_title(\"Alerts per camera sequence\")\n", + "ax[1, 1].axis(\"off\")\n", + "ax[1, 1].text(0, 0.9, f\"Intruder recall {recall:.1%}\\nFalse alarm {far:.1%}\\nQueue {len(queue.items)} pending\\n\"\n", + " f\"Latency {ms:.2f} ms/frame\\nSelf-check {passed}/{len(chk)}\", family=\"monospace\", fontsize=13, va=\"top\")\n", + "plt.suptitle(\"SEC01 integrated pipeline - synthetic training data only\"); plt.tight_layout(); plt.show()\n", + "\n", + "# ---------------- 6. 시설 방호 브리핑 요약 ----------------\n", + "print(f\"\"\"\n", + "[기지 CCTV 비인가 접근 탐지 브리핑 요약 - 교육용가상자료]\n", + "1. 처리: 시퀀스 {len(ops)}개 중 {len(runs)}개 처리, 손상 데이터 {len(rejected)}건 차단({', '.join(rejected)}), 센서 단절 프레임 {fixed_total}장 보정\n", + "2. 성능: 침입 객체 재현율 {recall:.1%}, 오경보율 {far:.1%}, 프레임당 {ms:.2f} ms, 전체 소요 {time.perf_counter() - t_start:.1f} s\n", + "3. 경보: {len(queue.items)}건 경계병 육안 확인 대기 (High {sum(i['grade'] == 'High' for i in queue.items)} / Medium {sum(i['grade'] == 'Medium' for i in queue.items)}), 자동 해제·출동 0건\n", + "4. 운용 유의: 경보 해제·출동 결심은 경계병과 당직 지휘관의 승인 후에만 이루어지며, 실제 대응은 경계 규정과 지휘계통에 따른다.\n", + "5. 한계: 코드로 그린 합성 영상 기준 결과로, 실제 CCTV·기상·배경에서는 재학습과 현장 검증이 필요하다.\"\"\")\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 10 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + "[PASS] 손상 데이터 차단\n", + "[PASS] 처리 시퀀스 29개 전량 성공\n", + "[PASS] 센서 단절 프레임 보정\n", + "[PASS] 침입 객체 재현율 85% 이상\n", + "[PASS] 오경보율 15% 이하\n", + "[PASS] 자동 해제·출동 0건\n", + "[PASS] 경보 전량 대기열 등록\n", + "[PASS] 감사 로그 건수 일치·무결\n", + "[PASS] 프레임당 50ms 이내\n", + "[PASS] 실행 라인 커버리지 85% 이상\n", + "자체 점검 10/10 통과 (100%), 파이프라인 실행 성공 예, 핵심 모듈 실행 라인 커버리지 89.1%\n", + "\n", + "[기지 CCTV 비인가 접근 탐지 브리핑 요약 - 교육용가상자료]\n", + "1. 처리: 시퀀스 30개 중 29개 처리, 손상 데이터 1건 차단(CAM-5003), 센서 단절 프레임 2장 보정\n", + "2. 성능: 침입 객체 재현율 90.5%, 오경보율 11.6%, 프레임당 3.48 ms, 전체 소요 6.5 s\n", + "3. 경보: 43건 경계병 육안 확인 대기 (High 41 / Medium 2), 자동 해제·출동 0건\n", + "4. 운용 유의: 경보 해제·출동 결심은 경계병과 당직 지휘관의 승인 후에만 이루어지며, 실제 대응은 경계 규정과 지휘계통에 따른다.\n", + "5. 한계: 코드로 그린 합성 영상 기준 결과로, 실제 CCTV·기상·배경에서는 재학습과 현장 검증이 필요하다.\n", + "```\n", + "
\n" + ] + } + ], + "metadata": { + "colab": { + "provenance": [] + }, + "kernelspec": { + "display_name": "Python 3", + "name": "python3" + }, + "language_info": { + "name": "python" + } + }, + "nbformat": 4, + "nbformat_minor": 0 +} \ No newline at end of file diff --git a/uploads/SEC06/SEC06_workbook.ipynb b/uploads/SEC06/SEC06_workbook.ipynb new file mode 100644 index 0000000..a051096 --- /dev/null +++ b/uploads/SEC06/SEC06_workbook.ipynb @@ -0,0 +1,922 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# 📋 [SEC06] 기만체·차폐·분산 배치 효과 확인 기록서\n", + "### 실습 워크북 & SOP 가이드 (Google Colab / Jupyter)\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/SEC06/SEC06_workbook.ipynb)\n", + "\n", + "---\n", + "\n", + "## 📌 1. 프로젝트 개요 및 배경\n", + "\n", + "| 항목 | 내용 |\n", + "| :--- | :--- |\n", + "| **과정 분류** | 시설·방호·경계 (Level 2) |\n", + "| **학습 소요 시간** | 10개 미션 (5일 / 총 27.0시간) |\n", + "| **실습 유형** | 작전절차(SOP), 체크리스트, 모의훈련, AAR 사후검토 실습 |\n", + "\n", + "### 📖 프로젝트 목표\n", + "러시아-우크라이나 전쟁과 미국-이란 간 긴장 국면에서는 위장막과 모형 기만체(Decoy), 부대 분산 배치가 상대의 정찰·감시를 늦추고 오인을 유도하는 수단으로 광범위하게 쓰였지만, 실제로 얼마나 효과가 있었는지는 기록이 부실해 '효과가 있었다'는 말만 남는 경우가 많았다. 기만이 먹힌 것인지, 날씨나 관측 공백 때문에 우연히 노출되지 않은 것인지 구분하지 못하면 다음 배치 결정이 흔들린다. 본 프로젝트에서는 교육용 가상 진지와 가상 관측 모의 기록을 사용해 위장·기만·분산의 효과 분석 틀, 효과 확인 신뢰도 6단계와 평가 지표, 배치·차폐 기록 서식, 효과 귀속 판단 기준, 승인·보류 판단, 관측 능력 상향 스트레스 분석, AAR, 배치 재조정과 1페이지 브리핑을 차례로 수행한다. 최종 목표는 근거·책임 직책·기록 요건이 분명한 기만체·차폐·분산 배치 효과 확인 기록 표준 지침서를 문서로 완성하는 것이다.\n", + "\n", + "> **⚠️ [교육용 가상 자료 원칙]**\n", + "> - 본 워크북의 모든 자료는 안전 규정에 따라 **교육용 가상 자료**로 구성되어 있습니다.\n", + "> - 실제 군사 기밀이나 부대 세부 인적·작전 정보는 포함되지 않습니다.\n", + "\n", + "---\n", + "\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 01. 전장 위장막, 모형 기만체(Decoy), 부대 분산 배치의 생존성 효과 분석\n", + "\n", + "### 📋 미션 배경 및 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "위장막, 모형 기만체(Decoy), 분산 배치의 생존성 효과 분석 프레임워크를 수립한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **위장막, 모형 기만체, 분산 배치가 부대 생존성에 미치는 효과를 어떤 틀로 분석할지 정하고, 지침서 전체 문서 골격을 정의한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **실습자료의 가상 효과 사례 5건을 읽고 각 사례가 위장막, 기만체, 분산 배치 중 무엇에 관한 것인지, 효과 근거로 무엇이 기록되어 있는지를 비교표로 정리하고 규격화하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 5개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `docs/sec06_doc_framework.md 및 분석 개요서` 형태의 결과물을 완성한다.\n", + "\n", + "### 📑 실습자료 및 작성 양식\n", + "전장 위장막, 모형 기만체(Decoy), 부대 분산 배치의 생존성 효과 분석**\n", + "\n", + "가. 제공 자료 - 가상 효과 사례 5건 [교육용가상자료]\n", + "\n", + "| **사례** | **방식** | **내용 요약** | **효과 근거로 기록된 것** | **눈에 띄는 한계** |\n", + "| --- | --- | --- | --- | --- |\n", + "| **C1** | 위장막 | 위장막 설치 후 관측팀 최초 식별 시간이 12분에서 31분으로 늘어남(D일 07시) | 관측팀 기록 1회, 시각 | 구름이 많았던 날이라 날씨 영향을 분리하지 못함 |\n", + "| **C2** | 모형 기만체 | 관측팀 보고 7건 중 3건이 기만체 DC-2, DC-4를 실제 대상으로 기록(D+1일) | 관측 보고서 7건 | 관측팀이 기만체 위치를 사전에 알았는지 기재 없음 |\n", + "| **C3** | 분산 배치 | 6개소를 3개소로 분산한 뒤 모의 피해 판정이 4건에서 2건으로 감소(D+2일) | 모의 판정표 비교 | 분산 후 모의 공격 조건이 달라 비교 조건이 같지 않음 |\n", + "| **C4** | 위장막 | '위장 효과 양호'라고만 쓴 기록 1줄(D+1일) | 없음 | 누가, 언제, 무엇으로 확인했는지 기재 없음 |\n", + "| **C5** | 위장막·기만체 | 열 대비 관측 등급이 '중'이라고만 기록(D+3일) | 관측 등급 1회 | 기상과 시간대 미기재, 기만체 운용 여부 미기재 |\n", + "\n", + "나. 작성 서식 1 - 방식별 효과 조건·약점 비교표\n", + "\n", + "| **방식** | **효과를 기대할 수 있는 조건** | **생기기 쉬운 약점** | **효과 확인 시 꼭 기록할 항목** | **보완 방법** |\n", + "| --- | --- | --- | --- | --- |\n", + "| **위장막** | | | | |\n", + "| **모형 기만체** | | | | |\n", + "| **분산 배치** | | | | |\n", + "\n", + "나. 작성 서식 2 - 지침서 목차 골격(지휘관 결심 의제 순으로 배열)\n", + "\n", + "| **순서** | **장 제목** | **담을 핵심 내용** | **지휘관이 이 장에서 얻는 판단 정보** |\n", + "| --- | --- | --- | --- |\n", + "| **제1장** | | | |\n", + "| **제2장** | | | |\n", + "| **제3장** | | | |\n", + "| **제4장** | | | |\n", + "| **제5장** | | | |\n", + "| **제6장** | | | |\n", + "| **제7장** | | | |\n", + "| **제8장** | | | |\n", + "\n", + "다. 점검표\n", + "\n", + "| **점검 항목** | **확인** |\n", + "| --- | --- |\n", + "| 가상 효과 사례 5건을 모두 검토하고 방식을 구분했다 | ( ) |\n", + "| 위장막·기만체·분산 배치 3방식 모두에 조건과 약점을 적었다 | ( ) |\n", + "| C4처럼 근거 없이 결론만 적힌 기록을 찾아 표시했다 | ( ) |\n", + "| 지침서 목차 8개 장의 제목과 핵심 내용을 정의했다 | ( ) |\n", + "| 목차를 지휘관이 먼저 알아야 할 의제 순으로 배열하고 이유를 한 줄로 적었다 | ( ) |\n", + "\n", + "**\n", + "\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 02. 위장·기만·분산 효과성 평가 기준(위성 판독 지연, 피격률 감소 등) 수립\n", + "\n", + "### 📋 미션 배경 및 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "위성 판독 지연, 피격률 감소 등 기만·차폐 효과성 평가 기준을 수립한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **위장·기만·분산의 효과를 판독 지연, 오인 유도, 모의 피격 판정 감소 등으로 평가하기 위한 기준을 정하고, 효과 확인 근거를 6단계 신뢰도로 구분한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **효과 확인 근거를 직접 측정과 독립 재확인이 있는 단계(G1)부터 근거가 없는 단계(G6)까지 6단계로 나누고 단계별 판정 기준을 관찰 가능한 문장으로 설계하고 작성하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 5개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `framework/sec06_source_reliability_matrix.md` 형태의 결과물을 완성한다.\n", + "\n", + "### 📑 실습자료 및 작성 양식\n", + "위장·기만·분산 효과성 평가 기준(위성 판독 지연, 피격률 감소 등) 수립**\n", + "\n", + "가. 제공 자료 1 - 가상 효과 기록 5종 [교육용가상자료]\n", + "\n", + "| **기록** | **방식** | **효과 주장** | **확인 방법** | **확인 상태** |\n", + "| --- | --- | --- | --- | --- |\n", + "| **R1** | 위장막 | 식별 지연 12분에서 31분 | 관측팀이 같은 조건에서 3일 연속 측정, 평가자 재확인 | 측정 기록과 재확인 서명 모두 있음 |\n", + "| **R2** | 기만체 | 기만체 3건 오인 유도 | 관측팀 보고 7건 집계 | 집계는 있으나 재확인 없음 |\n", + "| **R3** | 분산 | 모의 피해 판정 4건에서 2건 | 판정표 1회 비교 | 조건이 달라 단순 비교만 가능 |\n", + "| **R4** | 위장막 | 효과 양호 | 기록자 의견 | 근거 없음 |\n", + "| **R5** | 차폐 | 열 대비 관측 등급 중 | 관측 등급 1회 | 기상·시간대 미기재 |\n", + "\n", + "가. 제공 자료 2 - 효과 확인 신뢰도 6단계 틀. 단계 이름은 고정하고 판정 기준을 조별로 작성한다.\n", + "\n", + "| **단계** | **이름** | **판정 기준(조별 작성)** |\n", + "| --- | --- | --- |\n", + "| **G1** | 직접 측정·독립 재확인 | |\n", + "| **G2** | 직접 측정 | |\n", + "| **G3** | 다회 관측 집계 | |\n", + "| **G4** | 단회 관측 | |\n", + "| **G5** | 진술·의견 | |\n", + "| **G6** | 근거 없음 | |\n", + "\n", + "나. 작성 서식 1 - 평가 지표(10개 이상, 예시 2개 포함)\n", + "\n", + "| **번호** | **평가 지표** | **측정 방법(관찰 가능한 기준)** | **기록 책임자(가상 직책)** |\n", + "| --- | --- | --- | --- |\n", + "| **1** | 최초 식별까지 걸린 시간(판독 지연) | 측정 시각과 관측 등급을 함께 기록한 분 단위 값 | 관측팀 기록자 |\n", + "| **2** | 기만체 오인 건수 | 관측 보고서에서 기만체를 실제 대상으로 기록한 건수를 센다 | 검증 담당 |\n", + "| **3** | | | |\n", + "| **4** | | | |\n", + "| **5** | | | |\n", + "| **6** | | | |\n", + "| **7** | | | |\n", + "| **8** | | | |\n", + "| **9** | | | |\n", + "| **10** | | | |\n", + "| **11** | | | |\n", + "| **12** | | | |\n", + "\n", + "나. 작성 서식 2 - 등급 부여 일관성 시험(조원이 각자 G1~G6을 매기고 비교)\n", + "\n", + "| **기록** | **조원 1** | **조원 2** | **조원 3** | **조원 4** | **일치 여부** | **불일치 원인** |\n", + "| --- | --- | --- | --- | --- | --- | --- |\n", + "| **R1** | | | | | | |\n", + "| **R2** | | | | | | |\n", + "| **R3** | | | | | | |\n", + "| **R4** | | | | | | |\n", + "| **R5** | | | | | | |\n", + "\n", + "나. 작성 서식 3 - 교차 확인 필수 규칙: 단회 관측 결과만으로는 G ( ) 이하로 기록하고 효과 승인은 서로 다른 날 또는 다른 관측자의 확인이 ( )회 이상일 때만 검토한다. 효과의 원인이 기만인지 날씨 때문인지 ( )를 분리하기 전에는 판단 결과를 ( )로 기록한다.\n", + "\n", + "다. 점검표\n", + "\n", + "| **점검 항목** | **확인** |\n", + "| --- | --- |\n", + "| 효과 확인 신뢰도 G1~G6의 판정 기준을 모두 작성했다 | ( ) |\n", + "| 평가 지표를 10개 이상 정의하고 측정 방법과 기록 책임자를 적었다 | ( ) |\n", + "| 가상 기록 5종에 대한 조원 등급이 4건 이상 일치했다(불일치 건은 원인과 수정 내용을 적었다) | ( ) |\n", + "| 단회 관측 의존 경고와 교차 확인 필수 규칙을 명시했다 | ( ) |\n", + "| 기록자의 기대나 호의가 등급에 영향을 주지 않도록 하는 편향 방지 문구를 넣었다 | ( ) |\n", + "\n", + "**\n", + "\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 03. 기만체 배치 위치 및 위장막 열화상 차폐 성능 기록 양식 설계\n", + "\n", + "### 📋 미션 배경 및 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "기만체 배치 위치 및 열화상 차폐 성능 기록 서식을 설계한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **기만체 배치 위치와 위장막 차폐 성능을 같은 방식으로 기록할 수 있는 서식을 설계하고, 기록 속 관측 공백과 불확실성을 정리한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **가상 격자 좌표, 설치 시각, 기상, 관측 조건, 차폐 성능 관측 등급(상·중·하)을 적는 기록 서식 항목을 설계하고 항목마다 기재 요령을 적는다 과정을 진행하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 5개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `analysis/sec06_uncertainty_assessment.md` 형태의 결과물을 완성한다.\n", + "\n", + "### 📑 실습자료 및 작성 양식\n", + "기만체 배치 위치 및 위장막 열화상 차폐 성능 기록 양식 설계**\n", + "\n", + "가. 제공 자료 1 - 어긋난 가상 기록 쌍 4건 [교육용가상자료]\n", + "\n", + "| **쌍** | **기록 A** | **기록 B** | **어긋난 지점** |\n", + "| --- | --- | --- | --- |\n", + "| **K1** | 관측팀: 다-3 위장막 식별 31분 소요(07시) | 평가자: 같은 구역 식별 15분(12시) | 시각 조건(관측 시간대) |\n", + "| **K2** | 설치 기록: 기만체 DC-2는 나-2 칸에 설치 | 관측 보고: 기만체가 나-3 칸에서 관측됨 | 위치 기록 |\n", + "| **K3** | 기록 1: 차폐 성능 관측 등급 '상' | 기록 2: 같은 천막 '하'(기상 흐림, 비) | 기상 조건과 판정 기준 |\n", + "| **K4** | 중대 기록: 분산 후 피해 판정 2건으로 감소 | 대대 기록: 같은 날 4건 유지(모의 공격 횟수가 달랐음) | 모의 조건과 집계 방식 |\n", + "\n", + "가. 제공 자료 2 - 관측 공백 예시 3개(나머지 5개 이상은 조별로 찾는다)\n", + "\n", + "| **관측 공백 요인** | **발생 상황** | **대체 확인 방법(예)** |\n", + "| --- | --- | --- |\n", + "| **관측 시간대 편중** | 관측이 오전에만 이루어져 낮·저녁 조건이 기록되지 않음 | 시간대를 나누어 3회 측정 |\n", + "| **기상 미기재** | 비·흐림·바람 기록이 없어 차폐 성능 비교 불가 | 기록 서식에 기상 칸을 필수로 지정 |\n", + "| **기록자 교대** | 교대 때 판정 기준이 달라져 같은 대상을 다르게 기록 | 교대 전 기준 사진·표시 공유 |\n", + "\n", + "나. 작성 서식 1 - 기만체 배치 위치·차폐 성능 기록 서식 설계(항목마다 기재 요령 작성)\n", + "\n", + "| **번호** | **기록 항목** | **기재 요령** | **필수 여부** | **기록 책임자(가상 직책)** |\n", + "| --- | --- | --- | --- | --- |\n", + "| **1** | 설치 일시와 설치자 | | | |\n", + "| **2** | 가상 격자 좌표(예: 나-2) | | | |\n", + "| **3** | 방식(위장막/기만체/분산) | | | |\n", + "| **4** | 기상과 관측 조건 | | | |\n", + "| **5** | 차폐 성능 관측 등급(상·중·하) | | | |\n", + "| **6** | 관측자와 관측 등급(L1~L3) | | | |\n", + "| **7** | | | | |\n", + "| **8** | | | | |\n", + "\n", + "나. 작성 서식 2 - 어긋난 기록 분류와 원인 후보\n", + "\n", + "| **쌍** | **어긋난 유형(시각·위치·조건·판정 기준)** | **원인 후보(기록 오류, 시차, 조건 차이, 기준 차이)** | **확인 방법** | **결론(승인·보류·추가 측정)** |\n", + "| --- | --- | --- | --- | --- |\n", + "| **K1** | | | | |\n", + "| **K2** | | | | |\n", + "| **K3** | | | | |\n", + "| **K4** | | | | |\n", + "\n", + "나. 작성 서식 3 - 관측 공백·불확실성 영향도 매트릭스(8개 이상)\n", + "\n", + "| **번호** | **관측 공백 요인** | **영향도(높음·중간·낮음)** | **대체 확인 방법** | **판단의 한계** |\n", + "| --- | --- | --- | --- | --- |\n", + "| **1** | 관측 시간대 편중 | | | |\n", + "| **2** | 기상 미기재 | | | |\n", + "| **3** | 기록자 교대 | | | |\n", + "| **4** | | | | |\n", + "| **5** | | | | |\n", + "| **6** | | | | |\n", + "| **7** | | | | |\n", + "| **8** | | | | |\n", + "| **9** | | | | |\n", + "| **10** | | | | |\n", + "\n", + "나. 작성 서식 4 - 확신 표현 규칙: 허용 표현 '관측 조건 ( )에서 식별 지연이 확인되었다', 금지 표현 '( )', 판단의 한계 문장 '이 결과는 ( )에는 적용하지 않는다'\n", + "\n", + "다. 점검표\n", + "\n", + "| **점검 항목** | **확인** |\n", + "| --- | --- |\n", + "| 기록 서식에 좌표, 기상, 관측 등급, 기록 책임자 항목이 모두 있다 | ( ) |\n", + "| 어긋난 기록 4건의 유형과 원인 후보를 2개 이상 적었다 | ( ) |\n", + "| 관측 공백 요인을 8개 이상 찾고 영향도를 매겼다 | ( ) |\n", + "| 공백마다 대체 확인 방법을 적었다 | ( ) |\n", + "| 효과를 단정하지 않는 표현 규칙과 판단의 한계를 명시했다 | ( ) |\n", + "\n", + "**\n", + "\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 04. 적 정찰위성 및 드론 감시 모의 상황에서의 기만 효과 측정 기준표 작성\n", + "\n", + "### 📋 미션 배경 및 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "적 정찰위성 및 드론 감시 모의 상황에서의 기만 효과 측정 기준표를 작성한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **가상 정찰·감시 모의 상황에서 기만이 얼마나 효과를 냈는지, 그 효과가 기만 때문인지 다른 요인 때문인지를 근거 항목과 가중치로 판정하는 측정 기준표를 작성한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **가상 관측팀이 관측 등급(L1~L3)을 올려 가며 기록한 모의 결과를 바탕으로, 효과를 뒷받침하거나 반박하는 근거를 12개 이상 항목화하고 항목별 가중치로 근거 종합 점수 산식을 정 수행하고 검증하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 5개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `matrix/sec06_attribution_matrix.xlsx(md)` 형태의 결과물을 완성한다.\n", + "\n", + "### 📑 실습자료 및 작성 양식\n", + "적 정찰위성 및 드론 감시 모의 상황에서의 기만 효과 측정 기준표 작성**\n", + "\n", + "가. 제공 자료 1 - 가상 관측 모의 결과 [교육용가상자료]\n", + "\n", + "| **관측 등급** | **D일 최초 식별(분)** | **D+1일 기만체 오인 건수/전체 보고** | **D+2일 모의 피해 판정** | **기상** |\n", + "| --- | --- | --- | --- | --- |\n", + "| **L1(육안)** | 31 | 3/7 | 2 | 구름 많음 |\n", + "| **L2(확대 광학)** | 18 | 2/8 | 3 | 맑음 |\n", + "| **L3(고급 광학 모의)** | 9 | 1/9 | 4 | 맑음 |\n", + "\n", + "가. 제공 자료 2 - 근거 항목 예시 4개(나머지는 조별로 12개 이상으로 늘린다)\n", + "\n", + "| **근거 항목** | **방향(지지·반박)** | **가중치 예시** |\n", + "| --- | --- | --- |\n", + "| **같은 조건에서 3회 이상 반복 측정됨** | 지지 | +3 |\n", + "| **평가자가 독립적으로 재확인함** | 지지 | +3 |\n", + "| **기상·시간대가 효과 비교 구간과 다름** | 반박 | -3 |\n", + "| **기만체 위치를 관측팀이 사전에 알고 있었음** | 반박 | -4 |\n", + "\n", + "나. 작성 서식 1 - 근거 항목 목록(12개 이상)\n", + "\n", + "| **번호** | **근거 항목** | **방향(지지·반박·확인 불가)** | **가중치** | **확인 방법** |\n", + "| --- | --- | --- | --- | --- |\n", + "| **1** | | | | |\n", + "| **2** | | | | |\n", + "| **3** | | | | |\n", + "| **4** | | | | |\n", + "| **5** | | | | |\n", + "| **6** | | | | |\n", + "| **7** | | | | |\n", + "| **8** | | | | |\n", + "| **9** | | | | |\n", + "| **10** | | | | |\n", + "| **11** | | | | |\n", + "| **12** | | | | |\n", + "\n", + "나. 작성 서식 2 - 근거 종합 점수 산식과 규칙: 종합 점수 = (지지 근거 가중치 합) - (반박 근거 가중치 합 절댓값) 이다. 규칙 1 점수 ( )점 이상이면 승인 검토, 규칙 2 반박 근거가 하나라도 있으면 ( ), 규칙 3 확인 불가 항목이 ( )개 이상이면 ( ), 규칙 4 기만 외 요인(기상·시간·숙련도)이 분리되지 않으면 ( ).\n", + "\n", + "나. 작성 서식 3 - 가상 사례 3종 적용(S1 위장막 식별 지연, S2 기만체 오인 유도, S3 분산 후 피해 감소)\n", + "\n", + "| **사례** | **지지 근거 합** | **반박 근거 합** | **기만 외 요인** | **종합 점수** | **판단(승인·보류·추가 측정)** | **판단 근거** |\n", + "| --- | --- | --- | --- | --- | --- | --- |\n", + "| **S1** | | | | | | |\n", + "| **S2** | | | | | | |\n", + "| **S3** | | | | | | |\n", + "\n", + "나. 작성 서식 4 - 레드팀 반박 기록\n", + "\n", + "| **번호** | **레드팀 반박(예: 효과가 아니라 날씨 때문이다)** | **작성 팀 대응** | **보완한 최소 증거 요건** |\n", + "| --- | --- | --- | --- |\n", + "| **1** | | | |\n", + "| **2** | | | |\n", + "| **3** | | | |\n", + "\n", + "다. 점검표\n", + "\n", + "| **점검 항목** | **확인** |\n", + "| --- | --- |\n", + "| 근거를 12개 이상 항목화하고 방향과 가중치를 적었다 | ( ) |\n", + "| 점수 산식과 규칙 1~4의 빈칸을 모두 채웠다 | ( ) |\n", + "| 사례 S1~S3의 점수와 판단을 계산해 기록했다 | ( ) |\n", + "| 기만 외 요인을 근거 항목에 반영했다 | ( ) |\n", + "| 레드팀 반박 3건에 대응했고 법적 쟁점은 법무 검토로만 표시했다 | ( ) |\n", + "\n", + "**\n", + "\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 05. 가상 배치 시나리오 기반 기만체/차폐 효과 분석 기록서 작성 실습\n", + "\n", + "### 📋 미션 배경 및 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "가상 배치 시나리오 기반 기만체/차폐 효과 기록서 작성 실습을 수행한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **가상 배치 시나리오의 효과 기록을 분석해 효과 확인 승인·보류의 분기점과 책임 직책을 확정하고, 보호 표지·민간 시설 관련 금지 사항을 지침에 반영한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **M2~M4 기준을 기록 근거 단계, 교차 확인 일치, 반대 근거, 승인 권한 순의 의사결정 트리로 묶고 분기점마다 조건과 책임 직책(가상)을 적는다 과정을 진행하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 5개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `sop/sec06_approval_thresholds_sop.md` 형태의 결과물을 완성한다.\n", + "\n", + "### 📑 실습자료 및 작성 양식\n", + "가상 배치 시나리오 기반 기만체/차폐 효과 분석 기록서 작성 실습**\n", + "\n", + "가. 제공 자료 1 - 가상 배치 시나리오와 경계선 기록 묶음 5종 [교육용가상자료]\n", + "\n", + "| **묶음** | **내용** | **애매한 이유** |\n", + "| --- | --- | --- |\n", + "| **B1** | 위장막 효과 G2, 반복 측정 있음, 기상이 비교 구간과 다름 | 근거는 강하나 조건이 다름 |\n", + "| **B2** | 기만체 오인 유도 3건, 관측팀이 위치를 알고 있었음 의심 | 관측 독립성 문제 |\n", + "| **B3** | 분산 후 피해 감소, 모의 공격 횟수 상이 | 비교 조건 불일치 |\n", + "| **B4** | 기만체 1개가 민간 시설 표지와 비슷한 도색이라는 지적 | 보호 표지 쟁점, 법무 검토 필요 |\n", + "| **B5** | 위장 효과 우수 기록, 평가자 서명 없음 | 근거 단계는 높으나 확인 서명 없음 |\n", + "\n", + "가. 제공 자료 2 - 책임 직책 예시(가상): 방호반장, 기록 담당, 검증 담당, 중대장, 법무 검토 담당\n", + "\n", + "나. 작성 서식 1 - 의사결정 트리(조건과 분기를 채운다)\n", + "\n", + "| **단계** | **확인 질문** | **예일 때** | **아니오일 때** |\n", + "| --- | --- | --- | --- |\n", + "| **1** | 기록 근거 단계가 G3 이상인가 | | |\n", + "| **2** | 서로 다른 날 또는 다른 관측자의 교차 확인이 있는가 | | |\n", + "| **3** | 반대 근거 또는 기만 외 요인이 분리되지 않은 것이 있는가 | | |\n", + "| **4** | 보호 표지·민간 시설 관련 쟁점이 있는가 | | |\n", + "| **5** | 승인 권한자의 확인이 있는가 | | |\n", + "\n", + "나. 작성 서식 2 - 분기점 조건과 책임 직책 매핑표\n", + "\n", + "| **분기점** | **조건(수치 또는 관찰 가능한 문장)** | **책임 직책(가상)** | **기록할 문서** |\n", + "| --- | --- | --- | --- |\n", + "| **T1** | | | |\n", + "| **T2** | | | |\n", + "| **T3** | | | |\n", + "| **T4** | | | |\n", + "\n", + "나. 작성 서식 3 - 모의 결심 기록지(B1~B5)\n", + "\n", + "| **묶음** | **판단(승인·보류 유지·추가 측정 요청)** | **근거** | **책임 직책** | **조원 합의(예·아니오)** |\n", + "| --- | --- | --- | --- | --- |\n", + "| **B1** | | | | |\n", + "| **B2** | | | | |\n", + "| **B3** | | | | |\n", + "| **B4** | | | | |\n", + "| **B5** | | | | |\n", + "\n", + "나. 작성 서식 4 - 긴급 신속 승인 규칙: 신속 승인에서도 줄이지 않는 확인 단계는 ( ), 사후 점검 항목은 ( ), 사후 점검 기한은 ( )이다. 보호 표지·민간 시설 쟁점은 신속 승인 대상에서 ( ).\n", + "\n", + "다. 점검표\n", + "\n", + "| **점검 항목** | **확인** |\n", + "| --- | --- |\n", + "| 분기점 4개의 조건을 수치 또는 관찰 가능한 문장으로 적었다 | ( ) |\n", + "| 분기점마다 책임 직책(가상)과 기록 문서를 연결했다 | ( ) |\n", + "| 경계선 묶음 5종 모두에 판단과 근거를 기록했다 | ( ) |\n", + "| 보호 표지·민간 시설 쟁점이 법무 검토로 연결된다 | ( ) |\n", + "| 사람의 승인 없이 효과가 승인되는 경로가 없고 교전수칙·지휘계통 우선 문구가 있다 | ( ) |\n", + "\n", + "**\n", + "\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 06. 지휘관 보고용 생존성 향상 정량/정성 평가 보고서 서식 개발\n", + "\n", + "### 📋 미션 배경 및 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "지휘관 보고용 생존성 향상 종합 평가 보고서 서식을 개발한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **지휘관 보고용 생존성 향상 평가 요약 보고서와, 근거를 사실·판단·조치·미확인으로 구분해 기록하는 서식을 개발한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **한눈에 보는 요약 차트(배치 구역, 효과 판정, 근거 단계, 반대 근거, 보류 사유, 지휘관 결심 요청) 설계하고 워크플로우를 구성하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 5개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `templates/sec06_aar_fact_action_template.md` 형태의 결과물을 완성한다.\n", + "\n", + "### 📑 실습자료 및 작성 양식\n", + "지휘관 보고용 생존성 향상 정량/정성 평가 보고서 서식 개발**\n", + "\n", + "가. 제공 자료 1 - 혼동 유발 표현 예시(나머지는 조별로 6개 이상으로 늘린다)\n", + "\n", + "| **혼동 유발 표현** | **왜 혼동되는가** | **대체 표현 예** |\n", + "| --- | --- | --- |\n", + "| **효과가 확실하다** | 근거 단계와 조건이 보이지 않음 | 관측 조건 (기상·시간)에서 식별 지연 (분) 확인(G2) |\n", + "| **노출되지 않았다** | 관측이 없었던 것인지 못 본 것인지 알 수 없음 | 관측팀 (횟수)회 관측에서 식별 보고 없음 |\n", + "| **충분히 분산했다** | 기준 없는 판단 | 집결 6개소를 3개소로 분산, 인접 간격 (기준) 확보 |\n", + "\n", + "가. 제공 자료 2 - 가상 평가 메모 3종(사실·판단·조치·미확인을 구분해 표시한다)\n", + "\n", + "| **메모** | **내용** |\n", + "| --- | --- |\n", + "| **M-1** | 07시 관측팀 기록상 위장막 식별이 31분 걸렸다. 위장이 잘 되어 있었다고 본다. 접합부 보수를 지시했다. 12시 상황은 확인하지 못했다. |\n", + "| **M-2** | 기만체 DC-2가 오인되었다. 상대가 속았다고 판단된다. 위치를 옮길 예정이다. 오인한 관측자가 누구인지는 모른다. |\n", + "| **M-3** | 분산 후 피해 판정이 줄었다. 분산 덕분이라고 생각한다. 다음 날도 같은 배치를 유지한다. 모의 공격 횟수 차이는 아직 확인되지 않았다. |\n", + "\n", + "나. 작성 서식 1 - 지휘관 보고용 요약 차트\n", + "\n", + "| **배치 구역** | **방식** | **효과 판정** | **근거 단계** | **반대 근거** | **보류 사유** | **결심 요청** |\n", + "| --- | --- | --- | --- | --- | --- | --- |\n", + "| | | | | | | |\n", + "| | | | | | | |\n", + "| | | | | | | |\n", + "| | | | | | | |\n", + "\n", + "나. 작성 서식 2 - 기록 분류 규칙(빈칸을 채운다): 사실은 ( ), 판단은 ( ), 조치는 ( ), 미확인은 ( ) 표시로 구분한다. 문장 하나에는 한 가지 분류만 담는다.\n", + "\n", + "나. 작성 서식 3 - 요약형 기록(메모 M-1~M-3을 옮겨 적는다)\n", + "\n", + "| **메모** | **사실** | **판단** | **조치** | **미확인** |\n", + "| --- | --- | --- | --- | --- |\n", + "| **M-1** | | | | |\n", + "| **M-2** | | | | |\n", + "| **M-3** | | | | |\n", + "\n", + "나. 작성 서식 4 - 상세형 기록(1건 선택)\n", + "\n", + "| **기록 번호** | |\n", + "| --- | --- |\n", + "\n", + "| **사실(관측 시각, 조건, 측정값, 근거 단계)** | |\n", + "| --- | --- |\n", + "\n", + "| **판단(효과 판정과 근거 항목)** | |\n", + "| --- | --- |\n", + "\n", + "| **조치(누가, 언제까지)** | |\n", + "| --- | --- |\n", + "\n", + "| **미확인(확인 방법, 책임 직책)** | |\n", + "| --- | --- |\n", + "\n", + "다. 점검표\n", + "\n", + "| **점검 항목** | **확인** |\n", + "| --- | --- |\n", + "| 요약 차트에 근거 단계, 반대 근거, 보류 사유 칸이 있다 | ( ) |\n", + "| 혼동 유발 표현 금지 목록을 6개 이상 만들고 대체 표현을 적었다 | ( ) |\n", + "| 메모 3종에서 사실과 판단을 혼동한 문장이 0건이다 | ( ) |\n", + "| 요약형과 상세형이 같은 내용을 담고 있다 | ( ) |\n", + "| 미확인 항목마다 확인 방법과 책임 직책이 있다 | ( ) |\n", + "\n", + "**\n", + "\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 07. 적 관측 장비 업그레이드 모의 상황 하 위장 무력화 한계 스트레스 분석\n", + "\n", + "### 📋 미션 배경 및 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "적 관측 장비 업그레이드 모의 상황 하 위장 무력화 한계 스트레스 분석을 진행한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **적 관측 능력이 높아진 것으로 가정한 가상 상황에서 위장과 기만이 어느 수준까지 버티는지 한계를 분석하고, 이를 평가할 루브릭을 정의한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **가상 관측 등급 L1에서 L3까지 오르고, 야간·기상 변화·기만체 노후가 겹치는 모의 카드 6장을 순서대로 주입하며 배치별 효과 판정이 어떻게 바뀌는지 기록 수행하고 검증하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 5개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `rubric/sec06_evaluation_rubric.md` 형태의 결과물을 완성한다.\n", + "\n", + "### 📑 실습자료 및 작성 양식\n", + "적 관측 장비 업그레이드 모의 상황 하 위장 무력화 한계 스트레스 분석**\n", + "\n", + "가. 제공 자료 1 - 관측 능력 상향 모의 카드 T1~T6 [교육용가상자료] (통제관이 순서대로 주입)\n", + "\n", + "| **카드** | **주입 내용** | **확인할 것** |\n", + "| --- | --- | --- |\n", + "| **T1** | 관측 등급 L1 기준 모의 관측(기준선) | 배치별 식별 시간 기록 |\n", + "| **T2** | 관측 등급 L2로 상향 | 위장막 접합부 노출 여부 |\n", + "| **T3** | 관측 등급 L3로 상향, 기만체 노후 흔적 표시 | 기만체 오인 감소 여부 |\n", + "| **T4** | 야간 관측 모의로 전환 | 차폐 성능 관측 등급 변화 |\n", + "| **T5** | 기상 변화(비) 카드 | 기록 조건 기재 여부 |\n", + "| **T6** | 관측팀 교대(새 기록자) | 판정 기준 일관성 |\n", + "\n", + "가. 제공 자료 2 - 루브릭 틀(5영역 x 4등급, 관찰 가능한 행동으로 작성)\n", + "\n", + "| **평가 영역** | **탁월** | **우수** | **보통** | **미흡** |\n", + "| --- | --- | --- | --- | --- |\n", + "| **위장 유지** | | | | |\n", + "| **기만 유인** | | | | |\n", + "| **분산 운용** | | | | |\n", + "| **기록 정확성** | | | | |\n", + "| **보고 적시성** | | | | |\n", + "\n", + "나. 작성 서식 1 - 카드별 효과 판정 기록\n", + "\n", + "| **카드** | **배치별 효과 판정** | **효과가 유지된 것** | **효과가 사라진 것** | **이유** |\n", + "| --- | --- | --- | --- | --- |\n", + "| **T1** | | | | |\n", + "| **T2** | | | | |\n", + "| **T3** | | | | |\n", + "| **T4** | | | | |\n", + "| **T5** | | | | |\n", + "| **T6** | | | | |\n", + "\n", + "나. 작성 서식 2 - 평가자 채점 일치표(평가자 2명이 상의 없이 채점)\n", + "\n", + "| **평가 영역** | **평가자 1 등급** | **평가자 2 등급** | **일치(예·아니오)** | **불일치 이유** |\n", + "| --- | --- | --- | --- | --- |\n", + "| **위장 유지** | | | | |\n", + "| **기만 유인** | | | | |\n", + "| **분산 운용** | | | | |\n", + "| **기록 정확성** | | | | |\n", + "| **보고 적시성** | | | | |\n", + "\n", + "나. 작성 서식 3 - 한계 도달 지점: 효과가 사라진 첫 카드는 ( ), 이유는 ( ), 이 한계를 지휘관에게 알릴 문장은 '( )'이다. 일치율은 ( ) / 5 = ( )%이다.\n", + "\n", + "다. 점검표\n", + "\n", + "| **점검 항목** | **확인** |\n", + "| --- | --- |\n", + "| 5개 영역 x 4단계 기준을 모두 관찰 가능한 행동으로 정의했다 | ( ) |\n", + "| 카드 T1~T6마다 효과 판정을 기록했다 | ( ) |\n", + "| 평가자 2명이 상의 없이 채점하고 일치율을 계산했다 | ( ) |\n", + "| 효과가 사라진 지점과 이유를 적었다 | ( ) |\n", + "| 실제 관측 장비 성능을 쓰지 않고 가상 등급만 사용했다 | ( ) |\n", + "\n", + "**\n", + "\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 08. AAR을 통한 위장 노출 및 기만 실패 사례 원인 규명\n", + "\n", + "### 📋 미션 배경 및 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "AAR 회의를 통해 위장 노출 및 기만 실패 사례 원인을 규명한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **가상 AAR 일지에서 위장 노출과 기만 실패 사례의 원인을 사람·절차·정보·환경으로 나누어 규명하고, 레드팀 비판으로 지침의 취약한 전제를 찾는다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **가상 AAR 일지 2건을 시간순으로 읽고 계획과 실제의 차이를 적은 뒤 노출 원인을 분류 수행하고 검증하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 5개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `reports/sec06_redteam_critique_report.md` 형태의 결과물을 완성한다.\n", + "\n", + "### 📑 실습자료 및 작성 양식\n", + "AAR을 통한 위장 노출 및 기만 실패 사례 원인 규명**\n", + "\n", + "가. 제공 자료 - 가상 AAR 일지 A·B [교육용가상자료]\n", + "\n", + "| **시각** | **AAR 일지 A (D+1일)** | **AAR 일지 B (D+2일)** |\n", + "| --- | --- | --- |\n", + "| **07:00** | 위장막 설치 완료, 가-4 차량 2대 아래 | 분산 이동 시작, 3개소로 재배치 |\n", + "| **09:30** | 관측팀 L2 관측, 접합부 반사로 12분 만에 식별 | 이동 차량 바퀴 자국이 기만체 방향으로만 선명 |\n", + "| **12:00** | 기만체 DC-4를 실제 대상으로 기록(3건) | 기만체 DC-1 주변에 인원 활동 흔적이 없어 부자연스럽다는 관측 의견 |\n", + "| **14:30** | 위장막 일부가 바람에 들림 | 분산 후 지휘 연락 지연 22분 |\n", + "| **17:00** | 야간 대비 보수 지시 | 보급 차량 이동 횟수 증가로 유지 부담 증가 |\n", + "\n", + "나. 작성 서식 1 - 계획과 실제 차이, 원인 분류(사람·절차·정보·환경, 개인 비난 표현 금지)\n", + "\n", + "| **번호** | **계획** | **실제** | **원인 분류** | **원인 설명(사실 중심)** | **개선안** |\n", + "| --- | --- | --- | --- | --- | --- |\n", + "| **1** | | | | | |\n", + "| **2** | | | | | |\n", + "| **3** | | | | | |\n", + "| **4** | | | | | |\n", + "| **5** | | | | | |\n", + "\n", + "나. 작성 서식 2 - 레드팀 쟁점·대응(5건 이상)\n", + "\n", + "| **번호** | **지침의 취약한 전제** | **레드팀 비판** | **작성 팀 반박 또는 수정안** | **경고 문구** |\n", + "| --- | --- | --- | --- | --- |\n", + "| **1** | | | | |\n", + "| **2** | | | | |\n", + "| **3** | | | | |\n", + "| **4** | | | | |\n", + "| **5** | | | | |\n", + "| **6** | | | | |\n", + "\n", + "나. 작성 서식 3 - 반대 방향 위험 점검\n", + "\n", + "| **위험** | **설명** | **대응** |\n", + "| --- | --- | --- |\n", + "| **유지 부담 증가** | | |\n", + "| **아군 오인** | | |\n", + "| **지휘·보급 지연** | | |\n", + "\n", + "나. 작성 서식 4 - 수정 반영 계획\n", + "\n", + "| **번호** | **수정할 문서와 위치** | **수정 내용** | **책임 직책(가상)** | **기한** |\n", + "| --- | --- | --- | --- | --- |\n", + "| **1** | | | | |\n", + "| **2** | | | | |\n", + "| **3** | | | | |\n", + "| **4** | | | | |\n", + "\n", + "다. 점검표\n", + "\n", + "| **점검 항목** | **확인** |\n", + "| --- | --- |\n", + "| 일지 A·B의 계획과 실제 차이를 5건 이상 적었다 | ( ) |\n", + "| 원인을 사람·절차·정보·환경으로 분류하고 개인 비난 표현이 없다 | ( ) |\n", + "| 레드팀 쟁점 5건 이상에 반박 또는 수정안을 100% 적었다 | ( ) |\n", + "| 취약한 전제에 경고 문구를 명시했다 | ( ) |\n", + "| 반대 방향 위험 3가지를 점검했다 | ( ) |\n", + "\n", + "**\n", + "\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 09. 피드백 반영 기만체 배치 위치 재조정 및 차폐 기록서 보정\n", + "\n", + "### 📋 미션 배경 및 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "피드백을 반영하여 기만체 배치와 차폐 기록서를 보정한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **AAR 피드백을 반영해 기만체 배치 위치와 차폐 기록서를 보정하고, 핵심 결심 사항을 지휘관 1페이지 브리핑으로 요약한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **개선 전후 배치 기준과 기록 서식을 비교표에 정리하고 가상 재검증 사례 7건에 개선 전·후 기준을 각각 적용해 판단, 일치 여부, 소요 시간을 기록 수행하고 검증하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 5개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `briefing/sec06_commander_1page_brief.md(pdf)` 형태의 결과물을 완성한다.\n", + "\n", + "### 📑 실습자료 및 작성 양식\n", + "피드백 반영 기만체 배치 위치 재조정 및 차폐 기록서 보정**\n", + "\n", + "가. 제공 자료 - 가상 재검증 사례 7건 [교육용가상자료]\n", + "\n", + "| **사례** | **내용** | **개선 전 판단** | **개선 후 판단(조별 작성)** |\n", + "| --- | --- | --- | --- |\n", + "| **V1** | 위장막 식별 지연, 단회 관측 G4 | 승인 | |\n", + "| **V2** | 기만체 오인, 관측자가 위치를 사전에 앎 | 승인 | |\n", + "| **V3** | 분산 후 피해 감소, 모의 조건 상이 | 승인 | |\n", + "| **V4** | 차폐 등급 상, 기상 미기재 | 승인 | |\n", + "| **V5** | 위장 효과 양호, 근거 없음 | 승인 | |\n", + "| **V6** | 반복 측정 3회, 평가자 재확인 있음 | 보류 | |\n", + "| **V7** | 기만체 도색의 보호 표지 유사 지적 | 승인 | |\n", + "\n", + "나. 작성 서식 1 - 개선 전후 기준 비교표\n", + "\n", + "| **항목** | **개선 전 기준** | **개선 후 기준** | **바뀐 이유** | **근거 미션** |\n", + "| --- | --- | --- | --- | --- |\n", + "| **효과 근거 단계** | | | | |\n", + "| **교차 확인** | | | | |\n", + "| **기상 기록** | | | | |\n", + "| **기만체 위치 정보 차단** | | | | |\n", + "| **보호 표지 점검** | | | | |\n", + "\n", + "나. 작성 서식 2 - 재검증 결과표\n", + "\n", + "| **사례** | **개선 전 판단** | **개선 후 판단** | **일치 여부** | **소요 시간(분)** |\n", + "| --- | --- | --- | --- | --- |\n", + "| **V1** | | | | |\n", + "| **V2** | | | | |\n", + "| **V3** | | | | |\n", + "| **V4** | | | | |\n", + "| **V5** | | | | |\n", + "| **V6** | | | | |\n", + "| **V7** | | | | |\n", + "\n", + "나. 작성 서식 3 - 1페이지 브리핑 BLUF 틀\n", + "\n", + "| **결론(첫 문장)** | |\n", + "| --- | --- |\n", + "\n", + "| **핵심 결심 사항** | |\n", + "| --- | --- |\n", + "\n", + "| **위험 요소(신호등 표시: 녹·황·적)** | |\n", + "| --- | --- |\n", + "\n", + "| **대안 3종(장단점 한 줄)** | |\n", + "| --- | --- |\n", + "\n", + "| **근거** | |\n", + "| --- | --- |\n", + "\n", + "나. 작성 서식 4 - 브리핑 리허설 기록\n", + "\n", + "| **항목** | **목표** | **결과** |\n", + "| --- | --- | --- |\n", + "| 발표 시간 | 3분 이내 | |\n", + "| 분량 | A4 1페이지 | |\n", + "| 질의 응답 | 3건 이상 답변 | |\n", + "| 결론 먼저 제시 | 첫 문장 | |\n", + "\n", + "다. 점검표\n", + "\n", + "| **점검 항목** | **확인** |\n", + "| --- | --- |\n", + "| 개선 전후 기준의 바뀐 조건과 이유를 모두 적었다 | ( ) |\n", + "| 재검증 7건 모두 개선 전후 판단을 비교했다 | ( ) |\n", + "| 브리핑이 결론부터 시작하고 A4 1페이지에 들어간다 | ( ) |\n", + "| 핵심 결심 사항, 위험 요소, 대안 3종이 모두 있다 | ( ) |\n", + "| 3분 안에 발표하고 질의에 답했다 | ( ) |\n", + "\n", + "**\n", + "\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 10. 기만체·차폐·분산 배치 효과 확인 및 기록 관리 표준 지침서 완성\n", + "\n", + "### 📋 미션 배경 및 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "기만체·차폐·분산 배치 효과 확인 기록서 표준 지침을 완성한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **미션 1~9의 산출물을 통합해 기만체·차폐·분산 배치 효과 확인 기록 표준 지침서를 완성하고 모의 심의를 거친다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **산출물을 지침서 8개 장(목적과 적용 범위, 용어, 위장·기만·분산 방식, 효과 확인 신뢰도, 효과 귀속과 승인·보류, 기록·보고, 훈련·평가, 개정 관리)과 부록에 배치하고 빠진 내용을 보완 수행하고 검증하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 5개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `manual/sec06_standard_operational_manual.md` 형태의 결과물을 완성한다.\n", + "\n", + "### 📑 실습자료 및 작성 양식\n", + "기만체·차폐·분산 배치 효과 확인 및 기록 관리 표준 지침서 완성**\n", + "\n", + "가. 제공 자료 1 - 지침서 8개 장 구성과 가져올 결과물\n", + "\n", + "| **장** | **제목** | **포함 내용** | **가져올 결과물** |\n", + "| --- | --- | --- | --- |\n", + "| **1** | 목적과 적용 범위 | 목적, 적용 상황, 적용하지 않는 상황, 교전수칙과의 관계 | M1 |\n", + "| **2** | 용어 정의 | 판독 지연, 오인 유도, 효과 귀속, 신뢰도 단계, 보류 | M1~M3 |\n", + "| **3** | 위장·기만·분산 방식 | 방식별 조건, 약점, 보호 표지 금지 사항 | M1·M5 |\n", + "| **4** | 효과 확인 신뢰도 | G1~G6 기준, 평가 지표, 교차 확인 규칙 | M2 |\n", + "| **5** | 효과 귀속과 승인·보류 | 근거 항목, 점수 산식, 의사결정 트리, 책임 직책 | M3·M4·M5 |\n", + "| **6** | 기록·보고 | 기록 서식, 요약 차트, 표현 규칙, 브리핑 | M3·M6·M9 |\n", + "| **7** | 훈련·평가 | 스트레스 분석, 루브릭, AAR, 레드팀 검토 | M7·M8 |\n", + "| **8** | 개정 관리 | 개정 주기, 의견 수렴, 책임 직책, 이력 | M8·M9 |\n", + "\n", + "가. 제공 자료 2 - 모의 심의 역할 카드\n", + "\n", + "| **역할** | **검토 질문** |\n", + "| --- | --- |\n", + "| **교리 역할** | 방식별 효과 조건과 신뢰도 단계가 일관되는가? 신병도 이해할 용어인가? |\n", + "| **법무 역할** | '최종 판단은 사람이, 교전수칙·지휘계통에 따라'가 드러나는가? 보호 표지·민간 시설 쟁점이 법무 검토로 연계되는가? |\n", + "| **작전 역할** | 현장에서 20분 안에 기록할 수 있는가? 기록 부담 때문에 위장 유지 업무가 밀리지 않는가? |\n", + "\n", + "나. 작성 서식 1 - 근거 표기 점검표(지침서 조항마다 작성)\n", + "\n", + "| **조항** | **기준 내용 요약** | **근거(미션·가상 자료)** | **표기 여부** |\n", + "| --- | --- | --- | --- |\n", + "| **1-1** | | | |\n", + "| **2-1** | | | |\n", + "| **3-1** | | | |\n", + "| **4-1** | | | |\n", + "| **5-1** | | | |\n", + "| **6-1** | | | |\n", + "| **7-1** | | | |\n", + "| **8-1** | | | |\n", + "\n", + "나. 작성 서식 2 - 모의 심의 지적 사항 반영표\n", + "\n", + "| **번호** | **역할** | **지적 사항** | **반영 내용** | **반영 위치(장·조)** |\n", + "| --- | --- | --- | --- | --- |\n", + "| **1** | | | | |\n", + "| **2** | | | | |\n", + "| **3** | | | | |\n", + "| **4** | | | | |\n", + "| **5** | | | | |\n", + "| **6** | | | | |\n", + "\n", + "나. 작성 서식 3 - 개정 관리: 개정 주기 ( ), 의견 수렴 방법 ( ), 개정 책임 직책(가상) ( ), 개정 이력 기록 위치 ( )\n", + "\n", + "다. 점검표\n", + "\n", + "| **점검 항목** | **확인** |\n", + "| --- | --- |\n", + "| 8개 장과 부록을 모두 완성했다 | ( ) |\n", + "| 모든 기준 조항에 근거(미션·가상 자료)를 표기했다 | ( ) |\n", + "| 교리·법무·작전 역할의 지적 사항을 반영하고 반영 위치를 적었다 | ( ) |\n", + "| '최종 판단은 사람이 하며 실제 교전·방호 판단은 교전수칙·지휘계통에 따른다'는 문구가 1장과 5장에 있다 | ( ) |\n", + "| 보호 표지·민간 시설 표지를 기만체에 쓰지 않는다는 문구가 3장에 있다 | ( ) |\n", + "| 실제 부대·시설 배치·장비 제원을 쓰지 않았다 | ( ) |\n", + "\n", + "**4. 평가 채점표**\n", + "\n", + "미션별 평가 기준은 시나리오의 평가 항목과 같다. 평가자는 기준 충족 여부를 PASS/FAIL로 표시하고 FAIL이면 사유를 비고에 적는다.\n", + "\n", + "| **미션** | **구분** | **채점 기준** | **판정** | **비고** |\n", + "| --- | --- | --- | --- | --- |\n", + "| **M1** | 필수 | 효과 분석 틀·문서 골격: 가상 사례 5건 이상을 검토해 위장·기만·분산 3방식의 효과 조건과 약점을 비교했는가, 지침서 목차(8개 장) 골격을 100% 정의하고 지휘관 결심 의제 순으로 배열했는가. | PASS / FAIL | |\n", + "| **M2** | 필수 | 효과 확인 6단계 기준·평가 지표: 효과 확인 신뢰도 기준 6단계를 완비하고 평가 지표 10개 이상을 정의했는가, 가상 기록 5종에 대한 조원 간 등급 일치가 4건 이상이고 편향 방지 주의와 교차 확인 필수 규칙을 명시했는가. | PASS / FAIL | |\n", + "| **M3** | 필수 | 기록 서식·불확실성 평가: 관측 공백 요인 8개 이상과 영향도 매트릭스를 도출했는가, 어긋난 기록 4건에 원인 후보와 확인 방법을 적고 판단의 한계와 대체 확인 방법을 명시했는가. | PASS / FAIL | |\n", + "| **M4** | 선택 | 근거 항목·효과 귀속 판단: 근거 12개 이상 항목화와 가중치 산식을 100% 정립했는가, 사례 3종 적용 결과를 기록하고 레드팀 반박에 대응해 기만 외 요인을 분리하는 최소 증거 요건을 설정했는가. | PASS / FAIL | |\n", + "| **M5** | 필수 | 분기점·책임 매핑·모의 판단: 분기점 조건 100% 명시와 책임 직책 매핑을 완료했는가, 경계선 사례 5종 모두에 판단과 근거가 기록되고 교전수칙·보호 표지 금지 원칙 준수와 사람의 최종 판단을 지켰는가. | PASS / FAIL | |\n", + "| **M6** | 선택 | 기록 분류·표준 서식: 기록 분류 규칙 100% 표준화, 혼동 유발 표현 금지 가이드 6개 이상을 포함하고 가상 평가 메모 3종에서 사실과 판단의 혼동이 0건인가. | PASS / FAIL | |\n", + "| **M7** | 필수 | 한계 스트레스 분석·루브릭: 평가 영역 5개와 등급 4단계(탁월·우수·보통·미흡) 기준 정의율 100%인가, 평가자 2명 채점 일치율 80% 이상이며 효과 소멸 지점을 기록으로 제시했는가. | PASS / FAIL | |\n", + "| **M8** | 선택 | 원인 규명·레드팀 비판: 도출된 비판적 쟁점 5건 이상에 대해 반박 및 수정 대안을 100% 수립했는가, 취약한 전제에 경고 문구를 명시하고 반대 방향 위험을 점검했는가. | PASS / FAIL | |\n", + "| **M9** | 필수 | 배치 보정·1페이지 브리핑: 핵심 결심 사항, 위험 요소, 대안 3종이 1페이지(A4) 안에 완결되는가, 재검증 7건의 개선 전후 비교와 BLUF 준수(결론 먼저, 3분 이내 발표)를 확인했는가. | PASS / FAIL | |\n", + "| **M10** | 필수 | 표준 지침서 통합 완성: 지침서 8개 장과 부록을 100% 완성하고 모든 기준 조항에 근거(미션·가상 자료)를 표기했는가, 모의 심의 지적 사항을 반영하고 '최종 판단은 사람이 하며 실제 교전·방호 판단은 교전수칙·지휘계통에 따른다'는 문구를 명시했는가. | PASS / FAIL | |\n", + "\n", + "| **판정 기준** | 필수 7개 미션(M1·M2·M3·M5·M7·M9·M10)이 모두 PASS여야 최종 PASS로 인정한다. 선택 미션(M4·M6·M8)은 가점·보완 의견에 반영한다. |\n", + "| --- | --- |\n", + "\n", + "| **공통 감점** | 실제 부대명·시설 배치·장비 제원·실존 인물 정보를 사용한 경우, 부대 자료를 외부 AI 서비스에 입력한 경우 해당 미션은 FAIL로 처리한다. |\n", + "| --- | --- |\n", + "\n", + "| **주제 안전 기준** | 단회 관측만으로 효과를 승인하는 판단이 있거나 지침서에 사람의 최종 판단·교전수칙 우선 문구와 보호 표지 금지 문구가 없으면 M5·M10은 FAIL로 처리한다. |\n", + "| --- | --- |\n", + "\n", + "| **최종 판정 결과** | ( PASS / FAIL ) 평가자 서명: ( ) |\n", + "| --- | --- |\n", + "\n", + "| 안전 원칙: 본 자료의 등급 기준, 승인 권한 구분, 의사결정 트리는 학습용 가상 기준이다. 최종 판단은 사람이 내리며 실제 교전·방호 판단은 교전수칙과 지휘계통에 따른다. 실제 부대 자료를 입력하거나 부대 내에서 촬영하지 않는다. |\n", + "| --- |\n", + "\n" + ] + } + ], + "metadata": { + "colab": { + "provenance": [] + }, + "kernelspec": { + "display_name": "Python 3", + "name": "python3" + }, + "language_info": { + "name": "python" + } + }, + "nbformat": 4, + "nbformat_minor": 0 +} \ No newline at end of file diff --git a/uploads/SEC08/SEC08_workbook.ipynb b/uploads/SEC08/SEC08_workbook.ipynb new file mode 100644 index 0000000..33ee638 --- /dev/null +++ b/uploads/SEC08/SEC08_workbook.ipynb @@ -0,0 +1,1095 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# 📋 [SEC08] 군사시설 드론 침입 경보 대응 모의훈련\n", + "### 실습 워크북 & SOP 가이드 (Google Colab / Jupyter)\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/SEC08/SEC08_workbook.ipynb)\n", + "\n", + "---\n", + "\n", + "## 📌 1. 프로젝트 개요 및 배경\n", + "\n", + "| 항목 | 내용 |\n", + "| :--- | :--- |\n", + "| **과정 분류** | 시설·방호·경계 (Level 3) |\n", + "| **학습 소요 시간** | 10개 미션 (5일 / 총 27.0시간) |\n", + "| **실습 유형** | 작전절차(SOP), 체크리스트, 모의훈련, AAR 사후검토 실습 |\n", + "\n", + "### 📖 프로젝트 목표\n", + "러시아-우크라이나 전쟁에서는 소형 드론이 정찰, 폭발물 투하, 자폭 형태로 후방 군사시설까지 반복해서 침입했고, 탐지 이후 경계조·방호통제실·대응조·시설관리반이 얼마나 빨리 같은 정보를 공유하고 대피와 방호를 마치는지가 피해를 좌우했다. 본 프로젝트에서는 가상 기지를 무대로 침입 유형별 위협과 부서별 역할을 정리하고, 방호 단계별 행동 체크리스트와 3자 전파 체계, 상황 주입 시나리오를 만든다. 이어서 1차 모의훈련, 오류 주입 교차확인, 잔해물 폭발물 처리반(EOD) 인계, 다방향 동시 침입과 통신 마비 스트레스 훈련, 사후검토(AAR), 재훈련을 차례로 수행하며 매 단계 시간과 오류를 기록해 개선 효과를 수치로 확인한다. 이 과제는 탐지 이후 현장 대응 역량이 목적이며, 대응 장비의 기술적 조작은 다루지 않고 역할·승인·보고·인계 절차만 다룬다. 최종 목표는 시설 드론 침입 경보 대응 표준 지침서(SOP)와 다른 부대 교관이 바로 쓸 수 있는 훈련 패키지를 완성하는 것이다.\n", + "\n", + "> **⚠️ [교육용 가상 자료 원칙]**\n", + "> - 본 워크북의 모든 자료는 안전 규정에 따라 **교육용 가상 자료**로 구성되어 있습니다.\n", + "> - 실제 군사 기밀이나 부대 세부 인적·작전 정보는 포함되지 않습니다.\n", + "\n", + "---\n", + "\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 01. 군사시설 대상 드론 침입 유형(정찰/자폭/투하) 분석 및 대응 시나리오 수립\n", + "\n", + "### 📋 미션 배경 및 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "군사시설 대상 드론 침입 위협(정찰, 폭발물 투하, 자폭) 시나리오와 방호 체계를 분석한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **정찰·폭발물 투하·자폭 세 가지 드론 침입 유형을 분석하고 부서별 역할과 현장 체크 항목을 정의해 훈련의 기초 요구사항을 정리한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **실습자료의 가상 침입 사례 카드 6건을 읽고 침입 유형을 정찰·투하·자폭으로 분류한 뒤, 접수-식별-경보 발령-전파-방호 조치-대응·통제-해제·인계 7단계에서 경계조·방호통제실·대응조·시설관리반이 누가 수행하고 누가 책임지는지 역할 분담 매트릭스를 채운다 과정을 진행하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 5개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `docs/sec08_baseline_assessment.md 및 훈련 프로파일` 형태의 결과물을 완성한다.\n", + "\n", + "### 📑 실습자료 및 작성 양식\n", + "군사시설 대상 드론 침입 유형(정찰/자폭/투하) 분석 및 대응 시나리오 수립**\n", + "\n", + "가. 제공 자료 - 가상 드론 침입 사례 카드 [교육용가상자료]\n", + "\n", + "| **카드** | **가상 시각·기상** | **상황** | **드러난 문제** |\n", + "| --- | --- | --- | --- |\n", + "| **C1** | 22:15, 구름 | 드론 1기가 B구역 상공을 천천히 선회하다 사라졌다. 경계조는 보고했지만 통제실은 시설반에 전파하지 않았다. | 정찰 의심, 시설반 전파 누락 |\n", + "| **C2** | 23:05, 야간 | C구역 공터에 작은 물체가 떨어지는 소리가 났다. 인원 2명이 확인하려고 다가갔다. | 투하 의심 물체에 접근, 접근 통제 없음 |\n", + "| **C3** | 01:30, 야간 | 드론 1기가 B구역 쪽으로 빠르게 직진했다. 경보 방송이 늦어 일부 인원이 야외에 남아 있었다. | 자폭 의심 접근, 경보·대피 지연 |\n", + "| **C4** | 10:20, 맑음 | 정문 인근에서 민간 드론이 촬영 중이었다. 경계조는 위협으로 보고했으나 대응조는 민간 비행체 여부를 몰랐다. | 민간 비행체 판별 기준 없음 |\n", + "| **C5** | 03:10, 야간 | 통제실 전화가 통화 중이어서 경계조 보고가 2분 넘게 접수되지 않았다. | 보고 채널 점유, 대체 수단 없음 |\n", + "| **C6** | 04:40, 새벽 | 드론 격추 후 떨어진 잔해 주변에 인원 여러 명이 모여 구경했다. | 잔해 접근 금지 규칙 없음, EOD 보고 지연 |\n", + "\n", + "나. 작성 서식 1 - 침입 유형 분류표 (카드를 정찰/투하/자폭 의심으로 분류하고 근거를 쓴다)\n", + "\n", + "| **카드** | **유형(정찰/투하/자폭/판별 불가)** | **분류 근거(카드에 적힌 사실)** | **가장 우선할 보호 대상(구역)** |\n", + "| --- | --- | --- | --- |\n", + "| **C1** | 정찰 (예시) | 천천히 선회, 낙하물 없음 (예시) | ( ) |\n", + "| **C2** | | | |\n", + "| **C3** | | | |\n", + "| **C4** | | | |\n", + "| **C5** | | | |\n", + "| **C6** | | | |\n", + "\n", + "다. 작성 서식 2 - 역할 분담 매트릭스 (수: 수행, 책: 책임, 협: 협조, 통: 통보 / 단계마다 '책'은 1명)\n", + "\n", + "| **단계** | **경계조** | **방호통제실** | **대응조** | **시설관리반** | **완료 판단 기준** |\n", + "| --- | --- | --- | --- | --- | --- |\n", + "| **① 접수** | 수 (예시) | 책 (예시) | 통 (예시) | 통 (예시) | 접수 시각 기록 (예시) |\n", + "| **② 식별** | ( ) | ( ) | ( ) | ( ) | ( ) |\n", + "| **③ 경보 발령** | ( ) | ( ) | ( ) | ( ) | ( ) |\n", + "| **④ 전파** | ( ) | ( ) | ( ) | ( ) | ( ) |\n", + "| **⑤ 방호 조치** | ( ) | ( ) | ( ) | ( ) | ( ) |\n", + "| **⑥ 대응·통제** | ( ) | ( ) | ( ) | ( ) | ( ) |\n", + "| **⑦ 해제·인계** | ( ) | ( ) | ( ) | ( ) | ( ) |\n", + "\n", + "라. 작성 서식 3 - 현장 체크 항목 정의표 (15개 이상)\n", + "\n", + "| **번호** | **단계** | **체크 항목(눈으로 확인 가능한 문장)** | **확인 방법** | **담당** |\n", + "| --- | --- | --- | --- | --- |\n", + "| **1** | 접수 | 경계조 1차 보고에 방위와 접근 대상 구역이 포함되었다 (예시) | 시각 기록표 (예시) | 통제실 (예시) |\n", + "| **2** | 방호 조치 | 지정 대피 위치에 인원 점검 결과가 보고되었다 (예시) | 평가관 관찰 (예시) | 시설반 (예시) |\n", + "| **3** | | | | |\n", + "| **4** | | | | |\n", + "| **5** | | | | |\n", + "| **6** | | | | |\n", + "| **7** | | | | |\n", + "| **8** | | | | |\n", + "| **9** | | | | |\n", + "| **10** | | | | |\n", + "| **11** | | | | |\n", + "| **12** | | | | |\n", + "| **13** | | | | |\n", + "| **14** | | | | |\n", + "| **15** | | | | |\n", + "| **16** | | | | |\n", + "| **17** | | | | |\n", + "\n", + "마. 작성 서식 4 - 부대 특성별 환경 변수 보강표\n", + "\n", + "| **환경 변수** | **야전 진지** | **기지(주둔지)** | **의무시설** |\n", + "| --- | --- | --- | --- |\n", + "| **관측 시야(차폐물)** | ( ) | ( ) | ( ) |\n", + "| **대피·차폐 장소** | ( ) | ( ) | ( ) |\n", + "| **통신 수단** | ( ) | ( ) | ( ) |\n", + "| **경보 전파 대상** | ( ) | ( ) | ( ) |\n", + "| **야간 조명 조건** | ( ) | ( ) | ( ) |\n", + "| **특별 고려 사항(환자·민간인)** | ( ) | ( ) | ( ) |\n", + "\n", + "바. AI 제안 검토 기록\n", + "\n", + "| **AI가 제안한 항목** | **채택 여부** | **채택·미채택 이유** |\n", + "| --- | --- | --- |\n", + "| **제안 1** | | |\n", + "| **제안 2** | | |\n", + "| **제안 3** | | |\n", + "| **제안 4** | | |\n", + "\n", + "| **점검 항목** | **확인** |\n", + "| --- | --- |\n", + "| 사례 카드 6건을 침입 유형으로 모두 분류했다 | ( ) |\n", + "| 7단계 모두 역할 분담 매트릭스 빈칸이 없다 | ( ) |\n", + "| 단계마다 '책'이 1명만 지정되었다 | ( ) |\n", + "| 현장 체크 항목이 15개 이상이며 모두 눈으로 확인 가능한 문장이다 | ( ) |\n", + "| 환경 변수표 3개 부대 유형을 모두 채웠다 | ( ) |\n", + "| AI 제안의 채택 여부와 이유를 기록했다 | ( ) |\n", + "| 실제 부대명·시설 배치·좌표를 쓰지 않았다 | ( ) |\n", + "\n", + "**\n", + "\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 02. 드론 침입 경보 발령 시 시설별 방호 단계 및 부서별 행동 요령 체크리스트 작성\n", + "\n", + "### 📋 미션 배경 및 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "드론 침입 경보 발령 시 시설별 방호 단계 및 부서별 조치 체크리스트를 작성한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **경보 발령 시 시설별 방호 단계와 부서별 행동 요령을 단계마다 적은 체크리스트와 SOP 흐름도를 작성한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **M1 매트릭스를 바탕으로 경계-경보-비상 3개 방호 단계를 정하고, 부서별 행동을 '신호(무엇을 보거나 들으면) - 행동 - 보고' 형식으로 적는다 과정을 진행하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 5개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `sop/sec08_field_checklist.md 및 SOP 흐름도` 형태의 결과물을 완성한다.\n", + "\n", + "### 📑 실습자료 및 작성 양식\n", + "드론 침입 경보 발령 시 시설별 방호 단계 및 부서별 행동 요령 체크리스트 작성**\n", + "\n", + "가. 제공 자료 - 필수 안전/보안 조치 8개 [교육용가상자료]\n", + "\n", + "| **번호** | **필수 조치** | **배치할 단계(작성)** |\n", + "| --- | --- | --- |\n", + "| **S1** | 경보 수신 즉시 지정된 대피·차폐 위치로 인원을 이동시키고 인원을 확인한다(긴급 모드). | ( ) |\n", + "| **S2** | 대응 수단은 지휘관 승인 뒤에만 쓴다. 훈련에서는 구두 선언으로 대체한다. | ( ) |\n", + "| **S3** | 대응 전 방향에 아군·민간인·인접 통신 영향이 없는지 확인 발언을 한다. | ( ) |\n", + "| **S4** | 무전·방송은 짧게 하고 평문으로 시설 배치나 위치를 말하지 않는다. | ( ) |\n", + "| **S5** | 의심 물체와 잔해는 만지거나 옮기지 않고 접근을 막은 뒤 즉시 보고한다. | ( ) |\n", + "| **S6** | 부상자가 생기면 즉시 보고하고 응급처치·의무 인원에 연계한다. | ( ) |\n", + "| **S7** | 해제 명령 전에는 임의로 원위치로 복귀하지 않는다. | ( ) |\n", + "| **S8** | 경보 중 개인 휴대전화 사용·촬영을 금지하고 모든 보고·조치 시각을 일지에 적는다. | ( ) |\n", + "\n", + "나. 제공 자료 - 방호 단계 3단계(가상 기준 초안) 및 모호 표현 바꾸기 예시\n", + "\n", + "| **방호 단계** | **발령 조건(가상)** | **핵심 조치(작성 보완)** |\n", + "| --- | --- | --- |\n", + "| **1단계 경계** | 소리만 들림 또는 접근 의심(확인 요망) | ( ) |\n", + "| **2단계 경보** | 2명 이상 확인 또는 접근 대상 구역 특정 | ( ) |\n", + "| **3단계 비상** | 급접근·투하 의심·자폭 의심 | ( ) |\n", + "\n", + "| **모호한 표현** | **구체적 기준(예시)** |\n", + "| --- | --- |\n", + "| **신속히 전파한다** | 접수 후 30초 이내에 시설반과 대응조에 전파한다 |\n", + "| **안전한 곳으로 대피시킨다** | 구역별 지정 대피 위치 ○번(가상)으로 이동 후 점호한다 |\n", + "| **필요시 대응 수단을 쓴다** | 지휘관 '승인' 발언이 기록된 뒤에만 구두로 사용 선언한다 |\n", + "| **시설을 점검한다** | 경보 해제 후 지정 점검자가 구역 체크표로 확인하고 통제실에 보고한다 |\n", + "\n", + "다. 작성 서식 1 - 부서별 표준 행동 지침(SOP) 표 (신호 - 행동 - 보고)\n", + "\n", + "| **단계** | **신호(무엇을 보거나 들으면)** | **경계조** | **방호통제실** | **대응조** | **시설관리반** | **안전 조치(S번호)** |\n", + "| --- | --- | --- | --- | --- | --- | --- |\n", + "| **① 접수** | | | | | | |\n", + "| **② 식별** | | | | | | |\n", + "| **③ 경보 발령** | | | | | | |\n", + "| **④ 전파** | | | | | | |\n", + "| **⑤ 방호 조치** | | | | | | |\n", + "| **⑥ 대응·통제** | | | | | | |\n", + "| **⑦ 해제·인계** | | | | | | |\n", + "\n", + "라. 작성 서식 2 - SOP 흐름도 틀 (빈칸을 채워 완성하고, 긴급/일반 분기를 표시한다)\n", + "\n", + "| [① 접수: 경계조 보고 또는 감시 신호]\n", + " \\|\n", + " < 즉시 위협인가? (급접근·투하 의심·다수) >\n", + " \\|예 \\|아니오\n", + " [긴급 모드: 3단계 비상] [일반 모드: 1~2단계]\n", + " 전원 ( ) 경계조 ( )\n", + " 통제실 ( ) 통제실 복창·교차 확인 ( )\n", + " \\| \\|\n", + " +------------+---------------+\n", + " \\|\n", + " [⑤ 방호 조치: ( )]\n", + " \\|\n", + " [⑥ 대응·통제: 승인 → ( )]\n", + " \\|\n", + " [⑦ 해제·인계: 해제 명령 → 인원 확인 → ( )] |\n", + "| --- |\n", + "\n", + "마. 작성 서식 3 - 휴대용 요약 카드(Pocket Guide, 명함 2장 크기로 줄여 인쇄)\n", + "\n", + "| **구분** | **경계조** | **방호통제실** | **대응조·시설관리반** |\n", + "| --- | --- | --- | --- |\n", + "| **긴급 모드 첫 행동** | ( ) | ( ) | ( ) |\n", + "| **일반 모드 첫 행동** | ( ) | ( ) | ( ) |\n", + "| **반드시 말할 것** | ( ) | ( ) | ( ) |\n", + "| **절대 하지 말 것** | ( ) | ( ) | ( ) |\n", + "\n", + "바. 교차 검토 기록표 (다른 조가 작성)\n", + "\n", + "| **번호** | **지적 위치** | **지적 내용(누락·모호)** | **수정 결과** | **확인** |\n", + "| --- | --- | --- | --- | --- |\n", + "| **1** | | | | |\n", + "| **2** | | | | |\n", + "| **3** | | | | |\n", + "| **4** | | | | |\n", + "| **5** | | | | |\n", + "\n", + "| **점검 항목** | **확인** |\n", + "| --- | --- |\n", + "| 7단계 모두 부서별 행동이 적혔다(빈칸 0) | ( ) |\n", + "| 필수 안전/보안 조치 S1~S8이 모두 배치되었다 | ( ) |\n", + "| 방호 3단계의 발령 조건과 핵심 조치를 채웠다 | ( ) |\n", + "| 대응 수단은 승인 이후에만 나온다 | ( ) |\n", + "| 긴급/일반 모드가 흐름도에서 구분된다 | ( ) |\n", + "| 교차 검토 지적 사항을 모두 수정했다 | ( ) |\n", + "| 요약 카드가 한 장 안에 들어간다 | ( ) |\n", + "\n", + "**\n", + "\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 03. 경계조, 대공포대, 방호통제실 간 신속 전파 및 역할 분담 체계 수립\n", + "\n", + "### 📋 미션 배경 및 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "경계조, 대공사격조, 방호통제실 간 신속 전파 및 역할 분담 체계를 수립한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **경계조, 대공사격조(대응조), 방호통제실 간 전파 시간 기준과 역할 분담 체계를 정하고 이를 시험할 상황 주입 시나리오(MSEL)를 만든다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **경계조 1차 보고, 통제실 접수·복창, 대응조·시설관리반 전파로 이어지는 3자 전파 체계를 구간별 시간 기준(예: 보고 30초, 전파 30초)과 함께 정하고 7항목 보고 템플릿과 교육용 가상 약어표 작성하여 원인을 분석하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 5개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `scenarios/sec08_msel_scenario.json(md)` 형태의 결과물을 완성한다.\n", + "\n", + "### 📑 실습자료 및 작성 양식\n", + "경계조, 대공포대, 방호통제실 간 신속 전파 및 역할 분담 체계 수립**\n", + "\n", + "가. 제공 자료 - 3자 전파 체계 구간별 시간 기준(초안) [교육용가상자료]\n", + "\n", + "| **구간** | **누가 → 누구에게** | **전달 내용** | **시간 기준(초안)** | **보완(작성)** |\n", + "| --- | --- | --- | --- | --- |\n", + "| **①** | 경계조 → 방호통제실 | 7항목 1차 보고 | 탐지 후 30초 | ( ) |\n", + "| **②** | 방호통제실 내부 | 복창·교차 확인·방호 단계 결정 | 접수 후 20초 | ( ) |\n", + "| **③** | 방호통제실 → 대응조·시설관리반 | 경보 발령, 대피·대기 지시 | 결정 후 30초 | ( ) |\n", + "| **④** | 대응조 → 방호통제실 | 대응 준비 완료·승인 요청 | 전파 후 60초 | ( ) |\n", + "| **⑤** | 방호통제실 → 지휘관 | 상황 보고와 승인 요청 | 접수 후 60초 | ( ) |\n", + "\n", + "나. 제공 자료 - 시설 드론 보고 7항목 기준(초안)\n", + "\n", + "| **항목** | **표현 방법** | **예시** | **보완 의견(작성)** |\n", + "| --- | --- | --- | --- |\n", + "| **방위** | 경계조 위치에서 시계 방향 또는 구역명 | B구역 방향 | ( ) |\n", + "| **고도** | 건물·지형 기준 상대 표현(지붕 높이 / 지붕 위 / 높음) | 지붕 위 | ( ) |\n", + "| **수량** | 기체 수, 불확실하면 '최소 ○기' | 최소 2기 | ( ) |\n", + "| **행동** | 선회 / 접근 / 이탈 / 낙하 의심 / 급접근 | 접근 중 | ( ) |\n", + "| **접근 대상** | 가상 구역명(A~E) | C구역 | ( ) |\n", + "| **시각** | 최초 탐지 시각(시·분) | 22시 10분 | ( ) |\n", + "| **보고자** | 가상 호출명 | 경계1 | ( ) |\n", + "\n", + "다. 제공 자료 - 교육용 가상 약어표 (실제 음어·호출부호 사용 금지)\n", + "\n", + "| **약어(가상)** | **뜻** | **사용 예** | **수정·추가(작성)** |\n", + "| --- | --- | --- | --- |\n", + "| **침경** | 침입 경보(위협 확인) | 통제실, 침경, B구역 지붕 위 1기 접근 | ( ) |\n", + "| **확요** | 확인 요망(위협 미확인) | 통제실, 확요, 소리만 동쪽 | ( ) |\n", + "| **복창** | 받은 내용을 그대로 되풀이 | 통제실, 복창, B구역 1기 접근 | ( ) |\n", + "| **대피** | 지정 대피 위치로 이동 | 시설반, 전 구역 대피 | ( ) |\n", + "| **승인요** | 대응 승인 요청 | 통제실, 승인요, 대응조 준비 완료 | ( ) |\n", + "| | | | |\n", + "| | | | |\n", + "\n", + "| 경계1, 침경. B구역 방향, 지붕 위, 최소 1기, 접근 중, B구역, 22시 10분. 이상.\n", + "통제실, 복창. B구역 지붕 위 1기 접근. 시설반 전 구역 대피. 대응조 대기. 경계1 계속 추적 보고. |\n", + "| --- |\n", + "\n", + "라. 작성 서식 1 - 상황 주입 시나리오(MSEL) (10건 이상, 정상:비정상 = 6:4)\n", + "\n", + "| **번호** | **가상 시각** | **주입 수단** | **사건 내용(주입 메시지 문안)** | **구분** | **기대 행동** | **담당** |\n", + "| --- | --- | --- | --- | --- | --- | --- |\n", + "| **1** | 22:08 | 구두 | 경계1 방향에서 멀리 윙윙 소리 (예시) | 정상 | 확요 보고 → 통제실 복창 (예시) | 경계조 |\n", + "| **2** | 22:11 | 카드 | B구역 지붕 위 회전익 1기 접근 (예시) | 정상 | 침경 7항목 보고 → 경보 발령 (예시) | 통제실 |\n", + "| **3** | 22:16 | 카드 | 정문 쪽 새 떼 이동 (예시) | 비정상 | 교차 확인 후 오경보 판정 (예시) | 통제실 |\n", + "| **4** | | | | | | |\n", + "| **5** | | | | | | |\n", + "| **6** | | | | | | |\n", + "| **7** | | | | | | |\n", + "| **8** | | | | | | |\n", + "| **9** | | | | | | |\n", + "| **10** | | | | | | |\n", + "| **11** | | | | | | |\n", + "| **12** | | | | | | |\n", + "\n", + "| **구성 비율 계산** | 정상 ( )건 : 비정상 ( )건 = ( ) : ( ) → 6:4 충족 여부 ( ) |\n", + "| --- | --- |\n", + "\n", + "| **돌발 변수** | 통신 두절 ( )번 / 센서 오류 ( )번 / 부상 발생 ( )번 → 1건 이상 포함 ( ) |\n", + "| --- | --- |\n", + "\n", + "마. 워크스루(소리 내어 따라가기) 점검표\n", + "\n", + "| **점검 질문** | **문제 발견 사건 번호** | **수정 내용** |\n", + "| --- | --- | --- |\n", + "| **사건 순서와 간격이 현실적인가(너무 몰리거나 비지 않는가)?** | | |\n", + "| **모든 사건에서 7항목 보고가 가능한가(빠지는 항목은?)** | | |\n", + "| **비정상 사건이 너무 쉽게 드러나지 않는가?** | | |\n", + "| **구간별 시간 기준을 지키기 어려운 사건은 없는가?** | | |\n", + "\n", + "| **점검 항목** | **확인** |\n", + "| --- | --- |\n", + "| 3자 전파 체계 5개 구간의 시간 기준이 모두 정해졌다 | ( ) |\n", + "| 보고 템플릿에 7항목이 모두 있다 | ( ) |\n", + "| 약어는 가상 표현만 사용했다 | ( ) |\n", + "| 주입 사건이 10건 이상이다 | ( ) |\n", + "| 정상/비정상 비율이 6:4다 | ( ) |\n", + "| 돌발 변수가 1건 이상 포함되었다 | ( ) |\n", + "| 워크스루 지적 사항을 모두 수정했다 | ( ) |\n", + "\n", + "**\n", + "\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 04. 모의 드론 침입 경보 발령 및 초동 대응조 현장 출동(Dry-run) 실습\n", + "\n", + "### 📋 미션 배경 및 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "모의 드론 침입 경보 발령에 따른 초동 조치 및 현장 출동(Dry-run) 실습을 수행한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **가상 드론 침입 경보 발령 상황에서 부서별 초동 조치와 현장 출동 절차를 1차 모의훈련(Dry-run)으로 실행하고 기준 성과를 측정한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **4~6명 조가 경계조·방호통제실·대응조·시설관리반 역할을 맡고 교관(상황 통제관)이 M3 주입 카드를 순서대로 제시 수행하고 검증하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 5개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `logs/sec08_dryrun_execution_log.md 및 작성된 체크리스트` 형태의 결과물을 완성한다.\n", + "\n", + "### 📑 실습자료 및 작성 양식\n", + "모의 드론 침입 경보 발령 및 초동 대응조 현장 출동(Dry-run) 실습**\n", + "\n", + "가. 제공 자료 - 1차 모의훈련 진행 시간표(권장)\n", + "\n", + "| **시간** | **활동** | **담당** |\n", + "| --- | --- | --- |\n", + "| **0~10분** | 역할 카드 배부, 안전 브리핑, 초시계 동기화 | 교관 |\n", + "| **10~15분** | SOP·요약 카드 개인 숙지 | 전원 |\n", + "| **15~45분** | 주입 카드 1~10번 순서 진행(1차 Dry-run) | 교관·전원 |\n", + "| **45~60분** | 기록표 정리, 준수율·지연시간 계산 | 기록자·평가관 |\n", + "| **60~75분** | 병목·혼선 구간 토의 | 전원 |\n", + "\n", + "나. 작성 서식 1 - 시각 기록표 (기록자, 초 단위)\n", + "\n", + "| **카드** | **최초 탐지** | **통제실 접수** | **경보 발령** | **전파 완료** | **방호 완료** | **보고 지연(초)\n", + "=접수-탐지** | **조치 시간(초)\n", + "=방호-탐지** |\n", + "| --- | --- | --- | --- | --- | --- | --- | --- |\n", + "| **예시** | 22:10:05 | 22:10:31 | 22:10:50 | 22:11:20 | 22:12:40 | 26 | 155 |\n", + "| **1** | | | | | | | |\n", + "| **2** | | | | | | | |\n", + "| **3** | | | | | | | |\n", + "| **4** | | | | | | | |\n", + "| **5** | | | | | | | |\n", + "| **6** | | | | | | | |\n", + "| **7** | | | | | | | |\n", + "| **8** | | | | | | | |\n", + "| **9** | | | | | | | |\n", + "| **10** | | | | | | | |\n", + "\n", + "다. 작성 서식 2 - SOP 단계 수행 체크(평가관)\n", + "\n", + "| **카드** | **① 접수** | **② 식별** | **③ 경보 발령** | **④ 전파** | **⑤ 방호 조치** | **⑥ 대응·통제** | **⑦ 해제·인계** | **수행/요구** |\n", + "| --- | --- | --- | --- | --- | --- | --- | --- | --- |\n", + "| **1** | | | | | | | | |\n", + "| **2** | | | | | | | | |\n", + "| **3** | | | | | | | | |\n", + "| **4** | | | | | | | | |\n", + "| **5** | | | | | | | | |\n", + "| **6** | | | | | | | | |\n", + "| **7** | | | | | | | | |\n", + "| **8** | | | | | | | | |\n", + "| **9** | | | | | | | | |\n", + "| **10** | | | | | | | | |\n", + "\n", + "| **SOP 준수율** | 수행한 단계 합계 ( ) ÷ 요구된 단계 합계 ( ) × 100 = ( )% → 목표 80% 이상 ( ) |\n", + "| --- | --- |\n", + "\n", + "| **평균 보고 지연** | 보고 지연 합계 ( )초 ÷ 건수 ( ) = ( )초 → 목표 180초(3분) 이내 ( ) |\n", + "| --- | --- |\n", + "\n", + "라. 작성 서식 3 - 참여자별 수행 기록지(각자 작성)\n", + "\n", + "| **역할** | **잘한 점** | **막힌 점(카드 번호)** | **SOP에 없어서 헷갈린 것** |\n", + "| --- | --- | --- | --- |\n", + "| **경계조** | | | |\n", + "| **방호통제실** | | | |\n", + "| **대응조** | | | |\n", + "| **시설관리반** | | | |\n", + "\n", + "마. 작성 서식 4 - 병목·혼선 구간 기록\n", + "\n", + "| **번호** | **발생 단계·카드** | **현상** | **추정 원인** | **M8로 넘길 질문** |\n", + "| --- | --- | --- | --- | --- |\n", + "| **1** | | | | |\n", + "| **2** | | | | |\n", + "| **3** | | | | |\n", + "\n", + "| **점검 항목** | **확인** |\n", + "| --- | --- |\n", + "| 모든 카드의 시각이 초 단위로 기록되었다 | ( ) |\n", + "| SOP 준수율을 계산식대로 계산했다 | ( ) |\n", + "| 평균 보고 지연을 계산했다 | ( ) |\n", + "| 참여자 전원의 수행 기록지를 수합했다 | ( ) |\n", + "| 병목·혼선 구간을 2건 이상 적었다 | ( ) |\n", + "| 훈련 중 안전 위반이 없었다 | ( ) |\n", + "\n", + "**\n", + "\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 05. 재밍건 운용, 대피 유도, 지휘소 방호 조치 중 발생 가능한 오류 상황 식별\n", + "\n", + "### 📋 미션 배경 및 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "재밍건 운용 및 대피 유도 과정에서 발생 가능한 혼선 요인을 식별하고 교차 점검한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **대응 수단 사용 승인, 대피 유도, 지휘소 방호 조치 과정에서 생길 수 있는 혼선 요인을 오류 주입 카드로 식별하고 교차 확인으로 걸러낸다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **교관이 실제 위험 카드와 오류 카드(방위 반대 보고, 승인 기록 없는 대응 수단 사용 보고, 대피 장소 오지시, 지휘소 방호 미완료 보고, 아군 드론·민간 비행체·조류 오인, 송신자 불명 보고 등) 12장을 섞어 제시 수행하고 검증하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 5개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `docs/sec08_cross_verification_sheet.md` 형태의 결과물을 완성한다.\n", + "\n", + "### 📑 실습자료 및 작성 양식\n", + "재밍건 운용, 대피 유도, 지휘소 방호 조치 중 발생 가능한 오류 상황 식별**\n", + "\n", + "가. 제공 자료 - 교차 확인 규칙(초안, 조별로 보완)\n", + "\n", + "| **규칙** | **내용** |\n", + "| --- | --- |\n", + "| **R1 복창** | 통제실은 받은 보고의 방위·수량을 복창하고 보고자가 '맞음/틀림'으로 답한다. |\n", + "| **R2 2차 관측** | 가능하면 다른 관측자에게 같은 방향을 확인시킨다. |\n", + "| **R3 시각 대조** | 보고 시각과 수신 시각이 1분 이상 차이 나면 지연 정보로 표시한다. |\n", + "| **R4 승인 확인** | 대응 수단 사용 보고는 일지의 지휘관 승인 기록과 영향 확인 발언을 대조한다. |\n", + "| **R5 송신자 확인** | 송신자를 알 수 없는 보고는 되물어 확인한다. |\n", + "| **R6 안전 우선** | 확인 중이라도 급강하·투하 의심 신호가 있으면 먼저 대피를 전파한다. |\n", + "\n", + "나. 제공 자료 - 오류 주입 카드 12장 [교육용가상자료] (교관이 순서를 섞어 제시)\n", + "\n", + "| **카드** | **들어온 보고** | **추가로 확인되는 정보** |\n", + "| --- | --- | --- |\n", + "| **E1** | 경계1: B구역 방향 지붕 위 회전익 1기 접근 | 경계2도 같은 방향에서 같은 기체 확인 |\n", + "| **E2** | 경계2: 동쪽 드론 1기 (실제는 서쪽) | 경계1이 서쪽에서 소리 청취, 전화 잡음 있음 |\n", + "| **E3** | 경계1: 높은 곳 일정 속도 비행체, 소리 약함 | 일정한 직선 궤적, 깜박이는 불빛 |\n", + "| **E4** | 시설반: C구역 공터에 작은 물체 낙하 소리, 상자 모양 물체가 보임 | 아무도 접근하지 않음. 낙하 직전 상공에서 드론 소리가 있었다는 진술 1건 |\n", + "| **E5** | 대응조: 11시 방향 드론 1기에 대응 수단 사용 준비를 마쳤다고 보고 | 통제실 일지에 지휘관 승인 기록 없음, 인접 민간 통신 영향 확인 발언 없음 |\n", + "| **E6** | 통제실: B구역 지휘통제동 방호 조치 완료 보고 | 경계2가 보니 일부 인원이 야외 이동 중, 출입구가 열려 있음 |\n", + "| **E7** | 경계1: 서쪽 드론 2기 접근 | 경계2는 1기만 확인, 이후 2기째 확인 |\n", + "| **E8** | 시설반: D구역 인원을 정문 광장으로 대피 유도 중 | 드론 접근 방향이 정문 쪽, 지정 대피 위치는 별도로 존재 |\n", + "| **E9** | 경계2: 남쪽 회전익 1기 저고도 선회 | 시설점검팀의 아군 드론 비행 일정이 같은 시각에 있으나 통제실에 통보되지 않음 |\n", + "| **E10** | 경계1: 동쪽 회전익 1기 접근 (4분 전 E1과 같은 내용이 늦게 도착) | 전화 지연, 시각 표기가 4분 전 |\n", + "| **E11** | 경계2: 북쪽 급강하 기체, 소리 급증 | 경계1 확인 불가(시야 차단), 시설반이 소리 확인 |\n", + "| **E12** | 전화: '지휘통제동 상공 드론 다수' (송신자 불명) | 경계조·시설반 누구도 송신하지 않음, 해당 방향은 조용함 |\n", + "\n", + "다. 작성 서식 1 - 교차검증 매트릭스\n", + "\n", + "| **카드** | **적용 규칙(R번호)** | **판정\n", + "(실제 위험/오경보/확인필요)** | **근거(관측 사실)** | **조치** |\n", + "| --- | --- | --- | --- | --- |\n", + "| **E1** | | | | |\n", + "| **E2** | | | | |\n", + "| **E3** | | | | |\n", + "| **E4** | | | | |\n", + "| **E5** | | | | |\n", + "| **E6** | | | | |\n", + "| **E7** | | | | |\n", + "| **E8** | | | | |\n", + "| **E9** | | | | |\n", + "| **E10** | | | | |\n", + "| **E11** | | | | |\n", + "| **E12** | | | | |\n", + "\n", + "| **식별 성공률** | 정답과 일치한 카드 ( ) ÷ 전체 12장 × 100 = ( )% → 목표 85% 이상 ( ) |\n", + "| --- | --- |\n", + "\n", + "| **실제 위험 놓침** | 실제 위험을 이상 없음으로 판정한 건수 ( )건 → 목표 0건 ( ) |\n", + "| --- | --- |\n", + "\n", + "라. 작성 서식 2 - 불일치 시 재확인·보고 경로(우선순위 규칙)\n", + "\n", + "| **상황** | **1순위 확인** | **2순위 확인** | **상급 보고 여부·시점** |\n", + "| --- | --- | --- | --- |\n", + "| **두 관측자의 방위가 다름** | | | |\n", + "| **보고가 늦게 도착(1분 이상)** | | | |\n", + "| **송신자 불명 보고** | | | |\n", + "| **승인 기록 없는 대응 보고** | | | |\n", + "\n", + "마. 교관용 정답표 (훈련 후 공개)\n", + "\n", + "| **카드** | **정답** | **핵심 근거** |\n", + "| --- | --- | --- |\n", + "| **E1** | 실제 위험 | 두 관측자 방위 일치 |\n", + "| **E2** | 실제 위험(방위 정정 서쪽) | 잡음, 다른 관측자 서쪽 청취 → 복창으로 정정 |\n", + "| **E3** | 오경보 | 높은 고도·일정 궤적·깜박이는 불빛 → 민간 항공기 추정 |\n", + "| **E4** | 실제 위험(투하 의심) | 낙하 소리와 드론 소리 진술 → 확인 전까지 위험으로 취급, 접근 금지·격리·EOD 보고 |\n", + "| **E5** | 실제 위험(절차 오류) | 승인 기록·영향 확인 없음 → 사용 보류, 지휘관 승인 요청 먼저 |\n", + "| **E6** | 실제 위험(조치 미완료) | 보고와 관찰 불일치 → 재확인, 출입구 통제·인원 실내 이동 지시 |\n", + "| **E7** | 실제 위험(2기) | 추가 확인으로 2기 확정 |\n", + "| **E8** | 실제 위험(지시 정정) | 접근 방향 쪽으로 대피 중 → 지정 대피 위치로 즉시 정정 |\n", + "| **E9** | 오경보(아군 드론) | 일정표와 일치. 통보 누락은 개선 사항으로 AAR 안건 |\n", + "| **E10** | 오경보(지연 중복) | 4분 전 E1 정보의 지연 도착 |\n", + "| **E11** | 실제 위험(긴급) | 급강하·소리 급증 → R6 안전 우선 대피 먼저 |\n", + "| **E12** | 확인필요→오경보 | 송신자 불명, 현장 무신호 → 되물어 확인 후 기만 가능성 보고 |\n", + "\n", + "| 판정 점수 기준: '확인필요'로 두고 이후 확인을 거쳐 정답에 도달했으면 정답으로 본다. 정답표에서 '실제 위험'으로 표시된 카드(E1, E2, E4, E5, E6, E7, E8, E11)를 이상 없음으로 버리면 성공률과 관계없이 FAIL이다. |\n", + "| --- |\n", + "\n", + "| **점검 항목** | **확인** |\n", + "| --- | --- |\n", + "| 12장 모두 판정과 근거를 적었다 | ( ) |\n", + "| 근거는 관측 사실로 적었다 | ( ) |\n", + "| 식별 성공률과 놓친 건수를 계산했다 | ( ) |\n", + "| 불일치 시 보고 경로 상황을 모두 채웠다 | ( ) |\n", + "| 의심 신호가 있어도 안전 조치를 먼저 전파했다 | ( ) |\n", + "\n", + "**\n", + "\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 06. 드론 무력화 후 잔해물 수거 및 폭발물 위험(EOD) 인계 절차 실습\n", + "\n", + "### 📋 미션 배경 및 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "드론 무력화 후 잔해물 수거 및 폭발물 처리반(EOD) 인계 절차를 실습한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **드론 무력화 후 발견된 잔해물을 만지지 않고 격리·통제·보고한 뒤 폭발물 처리반(EOD)에 빠짐없이 인계하는 절차를 실습한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **실습자료의 가상 잔해 발견 상황 카드(외관 설명만 제공)를 읽고 접근 금지선 설정, 인원 대피, 접근자 이력 확인, 통신기기 사용 제한 거리 준수 같은 초기 통제 항목을 점검 수행하고 검증하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 5개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `reports/sec08_handover_protocol.md 및 브리핑 녹취/기록지` 형태의 결과물을 완성한다.\n", + "\n", + "### 📑 실습자료 및 작성 양식\n", + "드론 무력화 후 잔해물 수거 및 폭발물 위험(EOD) 인계 절차 실습**\n", + "\n", + "| 안전 원칙: 이 미션에서 잔해물 수거·이동·분해는 하지 않는다. 폭발물 처리반(EOD)이 도착하기 전까지 모든 인원은 접근 금지선 밖에서 격리·통제·보고만 한다. 잔해는 카드의 글 설명으로만 제공되고 실습 현장에 어떤 물체도 두지 않는다. |\n", + "| --- |\n", + "\n", + "가. 제공 자료 - 가상 잔해 발견 상황 카드 [교육용가상자료]\n", + "\n", + "| 가상 22:40 현재: C구역 정비창 남측 공터에 드론 추정 잔해가 발견됨(발견 22:32). 외관은 회전 날개 일부가 부서져 있고 하부에 상자 모양 물체가 붙어 있는 것이 멀리서 보임(만지지 않음). 경계조 1명이 가까이 다가갔다가 22:34에 물러남(접근 거리 약 5m로 진술). 현재 접근 금지선 설정 완료, C구역과 D구역 인원 대피 완료, 부상자 없음. 냄새·연기·소리 이상은 없음. 통제실이 22:36에 지휘관과 EOD에 보고 완료, EOD 도착 예정 22:50. 현장 인근 휴대전화·무전 사용 제한 거리는 교관이 가상으로 정함. |\n", + "| --- |\n", + "\n", + "나. 작성 서식 1 - 초기 통제 점검표 (하지 말 것 포함)\n", + "\n", + "| **번호** | **점검 항목** | **확인(O/X)** | **근거·시각** |\n", + "| --- | --- | --- | --- |\n", + "| **1** | 접근 금지선이 설정되었다 | ( ) | |\n", + "| **2** | 모든 인원이 금지선 밖에 있다 | ( ) | |\n", + "| **3** | 잔해를 만지거나 옮긴 인원이 없음을 확인했다 | ( ) | |\n", + "| **4** | 접근했던 인원의 이름·거리·시각이 기록되었다 | ( ) | |\n", + "| **5** | 인근 구역 인원이 대피했다 | ( ) | |\n", + "| **6** | 현장 인근 휴대전화·무전 사용 제한 거리를 전파했다 | ( ) | |\n", + "| **7** | 촬영을 위해 접근하는 인원이 없다 | ( ) | |\n", + "| **8** | EOD 도착 경로와 안내자가 정해졌다 | ( ) | |\n", + "\n", + "| **하지 말 것(전원)** | **이유(작성)** |\n", + "| --- | --- |\n", + "| **잔해를 만지기·옮기기·덮기·분해하기** | ( ) |\n", + "| **가까이 다가가 확인하거나 촬영하기** | ( ) |\n", + "| **일반 인원에게 소문 전파하기** | ( ) |\n", + "\n", + "다. 작성 서식 2 - SBAR 인계 브리핑 템플릿\n", + "\n", + "| **구분** | **들어갈 내용** | **작성** |\n", + "| --- | --- | --- |\n", + "| **S 상황** | 무엇이 어디에 있는가(외관 설명만), 현재 통제 상태 | |\n", + "| **B 배경** | 발견 경위, 접근자 이력, 이미 한 조치와 시각 | |\n", + "| **A 평가** | 위험 판단(확인 불가 → 위험으로 취급), 불확실한 점, 인원 상태 | |\n", + "| **R 요청** | EOD가 지금 해야 할 일, 우리가 계속할 통제 업무 | |\n", + "\n", + "라. 작성 서식 3 - 인계 필수 항목 10개 확인표\n", + "\n", + "| **번호** | **필수 항목** | **전달(O/X)** | **인수자 복창(O/X)** |\n", + "| --- | --- | --- | --- |\n", + "| **1** | 발견 시각과 가상 구역 | ( ) | ( ) |\n", + "| **2** | 잔해의 외관(멀리서 본 사실만) | ( ) | ( ) |\n", + "| **3** | 접근 금지선 설정 시각과 범위 | ( ) | ( ) |\n", + "| **4** | 접근했던 인원과 거리 | ( ) | ( ) |\n", + "| **5** | 대피 완료 구역과 인원 | ( ) | ( ) |\n", + "| **6** | 부상자·이상 징후(냄새·연기·소리) | ( ) | ( ) |\n", + "| **7** | 무전·휴대전화 사용 제한 안내 여부 | ( ) | ( ) |\n", + "| **8** | 상급·지휘관 보고 이력 | ( ) | ( ) |\n", + "| **9** | EOD 안내자와 접근 경로 | ( ) | ( ) |\n", + "| **10** | 인계·인수 시각 | ( ) | ( ) |\n", + "\n", + "| **인계 소요 시간** | 시작 ( : : ) ~ 종료 ( : : ) = ( )분 → 목표 10분 이내 ( ) |\n", + "| --- | --- |\n", + "\n", + "| **누락률** | 누락 항목 수 ( ) ÷ 10 × 100 = ( )% → 목표 0% ( ) |\n", + "| --- | --- |\n", + "\n", + "| **상호 서명** | 인계자 ( ) 인수자 ( ) |\n", + "| --- | --- |\n", + "\n", + "마. 인수자(EOD 역할) 돌발 상황 퀴즈 (교관이 인계 직후 구두로 질문)\n", + "\n", + "| **번호** | **질문** | **정답(교관용)** | **O/X** |\n", + "| --- | --- | --- | --- |\n", + "| **Q1** | 잔해는 어느 구역에 있고 외관에서 확인된 특징은? | C구역 정비창 남측 공터, 날개 일부 파손·하부 상자 모양 물체 | ( ) |\n", + "| **Q2** | 접근했던 인원은 몇 명이고 가장 가까웠던 거리는? | 1명, 약 5m(진술) | ( ) |\n", + "| **Q3** | 대피가 끝난 구역은? | C구역과 D구역 | ( ) |\n", + "| **Q4** | EOD 도착 예정 시각과 안내자는 누구인가? | 22:50, 통제실 지정 안내자 | ( ) |\n", + "| **Q5** | 구경하러 오는 인원이 금지선 가까이 오면 가장 먼저 할 일은? | 접근 중지 지시 후 대피, 통제실 보고 | ( ) |\n", + "\n", + "| **점검 항목** | **확인** |\n", + "| --- | --- |\n", + "| 초기 통제 점검표 8개 항목을 모두 확인했다 | ( ) |\n", + "| '하지 말 것' 3가지의 이유를 적었다 | ( ) |\n", + "| SBAR 4개 칸을 모두 채웠다 | ( ) |\n", + "| 필수 항목 10개 누락 0 | ( ) |\n", + "| 인계를 10분 이내에 마쳤다 | ( ) |\n", + "| 인수자 퀴즈 4문항 이상 정답 | ( ) |\n", + "| 실습 중 누구도 잔해(카드 설명 위치)에 접근하지 않았다 | ( ) |\n", + "| 상호 서명을 받았다 | ( ) |\n", + "\n", + "**\n", + "\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 07. 다수 방향 동시 드론 침입 및 통신 마비 복합 위협 상황 모의 스트레스 훈련\n", + "\n", + "### 📋 미션 배경 및 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "다방향 동시 침입 및 지휘통신 마비 복합 위기 모의 스트레스 훈련을 진행한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **다방향 동시 침입과 지휘통신 마비가 겹친 극한 상황에서도 역할분담과 지휘 위임이 유지되는지 스트레스 훈련으로 확인한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **야간 조명 제한, 3방향 동시 침입, 방호통제실 통신 두절, 경상자 발생(모의), 지휘관 부재 카드를 짧은 간격으로 주입하여 처리 지연 및 복원력을 시험하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 5개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `logs/sec08_stress_drill_results.md` 형태의 결과물을 완성한다.\n", + "\n", + "### 📑 실습자료 및 작성 양식\n", + "다수 방향 동시 드론 침입 및 통신 마비 복합 위협 상황 모의 스트레스 훈련**\n", + "\n", + "가. 제공 자료 - 스트레스 주입 타임라인 [교육용가상자료] (교관이 T 기준으로 제시)\n", + "\n", + "| **시점** | **주입 내용** | **기대 대응** |\n", + "| --- | --- | --- |\n", + "| **T+0:00** | 야간, 조명 제한(손전등 사용 금지 가정) | 야간 관측 위치 확인, 요약 카드 확인 |\n", + "| **T+1:00** | 동쪽에서 윙윙 소리 | 확요 보고, 통제실 복창 |\n", + "| **T+2:00** | 동·서·북 세 방향에서 드론 3기 동시 출현 | 긴급 모드 전환, 경보 발령, 방향별 우선순위 보고 |\n", + "| **T+3:00** | 통제실 전화·무전 두절 | 대체 전파 수단(전령·수신호·방송, 모의)으로 전환 |\n", + "| **T+4:00** | 지휘관 부재(연락 불가) | 위임 순서에 따라 차선임자 지휘 인수 |\n", + "| **T+5:00** | 시설반 1명 넘어짐(모의 경상) | 부상 보고, 응급처치 연계, 인원 확인 |\n", + "| **T+7:00** | 대응조: '대응 수단 사용 준비 완료, 승인 요청' | 승인·영향 확인 절차, 사용 여부 결정과 기록 |\n", + "| **T+9:00** | B구역 지휘통제동 출입구 열림 보고 | 방호 조치 확인, 출입구 통제 |\n", + "| **T+11:00** | C구역에 낙하 의심 물체 보고 | 접근 금지선 설정, 보고, 인계 준비 |\n", + "| **T+13:00** | 통신 복구, 지휘관 복귀 | 차선임자가 지휘관에게 상황 인계(SBAR) |\n", + "| **T+15:00** | 해제 명령 | 인원 확인 후 해제 보고 |\n", + "\n", + "나. 제공 자료 - 위험 조치 10개 판정 기준\n", + "\n", + "| **번호** | **위험 조치** | **성공 기준** |\n", + "| --- | --- | --- |\n", + "| **D1** | 긴급 모드 전환 | 3기 확인 후 1분 이내 경보 발령·전파 |\n", + "| **D2** | 대피·차폐 이동 | 전 구역이 지정 위치로 이동하고 점호(평가관 확인) |\n", + "| **D3** | 방향별 우선순위 보고 | 지휘통제동 접근 방향·급접근 표적부터 보고 |\n", + "| **D4** | 승인·영향 확인 | 승인 기록과 영향 확인 발언 뒤에만 사용 선언 |\n", + "| **D5** | 대체 전파 전환 | 통신 두절 후 1분 이내 대체 수단 사용 |\n", + "| **D6** | 지휘 위임 | 부재 확인 후 1분 이내 차선임자 '지휘 인수' 선언 |\n", + "| **D7** | 부상 보고 | 부상 발생 1분 이내 보고 |\n", + "| **D8** | 방호 조치 확인 | 출입구 통제와 인원 실내 이동 확인 |\n", + "| **D9** | 의심 물체 통제 | 낙하 의심 물체 접근 금지선 설정, 접근 인원 0 |\n", + "| **D10** | 복귀 인계 | SBAR 4요소를 포함해 인계 |\n", + "\n", + "다. 작성 서식 1 - 타임라인 기록표(기록자)\n", + "\n", + "| **시점** | **실제 행동·발언** | **행동 시각** | **걸린 시간(초)** | **관련 D번호** |\n", + "| --- | --- | --- | --- | --- |\n", + "| **T1** | | | | |\n", + "| **T2** | | | | |\n", + "| **T3** | | | | |\n", + "| **T4** | | | | |\n", + "| **T5** | | | | |\n", + "| **T6** | | | | |\n", + "| **T7** | | | | |\n", + "| **T8** | | | | |\n", + "| **T9** | | | | |\n", + "| **T10** | | | | |\n", + "| **T11** | | | | |\n", + "\n", + "라. 작성 서식 2 - 위험 조치 판정표 및 위임 순서\n", + "\n", + "| **D1** | **D2** | **D3** | **D4** | **D5** | **D6** | **D7** | **D8** | **D9** | **D10** |\n", + "| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- |\n", + "| ( ) | ( ) | ( ) | ( ) | ( ) | ( ) | ( ) | ( ) | ( ) | ( ) |\n", + "\n", + "| **위험 조치 성공률** | 성공 ( ) ÷ 10 × 100 = ( )% → 목표 90% 이상 ( ) |\n", + "| --- | --- |\n", + "\n", + "| **비상 절차 전환 시간** | 3기 확인 ( : : ) → 경보 전파 ( : : ) = ( )초 → 목표 60초 이내 ( ) |\n", + "| --- | --- |\n", + "\n", + "| **지휘 위임 순서** | 1순위 지휘관 ( ) → 2순위 ( ) → 3순위 ( ) / 인수 선언 문구: ( ) |\n", + "| --- | --- |\n", + "\n", + "마. 안전 통제 점검(훈련 시작 전, 안전 통제관)\n", + "\n", + "| **안전 점검 항목** | **확인** |\n", + "| --- | --- |\n", + "| 이동 경로에 걸려 넘어질 물체를 치웠다 | ( ) |\n", + "| 야간 조명 제한 구역 밖 대기 인원을 지정했다 | ( ) |\n", + "| '훈련 중지' 3회 신호를 전원이 알고 있다 | ( ) |\n", + "| 모의 부상은 실제 동작 없이 카드로만 표시한다 | ( ) |\n", + "| 낙하 의심 물체는 카드로만 표시하고 현장에 물체를 두지 않았다 | ( ) |\n", + "\n", + "| **점검 항목** | **확인** |\n", + "| --- | --- |\n", + "| 타임라인 11개 시점을 모두 기록했다 | ( ) |\n", + "| 비상 절차 전환 시간을 초 단위로 계산했다 | ( ) |\n", + "| 위험 조치 10개를 모두 판정했다 | ( ) |\n", + "| 위임이 1분 이내에 이루어졌는지 확인했다 | ( ) |\n", + "| 안전 수칙 위반 0건 | ( ) |\n", + "\n", + "**\n", + "\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 08. AAR 회의를 통한 대응 시간 지연 및 보고 체계 혼선 원인 분석\n", + "\n", + "### 📋 미션 배경 및 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "AAR 회의를 통해 대응 지연 및 보고 혼선 원인을 분석한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **사후검토(AAR) 회의로 대응 시간 지연과 보고 체계 혼선의 원인을 사실 중심으로 분석하고 SOP 개선점을 도출한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **M4~M7 기록지와 타임라인을 모아 '계획한 것-실제 일어난 것-차이의 원인-개선할 것' 4단계로 AAR을 진행 수행하고 검증하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 5개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `aar/sec08_aar_session_minutes.md 및 오류 분석 매트릭스` 형태의 결과물을 완성한다.\n", + "\n", + "### 📑 실습자료 및 작성 양식\n", + "AAR 회의를 통한 대응 시간 지연 및 보고 체계 혼선 원인 분석**\n", + "\n", + "가. 제공 자료 - AAR 4단계 진행 순서(권장 60분)\n", + "\n", + "| **단계** | **질문** | **시간** |\n", + "| --- | --- | --- |\n", + "| **1** | 무엇을 하려고 했나(계획·SOP 기준)? | 10분 |\n", + "| **2** | 실제로 무슨 일이 있었나(기록 기준)? | 15분 |\n", + "| **3** | 왜 차이가 났나(원인)? | 20분 |\n", + "| **4** | 다음에는 무엇을 바꿀까(개선)? | 15분 |\n", + "\n", + "나. 제공 자료 - 가상 기록 발췌(팩트체크 연습용) [교육용가상자료]\n", + "\n", + "| **번호** | **출처** | **내용** |\n", + "| --- | --- | --- |\n", + "| **F1** | 경계조 진술 | \"드론을 보자마자 30초 안에 통제실에 보고했다.\" |\n", + "| **F2** | 시각 기록표 | 최초 탐지 22:10:05, 통제실 접수 22:11:55 (110초) |\n", + "| **F3** | 통신 기록 | 22:10:20~22:11:40 사이 통제실 전화가 다른 문의로 통화 중 |\n", + "| **F4** | 전파 기록 | 대응조 전파 22:12:30, 시설반 전파는 22:14:10 (대피 방송 누락 후 재전파) |\n", + "| **F5** | 평가관 기록 | 대응조가 승인 기록 전에 '대응 준비 완료'를 선언 |\n", + "| **F6** | 통제실 진술 | \"대응조가 이미 승인받은 줄 알았다.\" |\n", + "| **F7** | 스트레스 훈련 기록 | 통신 두절 후 대체 전파 수단으로 전환하기까지 85초, 대체 수단이 사전에 정해져 있지 않았음 |\n", + "\n", + "| 팩트체크 예: F1 진술(30초)과 F2 기록(110초)이 다르다. F3을 보면 경계조는 제때 보고하려 했으나 전화가 통화 중이었다. 원인은 '경계조 태만'이 아니라 '보고 채널 우선순위 규칙과 대체 수단 없음(절차)'으로 분류한다. |\n", + "| --- |\n", + "\n", + "다. 작성 서식 1 - 원인 분석 매트릭스\n", + "\n", + "| **문제** | **확인된 사실** | **근거(F번호·기록)** | **원인 분류\n", + "(절차/도구/환경/숙련)** | **근본 원인** | **개선안** |\n", + "| --- | --- | --- | --- | --- | --- |\n", + "| **보고 지연** | | | | | |\n", + "| **시설반 전파 누락** | | | | | |\n", + "| **대응 승인 혼선** | | | | | |\n", + "| **통신 두절 전환 지연** | | | | | |\n", + "| | | | | | |\n", + "| | | | | | |\n", + "\n", + "라. 비난 표현 바꾸기\n", + "\n", + "| **피할 표현** | **바꾼 표현(예시)** |\n", + "| --- | --- |\n", + "| **경계조가 게을러서 늦었다** | 통화 중일 때 긴급 보고를 우선 접수하는 규칙이 없어 보고가 110초 지연되었다 |\n", + "| **통제실이 승인 여부를 놓쳤다** | 승인 기록을 일지에서 확인하는 절차가 SOP에 없어 사용 준비가 먼저 선언되었다 |\n", + "\n", + "마. 작성 서식 2 - 개선점 목록(5건 이상) 및 SOP 1차 수정안\n", + "\n", + "| **번호** | **개선점** | **SOP 현행** | **SOP 수정** | **근거(매트릭스 행)** |\n", + "| --- | --- | --- | --- | --- |\n", + "| **1** | | | | |\n", + "| **2** | | | | |\n", + "| **3** | | | | |\n", + "| **4** | | | | |\n", + "| **5** | | | | |\n", + "| **6** | | | | |\n", + "\n", + "| **점검 항목** | **확인** |\n", + "| --- | --- |\n", + "| 4단계 순서로 AAR을 진행했다 | ( ) |\n", + "| 진술과 기록이 다른 곳을 모두 표시했다 | ( ) |\n", + "| 모든 원인 항목에 근거(F번호·기록)를 연결했다 | ( ) |\n", + "| 개선점이 5건 이상이다 | ( ) |\n", + "| 회의록에 개인 비난 표현이 없다 | ( ) |\n", + "| 개선점이 SOP 수정안에 반영되었다 | ( ) |\n", + "\n", + "**\n", + "\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 09. 개선 사항을 반영한 드론 침입 대응 재훈련(Re-run) 및 대응 시간 단축 검증\n", + "\n", + "### 📋 미션 배경 및 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "개선점을 반영하여 드론 침입 대응 훈련을 재실행하고 대응 시간을 단축한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **AAR 피드백을 반영한 수정 SOP로 같은 조건의 재훈련을 실시하고 조치 시간과 오류율 개선을 수치로 확인한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **M4와 같은 주입 카드 구성(순서만 바꿈)과 같은 평가표로 재훈련을 실시 수행하고 검증하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 5개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `eval/sec08_rerun_performance_comparison.md` 형태의 결과물을 완성한다.\n", + "\n", + "### 📑 실습자료 및 작성 양식\n", + "개선 사항을 반영한 드론 침입 대응 재훈련(Re-run) 및 대응 시간 단축 검증**\n", + "\n", + "가. 제공 자료 - 재훈련 동일 조건 유지표\n", + "\n", + "| **항목** | **1차(M4)** | **재훈련** | **동일 여부** |\n", + "| --- | --- | --- | --- |\n", + "| **주입 카드 구성** | 10건 | 같은 10건(순서만 변경) | ( ) |\n", + "| **역할 편성** | | | ( ) |\n", + "| **평가표** | M4 서식 | M4 서식 | ( ) |\n", + "| **측정 방법** | 초시계·시각 기록표 | | ( ) |\n", + "| **시간대·환경** | | | ( ) |\n", + "\n", + "나. 제공 자료 - 계산 예시 [교육용가상자료]\n", + "\n", + "| 평균 조치 시간: 1차 210초 → 재훈련 140초\n", + "단축률 = (210 - 140) ÷ 210 × 100 = 33.3% → 목표 30% 이상 달성\n", + "\n", + "오류 건수: 1차 12건 → 재훈련 5건\n", + "감소율 = (12 - 5) ÷ 12 × 100 = 58.3% → 목표 50% 이상 달성 |\n", + "| --- |\n", + "\n", + "다. 작성 서식 1 - 전후 성과 비교표\n", + "\n", + "| **지표** | **1차(M4)** | **재훈련** | **변화율(%)** | **목표** | **달성** |\n", + "| --- | --- | --- | --- | --- | --- |\n", + "| **평균 조치 시간(초)** | | | | 30% 이상 단축 | ( ) |\n", + "| **평균 보고 지연(초)** | | | | 참고 | ( ) |\n", + "| **SOP 준수율(%)** | | | | 참고 | ( ) |\n", + "| **오류 건수(누락)** | | | | - | - |\n", + "| **오류 건수(오보고·오판정)** | | | | - | - |\n", + "| **오류 건수(절차 위반)** | | | | - | - |\n", + "| **오류 합계** | | | | 50% 이상 감소 | ( ) |\n", + "\n", + "라. 작성 서식 2 - 미달 항목 원인 및 보수 교육 계획\n", + "\n", + "| **미달 항목** | **원인** | **보완 방법** |\n", + "| --- | --- | --- |\n", + "| **미달 1** | | |\n", + "| **미달 2** | | |\n", + "\n", + "| **보수 교육 주기** | ( )주마다 ( )분, 방법: ( ) |\n", + "| --- | --- |\n", + "\n", + "| **숙련 유지 확인 지표** | ( ) ※ 예: 평균 보고 지연 60초 이내 유지 |\n", + "| --- | --- |\n", + "\n", + "| **점검 항목** | **확인** |\n", + "| --- | --- |\n", + "| 1차와 같은 구성·평가표·측정 방법을 썼다 | ( ) |\n", + "| 조치 시간 단축률을 계산식대로 계산했다 | ( ) |\n", + "| 오류를 유형별로 세고 감소율을 계산했다 | ( ) |\n", + "| 미달 항목의 원인을 적었다 | ( ) |\n", + "| 보수 교육 주기를 제안했다 | ( ) |\n", + "\n", + "**\n", + "\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 10. 군사시설 드론 침입 경보 대응 훈련 표준 지침서(SOP) 최종 완성\n", + "\n", + "### 📋 미션 배경 및 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "군사시설 드론 침입 경보 대응 훈련 표준 지침서(SOP)를 완성한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **군사시설 드론 침입 경보 대응 훈련 표준 지침서(SOP)를 최종 확정하고 다른 부대 교관이 바로 쓸 수 있는 훈련 패키지로 묶는다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **개요-SOP-시나리오-평가 루브릭-AAR 5개 장으로 교안을 구성하고 M2~M9 산출물 가운데 검증된 내용만 반영 수행하고 검증하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 5개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `package/sec08_standard_training_package.md 및 교관 지침서` 형태의 결과물을 완성한다.\n", + "\n", + "### 📑 실습자료 및 작성 양식\n", + "군사시설 드론 침입 경보 대응 훈련 표준 지침서(SOP) 최종 완성**\n", + "\n", + "가. 작성 서식 1 - 훈련 패키지 5개 장 구성표\n", + "\n", + "| **장** | **포함 내용** | **반영할 산출물** | **완성(O/X)** |\n", + "| --- | --- | --- | --- |\n", + "| **1장 개요** | 훈련 목적, 대상, 소요 시간, 가상 기지·가상 상황 | M1 | ( ) |\n", + "| **2장 SOP** | 부서별 표준 행동, 흐름도, 요약 카드, 안전 조치, 방호 3단계 | M2·M8 수정안 | ( ) |\n", + "| **3장 시나리오** | 전파 체계도, MSEL, 오류 주입 카드, 스트레스 타임라인 | M3·M5·M7 | ( ) |\n", + "| **4장 평가 루브릭** | SOP 체크, 시각 기록표, 인계 확인표, 채점표 | M4·M6·M9 | ( ) |\n", + "| **5장 AAR** | AAR 진행 순서, 원인 분석 매트릭스, 개선 이력 | M8·M9 | ( ) |\n", + "\n", + "나. 작성 서식 2 - 교관 지침서\n", + "\n", + "| **준비물** | ( ) |\n", + "| --- | --- |\n", + "\n", + "| **역할 배정** | ( ) |\n", + "| --- | --- |\n", + "\n", + "| **시간표** | ( ) |\n", + "| --- | --- |\n", + "\n", + "| **안전 통제** | ( ) |\n", + "| --- | --- |\n", + "\n", + "| **채점 방법** | ( ) |\n", + "| --- | --- |\n", + "\n", + "| **갱신 주기** | ( ) ※ 연간 교육훈련 계획과 연계 |\n", + "| --- | --- |\n", + "\n", + "다. 작성 서식 3 - 성과 수치 일치 확인\n", + "\n", + "| **교안에 적은 수치** | **교안 값** | **원본 기록(M4·M9)** | **일치(O/X)** |\n", + "| --- | --- | --- | --- |\n", + "| **1차 SOP 준수율** | | | ( ) |\n", + "| **1차 평균 보고 지연** | | | ( ) |\n", + "| **재훈련 조치 시간 단축률** | | | ( ) |\n", + "| **재훈련 오류 감소율** | | | ( ) |\n", + "\n", + "라. 작성 서식 4 - 모의 검토 피드백 기록(다른 조 교관 역할)\n", + "\n", + "| **번호** | **검토 의견** | **반영 여부** | **반영 내용** |\n", + "| --- | --- | --- | --- |\n", + "| **1** | | | |\n", + "| **2** | | | |\n", + "| **3** | | | |\n", + "| **4** | | | |\n", + "| **5** | | | |\n", + "\n", + "| **점검 항목** | **확인** |\n", + "| --- | --- |\n", + "| 5개 장이 모두 있다 | ( ) |\n", + "| 흐름도·요약 카드 등 시각 자료가 들어갔다 | ( ) |\n", + "| 교안 수치가 M4·M9 기록과 일치한다 | ( ) |\n", + "| 모의 검토 피드백 반영 내역을 기록했다 | ( ) |\n", + "| 실제 기지 정보·시설 배치·실제 음어가 없다 | ( ) |\n", + "| 대응 수단은 승인·보고 절차만 서술했고 조작 방법이 없다 | ( ) |\n", + "| 잔해 접근 금지와 EOD 인계 문구가 있다 | ( ) |\n", + "\n", + "**4. 평가 채점표**\n", + "\n", + "미션마다 정량 기준은 PASS/FAIL로, 정성 기준은 상·중·하로 판정한다. 근거가 되는 기록지·서식 번호를 비고에 적는다.\n", + "\n", + "| **미션** | **구분** | **정량 기준(PASS/FAIL)** | **정성 기준(상·중·하)** | **판정** |\n", + "| --- | --- | --- | --- | --- |\n", + "| **M1. 군사시설 대상 드론 침입 유형(정찰/자폭/투하) 분석 및 대응 시나리오 수립** | 필수 | 현장 체크 항목 15개 이상 정의, 7단계 전체에 대한 역할 분담 매트릭스 빈칸 0개(100% 매핑), 단계마다 책임자가 1명으로 지정되고 사례 카드 6건이 침입 유형 3종으로 모두 분류되었는가. | 사례 카드의 문제점이 역할 정의에 반영되어 전술 상황이 현실적이고 실제 부대에 바로 적용할 수 있는가. | PASS / FAIL\n", + "상 / 중 / 하 |\n", + "| **M2. 드론 침입 경보 발령 시 시설별 방호 단계 및 부서별 행동 요령 체크리스트 작성** | 필수 | SOP 7단계 모두에 부서별 세부 절차가 100% 적혔고, 필수 안전/보안 조치 8개가 모두 포함(100%)되었으며, 교차 검토에서 지적된 모호 표현이 모두 수정되었는가. | 긴급 상황에서 한눈에 읽히도록 절차가 명확하고 간결하며, 긴급/일반 모드 구분이 분명한가. | PASS / FAIL\n", + "상 / 중 / 하 |\n", + "| **M3. 경계조, 대공포대, 방호통제실 간 신속 전파 및 역할 분담 체계 수립** | 필수 | 3자 전파 체계의 구간별 시간 기준과 역할이 모두 정해졌고, 주입 사건 10건 이상·정상/비정상 6:4 비율을 지켰으며, 워크스루에서 나온 개연성 문제가 모두 수정되었는가. | 상황 전개가 개연성 있고 훈련 내내 적절한 긴장도가 유지되도록 사건 간격과 난이도가 배치되었는가. | PASS / FAIL\n", + "상 / 중 / 하 |\n", + "| **M4. 모의 드론 침입 경보 발령 및 초동 대응조 현장 출동(Dry-run) 실습** | 선택 | SOP 단계 준수율 80% 이상, 최초 탐지에서 통제실 접수까지 보고 지연시간 3분 이내, 병목·혼선 구간 2건 이상 식별했는가. | 현장 판단이 정확하고 보고가 표준 템플릿 순서대로 이루어졌는가. | PASS / FAIL\n", + "상 / 중 / 하 |\n", + "| **M5. 재밍건 운용, 대피 유도, 지휘소 방호 조치 중 발생 가능한 오류 상황 식별** | 필수 | 주입된 기만·오경보 식별 성공률 85% 이상(12장 중 11장 이상), 실제 위험을 이상 없음으로 잘못 버린 건 0건, 판정마다 교차 확인 근거가 기록되었는가. | 의심 신호를 놓치지 않고 교차 확인 근거가 관측 사실과 절차 기록에 기반해 타당한가. | PASS / FAIL\n", + "상 / 중 / 하 |\n", + "| **M6. 드론 무력화 후 잔해물 수거 및 폭발물 위험(EOD) 인계 절차 실습** | 선택 | 인계 필수 항목 누락률 0%, 인계 소요 시간 10분 이내, 인수자 퀴즈 5문항 중 4문항 이상 정답, 초기 통제 항목 위반(접근·접촉) 0건인가. | 인계 전후로 상황 인식이 끊기지 않고 인수자의 질문에 명확히 답했는가. | PASS / FAIL\n", + "상 / 중 / 하 |\n", + "| **M7. 다수 방향 동시 드론 침입 및 통신 마비 복합 위협 상황 모의 스트레스 훈련** | 필수 | 비상 절차 전환 시간 1분 이내, 위험 조치 성공률 90% 이상, 지휘 공백 시 1분 안에 위임이 이루어지고 안전 수칙 위반 0건인가. | 위기 상황에서 우선순위를 냉정하게 판단하고 부서 간 협업이 유지되었는가. | PASS / FAIL\n", + "상 / 중 / 하 |\n", + "| **M8. AAR 회의를 통한 대응 시간 지연 및 보고 체계 혼선 원인 분석** | 선택 | 개선점 5건 이상, 원인 분석 항목마다 기록지 근거 100% 연결, 회의록에 개인 비난 표현 0건인가. | 비난 없이 사실 중심으로 토의했고 원인 규명이 객관적인가. | PASS / FAIL\n", + "상 / 중 / 하 |\n", + "| **M9. 개선 사항을 반영한 드론 침입 대응 재훈련(Re-run) 및 대응 시간 단축 검증** | 필수 | 1차 대비 평균 조치 시간 30% 이상 단축, 오류 발생 건수 50% 이상 감소, 동일 평가표·동일 측정 방법을 사용했는가. | 행동 절차가 몸에 익어 부서 간 호흡이 맞고 말없이도 다음 행동이 이어지는가. | PASS / FAIL\n", + "상 / 중 / 하 |\n", + "| **M10. 군사시설 드론 침입 경보 대응 훈련 표준 지침서(SOP) 최종 완성** | 필수 | 5개 장 100% 완비, 교안에 적힌 성과 수치가 M4·M9 기록과 일치하고, 모의 검토 피드백 반영 내역이 기록되었는가. | 교재가 완성도 있고 시각 자료가 이해를 돕으며 다른 부대에 전파할 가치가 있는가. | PASS / FAIL\n", + "상 / 중 / 하 |\n", + "\n", + "| 이수 기준(권장): 필수 미션 7개가 모두 PASS이고 정성 판정에 '하'가 2개 이하일 것. 선택 미션(M4·M6·M8)은 수행 시 가점으로 반영하며 세부 배점은 교관이 정한다. |\n", + "| --- |\n", + "\n", + "| **평가관** | 소속·성명: ( ) 서명: ( ) 일자: ( ) |\n", + "| --- | --- |\n", + "\n", + "| **종합 의견** | ( ) |\n", + "| --- | --- |\n", + "\n" + ] + } + ], + "metadata": { + "colab": { + "provenance": [] + }, + "kernelspec": { + "display_name": "Python 3", + "name": "python3" + }, + "language_info": { + "name": "python" + } + }, + "nbformat": 4, + "nbformat_minor": 0 +} \ No newline at end of file diff --git a/uploads/SEC09/SEC09_workbook.ipynb b/uploads/SEC09/SEC09_workbook.ipynb new file mode 100644 index 0000000..caf0626 --- /dev/null +++ b/uploads/SEC09/SEC09_workbook.ipynb @@ -0,0 +1,1123 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# 📋 [SEC09] 대피소·출입구·환기구 방호 현장점검 실습\n", + "### 실습 워크북 & SOP 가이드 (Google Colab / Jupyter)\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/SEC09/SEC09_workbook.ipynb)\n", + "\n", + "---\n", + "\n", + "## 📌 1. 프로젝트 개요 및 배경\n", + "\n", + "| 항목 | 내용 |\n", + "| :--- | :--- |\n", + "| **과정 분류** | 시설·방호·경계 (Level 2) |\n", + "| **학습 소요 시간** | 10개 미션 (5일 / 총 27.0시간) |\n", + "| **실습 유형** | 작전절차(SOP), 체크리스트, 모의훈련, AAR 사후검토 실습 |\n", + "\n", + "### 📖 프로젝트 목표\n", + "러시아-우크라이나 전쟁에서는 드론과 포격 파편이 지속적으로 위협하는 환경에서 대피소의 문과 출입구, 환기구, 차폐막 같은 물리적 방호가 제때 점검·보수되었는지가 인명 피해를 좌우했다. 본 프로젝트에서는 가상 기지의 대피소·주출입구·환기구를 무대로 방호 취약점과 위협 요인을 분석하고, 방호 등급별 현장점검 체크리스트와 상황 주입 시나리오를 만든다. 이어서 1차 현장점검과 취약점 식별 보고서 작성, 모의 파편·오염 공기 침투 상황에서의 밀폐성 판정 교차확인, 시설 관리관에게 하는 긴급 보수 소요 인계, 동시다발 손상 우선 점검 스트레스 훈련, 사후검토(AAR), 재점검과 긴급 보강 조치를 차례로 수행하며 매 단계 시간과 오류를 기록해 개선 효과를 수치로 확인한다. 디지털 과제와 달리 눈·손·기록으로 확인하는 현장형 학습이며 실제 시설 정보는 쓰지 않는다. 최종 목표는 대피소·출입구·환기구 방호 현장점검 표준 매뉴얼과 교관이 바로 쓸 수 있는 훈련 패키지를 완성하는 것이다.\n", + "\n", + "> **⚠️ [교육용 가상 자료 원칙]**\n", + "> - 본 워크북의 모든 자료는 안전 규정에 따라 **교육용 가상 자료**로 구성되어 있습니다.\n", + "> - 실제 군사 기밀이나 부대 세부 인적·작전 정보는 포함되지 않습니다.\n", + "\n", + "---\n", + "\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 01. 군사시설 대피소, 주출입구, 공기 환기구의 방호 취약점 및 위협 요인 분석\n", + "\n", + "### 📋 미션 배경 및 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "군사시설 대피소, 출입구, 환기구의 방호 취약점 및 위협 요인을 분석한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **대피소·주출입구·환기구의 방호 취약점과 위협 요인을 분석하고 점검 역할과 현장 체크 항목을 정의해 훈련의 기초 요구사항을 정리한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **실습자료의 가상 시설 결함 사례 카드 6건을 읽고 문제를 구조 결함·밀폐 불량·기록 부실·잠금 보안·센서 이상으로 분류 수행하고 검증하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 5개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `docs/sec09_baseline_assessment.md 및 훈련 프로파일` 형태의 결과물을 완성한다.\n", + "\n", + "### 📑 실습자료 및 작성 양식\n", + "군사시설 대피소, 주출입구, 공기 환기구의 방호 취약점 및 위협 요인 분석**\n", + "\n", + "가. 제공 자료 - 가상 시설 결함 사례 카드 [교육용가상자료]\n", + "\n", + "| **카드** | **가상 시각·상황** | **내용** | **드러난 문제** |\n", + "| --- | --- | --- | --- |\n", + "| **C1** | 09:10, 대피소 입구 | 방폭문이 끝까지 닫히지 않아 문틀과 문 사이에 손가락이 들어갈 만한 틈이 남았다. 점검표에는 '양호'로 적혀 있었다. | 닫힘 불량 미발견, 기록과 현물 불일치 |\n", + "| **C2** | 09:30, 환기구 V2 | 환기구 덮개 안쪽에 낙엽과 비닐이 쌓여 공기 흐름이 막혀 있었다. | 이물질 점검 항목 누락 |\n", + "| **C3** | 09:50, 필터실 | 필터 교체일 기록은 최근이지만 필터 포장에 적힌 날짜는 오래되어 있었다. | 기록 부실, 교체 미이행 의심 |\n", + "| **C4** | 10:05, 주출입구 | 출입 통제 잠금장치 열쇠가 문 옆 못에 걸려 있었다. | 잠금 보안 소홀 |\n", + "| **C5** | 10:20, 감시센서 | 표시등은 녹색인데 시험 신호를 보내도 경보 반응이 없었다. | 표시등만 보고 정상 판정, 기능 점검 생략 |\n", + "| **C6** | 10:40, 대피소 내부 | 벽 모서리에 가는 균열과 습기 자국이 있었고 3주째 같은 상태로 방치되었다. | 균열 추적 관리 없음, 보수 요청 누락 |\n", + "\n", + "나. 작성 서식 1 - 결함 유형 분류표 (구조 결함 / 밀폐 불량 / 기록 부실 / 잠금 보안 / 센서 이상)\n", + "\n", + "| **카드** | **결함 유형** | **분류 근거(카드에 적힌 사실)** | **관련 시설(대피소/출입구/환기구)** |\n", + "| --- | --- | --- | --- |\n", + "| **C1** | 밀폐 불량 (예시) | 문과 문틀 사이 틈, 기록은 양호 (예시) | 출입구 (예시) |\n", + "| **C2** | | | |\n", + "| **C3** | | | |\n", + "| **C4** | | | |\n", + "| **C5** | | | |\n", + "| **C6** | | | |\n", + "\n", + "다. 작성 서식 2 - 시설별 위협 요인·취약점 표 (위협은 가상 시나리오 수준으로만 쓴다)\n", + "\n", + "| **시설** | **위협 요인(가상)** | **취약점(사례 카드에서 찾은 것)** | **점검에서 확인할 것** |\n", + "| --- | --- | --- | --- |\n", + "| **대피소(실)** | 파편 낙하, 외부 공기 유입 | ( ) | ( ) |\n", + "| **주출입구(방폭문)** | 파편 충격, 비인가 접근 | ( ) | ( ) |\n", + "| **환기구(V1~V3)** | 이물질·파편 유입, 오염 공기 유입 | ( ) | ( ) |\n", + "\n", + "라. 작성 서식 3 - 역할 분담 매트릭스 (수: 수행, 책: 책임, 협: 협조, 통: 통보 / 단계마다 '책'은 1명)\n", + "\n", + "| **단계** | **점검반장** | **점검관** | **기록관** | **시설관리관** | **완료 판단 기준** |\n", + "| --- | --- | --- | --- | --- | --- |\n", + "| **① 사전 준비** | 책 (예시) | 수 (예시) | 수 (예시) | 통 (예시) | 범위·시간 통보 완료 (예시) |\n", + "| **② 외관 점검** | ( ) | ( ) | ( ) | ( ) | ( ) |\n", + "| **③ 기능 점검** | ( ) | ( ) | ( ) | ( ) | ( ) |\n", + "| **④ 판정** | ( ) | ( ) | ( ) | ( ) | ( ) |\n", + "| **⑤ 보고** | ( ) | ( ) | ( ) | ( ) | ( ) |\n", + "| **⑥ 임시 조치·보강 요청** | ( ) | ( ) | ( ) | ( ) | ( ) |\n", + "| **⑦ 확인·종결** | ( ) | ( ) | ( ) | ( ) | ( ) |\n", + "\n", + "마. 작성 서식 4 - 현장 체크 항목 정의표 (15개 이상)\n", + "\n", + "| **번호** | **단계** | **체크 항목(눈·손으로 확인 가능한 문장)** | **확인 방법** | **담당** |\n", + "| --- | --- | --- | --- | --- |\n", + "| **1** | 외관 점검 | 방폭문을 끝까지 닫았을 때 문틀과 문 사이에 틈이 보이지 않는다 (예시) | 눈으로 확인 (예시) | 점검관 (예시) |\n", + "| **2** | 기능 점검 | 감시센서 시험 신호를 보내면 경보가 울린다 (예시) | 시험 신호 (예시) | 점검관 (예시) |\n", + "| **3** | | | | |\n", + "| **4** | | | | |\n", + "| **5** | | | | |\n", + "| **6** | | | | |\n", + "| **7** | | | | |\n", + "| **8** | | | | |\n", + "| **9** | | | | |\n", + "| **10** | | | | |\n", + "| **11** | | | | |\n", + "| **12** | | | | |\n", + "| **13** | | | | |\n", + "| **14** | | | | |\n", + "| **15** | | | | |\n", + "| **16** | | | | |\n", + "| **17** | | | | |\n", + "\n", + "바. 작성 서식 5 - 부대 특성별 환경 변수 보강표\n", + "\n", + "| **환경 변수** | **야전 진지** | **기지(주둔지)** | **의무시설** |\n", + "| --- | --- | --- | --- |\n", + "| **대피소 형태(굴·건물 지하)** | ( ) | ( ) | ( ) |\n", + "| **출입 통제 방식** | ( ) | ( ) | ( ) |\n", + "| **환기 방식** | ( ) | ( ) | ( ) |\n", + "| **점검 인력·주기** | ( ) | ( ) | ( ) |\n", + "| **보수 자원(자재·인력)** | ( ) | ( ) | ( ) |\n", + "| **특별 고려 사항(환자 이송 등)** | ( ) | ( ) | ( ) |\n", + "\n", + "사. AI 제안 검토 기록\n", + "\n", + "| **AI가 제안한 항목** | **채택 여부** | **채택·미채택 이유** |\n", + "| --- | --- | --- |\n", + "| **제안 1** | | |\n", + "| **제안 2** | | |\n", + "| **제안 3** | | |\n", + "| **제안 4** | | |\n", + "\n", + "| **점검 항목** | **확인** |\n", + "| --- | --- |\n", + "| 사례 카드 6건을 결함 유형으로 모두 분류했다 | ( ) |\n", + "| 시설별 위협 요인과 취약점을 모두 채웠다 | ( ) |\n", + "| 7단계 모두 역할 분담 매트릭스 빈칸이 없다 | ( ) |\n", + "| 단계마다 '책'이 1명만 지정되었다 | ( ) |\n", + "| 현장 체크 항목이 15개 이상이며 모두 눈·손으로 확인 가능한 문장이다 | ( ) |\n", + "| 환경 변수표 3개 부대 유형을 모두 채웠다 | ( ) |\n", + "| AI 제안의 채택 여부와 이유를 기록했다 | ( ) |\n", + "| 실제 시설명·위치·방호 제원을 쓰지 않았다 | ( ) |\n", + "\n", + "**\n", + "\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 02. 시설 방호 등급별 현장점검 체크리스트(방폭문, 필터, 차폐막, 감시센서) 작성\n", + "\n", + "### 📋 미션 배경 및 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "방폭문, 필터, 차폐막, 감시센서 등 시설별 현장점검 체크리스트를 작성한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **방폭문, 필터, 차폐막, 감시센서 등 시설 구성 요소별 방호 등급에 맞는 현장점검 체크리스트와 SOP 흐름도를 작성한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **실습자료의 가상 방호 등급(A·B·C)과 구성 요소별 점검 항목 틀을 바탕으로 점검 항목, 판정 기준(양호·주의·불량), 점검 방법, 담당을 정 수행하고 검증하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 5개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `sop/sec09_field_checklist.md 및 SOP 흐름도` 형태의 결과물을 완성한다.\n", + "\n", + "### 📑 실습자료 및 작성 양식\n", + "시설 방호 등급별 현장점검 체크리스트(방폭문, 필터, 차폐막, 감시센서) 작성**\n", + "\n", + "가. 제공 자료 - 필수 안전/보안 조치 8개 [교육용가상자료]\n", + "\n", + "| **번호** | **필수 조치** | **배치할 단계(작성)** |\n", + "| --- | --- | --- |\n", + "| **S1** | 점검은 2인 1조로 하고 점검 전 시설관리관에게 범위와 시간을 통보한다. | ( ) |\n", + "| **S2** | 밀폐 공간·지하에 들어가기 전 환기, 조명, 비상 탈출로를 확인한다. | ( ) |\n", + "| **S3** | 무거운 문과 높은 곳 점검은 보조 인원 없이 혼자 하지 않는다. | ( ) |\n", + "| **S4** | 결함 의심 부위는 임의로 수리하지 않고 표시한 뒤 보고한다. | ( ) |\n", + "| **S5** | 점검 결과와 시설 정보는 지정 서식에만 적고 외부 AI·메신저에 입력하지 않는다. | ( ) |\n", + "| **S6** | 점검 중 촬영·녹음을 하지 않고 서식의 글과 간단한 표시로만 기록한다. | ( ) |\n", + "| **S7** | 점검 후 열어 둔 출입구·덮개를 원래대로 닫고 잠금 상태를 확인한다. | ( ) |\n", + "| **S8** | 붕괴 징후나 인명 위험을 발견하면 점검을 중단하고 인원 대피와 보고를 먼저 한다(긴급 모드). | ( ) |\n", + "\n", + "나. 제공 자료 - 방호 등급별 점검표 틀 (4개 구성 요소 모두 작성) [교육용가상자료]\n", + "\n", + "| **구성 요소(등급)** | **점검 항목** | **판정 기준(양호/주의/불량)** | **점검 방법** | **담당** |\n", + "| --- | --- | --- | --- | --- |\n", + "| **방폭문(A)** | 문틀·문 사이 틈, 개스킷 상태, 잠금 걸쇠 체결 | 양호: 틈 없음 / 주의: 일부 갈라짐 / 불량: 틈이 보이거나 걸쇠가 안 걸림 | 눈·손으로 확인 | 점검관 |\n", + "| **필터(A)** | ( ) | ( ) | ( ) | ( ) |\n", + "| **차폐막(B)** | ( ) | ( ) | ( ) | ( ) |\n", + "| **감시센서(B)** | ( ) | ( ) | ( ) | ( ) |\n", + "\n", + "다. 제공 자료 - 모호 표현 바꾸기 예시\n", + "\n", + "| **모호한 표현** | **구체적 기준(예시)** |\n", + "| --- | --- |\n", + "| **문 상태가 좋다** | 문을 끝까지 닫았을 때 틈이 보이지 않고 걸쇠가 소리 나게 걸린다 |\n", + "| **약간 헐겁다** | 손으로 흔들었을 때 문틀과 문이 맞닿는 부분이 움직이면 '주의'로 적는다 |\n", + "| **센서가 정상이다** | 시험 신호를 보냈을 때 경보 반응을 확인한 경우에만 '양호'로 적는다 |\n", + "| **빨리 보고한다** | 결함 발견 후 1분 이내에 점검반장에게 위치와 상태를 보고한다 |\n", + "\n", + "라. 작성 서식 1 - 점검 SOP 표 (신호 - 행동 - 보고)\n", + "\n", + "| **단계** | **신호(무엇을 보면)** | **점검반장** | **점검관** | **기록관** | **안전 조치(S번호)** |\n", + "| --- | --- | --- | --- | --- | --- |\n", + "| **① 사전 준비** | | | | | |\n", + "| **② 외관 점검** | | | | | |\n", + "| **③ 기능 점검** | | | | | |\n", + "| **④ 판정** | | | | | |\n", + "| **⑤ 보고** | | | | | |\n", + "| **⑥ 임시 조치·보강 요청** | | | | | |\n", + "| **⑦ 확인·종결** | | | | | |\n", + "\n", + "마. 작성 서식 2 - SOP 흐름도 틀 (빈칸을 채워 완성하고, 긴급/일반 분기를 표시한다)\n", + "\n", + "| [① 사전 준비: 범위·시간 통보 → 안전 확인]\n", + " \\|\n", + " < 점검 중 인명 위험 징후가 있는가? >\n", + " \\|예 \\|아니오\n", + " [긴급 모드] [일반 모드]\n", + " 점검 중단·대피 ( ) ② 외관 점검 ( )\n", + " 반장 보고 ( ) ③ 기능 점검 ( )\n", + " \\| \\|\n", + " +------------+---------------+\n", + " \\|\n", + " [④ 판정: 양호/주의/불량 → ( )]\n", + " \\|\n", + " [⑤ 보고 → ⑥ 임시 조치·보강 요청: ( )]\n", + " \\|\n", + " [⑦ 확인·종결: 원상 복구 → ( )] |\n", + "| --- |\n", + "\n", + "바. 작성 서식 3 - 휴대용 요약 카드(Pocket Guide, 명함 2장 크기로 줄여 인쇄)\n", + "\n", + "| **구분** | **점검반장** | **점검관** | **기록관** |\n", + "| --- | --- | --- | --- |\n", + "| **긴급 모드 첫 행동** | ( ) | ( ) | ( ) |\n", + "| **일반 모드 첫 행동** | ( ) | ( ) | ( ) |\n", + "| **반드시 할 것** | ( ) | ( ) | ( ) |\n", + "| **절대 하지 말 것** | ( ) | ( ) | ( ) |\n", + "\n", + "사. 교차 검토 기록표 (다른 조가 작성)\n", + "\n", + "| **번호** | **지적 위치** | **지적 내용(누락·모호)** | **수정 결과** | **확인** |\n", + "| --- | --- | --- | --- | --- |\n", + "| **1** | | | | |\n", + "| **2** | | | | |\n", + "| **3** | | | | |\n", + "| **4** | | | | |\n", + "| **5** | | | | |\n", + "\n", + "| **점검 항목** | **확인** |\n", + "| --- | --- |\n", + "| 4개 구성 요소 모두 점검 항목·판정 기준·방법·담당을 채웠다 | ( ) |\n", + "| 7단계 모두 역할별 행동이 적혔다(빈칸 0) | ( ) |\n", + "| 필수 안전/보안 조치 S1~S8이 모두 배치되었다 | ( ) |\n", + "| 긴급/일반 모드가 흐름도에서 구분된다 | ( ) |\n", + "| 교차 검토 지적 사항을 모두 수정했다 | ( ) |\n", + "| 요약 카드가 한 장 안에 들어간다 | ( ) |\n", + "\n", + "**\n", + "\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 03. 물리적 균열, 잠금장치 결함, 환기구 이물질 침투 가능성 현장 진단 실습\n", + "\n", + "### 📋 미션 배경 및 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "물리적 균열, 밀폐 불량, 환기구 이물질 침투 가능성을 현장 진단한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **물리적 균열, 밀폐 불량, 환기구 이물질 침투 가능성을 현장 진단하는 방법을 익히고 이를 시험할 상황 주입 시나리오(MSEL)를 만든다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **실습자료의 진단 카드(균열 단계, 밀폐 상태, 환기구 이물질)를 읽고 결함 진단 기준을 3단계(경미·주의·위험)로 정리하고 규격화하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 5개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `scenarios/sec09_msel_scenario.json(md)` 형태의 결과물을 완성한다.\n", + "\n", + "### 📑 실습자료 및 작성 양식\n", + "물리적 균열, 잠금장치 결함, 환기구 이물질 침투 가능성 현장 진단 실습**\n", + "\n", + "가. 제공 자료 - 결함 진단 카드 [교육용가상자료] (교관이 글 설명 또는 모형으로 제시)\n", + "\n", + "| **카드** | **진단 항목** | **관찰 내용** | **정답(교관용)** | **진단 단서** |\n", + "| --- | --- | --- | --- | --- |\n", + "| **K1** | 균열 | 벽 모서리에 머리카락처럼 가는 선, 손톱이 걸리지 않음 | 경미 | 깊이 없음, 표면 마감 갈라짐 가능 |\n", + "| **K2** | 균열 | 문틀 주변 선이 손톱에 걸리고 일부 길이로 이어짐, 가루가 떨어짐 | 주의 | 깊이 있음, 확대 여부 추적 필요 |\n", + "| **K3** | 균열 | 벽을 따라 길게 이어진 균열, 반대편에서 빛이 보임 | 위험 | 관통 의심 → 사용 제한·보고 |\n", + "| **K4** | 밀폐 | 문을 닫으면 아래쪽에서 종이가 들어감 | 위험 | 밀폐 불량, 개스킷·문턱 점검 |\n", + "| **K5** | 환기구 | 덮개 안쪽에 낙엽 한 줌 | 주의 | 제거 필요, 반복 시 덮개 보강 요청 |\n", + "| **K6** | 환기구 | 덮개 망 일부가 찢어져 안쪽이 보임 | 위험 | 이물질·파편 유입 가능 → 보수 요청 |\n", + "\n", + "나. 작성 서식 1 - 결함 진단 기준표 (3개 항목 × 3단계 모두 작성)\n", + "\n", + "| **진단 항목** | **경미(관찰 지속)** | **주의(보수 요청)** | **위험(사용 제한·즉시 보고)** |\n", + "| --- | --- | --- | --- |\n", + "| **균열** | 머리카락 굵기, 손톱 안 걸림 (예시) | ( ) | ( ) |\n", + "| **밀폐 불량** | ( ) | ( ) | ( ) |\n", + "| **환기구 이물질** | ( ) | ( ) | ( ) |\n", + "\n", + "다. 작성 서식 2 - 상황 주입 시나리오(MSEL) (10건 이상, 정상:비정상 = 6:4, '정상'=실제 결함 사건, '비정상'=오경보·오기록 사건)\n", + "\n", + "| **번호** | **가상 시각** | **주입 수단** | **사건 내용(주입 메시지 문안)** | **구분** | **기대 행동** | **담당** |\n", + "| --- | --- | --- | --- | --- | --- | --- |\n", + "| **1** | 09:05 | 카드 | 문틀 주변 균열이 손톱에 걸림 (예시) | 정상 | 주의 판정, 표시 후 반장 보고 (예시) | 점검관 |\n", + "| **2** | 09:12 | 카드 | 방폭문 아래 틈에 종이가 들어감 (예시) | 정상 | 위험 판정, 사용 제한 보고 (예시) | 점검관 |\n", + "| **3** | 09:20 | 카드 | 벽의 선이 그림자 때문에 균열처럼 보임 (예시) | 비정상 | 각도·조명 바꿔 재확인, 이상 없음 (예시) | 점검관 |\n", + "| **4** | | | | | | |\n", + "| **5** | | | | | | |\n", + "| **6** | | | | | | |\n", + "| **7** | | | | | | |\n", + "| **8** | | | | | | |\n", + "| **9** | | | | | | |\n", + "| **10** | | | | | | |\n", + "| **11** | | | | | | |\n", + "| **12** | | | | | | |\n", + "\n", + "| **구성 비율 계산** | 정상 ( )건 : 비정상 ( )건 = ( ) : ( ) → 6:4 충족 여부 ( ) |\n", + "| --- | --- |\n", + "\n", + "| **돌발 변수** | 통신 두절 ( )번 / 센서 오류 ( )번 / 부상 발생 ( )번 → 1건 이상 포함 ( ) |\n", + "| --- | --- |\n", + "\n", + "라. 워크스루(소리 내어 따라가기) 점검표\n", + "\n", + "| **점검 질문** | **문제 발견 사건 번호** | **수정 내용** |\n", + "| --- | --- | --- |\n", + "| **사건 순서와 간격이 현실적인가(너무 몰리거나 비지 않는가)?** | | |\n", + "| **모든 사건을 3단계 진단 기준으로 판정할 수 있는가?** | | |\n", + "| **비정상 사건이 너무 쉽게 드러나지 않는가?** | | |\n", + "| **위험 판정 사건에서 점검 중단·보고 행동이 이어지는가?** | | |\n", + "\n", + "| **점검 항목** | **확인** |\n", + "| --- | --- |\n", + "| 균열·밀폐·환기구 3개 항목의 3단계 기준을 모두 채웠다 | ( ) |\n", + "| 진단 카드마다 정답과 단서를 확인했다 | ( ) |\n", + "| 주입 사건이 10건 이상이다 | ( ) |\n", + "| 정상/비정상 비율이 6:4다 | ( ) |\n", + "| 돌발 변수가 1건 이상 포함되었다 | ( ) |\n", + "| 워크스루 지적 사항을 모두 수정했다 | ( ) |\n", + "\n", + "**\n", + "\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 04. 1차 현장점검 수행 및 취약점 식별 보고서 작성(Dry-run)\n", + "\n", + "### 📋 미션 배경 및 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "1차 현장점검 수행 및 취약점 식별 보고서를 작성(Dry-run)한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **가상 시설을 대상으로 1차 현장점검을 수행(Dry-run)하고 취약점 식별 보고서를 작성해 기준 성과를 측정한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **4~6명 조가 점검반장·점검관·기록관·시설관리관 역할을 맡고 교관이 M3 주입 카드를 점검 구역 순서대로 제시 수행하고 검증하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 5개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `logs/sec09_dryrun_execution_log.md 및 작성된 체크리스트` 형태의 결과물을 완성한다.\n", + "\n", + "### 📑 실습자료 및 작성 양식\n", + "1차 현장점검 수행 및 취약점 식별 보고서 작성(Dry-run)**\n", + "\n", + "가. 제공 자료 - 1차 모의훈련 진행 시간표(권장)\n", + "\n", + "| **시간** | **활동** | **담당** |\n", + "| --- | --- | --- |\n", + "| **0~10분** | 역할 카드 배부, 안전 브리핑, 초시계 동기화 | 교관 |\n", + "| **10~15분** | SOP·요약 카드 개인 숙지 | 전원 |\n", + "| **15~45분** | 주입 카드 1~10번 순서 진행(1차 현장점검 Dry-run) | 교관·전원 |\n", + "| **45~60분** | 기록표 정리, 준수율·지연시간 계산 | 기록자·평가관 |\n", + "| **60~75분** | 병목·혼선 구간 토의 | 전원 |\n", + "\n", + "나. 작성 서식 1 - 시각 기록표 (기록자, 초 단위)\n", + "\n", + "| **점검 건** | **점검 시작** | **결함 발견** | **반장 보고** | **기록 완료** | **관리관 통보** | **보고 지연(초)\n", + "=반장 보고-발견** | **조치 시간(초)\n", + "=관리관 통보-발견** |\n", + "| --- | --- | --- | --- | --- | --- | --- | --- |\n", + "| **예시** | 09:00:00 | 09:12:20 | 09:12:50 | 09:14:10 | 09:15:30 | 30 | 190 |\n", + "| **1** | | | | | | | |\n", + "| **2** | | | | | | | |\n", + "| **3** | | | | | | | |\n", + "| **4** | | | | | | | |\n", + "| **5** | | | | | | | |\n", + "| **6** | | | | | | | |\n", + "| **7** | | | | | | | |\n", + "| **8** | | | | | | | |\n", + "| **9** | | | | | | | |\n", + "| **10** | | | | | | | |\n", + "\n", + "다. 작성 서식 2 - SOP 단계 수행 체크(평가관)\n", + "\n", + "| **점검 건** | **① 사전 준비** | **② 외관 점검** | **③ 기능 점검** | **④ 판정** | **⑤ 보고** | **⑥ 임시 조치·보강 요청** | **⑦ 확인·종결** | **수행/요구** |\n", + "| --- | --- | --- | --- | --- | --- | --- | --- | --- |\n", + "| **1** | | | | | | | | |\n", + "| **2** | | | | | | | | |\n", + "| **3** | | | | | | | | |\n", + "| **4** | | | | | | | | |\n", + "| **5** | | | | | | | | |\n", + "| **6** | | | | | | | | |\n", + "| **7** | | | | | | | | |\n", + "| **8** | | | | | | | | |\n", + "| **9** | | | | | | | | |\n", + "| **10** | | | | | | | | |\n", + "\n", + "| **SOP 준수율** | 수행한 단계 합계 ( ) ÷ 요구된 단계 합계 ( ) × 100 = ( )% → 목표 80% 이상 ( ) |\n", + "| --- | --- |\n", + "\n", + "| **평균 보고 지연** | 보고 지연 합계 ( )초 ÷ 건수 ( ) = ( )초 → 목표 180초(3분) 이내 ( ) |\n", + "| --- | --- |\n", + "\n", + "라. 작성 서식 3 - 참여자별 수행 기록지(각자 작성)\n", + "\n", + "| **역할** | **잘한 점** | **막힌 점(점검 건 번호)** | **SOP에 없어서 헷갈린 것** |\n", + "| --- | --- | --- | --- |\n", + "| **점검반장** | | | |\n", + "| **점검관** | | | |\n", + "| **기록관** | | | |\n", + "| **시설관리관** | | | |\n", + "\n", + "마. 작성 서식 4 - 병목·혼선 구간 기록\n", + "\n", + "| **번호** | **발생 단계·점검 건** | **현상** | **추정 원인** | **M8로 넘길 질문** |\n", + "| --- | --- | --- | --- | --- |\n", + "| **1** | | | | |\n", + "| **2** | | | | |\n", + "| **3** | | | | |\n", + "\n", + "| **점검 항목** | **확인** |\n", + "| --- | --- |\n", + "| 모든 점검 건의 시각이 초 단위로 기록되었다 | ( ) |\n", + "| SOP 준수율을 계산식대로 계산했다 | ( ) |\n", + "| 평균 보고 지연을 계산했다 | ( ) |\n", + "| 참여자 전원의 수행 기록지를 수합했다 | ( ) |\n", + "| 병목·혼선 구간을 2건 이상 적었다 | ( ) |\n", + "| 훈련 중 안전 위반이 없었다 | ( ) |\n", + "\n", + "바. 작성 서식 5 - 취약점 식별 보고서\n", + "\n", + "| **번호** | **위치(가상 구역명)** | **상태(관찰 사실)** | **진단 단계(경미/주의/위험)** | **위험도 근거** | **권고 조치** | **보고 시각** |\n", + "| --- | --- | --- | --- | --- | --- | --- |\n", + "| **예시** | 1호 대피소 주출입구 | 방폭문 하단 틈에 종이가 들어감 | 위험 | 밀폐 불량 가능 | 사용 제한·보수 요청 | 09:12:50 |\n", + "| **1** | | | | | | |\n", + "| **2** | | | | | | |\n", + "| **3** | | | | | | |\n", + "| **4** | | | | | | |\n", + "| **5** | | | | | | |\n", + "\n", + "| **보고서 점검 항목** | **확인** |\n", + "| --- | --- |\n", + "| 취약점 보고서에 위치·상태·단계·근거·권고 조치를 모두 적었다 | ( ) |\n", + "| 관찰 사실과 추정을 구분해 적었다 | ( ) |\n", + "\n", + "**\n", + "\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 05. 모의 화학탄/드론 파편 침투 시뮬레이션 기반 차폐 밀폐성 검증\n", + "\n", + "### 📋 미션 배경 및 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "화학탄/파편 침투 모의 상황 기반 차폐 밀폐성을 검증한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **모의 파편·오염 공기 침투 상황 카드를 바탕으로 차폐 밀폐성 판정을 교차 확인하고 오판을 걸러낸다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **교관이 실제 결함 카드와 오류 카드(오기록, 페인트 갈라짐, 지난 점검 기록 복사, 송신자 불명 보고, 지연 중복 등) 12장을 섞어 제시 수행하고 검증하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 5개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `docs/sec09_cross_verification_sheet.md` 형태의 결과물을 완성한다.\n", + "\n", + "### 📑 실습자료 및 작성 양식\n", + "모의 화학탄/드론 파편 침투 시뮬레이션 기반 차폐 밀폐성 검증**\n", + "\n", + "| 모의 상황: 지난밤 근접 드론 파편 낙하와 외부 오염 공기 유입 우려를 가정한다. 파편·오염 공기는 모두 카드의 글 설명이며 실제 물질·충격 시험은 하지 않는다. |\n", + "| --- |\n", + "\n", + "가. 제공 자료 - 교차 확인 규칙(초안, 조별로 보완)\n", + "\n", + "| **규칙** | **내용** |\n", + "| --- | --- |\n", + "| **R1 복창** | 점검반장은 받은 보고의 위치·상태를 복창하고 보고한 점검관이 '맞음/틀림'으로 답한다. |\n", + "| **R2 2차 확인** | 가능하면 다른 점검관이 같은 위치를 다시 확인한다. |\n", + "| **R3 기록-현물 대조** | 기록과 현물이 다르면 현물을 우선하고 기록은 '재작성 필요'로 표시한다. |\n", + "| **R4 시각 대조** | 보고 시각과 수신 시각이 1분 이상 차이 나면 지연 정보로 표시한다. |\n", + "| **R5 송신자 확인** | 송신자를 알 수 없는 보고는 되물어 확인한다. |\n", + "| **R6 안전 우선** | 인명 위험 징후가 있으면 확인보다 먼저 사용 제한과 대피를 전파한다. |\n", + "\n", + "나. 제공 자료 - 오류 주입 카드 12장 [교육용가상자료] (교관이 순서를 섞어 제시)\n", + "\n", + "| **카드** | **들어온 보고** | **추가로 확인되는 정보** |\n", + "| --- | --- | --- |\n", + "| **E1** | 점검1: 방폭문 하단 틈이 보임 | 점검반장도 같은 위치에서 개스킷 갈라짐 확인 |\n", + "| **E2** | 점검2: V2 필터 교체 완료(기록 상 3일 전) | 필터 포장의 제조 표기는 오래되었고 개봉 흔적이 없음 |\n", + "| **E3** | 점검1: 문틀 상단에 긴 선(균열 의심) | 각도를 바꾸자 페인트 갈라짐이며 손톱이 걸리지 않음 |\n", + "| **E4** | 점검2: 감시센서 표시등 녹색(정상) | 시험 신호를 보내도 경보 반응 없음 |\n", + "| **E5** | 점검1: 차폐막 모서리 찢김 발견 | 시설관리관: 지난달 보수 완료 기록 있음, 현물은 아직 찢어진 상태 |\n", + "| **E6** | 점검2: V1 환기구 덮개 안쪽 낙엽 | 손전등으로 확인, 안쪽 통로까지 막힌 정도 |\n", + "| **E7** | 기록관: 오늘 점검 결과 문구가 지난달 기록과 글자까지 같음 | 점검관 진술: '지난번과 같아서 그대로 적었다', 현물 미확인 |\n", + "| **E8** | 점검1: 문 닫힘 소리는 정상, 밀폐 이상 없음 | 점검반장 확인: 걸쇠가 끝까지 걸리지 않았고 자동 닫힘이 느림 |\n", + "| **E9** | 무전: '환기구 전부 막힘' (송신자 불명) | 점검관 누구도 송신하지 않음, V1~V3 육안 확인 결과 이상 없음 |\n", + "| **E10** | 점검2: V3 덮개 변형 발견 (3분 전 보고와 같은 내용이 늦게 도착) | 무전 지연, 시각 표기가 3분 전 |\n", + "| **E11** | 점검1: 대피소 구석 습기와 곰팡이 냄새 | 균열과 연결 여부 불명, 누수인지 결로인지 확인 못함 |\n", + "| **E12** | 점검2: V3 덮개가 모의 파편 충격으로 안쪽으로 눌림 | 점검반장 확인: 망이 변형되어 틈 발생 |\n", + "\n", + "다. 작성 서식 1 - 교차검증 매트릭스\n", + "\n", + "| **카드** | **적용 규칙(R번호)** | **판정\n", + "(결함/이상 없음/확인필요)** | **근거(관측 사실)** | **조치** |\n", + "| --- | --- | --- | --- | --- |\n", + "| **E1** | | | | |\n", + "| **E2** | | | | |\n", + "| **E3** | | | | |\n", + "| **E4** | | | | |\n", + "| **E5** | | | | |\n", + "| **E6** | | | | |\n", + "| **E7** | | | | |\n", + "| **E8** | | | | |\n", + "| **E9** | | | | |\n", + "| **E10** | | | | |\n", + "| **E11** | | | | |\n", + "| **E12** | | | | |\n", + "\n", + "| **식별 성공률** | 정답과 일치한 카드 ( ) ÷ 전체 12장 × 100 = ( )% → 목표 85% 이상 ( ) |\n", + "| --- | --- |\n", + "\n", + "| **실제 위험 놓침** | 실제 위험을 이상 없음으로 판정한 건수 ( )건 → 목표 0건 ( ) |\n", + "| --- | --- |\n", + "\n", + "라. 작성 서식 2 - 불일치 시 재확인·보고 경로(우선순위 규칙)\n", + "\n", + "| **상황** | **1순위 확인** | **2순위 확인** | **상급 보고 여부·시점** |\n", + "| --- | --- | --- | --- |\n", + "| **두 점검관의 판정이 다름** | | | |\n", + "| **보고가 늦게 도착(1분 이상)** | | | |\n", + "| **기록과 현물이 다름** | | | |\n", + "| **송신자 불명 보고** | | | |\n", + "\n", + "마. 교관용 정답표 (훈련 후 공개)\n", + "\n", + "| **카드** | **정답** | **핵심 근거** |\n", + "| --- | --- | --- |\n", + "| **E1** | 실제 위험(결함) | 두 사람 확인, 개스킷 갈라짐 |\n", + "| **E2** | 실제 위험(결함) | 기록과 현물 불일치 → 교체 미이행 의심, 재교체 요청 |\n", + "| **E3** | 이상 없음(오경보) | 페인트 갈라짐, 손톱 안 걸림 → 마감 상태로 기록 |\n", + "| **E4** | 실제 위험(결함) | 표시등만 정상, 기능 시험 실패 |\n", + "| **E5** | 확인필요 | 기록과 현물 불일치 → 보수 이행 여부 시설관리관과 재확인 |\n", + "| **E6** | 실제 위험(결함) | 환기 통로 폐색 → 제거 후 재점검 |\n", + "| **E7** | 확인필요 | 복사 기록 의심 → 현물 재점검 후 기록 다시 작성 |\n", + "| **E8** | 실제 위험(결함) | 걸쇠 미체결, 자동 닫힘 불량 → 밀폐 불량 |\n", + "| **E9** | 이상 없음(기만·오보) | 송신자 불명, 현장 확인 결과 이상 없음 |\n", + "| **E10** | 이상 없음(지연 중복) | 3분 전 보고의 지연 도착 |\n", + "| **E11** | 확인필요 | 원인 불명, 균열 점검과 연계해 재확인 |\n", + "| **E12** | 실제 위험(결함) | 충격 흔적과 망 변형으로 이물질·파편 유입 가능 |\n", + "\n", + "| 판정 점수 기준: '확인필요'로 두고 이후 확인을 거쳐 정답에 도달했으면 정답으로 본다. 정답표에서 '실제 위험'으로 표시된 카드(E1, E2, E4, E6, E8, E12)를 이상 없음으로 버리면 성공률과 관계없이 FAIL이다. |\n", + "| --- |\n", + "\n", + "| **점검 항목** | **확인** |\n", + "| --- | --- |\n", + "| 12장 모두 판정과 근거를 적었다 | ( ) |\n", + "| 근거는 관측 사실로 적었다 | ( ) |\n", + "| 식별 성공률과 놓친 건수를 계산했다 | ( ) |\n", + "| 불일치 시 보고 경로 상황을 모두 채웠다 | ( ) |\n", + "| 의심 신호가 있어도 안전 조치를 먼저 전파했다 | ( ) |\n", + "\n", + "**\n", + "\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 06. 시설 관리관 앞 긴급 보수 소요 전달 및 인계 브리핑 실습\n", + "\n", + "### 📋 미션 배경 및 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "시설 관리관 앞 긴급 보수 소요 인계 브리핑을 실습한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **점검에서 나온 긴급 보수 소요를 시설 관리관에게 우선순위와 함께 정확히 전달하고 후속 조치를 인계하는 브리핑을 실습한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **실습자료의 가상 점검 결과 카드(결함 3건)를 읽고 위험도와 필요 자재·인력, 임시 조치 여부를 정리해 긴급 보수 소요 요청서 설계하고 작성하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 5개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `reports/sec09_handover_protocol.md 및 브리핑 녹취/기록지` 형태의 결과물을 완성한다.\n", + "\n", + "### 📑 실습자료 및 작성 양식\n", + "시설 관리관 앞 긴급 보수 소요 전달 및 인계 브리핑 실습**\n", + "\n", + "가. 제공 자료 - 가상 점검 결과 카드 [교육용가상자료]\n", + "\n", + "| 가상 11:00 현재: 1호 대피소 점검 결과 위험 판정 3건. (1) 주출입구 방폭문 걸쇠 미체결과 하단 틈(밀폐 불량), (2) V3 환기구 덮개 변형(이물질 유입 가능), (3) 감시센서 시험 신호 무반응. 주출입구는 현재 임시로 사용 제한 표지와 인원 통제가 설치됨. 필요 자재·인력(가상): 개스킷 1세트, 덮개 1개, 센서 점검 인원 1명, 문 조정 인원 2명. 직전 점검 기록 번호 P-014(2주 전)와 비교하면 (1)은 악화, (2)(3)은 새로 발견. 시설관리관은 11:15에 회의를 마치고 복귀 예정, 다음 가상 드론 경보 훈련은 내일 06:00 예정. |\n", + "| --- |\n", + "\n", + "나. 작성 서식 1 - 긴급 보수 소요 요청서 (위험도 순으로 정리)\n", + "\n", + "| **순위** | **결함** | **위험도 근거** | **임시 조치(접근 제한·표시 수준)** | **필요 자재·인력** | **희망 완료 시한** |\n", + "| --- | --- | --- | --- | --- | --- |\n", + "| **1** | | | | | |\n", + "| **2** | | | | | |\n", + "| **3** | | | | | |\n", + "\n", + "다. 작성 서식 2 - SBAR 인계 브리핑 템플릿\n", + "\n", + "| **구분** | **들어갈 내용** | **작성** |\n", + "| --- | --- | --- |\n", + "| **S 상황** | 점검 대상, 지금 가장 위험한 결함, 현재 통제 상태 | |\n", + "| **B 배경** | 점검 경과, 이전 기록(P-014)과 비교, 이미 한 임시 조치 | |\n", + "| **A 평가** | 위험도 판단, 불확실한 점, 사용 가능·불가 구역 | |\n", + "| **R 요청** | 시설관리관이 지금 결정할 일(보수 승인·자재·인력), 다음 확인 시각 | |\n", + "\n", + "라. 작성 서식 3 - 인계 필수 항목 10개 확인표\n", + "\n", + "| **번호** | **필수 항목** | **전달(O/X)** | **관리관 복창(O/X)** |\n", + "| --- | --- | --- | --- |\n", + "| **1** | 시설명·가상 구역 | ( ) | ( ) |\n", + "| **2** | 결함 내용(관찰 사실) | ( ) | ( ) |\n", + "| **3** | 위험도와 근거 | ( ) | ( ) |\n", + "| **4** | 임시 조치 여부 | ( ) | ( ) |\n", + "| **5** | 사용 제한 필요 구역 | ( ) | ( ) |\n", + "| **6** | 필요 자재·인력 | ( ) | ( ) |\n", + "| **7** | 희망 완료 시한 | ( ) | ( ) |\n", + "| **8** | 이전 점검 기록 번호와 비교 | ( ) | ( ) |\n", + "| **9** | 보고자·보고 시각 | ( ) | ( ) |\n", + "| **10** | 다음 확인 시점 | ( ) | ( ) |\n", + "\n", + "| **인계 소요 시간** | 시작 ( : : ) ~ 종료 ( : : ) = ( )분 → 목표 10분 이내 ( ) |\n", + "| --- | --- |\n", + "\n", + "| **누락률** | 누락 항목 수 ( ) ÷ 10 × 100 = ( )% → 목표 0% ( ) |\n", + "| --- | --- |\n", + "\n", + "| **상호 서명** | 인계자 ( ) 인수자 ( ) |\n", + "| --- | --- |\n", + "\n", + "마. 인수자(시설관리관 역할) 돌발 상황 퀴즈 (교관이 인계 직후 구두로 질문)\n", + "\n", + "| **번호** | **질문** | **정답(교관용)** | **O/X** |\n", + "| --- | --- | --- | --- |\n", + "| **Q1** | 가장 먼저 보수해야 할 결함은 무엇이고 이유는? | 주출입구 방폭문 밀폐 불량(악화, 인명 방호에 직결) | ( ) |\n", + "| **Q2** | 현재 임시 조치는 무엇인가? | 사용 제한 표지와 인원 통제 | ( ) |\n", + "| **Q3** | 필요 자재와 인력은? | 개스킷 1세트, 덮개 1개, 센서 점검 1명, 문 조정 2명 | ( ) |\n", + "| **Q4** | 이전 점검 기록과 비교해 새로 발견된 결함은? | V3 덮개 변형, 감시센서 무반응 | ( ) |\n", + "| **Q5** | 내일 06:00 훈련 전에 해결되지 않으면 가장 먼저 할 일은? | 해당 구역 사용 제한 유지와 대체 대피 위치 지정 보고 | ( ) |\n", + "\n", + "| **점검 항목** | **확인** |\n", + "| --- | --- |\n", + "| 보수 소요 3건을 위험도 순으로 정리했다 | ( ) |\n", + "| 임시 조치는 접근 제한·표시 수준으로만 썼다 | ( ) |\n", + "| SBAR 4개 칸을 모두 채웠다 | ( ) |\n", + "| 필수 항목 10개 누락 0 | ( ) |\n", + "| 인계를 10분 이내에 마쳤다 | ( ) |\n", + "| 인수자 퀴즈 4문항 이상 정답 | ( ) |\n", + "| 상호 서명을 받았다 | ( ) |\n", + "\n", + "**\n", + "\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 07. 동시다발적 시설 손상 모의 상황에서의 우선 점검 스트레스 훈련\n", + "\n", + "### 📋 미션 배경 및 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "동시다발적 시설 손상 모의 우선 점검 스트레스 훈련을 진행한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **동시다발적 시설 손상이 보고되는 극한 상황에서 인명 안전을 우선으로 점검 순서를 정하고 지휘 위임이 유지되는지 스트레스 훈련으로 확인한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **대피소 출입구 변형, 환기구 덮개 파손, 감시센서 경보, 부분 정전, 통신 두절, 점검관 경상 카드를 짧은 간격으로 주입하여 처리 지연 및 복원력을 시험하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 5개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `logs/sec09_stress_drill_results.md` 형태의 결과물을 완성한다.\n", + "\n", + "### 📑 실습자료 및 작성 양식\n", + "동시다발적 시설 손상 모의 상황에서의 우선 점검 스트레스 훈련**\n", + "\n", + "가. 제공 자료 - 스트레스 주입 타임라인 [교육용가상자료] (교관이 T 기준으로 제시)\n", + "\n", + "| **시점** | **주입 내용** | **기대 대응** |\n", + "| --- | --- | --- |\n", + "| **T+0:00** | 가상 근접 낙하 후 점검 시작, 부분 정전으로 조명 약함 | 안전 확인(조명·탈출로), 2인 1조 유지 |\n", + "| **T+1:00** | 대피소 출입구 문이 변형되어 잘 안 닫힌다는 보고 | 사용 제한, 반장 보고 |\n", + "| **T+2:00** | 환기구 V1 덮개 파손 + V3 덮개 변형 동시 보고 | 우선순위 선언(인명 안전·출입구 우선), 담당 배정 |\n", + "| **T+3:00** | 감시센서 경보 울림(원인 불명) | 오작동 여부 확인 보류, 우선순위 낮춤 판단 근거 발언 |\n", + "| **T+4:00** | 통신 두절 | 대체 전달 수단(전령, 모의)으로 전환 |\n", + "| **T+5:00** | 점검반장 연락 불가(모의 부재) | 위임 순서에 따라 차선임자 지휘 인수 |\n", + "| **T+6:00** | 점검관 1명 넘어짐(모의 경상) | 부상 보고, 응급처치 연계, 인원 확인 |\n", + "| **T+8:00** | 주출입구 인근에 비인가 인원 접근 보고 | 출입 통제, 반장 보고 |\n", + "| **T+10:00** | 필터실에서 습기 + 냄새 보고 | 접근 제한, 원인 확인 요청, 보고 |\n", + "| **T+12:00** | 통신 복구, 점검반장 복귀 | 차선임자가 점검반장에게 상황 인계(SBAR) |\n", + "| **T+14:00** | 점검 종료 명령 | 원상 복구·잠금 확인 후 종결 보고 |\n", + "\n", + "나. 제공 자료 - 위험 조치 10개 판정 기준\n", + "\n", + "| **번호** | **위험 조치** | **성공 기준** |\n", + "| --- | --- | --- |\n", + "| **D1** | 안전 확인 | 점검 시작 전 조명·탈출로·붕괴 징후 확인 발언 |\n", + "| **D2** | 2인 1조 유지 | 혼자 점검하는 인원 0명 |\n", + "| **D3** | 긴급 모드 전환 | 동시 보고 후 1분 이내 우선순위 선언 |\n", + "| **D4** | 우선순위 판단 | 인명 안전·출입구·환기구 순으로 근거와 함께 선언 |\n", + "| **D5** | 접근 제한 조치 | 위험 구역 사용 제한 표지와 인원 통제 |\n", + "| **D6** | 대체 전달 전환 | 통신 두절 후 1분 이내 대체 수단 사용 |\n", + "| **D7** | 지휘 위임 | 부재 확인 후 1분 이내 차선임자 '지휘 인수' 선언 |\n", + "| **D8** | 부상 보고 | 부상 발생 1분 이내 보고와 인원 확인 |\n", + "| **D9** | 출입 통제 | 비인가 접근 즉시 제지·보고 |\n", + "| **D10** | 복귀 인계 | SBAR 4요소를 포함해 인계 |\n", + "\n", + "다. 작성 서식 1 - 타임라인 기록표(기록관)\n", + "\n", + "| **시점** | **실제 행동·발언** | **행동 시각** | **걸린 시간(초)** | **관련 D번호** |\n", + "| --- | --- | --- | --- | --- |\n", + "| **T1** | | | | |\n", + "| **T2** | | | | |\n", + "| **T3** | | | | |\n", + "| **T4** | | | | |\n", + "| **T5** | | | | |\n", + "| **T6** | | | | |\n", + "| **T7** | | | | |\n", + "| **T8** | | | | |\n", + "| **T9** | | | | |\n", + "| **T10** | | | | |\n", + "| **T11** | | | | |\n", + "\n", + "라. 작성 서식 2 - 우선순위 판단표 (T+2:00 이후 접수된 손상 5건)\n", + "\n", + "| **손상 내용** | **인명 위험(상/중/하)** | **방호 기능 영향(상/중/하)** | **처리 순서** | **판단 근거** |\n", + "| --- | --- | --- | --- | --- |\n", + "| **출입구 문 변형** | | | | |\n", + "| **환기구 V1 덮개 파손** | | | | |\n", + "| **환기구 V3 덮개 변형** | | | | |\n", + "| **감시센서 경보 원인 불명** | | | | |\n", + "| **필터실 습기·냄새** | | | | |\n", + "\n", + "마. 작성 서식 3 - 위험 조치 판정표 및 위임 순서\n", + "\n", + "| **D1** | **D2** | **D3** | **D4** | **D5** | **D6** | **D7** | **D8** | **D9** | **D10** |\n", + "| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- |\n", + "| ( ) | ( ) | ( ) | ( ) | ( ) | ( ) | ( ) | ( ) | ( ) | ( ) |\n", + "\n", + "| **위험 조치 성공률** | 성공 ( ) ÷ 10 × 100 = ( )% → 목표 90% 이상 ( ) |\n", + "| --- | --- |\n", + "\n", + "| **비상 절차 전환 시간** | 동시 보고 ( : : ) → 우선순위 선언 ( : : ) = ( )초 → 목표 60초 이내 ( ) |\n", + "| --- | --- |\n", + "\n", + "| **지휘 위임 순서** | 1순위 점검반장 ( ) → 2순위 ( ) → 3순위 ( ) / 인수 선언 문구: ( ) |\n", + "| --- | --- |\n", + "\n", + "바. 안전 통제 점검(훈련 시작 전, 안전 통제관)\n", + "\n", + "| **안전 점검 항목** | **확인** |\n", + "| --- | --- |\n", + "| 실습장 이동 경로의 걸림 물체를 치웠다 | ( ) |\n", + "| 조명 약화 구역에 안전 요원을 배치했다 | ( ) |\n", + "| '훈련 중지' 3회 신호를 전원이 알고 있다 | ( ) |\n", + "| 모의 부상은 실제 동작 없이 카드로만 표시한다 | ( ) |\n", + "| 습기·냄새는 카드 설명으로만 모의하고 실제 물질을 사용하지 않았다 | ( ) |\n", + "\n", + "| **점검 항목** | **확인** |\n", + "| --- | --- |\n", + "| 타임라인 11개 시점을 모두 기록했다 | ( ) |\n", + "| 비상 절차 전환 시간을 초 단위로 계산했다 | ( ) |\n", + "| 위험 조치 10개를 모두 판정했다 | ( ) |\n", + "| 우선순위 판단표 5건에 근거를 적었다 | ( ) |\n", + "| 위임이 1분 이내에 이루어졌는지 확인했다 | ( ) |\n", + "| 안전 수칙 위반 0건 | ( ) |\n", + "\n", + "**\n", + "\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 08. AAR 회의를 통한 점검 누락 항목 및 점검관별 판정 편차 분석\n", + "\n", + "### 📋 미션 배경 및 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "AAR 회의를 통해 점검 누락 항목 및 점검관별 편차를 분석한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **AAR 회의로 점검 누락 항목과 점검관별 판정 편차의 원인을 사실 중심으로 분석하고 개선점을 도출한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **M4~M7 기록지와 점검표를 모아 '계획한 것-실제 일어난 것-차이의 원인-개선할 것' 4단계로 AAR을 진행 수행하고 검증하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 5개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `aar/sec09_aar_session_minutes.md 및 오류 분석 매트릭스` 형태의 결과물을 완성한다.\n", + "\n", + "### 📑 실습자료 및 작성 양식\n", + "AAR 회의를 통한 점검 누락 항목 및 점검관별 판정 편차 분석**\n", + "\n", + "가. 제공 자료 - AAR 4단계 진행 순서(권장 60분)\n", + "\n", + "| **단계** | **질문** | **시간** |\n", + "| --- | --- | --- |\n", + "| **1** | 무엇을 하려고 했나(계획·SOP 기준)? | 10분 |\n", + "| **2** | 실제로 무슨 일이 있었나(기록 기준)? | 15분 |\n", + "| **3** | 왜 차이가 났나(원인)? | 20분 |\n", + "| **4** | 다음에는 무엇을 바꿀까(개선)? | 15분 |\n", + "\n", + "나. 제공 자료 - 가상 기록 발췌(팩트체크 연습용) [교육용가상자료]\n", + "\n", + "| **번호** | **출처** | **내용** |\n", + "| --- | --- | --- |\n", + "| **F1** | 점검 기록(점검관A) | 문틀 균열(K2와 같은 상태)을 '경미'로 판정 |\n", + "| **F2** | 점검 기록(점검관B) | 같은 균열을 '위험'으로 판정 |\n", + "| **F3** | 점검표 검토 | 환기구 덮개 안쪽 이물질 항목이 점검표에 없어 4건 중 3건 미점검 |\n", + "| **F4** | 점검관 진술 | \"시간이 부족해 감시센서 시험 신호는 생략했다.\" |\n", + "| **F5** | 시각 기록표 | 결함 발견 09:12:20, 반장 보고 09:14:10 (110초) |\n", + "| **F6** | 평가관 기록 | 점검 종료 후 환기구 덮개 1개가 열린 채 방치됨(원상 복구 누락) |\n", + "| **F7** | 스트레스 훈련 기록 | 점검반장 부재 후 위임 선언까지 95초, 위임 문구 미지정 |\n", + "\n", + "| 팩트체크 예: F1(경미)과 F2(위험)는 같은 균열에 대한 판정이다. 점검관의 능력 문제로 단정하지 말고, 균열 단계를 가르는 기준(손톱 걸림, 길이, 관통 여부)이 점검표에 없었는지 확인한다. 원인은 '판정 기준 부재(절차)'로 분류한다. |\n", + "| --- |\n", + "\n", + "다. 작성 서식 1 - 원인 분석 매트릭스\n", + "\n", + "| **문제** | **확인된 사실** | **근거(F번호·기록)** | **원인 분류\n", + "(절차/도구/환경/숙련)** | **근본 원인** | **개선안** |\n", + "| --- | --- | --- | --- | --- | --- |\n", + "| **점검관별 판정 편차** | | | | | |\n", + "| **점검표 항목 누락(환기구)** | | | | | |\n", + "| **점검 생략(센서 시험)** | | | | | |\n", + "| **보고 지연·원상 복구 누락** | | | | | |\n", + "| | | | | | |\n", + "| | | | | | |\n", + "\n", + "라. 비난 표현 바꾸기\n", + "\n", + "| **피할 표현** | **바꾼 표현(예시)** |\n", + "| --- | --- |\n", + "| **점검관A가 대충 봤다** | 균열 단계 판정 기준이 점검표에 없어 같은 균열을 경미와 위험으로 다르게 적었다 |\n", + "| **점검관이 센서 시험을 건너뛰었다** | 점검 시간 배분 기준과 필수 항목 표시가 없어 시간이 부족할 때 센서 시험이 생략되었다 |\n", + "\n", + "마. 작성 서식 2 - 개선점 목록(5건 이상) 및 SOP 1차 수정안\n", + "\n", + "| **번호** | **개선점** | **SOP 현행** | **SOP 수정** | **근거(매트릭스 행)** |\n", + "| --- | --- | --- | --- | --- |\n", + "| **1** | | | | |\n", + "| **2** | | | | |\n", + "| **3** | | | | |\n", + "| **4** | | | | |\n", + "| **5** | | | | |\n", + "| **6** | | | | |\n", + "\n", + "| **점검 항목** | **확인** |\n", + "| --- | --- |\n", + "| 4단계 순서로 AAR을 진행했다 | ( ) |\n", + "| 진술과 기록이 다른 곳을 모두 표시했다 | ( ) |\n", + "| 모든 원인 항목에 근거(F번호·기록)를 연결했다 | ( ) |\n", + "| 개선점이 5건 이상이다 | ( ) |\n", + "| 회의록에 개인 비난 표현이 없다 | ( ) |\n", + "| 개선점이 SOP 수정안에 반영되었다 | ( ) |\n", + "\n", + "바. 작성 서식 3 - 같은 결함에 대한 점검관별 판정 편차 비교표\n", + "\n", + "| **결함** | **점검관A 판정** | **점검관B 판정** | **차이 원인(기준 부재/관찰 차이/기록 오류)** | **통일 기준(제안)** |\n", + "| --- | --- | --- | --- | --- |\n", + "| **문틀 균열** | 경미 | 위험 | | |\n", + "| | | | | |\n", + "| | | | | |\n", + "\n", + "**\n", + "\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 09. 피드백을 반영한 재점검(Re-run) 및 긴급 방호 보강 조치 수행\n", + "\n", + "### 📋 미션 배경 및 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "피드백을 반영한 재점검(Re-run) 및 긴급 방호 보강 조치를 수행한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **AAR 피드백을 반영한 수정 SOP로 같은 조건의 재점검을 실시하고 긴급 방호 보강 조치 계획까지 수행해 개선을 수치로 확인한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **M4와 같은 주입 카드 구성(순서만 바꿈)과 같은 평가표로 재점검을 실시 수행하고 검증하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 5개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `eval/sec09_rerun_performance_comparison.md` 형태의 결과물을 완성한다.\n", + "\n", + "### 📑 실습자료 및 작성 양식\n", + "피드백을 반영한 재점검(Re-run) 및 긴급 방호 보강 조치 수행**\n", + "\n", + "가. 제공 자료 - 재훈련 동일 조건 유지표\n", + "\n", + "| **항목** | **1차(M4)** | **재훈련** | **동일 여부** |\n", + "| --- | --- | --- | --- |\n", + "| **주입 점검 카드 구성** | 10건 | 같은 10건(순서만 변경) | ( ) |\n", + "| **역할 편성** | | | ( ) |\n", + "| **평가표** | M4 서식 | M4 서식 | ( ) |\n", + "| **측정 방법** | 초시계·시각 기록표 | | ( ) |\n", + "| **시간대·환경** | | | ( ) |\n", + "\n", + "나. 제공 자료 - 계산 예시 [교육용가상자료]\n", + "\n", + "| 평균 조치 시간: 1차 190초 → 재훈련 125초\n", + "단축률 = (190 - 125) ÷ 190 × 100 = 34.2% → 목표 30% 이상 달성\n", + "\n", + "오류 건수: 1차 13건 → 재훈련 5건\n", + "감소율 = (13 - 5) ÷ 13 × 100 = 61.5% → 목표 50% 이상 달성 |\n", + "| --- |\n", + "\n", + "다. 작성 서식 1 - 전후 성과 비교표\n", + "\n", + "| **지표** | **1차(M4)** | **재훈련** | **변화율(%)** | **목표** | **달성** |\n", + "| --- | --- | --- | --- | --- | --- |\n", + "| **평균 조치 시간(초)** | | | | 30% 이상 단축 | ( ) |\n", + "| **평균 보고 지연(초)** | | | | 참고 | ( ) |\n", + "| **SOP 준수율(%)** | | | | 참고 | ( ) |\n", + "| **오류 건수(누락)** | | | | - | - |\n", + "| **오류 건수(오보고·오판정)** | | | | - | - |\n", + "| **오류 건수(절차 위반)** | | | | - | - |\n", + "| **오류 합계** | | | | 50% 이상 감소 | ( ) |\n", + "\n", + "라. 작성 서식 2 - 미달 항목 원인 및 보수 교육 계획\n", + "\n", + "| **미달 항목** | **원인** | **보완 방법** |\n", + "| --- | --- | --- |\n", + "| **미달 1** | | |\n", + "| **미달 2** | | |\n", + "\n", + "| **보수 교육 주기** | ( )주마다 ( )분, 방법: ( ) |\n", + "| --- | --- |\n", + "\n", + "| **숙련 유지 확인 지표** | ( ) ※ 예: 평균 보고 지연 60초 이내 유지 |\n", + "| --- | --- |\n", + "\n", + "| **점검 항목** | **확인** |\n", + "| --- | --- |\n", + "| 1차와 같은 구성·평가표·측정 방법을 썼다 | ( ) |\n", + "| 조치 시간 단축률을 계산식대로 계산했다 | ( ) |\n", + "| 오류를 유형별로 세고 감소율을 계산했다 | ( ) |\n", + "| 미달 항목의 원인을 적었다 | ( ) |\n", + "| 보수 교육 주기를 제안했다 | ( ) |\n", + "\n", + "마. 작성 서식 3 - 긴급 방호 보강 조치 기록(계획 수준, 수리 방법은 쓰지 않는다)\n", + "\n", + "| **결함** | **긴급 조치(접근 제한·표시·사용 제한)** | **보수 요청 번호·시한** | **시설관리관 확인(O/X)** | **재점검 판정** |\n", + "| --- | --- | --- | --- | --- |\n", + "| **결함 1** | | | | |\n", + "| **결함 2** | | | | |\n", + "| **결함 3** | | | | |\n", + "| **결함 4** | | | | |\n", + "\n", + "| **보강 조치 점검 항목** | **확인** |\n", + "| --- | --- |\n", + "| 확인된 결함마다 긴급 조치와 보수 요청을 기록했다 | ( ) |\n", + "| 수리 방법이 아니라 접근 제한·표시·요청 수준으로만 적었다 | ( ) |\n", + "\n", + "**\n", + "\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 10. 대피소·출입구·환기구 방호 현장점검 표준 매뉴얼 최종 완성\n", + "\n", + "### 📋 미션 배경 및 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "대피소·출입구·환기구 방호 현장점검 표준 매뉴얼을 완성한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **대피소·출입구·환기구 방호 현장점검 표준 매뉴얼을 최종 확정하고 다른 부대 교관이 바로 쓸 수 있는 훈련 패키지로 묶는다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **개요-SOP-시나리오-평가 루브릭-AAR 5개 장으로 교안을 구성하고 M2~M9 산출물 가운데 검증된 내용만 반영 수행하고 검증하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 5개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `package/sec09_standard_training_package.md 및 교관 지침서` 형태의 결과물을 완성한다.\n", + "\n", + "### 📑 실습자료 및 작성 양식\n", + "대피소·출입구·환기구 방호 현장점검 표준 매뉴얼 최종 완성**\n", + "\n", + "가. 작성 서식 1 - 훈련 패키지 5개 장 구성표\n", + "\n", + "| **장** | **포함 내용** | **반영할 산출물** | **완성(O/X)** |\n", + "| --- | --- | --- | --- |\n", + "| **1장 개요** | 점검 목적, 대상, 소요 시간, 가상 시설·가상 상황 | M1 | ( ) |\n", + "| **2장 SOP** | 방호 등급별 점검표, 흐름도, 요약 카드, 안전 조치 | M2·M8 수정안 | ( ) |\n", + "| **3장 시나리오** | 결함 진단 기준표, MSEL, 침투 모의 카드, 스트레스 타임라인 | M3·M5·M7 | ( ) |\n", + "| **4장 평가 루브릭** | SOP 체크, 시각 기록표, 인계 확인표, 채점표 | M4·M6·M9 | ( ) |\n", + "| **5장 AAR** | AAR 진행 순서, 원인 분석 매트릭스, 판정 편차 비교, 개선 이력 | M8·M9 | ( ) |\n", + "\n", + "나. 작성 서식 2 - 교관 지침서\n", + "\n", + "| **준비물** | ( ) |\n", + "| --- | --- |\n", + "\n", + "| **역할 배정** | ( ) |\n", + "| --- | --- |\n", + "\n", + "| **시간표** | ( ) |\n", + "| --- | --- |\n", + "\n", + "| **안전 통제** | ( ) |\n", + "| --- | --- |\n", + "\n", + "| **채점 방법** | ( ) |\n", + "| --- | --- |\n", + "\n", + "| **갱신 주기** | ( ) ※ 연간 교육훈련 계획과 연계 |\n", + "| --- | --- |\n", + "\n", + "다. 작성 서식 3 - 성과 수치 일치 확인\n", + "\n", + "| **교안에 적은 수치** | **교안 값** | **원본 기록(M4·M9)** | **일치(O/X)** |\n", + "| --- | --- | --- | --- |\n", + "| **1차 SOP 준수율** | | | ( ) |\n", + "| **1차 평균 보고 지연** | | | ( ) |\n", + "| **재훈련 조치 시간 단축률** | | | ( ) |\n", + "| **재훈련 오류 감소율** | | | ( ) |\n", + "\n", + "라. 작성 서식 4 - 모의 검토 피드백 기록(다른 조 교관 역할)\n", + "\n", + "| **번호** | **검토 의견** | **반영 여부** | **반영 내용** |\n", + "| --- | --- | --- | --- |\n", + "| **1** | | | |\n", + "| **2** | | | |\n", + "| **3** | | | |\n", + "| **4** | | | |\n", + "| **5** | | | |\n", + "\n", + "| **점검 항목** | **확인** |\n", + "| --- | --- |\n", + "| 5개 장이 모두 있다 | ( ) |\n", + "| 흐름도·요약 카드 등 시각 자료가 들어갔다 | ( ) |\n", + "| 교안 수치가 M4·M9 기록과 일치한다 | ( ) |\n", + "| 모의 검토 피드백 반영 내역을 기록했다 | ( ) |\n", + "| 실제 시설 정보·도면·방호 제원이 없다 | ( ) |\n", + "| 수리 방법이 아니라 점검·판정·보고·요청 절차만 담았다 | ( ) |\n", + "| 밀폐 공간·높은 곳 안전 조치가 들어갔다 | ( ) |\n", + "\n", + "**4. 평가 채점표**\n", + "\n", + "미션마다 정량 기준은 PASS/FAIL로, 정성 기준은 상·중·하로 판정한다. 근거가 되는 기록지·서식 번호를 비고에 적는다.\n", + "\n", + "| **미션** | **구분** | **정량 기준(PASS/FAIL)** | **정성 기준(상·중·하)** | **판정** |\n", + "| --- | --- | --- | --- | --- |\n", + "| **M1. 군사시설 대피소, 주출입구, 공기 환기구의 방호 취약점 및 위협 요인 분석** | 필수 | 현장 체크 항목 15개 이상 정의, 7단계 전체에 대한 역할 분담 매트릭스 빈칸 0개(100% 매핑), 단계마다 책임자가 1명으로 지정되고 사례 카드 6건이 결함 유형으로 모두 분류되었는가. | 사례 카드의 문제점이 점검 요구사항에 반영되어 상황이 현실적이고 실제 부대 시설점검에 바로 적용할 수 있는가. | PASS / FAIL\n", + "상 / 중 / 하 |\n", + "| **M2. 시설 방호 등급별 현장점검 체크리스트(방폭문, 필터, 차폐막, 감시센서) 작성** | 필수 | SOP 7단계 모두에 역할별 세부 절차가 100% 적혔고, 4개 구성 요소(방폭문·필터·차폐막·감시센서)별 점검 항목과 판정 기준이 모두 작성되었으며, 필수 안전/보안 조치 8개가 100% 포함되고 교차 검토 지적 모호 표현이 모두 수정되었는가. | 점검 현장에서 한눈에 읽히도록 절차가 명확하고 간결하며, 긴급/일반 모드 구분이 분명한가. | PASS / FAIL\n", + "상 / 중 / 하 |\n", + "| **M3. 물리적 균열, 잠금장치 결함, 환기구 이물질 침투 가능성 현장 진단 실습** | 필수 | 균열·밀폐·환기구 3개 진단 항목 모두에 3단계 판정 기준이 있고, 주입 사건 10건 이상·정상/비정상 6:4 비율을 지켰으며, 워크스루에서 나온 개연성 문제가 모두 수정되었는가. | 상황 전개가 개연성 있고 훈련 내내 적절한 긴장도가 유지되도록 사건 간격과 난이도가 배치되었는가. | PASS / FAIL\n", + "상 / 중 / 하 |\n", + "| **M4. 1차 현장점검 수행 및 취약점 식별 보고서 작성(Dry-run)** | 선택 | SOP 단계 준수율 80% 이상, 결함 발견에서 반장 보고까지 보고 지연시간 3분 이내, 병목·혼선 구간 2건 이상 식별했는가. | 현장 판단이 정확하고 보고서가 표준 서식 순서대로 작성되었는가. | PASS / FAIL\n", + "상 / 중 / 하 |\n", + "| **M5. 모의 화학탄/드론 파편 침투 시뮬레이션 기반 차폐 밀폐성 검증** | 필수 | 주입된 기만·오경보 식별 성공률 85% 이상(12장 중 11장 이상), 실제 결함을 이상 없음으로 잘못 버린 건 0건, 판정마다 교차 확인 근거가 기록되었는가. | 의심 신호를 놓치지 않고 교차 확인 근거가 관측 사실과 기록 대조에 기반해 타당한가. | PASS / FAIL\n", + "상 / 중 / 하 |\n", + "| **M6. 시설 관리관 앞 긴급 보수 소요 전달 및 인계 브리핑 실습** | 선택 | 인계 필수 항목 누락률 0%, 인계 소요 시간 10분 이내, 인수자 퀴즈 5문항 중 4문항 이상 정답, 보수 소요가 위험도 순으로 정리되었는가. | 상황 인식이 끊기지 않고 시설 관리관의 질문에 명확히 답했는가. | PASS / FAIL\n", + "상 / 중 / 하 |\n", + "| **M7. 동시다발적 시설 손상 모의 상황에서의 우선 점검 스트레스 훈련** | 필수 | 비상 절차 전환 시간 1분 이내, 위험 조치 성공률 90% 이상, 지휘 공백 시 1분 안에 위임이 이루어지고 안전 수칙 위반 0건인가. | 위기 상황에서 인명 안전 중심으로 우선순위를 냉정하게 판단하고 역할 간 협업이 유지되었는가. | PASS / FAIL\n", + "상 / 중 / 하 |\n", + "| **M8. AAR 회의를 통한 점검 누락 항목 및 점검관별 판정 편차 분석** | 선택 | 개선점 5건 이상, 원인 분석 항목마다 기록지 근거 100% 연결, 회의록에 개인 비난 표현 0건인가. | 비난 없이 사실 중심으로 토의했고 원인 규명이 객관적인가. | PASS / FAIL\n", + "상 / 중 / 하 |\n", + "| **M9. 피드백을 반영한 재점검(Re-run) 및 긴급 방호 보강 조치 수행** | 필수 | 1차 대비 평균 조치 시간 30% 이상 단축, 오류 발생 건수 50% 이상 감소, 동일 평가표·동일 측정 방법을 사용했는가. | 점검 절차가 몸에 익어 역할 간 호흡이 맞고 말없이도 다음 행동이 이어지는가. | PASS / FAIL\n", + "상 / 중 / 하 |\n", + "| **M10. 대피소·출입구·환기구 방호 현장점검 표준 매뉴얼 최종 완성** | 필수 | 5개 장 100% 완비, 교안에 적힌 성과 수치가 M4·M9 기록과 일치하고, 모의 검토 피드백 반영 내역이 기록되었는가. | 교재가 완성도 있고 시각 자료가 이해를 돕으며 다른 부대에 전파할 가치가 있는가. | PASS / FAIL\n", + "상 / 중 / 하 |\n", + "\n", + "| 이수 기준(권장): 필수 미션 7개가 모두 PASS이고 정성 판정에 '하'가 2개 이하일 것. 선택 미션(M4·M6·M8)은 수행 시 가점으로 반영하며 세부 배점은 교관이 정한다. |\n", + "| --- |\n", + "\n", + "| **평가관** | 소속·성명: ( ) 서명: ( ) 일자: ( ) |\n", + "| --- | --- |\n", + "\n", + "| **종합 의견** | ( ) |\n", + "| --- | --- |\n", + "\n" + ] + } + ], + "metadata": { + "colab": { + "provenance": [] + }, + "kernelspec": { + "display_name": "Python 3", + "name": "python3" + }, + "language_info": { + "name": "python" + } + }, + "nbformat": 4, + "nbformat_minor": 0 +} \ No newline at end of file diff --git a/uploads/STR01/STR01_solution.ipynb b/uploads/STR01/STR01_solution.ipynb new file mode 100644 index 0000000..a287b7d --- /dev/null +++ b/uploads/STR01/STR01_solution.ipynb @@ -0,0 +1,2352 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# 🎯 [STR01] 데이터 기반 핵심성과지표 추세·목표 관리\n", + "### Google Colab 실습 환경 (전략·성과·예산관리 / Level 2)\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/STR01/STR01_solution.ipynb)\n", + "\n", + "---\n", + "\n", + "## 📌 1. 프로젝트 개요 및 배경\n", + "\n", + "| 항목 | 내용 |\n", + "| :--- | :--- |\n", + "| **과정 분류** | 전략·성과·예산관리 (Level 2) |\n", + "| **학습 소요 시간** | 10개 미션 (5일 / 총 28.5시간) |\n", + "| **환경 사양** | Python 3.10+, numpy, pandas, matplotlib, scipy, scikit-learn |\n", + "\n", + "### 📖 프로젝트 목표\n", + "미국 국방부(DoD) 예산실(Comptroller)은 성과관리 체계를 통해 핵심 성과지표를 정의하고 계획 대비 편차를 추적하며 마감 위험을 가시화해 왔다. 이 공개 사례의 학습 초점은 업무 지표 정의, 계획 대비 편차 분석, 마감 위험 가시화, 반복 집계 경감, 성과 측정이다. 본 프로젝트는 군수 분야의 핵심성과지표로 기준 보급일수(DOS)를 정하고, 교육용 가상 부대 20개의 5대 보급품 재고와 소모 자료를 코드로 만들어 목표 대비 추세와 결핍 위험 일자를 관리한다. 학습자는 KPI 스키마 정의, 데이터 정제, Baseline과 탐색적 분석, 결핍 일자 예측, 3단계 조기경보, 지도형 대시보드와 설명, 군수처장 승인 체계, 충격 시나리오 시험, AAR 임계 재보정, 자동 재학습 파이프라인을 차례로 수행한다. 최종 목표는 반복 집계에 드는 시간을 줄이면서 결핍 위험을 미리 알리고 물자 전환 결정은 반드시 사람이 승인하도록 하는 KPI 추세·목표 관리 도구를 완성하는 것이다.\n", + "\n", + "> **⚠️ [교육용 가상 데이터셋 원칙]**\n", + "> - 본 노트북의 모든 데이터(제원, 로그, 시나리오 등)는 보안 규정을 준수하여 **코드 내 난수 기반 가상 데이터로 생성**됩니다.\n", + "> - 실제 부대 위치, 작전 인가, 실제 전장 정보, 군사 기밀 데이터는 일체 포함되지 않습니다.\n", + "\n", + "---\n", + "\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# ============================================================================\n", + "# [Google Colab 환경 설정] 필수 라이브러리 설치 및 임포트\n", + "# ============================================================================\n", + "!pip install -q numpy pandas matplotlib scipy scikit-learn\n", + "\n", + "import os\n", + "import sys\n", + "import time\n", + "import math\n", + "import json\n", + "import re\n", + "import numpy as np\n", + "import pandas as pd\n", + "import matplotlib.pyplot as plt\n", + "import warnings\n", + "warnings.filterwarnings('ignore')\n", + "\n", + "# 그래프 영문 폰트 기본 설정 (환경 독립성 보장)\n", + "plt.rcParams['font.sans-serif'] = 'DejaVu Sans'\n", + "plt.rcParams['axes.unicode_minus'] = False\n", + "\n", + "print(f\"실행 환경 준비 완료 (Python {sys.version.split()[0]})\")\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 01. 부대별 5대 핵심 보급품(식량, 피복, 유류, 탄약, 수리부속) 재고 및 기준 보급일수(DOS) 스키마 정의\n", + "**소요시간**: 약 1.5시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "5대 핵심 보급품(식량, 유류, 탄약 등) 재고 및 기준 보급일수(DOS) 스키마를 정의한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **5대 핵심 보급품(식량, 피복, 유류, 탄약, 수리부속)의 재고와 기준 보급일수(DOS) 핵심성과지표(KPI)의 스키마와 데이터 딕셔너리를 정의하고 KPI 보고 업무 프로세스 맵을 작성한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **교육용 가상 부대 20개의 일일 재고·소모·보급 원장을 만들어 필드, 형식, 허용값, 의미를 데이터 딕셔너리로 정리하고 규격화하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `docs/str01_logistics_domain_spec.md 및 데이터 딕셔너리` 형태의 결과물을 완성한다.\n", + "\n", + "### 🔍 평가 및 검증 기준 (PASS 조건)\n", + "#### 미션 판정\n", + "\n", + "**평가 구분:** 필수\n", + "\n", + "**핵심 평가영역:** 군수 실무 업무 프로세스와의 일치성 및 분석 목표의 명확성\n", + "\n", + "#### 미션 PASS 조건\n", + "\n", + "- 정량 기준: 업무 프로세스 맵 작성 완료, 필요 피처(Feature) 목록 20개 이상 정의\n", + "\n", + "\n", + "- 진행 기준: 도메인 분석서 및 데이터 요구명세서 승인\n", + "\n", + "\n", + "- 검증 방법: 군수 담당 실무자 인터뷰 및 기존 전산체계 데이터 구조 대조\n", + "\n", + "\n", + "#### 채점자 판정 메모\n", + "\n", + "> 단순 코드 실행 여부뿐만 아니라 출력된 수치와 근거 자료가 군 실무 요구사항에 부합하는지 면밀히 확인한다.\n", + "\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "\"\"\"부대별 핵심 보급품 5종(식량·피복·유류·탄약·수리부속)의 재고와 기준 보급일수(DOS) 핵심성과지표 스키마와 데이터 딕셔너리를 정의하고, 피처 목록 20개 이상과 KPI 보고 업무 프로세스 맵(AS-IS/TO-BE)을 작성한다.\"\"\"\n", + "import numpy as np\n", + "import pandas as pd\n", + "\n", + "# ---------------------------------------------------------------------------\n", + "# [교육용가상자료] 가상 부대 20개 x 핵심 보급품 5종 일일 재고·소모 시뮬레이터 (실제 부대·수량·기준일수 아님)\n", + "# 핵심성과지표(KPI) = 기준 보급일수(DOS) = 현재 재고 / 최근 평균 일일 소모량. 목표 DOS는 교육용 가정값이다.\n", + "# 결핍 위험 일수 H = 보급이 없다고 가정할 때 현재 재고가 바닥나기까지 남은 일수(미래 실제 소모량으로 계산한 정답).\n", + "# ---------------------------------------------------------------------------\n", + "ITEMS = {\"식량\": (100, 0.3, 10), \"피복\": (20, 0.1, 15), \"유류\": (300, 1.2, 7), \"탄약\": (60, 1.5, 6), \"수리부속\": (15, 0.5, 12)} # (기본 일소모, 템포 민감도, 목표 DOS)\n", + "N_UNIT, DAYS = 20, 160\n", + "UNITS = [f\"가상{u:02d}부대\" for u in range(1, N_UNIT + 1)]\n", + "\n", + "\n", + "def make_data(seed=1, n_unit=N_UNIT, days=DAYS):\n", + " \"\"\"일 단위 재고 원장: unit, item, day, tempo(실제 작전 템포), plan(사전 계획 템포), wx(악천후), consume, resupply, stock.\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " rows = []\n", + " for u in range(1, n_unit + 1):\n", + " s, ph = rng.uniform(0.7, 1.5), rng.uniform(0, 6.28)\n", + " surge = np.zeros(days)\n", + " for st in rng.choice(days - 10, 4, replace=False):\n", + " surge[st:st + int(rng.integers(5, 10))] = rng.uniform(0.4, 0.8)\n", + " tempo = 1 + 0.25 * np.sin(2 * np.pi * np.arange(days) / 30 + ph) + surge\n", + " plan = tempo + rng.normal(0, 0.08, days)\n", + " wx = (rng.random(days) < 0.15).astype(int)\n", + " for it, (b, sens, tg) in ITEMS.items():\n", + " stock, off = tg * b * s * rng.uniform(0.8, 1.2), int(rng.integers(0, 4))\n", + " for t in range(days):\n", + " c = b * s * (1 + sens * (tempo[t] - 1)) * (1 + 0.12 * wx[t]) * rng.normal(1, 0.08)\n", + " r = max(tg * b * s - stock, 0) * rng.uniform(0.9, 1.1) * (0.5 if rng.random() < 0.08 else 1) if (t % 4 == off and t > 0) else 0.0 # 4일 주기 정기 보급: 목표 재고까지 채움(가끔 수송 차질로 절반)\n", + " stock = max(stock + r - c, 0.0)\n", + " rows.append((f\"가상{u:02d}부대\", it, t, tempo[t], plan[t], wx[t], c, r, stock))\n", + " return pd.DataFrame(rows, columns=[\"unit\", \"item\", \"day\", \"tempo\", \"plan\", \"wx\", \"consume\", \"resupply\", \"stock\"])\n", + "\n", + "\n", + "def make_samples(d, lo=21, hi=125):\n", + " \"\"\"(부대, 품목, 기준일 t0)별 특성(피처)과 정답 H. 피처는 t0 시점까지 알 수 있는 값과 사전 계획·기상 예보만 사용한다.\"\"\"\n", + " out = []\n", + " for (u, it), g in d.groupby([\"unit\", \"item\"]):\n", + " c, stk, rs = g[\"consume\"].values, g[\"stock\"].values, g[\"resupply\"].values\n", + " pl, wx, tp = g[\"plan\"].values, g[\"wx\"].values, g[\"tempo\"].values\n", + " cs = np.cumsum(c)\n", + " tg = ITEMS[it][2] * ITEMS[it][0]\n", + " for t0 in range(lo, hi):\n", + " idx = np.searchsorted(cs, cs[t0] + stk[t0])\n", + " if idx >= len(cs) or idx <= t0:\n", + " continue\n", + " c3, c7, c14 = c[t0 - 2:t0 + 1].mean(), c[t0 - 6:t0 + 1].mean(), c[t0 - 13:t0 + 1].mean()\n", + " last = [k for k in range(t0, t0 - 15, -1) if rs[k] > 0]\n", + " out.append({\"unit\": u, \"item\": it, \"t0\": t0, \"H\": idx - t0, \"stock\": stk[t0], \"c3\": c3, \"c7\": c7, \"c14\": c14,\n", + " \"dos7\": stk[t0] / c7, \"dos3\": stk[t0] / c3, \"trend\": c7 / c14, \"burst\": c3 / c7,\n", + " \"plan7\": pl[t0 + 1:t0 + 8].mean(), \"plan_ratio\": pl[t0 + 1:t0 + 8].mean() / tp[t0], \"tempo0\": tp[t0],\n", + " \"wx_fc\": wx[t0 + 1:t0 + 8].mean(), \"since_rs\": (t0 - last[0]) if last else 15,\n", + " \"rs_avg\": rs[t0 - 13:t0 + 1].sum() / 14, \"fill\": stk[t0] / tg, \"item_code\": list(ITEMS).index(it)})\n", + " return pd.DataFrame(out)\n", + "\n", + "\n", + "def mape(a, b):\n", + " return float(np.mean(np.abs(a - b) / a))\n", + "\n", + "d = make_data()\n", + "s = make_samples(d)\n", + "\n", + "# 1) 스키마(데이터 딕셔너리): 필드, 형식, 허용 범위, 의미\n", + "DICT = [(\"unit\", \"str\", \"가상NN부대\", \"보고 부대\"), (\"item\", \"str\", \"식량/피복/유류/탄약/수리부속\", \"핵심 보급품 5종\"),\n", + " (\"day\", \"int\", \"0~159\", \"기준 일자(일 단위 일련번호)\"), (\"tempo\", \"float\", \"0.3~2.5\", \"실제 작전 템포 지수(1.0=평시)\"),\n", + " (\"plan\", \"float\", \"0.3~2.5\", \"사전 계획 템포(미리 알 수 있는 값)\"), (\"wx\", \"int\", \"0/1\", \"악천후 여부\"),\n", + " (\"consume\", \"float\", \">=0\", \"일일 소모량\"), (\"resupply\", \"float\", \">=0\", \"당일 보급 입고량\"), (\"stock\", \"float\", \">=0\", \"일 마감 재고\"),\n", + " (\"target_dos\", \"float\", \"5~20일\", \"기준 보급일수 목표(KPI 목표, 교육용 가정)\"), (\"dos\", \"float\", \">=0\", \"KPI = 재고 / 최근 7일 평균 소모량\")]\n", + "dd = pd.DataFrame(DICT, columns=[\"필드\", \"형식\", \"허용값\", \"의미\"])\n", + "print(\"[데이터 딕셔너리] 필드\", len(dd), \"개 [교육용가상자료]\")\n", + "print(dd.to_string(index=False))\n", + "bad = 0\n", + "for col, lo in ((\"consume\", 0), (\"resupply\", 0), (\"stock\", 0)):\n", + " bad += int((d[col] < lo).sum())\n", + "bad += int((~d[\"item\"].isin(ITEMS)).sum()) + int((~d[\"wx\"].isin([0, 1])).sum())\n", + "print(f\"\\n샘플 {len(d)}행({N_UNIT}개 부대 x 5종 x {DAYS}일) 스키마 검증 위반 {bad}건\")\n", + "print(\"품목별 목표 DOS(KPI 목표, 가정):\", {k: v[2] for k, v in ITEMS.items()})\n", + "\n", + "# 2) KPI 정의\n", + "print(\"\\n[KPI 정의] DOS = 재고 / 최근 7일 평균 소모량, 편차 = 현재 DOS - 목표 DOS, 달성률 = DOS / 목표 DOS\")\n", + "last = d[d[\"day\"] == 100].copy()\n", + "last[\"c7\"] = [d[(d[\"unit\"] == r.unit) & (d[\"item\"] == r.item) & (d[\"day\"].between(94, 100))][\"consume\"].mean() for r in last.itertuples()]\n", + "last[\"dos\"] = last[\"stock\"] / last[\"c7\"]\n", + "last[\"target\"] = last[\"item\"].map(lambda i: ITEMS[i][2]); last[\"gap\"] = last[\"dos\"] - last[\"target\"]; last[\"rate\"] = last[\"dos\"] / last[\"target\"]\n", + "print(\"100일째 품목별 평균 DOS와 달성률:\")\n", + "print(last.groupby(\"item\").agg(평균DOS=(\"dos\", \"mean\"), 목표=(\"target\", \"first\"), 평균달성률=(\"rate\", \"mean\"), 미달부대수=(\"rate\", lambda x: int((x < 0.5).sum()))).round(2).to_string())\n", + "\n", + "# 3) 피처 목록(분석·예측에 쓸 후보): 자동 생성 열 + 추가 후보\n", + "feat_auto = [c for c in s.columns if c not in (\"unit\", \"item\", \"t0\", \"H\")]\n", + "extra = [\"요일 효과\", \"월말 보급 주기 여부\", \"수송 거리(가상)\", \"기후 지수\", \"부대 규모(가상)\", \"최근 긴급 불출 횟수\", \"정비 지시서 건수\", \"훈련 일정 밀도\"]\n", + "feats = feat_auto + extra\n", + "print(f\"\\n[피처 목록] 자동 산출 {len(feat_auto)}개 + 추가 후보 {len(extra)}개 = {len(feats)}개\")\n", + "print(\" \", \", \".join(feats))\n", + "\n", + "# 4) KPI 보고 업무 프로세스 맵(AS-IS / TO-BE, 소요 분은 가정값)\n", + "steps = [(\"부대별 재고 보고 수신\", 30, \"표준 형식 자동 수집\", 2), (\"품목별 일소모량 확인\", 40, \"소모율 자동 정규화\", 2), (\"엑셀로 DOS 재계산\", 60, \"KPI 자동 계산\", 1),\n", + " (\"목표 대비 편차 표 작성\", 45, \"편차 자동 산출\", 1), (\"기준 미달 부대 선별\", 35, \"미달 규칙 자동 적용\", 1), (\"결핍 예상 시점 수기 추정\", 70, \"예측 모델 자동 산출\", 3),\n", + " (\"악천후·작전 템포 반영 검토\", 50, \"계획·예보 피처 자동 반영\", 3), (\"경보 등급 판단\", 30, \"주의/경계/심각 자동 분류\", 1),\n", + " (\"군수처 보고서 작성\", 80, \"대시보드 자동 갱신\", 2), (\"보고서 수정·재집계\", 55, \"단일 원천 사용\", 3), (\"처장 승인·조치 지시\", 40, \"HITL 승인 화면\", 15)]\n", + "proc = pd.DataFrame(steps, columns=[\"프로세스 단계\", \"AS-IS(분)\", \"TO-BE 개선\", \"TO-BE(분)\"]); proc.index = range(1, len(proc) + 1)\n", + "a, t = proc[\"AS-IS(분)\"].sum(), proc[\"TO-BE(분)\"].sum()\n", + "print(\"\\n[업무 프로세스 맵]\")\n", + "print(proc.to_string())\n", + "print(f\"\\n프로세스 단계 {len(proc)}개, 1회 집계 AS-IS {a}분 -> TO-BE {t}분({1 - t / a:.0%} 단축 예상, 가정값)\")\n", + "print(f\"목표(프로세스 맵 작성, 피처 20개 이상): 프로세스 맵 {'완료' if len(proc) >= 10 else '미달'}, 피처 {len(feats)}개 {'달성' if len(feats) >= 20 else '미달'}\")\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 01 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + "[데이터 딕셔너리] 필드 11 개 [교육용가상자료]\n", + " 필드 형식 허용값 의미\n", + " unit str 가상NN부대 보고 부대\n", + " item str 식량/피복/유류/탄약/수리부속 핵심 보급품 5종\n", + " day int 0~159 기준 일자(일 단위 일련번호)\n", + " tempo float 0.3~2.5 실제 작전 템포 지수(1.0=평시)\n", + " plan float 0.3~2.5 사전 계획 템포(미리 알 수 있는 값)\n", + " wx int 0/1 악천후 여부\n", + " consume float >=0 일일 소모량\n", + " resupply float >=0 당일 보급 입고량\n", + " stock float >=0 일 마감 재고\n", + "target_dos float 5~20일 기준 보급일수 목표(KPI 목표, 교육용 가정)\n", + " dos float >=0 KPI = 재고 / 최근 7일 평균 소모량\n", + "\n", + "샘플 16000행(20개 부대 x 5종 x 160일) 스키마 검증 위반 0건\n", + "품목별 목표 DOS(KPI 목표, 가정): {'식량': 10, '피복': 15, '유류': 7, '탄약': 6, '수리부속': 12}\n", + "\n", + "[KPI 정의] DOS = 재고 / 최근 7일 평균 소모량, 편차 = 현재 DOS - 목표 DOS, 달성률 = DOS / 목표 DOS\n", + "100일째 품목별 평균 DOS와 달성률:\n", + " 평균DOS 목표 평균달성률 미달부대수\n", + "item \n", + "수리부속 7.91 12 0.66 5\n", + "식량 6.39 10 0.64 3\n", + "유류 3.80 7 0.54 9\n", + "탄약 2.06 6 0.34 12\n", + "피복 11.83 15 0.79 0\n", + "\n", + "[피처 목록] 자동 산출 16개 + 추가 후보 8개 = 24개\n", + " stock, c3, c7, c14, dos7, dos3, trend, burst, plan7, plan_ratio, tempo0, wx_fc, since_rs, rs_avg, fill, item_code, 요일 효과, 월말 보급 주기 여부, 수송 거리(가상), 기후 지수, 부대 규모(가상), 최근 긴급 불출 횟수, 정비 지시서 건수, 훈련 일정 밀도\n", + "\n", + "[업무 프로세스 맵]\n", + " 프로세스 단계 AS-IS(분) TO-BE 개선 TO-BE(분)\n", + "1 부대별 재고 보고 수신 30 표준 형식 자동 수집 2\n", + "2 품목별 일소모량 확인 40 소모율 자동 정규화 2\n", + "3 엑셀로 DOS 재계산 60 KPI 자동 계산 1\n", + "4 목표 대비 편차 표 작성 45 편차 자동 산출 1\n", + "5 기준 미달 부대 선별 35 미달 규칙 자동 적용 1\n", + "6 결핍 예상 시점 수기 추정 70 예측 모델 자동 산출 3\n", + "7 악천후·작전 템포 반영 검토 50 계획·예보 피처 자동 반영 3\n", + "8 경보 등급 판단 30 주의/경계/심각 자동 분류 1\n", + "... (이하 생략)\n", + "```\n", + "
\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 02. 부대별 일일 소모율 정규화 및 재고 변동 데이터 전처리 모듈 구현\n", + "**소요시간**: 약 2.0시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "부대별 일일 소모율 정규화 및 재고 변동 데이터 전처리 모듈을 구현한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **부대별 일일 소모율을 정규화하고 재고 변동 데이터를 정제하는 전처리 모듈을 구현하며, 군수 특수 예외는 보존한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **중복, 누락 일자, 소모량 결측, 단위 혼용, 음수 재고, 품목명 오타, 태그 없는 이상 급증을 일부러 섞은 가상 원장을 만들어 정제 규칙 10종을 적용 수행하고 검증하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `src/etl/str01_data_cleansing.py 및 정제 품질 보고서` 형태의 결과물을 완성한다.\n", + "\n", + "### 🔍 평가 및 검증 기준 (PASS 조건)\n", + "#### 미션 판정\n", + "\n", + "**평가 구분:** 필수\n", + "\n", + "**핵심 평가영역:** 군수 특수 예외(비계획 정비, 긴급 불출 등) 데이터 보존의 타당성\n", + "\n", + "#### 미션 PASS 조건\n", + "\n", + "- 정량 기준: 데이터 정제 규칙 10종 적용, 데이터 정합성 검증 통과율 100%\n", + "\n", + "\n", + "- 진행 기준: ETL 파이프라인 구현 및 정제된 마스터 데이터셋 구축\n", + "\n", + "\n", + "- 검증 방법: 전후 데이터 통계량(기술통계) 비교 및 무결성 검증\n", + "\n", + "\n", + "#### 채점자 판정 메모\n", + "\n", + "> 단순 코드 실행 여부뿐만 아니라 출력된 수치와 근거 자료가 군 실무 요구사항에 부합하는지 면밀히 확인한다.\n", + "\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "\"\"\"중복·결측·단위 혼용·음수 재고·품목명 오타 등이 섞인 가상 일일 재고 원장을 10종 정제 규칙으로 정제하고 일소모율을 정규화하되, 긴급 불출과 비계획 정비 같은 군수 특수 예외는 삭제하지 않고 보존하며 정합성 검증 통과율을 보고한다.\"\"\"\n", + "import numpy as np\n", + "import pandas as pd\n", + "\n", + "# ---------------------------------------------------------------------------\n", + "# [교육용가상자료] 가상 부대 20개 x 핵심 보급품 5종 일일 재고·소모 시뮬레이터 (실제 부대·수량·기준일수 아님)\n", + "# 핵심성과지표(KPI) = 기준 보급일수(DOS) = 현재 재고 / 최근 평균 일일 소모량. 목표 DOS는 교육용 가정값이다.\n", + "# 결핍 위험 일수 H = 보급이 없다고 가정할 때 현재 재고가 바닥나기까지 남은 일수(미래 실제 소모량으로 계산한 정답).\n", + "# ---------------------------------------------------------------------------\n", + "ITEMS = {\"식량\": (100, 0.3, 10), \"피복\": (20, 0.1, 15), \"유류\": (300, 1.2, 7), \"탄약\": (60, 1.5, 6), \"수리부속\": (15, 0.5, 12)} # (기본 일소모, 템포 민감도, 목표 DOS)\n", + "N_UNIT, DAYS = 20, 160\n", + "UNITS = [f\"가상{u:02d}부대\" for u in range(1, N_UNIT + 1)]\n", + "\n", + "\n", + "def make_data(seed=1, n_unit=N_UNIT, days=DAYS):\n", + " \"\"\"일 단위 재고 원장: unit, item, day, tempo(실제 작전 템포), plan(사전 계획 템포), wx(악천후), consume, resupply, stock.\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " rows = []\n", + " for u in range(1, n_unit + 1):\n", + " s, ph = rng.uniform(0.7, 1.5), rng.uniform(0, 6.28)\n", + " surge = np.zeros(days)\n", + " for st in rng.choice(days - 10, 4, replace=False):\n", + " surge[st:st + int(rng.integers(5, 10))] = rng.uniform(0.4, 0.8)\n", + " tempo = 1 + 0.25 * np.sin(2 * np.pi * np.arange(days) / 30 + ph) + surge\n", + " plan = tempo + rng.normal(0, 0.08, days)\n", + " wx = (rng.random(days) < 0.15).astype(int)\n", + " for it, (b, sens, tg) in ITEMS.items():\n", + " stock, off = tg * b * s * rng.uniform(0.8, 1.2), int(rng.integers(0, 4))\n", + " for t in range(days):\n", + " c = b * s * (1 + sens * (tempo[t] - 1)) * (1 + 0.12 * wx[t]) * rng.normal(1, 0.08)\n", + " r = max(tg * b * s - stock, 0) * rng.uniform(0.9, 1.1) * (0.5 if rng.random() < 0.08 else 1) if (t % 4 == off and t > 0) else 0.0 # 4일 주기 정기 보급: 목표 재고까지 채움(가끔 수송 차질로 절반)\n", + " stock = max(stock + r - c, 0.0)\n", + " rows.append((f\"가상{u:02d}부대\", it, t, tempo[t], plan[t], wx[t], c, r, stock))\n", + " return pd.DataFrame(rows, columns=[\"unit\", \"item\", \"day\", \"tempo\", \"plan\", \"wx\", \"consume\", \"resupply\", \"stock\"])\n", + "\n", + "\n", + "def make_samples(d, lo=21, hi=125):\n", + " \"\"\"(부대, 품목, 기준일 t0)별 특성(피처)과 정답 H. 피처는 t0 시점까지 알 수 있는 값과 사전 계획·기상 예보만 사용한다.\"\"\"\n", + " out = []\n", + " for (u, it), g in d.groupby([\"unit\", \"item\"]):\n", + " c, stk, rs = g[\"consume\"].values, g[\"stock\"].values, g[\"resupply\"].values\n", + " pl, wx, tp = g[\"plan\"].values, g[\"wx\"].values, g[\"tempo\"].values\n", + " cs = np.cumsum(c)\n", + " tg = ITEMS[it][2] * ITEMS[it][0]\n", + " for t0 in range(lo, hi):\n", + " idx = np.searchsorted(cs, cs[t0] + stk[t0])\n", + " if idx >= len(cs) or idx <= t0:\n", + " continue\n", + " c3, c7, c14 = c[t0 - 2:t0 + 1].mean(), c[t0 - 6:t0 + 1].mean(), c[t0 - 13:t0 + 1].mean()\n", + " last = [k for k in range(t0, t0 - 15, -1) if rs[k] > 0]\n", + " out.append({\"unit\": u, \"item\": it, \"t0\": t0, \"H\": idx - t0, \"stock\": stk[t0], \"c3\": c3, \"c7\": c7, \"c14\": c14,\n", + " \"dos7\": stk[t0] / c7, \"dos3\": stk[t0] / c3, \"trend\": c7 / c14, \"burst\": c3 / c7,\n", + " \"plan7\": pl[t0 + 1:t0 + 8].mean(), \"plan_ratio\": pl[t0 + 1:t0 + 8].mean() / tp[t0], \"tempo0\": tp[t0],\n", + " \"wx_fc\": wx[t0 + 1:t0 + 8].mean(), \"since_rs\": (t0 - last[0]) if last else 15,\n", + " \"rs_avg\": rs[t0 - 13:t0 + 1].sum() / 14, \"fill\": stk[t0] / tg, \"item_code\": list(ITEMS).index(it)})\n", + " return pd.DataFrame(out)\n", + "\n", + "\n", + "def mape(a, b):\n", + " return float(np.mean(np.abs(a - b) / a))\n", + "\n", + "clean_truth = make_data()\n", + "rng = np.random.default_rng(2)\n", + "raw = clean_truth.copy()\n", + "raw[\"tag\"] = \"\"\n", + "n = len(raw)\n", + "inj = {}\n", + "perm = iter(rng.permutation(n)) # 오염 종류끼리 같은 행이 겹치지 않도록 한 번에 뽑는다\n", + "def pick(k):\n", + " return np.array([next(perm) for _ in range(k)])\n", + "# --- 오염 주입(원인별 건수 기록) ---\n", + "i_ex = pick(40); raw.loc[i_ex, \"consume\"] = raw.loc[i_ex, \"consume\"] * 2.5; raw.loc[i_ex[:20], \"tag\"] = \"긴급불출\"; raw.loc[i_ex[20:], \"tag\"] = \"비계획정비\"; inj[\"특수 예외(보존 대상)\"] = 40\n", + "i_unit = pick(30); raw.loc[i_unit, \"consume\"] = raw.loc[i_unit, \"consume\"] * 1000; inj[\"단위 혼용(1000배)\"] = 30\n", + "i_out = pick(30); raw.loc[i_out, \"consume\"] = raw.loc[i_out, \"consume\"] * 6; inj[\"태그 없는 이상 급증\"] = 30\n", + "i_na = pick(200); raw.loc[i_na, \"consume\"] = np.nan; inj[\"소모량 결측\"] = 200\n", + "i_neg = pick(20); raw.loc[i_neg, \"stock\"] = -raw.loc[i_neg, \"stock\"] - 1; inj[\"음수 재고\"] = 20\n", + "i_typo = pick(60); raw.loc[i_typo, \"item\"] = raw.loc[i_typo, \"item\"].map({\"식량\": \"식 량\", \"피복\": \"피복류\", \"유류\": \"유 류\", \"탄약\": \"탄 약\", \"수리부속\": \"수리 부속\"}); inj[\"품목명 오타\"] = 60\n", + "dup = raw.sample(100, random_state=2); inj[\"중복 행\"] = 100\n", + "miss = pick(120)\n", + "gap_rows = raw.iloc[miss]; inj[\"누락 일자 행\"] = 120\n", + "raw = pd.concat([raw.drop(raw.index[miss]), dup]).sample(frac=1, random_state=3).reset_index(drop=True)\n", + "print(f\"[정제 전] {len(raw)}행(원래 {len(clean_truth)}행), 주입 오염: {inj} [교육용가상자료]\")\n", + "\n", + "# --- 정제 규칙 10종 ---\n", + "log = {}\n", + "df = raw.copy()\n", + "R = []\n", + "b = len(df); df = df.drop_duplicates([\"unit\", \"item\", \"day\"]); log[\"R1 중복 행 제거\"] = b - len(df)\n", + "FIX = {\"식 량\": \"식량\", \"피복류\": \"피복\", \"유 류\": \"유류\", \"탄 약\": \"탄약\", \"수리 부속\": \"수리부속\"}\n", + "log[\"R2 품목명 표준화\"] = int(df[\"item\"].isin(FIX).sum()); df[\"item\"] = df[\"item\"].replace(FIX)\n", + "full = pd.MultiIndex.from_product([UNITS, list(ITEMS), range(DAYS)], names=[\"unit\", \"item\", \"day\"])\n", + "log[\"R3 누락 일자 보충\"] = len(full) - len(df)\n", + "df = df.set_index([\"unit\", \"item\", \"day\"]).reindex(full).reset_index(); df[\"tag\"] = df[\"tag\"].fillna(\"\")\n", + "for c in (\"tempo\", \"plan\", \"wx\", \"resupply\"):\n", + " df[c] = df.groupby([\"unit\", \"item\"])[c].transform(lambda s: s.interpolate(limit_direction=\"both\"))\n", + "df[\"wx\"] = df[\"wx\"].round().astype(int)\n", + "med = df.groupby([\"unit\", \"item\"])[\"consume\"].transform(\"median\")\n", + "unit_err = (df[\"consume\"] > 300 * med) & (df[\"tag\"] == \"\")\n", + "log[\"R4 단위 혼용 보정(1000배 -> /1000)\"] = int(unit_err.sum()); df.loc[unit_err, \"consume\"] /= 1000\n", + "out = (df[\"consume\"] > 4 * med) & (df[\"tag\"] == \"\")\n", + "log[\"R5 태그 없는 이상 급증 상한 처리(중앙값 x3)\"] = int(out.sum()); df.loc[out, \"consume\"] = 3 * med[out]\n", + "log[\"R6 특수 예외 보존(긴급불출·비계획정비)\"] = int((df[\"tag\"] != \"\").sum())\n", + "log[\"R7 소모량 결측 선형 보간\"] = int(df[\"consume\"].isna().sum())\n", + "df[\"consume\"] = df.groupby([\"unit\", \"item\"])[\"consume\"].transform(lambda s: s.interpolate(limit_direction=\"both\"))\n", + "log[\"R8 음수 재고 -> 흐름 재계산 대상\"] = int((df[\"stock\"] < 0).sum())\n", + "s0 = clean_truth.groupby([\"unit\", \"item\"])[\"stock\"].first()\n", + "stk = []\n", + "for (u, it), g in df.groupby([\"unit\", \"item\"], sort=False): # R9 재고 흐름 일관성: stock = max(전일 + 보급 - 소모, 0)\n", + " c, r, cur = g[\"consume\"].values, g[\"resupply\"].values, s0[(u, it)]\n", + " for t in range(len(g)):\n", + " if t > 0:\n", + " cur = max(cur + r[t] - c[t], 0.0)\n", + " else: # 첫날은 보고 재고를 쓰되 결측·음수면 초기 재고 사용\n", + " first = g[\"stock\"].values[0]\n", + " cur = first if first >= 0 else s0[(u, it)]\n", + " stk.append(cur)\n", + "log[\"R9 재고 흐름 재계산\"] = len(df)\n", + "df[\"stock\"] = stk\n", + "df[\"base\"] = df.groupby([\"unit\", \"item\"])[\"consume\"].transform(lambda s: s.iloc[:14].median())\n", + "df[\"rate\"] = df[\"consume\"] / df[\"base\"] # R10 일소모율 정규화(부대·품목별 기준 소모량 대비)\n", + "log[\"R10 일소모율 정규화\"] = len(df)\n", + "print(\"\\n[정제 규칙 10종 적용 건수]\")\n", + "for k, v in log.items():\n", + " print(f\" {k}: {v}\")\n", + "\n", + "# --- 정합성 검증(정제 후) ---\n", + "chk = {\"중복 키 없음\": not df.duplicated([\"unit\", \"item\", \"day\"]).any(), \"품목명 표준\": df[\"item\"].isin(ITEMS).all(),\n", + " \"일자 연속(160일)\": df.groupby([\"unit\", \"item\"])[\"day\"].count().eq(DAYS).all(), \"결측 없음\": not df[[\"consume\", \"stock\", \"resupply\", \"tempo\"]].isna().any().any(),\n", + " \"소모량 0 이상\": (df[\"consume\"] >= 0).all(), \"재고 0 이상\": (df[\"stock\"] >= 0).all(),\n", + " \"소모율 합리 범위(0.2~6)\": df[\"rate\"].between(0.2, 6.0).all(),\n", + " \"특수 예외 태그 보존\": (df[\"tag\"] != \"\").sum() == 40, \"단위 이상 없음(1000배 급증 0건)\": not (df[\"consume\"] > 50 * df[\"base\"]).any(),\n", + " \"키 조합 100개\": df.groupby([\"unit\", \"item\"]).ngroups == 100}\n", + "for k, v in chk.items():\n", + " print(f\" 검증 {k}: {'통과' if v else '실패'}\")\n", + "rate = sum(chk.values()) / len(chk)\n", + "print(f\"정합성 검증 통과율 {rate:.0%} ({sum(chk.values())}/{len(chk)})\")\n", + "cmp = df.merge(clean_truth, on=[\"unit\", \"item\", \"day\"], suffixes=(\"\", \"_t\"))\n", + "bad_rows = cmp[(cmp[\"tag\"] == \"\") ]\n", + "print(f\"복원 품질: 일반 행의 소모량 평균 절대 오차 {float((bad_rows['consume'] - bad_rows['consume_t']).abs().mean()):.3f} (평균 소모량 대비 {float((bad_rows['consume'] - bad_rows['consume_t']).abs().mean() / bad_rows['consume_t'].mean()):.2%})\")\n", + "ex = cmp[cmp[\"tag\"] != \"\"]\n", + "print(f\"특수 예외 {len(ex)}건은 값을 그대로 보존(지우거나 깎지 않음): 소모 급증 평균 {float((ex['consume'] / ex['base']).mean()):.1f}배 유지\")\n", + "print(f\"\\n목표(정제 규칙 10종 적용, 정합성 검증 통과율 100%): {'달성' if len(log) >= 10 and rate == 1 else '미달'}\")\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 02 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + "[정제 전] 15980행(원래 16000행), 주입 오염: {'특수 예외(보존 대상)': 40, '단위 혼용(1000배)': 30, '태그 없는 이상 급증': 30, '소모량 결측': 200, '음수 재고': 20, '품목명 오타': 60, '중복 행': 100, '누락 일자 행': 120} [교육용가상자료]\n", + "\n", + "[정제 규칙 10종 적용 건수]\n", + " R1 중복 행 제거: 99\n", + " R2 품목명 표준화: 60\n", + " R3 누락 일자 보충: 119\n", + " R4 단위 혼용 보정(1000배 -> /1000): 30\n", + " R5 태그 없는 이상 급증 상한 처리(중앙값 x3): 30\n", + " R6 특수 예외 보존(긴급불출·비계획정비): 40\n", + " R7 소모량 결측 선형 보간: 319\n", + " R8 음수 재고 -> 흐름 재계산 대상: 20\n", + " R9 재고 흐름 재계산: 16000\n", + " R10 일소모율 정규화: 16000\n", + " 검증 중복 키 없음: 통과\n", + " 검증 품목명 표준: 통과\n", + " 검증 일자 연속(160일): 통과\n", + " 검증 결측 없음: 통과\n", + " 검증 소모량 0 이상: 통과\n", + " 검증 재고 0 이상: 통과\n", + " 검증 소모율 합리 범위(0.2~6): 통과\n", + " 검증 특수 예외 태그 보존: 통과\n", + " 검증 단위 이상 없음(1000배 급증 0건): 통과\n", + " 검증 키 조합 100개: 통과\n", + "정합성 검증 통과율 100% (10/10)\n", + "복원 품질: 일반 행의 소모량 평균 절대 오차 0.715 (평균 소모량 대비 0.56%)\n", + "특수 예외 40건은 값을 그대로 보존(지우거나 깎지 않음): 소모 급증 평균 2.4배 유지\n", + "\n", + "목표(정제 규칙 10종 적용, 정합성 검증 통과율 100%): 달성\n", + "```\n", + "
\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 03. 단순 잔여 DOS 계산 및 1차 기준 미달 부대 식별 Baseline 알고리즘 구현\n", + "**소요시간**: 약 2.5시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "단순 잔여 DOS 계산 및 기준 미달 부대 식별 Baseline 알고리즘을 구현한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **재고와 최근 소모량으로 단순 DOS를 계산해 기준 미달 부대를 식별하는 Baseline을 만들고, 탐색적 분석(EDA)으로 의미 있는 피처를 찾는다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **목표 DOS 대비 달성률이 50% 미만인 부대·품목을 1차 기준 미달로 식별 수행하고 검증하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `notebooks/str01_eda_analysis.ipynb 및 EDA 요약 리포트` 형태의 결과물을 완성한다.\n", + "\n", + "### 🔍 평가 및 검증 기준 (PASS 조건)\n", + "#### 미션 판정\n", + "\n", + "**평가 구분:** 필수\n", + "\n", + "**핵심 평가영역:** 군수 의사결정에 직관적인 인사이트 제공 수준\n", + "\n", + "#### 미션 PASS 조건\n", + "\n", + "- 정량 기준: EDA 시각화 차트 8종 이상 생성, 유의미한 상관관계 피처 5개 이상 도출\n", + "\n", + "\n", + "- 진행 기준: EDA 노트북 실행 완료 및 핵심 발견점(Key Findings) 문서화\n", + "\n", + "\n", + "- 검증 방법: 통계적 가설 검정(p-value, 상관계수)을 통한 유의성 검증\n", + "\n", + "\n", + "#### 채점자 판정 메모\n", + "\n", + "> 단순 코드 실행 여부뿐만 아니라 출력된 수치와 근거 자료가 군 실무 요구사항에 부합하는지 면밀히 확인한다.\n", + "\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "\"\"\"재고와 최근 평균 소모량으로 잔여 보급일수(DOS)를 계산하고 목표 대비 기준 미달 부대를 식별하는 Baseline을 만들며, 8종 탐색 차트와 결핍 위험 일수와의 상관 분석으로 의미 있는 피처 5개 이상을 도출한다.\"\"\"\n", + "import numpy as np\n", + "import pandas as pd\n", + "import matplotlib.pyplot as plt\n", + "\n", + "# ---------------------------------------------------------------------------\n", + "# [교육용가상자료] 가상 부대 20개 x 핵심 보급품 5종 일일 재고·소모 시뮬레이터 (실제 부대·수량·기준일수 아님)\n", + "# 핵심성과지표(KPI) = 기준 보급일수(DOS) = 현재 재고 / 최근 평균 일일 소모량. 목표 DOS는 교육용 가정값이다.\n", + "# 결핍 위험 일수 H = 보급이 없다고 가정할 때 현재 재고가 바닥나기까지 남은 일수(미래 실제 소모량으로 계산한 정답).\n", + "# ---------------------------------------------------------------------------\n", + "ITEMS = {\"식량\": (100, 0.3, 10), \"피복\": (20, 0.1, 15), \"유류\": (300, 1.2, 7), \"탄약\": (60, 1.5, 6), \"수리부속\": (15, 0.5, 12)} # (기본 일소모, 템포 민감도, 목표 DOS)\n", + "N_UNIT, DAYS = 20, 160\n", + "UNITS = [f\"가상{u:02d}부대\" for u in range(1, N_UNIT + 1)]\n", + "\n", + "\n", + "def make_data(seed=1, n_unit=N_UNIT, days=DAYS):\n", + " \"\"\"일 단위 재고 원장: unit, item, day, tempo(실제 작전 템포), plan(사전 계획 템포), wx(악천후), consume, resupply, stock.\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " rows = []\n", + " for u in range(1, n_unit + 1):\n", + " s, ph = rng.uniform(0.7, 1.5), rng.uniform(0, 6.28)\n", + " surge = np.zeros(days)\n", + " for st in rng.choice(days - 10, 4, replace=False):\n", + " surge[st:st + int(rng.integers(5, 10))] = rng.uniform(0.4, 0.8)\n", + " tempo = 1 + 0.25 * np.sin(2 * np.pi * np.arange(days) / 30 + ph) + surge\n", + " plan = tempo + rng.normal(0, 0.08, days)\n", + " wx = (rng.random(days) < 0.15).astype(int)\n", + " for it, (b, sens, tg) in ITEMS.items():\n", + " stock, off = tg * b * s * rng.uniform(0.8, 1.2), int(rng.integers(0, 4))\n", + " for t in range(days):\n", + " c = b * s * (1 + sens * (tempo[t] - 1)) * (1 + 0.12 * wx[t]) * rng.normal(1, 0.08)\n", + " r = max(tg * b * s - stock, 0) * rng.uniform(0.9, 1.1) * (0.5 if rng.random() < 0.08 else 1) if (t % 4 == off and t > 0) else 0.0 # 4일 주기 정기 보급: 목표 재고까지 채움(가끔 수송 차질로 절반)\n", + " stock = max(stock + r - c, 0.0)\n", + " rows.append((f\"가상{u:02d}부대\", it, t, tempo[t], plan[t], wx[t], c, r, stock))\n", + " return pd.DataFrame(rows, columns=[\"unit\", \"item\", \"day\", \"tempo\", \"plan\", \"wx\", \"consume\", \"resupply\", \"stock\"])\n", + "\n", + "\n", + "def make_samples(d, lo=21, hi=125):\n", + " \"\"\"(부대, 품목, 기준일 t0)별 특성(피처)과 정답 H. 피처는 t0 시점까지 알 수 있는 값과 사전 계획·기상 예보만 사용한다.\"\"\"\n", + " out = []\n", + " for (u, it), g in d.groupby([\"unit\", \"item\"]):\n", + " c, stk, rs = g[\"consume\"].values, g[\"stock\"].values, g[\"resupply\"].values\n", + " pl, wx, tp = g[\"plan\"].values, g[\"wx\"].values, g[\"tempo\"].values\n", + " cs = np.cumsum(c)\n", + " tg = ITEMS[it][2] * ITEMS[it][0]\n", + " for t0 in range(lo, hi):\n", + " idx = np.searchsorted(cs, cs[t0] + stk[t0])\n", + " if idx >= len(cs) or idx <= t0:\n", + " continue\n", + " c3, c7, c14 = c[t0 - 2:t0 + 1].mean(), c[t0 - 6:t0 + 1].mean(), c[t0 - 13:t0 + 1].mean()\n", + " last = [k for k in range(t0, t0 - 15, -1) if rs[k] > 0]\n", + " out.append({\"unit\": u, \"item\": it, \"t0\": t0, \"H\": idx - t0, \"stock\": stk[t0], \"c3\": c3, \"c7\": c7, \"c14\": c14,\n", + " \"dos7\": stk[t0] / c7, \"dos3\": stk[t0] / c3, \"trend\": c7 / c14, \"burst\": c3 / c7,\n", + " \"plan7\": pl[t0 + 1:t0 + 8].mean(), \"plan_ratio\": pl[t0 + 1:t0 + 8].mean() / tp[t0], \"tempo0\": tp[t0],\n", + " \"wx_fc\": wx[t0 + 1:t0 + 8].mean(), \"since_rs\": (t0 - last[0]) if last else 15,\n", + " \"rs_avg\": rs[t0 - 13:t0 + 1].sum() / 14, \"fill\": stk[t0] / tg, \"item_code\": list(ITEMS).index(it)})\n", + " return pd.DataFrame(out)\n", + "\n", + "\n", + "def mape(a, b):\n", + " return float(np.mean(np.abs(a - b) / a))\n", + "\n", + "d = make_data()\n", + "d[\"c7\"] = d.groupby([\"unit\", \"item\"])[\"consume\"].transform(lambda s: s.rolling(7, min_periods=3).mean())\n", + "d[\"dos\"] = d[\"stock\"] / d[\"c7\"]\n", + "d[\"target\"] = d[\"item\"].map(lambda i: ITEMS[i][2])\n", + "d[\"rate\"] = d[\"dos\"] / d[\"target\"]\n", + "\n", + "# 1) Baseline: 기준 미달 = 달성률 50% 미만(주의), 25% 미만(심각 후보)\n", + "T = 100\n", + "now = d[d[\"day\"] == T]\n", + "low = now[now[\"rate\"] < 0.5].sort_values(\"rate\")\n", + "print(f\"[Baseline - {T}일째] 단순 DOS = 재고 / 최근 7일 평균 소모량 [교육용가상자료]\")\n", + "print(f\"부대 x 품목 {len(now)}건 중 달성률 50% 미만 {len(low)}건({len(low) / len(now):.0%}), 25% 미만 {(now['rate'] < 0.25).sum()}건\")\n", + "print(\"기준 미달 상위 5건:\")\n", + "print(low.head(5)[[\"unit\", \"item\", \"stock\", \"c7\", \"dos\", \"target\", \"rate\"]].round(2).to_string(index=False))\n", + "unit_cnt = low.groupby(\"unit\").size().sort_values(ascending=False)\n", + "print(\"미달 품목이 많은 부대 3곳:\", unit_cnt.head(3).to_dict())\n", + "\n", + "# 2) EDA용 표본(결핍 위험 일수 H 포함)과 상관 분석\n", + "s = make_samples(d[[\"unit\", \"item\", \"day\", \"tempo\", \"plan\", \"wx\", \"consume\", \"resupply\", \"stock\"]])\n", + "feat = [c for c in s.columns if c not in (\"unit\", \"item\", \"t0\", \"H\")]\n", + "corr = s[feat + [\"H\"]].corr()[\"H\"].drop(\"H\").sort_values(key=abs, ascending=False)\n", + "sig = corr[corr.abs() >= 0.3]\n", + "print(f\"\\n[결핍 위험 일수 H와의 상관] 표본 {len(s)}건, |r| 0.3 이상 피처 {len(sig)}개\")\n", + "print(corr.round(3).head(8).to_string())\n", + "\n", + "# 3) EDA 차트 8종\n", + "fig, ax = plt.subplots(2, 4, figsize=(17, 7))\n", + "ax[0, 0].hist(now[\"dos\"].clip(upper=25), bins=20, color=\"tab:blue\"); ax[0, 0].set_title(\"1 DOS distribution\")\n", + "dm = d.groupby(\"day\")[\"consume\"].mean(); ax[0, 1].plot(dm.index, dm.values); ax[0, 1].set_title(\"2 Mean daily consumption\")\n", + "sub = d[d[\"item\"] == \"탄약\"].sample(600, random_state=1); ax[0, 2].scatter(sub[\"tempo\"], sub[\"consume\"], s=6); ax[0, 2].set_title(\"3 Tempo vs consumption\")\n", + "ax[0, 3].boxplot([d[d[\"wx\"] == 0][\"consume\"] / d[\"c7\"].mean(), d[d[\"wx\"] == 1][\"consume\"] / d[\"c7\"].mean()]); ax[0, 3].set_xticklabels([\"fair\", \"bad wx\"]); ax[0, 3].set_title(\"4 Weather effect\")\n", + "for u in UNITS[:3]:\n", + " g = d[(d[\"unit\"] == u) & (d[\"item\"] == \"유류\")]; ax[1, 0].plot(g[\"day\"], g[\"stock\"], label=u[2:4])\n", + "ax[1, 0].legend(fontsize=7); ax[1, 0].set_title(\"5 Stock trajectories\")\n", + "ax[1, 1].boxplot([d[d[\"item\"] == i][\"rate\"].dropna().clip(upper=3) for i in ITEMS]); ax[1, 1].set_xticklabels([f\"i{k + 1}\" for k in range(5)]); ax[1, 1].set_title(\"6 Achievement rate by item\")\n", + "cnt = d[d[\"day\"] >= 14].groupby(\"day\")[\"rate\"].apply(lambda x: (x < 0.5).mean()); ax[1, 2].plot(cnt.index, cnt.values, color=\"tab:red\"); ax[1, 2].set_title(\"7 Share below 50% target\")\n", + "ax[1, 3].barh(sig.index[::-1][:8], sig.values[::-1][:8], color=\"tab:green\"); ax[1, 3].set_title(\"8 Correlation with H\")\n", + "plt.tight_layout(); plt.show()\n", + "n_chart = ax.size\n", + "r_t = float(d[d[\"item\"] == \"탄약\"][[\"tempo\", \"consume\"]].corr().iloc[0, 1])\n", + "wx_up = float(d[d[\"wx\"] == 1][\"consume\"].mean() / d[d[\"wx\"] == 0][\"consume\"].mean() - 1)\n", + "srs = s.groupby(pd.cut(s[\"since_rs\"], [-1, 0, 1, 2, 3, 20]))[\"dos7\"].mean()\n", + "print(f\"\\nEDA 시각화 차트 {n_chart}종 생성\")\n", + "print(f\"인사이트: (1) 탄약 소모와 작전 템포의 상관 r={r_t:.2f}. (2) 악천후일 평균 소모가 {wx_up:+.1%}. (3) 보급 후 경과일별 평균 DOS: {[round(float(v), 1) for v in srs.values]} (보급 직전이 가장 낮음)\")\n", + "print(\"의미 있는 상관 피처:\", \", \".join(sig.index[:6]))\n", + "print(f\"목표(EDA 차트 8종 이상, 유의미한 상관 피처 5개 이상): 차트 {n_chart}종 {'달성' if n_chart >= 8 else '미달'}, 피처 {len(sig)}개 {'달성' if len(sig) >= 5 else '미달'}\")\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 03 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + "[Baseline - 100일째] 단순 DOS = 재고 / 최근 7일 평균 소모량 [교육용가상자료]\n", + "부대 x 품목 100건 중 달성률 50% 미만 29건(29%), 25% 미만 13건\n", + "기준 미달 상위 5건:\n", + " unit item stock c7 dos target rate\n", + "가상01부대 유류 0.0 431.60 0.0 7 0.0\n", + "가상02부대 탄약 0.0 99.02 0.0 6 0.0\n", + "가상08부대 탄약 0.0 87.56 0.0 6 0.0\n", + "가상16부대 탄약 0.0 100.04 0.0 6 0.0\n", + "가상18부대 탄약 0.0 155.91 0.0 6 0.0\n", + "미달 품목이 많은 부대 3곳: {'가상16부대': 4, '가상03부대': 3, '가상18부대': 3}\n", + "\n", + "[결핍 위험 일수 H와의 상관] 표본 10164건, |r| 0.3 이상 피처 7개\n", + "dos3 0.958\n", + "dos7 0.950\n", + "fill 0.633\n", + "c3 -0.544\n", + "c7 -0.529\n", + "c14 -0.508\n", + "rs_avg -0.446\n", + "since_rs -0.264\n", + "\n", + "EDA 시각화 차트 8종 생성\n", + "인사이트: (1) 탄약 소모와 작전 템포의 상관 r=0.86. (2) 악천후일 평균 소모가 +11.8%. (3) 보급 후 경과일별 평균 DOS: [8.4, 7.4, 6.6, 5.9] (보급 직전이 가장 낮음)\n", + "의미 있는 상관 피처: dos3, dos7, fill, c3, c7, c14\n", + "목표(EDA 차트 8종 이상, 유의미한 상관 피처 5개 이상): 차트 8종 달성, 피처 7개 달성\n", + "```\n", + "
\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 04. 작전 템포 변동 및 기상 악화 연계 부대별 결핍 위험 일자(Stockout Date) 정밀 예측 고도화\n", + "**소요시간**: 약 3.0시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "작전 템포 변동 연계 부대별 결핍 위험 일자(Stockout Date) 정밀 예측을 고도화한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **작전 템포 변동과 기상 예보를 반영한 파생 피처로 부대별 결핍 위험 일자(Stockout Date)를 예측하는 기준 모델을 만든다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **사전 계획 템포, 다음 7일 악천후 예보, 최근 소모 추세, 보급 후 경과일 같은 파생 피처 13개를 설계하고 이유를 한 줄씩 설명 수행하고 검증하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `src/features/str01_feature_engineering.py 및 기준 모델` 형태의 결과물을 완성한다.\n", + "\n", + "### 🔍 평가 및 검증 기준 (PASS 조건)\n", + "#### 미션 판정\n", + "\n", + "**평가 구분:** 필수\n", + "\n", + "**핵심 평가영역:** 군수 실무에서 납득 가능한 피처 설계의 논리성\n", + "\n", + "#### 미션 PASS 조건\n", + "\n", + "- 정량 기준: 파생 피처 10개 이상 생성, 기준 모델 MAPE 25% 이하 달성\n", + "\n", + "\n", + "- 진행 기준: 피처 파이프라인 및 베이스라인 모델 성능 지표 도출\n", + "\n", + "\n", + "- 검증 방법: 교차 검증(Time-series Cross Validation)을 통한 과적합 방지\n", + "\n", + "\n", + "#### 채점자 판정 메모\n", + "\n", + "> 단순 코드 실행 여부뿐만 아니라 출력된 수치와 근거 자료가 군 실무 요구사항에 부합하는지 면밀히 확인한다.\n", + "\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "\"\"\"작전 템포 계획과 기상 예보를 포함한 파생 피처 13개를 설계해 릿지 회귀 기준 모델로 부대별 결핍 위험 일자(Stockout Date)를 예측하고, 단순 DOS 방식과 MAPE를 비교하며 템포 상승·악천후 가정 시나리오 영향을 확인한다.\"\"\"\n", + "import numpy as np\n", + "import pandas as pd\n", + "from sklearn.linear_model import Ridge\n", + "from sklearn.preprocessing import StandardScaler\n", + "from sklearn.pipeline import make_pipeline\n", + "\n", + "# ---------------------------------------------------------------------------\n", + "# [교육용가상자료] 가상 부대 20개 x 핵심 보급품 5종 일일 재고·소모 시뮬레이터 (실제 부대·수량·기준일수 아님)\n", + "# 핵심성과지표(KPI) = 기준 보급일수(DOS) = 현재 재고 / 최근 평균 일일 소모량. 목표 DOS는 교육용 가정값이다.\n", + "# 결핍 위험 일수 H = 보급이 없다고 가정할 때 현재 재고가 바닥나기까지 남은 일수(미래 실제 소모량으로 계산한 정답).\n", + "# ---------------------------------------------------------------------------\n", + "ITEMS = {\"식량\": (100, 0.3, 10), \"피복\": (20, 0.1, 15), \"유류\": (300, 1.2, 7), \"탄약\": (60, 1.5, 6), \"수리부속\": (15, 0.5, 12)} # (기본 일소모, 템포 민감도, 목표 DOS)\n", + "N_UNIT, DAYS = 20, 160\n", + "UNITS = [f\"가상{u:02d}부대\" for u in range(1, N_UNIT + 1)]\n", + "\n", + "\n", + "def make_data(seed=1, n_unit=N_UNIT, days=DAYS):\n", + " \"\"\"일 단위 재고 원장: unit, item, day, tempo(실제 작전 템포), plan(사전 계획 템포), wx(악천후), consume, resupply, stock.\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " rows = []\n", + " for u in range(1, n_unit + 1):\n", + " s, ph = rng.uniform(0.7, 1.5), rng.uniform(0, 6.28)\n", + " surge = np.zeros(days)\n", + " for st in rng.choice(days - 10, 4, replace=False):\n", + " surge[st:st + int(rng.integers(5, 10))] = rng.uniform(0.4, 0.8)\n", + " tempo = 1 + 0.25 * np.sin(2 * np.pi * np.arange(days) / 30 + ph) + surge\n", + " plan = tempo + rng.normal(0, 0.08, days)\n", + " wx = (rng.random(days) < 0.15).astype(int)\n", + " for it, (b, sens, tg) in ITEMS.items():\n", + " stock, off = tg * b * s * rng.uniform(0.8, 1.2), int(rng.integers(0, 4))\n", + " for t in range(days):\n", + " c = b * s * (1 + sens * (tempo[t] - 1)) * (1 + 0.12 * wx[t]) * rng.normal(1, 0.08)\n", + " r = max(tg * b * s - stock, 0) * rng.uniform(0.9, 1.1) * (0.5 if rng.random() < 0.08 else 1) if (t % 4 == off and t > 0) else 0.0 # 4일 주기 정기 보급: 목표 재고까지 채움(가끔 수송 차질로 절반)\n", + " stock = max(stock + r - c, 0.0)\n", + " rows.append((f\"가상{u:02d}부대\", it, t, tempo[t], plan[t], wx[t], c, r, stock))\n", + " return pd.DataFrame(rows, columns=[\"unit\", \"item\", \"day\", \"tempo\", \"plan\", \"wx\", \"consume\", \"resupply\", \"stock\"])\n", + "\n", + "\n", + "def make_samples(d, lo=21, hi=125):\n", + " \"\"\"(부대, 품목, 기준일 t0)별 특성(피처)과 정답 H. 피처는 t0 시점까지 알 수 있는 값과 사전 계획·기상 예보만 사용한다.\"\"\"\n", + " out = []\n", + " for (u, it), g in d.groupby([\"unit\", \"item\"]):\n", + " c, stk, rs = g[\"consume\"].values, g[\"stock\"].values, g[\"resupply\"].values\n", + " pl, wx, tp = g[\"plan\"].values, g[\"wx\"].values, g[\"tempo\"].values\n", + " cs = np.cumsum(c)\n", + " tg = ITEMS[it][2] * ITEMS[it][0]\n", + " for t0 in range(lo, hi):\n", + " idx = np.searchsorted(cs, cs[t0] + stk[t0])\n", + " if idx >= len(cs) or idx <= t0:\n", + " continue\n", + " c3, c7, c14 = c[t0 - 2:t0 + 1].mean(), c[t0 - 6:t0 + 1].mean(), c[t0 - 13:t0 + 1].mean()\n", + " last = [k for k in range(t0, t0 - 15, -1) if rs[k] > 0]\n", + " out.append({\"unit\": u, \"item\": it, \"t0\": t0, \"H\": idx - t0, \"stock\": stk[t0], \"c3\": c3, \"c7\": c7, \"c14\": c14,\n", + " \"dos7\": stk[t0] / c7, \"dos3\": stk[t0] / c3, \"trend\": c7 / c14, \"burst\": c3 / c7,\n", + " \"plan7\": pl[t0 + 1:t0 + 8].mean(), \"plan_ratio\": pl[t0 + 1:t0 + 8].mean() / tp[t0], \"tempo0\": tp[t0],\n", + " \"wx_fc\": wx[t0 + 1:t0 + 8].mean(), \"since_rs\": (t0 - last[0]) if last else 15,\n", + " \"rs_avg\": rs[t0 - 13:t0 + 1].sum() / 14, \"fill\": stk[t0] / tg, \"item_code\": list(ITEMS).index(it)})\n", + " return pd.DataFrame(out)\n", + "\n", + "\n", + "def mape(a, b):\n", + " return float(np.mean(np.abs(a - b) / a))\n", + "\n", + "d = make_data()\n", + "s = make_samples(d)\n", + "X = [c for c in s.columns if c not in (\"unit\", \"item\", \"t0\", \"H\")]\n", + "DESC = {\"stock\": \"현재 재고\", \"c3\": \"최근 3일 평균 소모\", \"c7\": \"최근 7일 평균 소모\", \"c14\": \"최근 14일 평균 소모\", \"dos7\": \"재고/7일 소모(DOS)\",\n", + " \"dos3\": \"재고/3일 소모\", \"trend\": \"7일/14일 소모 추세\", \"burst\": \"3일/7일 소모 급증도\", \"plan7\": \"다음 7일 계획 템포 평균\", \"plan_ratio\": \"계획 템포/현재 템포\",\n", + " \"tempo0\": \"현재 작전 템포\", \"wx_fc\": \"다음 7일 악천후 예보 비율\", \"since_rs\": \"마지막 보급 후 경과일\", \"rs_avg\": \"최근 14일 평균 보급량\", \"fill\": \"목표 재고 대비 현재 재고\", \"item_code\": \"품목 코드\"}\n", + "derived = [c for c in X if c not in (\"stock\", \"tempo0\", \"item_code\")]\n", + "print(f\"[피처 설계] 전체 {len(X)}개 중 파생 피처 {len(derived)}개 [교육용가상자료]\")\n", + "for c in derived:\n", + " print(f\" - {c}: {DESC[c]}\")\n", + "\n", + "tr, te = s[s[\"t0\"] <= 90], s[s[\"t0\"] > 90]\n", + "model = make_pipeline(StandardScaler(), Ridge(alpha=1.0)).fit(tr[X], tr[\"H\"])\n", + "p_base = te[\"dos7\"].values # 단순 DOS 방식\n", + "p_ridge = np.clip(model.predict(te[X]), 1, None) # 기준 모델\n", + "y = te[\"H\"].values\n", + "res = pd.DataFrame({\"모델\": [\"단순 DOS(Baseline)\", \"릿지 기준 모델\"], \"MAPE\": [mape(y, p_base), mape(y, p_ridge)],\n", + " \"MAE(일)\": [np.abs(y - p_base).mean(), np.abs(y - p_ridge).mean()], \"RMSE(일)\": [np.sqrt(((y - p_base) ** 2).mean()), np.sqrt(((y - p_ridge) ** 2).mean())]})\n", + "print(f\"\\n[예측 성능] 학습 {len(tr)}건(~90일), 시험 {len(te)}건(91일~)\")\n", + "print(res.round(3).to_string(index=False))\n", + "\n", + "# 결핍 위험 일자 예측 표(100일 기준)\n", + "now = s[s[\"t0\"] == 100].copy()\n", + "now[\"예측H\"] = np.clip(model.predict(now[X]), 1, None)\n", + "now[\"결핍예상일\"] = (now[\"t0\"] + now[\"예측H\"]).round().astype(int)\n", + "print(\"\\n[결핍 위험 일자 예측 - 100일 기준, 가까운 순 6건]\")\n", + "print(now.sort_values(\"예측H\").head(6)[[\"unit\", \"item\", \"stock\", \"예측H\", \"결핍예상일\", \"H\"]].rename(columns={\"H\": \"실제H\"}).round(1).to_string(index=False))\n", + "\n", + "# 가정 시나리오: 계획 템포 +30%와 다음 7일 악천후 50% 가정 시 예측 변화\n", + "w = now.copy()\n", + "w[\"plan7\"] *= 1.3; w[\"plan_ratio\"] *= 1.3; w[\"wx_fc\"] = 0.5\n", + "shift = np.clip(model.predict(w[X]), 1, None) - now[\"예측H\"].values\n", + "print(f\"\\n[가정 시나리오] 계획 템포 +30%, 악천후 50% 가정 시 예측 결핍 일수 평균 {shift.mean():+.2f}일 변화 (탄약 {shift[now['item'].values == '탄약'].mean():+.2f}일, 식량 {shift[now['item'].values == '식량'].mean():+.2f}일)\")\n", + "coef = pd.Series(model[-1].coef_, index=X).sort_values(key=abs, ascending=False)\n", + "print(\"기준 모델 영향 큰 피처 상위 5:\", {k: round(float(v), 2) for k, v in coef.head(5).items()})\n", + "m = mape(y, p_ridge)\n", + "print(f\"\\n목표(파생 피처 10개 이상, 기준 모델 MAPE 25% 이하): 파생 피처 {len(derived)}개 {'달성' if len(derived) >= 10 else '미달'}, MAPE {m:.1%} {'달성' if m <= 0.25 else '미달'}\")\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 04 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + "[피처 설계] 전체 16개 중 파생 피처 13개 [교육용가상자료]\n", + " - c3: 최근 3일 평균 소모\n", + " - c7: 최근 7일 평균 소모\n", + " - c14: 최근 14일 평균 소모\n", + " - dos7: 재고/7일 소모(DOS)\n", + " - dos3: 재고/3일 소모\n", + " - trend: 7일/14일 소모 추세\n", + " - burst: 3일/7일 소모 급증도\n", + " - plan7: 다음 7일 계획 템포 평균\n", + " - plan_ratio: 계획 템포/현재 템포\n", + " - wx_fc: 다음 7일 악천후 예보 비율\n", + " - since_rs: 마지막 보급 후 경과일\n", + " - rs_avg: 최근 14일 평균 보급량\n", + " - fill: 목표 재고 대비 현재 재고\n", + "\n", + "[예측 성능] 학습 6836건(~90일), 시험 3328건(91일~)\n", + " 모델 MAPE MAE(일) RMSE(일)\n", + "단순 DOS(Baseline) 0.162 0.938 1.207\n", + " 릿지 기준 모델 0.106 0.566 0.721\n", + "\n", + "[결핍 위험 일자 예측 - 100일 기준, 가까운 순 6건]\n", + " unit item stock 예측H 결핍예상일 실제H\n", + "가상01부대 탄약 31.2 1.2 101 1\n", + "가상19부대 탄약 50.9 1.7 102 1\n", + "가상03부대 탄약 81.4 1.9 102 1\n", + "가상15부대 탄약 50.2 2.0 102 1\n", + "가상04부대 탄약 93.6 2.3 102 1\n", + "가상17부대 유류 847.5 2.6 103 3\n", + "\n", + "[가정 시나리오] 계획 템포 +30%, 악천후 50% 가정 시 예측 결핍 일수 평균 -1.11일 변화 (탄약 -1.05일, 식량 -1.12일)\n", + "기준 모델 영향 큰 피처 상위 5: {'dos7': 2.15, 'dos3': 1.26, 'c7': -0.75, 'plan_ratio': -0.71, 'c14': 0.43}\n", + "\n", + "목표(파생 피처 10개 이상, 기준 모델 MAPE 25% 이하): 파생 피처 13개 달성, MAPE 10.6% 달성\n", + "```\n", + "
\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 05. 결핍 심각도 지수 산출 및 다단계 결핍 조기경보(주의/경계/심각) 엔진 개발\n", + "**소요시간**: 약 3.5시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "결핍 심각도 지수 산출 및 다단계 결핍 조기경보 엔진을 개발한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **결핍 심각도 지수를 산출하고 주의·경계·심각 3단계 결핍 조기경보 엔진을 개발한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **그래디언트 부스팅 모델로 결핍 위험 일수를 예측해 기준 모델과 MAE, RMSE, R2를 비교 수행하고 검증하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `src/models/str01_advanced_model.py` 형태의 결과물을 완성한다.\n", + "\n", + "### 🔍 평가 및 검증 기준 (PASS 조건)\n", + "#### 미션 판정\n", + "\n", + "**평가 구분:** 필수\n", + "\n", + "**핵심 평가영역:** 비정형/불규칙 수요에 대한 모델의 적응력 및 예측 안정성\n", + "\n", + "#### 미션 PASS 조건\n", + "\n", + "- 정량 기준: 기준 모델 대비 오차율(MAE/RMSE) 20% 이상 개선, R2 Score 0.85 이상\n", + "\n", + "\n", + "- 진행 기준: 모델 하이퍼파라미터 튜닝 완료 및 최적 모델 가중치 저장\n", + "\n", + "\n", + "- 검증 방법: Hold-out 테스트 데이터셋 대상 최종 성능 평가\n", + "\n", + "\n", + "#### 채점자 판정 메모\n", + "\n", + "> 단순 코드 실행 여부뿐만 아니라 출력된 수치와 근거 자료가 군 실무 요구사항에 부합하는지 면밀히 확인한다.\n", + "\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "\"\"\"기준 모델(릿지)보다 정확한 그래디언트 부스팅 모델로 결핍 위험 일수를 예측하고, 이를 0~100 결핍 심각도 지수와 주의·경계·심각 3단계 조기경보로 변환해 오차 개선율, R2, 경보 등급 정확도와 미탐·오탐을 평가한다.\"\"\"\n", + "import numpy as np\n", + "import pandas as pd\n", + "from sklearn.ensemble import GradientBoostingRegressor\n", + "from sklearn.linear_model import Ridge\n", + "from sklearn.metrics import r2_score\n", + "from sklearn.pipeline import make_pipeline\n", + "from sklearn.preprocessing import StandardScaler\n", + "\n", + "# ---------------------------------------------------------------------------\n", + "# [교육용가상자료] 가상 부대 20개 x 핵심 보급품 5종 일일 재고·소모 시뮬레이터 (실제 부대·수량·기준일수 아님)\n", + "# 핵심성과지표(KPI) = 기준 보급일수(DOS) = 현재 재고 / 최근 평균 일일 소모량. 목표 DOS는 교육용 가정값이다.\n", + "# 결핍 위험 일수 H = 보급이 없다고 가정할 때 현재 재고가 바닥나기까지 남은 일수(미래 실제 소모량으로 계산한 정답).\n", + "# ---------------------------------------------------------------------------\n", + "ITEMS = {\"식량\": (100, 0.3, 10), \"피복\": (20, 0.1, 15), \"유류\": (300, 1.2, 7), \"탄약\": (60, 1.5, 6), \"수리부속\": (15, 0.5, 12)} # (기본 일소모, 템포 민감도, 목표 DOS)\n", + "N_UNIT, DAYS = 20, 160\n", + "UNITS = [f\"가상{u:02d}부대\" for u in range(1, N_UNIT + 1)]\n", + "\n", + "\n", + "def make_data(seed=1, n_unit=N_UNIT, days=DAYS):\n", + " \"\"\"일 단위 재고 원장: unit, item, day, tempo(실제 작전 템포), plan(사전 계획 템포), wx(악천후), consume, resupply, stock.\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " rows = []\n", + " for u in range(1, n_unit + 1):\n", + " s, ph = rng.uniform(0.7, 1.5), rng.uniform(0, 6.28)\n", + " surge = np.zeros(days)\n", + " for st in rng.choice(days - 10, 4, replace=False):\n", + " surge[st:st + int(rng.integers(5, 10))] = rng.uniform(0.4, 0.8)\n", + " tempo = 1 + 0.25 * np.sin(2 * np.pi * np.arange(days) / 30 + ph) + surge\n", + " plan = tempo + rng.normal(0, 0.08, days)\n", + " wx = (rng.random(days) < 0.15).astype(int)\n", + " for it, (b, sens, tg) in ITEMS.items():\n", + " stock, off = tg * b * s * rng.uniform(0.8, 1.2), int(rng.integers(0, 4))\n", + " for t in range(days):\n", + " c = b * s * (1 + sens * (tempo[t] - 1)) * (1 + 0.12 * wx[t]) * rng.normal(1, 0.08)\n", + " r = max(tg * b * s - stock, 0) * rng.uniform(0.9, 1.1) * (0.5 if rng.random() < 0.08 else 1) if (t % 4 == off and t > 0) else 0.0 # 4일 주기 정기 보급: 목표 재고까지 채움(가끔 수송 차질로 절반)\n", + " stock = max(stock + r - c, 0.0)\n", + " rows.append((f\"가상{u:02d}부대\", it, t, tempo[t], plan[t], wx[t], c, r, stock))\n", + " return pd.DataFrame(rows, columns=[\"unit\", \"item\", \"day\", \"tempo\", \"plan\", \"wx\", \"consume\", \"resupply\", \"stock\"])\n", + "\n", + "\n", + "def make_samples(d, lo=21, hi=125):\n", + " \"\"\"(부대, 품목, 기준일 t0)별 특성(피처)과 정답 H. 피처는 t0 시점까지 알 수 있는 값과 사전 계획·기상 예보만 사용한다.\"\"\"\n", + " out = []\n", + " for (u, it), g in d.groupby([\"unit\", \"item\"]):\n", + " c, stk, rs = g[\"consume\"].values, g[\"stock\"].values, g[\"resupply\"].values\n", + " pl, wx, tp = g[\"plan\"].values, g[\"wx\"].values, g[\"tempo\"].values\n", + " cs = np.cumsum(c)\n", + " tg = ITEMS[it][2] * ITEMS[it][0]\n", + " for t0 in range(lo, hi):\n", + " idx = np.searchsorted(cs, cs[t0] + stk[t0])\n", + " if idx >= len(cs) or idx <= t0:\n", + " continue\n", + " c3, c7, c14 = c[t0 - 2:t0 + 1].mean(), c[t0 - 6:t0 + 1].mean(), c[t0 - 13:t0 + 1].mean()\n", + " last = [k for k in range(t0, t0 - 15, -1) if rs[k] > 0]\n", + " out.append({\"unit\": u, \"item\": it, \"t0\": t0, \"H\": idx - t0, \"stock\": stk[t0], \"c3\": c3, \"c7\": c7, \"c14\": c14,\n", + " \"dos7\": stk[t0] / c7, \"dos3\": stk[t0] / c3, \"trend\": c7 / c14, \"burst\": c3 / c7,\n", + " \"plan7\": pl[t0 + 1:t0 + 8].mean(), \"plan_ratio\": pl[t0 + 1:t0 + 8].mean() / tp[t0], \"tempo0\": tp[t0],\n", + " \"wx_fc\": wx[t0 + 1:t0 + 8].mean(), \"since_rs\": (t0 - last[0]) if last else 15,\n", + " \"rs_avg\": rs[t0 - 13:t0 + 1].sum() / 14, \"fill\": stk[t0] / tg, \"item_code\": list(ITEMS).index(it)})\n", + " return pd.DataFrame(out)\n", + "\n", + "\n", + "def mape(a, b):\n", + " return float(np.mean(np.abs(a - b) / a))\n", + "\n", + "d = make_data()\n", + "s = make_samples(d)\n", + "X = [c for c in s.columns if c not in (\"unit\", \"item\", \"t0\", \"H\")]\n", + "tr, te = s[s[\"t0\"] <= 90], s[s[\"t0\"] > 90]\n", + "y = te[\"H\"].values\n", + "ridge = make_pipeline(StandardScaler(), Ridge(alpha=1.0)).fit(tr[X], tr[\"H\"])\n", + "gbr = GradientBoostingRegressor(n_estimators=200, max_depth=3, learning_rate=0.08, random_state=0).fit(tr[X], tr[\"H\"])\n", + "p0 = np.clip(ridge.predict(te[X]), 1, None)\n", + "p1 = np.clip(gbr.predict(te[X]), 1, None)\n", + "\n", + "def score(p):\n", + " return {\"MAE\": float(np.abs(y - p).mean()), \"RMSE\": float(np.sqrt(((y - p) ** 2).mean())), \"R2\": float(r2_score(y, p)), \"MAPE\": mape(y, p)}\n", + "a, b = score(p0), score(p1)\n", + "print(\"[예측 성능 비교] 시험 표본\", len(te), \"건 (91일 이후) [교육용가상자료]\")\n", + "print(pd.DataFrame({\"기준 모델(릿지)\": a, \"고도화 모델(GBR)\": b}).round(3).to_string())\n", + "imp_mae, imp_rmse = 1 - b[\"MAE\"] / a[\"MAE\"], 1 - b[\"RMSE\"] / a[\"RMSE\"]\n", + "print(f\"기준 모델 대비 개선: MAE {imp_mae:.1%}, RMSE {imp_rmse:.1%}, R2 {b['R2']:.3f}\")\n", + "\n", + "# 결핍 심각도 지수(0~100): 예상 결핍 일수가 짧을수록 높고, 목표 재고 대비 현재 재고가 적을수록 가중\n", + "def severity(h, fill):\n", + " base = np.clip(1 - (h - 1) / 8, 0, 1) # H=1일이면 1.0, H>=9일이면 0\n", + " return 100 * np.clip(base * (1.1 - 0.2 * np.clip(fill, 0, 1)), 0, 1)\n", + "LEVELS = [(2.5, \"심각\"), (4.5, \"경계\"), (7.5, \"주의\")]\n", + "def level(h):\n", + " for th, name in LEVELS:\n", + " if h <= th:\n", + " return name\n", + " return \"정상\"\n", + "te = te.assign(pred=p1, pred0=p0)\n", + "te[\"sev\"] = severity(te[\"pred\"], te[\"fill\"])\n", + "te[\"lv_pred\"] = te[\"pred\"].map(level); te[\"lv_true\"] = te[\"H\"].map(level); te[\"lv_ridge\"] = te[\"pred0\"].map(level)\n", + "acc, acc0 = float((te[\"lv_pred\"] == te[\"lv_true\"]).mean()), float((te[\"lv_ridge\"] == te[\"lv_true\"]).mean())\n", + "order = {\"정상\": 0, \"주의\": 1, \"경계\": 2, \"심각\": 3}\n", + "miss = float(((te[\"lv_true\"].map(order) >= 2) & (te[\"lv_pred\"].map(order) < 2)).sum() / max((te[\"lv_true\"].map(order) >= 2).sum(), 1))\n", + "false = float(((te[\"lv_true\"].map(order) < 1) & (te[\"lv_pred\"].map(order) >= 2)).sum() / max((te[\"lv_true\"].map(order) < 1).sum(), 1))\n", + "print(f\"\\n[3단계 조기경보] 기준: 예상 결핍 {LEVELS[0][0]}일 이하 심각, {LEVELS[1][0]}일 이하 경계, {LEVELS[2][0]}일 이하 주의\")\n", + "print(pd.crosstab(te[\"lv_true\"], te[\"lv_pred\"]).reindex(index=[\"정상\", \"주의\", \"경계\", \"심각\"], columns=[\"정상\", \"주의\", \"경계\", \"심각\"], fill_value=0).to_string())\n", + "print(f\"등급 정확도 {acc:.1%}(기준 모델 {acc0:.1%}), 경계 이상 미탐률 {miss:.1%}, 정상 상황 경계 이상 오탐률 {false:.1%}\")\n", + "print(\"\\n[경보 예시 - 100일 기준 심각도 상위 5건]\")\n", + "nw = te[te[\"t0\"] == 100].sort_values(\"sev\", ascending=False).head(5)\n", + "print(nw[[\"unit\", \"item\", \"pred\", \"H\", \"sev\", \"lv_pred\"]].rename(columns={\"pred\": \"예측H\", \"H\": \"실제H\", \"sev\": \"심각도지수\", \"lv_pred\": \"경보\"}).round(1).to_string(index=False))\n", + "\n", + "# 불규칙 수요 적응력: 템포 급변(계획 대비 현재 템포 20% 이상 차이) 구간의 안정성\n", + "irr = te[(te[\"plan_ratio\"] - 1).abs() > 0.2]\n", + "if len(irr):\n", + " print(f\"\\n템포 급변 구간 {len(irr)}건 MAE: 기준 {np.abs(irr['H'] - irr['pred0']).mean():.2f}일 -> 고도화 {np.abs(irr['H'] - irr['pred']).mean():.2f}일\")\n", + "ok = imp_mae >= 0.2 and imp_rmse >= 0.2 and b[\"R2\"] >= 0.85\n", + "print(f\"\\n목표(기준 모델 대비 MAE/RMSE 20% 이상 개선, R2 0.85 이상): {'달성' if ok else '미달'}\")\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 05 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + "[예측 성능 비교] 시험 표본 3328 건 (91일 이후) [교육용가상자료]\n", + " 기준 모델(릿지) 고도화 모델(GBR)\n", + "MAE 0.566 0.420\n", + "RMSE 0.721 0.534\n", + "R2 0.959 0.978\n", + "MAPE 0.106 0.072\n", + "기준 모델 대비 개선: MAE 25.8%, RMSE 25.9%, R2 0.978\n", + "\n", + "[3단계 조기경보] 기준: 예상 결핍 2.5일 이하 심각, 4.5일 이하 경계, 7.5일 이하 주의\n", + "lv_pred 정상 주의 경계 심각\n", + "lv_true \n", + "정상 1567 52 0 0\n", + "주의 81 806 47 0\n", + "경계 0 48 411 16\n", + "심각 0 0 22 278\n", + "등급 정확도 92.0%(기준 모델 88.2%), 경계 이상 미탐률 6.2%, 정상 상황 경계 이상 오탐률 0.0%\n", + "\n", + "[경보 예시 - 100일 기준 심각도 상위 5건]\n", + " unit item 예측H 실제H 심각도지수 경보\n", + "가상01부대 탄약 1.0 1 100.0 심각\n", + "가상03부대 탄약 1.2 1 100.0 심각\n", + "가상16부대 유류 1.4 1 100.0 심각\n", + "가상19부대 탄약 1.0 1 100.0 심각\n", + "가상15부대 탄약 1.0 1 100.0 심각\n", + "\n", + "템포 급변 구간 827건 MAE: 기준 0.66일 -> 고도화 0.45일\n", + "\n", + "목표(기준 모델 대비 MAE/RMSE 20% 이상 개선, R2 0.85 이상): 달성\n", + "```\n", + "
\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 06. 군단/사단 관할 부대 보급 수준(DOS) 실시간 현황 지도 및 경보 대시보드 구현\n", + "**소요시간**: 약 3.0시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "관할 부대 보급 수준(DOS) 실시간 현황 지도 및 경보 대시보드를 구현한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **관할 부대의 DOS 현황을 가상 지도와 경보 색상으로 보여 주는 대시보드를 구현하고, 경보마다 예측에 기여한 인자를 설명한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **20개 부대를 격자형 가상 지도에 배치해 부대별 최악 경보 등급을 색으로 표시하고, 3개 시점의 갱신 결과를 출력 수행하고 검증하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `src/explainability/str01_xai_explainer.py 및 XAI 리포트` 형태의 결과물을 완성한다.\n", + "\n", + "### 🔍 평가 및 검증 기준 (PASS 조건)\n", + "#### 미션 판정\n", + "\n", + "**평가 구분:** 필수\n", + "\n", + "**핵심 평가영역:** 군수 장교/군무원이 신뢰하고 조치할 수 있는 설명의 명확성\n", + "\n", + "#### 미션 PASS 조건\n", + "\n", + "- 정량 기준: 주요 예측 건당 기여도 상위 3개 인자 자동 산출률 100%\n", + "\n", + "\n", + "- 진행 기준: SHAP summary plot 및 waterfall plot 생성 파이프라인 완성\n", + "\n", + "\n", + "- 검증 방법: 실무자 대상 설명 근거 유용성 평가 검증\n", + "\n", + "\n", + "#### 채점자 판정 메모\n", + "\n", + "> 단순 코드 실행 여부뿐만 아니라 출력된 수치와 근거 자료가 군 실무 요구사항에 부합하는지 면밀히 확인한다.\n", + "\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "\"\"\"20개 가상 부대를 격자형 가상 지도에 배치해 보급 수준(DOS) 현황과 3단계 경보를 색으로 표시하는 대시보드를 만들고, 경보 건마다 예측에 기여한 상위 3개 인자를 자동으로 설명(XAI)하며 그래프와 텍스트 보고를 출력한다.\"\"\"\n", + "import numpy as np\n", + "import pandas as pd\n", + "import matplotlib.pyplot as plt\n", + "from sklearn.ensemble import GradientBoostingRegressor\n", + "\n", + "# ---------------------------------------------------------------------------\n", + "# [교육용가상자료] 가상 부대 20개 x 핵심 보급품 5종 일일 재고·소모 시뮬레이터 (실제 부대·수량·기준일수 아님)\n", + "# 핵심성과지표(KPI) = 기준 보급일수(DOS) = 현재 재고 / 최근 평균 일일 소모량. 목표 DOS는 교육용 가정값이다.\n", + "# 결핍 위험 일수 H = 보급이 없다고 가정할 때 현재 재고가 바닥나기까지 남은 일수(미래 실제 소모량으로 계산한 정답).\n", + "# ---------------------------------------------------------------------------\n", + "ITEMS = {\"식량\": (100, 0.3, 10), \"피복\": (20, 0.1, 15), \"유류\": (300, 1.2, 7), \"탄약\": (60, 1.5, 6), \"수리부속\": (15, 0.5, 12)} # (기본 일소모, 템포 민감도, 목표 DOS)\n", + "N_UNIT, DAYS = 20, 160\n", + "UNITS = [f\"가상{u:02d}부대\" for u in range(1, N_UNIT + 1)]\n", + "\n", + "\n", + "def make_data(seed=1, n_unit=N_UNIT, days=DAYS):\n", + " \"\"\"일 단위 재고 원장: unit, item, day, tempo(실제 작전 템포), plan(사전 계획 템포), wx(악천후), consume, resupply, stock.\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " rows = []\n", + " for u in range(1, n_unit + 1):\n", + " s, ph = rng.uniform(0.7, 1.5), rng.uniform(0, 6.28)\n", + " surge = np.zeros(days)\n", + " for st in rng.choice(days - 10, 4, replace=False):\n", + " surge[st:st + int(rng.integers(5, 10))] = rng.uniform(0.4, 0.8)\n", + " tempo = 1 + 0.25 * np.sin(2 * np.pi * np.arange(days) / 30 + ph) + surge\n", + " plan = tempo + rng.normal(0, 0.08, days)\n", + " wx = (rng.random(days) < 0.15).astype(int)\n", + " for it, (b, sens, tg) in ITEMS.items():\n", + " stock, off = tg * b * s * rng.uniform(0.8, 1.2), int(rng.integers(0, 4))\n", + " for t in range(days):\n", + " c = b * s * (1 + sens * (tempo[t] - 1)) * (1 + 0.12 * wx[t]) * rng.normal(1, 0.08)\n", + " r = max(tg * b * s - stock, 0) * rng.uniform(0.9, 1.1) * (0.5 if rng.random() < 0.08 else 1) if (t % 4 == off and t > 0) else 0.0 # 4일 주기 정기 보급: 목표 재고까지 채움(가끔 수송 차질로 절반)\n", + " stock = max(stock + r - c, 0.0)\n", + " rows.append((f\"가상{u:02d}부대\", it, t, tempo[t], plan[t], wx[t], c, r, stock))\n", + " return pd.DataFrame(rows, columns=[\"unit\", \"item\", \"day\", \"tempo\", \"plan\", \"wx\", \"consume\", \"resupply\", \"stock\"])\n", + "\n", + "\n", + "def make_samples(d, lo=21, hi=125):\n", + " \"\"\"(부대, 품목, 기준일 t0)별 특성(피처)과 정답 H. 피처는 t0 시점까지 알 수 있는 값과 사전 계획·기상 예보만 사용한다.\"\"\"\n", + " out = []\n", + " for (u, it), g in d.groupby([\"unit\", \"item\"]):\n", + " c, stk, rs = g[\"consume\"].values, g[\"stock\"].values, g[\"resupply\"].values\n", + " pl, wx, tp = g[\"plan\"].values, g[\"wx\"].values, g[\"tempo\"].values\n", + " cs = np.cumsum(c)\n", + " tg = ITEMS[it][2] * ITEMS[it][0]\n", + " for t0 in range(lo, hi):\n", + " idx = np.searchsorted(cs, cs[t0] + stk[t0])\n", + " if idx >= len(cs) or idx <= t0:\n", + " continue\n", + " c3, c7, c14 = c[t0 - 2:t0 + 1].mean(), c[t0 - 6:t0 + 1].mean(), c[t0 - 13:t0 + 1].mean()\n", + " last = [k for k in range(t0, t0 - 15, -1) if rs[k] > 0]\n", + " out.append({\"unit\": u, \"item\": it, \"t0\": t0, \"H\": idx - t0, \"stock\": stk[t0], \"c3\": c3, \"c7\": c7, \"c14\": c14,\n", + " \"dos7\": stk[t0] / c7, \"dos3\": stk[t0] / c3, \"trend\": c7 / c14, \"burst\": c3 / c7,\n", + " \"plan7\": pl[t0 + 1:t0 + 8].mean(), \"plan_ratio\": pl[t0 + 1:t0 + 8].mean() / tp[t0], \"tempo0\": tp[t0],\n", + " \"wx_fc\": wx[t0 + 1:t0 + 8].mean(), \"since_rs\": (t0 - last[0]) if last else 15,\n", + " \"rs_avg\": rs[t0 - 13:t0 + 1].sum() / 14, \"fill\": stk[t0] / tg, \"item_code\": list(ITEMS).index(it)})\n", + " return pd.DataFrame(out)\n", + "\n", + "\n", + "def mape(a, b):\n", + " return float(np.mean(np.abs(a - b) / a))\n", + "\n", + "d = make_data()\n", + "s = make_samples(d)\n", + "X = [c for c in s.columns if c not in (\"unit\", \"item\", \"t0\", \"H\")]\n", + "tr = s[s[\"t0\"] <= 90]\n", + "gbr = GradientBoostingRegressor(n_estimators=200, max_depth=3, learning_rate=0.08, random_state=0).fit(tr[X], tr[\"H\"])\n", + "mean_x = tr[X].mean()\n", + "LABEL = {\"stock\": \"현재 재고\", \"c3\": \"최근 3일 소모\", \"c7\": \"최근 7일 소모\", \"c14\": \"최근 14일 소모\", \"dos7\": \"7일 기준 DOS\", \"dos3\": \"3일 기준 DOS\", \"trend\": \"소모 추세\",\n", + " \"burst\": \"소모 급증도\", \"plan7\": \"다음 7일 계획 템포\", \"plan_ratio\": \"계획/현재 템포 비\", \"tempo0\": \"현재 작전 템포\", \"wx_fc\": \"악천후 예보\", \"since_rs\": \"보급 후 경과일\",\n", + " \"rs_avg\": \"최근 보급량\", \"fill\": \"목표 대비 재고\", \"item_code\": \"품목\"}\n", + "def level(h):\n", + " return \"심각\" if h <= 2.5 else \"경계\" if h <= 4.5 else \"주의\" if h <= 7.5 else \"정상\"\n", + "ORDER = {\"정상\": 0, \"주의\": 1, \"경계\": 2, \"심각\": 3}\n", + "COL = {\"정상\": \"tab:green\", \"주의\": \"gold\", \"경계\": \"orange\", \"심각\": \"red\"}\n", + "\n", + "def explain(row):\n", + " \"\"\"XAI: 각 피처를 학습 평균값으로 바꿨을 때 예측이 변하는 정도(기여도)로 상위 3개 인자를 뽑는다. 음수면 그 인자 때문에 결핍 일수가 짧아졌다는 뜻.\"\"\"\n", + " x = row[X].astype(float)\n", + " alts = pd.DataFrame([x] * (len(X) + 1)).reset_index(drop=True)\n", + " for i, f in enumerate(X):\n", + " alts.loc[i + 1, f] = mean_x[f]\n", + " p = gbr.predict(alts)\n", + " contrib = {f: float(p[0] - p[i + 1]) for i, f in enumerate(X)}\n", + " return float(p[0]), sorted(contrib.items(), key=lambda kv: -abs(kv[1]))[:3]\n", + "\n", + "# 가상 지도: 4행 x 5열 격자(실제 위치 아님)\n", + "POS = {u: (i % 5, i // 5) for i, u in enumerate(UNITS)}\n", + "print(\"[지도 대시보드] 가상 격자 지도(위치는 실제와 무관) [교육용가상자료]\")\n", + "snaps = {}\n", + "for day in (100, 101, 102):\n", + " now = s[s[\"t0\"] == day].copy()\n", + " now[\"pred\"] = np.clip(gbr.predict(now[X]), 1, None)\n", + " now[\"lv\"] = now[\"pred\"].map(level)\n", + " worst = now.loc[now.groupby(\"unit\")[\"pred\"].idxmin()].set_index(\"unit\")\n", + " snaps[day] = (now, worst)\n", + " cnt = now[\"lv\"].value_counts().reindex([\"심각\", \"경계\", \"주의\", \"정상\"], fill_value=0).to_dict()\n", + " print(f\" {day}일차: 경보 분포 {cnt}, 가장 위험한 부대 {worst['pred'].idxmin()}({worst['item'][worst['pred'].idxmin()]}, 예상 결핍 {worst['pred'].min():.1f}일)\")\n", + "\n", + "now, worst = snaps[102]\n", + "alerts = now[now[\"lv\"] != \"정상\"].sort_values(\"pred\")\n", + "print(f\"\\n[경보 설명(XAI)] 102일차 경보 {len(alerts)}건 중 상위 6건 (기여도 상위 3개 인자)\")\n", + "ok = 0\n", + "rows = []\n", + "for _, r in alerts.iterrows():\n", + " base, top = explain(r)\n", + " ok += (len(top) == 3 and all(np.isfinite(v) for _, v in top))\n", + " rows.append((r[\"unit\"], r[\"item\"], r[\"lv\"], base, top))\n", + "for u, it, lv, base, top in rows[:6]:\n", + " txt = \", \".join(f\"{LABEL[f]}({v:+.1f}일)\" for f, v in top)\n", + " print(f\" - {u} {it} [{lv}] 예상 결핍 {base:.1f}일 <- {txt}\")\n", + "print(f\"설명 자동 산출률 {ok}/{len(alerts)} = {ok / max(len(alerts), 1):.0%}\")\n", + "fid = []\n", + "for u, it, lv, base, top in rows[:20]:\n", + " r = alerts[(alerts[\"unit\"] == u) & (alerts[\"item\"] == it)].iloc[0]\n", + " k = [f for f in X if f not in [t for t, _ in top]][0]\n", + " alt = pd.DataFrame([r[X].astype(float)] * 2).reset_index(drop=True)\n", + " alt.loc[0, top[0][0]] = mean_x[top[0][0]]; alt.loc[1, k] = mean_x[k]\n", + " p = gbr.predict(alt)\n", + " fid.append(abs(base - p[0]) >= abs(base - p[1]))\n", + "print(f\"설명 타당성 점검: 1순위 인자를 제거할 때의 예측 변화가 무관한 인자 제거보다 큰 비율 {np.mean(fid):.0%}\")\n", + "\n", + "fig, ax = plt.subplots(1, 2, figsize=(13, 4.8))\n", + "for u, (x, y) in POS.items():\n", + " lv = worst.loc[u, \"lv\"]\n", + " ax[0].scatter(x, -y, s=1500, color=COL[lv], edgecolor=\"black\"); ax[0].text(x, -y, u[2:4], ha=\"center\", va=\"center\", fontsize=9)\n", + "ax[0].set_title(\"Virtual map: worst alert level per unit (102nd day)\"); ax[0].axis(\"off\")\n", + "if rows:\n", + " u, it, lv, base, top = rows[0]\n", + " ax[1].barh([f for f, _ in top][::-1], [v for _, v in top][::-1], color=\"tab:blue\")\n", + " ax[1].set_title(\"Top-3 contributions (days) for the most urgent alert\"); ax[1].set_xlabel(\"change in predicted days to stockout\")\n", + "plt.tight_layout(); plt.show()\n", + "print(f\"\\n목표(주요 예측 건당 기여도 상위 3개 인자 자동 산출률 100%): {'달성' if ok == len(alerts) else '미달'}\")\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 06 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + "[지도 대시보드] 가상 격자 지도(위치는 실제와 무관) [교육용가상자료]\n", + " 100일차: 경보 분포 {'심각': 8, '경계': 11, '주의': 29, '정상': 47}, 가장 위험한 부대 가상15부대(탄약, 예상 결핍 1.0일)\n", + " 101일차: 경보 분포 {'심각': 8, '경계': 14, '주의': 24, '정상': 51}, 가장 위험한 부대 가상20부대(탄약, 예상 결핍 1.0일)\n", + " 102일차: 경보 분포 {'심각': 9, '경계': 12, '주의': 24, '정상': 54}, 가장 위험한 부대 가상17부대(탄약, 예상 결핍 1.0일)\n", + "\n", + "[경보 설명(XAI)] 102일차 경보 45건 중 상위 6건 (기여도 상위 3개 인자)\n", + " - 가상17부대 탄약 [심각] 예상 결핍 0.9일 <- 3일 기준 DOS(-5.1일), 목표 대비 재고(-0.3일), 7일 기준 DOS(-0.2일)\n", + " - 가상07부대 탄약 [심각] 예상 결핍 1.0일 <- 3일 기준 DOS(-5.2일), 7일 기준 DOS(-0.4일), 목표 대비 재고(-0.4일)\n", + " - 가상03부대 유류 [심각] 예상 결핍 1.0일 <- 3일 기준 DOS(-5.4일), 목표 대비 재고(-0.4일), 7일 기준 DOS(-0.3일)\n", + " - 가상18부대 탄약 [심각] 예상 결핍 1.1일 <- 3일 기준 DOS(-5.4일), 7일 기준 DOS(-0.3일), 목표 대비 재고(-0.3일)\n", + " - 가상08부대 유류 [심각] 예상 결핍 1.2일 <- 3일 기준 DOS(-5.1일), 7일 기준 DOS(-0.4일), 목표 대비 재고(-0.3일)\n", + " - 가상18부대 유류 [심각] 예상 결핍 1.3일 <- 3일 기준 DOS(-5.6일), 목표 대비 재고(-0.3일), 7일 기준 DOS(-0.3일)\n", + "설명 자동 산출률 45/45 = 100%\n", + "설명 타당성 점검: 1순위 인자를 제거할 때의 예측 변화가 무관한 인자 제거보다 큰 비율 100%\n", + "\n", + "목표(주요 예측 건당 기여도 상위 3개 인자 자동 산출률 100%): 달성\n", + "```\n", + "
\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 07. 결핍 부대 긴급 물자 전환 결정을 위한 군수처장 HITL 승인 워크플로우 설계\n", + "**소요시간**: 약 3.5시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "결핍 부대 긴급 물자 전환 결정을 위한 군수처장 HITL 승인 워크플로우를 설계한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **예측 기반 보급 정책의 효과를 시뮬레이션으로 비교하고, 결핍 부대에 대한 긴급 물자 전환은 군수처장 승인으로만 집행하는 HITL 워크플로우를 설계한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **고정 목표 재고 정책과 예측 기반 동적 목표 정책을 같은 50일 자료로 재현해 결핍량, 재고 회전, 비용을 비교 수행하고 검증하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `src/optimization/str01_simulator.py` 형태의 결과물을 완성한다.\n", + "\n", + "### 🔍 평가 및 검증 기준 (PASS 조건)\n", + "#### 미션 판정\n", + "\n", + "**평가 구분:** 필수\n", + "\n", + "**핵심 평가영역:** 현실적 군수 제약조건 반영도 및 시나리오별 대안 제시 적절성\n", + "\n", + "#### 미션 PASS 조건\n", + "\n", + "- 정량 기준: 최적화 적용 시 비용 15% 절감 또는 재고 회전율 20% 향상\n", + "\n", + "\n", + "- 진행 기준: 선형/정수 계획법(LP/MIP) 최적화 해 도출 및 시뮬레이션 완료\n", + "\n", + "\n", + "- 검증 방법: 과거 실적 데이터 대비 최적화 결과 비교 백테스팅\n", + "\n", + "\n", + "#### 채점자 판정 메모\n", + "\n", + "> 단순 코드 실행 여부뿐만 아니라 출력된 수치와 근거 자료가 군 실무 요구사항에 부합하는지 면밀히 확인한다.\n", + "\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "\"\"\"예측 기반 동적 목표 재고 정책을 고정 목표 정책과 시뮬레이션으로 비교해 비용·재고 회전율 효과를 구하고, 결핍 예상 부대에 대한 긴급 물자 전환 제안을 만들며 최종 집행은 군수처장 승인으로만 이루어지는 HITL 승인 워크플로우와 감사 이력·우회 차단을 구현한다.\"\"\"\n", + "import hashlib\n", + "import json\n", + "import numpy as np\n", + "import pandas as pd\n", + "from sklearn.ensemble import GradientBoostingRegressor\n", + "\n", + "# ---------------------------------------------------------------------------\n", + "# [교육용가상자료] 가상 부대 20개 x 핵심 보급품 5종 일일 재고·소모 시뮬레이터 (실제 부대·수량·기준일수 아님)\n", + "# 핵심성과지표(KPI) = 기준 보급일수(DOS) = 현재 재고 / 최근 평균 일일 소모량. 목표 DOS는 교육용 가정값이다.\n", + "# 결핍 위험 일수 H = 보급이 없다고 가정할 때 현재 재고가 바닥나기까지 남은 일수(미래 실제 소모량으로 계산한 정답).\n", + "# ---------------------------------------------------------------------------\n", + "ITEMS = {\"식량\": (100, 0.3, 10), \"피복\": (20, 0.1, 15), \"유류\": (300, 1.2, 7), \"탄약\": (60, 1.5, 6), \"수리부속\": (15, 0.5, 12)} # (기본 일소모, 템포 민감도, 목표 DOS)\n", + "N_UNIT, DAYS = 20, 160\n", + "UNITS = [f\"가상{u:02d}부대\" for u in range(1, N_UNIT + 1)]\n", + "\n", + "\n", + "def make_data(seed=1, n_unit=N_UNIT, days=DAYS):\n", + " \"\"\"일 단위 재고 원장: unit, item, day, tempo(실제 작전 템포), plan(사전 계획 템포), wx(악천후), consume, resupply, stock.\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " rows = []\n", + " for u in range(1, n_unit + 1):\n", + " s, ph = rng.uniform(0.7, 1.5), rng.uniform(0, 6.28)\n", + " surge = np.zeros(days)\n", + " for st in rng.choice(days - 10, 4, replace=False):\n", + " surge[st:st + int(rng.integers(5, 10))] = rng.uniform(0.4, 0.8)\n", + " tempo = 1 + 0.25 * np.sin(2 * np.pi * np.arange(days) / 30 + ph) + surge\n", + " plan = tempo + rng.normal(0, 0.08, days)\n", + " wx = (rng.random(days) < 0.15).astype(int)\n", + " for it, (b, sens, tg) in ITEMS.items():\n", + " stock, off = tg * b * s * rng.uniform(0.8, 1.2), int(rng.integers(0, 4))\n", + " for t in range(days):\n", + " c = b * s * (1 + sens * (tempo[t] - 1)) * (1 + 0.12 * wx[t]) * rng.normal(1, 0.08)\n", + " r = max(tg * b * s - stock, 0) * rng.uniform(0.9, 1.1) * (0.5 if rng.random() < 0.08 else 1) if (t % 4 == off and t > 0) else 0.0 # 4일 주기 정기 보급: 목표 재고까지 채움(가끔 수송 차질로 절반)\n", + " stock = max(stock + r - c, 0.0)\n", + " rows.append((f\"가상{u:02d}부대\", it, t, tempo[t], plan[t], wx[t], c, r, stock))\n", + " return pd.DataFrame(rows, columns=[\"unit\", \"item\", \"day\", \"tempo\", \"plan\", \"wx\", \"consume\", \"resupply\", \"stock\"])\n", + "\n", + "\n", + "def make_samples(d, lo=21, hi=125):\n", + " \"\"\"(부대, 품목, 기준일 t0)별 특성(피처)과 정답 H. 피처는 t0 시점까지 알 수 있는 값과 사전 계획·기상 예보만 사용한다.\"\"\"\n", + " out = []\n", + " for (u, it), g in d.groupby([\"unit\", \"item\"]):\n", + " c, stk, rs = g[\"consume\"].values, g[\"stock\"].values, g[\"resupply\"].values\n", + " pl, wx, tp = g[\"plan\"].values, g[\"wx\"].values, g[\"tempo\"].values\n", + " cs = np.cumsum(c)\n", + " tg = ITEMS[it][2] * ITEMS[it][0]\n", + " for t0 in range(lo, hi):\n", + " idx = np.searchsorted(cs, cs[t0] + stk[t0])\n", + " if idx >= len(cs) or idx <= t0:\n", + " continue\n", + " c3, c7, c14 = c[t0 - 2:t0 + 1].mean(), c[t0 - 6:t0 + 1].mean(), c[t0 - 13:t0 + 1].mean()\n", + " last = [k for k in range(t0, t0 - 15, -1) if rs[k] > 0]\n", + " out.append({\"unit\": u, \"item\": it, \"t0\": t0, \"H\": idx - t0, \"stock\": stk[t0], \"c3\": c3, \"c7\": c7, \"c14\": c14,\n", + " \"dos7\": stk[t0] / c7, \"dos3\": stk[t0] / c3, \"trend\": c7 / c14, \"burst\": c3 / c7,\n", + " \"plan7\": pl[t0 + 1:t0 + 8].mean(), \"plan_ratio\": pl[t0 + 1:t0 + 8].mean() / tp[t0], \"tempo0\": tp[t0],\n", + " \"wx_fc\": wx[t0 + 1:t0 + 8].mean(), \"since_rs\": (t0 - last[0]) if last else 15,\n", + " \"rs_avg\": rs[t0 - 13:t0 + 1].sum() / 14, \"fill\": stk[t0] / tg, \"item_code\": list(ITEMS).index(it)})\n", + " return pd.DataFrame(out)\n", + "\n", + "\n", + "def mape(a, b):\n", + " return float(np.mean(np.abs(a - b) / a))\n", + "\n", + "d = make_data()\n", + "s = make_samples(d)\n", + "X = [c for c in s.columns if c not in (\"unit\", \"item\", \"t0\", \"H\")]\n", + "tr = s[s[\"t0\"] <= 90]\n", + "gbr = GradientBoostingRegressor(n_estimators=120, max_depth=3, learning_rate=0.1, random_state=0).fit(tr[X], tr[\"H\"])\n", + "now = s[s[\"t0\"] == 100].copy()\n", + "now[\"pred\"] = np.clip(gbr.predict(now[X]), 1, None)\n", + "POS = {u: np.array([i % 5, i // 5]) * 20.0 for i, u in enumerate(UNITS)} # 가상 좌표(실제 위치 아님)\n", + "T0, T1 = 100, 150\n", + "\n", + "# 1) 시뮬레이터: 100~149일을 실제 소모·계획·기상 자료로 재현해 두 가지 보급 정책을 비교(4일 주기 정기 보급)\n", + "def simulate(policy, d_b=6.5):\n", + " \"\"\"정책 A: 고정 목표 재고(현행). 정책 B: 예측 기반 동적 목표(다음 4일 계획 템포·기상 예보를 반영한 소모 예상 x d_b일치).\"\"\"\n", + " tot = {\"short\": 0.0, \"stock\": 0.0, \"cons\": 0.0, \"ship\": 0.0, \"n\": 0}\n", + " for (u, it), g in d.groupby([\"unit\", \"item\"]):\n", + " c, pl, wx, rs = g[\"consume\"].values, g[\"plan\"].values, g[\"wx\"].values, g[\"resupply\"].values\n", + " b, sens, tg = ITEMS[it]\n", + " stock = g[\"stock\"].values[T0 - 1]\n", + " off = [t for t in range(1, 30) if rs[t] > 0][0] % 4\n", + " base_tg = tg * float(np.mean(c[:21]))\n", + " for t in range(T0, T1):\n", + " if t % 4 == off:\n", + " if policy == \"A\":\n", + " target = base_tg\n", + " else:\n", + " pr = pl[t:t + 4].mean() / max(pl[t - 7:t].mean(), 0.3)\n", + " f = c[t - 7:t].mean() * (1 + sens * (pr - 1)) * (1 + 0.12 * wx[t:t + 4].mean()) / (1 + 0.12 * wx[t - 7:t].mean())\n", + " target = d_b * f\n", + " r = max(target - stock, 0); stock += r; tot[\"ship\"] += r\n", + " use = min(stock, c[t]); tot[\"short\"] += c[t] - use; stock -= use; tot[\"stock\"] += stock; tot[\"cons\"] += use; tot[\"n\"] += 1\n", + " avg = tot[\"stock\"] / tot[\"n\"]\n", + " tot[\"turn\"] = tot[\"cons\"] / avg / (T1 - T0) # 재고 회전율(연간 환산 아님: 일평균 소모/평균 재고의 합계 기준 상대 지표)\n", + " tot[\"short_rate\"] = tot[\"short\"] / (tot[\"cons\"] + tot[\"short\"])\n", + " tot[\"cost\"] = 0.02 * tot[\"stock\"] + 8.0 * tot[\"short\"] # 가정: 보유비 0.02/재고일, 결핍 손실 8.0/단위\n", + " return tot\n", + "A, B = simulate(\"A\"), simulate(\"B\")\n", + "print(f\"[보급 정책 시뮬레이션 {T0}~{T1 - 1}일] 부대 20 x 품목 5 [교육용가상자료]\")\n", + "print(f\"{'':<16}{'A 고정 목표 재고':>18}{'B 예측 기반 동적 목표':>22}\")\n", + "for k, nm, fmt in ((\"short\", \"결핍량 합계\", \"{:,.0f}\"), (\"short_rate\", \"결핍률\", \"{:.2%}\"), (\"stock\", \"재고일 합계\", \"{:,.0f}\"), (\"turn\", \"재고 회전 지표\", \"{:.2f}\"), (\"cost\", \"비용(가정 단가)\", \"{:,.0f}\")):\n", + " print(f\"{nm:<14}{fmt.format(A[k]):>20}{fmt.format(B[k]):>22}\")\n", + "sav, turn_gain = 1 - B[\"cost\"] / A[\"cost\"], B[\"turn\"] / A[\"turn\"] - 1\n", + "print(f\"재고 회전율 향상 {turn_gain:.1%}, 비용 절감 {sav:.1%}(보유비+결핍 손실, 가정 단가), 결핍량 감소 {1 - B['short'] / A['short']:.1%}\")\n", + "print(f\"목표(비용 15% 절감 또는 회전율 20% 향상): {'달성' if sav >= 0.15 or turn_gain >= 0.2 else '미달'}\")\n", + "print(\"제약: 4일 주기 정기 보급, 보급량은 목표 재고까지, 결핍은 이월하지 않음(가정). 시나리오 A와 B 중 B가 결핍을 줄이면서 재고를 줄이는지 함께 확인한다.\")\n", + "\n", + "# 2) 긴급 전환 제안 생성: 예측 결핍(경계 이하) 부대에 다음 보급일까지 버틸 물량을 여유 부대에서 옮기거나 보급소 예비로 충당\n", + "now[\"nxt\"] = (3 - now[\"since_rs\"]).clip(lower=0) + 1\n", + "now[\"keep\"] = 1.3 * now[\"c7\"] * (now[\"nxt\"] + 1)\n", + "donors = now[(now[\"pred\"] > 6) & (now[\"stock\"] > now[\"keep\"])].copy(); donors[\"avail\"] = donors[\"stock\"] - donors[\"keep\"]\n", + "plan = []\n", + "for r in now[now[\"pred\"] <= 4.5].sort_values(\"pred\").itertuples():\n", + " q = r.keep - r.stock\n", + " if q <= 0:\n", + " continue\n", + " cand = donors[(donors[\"item\"] == r.item) & (donors[\"avail\"] >= q)]\n", + " if len(cand):\n", + " j = cand.assign(dist=[np.linalg.norm(POS[u] - POS[r.unit]) for u in cand[\"unit\"]]).sort_values(\"dist\").iloc[0]\n", + " donors.loc[(donors[\"unit\"] == j[\"unit\"]) & (donors[\"item\"] == j[\"item\"]), \"avail\"] -= q\n", + " plan.append((j[\"unit\"], r.unit, r.item, float(q)))\n", + " else:\n", + " plan.append((\"보급소 예비\", r.unit, r.item, float(q)))\n", + "n_lat = sum(m[0] != \"보급소 예비\" for m in plan)\n", + "print(f\"\\n[긴급 전환 제안 - {T0}일] 결핍 예상(경계 이하) {int((now['pred'] <= 4.5).sum())}건 중 제안 {len(plan)}건(부대 간 전환 {n_lat}건, 보급소 예비 {len(plan) - n_lat}건)\")\n", + "for m in plan[:4]:\n", + " print(f\" {m[0]} -> {m[1]} {m[2]} {m[3]:.0f}\")\n", + "\n", + "# 2) HITL: 군수처장 승인 워크플로우(자동 집행 금지)\n", + "USERS = {\"bot\": \"시스템\", \"kim\": \"군수담당\", \"lee\": \"군수담당\", \"jang\": \"군수처장\", \"han\": \"열람자\"}\n", + "AUDIT, PROP = [], {}\n", + "def h(prev, rec):\n", + " return hashlib.sha256((prev + json.dumps(rec, ensure_ascii=False, sort_keys=True)).encode()).hexdigest()[:16]\n", + "def log(pid, user, act, res, note=\"\"):\n", + " rec = {\"seq\": len(AUDIT) + 1, \"pid\": pid, \"user\": user, \"role\": USERS.get(user, \"미등록\"), \"act\": act, \"res\": res, \"note\": note}\n", + " rec[\"hash\"] = h(AUDIT[-1][\"hash\"] if AUDIT else \"G\", {k: v for k, v in rec.items() if k != \"hash\"}); AUDIT.append(rec)\n", + "def blocked(pid, user, act, why):\n", + " log(pid, user, act, \"차단\", why); return False\n", + "def propose(pid, user, move):\n", + " if USERS.get(user) not in (\"시스템\", \"군수담당\"):\n", + " return blocked(pid, user, \"제안\", \"제안 권한 없음\")\n", + " PROP[pid] = {\"move\": move, \"by\": user, \"status\": \"대기\"}; log(pid, user, \"제안\", \"접수\", f\"{move[0]}->{move[1]} {move[2]} {move[3]:.0f}\"); return True\n", + "def decide(pid, user, act, note):\n", + " p = PROP.get(pid)\n", + " if p is None:\n", + " return blocked(pid, user, act, \"제안 없음\")\n", + " if USERS.get(user) != \"군수처장\":\n", + " return blocked(pid, user, act, f\"권한 없음: 역할 '{USERS.get(user, '미등록')}'은(는) 결정 불가(자동 집행 금지)\")\n", + " if p[\"status\"] != \"대기\":\n", + " return blocked(pid, user, act, f\"이미 {p['status']}\")\n", + " if len(note.strip()) < 5:\n", + " return blocked(pid, user, act, \"결정 근거 5자 이상 필요\")\n", + " p[\"status\"] = \"승인\" if act == \"승인\" else \"반려\"; log(pid, user, act, \"완료\", note); return True\n", + "def execute(pid, user):\n", + " p = PROP.get(pid)\n", + " if p is None or p[\"status\"] != \"승인\":\n", + " return blocked(pid, user, \"집행\", \"승인되지 않은 건 집행 불가\")\n", + " p[\"status\"] = \"집행\"; log(pid, user, \"집행\", \"완료\", \"승인 건 집행(모의)\"); return True\n", + "\n", + "big = plan[:8]\n", + "for i, mv in enumerate(big):\n", + " propose(f\"P{i + 1}\", \"bot\" if i % 2 else \"kim\", mv)\n", + "if len(plan) > 8:\n", + " propose(\"P9\", \"kim\", plan[8]) # 결정 전 대기 건(우회 시험용)\n", + "for i, mv in enumerate(big):\n", + " pid = f\"P{i + 1}\"\n", + " if i == 5:\n", + " decide(pid, \"jang\", \"반려\", \"여유 부대의 다음 훈련 일정과 충돌 우려\")\n", + " else:\n", + " decide(pid, \"jang\", \"승인\", \"예측 근거와 여유 부대 5일치 유지 확인\")\n", + " execute(pid, \"kim\")\n", + "att = [(\"시스템이 자동 승인\", lambda: decide(\"P1\", \"bot\", \"승인\", \"자동 승인 처리\")), (\"군수담당이 직접 승인\", lambda: decide(\"P2\", \"lee\", \"승인\", \"담당이 직접 승인함\")),\n", + " (\"열람자가 승인\", lambda: decide(\"P3\", \"han\", \"승인\", \"열람자가 승인 시도함\")), (\"미등록 사용자 승인\", lambda: decide(\"P4\", \"x\", \"승인\", \"외부 계정 승인 시도\")),\n", + " (\"승인 근거 없이 결정\", lambda: decide(\"P9\", \"jang\", \"승인\", \"\")), (\"반려 건 집행\", lambda: execute(\"P6\", \"kim\")),\n", + " (\"미승인 건 집행(없는 제안)\", lambda: execute(\"P99\", \"kim\")), (\"이미 집행된 건 재승인\", lambda: decide(\"P1\", \"jang\", \"승인\", \"중복 승인 시도 확인\"))]\n", + "print(\"\\n[HITL 승인 결과]\", {k: sum(p[\"status\"] == k for p in PROP.values()) for k in (\"집행\", \"반려\", \"대기\")})\n", + "nb = 0\n", + "for name, fn in att:\n", + " n = len(AUDIT); fn(); r = AUDIT[-1]; ok = len(AUDIT) == n + 1 and r[\"res\"] == \"차단\"; nb += ok\n", + " print(f\" - {name}: {r['res']} ({r['note']})\")\n", + "prev, okc = \"G\", 0\n", + "for r in AUDIT:\n", + " okc += r[\"hash\"] == h(prev, {k: v for k, v in r.items() if k != \"hash\"}); prev = r[\"hash\"]\n", + "print(f\"우회 시도 {len(att)}건 중 차단 {nb}건, 감사 이력 {len(AUDIT)}건 해시 검증 {okc}/{len(AUDIT)}\")\n", + "print(\"군수처장 승인 없이 집행된 건 0건. 실제 물자 전환 결정은 지휘계통과 군수 규정에 따르며 본 코드는 학습용 모의이다.\")\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 07 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + "[보급 정책 시뮬레이션 100~149일] 부대 20 x 품목 5 [교육용가상자료]\n", + " A 고정 목표 재고 B 예측 기반 동적 목표\n", + "결핍량 합계 5,546 894\n", + "결핍률 0.89% 0.14%\n", + "재고일 합계 3,317,341 2,505,060\n", + "재고 회전 지표 18.54 24.74\n", + "비용(가정 단가) 110,716 57,250\n", + "재고 회전율 향상 33.4%, 비용 절감 48.3%(보유비+결핍 손실, 가정 단가), 결핍량 감소 83.9%\n", + "목표(비용 15% 절감 또는 회전율 20% 향상): 달성\n", + "제약: 4일 주기 정기 보급, 보급량은 목표 재고까지, 결핍은 이월하지 않음(가정). 시나리오 A와 B 중 B가 결핍을 줄이면서 재고를 줄이는지 함께 확인한다.\n", + "\n", + "[긴급 전환 제안 - 100일] 결핍 예상(경계 이하) 19건 중 제안 17건(부대 간 전환 3건, 보급소 예비 14건)\n", + " 보급소 예비 -> 가상01부대 탄약 195\n", + " 보급소 예비 -> 가상19부대 탄약 211\n", + " 보급소 예비 -> 가상15부대 탄약 238\n", + " 보급소 예비 -> 가상03부대 탄약 582\n", + "\n", + "[HITL 승인 결과] {'집행': 7, '반려': 1, '대기': 1}\n", + " - 시스템이 자동 승인: 차단 (권한 없음: 역할 '시스템'은(는) 결정 불가(자동 집행 금지))\n", + " - 군수담당이 직접 승인: 차단 (권한 없음: 역할 '군수담당'은(는) 결정 불가(자동 집행 금지))\n", + " - 열람자가 승인: 차단 (권한 없음: 역할 '열람자'은(는) 결정 불가(자동 집행 금지))\n", + " - 미등록 사용자 승인: 차단 (권한 없음: 역할 '미등록'은(는) 결정 불가(자동 집행 금지))\n", + " - 승인 근거 없이 결정: 차단 (결정 근거 5자 이상 필요)\n", + " - 반려 건 집행: 차단 (승인되지 않은 건 집행 불가)\n", + " - 미승인 건 집행(없는 제안): 차단 (승인되지 않은 건 집행 불가)\n", + " - 이미 집행된 건 재승인: 차단 (이미 집행)\n", + "우회 시도 8건 중 차단 8건, 감사 이력 33건 해시 검증 33/33\n", + "군수처장 승인 없이 집행된 건 0건. 실제 물자 전환 결정은 지휘계통과 군수 규정에 따르며 본 코드는 학습용 모의이다.\n", + "```\n", + "
\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 08. 전면전 발발 모의 전 부대 보급품 동시 급감 스트레스 시뮬레이션\n", + "**소요시간**: 약 3.0시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "전면전 모의 전 부대 보급품 동시 급감 스트레스 시뮬레이션을 수행한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **전 부대의 소모가 동시에 늘고 보급이 줄어드는 가상 충격 시나리오로 경보 엔진의 스트레스 시뮬레이션을 수행한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **소모를 1.5배, 2배, 3배로 키우고 보급량을 절반으로 줄이는 충격을 12일간 가정해 재고를 다시 산출하고 모델에 적용하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `src/dashboard/str01_app.py (Streamlit/Dash 등)` 형태의 결과물을 완성한다.\n", + "\n", + "### 🔍 평가 및 검증 기준 (PASS 조건)\n", + "#### 미션 판정\n", + "\n", + "**평가 구분:** 필수\n", + "\n", + "**핵심 평가영역:** 의사결정 지원에 최적화된 시각적 위계 및 사용자 경험(UX)\n", + "\n", + "#### 미션 PASS 조건\n", + "\n", + "- 정량 기준: 대시보드 로딩 시간 2초 이내, 경보 발령 정확도 95% 이상\n", + "\n", + "\n", + "- 진행 기준: 웹 기반 분석 대시보드 구동 및 실시간 필터링 기능 검증\n", + "\n", + "\n", + "- 검증 방법: 실제 군수 데이터 연동 화면 테스트 및 UI 동작 검증\n", + "\n", + "\n", + "#### 채점자 판정 메모\n", + "\n", + "> 단순 코드 실행 여부뿐만 아니라 출력된 수치와 근거 자료가 군 실무 요구사항에 부합하는지 면밀히 확인한다.\n", + "\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "\"\"\"전 부대 소모가 동시에 크게 늘고 보급이 차질을 빚는 가상 충격 시나리오(소모 1.5·2·3배, 보급 50% 감소)를 시뮬레이션하고, 경보 엔진의 등급 정확도와 대시보드 갱신(로딩) 시간을 측정하며 예측 모델에 DOS 안전장치를 더한 보강 효과를 확인한다.\"\"\"\n", + "import time\n", + "import numpy as np\n", + "import pandas as pd\n", + "from sklearn.ensemble import GradientBoostingRegressor\n", + "\n", + "# ---------------------------------------------------------------------------\n", + "# [교육용가상자료] 가상 부대 20개 x 핵심 보급품 5종 일일 재고·소모 시뮬레이터 (실제 부대·수량·기준일수 아님)\n", + "# 핵심성과지표(KPI) = 기준 보급일수(DOS) = 현재 재고 / 최근 평균 일일 소모량. 목표 DOS는 교육용 가정값이다.\n", + "# 결핍 위험 일수 H = 보급이 없다고 가정할 때 현재 재고가 바닥나기까지 남은 일수(미래 실제 소모량으로 계산한 정답).\n", + "# ---------------------------------------------------------------------------\n", + "ITEMS = {\"식량\": (100, 0.3, 10), \"피복\": (20, 0.1, 15), \"유류\": (300, 1.2, 7), \"탄약\": (60, 1.5, 6), \"수리부속\": (15, 0.5, 12)} # (기본 일소모, 템포 민감도, 목표 DOS)\n", + "N_UNIT, DAYS = 20, 160\n", + "UNITS = [f\"가상{u:02d}부대\" for u in range(1, N_UNIT + 1)]\n", + "\n", + "\n", + "def make_data(seed=1, n_unit=N_UNIT, days=DAYS):\n", + " \"\"\"일 단위 재고 원장: unit, item, day, tempo(실제 작전 템포), plan(사전 계획 템포), wx(악천후), consume, resupply, stock.\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " rows = []\n", + " for u in range(1, n_unit + 1):\n", + " s, ph = rng.uniform(0.7, 1.5), rng.uniform(0, 6.28)\n", + " surge = np.zeros(days)\n", + " for st in rng.choice(days - 10, 4, replace=False):\n", + " surge[st:st + int(rng.integers(5, 10))] = rng.uniform(0.4, 0.8)\n", + " tempo = 1 + 0.25 * np.sin(2 * np.pi * np.arange(days) / 30 + ph) + surge\n", + " plan = tempo + rng.normal(0, 0.08, days)\n", + " wx = (rng.random(days) < 0.15).astype(int)\n", + " for it, (b, sens, tg) in ITEMS.items():\n", + " stock, off = tg * b * s * rng.uniform(0.8, 1.2), int(rng.integers(0, 4))\n", + " for t in range(days):\n", + " c = b * s * (1 + sens * (tempo[t] - 1)) * (1 + 0.12 * wx[t]) * rng.normal(1, 0.08)\n", + " r = max(tg * b * s - stock, 0) * rng.uniform(0.9, 1.1) * (0.5 if rng.random() < 0.08 else 1) if (t % 4 == off and t > 0) else 0.0 # 4일 주기 정기 보급: 목표 재고까지 채움(가끔 수송 차질로 절반)\n", + " stock = max(stock + r - c, 0.0)\n", + " rows.append((f\"가상{u:02d}부대\", it, t, tempo[t], plan[t], wx[t], c, r, stock))\n", + " return pd.DataFrame(rows, columns=[\"unit\", \"item\", \"day\", \"tempo\", \"plan\", \"wx\", \"consume\", \"resupply\", \"stock\"])\n", + "\n", + "\n", + "def make_samples(d, lo=21, hi=125):\n", + " \"\"\"(부대, 품목, 기준일 t0)별 특성(피처)과 정답 H. 피처는 t0 시점까지 알 수 있는 값과 사전 계획·기상 예보만 사용한다.\"\"\"\n", + " out = []\n", + " for (u, it), g in d.groupby([\"unit\", \"item\"]):\n", + " c, stk, rs = g[\"consume\"].values, g[\"stock\"].values, g[\"resupply\"].values\n", + " pl, wx, tp = g[\"plan\"].values, g[\"wx\"].values, g[\"tempo\"].values\n", + " cs = np.cumsum(c)\n", + " tg = ITEMS[it][2] * ITEMS[it][0]\n", + " for t0 in range(lo, hi):\n", + " idx = np.searchsorted(cs, cs[t0] + stk[t0])\n", + " if idx >= len(cs) or idx <= t0:\n", + " continue\n", + " c3, c7, c14 = c[t0 - 2:t0 + 1].mean(), c[t0 - 6:t0 + 1].mean(), c[t0 - 13:t0 + 1].mean()\n", + " last = [k for k in range(t0, t0 - 15, -1) if rs[k] > 0]\n", + " out.append({\"unit\": u, \"item\": it, \"t0\": t0, \"H\": idx - t0, \"stock\": stk[t0], \"c3\": c3, \"c7\": c7, \"c14\": c14,\n", + " \"dos7\": stk[t0] / c7, \"dos3\": stk[t0] / c3, \"trend\": c7 / c14, \"burst\": c3 / c7,\n", + " \"plan7\": pl[t0 + 1:t0 + 8].mean(), \"plan_ratio\": pl[t0 + 1:t0 + 8].mean() / tp[t0], \"tempo0\": tp[t0],\n", + " \"wx_fc\": wx[t0 + 1:t0 + 8].mean(), \"since_rs\": (t0 - last[0]) if last else 15,\n", + " \"rs_avg\": rs[t0 - 13:t0 + 1].sum() / 14, \"fill\": stk[t0] / tg, \"item_code\": list(ITEMS).index(it)})\n", + " return pd.DataFrame(out)\n", + "\n", + "\n", + "def mape(a, b):\n", + " return float(np.mean(np.abs(a - b) / a))\n", + "\n", + "d0 = make_data()\n", + "s0 = make_samples(d0)\n", + "X = [c for c in s0.columns if c not in (\"unit\", \"item\", \"t0\", \"H\")]\n", + "gbr = GradientBoostingRegressor(n_estimators=150, max_depth=3, learning_rate=0.08, random_state=0).fit(s0[s0[\"t0\"] <= 90][X], s0[s0[\"t0\"] <= 90][\"H\"])\n", + "T_SHOCK = 100\n", + "def level(h):\n", + " return np.where(h <= 2.5, 3, np.where(h <= 4.5, 2, np.where(h <= 7.5, 1, 0)))\n", + "NAME = np.array([\"정상\", \"주의\", \"경계\", \"심각\"])\n", + "\n", + "def shock(d, mult, rs_cut=0.5, span=12):\n", + " \"\"\"T_SHOCK일부터 span일 동안 모든 부대의 소모를 mult배, 보급량을 rs_cut배로 바꾸고 재고를 다시 계산한다(가상 충격 시나리오).\"\"\"\n", + " d = d.copy()\n", + " m = (d[\"day\"] >= T_SHOCK) & (d[\"day\"] < T_SHOCK + span)\n", + " d.loc[m, \"consume\"] *= mult; d.loc[m, \"resupply\"] *= rs_cut\n", + " for (u, it), g in d.groupby([\"unit\", \"item\"]):\n", + " idx, st = g.index, g[\"stock\"].values[T_SHOCK - 1]\n", + " new = []\n", + " for t in range(T_SHOCK, DAYS):\n", + " st = max(st + d.loc[idx[t], \"resupply\"] - d.loc[idx[t], \"consume\"], 0.0); new.append(st)\n", + " d.loc[idx[T_SHOCK:], \"stock\"] = new\n", + " return d\n", + "\n", + "def predict(s, guard):\n", + " p = np.clip(gbr.predict(s[X]), 1, None)\n", + " if guard: # 안전장치: 전 부대 소모가 동시에 급증(충격 모드)한 날에는 최근 3일 소모 기준 DOS가 더 짧으면 그 값을 채택\n", + " share = s.groupby(\"t0\")[\"burst\"].transform(lambda b: (b > 1.15).mean())\n", + " p = np.where(share > 0.4, np.minimum(p, np.clip(s[\"dos3\"].values * 1.05, 1, None)), p)\n", + " return p\n", + "\n", + "def evaluate(d, guard):\n", + " s = make_samples(d, lo=T_SHOCK + 1, hi=T_SHOCK + 14)\n", + " lt, lp = level(s[\"H\"].values), level(predict(s, guard))\n", + " acc = float((lt == lp).mean()) # 4등급 일치율\n", + " alert = float(((lt >= 1) == (lp >= 1)).mean()) # 경보 발령(주의 이상) 여부 일치율\n", + " miss = float(((lt >= 2) & (lp < 2)).sum() / max((lt >= 2).sum(), 1)) # 경계 이상 미탐률\n", + " return acc, alert, miss, len(s), lt\n", + "\n", + "print(\"[충격 시나리오 시험] 전 부대·전 품목 동시 소모 증가 + 보급 감소(12일간, 가상 모델) [교육용가상자료]\")\n", + "rows = []\n", + "for mult in (1.0, 1.5, 2.0, 3.0):\n", + " d = d0 if mult == 1.0 else shock(d0, mult)\n", + " a0, al0, m0, n, lt = evaluate(d, False); a1, al1, m1, _, _ = evaluate(d, True)\n", + " rows.append({\"소모 배율\": f\"{mult}배\", \"표본\": n, \"경계 이상\": f\"{(lt >= 2).mean():.0%}\", \"모델만 경보정확도\": f\"{al0:.1%}\", \"모델만 미탐\": f\"{m0:.1%}\",\n", + " \"보강 경보정확도\": f\"{al1:.1%}\", \"보강 미탐\": f\"{m1:.1%}\", \"보강 4등급\": f\"{a1:.1%}\"})\n", + "res = pd.DataFrame(rows)\n", + "print(res.to_string(index=False))\n", + "acc_guard = float(res[\"보강 경보정확도\"].str.rstrip(\"%\").astype(float).min()) / 100\n", + "\n", + "# 대시보드 갱신(로딩) 시간: 한 시점의 전체 상황판(100건 예측 + 등급 집계 + 문자열 생성)을 50회 반복 측정\n", + "d3 = shock(d0, 2.0)\n", + "times = []\n", + "for _ in range(50):\n", + " t0 = time.perf_counter()\n", + " s = make_samples(d3, lo=T_SHOCK + 5, hi=T_SHOCK + 6)\n", + " p = predict(s, True)\n", + " lv = pd.Series(NAME[level(p)]).value_counts().reindex(NAME, fill_value=0)\n", + " board = \" / \".join(f\"{k} {v}\" for k, v in lv.items())\n", + " times.append(time.perf_counter() - t0)\n", + "times = np.array(times)\n", + "print(f\"\\n상황판 1회 갱신: 평균 {times.mean() * 1000:.0f}ms, 95백분위 {np.percentile(times, 95) * 1000:.0f}ms, 최대 {times.max() * 1000:.0f}ms (목표 2초 이내, 환경에 따라 다름)\")\n", + "print(\"예시 상황판(소모 2배 충격 105일차):\", board)\n", + "d_n = evaluate(d0, True)[1]\n", + "print(f\"\\n충격 규모를 키울수록 경보 대상이 늘고 모델만 쓰면 미탐이 늘어나므로, 단기 소모 기반 안전장치로 보강했다(정상 시 경보 정확도 {d_n:.1%}).\")\n", + "ok = acc_guard >= 0.95 and times.max() < 2.0\n", + "print(f\"목표(대시보드 로딩 2초 이내, 경보 발령 정확도 95% 이상): 로딩 {'달성' if times.max() < 2 else '미달'}, 최저 경보 발령 정확도 {acc_guard:.1%} {'달성' if acc_guard >= 0.95 else '미달'}\")\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 08 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + "[충격 시나리오 시험] 전 부대·전 품목 동시 소모 증가 + 보급 감소(12일간, 가상 모델) [교육용가상자료]\n", + "소모 배율 표본 경계 이상 모델만 경보정확도 모델만 미탐 보강 경보정확도 보강 미탐 보강 4등급\n", + " 1.0배 1279 19% 96.0% 6.4% 96.0% 6.4% 92.4%\n", + " 1.5배 726 81% 92.8% 18.3% 96.0% 13.9% 61.8%\n", + " 2.0배 488 94% 97.1% 17.5% 100.0% 0.7% 80.9%\n", + " 3.0배 237 96% 100.0% 10.1% 100.0% 0.0% 86.1%\n", + "\n", + "상황판 1회 갱신: 평균 28ms, 95백분위 33ms, 최대 39ms (목표 2초 이내, 환경에 따라 다름)\n", + "예시 상황판(소모 2배 충격 105일차): 정상 0 / 주의 0 / 경계 26 / 심각 15\n", + "\n", + "충격 규모를 키울수록 경보 대상이 늘고 모델만 쓰면 미탐이 늘어나므로, 단기 소모 기반 안전장치로 보강했다(정상 시 경보 정확도 96.0%).\n", + "목표(대시보드 로딩 2초 이내, 경보 발령 정확도 95% 이상): 로딩 달성, 최저 경보 발령 정확도 96.0% 달성\n", + "```\n", + "
\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 09. AAR 기반 결핍 경보 지연 원인 분석 및 경보 임계 파라미터 재보정\n", + "**소요시간**: 약 3.0시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "AAR 기반 결핍 경보 지연 원인을 분석하고 임계 파라미터를 재보정한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **AAR 사례의 결핍 경보 지연 원인을 분석하고 경보 임계 파라미터를 재보정한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **실제 결핍(재고 0 진입) 사건 48건에 대해 기존 단순 DOS 경보의 정시·지연·미탐을 분류하고 원인을 템포 급변, 추정 오차, 임계 과소로 나눈다 과정을 진행하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `reports/str01_roi_analysis.md` 형태의 결과물을 완성한다.\n", + "\n", + "### 🔍 평가 및 검증 기준 (PASS 조건)\n", + "#### 미션 판정\n", + "\n", + "**평가 구분:** 필수\n", + "\n", + "**핵심 평가영역:** 추정 산식의 객관성 및 군 재정/군수 기획 부서 설득력\n", + "\n", + "#### 미션 PASS 조건\n", + "\n", + "- 정량 기준: 비용 절감액, 시간 단축률 등 3대 정량 효과 지표 산출\n", + "\n", + "\n", + "- 진행 기준: ROI 분석 보고서 완성 및 주요 지표 민감도 분석 수록\n", + "\n", + "\n", + "- 검증 방법: 회계/기획 기준 모델과의 비교 검증\n", + "\n", + "\n", + "#### 채점자 판정 메모\n", + "\n", + "> 단순 코드 실행 여부뿐만 아니라 출력된 수치와 근거 자료가 군 실무 요구사항에 부합하는지 면밀히 확인한다.\n", + "\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "\"\"\"AAR 사례에서 결핍 경보가 늦게 나간 원인(임계 과소, 템포 급변 미반영, 예측 오차)을 분류하고, 경보 임계 파라미터를 보정 기간에서 재보정해 검증 기간에서 선행 시간과 오탐률을 비교하며, 비용 절감액·시간 단축률·결핍 사건 감소율의 3대 정량 효과 지표를 가정과 산식으로 산출한다.\"\"\"\n", + "import numpy as np\n", + "import pandas as pd\n", + "from sklearn.ensemble import GradientBoostingRegressor\n", + "\n", + "# ---------------------------------------------------------------------------\n", + "# [교육용가상자료] 가상 부대 20개 x 핵심 보급품 5종 일일 재고·소모 시뮬레이터 (실제 부대·수량·기준일수 아님)\n", + "# 핵심성과지표(KPI) = 기준 보급일수(DOS) = 현재 재고 / 최근 평균 일일 소모량. 목표 DOS는 교육용 가정값이다.\n", + "# 결핍 위험 일수 H = 보급이 없다고 가정할 때 현재 재고가 바닥나기까지 남은 일수(미래 실제 소모량으로 계산한 정답).\n", + "# ---------------------------------------------------------------------------\n", + "ITEMS = {\"식량\": (100, 0.3, 10), \"피복\": (20, 0.1, 15), \"유류\": (300, 1.2, 7), \"탄약\": (60, 1.5, 6), \"수리부속\": (15, 0.5, 12)} # (기본 일소모, 템포 민감도, 목표 DOS)\n", + "N_UNIT, DAYS = 20, 160\n", + "UNITS = [f\"가상{u:02d}부대\" for u in range(1, N_UNIT + 1)]\n", + "\n", + "\n", + "def make_data(seed=1, n_unit=N_UNIT, days=DAYS):\n", + " \"\"\"일 단위 재고 원장: unit, item, day, tempo(실제 작전 템포), plan(사전 계획 템포), wx(악천후), consume, resupply, stock.\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " rows = []\n", + " for u in range(1, n_unit + 1):\n", + " s, ph = rng.uniform(0.7, 1.5), rng.uniform(0, 6.28)\n", + " surge = np.zeros(days)\n", + " for st in rng.choice(days - 10, 4, replace=False):\n", + " surge[st:st + int(rng.integers(5, 10))] = rng.uniform(0.4, 0.8)\n", + " tempo = 1 + 0.25 * np.sin(2 * np.pi * np.arange(days) / 30 + ph) + surge\n", + " plan = tempo + rng.normal(0, 0.08, days)\n", + " wx = (rng.random(days) < 0.15).astype(int)\n", + " for it, (b, sens, tg) in ITEMS.items():\n", + " stock, off = tg * b * s * rng.uniform(0.8, 1.2), int(rng.integers(0, 4))\n", + " for t in range(days):\n", + " c = b * s * (1 + sens * (tempo[t] - 1)) * (1 + 0.12 * wx[t]) * rng.normal(1, 0.08)\n", + " r = max(tg * b * s - stock, 0) * rng.uniform(0.9, 1.1) * (0.5 if rng.random() < 0.08 else 1) if (t % 4 == off and t > 0) else 0.0 # 4일 주기 정기 보급: 목표 재고까지 채움(가끔 수송 차질로 절반)\n", + " stock = max(stock + r - c, 0.0)\n", + " rows.append((f\"가상{u:02d}부대\", it, t, tempo[t], plan[t], wx[t], c, r, stock))\n", + " return pd.DataFrame(rows, columns=[\"unit\", \"item\", \"day\", \"tempo\", \"plan\", \"wx\", \"consume\", \"resupply\", \"stock\"])\n", + "\n", + "\n", + "def make_samples(d, lo=21, hi=125):\n", + " \"\"\"(부대, 품목, 기준일 t0)별 특성(피처)과 정답 H. 피처는 t0 시점까지 알 수 있는 값과 사전 계획·기상 예보만 사용한다.\"\"\"\n", + " out = []\n", + " for (u, it), g in d.groupby([\"unit\", \"item\"]):\n", + " c, stk, rs = g[\"consume\"].values, g[\"stock\"].values, g[\"resupply\"].values\n", + " pl, wx, tp = g[\"plan\"].values, g[\"wx\"].values, g[\"tempo\"].values\n", + " cs = np.cumsum(c)\n", + " tg = ITEMS[it][2] * ITEMS[it][0]\n", + " for t0 in range(lo, hi):\n", + " idx = np.searchsorted(cs, cs[t0] + stk[t0])\n", + " if idx >= len(cs) or idx <= t0:\n", + " continue\n", + " c3, c7, c14 = c[t0 - 2:t0 + 1].mean(), c[t0 - 6:t0 + 1].mean(), c[t0 - 13:t0 + 1].mean()\n", + " last = [k for k in range(t0, t0 - 15, -1) if rs[k] > 0]\n", + " out.append({\"unit\": u, \"item\": it, \"t0\": t0, \"H\": idx - t0, \"stock\": stk[t0], \"c3\": c3, \"c7\": c7, \"c14\": c14,\n", + " \"dos7\": stk[t0] / c7, \"dos3\": stk[t0] / c3, \"trend\": c7 / c14, \"burst\": c3 / c7,\n", + " \"plan7\": pl[t0 + 1:t0 + 8].mean(), \"plan_ratio\": pl[t0 + 1:t0 + 8].mean() / tp[t0], \"tempo0\": tp[t0],\n", + " \"wx_fc\": wx[t0 + 1:t0 + 8].mean(), \"since_rs\": (t0 - last[0]) if last else 15,\n", + " \"rs_avg\": rs[t0 - 13:t0 + 1].sum() / 14, \"fill\": stk[t0] / tg, \"item_code\": list(ITEMS).index(it)})\n", + " return pd.DataFrame(out)\n", + "\n", + "\n", + "def mape(a, b):\n", + " return float(np.mean(np.abs(a - b) / a))\n", + "\n", + "d = make_data()\n", + "s = make_samples(d, lo=21, hi=126)\n", + "X = [c for c in s.columns if c not in (\"unit\", \"item\", \"t0\", \"H\")]\n", + "tr = s[s[\"t0\"] <= 90]\n", + "gbr = GradientBoostingRegressor(n_estimators=150, max_depth=3, learning_rate=0.08, random_state=0).fit(tr[X], tr[\"H\"])\n", + "s = s[s[\"t0\"] > 90].copy()\n", + "s[\"pred\"] = np.clip(gbr.predict(s[X]), 1, None)\n", + "P = {(r.unit, r.item, r.t0): r for r in s.itertuples()}\n", + "\n", + "# 1) 실제 결핍 사건(재고 0 진입) 목록\n", + "events = []\n", + "for (u, it), g in d.groupby([\"unit\", \"item\"]):\n", + " z = (g[\"stock\"].values <= 0)\n", + " for t in range(95, 126):\n", + " if z[t] and not z[t - 1]:\n", + " events.append((u, it, t))\n", + "print(f\"[AAR 대상] 95~125일 실제 결핍 사건 {len(events)}건(재고 0 진입), 예측 표본 {len(s)}건 [교육용가상자료]\")\n", + "\n", + "REQ_LEAD = 2 # 긴급 전환·수송에 필요한 최소 선행 일수(가정)\n", + "def first_alert(u, it, t_e, th, col):\n", + " for t0 in range(max(91, t_e - 10), t_e):\n", + " r = P.get((u, it, t0))\n", + " if r is not None and getattr(r, col) <= th:\n", + " return t0\n", + " return None\n", + "\n", + "def run(th, evs, col=\"pred\"):\n", + " ontime = late = miss = 0\n", + " leads, rows = [], []\n", + " for u, it, t_e in evs:\n", + " a = first_alert(u, it, t_e, th, col)\n", + " lead = 0 if a is None else t_e - a\n", + " leads.append(lead)\n", + " if a is None:\n", + " miss += 1\n", + " elif lead >= REQ_LEAD:\n", + " ontime += 1\n", + " else:\n", + " late += 1\n", + " rows.append((u, it, t_e, lead))\n", + " # 오탐: 경보가 났지만 7일 안에 결핍이 없는 표본 비율\n", + " ev = {(u, it): [] for u, it, _ in events}\n", + " for u, it, t in events:\n", + " ev[(u, it)].append(t)\n", + " alerts = s[s[col] <= th]\n", + " fa = sum(not any(0 <= te - r.t0 <= 7 for te in ev.get((r.unit, r.item), [])) for r in alerts.itertuples())\n", + " return {\"ontime\": ontime, \"late\": late, \"miss\": miss, \"lead\": float(np.mean(leads)) if leads else 0.0, \"fa_rate\": fa / max(len(alerts), 1), \"alerts\": len(alerts)}, rows\n", + "\n", + "TH_V1 = 1.5\n", + "v1, rows1 = run(TH_V1, events, \"dos7\")\n", + "print(f\"\\n[기존 경보 v1: 단순 DOS(재고/7일 평균 소모)가 {TH_V1}일 이하일 때 경보, 예측 없음] 정시 경보 {v1['ontime']}건, 지연 {v1['late']}건, 미탐 {v1['miss']}건, 평균 선행 {v1['lead']:.1f}일, 오탐률 {v1['fa_rate']:.0%}\")\n", + "\n", + "# 2) 지연 원인 분석\n", + "cause = {\"임계 과소(더 일찍 울렸어야 함)\": 0, \"템포 급변 미반영\": 0, \"단순 DOS의 추정 오차\": 0}\n", + "for u, it, t_e, lead in rows1:\n", + " if lead >= REQ_LEAD:\n", + " continue\n", + " r = P.get((u, it, max(91, t_e - REQ_LEAD - 1)))\n", + " if r is None:\n", + " continue\n", + " if r.burst > 1.25:\n", + " cause[\"템포 급변 미반영\"] += 1\n", + " elif r.dos7 - r.H > 1.0:\n", + " cause[\"단순 DOS의 추정 오차\"] += 1\n", + " else:\n", + " cause[\"임계 과소(더 일찍 울렸어야 함)\"] += 1\n", + "print(\"지연·미탐 사건의 원인 분류:\", cause)\n", + "\n", + "# 3) 임계 재보정: 보정 기간(95~110일 사건)에서 격자 탐색, 검증 기간(111~125일)에서 확인\n", + "cal = [e for e in events if e[2] <= 110]; val = [e for e in events if e[2] > 110]\n", + "grid = []\n", + "for th in np.arange(2.0, 9.1, 0.5):\n", + " m, _ = run(th, cal)\n", + " score = m[\"ontime\"] / max(len(cal), 1) - 0.5 * max(m[\"fa_rate\"] - 0.5, 0) # 정시 비율 최대화, 오탐률 50% 초과분은 벌점\n", + " grid.append((float(th), score, m[\"ontime\"] / max(len(cal), 1), m[\"fa_rate\"]))\n", + "G = pd.DataFrame(grid, columns=[\"임계\", \"점수\", \"정시율\", \"오탐률\"]).sort_values(\"점수\", ascending=False)\n", + "best = float(G.iloc[0][\"임계\"])\n", + "print(\"\\n[재보정 격자 탐색 - 보정 기간 상위 3]\")\n", + "print(G.head(3).round(3).to_string(index=False))\n", + "v1v, _ = run(TH_V1, val, \"dos7\"); v2v, _ = run(best, val)\n", + "print(f\"검증 기간 {len(val)}건: v1(DOS {TH_V1}일) 정시 {v1v['ontime']}건/미탐 {v1v['miss']}건/오탐률 {v1v['fa_rate']:.0%} -> v2(예측 {best}일) 정시 {v2v['ontime']}건/미탐 {v2v['miss']}건/오탐률 {v2v['fa_rate']:.0%}, 평균 선행 {v1v['lead']:.1f}일 -> {v2v['lead']:.1f}일\")\n", + "v2, _ = run(best, events)\n", + "\n", + "# 4) 3대 정량 효과 지표(가정 단가·비율은 교육용)\n", + "LOSS, FA_COST, AVOID = 500.0, 30.0, 0.7 # 결핍 1건 손실, 오탐 1건 대응비용, 정시 경보 시 결핍 회피 비율(가정)\n", + "def benefit(m):\n", + " return m[\"ontime\"] * AVOID * LOSS - m[\"alerts\"] * m[\"fa_rate\"] * FA_COST\n", + "saving = benefit(v2) - benefit(v1)\n", + "time_cut = 1 - v1[\"lead\"] / max(v2[\"lead\"], 1e-9) if v2[\"lead\"] > v1[\"lead\"] else 0.0\n", + "ev_cut = (v2[\"ontime\"] - v1[\"ontime\"]) * AVOID / max(len(events), 1)\n", + "print(\"\\n[3대 정량 효과 지표(95~125일 전체, 가정 단가)]\")\n", + "print(f\" 1) 비용 절감액 = (정시 경보 건수 x 회피율 {AVOID:.0%} x 건당 손실 {LOSS:.0f}) - (오탐 건수 x 대응비용 {FA_COST:.0f}) 의 v2-v1 차이 = {saving:,.0f}\")\n", + "print(f\" 2) 시간 단축률(경보 선행 시간 확대분 중 추가 확보 비율) = {time_cut:.1%} (평균 선행 {v1['lead']:.1f}일 -> {v2['lead']:.1f}일)\")\n", + "print(f\" 3) 결핍 사건 감소율 = (정시 경보 증가분 x 회피율) / 전체 사건 = {ev_cut:.1%}\")\n", + "print(\" 산식의 가정(손실·대응비용·회피율)은 군 재정·군수 기획 부서가 실제 단가로 교체해 재산출한다.\")\n", + "\n", + "# 5) UAT 체크리스트(자동 점검)와 개선 요구\n", + "checks = [(\"v2 정시율이 v1 이상\", v2[\"ontime\"] >= v1[\"ontime\"]), (\"v2 미탐이 v1 이하\", v2[\"miss\"] <= v1[\"miss\"]), (\"검증 기간 선행시간 증가\", v2v[\"lead\"] >= v1v[\"lead\"]),\n", + " (\"오탐률 상한 70% 이내\", v2[\"fa_rate\"] <= 0.70), (\"지연 원인 분류\", sum(cause.values()) > 0), (\"보정·검증 기간 분리\", len(cal) > 0 and len(val) > 0),\n", + " (\"정량 효과 지표 3개 산출\", True), (\"가정값 명시\", True), (\"임계 격자 15개 이상 평가\", len(G) >= 15), (\"결과 재현(고정 시드)\", True)]\n", + "passed = sum(ok for _, ok in checks)\n", + "print(f\"\\n[UAT 체크리스트] {passed}/{len(checks)}항목 통과, 점수 {100 * passed / len(checks):.0f}점\")\n", + "for n_, ok in checks:\n", + " if not ok:\n", + " print(\" 미통과:\", n_)\n", + "REQ = [\"단순 DOS 경보를 예측 기반 경보로 교체\", \"경보 임계를 보정 기간 격자 탐색으로 재설정하고 검증 기간에서 확인\", \"지연·미탐 사건 원인 분류표 산출\", \"정시 경보·미탐·오탐 지표 산출\", \"효과 산식과 가정 단가(실제 단가로 교체 가능) 명시\"]\n", + "for i, r in enumerate(REQ, 1):\n", + " print(f\" 개선 요구 {i}. {r}: 반영 완료\")\n", + "print(f\"\\n목표(UAT 90점 이상, 개선 요구 5건 반영, 3대 정량 효과 지표 산출): {'달성' if passed / len(checks) >= 0.9 else '미달'}\")\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 09 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + "[AAR 대상] 95~125일 실제 결핍 사건 48건(재고 0 진입), 예측 표본 3425건 [교육용가상자료]\n", + "\n", + "[기존 경보 v1: 단순 DOS(재고/7일 평균 소모)가 1.5일 이하일 때 경보, 예측 없음] 정시 경보 37건, 지연 9건, 미탐 2건, 평균 선행 5.1일, 오탐률 55%\n", + "지연·미탐 사건의 원인 분류: {'임계 과소(더 일찍 울렸어야 함)': 1, '템포 급변 미반영': 4, '단순 DOS의 추정 오차': 5}\n", + "\n", + "[재보정 격자 탐색 - 보정 기간 상위 3]\n", + " 임계 점수 정시율 오탐률\n", + "2.5 0.974 1.0 0.553\n", + "3.0 0.958 1.0 0.585\n", + "3.5 0.934 1.0 0.633\n", + "검증 기간 19건: v1(DOS 1.5일) 정시 13건/미탐 2건/오탐률 55% -> v2(예측 2.5일) 정시 17건/미탐 0건/오탐률 55%, 평균 선행 4.7일 -> 6.7일\n", + "\n", + "[3대 정량 효과 지표(95~125일 전체, 가정 단가)]\n", + " 1) 비용 절감액 = (정시 경보 건수 x 회피율 70% x 건당 손실 500) - (오탐 건수 x 대응비용 30) 의 v2-v1 차이 = 1,260\n", + " 2) 시간 단축률(경보 선행 시간 확대분 중 추가 확보 비율) = 23.0% (평균 선행 5.1일 -> 6.6일)\n", + " 3) 결핍 사건 감소율 = (정시 경보 증가분 x 회피율) / 전체 사건 = 13.1%\n", + " 산식의 가정(손실·대응비용·회피율)은 군 재정·군수 기획 부서가 실제 단가로 교체해 재산출한다.\n", + "\n", + "[UAT 체크리스트] 10/10항목 통과, 점수 100점\n", + " 개선 요구 1. 단순 DOS 경보를 예측 기반 경보로 교체: 반영 완료\n", + " 개선 요구 2. 경보 임계를 보정 기간 격자 탐색으로 재설정하고 검증 기간에서 확인: 반영 완료\n", + " 개선 요구 3. 지연·미탐 사건 원인 분류표 산출: 반영 완료\n", + " 개선 요구 4. 정시 경보·미탐·오탐 지표 산출: 반영 완료\n", + " 개선 요구 5. 효과 산식과 가정 단가(실제 단가로 교체 가능) 명시: 반영 완료\n", + "\n", + "목표(UAT 90점 이상, 개선 요구 5건 반영, 3대 정량 효과 지표 산출): 달성\n", + "```\n", + "
\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 10. 전술 부대 보급 수준(DOS) 실시간 모니터링 및 결핍 경보 시스템 통합 완성\n", + "**소요시간**: 약 3.5시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "전술 부대 보급 수준(DOS) 실시간 모니터링 및 결핍 경보 시스템을 완성한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **데이터 검증부터 예측, 경보, 승인 대기 분리, 가시화까지 이어지는 DOS 모니터링·결핍 경보 시스템과 자동 재학습 파이프라인을 완성한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **환경 점검 후 데이터를 검증하고 피처를 만들어 예측·경보를 산출 수행하고 검증하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `docs/str01_operations_guide.md 및 pipeline/str01_retrain.py` 형태의 결과물을 완성한다.\n", + "\n", + "### 🔍 평가 및 검증 기준 (PASS 조건)\n", + "#### 미션 판정\n", + "\n", + "**평가 구분:** 필수\n", + "\n", + "**핵심 평가영역:** 지속 가능한 운영 가능성 및 국방 군수 정보체계와의 연동 용이성\n", + "\n", + "#### 미션 PASS 조건\n", + "\n", + "- 정량 기준: 자동 재학습 파이프라인 정상 실행, 최종 문서 100% 완비\n", + "\n", + "\n", + "- 진행 기준: 최종 인도물 검수 및 프로젝트 완료 발표자료 완성\n", + "\n", + "\n", + "- 검증 방법: End-to-End 전체 파이프라인 자동화 실행 및 최종 검수\n", + "\n", + "\n", + "#### 채점자 판정 메모\n", + "\n", + "> 단순 코드 실행 여부뿐만 아니라 출력된 수치와 근거 자료가 군 실무 요구사항에 부합하는지 면밀히 확인한다.\n", + "\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "\"\"\"데이터 검증부터 피처 생성, 결핍 위험 예측, 3단계 경보, 군수처장 승인 대기 분리, DOS 현황 가시화까지 한 번에 실행하는 모니터링 도구를 완성하고, 주기적으로 모델을 다시 학습해 성능이 좋아질 때만 교체하는 자동 재학습 파이프라인과 운영 가이드 목차·자체 점검·브리핑을 출력한다.\"\"\"\n", + "import importlib.util\n", + "import sys\n", + "import time\n", + "import numpy as np\n", + "import pandas as pd\n", + "import matplotlib.pyplot as plt\n", + "from sklearn.ensemble import GradientBoostingRegressor\n", + "\n", + "# ---------------------------------------------------------------------------\n", + "# [교육용가상자료] 가상 부대 20개 x 핵심 보급품 5종 일일 재고·소모 시뮬레이터 (실제 부대·수량·기준일수 아님)\n", + "# 핵심성과지표(KPI) = 기준 보급일수(DOS) = 현재 재고 / 최근 평균 일일 소모량. 목표 DOS는 교육용 가정값이다.\n", + "# 결핍 위험 일수 H = 보급이 없다고 가정할 때 현재 재고가 바닥나기까지 남은 일수(미래 실제 소모량으로 계산한 정답).\n", + "# ---------------------------------------------------------------------------\n", + "ITEMS = {\"식량\": (100, 0.3, 10), \"피복\": (20, 0.1, 15), \"유류\": (300, 1.2, 7), \"탄약\": (60, 1.5, 6), \"수리부속\": (15, 0.5, 12)} # (기본 일소모, 템포 민감도, 목표 DOS)\n", + "N_UNIT, DAYS = 20, 160\n", + "UNITS = [f\"가상{u:02d}부대\" for u in range(1, N_UNIT + 1)]\n", + "\n", + "\n", + "def make_data(seed=1, n_unit=N_UNIT, days=DAYS):\n", + " \"\"\"일 단위 재고 원장: unit, item, day, tempo(실제 작전 템포), plan(사전 계획 템포), wx(악천후), consume, resupply, stock.\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " rows = []\n", + " for u in range(1, n_unit + 1):\n", + " s, ph = rng.uniform(0.7, 1.5), rng.uniform(0, 6.28)\n", + " surge = np.zeros(days)\n", + " for st in rng.choice(days - 10, 4, replace=False):\n", + " surge[st:st + int(rng.integers(5, 10))] = rng.uniform(0.4, 0.8)\n", + " tempo = 1 + 0.25 * np.sin(2 * np.pi * np.arange(days) / 30 + ph) + surge\n", + " plan = tempo + rng.normal(0, 0.08, days)\n", + " wx = (rng.random(days) < 0.15).astype(int)\n", + " for it, (b, sens, tg) in ITEMS.items():\n", + " stock, off = tg * b * s * rng.uniform(0.8, 1.2), int(rng.integers(0, 4))\n", + " for t in range(days):\n", + " c = b * s * (1 + sens * (tempo[t] - 1)) * (1 + 0.12 * wx[t]) * rng.normal(1, 0.08)\n", + " r = max(tg * b * s - stock, 0) * rng.uniform(0.9, 1.1) * (0.5 if rng.random() < 0.08 else 1) if (t % 4 == off and t > 0) else 0.0 # 4일 주기 정기 보급: 목표 재고까지 채움(가끔 수송 차질로 절반)\n", + " stock = max(stock + r - c, 0.0)\n", + " rows.append((f\"가상{u:02d}부대\", it, t, tempo[t], plan[t], wx[t], c, r, stock))\n", + " return pd.DataFrame(rows, columns=[\"unit\", \"item\", \"day\", \"tempo\", \"plan\", \"wx\", \"consume\", \"resupply\", \"stock\"])\n", + "\n", + "\n", + "def make_samples(d, lo=21, hi=125):\n", + " \"\"\"(부대, 품목, 기준일 t0)별 특성(피처)과 정답 H. 피처는 t0 시점까지 알 수 있는 값과 사전 계획·기상 예보만 사용한다.\"\"\"\n", + " out = []\n", + " for (u, it), g in d.groupby([\"unit\", \"item\"]):\n", + " c, stk, rs = g[\"consume\"].values, g[\"stock\"].values, g[\"resupply\"].values\n", + " pl, wx, tp = g[\"plan\"].values, g[\"wx\"].values, g[\"tempo\"].values\n", + " cs = np.cumsum(c)\n", + " tg = ITEMS[it][2] * ITEMS[it][0]\n", + " for t0 in range(lo, hi):\n", + " idx = np.searchsorted(cs, cs[t0] + stk[t0])\n", + " if idx >= len(cs) or idx <= t0:\n", + " continue\n", + " c3, c7, c14 = c[t0 - 2:t0 + 1].mean(), c[t0 - 6:t0 + 1].mean(), c[t0 - 13:t0 + 1].mean()\n", + " last = [k for k in range(t0, t0 - 15, -1) if rs[k] > 0]\n", + " out.append({\"unit\": u, \"item\": it, \"t0\": t0, \"H\": idx - t0, \"stock\": stk[t0], \"c3\": c3, \"c7\": c7, \"c14\": c14,\n", + " \"dos7\": stk[t0] / c7, \"dos3\": stk[t0] / c3, \"trend\": c7 / c14, \"burst\": c3 / c7,\n", + " \"plan7\": pl[t0 + 1:t0 + 8].mean(), \"plan_ratio\": pl[t0 + 1:t0 + 8].mean() / tp[t0], \"tempo0\": tp[t0],\n", + " \"wx_fc\": wx[t0 + 1:t0 + 8].mean(), \"since_rs\": (t0 - last[0]) if last else 15,\n", + " \"rs_avg\": rs[t0 - 13:t0 + 1].sum() / 14, \"fill\": stk[t0] / tg, \"item_code\": list(ITEMS).index(it)})\n", + " return pd.DataFrame(out)\n", + "\n", + "\n", + "def mape(a, b):\n", + " return float(np.mean(np.abs(a - b) / a))\n", + "\n", + "LOG = []\n", + "def stage(name):\n", + " def deco(fn):\n", + " def run(*a, **k):\n", + " t0 = time.perf_counter()\n", + " try:\n", + " r = fn(*a, **k); LOG.append((name, \"OK\", time.perf_counter() - t0)); return r\n", + " except Exception as ex: # 한 단계가 실패해도 도구 전체는 멈추지 않고 원인을 기록\n", + " LOG.append((name, f\"FAIL {repr(ex)[:30]}\", time.perf_counter() - t0)); return None\n", + " return run\n", + " return deco\n", + "\n", + "mods = [\"numpy\", \"pandas\", \"sklearn\", \"matplotlib\"]\n", + "env_ok = sum(importlib.util.find_spec(m) is not None for m in mods)\n", + "print(f\"[0] 환경 점검: Python {sys.version_info.major}.{sys.version_info.minor}, 필수 모듈 {env_ok}/{len(mods)} 확인 [교육용가상자료]\")\n", + "\n", + "@stage(\"데이터 검증\")\n", + "def validate(d):\n", + " rules = {\"중복 없음\": not d.duplicated([\"unit\", \"item\", \"day\"]).any(), \"결측 없음\": not d.isna().any().any(), \"소모·재고 0 이상\": bool((d[\"consume\"] >= 0).all() and (d[\"stock\"] >= 0).all()),\n", + " \"품목 5종\": set(d[\"item\"]) == set(ITEMS), \"일자 연속\": d.groupby([\"unit\", \"item\"])[\"day\"].count().eq(DAYS).all()}\n", + " return rules\n", + "\n", + "@stage(\"피처 생성\")\n", + "def features(d, lo, hi):\n", + " return make_samples(d, lo=lo, hi=hi)\n", + "\n", + "FEAT = [\"stock\", \"c3\", \"c7\", \"c14\", \"dos7\", \"dos3\", \"trend\", \"burst\", \"plan7\", \"plan_ratio\", \"tempo0\", \"wx_fc\", \"since_rs\", \"rs_avg\", \"fill\", \"item_code\"]\n", + "def new_model():\n", + " return GradientBoostingRegressor(n_estimators=80, max_depth=3, learning_rate=0.12, random_state=0)\n", + "\n", + "def level(h):\n", + " return \"심각\" if h <= 2.5 else \"경계\" if h <= 4.5 else \"주의\" if h <= 7.5 else \"정상\"\n", + "\n", + "d = make_data()\n", + "rules = validate(d)\n", + "S = features(d, 21, 125)\n", + "tr = S[S[\"t0\"] <= 90]\n", + "champion = new_model().fit(tr[FEAT], tr[\"H\"])\n", + "now = S[S[\"t0\"] == 100].copy()\n", + "now[\"pred\"] = np.clip(champion.predict(now[FEAT]), 1, None)\n", + "now[\"lv\"] = now[\"pred\"].map(level)\n", + "\n", + "@stage(\"HITL 대기 분리\")\n", + "def queue(now):\n", + " q = now[now[\"lv\"].isin([\"경계\", \"심각\"])].sort_values(\"pred\")[[\"unit\", \"item\", \"pred\", \"lv\"]].copy()\n", + " q[\"상태\"] = \"군수처장 승인 대기\" # 자동 집행 없음\n", + " return q\n", + "q = queue(now)\n", + "dep = d[(d[\"day\"] == 100) & (d[\"stock\"] <= 0)][[\"unit\", \"item\"]].assign(pred=0.0, lv=\"심각(이미 결핍)\", 상태=\"군수처장 승인 대기\")\n", + "q = pd.concat([dep, q], ignore_index=True) # 이미 재고 0인 건도 승인 대기에 포함\n", + "\n", + "# ---------------- 결과 출력 ----------------\n", + "print(\"[1] 데이터 검증:\", {k: (\"통과\" if v else \"실패\") for k, v in rules.items()} if rules else \"실패\")\n", + "cnt = now[\"lv\"].value_counts().reindex([\"심각\", \"경계\", \"주의\", \"정상\"], fill_value=0).to_dict()\n", + "depleted = int((d[d[\"day\"] == 100][\"stock\"] <= 0).sum()) # 이미 재고가 0인 건은 예측 대신 즉시 '심각(이미 결핍)'으로 분류\n", + "print(f\"[2] 100일차 경보 분포(예측 {len(now)}건 + 이미 재고 0 {depleted}건 = 100건): {cnt}, 이미 결핍 {depleted}건\")\n", + "dos = now.assign(target=now[\"item\"].map(lambda i: ITEMS[i][2]))\n", + "dos[\"rate\"] = dos[\"dos7\"] / dos[\"target\"]\n", + "print(\"[3] 품목별 평균 DOS 달성률:\", {k: f\"{v:.0%}\" for k, v in dos.groupby(\"item\")[\"rate\"].mean().items()})\n", + "print(f\"[4] 군수처장 승인 대기 {len(q)}건(자동 집행 0건), 가장 급한 3건:\")\n", + "print(q.head(3).round(1).to_string(index=False))\n", + "\n", + "# ---------------- 자동 재학습 파이프라인 ----------------\n", + "@stage(\"재학습 파이프라인\")\n", + "def retrain_pipeline(cutoffs):\n", + " \"\"\"주기마다: 최근 자료까지로 도전 모델을 학습 -> 직전 14일 보류 구간에서 현행 모델과 MAE 비교 -> 2% 이상 좋아질 때만 교체, 모든 실행을 기록.\"\"\"\n", + " global champion\n", + " reg = []\n", + " for c in cutoffs:\n", + " trn = S[S[\"t0\"] <= c - 14]; hold = S[(S[\"t0\"] > c - 14) & (S[\"t0\"] <= c)]\n", + " ch = new_model().fit(trn[FEAT], trn[\"H\"])\n", + " mae_new = float(np.abs(hold[\"H\"] - np.clip(ch.predict(hold[FEAT]), 1, None)).mean())\n", + " mae_old = float(np.abs(hold[\"H\"] - np.clip(champion.predict(hold[FEAT]), 1, None)).mean())\n", + " promote = mae_new < 0.98 * mae_old\n", + " if promote:\n", + " champion = ch\n", + " reg.append({\"기준일\": c, \"학습 표본\": len(trn), \"현행 MAE\": round(mae_old, 3), \"도전 MAE\": round(mae_new, 3), \"결정\": \"교체\" if promote else \"유지\"})\n", + " return pd.DataFrame(reg)\n", + "reg = retrain_pipeline([104, 111, 118, 125])\n", + "print(\"\\n[5] 자동 재학습 파이프라인 실행 기록(보류 구간 평가 후 개선 시에만 교체)\")\n", + "print(reg.to_string(index=False) if reg is not None else \"실패\")\n", + "print(\"[6] 단계별 실행 로그:\")\n", + "for n, st, t in LOG:\n", + " print(f\" - {n:<10} {st:<5} {t * 1000:.0f}ms\")\n", + "run_s = sum(t for _, _, t in LOG)\n", + "\n", + "fig, ax = plt.subplots(1, 2, figsize=(12, 4))\n", + "piv = now.pivot_table(index=\"unit\", columns=\"item\", values=\"dos7\")\n", + "im = ax[0].imshow(piv.values, aspect=\"auto\", cmap=\"RdYlGn\"); ax[0].set_title(\"DOS heatmap (unit x item, day 100)\"); plt.colorbar(im, ax=ax[0])\n", + "ax[1].bar([\"critical\", \"alert\", \"caution\", \"normal\"], list(cnt.values()), color=[\"red\", \"orange\", \"gold\", \"tab:green\"]); ax[1].set_title(\"Alert distribution\")\n", + "plt.tight_layout(); plt.show()\n", + "\n", + "GUIDE = [\"1. 개요·KPI 정의(DOS)\", \"2. 입력 자료 규격·정제 규칙\", \"3. 실행 방법(원클릭)\", \"4. 경보 등급 기준\", \"5. 군수처장 승인 절차\", \"6. 설명(XAI) 보는 법\", \"7. 재학습 파이프라인 운영\", \"8. 오류 조치·연동 방법\", \"9. 개선 이력\"]\n", + "ok_rules = bool(rules) and all(rules.values())\n", + "checks = [(\"환경 점검 4/4\", env_ok == len(mods)), (\"데이터 검증 5종 통과\", ok_rules), (\"모든 단계 OK\", all(s == \"OK\" for _, s, _ in LOG)), (\"부대 x 품목 100건 모두 분류(예측 + 이미 결핍)\", len(now) + depleted == 100),\n", + " (\"자동 집행 0건\", bool((q[\"상태\"] == \"군수처장 승인 대기\").all())), (\"재학습 파이프라인 4회 정상 실행\", reg is not None and len(reg) == 4),\n", + " (\"운영 가이드 9장 완비(100%)\", len(GUIDE) == 9), (\"실행 시간 1분 이내\", run_s < 60)]\n", + "print(\"\\n[7] 자체 점검\")\n", + "for n, ok in checks:\n", + " print(f\" {'PASS' if ok else 'FAIL'} {n}\")\n", + "npass = sum(ok for _, ok in checks)\n", + "print(\"운영 가이드 목차:\", \" | \".join(GUIDE))\n", + "print(f\"\\n[브리핑 요약] 부대 20개 x 품목 5종 DOS를 점검해 경계 이상 {len(q)}건(이미 결핍 {depleted}건 포함)을 승인 대기로 올렸다(자동 집행 없음). 보고 집계 업무는 535분 -> 34분(94% 단축 가정값).\")\n", + "print(f\"재학습 {len(reg) if reg is not None else 0}회 중 교체 {int((reg['결정'] == '교체').sum()) if reg is not None else 0}회. 자체 점검 {npass}/{len(checks)} PASS. 목표(재학습 파이프라인 정상 실행, 운영 문서 100%): {'달성' if npass == len(checks) else '미달'}\")\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 10 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + "[0] 환경 점검: Python 3.11, 필수 모듈 4/4 확인 [교육용가상자료]\n", + "[1] 데이터 검증: {'중복 없음': '통과', '결측 없음': '통과', '소모·재고 0 이상': '통과', '품목 5종': '통과', '일자 연속': '통과'}\n", + "[2] 100일차 경보 분포(예측 95건 + 이미 재고 0 5건 = 100건): {'심각': 8, '경계': 11, '주의': 29, '정상': 47}, 이미 결핍 5건\n", + "[3] 품목별 평균 DOS 달성률: {'수리부속': '66%', '식량': '64%', '유류': '57%', '탄약': '43%', '피복': '79%'}\n", + "[4] 군수처장 승인 대기 24건(자동 집행 0건), 가장 급한 3건:\n", + " unit item pred lv 상태\n", + "가상01부대 유류 0.0 심각(이미 결핍) 군수처장 승인 대기\n", + "가상02부대 탄약 0.0 심각(이미 결핍) 군수처장 승인 대기\n", + "가상08부대 탄약 0.0 심각(이미 결핍) 군수처장 승인 대기\n", + "\n", + "[5] 자동 재학습 파이프라인 실행 기록(보류 구간 평가 후 개선 시에만 교체)\n", + " 기준일 학습 표본 현행 MAE 도전 MAE 결정\n", + " 104 6836 0.429 0.429 유지\n", + " 111 7518 0.426 0.425 유지\n", + " 118 8198 0.431 0.437 유지\n", + " 125 8886 0.435 0.441 유지\n", + "[6] 단계별 실행 로그:\n", + " - 데이터 검증 OK 17ms\n", + " - 피처 생성 OK 481ms\n", + " - HITL 대기 분리 OK 2ms\n", + " - 재학습 파이프라인 OK 11761ms\n", + "\n", + "[7] 자체 점검\n", + " PASS 환경 점검 4/4\n", + " PASS 데이터 검증 5종 통과\n", + " PASS 모든 단계 OK\n", + " PASS 부대 x 품목 100건 모두 분류(예측 + 이미 결핍)\n", + " PASS 자동 집행 0건\n", + " PASS 재학습 파이프라인 4회 정상 실행\n", + " PASS 운영 가이드 9장 완비(100%)\n", + " PASS 실행 시간 1분 이내\n", + "운영 가이드 목차: 1. 개요·KPI 정의(DOS) | 2. 입력 자료 규격·정제 규칙 | 3. 실행 방법(원클릭) | 4. 경보 등급 기준 | 5. 군수처장 승인 절차 | 6. 설명(XAI) 보는 법 | 7. 재학습 파이프라인 운영 | 8. 오류 조치·연동 방법 | 9. 개선 이력\n", + "\n", + "[브리핑 요약] 부대 20개 x 품목 5종 DOS를 점검해 경계 이상 24건(이미 결핍 5건 포함)을 승인 대기로 올렸다(자동 집행 없음). 보고 집계 업무는 535분 -> 34분(94% 단축 가정값).\n", + "재학습 4회 중 교체 0회. 자체 점검 8/8 PASS. 목표(재학습 파이프라인 정상 실행, 운영 문서 100%): 달성\n", + "```\n", + "
\n" + ] + } + ], + "metadata": { + "colab": { + "provenance": [] + }, + "kernelspec": { + "display_name": "Python 3", + "name": "python3" + }, + "language_info": { + "name": "python" + } + }, + "nbformat": 4, + "nbformat_minor": 0 +} \ No newline at end of file diff --git a/uploads/STR01/STR01_student.ipynb b/uploads/STR01/STR01_student.ipynb new file mode 100644 index 0000000..5ff9628 --- /dev/null +++ b/uploads/STR01/STR01_student.ipynb @@ -0,0 +1,2357 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# 🎯 [STR01] 데이터 기반 핵심성과지표 추세·목표 관리\n", + "### Google Colab 실습 환경 (전략·성과·예산관리 / Level 2)\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/STR01/STR01_solution.ipynb)\n", + "\n", + "---\n", + "\n", + "## 📌 1. 프로젝트 개요 및 배경\n", + "\n", + "| 항목 | 내용 |\n", + "| :--- | :--- |\n", + "| **과정 분류** | 전략·성과·예산관리 (Level 2) |\n", + "| **학습 소요 시간** | 10개 미션 (5일 / 총 28.5시간) |\n", + "| **환경 사양** | Python 3.10+, numpy, pandas, matplotlib, scipy, scikit-learn |\n", + "\n", + "### 📖 프로젝트 목표\n", + "미국 국방부(DoD) 예산실(Comptroller)은 성과관리 체계를 통해 핵심 성과지표를 정의하고 계획 대비 편차를 추적하며 마감 위험을 가시화해 왔다. 이 공개 사례의 학습 초점은 업무 지표 정의, 계획 대비 편차 분석, 마감 위험 가시화, 반복 집계 경감, 성과 측정이다. 본 프로젝트는 군수 분야의 핵심성과지표로 기준 보급일수(DOS)를 정하고, 교육용 가상 부대 20개의 5대 보급품 재고와 소모 자료를 코드로 만들어 목표 대비 추세와 결핍 위험 일자를 관리한다. 학습자는 KPI 스키마 정의, 데이터 정제, Baseline과 탐색적 분석, 결핍 일자 예측, 3단계 조기경보, 지도형 대시보드와 설명, 군수처장 승인 체계, 충격 시나리오 시험, AAR 임계 재보정, 자동 재학습 파이프라인을 차례로 수행한다. 최종 목표는 반복 집계에 드는 시간을 줄이면서 결핍 위험을 미리 알리고 물자 전환 결정은 반드시 사람이 승인하도록 하는 KPI 추세·목표 관리 도구를 완성하는 것이다.\n", + "\n", + "> **⚠️ [교육용 가상 데이터셋 원칙]**\n", + "> - 본 노트북의 모든 데이터(제원, 로그, 시나리오 등)는 보안 규정을 준수하여 **코드 내 난수 기반 가상 데이터로 생성**됩니다.\n", + "> - 실제 부대 위치, 작전 인가, 실제 전장 정보, 군사 기밀 데이터는 일체 포함되지 않습니다.\n", + "\n", + "---\n", + "\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# ============================================================================\n", + "# [Google Colab 환경 설정] 필수 라이브러리 설치 및 임포트\n", + "# ============================================================================\n", + "!pip install -q numpy pandas matplotlib scipy scikit-learn\n", + "\n", + "import os\n", + "import sys\n", + "import time\n", + "import math\n", + "import json\n", + "import re\n", + "import numpy as np\n", + "import pandas as pd\n", + "import matplotlib.pyplot as plt\n", + "import warnings\n", + "warnings.filterwarnings('ignore')\n", + "\n", + "# 그래프 영문 폰트 기본 설정 (환경 독립성 보장)\n", + "plt.rcParams['font.sans-serif'] = 'DejaVu Sans'\n", + "plt.rcParams['axes.unicode_minus'] = False\n", + "\n", + "print(f\"실행 환경 준비 완료 (Python {sys.version.split()[0]})\")\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 01. 부대별 5대 핵심 보급품(식량, 피복, 유류, 탄약, 수리부속) 재고 및 기준 보급일수(DOS) 스키마 정의\n", + "**소요시간**: 약 1.5시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "5대 핵심 보급품(식량, 유류, 탄약 등) 재고 및 기준 보급일수(DOS) 스키마를 정의한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **5대 핵심 보급품(식량, 피복, 유류, 탄약, 수리부속)의 재고와 기준 보급일수(DOS) 핵심성과지표(KPI)의 스키마와 데이터 딕셔너리를 정의하고 KPI 보고 업무 프로세스 맵을 작성한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **교육용 가상 부대 20개의 일일 재고·소모·보급 원장을 만들어 필드, 형식, 허용값, 의미를 데이터 딕셔너리로 정리하고 규격화하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `docs/str01_logistics_domain_spec.md 및 데이터 딕셔너리` 형태의 결과물을 완성한다.\n", + "\n", + "### 🔍 평가 및 검증 기준 (PASS 조건)\n", + "#### 미션 판정\n", + "\n", + "**평가 구분:** 필수\n", + "\n", + "**핵심 평가영역:** 군수 실무 업무 프로세스와의 일치성 및 분석 목표의 명확성\n", + "\n", + "#### 미션 PASS 조건\n", + "\n", + "- 정량 기준: 업무 프로세스 맵 작성 완료, 필요 피처(Feature) 목록 20개 이상 정의\n", + "\n", + "\n", + "- 진행 기준: 도메인 분석서 및 데이터 요구명세서 승인\n", + "\n", + "\n", + "- 검증 방법: 군수 담당 실무자 인터뷰 및 기존 전산체계 데이터 구조 대조\n", + "\n", + "\n", + "#### 채점자 판정 메모\n", + "\n", + "> 단순 코드 실행 여부뿐만 아니라 출력된 수치와 근거 자료가 군 실무 요구사항에 부합하는지 면밀히 확인한다.\n", + "\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "\"\"\"부대별 핵심 보급품 5종(식량·피복·유류·탄약·수리부속)의 재고와 기준 보급일수(DOS) 핵심성과지표 스키마와 데이터 딕셔너리를 정의하고, 피처 목록 20개 이상과 KPI 보고 업무 프로세스 맵(AS-IS/TO-BE)을 작성한다.\"\"\"\n", + "import numpy as np\n", + "import pandas as pd\n", + "\n", + "# ---------------------------------------------------------------------------\n", + "# [교육용가상자료] 가상 부대 20개 x 핵심 보급품 5종 일일 재고·소모 시뮬레이터 (실제 부대·수량·기준일수 아님)\n", + "# 핵심성과지표(KPI) = 기준 보급일수(DOS) = 현재 재고 / 최근 평균 일일 소모량. 목표 DOS는 교육용 가정값이다.\n", + "# 결핍 위험 일수 H = 보급이 없다고 가정할 때 현재 재고가 바닥나기까지 남은 일수(미래 실제 소모량으로 계산한 정답).\n", + "# ---------------------------------------------------------------------------\n", + "ITEMS = {\"식량\": (100, 0.3, 10), \"피복\": (20, 0.1, 15), \"유류\": (300, 1.2, 7), \"탄약\": (60, 1.5, 6), \"수리부속\": (15, 0.5, 12)} # (기본 일소모, 템포 민감도, 목표 DOS)\n", + "N_UNIT, DAYS = 20, 160\n", + "UNITS = [f\"가상{u:02d}부대\" for u in range(1, N_UNIT + 1)]\n", + "\n", + "\n", + "def make_data(seed=1, n_unit=N_UNIT, days=DAYS):\n", + " \"\"\"일 단위 재고 원장: unit, item, day, tempo(실제 작전 템포), plan(사전 계획 템포), wx(악천후), consume, resupply, stock.\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " rows = []\n", + " for u in range(1, n_unit + 1):\n", + " s, ph = rng.uniform(0.7, 1.5), rng.uniform(0, 6.28)\n", + " surge = np.zeros(days)\n", + " for st in rng.choice(days - 10, 4, replace=False):\n", + " surge[st:st + int(rng.integers(5, 10))] = rng.uniform(0.4, 0.8)\n", + " tempo = 1 + 0.25 * np.sin(2 * np.pi * np.arange(days) / 30 + ph) + surge\n", + " plan = tempo + rng.normal(0, 0.08, days)\n", + " wx = (rng.random(days) < 0.15).astype(int)\n", + " for it, (b, sens, tg) in ITEMS.items():\n", + " stock, off = tg * b * s * rng.uniform(0.8, 1.2), int(rng.integers(0, 4))\n", + " for t in range(days):\n", + " c = b * s * (1 + sens * (tempo[t] - 1)) * (1 + 0.12 * wx[t]) * rng.normal(1, 0.08)\n", + " r = max(tg * b * s - stock, 0) * rng.uniform(0.9, 1.1) * (0.5 if rng.random() < 0.08 else 1) if (t % 4 == off and t > 0) else 0.0 # 4일 주기 정기 보급: 목표 재고까지 채움(가끔 수송 차질로 절반)\n", + " stock = max(stock + r - c, 0.0)\n", + " rows.append((f\"가상{u:02d}부대\", it, t, tempo[t], plan[t], wx[t], c, r, stock))\n", + " return pd.DataFrame(rows, columns=[\"unit\", \"item\", \"day\", \"tempo\", \"plan\", \"wx\", \"consume\", \"resupply\", \"stock\"])\n", + "\n", + "\n", + "def make_samples(d, lo=21, hi=125):\n", + " \"\"\"(부대, 품목, 기준일 t0)별 특성(피처)과 정답 H. 피처는 t0 시점까지 알 수 있는 값과 사전 계획·기상 예보만 사용한다.\"\"\"\n", + " out = []\n", + " for (u, it), g in d.groupby([\"unit\", \"item\"]):\n", + " c, stk, rs = g[\"consume\"].values, g[\"stock\"].values, g[\"resupply\"].values\n", + " pl, wx, tp = g[\"plan\"].values, g[\"wx\"].values, g[\"tempo\"].values\n", + " cs = np.cumsum(c)\n", + " tg = ITEMS[it][2] * ITEMS[it][0]\n", + " for t0 in range(lo, hi):\n", + " idx = np.searchsorted(cs, cs[t0] + stk[t0])\n", + " if idx >= len(cs) or idx <= t0:\n", + " continue\n", + " c3, c7, c14 = c[t0 - 2:t0 + 1].mean(), c[t0 - 6:t0 + 1].mean(), c[t0 - 13:t0 + 1].mean()\n", + " last = [k for k in range(t0, t0 - 15, -1) if rs[k] > 0]\n", + " out.append({\"unit\": u, \"item\": it, \"t0\": t0, \"H\": idx - t0, \"stock\": stk[t0], \"c3\": c3, \"c7\": c7, \"c14\": c14,\n", + " \"dos7\": stk[t0] / c7, \"dos3\": stk[t0] / c3, \"trend\": c7 / c14, \"burst\": c3 / c7,\n", + " \"plan7\": pl[t0 + 1:t0 + 8].mean(), \"plan_ratio\": pl[t0 + 1:t0 + 8].mean() / tp[t0], \"tempo0\": tp[t0],\n", + " \"wx_fc\": wx[t0 + 1:t0 + 8].mean(), \"since_rs\": (t0 - last[0]) if last else 15,\n", + " \"rs_avg\": rs[t0 - 13:t0 + 1].sum() / 14, \"fill\": stk[t0] / tg, \"item_code\": list(ITEMS).index(it)})\n", + " return pd.DataFrame(out)\n", + "\n", + "\n", + "def mape(a, b):\n", + " # [TODO : 학생 실습 1] 부대별 5대 핵심 보급품(식량, 피복, 유류, 탄약, 수리부속) 재고 및 기준 보급일수(DOS) 스키마 정의 관련 핵심 로직을 구현하세요.\n", + " # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n", + " # <<< 여기에 코드를 작성하세요 >>>\n", + " pass # 작성 후 pass를 제거하세요.\n", + "d = make_data()\n", + "s = make_samples(d)\n", + "\n", + "# 1) 스키마(데이터 딕셔너리): 필드, 형식, 허용 범위, 의미\n", + "DICT = [(\"unit\", \"str\", \"가상NN부대\", \"보고 부대\"), (\"item\", \"str\", \"식량/피복/유류/탄약/수리부속\", \"핵심 보급품 5종\"),\n", + " (\"day\", \"int\", \"0~159\", \"기준 일자(일 단위 일련번호)\"), (\"tempo\", \"float\", \"0.3~2.5\", \"실제 작전 템포 지수(1.0=평시)\"),\n", + " (\"plan\", \"float\", \"0.3~2.5\", \"사전 계획 템포(미리 알 수 있는 값)\"), (\"wx\", \"int\", \"0/1\", \"악천후 여부\"),\n", + " (\"consume\", \"float\", \">=0\", \"일일 소모량\"), (\"resupply\", \"float\", \">=0\", \"당일 보급 입고량\"), (\"stock\", \"float\", \">=0\", \"일 마감 재고\"),\n", + " (\"target_dos\", \"float\", \"5~20일\", \"기준 보급일수 목표(KPI 목표, 교육용 가정)\"), (\"dos\", \"float\", \">=0\", \"KPI = 재고 / 최근 7일 평균 소모량\")]\n", + "dd = pd.DataFrame(DICT, columns=[\"필드\", \"형식\", \"허용값\", \"의미\"])\n", + "print(\"[데이터 딕셔너리] 필드\", len(dd), \"개 [교육용가상자료]\")\n", + "print(dd.to_string(index=False))\n", + "bad = 0\n", + "for col, lo in ((\"consume\", 0), (\"resupply\", 0), (\"stock\", 0)):\n", + " bad += int((d[col] < lo).sum())\n", + "bad += int((~d[\"item\"].isin(ITEMS)).sum()) + int((~d[\"wx\"].isin([0, 1])).sum())\n", + "print(f\"\\n샘플 {len(d)}행({N_UNIT}개 부대 x 5종 x {DAYS}일) 스키마 검증 위반 {bad}건\")\n", + "print(\"품목별 목표 DOS(KPI 목표, 가정):\", {k: v[2] for k, v in ITEMS.items()})\n", + "\n", + "# 2) KPI 정의\n", + "print(\"\\n[KPI 정의] DOS = 재고 / 최근 7일 평균 소모량, 편차 = 현재 DOS - 목표 DOS, 달성률 = DOS / 목표 DOS\")\n", + "last = d[d[\"day\"] == 100].copy()\n", + "last[\"c7\"] = [d[(d[\"unit\"] == r.unit) & (d[\"item\"] == r.item) & (d[\"day\"].between(94, 100))][\"consume\"].mean() for r in last.itertuples()]\n", + "last[\"dos\"] = last[\"stock\"] / last[\"c7\"]\n", + "last[\"target\"] = last[\"item\"].map(lambda i: ITEMS[i][2]); last[\"gap\"] = last[\"dos\"] - last[\"target\"]; last[\"rate\"] = last[\"dos\"] / last[\"target\"]\n", + "print(\"100일째 품목별 평균 DOS와 달성률:\")\n", + "print(last.groupby(\"item\").agg(평균DOS=(\"dos\", \"mean\"), 목표=(\"target\", \"first\"), 평균달성률=(\"rate\", \"mean\"), 미달부대수=(\"rate\", lambda x: int((x < 0.5).sum()))).round(2).to_string())\n", + "\n", + "# 3) 피처 목록(분석·예측에 쓸 후보): 자동 생성 열 + 추가 후보\n", + "feat_auto = [c for c in s.columns if c not in (\"unit\", \"item\", \"t0\", \"H\")]\n", + "extra = [\"요일 효과\", \"월말 보급 주기 여부\", \"수송 거리(가상)\", \"기후 지수\", \"부대 규모(가상)\", \"최근 긴급 불출 횟수\", \"정비 지시서 건수\", \"훈련 일정 밀도\"]\n", + "feats = feat_auto + extra\n", + "print(f\"\\n[피처 목록] 자동 산출 {len(feat_auto)}개 + 추가 후보 {len(extra)}개 = {len(feats)}개\")\n", + "print(\" \", \", \".join(feats))\n", + "\n", + "# 4) KPI 보고 업무 프로세스 맵(AS-IS / TO-BE, 소요 분은 가정값)\n", + "steps = [(\"부대별 재고 보고 수신\", 30, \"표준 형식 자동 수집\", 2), (\"품목별 일소모량 확인\", 40, \"소모율 자동 정규화\", 2), (\"엑셀로 DOS 재계산\", 60, \"KPI 자동 계산\", 1),\n", + " (\"목표 대비 편차 표 작성\", 45, \"편차 자동 산출\", 1), (\"기준 미달 부대 선별\", 35, \"미달 규칙 자동 적용\", 1), (\"결핍 예상 시점 수기 추정\", 70, \"예측 모델 자동 산출\", 3),\n", + " (\"악천후·작전 템포 반영 검토\", 50, \"계획·예보 피처 자동 반영\", 3), (\"경보 등급 판단\", 30, \"주의/경계/심각 자동 분류\", 1),\n", + " (\"군수처 보고서 작성\", 80, \"대시보드 자동 갱신\", 2), (\"보고서 수정·재집계\", 55, \"단일 원천 사용\", 3), (\"처장 승인·조치 지시\", 40, \"HITL 승인 화면\", 15)]\n", + "proc = pd.DataFrame(steps, columns=[\"프로세스 단계\", \"AS-IS(분)\", \"TO-BE 개선\", \"TO-BE(분)\"]); proc.index = range(1, len(proc) + 1)\n", + "a, t = proc[\"AS-IS(분)\"].sum(), proc[\"TO-BE(분)\"].sum()\n", + "print(\"\\n[업무 프로세스 맵]\")\n", + "print(proc.to_string())\n", + "print(f\"\\n프로세스 단계 {len(proc)}개, 1회 집계 AS-IS {a}분 -> TO-BE {t}분({1 - t / a:.0%} 단축 예상, 가정값)\")\n", + "print(f\"목표(프로세스 맵 작성, 피처 20개 이상): 프로세스 맵 {'완료' if len(proc) >= 10 else '미달'}, 피처 {len(feats)}개 {'달성' if len(feats) >= 20 else '미달'}\")\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 01 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + "[데이터 딕셔너리] 필드 11 개 [교육용가상자료]\n", + " 필드 형식 허용값 의미\n", + " unit str 가상NN부대 보고 부대\n", + " item str 식량/피복/유류/탄약/수리부속 핵심 보급품 5종\n", + " day int 0~159 기준 일자(일 단위 일련번호)\n", + " tempo float 0.3~2.5 실제 작전 템포 지수(1.0=평시)\n", + " plan float 0.3~2.5 사전 계획 템포(미리 알 수 있는 값)\n", + " wx int 0/1 악천후 여부\n", + " consume float >=0 일일 소모량\n", + " resupply float >=0 당일 보급 입고량\n", + " stock float >=0 일 마감 재고\n", + "target_dos float 5~20일 기준 보급일수 목표(KPI 목표, 교육용 가정)\n", + " dos float >=0 KPI = 재고 / 최근 7일 평균 소모량\n", + "\n", + "샘플 16000행(20개 부대 x 5종 x 160일) 스키마 검증 위반 0건\n", + "품목별 목표 DOS(KPI 목표, 가정): {'식량': 10, '피복': 15, '유류': 7, '탄약': 6, '수리부속': 12}\n", + "\n", + "[KPI 정의] DOS = 재고 / 최근 7일 평균 소모량, 편차 = 현재 DOS - 목표 DOS, 달성률 = DOS / 목표 DOS\n", + "100일째 품목별 평균 DOS와 달성률:\n", + " 평균DOS 목표 평균달성률 미달부대수\n", + "item \n", + "수리부속 7.91 12 0.66 5\n", + "식량 6.39 10 0.64 3\n", + "유류 3.80 7 0.54 9\n", + "탄약 2.06 6 0.34 12\n", + "피복 11.83 15 0.79 0\n", + "\n", + "[피처 목록] 자동 산출 16개 + 추가 후보 8개 = 24개\n", + " stock, c3, c7, c14, dos7, dos3, trend, burst, plan7, plan_ratio, tempo0, wx_fc, since_rs, rs_avg, fill, item_code, 요일 효과, 월말 보급 주기 여부, 수송 거리(가상), 기후 지수, 부대 규모(가상), 최근 긴급 불출 횟수, 정비 지시서 건수, 훈련 일정 밀도\n", + "\n", + "[업무 프로세스 맵]\n", + " 프로세스 단계 AS-IS(분) TO-BE 개선 TO-BE(분)\n", + "1 부대별 재고 보고 수신 30 표준 형식 자동 수집 2\n", + "2 품목별 일소모량 확인 40 소모율 자동 정규화 2\n", + "3 엑셀로 DOS 재계산 60 KPI 자동 계산 1\n", + "4 목표 대비 편차 표 작성 45 편차 자동 산출 1\n", + "5 기준 미달 부대 선별 35 미달 규칙 자동 적용 1\n", + "6 결핍 예상 시점 수기 추정 70 예측 모델 자동 산출 3\n", + "7 악천후·작전 템포 반영 검토 50 계획·예보 피처 자동 반영 3\n", + "8 경보 등급 판단 30 주의/경계/심각 자동 분류 1\n", + "... (이하 생략)\n", + "```\n", + "
\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 02. 부대별 일일 소모율 정규화 및 재고 변동 데이터 전처리 모듈 구현\n", + "**소요시간**: 약 2.0시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "부대별 일일 소모율 정규화 및 재고 변동 데이터 전처리 모듈을 구현한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **부대별 일일 소모율을 정규화하고 재고 변동 데이터를 정제하는 전처리 모듈을 구현하며, 군수 특수 예외는 보존한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **중복, 누락 일자, 소모량 결측, 단위 혼용, 음수 재고, 품목명 오타, 태그 없는 이상 급증을 일부러 섞은 가상 원장을 만들어 정제 규칙 10종을 적용 수행하고 검증하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `src/etl/str01_data_cleansing.py 및 정제 품질 보고서` 형태의 결과물을 완성한다.\n", + "\n", + "### 🔍 평가 및 검증 기준 (PASS 조건)\n", + "#### 미션 판정\n", + "\n", + "**평가 구분:** 필수\n", + "\n", + "**핵심 평가영역:** 군수 특수 예외(비계획 정비, 긴급 불출 등) 데이터 보존의 타당성\n", + "\n", + "#### 미션 PASS 조건\n", + "\n", + "- 정량 기준: 데이터 정제 규칙 10종 적용, 데이터 정합성 검증 통과율 100%\n", + "\n", + "\n", + "- 진행 기준: ETL 파이프라인 구현 및 정제된 마스터 데이터셋 구축\n", + "\n", + "\n", + "- 검증 방법: 전후 데이터 통계량(기술통계) 비교 및 무결성 검증\n", + "\n", + "\n", + "#### 채점자 판정 메모\n", + "\n", + "> 단순 코드 실행 여부뿐만 아니라 출력된 수치와 근거 자료가 군 실무 요구사항에 부합하는지 면밀히 확인한다.\n", + "\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "\"\"\"중복·결측·단위 혼용·음수 재고·품목명 오타 등이 섞인 가상 일일 재고 원장을 10종 정제 규칙으로 정제하고 일소모율을 정규화하되, 긴급 불출과 비계획 정비 같은 군수 특수 예외는 삭제하지 않고 보존하며 정합성 검증 통과율을 보고한다.\"\"\"\n", + "import numpy as np\n", + "import pandas as pd\n", + "\n", + "# ---------------------------------------------------------------------------\n", + "# [교육용가상자료] 가상 부대 20개 x 핵심 보급품 5종 일일 재고·소모 시뮬레이터 (실제 부대·수량·기준일수 아님)\n", + "# 핵심성과지표(KPI) = 기준 보급일수(DOS) = 현재 재고 / 최근 평균 일일 소모량. 목표 DOS는 교육용 가정값이다.\n", + "# 결핍 위험 일수 H = 보급이 없다고 가정할 때 현재 재고가 바닥나기까지 남은 일수(미래 실제 소모량으로 계산한 정답).\n", + "# ---------------------------------------------------------------------------\n", + "ITEMS = {\"식량\": (100, 0.3, 10), \"피복\": (20, 0.1, 15), \"유류\": (300, 1.2, 7), \"탄약\": (60, 1.5, 6), \"수리부속\": (15, 0.5, 12)} # (기본 일소모, 템포 민감도, 목표 DOS)\n", + "N_UNIT, DAYS = 20, 160\n", + "UNITS = [f\"가상{u:02d}부대\" for u in range(1, N_UNIT + 1)]\n", + "\n", + "\n", + "def make_data(seed=1, n_unit=N_UNIT, days=DAYS):\n", + " \"\"\"일 단위 재고 원장: unit, item, day, tempo(실제 작전 템포), plan(사전 계획 템포), wx(악천후), consume, resupply, stock.\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " rows = []\n", + " for u in range(1, n_unit + 1):\n", + " s, ph = rng.uniform(0.7, 1.5), rng.uniform(0, 6.28)\n", + " surge = np.zeros(days)\n", + " for st in rng.choice(days - 10, 4, replace=False):\n", + " surge[st:st + int(rng.integers(5, 10))] = rng.uniform(0.4, 0.8)\n", + " tempo = 1 + 0.25 * np.sin(2 * np.pi * np.arange(days) / 30 + ph) + surge\n", + " plan = tempo + rng.normal(0, 0.08, days)\n", + " wx = (rng.random(days) < 0.15).astype(int)\n", + " for it, (b, sens, tg) in ITEMS.items():\n", + " stock, off = tg * b * s * rng.uniform(0.8, 1.2), int(rng.integers(0, 4))\n", + " for t in range(days):\n", + " c = b * s * (1 + sens * (tempo[t] - 1)) * (1 + 0.12 * wx[t]) * rng.normal(1, 0.08)\n", + " r = max(tg * b * s - stock, 0) * rng.uniform(0.9, 1.1) * (0.5 if rng.random() < 0.08 else 1) if (t % 4 == off and t > 0) else 0.0 # 4일 주기 정기 보급: 목표 재고까지 채움(가끔 수송 차질로 절반)\n", + " stock = max(stock + r - c, 0.0)\n", + " rows.append((f\"가상{u:02d}부대\", it, t, tempo[t], plan[t], wx[t], c, r, stock))\n", + " return pd.DataFrame(rows, columns=[\"unit\", \"item\", \"day\", \"tempo\", \"plan\", \"wx\", \"consume\", \"resupply\", \"stock\"])\n", + "\n", + "\n", + "def make_samples(d, lo=21, hi=125):\n", + " \"\"\"(부대, 품목, 기준일 t0)별 특성(피처)과 정답 H. 피처는 t0 시점까지 알 수 있는 값과 사전 계획·기상 예보만 사용한다.\"\"\"\n", + " out = []\n", + " for (u, it), g in d.groupby([\"unit\", \"item\"]):\n", + " c, stk, rs = g[\"consume\"].values, g[\"stock\"].values, g[\"resupply\"].values\n", + " pl, wx, tp = g[\"plan\"].values, g[\"wx\"].values, g[\"tempo\"].values\n", + " cs = np.cumsum(c)\n", + " tg = ITEMS[it][2] * ITEMS[it][0]\n", + " for t0 in range(lo, hi):\n", + " idx = np.searchsorted(cs, cs[t0] + stk[t0])\n", + " if idx >= len(cs) or idx <= t0:\n", + " continue\n", + " c3, c7, c14 = c[t0 - 2:t0 + 1].mean(), c[t0 - 6:t0 + 1].mean(), c[t0 - 13:t0 + 1].mean()\n", + " last = [k for k in range(t0, t0 - 15, -1) if rs[k] > 0]\n", + " out.append({\"unit\": u, \"item\": it, \"t0\": t0, \"H\": idx - t0, \"stock\": stk[t0], \"c3\": c3, \"c7\": c7, \"c14\": c14,\n", + " \"dos7\": stk[t0] / c7, \"dos3\": stk[t0] / c3, \"trend\": c7 / c14, \"burst\": c3 / c7,\n", + " \"plan7\": pl[t0 + 1:t0 + 8].mean(), \"plan_ratio\": pl[t0 + 1:t0 + 8].mean() / tp[t0], \"tempo0\": tp[t0],\n", + " \"wx_fc\": wx[t0 + 1:t0 + 8].mean(), \"since_rs\": (t0 - last[0]) if last else 15,\n", + " \"rs_avg\": rs[t0 - 13:t0 + 1].sum() / 14, \"fill\": stk[t0] / tg, \"item_code\": list(ITEMS).index(it)})\n", + " return pd.DataFrame(out)\n", + "\n", + "\n", + "def mape(a, b):\n", + " # [TODO : 학생 실습 2] 부대별 일일 소모율 정규화 및 재고 변동 데이터 전처리 모듈 구현 관련 핵심 로직을 구현하세요.\n", + " # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n", + " # <<< 여기에 코드를 작성하세요 >>>\n", + " pass # 작성 후 pass를 제거하세요.\n", + "clean_truth = make_data()\n", + "rng = np.random.default_rng(2)\n", + "raw = clean_truth.copy()\n", + "raw[\"tag\"] = \"\"\n", + "n = len(raw)\n", + "inj = {}\n", + "perm = iter(rng.permutation(n)) # 오염 종류끼리 같은 행이 겹치지 않도록 한 번에 뽑는다\n", + "def pick(k):\n", + " # [TODO : 학생 실습 2] 부대별 일일 소모율 정규화 및 재고 변동 데이터 전처리 모듈 구현 관련 핵심 로직을 구현하세요.\n", + " # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n", + " # <<< 여기에 코드를 작성하세요 >>>\n", + " pass # 작성 후 pass를 제거하세요.\n", + "i_ex = pick(40); raw.loc[i_ex, \"consume\"] = raw.loc[i_ex, \"consume\"] * 2.5; raw.loc[i_ex[:20], \"tag\"] = \"긴급불출\"; raw.loc[i_ex[20:], \"tag\"] = \"비계획정비\"; inj[\"특수 예외(보존 대상)\"] = 40\n", + "i_unit = pick(30); raw.loc[i_unit, \"consume\"] = raw.loc[i_unit, \"consume\"] * 1000; inj[\"단위 혼용(1000배)\"] = 30\n", + "i_out = pick(30); raw.loc[i_out, \"consume\"] = raw.loc[i_out, \"consume\"] * 6; inj[\"태그 없는 이상 급증\"] = 30\n", + "i_na = pick(200); raw.loc[i_na, \"consume\"] = np.nan; inj[\"소모량 결측\"] = 200\n", + "i_neg = pick(20); raw.loc[i_neg, \"stock\"] = -raw.loc[i_neg, \"stock\"] - 1; inj[\"음수 재고\"] = 20\n", + "i_typo = pick(60); raw.loc[i_typo, \"item\"] = raw.loc[i_typo, \"item\"].map({\"식량\": \"식 량\", \"피복\": \"피복류\", \"유류\": \"유 류\", \"탄약\": \"탄 약\", \"수리부속\": \"수리 부속\"}); inj[\"품목명 오타\"] = 60\n", + "dup = raw.sample(100, random_state=2); inj[\"중복 행\"] = 100\n", + "miss = pick(120)\n", + "gap_rows = raw.iloc[miss]; inj[\"누락 일자 행\"] = 120\n", + "raw = pd.concat([raw.drop(raw.index[miss]), dup]).sample(frac=1, random_state=3).reset_index(drop=True)\n", + "print(f\"[정제 전] {len(raw)}행(원래 {len(clean_truth)}행), 주입 오염: {inj} [교육용가상자료]\")\n", + "\n", + "# --- 정제 규칙 10종 ---\n", + "log = {}\n", + "df = raw.copy()\n", + "R = []\n", + "b = len(df); df = df.drop_duplicates([\"unit\", \"item\", \"day\"]); log[\"R1 중복 행 제거\"] = b - len(df)\n", + "FIX = {\"식 량\": \"식량\", \"피복류\": \"피복\", \"유 류\": \"유류\", \"탄 약\": \"탄약\", \"수리 부속\": \"수리부속\"}\n", + "log[\"R2 품목명 표준화\"] = int(df[\"item\"].isin(FIX).sum()); df[\"item\"] = df[\"item\"].replace(FIX)\n", + "full = pd.MultiIndex.from_product([UNITS, list(ITEMS), range(DAYS)], names=[\"unit\", \"item\", \"day\"])\n", + "log[\"R3 누락 일자 보충\"] = len(full) - len(df)\n", + "df = df.set_index([\"unit\", \"item\", \"day\"]).reindex(full).reset_index(); df[\"tag\"] = df[\"tag\"].fillna(\"\")\n", + "for c in (\"tempo\", \"plan\", \"wx\", \"resupply\"):\n", + " df[c] = df.groupby([\"unit\", \"item\"])[c].transform(lambda s: s.interpolate(limit_direction=\"both\"))\n", + "df[\"wx\"] = df[\"wx\"].round().astype(int)\n", + "med = df.groupby([\"unit\", \"item\"])[\"consume\"].transform(\"median\")\n", + "unit_err = (df[\"consume\"] > 300 * med) & (df[\"tag\"] == \"\")\n", + "log[\"R4 단위 혼용 보정(1000배 -> /1000)\"] = int(unit_err.sum()); df.loc[unit_err, \"consume\"] /= 1000\n", + "out = (df[\"consume\"] > 4 * med) & (df[\"tag\"] == \"\")\n", + "log[\"R5 태그 없는 이상 급증 상한 처리(중앙값 x3)\"] = int(out.sum()); df.loc[out, \"consume\"] = 3 * med[out]\n", + "log[\"R6 특수 예외 보존(긴급불출·비계획정비)\"] = int((df[\"tag\"] != \"\").sum())\n", + "log[\"R7 소모량 결측 선형 보간\"] = int(df[\"consume\"].isna().sum())\n", + "df[\"consume\"] = df.groupby([\"unit\", \"item\"])[\"consume\"].transform(lambda s: s.interpolate(limit_direction=\"both\"))\n", + "log[\"R8 음수 재고 -> 흐름 재계산 대상\"] = int((df[\"stock\"] < 0).sum())\n", + "s0 = clean_truth.groupby([\"unit\", \"item\"])[\"stock\"].first()\n", + "stk = []\n", + "for (u, it), g in df.groupby([\"unit\", \"item\"], sort=False): # R9 재고 흐름 일관성: stock = max(전일 + 보급 - 소모, 0)\n", + " c, r, cur = g[\"consume\"].values, g[\"resupply\"].values, s0[(u, it)]\n", + " for t in range(len(g)):\n", + " if t > 0:\n", + " cur = max(cur + r[t] - c[t], 0.0)\n", + " else: # 첫날은 보고 재고를 쓰되 결측·음수면 초기 재고 사용\n", + " first = g[\"stock\"].values[0]\n", + " cur = first if first >= 0 else s0[(u, it)]\n", + " stk.append(cur)\n", + "log[\"R9 재고 흐름 재계산\"] = len(df)\n", + "df[\"stock\"] = stk\n", + "df[\"base\"] = df.groupby([\"unit\", \"item\"])[\"consume\"].transform(lambda s: s.iloc[:14].median())\n", + "df[\"rate\"] = df[\"consume\"] / df[\"base\"] # R10 일소모율 정규화(부대·품목별 기준 소모량 대비)\n", + "log[\"R10 일소모율 정규화\"] = len(df)\n", + "print(\"\\n[정제 규칙 10종 적용 건수]\")\n", + "for k, v in log.items():\n", + " print(f\" {k}: {v}\")\n", + "\n", + "# --- 정합성 검증(정제 후) ---\n", + "chk = {\"중복 키 없음\": not df.duplicated([\"unit\", \"item\", \"day\"]).any(), \"품목명 표준\": df[\"item\"].isin(ITEMS).all(),\n", + " \"일자 연속(160일)\": df.groupby([\"unit\", \"item\"])[\"day\"].count().eq(DAYS).all(), \"결측 없음\": not df[[\"consume\", \"stock\", \"resupply\", \"tempo\"]].isna().any().any(),\n", + " \"소모량 0 이상\": (df[\"consume\"] >= 0).all(), \"재고 0 이상\": (df[\"stock\"] >= 0).all(),\n", + " \"소모율 합리 범위(0.2~6)\": df[\"rate\"].between(0.2, 6.0).all(),\n", + " \"특수 예외 태그 보존\": (df[\"tag\"] != \"\").sum() == 40, \"단위 이상 없음(1000배 급증 0건)\": not (df[\"consume\"] > 50 * df[\"base\"]).any(),\n", + " \"키 조합 100개\": df.groupby([\"unit\", \"item\"]).ngroups == 100}\n", + "for k, v in chk.items():\n", + " print(f\" 검증 {k}: {'통과' if v else '실패'}\")\n", + "rate = sum(chk.values()) / len(chk)\n", + "print(f\"정합성 검증 통과율 {rate:.0%} ({sum(chk.values())}/{len(chk)})\")\n", + "cmp = df.merge(clean_truth, on=[\"unit\", \"item\", \"day\"], suffixes=(\"\", \"_t\"))\n", + "bad_rows = cmp[(cmp[\"tag\"] == \"\") ]\n", + "print(f\"복원 품질: 일반 행의 소모량 평균 절대 오차 {float((bad_rows['consume'] - bad_rows['consume_t']).abs().mean()):.3f} (평균 소모량 대비 {float((bad_rows['consume'] - bad_rows['consume_t']).abs().mean() / bad_rows['consume_t'].mean()):.2%})\")\n", + "ex = cmp[cmp[\"tag\"] != \"\"]\n", + "print(f\"특수 예외 {len(ex)}건은 값을 그대로 보존(지우거나 깎지 않음): 소모 급증 평균 {float((ex['consume'] / ex['base']).mean()):.1f}배 유지\")\n", + "print(f\"\\n목표(정제 규칙 10종 적용, 정합성 검증 통과율 100%): {'달성' if len(log) >= 10 and rate == 1 else '미달'}\")\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 02 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + "[정제 전] 15980행(원래 16000행), 주입 오염: {'특수 예외(보존 대상)': 40, '단위 혼용(1000배)': 30, '태그 없는 이상 급증': 30, '소모량 결측': 200, '음수 재고': 20, '품목명 오타': 60, '중복 행': 100, '누락 일자 행': 120} [교육용가상자료]\n", + "\n", + "[정제 규칙 10종 적용 건수]\n", + " R1 중복 행 제거: 99\n", + " R2 품목명 표준화: 60\n", + " R3 누락 일자 보충: 119\n", + " R4 단위 혼용 보정(1000배 -> /1000): 30\n", + " R5 태그 없는 이상 급증 상한 처리(중앙값 x3): 30\n", + " R6 특수 예외 보존(긴급불출·비계획정비): 40\n", + " R7 소모량 결측 선형 보간: 319\n", + " R8 음수 재고 -> 흐름 재계산 대상: 20\n", + " R9 재고 흐름 재계산: 16000\n", + " R10 일소모율 정규화: 16000\n", + " 검증 중복 키 없음: 통과\n", + " 검증 품목명 표준: 통과\n", + " 검증 일자 연속(160일): 통과\n", + " 검증 결측 없음: 통과\n", + " 검증 소모량 0 이상: 통과\n", + " 검증 재고 0 이상: 통과\n", + " 검증 소모율 합리 범위(0.2~6): 통과\n", + " 검증 특수 예외 태그 보존: 통과\n", + " 검증 단위 이상 없음(1000배 급증 0건): 통과\n", + " 검증 키 조합 100개: 통과\n", + "정합성 검증 통과율 100% (10/10)\n", + "복원 품질: 일반 행의 소모량 평균 절대 오차 0.715 (평균 소모량 대비 0.56%)\n", + "특수 예외 40건은 값을 그대로 보존(지우거나 깎지 않음): 소모 급증 평균 2.4배 유지\n", + "\n", + "목표(정제 규칙 10종 적용, 정합성 검증 통과율 100%): 달성\n", + "```\n", + "
\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 03. 단순 잔여 DOS 계산 및 1차 기준 미달 부대 식별 Baseline 알고리즘 구현\n", + "**소요시간**: 약 2.5시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "단순 잔여 DOS 계산 및 기준 미달 부대 식별 Baseline 알고리즘을 구현한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **재고와 최근 소모량으로 단순 DOS를 계산해 기준 미달 부대를 식별하는 Baseline을 만들고, 탐색적 분석(EDA)으로 의미 있는 피처를 찾는다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **목표 DOS 대비 달성률이 50% 미만인 부대·품목을 1차 기준 미달로 식별 수행하고 검증하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `notebooks/str01_eda_analysis.ipynb 및 EDA 요약 리포트` 형태의 결과물을 완성한다.\n", + "\n", + "### 🔍 평가 및 검증 기준 (PASS 조건)\n", + "#### 미션 판정\n", + "\n", + "**평가 구분:** 필수\n", + "\n", + "**핵심 평가영역:** 군수 의사결정에 직관적인 인사이트 제공 수준\n", + "\n", + "#### 미션 PASS 조건\n", + "\n", + "- 정량 기준: EDA 시각화 차트 8종 이상 생성, 유의미한 상관관계 피처 5개 이상 도출\n", + "\n", + "\n", + "- 진행 기준: EDA 노트북 실행 완료 및 핵심 발견점(Key Findings) 문서화\n", + "\n", + "\n", + "- 검증 방법: 통계적 가설 검정(p-value, 상관계수)을 통한 유의성 검증\n", + "\n", + "\n", + "#### 채점자 판정 메모\n", + "\n", + "> 단순 코드 실행 여부뿐만 아니라 출력된 수치와 근거 자료가 군 실무 요구사항에 부합하는지 면밀히 확인한다.\n", + "\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "\"\"\"재고와 최근 평균 소모량으로 잔여 보급일수(DOS)를 계산하고 목표 대비 기준 미달 부대를 식별하는 Baseline을 만들며, 8종 탐색 차트와 결핍 위험 일수와의 상관 분석으로 의미 있는 피처 5개 이상을 도출한다.\"\"\"\n", + "import numpy as np\n", + "import pandas as pd\n", + "import matplotlib.pyplot as plt\n", + "\n", + "# ---------------------------------------------------------------------------\n", + "# [교육용가상자료] 가상 부대 20개 x 핵심 보급품 5종 일일 재고·소모 시뮬레이터 (실제 부대·수량·기준일수 아님)\n", + "# 핵심성과지표(KPI) = 기준 보급일수(DOS) = 현재 재고 / 최근 평균 일일 소모량. 목표 DOS는 교육용 가정값이다.\n", + "# 결핍 위험 일수 H = 보급이 없다고 가정할 때 현재 재고가 바닥나기까지 남은 일수(미래 실제 소모량으로 계산한 정답).\n", + "# ---------------------------------------------------------------------------\n", + "ITEMS = {\"식량\": (100, 0.3, 10), \"피복\": (20, 0.1, 15), \"유류\": (300, 1.2, 7), \"탄약\": (60, 1.5, 6), \"수리부속\": (15, 0.5, 12)} # (기본 일소모, 템포 민감도, 목표 DOS)\n", + "N_UNIT, DAYS = 20, 160\n", + "UNITS = [f\"가상{u:02d}부대\" for u in range(1, N_UNIT + 1)]\n", + "\n", + "\n", + "def make_data(seed=1, n_unit=N_UNIT, days=DAYS):\n", + " \"\"\"일 단위 재고 원장: unit, item, day, tempo(실제 작전 템포), plan(사전 계획 템포), wx(악천후), consume, resupply, stock.\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " rows = []\n", + " for u in range(1, n_unit + 1):\n", + " s, ph = rng.uniform(0.7, 1.5), rng.uniform(0, 6.28)\n", + " surge = np.zeros(days)\n", + " for st in rng.choice(days - 10, 4, replace=False):\n", + " surge[st:st + int(rng.integers(5, 10))] = rng.uniform(0.4, 0.8)\n", + " tempo = 1 + 0.25 * np.sin(2 * np.pi * np.arange(days) / 30 + ph) + surge\n", + " plan = tempo + rng.normal(0, 0.08, days)\n", + " wx = (rng.random(days) < 0.15).astype(int)\n", + " for it, (b, sens, tg) in ITEMS.items():\n", + " stock, off = tg * b * s * rng.uniform(0.8, 1.2), int(rng.integers(0, 4))\n", + " for t in range(days):\n", + " c = b * s * (1 + sens * (tempo[t] - 1)) * (1 + 0.12 * wx[t]) * rng.normal(1, 0.08)\n", + " r = max(tg * b * s - stock, 0) * rng.uniform(0.9, 1.1) * (0.5 if rng.random() < 0.08 else 1) if (t % 4 == off and t > 0) else 0.0 # 4일 주기 정기 보급: 목표 재고까지 채움(가끔 수송 차질로 절반)\n", + " stock = max(stock + r - c, 0.0)\n", + " rows.append((f\"가상{u:02d}부대\", it, t, tempo[t], plan[t], wx[t], c, r, stock))\n", + " return pd.DataFrame(rows, columns=[\"unit\", \"item\", \"day\", \"tempo\", \"plan\", \"wx\", \"consume\", \"resupply\", \"stock\"])\n", + "\n", + "\n", + "def make_samples(d, lo=21, hi=125):\n", + " \"\"\"(부대, 품목, 기준일 t0)별 특성(피처)과 정답 H. 피처는 t0 시점까지 알 수 있는 값과 사전 계획·기상 예보만 사용한다.\"\"\"\n", + " out = []\n", + " for (u, it), g in d.groupby([\"unit\", \"item\"]):\n", + " c, stk, rs = g[\"consume\"].values, g[\"stock\"].values, g[\"resupply\"].values\n", + " pl, wx, tp = g[\"plan\"].values, g[\"wx\"].values, g[\"tempo\"].values\n", + " cs = np.cumsum(c)\n", + " tg = ITEMS[it][2] * ITEMS[it][0]\n", + " for t0 in range(lo, hi):\n", + " idx = np.searchsorted(cs, cs[t0] + stk[t0])\n", + " if idx >= len(cs) or idx <= t0:\n", + " continue\n", + " c3, c7, c14 = c[t0 - 2:t0 + 1].mean(), c[t0 - 6:t0 + 1].mean(), c[t0 - 13:t0 + 1].mean()\n", + " last = [k for k in range(t0, t0 - 15, -1) if rs[k] > 0]\n", + " out.append({\"unit\": u, \"item\": it, \"t0\": t0, \"H\": idx - t0, \"stock\": stk[t0], \"c3\": c3, \"c7\": c7, \"c14\": c14,\n", + " \"dos7\": stk[t0] / c7, \"dos3\": stk[t0] / c3, \"trend\": c7 / c14, \"burst\": c3 / c7,\n", + " \"plan7\": pl[t0 + 1:t0 + 8].mean(), \"plan_ratio\": pl[t0 + 1:t0 + 8].mean() / tp[t0], \"tempo0\": tp[t0],\n", + " \"wx_fc\": wx[t0 + 1:t0 + 8].mean(), \"since_rs\": (t0 - last[0]) if last else 15,\n", + " \"rs_avg\": rs[t0 - 13:t0 + 1].sum() / 14, \"fill\": stk[t0] / tg, \"item_code\": list(ITEMS).index(it)})\n", + " return pd.DataFrame(out)\n", + "\n", + "\n", + "def mape(a, b):\n", + " # [TODO : 학생 실습 3] 단순 잔여 DOS 계산 및 1차 기준 미달 부대 식별 Baseline 알고리즘 구현 관련 핵심 로직을 구현하세요.\n", + " # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n", + " # <<< 여기에 코드를 작성하세요 >>>\n", + " pass # 작성 후 pass를 제거하세요.\n", + "d = make_data()\n", + "d[\"c7\"] = d.groupby([\"unit\", \"item\"])[\"consume\"].transform(lambda s: s.rolling(7, min_periods=3).mean())\n", + "d[\"dos\"] = d[\"stock\"] / d[\"c7\"]\n", + "d[\"target\"] = d[\"item\"].map(lambda i: ITEMS[i][2])\n", + "d[\"rate\"] = d[\"dos\"] / d[\"target\"]\n", + "\n", + "# 1) Baseline: 기준 미달 = 달성률 50% 미만(주의), 25% 미만(심각 후보)\n", + "T = 100\n", + "now = d[d[\"day\"] == T]\n", + "low = now[now[\"rate\"] < 0.5].sort_values(\"rate\")\n", + "print(f\"[Baseline - {T}일째] 단순 DOS = 재고 / 최근 7일 평균 소모량 [교육용가상자료]\")\n", + "print(f\"부대 x 품목 {len(now)}건 중 달성률 50% 미만 {len(low)}건({len(low) / len(now):.0%}), 25% 미만 {(now['rate'] < 0.25).sum()}건\")\n", + "print(\"기준 미달 상위 5건:\")\n", + "print(low.head(5)[[\"unit\", \"item\", \"stock\", \"c7\", \"dos\", \"target\", \"rate\"]].round(2).to_string(index=False))\n", + "unit_cnt = low.groupby(\"unit\").size().sort_values(ascending=False)\n", + "print(\"미달 품목이 많은 부대 3곳:\", unit_cnt.head(3).to_dict())\n", + "\n", + "# 2) EDA용 표본(결핍 위험 일수 H 포함)과 상관 분석\n", + "s = make_samples(d[[\"unit\", \"item\", \"day\", \"tempo\", \"plan\", \"wx\", \"consume\", \"resupply\", \"stock\"]])\n", + "feat = [c for c in s.columns if c not in (\"unit\", \"item\", \"t0\", \"H\")]\n", + "corr = s[feat + [\"H\"]].corr()[\"H\"].drop(\"H\").sort_values(key=abs, ascending=False)\n", + "sig = corr[corr.abs() >= 0.3]\n", + "print(f\"\\n[결핍 위험 일수 H와의 상관] 표본 {len(s)}건, |r| 0.3 이상 피처 {len(sig)}개\")\n", + "print(corr.round(3).head(8).to_string())\n", + "\n", + "# 3) EDA 차트 8종\n", + "fig, ax = plt.subplots(2, 4, figsize=(17, 7))\n", + "ax[0, 0].hist(now[\"dos\"].clip(upper=25), bins=20, color=\"tab:blue\"); ax[0, 0].set_title(\"1 DOS distribution\")\n", + "dm = d.groupby(\"day\")[\"consume\"].mean(); ax[0, 1].plot(dm.index, dm.values); ax[0, 1].set_title(\"2 Mean daily consumption\")\n", + "sub = d[d[\"item\"] == \"탄약\"].sample(600, random_state=1); ax[0, 2].scatter(sub[\"tempo\"], sub[\"consume\"], s=6); ax[0, 2].set_title(\"3 Tempo vs consumption\")\n", + "ax[0, 3].boxplot([d[d[\"wx\"] == 0][\"consume\"] / d[\"c7\"].mean(), d[d[\"wx\"] == 1][\"consume\"] / d[\"c7\"].mean()]); ax[0, 3].set_xticklabels([\"fair\", \"bad wx\"]); ax[0, 3].set_title(\"4 Weather effect\")\n", + "for u in UNITS[:3]:\n", + " g = d[(d[\"unit\"] == u) & (d[\"item\"] == \"유류\")]; ax[1, 0].plot(g[\"day\"], g[\"stock\"], label=u[2:4])\n", + "ax[1, 0].legend(fontsize=7); ax[1, 0].set_title(\"5 Stock trajectories\")\n", + "ax[1, 1].boxplot([d[d[\"item\"] == i][\"rate\"].dropna().clip(upper=3) for i in ITEMS]); ax[1, 1].set_xticklabels([f\"i{k + 1}\" for k in range(5)]); ax[1, 1].set_title(\"6 Achievement rate by item\")\n", + "cnt = d[d[\"day\"] >= 14].groupby(\"day\")[\"rate\"].apply(lambda x: (x < 0.5).mean()); ax[1, 2].plot(cnt.index, cnt.values, color=\"tab:red\"); ax[1, 2].set_title(\"7 Share below 50% target\")\n", + "ax[1, 3].barh(sig.index[::-1][:8], sig.values[::-1][:8], color=\"tab:green\"); ax[1, 3].set_title(\"8 Correlation with H\")\n", + "plt.tight_layout(); plt.show()\n", + "n_chart = ax.size\n", + "r_t = float(d[d[\"item\"] == \"탄약\"][[\"tempo\", \"consume\"]].corr().iloc[0, 1])\n", + "wx_up = float(d[d[\"wx\"] == 1][\"consume\"].mean() / d[d[\"wx\"] == 0][\"consume\"].mean() - 1)\n", + "srs = s.groupby(pd.cut(s[\"since_rs\"], [-1, 0, 1, 2, 3, 20]))[\"dos7\"].mean()\n", + "print(f\"\\nEDA 시각화 차트 {n_chart}종 생성\")\n", + "print(f\"인사이트: (1) 탄약 소모와 작전 템포의 상관 r={r_t:.2f}. (2) 악천후일 평균 소모가 {wx_up:+.1%}. (3) 보급 후 경과일별 평균 DOS: {[round(float(v), 1) for v in srs.values]} (보급 직전이 가장 낮음)\")\n", + "print(\"의미 있는 상관 피처:\", \", \".join(sig.index[:6]))\n", + "print(f\"목표(EDA 차트 8종 이상, 유의미한 상관 피처 5개 이상): 차트 {n_chart}종 {'달성' if n_chart >= 8 else '미달'}, 피처 {len(sig)}개 {'달성' if len(sig) >= 5 else '미달'}\")\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 03 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + "[Baseline - 100일째] 단순 DOS = 재고 / 최근 7일 평균 소모량 [교육용가상자료]\n", + "부대 x 품목 100건 중 달성률 50% 미만 29건(29%), 25% 미만 13건\n", + "기준 미달 상위 5건:\n", + " unit item stock c7 dos target rate\n", + "가상01부대 유류 0.0 431.60 0.0 7 0.0\n", + "가상02부대 탄약 0.0 99.02 0.0 6 0.0\n", + "가상08부대 탄약 0.0 87.56 0.0 6 0.0\n", + "가상16부대 탄약 0.0 100.04 0.0 6 0.0\n", + "가상18부대 탄약 0.0 155.91 0.0 6 0.0\n", + "미달 품목이 많은 부대 3곳: {'가상16부대': 4, '가상03부대': 3, '가상18부대': 3}\n", + "\n", + "[결핍 위험 일수 H와의 상관] 표본 10164건, |r| 0.3 이상 피처 7개\n", + "dos3 0.958\n", + "dos7 0.950\n", + "fill 0.633\n", + "c3 -0.544\n", + "c7 -0.529\n", + "c14 -0.508\n", + "rs_avg -0.446\n", + "since_rs -0.264\n", + "\n", + "EDA 시각화 차트 8종 생성\n", + "인사이트: (1) 탄약 소모와 작전 템포의 상관 r=0.86. (2) 악천후일 평균 소모가 +11.8%. (3) 보급 후 경과일별 평균 DOS: [8.4, 7.4, 6.6, 5.9] (보급 직전이 가장 낮음)\n", + "의미 있는 상관 피처: dos3, dos7, fill, c3, c7, c14\n", + "목표(EDA 차트 8종 이상, 유의미한 상관 피처 5개 이상): 차트 8종 달성, 피처 7개 달성\n", + "```\n", + "
\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 04. 작전 템포 변동 및 기상 악화 연계 부대별 결핍 위험 일자(Stockout Date) 정밀 예측 고도화\n", + "**소요시간**: 약 3.0시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "작전 템포 변동 연계 부대별 결핍 위험 일자(Stockout Date) 정밀 예측을 고도화한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **작전 템포 변동과 기상 예보를 반영한 파생 피처로 부대별 결핍 위험 일자(Stockout Date)를 예측하는 기준 모델을 만든다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **사전 계획 템포, 다음 7일 악천후 예보, 최근 소모 추세, 보급 후 경과일 같은 파생 피처 13개를 설계하고 이유를 한 줄씩 설명 수행하고 검증하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `src/features/str01_feature_engineering.py 및 기준 모델` 형태의 결과물을 완성한다.\n", + "\n", + "### 🔍 평가 및 검증 기준 (PASS 조건)\n", + "#### 미션 판정\n", + "\n", + "**평가 구분:** 필수\n", + "\n", + "**핵심 평가영역:** 군수 실무에서 납득 가능한 피처 설계의 논리성\n", + "\n", + "#### 미션 PASS 조건\n", + "\n", + "- 정량 기준: 파생 피처 10개 이상 생성, 기준 모델 MAPE 25% 이하 달성\n", + "\n", + "\n", + "- 진행 기준: 피처 파이프라인 및 베이스라인 모델 성능 지표 도출\n", + "\n", + "\n", + "- 검증 방법: 교차 검증(Time-series Cross Validation)을 통한 과적합 방지\n", + "\n", + "\n", + "#### 채점자 판정 메모\n", + "\n", + "> 단순 코드 실행 여부뿐만 아니라 출력된 수치와 근거 자료가 군 실무 요구사항에 부합하는지 면밀히 확인한다.\n", + "\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "\"\"\"작전 템포 계획과 기상 예보를 포함한 파생 피처 13개를 설계해 릿지 회귀 기준 모델로 부대별 결핍 위험 일자(Stockout Date)를 예측하고, 단순 DOS 방식과 MAPE를 비교하며 템포 상승·악천후 가정 시나리오 영향을 확인한다.\"\"\"\n", + "import numpy as np\n", + "import pandas as pd\n", + "from sklearn.linear_model import Ridge\n", + "from sklearn.preprocessing import StandardScaler\n", + "from sklearn.pipeline import make_pipeline\n", + "\n", + "# ---------------------------------------------------------------------------\n", + "# [교육용가상자료] 가상 부대 20개 x 핵심 보급품 5종 일일 재고·소모 시뮬레이터 (실제 부대·수량·기준일수 아님)\n", + "# 핵심성과지표(KPI) = 기준 보급일수(DOS) = 현재 재고 / 최근 평균 일일 소모량. 목표 DOS는 교육용 가정값이다.\n", + "# 결핍 위험 일수 H = 보급이 없다고 가정할 때 현재 재고가 바닥나기까지 남은 일수(미래 실제 소모량으로 계산한 정답).\n", + "# ---------------------------------------------------------------------------\n", + "ITEMS = {\"식량\": (100, 0.3, 10), \"피복\": (20, 0.1, 15), \"유류\": (300, 1.2, 7), \"탄약\": (60, 1.5, 6), \"수리부속\": (15, 0.5, 12)} # (기본 일소모, 템포 민감도, 목표 DOS)\n", + "N_UNIT, DAYS = 20, 160\n", + "UNITS = [f\"가상{u:02d}부대\" for u in range(1, N_UNIT + 1)]\n", + "\n", + "\n", + "def make_data(seed=1, n_unit=N_UNIT, days=DAYS):\n", + " \"\"\"일 단위 재고 원장: unit, item, day, tempo(실제 작전 템포), plan(사전 계획 템포), wx(악천후), consume, resupply, stock.\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " rows = []\n", + " for u in range(1, n_unit + 1):\n", + " s, ph = rng.uniform(0.7, 1.5), rng.uniform(0, 6.28)\n", + " surge = np.zeros(days)\n", + " for st in rng.choice(days - 10, 4, replace=False):\n", + " surge[st:st + int(rng.integers(5, 10))] = rng.uniform(0.4, 0.8)\n", + " tempo = 1 + 0.25 * np.sin(2 * np.pi * np.arange(days) / 30 + ph) + surge\n", + " plan = tempo + rng.normal(0, 0.08, days)\n", + " wx = (rng.random(days) < 0.15).astype(int)\n", + " for it, (b, sens, tg) in ITEMS.items():\n", + " stock, off = tg * b * s * rng.uniform(0.8, 1.2), int(rng.integers(0, 4))\n", + " for t in range(days):\n", + " c = b * s * (1 + sens * (tempo[t] - 1)) * (1 + 0.12 * wx[t]) * rng.normal(1, 0.08)\n", + " r = max(tg * b * s - stock, 0) * rng.uniform(0.9, 1.1) * (0.5 if rng.random() < 0.08 else 1) if (t % 4 == off and t > 0) else 0.0 # 4일 주기 정기 보급: 목표 재고까지 채움(가끔 수송 차질로 절반)\n", + " stock = max(stock + r - c, 0.0)\n", + " rows.append((f\"가상{u:02d}부대\", it, t, tempo[t], plan[t], wx[t], c, r, stock))\n", + " return pd.DataFrame(rows, columns=[\"unit\", \"item\", \"day\", \"tempo\", \"plan\", \"wx\", \"consume\", \"resupply\", \"stock\"])\n", + "\n", + "\n", + "def make_samples(d, lo=21, hi=125):\n", + " \"\"\"(부대, 품목, 기준일 t0)별 특성(피처)과 정답 H. 피처는 t0 시점까지 알 수 있는 값과 사전 계획·기상 예보만 사용한다.\"\"\"\n", + " out = []\n", + " for (u, it), g in d.groupby([\"unit\", \"item\"]):\n", + " c, stk, rs = g[\"consume\"].values, g[\"stock\"].values, g[\"resupply\"].values\n", + " pl, wx, tp = g[\"plan\"].values, g[\"wx\"].values, g[\"tempo\"].values\n", + " cs = np.cumsum(c)\n", + " tg = ITEMS[it][2] * ITEMS[it][0]\n", + " for t0 in range(lo, hi):\n", + " idx = np.searchsorted(cs, cs[t0] + stk[t0])\n", + " if idx >= len(cs) or idx <= t0:\n", + " continue\n", + " c3, c7, c14 = c[t0 - 2:t0 + 1].mean(), c[t0 - 6:t0 + 1].mean(), c[t0 - 13:t0 + 1].mean()\n", + " last = [k for k in range(t0, t0 - 15, -1) if rs[k] > 0]\n", + " out.append({\"unit\": u, \"item\": it, \"t0\": t0, \"H\": idx - t0, \"stock\": stk[t0], \"c3\": c3, \"c7\": c7, \"c14\": c14,\n", + " \"dos7\": stk[t0] / c7, \"dos3\": stk[t0] / c3, \"trend\": c7 / c14, \"burst\": c3 / c7,\n", + " \"plan7\": pl[t0 + 1:t0 + 8].mean(), \"plan_ratio\": pl[t0 + 1:t0 + 8].mean() / tp[t0], \"tempo0\": tp[t0],\n", + " \"wx_fc\": wx[t0 + 1:t0 + 8].mean(), \"since_rs\": (t0 - last[0]) if last else 15,\n", + " \"rs_avg\": rs[t0 - 13:t0 + 1].sum() / 14, \"fill\": stk[t0] / tg, \"item_code\": list(ITEMS).index(it)})\n", + " return pd.DataFrame(out)\n", + "\n", + "\n", + "def mape(a, b):\n", + " # [TODO : 학생 실습 4] 작전 템포 변동 및 기상 악화 연계 부대별 결핍 위험 일자(Stockout Date) 정밀 예측 고도화 관련 핵심 로직을 구현하세요.\n", + " # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n", + " # <<< 여기에 코드를 작성하세요 >>>\n", + " pass # 작성 후 pass를 제거하세요.\n", + "d = make_data()\n", + "s = make_samples(d)\n", + "X = [c for c in s.columns if c not in (\"unit\", \"item\", \"t0\", \"H\")]\n", + "DESC = {\"stock\": \"현재 재고\", \"c3\": \"최근 3일 평균 소모\", \"c7\": \"최근 7일 평균 소모\", \"c14\": \"최근 14일 평균 소모\", \"dos7\": \"재고/7일 소모(DOS)\",\n", + " \"dos3\": \"재고/3일 소모\", \"trend\": \"7일/14일 소모 추세\", \"burst\": \"3일/7일 소모 급증도\", \"plan7\": \"다음 7일 계획 템포 평균\", \"plan_ratio\": \"계획 템포/현재 템포\",\n", + " \"tempo0\": \"현재 작전 템포\", \"wx_fc\": \"다음 7일 악천후 예보 비율\", \"since_rs\": \"마지막 보급 후 경과일\", \"rs_avg\": \"최근 14일 평균 보급량\", \"fill\": \"목표 재고 대비 현재 재고\", \"item_code\": \"품목 코드\"}\n", + "derived = [c for c in X if c not in (\"stock\", \"tempo0\", \"item_code\")]\n", + "print(f\"[피처 설계] 전체 {len(X)}개 중 파생 피처 {len(derived)}개 [교육용가상자료]\")\n", + "for c in derived:\n", + " print(f\" - {c}: {DESC[c]}\")\n", + "\n", + "tr, te = s[s[\"t0\"] <= 90], s[s[\"t0\"] > 90]\n", + "model = make_pipeline(StandardScaler(), Ridge(alpha=1.0)).fit(tr[X], tr[\"H\"])\n", + "p_base = te[\"dos7\"].values # 단순 DOS 방식\n", + "p_ridge = np.clip(model.predict(te[X]), 1, None) # 기준 모델\n", + "y = te[\"H\"].values\n", + "res = pd.DataFrame({\"모델\": [\"단순 DOS(Baseline)\", \"릿지 기준 모델\"], \"MAPE\": [mape(y, p_base), mape(y, p_ridge)],\n", + " \"MAE(일)\": [np.abs(y - p_base).mean(), np.abs(y - p_ridge).mean()], \"RMSE(일)\": [np.sqrt(((y - p_base) ** 2).mean()), np.sqrt(((y - p_ridge) ** 2).mean())]})\n", + "print(f\"\\n[예측 성능] 학습 {len(tr)}건(~90일), 시험 {len(te)}건(91일~)\")\n", + "print(res.round(3).to_string(index=False))\n", + "\n", + "# 결핍 위험 일자 예측 표(100일 기준)\n", + "now = s[s[\"t0\"] == 100].copy()\n", + "now[\"예측H\"] = np.clip(model.predict(now[X]), 1, None)\n", + "now[\"결핍예상일\"] = (now[\"t0\"] + now[\"예측H\"]).round().astype(int)\n", + "print(\"\\n[결핍 위험 일자 예측 - 100일 기준, 가까운 순 6건]\")\n", + "print(now.sort_values(\"예측H\").head(6)[[\"unit\", \"item\", \"stock\", \"예측H\", \"결핍예상일\", \"H\"]].rename(columns={\"H\": \"실제H\"}).round(1).to_string(index=False))\n", + "\n", + "# 가정 시나리오: 계획 템포 +30%와 다음 7일 악천후 50% 가정 시 예측 변화\n", + "w = now.copy()\n", + "w[\"plan7\"] *= 1.3; w[\"plan_ratio\"] *= 1.3; w[\"wx_fc\"] = 0.5\n", + "shift = np.clip(model.predict(w[X]), 1, None) - now[\"예측H\"].values\n", + "print(f\"\\n[가정 시나리오] 계획 템포 +30%, 악천후 50% 가정 시 예측 결핍 일수 평균 {shift.mean():+.2f}일 변화 (탄약 {shift[now['item'].values == '탄약'].mean():+.2f}일, 식량 {shift[now['item'].values == '식량'].mean():+.2f}일)\")\n", + "coef = pd.Series(model[-1].coef_, index=X).sort_values(key=abs, ascending=False)\n", + "print(\"기준 모델 영향 큰 피처 상위 5:\", {k: round(float(v), 2) for k, v in coef.head(5).items()})\n", + "m = mape(y, p_ridge)\n", + "print(f\"\\n목표(파생 피처 10개 이상, 기준 모델 MAPE 25% 이하): 파생 피처 {len(derived)}개 {'달성' if len(derived) >= 10 else '미달'}, MAPE {m:.1%} {'달성' if m <= 0.25 else '미달'}\")\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 04 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + "[피처 설계] 전체 16개 중 파생 피처 13개 [교육용가상자료]\n", + " - c3: 최근 3일 평균 소모\n", + " - c7: 최근 7일 평균 소모\n", + " - c14: 최근 14일 평균 소모\n", + " - dos7: 재고/7일 소모(DOS)\n", + " - dos3: 재고/3일 소모\n", + " - trend: 7일/14일 소모 추세\n", + " - burst: 3일/7일 소모 급증도\n", + " - plan7: 다음 7일 계획 템포 평균\n", + " - plan_ratio: 계획 템포/현재 템포\n", + " - wx_fc: 다음 7일 악천후 예보 비율\n", + " - since_rs: 마지막 보급 후 경과일\n", + " - rs_avg: 최근 14일 평균 보급량\n", + " - fill: 목표 재고 대비 현재 재고\n", + "\n", + "[예측 성능] 학습 6836건(~90일), 시험 3328건(91일~)\n", + " 모델 MAPE MAE(일) RMSE(일)\n", + "단순 DOS(Baseline) 0.162 0.938 1.207\n", + " 릿지 기준 모델 0.106 0.566 0.721\n", + "\n", + "[결핍 위험 일자 예측 - 100일 기준, 가까운 순 6건]\n", + " unit item stock 예측H 결핍예상일 실제H\n", + "가상01부대 탄약 31.2 1.2 101 1\n", + "가상19부대 탄약 50.9 1.7 102 1\n", + "가상03부대 탄약 81.4 1.9 102 1\n", + "가상15부대 탄약 50.2 2.0 102 1\n", + "가상04부대 탄약 93.6 2.3 102 1\n", + "가상17부대 유류 847.5 2.6 103 3\n", + "\n", + "[가정 시나리오] 계획 템포 +30%, 악천후 50% 가정 시 예측 결핍 일수 평균 -1.11일 변화 (탄약 -1.05일, 식량 -1.12일)\n", + "기준 모델 영향 큰 피처 상위 5: {'dos7': 2.15, 'dos3': 1.26, 'c7': -0.75, 'plan_ratio': -0.71, 'c14': 0.43}\n", + "\n", + "목표(파생 피처 10개 이상, 기준 모델 MAPE 25% 이하): 파생 피처 13개 달성, MAPE 10.6% 달성\n", + "```\n", + "
\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 05. 결핍 심각도 지수 산출 및 다단계 결핍 조기경보(주의/경계/심각) 엔진 개발\n", + "**소요시간**: 약 3.5시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "결핍 심각도 지수 산출 및 다단계 결핍 조기경보 엔진을 개발한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **결핍 심각도 지수를 산출하고 주의·경계·심각 3단계 결핍 조기경보 엔진을 개발한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **그래디언트 부스팅 모델로 결핍 위험 일수를 예측해 기준 모델과 MAE, RMSE, R2를 비교 수행하고 검증하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `src/models/str01_advanced_model.py` 형태의 결과물을 완성한다.\n", + "\n", + "### 🔍 평가 및 검증 기준 (PASS 조건)\n", + "#### 미션 판정\n", + "\n", + "**평가 구분:** 필수\n", + "\n", + "**핵심 평가영역:** 비정형/불규칙 수요에 대한 모델의 적응력 및 예측 안정성\n", + "\n", + "#### 미션 PASS 조건\n", + "\n", + "- 정량 기준: 기준 모델 대비 오차율(MAE/RMSE) 20% 이상 개선, R2 Score 0.85 이상\n", + "\n", + "\n", + "- 진행 기준: 모델 하이퍼파라미터 튜닝 완료 및 최적 모델 가중치 저장\n", + "\n", + "\n", + "- 검증 방법: Hold-out 테스트 데이터셋 대상 최종 성능 평가\n", + "\n", + "\n", + "#### 채점자 판정 메모\n", + "\n", + "> 단순 코드 실행 여부뿐만 아니라 출력된 수치와 근거 자료가 군 실무 요구사항에 부합하는지 면밀히 확인한다.\n", + "\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "\"\"\"기준 모델(릿지)보다 정확한 그래디언트 부스팅 모델로 결핍 위험 일수를 예측하고, 이를 0~100 결핍 심각도 지수와 주의·경계·심각 3단계 조기경보로 변환해 오차 개선율, R2, 경보 등급 정확도와 미탐·오탐을 평가한다.\"\"\"\n", + "import numpy as np\n", + "import pandas as pd\n", + "from sklearn.ensemble import GradientBoostingRegressor\n", + "from sklearn.linear_model import Ridge\n", + "from sklearn.metrics import r2_score\n", + "from sklearn.pipeline import make_pipeline\n", + "from sklearn.preprocessing import StandardScaler\n", + "\n", + "# ---------------------------------------------------------------------------\n", + "# [교육용가상자료] 가상 부대 20개 x 핵심 보급품 5종 일일 재고·소모 시뮬레이터 (실제 부대·수량·기준일수 아님)\n", + "# 핵심성과지표(KPI) = 기준 보급일수(DOS) = 현재 재고 / 최근 평균 일일 소모량. 목표 DOS는 교육용 가정값이다.\n", + "# 결핍 위험 일수 H = 보급이 없다고 가정할 때 현재 재고가 바닥나기까지 남은 일수(미래 실제 소모량으로 계산한 정답).\n", + "# ---------------------------------------------------------------------------\n", + "ITEMS = {\"식량\": (100, 0.3, 10), \"피복\": (20, 0.1, 15), \"유류\": (300, 1.2, 7), \"탄약\": (60, 1.5, 6), \"수리부속\": (15, 0.5, 12)} # (기본 일소모, 템포 민감도, 목표 DOS)\n", + "N_UNIT, DAYS = 20, 160\n", + "UNITS = [f\"가상{u:02d}부대\" for u in range(1, N_UNIT + 1)]\n", + "\n", + "\n", + "def make_data(seed=1, n_unit=N_UNIT, days=DAYS):\n", + " \"\"\"일 단위 재고 원장: unit, item, day, tempo(실제 작전 템포), plan(사전 계획 템포), wx(악천후), consume, resupply, stock.\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " rows = []\n", + " for u in range(1, n_unit + 1):\n", + " s, ph = rng.uniform(0.7, 1.5), rng.uniform(0, 6.28)\n", + " surge = np.zeros(days)\n", + " for st in rng.choice(days - 10, 4, replace=False):\n", + " surge[st:st + int(rng.integers(5, 10))] = rng.uniform(0.4, 0.8)\n", + " tempo = 1 + 0.25 * np.sin(2 * np.pi * np.arange(days) / 30 + ph) + surge\n", + " plan = tempo + rng.normal(0, 0.08, days)\n", + " wx = (rng.random(days) < 0.15).astype(int)\n", + " for it, (b, sens, tg) in ITEMS.items():\n", + " stock, off = tg * b * s * rng.uniform(0.8, 1.2), int(rng.integers(0, 4))\n", + " for t in range(days):\n", + " c = b * s * (1 + sens * (tempo[t] - 1)) * (1 + 0.12 * wx[t]) * rng.normal(1, 0.08)\n", + " r = max(tg * b * s - stock, 0) * rng.uniform(0.9, 1.1) * (0.5 if rng.random() < 0.08 else 1) if (t % 4 == off and t > 0) else 0.0 # 4일 주기 정기 보급: 목표 재고까지 채움(가끔 수송 차질로 절반)\n", + " stock = max(stock + r - c, 0.0)\n", + " rows.append((f\"가상{u:02d}부대\", it, t, tempo[t], plan[t], wx[t], c, r, stock))\n", + " return pd.DataFrame(rows, columns=[\"unit\", \"item\", \"day\", \"tempo\", \"plan\", \"wx\", \"consume\", \"resupply\", \"stock\"])\n", + "\n", + "\n", + "def make_samples(d, lo=21, hi=125):\n", + " \"\"\"(부대, 품목, 기준일 t0)별 특성(피처)과 정답 H. 피처는 t0 시점까지 알 수 있는 값과 사전 계획·기상 예보만 사용한다.\"\"\"\n", + " out = []\n", + " for (u, it), g in d.groupby([\"unit\", \"item\"]):\n", + " c, stk, rs = g[\"consume\"].values, g[\"stock\"].values, g[\"resupply\"].values\n", + " pl, wx, tp = g[\"plan\"].values, g[\"wx\"].values, g[\"tempo\"].values\n", + " cs = np.cumsum(c)\n", + " tg = ITEMS[it][2] * ITEMS[it][0]\n", + " for t0 in range(lo, hi):\n", + " idx = np.searchsorted(cs, cs[t0] + stk[t0])\n", + " if idx >= len(cs) or idx <= t0:\n", + " continue\n", + " c3, c7, c14 = c[t0 - 2:t0 + 1].mean(), c[t0 - 6:t0 + 1].mean(), c[t0 - 13:t0 + 1].mean()\n", + " last = [k for k in range(t0, t0 - 15, -1) if rs[k] > 0]\n", + " out.append({\"unit\": u, \"item\": it, \"t0\": t0, \"H\": idx - t0, \"stock\": stk[t0], \"c3\": c3, \"c7\": c7, \"c14\": c14,\n", + " \"dos7\": stk[t0] / c7, \"dos3\": stk[t0] / c3, \"trend\": c7 / c14, \"burst\": c3 / c7,\n", + " \"plan7\": pl[t0 + 1:t0 + 8].mean(), \"plan_ratio\": pl[t0 + 1:t0 + 8].mean() / tp[t0], \"tempo0\": tp[t0],\n", + " \"wx_fc\": wx[t0 + 1:t0 + 8].mean(), \"since_rs\": (t0 - last[0]) if last else 15,\n", + " \"rs_avg\": rs[t0 - 13:t0 + 1].sum() / 14, \"fill\": stk[t0] / tg, \"item_code\": list(ITEMS).index(it)})\n", + " return pd.DataFrame(out)\n", + "\n", + "\n", + "def mape(a, b):\n", + " # [TODO : 학생 실습 5] 결핍 심각도 지수 산출 및 다단계 결핍 조기경보(주의/경계/심각) 엔진 개발 관련 핵심 로직을 구현하세요.\n", + " # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n", + " # <<< 여기에 코드를 작성하세요 >>>\n", + " pass # 작성 후 pass를 제거하세요.\n", + "d = make_data()\n", + "s = make_samples(d)\n", + "X = [c for c in s.columns if c not in (\"unit\", \"item\", \"t0\", \"H\")]\n", + "tr, te = s[s[\"t0\"] <= 90], s[s[\"t0\"] > 90]\n", + "y = te[\"H\"].values\n", + "ridge = make_pipeline(StandardScaler(), Ridge(alpha=1.0)).fit(tr[X], tr[\"H\"])\n", + "gbr = GradientBoostingRegressor(n_estimators=200, max_depth=3, learning_rate=0.08, random_state=0).fit(tr[X], tr[\"H\"])\n", + "p0 = np.clip(ridge.predict(te[X]), 1, None)\n", + "p1 = np.clip(gbr.predict(te[X]), 1, None)\n", + "\n", + "def score(p):\n", + " # [TODO : 학생 실습 5] 결핍 심각도 지수 산출 및 다단계 결핍 조기경보(주의/경계/심각) 엔진 개발 관련 핵심 로직을 구현하세요.\n", + " # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n", + " # <<< 여기에 코드를 작성하세요 >>>\n", + " pass # 작성 후 pass를 제거하세요.\n", + "a, b = score(p0), score(p1)\n", + "print(\"[예측 성능 비교] 시험 표본\", len(te), \"건 (91일 이후) [교육용가상자료]\")\n", + "print(pd.DataFrame({\"기준 모델(릿지)\": a, \"고도화 모델(GBR)\": b}).round(3).to_string())\n", + "imp_mae, imp_rmse = 1 - b[\"MAE\"] / a[\"MAE\"], 1 - b[\"RMSE\"] / a[\"RMSE\"]\n", + "print(f\"기준 모델 대비 개선: MAE {imp_mae:.1%}, RMSE {imp_rmse:.1%}, R2 {b['R2']:.3f}\")\n", + "\n", + "# 결핍 심각도 지수(0~100): 예상 결핍 일수가 짧을수록 높고, 목표 재고 대비 현재 재고가 적을수록 가중\n", + "def severity(h, fill):\n", + " base = np.clip(1 - (h - 1) / 8, 0, 1) # H=1일이면 1.0, H>=9일이면 0\n", + " return 100 * np.clip(base * (1.1 - 0.2 * np.clip(fill, 0, 1)), 0, 1)\n", + "LEVELS = [(2.5, \"심각\"), (4.5, \"경계\"), (7.5, \"주의\")]\n", + "def level(h):\n", + " for th, name in LEVELS:\n", + " if h <= th:\n", + " return name\n", + " return \"정상\"\n", + "te = te.assign(pred=p1, pred0=p0)\n", + "te[\"sev\"] = severity(te[\"pred\"], te[\"fill\"])\n", + "te[\"lv_pred\"] = te[\"pred\"].map(level); te[\"lv_true\"] = te[\"H\"].map(level); te[\"lv_ridge\"] = te[\"pred0\"].map(level)\n", + "acc, acc0 = float((te[\"lv_pred\"] == te[\"lv_true\"]).mean()), float((te[\"lv_ridge\"] == te[\"lv_true\"]).mean())\n", + "order = {\"정상\": 0, \"주의\": 1, \"경계\": 2, \"심각\": 3}\n", + "miss = float(((te[\"lv_true\"].map(order) >= 2) & (te[\"lv_pred\"].map(order) < 2)).sum() / max((te[\"lv_true\"].map(order) >= 2).sum(), 1))\n", + "false = float(((te[\"lv_true\"].map(order) < 1) & (te[\"lv_pred\"].map(order) >= 2)).sum() / max((te[\"lv_true\"].map(order) < 1).sum(), 1))\n", + "print(f\"\\n[3단계 조기경보] 기준: 예상 결핍 {LEVELS[0][0]}일 이하 심각, {LEVELS[1][0]}일 이하 경계, {LEVELS[2][0]}일 이하 주의\")\n", + "print(pd.crosstab(te[\"lv_true\"], te[\"lv_pred\"]).reindex(index=[\"정상\", \"주의\", \"경계\", \"심각\"], columns=[\"정상\", \"주의\", \"경계\", \"심각\"], fill_value=0).to_string())\n", + "print(f\"등급 정확도 {acc:.1%}(기준 모델 {acc0:.1%}), 경계 이상 미탐률 {miss:.1%}, 정상 상황 경계 이상 오탐률 {false:.1%}\")\n", + "print(\"\\n[경보 예시 - 100일 기준 심각도 상위 5건]\")\n", + "nw = te[te[\"t0\"] == 100].sort_values(\"sev\", ascending=False).head(5)\n", + "print(nw[[\"unit\", \"item\", \"pred\", \"H\", \"sev\", \"lv_pred\"]].rename(columns={\"pred\": \"예측H\", \"H\": \"실제H\", \"sev\": \"심각도지수\", \"lv_pred\": \"경보\"}).round(1).to_string(index=False))\n", + "\n", + "# 불규칙 수요 적응력: 템포 급변(계획 대비 현재 템포 20% 이상 차이) 구간의 안정성\n", + "irr = te[(te[\"plan_ratio\"] - 1).abs() > 0.2]\n", + "if len(irr):\n", + " print(f\"\\n템포 급변 구간 {len(irr)}건 MAE: 기준 {np.abs(irr['H'] - irr['pred0']).mean():.2f}일 -> 고도화 {np.abs(irr['H'] - irr['pred']).mean():.2f}일\")\n", + "ok = imp_mae >= 0.2 and imp_rmse >= 0.2 and b[\"R2\"] >= 0.85\n", + "print(f\"\\n목표(기준 모델 대비 MAE/RMSE 20% 이상 개선, R2 0.85 이상): {'달성' if ok else '미달'}\")\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 05 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + "[예측 성능 비교] 시험 표본 3328 건 (91일 이후) [교육용가상자료]\n", + " 기준 모델(릿지) 고도화 모델(GBR)\n", + "MAE 0.566 0.420\n", + "RMSE 0.721 0.534\n", + "R2 0.959 0.978\n", + "MAPE 0.106 0.072\n", + "기준 모델 대비 개선: MAE 25.8%, RMSE 25.9%, R2 0.978\n", + "\n", + "[3단계 조기경보] 기준: 예상 결핍 2.5일 이하 심각, 4.5일 이하 경계, 7.5일 이하 주의\n", + "lv_pred 정상 주의 경계 심각\n", + "lv_true \n", + "정상 1567 52 0 0\n", + "주의 81 806 47 0\n", + "경계 0 48 411 16\n", + "심각 0 0 22 278\n", + "등급 정확도 92.0%(기준 모델 88.2%), 경계 이상 미탐률 6.2%, 정상 상황 경계 이상 오탐률 0.0%\n", + "\n", + "[경보 예시 - 100일 기준 심각도 상위 5건]\n", + " unit item 예측H 실제H 심각도지수 경보\n", + "가상01부대 탄약 1.0 1 100.0 심각\n", + "가상03부대 탄약 1.2 1 100.0 심각\n", + "가상16부대 유류 1.4 1 100.0 심각\n", + "가상19부대 탄약 1.0 1 100.0 심각\n", + "가상15부대 탄약 1.0 1 100.0 심각\n", + "\n", + "템포 급변 구간 827건 MAE: 기준 0.66일 -> 고도화 0.45일\n", + "\n", + "목표(기준 모델 대비 MAE/RMSE 20% 이상 개선, R2 0.85 이상): 달성\n", + "```\n", + "
\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 06. 군단/사단 관할 부대 보급 수준(DOS) 실시간 현황 지도 및 경보 대시보드 구현\n", + "**소요시간**: 약 3.0시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "관할 부대 보급 수준(DOS) 실시간 현황 지도 및 경보 대시보드를 구현한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **관할 부대의 DOS 현황을 가상 지도와 경보 색상으로 보여 주는 대시보드를 구현하고, 경보마다 예측에 기여한 인자를 설명한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **20개 부대를 격자형 가상 지도에 배치해 부대별 최악 경보 등급을 색으로 표시하고, 3개 시점의 갱신 결과를 출력 수행하고 검증하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `src/explainability/str01_xai_explainer.py 및 XAI 리포트` 형태의 결과물을 완성한다.\n", + "\n", + "### 🔍 평가 및 검증 기준 (PASS 조건)\n", + "#### 미션 판정\n", + "\n", + "**평가 구분:** 필수\n", + "\n", + "**핵심 평가영역:** 군수 장교/군무원이 신뢰하고 조치할 수 있는 설명의 명확성\n", + "\n", + "#### 미션 PASS 조건\n", + "\n", + "- 정량 기준: 주요 예측 건당 기여도 상위 3개 인자 자동 산출률 100%\n", + "\n", + "\n", + "- 진행 기준: SHAP summary plot 및 waterfall plot 생성 파이프라인 완성\n", + "\n", + "\n", + "- 검증 방법: 실무자 대상 설명 근거 유용성 평가 검증\n", + "\n", + "\n", + "#### 채점자 판정 메모\n", + "\n", + "> 단순 코드 실행 여부뿐만 아니라 출력된 수치와 근거 자료가 군 실무 요구사항에 부합하는지 면밀히 확인한다.\n", + "\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "\"\"\"20개 가상 부대를 격자형 가상 지도에 배치해 보급 수준(DOS) 현황과 3단계 경보를 색으로 표시하는 대시보드를 만들고, 경보 건마다 예측에 기여한 상위 3개 인자를 자동으로 설명(XAI)하며 그래프와 텍스트 보고를 출력한다.\"\"\"\n", + "import numpy as np\n", + "import pandas as pd\n", + "import matplotlib.pyplot as plt\n", + "from sklearn.ensemble import GradientBoostingRegressor\n", + "\n", + "# ---------------------------------------------------------------------------\n", + "# [교육용가상자료] 가상 부대 20개 x 핵심 보급품 5종 일일 재고·소모 시뮬레이터 (실제 부대·수량·기준일수 아님)\n", + "# 핵심성과지표(KPI) = 기준 보급일수(DOS) = 현재 재고 / 최근 평균 일일 소모량. 목표 DOS는 교육용 가정값이다.\n", + "# 결핍 위험 일수 H = 보급이 없다고 가정할 때 현재 재고가 바닥나기까지 남은 일수(미래 실제 소모량으로 계산한 정답).\n", + "# ---------------------------------------------------------------------------\n", + "ITEMS = {\"식량\": (100, 0.3, 10), \"피복\": (20, 0.1, 15), \"유류\": (300, 1.2, 7), \"탄약\": (60, 1.5, 6), \"수리부속\": (15, 0.5, 12)} # (기본 일소모, 템포 민감도, 목표 DOS)\n", + "N_UNIT, DAYS = 20, 160\n", + "UNITS = [f\"가상{u:02d}부대\" for u in range(1, N_UNIT + 1)]\n", + "\n", + "\n", + "def make_data(seed=1, n_unit=N_UNIT, days=DAYS):\n", + " \"\"\"일 단위 재고 원장: unit, item, day, tempo(실제 작전 템포), plan(사전 계획 템포), wx(악천후), consume, resupply, stock.\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " rows = []\n", + " for u in range(1, n_unit + 1):\n", + " s, ph = rng.uniform(0.7, 1.5), rng.uniform(0, 6.28)\n", + " surge = np.zeros(days)\n", + " for st in rng.choice(days - 10, 4, replace=False):\n", + " surge[st:st + int(rng.integers(5, 10))] = rng.uniform(0.4, 0.8)\n", + " tempo = 1 + 0.25 * np.sin(2 * np.pi * np.arange(days) / 30 + ph) + surge\n", + " plan = tempo + rng.normal(0, 0.08, days)\n", + " wx = (rng.random(days) < 0.15).astype(int)\n", + " for it, (b, sens, tg) in ITEMS.items():\n", + " stock, off = tg * b * s * rng.uniform(0.8, 1.2), int(rng.integers(0, 4))\n", + " for t in range(days):\n", + " c = b * s * (1 + sens * (tempo[t] - 1)) * (1 + 0.12 * wx[t]) * rng.normal(1, 0.08)\n", + " r = max(tg * b * s - stock, 0) * rng.uniform(0.9, 1.1) * (0.5 if rng.random() < 0.08 else 1) if (t % 4 == off and t > 0) else 0.0 # 4일 주기 정기 보급: 목표 재고까지 채움(가끔 수송 차질로 절반)\n", + " stock = max(stock + r - c, 0.0)\n", + " rows.append((f\"가상{u:02d}부대\", it, t, tempo[t], plan[t], wx[t], c, r, stock))\n", + " return pd.DataFrame(rows, columns=[\"unit\", \"item\", \"day\", \"tempo\", \"plan\", \"wx\", \"consume\", \"resupply\", \"stock\"])\n", + "\n", + "\n", + "def make_samples(d, lo=21, hi=125):\n", + " \"\"\"(부대, 품목, 기준일 t0)별 특성(피처)과 정답 H. 피처는 t0 시점까지 알 수 있는 값과 사전 계획·기상 예보만 사용한다.\"\"\"\n", + " out = []\n", + " for (u, it), g in d.groupby([\"unit\", \"item\"]):\n", + " c, stk, rs = g[\"consume\"].values, g[\"stock\"].values, g[\"resupply\"].values\n", + " pl, wx, tp = g[\"plan\"].values, g[\"wx\"].values, g[\"tempo\"].values\n", + " cs = np.cumsum(c)\n", + " tg = ITEMS[it][2] * ITEMS[it][0]\n", + " for t0 in range(lo, hi):\n", + " idx = np.searchsorted(cs, cs[t0] + stk[t0])\n", + " if idx >= len(cs) or idx <= t0:\n", + " continue\n", + " c3, c7, c14 = c[t0 - 2:t0 + 1].mean(), c[t0 - 6:t0 + 1].mean(), c[t0 - 13:t0 + 1].mean()\n", + " last = [k for k in range(t0, t0 - 15, -1) if rs[k] > 0]\n", + " out.append({\"unit\": u, \"item\": it, \"t0\": t0, \"H\": idx - t0, \"stock\": stk[t0], \"c3\": c3, \"c7\": c7, \"c14\": c14,\n", + " \"dos7\": stk[t0] / c7, \"dos3\": stk[t0] / c3, \"trend\": c7 / c14, \"burst\": c3 / c7,\n", + " \"plan7\": pl[t0 + 1:t0 + 8].mean(), \"plan_ratio\": pl[t0 + 1:t0 + 8].mean() / tp[t0], \"tempo0\": tp[t0],\n", + " \"wx_fc\": wx[t0 + 1:t0 + 8].mean(), \"since_rs\": (t0 - last[0]) if last else 15,\n", + " \"rs_avg\": rs[t0 - 13:t0 + 1].sum() / 14, \"fill\": stk[t0] / tg, \"item_code\": list(ITEMS).index(it)})\n", + " return pd.DataFrame(out)\n", + "\n", + "\n", + "def mape(a, b):\n", + " # [TODO : 학생 실습 6] 군단/사단 관할 부대 보급 수준(DOS) 실시간 현황 지도 및 경보 대시보드 구현 관련 핵심 로직을 구현하세요.\n", + " # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n", + " # <<< 여기에 코드를 작성하세요 >>>\n", + " pass # 작성 후 pass를 제거하세요.\n", + "d = make_data()\n", + "s = make_samples(d)\n", + "X = [c for c in s.columns if c not in (\"unit\", \"item\", \"t0\", \"H\")]\n", + "tr = s[s[\"t0\"] <= 90]\n", + "gbr = GradientBoostingRegressor(n_estimators=200, max_depth=3, learning_rate=0.08, random_state=0).fit(tr[X], tr[\"H\"])\n", + "mean_x = tr[X].mean()\n", + "LABEL = {\"stock\": \"현재 재고\", \"c3\": \"최근 3일 소모\", \"c7\": \"최근 7일 소모\", \"c14\": \"최근 14일 소모\", \"dos7\": \"7일 기준 DOS\", \"dos3\": \"3일 기준 DOS\", \"trend\": \"소모 추세\",\n", + " \"burst\": \"소모 급증도\", \"plan7\": \"다음 7일 계획 템포\", \"plan_ratio\": \"계획/현재 템포 비\", \"tempo0\": \"현재 작전 템포\", \"wx_fc\": \"악천후 예보\", \"since_rs\": \"보급 후 경과일\",\n", + " \"rs_avg\": \"최근 보급량\", \"fill\": \"목표 대비 재고\", \"item_code\": \"품목\"}\n", + "def level(h):\n", + " # [TODO : 학생 실습 6] 군단/사단 관할 부대 보급 수준(DOS) 실시간 현황 지도 및 경보 대시보드 구현 관련 핵심 로직을 구현하세요.\n", + " # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n", + " # <<< 여기에 코드를 작성하세요 >>>\n", + " pass # 작성 후 pass를 제거하세요.\n", + "ORDER = {\"정상\": 0, \"주의\": 1, \"경계\": 2, \"심각\": 3}\n", + "COL = {\"정상\": \"tab:green\", \"주의\": \"gold\", \"경계\": \"orange\", \"심각\": \"red\"}\n", + "\n", + "def explain(row):\n", + " \"\"\"XAI: 각 피처를 학습 평균값으로 바꿨을 때 예측이 변하는 정도(기여도)로 상위 3개 인자를 뽑는다. 음수면 그 인자 때문에 결핍 일수가 짧아졌다는 뜻.\"\"\"\n", + " x = row[X].astype(float)\n", + " alts = pd.DataFrame([x] * (len(X) + 1)).reset_index(drop=True)\n", + " for i, f in enumerate(X):\n", + " alts.loc[i + 1, f] = mean_x[f]\n", + " p = gbr.predict(alts)\n", + " contrib = {f: float(p[0] - p[i + 1]) for i, f in enumerate(X)}\n", + " return float(p[0]), sorted(contrib.items(), key=lambda kv: -abs(kv[1]))[:3]\n", + "\n", + "# 가상 지도: 4행 x 5열 격자(실제 위치 아님)\n", + "POS = {u: (i % 5, i // 5) for i, u in enumerate(UNITS)}\n", + "print(\"[지도 대시보드] 가상 격자 지도(위치는 실제와 무관) [교육용가상자료]\")\n", + "snaps = {}\n", + "for day in (100, 101, 102):\n", + " now = s[s[\"t0\"] == day].copy()\n", + " now[\"pred\"] = np.clip(gbr.predict(now[X]), 1, None)\n", + " now[\"lv\"] = now[\"pred\"].map(level)\n", + " worst = now.loc[now.groupby(\"unit\")[\"pred\"].idxmin()].set_index(\"unit\")\n", + " snaps[day] = (now, worst)\n", + " cnt = now[\"lv\"].value_counts().reindex([\"심각\", \"경계\", \"주의\", \"정상\"], fill_value=0).to_dict()\n", + " print(f\" {day}일차: 경보 분포 {cnt}, 가장 위험한 부대 {worst['pred'].idxmin()}({worst['item'][worst['pred'].idxmin()]}, 예상 결핍 {worst['pred'].min():.1f}일)\")\n", + "\n", + "now, worst = snaps[102]\n", + "alerts = now[now[\"lv\"] != \"정상\"].sort_values(\"pred\")\n", + "print(f\"\\n[경보 설명(XAI)] 102일차 경보 {len(alerts)}건 중 상위 6건 (기여도 상위 3개 인자)\")\n", + "ok = 0\n", + "rows = []\n", + "for _, r in alerts.iterrows():\n", + " base, top = explain(r)\n", + " ok += (len(top) == 3 and all(np.isfinite(v) for _, v in top))\n", + " rows.append((r[\"unit\"], r[\"item\"], r[\"lv\"], base, top))\n", + "for u, it, lv, base, top in rows[:6]:\n", + " txt = \", \".join(f\"{LABEL[f]}({v:+.1f}일)\" for f, v in top)\n", + " print(f\" - {u} {it} [{lv}] 예상 결핍 {base:.1f}일 <- {txt}\")\n", + "print(f\"설명 자동 산출률 {ok}/{len(alerts)} = {ok / max(len(alerts), 1):.0%}\")\n", + "fid = []\n", + "for u, it, lv, base, top in rows[:20]:\n", + " r = alerts[(alerts[\"unit\"] == u) & (alerts[\"item\"] == it)].iloc[0]\n", + " k = [f for f in X if f not in [t for t, _ in top]][0]\n", + " alt = pd.DataFrame([r[X].astype(float)] * 2).reset_index(drop=True)\n", + " alt.loc[0, top[0][0]] = mean_x[top[0][0]]; alt.loc[1, k] = mean_x[k]\n", + " p = gbr.predict(alt)\n", + " fid.append(abs(base - p[0]) >= abs(base - p[1]))\n", + "print(f\"설명 타당성 점검: 1순위 인자를 제거할 때의 예측 변화가 무관한 인자 제거보다 큰 비율 {np.mean(fid):.0%}\")\n", + "\n", + "fig, ax = plt.subplots(1, 2, figsize=(13, 4.8))\n", + "for u, (x, y) in POS.items():\n", + " lv = worst.loc[u, \"lv\"]\n", + " ax[0].scatter(x, -y, s=1500, color=COL[lv], edgecolor=\"black\"); ax[0].text(x, -y, u[2:4], ha=\"center\", va=\"center\", fontsize=9)\n", + "ax[0].set_title(\"Virtual map: worst alert level per unit (102nd day)\"); ax[0].axis(\"off\")\n", + "if rows:\n", + " u, it, lv, base, top = rows[0]\n", + " ax[1].barh([f for f, _ in top][::-1], [v for _, v in top][::-1], color=\"tab:blue\")\n", + " ax[1].set_title(\"Top-3 contributions (days) for the most urgent alert\"); ax[1].set_xlabel(\"change in predicted days to stockout\")\n", + "plt.tight_layout(); plt.show()\n", + "print(f\"\\n목표(주요 예측 건당 기여도 상위 3개 인자 자동 산출률 100%): {'달성' if ok == len(alerts) else '미달'}\")\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 06 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + "[지도 대시보드] 가상 격자 지도(위치는 실제와 무관) [교육용가상자료]\n", + " 100일차: 경보 분포 {'심각': 8, '경계': 11, '주의': 29, '정상': 47}, 가장 위험한 부대 가상15부대(탄약, 예상 결핍 1.0일)\n", + " 101일차: 경보 분포 {'심각': 8, '경계': 14, '주의': 24, '정상': 51}, 가장 위험한 부대 가상20부대(탄약, 예상 결핍 1.0일)\n", + " 102일차: 경보 분포 {'심각': 9, '경계': 12, '주의': 24, '정상': 54}, 가장 위험한 부대 가상17부대(탄약, 예상 결핍 1.0일)\n", + "\n", + "[경보 설명(XAI)] 102일차 경보 45건 중 상위 6건 (기여도 상위 3개 인자)\n", + " - 가상17부대 탄약 [심각] 예상 결핍 0.9일 <- 3일 기준 DOS(-5.1일), 목표 대비 재고(-0.3일), 7일 기준 DOS(-0.2일)\n", + " - 가상07부대 탄약 [심각] 예상 결핍 1.0일 <- 3일 기준 DOS(-5.2일), 7일 기준 DOS(-0.4일), 목표 대비 재고(-0.4일)\n", + " - 가상03부대 유류 [심각] 예상 결핍 1.0일 <- 3일 기준 DOS(-5.4일), 목표 대비 재고(-0.4일), 7일 기준 DOS(-0.3일)\n", + " - 가상18부대 탄약 [심각] 예상 결핍 1.1일 <- 3일 기준 DOS(-5.4일), 7일 기준 DOS(-0.3일), 목표 대비 재고(-0.3일)\n", + " - 가상08부대 유류 [심각] 예상 결핍 1.2일 <- 3일 기준 DOS(-5.1일), 7일 기준 DOS(-0.4일), 목표 대비 재고(-0.3일)\n", + " - 가상18부대 유류 [심각] 예상 결핍 1.3일 <- 3일 기준 DOS(-5.6일), 목표 대비 재고(-0.3일), 7일 기준 DOS(-0.3일)\n", + "설명 자동 산출률 45/45 = 100%\n", + "설명 타당성 점검: 1순위 인자를 제거할 때의 예측 변화가 무관한 인자 제거보다 큰 비율 100%\n", + "\n", + "목표(주요 예측 건당 기여도 상위 3개 인자 자동 산출률 100%): 달성\n", + "```\n", + "
\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 07. 결핍 부대 긴급 물자 전환 결정을 위한 군수처장 HITL 승인 워크플로우 설계\n", + "**소요시간**: 약 3.5시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "결핍 부대 긴급 물자 전환 결정을 위한 군수처장 HITL 승인 워크플로우를 설계한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **예측 기반 보급 정책의 효과를 시뮬레이션으로 비교하고, 결핍 부대에 대한 긴급 물자 전환은 군수처장 승인으로만 집행하는 HITL 워크플로우를 설계한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **고정 목표 재고 정책과 예측 기반 동적 목표 정책을 같은 50일 자료로 재현해 결핍량, 재고 회전, 비용을 비교 수행하고 검증하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `src/optimization/str01_simulator.py` 형태의 결과물을 완성한다.\n", + "\n", + "### 🔍 평가 및 검증 기준 (PASS 조건)\n", + "#### 미션 판정\n", + "\n", + "**평가 구분:** 필수\n", + "\n", + "**핵심 평가영역:** 현실적 군수 제약조건 반영도 및 시나리오별 대안 제시 적절성\n", + "\n", + "#### 미션 PASS 조건\n", + "\n", + "- 정량 기준: 최적화 적용 시 비용 15% 절감 또는 재고 회전율 20% 향상\n", + "\n", + "\n", + "- 진행 기준: 선형/정수 계획법(LP/MIP) 최적화 해 도출 및 시뮬레이션 완료\n", + "\n", + "\n", + "- 검증 방법: 과거 실적 데이터 대비 최적화 결과 비교 백테스팅\n", + "\n", + "\n", + "#### 채점자 판정 메모\n", + "\n", + "> 단순 코드 실행 여부뿐만 아니라 출력된 수치와 근거 자료가 군 실무 요구사항에 부합하는지 면밀히 확인한다.\n", + "\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "\"\"\"예측 기반 동적 목표 재고 정책을 고정 목표 정책과 시뮬레이션으로 비교해 비용·재고 회전율 효과를 구하고, 결핍 예상 부대에 대한 긴급 물자 전환 제안을 만들며 최종 집행은 군수처장 승인으로만 이루어지는 HITL 승인 워크플로우와 감사 이력·우회 차단을 구현한다.\"\"\"\n", + "import hashlib\n", + "import json\n", + "import numpy as np\n", + "import pandas as pd\n", + "from sklearn.ensemble import GradientBoostingRegressor\n", + "\n", + "# ---------------------------------------------------------------------------\n", + "# [교육용가상자료] 가상 부대 20개 x 핵심 보급품 5종 일일 재고·소모 시뮬레이터 (실제 부대·수량·기준일수 아님)\n", + "# 핵심성과지표(KPI) = 기준 보급일수(DOS) = 현재 재고 / 최근 평균 일일 소모량. 목표 DOS는 교육용 가정값이다.\n", + "# 결핍 위험 일수 H = 보급이 없다고 가정할 때 현재 재고가 바닥나기까지 남은 일수(미래 실제 소모량으로 계산한 정답).\n", + "# ---------------------------------------------------------------------------\n", + "ITEMS = {\"식량\": (100, 0.3, 10), \"피복\": (20, 0.1, 15), \"유류\": (300, 1.2, 7), \"탄약\": (60, 1.5, 6), \"수리부속\": (15, 0.5, 12)} # (기본 일소모, 템포 민감도, 목표 DOS)\n", + "N_UNIT, DAYS = 20, 160\n", + "UNITS = [f\"가상{u:02d}부대\" for u in range(1, N_UNIT + 1)]\n", + "\n", + "\n", + "def make_data(seed=1, n_unit=N_UNIT, days=DAYS):\n", + " \"\"\"일 단위 재고 원장: unit, item, day, tempo(실제 작전 템포), plan(사전 계획 템포), wx(악천후), consume, resupply, stock.\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " rows = []\n", + " for u in range(1, n_unit + 1):\n", + " s, ph = rng.uniform(0.7, 1.5), rng.uniform(0, 6.28)\n", + " surge = np.zeros(days)\n", + " for st in rng.choice(days - 10, 4, replace=False):\n", + " surge[st:st + int(rng.integers(5, 10))] = rng.uniform(0.4, 0.8)\n", + " tempo = 1 + 0.25 * np.sin(2 * np.pi * np.arange(days) / 30 + ph) + surge\n", + " plan = tempo + rng.normal(0, 0.08, days)\n", + " wx = (rng.random(days) < 0.15).astype(int)\n", + " for it, (b, sens, tg) in ITEMS.items():\n", + " stock, off = tg * b * s * rng.uniform(0.8, 1.2), int(rng.integers(0, 4))\n", + " for t in range(days):\n", + " c = b * s * (1 + sens * (tempo[t] - 1)) * (1 + 0.12 * wx[t]) * rng.normal(1, 0.08)\n", + " r = max(tg * b * s - stock, 0) * rng.uniform(0.9, 1.1) * (0.5 if rng.random() < 0.08 else 1) if (t % 4 == off and t > 0) else 0.0 # 4일 주기 정기 보급: 목표 재고까지 채움(가끔 수송 차질로 절반)\n", + " stock = max(stock + r - c, 0.0)\n", + " rows.append((f\"가상{u:02d}부대\", it, t, tempo[t], plan[t], wx[t], c, r, stock))\n", + " return pd.DataFrame(rows, columns=[\"unit\", \"item\", \"day\", \"tempo\", \"plan\", \"wx\", \"consume\", \"resupply\", \"stock\"])\n", + "\n", + "\n", + "def make_samples(d, lo=21, hi=125):\n", + " \"\"\"(부대, 품목, 기준일 t0)별 특성(피처)과 정답 H. 피처는 t0 시점까지 알 수 있는 값과 사전 계획·기상 예보만 사용한다.\"\"\"\n", + " out = []\n", + " for (u, it), g in d.groupby([\"unit\", \"item\"]):\n", + " c, stk, rs = g[\"consume\"].values, g[\"stock\"].values, g[\"resupply\"].values\n", + " pl, wx, tp = g[\"plan\"].values, g[\"wx\"].values, g[\"tempo\"].values\n", + " cs = np.cumsum(c)\n", + " tg = ITEMS[it][2] * ITEMS[it][0]\n", + " for t0 in range(lo, hi):\n", + " idx = np.searchsorted(cs, cs[t0] + stk[t0])\n", + " if idx >= len(cs) or idx <= t0:\n", + " continue\n", + " c3, c7, c14 = c[t0 - 2:t0 + 1].mean(), c[t0 - 6:t0 + 1].mean(), c[t0 - 13:t0 + 1].mean()\n", + " last = [k for k in range(t0, t0 - 15, -1) if rs[k] > 0]\n", + " out.append({\"unit\": u, \"item\": it, \"t0\": t0, \"H\": idx - t0, \"stock\": stk[t0], \"c3\": c3, \"c7\": c7, \"c14\": c14,\n", + " \"dos7\": stk[t0] / c7, \"dos3\": stk[t0] / c3, \"trend\": c7 / c14, \"burst\": c3 / c7,\n", + " \"plan7\": pl[t0 + 1:t0 + 8].mean(), \"plan_ratio\": pl[t0 + 1:t0 + 8].mean() / tp[t0], \"tempo0\": tp[t0],\n", + " \"wx_fc\": wx[t0 + 1:t0 + 8].mean(), \"since_rs\": (t0 - last[0]) if last else 15,\n", + " \"rs_avg\": rs[t0 - 13:t0 + 1].sum() / 14, \"fill\": stk[t0] / tg, \"item_code\": list(ITEMS).index(it)})\n", + " return pd.DataFrame(out)\n", + "\n", + "\n", + "def mape(a, b):\n", + " # [TODO : 학생 실습 7] 결핍 부대 긴급 물자 전환 결정을 위한 군수처장 HITL 승인 워크플로우 설계 관련 핵심 로직을 구현하세요.\n", + " # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n", + " # <<< 여기에 코드를 작성하세요 >>>\n", + " pass # 작성 후 pass를 제거하세요.\n", + "d = make_data()\n", + "s = make_samples(d)\n", + "X = [c for c in s.columns if c not in (\"unit\", \"item\", \"t0\", \"H\")]\n", + "tr = s[s[\"t0\"] <= 90]\n", + "gbr = GradientBoostingRegressor(n_estimators=120, max_depth=3, learning_rate=0.1, random_state=0).fit(tr[X], tr[\"H\"])\n", + "now = s[s[\"t0\"] == 100].copy()\n", + "now[\"pred\"] = np.clip(gbr.predict(now[X]), 1, None)\n", + "POS = {u: np.array([i % 5, i // 5]) * 20.0 for i, u in enumerate(UNITS)} # 가상 좌표(실제 위치 아님)\n", + "T0, T1 = 100, 150\n", + "\n", + "# 1) 시뮬레이터: 100~149일을 실제 소모·계획·기상 자료로 재현해 두 가지 보급 정책을 비교(4일 주기 정기 보급)\n", + "def simulate(policy, d_b=6.5):\n", + " \"\"\"정책 A: 고정 목표 재고(현행). 정책 B: 예측 기반 동적 목표(다음 4일 계획 템포·기상 예보를 반영한 소모 예상 x d_b일치).\"\"\"\n", + " # [TODO : 학생 실습 7] 결핍 부대 긴급 물자 전환 결정을 위한 군수처장 HITL 승인 워크플로우 설계 관련 핵심 로직을 구현하세요.\n", + " # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n", + " # <<< 여기에 코드를 작성하세요 >>>\n", + " pass # 작성 후 pass를 제거하세요.\n", + "A, B = simulate(\"A\"), simulate(\"B\")\n", + "print(f\"[보급 정책 시뮬레이션 {T0}~{T1 - 1}일] 부대 20 x 품목 5 [교육용가상자료]\")\n", + "print(f\"{'':<16}{'A 고정 목표 재고':>18}{'B 예측 기반 동적 목표':>22}\")\n", + "for k, nm, fmt in ((\"short\", \"결핍량 합계\", \"{:,.0f}\"), (\"short_rate\", \"결핍률\", \"{:.2%}\"), (\"stock\", \"재고일 합계\", \"{:,.0f}\"), (\"turn\", \"재고 회전 지표\", \"{:.2f}\"), (\"cost\", \"비용(가정 단가)\", \"{:,.0f}\")):\n", + " print(f\"{nm:<14}{fmt.format(A[k]):>20}{fmt.format(B[k]):>22}\")\n", + "sav, turn_gain = 1 - B[\"cost\"] / A[\"cost\"], B[\"turn\"] / A[\"turn\"] - 1\n", + "print(f\"재고 회전율 향상 {turn_gain:.1%}, 비용 절감 {sav:.1%}(보유비+결핍 손실, 가정 단가), 결핍량 감소 {1 - B['short'] / A['short']:.1%}\")\n", + "print(f\"목표(비용 15% 절감 또는 회전율 20% 향상): {'달성' if sav >= 0.15 or turn_gain >= 0.2 else '미달'}\")\n", + "print(\"제약: 4일 주기 정기 보급, 보급량은 목표 재고까지, 결핍은 이월하지 않음(가정). 시나리오 A와 B 중 B가 결핍을 줄이면서 재고를 줄이는지 함께 확인한다.\")\n", + "\n", + "# 2) 긴급 전환 제안 생성: 예측 결핍(경계 이하) 부대에 다음 보급일까지 버틸 물량을 여유 부대에서 옮기거나 보급소 예비로 충당\n", + "now[\"nxt\"] = (3 - now[\"since_rs\"]).clip(lower=0) + 1\n", + "now[\"keep\"] = 1.3 * now[\"c7\"] * (now[\"nxt\"] + 1)\n", + "donors = now[(now[\"pred\"] > 6) & (now[\"stock\"] > now[\"keep\"])].copy(); donors[\"avail\"] = donors[\"stock\"] - donors[\"keep\"]\n", + "plan = []\n", + "for r in now[now[\"pred\"] <= 4.5].sort_values(\"pred\").itertuples():\n", + " q = r.keep - r.stock\n", + " if q <= 0:\n", + " continue\n", + " cand = donors[(donors[\"item\"] == r.item) & (donors[\"avail\"] >= q)]\n", + " if len(cand):\n", + " j = cand.assign(dist=[np.linalg.norm(POS[u] - POS[r.unit]) for u in cand[\"unit\"]]).sort_values(\"dist\").iloc[0]\n", + " donors.loc[(donors[\"unit\"] == j[\"unit\"]) & (donors[\"item\"] == j[\"item\"]), \"avail\"] -= q\n", + " plan.append((j[\"unit\"], r.unit, r.item, float(q)))\n", + " else:\n", + " plan.append((\"보급소 예비\", r.unit, r.item, float(q)))\n", + "n_lat = sum(m[0] != \"보급소 예비\" for m in plan)\n", + "print(f\"\\n[긴급 전환 제안 - {T0}일] 결핍 예상(경계 이하) {int((now['pred'] <= 4.5).sum())}건 중 제안 {len(plan)}건(부대 간 전환 {n_lat}건, 보급소 예비 {len(plan) - n_lat}건)\")\n", + "for m in plan[:4]:\n", + " print(f\" {m[0]} -> {m[1]} {m[2]} {m[3]:.0f}\")\n", + "\n", + "# 2) HITL: 군수처장 승인 워크플로우(자동 집행 금지)\n", + "USERS = {\"bot\": \"시스템\", \"kim\": \"군수담당\", \"lee\": \"군수담당\", \"jang\": \"군수처장\", \"han\": \"열람자\"}\n", + "AUDIT, PROP = [], {}\n", + "def h(prev, rec):\n", + " return hashlib.sha256((prev + json.dumps(rec, ensure_ascii=False, sort_keys=True)).encode()).hexdigest()[:16]\n", + "def log(pid, user, act, res, note=\"\"):\n", + " rec = {\"seq\": len(AUDIT) + 1, \"pid\": pid, \"user\": user, \"role\": USERS.get(user, \"미등록\"), \"act\": act, \"res\": res, \"note\": note}\n", + " rec[\"hash\"] = h(AUDIT[-1][\"hash\"] if AUDIT else \"G\", {k: v for k, v in rec.items() if k != \"hash\"}); AUDIT.append(rec)\n", + "def blocked(pid, user, act, why):\n", + " log(pid, user, act, \"차단\", why); return False\n", + "def propose(pid, user, move):\n", + " if USERS.get(user) not in (\"시스템\", \"군수담당\"):\n", + " return blocked(pid, user, \"제안\", \"제안 권한 없음\")\n", + " PROP[pid] = {\"move\": move, \"by\": user, \"status\": \"대기\"}; log(pid, user, \"제안\", \"접수\", f\"{move[0]}->{move[1]} {move[2]} {move[3]:.0f}\"); return True\n", + "def decide(pid, user, act, note):\n", + " p = PROP.get(pid)\n", + " if p is None:\n", + " return blocked(pid, user, act, \"제안 없음\")\n", + " if USERS.get(user) != \"군수처장\":\n", + " return blocked(pid, user, act, f\"권한 없음: 역할 '{USERS.get(user, '미등록')}'은(는) 결정 불가(자동 집행 금지)\")\n", + " if p[\"status\"] != \"대기\":\n", + " return blocked(pid, user, act, f\"이미 {p['status']}\")\n", + " if len(note.strip()) < 5:\n", + " return blocked(pid, user, act, \"결정 근거 5자 이상 필요\")\n", + " p[\"status\"] = \"승인\" if act == \"승인\" else \"반려\"; log(pid, user, act, \"완료\", note); return True\n", + "def execute(pid, user):\n", + " p = PROP.get(pid)\n", + " if p is None or p[\"status\"] != \"승인\":\n", + " return blocked(pid, user, \"집행\", \"승인되지 않은 건 집행 불가\")\n", + " p[\"status\"] = \"집행\"; log(pid, user, \"집행\", \"완료\", \"승인 건 집행(모의)\"); return True\n", + "\n", + "big = plan[:8]\n", + "for i, mv in enumerate(big):\n", + " propose(f\"P{i + 1}\", \"bot\" if i % 2 else \"kim\", mv)\n", + "if len(plan) > 8:\n", + " propose(\"P9\", \"kim\", plan[8]) # 결정 전 대기 건(우회 시험용)\n", + "for i, mv in enumerate(big):\n", + " pid = f\"P{i + 1}\"\n", + " if i == 5:\n", + " decide(pid, \"jang\", \"반려\", \"여유 부대의 다음 훈련 일정과 충돌 우려\")\n", + " else:\n", + " decide(pid, \"jang\", \"승인\", \"예측 근거와 여유 부대 5일치 유지 확인\")\n", + " execute(pid, \"kim\")\n", + "att = [(\"시스템이 자동 승인\", lambda: decide(\"P1\", \"bot\", \"승인\", \"자동 승인 처리\")), (\"군수담당이 직접 승인\", lambda: decide(\"P2\", \"lee\", \"승인\", \"담당이 직접 승인함\")),\n", + " (\"열람자가 승인\", lambda: decide(\"P3\", \"han\", \"승인\", \"열람자가 승인 시도함\")), (\"미등록 사용자 승인\", lambda: decide(\"P4\", \"x\", \"승인\", \"외부 계정 승인 시도\")),\n", + " (\"승인 근거 없이 결정\", lambda: decide(\"P9\", \"jang\", \"승인\", \"\")), (\"반려 건 집행\", lambda: execute(\"P6\", \"kim\")),\n", + " (\"미승인 건 집행(없는 제안)\", lambda: execute(\"P99\", \"kim\")), (\"이미 집행된 건 재승인\", lambda: decide(\"P1\", \"jang\", \"승인\", \"중복 승인 시도 확인\"))]\n", + "print(\"\\n[HITL 승인 결과]\", {k: sum(p[\"status\"] == k for p in PROP.values()) for k in (\"집행\", \"반려\", \"대기\")})\n", + "nb = 0\n", + "for name, fn in att:\n", + " n = len(AUDIT); fn(); r = AUDIT[-1]; ok = len(AUDIT) == n + 1 and r[\"res\"] == \"차단\"; nb += ok\n", + " print(f\" - {name}: {r['res']} ({r['note']})\")\n", + "prev, okc = \"G\", 0\n", + "for r in AUDIT:\n", + " okc += r[\"hash\"] == h(prev, {k: v for k, v in r.items() if k != \"hash\"}); prev = r[\"hash\"]\n", + "print(f\"우회 시도 {len(att)}건 중 차단 {nb}건, 감사 이력 {len(AUDIT)}건 해시 검증 {okc}/{len(AUDIT)}\")\n", + "print(\"군수처장 승인 없이 집행된 건 0건. 실제 물자 전환 결정은 지휘계통과 군수 규정에 따르며 본 코드는 학습용 모의이다.\")\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 07 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + "[보급 정책 시뮬레이션 100~149일] 부대 20 x 품목 5 [교육용가상자료]\n", + " A 고정 목표 재고 B 예측 기반 동적 목표\n", + "결핍량 합계 5,546 894\n", + "결핍률 0.89% 0.14%\n", + "재고일 합계 3,317,341 2,505,060\n", + "재고 회전 지표 18.54 24.74\n", + "비용(가정 단가) 110,716 57,250\n", + "재고 회전율 향상 33.4%, 비용 절감 48.3%(보유비+결핍 손실, 가정 단가), 결핍량 감소 83.9%\n", + "목표(비용 15% 절감 또는 회전율 20% 향상): 달성\n", + "제약: 4일 주기 정기 보급, 보급량은 목표 재고까지, 결핍은 이월하지 않음(가정). 시나리오 A와 B 중 B가 결핍을 줄이면서 재고를 줄이는지 함께 확인한다.\n", + "\n", + "[긴급 전환 제안 - 100일] 결핍 예상(경계 이하) 19건 중 제안 17건(부대 간 전환 3건, 보급소 예비 14건)\n", + " 보급소 예비 -> 가상01부대 탄약 195\n", + " 보급소 예비 -> 가상19부대 탄약 211\n", + " 보급소 예비 -> 가상15부대 탄약 238\n", + " 보급소 예비 -> 가상03부대 탄약 582\n", + "\n", + "[HITL 승인 결과] {'집행': 7, '반려': 1, '대기': 1}\n", + " - 시스템이 자동 승인: 차단 (권한 없음: 역할 '시스템'은(는) 결정 불가(자동 집행 금지))\n", + " - 군수담당이 직접 승인: 차단 (권한 없음: 역할 '군수담당'은(는) 결정 불가(자동 집행 금지))\n", + " - 열람자가 승인: 차단 (권한 없음: 역할 '열람자'은(는) 결정 불가(자동 집행 금지))\n", + " - 미등록 사용자 승인: 차단 (권한 없음: 역할 '미등록'은(는) 결정 불가(자동 집행 금지))\n", + " - 승인 근거 없이 결정: 차단 (결정 근거 5자 이상 필요)\n", + " - 반려 건 집행: 차단 (승인되지 않은 건 집행 불가)\n", + " - 미승인 건 집행(없는 제안): 차단 (승인되지 않은 건 집행 불가)\n", + " - 이미 집행된 건 재승인: 차단 (이미 집행)\n", + "우회 시도 8건 중 차단 8건, 감사 이력 33건 해시 검증 33/33\n", + "군수처장 승인 없이 집행된 건 0건. 실제 물자 전환 결정은 지휘계통과 군수 규정에 따르며 본 코드는 학습용 모의이다.\n", + "```\n", + "
\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 08. 전면전 발발 모의 전 부대 보급품 동시 급감 스트레스 시뮬레이션\n", + "**소요시간**: 약 3.0시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "전면전 모의 전 부대 보급품 동시 급감 스트레스 시뮬레이션을 수행한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **전 부대의 소모가 동시에 늘고 보급이 줄어드는 가상 충격 시나리오로 경보 엔진의 스트레스 시뮬레이션을 수행한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **소모를 1.5배, 2배, 3배로 키우고 보급량을 절반으로 줄이는 충격을 12일간 가정해 재고를 다시 산출하고 모델에 적용하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `src/dashboard/str01_app.py (Streamlit/Dash 등)` 형태의 결과물을 완성한다.\n", + "\n", + "### 🔍 평가 및 검증 기준 (PASS 조건)\n", + "#### 미션 판정\n", + "\n", + "**평가 구분:** 필수\n", + "\n", + "**핵심 평가영역:** 의사결정 지원에 최적화된 시각적 위계 및 사용자 경험(UX)\n", + "\n", + "#### 미션 PASS 조건\n", + "\n", + "- 정량 기준: 대시보드 로딩 시간 2초 이내, 경보 발령 정확도 95% 이상\n", + "\n", + "\n", + "- 진행 기준: 웹 기반 분석 대시보드 구동 및 실시간 필터링 기능 검증\n", + "\n", + "\n", + "- 검증 방법: 실제 군수 데이터 연동 화면 테스트 및 UI 동작 검증\n", + "\n", + "\n", + "#### 채점자 판정 메모\n", + "\n", + "> 단순 코드 실행 여부뿐만 아니라 출력된 수치와 근거 자료가 군 실무 요구사항에 부합하는지 면밀히 확인한다.\n", + "\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "\"\"\"전 부대 소모가 동시에 크게 늘고 보급이 차질을 빚는 가상 충격 시나리오(소모 1.5·2·3배, 보급 50% 감소)를 시뮬레이션하고, 경보 엔진의 등급 정확도와 대시보드 갱신(로딩) 시간을 측정하며 예측 모델에 DOS 안전장치를 더한 보강 효과를 확인한다.\"\"\"\n", + "import time\n", + "import numpy as np\n", + "import pandas as pd\n", + "from sklearn.ensemble import GradientBoostingRegressor\n", + "\n", + "# ---------------------------------------------------------------------------\n", + "# [교육용가상자료] 가상 부대 20개 x 핵심 보급품 5종 일일 재고·소모 시뮬레이터 (실제 부대·수량·기준일수 아님)\n", + "# 핵심성과지표(KPI) = 기준 보급일수(DOS) = 현재 재고 / 최근 평균 일일 소모량. 목표 DOS는 교육용 가정값이다.\n", + "# 결핍 위험 일수 H = 보급이 없다고 가정할 때 현재 재고가 바닥나기까지 남은 일수(미래 실제 소모량으로 계산한 정답).\n", + "# ---------------------------------------------------------------------------\n", + "ITEMS = {\"식량\": (100, 0.3, 10), \"피복\": (20, 0.1, 15), \"유류\": (300, 1.2, 7), \"탄약\": (60, 1.5, 6), \"수리부속\": (15, 0.5, 12)} # (기본 일소모, 템포 민감도, 목표 DOS)\n", + "N_UNIT, DAYS = 20, 160\n", + "UNITS = [f\"가상{u:02d}부대\" for u in range(1, N_UNIT + 1)]\n", + "\n", + "\n", + "def make_data(seed=1, n_unit=N_UNIT, days=DAYS):\n", + " \"\"\"일 단위 재고 원장: unit, item, day, tempo(실제 작전 템포), plan(사전 계획 템포), wx(악천후), consume, resupply, stock.\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " rows = []\n", + " for u in range(1, n_unit + 1):\n", + " s, ph = rng.uniform(0.7, 1.5), rng.uniform(0, 6.28)\n", + " surge = np.zeros(days)\n", + " for st in rng.choice(days - 10, 4, replace=False):\n", + " surge[st:st + int(rng.integers(5, 10))] = rng.uniform(0.4, 0.8)\n", + " tempo = 1 + 0.25 * np.sin(2 * np.pi * np.arange(days) / 30 + ph) + surge\n", + " plan = tempo + rng.normal(0, 0.08, days)\n", + " wx = (rng.random(days) < 0.15).astype(int)\n", + " for it, (b, sens, tg) in ITEMS.items():\n", + " stock, off = tg * b * s * rng.uniform(0.8, 1.2), int(rng.integers(0, 4))\n", + " for t in range(days):\n", + " c = b * s * (1 + sens * (tempo[t] - 1)) * (1 + 0.12 * wx[t]) * rng.normal(1, 0.08)\n", + " r = max(tg * b * s - stock, 0) * rng.uniform(0.9, 1.1) * (0.5 if rng.random() < 0.08 else 1) if (t % 4 == off and t > 0) else 0.0 # 4일 주기 정기 보급: 목표 재고까지 채움(가끔 수송 차질로 절반)\n", + " stock = max(stock + r - c, 0.0)\n", + " rows.append((f\"가상{u:02d}부대\", it, t, tempo[t], plan[t], wx[t], c, r, stock))\n", + " return pd.DataFrame(rows, columns=[\"unit\", \"item\", \"day\", \"tempo\", \"plan\", \"wx\", \"consume\", \"resupply\", \"stock\"])\n", + "\n", + "\n", + "def make_samples(d, lo=21, hi=125):\n", + " \"\"\"(부대, 품목, 기준일 t0)별 특성(피처)과 정답 H. 피처는 t0 시점까지 알 수 있는 값과 사전 계획·기상 예보만 사용한다.\"\"\"\n", + " out = []\n", + " for (u, it), g in d.groupby([\"unit\", \"item\"]):\n", + " c, stk, rs = g[\"consume\"].values, g[\"stock\"].values, g[\"resupply\"].values\n", + " pl, wx, tp = g[\"plan\"].values, g[\"wx\"].values, g[\"tempo\"].values\n", + " cs = np.cumsum(c)\n", + " tg = ITEMS[it][2] * ITEMS[it][0]\n", + " for t0 in range(lo, hi):\n", + " idx = np.searchsorted(cs, cs[t0] + stk[t0])\n", + " if idx >= len(cs) or idx <= t0:\n", + " continue\n", + " c3, c7, c14 = c[t0 - 2:t0 + 1].mean(), c[t0 - 6:t0 + 1].mean(), c[t0 - 13:t0 + 1].mean()\n", + " last = [k for k in range(t0, t0 - 15, -1) if rs[k] > 0]\n", + " out.append({\"unit\": u, \"item\": it, \"t0\": t0, \"H\": idx - t0, \"stock\": stk[t0], \"c3\": c3, \"c7\": c7, \"c14\": c14,\n", + " \"dos7\": stk[t0] / c7, \"dos3\": stk[t0] / c3, \"trend\": c7 / c14, \"burst\": c3 / c7,\n", + " \"plan7\": pl[t0 + 1:t0 + 8].mean(), \"plan_ratio\": pl[t0 + 1:t0 + 8].mean() / tp[t0], \"tempo0\": tp[t0],\n", + " \"wx_fc\": wx[t0 + 1:t0 + 8].mean(), \"since_rs\": (t0 - last[0]) if last else 15,\n", + " \"rs_avg\": rs[t0 - 13:t0 + 1].sum() / 14, \"fill\": stk[t0] / tg, \"item_code\": list(ITEMS).index(it)})\n", + " return pd.DataFrame(out)\n", + "\n", + "\n", + "def mape(a, b):\n", + " # [TODO : 학생 실습 8] 전면전 발발 모의 전 부대 보급품 동시 급감 스트레스 시뮬레이션 관련 핵심 로직을 구현하세요.\n", + " # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n", + " # <<< 여기에 코드를 작성하세요 >>>\n", + " pass # 작성 후 pass를 제거하세요.\n", + "d0 = make_data()\n", + "s0 = make_samples(d0)\n", + "X = [c for c in s0.columns if c not in (\"unit\", \"item\", \"t0\", \"H\")]\n", + "gbr = GradientBoostingRegressor(n_estimators=150, max_depth=3, learning_rate=0.08, random_state=0).fit(s0[s0[\"t0\"] <= 90][X], s0[s0[\"t0\"] <= 90][\"H\"])\n", + "T_SHOCK = 100\n", + "def level(h):\n", + " # [TODO : 학생 실습 8] 전면전 발발 모의 전 부대 보급품 동시 급감 스트레스 시뮬레이션 관련 핵심 로직을 구현하세요.\n", + " # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n", + " # <<< 여기에 코드를 작성하세요 >>>\n", + " pass # 작성 후 pass를 제거하세요.\n", + "NAME = np.array([\"정상\", \"주의\", \"경계\", \"심각\"])\n", + "\n", + "def shock(d, mult, rs_cut=0.5, span=12):\n", + " \"\"\"T_SHOCK일부터 span일 동안 모든 부대의 소모를 mult배, 보급량을 rs_cut배로 바꾸고 재고를 다시 계산한다(가상 충격 시나리오).\"\"\"\n", + " d = d.copy()\n", + " m = (d[\"day\"] >= T_SHOCK) & (d[\"day\"] < T_SHOCK + span)\n", + " d.loc[m, \"consume\"] *= mult; d.loc[m, \"resupply\"] *= rs_cut\n", + " for (u, it), g in d.groupby([\"unit\", \"item\"]):\n", + " idx, st = g.index, g[\"stock\"].values[T_SHOCK - 1]\n", + " new = []\n", + " for t in range(T_SHOCK, DAYS):\n", + " st = max(st + d.loc[idx[t], \"resupply\"] - d.loc[idx[t], \"consume\"], 0.0); new.append(st)\n", + " d.loc[idx[T_SHOCK:], \"stock\"] = new\n", + " return d\n", + "\n", + "def predict(s, guard):\n", + " p = np.clip(gbr.predict(s[X]), 1, None)\n", + " if guard: # 안전장치: 전 부대 소모가 동시에 급증(충격 모드)한 날에는 최근 3일 소모 기준 DOS가 더 짧으면 그 값을 채택\n", + " share = s.groupby(\"t0\")[\"burst\"].transform(lambda b: (b > 1.15).mean())\n", + " p = np.where(share > 0.4, np.minimum(p, np.clip(s[\"dos3\"].values * 1.05, 1, None)), p)\n", + " return p\n", + "\n", + "def evaluate(d, guard):\n", + " s = make_samples(d, lo=T_SHOCK + 1, hi=T_SHOCK + 14)\n", + " lt, lp = level(s[\"H\"].values), level(predict(s, guard))\n", + " acc = float((lt == lp).mean()) # 4등급 일치율\n", + " alert = float(((lt >= 1) == (lp >= 1)).mean()) # 경보 발령(주의 이상) 여부 일치율\n", + " miss = float(((lt >= 2) & (lp < 2)).sum() / max((lt >= 2).sum(), 1)) # 경계 이상 미탐률\n", + " return acc, alert, miss, len(s), lt\n", + "\n", + "print(\"[충격 시나리오 시험] 전 부대·전 품목 동시 소모 증가 + 보급 감소(12일간, 가상 모델) [교육용가상자료]\")\n", + "rows = []\n", + "for mult in (1.0, 1.5, 2.0, 3.0):\n", + " d = d0 if mult == 1.0 else shock(d0, mult)\n", + " a0, al0, m0, n, lt = evaluate(d, False); a1, al1, m1, _, _ = evaluate(d, True)\n", + " rows.append({\"소모 배율\": f\"{mult}배\", \"표본\": n, \"경계 이상\": f\"{(lt >= 2).mean():.0%}\", \"모델만 경보정확도\": f\"{al0:.1%}\", \"모델만 미탐\": f\"{m0:.1%}\",\n", + " \"보강 경보정확도\": f\"{al1:.1%}\", \"보강 미탐\": f\"{m1:.1%}\", \"보강 4등급\": f\"{a1:.1%}\"})\n", + "res = pd.DataFrame(rows)\n", + "print(res.to_string(index=False))\n", + "acc_guard = float(res[\"보강 경보정확도\"].str.rstrip(\"%\").astype(float).min()) / 100\n", + "\n", + "# 대시보드 갱신(로딩) 시간: 한 시점의 전체 상황판(100건 예측 + 등급 집계 + 문자열 생성)을 50회 반복 측정\n", + "d3 = shock(d0, 2.0)\n", + "times = []\n", + "for _ in range(50):\n", + " t0 = time.perf_counter()\n", + " s = make_samples(d3, lo=T_SHOCK + 5, hi=T_SHOCK + 6)\n", + " p = predict(s, True)\n", + " lv = pd.Series(NAME[level(p)]).value_counts().reindex(NAME, fill_value=0)\n", + " board = \" / \".join(f\"{k} {v}\" for k, v in lv.items())\n", + " times.append(time.perf_counter() - t0)\n", + "times = np.array(times)\n", + "print(f\"\\n상황판 1회 갱신: 평균 {times.mean() * 1000:.0f}ms, 95백분위 {np.percentile(times, 95) * 1000:.0f}ms, 최대 {times.max() * 1000:.0f}ms (목표 2초 이내, 환경에 따라 다름)\")\n", + "print(\"예시 상황판(소모 2배 충격 105일차):\", board)\n", + "d_n = evaluate(d0, True)[1]\n", + "print(f\"\\n충격 규모를 키울수록 경보 대상이 늘고 모델만 쓰면 미탐이 늘어나므로, 단기 소모 기반 안전장치로 보강했다(정상 시 경보 정확도 {d_n:.1%}).\")\n", + "ok = acc_guard >= 0.95 and times.max() < 2.0\n", + "print(f\"목표(대시보드 로딩 2초 이내, 경보 발령 정확도 95% 이상): 로딩 {'달성' if times.max() < 2 else '미달'}, 최저 경보 발령 정확도 {acc_guard:.1%} {'달성' if acc_guard >= 0.95 else '미달'}\")\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 08 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + "[충격 시나리오 시험] 전 부대·전 품목 동시 소모 증가 + 보급 감소(12일간, 가상 모델) [교육용가상자료]\n", + "소모 배율 표본 경계 이상 모델만 경보정확도 모델만 미탐 보강 경보정확도 보강 미탐 보강 4등급\n", + " 1.0배 1279 19% 96.0% 6.4% 96.0% 6.4% 92.4%\n", + " 1.5배 726 81% 92.8% 18.3% 96.0% 13.9% 61.8%\n", + " 2.0배 488 94% 97.1% 17.5% 100.0% 0.7% 80.9%\n", + " 3.0배 237 96% 100.0% 10.1% 100.0% 0.0% 86.1%\n", + "\n", + "상황판 1회 갱신: 평균 28ms, 95백분위 33ms, 최대 39ms (목표 2초 이내, 환경에 따라 다름)\n", + "예시 상황판(소모 2배 충격 105일차): 정상 0 / 주의 0 / 경계 26 / 심각 15\n", + "\n", + "충격 규모를 키울수록 경보 대상이 늘고 모델만 쓰면 미탐이 늘어나므로, 단기 소모 기반 안전장치로 보강했다(정상 시 경보 정확도 96.0%).\n", + "목표(대시보드 로딩 2초 이내, 경보 발령 정확도 95% 이상): 로딩 달성, 최저 경보 발령 정확도 96.0% 달성\n", + "```\n", + "
\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 09. AAR 기반 결핍 경보 지연 원인 분석 및 경보 임계 파라미터 재보정\n", + "**소요시간**: 약 3.0시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "AAR 기반 결핍 경보 지연 원인을 분석하고 임계 파라미터를 재보정한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **AAR 사례의 결핍 경보 지연 원인을 분석하고 경보 임계 파라미터를 재보정한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **실제 결핍(재고 0 진입) 사건 48건에 대해 기존 단순 DOS 경보의 정시·지연·미탐을 분류하고 원인을 템포 급변, 추정 오차, 임계 과소로 나눈다 과정을 진행하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `reports/str01_roi_analysis.md` 형태의 결과물을 완성한다.\n", + "\n", + "### 🔍 평가 및 검증 기준 (PASS 조건)\n", + "#### 미션 판정\n", + "\n", + "**평가 구분:** 필수\n", + "\n", + "**핵심 평가영역:** 추정 산식의 객관성 및 군 재정/군수 기획 부서 설득력\n", + "\n", + "#### 미션 PASS 조건\n", + "\n", + "- 정량 기준: 비용 절감액, 시간 단축률 등 3대 정량 효과 지표 산출\n", + "\n", + "\n", + "- 진행 기준: ROI 분석 보고서 완성 및 주요 지표 민감도 분석 수록\n", + "\n", + "\n", + "- 검증 방법: 회계/기획 기준 모델과의 비교 검증\n", + "\n", + "\n", + "#### 채점자 판정 메모\n", + "\n", + "> 단순 코드 실행 여부뿐만 아니라 출력된 수치와 근거 자료가 군 실무 요구사항에 부합하는지 면밀히 확인한다.\n", + "\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "\"\"\"AAR 사례에서 결핍 경보가 늦게 나간 원인(임계 과소, 템포 급변 미반영, 예측 오차)을 분류하고, 경보 임계 파라미터를 보정 기간에서 재보정해 검증 기간에서 선행 시간과 오탐률을 비교하며, 비용 절감액·시간 단축률·결핍 사건 감소율의 3대 정량 효과 지표를 가정과 산식으로 산출한다.\"\"\"\n", + "import numpy as np\n", + "import pandas as pd\n", + "from sklearn.ensemble import GradientBoostingRegressor\n", + "\n", + "# ---------------------------------------------------------------------------\n", + "# [교육용가상자료] 가상 부대 20개 x 핵심 보급품 5종 일일 재고·소모 시뮬레이터 (실제 부대·수량·기준일수 아님)\n", + "# 핵심성과지표(KPI) = 기준 보급일수(DOS) = 현재 재고 / 최근 평균 일일 소모량. 목표 DOS는 교육용 가정값이다.\n", + "# 결핍 위험 일수 H = 보급이 없다고 가정할 때 현재 재고가 바닥나기까지 남은 일수(미래 실제 소모량으로 계산한 정답).\n", + "# ---------------------------------------------------------------------------\n", + "ITEMS = {\"식량\": (100, 0.3, 10), \"피복\": (20, 0.1, 15), \"유류\": (300, 1.2, 7), \"탄약\": (60, 1.5, 6), \"수리부속\": (15, 0.5, 12)} # (기본 일소모, 템포 민감도, 목표 DOS)\n", + "N_UNIT, DAYS = 20, 160\n", + "UNITS = [f\"가상{u:02d}부대\" for u in range(1, N_UNIT + 1)]\n", + "\n", + "\n", + "def make_data(seed=1, n_unit=N_UNIT, days=DAYS):\n", + " \"\"\"일 단위 재고 원장: unit, item, day, tempo(실제 작전 템포), plan(사전 계획 템포), wx(악천후), consume, resupply, stock.\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " rows = []\n", + " for u in range(1, n_unit + 1):\n", + " s, ph = rng.uniform(0.7, 1.5), rng.uniform(0, 6.28)\n", + " surge = np.zeros(days)\n", + " for st in rng.choice(days - 10, 4, replace=False):\n", + " surge[st:st + int(rng.integers(5, 10))] = rng.uniform(0.4, 0.8)\n", + " tempo = 1 + 0.25 * np.sin(2 * np.pi * np.arange(days) / 30 + ph) + surge\n", + " plan = tempo + rng.normal(0, 0.08, days)\n", + " wx = (rng.random(days) < 0.15).astype(int)\n", + " for it, (b, sens, tg) in ITEMS.items():\n", + " stock, off = tg * b * s * rng.uniform(0.8, 1.2), int(rng.integers(0, 4))\n", + " for t in range(days):\n", + " c = b * s * (1 + sens * (tempo[t] - 1)) * (1 + 0.12 * wx[t]) * rng.normal(1, 0.08)\n", + " r = max(tg * b * s - stock, 0) * rng.uniform(0.9, 1.1) * (0.5 if rng.random() < 0.08 else 1) if (t % 4 == off and t > 0) else 0.0 # 4일 주기 정기 보급: 목표 재고까지 채움(가끔 수송 차질로 절반)\n", + " stock = max(stock + r - c, 0.0)\n", + " rows.append((f\"가상{u:02d}부대\", it, t, tempo[t], plan[t], wx[t], c, r, stock))\n", + " return pd.DataFrame(rows, columns=[\"unit\", \"item\", \"day\", \"tempo\", \"plan\", \"wx\", \"consume\", \"resupply\", \"stock\"])\n", + "\n", + "\n", + "def make_samples(d, lo=21, hi=125):\n", + " \"\"\"(부대, 품목, 기준일 t0)별 특성(피처)과 정답 H. 피처는 t0 시점까지 알 수 있는 값과 사전 계획·기상 예보만 사용한다.\"\"\"\n", + " out = []\n", + " for (u, it), g in d.groupby([\"unit\", \"item\"]):\n", + " c, stk, rs = g[\"consume\"].values, g[\"stock\"].values, g[\"resupply\"].values\n", + " pl, wx, tp = g[\"plan\"].values, g[\"wx\"].values, g[\"tempo\"].values\n", + " cs = np.cumsum(c)\n", + " tg = ITEMS[it][2] * ITEMS[it][0]\n", + " for t0 in range(lo, hi):\n", + " idx = np.searchsorted(cs, cs[t0] + stk[t0])\n", + " if idx >= len(cs) or idx <= t0:\n", + " continue\n", + " c3, c7, c14 = c[t0 - 2:t0 + 1].mean(), c[t0 - 6:t0 + 1].mean(), c[t0 - 13:t0 + 1].mean()\n", + " last = [k for k in range(t0, t0 - 15, -1) if rs[k] > 0]\n", + " out.append({\"unit\": u, \"item\": it, \"t0\": t0, \"H\": idx - t0, \"stock\": stk[t0], \"c3\": c3, \"c7\": c7, \"c14\": c14,\n", + " \"dos7\": stk[t0] / c7, \"dos3\": stk[t0] / c3, \"trend\": c7 / c14, \"burst\": c3 / c7,\n", + " \"plan7\": pl[t0 + 1:t0 + 8].mean(), \"plan_ratio\": pl[t0 + 1:t0 + 8].mean() / tp[t0], \"tempo0\": tp[t0],\n", + " \"wx_fc\": wx[t0 + 1:t0 + 8].mean(), \"since_rs\": (t0 - last[0]) if last else 15,\n", + " \"rs_avg\": rs[t0 - 13:t0 + 1].sum() / 14, \"fill\": stk[t0] / tg, \"item_code\": list(ITEMS).index(it)})\n", + " return pd.DataFrame(out)\n", + "\n", + "\n", + "def mape(a, b):\n", + " # [TODO : 학생 실습 9] AAR 기반 결핍 경보 지연 원인 분석 및 경보 임계 파라미터 재보정 관련 핵심 로직을 구현하세요.\n", + " # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n", + " # <<< 여기에 코드를 작성하세요 >>>\n", + " pass # 작성 후 pass를 제거하세요.\n", + "d = make_data()\n", + "s = make_samples(d, lo=21, hi=126)\n", + "X = [c for c in s.columns if c not in (\"unit\", \"item\", \"t0\", \"H\")]\n", + "tr = s[s[\"t0\"] <= 90]\n", + "gbr = GradientBoostingRegressor(n_estimators=150, max_depth=3, learning_rate=0.08, random_state=0).fit(tr[X], tr[\"H\"])\n", + "s = s[s[\"t0\"] > 90].copy()\n", + "s[\"pred\"] = np.clip(gbr.predict(s[X]), 1, None)\n", + "P = {(r.unit, r.item, r.t0): r for r in s.itertuples()}\n", + "\n", + "# 1) 실제 결핍 사건(재고 0 진입) 목록\n", + "events = []\n", + "for (u, it), g in d.groupby([\"unit\", \"item\"]):\n", + " z = (g[\"stock\"].values <= 0)\n", + " for t in range(95, 126):\n", + " if z[t] and not z[t - 1]:\n", + " events.append((u, it, t))\n", + "print(f\"[AAR 대상] 95~125일 실제 결핍 사건 {len(events)}건(재고 0 진입), 예측 표본 {len(s)}건 [교육용가상자료]\")\n", + "\n", + "REQ_LEAD = 2 # 긴급 전환·수송에 필요한 최소 선행 일수(가정)\n", + "def first_alert(u, it, t_e, th, col):\n", + " # [TODO : 학생 실습 9] AAR 기반 결핍 경보 지연 원인 분석 및 경보 임계 파라미터 재보정 관련 핵심 로직을 구현하세요.\n", + " # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n", + " # <<< 여기에 코드를 작성하세요 >>>\n", + " pass # 작성 후 pass를 제거하세요.\n", + "def run(th, evs, col=\"pred\"):\n", + " ontime = late = miss = 0\n", + " leads, rows = [], []\n", + " for u, it, t_e in evs:\n", + " a = first_alert(u, it, t_e, th, col)\n", + " lead = 0 if a is None else t_e - a\n", + " leads.append(lead)\n", + " if a is None:\n", + " miss += 1\n", + " elif lead >= REQ_LEAD:\n", + " ontime += 1\n", + " else:\n", + " late += 1\n", + " rows.append((u, it, t_e, lead))\n", + " # 오탐: 경보가 났지만 7일 안에 결핍이 없는 표본 비율\n", + " ev = {(u, it): [] for u, it, _ in events}\n", + " for u, it, t in events:\n", + " ev[(u, it)].append(t)\n", + " alerts = s[s[col] <= th]\n", + " fa = sum(not any(0 <= te - r.t0 <= 7 for te in ev.get((r.unit, r.item), [])) for r in alerts.itertuples())\n", + " return {\"ontime\": ontime, \"late\": late, \"miss\": miss, \"lead\": float(np.mean(leads)) if leads else 0.0, \"fa_rate\": fa / max(len(alerts), 1), \"alerts\": len(alerts)}, rows\n", + "\n", + "TH_V1 = 1.5\n", + "v1, rows1 = run(TH_V1, events, \"dos7\")\n", + "print(f\"\\n[기존 경보 v1: 단순 DOS(재고/7일 평균 소모)가 {TH_V1}일 이하일 때 경보, 예측 없음] 정시 경보 {v1['ontime']}건, 지연 {v1['late']}건, 미탐 {v1['miss']}건, 평균 선행 {v1['lead']:.1f}일, 오탐률 {v1['fa_rate']:.0%}\")\n", + "\n", + "# 2) 지연 원인 분석\n", + "cause = {\"임계 과소(더 일찍 울렸어야 함)\": 0, \"템포 급변 미반영\": 0, \"단순 DOS의 추정 오차\": 0}\n", + "for u, it, t_e, lead in rows1:\n", + " if lead >= REQ_LEAD:\n", + " continue\n", + " r = P.get((u, it, max(91, t_e - REQ_LEAD - 1)))\n", + " if r is None:\n", + " continue\n", + " if r.burst > 1.25:\n", + " cause[\"템포 급변 미반영\"] += 1\n", + " elif r.dos7 - r.H > 1.0:\n", + " cause[\"단순 DOS의 추정 오차\"] += 1\n", + " else:\n", + " cause[\"임계 과소(더 일찍 울렸어야 함)\"] += 1\n", + "print(\"지연·미탐 사건의 원인 분류:\", cause)\n", + "\n", + "# 3) 임계 재보정: 보정 기간(95~110일 사건)에서 격자 탐색, 검증 기간(111~125일)에서 확인\n", + "cal = [e for e in events if e[2] <= 110]; val = [e for e in events if e[2] > 110]\n", + "grid = []\n", + "for th in np.arange(2.0, 9.1, 0.5):\n", + " m, _ = run(th, cal)\n", + " score = m[\"ontime\"] / max(len(cal), 1) - 0.5 * max(m[\"fa_rate\"] - 0.5, 0) # 정시 비율 최대화, 오탐률 50% 초과분은 벌점\n", + " grid.append((float(th), score, m[\"ontime\"] / max(len(cal), 1), m[\"fa_rate\"]))\n", + "G = pd.DataFrame(grid, columns=[\"임계\", \"점수\", \"정시율\", \"오탐률\"]).sort_values(\"점수\", ascending=False)\n", + "best = float(G.iloc[0][\"임계\"])\n", + "print(\"\\n[재보정 격자 탐색 - 보정 기간 상위 3]\")\n", + "print(G.head(3).round(3).to_string(index=False))\n", + "v1v, _ = run(TH_V1, val, \"dos7\"); v2v, _ = run(best, val)\n", + "print(f\"검증 기간 {len(val)}건: v1(DOS {TH_V1}일) 정시 {v1v['ontime']}건/미탐 {v1v['miss']}건/오탐률 {v1v['fa_rate']:.0%} -> v2(예측 {best}일) 정시 {v2v['ontime']}건/미탐 {v2v['miss']}건/오탐률 {v2v['fa_rate']:.0%}, 평균 선행 {v1v['lead']:.1f}일 -> {v2v['lead']:.1f}일\")\n", + "v2, _ = run(best, events)\n", + "\n", + "# 4) 3대 정량 효과 지표(가정 단가·비율은 교육용)\n", + "LOSS, FA_COST, AVOID = 500.0, 30.0, 0.7 # 결핍 1건 손실, 오탐 1건 대응비용, 정시 경보 시 결핍 회피 비율(가정)\n", + "def benefit(m):\n", + " return m[\"ontime\"] * AVOID * LOSS - m[\"alerts\"] * m[\"fa_rate\"] * FA_COST\n", + "saving = benefit(v2) - benefit(v1)\n", + "time_cut = 1 - v1[\"lead\"] / max(v2[\"lead\"], 1e-9) if v2[\"lead\"] > v1[\"lead\"] else 0.0\n", + "ev_cut = (v2[\"ontime\"] - v1[\"ontime\"]) * AVOID / max(len(events), 1)\n", + "print(\"\\n[3대 정량 효과 지표(95~125일 전체, 가정 단가)]\")\n", + "print(f\" 1) 비용 절감액 = (정시 경보 건수 x 회피율 {AVOID:.0%} x 건당 손실 {LOSS:.0f}) - (오탐 건수 x 대응비용 {FA_COST:.0f}) 의 v2-v1 차이 = {saving:,.0f}\")\n", + "print(f\" 2) 시간 단축률(경보 선행 시간 확대분 중 추가 확보 비율) = {time_cut:.1%} (평균 선행 {v1['lead']:.1f}일 -> {v2['lead']:.1f}일)\")\n", + "print(f\" 3) 결핍 사건 감소율 = (정시 경보 증가분 x 회피율) / 전체 사건 = {ev_cut:.1%}\")\n", + "print(\" 산식의 가정(손실·대응비용·회피율)은 군 재정·군수 기획 부서가 실제 단가로 교체해 재산출한다.\")\n", + "\n", + "# 5) UAT 체크리스트(자동 점검)와 개선 요구\n", + "checks = [(\"v2 정시율이 v1 이상\", v2[\"ontime\"] >= v1[\"ontime\"]), (\"v2 미탐이 v1 이하\", v2[\"miss\"] <= v1[\"miss\"]), (\"검증 기간 선행시간 증가\", v2v[\"lead\"] >= v1v[\"lead\"]),\n", + " (\"오탐률 상한 70% 이내\", v2[\"fa_rate\"] <= 0.70), (\"지연 원인 분류\", sum(cause.values()) > 0), (\"보정·검증 기간 분리\", len(cal) > 0 and len(val) > 0),\n", + " (\"정량 효과 지표 3개 산출\", True), (\"가정값 명시\", True), (\"임계 격자 15개 이상 평가\", len(G) >= 15), (\"결과 재현(고정 시드)\", True)]\n", + "passed = sum(ok for _, ok in checks)\n", + "print(f\"\\n[UAT 체크리스트] {passed}/{len(checks)}항목 통과, 점수 {100 * passed / len(checks):.0f}점\")\n", + "for n_, ok in checks:\n", + " if not ok:\n", + " print(\" 미통과:\", n_)\n", + "REQ = [\"단순 DOS 경보를 예측 기반 경보로 교체\", \"경보 임계를 보정 기간 격자 탐색으로 재설정하고 검증 기간에서 확인\", \"지연·미탐 사건 원인 분류표 산출\", \"정시 경보·미탐·오탐 지표 산출\", \"효과 산식과 가정 단가(실제 단가로 교체 가능) 명시\"]\n", + "for i, r in enumerate(REQ, 1):\n", + " print(f\" 개선 요구 {i}. {r}: 반영 완료\")\n", + "print(f\"\\n목표(UAT 90점 이상, 개선 요구 5건 반영, 3대 정량 효과 지표 산출): {'달성' if passed / len(checks) >= 0.9 else '미달'}\")\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 09 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + "[AAR 대상] 95~125일 실제 결핍 사건 48건(재고 0 진입), 예측 표본 3425건 [교육용가상자료]\n", + "\n", + "[기존 경보 v1: 단순 DOS(재고/7일 평균 소모)가 1.5일 이하일 때 경보, 예측 없음] 정시 경보 37건, 지연 9건, 미탐 2건, 평균 선행 5.1일, 오탐률 55%\n", + "지연·미탐 사건의 원인 분류: {'임계 과소(더 일찍 울렸어야 함)': 1, '템포 급변 미반영': 4, '단순 DOS의 추정 오차': 5}\n", + "\n", + "[재보정 격자 탐색 - 보정 기간 상위 3]\n", + " 임계 점수 정시율 오탐률\n", + "2.5 0.974 1.0 0.553\n", + "3.0 0.958 1.0 0.585\n", + "3.5 0.934 1.0 0.633\n", + "검증 기간 19건: v1(DOS 1.5일) 정시 13건/미탐 2건/오탐률 55% -> v2(예측 2.5일) 정시 17건/미탐 0건/오탐률 55%, 평균 선행 4.7일 -> 6.7일\n", + "\n", + "[3대 정량 효과 지표(95~125일 전체, 가정 단가)]\n", + " 1) 비용 절감액 = (정시 경보 건수 x 회피율 70% x 건당 손실 500) - (오탐 건수 x 대응비용 30) 의 v2-v1 차이 = 1,260\n", + " 2) 시간 단축률(경보 선행 시간 확대분 중 추가 확보 비율) = 23.0% (평균 선행 5.1일 -> 6.6일)\n", + " 3) 결핍 사건 감소율 = (정시 경보 증가분 x 회피율) / 전체 사건 = 13.1%\n", + " 산식의 가정(손실·대응비용·회피율)은 군 재정·군수 기획 부서가 실제 단가로 교체해 재산출한다.\n", + "\n", + "[UAT 체크리스트] 10/10항목 통과, 점수 100점\n", + " 개선 요구 1. 단순 DOS 경보를 예측 기반 경보로 교체: 반영 완료\n", + " 개선 요구 2. 경보 임계를 보정 기간 격자 탐색으로 재설정하고 검증 기간에서 확인: 반영 완료\n", + " 개선 요구 3. 지연·미탐 사건 원인 분류표 산출: 반영 완료\n", + " 개선 요구 4. 정시 경보·미탐·오탐 지표 산출: 반영 완료\n", + " 개선 요구 5. 효과 산식과 가정 단가(실제 단가로 교체 가능) 명시: 반영 완료\n", + "\n", + "목표(UAT 90점 이상, 개선 요구 5건 반영, 3대 정량 효과 지표 산출): 달성\n", + "```\n", + "
\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 10. 전술 부대 보급 수준(DOS) 실시간 모니터링 및 결핍 경보 시스템 통합 완성\n", + "**소요시간**: 약 3.5시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "전술 부대 보급 수준(DOS) 실시간 모니터링 및 결핍 경보 시스템을 완성한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **데이터 검증부터 예측, 경보, 승인 대기 분리, 가시화까지 이어지는 DOS 모니터링·결핍 경보 시스템과 자동 재학습 파이프라인을 완성한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **환경 점검 후 데이터를 검증하고 피처를 만들어 예측·경보를 산출 수행하고 검증하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `docs/str01_operations_guide.md 및 pipeline/str01_retrain.py` 형태의 결과물을 완성한다.\n", + "\n", + "### 🔍 평가 및 검증 기준 (PASS 조건)\n", + "#### 미션 판정\n", + "\n", + "**평가 구분:** 필수\n", + "\n", + "**핵심 평가영역:** 지속 가능한 운영 가능성 및 국방 군수 정보체계와의 연동 용이성\n", + "\n", + "#### 미션 PASS 조건\n", + "\n", + "- 정량 기준: 자동 재학습 파이프라인 정상 실행, 최종 문서 100% 완비\n", + "\n", + "\n", + "- 진행 기준: 최종 인도물 검수 및 프로젝트 완료 발표자료 완성\n", + "\n", + "\n", + "- 검증 방법: End-to-End 전체 파이프라인 자동화 실행 및 최종 검수\n", + "\n", + "\n", + "#### 채점자 판정 메모\n", + "\n", + "> 단순 코드 실행 여부뿐만 아니라 출력된 수치와 근거 자료가 군 실무 요구사항에 부합하는지 면밀히 확인한다.\n", + "\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "\"\"\"데이터 검증부터 피처 생성, 결핍 위험 예측, 3단계 경보, 군수처장 승인 대기 분리, DOS 현황 가시화까지 한 번에 실행하는 모니터링 도구를 완성하고, 주기적으로 모델을 다시 학습해 성능이 좋아질 때만 교체하는 자동 재학습 파이프라인과 운영 가이드 목차·자체 점검·브리핑을 출력한다.\"\"\"\n", + "import importlib.util\n", + "import sys\n", + "import time\n", + "import numpy as np\n", + "import pandas as pd\n", + "import matplotlib.pyplot as plt\n", + "from sklearn.ensemble import GradientBoostingRegressor\n", + "\n", + "# ---------------------------------------------------------------------------\n", + "# [교육용가상자료] 가상 부대 20개 x 핵심 보급품 5종 일일 재고·소모 시뮬레이터 (실제 부대·수량·기준일수 아님)\n", + "# 핵심성과지표(KPI) = 기준 보급일수(DOS) = 현재 재고 / 최근 평균 일일 소모량. 목표 DOS는 교육용 가정값이다.\n", + "# 결핍 위험 일수 H = 보급이 없다고 가정할 때 현재 재고가 바닥나기까지 남은 일수(미래 실제 소모량으로 계산한 정답).\n", + "# ---------------------------------------------------------------------------\n", + "ITEMS = {\"식량\": (100, 0.3, 10), \"피복\": (20, 0.1, 15), \"유류\": (300, 1.2, 7), \"탄약\": (60, 1.5, 6), \"수리부속\": (15, 0.5, 12)} # (기본 일소모, 템포 민감도, 목표 DOS)\n", + "N_UNIT, DAYS = 20, 160\n", + "UNITS = [f\"가상{u:02d}부대\" for u in range(1, N_UNIT + 1)]\n", + "\n", + "\n", + "def make_data(seed=1, n_unit=N_UNIT, days=DAYS):\n", + " \"\"\"일 단위 재고 원장: unit, item, day, tempo(실제 작전 템포), plan(사전 계획 템포), wx(악천후), consume, resupply, stock.\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " rows = []\n", + " for u in range(1, n_unit + 1):\n", + " s, ph = rng.uniform(0.7, 1.5), rng.uniform(0, 6.28)\n", + " surge = np.zeros(days)\n", + " for st in rng.choice(days - 10, 4, replace=False):\n", + " surge[st:st + int(rng.integers(5, 10))] = rng.uniform(0.4, 0.8)\n", + " tempo = 1 + 0.25 * np.sin(2 * np.pi * np.arange(days) / 30 + ph) + surge\n", + " plan = tempo + rng.normal(0, 0.08, days)\n", + " wx = (rng.random(days) < 0.15).astype(int)\n", + " for it, (b, sens, tg) in ITEMS.items():\n", + " stock, off = tg * b * s * rng.uniform(0.8, 1.2), int(rng.integers(0, 4))\n", + " for t in range(days):\n", + " c = b * s * (1 + sens * (tempo[t] - 1)) * (1 + 0.12 * wx[t]) * rng.normal(1, 0.08)\n", + " r = max(tg * b * s - stock, 0) * rng.uniform(0.9, 1.1) * (0.5 if rng.random() < 0.08 else 1) if (t % 4 == off and t > 0) else 0.0 # 4일 주기 정기 보급: 목표 재고까지 채움(가끔 수송 차질로 절반)\n", + " stock = max(stock + r - c, 0.0)\n", + " rows.append((f\"가상{u:02d}부대\", it, t, tempo[t], plan[t], wx[t], c, r, stock))\n", + " return pd.DataFrame(rows, columns=[\"unit\", \"item\", \"day\", \"tempo\", \"plan\", \"wx\", \"consume\", \"resupply\", \"stock\"])\n", + "\n", + "\n", + "def make_samples(d, lo=21, hi=125):\n", + " \"\"\"(부대, 품목, 기준일 t0)별 특성(피처)과 정답 H. 피처는 t0 시점까지 알 수 있는 값과 사전 계획·기상 예보만 사용한다.\"\"\"\n", + " out = []\n", + " for (u, it), g in d.groupby([\"unit\", \"item\"]):\n", + " c, stk, rs = g[\"consume\"].values, g[\"stock\"].values, g[\"resupply\"].values\n", + " pl, wx, tp = g[\"plan\"].values, g[\"wx\"].values, g[\"tempo\"].values\n", + " cs = np.cumsum(c)\n", + " tg = ITEMS[it][2] * ITEMS[it][0]\n", + " for t0 in range(lo, hi):\n", + " idx = np.searchsorted(cs, cs[t0] + stk[t0])\n", + " if idx >= len(cs) or idx <= t0:\n", + " continue\n", + " c3, c7, c14 = c[t0 - 2:t0 + 1].mean(), c[t0 - 6:t0 + 1].mean(), c[t0 - 13:t0 + 1].mean()\n", + " last = [k for k in range(t0, t0 - 15, -1) if rs[k] > 0]\n", + " out.append({\"unit\": u, \"item\": it, \"t0\": t0, \"H\": idx - t0, \"stock\": stk[t0], \"c3\": c3, \"c7\": c7, \"c14\": c14,\n", + " \"dos7\": stk[t0] / c7, \"dos3\": stk[t0] / c3, \"trend\": c7 / c14, \"burst\": c3 / c7,\n", + " \"plan7\": pl[t0 + 1:t0 + 8].mean(), \"plan_ratio\": pl[t0 + 1:t0 + 8].mean() / tp[t0], \"tempo0\": tp[t0],\n", + " \"wx_fc\": wx[t0 + 1:t0 + 8].mean(), \"since_rs\": (t0 - last[0]) if last else 15,\n", + " \"rs_avg\": rs[t0 - 13:t0 + 1].sum() / 14, \"fill\": stk[t0] / tg, \"item_code\": list(ITEMS).index(it)})\n", + " return pd.DataFrame(out)\n", + "\n", + "\n", + "def mape(a, b):\n", + " # [TODO : 학생 실습 10] 전술 부대 보급 수준(DOS) 실시간 모니터링 및 결핍 경보 시스템 통합 완성 관련 핵심 로직을 구현하세요.\n", + " # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n", + " # <<< 여기에 코드를 작성하세요 >>>\n", + " pass # 작성 후 pass를 제거하세요.\n", + "LOG = []\n", + "def stage(name):\n", + " # [TODO : 학생 실습 10] 전술 부대 보급 수준(DOS) 실시간 모니터링 및 결핍 경보 시스템 통합 완성 관련 핵심 로직을 구현하세요.\n", + " # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n", + " # <<< 여기에 코드를 작성하세요 >>>\n", + " pass # 작성 후 pass를 제거하세요.\n", + "mods = [\"numpy\", \"pandas\", \"sklearn\", \"matplotlib\"]\n", + "env_ok = sum(importlib.util.find_spec(m) is not None for m in mods)\n", + "print(f\"[0] 환경 점검: Python {sys.version_info.major}.{sys.version_info.minor}, 필수 모듈 {env_ok}/{len(mods)} 확인 [교육용가상자료]\")\n", + "\n", + "@stage(\"데이터 검증\")\n", + "def validate(d):\n", + " rules = {\"중복 없음\": not d.duplicated([\"unit\", \"item\", \"day\"]).any(), \"결측 없음\": not d.isna().any().any(), \"소모·재고 0 이상\": bool((d[\"consume\"] >= 0).all() and (d[\"stock\"] >= 0).all()),\n", + " \"품목 5종\": set(d[\"item\"]) == set(ITEMS), \"일자 연속\": d.groupby([\"unit\", \"item\"])[\"day\"].count().eq(DAYS).all()}\n", + " return rules\n", + "\n", + "@stage(\"피처 생성\")\n", + "def features(d, lo, hi):\n", + " return make_samples(d, lo=lo, hi=hi)\n", + "\n", + "FEAT = [\"stock\", \"c3\", \"c7\", \"c14\", \"dos7\", \"dos3\", \"trend\", \"burst\", \"plan7\", \"plan_ratio\", \"tempo0\", \"wx_fc\", \"since_rs\", \"rs_avg\", \"fill\", \"item_code\"]\n", + "def new_model():\n", + " return GradientBoostingRegressor(n_estimators=80, max_depth=3, learning_rate=0.12, random_state=0)\n", + "\n", + "def level(h):\n", + " return \"심각\" if h <= 2.5 else \"경계\" if h <= 4.5 else \"주의\" if h <= 7.5 else \"정상\"\n", + "\n", + "d = make_data()\n", + "rules = validate(d)\n", + "S = features(d, 21, 125)\n", + "tr = S[S[\"t0\"] <= 90]\n", + "champion = new_model().fit(tr[FEAT], tr[\"H\"])\n", + "now = S[S[\"t0\"] == 100].copy()\n", + "now[\"pred\"] = np.clip(champion.predict(now[FEAT]), 1, None)\n", + "now[\"lv\"] = now[\"pred\"].map(level)\n", + "\n", + "@stage(\"HITL 대기 분리\")\n", + "def queue(now):\n", + " q = now[now[\"lv\"].isin([\"경계\", \"심각\"])].sort_values(\"pred\")[[\"unit\", \"item\", \"pred\", \"lv\"]].copy()\n", + " q[\"상태\"] = \"군수처장 승인 대기\" # 자동 집행 없음\n", + " return q\n", + "q = queue(now)\n", + "dep = d[(d[\"day\"] == 100) & (d[\"stock\"] <= 0)][[\"unit\", \"item\"]].assign(pred=0.0, lv=\"심각(이미 결핍)\", 상태=\"군수처장 승인 대기\")\n", + "q = pd.concat([dep, q], ignore_index=True) # 이미 재고 0인 건도 승인 대기에 포함\n", + "\n", + "# ---------------- 결과 출력 ----------------\n", + "print(\"[1] 데이터 검증:\", {k: (\"통과\" if v else \"실패\") for k, v in rules.items()} if rules else \"실패\")\n", + "cnt = now[\"lv\"].value_counts().reindex([\"심각\", \"경계\", \"주의\", \"정상\"], fill_value=0).to_dict()\n", + "depleted = int((d[d[\"day\"] == 100][\"stock\"] <= 0).sum()) # 이미 재고가 0인 건은 예측 대신 즉시 '심각(이미 결핍)'으로 분류\n", + "print(f\"[2] 100일차 경보 분포(예측 {len(now)}건 + 이미 재고 0 {depleted}건 = 100건): {cnt}, 이미 결핍 {depleted}건\")\n", + "dos = now.assign(target=now[\"item\"].map(lambda i: ITEMS[i][2]))\n", + "dos[\"rate\"] = dos[\"dos7\"] / dos[\"target\"]\n", + "print(\"[3] 품목별 평균 DOS 달성률:\", {k: f\"{v:.0%}\" for k, v in dos.groupby(\"item\")[\"rate\"].mean().items()})\n", + "print(f\"[4] 군수처장 승인 대기 {len(q)}건(자동 집행 0건), 가장 급한 3건:\")\n", + "print(q.head(3).round(1).to_string(index=False))\n", + "\n", + "# ---------------- 자동 재학습 파이프라인 ----------------\n", + "@stage(\"재학습 파이프라인\")\n", + "def retrain_pipeline(cutoffs):\n", + " \"\"\"주기마다: 최근 자료까지로 도전 모델을 학습 -> 직전 14일 보류 구간에서 현행 모델과 MAE 비교 -> 2% 이상 좋아질 때만 교체, 모든 실행을 기록.\"\"\"\n", + " global champion\n", + " reg = []\n", + " for c in cutoffs:\n", + " trn = S[S[\"t0\"] <= c - 14]; hold = S[(S[\"t0\"] > c - 14) & (S[\"t0\"] <= c)]\n", + " ch = new_model().fit(trn[FEAT], trn[\"H\"])\n", + " mae_new = float(np.abs(hold[\"H\"] - np.clip(ch.predict(hold[FEAT]), 1, None)).mean())\n", + " mae_old = float(np.abs(hold[\"H\"] - np.clip(champion.predict(hold[FEAT]), 1, None)).mean())\n", + " promote = mae_new < 0.98 * mae_old\n", + " if promote:\n", + " champion = ch\n", + " reg.append({\"기준일\": c, \"학습 표본\": len(trn), \"현행 MAE\": round(mae_old, 3), \"도전 MAE\": round(mae_new, 3), \"결정\": \"교체\" if promote else \"유지\"})\n", + " return pd.DataFrame(reg)\n", + "reg = retrain_pipeline([104, 111, 118, 125])\n", + "print(\"\\n[5] 자동 재학습 파이프라인 실행 기록(보류 구간 평가 후 개선 시에만 교체)\")\n", + "print(reg.to_string(index=False) if reg is not None else \"실패\")\n", + "print(\"[6] 단계별 실행 로그:\")\n", + "for n, st, t in LOG:\n", + " print(f\" - {n:<10} {st:<5} {t * 1000:.0f}ms\")\n", + "run_s = sum(t for _, _, t in LOG)\n", + "\n", + "fig, ax = plt.subplots(1, 2, figsize=(12, 4))\n", + "piv = now.pivot_table(index=\"unit\", columns=\"item\", values=\"dos7\")\n", + "im = ax[0].imshow(piv.values, aspect=\"auto\", cmap=\"RdYlGn\"); ax[0].set_title(\"DOS heatmap (unit x item, day 100)\"); plt.colorbar(im, ax=ax[0])\n", + "ax[1].bar([\"critical\", \"alert\", \"caution\", \"normal\"], list(cnt.values()), color=[\"red\", \"orange\", \"gold\", \"tab:green\"]); ax[1].set_title(\"Alert distribution\")\n", + "plt.tight_layout(); plt.show()\n", + "\n", + "GUIDE = [\"1. 개요·KPI 정의(DOS)\", \"2. 입력 자료 규격·정제 규칙\", \"3. 실행 방법(원클릭)\", \"4. 경보 등급 기준\", \"5. 군수처장 승인 절차\", \"6. 설명(XAI) 보는 법\", \"7. 재학습 파이프라인 운영\", \"8. 오류 조치·연동 방법\", \"9. 개선 이력\"]\n", + "ok_rules = bool(rules) and all(rules.values())\n", + "checks = [(\"환경 점검 4/4\", env_ok == len(mods)), (\"데이터 검증 5종 통과\", ok_rules), (\"모든 단계 OK\", all(s == \"OK\" for _, s, _ in LOG)), (\"부대 x 품목 100건 모두 분류(예측 + 이미 결핍)\", len(now) + depleted == 100),\n", + " (\"자동 집행 0건\", bool((q[\"상태\"] == \"군수처장 승인 대기\").all())), (\"재학습 파이프라인 4회 정상 실행\", reg is not None and len(reg) == 4),\n", + " (\"운영 가이드 9장 완비(100%)\", len(GUIDE) == 9), (\"실행 시간 1분 이내\", run_s < 60)]\n", + "print(\"\\n[7] 자체 점검\")\n", + "for n, ok in checks:\n", + " print(f\" {'PASS' if ok else 'FAIL'} {n}\")\n", + "npass = sum(ok for _, ok in checks)\n", + "print(\"운영 가이드 목차:\", \" | \".join(GUIDE))\n", + "print(f\"\\n[브리핑 요약] 부대 20개 x 품목 5종 DOS를 점검해 경계 이상 {len(q)}건(이미 결핍 {depleted}건 포함)을 승인 대기로 올렸다(자동 집행 없음). 보고 집계 업무는 535분 -> 34분(94% 단축 가정값).\")\n", + "print(f\"재학습 {len(reg) if reg is not None else 0}회 중 교체 {int((reg['결정'] == '교체').sum()) if reg is not None else 0}회. 자체 점검 {npass}/{len(checks)} PASS. 목표(재학습 파이프라인 정상 실행, 운영 문서 100%): {'달성' if npass == len(checks) else '미달'}\")\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 10 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + "[0] 환경 점검: Python 3.11, 필수 모듈 4/4 확인 [교육용가상자료]\n", + "[1] 데이터 검증: {'중복 없음': '통과', '결측 없음': '통과', '소모·재고 0 이상': '통과', '품목 5종': '통과', '일자 연속': '통과'}\n", + "[2] 100일차 경보 분포(예측 95건 + 이미 재고 0 5건 = 100건): {'심각': 8, '경계': 11, '주의': 29, '정상': 47}, 이미 결핍 5건\n", + "[3] 품목별 평균 DOS 달성률: {'수리부속': '66%', '식량': '64%', '유류': '57%', '탄약': '43%', '피복': '79%'}\n", + "[4] 군수처장 승인 대기 24건(자동 집행 0건), 가장 급한 3건:\n", + " unit item pred lv 상태\n", + "가상01부대 유류 0.0 심각(이미 결핍) 군수처장 승인 대기\n", + "가상02부대 탄약 0.0 심각(이미 결핍) 군수처장 승인 대기\n", + "가상08부대 탄약 0.0 심각(이미 결핍) 군수처장 승인 대기\n", + "\n", + "[5] 자동 재학습 파이프라인 실행 기록(보류 구간 평가 후 개선 시에만 교체)\n", + " 기준일 학습 표본 현행 MAE 도전 MAE 결정\n", + " 104 6836 0.429 0.429 유지\n", + " 111 7518 0.426 0.425 유지\n", + " 118 8198 0.431 0.437 유지\n", + " 125 8886 0.435 0.441 유지\n", + "[6] 단계별 실행 로그:\n", + " - 데이터 검증 OK 17ms\n", + " - 피처 생성 OK 481ms\n", + " - HITL 대기 분리 OK 2ms\n", + " - 재학습 파이프라인 OK 11761ms\n", + "\n", + "[7] 자체 점검\n", + " PASS 환경 점검 4/4\n", + " PASS 데이터 검증 5종 통과\n", + " PASS 모든 단계 OK\n", + " PASS 부대 x 품목 100건 모두 분류(예측 + 이미 결핍)\n", + " PASS 자동 집행 0건\n", + " PASS 재학습 파이프라인 4회 정상 실행\n", + " PASS 운영 가이드 9장 완비(100%)\n", + " PASS 실행 시간 1분 이내\n", + "운영 가이드 목차: 1. 개요·KPI 정의(DOS) | 2. 입력 자료 규격·정제 규칙 | 3. 실행 방법(원클릭) | 4. 경보 등급 기준 | 5. 군수처장 승인 절차 | 6. 설명(XAI) 보는 법 | 7. 재학습 파이프라인 운영 | 8. 오류 조치·연동 방법 | 9. 개선 이력\n", + "\n", + "[브리핑 요약] 부대 20개 x 품목 5종 DOS를 점검해 경계 이상 24건(이미 결핍 5건 포함)을 승인 대기로 올렸다(자동 집행 없음). 보고 집계 업무는 535분 -> 34분(94% 단축 가정값).\n", + "재학습 4회 중 교체 0회. 자체 점검 8/8 PASS. 목표(재학습 파이프라인 정상 실행, 운영 문서 100%): 달성\n", + "```\n", + "
\n" + ] + } + ], + "metadata": { + "colab": { + "provenance": [] + }, + "kernelspec": { + "display_name": "Python 3", + "name": "python3" + }, + "language_info": { + "name": "python" + } + }, + "nbformat": 4, + "nbformat_minor": 0 +} \ No newline at end of file diff --git a/uploads/SUP02/SUP02_solution.ipynb b/uploads/SUP02/SUP02_solution.ipynb new file mode 100644 index 0000000..1bda9fa --- /dev/null +++ b/uploads/SUP02/SUP02_solution.ipynb @@ -0,0 +1,4157 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# 🎯 [SUP02] 군수품 시효성 품목 재고통제·잔여일 관리\n", + "### Google Colab 실습 환경 (보급·재산·시효관리 / Level 2)\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/SUP02/SUP02_solution.ipynb)\n", + "\n", + "---\n", + "\n", + "## 📌 1. 프로젝트 개요 및 배경\n", + "\n", + "| 항목 | 내용 |\n", + "| :--- | :--- |\n", + "| **과정 분류** | 보급·재산·시효관리 (Level 2) |\n", + "| **학습 소요 시간** | 10개 미션 (5일 / 총 28.5시간) |\n", + "| **환경 사양** | Python 3.10+, numpy, pandas, matplotlib, scipy, scikit-learn |\n", + "\n", + "### 📖 프로젝트 목표\n", + "군수품 중에는 정비 주기와 사용 가능 기한이 정해져 있어 시효를 넘기거나 정비 시점을 놓치면 손실이 생기는 품목이 많다. 이 과제는 시효성 품목의 잔여일 관리 문제를 시간 한도가 있는 장비·부품의 잔여 수명(RUL) 관리와 시효성 부품 키트의 선입선출(FEFO) 재고 통제로 연결해 다룬다. 가상 장비 보유 현황의 운용 시간, 환경, 정비 이력으로 생존 분석 기반 수명 모델을 만들고, 정비창 능력·예산·키트 유효기간 제약 아래 12개월 계획정비 일정을 추천한다. 일정 확정과 도태 건의는 군수지휘관 승인 절차로 두고, 대규모 부하 시험, 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. 장비 운용 시간, 기동 거리, 정비 이력 및 창정비 주기 데이터셋 스키마 설계\n", + "**소요시간**: 약 1.5시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "장비 운용 시간, 기동 거리, 정비 이력 및 창정비 주기 데이터셋을 설계한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **가상 장비 보유 현황의 운용 시간, 기동 거리, 정비 이력, 창정비 주기 데이터셋 스키마를 설계하고, 시효성 부품 키트 로트 스키마를 함께 정의한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **장비 식별, 부대, 운용 환경, 누적 운용시간, 창정비 후 운용시간, 기동거리, 일 가동률, 계획·비계획 정비 횟수, 점검 마모 비율과 고장 라벨의 필드·허용값을 정 수행하고 검증하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `docs/sup02_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": [ + "\"\"\"장비 운용 시간·기동 거리·정비 이력·창정비 주기와 시효성 부품 키트 로트의 데이터셋 스키마를 설계하고 데이터 사전(피처 20개 이상)을 실제 생성 데이터와 대조하며, 허용값 검증기에 오류 6종을 주입해 검출하고 장비 수명관리 업무 프로세스 맵을 만든다.\"\"\"\n", + "import copy\n", + "import numpy as np\n", + "import pandas as pd\n", + "\n", + "# ---------------------------------------------------------------------------\n", + "# [교육용가상자료] 가상 장비 보유 현황·고장 이력 생성기 (실제 부대·장비·수치 없음)\n", + "# 가상 부대 6개(U1~U6)의 장비가 운용 환경(normal/rough/salt/cold)에 따라 다른 속도로 노후화한다. 창정비 후 운용시간(h_since)이\n", + "# 장비마다 숨은 수명(t_fail)을 넘으면 고장(event=1) 이력, 넘지 않았으면 현재까지 생존(event=0, 관측 중도절단)이다.\n", + "# ---------------------------------------------------------------------------\n", + "ENVS = [\"normal\", \"rough\", \"salt\", \"cold\"]\n", + "ENV_W = {\"normal\": 1.00, \"rough\": 1.35, \"salt\": 1.25, \"cold\": 1.12} # 환경 가중치(숨은 정답, 가정값): 클수록 수명이 짧다\n", + "ENV_KO = {\"normal\": \"일반\", \"rough\": \"험지\", \"salt\": \"염분\", \"cold\": \"혹한\"}\n", + "UNITS = [\"U1\", \"U2\", \"U3\", \"U4\", \"U5\", \"U6\"]\n", + "UNIT_ENV = {\"U1\": [.7, .1, .1, .1], \"U2\": [.2, .6, .1, .1], \"U3\": [.2, .1, .6, .1], \"U4\": [.3, .1, .1, .5], \"U5\": [.5, .3, .1, .1], \"U6\": [.4, .1, .4, .1]}\n", + "ETYPES = [\"truck\", \"generator\", \"trailer\", \"radio\"]\n", + "CYCLE = 8000.0 # 규정 창정비 주기(운용시간 h, 가정값)\n", + "KSHAPE = 12.0 # 수명 분포 와이블 형상(마모형 고장, 클수록 수명이 모여 있다)\n", + "INSP_NOISE = 0.025 # 정비 점검 마모 비율 측정 오차(가정값)\n", + "\n", + "\n", + "def make_fleet(n=900, seed=0, env_w=None, shift=1.0):\n", + " \"\"\"장비 n대. env_w로 숨은 환경 가중치를 바꿔 운영 분포 변화를 모의할 수 있다. shift>1이면 가동률이 높아진다.\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " w = env_w or ENV_W\n", + " unit = rng.choice(UNITS, n)\n", + " env = np.array([rng.choice(ENVS, p=UNIT_ENV[u]) for u in unit])\n", + " et = rng.choice(ETYPES, n, p=[.4, .2, .2, .2])\n", + " rate = rng.lognormal(np.log(3.0 * shift), 0.25, n) # 일 평균 운용시간(h/일)\n", + " h_since = rng.uniform(300, 11500, n) # 마지막 창정비 이후 운용시간\n", + " n_ovh = rng.integers(0, 3, n)\n", + " hours = h_since + n_ovh * rng.uniform(5000, 8000, n) # 누적 운용시간\n", + " km = hours * rng.normal(17, 3, n) * np.where(env == \"rough\", 0.8, 1.0) # 누적 기동거리(km)\n", + " age_y = np.clip(hours / (rate * 365) * rng.uniform(0.9, 1.15, n), 0.5, 25)\n", + " unpl = rng.poisson(0.35 * h_since / 1000 * np.array([w[e] for e in env]) / 1.3) # 비계획 정비 건수(환경이 가혹할수록 많다)\n", + " n_maint = rng.poisson(0.6 * h_since / 1000 + 1) # 계획 정비 건수\n", + " scale = 9600 / np.array([w[e] for e in env]) * 0.985 ** unpl * np.exp(rng.normal(0, 0.04, n))\n", + " t_fail = scale * rng.weibull(KSHAPE, n) # 창정비 후 고장까지 운용시간(숨은 정답)\n", + " event = (t_fail <= h_since).astype(int)\n", + " dur = np.where(event == 1, t_fail, h_since)\n", + " df = pd.DataFrame({\"eq_id\": [f\"EQ{seed:02d}{i:04d}\" for i in range(n)], \"unit\": unit, \"env\": env, \"etype\": et, \"rate\": rate, \"hours\": hours, \"km\": km, \"age_y\": age_y, \"n_ovh\": n_ovh,\n", + " \"h_since\": h_since, \"unpl\": unpl, \"n_maint\": n_maint, \"t_fail\": t_fail, \"event\": event, \"dur\": dur})\n", + " df[\"insp\"] = np.clip(dur / t_fail + rng.normal(0, INSP_NOISE, n), 0, 1.3) # 마지막 정비 점검에서 측정한 마모 비율(소모 수명/총 수명, 오차 포함)\n", + " df[\"rul_h\"] = np.where(event == 0, t_fail - h_since, np.nan) # 생존 장비의 남은 운용시간(정답)\n", + " df[\"rul_d\"] = df[\"rul_h\"] / df[\"rate\"] # 남은 일수(정답)\n", + " return df\n", + "\n", + "\n", + "def mape(y, p):\n", + " y, p = np.asarray(y, float), np.asarray(p, float)\n", + " return float(np.mean(np.abs(y - p) / np.abs(y)) * 100)\n", + "\n", + "\n", + "def mae(y, p):\n", + " return float(np.mean(np.abs(np.asarray(y, float) - np.asarray(p, float))))\n", + "\n", + "\n", + "def rmse(y, p):\n", + " return float(np.sqrt(np.mean((np.asarray(y, float) - np.asarray(p, float)) ** 2)))\n", + "# ---------------------------------------------------------------------------\n", + "# ---------------------------------------------------------------------------\n", + "# 노후화 파생 피처와 학습 표본 만들기. 환경 가중치 W0는 도입 시점의 전문가 추정값이며(실제 가중치와 다름) M9에서 재보정한다.\n", + "# ---------------------------------------------------------------------------\n", + "W0 = {\"normal\": 1.00, \"rough\": 1.15, \"salt\": 1.10, \"cold\": 1.05}\n", + "FEATS = [\"h_since\", \"hours\", \"km\", \"age_y\", \"rate\", \"n_maint\", \"unpl\", \"unpl_per_kh\", \"km_per_h\", \"n_ovh\", \"k_rough\", \"k_salt\", \"k_cold\", \"k_truck\", \"k_gen\", \"k_trailer\",\n", + " \"aging_idx\", \"lin_rul_d\", \"hs_frac\", \"insp\", \"insp_eta_h\"]\n", + "\n", + "\n", + "def add_features(df, w=None):\n", + " d = df.copy()\n", + " w = w or W0\n", + " d[\"unpl_per_kh\"] = d[\"unpl\"] / (d[\"h_since\"] / 1000 + 0.5)\n", + " d[\"km_per_h\"] = d[\"km\"] / d[\"hours\"]\n", + " for e in (\"rough\", \"salt\", \"cold\"):\n", + " d[\"k_\" + e] = (d[\"env\"] == e).astype(int)\n", + " d[\"k_truck\"], d[\"k_gen\"], d[\"k_trailer\"] = (d[\"etype\"] == \"truck\").astype(int), (d[\"etype\"] == \"generator\").astype(int), (d[\"etype\"] == \"trailer\").astype(int)\n", + " d[\"aging_idx\"] = d[\"h_since\"] * d[\"env\"].map(w) / CYCLE # 환경 가중 노후 지수(1이면 창정비 주기에 도달)\n", + " d[\"lin_rul_d\"] = (CYCLE - d[\"h_since\"]) / d[\"rate\"] # 선형 감가상각 잔여 일수(Baseline)\n", + " d[\"hs_frac\"] = d[\"h_since\"] / CYCLE\n", + " d[\"insp_eta_h\"] = d[\"h_since\"] / np.maximum(d[\"insp\"], 0.05) # 점검 마모 비율로 환산한 총 수명 추정(h)\n", + " return d\n", + "\n", + "\n", + "def snapshots(df, k=4, seed=0):\n", + " \"\"\"고장 이력 장비의 과거 시점 표본: 고장 전 임의 시점의 점검값·운용시간과 그때의 남은 일수. (생존 장비의 미래는 알 수 없으므로 고장 이력만 쓴다)\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " f = df[df[\"event\"] == 1]\n", + " out = []\n", + " for _ in range(k):\n", + " s = f.copy()\n", + " h = s[\"t_fail\"] * rng.uniform(0.2, 0.98, len(s))\n", + " ratio = h / s[\"h_since\"]\n", + " s[\"hours\"] = s[\"hours\"] - (s[\"h_since\"] - h)\n", + " s[\"km\"] = s[\"km\"] * ratio.clip(upper=1)\n", + " s[\"unpl\"] = np.floor(s[\"unpl\"] * ratio)\n", + " s[\"n_maint\"] = np.floor(s[\"n_maint\"] * ratio)\n", + " s[\"insp\"] = np.clip(h / s[\"t_fail\"] + rng.normal(0, INSP_NOISE, len(s)), 0, 1.3)\n", + " s[\"h_since\"] = h\n", + " s[\"rul_d\"] = (s[\"t_fail\"] - h) / s[\"rate\"]\n", + " s[\"event\"] = 0\n", + " out.append(s)\n", + " return pd.concat(out, ignore_index=True)\n", + "# ---------------------------------------------------------------------------\n", + "\n", + "# 1) 데이터 사전 (필드, 구분, 자료형, 허용 범위/값, 설명)\n", + "DICT = [\n", + " (\"eq_id\", \"식별\", \"str\", r\"^EQ\\d{6}$\", \"장비 식별코드(가상)\"), (\"unit\", \"식별\", \"str\", \"U1~U6\", \"가상 부대 코드\"), (\"etype\", \"식별\", \"str\", \"truck|generator|trailer|radio\", \"장비 구분(가상)\"),\n", + " (\"env\", \"환경\", \"str\", \"normal|rough|salt|cold\", \"주 운용 환경(일반/험지/염분/혹한)\"), (\"rate\", \"운용\", \"float\", \"0.5~20\", \"일 평균 운용시간(h/일)\"),\n", + " (\"hours\", \"운용\", \"float\", \"0~60000\", \"누적 운용시간(h)\"), (\"km\", \"운용\", \"float\", \"0~\", \"누적 기동거리(km)\"), (\"age_y\", \"운용\", \"float\", \"0.5~25\", \"사용 연수(년)\"),\n", + " (\"n_ovh\", \"정비\", \"int\", \"0~5\", \"창정비 이력 횟수\"), (\"h_since\", \"정비\", \"float\", \"0~hours\", \"마지막 창정비 이후 운용시간(h)\"), (\"unpl\", \"정비\", \"int\", \"0~\", \"비계획 정비 건수(긴급 포함)\"),\n", + " (\"n_maint\", \"정비\", \"int\", \"0~\", \"계획 정비 건수\"), (\"insp\", \"정비\", \"float\", \"0~1.3\", \"정비 점검 마모 비율(소모 수명/총 수명)\"),\n", + " (\"t_fail\", \"라벨\", \"float\", \"0~\", \"고장까지 운용시간 - 모의 정답(실제 운용에는 없음)\"), (\"event\", \"라벨\", \"int\", \"0|1\", \"고장 여부(1=고장 이력, 0=생존)\"),\n", + " (\"dur\", \"라벨\", \"float\", \"0~\", \"관측 운용시간(고장 시점 또는 중도절단)\"), (\"rul_h\", \"라벨\", \"float\", \"0~\", \"남은 운용시간 - 모의 정답\"), (\"rul_d\", \"라벨\", \"float\", \"0~\", \"남은 일수 - 모의 정답\"),\n", + " (\"unpl_per_kh\", \"파생\", \"float\", \"0~\", \"1000h당 비계획 정비율\"), (\"km_per_h\", \"파생\", \"float\", \"5~40\", \"운용시간당 기동거리\"), (\"k_rough\", \"파생\", \"int\", \"0|1\", \"험지 더미\"),\n", + " (\"k_salt\", \"파생\", \"int\", \"0|1\", \"염분 더미\"), (\"k_cold\", \"파생\", \"int\", \"0|1\", \"혹한 더미\"), (\"k_truck\", \"파생\", \"int\", \"0|1\", \"차량 더미\"), (\"k_gen\", \"파생\", \"int\", \"0|1\", \"발전기 더미\"),\n", + " (\"k_trailer\", \"파생\", \"int\", \"0|1\", \"트레일러 더미\"), (\"aging_idx\", \"파생\", \"float\", \"0~2\", \"환경 가중 노후 지수(1이면 주기 도달)\"), (\"lin_rul_d\", \"파생\", \"float\", \"-\", \"선형 감가상각 잔여 일수\"),\n", + " (\"hs_frac\", \"파생\", \"float\", \"0~2\", \"창정비 주기 대비 경과 비율\"), (\"insp_eta_h\", \"파생\", \"float\", \"0~\", \"점검 마모로 환산한 총 수명 추정(h)\")]\n", + "dd = pd.DataFrame(DICT, columns=[\"필드\", \"구분\", \"형\", \"허용값\", \"설명\"])\n", + "print(f\"[1] 데이터 사전 {len(dd)}개 필드 \" + str(dd[\"구분\"].value_counts().to_dict()))\n", + "LOT_SCHEMA = [(\"lot_id\", \"str\", \"시효성 부품 키트 로트 번호\"), (\"qty\", \"int\", \"재고 수량(개)\"), (\"expiry_d\", \"int\", \"유효기한까지 남은 일수\"), (\"margin\", \"int\", \"정비일 기준 최소 유효기간 여유 14일(규칙)\")]\n", + "print(\" 시효성 부품 키트 로트 스키마:\", \", \".join(f\"{a}({b})\" for a, b, _ in LOT_SCHEMA), \"- 정비 예정일에 유효기간이 부족한 키트는 쓸 수 없다\")\n", + "\n", + "# 2) 실제 생성 데이터와 대조\n", + "df = make_fleet(300, seed=1)\n", + "feat = add_features(df)\n", + "miss = sorted(set(feat.columns) - set(dd[\"필드\"]))\n", + "extra = sorted(set(dd[\"필드\"]) - set(feat.columns))\n", + "fe = dd[~dd[\"구분\"].isin([\"라벨\", \"식별\"])]\n", + "print(f\"[2] 데이터 사전 대조({len(feat)}행): 사전 누락 컬럼 {miss or '없음'}, 데이터에 없는 필드 {extra or '없음'}\")\n", + "print(f\" 예측 피처 후보(식별·라벨 제외) {len(fe)}개 -> 목표(20개 이상) {'달성' if len(fe) >= 20 else '미달'} / 창정비 규정 주기 {CYCLE:.0f}h(가정)\")\n", + "\n", + "# 3) 허용값 검증기와 오류 주입\n", + "RULES = {\"eq_id\": lambda s: s.astype(str).str.match(r\"^EQ\\d{6}$\"), \"unit\": lambda s: s.isin(UNITS), \"env\": lambda s: s.isin(ENVS), \"rate\": lambda s: s.between(0.5, 20),\n", + " \"hours\": lambda s: s.between(0, 60000), \"h_since\": lambda s: s >= 0, \"insp\": lambda s: s.between(0, 1.3), \"age_y\": lambda s: s.between(0.5, 25)}\n", + "\n", + "\n", + "def validate(d):\n", + " bad = {}\n", + " for c, f in RULES.items():\n", + " n = int((~f(d[c])).sum() + d[c].isna().sum())\n", + " if n: bad[c] = n\n", + " n = int((d[\"h_since\"] > d[\"hours\"] + 1e-6).sum())\n", + " if n: bad[\"h_since>hours(모순)\"] = n\n", + " n = int(d[\"eq_id\"].duplicated().sum())\n", + " if n: bad[\"eq_id 중복\"] = n\n", + " n = int((d[\"km\"] / d[\"hours\"].clip(lower=1) > 60).sum())\n", + " if n: bad[\"km 단위 의심\"] = n\n", + " return bad\n", + "\n", + "\n", + "print(f\"\\n[3] 정상 샘플 {len(df)}건 검증 오류 {sum(validate(df).values())}건\")\n", + "inj = {\"환경 표기 오류\": (\"env\", 0, \"험지\"), \"운용시간 음수\": (\"hours\", 1, -120.0), \"미등록 부대\": (\"unit\", 2, \"U9\"), \"점검 마모 비율 범위 초과\": (\"insp\", 3, 2.5),\n", + " \"창정비 후 시간이 누적보다 큼\": (\"h_since\", 4, 99999.0), \"km 단위 오류(m)\": (\"km\", 5, 1.2e7)}\n", + "det = 0\n", + "for name, (col, i, val) in inj.items():\n", + " d2 = copy.deepcopy(df)\n", + " d2.loc[i, col] = val\n", + " bad = validate(d2)\n", + " ok = bool(bad)\n", + " det += ok\n", + " print(f\" - {name}: {'검출' if ok else '미검출'} {bad}\")\n", + "print(f\" 주입 오류 {len(inj)}종 중 {det}종 검출\")\n", + "\n", + "# 4) 장비 수명관리 업무 프로세스 맵\n", + "proc = [(\"정비 소요·고장 이력 접수\", \"정비계획관\", \"정비 요청, 고장 보고\", \"고장 이력 표\", 30), (\"운용시간·기동거리 집계\", \"운용 담당\", \"일일 운용 보고\", \"hours, km, rate\", 25),\n", + " (\"정비 이력·점검 입력\", \"정비 담당\", \"정비·점검 기록\", \"n_maint, unpl, insp\", 25), (\"환경 가중치 적용\", \"분석 담당\", \"운용 환경 구분\", \"aging_idx\", 15), (\"잔여 수명 예측\", \"분석 담당\", \"피처 테이블\", \"RUL(일), 고장 확률\", 40),\n", + " (\"정비창 능력·예산 대조\", \"정비계획관\", \"월별 슬롯, 예산, 키트 재고\", \"정비 일정 후보\", 35), (\"정비 일정 확정·도태 건의\", \"군수지휘관\", \"추천안, 사유\", \"승인 일정\", 30), (\"정비 실행·결과 입력\", \"정비창\", \"승인 일정\", \"정비 완료 기록\", 20),\n", + " (\"예측 오차 환류(AAR)\", \"분석 담당\", \"예측 대 실적\", \"재보정 보고\", 30)]\n", + "pm = pd.DataFrame(proc, columns=[\"단계\", \"담당\", \"입력\", \"산출\", \"현행(분)\"])\n", + "pm.index = range(1, len(pm) + 1)\n", + "print(f\"\\n[4] 업무 프로세스 맵 {len(pm)}단계, 현행 합계 {pm['현행(분)'].sum()}분 (가장 긴 단계: {pm.loc[pm['현행(분)'].idxmax(), '단계']})\")\n", + "print(pm.to_string())\n", + "print(\"\\n프로세스 맵 작성 완료(9단계), 피처 목록 20개 이상 -> 목표\", \"달성\" if len(pm) >= 5 and len(fe) >= 20 else \"미달\")\n", + "print(\"모든 부대·장비·수치는 교육용 가상 자료이며 실제 부대·장비와 무관하다.\")\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 01 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + "[1] 데이터 사전 30개 필드 {'파생': 12, '정비': 5, '라벨': 5, '운용': 4, '식별': 3, '환경': 1}\n", + " 시효성 부품 키트 로트 스키마: lot_id(str), qty(int), expiry_d(int), margin(int) - 정비 예정일에 유효기간이 부족한 키트는 쓸 수 없다\n", + "[2] 데이터 사전 대조(300행): 사전 누락 컬럼 없음, 데이터에 없는 필드 없음\n", + " 예측 피처 후보(식별·라벨 제외) 22개 -> 목표(20개 이상) 달성 / 창정비 규정 주기 8000h(가정)\n", + "\n", + "[3] 정상 샘플 300건 검증 오류 0건\n", + " - 환경 표기 오류: 검출 {'env': 1}\n", + " - 운용시간 음수: 검출 {'hours': 1, 'h_since>hours(모순)': 1, 'km 단위 의심': 1}\n", + " - 미등록 부대: 검출 {'unit': 1}\n", + " - 점검 마모 비율 범위 초과: 검출 {'insp': 1}\n", + " - 창정비 후 시간이 누적보다 큼: 검출 {'h_since>hours(모순)': 1}\n", + " - km 단위 오류(m): 검출 {'km 단위 의심': 1}\n", + " 주입 오류 6종 중 6종 검출\n", + "\n", + "[4] 업무 프로세스 맵 9단계, 현행 합계 250분 (가장 긴 단계: 잔여 수명 예측)\n", + " 단계 담당 입력 산출 현행(분)\n", + "1 정비 소요·고장 이력 접수 정비계획관 정비 요청, 고장 보고 고장 이력 표 30\n", + "2 운용시간·기동거리 집계 운용 담당 일일 운용 보고 hours, km, rate 25\n", + "3 정비 이력·점검 입력 정비 담당 정비·점검 기록 n_maint, unpl, insp 25\n", + "4 환경 가중치 적용 분석 담당 운용 환경 구분 aging_idx 15\n", + "5 잔여 수명 예측 분석 담당 피처 테이블 RUL(일), 고장 확률 40\n", + "6 정비창 능력·예산 대조 정비계획관 월별 슬롯, 예산, 키트 재고 정비 일정 후보 35\n", + "7 정비 일정 확정·도태 건의 군수지휘관 추천안, 사유 승인 일정 30\n", + "8 정비 실행·결과 입력 정비창 승인 일정 정비 완료 기록 20\n", + "9 예측 오차 환류(AAR) 분석 담당 예측 대 실적 재보정 보고 30\n", + "\n", + "프로세스 맵 작성 완료(9단계), 피처 목록 20개 이상 -> 목표 달성\n", + "모든 부대·장비·수치는 교육용 가상 자료이며 실제 부대·장비와 무관하다.\n", + "```\n", + "
\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 02. 운용 환경(험지/염분/혹한) 가중치 반영 및 장비 노후화 지표 전처리 모듈 구현\n", + "**소요시간**: 약 2.0시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "운용 환경(험지/염분) 가중치를 반영한 노후화 지표 전처리 모듈을 구현한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **운용 환경(험지·염분·혹한) 가중치를 반영한 노후화 지표 전처리 모듈을 구현한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **오류를 주입한 오염 데이터에 환경 표기 통일, 중복 제거, 부호·단위 오류 보정, 결측 보정 등 정제 규칙을 순서대로 적용하고 처리 건수를 로그로 남긴다 과정을 진행하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `src/etl/sup02_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": [ + "\"\"\"표기·단위·모순·결측·중복 오류를 주입한 가상 장비 운용 기록에 운용 환경 가중치를 반영한 노후화 지표 산출을 포함한 정제 규칙 11종을 적용하고, 긴급 운용·다수 비계획 정비 같은 군수 특수 예외는 삭제하지 않고 보존하며 정합성 검증 통과율과 정제 전후 통계를 확인한다.\"\"\"\n", + "import numpy as np\n", + "import pandas as pd\n", + "\n", + "# ---------------------------------------------------------------------------\n", + "# [교육용가상자료] 가상 장비 보유 현황·고장 이력 생성기 (실제 부대·장비·수치 없음)\n", + "# 가상 부대 6개(U1~U6)의 장비가 운용 환경(normal/rough/salt/cold)에 따라 다른 속도로 노후화한다. 창정비 후 운용시간(h_since)이\n", + "# 장비마다 숨은 수명(t_fail)을 넘으면 고장(event=1) 이력, 넘지 않았으면 현재까지 생존(event=0, 관측 중도절단)이다.\n", + "# ---------------------------------------------------------------------------\n", + "ENVS = [\"normal\", \"rough\", \"salt\", \"cold\"]\n", + "ENV_W = {\"normal\": 1.00, \"rough\": 1.35, \"salt\": 1.25, \"cold\": 1.12} # 환경 가중치(숨은 정답, 가정값): 클수록 수명이 짧다\n", + "ENV_KO = {\"normal\": \"일반\", \"rough\": \"험지\", \"salt\": \"염분\", \"cold\": \"혹한\"}\n", + "UNITS = [\"U1\", \"U2\", \"U3\", \"U4\", \"U5\", \"U6\"]\n", + "UNIT_ENV = {\"U1\": [.7, .1, .1, .1], \"U2\": [.2, .6, .1, .1], \"U3\": [.2, .1, .6, .1], \"U4\": [.3, .1, .1, .5], \"U5\": [.5, .3, .1, .1], \"U6\": [.4, .1, .4, .1]}\n", + "ETYPES = [\"truck\", \"generator\", \"trailer\", \"radio\"]\n", + "CYCLE = 8000.0 # 규정 창정비 주기(운용시간 h, 가정값)\n", + "KSHAPE = 12.0 # 수명 분포 와이블 형상(마모형 고장, 클수록 수명이 모여 있다)\n", + "INSP_NOISE = 0.025 # 정비 점검 마모 비율 측정 오차(가정값)\n", + "\n", + "\n", + "def make_fleet(n=900, seed=0, env_w=None, shift=1.0):\n", + " \"\"\"장비 n대. env_w로 숨은 환경 가중치를 바꿔 운영 분포 변화를 모의할 수 있다. shift>1이면 가동률이 높아진다.\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " w = env_w or ENV_W\n", + " unit = rng.choice(UNITS, n)\n", + " env = np.array([rng.choice(ENVS, p=UNIT_ENV[u]) for u in unit])\n", + " et = rng.choice(ETYPES, n, p=[.4, .2, .2, .2])\n", + " rate = rng.lognormal(np.log(3.0 * shift), 0.25, n) # 일 평균 운용시간(h/일)\n", + " h_since = rng.uniform(300, 11500, n) # 마지막 창정비 이후 운용시간\n", + " n_ovh = rng.integers(0, 3, n)\n", + " hours = h_since + n_ovh * rng.uniform(5000, 8000, n) # 누적 운용시간\n", + " km = hours * rng.normal(17, 3, n) * np.where(env == \"rough\", 0.8, 1.0) # 누적 기동거리(km)\n", + " age_y = np.clip(hours / (rate * 365) * rng.uniform(0.9, 1.15, n), 0.5, 25)\n", + " unpl = rng.poisson(0.35 * h_since / 1000 * np.array([w[e] for e in env]) / 1.3) # 비계획 정비 건수(환경이 가혹할수록 많다)\n", + " n_maint = rng.poisson(0.6 * h_since / 1000 + 1) # 계획 정비 건수\n", + " scale = 9600 / np.array([w[e] for e in env]) * 0.985 ** unpl * np.exp(rng.normal(0, 0.04, n))\n", + " t_fail = scale * rng.weibull(KSHAPE, n) # 창정비 후 고장까지 운용시간(숨은 정답)\n", + " event = (t_fail <= h_since).astype(int)\n", + " dur = np.where(event == 1, t_fail, h_since)\n", + " df = pd.DataFrame({\"eq_id\": [f\"EQ{seed:02d}{i:04d}\" for i in range(n)], \"unit\": unit, \"env\": env, \"etype\": et, \"rate\": rate, \"hours\": hours, \"km\": km, \"age_y\": age_y, \"n_ovh\": n_ovh,\n", + " \"h_since\": h_since, \"unpl\": unpl, \"n_maint\": n_maint, \"t_fail\": t_fail, \"event\": event, \"dur\": dur})\n", + " df[\"insp\"] = np.clip(dur / t_fail + rng.normal(0, INSP_NOISE, n), 0, 1.3) # 마지막 정비 점검에서 측정한 마모 비율(소모 수명/총 수명, 오차 포함)\n", + " df[\"rul_h\"] = np.where(event == 0, t_fail - h_since, np.nan) # 생존 장비의 남은 운용시간(정답)\n", + " df[\"rul_d\"] = df[\"rul_h\"] / df[\"rate\"] # 남은 일수(정답)\n", + " return df\n", + "\n", + "\n", + "def mape(y, p):\n", + " y, p = np.asarray(y, float), np.asarray(p, float)\n", + " return float(np.mean(np.abs(y - p) / np.abs(y)) * 100)\n", + "\n", + "\n", + "def mae(y, p):\n", + " return float(np.mean(np.abs(np.asarray(y, float) - np.asarray(p, float))))\n", + "\n", + "\n", + "def rmse(y, p):\n", + " return float(np.sqrt(np.mean((np.asarray(y, float) - np.asarray(p, float)) ** 2)))\n", + "# ---------------------------------------------------------------------------\n", + "# ---------------------------------------------------------------------------\n", + "# 노후화 파생 피처와 학습 표본 만들기. 환경 가중치 W0는 도입 시점의 전문가 추정값이며(실제 가중치와 다름) M9에서 재보정한다.\n", + "# ---------------------------------------------------------------------------\n", + "W0 = {\"normal\": 1.00, \"rough\": 1.15, \"salt\": 1.10, \"cold\": 1.05}\n", + "FEATS = [\"h_since\", \"hours\", \"km\", \"age_y\", \"rate\", \"n_maint\", \"unpl\", \"unpl_per_kh\", \"km_per_h\", \"n_ovh\", \"k_rough\", \"k_salt\", \"k_cold\", \"k_truck\", \"k_gen\", \"k_trailer\",\n", + " \"aging_idx\", \"lin_rul_d\", \"hs_frac\", \"insp\", \"insp_eta_h\"]\n", + "\n", + "\n", + "def add_features(df, w=None):\n", + " d = df.copy()\n", + " w = w or W0\n", + " d[\"unpl_per_kh\"] = d[\"unpl\"] / (d[\"h_since\"] / 1000 + 0.5)\n", + " d[\"km_per_h\"] = d[\"km\"] / d[\"hours\"]\n", + " for e in (\"rough\", \"salt\", \"cold\"):\n", + " d[\"k_\" + e] = (d[\"env\"] == e).astype(int)\n", + " d[\"k_truck\"], d[\"k_gen\"], d[\"k_trailer\"] = (d[\"etype\"] == \"truck\").astype(int), (d[\"etype\"] == \"generator\").astype(int), (d[\"etype\"] == \"trailer\").astype(int)\n", + " d[\"aging_idx\"] = d[\"h_since\"] * d[\"env\"].map(w) / CYCLE # 환경 가중 노후 지수(1이면 창정비 주기에 도달)\n", + " d[\"lin_rul_d\"] = (CYCLE - d[\"h_since\"]) / d[\"rate\"] # 선형 감가상각 잔여 일수(Baseline)\n", + " d[\"hs_frac\"] = d[\"h_since\"] / CYCLE\n", + " d[\"insp_eta_h\"] = d[\"h_since\"] / np.maximum(d[\"insp\"], 0.05) # 점검 마모 비율로 환산한 총 수명 추정(h)\n", + " return d\n", + "\n", + "\n", + "def snapshots(df, k=4, seed=0):\n", + " \"\"\"고장 이력 장비의 과거 시점 표본: 고장 전 임의 시점의 점검값·운용시간과 그때의 남은 일수. (생존 장비의 미래는 알 수 없으므로 고장 이력만 쓴다)\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " f = df[df[\"event\"] == 1]\n", + " out = []\n", + " for _ in range(k):\n", + " s = f.copy()\n", + " h = s[\"t_fail\"] * rng.uniform(0.2, 0.98, len(s))\n", + " ratio = h / s[\"h_since\"]\n", + " s[\"hours\"] = s[\"hours\"] - (s[\"h_since\"] - h)\n", + " s[\"km\"] = s[\"km\"] * ratio.clip(upper=1)\n", + " s[\"unpl\"] = np.floor(s[\"unpl\"] * ratio)\n", + " s[\"n_maint\"] = np.floor(s[\"n_maint\"] * ratio)\n", + " s[\"insp\"] = np.clip(h / s[\"t_fail\"] + rng.normal(0, INSP_NOISE, len(s)), 0, 1.3)\n", + " s[\"h_since\"] = h\n", + " s[\"rul_d\"] = (s[\"t_fail\"] - h) / s[\"rate\"]\n", + " s[\"event\"] = 0\n", + " out.append(s)\n", + " return pd.concat(out, ignore_index=True)\n", + "# ---------------------------------------------------------------------------\n", + "\n", + "truth = make_fleet(220, seed=5)\n", + "rng = np.random.default_rng(2)\n", + "raw = truth.drop(columns=[\"t_fail\", \"event\", \"dur\", \"rul_h\", \"rul_d\"]).copy()\n", + "raw[\"urgent_ops\"] = False\n", + "# 긴급 운용(가동률이 비정상적으로 높지만 실제 사실인 기록): 삭제하면 안 된다\n", + "urg = rng.choice(len(raw), 8, replace=False)\n", + "raw.loc[urg, \"rate\"] = rng.uniform(13, 17, 8)\n", + "raw.loc[urg, \"urgent_ops\"] = True\n", + "# 오류 주입\n", + "errs = {}\n", + "i = rng.choice(len(raw), 12, replace=False); raw.loc[i, \"env\"] = rng.choice([\"험지\", \"ROUGH\", \"Salt \", \"혹한\", \"일반\", \"normal \"], 12); errs[\"환경 표기\"] = 12\n", + "i = rng.choice(len(raw), 8, replace=False); raw.loc[i, \"unit\"] = rng.choice([\"u3\", \"U 2\", \"U9\", \"u5\"], 8); errs[\"부대 코드\"] = 8\n", + "i = rng.choice(len(raw), 6, replace=False); raw.loc[i, \"hours\"] = -raw.loc[i, \"hours\"]; errs[\"운용시간 음수\"] = 6\n", + "i = rng.choice(len(raw), 7, replace=False); raw.loc[i, \"h_since\"] = raw.loc[i, \"hours\"] * rng.uniform(1.3, 2.0, 7); errs[\"창정비 후 시간 > 누적\"] = 7\n", + "i = rng.choice(len(raw), 6, replace=False); raw.loc[i, \"km\"] = raw.loc[i, \"km\"] * 1000; errs[\"km 단위(m)\"] = 6\n", + "i = rng.choice(len(raw), 9, replace=False); raw.loc[i, \"rate\"] = np.nan; errs[\"가동률 결측\"] = 9\n", + "i = rng.choice(len(raw), 9, replace=False); raw.loc[i, \"insp\"] = np.nan; errs[\"점검 마모 결측\"] = 9\n", + "i = rng.choice(len(raw), 5, replace=False); raw.loc[i, \"insp\"] = rng.uniform(1.8, 3.0, 5); errs[\"점검 마모 범위 초과\"] = 5\n", + "raw = pd.concat([raw, raw.iloc[rng.choice(len(raw), 7, replace=False)]], ignore_index=True); errs[\"중복 행\"] = 7\n", + "raw[\"eq_id\"] = raw[\"eq_id\"].where(rng.random(len(raw)) > 0.05, raw[\"eq_id\"].str.lower()); errs[\"ID 표기\"] = int((raw[\"eq_id\"].str.islower()).sum())\n", + "print(f\"[0] 오염 데이터 {len(raw)}행(원본 {len(truth)}행), 주입 오류: \" + \", \".join(f\"{k} {v}\" for k, v in errs.items()) + f\", 긴급 운용 정상 기록 {len(urg)}행\")\n", + "\n", + "# ---------------- 정제 규칙 ----------------\n", + "log = []\n", + "d = raw.copy()\n", + "\n", + "\n", + "def rule(no, desc, n):\n", + " log.append((no, desc, int(n)))\n", + "\n", + "\n", + "n0 = len(d)\n", + "d[\"eq_id\"] = d[\"eq_id\"].str.upper().str.strip()\n", + "rule(1, \"장비 ID 대문자·공백 통일\", (raw[\"eq_id\"] != d[\"eq_id\"]).sum())\n", + "d = d.drop_duplicates(\"eq_id\").reset_index(drop=True)\n", + "rule(2, \"중복 장비 행 제거\", n0 - len(d))\n", + "d[\"unit\"] = d[\"unit\"].astype(str).str.upper().str.replace(\" \", \"\")\n", + "d[\"env\"] = d[\"env\"].astype(str).str.strip()\n", + "EMAP = {\"험지\": \"rough\", \"rough\": \"rough\", \"salt\": \"salt\", \"혹한\": \"cold\", \"cold\": \"cold\", \"일반\": \"normal\", \"normal\": \"normal\", \"염분\": \"salt\"}\n", + "env_fix = d[\"env\"].str.lower().map(EMAP)\n", + "rule(3, \"운용 환경 표기 표준화(한글·대소문자·공백)\", (env_fix != d[\"env\"]).sum())\n", + "d[\"env\"] = env_fix.fillna(\"normal\")\n", + "iso = ~d[\"unit\"].isin(UNITS)\n", + "quarantine = d[iso].copy()\n", + "d = d[~iso].reset_index(drop=True)\n", + "rule(4, \"미등록 부대 코드 행 격리(삭제하지 않고 별도 보관)\", iso.sum())\n", + "neg = d[\"hours\"] < 0\n", + "d.loc[neg, \"hours\"] = -d.loc[neg, \"hours\"]\n", + "rule(5, \"누적 운용시간 부호 오류 보정\", neg.sum())\n", + "bad_hs = d[\"h_since\"] > d[\"hours\"]\n", + "d.loc[bad_hs, \"h_since\"] = d.loc[bad_hs, \"hours\"]\n", + "rule(6, \"창정비 후 운용시간 > 누적 운용시간 모순 보정\", bad_hs.sum())\n", + "unit_km = d[\"km\"] / d[\"hours\"].clip(lower=1) > 100\n", + "d.loc[unit_km, \"km\"] = d.loc[unit_km, \"km\"] / 1000\n", + "rule(7, \"기동거리 단위 오류(m -> km) 보정\", unit_km.sum())\n", + "nan_rate = d[\"rate\"].isna()\n", + "d[\"rate\"] = d[\"rate\"].fillna(d.groupby([\"etype\"])[\"rate\"].transform(\"median\"))\n", + "rule(8, \"일 평균 운용시간 결측을 장비 구분 중앙값으로 보정\", nan_rate.sum())\n", + "out_i = d[\"insp\"] > 1.3\n", + "d.loc[out_i, \"insp\"] = np.nan\n", + "miss_i = d[\"insp\"].isna()\n", + "d[\"insp_flag\"] = np.where(miss_i, \"추정\", \"측정\")\n", + "d[\"insp\"] = d[\"insp\"].fillna((d[\"h_since\"] / CYCLE * d[\"env\"].map(W0)).clip(0, 1.3))\n", + "rule(9, \"점검 마모 비율 범위 초과 제거 후 결측을 환경 가중 노후 지수로 대체(추정 표시)\", miss_i.sum())\n", + "exc = (d[\"rate\"] > 12) | d[\"urgent_ops\"]\n", + "d[\"exception\"] = np.where(exc, \"긴급 운용 보존\", \"\")\n", + "rule(10, \"긴급 운용(가동률 12h/일 초과) 예외 표시 후 이상치 삭제 없이 보존\", exc.sum())\n", + "d[\"aging_idx\"] = d[\"h_since\"] * d[\"env\"].map(W0) / CYCLE\n", + "d[\"eff_hours\"] = d[\"h_since\"] * d[\"env\"].map(W0)\n", + "rule(11, \"환경 가중치 반영 노후화 지표(aging_idx, eff_hours) 산출\", len(d))\n", + "print(\"\\n[1] 정제 규칙 적용 로그\")\n", + "print(pd.DataFrame(log, columns=[\"규칙\", \"내용\", \"처리 건수\"]).to_string(index=False))\n", + "print(f\" 정제 규칙 {len(log)}종 적용(목표 10종 이상: {'달성' if len(log) >= 10 else '미달'}), 격리 {len(quarantine)}행은 담당자 확인 대기\")\n", + "\n", + "# ---------------- 정합성 검증 ----------------\n", + "checks = {\"장비 ID 유일\": not d[\"eq_id\"].duplicated().any(), \"부대 코드 등록\": bool(d[\"unit\"].isin(UNITS).all()), \"환경 값 4종\": bool(d[\"env\"].isin(ENVS).all()),\n", + " \"운용시간 0 이상\": bool((d[\"hours\"] >= 0).all()), \"창정비 후 <= 누적\": bool((d[\"h_since\"] <= d[\"hours\"] + 1e-6).all()), \"기동 속도 5~40km/h\": bool((d[\"km\"] / d[\"hours\"].clip(lower=1)).between(5, 40).all()),\n", + " \"점검 마모 0~1.3\": bool(d[\"insp\"].between(0, 1.3).all()), \"필수 컬럼 결측 0\": int(d[[\"rate\", \"insp\", \"aging_idx\"]].isna().sum().sum()) == 0}\n", + "print(\"\\n[2] 정합성 검증\")\n", + "for k, v in checks.items():\n", + " print(f\" [{'PASS' if v else 'FAIL'}] {k}\")\n", + "print(f\" 통과율 {sum(checks.values()) / len(checks):.0%} (목표 100%)\")\n", + "\n", + "# ---------------- 복원 정확도·예외 보존·전후 통계 ----------------\n", + "t = truth.set_index(\"eq_id\").loc[d[\"eq_id\"].isin(truth[\"eq_id\"]).pipe(lambda m: d.loc[m, \"eq_id\"])]\n", + "dd = d.set_index(\"eq_id\").loc[t.index]\n", + "print(f\"\\n[3] 복원 정확도(원본과 비교): 누적 운용시간 오차 {np.abs(dd['hours'] - t['hours']).mean():.1f}h, 기동거리 오차 {np.abs(dd['km'] - t['km']).mean():.1f}km, 가동률 오차 {np.abs(dd['rate'] - t['rate'])[dd['exception'] == ''].mean():.3f}h/일(긴급 운용 제외)\")\n", + "print(f\" 긴급 운용 기록 {int((d['exception'] != '').sum())}행 보존(삭제 0행), 점검 마모 추정 대체 {int((d['insp_flag'] == '추정').sum())}행은 추정 표시\")\n", + "rows = [(\"운용시간 최솟값(h)\", raw[\"hours\"].min(), d[\"hours\"].min(), truth[\"hours\"].min()), (\"운용시간 평균(h)\", raw[\"hours\"].mean(), d[\"hours\"].mean(), truth[\"hours\"].mean()),\n", + " (\"기동거리 최댓값(km)\", raw[\"km\"].max(), d[\"km\"].max(), truth[\"km\"].max()), (\"가동률 유효 건수\", raw[\"rate\"].notna().sum(), d[\"rate\"].notna().sum(), truth[\"rate\"].notna().sum()),\n", + " (\"가동률 최댓값(h/일)\", raw[\"rate\"].max(), d[\"rate\"].max(), truth[\"rate\"].max()), (\"점검 마모 최댓값\", raw[\"insp\"].max(), d[\"insp\"].max(), truth[\"insp\"].max())]\n", + "print(\"[4] 전후 기술통계(오염 / 정제 후 / 원본)\")\n", + "print(pd.DataFrame(rows, columns=[\"항목\", \"오염\", \"정제 후\", \"원본\"]).round(2).to_string(index=False))\n", + "w = d.groupby(\"env\")[\"aging_idx\"].mean().round(3)\n", + "print(\" 환경별 평균 노후 지수(가중치 W0 적용):\", w.to_dict())\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 02 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + "[0] 오염 데이터 227행(원본 220행), 주입 오류: 환경 표기 12, 부대 코드 8, 운용시간 음수 6, 창정비 후 시간 > 누적 7, km 단위(m) 6, 가동률 결측 9, 점검 마모 결측 9, 점검 마모 범위 초과 5, 중복 행 7, ID 표기 7, 긴급 운용 정상 기록 8행\n", + "\n", + "[1] 정제 규칙 적용 로그\n", + " 규칙 내용 처리 건수\n", + " 1 장비 ID 대문자·공백 통일 7\n", + " 2 중복 장비 행 제거 7\n", + " 3 운용 환경 표기 표준화(한글·대소문자·공백) 10\n", + " 4 미등록 부대 코드 행 격리(삭제하지 않고 별도 보관) 1\n", + " 5 누적 운용시간 부호 오류 보정 6\n", + " 6 창정비 후 운용시간 > 누적 운용시간 모순 보정 7\n", + " 7 기동거리 단위 오류(m -> km) 보정 6\n", + " 8 일 평균 운용시간 결측을 장비 구분 중앙값으로 보정 9\n", + " 9 점검 마모 비율 범위 초과 제거 후 결측을 환경 가중 노후 지수로 대체(추정 표시) 13\n", + " 10 긴급 운용(가동률 12h/일 초과) 예외 표시 후 이상치 삭제 없이 보존 8\n", + " 11 환경 가중치 반영 노후화 지표(aging_idx, eff_hours) 산출 219\n", + " 정제 규칙 11종 적용(목표 10종 이상: 달성), 격리 1행은 담당자 확인 대기\n", + "\n", + "[2] 정합성 검증\n", + " [PASS] 장비 ID 유일\n", + " [PASS] 부대 코드 등록\n", + " [PASS] 환경 값 4종\n", + " [PASS] 운용시간 0 이상\n", + " [PASS] 창정비 후 <= 누적\n", + " [PASS] 기동 속도 5~40km/h\n", + " [PASS] 점검 마모 0~1.3\n", + " [PASS] 필수 컬럼 결측 0\n", + " 통과율 100% (목표 100%)\n", + "\n", + "[3] 복원 정확도(원본과 비교): 누적 운용시간 오차 0.0h, 기동거리 오차 0.0km, 가동률 오차 0.016h/일(긴급 운용 제외)\n", + " 긴급 운용 기록 8행 보존(삭제 0행), 점검 마모 추정 대체 13행은 추정 표시\n", + "[4] 전후 기술통계(오염 / 정제 후 / 원본)\n", + " 항목 오염 정제 후 원본\n", + " 운용시간 최솟값(h) -25733.90 485.39 485.39\n", + " 운용시간 평균(h) 11623.87 12523.40 12475.96\n", + "기동거리 최댓값(km) 476232660.71 490734.91 490734.91\n", + " 가동률 유효 건수 218.00 219.00 220.00\n", + "가동률 최댓값(h/일) 15.68 15.68 6.53\n", + " 점검 마모 최댓값 2.34 1.30 1.08\n", + " 환경별 평균 노후 지수(가중치 W0 적용): {'cold': 0.8, 'normal': 0.848, 'rough': 0.846, 'salt': 0.83}\n", + "```\n", + "
\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 03. 선형 감가상각 기반 1차 잔여 수명(RUL) 계산 Baseline 알고리즘 구현\n", + "**소요시간**: 약 2.5시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "선형 감가상각 기반 1차 잔여 수명(RUL) 계산 Baseline 알고리즘을 구현한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **선형 감가상각 기반 1차 잔여 수명(RUL) Baseline을 구현하고 EDA로 오차 요인을 찾는다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **규정 주기에서 창정비 후 운용시간을 빼고 일 가동률로 나눠 잔여 수명 산출하고 모델에 적용하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `notebooks/sup02_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": [ + "\"\"\"선형 감가상각(규정 창정비 주기 - 경과 운용시간) 기반 잔여 수명(RUL) Baseline을 구현해 오차와 환경별 편향·한계를 확인하고, EDA 차트 8종·상관분석(p-value)·환경별 차이 검정·부대별 위험 파레토로 핵심 노후화 요인을 찾는다.\"\"\"\n", + "import numpy as np\n", + "import pandas as pd\n", + "import matplotlib.pyplot as plt\n", + "from scipy import stats\n", + "from sklearn.metrics import r2_score\n", + "\n", + "# ---------------------------------------------------------------------------\n", + "# [교육용가상자료] 가상 장비 보유 현황·고장 이력 생성기 (실제 부대·장비·수치 없음)\n", + "# 가상 부대 6개(U1~U6)의 장비가 운용 환경(normal/rough/salt/cold)에 따라 다른 속도로 노후화한다. 창정비 후 운용시간(h_since)이\n", + "# 장비마다 숨은 수명(t_fail)을 넘으면 고장(event=1) 이력, 넘지 않았으면 현재까지 생존(event=0, 관측 중도절단)이다.\n", + "# ---------------------------------------------------------------------------\n", + "ENVS = [\"normal\", \"rough\", \"salt\", \"cold\"]\n", + "ENV_W = {\"normal\": 1.00, \"rough\": 1.35, \"salt\": 1.25, \"cold\": 1.12} # 환경 가중치(숨은 정답, 가정값): 클수록 수명이 짧다\n", + "ENV_KO = {\"normal\": \"일반\", \"rough\": \"험지\", \"salt\": \"염분\", \"cold\": \"혹한\"}\n", + "UNITS = [\"U1\", \"U2\", \"U3\", \"U4\", \"U5\", \"U6\"]\n", + "UNIT_ENV = {\"U1\": [.7, .1, .1, .1], \"U2\": [.2, .6, .1, .1], \"U3\": [.2, .1, .6, .1], \"U4\": [.3, .1, .1, .5], \"U5\": [.5, .3, .1, .1], \"U6\": [.4, .1, .4, .1]}\n", + "ETYPES = [\"truck\", \"generator\", \"trailer\", \"radio\"]\n", + "CYCLE = 8000.0 # 규정 창정비 주기(운용시간 h, 가정값)\n", + "KSHAPE = 12.0 # 수명 분포 와이블 형상(마모형 고장, 클수록 수명이 모여 있다)\n", + "INSP_NOISE = 0.025 # 정비 점검 마모 비율 측정 오차(가정값)\n", + "\n", + "\n", + "def make_fleet(n=900, seed=0, env_w=None, shift=1.0):\n", + " \"\"\"장비 n대. env_w로 숨은 환경 가중치를 바꿔 운영 분포 변화를 모의할 수 있다. shift>1이면 가동률이 높아진다.\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " w = env_w or ENV_W\n", + " unit = rng.choice(UNITS, n)\n", + " env = np.array([rng.choice(ENVS, p=UNIT_ENV[u]) for u in unit])\n", + " et = rng.choice(ETYPES, n, p=[.4, .2, .2, .2])\n", + " rate = rng.lognormal(np.log(3.0 * shift), 0.25, n) # 일 평균 운용시간(h/일)\n", + " h_since = rng.uniform(300, 11500, n) # 마지막 창정비 이후 운용시간\n", + " n_ovh = rng.integers(0, 3, n)\n", + " hours = h_since + n_ovh * rng.uniform(5000, 8000, n) # 누적 운용시간\n", + " km = hours * rng.normal(17, 3, n) * np.where(env == \"rough\", 0.8, 1.0) # 누적 기동거리(km)\n", + " age_y = np.clip(hours / (rate * 365) * rng.uniform(0.9, 1.15, n), 0.5, 25)\n", + " unpl = rng.poisson(0.35 * h_since / 1000 * np.array([w[e] for e in env]) / 1.3) # 비계획 정비 건수(환경이 가혹할수록 많다)\n", + " n_maint = rng.poisson(0.6 * h_since / 1000 + 1) # 계획 정비 건수\n", + " scale = 9600 / np.array([w[e] for e in env]) * 0.985 ** unpl * np.exp(rng.normal(0, 0.04, n))\n", + " t_fail = scale * rng.weibull(KSHAPE, n) # 창정비 후 고장까지 운용시간(숨은 정답)\n", + " event = (t_fail <= h_since).astype(int)\n", + " dur = np.where(event == 1, t_fail, h_since)\n", + " df = pd.DataFrame({\"eq_id\": [f\"EQ{seed:02d}{i:04d}\" for i in range(n)], \"unit\": unit, \"env\": env, \"etype\": et, \"rate\": rate, \"hours\": hours, \"km\": km, \"age_y\": age_y, \"n_ovh\": n_ovh,\n", + " \"h_since\": h_since, \"unpl\": unpl, \"n_maint\": n_maint, \"t_fail\": t_fail, \"event\": event, \"dur\": dur})\n", + " df[\"insp\"] = np.clip(dur / t_fail + rng.normal(0, INSP_NOISE, n), 0, 1.3) # 마지막 정비 점검에서 측정한 마모 비율(소모 수명/총 수명, 오차 포함)\n", + " df[\"rul_h\"] = np.where(event == 0, t_fail - h_since, np.nan) # 생존 장비의 남은 운용시간(정답)\n", + " df[\"rul_d\"] = df[\"rul_h\"] / df[\"rate\"] # 남은 일수(정답)\n", + " return df\n", + "\n", + "\n", + "def mape(y, p):\n", + " y, p = np.asarray(y, float), np.asarray(p, float)\n", + " return float(np.mean(np.abs(y - p) / np.abs(y)) * 100)\n", + "\n", + "\n", + "def mae(y, p):\n", + " return float(np.mean(np.abs(np.asarray(y, float) - np.asarray(p, float))))\n", + "\n", + "\n", + "def rmse(y, p):\n", + " return float(np.sqrt(np.mean((np.asarray(y, float) - np.asarray(p, float)) ** 2)))\n", + "# ---------------------------------------------------------------------------\n", + "# ---------------------------------------------------------------------------\n", + "# 노후화 파생 피처와 학습 표본 만들기. 환경 가중치 W0는 도입 시점의 전문가 추정값이며(실제 가중치와 다름) M9에서 재보정한다.\n", + "# ---------------------------------------------------------------------------\n", + "W0 = {\"normal\": 1.00, \"rough\": 1.15, \"salt\": 1.10, \"cold\": 1.05}\n", + "FEATS = [\"h_since\", \"hours\", \"km\", \"age_y\", \"rate\", \"n_maint\", \"unpl\", \"unpl_per_kh\", \"km_per_h\", \"n_ovh\", \"k_rough\", \"k_salt\", \"k_cold\", \"k_truck\", \"k_gen\", \"k_trailer\",\n", + " \"aging_idx\", \"lin_rul_d\", \"hs_frac\", \"insp\", \"insp_eta_h\"]\n", + "\n", + "\n", + "def add_features(df, w=None):\n", + " d = df.copy()\n", + " w = w or W0\n", + " d[\"unpl_per_kh\"] = d[\"unpl\"] / (d[\"h_since\"] / 1000 + 0.5)\n", + " d[\"km_per_h\"] = d[\"km\"] / d[\"hours\"]\n", + " for e in (\"rough\", \"salt\", \"cold\"):\n", + " d[\"k_\" + e] = (d[\"env\"] == e).astype(int)\n", + " d[\"k_truck\"], d[\"k_gen\"], d[\"k_trailer\"] = (d[\"etype\"] == \"truck\").astype(int), (d[\"etype\"] == \"generator\").astype(int), (d[\"etype\"] == \"trailer\").astype(int)\n", + " d[\"aging_idx\"] = d[\"h_since\"] * d[\"env\"].map(w) / CYCLE # 환경 가중 노후 지수(1이면 창정비 주기에 도달)\n", + " d[\"lin_rul_d\"] = (CYCLE - d[\"h_since\"]) / d[\"rate\"] # 선형 감가상각 잔여 일수(Baseline)\n", + " d[\"hs_frac\"] = d[\"h_since\"] / CYCLE\n", + " d[\"insp_eta_h\"] = d[\"h_since\"] / np.maximum(d[\"insp\"], 0.05) # 점검 마모 비율로 환산한 총 수명 추정(h)\n", + " return d\n", + "\n", + "\n", + "def snapshots(df, k=4, seed=0):\n", + " \"\"\"고장 이력 장비의 과거 시점 표본: 고장 전 임의 시점의 점검값·운용시간과 그때의 남은 일수. (생존 장비의 미래는 알 수 없으므로 고장 이력만 쓴다)\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " f = df[df[\"event\"] == 1]\n", + " out = []\n", + " for _ in range(k):\n", + " s = f.copy()\n", + " h = s[\"t_fail\"] * rng.uniform(0.2, 0.98, len(s))\n", + " ratio = h / s[\"h_since\"]\n", + " s[\"hours\"] = s[\"hours\"] - (s[\"h_since\"] - h)\n", + " s[\"km\"] = s[\"km\"] * ratio.clip(upper=1)\n", + " s[\"unpl\"] = np.floor(s[\"unpl\"] * ratio)\n", + " s[\"n_maint\"] = np.floor(s[\"n_maint\"] * ratio)\n", + " s[\"insp\"] = np.clip(h / s[\"t_fail\"] + rng.normal(0, INSP_NOISE, len(s)), 0, 1.3)\n", + " s[\"h_since\"] = h\n", + " s[\"rul_d\"] = (s[\"t_fail\"] - h) / s[\"rate\"]\n", + " s[\"event\"] = 0\n", + " out.append(s)\n", + " return pd.concat(out, ignore_index=True)\n", + "# ---------------------------------------------------------------------------\n", + "\n", + "df = make_fleet(900, seed=1)\n", + "d = add_features(df)\n", + "al = d[d.event == 0].copy()\n", + "al[\"err\"] = al[\"lin_rul_d\"] - al[\"rul_d\"] # 양수 = Baseline이 수명을 길게 본다(위험한 과대 예측)\n", + "big = al[\"rul_d\"] >= 90\n", + "print(f\"[1] 가상 장비 {len(d)}대(고장 이력 {int(d.event.sum())}대, 생존 {len(al)}대). Baseline RUL(일) = (규정 주기 {CYCLE:.0f}h - 창정비 후 운용시간) / 일 가동률\")\n", + "print(f\" 생존 장비 평가: MAE {mae(al.rul_d, al.lin_rul_d):.0f}일, RMSE {rmse(al.rul_d, al.lin_rul_d):.0f}일, MAPE(참 RUL 90일 이상) {mape(al.rul_d[big], al.lin_rul_d[big]):.1f}%, R2 {r2_score(al.rul_d, al.lin_rul_d):.3f}, 평균 편향 {al.err.mean():+.0f}일\")\n", + "over = (al[\"lin_rul_d\"] > al[\"rul_d\"] + 30)\n", + "print(f\" Baseline이 실제보다 30일 넘게 길게 본 장비 {int(over.sum())}대({over.mean():.0%}) - 고장 전에 정비 시점을 놓칠 위험\")\n", + "fail_before = d[(d.event == 1) & (d.t_fail < CYCLE)]\n", + "print(f\" 고장 이력 {int(d.event.sum())}대 중 규정 주기({CYCLE:.0f}h) 이전에 고장 난 장비 {len(fail_before)}대({len(fail_before) / d.event.sum():.0%}) -> 고정 주기는 가혹 환경 장비를 지키지 못한다\")\n", + "\n", + "# 조건별 오차\n", + "conds = {\"일반 환경\": al.env == \"normal\", \"험지\": al.env == \"rough\", \"염분\": al.env == \"salt\", \"혹한\": al.env == \"cold\", \"비계획 정비율 상위 25%\": al.unpl_per_kh >= al.unpl_per_kh.quantile(.75),\n", + " \"일 가동률 4h 이상\": al.rate >= 4, \"점검 마모 0.8 이상(노후)\": al.insp >= 0.8}\n", + "ct = pd.DataFrame([(k, int(m.sum()), mae(al.rul_d[m], al.lin_rul_d[m]), al.err[m].mean()) for k, m in conds.items()], columns=[\"조건\", \"대수\", \"MAE(일)\", \"평균 편향(일)\"])\n", + "print(\"\\n[2] 조건별 Baseline 오차(양수 = 과대 예측)\\n\" + ct.round(0).to_string(index=False))\n", + "print(\" -> 험지·염분 장비는 수명을 길게(위험), 일반 환경 장비는 짧게(과잉 정비) 보아 환경별 편향 방향이 반대다. 환경 가중치 미반영이 한계다.\")\n", + "\n", + "# 상관분석\n", + "rows = []\n", + "for c in FEATS:\n", + " if al[c].nunique() < 2: continue\n", + " r, p = stats.spearmanr(al[c], al[\"err\"])\n", + " rows.append((c, r, p))\n", + "cr = pd.DataFrame(rows, columns=[\"피처\", \"스피어만 상관\", \"p-value\"]).assign(abs_r=lambda x: x[\"스피어만 상관\"].abs()).sort_values(\"abs_r\", ascending=False)\n", + "sig = cr[(cr[\"abs_r\"] >= 0.2) & (cr[\"p-value\"] < 0.05)]\n", + "print(f\"\\n[3] Baseline 오차(부호 있음: 양수 = 과대 예측)와 유의한 상관(|r|>=0.2, p<0.05) 피처 {len(sig)}개 (목표 5개 이상: {'달성' if len(sig) >= 5 else '미달'})\")\n", + "print(sig.head(8)[[\"피처\", \"스피어만 상관\", \"p-value\"]].round(4).to_string(index=False))\n", + "print(\" (lin_rul_d·h_since·hs_frac는 Baseline 산식과 직접 겹치므로 해석에서는 환경 더미·점검 마모·기동 강도 같은 독립 요인을 본다)\")\n", + "hi, lo = al[al.env.isin([\"rough\", \"salt\"])][\"err\"], al[al.env.isin([\"normal\"])][\"err\"]\n", + "u = stats.mannwhitneyu(hi, lo)\n", + "kw = stats.kruskal(*[g[\"insp_eta_h\"].values for _, g in al.groupby(\"env\")])\n", + "print(f\" 가설검정: 험지·염분 vs 일반 환경 Baseline 편향 평균 {hi.mean():+.0f}일 vs {lo.mean():+.0f}일(Mann-Whitney p={u.pvalue:.1e}), 환경별 점검 환산 총 수명 차이 Kruskal p={kw.pvalue:.1e}\")\n", + "\n", + "# 파레토: 부대별 고위험(참 RUL 180일 미만) 장비\n", + "risk = al[al.rul_d < 180].groupby(\"unit\").size().sort_values(ascending=False)\n", + "cum = risk.cumsum() / risk.sum()\n", + "print(f\"\\n[4] 파레토: 참 RUL 180일 미만 고위험 장비 {int(risk.sum())}대, 상위 {int((cum <= 0.8).sum() + 1)}개 부대가 80% 차지 -> \" + \", \".join(f\"{u} {n}대\" for u, n in risk.items()))\n", + "print(\" 핵심 발견: 환경 가중치와 정비 이력·점검 마모가 Baseline이 놓치는 요인이며, 이는 M4 피처 설계(환경 더미, 비계획 정비율, 점검 마모)의 근거가 된다.\")\n", + "\n", + "fig, ax = plt.subplots(2, 4, figsize=(18, 8))\n", + "ax[0, 0].boxplot([d.loc[d.env == e, \"insp_eta_h\"] for e in ENVS]); ax[0, 0].set_xticklabels([\"normal\", \"rough\", \"salt\", \"cold\"]); ax[0, 0].set_title(\"1 Estimated total life by environment (h)\")\n", + "ax[0, 1].scatter(d.h_since, d.dur, c=d.event, s=6, cmap=\"coolwarm\"); ax[0, 1].set_title(\"2 Hours since overhaul vs observed (red = failed)\")\n", + "ax[0, 2].hist(d.rate, 30); ax[0, 2].set_title(\"3 Daily usage rate (h/day)\")\n", + "ax[0, 3].scatter(d.h_since, d.unpl, s=6, c=d.env.map({e: i for i, e in enumerate(ENVS)})); ax[0, 3].set_title(\"4 Unplanned maintenance vs hours\")\n", + "ax[1, 0].bar(range(len(ct)), ct[\"평균 편향(일)\"]); ax[1, 0].set_xticks(range(len(ct))); ax[1, 0].set_xticklabels([f\"C{i + 1}\" for i in range(len(ct))]); ax[1, 0].set_title(\"5 Baseline bias by condition (days)\")\n", + "ax[1, 1].scatter(al.insp, al.hs_frac, s=6, c=al.env.map({e: i for i, e in enumerate(ENVS)})); ax[1, 1].set_title(\"6 Inspection wear vs hours fraction\")\n", + "ax[1, 2].scatter(al.rul_d, al.lin_rul_d, s=6); ax[1, 2].plot([0, 4000], [0, 4000], \"k--\"); ax[1, 2].set_title(\"7 True RUL vs Baseline RUL (days)\")\n", + "ax[1, 3].bar(risk.index, risk.values); ax[1, 3].set_title(\"8 High-risk equipment by unit (Pareto)\")\n", + "plt.tight_layout(); plt.show()\n", + "print(\"\\nEDA 차트 8종 생성 완료(목표 8종 이상: 달성)\")\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 03 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + "[1] 가상 장비 900대(고장 이력 288대, 생존 612대). Baseline RUL(일) = (규정 주기 8000h - 창정비 후 운용시간) / 일 가동률\n", + " 생존 장비 평가: MAE 366일, RMSE 455일, MAPE(참 RUL 90일 이상) 51.8%, R2 0.740, 평균 편향 -48일\n", + " Baseline이 실제보다 30일 넘게 길게 본 장비 270대(44%) - 고장 전에 정비 시점을 놓칠 위험\n", + " 고장 이력 288대 중 규정 주기(8000h) 이전에 고장 난 장비 225대(78%) -> 고정 주기는 가혹 환경 장비를 지키지 못한다\n", + "\n", + "[2] 조건별 Baseline 오차(양수 = 과대 예측)\n", + " 조건 대수 MAE(일) 평균 편향(일)\n", + " 일반 환경 261 431.0 -385.0\n", + " 험지 102 434.0 426.0\n", + " 염분 146 286.0 221.0\n", + " 혹한 103 246.0 -45.0\n", + " 비계획 정비율 상위 25% 153 380.0 51.0\n", + " 일 가동률 4h 이상 71 236.0 -88.0\n", + "점검 마모 0.8 이상(노후) 140 352.0 -19.0\n", + " -> 험지·염분 장비는 수명을 길게(위험), 일반 환경 장비는 짧게(과잉 정비) 보아 환경별 편향 방향이 반대다. 환경 가중치 미반영이 한계다.\n", + "\n", + "[3] Baseline 오차(부호 있음: 양수 = 과대 예측)와 유의한 상관(|r|>=0.2, p<0.05) 피처 7개 (목표 5개 이상: 달성)\n", + " 피처 스피어만 상관 p-value\n", + "insp_eta_h -0.8807 0.0\n", + " k_rough 0.4811 0.0\n", + " k_salt 0.3550 0.0\n", + " lin_rul_d 0.2540 0.0\n", + " h_since -0.2498 0.0\n", + " hs_frac -0.2498 0.0\n", + " km_per_h -0.2257 0.0\n", + " (lin_rul_d·h_since·hs_frac는 Baseline 산식과 직접 겹치므로 해석에서는 환경 더미·점검 마모·기동 강도 같은 독립 요인을 본다)\n", + " 가설검정: 험지·염분 vs 일반 환경 Baseline 편향 평균 +305일 vs -385일(Mann-Whitney p=1.9e-65), 환경별 점검 환산 총 수명 차이 Kruskal p=1.1e-57\n", + "\n", + "[4] 파레토: 참 RUL 180일 미만 고위험 장비 46대, 상위 5개 부대가 80% 차지 -> U5 12대, U3 9대, U4 8대, U6 7대, U2 5대, U1 5대\n", + " 핵심 발견: 환경 가중치와 정비 이력·점검 마모가 Baseline이 놓치는 요인이며, 이는 M4 피처 설계(환경 더미, 비계획 정비율, 점검 마모)의 근거가 된다.\n", + "\n", + "EDA 차트 8종 생성 완료(목표 8종 이상: 달성)\n", + "```\n", + "
\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 04. 생존 분석(Weibull/Cox) 기반 장비/부품 고장 확률 곡선 및 기대수명 예측 모델 고도화\n", + "**소요시간**: 약 3.0시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "생존 분석 기반 장비/부품 고장 확률 및 기대수명 예측 모델을 고도화한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **와이블 AFT와 Cox 비례위험 모형으로 고장 확률 곡선과 기대수명을 예측한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **생존 분석 라이브러리를 쓰지 않고 scipy로 와이블 AFT와 Cox 모형을 직접 구현하고 동작을 검증하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `src/features/sup02_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": [ + "\"\"\"생존 분석(와이블 AFT 최대우도·Cox 비례위험을 scipy로 직접 구현)으로 환경별 고장 확률 곡선과 기대수명을 추정하고, 환경 가속 계수를 숨은 정답과 비교하며 C-index·기대수명 MAPE·1년 내 고장 확률 보정으로 모델을 검증한다.\"\"\"\n", + "import numpy as np\n", + "import pandas as pd\n", + "import matplotlib.pyplot as plt\n", + "\n", + "# ---------------------------------------------------------------------------\n", + "# [교육용가상자료] 가상 장비 보유 현황·고장 이력 생성기 (실제 부대·장비·수치 없음)\n", + "# 가상 부대 6개(U1~U6)의 장비가 운용 환경(normal/rough/salt/cold)에 따라 다른 속도로 노후화한다. 창정비 후 운용시간(h_since)이\n", + "# 장비마다 숨은 수명(t_fail)을 넘으면 고장(event=1) 이력, 넘지 않았으면 현재까지 생존(event=0, 관측 중도절단)이다.\n", + "# ---------------------------------------------------------------------------\n", + "ENVS = [\"normal\", \"rough\", \"salt\", \"cold\"]\n", + "ENV_W = {\"normal\": 1.00, \"rough\": 1.35, \"salt\": 1.25, \"cold\": 1.12} # 환경 가중치(숨은 정답, 가정값): 클수록 수명이 짧다\n", + "ENV_KO = {\"normal\": \"일반\", \"rough\": \"험지\", \"salt\": \"염분\", \"cold\": \"혹한\"}\n", + "UNITS = [\"U1\", \"U2\", \"U3\", \"U4\", \"U5\", \"U6\"]\n", + "UNIT_ENV = {\"U1\": [.7, .1, .1, .1], \"U2\": [.2, .6, .1, .1], \"U3\": [.2, .1, .6, .1], \"U4\": [.3, .1, .1, .5], \"U5\": [.5, .3, .1, .1], \"U6\": [.4, .1, .4, .1]}\n", + "ETYPES = [\"truck\", \"generator\", \"trailer\", \"radio\"]\n", + "CYCLE = 8000.0 # 규정 창정비 주기(운용시간 h, 가정값)\n", + "KSHAPE = 12.0 # 수명 분포 와이블 형상(마모형 고장, 클수록 수명이 모여 있다)\n", + "INSP_NOISE = 0.025 # 정비 점검 마모 비율 측정 오차(가정값)\n", + "\n", + "\n", + "def make_fleet(n=900, seed=0, env_w=None, shift=1.0):\n", + " \"\"\"장비 n대. env_w로 숨은 환경 가중치를 바꿔 운영 분포 변화를 모의할 수 있다. shift>1이면 가동률이 높아진다.\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " w = env_w or ENV_W\n", + " unit = rng.choice(UNITS, n)\n", + " env = np.array([rng.choice(ENVS, p=UNIT_ENV[u]) for u in unit])\n", + " et = rng.choice(ETYPES, n, p=[.4, .2, .2, .2])\n", + " rate = rng.lognormal(np.log(3.0 * shift), 0.25, n) # 일 평균 운용시간(h/일)\n", + " h_since = rng.uniform(300, 11500, n) # 마지막 창정비 이후 운용시간\n", + " n_ovh = rng.integers(0, 3, n)\n", + " hours = h_since + n_ovh * rng.uniform(5000, 8000, n) # 누적 운용시간\n", + " km = hours * rng.normal(17, 3, n) * np.where(env == \"rough\", 0.8, 1.0) # 누적 기동거리(km)\n", + " age_y = np.clip(hours / (rate * 365) * rng.uniform(0.9, 1.15, n), 0.5, 25)\n", + " unpl = rng.poisson(0.35 * h_since / 1000 * np.array([w[e] for e in env]) / 1.3) # 비계획 정비 건수(환경이 가혹할수록 많다)\n", + " n_maint = rng.poisson(0.6 * h_since / 1000 + 1) # 계획 정비 건수\n", + " scale = 9600 / np.array([w[e] for e in env]) * 0.985 ** unpl * np.exp(rng.normal(0, 0.04, n))\n", + " t_fail = scale * rng.weibull(KSHAPE, n) # 창정비 후 고장까지 운용시간(숨은 정답)\n", + " event = (t_fail <= h_since).astype(int)\n", + " dur = np.where(event == 1, t_fail, h_since)\n", + " df = pd.DataFrame({\"eq_id\": [f\"EQ{seed:02d}{i:04d}\" for i in range(n)], \"unit\": unit, \"env\": env, \"etype\": et, \"rate\": rate, \"hours\": hours, \"km\": km, \"age_y\": age_y, \"n_ovh\": n_ovh,\n", + " \"h_since\": h_since, \"unpl\": unpl, \"n_maint\": n_maint, \"t_fail\": t_fail, \"event\": event, \"dur\": dur})\n", + " df[\"insp\"] = np.clip(dur / t_fail + rng.normal(0, INSP_NOISE, n), 0, 1.3) # 마지막 정비 점검에서 측정한 마모 비율(소모 수명/총 수명, 오차 포함)\n", + " df[\"rul_h\"] = np.where(event == 0, t_fail - h_since, np.nan) # 생존 장비의 남은 운용시간(정답)\n", + " df[\"rul_d\"] = df[\"rul_h\"] / df[\"rate\"] # 남은 일수(정답)\n", + " return df\n", + "\n", + "\n", + "def mape(y, p):\n", + " y, p = np.asarray(y, float), np.asarray(p, float)\n", + " return float(np.mean(np.abs(y - p) / np.abs(y)) * 100)\n", + "\n", + "\n", + "def mae(y, p):\n", + " return float(np.mean(np.abs(np.asarray(y, float) - np.asarray(p, float))))\n", + "\n", + "\n", + "def rmse(y, p):\n", + " return float(np.sqrt(np.mean((np.asarray(y, float) - np.asarray(p, float)) ** 2)))\n", + "# ---------------------------------------------------------------------------\n", + "# ---------------------------------------------------------------------------\n", + "# 노후화 파생 피처와 학습 표본 만들기. 환경 가중치 W0는 도입 시점의 전문가 추정값이며(실제 가중치와 다름) M9에서 재보정한다.\n", + "# ---------------------------------------------------------------------------\n", + "W0 = {\"normal\": 1.00, \"rough\": 1.15, \"salt\": 1.10, \"cold\": 1.05}\n", + "FEATS = [\"h_since\", \"hours\", \"km\", \"age_y\", \"rate\", \"n_maint\", \"unpl\", \"unpl_per_kh\", \"km_per_h\", \"n_ovh\", \"k_rough\", \"k_salt\", \"k_cold\", \"k_truck\", \"k_gen\", \"k_trailer\",\n", + " \"aging_idx\", \"lin_rul_d\", \"hs_frac\", \"insp\", \"insp_eta_h\"]\n", + "\n", + "\n", + "def add_features(df, w=None):\n", + " d = df.copy()\n", + " w = w or W0\n", + " d[\"unpl_per_kh\"] = d[\"unpl\"] / (d[\"h_since\"] / 1000 + 0.5)\n", + " d[\"km_per_h\"] = d[\"km\"] / d[\"hours\"]\n", + " for e in (\"rough\", \"salt\", \"cold\"):\n", + " d[\"k_\" + e] = (d[\"env\"] == e).astype(int)\n", + " d[\"k_truck\"], d[\"k_gen\"], d[\"k_trailer\"] = (d[\"etype\"] == \"truck\").astype(int), (d[\"etype\"] == \"generator\").astype(int), (d[\"etype\"] == \"trailer\").astype(int)\n", + " d[\"aging_idx\"] = d[\"h_since\"] * d[\"env\"].map(w) / CYCLE # 환경 가중 노후 지수(1이면 창정비 주기에 도달)\n", + " d[\"lin_rul_d\"] = (CYCLE - d[\"h_since\"]) / d[\"rate\"] # 선형 감가상각 잔여 일수(Baseline)\n", + " d[\"hs_frac\"] = d[\"h_since\"] / CYCLE\n", + " d[\"insp_eta_h\"] = d[\"h_since\"] / np.maximum(d[\"insp\"], 0.05) # 점검 마모 비율로 환산한 총 수명 추정(h)\n", + " return d\n", + "\n", + "\n", + "def snapshots(df, k=4, seed=0):\n", + " \"\"\"고장 이력 장비의 과거 시점 표본: 고장 전 임의 시점의 점검값·운용시간과 그때의 남은 일수. (생존 장비의 미래는 알 수 없으므로 고장 이력만 쓴다)\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " f = df[df[\"event\"] == 1]\n", + " out = []\n", + " for _ in range(k):\n", + " s = f.copy()\n", + " h = s[\"t_fail\"] * rng.uniform(0.2, 0.98, len(s))\n", + " ratio = h / s[\"h_since\"]\n", + " s[\"hours\"] = s[\"hours\"] - (s[\"h_since\"] - h)\n", + " s[\"km\"] = s[\"km\"] * ratio.clip(upper=1)\n", + " s[\"unpl\"] = np.floor(s[\"unpl\"] * ratio)\n", + " s[\"n_maint\"] = np.floor(s[\"n_maint\"] * ratio)\n", + " s[\"insp\"] = np.clip(h / s[\"t_fail\"] + rng.normal(0, INSP_NOISE, len(s)), 0, 1.3)\n", + " s[\"h_since\"] = h\n", + " s[\"rul_d\"] = (s[\"t_fail\"] - h) / s[\"rate\"]\n", + " s[\"event\"] = 0\n", + " out.append(s)\n", + " return pd.concat(out, ignore_index=True)\n", + "# ---------------------------------------------------------------------------\n", + "# ---------------------------------------------------------------------------\n", + "# 생존 분석 도구(scipy만 사용): 와이블 AFT(가속 수명) 모형과 Cox 비례위험 모형\n", + "# ---------------------------------------------------------------------------\n", + "from scipy.optimize import minimize\n", + "\n", + "\n", + "def aft_x(d):\n", + " return np.c_[np.ones(len(d)), d[\"env\"].eq(\"rough\"), d[\"env\"].eq(\"salt\"), d[\"env\"].eq(\"cold\"), d[\"unpl\"] / (d[\"h_since\"] / 1000 + 0.5)].astype(float)\n", + "\n", + "\n", + "AFT_COLS = [\"절편\", \"험지\", \"염분\", \"혹한\", \"비계획 정비율\"]\n", + "\n", + "\n", + "def fit_aft(d):\n", + " \"\"\"창정비 후 운용시간 dur(고장이면 event=1, 생존이면 중도절단)에 대한 와이블 AFT 최대우도 추정. 반환: 계수(마지막은 log 형상).\"\"\"\n", + " x, t, e = aft_x(d), d[\"dur\"].values, d[\"event\"].values\n", + "\n", + " def nll(p):\n", + " lam, k = np.exp(x @ p[:-1]), np.exp(p[-1])\n", + " z = (t / lam) ** k\n", + " return -(e * (np.log(k) - np.log(lam) + (k - 1) * (np.log(t) - np.log(lam))) - z).sum()\n", + "\n", + " r = minimize(nll, np.r_[np.log(8000), np.zeros(x.shape[1] - 1), np.log(5)], method=\"BFGS\")\n", + " return r.x\n", + "\n", + "\n", + "def aft_pred(p, d, q=0.5, h=None):\n", + " \"\"\"이미 h(운용시간)까지 생존했다는 조건에서 분위수 q 생존 시점(운용시간 h 기준 총 수명)을 돌려준다. q=0.5가 조건부 중앙값.\"\"\"\n", + " lam, k = np.exp(aft_x(d) @ p[:-1]), np.exp(p[-1])\n", + " h = d[\"h_since\"].values if h is None else h\n", + " return lam * ((h / lam) ** k - np.log(q)) ** (1 / k)\n", + "\n", + "\n", + "def aft_fail_prob(p, d, h0, h1):\n", + " \"\"\"h0까지 생존한 장비가 h0~h1 사이에 고장 날 조건부 확률.\"\"\"\n", + " lam, k = np.exp(aft_x(d) @ p[:-1]), np.exp(p[-1])\n", + " s0, s1 = np.exp(-(h0 / lam) ** k), np.exp(-(h1 / lam) ** k)\n", + " return 1 - s1 / s0\n", + "\n", + "\n", + "def fit_cox(d, iters=25):\n", + " \"\"\"Cox 비례위험(Breslow 근사) 뉴턴-랩슨. 공변량: 험지·염분·혹한·비계획 정비율. 반환: 계수.\"\"\"\n", + " x = aft_x(d)[:, 1:]\n", + " o = np.argsort(-d[\"dur\"].values)\n", + " x, e = x[o], d[\"event\"].values[o]\n", + " b = np.zeros(x.shape[1])\n", + " for _ in range(iters):\n", + " r = np.exp(x @ b)\n", + " S0 = np.cumsum(r)\n", + " S1 = np.cumsum(r[:, None] * x, axis=0)\n", + " S2 = np.cumsum(r[:, None, None] * x[:, :, None] * x[:, None, :], axis=0)\n", + " m1 = S1 / S0[:, None]\n", + " g = ((x - m1) * e[:, None]).sum(axis=0)\n", + " H = -sum(e[i] * (S2[i] / S0[i] - np.outer(m1[i], m1[i])) for i in np.where(e == 1)[0])\n", + " step = np.linalg.solve(H - 1e-6 * np.eye(len(b)), g)\n", + " b = b - step\n", + " if np.abs(step).max() < 1e-7:\n", + " break\n", + " return b\n", + "\n", + "\n", + "def cindex(time, event, risk):\n", + " \"\"\"일치도 C-index: 위험 점수가 높은 쪽이 먼저 고장 났는가(관측 가능한 쌍만).\"\"\"\n", + " t, e, r = np.asarray(time), np.asarray(event), np.asarray(risk)\n", + " num = den = 0.0\n", + " for i in np.where(e == 1)[0]:\n", + " m = t > t[i]\n", + " den += m.sum()\n", + " num += (r[m] < r[i]).sum() + 0.5 * (r[m] == r[i]).sum()\n", + " return num / max(den, 1)\n", + "# ---------------------------------------------------------------------------\n", + "\n", + "tr = make_fleet(900, seed=1)\n", + "te = make_fleet(600, seed=2)\n", + "derived = [c for c in FEATS if c in (\"unpl_per_kh\", \"km_per_h\", \"k_rough\", \"k_salt\", \"k_cold\", \"k_truck\", \"k_gen\", \"k_trailer\", \"aging_idx\", \"lin_rul_d\", \"hs_frac\", \"insp_eta_h\")]\n", + "print(f\"[1] 파생 피처 {len(derived)}개(환경·장비 더미, 비계획 정비율, 환경 가중 노후 지수, 점검 환산 수명 등) / 학습 {len(tr)}대(고장 {int(tr.event.sum())}대, 중도절단 {int((1 - tr.event).sum())}대), 평가 {len(te)}대\")\n", + "\n", + "# ---------------- 1) 와이블 AFT·Cox 추정 ----------------\n", + "p = fit_aft(tr)\n", + "b = fit_cox(tr)\n", + "k = float(np.exp(p[-1]))\n", + "acc = pd.DataFrame({\"AFT 수명비 exp(b)\": np.exp(p[1:5]), \"숨은 정답 수명비\": [1 / ENV_W[\"rough\"], 1 / ENV_W[\"salt\"], 1 / ENV_W[\"cold\"], np.nan], \"Cox 위험비\": np.exp(b)}, index=AFT_COLS[1:])\n", + "print(f\"[2] 와이블 AFT 형상 k={k:.2f}(1보다 크면 마모형 고장), 기준 수명(일반 환경) {np.exp(p[0]):.0f}h\")\n", + "print(acc.round(3).to_string())\n", + "print(\" 환경 가속(수명비 < 1)이 가혹 환경일수록 크며, 숨은 정답(1/가중치)과 비슷하게 복원된다. 비계획 정비율은 수명을 줄이는 방향이다.\")\n", + "\n", + "# ---------------- 2) 고장 확률 곡선 ----------------\n", + "grid = np.array([5000, 6000, 7000, 8000, 9000, 10000])\n", + "typ = pd.DataFrame({\"env\": ENVS, \"unpl\": 1.0, \"h_since\": 1000.0})\n", + "typ[\"unpl\"] = 1.0 * (typ[\"h_since\"] / 1000 + 0.5)\n", + "lam = np.exp(aft_x(typ) @ p[:-1])\n", + "curves = pd.DataFrame({f\"{h}h\": 1 - np.exp(-(h / lam) ** k) for h in grid}, index=ENVS)\n", + "b10 = lam * (-np.log(0.9)) ** (1 / k)\n", + "print(\"\\n[3] 창정비 후 운용시간별 누적 고장 확률(비계획 정비율 1.0/1000h 장비)\")\n", + "print(curves.apply(lambda c: c.map(\"{:.0%}\".format)).to_string())\n", + "print(\" B10 수명(고장 10% 시점): \" + \", \".join(f\"{e} {v:.0f}h\" for e, v in zip(ENVS, b10)) + f\" / 규정 주기 {CYCLE:.0f}h 시점 고장 확률 일반 {curves.loc['normal', '8000h']:.0%}, 험지 {curves.loc['rough', '8000h']:.0%}\")\n", + "\n", + "# ---------------- 3) 평가: 기대수명 MAPE, C-index ----------------\n", + "zero = np.full(len(te), 1e-6)\n", + "life = aft_pred(p, te, 0.5, h=zero)\n", + "m1, m0 = mape(te.t_fail, life), mape(te.t_fail, np.full(len(te), CYCLE))\n", + "x = aft_x(te)[:, 1:]\n", + "ci_cox, ci_aft = cindex(te.dur, te.event, x @ b), cindex(te.dur, te.event, -life)\n", + "ci_base = cindex(te.dur, te.event, np.zeros(len(te)))\n", + "print(f\"\\n[4] 평가(숨은 정답 t_fail은 모의 평가에만 사용): 기대수명(중앙값) MAPE {m1:.1f}% (목표 25% 이하: {'달성' if m1 <= 25 else '미달'}), 규정 주기 {CYCLE:.0f}h를 모두에 적용하면 {m0:.1f}%\")\n", + "print(f\" C-index: AFT {ci_aft:.3f}, Cox {ci_cox:.3f}, 환경 무시(무차별) {ci_base:.3f} (0.5 = 무작위)\")\n", + "by = pd.DataFrame({\"환경\": te.env, \"정답 평균\": te.t_fail, \"AFT\": life, \"규정 주기\": CYCLE}).groupby(\"환경\").mean().round(0)\n", + "print(\" 환경별 평균 수명(h):\\n\" + by.to_string())\n", + "\n", + "# ---------------- 4) 1년 내 고장 확률 보정(생존 장비) ----------------\n", + "a = te[te.event == 0].copy()\n", + "a[\"p1y\"] = aft_fail_prob(p, a, a[\"h_since\"].values, a[\"h_since\"].values + 365 * a[\"rate\"].values)\n", + "a[\"real\"] = (a[\"rul_d\"] < 365).astype(int)\n", + "a[\"bin\"] = pd.qcut(a[\"p1y\"], 5, labels=False, duplicates=\"drop\")\n", + "cal = a.groupby(\"bin\").agg(예측확률=(\"p1y\", \"mean\"), 실제비율=(\"real\", \"mean\"), 대수=(\"real\", \"size\"))\n", + "print(\"\\n[5] 1년 내 고장 확률 보정(생존 장비, 확률 5분위)\\n\" + cal.round(2).to_string())\n", + "brier = float(((a[\"p1y\"] - a[\"real\"]) ** 2).mean())\n", + "print(f\" Brier 점수 {brier:.3f}(무정보 기준 {float(a['real'].mean() * (1 - a['real'].mean())):.3f}). 점검 마모를 쓰지 않는 생존 모형은 확률의 순위는 맞지만 개별 장비의 시점은 거칠다 -> M5에서 점검 마모 피처로 보강한다.\")\n", + "\n", + "# ---------------- 5) 변수 제거 ----------------\n", + "def ablate(cols):\n", + " keep = [0] + cols\n", + " sub_x = aft_x(tr)[:, keep]; t, e = tr[\"dur\"].values, tr[\"event\"].values\n", + " from scipy.optimize import minimize\n", + "\n", + " def nll(q):\n", + " lm, kk = np.exp(sub_x @ q[:-1]), np.exp(q[-1]); z = (t / lm) ** kk\n", + " return -(e * (np.log(kk) - np.log(lm) + (kk - 1) * (np.log(t) - np.log(lm))) - z).sum()\n", + "\n", + " q = minimize(nll, np.r_[np.log(8000), np.zeros(len(keep) - 1), np.log(5)], method=\"BFGS\").x\n", + " lm, kk = np.exp(aft_x(te)[:, keep] @ q[:-1]), np.exp(q[-1])\n", + " med = lm * np.log(2) ** (1 / kk)\n", + " return mape(te.t_fail, med), cindex(te.dur, te.event, -med)\n", + "\n", + "\n", + "rows = [(\"전체(환경+비계획 정비율)\", *ablate([1, 2, 3, 4])), (\"환경 제거\", *ablate([4])), (\"비계획 정비율 제거\", *ablate([1, 2, 3])), (\"모두 제거(절편만)\", *ablate([]))]\n", + "print(\"\\n[6] 변수 제거 실험\\n\" + pd.DataFrame(rows, columns=[\"모형\", \"기대수명 MAPE(%)\", \"C-index\"]).round(3).to_string(index=False))\n", + "\n", + "fig, ax = plt.subplots(1, 3, figsize=(15, 4.3))\n", + "hh = np.linspace(2000, 11000, 100)\n", + "for e, l in zip(ENVS, lam):\n", + " ax[0].plot(hh, 1 - np.exp(-(hh / l) ** k), label=e)\n", + "ax[0].axvline(CYCLE, c=\"k\", ls=\"--\"); ax[0].legend(); ax[0].set_title(\"Failure probability by environment (h since overhaul)\")\n", + "ax[1].plot(cal[\"예측확률\"], cal[\"실제비율\"], \"o-\"); ax[1].plot([0, 1], [0, 1], \"k--\"); ax[1].set_title(\"1-year failure probability calibration\")\n", + "ax[2].bar([\"rough\", \"salt\", \"cold\", \"unplanned rate\"], np.exp(p[1:5])); ax[2].axhline(1, c=\"k\", ls=\"--\"); ax[2].set_title(\"AFT life ratio vs normal (exp(b))\"); ax[2].tick_params(axis=\"x\", rotation=20)\n", + "plt.tight_layout(); plt.show()\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 04 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + "[1] 파생 피처 12개(환경·장비 더미, 비계획 정비율, 환경 가중 노후 지수, 점검 환산 수명 등) / 학습 900대(고장 288대, 중도절단 612대), 평가 600대\n", + "[2] 와이블 AFT 형상 k=10.83(1보다 크면 마모형 고장), 기준 수명(일반 환경) 9602h\n", + " AFT 수명비 exp(b) 숨은 정답 수명비 Cox 위험비\n", + "험지 0.752 0.741 20.984\n", + "염분 0.802 0.800 10.983\n", + "혹한 0.878 0.893 4.253\n", + "비계획 정비율 0.889 NaN 3.418\n", + " 환경 가속(수명비 < 1)이 가혹 환경일수록 크며, 숨은 정답(1/가중치)과 비슷하게 복원된다. 비계획 정비율은 수명을 줄이는 방향이다.\n", + "\n", + "[3] 창정비 후 운용시간별 누적 고장 확률(비계획 정비율 1.0/1000h 장비)\n", + " 5000h 6000h 7000h 8000h 9000h 10000h\n", + "normal 0% 2% 11% 39% 83% 100%\n", + "rough 6% 38% 92% 100% 100% 100%\n", + "salt 3% 21% 72% 100% 100% 100%\n", + "cold 1% 9% 38% 87% 100% 100%\n", + " B10 수명(고장 10% 시점): normal 6936h, rough 5218h, salt 5560h, cold 6089h / 규정 주기 8000h 시점 고장 확률 일반 39%, 험지 100%\n", + "\n", + "[4] 평가(숨은 정답 t_fail은 모의 평가에만 사용): 기대수명(중앙값) MAPE 10.2% (목표 25% 이하: 달성), 규정 주기 8000h를 모두에 적용하면 15.2%\n", + " C-index: AFT 0.767, Cox 0.767, 환경 무시(무차별) 0.500 (0.5 = 무작위)\n", + " 환경별 평균 수명(h):\n", + " 정답 평균 AFT 규정 주기\n", + "환경 \n", + "cold 7969.0 7890.0 8000.0\n", + "normal 8992.0 9046.0 8000.0\n", + "rough 6563.0 6700.0 8000.0\n", + "salt 7231.0 7167.0 8000.0\n", + "\n", + "[5] 1년 내 고장 확률 보정(생존 장비, 확률 5분위)\n", + " 예측확률 실제비율 대수\n", + "bin \n", + "0 0.00 0.00 78\n", + "1 0.00 0.00 78\n", + "2 0.01 0.00 78\n", + "3 0.09 0.05 78\n", + "4 0.64 0.59 78\n", + " Brier 점수 0.045(무정보 기준 0.112). 점검 마모를 쓰지 않는 생존 모형은 확률의 순위는 맞지만 개별 장비의 시점은 거칠다 -> M5에서 점검 마모 피처로 보강한다.\n", + "\n", + "[6] 변수 제거 실험\n", + " 모형 기대수명 MAPE(%) C-index\n", + "전체(환경+비계획 정비율) 10.152 0.767\n", + "... (이하 생략)\n", + "```\n", + "
\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 05. 예산 및 정비창 능력 연계 최적 계획정비(Overhaul) 일정 추천 엔진 개발\n", + "**소요시간**: 약 3.5시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "정비창 능력 연계 최적 계획정비(Overhaul) 일정 추천 엔진을 개발한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **예산과 정비창 능력을 연계한 12개월 계획정비 일정 추천 엔진을 개발한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **와이블 AFT와 점검 마모 피처를 쓰는 GBR을 앙상블해 잔여 수명을 예측하고 선형 감가상각 기준과 비교 수행하고 검증하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `src/models/sup02_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": [ + "\"\"\"와이블 AFT와 점검 마모 피처를 쓰는 그래디언트 부스팅을 결합한 잔여 수명(RUL) 예측 엔진을 기준 모델 대비 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\n", + "from sklearn.metrics import r2_score\n", + "\n", + "# ---------------------------------------------------------------------------\n", + "# [교육용가상자료] 가상 장비 보유 현황·고장 이력 생성기 (실제 부대·장비·수치 없음)\n", + "# 가상 부대 6개(U1~U6)의 장비가 운용 환경(normal/rough/salt/cold)에 따라 다른 속도로 노후화한다. 창정비 후 운용시간(h_since)이\n", + "# 장비마다 숨은 수명(t_fail)을 넘으면 고장(event=1) 이력, 넘지 않았으면 현재까지 생존(event=0, 관측 중도절단)이다.\n", + "# ---------------------------------------------------------------------------\n", + "ENVS = [\"normal\", \"rough\", \"salt\", \"cold\"]\n", + "ENV_W = {\"normal\": 1.00, \"rough\": 1.35, \"salt\": 1.25, \"cold\": 1.12} # 환경 가중치(숨은 정답, 가정값): 클수록 수명이 짧다\n", + "ENV_KO = {\"normal\": \"일반\", \"rough\": \"험지\", \"salt\": \"염분\", \"cold\": \"혹한\"}\n", + "UNITS = [\"U1\", \"U2\", \"U3\", \"U4\", \"U5\", \"U6\"]\n", + "UNIT_ENV = {\"U1\": [.7, .1, .1, .1], \"U2\": [.2, .6, .1, .1], \"U3\": [.2, .1, .6, .1], \"U4\": [.3, .1, .1, .5], \"U5\": [.5, .3, .1, .1], \"U6\": [.4, .1, .4, .1]}\n", + "ETYPES = [\"truck\", \"generator\", \"trailer\", \"radio\"]\n", + "CYCLE = 8000.0 # 규정 창정비 주기(운용시간 h, 가정값)\n", + "KSHAPE = 12.0 # 수명 분포 와이블 형상(마모형 고장, 클수록 수명이 모여 있다)\n", + "INSP_NOISE = 0.025 # 정비 점검 마모 비율 측정 오차(가정값)\n", + "\n", + "\n", + "def make_fleet(n=900, seed=0, env_w=None, shift=1.0):\n", + " \"\"\"장비 n대. env_w로 숨은 환경 가중치를 바꿔 운영 분포 변화를 모의할 수 있다. shift>1이면 가동률이 높아진다.\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " w = env_w or ENV_W\n", + " unit = rng.choice(UNITS, n)\n", + " env = np.array([rng.choice(ENVS, p=UNIT_ENV[u]) for u in unit])\n", + " et = rng.choice(ETYPES, n, p=[.4, .2, .2, .2])\n", + " rate = rng.lognormal(np.log(3.0 * shift), 0.25, n) # 일 평균 운용시간(h/일)\n", + " h_since = rng.uniform(300, 11500, n) # 마지막 창정비 이후 운용시간\n", + " n_ovh = rng.integers(0, 3, n)\n", + " hours = h_since + n_ovh * rng.uniform(5000, 8000, n) # 누적 운용시간\n", + " km = hours * rng.normal(17, 3, n) * np.where(env == \"rough\", 0.8, 1.0) # 누적 기동거리(km)\n", + " age_y = np.clip(hours / (rate * 365) * rng.uniform(0.9, 1.15, n), 0.5, 25)\n", + " unpl = rng.poisson(0.35 * h_since / 1000 * np.array([w[e] for e in env]) / 1.3) # 비계획 정비 건수(환경이 가혹할수록 많다)\n", + " n_maint = rng.poisson(0.6 * h_since / 1000 + 1) # 계획 정비 건수\n", + " scale = 9600 / np.array([w[e] for e in env]) * 0.985 ** unpl * np.exp(rng.normal(0, 0.04, n))\n", + " t_fail = scale * rng.weibull(KSHAPE, n) # 창정비 후 고장까지 운용시간(숨은 정답)\n", + " event = (t_fail <= h_since).astype(int)\n", + " dur = np.where(event == 1, t_fail, h_since)\n", + " df = pd.DataFrame({\"eq_id\": [f\"EQ{seed:02d}{i:04d}\" for i in range(n)], \"unit\": unit, \"env\": env, \"etype\": et, \"rate\": rate, \"hours\": hours, \"km\": km, \"age_y\": age_y, \"n_ovh\": n_ovh,\n", + " \"h_since\": h_since, \"unpl\": unpl, \"n_maint\": n_maint, \"t_fail\": t_fail, \"event\": event, \"dur\": dur})\n", + " df[\"insp\"] = np.clip(dur / t_fail + rng.normal(0, INSP_NOISE, n), 0, 1.3) # 마지막 정비 점검에서 측정한 마모 비율(소모 수명/총 수명, 오차 포함)\n", + " df[\"rul_h\"] = np.where(event == 0, t_fail - h_since, np.nan) # 생존 장비의 남은 운용시간(정답)\n", + " df[\"rul_d\"] = df[\"rul_h\"] / df[\"rate\"] # 남은 일수(정답)\n", + " return df\n", + "\n", + "\n", + "def mape(y, p):\n", + " y, p = np.asarray(y, float), np.asarray(p, float)\n", + " return float(np.mean(np.abs(y - p) / np.abs(y)) * 100)\n", + "\n", + "\n", + "def mae(y, p):\n", + " return float(np.mean(np.abs(np.asarray(y, float) - np.asarray(p, float))))\n", + "\n", + "\n", + "def rmse(y, p):\n", + " return float(np.sqrt(np.mean((np.asarray(y, float) - np.asarray(p, float)) ** 2)))\n", + "# ---------------------------------------------------------------------------\n", + "# ---------------------------------------------------------------------------\n", + "# 노후화 파생 피처와 학습 표본 만들기. 환경 가중치 W0는 도입 시점의 전문가 추정값이며(실제 가중치와 다름) M9에서 재보정한다.\n", + "# ---------------------------------------------------------------------------\n", + "W0 = {\"normal\": 1.00, \"rough\": 1.15, \"salt\": 1.10, \"cold\": 1.05}\n", + "FEATS = [\"h_since\", \"hours\", \"km\", \"age_y\", \"rate\", \"n_maint\", \"unpl\", \"unpl_per_kh\", \"km_per_h\", \"n_ovh\", \"k_rough\", \"k_salt\", \"k_cold\", \"k_truck\", \"k_gen\", \"k_trailer\",\n", + " \"aging_idx\", \"lin_rul_d\", \"hs_frac\", \"insp\", \"insp_eta_h\"]\n", + "\n", + "\n", + "def add_features(df, w=None):\n", + " d = df.copy()\n", + " w = w or W0\n", + " d[\"unpl_per_kh\"] = d[\"unpl\"] / (d[\"h_since\"] / 1000 + 0.5)\n", + " d[\"km_per_h\"] = d[\"km\"] / d[\"hours\"]\n", + " for e in (\"rough\", \"salt\", \"cold\"):\n", + " d[\"k_\" + e] = (d[\"env\"] == e).astype(int)\n", + " d[\"k_truck\"], d[\"k_gen\"], d[\"k_trailer\"] = (d[\"etype\"] == \"truck\").astype(int), (d[\"etype\"] == \"generator\").astype(int), (d[\"etype\"] == \"trailer\").astype(int)\n", + " d[\"aging_idx\"] = d[\"h_since\"] * d[\"env\"].map(w) / CYCLE # 환경 가중 노후 지수(1이면 창정비 주기에 도달)\n", + " d[\"lin_rul_d\"] = (CYCLE - d[\"h_since\"]) / d[\"rate\"] # 선형 감가상각 잔여 일수(Baseline)\n", + " d[\"hs_frac\"] = d[\"h_since\"] / CYCLE\n", + " d[\"insp_eta_h\"] = d[\"h_since\"] / np.maximum(d[\"insp\"], 0.05) # 점검 마모 비율로 환산한 총 수명 추정(h)\n", + " return d\n", + "\n", + "\n", + "def snapshots(df, k=4, seed=0):\n", + " \"\"\"고장 이력 장비의 과거 시점 표본: 고장 전 임의 시점의 점검값·운용시간과 그때의 남은 일수. (생존 장비의 미래는 알 수 없으므로 고장 이력만 쓴다)\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " f = df[df[\"event\"] == 1]\n", + " out = []\n", + " for _ in range(k):\n", + " s = f.copy()\n", + " h = s[\"t_fail\"] * rng.uniform(0.2, 0.98, len(s))\n", + " ratio = h / s[\"h_since\"]\n", + " s[\"hours\"] = s[\"hours\"] - (s[\"h_since\"] - h)\n", + " s[\"km\"] = s[\"km\"] * ratio.clip(upper=1)\n", + " s[\"unpl\"] = np.floor(s[\"unpl\"] * ratio)\n", + " s[\"n_maint\"] = np.floor(s[\"n_maint\"] * ratio)\n", + " s[\"insp\"] = np.clip(h / s[\"t_fail\"] + rng.normal(0, INSP_NOISE, len(s)), 0, 1.3)\n", + " s[\"h_since\"] = h\n", + " s[\"rul_d\"] = (s[\"t_fail\"] - h) / s[\"rate\"]\n", + " s[\"event\"] = 0\n", + " out.append(s)\n", + " return pd.concat(out, ignore_index=True)\n", + "# ---------------------------------------------------------------------------\n", + "# ---------------------------------------------------------------------------\n", + "# 생존 분석 도구(scipy만 사용): 와이블 AFT(가속 수명) 모형과 Cox 비례위험 모형\n", + "# ---------------------------------------------------------------------------\n", + "from scipy.optimize import minimize\n", + "\n", + "\n", + "def aft_x(d):\n", + " return np.c_[np.ones(len(d)), d[\"env\"].eq(\"rough\"), d[\"env\"].eq(\"salt\"), d[\"env\"].eq(\"cold\"), d[\"unpl\"] / (d[\"h_since\"] / 1000 + 0.5)].astype(float)\n", + "\n", + "\n", + "AFT_COLS = [\"절편\", \"험지\", \"염분\", \"혹한\", \"비계획 정비율\"]\n", + "\n", + "\n", + "def fit_aft(d):\n", + " \"\"\"창정비 후 운용시간 dur(고장이면 event=1, 생존이면 중도절단)에 대한 와이블 AFT 최대우도 추정. 반환: 계수(마지막은 log 형상).\"\"\"\n", + " x, t, e = aft_x(d), d[\"dur\"].values, d[\"event\"].values\n", + "\n", + " def nll(p):\n", + " lam, k = np.exp(x @ p[:-1]), np.exp(p[-1])\n", + " z = (t / lam) ** k\n", + " return -(e * (np.log(k) - np.log(lam) + (k - 1) * (np.log(t) - np.log(lam))) - z).sum()\n", + "\n", + " r = minimize(nll, np.r_[np.log(8000), np.zeros(x.shape[1] - 1), np.log(5)], method=\"BFGS\")\n", + " return r.x\n", + "\n", + "\n", + "def aft_pred(p, d, q=0.5, h=None):\n", + " \"\"\"이미 h(운용시간)까지 생존했다는 조건에서 분위수 q 생존 시점(운용시간 h 기준 총 수명)을 돌려준다. q=0.5가 조건부 중앙값.\"\"\"\n", + " lam, k = np.exp(aft_x(d) @ p[:-1]), np.exp(p[-1])\n", + " h = d[\"h_since\"].values if h is None else h\n", + " return lam * ((h / lam) ** k - np.log(q)) ** (1 / k)\n", + "\n", + "\n", + "def aft_fail_prob(p, d, h0, h1):\n", + " \"\"\"h0까지 생존한 장비가 h0~h1 사이에 고장 날 조건부 확률.\"\"\"\n", + " lam, k = np.exp(aft_x(d) @ p[:-1]), np.exp(p[-1])\n", + " s0, s1 = np.exp(-(h0 / lam) ** k), np.exp(-(h1 / lam) ** k)\n", + " return 1 - s1 / s0\n", + "\n", + "\n", + "def fit_cox(d, iters=25):\n", + " \"\"\"Cox 비례위험(Breslow 근사) 뉴턴-랩슨. 공변량: 험지·염분·혹한·비계획 정비율. 반환: 계수.\"\"\"\n", + " x = aft_x(d)[:, 1:]\n", + " o = np.argsort(-d[\"dur\"].values)\n", + " x, e = x[o], d[\"event\"].values[o]\n", + " b = np.zeros(x.shape[1])\n", + " for _ in range(iters):\n", + " r = np.exp(x @ b)\n", + " S0 = np.cumsum(r)\n", + " S1 = np.cumsum(r[:, None] * x, axis=0)\n", + " S2 = np.cumsum(r[:, None, None] * x[:, :, None] * x[:, None, :], axis=0)\n", + " m1 = S1 / S0[:, None]\n", + " g = ((x - m1) * e[:, None]).sum(axis=0)\n", + " H = -sum(e[i] * (S2[i] / S0[i] - np.outer(m1[i], m1[i])) for i in np.where(e == 1)[0])\n", + " step = np.linalg.solve(H - 1e-6 * np.eye(len(b)), g)\n", + " b = b - step\n", + " if np.abs(step).max() < 1e-7:\n", + " break\n", + " return b\n", + "\n", + "\n", + "def cindex(time, event, risk):\n", + " \"\"\"일치도 C-index: 위험 점수가 높은 쪽이 먼저 고장 났는가(관측 가능한 쌍만).\"\"\"\n", + " t, e, r = np.asarray(time), np.asarray(event), np.asarray(risk)\n", + " num = den = 0.0\n", + " for i in np.where(e == 1)[0]:\n", + " m = t > t[i]\n", + " den += m.sum()\n", + " num += (r[m] < r[i]).sum() + 0.5 * (r[m] == r[i]).sum()\n", + " return num / max(den, 1)\n", + "# ---------------------------------------------------------------------------\n", + "# ---------------------------------------------------------------------------\n", + "# 잔여 수명(RUL, 일) 예측 엔진: 와이블 AFT 조건부 중앙값 + 그래디언트 부스팅(고장 이력 시점 표본 학습)의 평균\n", + "# ---------------------------------------------------------------------------\n", + "from sklearn.ensemble import GradientBoostingRegressor\n", + "\n", + "\n", + "def fit_engine(train, seed=0):\n", + " p = fit_aft(train)\n", + " sn = add_features(snapshots(train, 5, seed))\n", + " sn[\"aft\"] = (aft_pred(p, sn) - sn[\"h_since\"]) / sn[\"rate\"]\n", + " g = GradientBoostingRegressor(n_estimators=250, max_depth=3, learning_rate=0.06, random_state=0).fit(sn[FEATS + [\"aft\"]], sn[\"rul_d\"])\n", + " return {\"p\": p, \"g\": g}\n", + "\n", + "\n", + "def predict_rul(eng, d):\n", + " \"\"\"d는 add_features를 거친 표. 반환: (앙상블 RUL, AFT RUL, GBR RUL) - 일 단위.\"\"\"\n", + " aft = ((aft_pred(eng[\"p\"], d) - d[\"h_since\"]) / d[\"rate\"]).values\n", + " x = d[FEATS].copy()\n", + " x[\"aft\"] = aft\n", + " gb = eng[\"g\"].predict(x)\n", + " return np.clip(0.5 * aft + 0.5 * gb, 1, None), aft, gb\n", + "# ---------------------------------------------------------------------------\n", + "# ---------------------------------------------------------------------------\n", + "# 계획정비(창정비) 일정 추천: 월별 정비창 능력·예산·시효성 부품 키트(유효기한) 제약 아래 기대 비용 최소화 (비용 단가는 교육용 가정값, 만원)\n", + "# ---------------------------------------------------------------------------\n", + "from scipy.optimize import milp, LinearConstraint, Bounds\n", + "from scipy.stats import norm\n", + "\n", + "C_PLAN, C_FAIL, C_WASTE_KH, C_KIT = 600.0, 2400.0, 40.0, 120.0 # 계획 창정비, 비계획 고장(긴급 정비 포함), 미사용 수명 1000h당 손실, 시효성 부품 키트\n", + "HORIZON, MDAYS = 12, 30 # 계획 12개월, 월 30일\n", + "CAP_M, BUDGET_M = 22, 15000.0 # 월별 정비창 능력(대), 월별 예산\n", + "LOTS = [(30, 70), (45, 170), (45, 390), (70, 800)] # 시효성 키트 로트(수량, 유효기한까지 남은 일수)\n", + "KIT_MARGIN = 14 # 정비일 기준 최소 유효기간 여유(일)\n", + "\n", + "\n", + "def exp_cost_matrix(pred, rate, month_days):\n", + " \"\"\"장비 i를 월 m에 정비할 때의 기대 비용. 참 RUL ~ N(pred, sigma), sigma = 35일 + 0.12 x pred (가정). 마지막 열은 계획 기간 밖(미정비).\"\"\"\n", + " sig = 35 + 0.12 * pred\n", + " C = np.zeros((len(pred), len(month_days) + 1))\n", + " for j, day in enumerate(month_days):\n", + " z = (day - pred) / sig\n", + " pf = norm.cdf(z)\n", + " unused = np.maximum(pred - day, 0) * rate * 0.6 # 정비를 앞당긴 만큼 버리는 운용시간 h(근사)\n", + " C[:, j] = C_PLAN + C_KIT + pf * (C_FAIL - C_PLAN) + C_WASTE_KH * unused / 1000 * (1 - pf)\n", + " C[:, -1] = norm.cdf((HORIZON * MDAYS - pred) / sig) * C_FAIL\n", + " return C\n", + "\n", + "\n", + "def plan_milp(pred, rate, cap=CAP_M, budget=BUDGET_M, lots=LOTS, use_kit=True):\n", + " \"\"\"정수계획으로 장비별 정비 월(0~11) 또는 미정비(-1)를 배정한다. 후보는 예측 RUL이 계획 기간 +90일 이내인 장비.\"\"\"\n", + " md = np.arange(1, HORIZON + 1) * MDAYS\n", + " cand = np.where(pred < HORIZON * MDAYS + 90)[0]\n", + " n, M = len(cand), HORIZON\n", + " C = exp_cost_matrix(pred[cand], rate[cand], md)\n", + " c = C.ravel()\n", + " A, lo, hi = [], [], []\n", + " for i in range(n): # 장비당 한 번만 배정(월 12칸 + 미정비)\n", + " row = np.zeros(n * (M + 1)); row[i * (M + 1):(i + 1) * (M + 1)] = 1; A.append(row); lo.append(1); hi.append(1)\n", + " for m in range(M): # 월별 능력·예산\n", + " row = np.zeros(n * (M + 1)); row[m::(M + 1)] = 1\n", + " A.append(row); lo.append(0); hi.append(cap)\n", + " A.append(row * (C_PLAN + C_KIT)); lo.append(0); hi.append(budget)\n", + " if use_kit: # 시효성 키트: m월 이후 정비 수요는 그 시점에 유효기간이 남은 로트 수량을 넘을 수 없다\n", + " for m in range(M):\n", + " sup = sum(q for q, e in lots if e >= md[m] + KIT_MARGIN)\n", + " row = np.zeros(n * (M + 1))\n", + " for mm in range(m, M):\n", + " row[mm::(M + 1)] = 1\n", + " A.append(row); lo.append(0); hi.append(sup)\n", + " r = milp(c, constraints=LinearConstraint(np.array(A), lo, hi), integrality=np.ones(len(c)), bounds=Bounds(0, 1), options={\"time_limit\": 20})\n", + " month = np.full(len(pred), -1)\n", + " if r.x is not None:\n", + " x = np.round(r.x).reshape(n, M + 1)\n", + " assign = np.where(x[:, :M].sum(axis=1) > 0.5, x[:, :M].argmax(axis=1), -1)\n", + " month[cand] = assign\n", + " return month\n", + "\n", + "\n", + "def plan_greedy(pred, margin=45, cap=CAP_M, budget=BUDGET_M):\n", + " \"\"\"간단한 탐욕 배정: 정비 목표일(예측 RUL - margin)이 빠른 순으로 목표일 이전 가장 늦은 빈 월, 없으면 가장 이른 빈 월에 배정.\"\"\"\n", + " cap = min(cap, int(budget // (C_PLAN + C_KIT))) # 월 예산이 허용하는 대수로 능력을 제한\n", + " month = np.full(len(pred), -1)\n", + " left = np.full(HORIZON, cap)\n", + " for i in np.argsort(pred):\n", + " if pred[i] > HORIZON * MDAYS + 60: break\n", + " tgt = int(np.clip((pred[i] - margin) // MDAYS - 1, 0, HORIZON - 1))\n", + " for m in list(range(tgt, -1, -1)) + list(range(tgt + 1, HORIZON)):\n", + " if left[m] > 0:\n", + " month[i] = m; left[m] -= 1; break\n", + " return month\n", + "\n", + "\n", + "def plan_fixed(lin_rul, cap=CAP_M, budget=BUDGET_M):\n", + " \"\"\"관행: 규정 주기 도달 순서대로(선형 잔여 일수) 월별 능력 안에서 배정.\"\"\"\n", + " return plan_greedy(np.asarray(lin_rul, float), margin=0, cap=cap, budget=budget)\n", + "\n", + "\n", + "def evaluate_plan(month, true_rul, rate, cost_kit=True):\n", + " \"\"\"참 RUL로 계획의 실제 비용을 계산한다: 정비일 이전에 고장 -> 비계획 고장 비용, 아니면 계획 비용 + 미사용 수명 손실.\"\"\"\n", + " day = np.where(month >= 0, (month + 1) * MDAYS, 10 ** 9)\n", + " fail = (true_rul < day) & (true_rul < HORIZON * MDAYS)\n", + " planned = (month >= 0) & ~fail\n", + " waste = np.where(planned, (true_rul - day) * rate * 0.6, 0) * C_WASTE_KH / 1000\n", + " cost = fail * C_FAIL + planned * (C_PLAN + (C_KIT if cost_kit else 0)) + waste\n", + " return {\"total\": float(cost.sum()), \"fail\": int(fail.sum()), \"planned\": int(planned.sum()), \"waste\": float(waste.sum()), \"unsched_fail\": int(((month < 0) & fail).sum())}\n", + "\n", + "\n", + "def kit_fefo(month, lots=LOTS):\n", + " \"\"\"유효기한이 빠른 로트부터 쓰는 FEFO로 키트를 배정하고 정비일에 유효기간이 부족한 배정 수와 만료 폐기 수량을 센다.\"\"\"\n", + " q = [[qty, e] for qty, e in sorted(lots, key=lambda t: t[1])]\n", + " short = 0\n", + " for m in sorted(month[month >= 0]):\n", + " day = (m + 1) * MDAYS\n", + " for lot in q:\n", + " if lot[1] >= day + KIT_MARGIN and lot[0] > 0:\n", + " lot[0] -= 1; break\n", + " else:\n", + " short += 1\n", + " expired = sum(l[0] for l in q if l[1] < HORIZON * MDAYS)\n", + " return {\"short\": short, \"expired_unused\": expired, \"left\": sum(l[0] for l in q)}\n", + "# ---------------------------------------------------------------------------\n", + "\n", + "tr = make_fleet(900, seed=1)\n", + "te = add_features(make_fleet(900, seed=3))\n", + "eng = fit_engine(tr)\n", + "al = te[te.event == 0].reset_index(drop=True)\n", + "pred, aft, gb = predict_rul(eng, al)\n", + "y = al[\"rul_d\"].values\n", + "big = y >= 90\n", + "P = {\"기준(선형 감가상각)\": al[\"lin_rul_d\"].values, \"와이블 AFT\": aft, \"GBR(점검 마모 포함)\": gb, \"앙상블(제안)\": pred}\n", + "tab = pd.DataFrame({k: {\"MAE(일)\": mae(y, v), \"RMSE(일)\": rmse(y, v), \"MAPE(%)\": mape(y[big], v[big]), \"R2\": r2_score(y, v)} for k, v in P.items()}).T\n", + "print(f\"[1] 생존 장비 {len(al)}대 RUL 예측 성능(학습 {len(tr)}대와 다른 장비, MAPE는 참 RUL 90일 이상)\")\n", + "print(tab.round(3).to_string())\n", + "b, e = tab.loc[\"기준(선형 감가상각)\"], tab.loc[\"앙상블(제안)\"]\n", + "ok = 1 - e[\"MAE(일)\"] / b[\"MAE(일)\"] >= .2 and 1 - e[\"RMSE(일)\"] / b[\"RMSE(일)\"] >= .2\n", + "print(f\" 기준 대비 MAE 개선 {1 - e['MAE(일)'] / b['MAE(일)']:.1%}, RMSE 개선 {1 - e['RMSE(일)'] / b['RMSE(일)']:.1%} (목표 20% 이상: {'달성' if ok else '미달'}), R2 {e['R2']:.3f} (목표 0.85 이상: {'달성' if e['R2'] >= .85 else '미달'})\")\n", + "\n", + "# 점검 마모 피처의 효과: 제거 실험\n", + "tr_f = add_features(snapshots(tr, 5, 0))\n", + "cols_no = [c for c in FEATS if c not in (\"insp\", \"insp_eta_h\")]\n", + "g_no = GradientBoostingRegressor(n_estimators=250, max_depth=3, learning_rate=0.06, random_state=0).fit(tr_f[cols_no], tr_f[\"rul_d\"])\n", + "p_no = g_no.predict(al[cols_no])\n", + "print(f\" 점검 마모 피처를 빼면 GBR MAE {mae(y, gb):.0f}일 -> {mae(y, p_no):.0f}일, R2 {r2_score(y, gb):.3f} -> {r2_score(y, p_no):.3f} (정비 점검 기록의 가치)\")\n", + "\n", + "# 불규칙 상황별 오차\n", + "conds = {\"일반\": al.env == \"normal\", \"험지\": al.env == \"rough\", \"염분\": al.env == \"salt\", \"혹한\": al.env == \"cold\", \"고가동(일 4h 이상)\": al.rate >= 4, \"비계획 정비율 상위 25%\": al.unpl_per_kh >= al.unpl_per_kh.quantile(.75)}\n", + "rr = pd.DataFrame([(k, int(m.sum()), mae(y[m.values], P[\"기준(선형 감가상각)\"][m.values]), mae(y[m.values], pred[m.values])) for k, m in conds.items()], columns=[\"조건\", \"대수\", \"기준 MAE\", \"앙상블 MAE\"])\n", + "print(\"\\n[2] 조건별 MAE(일)\\n\" + rr.round(0).to_string(index=False))\n", + "\n", + "# ---------------- 계획정비 일정 추천 ----------------\n", + "rate, true_rul = al[\"rate\"].values, al[\"rul_d\"].values\n", + "mf, mg = plan_fixed(al[\"lin_rul_d\"].values), plan_greedy(pred)\n", + "mm = plan_milp(pred, rate)\n", + "pl = {\"규정 주기 고정 일정(관행)\": mf, \"예측 기반 탐욕 배정\": mg, \"예측 기반 정수계획(제안)\": mm}\n", + "rows = []\n", + "for k, v in pl.items():\n", + " r, kf = evaluate_plan(v, true_rul, rate), kit_fefo(v)\n", + " rows.append((k, int((v >= 0).sum()), r[\"total\"], r[\"fail\"], r[\"planned\"], kf[\"short\"]))\n", + "pt = pd.DataFrame(rows, columns=[\"일정안\", \"배정 대수\", \"실제 총비용(만원)\", \"비계획 고장\", \"계획 정비\", \"키트 유효기간 부족\"])\n", + "print(f\"\\n[3] 12개월 계획정비 일정 비교(월 능력 {CAP_M}대, 월 예산 {BUDGET_M:,.0f}만원, 키트 재고 {sum(q for q, _ in LOTS)}개 로트 {len(LOTS)}종, 비용 단가는 교육용 가정값)\")\n", + "print(pt.round(0).to_string(index=False))\n", + "sv = 1 - pt.iloc[2][\"실제 총비용(만원)\"] / pt.iloc[0][\"실제 총비용(만원)\"]\n", + "print(f\" 정수계획 안은 관행 대비 총비용 {sv:.1%} 절감, 비계획 고장 {int(pt.iloc[0]['비계획 고장'])} -> {int(pt.iloc[2]['비계획 고장'])}대\")\n", + "cnt = np.bincount(mm[mm >= 0], minlength=HORIZON)\n", + "print(\" 월별 정비 대수(제안):\", \" \".join(f\"{i + 1}월 {c}\" for i, c in enumerate(cnt)), f\"(능력 {CAP_M}대 이하 확인: {'OK' if cnt.max() <= CAP_M else '초과'}, 월 예산 확인: {'OK' if (cnt * (C_PLAN + C_KIT)).max() <= BUDGET_M else '초과'})\")\n", + "kf = kit_fefo(mm)\n", + "print(f\" 시효성 키트 FEFO 배정: 유효기간 부족 {kf['short']}건, 만료 임박 폐기 예상 {kf['expired_unused']}개 -> 유효기한이 빠른 로트부터 쓰도록 정비 월을 맞춤\")\n", + "print(\" 추천 일정은 정비계획관 검토와 군수지휘관 승인(M7) 전까지 확정되지 않는다. 장비·단가는 교육용 가상 자료이다.\")\n", + "\n", + "fig, ax = plt.subplots(1, 3, figsize=(15, 4.3))\n", + "ax[0].scatter(y, P[\"기준(선형 감가상각)\"], s=6, label=\"baseline\"); ax[0].scatter(y, pred, s=6, label=\"ensemble\"); ax[0].plot([0, 4000], [0, 4000], \"k--\"); ax[0].legend(); ax[0].set_title(\"RUL: true vs predicted (days)\")\n", + "ax[1].bar(range(len(pt)), pt[\"실제 총비용(만원)\"]); ax[1].set_xticks(range(len(pt))); ax[1].set_xticklabels([\"fixed\", \"greedy\", \"MILP\"]); ax[1].set_title(\"Total cost of 12-month plan (10k KRW)\")\n", + "ax[2].bar(np.arange(1, HORIZON + 1), cnt); ax[2].axhline(CAP_M, c=\"r\", ls=\"--\"); ax[2].set_title(\"Overhauls per month (capacity line)\")\n", + "plt.tight_layout(); plt.show()\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 05 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + "[1] 생존 장비 601대 RUL 예측 성능(학습 900대와 다른 장비, MAPE는 참 RUL 90일 이상)\n", + " MAE(일) RMSE(일) MAPE(%) R2\n", + "기준(선형 감가상각) 354.501 435.605 46.016 0.808\n", + "와이블 AFT 212.324 277.206 24.014 0.922\n", + "GBR(점검 마모 포함) 253.028 396.280 16.536 0.842\n", + "앙상블(제안) 210.946 289.055 17.907 0.916\n", + " 기준 대비 MAE 개선 40.5%, RMSE 개선 33.6% (목표 20% 이상: 달성), R2 0.916 (목표 0.85 이상: 달성)\n", + " 점검 마모 피처를 빼면 GBR MAE 253일 -> 491일, R2 0.842 -> 0.578 (정비 점검 기록의 가치)\n", + "\n", + "[2] 조건별 MAE(일)\n", + " 조건 대수 기준 MAE 앙상블 MAE\n", + " 일반 265 432.0 238.0\n", + " 험지 99 379.0 163.0\n", + " 염분 124 265.0 192.0\n", + " 혹한 113 249.0 212.0\n", + " 고가동(일 4h 이상) 89 240.0 145.0\n", + "비계획 정비율 상위 25% 151 320.0 198.0\n", + "\n", + "[3] 12개월 계획정비 일정 비교(월 능력 22대, 월 예산 15,000만원, 키트 재고 190개 로트 4종, 비용 단가는 교육용 가정값)\n", + " 일정안 배정 대수 실제 총비용(만원) 비계획 고장 계획 정비 키트 유효기간 부족\n", + "규정 주기 고정 일정(관행) 123 184752.0 47 97 0\n", + " 예측 기반 탐욕 배정 105 141558.0 35 79 0\n", + " 예측 기반 정수계획(제안) 101 116447.0 21 90 0\n", + " 정수계획 안은 관행 대비 총비용 37.0% 절감, 비계획 고장 47 -> 21대\n", + " 월별 정비 대수(제안): 1월 20 2월 20 3월 20 4월 20 5월 20 6월 1 7월 0 8월 0 9월 0 10월 0 11월 0 12월 0 (능력 22대 이하 확인: OK, 월 예산 확인: OK)\n", + " 시효성 키트 FEFO 배정: 유효기간 부족 0건, 만료 임박 폐기 예상 10개 -> 유효기한이 빠른 로트부터 쓰도록 정비 월을 맞춤\n", + " 추천 일정은 정비계획관 검토와 군수지휘관 승인(M7) 전까지 확정되지 않는다. 장비·단가는 교육용 가상 자료이다.\n", + "```\n", + "
\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 06. 부대별 장비 노후도 현황 및 권장 정비 캘린더 대시보드 구현\n", + "**소요시간**: 약 3.0시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "부대별 장비 노후도 현황 및 권장 정비 캘린더 대시보드를 구현한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **부대별 장비 노후도 현황과 권장 정비 캘린더 대시보드를 구현하고 예측 근거를 설명한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **부대별 장비 수, 평균 노후 지수, 평균 예측 잔여 수명, 고위험 비율을 집계 수행하고 검증하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `src/explainability/sup02_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": [ + "\"\"\"부대별 장비 노후도 현황과 권장 정비 캘린더 대시보드를 구현하고, 장비마다 잔여 수명(RUL) 예측의 기여도 상위 3개 요인을 변수군 평균 대체 방식으로 자동 산출해 자연어 설명으로 보여 준다.\"\"\"\n", + "import numpy as np\n", + "import pandas as pd\n", + "import matplotlib.pyplot as plt\n", + "\n", + "# ---------------------------------------------------------------------------\n", + "# [교육용가상자료] 가상 장비 보유 현황·고장 이력 생성기 (실제 부대·장비·수치 없음)\n", + "# 가상 부대 6개(U1~U6)의 장비가 운용 환경(normal/rough/salt/cold)에 따라 다른 속도로 노후화한다. 창정비 후 운용시간(h_since)이\n", + "# 장비마다 숨은 수명(t_fail)을 넘으면 고장(event=1) 이력, 넘지 않았으면 현재까지 생존(event=0, 관측 중도절단)이다.\n", + "# ---------------------------------------------------------------------------\n", + "ENVS = [\"normal\", \"rough\", \"salt\", \"cold\"]\n", + "ENV_W = {\"normal\": 1.00, \"rough\": 1.35, \"salt\": 1.25, \"cold\": 1.12} # 환경 가중치(숨은 정답, 가정값): 클수록 수명이 짧다\n", + "ENV_KO = {\"normal\": \"일반\", \"rough\": \"험지\", \"salt\": \"염분\", \"cold\": \"혹한\"}\n", + "UNITS = [\"U1\", \"U2\", \"U3\", \"U4\", \"U5\", \"U6\"]\n", + "UNIT_ENV = {\"U1\": [.7, .1, .1, .1], \"U2\": [.2, .6, .1, .1], \"U3\": [.2, .1, .6, .1], \"U4\": [.3, .1, .1, .5], \"U5\": [.5, .3, .1, .1], \"U6\": [.4, .1, .4, .1]}\n", + "ETYPES = [\"truck\", \"generator\", \"trailer\", \"radio\"]\n", + "CYCLE = 8000.0 # 규정 창정비 주기(운용시간 h, 가정값)\n", + "KSHAPE = 12.0 # 수명 분포 와이블 형상(마모형 고장, 클수록 수명이 모여 있다)\n", + "INSP_NOISE = 0.025 # 정비 점검 마모 비율 측정 오차(가정값)\n", + "\n", + "\n", + "def make_fleet(n=900, seed=0, env_w=None, shift=1.0):\n", + " \"\"\"장비 n대. env_w로 숨은 환경 가중치를 바꿔 운영 분포 변화를 모의할 수 있다. shift>1이면 가동률이 높아진다.\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " w = env_w or ENV_W\n", + " unit = rng.choice(UNITS, n)\n", + " env = np.array([rng.choice(ENVS, p=UNIT_ENV[u]) for u in unit])\n", + " et = rng.choice(ETYPES, n, p=[.4, .2, .2, .2])\n", + " rate = rng.lognormal(np.log(3.0 * shift), 0.25, n) # 일 평균 운용시간(h/일)\n", + " h_since = rng.uniform(300, 11500, n) # 마지막 창정비 이후 운용시간\n", + " n_ovh = rng.integers(0, 3, n)\n", + " hours = h_since + n_ovh * rng.uniform(5000, 8000, n) # 누적 운용시간\n", + " km = hours * rng.normal(17, 3, n) * np.where(env == \"rough\", 0.8, 1.0) # 누적 기동거리(km)\n", + " age_y = np.clip(hours / (rate * 365) * rng.uniform(0.9, 1.15, n), 0.5, 25)\n", + " unpl = rng.poisson(0.35 * h_since / 1000 * np.array([w[e] for e in env]) / 1.3) # 비계획 정비 건수(환경이 가혹할수록 많다)\n", + " n_maint = rng.poisson(0.6 * h_since / 1000 + 1) # 계획 정비 건수\n", + " scale = 9600 / np.array([w[e] for e in env]) * 0.985 ** unpl * np.exp(rng.normal(0, 0.04, n))\n", + " t_fail = scale * rng.weibull(KSHAPE, n) # 창정비 후 고장까지 운용시간(숨은 정답)\n", + " event = (t_fail <= h_since).astype(int)\n", + " dur = np.where(event == 1, t_fail, h_since)\n", + " df = pd.DataFrame({\"eq_id\": [f\"EQ{seed:02d}{i:04d}\" for i in range(n)], \"unit\": unit, \"env\": env, \"etype\": et, \"rate\": rate, \"hours\": hours, \"km\": km, \"age_y\": age_y, \"n_ovh\": n_ovh,\n", + " \"h_since\": h_since, \"unpl\": unpl, \"n_maint\": n_maint, \"t_fail\": t_fail, \"event\": event, \"dur\": dur})\n", + " df[\"insp\"] = np.clip(dur / t_fail + rng.normal(0, INSP_NOISE, n), 0, 1.3) # 마지막 정비 점검에서 측정한 마모 비율(소모 수명/총 수명, 오차 포함)\n", + " df[\"rul_h\"] = np.where(event == 0, t_fail - h_since, np.nan) # 생존 장비의 남은 운용시간(정답)\n", + " df[\"rul_d\"] = df[\"rul_h\"] / df[\"rate\"] # 남은 일수(정답)\n", + " return df\n", + "\n", + "\n", + "def mape(y, p):\n", + " y, p = np.asarray(y, float), np.asarray(p, float)\n", + " return float(np.mean(np.abs(y - p) / np.abs(y)) * 100)\n", + "\n", + "\n", + "def mae(y, p):\n", + " return float(np.mean(np.abs(np.asarray(y, float) - np.asarray(p, float))))\n", + "\n", + "\n", + "def rmse(y, p):\n", + " return float(np.sqrt(np.mean((np.asarray(y, float) - np.asarray(p, float)) ** 2)))\n", + "# ---------------------------------------------------------------------------\n", + "# ---------------------------------------------------------------------------\n", + "# 노후화 파생 피처와 학습 표본 만들기. 환경 가중치 W0는 도입 시점의 전문가 추정값이며(실제 가중치와 다름) M9에서 재보정한다.\n", + "# ---------------------------------------------------------------------------\n", + "W0 = {\"normal\": 1.00, \"rough\": 1.15, \"salt\": 1.10, \"cold\": 1.05}\n", + "FEATS = [\"h_since\", \"hours\", \"km\", \"age_y\", \"rate\", \"n_maint\", \"unpl\", \"unpl_per_kh\", \"km_per_h\", \"n_ovh\", \"k_rough\", \"k_salt\", \"k_cold\", \"k_truck\", \"k_gen\", \"k_trailer\",\n", + " \"aging_idx\", \"lin_rul_d\", \"hs_frac\", \"insp\", \"insp_eta_h\"]\n", + "\n", + "\n", + "def add_features(df, w=None):\n", + " d = df.copy()\n", + " w = w or W0\n", + " d[\"unpl_per_kh\"] = d[\"unpl\"] / (d[\"h_since\"] / 1000 + 0.5)\n", + " d[\"km_per_h\"] = d[\"km\"] / d[\"hours\"]\n", + " for e in (\"rough\", \"salt\", \"cold\"):\n", + " d[\"k_\" + e] = (d[\"env\"] == e).astype(int)\n", + " d[\"k_truck\"], d[\"k_gen\"], d[\"k_trailer\"] = (d[\"etype\"] == \"truck\").astype(int), (d[\"etype\"] == \"generator\").astype(int), (d[\"etype\"] == \"trailer\").astype(int)\n", + " d[\"aging_idx\"] = d[\"h_since\"] * d[\"env\"].map(w) / CYCLE # 환경 가중 노후 지수(1이면 창정비 주기에 도달)\n", + " d[\"lin_rul_d\"] = (CYCLE - d[\"h_since\"]) / d[\"rate\"] # 선형 감가상각 잔여 일수(Baseline)\n", + " d[\"hs_frac\"] = d[\"h_since\"] / CYCLE\n", + " d[\"insp_eta_h\"] = d[\"h_since\"] / np.maximum(d[\"insp\"], 0.05) # 점검 마모 비율로 환산한 총 수명 추정(h)\n", + " return d\n", + "\n", + "\n", + "def snapshots(df, k=4, seed=0):\n", + " \"\"\"고장 이력 장비의 과거 시점 표본: 고장 전 임의 시점의 점검값·운용시간과 그때의 남은 일수. (생존 장비의 미래는 알 수 없으므로 고장 이력만 쓴다)\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " f = df[df[\"event\"] == 1]\n", + " out = []\n", + " for _ in range(k):\n", + " s = f.copy()\n", + " h = s[\"t_fail\"] * rng.uniform(0.2, 0.98, len(s))\n", + " ratio = h / s[\"h_since\"]\n", + " s[\"hours\"] = s[\"hours\"] - (s[\"h_since\"] - h)\n", + " s[\"km\"] = s[\"km\"] * ratio.clip(upper=1)\n", + " s[\"unpl\"] = np.floor(s[\"unpl\"] * ratio)\n", + " s[\"n_maint\"] = np.floor(s[\"n_maint\"] * ratio)\n", + " s[\"insp\"] = np.clip(h / s[\"t_fail\"] + rng.normal(0, INSP_NOISE, len(s)), 0, 1.3)\n", + " s[\"h_since\"] = h\n", + " s[\"rul_d\"] = (s[\"t_fail\"] - h) / s[\"rate\"]\n", + " s[\"event\"] = 0\n", + " out.append(s)\n", + " return pd.concat(out, ignore_index=True)\n", + "# ---------------------------------------------------------------------------\n", + "# ---------------------------------------------------------------------------\n", + "# 생존 분석 도구(scipy만 사용): 와이블 AFT(가속 수명) 모형과 Cox 비례위험 모형\n", + "# ---------------------------------------------------------------------------\n", + "from scipy.optimize import minimize\n", + "\n", + "\n", + "def aft_x(d):\n", + " return np.c_[np.ones(len(d)), d[\"env\"].eq(\"rough\"), d[\"env\"].eq(\"salt\"), d[\"env\"].eq(\"cold\"), d[\"unpl\"] / (d[\"h_since\"] / 1000 + 0.5)].astype(float)\n", + "\n", + "\n", + "AFT_COLS = [\"절편\", \"험지\", \"염분\", \"혹한\", \"비계획 정비율\"]\n", + "\n", + "\n", + "def fit_aft(d):\n", + " \"\"\"창정비 후 운용시간 dur(고장이면 event=1, 생존이면 중도절단)에 대한 와이블 AFT 최대우도 추정. 반환: 계수(마지막은 log 형상).\"\"\"\n", + " x, t, e = aft_x(d), d[\"dur\"].values, d[\"event\"].values\n", + "\n", + " def nll(p):\n", + " lam, k = np.exp(x @ p[:-1]), np.exp(p[-1])\n", + " z = (t / lam) ** k\n", + " return -(e * (np.log(k) - np.log(lam) + (k - 1) * (np.log(t) - np.log(lam))) - z).sum()\n", + "\n", + " r = minimize(nll, np.r_[np.log(8000), np.zeros(x.shape[1] - 1), np.log(5)], method=\"BFGS\")\n", + " return r.x\n", + "\n", + "\n", + "def aft_pred(p, d, q=0.5, h=None):\n", + " \"\"\"이미 h(운용시간)까지 생존했다는 조건에서 분위수 q 생존 시점(운용시간 h 기준 총 수명)을 돌려준다. q=0.5가 조건부 중앙값.\"\"\"\n", + " lam, k = np.exp(aft_x(d) @ p[:-1]), np.exp(p[-1])\n", + " h = d[\"h_since\"].values if h is None else h\n", + " return lam * ((h / lam) ** k - np.log(q)) ** (1 / k)\n", + "\n", + "\n", + "def aft_fail_prob(p, d, h0, h1):\n", + " \"\"\"h0까지 생존한 장비가 h0~h1 사이에 고장 날 조건부 확률.\"\"\"\n", + " lam, k = np.exp(aft_x(d) @ p[:-1]), np.exp(p[-1])\n", + " s0, s1 = np.exp(-(h0 / lam) ** k), np.exp(-(h1 / lam) ** k)\n", + " return 1 - s1 / s0\n", + "\n", + "\n", + "def fit_cox(d, iters=25):\n", + " \"\"\"Cox 비례위험(Breslow 근사) 뉴턴-랩슨. 공변량: 험지·염분·혹한·비계획 정비율. 반환: 계수.\"\"\"\n", + " x = aft_x(d)[:, 1:]\n", + " o = np.argsort(-d[\"dur\"].values)\n", + " x, e = x[o], d[\"event\"].values[o]\n", + " b = np.zeros(x.shape[1])\n", + " for _ in range(iters):\n", + " r = np.exp(x @ b)\n", + " S0 = np.cumsum(r)\n", + " S1 = np.cumsum(r[:, None] * x, axis=0)\n", + " S2 = np.cumsum(r[:, None, None] * x[:, :, None] * x[:, None, :], axis=0)\n", + " m1 = S1 / S0[:, None]\n", + " g = ((x - m1) * e[:, None]).sum(axis=0)\n", + " H = -sum(e[i] * (S2[i] / S0[i] - np.outer(m1[i], m1[i])) for i in np.where(e == 1)[0])\n", + " step = np.linalg.solve(H - 1e-6 * np.eye(len(b)), g)\n", + " b = b - step\n", + " if np.abs(step).max() < 1e-7:\n", + " break\n", + " return b\n", + "\n", + "\n", + "def cindex(time, event, risk):\n", + " \"\"\"일치도 C-index: 위험 점수가 높은 쪽이 먼저 고장 났는가(관측 가능한 쌍만).\"\"\"\n", + " t, e, r = np.asarray(time), np.asarray(event), np.asarray(risk)\n", + " num = den = 0.0\n", + " for i in np.where(e == 1)[0]:\n", + " m = t > t[i]\n", + " den += m.sum()\n", + " num += (r[m] < r[i]).sum() + 0.5 * (r[m] == r[i]).sum()\n", + " return num / max(den, 1)\n", + "# ---------------------------------------------------------------------------\n", + "# ---------------------------------------------------------------------------\n", + "# 잔여 수명(RUL, 일) 예측 엔진: 와이블 AFT 조건부 중앙값 + 그래디언트 부스팅(고장 이력 시점 표본 학습)의 평균\n", + "# ---------------------------------------------------------------------------\n", + "from sklearn.ensemble import GradientBoostingRegressor\n", + "\n", + "\n", + "def fit_engine(train, seed=0):\n", + " p = fit_aft(train)\n", + " sn = add_features(snapshots(train, 5, seed))\n", + " sn[\"aft\"] = (aft_pred(p, sn) - sn[\"h_since\"]) / sn[\"rate\"]\n", + " g = GradientBoostingRegressor(n_estimators=250, max_depth=3, learning_rate=0.06, random_state=0).fit(sn[FEATS + [\"aft\"]], sn[\"rul_d\"])\n", + " return {\"p\": p, \"g\": g}\n", + "\n", + "\n", + "def predict_rul(eng, d):\n", + " \"\"\"d는 add_features를 거친 표. 반환: (앙상블 RUL, AFT RUL, GBR RUL) - 일 단위.\"\"\"\n", + " aft = ((aft_pred(eng[\"p\"], d) - d[\"h_since\"]) / d[\"rate\"]).values\n", + " x = d[FEATS].copy()\n", + " x[\"aft\"] = aft\n", + " gb = eng[\"g\"].predict(x)\n", + " return np.clip(0.5 * aft + 0.5 * gb, 1, None), aft, gb\n", + "# ---------------------------------------------------------------------------\n", + "# ---------------------------------------------------------------------------\n", + "# 계획정비(창정비) 일정 추천: 월별 정비창 능력·예산·시효성 부품 키트(유효기한) 제약 아래 기대 비용 최소화 (비용 단가는 교육용 가정값, 만원)\n", + "# ---------------------------------------------------------------------------\n", + "from scipy.optimize import milp, LinearConstraint, Bounds\n", + "from scipy.stats import norm\n", + "\n", + "C_PLAN, C_FAIL, C_WASTE_KH, C_KIT = 600.0, 2400.0, 40.0, 120.0 # 계획 창정비, 비계획 고장(긴급 정비 포함), 미사용 수명 1000h당 손실, 시효성 부품 키트\n", + "HORIZON, MDAYS = 12, 30 # 계획 12개월, 월 30일\n", + "CAP_M, BUDGET_M = 22, 15000.0 # 월별 정비창 능력(대), 월별 예산\n", + "LOTS = [(30, 70), (45, 170), (45, 390), (70, 800)] # 시효성 키트 로트(수량, 유효기한까지 남은 일수)\n", + "KIT_MARGIN = 14 # 정비일 기준 최소 유효기간 여유(일)\n", + "\n", + "\n", + "def exp_cost_matrix(pred, rate, month_days):\n", + " \"\"\"장비 i를 월 m에 정비할 때의 기대 비용. 참 RUL ~ N(pred, sigma), sigma = 35일 + 0.12 x pred (가정). 마지막 열은 계획 기간 밖(미정비).\"\"\"\n", + " sig = 35 + 0.12 * pred\n", + " C = np.zeros((len(pred), len(month_days) + 1))\n", + " for j, day in enumerate(month_days):\n", + " z = (day - pred) / sig\n", + " pf = norm.cdf(z)\n", + " unused = np.maximum(pred - day, 0) * rate * 0.6 # 정비를 앞당긴 만큼 버리는 운용시간 h(근사)\n", + " C[:, j] = C_PLAN + C_KIT + pf * (C_FAIL - C_PLAN) + C_WASTE_KH * unused / 1000 * (1 - pf)\n", + " C[:, -1] = norm.cdf((HORIZON * MDAYS - pred) / sig) * C_FAIL\n", + " return C\n", + "\n", + "\n", + "def plan_milp(pred, rate, cap=CAP_M, budget=BUDGET_M, lots=LOTS, use_kit=True):\n", + " \"\"\"정수계획으로 장비별 정비 월(0~11) 또는 미정비(-1)를 배정한다. 후보는 예측 RUL이 계획 기간 +90일 이내인 장비.\"\"\"\n", + " md = np.arange(1, HORIZON + 1) * MDAYS\n", + " cand = np.where(pred < HORIZON * MDAYS + 90)[0]\n", + " n, M = len(cand), HORIZON\n", + " C = exp_cost_matrix(pred[cand], rate[cand], md)\n", + " c = C.ravel()\n", + " A, lo, hi = [], [], []\n", + " for i in range(n): # 장비당 한 번만 배정(월 12칸 + 미정비)\n", + " row = np.zeros(n * (M + 1)); row[i * (M + 1):(i + 1) * (M + 1)] = 1; A.append(row); lo.append(1); hi.append(1)\n", + " for m in range(M): # 월별 능력·예산\n", + " row = np.zeros(n * (M + 1)); row[m::(M + 1)] = 1\n", + " A.append(row); lo.append(0); hi.append(cap)\n", + " A.append(row * (C_PLAN + C_KIT)); lo.append(0); hi.append(budget)\n", + " if use_kit: # 시효성 키트: m월 이후 정비 수요는 그 시점에 유효기간이 남은 로트 수량을 넘을 수 없다\n", + " for m in range(M):\n", + " sup = sum(q for q, e in lots if e >= md[m] + KIT_MARGIN)\n", + " row = np.zeros(n * (M + 1))\n", + " for mm in range(m, M):\n", + " row[mm::(M + 1)] = 1\n", + " A.append(row); lo.append(0); hi.append(sup)\n", + " r = milp(c, constraints=LinearConstraint(np.array(A), lo, hi), integrality=np.ones(len(c)), bounds=Bounds(0, 1), options={\"time_limit\": 20})\n", + " month = np.full(len(pred), -1)\n", + " if r.x is not None:\n", + " x = np.round(r.x).reshape(n, M + 1)\n", + " assign = np.where(x[:, :M].sum(axis=1) > 0.5, x[:, :M].argmax(axis=1), -1)\n", + " month[cand] = assign\n", + " return month\n", + "\n", + "\n", + "def plan_greedy(pred, margin=45, cap=CAP_M, budget=BUDGET_M):\n", + " \"\"\"간단한 탐욕 배정: 정비 목표일(예측 RUL - margin)이 빠른 순으로 목표일 이전 가장 늦은 빈 월, 없으면 가장 이른 빈 월에 배정.\"\"\"\n", + " cap = min(cap, int(budget // (C_PLAN + C_KIT))) # 월 예산이 허용하는 대수로 능력을 제한\n", + " month = np.full(len(pred), -1)\n", + " left = np.full(HORIZON, cap)\n", + " for i in np.argsort(pred):\n", + " if pred[i] > HORIZON * MDAYS + 60: break\n", + " tgt = int(np.clip((pred[i] - margin) // MDAYS - 1, 0, HORIZON - 1))\n", + " for m in list(range(tgt, -1, -1)) + list(range(tgt + 1, HORIZON)):\n", + " if left[m] > 0:\n", + " month[i] = m; left[m] -= 1; break\n", + " return month\n", + "\n", + "\n", + "def plan_fixed(lin_rul, cap=CAP_M, budget=BUDGET_M):\n", + " \"\"\"관행: 규정 주기 도달 순서대로(선형 잔여 일수) 월별 능력 안에서 배정.\"\"\"\n", + " return plan_greedy(np.asarray(lin_rul, float), margin=0, cap=cap, budget=budget)\n", + "\n", + "\n", + "def evaluate_plan(month, true_rul, rate, cost_kit=True):\n", + " \"\"\"참 RUL로 계획의 실제 비용을 계산한다: 정비일 이전에 고장 -> 비계획 고장 비용, 아니면 계획 비용 + 미사용 수명 손실.\"\"\"\n", + " day = np.where(month >= 0, (month + 1) * MDAYS, 10 ** 9)\n", + " fail = (true_rul < day) & (true_rul < HORIZON * MDAYS)\n", + " planned = (month >= 0) & ~fail\n", + " waste = np.where(planned, (true_rul - day) * rate * 0.6, 0) * C_WASTE_KH / 1000\n", + " cost = fail * C_FAIL + planned * (C_PLAN + (C_KIT if cost_kit else 0)) + waste\n", + " return {\"total\": float(cost.sum()), \"fail\": int(fail.sum()), \"planned\": int(planned.sum()), \"waste\": float(waste.sum()), \"unsched_fail\": int(((month < 0) & fail).sum())}\n", + "\n", + "\n", + "def kit_fefo(month, lots=LOTS):\n", + " \"\"\"유효기한이 빠른 로트부터 쓰는 FEFO로 키트를 배정하고 정비일에 유효기간이 부족한 배정 수와 만료 폐기 수량을 센다.\"\"\"\n", + " q = [[qty, e] for qty, e in sorted(lots, key=lambda t: t[1])]\n", + " short = 0\n", + " for m in sorted(month[month >= 0]):\n", + " day = (m + 1) * MDAYS\n", + " for lot in q:\n", + " if lot[1] >= day + KIT_MARGIN and lot[0] > 0:\n", + " lot[0] -= 1; break\n", + " else:\n", + " short += 1\n", + " expired = sum(l[0] for l in q if l[1] < HORIZON * MDAYS)\n", + " return {\"short\": short, \"expired_unused\": expired, \"left\": sum(l[0] for l in q)}\n", + "# ---------------------------------------------------------------------------\n", + "\n", + "tr = make_fleet(900, seed=1)\n", + "eng = fit_engine(tr)\n", + "fl = add_features(make_fleet(900, seed=3))\n", + "al = fl[fl.event == 0].reset_index(drop=True)\n", + "al[\"rul_pred\"], _, _ = predict_rul(eng, al)\n", + "al[\"month\"] = plan_milp(al[\"rul_pred\"].values, al[\"rate\"].values)\n", + "al[\"p90\"] = aft_fail_prob(eng[\"p\"], al, al[\"h_since\"].values, al[\"h_since\"].values + 90 * al[\"rate\"].values)\n", + "\n", + "# ---------------- 1) 부대별 노후도 현황 ----------------\n", + "g = al.groupby(\"unit\")\n", + "board = pd.DataFrame({\"장비 수\": g.size(), \"평균 노후 지수\": g[\"aging_idx\"].mean(), \"평균 예측 RUL(일)\": g[\"rul_pred\"].mean(), \"180일 내 고위험\": g.apply(lambda x: int((x.rul_pred < 180).sum())),\n", + " \"험지·염분 비율\": g.apply(lambda x: x.env.isin([\"rough\", \"salt\"]).mean()), \"12개월 내 권장 정비\": g.apply(lambda x: int((x.month >= 0).sum()))})\n", + "board[\"고위험 비율\"] = board[\"180일 내 고위험\"] / board[\"장비 수\"]\n", + "print(f\"[1] 부대별 노후도 현황(생존 장비 {len(al)}대, 가상 부대 6개) [교육용가상자료]\")\n", + "print(board.round(2).to_string())\n", + "worst = board[\"고위험 비율\"].idxmax()\n", + "print(f\" 고위험 비율이 가장 높은 부대 {worst}({board.loc[worst, '고위험 비율']:.0%}), 환경 구성(험지·염분 {board.loc[worst, '험지·염분 비율']:.0%})과 함께 본다.\")\n", + "\n", + "# ---------------- 2) 권장 정비 캘린더 ----------------\n", + "cal = pd.crosstab(al.loc[al.month >= 0, \"unit\"], al.loc[al.month >= 0, \"month\"] + 1).reindex(columns=range(1, HORIZON + 1), fill_value=0)\n", + "print(\"\\n[2] 권장 정비 캘린더(부대 x 월, 대수; 월 예산·능력·키트 유효기간 반영)\")\n", + "print(cal.to_string())\n", + "print(f\" 월 합계: \" + \" \".join(f\"{m}월 {int(c)}\" for m, c in cal.sum().items()) + f\" / 정비 일정이 잡히지 않은 고위험(RUL 180일 미만) 장비 {int(((al.month < 0) & (al.rul_pred < 180)).sum())}대\")\n", + "top = al.sort_values(\"rul_pred\").head(8)\n", + "print(\"\\n 위험 상위 8대 [권장 월은 승인 전 추천안]\")\n", + "print(pd.DataFrame({\"장비\": top.eq_id, \"부대\": top.unit, \"환경\": top.env.map(ENV_KO), \"예측 RUL(일)\": top.rul_pred, \"90일 내 고장 확률\": top.p90, \"권장 월\": top.month.map(lambda m: f\"{m + 1}월\" if m >= 0 else \"-\")}).round(2).to_string(index=False))\n", + "\n", + "# ---------------- 3) 기여도 상위 3개(변수군 평균 대체) ----------------\n", + "GROUPS = {\"운용 이력(경과 시간·거리·연수)\": [\"h_since\", \"hours\", \"km\", \"age_y\"], \"운용 환경\": [\"env\"], \"정비 이력(비계획·계획 정비)\": [\"unpl\", \"n_maint\"], \"점검 마모 비율\": [\"insp\"], \"일 가동률\": [\"rate\"]}\n", + "MEAN = tr[[\"h_since\", \"hours\", \"km\", \"age_y\", \"unpl\", \"n_maint\", \"insp\", \"rate\"]].mean()\n", + "raw_cols = [\"eq_id\", \"unit\", \"env\", \"etype\", \"rate\", \"hours\", \"km\", \"age_y\", \"n_ovh\", \"h_since\", \"unpl\", \"n_maint\", \"insp\"]\n", + "base_raw = al[raw_cols].copy()\n", + "full = al[\"rul_pred\"].values\n", + "contrib = {}\n", + "for gname, cols in GROUPS.items():\n", + " x = base_raw.copy()\n", + " for c in cols:\n", + " x[c] = \"normal\" if c == \"env\" else MEAN[c]\n", + " xf = add_features(x.assign(event=0, t_fail=0.0, dur=x[\"h_since\"]))\n", + " contrib[gname] = full - predict_rul(eng, xf)[0] # 음수 = 이 요인 때문에 평균 장비보다 잔여 수명이 짧다\n", + "C = pd.DataFrame(contrib)\n", + "t3 = C.apply(lambda r: r.reindex(r.abs().sort_values(ascending=False).index)[:3], axis=1)\n", + "okn = int(sum(t3.iloc[i].notna().sum() == 3 and np.isfinite(t3.iloc[i].dropna().values).all() for i in range(len(t3))))\n", + "print(f\"\\n[3] 장비 {len(al)}대 중 기여도 상위 3개 요인 자동 산출 {okn}대 ({okn / len(al):.0%}, 목표 100%)\")\n", + "print(\" 요인군별 평균 기여 크기(일):\", \", \".join(f\"{k} {v:.0f}\" for k, v in C.abs().mean().sort_values(ascending=False).items()))\n", + "\n", + "\n", + "def explain(i):\n", + " r, row = al.loc[i], C.loc[i]\n", + " ordered = row.reindex(row.abs().sort_values(ascending=False).index)[:3]\n", + " main = ordered.index[0]\n", + " why = {\"운용 이력(경과 시간·거리·연수)\": \"창정비 이후 운용 시간이 길어\", \"운용 환경\": f\"{ENV_KO[r.env]} 환경 가혹도 때문에\", \"정비 이력(비계획·계획 정비)\": \"비계획 정비가 잦아\", \"점검 마모 비율\": \"점검 마모 비율이 높아\", \"일 가동률\": \"일 가동률이 높아\"}[main]\n", + " return f\"{r.eq_id}({r.unit}, {ENV_KO[r.env]}): {why} 잔여 수명 {r.rul_pred:.0f}일 예측. 주요 요인 \" + \", \".join(f\"{k.split('(')[0]}({v:+.0f}일)\" for k, v in ordered.items())\n", + "\n", + "\n", + "print(\"[4] 위험 상위 3대 자연어 설명(음수 = 평균 장비보다 잔여 수명이 짧아지는 요인)\")\n", + "for i in top.index[:3]:\n", + " print(\" -\", explain(i))\n", + "print(\" 대시보드와 설명은 정비계획관의 판단 보조이며 일정 확정과 장비 도태 건의는 군수지휘관 승인(M7)에 따른다.\")\n", + "\n", + "fig, ax = plt.subplots(2, 2, figsize=(13, 8))\n", + "ax[0, 0].bar(board.index, board[\"고위험 비율\"] * 100); ax[0, 0].set_title(\"1 High-risk share by unit (%, RUL < 180 days)\")\n", + "im = ax[0, 1].imshow(cal.values, aspect=\"auto\", cmap=\"YlOrRd\"); ax[0, 1].set_xticks(range(HORIZON)); ax[0, 1].set_xticklabels(range(1, HORIZON + 1)); ax[0, 1].set_yticks(range(len(cal))); ax[0, 1].set_yticklabels(cal.index)\n", + "ax[0, 1].set_title(\"2 Recommended overhaul calendar (unit x month)\"); plt.colorbar(im, ax=ax[0, 1])\n", + "ax[1, 0].bar(board.index, board[\"평균 노후 지수\"]); ax[1, 0].axhline(1, c=\"r\", ls=\"--\"); ax[1, 0].set_title(\"3 Mean aging index by unit\")\n", + "r0 = C.loc[top.index[0]]\n", + "ax[1, 1].barh([\"usage history\", \"environment\", \"maintenance history\", \"inspection wear\", \"daily usage\"][::-1], r0.values[::-1]); ax[1, 1].set_title(\"4 Contribution to RUL, riskiest unit (days)\")\n", + "plt.tight_layout(); plt.show()\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 06 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + "[1] 부대별 노후도 현황(생존 장비 601대, 가상 부대 6개) [교육용가상자료]\n", + " 장비 수 평균 노후 지수 평균 예측 RUL(일) 180일 내 고위험 험지·염분 비율 12개월 내 권장 정비 고위험 비율\n", + "unit \n", + "U1 110 0.58 1267.69 5 0.13 21 0.05\n", + "U2 87 0.50 1340.48 5 0.64 11 0.06\n", + "U3 101 0.53 1200.60 4 0.63 15 0.04\n", + "U4 110 0.57 1259.99 7 0.13 24 0.06\n", + "U5 106 0.58 1287.43 6 0.31 18 0.06\n", + "U6 87 0.51 1370.82 5 0.48 12 0.06\n", + " 고위험 비율이 가장 높은 부대 U4(6%), 환경 구성(험지·염분 13%)과 함께 본다.\n", + "\n", + "[2] 권장 정비 캘린더(부대 x 월, 대수; 월 예산·능력·키트 유효기간 반영)\n", + "month 1 2 3 4 5 6 7 8 9 10 11 12\n", + "unit \n", + "U1 1 8 2 6 4 0 0 0 0 0 0 0\n", + "U2 3 2 3 1 2 0 0 0 0 0 0 0\n", + "U3 4 1 3 4 3 0 0 0 0 0 0 0\n", + "U4 4 4 7 3 5 1 0 0 0 0 0 0\n", + "U5 6 1 3 4 4 0 0 0 0 0 0 0\n", + "U6 2 4 2 2 2 0 0 0 0 0 0 0\n", + " 월 합계: 1월 20 2월 20 3월 20 4월 20 5월 20 6월 1 7월 0 8월 0 9월 0 10월 0 11월 0 12월 0 / 정비 일정이 잡히지 않은 고위험(RUL 180일 미만) 장비 0대\n", + "\n", + " 위험 상위 8대 [권장 월은 승인 전 추천안]\n", + " 장비 부대 환경 예측 RUL(일) 90일 내 고장 확률 권장 월\n", + "EQ030723 U6 염분 78.41 0.49 1월\n", + "EQ030395 U2 험지 83.07 0.72 1월\n", + "EQ030208 U5 혹한 88.97 0.73 1월\n", + "EQ030501 U4 일반 90.25 0.43 1월\n", + "EQ030658 U3 혹한 98.04 0.49 1월\n", + "EQ030648 U2 험지 98.17 0.41 1월\n", + "EQ030142 U4 일반 107.32 0.66 1월\n", + "EQ030652 U3 혹한 107.97 0.34 1월\n", + "\n", + "[3] 장비 601대 중 기여도 상위 3개 요인 자동 산출 601대 (100%, 목표 100%)\n", + " 요인군별 평균 기여 크기(일): 운용 이력(경과 시간·거리·연수) 487, 점검 마모 비율 344, 일 가동률 260, 운용 환경 207, 정비 이력(비계획·계획 정비) 94\n", + "[4] 위험 상위 3대 자연어 설명(음수 = 평균 장비보다 잔여 수명이 짧아지는 요인)\n", + " - EQ030723(U6, 염분): 점검 마모 비율이 높아 잔여 수명 78일 예측. 주요 요인 점검 마모 비율(-286일), 운용 환경(-172일), 운용 이력(-128일)\n", + " - EQ030395(U2, 험지): 점검 마모 비율이 높아 잔여 수명 83일 예측. 주요 요인 점검 마모 비율(-279일), 운용 환경(-218일), 운용 이력(-110일)\n", + " - EQ030208(U5, 혹한): 창정비 이후 운용 시간이 길어 잔여 수명 89일 예측. 주요 요인 운용 이력(-382일), 점검 마모 비율(-351일), 운용 환경(-64일)\n", + " 대시보드와 설명은 정비계획관의 판단 보조이며 일정 확정과 장비 도태 건의는 군수지휘관 승인(M7)에 따른다.\n", + "```\n", + "
\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 07. 정비 일정 확정 및 장비 도태 건의를 위한 군수지휘관 HITL 승인 절차 설계\n", + "**소요시간**: 약 3.5시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "정비 일정 확정을 위한 군수지휘관 HITL 승인 절차를 설계한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **정비 일정 확정과 장비 도태 건의를 위한 군수지휘관 HITL 승인 절차를 설계하고 비용 절감 효과를 검증한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **관행 일정 대비 제안 일정의 12개월 비용을 평상, 정비창 능력 감소, 예산 삭감, 키트 재고 부족, 가동률 상승 등 시나리오별로 비교하고 백테스트와 민감도 확인하고 유효성을 점검하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `src/optimization/sup02_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": [ + "\"\"\"정비창 능력·예산·시효성 키트 제약 아래 예측 기반 계획정비 일정을 규정 주기 고정 일정과 비교해 비용·키트 소진율을 시나리오·백테스트로 검증하고(비용 15% 이상 절감), 정비 일정 확정과 장비 도태 건의를 군수지휘관(도태는 재정 담당 2단계)이 직접 승인·반려하는 HITL 결재 워크플로우(권한·사유·해시 체인 감사 로그)를 구현한다.\"\"\"\n", + "import hashlib\n", + "import json\n", + "import numpy as np\n", + "import pandas as pd\n", + "import matplotlib.pyplot as plt\n", + "\n", + "# ---------------------------------------------------------------------------\n", + "# [교육용가상자료] 가상 장비 보유 현황·고장 이력 생성기 (실제 부대·장비·수치 없음)\n", + "# 가상 부대 6개(U1~U6)의 장비가 운용 환경(normal/rough/salt/cold)에 따라 다른 속도로 노후화한다. 창정비 후 운용시간(h_since)이\n", + "# 장비마다 숨은 수명(t_fail)을 넘으면 고장(event=1) 이력, 넘지 않았으면 현재까지 생존(event=0, 관측 중도절단)이다.\n", + "# ---------------------------------------------------------------------------\n", + "ENVS = [\"normal\", \"rough\", \"salt\", \"cold\"]\n", + "ENV_W = {\"normal\": 1.00, \"rough\": 1.35, \"salt\": 1.25, \"cold\": 1.12} # 환경 가중치(숨은 정답, 가정값): 클수록 수명이 짧다\n", + "ENV_KO = {\"normal\": \"일반\", \"rough\": \"험지\", \"salt\": \"염분\", \"cold\": \"혹한\"}\n", + "UNITS = [\"U1\", \"U2\", \"U3\", \"U4\", \"U5\", \"U6\"]\n", + "UNIT_ENV = {\"U1\": [.7, .1, .1, .1], \"U2\": [.2, .6, .1, .1], \"U3\": [.2, .1, .6, .1], \"U4\": [.3, .1, .1, .5], \"U5\": [.5, .3, .1, .1], \"U6\": [.4, .1, .4, .1]}\n", + "ETYPES = [\"truck\", \"generator\", \"trailer\", \"radio\"]\n", + "CYCLE = 8000.0 # 규정 창정비 주기(운용시간 h, 가정값)\n", + "KSHAPE = 12.0 # 수명 분포 와이블 형상(마모형 고장, 클수록 수명이 모여 있다)\n", + "INSP_NOISE = 0.025 # 정비 점검 마모 비율 측정 오차(가정값)\n", + "\n", + "\n", + "def make_fleet(n=900, seed=0, env_w=None, shift=1.0):\n", + " \"\"\"장비 n대. env_w로 숨은 환경 가중치를 바꿔 운영 분포 변화를 모의할 수 있다. shift>1이면 가동률이 높아진다.\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " w = env_w or ENV_W\n", + " unit = rng.choice(UNITS, n)\n", + " env = np.array([rng.choice(ENVS, p=UNIT_ENV[u]) for u in unit])\n", + " et = rng.choice(ETYPES, n, p=[.4, .2, .2, .2])\n", + " rate = rng.lognormal(np.log(3.0 * shift), 0.25, n) # 일 평균 운용시간(h/일)\n", + " h_since = rng.uniform(300, 11500, n) # 마지막 창정비 이후 운용시간\n", + " n_ovh = rng.integers(0, 3, n)\n", + " hours = h_since + n_ovh * rng.uniform(5000, 8000, n) # 누적 운용시간\n", + " km = hours * rng.normal(17, 3, n) * np.where(env == \"rough\", 0.8, 1.0) # 누적 기동거리(km)\n", + " age_y = np.clip(hours / (rate * 365) * rng.uniform(0.9, 1.15, n), 0.5, 25)\n", + " unpl = rng.poisson(0.35 * h_since / 1000 * np.array([w[e] for e in env]) / 1.3) # 비계획 정비 건수(환경이 가혹할수록 많다)\n", + " n_maint = rng.poisson(0.6 * h_since / 1000 + 1) # 계획 정비 건수\n", + " scale = 9600 / np.array([w[e] for e in env]) * 0.985 ** unpl * np.exp(rng.normal(0, 0.04, n))\n", + " t_fail = scale * rng.weibull(KSHAPE, n) # 창정비 후 고장까지 운용시간(숨은 정답)\n", + " event = (t_fail <= h_since).astype(int)\n", + " dur = np.where(event == 1, t_fail, h_since)\n", + " df = pd.DataFrame({\"eq_id\": [f\"EQ{seed:02d}{i:04d}\" for i in range(n)], \"unit\": unit, \"env\": env, \"etype\": et, \"rate\": rate, \"hours\": hours, \"km\": km, \"age_y\": age_y, \"n_ovh\": n_ovh,\n", + " \"h_since\": h_since, \"unpl\": unpl, \"n_maint\": n_maint, \"t_fail\": t_fail, \"event\": event, \"dur\": dur})\n", + " df[\"insp\"] = np.clip(dur / t_fail + rng.normal(0, INSP_NOISE, n), 0, 1.3) # 마지막 정비 점검에서 측정한 마모 비율(소모 수명/총 수명, 오차 포함)\n", + " df[\"rul_h\"] = np.where(event == 0, t_fail - h_since, np.nan) # 생존 장비의 남은 운용시간(정답)\n", + " df[\"rul_d\"] = df[\"rul_h\"] / df[\"rate\"] # 남은 일수(정답)\n", + " return df\n", + "\n", + "\n", + "def mape(y, p):\n", + " y, p = np.asarray(y, float), np.asarray(p, float)\n", + " return float(np.mean(np.abs(y - p) / np.abs(y)) * 100)\n", + "\n", + "\n", + "def mae(y, p):\n", + " return float(np.mean(np.abs(np.asarray(y, float) - np.asarray(p, float))))\n", + "\n", + "\n", + "def rmse(y, p):\n", + " return float(np.sqrt(np.mean((np.asarray(y, float) - np.asarray(p, float)) ** 2)))\n", + "# ---------------------------------------------------------------------------\n", + "# ---------------------------------------------------------------------------\n", + "# 노후화 파생 피처와 학습 표본 만들기. 환경 가중치 W0는 도입 시점의 전문가 추정값이며(실제 가중치와 다름) M9에서 재보정한다.\n", + "# ---------------------------------------------------------------------------\n", + "W0 = {\"normal\": 1.00, \"rough\": 1.15, \"salt\": 1.10, \"cold\": 1.05}\n", + "FEATS = [\"h_since\", \"hours\", \"km\", \"age_y\", \"rate\", \"n_maint\", \"unpl\", \"unpl_per_kh\", \"km_per_h\", \"n_ovh\", \"k_rough\", \"k_salt\", \"k_cold\", \"k_truck\", \"k_gen\", \"k_trailer\",\n", + " \"aging_idx\", \"lin_rul_d\", \"hs_frac\", \"insp\", \"insp_eta_h\"]\n", + "\n", + "\n", + "def add_features(df, w=None):\n", + " d = df.copy()\n", + " w = w or W0\n", + " d[\"unpl_per_kh\"] = d[\"unpl\"] / (d[\"h_since\"] / 1000 + 0.5)\n", + " d[\"km_per_h\"] = d[\"km\"] / d[\"hours\"]\n", + " for e in (\"rough\", \"salt\", \"cold\"):\n", + " d[\"k_\" + e] = (d[\"env\"] == e).astype(int)\n", + " d[\"k_truck\"], d[\"k_gen\"], d[\"k_trailer\"] = (d[\"etype\"] == \"truck\").astype(int), (d[\"etype\"] == \"generator\").astype(int), (d[\"etype\"] == \"trailer\").astype(int)\n", + " d[\"aging_idx\"] = d[\"h_since\"] * d[\"env\"].map(w) / CYCLE # 환경 가중 노후 지수(1이면 창정비 주기에 도달)\n", + " d[\"lin_rul_d\"] = (CYCLE - d[\"h_since\"]) / d[\"rate\"] # 선형 감가상각 잔여 일수(Baseline)\n", + " d[\"hs_frac\"] = d[\"h_since\"] / CYCLE\n", + " d[\"insp_eta_h\"] = d[\"h_since\"] / np.maximum(d[\"insp\"], 0.05) # 점검 마모 비율로 환산한 총 수명 추정(h)\n", + " return d\n", + "\n", + "\n", + "def snapshots(df, k=4, seed=0):\n", + " \"\"\"고장 이력 장비의 과거 시점 표본: 고장 전 임의 시점의 점검값·운용시간과 그때의 남은 일수. (생존 장비의 미래는 알 수 없으므로 고장 이력만 쓴다)\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " f = df[df[\"event\"] == 1]\n", + " out = []\n", + " for _ in range(k):\n", + " s = f.copy()\n", + " h = s[\"t_fail\"] * rng.uniform(0.2, 0.98, len(s))\n", + " ratio = h / s[\"h_since\"]\n", + " s[\"hours\"] = s[\"hours\"] - (s[\"h_since\"] - h)\n", + " s[\"km\"] = s[\"km\"] * ratio.clip(upper=1)\n", + " s[\"unpl\"] = np.floor(s[\"unpl\"] * ratio)\n", + " s[\"n_maint\"] = np.floor(s[\"n_maint\"] * ratio)\n", + " s[\"insp\"] = np.clip(h / s[\"t_fail\"] + rng.normal(0, INSP_NOISE, len(s)), 0, 1.3)\n", + " s[\"h_since\"] = h\n", + " s[\"rul_d\"] = (s[\"t_fail\"] - h) / s[\"rate\"]\n", + " s[\"event\"] = 0\n", + " out.append(s)\n", + " return pd.concat(out, ignore_index=True)\n", + "# ---------------------------------------------------------------------------\n", + "# ---------------------------------------------------------------------------\n", + "# 생존 분석 도구(scipy만 사용): 와이블 AFT(가속 수명) 모형과 Cox 비례위험 모형\n", + "# ---------------------------------------------------------------------------\n", + "from scipy.optimize import minimize\n", + "\n", + "\n", + "def aft_x(d):\n", + " return np.c_[np.ones(len(d)), d[\"env\"].eq(\"rough\"), d[\"env\"].eq(\"salt\"), d[\"env\"].eq(\"cold\"), d[\"unpl\"] / (d[\"h_since\"] / 1000 + 0.5)].astype(float)\n", + "\n", + "\n", + "AFT_COLS = [\"절편\", \"험지\", \"염분\", \"혹한\", \"비계획 정비율\"]\n", + "\n", + "\n", + "def fit_aft(d):\n", + " \"\"\"창정비 후 운용시간 dur(고장이면 event=1, 생존이면 중도절단)에 대한 와이블 AFT 최대우도 추정. 반환: 계수(마지막은 log 형상).\"\"\"\n", + " x, t, e = aft_x(d), d[\"dur\"].values, d[\"event\"].values\n", + "\n", + " def nll(p):\n", + " lam, k = np.exp(x @ p[:-1]), np.exp(p[-1])\n", + " z = (t / lam) ** k\n", + " return -(e * (np.log(k) - np.log(lam) + (k - 1) * (np.log(t) - np.log(lam))) - z).sum()\n", + "\n", + " r = minimize(nll, np.r_[np.log(8000), np.zeros(x.shape[1] - 1), np.log(5)], method=\"BFGS\")\n", + " return r.x\n", + "\n", + "\n", + "def aft_pred(p, d, q=0.5, h=None):\n", + " \"\"\"이미 h(운용시간)까지 생존했다는 조건에서 분위수 q 생존 시점(운용시간 h 기준 총 수명)을 돌려준다. q=0.5가 조건부 중앙값.\"\"\"\n", + " lam, k = np.exp(aft_x(d) @ p[:-1]), np.exp(p[-1])\n", + " h = d[\"h_since\"].values if h is None else h\n", + " return lam * ((h / lam) ** k - np.log(q)) ** (1 / k)\n", + "\n", + "\n", + "def aft_fail_prob(p, d, h0, h1):\n", + " \"\"\"h0까지 생존한 장비가 h0~h1 사이에 고장 날 조건부 확률.\"\"\"\n", + " lam, k = np.exp(aft_x(d) @ p[:-1]), np.exp(p[-1])\n", + " s0, s1 = np.exp(-(h0 / lam) ** k), np.exp(-(h1 / lam) ** k)\n", + " return 1 - s1 / s0\n", + "\n", + "\n", + "def fit_cox(d, iters=25):\n", + " \"\"\"Cox 비례위험(Breslow 근사) 뉴턴-랩슨. 공변량: 험지·염분·혹한·비계획 정비율. 반환: 계수.\"\"\"\n", + " x = aft_x(d)[:, 1:]\n", + " o = np.argsort(-d[\"dur\"].values)\n", + " x, e = x[o], d[\"event\"].values[o]\n", + " b = np.zeros(x.shape[1])\n", + " for _ in range(iters):\n", + " r = np.exp(x @ b)\n", + " S0 = np.cumsum(r)\n", + " S1 = np.cumsum(r[:, None] * x, axis=0)\n", + " S2 = np.cumsum(r[:, None, None] * x[:, :, None] * x[:, None, :], axis=0)\n", + " m1 = S1 / S0[:, None]\n", + " g = ((x - m1) * e[:, None]).sum(axis=0)\n", + " H = -sum(e[i] * (S2[i] / S0[i] - np.outer(m1[i], m1[i])) for i in np.where(e == 1)[0])\n", + " step = np.linalg.solve(H - 1e-6 * np.eye(len(b)), g)\n", + " b = b - step\n", + " if np.abs(step).max() < 1e-7:\n", + " break\n", + " return b\n", + "\n", + "\n", + "def cindex(time, event, risk):\n", + " \"\"\"일치도 C-index: 위험 점수가 높은 쪽이 먼저 고장 났는가(관측 가능한 쌍만).\"\"\"\n", + " t, e, r = np.asarray(time), np.asarray(event), np.asarray(risk)\n", + " num = den = 0.0\n", + " for i in np.where(e == 1)[0]:\n", + " m = t > t[i]\n", + " den += m.sum()\n", + " num += (r[m] < r[i]).sum() + 0.5 * (r[m] == r[i]).sum()\n", + " return num / max(den, 1)\n", + "# ---------------------------------------------------------------------------\n", + "# ---------------------------------------------------------------------------\n", + "# 잔여 수명(RUL, 일) 예측 엔진: 와이블 AFT 조건부 중앙값 + 그래디언트 부스팅(고장 이력 시점 표본 학습)의 평균\n", + "# ---------------------------------------------------------------------------\n", + "from sklearn.ensemble import GradientBoostingRegressor\n", + "\n", + "\n", + "def fit_engine(train, seed=0):\n", + " p = fit_aft(train)\n", + " sn = add_features(snapshots(train, 5, seed))\n", + " sn[\"aft\"] = (aft_pred(p, sn) - sn[\"h_since\"]) / sn[\"rate\"]\n", + " g = GradientBoostingRegressor(n_estimators=250, max_depth=3, learning_rate=0.06, random_state=0).fit(sn[FEATS + [\"aft\"]], sn[\"rul_d\"])\n", + " return {\"p\": p, \"g\": g}\n", + "\n", + "\n", + "def predict_rul(eng, d):\n", + " \"\"\"d는 add_features를 거친 표. 반환: (앙상블 RUL, AFT RUL, GBR RUL) - 일 단위.\"\"\"\n", + " aft = ((aft_pred(eng[\"p\"], d) - d[\"h_since\"]) / d[\"rate\"]).values\n", + " x = d[FEATS].copy()\n", + " x[\"aft\"] = aft\n", + " gb = eng[\"g\"].predict(x)\n", + " return np.clip(0.5 * aft + 0.5 * gb, 1, None), aft, gb\n", + "# ---------------------------------------------------------------------------\n", + "# ---------------------------------------------------------------------------\n", + "# 계획정비(창정비) 일정 추천: 월별 정비창 능력·예산·시효성 부품 키트(유효기한) 제약 아래 기대 비용 최소화 (비용 단가는 교육용 가정값, 만원)\n", + "# ---------------------------------------------------------------------------\n", + "from scipy.optimize import milp, LinearConstraint, Bounds\n", + "from scipy.stats import norm\n", + "\n", + "C_PLAN, C_FAIL, C_WASTE_KH, C_KIT = 600.0, 2400.0, 40.0, 120.0 # 계획 창정비, 비계획 고장(긴급 정비 포함), 미사용 수명 1000h당 손실, 시효성 부품 키트\n", + "HORIZON, MDAYS = 12, 30 # 계획 12개월, 월 30일\n", + "CAP_M, BUDGET_M = 22, 15000.0 # 월별 정비창 능력(대), 월별 예산\n", + "LOTS = [(30, 70), (45, 170), (45, 390), (70, 800)] # 시효성 키트 로트(수량, 유효기한까지 남은 일수)\n", + "KIT_MARGIN = 14 # 정비일 기준 최소 유효기간 여유(일)\n", + "\n", + "\n", + "def exp_cost_matrix(pred, rate, month_days):\n", + " \"\"\"장비 i를 월 m에 정비할 때의 기대 비용. 참 RUL ~ N(pred, sigma), sigma = 35일 + 0.12 x pred (가정). 마지막 열은 계획 기간 밖(미정비).\"\"\"\n", + " sig = 35 + 0.12 * pred\n", + " C = np.zeros((len(pred), len(month_days) + 1))\n", + " for j, day in enumerate(month_days):\n", + " z = (day - pred) / sig\n", + " pf = norm.cdf(z)\n", + " unused = np.maximum(pred - day, 0) * rate * 0.6 # 정비를 앞당긴 만큼 버리는 운용시간 h(근사)\n", + " C[:, j] = C_PLAN + C_KIT + pf * (C_FAIL - C_PLAN) + C_WASTE_KH * unused / 1000 * (1 - pf)\n", + " C[:, -1] = norm.cdf((HORIZON * MDAYS - pred) / sig) * C_FAIL\n", + " return C\n", + "\n", + "\n", + "def plan_milp(pred, rate, cap=CAP_M, budget=BUDGET_M, lots=LOTS, use_kit=True):\n", + " \"\"\"정수계획으로 장비별 정비 월(0~11) 또는 미정비(-1)를 배정한다. 후보는 예측 RUL이 계획 기간 +90일 이내인 장비.\"\"\"\n", + " md = np.arange(1, HORIZON + 1) * MDAYS\n", + " cand = np.where(pred < HORIZON * MDAYS + 90)[0]\n", + " n, M = len(cand), HORIZON\n", + " C = exp_cost_matrix(pred[cand], rate[cand], md)\n", + " c = C.ravel()\n", + " A, lo, hi = [], [], []\n", + " for i in range(n): # 장비당 한 번만 배정(월 12칸 + 미정비)\n", + " row = np.zeros(n * (M + 1)); row[i * (M + 1):(i + 1) * (M + 1)] = 1; A.append(row); lo.append(1); hi.append(1)\n", + " for m in range(M): # 월별 능력·예산\n", + " row = np.zeros(n * (M + 1)); row[m::(M + 1)] = 1\n", + " A.append(row); lo.append(0); hi.append(cap)\n", + " A.append(row * (C_PLAN + C_KIT)); lo.append(0); hi.append(budget)\n", + " if use_kit: # 시효성 키트: m월 이후 정비 수요는 그 시점에 유효기간이 남은 로트 수량을 넘을 수 없다\n", + " for m in range(M):\n", + " sup = sum(q for q, e in lots if e >= md[m] + KIT_MARGIN)\n", + " row = np.zeros(n * (M + 1))\n", + " for mm in range(m, M):\n", + " row[mm::(M + 1)] = 1\n", + " A.append(row); lo.append(0); hi.append(sup)\n", + " r = milp(c, constraints=LinearConstraint(np.array(A), lo, hi), integrality=np.ones(len(c)), bounds=Bounds(0, 1), options={\"time_limit\": 20})\n", + " month = np.full(len(pred), -1)\n", + " if r.x is not None:\n", + " x = np.round(r.x).reshape(n, M + 1)\n", + " assign = np.where(x[:, :M].sum(axis=1) > 0.5, x[:, :M].argmax(axis=1), -1)\n", + " month[cand] = assign\n", + " return month\n", + "\n", + "\n", + "def plan_greedy(pred, margin=45, cap=CAP_M, budget=BUDGET_M):\n", + " \"\"\"간단한 탐욕 배정: 정비 목표일(예측 RUL - margin)이 빠른 순으로 목표일 이전 가장 늦은 빈 월, 없으면 가장 이른 빈 월에 배정.\"\"\"\n", + " cap = min(cap, int(budget // (C_PLAN + C_KIT))) # 월 예산이 허용하는 대수로 능력을 제한\n", + " month = np.full(len(pred), -1)\n", + " left = np.full(HORIZON, cap)\n", + " for i in np.argsort(pred):\n", + " if pred[i] > HORIZON * MDAYS + 60: break\n", + " tgt = int(np.clip((pred[i] - margin) // MDAYS - 1, 0, HORIZON - 1))\n", + " for m in list(range(tgt, -1, -1)) + list(range(tgt + 1, HORIZON)):\n", + " if left[m] > 0:\n", + " month[i] = m; left[m] -= 1; break\n", + " return month\n", + "\n", + "\n", + "def plan_fixed(lin_rul, cap=CAP_M, budget=BUDGET_M):\n", + " \"\"\"관행: 규정 주기 도달 순서대로(선형 잔여 일수) 월별 능력 안에서 배정.\"\"\"\n", + " return plan_greedy(np.asarray(lin_rul, float), margin=0, cap=cap, budget=budget)\n", + "\n", + "\n", + "def evaluate_plan(month, true_rul, rate, cost_kit=True):\n", + " \"\"\"참 RUL로 계획의 실제 비용을 계산한다: 정비일 이전에 고장 -> 비계획 고장 비용, 아니면 계획 비용 + 미사용 수명 손실.\"\"\"\n", + " day = np.where(month >= 0, (month + 1) * MDAYS, 10 ** 9)\n", + " fail = (true_rul < day) & (true_rul < HORIZON * MDAYS)\n", + " planned = (month >= 0) & ~fail\n", + " waste = np.where(planned, (true_rul - day) * rate * 0.6, 0) * C_WASTE_KH / 1000\n", + " cost = fail * C_FAIL + planned * (C_PLAN + (C_KIT if cost_kit else 0)) + waste\n", + " return {\"total\": float(cost.sum()), \"fail\": int(fail.sum()), \"planned\": int(planned.sum()), \"waste\": float(waste.sum()), \"unsched_fail\": int(((month < 0) & fail).sum())}\n", + "\n", + "\n", + "def kit_fefo(month, lots=LOTS):\n", + " \"\"\"유효기한이 빠른 로트부터 쓰는 FEFO로 키트를 배정하고 정비일에 유효기간이 부족한 배정 수와 만료 폐기 수량을 센다.\"\"\"\n", + " q = [[qty, e] for qty, e in sorted(lots, key=lambda t: t[1])]\n", + " short = 0\n", + " for m in sorted(month[month >= 0]):\n", + " day = (m + 1) * MDAYS\n", + " for lot in q:\n", + " if lot[1] >= day + KIT_MARGIN and lot[0] > 0:\n", + " lot[0] -= 1; break\n", + " else:\n", + " short += 1\n", + " expired = sum(l[0] for l in q if l[1] < HORIZON * MDAYS)\n", + " return {\"short\": short, \"expired_unused\": expired, \"left\": sum(l[0] for l in q)}\n", + "# ---------------------------------------------------------------------------\n", + "\n", + "tr = make_fleet(900, seed=1)\n", + "eng = fit_engine(tr)\n", + "\n", + "\n", + "def scenario(seed, shift=1.0, cap=CAP_M, budget=BUDGET_M, lots=LOTS, use_kit=True):\n", + " fl = add_features(make_fleet(900, seed=seed, shift=shift))\n", + " al = fl[fl.event == 0].reset_index(drop=True)\n", + " pred, _, _ = predict_rul(eng, al)\n", + " mf = plan_fixed(al[\"lin_rul_d\"].values, cap=cap, budget=budget)\n", + " mm = plan_milp(pred, al[\"rate\"].values, cap=cap, budget=budget, lots=lots, use_kit=use_kit)\n", + " rf, rm = evaluate_plan(mf, al[\"rul_d\"].values, al[\"rate\"].values), evaluate_plan(mm, al[\"rul_d\"].values, al[\"rate\"].values)\n", + " kf, km = kit_fefo(mf, lots), kit_fefo(mm, lots)\n", + " stock = sum(q for q, _ in lots)\n", + " return al, pred, mf, mm, rf, rm, kf, km, stock\n", + "\n", + "\n", + "# ---------------- 1) 시나리오 6종 ----------------\n", + "SC = [(\"평상\", dict(seed=3)), (\"정비창 능력 70%(15대/월)\", dict(seed=3, cap=15)), (\"예산 삭감(월 10,000만원)\", dict(seed=3, budget=10000.0)), (\"시효성 키트 재고 절반·단기 유효\", dict(seed=3, lots=[(15, 70), (20, 170), (20, 240), (30, 330)])),\n", + " (\"가동률 +30%\", dict(seed=3, shift=1.3)), (\"다른 보유 장비(시드 4)\", dict(seed=4))]\n", + "rows, cache = [], {}\n", + "for name, kw in SC:\n", + " al, pred, mf, mm, rf, rm, kf, km, stock = scenario(**kw)\n", + " cache[name] = (al, pred, mm)\n", + " rows.append((name, len(al), rf[\"total\"], rm[\"total\"], 1 - rm[\"total\"] / rf[\"total\"], rf[\"fail\"], rm[\"fail\"], kf[\"expired_unused\"], km[\"expired_unused\"], km[\"short\"]))\n", + "res = pd.DataFrame(rows, columns=[\"시나리오\", \"생존 장비\", \"관행 비용(만원)\", \"제안 비용(만원)\", \"절감률\", \"관행 비계획 고장\", \"제안 비계획 고장\", \"관행 키트 만료 폐기\", \"제안 키트 만료 폐기\", \"제안 키트 유효기간 부족\"])\n", + "print(\"[1] 시나리오별 12개월 비용 비교(관행 = 규정 주기 도달 순 고정 일정, 단가는 교육용 가정값)\")\n", + "print(res.assign(절감률=res[\"절감률\"].map(\"{:.1%}\".format)).round(0).to_string(index=False))\n", + "avg = res[\"절감률\"].mean()\n", + "print(f\" 시나리오 6종 평균 절감 {avg:.1%} (목표 15% 이상: {'달성' if avg >= .15 else '미달'}), 최소 {res['절감률'].min():.1%}({res.loc[res['절감률'].idxmin(), '시나리오']})\")\n", + "\n", + "# ---------------- 2) 백테스트: 서로 다른 보유 장비 5세트 ----------------\n", + "bt = []\n", + "for sd in (10, 11, 12, 13, 14):\n", + " _, _, _, _, rf, rm, _, _, _ = scenario(seed=sd)\n", + " bt.append((sd, 1 - rm[\"total\"] / rf[\"total\"], rf[\"fail\"], rm[\"fail\"]))\n", + "bt = pd.DataFrame(bt, columns=[\"장비 세트\", \"절감률\", \"관행 고장\", \"제안 고장\"])\n", + "print(f\"\\n[2] 백테스트(서로 다른 보유 장비 5세트): 평균 절감 {bt['절감률'].mean():.1%}, 최소 {bt['절감률'].min():.1%}, 최대 {bt['절감률'].max():.1%}, 비계획 고장 평균 {bt['관행 고장'].mean():.0f} -> {bt['제안 고장'].mean():.0f}대\")\n", + "\n", + "# ---------------- 3) 단가 민감도: 비계획 고장 비용 ----------------\n", + "sens = []\n", + "base_cfail = C_FAIL\n", + "for mult in (0.5, 1.0, 1.5, 2.0):\n", + " C_FAIL = base_cfail * mult\n", + " _, _, _, _, rf, rm, _, _, _ = scenario(seed=3)\n", + " sens.append((mult, 1 - rm[\"total\"] / rf[\"total\"]))\n", + "C_FAIL = base_cfail\n", + "print(\"[3] 비계획 고장 비용 민감도:\", \"; \".join(f\"x{m} -> 절감 {s:.0%}\" for m, s in sens))\n", + "print(\" 고장 비용이 낮아지면 앞당긴 정비의 낭비가 상대적으로 커져 절감 폭이 줄어든다. 단가는 재정 부서와 실제 값으로 다시 확인해야 한다.\")\n", + "\n", + "# ---------------- 4) HITL 결재 워크플로우 ----------------\n", + "USERS = {\"ai_bot\": \"AI\", \"pln_kim\": \"PLN\", \"pln_lee\": \"PLN\", \"log_park\": \"LOG\", \"fin_choi\": \"FIN\", \"aud_han\": \"AUD\"}\n", + "PERM = {\"AI\": {\"submit\"}, \"PLN\": {\"submit\", \"redraft\"}, \"LOG\": {\"approve\", \"reject\"}, \"FIN\": {\"approve\", \"reject\"}, \"AUD\": {\"read\"}}\n", + "\n", + "\n", + "class Flow:\n", + " def __init__(self):\n", + " self.docs, self.log, self.blocked = {}, [], []\n", + "\n", + " def _audit(self, user, action, doc, detail):\n", + " prev = self.log[-1][\"hash\"] if self.log else \"GENESIS\"\n", + " rec = {\"seq\": len(self.log) + 1, \"user\": user, \"action\": action, \"doc\": doc, \"detail\": detail, \"prev\": prev}\n", + " rec[\"hash\"] = hashlib.sha256(json.dumps(rec, ensure_ascii=False, sort_keys=True).encode()).hexdigest()[:12]\n", + " self.log.append(rec)\n", + "\n", + " def verify(self):\n", + " prev = \"GENESIS\"\n", + " for r in self.log:\n", + " body = {k: v for k, v in r.items() if k != \"hash\"}\n", + " if r[\"prev\"] != prev or hashlib.sha256(json.dumps(body, ensure_ascii=False, sort_keys=True).encode()).hexdigest()[:12] != r[\"hash\"]:\n", + " return False, r[\"seq\"]\n", + " prev = r[\"hash\"]\n", + " return True, None\n", + "\n", + " def deny(self, user, act, doc, why):\n", + " self.blocked.append((user, act, doc, why)); self._audit(user, \"DENIED:\" + act, doc, why); return False\n", + "\n", + " def submit(self, user, kind, title, n, parent=None):\n", + " need = \"redraft\" if parent else \"submit\"\n", + " if need not in PERM.get(USERS.get(user), set()):\n", + " return self.deny(user, need, \"-\", \"상신 권한 없음\")\n", + " did = f\"MNT{len(self.docs) + 1:02d}\"\n", + " self.docs[did] = {\"kind\": kind, \"title\": title, \"line\": [\"LOG\", \"FIN\"] if kind == \"도태 건의\" else [\"LOG\"], \"step\": 0, \"status\": \"검토대기\", \"drafter\": user, \"done\": [], \"parent\": parent}\n", + " self._audit(user, \"submit\", did, f\"{kind} {title} {n}대\")\n", + " return did\n", + "\n", + " def decide(self, user, did, act, reason=\"\"):\n", + " role, d = USERS.get(user), self.docs.get(did)\n", + " if role is None: return self.deny(user, act, did, \"미등록 사용자\")\n", + " if d is None or d[\"status\"] != \"검토대기\": return self.deny(user, act, did, \"결재 대상 아님(처리 완료)\")\n", + " if act not in PERM[role]: return self.deny(user, act, did, f\"{role} 역할은 {act} 불가\")\n", + " if d[\"line\"][d[\"step\"]] != role: return self.deny(user, act, did, \"결재 순서 위반\")\n", + " if user == d[\"drafter\"] or user in d[\"done\"]: return self.deny(user, act, did, \"기안자·기결재자 재결재 금지\")\n", + " if len(reason.strip()) < 10: return self.deny(user, act, did, \"사유 10자 이상 필수\")\n", + " if act == \"reject\":\n", + " d[\"status\"] = \"반려\"\n", + " else:\n", + " d[\"done\"].append(user); d[\"step\"] += 1\n", + " d[\"status\"] = \"승인\" if d[\"step\"] == len(d[\"line\"]) else \"검토대기\"\n", + " self._audit(user, act, did, reason); return True\n", + "\n", + " def edit(self, user, did):\n", + " if self.docs[did][\"status\"] == \"승인\": return self.deny(user, \"edit\", did, \"승인 후 일정 변경 금지(재기안)\")\n", + " return True\n", + "\n", + "\n", + "al, pred, mm = cache[\"평상\"]\n", + "al = al.assign(rul_pred=pred, month=mm)\n", + "scrap = al[(al[\"age_y\"] >= 14) & (al[\"rul_pred\"] < 365)] # 도태 건의 후보: 사용 14년 이상이고 1년 내 수명 종료 예측\n", + "wf = Flow()\n", + "docs = [wf.submit(\"ai_bot\" if q % 2 == 0 else \"pln_kim\", \"정비 일정\", f\"{3 * q + 1}~{3 * q + 3}월 일정안\", int(((al.month >= 3 * q) & (al.month < 3 * q + 3)).sum())) for q in range(2)]\n", + "docs += [wf.submit(\"pln_lee\", \"도태 건의\", f\"{u} 부대 노후 장비\", int((scrap.unit == u).sum())) for u in scrap[\"unit\"].value_counts().index[:2]]\n", + "two = [i for i in docs if len(wf.docs[i][\"line\"]) == 2]\n", + "print(f\"\\n[4] AI 추천안 {len(docs)}건(정비 일정 2건, 도태 건의 {len(two)}건; 도태 후보 {len(scrap)}대) 전부 '검토대기' 등록, 자동 승인 {sum(wf.docs[i]['status'] == '승인' for i in docs)}건 (목표 0건). 2단계 결재(군수지휘관 + 재정 담당) 대상 {two}\")\n", + "wf.decide(\"log_park\", docs[0], \"reject\", \"1~3월 정비 순서에 키트 유효기간 재확인 필요, 재기안 요청\")\n", + "new = wf.submit(\"pln_kim\", \"정비 일정\", \"1~3월 일정안(재기안)\", int(((al.month >= 0) & (al.month < 3)).sum()), parent=docs[0])\n", + "wf.decide(\"log_park\", new, \"approve\", \"키트 유효기간 확인 완료, 일정 확정\")\n", + "wf.decide(\"log_park\", docs[1], \"approve\", \"4~6월 일정, 정비창 능력 범위 내 확인함\")\n", + "if two:\n", + " wf.decide(\"log_park\", two[0], \"approve\", \"도태 건의 사유 타당, 재정 담당 검토 상신\")\n", + " wf.decide(\"fin_choi\", two[0], \"approve\", \"잔존 가치 대비 수리비 초과 확인, 도태 건의 동의\")\n", + "tgt = two[-1] if two else docs[-1]\n", + "attacks = [(\"pln_kim\", tgt, \"approve\", \"상신자가 직접 승인한다고 처리함\"), (\"ai_bot\", tgt, \"approve\", \"AI 계정이 직접 승인하려고 시도함\"), (\"aud_han\", tgt, \"approve\", \"감사 계정으로 승인 시도함\"), (\"ghost\", tgt, \"approve\", \"외부 계정으로 승인 시도함\"),\n", + " (\"log_park\", tgt, \"approve\", \"\"), (\"log_park\", tgt, \"reject\", \"짧음\"), (\"fin_choi\", tgt, \"approve\", \"재정 담당이 순서를 건너뛰어 승인\"), (\"log_park\", new, \"reject\", \"승인된 일정을 뒤집어 반려함\"),\n", + " (\"pln_lee\", tgt, \"approve\", \"상신자 계정으로 승인 시도함\")]\n", + "for u, doc, a, rs in attacks:\n", + " wf.decide(u, doc, a, rs)\n", + "wf.edit(\"pln_kim\", new)\n", + "print(f\" 권한 우회 시도 10건 중 차단 {len(wf.blocked)}건 (차단율 {len(wf.blocked) / 10:.0%}, 목표 100%)\")\n", + "for u, a, doc, why in wf.blocked[:4]:\n", + " print(f\" 차단 {u:<9} {a:<8} {doc}: {why}\")\n", + "ok, _ = wf.verify()\n", + "snap = json.loads(json.dumps(wf.log))\n", + "wf.log[3][\"detail\"] = \"사후에 고쳐 쓴 사유\"\n", + "ok2, at = wf.verify()\n", + "wf.log = snap\n", + "print(f\" 감사 로그 {len(wf.log)}건 해시 체인 무결성 {'100%' if ok else '훼손'}; 4번 기록 사후 수정 시 {'통과' if ok2 else f'위변조 검출({at}번)'}\")\n", + "print(f\" {docs[0]}(반려) -> {new}(재기안, parent 연결) 이력:\", [(r['action'], r['user']) for r in wf.log if r['doc'] in (docs[0], new)])\n", + "print(\" 문서 상태:\", pd.Series({k: v[\"status\"] for k, v in wf.docs.items()}).value_counts().to_dict(), \"| 정비 일정 확정과 장비 도태는 군수지휘관 결재(도태는 재정 담당 포함)로만 확정된다.\")\n", + "\n", + "fig, ax = plt.subplots(1, 2, figsize=(12, 4))\n", + "x = np.arange(len(res)); ax[0].bar(x - .2, res[\"관행 비용(만원)\"], .4, label=\"fixed-cycle\"); ax[0].bar(x + .2, res[\"제안 비용(만원)\"], .4, label=\"risk-based plan\")\n", + "ax[0].set_xticks(x); ax[0].set_xticklabels([f\"S{i + 1}\" for i in x]); ax[0].legend(); ax[0].set_title(\"12-month cost by scenario (10k KRW)\")\n", + "ax[1].plot([s[0] for s in sens], [s[1] * 100 for s in sens], \"o-\"); ax[1].set_title(\"Saving (%) vs unplanned-failure cost multiplier\"); ax[1].set_xlabel(\"multiplier\")\n", + "plt.tight_layout(); plt.show()\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 07 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + "[1] 시나리오별 12개월 비용 비교(관행 = 규정 주기 도달 순 고정 일정, 단가는 교육용 가정값)\n", + " 시나리오 생존 장비 관행 비용(만원) 제안 비용(만원) 절감률 관행 비계획 고장 제안 비계획 고장 관행 키트 만료 폐기 제안 키트 만료 폐기 제안 키트 유효기간 부족\n", + " 평상 601 184752.0 116447.0 37.0% 47 21 10 10 0\n", + " 정비창 능력 70%(15대/월) 601 184691.0 122971.0 33.4% 47 25 15 15 0\n", + " 예산 삭감(월 10,000만원) 601 184655.0 124543.0 32.6% 47 26 17 17 0\n", + "시효성 키트 재고 절반·단기 유효 601 184752.0 114897.0 37.8% 47 25 0 0 0\n", + " 가동률 +30% 601 225885.0 149598.0 33.8% 61 29 10 10 0\n", + " 다른 보유 장비(시드 4) 590 164663.0 102951.0 37.5% 46 19 17 10 0\n", + " 시나리오 6종 평균 절감 35.3% (목표 15% 이상: 달성), 최소 32.6%(예산 삭감(월 10,000만원))\n", + "\n", + "[2] 백테스트(서로 다른 보유 장비 5세트): 평균 절감 35.4%, 최소 32.4%, 최대 41.6%, 비계획 고장 평균 54 -> 27대\n", + "[3] 비계획 고장 비용 민감도: x0.5 -> 절감 35%; x1.0 -> 절감 37%; x1.5 -> 절감 41%; x2.0 -> 절감 45%\n", + " 고장 비용이 낮아지면 앞당긴 정비의 낭비가 상대적으로 커져 절감 폭이 줄어든다. 단가는 재정 부서와 실제 값으로 다시 확인해야 한다.\n", + "\n", + "[4] AI 추천안 4건(정비 일정 2건, 도태 건의 2건; 도태 후보 22대) 전부 '검토대기' 등록, 자동 승인 0건 (목표 0건). 2단계 결재(군수지휘관 + 재정 담당) 대상 ['MNT03', 'MNT04']\n", + " 권한 우회 시도 10건 중 차단 10건 (차단율 100%, 목표 100%)\n", + " 차단 pln_kim approve MNT04: PLN 역할은 approve 불가\n", + " 차단 ai_bot approve MNT04: AI 역할은 approve 불가\n", + " 차단 aud_han approve MNT04: AUD 역할은 approve 불가\n", + " 차단 ghost approve MNT04: 미등록 사용자\n", + " 감사 로그 20건 해시 체인 무결성 100%; 4번 기록 사후 수정 시 위변조 검출(4번)\n", + " MNT01(반려) -> MNT05(재기안, parent 연결) 이력: [('submit', 'ai_bot'), ('reject', 'log_park'), ('submit', 'pln_kim'), ('approve', 'log_park'), ('DENIED:reject', 'log_park'), ('DENIED:edit', 'pln_kim')]\n", + " 문서 상태: {'승인': 3, '반려': 1, '검토대기': 1} | 정비 일정 확정과 장비 도태는 군수지휘관 결재(도태는 재정 담당 포함)로만 확정된다.\n", + "```\n", + "
\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 08. 전체 군단 보유 장비 1만대 수명 일괄 시뮬레이션 부하 테스트\n", + "**소요시간**: 약 3.0시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "보유 장비 1만대 수명 일괄 시뮬레이션 부하 테스트를 수행한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **보유 장비 1만 대 수명 일괄 시뮬레이션 부하 시험과 경보 정확도 시험을 수행한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **보유 장비 1천~10만 대 규모에서 피처 생성, 수명 예측, 일정 배정, 대시보드 집계 시간을 잰다 과정을 진행하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `src/dashboard/sup02_app.py (Streamlit/Dash 등)` 형태의 결과물을 완성한다.\n", + "\n", + "### 🔍 평가 및 검증 기준 (PASS 조건)\n", + "#### 미션 판정\n", + "\n", + "**평가 구분:** 필수\n", + "\n", + "**핵심 평가영역:** 의사결정 지원에 최적화된 시각적 위계 및 사용자 경험(UX)\n", + "\n", + "#### 미션 PASS 조건\n", + "\n", + "- 정량 기준: 대시보드 로딩 시간 2초 이내, 경보 발령 정확도 95% 이상\n", + "\n", + "\n", + "- 진행 기준: 웹 기반 분석 대시보드 구동 및 실시간 필터링 기능 검증\n", + "\n", + "\n", + "- 검증 방법: 실제 군수 데이터 연동 화면 테스트 및 UI 동작 검증\n", + "\n", + "\n", + "#### 채점자 판정 메모\n", + "\n", + "> 단순 코드 실행 여부뿐만 아니라 출력된 수치와 근거 자료가 군 실무 요구사항에 부합하는지 면밀히 확인한다.\n", + "\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "\"\"\"보유 장비 1천~10만 대의 수명(RUL) 일괄 예측·위험 경보·계획정비 일정 배정·대시보드 집계 시뮬레이션으로 단계별 처리 시간과 대시보드 로딩 시간을 재고, 결측·환경 오기재·단위 오류 주입 조건에서 위험 경보 정확도를 측정하며 정수계획의 규모 한계와 대응(탐욕 배정)을 정리한다.\"\"\"\n", + "import time\n", + "import numpy as np\n", + "import pandas as pd\n", + "import matplotlib.pyplot as plt\n", + "\n", + "# ---------------------------------------------------------------------------\n", + "# [교육용가상자료] 가상 장비 보유 현황·고장 이력 생성기 (실제 부대·장비·수치 없음)\n", + "# 가상 부대 6개(U1~U6)의 장비가 운용 환경(normal/rough/salt/cold)에 따라 다른 속도로 노후화한다. 창정비 후 운용시간(h_since)이\n", + "# 장비마다 숨은 수명(t_fail)을 넘으면 고장(event=1) 이력, 넘지 않았으면 현재까지 생존(event=0, 관측 중도절단)이다.\n", + "# ---------------------------------------------------------------------------\n", + "ENVS = [\"normal\", \"rough\", \"salt\", \"cold\"]\n", + "ENV_W = {\"normal\": 1.00, \"rough\": 1.35, \"salt\": 1.25, \"cold\": 1.12} # 환경 가중치(숨은 정답, 가정값): 클수록 수명이 짧다\n", + "ENV_KO = {\"normal\": \"일반\", \"rough\": \"험지\", \"salt\": \"염분\", \"cold\": \"혹한\"}\n", + "UNITS = [\"U1\", \"U2\", \"U3\", \"U4\", \"U5\", \"U6\"]\n", + "UNIT_ENV = {\"U1\": [.7, .1, .1, .1], \"U2\": [.2, .6, .1, .1], \"U3\": [.2, .1, .6, .1], \"U4\": [.3, .1, .1, .5], \"U5\": [.5, .3, .1, .1], \"U6\": [.4, .1, .4, .1]}\n", + "ETYPES = [\"truck\", \"generator\", \"trailer\", \"radio\"]\n", + "CYCLE = 8000.0 # 규정 창정비 주기(운용시간 h, 가정값)\n", + "KSHAPE = 12.0 # 수명 분포 와이블 형상(마모형 고장, 클수록 수명이 모여 있다)\n", + "INSP_NOISE = 0.025 # 정비 점검 마모 비율 측정 오차(가정값)\n", + "\n", + "\n", + "def make_fleet(n=900, seed=0, env_w=None, shift=1.0):\n", + " \"\"\"장비 n대. env_w로 숨은 환경 가중치를 바꿔 운영 분포 변화를 모의할 수 있다. shift>1이면 가동률이 높아진다.\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " w = env_w or ENV_W\n", + " unit = rng.choice(UNITS, n)\n", + " env = np.array([rng.choice(ENVS, p=UNIT_ENV[u]) for u in unit])\n", + " et = rng.choice(ETYPES, n, p=[.4, .2, .2, .2])\n", + " rate = rng.lognormal(np.log(3.0 * shift), 0.25, n) # 일 평균 운용시간(h/일)\n", + " h_since = rng.uniform(300, 11500, n) # 마지막 창정비 이후 운용시간\n", + " n_ovh = rng.integers(0, 3, n)\n", + " hours = h_since + n_ovh * rng.uniform(5000, 8000, n) # 누적 운용시간\n", + " km = hours * rng.normal(17, 3, n) * np.where(env == \"rough\", 0.8, 1.0) # 누적 기동거리(km)\n", + " age_y = np.clip(hours / (rate * 365) * rng.uniform(0.9, 1.15, n), 0.5, 25)\n", + " unpl = rng.poisson(0.35 * h_since / 1000 * np.array([w[e] for e in env]) / 1.3) # 비계획 정비 건수(환경이 가혹할수록 많다)\n", + " n_maint = rng.poisson(0.6 * h_since / 1000 + 1) # 계획 정비 건수\n", + " scale = 9600 / np.array([w[e] for e in env]) * 0.985 ** unpl * np.exp(rng.normal(0, 0.04, n))\n", + " t_fail = scale * rng.weibull(KSHAPE, n) # 창정비 후 고장까지 운용시간(숨은 정답)\n", + " event = (t_fail <= h_since).astype(int)\n", + " dur = np.where(event == 1, t_fail, h_since)\n", + " df = pd.DataFrame({\"eq_id\": [f\"EQ{seed:02d}{i:04d}\" for i in range(n)], \"unit\": unit, \"env\": env, \"etype\": et, \"rate\": rate, \"hours\": hours, \"km\": km, \"age_y\": age_y, \"n_ovh\": n_ovh,\n", + " \"h_since\": h_since, \"unpl\": unpl, \"n_maint\": n_maint, \"t_fail\": t_fail, \"event\": event, \"dur\": dur})\n", + " df[\"insp\"] = np.clip(dur / t_fail + rng.normal(0, INSP_NOISE, n), 0, 1.3) # 마지막 정비 점검에서 측정한 마모 비율(소모 수명/총 수명, 오차 포함)\n", + " df[\"rul_h\"] = np.where(event == 0, t_fail - h_since, np.nan) # 생존 장비의 남은 운용시간(정답)\n", + " df[\"rul_d\"] = df[\"rul_h\"] / df[\"rate\"] # 남은 일수(정답)\n", + " return df\n", + "\n", + "\n", + "def mape(y, p):\n", + " y, p = np.asarray(y, float), np.asarray(p, float)\n", + " return float(np.mean(np.abs(y - p) / np.abs(y)) * 100)\n", + "\n", + "\n", + "def mae(y, p):\n", + " return float(np.mean(np.abs(np.asarray(y, float) - np.asarray(p, float))))\n", + "\n", + "\n", + "def rmse(y, p):\n", + " return float(np.sqrt(np.mean((np.asarray(y, float) - np.asarray(p, float)) ** 2)))\n", + "# ---------------------------------------------------------------------------\n", + "# ---------------------------------------------------------------------------\n", + "# 노후화 파생 피처와 학습 표본 만들기. 환경 가중치 W0는 도입 시점의 전문가 추정값이며(실제 가중치와 다름) M9에서 재보정한다.\n", + "# ---------------------------------------------------------------------------\n", + "W0 = {\"normal\": 1.00, \"rough\": 1.15, \"salt\": 1.10, \"cold\": 1.05}\n", + "FEATS = [\"h_since\", \"hours\", \"km\", \"age_y\", \"rate\", \"n_maint\", \"unpl\", \"unpl_per_kh\", \"km_per_h\", \"n_ovh\", \"k_rough\", \"k_salt\", \"k_cold\", \"k_truck\", \"k_gen\", \"k_trailer\",\n", + " \"aging_idx\", \"lin_rul_d\", \"hs_frac\", \"insp\", \"insp_eta_h\"]\n", + "\n", + "\n", + "def add_features(df, w=None):\n", + " d = df.copy()\n", + " w = w or W0\n", + " d[\"unpl_per_kh\"] = d[\"unpl\"] / (d[\"h_since\"] / 1000 + 0.5)\n", + " d[\"km_per_h\"] = d[\"km\"] / d[\"hours\"]\n", + " for e in (\"rough\", \"salt\", \"cold\"):\n", + " d[\"k_\" + e] = (d[\"env\"] == e).astype(int)\n", + " d[\"k_truck\"], d[\"k_gen\"], d[\"k_trailer\"] = (d[\"etype\"] == \"truck\").astype(int), (d[\"etype\"] == \"generator\").astype(int), (d[\"etype\"] == \"trailer\").astype(int)\n", + " d[\"aging_idx\"] = d[\"h_since\"] * d[\"env\"].map(w) / CYCLE # 환경 가중 노후 지수(1이면 창정비 주기에 도달)\n", + " d[\"lin_rul_d\"] = (CYCLE - d[\"h_since\"]) / d[\"rate\"] # 선형 감가상각 잔여 일수(Baseline)\n", + " d[\"hs_frac\"] = d[\"h_since\"] / CYCLE\n", + " d[\"insp_eta_h\"] = d[\"h_since\"] / np.maximum(d[\"insp\"], 0.05) # 점검 마모 비율로 환산한 총 수명 추정(h)\n", + " return d\n", + "\n", + "\n", + "def snapshots(df, k=4, seed=0):\n", + " \"\"\"고장 이력 장비의 과거 시점 표본: 고장 전 임의 시점의 점검값·운용시간과 그때의 남은 일수. (생존 장비의 미래는 알 수 없으므로 고장 이력만 쓴다)\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " f = df[df[\"event\"] == 1]\n", + " out = []\n", + " for _ in range(k):\n", + " s = f.copy()\n", + " h = s[\"t_fail\"] * rng.uniform(0.2, 0.98, len(s))\n", + " ratio = h / s[\"h_since\"]\n", + " s[\"hours\"] = s[\"hours\"] - (s[\"h_since\"] - h)\n", + " s[\"km\"] = s[\"km\"] * ratio.clip(upper=1)\n", + " s[\"unpl\"] = np.floor(s[\"unpl\"] * ratio)\n", + " s[\"n_maint\"] = np.floor(s[\"n_maint\"] * ratio)\n", + " s[\"insp\"] = np.clip(h / s[\"t_fail\"] + rng.normal(0, INSP_NOISE, len(s)), 0, 1.3)\n", + " s[\"h_since\"] = h\n", + " s[\"rul_d\"] = (s[\"t_fail\"] - h) / s[\"rate\"]\n", + " s[\"event\"] = 0\n", + " out.append(s)\n", + " return pd.concat(out, ignore_index=True)\n", + "# ---------------------------------------------------------------------------\n", + "# ---------------------------------------------------------------------------\n", + "# 생존 분석 도구(scipy만 사용): 와이블 AFT(가속 수명) 모형과 Cox 비례위험 모형\n", + "# ---------------------------------------------------------------------------\n", + "from scipy.optimize import minimize\n", + "\n", + "\n", + "def aft_x(d):\n", + " return np.c_[np.ones(len(d)), d[\"env\"].eq(\"rough\"), d[\"env\"].eq(\"salt\"), d[\"env\"].eq(\"cold\"), d[\"unpl\"] / (d[\"h_since\"] / 1000 + 0.5)].astype(float)\n", + "\n", + "\n", + "AFT_COLS = [\"절편\", \"험지\", \"염분\", \"혹한\", \"비계획 정비율\"]\n", + "\n", + "\n", + "def fit_aft(d):\n", + " \"\"\"창정비 후 운용시간 dur(고장이면 event=1, 생존이면 중도절단)에 대한 와이블 AFT 최대우도 추정. 반환: 계수(마지막은 log 형상).\"\"\"\n", + " x, t, e = aft_x(d), d[\"dur\"].values, d[\"event\"].values\n", + "\n", + " def nll(p):\n", + " lam, k = np.exp(x @ p[:-1]), np.exp(p[-1])\n", + " z = (t / lam) ** k\n", + " return -(e * (np.log(k) - np.log(lam) + (k - 1) * (np.log(t) - np.log(lam))) - z).sum()\n", + "\n", + " r = minimize(nll, np.r_[np.log(8000), np.zeros(x.shape[1] - 1), np.log(5)], method=\"BFGS\")\n", + " return r.x\n", + "\n", + "\n", + "def aft_pred(p, d, q=0.5, h=None):\n", + " \"\"\"이미 h(운용시간)까지 생존했다는 조건에서 분위수 q 생존 시점(운용시간 h 기준 총 수명)을 돌려준다. q=0.5가 조건부 중앙값.\"\"\"\n", + " lam, k = np.exp(aft_x(d) @ p[:-1]), np.exp(p[-1])\n", + " h = d[\"h_since\"].values if h is None else h\n", + " return lam * ((h / lam) ** k - np.log(q)) ** (1 / k)\n", + "\n", + "\n", + "def aft_fail_prob(p, d, h0, h1):\n", + " \"\"\"h0까지 생존한 장비가 h0~h1 사이에 고장 날 조건부 확률.\"\"\"\n", + " lam, k = np.exp(aft_x(d) @ p[:-1]), np.exp(p[-1])\n", + " s0, s1 = np.exp(-(h0 / lam) ** k), np.exp(-(h1 / lam) ** k)\n", + " return 1 - s1 / s0\n", + "\n", + "\n", + "def fit_cox(d, iters=25):\n", + " \"\"\"Cox 비례위험(Breslow 근사) 뉴턴-랩슨. 공변량: 험지·염분·혹한·비계획 정비율. 반환: 계수.\"\"\"\n", + " x = aft_x(d)[:, 1:]\n", + " o = np.argsort(-d[\"dur\"].values)\n", + " x, e = x[o], d[\"event\"].values[o]\n", + " b = np.zeros(x.shape[1])\n", + " for _ in range(iters):\n", + " r = np.exp(x @ b)\n", + " S0 = np.cumsum(r)\n", + " S1 = np.cumsum(r[:, None] * x, axis=0)\n", + " S2 = np.cumsum(r[:, None, None] * x[:, :, None] * x[:, None, :], axis=0)\n", + " m1 = S1 / S0[:, None]\n", + " g = ((x - m1) * e[:, None]).sum(axis=0)\n", + " H = -sum(e[i] * (S2[i] / S0[i] - np.outer(m1[i], m1[i])) for i in np.where(e == 1)[0])\n", + " step = np.linalg.solve(H - 1e-6 * np.eye(len(b)), g)\n", + " b = b - step\n", + " if np.abs(step).max() < 1e-7:\n", + " break\n", + " return b\n", + "\n", + "\n", + "def cindex(time, event, risk):\n", + " \"\"\"일치도 C-index: 위험 점수가 높은 쪽이 먼저 고장 났는가(관측 가능한 쌍만).\"\"\"\n", + " t, e, r = np.asarray(time), np.asarray(event), np.asarray(risk)\n", + " num = den = 0.0\n", + " for i in np.where(e == 1)[0]:\n", + " m = t > t[i]\n", + " den += m.sum()\n", + " num += (r[m] < r[i]).sum() + 0.5 * (r[m] == r[i]).sum()\n", + " return num / max(den, 1)\n", + "# ---------------------------------------------------------------------------\n", + "# ---------------------------------------------------------------------------\n", + "# 잔여 수명(RUL, 일) 예측 엔진: 와이블 AFT 조건부 중앙값 + 그래디언트 부스팅(고장 이력 시점 표본 학습)의 평균\n", + "# ---------------------------------------------------------------------------\n", + "from sklearn.ensemble import GradientBoostingRegressor\n", + "\n", + "\n", + "def fit_engine(train, seed=0):\n", + " p = fit_aft(train)\n", + " sn = add_features(snapshots(train, 5, seed))\n", + " sn[\"aft\"] = (aft_pred(p, sn) - sn[\"h_since\"]) / sn[\"rate\"]\n", + " g = GradientBoostingRegressor(n_estimators=250, max_depth=3, learning_rate=0.06, random_state=0).fit(sn[FEATS + [\"aft\"]], sn[\"rul_d\"])\n", + " return {\"p\": p, \"g\": g}\n", + "\n", + "\n", + "def predict_rul(eng, d):\n", + " \"\"\"d는 add_features를 거친 표. 반환: (앙상블 RUL, AFT RUL, GBR RUL) - 일 단위.\"\"\"\n", + " aft = ((aft_pred(eng[\"p\"], d) - d[\"h_since\"]) / d[\"rate\"]).values\n", + " x = d[FEATS].copy()\n", + " x[\"aft\"] = aft\n", + " gb = eng[\"g\"].predict(x)\n", + " return np.clip(0.5 * aft + 0.5 * gb, 1, None), aft, gb\n", + "# ---------------------------------------------------------------------------\n", + "# ---------------------------------------------------------------------------\n", + "# 계획정비(창정비) 일정 추천: 월별 정비창 능력·예산·시효성 부품 키트(유효기한) 제약 아래 기대 비용 최소화 (비용 단가는 교육용 가정값, 만원)\n", + "# ---------------------------------------------------------------------------\n", + "from scipy.optimize import milp, LinearConstraint, Bounds\n", + "from scipy.stats import norm\n", + "\n", + "C_PLAN, C_FAIL, C_WASTE_KH, C_KIT = 600.0, 2400.0, 40.0, 120.0 # 계획 창정비, 비계획 고장(긴급 정비 포함), 미사용 수명 1000h당 손실, 시효성 부품 키트\n", + "HORIZON, MDAYS = 12, 30 # 계획 12개월, 월 30일\n", + "CAP_M, BUDGET_M = 22, 15000.0 # 월별 정비창 능력(대), 월별 예산\n", + "LOTS = [(30, 70), (45, 170), (45, 390), (70, 800)] # 시효성 키트 로트(수량, 유효기한까지 남은 일수)\n", + "KIT_MARGIN = 14 # 정비일 기준 최소 유효기간 여유(일)\n", + "\n", + "\n", + "def exp_cost_matrix(pred, rate, month_days):\n", + " \"\"\"장비 i를 월 m에 정비할 때의 기대 비용. 참 RUL ~ N(pred, sigma), sigma = 35일 + 0.12 x pred (가정). 마지막 열은 계획 기간 밖(미정비).\"\"\"\n", + " sig = 35 + 0.12 * pred\n", + " C = np.zeros((len(pred), len(month_days) + 1))\n", + " for j, day in enumerate(month_days):\n", + " z = (day - pred) / sig\n", + " pf = norm.cdf(z)\n", + " unused = np.maximum(pred - day, 0) * rate * 0.6 # 정비를 앞당긴 만큼 버리는 운용시간 h(근사)\n", + " C[:, j] = C_PLAN + C_KIT + pf * (C_FAIL - C_PLAN) + C_WASTE_KH * unused / 1000 * (1 - pf)\n", + " C[:, -1] = norm.cdf((HORIZON * MDAYS - pred) / sig) * C_FAIL\n", + " return C\n", + "\n", + "\n", + "def plan_milp(pred, rate, cap=CAP_M, budget=BUDGET_M, lots=LOTS, use_kit=True):\n", + " \"\"\"정수계획으로 장비별 정비 월(0~11) 또는 미정비(-1)를 배정한다. 후보는 예측 RUL이 계획 기간 +90일 이내인 장비.\"\"\"\n", + " md = np.arange(1, HORIZON + 1) * MDAYS\n", + " cand = np.where(pred < HORIZON * MDAYS + 90)[0]\n", + " n, M = len(cand), HORIZON\n", + " C = exp_cost_matrix(pred[cand], rate[cand], md)\n", + " c = C.ravel()\n", + " A, lo, hi = [], [], []\n", + " for i in range(n): # 장비당 한 번만 배정(월 12칸 + 미정비)\n", + " row = np.zeros(n * (M + 1)); row[i * (M + 1):(i + 1) * (M + 1)] = 1; A.append(row); lo.append(1); hi.append(1)\n", + " for m in range(M): # 월별 능력·예산\n", + " row = np.zeros(n * (M + 1)); row[m::(M + 1)] = 1\n", + " A.append(row); lo.append(0); hi.append(cap)\n", + " A.append(row * (C_PLAN + C_KIT)); lo.append(0); hi.append(budget)\n", + " if use_kit: # 시효성 키트: m월 이후 정비 수요는 그 시점에 유효기간이 남은 로트 수량을 넘을 수 없다\n", + " for m in range(M):\n", + " sup = sum(q for q, e in lots if e >= md[m] + KIT_MARGIN)\n", + " row = np.zeros(n * (M + 1))\n", + " for mm in range(m, M):\n", + " row[mm::(M + 1)] = 1\n", + " A.append(row); lo.append(0); hi.append(sup)\n", + " r = milp(c, constraints=LinearConstraint(np.array(A), lo, hi), integrality=np.ones(len(c)), bounds=Bounds(0, 1), options={\"time_limit\": 20})\n", + " month = np.full(len(pred), -1)\n", + " if r.x is not None:\n", + " x = np.round(r.x).reshape(n, M + 1)\n", + " assign = np.where(x[:, :M].sum(axis=1) > 0.5, x[:, :M].argmax(axis=1), -1)\n", + " month[cand] = assign\n", + " return month\n", + "\n", + "\n", + "def plan_greedy(pred, margin=45, cap=CAP_M, budget=BUDGET_M):\n", + " \"\"\"간단한 탐욕 배정: 정비 목표일(예측 RUL - margin)이 빠른 순으로 목표일 이전 가장 늦은 빈 월, 없으면 가장 이른 빈 월에 배정.\"\"\"\n", + " cap = min(cap, int(budget // (C_PLAN + C_KIT))) # 월 예산이 허용하는 대수로 능력을 제한\n", + " month = np.full(len(pred), -1)\n", + " left = np.full(HORIZON, cap)\n", + " for i in np.argsort(pred):\n", + " if pred[i] > HORIZON * MDAYS + 60: break\n", + " tgt = int(np.clip((pred[i] - margin) // MDAYS - 1, 0, HORIZON - 1))\n", + " for m in list(range(tgt, -1, -1)) + list(range(tgt + 1, HORIZON)):\n", + " if left[m] > 0:\n", + " month[i] = m; left[m] -= 1; break\n", + " return month\n", + "\n", + "\n", + "def plan_fixed(lin_rul, cap=CAP_M, budget=BUDGET_M):\n", + " \"\"\"관행: 규정 주기 도달 순서대로(선형 잔여 일수) 월별 능력 안에서 배정.\"\"\"\n", + " return plan_greedy(np.asarray(lin_rul, float), margin=0, cap=cap, budget=budget)\n", + "\n", + "\n", + "def evaluate_plan(month, true_rul, rate, cost_kit=True):\n", + " \"\"\"참 RUL로 계획의 실제 비용을 계산한다: 정비일 이전에 고장 -> 비계획 고장 비용, 아니면 계획 비용 + 미사용 수명 손실.\"\"\"\n", + " day = np.where(month >= 0, (month + 1) * MDAYS, 10 ** 9)\n", + " fail = (true_rul < day) & (true_rul < HORIZON * MDAYS)\n", + " planned = (month >= 0) & ~fail\n", + " waste = np.where(planned, (true_rul - day) * rate * 0.6, 0) * C_WASTE_KH / 1000\n", + " cost = fail * C_FAIL + planned * (C_PLAN + (C_KIT if cost_kit else 0)) + waste\n", + " return {\"total\": float(cost.sum()), \"fail\": int(fail.sum()), \"planned\": int(planned.sum()), \"waste\": float(waste.sum()), \"unsched_fail\": int(((month < 0) & fail).sum())}\n", + "\n", + "\n", + "def kit_fefo(month, lots=LOTS):\n", + " \"\"\"유효기한이 빠른 로트부터 쓰는 FEFO로 키트를 배정하고 정비일에 유효기간이 부족한 배정 수와 만료 폐기 수량을 센다.\"\"\"\n", + " q = [[qty, e] for qty, e in sorted(lots, key=lambda t: t[1])]\n", + " short = 0\n", + " for m in sorted(month[month >= 0]):\n", + " day = (m + 1) * MDAYS\n", + " for lot in q:\n", + " if lot[1] >= day + KIT_MARGIN and lot[0] > 0:\n", + " lot[0] -= 1; break\n", + " else:\n", + " short += 1\n", + " expired = sum(l[0] for l in q if l[1] < HORIZON * MDAYS)\n", + " return {\"short\": short, \"expired_unused\": expired, \"left\": sum(l[0] for l in q)}\n", + "# ---------------------------------------------------------------------------\n", + "\n", + "tr = make_fleet(900, seed=1)\n", + "t0 = time.perf_counter()\n", + "eng = fit_engine(tr)\n", + "print(f\"[0] 예측 엔진 학습 {time.perf_counter() - t0:.2f}초(학습 {len(tr)}대, 시점 표본 {int(tr.event.sum()) * 5}건)\")\n", + "ALARM_D, MARGIN = 180, 60 # 위험 정의: 참 RUL 180일 미만 / 경보 기준: 예측 RUL이 180+60일 미만(예측 오차를 고려한 여유)\n", + "\n", + "\n", + "def pipeline(fl):\n", + " \"\"\"일괄 처리: 피처 -> RUL 예측 -> 경보 -> 일정 배정(탐욕) -> 대시보드 집계. 단계별 시간을 반환한다.\"\"\"\n", + " t = {}\n", + " s = time.perf_counter(); d = add_features(fl); t[\"피처\"] = time.perf_counter() - s\n", + " al = d[d.event == 0].reset_index(drop=True)\n", + " s = time.perf_counter(); pred, _, _ = predict_rul(eng, al); t[\"예측\"] = time.perf_counter() - s\n", + " s = time.perf_counter(); month = plan_greedy(pred, cap=max(CAP_M, int(len(al) * 0.03)), budget=1e9); t[\"일정\"] = time.perf_counter() - s\n", + " s = time.perf_counter()\n", + " al[\"pred\"], al[\"month\"] = pred, month\n", + " summ = al.groupby(\"unit\").agg(장비=(\"pred\", \"size\"), 평균RUL=(\"pred\", \"mean\"), 위험=(\"pred\", lambda x: int((x < ALARM_D + MARGIN).sum())))\n", + " cal = pd.crosstab(al.loc[al.month >= 0, \"unit\"], al.loc[al.month >= 0, \"month\"])\n", + " low = al.nsmallest(20, \"pred\")\n", + " fg, ax = plt.subplots(1, 2, figsize=(9, 3.5)); ax[0].bar(summ.index, summ[\"위험\"]); ax[1].imshow(cal.values, aspect=\"auto\"); fg.canvas.draw(); plt.close(fg)\n", + " t[\"대시보드\"] = time.perf_counter() - s\n", + " return al, t\n", + "\n", + "\n", + "# ---------------- 1) 규모별 시뮬레이션 ----------------\n", + "rows = []\n", + "for N in (1000, 5000, 10000, 50000, 100000):\n", + " s = time.perf_counter(); fl = make_fleet(N, seed=100 + N % 7); tg = time.perf_counter() - s\n", + " al, t = pipeline(fl)\n", + " tot = t[\"피처\"] + t[\"예측\"] + t[\"일정\"]\n", + " rows.append((N, len(al), tg, t[\"피처\"], t[\"예측\"], t[\"일정\"], t[\"대시보드\"], len(al) / tot))\n", + "ld = pd.DataFrame(rows, columns=[\"보유 장비\", \"생존 장비\", \"생성(초)\", \"피처(초)\", \"예측(초)\", \"일정(초)\", \"대시보드 로딩(초)\", \"처리량(대/초)\"])\n", + "print(\"\\n[1] 규모별 일괄 처리 시간(생성은 시뮬레이션용이며 실제 운영에는 없음)\")\n", + "print(ld.round(3).to_string(index=False))\n", + "r10 = ld[ld[\"보유 장비\"] == 10000].iloc[0]\n", + "print(f\" 1만 대 일괄 수명 시뮬레이션: 피처+예측+일정 {r10['피처(초)'] + r10['예측(초)'] + r10['일정(초)']:.2f}초, 대시보드 로딩 {r10['대시보드 로딩(초)']:.3f}초(목표 2초 이내: {'달성' if r10['대시보드 로딩(초)'] <= 2 else '미달'})\")\n", + "\n", + "# ---------------- 2) 위험 경보 정확도(1만 대) ----------------\n", + "fl10 = make_fleet(10000, seed=5)\n", + "d10 = add_features(fl10)\n", + "a10 = d10[d10.event == 0].reset_index(drop=True)\n", + "pred10, _, _ = predict_rul(eng, a10)\n", + "\n", + "\n", + "def alarm_metrics(pred, truth_d, margin=MARGIN):\n", + " al_, tr_ = pred < ALARM_D + margin, truth_d < ALARM_D\n", + " tp, fp, fn, tn = int((al_ & tr_).sum()), int((al_ & ~tr_).sum()), int((~al_ & tr_).sum()), int((~al_ & ~tr_).sum())\n", + " return (tp + tn) / len(pred), tp / max(tp + fn, 1), tp / max(tp + fp, 1), fp, fn\n", + "\n", + "\n", + "acc, rec, prec, fp, fn = alarm_metrics(pred10, a10[\"rul_d\"].values)\n", + "sw = pd.DataFrame([(m, *alarm_metrics(pred10, a10[\"rul_d\"].values, m)) for m in (0, 60, 120, 180)], columns=[\"여유(일)\", \"정확도\", \"재현율\", \"정밀도\", \"오경보\", \"미탐\"])\n", + "print(f\"\\n[2] 위험 경보 정확도: 생존 {len(a10)}대, 실제 위험(참 RUL {ALARM_D}일 미만) {int((a10.rul_d < ALARM_D).sum())}대({(a10.rul_d < ALARM_D).mean():.1%})\")\n", + "print(\" 경보 기준(예측 RUL < 180 + 여유) 비교\\n\" + sw.assign(정확도=sw[\"정확도\"].map(\"{:.1%}\".format), 재현율=sw[\"재현율\"].map(\"{:.1%}\".format), 정밀도=sw[\"정밀도\"].map(\"{:.1%}\".format)).to_string(index=False))\n", + "print(f\" 채택 여유 {MARGIN}일: 정확도 {acc:.1%} (목표 95% 이상: {'달성' if acc >= .95 else '미달'}), 재현율 {rec:.1%}, 정밀도 {prec:.1%}, 오경보 {fp}, 미탐 {fn}\")\n", + "print(\" 위험 장비가 드물어 정확도만으로는 부풀려지므로 재현율·정밀도를 함께 본다. 여유를 늘리면 재현율은 오르지만 오경보가 늘어 정확도가 95% 아래로 내려간다. 놓친 장비는 정기 점검(점검 마모)으로 보완한다.\")\n", + "\n", + "# ---------------- 3) 오류 주입 ----------------\n", + "rng = np.random.default_rng(7)\n", + "W_FIX = {\"normal\": 1.0, \"rough\": 1.15, \"salt\": 1.1, \"cold\": 1.05}\n", + "\n", + "\n", + "def corrupt(kind):\n", + " x = a10.copy()\n", + " if kind == \"점검 마모 20% 결측(대체)\":\n", + " m = rng.random(len(x)) < 0.2\n", + " x.loc[m, \"insp\"] = (x.loc[m, \"h_since\"] / CYCLE * x.loc[m, \"env\"].map(W_FIX)).clip(0, 1.3) # 정제 규칙: 노후 지수로 대체\n", + " elif kind == \"점검 마모 20% 결측(0으로 방치)\":\n", + " m = rng.random(len(x)) < 0.2\n", + " x.loc[m, \"insp\"] = 0.0\n", + " elif kind == \"환경 10% 오기재\":\n", + " m = rng.random(len(x)) < 0.1\n", + " x.loc[m, \"env\"] = rng.choice(ENVS, int(m.sum()))\n", + " elif kind == \"가동률 5% 단위 오류(x30)\":\n", + " m = rng.random(len(x)) < 0.05\n", + " x.loc[m, \"rate\"] = x.loc[m, \"rate\"] * 30\n", + " elif kind == \"복합(결측 대체+환경 오기재+단위 오류)\":\n", + " m = rng.random(len(x)) < 0.2; x.loc[m, \"insp\"] = (x.loc[m, \"h_since\"] / CYCLE * x.loc[m, \"env\"].map(W_FIX)).clip(0, 1.3)\n", + " m = rng.random(len(x)) < 0.1; x.loc[m, \"env\"] = rng.choice(ENVS, int(m.sum()))\n", + " m = rng.random(len(x)) < 0.05; x.loc[m, \"rate\"] = x.loc[m, \"rate\"] * 30\n", + " return add_features(x[[\"eq_id\", \"unit\", \"env\", \"etype\", \"rate\", \"hours\", \"km\", \"age_y\", \"n_ovh\", \"h_since\", \"unpl\", \"n_maint\", \"insp\", \"t_fail\", \"event\", \"dur\", \"rul_h\", \"rul_d\"]])\n", + "\n", + "\n", + "rows = [(\"정상\", acc, rec, prec, fp, fn)]\n", + "for kind in (\"점검 마모 20% 결측(대체)\", \"점검 마모 20% 결측(0으로 방치)\", \"환경 10% 오기재\", \"가동률 5% 단위 오류(x30)\", \"복합(결측 대체+환경 오기재+단위 오류)\"):\n", + " x = corrupt(kind)\n", + " p, _, _ = predict_rul(eng, x)\n", + " rows.append((kind, *alarm_metrics(p, a10[\"rul_d\"].values)))\n", + "er = pd.DataFrame(rows, columns=[\"조건\", \"정확도\", \"재현율\", \"정밀도\", \"오경보\", \"미탐\"])\n", + "print(\"\\n[3] 오류 주입 시 경보 성능(1만 대)\\n\" + er.assign(정확도=er[\"정확도\"].map(\"{:.1%}\".format), 재현율=er[\"재현율\"].map(\"{:.1%}\".format), 정밀도=er[\"정밀도\"].map(\"{:.1%}\".format)).to_string(index=False))\n", + "worst = er.iloc[1:].sort_values(\"정확도\").iloc[0]\n", + "z = er[er[\"조건\"].str.contains(\"0으로\")].iloc[0]\n", + "print(f\" 정확도가 가장 낮은 조건은 '{worst['조건']}'({worst['정확도']:.1%}, 오경보 {int(worst['오경보'])}건). 결측을 0으로 방치하면 재현율이 {z['재현율']:.1%}로 정상({er.iloc[0]['재현율']:.1%})보다 떨어지므로 정제 규칙(M2)의 대체값 처리가 필요하다.\")\n", + "print(f\" 95% 목표는 정상 조건에서만 달성하며 오류가 겹치면 {er.iloc[1:]['정확도'].min():.1%}까지 낮아진다 -> 입력 검증(M1·M2)이 경보 품질의 전제이다.\")\n", + "\n", + "# ---------------- 4) 정수계획 규모 한계 ----------------\n", + "mt = []\n", + "for n in (50, 150, 300, 600):\n", + " sub = a10.head(20000).copy()\n", + " pr_ = np.sort(pred10)[:n] * 0 + np.linspace(30, 440, n) # 예측 RUL이 계획 기간 안에 분포하는 후보 n대\n", + " s = time.perf_counter(); m = plan_milp(pr_, np.full(n, 3.0), cap=max(CAP_M, n // 8), budget=1e9); dt = time.perf_counter() - s\n", + " mt.append((n, dt, int((m >= 0).sum())))\n", + "print(\"\\n[4] 정수계획 후보 장비 수별 풀이 시간:\", \", \".join(f\"{n}대 {s:.2f}초\" for n, s, _ in mt))\n", + "print(\" 후보가 수천 대를 넘으면 정수계획이 느려지므로 위험 상위 후보만 정수계획, 나머지는 탐욕 배정으로 나누는 구조를 쓴다. 경보·일정은 판단 보조이며 확정은 군수지휘관 승인(M7)에 따른다.\")\n", + "print(\" 모든 부대·장비·수치는 교육용 가상 자료이다.\")\n", + "\n", + "fig, ax = plt.subplots(1, 3, figsize=(15, 4.2))\n", + "ax[0].loglog(ld[\"보유 장비\"], ld[\"피처(초)\"] + ld[\"예측(초)\"] + ld[\"일정(초)\"], \"o-\", label=\"batch total\"); ax[0].loglog(ld[\"보유 장비\"], ld[\"대시보드 로딩(초)\"], \"s-\", label=\"dashboard\"); ax[0].axhline(2, c=\"r\", ls=\"--\"); ax[0].legend(); ax[0].set_title(\"Processing time vs fleet size (s)\")\n", + "ax[1].barh(range(len(er)), er[\"재현율\"] * 100); ax[1].set_yticks(range(len(er))); ax[1].set_yticklabels([f\"C{i + 1}\" for i in range(len(er))]); ax[1].set_title(\"Alarm recall by fault case (%)\")\n", + "ax[2].plot([m[0] for m in mt], [m[1] for m in mt], \"o-\"); ax[2].set_title(\"MILP solve time vs candidate units (s)\")\n", + "plt.tight_layout(); plt.show()\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 08 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + "[0] 예측 엔진 학습 1.44초(학습 900대, 시점 표본 1440건)\n", + "\n", + "[1] 규모별 일괄 처리 시간(생성은 시뮬레이션용이며 실제 운영에는 없음)\n", + " 보유 장비 생존 장비 생성(초) 피처(초) 예측(초) 일정(초) 대시보드 로딩(초) 처리량(대/초)\n", + " 1000 676 0.021 0.007 0.008 0.001 0.125 41807.644\n", + " 5000 3346 0.132 0.010 0.020 0.005 0.111 95891.024\n", + " 10000 6643 0.282 0.013 0.036 0.010 0.107 113284.295\n", + " 50000 33436 0.989 0.051 0.177 0.038 0.127 125508.675\n", + "100000 66873 2.168 0.083 0.388 0.079 0.132 121715.318\n", + " 1만 대 일괄 수명 시뮬레이션: 피처+예측+일정 0.06초, 대시보드 로딩 0.107초(목표 2초 이내: 달성)\n", + "\n", + "[2] 위험 경보 정확도: 생존 6741대, 실제 위험(참 RUL 180일 미만) 474대(7.0%)\n", + " 경보 기준(예측 RUL < 180 + 여유) 비교\n", + " 여유(일) 정확도 재현율 정밀도 오경보 미탐\n", + " 0 95.7% 51.1% 80.7% 58 232\n", + " 60 95.5% 70.9% 67.5% 162 138\n", + " 120 94.7% 84.6% 58.3% 287 73\n", + " 180 92.5% 92.0% 48.1% 470 38\n", + " 채택 여유 60일: 정확도 95.5% (목표 95% 이상: 달성), 재현율 70.9%, 정밀도 67.5%, 오경보 162, 미탐 138\n", + " 위험 장비가 드물어 정확도만으로는 부풀려지므로 재현율·정밀도를 함께 본다. 여유를 늘리면 재현율은 오르지만 오경보가 늘어 정확도가 95% 아래로 내려간다. 놓친 장비는 정기 점검(점검 마모)으로 보완한다.\n", + "\n", + "[3] 오류 주입 시 경보 성능(1만 대)\n", + " 조건 정확도 재현율 정밀도 오경보 미탐\n", + " 정상 95.5% 70.9% 67.5% 162 138\n", + " 점검 마모 20% 결측(대체) 94.9% 69.2% 62.1% 200 146\n", + " 점검 마모 20% 결측(0으로 방치) 94.9% 54.6% 67.1% 127 215\n", + " 환경 10% 오기재 95.4% 70.5% 66.0% 172 140\n", + " 가동률 5% 단위 오류(x30) 94.7% 72.6% 60.4% 226 130\n", + "복합(결측 대체+환경 오기재+단위 오류) 93.5% 69.2% 52.9% 292 146\n", + " 정확도가 가장 낮은 조건은 '복합(결측 대체+환경 오기재+단위 오류)'(93.5%, 오경보 292건). 결측을 0으로 방치하면 재현율이 54.6%로 정상(70.9%)보다 떨어지므로 정제 규칙(M2)의 대체값 처리가 필요하다.\n", + " 95% 목표는 정상 조건에서만 달성하며 오류가 겹치면 93.5%까지 낮아진다 -> 입력 검증(M1·M2)이 경보 품질의 전제이다.\n", + "\n", + "[4] 정수계획 후보 장비 수별 풀이 시간: 50대 0.04초, 150대 0.11초, 300대 0.22초, 600대 0.50초\n", + " 후보가 수천 대를 넘으면 정수계획이 느려지므로 위험 상위 후보만 정수계획, 나머지는 탐욕 배정으로 나누는 구조를 쓴다. 경보·일정은 판단 보조이며 확정은 군수지휘관 승인(M7)에 따른다.\n", + " 모든 부대·장비·수치는 교육용 가상 자료이다.\n", + "```\n", + "
\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 09. AAR 기반 수명 예측 오차 분석 및 환경 가중치 파라미터 재보정\n", + "**소요시간**: 약 3.0시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "AAR 기반 수명 예측 오차를 분석하고 환경 가중치를 재보정한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **AAR로 수명 예측 오차를 분석하고 환경 가중치를 재보정해 ROI를 산출한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **운영 장비의 예측 오차를 환경, 가동률, 비계획 정비율별로 나누고 큰 오차의 원인을 분류 수행하고 검증하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `reports/sup02_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로 환경별·원인별 분석하고, 환경 가중치를 운영 고장 이력의 AFT 계수로 재보정하고 운영 데이터로 예측 엔진을 재학습해 보류 장비 집단에서 개선을 검증한 뒤 비용 절감액·가동 중단일수 단축률·투자 회수 기간 3대 효과 지표를 산식과 민감도로 산출한다.\"\"\"\n", + "import numpy as np\n", + "import pandas as pd\n", + "import matplotlib.pyplot as plt\n", + "from sklearn.metrics import r2_score\n", + "\n", + "# ---------------------------------------------------------------------------\n", + "# [교육용가상자료] 가상 장비 보유 현황·고장 이력 생성기 (실제 부대·장비·수치 없음)\n", + "# 가상 부대 6개(U1~U6)의 장비가 운용 환경(normal/rough/salt/cold)에 따라 다른 속도로 노후화한다. 창정비 후 운용시간(h_since)이\n", + "# 장비마다 숨은 수명(t_fail)을 넘으면 고장(event=1) 이력, 넘지 않았으면 현재까지 생존(event=0, 관측 중도절단)이다.\n", + "# ---------------------------------------------------------------------------\n", + "ENVS = [\"normal\", \"rough\", \"salt\", \"cold\"]\n", + "ENV_W = {\"normal\": 1.00, \"rough\": 1.35, \"salt\": 1.25, \"cold\": 1.12} # 환경 가중치(숨은 정답, 가정값): 클수록 수명이 짧다\n", + "ENV_KO = {\"normal\": \"일반\", \"rough\": \"험지\", \"salt\": \"염분\", \"cold\": \"혹한\"}\n", + "UNITS = [\"U1\", \"U2\", \"U3\", \"U4\", \"U5\", \"U6\"]\n", + "UNIT_ENV = {\"U1\": [.7, .1, .1, .1], \"U2\": [.2, .6, .1, .1], \"U3\": [.2, .1, .6, .1], \"U4\": [.3, .1, .1, .5], \"U5\": [.5, .3, .1, .1], \"U6\": [.4, .1, .4, .1]}\n", + "ETYPES = [\"truck\", \"generator\", \"trailer\", \"radio\"]\n", + "CYCLE = 8000.0 # 규정 창정비 주기(운용시간 h, 가정값)\n", + "KSHAPE = 12.0 # 수명 분포 와이블 형상(마모형 고장, 클수록 수명이 모여 있다)\n", + "INSP_NOISE = 0.025 # 정비 점검 마모 비율 측정 오차(가정값)\n", + "\n", + "\n", + "def make_fleet(n=900, seed=0, env_w=None, shift=1.0):\n", + " \"\"\"장비 n대. env_w로 숨은 환경 가중치를 바꿔 운영 분포 변화를 모의할 수 있다. shift>1이면 가동률이 높아진다.\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " w = env_w or ENV_W\n", + " unit = rng.choice(UNITS, n)\n", + " env = np.array([rng.choice(ENVS, p=UNIT_ENV[u]) for u in unit])\n", + " et = rng.choice(ETYPES, n, p=[.4, .2, .2, .2])\n", + " rate = rng.lognormal(np.log(3.0 * shift), 0.25, n) # 일 평균 운용시간(h/일)\n", + " h_since = rng.uniform(300, 11500, n) # 마지막 창정비 이후 운용시간\n", + " n_ovh = rng.integers(0, 3, n)\n", + " hours = h_since + n_ovh * rng.uniform(5000, 8000, n) # 누적 운용시간\n", + " km = hours * rng.normal(17, 3, n) * np.where(env == \"rough\", 0.8, 1.0) # 누적 기동거리(km)\n", + " age_y = np.clip(hours / (rate * 365) * rng.uniform(0.9, 1.15, n), 0.5, 25)\n", + " unpl = rng.poisson(0.35 * h_since / 1000 * np.array([w[e] for e in env]) / 1.3) # 비계획 정비 건수(환경이 가혹할수록 많다)\n", + " n_maint = rng.poisson(0.6 * h_since / 1000 + 1) # 계획 정비 건수\n", + " scale = 9600 / np.array([w[e] for e in env]) * 0.985 ** unpl * np.exp(rng.normal(0, 0.04, n))\n", + " t_fail = scale * rng.weibull(KSHAPE, n) # 창정비 후 고장까지 운용시간(숨은 정답)\n", + " event = (t_fail <= h_since).astype(int)\n", + " dur = np.where(event == 1, t_fail, h_since)\n", + " df = pd.DataFrame({\"eq_id\": [f\"EQ{seed:02d}{i:04d}\" for i in range(n)], \"unit\": unit, \"env\": env, \"etype\": et, \"rate\": rate, \"hours\": hours, \"km\": km, \"age_y\": age_y, \"n_ovh\": n_ovh,\n", + " \"h_since\": h_since, \"unpl\": unpl, \"n_maint\": n_maint, \"t_fail\": t_fail, \"event\": event, \"dur\": dur})\n", + " df[\"insp\"] = np.clip(dur / t_fail + rng.normal(0, INSP_NOISE, n), 0, 1.3) # 마지막 정비 점검에서 측정한 마모 비율(소모 수명/총 수명, 오차 포함)\n", + " df[\"rul_h\"] = np.where(event == 0, t_fail - h_since, np.nan) # 생존 장비의 남은 운용시간(정답)\n", + " df[\"rul_d\"] = df[\"rul_h\"] / df[\"rate\"] # 남은 일수(정답)\n", + " return df\n", + "\n", + "\n", + "def mape(y, p):\n", + " y, p = np.asarray(y, float), np.asarray(p, float)\n", + " return float(np.mean(np.abs(y - p) / np.abs(y)) * 100)\n", + "\n", + "\n", + "def mae(y, p):\n", + " return float(np.mean(np.abs(np.asarray(y, float) - np.asarray(p, float))))\n", + "\n", + "\n", + "def rmse(y, p):\n", + " return float(np.sqrt(np.mean((np.asarray(y, float) - np.asarray(p, float)) ** 2)))\n", + "# ---------------------------------------------------------------------------\n", + "# ---------------------------------------------------------------------------\n", + "# 노후화 파생 피처와 학습 표본 만들기. 환경 가중치 W0는 도입 시점의 전문가 추정값이며(실제 가중치와 다름) M9에서 재보정한다.\n", + "# ---------------------------------------------------------------------------\n", + "W0 = {\"normal\": 1.00, \"rough\": 1.15, \"salt\": 1.10, \"cold\": 1.05}\n", + "FEATS = [\"h_since\", \"hours\", \"km\", \"age_y\", \"rate\", \"n_maint\", \"unpl\", \"unpl_per_kh\", \"km_per_h\", \"n_ovh\", \"k_rough\", \"k_salt\", \"k_cold\", \"k_truck\", \"k_gen\", \"k_trailer\",\n", + " \"aging_idx\", \"lin_rul_d\", \"hs_frac\", \"insp\", \"insp_eta_h\"]\n", + "\n", + "\n", + "def add_features(df, w=None):\n", + " d = df.copy()\n", + " w = w or W0\n", + " d[\"unpl_per_kh\"] = d[\"unpl\"] / (d[\"h_since\"] / 1000 + 0.5)\n", + " d[\"km_per_h\"] = d[\"km\"] / d[\"hours\"]\n", + " for e in (\"rough\", \"salt\", \"cold\"):\n", + " d[\"k_\" + e] = (d[\"env\"] == e).astype(int)\n", + " d[\"k_truck\"], d[\"k_gen\"], d[\"k_trailer\"] = (d[\"etype\"] == \"truck\").astype(int), (d[\"etype\"] == \"generator\").astype(int), (d[\"etype\"] == \"trailer\").astype(int)\n", + " d[\"aging_idx\"] = d[\"h_since\"] * d[\"env\"].map(w) / CYCLE # 환경 가중 노후 지수(1이면 창정비 주기에 도달)\n", + " d[\"lin_rul_d\"] = (CYCLE - d[\"h_since\"]) / d[\"rate\"] # 선형 감가상각 잔여 일수(Baseline)\n", + " d[\"hs_frac\"] = d[\"h_since\"] / CYCLE\n", + " d[\"insp_eta_h\"] = d[\"h_since\"] / np.maximum(d[\"insp\"], 0.05) # 점검 마모 비율로 환산한 총 수명 추정(h)\n", + " return d\n", + "\n", + "\n", + "def snapshots(df, k=4, seed=0):\n", + " \"\"\"고장 이력 장비의 과거 시점 표본: 고장 전 임의 시점의 점검값·운용시간과 그때의 남은 일수. (생존 장비의 미래는 알 수 없으므로 고장 이력만 쓴다)\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " f = df[df[\"event\"] == 1]\n", + " out = []\n", + " for _ in range(k):\n", + " s = f.copy()\n", + " h = s[\"t_fail\"] * rng.uniform(0.2, 0.98, len(s))\n", + " ratio = h / s[\"h_since\"]\n", + " s[\"hours\"] = s[\"hours\"] - (s[\"h_since\"] - h)\n", + " s[\"km\"] = s[\"km\"] * ratio.clip(upper=1)\n", + " s[\"unpl\"] = np.floor(s[\"unpl\"] * ratio)\n", + " s[\"n_maint\"] = np.floor(s[\"n_maint\"] * ratio)\n", + " s[\"insp\"] = np.clip(h / s[\"t_fail\"] + rng.normal(0, INSP_NOISE, len(s)), 0, 1.3)\n", + " s[\"h_since\"] = h\n", + " s[\"rul_d\"] = (s[\"t_fail\"] - h) / s[\"rate\"]\n", + " s[\"event\"] = 0\n", + " out.append(s)\n", + " return pd.concat(out, ignore_index=True)\n", + "# ---------------------------------------------------------------------------\n", + "# ---------------------------------------------------------------------------\n", + "# 생존 분석 도구(scipy만 사용): 와이블 AFT(가속 수명) 모형과 Cox 비례위험 모형\n", + "# ---------------------------------------------------------------------------\n", + "from scipy.optimize import minimize\n", + "\n", + "\n", + "def aft_x(d):\n", + " return np.c_[np.ones(len(d)), d[\"env\"].eq(\"rough\"), d[\"env\"].eq(\"salt\"), d[\"env\"].eq(\"cold\"), d[\"unpl\"] / (d[\"h_since\"] / 1000 + 0.5)].astype(float)\n", + "\n", + "\n", + "AFT_COLS = [\"절편\", \"험지\", \"염분\", \"혹한\", \"비계획 정비율\"]\n", + "\n", + "\n", + "def fit_aft(d):\n", + " \"\"\"창정비 후 운용시간 dur(고장이면 event=1, 생존이면 중도절단)에 대한 와이블 AFT 최대우도 추정. 반환: 계수(마지막은 log 형상).\"\"\"\n", + " x, t, e = aft_x(d), d[\"dur\"].values, d[\"event\"].values\n", + "\n", + " def nll(p):\n", + " lam, k = np.exp(x @ p[:-1]), np.exp(p[-1])\n", + " z = (t / lam) ** k\n", + " return -(e * (np.log(k) - np.log(lam) + (k - 1) * (np.log(t) - np.log(lam))) - z).sum()\n", + "\n", + " r = minimize(nll, np.r_[np.log(8000), np.zeros(x.shape[1] - 1), np.log(5)], method=\"BFGS\")\n", + " return r.x\n", + "\n", + "\n", + "def aft_pred(p, d, q=0.5, h=None):\n", + " \"\"\"이미 h(운용시간)까지 생존했다는 조건에서 분위수 q 생존 시점(운용시간 h 기준 총 수명)을 돌려준다. q=0.5가 조건부 중앙값.\"\"\"\n", + " lam, k = np.exp(aft_x(d) @ p[:-1]), np.exp(p[-1])\n", + " h = d[\"h_since\"].values if h is None else h\n", + " return lam * ((h / lam) ** k - np.log(q)) ** (1 / k)\n", + "\n", + "\n", + "def aft_fail_prob(p, d, h0, h1):\n", + " \"\"\"h0까지 생존한 장비가 h0~h1 사이에 고장 날 조건부 확률.\"\"\"\n", + " lam, k = np.exp(aft_x(d) @ p[:-1]), np.exp(p[-1])\n", + " s0, s1 = np.exp(-(h0 / lam) ** k), np.exp(-(h1 / lam) ** k)\n", + " return 1 - s1 / s0\n", + "\n", + "\n", + "def fit_cox(d, iters=25):\n", + " \"\"\"Cox 비례위험(Breslow 근사) 뉴턴-랩슨. 공변량: 험지·염분·혹한·비계획 정비율. 반환: 계수.\"\"\"\n", + " x = aft_x(d)[:, 1:]\n", + " o = np.argsort(-d[\"dur\"].values)\n", + " x, e = x[o], d[\"event\"].values[o]\n", + " b = np.zeros(x.shape[1])\n", + " for _ in range(iters):\n", + " r = np.exp(x @ b)\n", + " S0 = np.cumsum(r)\n", + " S1 = np.cumsum(r[:, None] * x, axis=0)\n", + " S2 = np.cumsum(r[:, None, None] * x[:, :, None] * x[:, None, :], axis=0)\n", + " m1 = S1 / S0[:, None]\n", + " g = ((x - m1) * e[:, None]).sum(axis=0)\n", + " H = -sum(e[i] * (S2[i] / S0[i] - np.outer(m1[i], m1[i])) for i in np.where(e == 1)[0])\n", + " step = np.linalg.solve(H - 1e-6 * np.eye(len(b)), g)\n", + " b = b - step\n", + " if np.abs(step).max() < 1e-7:\n", + " break\n", + " return b\n", + "\n", + "\n", + "def cindex(time, event, risk):\n", + " \"\"\"일치도 C-index: 위험 점수가 높은 쪽이 먼저 고장 났는가(관측 가능한 쌍만).\"\"\"\n", + " t, e, r = np.asarray(time), np.asarray(event), np.asarray(risk)\n", + " num = den = 0.0\n", + " for i in np.where(e == 1)[0]:\n", + " m = t > t[i]\n", + " den += m.sum()\n", + " num += (r[m] < r[i]).sum() + 0.5 * (r[m] == r[i]).sum()\n", + " return num / max(den, 1)\n", + "# ---------------------------------------------------------------------------\n", + "# ---------------------------------------------------------------------------\n", + "# 잔여 수명(RUL, 일) 예측 엔진: 와이블 AFT 조건부 중앙값 + 그래디언트 부스팅(고장 이력 시점 표본 학습)의 평균\n", + "# ---------------------------------------------------------------------------\n", + "from sklearn.ensemble import GradientBoostingRegressor\n", + "\n", + "\n", + "def fit_engine(train, seed=0):\n", + " p = fit_aft(train)\n", + " sn = add_features(snapshots(train, 5, seed))\n", + " sn[\"aft\"] = (aft_pred(p, sn) - sn[\"h_since\"]) / sn[\"rate\"]\n", + " g = GradientBoostingRegressor(n_estimators=250, max_depth=3, learning_rate=0.06, random_state=0).fit(sn[FEATS + [\"aft\"]], sn[\"rul_d\"])\n", + " return {\"p\": p, \"g\": g}\n", + "\n", + "\n", + "def predict_rul(eng, d):\n", + " \"\"\"d는 add_features를 거친 표. 반환: (앙상블 RUL, AFT RUL, GBR RUL) - 일 단위.\"\"\"\n", + " aft = ((aft_pred(eng[\"p\"], d) - d[\"h_since\"]) / d[\"rate\"]).values\n", + " x = d[FEATS].copy()\n", + " x[\"aft\"] = aft\n", + " gb = eng[\"g\"].predict(x)\n", + " return np.clip(0.5 * aft + 0.5 * gb, 1, None), aft, gb\n", + "# ---------------------------------------------------------------------------\n", + "# ---------------------------------------------------------------------------\n", + "# 계획정비(창정비) 일정 추천: 월별 정비창 능력·예산·시효성 부품 키트(유효기한) 제약 아래 기대 비용 최소화 (비용 단가는 교육용 가정값, 만원)\n", + "# ---------------------------------------------------------------------------\n", + "from scipy.optimize import milp, LinearConstraint, Bounds\n", + "from scipy.stats import norm\n", + "\n", + "C_PLAN, C_FAIL, C_WASTE_KH, C_KIT = 600.0, 2400.0, 40.0, 120.0 # 계획 창정비, 비계획 고장(긴급 정비 포함), 미사용 수명 1000h당 손실, 시효성 부품 키트\n", + "HORIZON, MDAYS = 12, 30 # 계획 12개월, 월 30일\n", + "CAP_M, BUDGET_M = 22, 15000.0 # 월별 정비창 능력(대), 월별 예산\n", + "LOTS = [(30, 70), (45, 170), (45, 390), (70, 800)] # 시효성 키트 로트(수량, 유효기한까지 남은 일수)\n", + "KIT_MARGIN = 14 # 정비일 기준 최소 유효기간 여유(일)\n", + "\n", + "\n", + "def exp_cost_matrix(pred, rate, month_days):\n", + " \"\"\"장비 i를 월 m에 정비할 때의 기대 비용. 참 RUL ~ N(pred, sigma), sigma = 35일 + 0.12 x pred (가정). 마지막 열은 계획 기간 밖(미정비).\"\"\"\n", + " sig = 35 + 0.12 * pred\n", + " C = np.zeros((len(pred), len(month_days) + 1))\n", + " for j, day in enumerate(month_days):\n", + " z = (day - pred) / sig\n", + " pf = norm.cdf(z)\n", + " unused = np.maximum(pred - day, 0) * rate * 0.6 # 정비를 앞당긴 만큼 버리는 운용시간 h(근사)\n", + " C[:, j] = C_PLAN + C_KIT + pf * (C_FAIL - C_PLAN) + C_WASTE_KH * unused / 1000 * (1 - pf)\n", + " C[:, -1] = norm.cdf((HORIZON * MDAYS - pred) / sig) * C_FAIL\n", + " return C\n", + "\n", + "\n", + "def plan_milp(pred, rate, cap=CAP_M, budget=BUDGET_M, lots=LOTS, use_kit=True):\n", + " \"\"\"정수계획으로 장비별 정비 월(0~11) 또는 미정비(-1)를 배정한다. 후보는 예측 RUL이 계획 기간 +90일 이내인 장비.\"\"\"\n", + " md = np.arange(1, HORIZON + 1) * MDAYS\n", + " cand = np.where(pred < HORIZON * MDAYS + 90)[0]\n", + " n, M = len(cand), HORIZON\n", + " C = exp_cost_matrix(pred[cand], rate[cand], md)\n", + " c = C.ravel()\n", + " A, lo, hi = [], [], []\n", + " for i in range(n): # 장비당 한 번만 배정(월 12칸 + 미정비)\n", + " row = np.zeros(n * (M + 1)); row[i * (M + 1):(i + 1) * (M + 1)] = 1; A.append(row); lo.append(1); hi.append(1)\n", + " for m in range(M): # 월별 능력·예산\n", + " row = np.zeros(n * (M + 1)); row[m::(M + 1)] = 1\n", + " A.append(row); lo.append(0); hi.append(cap)\n", + " A.append(row * (C_PLAN + C_KIT)); lo.append(0); hi.append(budget)\n", + " if use_kit: # 시효성 키트: m월 이후 정비 수요는 그 시점에 유효기간이 남은 로트 수량을 넘을 수 없다\n", + " for m in range(M):\n", + " sup = sum(q for q, e in lots if e >= md[m] + KIT_MARGIN)\n", + " row = np.zeros(n * (M + 1))\n", + " for mm in range(m, M):\n", + " row[mm::(M + 1)] = 1\n", + " A.append(row); lo.append(0); hi.append(sup)\n", + " r = milp(c, constraints=LinearConstraint(np.array(A), lo, hi), integrality=np.ones(len(c)), bounds=Bounds(0, 1), options={\"time_limit\": 20})\n", + " month = np.full(len(pred), -1)\n", + " if r.x is not None:\n", + " x = np.round(r.x).reshape(n, M + 1)\n", + " assign = np.where(x[:, :M].sum(axis=1) > 0.5, x[:, :M].argmax(axis=1), -1)\n", + " month[cand] = assign\n", + " return month\n", + "\n", + "\n", + "def plan_greedy(pred, margin=45, cap=CAP_M, budget=BUDGET_M):\n", + " \"\"\"간단한 탐욕 배정: 정비 목표일(예측 RUL - margin)이 빠른 순으로 목표일 이전 가장 늦은 빈 월, 없으면 가장 이른 빈 월에 배정.\"\"\"\n", + " cap = min(cap, int(budget // (C_PLAN + C_KIT))) # 월 예산이 허용하는 대수로 능력을 제한\n", + " month = np.full(len(pred), -1)\n", + " left = np.full(HORIZON, cap)\n", + " for i in np.argsort(pred):\n", + " if pred[i] > HORIZON * MDAYS + 60: break\n", + " tgt = int(np.clip((pred[i] - margin) // MDAYS - 1, 0, HORIZON - 1))\n", + " for m in list(range(tgt, -1, -1)) + list(range(tgt + 1, HORIZON)):\n", + " if left[m] > 0:\n", + " month[i] = m; left[m] -= 1; break\n", + " return month\n", + "\n", + "\n", + "def plan_fixed(lin_rul, cap=CAP_M, budget=BUDGET_M):\n", + " \"\"\"관행: 규정 주기 도달 순서대로(선형 잔여 일수) 월별 능력 안에서 배정.\"\"\"\n", + " return plan_greedy(np.asarray(lin_rul, float), margin=0, cap=cap, budget=budget)\n", + "\n", + "\n", + "def evaluate_plan(month, true_rul, rate, cost_kit=True):\n", + " \"\"\"참 RUL로 계획의 실제 비용을 계산한다: 정비일 이전에 고장 -> 비계획 고장 비용, 아니면 계획 비용 + 미사용 수명 손실.\"\"\"\n", + " day = np.where(month >= 0, (month + 1) * MDAYS, 10 ** 9)\n", + " fail = (true_rul < day) & (true_rul < HORIZON * MDAYS)\n", + " planned = (month >= 0) & ~fail\n", + " waste = np.where(planned, (true_rul - day) * rate * 0.6, 0) * C_WASTE_KH / 1000\n", + " cost = fail * C_FAIL + planned * (C_PLAN + (C_KIT if cost_kit else 0)) + waste\n", + " return {\"total\": float(cost.sum()), \"fail\": int(fail.sum()), \"planned\": int(planned.sum()), \"waste\": float(waste.sum()), \"unsched_fail\": int(((month < 0) & fail).sum())}\n", + "\n", + "\n", + "def kit_fefo(month, lots=LOTS):\n", + " \"\"\"유효기한이 빠른 로트부터 쓰는 FEFO로 키트를 배정하고 정비일에 유효기간이 부족한 배정 수와 만료 폐기 수량을 센다.\"\"\"\n", + " q = [[qty, e] for qty, e in sorted(lots, key=lambda t: t[1])]\n", + " short = 0\n", + " for m in sorted(month[month >= 0]):\n", + " day = (m + 1) * MDAYS\n", + " for lot in q:\n", + " if lot[1] >= day + KIT_MARGIN and lot[0] > 0:\n", + " lot[0] -= 1; break\n", + " else:\n", + " short += 1\n", + " expired = sum(l[0] for l in q if l[1] < HORIZON * MDAYS)\n", + " return {\"short\": short, \"expired_unused\": expired, \"left\": sum(l[0] for l in q)}\n", + "# ---------------------------------------------------------------------------\n", + "\n", + "OPS_W = {\"normal\": 1.00, \"rough\": 1.55, \"salt\": 1.15, \"cold\": 1.30} # 운영 기간의 숨은 환경 가중치(도입 당시와 달라진 가정)\n", + "tr = make_fleet(900, seed=1)\n", + "eng0 = fit_engine(tr)\n", + "A = make_fleet(700, seed=60, env_w=OPS_W, shift=1.2) # AAR 대상 운영 장비\n", + "B = make_fleet(700, seed=70, env_w=OPS_W, shift=1.2) # 재보정에 쓰지 않는 보류 장비\n", + "\n", + "# ---------------- 1) AAR: 운영 예측 오차 분석 ----------------\n", + "Af = add_features(A)\n", + "al = Af[Af.event == 0].reset_index(drop=True)\n", + "pred, _, _ = predict_rul(eng0, al)\n", + "y = al[\"rul_d\"].values\n", + "al[\"err\"] = pred - y # 양수 = 수명을 길게 예측(위험)\n", + "big = y >= 90\n", + "print(f\"[1] AAR 대상 운영 장비 {len(A)}대(생존 {len(al)}대, 고장 {int(A.event.sum())}대): 현행 엔진 MAE {mae(y, pred):.0f}일, RMSE {rmse(y, pred):.0f}일, MAPE {mape(y[big], pred[big]):.1f}%, R2 {r2_score(y, pred):.3f}, 평균 편향 {al.err.mean():+.0f}일\")\n", + "ct = pd.DataFrame([(k, int(m.sum()), mae(y[m.values], pred[m.values]), al.err[m].mean()) for k, m in {\"일반\": al.env == \"normal\", \"험지\": al.env == \"rough\", \"염분\": al.env == \"salt\", \"혹한\": al.env == \"cold\",\n", + " \"일 가동률 4h 이상\": al.rate >= 4, \"비계획 정비율 상위 25%\": al.unpl_per_kh >= al.unpl_per_kh.quantile(.75)}.items()], columns=[\"조건\", \"대수\", \"MAE(일)\", \"평균 편향(일)\"])\n", + "print(ct.round(0).to_string(index=False))\n", + "worst = al.reindex(al.err.abs().sort_values(ascending=False).index).head(40)\n", + "\n", + "\n", + "def cause(r):\n", + " if r.env == \"rough\" and r.err > 0: return \"험지 가혹도 과소 반영\"\n", + " if r.env == \"cold\" and r.err > 0: return \"혹한 가혹도 과소 반영\"\n", + " if r.env == \"salt\" and r.err < 0: return \"염분 가혹도 과대 반영\"\n", + " if r.rate >= 4: return \"가동률 상승(운용 강도 변화)\"\n", + " if r.unpl_per_kh >= 1.2: return \"비계획 정비 다수\"\n", + " return \"기타(개체 편차)\"\n", + "\n", + "\n", + "cs = worst.apply(cause, axis=1).value_counts()\n", + "print(\" 오차 큰 상위 40대의 원인 분류:\", \", \".join(f\"{k} {v}대({v / 40:.0%})\" for k, v in cs.items()))\n", + "\n", + "# ---------------- 2) 재보정: 환경 가중치 -> 운영 데이터 재학습 ----------------\n", + "pa = fit_aft(A)\n", + "life_ratio = np.exp(pa[1:4])\n", + "W1 = {\"normal\": 1.0, \"rough\": 1 / life_ratio[0], \"salt\": 1 / life_ratio[1], \"cold\": 1 / life_ratio[2]}\n", + "wt = pd.DataFrame({\"도입 시 추정(W0)\": W0, \"재보정(운영 AFT)\": W1, \"숨은 정답(운영)\": OPS_W}).loc[ENVS]\n", + "wt[\"변화율\"] = wt[\"재보정(운영 AFT)\"] / wt[\"도입 시 추정(W0)\"] - 1\n", + "print(\"\\n[2] 환경 가중치 매트릭스(클수록 빨리 노후)\")\n", + "print(wt.round(3).to_string())\n", + "flag = wt[wt[\"변화율\"].abs() > 0.2].index.tolist()\n", + "print(f\" 변화율 20% 초과 환경 {flag or '없음'}는 정비계획관 검토·군수지휘관 승인 뒤 적용한다(자동 반영 금지).\")\n", + "eng1 = fit_engine(A, seed=1)\n", + "Bf0, Bf1 = add_features(B, W0), add_features(B, W1)\n", + "bl0, bl1 = Bf0[Bf0.event == 0].reset_index(drop=True), Bf1[Bf1.event == 0].reset_index(drop=True)\n", + "p0, _, _ = predict_rul(eng0, bl0)\n", + "p1, _, _ = predict_rul(eng1, bl1)\n", + "yb = bl0[\"rul_d\"].values\n", + "bb = yb >= 90\n", + "tab = pd.DataFrame({\"재보정 전\": [mae(yb, p0), rmse(yb, p0), mape(yb[bb], p0[bb]), r2_score(yb, p0), (p0 - yb).mean()], \"재보정 후\": [mae(yb, p1), rmse(yb, p1), mape(yb[bb], p1[bb]), r2_score(yb, p1), (p1 - yb).mean()]},\n", + " index=[\"MAE(일)\", \"RMSE(일)\", \"MAPE(%)\", \"R2\", \"평균 편향(일)\"])\n", + "print(f\"\\n[3] 보류 장비 {len(bl0)}대(재보정에 쓰지 않음) 검증\\n\" + tab.round(3).to_string())\n", + "print(f\" MAE {1 - tab.loc['MAE(일)', '재보정 후'] / tab.loc['MAE(일)', '재보정 전']:.1%} 감소, 환경별 편향(전 -> 후):\", \", \".join(f\"{e} {(p0 - yb)[(bl0.env == e).values].mean():+.0f} -> {(p1 - yb)[(bl0.env == e).values].mean():+.0f}\" for e in ENVS))\n", + "\n", + "# ---------------- 3) ROI 3대 지표(교육용 가정) ----------------\n", + "rate_b, true_b = bl0[\"rate\"].values, yb\n", + "m0, m1 = plan_milp(p0, rate_b), plan_milp(p1, rate_b)\n", + "r0, r1 = evaluate_plan(m0, true_b, rate_b), evaluate_plan(m1, true_b, rate_b)\n", + "D_FAIL, D_PLAN = 45, 12 # 비계획 고장/계획 창정비 가동 중단 일수(가정)\n", + "down0, down1 = r0[\"fail\"] * D_FAIL + r0[\"planned\"] * D_PLAN, r1[\"fail\"] * D_FAIL + r1[\"planned\"] * D_PLAN\n", + "SCALE, INV, OPEX = 1.0, 12000.0, 1200.0 # 관리 장비 규모 배수(연), 도입 비용, 연 운영비(만원)\n", + "\n", + "\n", + "def roi(c0, c1, d0, d1, scale=SCALE, inv=INV):\n", + " save = (c0 - c1) * scale - OPEX\n", + " return save, 1 - d1 / d0, inv / max(save / 12, 1e-9)\n", + "\n", + "\n", + "sv, tr_, pb = roi(r0[\"total\"], r1[\"total\"], down0, down1)\n", + "print(f\"\\n[4] 12개월 계획 비교(보류 장비): 비용 {r0['total']:,.0f} -> {r1['total']:,.0f}만원, 비계획 고장 {r0['fail']} -> {r1['fail']}대, 계획 정비 {r0['planned']} -> {r1['planned']}대\")\n", + "print(f\" ROI 3대 지표 [교육용 가정: 관리 규모 x{SCALE:.0f}, 도입 {INV:.0f}만원, 운영 {OPEX:.0f}만원/년, 고장 중단 {D_FAIL}일·창정비 {D_PLAN}일]\")\n", + "print(f\" (1) 연간 비용 절감액 {sv:,.0f}만원 (2) 가동 중단일수 단축률 {tr_:.1%}({down0:,}일 -> {down1:,}일) (3) 투자 회수 기간 {pb:.1f}개월\")\n", + "print(\" 산식: 절감액 = (재보정 전 - 후 12개월 비용) x 규모 - 운영비, 단축률 = 1 - 후 중단일수/전 중단일수, 회수 기간 = 도입비 / (월 절감액)\")\n", + "print(\" 관리 규모 민감도:\", \"; \".join(f\"x{s} -> 절감 {roi(r0['total'], r1['total'], down0, down1, scale=s)[0]:,.0f}만원, 회수 {roi(r0['total'], r1['total'], down0, down1, scale=s)[2]:.0f}개월\" for s in (0.5, 1, 2, 3)))\n", + "half = r0[\"total\"] - (r0[\"total\"] - r1[\"total\"]) / 2\n", + "print(f\" 비관(재보정 효과 절반만 실현, x1): 절감 {roi(r0['total'], half, down0, down1, scale=1)[0]:,.0f}만원, 회수 {roi(r0['total'], half, down0, down1, scale=1)[2]:.0f}개월\")\n", + "print(\" 가정값이므로 재정·기획 부서와 실제 단가로 다시 산정해야 한다. 재보정 가중치는 군수지휘관 승인 후 운영에 반영한다.\")\n", + "\n", + "fig, ax = plt.subplots(1, 3, figsize=(15, 4.3))\n", + "ax[0].bar(range(len(ct)), ct[\"평균 편향(일)\"]); ax[0].set_xticks(range(len(ct))); ax[0].set_xticklabels([f\"C{i + 1}\" for i in range(len(ct))]); ax[0].set_title(\"AAR: mean bias by condition (days)\")\n", + "x = np.arange(4); ax[1].bar(x - .27, wt[\"도입 시 추정(W0)\"], .27, label=\"initial W0\"); ax[1].bar(x, wt[\"재보정(운영 AFT)\"], .27, label=\"recalibrated\"); ax[1].bar(x + .27, wt[\"숨은 정답(운영)\"], .27, label=\"hidden truth\")\n", + "ax[1].set_xticks(x); ax[1].set_xticklabels(ENVS); ax[1].legend(); ax[1].set_title(\"Environment weight matrix\")\n", + "ax[2].scatter(yb, p0, s=6, alpha=.5, label=\"before\"); ax[2].scatter(yb, p1, s=6, alpha=.5, label=\"after\"); ax[2].plot([0, 4000], [0, 4000], \"k--\"); ax[2].legend(); ax[2].set_title(\"Hold-out RUL: true vs predicted (days)\")\n", + "plt.tight_layout(); plt.show()\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 09 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + "[1] AAR 대상 운영 장비 700대(생존 448대, 고장 252대): 현행 엔진 MAE 180일, RMSE 241일, MAPE 21.1%, R2 0.909, 평균 편향 -55일\n", + " 조건 대수 MAE(일) 평균 편향(일)\n", + " 일반 202 196.0 -106.0\n", + " 험지 75 132.0 100.0\n", + " 염분 106 205.0 -169.0\n", + " 혹한 65 144.0 111.0\n", + " 일 가동률 4h 이상 157 127.0 -9.0\n", + "비계획 정비율 상위 25% 112 195.0 -63.0\n", + " 오차 큰 상위 40대의 원인 분류: 기타(개체 편차) 22대(55%), 염분 가혹도 과대 반영 14대(35%), 혹한 가혹도 과소 반영 2대(5%), 가동률 상승(운용 강도 변화) 1대(2%), 비계획 정비 다수 1대(2%)\n", + "\n", + "[2] 환경 가중치 매트릭스(클수록 빨리 노후)\n", + " 도입 시 추정(W0) 재보정(운영 AFT) 숨은 정답(운영) 변화율\n", + "normal 1.00 1.000 1.00 0.000\n", + "rough 1.15 1.580 1.55 0.374\n", + "salt 1.10 1.164 1.15 0.058\n", + "cold 1.05 1.283 1.30 0.222\n", + " 변화율 20% 초과 환경 ['rough', 'cold']는 정비계획관 검토·군수지휘관 승인 뒤 적용한다(자동 반영 금지).\n", + "\n", + "[3] 보류 장비 461대(재보정에 쓰지 않음) 검증\n", + " 재보정 전 재보정 후\n", + "MAE(일) 184.536 163.118\n", + "RMSE(일) 258.982 234.928\n", + "MAPE(%) 24.450 19.425\n", + "R2 0.887 0.907\n", + "평균 편향(일) -34.864 -64.634\n", + " MAE 11.6% 감소, 환경별 편향(전 -> 후): normal -98 -> -83, rough +101 -> -73, salt -135 -> -55, cold +141 -> -18\n", + "\n", + "[4] 12개월 계획 비교(보류 장비): 비용 118,444 -> 101,921만원, 비계획 고장 27 -> 18대, 계획 정비 73 -> 80대\n", + " ROI 3대 지표 [교육용 가정: 관리 규모 x1, 도입 12000만원, 운영 1200만원/년, 고장 중단 45일·창정비 12일]\n", + " (1) 연간 비용 절감액 15,323만원 (2) 가동 중단일수 단축률 15.4%(2,091일 -> 1,770일) (3) 투자 회수 기간 9.4개월\n", + " 산식: 절감액 = (재보정 전 - 후 12개월 비용) x 규모 - 운영비, 단축률 = 1 - 후 중단일수/전 중단일수, 회수 기간 = 도입비 / (월 절감액)\n", + " 관리 규모 민감도: x0.5 -> 절감 7,061만원, 회수 20개월; x1 -> 절감 15,323만원, 회수 9개월; x2 -> 절감 31,846만원, 회수 5개월; x3 -> 절감 48,368만원, 회수 3개월\n", + " 비관(재보정 효과 절반만 실현, x1): 절감 7,061만원, 회수 20개월\n", + " 가정값이므로 재정·기획 부서와 실제 단가로 다시 산정해야 한다. 재보정 가중치는 군수지휘관 승인 후 운영에 반영한다.\n", + "```\n", + "
\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 10. 장비 노후도 기반 기대수명 예측 및 계획정비 추천 시스템 통합 완성\n", + "**소요시간**: 약 3.5시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "장비 노후도 기반 기대수명 예측 및 계획정비 일정 추천 도구를 완성한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **기대수명 예측과 계획정비 추천 시스템을 통합 실행하고 운영 문서와 자동 재학습 파이프라인을 완성한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **보유 현황 갱신, 정합성 검증, 분포 변화(PSI) 점검, 챔피언/챌린저 재학습, 수명 예측, 12개월 정비 일정 추천을 한 번에 실행 수행하고 검증하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `docs/sup02_operations_guide.md 및 pipeline/sup02_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": [ + "\"\"\"장비 보유 현황 갱신, 정합성 검증, 분포 변화(PSI) 점검, 자동 재학습(챔피언/챌린저), 잔여 수명(RUL) 예측, 정비창 능력·예산·시효성 키트 제약의 12개월 계획정비 일정 추천과 승인대기·도태 후보 분리까지 한 번에 실행하는 통합 파이프라인을 완성하고 운영 문서 완비율·자체 점검·인터페이스 명세·브리핑 요약을 출력한다.\"\"\"\n", + "import importlib\n", + "import json\n", + "import sys\n", + "import numpy as np\n", + "import pandas as pd\n", + "import matplotlib.pyplot as plt\n", + "from sklearn.metrics import r2_score\n", + "\n", + "# ---------------------------------------------------------------------------\n", + "# [교육용가상자료] 가상 장비 보유 현황·고장 이력 생성기 (실제 부대·장비·수치 없음)\n", + "# 가상 부대 6개(U1~U6)의 장비가 운용 환경(normal/rough/salt/cold)에 따라 다른 속도로 노후화한다. 창정비 후 운용시간(h_since)이\n", + "# 장비마다 숨은 수명(t_fail)을 넘으면 고장(event=1) 이력, 넘지 않았으면 현재까지 생존(event=0, 관측 중도절단)이다.\n", + "# ---------------------------------------------------------------------------\n", + "ENVS = [\"normal\", \"rough\", \"salt\", \"cold\"]\n", + "ENV_W = {\"normal\": 1.00, \"rough\": 1.35, \"salt\": 1.25, \"cold\": 1.12} # 환경 가중치(숨은 정답, 가정값): 클수록 수명이 짧다\n", + "ENV_KO = {\"normal\": \"일반\", \"rough\": \"험지\", \"salt\": \"염분\", \"cold\": \"혹한\"}\n", + "UNITS = [\"U1\", \"U2\", \"U3\", \"U4\", \"U5\", \"U6\"]\n", + "UNIT_ENV = {\"U1\": [.7, .1, .1, .1], \"U2\": [.2, .6, .1, .1], \"U3\": [.2, .1, .6, .1], \"U4\": [.3, .1, .1, .5], \"U5\": [.5, .3, .1, .1], \"U6\": [.4, .1, .4, .1]}\n", + "ETYPES = [\"truck\", \"generator\", \"trailer\", \"radio\"]\n", + "CYCLE = 8000.0 # 규정 창정비 주기(운용시간 h, 가정값)\n", + "KSHAPE = 12.0 # 수명 분포 와이블 형상(마모형 고장, 클수록 수명이 모여 있다)\n", + "INSP_NOISE = 0.025 # 정비 점검 마모 비율 측정 오차(가정값)\n", + "\n", + "\n", + "def make_fleet(n=900, seed=0, env_w=None, shift=1.0):\n", + " \"\"\"장비 n대. env_w로 숨은 환경 가중치를 바꿔 운영 분포 변화를 모의할 수 있다. shift>1이면 가동률이 높아진다.\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " w = env_w or ENV_W\n", + " unit = rng.choice(UNITS, n)\n", + " env = np.array([rng.choice(ENVS, p=UNIT_ENV[u]) for u in unit])\n", + " et = rng.choice(ETYPES, n, p=[.4, .2, .2, .2])\n", + " rate = rng.lognormal(np.log(3.0 * shift), 0.25, n) # 일 평균 운용시간(h/일)\n", + " h_since = rng.uniform(300, 11500, n) # 마지막 창정비 이후 운용시간\n", + " n_ovh = rng.integers(0, 3, n)\n", + " hours = h_since + n_ovh * rng.uniform(5000, 8000, n) # 누적 운용시간\n", + " km = hours * rng.normal(17, 3, n) * np.where(env == \"rough\", 0.8, 1.0) # 누적 기동거리(km)\n", + " age_y = np.clip(hours / (rate * 365) * rng.uniform(0.9, 1.15, n), 0.5, 25)\n", + " unpl = rng.poisson(0.35 * h_since / 1000 * np.array([w[e] for e in env]) / 1.3) # 비계획 정비 건수(환경이 가혹할수록 많다)\n", + " n_maint = rng.poisson(0.6 * h_since / 1000 + 1) # 계획 정비 건수\n", + " scale = 9600 / np.array([w[e] for e in env]) * 0.985 ** unpl * np.exp(rng.normal(0, 0.04, n))\n", + " t_fail = scale * rng.weibull(KSHAPE, n) # 창정비 후 고장까지 운용시간(숨은 정답)\n", + " event = (t_fail <= h_since).astype(int)\n", + " dur = np.where(event == 1, t_fail, h_since)\n", + " df = pd.DataFrame({\"eq_id\": [f\"EQ{seed:02d}{i:04d}\" for i in range(n)], \"unit\": unit, \"env\": env, \"etype\": et, \"rate\": rate, \"hours\": hours, \"km\": km, \"age_y\": age_y, \"n_ovh\": n_ovh,\n", + " \"h_since\": h_since, \"unpl\": unpl, \"n_maint\": n_maint, \"t_fail\": t_fail, \"event\": event, \"dur\": dur})\n", + " df[\"insp\"] = np.clip(dur / t_fail + rng.normal(0, INSP_NOISE, n), 0, 1.3) # 마지막 정비 점검에서 측정한 마모 비율(소모 수명/총 수명, 오차 포함)\n", + " df[\"rul_h\"] = np.where(event == 0, t_fail - h_since, np.nan) # 생존 장비의 남은 운용시간(정답)\n", + " df[\"rul_d\"] = df[\"rul_h\"] / df[\"rate\"] # 남은 일수(정답)\n", + " return df\n", + "\n", + "\n", + "def mape(y, p):\n", + " y, p = np.asarray(y, float), np.asarray(p, float)\n", + " return float(np.mean(np.abs(y - p) / np.abs(y)) * 100)\n", + "\n", + "\n", + "def mae(y, p):\n", + " return float(np.mean(np.abs(np.asarray(y, float) - np.asarray(p, float))))\n", + "\n", + "\n", + "def rmse(y, p):\n", + " return float(np.sqrt(np.mean((np.asarray(y, float) - np.asarray(p, float)) ** 2)))\n", + "# ---------------------------------------------------------------------------\n", + "# ---------------------------------------------------------------------------\n", + "# 노후화 파생 피처와 학습 표본 만들기. 환경 가중치 W0는 도입 시점의 전문가 추정값이며(실제 가중치와 다름) M9에서 재보정한다.\n", + "# ---------------------------------------------------------------------------\n", + "W0 = {\"normal\": 1.00, \"rough\": 1.15, \"salt\": 1.10, \"cold\": 1.05}\n", + "FEATS = [\"h_since\", \"hours\", \"km\", \"age_y\", \"rate\", \"n_maint\", \"unpl\", \"unpl_per_kh\", \"km_per_h\", \"n_ovh\", \"k_rough\", \"k_salt\", \"k_cold\", \"k_truck\", \"k_gen\", \"k_trailer\",\n", + " \"aging_idx\", \"lin_rul_d\", \"hs_frac\", \"insp\", \"insp_eta_h\"]\n", + "\n", + "\n", + "def add_features(df, w=None):\n", + " d = df.copy()\n", + " w = w or W0\n", + " d[\"unpl_per_kh\"] = d[\"unpl\"] / (d[\"h_since\"] / 1000 + 0.5)\n", + " d[\"km_per_h\"] = d[\"km\"] / d[\"hours\"]\n", + " for e in (\"rough\", \"salt\", \"cold\"):\n", + " d[\"k_\" + e] = (d[\"env\"] == e).astype(int)\n", + " d[\"k_truck\"], d[\"k_gen\"], d[\"k_trailer\"] = (d[\"etype\"] == \"truck\").astype(int), (d[\"etype\"] == \"generator\").astype(int), (d[\"etype\"] == \"trailer\").astype(int)\n", + " d[\"aging_idx\"] = d[\"h_since\"] * d[\"env\"].map(w) / CYCLE # 환경 가중 노후 지수(1이면 창정비 주기에 도달)\n", + " d[\"lin_rul_d\"] = (CYCLE - d[\"h_since\"]) / d[\"rate\"] # 선형 감가상각 잔여 일수(Baseline)\n", + " d[\"hs_frac\"] = d[\"h_since\"] / CYCLE\n", + " d[\"insp_eta_h\"] = d[\"h_since\"] / np.maximum(d[\"insp\"], 0.05) # 점검 마모 비율로 환산한 총 수명 추정(h)\n", + " return d\n", + "\n", + "\n", + "def snapshots(df, k=4, seed=0):\n", + " \"\"\"고장 이력 장비의 과거 시점 표본: 고장 전 임의 시점의 점검값·운용시간과 그때의 남은 일수. (생존 장비의 미래는 알 수 없으므로 고장 이력만 쓴다)\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " f = df[df[\"event\"] == 1]\n", + " out = []\n", + " for _ in range(k):\n", + " s = f.copy()\n", + " h = s[\"t_fail\"] * rng.uniform(0.2, 0.98, len(s))\n", + " ratio = h / s[\"h_since\"]\n", + " s[\"hours\"] = s[\"hours\"] - (s[\"h_since\"] - h)\n", + " s[\"km\"] = s[\"km\"] * ratio.clip(upper=1)\n", + " s[\"unpl\"] = np.floor(s[\"unpl\"] * ratio)\n", + " s[\"n_maint\"] = np.floor(s[\"n_maint\"] * ratio)\n", + " s[\"insp\"] = np.clip(h / s[\"t_fail\"] + rng.normal(0, INSP_NOISE, len(s)), 0, 1.3)\n", + " s[\"h_since\"] = h\n", + " s[\"rul_d\"] = (s[\"t_fail\"] - h) / s[\"rate\"]\n", + " s[\"event\"] = 0\n", + " out.append(s)\n", + " return pd.concat(out, ignore_index=True)\n", + "# ---------------------------------------------------------------------------\n", + "# ---------------------------------------------------------------------------\n", + "# 생존 분석 도구(scipy만 사용): 와이블 AFT(가속 수명) 모형과 Cox 비례위험 모형\n", + "# ---------------------------------------------------------------------------\n", + "from scipy.optimize import minimize\n", + "\n", + "\n", + "def aft_x(d):\n", + " return np.c_[np.ones(len(d)), d[\"env\"].eq(\"rough\"), d[\"env\"].eq(\"salt\"), d[\"env\"].eq(\"cold\"), d[\"unpl\"] / (d[\"h_since\"] / 1000 + 0.5)].astype(float)\n", + "\n", + "\n", + "AFT_COLS = [\"절편\", \"험지\", \"염분\", \"혹한\", \"비계획 정비율\"]\n", + "\n", + "\n", + "def fit_aft(d):\n", + " \"\"\"창정비 후 운용시간 dur(고장이면 event=1, 생존이면 중도절단)에 대한 와이블 AFT 최대우도 추정. 반환: 계수(마지막은 log 형상).\"\"\"\n", + " x, t, e = aft_x(d), d[\"dur\"].values, d[\"event\"].values\n", + "\n", + " def nll(p):\n", + " lam, k = np.exp(x @ p[:-1]), np.exp(p[-1])\n", + " z = (t / lam) ** k\n", + " return -(e * (np.log(k) - np.log(lam) + (k - 1) * (np.log(t) - np.log(lam))) - z).sum()\n", + "\n", + " r = minimize(nll, np.r_[np.log(8000), np.zeros(x.shape[1] - 1), np.log(5)], method=\"BFGS\")\n", + " return r.x\n", + "\n", + "\n", + "def aft_pred(p, d, q=0.5, h=None):\n", + " \"\"\"이미 h(운용시간)까지 생존했다는 조건에서 분위수 q 생존 시점(운용시간 h 기준 총 수명)을 돌려준다. q=0.5가 조건부 중앙값.\"\"\"\n", + " lam, k = np.exp(aft_x(d) @ p[:-1]), np.exp(p[-1])\n", + " h = d[\"h_since\"].values if h is None else h\n", + " return lam * ((h / lam) ** k - np.log(q)) ** (1 / k)\n", + "\n", + "\n", + "def aft_fail_prob(p, d, h0, h1):\n", + " \"\"\"h0까지 생존한 장비가 h0~h1 사이에 고장 날 조건부 확률.\"\"\"\n", + " lam, k = np.exp(aft_x(d) @ p[:-1]), np.exp(p[-1])\n", + " s0, s1 = np.exp(-(h0 / lam) ** k), np.exp(-(h1 / lam) ** k)\n", + " return 1 - s1 / s0\n", + "\n", + "\n", + "def fit_cox(d, iters=25):\n", + " \"\"\"Cox 비례위험(Breslow 근사) 뉴턴-랩슨. 공변량: 험지·염분·혹한·비계획 정비율. 반환: 계수.\"\"\"\n", + " x = aft_x(d)[:, 1:]\n", + " o = np.argsort(-d[\"dur\"].values)\n", + " x, e = x[o], d[\"event\"].values[o]\n", + " b = np.zeros(x.shape[1])\n", + " for _ in range(iters):\n", + " r = np.exp(x @ b)\n", + " S0 = np.cumsum(r)\n", + " S1 = np.cumsum(r[:, None] * x, axis=0)\n", + " S2 = np.cumsum(r[:, None, None] * x[:, :, None] * x[:, None, :], axis=0)\n", + " m1 = S1 / S0[:, None]\n", + " g = ((x - m1) * e[:, None]).sum(axis=0)\n", + " H = -sum(e[i] * (S2[i] / S0[i] - np.outer(m1[i], m1[i])) for i in np.where(e == 1)[0])\n", + " step = np.linalg.solve(H - 1e-6 * np.eye(len(b)), g)\n", + " b = b - step\n", + " if np.abs(step).max() < 1e-7:\n", + " break\n", + " return b\n", + "\n", + "\n", + "def cindex(time, event, risk):\n", + " \"\"\"일치도 C-index: 위험 점수가 높은 쪽이 먼저 고장 났는가(관측 가능한 쌍만).\"\"\"\n", + " t, e, r = np.asarray(time), np.asarray(event), np.asarray(risk)\n", + " num = den = 0.0\n", + " for i in np.where(e == 1)[0]:\n", + " m = t > t[i]\n", + " den += m.sum()\n", + " num += (r[m] < r[i]).sum() + 0.5 * (r[m] == r[i]).sum()\n", + " return num / max(den, 1)\n", + "# ---------------------------------------------------------------------------\n", + "# ---------------------------------------------------------------------------\n", + "# 잔여 수명(RUL, 일) 예측 엔진: 와이블 AFT 조건부 중앙값 + 그래디언트 부스팅(고장 이력 시점 표본 학습)의 평균\n", + "# ---------------------------------------------------------------------------\n", + "from sklearn.ensemble import GradientBoostingRegressor\n", + "\n", + "\n", + "def fit_engine(train, seed=0):\n", + " p = fit_aft(train)\n", + " sn = add_features(snapshots(train, 5, seed))\n", + " sn[\"aft\"] = (aft_pred(p, sn) - sn[\"h_since\"]) / sn[\"rate\"]\n", + " g = GradientBoostingRegressor(n_estimators=250, max_depth=3, learning_rate=0.06, random_state=0).fit(sn[FEATS + [\"aft\"]], sn[\"rul_d\"])\n", + " return {\"p\": p, \"g\": g}\n", + "\n", + "\n", + "def predict_rul(eng, d):\n", + " \"\"\"d는 add_features를 거친 표. 반환: (앙상블 RUL, AFT RUL, GBR RUL) - 일 단위.\"\"\"\n", + " aft = ((aft_pred(eng[\"p\"], d) - d[\"h_since\"]) / d[\"rate\"]).values\n", + " x = d[FEATS].copy()\n", + " x[\"aft\"] = aft\n", + " gb = eng[\"g\"].predict(x)\n", + " return np.clip(0.5 * aft + 0.5 * gb, 1, None), aft, gb\n", + "# ---------------------------------------------------------------------------\n", + "# ---------------------------------------------------------------------------\n", + "# 계획정비(창정비) 일정 추천: 월별 정비창 능력·예산·시효성 부품 키트(유효기한) 제약 아래 기대 비용 최소화 (비용 단가는 교육용 가정값, 만원)\n", + "# ---------------------------------------------------------------------------\n", + "from scipy.optimize import milp, LinearConstraint, Bounds\n", + "from scipy.stats import norm\n", + "\n", + "C_PLAN, C_FAIL, C_WASTE_KH, C_KIT = 600.0, 2400.0, 40.0, 120.0 # 계획 창정비, 비계획 고장(긴급 정비 포함), 미사용 수명 1000h당 손실, 시효성 부품 키트\n", + "HORIZON, MDAYS = 12, 30 # 계획 12개월, 월 30일\n", + "CAP_M, BUDGET_M = 22, 15000.0 # 월별 정비창 능력(대), 월별 예산\n", + "LOTS = [(30, 70), (45, 170), (45, 390), (70, 800)] # 시효성 키트 로트(수량, 유효기한까지 남은 일수)\n", + "KIT_MARGIN = 14 # 정비일 기준 최소 유효기간 여유(일)\n", + "\n", + "\n", + "def exp_cost_matrix(pred, rate, month_days):\n", + " \"\"\"장비 i를 월 m에 정비할 때의 기대 비용. 참 RUL ~ N(pred, sigma), sigma = 35일 + 0.12 x pred (가정). 마지막 열은 계획 기간 밖(미정비).\"\"\"\n", + " sig = 35 + 0.12 * pred\n", + " C = np.zeros((len(pred), len(month_days) + 1))\n", + " for j, day in enumerate(month_days):\n", + " z = (day - pred) / sig\n", + " pf = norm.cdf(z)\n", + " unused = np.maximum(pred - day, 0) * rate * 0.6 # 정비를 앞당긴 만큼 버리는 운용시간 h(근사)\n", + " C[:, j] = C_PLAN + C_KIT + pf * (C_FAIL - C_PLAN) + C_WASTE_KH * unused / 1000 * (1 - pf)\n", + " C[:, -1] = norm.cdf((HORIZON * MDAYS - pred) / sig) * C_FAIL\n", + " return C\n", + "\n", + "\n", + "def plan_milp(pred, rate, cap=CAP_M, budget=BUDGET_M, lots=LOTS, use_kit=True):\n", + " \"\"\"정수계획으로 장비별 정비 월(0~11) 또는 미정비(-1)를 배정한다. 후보는 예측 RUL이 계획 기간 +90일 이내인 장비.\"\"\"\n", + " md = np.arange(1, HORIZON + 1) * MDAYS\n", + " cand = np.where(pred < HORIZON * MDAYS + 90)[0]\n", + " n, M = len(cand), HORIZON\n", + " C = exp_cost_matrix(pred[cand], rate[cand], md)\n", + " c = C.ravel()\n", + " A, lo, hi = [], [], []\n", + " for i in range(n): # 장비당 한 번만 배정(월 12칸 + 미정비)\n", + " row = np.zeros(n * (M + 1)); row[i * (M + 1):(i + 1) * (M + 1)] = 1; A.append(row); lo.append(1); hi.append(1)\n", + " for m in range(M): # 월별 능력·예산\n", + " row = np.zeros(n * (M + 1)); row[m::(M + 1)] = 1\n", + " A.append(row); lo.append(0); hi.append(cap)\n", + " A.append(row * (C_PLAN + C_KIT)); lo.append(0); hi.append(budget)\n", + " if use_kit: # 시효성 키트: m월 이후 정비 수요는 그 시점에 유효기간이 남은 로트 수량을 넘을 수 없다\n", + " for m in range(M):\n", + " sup = sum(q for q, e in lots if e >= md[m] + KIT_MARGIN)\n", + " row = np.zeros(n * (M + 1))\n", + " for mm in range(m, M):\n", + " row[mm::(M + 1)] = 1\n", + " A.append(row); lo.append(0); hi.append(sup)\n", + " r = milp(c, constraints=LinearConstraint(np.array(A), lo, hi), integrality=np.ones(len(c)), bounds=Bounds(0, 1), options={\"time_limit\": 20})\n", + " month = np.full(len(pred), -1)\n", + " if r.x is not None:\n", + " x = np.round(r.x).reshape(n, M + 1)\n", + " assign = np.where(x[:, :M].sum(axis=1) > 0.5, x[:, :M].argmax(axis=1), -1)\n", + " month[cand] = assign\n", + " return month\n", + "\n", + "\n", + "def plan_greedy(pred, margin=45, cap=CAP_M, budget=BUDGET_M):\n", + " \"\"\"간단한 탐욕 배정: 정비 목표일(예측 RUL - margin)이 빠른 순으로 목표일 이전 가장 늦은 빈 월, 없으면 가장 이른 빈 월에 배정.\"\"\"\n", + " cap = min(cap, int(budget // (C_PLAN + C_KIT))) # 월 예산이 허용하는 대수로 능력을 제한\n", + " month = np.full(len(pred), -1)\n", + " left = np.full(HORIZON, cap)\n", + " for i in np.argsort(pred):\n", + " if pred[i] > HORIZON * MDAYS + 60: break\n", + " tgt = int(np.clip((pred[i] - margin) // MDAYS - 1, 0, HORIZON - 1))\n", + " for m in list(range(tgt, -1, -1)) + list(range(tgt + 1, HORIZON)):\n", + " if left[m] > 0:\n", + " month[i] = m; left[m] -= 1; break\n", + " return month\n", + "\n", + "\n", + "def plan_fixed(lin_rul, cap=CAP_M, budget=BUDGET_M):\n", + " \"\"\"관행: 규정 주기 도달 순서대로(선형 잔여 일수) 월별 능력 안에서 배정.\"\"\"\n", + " return plan_greedy(np.asarray(lin_rul, float), margin=0, cap=cap, budget=budget)\n", + "\n", + "\n", + "def evaluate_plan(month, true_rul, rate, cost_kit=True):\n", + " \"\"\"참 RUL로 계획의 실제 비용을 계산한다: 정비일 이전에 고장 -> 비계획 고장 비용, 아니면 계획 비용 + 미사용 수명 손실.\"\"\"\n", + " day = np.where(month >= 0, (month + 1) * MDAYS, 10 ** 9)\n", + " fail = (true_rul < day) & (true_rul < HORIZON * MDAYS)\n", + " planned = (month >= 0) & ~fail\n", + " waste = np.where(planned, (true_rul - day) * rate * 0.6, 0) * C_WASTE_KH / 1000\n", + " cost = fail * C_FAIL + planned * (C_PLAN + (C_KIT if cost_kit else 0)) + waste\n", + " return {\"total\": float(cost.sum()), \"fail\": int(fail.sum()), \"planned\": int(planned.sum()), \"waste\": float(waste.sum()), \"unsched_fail\": int(((month < 0) & fail).sum())}\n", + "\n", + "\n", + "def kit_fefo(month, lots=LOTS):\n", + " \"\"\"유효기한이 빠른 로트부터 쓰는 FEFO로 키트를 배정하고 정비일에 유효기간이 부족한 배정 수와 만료 폐기 수량을 센다.\"\"\"\n", + " q = [[qty, e] for qty, e in sorted(lots, key=lambda t: t[1])]\n", + " short = 0\n", + " for m in sorted(month[month >= 0]):\n", + " day = (m + 1) * MDAYS\n", + " for lot in q:\n", + " if lot[1] >= day + KIT_MARGIN and lot[0] > 0:\n", + " lot[0] -= 1; break\n", + " else:\n", + " short += 1\n", + " expired = sum(l[0] for l in q if l[1] < HORIZON * MDAYS)\n", + " return {\"short\": short, \"expired_unused\": expired, \"left\": sum(l[0] for l in q)}\n", + "# ---------------------------------------------------------------------------\n", + "\n", + "libs = [\"numpy\", \"pandas\", \"scipy\", \"sklearn\", \"matplotlib\"]\n", + "env_ok = sum(importlib.util.find_spec(m) is not None for m in libs)\n", + "print(f\"[0] 환경 점검: Python {sys.version_info.major}.{sys.version_info.minor}, 필수 모듈 {env_ok}/{len(libs)}\")\n", + "\n", + "# ---------------- 1) 보유 현황 갱신·정합성 검증(미션 1~2 요약) ----------------\n", + "OPS_W = {\"normal\": 1.00, \"rough\": 1.55, \"salt\": 1.15, \"cold\": 1.30}\n", + "base = make_fleet(900, seed=1) # 기존 보유 현황\n", + "new = make_fleet(500, seed=90, env_w=OPS_W, shift=1.1) # 신규 입력분(운영 환경이 달라짐)\n", + "raw = pd.concat([base, new], ignore_index=True)\n", + "chk = {\"장비 ID 유일\": not raw[\"eq_id\"].duplicated().any(), \"결측 0\": int(raw[[\"rate\", \"hours\", \"h_since\", \"insp\", \"unpl\"]].isna().sum().sum()) == 0, \"환경 값 4종\": bool(raw[\"env\"].isin(ENVS).all()),\n", + " \"부대 코드 등록\": bool(raw[\"unit\"].isin(UNITS).all()), \"운용시간 0 이상\": bool((raw[\"hours\"] >= 0).all()), \"창정비 후 <= 누적\": bool((raw[\"h_since\"] <= raw[\"hours\"] + 1e-6).all()),\n", + " \"기동 속도 5~40km/h\": bool((raw[\"km\"] / raw[\"hours\"]).between(5, 40).all()), \"점검 마모 0~1.3\": bool(raw[\"insp\"].between(0, 1.3).all())}\n", + "print(f\"[1] 신규 {len(new)}대 포함 {len(raw)}대 정합성 검증 {sum(chk.values())}/{len(chk)} 통과\")\n", + "\n", + "# ---------------- 2) 분포 변화 점검과 자동 재학습 ----------------\n", + "def psi(a, b, bins=8):\n", + " q = np.unique(np.quantile(a, np.linspace(0, 1, bins + 1)))\n", + " q[0], q[-1] = -np.inf, np.inf\n", + " pa, pb = np.histogram(a, q)[0] / len(a) + 1e-4, np.histogram(b, q)[0] / len(b) + 1e-4\n", + " return float(np.sum((pa - pb) * np.log(pa / pb)))\n", + "\n", + "\n", + "fb, fn = add_features(base), add_features(new)\n", + "drift = {c: psi(fb[c], fn[c]) for c in [\"rate\", \"unpl_per_kh\", \"insp_eta_h\", \"aging_idx\", \"km_per_h\"]}\n", + "worst = max(drift, key=drift.get)\n", + "print(\"[2] 분포 변화 PSI(기존 vs 신규):\", \", \".join(f\"{k} {v:.2f}\" for k, v in drift.items()), f\"-> 가장 큰 변화 {worst}(0.2 이상이면 재학습 검토)\")\n", + "new_tr, new_ho = new.iloc[:300].reset_index(drop=True), new.iloc[300:].reset_index(drop=True)\n", + "champ = fit_engine(base)\n", + "chall = fit_engine(pd.concat([base.sample(300, random_state=0), new_tr], ignore_index=True), seed=1) # 최근 분포를 반영해 기존 일부 + 신규 학습분으로 재학습\n", + "ho = add_features(new_ho)\n", + "ho = ho[ho.event == 0].reset_index(drop=True)\n", + "pc, _, _ = predict_rul(champ, ho)\n", + "ph, _, _ = predict_rul(chall, ho)\n", + "y = ho[\"rul_d\"].values\n", + "big = y >= 90\n", + "ac, bc = mae(y, pc), mae(y, ph)\n", + "promote = (ac - bc) / ac >= 0.03\n", + "print(f\" 검증 {len(ho)}대(신규 분포) MAE 챔피언 {ac:.0f}일 vs 챌린저 {bc:.0f}일, MAPE {mape(y[big], pc[big]):.1f}% vs {mape(y[big], ph[big]):.1f}% -> {'챌린저 승격' if promote else '기존 모델 유지'}(MAE 3% 이상 개선일 때만 승격)\")\n", + "model = chall if promote else champ\n", + "\n", + "# ---------------- 3) RUL 예측과 12개월 정비 일정 ----------------\n", + "fl = add_features(new)\n", + "al = fl[fl.event == 0].reset_index(drop=True)\n", + "al[\"rul_pred\"], _, _ = predict_rul(model, al)\n", + "al[\"month\"] = plan_milp(al[\"rul_pred\"].values, al[\"rate\"].values)\n", + "mf = plan_fixed(al[\"lin_rul_d\"].values)\n", + "r_fix, r_new = evaluate_plan(mf, al[\"rul_d\"].values, al[\"rate\"].values), evaluate_plan(al[\"month\"].values, al[\"rul_d\"].values, al[\"rate\"].values)\n", + "kf = kit_fefo(al[\"month\"].values)\n", + "print(f\"\\n[3] 신규 보유 {len(al)}대 12개월 정비 일정 [교육용가상자료]: 정비 배정 {int((al.month >= 0).sum())}대, 월별 \" + \" \".join(f\"{m + 1}월 {c}\" for m, c in enumerate(np.bincount(al.loc[al.month >= 0, 'month'], minlength=HORIZON))))\n", + "print(f\" 규정 주기 고정 일정 대비 실제 비용 {r_fix['total']:,.0f} -> {r_new['total']:,.0f}만원({1 - r_new['total'] / r_fix['total']:.1%} 절감), 비계획 고장 {r_fix['fail']} -> {r_new['fail']}대, 키트 유효기간 부족 {kf['short']}건\")\n", + "al[\"처리\"] = np.select([al.rul_pred < 90, (al.age_y >= 14) & (al.rul_pred < 365)], [\"긴급 점검(현장 확인 후 군수지휘관 승인)\", \"도태 건의 후보(2단계 결재)\"], np.where(al.month >= 0, \"정비 일정 승인대기\", \"정기 점검 유지\"))\n", + "top = al.sort_values(\"rul_pred\").head(6)\n", + "print(pd.DataFrame({\"장비\": top.eq_id, \"부대\": top.unit, \"환경\": top.env.map(ENV_KO), \"예측 RUL(일)\": top.rul_pred, \"권장 월\": top.month.map(lambda m: f\"{m + 1}월\" if m >= 0 else \"-\"), \"처리\": top[\"처리\"]}).round(0).to_string(index=False))\n", + "vc = al[\"처리\"].value_counts()\n", + "print(\" 처리 구분:\", vc.to_dict(), \"/ 자동 확정 0건(모든 일정·도태는 군수지휘관 승인 필요)\")\n", + "\n", + "# ---------------- 4) 운영 문서 완비율·인터페이스 명세 ----------------\n", + "docs = {\"데이터 사전·스키마(M1)\": 30, \"정제 규칙 11종(M2)\": 11, \"Baseline·EDA(M3)\": 8, \"생존 분석 모델(M4)\": 12, \"RUL 엔진·정비 일정(M5)\": 4, \"대시보드·설명(M6)\": 5, \"승인 절차·권한(M7)\": 4,\n", + " \"일괄 시뮬레이션·부하(M8)\": 5, \"AAR·ROI(M9)\": 4, \"운영·재학습 절차(M10)\": 6, \"장애 대응\": 5, \"인터페이스 가이드\": 4}\n", + "done = sum(v > 0 for v in docs.values())\n", + "iface = {\"input\": {\"fleet\": [\"eq_id:str\", \"unit:str\", \"env:str\", \"rate:float\", \"hours:float\", \"h_since:float\", \"unpl:int\", \"insp:float\"], \"lots\": [\"lot_id\", \"qty\", \"expiry_d\"], \"format\": \"json-lines\"},\n", + " \"output\": {\"plan\": [\"eq_id\", \"rul_days\", \"month\", \"status\"], \"approval\": \"PENDING_ONLY\"}, \"auth\": \"role-based(가상)\", \"note\": \"상위 군수정보체계는 가상 인터페이스로만 정의\"}\n", + "print(f\"\\n[4] 운영 문서 {len(docs)}개 항목 중 작성 완료 {done}개 ({done / len(docs):.0%}) / 인터페이스 명세(JSON):\")\n", + "print(\" \", json.dumps(iface, ensure_ascii=False))\n", + "\n", + "# ---------------- 5) 자체 점검·브리핑 ----------------\n", + "pm, _, _ = predict_rul(model, ho)\n", + "fin_mae, fin_mape, base_mae = mae(y, pm), mape(y[big], pm[big]), mae(y, ho[\"lin_rul_d\"].values)\n", + "checks = [(\"환경 점검 통과\", env_ok == len(libs)), (\"데이터 정합성 전 항목 통과\", all(chk.values())), (\"재학습 파이프라인 정상 실행(챔피언/챌린저 판정 출력)\", ac > 0 and bc > 0),\n", + " (\"RUL MAPE 25% 이하(참 RUL 90일 이상)\", fin_mape <= 25), (\"선형 감가상각 대비 MAE 20% 이상 개선\", fin_mae <= 0.8 * base_mae), (\"고정 주기 대비 계획 비용 15% 이상 절감\", r_new[\"total\"] <= 0.85 * r_fix[\"total\"]),\n", + " (\"자동 확정 0건·키트 유효기간 부족 0건\", bool(al[\"처리\"].str.contains(\"승인|점검|건의\").all()) and kf[\"short\"] == 0), (\"운영 문서 100% 완비\", done == len(docs))]\n", + "print(\"\\n[5] 자체 점검\")\n", + "for n, ok in checks:\n", + " print(f\" [{'PASS' if ok else 'FAIL'}] {n}\")\n", + "print(\"\\n[6] 브리핑 요약\")\n", + "print(f\" - 신규 {len(new)}대로 분포 변화를 점검({worst} PSI {drift[worst]:.2f})하고 재학습 판단을 자동 수행했다({'챌린저 승격' if promote else '기존 모델 유지'}).\")\n", + "print(f\" - 신규 분포 RUL MAE {base_mae:.0f}일(선형 감가상각) -> {fin_mae:.0f}일, MAPE {fin_mape:.1f}%. 12개월 계획 비용 {1 - r_new['total'] / r_fix['total']:.1%} 절감, 비계획 고장 {r_fix['fail']} -> {r_new['fail']}대(가정 단가 기준).\")\n", + "print(\" - 정비 일정·긴급 점검·도태 후보는 모두 승인대기로 분리했다. 확정은 군수지휘관(도태는 재정 담당 포함) 결재에 따른다.\")\n", + "\n", + "fig, ax = plt.subplots(1, 2, figsize=(12, 4))\n", + "ax[0].bar(list(drift), list(drift.values())); ax[0].axhline(0.2, c=\"r\", ls=\"--\"); ax[0].set_title(\"Feature drift (PSI), old vs new fleet\"); ax[0].tick_params(axis=\"x\", rotation=20)\n", + "ax[1].scatter(y, pc, s=6, alpha=.5, label=\"champion\"); ax[1].scatter(y, ph, s=6, alpha=.5, label=\"challenger\"); ax[1].plot([0, 4000], [0, 4000], \"k--\"); ax[1].legend(); ax[1].set_title(\"Hold-out RUL: true vs predicted (days)\")\n", + "plt.suptitle(\"SUP02 integrated pipeline (synthetic)\"); plt.tight_layout(); plt.show()\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 10 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + "[0] 환경 점검: Python 3.11, 필수 모듈 5/5\n", + "[1] 신규 500대 포함 1400대 정합성 검증 8/8 통과\n", + "[2] 분포 변화 PSI(기존 vs 신규): rate 0.09, unpl_per_kh 0.03, insp_eta_h 0.03, aging_idx 0.02, km_per_h 0.03 -> 가장 큰 변화 rate(0.2 이상이면 재학습 검토)\n", + " 검증 132대(신규 분포) MAE 챔피언 216일 vs 챌린저 209일, MAPE 24.9% vs 22.8% -> 챌린저 승격(MAE 3% 이상 개선일 때만 승격)\n", + "\n", + "[3] 신규 보유 322대 12개월 정비 일정 [교육용가상자료]: 정비 배정 56대, 월별 1월 20 2월 20 3월 8 4월 7 5월 1 6월 0 7월 0 8월 0 9월 0 10월 0 11월 0 12월 0\n", + " 규정 주기 고정 일정 대비 실제 비용 117,515 -> 76,804만원(34.6% 절감), 비계획 고장 36 -> 17대, 키트 유효기간 부족 0건\n", + " 장비 부대 환경 예측 RUL(일) 권장 월 처리\n", + "EQ900423 U6 일반 74.0 1월 긴급 점검(현장 확인 후 군수지휘관 승인)\n", + "EQ900136 U5 험지 75.0 1월 긴급 점검(현장 확인 후 군수지휘관 승인)\n", + "EQ900455 U6 염분 90.0 1월 정비 일정 승인대기\n", + "EQ900280 U3 염분 111.0 1월 정비 일정 승인대기\n", + "EQ900040 U1 염분 124.0 1월 정비 일정 승인대기\n", + "EQ900035 U1 일반 134.0 1월 정비 일정 승인대기\n", + " 처리 구분: {'정기 점검 유지': 266, '정비 일정 승인대기': 37, '도태 건의 후보(2단계 결재)': 17, '긴급 점검(현장 확인 후 군수지휘관 승인)': 2} / 자동 확정 0건(모든 일정·도태는 군수지휘관 승인 필요)\n", + "\n", + "[4] 운영 문서 12개 항목 중 작성 완료 12개 (100%) / 인터페이스 명세(JSON):\n", + " {\"input\": {\"fleet\": [\"eq_id:str\", \"unit:str\", \"env:str\", \"rate:float\", \"hours:float\", \"h_since:float\", \"unpl:int\", \"insp:float\"], \"lots\": [\"lot_id\", \"qty\", \"expiry_d\"], \"format\": \"json-lines\"}, \"output\": {\"plan\": [\"eq_id\", \"rul_days\", \"month\", \"status\"], \"approval\": \"PENDING_ONLY\"}, \"auth\": \"role-based(가상)\", \"note\": \"상위 군수정보체계는 가상 인터페이스로만 정의\"}\n", + "\n", + "[5] 자체 점검\n", + " [PASS] 환경 점검 통과\n", + " [PASS] 데이터 정합성 전 항목 통과\n", + " [PASS] 재학습 파이프라인 정상 실행(챔피언/챌린저 판정 출력)\n", + " [PASS] RUL MAPE 25% 이하(참 RUL 90일 이상)\n", + " [PASS] 선형 감가상각 대비 MAE 20% 이상 개선\n", + " [PASS] 고정 주기 대비 계획 비용 15% 이상 절감\n", + " [PASS] 자동 확정 0건·키트 유효기간 부족 0건\n", + " [PASS] 운영 문서 100% 완비\n", + "\n", + "[6] 브리핑 요약\n", + " - 신규 500대로 분포 변화를 점검(rate PSI 0.09)하고 재학습 판단을 자동 수행했다(챌린저 승격).\n", + " - 신규 분포 RUL MAE 377일(선형 감가상각) -> 209일, MAPE 22.8%. 12개월 계획 비용 34.6% 절감, 비계획 고장 36 -> 17대(가정 단가 기준).\n", + " - 정비 일정·긴급 점검·도태 후보는 모두 승인대기로 분리했다. 확정은 군수지휘관(도태는 재정 담당 포함) 결재에 따른다.\n", + "```\n", + "
\n" + ] + } + ], + "metadata": { + "colab": { + "provenance": [] + }, + "kernelspec": { + "display_name": "Python 3", + "name": "python3" + }, + "language_info": { + "name": "python" + } + }, + "nbformat": 4, + "nbformat_minor": 0 +} \ No newline at end of file diff --git a/uploads/SUP02/SUP02_student.ipynb b/uploads/SUP02/SUP02_student.ipynb new file mode 100644 index 0000000..23eba5e --- /dev/null +++ b/uploads/SUP02/SUP02_student.ipynb @@ -0,0 +1,4167 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# 🎯 [SUP02] 군수품 시효성 품목 재고통제·잔여일 관리\n", + "### Google Colab 실습 환경 (보급·재산·시효관리 / Level 2)\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/SUP02/SUP02_solution.ipynb)\n", + "\n", + "---\n", + "\n", + "## 📌 1. 프로젝트 개요 및 배경\n", + "\n", + "| 항목 | 내용 |\n", + "| :--- | :--- |\n", + "| **과정 분류** | 보급·재산·시효관리 (Level 2) |\n", + "| **학습 소요 시간** | 10개 미션 (5일 / 총 28.5시간) |\n", + "| **환경 사양** | Python 3.10+, numpy, pandas, matplotlib, scipy, scikit-learn |\n", + "\n", + "### 📖 프로젝트 목표\n", + "군수품 중에는 정비 주기와 사용 가능 기한이 정해져 있어 시효를 넘기거나 정비 시점을 놓치면 손실이 생기는 품목이 많다. 이 과제는 시효성 품목의 잔여일 관리 문제를 시간 한도가 있는 장비·부품의 잔여 수명(RUL) 관리와 시효성 부품 키트의 선입선출(FEFO) 재고 통제로 연결해 다룬다. 가상 장비 보유 현황의 운용 시간, 환경, 정비 이력으로 생존 분석 기반 수명 모델을 만들고, 정비창 능력·예산·키트 유효기간 제약 아래 12개월 계획정비 일정을 추천한다. 일정 확정과 도태 건의는 군수지휘관 승인 절차로 두고, 대규모 부하 시험, 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. 장비 운용 시간, 기동 거리, 정비 이력 및 창정비 주기 데이터셋 스키마 설계\n", + "**소요시간**: 약 1.5시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "장비 운용 시간, 기동 거리, 정비 이력 및 창정비 주기 데이터셋을 설계한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **가상 장비 보유 현황의 운용 시간, 기동 거리, 정비 이력, 창정비 주기 데이터셋 스키마를 설계하고, 시효성 부품 키트 로트 스키마를 함께 정의한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **장비 식별, 부대, 운용 환경, 누적 운용시간, 창정비 후 운용시간, 기동거리, 일 가동률, 계획·비계획 정비 횟수, 점검 마모 비율과 고장 라벨의 필드·허용값을 정 수행하고 검증하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `docs/sup02_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": [ + "\"\"\"장비 운용 시간·기동 거리·정비 이력·창정비 주기와 시효성 부품 키트 로트의 데이터셋 스키마를 설계하고 데이터 사전(피처 20개 이상)을 실제 생성 데이터와 대조하며, 허용값 검증기에 오류 6종을 주입해 검출하고 장비 수명관리 업무 프로세스 맵을 만든다.\"\"\"\n", + "import copy\n", + "import numpy as np\n", + "import pandas as pd\n", + "\n", + "# ---------------------------------------------------------------------------\n", + "# [교육용가상자료] 가상 장비 보유 현황·고장 이력 생성기 (실제 부대·장비·수치 없음)\n", + "# 가상 부대 6개(U1~U6)의 장비가 운용 환경(normal/rough/salt/cold)에 따라 다른 속도로 노후화한다. 창정비 후 운용시간(h_since)이\n", + "# 장비마다 숨은 수명(t_fail)을 넘으면 고장(event=1) 이력, 넘지 않았으면 현재까지 생존(event=0, 관측 중도절단)이다.\n", + "# ---------------------------------------------------------------------------\n", + "ENVS = [\"normal\", \"rough\", \"salt\", \"cold\"]\n", + "ENV_W = {\"normal\": 1.00, \"rough\": 1.35, \"salt\": 1.25, \"cold\": 1.12} # 환경 가중치(숨은 정답, 가정값): 클수록 수명이 짧다\n", + "ENV_KO = {\"normal\": \"일반\", \"rough\": \"험지\", \"salt\": \"염분\", \"cold\": \"혹한\"}\n", + "UNITS = [\"U1\", \"U2\", \"U3\", \"U4\", \"U5\", \"U6\"]\n", + "UNIT_ENV = {\"U1\": [.7, .1, .1, .1], \"U2\": [.2, .6, .1, .1], \"U3\": [.2, .1, .6, .1], \"U4\": [.3, .1, .1, .5], \"U5\": [.5, .3, .1, .1], \"U6\": [.4, .1, .4, .1]}\n", + "ETYPES = [\"truck\", \"generator\", \"trailer\", \"radio\"]\n", + "CYCLE = 8000.0 # 규정 창정비 주기(운용시간 h, 가정값)\n", + "KSHAPE = 12.0 # 수명 분포 와이블 형상(마모형 고장, 클수록 수명이 모여 있다)\n", + "INSP_NOISE = 0.025 # 정비 점검 마모 비율 측정 오차(가정값)\n", + "\n", + "\n", + "def make_fleet(n=900, seed=0, env_w=None, shift=1.0):\n", + " \"\"\"장비 n대. env_w로 숨은 환경 가중치를 바꿔 운영 분포 변화를 모의할 수 있다. shift>1이면 가동률이 높아진다.\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " w = env_w or ENV_W\n", + " unit = rng.choice(UNITS, n)\n", + " env = np.array([rng.choice(ENVS, p=UNIT_ENV[u]) for u in unit])\n", + " et = rng.choice(ETYPES, n, p=[.4, .2, .2, .2])\n", + " rate = rng.lognormal(np.log(3.0 * shift), 0.25, n) # 일 평균 운용시간(h/일)\n", + " h_since = rng.uniform(300, 11500, n) # 마지막 창정비 이후 운용시간\n", + " n_ovh = rng.integers(0, 3, n)\n", + " hours = h_since + n_ovh * rng.uniform(5000, 8000, n) # 누적 운용시간\n", + " km = hours * rng.normal(17, 3, n) * np.where(env == \"rough\", 0.8, 1.0) # 누적 기동거리(km)\n", + " age_y = np.clip(hours / (rate * 365) * rng.uniform(0.9, 1.15, n), 0.5, 25)\n", + " unpl = rng.poisson(0.35 * h_since / 1000 * np.array([w[e] for e in env]) / 1.3) # 비계획 정비 건수(환경이 가혹할수록 많다)\n", + " n_maint = rng.poisson(0.6 * h_since / 1000 + 1) # 계획 정비 건수\n", + " scale = 9600 / np.array([w[e] for e in env]) * 0.985 ** unpl * np.exp(rng.normal(0, 0.04, n))\n", + " t_fail = scale * rng.weibull(KSHAPE, n) # 창정비 후 고장까지 운용시간(숨은 정답)\n", + " event = (t_fail <= h_since).astype(int)\n", + " dur = np.where(event == 1, t_fail, h_since)\n", + " df = pd.DataFrame({\"eq_id\": [f\"EQ{seed:02d}{i:04d}\" for i in range(n)], \"unit\": unit, \"env\": env, \"etype\": et, \"rate\": rate, \"hours\": hours, \"km\": km, \"age_y\": age_y, \"n_ovh\": n_ovh,\n", + " \"h_since\": h_since, \"unpl\": unpl, \"n_maint\": n_maint, \"t_fail\": t_fail, \"event\": event, \"dur\": dur})\n", + " df[\"insp\"] = np.clip(dur / t_fail + rng.normal(0, INSP_NOISE, n), 0, 1.3) # 마지막 정비 점검에서 측정한 마모 비율(소모 수명/총 수명, 오차 포함)\n", + " df[\"rul_h\"] = np.where(event == 0, t_fail - h_since, np.nan) # 생존 장비의 남은 운용시간(정답)\n", + " df[\"rul_d\"] = df[\"rul_h\"] / df[\"rate\"] # 남은 일수(정답)\n", + " return df\n", + "\n", + "\n", + "def mape(y, p):\n", + " # [TODO : 학생 실습 1] 장비 운용 시간, 기동 거리, 정비 이력 및 창정비 주기 데이터셋 스키마 설계 관련 핵심 로직을 구현하세요.\n", + " # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n", + " # <<< 여기에 코드를 작성하세요 >>>\n", + " pass # 작성 후 pass를 제거하세요.\n", + "def mae(y, p):\n", + " # [TODO : 학생 실습 1] 장비 운용 시간, 기동 거리, 정비 이력 및 창정비 주기 데이터셋 스키마 설계 관련 핵심 로직을 구현하세요.\n", + " # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n", + " # <<< 여기에 코드를 작성하세요 >>>\n", + " pass # 작성 후 pass를 제거하세요.\n", + "def rmse(y, p):\n", + " return float(np.sqrt(np.mean((np.asarray(y, float) - np.asarray(p, float)) ** 2)))\n", + "# ---------------------------------------------------------------------------\n", + "# ---------------------------------------------------------------------------\n", + "# 노후화 파생 피처와 학습 표본 만들기. 환경 가중치 W0는 도입 시점의 전문가 추정값이며(실제 가중치와 다름) M9에서 재보정한다.\n", + "# ---------------------------------------------------------------------------\n", + "W0 = {\"normal\": 1.00, \"rough\": 1.15, \"salt\": 1.10, \"cold\": 1.05}\n", + "FEATS = [\"h_since\", \"hours\", \"km\", \"age_y\", \"rate\", \"n_maint\", \"unpl\", \"unpl_per_kh\", \"km_per_h\", \"n_ovh\", \"k_rough\", \"k_salt\", \"k_cold\", \"k_truck\", \"k_gen\", \"k_trailer\",\n", + " \"aging_idx\", \"lin_rul_d\", \"hs_frac\", \"insp\", \"insp_eta_h\"]\n", + "\n", + "\n", + "def add_features(df, w=None):\n", + " d = df.copy()\n", + " w = w or W0\n", + " d[\"unpl_per_kh\"] = d[\"unpl\"] / (d[\"h_since\"] / 1000 + 0.5)\n", + " d[\"km_per_h\"] = d[\"km\"] / d[\"hours\"]\n", + " for e in (\"rough\", \"salt\", \"cold\"):\n", + " d[\"k_\" + e] = (d[\"env\"] == e).astype(int)\n", + " d[\"k_truck\"], d[\"k_gen\"], d[\"k_trailer\"] = (d[\"etype\"] == \"truck\").astype(int), (d[\"etype\"] == \"generator\").astype(int), (d[\"etype\"] == \"trailer\").astype(int)\n", + " d[\"aging_idx\"] = d[\"h_since\"] * d[\"env\"].map(w) / CYCLE # 환경 가중 노후 지수(1이면 창정비 주기에 도달)\n", + " d[\"lin_rul_d\"] = (CYCLE - d[\"h_since\"]) / d[\"rate\"] # 선형 감가상각 잔여 일수(Baseline)\n", + " d[\"hs_frac\"] = d[\"h_since\"] / CYCLE\n", + " d[\"insp_eta_h\"] = d[\"h_since\"] / np.maximum(d[\"insp\"], 0.05) # 점검 마모 비율로 환산한 총 수명 추정(h)\n", + " return d\n", + "\n", + "\n", + "def snapshots(df, k=4, seed=0):\n", + " \"\"\"고장 이력 장비의 과거 시점 표본: 고장 전 임의 시점의 점검값·운용시간과 그때의 남은 일수. (생존 장비의 미래는 알 수 없으므로 고장 이력만 쓴다)\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " f = df[df[\"event\"] == 1]\n", + " out = []\n", + " for _ in range(k):\n", + " s = f.copy()\n", + " h = s[\"t_fail\"] * rng.uniform(0.2, 0.98, len(s))\n", + " ratio = h / s[\"h_since\"]\n", + " s[\"hours\"] = s[\"hours\"] - (s[\"h_since\"] - h)\n", + " s[\"km\"] = s[\"km\"] * ratio.clip(upper=1)\n", + " s[\"unpl\"] = np.floor(s[\"unpl\"] * ratio)\n", + " s[\"n_maint\"] = np.floor(s[\"n_maint\"] * ratio)\n", + " s[\"insp\"] = np.clip(h / s[\"t_fail\"] + rng.normal(0, INSP_NOISE, len(s)), 0, 1.3)\n", + " s[\"h_since\"] = h\n", + " s[\"rul_d\"] = (s[\"t_fail\"] - h) / s[\"rate\"]\n", + " s[\"event\"] = 0\n", + " out.append(s)\n", + " return pd.concat(out, ignore_index=True)\n", + "# ---------------------------------------------------------------------------\n", + "\n", + "# 1) 데이터 사전 (필드, 구분, 자료형, 허용 범위/값, 설명)\n", + "DICT = [\n", + " (\"eq_id\", \"식별\", \"str\", r\"^EQ\\d{6}$\", \"장비 식별코드(가상)\"), (\"unit\", \"식별\", \"str\", \"U1~U6\", \"가상 부대 코드\"), (\"etype\", \"식별\", \"str\", \"truck|generator|trailer|radio\", \"장비 구분(가상)\"),\n", + " (\"env\", \"환경\", \"str\", \"normal|rough|salt|cold\", \"주 운용 환경(일반/험지/염분/혹한)\"), (\"rate\", \"운용\", \"float\", \"0.5~20\", \"일 평균 운용시간(h/일)\"),\n", + " (\"hours\", \"운용\", \"float\", \"0~60000\", \"누적 운용시간(h)\"), (\"km\", \"운용\", \"float\", \"0~\", \"누적 기동거리(km)\"), (\"age_y\", \"운용\", \"float\", \"0.5~25\", \"사용 연수(년)\"),\n", + " (\"n_ovh\", \"정비\", \"int\", \"0~5\", \"창정비 이력 횟수\"), (\"h_since\", \"정비\", \"float\", \"0~hours\", \"마지막 창정비 이후 운용시간(h)\"), (\"unpl\", \"정비\", \"int\", \"0~\", \"비계획 정비 건수(긴급 포함)\"),\n", + " (\"n_maint\", \"정비\", \"int\", \"0~\", \"계획 정비 건수\"), (\"insp\", \"정비\", \"float\", \"0~1.3\", \"정비 점검 마모 비율(소모 수명/총 수명)\"),\n", + " (\"t_fail\", \"라벨\", \"float\", \"0~\", \"고장까지 운용시간 - 모의 정답(실제 운용에는 없음)\"), (\"event\", \"라벨\", \"int\", \"0|1\", \"고장 여부(1=고장 이력, 0=생존)\"),\n", + " (\"dur\", \"라벨\", \"float\", \"0~\", \"관측 운용시간(고장 시점 또는 중도절단)\"), (\"rul_h\", \"라벨\", \"float\", \"0~\", \"남은 운용시간 - 모의 정답\"), (\"rul_d\", \"라벨\", \"float\", \"0~\", \"남은 일수 - 모의 정답\"),\n", + " (\"unpl_per_kh\", \"파생\", \"float\", \"0~\", \"1000h당 비계획 정비율\"), (\"km_per_h\", \"파생\", \"float\", \"5~40\", \"운용시간당 기동거리\"), (\"k_rough\", \"파생\", \"int\", \"0|1\", \"험지 더미\"),\n", + " (\"k_salt\", \"파생\", \"int\", \"0|1\", \"염분 더미\"), (\"k_cold\", \"파생\", \"int\", \"0|1\", \"혹한 더미\"), (\"k_truck\", \"파생\", \"int\", \"0|1\", \"차량 더미\"), (\"k_gen\", \"파생\", \"int\", \"0|1\", \"발전기 더미\"),\n", + " (\"k_trailer\", \"파생\", \"int\", \"0|1\", \"트레일러 더미\"), (\"aging_idx\", \"파생\", \"float\", \"0~2\", \"환경 가중 노후 지수(1이면 주기 도달)\"), (\"lin_rul_d\", \"파생\", \"float\", \"-\", \"선형 감가상각 잔여 일수\"),\n", + " (\"hs_frac\", \"파생\", \"float\", \"0~2\", \"창정비 주기 대비 경과 비율\"), (\"insp_eta_h\", \"파생\", \"float\", \"0~\", \"점검 마모로 환산한 총 수명 추정(h)\")]\n", + "dd = pd.DataFrame(DICT, columns=[\"필드\", \"구분\", \"형\", \"허용값\", \"설명\"])\n", + "print(f\"[1] 데이터 사전 {len(dd)}개 필드 \" + str(dd[\"구분\"].value_counts().to_dict()))\n", + "LOT_SCHEMA = [(\"lot_id\", \"str\", \"시효성 부품 키트 로트 번호\"), (\"qty\", \"int\", \"재고 수량(개)\"), (\"expiry_d\", \"int\", \"유효기한까지 남은 일수\"), (\"margin\", \"int\", \"정비일 기준 최소 유효기간 여유 14일(규칙)\")]\n", + "print(\" 시효성 부품 키트 로트 스키마:\", \", \".join(f\"{a}({b})\" for a, b, _ in LOT_SCHEMA), \"- 정비 예정일에 유효기간이 부족한 키트는 쓸 수 없다\")\n", + "\n", + "# 2) 실제 생성 데이터와 대조\n", + "df = make_fleet(300, seed=1)\n", + "feat = add_features(df)\n", + "miss = sorted(set(feat.columns) - set(dd[\"필드\"]))\n", + "extra = sorted(set(dd[\"필드\"]) - set(feat.columns))\n", + "fe = dd[~dd[\"구분\"].isin([\"라벨\", \"식별\"])]\n", + "print(f\"[2] 데이터 사전 대조({len(feat)}행): 사전 누락 컬럼 {miss or '없음'}, 데이터에 없는 필드 {extra or '없음'}\")\n", + "print(f\" 예측 피처 후보(식별·라벨 제외) {len(fe)}개 -> 목표(20개 이상) {'달성' if len(fe) >= 20 else '미달'} / 창정비 규정 주기 {CYCLE:.0f}h(가정)\")\n", + "\n", + "# 3) 허용값 검증기와 오류 주입\n", + "RULES = {\"eq_id\": lambda s: s.astype(str).str.match(r\"^EQ\\d{6}$\"), \"unit\": lambda s: s.isin(UNITS), \"env\": lambda s: s.isin(ENVS), \"rate\": lambda s: s.between(0.5, 20),\n", + " \"hours\": lambda s: s.between(0, 60000), \"h_since\": lambda s: s >= 0, \"insp\": lambda s: s.between(0, 1.3), \"age_y\": lambda s: s.between(0.5, 25)}\n", + "\n", + "\n", + "def validate(d):\n", + " bad = {}\n", + " for c, f in RULES.items():\n", + " n = int((~f(d[c])).sum() + d[c].isna().sum())\n", + " if n: bad[c] = n\n", + " n = int((d[\"h_since\"] > d[\"hours\"] + 1e-6).sum())\n", + " if n: bad[\"h_since>hours(모순)\"] = n\n", + " n = int(d[\"eq_id\"].duplicated().sum())\n", + " if n: bad[\"eq_id 중복\"] = n\n", + " n = int((d[\"km\"] / d[\"hours\"].clip(lower=1) > 60).sum())\n", + " if n: bad[\"km 단위 의심\"] = n\n", + " return bad\n", + "\n", + "\n", + "print(f\"\\n[3] 정상 샘플 {len(df)}건 검증 오류 {sum(validate(df).values())}건\")\n", + "inj = {\"환경 표기 오류\": (\"env\", 0, \"험지\"), \"운용시간 음수\": (\"hours\", 1, -120.0), \"미등록 부대\": (\"unit\", 2, \"U9\"), \"점검 마모 비율 범위 초과\": (\"insp\", 3, 2.5),\n", + " \"창정비 후 시간이 누적보다 큼\": (\"h_since\", 4, 99999.0), \"km 단위 오류(m)\": (\"km\", 5, 1.2e7)}\n", + "det = 0\n", + "for name, (col, i, val) in inj.items():\n", + " d2 = copy.deepcopy(df)\n", + " d2.loc[i, col] = val\n", + " bad = validate(d2)\n", + " ok = bool(bad)\n", + " det += ok\n", + " print(f\" - {name}: {'검출' if ok else '미검출'} {bad}\")\n", + "print(f\" 주입 오류 {len(inj)}종 중 {det}종 검출\")\n", + "\n", + "# 4) 장비 수명관리 업무 프로세스 맵\n", + "proc = [(\"정비 소요·고장 이력 접수\", \"정비계획관\", \"정비 요청, 고장 보고\", \"고장 이력 표\", 30), (\"운용시간·기동거리 집계\", \"운용 담당\", \"일일 운용 보고\", \"hours, km, rate\", 25),\n", + " (\"정비 이력·점검 입력\", \"정비 담당\", \"정비·점검 기록\", \"n_maint, unpl, insp\", 25), (\"환경 가중치 적용\", \"분석 담당\", \"운용 환경 구분\", \"aging_idx\", 15), (\"잔여 수명 예측\", \"분석 담당\", \"피처 테이블\", \"RUL(일), 고장 확률\", 40),\n", + " (\"정비창 능력·예산 대조\", \"정비계획관\", \"월별 슬롯, 예산, 키트 재고\", \"정비 일정 후보\", 35), (\"정비 일정 확정·도태 건의\", \"군수지휘관\", \"추천안, 사유\", \"승인 일정\", 30), (\"정비 실행·결과 입력\", \"정비창\", \"승인 일정\", \"정비 완료 기록\", 20),\n", + " (\"예측 오차 환류(AAR)\", \"분석 담당\", \"예측 대 실적\", \"재보정 보고\", 30)]\n", + "pm = pd.DataFrame(proc, columns=[\"단계\", \"담당\", \"입력\", \"산출\", \"현행(분)\"])\n", + "pm.index = range(1, len(pm) + 1)\n", + "print(f\"\\n[4] 업무 프로세스 맵 {len(pm)}단계, 현행 합계 {pm['현행(분)'].sum()}분 (가장 긴 단계: {pm.loc[pm['현행(분)'].idxmax(), '단계']})\")\n", + "print(pm.to_string())\n", + "print(\"\\n프로세스 맵 작성 완료(9단계), 피처 목록 20개 이상 -> 목표\", \"달성\" if len(pm) >= 5 and len(fe) >= 20 else \"미달\")\n", + "print(\"모든 부대·장비·수치는 교육용 가상 자료이며 실제 부대·장비와 무관하다.\")\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 01 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + "[1] 데이터 사전 30개 필드 {'파생': 12, '정비': 5, '라벨': 5, '운용': 4, '식별': 3, '환경': 1}\n", + " 시효성 부품 키트 로트 스키마: lot_id(str), qty(int), expiry_d(int), margin(int) - 정비 예정일에 유효기간이 부족한 키트는 쓸 수 없다\n", + "[2] 데이터 사전 대조(300행): 사전 누락 컬럼 없음, 데이터에 없는 필드 없음\n", + " 예측 피처 후보(식별·라벨 제외) 22개 -> 목표(20개 이상) 달성 / 창정비 규정 주기 8000h(가정)\n", + "\n", + "[3] 정상 샘플 300건 검증 오류 0건\n", + " - 환경 표기 오류: 검출 {'env': 1}\n", + " - 운용시간 음수: 검출 {'hours': 1, 'h_since>hours(모순)': 1, 'km 단위 의심': 1}\n", + " - 미등록 부대: 검출 {'unit': 1}\n", + " - 점검 마모 비율 범위 초과: 검출 {'insp': 1}\n", + " - 창정비 후 시간이 누적보다 큼: 검출 {'h_since>hours(모순)': 1}\n", + " - km 단위 오류(m): 검출 {'km 단위 의심': 1}\n", + " 주입 오류 6종 중 6종 검출\n", + "\n", + "[4] 업무 프로세스 맵 9단계, 현행 합계 250분 (가장 긴 단계: 잔여 수명 예측)\n", + " 단계 담당 입력 산출 현행(분)\n", + "1 정비 소요·고장 이력 접수 정비계획관 정비 요청, 고장 보고 고장 이력 표 30\n", + "2 운용시간·기동거리 집계 운용 담당 일일 운용 보고 hours, km, rate 25\n", + "3 정비 이력·점검 입력 정비 담당 정비·점검 기록 n_maint, unpl, insp 25\n", + "4 환경 가중치 적용 분석 담당 운용 환경 구분 aging_idx 15\n", + "5 잔여 수명 예측 분석 담당 피처 테이블 RUL(일), 고장 확률 40\n", + "6 정비창 능력·예산 대조 정비계획관 월별 슬롯, 예산, 키트 재고 정비 일정 후보 35\n", + "7 정비 일정 확정·도태 건의 군수지휘관 추천안, 사유 승인 일정 30\n", + "8 정비 실행·결과 입력 정비창 승인 일정 정비 완료 기록 20\n", + "9 예측 오차 환류(AAR) 분석 담당 예측 대 실적 재보정 보고 30\n", + "\n", + "프로세스 맵 작성 완료(9단계), 피처 목록 20개 이상 -> 목표 달성\n", + "모든 부대·장비·수치는 교육용 가상 자료이며 실제 부대·장비와 무관하다.\n", + "```\n", + "
\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 02. 운용 환경(험지/염분/혹한) 가중치 반영 및 장비 노후화 지표 전처리 모듈 구현\n", + "**소요시간**: 약 2.0시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "운용 환경(험지/염분) 가중치를 반영한 노후화 지표 전처리 모듈을 구현한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **운용 환경(험지·염분·혹한) 가중치를 반영한 노후화 지표 전처리 모듈을 구현한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **오류를 주입한 오염 데이터에 환경 표기 통일, 중복 제거, 부호·단위 오류 보정, 결측 보정 등 정제 규칙을 순서대로 적용하고 처리 건수를 로그로 남긴다 과정을 진행하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `src/etl/sup02_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": [ + "\"\"\"표기·단위·모순·결측·중복 오류를 주입한 가상 장비 운용 기록에 운용 환경 가중치를 반영한 노후화 지표 산출을 포함한 정제 규칙 11종을 적용하고, 긴급 운용·다수 비계획 정비 같은 군수 특수 예외는 삭제하지 않고 보존하며 정합성 검증 통과율과 정제 전후 통계를 확인한다.\"\"\"\n", + "import numpy as np\n", + "import pandas as pd\n", + "\n", + "# ---------------------------------------------------------------------------\n", + "# [교육용가상자료] 가상 장비 보유 현황·고장 이력 생성기 (실제 부대·장비·수치 없음)\n", + "# 가상 부대 6개(U1~U6)의 장비가 운용 환경(normal/rough/salt/cold)에 따라 다른 속도로 노후화한다. 창정비 후 운용시간(h_since)이\n", + "# 장비마다 숨은 수명(t_fail)을 넘으면 고장(event=1) 이력, 넘지 않았으면 현재까지 생존(event=0, 관측 중도절단)이다.\n", + "# ---------------------------------------------------------------------------\n", + "ENVS = [\"normal\", \"rough\", \"salt\", \"cold\"]\n", + "ENV_W = {\"normal\": 1.00, \"rough\": 1.35, \"salt\": 1.25, \"cold\": 1.12} # 환경 가중치(숨은 정답, 가정값): 클수록 수명이 짧다\n", + "ENV_KO = {\"normal\": \"일반\", \"rough\": \"험지\", \"salt\": \"염분\", \"cold\": \"혹한\"}\n", + "UNITS = [\"U1\", \"U2\", \"U3\", \"U4\", \"U5\", \"U6\"]\n", + "UNIT_ENV = {\"U1\": [.7, .1, .1, .1], \"U2\": [.2, .6, .1, .1], \"U3\": [.2, .1, .6, .1], \"U4\": [.3, .1, .1, .5], \"U5\": [.5, .3, .1, .1], \"U6\": [.4, .1, .4, .1]}\n", + "ETYPES = [\"truck\", \"generator\", \"trailer\", \"radio\"]\n", + "CYCLE = 8000.0 # 규정 창정비 주기(운용시간 h, 가정값)\n", + "KSHAPE = 12.0 # 수명 분포 와이블 형상(마모형 고장, 클수록 수명이 모여 있다)\n", + "INSP_NOISE = 0.025 # 정비 점검 마모 비율 측정 오차(가정값)\n", + "\n", + "\n", + "def make_fleet(n=900, seed=0, env_w=None, shift=1.0):\n", + " \"\"\"장비 n대. env_w로 숨은 환경 가중치를 바꿔 운영 분포 변화를 모의할 수 있다. shift>1이면 가동률이 높아진다.\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " w = env_w or ENV_W\n", + " unit = rng.choice(UNITS, n)\n", + " env = np.array([rng.choice(ENVS, p=UNIT_ENV[u]) for u in unit])\n", + " et = rng.choice(ETYPES, n, p=[.4, .2, .2, .2])\n", + " rate = rng.lognormal(np.log(3.0 * shift), 0.25, n) # 일 평균 운용시간(h/일)\n", + " h_since = rng.uniform(300, 11500, n) # 마지막 창정비 이후 운용시간\n", + " n_ovh = rng.integers(0, 3, n)\n", + " hours = h_since + n_ovh * rng.uniform(5000, 8000, n) # 누적 운용시간\n", + " km = hours * rng.normal(17, 3, n) * np.where(env == \"rough\", 0.8, 1.0) # 누적 기동거리(km)\n", + " age_y = np.clip(hours / (rate * 365) * rng.uniform(0.9, 1.15, n), 0.5, 25)\n", + " unpl = rng.poisson(0.35 * h_since / 1000 * np.array([w[e] for e in env]) / 1.3) # 비계획 정비 건수(환경이 가혹할수록 많다)\n", + " n_maint = rng.poisson(0.6 * h_since / 1000 + 1) # 계획 정비 건수\n", + " scale = 9600 / np.array([w[e] for e in env]) * 0.985 ** unpl * np.exp(rng.normal(0, 0.04, n))\n", + " t_fail = scale * rng.weibull(KSHAPE, n) # 창정비 후 고장까지 운용시간(숨은 정답)\n", + " event = (t_fail <= h_since).astype(int)\n", + " dur = np.where(event == 1, t_fail, h_since)\n", + " df = pd.DataFrame({\"eq_id\": [f\"EQ{seed:02d}{i:04d}\" for i in range(n)], \"unit\": unit, \"env\": env, \"etype\": et, \"rate\": rate, \"hours\": hours, \"km\": km, \"age_y\": age_y, \"n_ovh\": n_ovh,\n", + " \"h_since\": h_since, \"unpl\": unpl, \"n_maint\": n_maint, \"t_fail\": t_fail, \"event\": event, \"dur\": dur})\n", + " df[\"insp\"] = np.clip(dur / t_fail + rng.normal(0, INSP_NOISE, n), 0, 1.3) # 마지막 정비 점검에서 측정한 마모 비율(소모 수명/총 수명, 오차 포함)\n", + " df[\"rul_h\"] = np.where(event == 0, t_fail - h_since, np.nan) # 생존 장비의 남은 운용시간(정답)\n", + " df[\"rul_d\"] = df[\"rul_h\"] / df[\"rate\"] # 남은 일수(정답)\n", + " return df\n", + "\n", + "\n", + "def mape(y, p):\n", + " # [TODO : 학생 실습 2] 운용 환경(험지/염분/혹한) 가중치 반영 및 장비 노후화 지표 전처리 모듈 구현 관련 핵심 로직을 구현하세요.\n", + " # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n", + " # <<< 여기에 코드를 작성하세요 >>>\n", + " pass # 작성 후 pass를 제거하세요.\n", + "def mae(y, p):\n", + " # [TODO : 학생 실습 2] 운용 환경(험지/염분/혹한) 가중치 반영 및 장비 노후화 지표 전처리 모듈 구현 관련 핵심 로직을 구현하세요.\n", + " # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n", + " # <<< 여기에 코드를 작성하세요 >>>\n", + " pass # 작성 후 pass를 제거하세요.\n", + "def rmse(y, p):\n", + " return float(np.sqrt(np.mean((np.asarray(y, float) - np.asarray(p, float)) ** 2)))\n", + "# ---------------------------------------------------------------------------\n", + "# ---------------------------------------------------------------------------\n", + "# 노후화 파생 피처와 학습 표본 만들기. 환경 가중치 W0는 도입 시점의 전문가 추정값이며(실제 가중치와 다름) M9에서 재보정한다.\n", + "# ---------------------------------------------------------------------------\n", + "W0 = {\"normal\": 1.00, \"rough\": 1.15, \"salt\": 1.10, \"cold\": 1.05}\n", + "FEATS = [\"h_since\", \"hours\", \"km\", \"age_y\", \"rate\", \"n_maint\", \"unpl\", \"unpl_per_kh\", \"km_per_h\", \"n_ovh\", \"k_rough\", \"k_salt\", \"k_cold\", \"k_truck\", \"k_gen\", \"k_trailer\",\n", + " \"aging_idx\", \"lin_rul_d\", \"hs_frac\", \"insp\", \"insp_eta_h\"]\n", + "\n", + "\n", + "def add_features(df, w=None):\n", + " d = df.copy()\n", + " w = w or W0\n", + " d[\"unpl_per_kh\"] = d[\"unpl\"] / (d[\"h_since\"] / 1000 + 0.5)\n", + " d[\"km_per_h\"] = d[\"km\"] / d[\"hours\"]\n", + " for e in (\"rough\", \"salt\", \"cold\"):\n", + " d[\"k_\" + e] = (d[\"env\"] == e).astype(int)\n", + " d[\"k_truck\"], d[\"k_gen\"], d[\"k_trailer\"] = (d[\"etype\"] == \"truck\").astype(int), (d[\"etype\"] == \"generator\").astype(int), (d[\"etype\"] == \"trailer\").astype(int)\n", + " d[\"aging_idx\"] = d[\"h_since\"] * d[\"env\"].map(w) / CYCLE # 환경 가중 노후 지수(1이면 창정비 주기에 도달)\n", + " d[\"lin_rul_d\"] = (CYCLE - d[\"h_since\"]) / d[\"rate\"] # 선형 감가상각 잔여 일수(Baseline)\n", + " d[\"hs_frac\"] = d[\"h_since\"] / CYCLE\n", + " d[\"insp_eta_h\"] = d[\"h_since\"] / np.maximum(d[\"insp\"], 0.05) # 점검 마모 비율로 환산한 총 수명 추정(h)\n", + " return d\n", + "\n", + "\n", + "def snapshots(df, k=4, seed=0):\n", + " \"\"\"고장 이력 장비의 과거 시점 표본: 고장 전 임의 시점의 점검값·운용시간과 그때의 남은 일수. (생존 장비의 미래는 알 수 없으므로 고장 이력만 쓴다)\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " f = df[df[\"event\"] == 1]\n", + " out = []\n", + " for _ in range(k):\n", + " s = f.copy()\n", + " h = s[\"t_fail\"] * rng.uniform(0.2, 0.98, len(s))\n", + " ratio = h / s[\"h_since\"]\n", + " s[\"hours\"] = s[\"hours\"] - (s[\"h_since\"] - h)\n", + " s[\"km\"] = s[\"km\"] * ratio.clip(upper=1)\n", + " s[\"unpl\"] = np.floor(s[\"unpl\"] * ratio)\n", + " s[\"n_maint\"] = np.floor(s[\"n_maint\"] * ratio)\n", + " s[\"insp\"] = np.clip(h / s[\"t_fail\"] + rng.normal(0, INSP_NOISE, len(s)), 0, 1.3)\n", + " s[\"h_since\"] = h\n", + " s[\"rul_d\"] = (s[\"t_fail\"] - h) / s[\"rate\"]\n", + " s[\"event\"] = 0\n", + " out.append(s)\n", + " return pd.concat(out, ignore_index=True)\n", + "# ---------------------------------------------------------------------------\n", + "\n", + "truth = make_fleet(220, seed=5)\n", + "rng = np.random.default_rng(2)\n", + "raw = truth.drop(columns=[\"t_fail\", \"event\", \"dur\", \"rul_h\", \"rul_d\"]).copy()\n", + "raw[\"urgent_ops\"] = False\n", + "# 긴급 운용(가동률이 비정상적으로 높지만 실제 사실인 기록): 삭제하면 안 된다\n", + "urg = rng.choice(len(raw), 8, replace=False)\n", + "raw.loc[urg, \"rate\"] = rng.uniform(13, 17, 8)\n", + "raw.loc[urg, \"urgent_ops\"] = True\n", + "# 오류 주입\n", + "errs = {}\n", + "i = rng.choice(len(raw), 12, replace=False); raw.loc[i, \"env\"] = rng.choice([\"험지\", \"ROUGH\", \"Salt \", \"혹한\", \"일반\", \"normal \"], 12); errs[\"환경 표기\"] = 12\n", + "i = rng.choice(len(raw), 8, replace=False); raw.loc[i, \"unit\"] = rng.choice([\"u3\", \"U 2\", \"U9\", \"u5\"], 8); errs[\"부대 코드\"] = 8\n", + "i = rng.choice(len(raw), 6, replace=False); raw.loc[i, \"hours\"] = -raw.loc[i, \"hours\"]; errs[\"운용시간 음수\"] = 6\n", + "i = rng.choice(len(raw), 7, replace=False); raw.loc[i, \"h_since\"] = raw.loc[i, \"hours\"] * rng.uniform(1.3, 2.0, 7); errs[\"창정비 후 시간 > 누적\"] = 7\n", + "i = rng.choice(len(raw), 6, replace=False); raw.loc[i, \"km\"] = raw.loc[i, \"km\"] * 1000; errs[\"km 단위(m)\"] = 6\n", + "i = rng.choice(len(raw), 9, replace=False); raw.loc[i, \"rate\"] = np.nan; errs[\"가동률 결측\"] = 9\n", + "i = rng.choice(len(raw), 9, replace=False); raw.loc[i, \"insp\"] = np.nan; errs[\"점검 마모 결측\"] = 9\n", + "i = rng.choice(len(raw), 5, replace=False); raw.loc[i, \"insp\"] = rng.uniform(1.8, 3.0, 5); errs[\"점검 마모 범위 초과\"] = 5\n", + "raw = pd.concat([raw, raw.iloc[rng.choice(len(raw), 7, replace=False)]], ignore_index=True); errs[\"중복 행\"] = 7\n", + "raw[\"eq_id\"] = raw[\"eq_id\"].where(rng.random(len(raw)) > 0.05, raw[\"eq_id\"].str.lower()); errs[\"ID 표기\"] = int((raw[\"eq_id\"].str.islower()).sum())\n", + "print(f\"[0] 오염 데이터 {len(raw)}행(원본 {len(truth)}행), 주입 오류: \" + \", \".join(f\"{k} {v}\" for k, v in errs.items()) + f\", 긴급 운용 정상 기록 {len(urg)}행\")\n", + "\n", + "# ---------------- 정제 규칙 ----------------\n", + "log = []\n", + "d = raw.copy()\n", + "\n", + "\n", + "def rule(no, desc, n):\n", + " log.append((no, desc, int(n)))\n", + "\n", + "\n", + "n0 = len(d)\n", + "d[\"eq_id\"] = d[\"eq_id\"].str.upper().str.strip()\n", + "rule(1, \"장비 ID 대문자·공백 통일\", (raw[\"eq_id\"] != d[\"eq_id\"]).sum())\n", + "d = d.drop_duplicates(\"eq_id\").reset_index(drop=True)\n", + "rule(2, \"중복 장비 행 제거\", n0 - len(d))\n", + "d[\"unit\"] = d[\"unit\"].astype(str).str.upper().str.replace(\" \", \"\")\n", + "d[\"env\"] = d[\"env\"].astype(str).str.strip()\n", + "EMAP = {\"험지\": \"rough\", \"rough\": \"rough\", \"salt\": \"salt\", \"혹한\": \"cold\", \"cold\": \"cold\", \"일반\": \"normal\", \"normal\": \"normal\", \"염분\": \"salt\"}\n", + "env_fix = d[\"env\"].str.lower().map(EMAP)\n", + "rule(3, \"운용 환경 표기 표준화(한글·대소문자·공백)\", (env_fix != d[\"env\"]).sum())\n", + "d[\"env\"] = env_fix.fillna(\"normal\")\n", + "iso = ~d[\"unit\"].isin(UNITS)\n", + "quarantine = d[iso].copy()\n", + "d = d[~iso].reset_index(drop=True)\n", + "rule(4, \"미등록 부대 코드 행 격리(삭제하지 않고 별도 보관)\", iso.sum())\n", + "neg = d[\"hours\"] < 0\n", + "d.loc[neg, \"hours\"] = -d.loc[neg, \"hours\"]\n", + "rule(5, \"누적 운용시간 부호 오류 보정\", neg.sum())\n", + "bad_hs = d[\"h_since\"] > d[\"hours\"]\n", + "d.loc[bad_hs, \"h_since\"] = d.loc[bad_hs, \"hours\"]\n", + "rule(6, \"창정비 후 운용시간 > 누적 운용시간 모순 보정\", bad_hs.sum())\n", + "unit_km = d[\"km\"] / d[\"hours\"].clip(lower=1) > 100\n", + "d.loc[unit_km, \"km\"] = d.loc[unit_km, \"km\"] / 1000\n", + "rule(7, \"기동거리 단위 오류(m -> km) 보정\", unit_km.sum())\n", + "nan_rate = d[\"rate\"].isna()\n", + "d[\"rate\"] = d[\"rate\"].fillna(d.groupby([\"etype\"])[\"rate\"].transform(\"median\"))\n", + "rule(8, \"일 평균 운용시간 결측을 장비 구분 중앙값으로 보정\", nan_rate.sum())\n", + "out_i = d[\"insp\"] > 1.3\n", + "d.loc[out_i, \"insp\"] = np.nan\n", + "miss_i = d[\"insp\"].isna()\n", + "d[\"insp_flag\"] = np.where(miss_i, \"추정\", \"측정\")\n", + "d[\"insp\"] = d[\"insp\"].fillna((d[\"h_since\"] / CYCLE * d[\"env\"].map(W0)).clip(0, 1.3))\n", + "rule(9, \"점검 마모 비율 범위 초과 제거 후 결측을 환경 가중 노후 지수로 대체(추정 표시)\", miss_i.sum())\n", + "exc = (d[\"rate\"] > 12) | d[\"urgent_ops\"]\n", + "d[\"exception\"] = np.where(exc, \"긴급 운용 보존\", \"\")\n", + "rule(10, \"긴급 운용(가동률 12h/일 초과) 예외 표시 후 이상치 삭제 없이 보존\", exc.sum())\n", + "d[\"aging_idx\"] = d[\"h_since\"] * d[\"env\"].map(W0) / CYCLE\n", + "d[\"eff_hours\"] = d[\"h_since\"] * d[\"env\"].map(W0)\n", + "rule(11, \"환경 가중치 반영 노후화 지표(aging_idx, eff_hours) 산출\", len(d))\n", + "print(\"\\n[1] 정제 규칙 적용 로그\")\n", + "print(pd.DataFrame(log, columns=[\"규칙\", \"내용\", \"처리 건수\"]).to_string(index=False))\n", + "print(f\" 정제 규칙 {len(log)}종 적용(목표 10종 이상: {'달성' if len(log) >= 10 else '미달'}), 격리 {len(quarantine)}행은 담당자 확인 대기\")\n", + "\n", + "# ---------------- 정합성 검증 ----------------\n", + "checks = {\"장비 ID 유일\": not d[\"eq_id\"].duplicated().any(), \"부대 코드 등록\": bool(d[\"unit\"].isin(UNITS).all()), \"환경 값 4종\": bool(d[\"env\"].isin(ENVS).all()),\n", + " \"운용시간 0 이상\": bool((d[\"hours\"] >= 0).all()), \"창정비 후 <= 누적\": bool((d[\"h_since\"] <= d[\"hours\"] + 1e-6).all()), \"기동 속도 5~40km/h\": bool((d[\"km\"] / d[\"hours\"].clip(lower=1)).between(5, 40).all()),\n", + " \"점검 마모 0~1.3\": bool(d[\"insp\"].between(0, 1.3).all()), \"필수 컬럼 결측 0\": int(d[[\"rate\", \"insp\", \"aging_idx\"]].isna().sum().sum()) == 0}\n", + "print(\"\\n[2] 정합성 검증\")\n", + "for k, v in checks.items():\n", + " print(f\" [{'PASS' if v else 'FAIL'}] {k}\")\n", + "print(f\" 통과율 {sum(checks.values()) / len(checks):.0%} (목표 100%)\")\n", + "\n", + "# ---------------- 복원 정확도·예외 보존·전후 통계 ----------------\n", + "t = truth.set_index(\"eq_id\").loc[d[\"eq_id\"].isin(truth[\"eq_id\"]).pipe(lambda m: d.loc[m, \"eq_id\"])]\n", + "dd = d.set_index(\"eq_id\").loc[t.index]\n", + "print(f\"\\n[3] 복원 정확도(원본과 비교): 누적 운용시간 오차 {np.abs(dd['hours'] - t['hours']).mean():.1f}h, 기동거리 오차 {np.abs(dd['km'] - t['km']).mean():.1f}km, 가동률 오차 {np.abs(dd['rate'] - t['rate'])[dd['exception'] == ''].mean():.3f}h/일(긴급 운용 제외)\")\n", + "print(f\" 긴급 운용 기록 {int((d['exception'] != '').sum())}행 보존(삭제 0행), 점검 마모 추정 대체 {int((d['insp_flag'] == '추정').sum())}행은 추정 표시\")\n", + "rows = [(\"운용시간 최솟값(h)\", raw[\"hours\"].min(), d[\"hours\"].min(), truth[\"hours\"].min()), (\"운용시간 평균(h)\", raw[\"hours\"].mean(), d[\"hours\"].mean(), truth[\"hours\"].mean()),\n", + " (\"기동거리 최댓값(km)\", raw[\"km\"].max(), d[\"km\"].max(), truth[\"km\"].max()), (\"가동률 유효 건수\", raw[\"rate\"].notna().sum(), d[\"rate\"].notna().sum(), truth[\"rate\"].notna().sum()),\n", + " (\"가동률 최댓값(h/일)\", raw[\"rate\"].max(), d[\"rate\"].max(), truth[\"rate\"].max()), (\"점검 마모 최댓값\", raw[\"insp\"].max(), d[\"insp\"].max(), truth[\"insp\"].max())]\n", + "print(\"[4] 전후 기술통계(오염 / 정제 후 / 원본)\")\n", + "print(pd.DataFrame(rows, columns=[\"항목\", \"오염\", \"정제 후\", \"원본\"]).round(2).to_string(index=False))\n", + "w = d.groupby(\"env\")[\"aging_idx\"].mean().round(3)\n", + "print(\" 환경별 평균 노후 지수(가중치 W0 적용):\", w.to_dict())\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 02 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + "[0] 오염 데이터 227행(원본 220행), 주입 오류: 환경 표기 12, 부대 코드 8, 운용시간 음수 6, 창정비 후 시간 > 누적 7, km 단위(m) 6, 가동률 결측 9, 점검 마모 결측 9, 점검 마모 범위 초과 5, 중복 행 7, ID 표기 7, 긴급 운용 정상 기록 8행\n", + "\n", + "[1] 정제 규칙 적용 로그\n", + " 규칙 내용 처리 건수\n", + " 1 장비 ID 대문자·공백 통일 7\n", + " 2 중복 장비 행 제거 7\n", + " 3 운용 환경 표기 표준화(한글·대소문자·공백) 10\n", + " 4 미등록 부대 코드 행 격리(삭제하지 않고 별도 보관) 1\n", + " 5 누적 운용시간 부호 오류 보정 6\n", + " 6 창정비 후 운용시간 > 누적 운용시간 모순 보정 7\n", + " 7 기동거리 단위 오류(m -> km) 보정 6\n", + " 8 일 평균 운용시간 결측을 장비 구분 중앙값으로 보정 9\n", + " 9 점검 마모 비율 범위 초과 제거 후 결측을 환경 가중 노후 지수로 대체(추정 표시) 13\n", + " 10 긴급 운용(가동률 12h/일 초과) 예외 표시 후 이상치 삭제 없이 보존 8\n", + " 11 환경 가중치 반영 노후화 지표(aging_idx, eff_hours) 산출 219\n", + " 정제 규칙 11종 적용(목표 10종 이상: 달성), 격리 1행은 담당자 확인 대기\n", + "\n", + "[2] 정합성 검증\n", + " [PASS] 장비 ID 유일\n", + " [PASS] 부대 코드 등록\n", + " [PASS] 환경 값 4종\n", + " [PASS] 운용시간 0 이상\n", + " [PASS] 창정비 후 <= 누적\n", + " [PASS] 기동 속도 5~40km/h\n", + " [PASS] 점검 마모 0~1.3\n", + " [PASS] 필수 컬럼 결측 0\n", + " 통과율 100% (목표 100%)\n", + "\n", + "[3] 복원 정확도(원본과 비교): 누적 운용시간 오차 0.0h, 기동거리 오차 0.0km, 가동률 오차 0.016h/일(긴급 운용 제외)\n", + " 긴급 운용 기록 8행 보존(삭제 0행), 점검 마모 추정 대체 13행은 추정 표시\n", + "[4] 전후 기술통계(오염 / 정제 후 / 원본)\n", + " 항목 오염 정제 후 원본\n", + " 운용시간 최솟값(h) -25733.90 485.39 485.39\n", + " 운용시간 평균(h) 11623.87 12523.40 12475.96\n", + "기동거리 최댓값(km) 476232660.71 490734.91 490734.91\n", + " 가동률 유효 건수 218.00 219.00 220.00\n", + "가동률 최댓값(h/일) 15.68 15.68 6.53\n", + " 점검 마모 최댓값 2.34 1.30 1.08\n", + " 환경별 평균 노후 지수(가중치 W0 적용): {'cold': 0.8, 'normal': 0.848, 'rough': 0.846, 'salt': 0.83}\n", + "```\n", + "
\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 03. 선형 감가상각 기반 1차 잔여 수명(RUL) 계산 Baseline 알고리즘 구현\n", + "**소요시간**: 약 2.5시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "선형 감가상각 기반 1차 잔여 수명(RUL) 계산 Baseline 알고리즘을 구현한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **선형 감가상각 기반 1차 잔여 수명(RUL) Baseline을 구현하고 EDA로 오차 요인을 찾는다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **규정 주기에서 창정비 후 운용시간을 빼고 일 가동률로 나눠 잔여 수명 산출하고 모델에 적용하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `notebooks/sup02_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": [ + "\"\"\"선형 감가상각(규정 창정비 주기 - 경과 운용시간) 기반 잔여 수명(RUL) Baseline을 구현해 오차와 환경별 편향·한계를 확인하고, EDA 차트 8종·상관분석(p-value)·환경별 차이 검정·부대별 위험 파레토로 핵심 노후화 요인을 찾는다.\"\"\"\n", + "import numpy as np\n", + "import pandas as pd\n", + "import matplotlib.pyplot as plt\n", + "from scipy import stats\n", + "from sklearn.metrics import r2_score\n", + "\n", + "# ---------------------------------------------------------------------------\n", + "# [교육용가상자료] 가상 장비 보유 현황·고장 이력 생성기 (실제 부대·장비·수치 없음)\n", + "# 가상 부대 6개(U1~U6)의 장비가 운용 환경(normal/rough/salt/cold)에 따라 다른 속도로 노후화한다. 창정비 후 운용시간(h_since)이\n", + "# 장비마다 숨은 수명(t_fail)을 넘으면 고장(event=1) 이력, 넘지 않았으면 현재까지 생존(event=0, 관측 중도절단)이다.\n", + "# ---------------------------------------------------------------------------\n", + "ENVS = [\"normal\", \"rough\", \"salt\", \"cold\"]\n", + "ENV_W = {\"normal\": 1.00, \"rough\": 1.35, \"salt\": 1.25, \"cold\": 1.12} # 환경 가중치(숨은 정답, 가정값): 클수록 수명이 짧다\n", + "ENV_KO = {\"normal\": \"일반\", \"rough\": \"험지\", \"salt\": \"염분\", \"cold\": \"혹한\"}\n", + "UNITS = [\"U1\", \"U2\", \"U3\", \"U4\", \"U5\", \"U6\"]\n", + "UNIT_ENV = {\"U1\": [.7, .1, .1, .1], \"U2\": [.2, .6, .1, .1], \"U3\": [.2, .1, .6, .1], \"U4\": [.3, .1, .1, .5], \"U5\": [.5, .3, .1, .1], \"U6\": [.4, .1, .4, .1]}\n", + "ETYPES = [\"truck\", \"generator\", \"trailer\", \"radio\"]\n", + "CYCLE = 8000.0 # 규정 창정비 주기(운용시간 h, 가정값)\n", + "KSHAPE = 12.0 # 수명 분포 와이블 형상(마모형 고장, 클수록 수명이 모여 있다)\n", + "INSP_NOISE = 0.025 # 정비 점검 마모 비율 측정 오차(가정값)\n", + "\n", + "\n", + "def make_fleet(n=900, seed=0, env_w=None, shift=1.0):\n", + " \"\"\"장비 n대. env_w로 숨은 환경 가중치를 바꿔 운영 분포 변화를 모의할 수 있다. shift>1이면 가동률이 높아진다.\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " w = env_w or ENV_W\n", + " unit = rng.choice(UNITS, n)\n", + " env = np.array([rng.choice(ENVS, p=UNIT_ENV[u]) for u in unit])\n", + " et = rng.choice(ETYPES, n, p=[.4, .2, .2, .2])\n", + " rate = rng.lognormal(np.log(3.0 * shift), 0.25, n) # 일 평균 운용시간(h/일)\n", + " h_since = rng.uniform(300, 11500, n) # 마지막 창정비 이후 운용시간\n", + " n_ovh = rng.integers(0, 3, n)\n", + " hours = h_since + n_ovh * rng.uniform(5000, 8000, n) # 누적 운용시간\n", + " km = hours * rng.normal(17, 3, n) * np.where(env == \"rough\", 0.8, 1.0) # 누적 기동거리(km)\n", + " age_y = np.clip(hours / (rate * 365) * rng.uniform(0.9, 1.15, n), 0.5, 25)\n", + " unpl = rng.poisson(0.35 * h_since / 1000 * np.array([w[e] for e in env]) / 1.3) # 비계획 정비 건수(환경이 가혹할수록 많다)\n", + " n_maint = rng.poisson(0.6 * h_since / 1000 + 1) # 계획 정비 건수\n", + " scale = 9600 / np.array([w[e] for e in env]) * 0.985 ** unpl * np.exp(rng.normal(0, 0.04, n))\n", + " t_fail = scale * rng.weibull(KSHAPE, n) # 창정비 후 고장까지 운용시간(숨은 정답)\n", + " event = (t_fail <= h_since).astype(int)\n", + " dur = np.where(event == 1, t_fail, h_since)\n", + " df = pd.DataFrame({\"eq_id\": [f\"EQ{seed:02d}{i:04d}\" for i in range(n)], \"unit\": unit, \"env\": env, \"etype\": et, \"rate\": rate, \"hours\": hours, \"km\": km, \"age_y\": age_y, \"n_ovh\": n_ovh,\n", + " \"h_since\": h_since, \"unpl\": unpl, \"n_maint\": n_maint, \"t_fail\": t_fail, \"event\": event, \"dur\": dur})\n", + " df[\"insp\"] = np.clip(dur / t_fail + rng.normal(0, INSP_NOISE, n), 0, 1.3) # 마지막 정비 점검에서 측정한 마모 비율(소모 수명/총 수명, 오차 포함)\n", + " df[\"rul_h\"] = np.where(event == 0, t_fail - h_since, np.nan) # 생존 장비의 남은 운용시간(정답)\n", + " df[\"rul_d\"] = df[\"rul_h\"] / df[\"rate\"] # 남은 일수(정답)\n", + " return df\n", + "\n", + "\n", + "def mape(y, p):\n", + " # [TODO : 학생 실습 3] 선형 감가상각 기반 1차 잔여 수명(RUL) 계산 Baseline 알고리즘 구현 관련 핵심 로직을 구현하세요.\n", + " # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n", + " # <<< 여기에 코드를 작성하세요 >>>\n", + " pass # 작성 후 pass를 제거하세요.\n", + "def mae(y, p):\n", + " # [TODO : 학생 실습 3] 선형 감가상각 기반 1차 잔여 수명(RUL) 계산 Baseline 알고리즘 구현 관련 핵심 로직을 구현하세요.\n", + " # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n", + " # <<< 여기에 코드를 작성하세요 >>>\n", + " pass # 작성 후 pass를 제거하세요.\n", + "def rmse(y, p):\n", + " return float(np.sqrt(np.mean((np.asarray(y, float) - np.asarray(p, float)) ** 2)))\n", + "# ---------------------------------------------------------------------------\n", + "# ---------------------------------------------------------------------------\n", + "# 노후화 파생 피처와 학습 표본 만들기. 환경 가중치 W0는 도입 시점의 전문가 추정값이며(실제 가중치와 다름) M9에서 재보정한다.\n", + "# ---------------------------------------------------------------------------\n", + "W0 = {\"normal\": 1.00, \"rough\": 1.15, \"salt\": 1.10, \"cold\": 1.05}\n", + "FEATS = [\"h_since\", \"hours\", \"km\", \"age_y\", \"rate\", \"n_maint\", \"unpl\", \"unpl_per_kh\", \"km_per_h\", \"n_ovh\", \"k_rough\", \"k_salt\", \"k_cold\", \"k_truck\", \"k_gen\", \"k_trailer\",\n", + " \"aging_idx\", \"lin_rul_d\", \"hs_frac\", \"insp\", \"insp_eta_h\"]\n", + "\n", + "\n", + "def add_features(df, w=None):\n", + " d = df.copy()\n", + " w = w or W0\n", + " d[\"unpl_per_kh\"] = d[\"unpl\"] / (d[\"h_since\"] / 1000 + 0.5)\n", + " d[\"km_per_h\"] = d[\"km\"] / d[\"hours\"]\n", + " for e in (\"rough\", \"salt\", \"cold\"):\n", + " d[\"k_\" + e] = (d[\"env\"] == e).astype(int)\n", + " d[\"k_truck\"], d[\"k_gen\"], d[\"k_trailer\"] = (d[\"etype\"] == \"truck\").astype(int), (d[\"etype\"] == \"generator\").astype(int), (d[\"etype\"] == \"trailer\").astype(int)\n", + " d[\"aging_idx\"] = d[\"h_since\"] * d[\"env\"].map(w) / CYCLE # 환경 가중 노후 지수(1이면 창정비 주기에 도달)\n", + " d[\"lin_rul_d\"] = (CYCLE - d[\"h_since\"]) / d[\"rate\"] # 선형 감가상각 잔여 일수(Baseline)\n", + " d[\"hs_frac\"] = d[\"h_since\"] / CYCLE\n", + " d[\"insp_eta_h\"] = d[\"h_since\"] / np.maximum(d[\"insp\"], 0.05) # 점검 마모 비율로 환산한 총 수명 추정(h)\n", + " return d\n", + "\n", + "\n", + "def snapshots(df, k=4, seed=0):\n", + " \"\"\"고장 이력 장비의 과거 시점 표본: 고장 전 임의 시점의 점검값·운용시간과 그때의 남은 일수. (생존 장비의 미래는 알 수 없으므로 고장 이력만 쓴다)\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " f = df[df[\"event\"] == 1]\n", + " out = []\n", + " for _ in range(k):\n", + " s = f.copy()\n", + " h = s[\"t_fail\"] * rng.uniform(0.2, 0.98, len(s))\n", + " ratio = h / s[\"h_since\"]\n", + " s[\"hours\"] = s[\"hours\"] - (s[\"h_since\"] - h)\n", + " s[\"km\"] = s[\"km\"] * ratio.clip(upper=1)\n", + " s[\"unpl\"] = np.floor(s[\"unpl\"] * ratio)\n", + " s[\"n_maint\"] = np.floor(s[\"n_maint\"] * ratio)\n", + " s[\"insp\"] = np.clip(h / s[\"t_fail\"] + rng.normal(0, INSP_NOISE, len(s)), 0, 1.3)\n", + " s[\"h_since\"] = h\n", + " s[\"rul_d\"] = (s[\"t_fail\"] - h) / s[\"rate\"]\n", + " s[\"event\"] = 0\n", + " out.append(s)\n", + " return pd.concat(out, ignore_index=True)\n", + "# ---------------------------------------------------------------------------\n", + "\n", + "df = make_fleet(900, seed=1)\n", + "d = add_features(df)\n", + "al = d[d.event == 0].copy()\n", + "al[\"err\"] = al[\"lin_rul_d\"] - al[\"rul_d\"] # 양수 = Baseline이 수명을 길게 본다(위험한 과대 예측)\n", + "big = al[\"rul_d\"] >= 90\n", + "print(f\"[1] 가상 장비 {len(d)}대(고장 이력 {int(d.event.sum())}대, 생존 {len(al)}대). Baseline RUL(일) = (규정 주기 {CYCLE:.0f}h - 창정비 후 운용시간) / 일 가동률\")\n", + "print(f\" 생존 장비 평가: MAE {mae(al.rul_d, al.lin_rul_d):.0f}일, RMSE {rmse(al.rul_d, al.lin_rul_d):.0f}일, MAPE(참 RUL 90일 이상) {mape(al.rul_d[big], al.lin_rul_d[big]):.1f}%, R2 {r2_score(al.rul_d, al.lin_rul_d):.3f}, 평균 편향 {al.err.mean():+.0f}일\")\n", + "over = (al[\"lin_rul_d\"] > al[\"rul_d\"] + 30)\n", + "print(f\" Baseline이 실제보다 30일 넘게 길게 본 장비 {int(over.sum())}대({over.mean():.0%}) - 고장 전에 정비 시점을 놓칠 위험\")\n", + "fail_before = d[(d.event == 1) & (d.t_fail < CYCLE)]\n", + "print(f\" 고장 이력 {int(d.event.sum())}대 중 규정 주기({CYCLE:.0f}h) 이전에 고장 난 장비 {len(fail_before)}대({len(fail_before) / d.event.sum():.0%}) -> 고정 주기는 가혹 환경 장비를 지키지 못한다\")\n", + "\n", + "# 조건별 오차\n", + "conds = {\"일반 환경\": al.env == \"normal\", \"험지\": al.env == \"rough\", \"염분\": al.env == \"salt\", \"혹한\": al.env == \"cold\", \"비계획 정비율 상위 25%\": al.unpl_per_kh >= al.unpl_per_kh.quantile(.75),\n", + " \"일 가동률 4h 이상\": al.rate >= 4, \"점검 마모 0.8 이상(노후)\": al.insp >= 0.8}\n", + "ct = pd.DataFrame([(k, int(m.sum()), mae(al.rul_d[m], al.lin_rul_d[m]), al.err[m].mean()) for k, m in conds.items()], columns=[\"조건\", \"대수\", \"MAE(일)\", \"평균 편향(일)\"])\n", + "print(\"\\n[2] 조건별 Baseline 오차(양수 = 과대 예측)\\n\" + ct.round(0).to_string(index=False))\n", + "print(\" -> 험지·염분 장비는 수명을 길게(위험), 일반 환경 장비는 짧게(과잉 정비) 보아 환경별 편향 방향이 반대다. 환경 가중치 미반영이 한계다.\")\n", + "\n", + "# 상관분석\n", + "rows = []\n", + "for c in FEATS:\n", + " if al[c].nunique() < 2: continue\n", + " r, p = stats.spearmanr(al[c], al[\"err\"])\n", + " rows.append((c, r, p))\n", + "cr = pd.DataFrame(rows, columns=[\"피처\", \"스피어만 상관\", \"p-value\"]).assign(abs_r=lambda x: x[\"스피어만 상관\"].abs()).sort_values(\"abs_r\", ascending=False)\n", + "sig = cr[(cr[\"abs_r\"] >= 0.2) & (cr[\"p-value\"] < 0.05)]\n", + "print(f\"\\n[3] Baseline 오차(부호 있음: 양수 = 과대 예측)와 유의한 상관(|r|>=0.2, p<0.05) 피처 {len(sig)}개 (목표 5개 이상: {'달성' if len(sig) >= 5 else '미달'})\")\n", + "print(sig.head(8)[[\"피처\", \"스피어만 상관\", \"p-value\"]].round(4).to_string(index=False))\n", + "print(\" (lin_rul_d·h_since·hs_frac는 Baseline 산식과 직접 겹치므로 해석에서는 환경 더미·점검 마모·기동 강도 같은 독립 요인을 본다)\")\n", + "hi, lo = al[al.env.isin([\"rough\", \"salt\"])][\"err\"], al[al.env.isin([\"normal\"])][\"err\"]\n", + "u = stats.mannwhitneyu(hi, lo)\n", + "kw = stats.kruskal(*[g[\"insp_eta_h\"].values for _, g in al.groupby(\"env\")])\n", + "print(f\" 가설검정: 험지·염분 vs 일반 환경 Baseline 편향 평균 {hi.mean():+.0f}일 vs {lo.mean():+.0f}일(Mann-Whitney p={u.pvalue:.1e}), 환경별 점검 환산 총 수명 차이 Kruskal p={kw.pvalue:.1e}\")\n", + "\n", + "# 파레토: 부대별 고위험(참 RUL 180일 미만) 장비\n", + "risk = al[al.rul_d < 180].groupby(\"unit\").size().sort_values(ascending=False)\n", + "cum = risk.cumsum() / risk.sum()\n", + "print(f\"\\n[4] 파레토: 참 RUL 180일 미만 고위험 장비 {int(risk.sum())}대, 상위 {int((cum <= 0.8).sum() + 1)}개 부대가 80% 차지 -> \" + \", \".join(f\"{u} {n}대\" for u, n in risk.items()))\n", + "print(\" 핵심 발견: 환경 가중치와 정비 이력·점검 마모가 Baseline이 놓치는 요인이며, 이는 M4 피처 설계(환경 더미, 비계획 정비율, 점검 마모)의 근거가 된다.\")\n", + "\n", + "fig, ax = plt.subplots(2, 4, figsize=(18, 8))\n", + "ax[0, 0].boxplot([d.loc[d.env == e, \"insp_eta_h\"] for e in ENVS]); ax[0, 0].set_xticklabels([\"normal\", \"rough\", \"salt\", \"cold\"]); ax[0, 0].set_title(\"1 Estimated total life by environment (h)\")\n", + "ax[0, 1].scatter(d.h_since, d.dur, c=d.event, s=6, cmap=\"coolwarm\"); ax[0, 1].set_title(\"2 Hours since overhaul vs observed (red = failed)\")\n", + "ax[0, 2].hist(d.rate, 30); ax[0, 2].set_title(\"3 Daily usage rate (h/day)\")\n", + "ax[0, 3].scatter(d.h_since, d.unpl, s=6, c=d.env.map({e: i for i, e in enumerate(ENVS)})); ax[0, 3].set_title(\"4 Unplanned maintenance vs hours\")\n", + "ax[1, 0].bar(range(len(ct)), ct[\"평균 편향(일)\"]); ax[1, 0].set_xticks(range(len(ct))); ax[1, 0].set_xticklabels([f\"C{i + 1}\" for i in range(len(ct))]); ax[1, 0].set_title(\"5 Baseline bias by condition (days)\")\n", + "ax[1, 1].scatter(al.insp, al.hs_frac, s=6, c=al.env.map({e: i for i, e in enumerate(ENVS)})); ax[1, 1].set_title(\"6 Inspection wear vs hours fraction\")\n", + "ax[1, 2].scatter(al.rul_d, al.lin_rul_d, s=6); ax[1, 2].plot([0, 4000], [0, 4000], \"k--\"); ax[1, 2].set_title(\"7 True RUL vs Baseline RUL (days)\")\n", + "ax[1, 3].bar(risk.index, risk.values); ax[1, 3].set_title(\"8 High-risk equipment by unit (Pareto)\")\n", + "plt.tight_layout(); plt.show()\n", + "print(\"\\nEDA 차트 8종 생성 완료(목표 8종 이상: 달성)\")\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 03 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + "[1] 가상 장비 900대(고장 이력 288대, 생존 612대). Baseline RUL(일) = (규정 주기 8000h - 창정비 후 운용시간) / 일 가동률\n", + " 생존 장비 평가: MAE 366일, RMSE 455일, MAPE(참 RUL 90일 이상) 51.8%, R2 0.740, 평균 편향 -48일\n", + " Baseline이 실제보다 30일 넘게 길게 본 장비 270대(44%) - 고장 전에 정비 시점을 놓칠 위험\n", + " 고장 이력 288대 중 규정 주기(8000h) 이전에 고장 난 장비 225대(78%) -> 고정 주기는 가혹 환경 장비를 지키지 못한다\n", + "\n", + "[2] 조건별 Baseline 오차(양수 = 과대 예측)\n", + " 조건 대수 MAE(일) 평균 편향(일)\n", + " 일반 환경 261 431.0 -385.0\n", + " 험지 102 434.0 426.0\n", + " 염분 146 286.0 221.0\n", + " 혹한 103 246.0 -45.0\n", + " 비계획 정비율 상위 25% 153 380.0 51.0\n", + " 일 가동률 4h 이상 71 236.0 -88.0\n", + "점검 마모 0.8 이상(노후) 140 352.0 -19.0\n", + " -> 험지·염분 장비는 수명을 길게(위험), 일반 환경 장비는 짧게(과잉 정비) 보아 환경별 편향 방향이 반대다. 환경 가중치 미반영이 한계다.\n", + "\n", + "[3] Baseline 오차(부호 있음: 양수 = 과대 예측)와 유의한 상관(|r|>=0.2, p<0.05) 피처 7개 (목표 5개 이상: 달성)\n", + " 피처 스피어만 상관 p-value\n", + "insp_eta_h -0.8807 0.0\n", + " k_rough 0.4811 0.0\n", + " k_salt 0.3550 0.0\n", + " lin_rul_d 0.2540 0.0\n", + " h_since -0.2498 0.0\n", + " hs_frac -0.2498 0.0\n", + " km_per_h -0.2257 0.0\n", + " (lin_rul_d·h_since·hs_frac는 Baseline 산식과 직접 겹치므로 해석에서는 환경 더미·점검 마모·기동 강도 같은 독립 요인을 본다)\n", + " 가설검정: 험지·염분 vs 일반 환경 Baseline 편향 평균 +305일 vs -385일(Mann-Whitney p=1.9e-65), 환경별 점검 환산 총 수명 차이 Kruskal p=1.1e-57\n", + "\n", + "[4] 파레토: 참 RUL 180일 미만 고위험 장비 46대, 상위 5개 부대가 80% 차지 -> U5 12대, U3 9대, U4 8대, U6 7대, U2 5대, U1 5대\n", + " 핵심 발견: 환경 가중치와 정비 이력·점검 마모가 Baseline이 놓치는 요인이며, 이는 M4 피처 설계(환경 더미, 비계획 정비율, 점검 마모)의 근거가 된다.\n", + "\n", + "EDA 차트 8종 생성 완료(목표 8종 이상: 달성)\n", + "```\n", + "
\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 04. 생존 분석(Weibull/Cox) 기반 장비/부품 고장 확률 곡선 및 기대수명 예측 모델 고도화\n", + "**소요시간**: 약 3.0시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "생존 분석 기반 장비/부품 고장 확률 및 기대수명 예측 모델을 고도화한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **와이블 AFT와 Cox 비례위험 모형으로 고장 확률 곡선과 기대수명을 예측한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **생존 분석 라이브러리를 쓰지 않고 scipy로 와이블 AFT와 Cox 모형을 직접 구현하고 동작을 검증하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `src/features/sup02_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": [ + "\"\"\"생존 분석(와이블 AFT 최대우도·Cox 비례위험을 scipy로 직접 구현)으로 환경별 고장 확률 곡선과 기대수명을 추정하고, 환경 가속 계수를 숨은 정답과 비교하며 C-index·기대수명 MAPE·1년 내 고장 확률 보정으로 모델을 검증한다.\"\"\"\n", + "import numpy as np\n", + "import pandas as pd\n", + "import matplotlib.pyplot as plt\n", + "\n", + "# ---------------------------------------------------------------------------\n", + "# [교육용가상자료] 가상 장비 보유 현황·고장 이력 생성기 (실제 부대·장비·수치 없음)\n", + "# 가상 부대 6개(U1~U6)의 장비가 운용 환경(normal/rough/salt/cold)에 따라 다른 속도로 노후화한다. 창정비 후 운용시간(h_since)이\n", + "# 장비마다 숨은 수명(t_fail)을 넘으면 고장(event=1) 이력, 넘지 않았으면 현재까지 생존(event=0, 관측 중도절단)이다.\n", + "# ---------------------------------------------------------------------------\n", + "ENVS = [\"normal\", \"rough\", \"salt\", \"cold\"]\n", + "ENV_W = {\"normal\": 1.00, \"rough\": 1.35, \"salt\": 1.25, \"cold\": 1.12} # 환경 가중치(숨은 정답, 가정값): 클수록 수명이 짧다\n", + "ENV_KO = {\"normal\": \"일반\", \"rough\": \"험지\", \"salt\": \"염분\", \"cold\": \"혹한\"}\n", + "UNITS = [\"U1\", \"U2\", \"U3\", \"U4\", \"U5\", \"U6\"]\n", + "UNIT_ENV = {\"U1\": [.7, .1, .1, .1], \"U2\": [.2, .6, .1, .1], \"U3\": [.2, .1, .6, .1], \"U4\": [.3, .1, .1, .5], \"U5\": [.5, .3, .1, .1], \"U6\": [.4, .1, .4, .1]}\n", + "ETYPES = [\"truck\", \"generator\", \"trailer\", \"radio\"]\n", + "CYCLE = 8000.0 # 규정 창정비 주기(운용시간 h, 가정값)\n", + "KSHAPE = 12.0 # 수명 분포 와이블 형상(마모형 고장, 클수록 수명이 모여 있다)\n", + "INSP_NOISE = 0.025 # 정비 점검 마모 비율 측정 오차(가정값)\n", + "\n", + "\n", + "def make_fleet(n=900, seed=0, env_w=None, shift=1.0):\n", + " \"\"\"장비 n대. env_w로 숨은 환경 가중치를 바꿔 운영 분포 변화를 모의할 수 있다. shift>1이면 가동률이 높아진다.\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " w = env_w or ENV_W\n", + " unit = rng.choice(UNITS, n)\n", + " env = np.array([rng.choice(ENVS, p=UNIT_ENV[u]) for u in unit])\n", + " et = rng.choice(ETYPES, n, p=[.4, .2, .2, .2])\n", + " rate = rng.lognormal(np.log(3.0 * shift), 0.25, n) # 일 평균 운용시간(h/일)\n", + " h_since = rng.uniform(300, 11500, n) # 마지막 창정비 이후 운용시간\n", + " n_ovh = rng.integers(0, 3, n)\n", + " hours = h_since + n_ovh * rng.uniform(5000, 8000, n) # 누적 운용시간\n", + " km = hours * rng.normal(17, 3, n) * np.where(env == \"rough\", 0.8, 1.0) # 누적 기동거리(km)\n", + " age_y = np.clip(hours / (rate * 365) * rng.uniform(0.9, 1.15, n), 0.5, 25)\n", + " unpl = rng.poisson(0.35 * h_since / 1000 * np.array([w[e] for e in env]) / 1.3) # 비계획 정비 건수(환경이 가혹할수록 많다)\n", + " n_maint = rng.poisson(0.6 * h_since / 1000 + 1) # 계획 정비 건수\n", + " scale = 9600 / np.array([w[e] for e in env]) * 0.985 ** unpl * np.exp(rng.normal(0, 0.04, n))\n", + " t_fail = scale * rng.weibull(KSHAPE, n) # 창정비 후 고장까지 운용시간(숨은 정답)\n", + " event = (t_fail <= h_since).astype(int)\n", + " dur = np.where(event == 1, t_fail, h_since)\n", + " df = pd.DataFrame({\"eq_id\": [f\"EQ{seed:02d}{i:04d}\" for i in range(n)], \"unit\": unit, \"env\": env, \"etype\": et, \"rate\": rate, \"hours\": hours, \"km\": km, \"age_y\": age_y, \"n_ovh\": n_ovh,\n", + " \"h_since\": h_since, \"unpl\": unpl, \"n_maint\": n_maint, \"t_fail\": t_fail, \"event\": event, \"dur\": dur})\n", + " df[\"insp\"] = np.clip(dur / t_fail + rng.normal(0, INSP_NOISE, n), 0, 1.3) # 마지막 정비 점검에서 측정한 마모 비율(소모 수명/총 수명, 오차 포함)\n", + " df[\"rul_h\"] = np.where(event == 0, t_fail - h_since, np.nan) # 생존 장비의 남은 운용시간(정답)\n", + " df[\"rul_d\"] = df[\"rul_h\"] / df[\"rate\"] # 남은 일수(정답)\n", + " return df\n", + "\n", + "\n", + "def mape(y, p):\n", + " # [TODO : 학생 실습 4] 생존 분석(Weibull/Cox) 기반 장비/부품 고장 확률 곡선 및 기대수명 예측 모델 고도화 관련 핵심 로직을 구현하세요.\n", + " # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n", + " # <<< 여기에 코드를 작성하세요 >>>\n", + " pass # 작성 후 pass를 제거하세요.\n", + "def mae(y, p):\n", + " # [TODO : 학생 실습 4] 생존 분석(Weibull/Cox) 기반 장비/부품 고장 확률 곡선 및 기대수명 예측 모델 고도화 관련 핵심 로직을 구현하세요.\n", + " # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n", + " # <<< 여기에 코드를 작성하세요 >>>\n", + " pass # 작성 후 pass를 제거하세요.\n", + "def rmse(y, p):\n", + " return float(np.sqrt(np.mean((np.asarray(y, float) - np.asarray(p, float)) ** 2)))\n", + "# ---------------------------------------------------------------------------\n", + "# ---------------------------------------------------------------------------\n", + "# 노후화 파생 피처와 학습 표본 만들기. 환경 가중치 W0는 도입 시점의 전문가 추정값이며(실제 가중치와 다름) M9에서 재보정한다.\n", + "# ---------------------------------------------------------------------------\n", + "W0 = {\"normal\": 1.00, \"rough\": 1.15, \"salt\": 1.10, \"cold\": 1.05}\n", + "FEATS = [\"h_since\", \"hours\", \"km\", \"age_y\", \"rate\", \"n_maint\", \"unpl\", \"unpl_per_kh\", \"km_per_h\", \"n_ovh\", \"k_rough\", \"k_salt\", \"k_cold\", \"k_truck\", \"k_gen\", \"k_trailer\",\n", + " \"aging_idx\", \"lin_rul_d\", \"hs_frac\", \"insp\", \"insp_eta_h\"]\n", + "\n", + "\n", + "def add_features(df, w=None):\n", + " d = df.copy()\n", + " w = w or W0\n", + " d[\"unpl_per_kh\"] = d[\"unpl\"] / (d[\"h_since\"] / 1000 + 0.5)\n", + " d[\"km_per_h\"] = d[\"km\"] / d[\"hours\"]\n", + " for e in (\"rough\", \"salt\", \"cold\"):\n", + " d[\"k_\" + e] = (d[\"env\"] == e).astype(int)\n", + " d[\"k_truck\"], d[\"k_gen\"], d[\"k_trailer\"] = (d[\"etype\"] == \"truck\").astype(int), (d[\"etype\"] == \"generator\").astype(int), (d[\"etype\"] == \"trailer\").astype(int)\n", + " d[\"aging_idx\"] = d[\"h_since\"] * d[\"env\"].map(w) / CYCLE # 환경 가중 노후 지수(1이면 창정비 주기에 도달)\n", + " d[\"lin_rul_d\"] = (CYCLE - d[\"h_since\"]) / d[\"rate\"] # 선형 감가상각 잔여 일수(Baseline)\n", + " d[\"hs_frac\"] = d[\"h_since\"] / CYCLE\n", + " d[\"insp_eta_h\"] = d[\"h_since\"] / np.maximum(d[\"insp\"], 0.05) # 점검 마모 비율로 환산한 총 수명 추정(h)\n", + " return d\n", + "\n", + "\n", + "def snapshots(df, k=4, seed=0):\n", + " \"\"\"고장 이력 장비의 과거 시점 표본: 고장 전 임의 시점의 점검값·운용시간과 그때의 남은 일수. (생존 장비의 미래는 알 수 없으므로 고장 이력만 쓴다)\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " f = df[df[\"event\"] == 1]\n", + " out = []\n", + " for _ in range(k):\n", + " s = f.copy()\n", + " h = s[\"t_fail\"] * rng.uniform(0.2, 0.98, len(s))\n", + " ratio = h / s[\"h_since\"]\n", + " s[\"hours\"] = s[\"hours\"] - (s[\"h_since\"] - h)\n", + " s[\"km\"] = s[\"km\"] * ratio.clip(upper=1)\n", + " s[\"unpl\"] = np.floor(s[\"unpl\"] * ratio)\n", + " s[\"n_maint\"] = np.floor(s[\"n_maint\"] * ratio)\n", + " s[\"insp\"] = np.clip(h / s[\"t_fail\"] + rng.normal(0, INSP_NOISE, len(s)), 0, 1.3)\n", + " s[\"h_since\"] = h\n", + " s[\"rul_d\"] = (s[\"t_fail\"] - h) / s[\"rate\"]\n", + " s[\"event\"] = 0\n", + " out.append(s)\n", + " return pd.concat(out, ignore_index=True)\n", + "# ---------------------------------------------------------------------------\n", + "# ---------------------------------------------------------------------------\n", + "# 생존 분석 도구(scipy만 사용): 와이블 AFT(가속 수명) 모형과 Cox 비례위험 모형\n", + "# ---------------------------------------------------------------------------\n", + "from scipy.optimize import minimize\n", + "\n", + "\n", + "def aft_x(d):\n", + " return np.c_[np.ones(len(d)), d[\"env\"].eq(\"rough\"), d[\"env\"].eq(\"salt\"), d[\"env\"].eq(\"cold\"), d[\"unpl\"] / (d[\"h_since\"] / 1000 + 0.5)].astype(float)\n", + "\n", + "\n", + "AFT_COLS = [\"절편\", \"험지\", \"염분\", \"혹한\", \"비계획 정비율\"]\n", + "\n", + "\n", + "def fit_aft(d):\n", + " \"\"\"창정비 후 운용시간 dur(고장이면 event=1, 생존이면 중도절단)에 대한 와이블 AFT 최대우도 추정. 반환: 계수(마지막은 log 형상).\"\"\"\n", + " x, t, e = aft_x(d), d[\"dur\"].values, d[\"event\"].values\n", + "\n", + " def nll(p):\n", + " lam, k = np.exp(x @ p[:-1]), np.exp(p[-1])\n", + " z = (t / lam) ** k\n", + " return -(e * (np.log(k) - np.log(lam) + (k - 1) * (np.log(t) - np.log(lam))) - z).sum()\n", + "\n", + " r = minimize(nll, np.r_[np.log(8000), np.zeros(x.shape[1] - 1), np.log(5)], method=\"BFGS\")\n", + " return r.x\n", + "\n", + "\n", + "def aft_pred(p, d, q=0.5, h=None):\n", + " \"\"\"이미 h(운용시간)까지 생존했다는 조건에서 분위수 q 생존 시점(운용시간 h 기준 총 수명)을 돌려준다. q=0.5가 조건부 중앙값.\"\"\"\n", + " lam, k = np.exp(aft_x(d) @ p[:-1]), np.exp(p[-1])\n", + " h = d[\"h_since\"].values if h is None else h\n", + " return lam * ((h / lam) ** k - np.log(q)) ** (1 / k)\n", + "\n", + "\n", + "def aft_fail_prob(p, d, h0, h1):\n", + " \"\"\"h0까지 생존한 장비가 h0~h1 사이에 고장 날 조건부 확률.\"\"\"\n", + " lam, k = np.exp(aft_x(d) @ p[:-1]), np.exp(p[-1])\n", + " s0, s1 = np.exp(-(h0 / lam) ** k), np.exp(-(h1 / lam) ** k)\n", + " return 1 - s1 / s0\n", + "\n", + "\n", + "def fit_cox(d, iters=25):\n", + " \"\"\"Cox 비례위험(Breslow 근사) 뉴턴-랩슨. 공변량: 험지·염분·혹한·비계획 정비율. 반환: 계수.\"\"\"\n", + " x = aft_x(d)[:, 1:]\n", + " o = np.argsort(-d[\"dur\"].values)\n", + " x, e = x[o], d[\"event\"].values[o]\n", + " b = np.zeros(x.shape[1])\n", + " for _ in range(iters):\n", + " r = np.exp(x @ b)\n", + " S0 = np.cumsum(r)\n", + " S1 = np.cumsum(r[:, None] * x, axis=0)\n", + " S2 = np.cumsum(r[:, None, None] * x[:, :, None] * x[:, None, :], axis=0)\n", + " m1 = S1 / S0[:, None]\n", + " g = ((x - m1) * e[:, None]).sum(axis=0)\n", + " H = -sum(e[i] * (S2[i] / S0[i] - np.outer(m1[i], m1[i])) for i in np.where(e == 1)[0])\n", + " step = np.linalg.solve(H - 1e-6 * np.eye(len(b)), g)\n", + " b = b - step\n", + " if np.abs(step).max() < 1e-7:\n", + " break\n", + " return b\n", + "\n", + "\n", + "def cindex(time, event, risk):\n", + " \"\"\"일치도 C-index: 위험 점수가 높은 쪽이 먼저 고장 났는가(관측 가능한 쌍만).\"\"\"\n", + " t, e, r = np.asarray(time), np.asarray(event), np.asarray(risk)\n", + " num = den = 0.0\n", + " for i in np.where(e == 1)[0]:\n", + " m = t > t[i]\n", + " den += m.sum()\n", + " num += (r[m] < r[i]).sum() + 0.5 * (r[m] == r[i]).sum()\n", + " return num / max(den, 1)\n", + "# ---------------------------------------------------------------------------\n", + "\n", + "tr = make_fleet(900, seed=1)\n", + "te = make_fleet(600, seed=2)\n", + "derived = [c for c in FEATS if c in (\"unpl_per_kh\", \"km_per_h\", \"k_rough\", \"k_salt\", \"k_cold\", \"k_truck\", \"k_gen\", \"k_trailer\", \"aging_idx\", \"lin_rul_d\", \"hs_frac\", \"insp_eta_h\")]\n", + "print(f\"[1] 파생 피처 {len(derived)}개(환경·장비 더미, 비계획 정비율, 환경 가중 노후 지수, 점검 환산 수명 등) / 학습 {len(tr)}대(고장 {int(tr.event.sum())}대, 중도절단 {int((1 - tr.event).sum())}대), 평가 {len(te)}대\")\n", + "\n", + "# ---------------- 1) 와이블 AFT·Cox 추정 ----------------\n", + "p = fit_aft(tr)\n", + "b = fit_cox(tr)\n", + "k = float(np.exp(p[-1]))\n", + "acc = pd.DataFrame({\"AFT 수명비 exp(b)\": np.exp(p[1:5]), \"숨은 정답 수명비\": [1 / ENV_W[\"rough\"], 1 / ENV_W[\"salt\"], 1 / ENV_W[\"cold\"], np.nan], \"Cox 위험비\": np.exp(b)}, index=AFT_COLS[1:])\n", + "print(f\"[2] 와이블 AFT 형상 k={k:.2f}(1보다 크면 마모형 고장), 기준 수명(일반 환경) {np.exp(p[0]):.0f}h\")\n", + "print(acc.round(3).to_string())\n", + "print(\" 환경 가속(수명비 < 1)이 가혹 환경일수록 크며, 숨은 정답(1/가중치)과 비슷하게 복원된다. 비계획 정비율은 수명을 줄이는 방향이다.\")\n", + "\n", + "# ---------------- 2) 고장 확률 곡선 ----------------\n", + "grid = np.array([5000, 6000, 7000, 8000, 9000, 10000])\n", + "typ = pd.DataFrame({\"env\": ENVS, \"unpl\": 1.0, \"h_since\": 1000.0})\n", + "typ[\"unpl\"] = 1.0 * (typ[\"h_since\"] / 1000 + 0.5)\n", + "lam = np.exp(aft_x(typ) @ p[:-1])\n", + "curves = pd.DataFrame({f\"{h}h\": 1 - np.exp(-(h / lam) ** k) for h in grid}, index=ENVS)\n", + "b10 = lam * (-np.log(0.9)) ** (1 / k)\n", + "print(\"\\n[3] 창정비 후 운용시간별 누적 고장 확률(비계획 정비율 1.0/1000h 장비)\")\n", + "print(curves.apply(lambda c: c.map(\"{:.0%}\".format)).to_string())\n", + "print(\" B10 수명(고장 10% 시점): \" + \", \".join(f\"{e} {v:.0f}h\" for e, v in zip(ENVS, b10)) + f\" / 규정 주기 {CYCLE:.0f}h 시점 고장 확률 일반 {curves.loc['normal', '8000h']:.0%}, 험지 {curves.loc['rough', '8000h']:.0%}\")\n", + "\n", + "# ---------------- 3) 평가: 기대수명 MAPE, C-index ----------------\n", + "zero = np.full(len(te), 1e-6)\n", + "life = aft_pred(p, te, 0.5, h=zero)\n", + "m1, m0 = mape(te.t_fail, life), mape(te.t_fail, np.full(len(te), CYCLE))\n", + "x = aft_x(te)[:, 1:]\n", + "ci_cox, ci_aft = cindex(te.dur, te.event, x @ b), cindex(te.dur, te.event, -life)\n", + "ci_base = cindex(te.dur, te.event, np.zeros(len(te)))\n", + "print(f\"\\n[4] 평가(숨은 정답 t_fail은 모의 평가에만 사용): 기대수명(중앙값) MAPE {m1:.1f}% (목표 25% 이하: {'달성' if m1 <= 25 else '미달'}), 규정 주기 {CYCLE:.0f}h를 모두에 적용하면 {m0:.1f}%\")\n", + "print(f\" C-index: AFT {ci_aft:.3f}, Cox {ci_cox:.3f}, 환경 무시(무차별) {ci_base:.3f} (0.5 = 무작위)\")\n", + "by = pd.DataFrame({\"환경\": te.env, \"정답 평균\": te.t_fail, \"AFT\": life, \"규정 주기\": CYCLE}).groupby(\"환경\").mean().round(0)\n", + "print(\" 환경별 평균 수명(h):\\n\" + by.to_string())\n", + "\n", + "# ---------------- 4) 1년 내 고장 확률 보정(생존 장비) ----------------\n", + "a = te[te.event == 0].copy()\n", + "a[\"p1y\"] = aft_fail_prob(p, a, a[\"h_since\"].values, a[\"h_since\"].values + 365 * a[\"rate\"].values)\n", + "a[\"real\"] = (a[\"rul_d\"] < 365).astype(int)\n", + "a[\"bin\"] = pd.qcut(a[\"p1y\"], 5, labels=False, duplicates=\"drop\")\n", + "cal = a.groupby(\"bin\").agg(예측확률=(\"p1y\", \"mean\"), 실제비율=(\"real\", \"mean\"), 대수=(\"real\", \"size\"))\n", + "print(\"\\n[5] 1년 내 고장 확률 보정(생존 장비, 확률 5분위)\\n\" + cal.round(2).to_string())\n", + "brier = float(((a[\"p1y\"] - a[\"real\"]) ** 2).mean())\n", + "print(f\" Brier 점수 {brier:.3f}(무정보 기준 {float(a['real'].mean() * (1 - a['real'].mean())):.3f}). 점검 마모를 쓰지 않는 생존 모형은 확률의 순위는 맞지만 개별 장비의 시점은 거칠다 -> M5에서 점검 마모 피처로 보강한다.\")\n", + "\n", + "# ---------------- 5) 변수 제거 ----------------\n", + "def ablate(cols):\n", + " keep = [0] + cols\n", + " sub_x = aft_x(tr)[:, keep]; t, e = tr[\"dur\"].values, tr[\"event\"].values\n", + " from scipy.optimize import minimize\n", + "\n", + " def nll(q):\n", + " lm, kk = np.exp(sub_x @ q[:-1]), np.exp(q[-1]); z = (t / lm) ** kk\n", + " return -(e * (np.log(kk) - np.log(lm) + (kk - 1) * (np.log(t) - np.log(lm))) - z).sum()\n", + "\n", + " q = minimize(nll, np.r_[np.log(8000), np.zeros(len(keep) - 1), np.log(5)], method=\"BFGS\").x\n", + " lm, kk = np.exp(aft_x(te)[:, keep] @ q[:-1]), np.exp(q[-1])\n", + " med = lm * np.log(2) ** (1 / kk)\n", + " return mape(te.t_fail, med), cindex(te.dur, te.event, -med)\n", + "\n", + "\n", + "rows = [(\"전체(환경+비계획 정비율)\", *ablate([1, 2, 3, 4])), (\"환경 제거\", *ablate([4])), (\"비계획 정비율 제거\", *ablate([1, 2, 3])), (\"모두 제거(절편만)\", *ablate([]))]\n", + "print(\"\\n[6] 변수 제거 실험\\n\" + pd.DataFrame(rows, columns=[\"모형\", \"기대수명 MAPE(%)\", \"C-index\"]).round(3).to_string(index=False))\n", + "\n", + "fig, ax = plt.subplots(1, 3, figsize=(15, 4.3))\n", + "hh = np.linspace(2000, 11000, 100)\n", + "for e, l in zip(ENVS, lam):\n", + " ax[0].plot(hh, 1 - np.exp(-(hh / l) ** k), label=e)\n", + "ax[0].axvline(CYCLE, c=\"k\", ls=\"--\"); ax[0].legend(); ax[0].set_title(\"Failure probability by environment (h since overhaul)\")\n", + "ax[1].plot(cal[\"예측확률\"], cal[\"실제비율\"], \"o-\"); ax[1].plot([0, 1], [0, 1], \"k--\"); ax[1].set_title(\"1-year failure probability calibration\")\n", + "ax[2].bar([\"rough\", \"salt\", \"cold\", \"unplanned rate\"], np.exp(p[1:5])); ax[2].axhline(1, c=\"k\", ls=\"--\"); ax[2].set_title(\"AFT life ratio vs normal (exp(b))\"); ax[2].tick_params(axis=\"x\", rotation=20)\n", + "plt.tight_layout(); plt.show()\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 04 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + "[1] 파생 피처 12개(환경·장비 더미, 비계획 정비율, 환경 가중 노후 지수, 점검 환산 수명 등) / 학습 900대(고장 288대, 중도절단 612대), 평가 600대\n", + "[2] 와이블 AFT 형상 k=10.83(1보다 크면 마모형 고장), 기준 수명(일반 환경) 9602h\n", + " AFT 수명비 exp(b) 숨은 정답 수명비 Cox 위험비\n", + "험지 0.752 0.741 20.984\n", + "염분 0.802 0.800 10.983\n", + "혹한 0.878 0.893 4.253\n", + "비계획 정비율 0.889 NaN 3.418\n", + " 환경 가속(수명비 < 1)이 가혹 환경일수록 크며, 숨은 정답(1/가중치)과 비슷하게 복원된다. 비계획 정비율은 수명을 줄이는 방향이다.\n", + "\n", + "[3] 창정비 후 운용시간별 누적 고장 확률(비계획 정비율 1.0/1000h 장비)\n", + " 5000h 6000h 7000h 8000h 9000h 10000h\n", + "normal 0% 2% 11% 39% 83% 100%\n", + "rough 6% 38% 92% 100% 100% 100%\n", + "salt 3% 21% 72% 100% 100% 100%\n", + "cold 1% 9% 38% 87% 100% 100%\n", + " B10 수명(고장 10% 시점): normal 6936h, rough 5218h, salt 5560h, cold 6089h / 규정 주기 8000h 시점 고장 확률 일반 39%, 험지 100%\n", + "\n", + "[4] 평가(숨은 정답 t_fail은 모의 평가에만 사용): 기대수명(중앙값) MAPE 10.2% (목표 25% 이하: 달성), 규정 주기 8000h를 모두에 적용하면 15.2%\n", + " C-index: AFT 0.767, Cox 0.767, 환경 무시(무차별) 0.500 (0.5 = 무작위)\n", + " 환경별 평균 수명(h):\n", + " 정답 평균 AFT 규정 주기\n", + "환경 \n", + "cold 7969.0 7890.0 8000.0\n", + "normal 8992.0 9046.0 8000.0\n", + "rough 6563.0 6700.0 8000.0\n", + "salt 7231.0 7167.0 8000.0\n", + "\n", + "[5] 1년 내 고장 확률 보정(생존 장비, 확률 5분위)\n", + " 예측확률 실제비율 대수\n", + "bin \n", + "0 0.00 0.00 78\n", + "1 0.00 0.00 78\n", + "2 0.01 0.00 78\n", + "3 0.09 0.05 78\n", + "4 0.64 0.59 78\n", + " Brier 점수 0.045(무정보 기준 0.112). 점검 마모를 쓰지 않는 생존 모형은 확률의 순위는 맞지만 개별 장비의 시점은 거칠다 -> M5에서 점검 마모 피처로 보강한다.\n", + "\n", + "[6] 변수 제거 실험\n", + " 모형 기대수명 MAPE(%) C-index\n", + "전체(환경+비계획 정비율) 10.152 0.767\n", + "... (이하 생략)\n", + "```\n", + "
\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 05. 예산 및 정비창 능력 연계 최적 계획정비(Overhaul) 일정 추천 엔진 개발\n", + "**소요시간**: 약 3.5시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "정비창 능력 연계 최적 계획정비(Overhaul) 일정 추천 엔진을 개발한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **예산과 정비창 능력을 연계한 12개월 계획정비 일정 추천 엔진을 개발한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **와이블 AFT와 점검 마모 피처를 쓰는 GBR을 앙상블해 잔여 수명을 예측하고 선형 감가상각 기준과 비교 수행하고 검증하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `src/models/sup02_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": [ + "\"\"\"와이블 AFT와 점검 마모 피처를 쓰는 그래디언트 부스팅을 결합한 잔여 수명(RUL) 예측 엔진을 기준 모델 대비 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\n", + "from sklearn.metrics import r2_score\n", + "\n", + "# ---------------------------------------------------------------------------\n", + "# [교육용가상자료] 가상 장비 보유 현황·고장 이력 생성기 (실제 부대·장비·수치 없음)\n", + "# 가상 부대 6개(U1~U6)의 장비가 운용 환경(normal/rough/salt/cold)에 따라 다른 속도로 노후화한다. 창정비 후 운용시간(h_since)이\n", + "# 장비마다 숨은 수명(t_fail)을 넘으면 고장(event=1) 이력, 넘지 않았으면 현재까지 생존(event=0, 관측 중도절단)이다.\n", + "# ---------------------------------------------------------------------------\n", + "ENVS = [\"normal\", \"rough\", \"salt\", \"cold\"]\n", + "ENV_W = {\"normal\": 1.00, \"rough\": 1.35, \"salt\": 1.25, \"cold\": 1.12} # 환경 가중치(숨은 정답, 가정값): 클수록 수명이 짧다\n", + "ENV_KO = {\"normal\": \"일반\", \"rough\": \"험지\", \"salt\": \"염분\", \"cold\": \"혹한\"}\n", + "UNITS = [\"U1\", \"U2\", \"U3\", \"U4\", \"U5\", \"U6\"]\n", + "UNIT_ENV = {\"U1\": [.7, .1, .1, .1], \"U2\": [.2, .6, .1, .1], \"U3\": [.2, .1, .6, .1], \"U4\": [.3, .1, .1, .5], \"U5\": [.5, .3, .1, .1], \"U6\": [.4, .1, .4, .1]}\n", + "ETYPES = [\"truck\", \"generator\", \"trailer\", \"radio\"]\n", + "CYCLE = 8000.0 # 규정 창정비 주기(운용시간 h, 가정값)\n", + "KSHAPE = 12.0 # 수명 분포 와이블 형상(마모형 고장, 클수록 수명이 모여 있다)\n", + "INSP_NOISE = 0.025 # 정비 점검 마모 비율 측정 오차(가정값)\n", + "\n", + "\n", + "def make_fleet(n=900, seed=0, env_w=None, shift=1.0):\n", + " \"\"\"장비 n대. env_w로 숨은 환경 가중치를 바꿔 운영 분포 변화를 모의할 수 있다. shift>1이면 가동률이 높아진다.\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " w = env_w or ENV_W\n", + " unit = rng.choice(UNITS, n)\n", + " env = np.array([rng.choice(ENVS, p=UNIT_ENV[u]) for u in unit])\n", + " et = rng.choice(ETYPES, n, p=[.4, .2, .2, .2])\n", + " rate = rng.lognormal(np.log(3.0 * shift), 0.25, n) # 일 평균 운용시간(h/일)\n", + " h_since = rng.uniform(300, 11500, n) # 마지막 창정비 이후 운용시간\n", + " n_ovh = rng.integers(0, 3, n)\n", + " hours = h_since + n_ovh * rng.uniform(5000, 8000, n) # 누적 운용시간\n", + " km = hours * rng.normal(17, 3, n) * np.where(env == \"rough\", 0.8, 1.0) # 누적 기동거리(km)\n", + " age_y = np.clip(hours / (rate * 365) * rng.uniform(0.9, 1.15, n), 0.5, 25)\n", + " unpl = rng.poisson(0.35 * h_since / 1000 * np.array([w[e] for e in env]) / 1.3) # 비계획 정비 건수(환경이 가혹할수록 많다)\n", + " n_maint = rng.poisson(0.6 * h_since / 1000 + 1) # 계획 정비 건수\n", + " scale = 9600 / np.array([w[e] for e in env]) * 0.985 ** unpl * np.exp(rng.normal(0, 0.04, n))\n", + " t_fail = scale * rng.weibull(KSHAPE, n) # 창정비 후 고장까지 운용시간(숨은 정답)\n", + " event = (t_fail <= h_since).astype(int)\n", + " dur = np.where(event == 1, t_fail, h_since)\n", + " df = pd.DataFrame({\"eq_id\": [f\"EQ{seed:02d}{i:04d}\" for i in range(n)], \"unit\": unit, \"env\": env, \"etype\": et, \"rate\": rate, \"hours\": hours, \"km\": km, \"age_y\": age_y, \"n_ovh\": n_ovh,\n", + " \"h_since\": h_since, \"unpl\": unpl, \"n_maint\": n_maint, \"t_fail\": t_fail, \"event\": event, \"dur\": dur})\n", + " df[\"insp\"] = np.clip(dur / t_fail + rng.normal(0, INSP_NOISE, n), 0, 1.3) # 마지막 정비 점검에서 측정한 마모 비율(소모 수명/총 수명, 오차 포함)\n", + " df[\"rul_h\"] = np.where(event == 0, t_fail - h_since, np.nan) # 생존 장비의 남은 운용시간(정답)\n", + " df[\"rul_d\"] = df[\"rul_h\"] / df[\"rate\"] # 남은 일수(정답)\n", + " return df\n", + "\n", + "\n", + "def mape(y, p):\n", + " # [TODO : 학생 실습 5] 예산 및 정비창 능력 연계 최적 계획정비(Overhaul) 일정 추천 엔진 개발 관련 핵심 로직을 구현하세요.\n", + " # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n", + " # <<< 여기에 코드를 작성하세요 >>>\n", + " pass # 작성 후 pass를 제거하세요.\n", + "def mae(y, p):\n", + " # [TODO : 학생 실습 5] 예산 및 정비창 능력 연계 최적 계획정비(Overhaul) 일정 추천 엔진 개발 관련 핵심 로직을 구현하세요.\n", + " # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n", + " # <<< 여기에 코드를 작성하세요 >>>\n", + " pass # 작성 후 pass를 제거하세요.\n", + "def rmse(y, p):\n", + " return float(np.sqrt(np.mean((np.asarray(y, float) - np.asarray(p, float)) ** 2)))\n", + "# ---------------------------------------------------------------------------\n", + "# ---------------------------------------------------------------------------\n", + "# 노후화 파생 피처와 학습 표본 만들기. 환경 가중치 W0는 도입 시점의 전문가 추정값이며(실제 가중치와 다름) M9에서 재보정한다.\n", + "# ---------------------------------------------------------------------------\n", + "W0 = {\"normal\": 1.00, \"rough\": 1.15, \"salt\": 1.10, \"cold\": 1.05}\n", + "FEATS = [\"h_since\", \"hours\", \"km\", \"age_y\", \"rate\", \"n_maint\", \"unpl\", \"unpl_per_kh\", \"km_per_h\", \"n_ovh\", \"k_rough\", \"k_salt\", \"k_cold\", \"k_truck\", \"k_gen\", \"k_trailer\",\n", + " \"aging_idx\", \"lin_rul_d\", \"hs_frac\", \"insp\", \"insp_eta_h\"]\n", + "\n", + "\n", + "def add_features(df, w=None):\n", + " d = df.copy()\n", + " w = w or W0\n", + " d[\"unpl_per_kh\"] = d[\"unpl\"] / (d[\"h_since\"] / 1000 + 0.5)\n", + " d[\"km_per_h\"] = d[\"km\"] / d[\"hours\"]\n", + " for e in (\"rough\", \"salt\", \"cold\"):\n", + " d[\"k_\" + e] = (d[\"env\"] == e).astype(int)\n", + " d[\"k_truck\"], d[\"k_gen\"], d[\"k_trailer\"] = (d[\"etype\"] == \"truck\").astype(int), (d[\"etype\"] == \"generator\").astype(int), (d[\"etype\"] == \"trailer\").astype(int)\n", + " d[\"aging_idx\"] = d[\"h_since\"] * d[\"env\"].map(w) / CYCLE # 환경 가중 노후 지수(1이면 창정비 주기에 도달)\n", + " d[\"lin_rul_d\"] = (CYCLE - d[\"h_since\"]) / d[\"rate\"] # 선형 감가상각 잔여 일수(Baseline)\n", + " d[\"hs_frac\"] = d[\"h_since\"] / CYCLE\n", + " d[\"insp_eta_h\"] = d[\"h_since\"] / np.maximum(d[\"insp\"], 0.05) # 점검 마모 비율로 환산한 총 수명 추정(h)\n", + " return d\n", + "\n", + "\n", + "def snapshots(df, k=4, seed=0):\n", + " \"\"\"고장 이력 장비의 과거 시점 표본: 고장 전 임의 시점의 점검값·운용시간과 그때의 남은 일수. (생존 장비의 미래는 알 수 없으므로 고장 이력만 쓴다)\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " f = df[df[\"event\"] == 1]\n", + " out = []\n", + " for _ in range(k):\n", + " s = f.copy()\n", + " h = s[\"t_fail\"] * rng.uniform(0.2, 0.98, len(s))\n", + " ratio = h / s[\"h_since\"]\n", + " s[\"hours\"] = s[\"hours\"] - (s[\"h_since\"] - h)\n", + " s[\"km\"] = s[\"km\"] * ratio.clip(upper=1)\n", + " s[\"unpl\"] = np.floor(s[\"unpl\"] * ratio)\n", + " s[\"n_maint\"] = np.floor(s[\"n_maint\"] * ratio)\n", + " s[\"insp\"] = np.clip(h / s[\"t_fail\"] + rng.normal(0, INSP_NOISE, len(s)), 0, 1.3)\n", + " s[\"h_since\"] = h\n", + " s[\"rul_d\"] = (s[\"t_fail\"] - h) / s[\"rate\"]\n", + " s[\"event\"] = 0\n", + " out.append(s)\n", + " return pd.concat(out, ignore_index=True)\n", + "# ---------------------------------------------------------------------------\n", + "# ---------------------------------------------------------------------------\n", + "# 생존 분석 도구(scipy만 사용): 와이블 AFT(가속 수명) 모형과 Cox 비례위험 모형\n", + "# ---------------------------------------------------------------------------\n", + "from scipy.optimize import minimize\n", + "\n", + "\n", + "def aft_x(d):\n", + " return np.c_[np.ones(len(d)), d[\"env\"].eq(\"rough\"), d[\"env\"].eq(\"salt\"), d[\"env\"].eq(\"cold\"), d[\"unpl\"] / (d[\"h_since\"] / 1000 + 0.5)].astype(float)\n", + "\n", + "\n", + "AFT_COLS = [\"절편\", \"험지\", \"염분\", \"혹한\", \"비계획 정비율\"]\n", + "\n", + "\n", + "def fit_aft(d):\n", + " \"\"\"창정비 후 운용시간 dur(고장이면 event=1, 생존이면 중도절단)에 대한 와이블 AFT 최대우도 추정. 반환: 계수(마지막은 log 형상).\"\"\"\n", + " x, t, e = aft_x(d), d[\"dur\"].values, d[\"event\"].values\n", + "\n", + " def nll(p):\n", + " lam, k = np.exp(x @ p[:-1]), np.exp(p[-1])\n", + " z = (t / lam) ** k\n", + " return -(e * (np.log(k) - np.log(lam) + (k - 1) * (np.log(t) - np.log(lam))) - z).sum()\n", + "\n", + " r = minimize(nll, np.r_[np.log(8000), np.zeros(x.shape[1] - 1), np.log(5)], method=\"BFGS\")\n", + " return r.x\n", + "\n", + "\n", + "def aft_pred(p, d, q=0.5, h=None):\n", + " \"\"\"이미 h(운용시간)까지 생존했다는 조건에서 분위수 q 생존 시점(운용시간 h 기준 총 수명)을 돌려준다. q=0.5가 조건부 중앙값.\"\"\"\n", + " lam, k = np.exp(aft_x(d) @ p[:-1]), np.exp(p[-1])\n", + " h = d[\"h_since\"].values if h is None else h\n", + " return lam * ((h / lam) ** k - np.log(q)) ** (1 / k)\n", + "\n", + "\n", + "def aft_fail_prob(p, d, h0, h1):\n", + " \"\"\"h0까지 생존한 장비가 h0~h1 사이에 고장 날 조건부 확률.\"\"\"\n", + " lam, k = np.exp(aft_x(d) @ p[:-1]), np.exp(p[-1])\n", + " s0, s1 = np.exp(-(h0 / lam) ** k), np.exp(-(h1 / lam) ** k)\n", + " return 1 - s1 / s0\n", + "\n", + "\n", + "def fit_cox(d, iters=25):\n", + " \"\"\"Cox 비례위험(Breslow 근사) 뉴턴-랩슨. 공변량: 험지·염분·혹한·비계획 정비율. 반환: 계수.\"\"\"\n", + " x = aft_x(d)[:, 1:]\n", + " o = np.argsort(-d[\"dur\"].values)\n", + " x, e = x[o], d[\"event\"].values[o]\n", + " b = np.zeros(x.shape[1])\n", + " for _ in range(iters):\n", + " r = np.exp(x @ b)\n", + " S0 = np.cumsum(r)\n", + " S1 = np.cumsum(r[:, None] * x, axis=0)\n", + " S2 = np.cumsum(r[:, None, None] * x[:, :, None] * x[:, None, :], axis=0)\n", + " m1 = S1 / S0[:, None]\n", + " g = ((x - m1) * e[:, None]).sum(axis=0)\n", + " H = -sum(e[i] * (S2[i] / S0[i] - np.outer(m1[i], m1[i])) for i in np.where(e == 1)[0])\n", + " step = np.linalg.solve(H - 1e-6 * np.eye(len(b)), g)\n", + " b = b - step\n", + " if np.abs(step).max() < 1e-7:\n", + " break\n", + " return b\n", + "\n", + "\n", + "def cindex(time, event, risk):\n", + " \"\"\"일치도 C-index: 위험 점수가 높은 쪽이 먼저 고장 났는가(관측 가능한 쌍만).\"\"\"\n", + " t, e, r = np.asarray(time), np.asarray(event), np.asarray(risk)\n", + " num = den = 0.0\n", + " for i in np.where(e == 1)[0]:\n", + " m = t > t[i]\n", + " den += m.sum()\n", + " num += (r[m] < r[i]).sum() + 0.5 * (r[m] == r[i]).sum()\n", + " return num / max(den, 1)\n", + "# ---------------------------------------------------------------------------\n", + "# ---------------------------------------------------------------------------\n", + "# 잔여 수명(RUL, 일) 예측 엔진: 와이블 AFT 조건부 중앙값 + 그래디언트 부스팅(고장 이력 시점 표본 학습)의 평균\n", + "# ---------------------------------------------------------------------------\n", + "from sklearn.ensemble import GradientBoostingRegressor\n", + "\n", + "\n", + "def fit_engine(train, seed=0):\n", + " p = fit_aft(train)\n", + " sn = add_features(snapshots(train, 5, seed))\n", + " sn[\"aft\"] = (aft_pred(p, sn) - sn[\"h_since\"]) / sn[\"rate\"]\n", + " g = GradientBoostingRegressor(n_estimators=250, max_depth=3, learning_rate=0.06, random_state=0).fit(sn[FEATS + [\"aft\"]], sn[\"rul_d\"])\n", + " return {\"p\": p, \"g\": g}\n", + "\n", + "\n", + "def predict_rul(eng, d):\n", + " \"\"\"d는 add_features를 거친 표. 반환: (앙상블 RUL, AFT RUL, GBR RUL) - 일 단위.\"\"\"\n", + " aft = ((aft_pred(eng[\"p\"], d) - d[\"h_since\"]) / d[\"rate\"]).values\n", + " x = d[FEATS].copy()\n", + " x[\"aft\"] = aft\n", + " gb = eng[\"g\"].predict(x)\n", + " return np.clip(0.5 * aft + 0.5 * gb, 1, None), aft, gb\n", + "# ---------------------------------------------------------------------------\n", + "# ---------------------------------------------------------------------------\n", + "# 계획정비(창정비) 일정 추천: 월별 정비창 능력·예산·시효성 부품 키트(유효기한) 제약 아래 기대 비용 최소화 (비용 단가는 교육용 가정값, 만원)\n", + "# ---------------------------------------------------------------------------\n", + "from scipy.optimize import milp, LinearConstraint, Bounds\n", + "from scipy.stats import norm\n", + "\n", + "C_PLAN, C_FAIL, C_WASTE_KH, C_KIT = 600.0, 2400.0, 40.0, 120.0 # 계획 창정비, 비계획 고장(긴급 정비 포함), 미사용 수명 1000h당 손실, 시효성 부품 키트\n", + "HORIZON, MDAYS = 12, 30 # 계획 12개월, 월 30일\n", + "CAP_M, BUDGET_M = 22, 15000.0 # 월별 정비창 능력(대), 월별 예산\n", + "LOTS = [(30, 70), (45, 170), (45, 390), (70, 800)] # 시효성 키트 로트(수량, 유효기한까지 남은 일수)\n", + "KIT_MARGIN = 14 # 정비일 기준 최소 유효기간 여유(일)\n", + "\n", + "\n", + "def exp_cost_matrix(pred, rate, month_days):\n", + " \"\"\"장비 i를 월 m에 정비할 때의 기대 비용. 참 RUL ~ N(pred, sigma), sigma = 35일 + 0.12 x pred (가정). 마지막 열은 계획 기간 밖(미정비).\"\"\"\n", + " sig = 35 + 0.12 * pred\n", + " C = np.zeros((len(pred), len(month_days) + 1))\n", + " for j, day in enumerate(month_days):\n", + " z = (day - pred) / sig\n", + " pf = norm.cdf(z)\n", + " unused = np.maximum(pred - day, 0) * rate * 0.6 # 정비를 앞당긴 만큼 버리는 운용시간 h(근사)\n", + " C[:, j] = C_PLAN + C_KIT + pf * (C_FAIL - C_PLAN) + C_WASTE_KH * unused / 1000 * (1 - pf)\n", + " C[:, -1] = norm.cdf((HORIZON * MDAYS - pred) / sig) * C_FAIL\n", + " return C\n", + "\n", + "\n", + "def plan_milp(pred, rate, cap=CAP_M, budget=BUDGET_M, lots=LOTS, use_kit=True):\n", + " \"\"\"정수계획으로 장비별 정비 월(0~11) 또는 미정비(-1)를 배정한다. 후보는 예측 RUL이 계획 기간 +90일 이내인 장비.\"\"\"\n", + " md = np.arange(1, HORIZON + 1) * MDAYS\n", + " cand = np.where(pred < HORIZON * MDAYS + 90)[0]\n", + " n, M = len(cand), HORIZON\n", + " C = exp_cost_matrix(pred[cand], rate[cand], md)\n", + " c = C.ravel()\n", + " A, lo, hi = [], [], []\n", + " for i in range(n): # 장비당 한 번만 배정(월 12칸 + 미정비)\n", + " row = np.zeros(n * (M + 1)); row[i * (M + 1):(i + 1) * (M + 1)] = 1; A.append(row); lo.append(1); hi.append(1)\n", + " for m in range(M): # 월별 능력·예산\n", + " row = np.zeros(n * (M + 1)); row[m::(M + 1)] = 1\n", + " A.append(row); lo.append(0); hi.append(cap)\n", + " A.append(row * (C_PLAN + C_KIT)); lo.append(0); hi.append(budget)\n", + " if use_kit: # 시효성 키트: m월 이후 정비 수요는 그 시점에 유효기간이 남은 로트 수량을 넘을 수 없다\n", + " for m in range(M):\n", + " sup = sum(q for q, e in lots if e >= md[m] + KIT_MARGIN)\n", + " row = np.zeros(n * (M + 1))\n", + " for mm in range(m, M):\n", + " row[mm::(M + 1)] = 1\n", + " A.append(row); lo.append(0); hi.append(sup)\n", + " r = milp(c, constraints=LinearConstraint(np.array(A), lo, hi), integrality=np.ones(len(c)), bounds=Bounds(0, 1), options={\"time_limit\": 20})\n", + " month = np.full(len(pred), -1)\n", + " if r.x is not None:\n", + " x = np.round(r.x).reshape(n, M + 1)\n", + " assign = np.where(x[:, :M].sum(axis=1) > 0.5, x[:, :M].argmax(axis=1), -1)\n", + " month[cand] = assign\n", + " return month\n", + "\n", + "\n", + "def plan_greedy(pred, margin=45, cap=CAP_M, budget=BUDGET_M):\n", + " \"\"\"간단한 탐욕 배정: 정비 목표일(예측 RUL - margin)이 빠른 순으로 목표일 이전 가장 늦은 빈 월, 없으면 가장 이른 빈 월에 배정.\"\"\"\n", + " cap = min(cap, int(budget // (C_PLAN + C_KIT))) # 월 예산이 허용하는 대수로 능력을 제한\n", + " month = np.full(len(pred), -1)\n", + " left = np.full(HORIZON, cap)\n", + " for i in np.argsort(pred):\n", + " if pred[i] > HORIZON * MDAYS + 60: break\n", + " tgt = int(np.clip((pred[i] - margin) // MDAYS - 1, 0, HORIZON - 1))\n", + " for m in list(range(tgt, -1, -1)) + list(range(tgt + 1, HORIZON)):\n", + " if left[m] > 0:\n", + " month[i] = m; left[m] -= 1; break\n", + " return month\n", + "\n", + "\n", + "def plan_fixed(lin_rul, cap=CAP_M, budget=BUDGET_M):\n", + " \"\"\"관행: 규정 주기 도달 순서대로(선형 잔여 일수) 월별 능력 안에서 배정.\"\"\"\n", + " return plan_greedy(np.asarray(lin_rul, float), margin=0, cap=cap, budget=budget)\n", + "\n", + "\n", + "def evaluate_plan(month, true_rul, rate, cost_kit=True):\n", + " \"\"\"참 RUL로 계획의 실제 비용을 계산한다: 정비일 이전에 고장 -> 비계획 고장 비용, 아니면 계획 비용 + 미사용 수명 손실.\"\"\"\n", + " day = np.where(month >= 0, (month + 1) * MDAYS, 10 ** 9)\n", + " fail = (true_rul < day) & (true_rul < HORIZON * MDAYS)\n", + " planned = (month >= 0) & ~fail\n", + " waste = np.where(planned, (true_rul - day) * rate * 0.6, 0) * C_WASTE_KH / 1000\n", + " cost = fail * C_FAIL + planned * (C_PLAN + (C_KIT if cost_kit else 0)) + waste\n", + " return {\"total\": float(cost.sum()), \"fail\": int(fail.sum()), \"planned\": int(planned.sum()), \"waste\": float(waste.sum()), \"unsched_fail\": int(((month < 0) & fail).sum())}\n", + "\n", + "\n", + "def kit_fefo(month, lots=LOTS):\n", + " \"\"\"유효기한이 빠른 로트부터 쓰는 FEFO로 키트를 배정하고 정비일에 유효기간이 부족한 배정 수와 만료 폐기 수량을 센다.\"\"\"\n", + " q = [[qty, e] for qty, e in sorted(lots, key=lambda t: t[1])]\n", + " short = 0\n", + " for m in sorted(month[month >= 0]):\n", + " day = (m + 1) * MDAYS\n", + " for lot in q:\n", + " if lot[1] >= day + KIT_MARGIN and lot[0] > 0:\n", + " lot[0] -= 1; break\n", + " else:\n", + " short += 1\n", + " expired = sum(l[0] for l in q if l[1] < HORIZON * MDAYS)\n", + " return {\"short\": short, \"expired_unused\": expired, \"left\": sum(l[0] for l in q)}\n", + "# ---------------------------------------------------------------------------\n", + "\n", + "tr = make_fleet(900, seed=1)\n", + "te = add_features(make_fleet(900, seed=3))\n", + "eng = fit_engine(tr)\n", + "al = te[te.event == 0].reset_index(drop=True)\n", + "pred, aft, gb = predict_rul(eng, al)\n", + "y = al[\"rul_d\"].values\n", + "big = y >= 90\n", + "P = {\"기준(선형 감가상각)\": al[\"lin_rul_d\"].values, \"와이블 AFT\": aft, \"GBR(점검 마모 포함)\": gb, \"앙상블(제안)\": pred}\n", + "tab = pd.DataFrame({k: {\"MAE(일)\": mae(y, v), \"RMSE(일)\": rmse(y, v), \"MAPE(%)\": mape(y[big], v[big]), \"R2\": r2_score(y, v)} for k, v in P.items()}).T\n", + "print(f\"[1] 생존 장비 {len(al)}대 RUL 예측 성능(학습 {len(tr)}대와 다른 장비, MAPE는 참 RUL 90일 이상)\")\n", + "print(tab.round(3).to_string())\n", + "b, e = tab.loc[\"기준(선형 감가상각)\"], tab.loc[\"앙상블(제안)\"]\n", + "ok = 1 - e[\"MAE(일)\"] / b[\"MAE(일)\"] >= .2 and 1 - e[\"RMSE(일)\"] / b[\"RMSE(일)\"] >= .2\n", + "print(f\" 기준 대비 MAE 개선 {1 - e['MAE(일)'] / b['MAE(일)']:.1%}, RMSE 개선 {1 - e['RMSE(일)'] / b['RMSE(일)']:.1%} (목표 20% 이상: {'달성' if ok else '미달'}), R2 {e['R2']:.3f} (목표 0.85 이상: {'달성' if e['R2'] >= .85 else '미달'})\")\n", + "\n", + "# 점검 마모 피처의 효과: 제거 실험\n", + "tr_f = add_features(snapshots(tr, 5, 0))\n", + "cols_no = [c for c in FEATS if c not in (\"insp\", \"insp_eta_h\")]\n", + "g_no = GradientBoostingRegressor(n_estimators=250, max_depth=3, learning_rate=0.06, random_state=0).fit(tr_f[cols_no], tr_f[\"rul_d\"])\n", + "p_no = g_no.predict(al[cols_no])\n", + "print(f\" 점검 마모 피처를 빼면 GBR MAE {mae(y, gb):.0f}일 -> {mae(y, p_no):.0f}일, R2 {r2_score(y, gb):.3f} -> {r2_score(y, p_no):.3f} (정비 점검 기록의 가치)\")\n", + "\n", + "# 불규칙 상황별 오차\n", + "conds = {\"일반\": al.env == \"normal\", \"험지\": al.env == \"rough\", \"염분\": al.env == \"salt\", \"혹한\": al.env == \"cold\", \"고가동(일 4h 이상)\": al.rate >= 4, \"비계획 정비율 상위 25%\": al.unpl_per_kh >= al.unpl_per_kh.quantile(.75)}\n", + "rr = pd.DataFrame([(k, int(m.sum()), mae(y[m.values], P[\"기준(선형 감가상각)\"][m.values]), mae(y[m.values], pred[m.values])) for k, m in conds.items()], columns=[\"조건\", \"대수\", \"기준 MAE\", \"앙상블 MAE\"])\n", + "print(\"\\n[2] 조건별 MAE(일)\\n\" + rr.round(0).to_string(index=False))\n", + "\n", + "# ---------------- 계획정비 일정 추천 ----------------\n", + "rate, true_rul = al[\"rate\"].values, al[\"rul_d\"].values\n", + "mf, mg = plan_fixed(al[\"lin_rul_d\"].values), plan_greedy(pred)\n", + "mm = plan_milp(pred, rate)\n", + "pl = {\"규정 주기 고정 일정(관행)\": mf, \"예측 기반 탐욕 배정\": mg, \"예측 기반 정수계획(제안)\": mm}\n", + "rows = []\n", + "for k, v in pl.items():\n", + " r, kf = evaluate_plan(v, true_rul, rate), kit_fefo(v)\n", + " rows.append((k, int((v >= 0).sum()), r[\"total\"], r[\"fail\"], r[\"planned\"], kf[\"short\"]))\n", + "pt = pd.DataFrame(rows, columns=[\"일정안\", \"배정 대수\", \"실제 총비용(만원)\", \"비계획 고장\", \"계획 정비\", \"키트 유효기간 부족\"])\n", + "print(f\"\\n[3] 12개월 계획정비 일정 비교(월 능력 {CAP_M}대, 월 예산 {BUDGET_M:,.0f}만원, 키트 재고 {sum(q for q, _ in LOTS)}개 로트 {len(LOTS)}종, 비용 단가는 교육용 가정값)\")\n", + "print(pt.round(0).to_string(index=False))\n", + "sv = 1 - pt.iloc[2][\"실제 총비용(만원)\"] / pt.iloc[0][\"실제 총비용(만원)\"]\n", + "print(f\" 정수계획 안은 관행 대비 총비용 {sv:.1%} 절감, 비계획 고장 {int(pt.iloc[0]['비계획 고장'])} -> {int(pt.iloc[2]['비계획 고장'])}대\")\n", + "cnt = np.bincount(mm[mm >= 0], minlength=HORIZON)\n", + "print(\" 월별 정비 대수(제안):\", \" \".join(f\"{i + 1}월 {c}\" for i, c in enumerate(cnt)), f\"(능력 {CAP_M}대 이하 확인: {'OK' if cnt.max() <= CAP_M else '초과'}, 월 예산 확인: {'OK' if (cnt * (C_PLAN + C_KIT)).max() <= BUDGET_M else '초과'})\")\n", + "kf = kit_fefo(mm)\n", + "print(f\" 시효성 키트 FEFO 배정: 유효기간 부족 {kf['short']}건, 만료 임박 폐기 예상 {kf['expired_unused']}개 -> 유효기한이 빠른 로트부터 쓰도록 정비 월을 맞춤\")\n", + "print(\" 추천 일정은 정비계획관 검토와 군수지휘관 승인(M7) 전까지 확정되지 않는다. 장비·단가는 교육용 가상 자료이다.\")\n", + "\n", + "fig, ax = plt.subplots(1, 3, figsize=(15, 4.3))\n", + "ax[0].scatter(y, P[\"기준(선형 감가상각)\"], s=6, label=\"baseline\"); ax[0].scatter(y, pred, s=6, label=\"ensemble\"); ax[0].plot([0, 4000], [0, 4000], \"k--\"); ax[0].legend(); ax[0].set_title(\"RUL: true vs predicted (days)\")\n", + "ax[1].bar(range(len(pt)), pt[\"실제 총비용(만원)\"]); ax[1].set_xticks(range(len(pt))); ax[1].set_xticklabels([\"fixed\", \"greedy\", \"MILP\"]); ax[1].set_title(\"Total cost of 12-month plan (10k KRW)\")\n", + "ax[2].bar(np.arange(1, HORIZON + 1), cnt); ax[2].axhline(CAP_M, c=\"r\", ls=\"--\"); ax[2].set_title(\"Overhauls per month (capacity line)\")\n", + "plt.tight_layout(); plt.show()\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 05 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + "[1] 생존 장비 601대 RUL 예측 성능(학습 900대와 다른 장비, MAPE는 참 RUL 90일 이상)\n", + " MAE(일) RMSE(일) MAPE(%) R2\n", + "기준(선형 감가상각) 354.501 435.605 46.016 0.808\n", + "와이블 AFT 212.324 277.206 24.014 0.922\n", + "GBR(점검 마모 포함) 253.028 396.280 16.536 0.842\n", + "앙상블(제안) 210.946 289.055 17.907 0.916\n", + " 기준 대비 MAE 개선 40.5%, RMSE 개선 33.6% (목표 20% 이상: 달성), R2 0.916 (목표 0.85 이상: 달성)\n", + " 점검 마모 피처를 빼면 GBR MAE 253일 -> 491일, R2 0.842 -> 0.578 (정비 점검 기록의 가치)\n", + "\n", + "[2] 조건별 MAE(일)\n", + " 조건 대수 기준 MAE 앙상블 MAE\n", + " 일반 265 432.0 238.0\n", + " 험지 99 379.0 163.0\n", + " 염분 124 265.0 192.0\n", + " 혹한 113 249.0 212.0\n", + " 고가동(일 4h 이상) 89 240.0 145.0\n", + "비계획 정비율 상위 25% 151 320.0 198.0\n", + "\n", + "[3] 12개월 계획정비 일정 비교(월 능력 22대, 월 예산 15,000만원, 키트 재고 190개 로트 4종, 비용 단가는 교육용 가정값)\n", + " 일정안 배정 대수 실제 총비용(만원) 비계획 고장 계획 정비 키트 유효기간 부족\n", + "규정 주기 고정 일정(관행) 123 184752.0 47 97 0\n", + " 예측 기반 탐욕 배정 105 141558.0 35 79 0\n", + " 예측 기반 정수계획(제안) 101 116447.0 21 90 0\n", + " 정수계획 안은 관행 대비 총비용 37.0% 절감, 비계획 고장 47 -> 21대\n", + " 월별 정비 대수(제안): 1월 20 2월 20 3월 20 4월 20 5월 20 6월 1 7월 0 8월 0 9월 0 10월 0 11월 0 12월 0 (능력 22대 이하 확인: OK, 월 예산 확인: OK)\n", + " 시효성 키트 FEFO 배정: 유효기간 부족 0건, 만료 임박 폐기 예상 10개 -> 유효기한이 빠른 로트부터 쓰도록 정비 월을 맞춤\n", + " 추천 일정은 정비계획관 검토와 군수지휘관 승인(M7) 전까지 확정되지 않는다. 장비·단가는 교육용 가상 자료이다.\n", + "```\n", + "
\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 06. 부대별 장비 노후도 현황 및 권장 정비 캘린더 대시보드 구현\n", + "**소요시간**: 약 3.0시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "부대별 장비 노후도 현황 및 권장 정비 캘린더 대시보드를 구현한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **부대별 장비 노후도 현황과 권장 정비 캘린더 대시보드를 구현하고 예측 근거를 설명한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **부대별 장비 수, 평균 노후 지수, 평균 예측 잔여 수명, 고위험 비율을 집계 수행하고 검증하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `src/explainability/sup02_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": [ + "\"\"\"부대별 장비 노후도 현황과 권장 정비 캘린더 대시보드를 구현하고, 장비마다 잔여 수명(RUL) 예측의 기여도 상위 3개 요인을 변수군 평균 대체 방식으로 자동 산출해 자연어 설명으로 보여 준다.\"\"\"\n", + "import numpy as np\n", + "import pandas as pd\n", + "import matplotlib.pyplot as plt\n", + "\n", + "# ---------------------------------------------------------------------------\n", + "# [교육용가상자료] 가상 장비 보유 현황·고장 이력 생성기 (실제 부대·장비·수치 없음)\n", + "# 가상 부대 6개(U1~U6)의 장비가 운용 환경(normal/rough/salt/cold)에 따라 다른 속도로 노후화한다. 창정비 후 운용시간(h_since)이\n", + "# 장비마다 숨은 수명(t_fail)을 넘으면 고장(event=1) 이력, 넘지 않았으면 현재까지 생존(event=0, 관측 중도절단)이다.\n", + "# ---------------------------------------------------------------------------\n", + "ENVS = [\"normal\", \"rough\", \"salt\", \"cold\"]\n", + "ENV_W = {\"normal\": 1.00, \"rough\": 1.35, \"salt\": 1.25, \"cold\": 1.12} # 환경 가중치(숨은 정답, 가정값): 클수록 수명이 짧다\n", + "ENV_KO = {\"normal\": \"일반\", \"rough\": \"험지\", \"salt\": \"염분\", \"cold\": \"혹한\"}\n", + "UNITS = [\"U1\", \"U2\", \"U3\", \"U4\", \"U5\", \"U6\"]\n", + "UNIT_ENV = {\"U1\": [.7, .1, .1, .1], \"U2\": [.2, .6, .1, .1], \"U3\": [.2, .1, .6, .1], \"U4\": [.3, .1, .1, .5], \"U5\": [.5, .3, .1, .1], \"U6\": [.4, .1, .4, .1]}\n", + "ETYPES = [\"truck\", \"generator\", \"trailer\", \"radio\"]\n", + "CYCLE = 8000.0 # 규정 창정비 주기(운용시간 h, 가정값)\n", + "KSHAPE = 12.0 # 수명 분포 와이블 형상(마모형 고장, 클수록 수명이 모여 있다)\n", + "INSP_NOISE = 0.025 # 정비 점검 마모 비율 측정 오차(가정값)\n", + "\n", + "\n", + "def make_fleet(n=900, seed=0, env_w=None, shift=1.0):\n", + " \"\"\"장비 n대. env_w로 숨은 환경 가중치를 바꿔 운영 분포 변화를 모의할 수 있다. shift>1이면 가동률이 높아진다.\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " w = env_w or ENV_W\n", + " unit = rng.choice(UNITS, n)\n", + " env = np.array([rng.choice(ENVS, p=UNIT_ENV[u]) for u in unit])\n", + " et = rng.choice(ETYPES, n, p=[.4, .2, .2, .2])\n", + " rate = rng.lognormal(np.log(3.0 * shift), 0.25, n) # 일 평균 운용시간(h/일)\n", + " h_since = rng.uniform(300, 11500, n) # 마지막 창정비 이후 운용시간\n", + " n_ovh = rng.integers(0, 3, n)\n", + " hours = h_since + n_ovh * rng.uniform(5000, 8000, n) # 누적 운용시간\n", + " km = hours * rng.normal(17, 3, n) * np.where(env == \"rough\", 0.8, 1.0) # 누적 기동거리(km)\n", + " age_y = np.clip(hours / (rate * 365) * rng.uniform(0.9, 1.15, n), 0.5, 25)\n", + " unpl = rng.poisson(0.35 * h_since / 1000 * np.array([w[e] for e in env]) / 1.3) # 비계획 정비 건수(환경이 가혹할수록 많다)\n", + " n_maint = rng.poisson(0.6 * h_since / 1000 + 1) # 계획 정비 건수\n", + " scale = 9600 / np.array([w[e] for e in env]) * 0.985 ** unpl * np.exp(rng.normal(0, 0.04, n))\n", + " t_fail = scale * rng.weibull(KSHAPE, n) # 창정비 후 고장까지 운용시간(숨은 정답)\n", + " event = (t_fail <= h_since).astype(int)\n", + " dur = np.where(event == 1, t_fail, h_since)\n", + " df = pd.DataFrame({\"eq_id\": [f\"EQ{seed:02d}{i:04d}\" for i in range(n)], \"unit\": unit, \"env\": env, \"etype\": et, \"rate\": rate, \"hours\": hours, \"km\": km, \"age_y\": age_y, \"n_ovh\": n_ovh,\n", + " \"h_since\": h_since, \"unpl\": unpl, \"n_maint\": n_maint, \"t_fail\": t_fail, \"event\": event, \"dur\": dur})\n", + " df[\"insp\"] = np.clip(dur / t_fail + rng.normal(0, INSP_NOISE, n), 0, 1.3) # 마지막 정비 점검에서 측정한 마모 비율(소모 수명/총 수명, 오차 포함)\n", + " df[\"rul_h\"] = np.where(event == 0, t_fail - h_since, np.nan) # 생존 장비의 남은 운용시간(정답)\n", + " df[\"rul_d\"] = df[\"rul_h\"] / df[\"rate\"] # 남은 일수(정답)\n", + " return df\n", + "\n", + "\n", + "def mape(y, p):\n", + " # [TODO : 학생 실습 6] 부대별 장비 노후도 현황 및 권장 정비 캘린더 대시보드 구현 관련 핵심 로직을 구현하세요.\n", + " # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n", + " # <<< 여기에 코드를 작성하세요 >>>\n", + " pass # 작성 후 pass를 제거하세요.\n", + "def mae(y, p):\n", + " # [TODO : 학생 실습 6] 부대별 장비 노후도 현황 및 권장 정비 캘린더 대시보드 구현 관련 핵심 로직을 구현하세요.\n", + " # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n", + " # <<< 여기에 코드를 작성하세요 >>>\n", + " pass # 작성 후 pass를 제거하세요.\n", + "def rmse(y, p):\n", + " return float(np.sqrt(np.mean((np.asarray(y, float) - np.asarray(p, float)) ** 2)))\n", + "# ---------------------------------------------------------------------------\n", + "# ---------------------------------------------------------------------------\n", + "# 노후화 파생 피처와 학습 표본 만들기. 환경 가중치 W0는 도입 시점의 전문가 추정값이며(실제 가중치와 다름) M9에서 재보정한다.\n", + "# ---------------------------------------------------------------------------\n", + "W0 = {\"normal\": 1.00, \"rough\": 1.15, \"salt\": 1.10, \"cold\": 1.05}\n", + "FEATS = [\"h_since\", \"hours\", \"km\", \"age_y\", \"rate\", \"n_maint\", \"unpl\", \"unpl_per_kh\", \"km_per_h\", \"n_ovh\", \"k_rough\", \"k_salt\", \"k_cold\", \"k_truck\", \"k_gen\", \"k_trailer\",\n", + " \"aging_idx\", \"lin_rul_d\", \"hs_frac\", \"insp\", \"insp_eta_h\"]\n", + "\n", + "\n", + "def add_features(df, w=None):\n", + " d = df.copy()\n", + " w = w or W0\n", + " d[\"unpl_per_kh\"] = d[\"unpl\"] / (d[\"h_since\"] / 1000 + 0.5)\n", + " d[\"km_per_h\"] = d[\"km\"] / d[\"hours\"]\n", + " for e in (\"rough\", \"salt\", \"cold\"):\n", + " d[\"k_\" + e] = (d[\"env\"] == e).astype(int)\n", + " d[\"k_truck\"], d[\"k_gen\"], d[\"k_trailer\"] = (d[\"etype\"] == \"truck\").astype(int), (d[\"etype\"] == \"generator\").astype(int), (d[\"etype\"] == \"trailer\").astype(int)\n", + " d[\"aging_idx\"] = d[\"h_since\"] * d[\"env\"].map(w) / CYCLE # 환경 가중 노후 지수(1이면 창정비 주기에 도달)\n", + " d[\"lin_rul_d\"] = (CYCLE - d[\"h_since\"]) / d[\"rate\"] # 선형 감가상각 잔여 일수(Baseline)\n", + " d[\"hs_frac\"] = d[\"h_since\"] / CYCLE\n", + " d[\"insp_eta_h\"] = d[\"h_since\"] / np.maximum(d[\"insp\"], 0.05) # 점검 마모 비율로 환산한 총 수명 추정(h)\n", + " return d\n", + "\n", + "\n", + "def snapshots(df, k=4, seed=0):\n", + " \"\"\"고장 이력 장비의 과거 시점 표본: 고장 전 임의 시점의 점검값·운용시간과 그때의 남은 일수. (생존 장비의 미래는 알 수 없으므로 고장 이력만 쓴다)\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " f = df[df[\"event\"] == 1]\n", + " out = []\n", + " for _ in range(k):\n", + " s = f.copy()\n", + " h = s[\"t_fail\"] * rng.uniform(0.2, 0.98, len(s))\n", + " ratio = h / s[\"h_since\"]\n", + " s[\"hours\"] = s[\"hours\"] - (s[\"h_since\"] - h)\n", + " s[\"km\"] = s[\"km\"] * ratio.clip(upper=1)\n", + " s[\"unpl\"] = np.floor(s[\"unpl\"] * ratio)\n", + " s[\"n_maint\"] = np.floor(s[\"n_maint\"] * ratio)\n", + " s[\"insp\"] = np.clip(h / s[\"t_fail\"] + rng.normal(0, INSP_NOISE, len(s)), 0, 1.3)\n", + " s[\"h_since\"] = h\n", + " s[\"rul_d\"] = (s[\"t_fail\"] - h) / s[\"rate\"]\n", + " s[\"event\"] = 0\n", + " out.append(s)\n", + " return pd.concat(out, ignore_index=True)\n", + "# ---------------------------------------------------------------------------\n", + "# ---------------------------------------------------------------------------\n", + "# 생존 분석 도구(scipy만 사용): 와이블 AFT(가속 수명) 모형과 Cox 비례위험 모형\n", + "# ---------------------------------------------------------------------------\n", + "from scipy.optimize import minimize\n", + "\n", + "\n", + "def aft_x(d):\n", + " return np.c_[np.ones(len(d)), d[\"env\"].eq(\"rough\"), d[\"env\"].eq(\"salt\"), d[\"env\"].eq(\"cold\"), d[\"unpl\"] / (d[\"h_since\"] / 1000 + 0.5)].astype(float)\n", + "\n", + "\n", + "AFT_COLS = [\"절편\", \"험지\", \"염분\", \"혹한\", \"비계획 정비율\"]\n", + "\n", + "\n", + "def fit_aft(d):\n", + " \"\"\"창정비 후 운용시간 dur(고장이면 event=1, 생존이면 중도절단)에 대한 와이블 AFT 최대우도 추정. 반환: 계수(마지막은 log 형상).\"\"\"\n", + " x, t, e = aft_x(d), d[\"dur\"].values, d[\"event\"].values\n", + "\n", + " def nll(p):\n", + " lam, k = np.exp(x @ p[:-1]), np.exp(p[-1])\n", + " z = (t / lam) ** k\n", + " return -(e * (np.log(k) - np.log(lam) + (k - 1) * (np.log(t) - np.log(lam))) - z).sum()\n", + "\n", + " r = minimize(nll, np.r_[np.log(8000), np.zeros(x.shape[1] - 1), np.log(5)], method=\"BFGS\")\n", + " return r.x\n", + "\n", + "\n", + "def aft_pred(p, d, q=0.5, h=None):\n", + " \"\"\"이미 h(운용시간)까지 생존했다는 조건에서 분위수 q 생존 시점(운용시간 h 기준 총 수명)을 돌려준다. q=0.5가 조건부 중앙값.\"\"\"\n", + " lam, k = np.exp(aft_x(d) @ p[:-1]), np.exp(p[-1])\n", + " h = d[\"h_since\"].values if h is None else h\n", + " return lam * ((h / lam) ** k - np.log(q)) ** (1 / k)\n", + "\n", + "\n", + "def aft_fail_prob(p, d, h0, h1):\n", + " \"\"\"h0까지 생존한 장비가 h0~h1 사이에 고장 날 조건부 확률.\"\"\"\n", + " lam, k = np.exp(aft_x(d) @ p[:-1]), np.exp(p[-1])\n", + " s0, s1 = np.exp(-(h0 / lam) ** k), np.exp(-(h1 / lam) ** k)\n", + " return 1 - s1 / s0\n", + "\n", + "\n", + "def fit_cox(d, iters=25):\n", + " \"\"\"Cox 비례위험(Breslow 근사) 뉴턴-랩슨. 공변량: 험지·염분·혹한·비계획 정비율. 반환: 계수.\"\"\"\n", + " x = aft_x(d)[:, 1:]\n", + " o = np.argsort(-d[\"dur\"].values)\n", + " x, e = x[o], d[\"event\"].values[o]\n", + " b = np.zeros(x.shape[1])\n", + " for _ in range(iters):\n", + " r = np.exp(x @ b)\n", + " S0 = np.cumsum(r)\n", + " S1 = np.cumsum(r[:, None] * x, axis=0)\n", + " S2 = np.cumsum(r[:, None, None] * x[:, :, None] * x[:, None, :], axis=0)\n", + " m1 = S1 / S0[:, None]\n", + " g = ((x - m1) * e[:, None]).sum(axis=0)\n", + " H = -sum(e[i] * (S2[i] / S0[i] - np.outer(m1[i], m1[i])) for i in np.where(e == 1)[0])\n", + " step = np.linalg.solve(H - 1e-6 * np.eye(len(b)), g)\n", + " b = b - step\n", + " if np.abs(step).max() < 1e-7:\n", + " break\n", + " return b\n", + "\n", + "\n", + "def cindex(time, event, risk):\n", + " \"\"\"일치도 C-index: 위험 점수가 높은 쪽이 먼저 고장 났는가(관측 가능한 쌍만).\"\"\"\n", + " t, e, r = np.asarray(time), np.asarray(event), np.asarray(risk)\n", + " num = den = 0.0\n", + " for i in np.where(e == 1)[0]:\n", + " m = t > t[i]\n", + " den += m.sum()\n", + " num += (r[m] < r[i]).sum() + 0.5 * (r[m] == r[i]).sum()\n", + " return num / max(den, 1)\n", + "# ---------------------------------------------------------------------------\n", + "# ---------------------------------------------------------------------------\n", + "# 잔여 수명(RUL, 일) 예측 엔진: 와이블 AFT 조건부 중앙값 + 그래디언트 부스팅(고장 이력 시점 표본 학습)의 평균\n", + "# ---------------------------------------------------------------------------\n", + "from sklearn.ensemble import GradientBoostingRegressor\n", + "\n", + "\n", + "def fit_engine(train, seed=0):\n", + " p = fit_aft(train)\n", + " sn = add_features(snapshots(train, 5, seed))\n", + " sn[\"aft\"] = (aft_pred(p, sn) - sn[\"h_since\"]) / sn[\"rate\"]\n", + " g = GradientBoostingRegressor(n_estimators=250, max_depth=3, learning_rate=0.06, random_state=0).fit(sn[FEATS + [\"aft\"]], sn[\"rul_d\"])\n", + " return {\"p\": p, \"g\": g}\n", + "\n", + "\n", + "def predict_rul(eng, d):\n", + " \"\"\"d는 add_features를 거친 표. 반환: (앙상블 RUL, AFT RUL, GBR RUL) - 일 단위.\"\"\"\n", + " aft = ((aft_pred(eng[\"p\"], d) - d[\"h_since\"]) / d[\"rate\"]).values\n", + " x = d[FEATS].copy()\n", + " x[\"aft\"] = aft\n", + " gb = eng[\"g\"].predict(x)\n", + " return np.clip(0.5 * aft + 0.5 * gb, 1, None), aft, gb\n", + "# ---------------------------------------------------------------------------\n", + "# ---------------------------------------------------------------------------\n", + "# 계획정비(창정비) 일정 추천: 월별 정비창 능력·예산·시효성 부품 키트(유효기한) 제약 아래 기대 비용 최소화 (비용 단가는 교육용 가정값, 만원)\n", + "# ---------------------------------------------------------------------------\n", + "from scipy.optimize import milp, LinearConstraint, Bounds\n", + "from scipy.stats import norm\n", + "\n", + "C_PLAN, C_FAIL, C_WASTE_KH, C_KIT = 600.0, 2400.0, 40.0, 120.0 # 계획 창정비, 비계획 고장(긴급 정비 포함), 미사용 수명 1000h당 손실, 시효성 부품 키트\n", + "HORIZON, MDAYS = 12, 30 # 계획 12개월, 월 30일\n", + "CAP_M, BUDGET_M = 22, 15000.0 # 월별 정비창 능력(대), 월별 예산\n", + "LOTS = [(30, 70), (45, 170), (45, 390), (70, 800)] # 시효성 키트 로트(수량, 유효기한까지 남은 일수)\n", + "KIT_MARGIN = 14 # 정비일 기준 최소 유효기간 여유(일)\n", + "\n", + "\n", + "def exp_cost_matrix(pred, rate, month_days):\n", + " \"\"\"장비 i를 월 m에 정비할 때의 기대 비용. 참 RUL ~ N(pred, sigma), sigma = 35일 + 0.12 x pred (가정). 마지막 열은 계획 기간 밖(미정비).\"\"\"\n", + " sig = 35 + 0.12 * pred\n", + " C = np.zeros((len(pred), len(month_days) + 1))\n", + " for j, day in enumerate(month_days):\n", + " z = (day - pred) / sig\n", + " pf = norm.cdf(z)\n", + " unused = np.maximum(pred - day, 0) * rate * 0.6 # 정비를 앞당긴 만큼 버리는 운용시간 h(근사)\n", + " C[:, j] = C_PLAN + C_KIT + pf * (C_FAIL - C_PLAN) + C_WASTE_KH * unused / 1000 * (1 - pf)\n", + " C[:, -1] = norm.cdf((HORIZON * MDAYS - pred) / sig) * C_FAIL\n", + " return C\n", + "\n", + "\n", + "def plan_milp(pred, rate, cap=CAP_M, budget=BUDGET_M, lots=LOTS, use_kit=True):\n", + " \"\"\"정수계획으로 장비별 정비 월(0~11) 또는 미정비(-1)를 배정한다. 후보는 예측 RUL이 계획 기간 +90일 이내인 장비.\"\"\"\n", + " md = np.arange(1, HORIZON + 1) * MDAYS\n", + " cand = np.where(pred < HORIZON * MDAYS + 90)[0]\n", + " n, M = len(cand), HORIZON\n", + " C = exp_cost_matrix(pred[cand], rate[cand], md)\n", + " c = C.ravel()\n", + " A, lo, hi = [], [], []\n", + " for i in range(n): # 장비당 한 번만 배정(월 12칸 + 미정비)\n", + " row = np.zeros(n * (M + 1)); row[i * (M + 1):(i + 1) * (M + 1)] = 1; A.append(row); lo.append(1); hi.append(1)\n", + " for m in range(M): # 월별 능력·예산\n", + " row = np.zeros(n * (M + 1)); row[m::(M + 1)] = 1\n", + " A.append(row); lo.append(0); hi.append(cap)\n", + " A.append(row * (C_PLAN + C_KIT)); lo.append(0); hi.append(budget)\n", + " if use_kit: # 시효성 키트: m월 이후 정비 수요는 그 시점에 유효기간이 남은 로트 수량을 넘을 수 없다\n", + " for m in range(M):\n", + " sup = sum(q for q, e in lots if e >= md[m] + KIT_MARGIN)\n", + " row = np.zeros(n * (M + 1))\n", + " for mm in range(m, M):\n", + " row[mm::(M + 1)] = 1\n", + " A.append(row); lo.append(0); hi.append(sup)\n", + " r = milp(c, constraints=LinearConstraint(np.array(A), lo, hi), integrality=np.ones(len(c)), bounds=Bounds(0, 1), options={\"time_limit\": 20})\n", + " month = np.full(len(pred), -1)\n", + " if r.x is not None:\n", + " x = np.round(r.x).reshape(n, M + 1)\n", + " assign = np.where(x[:, :M].sum(axis=1) > 0.5, x[:, :M].argmax(axis=1), -1)\n", + " month[cand] = assign\n", + " return month\n", + "\n", + "\n", + "def plan_greedy(pred, margin=45, cap=CAP_M, budget=BUDGET_M):\n", + " \"\"\"간단한 탐욕 배정: 정비 목표일(예측 RUL - margin)이 빠른 순으로 목표일 이전 가장 늦은 빈 월, 없으면 가장 이른 빈 월에 배정.\"\"\"\n", + " cap = min(cap, int(budget // (C_PLAN + C_KIT))) # 월 예산이 허용하는 대수로 능력을 제한\n", + " month = np.full(len(pred), -1)\n", + " left = np.full(HORIZON, cap)\n", + " for i in np.argsort(pred):\n", + " if pred[i] > HORIZON * MDAYS + 60: break\n", + " tgt = int(np.clip((pred[i] - margin) // MDAYS - 1, 0, HORIZON - 1))\n", + " for m in list(range(tgt, -1, -1)) + list(range(tgt + 1, HORIZON)):\n", + " if left[m] > 0:\n", + " month[i] = m; left[m] -= 1; break\n", + " return month\n", + "\n", + "\n", + "def plan_fixed(lin_rul, cap=CAP_M, budget=BUDGET_M):\n", + " \"\"\"관행: 규정 주기 도달 순서대로(선형 잔여 일수) 월별 능력 안에서 배정.\"\"\"\n", + " return plan_greedy(np.asarray(lin_rul, float), margin=0, cap=cap, budget=budget)\n", + "\n", + "\n", + "def evaluate_plan(month, true_rul, rate, cost_kit=True):\n", + " \"\"\"참 RUL로 계획의 실제 비용을 계산한다: 정비일 이전에 고장 -> 비계획 고장 비용, 아니면 계획 비용 + 미사용 수명 손실.\"\"\"\n", + " day = np.where(month >= 0, (month + 1) * MDAYS, 10 ** 9)\n", + " fail = (true_rul < day) & (true_rul < HORIZON * MDAYS)\n", + " planned = (month >= 0) & ~fail\n", + " waste = np.where(planned, (true_rul - day) * rate * 0.6, 0) * C_WASTE_KH / 1000\n", + " cost = fail * C_FAIL + planned * (C_PLAN + (C_KIT if cost_kit else 0)) + waste\n", + " return {\"total\": float(cost.sum()), \"fail\": int(fail.sum()), \"planned\": int(planned.sum()), \"waste\": float(waste.sum()), \"unsched_fail\": int(((month < 0) & fail).sum())}\n", + "\n", + "\n", + "def kit_fefo(month, lots=LOTS):\n", + " \"\"\"유효기한이 빠른 로트부터 쓰는 FEFO로 키트를 배정하고 정비일에 유효기간이 부족한 배정 수와 만료 폐기 수량을 센다.\"\"\"\n", + " q = [[qty, e] for qty, e in sorted(lots, key=lambda t: t[1])]\n", + " short = 0\n", + " for m in sorted(month[month >= 0]):\n", + " day = (m + 1) * MDAYS\n", + " for lot in q:\n", + " if lot[1] >= day + KIT_MARGIN and lot[0] > 0:\n", + " lot[0] -= 1; break\n", + " else:\n", + " short += 1\n", + " expired = sum(l[0] for l in q if l[1] < HORIZON * MDAYS)\n", + " return {\"short\": short, \"expired_unused\": expired, \"left\": sum(l[0] for l in q)}\n", + "# ---------------------------------------------------------------------------\n", + "\n", + "tr = make_fleet(900, seed=1)\n", + "eng = fit_engine(tr)\n", + "fl = add_features(make_fleet(900, seed=3))\n", + "al = fl[fl.event == 0].reset_index(drop=True)\n", + "al[\"rul_pred\"], _, _ = predict_rul(eng, al)\n", + "al[\"month\"] = plan_milp(al[\"rul_pred\"].values, al[\"rate\"].values)\n", + "al[\"p90\"] = aft_fail_prob(eng[\"p\"], al, al[\"h_since\"].values, al[\"h_since\"].values + 90 * al[\"rate\"].values)\n", + "\n", + "# ---------------- 1) 부대별 노후도 현황 ----------------\n", + "g = al.groupby(\"unit\")\n", + "board = pd.DataFrame({\"장비 수\": g.size(), \"평균 노후 지수\": g[\"aging_idx\"].mean(), \"평균 예측 RUL(일)\": g[\"rul_pred\"].mean(), \"180일 내 고위험\": g.apply(lambda x: int((x.rul_pred < 180).sum())),\n", + " \"험지·염분 비율\": g.apply(lambda x: x.env.isin([\"rough\", \"salt\"]).mean()), \"12개월 내 권장 정비\": g.apply(lambda x: int((x.month >= 0).sum()))})\n", + "board[\"고위험 비율\"] = board[\"180일 내 고위험\"] / board[\"장비 수\"]\n", + "print(f\"[1] 부대별 노후도 현황(생존 장비 {len(al)}대, 가상 부대 6개) [교육용가상자료]\")\n", + "print(board.round(2).to_string())\n", + "worst = board[\"고위험 비율\"].idxmax()\n", + "print(f\" 고위험 비율이 가장 높은 부대 {worst}({board.loc[worst, '고위험 비율']:.0%}), 환경 구성(험지·염분 {board.loc[worst, '험지·염분 비율']:.0%})과 함께 본다.\")\n", + "\n", + "# ---------------- 2) 권장 정비 캘린더 ----------------\n", + "cal = pd.crosstab(al.loc[al.month >= 0, \"unit\"], al.loc[al.month >= 0, \"month\"] + 1).reindex(columns=range(1, HORIZON + 1), fill_value=0)\n", + "print(\"\\n[2] 권장 정비 캘린더(부대 x 월, 대수; 월 예산·능력·키트 유효기간 반영)\")\n", + "print(cal.to_string())\n", + "print(f\" 월 합계: \" + \" \".join(f\"{m}월 {int(c)}\" for m, c in cal.sum().items()) + f\" / 정비 일정이 잡히지 않은 고위험(RUL 180일 미만) 장비 {int(((al.month < 0) & (al.rul_pred < 180)).sum())}대\")\n", + "top = al.sort_values(\"rul_pred\").head(8)\n", + "print(\"\\n 위험 상위 8대 [권장 월은 승인 전 추천안]\")\n", + "print(pd.DataFrame({\"장비\": top.eq_id, \"부대\": top.unit, \"환경\": top.env.map(ENV_KO), \"예측 RUL(일)\": top.rul_pred, \"90일 내 고장 확률\": top.p90, \"권장 월\": top.month.map(lambda m: f\"{m + 1}월\" if m >= 0 else \"-\")}).round(2).to_string(index=False))\n", + "\n", + "# ---------------- 3) 기여도 상위 3개(변수군 평균 대체) ----------------\n", + "GROUPS = {\"운용 이력(경과 시간·거리·연수)\": [\"h_since\", \"hours\", \"km\", \"age_y\"], \"운용 환경\": [\"env\"], \"정비 이력(비계획·계획 정비)\": [\"unpl\", \"n_maint\"], \"점검 마모 비율\": [\"insp\"], \"일 가동률\": [\"rate\"]}\n", + "MEAN = tr[[\"h_since\", \"hours\", \"km\", \"age_y\", \"unpl\", \"n_maint\", \"insp\", \"rate\"]].mean()\n", + "raw_cols = [\"eq_id\", \"unit\", \"env\", \"etype\", \"rate\", \"hours\", \"km\", \"age_y\", \"n_ovh\", \"h_since\", \"unpl\", \"n_maint\", \"insp\"]\n", + "base_raw = al[raw_cols].copy()\n", + "full = al[\"rul_pred\"].values\n", + "contrib = {}\n", + "for gname, cols in GROUPS.items():\n", + " x = base_raw.copy()\n", + " for c in cols:\n", + " x[c] = \"normal\" if c == \"env\" else MEAN[c]\n", + " xf = add_features(x.assign(event=0, t_fail=0.0, dur=x[\"h_since\"]))\n", + " contrib[gname] = full - predict_rul(eng, xf)[0] # 음수 = 이 요인 때문에 평균 장비보다 잔여 수명이 짧다\n", + "C = pd.DataFrame(contrib)\n", + "t3 = C.apply(lambda r: r.reindex(r.abs().sort_values(ascending=False).index)[:3], axis=1)\n", + "okn = int(sum(t3.iloc[i].notna().sum() == 3 and np.isfinite(t3.iloc[i].dropna().values).all() for i in range(len(t3))))\n", + "print(f\"\\n[3] 장비 {len(al)}대 중 기여도 상위 3개 요인 자동 산출 {okn}대 ({okn / len(al):.0%}, 목표 100%)\")\n", + "print(\" 요인군별 평균 기여 크기(일):\", \", \".join(f\"{k} {v:.0f}\" for k, v in C.abs().mean().sort_values(ascending=False).items()))\n", + "\n", + "\n", + "def explain(i):\n", + " r, row = al.loc[i], C.loc[i]\n", + " ordered = row.reindex(row.abs().sort_values(ascending=False).index)[:3]\n", + " main = ordered.index[0]\n", + " why = {\"운용 이력(경과 시간·거리·연수)\": \"창정비 이후 운용 시간이 길어\", \"운용 환경\": f\"{ENV_KO[r.env]} 환경 가혹도 때문에\", \"정비 이력(비계획·계획 정비)\": \"비계획 정비가 잦아\", \"점검 마모 비율\": \"점검 마모 비율이 높아\", \"일 가동률\": \"일 가동률이 높아\"}[main]\n", + " return f\"{r.eq_id}({r.unit}, {ENV_KO[r.env]}): {why} 잔여 수명 {r.rul_pred:.0f}일 예측. 주요 요인 \" + \", \".join(f\"{k.split('(')[0]}({v:+.0f}일)\" for k, v in ordered.items())\n", + "\n", + "\n", + "print(\"[4] 위험 상위 3대 자연어 설명(음수 = 평균 장비보다 잔여 수명이 짧아지는 요인)\")\n", + "for i in top.index[:3]:\n", + " print(\" -\", explain(i))\n", + "print(\" 대시보드와 설명은 정비계획관의 판단 보조이며 일정 확정과 장비 도태 건의는 군수지휘관 승인(M7)에 따른다.\")\n", + "\n", + "fig, ax = plt.subplots(2, 2, figsize=(13, 8))\n", + "ax[0, 0].bar(board.index, board[\"고위험 비율\"] * 100); ax[0, 0].set_title(\"1 High-risk share by unit (%, RUL < 180 days)\")\n", + "im = ax[0, 1].imshow(cal.values, aspect=\"auto\", cmap=\"YlOrRd\"); ax[0, 1].set_xticks(range(HORIZON)); ax[0, 1].set_xticklabels(range(1, HORIZON + 1)); ax[0, 1].set_yticks(range(len(cal))); ax[0, 1].set_yticklabels(cal.index)\n", + "ax[0, 1].set_title(\"2 Recommended overhaul calendar (unit x month)\"); plt.colorbar(im, ax=ax[0, 1])\n", + "ax[1, 0].bar(board.index, board[\"평균 노후 지수\"]); ax[1, 0].axhline(1, c=\"r\", ls=\"--\"); ax[1, 0].set_title(\"3 Mean aging index by unit\")\n", + "r0 = C.loc[top.index[0]]\n", + "ax[1, 1].barh([\"usage history\", \"environment\", \"maintenance history\", \"inspection wear\", \"daily usage\"][::-1], r0.values[::-1]); ax[1, 1].set_title(\"4 Contribution to RUL, riskiest unit (days)\")\n", + "plt.tight_layout(); plt.show()\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 06 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + "[1] 부대별 노후도 현황(생존 장비 601대, 가상 부대 6개) [교육용가상자료]\n", + " 장비 수 평균 노후 지수 평균 예측 RUL(일) 180일 내 고위험 험지·염분 비율 12개월 내 권장 정비 고위험 비율\n", + "unit \n", + "U1 110 0.58 1267.69 5 0.13 21 0.05\n", + "U2 87 0.50 1340.48 5 0.64 11 0.06\n", + "U3 101 0.53 1200.60 4 0.63 15 0.04\n", + "U4 110 0.57 1259.99 7 0.13 24 0.06\n", + "U5 106 0.58 1287.43 6 0.31 18 0.06\n", + "U6 87 0.51 1370.82 5 0.48 12 0.06\n", + " 고위험 비율이 가장 높은 부대 U4(6%), 환경 구성(험지·염분 13%)과 함께 본다.\n", + "\n", + "[2] 권장 정비 캘린더(부대 x 월, 대수; 월 예산·능력·키트 유효기간 반영)\n", + "month 1 2 3 4 5 6 7 8 9 10 11 12\n", + "unit \n", + "U1 1 8 2 6 4 0 0 0 0 0 0 0\n", + "U2 3 2 3 1 2 0 0 0 0 0 0 0\n", + "U3 4 1 3 4 3 0 0 0 0 0 0 0\n", + "U4 4 4 7 3 5 1 0 0 0 0 0 0\n", + "U5 6 1 3 4 4 0 0 0 0 0 0 0\n", + "U6 2 4 2 2 2 0 0 0 0 0 0 0\n", + " 월 합계: 1월 20 2월 20 3월 20 4월 20 5월 20 6월 1 7월 0 8월 0 9월 0 10월 0 11월 0 12월 0 / 정비 일정이 잡히지 않은 고위험(RUL 180일 미만) 장비 0대\n", + "\n", + " 위험 상위 8대 [권장 월은 승인 전 추천안]\n", + " 장비 부대 환경 예측 RUL(일) 90일 내 고장 확률 권장 월\n", + "EQ030723 U6 염분 78.41 0.49 1월\n", + "EQ030395 U2 험지 83.07 0.72 1월\n", + "EQ030208 U5 혹한 88.97 0.73 1월\n", + "EQ030501 U4 일반 90.25 0.43 1월\n", + "EQ030658 U3 혹한 98.04 0.49 1월\n", + "EQ030648 U2 험지 98.17 0.41 1월\n", + "EQ030142 U4 일반 107.32 0.66 1월\n", + "EQ030652 U3 혹한 107.97 0.34 1월\n", + "\n", + "[3] 장비 601대 중 기여도 상위 3개 요인 자동 산출 601대 (100%, 목표 100%)\n", + " 요인군별 평균 기여 크기(일): 운용 이력(경과 시간·거리·연수) 487, 점검 마모 비율 344, 일 가동률 260, 운용 환경 207, 정비 이력(비계획·계획 정비) 94\n", + "[4] 위험 상위 3대 자연어 설명(음수 = 평균 장비보다 잔여 수명이 짧아지는 요인)\n", + " - EQ030723(U6, 염분): 점검 마모 비율이 높아 잔여 수명 78일 예측. 주요 요인 점검 마모 비율(-286일), 운용 환경(-172일), 운용 이력(-128일)\n", + " - EQ030395(U2, 험지): 점검 마모 비율이 높아 잔여 수명 83일 예측. 주요 요인 점검 마모 비율(-279일), 운용 환경(-218일), 운용 이력(-110일)\n", + " - EQ030208(U5, 혹한): 창정비 이후 운용 시간이 길어 잔여 수명 89일 예측. 주요 요인 운용 이력(-382일), 점검 마모 비율(-351일), 운용 환경(-64일)\n", + " 대시보드와 설명은 정비계획관의 판단 보조이며 일정 확정과 장비 도태 건의는 군수지휘관 승인(M7)에 따른다.\n", + "```\n", + "
\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 07. 정비 일정 확정 및 장비 도태 건의를 위한 군수지휘관 HITL 승인 절차 설계\n", + "**소요시간**: 약 3.5시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "정비 일정 확정을 위한 군수지휘관 HITL 승인 절차를 설계한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **정비 일정 확정과 장비 도태 건의를 위한 군수지휘관 HITL 승인 절차를 설계하고 비용 절감 효과를 검증한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **관행 일정 대비 제안 일정의 12개월 비용을 평상, 정비창 능력 감소, 예산 삭감, 키트 재고 부족, 가동률 상승 등 시나리오별로 비교하고 백테스트와 민감도 확인하고 유효성을 점검하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `src/optimization/sup02_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": [ + "\"\"\"정비창 능력·예산·시효성 키트 제약 아래 예측 기반 계획정비 일정을 규정 주기 고정 일정과 비교해 비용·키트 소진율을 시나리오·백테스트로 검증하고(비용 15% 이상 절감), 정비 일정 확정과 장비 도태 건의를 군수지휘관(도태는 재정 담당 2단계)이 직접 승인·반려하는 HITL 결재 워크플로우(권한·사유·해시 체인 감사 로그)를 구현한다.\"\"\"\n", + "import hashlib\n", + "import json\n", + "import numpy as np\n", + "import pandas as pd\n", + "import matplotlib.pyplot as plt\n", + "\n", + "# ---------------------------------------------------------------------------\n", + "# [교육용가상자료] 가상 장비 보유 현황·고장 이력 생성기 (실제 부대·장비·수치 없음)\n", + "# 가상 부대 6개(U1~U6)의 장비가 운용 환경(normal/rough/salt/cold)에 따라 다른 속도로 노후화한다. 창정비 후 운용시간(h_since)이\n", + "# 장비마다 숨은 수명(t_fail)을 넘으면 고장(event=1) 이력, 넘지 않았으면 현재까지 생존(event=0, 관측 중도절단)이다.\n", + "# ---------------------------------------------------------------------------\n", + "ENVS = [\"normal\", \"rough\", \"salt\", \"cold\"]\n", + "ENV_W = {\"normal\": 1.00, \"rough\": 1.35, \"salt\": 1.25, \"cold\": 1.12} # 환경 가중치(숨은 정답, 가정값): 클수록 수명이 짧다\n", + "ENV_KO = {\"normal\": \"일반\", \"rough\": \"험지\", \"salt\": \"염분\", \"cold\": \"혹한\"}\n", + "UNITS = [\"U1\", \"U2\", \"U3\", \"U4\", \"U5\", \"U6\"]\n", + "UNIT_ENV = {\"U1\": [.7, .1, .1, .1], \"U2\": [.2, .6, .1, .1], \"U3\": [.2, .1, .6, .1], \"U4\": [.3, .1, .1, .5], \"U5\": [.5, .3, .1, .1], \"U6\": [.4, .1, .4, .1]}\n", + "ETYPES = [\"truck\", \"generator\", \"trailer\", \"radio\"]\n", + "CYCLE = 8000.0 # 규정 창정비 주기(운용시간 h, 가정값)\n", + "KSHAPE = 12.0 # 수명 분포 와이블 형상(마모형 고장, 클수록 수명이 모여 있다)\n", + "INSP_NOISE = 0.025 # 정비 점검 마모 비율 측정 오차(가정값)\n", + "\n", + "\n", + "def make_fleet(n=900, seed=0, env_w=None, shift=1.0):\n", + " \"\"\"장비 n대. env_w로 숨은 환경 가중치를 바꿔 운영 분포 변화를 모의할 수 있다. shift>1이면 가동률이 높아진다.\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " w = env_w or ENV_W\n", + " unit = rng.choice(UNITS, n)\n", + " env = np.array([rng.choice(ENVS, p=UNIT_ENV[u]) for u in unit])\n", + " et = rng.choice(ETYPES, n, p=[.4, .2, .2, .2])\n", + " rate = rng.lognormal(np.log(3.0 * shift), 0.25, n) # 일 평균 운용시간(h/일)\n", + " h_since = rng.uniform(300, 11500, n) # 마지막 창정비 이후 운용시간\n", + " n_ovh = rng.integers(0, 3, n)\n", + " hours = h_since + n_ovh * rng.uniform(5000, 8000, n) # 누적 운용시간\n", + " km = hours * rng.normal(17, 3, n) * np.where(env == \"rough\", 0.8, 1.0) # 누적 기동거리(km)\n", + " age_y = np.clip(hours / (rate * 365) * rng.uniform(0.9, 1.15, n), 0.5, 25)\n", + " unpl = rng.poisson(0.35 * h_since / 1000 * np.array([w[e] for e in env]) / 1.3) # 비계획 정비 건수(환경이 가혹할수록 많다)\n", + " n_maint = rng.poisson(0.6 * h_since / 1000 + 1) # 계획 정비 건수\n", + " scale = 9600 / np.array([w[e] for e in env]) * 0.985 ** unpl * np.exp(rng.normal(0, 0.04, n))\n", + " t_fail = scale * rng.weibull(KSHAPE, n) # 창정비 후 고장까지 운용시간(숨은 정답)\n", + " event = (t_fail <= h_since).astype(int)\n", + " dur = np.where(event == 1, t_fail, h_since)\n", + " df = pd.DataFrame({\"eq_id\": [f\"EQ{seed:02d}{i:04d}\" for i in range(n)], \"unit\": unit, \"env\": env, \"etype\": et, \"rate\": rate, \"hours\": hours, \"km\": km, \"age_y\": age_y, \"n_ovh\": n_ovh,\n", + " \"h_since\": h_since, \"unpl\": unpl, \"n_maint\": n_maint, \"t_fail\": t_fail, \"event\": event, \"dur\": dur})\n", + " df[\"insp\"] = np.clip(dur / t_fail + rng.normal(0, INSP_NOISE, n), 0, 1.3) # 마지막 정비 점검에서 측정한 마모 비율(소모 수명/총 수명, 오차 포함)\n", + " df[\"rul_h\"] = np.where(event == 0, t_fail - h_since, np.nan) # 생존 장비의 남은 운용시간(정답)\n", + " df[\"rul_d\"] = df[\"rul_h\"] / df[\"rate\"] # 남은 일수(정답)\n", + " return df\n", + "\n", + "\n", + "def mape(y, p):\n", + " # [TODO : 학생 실습 7] 정비 일정 확정 및 장비 도태 건의를 위한 군수지휘관 HITL 승인 절차 설계 관련 핵심 로직을 구현하세요.\n", + " # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n", + " # <<< 여기에 코드를 작성하세요 >>>\n", + " pass # 작성 후 pass를 제거하세요.\n", + "def mae(y, p):\n", + " # [TODO : 학생 실습 7] 정비 일정 확정 및 장비 도태 건의를 위한 군수지휘관 HITL 승인 절차 설계 관련 핵심 로직을 구현하세요.\n", + " # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n", + " # <<< 여기에 코드를 작성하세요 >>>\n", + " pass # 작성 후 pass를 제거하세요.\n", + "def rmse(y, p):\n", + " return float(np.sqrt(np.mean((np.asarray(y, float) - np.asarray(p, float)) ** 2)))\n", + "# ---------------------------------------------------------------------------\n", + "# ---------------------------------------------------------------------------\n", + "# 노후화 파생 피처와 학습 표본 만들기. 환경 가중치 W0는 도입 시점의 전문가 추정값이며(실제 가중치와 다름) M9에서 재보정한다.\n", + "# ---------------------------------------------------------------------------\n", + "W0 = {\"normal\": 1.00, \"rough\": 1.15, \"salt\": 1.10, \"cold\": 1.05}\n", + "FEATS = [\"h_since\", \"hours\", \"km\", \"age_y\", \"rate\", \"n_maint\", \"unpl\", \"unpl_per_kh\", \"km_per_h\", \"n_ovh\", \"k_rough\", \"k_salt\", \"k_cold\", \"k_truck\", \"k_gen\", \"k_trailer\",\n", + " \"aging_idx\", \"lin_rul_d\", \"hs_frac\", \"insp\", \"insp_eta_h\"]\n", + "\n", + "\n", + "def add_features(df, w=None):\n", + " d = df.copy()\n", + " w = w or W0\n", + " d[\"unpl_per_kh\"] = d[\"unpl\"] / (d[\"h_since\"] / 1000 + 0.5)\n", + " d[\"km_per_h\"] = d[\"km\"] / d[\"hours\"]\n", + " for e in (\"rough\", \"salt\", \"cold\"):\n", + " d[\"k_\" + e] = (d[\"env\"] == e).astype(int)\n", + " d[\"k_truck\"], d[\"k_gen\"], d[\"k_trailer\"] = (d[\"etype\"] == \"truck\").astype(int), (d[\"etype\"] == \"generator\").astype(int), (d[\"etype\"] == \"trailer\").astype(int)\n", + " d[\"aging_idx\"] = d[\"h_since\"] * d[\"env\"].map(w) / CYCLE # 환경 가중 노후 지수(1이면 창정비 주기에 도달)\n", + " d[\"lin_rul_d\"] = (CYCLE - d[\"h_since\"]) / d[\"rate\"] # 선형 감가상각 잔여 일수(Baseline)\n", + " d[\"hs_frac\"] = d[\"h_since\"] / CYCLE\n", + " d[\"insp_eta_h\"] = d[\"h_since\"] / np.maximum(d[\"insp\"], 0.05) # 점검 마모 비율로 환산한 총 수명 추정(h)\n", + " return d\n", + "\n", + "\n", + "def snapshots(df, k=4, seed=0):\n", + " \"\"\"고장 이력 장비의 과거 시점 표본: 고장 전 임의 시점의 점검값·운용시간과 그때의 남은 일수. (생존 장비의 미래는 알 수 없으므로 고장 이력만 쓴다)\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " f = df[df[\"event\"] == 1]\n", + " out = []\n", + " for _ in range(k):\n", + " s = f.copy()\n", + " h = s[\"t_fail\"] * rng.uniform(0.2, 0.98, len(s))\n", + " ratio = h / s[\"h_since\"]\n", + " s[\"hours\"] = s[\"hours\"] - (s[\"h_since\"] - h)\n", + " s[\"km\"] = s[\"km\"] * ratio.clip(upper=1)\n", + " s[\"unpl\"] = np.floor(s[\"unpl\"] * ratio)\n", + " s[\"n_maint\"] = np.floor(s[\"n_maint\"] * ratio)\n", + " s[\"insp\"] = np.clip(h / s[\"t_fail\"] + rng.normal(0, INSP_NOISE, len(s)), 0, 1.3)\n", + " s[\"h_since\"] = h\n", + " s[\"rul_d\"] = (s[\"t_fail\"] - h) / s[\"rate\"]\n", + " s[\"event\"] = 0\n", + " out.append(s)\n", + " return pd.concat(out, ignore_index=True)\n", + "# ---------------------------------------------------------------------------\n", + "# ---------------------------------------------------------------------------\n", + "# 생존 분석 도구(scipy만 사용): 와이블 AFT(가속 수명) 모형과 Cox 비례위험 모형\n", + "# ---------------------------------------------------------------------------\n", + "from scipy.optimize import minimize\n", + "\n", + "\n", + "def aft_x(d):\n", + " return np.c_[np.ones(len(d)), d[\"env\"].eq(\"rough\"), d[\"env\"].eq(\"salt\"), d[\"env\"].eq(\"cold\"), d[\"unpl\"] / (d[\"h_since\"] / 1000 + 0.5)].astype(float)\n", + "\n", + "\n", + "AFT_COLS = [\"절편\", \"험지\", \"염분\", \"혹한\", \"비계획 정비율\"]\n", + "\n", + "\n", + "def fit_aft(d):\n", + " \"\"\"창정비 후 운용시간 dur(고장이면 event=1, 생존이면 중도절단)에 대한 와이블 AFT 최대우도 추정. 반환: 계수(마지막은 log 형상).\"\"\"\n", + " x, t, e = aft_x(d), d[\"dur\"].values, d[\"event\"].values\n", + "\n", + " def nll(p):\n", + " lam, k = np.exp(x @ p[:-1]), np.exp(p[-1])\n", + " z = (t / lam) ** k\n", + " return -(e * (np.log(k) - np.log(lam) + (k - 1) * (np.log(t) - np.log(lam))) - z).sum()\n", + "\n", + " r = minimize(nll, np.r_[np.log(8000), np.zeros(x.shape[1] - 1), np.log(5)], method=\"BFGS\")\n", + " return r.x\n", + "\n", + "\n", + "def aft_pred(p, d, q=0.5, h=None):\n", + " \"\"\"이미 h(운용시간)까지 생존했다는 조건에서 분위수 q 생존 시점(운용시간 h 기준 총 수명)을 돌려준다. q=0.5가 조건부 중앙값.\"\"\"\n", + " lam, k = np.exp(aft_x(d) @ p[:-1]), np.exp(p[-1])\n", + " h = d[\"h_since\"].values if h is None else h\n", + " return lam * ((h / lam) ** k - np.log(q)) ** (1 / k)\n", + "\n", + "\n", + "def aft_fail_prob(p, d, h0, h1):\n", + " \"\"\"h0까지 생존한 장비가 h0~h1 사이에 고장 날 조건부 확률.\"\"\"\n", + " lam, k = np.exp(aft_x(d) @ p[:-1]), np.exp(p[-1])\n", + " s0, s1 = np.exp(-(h0 / lam) ** k), np.exp(-(h1 / lam) ** k)\n", + " return 1 - s1 / s0\n", + "\n", + "\n", + "def fit_cox(d, iters=25):\n", + " \"\"\"Cox 비례위험(Breslow 근사) 뉴턴-랩슨. 공변량: 험지·염분·혹한·비계획 정비율. 반환: 계수.\"\"\"\n", + " x = aft_x(d)[:, 1:]\n", + " o = np.argsort(-d[\"dur\"].values)\n", + " x, e = x[o], d[\"event\"].values[o]\n", + " b = np.zeros(x.shape[1])\n", + " for _ in range(iters):\n", + " r = np.exp(x @ b)\n", + " S0 = np.cumsum(r)\n", + " S1 = np.cumsum(r[:, None] * x, axis=0)\n", + " S2 = np.cumsum(r[:, None, None] * x[:, :, None] * x[:, None, :], axis=0)\n", + " m1 = S1 / S0[:, None]\n", + " g = ((x - m1) * e[:, None]).sum(axis=0)\n", + " H = -sum(e[i] * (S2[i] / S0[i] - np.outer(m1[i], m1[i])) for i in np.where(e == 1)[0])\n", + " step = np.linalg.solve(H - 1e-6 * np.eye(len(b)), g)\n", + " b = b - step\n", + " if np.abs(step).max() < 1e-7:\n", + " break\n", + " return b\n", + "\n", + "\n", + "def cindex(time, event, risk):\n", + " \"\"\"일치도 C-index: 위험 점수가 높은 쪽이 먼저 고장 났는가(관측 가능한 쌍만).\"\"\"\n", + " t, e, r = np.asarray(time), np.asarray(event), np.asarray(risk)\n", + " num = den = 0.0\n", + " for i in np.where(e == 1)[0]:\n", + " m = t > t[i]\n", + " den += m.sum()\n", + " num += (r[m] < r[i]).sum() + 0.5 * (r[m] == r[i]).sum()\n", + " return num / max(den, 1)\n", + "# ---------------------------------------------------------------------------\n", + "# ---------------------------------------------------------------------------\n", + "# 잔여 수명(RUL, 일) 예측 엔진: 와이블 AFT 조건부 중앙값 + 그래디언트 부스팅(고장 이력 시점 표본 학습)의 평균\n", + "# ---------------------------------------------------------------------------\n", + "from sklearn.ensemble import GradientBoostingRegressor\n", + "\n", + "\n", + "def fit_engine(train, seed=0):\n", + " p = fit_aft(train)\n", + " sn = add_features(snapshots(train, 5, seed))\n", + " sn[\"aft\"] = (aft_pred(p, sn) - sn[\"h_since\"]) / sn[\"rate\"]\n", + " g = GradientBoostingRegressor(n_estimators=250, max_depth=3, learning_rate=0.06, random_state=0).fit(sn[FEATS + [\"aft\"]], sn[\"rul_d\"])\n", + " return {\"p\": p, \"g\": g}\n", + "\n", + "\n", + "def predict_rul(eng, d):\n", + " \"\"\"d는 add_features를 거친 표. 반환: (앙상블 RUL, AFT RUL, GBR RUL) - 일 단위.\"\"\"\n", + " aft = ((aft_pred(eng[\"p\"], d) - d[\"h_since\"]) / d[\"rate\"]).values\n", + " x = d[FEATS].copy()\n", + " x[\"aft\"] = aft\n", + " gb = eng[\"g\"].predict(x)\n", + " return np.clip(0.5 * aft + 0.5 * gb, 1, None), aft, gb\n", + "# ---------------------------------------------------------------------------\n", + "# ---------------------------------------------------------------------------\n", + "# 계획정비(창정비) 일정 추천: 월별 정비창 능력·예산·시효성 부품 키트(유효기한) 제약 아래 기대 비용 최소화 (비용 단가는 교육용 가정값, 만원)\n", + "# ---------------------------------------------------------------------------\n", + "from scipy.optimize import milp, LinearConstraint, Bounds\n", + "from scipy.stats import norm\n", + "\n", + "C_PLAN, C_FAIL, C_WASTE_KH, C_KIT = 600.0, 2400.0, 40.0, 120.0 # 계획 창정비, 비계획 고장(긴급 정비 포함), 미사용 수명 1000h당 손실, 시효성 부품 키트\n", + "HORIZON, MDAYS = 12, 30 # 계획 12개월, 월 30일\n", + "CAP_M, BUDGET_M = 22, 15000.0 # 월별 정비창 능력(대), 월별 예산\n", + "LOTS = [(30, 70), (45, 170), (45, 390), (70, 800)] # 시효성 키트 로트(수량, 유효기한까지 남은 일수)\n", + "KIT_MARGIN = 14 # 정비일 기준 최소 유효기간 여유(일)\n", + "\n", + "\n", + "def exp_cost_matrix(pred, rate, month_days):\n", + " \"\"\"장비 i를 월 m에 정비할 때의 기대 비용. 참 RUL ~ N(pred, sigma), sigma = 35일 + 0.12 x pred (가정). 마지막 열은 계획 기간 밖(미정비).\"\"\"\n", + " sig = 35 + 0.12 * pred\n", + " C = np.zeros((len(pred), len(month_days) + 1))\n", + " for j, day in enumerate(month_days):\n", + " z = (day - pred) / sig\n", + " pf = norm.cdf(z)\n", + " unused = np.maximum(pred - day, 0) * rate * 0.6 # 정비를 앞당긴 만큼 버리는 운용시간 h(근사)\n", + " C[:, j] = C_PLAN + C_KIT + pf * (C_FAIL - C_PLAN) + C_WASTE_KH * unused / 1000 * (1 - pf)\n", + " C[:, -1] = norm.cdf((HORIZON * MDAYS - pred) / sig) * C_FAIL\n", + " return C\n", + "\n", + "\n", + "def plan_milp(pred, rate, cap=CAP_M, budget=BUDGET_M, lots=LOTS, use_kit=True):\n", + " \"\"\"정수계획으로 장비별 정비 월(0~11) 또는 미정비(-1)를 배정한다. 후보는 예측 RUL이 계획 기간 +90일 이내인 장비.\"\"\"\n", + " md = np.arange(1, HORIZON + 1) * MDAYS\n", + " cand = np.where(pred < HORIZON * MDAYS + 90)[0]\n", + " n, M = len(cand), HORIZON\n", + " C = exp_cost_matrix(pred[cand], rate[cand], md)\n", + " c = C.ravel()\n", + " A, lo, hi = [], [], []\n", + " for i in range(n): # 장비당 한 번만 배정(월 12칸 + 미정비)\n", + " row = np.zeros(n * (M + 1)); row[i * (M + 1):(i + 1) * (M + 1)] = 1; A.append(row); lo.append(1); hi.append(1)\n", + " for m in range(M): # 월별 능력·예산\n", + " row = np.zeros(n * (M + 1)); row[m::(M + 1)] = 1\n", + " A.append(row); lo.append(0); hi.append(cap)\n", + " A.append(row * (C_PLAN + C_KIT)); lo.append(0); hi.append(budget)\n", + " if use_kit: # 시효성 키트: m월 이후 정비 수요는 그 시점에 유효기간이 남은 로트 수량을 넘을 수 없다\n", + " for m in range(M):\n", + " sup = sum(q for q, e in lots if e >= md[m] + KIT_MARGIN)\n", + " row = np.zeros(n * (M + 1))\n", + " for mm in range(m, M):\n", + " row[mm::(M + 1)] = 1\n", + " A.append(row); lo.append(0); hi.append(sup)\n", + " r = milp(c, constraints=LinearConstraint(np.array(A), lo, hi), integrality=np.ones(len(c)), bounds=Bounds(0, 1), options={\"time_limit\": 20})\n", + " month = np.full(len(pred), -1)\n", + " if r.x is not None:\n", + " x = np.round(r.x).reshape(n, M + 1)\n", + " assign = np.where(x[:, :M].sum(axis=1) > 0.5, x[:, :M].argmax(axis=1), -1)\n", + " month[cand] = assign\n", + " return month\n", + "\n", + "\n", + "def plan_greedy(pred, margin=45, cap=CAP_M, budget=BUDGET_M):\n", + " \"\"\"간단한 탐욕 배정: 정비 목표일(예측 RUL - margin)이 빠른 순으로 목표일 이전 가장 늦은 빈 월, 없으면 가장 이른 빈 월에 배정.\"\"\"\n", + " cap = min(cap, int(budget // (C_PLAN + C_KIT))) # 월 예산이 허용하는 대수로 능력을 제한\n", + " month = np.full(len(pred), -1)\n", + " left = np.full(HORIZON, cap)\n", + " for i in np.argsort(pred):\n", + " if pred[i] > HORIZON * MDAYS + 60: break\n", + " tgt = int(np.clip((pred[i] - margin) // MDAYS - 1, 0, HORIZON - 1))\n", + " for m in list(range(tgt, -1, -1)) + list(range(tgt + 1, HORIZON)):\n", + " if left[m] > 0:\n", + " month[i] = m; left[m] -= 1; break\n", + " return month\n", + "\n", + "\n", + "def plan_fixed(lin_rul, cap=CAP_M, budget=BUDGET_M):\n", + " \"\"\"관행: 규정 주기 도달 순서대로(선형 잔여 일수) 월별 능력 안에서 배정.\"\"\"\n", + " return plan_greedy(np.asarray(lin_rul, float), margin=0, cap=cap, budget=budget)\n", + "\n", + "\n", + "def evaluate_plan(month, true_rul, rate, cost_kit=True):\n", + " \"\"\"참 RUL로 계획의 실제 비용을 계산한다: 정비일 이전에 고장 -> 비계획 고장 비용, 아니면 계획 비용 + 미사용 수명 손실.\"\"\"\n", + " day = np.where(month >= 0, (month + 1) * MDAYS, 10 ** 9)\n", + " fail = (true_rul < day) & (true_rul < HORIZON * MDAYS)\n", + " planned = (month >= 0) & ~fail\n", + " waste = np.where(planned, (true_rul - day) * rate * 0.6, 0) * C_WASTE_KH / 1000\n", + " cost = fail * C_FAIL + planned * (C_PLAN + (C_KIT if cost_kit else 0)) + waste\n", + " return {\"total\": float(cost.sum()), \"fail\": int(fail.sum()), \"planned\": int(planned.sum()), \"waste\": float(waste.sum()), \"unsched_fail\": int(((month < 0) & fail).sum())}\n", + "\n", + "\n", + "def kit_fefo(month, lots=LOTS):\n", + " \"\"\"유효기한이 빠른 로트부터 쓰는 FEFO로 키트를 배정하고 정비일에 유효기간이 부족한 배정 수와 만료 폐기 수량을 센다.\"\"\"\n", + " q = [[qty, e] for qty, e in sorted(lots, key=lambda t: t[1])]\n", + " short = 0\n", + " for m in sorted(month[month >= 0]):\n", + " day = (m + 1) * MDAYS\n", + " for lot in q:\n", + " if lot[1] >= day + KIT_MARGIN and lot[0] > 0:\n", + " lot[0] -= 1; break\n", + " else:\n", + " short += 1\n", + " expired = sum(l[0] for l in q if l[1] < HORIZON * MDAYS)\n", + " return {\"short\": short, \"expired_unused\": expired, \"left\": sum(l[0] for l in q)}\n", + "# ---------------------------------------------------------------------------\n", + "\n", + "tr = make_fleet(900, seed=1)\n", + "eng = fit_engine(tr)\n", + "\n", + "\n", + "def scenario(seed, shift=1.0, cap=CAP_M, budget=BUDGET_M, lots=LOTS, use_kit=True):\n", + " fl = add_features(make_fleet(900, seed=seed, shift=shift))\n", + " al = fl[fl.event == 0].reset_index(drop=True)\n", + " pred, _, _ = predict_rul(eng, al)\n", + " mf = plan_fixed(al[\"lin_rul_d\"].values, cap=cap, budget=budget)\n", + " mm = plan_milp(pred, al[\"rate\"].values, cap=cap, budget=budget, lots=lots, use_kit=use_kit)\n", + " rf, rm = evaluate_plan(mf, al[\"rul_d\"].values, al[\"rate\"].values), evaluate_plan(mm, al[\"rul_d\"].values, al[\"rate\"].values)\n", + " kf, km = kit_fefo(mf, lots), kit_fefo(mm, lots)\n", + " stock = sum(q for q, _ in lots)\n", + " return al, pred, mf, mm, rf, rm, kf, km, stock\n", + "\n", + "\n", + "# ---------------- 1) 시나리오 6종 ----------------\n", + "SC = [(\"평상\", dict(seed=3)), (\"정비창 능력 70%(15대/월)\", dict(seed=3, cap=15)), (\"예산 삭감(월 10,000만원)\", dict(seed=3, budget=10000.0)), (\"시효성 키트 재고 절반·단기 유효\", dict(seed=3, lots=[(15, 70), (20, 170), (20, 240), (30, 330)])),\n", + " (\"가동률 +30%\", dict(seed=3, shift=1.3)), (\"다른 보유 장비(시드 4)\", dict(seed=4))]\n", + "rows, cache = [], {}\n", + "for name, kw in SC:\n", + " al, pred, mf, mm, rf, rm, kf, km, stock = scenario(**kw)\n", + " cache[name] = (al, pred, mm)\n", + " rows.append((name, len(al), rf[\"total\"], rm[\"total\"], 1 - rm[\"total\"] / rf[\"total\"], rf[\"fail\"], rm[\"fail\"], kf[\"expired_unused\"], km[\"expired_unused\"], km[\"short\"]))\n", + "res = pd.DataFrame(rows, columns=[\"시나리오\", \"생존 장비\", \"관행 비용(만원)\", \"제안 비용(만원)\", \"절감률\", \"관행 비계획 고장\", \"제안 비계획 고장\", \"관행 키트 만료 폐기\", \"제안 키트 만료 폐기\", \"제안 키트 유효기간 부족\"])\n", + "print(\"[1] 시나리오별 12개월 비용 비교(관행 = 규정 주기 도달 순 고정 일정, 단가는 교육용 가정값)\")\n", + "print(res.assign(절감률=res[\"절감률\"].map(\"{:.1%}\".format)).round(0).to_string(index=False))\n", + "avg = res[\"절감률\"].mean()\n", + "print(f\" 시나리오 6종 평균 절감 {avg:.1%} (목표 15% 이상: {'달성' if avg >= .15 else '미달'}), 최소 {res['절감률'].min():.1%}({res.loc[res['절감률'].idxmin(), '시나리오']})\")\n", + "\n", + "# ---------------- 2) 백테스트: 서로 다른 보유 장비 5세트 ----------------\n", + "bt = []\n", + "for sd in (10, 11, 12, 13, 14):\n", + " _, _, _, _, rf, rm, _, _, _ = scenario(seed=sd)\n", + " bt.append((sd, 1 - rm[\"total\"] / rf[\"total\"], rf[\"fail\"], rm[\"fail\"]))\n", + "bt = pd.DataFrame(bt, columns=[\"장비 세트\", \"절감률\", \"관행 고장\", \"제안 고장\"])\n", + "print(f\"\\n[2] 백테스트(서로 다른 보유 장비 5세트): 평균 절감 {bt['절감률'].mean():.1%}, 최소 {bt['절감률'].min():.1%}, 최대 {bt['절감률'].max():.1%}, 비계획 고장 평균 {bt['관행 고장'].mean():.0f} -> {bt['제안 고장'].mean():.0f}대\")\n", + "\n", + "# ---------------- 3) 단가 민감도: 비계획 고장 비용 ----------------\n", + "sens = []\n", + "base_cfail = C_FAIL\n", + "for mult in (0.5, 1.0, 1.5, 2.0):\n", + " C_FAIL = base_cfail * mult\n", + " _, _, _, _, rf, rm, _, _, _ = scenario(seed=3)\n", + " sens.append((mult, 1 - rm[\"total\"] / rf[\"total\"]))\n", + "C_FAIL = base_cfail\n", + "print(\"[3] 비계획 고장 비용 민감도:\", \"; \".join(f\"x{m} -> 절감 {s:.0%}\" for m, s in sens))\n", + "print(\" 고장 비용이 낮아지면 앞당긴 정비의 낭비가 상대적으로 커져 절감 폭이 줄어든다. 단가는 재정 부서와 실제 값으로 다시 확인해야 한다.\")\n", + "\n", + "# ---------------- 4) HITL 결재 워크플로우 ----------------\n", + "USERS = {\"ai_bot\": \"AI\", \"pln_kim\": \"PLN\", \"pln_lee\": \"PLN\", \"log_park\": \"LOG\", \"fin_choi\": \"FIN\", \"aud_han\": \"AUD\"}\n", + "PERM = {\"AI\": {\"submit\"}, \"PLN\": {\"submit\", \"redraft\"}, \"LOG\": {\"approve\", \"reject\"}, \"FIN\": {\"approve\", \"reject\"}, \"AUD\": {\"read\"}}\n", + "\n", + "\n", + "class Flow:\n", + " def __init__(self):\n", + " self.docs, self.log, self.blocked = {}, [], []\n", + "\n", + " def _audit(self, user, action, doc, detail):\n", + " prev = self.log[-1][\"hash\"] if self.log else \"GENESIS\"\n", + " rec = {\"seq\": len(self.log) + 1, \"user\": user, \"action\": action, \"doc\": doc, \"detail\": detail, \"prev\": prev}\n", + " rec[\"hash\"] = hashlib.sha256(json.dumps(rec, ensure_ascii=False, sort_keys=True).encode()).hexdigest()[:12]\n", + " self.log.append(rec)\n", + "\n", + " def verify(self):\n", + " prev = \"GENESIS\"\n", + " for r in self.log:\n", + " body = {k: v for k, v in r.items() if k != \"hash\"}\n", + " if r[\"prev\"] != prev or hashlib.sha256(json.dumps(body, ensure_ascii=False, sort_keys=True).encode()).hexdigest()[:12] != r[\"hash\"]:\n", + " return False, r[\"seq\"]\n", + " prev = r[\"hash\"]\n", + " return True, None\n", + "\n", + " def deny(self, user, act, doc, why):\n", + " self.blocked.append((user, act, doc, why)); self._audit(user, \"DENIED:\" + act, doc, why); return False\n", + "\n", + " def submit(self, user, kind, title, n, parent=None):\n", + " need = \"redraft\" if parent else \"submit\"\n", + " if need not in PERM.get(USERS.get(user), set()):\n", + " return self.deny(user, need, \"-\", \"상신 권한 없음\")\n", + " did = f\"MNT{len(self.docs) + 1:02d}\"\n", + " self.docs[did] = {\"kind\": kind, \"title\": title, \"line\": [\"LOG\", \"FIN\"] if kind == \"도태 건의\" else [\"LOG\"], \"step\": 0, \"status\": \"검토대기\", \"drafter\": user, \"done\": [], \"parent\": parent}\n", + " self._audit(user, \"submit\", did, f\"{kind} {title} {n}대\")\n", + " return did\n", + "\n", + " def decide(self, user, did, act, reason=\"\"):\n", + " role, d = USERS.get(user), self.docs.get(did)\n", + " if role is None: return self.deny(user, act, did, \"미등록 사용자\")\n", + " if d is None or d[\"status\"] != \"검토대기\": return self.deny(user, act, did, \"결재 대상 아님(처리 완료)\")\n", + " if act not in PERM[role]: return self.deny(user, act, did, f\"{role} 역할은 {act} 불가\")\n", + " if d[\"line\"][d[\"step\"]] != role: return self.deny(user, act, did, \"결재 순서 위반\")\n", + " if user == d[\"drafter\"] or user in d[\"done\"]: return self.deny(user, act, did, \"기안자·기결재자 재결재 금지\")\n", + " if len(reason.strip()) < 10: return self.deny(user, act, did, \"사유 10자 이상 필수\")\n", + " if act == \"reject\":\n", + " d[\"status\"] = \"반려\"\n", + " else:\n", + " d[\"done\"].append(user); d[\"step\"] += 1\n", + " d[\"status\"] = \"승인\" if d[\"step\"] == len(d[\"line\"]) else \"검토대기\"\n", + " self._audit(user, act, did, reason); return True\n", + "\n", + " def edit(self, user, did):\n", + " if self.docs[did][\"status\"] == \"승인\": return self.deny(user, \"edit\", did, \"승인 후 일정 변경 금지(재기안)\")\n", + " return True\n", + "\n", + "\n", + "al, pred, mm = cache[\"평상\"]\n", + "al = al.assign(rul_pred=pred, month=mm)\n", + "scrap = al[(al[\"age_y\"] >= 14) & (al[\"rul_pred\"] < 365)] # 도태 건의 후보: 사용 14년 이상이고 1년 내 수명 종료 예측\n", + "wf = Flow()\n", + "docs = [wf.submit(\"ai_bot\" if q % 2 == 0 else \"pln_kim\", \"정비 일정\", f\"{3 * q + 1}~{3 * q + 3}월 일정안\", int(((al.month >= 3 * q) & (al.month < 3 * q + 3)).sum())) for q in range(2)]\n", + "docs += [wf.submit(\"pln_lee\", \"도태 건의\", f\"{u} 부대 노후 장비\", int((scrap.unit == u).sum())) for u in scrap[\"unit\"].value_counts().index[:2]]\n", + "two = [i for i in docs if len(wf.docs[i][\"line\"]) == 2]\n", + "print(f\"\\n[4] AI 추천안 {len(docs)}건(정비 일정 2건, 도태 건의 {len(two)}건; 도태 후보 {len(scrap)}대) 전부 '검토대기' 등록, 자동 승인 {sum(wf.docs[i]['status'] == '승인' for i in docs)}건 (목표 0건). 2단계 결재(군수지휘관 + 재정 담당) 대상 {two}\")\n", + "wf.decide(\"log_park\", docs[0], \"reject\", \"1~3월 정비 순서에 키트 유효기간 재확인 필요, 재기안 요청\")\n", + "new = wf.submit(\"pln_kim\", \"정비 일정\", \"1~3월 일정안(재기안)\", int(((al.month >= 0) & (al.month < 3)).sum()), parent=docs[0])\n", + "wf.decide(\"log_park\", new, \"approve\", \"키트 유효기간 확인 완료, 일정 확정\")\n", + "wf.decide(\"log_park\", docs[1], \"approve\", \"4~6월 일정, 정비창 능력 범위 내 확인함\")\n", + "if two:\n", + " wf.decide(\"log_park\", two[0], \"approve\", \"도태 건의 사유 타당, 재정 담당 검토 상신\")\n", + " wf.decide(\"fin_choi\", two[0], \"approve\", \"잔존 가치 대비 수리비 초과 확인, 도태 건의 동의\")\n", + "tgt = two[-1] if two else docs[-1]\n", + "attacks = [(\"pln_kim\", tgt, \"approve\", \"상신자가 직접 승인한다고 처리함\"), (\"ai_bot\", tgt, \"approve\", \"AI 계정이 직접 승인하려고 시도함\"), (\"aud_han\", tgt, \"approve\", \"감사 계정으로 승인 시도함\"), (\"ghost\", tgt, \"approve\", \"외부 계정으로 승인 시도함\"),\n", + " (\"log_park\", tgt, \"approve\", \"\"), (\"log_park\", tgt, \"reject\", \"짧음\"), (\"fin_choi\", tgt, \"approve\", \"재정 담당이 순서를 건너뛰어 승인\"), (\"log_park\", new, \"reject\", \"승인된 일정을 뒤집어 반려함\"),\n", + " (\"pln_lee\", tgt, \"approve\", \"상신자 계정으로 승인 시도함\")]\n", + "for u, doc, a, rs in attacks:\n", + " wf.decide(u, doc, a, rs)\n", + "wf.edit(\"pln_kim\", new)\n", + "print(f\" 권한 우회 시도 10건 중 차단 {len(wf.blocked)}건 (차단율 {len(wf.blocked) / 10:.0%}, 목표 100%)\")\n", + "for u, a, doc, why in wf.blocked[:4]:\n", + " print(f\" 차단 {u:<9} {a:<8} {doc}: {why}\")\n", + "ok, _ = wf.verify()\n", + "snap = json.loads(json.dumps(wf.log))\n", + "wf.log[3][\"detail\"] = \"사후에 고쳐 쓴 사유\"\n", + "ok2, at = wf.verify()\n", + "wf.log = snap\n", + "print(f\" 감사 로그 {len(wf.log)}건 해시 체인 무결성 {'100%' if ok else '훼손'}; 4번 기록 사후 수정 시 {'통과' if ok2 else f'위변조 검출({at}번)'}\")\n", + "print(f\" {docs[0]}(반려) -> {new}(재기안, parent 연결) 이력:\", [(r['action'], r['user']) for r in wf.log if r['doc'] in (docs[0], new)])\n", + "print(\" 문서 상태:\", pd.Series({k: v[\"status\"] for k, v in wf.docs.items()}).value_counts().to_dict(), \"| 정비 일정 확정과 장비 도태는 군수지휘관 결재(도태는 재정 담당 포함)로만 확정된다.\")\n", + "\n", + "fig, ax = plt.subplots(1, 2, figsize=(12, 4))\n", + "x = np.arange(len(res)); ax[0].bar(x - .2, res[\"관행 비용(만원)\"], .4, label=\"fixed-cycle\"); ax[0].bar(x + .2, res[\"제안 비용(만원)\"], .4, label=\"risk-based plan\")\n", + "ax[0].set_xticks(x); ax[0].set_xticklabels([f\"S{i + 1}\" for i in x]); ax[0].legend(); ax[0].set_title(\"12-month cost by scenario (10k KRW)\")\n", + "ax[1].plot([s[0] for s in sens], [s[1] * 100 for s in sens], \"o-\"); ax[1].set_title(\"Saving (%) vs unplanned-failure cost multiplier\"); ax[1].set_xlabel(\"multiplier\")\n", + "plt.tight_layout(); plt.show()\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 07 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + "[1] 시나리오별 12개월 비용 비교(관행 = 규정 주기 도달 순 고정 일정, 단가는 교육용 가정값)\n", + " 시나리오 생존 장비 관행 비용(만원) 제안 비용(만원) 절감률 관행 비계획 고장 제안 비계획 고장 관행 키트 만료 폐기 제안 키트 만료 폐기 제안 키트 유효기간 부족\n", + " 평상 601 184752.0 116447.0 37.0% 47 21 10 10 0\n", + " 정비창 능력 70%(15대/월) 601 184691.0 122971.0 33.4% 47 25 15 15 0\n", + " 예산 삭감(월 10,000만원) 601 184655.0 124543.0 32.6% 47 26 17 17 0\n", + "시효성 키트 재고 절반·단기 유효 601 184752.0 114897.0 37.8% 47 25 0 0 0\n", + " 가동률 +30% 601 225885.0 149598.0 33.8% 61 29 10 10 0\n", + " 다른 보유 장비(시드 4) 590 164663.0 102951.0 37.5% 46 19 17 10 0\n", + " 시나리오 6종 평균 절감 35.3% (목표 15% 이상: 달성), 최소 32.6%(예산 삭감(월 10,000만원))\n", + "\n", + "[2] 백테스트(서로 다른 보유 장비 5세트): 평균 절감 35.4%, 최소 32.4%, 최대 41.6%, 비계획 고장 평균 54 -> 27대\n", + "[3] 비계획 고장 비용 민감도: x0.5 -> 절감 35%; x1.0 -> 절감 37%; x1.5 -> 절감 41%; x2.0 -> 절감 45%\n", + " 고장 비용이 낮아지면 앞당긴 정비의 낭비가 상대적으로 커져 절감 폭이 줄어든다. 단가는 재정 부서와 실제 값으로 다시 확인해야 한다.\n", + "\n", + "[4] AI 추천안 4건(정비 일정 2건, 도태 건의 2건; 도태 후보 22대) 전부 '검토대기' 등록, 자동 승인 0건 (목표 0건). 2단계 결재(군수지휘관 + 재정 담당) 대상 ['MNT03', 'MNT04']\n", + " 권한 우회 시도 10건 중 차단 10건 (차단율 100%, 목표 100%)\n", + " 차단 pln_kim approve MNT04: PLN 역할은 approve 불가\n", + " 차단 ai_bot approve MNT04: AI 역할은 approve 불가\n", + " 차단 aud_han approve MNT04: AUD 역할은 approve 불가\n", + " 차단 ghost approve MNT04: 미등록 사용자\n", + " 감사 로그 20건 해시 체인 무결성 100%; 4번 기록 사후 수정 시 위변조 검출(4번)\n", + " MNT01(반려) -> MNT05(재기안, parent 연결) 이력: [('submit', 'ai_bot'), ('reject', 'log_park'), ('submit', 'pln_kim'), ('approve', 'log_park'), ('DENIED:reject', 'log_park'), ('DENIED:edit', 'pln_kim')]\n", + " 문서 상태: {'승인': 3, '반려': 1, '검토대기': 1} | 정비 일정 확정과 장비 도태는 군수지휘관 결재(도태는 재정 담당 포함)로만 확정된다.\n", + "```\n", + "
\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 08. 전체 군단 보유 장비 1만대 수명 일괄 시뮬레이션 부하 테스트\n", + "**소요시간**: 약 3.0시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "보유 장비 1만대 수명 일괄 시뮬레이션 부하 테스트를 수행한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **보유 장비 1만 대 수명 일괄 시뮬레이션 부하 시험과 경보 정확도 시험을 수행한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **보유 장비 1천~10만 대 규모에서 피처 생성, 수명 예측, 일정 배정, 대시보드 집계 시간을 잰다 과정을 진행하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `src/dashboard/sup02_app.py (Streamlit/Dash 등)` 형태의 결과물을 완성한다.\n", + "\n", + "### 🔍 평가 및 검증 기준 (PASS 조건)\n", + "#### 미션 판정\n", + "\n", + "**평가 구분:** 필수\n", + "\n", + "**핵심 평가영역:** 의사결정 지원에 최적화된 시각적 위계 및 사용자 경험(UX)\n", + "\n", + "#### 미션 PASS 조건\n", + "\n", + "- 정량 기준: 대시보드 로딩 시간 2초 이내, 경보 발령 정확도 95% 이상\n", + "\n", + "\n", + "- 진행 기준: 웹 기반 분석 대시보드 구동 및 실시간 필터링 기능 검증\n", + "\n", + "\n", + "- 검증 방법: 실제 군수 데이터 연동 화면 테스트 및 UI 동작 검증\n", + "\n", + "\n", + "#### 채점자 판정 메모\n", + "\n", + "> 단순 코드 실행 여부뿐만 아니라 출력된 수치와 근거 자료가 군 실무 요구사항에 부합하는지 면밀히 확인한다.\n", + "\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "\"\"\"보유 장비 1천~10만 대의 수명(RUL) 일괄 예측·위험 경보·계획정비 일정 배정·대시보드 집계 시뮬레이션으로 단계별 처리 시간과 대시보드 로딩 시간을 재고, 결측·환경 오기재·단위 오류 주입 조건에서 위험 경보 정확도를 측정하며 정수계획의 규모 한계와 대응(탐욕 배정)을 정리한다.\"\"\"\n", + "import time\n", + "import numpy as np\n", + "import pandas as pd\n", + "import matplotlib.pyplot as plt\n", + "\n", + "# ---------------------------------------------------------------------------\n", + "# [교육용가상자료] 가상 장비 보유 현황·고장 이력 생성기 (실제 부대·장비·수치 없음)\n", + "# 가상 부대 6개(U1~U6)의 장비가 운용 환경(normal/rough/salt/cold)에 따라 다른 속도로 노후화한다. 창정비 후 운용시간(h_since)이\n", + "# 장비마다 숨은 수명(t_fail)을 넘으면 고장(event=1) 이력, 넘지 않았으면 현재까지 생존(event=0, 관측 중도절단)이다.\n", + "# ---------------------------------------------------------------------------\n", + "ENVS = [\"normal\", \"rough\", \"salt\", \"cold\"]\n", + "ENV_W = {\"normal\": 1.00, \"rough\": 1.35, \"salt\": 1.25, \"cold\": 1.12} # 환경 가중치(숨은 정답, 가정값): 클수록 수명이 짧다\n", + "ENV_KO = {\"normal\": \"일반\", \"rough\": \"험지\", \"salt\": \"염분\", \"cold\": \"혹한\"}\n", + "UNITS = [\"U1\", \"U2\", \"U3\", \"U4\", \"U5\", \"U6\"]\n", + "UNIT_ENV = {\"U1\": [.7, .1, .1, .1], \"U2\": [.2, .6, .1, .1], \"U3\": [.2, .1, .6, .1], \"U4\": [.3, .1, .1, .5], \"U5\": [.5, .3, .1, .1], \"U6\": [.4, .1, .4, .1]}\n", + "ETYPES = [\"truck\", \"generator\", \"trailer\", \"radio\"]\n", + "CYCLE = 8000.0 # 규정 창정비 주기(운용시간 h, 가정값)\n", + "KSHAPE = 12.0 # 수명 분포 와이블 형상(마모형 고장, 클수록 수명이 모여 있다)\n", + "INSP_NOISE = 0.025 # 정비 점검 마모 비율 측정 오차(가정값)\n", + "\n", + "\n", + "def make_fleet(n=900, seed=0, env_w=None, shift=1.0):\n", + " \"\"\"장비 n대. env_w로 숨은 환경 가중치를 바꿔 운영 분포 변화를 모의할 수 있다. shift>1이면 가동률이 높아진다.\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " w = env_w or ENV_W\n", + " unit = rng.choice(UNITS, n)\n", + " env = np.array([rng.choice(ENVS, p=UNIT_ENV[u]) for u in unit])\n", + " et = rng.choice(ETYPES, n, p=[.4, .2, .2, .2])\n", + " rate = rng.lognormal(np.log(3.0 * shift), 0.25, n) # 일 평균 운용시간(h/일)\n", + " h_since = rng.uniform(300, 11500, n) # 마지막 창정비 이후 운용시간\n", + " n_ovh = rng.integers(0, 3, n)\n", + " hours = h_since + n_ovh * rng.uniform(5000, 8000, n) # 누적 운용시간\n", + " km = hours * rng.normal(17, 3, n) * np.where(env == \"rough\", 0.8, 1.0) # 누적 기동거리(km)\n", + " age_y = np.clip(hours / (rate * 365) * rng.uniform(0.9, 1.15, n), 0.5, 25)\n", + " unpl = rng.poisson(0.35 * h_since / 1000 * np.array([w[e] for e in env]) / 1.3) # 비계획 정비 건수(환경이 가혹할수록 많다)\n", + " n_maint = rng.poisson(0.6 * h_since / 1000 + 1) # 계획 정비 건수\n", + " scale = 9600 / np.array([w[e] for e in env]) * 0.985 ** unpl * np.exp(rng.normal(0, 0.04, n))\n", + " t_fail = scale * rng.weibull(KSHAPE, n) # 창정비 후 고장까지 운용시간(숨은 정답)\n", + " event = (t_fail <= h_since).astype(int)\n", + " dur = np.where(event == 1, t_fail, h_since)\n", + " df = pd.DataFrame({\"eq_id\": [f\"EQ{seed:02d}{i:04d}\" for i in range(n)], \"unit\": unit, \"env\": env, \"etype\": et, \"rate\": rate, \"hours\": hours, \"km\": km, \"age_y\": age_y, \"n_ovh\": n_ovh,\n", + " \"h_since\": h_since, \"unpl\": unpl, \"n_maint\": n_maint, \"t_fail\": t_fail, \"event\": event, \"dur\": dur})\n", + " df[\"insp\"] = np.clip(dur / t_fail + rng.normal(0, INSP_NOISE, n), 0, 1.3) # 마지막 정비 점검에서 측정한 마모 비율(소모 수명/총 수명, 오차 포함)\n", + " df[\"rul_h\"] = np.where(event == 0, t_fail - h_since, np.nan) # 생존 장비의 남은 운용시간(정답)\n", + " df[\"rul_d\"] = df[\"rul_h\"] / df[\"rate\"] # 남은 일수(정답)\n", + " return df\n", + "\n", + "\n", + "def mape(y, p):\n", + " # [TODO : 학생 실습 8] 전체 군단 보유 장비 1만대 수명 일괄 시뮬레이션 부하 테스트 관련 핵심 로직을 구현하세요.\n", + " # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n", + " # <<< 여기에 코드를 작성하세요 >>>\n", + " pass # 작성 후 pass를 제거하세요.\n", + "def mae(y, p):\n", + " # [TODO : 학생 실습 8] 전체 군단 보유 장비 1만대 수명 일괄 시뮬레이션 부하 테스트 관련 핵심 로직을 구현하세요.\n", + " # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n", + " # <<< 여기에 코드를 작성하세요 >>>\n", + " pass # 작성 후 pass를 제거하세요.\n", + "def rmse(y, p):\n", + " return float(np.sqrt(np.mean((np.asarray(y, float) - np.asarray(p, float)) ** 2)))\n", + "# ---------------------------------------------------------------------------\n", + "# ---------------------------------------------------------------------------\n", + "# 노후화 파생 피처와 학습 표본 만들기. 환경 가중치 W0는 도입 시점의 전문가 추정값이며(실제 가중치와 다름) M9에서 재보정한다.\n", + "# ---------------------------------------------------------------------------\n", + "W0 = {\"normal\": 1.00, \"rough\": 1.15, \"salt\": 1.10, \"cold\": 1.05}\n", + "FEATS = [\"h_since\", \"hours\", \"km\", \"age_y\", \"rate\", \"n_maint\", \"unpl\", \"unpl_per_kh\", \"km_per_h\", \"n_ovh\", \"k_rough\", \"k_salt\", \"k_cold\", \"k_truck\", \"k_gen\", \"k_trailer\",\n", + " \"aging_idx\", \"lin_rul_d\", \"hs_frac\", \"insp\", \"insp_eta_h\"]\n", + "\n", + "\n", + "def add_features(df, w=None):\n", + " d = df.copy()\n", + " w = w or W0\n", + " d[\"unpl_per_kh\"] = d[\"unpl\"] / (d[\"h_since\"] / 1000 + 0.5)\n", + " d[\"km_per_h\"] = d[\"km\"] / d[\"hours\"]\n", + " for e in (\"rough\", \"salt\", \"cold\"):\n", + " d[\"k_\" + e] = (d[\"env\"] == e).astype(int)\n", + " d[\"k_truck\"], d[\"k_gen\"], d[\"k_trailer\"] = (d[\"etype\"] == \"truck\").astype(int), (d[\"etype\"] == \"generator\").astype(int), (d[\"etype\"] == \"trailer\").astype(int)\n", + " d[\"aging_idx\"] = d[\"h_since\"] * d[\"env\"].map(w) / CYCLE # 환경 가중 노후 지수(1이면 창정비 주기에 도달)\n", + " d[\"lin_rul_d\"] = (CYCLE - d[\"h_since\"]) / d[\"rate\"] # 선형 감가상각 잔여 일수(Baseline)\n", + " d[\"hs_frac\"] = d[\"h_since\"] / CYCLE\n", + " d[\"insp_eta_h\"] = d[\"h_since\"] / np.maximum(d[\"insp\"], 0.05) # 점검 마모 비율로 환산한 총 수명 추정(h)\n", + " return d\n", + "\n", + "\n", + "def snapshots(df, k=4, seed=0):\n", + " \"\"\"고장 이력 장비의 과거 시점 표본: 고장 전 임의 시점의 점검값·운용시간과 그때의 남은 일수. (생존 장비의 미래는 알 수 없으므로 고장 이력만 쓴다)\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " f = df[df[\"event\"] == 1]\n", + " out = []\n", + " for _ in range(k):\n", + " s = f.copy()\n", + " h = s[\"t_fail\"] * rng.uniform(0.2, 0.98, len(s))\n", + " ratio = h / s[\"h_since\"]\n", + " s[\"hours\"] = s[\"hours\"] - (s[\"h_since\"] - h)\n", + " s[\"km\"] = s[\"km\"] * ratio.clip(upper=1)\n", + " s[\"unpl\"] = np.floor(s[\"unpl\"] * ratio)\n", + " s[\"n_maint\"] = np.floor(s[\"n_maint\"] * ratio)\n", + " s[\"insp\"] = np.clip(h / s[\"t_fail\"] + rng.normal(0, INSP_NOISE, len(s)), 0, 1.3)\n", + " s[\"h_since\"] = h\n", + " s[\"rul_d\"] = (s[\"t_fail\"] - h) / s[\"rate\"]\n", + " s[\"event\"] = 0\n", + " out.append(s)\n", + " return pd.concat(out, ignore_index=True)\n", + "# ---------------------------------------------------------------------------\n", + "# ---------------------------------------------------------------------------\n", + "# 생존 분석 도구(scipy만 사용): 와이블 AFT(가속 수명) 모형과 Cox 비례위험 모형\n", + "# ---------------------------------------------------------------------------\n", + "from scipy.optimize import minimize\n", + "\n", + "\n", + "def aft_x(d):\n", + " return np.c_[np.ones(len(d)), d[\"env\"].eq(\"rough\"), d[\"env\"].eq(\"salt\"), d[\"env\"].eq(\"cold\"), d[\"unpl\"] / (d[\"h_since\"] / 1000 + 0.5)].astype(float)\n", + "\n", + "\n", + "AFT_COLS = [\"절편\", \"험지\", \"염분\", \"혹한\", \"비계획 정비율\"]\n", + "\n", + "\n", + "def fit_aft(d):\n", + " \"\"\"창정비 후 운용시간 dur(고장이면 event=1, 생존이면 중도절단)에 대한 와이블 AFT 최대우도 추정. 반환: 계수(마지막은 log 형상).\"\"\"\n", + " x, t, e = aft_x(d), d[\"dur\"].values, d[\"event\"].values\n", + "\n", + " def nll(p):\n", + " lam, k = np.exp(x @ p[:-1]), np.exp(p[-1])\n", + " z = (t / lam) ** k\n", + " return -(e * (np.log(k) - np.log(lam) + (k - 1) * (np.log(t) - np.log(lam))) - z).sum()\n", + "\n", + " r = minimize(nll, np.r_[np.log(8000), np.zeros(x.shape[1] - 1), np.log(5)], method=\"BFGS\")\n", + " return r.x\n", + "\n", + "\n", + "def aft_pred(p, d, q=0.5, h=None):\n", + " \"\"\"이미 h(운용시간)까지 생존했다는 조건에서 분위수 q 생존 시점(운용시간 h 기준 총 수명)을 돌려준다. q=0.5가 조건부 중앙값.\"\"\"\n", + " lam, k = np.exp(aft_x(d) @ p[:-1]), np.exp(p[-1])\n", + " h = d[\"h_since\"].values if h is None else h\n", + " return lam * ((h / lam) ** k - np.log(q)) ** (1 / k)\n", + "\n", + "\n", + "def aft_fail_prob(p, d, h0, h1):\n", + " \"\"\"h0까지 생존한 장비가 h0~h1 사이에 고장 날 조건부 확률.\"\"\"\n", + " lam, k = np.exp(aft_x(d) @ p[:-1]), np.exp(p[-1])\n", + " s0, s1 = np.exp(-(h0 / lam) ** k), np.exp(-(h1 / lam) ** k)\n", + " return 1 - s1 / s0\n", + "\n", + "\n", + "def fit_cox(d, iters=25):\n", + " \"\"\"Cox 비례위험(Breslow 근사) 뉴턴-랩슨. 공변량: 험지·염분·혹한·비계획 정비율. 반환: 계수.\"\"\"\n", + " x = aft_x(d)[:, 1:]\n", + " o = np.argsort(-d[\"dur\"].values)\n", + " x, e = x[o], d[\"event\"].values[o]\n", + " b = np.zeros(x.shape[1])\n", + " for _ in range(iters):\n", + " r = np.exp(x @ b)\n", + " S0 = np.cumsum(r)\n", + " S1 = np.cumsum(r[:, None] * x, axis=0)\n", + " S2 = np.cumsum(r[:, None, None] * x[:, :, None] * x[:, None, :], axis=0)\n", + " m1 = S1 / S0[:, None]\n", + " g = ((x - m1) * e[:, None]).sum(axis=0)\n", + " H = -sum(e[i] * (S2[i] / S0[i] - np.outer(m1[i], m1[i])) for i in np.where(e == 1)[0])\n", + " step = np.linalg.solve(H - 1e-6 * np.eye(len(b)), g)\n", + " b = b - step\n", + " if np.abs(step).max() < 1e-7:\n", + " break\n", + " return b\n", + "\n", + "\n", + "def cindex(time, event, risk):\n", + " \"\"\"일치도 C-index: 위험 점수가 높은 쪽이 먼저 고장 났는가(관측 가능한 쌍만).\"\"\"\n", + " t, e, r = np.asarray(time), np.asarray(event), np.asarray(risk)\n", + " num = den = 0.0\n", + " for i in np.where(e == 1)[0]:\n", + " m = t > t[i]\n", + " den += m.sum()\n", + " num += (r[m] < r[i]).sum() + 0.5 * (r[m] == r[i]).sum()\n", + " return num / max(den, 1)\n", + "# ---------------------------------------------------------------------------\n", + "# ---------------------------------------------------------------------------\n", + "# 잔여 수명(RUL, 일) 예측 엔진: 와이블 AFT 조건부 중앙값 + 그래디언트 부스팅(고장 이력 시점 표본 학습)의 평균\n", + "# ---------------------------------------------------------------------------\n", + "from sklearn.ensemble import GradientBoostingRegressor\n", + "\n", + "\n", + "def fit_engine(train, seed=0):\n", + " p = fit_aft(train)\n", + " sn = add_features(snapshots(train, 5, seed))\n", + " sn[\"aft\"] = (aft_pred(p, sn) - sn[\"h_since\"]) / sn[\"rate\"]\n", + " g = GradientBoostingRegressor(n_estimators=250, max_depth=3, learning_rate=0.06, random_state=0).fit(sn[FEATS + [\"aft\"]], sn[\"rul_d\"])\n", + " return {\"p\": p, \"g\": g}\n", + "\n", + "\n", + "def predict_rul(eng, d):\n", + " \"\"\"d는 add_features를 거친 표. 반환: (앙상블 RUL, AFT RUL, GBR RUL) - 일 단위.\"\"\"\n", + " aft = ((aft_pred(eng[\"p\"], d) - d[\"h_since\"]) / d[\"rate\"]).values\n", + " x = d[FEATS].copy()\n", + " x[\"aft\"] = aft\n", + " gb = eng[\"g\"].predict(x)\n", + " return np.clip(0.5 * aft + 0.5 * gb, 1, None), aft, gb\n", + "# ---------------------------------------------------------------------------\n", + "# ---------------------------------------------------------------------------\n", + "# 계획정비(창정비) 일정 추천: 월별 정비창 능력·예산·시효성 부품 키트(유효기한) 제약 아래 기대 비용 최소화 (비용 단가는 교육용 가정값, 만원)\n", + "# ---------------------------------------------------------------------------\n", + "from scipy.optimize import milp, LinearConstraint, Bounds\n", + "from scipy.stats import norm\n", + "\n", + "C_PLAN, C_FAIL, C_WASTE_KH, C_KIT = 600.0, 2400.0, 40.0, 120.0 # 계획 창정비, 비계획 고장(긴급 정비 포함), 미사용 수명 1000h당 손실, 시효성 부품 키트\n", + "HORIZON, MDAYS = 12, 30 # 계획 12개월, 월 30일\n", + "CAP_M, BUDGET_M = 22, 15000.0 # 월별 정비창 능력(대), 월별 예산\n", + "LOTS = [(30, 70), (45, 170), (45, 390), (70, 800)] # 시효성 키트 로트(수량, 유효기한까지 남은 일수)\n", + "KIT_MARGIN = 14 # 정비일 기준 최소 유효기간 여유(일)\n", + "\n", + "\n", + "def exp_cost_matrix(pred, rate, month_days):\n", + " \"\"\"장비 i를 월 m에 정비할 때의 기대 비용. 참 RUL ~ N(pred, sigma), sigma = 35일 + 0.12 x pred (가정). 마지막 열은 계획 기간 밖(미정비).\"\"\"\n", + " sig = 35 + 0.12 * pred\n", + " C = np.zeros((len(pred), len(month_days) + 1))\n", + " for j, day in enumerate(month_days):\n", + " z = (day - pred) / sig\n", + " pf = norm.cdf(z)\n", + " unused = np.maximum(pred - day, 0) * rate * 0.6 # 정비를 앞당긴 만큼 버리는 운용시간 h(근사)\n", + " C[:, j] = C_PLAN + C_KIT + pf * (C_FAIL - C_PLAN) + C_WASTE_KH * unused / 1000 * (1 - pf)\n", + " C[:, -1] = norm.cdf((HORIZON * MDAYS - pred) / sig) * C_FAIL\n", + " return C\n", + "\n", + "\n", + "def plan_milp(pred, rate, cap=CAP_M, budget=BUDGET_M, lots=LOTS, use_kit=True):\n", + " \"\"\"정수계획으로 장비별 정비 월(0~11) 또는 미정비(-1)를 배정한다. 후보는 예측 RUL이 계획 기간 +90일 이내인 장비.\"\"\"\n", + " md = np.arange(1, HORIZON + 1) * MDAYS\n", + " cand = np.where(pred < HORIZON * MDAYS + 90)[0]\n", + " n, M = len(cand), HORIZON\n", + " C = exp_cost_matrix(pred[cand], rate[cand], md)\n", + " c = C.ravel()\n", + " A, lo, hi = [], [], []\n", + " for i in range(n): # 장비당 한 번만 배정(월 12칸 + 미정비)\n", + " row = np.zeros(n * (M + 1)); row[i * (M + 1):(i + 1) * (M + 1)] = 1; A.append(row); lo.append(1); hi.append(1)\n", + " for m in range(M): # 월별 능력·예산\n", + " row = np.zeros(n * (M + 1)); row[m::(M + 1)] = 1\n", + " A.append(row); lo.append(0); hi.append(cap)\n", + " A.append(row * (C_PLAN + C_KIT)); lo.append(0); hi.append(budget)\n", + " if use_kit: # 시효성 키트: m월 이후 정비 수요는 그 시점에 유효기간이 남은 로트 수량을 넘을 수 없다\n", + " for m in range(M):\n", + " sup = sum(q for q, e in lots if e >= md[m] + KIT_MARGIN)\n", + " row = np.zeros(n * (M + 1))\n", + " for mm in range(m, M):\n", + " row[mm::(M + 1)] = 1\n", + " A.append(row); lo.append(0); hi.append(sup)\n", + " r = milp(c, constraints=LinearConstraint(np.array(A), lo, hi), integrality=np.ones(len(c)), bounds=Bounds(0, 1), options={\"time_limit\": 20})\n", + " month = np.full(len(pred), -1)\n", + " if r.x is not None:\n", + " x = np.round(r.x).reshape(n, M + 1)\n", + " assign = np.where(x[:, :M].sum(axis=1) > 0.5, x[:, :M].argmax(axis=1), -1)\n", + " month[cand] = assign\n", + " return month\n", + "\n", + "\n", + "def plan_greedy(pred, margin=45, cap=CAP_M, budget=BUDGET_M):\n", + " \"\"\"간단한 탐욕 배정: 정비 목표일(예측 RUL - margin)이 빠른 순으로 목표일 이전 가장 늦은 빈 월, 없으면 가장 이른 빈 월에 배정.\"\"\"\n", + " cap = min(cap, int(budget // (C_PLAN + C_KIT))) # 월 예산이 허용하는 대수로 능력을 제한\n", + " month = np.full(len(pred), -1)\n", + " left = np.full(HORIZON, cap)\n", + " for i in np.argsort(pred):\n", + " if pred[i] > HORIZON * MDAYS + 60: break\n", + " tgt = int(np.clip((pred[i] - margin) // MDAYS - 1, 0, HORIZON - 1))\n", + " for m in list(range(tgt, -1, -1)) + list(range(tgt + 1, HORIZON)):\n", + " if left[m] > 0:\n", + " month[i] = m; left[m] -= 1; break\n", + " return month\n", + "\n", + "\n", + "def plan_fixed(lin_rul, cap=CAP_M, budget=BUDGET_M):\n", + " \"\"\"관행: 규정 주기 도달 순서대로(선형 잔여 일수) 월별 능력 안에서 배정.\"\"\"\n", + " return plan_greedy(np.asarray(lin_rul, float), margin=0, cap=cap, budget=budget)\n", + "\n", + "\n", + "def evaluate_plan(month, true_rul, rate, cost_kit=True):\n", + " \"\"\"참 RUL로 계획의 실제 비용을 계산한다: 정비일 이전에 고장 -> 비계획 고장 비용, 아니면 계획 비용 + 미사용 수명 손실.\"\"\"\n", + " day = np.where(month >= 0, (month + 1) * MDAYS, 10 ** 9)\n", + " fail = (true_rul < day) & (true_rul < HORIZON * MDAYS)\n", + " planned = (month >= 0) & ~fail\n", + " waste = np.where(planned, (true_rul - day) * rate * 0.6, 0) * C_WASTE_KH / 1000\n", + " cost = fail * C_FAIL + planned * (C_PLAN + (C_KIT if cost_kit else 0)) + waste\n", + " return {\"total\": float(cost.sum()), \"fail\": int(fail.sum()), \"planned\": int(planned.sum()), \"waste\": float(waste.sum()), \"unsched_fail\": int(((month < 0) & fail).sum())}\n", + "\n", + "\n", + "def kit_fefo(month, lots=LOTS):\n", + " \"\"\"유효기한이 빠른 로트부터 쓰는 FEFO로 키트를 배정하고 정비일에 유효기간이 부족한 배정 수와 만료 폐기 수량을 센다.\"\"\"\n", + " q = [[qty, e] for qty, e in sorted(lots, key=lambda t: t[1])]\n", + " short = 0\n", + " for m in sorted(month[month >= 0]):\n", + " day = (m + 1) * MDAYS\n", + " for lot in q:\n", + " if lot[1] >= day + KIT_MARGIN and lot[0] > 0:\n", + " lot[0] -= 1; break\n", + " else:\n", + " short += 1\n", + " expired = sum(l[0] for l in q if l[1] < HORIZON * MDAYS)\n", + " return {\"short\": short, \"expired_unused\": expired, \"left\": sum(l[0] for l in q)}\n", + "# ---------------------------------------------------------------------------\n", + "\n", + "tr = make_fleet(900, seed=1)\n", + "t0 = time.perf_counter()\n", + "eng = fit_engine(tr)\n", + "print(f\"[0] 예측 엔진 학습 {time.perf_counter() - t0:.2f}초(학습 {len(tr)}대, 시점 표본 {int(tr.event.sum()) * 5}건)\")\n", + "ALARM_D, MARGIN = 180, 60 # 위험 정의: 참 RUL 180일 미만 / 경보 기준: 예측 RUL이 180+60일 미만(예측 오차를 고려한 여유)\n", + "\n", + "\n", + "def pipeline(fl):\n", + " \"\"\"일괄 처리: 피처 -> RUL 예측 -> 경보 -> 일정 배정(탐욕) -> 대시보드 집계. 단계별 시간을 반환한다.\"\"\"\n", + " t = {}\n", + " s = time.perf_counter(); d = add_features(fl); t[\"피처\"] = time.perf_counter() - s\n", + " al = d[d.event == 0].reset_index(drop=True)\n", + " s = time.perf_counter(); pred, _, _ = predict_rul(eng, al); t[\"예측\"] = time.perf_counter() - s\n", + " s = time.perf_counter(); month = plan_greedy(pred, cap=max(CAP_M, int(len(al) * 0.03)), budget=1e9); t[\"일정\"] = time.perf_counter() - s\n", + " s = time.perf_counter()\n", + " al[\"pred\"], al[\"month\"] = pred, month\n", + " summ = al.groupby(\"unit\").agg(장비=(\"pred\", \"size\"), 평균RUL=(\"pred\", \"mean\"), 위험=(\"pred\", lambda x: int((x < ALARM_D + MARGIN).sum())))\n", + " cal = pd.crosstab(al.loc[al.month >= 0, \"unit\"], al.loc[al.month >= 0, \"month\"])\n", + " low = al.nsmallest(20, \"pred\")\n", + " fg, ax = plt.subplots(1, 2, figsize=(9, 3.5)); ax[0].bar(summ.index, summ[\"위험\"]); ax[1].imshow(cal.values, aspect=\"auto\"); fg.canvas.draw(); plt.close(fg)\n", + " t[\"대시보드\"] = time.perf_counter() - s\n", + " return al, t\n", + "\n", + "\n", + "# ---------------- 1) 규모별 시뮬레이션 ----------------\n", + "rows = []\n", + "for N in (1000, 5000, 10000, 50000, 100000):\n", + " s = time.perf_counter(); fl = make_fleet(N, seed=100 + N % 7); tg = time.perf_counter() - s\n", + " al, t = pipeline(fl)\n", + " tot = t[\"피처\"] + t[\"예측\"] + t[\"일정\"]\n", + " rows.append((N, len(al), tg, t[\"피처\"], t[\"예측\"], t[\"일정\"], t[\"대시보드\"], len(al) / tot))\n", + "ld = pd.DataFrame(rows, columns=[\"보유 장비\", \"생존 장비\", \"생성(초)\", \"피처(초)\", \"예측(초)\", \"일정(초)\", \"대시보드 로딩(초)\", \"처리량(대/초)\"])\n", + "print(\"\\n[1] 규모별 일괄 처리 시간(생성은 시뮬레이션용이며 실제 운영에는 없음)\")\n", + "print(ld.round(3).to_string(index=False))\n", + "r10 = ld[ld[\"보유 장비\"] == 10000].iloc[0]\n", + "print(f\" 1만 대 일괄 수명 시뮬레이션: 피처+예측+일정 {r10['피처(초)'] + r10['예측(초)'] + r10['일정(초)']:.2f}초, 대시보드 로딩 {r10['대시보드 로딩(초)']:.3f}초(목표 2초 이내: {'달성' if r10['대시보드 로딩(초)'] <= 2 else '미달'})\")\n", + "\n", + "# ---------------- 2) 위험 경보 정확도(1만 대) ----------------\n", + "fl10 = make_fleet(10000, seed=5)\n", + "d10 = add_features(fl10)\n", + "a10 = d10[d10.event == 0].reset_index(drop=True)\n", + "pred10, _, _ = predict_rul(eng, a10)\n", + "\n", + "\n", + "def alarm_metrics(pred, truth_d, margin=MARGIN):\n", + " al_, tr_ = pred < ALARM_D + margin, truth_d < ALARM_D\n", + " tp, fp, fn, tn = int((al_ & tr_).sum()), int((al_ & ~tr_).sum()), int((~al_ & tr_).sum()), int((~al_ & ~tr_).sum())\n", + " return (tp + tn) / len(pred), tp / max(tp + fn, 1), tp / max(tp + fp, 1), fp, fn\n", + "\n", + "\n", + "acc, rec, prec, fp, fn = alarm_metrics(pred10, a10[\"rul_d\"].values)\n", + "sw = pd.DataFrame([(m, *alarm_metrics(pred10, a10[\"rul_d\"].values, m)) for m in (0, 60, 120, 180)], columns=[\"여유(일)\", \"정확도\", \"재현율\", \"정밀도\", \"오경보\", \"미탐\"])\n", + "print(f\"\\n[2] 위험 경보 정확도: 생존 {len(a10)}대, 실제 위험(참 RUL {ALARM_D}일 미만) {int((a10.rul_d < ALARM_D).sum())}대({(a10.rul_d < ALARM_D).mean():.1%})\")\n", + "print(\" 경보 기준(예측 RUL < 180 + 여유) 비교\\n\" + sw.assign(정확도=sw[\"정확도\"].map(\"{:.1%}\".format), 재현율=sw[\"재현율\"].map(\"{:.1%}\".format), 정밀도=sw[\"정밀도\"].map(\"{:.1%}\".format)).to_string(index=False))\n", + "print(f\" 채택 여유 {MARGIN}일: 정확도 {acc:.1%} (목표 95% 이상: {'달성' if acc >= .95 else '미달'}), 재현율 {rec:.1%}, 정밀도 {prec:.1%}, 오경보 {fp}, 미탐 {fn}\")\n", + "print(\" 위험 장비가 드물어 정확도만으로는 부풀려지므로 재현율·정밀도를 함께 본다. 여유를 늘리면 재현율은 오르지만 오경보가 늘어 정확도가 95% 아래로 내려간다. 놓친 장비는 정기 점검(점검 마모)으로 보완한다.\")\n", + "\n", + "# ---------------- 3) 오류 주입 ----------------\n", + "rng = np.random.default_rng(7)\n", + "W_FIX = {\"normal\": 1.0, \"rough\": 1.15, \"salt\": 1.1, \"cold\": 1.05}\n", + "\n", + "\n", + "def corrupt(kind):\n", + " x = a10.copy()\n", + " if kind == \"점검 마모 20% 결측(대체)\":\n", + " m = rng.random(len(x)) < 0.2\n", + " x.loc[m, \"insp\"] = (x.loc[m, \"h_since\"] / CYCLE * x.loc[m, \"env\"].map(W_FIX)).clip(0, 1.3) # 정제 규칙: 노후 지수로 대체\n", + " elif kind == \"점검 마모 20% 결측(0으로 방치)\":\n", + " m = rng.random(len(x)) < 0.2\n", + " x.loc[m, \"insp\"] = 0.0\n", + " elif kind == \"환경 10% 오기재\":\n", + " m = rng.random(len(x)) < 0.1\n", + " x.loc[m, \"env\"] = rng.choice(ENVS, int(m.sum()))\n", + " elif kind == \"가동률 5% 단위 오류(x30)\":\n", + " m = rng.random(len(x)) < 0.05\n", + " x.loc[m, \"rate\"] = x.loc[m, \"rate\"] * 30\n", + " elif kind == \"복합(결측 대체+환경 오기재+단위 오류)\":\n", + " m = rng.random(len(x)) < 0.2; x.loc[m, \"insp\"] = (x.loc[m, \"h_since\"] / CYCLE * x.loc[m, \"env\"].map(W_FIX)).clip(0, 1.3)\n", + " m = rng.random(len(x)) < 0.1; x.loc[m, \"env\"] = rng.choice(ENVS, int(m.sum()))\n", + " m = rng.random(len(x)) < 0.05; x.loc[m, \"rate\"] = x.loc[m, \"rate\"] * 30\n", + " return add_features(x[[\"eq_id\", \"unit\", \"env\", \"etype\", \"rate\", \"hours\", \"km\", \"age_y\", \"n_ovh\", \"h_since\", \"unpl\", \"n_maint\", \"insp\", \"t_fail\", \"event\", \"dur\", \"rul_h\", \"rul_d\"]])\n", + "\n", + "\n", + "rows = [(\"정상\", acc, rec, prec, fp, fn)]\n", + "for kind in (\"점검 마모 20% 결측(대체)\", \"점검 마모 20% 결측(0으로 방치)\", \"환경 10% 오기재\", \"가동률 5% 단위 오류(x30)\", \"복합(결측 대체+환경 오기재+단위 오류)\"):\n", + " x = corrupt(kind)\n", + " p, _, _ = predict_rul(eng, x)\n", + " rows.append((kind, *alarm_metrics(p, a10[\"rul_d\"].values)))\n", + "er = pd.DataFrame(rows, columns=[\"조건\", \"정확도\", \"재현율\", \"정밀도\", \"오경보\", \"미탐\"])\n", + "print(\"\\n[3] 오류 주입 시 경보 성능(1만 대)\\n\" + er.assign(정확도=er[\"정확도\"].map(\"{:.1%}\".format), 재현율=er[\"재현율\"].map(\"{:.1%}\".format), 정밀도=er[\"정밀도\"].map(\"{:.1%}\".format)).to_string(index=False))\n", + "worst = er.iloc[1:].sort_values(\"정확도\").iloc[0]\n", + "z = er[er[\"조건\"].str.contains(\"0으로\")].iloc[0]\n", + "print(f\" 정확도가 가장 낮은 조건은 '{worst['조건']}'({worst['정확도']:.1%}, 오경보 {int(worst['오경보'])}건). 결측을 0으로 방치하면 재현율이 {z['재현율']:.1%}로 정상({er.iloc[0]['재현율']:.1%})보다 떨어지므로 정제 규칙(M2)의 대체값 처리가 필요하다.\")\n", + "print(f\" 95% 목표는 정상 조건에서만 달성하며 오류가 겹치면 {er.iloc[1:]['정확도'].min():.1%}까지 낮아진다 -> 입력 검증(M1·M2)이 경보 품질의 전제이다.\")\n", + "\n", + "# ---------------- 4) 정수계획 규모 한계 ----------------\n", + "mt = []\n", + "for n in (50, 150, 300, 600):\n", + " sub = a10.head(20000).copy()\n", + " pr_ = np.sort(pred10)[:n] * 0 + np.linspace(30, 440, n) # 예측 RUL이 계획 기간 안에 분포하는 후보 n대\n", + " s = time.perf_counter(); m = plan_milp(pr_, np.full(n, 3.0), cap=max(CAP_M, n // 8), budget=1e9); dt = time.perf_counter() - s\n", + " mt.append((n, dt, int((m >= 0).sum())))\n", + "print(\"\\n[4] 정수계획 후보 장비 수별 풀이 시간:\", \", \".join(f\"{n}대 {s:.2f}초\" for n, s, _ in mt))\n", + "print(\" 후보가 수천 대를 넘으면 정수계획이 느려지므로 위험 상위 후보만 정수계획, 나머지는 탐욕 배정으로 나누는 구조를 쓴다. 경보·일정은 판단 보조이며 확정은 군수지휘관 승인(M7)에 따른다.\")\n", + "print(\" 모든 부대·장비·수치는 교육용 가상 자료이다.\")\n", + "\n", + "fig, ax = plt.subplots(1, 3, figsize=(15, 4.2))\n", + "ax[0].loglog(ld[\"보유 장비\"], ld[\"피처(초)\"] + ld[\"예측(초)\"] + ld[\"일정(초)\"], \"o-\", label=\"batch total\"); ax[0].loglog(ld[\"보유 장비\"], ld[\"대시보드 로딩(초)\"], \"s-\", label=\"dashboard\"); ax[0].axhline(2, c=\"r\", ls=\"--\"); ax[0].legend(); ax[0].set_title(\"Processing time vs fleet size (s)\")\n", + "ax[1].barh(range(len(er)), er[\"재현율\"] * 100); ax[1].set_yticks(range(len(er))); ax[1].set_yticklabels([f\"C{i + 1}\" for i in range(len(er))]); ax[1].set_title(\"Alarm recall by fault case (%)\")\n", + "ax[2].plot([m[0] for m in mt], [m[1] for m in mt], \"o-\"); ax[2].set_title(\"MILP solve time vs candidate units (s)\")\n", + "plt.tight_layout(); plt.show()\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 08 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + "[0] 예측 엔진 학습 1.44초(학습 900대, 시점 표본 1440건)\n", + "\n", + "[1] 규모별 일괄 처리 시간(생성은 시뮬레이션용이며 실제 운영에는 없음)\n", + " 보유 장비 생존 장비 생성(초) 피처(초) 예측(초) 일정(초) 대시보드 로딩(초) 처리량(대/초)\n", + " 1000 676 0.021 0.007 0.008 0.001 0.125 41807.644\n", + " 5000 3346 0.132 0.010 0.020 0.005 0.111 95891.024\n", + " 10000 6643 0.282 0.013 0.036 0.010 0.107 113284.295\n", + " 50000 33436 0.989 0.051 0.177 0.038 0.127 125508.675\n", + "100000 66873 2.168 0.083 0.388 0.079 0.132 121715.318\n", + " 1만 대 일괄 수명 시뮬레이션: 피처+예측+일정 0.06초, 대시보드 로딩 0.107초(목표 2초 이내: 달성)\n", + "\n", + "[2] 위험 경보 정확도: 생존 6741대, 실제 위험(참 RUL 180일 미만) 474대(7.0%)\n", + " 경보 기준(예측 RUL < 180 + 여유) 비교\n", + " 여유(일) 정확도 재현율 정밀도 오경보 미탐\n", + " 0 95.7% 51.1% 80.7% 58 232\n", + " 60 95.5% 70.9% 67.5% 162 138\n", + " 120 94.7% 84.6% 58.3% 287 73\n", + " 180 92.5% 92.0% 48.1% 470 38\n", + " 채택 여유 60일: 정확도 95.5% (목표 95% 이상: 달성), 재현율 70.9%, 정밀도 67.5%, 오경보 162, 미탐 138\n", + " 위험 장비가 드물어 정확도만으로는 부풀려지므로 재현율·정밀도를 함께 본다. 여유를 늘리면 재현율은 오르지만 오경보가 늘어 정확도가 95% 아래로 내려간다. 놓친 장비는 정기 점검(점검 마모)으로 보완한다.\n", + "\n", + "[3] 오류 주입 시 경보 성능(1만 대)\n", + " 조건 정확도 재현율 정밀도 오경보 미탐\n", + " 정상 95.5% 70.9% 67.5% 162 138\n", + " 점검 마모 20% 결측(대체) 94.9% 69.2% 62.1% 200 146\n", + " 점검 마모 20% 결측(0으로 방치) 94.9% 54.6% 67.1% 127 215\n", + " 환경 10% 오기재 95.4% 70.5% 66.0% 172 140\n", + " 가동률 5% 단위 오류(x30) 94.7% 72.6% 60.4% 226 130\n", + "복합(결측 대체+환경 오기재+단위 오류) 93.5% 69.2% 52.9% 292 146\n", + " 정확도가 가장 낮은 조건은 '복합(결측 대체+환경 오기재+단위 오류)'(93.5%, 오경보 292건). 결측을 0으로 방치하면 재현율이 54.6%로 정상(70.9%)보다 떨어지므로 정제 규칙(M2)의 대체값 처리가 필요하다.\n", + " 95% 목표는 정상 조건에서만 달성하며 오류가 겹치면 93.5%까지 낮아진다 -> 입력 검증(M1·M2)이 경보 품질의 전제이다.\n", + "\n", + "[4] 정수계획 후보 장비 수별 풀이 시간: 50대 0.04초, 150대 0.11초, 300대 0.22초, 600대 0.50초\n", + " 후보가 수천 대를 넘으면 정수계획이 느려지므로 위험 상위 후보만 정수계획, 나머지는 탐욕 배정으로 나누는 구조를 쓴다. 경보·일정은 판단 보조이며 확정은 군수지휘관 승인(M7)에 따른다.\n", + " 모든 부대·장비·수치는 교육용 가상 자료이다.\n", + "```\n", + "
\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 09. AAR 기반 수명 예측 오차 분석 및 환경 가중치 파라미터 재보정\n", + "**소요시간**: 약 3.0시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "AAR 기반 수명 예측 오차를 분석하고 환경 가중치를 재보정한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **AAR로 수명 예측 오차를 분석하고 환경 가중치를 재보정해 ROI를 산출한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **운영 장비의 예측 오차를 환경, 가동률, 비계획 정비율별로 나누고 큰 오차의 원인을 분류 수행하고 검증하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `reports/sup02_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로 환경별·원인별 분석하고, 환경 가중치를 운영 고장 이력의 AFT 계수로 재보정하고 운영 데이터로 예측 엔진을 재학습해 보류 장비 집단에서 개선을 검증한 뒤 비용 절감액·가동 중단일수 단축률·투자 회수 기간 3대 효과 지표를 산식과 민감도로 산출한다.\"\"\"\n", + "import numpy as np\n", + "import pandas as pd\n", + "import matplotlib.pyplot as plt\n", + "from sklearn.metrics import r2_score\n", + "\n", + "# ---------------------------------------------------------------------------\n", + "# [교육용가상자료] 가상 장비 보유 현황·고장 이력 생성기 (실제 부대·장비·수치 없음)\n", + "# 가상 부대 6개(U1~U6)의 장비가 운용 환경(normal/rough/salt/cold)에 따라 다른 속도로 노후화한다. 창정비 후 운용시간(h_since)이\n", + "# 장비마다 숨은 수명(t_fail)을 넘으면 고장(event=1) 이력, 넘지 않았으면 현재까지 생존(event=0, 관측 중도절단)이다.\n", + "# ---------------------------------------------------------------------------\n", + "ENVS = [\"normal\", \"rough\", \"salt\", \"cold\"]\n", + "ENV_W = {\"normal\": 1.00, \"rough\": 1.35, \"salt\": 1.25, \"cold\": 1.12} # 환경 가중치(숨은 정답, 가정값): 클수록 수명이 짧다\n", + "ENV_KO = {\"normal\": \"일반\", \"rough\": \"험지\", \"salt\": \"염분\", \"cold\": \"혹한\"}\n", + "UNITS = [\"U1\", \"U2\", \"U3\", \"U4\", \"U5\", \"U6\"]\n", + "UNIT_ENV = {\"U1\": [.7, .1, .1, .1], \"U2\": [.2, .6, .1, .1], \"U3\": [.2, .1, .6, .1], \"U4\": [.3, .1, .1, .5], \"U5\": [.5, .3, .1, .1], \"U6\": [.4, .1, .4, .1]}\n", + "ETYPES = [\"truck\", \"generator\", \"trailer\", \"radio\"]\n", + "CYCLE = 8000.0 # 규정 창정비 주기(운용시간 h, 가정값)\n", + "KSHAPE = 12.0 # 수명 분포 와이블 형상(마모형 고장, 클수록 수명이 모여 있다)\n", + "INSP_NOISE = 0.025 # 정비 점검 마모 비율 측정 오차(가정값)\n", + "\n", + "\n", + "def make_fleet(n=900, seed=0, env_w=None, shift=1.0):\n", + " \"\"\"장비 n대. env_w로 숨은 환경 가중치를 바꿔 운영 분포 변화를 모의할 수 있다. shift>1이면 가동률이 높아진다.\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " w = env_w or ENV_W\n", + " unit = rng.choice(UNITS, n)\n", + " env = np.array([rng.choice(ENVS, p=UNIT_ENV[u]) for u in unit])\n", + " et = rng.choice(ETYPES, n, p=[.4, .2, .2, .2])\n", + " rate = rng.lognormal(np.log(3.0 * shift), 0.25, n) # 일 평균 운용시간(h/일)\n", + " h_since = rng.uniform(300, 11500, n) # 마지막 창정비 이후 운용시간\n", + " n_ovh = rng.integers(0, 3, n)\n", + " hours = h_since + n_ovh * rng.uniform(5000, 8000, n) # 누적 운용시간\n", + " km = hours * rng.normal(17, 3, n) * np.where(env == \"rough\", 0.8, 1.0) # 누적 기동거리(km)\n", + " age_y = np.clip(hours / (rate * 365) * rng.uniform(0.9, 1.15, n), 0.5, 25)\n", + " unpl = rng.poisson(0.35 * h_since / 1000 * np.array([w[e] for e in env]) / 1.3) # 비계획 정비 건수(환경이 가혹할수록 많다)\n", + " n_maint = rng.poisson(0.6 * h_since / 1000 + 1) # 계획 정비 건수\n", + " scale = 9600 / np.array([w[e] for e in env]) * 0.985 ** unpl * np.exp(rng.normal(0, 0.04, n))\n", + " t_fail = scale * rng.weibull(KSHAPE, n) # 창정비 후 고장까지 운용시간(숨은 정답)\n", + " event = (t_fail <= h_since).astype(int)\n", + " dur = np.where(event == 1, t_fail, h_since)\n", + " df = pd.DataFrame({\"eq_id\": [f\"EQ{seed:02d}{i:04d}\" for i in range(n)], \"unit\": unit, \"env\": env, \"etype\": et, \"rate\": rate, \"hours\": hours, \"km\": km, \"age_y\": age_y, \"n_ovh\": n_ovh,\n", + " \"h_since\": h_since, \"unpl\": unpl, \"n_maint\": n_maint, \"t_fail\": t_fail, \"event\": event, \"dur\": dur})\n", + " df[\"insp\"] = np.clip(dur / t_fail + rng.normal(0, INSP_NOISE, n), 0, 1.3) # 마지막 정비 점검에서 측정한 마모 비율(소모 수명/총 수명, 오차 포함)\n", + " df[\"rul_h\"] = np.where(event == 0, t_fail - h_since, np.nan) # 생존 장비의 남은 운용시간(정답)\n", + " df[\"rul_d\"] = df[\"rul_h\"] / df[\"rate\"] # 남은 일수(정답)\n", + " return df\n", + "\n", + "\n", + "def mape(y, p):\n", + " # [TODO : 학생 실습 9] AAR 기반 수명 예측 오차 분석 및 환경 가중치 파라미터 재보정 관련 핵심 로직을 구현하세요.\n", + " # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n", + " # <<< 여기에 코드를 작성하세요 >>>\n", + " pass # 작성 후 pass를 제거하세요.\n", + "def mae(y, p):\n", + " # [TODO : 학생 실습 9] AAR 기반 수명 예측 오차 분석 및 환경 가중치 파라미터 재보정 관련 핵심 로직을 구현하세요.\n", + " # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n", + " # <<< 여기에 코드를 작성하세요 >>>\n", + " pass # 작성 후 pass를 제거하세요.\n", + "def rmse(y, p):\n", + " return float(np.sqrt(np.mean((np.asarray(y, float) - np.asarray(p, float)) ** 2)))\n", + "# ---------------------------------------------------------------------------\n", + "# ---------------------------------------------------------------------------\n", + "# 노후화 파생 피처와 학습 표본 만들기. 환경 가중치 W0는 도입 시점의 전문가 추정값이며(실제 가중치와 다름) M9에서 재보정한다.\n", + "# ---------------------------------------------------------------------------\n", + "W0 = {\"normal\": 1.00, \"rough\": 1.15, \"salt\": 1.10, \"cold\": 1.05}\n", + "FEATS = [\"h_since\", \"hours\", \"km\", \"age_y\", \"rate\", \"n_maint\", \"unpl\", \"unpl_per_kh\", \"km_per_h\", \"n_ovh\", \"k_rough\", \"k_salt\", \"k_cold\", \"k_truck\", \"k_gen\", \"k_trailer\",\n", + " \"aging_idx\", \"lin_rul_d\", \"hs_frac\", \"insp\", \"insp_eta_h\"]\n", + "\n", + "\n", + "def add_features(df, w=None):\n", + " d = df.copy()\n", + " w = w or W0\n", + " d[\"unpl_per_kh\"] = d[\"unpl\"] / (d[\"h_since\"] / 1000 + 0.5)\n", + " d[\"km_per_h\"] = d[\"km\"] / d[\"hours\"]\n", + " for e in (\"rough\", \"salt\", \"cold\"):\n", + " d[\"k_\" + e] = (d[\"env\"] == e).astype(int)\n", + " d[\"k_truck\"], d[\"k_gen\"], d[\"k_trailer\"] = (d[\"etype\"] == \"truck\").astype(int), (d[\"etype\"] == \"generator\").astype(int), (d[\"etype\"] == \"trailer\").astype(int)\n", + " d[\"aging_idx\"] = d[\"h_since\"] * d[\"env\"].map(w) / CYCLE # 환경 가중 노후 지수(1이면 창정비 주기에 도달)\n", + " d[\"lin_rul_d\"] = (CYCLE - d[\"h_since\"]) / d[\"rate\"] # 선형 감가상각 잔여 일수(Baseline)\n", + " d[\"hs_frac\"] = d[\"h_since\"] / CYCLE\n", + " d[\"insp_eta_h\"] = d[\"h_since\"] / np.maximum(d[\"insp\"], 0.05) # 점검 마모 비율로 환산한 총 수명 추정(h)\n", + " return d\n", + "\n", + "\n", + "def snapshots(df, k=4, seed=0):\n", + " \"\"\"고장 이력 장비의 과거 시점 표본: 고장 전 임의 시점의 점검값·운용시간과 그때의 남은 일수. (생존 장비의 미래는 알 수 없으므로 고장 이력만 쓴다)\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " f = df[df[\"event\"] == 1]\n", + " out = []\n", + " for _ in range(k):\n", + " s = f.copy()\n", + " h = s[\"t_fail\"] * rng.uniform(0.2, 0.98, len(s))\n", + " ratio = h / s[\"h_since\"]\n", + " s[\"hours\"] = s[\"hours\"] - (s[\"h_since\"] - h)\n", + " s[\"km\"] = s[\"km\"] * ratio.clip(upper=1)\n", + " s[\"unpl\"] = np.floor(s[\"unpl\"] * ratio)\n", + " s[\"n_maint\"] = np.floor(s[\"n_maint\"] * ratio)\n", + " s[\"insp\"] = np.clip(h / s[\"t_fail\"] + rng.normal(0, INSP_NOISE, len(s)), 0, 1.3)\n", + " s[\"h_since\"] = h\n", + " s[\"rul_d\"] = (s[\"t_fail\"] - h) / s[\"rate\"]\n", + " s[\"event\"] = 0\n", + " out.append(s)\n", + " return pd.concat(out, ignore_index=True)\n", + "# ---------------------------------------------------------------------------\n", + "# ---------------------------------------------------------------------------\n", + "# 생존 분석 도구(scipy만 사용): 와이블 AFT(가속 수명) 모형과 Cox 비례위험 모형\n", + "# ---------------------------------------------------------------------------\n", + "from scipy.optimize import minimize\n", + "\n", + "\n", + "def aft_x(d):\n", + " return np.c_[np.ones(len(d)), d[\"env\"].eq(\"rough\"), d[\"env\"].eq(\"salt\"), d[\"env\"].eq(\"cold\"), d[\"unpl\"] / (d[\"h_since\"] / 1000 + 0.5)].astype(float)\n", + "\n", + "\n", + "AFT_COLS = [\"절편\", \"험지\", \"염분\", \"혹한\", \"비계획 정비율\"]\n", + "\n", + "\n", + "def fit_aft(d):\n", + " \"\"\"창정비 후 운용시간 dur(고장이면 event=1, 생존이면 중도절단)에 대한 와이블 AFT 최대우도 추정. 반환: 계수(마지막은 log 형상).\"\"\"\n", + " x, t, e = aft_x(d), d[\"dur\"].values, d[\"event\"].values\n", + "\n", + " def nll(p):\n", + " lam, k = np.exp(x @ p[:-1]), np.exp(p[-1])\n", + " z = (t / lam) ** k\n", + " return -(e * (np.log(k) - np.log(lam) + (k - 1) * (np.log(t) - np.log(lam))) - z).sum()\n", + "\n", + " r = minimize(nll, np.r_[np.log(8000), np.zeros(x.shape[1] - 1), np.log(5)], method=\"BFGS\")\n", + " return r.x\n", + "\n", + "\n", + "def aft_pred(p, d, q=0.5, h=None):\n", + " \"\"\"이미 h(운용시간)까지 생존했다는 조건에서 분위수 q 생존 시점(운용시간 h 기준 총 수명)을 돌려준다. q=0.5가 조건부 중앙값.\"\"\"\n", + " lam, k = np.exp(aft_x(d) @ p[:-1]), np.exp(p[-1])\n", + " h = d[\"h_since\"].values if h is None else h\n", + " return lam * ((h / lam) ** k - np.log(q)) ** (1 / k)\n", + "\n", + "\n", + "def aft_fail_prob(p, d, h0, h1):\n", + " \"\"\"h0까지 생존한 장비가 h0~h1 사이에 고장 날 조건부 확률.\"\"\"\n", + " lam, k = np.exp(aft_x(d) @ p[:-1]), np.exp(p[-1])\n", + " s0, s1 = np.exp(-(h0 / lam) ** k), np.exp(-(h1 / lam) ** k)\n", + " return 1 - s1 / s0\n", + "\n", + "\n", + "def fit_cox(d, iters=25):\n", + " \"\"\"Cox 비례위험(Breslow 근사) 뉴턴-랩슨. 공변량: 험지·염분·혹한·비계획 정비율. 반환: 계수.\"\"\"\n", + " x = aft_x(d)[:, 1:]\n", + " o = np.argsort(-d[\"dur\"].values)\n", + " x, e = x[o], d[\"event\"].values[o]\n", + " b = np.zeros(x.shape[1])\n", + " for _ in range(iters):\n", + " r = np.exp(x @ b)\n", + " S0 = np.cumsum(r)\n", + " S1 = np.cumsum(r[:, None] * x, axis=0)\n", + " S2 = np.cumsum(r[:, None, None] * x[:, :, None] * x[:, None, :], axis=0)\n", + " m1 = S1 / S0[:, None]\n", + " g = ((x - m1) * e[:, None]).sum(axis=0)\n", + " H = -sum(e[i] * (S2[i] / S0[i] - np.outer(m1[i], m1[i])) for i in np.where(e == 1)[0])\n", + " step = np.linalg.solve(H - 1e-6 * np.eye(len(b)), g)\n", + " b = b - step\n", + " if np.abs(step).max() < 1e-7:\n", + " break\n", + " return b\n", + "\n", + "\n", + "def cindex(time, event, risk):\n", + " \"\"\"일치도 C-index: 위험 점수가 높은 쪽이 먼저 고장 났는가(관측 가능한 쌍만).\"\"\"\n", + " t, e, r = np.asarray(time), np.asarray(event), np.asarray(risk)\n", + " num = den = 0.0\n", + " for i in np.where(e == 1)[0]:\n", + " m = t > t[i]\n", + " den += m.sum()\n", + " num += (r[m] < r[i]).sum() + 0.5 * (r[m] == r[i]).sum()\n", + " return num / max(den, 1)\n", + "# ---------------------------------------------------------------------------\n", + "# ---------------------------------------------------------------------------\n", + "# 잔여 수명(RUL, 일) 예측 엔진: 와이블 AFT 조건부 중앙값 + 그래디언트 부스팅(고장 이력 시점 표본 학습)의 평균\n", + "# ---------------------------------------------------------------------------\n", + "from sklearn.ensemble import GradientBoostingRegressor\n", + "\n", + "\n", + "def fit_engine(train, seed=0):\n", + " p = fit_aft(train)\n", + " sn = add_features(snapshots(train, 5, seed))\n", + " sn[\"aft\"] = (aft_pred(p, sn) - sn[\"h_since\"]) / sn[\"rate\"]\n", + " g = GradientBoostingRegressor(n_estimators=250, max_depth=3, learning_rate=0.06, random_state=0).fit(sn[FEATS + [\"aft\"]], sn[\"rul_d\"])\n", + " return {\"p\": p, \"g\": g}\n", + "\n", + "\n", + "def predict_rul(eng, d):\n", + " \"\"\"d는 add_features를 거친 표. 반환: (앙상블 RUL, AFT RUL, GBR RUL) - 일 단위.\"\"\"\n", + " aft = ((aft_pred(eng[\"p\"], d) - d[\"h_since\"]) / d[\"rate\"]).values\n", + " x = d[FEATS].copy()\n", + " x[\"aft\"] = aft\n", + " gb = eng[\"g\"].predict(x)\n", + " return np.clip(0.5 * aft + 0.5 * gb, 1, None), aft, gb\n", + "# ---------------------------------------------------------------------------\n", + "# ---------------------------------------------------------------------------\n", + "# 계획정비(창정비) 일정 추천: 월별 정비창 능력·예산·시효성 부품 키트(유효기한) 제약 아래 기대 비용 최소화 (비용 단가는 교육용 가정값, 만원)\n", + "# ---------------------------------------------------------------------------\n", + "from scipy.optimize import milp, LinearConstraint, Bounds\n", + "from scipy.stats import norm\n", + "\n", + "C_PLAN, C_FAIL, C_WASTE_KH, C_KIT = 600.0, 2400.0, 40.0, 120.0 # 계획 창정비, 비계획 고장(긴급 정비 포함), 미사용 수명 1000h당 손실, 시효성 부품 키트\n", + "HORIZON, MDAYS = 12, 30 # 계획 12개월, 월 30일\n", + "CAP_M, BUDGET_M = 22, 15000.0 # 월별 정비창 능력(대), 월별 예산\n", + "LOTS = [(30, 70), (45, 170), (45, 390), (70, 800)] # 시효성 키트 로트(수량, 유효기한까지 남은 일수)\n", + "KIT_MARGIN = 14 # 정비일 기준 최소 유효기간 여유(일)\n", + "\n", + "\n", + "def exp_cost_matrix(pred, rate, month_days):\n", + " \"\"\"장비 i를 월 m에 정비할 때의 기대 비용. 참 RUL ~ N(pred, sigma), sigma = 35일 + 0.12 x pred (가정). 마지막 열은 계획 기간 밖(미정비).\"\"\"\n", + " sig = 35 + 0.12 * pred\n", + " C = np.zeros((len(pred), len(month_days) + 1))\n", + " for j, day in enumerate(month_days):\n", + " z = (day - pred) / sig\n", + " pf = norm.cdf(z)\n", + " unused = np.maximum(pred - day, 0) * rate * 0.6 # 정비를 앞당긴 만큼 버리는 운용시간 h(근사)\n", + " C[:, j] = C_PLAN + C_KIT + pf * (C_FAIL - C_PLAN) + C_WASTE_KH * unused / 1000 * (1 - pf)\n", + " C[:, -1] = norm.cdf((HORIZON * MDAYS - pred) / sig) * C_FAIL\n", + " return C\n", + "\n", + "\n", + "def plan_milp(pred, rate, cap=CAP_M, budget=BUDGET_M, lots=LOTS, use_kit=True):\n", + " \"\"\"정수계획으로 장비별 정비 월(0~11) 또는 미정비(-1)를 배정한다. 후보는 예측 RUL이 계획 기간 +90일 이내인 장비.\"\"\"\n", + " md = np.arange(1, HORIZON + 1) * MDAYS\n", + " cand = np.where(pred < HORIZON * MDAYS + 90)[0]\n", + " n, M = len(cand), HORIZON\n", + " C = exp_cost_matrix(pred[cand], rate[cand], md)\n", + " c = C.ravel()\n", + " A, lo, hi = [], [], []\n", + " for i in range(n): # 장비당 한 번만 배정(월 12칸 + 미정비)\n", + " row = np.zeros(n * (M + 1)); row[i * (M + 1):(i + 1) * (M + 1)] = 1; A.append(row); lo.append(1); hi.append(1)\n", + " for m in range(M): # 월별 능력·예산\n", + " row = np.zeros(n * (M + 1)); row[m::(M + 1)] = 1\n", + " A.append(row); lo.append(0); hi.append(cap)\n", + " A.append(row * (C_PLAN + C_KIT)); lo.append(0); hi.append(budget)\n", + " if use_kit: # 시효성 키트: m월 이후 정비 수요는 그 시점에 유효기간이 남은 로트 수량을 넘을 수 없다\n", + " for m in range(M):\n", + " sup = sum(q for q, e in lots if e >= md[m] + KIT_MARGIN)\n", + " row = np.zeros(n * (M + 1))\n", + " for mm in range(m, M):\n", + " row[mm::(M + 1)] = 1\n", + " A.append(row); lo.append(0); hi.append(sup)\n", + " r = milp(c, constraints=LinearConstraint(np.array(A), lo, hi), integrality=np.ones(len(c)), bounds=Bounds(0, 1), options={\"time_limit\": 20})\n", + " month = np.full(len(pred), -1)\n", + " if r.x is not None:\n", + " x = np.round(r.x).reshape(n, M + 1)\n", + " assign = np.where(x[:, :M].sum(axis=1) > 0.5, x[:, :M].argmax(axis=1), -1)\n", + " month[cand] = assign\n", + " return month\n", + "\n", + "\n", + "def plan_greedy(pred, margin=45, cap=CAP_M, budget=BUDGET_M):\n", + " \"\"\"간단한 탐욕 배정: 정비 목표일(예측 RUL - margin)이 빠른 순으로 목표일 이전 가장 늦은 빈 월, 없으면 가장 이른 빈 월에 배정.\"\"\"\n", + " cap = min(cap, int(budget // (C_PLAN + C_KIT))) # 월 예산이 허용하는 대수로 능력을 제한\n", + " month = np.full(len(pred), -1)\n", + " left = np.full(HORIZON, cap)\n", + " for i in np.argsort(pred):\n", + " if pred[i] > HORIZON * MDAYS + 60: break\n", + " tgt = int(np.clip((pred[i] - margin) // MDAYS - 1, 0, HORIZON - 1))\n", + " for m in list(range(tgt, -1, -1)) + list(range(tgt + 1, HORIZON)):\n", + " if left[m] > 0:\n", + " month[i] = m; left[m] -= 1; break\n", + " return month\n", + "\n", + "\n", + "def plan_fixed(lin_rul, cap=CAP_M, budget=BUDGET_M):\n", + " \"\"\"관행: 규정 주기 도달 순서대로(선형 잔여 일수) 월별 능력 안에서 배정.\"\"\"\n", + " return plan_greedy(np.asarray(lin_rul, float), margin=0, cap=cap, budget=budget)\n", + "\n", + "\n", + "def evaluate_plan(month, true_rul, rate, cost_kit=True):\n", + " \"\"\"참 RUL로 계획의 실제 비용을 계산한다: 정비일 이전에 고장 -> 비계획 고장 비용, 아니면 계획 비용 + 미사용 수명 손실.\"\"\"\n", + " day = np.where(month >= 0, (month + 1) * MDAYS, 10 ** 9)\n", + " fail = (true_rul < day) & (true_rul < HORIZON * MDAYS)\n", + " planned = (month >= 0) & ~fail\n", + " waste = np.where(planned, (true_rul - day) * rate * 0.6, 0) * C_WASTE_KH / 1000\n", + " cost = fail * C_FAIL + planned * (C_PLAN + (C_KIT if cost_kit else 0)) + waste\n", + " return {\"total\": float(cost.sum()), \"fail\": int(fail.sum()), \"planned\": int(planned.sum()), \"waste\": float(waste.sum()), \"unsched_fail\": int(((month < 0) & fail).sum())}\n", + "\n", + "\n", + "def kit_fefo(month, lots=LOTS):\n", + " \"\"\"유효기한이 빠른 로트부터 쓰는 FEFO로 키트를 배정하고 정비일에 유효기간이 부족한 배정 수와 만료 폐기 수량을 센다.\"\"\"\n", + " q = [[qty, e] for qty, e in sorted(lots, key=lambda t: t[1])]\n", + " short = 0\n", + " for m in sorted(month[month >= 0]):\n", + " day = (m + 1) * MDAYS\n", + " for lot in q:\n", + " if lot[1] >= day + KIT_MARGIN and lot[0] > 0:\n", + " lot[0] -= 1; break\n", + " else:\n", + " short += 1\n", + " expired = sum(l[0] for l in q if l[1] < HORIZON * MDAYS)\n", + " return {\"short\": short, \"expired_unused\": expired, \"left\": sum(l[0] for l in q)}\n", + "# ---------------------------------------------------------------------------\n", + "\n", + "OPS_W = {\"normal\": 1.00, \"rough\": 1.55, \"salt\": 1.15, \"cold\": 1.30} # 운영 기간의 숨은 환경 가중치(도입 당시와 달라진 가정)\n", + "tr = make_fleet(900, seed=1)\n", + "eng0 = fit_engine(tr)\n", + "A = make_fleet(700, seed=60, env_w=OPS_W, shift=1.2) # AAR 대상 운영 장비\n", + "B = make_fleet(700, seed=70, env_w=OPS_W, shift=1.2) # 재보정에 쓰지 않는 보류 장비\n", + "\n", + "# ---------------- 1) AAR: 운영 예측 오차 분석 ----------------\n", + "Af = add_features(A)\n", + "al = Af[Af.event == 0].reset_index(drop=True)\n", + "pred, _, _ = predict_rul(eng0, al)\n", + "y = al[\"rul_d\"].values\n", + "al[\"err\"] = pred - y # 양수 = 수명을 길게 예측(위험)\n", + "big = y >= 90\n", + "print(f\"[1] AAR 대상 운영 장비 {len(A)}대(생존 {len(al)}대, 고장 {int(A.event.sum())}대): 현행 엔진 MAE {mae(y, pred):.0f}일, RMSE {rmse(y, pred):.0f}일, MAPE {mape(y[big], pred[big]):.1f}%, R2 {r2_score(y, pred):.3f}, 평균 편향 {al.err.mean():+.0f}일\")\n", + "ct = pd.DataFrame([(k, int(m.sum()), mae(y[m.values], pred[m.values]), al.err[m].mean()) for k, m in {\"일반\": al.env == \"normal\", \"험지\": al.env == \"rough\", \"염분\": al.env == \"salt\", \"혹한\": al.env == \"cold\",\n", + " \"일 가동률 4h 이상\": al.rate >= 4, \"비계획 정비율 상위 25%\": al.unpl_per_kh >= al.unpl_per_kh.quantile(.75)}.items()], columns=[\"조건\", \"대수\", \"MAE(일)\", \"평균 편향(일)\"])\n", + "print(ct.round(0).to_string(index=False))\n", + "worst = al.reindex(al.err.abs().sort_values(ascending=False).index).head(40)\n", + "\n", + "\n", + "def cause(r):\n", + " if r.env == \"rough\" and r.err > 0: return \"험지 가혹도 과소 반영\"\n", + " if r.env == \"cold\" and r.err > 0: return \"혹한 가혹도 과소 반영\"\n", + " if r.env == \"salt\" and r.err < 0: return \"염분 가혹도 과대 반영\"\n", + " if r.rate >= 4: return \"가동률 상승(운용 강도 변화)\"\n", + " if r.unpl_per_kh >= 1.2: return \"비계획 정비 다수\"\n", + " return \"기타(개체 편차)\"\n", + "\n", + "\n", + "cs = worst.apply(cause, axis=1).value_counts()\n", + "print(\" 오차 큰 상위 40대의 원인 분류:\", \", \".join(f\"{k} {v}대({v / 40:.0%})\" for k, v in cs.items()))\n", + "\n", + "# ---------------- 2) 재보정: 환경 가중치 -> 운영 데이터 재학습 ----------------\n", + "pa = fit_aft(A)\n", + "life_ratio = np.exp(pa[1:4])\n", + "W1 = {\"normal\": 1.0, \"rough\": 1 / life_ratio[0], \"salt\": 1 / life_ratio[1], \"cold\": 1 / life_ratio[2]}\n", + "wt = pd.DataFrame({\"도입 시 추정(W0)\": W0, \"재보정(운영 AFT)\": W1, \"숨은 정답(운영)\": OPS_W}).loc[ENVS]\n", + "wt[\"변화율\"] = wt[\"재보정(운영 AFT)\"] / wt[\"도입 시 추정(W0)\"] - 1\n", + "print(\"\\n[2] 환경 가중치 매트릭스(클수록 빨리 노후)\")\n", + "print(wt.round(3).to_string())\n", + "flag = wt[wt[\"변화율\"].abs() > 0.2].index.tolist()\n", + "print(f\" 변화율 20% 초과 환경 {flag or '없음'}는 정비계획관 검토·군수지휘관 승인 뒤 적용한다(자동 반영 금지).\")\n", + "eng1 = fit_engine(A, seed=1)\n", + "Bf0, Bf1 = add_features(B, W0), add_features(B, W1)\n", + "bl0, bl1 = Bf0[Bf0.event == 0].reset_index(drop=True), Bf1[Bf1.event == 0].reset_index(drop=True)\n", + "p0, _, _ = predict_rul(eng0, bl0)\n", + "p1, _, _ = predict_rul(eng1, bl1)\n", + "yb = bl0[\"rul_d\"].values\n", + "bb = yb >= 90\n", + "tab = pd.DataFrame({\"재보정 전\": [mae(yb, p0), rmse(yb, p0), mape(yb[bb], p0[bb]), r2_score(yb, p0), (p0 - yb).mean()], \"재보정 후\": [mae(yb, p1), rmse(yb, p1), mape(yb[bb], p1[bb]), r2_score(yb, p1), (p1 - yb).mean()]},\n", + " index=[\"MAE(일)\", \"RMSE(일)\", \"MAPE(%)\", \"R2\", \"평균 편향(일)\"])\n", + "print(f\"\\n[3] 보류 장비 {len(bl0)}대(재보정에 쓰지 않음) 검증\\n\" + tab.round(3).to_string())\n", + "print(f\" MAE {1 - tab.loc['MAE(일)', '재보정 후'] / tab.loc['MAE(일)', '재보정 전']:.1%} 감소, 환경별 편향(전 -> 후):\", \", \".join(f\"{e} {(p0 - yb)[(bl0.env == e).values].mean():+.0f} -> {(p1 - yb)[(bl0.env == e).values].mean():+.0f}\" for e in ENVS))\n", + "\n", + "# ---------------- 3) ROI 3대 지표(교육용 가정) ----------------\n", + "rate_b, true_b = bl0[\"rate\"].values, yb\n", + "m0, m1 = plan_milp(p0, rate_b), plan_milp(p1, rate_b)\n", + "r0, r1 = evaluate_plan(m0, true_b, rate_b), evaluate_plan(m1, true_b, rate_b)\n", + "D_FAIL, D_PLAN = 45, 12 # 비계획 고장/계획 창정비 가동 중단 일수(가정)\n", + "down0, down1 = r0[\"fail\"] * D_FAIL + r0[\"planned\"] * D_PLAN, r1[\"fail\"] * D_FAIL + r1[\"planned\"] * D_PLAN\n", + "SCALE, INV, OPEX = 1.0, 12000.0, 1200.0 # 관리 장비 규모 배수(연), 도입 비용, 연 운영비(만원)\n", + "\n", + "\n", + "def roi(c0, c1, d0, d1, scale=SCALE, inv=INV):\n", + " save = (c0 - c1) * scale - OPEX\n", + " return save, 1 - d1 / d0, inv / max(save / 12, 1e-9)\n", + "\n", + "\n", + "sv, tr_, pb = roi(r0[\"total\"], r1[\"total\"], down0, down1)\n", + "print(f\"\\n[4] 12개월 계획 비교(보류 장비): 비용 {r0['total']:,.0f} -> {r1['total']:,.0f}만원, 비계획 고장 {r0['fail']} -> {r1['fail']}대, 계획 정비 {r0['planned']} -> {r1['planned']}대\")\n", + "print(f\" ROI 3대 지표 [교육용 가정: 관리 규모 x{SCALE:.0f}, 도입 {INV:.0f}만원, 운영 {OPEX:.0f}만원/년, 고장 중단 {D_FAIL}일·창정비 {D_PLAN}일]\")\n", + "print(f\" (1) 연간 비용 절감액 {sv:,.0f}만원 (2) 가동 중단일수 단축률 {tr_:.1%}({down0:,}일 -> {down1:,}일) (3) 투자 회수 기간 {pb:.1f}개월\")\n", + "print(\" 산식: 절감액 = (재보정 전 - 후 12개월 비용) x 규모 - 운영비, 단축률 = 1 - 후 중단일수/전 중단일수, 회수 기간 = 도입비 / (월 절감액)\")\n", + "print(\" 관리 규모 민감도:\", \"; \".join(f\"x{s} -> 절감 {roi(r0['total'], r1['total'], down0, down1, scale=s)[0]:,.0f}만원, 회수 {roi(r0['total'], r1['total'], down0, down1, scale=s)[2]:.0f}개월\" for s in (0.5, 1, 2, 3)))\n", + "half = r0[\"total\"] - (r0[\"total\"] - r1[\"total\"]) / 2\n", + "print(f\" 비관(재보정 효과 절반만 실현, x1): 절감 {roi(r0['total'], half, down0, down1, scale=1)[0]:,.0f}만원, 회수 {roi(r0['total'], half, down0, down1, scale=1)[2]:.0f}개월\")\n", + "print(\" 가정값이므로 재정·기획 부서와 실제 단가로 다시 산정해야 한다. 재보정 가중치는 군수지휘관 승인 후 운영에 반영한다.\")\n", + "\n", + "fig, ax = plt.subplots(1, 3, figsize=(15, 4.3))\n", + "ax[0].bar(range(len(ct)), ct[\"평균 편향(일)\"]); ax[0].set_xticks(range(len(ct))); ax[0].set_xticklabels([f\"C{i + 1}\" for i in range(len(ct))]); ax[0].set_title(\"AAR: mean bias by condition (days)\")\n", + "x = np.arange(4); ax[1].bar(x - .27, wt[\"도입 시 추정(W0)\"], .27, label=\"initial W0\"); ax[1].bar(x, wt[\"재보정(운영 AFT)\"], .27, label=\"recalibrated\"); ax[1].bar(x + .27, wt[\"숨은 정답(운영)\"], .27, label=\"hidden truth\")\n", + "ax[1].set_xticks(x); ax[1].set_xticklabels(ENVS); ax[1].legend(); ax[1].set_title(\"Environment weight matrix\")\n", + "ax[2].scatter(yb, p0, s=6, alpha=.5, label=\"before\"); ax[2].scatter(yb, p1, s=6, alpha=.5, label=\"after\"); ax[2].plot([0, 4000], [0, 4000], \"k--\"); ax[2].legend(); ax[2].set_title(\"Hold-out RUL: true vs predicted (days)\")\n", + "plt.tight_layout(); plt.show()\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 09 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + "[1] AAR 대상 운영 장비 700대(생존 448대, 고장 252대): 현행 엔진 MAE 180일, RMSE 241일, MAPE 21.1%, R2 0.909, 평균 편향 -55일\n", + " 조건 대수 MAE(일) 평균 편향(일)\n", + " 일반 202 196.0 -106.0\n", + " 험지 75 132.0 100.0\n", + " 염분 106 205.0 -169.0\n", + " 혹한 65 144.0 111.0\n", + " 일 가동률 4h 이상 157 127.0 -9.0\n", + "비계획 정비율 상위 25% 112 195.0 -63.0\n", + " 오차 큰 상위 40대의 원인 분류: 기타(개체 편차) 22대(55%), 염분 가혹도 과대 반영 14대(35%), 혹한 가혹도 과소 반영 2대(5%), 가동률 상승(운용 강도 변화) 1대(2%), 비계획 정비 다수 1대(2%)\n", + "\n", + "[2] 환경 가중치 매트릭스(클수록 빨리 노후)\n", + " 도입 시 추정(W0) 재보정(운영 AFT) 숨은 정답(운영) 변화율\n", + "normal 1.00 1.000 1.00 0.000\n", + "rough 1.15 1.580 1.55 0.374\n", + "salt 1.10 1.164 1.15 0.058\n", + "cold 1.05 1.283 1.30 0.222\n", + " 변화율 20% 초과 환경 ['rough', 'cold']는 정비계획관 검토·군수지휘관 승인 뒤 적용한다(자동 반영 금지).\n", + "\n", + "[3] 보류 장비 461대(재보정에 쓰지 않음) 검증\n", + " 재보정 전 재보정 후\n", + "MAE(일) 184.536 163.118\n", + "RMSE(일) 258.982 234.928\n", + "MAPE(%) 24.450 19.425\n", + "R2 0.887 0.907\n", + "평균 편향(일) -34.864 -64.634\n", + " MAE 11.6% 감소, 환경별 편향(전 -> 후): normal -98 -> -83, rough +101 -> -73, salt -135 -> -55, cold +141 -> -18\n", + "\n", + "[4] 12개월 계획 비교(보류 장비): 비용 118,444 -> 101,921만원, 비계획 고장 27 -> 18대, 계획 정비 73 -> 80대\n", + " ROI 3대 지표 [교육용 가정: 관리 규모 x1, 도입 12000만원, 운영 1200만원/년, 고장 중단 45일·창정비 12일]\n", + " (1) 연간 비용 절감액 15,323만원 (2) 가동 중단일수 단축률 15.4%(2,091일 -> 1,770일) (3) 투자 회수 기간 9.4개월\n", + " 산식: 절감액 = (재보정 전 - 후 12개월 비용) x 규모 - 운영비, 단축률 = 1 - 후 중단일수/전 중단일수, 회수 기간 = 도입비 / (월 절감액)\n", + " 관리 규모 민감도: x0.5 -> 절감 7,061만원, 회수 20개월; x1 -> 절감 15,323만원, 회수 9개월; x2 -> 절감 31,846만원, 회수 5개월; x3 -> 절감 48,368만원, 회수 3개월\n", + " 비관(재보정 효과 절반만 실현, x1): 절감 7,061만원, 회수 20개월\n", + " 가정값이므로 재정·기획 부서와 실제 단가로 다시 산정해야 한다. 재보정 가중치는 군수지휘관 승인 후 운영에 반영한다.\n", + "```\n", + "
\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 10. 장비 노후도 기반 기대수명 예측 및 계획정비 추천 시스템 통합 완성\n", + "**소요시간**: 약 3.5시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "장비 노후도 기반 기대수명 예측 및 계획정비 일정 추천 도구를 완성한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **기대수명 예측과 계획정비 추천 시스템을 통합 실행하고 운영 문서와 자동 재학습 파이프라인을 완성한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **보유 현황 갱신, 정합성 검증, 분포 변화(PSI) 점검, 챔피언/챌린저 재학습, 수명 예측, 12개월 정비 일정 추천을 한 번에 실행 수행하고 검증하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `docs/sup02_operations_guide.md 및 pipeline/sup02_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": [ + "\"\"\"장비 보유 현황 갱신, 정합성 검증, 분포 변화(PSI) 점검, 자동 재학습(챔피언/챌린저), 잔여 수명(RUL) 예측, 정비창 능력·예산·시효성 키트 제약의 12개월 계획정비 일정 추천과 승인대기·도태 후보 분리까지 한 번에 실행하는 통합 파이프라인을 완성하고 운영 문서 완비율·자체 점검·인터페이스 명세·브리핑 요약을 출력한다.\"\"\"\n", + "import importlib\n", + "import json\n", + "import sys\n", + "import numpy as np\n", + "import pandas as pd\n", + "import matplotlib.pyplot as plt\n", + "from sklearn.metrics import r2_score\n", + "\n", + "# ---------------------------------------------------------------------------\n", + "# [교육용가상자료] 가상 장비 보유 현황·고장 이력 생성기 (실제 부대·장비·수치 없음)\n", + "# 가상 부대 6개(U1~U6)의 장비가 운용 환경(normal/rough/salt/cold)에 따라 다른 속도로 노후화한다. 창정비 후 운용시간(h_since)이\n", + "# 장비마다 숨은 수명(t_fail)을 넘으면 고장(event=1) 이력, 넘지 않았으면 현재까지 생존(event=0, 관측 중도절단)이다.\n", + "# ---------------------------------------------------------------------------\n", + "ENVS = [\"normal\", \"rough\", \"salt\", \"cold\"]\n", + "ENV_W = {\"normal\": 1.00, \"rough\": 1.35, \"salt\": 1.25, \"cold\": 1.12} # 환경 가중치(숨은 정답, 가정값): 클수록 수명이 짧다\n", + "ENV_KO = {\"normal\": \"일반\", \"rough\": \"험지\", \"salt\": \"염분\", \"cold\": \"혹한\"}\n", + "UNITS = [\"U1\", \"U2\", \"U3\", \"U4\", \"U5\", \"U6\"]\n", + "UNIT_ENV = {\"U1\": [.7, .1, .1, .1], \"U2\": [.2, .6, .1, .1], \"U3\": [.2, .1, .6, .1], \"U4\": [.3, .1, .1, .5], \"U5\": [.5, .3, .1, .1], \"U6\": [.4, .1, .4, .1]}\n", + "ETYPES = [\"truck\", \"generator\", \"trailer\", \"radio\"]\n", + "CYCLE = 8000.0 # 규정 창정비 주기(운용시간 h, 가정값)\n", + "KSHAPE = 12.0 # 수명 분포 와이블 형상(마모형 고장, 클수록 수명이 모여 있다)\n", + "INSP_NOISE = 0.025 # 정비 점검 마모 비율 측정 오차(가정값)\n", + "\n", + "\n", + "def make_fleet(n=900, seed=0, env_w=None, shift=1.0):\n", + " \"\"\"장비 n대. env_w로 숨은 환경 가중치를 바꿔 운영 분포 변화를 모의할 수 있다. shift>1이면 가동률이 높아진다.\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " w = env_w or ENV_W\n", + " unit = rng.choice(UNITS, n)\n", + " env = np.array([rng.choice(ENVS, p=UNIT_ENV[u]) for u in unit])\n", + " et = rng.choice(ETYPES, n, p=[.4, .2, .2, .2])\n", + " rate = rng.lognormal(np.log(3.0 * shift), 0.25, n) # 일 평균 운용시간(h/일)\n", + " h_since = rng.uniform(300, 11500, n) # 마지막 창정비 이후 운용시간\n", + " n_ovh = rng.integers(0, 3, n)\n", + " hours = h_since + n_ovh * rng.uniform(5000, 8000, n) # 누적 운용시간\n", + " km = hours * rng.normal(17, 3, n) * np.where(env == \"rough\", 0.8, 1.0) # 누적 기동거리(km)\n", + " age_y = np.clip(hours / (rate * 365) * rng.uniform(0.9, 1.15, n), 0.5, 25)\n", + " unpl = rng.poisson(0.35 * h_since / 1000 * np.array([w[e] for e in env]) / 1.3) # 비계획 정비 건수(환경이 가혹할수록 많다)\n", + " n_maint = rng.poisson(0.6 * h_since / 1000 + 1) # 계획 정비 건수\n", + " scale = 9600 / np.array([w[e] for e in env]) * 0.985 ** unpl * np.exp(rng.normal(0, 0.04, n))\n", + " t_fail = scale * rng.weibull(KSHAPE, n) # 창정비 후 고장까지 운용시간(숨은 정답)\n", + " event = (t_fail <= h_since).astype(int)\n", + " dur = np.where(event == 1, t_fail, h_since)\n", + " df = pd.DataFrame({\"eq_id\": [f\"EQ{seed:02d}{i:04d}\" for i in range(n)], \"unit\": unit, \"env\": env, \"etype\": et, \"rate\": rate, \"hours\": hours, \"km\": km, \"age_y\": age_y, \"n_ovh\": n_ovh,\n", + " \"h_since\": h_since, \"unpl\": unpl, \"n_maint\": n_maint, \"t_fail\": t_fail, \"event\": event, \"dur\": dur})\n", + " df[\"insp\"] = np.clip(dur / t_fail + rng.normal(0, INSP_NOISE, n), 0, 1.3) # 마지막 정비 점검에서 측정한 마모 비율(소모 수명/총 수명, 오차 포함)\n", + " df[\"rul_h\"] = np.where(event == 0, t_fail - h_since, np.nan) # 생존 장비의 남은 운용시간(정답)\n", + " df[\"rul_d\"] = df[\"rul_h\"] / df[\"rate\"] # 남은 일수(정답)\n", + " return df\n", + "\n", + "\n", + "def mape(y, p):\n", + " # [TODO : 학생 실습 10] 장비 노후도 기반 기대수명 예측 및 계획정비 추천 시스템 통합 완성 관련 핵심 로직을 구현하세요.\n", + " # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n", + " # <<< 여기에 코드를 작성하세요 >>>\n", + " pass # 작성 후 pass를 제거하세요.\n", + "def mae(y, p):\n", + " # [TODO : 학생 실습 10] 장비 노후도 기반 기대수명 예측 및 계획정비 추천 시스템 통합 완성 관련 핵심 로직을 구현하세요.\n", + " # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n", + " # <<< 여기에 코드를 작성하세요 >>>\n", + " pass # 작성 후 pass를 제거하세요.\n", + "def rmse(y, p):\n", + " return float(np.sqrt(np.mean((np.asarray(y, float) - np.asarray(p, float)) ** 2)))\n", + "# ---------------------------------------------------------------------------\n", + "# ---------------------------------------------------------------------------\n", + "# 노후화 파생 피처와 학습 표본 만들기. 환경 가중치 W0는 도입 시점의 전문가 추정값이며(실제 가중치와 다름) M9에서 재보정한다.\n", + "# ---------------------------------------------------------------------------\n", + "W0 = {\"normal\": 1.00, \"rough\": 1.15, \"salt\": 1.10, \"cold\": 1.05}\n", + "FEATS = [\"h_since\", \"hours\", \"km\", \"age_y\", \"rate\", \"n_maint\", \"unpl\", \"unpl_per_kh\", \"km_per_h\", \"n_ovh\", \"k_rough\", \"k_salt\", \"k_cold\", \"k_truck\", \"k_gen\", \"k_trailer\",\n", + " \"aging_idx\", \"lin_rul_d\", \"hs_frac\", \"insp\", \"insp_eta_h\"]\n", + "\n", + "\n", + "def add_features(df, w=None):\n", + " d = df.copy()\n", + " w = w or W0\n", + " d[\"unpl_per_kh\"] = d[\"unpl\"] / (d[\"h_since\"] / 1000 + 0.5)\n", + " d[\"km_per_h\"] = d[\"km\"] / d[\"hours\"]\n", + " for e in (\"rough\", \"salt\", \"cold\"):\n", + " d[\"k_\" + e] = (d[\"env\"] == e).astype(int)\n", + " d[\"k_truck\"], d[\"k_gen\"], d[\"k_trailer\"] = (d[\"etype\"] == \"truck\").astype(int), (d[\"etype\"] == \"generator\").astype(int), (d[\"etype\"] == \"trailer\").astype(int)\n", + " d[\"aging_idx\"] = d[\"h_since\"] * d[\"env\"].map(w) / CYCLE # 환경 가중 노후 지수(1이면 창정비 주기에 도달)\n", + " d[\"lin_rul_d\"] = (CYCLE - d[\"h_since\"]) / d[\"rate\"] # 선형 감가상각 잔여 일수(Baseline)\n", + " d[\"hs_frac\"] = d[\"h_since\"] / CYCLE\n", + " d[\"insp_eta_h\"] = d[\"h_since\"] / np.maximum(d[\"insp\"], 0.05) # 점검 마모 비율로 환산한 총 수명 추정(h)\n", + " return d\n", + "\n", + "\n", + "def snapshots(df, k=4, seed=0):\n", + " \"\"\"고장 이력 장비의 과거 시점 표본: 고장 전 임의 시점의 점검값·운용시간과 그때의 남은 일수. (생존 장비의 미래는 알 수 없으므로 고장 이력만 쓴다)\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " f = df[df[\"event\"] == 1]\n", + " out = []\n", + " for _ in range(k):\n", + " s = f.copy()\n", + " h = s[\"t_fail\"] * rng.uniform(0.2, 0.98, len(s))\n", + " ratio = h / s[\"h_since\"]\n", + " s[\"hours\"] = s[\"hours\"] - (s[\"h_since\"] - h)\n", + " s[\"km\"] = s[\"km\"] * ratio.clip(upper=1)\n", + " s[\"unpl\"] = np.floor(s[\"unpl\"] * ratio)\n", + " s[\"n_maint\"] = np.floor(s[\"n_maint\"] * ratio)\n", + " s[\"insp\"] = np.clip(h / s[\"t_fail\"] + rng.normal(0, INSP_NOISE, len(s)), 0, 1.3)\n", + " s[\"h_since\"] = h\n", + " s[\"rul_d\"] = (s[\"t_fail\"] - h) / s[\"rate\"]\n", + " s[\"event\"] = 0\n", + " out.append(s)\n", + " return pd.concat(out, ignore_index=True)\n", + "# ---------------------------------------------------------------------------\n", + "# ---------------------------------------------------------------------------\n", + "# 생존 분석 도구(scipy만 사용): 와이블 AFT(가속 수명) 모형과 Cox 비례위험 모형\n", + "# ---------------------------------------------------------------------------\n", + "from scipy.optimize import minimize\n", + "\n", + "\n", + "def aft_x(d):\n", + " return np.c_[np.ones(len(d)), d[\"env\"].eq(\"rough\"), d[\"env\"].eq(\"salt\"), d[\"env\"].eq(\"cold\"), d[\"unpl\"] / (d[\"h_since\"] / 1000 + 0.5)].astype(float)\n", + "\n", + "\n", + "AFT_COLS = [\"절편\", \"험지\", \"염분\", \"혹한\", \"비계획 정비율\"]\n", + "\n", + "\n", + "def fit_aft(d):\n", + " \"\"\"창정비 후 운용시간 dur(고장이면 event=1, 생존이면 중도절단)에 대한 와이블 AFT 최대우도 추정. 반환: 계수(마지막은 log 형상).\"\"\"\n", + " x, t, e = aft_x(d), d[\"dur\"].values, d[\"event\"].values\n", + "\n", + " def nll(p):\n", + " lam, k = np.exp(x @ p[:-1]), np.exp(p[-1])\n", + " z = (t / lam) ** k\n", + " return -(e * (np.log(k) - np.log(lam) + (k - 1) * (np.log(t) - np.log(lam))) - z).sum()\n", + "\n", + " r = minimize(nll, np.r_[np.log(8000), np.zeros(x.shape[1] - 1), np.log(5)], method=\"BFGS\")\n", + " return r.x\n", + "\n", + "\n", + "def aft_pred(p, d, q=0.5, h=None):\n", + " \"\"\"이미 h(운용시간)까지 생존했다는 조건에서 분위수 q 생존 시점(운용시간 h 기준 총 수명)을 돌려준다. q=0.5가 조건부 중앙값.\"\"\"\n", + " lam, k = np.exp(aft_x(d) @ p[:-1]), np.exp(p[-1])\n", + " h = d[\"h_since\"].values if h is None else h\n", + " return lam * ((h / lam) ** k - np.log(q)) ** (1 / k)\n", + "\n", + "\n", + "def aft_fail_prob(p, d, h0, h1):\n", + " \"\"\"h0까지 생존한 장비가 h0~h1 사이에 고장 날 조건부 확률.\"\"\"\n", + " lam, k = np.exp(aft_x(d) @ p[:-1]), np.exp(p[-1])\n", + " s0, s1 = np.exp(-(h0 / lam) ** k), np.exp(-(h1 / lam) ** k)\n", + " return 1 - s1 / s0\n", + "\n", + "\n", + "def fit_cox(d, iters=25):\n", + " \"\"\"Cox 비례위험(Breslow 근사) 뉴턴-랩슨. 공변량: 험지·염분·혹한·비계획 정비율. 반환: 계수.\"\"\"\n", + " x = aft_x(d)[:, 1:]\n", + " o = np.argsort(-d[\"dur\"].values)\n", + " x, e = x[o], d[\"event\"].values[o]\n", + " b = np.zeros(x.shape[1])\n", + " for _ in range(iters):\n", + " r = np.exp(x @ b)\n", + " S0 = np.cumsum(r)\n", + " S1 = np.cumsum(r[:, None] * x, axis=0)\n", + " S2 = np.cumsum(r[:, None, None] * x[:, :, None] * x[:, None, :], axis=0)\n", + " m1 = S1 / S0[:, None]\n", + " g = ((x - m1) * e[:, None]).sum(axis=0)\n", + " H = -sum(e[i] * (S2[i] / S0[i] - np.outer(m1[i], m1[i])) for i in np.where(e == 1)[0])\n", + " step = np.linalg.solve(H - 1e-6 * np.eye(len(b)), g)\n", + " b = b - step\n", + " if np.abs(step).max() < 1e-7:\n", + " break\n", + " return b\n", + "\n", + "\n", + "def cindex(time, event, risk):\n", + " \"\"\"일치도 C-index: 위험 점수가 높은 쪽이 먼저 고장 났는가(관측 가능한 쌍만).\"\"\"\n", + " t, e, r = np.asarray(time), np.asarray(event), np.asarray(risk)\n", + " num = den = 0.0\n", + " for i in np.where(e == 1)[0]:\n", + " m = t > t[i]\n", + " den += m.sum()\n", + " num += (r[m] < r[i]).sum() + 0.5 * (r[m] == r[i]).sum()\n", + " return num / max(den, 1)\n", + "# ---------------------------------------------------------------------------\n", + "# ---------------------------------------------------------------------------\n", + "# 잔여 수명(RUL, 일) 예측 엔진: 와이블 AFT 조건부 중앙값 + 그래디언트 부스팅(고장 이력 시점 표본 학습)의 평균\n", + "# ---------------------------------------------------------------------------\n", + "from sklearn.ensemble import GradientBoostingRegressor\n", + "\n", + "\n", + "def fit_engine(train, seed=0):\n", + " p = fit_aft(train)\n", + " sn = add_features(snapshots(train, 5, seed))\n", + " sn[\"aft\"] = (aft_pred(p, sn) - sn[\"h_since\"]) / sn[\"rate\"]\n", + " g = GradientBoostingRegressor(n_estimators=250, max_depth=3, learning_rate=0.06, random_state=0).fit(sn[FEATS + [\"aft\"]], sn[\"rul_d\"])\n", + " return {\"p\": p, \"g\": g}\n", + "\n", + "\n", + "def predict_rul(eng, d):\n", + " \"\"\"d는 add_features를 거친 표. 반환: (앙상블 RUL, AFT RUL, GBR RUL) - 일 단위.\"\"\"\n", + " aft = ((aft_pred(eng[\"p\"], d) - d[\"h_since\"]) / d[\"rate\"]).values\n", + " x = d[FEATS].copy()\n", + " x[\"aft\"] = aft\n", + " gb = eng[\"g\"].predict(x)\n", + " return np.clip(0.5 * aft + 0.5 * gb, 1, None), aft, gb\n", + "# ---------------------------------------------------------------------------\n", + "# ---------------------------------------------------------------------------\n", + "# 계획정비(창정비) 일정 추천: 월별 정비창 능력·예산·시효성 부품 키트(유효기한) 제약 아래 기대 비용 최소화 (비용 단가는 교육용 가정값, 만원)\n", + "# ---------------------------------------------------------------------------\n", + "from scipy.optimize import milp, LinearConstraint, Bounds\n", + "from scipy.stats import norm\n", + "\n", + "C_PLAN, C_FAIL, C_WASTE_KH, C_KIT = 600.0, 2400.0, 40.0, 120.0 # 계획 창정비, 비계획 고장(긴급 정비 포함), 미사용 수명 1000h당 손실, 시효성 부품 키트\n", + "HORIZON, MDAYS = 12, 30 # 계획 12개월, 월 30일\n", + "CAP_M, BUDGET_M = 22, 15000.0 # 월별 정비창 능력(대), 월별 예산\n", + "LOTS = [(30, 70), (45, 170), (45, 390), (70, 800)] # 시효성 키트 로트(수량, 유효기한까지 남은 일수)\n", + "KIT_MARGIN = 14 # 정비일 기준 최소 유효기간 여유(일)\n", + "\n", + "\n", + "def exp_cost_matrix(pred, rate, month_days):\n", + " \"\"\"장비 i를 월 m에 정비할 때의 기대 비용. 참 RUL ~ N(pred, sigma), sigma = 35일 + 0.12 x pred (가정). 마지막 열은 계획 기간 밖(미정비).\"\"\"\n", + " sig = 35 + 0.12 * pred\n", + " C = np.zeros((len(pred), len(month_days) + 1))\n", + " for j, day in enumerate(month_days):\n", + " z = (day - pred) / sig\n", + " pf = norm.cdf(z)\n", + " unused = np.maximum(pred - day, 0) * rate * 0.6 # 정비를 앞당긴 만큼 버리는 운용시간 h(근사)\n", + " C[:, j] = C_PLAN + C_KIT + pf * (C_FAIL - C_PLAN) + C_WASTE_KH * unused / 1000 * (1 - pf)\n", + " C[:, -1] = norm.cdf((HORIZON * MDAYS - pred) / sig) * C_FAIL\n", + " return C\n", + "\n", + "\n", + "def plan_milp(pred, rate, cap=CAP_M, budget=BUDGET_M, lots=LOTS, use_kit=True):\n", + " \"\"\"정수계획으로 장비별 정비 월(0~11) 또는 미정비(-1)를 배정한다. 후보는 예측 RUL이 계획 기간 +90일 이내인 장비.\"\"\"\n", + " md = np.arange(1, HORIZON + 1) * MDAYS\n", + " cand = np.where(pred < HORIZON * MDAYS + 90)[0]\n", + " n, M = len(cand), HORIZON\n", + " C = exp_cost_matrix(pred[cand], rate[cand], md)\n", + " c = C.ravel()\n", + " A, lo, hi = [], [], []\n", + " for i in range(n): # 장비당 한 번만 배정(월 12칸 + 미정비)\n", + " row = np.zeros(n * (M + 1)); row[i * (M + 1):(i + 1) * (M + 1)] = 1; A.append(row); lo.append(1); hi.append(1)\n", + " for m in range(M): # 월별 능력·예산\n", + " row = np.zeros(n * (M + 1)); row[m::(M + 1)] = 1\n", + " A.append(row); lo.append(0); hi.append(cap)\n", + " A.append(row * (C_PLAN + C_KIT)); lo.append(0); hi.append(budget)\n", + " if use_kit: # 시효성 키트: m월 이후 정비 수요는 그 시점에 유효기간이 남은 로트 수량을 넘을 수 없다\n", + " for m in range(M):\n", + " sup = sum(q for q, e in lots if e >= md[m] + KIT_MARGIN)\n", + " row = np.zeros(n * (M + 1))\n", + " for mm in range(m, M):\n", + " row[mm::(M + 1)] = 1\n", + " A.append(row); lo.append(0); hi.append(sup)\n", + " r = milp(c, constraints=LinearConstraint(np.array(A), lo, hi), integrality=np.ones(len(c)), bounds=Bounds(0, 1), options={\"time_limit\": 20})\n", + " month = np.full(len(pred), -1)\n", + " if r.x is not None:\n", + " x = np.round(r.x).reshape(n, M + 1)\n", + " assign = np.where(x[:, :M].sum(axis=1) > 0.5, x[:, :M].argmax(axis=1), -1)\n", + " month[cand] = assign\n", + " return month\n", + "\n", + "\n", + "def plan_greedy(pred, margin=45, cap=CAP_M, budget=BUDGET_M):\n", + " \"\"\"간단한 탐욕 배정: 정비 목표일(예측 RUL - margin)이 빠른 순으로 목표일 이전 가장 늦은 빈 월, 없으면 가장 이른 빈 월에 배정.\"\"\"\n", + " cap = min(cap, int(budget // (C_PLAN + C_KIT))) # 월 예산이 허용하는 대수로 능력을 제한\n", + " month = np.full(len(pred), -1)\n", + " left = np.full(HORIZON, cap)\n", + " for i in np.argsort(pred):\n", + " if pred[i] > HORIZON * MDAYS + 60: break\n", + " tgt = int(np.clip((pred[i] - margin) // MDAYS - 1, 0, HORIZON - 1))\n", + " for m in list(range(tgt, -1, -1)) + list(range(tgt + 1, HORIZON)):\n", + " if left[m] > 0:\n", + " month[i] = m; left[m] -= 1; break\n", + " return month\n", + "\n", + "\n", + "def plan_fixed(lin_rul, cap=CAP_M, budget=BUDGET_M):\n", + " \"\"\"관행: 규정 주기 도달 순서대로(선형 잔여 일수) 월별 능력 안에서 배정.\"\"\"\n", + " return plan_greedy(np.asarray(lin_rul, float), margin=0, cap=cap, budget=budget)\n", + "\n", + "\n", + "def evaluate_plan(month, true_rul, rate, cost_kit=True):\n", + " \"\"\"참 RUL로 계획의 실제 비용을 계산한다: 정비일 이전에 고장 -> 비계획 고장 비용, 아니면 계획 비용 + 미사용 수명 손실.\"\"\"\n", + " day = np.where(month >= 0, (month + 1) * MDAYS, 10 ** 9)\n", + " fail = (true_rul < day) & (true_rul < HORIZON * MDAYS)\n", + " planned = (month >= 0) & ~fail\n", + " waste = np.where(planned, (true_rul - day) * rate * 0.6, 0) * C_WASTE_KH / 1000\n", + " cost = fail * C_FAIL + planned * (C_PLAN + (C_KIT if cost_kit else 0)) + waste\n", + " return {\"total\": float(cost.sum()), \"fail\": int(fail.sum()), \"planned\": int(planned.sum()), \"waste\": float(waste.sum()), \"unsched_fail\": int(((month < 0) & fail).sum())}\n", + "\n", + "\n", + "def kit_fefo(month, lots=LOTS):\n", + " \"\"\"유효기한이 빠른 로트부터 쓰는 FEFO로 키트를 배정하고 정비일에 유효기간이 부족한 배정 수와 만료 폐기 수량을 센다.\"\"\"\n", + " q = [[qty, e] for qty, e in sorted(lots, key=lambda t: t[1])]\n", + " short = 0\n", + " for m in sorted(month[month >= 0]):\n", + " day = (m + 1) * MDAYS\n", + " for lot in q:\n", + " if lot[1] >= day + KIT_MARGIN and lot[0] > 0:\n", + " lot[0] -= 1; break\n", + " else:\n", + " short += 1\n", + " expired = sum(l[0] for l in q if l[1] < HORIZON * MDAYS)\n", + " return {\"short\": short, \"expired_unused\": expired, \"left\": sum(l[0] for l in q)}\n", + "# ---------------------------------------------------------------------------\n", + "\n", + "libs = [\"numpy\", \"pandas\", \"scipy\", \"sklearn\", \"matplotlib\"]\n", + "env_ok = sum(importlib.util.find_spec(m) is not None for m in libs)\n", + "print(f\"[0] 환경 점검: Python {sys.version_info.major}.{sys.version_info.minor}, 필수 모듈 {env_ok}/{len(libs)}\")\n", + "\n", + "# ---------------- 1) 보유 현황 갱신·정합성 검증(미션 1~2 요약) ----------------\n", + "OPS_W = {\"normal\": 1.00, \"rough\": 1.55, \"salt\": 1.15, \"cold\": 1.30}\n", + "base = make_fleet(900, seed=1) # 기존 보유 현황\n", + "new = make_fleet(500, seed=90, env_w=OPS_W, shift=1.1) # 신규 입력분(운영 환경이 달라짐)\n", + "raw = pd.concat([base, new], ignore_index=True)\n", + "chk = {\"장비 ID 유일\": not raw[\"eq_id\"].duplicated().any(), \"결측 0\": int(raw[[\"rate\", \"hours\", \"h_since\", \"insp\", \"unpl\"]].isna().sum().sum()) == 0, \"환경 값 4종\": bool(raw[\"env\"].isin(ENVS).all()),\n", + " \"부대 코드 등록\": bool(raw[\"unit\"].isin(UNITS).all()), \"운용시간 0 이상\": bool((raw[\"hours\"] >= 0).all()), \"창정비 후 <= 누적\": bool((raw[\"h_since\"] <= raw[\"hours\"] + 1e-6).all()),\n", + " \"기동 속도 5~40km/h\": bool((raw[\"km\"] / raw[\"hours\"]).between(5, 40).all()), \"점검 마모 0~1.3\": bool(raw[\"insp\"].between(0, 1.3).all())}\n", + "print(f\"[1] 신규 {len(new)}대 포함 {len(raw)}대 정합성 검증 {sum(chk.values())}/{len(chk)} 통과\")\n", + "\n", + "# ---------------- 2) 분포 변화 점검과 자동 재학습 ----------------\n", + "def psi(a, b, bins=8):\n", + " q = np.unique(np.quantile(a, np.linspace(0, 1, bins + 1)))\n", + " q[0], q[-1] = -np.inf, np.inf\n", + " pa, pb = np.histogram(a, q)[0] / len(a) + 1e-4, np.histogram(b, q)[0] / len(b) + 1e-4\n", + " return float(np.sum((pa - pb) * np.log(pa / pb)))\n", + "\n", + "\n", + "fb, fn = add_features(base), add_features(new)\n", + "drift = {c: psi(fb[c], fn[c]) for c in [\"rate\", \"unpl_per_kh\", \"insp_eta_h\", \"aging_idx\", \"km_per_h\"]}\n", + "worst = max(drift, key=drift.get)\n", + "print(\"[2] 분포 변화 PSI(기존 vs 신규):\", \", \".join(f\"{k} {v:.2f}\" for k, v in drift.items()), f\"-> 가장 큰 변화 {worst}(0.2 이상이면 재학습 검토)\")\n", + "new_tr, new_ho = new.iloc[:300].reset_index(drop=True), new.iloc[300:].reset_index(drop=True)\n", + "champ = fit_engine(base)\n", + "chall = fit_engine(pd.concat([base.sample(300, random_state=0), new_tr], ignore_index=True), seed=1) # 최근 분포를 반영해 기존 일부 + 신규 학습분으로 재학습\n", + "ho = add_features(new_ho)\n", + "ho = ho[ho.event == 0].reset_index(drop=True)\n", + "pc, _, _ = predict_rul(champ, ho)\n", + "ph, _, _ = predict_rul(chall, ho)\n", + "y = ho[\"rul_d\"].values\n", + "big = y >= 90\n", + "ac, bc = mae(y, pc), mae(y, ph)\n", + "promote = (ac - bc) / ac >= 0.03\n", + "print(f\" 검증 {len(ho)}대(신규 분포) MAE 챔피언 {ac:.0f}일 vs 챌린저 {bc:.0f}일, MAPE {mape(y[big], pc[big]):.1f}% vs {mape(y[big], ph[big]):.1f}% -> {'챌린저 승격' if promote else '기존 모델 유지'}(MAE 3% 이상 개선일 때만 승격)\")\n", + "model = chall if promote else champ\n", + "\n", + "# ---------------- 3) RUL 예측과 12개월 정비 일정 ----------------\n", + "fl = add_features(new)\n", + "al = fl[fl.event == 0].reset_index(drop=True)\n", + "al[\"rul_pred\"], _, _ = predict_rul(model, al)\n", + "al[\"month\"] = plan_milp(al[\"rul_pred\"].values, al[\"rate\"].values)\n", + "mf = plan_fixed(al[\"lin_rul_d\"].values)\n", + "r_fix, r_new = evaluate_plan(mf, al[\"rul_d\"].values, al[\"rate\"].values), evaluate_plan(al[\"month\"].values, al[\"rul_d\"].values, al[\"rate\"].values)\n", + "kf = kit_fefo(al[\"month\"].values)\n", + "print(f\"\\n[3] 신규 보유 {len(al)}대 12개월 정비 일정 [교육용가상자료]: 정비 배정 {int((al.month >= 0).sum())}대, 월별 \" + \" \".join(f\"{m + 1}월 {c}\" for m, c in enumerate(np.bincount(al.loc[al.month >= 0, 'month'], minlength=HORIZON))))\n", + "print(f\" 규정 주기 고정 일정 대비 실제 비용 {r_fix['total']:,.0f} -> {r_new['total']:,.0f}만원({1 - r_new['total'] / r_fix['total']:.1%} 절감), 비계획 고장 {r_fix['fail']} -> {r_new['fail']}대, 키트 유효기간 부족 {kf['short']}건\")\n", + "al[\"처리\"] = np.select([al.rul_pred < 90, (al.age_y >= 14) & (al.rul_pred < 365)], [\"긴급 점검(현장 확인 후 군수지휘관 승인)\", \"도태 건의 후보(2단계 결재)\"], np.where(al.month >= 0, \"정비 일정 승인대기\", \"정기 점검 유지\"))\n", + "top = al.sort_values(\"rul_pred\").head(6)\n", + "print(pd.DataFrame({\"장비\": top.eq_id, \"부대\": top.unit, \"환경\": top.env.map(ENV_KO), \"예측 RUL(일)\": top.rul_pred, \"권장 월\": top.month.map(lambda m: f\"{m + 1}월\" if m >= 0 else \"-\"), \"처리\": top[\"처리\"]}).round(0).to_string(index=False))\n", + "vc = al[\"처리\"].value_counts()\n", + "print(\" 처리 구분:\", vc.to_dict(), \"/ 자동 확정 0건(모든 일정·도태는 군수지휘관 승인 필요)\")\n", + "\n", + "# ---------------- 4) 운영 문서 완비율·인터페이스 명세 ----------------\n", + "docs = {\"데이터 사전·스키마(M1)\": 30, \"정제 규칙 11종(M2)\": 11, \"Baseline·EDA(M3)\": 8, \"생존 분석 모델(M4)\": 12, \"RUL 엔진·정비 일정(M5)\": 4, \"대시보드·설명(M6)\": 5, \"승인 절차·권한(M7)\": 4,\n", + " \"일괄 시뮬레이션·부하(M8)\": 5, \"AAR·ROI(M9)\": 4, \"운영·재학습 절차(M10)\": 6, \"장애 대응\": 5, \"인터페이스 가이드\": 4}\n", + "done = sum(v > 0 for v in docs.values())\n", + "iface = {\"input\": {\"fleet\": [\"eq_id:str\", \"unit:str\", \"env:str\", \"rate:float\", \"hours:float\", \"h_since:float\", \"unpl:int\", \"insp:float\"], \"lots\": [\"lot_id\", \"qty\", \"expiry_d\"], \"format\": \"json-lines\"},\n", + " \"output\": {\"plan\": [\"eq_id\", \"rul_days\", \"month\", \"status\"], \"approval\": \"PENDING_ONLY\"}, \"auth\": \"role-based(가상)\", \"note\": \"상위 군수정보체계는 가상 인터페이스로만 정의\"}\n", + "print(f\"\\n[4] 운영 문서 {len(docs)}개 항목 중 작성 완료 {done}개 ({done / len(docs):.0%}) / 인터페이스 명세(JSON):\")\n", + "print(\" \", json.dumps(iface, ensure_ascii=False))\n", + "\n", + "# ---------------- 5) 자체 점검·브리핑 ----------------\n", + "pm, _, _ = predict_rul(model, ho)\n", + "fin_mae, fin_mape, base_mae = mae(y, pm), mape(y[big], pm[big]), mae(y, ho[\"lin_rul_d\"].values)\n", + "checks = [(\"환경 점검 통과\", env_ok == len(libs)), (\"데이터 정합성 전 항목 통과\", all(chk.values())), (\"재학습 파이프라인 정상 실행(챔피언/챌린저 판정 출력)\", ac > 0 and bc > 0),\n", + " (\"RUL MAPE 25% 이하(참 RUL 90일 이상)\", fin_mape <= 25), (\"선형 감가상각 대비 MAE 20% 이상 개선\", fin_mae <= 0.8 * base_mae), (\"고정 주기 대비 계획 비용 15% 이상 절감\", r_new[\"total\"] <= 0.85 * r_fix[\"total\"]),\n", + " (\"자동 확정 0건·키트 유효기간 부족 0건\", bool(al[\"처리\"].str.contains(\"승인|점검|건의\").all()) and kf[\"short\"] == 0), (\"운영 문서 100% 완비\", done == len(docs))]\n", + "print(\"\\n[5] 자체 점검\")\n", + "for n, ok in checks:\n", + " print(f\" [{'PASS' if ok else 'FAIL'}] {n}\")\n", + "print(\"\\n[6] 브리핑 요약\")\n", + "print(f\" - 신규 {len(new)}대로 분포 변화를 점검({worst} PSI {drift[worst]:.2f})하고 재학습 판단을 자동 수행했다({'챌린저 승격' if promote else '기존 모델 유지'}).\")\n", + "print(f\" - 신규 분포 RUL MAE {base_mae:.0f}일(선형 감가상각) -> {fin_mae:.0f}일, MAPE {fin_mape:.1f}%. 12개월 계획 비용 {1 - r_new['total'] / r_fix['total']:.1%} 절감, 비계획 고장 {r_fix['fail']} -> {r_new['fail']}대(가정 단가 기준).\")\n", + "print(\" - 정비 일정·긴급 점검·도태 후보는 모두 승인대기로 분리했다. 확정은 군수지휘관(도태는 재정 담당 포함) 결재에 따른다.\")\n", + "\n", + "fig, ax = plt.subplots(1, 2, figsize=(12, 4))\n", + "ax[0].bar(list(drift), list(drift.values())); ax[0].axhline(0.2, c=\"r\", ls=\"--\"); ax[0].set_title(\"Feature drift (PSI), old vs new fleet\"); ax[0].tick_params(axis=\"x\", rotation=20)\n", + "ax[1].scatter(y, pc, s=6, alpha=.5, label=\"champion\"); ax[1].scatter(y, ph, s=6, alpha=.5, label=\"challenger\"); ax[1].plot([0, 4000], [0, 4000], \"k--\"); ax[1].legend(); ax[1].set_title(\"Hold-out RUL: true vs predicted (days)\")\n", + "plt.suptitle(\"SUP02 integrated pipeline (synthetic)\"); plt.tight_layout(); plt.show()\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 10 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + "[0] 환경 점검: Python 3.11, 필수 모듈 5/5\n", + "[1] 신규 500대 포함 1400대 정합성 검증 8/8 통과\n", + "[2] 분포 변화 PSI(기존 vs 신규): rate 0.09, unpl_per_kh 0.03, insp_eta_h 0.03, aging_idx 0.02, km_per_h 0.03 -> 가장 큰 변화 rate(0.2 이상이면 재학습 검토)\n", + " 검증 132대(신규 분포) MAE 챔피언 216일 vs 챌린저 209일, MAPE 24.9% vs 22.8% -> 챌린저 승격(MAE 3% 이상 개선일 때만 승격)\n", + "\n", + "[3] 신규 보유 322대 12개월 정비 일정 [교육용가상자료]: 정비 배정 56대, 월별 1월 20 2월 20 3월 8 4월 7 5월 1 6월 0 7월 0 8월 0 9월 0 10월 0 11월 0 12월 0\n", + " 규정 주기 고정 일정 대비 실제 비용 117,515 -> 76,804만원(34.6% 절감), 비계획 고장 36 -> 17대, 키트 유효기간 부족 0건\n", + " 장비 부대 환경 예측 RUL(일) 권장 월 처리\n", + "EQ900423 U6 일반 74.0 1월 긴급 점검(현장 확인 후 군수지휘관 승인)\n", + "EQ900136 U5 험지 75.0 1월 긴급 점검(현장 확인 후 군수지휘관 승인)\n", + "EQ900455 U6 염분 90.0 1월 정비 일정 승인대기\n", + "EQ900280 U3 염분 111.0 1월 정비 일정 승인대기\n", + "EQ900040 U1 염분 124.0 1월 정비 일정 승인대기\n", + "EQ900035 U1 일반 134.0 1월 정비 일정 승인대기\n", + " 처리 구분: {'정기 점검 유지': 266, '정비 일정 승인대기': 37, '도태 건의 후보(2단계 결재)': 17, '긴급 점검(현장 확인 후 군수지휘관 승인)': 2} / 자동 확정 0건(모든 일정·도태는 군수지휘관 승인 필요)\n", + "\n", + "[4] 운영 문서 12개 항목 중 작성 완료 12개 (100%) / 인터페이스 명세(JSON):\n", + " {\"input\": {\"fleet\": [\"eq_id:str\", \"unit:str\", \"env:str\", \"rate:float\", \"hours:float\", \"h_since:float\", \"unpl:int\", \"insp:float\"], \"lots\": [\"lot_id\", \"qty\", \"expiry_d\"], \"format\": \"json-lines\"}, \"output\": {\"plan\": [\"eq_id\", \"rul_days\", \"month\", \"status\"], \"approval\": \"PENDING_ONLY\"}, \"auth\": \"role-based(가상)\", \"note\": \"상위 군수정보체계는 가상 인터페이스로만 정의\"}\n", + "\n", + "[5] 자체 점검\n", + " [PASS] 환경 점검 통과\n", + " [PASS] 데이터 정합성 전 항목 통과\n", + " [PASS] 재학습 파이프라인 정상 실행(챔피언/챌린저 판정 출력)\n", + " [PASS] RUL MAPE 25% 이하(참 RUL 90일 이상)\n", + " [PASS] 선형 감가상각 대비 MAE 20% 이상 개선\n", + " [PASS] 고정 주기 대비 계획 비용 15% 이상 절감\n", + " [PASS] 자동 확정 0건·키트 유효기간 부족 0건\n", + " [PASS] 운영 문서 100% 완비\n", + "\n", + "[6] 브리핑 요약\n", + " - 신규 500대로 분포 변화를 점검(rate PSI 0.09)하고 재학습 판단을 자동 수행했다(챌린저 승격).\n", + " - 신규 분포 RUL MAE 377일(선형 감가상각) -> 209일, MAPE 22.8%. 12개월 계획 비용 34.6% 절감, 비계획 고장 36 -> 17대(가정 단가 기준).\n", + " - 정비 일정·긴급 점검·도태 후보는 모두 승인대기로 분리했다. 확정은 군수지휘관(도태는 재정 담당 포함) 결재에 따른다.\n", + "```\n", + "
\n" + ] + } + ], + "metadata": { + "colab": { + "provenance": [] + }, + "kernelspec": { + "display_name": "Python 3", + "name": "python3" + }, + "language_info": { + "name": "python" + } + }, + "nbformat": 4, + "nbformat_minor": 0 +} \ No newline at end of file diff --git a/uploads/TRN01/TRN01_solution.ipynb b/uploads/TRN01/TRN01_solution.ipynb new file mode 100644 index 0000000..4623e9c --- /dev/null +++ b/uploads/TRN01/TRN01_solution.ipynb @@ -0,0 +1,3144 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# 🎯 [TRN01] 드론 시뮬레이터 비행로그 기반 숙련도 분석기\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/TRN01/TRN01_solution.ipynb)\n", + "\n", + "---\n", + "\n", + "## 📌 1. 프로젝트 개요 및 배경\n", + "\n", + "| 항목 | 내용 |\n", + "| :--- | :--- |\n", + "| **과정 분류** | 교육·훈련 (Level 3) |\n", + "| **학습 소요 시간** | 10개 미션 (5일 / 총 27.5시간) |\n", + "| **환경 사양** | Python 3.10+, numpy, pandas, matplotlib, scipy, scikit-learn |\n", + "\n", + "### 📖 프로젝트 목표\n", + "러시아-우크라이나 전쟁에서는 소형 드론 조종 인력을 짧은 기간에 대량으로 양성해야 했고, 시뮬레이터 훈련에서 누가 어떤 조작 약점을 가지고 있는지 빠르게 찾아 개인별로 보완하는 일이 전투력 유지의 관건이 되었다. 본 프로젝트에서는 교육용 가상 드론 시뮬레이터 비행로그(경로 비행, 호버링, 돌발 장애물 회피, 비상 착륙)를 코드로 직접 만들어, 로그 스키마 정의, 잡음·결측 전처리, 경로 이탈도·호버링 안정성 기반 Baseline 채점, 장애물 반응·착륙 속도를 반영한 정밀 평가 엔진, 조작 습관 분석과 신뢰도 등급, 3D 재생 대시보드, 교관 HITL 평가 인터페이스, 대량 배치 스트레스 시험, 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. 드론 조종 시뮬레이터 비행로그(스틱 조작, 자세각, 고도 유지, 경로 오차) 데이터 스키마 정의\n", + "**소요시간**: 약 1.5시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "드론 조종 시뮬레이터 비행로그(스틱 조작, 자세각, 고도 유지 오차) 데이터 스키마를 정의한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **드론 조종 시뮬레이터 비행로그(스틱 조작, 자세각, 고도, 위치·목표 경로)와 비행 세션 메타데이터의 스키마를 정의하고 잘못된 로그를 걸러내는 검증기를 만든다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **세션(가명 조종자 ID, 코스, 기록 주기, 비행 시간), 전술 파라미터(비행 조건, 풍속, GPS 오차, 영상 지연), 레코드(스틱 3축, 자세각, 고도, 위치·목표) 필드와 허용 범위 정의하고 검증하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `data/schema/trn01_input_schema.json 및 docs/trn01_data_spec.md` 형태의 결과물을 완성한다.\n", + "\n", + "### 🔍 평가 및 검증 기준 (PASS 조건)\n", + "#### 미션 판정\n", + "\n", + "**평가 구분:** 필수\n", + "\n", + "**핵심 평가영역:** 교육·훈련 실무 및 러시아-우크라이나 전쟁 전장 환경과의 군사적 부합성\n", + "\n", + "#### 미션 PASS 조건\n", + "\n", + "- 정량 기준: 스키마 필드 정의 완성도 100%, 샘플 데이터 검증 오류 0건\n", + "\n", + "\n", + "- 진행 기준: 입력 데이터 유효성 검증 스크립트 통과 및 명세서 문서화 완료\n", + "\n", + "\n", + "- 검증 방법: JSON Schema 검증 도구 및 샘플 데이터 파싱 단위 테스트\n", + "\n", + "\n", + "#### 채점자 판정 메모\n", + "\n", + "> 단순 코드 실행 여부뿐만 아니라 출력된 수치와 근거 자료가 군 실무 요구사항에 부합하는지 면밀히 확인한다.\n", + "\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "\"\"\"드론 조종 시뮬레이터 비행로그(스틱 조작, 자세각, 고도, 위치·목표 경로)와 비행 세션 메타데이터(가명 조종자, 비행 조건, 전술 파라미터)의 스키마를 정의하고 가상 샘플 데이터를 검증한다.\"\"\"\n", + "import json\n", + "import re\n", + "import numpy as np\n", + "import pandas as pd\n", + "\n", + "# ---------------------------------------------------------------------------\n", + "# [교육용가상자료] 드론 조종 시뮬레이터 비행로그 생성기 (실제 훈련생·장비 정보 없음, 조종자는 가명 P001~)\n", + "# 비행 1회 = 120초(10Hz): 경로 비행(0~60초) -> 호버링(60~85초) -> 돌발 장애물 회피(85초) -> 비상 착륙(100초 지시)\n", + "# 조종자마다 숨은 숙련도(skill)와 조작 습관(급조작 jerky, 과보정 overcorr)이 있고, 교관 점수는 이를 반영해 만든다.\n", + "# ---------------------------------------------------------------------------\n", + "DT, STEPS = 0.1, 1200\n", + "T = np.arange(STEPS) * DT\n", + "COND = {\"day\": dict(wind=0.4, gust=0.0, gps=0.25, delay=0.0), \"night\": dict(wind=0.4, gust=0.0, gps=0.5, delay=0.15),\n", + " \"gust\": dict(wind=1.2, gust=2.5, gps=0.25, delay=0.0)}\n", + "OBST_T, LAND_T = 85.0, 100.0\n", + "\n", + "\n", + "def make_pilots(n=40, seed=7):\n", + " rng = np.random.default_rng(seed)\n", + " skill = rng.beta(2.2, 2.0, n)\n", + " return pd.DataFrame({\"pilot\": [f\"P{i + 1:03d}\" for i in range(n)], \"skill\": skill,\n", + " \"jerky\": rng.random(n) < 0.15 + 0.35 * (1 - skill), \"overcorr\": rng.random(n) < 0.15 + 0.35 * (1 - skill)})\n", + "\n", + "\n", + "def target_path(t):\n", + " \"\"\"사각형 경로(40x30m, 고도 20m)를 3m/s로 비행 -> 60초 지점에서 호버링 -> 착륙 지시 후 고도 0.\"\"\"\n", + " s = np.minimum(t, 60.0) * 3.0 % 140.0\n", + " x = np.select([s < 40, s < 70, s < 110], [s, 40.0, 40 - (s - 70)], 0.0)\n", + " y = np.select([s < 40, s < 70, s < 110], [0.0, s - 40, 30.0], 30 - (s - 110))\n", + " z = np.where(t >= LAND_T, 0.0, 20.0)\n", + " return np.stack([x, y, z], -1)\n", + "\n", + "\n", + "def fly(pilots, cond=\"day\", seed=0, delay_extra=0.0, obstacle_side=None):\n", + " \"\"\"여러 조종자의 비행 1회를 동시에 모의한다(벡터화). 반환: 로그 배열 dict, 비행별 정답 정보 DataFrame.\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " c = COND[cond]\n", + " delay_extra = delay_extra + c[\"delay\"]\n", + " n = len(pilots)\n", + " sk, jk, oc = pilots[\"skill\"].values, pilots[\"jerky\"].values, pilots[\"overcorr\"].values\n", + " gain = (0.5 + 1.2 * sk) * np.where(oc, 1.5, 1.0)\n", + " damp = np.where(oc, 0.25, 0.6 + 0.3 * sk)\n", + " noise = 0.03 + 0.35 * (1 - sk)\n", + " smooth = np.where(jk, 0.25, 0.75)\n", + " delay = np.round((0.25 + 0.35 * (1 - sk) + delay_extra) / DT).astype(int)\n", + " rt_obs = np.clip(0.5 + 1.5 * (1 - sk) + rng.normal(0, 0.2, n) + delay_extra, 0.3, 3.5)\n", + " h_flare = np.clip(0.8 + 5.0 * sk - 1.5 * oc + rng.normal(0, 0.4, n), 0.3, None)\n", + " side = rng.choice([-1, 1], n) if obstacle_side is None else np.full(n, obstacle_side)\n", + " pos = np.tile(target_path(np.zeros(1))[0], (n, 1)).astype(float); vel = np.zeros((n, 3)); stick = np.zeros((n, 3))\n", + " hist = np.zeros((STEPS, n, 3)); P = np.zeros((STEPS, n, 3)); S = np.zeros((STEPS, n, 3)); ATT = np.zeros((STEPS, n, 2))\n", + " tgt_all = np.zeros((STEPS, n, 3)); vz_touch = np.full(n, np.nan); landed = np.zeros(n, bool)\n", + " vmax = np.array([5.0, 5.0, 3.0])\n", + " gust = np.zeros((n, 2))\n", + " for i, t in enumerate(T):\n", + " tgt = np.tile(target_path(np.array([t]))[0], (n, 1))\n", + " if t >= OBST_T: # 장애물: 6m 옆으로 회피해야 함(조종자는 반응시간 이후 인지)\n", + " tgt_all[i] = tgt + np.c_[np.zeros(n), side * 6.0, np.zeros(n)]\n", + " tgt[:, 1] += np.where(t >= OBST_T + rt_obs, side * 6.0, 0.0)\n", + " else:\n", + " tgt_all[i] = tgt\n", + " hist[i] = pos\n", + " seen = hist[np.maximum(i - delay, 0), np.arange(n)] # 반응 지연만큼 늦게 본 위치\n", + " cmd = np.clip(gain[:, None] * (tgt - seen) / 4.0 - damp[:, None] * vel / vmax + rng.normal(0, 1, (n, 3)) * noise[:, None], -1, 1)\n", + " if t >= LAND_T: # 비상 착륙: 하강하다가 h_flare 높이에서 감속(숙련자일수록 일찍 감속)\n", + " cmd[:, 2] = np.where(seen[:, 2] > h_flare, -1.0, -0.12 + rng.normal(0, 0.05, n))\n", + " spikes = (jk[:, None] & (rng.random((n, 3)) < 0.02)) * rng.choice([-1, 1], (n, 3)) * 0.9\n", + " stick = np.clip(smooth[:, None] * stick + (1 - smooth[:, None]) * cmd + spikes, -1, 1)\n", + " gust = 0.95 * gust + rng.normal(0, c[\"gust\"] * 0.3, (n, 2))\n", + " wind = np.c_[np.full(n, c[\"wind\"]) + gust[:, 0], gust[:, 1], np.zeros(n)]\n", + " vel += (stick * vmax - vel) * DT / 0.6\n", + " vel[landed] = 0.0\n", + " pos = pos + (vel + wind * (~landed)[:, None]) * DT\n", + " touch = (pos[:, 2] <= 0) & ~landed & (t >= LAND_T)\n", + " vz_touch[touch] = -vel[touch, 2]; landed |= touch\n", + " pos[:, 2] = np.maximum(pos[:, 2], 0.0)\n", + " P[i], S[i] = pos, stick\n", + " ATT[i] = np.c_[stick[:, 1] * 28 + gust[:, 1] * 1.5, -stick[:, 0] * 28 + gust[:, 0] * 1.5]\n", + " g = rng.normal(0, c[\"gps\"], P.shape)\n", + " log = {\"t\": T, \"x\": P[..., 0] + g[..., 0], \"y\": P[..., 1] + g[..., 1], \"alt\": P[..., 2] + g[..., 2] * 0.5,\n", + " \"stick_pitch\": S[..., 0], \"stick_roll\": S[..., 1], \"throttle\": S[..., 2], \"roll_deg\": ATT[..., 0], \"pitch_deg\": ATT[..., 1],\n", + " \"tx\": tgt_all[..., 0], \"ty\": tgt_all[..., 1], \"talt\": tgt_all[..., 2], \"true_x\": P[..., 0], \"true_y\": P[..., 1], \"true_alt\": P[..., 2]}\n", + " k_obs = int((OBST_T + 2.5) / DT)\n", + " lateral = np.abs(P[k_obs, :, 1] - P[int(OBST_T / DT), :, 1])\n", + " truth = pd.DataFrame({\"pilot\": pilots[\"pilot\"].values, \"cond\": cond, \"skill\": sk, \"jerky\": jk, \"overcorr\": oc, \"rt_obs\": rt_obs,\n", + " \"near_collision\": lateral < 2.5, \"vz_touch\": np.round(vz_touch, 2), \"hard_landing\": np.nan_to_num(vz_touch, nan=3.0) > 1.6,\n", + " \"attitude_exceed\": (np.abs(ATT) > 35).any(axis=2).sum(axis=0) >= 10})\n", + " risk = truth[[\"near_collision\", \"hard_landing\", \"attitude_exceed\"]].sum(axis=1)\n", + " inst = 40 + 50 * sk - 5 * jk - 5 * oc - 4 * risk + rng.normal(0, 3, n) # 교관 평가 점수(가상)\n", + " truth[\"instructor\"] = np.clip(inst, 0, 100).round(1)\n", + " truth[\"grade\"] = np.where(truth[\"instructor\"] >= 75, \"A\", np.where(truth[\"instructor\"] >= 55, \"B\", \"C\"))\n", + " return log, truth\n", + "\n", + "\n", + "def one(log, j):\n", + " \"\"\"여러 조종자 로그에서 j번째 조종자 1명의 로그(DataFrame)를 꺼낸다.\"\"\"\n", + " return pd.DataFrame({k: (v if v.ndim == 1 else v[:, j]) for k, v in log.items()})\n", + "# ---------------------------------------------------------------------------\n", + "\n", + "SCHEMA = {\n", + " \"session\": {\n", + " \"session_id\": {\"type\": str, \"pattern\": r\"^S\\d{4}$\"},\n", + " \"pilot\": {\"type\": str, \"pattern\": r\"^P\\d{3}$\"}, # 가명 ID만 허용(실명·군번 금지)\n", + " \"sim_course\": {\"type\": str, \"enum\": [\"RECT40x30_HOVER_OBS_LAND\"]},\n", + " \"rate_hz\": {\"type\": int, \"enum\": [10, 20, 50]},\n", + " \"duration_s\": {\"type\": (int, float), \"min\": 10, \"max\": 1800},\n", + " },\n", + " \"tactical\": {\n", + " \"condition\": {\"type\": str, \"enum\": list(COND)},\n", + " \"wind_mps\": {\"type\": (int, float), \"min\": 0, \"max\": 20},\n", + " \"gps_error_m\": {\"type\": (int, float), \"min\": 0, \"max\": 50},\n", + " \"video_delay_ms\": {\"type\": (int, float), \"min\": 0, \"max\": 2000},\n", + " },\n", + " \"record\": {\n", + " \"t\": {\"type\": (int, float), \"min\": 0},\n", + " \"stick_roll\": {\"type\": (int, float), \"min\": -1, \"max\": 1},\n", + " \"stick_pitch\": {\"type\": (int, float), \"min\": -1, \"max\": 1},\n", + " \"throttle\": {\"type\": (int, float), \"min\": -1, \"max\": 1},\n", + " \"roll_deg\": {\"type\": (int, float), \"min\": -90, \"max\": 90},\n", + " \"pitch_deg\": {\"type\": (int, float), \"min\": -90, \"max\": 90},\n", + " \"alt\": {\"type\": (int, float), \"min\": -1, \"max\": 500},\n", + " \"x\": {\"type\": (int, float)}, \"y\": {\"type\": (int, float)},\n", + " \"tx\": {\"type\": (int, float)}, \"ty\": {\"type\": (int, float)}, \"talt\": {\"type\": (int, float), \"min\": 0, \"max\": 500},\n", + " },\n", + "}\n", + "\n", + "\n", + "def check(name, value, rule):\n", + " if not isinstance(value, rule[\"type\"]) or isinstance(value, bool):\n", + " return [f\"{name}: 자료형 오류({type(value).__name__})\"]\n", + " errs = []\n", + " if \"enum\" in rule and value not in rule[\"enum\"]:\n", + " errs.append(f\"{name}: 허용되지 않은 값 '{value}'\")\n", + " if \"min\" in rule and value < rule[\"min\"]:\n", + " errs.append(f\"{name}: 최소값 {rule['min']} 미만\")\n", + " if \"max\" in rule and value > rule[\"max\"]:\n", + " errs.append(f\"{name}: 최대값 {rule['max']} 초과\")\n", + " if \"pattern\" in rule and not re.match(rule[\"pattern\"], value):\n", + " errs.append(f\"{name}: 형식 불일치 '{value}'\")\n", + " return errs\n", + "\n", + "\n", + "def validate(sess):\n", + " errs = []\n", + " for g in (\"session\", \"tactical\"):\n", + " for k, rule in SCHEMA[g].items():\n", + " errs += [f\"{g}.{k}: 필수 필드 누락\"] if k not in sess.get(g, {}) else check(f\"{g}.{k}\", sess[g][k], rule)\n", + " prev = -1.0\n", + " for i, rec in enumerate(sess.get(\"records\", [])):\n", + " for k, rule in SCHEMA[\"record\"].items():\n", + " errs += [f\"records[{i}].{k}: 필수 필드 누락\"] if k not in rec else check(f\"records[{i}].{k}\", rec[k], rule)\n", + " if isinstance(rec.get(\"t\"), (int, float)) and rec[\"t\"] <= prev:\n", + " errs.append(f\"records[{i}].t: 시각 역행/중복\")\n", + " prev = rec.get(\"t\", prev)\n", + " return errs\n", + "\n", + "\n", + "# [교육용가상자료] 조종자 20명 x 비행 1회 -> 세션 20건(각 세션에서 레코드 30개만 표본 검증)\n", + "pilots = make_pilots(20, seed=7)\n", + "log, truth = fly(pilots, \"day\", seed=1)\n", + "rng = np.random.default_rng(2)\n", + "sessions = []\n", + "for j in range(20):\n", + " d = one(log, j).iloc[::40][list(SCHEMA[\"record\"])].round(3)\n", + " sessions.append({\"session\": {\"session_id\": f\"S{j + 1:04d}\", \"pilot\": pilots[\"pilot\"][j], \"sim_course\": \"RECT40x30_HOVER_OBS_LAND\",\n", + " \"rate_hz\": 10, \"duration_s\": 120.0},\n", + " \"tactical\": {\"condition\": \"day\", \"wind_mps\": float(round(rng.uniform(0, 3), 1)), \"gps_error_m\": 0.25,\n", + " \"video_delay_ms\": int(rng.integers(50, 250))},\n", + " \"records\": [{k: float(v) for k, v in r.items()} for r in d.to_dict(\"records\")]})\n", + "ok = sum(1 for s in sessions if not validate(s))\n", + "print(f\"정상 세션 {len(sessions)}건(레코드 {sum(len(s['records']) for s in sessions)}개) 중 검증 통과 {ok}건, 오류 {len(sessions) - ok}건\")\n", + "\n", + "bad = json.loads(json.dumps(sessions[0]))\n", + "bad[\"session\"][\"pilot\"] = \"KIM-01\" # 실명형 ID 입력\n", + "bad[\"tactical\"][\"condition\"] = \"storm\"\n", + "del bad[\"tactical\"][\"video_delay_ms\"]\n", + "bad[\"records\"][3][\"stick_roll\"] = 1.8\n", + "bad[\"records\"][5][\"t\"] = bad[\"records\"][4][\"t\"]\n", + "print(\"[오류 주입 결과]\")\n", + "for e in validate(bad):\n", + " print(\" -\", e)\n", + "\n", + "rows = []\n", + "for g in SCHEMA:\n", + " for k, r in SCHEMA[g].items():\n", + " t = r[\"type\"].__name__ if isinstance(r[\"type\"], type) else \"number\"\n", + " rows.append((g, k, t, str({x: y for x, y in r.items() if x != \"type\"})[:45]))\n", + "print(\"\\n[데이터 명세 요약]\")\n", + "print(pd.DataFrame(rows, columns=[\"그룹\", \"필드\", \"자료형\", \"규칙\"]).to_string(index=False))\n", + "print(\"\\n비행 단계: 경로 0~60초 / 호버링 60~85초 / 장애물 회피 85초 / 비상 착륙 지시 100초, 10Hz 기록\")\n", + "print(\"개인정보: 조종자는 가명 ID(P001~)로만 저장하고, 실명·군번·소속은 별도 대장에서 교관만 관리한다.\")\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 01 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + "정상 세션 20건(레코드 600개) 중 검증 통과 20건, 오류 0건\n", + "[오류 주입 결과]\n", + " - session.pilot: 형식 불일치 'KIM-01'\n", + " - tactical.condition: 허용되지 않은 값 'storm'\n", + " - tactical.video_delay_ms: 필수 필드 누락\n", + " - records[3].stick_roll: 최대값 1 초과\n", + " - records[5].t: 시각 역행/중복\n", + "\n", + "[데이터 명세 요약]\n", + " 그룹 필드 자료형 규칙\n", + " session session_id str {'pattern': '^S\\\\d{4}$'}\n", + " session pilot str {'pattern': '^P\\\\d{3}$'}\n", + " session sim_course str {'enum': ['RECT40x30_HOVER_OBS_LAND']}\n", + " session rate_hz int {'enum': [10, 20, 50]}\n", + " session duration_s number {'min': 10, 'max': 1800}\n", + "tactical condition str {'enum': ['day', 'night', 'gust']}\n", + "tactical wind_mps number {'min': 0, 'max': 20}\n", + "tactical gps_error_m number {'min': 0, 'max': 50}\n", + "tactical video_delay_ms number {'min': 0, 'max': 2000}\n", + " record t number {'min': 0}\n", + " record stick_roll number {'min': -1, 'max': 1}\n", + " record stick_pitch number {'min': -1, 'max': 1}\n", + " record throttle number {'min': -1, 'max': 1}\n", + " record roll_deg number {'min': -90, 'max': 90}\n", + " record pitch_deg number {'min': -90, 'max': 90}\n", + " record alt number {'min': -1, 'max': 500}\n", + " record x number {}\n", + " record y number {}\n", + " record tx number {}\n", + " record ty number {}\n", + " record talt number {'min': 0, 'max': 500}\n", + "\n", + "비행 단계: 경로 0~60초 / 호버링 60~85초 / 장애물 회피 85초 / 비상 착륙 지시 100초, 10Hz 기록\n", + "개인정보: 조종자는 가명 ID(P001~)로만 저장하고, 실명·군번·소속은 별도 대장에서 교관만 관리한다.\n", + "```\n", + "
\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 02. 시계열 비행 궤적 데이터 정제 및 이상 조작 노이즈 필터링 전처리 구현\n", + "**소요시간**: 약 2.0시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "시계열 비행 궤적 노이즈를 필터링하고 스틱 조작 데이터를 정규화하는 전처리 모듈을 구현한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **시계열 비행로그의 시각 중복·역행, GPS 튐, 센서 단절, 스틱 PWM 원시값을 정리하는 전처리 모듈을 구현한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **10Hz 격자로 시각을 정렬하고 중복을 제거한 뒤, 스틱 PWM(1000~2000us)을 -1~1로 정규화하고 중립 데드밴드를 적용 수행하고 검증하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `src/preprocess/trn01_preprocessor.py 및 전처리 전후 데이터셋` 형태의 결과물을 완성한다.\n", + "\n", + "### 🔍 평가 및 검증 기준 (PASS 조건)\n", + "#### 미션 판정\n", + "\n", + "**평가 구분:** 필수\n", + "\n", + "**핵심 평가영역:** 전술 환경 특유의 노이즈(스푸핑, 반사파, 센서 단절 등) 제거 타당성\n", + "\n", + "#### 미션 PASS 조건\n", + "\n", + "- 정량 기준: 데이터 정제 처리 속도 1,000건/초 이상, 전처리 오류율 0%\n", + "\n", + "\n", + "- 진행 기준: 정제 파이프라인 자동화 및 단위 테스트 케이스 5종 통과\n", + "\n", + "\n", + "- 검증 방법: pytest 기반 전처리 정합성 테스트 및 결측치 보정율 확인\n", + "\n", + "\n", + "#### 채점자 판정 메모\n", + "\n", + "> 단순 코드 실행 여부뿐만 아니라 출력된 수치와 근거 자료가 군 실무 요구사항에 부합하는지 면밀히 확인한다.\n", + "\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "\"\"\"시뮬레이터 원시 비행로그의 시각 중복·역행 정리, 스틱 PWM 값(1000~2000us) 정규화, GPS 튐(이상치) 제거(Hampel 필터), 센서 단절 구간 보간을 수행하는 전처리 모듈을 구현하고, 비행 조건(주간/야간/돌풍)별 임계값을 달리 적용해 처리 속도와 위치 오차 개선을 확인한다.\"\"\"\n", + "import time\n", + "import numpy as np\n", + "import pandas as pd\n", + "import matplotlib.pyplot as plt\n", + "\n", + "# ---------------------------------------------------------------------------\n", + "# [교육용가상자료] 드론 조종 시뮬레이터 비행로그 생성기 (실제 훈련생·장비 정보 없음, 조종자는 가명 P001~)\n", + "# 비행 1회 = 120초(10Hz): 경로 비행(0~60초) -> 호버링(60~85초) -> 돌발 장애물 회피(85초) -> 비상 착륙(100초 지시)\n", + "# 조종자마다 숨은 숙련도(skill)와 조작 습관(급조작 jerky, 과보정 overcorr)이 있고, 교관 점수는 이를 반영해 만든다.\n", + "# ---------------------------------------------------------------------------\n", + "DT, STEPS = 0.1, 1200\n", + "T = np.arange(STEPS) * DT\n", + "COND = {\"day\": dict(wind=0.4, gust=0.0, gps=0.25, delay=0.0), \"night\": dict(wind=0.4, gust=0.0, gps=0.5, delay=0.15),\n", + " \"gust\": dict(wind=1.2, gust=2.5, gps=0.25, delay=0.0)}\n", + "OBST_T, LAND_T = 85.0, 100.0\n", + "\n", + "\n", + "def make_pilots(n=40, seed=7):\n", + " rng = np.random.default_rng(seed)\n", + " skill = rng.beta(2.2, 2.0, n)\n", + " return pd.DataFrame({\"pilot\": [f\"P{i + 1:03d}\" for i in range(n)], \"skill\": skill,\n", + " \"jerky\": rng.random(n) < 0.15 + 0.35 * (1 - skill), \"overcorr\": rng.random(n) < 0.15 + 0.35 * (1 - skill)})\n", + "\n", + "\n", + "def target_path(t):\n", + " \"\"\"사각형 경로(40x30m, 고도 20m)를 3m/s로 비행 -> 60초 지점에서 호버링 -> 착륙 지시 후 고도 0.\"\"\"\n", + " s = np.minimum(t, 60.0) * 3.0 % 140.0\n", + " x = np.select([s < 40, s < 70, s < 110], [s, 40.0, 40 - (s - 70)], 0.0)\n", + " y = np.select([s < 40, s < 70, s < 110], [0.0, s - 40, 30.0], 30 - (s - 110))\n", + " z = np.where(t >= LAND_T, 0.0, 20.0)\n", + " return np.stack([x, y, z], -1)\n", + "\n", + "\n", + "def fly(pilots, cond=\"day\", seed=0, delay_extra=0.0, obstacle_side=None):\n", + " \"\"\"여러 조종자의 비행 1회를 동시에 모의한다(벡터화). 반환: 로그 배열 dict, 비행별 정답 정보 DataFrame.\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " c = COND[cond]\n", + " delay_extra = delay_extra + c[\"delay\"]\n", + " n = len(pilots)\n", + " sk, jk, oc = pilots[\"skill\"].values, pilots[\"jerky\"].values, pilots[\"overcorr\"].values\n", + " gain = (0.5 + 1.2 * sk) * np.where(oc, 1.5, 1.0)\n", + " damp = np.where(oc, 0.25, 0.6 + 0.3 * sk)\n", + " noise = 0.03 + 0.35 * (1 - sk)\n", + " smooth = np.where(jk, 0.25, 0.75)\n", + " delay = np.round((0.25 + 0.35 * (1 - sk) + delay_extra) / DT).astype(int)\n", + " rt_obs = np.clip(0.5 + 1.5 * (1 - sk) + rng.normal(0, 0.2, n) + delay_extra, 0.3, 3.5)\n", + " h_flare = np.clip(0.8 + 5.0 * sk - 1.5 * oc + rng.normal(0, 0.4, n), 0.3, None)\n", + " side = rng.choice([-1, 1], n) if obstacle_side is None else np.full(n, obstacle_side)\n", + " pos = np.tile(target_path(np.zeros(1))[0], (n, 1)).astype(float); vel = np.zeros((n, 3)); stick = np.zeros((n, 3))\n", + " hist = np.zeros((STEPS, n, 3)); P = np.zeros((STEPS, n, 3)); S = np.zeros((STEPS, n, 3)); ATT = np.zeros((STEPS, n, 2))\n", + " tgt_all = np.zeros((STEPS, n, 3)); vz_touch = np.full(n, np.nan); landed = np.zeros(n, bool)\n", + " vmax = np.array([5.0, 5.0, 3.0])\n", + " gust = np.zeros((n, 2))\n", + " for i, t in enumerate(T):\n", + " tgt = np.tile(target_path(np.array([t]))[0], (n, 1))\n", + " if t >= OBST_T: # 장애물: 6m 옆으로 회피해야 함(조종자는 반응시간 이후 인지)\n", + " tgt_all[i] = tgt + np.c_[np.zeros(n), side * 6.0, np.zeros(n)]\n", + " tgt[:, 1] += np.where(t >= OBST_T + rt_obs, side * 6.0, 0.0)\n", + " else:\n", + " tgt_all[i] = tgt\n", + " hist[i] = pos\n", + " seen = hist[np.maximum(i - delay, 0), np.arange(n)] # 반응 지연만큼 늦게 본 위치\n", + " cmd = np.clip(gain[:, None] * (tgt - seen) / 4.0 - damp[:, None] * vel / vmax + rng.normal(0, 1, (n, 3)) * noise[:, None], -1, 1)\n", + " if t >= LAND_T: # 비상 착륙: 하강하다가 h_flare 높이에서 감속(숙련자일수록 일찍 감속)\n", + " cmd[:, 2] = np.where(seen[:, 2] > h_flare, -1.0, -0.12 + rng.normal(0, 0.05, n))\n", + " spikes = (jk[:, None] & (rng.random((n, 3)) < 0.02)) * rng.choice([-1, 1], (n, 3)) * 0.9\n", + " stick = np.clip(smooth[:, None] * stick + (1 - smooth[:, None]) * cmd + spikes, -1, 1)\n", + " gust = 0.95 * gust + rng.normal(0, c[\"gust\"] * 0.3, (n, 2))\n", + " wind = np.c_[np.full(n, c[\"wind\"]) + gust[:, 0], gust[:, 1], np.zeros(n)]\n", + " vel += (stick * vmax - vel) * DT / 0.6\n", + " vel[landed] = 0.0\n", + " pos = pos + (vel + wind * (~landed)[:, None]) * DT\n", + " touch = (pos[:, 2] <= 0) & ~landed & (t >= LAND_T)\n", + " vz_touch[touch] = -vel[touch, 2]; landed |= touch\n", + " pos[:, 2] = np.maximum(pos[:, 2], 0.0)\n", + " P[i], S[i] = pos, stick\n", + " ATT[i] = np.c_[stick[:, 1] * 28 + gust[:, 1] * 1.5, -stick[:, 0] * 28 + gust[:, 0] * 1.5]\n", + " g = rng.normal(0, c[\"gps\"], P.shape)\n", + " log = {\"t\": T, \"x\": P[..., 0] + g[..., 0], \"y\": P[..., 1] + g[..., 1], \"alt\": P[..., 2] + g[..., 2] * 0.5,\n", + " \"stick_pitch\": S[..., 0], \"stick_roll\": S[..., 1], \"throttle\": S[..., 2], \"roll_deg\": ATT[..., 0], \"pitch_deg\": ATT[..., 1],\n", + " \"tx\": tgt_all[..., 0], \"ty\": tgt_all[..., 1], \"talt\": tgt_all[..., 2], \"true_x\": P[..., 0], \"true_y\": P[..., 1], \"true_alt\": P[..., 2]}\n", + " k_obs = int((OBST_T + 2.5) / DT)\n", + " lateral = np.abs(P[k_obs, :, 1] - P[int(OBST_T / DT), :, 1])\n", + " truth = pd.DataFrame({\"pilot\": pilots[\"pilot\"].values, \"cond\": cond, \"skill\": sk, \"jerky\": jk, \"overcorr\": oc, \"rt_obs\": rt_obs,\n", + " \"near_collision\": lateral < 2.5, \"vz_touch\": np.round(vz_touch, 2), \"hard_landing\": np.nan_to_num(vz_touch, nan=3.0) > 1.6,\n", + " \"attitude_exceed\": (np.abs(ATT) > 35).any(axis=2).sum(axis=0) >= 10})\n", + " risk = truth[[\"near_collision\", \"hard_landing\", \"attitude_exceed\"]].sum(axis=1)\n", + " inst = 40 + 50 * sk - 5 * jk - 5 * oc - 4 * risk + rng.normal(0, 3, n) # 교관 평가 점수(가상)\n", + " truth[\"instructor\"] = np.clip(inst, 0, 100).round(1)\n", + " truth[\"grade\"] = np.where(truth[\"instructor\"] >= 75, \"A\", np.where(truth[\"instructor\"] >= 55, \"B\", \"C\"))\n", + " return log, truth\n", + "\n", + "\n", + "def one(log, j):\n", + " \"\"\"여러 조종자 로그에서 j번째 조종자 1명의 로그(DataFrame)를 꺼낸다.\"\"\"\n", + " return pd.DataFrame({k: (v if v.ndim == 1 else v[:, j]) for k, v in log.items()})\n", + "# ---------------------------------------------------------------------------\n", + "\n", + "HAMPEL_K = {\"day\": 3.0, \"night\": 3.5, \"gust\": 4.5} # 조건별 이상치 판정 임계값(돌풍은 실제 큰 움직임이 많아 느슨하게)\n", + "\n", + "\n", + "def make_raw(log, j, cond, rng):\n", + " \"\"\"[교육용가상자료] 원시 로그: PWM 스틱값, GPS 튐 1%, 센서 단절(0.5~2초) 2회, 시각 중복 2%.\"\"\"\n", + " d = one(log, j)\n", + " raw = d[[\"t\", \"x\", \"y\", \"alt\", \"roll_deg\", \"pitch_deg\", \"true_x\", \"true_y\", \"true_alt\"]].copy()\n", + " for s in (\"stick_roll\", \"stick_pitch\", \"throttle\"):\n", + " raw[s + \"_pwm\"] = (1500 + 500 * d[s] + rng.normal(0, 4, len(d))).round().astype(int)\n", + " k = rng.random(len(raw)) < 0.01\n", + " for c in (\"x\", \"y\"):\n", + " raw.loc[k, c] += rng.choice([-1, 1], k.sum()) * rng.uniform(6, 15, k.sum())\n", + " for _ in range(2):\n", + " a = int(rng.integers(50, len(raw) - 30)); raw.loc[a:a + int(rng.integers(5, 20)), [\"x\", \"y\", \"alt\"]] = np.nan\n", + " dup = raw.sample(frac=0.02, random_state=int(rng.integers(1e6)))\n", + " raw = pd.concat([raw, dup]).sample(frac=1.0, random_state=1).reset_index(drop=True) # 순서 섞임 + 중복\n", + " raw[\"t\"] = raw[\"t\"] + rng.normal(0, 0.003, len(raw)) # 시각 흔들림\n", + " return raw\n", + "\n", + "\n", + "def pwm_to_stick(p, dead=8):\n", + " v = (np.asarray(p, float) - 1500) / 500\n", + " v[np.abs(np.asarray(p) - 1500) < dead] = 0.0 # 중립 부근 데드밴드\n", + " return np.clip(v, -1, 1)\n", + "\n", + "\n", + "def hampel(x, win=7, k=3.0):\n", + " s = pd.Series(x)\n", + " med = s.rolling(win, center=True, min_periods=1).median()\n", + " mad = (s - med).abs().rolling(win, center=True, min_periods=1).median() * 1.4826\n", + " out = (s - med).abs() > k * (mad + 0.3) # 0.3m: GPS 잡음 수준 이하 흔들림은 이상치로 보지 않음\n", + " return np.where(out, np.nan, s), int(out.sum())\n", + "\n", + "\n", + "def preprocess(raw, cond):\n", + " d = raw.copy()\n", + " d[\"k\"] = (d[\"t\"] / DT).round().astype(int) # 10Hz 격자 번호로 시각 정렬\n", + " d = d.sort_values(\"k\").drop_duplicates(\"k\").set_index(\"k\")\n", + " d = d.reindex(np.arange(0, d.index.max() + 1))\n", + " d[\"t\"] = np.round(d.index * DT, 1)\n", + " n_missing = int(d[[\"x\", \"y\", \"alt\"]].isna().any(axis=1).sum())\n", + " n_out = 0\n", + " for c in (\"x\", \"y\", \"alt\"):\n", + " d[c], n = hampel(d[c].values, k=HAMPEL_K[cond]); n_out += n\n", + " d[[\"x\", \"y\", \"alt\"]] = d[[\"x\", \"y\", \"alt\"]].interpolate(limit_direction=\"both\")\n", + " for s in (\"stick_roll\", \"stick_pitch\", \"throttle\"):\n", + " d[s] = pwm_to_stick(d[s + \"_pwm\"].interpolate(limit_direction=\"both\").round())\n", + " return d.reset_index(drop=True), n_missing, n_out\n", + "\n", + "\n", + "pilots = make_pilots(30, seed=7)\n", + "rng = np.random.default_rng(3)\n", + "rows, n_rec, t_total = [], 0, 0.0\n", + "for cond in COND:\n", + " log, _ = fly(pilots, cond, seed=1)\n", + " for j in range(len(pilots)):\n", + " raw = make_raw(log, j, cond, rng)\n", + " t0 = time.perf_counter()\n", + " clean, miss, out = preprocess(raw, cond)\n", + " t_total += time.perf_counter() - t0; n_rec += len(raw)\n", + " e0 = np.sqrt((raw[\"x\"] - raw[\"true_x\"]) ** 2 + (raw[\"y\"] - raw[\"true_y\"]) ** 2).mean()\n", + " e1 = np.sqrt((clean[\"x\"] - clean[\"true_x\"]) ** 2 + (clean[\"y\"] - clean[\"true_y\"]) ** 2).mean()\n", + " rows.append({\"조건\": cond, \"결측행\": miss, \"이상치\": out, \"남은결측\": int(clean[[\"x\", \"y\", \"alt\"]].isna().sum().sum()),\n", + " \"위치오차 전(m)\": e0, \"위치오차 후(m)\": e1})\n", + "df = pd.DataFrame(rows)\n", + "print(f\"원시 레코드 {n_rec:,}개(로그 {len(df)}개) 처리, {n_rec / t_total:,.0f} 레코드/초 (목표 1,000건/초 이상), 처리 중 오류 0건\")\n", + "print(f\"결측 보정율 {1 - df['남은결측'].sum() / max(df['결측행'].sum(), 1):.0%} (결측행 {df['결측행'].sum()}개 모두 보간)\")\n", + "print(\"\\n[조건별 전처리 효과(로그 평균)]\")\n", + "print(df.groupby(\"조건\")[[\"결측행\", \"이상치\", \"위치오차 전(m)\", \"위치오차 후(m)\"]].mean().round(2).to_string())\n", + "print(\"조건별 Hampel 임계값:\", HAMPEL_K, \"-> 돌풍 조건은 실제 급기동을 이상치로 지우지 않도록 느슨하게 둔다\")\n", + "\n", + "# 단위 테스트 5종\n", + "raw = make_raw(fly(pilots.iloc[:1], \"day\", seed=9)[0], 0, \"day\", np.random.default_rng(0))\n", + "clean, _, _ = preprocess(raw, \"day\")\n", + "spike = np.r_[np.zeros(10), 50.0, np.zeros(10)]\n", + "tests = {\n", + " \"PWM 1000/1500/2000 -> -1/0/1\": np.allclose(pwm_to_stick([1000, 1500, 2000]), [-1, 0, 1]),\n", + " \"데드밴드(1504 -> 0)\": pwm_to_stick([1504])[0] == 0.0,\n", + " \"단일 튐 값 제거\": np.isnan(hampel(spike)[0][10]),\n", + " \"시각 중복 제거·단조 증가\": clean[\"t\"].is_monotonic_increasing and clean[\"t\"].duplicated().sum() == 0,\n", + " \"결측 없음 + 10Hz 1,200행\": clean[[\"x\", \"y\", \"alt\"]].isna().sum().sum() == 0 and len(clean) == STEPS,\n", + "}\n", + "for k, v in tests.items():\n", + " print(f\"[{'PASS' if v else 'FAIL'}] {k}\")\n", + "print(f\"단위 테스트 {sum(tests.values())}/{len(tests)} 통과\")\n", + "\n", + "seg = slice(400, 700)\n", + "r2 = raw.sort_values(\"t\").iloc[seg]\n", + "plt.figure(figsize=(9, 3.5))\n", + "plt.plot(r2[\"t\"], r2[\"y\"], \".\", ms=3, c=\"gray\", label=\"raw (spikes, gaps)\")\n", + "plt.plot(clean[\"t\"].iloc[seg], clean[\"y\"].iloc[seg], c=\"tab:blue\", label=\"preprocessed\")\n", + "plt.plot(clean[\"t\"].iloc[seg], clean[\"true_y\"].iloc[seg], \"k:\", label=\"true\")\n", + "plt.xlabel(\"time (s)\"); plt.ylabel(\"y (m)\"); plt.legend(); plt.title(\"Flight log preprocessing (synthetic)\")\n", + "plt.tight_layout(); plt.show()\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 02 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + "원시 레코드 110,160개(로그 90개) 처리, 94,969 레코드/초 (목표 1,000건/초 이상), 처리 중 오류 0건\n", + "결측 보정율 100% (결측행 2348개 모두 보간)\n", + "\n", + "[조건별 전처리 효과(로그 평균)]\n", + " 결측행 이상치 위치오차 전(m) 위치오차 후(m)\n", + "조건 \n", + "day 25.37 24.63 0.47 0.31\n", + "gust 26.17 21.87 0.45 0.33\n", + "night 26.73 27.90 0.78 0.62\n", + "조건별 Hampel 임계값: {'day': 3.0, 'night': 3.5, 'gust': 4.5} -> 돌풍 조건은 실제 급기동을 이상치로 지우지 않도록 느슨하게 둔다\n", + "[PASS] PWM 1000/1500/2000 -> -1/0/1\n", + "[PASS] 데드밴드(1504 -> 0)\n", + "[PASS] 단일 튐 값 제거\n", + "[PASS] 시각 중복 제거·단조 증가\n", + "[PASS] 결측 없음 + 10Hz 1,200행\n", + "단위 테스트 5/5 통과\n", + "```\n", + "
\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 03. 비행 경로 이탈도 및 호버링 안정성 계산 1차 숙련도 채점 Baseline 알고리즘 구현\n", + "**소요시간**: 약 2.5시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "비행 경로 이탈도 및 호버링 안정성 기반 1차 숙련도 채점 Baseline 알고리즘을 개발한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **비행 경로 이탈도와 호버링 안정성, 고도 유지 오차로 1차 숙련도 점수와 등급(A/B/C)을 계산하는 Baseline 채점 알고리즘을 개발한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **경로 RMSE, 호버링 평균 이탈·흔들림, 고도 오차를 조건별로 표준화하고 기준 집단으로 가중치를 1차 튜닝해 0~100점 채점식 작성하여 원인을 분석하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `src/models/trn01_baseline_model.py 및 기본 추론 결과 보고서` 형태의 결과물을 완성한다.\n", + "\n", + "### 🔍 평가 및 검증 기준 (PASS 조건)\n", + "#### 미션 판정\n", + "\n", + "**평가 구분:** 필수\n", + "\n", + "**핵심 평가영역:** 알고리즘의 논리적 흐름 및 군사적 판단 기준의 투명성\n", + "\n", + "#### 미션 PASS 조건\n", + "\n", + "- 정량 기준: 기본 데이터셋 대상 탐지/처리 정확도 75% 이상 달성\n", + "\n", + "\n", + "- 진행 기준: 단위 기능 동작 확인 및 기본 시나리오 실행 로그 생성\n", + "\n", + "\n", + "- 검증 방법: 기준 벤치마크 데이터셋 대상 추론 결과 오차율 비교\n", + "\n", + "\n", + "#### 채점자 판정 메모\n", + "\n", + "> 단순 코드 실행 여부뿐만 아니라 출력된 수치와 근거 자료가 군 실무 요구사항에 부합하는지 면밀히 확인한다.\n", + "\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "\"\"\"비행 경로 이탈도(경로 RMSE)와 호버링 안정성(평균 이탈·흔들림), 고도 유지 오차로 1차 숙련도 점수(0~100)와 등급(A/B/C)을 계산하는 Baseline 채점 알고리즘을 구현하고, 교관 평가 등급과의 일치율(처리 정확도)과 판단 지연을 측정한다.\"\"\"\n", + "import time\n", + "import numpy as np\n", + "import pandas as pd\n", + "import matplotlib.pyplot as plt\n", + "from sklearn.linear_model import LinearRegression\n", + "\n", + "# ---------------------------------------------------------------------------\n", + "# [교육용가상자료] 드론 조종 시뮬레이터 비행로그 생성기 (실제 훈련생·장비 정보 없음, 조종자는 가명 P001~)\n", + "# 비행 1회 = 120초(10Hz): 경로 비행(0~60초) -> 호버링(60~85초) -> 돌발 장애물 회피(85초) -> 비상 착륙(100초 지시)\n", + "# 조종자마다 숨은 숙련도(skill)와 조작 습관(급조작 jerky, 과보정 overcorr)이 있고, 교관 점수는 이를 반영해 만든다.\n", + "# ---------------------------------------------------------------------------\n", + "DT, STEPS = 0.1, 1200\n", + "T = np.arange(STEPS) * DT\n", + "COND = {\"day\": dict(wind=0.4, gust=0.0, gps=0.25, delay=0.0), \"night\": dict(wind=0.4, gust=0.0, gps=0.5, delay=0.15),\n", + " \"gust\": dict(wind=1.2, gust=2.5, gps=0.25, delay=0.0)}\n", + "OBST_T, LAND_T = 85.0, 100.0\n", + "\n", + "\n", + "def make_pilots(n=40, seed=7):\n", + " rng = np.random.default_rng(seed)\n", + " skill = rng.beta(2.2, 2.0, n)\n", + " return pd.DataFrame({\"pilot\": [f\"P{i + 1:03d}\" for i in range(n)], \"skill\": skill,\n", + " \"jerky\": rng.random(n) < 0.15 + 0.35 * (1 - skill), \"overcorr\": rng.random(n) < 0.15 + 0.35 * (1 - skill)})\n", + "\n", + "\n", + "def target_path(t):\n", + " \"\"\"사각형 경로(40x30m, 고도 20m)를 3m/s로 비행 -> 60초 지점에서 호버링 -> 착륙 지시 후 고도 0.\"\"\"\n", + " s = np.minimum(t, 60.0) * 3.0 % 140.0\n", + " x = np.select([s < 40, s < 70, s < 110], [s, 40.0, 40 - (s - 70)], 0.0)\n", + " y = np.select([s < 40, s < 70, s < 110], [0.0, s - 40, 30.0], 30 - (s - 110))\n", + " z = np.where(t >= LAND_T, 0.0, 20.0)\n", + " return np.stack([x, y, z], -1)\n", + "\n", + "\n", + "def fly(pilots, cond=\"day\", seed=0, delay_extra=0.0, obstacle_side=None):\n", + " \"\"\"여러 조종자의 비행 1회를 동시에 모의한다(벡터화). 반환: 로그 배열 dict, 비행별 정답 정보 DataFrame.\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " c = COND[cond]\n", + " delay_extra = delay_extra + c[\"delay\"]\n", + " n = len(pilots)\n", + " sk, jk, oc = pilots[\"skill\"].values, pilots[\"jerky\"].values, pilots[\"overcorr\"].values\n", + " gain = (0.5 + 1.2 * sk) * np.where(oc, 1.5, 1.0)\n", + " damp = np.where(oc, 0.25, 0.6 + 0.3 * sk)\n", + " noise = 0.03 + 0.35 * (1 - sk)\n", + " smooth = np.where(jk, 0.25, 0.75)\n", + " delay = np.round((0.25 + 0.35 * (1 - sk) + delay_extra) / DT).astype(int)\n", + " rt_obs = np.clip(0.5 + 1.5 * (1 - sk) + rng.normal(0, 0.2, n) + delay_extra, 0.3, 3.5)\n", + " h_flare = np.clip(0.8 + 5.0 * sk - 1.5 * oc + rng.normal(0, 0.4, n), 0.3, None)\n", + " side = rng.choice([-1, 1], n) if obstacle_side is None else np.full(n, obstacle_side)\n", + " pos = np.tile(target_path(np.zeros(1))[0], (n, 1)).astype(float); vel = np.zeros((n, 3)); stick = np.zeros((n, 3))\n", + " hist = np.zeros((STEPS, n, 3)); P = np.zeros((STEPS, n, 3)); S = np.zeros((STEPS, n, 3)); ATT = np.zeros((STEPS, n, 2))\n", + " tgt_all = np.zeros((STEPS, n, 3)); vz_touch = np.full(n, np.nan); landed = np.zeros(n, bool)\n", + " vmax = np.array([5.0, 5.0, 3.0])\n", + " gust = np.zeros((n, 2))\n", + " for i, t in enumerate(T):\n", + " tgt = np.tile(target_path(np.array([t]))[0], (n, 1))\n", + " if t >= OBST_T: # 장애물: 6m 옆으로 회피해야 함(조종자는 반응시간 이후 인지)\n", + " tgt_all[i] = tgt + np.c_[np.zeros(n), side * 6.0, np.zeros(n)]\n", + " tgt[:, 1] += np.where(t >= OBST_T + rt_obs, side * 6.0, 0.0)\n", + " else:\n", + " tgt_all[i] = tgt\n", + " hist[i] = pos\n", + " seen = hist[np.maximum(i - delay, 0), np.arange(n)] # 반응 지연만큼 늦게 본 위치\n", + " cmd = np.clip(gain[:, None] * (tgt - seen) / 4.0 - damp[:, None] * vel / vmax + rng.normal(0, 1, (n, 3)) * noise[:, None], -1, 1)\n", + " if t >= LAND_T: # 비상 착륙: 하강하다가 h_flare 높이에서 감속(숙련자일수록 일찍 감속)\n", + " cmd[:, 2] = np.where(seen[:, 2] > h_flare, -1.0, -0.12 + rng.normal(0, 0.05, n))\n", + " spikes = (jk[:, None] & (rng.random((n, 3)) < 0.02)) * rng.choice([-1, 1], (n, 3)) * 0.9\n", + " stick = np.clip(smooth[:, None] * stick + (1 - smooth[:, None]) * cmd + spikes, -1, 1)\n", + " gust = 0.95 * gust + rng.normal(0, c[\"gust\"] * 0.3, (n, 2))\n", + " wind = np.c_[np.full(n, c[\"wind\"]) + gust[:, 0], gust[:, 1], np.zeros(n)]\n", + " vel += (stick * vmax - vel) * DT / 0.6\n", + " vel[landed] = 0.0\n", + " pos = pos + (vel + wind * (~landed)[:, None]) * DT\n", + " touch = (pos[:, 2] <= 0) & ~landed & (t >= LAND_T)\n", + " vz_touch[touch] = -vel[touch, 2]; landed |= touch\n", + " pos[:, 2] = np.maximum(pos[:, 2], 0.0)\n", + " P[i], S[i] = pos, stick\n", + " ATT[i] = np.c_[stick[:, 1] * 28 + gust[:, 1] * 1.5, -stick[:, 0] * 28 + gust[:, 0] * 1.5]\n", + " g = rng.normal(0, c[\"gps\"], P.shape)\n", + " log = {\"t\": T, \"x\": P[..., 0] + g[..., 0], \"y\": P[..., 1] + g[..., 1], \"alt\": P[..., 2] + g[..., 2] * 0.5,\n", + " \"stick_pitch\": S[..., 0], \"stick_roll\": S[..., 1], \"throttle\": S[..., 2], \"roll_deg\": ATT[..., 0], \"pitch_deg\": ATT[..., 1],\n", + " \"tx\": tgt_all[..., 0], \"ty\": tgt_all[..., 1], \"talt\": tgt_all[..., 2], \"true_x\": P[..., 0], \"true_y\": P[..., 1], \"true_alt\": P[..., 2]}\n", + " k_obs = int((OBST_T + 2.5) / DT)\n", + " lateral = np.abs(P[k_obs, :, 1] - P[int(OBST_T / DT), :, 1])\n", + " truth = pd.DataFrame({\"pilot\": pilots[\"pilot\"].values, \"cond\": cond, \"skill\": sk, \"jerky\": jk, \"overcorr\": oc, \"rt_obs\": rt_obs,\n", + " \"near_collision\": lateral < 2.5, \"vz_touch\": np.round(vz_touch, 2), \"hard_landing\": np.nan_to_num(vz_touch, nan=3.0) > 1.6,\n", + " \"attitude_exceed\": (np.abs(ATT) > 35).any(axis=2).sum(axis=0) >= 10})\n", + " risk = truth[[\"near_collision\", \"hard_landing\", \"attitude_exceed\"]].sum(axis=1)\n", + " inst = 40 + 50 * sk - 5 * jk - 5 * oc - 4 * risk + rng.normal(0, 3, n) # 교관 평가 점수(가상)\n", + " truth[\"instructor\"] = np.clip(inst, 0, 100).round(1)\n", + " truth[\"grade\"] = np.where(truth[\"instructor\"] >= 75, \"A\", np.where(truth[\"instructor\"] >= 55, \"B\", \"C\"))\n", + " return log, truth\n", + "\n", + "\n", + "def one(log, j):\n", + " \"\"\"여러 조종자 로그에서 j번째 조종자 1명의 로그(DataFrame)를 꺼낸다.\"\"\"\n", + " return pd.DataFrame({k: (v if v.ndim == 1 else v[:, j]) for k, v in log.items()})\n", + "# ---------------------------------------------------------------------------\n", + "\n", + "# ---- 비행로그 특징 추출 공통 함수(여러 조종자 로그를 한 번에 계산) ----\n", + "from scipy.ndimage import uniform_filter1d\n", + "\n", + "BASE_F = [\"path_rmse\", \"hover_drift\", \"hover_std\", \"alt_err\"]\n", + "ADV_F = BASE_F + [\"obs_rt\", \"obs_lateral\", \"touch_vz\", \"att_exceed\", \"jerk\", \"overshoot\", \"n_risk\"]\n", + "RULES = {\"near_collision\": (\"obs_lateral\", \"<\", 2.5), \"hard_landing\": (\"touch_vz\", \">\", 2.0), \"attitude_exceed\": (\"att_exceed\", \">=\", 10)}\n", + "\n", + "\n", + "def detect(df):\n", + " \"\"\"위험 조작 사건 탐지 규칙: 장애물 2.5초 후 회피 거리 2.5m 미만, 접지 속도 2.0m/s 초과, 자세 35도 초과 1초 이상.\"\"\"\n", + " out = {}\n", + " for ev, (f, op, th) in RULES.items():\n", + " out[ev] = df[f] < th if op == \"<\" else (df[f] > th if op == \">\" else df[f] >= th)\n", + " return pd.DataFrame(out)\n", + "\n", + "\n", + "def features(log):\n", + " \"\"\"경로 이탈도·호버링 안정성(기본) + 장애물 반응·착륙 속도·자세 한계·급조작·과보정(고도화) 특징.\"\"\"\n", + " X, Y, A = (uniform_filter1d(log[k], 9, axis=0) for k in (\"x\", \"y\", \"alt\")) # GPS 잡음 완화(이동평균 0.9초)\n", + " h = np.hypot(X - log[\"tx\"], Y - log[\"ty\"])\n", + " cr, hv = slice(0, 600), slice(620, 850)\n", + " k0, k1 = int(OBST_T / DT), int((OBST_T + 2.5) / DT)\n", + " side = np.sign(log[\"ty\"][k0 + 1] - log[\"ty\"][k0 - 1])\n", + " moved = (log[\"stick_roll\"][k0:k0 + 40] * side) > 0.3\n", + " rt = np.where(moved.any(0), moved.argmax(0) * DT, 4.0)\n", + " vz = -np.gradient(A, DT, axis=0)\n", + " land = np.argmax((A < 0.4) & (T[:, None] >= LAND_T), axis=0)\n", + " touch = np.array([vz[max(i - 6, 0):i - 2, j].mean() if i > 6 else 3.0 for j, i in enumerate(land)])\n", + " ds = np.abs(np.diff(np.stack([log[\"stick_roll\"], log[\"stick_pitch\"]]), axis=1)) / DT\n", + " return pd.DataFrame({\n", + " \"path_rmse\": np.sqrt((h[cr] ** 2).mean(0)), \"hover_drift\": h[hv].mean(0), \"hover_std\": np.hypot(X[hv].std(0), Y[hv].std(0)),\n", + " \"alt_err\": np.abs(A[:850] - log[\"talt\"][:850]).mean(0),\n", + " \"obs_rt\": rt, \"obs_lateral\": np.abs(Y[k1] - Y[k0]), \"touch_vz\": touch,\n", + " \"att_exceed\": (np.maximum(np.abs(log[\"roll_deg\"]), np.abs(log[\"pitch_deg\"])) > 35).sum(0),\n", + " \"jerk\": np.percentile(ds, 95, axis=(0, 1)),\n", + " \"overshoot\": np.clip(np.abs(Y[k0:k0 + 120] - Y[k0]).max(0) - 6.0, 0, None)}).pipe(\n", + " lambda d: d.assign(n_risk=detect(d).sum(axis=1))) # overshoot: 회피 후 목표 6m를 넘어선 거리(과보정)\n", + "\n", + "\n", + "def make_dataset(n_pilots=40, seed=7, conds=(\"day\", \"night\", \"gust\"), flights=1):\n", + " \"\"\"조종자 n명 x 조건 x 반복 비행 -> 비행별 특징 + 정답(교관 점수·등급·위험 사건) 표.\"\"\"\n", + " pilots = make_pilots(n_pilots, seed)\n", + " out = []\n", + " for c in conds:\n", + " for f in range(flights):\n", + " log, tr = fly(pilots, c, seed=seed * 100 + list(COND).index(c) * 10 + f)\n", + " out.append(pd.concat([tr.reset_index(drop=True), features(log)], axis=1).assign(flight=f))\n", + " return pd.concat(out, ignore_index=True)\n", + "\n", + "\n", + "def cond_z(df, ref, cols):\n", + " \"\"\"조건별 기준 집단(ref)의 평균·표준편차로 표준화: 돌풍처럼 어려운 조건에서 받는 불이익을 줄인다.\"\"\"\n", + " z = df.copy()\n", + " z[cols] = z[cols].astype(float)\n", + " for c in df[\"cond\"].unique():\n", + " r = ref[ref[\"cond\"] == c][cols]\n", + " z.loc[df[\"cond\"] == c, cols] = (df.loc[df[\"cond\"] == c, cols] - r.mean()) / (r.std() + 1e-9)\n", + " return z\n", + "\n", + "\n", + "def to_grade(score):\n", + " return np.where(score >= 75, \"A\", np.where(score >= 55, \"B\", \"C\"))\n", + "\n", + "# [교육용가상자료] 기준(튜닝)용 조종자 40명, 평가용 조종자 40명 x 조건 3종(주간·야간·돌풍)\n", + "train = make_dataset(40, seed=7)\n", + "test = make_dataset(40, seed=8)\n", + "\n", + "# 1) 휴리스틱 가중치 1차 튜닝: 조건별 표준화 특징 -> 교관 점수 선형 근사\n", + "ztr, zte = cond_z(train, train, BASE_F), cond_z(test, train, BASE_F)\n", + "lr = LinearRegression().fit(ztr[BASE_F], train[\"instructor\"])\n", + "print(\"채점식: 점수 = {:.1f}\".format(lr.intercept_) + \"\".join(f\" {w:+.1f}x{f}\" for f, w in zip(BASE_F, lr.coef_)) + \" (특징은 조건별 표준화 값)\")\n", + "\n", + "t0 = time.perf_counter()\n", + "test[\"base_score\"] = np.clip(lr.predict(zte[BASE_F]), 0, 100)\n", + "test[\"base_grade\"] = to_grade(test[\"base_score\"])\n", + "ms = (time.perf_counter() - t0) * 1000 / len(test)\n", + "acc = (test[\"base_grade\"] == test[\"grade\"]).mean()\n", + "print(f\"평가 비행 {len(test)}회: 등급 일치율(처리 정확도) {acc:.1%} (목표 75% 이상: {'달성' if acc >= 0.75 else '미달'}), \"\n", + " f\"점수 평균 오차 {np.abs(test['base_score'] - test['instructor']).mean():.1f}점, 채점 지연 {ms:.3f} ms/비행\")\n", + "\n", + "# 2) 조건별 정확도 + 조건 표준화를 하지 않았을 때 비교\n", + "raw = LinearRegression().fit(train[BASE_F], train[\"instructor\"])\n", + "test[\"raw_grade\"] = to_grade(raw.predict(test[BASE_F]))\n", + "print(\"\\n조건 표준화 O 표준화 X\")\n", + "for c in COND:\n", + " s = test[test[\"cond\"] == c]\n", + " print(f\"{c:6s} {(s['base_grade'] == s['grade']).mean():8.1%} {(s['raw_grade'] == s['grade']).mean():8.1%}\")\n", + "print(f\"전체 {acc:8.1%} {(test['raw_grade'] == test['grade']).mean():8.1%}\")\n", + "gust_raw = raw.predict(test[test[\"cond\"] == \"gust\"][BASE_F]).mean() - raw.predict(test[test[\"cond\"] == \"day\"][BASE_F]).mean()\n", + "print(f\"-> 표준화하지 않으면 돌풍 비행 점수가 주간보다 평균 {gust_raw:+.1f}점 달라진다(조건 효과가 점수에 섞임). 표준화는 같은 조건 동료 대비 비교다.\")\n", + "\n", + "# 3) 혼동표와 한계 분석\n", + "print(\"\\n[교관 등급 x Baseline 등급]\")\n", + "print(pd.crosstab(test[\"grade\"].rename(\"교관\"), test[\"base_grade\"].rename(\"Baseline\")).to_string())\n", + "over = test[(test[\"grade\"] == \"C\") & (test[\"base_grade\"] != \"C\")]\n", + "ev = [\"near_collision\", \"hard_landing\"]\n", + "print(f\"\\n가장 많은 오류: 교관 C -> Baseline B {len(over)}건. 교관 C 비행의 {test[test['grade'] == 'C'][ev].any(axis=1).mean():.0%}가\"\n", + " f\" 장애물 근접·경착륙을 포함하지만 Baseline 특징(경로·호버링·고도)에는 이 정보가 없다.\")\n", + "print(\"-> 경로·호버링만 보는 Baseline은 돌발 상황 대응(장애물 회피, 비상 착륙)을 반영하지 못한다. 미션 4에서 반응 특징을 추가한다.\")\n", + "\n", + "plt.figure(figsize=(6, 5))\n", + "for c, col in zip(COND, [\"tab:blue\", \"tab:purple\", \"tab:orange\"]):\n", + " s = test[test[\"cond\"] == c]\n", + " plt.scatter(s[\"instructor\"], s[\"base_score\"], s=15, c=col, label=c)\n", + "plt.plot([20, 100], [20, 100], \"k--\"); plt.axhline(75, c=\"gray\", lw=0.5); plt.axhline(55, c=\"gray\", lw=0.5)\n", + "plt.xlabel(\"instructor score\"); plt.ylabel(\"baseline AI score\"); plt.legend(); plt.title(\"Baseline skill score vs instructor (synthetic)\")\n", + "plt.tight_layout(); plt.show()\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 03 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + "채점식: 점수 = 59.3 -0.4xpath_rmse -0.8xhover_drift +0.2xhover_std -13.0xalt_err (특징은 조건별 표준화 값)\n", + "평가 비행 120회: 등급 일치율(처리 정확도) 75.8% (목표 75% 이상: 달성), 점수 평균 오차 5.3점, 채점 지연 0.025 ms/비행\n", + "\n", + "조건 표준화 O 표준화 X\n", + "day 70.0% 75.0%\n", + "night 80.0% 70.0%\n", + "gust 77.5% 82.5%\n", + "전체 75.8% 75.8%\n", + "-> 표준화하지 않으면 돌풍 비행 점수가 주간보다 평균 -4.0점 달라진다(조건 효과가 점수에 섞임). 표준화는 같은 조건 동료 대비 비교다.\n", + "\n", + "[교관 등급 x Baseline 등급]\n", + "Baseline A B C\n", + "교관 \n", + "A 15 6 0\n", + "B 3 26 4\n", + "C 0 16 50\n", + "\n", + "가장 많은 오류: 교관 C -> Baseline B 16건. 교관 C 비행의 95%가 장애물 근접·경착륙을 포함하지만 Baseline 특징(경로·호버링·고도)에는 이 정보가 없다.\n", + "-> 경로·호버링만 보는 Baseline은 돌발 상황 대응(장애물 회피, 비상 착륙)을 반영하지 못한다. 미션 4에서 반응 특징을 추가한다.\n", + "```\n", + "
\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 04. 돌발 장애물 회피 및 비상 착륙 반응 속도 기반 정밀 숙련도 평가 엔진 고도화\n", + "**소요시간**: 약 3.0시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "돌발 장애물 회피 및 비상 착륙 반응 속도를 반영한 정밀 숙련도 평가 엔진을 고도화한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **돌발 장애물 회피 반응과 비상 착륙 접지 속도, 자세 한계, 급조작·과보정 특징을 반영한 정밀 숙련도 평가 엔진으로 고도화한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **장애물 출현 후 스틱 반응시간과 2.5초 후 회피 거리, 접지 직전 하강 속도, 자세 35도 초과 시간, 스틱 변화율, 회피 후 초과 이동을 특징으로 추가하고 위험 조작 사건(장애물 근접·경착륙·자세 한계) 탐지 규칙 작성하여 원인을 분석하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `src/models/trn01_advanced_detector.py 및 예외 탐지 테스트 결과` 형태의 결과물을 완성한다.\n", + "\n", + "### 🔍 평가 및 검증 기준 (PASS 조건)\n", + "#### 미션 판정\n", + "\n", + "**평가 구분:** 필수\n", + "\n", + "**핵심 평가영역:** 기만/교란 공격에 대한 방어 로직의 현실성 및 신뢰성\n", + "\n", + "#### 미션 PASS 조건\n", + "\n", + "- 정량 기준: 복합 위협 탐지 재현율(Recall) 85% 이상, 처리 지연 200ms 이하\n", + "\n", + "\n", + "- 진행 기준: 복합 위협 시나리오 10종에 대한 탐지 테스트 통과\n", + "\n", + "\n", + "- 검증 방법: 합성 위협 데이터셋 주입 시 이상치 탐지 정확도 검증\n", + "\n", + "\n", + "#### 채점자 판정 메모\n", + "\n", + "> 단순 코드 실행 여부뿐만 아니라 출력된 수치와 근거 자료가 군 실무 요구사항에 부합하는지 면밀히 확인한다.\n", + "\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "\"\"\"돌발 장애물 회피 반응시간·회피 거리, 비상 착륙 접지 속도, 자세 한계 초과, 급조작·과보정 특징을 추가한 정밀 숙련도 평가 엔진으로 고도화하고, 복합 시나리오 10종에서 위험 조작 사건(장애물 근접·경착륙·자세 한계) 탐지 재현율, 등급 일치율, 로그당 처리 지연을 검증한다.\"\"\"\n", + "import time\n", + "import numpy as np\n", + "import pandas as pd\n", + "import matplotlib.pyplot as plt\n", + "from sklearn.linear_model import LinearRegression, Ridge\n", + "\n", + "# ---------------------------------------------------------------------------\n", + "# [교육용가상자료] 드론 조종 시뮬레이터 비행로그 생성기 (실제 훈련생·장비 정보 없음, 조종자는 가명 P001~)\n", + "# 비행 1회 = 120초(10Hz): 경로 비행(0~60초) -> 호버링(60~85초) -> 돌발 장애물 회피(85초) -> 비상 착륙(100초 지시)\n", + "# 조종자마다 숨은 숙련도(skill)와 조작 습관(급조작 jerky, 과보정 overcorr)이 있고, 교관 점수는 이를 반영해 만든다.\n", + "# ---------------------------------------------------------------------------\n", + "DT, STEPS = 0.1, 1200\n", + "T = np.arange(STEPS) * DT\n", + "COND = {\"day\": dict(wind=0.4, gust=0.0, gps=0.25, delay=0.0), \"night\": dict(wind=0.4, gust=0.0, gps=0.5, delay=0.15),\n", + " \"gust\": dict(wind=1.2, gust=2.5, gps=0.25, delay=0.0)}\n", + "OBST_T, LAND_T = 85.0, 100.0\n", + "\n", + "\n", + "def make_pilots(n=40, seed=7):\n", + " rng = np.random.default_rng(seed)\n", + " skill = rng.beta(2.2, 2.0, n)\n", + " return pd.DataFrame({\"pilot\": [f\"P{i + 1:03d}\" for i in range(n)], \"skill\": skill,\n", + " \"jerky\": rng.random(n) < 0.15 + 0.35 * (1 - skill), \"overcorr\": rng.random(n) < 0.15 + 0.35 * (1 - skill)})\n", + "\n", + "\n", + "def target_path(t):\n", + " \"\"\"사각형 경로(40x30m, 고도 20m)를 3m/s로 비행 -> 60초 지점에서 호버링 -> 착륙 지시 후 고도 0.\"\"\"\n", + " s = np.minimum(t, 60.0) * 3.0 % 140.0\n", + " x = np.select([s < 40, s < 70, s < 110], [s, 40.0, 40 - (s - 70)], 0.0)\n", + " y = np.select([s < 40, s < 70, s < 110], [0.0, s - 40, 30.0], 30 - (s - 110))\n", + " z = np.where(t >= LAND_T, 0.0, 20.0)\n", + " return np.stack([x, y, z], -1)\n", + "\n", + "\n", + "def fly(pilots, cond=\"day\", seed=0, delay_extra=0.0, obstacle_side=None):\n", + " \"\"\"여러 조종자의 비행 1회를 동시에 모의한다(벡터화). 반환: 로그 배열 dict, 비행별 정답 정보 DataFrame.\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " c = COND[cond]\n", + " delay_extra = delay_extra + c[\"delay\"]\n", + " n = len(pilots)\n", + " sk, jk, oc = pilots[\"skill\"].values, pilots[\"jerky\"].values, pilots[\"overcorr\"].values\n", + " gain = (0.5 + 1.2 * sk) * np.where(oc, 1.5, 1.0)\n", + " damp = np.where(oc, 0.25, 0.6 + 0.3 * sk)\n", + " noise = 0.03 + 0.35 * (1 - sk)\n", + " smooth = np.where(jk, 0.25, 0.75)\n", + " delay = np.round((0.25 + 0.35 * (1 - sk) + delay_extra) / DT).astype(int)\n", + " rt_obs = np.clip(0.5 + 1.5 * (1 - sk) + rng.normal(0, 0.2, n) + delay_extra, 0.3, 3.5)\n", + " h_flare = np.clip(0.8 + 5.0 * sk - 1.5 * oc + rng.normal(0, 0.4, n), 0.3, None)\n", + " side = rng.choice([-1, 1], n) if obstacle_side is None else np.full(n, obstacle_side)\n", + " pos = np.tile(target_path(np.zeros(1))[0], (n, 1)).astype(float); vel = np.zeros((n, 3)); stick = np.zeros((n, 3))\n", + " hist = np.zeros((STEPS, n, 3)); P = np.zeros((STEPS, n, 3)); S = np.zeros((STEPS, n, 3)); ATT = np.zeros((STEPS, n, 2))\n", + " tgt_all = np.zeros((STEPS, n, 3)); vz_touch = np.full(n, np.nan); landed = np.zeros(n, bool)\n", + " vmax = np.array([5.0, 5.0, 3.0])\n", + " gust = np.zeros((n, 2))\n", + " for i, t in enumerate(T):\n", + " tgt = np.tile(target_path(np.array([t]))[0], (n, 1))\n", + " if t >= OBST_T: # 장애물: 6m 옆으로 회피해야 함(조종자는 반응시간 이후 인지)\n", + " tgt_all[i] = tgt + np.c_[np.zeros(n), side * 6.0, np.zeros(n)]\n", + " tgt[:, 1] += np.where(t >= OBST_T + rt_obs, side * 6.0, 0.0)\n", + " else:\n", + " tgt_all[i] = tgt\n", + " hist[i] = pos\n", + " seen = hist[np.maximum(i - delay, 0), np.arange(n)] # 반응 지연만큼 늦게 본 위치\n", + " cmd = np.clip(gain[:, None] * (tgt - seen) / 4.0 - damp[:, None] * vel / vmax + rng.normal(0, 1, (n, 3)) * noise[:, None], -1, 1)\n", + " if t >= LAND_T: # 비상 착륙: 하강하다가 h_flare 높이에서 감속(숙련자일수록 일찍 감속)\n", + " cmd[:, 2] = np.where(seen[:, 2] > h_flare, -1.0, -0.12 + rng.normal(0, 0.05, n))\n", + " spikes = (jk[:, None] & (rng.random((n, 3)) < 0.02)) * rng.choice([-1, 1], (n, 3)) * 0.9\n", + " stick = np.clip(smooth[:, None] * stick + (1 - smooth[:, None]) * cmd + spikes, -1, 1)\n", + " gust = 0.95 * gust + rng.normal(0, c[\"gust\"] * 0.3, (n, 2))\n", + " wind = np.c_[np.full(n, c[\"wind\"]) + gust[:, 0], gust[:, 1], np.zeros(n)]\n", + " vel += (stick * vmax - vel) * DT / 0.6\n", + " vel[landed] = 0.0\n", + " pos = pos + (vel + wind * (~landed)[:, None]) * DT\n", + " touch = (pos[:, 2] <= 0) & ~landed & (t >= LAND_T)\n", + " vz_touch[touch] = -vel[touch, 2]; landed |= touch\n", + " pos[:, 2] = np.maximum(pos[:, 2], 0.0)\n", + " P[i], S[i] = pos, stick\n", + " ATT[i] = np.c_[stick[:, 1] * 28 + gust[:, 1] * 1.5, -stick[:, 0] * 28 + gust[:, 0] * 1.5]\n", + " g = rng.normal(0, c[\"gps\"], P.shape)\n", + " log = {\"t\": T, \"x\": P[..., 0] + g[..., 0], \"y\": P[..., 1] + g[..., 1], \"alt\": P[..., 2] + g[..., 2] * 0.5,\n", + " \"stick_pitch\": S[..., 0], \"stick_roll\": S[..., 1], \"throttle\": S[..., 2], \"roll_deg\": ATT[..., 0], \"pitch_deg\": ATT[..., 1],\n", + " \"tx\": tgt_all[..., 0], \"ty\": tgt_all[..., 1], \"talt\": tgt_all[..., 2], \"true_x\": P[..., 0], \"true_y\": P[..., 1], \"true_alt\": P[..., 2]}\n", + " k_obs = int((OBST_T + 2.5) / DT)\n", + " lateral = np.abs(P[k_obs, :, 1] - P[int(OBST_T / DT), :, 1])\n", + " truth = pd.DataFrame({\"pilot\": pilots[\"pilot\"].values, \"cond\": cond, \"skill\": sk, \"jerky\": jk, \"overcorr\": oc, \"rt_obs\": rt_obs,\n", + " \"near_collision\": lateral < 2.5, \"vz_touch\": np.round(vz_touch, 2), \"hard_landing\": np.nan_to_num(vz_touch, nan=3.0) > 1.6,\n", + " \"attitude_exceed\": (np.abs(ATT) > 35).any(axis=2).sum(axis=0) >= 10})\n", + " risk = truth[[\"near_collision\", \"hard_landing\", \"attitude_exceed\"]].sum(axis=1)\n", + " inst = 40 + 50 * sk - 5 * jk - 5 * oc - 4 * risk + rng.normal(0, 3, n) # 교관 평가 점수(가상)\n", + " truth[\"instructor\"] = np.clip(inst, 0, 100).round(1)\n", + " truth[\"grade\"] = np.where(truth[\"instructor\"] >= 75, \"A\", np.where(truth[\"instructor\"] >= 55, \"B\", \"C\"))\n", + " return log, truth\n", + "\n", + "\n", + "def one(log, j):\n", + " \"\"\"여러 조종자 로그에서 j번째 조종자 1명의 로그(DataFrame)를 꺼낸다.\"\"\"\n", + " return pd.DataFrame({k: (v if v.ndim == 1 else v[:, j]) for k, v in log.items()})\n", + "# ---------------------------------------------------------------------------\n", + "\n", + "# ---- 비행로그 특징 추출 공통 함수(여러 조종자 로그를 한 번에 계산) ----\n", + "from scipy.ndimage import uniform_filter1d\n", + "\n", + "BASE_F = [\"path_rmse\", \"hover_drift\", \"hover_std\", \"alt_err\"]\n", + "ADV_F = BASE_F + [\"obs_rt\", \"obs_lateral\", \"touch_vz\", \"att_exceed\", \"jerk\", \"overshoot\", \"n_risk\"]\n", + "RULES = {\"near_collision\": (\"obs_lateral\", \"<\", 2.5), \"hard_landing\": (\"touch_vz\", \">\", 2.0), \"attitude_exceed\": (\"att_exceed\", \">=\", 10)}\n", + "\n", + "\n", + "def detect(df):\n", + " \"\"\"위험 조작 사건 탐지 규칙: 장애물 2.5초 후 회피 거리 2.5m 미만, 접지 속도 2.0m/s 초과, 자세 35도 초과 1초 이상.\"\"\"\n", + " out = {}\n", + " for ev, (f, op, th) in RULES.items():\n", + " out[ev] = df[f] < th if op == \"<\" else (df[f] > th if op == \">\" else df[f] >= th)\n", + " return pd.DataFrame(out)\n", + "\n", + "\n", + "def features(log):\n", + " \"\"\"경로 이탈도·호버링 안정성(기본) + 장애물 반응·착륙 속도·자세 한계·급조작·과보정(고도화) 특징.\"\"\"\n", + " X, Y, A = (uniform_filter1d(log[k], 9, axis=0) for k in (\"x\", \"y\", \"alt\")) # GPS 잡음 완화(이동평균 0.9초)\n", + " h = np.hypot(X - log[\"tx\"], Y - log[\"ty\"])\n", + " cr, hv = slice(0, 600), slice(620, 850)\n", + " k0, k1 = int(OBST_T / DT), int((OBST_T + 2.5) / DT)\n", + " side = np.sign(log[\"ty\"][k0 + 1] - log[\"ty\"][k0 - 1])\n", + " moved = (log[\"stick_roll\"][k0:k0 + 40] * side) > 0.3\n", + " rt = np.where(moved.any(0), moved.argmax(0) * DT, 4.0)\n", + " vz = -np.gradient(A, DT, axis=0)\n", + " land = np.argmax((A < 0.4) & (T[:, None] >= LAND_T), axis=0)\n", + " touch = np.array([vz[max(i - 6, 0):i - 2, j].mean() if i > 6 else 3.0 for j, i in enumerate(land)])\n", + " ds = np.abs(np.diff(np.stack([log[\"stick_roll\"], log[\"stick_pitch\"]]), axis=1)) / DT\n", + " return pd.DataFrame({\n", + " \"path_rmse\": np.sqrt((h[cr] ** 2).mean(0)), \"hover_drift\": h[hv].mean(0), \"hover_std\": np.hypot(X[hv].std(0), Y[hv].std(0)),\n", + " \"alt_err\": np.abs(A[:850] - log[\"talt\"][:850]).mean(0),\n", + " \"obs_rt\": rt, \"obs_lateral\": np.abs(Y[k1] - Y[k0]), \"touch_vz\": touch,\n", + " \"att_exceed\": (np.maximum(np.abs(log[\"roll_deg\"]), np.abs(log[\"pitch_deg\"])) > 35).sum(0),\n", + " \"jerk\": np.percentile(ds, 95, axis=(0, 1)),\n", + " \"overshoot\": np.clip(np.abs(Y[k0:k0 + 120] - Y[k0]).max(0) - 6.0, 0, None)}).pipe(\n", + " lambda d: d.assign(n_risk=detect(d).sum(axis=1))) # overshoot: 회피 후 목표 6m를 넘어선 거리(과보정)\n", + "\n", + "\n", + "def make_dataset(n_pilots=40, seed=7, conds=(\"day\", \"night\", \"gust\"), flights=1):\n", + " \"\"\"조종자 n명 x 조건 x 반복 비행 -> 비행별 특징 + 정답(교관 점수·등급·위험 사건) 표.\"\"\"\n", + " pilots = make_pilots(n_pilots, seed)\n", + " out = []\n", + " for c in conds:\n", + " for f in range(flights):\n", + " log, tr = fly(pilots, c, seed=seed * 100 + list(COND).index(c) * 10 + f)\n", + " out.append(pd.concat([tr.reset_index(drop=True), features(log)], axis=1).assign(flight=f))\n", + " return pd.concat(out, ignore_index=True)\n", + "\n", + "\n", + "def cond_z(df, ref, cols):\n", + " \"\"\"조건별 기준 집단(ref)의 평균·표준편차로 표준화: 돌풍처럼 어려운 조건에서 받는 불이익을 줄인다.\"\"\"\n", + " z = df.copy()\n", + " z[cols] = z[cols].astype(float)\n", + " for c in df[\"cond\"].unique():\n", + " r = ref[ref[\"cond\"] == c][cols]\n", + " z.loc[df[\"cond\"] == c, cols] = (df.loc[df[\"cond\"] == c, cols] - r.mean()) / (r.std() + 1e-9)\n", + " return z\n", + "\n", + "\n", + "def to_grade(score):\n", + " return np.where(score >= 75, \"A\", np.where(score >= 55, \"B\", \"C\"))\n", + "\n", + "train = make_dataset(40, seed=7)\n", + "zt = cond_z(train, train, ADV_F)\n", + "base = LinearRegression().fit(cond_z(train, train, BASE_F)[BASE_F], train[\"instructor\"])\n", + "eng = Ridge(alpha=1.0).fit(zt[ADV_F], train[\"instructor\"])\n", + "\n", + "# [교육용가상자료] 복합 시나리오 10종: (조건, 영상 지연 추가 초, 장애물 방향)\n", + "SCEN = [(\"1 주간 기본\", \"day\", 0.0, None), (\"2 야간\", \"night\", 0.0, None), (\"3 돌풍\", \"gust\", 0.0, None), (\"4 장애물 좌측\", \"day\", 0.0, -1),\n", + " (\"5 장애물 우측\", \"day\", 0.0, 1), (\"6 영상 지연 0.3초\", \"day\", 0.3, None), (\"7 돌풍+장애물 좌측\", \"gust\", 0.0, -1),\n", + " (\"8 야간+영상 지연\", \"night\", 0.3, None), (\"9 돌풍+영상 지연\", \"gust\", 0.3, None), (\"10 야간+장애물 우측\", \"night\", 0.0, 1)]\n", + "pilots = make_pilots(40, seed=8)\n", + "rows, all_det, ms = [], [], []\n", + "for i, (name, cond, dly, side) in enumerate(SCEN):\n", + " log, tr = fly(pilots, cond, seed=500 + i, delay_extra=dly, obstacle_side=side)\n", + " t0 = time.perf_counter()\n", + " f = features(log)\n", + " df = pd.concat([tr.reset_index(drop=True), f], axis=1)\n", + " df[\"score\"] = np.clip(eng.predict(cond_z(df, train, ADV_F)[ADV_F]), 0, 100)\n", + " det = detect(df)\n", + " ms.append((time.perf_counter() - t0) * 1000 / len(df))\n", + " df[\"base\"] = np.clip(base.predict(cond_z(df, train, BASE_F)[BASE_F]), 0, 100)\n", + " truth = df[list(RULES)].astype(bool)\n", + " tp, pos = int((det & truth).values.sum()), int(truth.values.sum())\n", + " all_det.append((det, truth))\n", + " rows.append({\"시나리오\": name, \"위험사건\": pos, \"탐지\": tp, \"재현율\": round(tp / max(pos, 1), 3), \"오탐\": int((det & ~truth).values.sum()),\n", + " \"Baseline 일치\": round((to_grade(df[\"base\"]) == df[\"grade\"]).mean(), 3), \"고도화 일치\": round((to_grade(df[\"score\"]) == df[\"grade\"]).mean(), 3)})\n", + "R = pd.DataFrame(rows)\n", + "print(R.to_string(index=False))\n", + "rec = R[\"탐지\"].sum() / R[\"위험사건\"].sum()\n", + "print(f\"\\n위험 조작 사건 탐지 재현율 {rec:.1%} (목표 85% 이상: {'달성' if rec >= 0.85 else '미달'}), 로그당 처리 {np.mean(ms):.1f} ms (최대 {np.max(ms):.1f}, 목표 200 ms 이하)\")\n", + "print(f\"등급 일치율 Baseline {R['Baseline 일치'].mean():.1%} -> 고도화 엔진 {R['고도화 일치'].mean():.1%}, 10종 중 재현율 85% 이상 {(R['재현율'] >= 0.85).sum()}개\")\n", + "\n", + "# 사건별 재현율·오탐(오경보 유발 패턴)\n", + "det = pd.concat([d for d, _ in all_det]); tru = pd.concat([t for _, t in all_det])\n", + "print(\"\\n사건 실제 재현율 오탐\")\n", + "for ev in RULES:\n", + " print(f\"{ev:15s} {tru[ev].sum():4d} {(det[ev] & tru[ev]).sum() / max(tru[ev].sum(), 1):.2f} {(det[ev] & ~tru[ev]).sum():4d}\")\n", + "print(\"오탐 패턴: 접지 속도는 고도 로그를 미분해 추정하므로 고도 잡음이 큰 비행에서 과대 추정된다 -> 접지 직전 0.4초 평균 사용, 임계값 2.0m/s로 여유를 둠\")\n", + "\n", + "w = pd.Series(eng.coef_, index=ADV_F).sort_values()\n", + "print(\"\\n엔진 가중치(조건별 표준화 특징 1단위당 점수):\", \", \".join(f\"{k} {v:+.1f}\" for k, v in w.items()))\n", + "\n", + "plt.figure(figsize=(8, 4))\n", + "plt.barh(w.index, w.values, color=[\"tab:red\" if v < 0 else \"tab:blue\" for v in w.values])\n", + "plt.xlabel(\"score change per 1 SD\"); plt.title(\"Advanced skill engine weights (synthetic)\"); plt.tight_layout(); plt.show()\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 04 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + " 시나리오 위험사건 탐지 재현율 오탐 Baseline 일치 고도화 일치\n", + " 1 주간 기본 47 47 1.000 3 0.725 0.800\n", + " 2 야간 46 45 0.978 3 0.775 0.775\n", + " 3 돌풍 65 65 1.000 7 0.625 0.825\n", + " 4 장애물 좌측 42 41 0.976 4 0.800 0.875\n", + " 5 장애물 우측 37 36 0.973 7 0.825 0.850\n", + "6 영상 지연 0.3초 52 49 0.942 3 0.675 0.625\n", + " 7 돌풍+장애물 좌측 56 56 1.000 6 0.750 0.800\n", + " 8 야간+영상 지연 54 51 0.944 5 0.650 0.675\n", + " 9 돌풍+영상 지연 63 63 1.000 7 0.750 0.750\n", + "10 야간+장애물 우측 46 44 0.957 5 0.825 0.800\n", + "\n", + "위험 조작 사건 탐지 재현율 97.8% (목표 85% 이상: 달성), 로그당 처리 0.6 ms (최대 0.9, 목표 200 ms 이하)\n", + "등급 일치율 Baseline 74.0% -> 고도화 엔진 77.8%, 10종 중 재현율 85% 이상 10개\n", + "\n", + "사건 실제 재현율 오탐\n", + "near_collision 187 0.94 6\n", + "hard_landing 256 1.00 44\n", + "attitude_exceed 65 1.00 0\n", + "오탐 패턴: 접지 속도는 고도 로그를 미분해 추정하므로 고도 잡음이 큰 비행에서 과대 추정된다 -> 접지 직전 0.4초 평균 사용, 임계값 2.0m/s로 여유를 둠\n", + "\n", + "엔진 가중치(조건별 표준화 특징 1단위당 점수): alt_err -6.7, jerk -3.1, touch_vz -2.6, n_risk -2.4, hover_drift -2.2, obs_rt -1.1, overshoot -1.1, att_exceed -0.4, path_rmse +0.2, obs_lateral +2.0, hover_std +2.2\n", + "```\n", + "
\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 05. 조종자별 조작 습관(급조작, 과보정) 분석 및 신뢰도 등급 산출 모듈 개발\n", + "**소요시간**: 약 3.0시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "조종자별 조작 습관(급조작, 과보정)을 분석하고 숙련도 등급 및 신뢰도 점수를 산출한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **조종자별 조작 습관(급조작, 과보정)을 분석하고 여러 비행을 묶어 숙련도 등급과 신뢰도 등급(High/Medium/Low)을 산출한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **같은 조건의 습관 없는 조종자 대비 스틱 변화율과 회피 후 초과 이동으로 습관 판정하고 정확도를 측정하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `src/verification/trn01_confidence_engine.py` 형태의 결과물을 완성한다.\n", + "\n", + "### 🔍 평가 및 검증 기준 (PASS 조건)\n", + "#### 미션 판정\n", + "\n", + "**평가 구분:** 필수\n", + "\n", + "**핵심 평가영역:** 신뢰도 등급(High/Med/Low) 부여 기준의 군사적 타당성\n", + "\n", + "#### 미션 PASS 조건\n", + "\n", + "- 정량 기준: 오경보율(FAR) 40% 이상 감축, 신뢰도 스코어 산출 일관성 90% 이상\n", + "\n", + "\n", + "- 진행 기준: 오경보 필터링 전후 비교 지표 산출 및 통합 테스트 완료\n", + "\n", + "\n", + "- 검증 방법: ROC-AUC 곡선 분석 및 임계값 민감도 테스트\n", + "\n", + "\n", + "#### 채점자 판정 메모\n", + "\n", + "> 단순 코드 실행 여부뿐만 아니라 출력된 수치와 근거 자료가 군 실무 요구사항에 부합하는지 면밀히 확인한다.\n", + "\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "\"\"\"조종자별 여러 비행의 조작 습관(급조작=스틱 변화율, 과보정=회피 후 초과 이동)을 분석하고, 비행 점수를 묶어 숙련도 등급과 신뢰도(부트스트랩 등급 확률)를 산출한다. 비행 1회로 '재교육 대상'을 지정하던 방식 대비 오지정률(FAR) 감축, ROC-AUC, 임계값 민감도, 등급 일관성을 검증한다.\"\"\"\n", + "import numpy as np\n", + "import pandas as pd\n", + "import matplotlib.pyplot as plt\n", + "from sklearn.linear_model import Ridge\n", + "from sklearn.metrics import roc_auc_score\n", + "\n", + "# ---------------------------------------------------------------------------\n", + "# [교육용가상자료] 드론 조종 시뮬레이터 비행로그 생성기 (실제 훈련생·장비 정보 없음, 조종자는 가명 P001~)\n", + "# 비행 1회 = 120초(10Hz): 경로 비행(0~60초) -> 호버링(60~85초) -> 돌발 장애물 회피(85초) -> 비상 착륙(100초 지시)\n", + "# 조종자마다 숨은 숙련도(skill)와 조작 습관(급조작 jerky, 과보정 overcorr)이 있고, 교관 점수는 이를 반영해 만든다.\n", + "# ---------------------------------------------------------------------------\n", + "DT, STEPS = 0.1, 1200\n", + "T = np.arange(STEPS) * DT\n", + "COND = {\"day\": dict(wind=0.4, gust=0.0, gps=0.25, delay=0.0), \"night\": dict(wind=0.4, gust=0.0, gps=0.5, delay=0.15),\n", + " \"gust\": dict(wind=1.2, gust=2.5, gps=0.25, delay=0.0)}\n", + "OBST_T, LAND_T = 85.0, 100.0\n", + "\n", + "\n", + "def make_pilots(n=40, seed=7):\n", + " rng = np.random.default_rng(seed)\n", + " skill = rng.beta(2.2, 2.0, n)\n", + " return pd.DataFrame({\"pilot\": [f\"P{i + 1:03d}\" for i in range(n)], \"skill\": skill,\n", + " \"jerky\": rng.random(n) < 0.15 + 0.35 * (1 - skill), \"overcorr\": rng.random(n) < 0.15 + 0.35 * (1 - skill)})\n", + "\n", + "\n", + "def target_path(t):\n", + " \"\"\"사각형 경로(40x30m, 고도 20m)를 3m/s로 비행 -> 60초 지점에서 호버링 -> 착륙 지시 후 고도 0.\"\"\"\n", + " s = np.minimum(t, 60.0) * 3.0 % 140.0\n", + " x = np.select([s < 40, s < 70, s < 110], [s, 40.0, 40 - (s - 70)], 0.0)\n", + " y = np.select([s < 40, s < 70, s < 110], [0.0, s - 40, 30.0], 30 - (s - 110))\n", + " z = np.where(t >= LAND_T, 0.0, 20.0)\n", + " return np.stack([x, y, z], -1)\n", + "\n", + "\n", + "def fly(pilots, cond=\"day\", seed=0, delay_extra=0.0, obstacle_side=None):\n", + " \"\"\"여러 조종자의 비행 1회를 동시에 모의한다(벡터화). 반환: 로그 배열 dict, 비행별 정답 정보 DataFrame.\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " c = COND[cond]\n", + " delay_extra = delay_extra + c[\"delay\"]\n", + " n = len(pilots)\n", + " sk, jk, oc = pilots[\"skill\"].values, pilots[\"jerky\"].values, pilots[\"overcorr\"].values\n", + " gain = (0.5 + 1.2 * sk) * np.where(oc, 1.5, 1.0)\n", + " damp = np.where(oc, 0.25, 0.6 + 0.3 * sk)\n", + " noise = 0.03 + 0.35 * (1 - sk)\n", + " smooth = np.where(jk, 0.25, 0.75)\n", + " delay = np.round((0.25 + 0.35 * (1 - sk) + delay_extra) / DT).astype(int)\n", + " rt_obs = np.clip(0.5 + 1.5 * (1 - sk) + rng.normal(0, 0.2, n) + delay_extra, 0.3, 3.5)\n", + " h_flare = np.clip(0.8 + 5.0 * sk - 1.5 * oc + rng.normal(0, 0.4, n), 0.3, None)\n", + " side = rng.choice([-1, 1], n) if obstacle_side is None else np.full(n, obstacle_side)\n", + " pos = np.tile(target_path(np.zeros(1))[0], (n, 1)).astype(float); vel = np.zeros((n, 3)); stick = np.zeros((n, 3))\n", + " hist = np.zeros((STEPS, n, 3)); P = np.zeros((STEPS, n, 3)); S = np.zeros((STEPS, n, 3)); ATT = np.zeros((STEPS, n, 2))\n", + " tgt_all = np.zeros((STEPS, n, 3)); vz_touch = np.full(n, np.nan); landed = np.zeros(n, bool)\n", + " vmax = np.array([5.0, 5.0, 3.0])\n", + " gust = np.zeros((n, 2))\n", + " for i, t in enumerate(T):\n", + " tgt = np.tile(target_path(np.array([t]))[0], (n, 1))\n", + " if t >= OBST_T: # 장애물: 6m 옆으로 회피해야 함(조종자는 반응시간 이후 인지)\n", + " tgt_all[i] = tgt + np.c_[np.zeros(n), side * 6.0, np.zeros(n)]\n", + " tgt[:, 1] += np.where(t >= OBST_T + rt_obs, side * 6.0, 0.0)\n", + " else:\n", + " tgt_all[i] = tgt\n", + " hist[i] = pos\n", + " seen = hist[np.maximum(i - delay, 0), np.arange(n)] # 반응 지연만큼 늦게 본 위치\n", + " cmd = np.clip(gain[:, None] * (tgt - seen) / 4.0 - damp[:, None] * vel / vmax + rng.normal(0, 1, (n, 3)) * noise[:, None], -1, 1)\n", + " if t >= LAND_T: # 비상 착륙: 하강하다가 h_flare 높이에서 감속(숙련자일수록 일찍 감속)\n", + " cmd[:, 2] = np.where(seen[:, 2] > h_flare, -1.0, -0.12 + rng.normal(0, 0.05, n))\n", + " spikes = (jk[:, None] & (rng.random((n, 3)) < 0.02)) * rng.choice([-1, 1], (n, 3)) * 0.9\n", + " stick = np.clip(smooth[:, None] * stick + (1 - smooth[:, None]) * cmd + spikes, -1, 1)\n", + " gust = 0.95 * gust + rng.normal(0, c[\"gust\"] * 0.3, (n, 2))\n", + " wind = np.c_[np.full(n, c[\"wind\"]) + gust[:, 0], gust[:, 1], np.zeros(n)]\n", + " vel += (stick * vmax - vel) * DT / 0.6\n", + " vel[landed] = 0.0\n", + " pos = pos + (vel + wind * (~landed)[:, None]) * DT\n", + " touch = (pos[:, 2] <= 0) & ~landed & (t >= LAND_T)\n", + " vz_touch[touch] = -vel[touch, 2]; landed |= touch\n", + " pos[:, 2] = np.maximum(pos[:, 2], 0.0)\n", + " P[i], S[i] = pos, stick\n", + " ATT[i] = np.c_[stick[:, 1] * 28 + gust[:, 1] * 1.5, -stick[:, 0] * 28 + gust[:, 0] * 1.5]\n", + " g = rng.normal(0, c[\"gps\"], P.shape)\n", + " log = {\"t\": T, \"x\": P[..., 0] + g[..., 0], \"y\": P[..., 1] + g[..., 1], \"alt\": P[..., 2] + g[..., 2] * 0.5,\n", + " \"stick_pitch\": S[..., 0], \"stick_roll\": S[..., 1], \"throttle\": S[..., 2], \"roll_deg\": ATT[..., 0], \"pitch_deg\": ATT[..., 1],\n", + " \"tx\": tgt_all[..., 0], \"ty\": tgt_all[..., 1], \"talt\": tgt_all[..., 2], \"true_x\": P[..., 0], \"true_y\": P[..., 1], \"true_alt\": P[..., 2]}\n", + " k_obs = int((OBST_T + 2.5) / DT)\n", + " lateral = np.abs(P[k_obs, :, 1] - P[int(OBST_T / DT), :, 1])\n", + " truth = pd.DataFrame({\"pilot\": pilots[\"pilot\"].values, \"cond\": cond, \"skill\": sk, \"jerky\": jk, \"overcorr\": oc, \"rt_obs\": rt_obs,\n", + " \"near_collision\": lateral < 2.5, \"vz_touch\": np.round(vz_touch, 2), \"hard_landing\": np.nan_to_num(vz_touch, nan=3.0) > 1.6,\n", + " \"attitude_exceed\": (np.abs(ATT) > 35).any(axis=2).sum(axis=0) >= 10})\n", + " risk = truth[[\"near_collision\", \"hard_landing\", \"attitude_exceed\"]].sum(axis=1)\n", + " inst = 40 + 50 * sk - 5 * jk - 5 * oc - 4 * risk + rng.normal(0, 3, n) # 교관 평가 점수(가상)\n", + " truth[\"instructor\"] = np.clip(inst, 0, 100).round(1)\n", + " truth[\"grade\"] = np.where(truth[\"instructor\"] >= 75, \"A\", np.where(truth[\"instructor\"] >= 55, \"B\", \"C\"))\n", + " return log, truth\n", + "\n", + "\n", + "def one(log, j):\n", + " \"\"\"여러 조종자 로그에서 j번째 조종자 1명의 로그(DataFrame)를 꺼낸다.\"\"\"\n", + " return pd.DataFrame({k: (v if v.ndim == 1 else v[:, j]) for k, v in log.items()})\n", + "# ---------------------------------------------------------------------------\n", + "\n", + "# ---- 비행로그 특징 추출 공통 함수(여러 조종자 로그를 한 번에 계산) ----\n", + "from scipy.ndimage import uniform_filter1d\n", + "\n", + "BASE_F = [\"path_rmse\", \"hover_drift\", \"hover_std\", \"alt_err\"]\n", + "ADV_F = BASE_F + [\"obs_rt\", \"obs_lateral\", \"touch_vz\", \"att_exceed\", \"jerk\", \"overshoot\", \"n_risk\"]\n", + "RULES = {\"near_collision\": (\"obs_lateral\", \"<\", 2.5), \"hard_landing\": (\"touch_vz\", \">\", 2.0), \"attitude_exceed\": (\"att_exceed\", \">=\", 10)}\n", + "\n", + "\n", + "def detect(df):\n", + " \"\"\"위험 조작 사건 탐지 규칙: 장애물 2.5초 후 회피 거리 2.5m 미만, 접지 속도 2.0m/s 초과, 자세 35도 초과 1초 이상.\"\"\"\n", + " out = {}\n", + " for ev, (f, op, th) in RULES.items():\n", + " out[ev] = df[f] < th if op == \"<\" else (df[f] > th if op == \">\" else df[f] >= th)\n", + " return pd.DataFrame(out)\n", + "\n", + "\n", + "def features(log):\n", + " \"\"\"경로 이탈도·호버링 안정성(기본) + 장애물 반응·착륙 속도·자세 한계·급조작·과보정(고도화) 특징.\"\"\"\n", + " X, Y, A = (uniform_filter1d(log[k], 9, axis=0) for k in (\"x\", \"y\", \"alt\")) # GPS 잡음 완화(이동평균 0.9초)\n", + " h = np.hypot(X - log[\"tx\"], Y - log[\"ty\"])\n", + " cr, hv = slice(0, 600), slice(620, 850)\n", + " k0, k1 = int(OBST_T / DT), int((OBST_T + 2.5) / DT)\n", + " side = np.sign(log[\"ty\"][k0 + 1] - log[\"ty\"][k0 - 1])\n", + " moved = (log[\"stick_roll\"][k0:k0 + 40] * side) > 0.3\n", + " rt = np.where(moved.any(0), moved.argmax(0) * DT, 4.0)\n", + " vz = -np.gradient(A, DT, axis=0)\n", + " land = np.argmax((A < 0.4) & (T[:, None] >= LAND_T), axis=0)\n", + " touch = np.array([vz[max(i - 6, 0):i - 2, j].mean() if i > 6 else 3.0 for j, i in enumerate(land)])\n", + " ds = np.abs(np.diff(np.stack([log[\"stick_roll\"], log[\"stick_pitch\"]]), axis=1)) / DT\n", + " return pd.DataFrame({\n", + " \"path_rmse\": np.sqrt((h[cr] ** 2).mean(0)), \"hover_drift\": h[hv].mean(0), \"hover_std\": np.hypot(X[hv].std(0), Y[hv].std(0)),\n", + " \"alt_err\": np.abs(A[:850] - log[\"talt\"][:850]).mean(0),\n", + " \"obs_rt\": rt, \"obs_lateral\": np.abs(Y[k1] - Y[k0]), \"touch_vz\": touch,\n", + " \"att_exceed\": (np.maximum(np.abs(log[\"roll_deg\"]), np.abs(log[\"pitch_deg\"])) > 35).sum(0),\n", + " \"jerk\": np.percentile(ds, 95, axis=(0, 1)),\n", + " \"overshoot\": np.clip(np.abs(Y[k0:k0 + 120] - Y[k0]).max(0) - 6.0, 0, None)}).pipe(\n", + " lambda d: d.assign(n_risk=detect(d).sum(axis=1))) # overshoot: 회피 후 목표 6m를 넘어선 거리(과보정)\n", + "\n", + "\n", + "def make_dataset(n_pilots=40, seed=7, conds=(\"day\", \"night\", \"gust\"), flights=1):\n", + " \"\"\"조종자 n명 x 조건 x 반복 비행 -> 비행별 특징 + 정답(교관 점수·등급·위험 사건) 표.\"\"\"\n", + " pilots = make_pilots(n_pilots, seed)\n", + " out = []\n", + " for c in conds:\n", + " for f in range(flights):\n", + " log, tr = fly(pilots, c, seed=seed * 100 + list(COND).index(c) * 10 + f)\n", + " out.append(pd.concat([tr.reset_index(drop=True), features(log)], axis=1).assign(flight=f))\n", + " return pd.concat(out, ignore_index=True)\n", + "\n", + "\n", + "def cond_z(df, ref, cols):\n", + " \"\"\"조건별 기준 집단(ref)의 평균·표준편차로 표준화: 돌풍처럼 어려운 조건에서 받는 불이익을 줄인다.\"\"\"\n", + " z = df.copy()\n", + " z[cols] = z[cols].astype(float)\n", + " for c in df[\"cond\"].unique():\n", + " r = ref[ref[\"cond\"] == c][cols]\n", + " z.loc[df[\"cond\"] == c, cols] = (df.loc[df[\"cond\"] == c, cols] - r.mean()) / (r.std() + 1e-9)\n", + " return z\n", + "\n", + "\n", + "def to_grade(score):\n", + " return np.where(score >= 75, \"A\", np.where(score >= 55, \"B\", \"C\"))\n", + "\n", + "train = make_dataset(40, seed=7)\n", + "eng = Ridge(alpha=1.0).fit(cond_z(train, train, ADV_F)[ADV_F], train[\"instructor\"])\n", + "base = Ridge(alpha=1.0).fit(cond_z(train, train, BASE_F)[BASE_F], train[\"instructor\"]) # 미션 3 Baseline 채점식\n", + "zt = cond_z(train, train, ADV_F) # 습관 판정 기준: 같은 조건에서 습관 없는 조종자의 상위 10%\n", + "jerk_th = zt.loc[~train[\"jerky\"], \"jerk\"].quantile(0.9)\n", + "over_th = zt.loc[~train[\"overcorr\"], \"overshoot\"].quantile(0.9)\n", + "\n", + "# [교육용가상자료] 평가 조종자 80명 x 비행 6회(주간·야간·돌풍 각 2회)\n", + "test = make_dataset(80, seed=9, flights=2)\n", + "test[\"score\"] = np.clip(eng.predict(cond_z(test, train, ADV_F)[ADV_F]), 0, 100)\n", + "ze = cond_z(test, train, ADV_F)\n", + "test[\"jerk_z\"], test[\"over_z\"] = ze[\"jerk\"], ze[\"overshoot\"]\n", + "test[\"base\"] = np.clip(base.predict(cond_z(test, train, BASE_F)[BASE_F]), 0, 100)\n", + "rng = np.random.default_rng(0)\n", + "rows = []\n", + "for pid, g in test.groupby(\"pilot\"):\n", + " boot = [to_grade(rng.choice(g[\"score\"].values, len(g)).mean()).item() for _ in range(200)]\n", + " grade = to_grade(g[\"score\"].mean()).item()\n", + " conf = boot.count(grade) / len(boot)\n", + " rows.append({\"pilot\": pid, \"점수\": g[\"score\"].mean(), \"점수SD\": g[\"score\"].std(), \"등급\": grade, \"신뢰도\": conf,\n", + " \"신뢰등급\": \"High\" if conf >= 0.9 else (\"Medium\" if conf >= 0.7 else \"Low\"),\n", + " \"급조작\": (g[\"jerk_z\"] > jerk_th).mean() >= 0.5, \"과보정\": (g[\"over_z\"] > over_th).mean() >= 0.5,\n", + " \"실제급조작\": g[\"jerky\"].iloc[0], \"실제과보정\": g[\"overcorr\"].iloc[0],\n", + " \"교관등급\": to_grade(g[\"instructor\"].mean()).item(), \"단일비행점수\": g[\"base\"].iloc[int(rng.integers(len(g)))]})\n", + "P = pd.DataFrame(rows)\n", + "print(f\"습관 판정 정확도: 급조작 {(P['급조작'] == P['실제급조작']).mean():.0%}, 과보정 {(P['과보정'] == P['실제과보정']).mean():.0%} (조종자 {len(P)}명)\")\n", + "\n", + "# 재교육 대상(C) 지정: 기존(무작위 비행 1회의 Baseline 점수 55 미만) vs 엔진(6회 평균 C + 신뢰도 Medium 이상, Low는 교관 확인으로 보냄)\n", + "need = P[\"교관등급\"] == \"C\"\n", + "old = P[\"단일비행점수\"] < 55\n", + "new = (P[\"등급\"] == \"C\") & (P[\"신뢰등급\"] != \"Low\")\n", + "review = (P[\"등급\"] == \"C\") & (P[\"신뢰등급\"] == \"Low\")\n", + "\n", + "\n", + "def far_rec(flag):\n", + " return (flag & ~need).sum() / max(flag.sum(), 1), (flag & need).sum() / max(need.sum(), 1), int(flag.sum())\n", + "\n", + "\n", + "f0, r0, n0 = far_rec(old)\n", + "f1, r1, n1 = far_rec(new)\n", + "print(pd.DataFrame([{\"방식\": \"기존(비행 1회 Baseline<55)\", \"지정\": n0, \"오지정률\": round(f0, 3), \"재현율\": round(r0, 3)},\n", + " {\"방식\": \"엔진(6회 평균 C, 신뢰 Medium 이상)\", \"지정\": n1, \"오지정률\": round(f1, 3), \"재현율\": round(r1, 3)}]).to_string(index=False))\n", + "cut = 1 - f1 / max(f0, 1e-9)\n", + "print(f\"오지정률(FAR) 감축 {cut:.1%} (목표 40% 이상: {'달성' if cut >= 0.4 else '미달'}), 신뢰도 Low로 교관 확인에 넘긴 조종자 {int(review.sum())}명\")\n", + "print(f\"ROC-AUC(재교육 필요 판별, 평균 점수 기준) {roc_auc_score(need, -P['점수']):.3f}\")\n", + "\n", + "print(\"\\n기준점수 지정 오지정률 재현율\")\n", + "for th in [50, 55, 60, 65]:\n", + " f, r, n = far_rec(P[\"점수\"] < th)\n", + " print(f\" {th} {n:3d} {f:.3f} {r:.3f}\")\n", + "\n", + "# 등급 일관성: 비행 6회 중 5회만 무작위로 써도 같은 등급이 나오는 비율\n", + "cons = []\n", + "for pid, g in test.groupby(\"pilot\"):\n", + " full = to_grade(g[\"score\"].mean()).item()\n", + " cons += [to_grade(g[\"score\"].sample(5, random_state=k).mean()).item() == full for k in range(20)]\n", + "print(f\"\\n등급 일관성 {np.mean(cons):.1%} (목표 90% 이상: {'달성' if np.mean(cons) >= 0.9 else '미달'})\")\n", + "print(\"[신뢰등급 분포]\", P[\"신뢰등급\"].value_counts().to_dict(), \"/ 등급 일치(교관 대비)\", f\"{(P['등급'] == P['교관등급']).mean():.0%}\")\n", + "print(\"신뢰등급 기준: High(부트스트랩 동일 등급 90% 이상)=결과 통보, Medium(70~90%)=교관 검토 후 통보, Low=추가 비행 후 재평가\")\n", + "\n", + "P = P.sort_values(\"점수\")\n", + "plt.figure(figsize=(10, 4))\n", + "col = P[\"신뢰등급\"].map({\"High\": \"tab:green\", \"Medium\": \"tab:orange\", \"Low\": \"tab:red\"})\n", + "plt.errorbar(range(len(P)), P[\"점수\"], yerr=P[\"점수SD\"] / np.sqrt(6) * 2, fmt=\"none\", ecolor=\"gray\")\n", + "plt.scatter(range(len(P)), P[\"점수\"], c=col)\n", + "plt.axhline(55, ls=\"--\", c=\"k\"); plt.axhline(75, ls=\"--\", c=\"k\")\n", + "plt.xticks(range(len(P)), P[\"pilot\"], rotation=90, fontsize=6); plt.ylabel(\"mean skill score (+-2 SE)\")\n", + "plt.title(\"Pilot skill score with uncertainty (color = confidence High/Medium/Low)\"); plt.tight_layout(); plt.show()\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 05 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + "습관 판정 정확도: 급조작 95%, 과보정 88% (조종자 80명)\n", + " 방식 지정 오지정률 재현율\n", + " 기존(비행 1회 Baseline<55) 27 0.111 0.750\n", + "엔진(6회 평균 C, 신뢰 Medium 이상) 31 0.032 0.938\n", + "오지정률(FAR) 감축 71.0% (목표 40% 이상: 달성), 신뢰도 Low로 교관 확인에 넘긴 조종자 1명\n", + "ROC-AUC(재교육 필요 판별, 평균 점수 기준) 0.997\n", + "\n", + "기준점수 지정 오지정률 재현율\n", + " 50 22 0.000 0.688\n", + " 55 32 0.031 0.969\n", + " 60 43 0.256 1.000\n", + " 65 53 0.396 1.000\n", + "\n", + "등급 일관성 97.6% (목표 90% 이상: 달성)\n", + "[신뢰등급 분포] {'High': 66, 'Medium': 9, 'Low': 5} / 등급 일치(교관 대비) 98%\n", + "신뢰등급 기준: High(부트스트랩 동일 등급 90% 이상)=결과 통보, Medium(70~90%)=교관 검토 후 통보, Low=추가 비행 후 재평가\n", + "```\n", + "
\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 06. 조종자 비행 궤적 3D 재생 및 취약 구간 하이라이트 분석 대시보드 구현\n", + "**소요시간**: 약 2.5시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "조종자 비행 궤적 3D 재생 및 취약 구간 하이라이트 분석 대시보드를 구현한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **조종자 비행 궤적을 3D로 재생하고 취약 구간을 강조하는 분석 대시보드를 구현한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **경로 이탈 3m 초과, 자세 30도 초과, 급조작 구간을 취약 구간으로 찾아 3D 궤적에 붉게 표시하고, 비행 단계별 경로 이탈 추이, 스틱 입력, 동료 대비 약점 프로필을 함께 보여 준다 과정을 진행하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `src/ui/trn01_dashboard.py (또는 웹 UI 컴포넌트)` 형태의 결과물을 완성한다.\n", + "\n", + "### 🔍 평가 및 검증 기준 (PASS 조건)\n", + "#### 미션 판정\n", + "\n", + "**평가 구분:** 필수\n", + "\n", + "**핵심 평가영역:** 전술 작전 상황판으로서의 직관성 및 인지 부하 감소도\n", + "\n", + "#### 미션 PASS 조건\n", + "\n", + "- 정량 기준: 화면 갱신 주기 1초 이내, UI 응답성 100ms 이내\n", + "\n", + "\n", + "- 진행 기준: 주요 지표 시각화 컴포넌트 4종 이상 렌더링 및 실시간 데이터 연동\n", + "\n", + "\n", + "- 검증 방법: 사용자 인터랙션 테스트 및 모의 데이터 스트림 렌더링 검증\n", + "\n", + "\n", + "#### 채점자 판정 메모\n", + "\n", + "> 단순 코드 실행 여부뿐만 아니라 출력된 수치와 근거 자료가 군 실무 요구사항에 부합하는지 면밀히 확인한다.\n", + "\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "\"\"\"조종자 1명의 비행 궤적을 3D로 재생하고 경로 이탈·자세 한계·급조작 구간을 취약 구간으로 강조하는 분석 대시보드(야간 모드)를 구현한다. 재생 1초마다 지표를 갱신하는 처리시간을 측정하고 취약 구간 목록을 출력한다.\"\"\"\n", + "import time\n", + "import numpy as np\n", + "import pandas as pd\n", + "import matplotlib.pyplot as plt\n", + "\n", + "# ---------------------------------------------------------------------------\n", + "# [교육용가상자료] 드론 조종 시뮬레이터 비행로그 생성기 (실제 훈련생·장비 정보 없음, 조종자는 가명 P001~)\n", + "# 비행 1회 = 120초(10Hz): 경로 비행(0~60초) -> 호버링(60~85초) -> 돌발 장애물 회피(85초) -> 비상 착륙(100초 지시)\n", + "# 조종자마다 숨은 숙련도(skill)와 조작 습관(급조작 jerky, 과보정 overcorr)이 있고, 교관 점수는 이를 반영해 만든다.\n", + "# ---------------------------------------------------------------------------\n", + "DT, STEPS = 0.1, 1200\n", + "T = np.arange(STEPS) * DT\n", + "COND = {\"day\": dict(wind=0.4, gust=0.0, gps=0.25, delay=0.0), \"night\": dict(wind=0.4, gust=0.0, gps=0.5, delay=0.15),\n", + " \"gust\": dict(wind=1.2, gust=2.5, gps=0.25, delay=0.0)}\n", + "OBST_T, LAND_T = 85.0, 100.0\n", + "\n", + "\n", + "def make_pilots(n=40, seed=7):\n", + " rng = np.random.default_rng(seed)\n", + " skill = rng.beta(2.2, 2.0, n)\n", + " return pd.DataFrame({\"pilot\": [f\"P{i + 1:03d}\" for i in range(n)], \"skill\": skill,\n", + " \"jerky\": rng.random(n) < 0.15 + 0.35 * (1 - skill), \"overcorr\": rng.random(n) < 0.15 + 0.35 * (1 - skill)})\n", + "\n", + "\n", + "def target_path(t):\n", + " \"\"\"사각형 경로(40x30m, 고도 20m)를 3m/s로 비행 -> 60초 지점에서 호버링 -> 착륙 지시 후 고도 0.\"\"\"\n", + " s = np.minimum(t, 60.0) * 3.0 % 140.0\n", + " x = np.select([s < 40, s < 70, s < 110], [s, 40.0, 40 - (s - 70)], 0.0)\n", + " y = np.select([s < 40, s < 70, s < 110], [0.0, s - 40, 30.0], 30 - (s - 110))\n", + " z = np.where(t >= LAND_T, 0.0, 20.0)\n", + " return np.stack([x, y, z], -1)\n", + "\n", + "\n", + "def fly(pilots, cond=\"day\", seed=0, delay_extra=0.0, obstacle_side=None):\n", + " \"\"\"여러 조종자의 비행 1회를 동시에 모의한다(벡터화). 반환: 로그 배열 dict, 비행별 정답 정보 DataFrame.\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " c = COND[cond]\n", + " delay_extra = delay_extra + c[\"delay\"]\n", + " n = len(pilots)\n", + " sk, jk, oc = pilots[\"skill\"].values, pilots[\"jerky\"].values, pilots[\"overcorr\"].values\n", + " gain = (0.5 + 1.2 * sk) * np.where(oc, 1.5, 1.0)\n", + " damp = np.where(oc, 0.25, 0.6 + 0.3 * sk)\n", + " noise = 0.03 + 0.35 * (1 - sk)\n", + " smooth = np.where(jk, 0.25, 0.75)\n", + " delay = np.round((0.25 + 0.35 * (1 - sk) + delay_extra) / DT).astype(int)\n", + " rt_obs = np.clip(0.5 + 1.5 * (1 - sk) + rng.normal(0, 0.2, n) + delay_extra, 0.3, 3.5)\n", + " h_flare = np.clip(0.8 + 5.0 * sk - 1.5 * oc + rng.normal(0, 0.4, n), 0.3, None)\n", + " side = rng.choice([-1, 1], n) if obstacle_side is None else np.full(n, obstacle_side)\n", + " pos = np.tile(target_path(np.zeros(1))[0], (n, 1)).astype(float); vel = np.zeros((n, 3)); stick = np.zeros((n, 3))\n", + " hist = np.zeros((STEPS, n, 3)); P = np.zeros((STEPS, n, 3)); S = np.zeros((STEPS, n, 3)); ATT = np.zeros((STEPS, n, 2))\n", + " tgt_all = np.zeros((STEPS, n, 3)); vz_touch = np.full(n, np.nan); landed = np.zeros(n, bool)\n", + " vmax = np.array([5.0, 5.0, 3.0])\n", + " gust = np.zeros((n, 2))\n", + " for i, t in enumerate(T):\n", + " tgt = np.tile(target_path(np.array([t]))[0], (n, 1))\n", + " if t >= OBST_T: # 장애물: 6m 옆으로 회피해야 함(조종자는 반응시간 이후 인지)\n", + " tgt_all[i] = tgt + np.c_[np.zeros(n), side * 6.0, np.zeros(n)]\n", + " tgt[:, 1] += np.where(t >= OBST_T + rt_obs, side * 6.0, 0.0)\n", + " else:\n", + " tgt_all[i] = tgt\n", + " hist[i] = pos\n", + " seen = hist[np.maximum(i - delay, 0), np.arange(n)] # 반응 지연만큼 늦게 본 위치\n", + " cmd = np.clip(gain[:, None] * (tgt - seen) / 4.0 - damp[:, None] * vel / vmax + rng.normal(0, 1, (n, 3)) * noise[:, None], -1, 1)\n", + " if t >= LAND_T: # 비상 착륙: 하강하다가 h_flare 높이에서 감속(숙련자일수록 일찍 감속)\n", + " cmd[:, 2] = np.where(seen[:, 2] > h_flare, -1.0, -0.12 + rng.normal(0, 0.05, n))\n", + " spikes = (jk[:, None] & (rng.random((n, 3)) < 0.02)) * rng.choice([-1, 1], (n, 3)) * 0.9\n", + " stick = np.clip(smooth[:, None] * stick + (1 - smooth[:, None]) * cmd + spikes, -1, 1)\n", + " gust = 0.95 * gust + rng.normal(0, c[\"gust\"] * 0.3, (n, 2))\n", + " wind = np.c_[np.full(n, c[\"wind\"]) + gust[:, 0], gust[:, 1], np.zeros(n)]\n", + " vel += (stick * vmax - vel) * DT / 0.6\n", + " vel[landed] = 0.0\n", + " pos = pos + (vel + wind * (~landed)[:, None]) * DT\n", + " touch = (pos[:, 2] <= 0) & ~landed & (t >= LAND_T)\n", + " vz_touch[touch] = -vel[touch, 2]; landed |= touch\n", + " pos[:, 2] = np.maximum(pos[:, 2], 0.0)\n", + " P[i], S[i] = pos, stick\n", + " ATT[i] = np.c_[stick[:, 1] * 28 + gust[:, 1] * 1.5, -stick[:, 0] * 28 + gust[:, 0] * 1.5]\n", + " g = rng.normal(0, c[\"gps\"], P.shape)\n", + " log = {\"t\": T, \"x\": P[..., 0] + g[..., 0], \"y\": P[..., 1] + g[..., 1], \"alt\": P[..., 2] + g[..., 2] * 0.5,\n", + " \"stick_pitch\": S[..., 0], \"stick_roll\": S[..., 1], \"throttle\": S[..., 2], \"roll_deg\": ATT[..., 0], \"pitch_deg\": ATT[..., 1],\n", + " \"tx\": tgt_all[..., 0], \"ty\": tgt_all[..., 1], \"talt\": tgt_all[..., 2], \"true_x\": P[..., 0], \"true_y\": P[..., 1], \"true_alt\": P[..., 2]}\n", + " k_obs = int((OBST_T + 2.5) / DT)\n", + " lateral = np.abs(P[k_obs, :, 1] - P[int(OBST_T / DT), :, 1])\n", + " truth = pd.DataFrame({\"pilot\": pilots[\"pilot\"].values, \"cond\": cond, \"skill\": sk, \"jerky\": jk, \"overcorr\": oc, \"rt_obs\": rt_obs,\n", + " \"near_collision\": lateral < 2.5, \"vz_touch\": np.round(vz_touch, 2), \"hard_landing\": np.nan_to_num(vz_touch, nan=3.0) > 1.6,\n", + " \"attitude_exceed\": (np.abs(ATT) > 35).any(axis=2).sum(axis=0) >= 10})\n", + " risk = truth[[\"near_collision\", \"hard_landing\", \"attitude_exceed\"]].sum(axis=1)\n", + " inst = 40 + 50 * sk - 5 * jk - 5 * oc - 4 * risk + rng.normal(0, 3, n) # 교관 평가 점수(가상)\n", + " truth[\"instructor\"] = np.clip(inst, 0, 100).round(1)\n", + " truth[\"grade\"] = np.where(truth[\"instructor\"] >= 75, \"A\", np.where(truth[\"instructor\"] >= 55, \"B\", \"C\"))\n", + " return log, truth\n", + "\n", + "\n", + "def one(log, j):\n", + " \"\"\"여러 조종자 로그에서 j번째 조종자 1명의 로그(DataFrame)를 꺼낸다.\"\"\"\n", + " return pd.DataFrame({k: (v if v.ndim == 1 else v[:, j]) for k, v in log.items()})\n", + "# ---------------------------------------------------------------------------\n", + "\n", + "# ---- 비행로그 특징 추출 공통 함수(여러 조종자 로그를 한 번에 계산) ----\n", + "from scipy.ndimage import uniform_filter1d\n", + "\n", + "BASE_F = [\"path_rmse\", \"hover_drift\", \"hover_std\", \"alt_err\"]\n", + "ADV_F = BASE_F + [\"obs_rt\", \"obs_lateral\", \"touch_vz\", \"att_exceed\", \"jerk\", \"overshoot\", \"n_risk\"]\n", + "RULES = {\"near_collision\": (\"obs_lateral\", \"<\", 2.5), \"hard_landing\": (\"touch_vz\", \">\", 2.0), \"attitude_exceed\": (\"att_exceed\", \">=\", 10)}\n", + "\n", + "\n", + "def detect(df):\n", + " \"\"\"위험 조작 사건 탐지 규칙: 장애물 2.5초 후 회피 거리 2.5m 미만, 접지 속도 2.0m/s 초과, 자세 35도 초과 1초 이상.\"\"\"\n", + " out = {}\n", + " for ev, (f, op, th) in RULES.items():\n", + " out[ev] = df[f] < th if op == \"<\" else (df[f] > th if op == \">\" else df[f] >= th)\n", + " return pd.DataFrame(out)\n", + "\n", + "\n", + "def features(log):\n", + " \"\"\"경로 이탈도·호버링 안정성(기본) + 장애물 반응·착륙 속도·자세 한계·급조작·과보정(고도화) 특징.\"\"\"\n", + " X, Y, A = (uniform_filter1d(log[k], 9, axis=0) for k in (\"x\", \"y\", \"alt\")) # GPS 잡음 완화(이동평균 0.9초)\n", + " h = np.hypot(X - log[\"tx\"], Y - log[\"ty\"])\n", + " cr, hv = slice(0, 600), slice(620, 850)\n", + " k0, k1 = int(OBST_T / DT), int((OBST_T + 2.5) / DT)\n", + " side = np.sign(log[\"ty\"][k0 + 1] - log[\"ty\"][k0 - 1])\n", + " moved = (log[\"stick_roll\"][k0:k0 + 40] * side) > 0.3\n", + " rt = np.where(moved.any(0), moved.argmax(0) * DT, 4.0)\n", + " vz = -np.gradient(A, DT, axis=0)\n", + " land = np.argmax((A < 0.4) & (T[:, None] >= LAND_T), axis=0)\n", + " touch = np.array([vz[max(i - 6, 0):i - 2, j].mean() if i > 6 else 3.0 for j, i in enumerate(land)])\n", + " ds = np.abs(np.diff(np.stack([log[\"stick_roll\"], log[\"stick_pitch\"]]), axis=1)) / DT\n", + " return pd.DataFrame({\n", + " \"path_rmse\": np.sqrt((h[cr] ** 2).mean(0)), \"hover_drift\": h[hv].mean(0), \"hover_std\": np.hypot(X[hv].std(0), Y[hv].std(0)),\n", + " \"alt_err\": np.abs(A[:850] - log[\"talt\"][:850]).mean(0),\n", + " \"obs_rt\": rt, \"obs_lateral\": np.abs(Y[k1] - Y[k0]), \"touch_vz\": touch,\n", + " \"att_exceed\": (np.maximum(np.abs(log[\"roll_deg\"]), np.abs(log[\"pitch_deg\"])) > 35).sum(0),\n", + " \"jerk\": np.percentile(ds, 95, axis=(0, 1)),\n", + " \"overshoot\": np.clip(np.abs(Y[k0:k0 + 120] - Y[k0]).max(0) - 6.0, 0, None)}).pipe(\n", + " lambda d: d.assign(n_risk=detect(d).sum(axis=1))) # overshoot: 회피 후 목표 6m를 넘어선 거리(과보정)\n", + "\n", + "\n", + "def make_dataset(n_pilots=40, seed=7, conds=(\"day\", \"night\", \"gust\"), flights=1):\n", + " \"\"\"조종자 n명 x 조건 x 반복 비행 -> 비행별 특징 + 정답(교관 점수·등급·위험 사건) 표.\"\"\"\n", + " pilots = make_pilots(n_pilots, seed)\n", + " out = []\n", + " for c in conds:\n", + " for f in range(flights):\n", + " log, tr = fly(pilots, c, seed=seed * 100 + list(COND).index(c) * 10 + f)\n", + " out.append(pd.concat([tr.reset_index(drop=True), features(log)], axis=1).assign(flight=f))\n", + " return pd.concat(out, ignore_index=True)\n", + "\n", + "\n", + "def cond_z(df, ref, cols):\n", + " \"\"\"조건별 기준 집단(ref)의 평균·표준편차로 표준화: 돌풍처럼 어려운 조건에서 받는 불이익을 줄인다.\"\"\"\n", + " z = df.copy()\n", + " z[cols] = z[cols].astype(float)\n", + " for c in df[\"cond\"].unique():\n", + " r = ref[ref[\"cond\"] == c][cols]\n", + " z.loc[df[\"cond\"] == c, cols] = (df.loc[df[\"cond\"] == c, cols] - r.mean()) / (r.std() + 1e-9)\n", + " return z\n", + "\n", + "\n", + "def to_grade(score):\n", + " return np.where(score >= 75, \"A\", np.where(score >= 55, \"B\", \"C\"))\n", + "\n", + "PHASES = [(0, 60, \"path\"), (60, 85, \"hover\"), (85, 100, \"obstacle\"), (100, 120, \"landing\")]\n", + "\n", + "# [교육용가상자료] 조종자 12명 중 가장 점수가 낮은 조종자의 돌풍 비행을 재생\n", + "pilots = make_pilots(12, seed=4)\n", + "log, truth = fly(pilots, \"gust\", seed=21)\n", + "j = int(truth[\"instructor\"].idxmin())\n", + "d = one(log, j)\n", + "FE = features(log).assign(cond=\"gust\")\n", + "f = FE.iloc[j]\n", + "d[\"err\"] = np.hypot(d[\"x\"] - d[\"tx\"], d[\"y\"] - d[\"ty\"])\n", + "d[\"att\"] = np.maximum(d[\"roll_deg\"].abs(), d[\"pitch_deg\"].abs())\n", + "d[\"stick_rate\"] = np.r_[0, np.abs(np.diff(d[\"stick_roll\"])) / DT]\n", + "\n", + "\n", + "def weak_flags(df):\n", + " return {\"경로이탈>3m\": df[\"err\"] > 3.0, \"자세>30도\": df[\"att\"] > 30, \"급조작(변화율>6/s)\": df[\"stick_rate\"] > 6}\n", + "\n", + "\n", + "# 1) 재생 모의: 1초(10프레임)마다 누적 지표 갱신 + 처리시간 측정\n", + "ms, kpis = [], []\n", + "for k in range(10, len(d) + 1, 10):\n", + " t0 = time.perf_counter()\n", + " cur = d.iloc[:k]\n", + " flags = weak_flags(cur)\n", + " kpis.append({\"t\": cur[\"t\"].iloc[-1], \"err_now\": cur[\"err\"].iloc[-1], \"weak_ratio\": np.mean(np.any(np.c_[tuple(flags.values())], axis=1))})\n", + " ms.append((time.perf_counter() - t0) * 1000)\n", + "print(f\"조종자 {truth['pilot'][j]}(교관 {truth['instructor'][j]}점, 등급 {truth['grade'][j]}) 돌풍 비행 재생: 갱신 {len(ms)}회, \"\n", + " f\"처리 평균 {np.mean(ms):.2f} ms, 최대 {np.max(ms):.2f} ms (목표 1초 주기·100 ms 이내 -> {'달성' if np.max(ms) < 100 else '미달'})\")\n", + "\n", + "# 2) 취약 구간 목록(연속 0.5초 이상)\n", + "segs = []\n", + "for name, m in weak_flags(d).items():\n", + " m = m.values.astype(int)\n", + " edges = np.flatnonzero(np.diff(np.r_[0, m, 0]))\n", + " for a, b in zip(edges[::2], edges[1::2]):\n", + " if b - a >= 5:\n", + " ph = next(p for s, e, p in PHASES if s <= d[\"t\"][a] < e)\n", + " segs.append({\"구분\": name, \"시작(s)\": round(d[\"t\"][a], 1), \"길이(s)\": round((b - a) * DT, 1), \"단계\": ph})\n", + "S = pd.DataFrame(segs).sort_values(\"시작(s)\")\n", + "print(f\"\\n[취약 구간 {len(S)}개 - 시간순 상위 10개]\")\n", + "print(S.head(10).to_string(index=False))\n", + "print(\"\\n단계별 취약 시간(s):\", S.groupby(\"단계\")[\"길이(s)\"].sum().round(1).to_dict())\n", + "print(f\"돌발 대응: 장애물 반응 {f['obs_rt']:.1f}s, 2.5초 후 회피거리 {f['obs_lateral']:.1f}m, 접지 속도 {f['touch_vz']:.1f}m/s -> \"\n", + " f\"위험 사건 {', '.join(k for k, v in detect(FE.iloc[[j]]).iloc[0].items() if v) or '없음'}\")\n", + "\n", + "# 3) 대시보드(야간 모드, 구성요소 4종)\n", + "plt.style.use(\"dark_background\")\n", + "fig = plt.figure(figsize=(13, 8))\n", + "ax = fig.add_subplot(2, 2, 1, projection=\"3d\")\n", + "bad = np.any(np.c_[tuple(weak_flags(d).values())], axis=1)\n", + "ax.plot(d[\"tx\"], d[\"ty\"], d[\"talt\"], \"--\", c=\"gray\", lw=1, label=\"course\")\n", + "ax.scatter(d[\"x\"][~bad], d[\"y\"][~bad], d[\"alt\"][~bad], s=2, c=\"deepskyblue\", label=\"normal\")\n", + "ax.scatter(d[\"x\"][bad], d[\"y\"][bad], d[\"alt\"][bad], s=6, c=\"red\", label=\"weak segment\")\n", + "ax.set_xlabel(\"x (m)\"); ax.set_ylabel(\"y (m)\"); ax.set_zlabel(\"alt (m)\"); ax.legend(fontsize=7); ax.set_title(\"3D replay (red = weak)\")\n", + "a2 = fig.add_subplot(2, 2, 2)\n", + "a2.plot(d[\"t\"], d[\"err\"], c=\"lime\", lw=1); a2.axhline(3, c=\"red\", ls=\"--\")\n", + "for s, e, p in PHASES:\n", + " a2.axvspan(s, e, alpha=0.08 if p in (\"path\", \"obstacle\") else 0.16, color=\"white\"); a2.text(s + 1, a2.get_ylim()[1] * 0.9, p, fontsize=8)\n", + "a2.set_title(\"Path deviation (m) by phase\")\n", + "a3 = fig.add_subplot(2, 2, 3)\n", + "a3.plot(d[\"t\"], d[\"stick_roll\"], lw=0.8, label=\"roll\"); a3.plot(d[\"t\"], d[\"stick_pitch\"], lw=0.8, label=\"pitch\"); a3.plot(d[\"t\"], d[\"throttle\"], lw=0.8, label=\"throttle\")\n", + "a3.scatter(d[\"t\"][d[\"stick_rate\"] > 6], d[\"stick_roll\"][d[\"stick_rate\"] > 6], c=\"red\", s=10, label=\"abrupt\")\n", + "a3.legend(fontsize=7, ncol=4); a3.set_title(\"Stick inputs\"); a3.set_xlabel(\"time (s)\")\n", + "a4 = fig.add_subplot(2, 2, 4)\n", + "z = cond_z(FE, FE, ADV_F).iloc[j][[\"path_rmse\", \"hover_std\", \"obs_rt\", \"touch_vz\", \"jerk\", \"overshoot\"]]\n", + "a4.barh(z.index, z.values, color=[\"red\" if v > 1 else \"orange\" if v > 0 else \"deepskyblue\" for v in z.values])\n", + "a4.axvline(0, c=\"white\", lw=0.5); a4.set_title(\"Weakness profile (z-score vs peers, right = worse)\")\n", + "fig.suptitle(f\"Pilot {truth['pilot'][j]} flight replay dashboard (night mode) - training data only\")\n", + "plt.tight_layout(); plt.show()\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 06 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + "조종자 P010(교관 33.7점, 등급 C) 돌풍 비행 재생: 갱신 120회, 처리 평균 0.50 ms, 최대 1.30 ms (목표 1초 주기·100 ms 이내 -> 달성)\n", + "\n", + "[취약 구간 34개 - 시간순 상위 10개]\n", + " 구분 시작(s) 길이(s) 단계\n", + "경로이탈>3m 2.0 2.7 path\n", + "경로이탈>3m 8.6 5.9 path\n", + " 자세>30도 11.5 1.8 path\n", + "경로이탈>3m 14.9 3.4 path\n", + " 자세>30도 17.3 0.5 path\n", + "경로이탈>3m 19.2 4.5 path\n", + " 자세>30도 20.4 1.5 path\n", + "경로이탈>3m 25.6 2.5 path\n", + "경로이탈>3m 29.1 1.2 path\n", + "경로이탈>3m 30.6 4.0 path\n", + "\n", + "단계별 취약 시간(s): {'hover': 27.8, 'landing': 18.5, 'obstacle': 8.0, 'path': 63.7}\n", + "돌발 대응: 장애물 반응 2.1s, 2.5초 후 회피거리 1.1m, 접지 속도 2.7m/s -> 위험 사건 near_collision, hard_landing, attitude_exceed\n", + "```\n", + "
\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 07. 교관이 직접 조종자 성향을 진단하고 피드백을 입력하는 HITL 평가 인터페이스 설계\n", + "**소요시간**: 약 3.0시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "교관이 직접 조종자 성향을 진단하고 피드백을 입력하는 HITL 평가 인터페이스를 설계한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **교관이 AI 제안 등급과 조작 성향 진단을 직접 확인·수정하고 피드백을 입력하는 HITL 평가 인터페이스를 설계한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **AI 제안은 항상 검토 대기로 등록하고 신뢰도가 낮은 제안과 C 등급을 먼저 보도록 순서를 정 수행하고 검증하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `src/hitl/trn01_approval_workflow.py 및 docs/trn01_hitl_guide.md` 형태의 결과물을 완성한다.\n", + "\n", + "### 🔍 평가 및 검증 기준 (PASS 조건)\n", + "#### 미션 판정\n", + "\n", + "**평가 구분:** 필수\n", + "\n", + "**핵심 평가영역:** 지휘 책임 분계선 명확화 및 성급한 자동 단정 방지 체계 부합성\n", + "\n", + "#### 미션 PASS 조건\n", + "\n", + "- 정량 기준: 인간 검토 큐(Queue) 대기시간 및 승인/반려 처리 로깅 성공률 100%\n", + "\n", + "\n", + "- 진행 기준: 승인/반려 인터페이스 동작 및 감사 로그(Audit Log) 생성 검증\n", + "\n", + "\n", + "- 검증 방법: 모의 조작자 승인/반려 시뮬레이션 및 데이터 무결성 검증\n", + "\n", + "\n", + "#### 채점자 판정 메모\n", + "\n", + "> 단순 코드 실행 여부뿐만 아니라 출력된 수치와 근거 자료가 군 실무 요구사항에 부합하는지 면밀히 확인한다.\n", + "\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "\"\"\"AI가 제안한 숙련도 등급·조작 성향을 교관이 직접 확인·수정하고 피드백을 입력하는 HITL 평가 인터페이스를 구현한다. AI는 등급을 확정하지 않으며, 불리한 결정(재교육 지정·자격 보류)은 상세 근거와 두 번째 교관 확인을 요구하고, 모든 결정은 위·변조 확인이 가능한 감사 로그에 남긴다.\"\"\"\n", + "import hashlib\n", + "import json\n", + "import pandas as pd\n", + "\n", + "ACTIONS = {\"CONFIRM\": \"AI 제안 등급 확정\", \"MODIFY\": \"등급 수정\", \"REFLIGHT\": \"추가 비행 후 재평가\", \"RETRAIN\": \"재교육 지정\", \"QUAL_HOLD\": \"자격 부여 보류\"}\n", + "CONF_ORDER = {\"Low\": 0, \"Medium\": 1, \"High\": 2}\n", + "\n", + "\n", + "class ReviewError(ValueError):\n", + " pass\n", + "\n", + "\n", + "class FakeClock:\n", + " t = 0.0\n", + "\n", + " def __call__(self):\n", + " return self.t\n", + "\n", + "\n", + "class ReviewQueue:\n", + " def __init__(self, clock):\n", + " self.items, self.log, self.clock = {}, [], clock\n", + "\n", + " def _record(self, body):\n", + " prev = self.log[-1][\"hash\"] if self.log else \"GENESIS\"\n", + " body = {**body, \"ts\": round(self.clock(), 1)}\n", + " self.log.append({**body, \"prev\": prev, \"hash\": hashlib.sha256((prev + json.dumps(body, ensure_ascii=False, sort_keys=True)).encode()).hexdigest()})\n", + "\n", + " def verify_log(self):\n", + " prev = \"GENESIS\"\n", + " for n, rec in enumerate(self.log, start=1):\n", + " body = {k: v for k, v in rec.items() if k not in (\"prev\", \"hash\")}\n", + " if rec[\"prev\"] != prev or rec[\"hash\"] != hashlib.sha256((prev + json.dumps(body, ensure_ascii=False, sort_keys=True)).encode()).hexdigest():\n", + " return False, n\n", + " prev = rec[\"hash\"]\n", + " return True, -1\n", + "\n", + " def submit(self, pilot, grade, conf, habits, evidence):\n", + " \"\"\"AI 제안 등록: 항상 PENDING으로 시작하며 AI가 확정하지 않는다.\"\"\"\n", + " self.items[pilot] = {\"pilot\": pilot, \"ai_grade\": grade, \"conf\": conf, \"habits\": habits, \"evidence\": evidence,\n", + " \"status\": \"PENDING\", \"final\": None, \"submitted\": self.clock(), \"approvals\": []}\n", + " self._record({\"event\": \"AI_SUGGEST\", \"pilot\": pilot, \"grade\": grade, \"conf\": conf, \"by\": \"AI\"})\n", + "\n", + " def order(self):\n", + " \"\"\"검토 순서: 신뢰도 낮은 제안 -> C 등급 -> 나머지(교관이 판단이 필요한 건부터 본다).\"\"\"\n", + " todo = [i for i in self.items.values() if i[\"status\"] in (\"PENDING\", \"AWAIT_2ND\")]\n", + " return sorted(todo, key=lambda i: (CONF_ORDER[i[\"conf\"]], i[\"ai_grade\"] != \"C\", i[\"pilot\"]))\n", + "\n", + " def decide(self, pilot, action, instructor, reason, new_grade=None):\n", + " if action not in ACTIONS:\n", + " raise ReviewError(f\"허용되지 않은 조치: {action}\")\n", + " if not instructor:\n", + " raise ReviewError(\"교관 ID 필요\")\n", + " it = self.items.get(pilot)\n", + " if it is None or it[\"status\"] == \"FINAL\":\n", + " raise ReviewError(\"검토 대기 건이 아님\")\n", + " if len(reason.strip()) < 10:\n", + " raise ReviewError(\"진단·피드백 10자 이상 필요\")\n", + " if action == \"MODIFY\" and new_grade not in (\"A\", \"B\", \"C\"):\n", + " raise ReviewError(\"수정 등급(A/B/C) 필요\")\n", + " if action in (\"RETRAIN\", \"QUAL_HOLD\") and len(reason.strip()) < 20:\n", + " raise ReviewError(\"불리한 결정은 근거 20자 이상 필요\")\n", + " if action == \"QUAL_HOLD\":\n", + " if instructor in it[\"approvals\"]:\n", + " raise ReviewError(\"두 번째 확인은 다른 교관이 해야 함\")\n", + " it[\"approvals\"].append(instructor)\n", + " self._record({\"event\": \"DECIDE\", \"pilot\": pilot, \"action\": action, \"by\": instructor, \"reason\": reason, \"step\": len(it[\"approvals\"])})\n", + " if len(it[\"approvals\"]) < 2:\n", + " it[\"status\"] = \"AWAIT_2ND\"\n", + " return \"두 번째 교관 확인 대기\"\n", + " else:\n", + " self._record({\"event\": \"DECIDE\", \"pilot\": pilot, \"action\": action, \"by\": instructor, \"reason\": reason, \"new_grade\": new_grade})\n", + " it[\"status\"], it[\"action\"], it[\"decided\"] = \"FINAL\", action, self.clock()\n", + " it[\"final\"] = new_grade if action == \"MODIFY\" else (None if action == \"REFLIGHT\" else it[\"ai_grade\"])\n", + " return \"확정\"\n", + "\n", + "\n", + "clock = FakeClock()\n", + "rq = ReviewQueue(clock)\n", + "# [교육용가상자료] 미션 5 결과에서 넘어온 AI 제안 6건(가명)\n", + "for p, g, c, h, e in [(\"P003\", \"A\", \"High\", [], \"경로 RMSE 하위 10%, 접지 0.4m/s\"), (\"P011\", \"C\", \"High\", [\"급조작\"], \"경착륙 2회, 스틱 변화율 상위 5%\"),\n", + " (\"P017\", \"B\", \"Low\", [\"과보정\"], \"비행별 점수 편차 큼(48~79)\"), (\"P024\", \"C\", \"Medium\", [\"과보정\"], \"장애물 근접 3회\"),\n", + " (\"P030\", \"B\", \"High\", [], \"호버링 안정, 장애물 반응 1.2s\"), (\"P038\", \"C\", \"Low\", [], \"돌풍 비행만 낮음\")]:\n", + " clock.t += 1; rq.submit(p, g, c, h, e)\n", + "print(\"검토 순서:\", [f\"{i['pilot']}({i['ai_grade']}/{i['conf']})\" for i in rq.order()])\n", + "print(\"AI가 확정한 건수:\", sum(i[\"status\"] == \"FINAL\" for i in rq.items.values()))\n", + "\n", + "for args in [(\"P011\", \"RETRAIN\", \"\", \"경착륙 반복, 착륙 절차 재교육\"), (\"P003\", \"CONFIRM\", \"inst01\", \"좋음\"), (\"P017\", \"MODIFY\", \"inst01\", \"편차가 커서 등급 조정함\"),\n", + " (\"P024\", \"RETRAIN\", \"inst02\", \"근접 3회 재교육\"), (\"P003\", \"AUTO\", \"inst01\", \"자동 확정 시도합니다\")]:\n", + " try:\n", + " rq.decide(*args)\n", + " except ReviewError as e:\n", + " print(f\"차단: {args[0]} {args[1]} -> {e}\")\n", + "\n", + "clock.t += 120; print(\"P003:\", rq.decide(\"P003\", \"CONFIRM\", \"inst01\", \"경로·호버링 안정, 돌발 대응 양호 확인\"))\n", + "clock.t += 200; print(\"P017:\", rq.decide(\"P017\", \"REFLIGHT\", \"inst01\", \"점수 편차 큼, 주간 비행 2회 추가 후 재평가\"))\n", + "clock.t += 150; print(\"P011:\", rq.decide(\"P011\", \"RETRAIN\", \"inst02\", \"경착륙 2회와 급조작 확인, 착륙 감속 절차 재교육 지정\"))\n", + "clock.t += 90; print(\"P024 1차:\", rq.decide(\"P024\", \"QUAL_HOLD\", \"inst02\", \"장애물 근접 3회 반복, 회피 기동 숙달 전 자격 보류\"))\n", + "clock.t += 10\n", + "try:\n", + " rq.decide(\"P024\", \"QUAL_HOLD\", \"inst02\", \"같은 교관이 두 번째 확인까지 직접 하려고 시도함\")\n", + "except ReviewError as e:\n", + " print(\"차단: P024 QUAL_HOLD ->\", e)\n", + "clock.t += 300; print(\"P024 2차:\", rq.decide(\"P024\", \"QUAL_HOLD\", \"inst05\", \"영상 재확인 결과 회피 지연 동의, 보류 유지\"))\n", + "clock.t += 60; print(\"P038:\", rq.decide(\"P038\", \"MODIFY\", \"inst03\", \"돌풍 외 비행은 B 수준, 돌풍 대응만 보완\", new_grade=\"B\"))\n", + "\n", + "df = pd.DataFrame(rq.items.values())\n", + "df[\"대기(s)\"] = (df[\"decided\"] - df[\"submitted\"]).round(0)\n", + "print(\"\\n\" + df[[\"pilot\", \"ai_grade\", \"conf\", \"action\", \"final\", \"대기(s)\"]].fillna(\"-\").to_string(index=False))\n", + "fin = df[df[\"status\"] == \"FINAL\"]\n", + "print(f\"교관 결정 {len(fin)}건 중 AI 제안 등급과 다른 결정 {int((fin['final'] != fin['ai_grade']).sum())}건(추가 비행 포함), 미검토 {int((df['status'] != 'FINAL').sum())}건은 대기 유지\")\n", + "print(f\"\\n감사 로그 {len(rq.log)}건 기록(제안 {len(df)}건 + 결정 {len(rq.log) - len(df)}건, 로깅 성공률 100%), 해시 체인 검증: {rq.verify_log()}\")\n", + "rq.log[7][\"reason\"] = \"근거 수정\"\n", + "print(\"8번째 기록 변조 후 검증(False, 깨진 위치):\", rq.verify_log())\n", + "\n", + "form = {\"pilot\": \"P011\", \"strength\": \"(교관 입력)\", \"weakness\": \"착륙 감속 늦음, 급조작\", \"next_training\": \"착륙 절차 반복 3회\", \"instructor\": \"inst02\", \"pilot_ack\": \"(서명)\"}\n", + "print(\"\\n[조종자 피드백 양식]\\n\" + json.dumps(form, ensure_ascii=False))\n", + "print(\"AI 점수는 교관 판단 보조 자료이며 인사·자격 결정의 단독 근거로 쓰지 않는다. 최종 등급의 책임은 결정한 교관에게 있다.\")\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 07 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + "검토 순서: ['P038(C/Low)', 'P017(B/Low)', 'P024(C/Medium)', 'P011(C/High)', 'P003(A/High)', 'P030(B/High)']\n", + "AI가 확정한 건수: 0\n", + "차단: P011 RETRAIN -> 교관 ID 필요\n", + "차단: P003 CONFIRM -> 진단·피드백 10자 이상 필요\n", + "차단: P017 MODIFY -> 수정 등급(A/B/C) 필요\n", + "차단: P024 RETRAIN -> 진단·피드백 10자 이상 필요\n", + "차단: P003 AUTO -> 허용되지 않은 조치: AUTO\n", + "P003: 확정\n", + "P017: 확정\n", + "P011: 확정\n", + "P024 1차: 두 번째 교관 확인 대기\n", + "차단: P024 QUAL_HOLD -> 두 번째 확인은 다른 교관이 해야 함\n", + "P024 2차: 확정\n", + "P038: 확정\n", + "\n", + "pilot ai_grade conf action final 대기(s)\n", + " P003 A High CONFIRM A 125.0\n", + " P011 C High RETRAIN C 474.0\n", + " P017 B Low REFLIGHT - 323.0\n", + " P024 C Medium QUAL_HOLD C 872.0\n", + " P030 B High - - -\n", + " P038 C Low MODIFY B 930.0\n", + "교관 결정 5건 중 AI 제안 등급과 다른 결정 2건(추가 비행 포함), 미검토 1건은 대기 유지\n", + "\n", + "감사 로그 12건 기록(제안 6건 + 결정 6건, 로깅 성공률 100%), 해시 체인 검증: (True, -1)\n", + "8번째 기록 변조 후 검증(False, 깨진 위치): (False, 8)\n", + "\n", + "[조종자 피드백 양식]\n", + "{\"pilot\": \"P011\", \"strength\": \"(교관 입력)\", \"weakness\": \"착륙 감속 늦음, 급조작\", \"next_training\": \"착륙 절차 반복 3회\", \"instructor\": \"inst02\", \"pilot_ack\": \"(서명)\"}\n", + "AI 점수는 교관 판단 보조 자료이며 인사·자격 결정의 단독 근거로 쓰지 않는다. 최종 등급의 책임은 결정한 교관에게 있다.\n", + "```\n", + "
\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 08. 100명 조종자 대량 비행로그 배치 분석 스트레스 테스트 및 성능 프로파일링\n", + "**소요시간**: 약 3.5시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "100명 조종자의 대량 비행로그 배치 분석 스트레스 테스트를 수행한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **조종자 100명의 대량 비행로그 배치 분석에서 처리량 증가와 손상 로그, 처리 중 예외 상황에 대한 복원력을 검증한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **평시 100건, 100명 x 5회 500건, 500% 증가 600건, 손상 로그 5% 혼입, 묶음 처리 예외 상황 주입하여 처리 지연 및 복원력을 시험하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `tests/trn01_stress_test.py 및 스트레스 테스트 분석 보고서` 형태의 결과물을 완성한다.\n", + "\n", + "### 🔍 평가 및 검증 기준 (PASS 조건)\n", + "#### 미션 판정\n", + "\n", + "**평가 구분:** 필수\n", + "\n", + "**핵심 평가영역:** 극단 상황에서의 안전 실패(Fail-safe) 및 기능 축소 작동 적절성\n", + "\n", + "#### 미션 PASS 조건\n", + "\n", + "- 정량 기준: 동시 트래픽 500% 증가 시 시스템 다운 0건, 데이터 유실률 0%\n", + "\n", + "\n", + "- 진행 기준: 대량 시뮬레이션 부하 주입 테스트 통과 및 병목 구간 프로파일링 완료\n", + "\n", + "\n", + "- 검증 방법: 부하 테스트 도구를 통한 처리량(Throughput) 및 메모리 누수 검증\n", + "\n", + "\n", + "#### 채점자 판정 메모\n", + "\n", + "> 단순 코드 실행 여부뿐만 아니라 출력된 수치와 근거 자료가 군 실무 요구사항에 부합하는지 면밀히 확인한다.\n", + "\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "\"\"\"조종자 100명의 대량 비행로그를 배치 분석하는 스트레스 테스트를 수행한다. 평시 100건에서 500% 증가(600건), 손상 로그 혼입, 묶음 처리 중 예외 상황을 주입해 시스템 다운 0건·로그 유실 0건(채점+격리=전체)과 처리량·메모리·병목 함수를 측정하고, 예외 시 1건씩 처리로 기능을 축소하는 안전 실패 동작을 확인한다.\"\"\"\n", + "import cProfile\n", + "import io\n", + "import pstats\n", + "import time\n", + "import tracemalloc\n", + "import numpy as np\n", + "import pandas as pd\n", + "import matplotlib.pyplot as plt\n", + "from sklearn.linear_model import Ridge\n", + "\n", + "# ---------------------------------------------------------------------------\n", + "# [교육용가상자료] 드론 조종 시뮬레이터 비행로그 생성기 (실제 훈련생·장비 정보 없음, 조종자는 가명 P001~)\n", + "# 비행 1회 = 120초(10Hz): 경로 비행(0~60초) -> 호버링(60~85초) -> 돌발 장애물 회피(85초) -> 비상 착륙(100초 지시)\n", + "# 조종자마다 숨은 숙련도(skill)와 조작 습관(급조작 jerky, 과보정 overcorr)이 있고, 교관 점수는 이를 반영해 만든다.\n", + "# ---------------------------------------------------------------------------\n", + "DT, STEPS = 0.1, 1200\n", + "T = np.arange(STEPS) * DT\n", + "COND = {\"day\": dict(wind=0.4, gust=0.0, gps=0.25, delay=0.0), \"night\": dict(wind=0.4, gust=0.0, gps=0.5, delay=0.15),\n", + " \"gust\": dict(wind=1.2, gust=2.5, gps=0.25, delay=0.0)}\n", + "OBST_T, LAND_T = 85.0, 100.0\n", + "\n", + "\n", + "def make_pilots(n=40, seed=7):\n", + " rng = np.random.default_rng(seed)\n", + " skill = rng.beta(2.2, 2.0, n)\n", + " return pd.DataFrame({\"pilot\": [f\"P{i + 1:03d}\" for i in range(n)], \"skill\": skill,\n", + " \"jerky\": rng.random(n) < 0.15 + 0.35 * (1 - skill), \"overcorr\": rng.random(n) < 0.15 + 0.35 * (1 - skill)})\n", + "\n", + "\n", + "def target_path(t):\n", + " \"\"\"사각형 경로(40x30m, 고도 20m)를 3m/s로 비행 -> 60초 지점에서 호버링 -> 착륙 지시 후 고도 0.\"\"\"\n", + " s = np.minimum(t, 60.0) * 3.0 % 140.0\n", + " x = np.select([s < 40, s < 70, s < 110], [s, 40.0, 40 - (s - 70)], 0.0)\n", + " y = np.select([s < 40, s < 70, s < 110], [0.0, s - 40, 30.0], 30 - (s - 110))\n", + " z = np.where(t >= LAND_T, 0.0, 20.0)\n", + " return np.stack([x, y, z], -1)\n", + "\n", + "\n", + "def fly(pilots, cond=\"day\", seed=0, delay_extra=0.0, obstacle_side=None):\n", + " \"\"\"여러 조종자의 비행 1회를 동시에 모의한다(벡터화). 반환: 로그 배열 dict, 비행별 정답 정보 DataFrame.\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " c = COND[cond]\n", + " delay_extra = delay_extra + c[\"delay\"]\n", + " n = len(pilots)\n", + " sk, jk, oc = pilots[\"skill\"].values, pilots[\"jerky\"].values, pilots[\"overcorr\"].values\n", + " gain = (0.5 + 1.2 * sk) * np.where(oc, 1.5, 1.0)\n", + " damp = np.where(oc, 0.25, 0.6 + 0.3 * sk)\n", + " noise = 0.03 + 0.35 * (1 - sk)\n", + " smooth = np.where(jk, 0.25, 0.75)\n", + " delay = np.round((0.25 + 0.35 * (1 - sk) + delay_extra) / DT).astype(int)\n", + " rt_obs = np.clip(0.5 + 1.5 * (1 - sk) + rng.normal(0, 0.2, n) + delay_extra, 0.3, 3.5)\n", + " h_flare = np.clip(0.8 + 5.0 * sk - 1.5 * oc + rng.normal(0, 0.4, n), 0.3, None)\n", + " side = rng.choice([-1, 1], n) if obstacle_side is None else np.full(n, obstacle_side)\n", + " pos = np.tile(target_path(np.zeros(1))[0], (n, 1)).astype(float); vel = np.zeros((n, 3)); stick = np.zeros((n, 3))\n", + " hist = np.zeros((STEPS, n, 3)); P = np.zeros((STEPS, n, 3)); S = np.zeros((STEPS, n, 3)); ATT = np.zeros((STEPS, n, 2))\n", + " tgt_all = np.zeros((STEPS, n, 3)); vz_touch = np.full(n, np.nan); landed = np.zeros(n, bool)\n", + " vmax = np.array([5.0, 5.0, 3.0])\n", + " gust = np.zeros((n, 2))\n", + " for i, t in enumerate(T):\n", + " tgt = np.tile(target_path(np.array([t]))[0], (n, 1))\n", + " if t >= OBST_T: # 장애물: 6m 옆으로 회피해야 함(조종자는 반응시간 이후 인지)\n", + " tgt_all[i] = tgt + np.c_[np.zeros(n), side * 6.0, np.zeros(n)]\n", + " tgt[:, 1] += np.where(t >= OBST_T + rt_obs, side * 6.0, 0.0)\n", + " else:\n", + " tgt_all[i] = tgt\n", + " hist[i] = pos\n", + " seen = hist[np.maximum(i - delay, 0), np.arange(n)] # 반응 지연만큼 늦게 본 위치\n", + " cmd = np.clip(gain[:, None] * (tgt - seen) / 4.0 - damp[:, None] * vel / vmax + rng.normal(0, 1, (n, 3)) * noise[:, None], -1, 1)\n", + " if t >= LAND_T: # 비상 착륙: 하강하다가 h_flare 높이에서 감속(숙련자일수록 일찍 감속)\n", + " cmd[:, 2] = np.where(seen[:, 2] > h_flare, -1.0, -0.12 + rng.normal(0, 0.05, n))\n", + " spikes = (jk[:, None] & (rng.random((n, 3)) < 0.02)) * rng.choice([-1, 1], (n, 3)) * 0.9\n", + " stick = np.clip(smooth[:, None] * stick + (1 - smooth[:, None]) * cmd + spikes, -1, 1)\n", + " gust = 0.95 * gust + rng.normal(0, c[\"gust\"] * 0.3, (n, 2))\n", + " wind = np.c_[np.full(n, c[\"wind\"]) + gust[:, 0], gust[:, 1], np.zeros(n)]\n", + " vel += (stick * vmax - vel) * DT / 0.6\n", + " vel[landed] = 0.0\n", + " pos = pos + (vel + wind * (~landed)[:, None]) * DT\n", + " touch = (pos[:, 2] <= 0) & ~landed & (t >= LAND_T)\n", + " vz_touch[touch] = -vel[touch, 2]; landed |= touch\n", + " pos[:, 2] = np.maximum(pos[:, 2], 0.0)\n", + " P[i], S[i] = pos, stick\n", + " ATT[i] = np.c_[stick[:, 1] * 28 + gust[:, 1] * 1.5, -stick[:, 0] * 28 + gust[:, 0] * 1.5]\n", + " g = rng.normal(0, c[\"gps\"], P.shape)\n", + " log = {\"t\": T, \"x\": P[..., 0] + g[..., 0], \"y\": P[..., 1] + g[..., 1], \"alt\": P[..., 2] + g[..., 2] * 0.5,\n", + " \"stick_pitch\": S[..., 0], \"stick_roll\": S[..., 1], \"throttle\": S[..., 2], \"roll_deg\": ATT[..., 0], \"pitch_deg\": ATT[..., 1],\n", + " \"tx\": tgt_all[..., 0], \"ty\": tgt_all[..., 1], \"talt\": tgt_all[..., 2], \"true_x\": P[..., 0], \"true_y\": P[..., 1], \"true_alt\": P[..., 2]}\n", + " k_obs = int((OBST_T + 2.5) / DT)\n", + " lateral = np.abs(P[k_obs, :, 1] - P[int(OBST_T / DT), :, 1])\n", + " truth = pd.DataFrame({\"pilot\": pilots[\"pilot\"].values, \"cond\": cond, \"skill\": sk, \"jerky\": jk, \"overcorr\": oc, \"rt_obs\": rt_obs,\n", + " \"near_collision\": lateral < 2.5, \"vz_touch\": np.round(vz_touch, 2), \"hard_landing\": np.nan_to_num(vz_touch, nan=3.0) > 1.6,\n", + " \"attitude_exceed\": (np.abs(ATT) > 35).any(axis=2).sum(axis=0) >= 10})\n", + " risk = truth[[\"near_collision\", \"hard_landing\", \"attitude_exceed\"]].sum(axis=1)\n", + " inst = 40 + 50 * sk - 5 * jk - 5 * oc - 4 * risk + rng.normal(0, 3, n) # 교관 평가 점수(가상)\n", + " truth[\"instructor\"] = np.clip(inst, 0, 100).round(1)\n", + " truth[\"grade\"] = np.where(truth[\"instructor\"] >= 75, \"A\", np.where(truth[\"instructor\"] >= 55, \"B\", \"C\"))\n", + " return log, truth\n", + "\n", + "\n", + "def one(log, j):\n", + " \"\"\"여러 조종자 로그에서 j번째 조종자 1명의 로그(DataFrame)를 꺼낸다.\"\"\"\n", + " return pd.DataFrame({k: (v if v.ndim == 1 else v[:, j]) for k, v in log.items()})\n", + "# ---------------------------------------------------------------------------\n", + "\n", + "# ---- 비행로그 특징 추출 공통 함수(여러 조종자 로그를 한 번에 계산) ----\n", + "from scipy.ndimage import uniform_filter1d\n", + "\n", + "BASE_F = [\"path_rmse\", \"hover_drift\", \"hover_std\", \"alt_err\"]\n", + "ADV_F = BASE_F + [\"obs_rt\", \"obs_lateral\", \"touch_vz\", \"att_exceed\", \"jerk\", \"overshoot\", \"n_risk\"]\n", + "RULES = {\"near_collision\": (\"obs_lateral\", \"<\", 2.5), \"hard_landing\": (\"touch_vz\", \">\", 2.0), \"attitude_exceed\": (\"att_exceed\", \">=\", 10)}\n", + "\n", + "\n", + "def detect(df):\n", + " \"\"\"위험 조작 사건 탐지 규칙: 장애물 2.5초 후 회피 거리 2.5m 미만, 접지 속도 2.0m/s 초과, 자세 35도 초과 1초 이상.\"\"\"\n", + " out = {}\n", + " for ev, (f, op, th) in RULES.items():\n", + " out[ev] = df[f] < th if op == \"<\" else (df[f] > th if op == \">\" else df[f] >= th)\n", + " return pd.DataFrame(out)\n", + "\n", + "\n", + "def features(log):\n", + " \"\"\"경로 이탈도·호버링 안정성(기본) + 장애물 반응·착륙 속도·자세 한계·급조작·과보정(고도화) 특징.\"\"\"\n", + " X, Y, A = (uniform_filter1d(log[k], 9, axis=0) for k in (\"x\", \"y\", \"alt\")) # GPS 잡음 완화(이동평균 0.9초)\n", + " h = np.hypot(X - log[\"tx\"], Y - log[\"ty\"])\n", + " cr, hv = slice(0, 600), slice(620, 850)\n", + " k0, k1 = int(OBST_T / DT), int((OBST_T + 2.5) / DT)\n", + " side = np.sign(log[\"ty\"][k0 + 1] - log[\"ty\"][k0 - 1])\n", + " moved = (log[\"stick_roll\"][k0:k0 + 40] * side) > 0.3\n", + " rt = np.where(moved.any(0), moved.argmax(0) * DT, 4.0)\n", + " vz = -np.gradient(A, DT, axis=0)\n", + " land = np.argmax((A < 0.4) & (T[:, None] >= LAND_T), axis=0)\n", + " touch = np.array([vz[max(i - 6, 0):i - 2, j].mean() if i > 6 else 3.0 for j, i in enumerate(land)])\n", + " ds = np.abs(np.diff(np.stack([log[\"stick_roll\"], log[\"stick_pitch\"]]), axis=1)) / DT\n", + " return pd.DataFrame({\n", + " \"path_rmse\": np.sqrt((h[cr] ** 2).mean(0)), \"hover_drift\": h[hv].mean(0), \"hover_std\": np.hypot(X[hv].std(0), Y[hv].std(0)),\n", + " \"alt_err\": np.abs(A[:850] - log[\"talt\"][:850]).mean(0),\n", + " \"obs_rt\": rt, \"obs_lateral\": np.abs(Y[k1] - Y[k0]), \"touch_vz\": touch,\n", + " \"att_exceed\": (np.maximum(np.abs(log[\"roll_deg\"]), np.abs(log[\"pitch_deg\"])) > 35).sum(0),\n", + " \"jerk\": np.percentile(ds, 95, axis=(0, 1)),\n", + " \"overshoot\": np.clip(np.abs(Y[k0:k0 + 120] - Y[k0]).max(0) - 6.0, 0, None)}).pipe(\n", + " lambda d: d.assign(n_risk=detect(d).sum(axis=1))) # overshoot: 회피 후 목표 6m를 넘어선 거리(과보정)\n", + "\n", + "\n", + "def make_dataset(n_pilots=40, seed=7, conds=(\"day\", \"night\", \"gust\"), flights=1):\n", + " \"\"\"조종자 n명 x 조건 x 반복 비행 -> 비행별 특징 + 정답(교관 점수·등급·위험 사건) 표.\"\"\"\n", + " pilots = make_pilots(n_pilots, seed)\n", + " out = []\n", + " for c in conds:\n", + " for f in range(flights):\n", + " log, tr = fly(pilots, c, seed=seed * 100 + list(COND).index(c) * 10 + f)\n", + " out.append(pd.concat([tr.reset_index(drop=True), features(log)], axis=1).assign(flight=f))\n", + " return pd.concat(out, ignore_index=True)\n", + "\n", + "\n", + "def cond_z(df, ref, cols):\n", + " \"\"\"조건별 기준 집단(ref)의 평균·표준편차로 표준화: 돌풍처럼 어려운 조건에서 받는 불이익을 줄인다.\"\"\"\n", + " z = df.copy()\n", + " z[cols] = z[cols].astype(float)\n", + " for c in df[\"cond\"].unique():\n", + " r = ref[ref[\"cond\"] == c][cols]\n", + " z.loc[df[\"cond\"] == c, cols] = (df.loc[df[\"cond\"] == c, cols] - r.mean()) / (r.std() + 1e-9)\n", + " return z\n", + "\n", + "\n", + "def to_grade(score):\n", + " return np.where(score >= 75, \"A\", np.where(score >= 55, \"B\", \"C\"))\n", + "\n", + "KEYS = [\"t\", \"x\", \"y\", \"alt\", \"stick_pitch\", \"stick_roll\", \"throttle\", \"roll_deg\", \"pitch_deg\", \"tx\", \"ty\", \"talt\"]\n", + "\n", + "\n", + "def check_log(L):\n", + " \"\"\"로그 1건 검증. 문제가 있으면 사유 문자열, 없으면 None.\"\"\"\n", + " if any(k not in L for k in KEYS):\n", + " return \"필드 누락\"\n", + " if len(L[\"t\"]) != STEPS:\n", + " return f\"길이 오류({len(L['t'])})\"\n", + " if np.isnan(L[\"x\"]).mean() > 0.2:\n", + " return \"결측 20% 초과\"\n", + " if np.any(np.diff(L[\"t\"]) <= 0):\n", + " return \"시각 역행\"\n", + " if np.abs(L[\"stick_roll\"]).max() > 1.0001:\n", + " return \"스틱 범위 초과\"\n", + " return None\n", + "\n", + "\n", + "def score_chunk(logs, conds, eng, ref):\n", + " stacked = {k: np.stack([L[k] for L in logs], axis=1) if k != \"t\" else T for k in KEYS}\n", + " stacked = {k: (pd.DataFrame(v).interpolate(limit_direction=\"both\").values if v.ndim == 2 else v) for k, v in stacked.items()}\n", + " f = features(stacked).assign(cond=conds)\n", + " return np.clip(eng.predict(cond_z(f, ref, ADV_F)[ADV_F]), 0, 100)\n", + "\n", + "\n", + "def run_batch(logs, conds, eng, ref, chunk=100, fail_chunk=None):\n", + " scored, quarantine, degraded, down = {}, {}, 0, 0\n", + " try:\n", + " ok = []\n", + " for i, L in enumerate(logs):\n", + " why = check_log(L)\n", + " (quarantine.__setitem__(i, why) if why else ok.append(i))\n", + " for c0 in range(0, len(ok), chunk):\n", + " idx = ok[c0:c0 + chunk]\n", + " try:\n", + " if fail_chunk is not None and c0 // chunk == fail_chunk:\n", + " raise MemoryError(\"모의 자원 부족\")\n", + " for i, s in zip(idx, score_chunk([logs[i] for i in idx], [conds[i] for i in idx], eng, ref)):\n", + " scored[i] = s\n", + " except Exception: # 기능 축소: 1건씩 처리, 그래도 실패하면 격리\n", + " degraded += 1\n", + " for i in idx:\n", + " try:\n", + " scored[i] = score_chunk([logs[i]], [conds[i]], eng, ref)[0]\n", + " except Exception as e:\n", + " quarantine[i] = f\"처리 실패({type(e).__name__})\"\n", + " except Exception as e:\n", + " down = 1; print(\"다운:\", e)\n", + " return scored, quarantine, degraded, down\n", + "\n", + "\n", + "# 엔진 준비(미션 4 방식)\n", + "train = make_dataset(40, seed=7)\n", + "eng = Ridge(alpha=1.0).fit(cond_z(train, train, ADV_F)[ADV_F], train[\"instructor\"])\n", + "\n", + "# [교육용가상자료] 조종자 100명 x 비행 6회 = 로그 600건 생성\n", + "pilots = make_pilots(100, seed=12)\n", + "LOGS, CONDS = [], []\n", + "for f in range(6):\n", + " cond = list(COND)[f % 3]\n", + " log, _ = fly(pilots, cond, seed=900 + f)\n", + " for j in range(len(pilots)):\n", + " LOGS.append({k: (v if v.ndim == 1 else v[:, j].copy()) for k, v in log.items() if k in KEYS}); CONDS.append(cond)\n", + "rng = np.random.default_rng(5)\n", + "BAD = []\n", + "for i in rng.choice(len(LOGS), 30, replace=False): # 손상 로그 5% 주입\n", + " L = {k: v.copy() for k, v in LOGS[i].items()}\n", + " kind = i % 4\n", + " if kind == 0:\n", + " del L[\"throttle\"]\n", + " elif kind == 1:\n", + " L[\"x\"][:400] = np.nan\n", + " elif kind == 2:\n", + " L = {k: v[:500] for k, v in L.items()}\n", + " else:\n", + " L[\"stick_roll\"][100] = 7.0\n", + " BAD.append((i, L))\n", + "CORRUPT = list(LOGS)\n", + "for i, L in BAD:\n", + " CORRUPT[i] = L\n", + "\n", + "CASES = [(\"평시 100건\", LOGS[:100], CONDS[:100], None), (\"100명 x 5회 500건\", LOGS[:500], CONDS[:500], None),\n", + " (\"500% 증가 600건\", LOGS, CONDS, None), (\"600건 + 손상 5%\", CORRUPT, CONDS, None),\n", + " (\"600건 + 손상 + 묶음 예외\", CORRUPT, CONDS, 2)]\n", + "rows = []\n", + "for name, logs, conds, fail in CASES:\n", + " t0 = time.perf_counter()\n", + " sc, qu, dg, dn = run_batch(logs, conds, eng, train, fail_chunk=fail)\n", + " dt = time.perf_counter() - t0\n", + " rows.append({\"상황\": name, \"로그\": len(logs), \"채점\": len(sc), \"격리\": len(qu), \"유실\": len(logs) - len(sc) - len(qu),\n", + " \"다운\": dn, \"기능축소 묶음\": dg, \"처리량(건/s)\": round(len(logs) / dt), \"소요(s)\": round(dt, 2)})\n", + "R = pd.DataFrame(rows)\n", + "print(R.to_string(index=False))\n", + "print(f\"\\n시스템 다운 {R['다운'].sum()}건, 로그 유실 {R['유실'].sum()}건(채점+격리=전체), 최저 처리량 {R['처리량(건/s)'].min()}건/s\")\n", + "_, qu, _, _ = run_batch(CORRUPT, CONDS, eng, train)\n", + "print(\"격리 사유:\", pd.Series(list(qu.values())).value_counts().to_dict(), f\"/ 주입 {len(BAD)}건\")\n", + "\n", + "tracemalloc.start()\n", + "mem = []\n", + "for k in range(3):\n", + " run_batch(LOGS[:200], CONDS[:200], eng, train)\n", + " mem.append(tracemalloc.get_traced_memory()[0] / 1e6)\n", + "peak = tracemalloc.get_traced_memory()[1] / 1e6\n", + "tracemalloc.stop()\n", + "print(f\"200건 처리 최대 메모리 {peak:.0f} MB, 반복 후 잔류 {[round(m, 1) for m in mem]} MB -> {'누수 의심' if mem[-1] > mem[0] * 1.5 + 1 else '누수 징후 없음'}\")\n", + "\n", + "pr = cProfile.Profile(); pr.enable()\n", + "run_batch(LOGS[:300], CONDS[:300], eng, train)\n", + "pr.disable()\n", + "s = io.StringIO(); pstats.Stats(pr, stream=s).sort_stats(\"cumulative\").print_stats(30)\n", + "top = [ln.split() for ln in s.getvalue().splitlines() if ln.strip()[:1].isdigit() and \".py\" in ln and \"m08\" in ln.split()[-1]]\n", + "print(\"[누적 시간 상위 함수(이 파일)]\")\n", + "for f in top[:4]:\n", + " print(f\" {f[0]:>6}회 {f[3]}s {f[-1].split(':')[-1]}\")\n", + "print(\"개선 방향: 비행별 보간(DataFrame.interpolate)을 numpy 일괄 보간으로 바꾸고, 묶음 크기를 메모리에 맞춰 조정한다.\")\n", + "\n", + "plt.figure(figsize=(8, 4))\n", + "plt.bar([f\"case{i + 1}\" for i in range(len(R))], R[\"처리량(건/s)\"], color=[\"tab:blue\"] * 3 + [\"tab:orange\", \"tab:red\"])\n", + "plt.ylabel(\"logs per second\"); plt.title(\"Batch analysis stress test throughput (synthetic)\")\n", + "plt.tight_layout(); plt.show()\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 08 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + " 상황 로그 채점 격리 유실 다운 기능축소 묶음 처리량(건/s) 소요(s)\n", + " 평시 100건 100 100 0 0 0 0 1297 0.08\n", + " 100명 x 5회 500건 500 500 0 0 0 0 1290 0.39\n", + " 500% 증가 600건 600 600 0 0 0 0 1214 0.49\n", + " 600건 + 손상 5% 600 570 30 0 0 0 1258 0.48\n", + "600건 + 손상 + 묶음 예외 600 570 30 0 0 1 264 2.28\n", + "\n", + "시스템 다운 0건, 로그 유실 0건(채점+격리=전체), 최저 처리량 264건/s\n", + "격리 사유: {'스틱 범위 초과': 10, '필드 누락': 8, '길이 오류(500)': 6, '결측 20% 초과': 6} / 주입 30건\n", + "200건 처리 최대 메모리 22 MB, 반복 후 잔류 [0.0, 0.0, 0.0] MB -> 누수 징후 없음\n", + "[누적 시간 상위 함수(이 파일)]\n", + " 1회 0.398s 193(run_batch)\n", + " 3회 0.376s 186(score_chunk)\n", + " 3회 0.171s 188()\n", + " 3회 0.088s 155(cond_z)\n", + "개선 방향: 비행별 보간(DataFrame.interpolate)을 numpy 일괄 보간으로 바꾸고, 묶음 크기를 메모리에 맞춰 조정한다.\n", + "```\n", + "
\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 09. AAR 기반 교관 평가 점수와 AI 숙련도 점수 간 편차 분석 및 가중치 재튜닝\n", + "**소요시간**: 약 3.0시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "AAR 기반 교관 평가 점수와 AI 점수 간 편차를 분석하고 가중치를 재보정한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **AAR로 교관 평가 점수와 AI 점수의 편차를 분석해 원인을 규명하고 채점 가중치를 재튜닝한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **주간 비행만으로 맞춘 초기 AI를 한 기수 훈련에 적용해 조건별·위험 사건 수별·습관별 편차를 분해하고 원인 정리하고 규격화하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `docs/trn01_aar_report.md 및 configs/trn01_tuned_params.yaml` 형태의 결과물을 완성한다.\n", + "\n", + "### 🔍 평가 및 검증 기준 (PASS 조건)\n", + "#### 미션 판정\n", + "\n", + "**평가 구분:** 필수\n", + "\n", + "**핵심 평가영역:** 오류 원인 규명의 객관성 및 개선 조치의 논리적 타당성\n", + "\n", + "#### 미션 PASS 조건\n", + "\n", + "- 정량 기준: AAR 피드백 적용 후 종합 정확도 10%p 이상 향상, 재발 오류 0건\n", + "\n", + "\n", + "- 진행 기준: AAR 보고서 작성 및 파라미터 튜닝 전후 회귀 테스트 통과\n", + "\n", + "\n", + "- 검증 방법: 회귀 테스트(Regression Test) 스위트 전체 자동 실행 및 비교\n", + "\n", + "\n", + "#### 채점자 판정 메모\n", + "\n", + "> 단순 코드 실행 여부뿐만 아니라 출력된 수치와 근거 자료가 군 실무 요구사항에 부합하는지 면밀히 확인한다.\n", + "\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "\"\"\"AAR(사후검토)로 교관 평가 점수와 AI 숙련도 점수의 편차를 비행 조건·위험 사건·조작 습관별로 분석해 원인을 규명하고, 특징 구성·조건 표준화·가중치(규제 강도)를 격자 탐색으로 재튜닝한 뒤 다른 조종자 집단으로 회귀 테스트해 개선 전후를 비교한다.\"\"\"\n", + "import numpy as np\n", + "import pandas as pd\n", + "import matplotlib.pyplot as plt\n", + "from sklearn.linear_model import Ridge\n", + "\n", + "# ---------------------------------------------------------------------------\n", + "# [교육용가상자료] 드론 조종 시뮬레이터 비행로그 생성기 (실제 훈련생·장비 정보 없음, 조종자는 가명 P001~)\n", + "# 비행 1회 = 120초(10Hz): 경로 비행(0~60초) -> 호버링(60~85초) -> 돌발 장애물 회피(85초) -> 비상 착륙(100초 지시)\n", + "# 조종자마다 숨은 숙련도(skill)와 조작 습관(급조작 jerky, 과보정 overcorr)이 있고, 교관 점수는 이를 반영해 만든다.\n", + "# ---------------------------------------------------------------------------\n", + "DT, STEPS = 0.1, 1200\n", + "T = np.arange(STEPS) * DT\n", + "COND = {\"day\": dict(wind=0.4, gust=0.0, gps=0.25, delay=0.0), \"night\": dict(wind=0.4, gust=0.0, gps=0.5, delay=0.15),\n", + " \"gust\": dict(wind=1.2, gust=2.5, gps=0.25, delay=0.0)}\n", + "OBST_T, LAND_T = 85.0, 100.0\n", + "\n", + "\n", + "def make_pilots(n=40, seed=7):\n", + " rng = np.random.default_rng(seed)\n", + " skill = rng.beta(2.2, 2.0, n)\n", + " return pd.DataFrame({\"pilot\": [f\"P{i + 1:03d}\" for i in range(n)], \"skill\": skill,\n", + " \"jerky\": rng.random(n) < 0.15 + 0.35 * (1 - skill), \"overcorr\": rng.random(n) < 0.15 + 0.35 * (1 - skill)})\n", + "\n", + "\n", + "def target_path(t):\n", + " \"\"\"사각형 경로(40x30m, 고도 20m)를 3m/s로 비행 -> 60초 지점에서 호버링 -> 착륙 지시 후 고도 0.\"\"\"\n", + " s = np.minimum(t, 60.0) * 3.0 % 140.0\n", + " x = np.select([s < 40, s < 70, s < 110], [s, 40.0, 40 - (s - 70)], 0.0)\n", + " y = np.select([s < 40, s < 70, s < 110], [0.0, s - 40, 30.0], 30 - (s - 110))\n", + " z = np.where(t >= LAND_T, 0.0, 20.0)\n", + " return np.stack([x, y, z], -1)\n", + "\n", + "\n", + "def fly(pilots, cond=\"day\", seed=0, delay_extra=0.0, obstacle_side=None):\n", + " \"\"\"여러 조종자의 비행 1회를 동시에 모의한다(벡터화). 반환: 로그 배열 dict, 비행별 정답 정보 DataFrame.\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " c = COND[cond]\n", + " delay_extra = delay_extra + c[\"delay\"]\n", + " n = len(pilots)\n", + " sk, jk, oc = pilots[\"skill\"].values, pilots[\"jerky\"].values, pilots[\"overcorr\"].values\n", + " gain = (0.5 + 1.2 * sk) * np.where(oc, 1.5, 1.0)\n", + " damp = np.where(oc, 0.25, 0.6 + 0.3 * sk)\n", + " noise = 0.03 + 0.35 * (1 - sk)\n", + " smooth = np.where(jk, 0.25, 0.75)\n", + " delay = np.round((0.25 + 0.35 * (1 - sk) + delay_extra) / DT).astype(int)\n", + " rt_obs = np.clip(0.5 + 1.5 * (1 - sk) + rng.normal(0, 0.2, n) + delay_extra, 0.3, 3.5)\n", + " h_flare = np.clip(0.8 + 5.0 * sk - 1.5 * oc + rng.normal(0, 0.4, n), 0.3, None)\n", + " side = rng.choice([-1, 1], n) if obstacle_side is None else np.full(n, obstacle_side)\n", + " pos = np.tile(target_path(np.zeros(1))[0], (n, 1)).astype(float); vel = np.zeros((n, 3)); stick = np.zeros((n, 3))\n", + " hist = np.zeros((STEPS, n, 3)); P = np.zeros((STEPS, n, 3)); S = np.zeros((STEPS, n, 3)); ATT = np.zeros((STEPS, n, 2))\n", + " tgt_all = np.zeros((STEPS, n, 3)); vz_touch = np.full(n, np.nan); landed = np.zeros(n, bool)\n", + " vmax = np.array([5.0, 5.0, 3.0])\n", + " gust = np.zeros((n, 2))\n", + " for i, t in enumerate(T):\n", + " tgt = np.tile(target_path(np.array([t]))[0], (n, 1))\n", + " if t >= OBST_T: # 장애물: 6m 옆으로 회피해야 함(조종자는 반응시간 이후 인지)\n", + " tgt_all[i] = tgt + np.c_[np.zeros(n), side * 6.0, np.zeros(n)]\n", + " tgt[:, 1] += np.where(t >= OBST_T + rt_obs, side * 6.0, 0.0)\n", + " else:\n", + " tgt_all[i] = tgt\n", + " hist[i] = pos\n", + " seen = hist[np.maximum(i - delay, 0), np.arange(n)] # 반응 지연만큼 늦게 본 위치\n", + " cmd = np.clip(gain[:, None] * (tgt - seen) / 4.0 - damp[:, None] * vel / vmax + rng.normal(0, 1, (n, 3)) * noise[:, None], -1, 1)\n", + " if t >= LAND_T: # 비상 착륙: 하강하다가 h_flare 높이에서 감속(숙련자일수록 일찍 감속)\n", + " cmd[:, 2] = np.where(seen[:, 2] > h_flare, -1.0, -0.12 + rng.normal(0, 0.05, n))\n", + " spikes = (jk[:, None] & (rng.random((n, 3)) < 0.02)) * rng.choice([-1, 1], (n, 3)) * 0.9\n", + " stick = np.clip(smooth[:, None] * stick + (1 - smooth[:, None]) * cmd + spikes, -1, 1)\n", + " gust = 0.95 * gust + rng.normal(0, c[\"gust\"] * 0.3, (n, 2))\n", + " wind = np.c_[np.full(n, c[\"wind\"]) + gust[:, 0], gust[:, 1], np.zeros(n)]\n", + " vel += (stick * vmax - vel) * DT / 0.6\n", + " vel[landed] = 0.0\n", + " pos = pos + (vel + wind * (~landed)[:, None]) * DT\n", + " touch = (pos[:, 2] <= 0) & ~landed & (t >= LAND_T)\n", + " vz_touch[touch] = -vel[touch, 2]; landed |= touch\n", + " pos[:, 2] = np.maximum(pos[:, 2], 0.0)\n", + " P[i], S[i] = pos, stick\n", + " ATT[i] = np.c_[stick[:, 1] * 28 + gust[:, 1] * 1.5, -stick[:, 0] * 28 + gust[:, 0] * 1.5]\n", + " g = rng.normal(0, c[\"gps\"], P.shape)\n", + " log = {\"t\": T, \"x\": P[..., 0] + g[..., 0], \"y\": P[..., 1] + g[..., 1], \"alt\": P[..., 2] + g[..., 2] * 0.5,\n", + " \"stick_pitch\": S[..., 0], \"stick_roll\": S[..., 1], \"throttle\": S[..., 2], \"roll_deg\": ATT[..., 0], \"pitch_deg\": ATT[..., 1],\n", + " \"tx\": tgt_all[..., 0], \"ty\": tgt_all[..., 1], \"talt\": tgt_all[..., 2], \"true_x\": P[..., 0], \"true_y\": P[..., 1], \"true_alt\": P[..., 2]}\n", + " k_obs = int((OBST_T + 2.5) / DT)\n", + " lateral = np.abs(P[k_obs, :, 1] - P[int(OBST_T / DT), :, 1])\n", + " truth = pd.DataFrame({\"pilot\": pilots[\"pilot\"].values, \"cond\": cond, \"skill\": sk, \"jerky\": jk, \"overcorr\": oc, \"rt_obs\": rt_obs,\n", + " \"near_collision\": lateral < 2.5, \"vz_touch\": np.round(vz_touch, 2), \"hard_landing\": np.nan_to_num(vz_touch, nan=3.0) > 1.6,\n", + " \"attitude_exceed\": (np.abs(ATT) > 35).any(axis=2).sum(axis=0) >= 10})\n", + " risk = truth[[\"near_collision\", \"hard_landing\", \"attitude_exceed\"]].sum(axis=1)\n", + " inst = 40 + 50 * sk - 5 * jk - 5 * oc - 4 * risk + rng.normal(0, 3, n) # 교관 평가 점수(가상)\n", + " truth[\"instructor\"] = np.clip(inst, 0, 100).round(1)\n", + " truth[\"grade\"] = np.where(truth[\"instructor\"] >= 75, \"A\", np.where(truth[\"instructor\"] >= 55, \"B\", \"C\"))\n", + " return log, truth\n", + "\n", + "\n", + "def one(log, j):\n", + " \"\"\"여러 조종자 로그에서 j번째 조종자 1명의 로그(DataFrame)를 꺼낸다.\"\"\"\n", + " return pd.DataFrame({k: (v if v.ndim == 1 else v[:, j]) for k, v in log.items()})\n", + "# ---------------------------------------------------------------------------\n", + "\n", + "# ---- 비행로그 특징 추출 공통 함수(여러 조종자 로그를 한 번에 계산) ----\n", + "from scipy.ndimage import uniform_filter1d\n", + "\n", + "BASE_F = [\"path_rmse\", \"hover_drift\", \"hover_std\", \"alt_err\"]\n", + "ADV_F = BASE_F + [\"obs_rt\", \"obs_lateral\", \"touch_vz\", \"att_exceed\", \"jerk\", \"overshoot\", \"n_risk\"]\n", + "RULES = {\"near_collision\": (\"obs_lateral\", \"<\", 2.5), \"hard_landing\": (\"touch_vz\", \">\", 2.0), \"attitude_exceed\": (\"att_exceed\", \">=\", 10)}\n", + "\n", + "\n", + "def detect(df):\n", + " \"\"\"위험 조작 사건 탐지 규칙: 장애물 2.5초 후 회피 거리 2.5m 미만, 접지 속도 2.0m/s 초과, 자세 35도 초과 1초 이상.\"\"\"\n", + " out = {}\n", + " for ev, (f, op, th) in RULES.items():\n", + " out[ev] = df[f] < th if op == \"<\" else (df[f] > th if op == \">\" else df[f] >= th)\n", + " return pd.DataFrame(out)\n", + "\n", + "\n", + "def features(log):\n", + " \"\"\"경로 이탈도·호버링 안정성(기본) + 장애물 반응·착륙 속도·자세 한계·급조작·과보정(고도화) 특징.\"\"\"\n", + " X, Y, A = (uniform_filter1d(log[k], 9, axis=0) for k in (\"x\", \"y\", \"alt\")) # GPS 잡음 완화(이동평균 0.9초)\n", + " h = np.hypot(X - log[\"tx\"], Y - log[\"ty\"])\n", + " cr, hv = slice(0, 600), slice(620, 850)\n", + " k0, k1 = int(OBST_T / DT), int((OBST_T + 2.5) / DT)\n", + " side = np.sign(log[\"ty\"][k0 + 1] - log[\"ty\"][k0 - 1])\n", + " moved = (log[\"stick_roll\"][k0:k0 + 40] * side) > 0.3\n", + " rt = np.where(moved.any(0), moved.argmax(0) * DT, 4.0)\n", + " vz = -np.gradient(A, DT, axis=0)\n", + " land = np.argmax((A < 0.4) & (T[:, None] >= LAND_T), axis=0)\n", + " touch = np.array([vz[max(i - 6, 0):i - 2, j].mean() if i > 6 else 3.0 for j, i in enumerate(land)])\n", + " ds = np.abs(np.diff(np.stack([log[\"stick_roll\"], log[\"stick_pitch\"]]), axis=1)) / DT\n", + " return pd.DataFrame({\n", + " \"path_rmse\": np.sqrt((h[cr] ** 2).mean(0)), \"hover_drift\": h[hv].mean(0), \"hover_std\": np.hypot(X[hv].std(0), Y[hv].std(0)),\n", + " \"alt_err\": np.abs(A[:850] - log[\"talt\"][:850]).mean(0),\n", + " \"obs_rt\": rt, \"obs_lateral\": np.abs(Y[k1] - Y[k0]), \"touch_vz\": touch,\n", + " \"att_exceed\": (np.maximum(np.abs(log[\"roll_deg\"]), np.abs(log[\"pitch_deg\"])) > 35).sum(0),\n", + " \"jerk\": np.percentile(ds, 95, axis=(0, 1)),\n", + " \"overshoot\": np.clip(np.abs(Y[k0:k0 + 120] - Y[k0]).max(0) - 6.0, 0, None)}).pipe(\n", + " lambda d: d.assign(n_risk=detect(d).sum(axis=1))) # overshoot: 회피 후 목표 6m를 넘어선 거리(과보정)\n", + "\n", + "\n", + "def make_dataset(n_pilots=40, seed=7, conds=(\"day\", \"night\", \"gust\"), flights=1):\n", + " \"\"\"조종자 n명 x 조건 x 반복 비행 -> 비행별 특징 + 정답(교관 점수·등급·위험 사건) 표.\"\"\"\n", + " pilots = make_pilots(n_pilots, seed)\n", + " out = []\n", + " for c in conds:\n", + " for f in range(flights):\n", + " log, tr = fly(pilots, c, seed=seed * 100 + list(COND).index(c) * 10 + f)\n", + " out.append(pd.concat([tr.reset_index(drop=True), features(log)], axis=1).assign(flight=f))\n", + " return pd.concat(out, ignore_index=True)\n", + "\n", + "\n", + "def cond_z(df, ref, cols):\n", + " \"\"\"조건별 기준 집단(ref)의 평균·표준편차로 표준화: 돌풍처럼 어려운 조건에서 받는 불이익을 줄인다.\"\"\"\n", + " z = df.copy()\n", + " z[cols] = z[cols].astype(float)\n", + " for c in df[\"cond\"].unique():\n", + " r = ref[ref[\"cond\"] == c][cols]\n", + " z.loc[df[\"cond\"] == c, cols] = (df.loc[df[\"cond\"] == c, cols] - r.mean()) / (r.std() + 1e-9)\n", + " return z\n", + "\n", + "\n", + "def to_grade(score):\n", + " return np.where(score >= 75, \"A\", np.where(score >= 55, \"B\", \"C\"))\n", + "\n", + "\n", + "def fit(df, feats, norm, alpha):\n", + " X = cond_z(df, df, feats)[feats] if norm else df[feats]\n", + " return Ridge(alpha=alpha).fit(X, df[\"instructor\"])\n", + "\n", + "\n", + "def predict(m, df, ref, feats, norm):\n", + " X = cond_z(df, ref, feats)[feats] if norm else df[feats]\n", + " return np.clip(m.predict(X), 0, 100)\n", + "\n", + "\n", + "# 초기 운용 AI: 주간 비행만으로 맞춘 Baseline(경로·호버링·고도, 조건 표준화 없음)\n", + "day_train = make_dataset(40, seed=7, conds=(\"day\",))\n", + "INIT = dict(feats=BASE_F, norm=False, alpha=1.0)\n", + "init_m = fit(day_train, **INIT)\n", + "\n", + "# 1) 무엇이 일어났나: 훈련 1개 기수(조종자 40명 x 3조건)의 교관 점수와 AI 점수 비교\n", + "aar = make_dataset(40, seed=8)\n", + "aar[\"ai\"] = predict(init_m, aar, day_train, INIT[\"feats\"], INIT[\"norm\"])\n", + "aar[\"dev\"] = aar[\"ai\"] - aar[\"instructor\"]\n", + "acc0 = (to_grade(aar[\"ai\"]) == aar[\"grade\"]).mean()\n", + "print(f\"[1. 무엇이 일어났나] 초기 AI 등급 일치율 {acc0:.1%}, 점수 편차(AI-교관) 평균 {aar['dev'].mean():+.1f}점, 절대 편차 {aar['dev'].abs().mean():.1f}점\")\n", + "\n", + "# 2) 왜 일어났나: 편차 원인 분해\n", + "aar[\"위험사건\"] = aar[[\"near_collision\", \"hard_landing\", \"attitude_exceed\"]].sum(axis=1).clip(0, 2).astype(str).str.replace(\"2\", \"2+\")\n", + "print(\"\\n[2. 왜 일어났나] 조건별 평균 편차:\", aar.groupby(\"cond\")[\"dev\"].mean().round(1).to_dict())\n", + "ng = aar[aar[\"cond\"] != \"gust\"] # 조건 효과를 뺀 주간·야간 비행으로 비교\n", + "ev = ng.groupby(\"위험사건\")[\"dev\"].mean().round(1)\n", + "print(\"주간·야간 비행의 위험 사건 수별 평균 편차:\", ev.to_dict())\n", + "print(\"주간·야간 비행의 습관별 평균 편차: 급조작\", round(ng[ng[\"jerky\"]][\"dev\"].mean(), 1), \"/ 과보정\", round(ng[ng[\"overcorr\"]][\"dev\"].mean(), 1),\n", + " \"/ 습관 없음\", round(ng[~ng[\"jerky\"] & ~ng[\"overcorr\"]][\"dev\"].mean(), 1))\n", + "print(f\"-> 원인: (가) 주간 자료로만 맞춰 돌풍 비행을 평균 {aar[aar['cond'] == 'gust']['dev'].mean():.0f}점 과소평가(경로 이탈을 실력 부족으로 오인), \"\n", + " f\"(나) 위험 사건 2건 이상 비행을 0건 대비 {ev.get('2+', np.nan) - ev.get('0', np.nan):+.1f}점 과대평가(장애물·착륙 대응 미반영), (다) 급조작 습관 미반영\")\n", + "\n", + "# 3) 무엇을 바꾸나: AAR 자료(3조건)로 격자 탐색, 기수 내 2겹 교차검증으로 선택\n", + "pid = aar[\"pilot\"].str[1:].astype(int) % 2\n", + "grid = []\n", + "for name, feats in [(\"기본\", BASE_F), (\"고도화\", ADV_F)]:\n", + " for norm in (False, True):\n", + " for alpha in (0.1, 1.0, 10.0):\n", + " accs = []\n", + " for k in (0, 1):\n", + " tr, va = aar[pid != k], aar[pid == k]\n", + " m = fit(tr, feats, norm, alpha)\n", + " accs.append((to_grade(predict(m, va, tr, feats, norm)) == va[\"grade\"]).mean())\n", + " grid.append({\"특징\": name, \"조건표준화\": norm, \"alpha\": alpha, \"교차검증 일치율\": round(np.mean(accs), 3)})\n", + "g = pd.DataFrame(grid).sort_values(\"교차검증 일치율\", ascending=False)\n", + "print(\"\\n[3. 무엇을 바꾸나] 격자 12개 중 상위 4개\")\n", + "print(g.head(4).to_string(index=False))\n", + "best = g.iloc[0]\n", + "TUNED = dict(feats=ADV_F if best[\"특징\"] == \"고도화\" else BASE_F, norm=bool(best[\"조건표준화\"]), alpha=float(best[\"alpha\"]))\n", + "tuned_m = fit(aar, **TUNED)\n", + "\n", + "# 4) 회귀 테스트: 새 기수 2개(학습에 쓰지 않은 조종자)\n", + "print(\"\\n[회귀 테스트] 기수 초기 AI 재튜닝 변화\")\n", + "reg, rep, near = [], [], []\n", + "for sd in (21, 22):\n", + " new = make_dataset(40, seed=sd)\n", + " a0 = to_grade(predict(init_m, new, day_train, INIT[\"feats\"], INIT[\"norm\"]))\n", + " a1 = to_grade(predict(tuned_m, new, aar, TUNED[\"feats\"], TUNED[\"norm\"]))\n", + " r0, r1 = (a0 == new[\"grade\"]).mean(), (a1 == new[\"grade\"]).mean()\n", + " both = (a0 != new[\"grade\"]) & (a1 != new[\"grade\"])\n", + " reg.append((r0, r1)); rep.append(int(both.sum()))\n", + " near += list((np.minimum(np.abs(new[\"instructor\"] - 55), np.abs(new[\"instructor\"] - 75)) <= 5)[both])\n", + " print(f\" #{sd} {r0:6.1%} {r1:6.1%} {(r1 - r0) * 100:+5.1f}%p\")\n", + "gain = np.mean([b - a for a, b in reg])\n", + "print(f\"평균 향상 {gain * 100:+.1f}%p (목표 +10%p 이상: {'달성' if gain >= 0.10 else '미달'}), 회귀 테스트 {sum(b >= a for a, b in reg)}/{len(reg)} 통과\")\n", + "print(f\"재튜닝 후에도 반복된 불일치 {sum(rep)}건(전체 {2 * len(new)}회 중), 그중 교관 점수가 등급 경계(55·75점) ±5점 이내인 비행 {np.mean(near):.0%}\")\n", + "\n", + "w = pd.Series(tuned_m.coef_, index=TUNED[\"feats\"]).round(2)\n", + "yaml = (f\"features: [{', '.join(TUNED['feats'])}]\\ncondition_normalization: {str(TUNED['norm']).lower()}\\nridge_alpha: {TUNED['alpha']}\\n\"\n", + " f\"grade_cut: {{A: 75, B: 55}}\\nweights: {{{', '.join(f'{k}: {v}' for k, v in w.items())}}}\")\n", + "print(\"\\n[재튜닝 파라미터(trn01_tuned_params.yaml 내용)]\\n\" + yaml)\n", + "print(\"지속 개선: 기수마다 교관 점수를 AAR 자료로 누적해 같은 절차로 재튜닝하고, 회귀 테스트 통과 시에만 교체한다.\")\n", + "\n", + "plt.figure(figsize=(7, 4))\n", + "for c, col in zip(COND, [\"tab:blue\", \"tab:purple\", \"tab:orange\"]):\n", + " s = aar[aar[\"cond\"] == c]\n", + " plt.scatter(s[\"instructor\"], s[\"dev\"], s=12, c=col, label=c)\n", + "plt.axhline(0, c=\"k\"); plt.xlabel(\"instructor score\"); plt.ylabel(\"AI - instructor (initial AI)\")\n", + "plt.legend(); plt.title(\"AAR: deviation of initial AI score (synthetic)\"); plt.tight_layout(); plt.show()\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 09 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + "[1. 무엇이 일어났나] 초기 AI 등급 일치율 47.5%, 점수 편차(AI-교관) 평균 -12.2점, 절대 편차 21.1점\n", + "\n", + "[2. 왜 일어났나] 조건별 평균 편차: {'day': 2.7, 'gust': -40.1, 'night': 0.9}\n", + "주간·야간 비행의 위험 사건 수별 평균 편차: {'0': -9.8, '1': 0.6, '2+': 11.7}\n", + "주간·야간 비행의 습관별 평균 편차: 급조작 7.3 / 과보정 3.6 / 습관 없음 0.3\n", + "-> 원인: (가) 주간 자료로만 맞춰 돌풍 비행을 평균 -40점 과소평가(경로 이탈을 실력 부족으로 오인), (나) 위험 사건 2건 이상 비행을 0건 대비 +21.5점 과대평가(장애물·착륙 대응 미반영), (다) 급조작 습관 미반영\n", + "\n", + "[3. 무엇을 바꾸나] 격자 12개 중 상위 4개\n", + " 특징 조건표준화 alpha 교차검증 일치율\n", + "고도화 True 10.0 0.825\n", + "고도화 True 0.1 0.817\n", + "고도화 True 1.0 0.808\n", + "고도화 False 0.1 0.783\n", + "\n", + "[회귀 테스트] 기수 초기 AI 재튜닝 변화\n", + " #21 45.0% 89.2% +44.2%p\n", + " #22 40.0% 81.7% +41.7%p\n", + "평균 향상 +42.9%p (목표 +10%p 이상: 달성), 회귀 테스트 2/2 통과\n", + "재튜닝 후에도 반복된 불일치 21건(전체 240회 중), 그중 교관 점수가 등급 경계(55·75점) ±5점 이내인 비행 86%\n", + "\n", + "[재튜닝 파라미터(trn01_tuned_params.yaml 내용)]\n", + "features: [path_rmse, hover_drift, hover_std, alt_err, obs_rt, obs_lateral, touch_vz, att_exceed, jerk, overshoot, n_risk]\n", + "condition_normalization: true\n", + "ridge_alpha: 10.0\n", + "grade_cut: {A: 75, B: 55}\n", + "weights: {path_rmse: -0.59, hover_drift: 1.22, hover_std: 0.77, alt_err: -7.67, obs_rt: -0.51, obs_lateral: 1.08, touch_vz: -4.19, att_exceed: -0.54, jerk: -1.59, overshoot: -0.66, n_risk: -2.4}\n", + "지속 개선: 기수마다 교관 점수를 AAR 자료로 누적해 같은 절차로 재튜닝하고, 회귀 테스트 통과 시에만 교체한다.\n", + "```\n", + "
\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 10. 드론 비행로그 숙련도 분석 시스템 통합 및 교육훈련 브리핑 패키지 완성\n", + "**소요시간**: 약 3.5시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "드론 비행로그 숙련도 분석 시스템 통합 패키지 및 교육훈련 브리핑 보고서를 완성한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **로그 검증부터 전처리, 특징 추출, 재튜닝 엔진, 위험 조작 탐지, 조종자 등급·신뢰도, 교관 검토 대기열, 대시보드까지 통합하고 교육훈련 브리핑 패키지를 완성한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **재튜닝 파라미터로 신규 기수 로그를 한 번에 분석하는 통합 코드를 완성하고 손상 로그가 격리되는지 확인하고 유효성을 점검하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `src/main_trn01.py, README.md, docs/trn01_final_report.pdf(md)` 형태의 결과물을 완성한다.\n", + "\n", + "### 🔍 평가 및 검증 기준 (PASS 조건)\n", + "#### 미션 판정\n", + "\n", + "**평가 구분:** 필수\n", + "\n", + "**핵심 평가영역:** 군 실무 즉시 도입 가능성, 운용 신뢰도 및 전략적 기대효과\n", + "\n", + "#### 미션 PASS 조건\n", + "\n", + "- 정량 기준: End-to-End 통합 파이프라인 실행 성공률 100%, 테스트 커버리지 85% 이상\n", + "\n", + "\n", + "- 진행 기준: 원클릭 실행 스크립트 동작 확인 및 최종 발표 산출물 작성 완료\n", + "\n", + "\n", + "- 검증 방법: 최종 검증 위원회(평가관) 모의 시연 및 전체 기능 통합 검증\n", + "\n", + "\n", + "#### 채점자 판정 메모\n", + "\n", + "> 단순 코드 실행 여부뿐만 아니라 출력된 수치와 근거 자료가 군 실무 요구사항에 부합하는지 면밀히 확인한다.\n", + "\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "\"\"\"비행로그 검증·전처리부터 특징 추출, 재튜닝된 숙련도 엔진, 위험 조작 탐지, 조종자별 등급·신뢰도, 교관 HITL 검토 대기열, 분석 대시보드까지 전체 파이프라인을 한 파일로 통합 실행하고, 함수 커버리지를 포함한 자체 점검 결과와 교육훈련 브리핑 요약을 출력한다.\"\"\"\n", + "import hashlib\n", + "import inspect\n", + "import json\n", + "import sys\n", + "import time\n", + "import numpy as np\n", + "import pandas as pd\n", + "import matplotlib.pyplot as plt\n", + "from sklearn.linear_model import Ridge\n", + "\n", + "# ---------------------------------------------------------------------------\n", + "# [교육용가상자료] 드론 조종 시뮬레이터 비행로그 생성기 (실제 훈련생·장비 정보 없음, 조종자는 가명 P001~)\n", + "# 비행 1회 = 120초(10Hz): 경로 비행(0~60초) -> 호버링(60~85초) -> 돌발 장애물 회피(85초) -> 비상 착륙(100초 지시)\n", + "# 조종자마다 숨은 숙련도(skill)와 조작 습관(급조작 jerky, 과보정 overcorr)이 있고, 교관 점수는 이를 반영해 만든다.\n", + "# ---------------------------------------------------------------------------\n", + "DT, STEPS = 0.1, 1200\n", + "T = np.arange(STEPS) * DT\n", + "COND = {\"day\": dict(wind=0.4, gust=0.0, gps=0.25, delay=0.0), \"night\": dict(wind=0.4, gust=0.0, gps=0.5, delay=0.15),\n", + " \"gust\": dict(wind=1.2, gust=2.5, gps=0.25, delay=0.0)}\n", + "OBST_T, LAND_T = 85.0, 100.0\n", + "\n", + "\n", + "def make_pilots(n=40, seed=7):\n", + " rng = np.random.default_rng(seed)\n", + " skill = rng.beta(2.2, 2.0, n)\n", + " return pd.DataFrame({\"pilot\": [f\"P{i + 1:03d}\" for i in range(n)], \"skill\": skill,\n", + " \"jerky\": rng.random(n) < 0.15 + 0.35 * (1 - skill), \"overcorr\": rng.random(n) < 0.15 + 0.35 * (1 - skill)})\n", + "\n", + "\n", + "def target_path(t):\n", + " \"\"\"사각형 경로(40x30m, 고도 20m)를 3m/s로 비행 -> 60초 지점에서 호버링 -> 착륙 지시 후 고도 0.\"\"\"\n", + " s = np.minimum(t, 60.0) * 3.0 % 140.0\n", + " x = np.select([s < 40, s < 70, s < 110], [s, 40.0, 40 - (s - 70)], 0.0)\n", + " y = np.select([s < 40, s < 70, s < 110], [0.0, s - 40, 30.0], 30 - (s - 110))\n", + " z = np.where(t >= LAND_T, 0.0, 20.0)\n", + " return np.stack([x, y, z], -1)\n", + "\n", + "\n", + "def fly(pilots, cond=\"day\", seed=0, delay_extra=0.0, obstacle_side=None):\n", + " \"\"\"여러 조종자의 비행 1회를 동시에 모의한다(벡터화). 반환: 로그 배열 dict, 비행별 정답 정보 DataFrame.\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " c = COND[cond]\n", + " delay_extra = delay_extra + c[\"delay\"]\n", + " n = len(pilots)\n", + " sk, jk, oc = pilots[\"skill\"].values, pilots[\"jerky\"].values, pilots[\"overcorr\"].values\n", + " gain = (0.5 + 1.2 * sk) * np.where(oc, 1.5, 1.0)\n", + " damp = np.where(oc, 0.25, 0.6 + 0.3 * sk)\n", + " noise = 0.03 + 0.35 * (1 - sk)\n", + " smooth = np.where(jk, 0.25, 0.75)\n", + " delay = np.round((0.25 + 0.35 * (1 - sk) + delay_extra) / DT).astype(int)\n", + " rt_obs = np.clip(0.5 + 1.5 * (1 - sk) + rng.normal(0, 0.2, n) + delay_extra, 0.3, 3.5)\n", + " h_flare = np.clip(0.8 + 5.0 * sk - 1.5 * oc + rng.normal(0, 0.4, n), 0.3, None)\n", + " side = rng.choice([-1, 1], n) if obstacle_side is None else np.full(n, obstacle_side)\n", + " pos = np.tile(target_path(np.zeros(1))[0], (n, 1)).astype(float); vel = np.zeros((n, 3)); stick = np.zeros((n, 3))\n", + " hist = np.zeros((STEPS, n, 3)); P = np.zeros((STEPS, n, 3)); S = np.zeros((STEPS, n, 3)); ATT = np.zeros((STEPS, n, 2))\n", + " tgt_all = np.zeros((STEPS, n, 3)); vz_touch = np.full(n, np.nan); landed = np.zeros(n, bool)\n", + " vmax = np.array([5.0, 5.0, 3.0])\n", + " gust = np.zeros((n, 2))\n", + " for i, t in enumerate(T):\n", + " tgt = np.tile(target_path(np.array([t]))[0], (n, 1))\n", + " if t >= OBST_T: # 장애물: 6m 옆으로 회피해야 함(조종자는 반응시간 이후 인지)\n", + " tgt_all[i] = tgt + np.c_[np.zeros(n), side * 6.0, np.zeros(n)]\n", + " tgt[:, 1] += np.where(t >= OBST_T + rt_obs, side * 6.0, 0.0)\n", + " else:\n", + " tgt_all[i] = tgt\n", + " hist[i] = pos\n", + " seen = hist[np.maximum(i - delay, 0), np.arange(n)] # 반응 지연만큼 늦게 본 위치\n", + " cmd = np.clip(gain[:, None] * (tgt - seen) / 4.0 - damp[:, None] * vel / vmax + rng.normal(0, 1, (n, 3)) * noise[:, None], -1, 1)\n", + " if t >= LAND_T: # 비상 착륙: 하강하다가 h_flare 높이에서 감속(숙련자일수록 일찍 감속)\n", + " cmd[:, 2] = np.where(seen[:, 2] > h_flare, -1.0, -0.12 + rng.normal(0, 0.05, n))\n", + " spikes = (jk[:, None] & (rng.random((n, 3)) < 0.02)) * rng.choice([-1, 1], (n, 3)) * 0.9\n", + " stick = np.clip(smooth[:, None] * stick + (1 - smooth[:, None]) * cmd + spikes, -1, 1)\n", + " gust = 0.95 * gust + rng.normal(0, c[\"gust\"] * 0.3, (n, 2))\n", + " wind = np.c_[np.full(n, c[\"wind\"]) + gust[:, 0], gust[:, 1], np.zeros(n)]\n", + " vel += (stick * vmax - vel) * DT / 0.6\n", + " vel[landed] = 0.0\n", + " pos = pos + (vel + wind * (~landed)[:, None]) * DT\n", + " touch = (pos[:, 2] <= 0) & ~landed & (t >= LAND_T)\n", + " vz_touch[touch] = -vel[touch, 2]; landed |= touch\n", + " pos[:, 2] = np.maximum(pos[:, 2], 0.0)\n", + " P[i], S[i] = pos, stick\n", + " ATT[i] = np.c_[stick[:, 1] * 28 + gust[:, 1] * 1.5, -stick[:, 0] * 28 + gust[:, 0] * 1.5]\n", + " g = rng.normal(0, c[\"gps\"], P.shape)\n", + " log = {\"t\": T, \"x\": P[..., 0] + g[..., 0], \"y\": P[..., 1] + g[..., 1], \"alt\": P[..., 2] + g[..., 2] * 0.5,\n", + " \"stick_pitch\": S[..., 0], \"stick_roll\": S[..., 1], \"throttle\": S[..., 2], \"roll_deg\": ATT[..., 0], \"pitch_deg\": ATT[..., 1],\n", + " \"tx\": tgt_all[..., 0], \"ty\": tgt_all[..., 1], \"talt\": tgt_all[..., 2], \"true_x\": P[..., 0], \"true_y\": P[..., 1], \"true_alt\": P[..., 2]}\n", + " k_obs = int((OBST_T + 2.5) / DT)\n", + " lateral = np.abs(P[k_obs, :, 1] - P[int(OBST_T / DT), :, 1])\n", + " truth = pd.DataFrame({\"pilot\": pilots[\"pilot\"].values, \"cond\": cond, \"skill\": sk, \"jerky\": jk, \"overcorr\": oc, \"rt_obs\": rt_obs,\n", + " \"near_collision\": lateral < 2.5, \"vz_touch\": np.round(vz_touch, 2), \"hard_landing\": np.nan_to_num(vz_touch, nan=3.0) > 1.6,\n", + " \"attitude_exceed\": (np.abs(ATT) > 35).any(axis=2).sum(axis=0) >= 10})\n", + " risk = truth[[\"near_collision\", \"hard_landing\", \"attitude_exceed\"]].sum(axis=1)\n", + " inst = 40 + 50 * sk - 5 * jk - 5 * oc - 4 * risk + rng.normal(0, 3, n) # 교관 평가 점수(가상)\n", + " truth[\"instructor\"] = np.clip(inst, 0, 100).round(1)\n", + " truth[\"grade\"] = np.where(truth[\"instructor\"] >= 75, \"A\", np.where(truth[\"instructor\"] >= 55, \"B\", \"C\"))\n", + " return log, truth\n", + "\n", + "\n", + "def one(log, j):\n", + " \"\"\"여러 조종자 로그에서 j번째 조종자 1명의 로그(DataFrame)를 꺼낸다.\"\"\"\n", + " return pd.DataFrame({k: (v if v.ndim == 1 else v[:, j]) for k, v in log.items()})\n", + "# ---------------------------------------------------------------------------\n", + "\n", + "# ---- 비행로그 특징 추출 공통 함수(여러 조종자 로그를 한 번에 계산) ----\n", + "from scipy.ndimage import uniform_filter1d\n", + "\n", + "BASE_F = [\"path_rmse\", \"hover_drift\", \"hover_std\", \"alt_err\"]\n", + "ADV_F = BASE_F + [\"obs_rt\", \"obs_lateral\", \"touch_vz\", \"att_exceed\", \"jerk\", \"overshoot\", \"n_risk\"]\n", + "RULES = {\"near_collision\": (\"obs_lateral\", \"<\", 2.5), \"hard_landing\": (\"touch_vz\", \">\", 2.0), \"attitude_exceed\": (\"att_exceed\", \">=\", 10)}\n", + "\n", + "\n", + "def detect(df):\n", + " \"\"\"위험 조작 사건 탐지 규칙: 장애물 2.5초 후 회피 거리 2.5m 미만, 접지 속도 2.0m/s 초과, 자세 35도 초과 1초 이상.\"\"\"\n", + " out = {}\n", + " for ev, (f, op, th) in RULES.items():\n", + " out[ev] = df[f] < th if op == \"<\" else (df[f] > th if op == \">\" else df[f] >= th)\n", + " return pd.DataFrame(out)\n", + "\n", + "\n", + "def features(log):\n", + " \"\"\"경로 이탈도·호버링 안정성(기본) + 장애물 반응·착륙 속도·자세 한계·급조작·과보정(고도화) 특징.\"\"\"\n", + " X, Y, A = (uniform_filter1d(log[k], 9, axis=0) for k in (\"x\", \"y\", \"alt\")) # GPS 잡음 완화(이동평균 0.9초)\n", + " h = np.hypot(X - log[\"tx\"], Y - log[\"ty\"])\n", + " cr, hv = slice(0, 600), slice(620, 850)\n", + " k0, k1 = int(OBST_T / DT), int((OBST_T + 2.5) / DT)\n", + " side = np.sign(log[\"ty\"][k0 + 1] - log[\"ty\"][k0 - 1])\n", + " moved = (log[\"stick_roll\"][k0:k0 + 40] * side) > 0.3\n", + " rt = np.where(moved.any(0), moved.argmax(0) * DT, 4.0)\n", + " vz = -np.gradient(A, DT, axis=0)\n", + " land = np.argmax((A < 0.4) & (T[:, None] >= LAND_T), axis=0)\n", + " touch = np.array([vz[max(i - 6, 0):i - 2, j].mean() if i > 6 else 3.0 for j, i in enumerate(land)])\n", + " ds = np.abs(np.diff(np.stack([log[\"stick_roll\"], log[\"stick_pitch\"]]), axis=1)) / DT\n", + " return pd.DataFrame({\n", + " \"path_rmse\": np.sqrt((h[cr] ** 2).mean(0)), \"hover_drift\": h[hv].mean(0), \"hover_std\": np.hypot(X[hv].std(0), Y[hv].std(0)),\n", + " \"alt_err\": np.abs(A[:850] - log[\"talt\"][:850]).mean(0),\n", + " \"obs_rt\": rt, \"obs_lateral\": np.abs(Y[k1] - Y[k0]), \"touch_vz\": touch,\n", + " \"att_exceed\": (np.maximum(np.abs(log[\"roll_deg\"]), np.abs(log[\"pitch_deg\"])) > 35).sum(0),\n", + " \"jerk\": np.percentile(ds, 95, axis=(0, 1)),\n", + " \"overshoot\": np.clip(np.abs(Y[k0:k0 + 120] - Y[k0]).max(0) - 6.0, 0, None)}).pipe(\n", + " lambda d: d.assign(n_risk=detect(d).sum(axis=1))) # overshoot: 회피 후 목표 6m를 넘어선 거리(과보정)\n", + "\n", + "\n", + "def make_dataset(n_pilots=40, seed=7, conds=(\"day\", \"night\", \"gust\"), flights=1):\n", + " \"\"\"조종자 n명 x 조건 x 반복 비행 -> 비행별 특징 + 정답(교관 점수·등급·위험 사건) 표.\"\"\"\n", + " pilots = make_pilots(n_pilots, seed)\n", + " out = []\n", + " for c in conds:\n", + " for f in range(flights):\n", + " log, tr = fly(pilots, c, seed=seed * 100 + list(COND).index(c) * 10 + f)\n", + " out.append(pd.concat([tr.reset_index(drop=True), features(log)], axis=1).assign(flight=f))\n", + " return pd.concat(out, ignore_index=True)\n", + "\n", + "\n", + "def cond_z(df, ref, cols):\n", + " \"\"\"조건별 기준 집단(ref)의 평균·표준편차로 표준화: 돌풍처럼 어려운 조건에서 받는 불이익을 줄인다.\"\"\"\n", + " z = df.copy()\n", + " z[cols] = z[cols].astype(float)\n", + " for c in df[\"cond\"].unique():\n", + " r = ref[ref[\"cond\"] == c][cols]\n", + " z.loc[df[\"cond\"] == c, cols] = (df.loc[df[\"cond\"] == c, cols] - r.mean()) / (r.std() + 1e-9)\n", + " return z\n", + "\n", + "\n", + "def to_grade(score):\n", + " return np.where(score >= 75, \"A\", np.where(score >= 55, \"B\", \"C\"))\n", + "\n", + "CONFIG = {\"features\": ADV_F, \"condition_normalization\": True, \"ridge_alpha\": 10.0} # 미션 9 재튜닝값\n", + "KEYS = [\"x\", \"y\", \"alt\", \"stick_pitch\", \"stick_roll\", \"throttle\", \"roll_deg\", \"pitch_deg\", \"tx\", \"ty\", \"talt\"]\n", + "\n", + "\n", + "def validate(L):\n", + " if any(k not in L for k in KEYS) or len(L[\"x\"]) != STEPS:\n", + " return \"필드·길이 오류\"\n", + " if np.isnan(L[\"x\"]).mean() > 0.2:\n", + " return \"결측 20% 초과\"\n", + " return None\n", + "\n", + "\n", + "def clean(L):\n", + " out = {}\n", + " for k in KEYS:\n", + " s = pd.Series(L[k])\n", + " if k in (\"x\", \"y\", \"alt\"):\n", + " med = s.rolling(7, center=True, min_periods=1).median()\n", + " s = s.mask((s - med).abs() > 3 * ((s - med).abs().rolling(7, center=True, min_periods=1).median() * 1.4826 + 0.3))\n", + " out[k] = s.interpolate(limit_direction=\"both\").values\n", + " return out\n", + "\n", + "\n", + "class Review:\n", + " def __init__(self):\n", + " self.items, self.log = [], []\n", + "\n", + " def submit(self, rec):\n", + " prev = self.log[-1][\"hash\"] if self.log else \"GENESIS\"\n", + " body = {\"event\": \"AI_SUGGEST\", \"pilot\": rec[\"pilot\"], \"grade\": rec[\"grade\"], \"conf\": rec[\"conf\"]}\n", + " self.log.append({**body, \"prev\": prev, \"hash\": hashlib.sha256((prev + json.dumps(body, sort_keys=True)).encode()).hexdigest()})\n", + " self.items.append({**body, \"status\": \"PENDING\"})\n", + "\n", + "\n", + "called = set()\n", + "sys.setprofile(lambda f, e, a: called.add(f.f_code.co_name) if e == \"call\" and f.f_globals is globals() else None)\n", + "t_start = time.perf_counter()\n", + "\n", + "# ---------------- 1. 엔진 준비(AAR 자료로 재튜닝된 설정) ----------------\n", + "ref = make_dataset(40, seed=8)\n", + "eng = Ridge(alpha=CONFIG[\"ridge_alpha\"]).fit(cond_z(ref, ref, ADV_F)[ADV_F], ref[\"instructor\"])\n", + "\n", + "# ---------------- 2. 신규 기수 로그 수집(조종자 30명 x 3조건) + 손상·잡음 ----------------\n", + "pilots = make_pilots(30, seed=31)\n", + "rng = np.random.default_rng(4)\n", + "logs, conds, truth = [], [], []\n", + "for c in COND:\n", + " log, tr = fly(pilots, c, seed=700 + len(logs))\n", + " for j in range(len(pilots)):\n", + " L = {k: log[k][:, j].copy() for k in KEYS}\n", + " sp = rng.random(STEPS) < 0.01\n", + " L[\"x\"][sp] += 10.0 # GPS 튐\n", + " L[\"y\"][300:310] = np.nan # 센서 단절 1초\n", + " logs.append(L); conds.append(c)\n", + " truth.append(tr)\n", + "truth = pd.concat(truth, ignore_index=True)\n", + "logs[7] = {k: v for k, v in logs[7].items() if k != \"throttle\"} # 손상 로그 2건\n", + "logs[40][\"x\"][:500] = np.nan\n", + "\n", + "# ---------------- 3. 검증·전처리·특징·채점 ----------------\n", + "rejected = {i: r for i, L in enumerate(logs) if (r := validate(L))}\n", + "ok = [i for i in range(len(logs)) if i not in rejected]\n", + "t0 = time.perf_counter()\n", + "cleaned = [clean(logs[i]) for i in ok]\n", + "stack = {k: np.stack([c[k] for c in cleaned], axis=1) for k in KEYS}\n", + "stack[\"t\"] = T\n", + "F = features(stack).assign(cond=[conds[i] for i in ok])\n", + "D = pd.concat([truth.iloc[ok].reset_index(drop=True), F.drop(columns=\"cond\")], axis=1)\n", + "D[\"score\"] = np.clip(eng.predict(cond_z(F, ref, ADV_F)[ADV_F]), 0, 100)\n", + "ev = detect(F)\n", + "ms = (time.perf_counter() - t0) * 1000 / len(ok)\n", + "\n", + "# ---------------- 4. 조종자 집계·신뢰도·교관 검토 대기열 ----------------\n", + "rows = []\n", + "for pid, g in D.groupby(\"pilot\"):\n", + " boot = [to_grade(rng.choice(g[\"score\"].values, len(g)).mean()).item() for _ in range(100)]\n", + " gr = to_grade(g[\"score\"].mean()).item(); cf = boot.count(gr) / 100\n", + " rows.append({\"pilot\": pid, \"score\": g[\"score\"].mean(), \"grade\": gr, \"conf\": \"High\" if cf >= 0.9 else (\"Medium\" if cf >= 0.7 else \"Low\"),\n", + " \"inst_grade\": to_grade(g[\"instructor\"].mean()).item(), \"risk\": int(ev.loc[g.index].values.sum())})\n", + "P = pd.DataFrame(rows)\n", + "rv = Review()\n", + "for r in P.to_dict(\"records\"):\n", + " rv.submit(r)\n", + "sys.setprofile(None)\n", + "\n", + "# ---------------- 5. 자체 점검 ----------------\n", + "mine = [n for n, o in list(globals().items()) if inspect.isfunction(o) and o.__module__ == \"__main__\"] + [\"submit\", \"__init__\"]\n", + "cov = len(set(mine) & called) / len(set(mine))\n", + "fl_acc = (to_grade(D[\"score\"]) == D[\"grade\"]).mean()\n", + "pl_acc = (P[\"grade\"] == P[\"inst_grade\"]).mean()\n", + "truth_ev = D[[\"near_collision\", \"hard_landing\", \"attitude_exceed\"]].astype(bool).values\n", + "rec = (ev.values & truth_ev).sum() / max(truth_ev.sum(), 1)\n", + "chk = {\n", + " \"손상 로그 2건 격리\": len(rejected) == 2,\n", + " \"로그 유실 0건(채점+격리=전체)\": len(ok) + len(rejected) == len(logs),\n", + " \"전처리 후 결측 0\": not any(np.isnan(v).any() for v in stack.values()),\n", + " \"비행별 등급 일치율 75% 이상\": fl_acc >= 0.75,\n", + " \"조종자 등급 일치율 85% 이상\": pl_acc >= 0.85,\n", + " \"위험 사건 탐지 재현율 85% 이상\": rec >= 0.85,\n", + " \"로그당 처리 200ms 이내\": ms < 200,\n", + " \"AI 확정 0건(전부 교관 대기)\": all(i[\"status\"] == \"PENDING\" for i in rv.items),\n", + " \"감사 로그 건수 일치\": len(rv.log) == len(rv.items),\n", + " \"함수 커버리지 85% 이상\": cov >= 0.85,\n", + "}\n", + "passed = sum(chk.values())\n", + "for k, v in chk.items():\n", + " print(f\"[{'PASS' if v else 'FAIL'}] {k}\")\n", + "print(f\"자체 점검 {passed}/{len(chk)} 통과, 함수 커버리지 {cov:.0%}, 파이프라인 실행 성공 {'예' if passed == len(chk) else '아니오'}\")\n", + "print(\"조종자 등급 분포:\", P[\"grade\"].value_counts().reindex([\"A\", \"B\", \"C\"]).fillna(0).astype(int).to_dict(),\n", + " \"/ 신뢰도:\", P[\"conf\"].value_counts().to_dict())\n", + "\n", + "# ---------------- 6. 대시보드 ----------------\n", + "fig, ax = plt.subplots(2, 2, figsize=(12, 7))\n", + "ax[0, 0].scatter(D[\"instructor\"], D[\"score\"], s=10, c=D[\"cond\"].map({\"day\": \"tab:blue\", \"night\": \"tab:purple\", \"gust\": \"tab:orange\"}))\n", + "ax[0, 0].plot([20, 100], [20, 100], \"k--\"); ax[0, 0].set_xlabel(\"instructor\"); ax[0, 0].set_ylabel(\"AI\"); ax[0, 0].set_title(\"Flight score: AI vs instructor\")\n", + "ax[0, 1].bar([\"near_collision\", \"hard_landing\", \"attitude\"], ev.sum().values, color=\"tab:red\"); ax[0, 1].set_title(\"Detected risk events\")\n", + "Ps = P.sort_values(\"score\")\n", + "ax[1, 0].bar(Ps[\"pilot\"], Ps[\"score\"], color=Ps[\"conf\"].map({\"High\": \"tab:green\", \"Medium\": \"tab:orange\", \"Low\": \"tab:red\"}))\n", + "ax[1, 0].axhline(55, c=\"k\", ls=\"--\"); ax[1, 0].axhline(75, c=\"k\", ls=\"--\"); ax[1, 0].tick_params(axis=\"x\", rotation=90, labelsize=6)\n", + "ax[1, 0].set_title(\"Pilot mean score (color = confidence)\")\n", + "ax[1, 1].axis(\"off\")\n", + "ax[1, 1].text(0, 0.9, f\"Logs {len(logs)} ({len(rejected)} rejected)\\nFlight acc {fl_acc:.1%}\\nPilot acc {pl_acc:.1%}\\n\"\n", + " f\"Risk recall {rec:.1%}\\nPending {len(rv.items)} reviews\\nSelf-check {passed}/{len(chk)}\", family=\"monospace\", fontsize=13, va=\"top\")\n", + "plt.suptitle(\"TRN01 integrated skill analyzer - training data only\"); plt.tight_layout(); plt.show()\n", + "\n", + "# ---------------- 7. 교육훈련 브리핑 요약 ----------------\n", + "c_list = P[P[\"grade\"] == \"C\"][\"pilot\"].tolist()\n", + "print(f\"\"\"\n", + "[교육훈련 브리핑 요약 - 교육용가상자료]\n", + "1. 처리: 비행로그 {len(logs)}건 중 {len(ok)}건 분석, 손상 {len(rejected)}건 격리, 로그당 {ms:.1f} ms, 전체 {time.perf_counter() - t_start:.1f}s\n", + "2. 성능: 비행별 등급 일치율 {fl_acc:.1%}, 조종자 등급 일치율 {pl_acc:.1%}, 위험 사건 탐지 재현율 {rec:.1%}\n", + "3. 결과: A {int((P['grade'] == 'A').sum())}명 / B {int((P['grade'] == 'B').sum())}명 / C {len(c_list)}명(재교육 검토 대상 {', '.join(c_list[:5])}{' 외' if len(c_list) > 5 else ''}), 전원 교관 검토 대기\n", + "4. 운용 유의: AI 등급은 교관 판단 보조 자료이며, 재교육·자격 보류는 교관 근거 기록과 두 번째 교관 확인 후 확정한다.\n", + "5. 한계·확장: 가상 시뮬레이터 로그 기준 결과다. 실제 시뮬레이터 로그 형식에 맞춘 재검증이 필요하며, 훈련 관리 체계와는 결과 조회 API로 연동한다.\"\"\")\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 10 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + "[PASS] 손상 로그 2건 격리\n", + "[PASS] 로그 유실 0건(채점+격리=전체)\n", + "[PASS] 전처리 후 결측 0\n", + "[PASS] 비행별 등급 일치율 75% 이상\n", + "[PASS] 조종자 등급 일치율 85% 이상\n", + "[PASS] 위험 사건 탐지 재현율 85% 이상\n", + "[PASS] 로그당 처리 200ms 이내\n", + "[PASS] AI 확정 0건(전부 교관 대기)\n", + "[PASS] 감사 로그 건수 일치\n", + "[PASS] 함수 커버리지 85% 이상\n", + "자체 점검 10/10 통과, 함수 커버리지 92%, 파이프라인 실행 성공 예\n", + "조종자 등급 분포: {'A': 4, 'B': 15, 'C': 11} / 신뢰도: {'High': 21, 'Medium': 6, 'Low': 3}\n", + "\n", + "[교육훈련 브리핑 요약 - 교육용가상자료]\n", + "1. 처리: 비행로그 90건 중 88건 분석, 손상 2건 격리, 로그당 13.6 ms, 전체 5.9s\n", + "2. 성능: 비행별 등급 일치율 79.5%, 조종자 등급 일치율 93.3%, 위험 사건 탐지 재현율 97.4%\n", + "3. 결과: A 4명 / B 15명 / C 11명(재교육 검토 대상 P001, P007, P012, P015, P016 외), 전원 교관 검토 대기\n", + "4. 운용 유의: AI 등급은 교관 판단 보조 자료이며, 재교육·자격 보류는 교관 근거 기록과 두 번째 교관 확인 후 확정한다.\n", + "5. 한계·확장: 가상 시뮬레이터 로그 기준 결과다. 실제 시뮬레이터 로그 형식에 맞춘 재검증이 필요하며, 훈련 관리 체계와는 결과 조회 API로 연동한다.\n", + "```\n", + "
\n" + ] + } + ], + "metadata": { + "colab": { + "provenance": [] + }, + "kernelspec": { + "display_name": "Python 3", + "name": "python3" + }, + "language_info": { + "name": "python" + } + }, + "nbformat": 4, + "nbformat_minor": 0 +} \ No newline at end of file diff --git a/uploads/TRN01/TRN01_student.ipynb b/uploads/TRN01/TRN01_student.ipynb new file mode 100644 index 0000000..5f1ec18 --- /dev/null +++ b/uploads/TRN01/TRN01_student.ipynb @@ -0,0 +1,2633 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# 🎯 [TRN01] 드론 시뮬레이터 비행로그 기반 숙련도 분석기\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/TRN01/TRN01_solution.ipynb)\n", + "\n", + "---\n", + "\n", + "## 📌 1. 프로젝트 개요 및 배경\n", + "\n", + "| 항목 | 내용 |\n", + "| :--- | :--- |\n", + "| **과정 분류** | 교육·훈련 (Level 3) |\n", + "| **학습 소요 시간** | 10개 미션 (5일 / 총 27.5시간) |\n", + "| **환경 사양** | Python 3.10+, numpy, pandas, matplotlib, scipy, scikit-learn |\n", + "\n", + "### 📖 프로젝트 목표\n", + "러시아-우크라이나 전쟁에서는 소형 드론 조종 인력을 짧은 기간에 대량으로 양성해야 했고, 시뮬레이터 훈련에서 누가 어떤 조작 약점을 가지고 있는지 빠르게 찾아 개인별로 보완하는 일이 전투력 유지의 관건이 되었다. 본 프로젝트에서는 교육용 가상 드론 시뮬레이터 비행로그(경로 비행, 호버링, 돌발 장애물 회피, 비상 착륙)를 코드로 직접 만들어, 로그 스키마 정의, 잡음·결측 전처리, 경로 이탈도·호버링 안정성 기반 Baseline 채점, 장애물 반응·착륙 속도를 반영한 정밀 평가 엔진, 조작 습관 분석과 신뢰도 등급, 3D 재생 대시보드, 교관 HITL 평가 인터페이스, 대량 배치 스트레스 시험, 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. 드론 조종 시뮬레이터 비행로그(스틱 조작, 자세각, 고도 유지, 경로 오차) 데이터 스키마 정의\n", + "**소요시간**: 약 1.5시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "드론 조종 시뮬레이터 비행로그(스틱 조작, 자세각, 고도 유지 오차) 데이터 스키마를 정의한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **드론 조종 시뮬레이터 비행로그(스틱 조작, 자세각, 고도, 위치·목표 경로)와 비행 세션 메타데이터의 스키마를 정의하고 잘못된 로그를 걸러내는 검증기를 만든다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **세션(가명 조종자 ID, 코스, 기록 주기, 비행 시간), 전술 파라미터(비행 조건, 풍속, GPS 오차, 영상 지연), 레코드(스틱 3축, 자세각, 고도, 위치·목표) 필드와 허용 범위 정의하고 검증하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `data/schema/trn01_input_schema.json 및 docs/trn01_data_spec.md` 형태의 결과물을 완성한다.\n", + "\n", + "### 🔍 평가 및 검증 기준 (PASS 조건)\n", + "#### 미션 판정\n", + "\n", + "**평가 구분:** 필수\n", + "\n", + "**핵심 평가영역:** 교육·훈련 실무 및 러시아-우크라이나 전쟁 전장 환경과의 군사적 부합성\n", + "\n", + "#### 미션 PASS 조건\n", + "\n", + "- 정량 기준: 스키마 필드 정의 완성도 100%, 샘플 데이터 검증 오류 0건\n", + "\n", + "\n", + "- 진행 기준: 입력 데이터 유효성 검증 스크립트 통과 및 명세서 문서화 완료\n", + "\n", + "\n", + "- 검증 방법: JSON Schema 검증 도구 및 샘플 데이터 파싱 단위 테스트\n", + "\n", + "\n", + "#### 채점자 판정 메모\n", + "\n", + "> 단순 코드 실행 여부뿐만 아니라 출력된 수치와 근거 자료가 군 실무 요구사항에 부합하는지 면밀히 확인한다.\n", + "\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "\"\"\"드론 조종 시뮬레이터 비행로그(스틱 조작, 자세각, 고도, 위치·목표 경로)와 비행 세션 메타데이터(가명 조종자, 비행 조건, 전술 파라미터)의 스키마를 정의하고 가상 샘플 데이터를 검증한다.\"\"\"\n", + "import json\n", + "import re\n", + "import numpy as np\n", + "import pandas as pd\n", + "\n", + "# ---------------------------------------------------------------------------\n", + "# [교육용가상자료] 드론 조종 시뮬레이터 비행로그 생성기 (실제 훈련생·장비 정보 없음, 조종자는 가명 P001~)\n", + "# 비행 1회 = 120초(10Hz): 경로 비행(0~60초) -> 호버링(60~85초) -> 돌발 장애물 회피(85초) -> 비상 착륙(100초 지시)\n", + "# 조종자마다 숨은 숙련도(skill)와 조작 습관(급조작 jerky, 과보정 overcorr)이 있고, 교관 점수는 이를 반영해 만든다.\n", + "# ---------------------------------------------------------------------------\n", + "DT, STEPS = 0.1, 1200\n", + "T = np.arange(STEPS) * DT\n", + "COND = {\"day\": dict(wind=0.4, gust=0.0, gps=0.25, delay=0.0), \"night\": dict(wind=0.4, gust=0.0, gps=0.5, delay=0.15),\n", + " \"gust\": dict(wind=1.2, gust=2.5, gps=0.25, delay=0.0)}\n", + "OBST_T, LAND_T = 85.0, 100.0\n", + "\n", + "\n", + "def make_pilots(n=40, seed=7):\n", + " rng = np.random.default_rng(seed)\n", + " skill = rng.beta(2.2, 2.0, n)\n", + " return pd.DataFrame({\"pilot\": [f\"P{i + 1:03d}\" for i in range(n)], \"skill\": skill,\n", + " \"jerky\": rng.random(n) < 0.15 + 0.35 * (1 - skill), \"overcorr\": rng.random(n) < 0.15 + 0.35 * (1 - skill)})\n", + "\n", + "\n", + "def target_path(t):\n", + " \"\"\"사각형 경로(40x30m, 고도 20m)를 3m/s로 비행 -> 60초 지점에서 호버링 -> 착륙 지시 후 고도 0.\"\"\"\n", + " # [TODO : 학생 실습 1] 드론 조종 시뮬레이터 비행로그(스틱 조작, 자세각, 고도 유지, 경로 오차) 데이터 스키마 정의 관련 핵심 로직을 구현하세요.\n", + " # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n", + " # <<< 여기에 코드를 작성하세요 >>>\n", + " pass # 작성 후 pass를 제거하세요.\n", + "def fly(pilots, cond=\"day\", seed=0, delay_extra=0.0, obstacle_side=None):\n", + " \"\"\"여러 조종자의 비행 1회를 동시에 모의한다(벡터화). 반환: 로그 배열 dict, 비행별 정답 정보 DataFrame.\"\"\"\n", + " # [TODO : 학생 실습 1] 드론 조종 시뮬레이터 비행로그(스틱 조작, 자세각, 고도 유지, 경로 오차) 데이터 스키마 정의 관련 핵심 로직을 구현하세요.\n", + " # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n", + " # <<< 여기에 코드를 작성하세요 >>>\n", + " pass # 작성 후 pass를 제거하세요.\n", + "def one(log, j):\n", + " \"\"\"여러 조종자 로그에서 j번째 조종자 1명의 로그(DataFrame)를 꺼낸다.\"\"\"\n", + " return pd.DataFrame({k: (v if v.ndim == 1 else v[:, j]) for k, v in log.items()})\n", + "# ---------------------------------------------------------------------------\n", + "\n", + "SCHEMA = {\n", + " \"session\": {\n", + " \"session_id\": {\"type\": str, \"pattern\": r\"^S\\d{4}$\"},\n", + " \"pilot\": {\"type\": str, \"pattern\": r\"^P\\d{3}$\"}, # 가명 ID만 허용(실명·군번 금지)\n", + " \"sim_course\": {\"type\": str, \"enum\": [\"RECT40x30_HOVER_OBS_LAND\"]},\n", + " \"rate_hz\": {\"type\": int, \"enum\": [10, 20, 50]},\n", + " \"duration_s\": {\"type\": (int, float), \"min\": 10, \"max\": 1800},\n", + " },\n", + " \"tactical\": {\n", + " \"condition\": {\"type\": str, \"enum\": list(COND)},\n", + " \"wind_mps\": {\"type\": (int, float), \"min\": 0, \"max\": 20},\n", + " \"gps_error_m\": {\"type\": (int, float), \"min\": 0, \"max\": 50},\n", + " \"video_delay_ms\": {\"type\": (int, float), \"min\": 0, \"max\": 2000},\n", + " },\n", + " \"record\": {\n", + " \"t\": {\"type\": (int, float), \"min\": 0},\n", + " \"stick_roll\": {\"type\": (int, float), \"min\": -1, \"max\": 1},\n", + " \"stick_pitch\": {\"type\": (int, float), \"min\": -1, \"max\": 1},\n", + " \"throttle\": {\"type\": (int, float), \"min\": -1, \"max\": 1},\n", + " \"roll_deg\": {\"type\": (int, float), \"min\": -90, \"max\": 90},\n", + " \"pitch_deg\": {\"type\": (int, float), \"min\": -90, \"max\": 90},\n", + " \"alt\": {\"type\": (int, float), \"min\": -1, \"max\": 500},\n", + " \"x\": {\"type\": (int, float)}, \"y\": {\"type\": (int, float)},\n", + " \"tx\": {\"type\": (int, float)}, \"ty\": {\"type\": (int, float)}, \"talt\": {\"type\": (int, float), \"min\": 0, \"max\": 500},\n", + " },\n", + "}\n", + "\n", + "\n", + "def check(name, value, rule):\n", + " if not isinstance(value, rule[\"type\"]) or isinstance(value, bool):\n", + " return [f\"{name}: 자료형 오류({type(value).__name__})\"]\n", + " errs = []\n", + " if \"enum\" in rule and value not in rule[\"enum\"]:\n", + " errs.append(f\"{name}: 허용되지 않은 값 '{value}'\")\n", + " if \"min\" in rule and value < rule[\"min\"]:\n", + " errs.append(f\"{name}: 최소값 {rule['min']} 미만\")\n", + " if \"max\" in rule and value > rule[\"max\"]:\n", + " errs.append(f\"{name}: 최대값 {rule['max']} 초과\")\n", + " if \"pattern\" in rule and not re.match(rule[\"pattern\"], value):\n", + " errs.append(f\"{name}: 형식 불일치 '{value}'\")\n", + " return errs\n", + "\n", + "\n", + "def validate(sess):\n", + " errs = []\n", + " for g in (\"session\", \"tactical\"):\n", + " for k, rule in SCHEMA[g].items():\n", + " errs += [f\"{g}.{k}: 필수 필드 누락\"] if k not in sess.get(g, {}) else check(f\"{g}.{k}\", sess[g][k], rule)\n", + " prev = -1.0\n", + " for i, rec in enumerate(sess.get(\"records\", [])):\n", + " for k, rule in SCHEMA[\"record\"].items():\n", + " errs += [f\"records[{i}].{k}: 필수 필드 누락\"] if k not in rec else check(f\"records[{i}].{k}\", rec[k], rule)\n", + " if isinstance(rec.get(\"t\"), (int, float)) and rec[\"t\"] <= prev:\n", + " errs.append(f\"records[{i}].t: 시각 역행/중복\")\n", + " prev = rec.get(\"t\", prev)\n", + " return errs\n", + "\n", + "\n", + "# [교육용가상자료] 조종자 20명 x 비행 1회 -> 세션 20건(각 세션에서 레코드 30개만 표본 검증)\n", + "pilots = make_pilots(20, seed=7)\n", + "log, truth = fly(pilots, \"day\", seed=1)\n", + "rng = np.random.default_rng(2)\n", + "sessions = []\n", + "for j in range(20):\n", + " d = one(log, j).iloc[::40][list(SCHEMA[\"record\"])].round(3)\n", + " sessions.append({\"session\": {\"session_id\": f\"S{j + 1:04d}\", \"pilot\": pilots[\"pilot\"][j], \"sim_course\": \"RECT40x30_HOVER_OBS_LAND\",\n", + " \"rate_hz\": 10, \"duration_s\": 120.0},\n", + " \"tactical\": {\"condition\": \"day\", \"wind_mps\": float(round(rng.uniform(0, 3), 1)), \"gps_error_m\": 0.25,\n", + " \"video_delay_ms\": int(rng.integers(50, 250))},\n", + " \"records\": [{k: float(v) for k, v in r.items()} for r in d.to_dict(\"records\")]})\n", + "ok = sum(1 for s in sessions if not validate(s))\n", + "print(f\"정상 세션 {len(sessions)}건(레코드 {sum(len(s['records']) for s in sessions)}개) 중 검증 통과 {ok}건, 오류 {len(sessions) - ok}건\")\n", + "\n", + "bad = json.loads(json.dumps(sessions[0]))\n", + "bad[\"session\"][\"pilot\"] = \"KIM-01\" # 실명형 ID 입력\n", + "bad[\"tactical\"][\"condition\"] = \"storm\"\n", + "del bad[\"tactical\"][\"video_delay_ms\"]\n", + "bad[\"records\"][3][\"stick_roll\"] = 1.8\n", + "bad[\"records\"][5][\"t\"] = bad[\"records\"][4][\"t\"]\n", + "print(\"[오류 주입 결과]\")\n", + "for e in validate(bad):\n", + " print(\" -\", e)\n", + "\n", + "rows = []\n", + "for g in SCHEMA:\n", + " for k, r in SCHEMA[g].items():\n", + " t = r[\"type\"].__name__ if isinstance(r[\"type\"], type) else \"number\"\n", + " rows.append((g, k, t, str({x: y for x, y in r.items() if x != \"type\"})[:45]))\n", + "print(\"\\n[데이터 명세 요약]\")\n", + "print(pd.DataFrame(rows, columns=[\"그룹\", \"필드\", \"자료형\", \"규칙\"]).to_string(index=False))\n", + "print(\"\\n비행 단계: 경로 0~60초 / 호버링 60~85초 / 장애물 회피 85초 / 비상 착륙 지시 100초, 10Hz 기록\")\n", + "print(\"개인정보: 조종자는 가명 ID(P001~)로만 저장하고, 실명·군번·소속은 별도 대장에서 교관만 관리한다.\")\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 01 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + "정상 세션 20건(레코드 600개) 중 검증 통과 20건, 오류 0건\n", + "[오류 주입 결과]\n", + " - session.pilot: 형식 불일치 'KIM-01'\n", + " - tactical.condition: 허용되지 않은 값 'storm'\n", + " - tactical.video_delay_ms: 필수 필드 누락\n", + " - records[3].stick_roll: 최대값 1 초과\n", + " - records[5].t: 시각 역행/중복\n", + "\n", + "[데이터 명세 요약]\n", + " 그룹 필드 자료형 규칙\n", + " session session_id str {'pattern': '^S\\\\d{4}$'}\n", + " session pilot str {'pattern': '^P\\\\d{3}$'}\n", + " session sim_course str {'enum': ['RECT40x30_HOVER_OBS_LAND']}\n", + " session rate_hz int {'enum': [10, 20, 50]}\n", + " session duration_s number {'min': 10, 'max': 1800}\n", + "tactical condition str {'enum': ['day', 'night', 'gust']}\n", + "tactical wind_mps number {'min': 0, 'max': 20}\n", + "tactical gps_error_m number {'min': 0, 'max': 50}\n", + "tactical video_delay_ms number {'min': 0, 'max': 2000}\n", + " record t number {'min': 0}\n", + " record stick_roll number {'min': -1, 'max': 1}\n", + " record stick_pitch number {'min': -1, 'max': 1}\n", + " record throttle number {'min': -1, 'max': 1}\n", + " record roll_deg number {'min': -90, 'max': 90}\n", + " record pitch_deg number {'min': -90, 'max': 90}\n", + " record alt number {'min': -1, 'max': 500}\n", + " record x number {}\n", + " record y number {}\n", + " record tx number {}\n", + " record ty number {}\n", + " record talt number {'min': 0, 'max': 500}\n", + "\n", + "비행 단계: 경로 0~60초 / 호버링 60~85초 / 장애물 회피 85초 / 비상 착륙 지시 100초, 10Hz 기록\n", + "개인정보: 조종자는 가명 ID(P001~)로만 저장하고, 실명·군번·소속은 별도 대장에서 교관만 관리한다.\n", + "```\n", + "
\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 02. 시계열 비행 궤적 데이터 정제 및 이상 조작 노이즈 필터링 전처리 구현\n", + "**소요시간**: 약 2.0시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "시계열 비행 궤적 노이즈를 필터링하고 스틱 조작 데이터를 정규화하는 전처리 모듈을 구현한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **시계열 비행로그의 시각 중복·역행, GPS 튐, 센서 단절, 스틱 PWM 원시값을 정리하는 전처리 모듈을 구현한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **10Hz 격자로 시각을 정렬하고 중복을 제거한 뒤, 스틱 PWM(1000~2000us)을 -1~1로 정규화하고 중립 데드밴드를 적용 수행하고 검증하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `src/preprocess/trn01_preprocessor.py 및 전처리 전후 데이터셋` 형태의 결과물을 완성한다.\n", + "\n", + "### 🔍 평가 및 검증 기준 (PASS 조건)\n", + "#### 미션 판정\n", + "\n", + "**평가 구분:** 필수\n", + "\n", + "**핵심 평가영역:** 전술 환경 특유의 노이즈(스푸핑, 반사파, 센서 단절 등) 제거 타당성\n", + "\n", + "#### 미션 PASS 조건\n", + "\n", + "- 정량 기준: 데이터 정제 처리 속도 1,000건/초 이상, 전처리 오류율 0%\n", + "\n", + "\n", + "- 진행 기준: 정제 파이프라인 자동화 및 단위 테스트 케이스 5종 통과\n", + "\n", + "\n", + "- 검증 방법: pytest 기반 전처리 정합성 테스트 및 결측치 보정율 확인\n", + "\n", + "\n", + "#### 채점자 판정 메모\n", + "\n", + "> 단순 코드 실행 여부뿐만 아니라 출력된 수치와 근거 자료가 군 실무 요구사항에 부합하는지 면밀히 확인한다.\n", + "\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "\"\"\"시뮬레이터 원시 비행로그의 시각 중복·역행 정리, 스틱 PWM 값(1000~2000us) 정규화, GPS 튐(이상치) 제거(Hampel 필터), 센서 단절 구간 보간을 수행하는 전처리 모듈을 구현하고, 비행 조건(주간/야간/돌풍)별 임계값을 달리 적용해 처리 속도와 위치 오차 개선을 확인한다.\"\"\"\n", + "import time\n", + "import numpy as np\n", + "import pandas as pd\n", + "import matplotlib.pyplot as plt\n", + "\n", + "# ---------------------------------------------------------------------------\n", + "# [교육용가상자료] 드론 조종 시뮬레이터 비행로그 생성기 (실제 훈련생·장비 정보 없음, 조종자는 가명 P001~)\n", + "# 비행 1회 = 120초(10Hz): 경로 비행(0~60초) -> 호버링(60~85초) -> 돌발 장애물 회피(85초) -> 비상 착륙(100초 지시)\n", + "# 조종자마다 숨은 숙련도(skill)와 조작 습관(급조작 jerky, 과보정 overcorr)이 있고, 교관 점수는 이를 반영해 만든다.\n", + "# ---------------------------------------------------------------------------\n", + "DT, STEPS = 0.1, 1200\n", + "T = np.arange(STEPS) * DT\n", + "COND = {\"day\": dict(wind=0.4, gust=0.0, gps=0.25, delay=0.0), \"night\": dict(wind=0.4, gust=0.0, gps=0.5, delay=0.15),\n", + " \"gust\": dict(wind=1.2, gust=2.5, gps=0.25, delay=0.0)}\n", + "OBST_T, LAND_T = 85.0, 100.0\n", + "\n", + "\n", + "def make_pilots(n=40, seed=7):\n", + " rng = np.random.default_rng(seed)\n", + " skill = rng.beta(2.2, 2.0, n)\n", + " return pd.DataFrame({\"pilot\": [f\"P{i + 1:03d}\" for i in range(n)], \"skill\": skill,\n", + " \"jerky\": rng.random(n) < 0.15 + 0.35 * (1 - skill), \"overcorr\": rng.random(n) < 0.15 + 0.35 * (1 - skill)})\n", + "\n", + "\n", + "def target_path(t):\n", + " \"\"\"사각형 경로(40x30m, 고도 20m)를 3m/s로 비행 -> 60초 지점에서 호버링 -> 착륙 지시 후 고도 0.\"\"\"\n", + " # [TODO : 학생 실습 2] 시계열 비행 궤적 데이터 정제 및 이상 조작 노이즈 필터링 전처리 구현 관련 핵심 로직을 구현하세요.\n", + " # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n", + " # <<< 여기에 코드를 작성하세요 >>>\n", + " pass # 작성 후 pass를 제거하세요.\n", + "def fly(pilots, cond=\"day\", seed=0, delay_extra=0.0, obstacle_side=None):\n", + " \"\"\"여러 조종자의 비행 1회를 동시에 모의한다(벡터화). 반환: 로그 배열 dict, 비행별 정답 정보 DataFrame.\"\"\"\n", + " # [TODO : 학생 실습 2] 시계열 비행 궤적 데이터 정제 및 이상 조작 노이즈 필터링 전처리 구현 관련 핵심 로직을 구현하세요.\n", + " # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n", + " # <<< 여기에 코드를 작성하세요 >>>\n", + " pass # 작성 후 pass를 제거하세요.\n", + "def one(log, j):\n", + " \"\"\"여러 조종자 로그에서 j번째 조종자 1명의 로그(DataFrame)를 꺼낸다.\"\"\"\n", + " return pd.DataFrame({k: (v if v.ndim == 1 else v[:, j]) for k, v in log.items()})\n", + "# ---------------------------------------------------------------------------\n", + "\n", + "HAMPEL_K = {\"day\": 3.0, \"night\": 3.5, \"gust\": 4.5} # 조건별 이상치 판정 임계값(돌풍은 실제 큰 움직임이 많아 느슨하게)\n", + "\n", + "\n", + "def make_raw(log, j, cond, rng):\n", + " \"\"\"[교육용가상자료] 원시 로그: PWM 스틱값, GPS 튐 1%, 센서 단절(0.5~2초) 2회, 시각 중복 2%.\"\"\"\n", + " d = one(log, j)\n", + " raw = d[[\"t\", \"x\", \"y\", \"alt\", \"roll_deg\", \"pitch_deg\", \"true_x\", \"true_y\", \"true_alt\"]].copy()\n", + " for s in (\"stick_roll\", \"stick_pitch\", \"throttle\"):\n", + " raw[s + \"_pwm\"] = (1500 + 500 * d[s] + rng.normal(0, 4, len(d))).round().astype(int)\n", + " k = rng.random(len(raw)) < 0.01\n", + " for c in (\"x\", \"y\"):\n", + " raw.loc[k, c] += rng.choice([-1, 1], k.sum()) * rng.uniform(6, 15, k.sum())\n", + " for _ in range(2):\n", + " a = int(rng.integers(50, len(raw) - 30)); raw.loc[a:a + int(rng.integers(5, 20)), [\"x\", \"y\", \"alt\"]] = np.nan\n", + " dup = raw.sample(frac=0.02, random_state=int(rng.integers(1e6)))\n", + " raw = pd.concat([raw, dup]).sample(frac=1.0, random_state=1).reset_index(drop=True) # 순서 섞임 + 중복\n", + " raw[\"t\"] = raw[\"t\"] + rng.normal(0, 0.003, len(raw)) # 시각 흔들림\n", + " return raw\n", + "\n", + "\n", + "def pwm_to_stick(p, dead=8):\n", + " v = (np.asarray(p, float) - 1500) / 500\n", + " v[np.abs(np.asarray(p) - 1500) < dead] = 0.0 # 중립 부근 데드밴드\n", + " return np.clip(v, -1, 1)\n", + "\n", + "\n", + "def hampel(x, win=7, k=3.0):\n", + " s = pd.Series(x)\n", + " med = s.rolling(win, center=True, min_periods=1).median()\n", + " mad = (s - med).abs().rolling(win, center=True, min_periods=1).median() * 1.4826\n", + " out = (s - med).abs() > k * (mad + 0.3) # 0.3m: GPS 잡음 수준 이하 흔들림은 이상치로 보지 않음\n", + " return np.where(out, np.nan, s), int(out.sum())\n", + "\n", + "\n", + "def preprocess(raw, cond):\n", + " d = raw.copy()\n", + " d[\"k\"] = (d[\"t\"] / DT).round().astype(int) # 10Hz 격자 번호로 시각 정렬\n", + " d = d.sort_values(\"k\").drop_duplicates(\"k\").set_index(\"k\")\n", + " d = d.reindex(np.arange(0, d.index.max() + 1))\n", + " d[\"t\"] = np.round(d.index * DT, 1)\n", + " n_missing = int(d[[\"x\", \"y\", \"alt\"]].isna().any(axis=1).sum())\n", + " n_out = 0\n", + " for c in (\"x\", \"y\", \"alt\"):\n", + " d[c], n = hampel(d[c].values, k=HAMPEL_K[cond]); n_out += n\n", + " d[[\"x\", \"y\", \"alt\"]] = d[[\"x\", \"y\", \"alt\"]].interpolate(limit_direction=\"both\")\n", + " for s in (\"stick_roll\", \"stick_pitch\", \"throttle\"):\n", + " d[s] = pwm_to_stick(d[s + \"_pwm\"].interpolate(limit_direction=\"both\").round())\n", + " return d.reset_index(drop=True), n_missing, n_out\n", + "\n", + "\n", + "pilots = make_pilots(30, seed=7)\n", + "rng = np.random.default_rng(3)\n", + "rows, n_rec, t_total = [], 0, 0.0\n", + "for cond in COND:\n", + " log, _ = fly(pilots, cond, seed=1)\n", + " for j in range(len(pilots)):\n", + " raw = make_raw(log, j, cond, rng)\n", + " t0 = time.perf_counter()\n", + " clean, miss, out = preprocess(raw, cond)\n", + " t_total += time.perf_counter() - t0; n_rec += len(raw)\n", + " e0 = np.sqrt((raw[\"x\"] - raw[\"true_x\"]) ** 2 + (raw[\"y\"] - raw[\"true_y\"]) ** 2).mean()\n", + " e1 = np.sqrt((clean[\"x\"] - clean[\"true_x\"]) ** 2 + (clean[\"y\"] - clean[\"true_y\"]) ** 2).mean()\n", + " rows.append({\"조건\": cond, \"결측행\": miss, \"이상치\": out, \"남은결측\": int(clean[[\"x\", \"y\", \"alt\"]].isna().sum().sum()),\n", + " \"위치오차 전(m)\": e0, \"위치오차 후(m)\": e1})\n", + "df = pd.DataFrame(rows)\n", + "print(f\"원시 레코드 {n_rec:,}개(로그 {len(df)}개) 처리, {n_rec / t_total:,.0f} 레코드/초 (목표 1,000건/초 이상), 처리 중 오류 0건\")\n", + "print(f\"결측 보정율 {1 - df['남은결측'].sum() / max(df['결측행'].sum(), 1):.0%} (결측행 {df['결측행'].sum()}개 모두 보간)\")\n", + "print(\"\\n[조건별 전처리 효과(로그 평균)]\")\n", + "print(df.groupby(\"조건\")[[\"결측행\", \"이상치\", \"위치오차 전(m)\", \"위치오차 후(m)\"]].mean().round(2).to_string())\n", + "print(\"조건별 Hampel 임계값:\", HAMPEL_K, \"-> 돌풍 조건은 실제 급기동을 이상치로 지우지 않도록 느슨하게 둔다\")\n", + "\n", + "# 단위 테스트 5종\n", + "raw = make_raw(fly(pilots.iloc[:1], \"day\", seed=9)[0], 0, \"day\", np.random.default_rng(0))\n", + "clean, _, _ = preprocess(raw, \"day\")\n", + "spike = np.r_[np.zeros(10), 50.0, np.zeros(10)]\n", + "tests = {\n", + " \"PWM 1000/1500/2000 -> -1/0/1\": np.allclose(pwm_to_stick([1000, 1500, 2000]), [-1, 0, 1]),\n", + " \"데드밴드(1504 -> 0)\": pwm_to_stick([1504])[0] == 0.0,\n", + " \"단일 튐 값 제거\": np.isnan(hampel(spike)[0][10]),\n", + " \"시각 중복 제거·단조 증가\": clean[\"t\"].is_monotonic_increasing and clean[\"t\"].duplicated().sum() == 0,\n", + " \"결측 없음 + 10Hz 1,200행\": clean[[\"x\", \"y\", \"alt\"]].isna().sum().sum() == 0 and len(clean) == STEPS,\n", + "}\n", + "for k, v in tests.items():\n", + " print(f\"[{'PASS' if v else 'FAIL'}] {k}\")\n", + "print(f\"단위 테스트 {sum(tests.values())}/{len(tests)} 통과\")\n", + "\n", + "seg = slice(400, 700)\n", + "r2 = raw.sort_values(\"t\").iloc[seg]\n", + "plt.figure(figsize=(9, 3.5))\n", + "plt.plot(r2[\"t\"], r2[\"y\"], \".\", ms=3, c=\"gray\", label=\"raw (spikes, gaps)\")\n", + "plt.plot(clean[\"t\"].iloc[seg], clean[\"y\"].iloc[seg], c=\"tab:blue\", label=\"preprocessed\")\n", + "plt.plot(clean[\"t\"].iloc[seg], clean[\"true_y\"].iloc[seg], \"k:\", label=\"true\")\n", + "plt.xlabel(\"time (s)\"); plt.ylabel(\"y (m)\"); plt.legend(); plt.title(\"Flight log preprocessing (synthetic)\")\n", + "plt.tight_layout(); plt.show()\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 02 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + "원시 레코드 110,160개(로그 90개) 처리, 94,969 레코드/초 (목표 1,000건/초 이상), 처리 중 오류 0건\n", + "결측 보정율 100% (결측행 2348개 모두 보간)\n", + "\n", + "[조건별 전처리 효과(로그 평균)]\n", + " 결측행 이상치 위치오차 전(m) 위치오차 후(m)\n", + "조건 \n", + "day 25.37 24.63 0.47 0.31\n", + "gust 26.17 21.87 0.45 0.33\n", + "night 26.73 27.90 0.78 0.62\n", + "조건별 Hampel 임계값: {'day': 3.0, 'night': 3.5, 'gust': 4.5} -> 돌풍 조건은 실제 급기동을 이상치로 지우지 않도록 느슨하게 둔다\n", + "[PASS] PWM 1000/1500/2000 -> -1/0/1\n", + "[PASS] 데드밴드(1504 -> 0)\n", + "[PASS] 단일 튐 값 제거\n", + "[PASS] 시각 중복 제거·단조 증가\n", + "[PASS] 결측 없음 + 10Hz 1,200행\n", + "단위 테스트 5/5 통과\n", + "```\n", + "
\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 03. 비행 경로 이탈도 및 호버링 안정성 계산 1차 숙련도 채점 Baseline 알고리즘 구현\n", + "**소요시간**: 약 2.5시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "비행 경로 이탈도 및 호버링 안정성 기반 1차 숙련도 채점 Baseline 알고리즘을 개발한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **비행 경로 이탈도와 호버링 안정성, 고도 유지 오차로 1차 숙련도 점수와 등급(A/B/C)을 계산하는 Baseline 채점 알고리즘을 개발한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **경로 RMSE, 호버링 평균 이탈·흔들림, 고도 오차를 조건별로 표준화하고 기준 집단으로 가중치를 1차 튜닝해 0~100점 채점식 작성하여 원인을 분석하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `src/models/trn01_baseline_model.py 및 기본 추론 결과 보고서` 형태의 결과물을 완성한다.\n", + "\n", + "### 🔍 평가 및 검증 기준 (PASS 조건)\n", + "#### 미션 판정\n", + "\n", + "**평가 구분:** 필수\n", + "\n", + "**핵심 평가영역:** 알고리즘의 논리적 흐름 및 군사적 판단 기준의 투명성\n", + "\n", + "#### 미션 PASS 조건\n", + "\n", + "- 정량 기준: 기본 데이터셋 대상 탐지/처리 정확도 75% 이상 달성\n", + "\n", + "\n", + "- 진행 기준: 단위 기능 동작 확인 및 기본 시나리오 실행 로그 생성\n", + "\n", + "\n", + "- 검증 방법: 기준 벤치마크 데이터셋 대상 추론 결과 오차율 비교\n", + "\n", + "\n", + "#### 채점자 판정 메모\n", + "\n", + "> 단순 코드 실행 여부뿐만 아니라 출력된 수치와 근거 자료가 군 실무 요구사항에 부합하는지 면밀히 확인한다.\n", + "\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "\"\"\"비행 경로 이탈도(경로 RMSE)와 호버링 안정성(평균 이탈·흔들림), 고도 유지 오차로 1차 숙련도 점수(0~100)와 등급(A/B/C)을 계산하는 Baseline 채점 알고리즘을 구현하고, 교관 평가 등급과의 일치율(처리 정확도)과 판단 지연을 측정한다.\"\"\"\n", + "import time\n", + "import numpy as np\n", + "import pandas as pd\n", + "import matplotlib.pyplot as plt\n", + "from sklearn.linear_model import LinearRegression\n", + "\n", + "# ---------------------------------------------------------------------------\n", + "# [교육용가상자료] 드론 조종 시뮬레이터 비행로그 생성기 (실제 훈련생·장비 정보 없음, 조종자는 가명 P001~)\n", + "# 비행 1회 = 120초(10Hz): 경로 비행(0~60초) -> 호버링(60~85초) -> 돌발 장애물 회피(85초) -> 비상 착륙(100초 지시)\n", + "# 조종자마다 숨은 숙련도(skill)와 조작 습관(급조작 jerky, 과보정 overcorr)이 있고, 교관 점수는 이를 반영해 만든다.\n", + "# ---------------------------------------------------------------------------\n", + "DT, STEPS = 0.1, 1200\n", + "T = np.arange(STEPS) * DT\n", + "COND = {\"day\": dict(wind=0.4, gust=0.0, gps=0.25, delay=0.0), \"night\": dict(wind=0.4, gust=0.0, gps=0.5, delay=0.15),\n", + " \"gust\": dict(wind=1.2, gust=2.5, gps=0.25, delay=0.0)}\n", + "OBST_T, LAND_T = 85.0, 100.0\n", + "\n", + "\n", + "def make_pilots(n=40, seed=7):\n", + " rng = np.random.default_rng(seed)\n", + " skill = rng.beta(2.2, 2.0, n)\n", + " return pd.DataFrame({\"pilot\": [f\"P{i + 1:03d}\" for i in range(n)], \"skill\": skill,\n", + " \"jerky\": rng.random(n) < 0.15 + 0.35 * (1 - skill), \"overcorr\": rng.random(n) < 0.15 + 0.35 * (1 - skill)})\n", + "\n", + "\n", + "def target_path(t):\n", + " \"\"\"사각형 경로(40x30m, 고도 20m)를 3m/s로 비행 -> 60초 지점에서 호버링 -> 착륙 지시 후 고도 0.\"\"\"\n", + " # [TODO : 학생 실습 3] 비행 경로 이탈도 및 호버링 안정성 계산 1차 숙련도 채점 Baseline 알고리즘 구현 관련 핵심 로직을 구현하세요.\n", + " # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n", + " # <<< 여기에 코드를 작성하세요 >>>\n", + " pass # 작성 후 pass를 제거하세요.\n", + "def fly(pilots, cond=\"day\", seed=0, delay_extra=0.0, obstacle_side=None):\n", + " \"\"\"여러 조종자의 비행 1회를 동시에 모의한다(벡터화). 반환: 로그 배열 dict, 비행별 정답 정보 DataFrame.\"\"\"\n", + " # [TODO : 학생 실습 3] 비행 경로 이탈도 및 호버링 안정성 계산 1차 숙련도 채점 Baseline 알고리즘 구현 관련 핵심 로직을 구현하세요.\n", + " # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n", + " # <<< 여기에 코드를 작성하세요 >>>\n", + " pass # 작성 후 pass를 제거하세요.\n", + "def one(log, j):\n", + " \"\"\"여러 조종자 로그에서 j번째 조종자 1명의 로그(DataFrame)를 꺼낸다.\"\"\"\n", + " return pd.DataFrame({k: (v if v.ndim == 1 else v[:, j]) for k, v in log.items()})\n", + "# ---------------------------------------------------------------------------\n", + "\n", + "# ---- 비행로그 특징 추출 공통 함수(여러 조종자 로그를 한 번에 계산) ----\n", + "from scipy.ndimage import uniform_filter1d\n", + "\n", + "BASE_F = [\"path_rmse\", \"hover_drift\", \"hover_std\", \"alt_err\"]\n", + "ADV_F = BASE_F + [\"obs_rt\", \"obs_lateral\", \"touch_vz\", \"att_exceed\", \"jerk\", \"overshoot\", \"n_risk\"]\n", + "RULES = {\"near_collision\": (\"obs_lateral\", \"<\", 2.5), \"hard_landing\": (\"touch_vz\", \">\", 2.0), \"attitude_exceed\": (\"att_exceed\", \">=\", 10)}\n", + "\n", + "\n", + "def detect(df):\n", + " \"\"\"위험 조작 사건 탐지 규칙: 장애물 2.5초 후 회피 거리 2.5m 미만, 접지 속도 2.0m/s 초과, 자세 35도 초과 1초 이상.\"\"\"\n", + " out = {}\n", + " for ev, (f, op, th) in RULES.items():\n", + " out[ev] = df[f] < th if op == \"<\" else (df[f] > th if op == \">\" else df[f] >= th)\n", + " return pd.DataFrame(out)\n", + "\n", + "\n", + "def features(log):\n", + " \"\"\"경로 이탈도·호버링 안정성(기본) + 장애물 반응·착륙 속도·자세 한계·급조작·과보정(고도화) 특징.\"\"\"\n", + " X, Y, A = (uniform_filter1d(log[k], 9, axis=0) for k in (\"x\", \"y\", \"alt\")) # GPS 잡음 완화(이동평균 0.9초)\n", + " h = np.hypot(X - log[\"tx\"], Y - log[\"ty\"])\n", + " cr, hv = slice(0, 600), slice(620, 850)\n", + " k0, k1 = int(OBST_T / DT), int((OBST_T + 2.5) / DT)\n", + " side = np.sign(log[\"ty\"][k0 + 1] - log[\"ty\"][k0 - 1])\n", + " moved = (log[\"stick_roll\"][k0:k0 + 40] * side) > 0.3\n", + " rt = np.where(moved.any(0), moved.argmax(0) * DT, 4.0)\n", + " vz = -np.gradient(A, DT, axis=0)\n", + " land = np.argmax((A < 0.4) & (T[:, None] >= LAND_T), axis=0)\n", + " touch = np.array([vz[max(i - 6, 0):i - 2, j].mean() if i > 6 else 3.0 for j, i in enumerate(land)])\n", + " ds = np.abs(np.diff(np.stack([log[\"stick_roll\"], log[\"stick_pitch\"]]), axis=1)) / DT\n", + " return pd.DataFrame({\n", + " \"path_rmse\": np.sqrt((h[cr] ** 2).mean(0)), \"hover_drift\": h[hv].mean(0), \"hover_std\": np.hypot(X[hv].std(0), Y[hv].std(0)),\n", + " \"alt_err\": np.abs(A[:850] - log[\"talt\"][:850]).mean(0),\n", + " \"obs_rt\": rt, \"obs_lateral\": np.abs(Y[k1] - Y[k0]), \"touch_vz\": touch,\n", + " \"att_exceed\": (np.maximum(np.abs(log[\"roll_deg\"]), np.abs(log[\"pitch_deg\"])) > 35).sum(0),\n", + " \"jerk\": np.percentile(ds, 95, axis=(0, 1)),\n", + " \"overshoot\": np.clip(np.abs(Y[k0:k0 + 120] - Y[k0]).max(0) - 6.0, 0, None)}).pipe(\n", + " lambda d: d.assign(n_risk=detect(d).sum(axis=1))) # overshoot: 회피 후 목표 6m를 넘어선 거리(과보정)\n", + "\n", + "\n", + "def make_dataset(n_pilots=40, seed=7, conds=(\"day\", \"night\", \"gust\"), flights=1):\n", + " \"\"\"조종자 n명 x 조건 x 반복 비행 -> 비행별 특징 + 정답(교관 점수·등급·위험 사건) 표.\"\"\"\n", + " pilots = make_pilots(n_pilots, seed)\n", + " out = []\n", + " for c in conds:\n", + " for f in range(flights):\n", + " log, tr = fly(pilots, c, seed=seed * 100 + list(COND).index(c) * 10 + f)\n", + " out.append(pd.concat([tr.reset_index(drop=True), features(log)], axis=1).assign(flight=f))\n", + " return pd.concat(out, ignore_index=True)\n", + "\n", + "\n", + "def cond_z(df, ref, cols):\n", + " \"\"\"조건별 기준 집단(ref)의 평균·표준편차로 표준화: 돌풍처럼 어려운 조건에서 받는 불이익을 줄인다.\"\"\"\n", + " z = df.copy()\n", + " z[cols] = z[cols].astype(float)\n", + " for c in df[\"cond\"].unique():\n", + " r = ref[ref[\"cond\"] == c][cols]\n", + " z.loc[df[\"cond\"] == c, cols] = (df.loc[df[\"cond\"] == c, cols] - r.mean()) / (r.std() + 1e-9)\n", + " return z\n", + "\n", + "\n", + "def to_grade(score):\n", + " return np.where(score >= 75, \"A\", np.where(score >= 55, \"B\", \"C\"))\n", + "\n", + "# [교육용가상자료] 기준(튜닝)용 조종자 40명, 평가용 조종자 40명 x 조건 3종(주간·야간·돌풍)\n", + "train = make_dataset(40, seed=7)\n", + "test = make_dataset(40, seed=8)\n", + "\n", + "# 1) 휴리스틱 가중치 1차 튜닝: 조건별 표준화 특징 -> 교관 점수 선형 근사\n", + "ztr, zte = cond_z(train, train, BASE_F), cond_z(test, train, BASE_F)\n", + "lr = LinearRegression().fit(ztr[BASE_F], train[\"instructor\"])\n", + "print(\"채점식: 점수 = {:.1f}\".format(lr.intercept_) + \"\".join(f\" {w:+.1f}x{f}\" for f, w in zip(BASE_F, lr.coef_)) + \" (특징은 조건별 표준화 값)\")\n", + "\n", + "t0 = time.perf_counter()\n", + "test[\"base_score\"] = np.clip(lr.predict(zte[BASE_F]), 0, 100)\n", + "test[\"base_grade\"] = to_grade(test[\"base_score\"])\n", + "ms = (time.perf_counter() - t0) * 1000 / len(test)\n", + "acc = (test[\"base_grade\"] == test[\"grade\"]).mean()\n", + "print(f\"평가 비행 {len(test)}회: 등급 일치율(처리 정확도) {acc:.1%} (목표 75% 이상: {'달성' if acc >= 0.75 else '미달'}), \"\n", + " f\"점수 평균 오차 {np.abs(test['base_score'] - test['instructor']).mean():.1f}점, 채점 지연 {ms:.3f} ms/비행\")\n", + "\n", + "# 2) 조건별 정확도 + 조건 표준화를 하지 않았을 때 비교\n", + "raw = LinearRegression().fit(train[BASE_F], train[\"instructor\"])\n", + "test[\"raw_grade\"] = to_grade(raw.predict(test[BASE_F]))\n", + "print(\"\\n조건 표준화 O 표준화 X\")\n", + "for c in COND:\n", + " s = test[test[\"cond\"] == c]\n", + " print(f\"{c:6s} {(s['base_grade'] == s['grade']).mean():8.1%} {(s['raw_grade'] == s['grade']).mean():8.1%}\")\n", + "print(f\"전체 {acc:8.1%} {(test['raw_grade'] == test['grade']).mean():8.1%}\")\n", + "gust_raw = raw.predict(test[test[\"cond\"] == \"gust\"][BASE_F]).mean() - raw.predict(test[test[\"cond\"] == \"day\"][BASE_F]).mean()\n", + "print(f\"-> 표준화하지 않으면 돌풍 비행 점수가 주간보다 평균 {gust_raw:+.1f}점 달라진다(조건 효과가 점수에 섞임). 표준화는 같은 조건 동료 대비 비교다.\")\n", + "\n", + "# 3) 혼동표와 한계 분석\n", + "print(\"\\n[교관 등급 x Baseline 등급]\")\n", + "print(pd.crosstab(test[\"grade\"].rename(\"교관\"), test[\"base_grade\"].rename(\"Baseline\")).to_string())\n", + "over = test[(test[\"grade\"] == \"C\") & (test[\"base_grade\"] != \"C\")]\n", + "ev = [\"near_collision\", \"hard_landing\"]\n", + "print(f\"\\n가장 많은 오류: 교관 C -> Baseline B {len(over)}건. 교관 C 비행의 {test[test['grade'] == 'C'][ev].any(axis=1).mean():.0%}가\"\n", + " f\" 장애물 근접·경착륙을 포함하지만 Baseline 특징(경로·호버링·고도)에는 이 정보가 없다.\")\n", + "print(\"-> 경로·호버링만 보는 Baseline은 돌발 상황 대응(장애물 회피, 비상 착륙)을 반영하지 못한다. 미션 4에서 반응 특징을 추가한다.\")\n", + "\n", + "plt.figure(figsize=(6, 5))\n", + "for c, col in zip(COND, [\"tab:blue\", \"tab:purple\", \"tab:orange\"]):\n", + " s = test[test[\"cond\"] == c]\n", + " plt.scatter(s[\"instructor\"], s[\"base_score\"], s=15, c=col, label=c)\n", + "plt.plot([20, 100], [20, 100], \"k--\"); plt.axhline(75, c=\"gray\", lw=0.5); plt.axhline(55, c=\"gray\", lw=0.5)\n", + "plt.xlabel(\"instructor score\"); plt.ylabel(\"baseline AI score\"); plt.legend(); plt.title(\"Baseline skill score vs instructor (synthetic)\")\n", + "plt.tight_layout(); plt.show()\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 03 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + "채점식: 점수 = 59.3 -0.4xpath_rmse -0.8xhover_drift +0.2xhover_std -13.0xalt_err (특징은 조건별 표준화 값)\n", + "평가 비행 120회: 등급 일치율(처리 정확도) 75.8% (목표 75% 이상: 달성), 점수 평균 오차 5.3점, 채점 지연 0.025 ms/비행\n", + "\n", + "조건 표준화 O 표준화 X\n", + "day 70.0% 75.0%\n", + "night 80.0% 70.0%\n", + "gust 77.5% 82.5%\n", + "전체 75.8% 75.8%\n", + "-> 표준화하지 않으면 돌풍 비행 점수가 주간보다 평균 -4.0점 달라진다(조건 효과가 점수에 섞임). 표준화는 같은 조건 동료 대비 비교다.\n", + "\n", + "[교관 등급 x Baseline 등급]\n", + "Baseline A B C\n", + "교관 \n", + "A 15 6 0\n", + "B 3 26 4\n", + "C 0 16 50\n", + "\n", + "가장 많은 오류: 교관 C -> Baseline B 16건. 교관 C 비행의 95%가 장애물 근접·경착륙을 포함하지만 Baseline 특징(경로·호버링·고도)에는 이 정보가 없다.\n", + "-> 경로·호버링만 보는 Baseline은 돌발 상황 대응(장애물 회피, 비상 착륙)을 반영하지 못한다. 미션 4에서 반응 특징을 추가한다.\n", + "```\n", + "
\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 04. 돌발 장애물 회피 및 비상 착륙 반응 속도 기반 정밀 숙련도 평가 엔진 고도화\n", + "**소요시간**: 약 3.0시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "돌발 장애물 회피 및 비상 착륙 반응 속도를 반영한 정밀 숙련도 평가 엔진을 고도화한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **돌발 장애물 회피 반응과 비상 착륙 접지 속도, 자세 한계, 급조작·과보정 특징을 반영한 정밀 숙련도 평가 엔진으로 고도화한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **장애물 출현 후 스틱 반응시간과 2.5초 후 회피 거리, 접지 직전 하강 속도, 자세 35도 초과 시간, 스틱 변화율, 회피 후 초과 이동을 특징으로 추가하고 위험 조작 사건(장애물 근접·경착륙·자세 한계) 탐지 규칙 작성하여 원인을 분석하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `src/models/trn01_advanced_detector.py 및 예외 탐지 테스트 결과` 형태의 결과물을 완성한다.\n", + "\n", + "### 🔍 평가 및 검증 기준 (PASS 조건)\n", + "#### 미션 판정\n", + "\n", + "**평가 구분:** 필수\n", + "\n", + "**핵심 평가영역:** 기만/교란 공격에 대한 방어 로직의 현실성 및 신뢰성\n", + "\n", + "#### 미션 PASS 조건\n", + "\n", + "- 정량 기준: 복합 위협 탐지 재현율(Recall) 85% 이상, 처리 지연 200ms 이하\n", + "\n", + "\n", + "- 진행 기준: 복합 위협 시나리오 10종에 대한 탐지 테스트 통과\n", + "\n", + "\n", + "- 검증 방법: 합성 위협 데이터셋 주입 시 이상치 탐지 정확도 검증\n", + "\n", + "\n", + "#### 채점자 판정 메모\n", + "\n", + "> 단순 코드 실행 여부뿐만 아니라 출력된 수치와 근거 자료가 군 실무 요구사항에 부합하는지 면밀히 확인한다.\n", + "\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "\"\"\"돌발 장애물 회피 반응시간·회피 거리, 비상 착륙 접지 속도, 자세 한계 초과, 급조작·과보정 특징을 추가한 정밀 숙련도 평가 엔진으로 고도화하고, 복합 시나리오 10종에서 위험 조작 사건(장애물 근접·경착륙·자세 한계) 탐지 재현율, 등급 일치율, 로그당 처리 지연을 검증한다.\"\"\"\n", + "import time\n", + "import numpy as np\n", + "import pandas as pd\n", + "import matplotlib.pyplot as plt\n", + "from sklearn.linear_model import LinearRegression, Ridge\n", + "\n", + "# ---------------------------------------------------------------------------\n", + "# [교육용가상자료] 드론 조종 시뮬레이터 비행로그 생성기 (실제 훈련생·장비 정보 없음, 조종자는 가명 P001~)\n", + "# 비행 1회 = 120초(10Hz): 경로 비행(0~60초) -> 호버링(60~85초) -> 돌발 장애물 회피(85초) -> 비상 착륙(100초 지시)\n", + "# 조종자마다 숨은 숙련도(skill)와 조작 습관(급조작 jerky, 과보정 overcorr)이 있고, 교관 점수는 이를 반영해 만든다.\n", + "# ---------------------------------------------------------------------------\n", + "DT, STEPS = 0.1, 1200\n", + "T = np.arange(STEPS) * DT\n", + "COND = {\"day\": dict(wind=0.4, gust=0.0, gps=0.25, delay=0.0), \"night\": dict(wind=0.4, gust=0.0, gps=0.5, delay=0.15),\n", + " \"gust\": dict(wind=1.2, gust=2.5, gps=0.25, delay=0.0)}\n", + "OBST_T, LAND_T = 85.0, 100.0\n", + "\n", + "\n", + "def make_pilots(n=40, seed=7):\n", + " rng = np.random.default_rng(seed)\n", + " skill = rng.beta(2.2, 2.0, n)\n", + " return pd.DataFrame({\"pilot\": [f\"P{i + 1:03d}\" for i in range(n)], \"skill\": skill,\n", + " \"jerky\": rng.random(n) < 0.15 + 0.35 * (1 - skill), \"overcorr\": rng.random(n) < 0.15 + 0.35 * (1 - skill)})\n", + "\n", + "\n", + "def target_path(t):\n", + " \"\"\"사각형 경로(40x30m, 고도 20m)를 3m/s로 비행 -> 60초 지점에서 호버링 -> 착륙 지시 후 고도 0.\"\"\"\n", + " # [TODO : 학생 실습 4] 돌발 장애물 회피 및 비상 착륙 반응 속도 기반 정밀 숙련도 평가 엔진 고도화 관련 핵심 로직을 구현하세요.\n", + " # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n", + " # <<< 여기에 코드를 작성하세요 >>>\n", + " pass # 작성 후 pass를 제거하세요.\n", + "def fly(pilots, cond=\"day\", seed=0, delay_extra=0.0, obstacle_side=None):\n", + " \"\"\"여러 조종자의 비행 1회를 동시에 모의한다(벡터화). 반환: 로그 배열 dict, 비행별 정답 정보 DataFrame.\"\"\"\n", + " # [TODO : 학생 실습 4] 돌발 장애물 회피 및 비상 착륙 반응 속도 기반 정밀 숙련도 평가 엔진 고도화 관련 핵심 로직을 구현하세요.\n", + " # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n", + " # <<< 여기에 코드를 작성하세요 >>>\n", + " pass # 작성 후 pass를 제거하세요.\n", + "def one(log, j):\n", + " \"\"\"여러 조종자 로그에서 j번째 조종자 1명의 로그(DataFrame)를 꺼낸다.\"\"\"\n", + " return pd.DataFrame({k: (v if v.ndim == 1 else v[:, j]) for k, v in log.items()})\n", + "# ---------------------------------------------------------------------------\n", + "\n", + "# ---- 비행로그 특징 추출 공통 함수(여러 조종자 로그를 한 번에 계산) ----\n", + "from scipy.ndimage import uniform_filter1d\n", + "\n", + "BASE_F = [\"path_rmse\", \"hover_drift\", \"hover_std\", \"alt_err\"]\n", + "ADV_F = BASE_F + [\"obs_rt\", \"obs_lateral\", \"touch_vz\", \"att_exceed\", \"jerk\", \"overshoot\", \"n_risk\"]\n", + "RULES = {\"near_collision\": (\"obs_lateral\", \"<\", 2.5), \"hard_landing\": (\"touch_vz\", \">\", 2.0), \"attitude_exceed\": (\"att_exceed\", \">=\", 10)}\n", + "\n", + "\n", + "def detect(df):\n", + " \"\"\"위험 조작 사건 탐지 규칙: 장애물 2.5초 후 회피 거리 2.5m 미만, 접지 속도 2.0m/s 초과, 자세 35도 초과 1초 이상.\"\"\"\n", + " out = {}\n", + " for ev, (f, op, th) in RULES.items():\n", + " out[ev] = df[f] < th if op == \"<\" else (df[f] > th if op == \">\" else df[f] >= th)\n", + " return pd.DataFrame(out)\n", + "\n", + "\n", + "def features(log):\n", + " \"\"\"경로 이탈도·호버링 안정성(기본) + 장애물 반응·착륙 속도·자세 한계·급조작·과보정(고도화) 특징.\"\"\"\n", + " X, Y, A = (uniform_filter1d(log[k], 9, axis=0) for k in (\"x\", \"y\", \"alt\")) # GPS 잡음 완화(이동평균 0.9초)\n", + " h = np.hypot(X - log[\"tx\"], Y - log[\"ty\"])\n", + " cr, hv = slice(0, 600), slice(620, 850)\n", + " k0, k1 = int(OBST_T / DT), int((OBST_T + 2.5) / DT)\n", + " side = np.sign(log[\"ty\"][k0 + 1] - log[\"ty\"][k0 - 1])\n", + " moved = (log[\"stick_roll\"][k0:k0 + 40] * side) > 0.3\n", + " rt = np.where(moved.any(0), moved.argmax(0) * DT, 4.0)\n", + " vz = -np.gradient(A, DT, axis=0)\n", + " land = np.argmax((A < 0.4) & (T[:, None] >= LAND_T), axis=0)\n", + " touch = np.array([vz[max(i - 6, 0):i - 2, j].mean() if i > 6 else 3.0 for j, i in enumerate(land)])\n", + " ds = np.abs(np.diff(np.stack([log[\"stick_roll\"], log[\"stick_pitch\"]]), axis=1)) / DT\n", + " return pd.DataFrame({\n", + " \"path_rmse\": np.sqrt((h[cr] ** 2).mean(0)), \"hover_drift\": h[hv].mean(0), \"hover_std\": np.hypot(X[hv].std(0), Y[hv].std(0)),\n", + " \"alt_err\": np.abs(A[:850] - log[\"talt\"][:850]).mean(0),\n", + " \"obs_rt\": rt, \"obs_lateral\": np.abs(Y[k1] - Y[k0]), \"touch_vz\": touch,\n", + " \"att_exceed\": (np.maximum(np.abs(log[\"roll_deg\"]), np.abs(log[\"pitch_deg\"])) > 35).sum(0),\n", + " \"jerk\": np.percentile(ds, 95, axis=(0, 1)),\n", + " \"overshoot\": np.clip(np.abs(Y[k0:k0 + 120] - Y[k0]).max(0) - 6.0, 0, None)}).pipe(\n", + " lambda d: d.assign(n_risk=detect(d).sum(axis=1))) # overshoot: 회피 후 목표 6m를 넘어선 거리(과보정)\n", + "\n", + "\n", + "def make_dataset(n_pilots=40, seed=7, conds=(\"day\", \"night\", \"gust\"), flights=1):\n", + " \"\"\"조종자 n명 x 조건 x 반복 비행 -> 비행별 특징 + 정답(교관 점수·등급·위험 사건) 표.\"\"\"\n", + " pilots = make_pilots(n_pilots, seed)\n", + " out = []\n", + " for c in conds:\n", + " for f in range(flights):\n", + " log, tr = fly(pilots, c, seed=seed * 100 + list(COND).index(c) * 10 + f)\n", + " out.append(pd.concat([tr.reset_index(drop=True), features(log)], axis=1).assign(flight=f))\n", + " return pd.concat(out, ignore_index=True)\n", + "\n", + "\n", + "def cond_z(df, ref, cols):\n", + " \"\"\"조건별 기준 집단(ref)의 평균·표준편차로 표준화: 돌풍처럼 어려운 조건에서 받는 불이익을 줄인다.\"\"\"\n", + " z = df.copy()\n", + " z[cols] = z[cols].astype(float)\n", + " for c in df[\"cond\"].unique():\n", + " r = ref[ref[\"cond\"] == c][cols]\n", + " z.loc[df[\"cond\"] == c, cols] = (df.loc[df[\"cond\"] == c, cols] - r.mean()) / (r.std() + 1e-9)\n", + " return z\n", + "\n", + "\n", + "def to_grade(score):\n", + " return np.where(score >= 75, \"A\", np.where(score >= 55, \"B\", \"C\"))\n", + "\n", + "train = make_dataset(40, seed=7)\n", + "zt = cond_z(train, train, ADV_F)\n", + "base = LinearRegression().fit(cond_z(train, train, BASE_F)[BASE_F], train[\"instructor\"])\n", + "eng = Ridge(alpha=1.0).fit(zt[ADV_F], train[\"instructor\"])\n", + "\n", + "# [교육용가상자료] 복합 시나리오 10종: (조건, 영상 지연 추가 초, 장애물 방향)\n", + "SCEN = [(\"1 주간 기본\", \"day\", 0.0, None), (\"2 야간\", \"night\", 0.0, None), (\"3 돌풍\", \"gust\", 0.0, None), (\"4 장애물 좌측\", \"day\", 0.0, -1),\n", + " (\"5 장애물 우측\", \"day\", 0.0, 1), (\"6 영상 지연 0.3초\", \"day\", 0.3, None), (\"7 돌풍+장애물 좌측\", \"gust\", 0.0, -1),\n", + " (\"8 야간+영상 지연\", \"night\", 0.3, None), (\"9 돌풍+영상 지연\", \"gust\", 0.3, None), (\"10 야간+장애물 우측\", \"night\", 0.0, 1)]\n", + "pilots = make_pilots(40, seed=8)\n", + "rows, all_det, ms = [], [], []\n", + "for i, (name, cond, dly, side) in enumerate(SCEN):\n", + " log, tr = fly(pilots, cond, seed=500 + i, delay_extra=dly, obstacle_side=side)\n", + " t0 = time.perf_counter()\n", + " f = features(log)\n", + " df = pd.concat([tr.reset_index(drop=True), f], axis=1)\n", + " df[\"score\"] = np.clip(eng.predict(cond_z(df, train, ADV_F)[ADV_F]), 0, 100)\n", + " det = detect(df)\n", + " ms.append((time.perf_counter() - t0) * 1000 / len(df))\n", + " df[\"base\"] = np.clip(base.predict(cond_z(df, train, BASE_F)[BASE_F]), 0, 100)\n", + " truth = df[list(RULES)].astype(bool)\n", + " tp, pos = int((det & truth).values.sum()), int(truth.values.sum())\n", + " all_det.append((det, truth))\n", + " rows.append({\"시나리오\": name, \"위험사건\": pos, \"탐지\": tp, \"재현율\": round(tp / max(pos, 1), 3), \"오탐\": int((det & ~truth).values.sum()),\n", + " \"Baseline 일치\": round((to_grade(df[\"base\"]) == df[\"grade\"]).mean(), 3), \"고도화 일치\": round((to_grade(df[\"score\"]) == df[\"grade\"]).mean(), 3)})\n", + "R = pd.DataFrame(rows)\n", + "print(R.to_string(index=False))\n", + "rec = R[\"탐지\"].sum() / R[\"위험사건\"].sum()\n", + "print(f\"\\n위험 조작 사건 탐지 재현율 {rec:.1%} (목표 85% 이상: {'달성' if rec >= 0.85 else '미달'}), 로그당 처리 {np.mean(ms):.1f} ms (최대 {np.max(ms):.1f}, 목표 200 ms 이하)\")\n", + "print(f\"등급 일치율 Baseline {R['Baseline 일치'].mean():.1%} -> 고도화 엔진 {R['고도화 일치'].mean():.1%}, 10종 중 재현율 85% 이상 {(R['재현율'] >= 0.85).sum()}개\")\n", + "\n", + "# 사건별 재현율·오탐(오경보 유발 패턴)\n", + "det = pd.concat([d for d, _ in all_det]); tru = pd.concat([t for _, t in all_det])\n", + "print(\"\\n사건 실제 재현율 오탐\")\n", + "for ev in RULES:\n", + " print(f\"{ev:15s} {tru[ev].sum():4d} {(det[ev] & tru[ev]).sum() / max(tru[ev].sum(), 1):.2f} {(det[ev] & ~tru[ev]).sum():4d}\")\n", + "print(\"오탐 패턴: 접지 속도는 고도 로그를 미분해 추정하므로 고도 잡음이 큰 비행에서 과대 추정된다 -> 접지 직전 0.4초 평균 사용, 임계값 2.0m/s로 여유를 둠\")\n", + "\n", + "w = pd.Series(eng.coef_, index=ADV_F).sort_values()\n", + "print(\"\\n엔진 가중치(조건별 표준화 특징 1단위당 점수):\", \", \".join(f\"{k} {v:+.1f}\" for k, v in w.items()))\n", + "\n", + "plt.figure(figsize=(8, 4))\n", + "plt.barh(w.index, w.values, color=[\"tab:red\" if v < 0 else \"tab:blue\" for v in w.values])\n", + "plt.xlabel(\"score change per 1 SD\"); plt.title(\"Advanced skill engine weights (synthetic)\"); plt.tight_layout(); plt.show()\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 04 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + " 시나리오 위험사건 탐지 재현율 오탐 Baseline 일치 고도화 일치\n", + " 1 주간 기본 47 47 1.000 3 0.725 0.800\n", + " 2 야간 46 45 0.978 3 0.775 0.775\n", + " 3 돌풍 65 65 1.000 7 0.625 0.825\n", + " 4 장애물 좌측 42 41 0.976 4 0.800 0.875\n", + " 5 장애물 우측 37 36 0.973 7 0.825 0.850\n", + "6 영상 지연 0.3초 52 49 0.942 3 0.675 0.625\n", + " 7 돌풍+장애물 좌측 56 56 1.000 6 0.750 0.800\n", + " 8 야간+영상 지연 54 51 0.944 5 0.650 0.675\n", + " 9 돌풍+영상 지연 63 63 1.000 7 0.750 0.750\n", + "10 야간+장애물 우측 46 44 0.957 5 0.825 0.800\n", + "\n", + "위험 조작 사건 탐지 재현율 97.8% (목표 85% 이상: 달성), 로그당 처리 0.6 ms (최대 0.9, 목표 200 ms 이하)\n", + "등급 일치율 Baseline 74.0% -> 고도화 엔진 77.8%, 10종 중 재현율 85% 이상 10개\n", + "\n", + "사건 실제 재현율 오탐\n", + "near_collision 187 0.94 6\n", + "hard_landing 256 1.00 44\n", + "attitude_exceed 65 1.00 0\n", + "오탐 패턴: 접지 속도는 고도 로그를 미분해 추정하므로 고도 잡음이 큰 비행에서 과대 추정된다 -> 접지 직전 0.4초 평균 사용, 임계값 2.0m/s로 여유를 둠\n", + "\n", + "엔진 가중치(조건별 표준화 특징 1단위당 점수): alt_err -6.7, jerk -3.1, touch_vz -2.6, n_risk -2.4, hover_drift -2.2, obs_rt -1.1, overshoot -1.1, att_exceed -0.4, path_rmse +0.2, obs_lateral +2.0, hover_std +2.2\n", + "```\n", + "
\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 05. 조종자별 조작 습관(급조작, 과보정) 분석 및 신뢰도 등급 산출 모듈 개발\n", + "**소요시간**: 약 3.0시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "조종자별 조작 습관(급조작, 과보정)을 분석하고 숙련도 등급 및 신뢰도 점수를 산출한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **조종자별 조작 습관(급조작, 과보정)을 분석하고 여러 비행을 묶어 숙련도 등급과 신뢰도 등급(High/Medium/Low)을 산출한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **같은 조건의 습관 없는 조종자 대비 스틱 변화율과 회피 후 초과 이동으로 습관 판정하고 정확도를 측정하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `src/verification/trn01_confidence_engine.py` 형태의 결과물을 완성한다.\n", + "\n", + "### 🔍 평가 및 검증 기준 (PASS 조건)\n", + "#### 미션 판정\n", + "\n", + "**평가 구분:** 필수\n", + "\n", + "**핵심 평가영역:** 신뢰도 등급(High/Med/Low) 부여 기준의 군사적 타당성\n", + "\n", + "#### 미션 PASS 조건\n", + "\n", + "- 정량 기준: 오경보율(FAR) 40% 이상 감축, 신뢰도 스코어 산출 일관성 90% 이상\n", + "\n", + "\n", + "- 진행 기준: 오경보 필터링 전후 비교 지표 산출 및 통합 테스트 완료\n", + "\n", + "\n", + "- 검증 방법: ROC-AUC 곡선 분석 및 임계값 민감도 테스트\n", + "\n", + "\n", + "#### 채점자 판정 메모\n", + "\n", + "> 단순 코드 실행 여부뿐만 아니라 출력된 수치와 근거 자료가 군 실무 요구사항에 부합하는지 면밀히 확인한다.\n", + "\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "\"\"\"조종자별 여러 비행의 조작 습관(급조작=스틱 변화율, 과보정=회피 후 초과 이동)을 분석하고, 비행 점수를 묶어 숙련도 등급과 신뢰도(부트스트랩 등급 확률)를 산출한다. 비행 1회로 '재교육 대상'을 지정하던 방식 대비 오지정률(FAR) 감축, ROC-AUC, 임계값 민감도, 등급 일관성을 검증한다.\"\"\"\n", + "import numpy as np\n", + "import pandas as pd\n", + "import matplotlib.pyplot as plt\n", + "from sklearn.linear_model import Ridge\n", + "from sklearn.metrics import roc_auc_score\n", + "\n", + "# ---------------------------------------------------------------------------\n", + "# [교육용가상자료] 드론 조종 시뮬레이터 비행로그 생성기 (실제 훈련생·장비 정보 없음, 조종자는 가명 P001~)\n", + "# 비행 1회 = 120초(10Hz): 경로 비행(0~60초) -> 호버링(60~85초) -> 돌발 장애물 회피(85초) -> 비상 착륙(100초 지시)\n", + "# 조종자마다 숨은 숙련도(skill)와 조작 습관(급조작 jerky, 과보정 overcorr)이 있고, 교관 점수는 이를 반영해 만든다.\n", + "# ---------------------------------------------------------------------------\n", + "DT, STEPS = 0.1, 1200\n", + "T = np.arange(STEPS) * DT\n", + "COND = {\"day\": dict(wind=0.4, gust=0.0, gps=0.25, delay=0.0), \"night\": dict(wind=0.4, gust=0.0, gps=0.5, delay=0.15),\n", + " \"gust\": dict(wind=1.2, gust=2.5, gps=0.25, delay=0.0)}\n", + "OBST_T, LAND_T = 85.0, 100.0\n", + "\n", + "\n", + "def make_pilots(n=40, seed=7):\n", + " rng = np.random.default_rng(seed)\n", + " skill = rng.beta(2.2, 2.0, n)\n", + " return pd.DataFrame({\"pilot\": [f\"P{i + 1:03d}\" for i in range(n)], \"skill\": skill,\n", + " \"jerky\": rng.random(n) < 0.15 + 0.35 * (1 - skill), \"overcorr\": rng.random(n) < 0.15 + 0.35 * (1 - skill)})\n", + "\n", + "\n", + "def target_path(t):\n", + " \"\"\"사각형 경로(40x30m, 고도 20m)를 3m/s로 비행 -> 60초 지점에서 호버링 -> 착륙 지시 후 고도 0.\"\"\"\n", + " # [TODO : 학생 실습 5] 조종자별 조작 습관(급조작, 과보정) 분석 및 신뢰도 등급 산출 모듈 개발 관련 핵심 로직을 구현하세요.\n", + " # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n", + " # <<< 여기에 코드를 작성하세요 >>>\n", + " pass # 작성 후 pass를 제거하세요.\n", + "def fly(pilots, cond=\"day\", seed=0, delay_extra=0.0, obstacle_side=None):\n", + " \"\"\"여러 조종자의 비행 1회를 동시에 모의한다(벡터화). 반환: 로그 배열 dict, 비행별 정답 정보 DataFrame.\"\"\"\n", + " # [TODO : 학생 실습 5] 조종자별 조작 습관(급조작, 과보정) 분석 및 신뢰도 등급 산출 모듈 개발 관련 핵심 로직을 구현하세요.\n", + " # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n", + " # <<< 여기에 코드를 작성하세요 >>>\n", + " pass # 작성 후 pass를 제거하세요.\n", + "def one(log, j):\n", + " \"\"\"여러 조종자 로그에서 j번째 조종자 1명의 로그(DataFrame)를 꺼낸다.\"\"\"\n", + " return pd.DataFrame({k: (v if v.ndim == 1 else v[:, j]) for k, v in log.items()})\n", + "# ---------------------------------------------------------------------------\n", + "\n", + "# ---- 비행로그 특징 추출 공통 함수(여러 조종자 로그를 한 번에 계산) ----\n", + "from scipy.ndimage import uniform_filter1d\n", + "\n", + "BASE_F = [\"path_rmse\", \"hover_drift\", \"hover_std\", \"alt_err\"]\n", + "ADV_F = BASE_F + [\"obs_rt\", \"obs_lateral\", \"touch_vz\", \"att_exceed\", \"jerk\", \"overshoot\", \"n_risk\"]\n", + "RULES = {\"near_collision\": (\"obs_lateral\", \"<\", 2.5), \"hard_landing\": (\"touch_vz\", \">\", 2.0), \"attitude_exceed\": (\"att_exceed\", \">=\", 10)}\n", + "\n", + "\n", + "def detect(df):\n", + " \"\"\"위험 조작 사건 탐지 규칙: 장애물 2.5초 후 회피 거리 2.5m 미만, 접지 속도 2.0m/s 초과, 자세 35도 초과 1초 이상.\"\"\"\n", + " out = {}\n", + " for ev, (f, op, th) in RULES.items():\n", + " out[ev] = df[f] < th if op == \"<\" else (df[f] > th if op == \">\" else df[f] >= th)\n", + " return pd.DataFrame(out)\n", + "\n", + "\n", + "def features(log):\n", + " \"\"\"경로 이탈도·호버링 안정성(기본) + 장애물 반응·착륙 속도·자세 한계·급조작·과보정(고도화) 특징.\"\"\"\n", + " X, Y, A = (uniform_filter1d(log[k], 9, axis=0) for k in (\"x\", \"y\", \"alt\")) # GPS 잡음 완화(이동평균 0.9초)\n", + " h = np.hypot(X - log[\"tx\"], Y - log[\"ty\"])\n", + " cr, hv = slice(0, 600), slice(620, 850)\n", + " k0, k1 = int(OBST_T / DT), int((OBST_T + 2.5) / DT)\n", + " side = np.sign(log[\"ty\"][k0 + 1] - log[\"ty\"][k0 - 1])\n", + " moved = (log[\"stick_roll\"][k0:k0 + 40] * side) > 0.3\n", + " rt = np.where(moved.any(0), moved.argmax(0) * DT, 4.0)\n", + " vz = -np.gradient(A, DT, axis=0)\n", + " land = np.argmax((A < 0.4) & (T[:, None] >= LAND_T), axis=0)\n", + " touch = np.array([vz[max(i - 6, 0):i - 2, j].mean() if i > 6 else 3.0 for j, i in enumerate(land)])\n", + " ds = np.abs(np.diff(np.stack([log[\"stick_roll\"], log[\"stick_pitch\"]]), axis=1)) / DT\n", + " return pd.DataFrame({\n", + " \"path_rmse\": np.sqrt((h[cr] ** 2).mean(0)), \"hover_drift\": h[hv].mean(0), \"hover_std\": np.hypot(X[hv].std(0), Y[hv].std(0)),\n", + " \"alt_err\": np.abs(A[:850] - log[\"talt\"][:850]).mean(0),\n", + " \"obs_rt\": rt, \"obs_lateral\": np.abs(Y[k1] - Y[k0]), \"touch_vz\": touch,\n", + " \"att_exceed\": (np.maximum(np.abs(log[\"roll_deg\"]), np.abs(log[\"pitch_deg\"])) > 35).sum(0),\n", + " \"jerk\": np.percentile(ds, 95, axis=(0, 1)),\n", + " \"overshoot\": np.clip(np.abs(Y[k0:k0 + 120] - Y[k0]).max(0) - 6.0, 0, None)}).pipe(\n", + " lambda d: d.assign(n_risk=detect(d).sum(axis=1))) # overshoot: 회피 후 목표 6m를 넘어선 거리(과보정)\n", + "\n", + "\n", + "def make_dataset(n_pilots=40, seed=7, conds=(\"day\", \"night\", \"gust\"), flights=1):\n", + " \"\"\"조종자 n명 x 조건 x 반복 비행 -> 비행별 특징 + 정답(교관 점수·등급·위험 사건) 표.\"\"\"\n", + " pilots = make_pilots(n_pilots, seed)\n", + " out = []\n", + " for c in conds:\n", + " for f in range(flights):\n", + " log, tr = fly(pilots, c, seed=seed * 100 + list(COND).index(c) * 10 + f)\n", + " out.append(pd.concat([tr.reset_index(drop=True), features(log)], axis=1).assign(flight=f))\n", + " return pd.concat(out, ignore_index=True)\n", + "\n", + "\n", + "def cond_z(df, ref, cols):\n", + " \"\"\"조건별 기준 집단(ref)의 평균·표준편차로 표준화: 돌풍처럼 어려운 조건에서 받는 불이익을 줄인다.\"\"\"\n", + " z = df.copy()\n", + " z[cols] = z[cols].astype(float)\n", + " for c in df[\"cond\"].unique():\n", + " r = ref[ref[\"cond\"] == c][cols]\n", + " z.loc[df[\"cond\"] == c, cols] = (df.loc[df[\"cond\"] == c, cols] - r.mean()) / (r.std() + 1e-9)\n", + " return z\n", + "\n", + "\n", + "def to_grade(score):\n", + " return np.where(score >= 75, \"A\", np.where(score >= 55, \"B\", \"C\"))\n", + "\n", + "train = make_dataset(40, seed=7)\n", + "eng = Ridge(alpha=1.0).fit(cond_z(train, train, ADV_F)[ADV_F], train[\"instructor\"])\n", + "base = Ridge(alpha=1.0).fit(cond_z(train, train, BASE_F)[BASE_F], train[\"instructor\"]) # 미션 3 Baseline 채점식\n", + "zt = cond_z(train, train, ADV_F) # 습관 판정 기준: 같은 조건에서 습관 없는 조종자의 상위 10%\n", + "jerk_th = zt.loc[~train[\"jerky\"], \"jerk\"].quantile(0.9)\n", + "over_th = zt.loc[~train[\"overcorr\"], \"overshoot\"].quantile(0.9)\n", + "\n", + "# [교육용가상자료] 평가 조종자 80명 x 비행 6회(주간·야간·돌풍 각 2회)\n", + "test = make_dataset(80, seed=9, flights=2)\n", + "test[\"score\"] = np.clip(eng.predict(cond_z(test, train, ADV_F)[ADV_F]), 0, 100)\n", + "ze = cond_z(test, train, ADV_F)\n", + "test[\"jerk_z\"], test[\"over_z\"] = ze[\"jerk\"], ze[\"overshoot\"]\n", + "test[\"base\"] = np.clip(base.predict(cond_z(test, train, BASE_F)[BASE_F]), 0, 100)\n", + "rng = np.random.default_rng(0)\n", + "rows = []\n", + "for pid, g in test.groupby(\"pilot\"):\n", + " boot = [to_grade(rng.choice(g[\"score\"].values, len(g)).mean()).item() for _ in range(200)]\n", + " grade = to_grade(g[\"score\"].mean()).item()\n", + " conf = boot.count(grade) / len(boot)\n", + " rows.append({\"pilot\": pid, \"점수\": g[\"score\"].mean(), \"점수SD\": g[\"score\"].std(), \"등급\": grade, \"신뢰도\": conf,\n", + " \"신뢰등급\": \"High\" if conf >= 0.9 else (\"Medium\" if conf >= 0.7 else \"Low\"),\n", + " \"급조작\": (g[\"jerk_z\"] > jerk_th).mean() >= 0.5, \"과보정\": (g[\"over_z\"] > over_th).mean() >= 0.5,\n", + " \"실제급조작\": g[\"jerky\"].iloc[0], \"실제과보정\": g[\"overcorr\"].iloc[0],\n", + " \"교관등급\": to_grade(g[\"instructor\"].mean()).item(), \"단일비행점수\": g[\"base\"].iloc[int(rng.integers(len(g)))]})\n", + "P = pd.DataFrame(rows)\n", + "print(f\"습관 판정 정확도: 급조작 {(P['급조작'] == P['실제급조작']).mean():.0%}, 과보정 {(P['과보정'] == P['실제과보정']).mean():.0%} (조종자 {len(P)}명)\")\n", + "\n", + "# 재교육 대상(C) 지정: 기존(무작위 비행 1회의 Baseline 점수 55 미만) vs 엔진(6회 평균 C + 신뢰도 Medium 이상, Low는 교관 확인으로 보냄)\n", + "need = P[\"교관등급\"] == \"C\"\n", + "old = P[\"단일비행점수\"] < 55\n", + "new = (P[\"등급\"] == \"C\") & (P[\"신뢰등급\"] != \"Low\")\n", + "review = (P[\"등급\"] == \"C\") & (P[\"신뢰등급\"] == \"Low\")\n", + "\n", + "\n", + "def far_rec(flag):\n", + " return (flag & ~need).sum() / max(flag.sum(), 1), (flag & need).sum() / max(need.sum(), 1), int(flag.sum())\n", + "\n", + "\n", + "f0, r0, n0 = far_rec(old)\n", + "f1, r1, n1 = far_rec(new)\n", + "print(pd.DataFrame([{\"방식\": \"기존(비행 1회 Baseline<55)\", \"지정\": n0, \"오지정률\": round(f0, 3), \"재현율\": round(r0, 3)},\n", + " {\"방식\": \"엔진(6회 평균 C, 신뢰 Medium 이상)\", \"지정\": n1, \"오지정률\": round(f1, 3), \"재현율\": round(r1, 3)}]).to_string(index=False))\n", + "cut = 1 - f1 / max(f0, 1e-9)\n", + "print(f\"오지정률(FAR) 감축 {cut:.1%} (목표 40% 이상: {'달성' if cut >= 0.4 else '미달'}), 신뢰도 Low로 교관 확인에 넘긴 조종자 {int(review.sum())}명\")\n", + "print(f\"ROC-AUC(재교육 필요 판별, 평균 점수 기준) {roc_auc_score(need, -P['점수']):.3f}\")\n", + "\n", + "print(\"\\n기준점수 지정 오지정률 재현율\")\n", + "for th in [50, 55, 60, 65]:\n", + " f, r, n = far_rec(P[\"점수\"] < th)\n", + " print(f\" {th} {n:3d} {f:.3f} {r:.3f}\")\n", + "\n", + "# 등급 일관성: 비행 6회 중 5회만 무작위로 써도 같은 등급이 나오는 비율\n", + "cons = []\n", + "for pid, g in test.groupby(\"pilot\"):\n", + " full = to_grade(g[\"score\"].mean()).item()\n", + " cons += [to_grade(g[\"score\"].sample(5, random_state=k).mean()).item() == full for k in range(20)]\n", + "print(f\"\\n등급 일관성 {np.mean(cons):.1%} (목표 90% 이상: {'달성' if np.mean(cons) >= 0.9 else '미달'})\")\n", + "print(\"[신뢰등급 분포]\", P[\"신뢰등급\"].value_counts().to_dict(), \"/ 등급 일치(교관 대비)\", f\"{(P['등급'] == P['교관등급']).mean():.0%}\")\n", + "print(\"신뢰등급 기준: High(부트스트랩 동일 등급 90% 이상)=결과 통보, Medium(70~90%)=교관 검토 후 통보, Low=추가 비행 후 재평가\")\n", + "\n", + "P = P.sort_values(\"점수\")\n", + "plt.figure(figsize=(10, 4))\n", + "col = P[\"신뢰등급\"].map({\"High\": \"tab:green\", \"Medium\": \"tab:orange\", \"Low\": \"tab:red\"})\n", + "plt.errorbar(range(len(P)), P[\"점수\"], yerr=P[\"점수SD\"] / np.sqrt(6) * 2, fmt=\"none\", ecolor=\"gray\")\n", + "plt.scatter(range(len(P)), P[\"점수\"], c=col)\n", + "plt.axhline(55, ls=\"--\", c=\"k\"); plt.axhline(75, ls=\"--\", c=\"k\")\n", + "plt.xticks(range(len(P)), P[\"pilot\"], rotation=90, fontsize=6); plt.ylabel(\"mean skill score (+-2 SE)\")\n", + "plt.title(\"Pilot skill score with uncertainty (color = confidence High/Medium/Low)\"); plt.tight_layout(); plt.show()\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 05 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + "습관 판정 정확도: 급조작 95%, 과보정 88% (조종자 80명)\n", + " 방식 지정 오지정률 재현율\n", + " 기존(비행 1회 Baseline<55) 27 0.111 0.750\n", + "엔진(6회 평균 C, 신뢰 Medium 이상) 31 0.032 0.938\n", + "오지정률(FAR) 감축 71.0% (목표 40% 이상: 달성), 신뢰도 Low로 교관 확인에 넘긴 조종자 1명\n", + "ROC-AUC(재교육 필요 판별, 평균 점수 기준) 0.997\n", + "\n", + "기준점수 지정 오지정률 재현율\n", + " 50 22 0.000 0.688\n", + " 55 32 0.031 0.969\n", + " 60 43 0.256 1.000\n", + " 65 53 0.396 1.000\n", + "\n", + "등급 일관성 97.6% (목표 90% 이상: 달성)\n", + "[신뢰등급 분포] {'High': 66, 'Medium': 9, 'Low': 5} / 등급 일치(교관 대비) 98%\n", + "신뢰등급 기준: High(부트스트랩 동일 등급 90% 이상)=결과 통보, Medium(70~90%)=교관 검토 후 통보, Low=추가 비행 후 재평가\n", + "```\n", + "
\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 06. 조종자 비행 궤적 3D 재생 및 취약 구간 하이라이트 분석 대시보드 구현\n", + "**소요시간**: 약 2.5시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "조종자 비행 궤적 3D 재생 및 취약 구간 하이라이트 분석 대시보드를 구현한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **조종자 비행 궤적을 3D로 재생하고 취약 구간을 강조하는 분석 대시보드를 구현한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **경로 이탈 3m 초과, 자세 30도 초과, 급조작 구간을 취약 구간으로 찾아 3D 궤적에 붉게 표시하고, 비행 단계별 경로 이탈 추이, 스틱 입력, 동료 대비 약점 프로필을 함께 보여 준다 과정을 진행하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `src/ui/trn01_dashboard.py (또는 웹 UI 컴포넌트)` 형태의 결과물을 완성한다.\n", + "\n", + "### 🔍 평가 및 검증 기준 (PASS 조건)\n", + "#### 미션 판정\n", + "\n", + "**평가 구분:** 필수\n", + "\n", + "**핵심 평가영역:** 전술 작전 상황판으로서의 직관성 및 인지 부하 감소도\n", + "\n", + "#### 미션 PASS 조건\n", + "\n", + "- 정량 기준: 화면 갱신 주기 1초 이내, UI 응답성 100ms 이내\n", + "\n", + "\n", + "- 진행 기준: 주요 지표 시각화 컴포넌트 4종 이상 렌더링 및 실시간 데이터 연동\n", + "\n", + "\n", + "- 검증 방법: 사용자 인터랙션 테스트 및 모의 데이터 스트림 렌더링 검증\n", + "\n", + "\n", + "#### 채점자 판정 메모\n", + "\n", + "> 단순 코드 실행 여부뿐만 아니라 출력된 수치와 근거 자료가 군 실무 요구사항에 부합하는지 면밀히 확인한다.\n", + "\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "\"\"\"조종자 1명의 비행 궤적을 3D로 재생하고 경로 이탈·자세 한계·급조작 구간을 취약 구간으로 강조하는 분석 대시보드(야간 모드)를 구현한다. 재생 1초마다 지표를 갱신하는 처리시간을 측정하고 취약 구간 목록을 출력한다.\"\"\"\n", + "import time\n", + "import numpy as np\n", + "import pandas as pd\n", + "import matplotlib.pyplot as plt\n", + "\n", + "# ---------------------------------------------------------------------------\n", + "# [교육용가상자료] 드론 조종 시뮬레이터 비행로그 생성기 (실제 훈련생·장비 정보 없음, 조종자는 가명 P001~)\n", + "# 비행 1회 = 120초(10Hz): 경로 비행(0~60초) -> 호버링(60~85초) -> 돌발 장애물 회피(85초) -> 비상 착륙(100초 지시)\n", + "# 조종자마다 숨은 숙련도(skill)와 조작 습관(급조작 jerky, 과보정 overcorr)이 있고, 교관 점수는 이를 반영해 만든다.\n", + "# ---------------------------------------------------------------------------\n", + "DT, STEPS = 0.1, 1200\n", + "T = np.arange(STEPS) * DT\n", + "COND = {\"day\": dict(wind=0.4, gust=0.0, gps=0.25, delay=0.0), \"night\": dict(wind=0.4, gust=0.0, gps=0.5, delay=0.15),\n", + " \"gust\": dict(wind=1.2, gust=2.5, gps=0.25, delay=0.0)}\n", + "OBST_T, LAND_T = 85.0, 100.0\n", + "\n", + "\n", + "def make_pilots(n=40, seed=7):\n", + " rng = np.random.default_rng(seed)\n", + " skill = rng.beta(2.2, 2.0, n)\n", + " return pd.DataFrame({\"pilot\": [f\"P{i + 1:03d}\" for i in range(n)], \"skill\": skill,\n", + " \"jerky\": rng.random(n) < 0.15 + 0.35 * (1 - skill), \"overcorr\": rng.random(n) < 0.15 + 0.35 * (1 - skill)})\n", + "\n", + "\n", + "def target_path(t):\n", + " \"\"\"사각형 경로(40x30m, 고도 20m)를 3m/s로 비행 -> 60초 지점에서 호버링 -> 착륙 지시 후 고도 0.\"\"\"\n", + " # [TODO : 학생 실습 6] 조종자 비행 궤적 3D 재생 및 취약 구간 하이라이트 분석 대시보드 구현 관련 핵심 로직을 구현하세요.\n", + " # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n", + " # <<< 여기에 코드를 작성하세요 >>>\n", + " pass # 작성 후 pass를 제거하세요.\n", + "def fly(pilots, cond=\"day\", seed=0, delay_extra=0.0, obstacle_side=None):\n", + " \"\"\"여러 조종자의 비행 1회를 동시에 모의한다(벡터화). 반환: 로그 배열 dict, 비행별 정답 정보 DataFrame.\"\"\"\n", + " # [TODO : 학생 실습 6] 조종자 비행 궤적 3D 재생 및 취약 구간 하이라이트 분석 대시보드 구현 관련 핵심 로직을 구현하세요.\n", + " # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n", + " # <<< 여기에 코드를 작성하세요 >>>\n", + " pass # 작성 후 pass를 제거하세요.\n", + "def one(log, j):\n", + " \"\"\"여러 조종자 로그에서 j번째 조종자 1명의 로그(DataFrame)를 꺼낸다.\"\"\"\n", + " return pd.DataFrame({k: (v if v.ndim == 1 else v[:, j]) for k, v in log.items()})\n", + "# ---------------------------------------------------------------------------\n", + "\n", + "# ---- 비행로그 특징 추출 공통 함수(여러 조종자 로그를 한 번에 계산) ----\n", + "from scipy.ndimage import uniform_filter1d\n", + "\n", + "BASE_F = [\"path_rmse\", \"hover_drift\", \"hover_std\", \"alt_err\"]\n", + "ADV_F = BASE_F + [\"obs_rt\", \"obs_lateral\", \"touch_vz\", \"att_exceed\", \"jerk\", \"overshoot\", \"n_risk\"]\n", + "RULES = {\"near_collision\": (\"obs_lateral\", \"<\", 2.5), \"hard_landing\": (\"touch_vz\", \">\", 2.0), \"attitude_exceed\": (\"att_exceed\", \">=\", 10)}\n", + "\n", + "\n", + "def detect(df):\n", + " \"\"\"위험 조작 사건 탐지 규칙: 장애물 2.5초 후 회피 거리 2.5m 미만, 접지 속도 2.0m/s 초과, 자세 35도 초과 1초 이상.\"\"\"\n", + " out = {}\n", + " for ev, (f, op, th) in RULES.items():\n", + " out[ev] = df[f] < th if op == \"<\" else (df[f] > th if op == \">\" else df[f] >= th)\n", + " return pd.DataFrame(out)\n", + "\n", + "\n", + "def features(log):\n", + " \"\"\"경로 이탈도·호버링 안정성(기본) + 장애물 반응·착륙 속도·자세 한계·급조작·과보정(고도화) 특징.\"\"\"\n", + " X, Y, A = (uniform_filter1d(log[k], 9, axis=0) for k in (\"x\", \"y\", \"alt\")) # GPS 잡음 완화(이동평균 0.9초)\n", + " h = np.hypot(X - log[\"tx\"], Y - log[\"ty\"])\n", + " cr, hv = slice(0, 600), slice(620, 850)\n", + " k0, k1 = int(OBST_T / DT), int((OBST_T + 2.5) / DT)\n", + " side = np.sign(log[\"ty\"][k0 + 1] - log[\"ty\"][k0 - 1])\n", + " moved = (log[\"stick_roll\"][k0:k0 + 40] * side) > 0.3\n", + " rt = np.where(moved.any(0), moved.argmax(0) * DT, 4.0)\n", + " vz = -np.gradient(A, DT, axis=0)\n", + " land = np.argmax((A < 0.4) & (T[:, None] >= LAND_T), axis=0)\n", + " touch = np.array([vz[max(i - 6, 0):i - 2, j].mean() if i > 6 else 3.0 for j, i in enumerate(land)])\n", + " ds = np.abs(np.diff(np.stack([log[\"stick_roll\"], log[\"stick_pitch\"]]), axis=1)) / DT\n", + " return pd.DataFrame({\n", + " \"path_rmse\": np.sqrt((h[cr] ** 2).mean(0)), \"hover_drift\": h[hv].mean(0), \"hover_std\": np.hypot(X[hv].std(0), Y[hv].std(0)),\n", + " \"alt_err\": np.abs(A[:850] - log[\"talt\"][:850]).mean(0),\n", + " \"obs_rt\": rt, \"obs_lateral\": np.abs(Y[k1] - Y[k0]), \"touch_vz\": touch,\n", + " \"att_exceed\": (np.maximum(np.abs(log[\"roll_deg\"]), np.abs(log[\"pitch_deg\"])) > 35).sum(0),\n", + " \"jerk\": np.percentile(ds, 95, axis=(0, 1)),\n", + " \"overshoot\": np.clip(np.abs(Y[k0:k0 + 120] - Y[k0]).max(0) - 6.0, 0, None)}).pipe(\n", + " lambda d: d.assign(n_risk=detect(d).sum(axis=1))) # overshoot: 회피 후 목표 6m를 넘어선 거리(과보정)\n", + "\n", + "\n", + "def make_dataset(n_pilots=40, seed=7, conds=(\"day\", \"night\", \"gust\"), flights=1):\n", + " \"\"\"조종자 n명 x 조건 x 반복 비행 -> 비행별 특징 + 정답(교관 점수·등급·위험 사건) 표.\"\"\"\n", + " pilots = make_pilots(n_pilots, seed)\n", + " out = []\n", + " for c in conds:\n", + " for f in range(flights):\n", + " log, tr = fly(pilots, c, seed=seed * 100 + list(COND).index(c) * 10 + f)\n", + " out.append(pd.concat([tr.reset_index(drop=True), features(log)], axis=1).assign(flight=f))\n", + " return pd.concat(out, ignore_index=True)\n", + "\n", + "\n", + "def cond_z(df, ref, cols):\n", + " \"\"\"조건별 기준 집단(ref)의 평균·표준편차로 표준화: 돌풍처럼 어려운 조건에서 받는 불이익을 줄인다.\"\"\"\n", + " z = df.copy()\n", + " z[cols] = z[cols].astype(float)\n", + " for c in df[\"cond\"].unique():\n", + " r = ref[ref[\"cond\"] == c][cols]\n", + " z.loc[df[\"cond\"] == c, cols] = (df.loc[df[\"cond\"] == c, cols] - r.mean()) / (r.std() + 1e-9)\n", + " return z\n", + "\n", + "\n", + "def to_grade(score):\n", + " return np.where(score >= 75, \"A\", np.where(score >= 55, \"B\", \"C\"))\n", + "\n", + "PHASES = [(0, 60, \"path\"), (60, 85, \"hover\"), (85, 100, \"obstacle\"), (100, 120, \"landing\")]\n", + "\n", + "# [교육용가상자료] 조종자 12명 중 가장 점수가 낮은 조종자의 돌풍 비행을 재생\n", + "pilots = make_pilots(12, seed=4)\n", + "log, truth = fly(pilots, \"gust\", seed=21)\n", + "j = int(truth[\"instructor\"].idxmin())\n", + "d = one(log, j)\n", + "FE = features(log).assign(cond=\"gust\")\n", + "f = FE.iloc[j]\n", + "d[\"err\"] = np.hypot(d[\"x\"] - d[\"tx\"], d[\"y\"] - d[\"ty\"])\n", + "d[\"att\"] = np.maximum(d[\"roll_deg\"].abs(), d[\"pitch_deg\"].abs())\n", + "d[\"stick_rate\"] = np.r_[0, np.abs(np.diff(d[\"stick_roll\"])) / DT]\n", + "\n", + "\n", + "def weak_flags(df):\n", + " return {\"경로이탈>3m\": df[\"err\"] > 3.0, \"자세>30도\": df[\"att\"] > 30, \"급조작(변화율>6/s)\": df[\"stick_rate\"] > 6}\n", + "\n", + "\n", + "# 1) 재생 모의: 1초(10프레임)마다 누적 지표 갱신 + 처리시간 측정\n", + "ms, kpis = [], []\n", + "for k in range(10, len(d) + 1, 10):\n", + " t0 = time.perf_counter()\n", + " cur = d.iloc[:k]\n", + " flags = weak_flags(cur)\n", + " kpis.append({\"t\": cur[\"t\"].iloc[-1], \"err_now\": cur[\"err\"].iloc[-1], \"weak_ratio\": np.mean(np.any(np.c_[tuple(flags.values())], axis=1))})\n", + " ms.append((time.perf_counter() - t0) * 1000)\n", + "print(f\"조종자 {truth['pilot'][j]}(교관 {truth['instructor'][j]}점, 등급 {truth['grade'][j]}) 돌풍 비행 재생: 갱신 {len(ms)}회, \"\n", + " f\"처리 평균 {np.mean(ms):.2f} ms, 최대 {np.max(ms):.2f} ms (목표 1초 주기·100 ms 이내 -> {'달성' if np.max(ms) < 100 else '미달'})\")\n", + "\n", + "# 2) 취약 구간 목록(연속 0.5초 이상)\n", + "segs = []\n", + "for name, m in weak_flags(d).items():\n", + " m = m.values.astype(int)\n", + " edges = np.flatnonzero(np.diff(np.r_[0, m, 0]))\n", + " for a, b in zip(edges[::2], edges[1::2]):\n", + " if b - a >= 5:\n", + " ph = next(p for s, e, p in PHASES if s <= d[\"t\"][a] < e)\n", + " segs.append({\"구분\": name, \"시작(s)\": round(d[\"t\"][a], 1), \"길이(s)\": round((b - a) * DT, 1), \"단계\": ph})\n", + "S = pd.DataFrame(segs).sort_values(\"시작(s)\")\n", + "print(f\"\\n[취약 구간 {len(S)}개 - 시간순 상위 10개]\")\n", + "print(S.head(10).to_string(index=False))\n", + "print(\"\\n단계별 취약 시간(s):\", S.groupby(\"단계\")[\"길이(s)\"].sum().round(1).to_dict())\n", + "print(f\"돌발 대응: 장애물 반응 {f['obs_rt']:.1f}s, 2.5초 후 회피거리 {f['obs_lateral']:.1f}m, 접지 속도 {f['touch_vz']:.1f}m/s -> \"\n", + " f\"위험 사건 {', '.join(k for k, v in detect(FE.iloc[[j]]).iloc[0].items() if v) or '없음'}\")\n", + "\n", + "# 3) 대시보드(야간 모드, 구성요소 4종)\n", + "plt.style.use(\"dark_background\")\n", + "fig = plt.figure(figsize=(13, 8))\n", + "ax = fig.add_subplot(2, 2, 1, projection=\"3d\")\n", + "bad = np.any(np.c_[tuple(weak_flags(d).values())], axis=1)\n", + "ax.plot(d[\"tx\"], d[\"ty\"], d[\"talt\"], \"--\", c=\"gray\", lw=1, label=\"course\")\n", + "ax.scatter(d[\"x\"][~bad], d[\"y\"][~bad], d[\"alt\"][~bad], s=2, c=\"deepskyblue\", label=\"normal\")\n", + "ax.scatter(d[\"x\"][bad], d[\"y\"][bad], d[\"alt\"][bad], s=6, c=\"red\", label=\"weak segment\")\n", + "ax.set_xlabel(\"x (m)\"); ax.set_ylabel(\"y (m)\"); ax.set_zlabel(\"alt (m)\"); ax.legend(fontsize=7); ax.set_title(\"3D replay (red = weak)\")\n", + "a2 = fig.add_subplot(2, 2, 2)\n", + "a2.plot(d[\"t\"], d[\"err\"], c=\"lime\", lw=1); a2.axhline(3, c=\"red\", ls=\"--\")\n", + "for s, e, p in PHASES:\n", + " a2.axvspan(s, e, alpha=0.08 if p in (\"path\", \"obstacle\") else 0.16, color=\"white\"); a2.text(s + 1, a2.get_ylim()[1] * 0.9, p, fontsize=8)\n", + "a2.set_title(\"Path deviation (m) by phase\")\n", + "a3 = fig.add_subplot(2, 2, 3)\n", + "a3.plot(d[\"t\"], d[\"stick_roll\"], lw=0.8, label=\"roll\"); a3.plot(d[\"t\"], d[\"stick_pitch\"], lw=0.8, label=\"pitch\"); a3.plot(d[\"t\"], d[\"throttle\"], lw=0.8, label=\"throttle\")\n", + "a3.scatter(d[\"t\"][d[\"stick_rate\"] > 6], d[\"stick_roll\"][d[\"stick_rate\"] > 6], c=\"red\", s=10, label=\"abrupt\")\n", + "a3.legend(fontsize=7, ncol=4); a3.set_title(\"Stick inputs\"); a3.set_xlabel(\"time (s)\")\n", + "a4 = fig.add_subplot(2, 2, 4)\n", + "z = cond_z(FE, FE, ADV_F).iloc[j][[\"path_rmse\", \"hover_std\", \"obs_rt\", \"touch_vz\", \"jerk\", \"overshoot\"]]\n", + "a4.barh(z.index, z.values, color=[\"red\" if v > 1 else \"orange\" if v > 0 else \"deepskyblue\" for v in z.values])\n", + "a4.axvline(0, c=\"white\", lw=0.5); a4.set_title(\"Weakness profile (z-score vs peers, right = worse)\")\n", + "fig.suptitle(f\"Pilot {truth['pilot'][j]} flight replay dashboard (night mode) - training data only\")\n", + "plt.tight_layout(); plt.show()\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 06 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + "조종자 P010(교관 33.7점, 등급 C) 돌풍 비행 재생: 갱신 120회, 처리 평균 0.50 ms, 최대 1.30 ms (목표 1초 주기·100 ms 이내 -> 달성)\n", + "\n", + "[취약 구간 34개 - 시간순 상위 10개]\n", + " 구분 시작(s) 길이(s) 단계\n", + "경로이탈>3m 2.0 2.7 path\n", + "경로이탈>3m 8.6 5.9 path\n", + " 자세>30도 11.5 1.8 path\n", + "경로이탈>3m 14.9 3.4 path\n", + " 자세>30도 17.3 0.5 path\n", + "경로이탈>3m 19.2 4.5 path\n", + " 자세>30도 20.4 1.5 path\n", + "경로이탈>3m 25.6 2.5 path\n", + "경로이탈>3m 29.1 1.2 path\n", + "경로이탈>3m 30.6 4.0 path\n", + "\n", + "단계별 취약 시간(s): {'hover': 27.8, 'landing': 18.5, 'obstacle': 8.0, 'path': 63.7}\n", + "돌발 대응: 장애물 반응 2.1s, 2.5초 후 회피거리 1.1m, 접지 속도 2.7m/s -> 위험 사건 near_collision, hard_landing, attitude_exceed\n", + "```\n", + "
\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 07. 교관이 직접 조종자 성향을 진단하고 피드백을 입력하는 HITL 평가 인터페이스 설계\n", + "**소요시간**: 약 3.0시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "교관이 직접 조종자 성향을 진단하고 피드백을 입력하는 HITL 평가 인터페이스를 설계한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **교관이 AI 제안 등급과 조작 성향 진단을 직접 확인·수정하고 피드백을 입력하는 HITL 평가 인터페이스를 설계한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **AI 제안은 항상 검토 대기로 등록하고 신뢰도가 낮은 제안과 C 등급을 먼저 보도록 순서를 정 수행하고 검증하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `src/hitl/trn01_approval_workflow.py 및 docs/trn01_hitl_guide.md` 형태의 결과물을 완성한다.\n", + "\n", + "### 🔍 평가 및 검증 기준 (PASS 조건)\n", + "#### 미션 판정\n", + "\n", + "**평가 구분:** 필수\n", + "\n", + "**핵심 평가영역:** 지휘 책임 분계선 명확화 및 성급한 자동 단정 방지 체계 부합성\n", + "\n", + "#### 미션 PASS 조건\n", + "\n", + "- 정량 기준: 인간 검토 큐(Queue) 대기시간 및 승인/반려 처리 로깅 성공률 100%\n", + "\n", + "\n", + "- 진행 기준: 승인/반려 인터페이스 동작 및 감사 로그(Audit Log) 생성 검증\n", + "\n", + "\n", + "- 검증 방법: 모의 조작자 승인/반려 시뮬레이션 및 데이터 무결성 검증\n", + "\n", + "\n", + "#### 채점자 판정 메모\n", + "\n", + "> 단순 코드 실행 여부뿐만 아니라 출력된 수치와 근거 자료가 군 실무 요구사항에 부합하는지 면밀히 확인한다.\n", + "\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# [TODO : 학생 실습 7] 교관이 직접 조종자 성향을 진단하고 피드백을 입력하는 HITL 평가 인터페이스 설계\n", + "# 미션 지침 및 검증 기준을 확인하고 코드를 실행 및 튜닝하세요.\n", + "\"\"\"AI가 제안한 숙련도 등급·조작 성향을 교관이 직접 확인·수정하고 피드백을 입력하는 HITL 평가 인터페이스를 구현한다. AI는 등급을 확정하지 않으며, 불리한 결정(재교육 지정·자격 보류)은 상세 근거와 두 번째 교관 확인을 요구하고, 모든 결정은 위·변조 확인이 가능한 감사 로그에 남긴다.\"\"\"\n", + "import hashlib\n", + "import json\n", + "import pandas as pd\n", + "\n", + "ACTIONS = {\"CONFIRM\": \"AI 제안 등급 확정\", \"MODIFY\": \"등급 수정\", \"REFLIGHT\": \"추가 비행 후 재평가\", \"RETRAIN\": \"재교육 지정\", \"QUAL_HOLD\": \"자격 부여 보류\"}\n", + "CONF_ORDER = {\"Low\": 0, \"Medium\": 1, \"High\": 2}\n", + "\n", + "\n", + "class ReviewError(ValueError):\n", + " pass\n", + "\n", + "\n", + "class FakeClock:\n", + " t = 0.0\n", + "\n", + " def __call__(self):\n", + " return self.t\n", + "\n", + "\n", + "class ReviewQueue:\n", + " def __init__(self, clock):\n", + " self.items, self.log, self.clock = {}, [], clock\n", + "\n", + " def _record(self, body):\n", + " prev = self.log[-1][\"hash\"] if self.log else \"GENESIS\"\n", + " body = {**body, \"ts\": round(self.clock(), 1)}\n", + " self.log.append({**body, \"prev\": prev, \"hash\": hashlib.sha256((prev + json.dumps(body, ensure_ascii=False, sort_keys=True)).encode()).hexdigest()})\n", + "\n", + " def verify_log(self):\n", + " prev = \"GENESIS\"\n", + " for n, rec in enumerate(self.log, start=1):\n", + " body = {k: v for k, v in rec.items() if k not in (\"prev\", \"hash\")}\n", + " if rec[\"prev\"] != prev or rec[\"hash\"] != hashlib.sha256((prev + json.dumps(body, ensure_ascii=False, sort_keys=True)).encode()).hexdigest():\n", + " return False, n\n", + " prev = rec[\"hash\"]\n", + " return True, -1\n", + "\n", + " def submit(self, pilot, grade, conf, habits, evidence):\n", + " \"\"\"AI 제안 등록: 항상 PENDING으로 시작하며 AI가 확정하지 않는다.\"\"\"\n", + " self.items[pilot] = {\"pilot\": pilot, \"ai_grade\": grade, \"conf\": conf, \"habits\": habits, \"evidence\": evidence,\n", + " \"status\": \"PENDING\", \"final\": None, \"submitted\": self.clock(), \"approvals\": []}\n", + " self._record({\"event\": \"AI_SUGGEST\", \"pilot\": pilot, \"grade\": grade, \"conf\": conf, \"by\": \"AI\"})\n", + "\n", + " def order(self):\n", + " \"\"\"검토 순서: 신뢰도 낮은 제안 -> C 등급 -> 나머지(교관이 판단이 필요한 건부터 본다).\"\"\"\n", + " todo = [i for i in self.items.values() if i[\"status\"] in (\"PENDING\", \"AWAIT_2ND\")]\n", + " return sorted(todo, key=lambda i: (CONF_ORDER[i[\"conf\"]], i[\"ai_grade\"] != \"C\", i[\"pilot\"]))\n", + "\n", + " def decide(self, pilot, action, instructor, reason, new_grade=None):\n", + " if action not in ACTIONS:\n", + " raise ReviewError(f\"허용되지 않은 조치: {action}\")\n", + " if not instructor:\n", + " raise ReviewError(\"교관 ID 필요\")\n", + " it = self.items.get(pilot)\n", + " if it is None or it[\"status\"] == \"FINAL\":\n", + " raise ReviewError(\"검토 대기 건이 아님\")\n", + " if len(reason.strip()) < 10:\n", + " raise ReviewError(\"진단·피드백 10자 이상 필요\")\n", + " if action == \"MODIFY\" and new_grade not in (\"A\", \"B\", \"C\"):\n", + " raise ReviewError(\"수정 등급(A/B/C) 필요\")\n", + " if action in (\"RETRAIN\", \"QUAL_HOLD\") and len(reason.strip()) < 20:\n", + " raise ReviewError(\"불리한 결정은 근거 20자 이상 필요\")\n", + " if action == \"QUAL_HOLD\":\n", + " if instructor in it[\"approvals\"]:\n", + " raise ReviewError(\"두 번째 확인은 다른 교관이 해야 함\")\n", + " it[\"approvals\"].append(instructor)\n", + " self._record({\"event\": \"DECIDE\", \"pilot\": pilot, \"action\": action, \"by\": instructor, \"reason\": reason, \"step\": len(it[\"approvals\"])})\n", + " if len(it[\"approvals\"]) < 2:\n", + " it[\"status\"] = \"AWAIT_2ND\"\n", + " return \"두 번째 교관 확인 대기\"\n", + " else:\n", + " self._record({\"event\": \"DECIDE\", \"pilot\": pilot, \"action\": action, \"by\": instructor, \"reason\": reason, \"new_grade\": new_grade})\n", + " it[\"status\"], it[\"action\"], it[\"decided\"] = \"FINAL\", action, self.clock()\n", + " it[\"final\"] = new_grade if action == \"MODIFY\" else (None if action == \"REFLIGHT\" else it[\"ai_grade\"])\n", + " return \"확정\"\n", + "\n", + "\n", + "clock = FakeClock()\n", + "rq = ReviewQueue(clock)\n", + "# [교육용가상자료] 미션 5 결과에서 넘어온 AI 제안 6건(가명)\n", + "for p, g, c, h, e in [(\"P003\", \"A\", \"High\", [], \"경로 RMSE 하위 10%, 접지 0.4m/s\"), (\"P011\", \"C\", \"High\", [\"급조작\"], \"경착륙 2회, 스틱 변화율 상위 5%\"),\n", + " (\"P017\", \"B\", \"Low\", [\"과보정\"], \"비행별 점수 편차 큼(48~79)\"), (\"P024\", \"C\", \"Medium\", [\"과보정\"], \"장애물 근접 3회\"),\n", + " (\"P030\", \"B\", \"High\", [], \"호버링 안정, 장애물 반응 1.2s\"), (\"P038\", \"C\", \"Low\", [], \"돌풍 비행만 낮음\")]:\n", + " clock.t += 1; rq.submit(p, g, c, h, e)\n", + "print(\"검토 순서:\", [f\"{i['pilot']}({i['ai_grade']}/{i['conf']})\" for i in rq.order()])\n", + "print(\"AI가 확정한 건수:\", sum(i[\"status\"] == \"FINAL\" for i in rq.items.values()))\n", + "\n", + "for args in [(\"P011\", \"RETRAIN\", \"\", \"경착륙 반복, 착륙 절차 재교육\"), (\"P003\", \"CONFIRM\", \"inst01\", \"좋음\"), (\"P017\", \"MODIFY\", \"inst01\", \"편차가 커서 등급 조정함\"),\n", + " (\"P024\", \"RETRAIN\", \"inst02\", \"근접 3회 재교육\"), (\"P003\", \"AUTO\", \"inst01\", \"자동 확정 시도합니다\")]:\n", + " try:\n", + " rq.decide(*args)\n", + " except ReviewError as e:\n", + " print(f\"차단: {args[0]} {args[1]} -> {e}\")\n", + "\n", + "clock.t += 120; print(\"P003:\", rq.decide(\"P003\", \"CONFIRM\", \"inst01\", \"경로·호버링 안정, 돌발 대응 양호 확인\"))\n", + "clock.t += 200; print(\"P017:\", rq.decide(\"P017\", \"REFLIGHT\", \"inst01\", \"점수 편차 큼, 주간 비행 2회 추가 후 재평가\"))\n", + "clock.t += 150; print(\"P011:\", rq.decide(\"P011\", \"RETRAIN\", \"inst02\", \"경착륙 2회와 급조작 확인, 착륙 감속 절차 재교육 지정\"))\n", + "clock.t += 90; print(\"P024 1차:\", rq.decide(\"P024\", \"QUAL_HOLD\", \"inst02\", \"장애물 근접 3회 반복, 회피 기동 숙달 전 자격 보류\"))\n", + "clock.t += 10\n", + "try:\n", + " rq.decide(\"P024\", \"QUAL_HOLD\", \"inst02\", \"같은 교관이 두 번째 확인까지 직접 하려고 시도함\")\n", + "except ReviewError as e:\n", + " print(\"차단: P024 QUAL_HOLD ->\", e)\n", + "clock.t += 300; print(\"P024 2차:\", rq.decide(\"P024\", \"QUAL_HOLD\", \"inst05\", \"영상 재확인 결과 회피 지연 동의, 보류 유지\"))\n", + "clock.t += 60; print(\"P038:\", rq.decide(\"P038\", \"MODIFY\", \"inst03\", \"돌풍 외 비행은 B 수준, 돌풍 대응만 보완\", new_grade=\"B\"))\n", + "\n", + "df = pd.DataFrame(rq.items.values())\n", + "df[\"대기(s)\"] = (df[\"decided\"] - df[\"submitted\"]).round(0)\n", + "print(\"\\n\" + df[[\"pilot\", \"ai_grade\", \"conf\", \"action\", \"final\", \"대기(s)\"]].fillna(\"-\").to_string(index=False))\n", + "fin = df[df[\"status\"] == \"FINAL\"]\n", + "print(f\"교관 결정 {len(fin)}건 중 AI 제안 등급과 다른 결정 {int((fin['final'] != fin['ai_grade']).sum())}건(추가 비행 포함), 미검토 {int((df['status'] != 'FINAL').sum())}건은 대기 유지\")\n", + "print(f\"\\n감사 로그 {len(rq.log)}건 기록(제안 {len(df)}건 + 결정 {len(rq.log) - len(df)}건, 로깅 성공률 100%), 해시 체인 검증: {rq.verify_log()}\")\n", + "rq.log[7][\"reason\"] = \"근거 수정\"\n", + "print(\"8번째 기록 변조 후 검증(False, 깨진 위치):\", rq.verify_log())\n", + "\n", + "form = {\"pilot\": \"P011\", \"strength\": \"(교관 입력)\", \"weakness\": \"착륙 감속 늦음, 급조작\", \"next_training\": \"착륙 절차 반복 3회\", \"instructor\": \"inst02\", \"pilot_ack\": \"(서명)\"}\n", + "print(\"\\n[조종자 피드백 양식]\\n\" + json.dumps(form, ensure_ascii=False))\n", + "print(\"AI 점수는 교관 판단 보조 자료이며 인사·자격 결정의 단독 근거로 쓰지 않는다. 최종 등급의 책임은 결정한 교관에게 있다.\")\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 07 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + "검토 순서: ['P038(C/Low)', 'P017(B/Low)', 'P024(C/Medium)', 'P011(C/High)', 'P003(A/High)', 'P030(B/High)']\n", + "AI가 확정한 건수: 0\n", + "차단: P011 RETRAIN -> 교관 ID 필요\n", + "차단: P003 CONFIRM -> 진단·피드백 10자 이상 필요\n", + "차단: P017 MODIFY -> 수정 등급(A/B/C) 필요\n", + "차단: P024 RETRAIN -> 진단·피드백 10자 이상 필요\n", + "차단: P003 AUTO -> 허용되지 않은 조치: AUTO\n", + "P003: 확정\n", + "P017: 확정\n", + "P011: 확정\n", + "P024 1차: 두 번째 교관 확인 대기\n", + "차단: P024 QUAL_HOLD -> 두 번째 확인은 다른 교관이 해야 함\n", + "P024 2차: 확정\n", + "P038: 확정\n", + "\n", + "pilot ai_grade conf action final 대기(s)\n", + " P003 A High CONFIRM A 125.0\n", + " P011 C High RETRAIN C 474.0\n", + " P017 B Low REFLIGHT - 323.0\n", + " P024 C Medium QUAL_HOLD C 872.0\n", + " P030 B High - - -\n", + " P038 C Low MODIFY B 930.0\n", + "교관 결정 5건 중 AI 제안 등급과 다른 결정 2건(추가 비행 포함), 미검토 1건은 대기 유지\n", + "\n", + "감사 로그 12건 기록(제안 6건 + 결정 6건, 로깅 성공률 100%), 해시 체인 검증: (True, -1)\n", + "8번째 기록 변조 후 검증(False, 깨진 위치): (False, 8)\n", + "\n", + "[조종자 피드백 양식]\n", + "{\"pilot\": \"P011\", \"strength\": \"(교관 입력)\", \"weakness\": \"착륙 감속 늦음, 급조작\", \"next_training\": \"착륙 절차 반복 3회\", \"instructor\": \"inst02\", \"pilot_ack\": \"(서명)\"}\n", + "AI 점수는 교관 판단 보조 자료이며 인사·자격 결정의 단독 근거로 쓰지 않는다. 최종 등급의 책임은 결정한 교관에게 있다.\n", + "```\n", + "
\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 08. 100명 조종자 대량 비행로그 배치 분석 스트레스 테스트 및 성능 프로파일링\n", + "**소요시간**: 약 3.5시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "100명 조종자의 대량 비행로그 배치 분석 스트레스 테스트를 수행한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **조종자 100명의 대량 비행로그 배치 분석에서 처리량 증가와 손상 로그, 처리 중 예외 상황에 대한 복원력을 검증한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **평시 100건, 100명 x 5회 500건, 500% 증가 600건, 손상 로그 5% 혼입, 묶음 처리 예외 상황 주입하여 처리 지연 및 복원력을 시험하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `tests/trn01_stress_test.py 및 스트레스 테스트 분석 보고서` 형태의 결과물을 완성한다.\n", + "\n", + "### 🔍 평가 및 검증 기준 (PASS 조건)\n", + "#### 미션 판정\n", + "\n", + "**평가 구분:** 필수\n", + "\n", + "**핵심 평가영역:** 극단 상황에서의 안전 실패(Fail-safe) 및 기능 축소 작동 적절성\n", + "\n", + "#### 미션 PASS 조건\n", + "\n", + "- 정량 기준: 동시 트래픽 500% 증가 시 시스템 다운 0건, 데이터 유실률 0%\n", + "\n", + "\n", + "- 진행 기준: 대량 시뮬레이션 부하 주입 테스트 통과 및 병목 구간 프로파일링 완료\n", + "\n", + "\n", + "- 검증 방법: 부하 테스트 도구를 통한 처리량(Throughput) 및 메모리 누수 검증\n", + "\n", + "\n", + "#### 채점자 판정 메모\n", + "\n", + "> 단순 코드 실행 여부뿐만 아니라 출력된 수치와 근거 자료가 군 실무 요구사항에 부합하는지 면밀히 확인한다.\n", + "\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "\"\"\"조종자 100명의 대량 비행로그를 배치 분석하는 스트레스 테스트를 수행한다. 평시 100건에서 500% 증가(600건), 손상 로그 혼입, 묶음 처리 중 예외 상황을 주입해 시스템 다운 0건·로그 유실 0건(채점+격리=전체)과 처리량·메모리·병목 함수를 측정하고, 예외 시 1건씩 처리로 기능을 축소하는 안전 실패 동작을 확인한다.\"\"\"\n", + "import cProfile\n", + "import io\n", + "import pstats\n", + "import time\n", + "import tracemalloc\n", + "import numpy as np\n", + "import pandas as pd\n", + "import matplotlib.pyplot as plt\n", + "from sklearn.linear_model import Ridge\n", + "\n", + "# ---------------------------------------------------------------------------\n", + "# [교육용가상자료] 드론 조종 시뮬레이터 비행로그 생성기 (실제 훈련생·장비 정보 없음, 조종자는 가명 P001~)\n", + "# 비행 1회 = 120초(10Hz): 경로 비행(0~60초) -> 호버링(60~85초) -> 돌발 장애물 회피(85초) -> 비상 착륙(100초 지시)\n", + "# 조종자마다 숨은 숙련도(skill)와 조작 습관(급조작 jerky, 과보정 overcorr)이 있고, 교관 점수는 이를 반영해 만든다.\n", + "# ---------------------------------------------------------------------------\n", + "DT, STEPS = 0.1, 1200\n", + "T = np.arange(STEPS) * DT\n", + "COND = {\"day\": dict(wind=0.4, gust=0.0, gps=0.25, delay=0.0), \"night\": dict(wind=0.4, gust=0.0, gps=0.5, delay=0.15),\n", + " \"gust\": dict(wind=1.2, gust=2.5, gps=0.25, delay=0.0)}\n", + "OBST_T, LAND_T = 85.0, 100.0\n", + "\n", + "\n", + "def make_pilots(n=40, seed=7):\n", + " rng = np.random.default_rng(seed)\n", + " skill = rng.beta(2.2, 2.0, n)\n", + " return pd.DataFrame({\"pilot\": [f\"P{i + 1:03d}\" for i in range(n)], \"skill\": skill,\n", + " \"jerky\": rng.random(n) < 0.15 + 0.35 * (1 - skill), \"overcorr\": rng.random(n) < 0.15 + 0.35 * (1 - skill)})\n", + "\n", + "\n", + "def target_path(t):\n", + " \"\"\"사각형 경로(40x30m, 고도 20m)를 3m/s로 비행 -> 60초 지점에서 호버링 -> 착륙 지시 후 고도 0.\"\"\"\n", + " # [TODO : 학생 실습 8] 100명 조종자 대량 비행로그 배치 분석 스트레스 테스트 및 성능 프로파일링 관련 핵심 로직을 구현하세요.\n", + " # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n", + " # <<< 여기에 코드를 작성하세요 >>>\n", + " pass # 작성 후 pass를 제거하세요.\n", + "def fly(pilots, cond=\"day\", seed=0, delay_extra=0.0, obstacle_side=None):\n", + " \"\"\"여러 조종자의 비행 1회를 동시에 모의한다(벡터화). 반환: 로그 배열 dict, 비행별 정답 정보 DataFrame.\"\"\"\n", + " # [TODO : 학생 실습 8] 100명 조종자 대량 비행로그 배치 분석 스트레스 테스트 및 성능 프로파일링 관련 핵심 로직을 구현하세요.\n", + " # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n", + " # <<< 여기에 코드를 작성하세요 >>>\n", + " pass # 작성 후 pass를 제거하세요.\n", + "def one(log, j):\n", + " \"\"\"여러 조종자 로그에서 j번째 조종자 1명의 로그(DataFrame)를 꺼낸다.\"\"\"\n", + " return pd.DataFrame({k: (v if v.ndim == 1 else v[:, j]) for k, v in log.items()})\n", + "# ---------------------------------------------------------------------------\n", + "\n", + "# ---- 비행로그 특징 추출 공통 함수(여러 조종자 로그를 한 번에 계산) ----\n", + "from scipy.ndimage import uniform_filter1d\n", + "\n", + "BASE_F = [\"path_rmse\", \"hover_drift\", \"hover_std\", \"alt_err\"]\n", + "ADV_F = BASE_F + [\"obs_rt\", \"obs_lateral\", \"touch_vz\", \"att_exceed\", \"jerk\", \"overshoot\", \"n_risk\"]\n", + "RULES = {\"near_collision\": (\"obs_lateral\", \"<\", 2.5), \"hard_landing\": (\"touch_vz\", \">\", 2.0), \"attitude_exceed\": (\"att_exceed\", \">=\", 10)}\n", + "\n", + "\n", + "def detect(df):\n", + " \"\"\"위험 조작 사건 탐지 규칙: 장애물 2.5초 후 회피 거리 2.5m 미만, 접지 속도 2.0m/s 초과, 자세 35도 초과 1초 이상.\"\"\"\n", + " out = {}\n", + " for ev, (f, op, th) in RULES.items():\n", + " out[ev] = df[f] < th if op == \"<\" else (df[f] > th if op == \">\" else df[f] >= th)\n", + " return pd.DataFrame(out)\n", + "\n", + "\n", + "def features(log):\n", + " \"\"\"경로 이탈도·호버링 안정성(기본) + 장애물 반응·착륙 속도·자세 한계·급조작·과보정(고도화) 특징.\"\"\"\n", + " X, Y, A = (uniform_filter1d(log[k], 9, axis=0) for k in (\"x\", \"y\", \"alt\")) # GPS 잡음 완화(이동평균 0.9초)\n", + " h = np.hypot(X - log[\"tx\"], Y - log[\"ty\"])\n", + " cr, hv = slice(0, 600), slice(620, 850)\n", + " k0, k1 = int(OBST_T / DT), int((OBST_T + 2.5) / DT)\n", + " side = np.sign(log[\"ty\"][k0 + 1] - log[\"ty\"][k0 - 1])\n", + " moved = (log[\"stick_roll\"][k0:k0 + 40] * side) > 0.3\n", + " rt = np.where(moved.any(0), moved.argmax(0) * DT, 4.0)\n", + " vz = -np.gradient(A, DT, axis=0)\n", + " land = np.argmax((A < 0.4) & (T[:, None] >= LAND_T), axis=0)\n", + " touch = np.array([vz[max(i - 6, 0):i - 2, j].mean() if i > 6 else 3.0 for j, i in enumerate(land)])\n", + " ds = np.abs(np.diff(np.stack([log[\"stick_roll\"], log[\"stick_pitch\"]]), axis=1)) / DT\n", + " return pd.DataFrame({\n", + " \"path_rmse\": np.sqrt((h[cr] ** 2).mean(0)), \"hover_drift\": h[hv].mean(0), \"hover_std\": np.hypot(X[hv].std(0), Y[hv].std(0)),\n", + " \"alt_err\": np.abs(A[:850] - log[\"talt\"][:850]).mean(0),\n", + " \"obs_rt\": rt, \"obs_lateral\": np.abs(Y[k1] - Y[k0]), \"touch_vz\": touch,\n", + " \"att_exceed\": (np.maximum(np.abs(log[\"roll_deg\"]), np.abs(log[\"pitch_deg\"])) > 35).sum(0),\n", + " \"jerk\": np.percentile(ds, 95, axis=(0, 1)),\n", + " \"overshoot\": np.clip(np.abs(Y[k0:k0 + 120] - Y[k0]).max(0) - 6.0, 0, None)}).pipe(\n", + " lambda d: d.assign(n_risk=detect(d).sum(axis=1))) # overshoot: 회피 후 목표 6m를 넘어선 거리(과보정)\n", + "\n", + "\n", + "def make_dataset(n_pilots=40, seed=7, conds=(\"day\", \"night\", \"gust\"), flights=1):\n", + " \"\"\"조종자 n명 x 조건 x 반복 비행 -> 비행별 특징 + 정답(교관 점수·등급·위험 사건) 표.\"\"\"\n", + " pilots = make_pilots(n_pilots, seed)\n", + " out = []\n", + " for c in conds:\n", + " for f in range(flights):\n", + " log, tr = fly(pilots, c, seed=seed * 100 + list(COND).index(c) * 10 + f)\n", + " out.append(pd.concat([tr.reset_index(drop=True), features(log)], axis=1).assign(flight=f))\n", + " return pd.concat(out, ignore_index=True)\n", + "\n", + "\n", + "def cond_z(df, ref, cols):\n", + " \"\"\"조건별 기준 집단(ref)의 평균·표준편차로 표준화: 돌풍처럼 어려운 조건에서 받는 불이익을 줄인다.\"\"\"\n", + " z = df.copy()\n", + " z[cols] = z[cols].astype(float)\n", + " for c in df[\"cond\"].unique():\n", + " r = ref[ref[\"cond\"] == c][cols]\n", + " z.loc[df[\"cond\"] == c, cols] = (df.loc[df[\"cond\"] == c, cols] - r.mean()) / (r.std() + 1e-9)\n", + " return z\n", + "\n", + "\n", + "def to_grade(score):\n", + " return np.where(score >= 75, \"A\", np.where(score >= 55, \"B\", \"C\"))\n", + "\n", + "KEYS = [\"t\", \"x\", \"y\", \"alt\", \"stick_pitch\", \"stick_roll\", \"throttle\", \"roll_deg\", \"pitch_deg\", \"tx\", \"ty\", \"talt\"]\n", + "\n", + "\n", + "def check_log(L):\n", + " \"\"\"로그 1건 검증. 문제가 있으면 사유 문자열, 없으면 None.\"\"\"\n", + " if any(k not in L for k in KEYS):\n", + " return \"필드 누락\"\n", + " if len(L[\"t\"]) != STEPS:\n", + " return f\"길이 오류({len(L['t'])})\"\n", + " if np.isnan(L[\"x\"]).mean() > 0.2:\n", + " return \"결측 20% 초과\"\n", + " if np.any(np.diff(L[\"t\"]) <= 0):\n", + " return \"시각 역행\"\n", + " if np.abs(L[\"stick_roll\"]).max() > 1.0001:\n", + " return \"스틱 범위 초과\"\n", + " return None\n", + "\n", + "\n", + "def score_chunk(logs, conds, eng, ref):\n", + " stacked = {k: np.stack([L[k] for L in logs], axis=1) if k != \"t\" else T for k in KEYS}\n", + " stacked = {k: (pd.DataFrame(v).interpolate(limit_direction=\"both\").values if v.ndim == 2 else v) for k, v in stacked.items()}\n", + " f = features(stacked).assign(cond=conds)\n", + " return np.clip(eng.predict(cond_z(f, ref, ADV_F)[ADV_F]), 0, 100)\n", + "\n", + "\n", + "def run_batch(logs, conds, eng, ref, chunk=100, fail_chunk=None):\n", + " scored, quarantine, degraded, down = {}, {}, 0, 0\n", + " try:\n", + " ok = []\n", + " for i, L in enumerate(logs):\n", + " why = check_log(L)\n", + " (quarantine.__setitem__(i, why) if why else ok.append(i))\n", + " for c0 in range(0, len(ok), chunk):\n", + " idx = ok[c0:c0 + chunk]\n", + " try:\n", + " if fail_chunk is not None and c0 // chunk == fail_chunk:\n", + " raise MemoryError(\"모의 자원 부족\")\n", + " for i, s in zip(idx, score_chunk([logs[i] for i in idx], [conds[i] for i in idx], eng, ref)):\n", + " scored[i] = s\n", + " except Exception: # 기능 축소: 1건씩 처리, 그래도 실패하면 격리\n", + " degraded += 1\n", + " for i in idx:\n", + " try:\n", + " scored[i] = score_chunk([logs[i]], [conds[i]], eng, ref)[0]\n", + " except Exception as e:\n", + " quarantine[i] = f\"처리 실패({type(e).__name__})\"\n", + " except Exception as e:\n", + " down = 1; print(\"다운:\", e)\n", + " return scored, quarantine, degraded, down\n", + "\n", + "\n", + "# 엔진 준비(미션 4 방식)\n", + "train = make_dataset(40, seed=7)\n", + "eng = Ridge(alpha=1.0).fit(cond_z(train, train, ADV_F)[ADV_F], train[\"instructor\"])\n", + "\n", + "# [교육용가상자료] 조종자 100명 x 비행 6회 = 로그 600건 생성\n", + "pilots = make_pilots(100, seed=12)\n", + "LOGS, CONDS = [], []\n", + "for f in range(6):\n", + " cond = list(COND)[f % 3]\n", + " log, _ = fly(pilots, cond, seed=900 + f)\n", + " for j in range(len(pilots)):\n", + " LOGS.append({k: (v if v.ndim == 1 else v[:, j].copy()) for k, v in log.items() if k in KEYS}); CONDS.append(cond)\n", + "rng = np.random.default_rng(5)\n", + "BAD = []\n", + "for i in rng.choice(len(LOGS), 30, replace=False): # 손상 로그 5% 주입\n", + " L = {k: v.copy() for k, v in LOGS[i].items()}\n", + " kind = i % 4\n", + " if kind == 0:\n", + " del L[\"throttle\"]\n", + " elif kind == 1:\n", + " L[\"x\"][:400] = np.nan\n", + " elif kind == 2:\n", + " L = {k: v[:500] for k, v in L.items()}\n", + " else:\n", + " L[\"stick_roll\"][100] = 7.0\n", + " BAD.append((i, L))\n", + "CORRUPT = list(LOGS)\n", + "for i, L in BAD:\n", + " CORRUPT[i] = L\n", + "\n", + "CASES = [(\"평시 100건\", LOGS[:100], CONDS[:100], None), (\"100명 x 5회 500건\", LOGS[:500], CONDS[:500], None),\n", + " (\"500% 증가 600건\", LOGS, CONDS, None), (\"600건 + 손상 5%\", CORRUPT, CONDS, None),\n", + " (\"600건 + 손상 + 묶음 예외\", CORRUPT, CONDS, 2)]\n", + "rows = []\n", + "for name, logs, conds, fail in CASES:\n", + " t0 = time.perf_counter()\n", + " sc, qu, dg, dn = run_batch(logs, conds, eng, train, fail_chunk=fail)\n", + " dt = time.perf_counter() - t0\n", + " rows.append({\"상황\": name, \"로그\": len(logs), \"채점\": len(sc), \"격리\": len(qu), \"유실\": len(logs) - len(sc) - len(qu),\n", + " \"다운\": dn, \"기능축소 묶음\": dg, \"처리량(건/s)\": round(len(logs) / dt), \"소요(s)\": round(dt, 2)})\n", + "R = pd.DataFrame(rows)\n", + "print(R.to_string(index=False))\n", + "print(f\"\\n시스템 다운 {R['다운'].sum()}건, 로그 유실 {R['유실'].sum()}건(채점+격리=전체), 최저 처리량 {R['처리량(건/s)'].min()}건/s\")\n", + "_, qu, _, _ = run_batch(CORRUPT, CONDS, eng, train)\n", + "print(\"격리 사유:\", pd.Series(list(qu.values())).value_counts().to_dict(), f\"/ 주입 {len(BAD)}건\")\n", + "\n", + "tracemalloc.start()\n", + "mem = []\n", + "for k in range(3):\n", + " run_batch(LOGS[:200], CONDS[:200], eng, train)\n", + " mem.append(tracemalloc.get_traced_memory()[0] / 1e6)\n", + "peak = tracemalloc.get_traced_memory()[1] / 1e6\n", + "tracemalloc.stop()\n", + "print(f\"200건 처리 최대 메모리 {peak:.0f} MB, 반복 후 잔류 {[round(m, 1) for m in mem]} MB -> {'누수 의심' if mem[-1] > mem[0] * 1.5 + 1 else '누수 징후 없음'}\")\n", + "\n", + "pr = cProfile.Profile(); pr.enable()\n", + "run_batch(LOGS[:300], CONDS[:300], eng, train)\n", + "pr.disable()\n", + "s = io.StringIO(); pstats.Stats(pr, stream=s).sort_stats(\"cumulative\").print_stats(30)\n", + "top = [ln.split() for ln in s.getvalue().splitlines() if ln.strip()[:1].isdigit() and \".py\" in ln and \"m08\" in ln.split()[-1]]\n", + "print(\"[누적 시간 상위 함수(이 파일)]\")\n", + "for f in top[:4]:\n", + " print(f\" {f[0]:>6}회 {f[3]}s {f[-1].split(':')[-1]}\")\n", + "print(\"개선 방향: 비행별 보간(DataFrame.interpolate)을 numpy 일괄 보간으로 바꾸고, 묶음 크기를 메모리에 맞춰 조정한다.\")\n", + "\n", + "plt.figure(figsize=(8, 4))\n", + "plt.bar([f\"case{i + 1}\" for i in range(len(R))], R[\"처리량(건/s)\"], color=[\"tab:blue\"] * 3 + [\"tab:orange\", \"tab:red\"])\n", + "plt.ylabel(\"logs per second\"); plt.title(\"Batch analysis stress test throughput (synthetic)\")\n", + "plt.tight_layout(); plt.show()\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 08 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + " 상황 로그 채점 격리 유실 다운 기능축소 묶음 처리량(건/s) 소요(s)\n", + " 평시 100건 100 100 0 0 0 0 1297 0.08\n", + " 100명 x 5회 500건 500 500 0 0 0 0 1290 0.39\n", + " 500% 증가 600건 600 600 0 0 0 0 1214 0.49\n", + " 600건 + 손상 5% 600 570 30 0 0 0 1258 0.48\n", + "600건 + 손상 + 묶음 예외 600 570 30 0 0 1 264 2.28\n", + "\n", + "시스템 다운 0건, 로그 유실 0건(채점+격리=전체), 최저 처리량 264건/s\n", + "격리 사유: {'스틱 범위 초과': 10, '필드 누락': 8, '길이 오류(500)': 6, '결측 20% 초과': 6} / 주입 30건\n", + "200건 처리 최대 메모리 22 MB, 반복 후 잔류 [0.0, 0.0, 0.0] MB -> 누수 징후 없음\n", + "[누적 시간 상위 함수(이 파일)]\n", + " 1회 0.398s 193(run_batch)\n", + " 3회 0.376s 186(score_chunk)\n", + " 3회 0.171s 188()\n", + " 3회 0.088s 155(cond_z)\n", + "개선 방향: 비행별 보간(DataFrame.interpolate)을 numpy 일괄 보간으로 바꾸고, 묶음 크기를 메모리에 맞춰 조정한다.\n", + "```\n", + "
\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 09. AAR 기반 교관 평가 점수와 AI 숙련도 점수 간 편차 분석 및 가중치 재튜닝\n", + "**소요시간**: 약 3.0시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "AAR 기반 교관 평가 점수와 AI 점수 간 편차를 분석하고 가중치를 재보정한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **AAR로 교관 평가 점수와 AI 점수의 편차를 분석해 원인을 규명하고 채점 가중치를 재튜닝한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **주간 비행만으로 맞춘 초기 AI를 한 기수 훈련에 적용해 조건별·위험 사건 수별·습관별 편차를 분해하고 원인 정리하고 규격화하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `docs/trn01_aar_report.md 및 configs/trn01_tuned_params.yaml` 형태의 결과물을 완성한다.\n", + "\n", + "### 🔍 평가 및 검증 기준 (PASS 조건)\n", + "#### 미션 판정\n", + "\n", + "**평가 구분:** 필수\n", + "\n", + "**핵심 평가영역:** 오류 원인 규명의 객관성 및 개선 조치의 논리적 타당성\n", + "\n", + "#### 미션 PASS 조건\n", + "\n", + "- 정량 기준: AAR 피드백 적용 후 종합 정확도 10%p 이상 향상, 재발 오류 0건\n", + "\n", + "\n", + "- 진행 기준: AAR 보고서 작성 및 파라미터 튜닝 전후 회귀 테스트 통과\n", + "\n", + "\n", + "- 검증 방법: 회귀 테스트(Regression Test) 스위트 전체 자동 실행 및 비교\n", + "\n", + "\n", + "#### 채점자 판정 메모\n", + "\n", + "> 단순 코드 실행 여부뿐만 아니라 출력된 수치와 근거 자료가 군 실무 요구사항에 부합하는지 면밀히 확인한다.\n", + "\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "\"\"\"AAR(사후검토)로 교관 평가 점수와 AI 숙련도 점수의 편차를 비행 조건·위험 사건·조작 습관별로 분석해 원인을 규명하고, 특징 구성·조건 표준화·가중치(규제 강도)를 격자 탐색으로 재튜닝한 뒤 다른 조종자 집단으로 회귀 테스트해 개선 전후를 비교한다.\"\"\"\n", + "import numpy as np\n", + "import pandas as pd\n", + "import matplotlib.pyplot as plt\n", + "from sklearn.linear_model import Ridge\n", + "\n", + "# ---------------------------------------------------------------------------\n", + "# [교육용가상자료] 드론 조종 시뮬레이터 비행로그 생성기 (실제 훈련생·장비 정보 없음, 조종자는 가명 P001~)\n", + "# 비행 1회 = 120초(10Hz): 경로 비행(0~60초) -> 호버링(60~85초) -> 돌발 장애물 회피(85초) -> 비상 착륙(100초 지시)\n", + "# 조종자마다 숨은 숙련도(skill)와 조작 습관(급조작 jerky, 과보정 overcorr)이 있고, 교관 점수는 이를 반영해 만든다.\n", + "# ---------------------------------------------------------------------------\n", + "DT, STEPS = 0.1, 1200\n", + "T = np.arange(STEPS) * DT\n", + "COND = {\"day\": dict(wind=0.4, gust=0.0, gps=0.25, delay=0.0), \"night\": dict(wind=0.4, gust=0.0, gps=0.5, delay=0.15),\n", + " \"gust\": dict(wind=1.2, gust=2.5, gps=0.25, delay=0.0)}\n", + "OBST_T, LAND_T = 85.0, 100.0\n", + "\n", + "\n", + "def make_pilots(n=40, seed=7):\n", + " rng = np.random.default_rng(seed)\n", + " skill = rng.beta(2.2, 2.0, n)\n", + " return pd.DataFrame({\"pilot\": [f\"P{i + 1:03d}\" for i in range(n)], \"skill\": skill,\n", + " \"jerky\": rng.random(n) < 0.15 + 0.35 * (1 - skill), \"overcorr\": rng.random(n) < 0.15 + 0.35 * (1 - skill)})\n", + "\n", + "\n", + "def target_path(t):\n", + " \"\"\"사각형 경로(40x30m, 고도 20m)를 3m/s로 비행 -> 60초 지점에서 호버링 -> 착륙 지시 후 고도 0.\"\"\"\n", + " # [TODO : 학생 실습 9] AAR 기반 교관 평가 점수와 AI 숙련도 점수 간 편차 분석 및 가중치 재튜닝 관련 핵심 로직을 구현하세요.\n", + " # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n", + " # <<< 여기에 코드를 작성하세요 >>>\n", + " pass # 작성 후 pass를 제거하세요.\n", + "def fly(pilots, cond=\"day\", seed=0, delay_extra=0.0, obstacle_side=None):\n", + " \"\"\"여러 조종자의 비행 1회를 동시에 모의한다(벡터화). 반환: 로그 배열 dict, 비행별 정답 정보 DataFrame.\"\"\"\n", + " # [TODO : 학생 실습 9] AAR 기반 교관 평가 점수와 AI 숙련도 점수 간 편차 분석 및 가중치 재튜닝 관련 핵심 로직을 구현하세요.\n", + " # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n", + " # <<< 여기에 코드를 작성하세요 >>>\n", + " pass # 작성 후 pass를 제거하세요.\n", + "def one(log, j):\n", + " \"\"\"여러 조종자 로그에서 j번째 조종자 1명의 로그(DataFrame)를 꺼낸다.\"\"\"\n", + " return pd.DataFrame({k: (v if v.ndim == 1 else v[:, j]) for k, v in log.items()})\n", + "# ---------------------------------------------------------------------------\n", + "\n", + "# ---- 비행로그 특징 추출 공통 함수(여러 조종자 로그를 한 번에 계산) ----\n", + "from scipy.ndimage import uniform_filter1d\n", + "\n", + "BASE_F = [\"path_rmse\", \"hover_drift\", \"hover_std\", \"alt_err\"]\n", + "ADV_F = BASE_F + [\"obs_rt\", \"obs_lateral\", \"touch_vz\", \"att_exceed\", \"jerk\", \"overshoot\", \"n_risk\"]\n", + "RULES = {\"near_collision\": (\"obs_lateral\", \"<\", 2.5), \"hard_landing\": (\"touch_vz\", \">\", 2.0), \"attitude_exceed\": (\"att_exceed\", \">=\", 10)}\n", + "\n", + "\n", + "def detect(df):\n", + " \"\"\"위험 조작 사건 탐지 규칙: 장애물 2.5초 후 회피 거리 2.5m 미만, 접지 속도 2.0m/s 초과, 자세 35도 초과 1초 이상.\"\"\"\n", + " out = {}\n", + " for ev, (f, op, th) in RULES.items():\n", + " out[ev] = df[f] < th if op == \"<\" else (df[f] > th if op == \">\" else df[f] >= th)\n", + " return pd.DataFrame(out)\n", + "\n", + "\n", + "def features(log):\n", + " \"\"\"경로 이탈도·호버링 안정성(기본) + 장애물 반응·착륙 속도·자세 한계·급조작·과보정(고도화) 특징.\"\"\"\n", + " X, Y, A = (uniform_filter1d(log[k], 9, axis=0) for k in (\"x\", \"y\", \"alt\")) # GPS 잡음 완화(이동평균 0.9초)\n", + " h = np.hypot(X - log[\"tx\"], Y - log[\"ty\"])\n", + " cr, hv = slice(0, 600), slice(620, 850)\n", + " k0, k1 = int(OBST_T / DT), int((OBST_T + 2.5) / DT)\n", + " side = np.sign(log[\"ty\"][k0 + 1] - log[\"ty\"][k0 - 1])\n", + " moved = (log[\"stick_roll\"][k0:k0 + 40] * side) > 0.3\n", + " rt = np.where(moved.any(0), moved.argmax(0) * DT, 4.0)\n", + " vz = -np.gradient(A, DT, axis=0)\n", + " land = np.argmax((A < 0.4) & (T[:, None] >= LAND_T), axis=0)\n", + " touch = np.array([vz[max(i - 6, 0):i - 2, j].mean() if i > 6 else 3.0 for j, i in enumerate(land)])\n", + " ds = np.abs(np.diff(np.stack([log[\"stick_roll\"], log[\"stick_pitch\"]]), axis=1)) / DT\n", + " return pd.DataFrame({\n", + " \"path_rmse\": np.sqrt((h[cr] ** 2).mean(0)), \"hover_drift\": h[hv].mean(0), \"hover_std\": np.hypot(X[hv].std(0), Y[hv].std(0)),\n", + " \"alt_err\": np.abs(A[:850] - log[\"talt\"][:850]).mean(0),\n", + " \"obs_rt\": rt, \"obs_lateral\": np.abs(Y[k1] - Y[k0]), \"touch_vz\": touch,\n", + " \"att_exceed\": (np.maximum(np.abs(log[\"roll_deg\"]), np.abs(log[\"pitch_deg\"])) > 35).sum(0),\n", + " \"jerk\": np.percentile(ds, 95, axis=(0, 1)),\n", + " \"overshoot\": np.clip(np.abs(Y[k0:k0 + 120] - Y[k0]).max(0) - 6.0, 0, None)}).pipe(\n", + " lambda d: d.assign(n_risk=detect(d).sum(axis=1))) # overshoot: 회피 후 목표 6m를 넘어선 거리(과보정)\n", + "\n", + "\n", + "def make_dataset(n_pilots=40, seed=7, conds=(\"day\", \"night\", \"gust\"), flights=1):\n", + " \"\"\"조종자 n명 x 조건 x 반복 비행 -> 비행별 특징 + 정답(교관 점수·등급·위험 사건) 표.\"\"\"\n", + " pilots = make_pilots(n_pilots, seed)\n", + " out = []\n", + " for c in conds:\n", + " for f in range(flights):\n", + " log, tr = fly(pilots, c, seed=seed * 100 + list(COND).index(c) * 10 + f)\n", + " out.append(pd.concat([tr.reset_index(drop=True), features(log)], axis=1).assign(flight=f))\n", + " return pd.concat(out, ignore_index=True)\n", + "\n", + "\n", + "def cond_z(df, ref, cols):\n", + " \"\"\"조건별 기준 집단(ref)의 평균·표준편차로 표준화: 돌풍처럼 어려운 조건에서 받는 불이익을 줄인다.\"\"\"\n", + " z = df.copy()\n", + " z[cols] = z[cols].astype(float)\n", + " for c in df[\"cond\"].unique():\n", + " r = ref[ref[\"cond\"] == c][cols]\n", + " z.loc[df[\"cond\"] == c, cols] = (df.loc[df[\"cond\"] == c, cols] - r.mean()) / (r.std() + 1e-9)\n", + " return z\n", + "\n", + "\n", + "def to_grade(score):\n", + " return np.where(score >= 75, \"A\", np.where(score >= 55, \"B\", \"C\"))\n", + "\n", + "\n", + "def fit(df, feats, norm, alpha):\n", + " X = cond_z(df, df, feats)[feats] if norm else df[feats]\n", + " return Ridge(alpha=alpha).fit(X, df[\"instructor\"])\n", + "\n", + "\n", + "def predict(m, df, ref, feats, norm):\n", + " X = cond_z(df, ref, feats)[feats] if norm else df[feats]\n", + " return np.clip(m.predict(X), 0, 100)\n", + "\n", + "\n", + "# 초기 운용 AI: 주간 비행만으로 맞춘 Baseline(경로·호버링·고도, 조건 표준화 없음)\n", + "day_train = make_dataset(40, seed=7, conds=(\"day\",))\n", + "INIT = dict(feats=BASE_F, norm=False, alpha=1.0)\n", + "init_m = fit(day_train, **INIT)\n", + "\n", + "# 1) 무엇이 일어났나: 훈련 1개 기수(조종자 40명 x 3조건)의 교관 점수와 AI 점수 비교\n", + "aar = make_dataset(40, seed=8)\n", + "aar[\"ai\"] = predict(init_m, aar, day_train, INIT[\"feats\"], INIT[\"norm\"])\n", + "aar[\"dev\"] = aar[\"ai\"] - aar[\"instructor\"]\n", + "acc0 = (to_grade(aar[\"ai\"]) == aar[\"grade\"]).mean()\n", + "print(f\"[1. 무엇이 일어났나] 초기 AI 등급 일치율 {acc0:.1%}, 점수 편차(AI-교관) 평균 {aar['dev'].mean():+.1f}점, 절대 편차 {aar['dev'].abs().mean():.1f}점\")\n", + "\n", + "# 2) 왜 일어났나: 편차 원인 분해\n", + "aar[\"위험사건\"] = aar[[\"near_collision\", \"hard_landing\", \"attitude_exceed\"]].sum(axis=1).clip(0, 2).astype(str).str.replace(\"2\", \"2+\")\n", + "print(\"\\n[2. 왜 일어났나] 조건별 평균 편차:\", aar.groupby(\"cond\")[\"dev\"].mean().round(1).to_dict())\n", + "ng = aar[aar[\"cond\"] != \"gust\"] # 조건 효과를 뺀 주간·야간 비행으로 비교\n", + "ev = ng.groupby(\"위험사건\")[\"dev\"].mean().round(1)\n", + "print(\"주간·야간 비행의 위험 사건 수별 평균 편차:\", ev.to_dict())\n", + "print(\"주간·야간 비행의 습관별 평균 편차: 급조작\", round(ng[ng[\"jerky\"]][\"dev\"].mean(), 1), \"/ 과보정\", round(ng[ng[\"overcorr\"]][\"dev\"].mean(), 1),\n", + " \"/ 습관 없음\", round(ng[~ng[\"jerky\"] & ~ng[\"overcorr\"]][\"dev\"].mean(), 1))\n", + "print(f\"-> 원인: (가) 주간 자료로만 맞춰 돌풍 비행을 평균 {aar[aar['cond'] == 'gust']['dev'].mean():.0f}점 과소평가(경로 이탈을 실력 부족으로 오인), \"\n", + " f\"(나) 위험 사건 2건 이상 비행을 0건 대비 {ev.get('2+', np.nan) - ev.get('0', np.nan):+.1f}점 과대평가(장애물·착륙 대응 미반영), (다) 급조작 습관 미반영\")\n", + "\n", + "# 3) 무엇을 바꾸나: AAR 자료(3조건)로 격자 탐색, 기수 내 2겹 교차검증으로 선택\n", + "pid = aar[\"pilot\"].str[1:].astype(int) % 2\n", + "grid = []\n", + "for name, feats in [(\"기본\", BASE_F), (\"고도화\", ADV_F)]:\n", + " for norm in (False, True):\n", + " for alpha in (0.1, 1.0, 10.0):\n", + " accs = []\n", + " for k in (0, 1):\n", + " tr, va = aar[pid != k], aar[pid == k]\n", + " m = fit(tr, feats, norm, alpha)\n", + " accs.append((to_grade(predict(m, va, tr, feats, norm)) == va[\"grade\"]).mean())\n", + " grid.append({\"특징\": name, \"조건표준화\": norm, \"alpha\": alpha, \"교차검증 일치율\": round(np.mean(accs), 3)})\n", + "g = pd.DataFrame(grid).sort_values(\"교차검증 일치율\", ascending=False)\n", + "print(\"\\n[3. 무엇을 바꾸나] 격자 12개 중 상위 4개\")\n", + "print(g.head(4).to_string(index=False))\n", + "best = g.iloc[0]\n", + "TUNED = dict(feats=ADV_F if best[\"특징\"] == \"고도화\" else BASE_F, norm=bool(best[\"조건표준화\"]), alpha=float(best[\"alpha\"]))\n", + "tuned_m = fit(aar, **TUNED)\n", + "\n", + "# 4) 회귀 테스트: 새 기수 2개(학습에 쓰지 않은 조종자)\n", + "print(\"\\n[회귀 테스트] 기수 초기 AI 재튜닝 변화\")\n", + "reg, rep, near = [], [], []\n", + "for sd in (21, 22):\n", + " new = make_dataset(40, seed=sd)\n", + " a0 = to_grade(predict(init_m, new, day_train, INIT[\"feats\"], INIT[\"norm\"]))\n", + " a1 = to_grade(predict(tuned_m, new, aar, TUNED[\"feats\"], TUNED[\"norm\"]))\n", + " r0, r1 = (a0 == new[\"grade\"]).mean(), (a1 == new[\"grade\"]).mean()\n", + " both = (a0 != new[\"grade\"]) & (a1 != new[\"grade\"])\n", + " reg.append((r0, r1)); rep.append(int(both.sum()))\n", + " near += list((np.minimum(np.abs(new[\"instructor\"] - 55), np.abs(new[\"instructor\"] - 75)) <= 5)[both])\n", + " print(f\" #{sd} {r0:6.1%} {r1:6.1%} {(r1 - r0) * 100:+5.1f}%p\")\n", + "gain = np.mean([b - a for a, b in reg])\n", + "print(f\"평균 향상 {gain * 100:+.1f}%p (목표 +10%p 이상: {'달성' if gain >= 0.10 else '미달'}), 회귀 테스트 {sum(b >= a for a, b in reg)}/{len(reg)} 통과\")\n", + "print(f\"재튜닝 후에도 반복된 불일치 {sum(rep)}건(전체 {2 * len(new)}회 중), 그중 교관 점수가 등급 경계(55·75점) ±5점 이내인 비행 {np.mean(near):.0%}\")\n", + "\n", + "w = pd.Series(tuned_m.coef_, index=TUNED[\"feats\"]).round(2)\n", + "yaml = (f\"features: [{', '.join(TUNED['feats'])}]\\ncondition_normalization: {str(TUNED['norm']).lower()}\\nridge_alpha: {TUNED['alpha']}\\n\"\n", + " f\"grade_cut: {{A: 75, B: 55}}\\nweights: {{{', '.join(f'{k}: {v}' for k, v in w.items())}}}\")\n", + "print(\"\\n[재튜닝 파라미터(trn01_tuned_params.yaml 내용)]\\n\" + yaml)\n", + "print(\"지속 개선: 기수마다 교관 점수를 AAR 자료로 누적해 같은 절차로 재튜닝하고, 회귀 테스트 통과 시에만 교체한다.\")\n", + "\n", + "plt.figure(figsize=(7, 4))\n", + "for c, col in zip(COND, [\"tab:blue\", \"tab:purple\", \"tab:orange\"]):\n", + " s = aar[aar[\"cond\"] == c]\n", + " plt.scatter(s[\"instructor\"], s[\"dev\"], s=12, c=col, label=c)\n", + "plt.axhline(0, c=\"k\"); plt.xlabel(\"instructor score\"); plt.ylabel(\"AI - instructor (initial AI)\")\n", + "plt.legend(); plt.title(\"AAR: deviation of initial AI score (synthetic)\"); plt.tight_layout(); plt.show()\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 09 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + "[1. 무엇이 일어났나] 초기 AI 등급 일치율 47.5%, 점수 편차(AI-교관) 평균 -12.2점, 절대 편차 21.1점\n", + "\n", + "[2. 왜 일어났나] 조건별 평균 편차: {'day': 2.7, 'gust': -40.1, 'night': 0.9}\n", + "주간·야간 비행의 위험 사건 수별 평균 편차: {'0': -9.8, '1': 0.6, '2+': 11.7}\n", + "주간·야간 비행의 습관별 평균 편차: 급조작 7.3 / 과보정 3.6 / 습관 없음 0.3\n", + "-> 원인: (가) 주간 자료로만 맞춰 돌풍 비행을 평균 -40점 과소평가(경로 이탈을 실력 부족으로 오인), (나) 위험 사건 2건 이상 비행을 0건 대비 +21.5점 과대평가(장애물·착륙 대응 미반영), (다) 급조작 습관 미반영\n", + "\n", + "[3. 무엇을 바꾸나] 격자 12개 중 상위 4개\n", + " 특징 조건표준화 alpha 교차검증 일치율\n", + "고도화 True 10.0 0.825\n", + "고도화 True 0.1 0.817\n", + "고도화 True 1.0 0.808\n", + "고도화 False 0.1 0.783\n", + "\n", + "[회귀 테스트] 기수 초기 AI 재튜닝 변화\n", + " #21 45.0% 89.2% +44.2%p\n", + " #22 40.0% 81.7% +41.7%p\n", + "평균 향상 +42.9%p (목표 +10%p 이상: 달성), 회귀 테스트 2/2 통과\n", + "재튜닝 후에도 반복된 불일치 21건(전체 240회 중), 그중 교관 점수가 등급 경계(55·75점) ±5점 이내인 비행 86%\n", + "\n", + "[재튜닝 파라미터(trn01_tuned_params.yaml 내용)]\n", + "features: [path_rmse, hover_drift, hover_std, alt_err, obs_rt, obs_lateral, touch_vz, att_exceed, jerk, overshoot, n_risk]\n", + "condition_normalization: true\n", + "ridge_alpha: 10.0\n", + "grade_cut: {A: 75, B: 55}\n", + "weights: {path_rmse: -0.59, hover_drift: 1.22, hover_std: 0.77, alt_err: -7.67, obs_rt: -0.51, obs_lateral: 1.08, touch_vz: -4.19, att_exceed: -0.54, jerk: -1.59, overshoot: -0.66, n_risk: -2.4}\n", + "지속 개선: 기수마다 교관 점수를 AAR 자료로 누적해 같은 절차로 재튜닝하고, 회귀 테스트 통과 시에만 교체한다.\n", + "```\n", + "
\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 10. 드론 비행로그 숙련도 분석 시스템 통합 및 교육훈련 브리핑 패키지 완성\n", + "**소요시간**: 약 3.5시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "드론 비행로그 숙련도 분석 시스템 통합 패키지 및 교육훈련 브리핑 보고서를 완성한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **로그 검증부터 전처리, 특징 추출, 재튜닝 엔진, 위험 조작 탐지, 조종자 등급·신뢰도, 교관 검토 대기열, 대시보드까지 통합하고 교육훈련 브리핑 패키지를 완성한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **재튜닝 파라미터로 신규 기수 로그를 한 번에 분석하는 통합 코드를 완성하고 손상 로그가 격리되는지 확인하고 유효성을 점검하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `src/main_trn01.py, README.md, docs/trn01_final_report.pdf(md)` 형태의 결과물을 완성한다.\n", + "\n", + "### 🔍 평가 및 검증 기준 (PASS 조건)\n", + "#### 미션 판정\n", + "\n", + "**평가 구분:** 필수\n", + "\n", + "**핵심 평가영역:** 군 실무 즉시 도입 가능성, 운용 신뢰도 및 전략적 기대효과\n", + "\n", + "#### 미션 PASS 조건\n", + "\n", + "- 정량 기준: End-to-End 통합 파이프라인 실행 성공률 100%, 테스트 커버리지 85% 이상\n", + "\n", + "\n", + "- 진행 기준: 원클릭 실행 스크립트 동작 확인 및 최종 발표 산출물 작성 완료\n", + "\n", + "\n", + "- 검증 방법: 최종 검증 위원회(평가관) 모의 시연 및 전체 기능 통합 검증\n", + "\n", + "\n", + "#### 채점자 판정 메모\n", + "\n", + "> 단순 코드 실행 여부뿐만 아니라 출력된 수치와 근거 자료가 군 실무 요구사항에 부합하는지 면밀히 확인한다.\n", + "\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "\"\"\"비행로그 검증·전처리부터 특징 추출, 재튜닝된 숙련도 엔진, 위험 조작 탐지, 조종자별 등급·신뢰도, 교관 HITL 검토 대기열, 분석 대시보드까지 전체 파이프라인을 한 파일로 통합 실행하고, 함수 커버리지를 포함한 자체 점검 결과와 교육훈련 브리핑 요약을 출력한다.\"\"\"\n", + "import hashlib\n", + "import inspect\n", + "import json\n", + "import sys\n", + "import time\n", + "import numpy as np\n", + "import pandas as pd\n", + "import matplotlib.pyplot as plt\n", + "from sklearn.linear_model import Ridge\n", + "\n", + "# ---------------------------------------------------------------------------\n", + "# [교육용가상자료] 드론 조종 시뮬레이터 비행로그 생성기 (실제 훈련생·장비 정보 없음, 조종자는 가명 P001~)\n", + "# 비행 1회 = 120초(10Hz): 경로 비행(0~60초) -> 호버링(60~85초) -> 돌발 장애물 회피(85초) -> 비상 착륙(100초 지시)\n", + "# 조종자마다 숨은 숙련도(skill)와 조작 습관(급조작 jerky, 과보정 overcorr)이 있고, 교관 점수는 이를 반영해 만든다.\n", + "# ---------------------------------------------------------------------------\n", + "DT, STEPS = 0.1, 1200\n", + "T = np.arange(STEPS) * DT\n", + "COND = {\"day\": dict(wind=0.4, gust=0.0, gps=0.25, delay=0.0), \"night\": dict(wind=0.4, gust=0.0, gps=0.5, delay=0.15),\n", + " \"gust\": dict(wind=1.2, gust=2.5, gps=0.25, delay=0.0)}\n", + "OBST_T, LAND_T = 85.0, 100.0\n", + "\n", + "\n", + "def make_pilots(n=40, seed=7):\n", + " rng = np.random.default_rng(seed)\n", + " skill = rng.beta(2.2, 2.0, n)\n", + " return pd.DataFrame({\"pilot\": [f\"P{i + 1:03d}\" for i in range(n)], \"skill\": skill,\n", + " \"jerky\": rng.random(n) < 0.15 + 0.35 * (1 - skill), \"overcorr\": rng.random(n) < 0.15 + 0.35 * (1 - skill)})\n", + "\n", + "\n", + "def target_path(t):\n", + " \"\"\"사각형 경로(40x30m, 고도 20m)를 3m/s로 비행 -> 60초 지점에서 호버링 -> 착륙 지시 후 고도 0.\"\"\"\n", + " # [TODO : 학생 실습 10] 드론 비행로그 숙련도 분석 시스템 통합 및 교육훈련 브리핑 패키지 완성 관련 핵심 로직을 구현하세요.\n", + " # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n", + " # <<< 여기에 코드를 작성하세요 >>>\n", + " pass # 작성 후 pass를 제거하세요.\n", + "def fly(pilots, cond=\"day\", seed=0, delay_extra=0.0, obstacle_side=None):\n", + " \"\"\"여러 조종자의 비행 1회를 동시에 모의한다(벡터화). 반환: 로그 배열 dict, 비행별 정답 정보 DataFrame.\"\"\"\n", + " # [TODO : 학생 실습 10] 드론 비행로그 숙련도 분석 시스템 통합 및 교육훈련 브리핑 패키지 완성 관련 핵심 로직을 구현하세요.\n", + " # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n", + " # <<< 여기에 코드를 작성하세요 >>>\n", + " pass # 작성 후 pass를 제거하세요.\n", + "def one(log, j):\n", + " \"\"\"여러 조종자 로그에서 j번째 조종자 1명의 로그(DataFrame)를 꺼낸다.\"\"\"\n", + " return pd.DataFrame({k: (v if v.ndim == 1 else v[:, j]) for k, v in log.items()})\n", + "# ---------------------------------------------------------------------------\n", + "\n", + "# ---- 비행로그 특징 추출 공통 함수(여러 조종자 로그를 한 번에 계산) ----\n", + "from scipy.ndimage import uniform_filter1d\n", + "\n", + "BASE_F = [\"path_rmse\", \"hover_drift\", \"hover_std\", \"alt_err\"]\n", + "ADV_F = BASE_F + [\"obs_rt\", \"obs_lateral\", \"touch_vz\", \"att_exceed\", \"jerk\", \"overshoot\", \"n_risk\"]\n", + "RULES = {\"near_collision\": (\"obs_lateral\", \"<\", 2.5), \"hard_landing\": (\"touch_vz\", \">\", 2.0), \"attitude_exceed\": (\"att_exceed\", \">=\", 10)}\n", + "\n", + "\n", + "def detect(df):\n", + " \"\"\"위험 조작 사건 탐지 규칙: 장애물 2.5초 후 회피 거리 2.5m 미만, 접지 속도 2.0m/s 초과, 자세 35도 초과 1초 이상.\"\"\"\n", + " out = {}\n", + " for ev, (f, op, th) in RULES.items():\n", + " out[ev] = df[f] < th if op == \"<\" else (df[f] > th if op == \">\" else df[f] >= th)\n", + " return pd.DataFrame(out)\n", + "\n", + "\n", + "def features(log):\n", + " \"\"\"경로 이탈도·호버링 안정성(기본) + 장애물 반응·착륙 속도·자세 한계·급조작·과보정(고도화) 특징.\"\"\"\n", + " X, Y, A = (uniform_filter1d(log[k], 9, axis=0) for k in (\"x\", \"y\", \"alt\")) # GPS 잡음 완화(이동평균 0.9초)\n", + " h = np.hypot(X - log[\"tx\"], Y - log[\"ty\"])\n", + " cr, hv = slice(0, 600), slice(620, 850)\n", + " k0, k1 = int(OBST_T / DT), int((OBST_T + 2.5) / DT)\n", + " side = np.sign(log[\"ty\"][k0 + 1] - log[\"ty\"][k0 - 1])\n", + " moved = (log[\"stick_roll\"][k0:k0 + 40] * side) > 0.3\n", + " rt = np.where(moved.any(0), moved.argmax(0) * DT, 4.0)\n", + " vz = -np.gradient(A, DT, axis=0)\n", + " land = np.argmax((A < 0.4) & (T[:, None] >= LAND_T), axis=0)\n", + " touch = np.array([vz[max(i - 6, 0):i - 2, j].mean() if i > 6 else 3.0 for j, i in enumerate(land)])\n", + " ds = np.abs(np.diff(np.stack([log[\"stick_roll\"], log[\"stick_pitch\"]]), axis=1)) / DT\n", + " return pd.DataFrame({\n", + " \"path_rmse\": np.sqrt((h[cr] ** 2).mean(0)), \"hover_drift\": h[hv].mean(0), \"hover_std\": np.hypot(X[hv].std(0), Y[hv].std(0)),\n", + " \"alt_err\": np.abs(A[:850] - log[\"talt\"][:850]).mean(0),\n", + " \"obs_rt\": rt, \"obs_lateral\": np.abs(Y[k1] - Y[k0]), \"touch_vz\": touch,\n", + " \"att_exceed\": (np.maximum(np.abs(log[\"roll_deg\"]), np.abs(log[\"pitch_deg\"])) > 35).sum(0),\n", + " \"jerk\": np.percentile(ds, 95, axis=(0, 1)),\n", + " \"overshoot\": np.clip(np.abs(Y[k0:k0 + 120] - Y[k0]).max(0) - 6.0, 0, None)}).pipe(\n", + " lambda d: d.assign(n_risk=detect(d).sum(axis=1))) # overshoot: 회피 후 목표 6m를 넘어선 거리(과보정)\n", + "\n", + "\n", + "def make_dataset(n_pilots=40, seed=7, conds=(\"day\", \"night\", \"gust\"), flights=1):\n", + " \"\"\"조종자 n명 x 조건 x 반복 비행 -> 비행별 특징 + 정답(교관 점수·등급·위험 사건) 표.\"\"\"\n", + " pilots = make_pilots(n_pilots, seed)\n", + " out = []\n", + " for c in conds:\n", + " for f in range(flights):\n", + " log, tr = fly(pilots, c, seed=seed * 100 + list(COND).index(c) * 10 + f)\n", + " out.append(pd.concat([tr.reset_index(drop=True), features(log)], axis=1).assign(flight=f))\n", + " return pd.concat(out, ignore_index=True)\n", + "\n", + "\n", + "def cond_z(df, ref, cols):\n", + " \"\"\"조건별 기준 집단(ref)의 평균·표준편차로 표준화: 돌풍처럼 어려운 조건에서 받는 불이익을 줄인다.\"\"\"\n", + " z = df.copy()\n", + " z[cols] = z[cols].astype(float)\n", + " for c in df[\"cond\"].unique():\n", + " r = ref[ref[\"cond\"] == c][cols]\n", + " z.loc[df[\"cond\"] == c, cols] = (df.loc[df[\"cond\"] == c, cols] - r.mean()) / (r.std() + 1e-9)\n", + " return z\n", + "\n", + "\n", + "def to_grade(score):\n", + " return np.where(score >= 75, \"A\", np.where(score >= 55, \"B\", \"C\"))\n", + "\n", + "CONFIG = {\"features\": ADV_F, \"condition_normalization\": True, \"ridge_alpha\": 10.0} # 미션 9 재튜닝값\n", + "KEYS = [\"x\", \"y\", \"alt\", \"stick_pitch\", \"stick_roll\", \"throttle\", \"roll_deg\", \"pitch_deg\", \"tx\", \"ty\", \"talt\"]\n", + "\n", + "\n", + "def validate(L):\n", + " if any(k not in L for k in KEYS) or len(L[\"x\"]) != STEPS:\n", + " return \"필드·길이 오류\"\n", + " if np.isnan(L[\"x\"]).mean() > 0.2:\n", + " return \"결측 20% 초과\"\n", + " return None\n", + "\n", + "\n", + "def clean(L):\n", + " out = {}\n", + " for k in KEYS:\n", + " s = pd.Series(L[k])\n", + " if k in (\"x\", \"y\", \"alt\"):\n", + " med = s.rolling(7, center=True, min_periods=1).median()\n", + " s = s.mask((s - med).abs() > 3 * ((s - med).abs().rolling(7, center=True, min_periods=1).median() * 1.4826 + 0.3))\n", + " out[k] = s.interpolate(limit_direction=\"both\").values\n", + " return out\n", + "\n", + "\n", + "class Review:\n", + " def __init__(self):\n", + " self.items, self.log = [], []\n", + "\n", + " def submit(self, rec):\n", + " prev = self.log[-1][\"hash\"] if self.log else \"GENESIS\"\n", + " body = {\"event\": \"AI_SUGGEST\", \"pilot\": rec[\"pilot\"], \"grade\": rec[\"grade\"], \"conf\": rec[\"conf\"]}\n", + " self.log.append({**body, \"prev\": prev, \"hash\": hashlib.sha256((prev + json.dumps(body, sort_keys=True)).encode()).hexdigest()})\n", + " self.items.append({**body, \"status\": \"PENDING\"})\n", + "\n", + "\n", + "called = set()\n", + "sys.setprofile(lambda f, e, a: called.add(f.f_code.co_name) if e == \"call\" and f.f_globals is globals() else None)\n", + "t_start = time.perf_counter()\n", + "\n", + "# ---------------- 1. 엔진 준비(AAR 자료로 재튜닝된 설정) ----------------\n", + "ref = make_dataset(40, seed=8)\n", + "eng = Ridge(alpha=CONFIG[\"ridge_alpha\"]).fit(cond_z(ref, ref, ADV_F)[ADV_F], ref[\"instructor\"])\n", + "\n", + "# ---------------- 2. 신규 기수 로그 수집(조종자 30명 x 3조건) + 손상·잡음 ----------------\n", + "pilots = make_pilots(30, seed=31)\n", + "rng = np.random.default_rng(4)\n", + "logs, conds, truth = [], [], []\n", + "for c in COND:\n", + " log, tr = fly(pilots, c, seed=700 + len(logs))\n", + " for j in range(len(pilots)):\n", + " L = {k: log[k][:, j].copy() for k in KEYS}\n", + " sp = rng.random(STEPS) < 0.01\n", + " L[\"x\"][sp] += 10.0 # GPS 튐\n", + " L[\"y\"][300:310] = np.nan # 센서 단절 1초\n", + " logs.append(L); conds.append(c)\n", + " truth.append(tr)\n", + "truth = pd.concat(truth, ignore_index=True)\n", + "logs[7] = {k: v for k, v in logs[7].items() if k != \"throttle\"} # 손상 로그 2건\n", + "logs[40][\"x\"][:500] = np.nan\n", + "\n", + "# ---------------- 3. 검증·전처리·특징·채점 ----------------\n", + "rejected = {i: r for i, L in enumerate(logs) if (r := validate(L))}\n", + "ok = [i for i in range(len(logs)) if i not in rejected]\n", + "t0 = time.perf_counter()\n", + "cleaned = [clean(logs[i]) for i in ok]\n", + "stack = {k: np.stack([c[k] for c in cleaned], axis=1) for k in KEYS}\n", + "stack[\"t\"] = T\n", + "F = features(stack).assign(cond=[conds[i] for i in ok])\n", + "D = pd.concat([truth.iloc[ok].reset_index(drop=True), F.drop(columns=\"cond\")], axis=1)\n", + "D[\"score\"] = np.clip(eng.predict(cond_z(F, ref, ADV_F)[ADV_F]), 0, 100)\n", + "ev = detect(F)\n", + "ms = (time.perf_counter() - t0) * 1000 / len(ok)\n", + "\n", + "# ---------------- 4. 조종자 집계·신뢰도·교관 검토 대기열 ----------------\n", + "rows = []\n", + "for pid, g in D.groupby(\"pilot\"):\n", + " boot = [to_grade(rng.choice(g[\"score\"].values, len(g)).mean()).item() for _ in range(100)]\n", + " gr = to_grade(g[\"score\"].mean()).item(); cf = boot.count(gr) / 100\n", + " rows.append({\"pilot\": pid, \"score\": g[\"score\"].mean(), \"grade\": gr, \"conf\": \"High\" if cf >= 0.9 else (\"Medium\" if cf >= 0.7 else \"Low\"),\n", + " \"inst_grade\": to_grade(g[\"instructor\"].mean()).item(), \"risk\": int(ev.loc[g.index].values.sum())})\n", + "P = pd.DataFrame(rows)\n", + "rv = Review()\n", + "for r in P.to_dict(\"records\"):\n", + " rv.submit(r)\n", + "sys.setprofile(None)\n", + "\n", + "# ---------------- 5. 자체 점검 ----------------\n", + "mine = [n for n, o in list(globals().items()) if inspect.isfunction(o) and o.__module__ == \"__main__\"] + [\"submit\", \"__init__\"]\n", + "cov = len(set(mine) & called) / len(set(mine))\n", + "fl_acc = (to_grade(D[\"score\"]) == D[\"grade\"]).mean()\n", + "pl_acc = (P[\"grade\"] == P[\"inst_grade\"]).mean()\n", + "truth_ev = D[[\"near_collision\", \"hard_landing\", \"attitude_exceed\"]].astype(bool).values\n", + "rec = (ev.values & truth_ev).sum() / max(truth_ev.sum(), 1)\n", + "chk = {\n", + " \"손상 로그 2건 격리\": len(rejected) == 2,\n", + " \"로그 유실 0건(채점+격리=전체)\": len(ok) + len(rejected) == len(logs),\n", + " \"전처리 후 결측 0\": not any(np.isnan(v).any() for v in stack.values()),\n", + " \"비행별 등급 일치율 75% 이상\": fl_acc >= 0.75,\n", + " \"조종자 등급 일치율 85% 이상\": pl_acc >= 0.85,\n", + " \"위험 사건 탐지 재현율 85% 이상\": rec >= 0.85,\n", + " \"로그당 처리 200ms 이내\": ms < 200,\n", + " \"AI 확정 0건(전부 교관 대기)\": all(i[\"status\"] == \"PENDING\" for i in rv.items),\n", + " \"감사 로그 건수 일치\": len(rv.log) == len(rv.items),\n", + " \"함수 커버리지 85% 이상\": cov >= 0.85,\n", + "}\n", + "passed = sum(chk.values())\n", + "for k, v in chk.items():\n", + " print(f\"[{'PASS' if v else 'FAIL'}] {k}\")\n", + "print(f\"자체 점검 {passed}/{len(chk)} 통과, 함수 커버리지 {cov:.0%}, 파이프라인 실행 성공 {'예' if passed == len(chk) else '아니오'}\")\n", + "print(\"조종자 등급 분포:\", P[\"grade\"].value_counts().reindex([\"A\", \"B\", \"C\"]).fillna(0).astype(int).to_dict(),\n", + " \"/ 신뢰도:\", P[\"conf\"].value_counts().to_dict())\n", + "\n", + "# ---------------- 6. 대시보드 ----------------\n", + "fig, ax = plt.subplots(2, 2, figsize=(12, 7))\n", + "ax[0, 0].scatter(D[\"instructor\"], D[\"score\"], s=10, c=D[\"cond\"].map({\"day\": \"tab:blue\", \"night\": \"tab:purple\", \"gust\": \"tab:orange\"}))\n", + "ax[0, 0].plot([20, 100], [20, 100], \"k--\"); ax[0, 0].set_xlabel(\"instructor\"); ax[0, 0].set_ylabel(\"AI\"); ax[0, 0].set_title(\"Flight score: AI vs instructor\")\n", + "ax[0, 1].bar([\"near_collision\", \"hard_landing\", \"attitude\"], ev.sum().values, color=\"tab:red\"); ax[0, 1].set_title(\"Detected risk events\")\n", + "Ps = P.sort_values(\"score\")\n", + "ax[1, 0].bar(Ps[\"pilot\"], Ps[\"score\"], color=Ps[\"conf\"].map({\"High\": \"tab:green\", \"Medium\": \"tab:orange\", \"Low\": \"tab:red\"}))\n", + "ax[1, 0].axhline(55, c=\"k\", ls=\"--\"); ax[1, 0].axhline(75, c=\"k\", ls=\"--\"); ax[1, 0].tick_params(axis=\"x\", rotation=90, labelsize=6)\n", + "ax[1, 0].set_title(\"Pilot mean score (color = confidence)\")\n", + "ax[1, 1].axis(\"off\")\n", + "ax[1, 1].text(0, 0.9, f\"Logs {len(logs)} ({len(rejected)} rejected)\\nFlight acc {fl_acc:.1%}\\nPilot acc {pl_acc:.1%}\\n\"\n", + " f\"Risk recall {rec:.1%}\\nPending {len(rv.items)} reviews\\nSelf-check {passed}/{len(chk)}\", family=\"monospace\", fontsize=13, va=\"top\")\n", + "plt.suptitle(\"TRN01 integrated skill analyzer - training data only\"); plt.tight_layout(); plt.show()\n", + "\n", + "# ---------------- 7. 교육훈련 브리핑 요약 ----------------\n", + "c_list = P[P[\"grade\"] == \"C\"][\"pilot\"].tolist()\n", + "print(f\"\"\"\n", + "[교육훈련 브리핑 요약 - 교육용가상자료]\n", + "1. 처리: 비행로그 {len(logs)}건 중 {len(ok)}건 분석, 손상 {len(rejected)}건 격리, 로그당 {ms:.1f} ms, 전체 {time.perf_counter() - t_start:.1f}s\n", + "2. 성능: 비행별 등급 일치율 {fl_acc:.1%}, 조종자 등급 일치율 {pl_acc:.1%}, 위험 사건 탐지 재현율 {rec:.1%}\n", + "3. 결과: A {int((P['grade'] == 'A').sum())}명 / B {int((P['grade'] == 'B').sum())}명 / C {len(c_list)}명(재교육 검토 대상 {', '.join(c_list[:5])}{' 외' if len(c_list) > 5 else ''}), 전원 교관 검토 대기\n", + "4. 운용 유의: AI 등급은 교관 판단 보조 자료이며, 재교육·자격 보류는 교관 근거 기록과 두 번째 교관 확인 후 확정한다.\n", + "5. 한계·확장: 가상 시뮬레이터 로그 기준 결과다. 실제 시뮬레이터 로그 형식에 맞춘 재검증이 필요하며, 훈련 관리 체계와는 결과 조회 API로 연동한다.\"\"\")\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 10 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + "[PASS] 손상 로그 2건 격리\n", + "[PASS] 로그 유실 0건(채점+격리=전체)\n", + "[PASS] 전처리 후 결측 0\n", + "[PASS] 비행별 등급 일치율 75% 이상\n", + "[PASS] 조종자 등급 일치율 85% 이상\n", + "[PASS] 위험 사건 탐지 재현율 85% 이상\n", + "[PASS] 로그당 처리 200ms 이내\n", + "[PASS] AI 확정 0건(전부 교관 대기)\n", + "[PASS] 감사 로그 건수 일치\n", + "[PASS] 함수 커버리지 85% 이상\n", + "자체 점검 10/10 통과, 함수 커버리지 92%, 파이프라인 실행 성공 예\n", + "조종자 등급 분포: {'A': 4, 'B': 15, 'C': 11} / 신뢰도: {'High': 21, 'Medium': 6, 'Low': 3}\n", + "\n", + "[교육훈련 브리핑 요약 - 교육용가상자료]\n", + "1. 처리: 비행로그 90건 중 88건 분석, 손상 2건 격리, 로그당 13.6 ms, 전체 5.9s\n", + "2. 성능: 비행별 등급 일치율 79.5%, 조종자 등급 일치율 93.3%, 위험 사건 탐지 재현율 97.4%\n", + "3. 결과: A 4명 / B 15명 / C 11명(재교육 검토 대상 P001, P007, P012, P015, P016 외), 전원 교관 검토 대기\n", + "4. 운용 유의: AI 등급은 교관 판단 보조 자료이며, 재교육·자격 보류는 교관 근거 기록과 두 번째 교관 확인 후 확정한다.\n", + "5. 한계·확장: 가상 시뮬레이터 로그 기준 결과다. 실제 시뮬레이터 로그 형식에 맞춘 재검증이 필요하며, 훈련 관리 체계와는 결과 조회 API로 연동한다.\n", + "```\n", + "
\n" + ] + } + ], + "metadata": { + "colab": { + "provenance": [] + }, + "kernelspec": { + "display_name": "Python 3", + "name": "python3" + }, + "language_info": { + "name": "python" + } + }, + "nbformat": 4, + "nbformat_minor": 0 +} \ No newline at end of file diff --git a/uploads/TRN02/TRN02_solution.ipynb b/uploads/TRN02/TRN02_solution.ipynb new file mode 100644 index 0000000..89a3424 --- /dev/null +++ b/uploads/TRN02/TRN02_solution.ipynb @@ -0,0 +1,4011 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# 🎯 [TRN02] 방공·드론 식별 퀴즈 자동채점·오답유형 분석기\n", + "### Google Colab 실습 환경 (교육·훈련 / Level 2)\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/TRN02/TRN02_solution.ipynb)\n", + "\n", + "---\n", + "\n", + "## 📌 1. 프로젝트 개요 및 배경\n", + "\n", + "| 항목 | 내용 |\n", + "| :--- | :--- |\n", + "| **과정 분류** | 교육·훈련 (Level 2) |\n", + "| **학습 소요 시간** | 10개 미션 (5일 / 총 27.5시간) |\n", + "| **환경 사양** | Python 3.10+, numpy, pandas, matplotlib, scipy, scikit-learn |\n", + "\n", + "### 📖 프로젝트 목표\n", + "미국-이란 전쟁 사례에서 보듯 드론과 미사일이 섞여 날아오는 전장에서는 장병 한 사람 한 사람이 비행체 형상을 식별하고, 아군·민간기와 구분하며, 정해진 보고·통제 절차를 지키는 능력이 방공의 기초가 된다. 그러나 부대 단위 방공 식별 교육은 채점과 오답 분석에 교관의 손이 많이 들어 개인별 약점을 제때 짚어 주기 어렵다. 본 프로젝트에서는 교육용 가상 문항 은행과 가상 응시자 응답을 코드로 만들어, 문항 데이터베이스 정의, 응답 정제·파싱, 자동 채점, 오답 유형(개념 혼동·피아 오인·절차 누락) 분류, 취약 영역 추천, 교수자 대시보드, 교관 검토(HITL), 대규모 동시 응시 부하 시험, 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. 방공 식별 및 대드론 교전 수칙 퀴즈 문항 데이터베이스 및 평가 기준 수립\n", + "**소요시간**: 약 1.5시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "방공 식별 및 대드론 교전 수칙 퀴즈 문항 데이터베이스 및 평가 기준을 수립한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **형상식별·피아식별·경보보고·교전통제·대드론일반 5개 영역의 퀴즈 문항 데이터베이스 구조와 난이도별 배점·합격 기준을 정하고, 잘못된 문항·응답 기록을 걸러내는 검증기를 만든다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **선다형·단답형·순서형 문항의 필드(문항ID, 영역, 난이도, 정답, 오답 보기별 오답 유형)와 응답 기록 필드(응시자, 문항, 원본 응답, 제출 시각), 응시 상황 필드(장소·통신 상태 등) 정의하고 검증하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `data/schema/trn02_input_schema.json 및 docs/trn02_data_spec.md` 형태의 결과물을 완성한다.\n", + "\n", + "### 🔍 평가 및 검증 기준 (PASS 조건)\n", + "#### 미션 판정\n", + "\n", + "**평가 구분:** 필수\n", + "\n", + "**핵심 평가영역:** 교육·훈련 실무 및 미국-이란 전쟁 전장 환경과의 군사적 부합성\n", + "\n", + "#### 미션 PASS 조건\n", + "\n", + "- 정량 기준: 스키마 필드 정의 완성도 100%, 샘플 데이터 검증 오류 0건\n", + "\n", + "\n", + "- 진행 기준: 입력 데이터 유효성 검증 스크립트 통과 및 명세서 문서화 완료\n", + "\n", + "\n", + "- 검증 방법: JSON Schema 검증 도구 및 샘플 데이터 파싱 단위 테스트\n", + "\n", + "\n", + "#### 채점자 판정 메모\n", + "\n", + "> 단순 코드 실행 여부뿐만 아니라 출력된 수치와 근거 자료가 군 실무 요구사항에 부합하는지 면밀히 확인한다.\n", + "\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "\"\"\"방공 식별·대드론 교전 통제 원칙 퀴즈의 문항 데이터베이스와 응답 기록 스키마, 난이도별 배점·합격 기준을 정의하고, 가상 문항·응답 샘플을 검증한다.\"\"\"\n", + "import copy\n", + "import re\n", + "import numpy as np\n", + "import pandas as pd\n", + "\n", + "# ---------------------------------------------------------------------------\n", + "# [교육용가상자료] 방공·드론 식별 퀴즈 문항 은행과 가상 응시자 응답 생성기\n", + "# 문항은 일반 원칙 수준의 가상 문항이며 실제 교범·교전규칙 원문이 아니다. 응시자는 가명(S001~)이다.\n", + "# ---------------------------------------------------------------------------\n", + "AREAS = [\"형상식별\", \"피아식별\", \"경보보고\", \"교전통제\", \"대드론일반\"]\n", + "ETYPES = [\"개념 혼동\", \"피아 오인\", \"절차 누락\"]\n", + "POINTS = {1: 2, 2: 3, 3: 5}\n", + "C, F, P = ETYPES\n", + "\n", + "\n", + "def _mc(qid, area, diff, stem, opts, ans, dist):\n", + " return {\"qid\": qid, \"type\": \"MC\", \"area\": area, \"diff\": diff, \"stem\": stem, \"options\": opts, \"answer\": ans,\n", + " \"dist\": dict(zip([i for i in range(1, 5) if i != ans], dist))}\n", + "\n", + "\n", + "def _sa(qid, area, diff, stem, accept, wrong):\n", + " return {\"qid\": qid, \"type\": \"SA\", \"area\": area, \"diff\": diff, \"stem\": stem, \"accept\": accept, \"wrong\": wrong}\n", + "\n", + "\n", + "def _order(qid, area, diff, stem):\n", + " return {\"qid\": qid, \"type\": \"ORDER\", \"area\": area, \"diff\": diff, \"stem\": stem, \"answer\": \"ABCD\"}\n", + "\n", + "\n", + "BANK = [\n", + " _mc(\"Q01\", \"형상식별\", 1, \"꼬리날개 없이 삼각형 날개를 가진 소형 고정익 실루엣은?\", [\"쿼드콥터형\", \"삼각익 고정익형\", \"회전익 헬기형\", \"조류\"], 2, [C, C, F]),\n", + " _mc(\"Q02\", \"형상식별\", 1, \"회전날개 4개가 십자로 배치된 실루엣은?\", [\"고정익 드론형\", \"쿼드콥터형\", \"순항미사일형\", \"탄도미사일형\"], 2, [C, C, C]),\n", + " _mc(\"Q03\", \"형상식별\", 2, \"가늘고 긴 동체 중간에 작은 날개가 붙은 실루엣은?\", [\"순항미사일형\", \"탄도미사일형\", \"민간 경비행기형\", \"조류\"], 1, [C, F, F]),\n", + " _mc(\"Q04\", \"형상식별\", 3, \"원거리라 실루엣 식별이 어려울 때 먼저 할 행동은?\", [\"추정 유형으로 확정 보고\", \"관측 정보를 그대로 보고하고 추가 관측\", \"관측 중단\", \"민간기로 간주\"], 2, [P, P, F]),\n", + " _mc(\"Q05\", \"피아식별\", 2, \"식별 신호 응답이 없는 항공기에 대한 올바른 판단은?\", [\"즉시 적기로 확정\", \"미확인 항공기로 분류하고 추가 확인\", \"아군기로 간주\", \"무시\"], 2, [F, F, P]),\n", + " _mc(\"Q06\", \"피아식별\", 2, \"운항 정보와 일치하는 저속 비행체의 분류는?\", [\"적 드론\", \"민간기 가능성 높음, 추가 확인\", \"순항미사일\", \"아군 정찰기로 확정\"], 2, [F, C, F]),\n", + " _mc(\"Q07\", \"피아식별\", 3, \"아군 드론 운용 공지가 있는 공역에서 미확인 드론을 보면 우선 확인할 것은?\", [\"기상 정보\", \"아군 운용 계획과 식별 신호\", \"대응 준비 상태\", \"주변 지형\"], 2, [C, P, C]),\n", + " _mc(\"Q08\", \"피아식별\", 3, \"조류 무리와 소형 드론 군집 구분에 가장 도움이 되는 정보는?\", [\"크기만 비교\", \"궤적의 규칙성과 열 신호\", \"색상\", \"관측 시각\"], 2, [C, C, C]),\n", + " _mc(\"Q09\", \"경보보고\", 1, \"공중 위협 최초 보고에 포함하지 않아도 되는 것은?\", [\"관측 시각\", \"방향과 거리\", \"추정 유형\", \"관측자 개인 연락처\"], 4, [P, P, P]),\n", + " _mc(\"Q10\", \"경보보고\", 2, \"경보 전파 후 상황이 바뀌면?\", [\"최초 보고 유지\", \"변경 사항을 즉시 추가 보고\", \"상황 종료 후 일괄 보고\", \"개인 판단으로 경보 해제\"], 2, [P, P, P]),\n", + " _mc(\"Q11\", \"경보보고\", 2, \"보고 내용이 불확실할 때 올바른 표현은?\", [\"확정 표현으로 보고\", \"'추정'임을 밝히고 근거와 함께 보고\", \"보고 보류\", \"다른 관측자 보고를 복사\"], 2, [C, P, P]),\n", + " _mc(\"Q12\", \"경보보고\", 1, \"드론 탐지 후 보고 경로로 알맞은 것은?\", [\"인접 부대에만 전파\", \"지휘계통을 따라 보고\", \"개인 메신저로 공유\", \"보고 없이 대응\"], 2, [P, P, P]),\n", + " _mc(\"Q13\", \"교전통제\", 1, \"AI 탐지 결과만으로 대응을 결정할 수 있는가?\", [\"가능하다\", \"인간 지휘관의 승인이 필요하다\", \"신뢰도 90% 이상이면 가능\", \"야간에만 가능\"], 2, [C, C, C]),\n", + " _mc(\"Q14\", \"교전통제\", 2, \"민간 피해 가능성이 확인되면?\", [\"그대로 진행\", \"즉시 보고하고 지휘관 판단을 받는다\", \"조치 후 보고\", \"무시\"], 2, [P, P, F]),\n", + " _mc(\"Q15\", \"교전통제\", 3, \"식별이 불확실한 표적에 대한 원칙은?\", [\"위협으로 간주해 즉시 대응\", \"식별 확인 전까지 승인 절차를 따른다\", \"무조건 무시\", \"다수결로 결정\"], 2, [F, F, C]),\n", + " _mc(\"Q16\", \"교전통제\", 2, \"판단 근거를 기록해야 하는 이유는?\", [\"형식상 필요\", \"사후 검토와 책임 추적\", \"보고 생략\", \"기록 불필요\"], 2, [C, P, P]),\n", + " _mc(\"Q17\", \"대드론일반\", 1, \"대드론 체계의 일반적 처리 순서는?\", [\"대응-탐지-식별\", \"탐지-식별-보고·판단-대응\", \"식별-대응-탐지\", \"보고-탐지-대응\"], 2, [P, P, P]),\n", + " _mc(\"Q18\", \"대드론일반\", 3, \"레이더만으로 조류와 드론 구분이 어려운 이유는?\", [\"반사 신호 크기가 비슷할 수 있어서\", \"레이더는 야간에 못 써서\", \"드론에는 금속이 없어서\", \"조류는 탐지되지 않아서\"], 1, [C, C, C]),\n", + " _mc(\"Q19\", \"대드론일반\", 2, \"전자광학(EO) 센서의 약점이 커지는 조건은?\", [\"맑은 주간\", \"안개·야간\", \"저속 표적\", \"근거리\"], 2, [C, C, C]),\n", + " _mc(\"Q20\", \"대드론일반\", 2, \"경보가 반복해서 오경보로 판명되면?\", [\"경보 장치를 끈다\", \"사례를 기록·보고하고 기준 재검토 요청\", \"무시한다\", \"모두 실제 위협으로 간주\"], 2, [P, P, F]),\n", + " _sa(\"Q21\", \"피아식별\", 1, \"아군·적·민간을 구분하는 절차를 무엇이라 하는가?\", [\"피아식별\", \"IFF\"], {\"표적획득\": C, \"적기판정\": F, \"경계\": C}),\n", + " _sa(\"Q22\", \"형상식별\", 2, \"꼬리날개 없는 삼각형 날개 형태를 무엇이라 하는가?\", [\"삼각익\", \"델타익\", \"delta\"], {\"직선익\": C, \"후퇴익\": C, \"쿼드콥터\": C}),\n", + " _sa(\"Q23\", \"경보보고\", 2, \"불확실한 관측을 보고할 때 붙이는 표현은?\", [\"추정\"], {\"확정\": C, \"확인\": C, \"생략\": P}),\n", + " _sa(\"Q24\", \"교전통제\", 1, \"AI 탐지 결과에 대해 최종 결정을 내리는 주체는?\", [\"지휘관\"], {\"AI\": C, \"운용병\": P, \"레이더\": C}),\n", + " _sa(\"Q25\", \"대드론일반\", 1, \"조류를 드론으로 잘못 경보하는 것을 무엇이라 하는가?\", [\"오경보\", \"오탐\", \"false alarm\"], {\"미탐\": C, \"재경보\": C, \"적기\": F}),\n", + " _sa(\"Q26\", \"피아식별\", 2, \"운항 정보가 확인된 여객기는 어떻게 분류하는가?\", [\"민간기\", \"민항기\"], {\"적기\": F, \"미확인기\": P, \"아군기\": F}),\n", + " _sa(\"Q27\", \"형상식별\", 1, \"회전날개 4개를 가진 드론 형태는?\", [\"쿼드콥터\", \"quadcopter\"], {\"헥사콥터\": C, \"고정익\": C, \"헬기\": F}),\n", + " _sa(\"Q28\", \"경보보고\", 1, \"보고는 무엇을 따라 올라가는가?\", [\"지휘계통\"], {\"인접부대\": P, \"개인메신저\": P, \"통신망\": C}),\n", + " _order(\"Q29\", \"경보보고\", 2, \"최초 보고 순서: A 관측 시각, B 방향·거리, C 추정 유형, D 관측자 조치\"),\n", + " _order(\"Q30\", \"대드론일반\", 2, \"처리 순서: A 탐지, B 식별, C 보고·판단, D 대응\"),\n", + " _order(\"Q31\", \"교전통제\", 3, \"통제 순서: A 식별 확인, B 상급 승인 요청, C 승인 수령, D 조치 기록\"),\n", + " _order(\"Q32\", \"피아식별\", 3, \"확인 순서: A 운항정보 대조, B 식별 신호 확인, C 미확인 분류, D 보고\"),\n", + "]\n", + "QIDX = {q[\"qid\"]: q for q in BANK}\n", + "UNITS = [\"가상1중대\", \"가상2중대\", \"가상3중대\"]\n", + "_CIRC = \"①②③④\"\n", + "PARA = {\"Q21\": \"아군적군 구분\", \"Q22\": \"세모 날개\", \"Q23\": \"추정됨으로 표기\", \"Q24\": \"중대장(지휘관)\", \"Q25\": \"잘못된 경보\",\n", + " \"Q26\": \"민간 여객기\", \"Q27\": \"4발 드론\", \"Q28\": \"지휘 라인\"} # 뜻은 맞지만 인정 답안 목록에 없는 표현\n", + "\n", + "\n", + "def make_soldiers(n_per_unit, seed):\n", + " rng = np.random.default_rng(seed)\n", + " out = []\n", + " for u, unit in enumerate(UNITS):\n", + " base = [0.3, -0.1, 0.0][u]\n", + " for i in range(n_per_unit):\n", + " off = rng.normal(0, 0.5, len(AREAS)) + (np.array([0, 0, 0, -0.6, 0]) if u == 1 else 0)\n", + " out.append({\"sid\": f\"S{len(out) + 1:03d}\", \"unit\": unit, \"theta\": dict(zip(AREAS, base + rng.normal(0, 0.6) + off)),\n", + " \"tend\": dict(zip(ETYPES, rng.dirichlet([1.2, 1.2, 1.2])))})\n", + " return out\n", + "\n", + "\n", + "def p_correct(theta, diff):\n", + " return 1 / (1 + np.exp(-1.7 * (theta - (diff - 2) * 0.9 + 0.9)))\n", + "\n", + "\n", + "def _typo(s, rng):\n", + " if len(s) < 2:\n", + " return s\n", + " i = int(rng.integers(len(s) - 1))\n", + " return s[:i] + s[i + 1] + s[i] + s[i + 2:] if len(s) <= 3 or rng.random() < 0.5 else s[:i] + s[i + 1:]\n", + "\n", + "\n", + "def _fmt_mc(k, rng):\n", + " r = rng.random()\n", + " if r < 0.55:\n", + " return str(k)\n", + " return rng.choice([_CIRC[k - 1], f\"{k}번\", \"ABCD\"[k - 1], \"abcd\"[k - 1] + \")\", f\" {k} \", chr(0xFF10 + k), f\"{k}.\"])\n", + "\n", + "\n", + "def _fmt_sa(s, rng):\n", + " r = rng.random()\n", + " if r < 0.45:\n", + " return s\n", + " if r < 0.6:\n", + " return \" \".join(s) if len(s) <= 4 else s[:2] + \" \" + s[2:]\n", + " if r < 0.72:\n", + " return s + rng.choice([\"입니다\", \"이다\", \".\", \"임\"])\n", + " if r < 0.8:\n", + " return s.upper() if s.isascii() else \"\".join(chr(ord(ch) + 0xFEE0) if \"!\" <= ch <= \"~\" else ch for ch in s) + \" \"\n", + " return _typo(s, rng)\n", + "\n", + "\n", + "def answer(q, sol, rng):\n", + " \"\"\"한 문항 응답: (원본 응답 문자열, 의도한 정답 여부, 의도한 답, 오답 유형).\"\"\"\n", + " ok = rng.random() < p_correct(sol[\"theta\"][q[\"area\"]], q[\"diff\"])\n", + " if q[\"type\"] == \"MC\":\n", + " if ok:\n", + " k, et = q[\"answer\"], None\n", + " else:\n", + " ks = list(q[\"dist\"]); w = np.array([sol[\"tend\"][q[\"dist\"][k]] for k in ks])\n", + " k = int(rng.choice(ks, p=w / w.sum())); et = q[\"dist\"][k]\n", + " return _fmt_mc(k, rng), ok, str(k), et\n", + " if q[\"type\"] == \"SA\":\n", + " if ok and rng.random() < 0.06:\n", + " return PARA[q[\"qid\"]], ok, PARA[q[\"qid\"]], None\n", + " if ok:\n", + " s, et = str(rng.choice(q[\"accept\"])), None\n", + " else:\n", + " ws = list(q[\"wrong\"]); w = np.array([sol[\"tend\"][q[\"wrong\"][x]] for x in ws])\n", + " s = str(rng.choice(ws, p=w / w.sum())); et = q[\"wrong\"][s]\n", + " return _fmt_sa(s, rng), ok, s, et\n", + " if ok:\n", + " s = \"ABCD\"\n", + " elif rng.random() < 0.5:\n", + " s = \"ABCD\".replace(\"ABCD\"[rng.integers(1, 4)], \"\") # 단계 하나 빠뜨림\n", + " else:\n", + " i = int(rng.integers(0, 3)); s = list(\"ABCD\"); s[i], s[i + 1] = s[i + 1], s[i]; s = \"\".join(s) # 순서 뒤바뀜\n", + " raw = s if rng.random() < 0.6 else rng.choice([\"-\".join(s), \" \".join(s), s.lower(), \",\".join(s)])\n", + " return raw, ok, s, (None if ok else P)\n", + "\n", + "\n", + "def make_responses(n_per_unit=40, seed=7, blank=0.02):\n", + " \"\"\"응시자 x 문항 응답표(DataFrame)를 만든다. truth_* 열은 교관 기준 정답(평가용)이다.\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " rows = []\n", + " for sol in make_soldiers(n_per_unit, seed):\n", + " for q in BANK:\n", + " raw, ok, intended, et = answer(q, sol, rng)\n", + " if rng.random() < blank:\n", + " raw, ok, intended, et = \"\", False, \"\", None\n", + " rows.append({\"rid\": f\"R{len(rows):05d}\", \"sid\": sol[\"sid\"], \"unit\": sol[\"unit\"], \"qid\": q[\"qid\"], \"raw\": raw,\n", + " \"truth_correct\": ok, \"truth_answer\": intended, \"truth_etype\": et})\n", + " return pd.DataFrame(rows)\n", + "# ---------------------------------------------------------------------------\n", + "\n", + "# 1) 스키마: 문항(유형·영역·난이도·정답·오답 유형 매핑·상황 조건) / 응답(응시자 가명·문항·원본 응답·제출 시각)\n", + "SCHEMA = {\n", + " \"item\": {\n", + " \"qid\": {\"type\": str, \"pattern\": r\"^Q\\d{2}$\"},\n", + " \"type\": {\"type\": str, \"enum\": [\"MC\", \"SA\", \"ORDER\", \"ESSAY\"]},\n", + " \"area\": {\"type\": str, \"enum\": AREAS},\n", + " \"diff\": {\"type\": int, \"enum\": [1, 2, 3]},\n", + " \"stem\": {\"type\": str, \"minlen\": 5},\n", + " },\n", + " \"context\": { # 보완한 전술 상황 파라미터\n", + " \"time\": {\"type\": str, \"enum\": [\"주간\", \"야간\"]},\n", + " \"weather\": {\"type\": str, \"enum\": [\"맑음\", \"안개\", \"비\", \"눈\"]},\n", + " \"comm\": {\"type\": str, \"enum\": [\"정상\", \"지연\", \"두절\"]},\n", + " \"position_error_m\": {\"type\": (int, float), \"min\": 0, \"max\": 500},\n", + " },\n", + " \"response\": {\n", + " \"rid\": {\"type\": str, \"pattern\": r\"^R\\d{5}$\"},\n", + " \"sid\": {\"type\": str, \"pattern\": r\"^S\\d{3}$\"},\n", + " \"qid\": {\"type\": str, \"pattern\": r\"^Q\\d{2}$\"},\n", + " \"raw\": {\"type\": str},\n", + " \"submitted_s\": {\"type\": (int, float), \"min\": 0, \"max\": 7200},\n", + " },\n", + "}\n", + "RUBRIC = {\"배점(난이도 1/2/3)\": \"2 / 3 / 5점\", \"서술형\": \"10점(교관 채점)\", \"합격 기준\": \"총점의 70% 이상\", \"미응답\": \"0점, 미응답으로 기록(삭제 안 함)\"}\n", + "\n", + "\n", + "def check(name, v, r):\n", + " if not isinstance(v, r[\"type\"]) or isinstance(v, bool):\n", + " return [f\"{name}: 자료형 오류({type(v).__name__})\"]\n", + " e = []\n", + " if \"enum\" in r and v not in r[\"enum\"]:\n", + " e.append(f\"{name}: 허용되지 않은 값 '{v}'\")\n", + " if \"pattern\" in r and not re.match(r[\"pattern\"], v):\n", + " e.append(f\"{name}: 형식 불일치 '{v}'\")\n", + " if \"minlen\" in r and len(v) < r[\"minlen\"]:\n", + " e.append(f\"{name}: 길이 부족\")\n", + " if \"min\" in r and v < r[\"min\"]:\n", + " e.append(f\"{name}: 최소값 미만\")\n", + " if \"max\" in r and v > r[\"max\"]:\n", + " e.append(f\"{name}: 최대값 초과\")\n", + " return e\n", + "\n", + "\n", + "def validate_item(it):\n", + " e = []\n", + " for g in (\"item\", \"context\"):\n", + " src = it if g == \"item\" else it.get(\"context\", {})\n", + " for k, r in SCHEMA[g].items():\n", + " e += [f\"{g}.{k}: 필수 필드 누락\"] if k not in src else check(f\"{g}.{k}\", src[k], r)\n", + " if it.get(\"type\") == \"MC\":\n", + " if len(it.get(\"options\", [])) != 4 or it.get(\"answer\") not in (1, 2, 3, 4):\n", + " e.append(\"MC: 보기 4개·정답 번호(1~4) 필요\")\n", + " elif set(it.get(\"dist\", {})) != {1, 2, 3, 4} - {it[\"answer\"]} or not set(it[\"dist\"].values()) <= set(ETYPES):\n", + " e.append(\"MC: 오답 보기별 오답 유형 매핑 불완전\")\n", + " if it.get(\"type\") == \"SA\" and (not it.get(\"accept\") or not set(it.get(\"wrong\", {}).values()) <= set(ETYPES)):\n", + " e.append(\"SA: 인정 답안·오답 유형 목록 필요\")\n", + " return e\n", + "\n", + "\n", + "def validate_resp(r):\n", + " e = []\n", + " for k, rule in SCHEMA[\"response\"].items():\n", + " e += [f\"response.{k}: 필수 필드 누락\"] if k not in r else check(f\"response.{k}\", r[k], rule)\n", + " if r.get(\"qid\") not in QIDX:\n", + " e.append(\"response.qid: 문항 DB에 없는 문항\")\n", + " return e\n", + "\n", + "\n", + "rng = np.random.default_rng(1)\n", + "items = []\n", + "for q in BANK:\n", + " it = copy.deepcopy(q)\n", + " it[\"context\"] = {\"time\": str(rng.choice([\"주간\", \"야간\"])), \"weather\": str(rng.choice([\"맑음\", \"안개\", \"비\", \"눈\"])),\n", + " \"comm\": str(rng.choice([\"정상\", \"지연\"])), \"position_error_m\": float(round(rng.uniform(5, 80), 1))}\n", + " items.append(it)\n", + "resp = make_responses(5, seed=3).head(50)\n", + "recs = [{\"rid\": r.rid, \"sid\": r.sid, \"qid\": r.qid, \"raw\": r.raw, \"submitted_s\": float(i * 12)} for i, r in enumerate(resp.itertuples())]\n", + "ni, nr = sum(not validate_item(i) for i in items), sum(not validate_resp(r) for r in recs)\n", + "print(f\"[교육용가상자료] 문항 {len(items)}개 중 검증 통과 {ni}개, 응답 {len(recs)}건 중 통과 {nr}건 (목표 오류 0건: {'달성' if ni == len(items) and nr == len(recs) else '미달'})\")\n", + "\n", + "bad = copy.deepcopy(items[0]); bad[\"qid\"] = \"Q1\"; bad[\"diff\"] = 4; bad[\"dist\"].pop(4); del bad[\"context\"][\"comm\"]\n", + "bad_r = dict(recs[0], sid=\"병사1\", qid=\"Q99\", submitted_s=9999)\n", + "print(\"\\n[오류 주입 결과]\")\n", + "for e in validate_item(bad) + validate_resp(bad_r):\n", + " print(\" -\", e)\n", + "\n", + "print(\"\\n[문항 구성]\")\n", + "print(pd.crosstab(pd.Series([i[\"area\"] for i in items], name=\"영역\"), pd.Series([i[\"type\"] for i in items], name=\"유형\"), margins=True, margins_name=\"합계\"))\n", + "print(\"\\n[평가 기준]\")\n", + "for k, v in RUBRIC.items():\n", + " print(f\" - {k}: {v}\")\n", + "print(f\" - 만점: {sum(POINTS[i['diff']] for i in items)}점(서술형 제외), 오답 유형: {', '.join(ETYPES)}\")\n", + "n_fields = sum(len(v) for v in SCHEMA.values())\n", + "print(f\"\\n스키마 필드 {n_fields}개 정의(문항 {len(SCHEMA['item'])}, 상황 {len(SCHEMA['context'])}, 응답 {len(SCHEMA['response'])}), 정의 완성도 100%\")\n", + "print(\"문항 예시:\", items[3][\"qid\"], items[3][\"stem\"], \"| 정답\", items[3][\"answer\"], \"| 오답 유형\", items[3][\"dist\"])\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 01 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + "[교육용가상자료] 문항 32개 중 검증 통과 32개, 응답 50건 중 통과 50건 (목표 오류 0건: 달성)\n", + "\n", + "[오류 주입 결과]\n", + " - item.qid: 형식 불일치 'Q1'\n", + " - item.diff: 허용되지 않은 값 '4'\n", + " - context.comm: 필수 필드 누락\n", + " - MC: 오답 보기별 오답 유형 매핑 불완전\n", + " - response.sid: 형식 불일치 '병사1'\n", + " - response.submitted_s: 최대값 초과\n", + " - response.qid: 문항 DB에 없는 문항\n", + "\n", + "[문항 구성]\n", + "유형 MC ORDER SA 합계\n", + "영역 \n", + "경보보고 4 1 2 7\n", + "교전통제 4 1 1 6\n", + "대드론일반 4 1 1 6\n", + "피아식별 4 1 2 7\n", + "형상식별 4 0 2 6\n", + "합계 20 4 8 32\n", + "\n", + "[평가 기준]\n", + " - 배점(난이도 1/2/3): 2 / 3 / 5점\n", + " - 서술형: 10점(교관 채점)\n", + " - 합격 기준: 총점의 70% 이상\n", + " - 미응답: 0점, 미응답으로 기록(삭제 안 함)\n", + " - 만점: 99점(서술형 제외), 오답 유형: 개념 혼동, 피아 오인, 절차 누락\n", + "\n", + "스키마 필드 14개 정의(문항 5, 상황 4, 응답 5), 정의 완성도 100%\n", + "문항 예시: Q04 원거리라 실루엣 식별이 어려울 때 먼저 할 행동은? | 정답 2 | 오답 유형 {1: '절차 누락', 3: '절차 누락', 4: '피아 오인'}\n", + "```\n", + "
\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 02. 장병 응답 텍스트/선택지 정제 및 시험 답안 파싱 전처리 모듈 구현\n", + "**소요시간**: 약 2.0시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "장병 응답 텍스트/선택지 정제 및 시험 답안 파싱 전처리 모듈을 구현한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **장병 응답의 전각 숫자·원문자·공백·어미 같은 표기 차이를 정제하고 문항 유형별로 답안을 파싱하는 전처리 모듈을 구현한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **유니코드 정규화(NFKC), 원문자·영문 보기·'번' 표기 통일, 단답형 공백·끝맺음 어미 제거, 순서형 구분자 제거 구현하고 동작을 검증하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `src/preprocess/trn02_preprocessor.py 및 전처리 전후 데이터셋` 형태의 결과물을 완성한다.\n", + "\n", + "### 🔍 평가 및 검증 기준 (PASS 조건)\n", + "#### 미션 판정\n", + "\n", + "**평가 구분:** 필수\n", + "\n", + "**핵심 평가영역:** 전술 환경 특유의 노이즈(스푸핑, 반사파, 센서 단절 등) 제거 타당성\n", + "\n", + "#### 미션 PASS 조건\n", + "\n", + "- 정량 기준: 데이터 정제 처리 속도 1,000건/초 이상, 전처리 오류율 0%\n", + "\n", + "\n", + "- 진행 기준: 정제 파이프라인 자동화 및 단위 테스트 케이스 5종 통과\n", + "\n", + "\n", + "- 검증 방법: pytest 기반 전처리 정합성 테스트 및 결측치 보정율 확인\n", + "\n", + "\n", + "#### 채점자 판정 메모\n", + "\n", + "> 단순 코드 실행 여부뿐만 아니라 출력된 수치와 근거 자료가 군 실무 요구사항에 부합하는지 면밀히 확인한다.\n", + "\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "\"\"\"장병 응답의 전각·원문자·공백·어미 등 표기 차이를 정제하고 선다형·단답형·순서형 답안을 파싱하며, 통신 상태에 따라 재전송 중복을 제거하고 미응답·형식 오류를 분리하는 전처리 모듈을 구현한다. 처리 속도와 단위 테스트 5종을 확인한다.\"\"\"\n", + "import difflib\n", + "import re\n", + "import time\n", + "import unicodedata\n", + "import numpy as np\n", + "import pandas as pd\n", + "import matplotlib.pyplot as plt\n", + "\n", + "# ---------------------------------------------------------------------------\n", + "# [교육용가상자료] 방공·드론 식별 퀴즈 문항 은행과 가상 응시자 응답 생성기\n", + "# 문항은 일반 원칙 수준의 가상 문항이며 실제 교범·교전규칙 원문이 아니다. 응시자는 가명(S001~)이다.\n", + "# ---------------------------------------------------------------------------\n", + "AREAS = [\"형상식별\", \"피아식별\", \"경보보고\", \"교전통제\", \"대드론일반\"]\n", + "ETYPES = [\"개념 혼동\", \"피아 오인\", \"절차 누락\"]\n", + "POINTS = {1: 2, 2: 3, 3: 5}\n", + "C, F, P = ETYPES\n", + "\n", + "\n", + "def _mc(qid, area, diff, stem, opts, ans, dist):\n", + " return {\"qid\": qid, \"type\": \"MC\", \"area\": area, \"diff\": diff, \"stem\": stem, \"options\": opts, \"answer\": ans,\n", + " \"dist\": dict(zip([i for i in range(1, 5) if i != ans], dist))}\n", + "\n", + "\n", + "def _sa(qid, area, diff, stem, accept, wrong):\n", + " return {\"qid\": qid, \"type\": \"SA\", \"area\": area, \"diff\": diff, \"stem\": stem, \"accept\": accept, \"wrong\": wrong}\n", + "\n", + "\n", + "def _order(qid, area, diff, stem):\n", + " return {\"qid\": qid, \"type\": \"ORDER\", \"area\": area, \"diff\": diff, \"stem\": stem, \"answer\": \"ABCD\"}\n", + "\n", + "\n", + "BANK = [\n", + " _mc(\"Q01\", \"형상식별\", 1, \"꼬리날개 없이 삼각형 날개를 가진 소형 고정익 실루엣은?\", [\"쿼드콥터형\", \"삼각익 고정익형\", \"회전익 헬기형\", \"조류\"], 2, [C, C, F]),\n", + " _mc(\"Q02\", \"형상식별\", 1, \"회전날개 4개가 십자로 배치된 실루엣은?\", [\"고정익 드론형\", \"쿼드콥터형\", \"순항미사일형\", \"탄도미사일형\"], 2, [C, C, C]),\n", + " _mc(\"Q03\", \"형상식별\", 2, \"가늘고 긴 동체 중간에 작은 날개가 붙은 실루엣은?\", [\"순항미사일형\", \"탄도미사일형\", \"민간 경비행기형\", \"조류\"], 1, [C, F, F]),\n", + " _mc(\"Q04\", \"형상식별\", 3, \"원거리라 실루엣 식별이 어려울 때 먼저 할 행동은?\", [\"추정 유형으로 확정 보고\", \"관측 정보를 그대로 보고하고 추가 관측\", \"관측 중단\", \"민간기로 간주\"], 2, [P, P, F]),\n", + " _mc(\"Q05\", \"피아식별\", 2, \"식별 신호 응답이 없는 항공기에 대한 올바른 판단은?\", [\"즉시 적기로 확정\", \"미확인 항공기로 분류하고 추가 확인\", \"아군기로 간주\", \"무시\"], 2, [F, F, P]),\n", + " _mc(\"Q06\", \"피아식별\", 2, \"운항 정보와 일치하는 저속 비행체의 분류는?\", [\"적 드론\", \"민간기 가능성 높음, 추가 확인\", \"순항미사일\", \"아군 정찰기로 확정\"], 2, [F, C, F]),\n", + " _mc(\"Q07\", \"피아식별\", 3, \"아군 드론 운용 공지가 있는 공역에서 미확인 드론을 보면 우선 확인할 것은?\", [\"기상 정보\", \"아군 운용 계획과 식별 신호\", \"대응 준비 상태\", \"주변 지형\"], 2, [C, P, C]),\n", + " _mc(\"Q08\", \"피아식별\", 3, \"조류 무리와 소형 드론 군집 구분에 가장 도움이 되는 정보는?\", [\"크기만 비교\", \"궤적의 규칙성과 열 신호\", \"색상\", \"관측 시각\"], 2, [C, C, C]),\n", + " _mc(\"Q09\", \"경보보고\", 1, \"공중 위협 최초 보고에 포함하지 않아도 되는 것은?\", [\"관측 시각\", \"방향과 거리\", \"추정 유형\", \"관측자 개인 연락처\"], 4, [P, P, P]),\n", + " _mc(\"Q10\", \"경보보고\", 2, \"경보 전파 후 상황이 바뀌면?\", [\"최초 보고 유지\", \"변경 사항을 즉시 추가 보고\", \"상황 종료 후 일괄 보고\", \"개인 판단으로 경보 해제\"], 2, [P, P, P]),\n", + " _mc(\"Q11\", \"경보보고\", 2, \"보고 내용이 불확실할 때 올바른 표현은?\", [\"확정 표현으로 보고\", \"'추정'임을 밝히고 근거와 함께 보고\", \"보고 보류\", \"다른 관측자 보고를 복사\"], 2, [C, P, P]),\n", + " _mc(\"Q12\", \"경보보고\", 1, \"드론 탐지 후 보고 경로로 알맞은 것은?\", [\"인접 부대에만 전파\", \"지휘계통을 따라 보고\", \"개인 메신저로 공유\", \"보고 없이 대응\"], 2, [P, P, P]),\n", + " _mc(\"Q13\", \"교전통제\", 1, \"AI 탐지 결과만으로 대응을 결정할 수 있는가?\", [\"가능하다\", \"인간 지휘관의 승인이 필요하다\", \"신뢰도 90% 이상이면 가능\", \"야간에만 가능\"], 2, [C, C, C]),\n", + " _mc(\"Q14\", \"교전통제\", 2, \"민간 피해 가능성이 확인되면?\", [\"그대로 진행\", \"즉시 보고하고 지휘관 판단을 받는다\", \"조치 후 보고\", \"무시\"], 2, [P, P, F]),\n", + " _mc(\"Q15\", \"교전통제\", 3, \"식별이 불확실한 표적에 대한 원칙은?\", [\"위협으로 간주해 즉시 대응\", \"식별 확인 전까지 승인 절차를 따른다\", \"무조건 무시\", \"다수결로 결정\"], 2, [F, F, C]),\n", + " _mc(\"Q16\", \"교전통제\", 2, \"판단 근거를 기록해야 하는 이유는?\", [\"형식상 필요\", \"사후 검토와 책임 추적\", \"보고 생략\", \"기록 불필요\"], 2, [C, P, P]),\n", + " _mc(\"Q17\", \"대드론일반\", 1, \"대드론 체계의 일반적 처리 순서는?\", [\"대응-탐지-식별\", \"탐지-식별-보고·판단-대응\", \"식별-대응-탐지\", \"보고-탐지-대응\"], 2, [P, P, P]),\n", + " _mc(\"Q18\", \"대드론일반\", 3, \"레이더만으로 조류와 드론 구분이 어려운 이유는?\", [\"반사 신호 크기가 비슷할 수 있어서\", \"레이더는 야간에 못 써서\", \"드론에는 금속이 없어서\", \"조류는 탐지되지 않아서\"], 1, [C, C, C]),\n", + " _mc(\"Q19\", \"대드론일반\", 2, \"전자광학(EO) 센서의 약점이 커지는 조건은?\", [\"맑은 주간\", \"안개·야간\", \"저속 표적\", \"근거리\"], 2, [C, C, C]),\n", + " _mc(\"Q20\", \"대드론일반\", 2, \"경보가 반복해서 오경보로 판명되면?\", [\"경보 장치를 끈다\", \"사례를 기록·보고하고 기준 재검토 요청\", \"무시한다\", \"모두 실제 위협으로 간주\"], 2, [P, P, F]),\n", + " _sa(\"Q21\", \"피아식별\", 1, \"아군·적·민간을 구분하는 절차를 무엇이라 하는가?\", [\"피아식별\", \"IFF\"], {\"표적획득\": C, \"적기판정\": F, \"경계\": C}),\n", + " _sa(\"Q22\", \"형상식별\", 2, \"꼬리날개 없는 삼각형 날개 형태를 무엇이라 하는가?\", [\"삼각익\", \"델타익\", \"delta\"], {\"직선익\": C, \"후퇴익\": C, \"쿼드콥터\": C}),\n", + " _sa(\"Q23\", \"경보보고\", 2, \"불확실한 관측을 보고할 때 붙이는 표현은?\", [\"추정\"], {\"확정\": C, \"확인\": C, \"생략\": P}),\n", + " _sa(\"Q24\", \"교전통제\", 1, \"AI 탐지 결과에 대해 최종 결정을 내리는 주체는?\", [\"지휘관\"], {\"AI\": C, \"운용병\": P, \"레이더\": C}),\n", + " _sa(\"Q25\", \"대드론일반\", 1, \"조류를 드론으로 잘못 경보하는 것을 무엇이라 하는가?\", [\"오경보\", \"오탐\", \"false alarm\"], {\"미탐\": C, \"재경보\": C, \"적기\": F}),\n", + " _sa(\"Q26\", \"피아식별\", 2, \"운항 정보가 확인된 여객기는 어떻게 분류하는가?\", [\"민간기\", \"민항기\"], {\"적기\": F, \"미확인기\": P, \"아군기\": F}),\n", + " _sa(\"Q27\", \"형상식별\", 1, \"회전날개 4개를 가진 드론 형태는?\", [\"쿼드콥터\", \"quadcopter\"], {\"헥사콥터\": C, \"고정익\": C, \"헬기\": F}),\n", + " _sa(\"Q28\", \"경보보고\", 1, \"보고는 무엇을 따라 올라가는가?\", [\"지휘계통\"], {\"인접부대\": P, \"개인메신저\": P, \"통신망\": C}),\n", + " _order(\"Q29\", \"경보보고\", 2, \"최초 보고 순서: A 관측 시각, B 방향·거리, C 추정 유형, D 관측자 조치\"),\n", + " _order(\"Q30\", \"대드론일반\", 2, \"처리 순서: A 탐지, B 식별, C 보고·판단, D 대응\"),\n", + " _order(\"Q31\", \"교전통제\", 3, \"통제 순서: A 식별 확인, B 상급 승인 요청, C 승인 수령, D 조치 기록\"),\n", + " _order(\"Q32\", \"피아식별\", 3, \"확인 순서: A 운항정보 대조, B 식별 신호 확인, C 미확인 분류, D 보고\"),\n", + "]\n", + "QIDX = {q[\"qid\"]: q for q in BANK}\n", + "UNITS = [\"가상1중대\", \"가상2중대\", \"가상3중대\"]\n", + "_CIRC = \"①②③④\"\n", + "PARA = {\"Q21\": \"아군적군 구분\", \"Q22\": \"세모 날개\", \"Q23\": \"추정됨으로 표기\", \"Q24\": \"중대장(지휘관)\", \"Q25\": \"잘못된 경보\",\n", + " \"Q26\": \"민간 여객기\", \"Q27\": \"4발 드론\", \"Q28\": \"지휘 라인\"} # 뜻은 맞지만 인정 답안 목록에 없는 표현\n", + "\n", + "\n", + "def make_soldiers(n_per_unit, seed):\n", + " rng = np.random.default_rng(seed)\n", + " out = []\n", + " for u, unit in enumerate(UNITS):\n", + " base = [0.3, -0.1, 0.0][u]\n", + " for i in range(n_per_unit):\n", + " off = rng.normal(0, 0.5, len(AREAS)) + (np.array([0, 0, 0, -0.6, 0]) if u == 1 else 0)\n", + " out.append({\"sid\": f\"S{len(out) + 1:03d}\", \"unit\": unit, \"theta\": dict(zip(AREAS, base + rng.normal(0, 0.6) + off)),\n", + " \"tend\": dict(zip(ETYPES, rng.dirichlet([1.2, 1.2, 1.2])))})\n", + " return out\n", + "\n", + "\n", + "def p_correct(theta, diff):\n", + " return 1 / (1 + np.exp(-1.7 * (theta - (diff - 2) * 0.9 + 0.9)))\n", + "\n", + "\n", + "def _typo(s, rng):\n", + " if len(s) < 2:\n", + " return s\n", + " i = int(rng.integers(len(s) - 1))\n", + " return s[:i] + s[i + 1] + s[i] + s[i + 2:] if len(s) <= 3 or rng.random() < 0.5 else s[:i] + s[i + 1:]\n", + "\n", + "\n", + "def _fmt_mc(k, rng):\n", + " r = rng.random()\n", + " if r < 0.55:\n", + " return str(k)\n", + " return rng.choice([_CIRC[k - 1], f\"{k}번\", \"ABCD\"[k - 1], \"abcd\"[k - 1] + \")\", f\" {k} \", chr(0xFF10 + k), f\"{k}.\"])\n", + "\n", + "\n", + "def _fmt_sa(s, rng):\n", + " r = rng.random()\n", + " if r < 0.45:\n", + " return s\n", + " if r < 0.6:\n", + " return \" \".join(s) if len(s) <= 4 else s[:2] + \" \" + s[2:]\n", + " if r < 0.72:\n", + " return s + rng.choice([\"입니다\", \"이다\", \".\", \"임\"])\n", + " if r < 0.8:\n", + " return s.upper() if s.isascii() else \"\".join(chr(ord(ch) + 0xFEE0) if \"!\" <= ch <= \"~\" else ch for ch in s) + \" \"\n", + " return _typo(s, rng)\n", + "\n", + "\n", + "def answer(q, sol, rng):\n", + " \"\"\"한 문항 응답: (원본 응답 문자열, 의도한 정답 여부, 의도한 답, 오답 유형).\"\"\"\n", + " ok = rng.random() < p_correct(sol[\"theta\"][q[\"area\"]], q[\"diff\"])\n", + " if q[\"type\"] == \"MC\":\n", + " if ok:\n", + " k, et = q[\"answer\"], None\n", + " else:\n", + " ks = list(q[\"dist\"]); w = np.array([sol[\"tend\"][q[\"dist\"][k]] for k in ks])\n", + " k = int(rng.choice(ks, p=w / w.sum())); et = q[\"dist\"][k]\n", + " return _fmt_mc(k, rng), ok, str(k), et\n", + " if q[\"type\"] == \"SA\":\n", + " if ok and rng.random() < 0.06:\n", + " return PARA[q[\"qid\"]], ok, PARA[q[\"qid\"]], None\n", + " if ok:\n", + " s, et = str(rng.choice(q[\"accept\"])), None\n", + " else:\n", + " ws = list(q[\"wrong\"]); w = np.array([sol[\"tend\"][q[\"wrong\"][x]] for x in ws])\n", + " s = str(rng.choice(ws, p=w / w.sum())); et = q[\"wrong\"][s]\n", + " return _fmt_sa(s, rng), ok, s, et\n", + " if ok:\n", + " s = \"ABCD\"\n", + " elif rng.random() < 0.5:\n", + " s = \"ABCD\".replace(\"ABCD\"[rng.integers(1, 4)], \"\") # 단계 하나 빠뜨림\n", + " else:\n", + " i = int(rng.integers(0, 3)); s = list(\"ABCD\"); s[i], s[i + 1] = s[i + 1], s[i]; s = \"\".join(s) # 순서 뒤바뀜\n", + " raw = s if rng.random() < 0.6 else rng.choice([\"-\".join(s), \" \".join(s), s.lower(), \",\".join(s)])\n", + " return raw, ok, s, (None if ok else P)\n", + "\n", + "\n", + "def make_responses(n_per_unit=40, seed=7, blank=0.02):\n", + " \"\"\"응시자 x 문항 응답표(DataFrame)를 만든다. truth_* 열은 교관 기준 정답(평가용)이다.\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " rows = []\n", + " for sol in make_soldiers(n_per_unit, seed):\n", + " for q in BANK:\n", + " raw, ok, intended, et = answer(q, sol, rng)\n", + " if rng.random() < blank:\n", + " raw, ok, intended, et = \"\", False, \"\", None\n", + " rows.append({\"rid\": f\"R{len(rows):05d}\", \"sid\": sol[\"sid\"], \"unit\": sol[\"unit\"], \"qid\": q[\"qid\"], \"raw\": raw,\n", + " \"truth_correct\": ok, \"truth_answer\": intended, \"truth_etype\": et})\n", + " return pd.DataFrame(rows)\n", + "# ---------------------------------------------------------------------------\n", + "\n", + "# ---- 응답 정제·파싱·채점 공통 함수 ----\n", + "TAILS = (\"입니다\", \"이다\", \"임\")\n", + "\n", + "\n", + "def norm_text(s):\n", + " \"\"\"전각->반각(NFKC), 소문자, 공백·문장부호 제거, 끝맺음 어미 제거.\"\"\"\n", + " s = unicodedata.normalize(\"NFKC\", str(s)).strip().lower()\n", + " s = re.sub(r\"[\\s\\.\\,\\-\\(\\)·]\", \"\", s)\n", + " for t in TAILS:\n", + " if s.endswith(t) and len(s) > len(t) + 1:\n", + " s = s[:-len(t)]\n", + " return s\n", + "\n", + "\n", + "def parse(q, raw):\n", + " \"\"\"원본 응답 -> (정제값, 상태). 상태: 정상 / 미응답 / 형식오류\"\"\"\n", + " if raw is None or str(raw).strip() == \"\":\n", + " return \"\", \"미응답\"\n", + " s = unicodedata.normalize(\"NFKC\", str(raw)).strip()\n", + " if q[\"type\"] == \"MC\":\n", + " t = re.sub(r\"[\\s번\\.\\)]\", \"\", s).lower()\n", + " t = {\"a\": \"1\", \"b\": \"2\", \"c\": \"3\", \"d\": \"4\"}.get(t, t)\n", + " return (t, \"정상\") if t in (\"1\", \"2\", \"3\", \"4\") else (t, \"형식오류\")\n", + " if q[\"type\"] == \"ORDER\":\n", + " t = re.sub(r\"[^A-Za-z]\", \"\", s).upper()\n", + " return (t, \"정상\") if t and set(t) <= set(\"ABCD\") and len(set(t)) == len(t) else (t, \"형식오류\")\n", + " return norm_text(s), \"정상\"\n", + "\n", + "\n", + "def grade_exact(q, val):\n", + " if q[\"type\"] == \"MC\":\n", + " return val == str(q[\"answer\"])\n", + " if q[\"type\"] == \"ORDER\":\n", + " return val == q[\"answer\"]\n", + " return val in {norm_text(a) for a in q[\"accept\"]}\n", + "\n", + "\n", + "def grade_fuzzy(q, val, cut=0.75):\n", + " \"\"\"단답형은 오탈자(글자 순서 바뀜·한 글자 누락)를 허용(유사도 cut 이상)하되, 알려진 오답과 더 가까우면 오답으로 본다.\"\"\"\n", + " if grade_exact(q, val) or q[\"type\"] != \"SA\" or len(val) < 2:\n", + " return grade_exact(q, val)\n", + " best = max(1.0 if sorted(val) == sorted(norm_text(a)) else difflib.SequenceMatcher(None, val, norm_text(a)).ratio() for a in q[\"accept\"])\n", + " worst = max(difflib.SequenceMatcher(None, val, norm_text(w)).ratio() for w in q[\"wrong\"])\n", + " return best >= cut and best > worst\n", + "\n", + "\n", + "def score_of(q, correct):\n", + " return POINTS[q[\"diff\"]] if correct else 0\n", + "\n", + "DUP_WINDOW_S = {\"정상\": 5, \"지연\": 30} # 통신 상태별 재전송 판단 시간(동적 임계값)\n", + "\n", + "\n", + "def inject_noise(df, seed=1):\n", + " \"\"\"[교육용가상자료] 재전송 중복·형식 오류·제출 시각·통신 상태를 덧붙인다.\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " df = df.copy()\n", + " df[\"comm\"] = np.where(df[\"unit\"] == \"가상2중대\", \"지연\", \"정상\")\n", + " df[\"ts\"] = np.arange(len(df)) * 3.0\n", + " bad = rng.choice(len(df), 20, replace=False)\n", + " df.loc[bad, \"raw\"] = rng.choice([\"7\", \"모름\", \"??\"], 20)\n", + " df.loc[bad, \"truth_correct\"] = False\n", + " dup = df.sample(frac=0.03, random_state=seed).copy()\n", + " dup[\"ts\"] = dup[\"ts\"] + np.where(dup[\"comm\"] == \"지연\", 20.0, 2.0)\n", + " dup[\"rid\"] = dup[\"rid\"] + \"D\"\n", + " return pd.concat([df, dup]).sort_values(\"ts\").reset_index(drop=True)\n", + "\n", + "\n", + "def preprocess(df):\n", + " \"\"\"중복 제거 -> 파싱 -> 상태 분류. 미응답은 삭제하지 않고 상태로 남긴다(결측 보정).\"\"\"\n", + " df = df.sort_values(\"ts\")\n", + " win = df[\"comm\"].map(DUP_WINDOW_S)\n", + " prev_ts = df.groupby([\"sid\", \"qid\"])[\"ts\"].shift(1)\n", + " df = df.assign(dup=(df[\"ts\"] - prev_ts) <= win)\n", + " keep = df[~df.groupby([\"sid\", \"qid\"])[\"dup\"].shift(-1, fill_value=False)] # 재전송이면 마지막 제출만 유지\n", + " pv = [parse(QIDX[q], r) for q, r in zip(keep[\"qid\"], keep[\"raw\"])]\n", + " return keep.assign(val=[p[0] for p in pv], status=[p[1] for p in pv]).drop(columns=\"dup\"), len(df) - len(keep)\n", + "\n", + "\n", + "data = inject_noise(make_responses(40, seed=7))\n", + "preprocess(data.head(100))\n", + "t0 = time.perf_counter()\n", + "clean, n_dup = preprocess(data)\n", + "dt = time.perf_counter() - t0\n", + "print(f\"응답 {len(data):,}건 전처리 {dt * 1000:.0f} ms, {len(data) / dt:,.0f}건/초 (목표 1,000건/초 이상: {'달성' if len(data) / dt >= 1000 else '미달'})\")\n", + "print(f\"재전송 중복 제거 {n_dup}건, 남은 응답 {len(clean):,}건(응시자 120명 x 32문항 = 3,840건과 일치: {len(clean) == 3840})\")\n", + "print(\"상태 분포:\", clean[\"status\"].value_counts().to_dict())\n", + "err = clean[(clean[\"status\"] == \"정상\") & clean.apply(lambda r: QIDX[r.qid][\"type\"] == \"MC\" and r.val not in \"1234\", axis=1)]\n", + "print(f\"전처리 오류(정상인데 파싱 실패) {len(err)}건, 오류율 {len(err) / len(clean):.1%}\")\n", + "\n", + "# 단위 테스트 5종\n", + "Q = QIDX\n", + "tests = {\n", + " \"T1 전각 숫자 '3' -> 3\": parse(Q[\"Q01\"], \"3\")[0] == \"3\",\n", + " \"T2 원문자·영문·'번' 표기 통일\": [parse(Q[\"Q01\"], s)[0] for s in [\"③\", \"c)\", \"3번\", \" 3 \"]] == [\"3\"] * 4,\n", + " \"T3 단답 공백·어미 제거\": parse(Q[\"Q24\"], \"지 휘 관 입니다\")[0] == \"지휘관\" and parse(Q[\"Q21\"], \"IFF\")[0] == \"iff\",\n", + " \"T4 순서형 구분자 제거\": parse(Q[\"Q30\"], \"a-b-c-d\") == (\"ABCD\", \"정상\") and parse(Q[\"Q30\"], \"A,B,B,D\")[1] == \"형식오류\",\n", + " \"T5 미응답 보존(결측 보정율 100%)\": (clean[\"status\"] == \"미응답\").sum() == (data.drop_duplicates([\"sid\", \"qid\"])[\"raw\"] == \"\").sum(),\n", + "}\n", + "for k, v in tests.items():\n", + " print(f\"[{'PASS' if v else 'FAIL'}] {k}\")\n", + "print(f\"단위 테스트 {sum(tests.values())}/5 통과\")\n", + "\n", + "print(\"\\n[전처리 전후 예시]\")\n", + "ex = clean[(clean[\"raw\"] != clean[\"val\"]) & clean[\"qid\"].isin([\"Q05\", \"Q13\", \"Q21\", \"Q22\", \"Q24\", \"Q25\", \"Q29\", \"Q31\"])].groupby(\"qid\").head(1)\n", + "print(ex[[\"qid\", \"raw\", \"val\", \"status\"]].rename(columns={\"raw\": \"원본\", \"val\": \"정제값\", \"status\": \"상태\"}).to_string(index=False))\n", + "print(\"통신 지연 중대는 재전송 판단 시간을 30초로 넓혀 중복 제출을 놓치지 않는다.\")\n", + "\n", + "st = clean.groupby([\"unit\", \"status\"]).size().unstack(fill_value=0)\n", + "st.index = [\"Co.1\", \"Co.2 (delayed comm)\", \"Co.3\"]\n", + "st.columns = st.columns.map({\"정상\": \"valid\", \"미응답\": \"blank\", \"형식오류\": \"format error\"})\n", + "st.plot(kind=\"bar\", stacked=True, figsize=(7, 4))\n", + "plt.title(\"Response status after preprocessing (synthetic)\"); plt.ylabel(\"responses\"); plt.xticks(rotation=0)\n", + "plt.tight_layout(); plt.show()\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 02 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + "응답 3,955건 전처리 18 ms, 220,239건/초 (목표 1,000건/초 이상: 달성)\n", + "재전송 중복 제거 115건, 남은 응답 3,840건(응시자 120명 x 32문항 = 3,840건과 일치: True)\n", + "상태 분포: {'정상': 3753, '미응답': 71, '형식오류': 16}\n", + "전처리 오류(정상인데 파싱 실패) 0건, 오류율 0.0%\n", + "[PASS] T1 전각 숫자 '3' -> 3\n", + "[PASS] T2 원문자·영문·'번' 표기 통일\n", + "[PASS] T3 단답 공백·어미 제거\n", + "[PASS] T4 순서형 구분자 제거\n", + "[PASS] T5 미응답 보존(결측 보정율 100%)\n", + "단위 테스트 5/5 통과\n", + "\n", + "[전처리 전후 예시]\n", + "qid 원본 정제값 상태\n", + "Q05 b) 2 정상\n", + "Q13 3 3 정상\n", + "Q21 피아식별임 피아식별 정상\n", + "Q25 잘못된 경보 잘못된경보 정상\n", + "Q31 A C D ACD 정상\n", + "Q22 de lta delta 정상\n", + "Q24 지 휘 관 지휘관 정상\n", + "Q29 A,B,C,D ABCD 정상\n", + "통신 지연 중대는 재전송 판단 시간을 30초로 넓혀 중복 제출을 놓치지 않는다.\n", + "```\n", + "
\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 03. 선다형 및 단답형 방공 퀴즈 자동 채점 Baseline 로직 구현\n", + "**소요시간**: 약 2.5시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "선다형 및 단답형 방공 퀴즈 자동 채점 Baseline 로직을 구현한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **선다형·단답형·순서형 퀴즈를 자동 채점하는 Baseline을 구현하고 교관 기준 판정과 비교해 정확도를 확인한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **정확 일치 채점과 오탈자(글자 순서 바뀜·한 글자 누락)를 허용하는 유사도 채점을 구현하고, 유사도 기준값을 바꿔 가며 단답형 채점 정확도를 비교 수행하고 검증하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 5개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `src/models/trn02_baseline_model.py 및 기본 추론 결과 보고서` 형태의 결과물을 완성한다.\n", + "\n", + "### 🔍 평가 및 검증 기준 (PASS 조건)\n", + "#### 미션 판정\n", + "\n", + "**평가 구분:** 필수\n", + "\n", + "**핵심 평가영역:** 알고리즘의 논리적 흐름 및 군사적 판단 기준의 투명성\n", + "\n", + "#### 미션 PASS 조건\n", + "\n", + "- 정량 기준: 기본 데이터셋 대상 탐지/처리 정확도 75% 이상 달성\n", + "\n", + "\n", + "- 진행 기준: 단위 기능 동작 확인 및 기본 시나리오 실행 로그 생성\n", + "\n", + "\n", + "- 검증 방법: 기준 벤치마크 데이터셋 대상 추론 결과 오차율 비교\n", + "\n", + "\n", + "#### 채점자 판정 메모\n", + "\n", + "> 단순 코드 실행 여부뿐만 아니라 출력된 수치와 근거 자료가 군 실무 요구사항에 부합하는지 면밀히 확인한다.\n", + "\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "\"\"\"선다형·단답형·순서형 방공 퀴즈를 자동 채점하는 Baseline을 구현한다. 정확 일치 채점과 오탈자 허용 채점의 정확도(교관 기준 대비)·처리 지연을 비교하고, 응시자별 점수와 합격 여부, 실행 로그를 출력한다.\"\"\"\n", + "import difflib\n", + "import re\n", + "import time\n", + "import unicodedata\n", + "import numpy as np\n", + "import pandas as pd\n", + "import matplotlib.pyplot as plt\n", + "\n", + "# ---------------------------------------------------------------------------\n", + "# [교육용가상자료] 방공·드론 식별 퀴즈 문항 은행과 가상 응시자 응답 생성기\n", + "# 문항은 일반 원칙 수준의 가상 문항이며 실제 교범·교전규칙 원문이 아니다. 응시자는 가명(S001~)이다.\n", + "# ---------------------------------------------------------------------------\n", + "AREAS = [\"형상식별\", \"피아식별\", \"경보보고\", \"교전통제\", \"대드론일반\"]\n", + "ETYPES = [\"개념 혼동\", \"피아 오인\", \"절차 누락\"]\n", + "POINTS = {1: 2, 2: 3, 3: 5}\n", + "C, F, P = ETYPES\n", + "\n", + "\n", + "def _mc(qid, area, diff, stem, opts, ans, dist):\n", + " return {\"qid\": qid, \"type\": \"MC\", \"area\": area, \"diff\": diff, \"stem\": stem, \"options\": opts, \"answer\": ans,\n", + " \"dist\": dict(zip([i for i in range(1, 5) if i != ans], dist))}\n", + "\n", + "\n", + "def _sa(qid, area, diff, stem, accept, wrong):\n", + " return {\"qid\": qid, \"type\": \"SA\", \"area\": area, \"diff\": diff, \"stem\": stem, \"accept\": accept, \"wrong\": wrong}\n", + "\n", + "\n", + "def _order(qid, area, diff, stem):\n", + " return {\"qid\": qid, \"type\": \"ORDER\", \"area\": area, \"diff\": diff, \"stem\": stem, \"answer\": \"ABCD\"}\n", + "\n", + "\n", + "BANK = [\n", + " _mc(\"Q01\", \"형상식별\", 1, \"꼬리날개 없이 삼각형 날개를 가진 소형 고정익 실루엣은?\", [\"쿼드콥터형\", \"삼각익 고정익형\", \"회전익 헬기형\", \"조류\"], 2, [C, C, F]),\n", + " _mc(\"Q02\", \"형상식별\", 1, \"회전날개 4개가 십자로 배치된 실루엣은?\", [\"고정익 드론형\", \"쿼드콥터형\", \"순항미사일형\", \"탄도미사일형\"], 2, [C, C, C]),\n", + " _mc(\"Q03\", \"형상식별\", 2, \"가늘고 긴 동체 중간에 작은 날개가 붙은 실루엣은?\", [\"순항미사일형\", \"탄도미사일형\", \"민간 경비행기형\", \"조류\"], 1, [C, F, F]),\n", + " _mc(\"Q04\", \"형상식별\", 3, \"원거리라 실루엣 식별이 어려울 때 먼저 할 행동은?\", [\"추정 유형으로 확정 보고\", \"관측 정보를 그대로 보고하고 추가 관측\", \"관측 중단\", \"민간기로 간주\"], 2, [P, P, F]),\n", + " _mc(\"Q05\", \"피아식별\", 2, \"식별 신호 응답이 없는 항공기에 대한 올바른 판단은?\", [\"즉시 적기로 확정\", \"미확인 항공기로 분류하고 추가 확인\", \"아군기로 간주\", \"무시\"], 2, [F, F, P]),\n", + " _mc(\"Q06\", \"피아식별\", 2, \"운항 정보와 일치하는 저속 비행체의 분류는?\", [\"적 드론\", \"민간기 가능성 높음, 추가 확인\", \"순항미사일\", \"아군 정찰기로 확정\"], 2, [F, C, F]),\n", + " _mc(\"Q07\", \"피아식별\", 3, \"아군 드론 운용 공지가 있는 공역에서 미확인 드론을 보면 우선 확인할 것은?\", [\"기상 정보\", \"아군 운용 계획과 식별 신호\", \"대응 준비 상태\", \"주변 지형\"], 2, [C, P, C]),\n", + " _mc(\"Q08\", \"피아식별\", 3, \"조류 무리와 소형 드론 군집 구분에 가장 도움이 되는 정보는?\", [\"크기만 비교\", \"궤적의 규칙성과 열 신호\", \"색상\", \"관측 시각\"], 2, [C, C, C]),\n", + " _mc(\"Q09\", \"경보보고\", 1, \"공중 위협 최초 보고에 포함하지 않아도 되는 것은?\", [\"관측 시각\", \"방향과 거리\", \"추정 유형\", \"관측자 개인 연락처\"], 4, [P, P, P]),\n", + " _mc(\"Q10\", \"경보보고\", 2, \"경보 전파 후 상황이 바뀌면?\", [\"최초 보고 유지\", \"변경 사항을 즉시 추가 보고\", \"상황 종료 후 일괄 보고\", \"개인 판단으로 경보 해제\"], 2, [P, P, P]),\n", + " _mc(\"Q11\", \"경보보고\", 2, \"보고 내용이 불확실할 때 올바른 표현은?\", [\"확정 표현으로 보고\", \"'추정'임을 밝히고 근거와 함께 보고\", \"보고 보류\", \"다른 관측자 보고를 복사\"], 2, [C, P, P]),\n", + " _mc(\"Q12\", \"경보보고\", 1, \"드론 탐지 후 보고 경로로 알맞은 것은?\", [\"인접 부대에만 전파\", \"지휘계통을 따라 보고\", \"개인 메신저로 공유\", \"보고 없이 대응\"], 2, [P, P, P]),\n", + " _mc(\"Q13\", \"교전통제\", 1, \"AI 탐지 결과만으로 대응을 결정할 수 있는가?\", [\"가능하다\", \"인간 지휘관의 승인이 필요하다\", \"신뢰도 90% 이상이면 가능\", \"야간에만 가능\"], 2, [C, C, C]),\n", + " _mc(\"Q14\", \"교전통제\", 2, \"민간 피해 가능성이 확인되면?\", [\"그대로 진행\", \"즉시 보고하고 지휘관 판단을 받는다\", \"조치 후 보고\", \"무시\"], 2, [P, P, F]),\n", + " _mc(\"Q15\", \"교전통제\", 3, \"식별이 불확실한 표적에 대한 원칙은?\", [\"위협으로 간주해 즉시 대응\", \"식별 확인 전까지 승인 절차를 따른다\", \"무조건 무시\", \"다수결로 결정\"], 2, [F, F, C]),\n", + " _mc(\"Q16\", \"교전통제\", 2, \"판단 근거를 기록해야 하는 이유는?\", [\"형식상 필요\", \"사후 검토와 책임 추적\", \"보고 생략\", \"기록 불필요\"], 2, [C, P, P]),\n", + " _mc(\"Q17\", \"대드론일반\", 1, \"대드론 체계의 일반적 처리 순서는?\", [\"대응-탐지-식별\", \"탐지-식별-보고·판단-대응\", \"식별-대응-탐지\", \"보고-탐지-대응\"], 2, [P, P, P]),\n", + " _mc(\"Q18\", \"대드론일반\", 3, \"레이더만으로 조류와 드론 구분이 어려운 이유는?\", [\"반사 신호 크기가 비슷할 수 있어서\", \"레이더는 야간에 못 써서\", \"드론에는 금속이 없어서\", \"조류는 탐지되지 않아서\"], 1, [C, C, C]),\n", + " _mc(\"Q19\", \"대드론일반\", 2, \"전자광학(EO) 센서의 약점이 커지는 조건은?\", [\"맑은 주간\", \"안개·야간\", \"저속 표적\", \"근거리\"], 2, [C, C, C]),\n", + " _mc(\"Q20\", \"대드론일반\", 2, \"경보가 반복해서 오경보로 판명되면?\", [\"경보 장치를 끈다\", \"사례를 기록·보고하고 기준 재검토 요청\", \"무시한다\", \"모두 실제 위협으로 간주\"], 2, [P, P, F]),\n", + " _sa(\"Q21\", \"피아식별\", 1, \"아군·적·민간을 구분하는 절차를 무엇이라 하는가?\", [\"피아식별\", \"IFF\"], {\"표적획득\": C, \"적기판정\": F, \"경계\": C}),\n", + " _sa(\"Q22\", \"형상식별\", 2, \"꼬리날개 없는 삼각형 날개 형태를 무엇이라 하는가?\", [\"삼각익\", \"델타익\", \"delta\"], {\"직선익\": C, \"후퇴익\": C, \"쿼드콥터\": C}),\n", + " _sa(\"Q23\", \"경보보고\", 2, \"불확실한 관측을 보고할 때 붙이는 표현은?\", [\"추정\"], {\"확정\": C, \"확인\": C, \"생략\": P}),\n", + " _sa(\"Q24\", \"교전통제\", 1, \"AI 탐지 결과에 대해 최종 결정을 내리는 주체는?\", [\"지휘관\"], {\"AI\": C, \"운용병\": P, \"레이더\": C}),\n", + " _sa(\"Q25\", \"대드론일반\", 1, \"조류를 드론으로 잘못 경보하는 것을 무엇이라 하는가?\", [\"오경보\", \"오탐\", \"false alarm\"], {\"미탐\": C, \"재경보\": C, \"적기\": F}),\n", + " _sa(\"Q26\", \"피아식별\", 2, \"운항 정보가 확인된 여객기는 어떻게 분류하는가?\", [\"민간기\", \"민항기\"], {\"적기\": F, \"미확인기\": P, \"아군기\": F}),\n", + " _sa(\"Q27\", \"형상식별\", 1, \"회전날개 4개를 가진 드론 형태는?\", [\"쿼드콥터\", \"quadcopter\"], {\"헥사콥터\": C, \"고정익\": C, \"헬기\": F}),\n", + " _sa(\"Q28\", \"경보보고\", 1, \"보고는 무엇을 따라 올라가는가?\", [\"지휘계통\"], {\"인접부대\": P, \"개인메신저\": P, \"통신망\": C}),\n", + " _order(\"Q29\", \"경보보고\", 2, \"최초 보고 순서: A 관측 시각, B 방향·거리, C 추정 유형, D 관측자 조치\"),\n", + " _order(\"Q30\", \"대드론일반\", 2, \"처리 순서: A 탐지, B 식별, C 보고·판단, D 대응\"),\n", + " _order(\"Q31\", \"교전통제\", 3, \"통제 순서: A 식별 확인, B 상급 승인 요청, C 승인 수령, D 조치 기록\"),\n", + " _order(\"Q32\", \"피아식별\", 3, \"확인 순서: A 운항정보 대조, B 식별 신호 확인, C 미확인 분류, D 보고\"),\n", + "]\n", + "QIDX = {q[\"qid\"]: q for q in BANK}\n", + "UNITS = [\"가상1중대\", \"가상2중대\", \"가상3중대\"]\n", + "_CIRC = \"①②③④\"\n", + "PARA = {\"Q21\": \"아군적군 구분\", \"Q22\": \"세모 날개\", \"Q23\": \"추정됨으로 표기\", \"Q24\": \"중대장(지휘관)\", \"Q25\": \"잘못된 경보\",\n", + " \"Q26\": \"민간 여객기\", \"Q27\": \"4발 드론\", \"Q28\": \"지휘 라인\"} # 뜻은 맞지만 인정 답안 목록에 없는 표현\n", + "\n", + "\n", + "def make_soldiers(n_per_unit, seed):\n", + " rng = np.random.default_rng(seed)\n", + " out = []\n", + " for u, unit in enumerate(UNITS):\n", + " base = [0.3, -0.1, 0.0][u]\n", + " for i in range(n_per_unit):\n", + " off = rng.normal(0, 0.5, len(AREAS)) + (np.array([0, 0, 0, -0.6, 0]) if u == 1 else 0)\n", + " out.append({\"sid\": f\"S{len(out) + 1:03d}\", \"unit\": unit, \"theta\": dict(zip(AREAS, base + rng.normal(0, 0.6) + off)),\n", + " \"tend\": dict(zip(ETYPES, rng.dirichlet([1.2, 1.2, 1.2])))})\n", + " return out\n", + "\n", + "\n", + "def p_correct(theta, diff):\n", + " return 1 / (1 + np.exp(-1.7 * (theta - (diff - 2) * 0.9 + 0.9)))\n", + "\n", + "\n", + "def _typo(s, rng):\n", + " if len(s) < 2:\n", + " return s\n", + " i = int(rng.integers(len(s) - 1))\n", + " return s[:i] + s[i + 1] + s[i] + s[i + 2:] if len(s) <= 3 or rng.random() < 0.5 else s[:i] + s[i + 1:]\n", + "\n", + "\n", + "def _fmt_mc(k, rng):\n", + " r = rng.random()\n", + " if r < 0.55:\n", + " return str(k)\n", + " return rng.choice([_CIRC[k - 1], f\"{k}번\", \"ABCD\"[k - 1], \"abcd\"[k - 1] + \")\", f\" {k} \", chr(0xFF10 + k), f\"{k}.\"])\n", + "\n", + "\n", + "def _fmt_sa(s, rng):\n", + " r = rng.random()\n", + " if r < 0.45:\n", + " return s\n", + " if r < 0.6:\n", + " return \" \".join(s) if len(s) <= 4 else s[:2] + \" \" + s[2:]\n", + " if r < 0.72:\n", + " return s + rng.choice([\"입니다\", \"이다\", \".\", \"임\"])\n", + " if r < 0.8:\n", + " return s.upper() if s.isascii() else \"\".join(chr(ord(ch) + 0xFEE0) if \"!\" <= ch <= \"~\" else ch for ch in s) + \" \"\n", + " return _typo(s, rng)\n", + "\n", + "\n", + "def answer(q, sol, rng):\n", + " \"\"\"한 문항 응답: (원본 응답 문자열, 의도한 정답 여부, 의도한 답, 오답 유형).\"\"\"\n", + " ok = rng.random() < p_correct(sol[\"theta\"][q[\"area\"]], q[\"diff\"])\n", + " if q[\"type\"] == \"MC\":\n", + " if ok:\n", + " k, et = q[\"answer\"], None\n", + " else:\n", + " ks = list(q[\"dist\"]); w = np.array([sol[\"tend\"][q[\"dist\"][k]] for k in ks])\n", + " k = int(rng.choice(ks, p=w / w.sum())); et = q[\"dist\"][k]\n", + " return _fmt_mc(k, rng), ok, str(k), et\n", + " if q[\"type\"] == \"SA\":\n", + " if ok and rng.random() < 0.06:\n", + " return PARA[q[\"qid\"]], ok, PARA[q[\"qid\"]], None\n", + " if ok:\n", + " s, et = str(rng.choice(q[\"accept\"])), None\n", + " else:\n", + " ws = list(q[\"wrong\"]); w = np.array([sol[\"tend\"][q[\"wrong\"][x]] for x in ws])\n", + " s = str(rng.choice(ws, p=w / w.sum())); et = q[\"wrong\"][s]\n", + " return _fmt_sa(s, rng), ok, s, et\n", + " if ok:\n", + " s = \"ABCD\"\n", + " elif rng.random() < 0.5:\n", + " s = \"ABCD\".replace(\"ABCD\"[rng.integers(1, 4)], \"\") # 단계 하나 빠뜨림\n", + " else:\n", + " i = int(rng.integers(0, 3)); s = list(\"ABCD\"); s[i], s[i + 1] = s[i + 1], s[i]; s = \"\".join(s) # 순서 뒤바뀜\n", + " raw = s if rng.random() < 0.6 else rng.choice([\"-\".join(s), \" \".join(s), s.lower(), \",\".join(s)])\n", + " return raw, ok, s, (None if ok else P)\n", + "\n", + "\n", + "def make_responses(n_per_unit=40, seed=7, blank=0.02):\n", + " \"\"\"응시자 x 문항 응답표(DataFrame)를 만든다. truth_* 열은 교관 기준 정답(평가용)이다.\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " rows = []\n", + " for sol in make_soldiers(n_per_unit, seed):\n", + " for q in BANK:\n", + " raw, ok, intended, et = answer(q, sol, rng)\n", + " if rng.random() < blank:\n", + " raw, ok, intended, et = \"\", False, \"\", None\n", + " rows.append({\"rid\": f\"R{len(rows):05d}\", \"sid\": sol[\"sid\"], \"unit\": sol[\"unit\"], \"qid\": q[\"qid\"], \"raw\": raw,\n", + " \"truth_correct\": ok, \"truth_answer\": intended, \"truth_etype\": et})\n", + " return pd.DataFrame(rows)\n", + "# ---------------------------------------------------------------------------\n", + "\n", + "# ---- 응답 정제·파싱·채점 공통 함수 ----\n", + "TAILS = (\"입니다\", \"이다\", \"임\")\n", + "\n", + "\n", + "def norm_text(s):\n", + " \"\"\"전각->반각(NFKC), 소문자, 공백·문장부호 제거, 끝맺음 어미 제거.\"\"\"\n", + " s = unicodedata.normalize(\"NFKC\", str(s)).strip().lower()\n", + " s = re.sub(r\"[\\s\\.\\,\\-\\(\\)·]\", \"\", s)\n", + " for t in TAILS:\n", + " if s.endswith(t) and len(s) > len(t) + 1:\n", + " s = s[:-len(t)]\n", + " return s\n", + "\n", + "\n", + "def parse(q, raw):\n", + " \"\"\"원본 응답 -> (정제값, 상태). 상태: 정상 / 미응답 / 형식오류\"\"\"\n", + " if raw is None or str(raw).strip() == \"\":\n", + " return \"\", \"미응답\"\n", + " s = unicodedata.normalize(\"NFKC\", str(raw)).strip()\n", + " if q[\"type\"] == \"MC\":\n", + " t = re.sub(r\"[\\s번\\.\\)]\", \"\", s).lower()\n", + " t = {\"a\": \"1\", \"b\": \"2\", \"c\": \"3\", \"d\": \"4\"}.get(t, t)\n", + " return (t, \"정상\") if t in (\"1\", \"2\", \"3\", \"4\") else (t, \"형식오류\")\n", + " if q[\"type\"] == \"ORDER\":\n", + " t = re.sub(r\"[^A-Za-z]\", \"\", s).upper()\n", + " return (t, \"정상\") if t and set(t) <= set(\"ABCD\") and len(set(t)) == len(t) else (t, \"형식오류\")\n", + " return norm_text(s), \"정상\"\n", + "\n", + "\n", + "def grade_exact(q, val):\n", + " if q[\"type\"] == \"MC\":\n", + " return val == str(q[\"answer\"])\n", + " if q[\"type\"] == \"ORDER\":\n", + " return val == q[\"answer\"]\n", + " return val in {norm_text(a) for a in q[\"accept\"]}\n", + "\n", + "\n", + "def grade_fuzzy(q, val, cut=0.75):\n", + " \"\"\"단답형은 오탈자(글자 순서 바뀜·한 글자 누락)를 허용(유사도 cut 이상)하되, 알려진 오답과 더 가까우면 오답으로 본다.\"\"\"\n", + " if grade_exact(q, val) or q[\"type\"] != \"SA\" or len(val) < 2:\n", + " return grade_exact(q, val)\n", + " best = max(1.0 if sorted(val) == sorted(norm_text(a)) else difflib.SequenceMatcher(None, val, norm_text(a)).ratio() for a in q[\"accept\"])\n", + " worst = max(difflib.SequenceMatcher(None, val, norm_text(w)).ratio() for w in q[\"wrong\"])\n", + " return best >= cut and best > worst\n", + "\n", + "\n", + "def score_of(q, correct):\n", + " return POINTS[q[\"diff\"]] if correct else 0\n", + "\n", + "PASS_RATE = 0.7\n", + "df = make_responses(40, seed=7)\n", + "df[\"type\"] = df[\"qid\"].map(lambda q: QIDX[q][\"type\"])\n", + "pv = [parse(QIDX[q], r) for q, r in zip(df[\"qid\"], df[\"raw\"])]\n", + "df[\"val\"] = [p[0] for p in pv]\n", + "\n", + "t0 = time.perf_counter()\n", + "df[\"exact\"] = [grade_exact(QIDX[q], v) for q, v in zip(df[\"qid\"], df[\"val\"])]\n", + "t1 = time.perf_counter()\n", + "df[\"fuzzy\"] = [grade_fuzzy(QIDX[q], v) for q, v in zip(df[\"qid\"], df[\"val\"])]\n", + "t2 = time.perf_counter()\n", + "\n", + "rows = []\n", + "for name, col, t in [(\"정확 일치\", \"exact\", t1 - t0), (\"오탈자 허용\", \"fuzzy\", t2 - t1)]:\n", + " rows.append({\"채점 방식\": name, \"채점 정확도\": (df[col] == df[\"truth_correct\"]).mean(),\n", + " **{f\"{k} 정확도\": (g[col] == g[\"truth_correct\"]).mean() for k, g in df.groupby(\"type\")},\n", + " \"건당 ms\": t / len(df) * 1000})\n", + "res = pd.DataFrame(rows)\n", + "print(f\"[교육용가상자료] 응시자 120명 x 32문항 = {len(df):,}건 채점 (교관 기준 정답과 비교)\")\n", + "print(res.round(4).to_string(index=False))\n", + "acc = res.loc[1, \"채점 정확도\"]\n", + "print(f\"\\n목표(채점 정확도 75% 이상): {'달성' if acc >= 0.75 else '미달'} ({acc:.1%}), 건당 처리 {res.loc[1, '건당 ms']:.3f} ms\")\n", + "\n", + "print(\"\\n[정확 일치 채점이 틀린 사례(오탈자·띄어쓰기)]\")\n", + "miss = df[(df[\"exact\"] != df[\"truth_correct\"])].groupby(\"qid\").head(1).head(5)\n", + "print(miss[[\"qid\", \"raw\", \"val\", \"truth_correct\", \"exact\", \"fuzzy\"]].rename(columns={\"truth_correct\": \"교관 판정\", \"exact\": \"정확일치\", \"fuzzy\": \"오탈자허용\"}).to_string(index=False))\n", + "\n", + "print(\"오탈자 허용 채점도 놓치는 '뜻은 맞지만 인정 답안 목록에 없는 표현'은 이의 제기·교관 검토(미션 7) 대상으로 넘긴다.\")\n", + "print(\"\\n[휴리스틱 1차 튜닝: 유사도 기준값]\")\n", + "sa = df[df[\"type\"] == \"SA\"]\n", + "for cut in [0.6, 0.7, 0.75, 0.8, 0.9]:\n", + " a = np.mean([grade_fuzzy(QIDX[q], v, cut) == t for q, v, t in zip(sa[\"qid\"], sa[\"val\"], sa[\"truth_correct\"])])\n", + " print(f\"기준 {cut}: 단답형 채점 정확도 {a:.3f}\")\n", + "\n", + "df[\"score\"] = [score_of(QIDX[q], c) for q, c in zip(df[\"qid\"], df[\"fuzzy\"])]\n", + "full = sum(POINTS[q[\"diff\"]] for q in BANK)\n", + "sc = df.groupby([\"sid\", \"unit\"])[\"score\"].sum().reset_index()\n", + "sc[\"합격\"] = sc[\"score\"] >= full * PASS_RATE\n", + "print(f\"\\n만점 {full}점, 평균 {sc['score'].mean():.1f}점, 합격률 {sc['합격'].mean():.1%}\")\n", + "print(sc.groupby(\"unit\").agg(평균점수=(\"score\", \"mean\"), 합격률=(\"합격\", \"mean\")).round(2).to_string())\n", + "print(f\"실행 로그: 문항 {len(BANK)}개, 응답 {len(df):,}건, 미응답 {(df['raw'] == '').sum()}건, 채점 예외 0건\")\n", + "\n", + "plt.figure(figsize=(7, 4))\n", + "plt.hist(sc[\"score\"], bins=15, color=\"tab:blue\"); plt.axvline(full * PASS_RATE, color=\"red\", ls=\"--\", label=\"pass line (70%)\")\n", + "plt.xlabel(\"total score\"); plt.ylabel(\"soldiers\"); plt.legend(); plt.title(\"Auto-graded score distribution (synthetic)\"); plt.show()\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 03 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + "[교육용가상자료] 응시자 120명 x 32문항 = 3,840건 채점 (교관 기준 정답과 비교)\n", + " 채점 방식 채점 정확도 MC 정확도 ORDER 정확도 SA 정확도 건당 ms\n", + " 정확 일치 0.9516 1.0 1.0 0.8062 0.0017\n", + "오탈자 허용 0.9901 1.0 1.0 0.9604 0.0070\n", + "\n", + "목표(채점 정확도 75% 이상): 달성 (99.0%), 건당 처리 0.007 ms\n", + "\n", + "[정확 일치 채점이 틀린 사례(오탈자·띄어쓰기)]\n", + "qid raw val 교관 판정 정확일치 오탈자허용\n", + "Q25 잘못된 경보 잘못된경보 True False False\n", + "Q21 아군적군 구분 아군적군구분 True False False\n", + "Q28 지휘통 지휘통 True False True\n", + "Q22 타델익 타델익 True False True\n", + "Q26 민기간 민기간 True False True\n", + "오탈자 허용 채점도 놓치는 '뜻은 맞지만 인정 답안 목록에 없는 표현'은 이의 제기·교관 검토(미션 7) 대상으로 넘긴다.\n", + "\n", + "[휴리스틱 1차 튜닝: 유사도 기준값]\n", + "기준 0.6: 단답형 채점 정확도 0.965\n", + "기준 0.7: 단답형 채점 정확도 0.960\n", + "기준 0.75: 단답형 채점 정확도 0.960\n", + "기준 0.8: 단답형 채점 정확도 0.956\n", + "기준 0.9: 단답형 채점 정확도 0.935\n", + "\n", + "만점 99점, 평균 67.4점, 합격률 50.8%\n", + " 평균점수 합격률\n", + "unit \n", + "가상1중대 75.40 0.68\n", + "가상2중대 58.55 0.28\n", + "가상3중대 68.18 0.57\n", + "실행 로그: 문항 32개, 응답 3,840건, 미응답 72건, 채점 예외 0건\n", + "```\n", + "
\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 04. 오답 유형(개념 혼동, 피아 오인, 절차 누락) 자동 분류 및 패턴 분석 엔진 고도화\n", + "**소요시간**: 약 3.0시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "오답 유형(개념 혼동, 피아 오인, 절차 누락) 자동 분류 엔진을 고도화한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **오답을 개념 혼동·피아 오인·절차 누락 유형으로 자동 분류하는 엔진을 고도화하고 개인·중대별 오답 패턴을 분석한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **선다형은 오답 보기별 유형 매핑, 순서형은 단계 누락·순서 뒤바뀜 규칙, 단답형은 오답 목록 대조와 오탈자 근사 일치에 문자 n-gram 분류 모델을 더 수행하고 검증하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `src/models/trn02_advanced_detector.py 및 예외 탐지 테스트 결과` 형태의 결과물을 완성한다.\n", + "\n", + "### 🔍 평가 및 검증 기준 (PASS 조건)\n", + "#### 미션 판정\n", + "\n", + "**평가 구분:** 필수\n", + "\n", + "**핵심 평가영역:** 기만/교란 공격에 대한 방어 로직의 현실성 및 신뢰성\n", + "\n", + "#### 미션 PASS 조건\n", + "\n", + "- 정량 기준: 복합 위협 탐지 재현율(Recall) 85% 이상, 처리 지연 200ms 이하\n", + "\n", + "\n", + "- 진행 기준: 복합 위협 시나리오 10종에 대한 탐지 테스트 통과\n", + "\n", + "\n", + "- 검증 방법: 합성 위협 데이터셋 주입 시 이상치 탐지 정확도 검증\n", + "\n", + "\n", + "#### 채점자 판정 메모\n", + "\n", + "> 단순 코드 실행 여부뿐만 아니라 출력된 수치와 근거 자료가 군 실무 요구사항에 부합하는지 면밀히 확인한다.\n", + "\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "\"\"\"오답을 개념 혼동·피아 오인·절차 누락 유형으로 자동 분류하는 엔진을 고도화한다. 보기 매핑·순서 규칙·오답 목록 대조에 문자 n-gram 분류 모델을 더해, 복합 오답 시나리오 10종에서 유형별 재현율과 처리 지연을 검증하고 개인·중대별 오답 패턴을 분석한다.\"\"\"\n", + "import difflib\n", + "import re\n", + "import time\n", + "import unicodedata\n", + "import numpy as np\n", + "import pandas as pd\n", + "import matplotlib.pyplot as plt\n", + "from sklearn.feature_extraction.text import TfidfVectorizer\n", + "from sklearn.linear_model import LogisticRegression\n", + "from sklearn.pipeline import make_pipeline\n", + "\n", + "# ---------------------------------------------------------------------------\n", + "# [교육용가상자료] 방공·드론 식별 퀴즈 문항 은행과 가상 응시자 응답 생성기\n", + "# 문항은 일반 원칙 수준의 가상 문항이며 실제 교범·교전규칙 원문이 아니다. 응시자는 가명(S001~)이다.\n", + "# ---------------------------------------------------------------------------\n", + "AREAS = [\"형상식별\", \"피아식별\", \"경보보고\", \"교전통제\", \"대드론일반\"]\n", + "ETYPES = [\"개념 혼동\", \"피아 오인\", \"절차 누락\"]\n", + "POINTS = {1: 2, 2: 3, 3: 5}\n", + "C, F, P = ETYPES\n", + "\n", + "\n", + "def _mc(qid, area, diff, stem, opts, ans, dist):\n", + " return {\"qid\": qid, \"type\": \"MC\", \"area\": area, \"diff\": diff, \"stem\": stem, \"options\": opts, \"answer\": ans,\n", + " \"dist\": dict(zip([i for i in range(1, 5) if i != ans], dist))}\n", + "\n", + "\n", + "def _sa(qid, area, diff, stem, accept, wrong):\n", + " return {\"qid\": qid, \"type\": \"SA\", \"area\": area, \"diff\": diff, \"stem\": stem, \"accept\": accept, \"wrong\": wrong}\n", + "\n", + "\n", + "def _order(qid, area, diff, stem):\n", + " return {\"qid\": qid, \"type\": \"ORDER\", \"area\": area, \"diff\": diff, \"stem\": stem, \"answer\": \"ABCD\"}\n", + "\n", + "\n", + "BANK = [\n", + " _mc(\"Q01\", \"형상식별\", 1, \"꼬리날개 없이 삼각형 날개를 가진 소형 고정익 실루엣은?\", [\"쿼드콥터형\", \"삼각익 고정익형\", \"회전익 헬기형\", \"조류\"], 2, [C, C, F]),\n", + " _mc(\"Q02\", \"형상식별\", 1, \"회전날개 4개가 십자로 배치된 실루엣은?\", [\"고정익 드론형\", \"쿼드콥터형\", \"순항미사일형\", \"탄도미사일형\"], 2, [C, C, C]),\n", + " _mc(\"Q03\", \"형상식별\", 2, \"가늘고 긴 동체 중간에 작은 날개가 붙은 실루엣은?\", [\"순항미사일형\", \"탄도미사일형\", \"민간 경비행기형\", \"조류\"], 1, [C, F, F]),\n", + " _mc(\"Q04\", \"형상식별\", 3, \"원거리라 실루엣 식별이 어려울 때 먼저 할 행동은?\", [\"추정 유형으로 확정 보고\", \"관측 정보를 그대로 보고하고 추가 관측\", \"관측 중단\", \"민간기로 간주\"], 2, [P, P, F]),\n", + " _mc(\"Q05\", \"피아식별\", 2, \"식별 신호 응답이 없는 항공기에 대한 올바른 판단은?\", [\"즉시 적기로 확정\", \"미확인 항공기로 분류하고 추가 확인\", \"아군기로 간주\", \"무시\"], 2, [F, F, P]),\n", + " _mc(\"Q06\", \"피아식별\", 2, \"운항 정보와 일치하는 저속 비행체의 분류는?\", [\"적 드론\", \"민간기 가능성 높음, 추가 확인\", \"순항미사일\", \"아군 정찰기로 확정\"], 2, [F, C, F]),\n", + " _mc(\"Q07\", \"피아식별\", 3, \"아군 드론 운용 공지가 있는 공역에서 미확인 드론을 보면 우선 확인할 것은?\", [\"기상 정보\", \"아군 운용 계획과 식별 신호\", \"대응 준비 상태\", \"주변 지형\"], 2, [C, P, C]),\n", + " _mc(\"Q08\", \"피아식별\", 3, \"조류 무리와 소형 드론 군집 구분에 가장 도움이 되는 정보는?\", [\"크기만 비교\", \"궤적의 규칙성과 열 신호\", \"색상\", \"관측 시각\"], 2, [C, C, C]),\n", + " _mc(\"Q09\", \"경보보고\", 1, \"공중 위협 최초 보고에 포함하지 않아도 되는 것은?\", [\"관측 시각\", \"방향과 거리\", \"추정 유형\", \"관측자 개인 연락처\"], 4, [P, P, P]),\n", + " _mc(\"Q10\", \"경보보고\", 2, \"경보 전파 후 상황이 바뀌면?\", [\"최초 보고 유지\", \"변경 사항을 즉시 추가 보고\", \"상황 종료 후 일괄 보고\", \"개인 판단으로 경보 해제\"], 2, [P, P, P]),\n", + " _mc(\"Q11\", \"경보보고\", 2, \"보고 내용이 불확실할 때 올바른 표현은?\", [\"확정 표현으로 보고\", \"'추정'임을 밝히고 근거와 함께 보고\", \"보고 보류\", \"다른 관측자 보고를 복사\"], 2, [C, P, P]),\n", + " _mc(\"Q12\", \"경보보고\", 1, \"드론 탐지 후 보고 경로로 알맞은 것은?\", [\"인접 부대에만 전파\", \"지휘계통을 따라 보고\", \"개인 메신저로 공유\", \"보고 없이 대응\"], 2, [P, P, P]),\n", + " _mc(\"Q13\", \"교전통제\", 1, \"AI 탐지 결과만으로 대응을 결정할 수 있는가?\", [\"가능하다\", \"인간 지휘관의 승인이 필요하다\", \"신뢰도 90% 이상이면 가능\", \"야간에만 가능\"], 2, [C, C, C]),\n", + " _mc(\"Q14\", \"교전통제\", 2, \"민간 피해 가능성이 확인되면?\", [\"그대로 진행\", \"즉시 보고하고 지휘관 판단을 받는다\", \"조치 후 보고\", \"무시\"], 2, [P, P, F]),\n", + " _mc(\"Q15\", \"교전통제\", 3, \"식별이 불확실한 표적에 대한 원칙은?\", [\"위협으로 간주해 즉시 대응\", \"식별 확인 전까지 승인 절차를 따른다\", \"무조건 무시\", \"다수결로 결정\"], 2, [F, F, C]),\n", + " _mc(\"Q16\", \"교전통제\", 2, \"판단 근거를 기록해야 하는 이유는?\", [\"형식상 필요\", \"사후 검토와 책임 추적\", \"보고 생략\", \"기록 불필요\"], 2, [C, P, P]),\n", + " _mc(\"Q17\", \"대드론일반\", 1, \"대드론 체계의 일반적 처리 순서는?\", [\"대응-탐지-식별\", \"탐지-식별-보고·판단-대응\", \"식별-대응-탐지\", \"보고-탐지-대응\"], 2, [P, P, P]),\n", + " _mc(\"Q18\", \"대드론일반\", 3, \"레이더만으로 조류와 드론 구분이 어려운 이유는?\", [\"반사 신호 크기가 비슷할 수 있어서\", \"레이더는 야간에 못 써서\", \"드론에는 금속이 없어서\", \"조류는 탐지되지 않아서\"], 1, [C, C, C]),\n", + " _mc(\"Q19\", \"대드론일반\", 2, \"전자광학(EO) 센서의 약점이 커지는 조건은?\", [\"맑은 주간\", \"안개·야간\", \"저속 표적\", \"근거리\"], 2, [C, C, C]),\n", + " _mc(\"Q20\", \"대드론일반\", 2, \"경보가 반복해서 오경보로 판명되면?\", [\"경보 장치를 끈다\", \"사례를 기록·보고하고 기준 재검토 요청\", \"무시한다\", \"모두 실제 위협으로 간주\"], 2, [P, P, F]),\n", + " _sa(\"Q21\", \"피아식별\", 1, \"아군·적·민간을 구분하는 절차를 무엇이라 하는가?\", [\"피아식별\", \"IFF\"], {\"표적획득\": C, \"적기판정\": F, \"경계\": C}),\n", + " _sa(\"Q22\", \"형상식별\", 2, \"꼬리날개 없는 삼각형 날개 형태를 무엇이라 하는가?\", [\"삼각익\", \"델타익\", \"delta\"], {\"직선익\": C, \"후퇴익\": C, \"쿼드콥터\": C}),\n", + " _sa(\"Q23\", \"경보보고\", 2, \"불확실한 관측을 보고할 때 붙이는 표현은?\", [\"추정\"], {\"확정\": C, \"확인\": C, \"생략\": P}),\n", + " _sa(\"Q24\", \"교전통제\", 1, \"AI 탐지 결과에 대해 최종 결정을 내리는 주체는?\", [\"지휘관\"], {\"AI\": C, \"운용병\": P, \"레이더\": C}),\n", + " _sa(\"Q25\", \"대드론일반\", 1, \"조류를 드론으로 잘못 경보하는 것을 무엇이라 하는가?\", [\"오경보\", \"오탐\", \"false alarm\"], {\"미탐\": C, \"재경보\": C, \"적기\": F}),\n", + " _sa(\"Q26\", \"피아식별\", 2, \"운항 정보가 확인된 여객기는 어떻게 분류하는가?\", [\"민간기\", \"민항기\"], {\"적기\": F, \"미확인기\": P, \"아군기\": F}),\n", + " _sa(\"Q27\", \"형상식별\", 1, \"회전날개 4개를 가진 드론 형태는?\", [\"쿼드콥터\", \"quadcopter\"], {\"헥사콥터\": C, \"고정익\": C, \"헬기\": F}),\n", + " _sa(\"Q28\", \"경보보고\", 1, \"보고는 무엇을 따라 올라가는가?\", [\"지휘계통\"], {\"인접부대\": P, \"개인메신저\": P, \"통신망\": C}),\n", + " _order(\"Q29\", \"경보보고\", 2, \"최초 보고 순서: A 관측 시각, B 방향·거리, C 추정 유형, D 관측자 조치\"),\n", + " _order(\"Q30\", \"대드론일반\", 2, \"처리 순서: A 탐지, B 식별, C 보고·판단, D 대응\"),\n", + " _order(\"Q31\", \"교전통제\", 3, \"통제 순서: A 식별 확인, B 상급 승인 요청, C 승인 수령, D 조치 기록\"),\n", + " _order(\"Q32\", \"피아식별\", 3, \"확인 순서: A 운항정보 대조, B 식별 신호 확인, C 미확인 분류, D 보고\"),\n", + "]\n", + "QIDX = {q[\"qid\"]: q for q in BANK}\n", + "UNITS = [\"가상1중대\", \"가상2중대\", \"가상3중대\"]\n", + "_CIRC = \"①②③④\"\n", + "PARA = {\"Q21\": \"아군적군 구분\", \"Q22\": \"세모 날개\", \"Q23\": \"추정됨으로 표기\", \"Q24\": \"중대장(지휘관)\", \"Q25\": \"잘못된 경보\",\n", + " \"Q26\": \"민간 여객기\", \"Q27\": \"4발 드론\", \"Q28\": \"지휘 라인\"} # 뜻은 맞지만 인정 답안 목록에 없는 표현\n", + "\n", + "\n", + "def make_soldiers(n_per_unit, seed):\n", + " rng = np.random.default_rng(seed)\n", + " out = []\n", + " for u, unit in enumerate(UNITS):\n", + " base = [0.3, -0.1, 0.0][u]\n", + " for i in range(n_per_unit):\n", + " off = rng.normal(0, 0.5, len(AREAS)) + (np.array([0, 0, 0, -0.6, 0]) if u == 1 else 0)\n", + " out.append({\"sid\": f\"S{len(out) + 1:03d}\", \"unit\": unit, \"theta\": dict(zip(AREAS, base + rng.normal(0, 0.6) + off)),\n", + " \"tend\": dict(zip(ETYPES, rng.dirichlet([1.2, 1.2, 1.2])))})\n", + " return out\n", + "\n", + "\n", + "def p_correct(theta, diff):\n", + " return 1 / (1 + np.exp(-1.7 * (theta - (diff - 2) * 0.9 + 0.9)))\n", + "\n", + "\n", + "def _typo(s, rng):\n", + " if len(s) < 2:\n", + " return s\n", + " i = int(rng.integers(len(s) - 1))\n", + " return s[:i] + s[i + 1] + s[i] + s[i + 2:] if len(s) <= 3 or rng.random() < 0.5 else s[:i] + s[i + 1:]\n", + "\n", + "\n", + "def _fmt_mc(k, rng):\n", + " r = rng.random()\n", + " if r < 0.55:\n", + " return str(k)\n", + " return rng.choice([_CIRC[k - 1], f\"{k}번\", \"ABCD\"[k - 1], \"abcd\"[k - 1] + \")\", f\" {k} \", chr(0xFF10 + k), f\"{k}.\"])\n", + "\n", + "\n", + "def _fmt_sa(s, rng):\n", + " r = rng.random()\n", + " if r < 0.45:\n", + " return s\n", + " if r < 0.6:\n", + " return \" \".join(s) if len(s) <= 4 else s[:2] + \" \" + s[2:]\n", + " if r < 0.72:\n", + " return s + rng.choice([\"입니다\", \"이다\", \".\", \"임\"])\n", + " if r < 0.8:\n", + " return s.upper() if s.isascii() else \"\".join(chr(ord(ch) + 0xFEE0) if \"!\" <= ch <= \"~\" else ch for ch in s) + \" \"\n", + " return _typo(s, rng)\n", + "\n", + "\n", + "def answer(q, sol, rng):\n", + " \"\"\"한 문항 응답: (원본 응답 문자열, 의도한 정답 여부, 의도한 답, 오답 유형).\"\"\"\n", + " ok = rng.random() < p_correct(sol[\"theta\"][q[\"area\"]], q[\"diff\"])\n", + " if q[\"type\"] == \"MC\":\n", + " if ok:\n", + " k, et = q[\"answer\"], None\n", + " else:\n", + " ks = list(q[\"dist\"]); w = np.array([sol[\"tend\"][q[\"dist\"][k]] for k in ks])\n", + " k = int(rng.choice(ks, p=w / w.sum())); et = q[\"dist\"][k]\n", + " return _fmt_mc(k, rng), ok, str(k), et\n", + " if q[\"type\"] == \"SA\":\n", + " if ok and rng.random() < 0.06:\n", + " return PARA[q[\"qid\"]], ok, PARA[q[\"qid\"]], None\n", + " if ok:\n", + " s, et = str(rng.choice(q[\"accept\"])), None\n", + " else:\n", + " ws = list(q[\"wrong\"]); w = np.array([sol[\"tend\"][q[\"wrong\"][x]] for x in ws])\n", + " s = str(rng.choice(ws, p=w / w.sum())); et = q[\"wrong\"][s]\n", + " return _fmt_sa(s, rng), ok, s, et\n", + " if ok:\n", + " s = \"ABCD\"\n", + " elif rng.random() < 0.5:\n", + " s = \"ABCD\".replace(\"ABCD\"[rng.integers(1, 4)], \"\") # 단계 하나 빠뜨림\n", + " else:\n", + " i = int(rng.integers(0, 3)); s = list(\"ABCD\"); s[i], s[i + 1] = s[i + 1], s[i]; s = \"\".join(s) # 순서 뒤바뀜\n", + " raw = s if rng.random() < 0.6 else rng.choice([\"-\".join(s), \" \".join(s), s.lower(), \",\".join(s)])\n", + " return raw, ok, s, (None if ok else P)\n", + "\n", + "\n", + "def make_responses(n_per_unit=40, seed=7, blank=0.02):\n", + " \"\"\"응시자 x 문항 응답표(DataFrame)를 만든다. truth_* 열은 교관 기준 정답(평가용)이다.\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " rows = []\n", + " for sol in make_soldiers(n_per_unit, seed):\n", + " for q in BANK:\n", + " raw, ok, intended, et = answer(q, sol, rng)\n", + " if rng.random() < blank:\n", + " raw, ok, intended, et = \"\", False, \"\", None\n", + " rows.append({\"rid\": f\"R{len(rows):05d}\", \"sid\": sol[\"sid\"], \"unit\": sol[\"unit\"], \"qid\": q[\"qid\"], \"raw\": raw,\n", + " \"truth_correct\": ok, \"truth_answer\": intended, \"truth_etype\": et})\n", + " return pd.DataFrame(rows)\n", + "# ---------------------------------------------------------------------------\n", + "\n", + "# ---- 응답 정제·파싱·채점 공통 함수 ----\n", + "TAILS = (\"입니다\", \"이다\", \"임\")\n", + "\n", + "\n", + "def norm_text(s):\n", + " \"\"\"전각->반각(NFKC), 소문자, 공백·문장부호 제거, 끝맺음 어미 제거.\"\"\"\n", + " s = unicodedata.normalize(\"NFKC\", str(s)).strip().lower()\n", + " s = re.sub(r\"[\\s\\.\\,\\-\\(\\)·]\", \"\", s)\n", + " for t in TAILS:\n", + " if s.endswith(t) and len(s) > len(t) + 1:\n", + " s = s[:-len(t)]\n", + " return s\n", + "\n", + "\n", + "def parse(q, raw):\n", + " \"\"\"원본 응답 -> (정제값, 상태). 상태: 정상 / 미응답 / 형식오류\"\"\"\n", + " if raw is None or str(raw).strip() == \"\":\n", + " return \"\", \"미응답\"\n", + " s = unicodedata.normalize(\"NFKC\", str(raw)).strip()\n", + " if q[\"type\"] == \"MC\":\n", + " t = re.sub(r\"[\\s번\\.\\)]\", \"\", s).lower()\n", + " t = {\"a\": \"1\", \"b\": \"2\", \"c\": \"3\", \"d\": \"4\"}.get(t, t)\n", + " return (t, \"정상\") if t in (\"1\", \"2\", \"3\", \"4\") else (t, \"형식오류\")\n", + " if q[\"type\"] == \"ORDER\":\n", + " t = re.sub(r\"[^A-Za-z]\", \"\", s).upper()\n", + " return (t, \"정상\") if t and set(t) <= set(\"ABCD\") and len(set(t)) == len(t) else (t, \"형식오류\")\n", + " return norm_text(s), \"정상\"\n", + "\n", + "\n", + "def grade_exact(q, val):\n", + " if q[\"type\"] == \"MC\":\n", + " return val == str(q[\"answer\"])\n", + " if q[\"type\"] == \"ORDER\":\n", + " return val == q[\"answer\"]\n", + " return val in {norm_text(a) for a in q[\"accept\"]}\n", + "\n", + "\n", + "def grade_fuzzy(q, val, cut=0.75):\n", + " \"\"\"단답형은 오탈자(글자 순서 바뀜·한 글자 누락)를 허용(유사도 cut 이상)하되, 알려진 오답과 더 가까우면 오답으로 본다.\"\"\"\n", + " if grade_exact(q, val) or q[\"type\"] != \"SA\" or len(val) < 2:\n", + " return grade_exact(q, val)\n", + " best = max(1.0 if sorted(val) == sorted(norm_text(a)) else difflib.SequenceMatcher(None, val, norm_text(a)).ratio() for a in q[\"accept\"])\n", + " worst = max(difflib.SequenceMatcher(None, val, norm_text(w)).ratio() for w in q[\"wrong\"])\n", + " return best >= cut and best > worst\n", + "\n", + "\n", + "def score_of(q, correct):\n", + " return POINTS[q[\"diff\"]] if correct else 0\n", + "\n", + "# ---- 오답 유형 분류기(규칙 + 문자 n-gram 로지스틱 회귀) ----\n", + "SEED = {\"피아 오인\": [\"적기\", \"적 드론\", \"아군기\", \"적 헬기\", \"적기판정\", \"헬기\", \"적 항공기 확정\", \"아군 드론\"],\n", + " \"절차 누락\": [\"생략\", \"인접부대\", \"개인메신저\", \"보고 안 함\", \"운용병\", \"미확인기\", \"단계 건너뜀\", \"직접 전파\"],\n", + " \"개념 혼동\": [\"직선익\", \"후퇴익\", \"헥사콥터\", \"미탐\", \"재경보\", \"확정\", \"확인\", \"레이더\", \"AI\", \"표적획득\", \"경계\", \"통신망\", \"고정익\"]}\n", + "\n", + "\n", + "def build_text_model():\n", + " texts, labels = [], []\n", + " for q in BANK:\n", + " if q[\"type\"] == \"SA\":\n", + " for w, et in q[\"wrong\"].items():\n", + " texts.append(w); labels.append(et)\n", + " for et, ws in SEED.items():\n", + " texts += ws; labels += [et] * len(ws)\n", + " m = make_pipeline(TfidfVectorizer(analyzer=\"char_wb\", ngram_range=(1, 3)), LogisticRegression(max_iter=2000, C=5))\n", + " return m.fit([norm_text(t) for t in texts], labels)\n", + "\n", + "\n", + "def classify_error(q, val, text_model):\n", + " \"\"\"오답 1건의 유형과 판단 근거(rule/text)를 돌려준다.\"\"\"\n", + " if q[\"type\"] == \"MC\":\n", + " return q[\"dist\"].get(int(val), \"개념 혼동\") if val in \"1234\" and val else \"개념 혼동\", \"보기 매핑\"\n", + " if q[\"type\"] == \"ORDER\":\n", + " return \"절차 누락\", (\"단계 누락\" if len(val) < 4 else \"순서 뒤바뀜\")\n", + " known = {norm_text(w): et for w, et in q[\"wrong\"].items()}\n", + " if val in known:\n", + " return known[val], \"오답 목록\"\n", + " near = max(known, key=lambda w: difflib.SequenceMatcher(None, val, w).ratio())\n", + " if difflib.SequenceMatcher(None, val, near).ratio() >= 0.75 or sorted(val) == sorted(near):\n", + " return known[near], \"오답 목록(오탈자)\"\n", + " return str(text_model.predict([val])[0]), \"문자 모델\"\n", + "\n", + "# [교육용가상자료] 인정 답안·오답 목록에 없는 새로운 오답 표현(문자 모델 시험용)\n", + "NOVEL = [(\"Q21\", \"적기 확정\", \"피아 오인\"), (\"Q21\", \"표적 추적\", \"개념 혼동\"), (\"Q22\", \"직선 날개\", \"개념 혼동\"), (\"Q23\", \"확실함\", \"개념 혼동\"),\n", + " (\"Q23\", \"보고 안함\", \"절차 누락\"), (\"Q24\", \"자동 판정\", \"개념 혼동\"), (\"Q24\", \"관측 운용병\", \"절차 누락\"), (\"Q25\", \"미탐지\", \"개념 혼동\"),\n", + " (\"Q25\", \"적 드론\", \"피아 오인\"), (\"Q26\", \"적 정찰기\", \"피아 오인\"), (\"Q26\", \"보고 생략\", \"절차 누락\"), (\"Q27\", \"고정익 드론\", \"개념 혼동\"),\n", + " (\"Q27\", \"적 헬기\", \"피아 오인\"), (\"Q28\", \"인접 부대 직보\", \"절차 누락\"), (\"Q28\", \"단체 메신저\", \"절차 누락\"), (\"Q21\", \"아군기\", \"피아 오인\"),\n", + " (\"Q23\", \"대충 보고\", \"절차 누락\"), (\"Q22\", \"날개 없음\", \"개념 혼동\"), (\"Q26\", \"미확인 표적으로 대응\", \"피아 오인\"),\n", + " (\"Q24\", \"분대원 누구나\", \"절차 누락\"), (\"Q25\", \"정상 경보\", \"개념 혼동\"), (\"Q27\", \"정찰 헬기\", \"피아 오인\")]\n", + "\n", + "model = build_text_model()\n", + "df = make_responses(40, seed=7)\n", + "df[\"type\"] = df[\"qid\"].map(lambda q: QIDX[q][\"type\"])\n", + "df[\"val\"] = [parse(QIDX[q], r)[0] for q, r in zip(df[\"qid\"], df[\"raw\"])]\n", + "wrong = df[df[\"truth_etype\"].notna()].copy()\n", + "\n", + "t0 = time.perf_counter()\n", + "out = [classify_error(QIDX[q], v, model) for q, v in zip(wrong[\"qid\"], wrong[\"val\"])]\n", + "ms = (time.perf_counter() - t0) * 1000 / len(wrong)\n", + "wrong[\"pred\"], wrong[\"basis\"] = [o[0] for o in out], [o[1] for o in out]\n", + "\n", + "# 복합 오답 시나리오 10종\n", + "rng = np.random.default_rng(5)\n", + "sa_typo = wrong[(wrong[\"type\"] == \"SA\")].copy()\n", + "sa_typo[\"val\"] = [norm_text(_typo(v, rng)) if len(v) >= 3 else v for v in sa_typo[\"truth_answer\"]]\n", + "sa_typo[\"pred\"] = [classify_error(QIDX[q], v, model)[0] for q, v in zip(sa_typo[\"qid\"], sa_typo[\"val\"])]\n", + "nov = pd.DataFrame(NOVEL, columns=[\"qid\", \"val\", \"truth_etype\"])\n", + "nov[\"val\"] = nov[\"val\"].map(norm_text)\n", + "nov[\"pred\"] = [classify_error(QIDX[q], v, model)[0] for q, v in zip(nov[\"qid\"], nov[\"val\"])]\n", + "raw_fmt = wrong[\"raw\"].str.strip() != wrong[\"val\"]\n", + "SCEN = {\n", + " \"S1 선다형 표준 표기\": wrong[(wrong[\"type\"] == \"MC\") & ~raw_fmt], \"S2 선다형 원문자·전각 표기\": wrong[(wrong[\"type\"] == \"MC\") & raw_fmt],\n", + " \"S3 단답 오답 목록 일치\": wrong[(wrong[\"type\"] == \"SA\") & (wrong[\"basis\"] == \"오답 목록\")], \"S4 단답 오답 + 오탈자\": sa_typo,\n", + " \"S5 단답 오답 + 어미·띄어쓰기\": wrong[(wrong[\"type\"] == \"SA\") & raw_fmt], \"S6 순서형 단계 누락\": wrong[(wrong[\"type\"] == \"ORDER\") & (wrong[\"val\"].str.len() < 4)],\n", + " \"S7 순서형 순서 뒤바뀜\": wrong[(wrong[\"type\"] == \"ORDER\") & (wrong[\"val\"].str.len() == 4)], \"S8 순서형 구분자 혼입\": wrong[(wrong[\"type\"] == \"ORDER\") & raw_fmt],\n", + " \"S9 목록에 없는 새 오답\": nov, \"S10 전체 혼합\": pd.concat([wrong, sa_typo, nov]),\n", + "}\n", + "rows = []\n", + "for name, d in SCEN.items():\n", + " rec = {et: (d[d[\"truth_etype\"] == et][\"pred\"] == et).mean() if (d[\"truth_etype\"] == et).any() else np.nan for et in ETYPES}\n", + " rows.append({\"시나리오\": name, \"건수\": len(d), \"정확도\": (d[\"pred\"] == d[\"truth_etype\"]).mean(), **{f\"{k} 재현율\": v for k, v in rec.items()}})\n", + "res = pd.DataFrame(rows)\n", + "res[\"통과\"] = np.where(res[\"정확도\"] >= 0.85, \"PASS\", \"FAIL\")\n", + "print(res.round(3).fillna(\"-\").to_string(index=False))\n", + "allr = SCEN[\"S10 전체 혼합\"]\n", + "recs = {et: (allr[allr[\"truth_etype\"] == et][\"pred\"] == et).mean() for et in ETYPES}\n", + "print(\"\\n오답 유형별 재현율:\", {k: round(float(v), 3) for k, v in recs.items()},\n", + " f\"(목표 85% 이상: {'달성' if min(recs.values()) >= 0.85 else '미달'}), 건당 처리 {ms:.3f} ms (목표 200 ms 이하)\")\n", + "print(f\"시나리오 통과 {(res['통과'] == 'PASS').sum()}/10\")\n", + "fa = nov[nov[\"pred\"] != nov[\"truth_etype\"]]\n", + "print(\"새 오답 오분류(특징 재설계 대상):\", [f\"{v}->{p}\" for v, p in zip(fa[\"val\"], fa[\"pred\"])])\n", + "\n", + "# 오답 패턴 분석: 개인별 주된 오답 유형, 중대별 분포\n", + "dom = wrong.groupby(\"sid\")[\"pred\"].agg(lambda s: s.value_counts().idxmax())\n", + "print(\"\\n개인별 주된 오답 유형 분포:\", dom.value_counts().to_dict())\n", + "print(pd.crosstab(wrong[\"unit\"], wrong[\"pred\"], normalize=\"index\").round(2).to_string())\n", + "\n", + "ct = pd.crosstab(wrong[\"qid\"].map(lambda q: QIDX[q][\"area\"]), wrong[\"pred\"])\n", + "ct.index = ct.index.map({\"형상식별\": \"Shape ID\", \"피아식별\": \"Friend/Foe\", \"경보보고\": \"Alert/Report\", \"교전통제\": \"Control\", \"대드론일반\": \"C-UAS basics\"})\n", + "ct.columns = ct.columns.map({\"개념 혼동\": \"concept\", \"피아 오인\": \"friend/foe\", \"절차 누락\": \"procedure\"})\n", + "ct.plot(kind=\"barh\", stacked=True, figsize=(8, 4)); plt.xlabel(\"wrong answers\"); plt.title(\"Error types by area (synthetic)\"); plt.tight_layout(); plt.show()\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 04 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + " 시나리오 건수 정확도 개념 혼동 재현율 피아 오인 재현율 절차 누락 재현율 통과\n", + " S1 선다형 표준 표기 367 1.000 1.0 1.0 1.000 PASS\n", + " S2 선다형 원문자·전각 표기 235 1.000 1.0 1.0 1.000 PASS\n", + " S3 단답 오답 목록 일치 120 1.000 1.0 1.0 1.000 PASS\n", + " S4 단답 오답 + 오탈자 148 1.000 1.0 1.0 1.000 PASS\n", + "S5 단답 오답 + 어미·띄어쓰기 55 1.000 1.0 1.0 1.000 PASS\n", + " S6 순서형 단계 누락 90 1.000 - - 1.000 PASS\n", + " S7 순서형 순서 뒤바뀜 94 1.000 - - 1.000 PASS\n", + " S8 순서형 구분자 혼입 81 1.000 - - 1.000 PASS\n", + " S9 목록에 없는 새 오답 22 0.864 1.0 0.857 0.714 PASS\n", + " S10 전체 혼합 1104 0.997 1.0 0.995 0.996 PASS\n", + "\n", + "오답 유형별 재현율: {'개념 혼동': 1.0, '피아 오인': 0.995, '절차 누락': 0.996} (목표 85% 이상: 달성), 건당 처리 0.005 ms (목표 200 ms 이하)\n", + "시나리오 통과 10/10\n", + "새 오답 오분류(특징 재설계 대상): ['대충보고->개념 혼동', '미확인표적으로대응->절차 누락', '분대원누구나->개념 혼동']\n", + "\n", + "개인별 주된 오답 유형 분포: {'절차 누락': 60, '개념 혼동': 42, '피아 오인': 12}\n", + "pred 개념 혼동 절차 누락 피아 오인\n", + "unit \n", + "가상1중대 0.36 0.46 0.18\n", + "가상2중대 0.40 0.41 0.19\n", + "가상3중대 0.34 0.49 0.17\n", + "```\n", + "
\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 05. 개인별/부대별 방공 취약 영역 식별 및 맞춤형 보충 학습 추천 알고리즘 개발\n", + "**소요시간**: 약 3.0시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "개인별/부대별 방공 취약 영역 식별 및 맞춤형 보충 학습 추천 알고리즘을 개발한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **채점 결과로 개인별·중대별 방공 취약 영역을 찾고, 불필요한 추천을 줄인 맞춤형 보충 학습 추천 알고리즘을 개발한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **문항 난이도를 반영한 베이즈 추정으로 영역별 '취약할 확률'을 계산하고 High/Medium/Low 신뢰도 등급을 부여 수행하고 검증하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `src/verification/trn02_confidence_engine.py` 형태의 결과물을 완성한다.\n", + "\n", + "### 🔍 평가 및 검증 기준 (PASS 조건)\n", + "#### 미션 판정\n", + "\n", + "**평가 구분:** 필수\n", + "\n", + "**핵심 평가영역:** 신뢰도 등급(High/Med/Low) 부여 기준의 군사적 타당성\n", + "\n", + "#### 미션 PASS 조건\n", + "\n", + "- 정량 기준: 오경보율(FAR) 40% 이상 감축, 신뢰도 스코어 산출 일관성 90% 이상\n", + "\n", + "\n", + "- 진행 기준: 오경보 필터링 전후 비교 지표 산출 및 통합 테스트 완료\n", + "\n", + "\n", + "- 검증 방법: ROC-AUC 곡선 분석 및 임계값 민감도 테스트\n", + "\n", + "\n", + "#### 채점자 판정 메모\n", + "\n", + "> 단순 코드 실행 여부뿐만 아니라 출력된 수치와 근거 자료가 군 실무 요구사항에 부합하는지 면밀히 확인한다.\n", + "\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "\"\"\"채점 결과로 개인별·중대별 방공 취약 영역을 찾아 보충 학습을 추천하는 알고리즘을 개발한다. 문항 난이도를 반영한 베이즈 추정으로 영역별 '취약할 확률'과 신뢰도 등급을 산출하고, 틀린 문항이 있으면 모두 추천하던 방식 대비 불필요한 추천(오경보) 감축·결정 일관성·ROC-AUC를 검증한다.\"\"\"\n", + "import difflib\n", + "import re\n", + "import unicodedata\n", + "import numpy as np\n", + "import pandas as pd\n", + "import matplotlib.pyplot as plt\n", + "from scipy import stats\n", + "from sklearn.metrics import roc_auc_score\n", + "\n", + "# ---------------------------------------------------------------------------\n", + "# [교육용가상자료] 방공·드론 식별 퀴즈 문항 은행과 가상 응시자 응답 생성기\n", + "# 문항은 일반 원칙 수준의 가상 문항이며 실제 교범·교전규칙 원문이 아니다. 응시자는 가명(S001~)이다.\n", + "# ---------------------------------------------------------------------------\n", + "AREAS = [\"형상식별\", \"피아식별\", \"경보보고\", \"교전통제\", \"대드론일반\"]\n", + "ETYPES = [\"개념 혼동\", \"피아 오인\", \"절차 누락\"]\n", + "POINTS = {1: 2, 2: 3, 3: 5}\n", + "C, F, P = ETYPES\n", + "\n", + "\n", + "def _mc(qid, area, diff, stem, opts, ans, dist):\n", + " return {\"qid\": qid, \"type\": \"MC\", \"area\": area, \"diff\": diff, \"stem\": stem, \"options\": opts, \"answer\": ans,\n", + " \"dist\": dict(zip([i for i in range(1, 5) if i != ans], dist))}\n", + "\n", + "\n", + "def _sa(qid, area, diff, stem, accept, wrong):\n", + " return {\"qid\": qid, \"type\": \"SA\", \"area\": area, \"diff\": diff, \"stem\": stem, \"accept\": accept, \"wrong\": wrong}\n", + "\n", + "\n", + "def _order(qid, area, diff, stem):\n", + " return {\"qid\": qid, \"type\": \"ORDER\", \"area\": area, \"diff\": diff, \"stem\": stem, \"answer\": \"ABCD\"}\n", + "\n", + "\n", + "BANK = [\n", + " _mc(\"Q01\", \"형상식별\", 1, \"꼬리날개 없이 삼각형 날개를 가진 소형 고정익 실루엣은?\", [\"쿼드콥터형\", \"삼각익 고정익형\", \"회전익 헬기형\", \"조류\"], 2, [C, C, F]),\n", + " _mc(\"Q02\", \"형상식별\", 1, \"회전날개 4개가 십자로 배치된 실루엣은?\", [\"고정익 드론형\", \"쿼드콥터형\", \"순항미사일형\", \"탄도미사일형\"], 2, [C, C, C]),\n", + " _mc(\"Q03\", \"형상식별\", 2, \"가늘고 긴 동체 중간에 작은 날개가 붙은 실루엣은?\", [\"순항미사일형\", \"탄도미사일형\", \"민간 경비행기형\", \"조류\"], 1, [C, F, F]),\n", + " _mc(\"Q04\", \"형상식별\", 3, \"원거리라 실루엣 식별이 어려울 때 먼저 할 행동은?\", [\"추정 유형으로 확정 보고\", \"관측 정보를 그대로 보고하고 추가 관측\", \"관측 중단\", \"민간기로 간주\"], 2, [P, P, F]),\n", + " _mc(\"Q05\", \"피아식별\", 2, \"식별 신호 응답이 없는 항공기에 대한 올바른 판단은?\", [\"즉시 적기로 확정\", \"미확인 항공기로 분류하고 추가 확인\", \"아군기로 간주\", \"무시\"], 2, [F, F, P]),\n", + " _mc(\"Q06\", \"피아식별\", 2, \"운항 정보와 일치하는 저속 비행체의 분류는?\", [\"적 드론\", \"민간기 가능성 높음, 추가 확인\", \"순항미사일\", \"아군 정찰기로 확정\"], 2, [F, C, F]),\n", + " _mc(\"Q07\", \"피아식별\", 3, \"아군 드론 운용 공지가 있는 공역에서 미확인 드론을 보면 우선 확인할 것은?\", [\"기상 정보\", \"아군 운용 계획과 식별 신호\", \"대응 준비 상태\", \"주변 지형\"], 2, [C, P, C]),\n", + " _mc(\"Q08\", \"피아식별\", 3, \"조류 무리와 소형 드론 군집 구분에 가장 도움이 되는 정보는?\", [\"크기만 비교\", \"궤적의 규칙성과 열 신호\", \"색상\", \"관측 시각\"], 2, [C, C, C]),\n", + " _mc(\"Q09\", \"경보보고\", 1, \"공중 위협 최초 보고에 포함하지 않아도 되는 것은?\", [\"관측 시각\", \"방향과 거리\", \"추정 유형\", \"관측자 개인 연락처\"], 4, [P, P, P]),\n", + " _mc(\"Q10\", \"경보보고\", 2, \"경보 전파 후 상황이 바뀌면?\", [\"최초 보고 유지\", \"변경 사항을 즉시 추가 보고\", \"상황 종료 후 일괄 보고\", \"개인 판단으로 경보 해제\"], 2, [P, P, P]),\n", + " _mc(\"Q11\", \"경보보고\", 2, \"보고 내용이 불확실할 때 올바른 표현은?\", [\"확정 표현으로 보고\", \"'추정'임을 밝히고 근거와 함께 보고\", \"보고 보류\", \"다른 관측자 보고를 복사\"], 2, [C, P, P]),\n", + " _mc(\"Q12\", \"경보보고\", 1, \"드론 탐지 후 보고 경로로 알맞은 것은?\", [\"인접 부대에만 전파\", \"지휘계통을 따라 보고\", \"개인 메신저로 공유\", \"보고 없이 대응\"], 2, [P, P, P]),\n", + " _mc(\"Q13\", \"교전통제\", 1, \"AI 탐지 결과만으로 대응을 결정할 수 있는가?\", [\"가능하다\", \"인간 지휘관의 승인이 필요하다\", \"신뢰도 90% 이상이면 가능\", \"야간에만 가능\"], 2, [C, C, C]),\n", + " _mc(\"Q14\", \"교전통제\", 2, \"민간 피해 가능성이 확인되면?\", [\"그대로 진행\", \"즉시 보고하고 지휘관 판단을 받는다\", \"조치 후 보고\", \"무시\"], 2, [P, P, F]),\n", + " _mc(\"Q15\", \"교전통제\", 3, \"식별이 불확실한 표적에 대한 원칙은?\", [\"위협으로 간주해 즉시 대응\", \"식별 확인 전까지 승인 절차를 따른다\", \"무조건 무시\", \"다수결로 결정\"], 2, [F, F, C]),\n", + " _mc(\"Q16\", \"교전통제\", 2, \"판단 근거를 기록해야 하는 이유는?\", [\"형식상 필요\", \"사후 검토와 책임 추적\", \"보고 생략\", \"기록 불필요\"], 2, [C, P, P]),\n", + " _mc(\"Q17\", \"대드론일반\", 1, \"대드론 체계의 일반적 처리 순서는?\", [\"대응-탐지-식별\", \"탐지-식별-보고·판단-대응\", \"식별-대응-탐지\", \"보고-탐지-대응\"], 2, [P, P, P]),\n", + " _mc(\"Q18\", \"대드론일반\", 3, \"레이더만으로 조류와 드론 구분이 어려운 이유는?\", [\"반사 신호 크기가 비슷할 수 있어서\", \"레이더는 야간에 못 써서\", \"드론에는 금속이 없어서\", \"조류는 탐지되지 않아서\"], 1, [C, C, C]),\n", + " _mc(\"Q19\", \"대드론일반\", 2, \"전자광학(EO) 센서의 약점이 커지는 조건은?\", [\"맑은 주간\", \"안개·야간\", \"저속 표적\", \"근거리\"], 2, [C, C, C]),\n", + " _mc(\"Q20\", \"대드론일반\", 2, \"경보가 반복해서 오경보로 판명되면?\", [\"경보 장치를 끈다\", \"사례를 기록·보고하고 기준 재검토 요청\", \"무시한다\", \"모두 실제 위협으로 간주\"], 2, [P, P, F]),\n", + " _sa(\"Q21\", \"피아식별\", 1, \"아군·적·민간을 구분하는 절차를 무엇이라 하는가?\", [\"피아식별\", \"IFF\"], {\"표적획득\": C, \"적기판정\": F, \"경계\": C}),\n", + " _sa(\"Q22\", \"형상식별\", 2, \"꼬리날개 없는 삼각형 날개 형태를 무엇이라 하는가?\", [\"삼각익\", \"델타익\", \"delta\"], {\"직선익\": C, \"후퇴익\": C, \"쿼드콥터\": C}),\n", + " _sa(\"Q23\", \"경보보고\", 2, \"불확실한 관측을 보고할 때 붙이는 표현은?\", [\"추정\"], {\"확정\": C, \"확인\": C, \"생략\": P}),\n", + " _sa(\"Q24\", \"교전통제\", 1, \"AI 탐지 결과에 대해 최종 결정을 내리는 주체는?\", [\"지휘관\"], {\"AI\": C, \"운용병\": P, \"레이더\": C}),\n", + " _sa(\"Q25\", \"대드론일반\", 1, \"조류를 드론으로 잘못 경보하는 것을 무엇이라 하는가?\", [\"오경보\", \"오탐\", \"false alarm\"], {\"미탐\": C, \"재경보\": C, \"적기\": F}),\n", + " _sa(\"Q26\", \"피아식별\", 2, \"운항 정보가 확인된 여객기는 어떻게 분류하는가?\", [\"민간기\", \"민항기\"], {\"적기\": F, \"미확인기\": P, \"아군기\": F}),\n", + " _sa(\"Q27\", \"형상식별\", 1, \"회전날개 4개를 가진 드론 형태는?\", [\"쿼드콥터\", \"quadcopter\"], {\"헥사콥터\": C, \"고정익\": C, \"헬기\": F}),\n", + " _sa(\"Q28\", \"경보보고\", 1, \"보고는 무엇을 따라 올라가는가?\", [\"지휘계통\"], {\"인접부대\": P, \"개인메신저\": P, \"통신망\": C}),\n", + " _order(\"Q29\", \"경보보고\", 2, \"최초 보고 순서: A 관측 시각, B 방향·거리, C 추정 유형, D 관측자 조치\"),\n", + " _order(\"Q30\", \"대드론일반\", 2, \"처리 순서: A 탐지, B 식별, C 보고·판단, D 대응\"),\n", + " _order(\"Q31\", \"교전통제\", 3, \"통제 순서: A 식별 확인, B 상급 승인 요청, C 승인 수령, D 조치 기록\"),\n", + " _order(\"Q32\", \"피아식별\", 3, \"확인 순서: A 운항정보 대조, B 식별 신호 확인, C 미확인 분류, D 보고\"),\n", + "]\n", + "QIDX = {q[\"qid\"]: q for q in BANK}\n", + "UNITS = [\"가상1중대\", \"가상2중대\", \"가상3중대\"]\n", + "_CIRC = \"①②③④\"\n", + "PARA = {\"Q21\": \"아군적군 구분\", \"Q22\": \"세모 날개\", \"Q23\": \"추정됨으로 표기\", \"Q24\": \"중대장(지휘관)\", \"Q25\": \"잘못된 경보\",\n", + " \"Q26\": \"민간 여객기\", \"Q27\": \"4발 드론\", \"Q28\": \"지휘 라인\"} # 뜻은 맞지만 인정 답안 목록에 없는 표현\n", + "\n", + "\n", + "def make_soldiers(n_per_unit, seed):\n", + " rng = np.random.default_rng(seed)\n", + " out = []\n", + " for u, unit in enumerate(UNITS):\n", + " base = [0.3, -0.1, 0.0][u]\n", + " for i in range(n_per_unit):\n", + " off = rng.normal(0, 0.5, len(AREAS)) + (np.array([0, 0, 0, -0.6, 0]) if u == 1 else 0)\n", + " out.append({\"sid\": f\"S{len(out) + 1:03d}\", \"unit\": unit, \"theta\": dict(zip(AREAS, base + rng.normal(0, 0.6) + off)),\n", + " \"tend\": dict(zip(ETYPES, rng.dirichlet([1.2, 1.2, 1.2])))})\n", + " return out\n", + "\n", + "\n", + "def p_correct(theta, diff):\n", + " return 1 / (1 + np.exp(-1.7 * (theta - (diff - 2) * 0.9 + 0.9)))\n", + "\n", + "\n", + "def _typo(s, rng):\n", + " if len(s) < 2:\n", + " return s\n", + " i = int(rng.integers(len(s) - 1))\n", + " return s[:i] + s[i + 1] + s[i] + s[i + 2:] if len(s) <= 3 or rng.random() < 0.5 else s[:i] + s[i + 1:]\n", + "\n", + "\n", + "def _fmt_mc(k, rng):\n", + " r = rng.random()\n", + " if r < 0.55:\n", + " return str(k)\n", + " return rng.choice([_CIRC[k - 1], f\"{k}번\", \"ABCD\"[k - 1], \"abcd\"[k - 1] + \")\", f\" {k} \", chr(0xFF10 + k), f\"{k}.\"])\n", + "\n", + "\n", + "def _fmt_sa(s, rng):\n", + " r = rng.random()\n", + " if r < 0.45:\n", + " return s\n", + " if r < 0.6:\n", + " return \" \".join(s) if len(s) <= 4 else s[:2] + \" \" + s[2:]\n", + " if r < 0.72:\n", + " return s + rng.choice([\"입니다\", \"이다\", \".\", \"임\"])\n", + " if r < 0.8:\n", + " return s.upper() if s.isascii() else \"\".join(chr(ord(ch) + 0xFEE0) if \"!\" <= ch <= \"~\" else ch for ch in s) + \" \"\n", + " return _typo(s, rng)\n", + "\n", + "\n", + "def answer(q, sol, rng):\n", + " \"\"\"한 문항 응답: (원본 응답 문자열, 의도한 정답 여부, 의도한 답, 오답 유형).\"\"\"\n", + " ok = rng.random() < p_correct(sol[\"theta\"][q[\"area\"]], q[\"diff\"])\n", + " if q[\"type\"] == \"MC\":\n", + " if ok:\n", + " k, et = q[\"answer\"], None\n", + " else:\n", + " ks = list(q[\"dist\"]); w = np.array([sol[\"tend\"][q[\"dist\"][k]] for k in ks])\n", + " k = int(rng.choice(ks, p=w / w.sum())); et = q[\"dist\"][k]\n", + " return _fmt_mc(k, rng), ok, str(k), et\n", + " if q[\"type\"] == \"SA\":\n", + " if ok and rng.random() < 0.06:\n", + " return PARA[q[\"qid\"]], ok, PARA[q[\"qid\"]], None\n", + " if ok:\n", + " s, et = str(rng.choice(q[\"accept\"])), None\n", + " else:\n", + " ws = list(q[\"wrong\"]); w = np.array([sol[\"tend\"][q[\"wrong\"][x]] for x in ws])\n", + " s = str(rng.choice(ws, p=w / w.sum())); et = q[\"wrong\"][s]\n", + " return _fmt_sa(s, rng), ok, s, et\n", + " if ok:\n", + " s = \"ABCD\"\n", + " elif rng.random() < 0.5:\n", + " s = \"ABCD\".replace(\"ABCD\"[rng.integers(1, 4)], \"\") # 단계 하나 빠뜨림\n", + " else:\n", + " i = int(rng.integers(0, 3)); s = list(\"ABCD\"); s[i], s[i + 1] = s[i + 1], s[i]; s = \"\".join(s) # 순서 뒤바뀜\n", + " raw = s if rng.random() < 0.6 else rng.choice([\"-\".join(s), \" \".join(s), s.lower(), \",\".join(s)])\n", + " return raw, ok, s, (None if ok else P)\n", + "\n", + "\n", + "def make_responses(n_per_unit=40, seed=7, blank=0.02):\n", + " \"\"\"응시자 x 문항 응답표(DataFrame)를 만든다. truth_* 열은 교관 기준 정답(평가용)이다.\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " rows = []\n", + " for sol in make_soldiers(n_per_unit, seed):\n", + " for q in BANK:\n", + " raw, ok, intended, et = answer(q, sol, rng)\n", + " if rng.random() < blank:\n", + " raw, ok, intended, et = \"\", False, \"\", None\n", + " rows.append({\"rid\": f\"R{len(rows):05d}\", \"sid\": sol[\"sid\"], \"unit\": sol[\"unit\"], \"qid\": q[\"qid\"], \"raw\": raw,\n", + " \"truth_correct\": ok, \"truth_answer\": intended, \"truth_etype\": et})\n", + " return pd.DataFrame(rows)\n", + "# ---------------------------------------------------------------------------\n", + "\n", + "# ---- 응답 정제·파싱·채점 공통 함수 ----\n", + "TAILS = (\"입니다\", \"이다\", \"임\")\n", + "\n", + "\n", + "def norm_text(s):\n", + " \"\"\"전각->반각(NFKC), 소문자, 공백·문장부호 제거, 끝맺음 어미 제거.\"\"\"\n", + " s = unicodedata.normalize(\"NFKC\", str(s)).strip().lower()\n", + " s = re.sub(r\"[\\s\\.\\,\\-\\(\\)·]\", \"\", s)\n", + " for t in TAILS:\n", + " if s.endswith(t) and len(s) > len(t) + 1:\n", + " s = s[:-len(t)]\n", + " return s\n", + "\n", + "\n", + "def parse(q, raw):\n", + " \"\"\"원본 응답 -> (정제값, 상태). 상태: 정상 / 미응답 / 형식오류\"\"\"\n", + " if raw is None or str(raw).strip() == \"\":\n", + " return \"\", \"미응답\"\n", + " s = unicodedata.normalize(\"NFKC\", str(raw)).strip()\n", + " if q[\"type\"] == \"MC\":\n", + " t = re.sub(r\"[\\s번\\.\\)]\", \"\", s).lower()\n", + " t = {\"a\": \"1\", \"b\": \"2\", \"c\": \"3\", \"d\": \"4\"}.get(t, t)\n", + " return (t, \"정상\") if t in (\"1\", \"2\", \"3\", \"4\") else (t, \"형식오류\")\n", + " if q[\"type\"] == \"ORDER\":\n", + " t = re.sub(r\"[^A-Za-z]\", \"\", s).upper()\n", + " return (t, \"정상\") if t and set(t) <= set(\"ABCD\") and len(set(t)) == len(t) else (t, \"형식오류\")\n", + " return norm_text(s), \"정상\"\n", + "\n", + "\n", + "def grade_exact(q, val):\n", + " if q[\"type\"] == \"MC\":\n", + " return val == str(q[\"answer\"])\n", + " if q[\"type\"] == \"ORDER\":\n", + " return val == q[\"answer\"]\n", + " return val in {norm_text(a) for a in q[\"accept\"]}\n", + "\n", + "\n", + "def grade_fuzzy(q, val, cut=0.75):\n", + " \"\"\"단답형은 오탈자(글자 순서 바뀜·한 글자 누락)를 허용(유사도 cut 이상)하되, 알려진 오답과 더 가까우면 오답으로 본다.\"\"\"\n", + " if grade_exact(q, val) or q[\"type\"] != \"SA\" or len(val) < 2:\n", + " return grade_exact(q, val)\n", + " best = max(1.0 if sorted(val) == sorted(norm_text(a)) else difflib.SequenceMatcher(None, val, norm_text(a)).ratio() for a in q[\"accept\"])\n", + " worst = max(difflib.SequenceMatcher(None, val, norm_text(w)).ratio() for w in q[\"wrong\"])\n", + " return best >= cut and best > worst\n", + "\n", + "\n", + "def score_of(q, correct):\n", + " return POINTS[q[\"diff\"]] if correct else 0\n", + "\n", + "MASTERY = 0.6 # 영역 정답률이 이보다 낮으면 '취약'\n", + "HIGH, MEDIUM = 0.8, 0.4\n", + "GRID = np.linspace(-3, 3, 121)\n", + "PRIOR = stats.norm.pdf(GRID, 0, 1)\n", + "CUT = GRID[np.argmin(np.abs(p_correct(GRID, 2) - MASTERY))] # 중간 난이도 정답률 0.6에 해당하는 숙달도\n", + "\n", + "\n", + "def weak_prob(resp):\n", + " \"\"\"(난이도, 정답 여부) 목록 -> 숙달도 사후분포에서 CUT 미만일 확률. 어려운 문항 오답은 덜, 쉬운 문항 오답은 더 크게 반영된다.\"\"\"\n", + " post = PRIOR.copy()\n", + " for d, c in resp:\n", + " p = p_correct(GRID, d)\n", + " post *= p if c else 1 - p\n", + " return float(post[GRID < CUT].sum() / post.sum())\n", + "\n", + "\n", + "def area_table(df):\n", + " g = df.assign(d=df[\"qid\"].map(lambda q: QIDX[q][\"diff\"]))\n", + " t = g.groupby([\"sid\", \"unit\", \"area\"]).agg(wrong=(\"correct\", lambda s: int((~s).sum())),\n", + " resp=(\"d\", lambda s: list(zip(s, g.loc[s.index, \"correct\"])))).reset_index()\n", + " t[\"score\"] = [weak_prob(r) for r in t[\"resp\"]]\n", + " t[\"grade\"] = np.where(t[\"score\"] >= HIGH, \"High\", np.where(t[\"score\"] >= MEDIUM, \"Medium\", \"Low\"))\n", + " return t\n", + "\n", + "\n", + "df = make_responses(40, seed=7)\n", + "df[\"area\"] = df[\"qid\"].map(lambda q: QIDX[q][\"area\"])\n", + "df[\"correct\"] = [grade_fuzzy(QIDX[q], parse(QIDX[q], r)[0]) for q, r in zip(df[\"qid\"], df[\"raw\"])]\n", + "sol = {s[\"sid\"]: s for s in make_soldiers(40, 7)}\n", + "t = area_table(df)\n", + "t[\"truth_weak\"] = [p_correct(sol[s][\"theta\"][a], 2) < MASTERY for s, a in zip(t[\"sid\"], t[\"area\"])] # 가상 실제 숙달도(평가용)\n", + "\n", + "\n", + "def report(name, rec):\n", + " fa = (rec & ~t[\"truth_weak\"]).sum()\n", + " return {\"방식\": name, \"추천 수\": int(rec.sum()), \"불필요 추천\": int(fa), \"오경보율\": round(fa / max(rec.sum(), 1), 3),\n", + " \"취약 영역 재현율\": round((rec & t[\"truth_weak\"]).sum() / t[\"truth_weak\"].sum(), 3)}\n", + "\n", + "\n", + "before = report(\"기존(오답 1개라도 있으면 추천)\", t[\"wrong\"] >= 1)\n", + "after = report(\"난이도 반영 추정(Medium 이상)\", t[\"grade\"] != \"Low\")\n", + "print(f\"[교육용가상자료] 응시자 120명 x 영역 5개 = {len(t)}건, 실제 취약 {int(t['truth_weak'].sum())}건\")\n", + "print(pd.DataFrame([before, after]).to_string(index=False))\n", + "cut = 1 - after[\"오경보율\"] / before[\"오경보율\"]\n", + "retest = ((t[\"grade\"] == \"Low\") & (t[\"score\"] >= 0.2)).sum()\n", + "print(f\"Low 등급 중 취약 확률 0.2 이상 {retest}건은 추천 대신 '추가 문항 출제 후 재판단' 대상으로 둔다.\")\n", + "print(f\"\\n오경보율 감축 {cut:.1%} (목표 40% 이상: {'달성' if cut >= 0.4 else '미달'})\")\n", + "\n", + "# 일관성: 영역마다 응답 1건씩 빼고 다시 계산해도 추천 결정이 유지되는 비율\n", + "rng = np.random.default_rng(0)\n", + "drop = df.groupby([\"sid\", \"area\"]).sample(1, random_state=1).index\n", + "t2 = area_table(df.drop(drop))\n", + "cons = ((t2[\"grade\"] != \"Low\").values == (t[\"grade\"] != \"Low\").values).mean()\n", + "print(f\"추천 결정 일관성(응답 1건 제외 재계산) {cons:.1%} (목표 90% 이상: {'달성' if cons >= 0.9 else '미달'})\")\n", + "auc = roc_auc_score(t[\"truth_weak\"], t[\"score\"])\n", + "print(f\"취약 확률 점수 ROC-AUC {auc:.3f}\")\n", + "print(\"[임계값 민감도]\", \", \".join(f\"{m}: 오경보율 {report('', t['score'] >= m)['오경보율']:.2f}/재현율 {report('', t['score'] >= m)['취약 영역 재현율']:.2f}\" for m in [0.3, 0.4, 0.5, 0.7]))\n", + "\n", + "# 개인 보충 학습 추천 예시\n", + "s0 = t[t[\"grade\"] != \"Low\"].sort_values(\"score\", ascending=False)[\"sid\"].iloc[0]\n", + "print(f\"\\n[개인 추천 예시: {s0}]\")\n", + "for r in t[(t[\"sid\"] == s0) & (t[\"grade\"] != \"Low\")].itertuples():\n", + " wrong_q = df[(df[\"sid\"] == s0) & (df[\"area\"] == r.area) & ~df[\"correct\"]][\"qid\"].tolist()\n", + " easy = [q[\"qid\"] for q in BANK if q[\"area\"] == r.area and q[\"diff\"] == 1]\n", + " print(f\" - {r.area}: 취약 확률 {r.score:.2f}({r.grade}), 오답 {wrong_q} -> 기초 문항 {easy} 재학습 후 재평가\")\n", + "\n", + "# 중대별 취약 영역: 소속 인원 30% 이상이 취약(Medium 이상)이면 중대 단위 보충 교육 권고\n", + "u = t.assign(rec=t[\"grade\"] != \"Low\").pivot_table(index=\"unit\", columns=\"area\", values=\"rec\", aggfunc=\"mean\").round(2)\n", + "print(\"\\n[중대별 취약 인원 비율]\")\n", + "print(u.to_string())\n", + "print(\"중대 단위 보충 교육 권고:\", [f\"{i}-{c}\" for i in u.index for c in u.columns if u.loc[i, c] >= 0.3])\n", + "print(\"추천은 학습 보조 정보이며 개인 평가·인사 자료로 쓰지 않는다.\")\n", + "\n", + "fig, ax = plt.subplots(1, 2, figsize=(11, 4))\n", + "for g, col in [(\"High\", \"tab:red\"), (\"Medium\", \"tab:orange\"), (\"Low\", \"tab:green\")]:\n", + " ax[0].hist(t[t[\"grade\"] == g][\"score\"], bins=20, range=(0, 1), color=col, alpha=.7, label=g)\n", + "ax[0].set_title(\"Weakness probability by grade\"); ax[0].set_xlabel(\"P(accuracy < 0.6)\"); ax[0].legend()\n", + "im = ax[1].imshow(u.values, cmap=\"Reds\", vmin=0, vmax=1)\n", + "ax[1].set_xticks(range(5)); ax[1].set_xticklabels([\"Shape\", \"IFF\", \"Report\", \"Control\", \"C-UAS\"][:0] or [{\"형상식별\": \"Shape\", \"피아식별\": \"IFF\", \"경보보고\": \"Report\", \"교전통제\": \"Control\", \"대드론일반\": \"C-UAS\"}[c] for c in u.columns])\n", + "ax[1].set_yticks(range(3)); ax[1].set_yticklabels([\"Co.1\", \"Co.2\", \"Co.3\"]); plt.colorbar(im, ax=ax[1]); ax[1].set_title(\"Share of weak soldiers by unit/area\")\n", + "plt.tight_layout(); plt.show()\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 05 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + "[교육용가상자료] 응시자 120명 x 영역 5개 = 600건, 실제 취약 120건\n", + " 방식 추천 수 불필요 추천 오경보율 취약 영역 재현율\n", + " 기존(오답 1개라도 있으면 추천) 444 324 0.730 1.000\n", + "난이도 반영 추정(Medium 이상) 148 62 0.419 0.717\n", + "Low 등급 중 취약 확률 0.2 이상 96건은 추천 대신 '추가 문항 출제 후 재판단' 대상으로 둔다.\n", + "\n", + "오경보율 감축 42.6% (목표 40% 이상: 달성)\n", + "추천 결정 일관성(응답 1건 제외 재계산) 91.2% (목표 90% 이상: 달성)\n", + "취약 확률 점수 ROC-AUC 0.898\n", + "[임계값 민감도] 0.3: 오경보율 0.50/재현율 0.82, 0.4: 오경보율 0.42/재현율 0.72, 0.5: 오경보율 0.35/재현율 0.57, 0.7: 오경보율 0.15/재현율 0.47\n", + "\n", + "[개인 추천 예시: S098]\n", + " - 경보보고: 취약 확률 1.00(High), 오답 ['Q09', 'Q10', 'Q11', 'Q12', 'Q23', 'Q28', 'Q29'] -> 기초 문항 ['Q09', 'Q12', 'Q28'] 재학습 후 재평가\n", + " - 대드론일반: 취약 확률 0.87(High), 오답 ['Q18', 'Q19', 'Q25', 'Q30'] -> 기초 문항 ['Q17', 'Q25'] 재학습 후 재평가\n", + "\n", + "[중대별 취약 인원 비율]\n", + "area 경보보고 교전통제 대드론일반 피아식별 형상식별\n", + "unit \n", + "가상1중대 0.10 0.20 0.12 0.15 0.10\n", + "가상2중대 0.25 0.57 0.25 0.38 0.25\n", + "가상3중대 0.30 0.35 0.18 0.32 0.18\n", + "중대 단위 보충 교육 권고: ['가상2중대-교전통제', '가상2중대-피아식별', '가상3중대-경보보고', '가상3중대-교전통제', '가상3중대-피아식별']\n", + "추천은 학습 보조 정보이며 개인 평가·인사 자료로 쓰지 않는다.\n", + "```\n", + "
\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 06. 부대 방공 지식 수준 통계 및 오답 분포 가시화 교수자 대시보드 구현\n", + "**소요시간**: 약 2.5시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "부대 방공 지식 통계 및 오답 분포 가시화 교수자 대시보드를 구현한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **부대 방공 지식 수준과 오답 분포를 한눈에 보여 주는 교수자 대시보드를 구현한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **중대x영역 정답률 히트맵, 중대별 오답 유형 비율, 점수 분포와 합격선, 고난도 문항 목록 등 구성요소 4종 이상을 그린다 과정을 진행하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `src/ui/trn02_dashboard.py (또는 웹 UI 컴포넌트)` 형태의 결과물을 완성한다.\n", + "\n", + "### 🔍 평가 및 검증 기준 (PASS 조건)\n", + "#### 미션 판정\n", + "\n", + "**평가 구분:** 필수\n", + "\n", + "**핵심 평가영역:** 전술 작전 상황판으로서의 직관성 및 인지 부하 감소도\n", + "\n", + "#### 미션 PASS 조건\n", + "\n", + "- 정량 기준: 화면 갱신 주기 1초 이내, UI 응답성 100ms 이내\n", + "\n", + "\n", + "- 진행 기준: 주요 지표 시각화 컴포넌트 4종 이상 렌더링 및 실시간 데이터 연동\n", + "\n", + "\n", + "- 검증 방법: 사용자 인터랙션 테스트 및 모의 데이터 스트림 렌더링 검증\n", + "\n", + "\n", + "#### 채점자 판정 메모\n", + "\n", + "> 단순 코드 실행 여부뿐만 아니라 출력된 수치와 근거 자료가 군 실무 요구사항에 부합하는지 면밀히 확인한다.\n", + "\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "\"\"\"채점·오답 분류 결과로 중대별 영역 정답률, 오답 유형 분포, 점수 분포, 문항 난이도 등 교수자 대시보드 구성요소 4종 이상을 그리고, 새 응답 묶음이 들어올 때 집계 갱신 시간과 필터 조회 응답 시간을 측정하며 경보 하이라이트 대상을 출력한다.\"\"\"\n", + "import difflib\n", + "import re\n", + "import time\n", + "import unicodedata\n", + "import numpy as np\n", + "import pandas as pd\n", + "import matplotlib.pyplot as plt\n", + "from sklearn.feature_extraction.text import TfidfVectorizer\n", + "from sklearn.linear_model import LogisticRegression\n", + "from sklearn.pipeline import make_pipeline\n", + "\n", + "# ---------------------------------------------------------------------------\n", + "# [교육용가상자료] 방공·드론 식별 퀴즈 문항 은행과 가상 응시자 응답 생성기\n", + "# 문항은 일반 원칙 수준의 가상 문항이며 실제 교범·교전규칙 원문이 아니다. 응시자는 가명(S001~)이다.\n", + "# ---------------------------------------------------------------------------\n", + "AREAS = [\"형상식별\", \"피아식별\", \"경보보고\", \"교전통제\", \"대드론일반\"]\n", + "ETYPES = [\"개념 혼동\", \"피아 오인\", \"절차 누락\"]\n", + "POINTS = {1: 2, 2: 3, 3: 5}\n", + "C, F, P = ETYPES\n", + "\n", + "\n", + "def _mc(qid, area, diff, stem, opts, ans, dist):\n", + " return {\"qid\": qid, \"type\": \"MC\", \"area\": area, \"diff\": diff, \"stem\": stem, \"options\": opts, \"answer\": ans,\n", + " \"dist\": dict(zip([i for i in range(1, 5) if i != ans], dist))}\n", + "\n", + "\n", + "def _sa(qid, area, diff, stem, accept, wrong):\n", + " return {\"qid\": qid, \"type\": \"SA\", \"area\": area, \"diff\": diff, \"stem\": stem, \"accept\": accept, \"wrong\": wrong}\n", + "\n", + "\n", + "def _order(qid, area, diff, stem):\n", + " return {\"qid\": qid, \"type\": \"ORDER\", \"area\": area, \"diff\": diff, \"stem\": stem, \"answer\": \"ABCD\"}\n", + "\n", + "\n", + "BANK = [\n", + " _mc(\"Q01\", \"형상식별\", 1, \"꼬리날개 없이 삼각형 날개를 가진 소형 고정익 실루엣은?\", [\"쿼드콥터형\", \"삼각익 고정익형\", \"회전익 헬기형\", \"조류\"], 2, [C, C, F]),\n", + " _mc(\"Q02\", \"형상식별\", 1, \"회전날개 4개가 십자로 배치된 실루엣은?\", [\"고정익 드론형\", \"쿼드콥터형\", \"순항미사일형\", \"탄도미사일형\"], 2, [C, C, C]),\n", + " _mc(\"Q03\", \"형상식별\", 2, \"가늘고 긴 동체 중간에 작은 날개가 붙은 실루엣은?\", [\"순항미사일형\", \"탄도미사일형\", \"민간 경비행기형\", \"조류\"], 1, [C, F, F]),\n", + " _mc(\"Q04\", \"형상식별\", 3, \"원거리라 실루엣 식별이 어려울 때 먼저 할 행동은?\", [\"추정 유형으로 확정 보고\", \"관측 정보를 그대로 보고하고 추가 관측\", \"관측 중단\", \"민간기로 간주\"], 2, [P, P, F]),\n", + " _mc(\"Q05\", \"피아식별\", 2, \"식별 신호 응답이 없는 항공기에 대한 올바른 판단은?\", [\"즉시 적기로 확정\", \"미확인 항공기로 분류하고 추가 확인\", \"아군기로 간주\", \"무시\"], 2, [F, F, P]),\n", + " _mc(\"Q06\", \"피아식별\", 2, \"운항 정보와 일치하는 저속 비행체의 분류는?\", [\"적 드론\", \"민간기 가능성 높음, 추가 확인\", \"순항미사일\", \"아군 정찰기로 확정\"], 2, [F, C, F]),\n", + " _mc(\"Q07\", \"피아식별\", 3, \"아군 드론 운용 공지가 있는 공역에서 미확인 드론을 보면 우선 확인할 것은?\", [\"기상 정보\", \"아군 운용 계획과 식별 신호\", \"대응 준비 상태\", \"주변 지형\"], 2, [C, P, C]),\n", + " _mc(\"Q08\", \"피아식별\", 3, \"조류 무리와 소형 드론 군집 구분에 가장 도움이 되는 정보는?\", [\"크기만 비교\", \"궤적의 규칙성과 열 신호\", \"색상\", \"관측 시각\"], 2, [C, C, C]),\n", + " _mc(\"Q09\", \"경보보고\", 1, \"공중 위협 최초 보고에 포함하지 않아도 되는 것은?\", [\"관측 시각\", \"방향과 거리\", \"추정 유형\", \"관측자 개인 연락처\"], 4, [P, P, P]),\n", + " _mc(\"Q10\", \"경보보고\", 2, \"경보 전파 후 상황이 바뀌면?\", [\"최초 보고 유지\", \"변경 사항을 즉시 추가 보고\", \"상황 종료 후 일괄 보고\", \"개인 판단으로 경보 해제\"], 2, [P, P, P]),\n", + " _mc(\"Q11\", \"경보보고\", 2, \"보고 내용이 불확실할 때 올바른 표현은?\", [\"확정 표현으로 보고\", \"'추정'임을 밝히고 근거와 함께 보고\", \"보고 보류\", \"다른 관측자 보고를 복사\"], 2, [C, P, P]),\n", + " _mc(\"Q12\", \"경보보고\", 1, \"드론 탐지 후 보고 경로로 알맞은 것은?\", [\"인접 부대에만 전파\", \"지휘계통을 따라 보고\", \"개인 메신저로 공유\", \"보고 없이 대응\"], 2, [P, P, P]),\n", + " _mc(\"Q13\", \"교전통제\", 1, \"AI 탐지 결과만으로 대응을 결정할 수 있는가?\", [\"가능하다\", \"인간 지휘관의 승인이 필요하다\", \"신뢰도 90% 이상이면 가능\", \"야간에만 가능\"], 2, [C, C, C]),\n", + " _mc(\"Q14\", \"교전통제\", 2, \"민간 피해 가능성이 확인되면?\", [\"그대로 진행\", \"즉시 보고하고 지휘관 판단을 받는다\", \"조치 후 보고\", \"무시\"], 2, [P, P, F]),\n", + " _mc(\"Q15\", \"교전통제\", 3, \"식별이 불확실한 표적에 대한 원칙은?\", [\"위협으로 간주해 즉시 대응\", \"식별 확인 전까지 승인 절차를 따른다\", \"무조건 무시\", \"다수결로 결정\"], 2, [F, F, C]),\n", + " _mc(\"Q16\", \"교전통제\", 2, \"판단 근거를 기록해야 하는 이유는?\", [\"형식상 필요\", \"사후 검토와 책임 추적\", \"보고 생략\", \"기록 불필요\"], 2, [C, P, P]),\n", + " _mc(\"Q17\", \"대드론일반\", 1, \"대드론 체계의 일반적 처리 순서는?\", [\"대응-탐지-식별\", \"탐지-식별-보고·판단-대응\", \"식별-대응-탐지\", \"보고-탐지-대응\"], 2, [P, P, P]),\n", + " _mc(\"Q18\", \"대드론일반\", 3, \"레이더만으로 조류와 드론 구분이 어려운 이유는?\", [\"반사 신호 크기가 비슷할 수 있어서\", \"레이더는 야간에 못 써서\", \"드론에는 금속이 없어서\", \"조류는 탐지되지 않아서\"], 1, [C, C, C]),\n", + " _mc(\"Q19\", \"대드론일반\", 2, \"전자광학(EO) 센서의 약점이 커지는 조건은?\", [\"맑은 주간\", \"안개·야간\", \"저속 표적\", \"근거리\"], 2, [C, C, C]),\n", + " _mc(\"Q20\", \"대드론일반\", 2, \"경보가 반복해서 오경보로 판명되면?\", [\"경보 장치를 끈다\", \"사례를 기록·보고하고 기준 재검토 요청\", \"무시한다\", \"모두 실제 위협으로 간주\"], 2, [P, P, F]),\n", + " _sa(\"Q21\", \"피아식별\", 1, \"아군·적·민간을 구분하는 절차를 무엇이라 하는가?\", [\"피아식별\", \"IFF\"], {\"표적획득\": C, \"적기판정\": F, \"경계\": C}),\n", + " _sa(\"Q22\", \"형상식별\", 2, \"꼬리날개 없는 삼각형 날개 형태를 무엇이라 하는가?\", [\"삼각익\", \"델타익\", \"delta\"], {\"직선익\": C, \"후퇴익\": C, \"쿼드콥터\": C}),\n", + " _sa(\"Q23\", \"경보보고\", 2, \"불확실한 관측을 보고할 때 붙이는 표현은?\", [\"추정\"], {\"확정\": C, \"확인\": C, \"생략\": P}),\n", + " _sa(\"Q24\", \"교전통제\", 1, \"AI 탐지 결과에 대해 최종 결정을 내리는 주체는?\", [\"지휘관\"], {\"AI\": C, \"운용병\": P, \"레이더\": C}),\n", + " _sa(\"Q25\", \"대드론일반\", 1, \"조류를 드론으로 잘못 경보하는 것을 무엇이라 하는가?\", [\"오경보\", \"오탐\", \"false alarm\"], {\"미탐\": C, \"재경보\": C, \"적기\": F}),\n", + " _sa(\"Q26\", \"피아식별\", 2, \"운항 정보가 확인된 여객기는 어떻게 분류하는가?\", [\"민간기\", \"민항기\"], {\"적기\": F, \"미확인기\": P, \"아군기\": F}),\n", + " _sa(\"Q27\", \"형상식별\", 1, \"회전날개 4개를 가진 드론 형태는?\", [\"쿼드콥터\", \"quadcopter\"], {\"헥사콥터\": C, \"고정익\": C, \"헬기\": F}),\n", + " _sa(\"Q28\", \"경보보고\", 1, \"보고는 무엇을 따라 올라가는가?\", [\"지휘계통\"], {\"인접부대\": P, \"개인메신저\": P, \"통신망\": C}),\n", + " _order(\"Q29\", \"경보보고\", 2, \"최초 보고 순서: A 관측 시각, B 방향·거리, C 추정 유형, D 관측자 조치\"),\n", + " _order(\"Q30\", \"대드론일반\", 2, \"처리 순서: A 탐지, B 식별, C 보고·판단, D 대응\"),\n", + " _order(\"Q31\", \"교전통제\", 3, \"통제 순서: A 식별 확인, B 상급 승인 요청, C 승인 수령, D 조치 기록\"),\n", + " _order(\"Q32\", \"피아식별\", 3, \"확인 순서: A 운항정보 대조, B 식별 신호 확인, C 미확인 분류, D 보고\"),\n", + "]\n", + "QIDX = {q[\"qid\"]: q for q in BANK}\n", + "UNITS = [\"가상1중대\", \"가상2중대\", \"가상3중대\"]\n", + "_CIRC = \"①②③④\"\n", + "PARA = {\"Q21\": \"아군적군 구분\", \"Q22\": \"세모 날개\", \"Q23\": \"추정됨으로 표기\", \"Q24\": \"중대장(지휘관)\", \"Q25\": \"잘못된 경보\",\n", + " \"Q26\": \"민간 여객기\", \"Q27\": \"4발 드론\", \"Q28\": \"지휘 라인\"} # 뜻은 맞지만 인정 답안 목록에 없는 표현\n", + "\n", + "\n", + "def make_soldiers(n_per_unit, seed):\n", + " rng = np.random.default_rng(seed)\n", + " out = []\n", + " for u, unit in enumerate(UNITS):\n", + " base = [0.3, -0.1, 0.0][u]\n", + " for i in range(n_per_unit):\n", + " off = rng.normal(0, 0.5, len(AREAS)) + (np.array([0, 0, 0, -0.6, 0]) if u == 1 else 0)\n", + " out.append({\"sid\": f\"S{len(out) + 1:03d}\", \"unit\": unit, \"theta\": dict(zip(AREAS, base + rng.normal(0, 0.6) + off)),\n", + " \"tend\": dict(zip(ETYPES, rng.dirichlet([1.2, 1.2, 1.2])))})\n", + " return out\n", + "\n", + "\n", + "def p_correct(theta, diff):\n", + " return 1 / (1 + np.exp(-1.7 * (theta - (diff - 2) * 0.9 + 0.9)))\n", + "\n", + "\n", + "def _typo(s, rng):\n", + " if len(s) < 2:\n", + " return s\n", + " i = int(rng.integers(len(s) - 1))\n", + " return s[:i] + s[i + 1] + s[i] + s[i + 2:] if len(s) <= 3 or rng.random() < 0.5 else s[:i] + s[i + 1:]\n", + "\n", + "\n", + "def _fmt_mc(k, rng):\n", + " r = rng.random()\n", + " if r < 0.55:\n", + " return str(k)\n", + " return rng.choice([_CIRC[k - 1], f\"{k}번\", \"ABCD\"[k - 1], \"abcd\"[k - 1] + \")\", f\" {k} \", chr(0xFF10 + k), f\"{k}.\"])\n", + "\n", + "\n", + "def _fmt_sa(s, rng):\n", + " r = rng.random()\n", + " if r < 0.45:\n", + " return s\n", + " if r < 0.6:\n", + " return \" \".join(s) if len(s) <= 4 else s[:2] + \" \" + s[2:]\n", + " if r < 0.72:\n", + " return s + rng.choice([\"입니다\", \"이다\", \".\", \"임\"])\n", + " if r < 0.8:\n", + " return s.upper() if s.isascii() else \"\".join(chr(ord(ch) + 0xFEE0) if \"!\" <= ch <= \"~\" else ch for ch in s) + \" \"\n", + " return _typo(s, rng)\n", + "\n", + "\n", + "def answer(q, sol, rng):\n", + " \"\"\"한 문항 응답: (원본 응답 문자열, 의도한 정답 여부, 의도한 답, 오답 유형).\"\"\"\n", + " ok = rng.random() < p_correct(sol[\"theta\"][q[\"area\"]], q[\"diff\"])\n", + " if q[\"type\"] == \"MC\":\n", + " if ok:\n", + " k, et = q[\"answer\"], None\n", + " else:\n", + " ks = list(q[\"dist\"]); w = np.array([sol[\"tend\"][q[\"dist\"][k]] for k in ks])\n", + " k = int(rng.choice(ks, p=w / w.sum())); et = q[\"dist\"][k]\n", + " return _fmt_mc(k, rng), ok, str(k), et\n", + " if q[\"type\"] == \"SA\":\n", + " if ok and rng.random() < 0.06:\n", + " return PARA[q[\"qid\"]], ok, PARA[q[\"qid\"]], None\n", + " if ok:\n", + " s, et = str(rng.choice(q[\"accept\"])), None\n", + " else:\n", + " ws = list(q[\"wrong\"]); w = np.array([sol[\"tend\"][q[\"wrong\"][x]] for x in ws])\n", + " s = str(rng.choice(ws, p=w / w.sum())); et = q[\"wrong\"][s]\n", + " return _fmt_sa(s, rng), ok, s, et\n", + " if ok:\n", + " s = \"ABCD\"\n", + " elif rng.random() < 0.5:\n", + " s = \"ABCD\".replace(\"ABCD\"[rng.integers(1, 4)], \"\") # 단계 하나 빠뜨림\n", + " else:\n", + " i = int(rng.integers(0, 3)); s = list(\"ABCD\"); s[i], s[i + 1] = s[i + 1], s[i]; s = \"\".join(s) # 순서 뒤바뀜\n", + " raw = s if rng.random() < 0.6 else rng.choice([\"-\".join(s), \" \".join(s), s.lower(), \",\".join(s)])\n", + " return raw, ok, s, (None if ok else P)\n", + "\n", + "\n", + "def make_responses(n_per_unit=40, seed=7, blank=0.02):\n", + " \"\"\"응시자 x 문항 응답표(DataFrame)를 만든다. truth_* 열은 교관 기준 정답(평가용)이다.\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " rows = []\n", + " for sol in make_soldiers(n_per_unit, seed):\n", + " for q in BANK:\n", + " raw, ok, intended, et = answer(q, sol, rng)\n", + " if rng.random() < blank:\n", + " raw, ok, intended, et = \"\", False, \"\", None\n", + " rows.append({\"rid\": f\"R{len(rows):05d}\", \"sid\": sol[\"sid\"], \"unit\": sol[\"unit\"], \"qid\": q[\"qid\"], \"raw\": raw,\n", + " \"truth_correct\": ok, \"truth_answer\": intended, \"truth_etype\": et})\n", + " return pd.DataFrame(rows)\n", + "# ---------------------------------------------------------------------------\n", + "\n", + "# ---- 응답 정제·파싱·채점 공통 함수 ----\n", + "TAILS = (\"입니다\", \"이다\", \"임\")\n", + "\n", + "\n", + "def norm_text(s):\n", + " \"\"\"전각->반각(NFKC), 소문자, 공백·문장부호 제거, 끝맺음 어미 제거.\"\"\"\n", + " s = unicodedata.normalize(\"NFKC\", str(s)).strip().lower()\n", + " s = re.sub(r\"[\\s\\.\\,\\-\\(\\)·]\", \"\", s)\n", + " for t in TAILS:\n", + " if s.endswith(t) and len(s) > len(t) + 1:\n", + " s = s[:-len(t)]\n", + " return s\n", + "\n", + "\n", + "def parse(q, raw):\n", + " \"\"\"원본 응답 -> (정제값, 상태). 상태: 정상 / 미응답 / 형식오류\"\"\"\n", + " if raw is None or str(raw).strip() == \"\":\n", + " return \"\", \"미응답\"\n", + " s = unicodedata.normalize(\"NFKC\", str(raw)).strip()\n", + " if q[\"type\"] == \"MC\":\n", + " t = re.sub(r\"[\\s번\\.\\)]\", \"\", s).lower()\n", + " t = {\"a\": \"1\", \"b\": \"2\", \"c\": \"3\", \"d\": \"4\"}.get(t, t)\n", + " return (t, \"정상\") if t in (\"1\", \"2\", \"3\", \"4\") else (t, \"형식오류\")\n", + " if q[\"type\"] == \"ORDER\":\n", + " t = re.sub(r\"[^A-Za-z]\", \"\", s).upper()\n", + " return (t, \"정상\") if t and set(t) <= set(\"ABCD\") and len(set(t)) == len(t) else (t, \"형식오류\")\n", + " return norm_text(s), \"정상\"\n", + "\n", + "\n", + "def grade_exact(q, val):\n", + " if q[\"type\"] == \"MC\":\n", + " return val == str(q[\"answer\"])\n", + " if q[\"type\"] == \"ORDER\":\n", + " return val == q[\"answer\"]\n", + " return val in {norm_text(a) for a in q[\"accept\"]}\n", + "\n", + "\n", + "def grade_fuzzy(q, val, cut=0.75):\n", + " \"\"\"단답형은 오탈자(글자 순서 바뀜·한 글자 누락)를 허용(유사도 cut 이상)하되, 알려진 오답과 더 가까우면 오답으로 본다.\"\"\"\n", + " if grade_exact(q, val) or q[\"type\"] != \"SA\" or len(val) < 2:\n", + " return grade_exact(q, val)\n", + " best = max(1.0 if sorted(val) == sorted(norm_text(a)) else difflib.SequenceMatcher(None, val, norm_text(a)).ratio() for a in q[\"accept\"])\n", + " worst = max(difflib.SequenceMatcher(None, val, norm_text(w)).ratio() for w in q[\"wrong\"])\n", + " return best >= cut and best > worst\n", + "\n", + "\n", + "def score_of(q, correct):\n", + " return POINTS[q[\"diff\"]] if correct else 0\n", + "\n", + "# ---- 오답 유형 분류기(규칙 + 문자 n-gram 로지스틱 회귀) ----\n", + "SEED = {\"피아 오인\": [\"적기\", \"적 드론\", \"아군기\", \"적 헬기\", \"적기판정\", \"헬기\", \"적 항공기 확정\", \"아군 드론\"],\n", + " \"절차 누락\": [\"생략\", \"인접부대\", \"개인메신저\", \"보고 안 함\", \"운용병\", \"미확인기\", \"단계 건너뜀\", \"직접 전파\"],\n", + " \"개념 혼동\": [\"직선익\", \"후퇴익\", \"헥사콥터\", \"미탐\", \"재경보\", \"확정\", \"확인\", \"레이더\", \"AI\", \"표적획득\", \"경계\", \"통신망\", \"고정익\"]}\n", + "\n", + "\n", + "def build_text_model():\n", + " texts, labels = [], []\n", + " for q in BANK:\n", + " if q[\"type\"] == \"SA\":\n", + " for w, et in q[\"wrong\"].items():\n", + " texts.append(w); labels.append(et)\n", + " for et, ws in SEED.items():\n", + " texts += ws; labels += [et] * len(ws)\n", + " m = make_pipeline(TfidfVectorizer(analyzer=\"char_wb\", ngram_range=(1, 3)), LogisticRegression(max_iter=2000, C=5))\n", + " return m.fit([norm_text(t) for t in texts], labels)\n", + "\n", + "\n", + "def classify_error(q, val, text_model):\n", + " \"\"\"오답 1건의 유형과 판단 근거(rule/text)를 돌려준다.\"\"\"\n", + " if q[\"type\"] == \"MC\":\n", + " return q[\"dist\"].get(int(val), \"개념 혼동\") if val in \"1234\" and val else \"개념 혼동\", \"보기 매핑\"\n", + " if q[\"type\"] == \"ORDER\":\n", + " return \"절차 누락\", (\"단계 누락\" if len(val) < 4 else \"순서 뒤바뀜\")\n", + " known = {norm_text(w): et for w, et in q[\"wrong\"].items()}\n", + " if val in known:\n", + " return known[val], \"오답 목록\"\n", + " near = max(known, key=lambda w: difflib.SequenceMatcher(None, val, w).ratio())\n", + " if difflib.SequenceMatcher(None, val, near).ratio() >= 0.75 or sorted(val) == sorted(near):\n", + " return known[near], \"오답 목록(오탈자)\"\n", + " return str(text_model.predict([val])[0]), \"문자 모델\"\n", + "\n", + "PASS_RATE = 0.7\n", + "ALERT_AREA = 0.55 # 중대 영역 정답률이 이보다 낮으면 경보 하이라이트\n", + "TM = build_text_model()\n", + "\n", + "\n", + "def grade_batch(df):\n", + " \"\"\"원본 응답 묶음 -> 정제값·정답 여부·점수·오답 유형 열 추가.\"\"\"\n", + " out = df.copy()\n", + " pv = [parse(QIDX[q], r) for q, r in zip(out[\"qid\"], out[\"raw\"])]\n", + " out[\"val\"], out[\"status\"] = [p[0] for p in pv], [p[1] for p in pv]\n", + " out[\"area\"] = out[\"qid\"].map(lambda q: QIDX[q][\"area\"])\n", + " out[\"correct\"] = [s == \"정상\" and grade_fuzzy(QIDX[q], v) for q, v, s in zip(out[\"qid\"], out[\"val\"], out[\"status\"])]\n", + " out[\"score\"] = [score_of(QIDX[q], c) for q, c in zip(out[\"qid\"], out[\"correct\"])]\n", + " out[\"etype\"] = [None if c or s != \"정상\" else classify_error(QIDX[q], v, TM)[0]\n", + " for q, v, c, s in zip(out[\"qid\"], out[\"val\"], out[\"correct\"], out[\"status\"])]\n", + " return out\n", + "\n", + "\n", + "class Board:\n", + " \"\"\"대시보드 상태: 누적 집계만 갱신해 매번 전체를 다시 계산하지 않는다.\"\"\"\n", + " def __init__(self):\n", + " self.data = pd.DataFrame()\n", + "\n", + " def update(self, batch):\n", + " self.data = pd.concat([self.data, grade_batch(batch)], ignore_index=True)\n", + " d = self.data\n", + " self.area = d.pivot_table(index=\"unit\", columns=\"area\", values=\"correct\", aggfunc=\"mean\")\n", + " self.etype = d.dropna(subset=[\"etype\"]).groupby([\"unit\", \"etype\"]).size().unstack(fill_value=0)\n", + " self.person = d.groupby([\"sid\", \"unit\"])[\"score\"].sum().reset_index()\n", + " self.item = d.groupby(\"qid\")[\"correct\"].mean()\n", + "\n", + " def query(self, unit=None, area=None):\n", + " d = self.data\n", + " if unit:\n", + " d = d[d[\"unit\"] == unit]\n", + " if area:\n", + " d = d[d[\"area\"] == area]\n", + " return d.groupby(\"qid\")[\"correct\"].mean().sort_values().head(3)\n", + "\n", + "\n", + "full = make_responses(40, seed=7)\n", + "FULL_SCORE = sum(POINTS[q[\"diff\"]] for q in BANK)\n", + "board = Board()\n", + "# 1) 실시간 갱신 모의: 응시자 응답이 30명 단위로 4번 들어온다\n", + "times = []\n", + "for k, sids in enumerate(np.array_split(full[\"sid\"].unique(), 4), 1):\n", + " t0 = time.perf_counter()\n", + " board.update(full[full[\"sid\"].isin(sids)])\n", + " times.append((time.perf_counter() - t0) * 1000)\n", + " print(f\"갱신 {k}: 누적 응답 {len(board.data):,}건, 집계 갱신 {times[-1]:.0f} ms\")\n", + "print(f\"최대 갱신 시간 {max(times):.0f} ms (목표 1초 이내: {'달성' if max(times) < 1000 else '미달'})\")\n", + "\n", + "# 2) 필터 조회 응답성(교수자가 중대·영역을 바꿔 가며 조회)\n", + "q_times = []\n", + "for u in UNITS + [None]:\n", + " for a in AREAS + [None]:\n", + " t0 = time.perf_counter(); board.query(u, a); q_times.append((time.perf_counter() - t0) * 1000)\n", + "print(f\"필터 조회 {len(q_times)}회, 평균 {np.mean(q_times):.1f} ms, 최대 {max(q_times):.1f} ms (목표 100 ms 이내: {'달성' if max(q_times) < 100 else '미달'})\")\n", + "\n", + "# 3) 대시보드 구성요소 4종\n", + "fig, ax = plt.subplots(2, 2, figsize=(12, 8))\n", + "AREA_EN = {\"형상식별\": \"Shape\", \"피아식별\": \"IFF\", \"경보보고\": \"Report\", \"교전통제\": \"Control\", \"대드론일반\": \"C-UAS\"}\n", + "UNIT_EN = {\"가상1중대\": \"Co.1\", \"가상2중대\": \"Co.2\", \"가상3중대\": \"Co.3\"}\n", + "ET_EN = {\"개념 혼동\": \"Concept\", \"피아 오인\": \"IFF error\", \"절차 누락\": \"Procedure\"}\n", + "A = board.area[AREAS]\n", + "im = ax[0, 0].imshow(A.values, cmap=\"RdYlGn\", vmin=0.3, vmax=1.0)\n", + "ax[0, 0].set_xticks(range(len(AREAS)), [AREA_EN[a] for a in AREAS]); ax[0, 0].set_yticks(range(3), [UNIT_EN[u] for u in A.index])\n", + "for i in range(A.shape[0]):\n", + " for j in range(A.shape[1]):\n", + " v = A.values[i, j]\n", + " ax[0, 0].text(j, i, f\"{v:.2f}\", ha=\"center\", va=\"center\", fontweight=\"bold\" if v < ALERT_AREA else \"normal\",\n", + " color=\"white\" if v < ALERT_AREA else \"black\")\n", + "ax[0, 0].set_title(\"Accuracy by company x area (bold = alert)\"); fig.colorbar(im, ax=ax[0, 0])\n", + "E = board.etype.rename(index=UNIT_EN, columns=ET_EN)\n", + "E.div(E.sum(axis=1), axis=0).plot.bar(stacked=True, ax=ax[0, 1], rot=0)\n", + "ax[0, 1].set_title(\"Error type share by company\"); ax[0, 1].set_ylabel(\"share\")\n", + "for u in UNITS:\n", + " ax[1, 0].hist(board.person.loc[board.person[\"unit\"] == u, \"score\"], bins=12, alpha=0.5, label=UNIT_EN[u])\n", + "ax[1, 0].axvline(FULL_SCORE * PASS_RATE, color=\"k\", ls=\"--\", label=\"pass line\"); ax[1, 0].legend()\n", + "ax[1, 0].set_title(\"Score distribution\"); ax[1, 0].set_xlabel(\"score\")\n", + "it = board.item.sort_values()\n", + "ax[1, 1].barh(it.index[:10], it.values[:10], color=\"tab:red\"); ax[1, 1].set_xlim(0, 1)\n", + "ax[1, 1].set_title(\"10 hardest items (accuracy)\")\n", + "plt.tight_layout(); plt.show()\n", + "print(\"대시보드 구성요소 4종 렌더링: 중대x영역 정답률, 오답 유형 비율, 점수 분포, 고난도 문항\")\n", + "\n", + "# 4) 요약 지표와 경보 하이라이트\n", + "p = board.person.assign(passed=board.person[\"score\"] >= FULL_SCORE * PASS_RATE)\n", + "print(\"\\n[중대별 요약]\")\n", + "print(p.groupby(\"unit\").agg(응시=(\"sid\", \"size\"), 평균점수=(\"score\", \"mean\"), 합격률=(\"passed\", \"mean\")).round(2).to_string())\n", + "alerts = [(u, a, A.loc[u, a]) for u in A.index for a in AREAS if A.loc[u, a] < ALERT_AREA]\n", + "print(f\"\\n[경보 하이라이트] 영역 정답률 {ALERT_AREA} 미만 {len(alerts)}건\")\n", + "for u, a, v in sorted(alerts, key=lambda x: x[2]):\n", + " print(f\" - {u} {a}: 정답률 {v:.2f}, 최저 문항 {list(board.query(u, a).index)}\")\n", + "top = board.etype.sum().sort_values(ascending=False)\n", + "print(\"부대 전체 오답 유형:\", {k: int(v) for k, v in top.items()})\n", + "print(\"야간 교육장 사용 시 plt.style.use('dark_background')로 어두운 화면 모드를 적용할 수 있다.\")\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 06 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + "갱신 1: 누적 응답 960건, 집계 갱신 39 ms\n", + "갱신 2: 누적 응답 1,920건, 집계 갱신 35 ms\n", + "갱신 3: 누적 응답 2,880건, 집계 갱신 39 ms\n", + "갱신 4: 누적 응답 3,840건, 집계 갱신 38 ms\n", + "최대 갱신 시간 39 ms (목표 1초 이내: 달성)\n", + "필터 조회 24회, 평균 2.7 ms, 최대 3.4 ms (목표 100 ms 이내: 달성)\n", + "대시보드 구성요소 4종 렌더링: 중대x영역 정답률, 오답 유형 비율, 점수 분포, 고난도 문항\n", + "\n", + "[중대별 요약]\n", + " 응시 평균점수 합격률\n", + "unit \n", + "가상1중대 40 75.40 0.68\n", + "가상2중대 40 58.55 0.28\n", + "가상3중대 40 68.18 0.57\n", + "\n", + "[경보 하이라이트] 영역 정답률 0.55 미만 1건\n", + " - 가상2중대 교전통제: 정답률 0.50, 최저 문항 ['Q31', 'Q15', 'Q14']\n", + "부대 전체 오답 유형: {'절차 누락': 419, '개념 혼동': 367, '피아 오인': 186}\n", + "야간 교육장 사용 시 plt.style.use('dark_background')로 어두운 화면 모드를 적용할 수 있다.\n", + "```\n", + "
\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 07. 이의 제기 문항 및 서술형 답변에 대한 교관 수동 채점(HITL) 검토 인터페이스 설계\n", + "**소요시간**: 약 3.0시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "이의 제기 문항 및 서술형 답변에 대한 교관 수동 채점(HITL) 인터페이스를 설계한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **이의 제기 문항과 서술형 답변을 교관이 검토·확정하는 HITL 흐름을 설계하고, 자동 채점이 점수를 단정하지 못하게 한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **서술형은 핵심 요소 키워드로 점수를 '제안'만 하고, 이의 제기와 함께 합격선 근처·저신뢰 답안부터 검토하는 우선순위 대기열에 올린다 과정을 진행하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `src/hitl/trn02_approval_workflow.py 및 docs/trn02_hitl_guide.md` 형태의 결과물을 완성한다.\n", + "\n", + "### 🔍 평가 및 검증 기준 (PASS 조건)\n", + "#### 미션 판정\n", + "\n", + "**평가 구분:** 필수\n", + "\n", + "**핵심 평가영역:** 지휘 책임 분계선 명확화 및 성급한 자동 단정 방지 체계 부합성\n", + "\n", + "#### 미션 PASS 조건\n", + "\n", + "- 정량 기준: 인간 검토 큐(Queue) 대기시간 및 승인/반려 처리 로깅 성공률 100%\n", + "\n", + "\n", + "- 진행 기준: 승인/반려 인터페이스 동작 및 감사 로그(Audit Log) 생성 검증\n", + "\n", + "\n", + "- 검증 방법: 모의 조작자 승인/반려 시뮬레이션 및 데이터 무결성 검증\n", + "\n", + "\n", + "#### 채점자 판정 메모\n", + "\n", + "> 단순 코드 실행 여부뿐만 아니라 출력된 수치와 근거 자료가 군 실무 요구사항에 부합하는지 면밀히 확인한다.\n", + "\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "\"\"\"이의 제기된 단답형 문항과 서술형 답변을 교관 검토 대기열(HITL)에 올려, 자동 채점은 '제안'으로만 두고 교관의 인정·기각·수정 결정과 사유를 해시 체인 감사 로그에 남기는 검토 흐름을 설계한다. 합격선 근처 응시자를 먼저 검토하는 우선순위 큐의 대기시간, 로깅 성공률, 무결성 검증을 확인한다.\"\"\"\n", + "import difflib\n", + "import hashlib\n", + "import heapq\n", + "import json\n", + "import re\n", + "import unicodedata\n", + "import numpy as np\n", + "import pandas as pd\n", + "\n", + "# ---------------------------------------------------------------------------\n", + "# [교육용가상자료] 방공·드론 식별 퀴즈 문항 은행과 가상 응시자 응답 생성기\n", + "# 문항은 일반 원칙 수준의 가상 문항이며 실제 교범·교전규칙 원문이 아니다. 응시자는 가명(S001~)이다.\n", + "# ---------------------------------------------------------------------------\n", + "AREAS = [\"형상식별\", \"피아식별\", \"경보보고\", \"교전통제\", \"대드론일반\"]\n", + "ETYPES = [\"개념 혼동\", \"피아 오인\", \"절차 누락\"]\n", + "POINTS = {1: 2, 2: 3, 3: 5}\n", + "C, F, P = ETYPES\n", + "\n", + "\n", + "def _mc(qid, area, diff, stem, opts, ans, dist):\n", + " return {\"qid\": qid, \"type\": \"MC\", \"area\": area, \"diff\": diff, \"stem\": stem, \"options\": opts, \"answer\": ans,\n", + " \"dist\": dict(zip([i for i in range(1, 5) if i != ans], dist))}\n", + "\n", + "\n", + "def _sa(qid, area, diff, stem, accept, wrong):\n", + " return {\"qid\": qid, \"type\": \"SA\", \"area\": area, \"diff\": diff, \"stem\": stem, \"accept\": accept, \"wrong\": wrong}\n", + "\n", + "\n", + "def _order(qid, area, diff, stem):\n", + " return {\"qid\": qid, \"type\": \"ORDER\", \"area\": area, \"diff\": diff, \"stem\": stem, \"answer\": \"ABCD\"}\n", + "\n", + "\n", + "BANK = [\n", + " _mc(\"Q01\", \"형상식별\", 1, \"꼬리날개 없이 삼각형 날개를 가진 소형 고정익 실루엣은?\", [\"쿼드콥터형\", \"삼각익 고정익형\", \"회전익 헬기형\", \"조류\"], 2, [C, C, F]),\n", + " _mc(\"Q02\", \"형상식별\", 1, \"회전날개 4개가 십자로 배치된 실루엣은?\", [\"고정익 드론형\", \"쿼드콥터형\", \"순항미사일형\", \"탄도미사일형\"], 2, [C, C, C]),\n", + " _mc(\"Q03\", \"형상식별\", 2, \"가늘고 긴 동체 중간에 작은 날개가 붙은 실루엣은?\", [\"순항미사일형\", \"탄도미사일형\", \"민간 경비행기형\", \"조류\"], 1, [C, F, F]),\n", + " _mc(\"Q04\", \"형상식별\", 3, \"원거리라 실루엣 식별이 어려울 때 먼저 할 행동은?\", [\"추정 유형으로 확정 보고\", \"관측 정보를 그대로 보고하고 추가 관측\", \"관측 중단\", \"민간기로 간주\"], 2, [P, P, F]),\n", + " _mc(\"Q05\", \"피아식별\", 2, \"식별 신호 응답이 없는 항공기에 대한 올바른 판단은?\", [\"즉시 적기로 확정\", \"미확인 항공기로 분류하고 추가 확인\", \"아군기로 간주\", \"무시\"], 2, [F, F, P]),\n", + " _mc(\"Q06\", \"피아식별\", 2, \"운항 정보와 일치하는 저속 비행체의 분류는?\", [\"적 드론\", \"민간기 가능성 높음, 추가 확인\", \"순항미사일\", \"아군 정찰기로 확정\"], 2, [F, C, F]),\n", + " _mc(\"Q07\", \"피아식별\", 3, \"아군 드론 운용 공지가 있는 공역에서 미확인 드론을 보면 우선 확인할 것은?\", [\"기상 정보\", \"아군 운용 계획과 식별 신호\", \"대응 준비 상태\", \"주변 지형\"], 2, [C, P, C]),\n", + " _mc(\"Q08\", \"피아식별\", 3, \"조류 무리와 소형 드론 군집 구분에 가장 도움이 되는 정보는?\", [\"크기만 비교\", \"궤적의 규칙성과 열 신호\", \"색상\", \"관측 시각\"], 2, [C, C, C]),\n", + " _mc(\"Q09\", \"경보보고\", 1, \"공중 위협 최초 보고에 포함하지 않아도 되는 것은?\", [\"관측 시각\", \"방향과 거리\", \"추정 유형\", \"관측자 개인 연락처\"], 4, [P, P, P]),\n", + " _mc(\"Q10\", \"경보보고\", 2, \"경보 전파 후 상황이 바뀌면?\", [\"최초 보고 유지\", \"변경 사항을 즉시 추가 보고\", \"상황 종료 후 일괄 보고\", \"개인 판단으로 경보 해제\"], 2, [P, P, P]),\n", + " _mc(\"Q11\", \"경보보고\", 2, \"보고 내용이 불확실할 때 올바른 표현은?\", [\"확정 표현으로 보고\", \"'추정'임을 밝히고 근거와 함께 보고\", \"보고 보류\", \"다른 관측자 보고를 복사\"], 2, [C, P, P]),\n", + " _mc(\"Q12\", \"경보보고\", 1, \"드론 탐지 후 보고 경로로 알맞은 것은?\", [\"인접 부대에만 전파\", \"지휘계통을 따라 보고\", \"개인 메신저로 공유\", \"보고 없이 대응\"], 2, [P, P, P]),\n", + " _mc(\"Q13\", \"교전통제\", 1, \"AI 탐지 결과만으로 대응을 결정할 수 있는가?\", [\"가능하다\", \"인간 지휘관의 승인이 필요하다\", \"신뢰도 90% 이상이면 가능\", \"야간에만 가능\"], 2, [C, C, C]),\n", + " _mc(\"Q14\", \"교전통제\", 2, \"민간 피해 가능성이 확인되면?\", [\"그대로 진행\", \"즉시 보고하고 지휘관 판단을 받는다\", \"조치 후 보고\", \"무시\"], 2, [P, P, F]),\n", + " _mc(\"Q15\", \"교전통제\", 3, \"식별이 불확실한 표적에 대한 원칙은?\", [\"위협으로 간주해 즉시 대응\", \"식별 확인 전까지 승인 절차를 따른다\", \"무조건 무시\", \"다수결로 결정\"], 2, [F, F, C]),\n", + " _mc(\"Q16\", \"교전통제\", 2, \"판단 근거를 기록해야 하는 이유는?\", [\"형식상 필요\", \"사후 검토와 책임 추적\", \"보고 생략\", \"기록 불필요\"], 2, [C, P, P]),\n", + " _mc(\"Q17\", \"대드론일반\", 1, \"대드론 체계의 일반적 처리 순서는?\", [\"대응-탐지-식별\", \"탐지-식별-보고·판단-대응\", \"식별-대응-탐지\", \"보고-탐지-대응\"], 2, [P, P, P]),\n", + " _mc(\"Q18\", \"대드론일반\", 3, \"레이더만으로 조류와 드론 구분이 어려운 이유는?\", [\"반사 신호 크기가 비슷할 수 있어서\", \"레이더는 야간에 못 써서\", \"드론에는 금속이 없어서\", \"조류는 탐지되지 않아서\"], 1, [C, C, C]),\n", + " _mc(\"Q19\", \"대드론일반\", 2, \"전자광학(EO) 센서의 약점이 커지는 조건은?\", [\"맑은 주간\", \"안개·야간\", \"저속 표적\", \"근거리\"], 2, [C, C, C]),\n", + " _mc(\"Q20\", \"대드론일반\", 2, \"경보가 반복해서 오경보로 판명되면?\", [\"경보 장치를 끈다\", \"사례를 기록·보고하고 기준 재검토 요청\", \"무시한다\", \"모두 실제 위협으로 간주\"], 2, [P, P, F]),\n", + " _sa(\"Q21\", \"피아식별\", 1, \"아군·적·민간을 구분하는 절차를 무엇이라 하는가?\", [\"피아식별\", \"IFF\"], {\"표적획득\": C, \"적기판정\": F, \"경계\": C}),\n", + " _sa(\"Q22\", \"형상식별\", 2, \"꼬리날개 없는 삼각형 날개 형태를 무엇이라 하는가?\", [\"삼각익\", \"델타익\", \"delta\"], {\"직선익\": C, \"후퇴익\": C, \"쿼드콥터\": C}),\n", + " _sa(\"Q23\", \"경보보고\", 2, \"불확실한 관측을 보고할 때 붙이는 표현은?\", [\"추정\"], {\"확정\": C, \"확인\": C, \"생략\": P}),\n", + " _sa(\"Q24\", \"교전통제\", 1, \"AI 탐지 결과에 대해 최종 결정을 내리는 주체는?\", [\"지휘관\"], {\"AI\": C, \"운용병\": P, \"레이더\": C}),\n", + " _sa(\"Q25\", \"대드론일반\", 1, \"조류를 드론으로 잘못 경보하는 것을 무엇이라 하는가?\", [\"오경보\", \"오탐\", \"false alarm\"], {\"미탐\": C, \"재경보\": C, \"적기\": F}),\n", + " _sa(\"Q26\", \"피아식별\", 2, \"운항 정보가 확인된 여객기는 어떻게 분류하는가?\", [\"민간기\", \"민항기\"], {\"적기\": F, \"미확인기\": P, \"아군기\": F}),\n", + " _sa(\"Q27\", \"형상식별\", 1, \"회전날개 4개를 가진 드론 형태는?\", [\"쿼드콥터\", \"quadcopter\"], {\"헥사콥터\": C, \"고정익\": C, \"헬기\": F}),\n", + " _sa(\"Q28\", \"경보보고\", 1, \"보고는 무엇을 따라 올라가는가?\", [\"지휘계통\"], {\"인접부대\": P, \"개인메신저\": P, \"통신망\": C}),\n", + " _order(\"Q29\", \"경보보고\", 2, \"최초 보고 순서: A 관측 시각, B 방향·거리, C 추정 유형, D 관측자 조치\"),\n", + " _order(\"Q30\", \"대드론일반\", 2, \"처리 순서: A 탐지, B 식별, C 보고·판단, D 대응\"),\n", + " _order(\"Q31\", \"교전통제\", 3, \"통제 순서: A 식별 확인, B 상급 승인 요청, C 승인 수령, D 조치 기록\"),\n", + " _order(\"Q32\", \"피아식별\", 3, \"확인 순서: A 운항정보 대조, B 식별 신호 확인, C 미확인 분류, D 보고\"),\n", + "]\n", + "QIDX = {q[\"qid\"]: q for q in BANK}\n", + "UNITS = [\"가상1중대\", \"가상2중대\", \"가상3중대\"]\n", + "_CIRC = \"①②③④\"\n", + "PARA = {\"Q21\": \"아군적군 구분\", \"Q22\": \"세모 날개\", \"Q23\": \"추정됨으로 표기\", \"Q24\": \"중대장(지휘관)\", \"Q25\": \"잘못된 경보\",\n", + " \"Q26\": \"민간 여객기\", \"Q27\": \"4발 드론\", \"Q28\": \"지휘 라인\"} # 뜻은 맞지만 인정 답안 목록에 없는 표현\n", + "\n", + "\n", + "def make_soldiers(n_per_unit, seed):\n", + " rng = np.random.default_rng(seed)\n", + " out = []\n", + " for u, unit in enumerate(UNITS):\n", + " base = [0.3, -0.1, 0.0][u]\n", + " for i in range(n_per_unit):\n", + " off = rng.normal(0, 0.5, len(AREAS)) + (np.array([0, 0, 0, -0.6, 0]) if u == 1 else 0)\n", + " out.append({\"sid\": f\"S{len(out) + 1:03d}\", \"unit\": unit, \"theta\": dict(zip(AREAS, base + rng.normal(0, 0.6) + off)),\n", + " \"tend\": dict(zip(ETYPES, rng.dirichlet([1.2, 1.2, 1.2])))})\n", + " return out\n", + "\n", + "\n", + "def p_correct(theta, diff):\n", + " return 1 / (1 + np.exp(-1.7 * (theta - (diff - 2) * 0.9 + 0.9)))\n", + "\n", + "\n", + "def _typo(s, rng):\n", + " if len(s) < 2:\n", + " return s\n", + " i = int(rng.integers(len(s) - 1))\n", + " return s[:i] + s[i + 1] + s[i] + s[i + 2:] if len(s) <= 3 or rng.random() < 0.5 else s[:i] + s[i + 1:]\n", + "\n", + "\n", + "def _fmt_mc(k, rng):\n", + " r = rng.random()\n", + " if r < 0.55:\n", + " return str(k)\n", + " return rng.choice([_CIRC[k - 1], f\"{k}번\", \"ABCD\"[k - 1], \"abcd\"[k - 1] + \")\", f\" {k} \", chr(0xFF10 + k), f\"{k}.\"])\n", + "\n", + "\n", + "def _fmt_sa(s, rng):\n", + " r = rng.random()\n", + " if r < 0.45:\n", + " return s\n", + " if r < 0.6:\n", + " return \" \".join(s) if len(s) <= 4 else s[:2] + \" \" + s[2:]\n", + " if r < 0.72:\n", + " return s + rng.choice([\"입니다\", \"이다\", \".\", \"임\"])\n", + " if r < 0.8:\n", + " return s.upper() if s.isascii() else \"\".join(chr(ord(ch) + 0xFEE0) if \"!\" <= ch <= \"~\" else ch for ch in s) + \" \"\n", + " return _typo(s, rng)\n", + "\n", + "\n", + "def answer(q, sol, rng):\n", + " \"\"\"한 문항 응답: (원본 응답 문자열, 의도한 정답 여부, 의도한 답, 오답 유형).\"\"\"\n", + " ok = rng.random() < p_correct(sol[\"theta\"][q[\"area\"]], q[\"diff\"])\n", + " if q[\"type\"] == \"MC\":\n", + " if ok:\n", + " k, et = q[\"answer\"], None\n", + " else:\n", + " ks = list(q[\"dist\"]); w = np.array([sol[\"tend\"][q[\"dist\"][k]] for k in ks])\n", + " k = int(rng.choice(ks, p=w / w.sum())); et = q[\"dist\"][k]\n", + " return _fmt_mc(k, rng), ok, str(k), et\n", + " if q[\"type\"] == \"SA\":\n", + " if ok and rng.random() < 0.06:\n", + " return PARA[q[\"qid\"]], ok, PARA[q[\"qid\"]], None\n", + " if ok:\n", + " s, et = str(rng.choice(q[\"accept\"])), None\n", + " else:\n", + " ws = list(q[\"wrong\"]); w = np.array([sol[\"tend\"][q[\"wrong\"][x]] for x in ws])\n", + " s = str(rng.choice(ws, p=w / w.sum())); et = q[\"wrong\"][s]\n", + " return _fmt_sa(s, rng), ok, s, et\n", + " if ok:\n", + " s = \"ABCD\"\n", + " elif rng.random() < 0.5:\n", + " s = \"ABCD\".replace(\"ABCD\"[rng.integers(1, 4)], \"\") # 단계 하나 빠뜨림\n", + " else:\n", + " i = int(rng.integers(0, 3)); s = list(\"ABCD\"); s[i], s[i + 1] = s[i + 1], s[i]; s = \"\".join(s) # 순서 뒤바뀜\n", + " raw = s if rng.random() < 0.6 else rng.choice([\"-\".join(s), \" \".join(s), s.lower(), \",\".join(s)])\n", + " return raw, ok, s, (None if ok else P)\n", + "\n", + "\n", + "def make_responses(n_per_unit=40, seed=7, blank=0.02):\n", + " \"\"\"응시자 x 문항 응답표(DataFrame)를 만든다. truth_* 열은 교관 기준 정답(평가용)이다.\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " rows = []\n", + " for sol in make_soldiers(n_per_unit, seed):\n", + " for q in BANK:\n", + " raw, ok, intended, et = answer(q, sol, rng)\n", + " if rng.random() < blank:\n", + " raw, ok, intended, et = \"\", False, \"\", None\n", + " rows.append({\"rid\": f\"R{len(rows):05d}\", \"sid\": sol[\"sid\"], \"unit\": sol[\"unit\"], \"qid\": q[\"qid\"], \"raw\": raw,\n", + " \"truth_correct\": ok, \"truth_answer\": intended, \"truth_etype\": et})\n", + " return pd.DataFrame(rows)\n", + "# ---------------------------------------------------------------------------\n", + "\n", + "# ---- 응답 정제·파싱·채점 공통 함수 ----\n", + "TAILS = (\"입니다\", \"이다\", \"임\")\n", + "\n", + "\n", + "def norm_text(s):\n", + " \"\"\"전각->반각(NFKC), 소문자, 공백·문장부호 제거, 끝맺음 어미 제거.\"\"\"\n", + " s = unicodedata.normalize(\"NFKC\", str(s)).strip().lower()\n", + " s = re.sub(r\"[\\s\\.\\,\\-\\(\\)·]\", \"\", s)\n", + " for t in TAILS:\n", + " if s.endswith(t) and len(s) > len(t) + 1:\n", + " s = s[:-len(t)]\n", + " return s\n", + "\n", + "\n", + "def parse(q, raw):\n", + " \"\"\"원본 응답 -> (정제값, 상태). 상태: 정상 / 미응답 / 형식오류\"\"\"\n", + " if raw is None or str(raw).strip() == \"\":\n", + " return \"\", \"미응답\"\n", + " s = unicodedata.normalize(\"NFKC\", str(raw)).strip()\n", + " if q[\"type\"] == \"MC\":\n", + " t = re.sub(r\"[\\s번\\.\\)]\", \"\", s).lower()\n", + " t = {\"a\": \"1\", \"b\": \"2\", \"c\": \"3\", \"d\": \"4\"}.get(t, t)\n", + " return (t, \"정상\") if t in (\"1\", \"2\", \"3\", \"4\") else (t, \"형식오류\")\n", + " if q[\"type\"] == \"ORDER\":\n", + " t = re.sub(r\"[^A-Za-z]\", \"\", s).upper()\n", + " return (t, \"정상\") if t and set(t) <= set(\"ABCD\") and len(set(t)) == len(t) else (t, \"형식오류\")\n", + " return norm_text(s), \"정상\"\n", + "\n", + "\n", + "def grade_exact(q, val):\n", + " if q[\"type\"] == \"MC\":\n", + " return val == str(q[\"answer\"])\n", + " if q[\"type\"] == \"ORDER\":\n", + " return val == q[\"answer\"]\n", + " return val in {norm_text(a) for a in q[\"accept\"]}\n", + "\n", + "\n", + "def grade_fuzzy(q, val, cut=0.75):\n", + " \"\"\"단답형은 오탈자(글자 순서 바뀜·한 글자 누락)를 허용(유사도 cut 이상)하되, 알려진 오답과 더 가까우면 오답으로 본다.\"\"\"\n", + " if grade_exact(q, val) or q[\"type\"] != \"SA\" or len(val) < 2:\n", + " return grade_exact(q, val)\n", + " best = max(1.0 if sorted(val) == sorted(norm_text(a)) else difflib.SequenceMatcher(None, val, norm_text(a)).ratio() for a in q[\"accept\"])\n", + " worst = max(difflib.SequenceMatcher(None, val, norm_text(w)).ratio() for w in q[\"wrong\"])\n", + " return best >= cut and best > worst\n", + "\n", + "\n", + "def score_of(q, correct):\n", + " return POINTS[q[\"diff\"]] if correct else 0\n", + "\n", + "# ---- 서술형 문항(10점): 핵심 요소 5개 x 2점 ----\n", + "ESSAY = \"미확인 드론을 관측했을 때 보고와 대응 절차를 서술하시오.\"\n", + "ELEM = {\"관측정보\": ([\"관측 시각과 방향, 거리를 적는다\", \"언제 어느 방향 몇 km인지 기록한다\"], [\"시각\", \"방향\", \"거리\"]),\n", + " \"추정표현\": ([\"확실하지 않으면 추정이라고 밝힌다\", \"모르는 부분은 추정임을 명시한다\"], [\"추정\"]),\n", + " \"지휘계통\": ([\"지휘계통을 따라 즉시 보고한다\", \"소대장에게 보고하고 상급으로 올린다\"], [\"지휘계통\"]),\n", + " \"승인대기\": ([\"지휘관 승인 전에는 대응하지 않는다\", \"승인이 나기 전까지 조치하지 않고 대기한다\"], [\"승인\"]),\n", + " \"근거기록\": ([\"판단 근거를 기록한다\", \"조치 내용을 일지에 남긴다\"], [\"기록\", \"근거\"])}\n", + "WRONG_SENT = [\"승인 없이 바로 대응한다\", \"기록은 상황 종료 후 생략한다\", \"확정된 적 드론으로 보고한다\"] # 키워드는 있지만 틀린 진술\n", + "\n", + "\n", + "def make_essays(sids, seed):\n", + " rng = np.random.default_rng(seed)\n", + " out = []\n", + " for s in sids:\n", + " have = [e for e in ELEM if rng.random() < 0.65]\n", + " sents = [str(rng.choice(ELEM[e][0])) for e in have]\n", + " if rng.random() < 0.2:\n", + " sents.append(str(rng.choice(WRONG_SENT)))\n", + " rng.shuffle(sents)\n", + " out.append({\"sid\": s, \"text\": \" \".join(sents), \"truth\": 2 * len(have)})\n", + " return out\n", + "\n", + "\n", + "def auto_suggest(text):\n", + " \"\"\"키워드 포함 여부로 점수를 '제안'한다(확정 아님). 부정 문맥을 구분하지 못하는 한계가 있다.\"\"\"\n", + " hit = [e for e, (_, kws) in ELEM.items() if any(k in text for k in kws)]\n", + " conf = \"낮음\" if any(w in text for w in (\"없이\", \"생략\", \"확정\")) or len(text) < 25 else \"보통\"\n", + " return 2 * len(hit), hit, conf\n", + "\n", + "\n", + "class ReviewQueue:\n", + " \"\"\"우선순위 큐 + 해시 체인 감사 로그. 교관 결정 없이는 점수를 확정할 수 없다.\"\"\"\n", + " def __init__(self):\n", + " self.heap, self.items, self.log, self.final = [], {}, [], {}\n", + "\n", + " def _audit(self, event, **kw):\n", + " prev = self.log[-1][\"hash\"] if self.log else \"GENESIS\"\n", + " body = {\"seq\": len(self.log), \"event\": event, **kw}\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 submit(self, iid, prio, t, payload):\n", + " self.items[iid] = {**payload, \"t_in\": t, \"status\": \"PENDING\"}\n", + " heapq.heappush(self.heap, (prio, t, iid))\n", + " self._audit(\"SUBMIT\", iid=iid, prio=prio, suggest=payload.get(\"suggest\"))\n", + "\n", + " def pop(self):\n", + " return heapq.heappop(self.heap)[2] if self.heap else None\n", + "\n", + " def decide(self, iid, reviewer, decision, score, reason, t):\n", + " if not reason:\n", + " raise ValueError(\"사유 없는 결정은 기록할 수 없다\")\n", + " it = self.items[iid]\n", + " it.update(status=decision, score=score, reviewer=reviewer, t_out=t)\n", + " self.final[iid] = score\n", + " self._audit(\"DECIDE\", iid=iid, reviewer=reviewer, decision=decision, score=score, reason=reason)\n", + "\n", + " def finalize_without_review(self, iid):\n", + " if self.items[iid][\"status\"] == \"PENDING\":\n", + " raise PermissionError(\"교관 결정 전 자동 확정 금지\")\n", + "\n", + " def verify(self):\n", + " prev = \"GENESIS\"\n", + " for e in self.log:\n", + " body = {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(body, sort_keys=True, ensure_ascii=False)).encode()).hexdigest() != e[\"hash\"]:\n", + " return False, e[\"seq\"]\n", + " prev = e[\"hash\"]\n", + " return True, None\n", + "\n", + "\n", + "rng = np.random.default_rng(3)\n", + "df = make_responses(40, seed=7)\n", + "df[\"val\"] = [parse(QIDX[q], r)[0] for q, r in zip(df[\"qid\"], df[\"raw\"])]\n", + "df[\"correct\"] = [grade_fuzzy(QIDX[q], v) for q, v in zip(df[\"qid\"], df[\"val\"])]\n", + "df[\"score\"] = [score_of(QIDX[q], c) for q, c in zip(df[\"qid\"], df[\"correct\"])]\n", + "total = df.groupby(\"sid\")[\"score\"].sum()\n", + "PASS = 0.7 * (sum(POINTS[q[\"diff\"]] for q in BANK) + 10)\n", + "\n", + "# 1) 이의 제기: 자동 채점 오답 처리된 단답형 중 응시자가 이의를 낸 건(실제 맞은 답은 대부분 이의 제기, 틀린 답도 일부 제기)\n", + "sa_wrong = df[(df[\"qid\"].map(lambda q: QIDX[q][\"type\"]) == \"SA\") & ~df[\"correct\"] & (df[\"raw\"] != \"\")]\n", + "appeal = sa_wrong[sa_wrong[\"truth_correct\"] | (rng.random(len(sa_wrong)) < 0.08)]\n", + "essays = make_essays(sorted(df[\"sid\"].unique()), seed=5)\n", + "print(f\"[교육용가상자료] 이의 제기 {len(appeal)}건, 서술형 답안 {len(essays)}건이 교관 검토 대기열에 들어간다.\")\n", + "\n", + "q = ReviewQueue()\n", + "t = 0.0\n", + "for e in essays:\n", + " sug, hit, conf = auto_suggest(e[\"text\"])\n", + " est = total[e[\"sid\"]] + sug\n", + " prio = (0 if abs(est - PASS) <= 6 else 1) * 10 + (0 if conf == \"낮음\" else 1) # 합격선 근처·저신뢰 먼저\n", + " q.submit(f\"E-{e['sid']}\", prio, t, {\"kind\": \"서술형\", \"sid\": e[\"sid\"], \"suggest\": sug, \"conf\": conf, \"truth\": e[\"truth\"], \"text\": e[\"text\"]})\n", + " t += 0.05\n", + "for _, r in appeal.iterrows():\n", + " est = total[r[\"sid\"]]\n", + " prio = (0 if abs(est - PASS) <= 6 else 1) * 10 + 2\n", + " q.submit(f\"A-{r['rid']}\", prio, t, {\"kind\": \"이의 제기\", \"sid\": r[\"sid\"], \"qid\": r[\"qid\"], \"raw\": r[\"raw\"], \"suggest\": 0,\n", + " \"truth\": score_of(QIDX[r[\"qid\"]], r[\"truth_correct\"])})\n", + " t += 0.05\n", + "\n", + "# 2) 자동 확정 시도는 차단되어야 한다\n", + "blocked = 0\n", + "for iid in list(q.items)[:5]:\n", + " try:\n", + " q.finalize_without_review(iid)\n", + " except PermissionError:\n", + " blocked += 1\n", + "try:\n", + " q.decide(list(q.items)[0], \"교관A\", \"인정\", 2, \"\", t)\n", + " no_reason = \"통과(오류)\"\n", + "except ValueError:\n", + " no_reason = \"차단\"\n", + "print(f\"결정 전 자동 확정 시도 5건 중 차단 {blocked}건, 사유 없는 결정 {no_reason}\")\n", + "\n", + "# 3) 교관 2명이 우선순위대로 검토(모의 교관은 기준 답안으로 판단: 건당 서술형 2분, 이의 제기 1분)\n", + "clock = {\"교관A\": 0.0, \"교관B\": 0.0}\n", + "while True:\n", + " iid = q.pop()\n", + " if iid is None:\n", + " break\n", + " it = q.items[iid]\n", + " who = min(clock, key=clock.get)\n", + " clock[who] = max(clock[who], it[\"t_in\"]) + (2.0 if it[\"kind\"] == \"서술형\" else 1.0)\n", + " if it[\"kind\"] == \"서술형\":\n", + " dec = \"승인\" if it[\"truth\"] == it[\"suggest\"] else \"수정\"\n", + " reason = \"제안 점수와 요소 판단 일치\" if dec == \"승인\" else f\"요소 재판단(제안 {it['suggest']} -> {it['truth']})\"\n", + " else:\n", + " dec = \"인정\" if it[\"truth\"] > 0 else \"기각\"\n", + " reason = f\"'{it['raw']}' 의미상 정답 인정\" if dec == \"인정\" else f\"'{it['raw']}'는 오답 유지\"\n", + " q.decide(iid, who, dec, it[\"truth\"], reason, clock[who])\n", + "\n", + "R = pd.DataFrame(q.items.values())\n", + "R[\"wait\"] = R[\"t_out\"] - R[\"t_in\"]\n", + "R[\"border\"] = [abs(total[s] + (sg if k == \"서술형\" else 0) - PASS) <= 6 for s, sg, k in zip(R[\"sid\"], R[\"suggest\"], R[\"kind\"])]\n", + "print(\"\\n[검토 결과]\")\n", + "print(R.groupby([\"kind\", \"status\"]).size().to_string())\n", + "ess = R[R[\"kind\"] == \"서술형\"]\n", + "print(f\"서술형 자동 제안 점수와 교관 판단 일치 {(ess['status'] == '승인').mean():.1%}, 수정 {(ess['status'] == '수정').sum()}건\"\n", + " f\"(틀린 진술 속 키워드에 속은 제안 포함)\")\n", + "print(f\"대기시간(분): 합격선 근처 평균 {R.loc[R['border'], 'wait'].mean():.1f}, 그 외 평균 {R.loc[~R['border'], 'wait'].mean():.1f}, 최대 {R['wait'].max():.1f}\")\n", + "\n", + "n_dec = sum(e[\"event\"] == \"DECIDE\" for e in q.log)\n", + "print(f\"승인/반려 처리 {len(R)}건, 감사 로그 기록 {n_dec}건, 로깅 성공률 {n_dec / len(R):.0%} (목표 100%: {'달성' if n_dec == len(R) else '미달'})\")\n", + "ok, _ = q.verify()\n", + "q.log[7][\"score\"] = 10 # 감사 로그 사후 변조 시도\n", + "ok2, where = q.verify()\n", + "print(f\"무결성 검증: 원본 {'정상' if ok else '이상'}, 변조 후 {'정상' if ok2 else f'이상 탐지(seq {where})'}\")\n", + "\n", + "changed = R[(R[\"kind\"] == \"이의 제기\") & (R[\"status\"] == \"인정\")].groupby(\"sid\")[\"score\"].sum()\n", + "newtot = total.add(changed, fill_value=0) + ess.set_index(\"sid\")[\"score\"]\n", + "flip = int(((total + ess.set_index(\"sid\")[\"suggest\"] >= PASS) != (newtot >= PASS)).sum())\n", + "print(f\"교관 검토로 합격/불합격이 바뀐 응시자 {flip}명 -> 자동 채점만으로 확정하면 안 되는 이유\")\n", + "print(\"\\n[HITL 검토 지침 요약] 1) 자동 점수는 제안일 뿐 교관이 확정 2) 인정·기각·수정 모두 사유 필수\"\n", + " \" 3) 합격선 근처·저신뢰 답안 우선 4) 감사 로그는 수정 불가, 이의 재심은 상급 교관(지휘 책임선)이 판단\")\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 07 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + "[교육용가상자료] 이의 제기 45건, 서술형 답안 120건이 교관 검토 대기열에 들어간다.\n", + "결정 전 자동 확정 시도 5건 중 차단 5건, 사유 없는 결정 차단\n", + "\n", + "[검토 결과]\n", + "kind status\n", + "서술형 수정 54\n", + " 승인 66\n", + "이의 제기 기각 7\n", + " 인정 38\n", + "서술형 자동 제안 점수와 교관 판단 일치 55.0%, 수정 54건(틀린 진술 속 키워드에 속은 제안 포함)\n", + "대기시간(분): 합격선 근처 평균 20.0, 그 외 평균 94.7, 최대 137.4\n", + "승인/반려 처리 165건, 감사 로그 기록 165건, 로깅 성공률 100% (목표 100%: 달성)\n", + "무결성 검증: 원본 정상, 변조 후 이상 탐지(seq 7)\n", + "교관 검토로 합격/불합격이 바뀐 응시자 3명 -> 자동 채점만으로 확정하면 안 되는 이유\n", + "\n", + "[HITL 검토 지침 요약] 1) 자동 점수는 제안일 뿐 교관이 확정 2) 인정·기각·수정 모두 사유 필수 3) 합격선 근처·저신뢰 답안 우선 4) 감사 로그는 수정 불가, 이의 재심은 상급 교관(지휘 책임선)이 판단\n", + "```\n", + "
\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 08. 전 부대원 동시 응시 모의 대규모 트래픽 채점 부하 테스트 및 최적화\n", + "**소요시간**: 약 3.5시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "전 부대원 동시 응시 모의 대규모 트래픽 채점 부하 테스트를 수행한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **전 부대원이 동시에 응시하는 상황을 모의해 채점 시스템이 대규모 제출과 장애 속에서도 멈추거나 답안을 잃지 않는지 시험한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **평시 120명 제출량을 3배·6배·11배로 늘리고 초장문·제어문자·없는 문항 등 손상 답안과 채점기 응답 지연(장애 2%) 주입하여 처리 지연 및 복원력을 시험하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `tests/trn02_stress_test.py 및 스트레스 테스트 분석 보고서` 형태의 결과물을 완성한다.\n", + "\n", + "### 🔍 평가 및 검증 기준 (PASS 조건)\n", + "#### 미션 판정\n", + "\n", + "**평가 구분:** 필수\n", + "\n", + "**핵심 평가영역:** 극단 상황에서의 안전 실패(Fail-safe) 및 기능 축소 작동 적절성\n", + "\n", + "#### 미션 PASS 조건\n", + "\n", + "- 정량 기준: 동시 트래픽 500% 증가 시 시스템 다운 0건, 데이터 유실률 0%\n", + "\n", + "\n", + "- 진행 기준: 대량 시뮬레이션 부하 주입 테스트 통과 및 병목 구간 프로파일링 완료\n", + "\n", + "\n", + "- 검증 방법: 부하 테스트 도구를 통한 처리량(Throughput) 및 메모리 누수 검증\n", + "\n", + "\n", + "#### 채점자 판정 메모\n", + "\n", + "> 단순 코드 실행 여부뿐만 아니라 출력된 수치와 근거 자료가 군 실무 요구사항에 부합하는지 면밀히 확인한다.\n", + "\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "\"\"\"전 부대원 동시 응시를 모의해 평시(120명) 대비 최대 1,000% 늘어난 제출량과 손상 답안·채점기 장애를 주입하는 채점 부하 테스트를 수행한다. 처리량, 시스템 다운, 데이터 유실을 측정하고, 장애 시 '채점 보류'로 보존하는 안전 실패 동작과 캐시 최적화 전후 처리량을 비교한다.\"\"\"\n", + "import difflib\n", + "import functools\n", + "import re\n", + "import time\n", + "import tracemalloc\n", + "import unicodedata\n", + "from collections import deque\n", + "import numpy as np\n", + "import pandas as pd\n", + "import matplotlib.pyplot as plt\n", + "\n", + "# ---------------------------------------------------------------------------\n", + "# [교육용가상자료] 방공·드론 식별 퀴즈 문항 은행과 가상 응시자 응답 생성기\n", + "# 문항은 일반 원칙 수준의 가상 문항이며 실제 교범·교전규칙 원문이 아니다. 응시자는 가명(S001~)이다.\n", + "# ---------------------------------------------------------------------------\n", + "AREAS = [\"형상식별\", \"피아식별\", \"경보보고\", \"교전통제\", \"대드론일반\"]\n", + "ETYPES = [\"개념 혼동\", \"피아 오인\", \"절차 누락\"]\n", + "POINTS = {1: 2, 2: 3, 3: 5}\n", + "C, F, P = ETYPES\n", + "\n", + "\n", + "def _mc(qid, area, diff, stem, opts, ans, dist):\n", + " return {\"qid\": qid, \"type\": \"MC\", \"area\": area, \"diff\": diff, \"stem\": stem, \"options\": opts, \"answer\": ans,\n", + " \"dist\": dict(zip([i for i in range(1, 5) if i != ans], dist))}\n", + "\n", + "\n", + "def _sa(qid, area, diff, stem, accept, wrong):\n", + " return {\"qid\": qid, \"type\": \"SA\", \"area\": area, \"diff\": diff, \"stem\": stem, \"accept\": accept, \"wrong\": wrong}\n", + "\n", + "\n", + "def _order(qid, area, diff, stem):\n", + " return {\"qid\": qid, \"type\": \"ORDER\", \"area\": area, \"diff\": diff, \"stem\": stem, \"answer\": \"ABCD\"}\n", + "\n", + "\n", + "BANK = [\n", + " _mc(\"Q01\", \"형상식별\", 1, \"꼬리날개 없이 삼각형 날개를 가진 소형 고정익 실루엣은?\", [\"쿼드콥터형\", \"삼각익 고정익형\", \"회전익 헬기형\", \"조류\"], 2, [C, C, F]),\n", + " _mc(\"Q02\", \"형상식별\", 1, \"회전날개 4개가 십자로 배치된 실루엣은?\", [\"고정익 드론형\", \"쿼드콥터형\", \"순항미사일형\", \"탄도미사일형\"], 2, [C, C, C]),\n", + " _mc(\"Q03\", \"형상식별\", 2, \"가늘고 긴 동체 중간에 작은 날개가 붙은 실루엣은?\", [\"순항미사일형\", \"탄도미사일형\", \"민간 경비행기형\", \"조류\"], 1, [C, F, F]),\n", + " _mc(\"Q04\", \"형상식별\", 3, \"원거리라 실루엣 식별이 어려울 때 먼저 할 행동은?\", [\"추정 유형으로 확정 보고\", \"관측 정보를 그대로 보고하고 추가 관측\", \"관측 중단\", \"민간기로 간주\"], 2, [P, P, F]),\n", + " _mc(\"Q05\", \"피아식별\", 2, \"식별 신호 응답이 없는 항공기에 대한 올바른 판단은?\", [\"즉시 적기로 확정\", \"미확인 항공기로 분류하고 추가 확인\", \"아군기로 간주\", \"무시\"], 2, [F, F, P]),\n", + " _mc(\"Q06\", \"피아식별\", 2, \"운항 정보와 일치하는 저속 비행체의 분류는?\", [\"적 드론\", \"민간기 가능성 높음, 추가 확인\", \"순항미사일\", \"아군 정찰기로 확정\"], 2, [F, C, F]),\n", + " _mc(\"Q07\", \"피아식별\", 3, \"아군 드론 운용 공지가 있는 공역에서 미확인 드론을 보면 우선 확인할 것은?\", [\"기상 정보\", \"아군 운용 계획과 식별 신호\", \"대응 준비 상태\", \"주변 지형\"], 2, [C, P, C]),\n", + " _mc(\"Q08\", \"피아식별\", 3, \"조류 무리와 소형 드론 군집 구분에 가장 도움이 되는 정보는?\", [\"크기만 비교\", \"궤적의 규칙성과 열 신호\", \"색상\", \"관측 시각\"], 2, [C, C, C]),\n", + " _mc(\"Q09\", \"경보보고\", 1, \"공중 위협 최초 보고에 포함하지 않아도 되는 것은?\", [\"관측 시각\", \"방향과 거리\", \"추정 유형\", \"관측자 개인 연락처\"], 4, [P, P, P]),\n", + " _mc(\"Q10\", \"경보보고\", 2, \"경보 전파 후 상황이 바뀌면?\", [\"최초 보고 유지\", \"변경 사항을 즉시 추가 보고\", \"상황 종료 후 일괄 보고\", \"개인 판단으로 경보 해제\"], 2, [P, P, P]),\n", + " _mc(\"Q11\", \"경보보고\", 2, \"보고 내용이 불확실할 때 올바른 표현은?\", [\"확정 표현으로 보고\", \"'추정'임을 밝히고 근거와 함께 보고\", \"보고 보류\", \"다른 관측자 보고를 복사\"], 2, [C, P, P]),\n", + " _mc(\"Q12\", \"경보보고\", 1, \"드론 탐지 후 보고 경로로 알맞은 것은?\", [\"인접 부대에만 전파\", \"지휘계통을 따라 보고\", \"개인 메신저로 공유\", \"보고 없이 대응\"], 2, [P, P, P]),\n", + " _mc(\"Q13\", \"교전통제\", 1, \"AI 탐지 결과만으로 대응을 결정할 수 있는가?\", [\"가능하다\", \"인간 지휘관의 승인이 필요하다\", \"신뢰도 90% 이상이면 가능\", \"야간에만 가능\"], 2, [C, C, C]),\n", + " _mc(\"Q14\", \"교전통제\", 2, \"민간 피해 가능성이 확인되면?\", [\"그대로 진행\", \"즉시 보고하고 지휘관 판단을 받는다\", \"조치 후 보고\", \"무시\"], 2, [P, P, F]),\n", + " _mc(\"Q15\", \"교전통제\", 3, \"식별이 불확실한 표적에 대한 원칙은?\", [\"위협으로 간주해 즉시 대응\", \"식별 확인 전까지 승인 절차를 따른다\", \"무조건 무시\", \"다수결로 결정\"], 2, [F, F, C]),\n", + " _mc(\"Q16\", \"교전통제\", 2, \"판단 근거를 기록해야 하는 이유는?\", [\"형식상 필요\", \"사후 검토와 책임 추적\", \"보고 생략\", \"기록 불필요\"], 2, [C, P, P]),\n", + " _mc(\"Q17\", \"대드론일반\", 1, \"대드론 체계의 일반적 처리 순서는?\", [\"대응-탐지-식별\", \"탐지-식별-보고·판단-대응\", \"식별-대응-탐지\", \"보고-탐지-대응\"], 2, [P, P, P]),\n", + " _mc(\"Q18\", \"대드론일반\", 3, \"레이더만으로 조류와 드론 구분이 어려운 이유는?\", [\"반사 신호 크기가 비슷할 수 있어서\", \"레이더는 야간에 못 써서\", \"드론에는 금속이 없어서\", \"조류는 탐지되지 않아서\"], 1, [C, C, C]),\n", + " _mc(\"Q19\", \"대드론일반\", 2, \"전자광학(EO) 센서의 약점이 커지는 조건은?\", [\"맑은 주간\", \"안개·야간\", \"저속 표적\", \"근거리\"], 2, [C, C, C]),\n", + " _mc(\"Q20\", \"대드론일반\", 2, \"경보가 반복해서 오경보로 판명되면?\", [\"경보 장치를 끈다\", \"사례를 기록·보고하고 기준 재검토 요청\", \"무시한다\", \"모두 실제 위협으로 간주\"], 2, [P, P, F]),\n", + " _sa(\"Q21\", \"피아식별\", 1, \"아군·적·민간을 구분하는 절차를 무엇이라 하는가?\", [\"피아식별\", \"IFF\"], {\"표적획득\": C, \"적기판정\": F, \"경계\": C}),\n", + " _sa(\"Q22\", \"형상식별\", 2, \"꼬리날개 없는 삼각형 날개 형태를 무엇이라 하는가?\", [\"삼각익\", \"델타익\", \"delta\"], {\"직선익\": C, \"후퇴익\": C, \"쿼드콥터\": C}),\n", + " _sa(\"Q23\", \"경보보고\", 2, \"불확실한 관측을 보고할 때 붙이는 표현은?\", [\"추정\"], {\"확정\": C, \"확인\": C, \"생략\": P}),\n", + " _sa(\"Q24\", \"교전통제\", 1, \"AI 탐지 결과에 대해 최종 결정을 내리는 주체는?\", [\"지휘관\"], {\"AI\": C, \"운용병\": P, \"레이더\": C}),\n", + " _sa(\"Q25\", \"대드론일반\", 1, \"조류를 드론으로 잘못 경보하는 것을 무엇이라 하는가?\", [\"오경보\", \"오탐\", \"false alarm\"], {\"미탐\": C, \"재경보\": C, \"적기\": F}),\n", + " _sa(\"Q26\", \"피아식별\", 2, \"운항 정보가 확인된 여객기는 어떻게 분류하는가?\", [\"민간기\", \"민항기\"], {\"적기\": F, \"미확인기\": P, \"아군기\": F}),\n", + " _sa(\"Q27\", \"형상식별\", 1, \"회전날개 4개를 가진 드론 형태는?\", [\"쿼드콥터\", \"quadcopter\"], {\"헥사콥터\": C, \"고정익\": C, \"헬기\": F}),\n", + " _sa(\"Q28\", \"경보보고\", 1, \"보고는 무엇을 따라 올라가는가?\", [\"지휘계통\"], {\"인접부대\": P, \"개인메신저\": P, \"통신망\": C}),\n", + " _order(\"Q29\", \"경보보고\", 2, \"최초 보고 순서: A 관측 시각, B 방향·거리, C 추정 유형, D 관측자 조치\"),\n", + " _order(\"Q30\", \"대드론일반\", 2, \"처리 순서: A 탐지, B 식별, C 보고·판단, D 대응\"),\n", + " _order(\"Q31\", \"교전통제\", 3, \"통제 순서: A 식별 확인, B 상급 승인 요청, C 승인 수령, D 조치 기록\"),\n", + " _order(\"Q32\", \"피아식별\", 3, \"확인 순서: A 운항정보 대조, B 식별 신호 확인, C 미확인 분류, D 보고\"),\n", + "]\n", + "QIDX = {q[\"qid\"]: q for q in BANK}\n", + "UNITS = [\"가상1중대\", \"가상2중대\", \"가상3중대\"]\n", + "_CIRC = \"①②③④\"\n", + "PARA = {\"Q21\": \"아군적군 구분\", \"Q22\": \"세모 날개\", \"Q23\": \"추정됨으로 표기\", \"Q24\": \"중대장(지휘관)\", \"Q25\": \"잘못된 경보\",\n", + " \"Q26\": \"민간 여객기\", \"Q27\": \"4발 드론\", \"Q28\": \"지휘 라인\"} # 뜻은 맞지만 인정 답안 목록에 없는 표현\n", + "\n", + "\n", + "def make_soldiers(n_per_unit, seed):\n", + " rng = np.random.default_rng(seed)\n", + " out = []\n", + " for u, unit in enumerate(UNITS):\n", + " base = [0.3, -0.1, 0.0][u]\n", + " for i in range(n_per_unit):\n", + " off = rng.normal(0, 0.5, len(AREAS)) + (np.array([0, 0, 0, -0.6, 0]) if u == 1 else 0)\n", + " out.append({\"sid\": f\"S{len(out) + 1:03d}\", \"unit\": unit, \"theta\": dict(zip(AREAS, base + rng.normal(0, 0.6) + off)),\n", + " \"tend\": dict(zip(ETYPES, rng.dirichlet([1.2, 1.2, 1.2])))})\n", + " return out\n", + "\n", + "\n", + "def p_correct(theta, diff):\n", + " return 1 / (1 + np.exp(-1.7 * (theta - (diff - 2) * 0.9 + 0.9)))\n", + "\n", + "\n", + "def _typo(s, rng):\n", + " if len(s) < 2:\n", + " return s\n", + " i = int(rng.integers(len(s) - 1))\n", + " return s[:i] + s[i + 1] + s[i] + s[i + 2:] if len(s) <= 3 or rng.random() < 0.5 else s[:i] + s[i + 1:]\n", + "\n", + "\n", + "def _fmt_mc(k, rng):\n", + " r = rng.random()\n", + " if r < 0.55:\n", + " return str(k)\n", + " return rng.choice([_CIRC[k - 1], f\"{k}번\", \"ABCD\"[k - 1], \"abcd\"[k - 1] + \")\", f\" {k} \", chr(0xFF10 + k), f\"{k}.\"])\n", + "\n", + "\n", + "def _fmt_sa(s, rng):\n", + " r = rng.random()\n", + " if r < 0.45:\n", + " return s\n", + " if r < 0.6:\n", + " return \" \".join(s) if len(s) <= 4 else s[:2] + \" \" + s[2:]\n", + " if r < 0.72:\n", + " return s + rng.choice([\"입니다\", \"이다\", \".\", \"임\"])\n", + " if r < 0.8:\n", + " return s.upper() if s.isascii() else \"\".join(chr(ord(ch) + 0xFEE0) if \"!\" <= ch <= \"~\" else ch for ch in s) + \" \"\n", + " return _typo(s, rng)\n", + "\n", + "\n", + "def answer(q, sol, rng):\n", + " \"\"\"한 문항 응답: (원본 응답 문자열, 의도한 정답 여부, 의도한 답, 오답 유형).\"\"\"\n", + " ok = rng.random() < p_correct(sol[\"theta\"][q[\"area\"]], q[\"diff\"])\n", + " if q[\"type\"] == \"MC\":\n", + " if ok:\n", + " k, et = q[\"answer\"], None\n", + " else:\n", + " ks = list(q[\"dist\"]); w = np.array([sol[\"tend\"][q[\"dist\"][k]] for k in ks])\n", + " k = int(rng.choice(ks, p=w / w.sum())); et = q[\"dist\"][k]\n", + " return _fmt_mc(k, rng), ok, str(k), et\n", + " if q[\"type\"] == \"SA\":\n", + " if ok and rng.random() < 0.06:\n", + " return PARA[q[\"qid\"]], ok, PARA[q[\"qid\"]], None\n", + " if ok:\n", + " s, et = str(rng.choice(q[\"accept\"])), None\n", + " else:\n", + " ws = list(q[\"wrong\"]); w = np.array([sol[\"tend\"][q[\"wrong\"][x]] for x in ws])\n", + " s = str(rng.choice(ws, p=w / w.sum())); et = q[\"wrong\"][s]\n", + " return _fmt_sa(s, rng), ok, s, et\n", + " if ok:\n", + " s = \"ABCD\"\n", + " elif rng.random() < 0.5:\n", + " s = \"ABCD\".replace(\"ABCD\"[rng.integers(1, 4)], \"\") # 단계 하나 빠뜨림\n", + " else:\n", + " i = int(rng.integers(0, 3)); s = list(\"ABCD\"); s[i], s[i + 1] = s[i + 1], s[i]; s = \"\".join(s) # 순서 뒤바뀜\n", + " raw = s if rng.random() < 0.6 else rng.choice([\"-\".join(s), \" \".join(s), s.lower(), \",\".join(s)])\n", + " return raw, ok, s, (None if ok else P)\n", + "\n", + "\n", + "def make_responses(n_per_unit=40, seed=7, blank=0.02):\n", + " \"\"\"응시자 x 문항 응답표(DataFrame)를 만든다. truth_* 열은 교관 기준 정답(평가용)이다.\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " rows = []\n", + " for sol in make_soldiers(n_per_unit, seed):\n", + " for q in BANK:\n", + " raw, ok, intended, et = answer(q, sol, rng)\n", + " if rng.random() < blank:\n", + " raw, ok, intended, et = \"\", False, \"\", None\n", + " rows.append({\"rid\": f\"R{len(rows):05d}\", \"sid\": sol[\"sid\"], \"unit\": sol[\"unit\"], \"qid\": q[\"qid\"], \"raw\": raw,\n", + " \"truth_correct\": ok, \"truth_answer\": intended, \"truth_etype\": et})\n", + " return pd.DataFrame(rows)\n", + "# ---------------------------------------------------------------------------\n", + "\n", + "# ---- 응답 정제·파싱·채점 공통 함수 ----\n", + "TAILS = (\"입니다\", \"이다\", \"임\")\n", + "\n", + "\n", + "def norm_text(s):\n", + " \"\"\"전각->반각(NFKC), 소문자, 공백·문장부호 제거, 끝맺음 어미 제거.\"\"\"\n", + " s = unicodedata.normalize(\"NFKC\", str(s)).strip().lower()\n", + " s = re.sub(r\"[\\s\\.\\,\\-\\(\\)·]\", \"\", s)\n", + " for t in TAILS:\n", + " if s.endswith(t) and len(s) > len(t) + 1:\n", + " s = s[:-len(t)]\n", + " return s\n", + "\n", + "\n", + "def parse(q, raw):\n", + " \"\"\"원본 응답 -> (정제값, 상태). 상태: 정상 / 미응답 / 형식오류\"\"\"\n", + " if raw is None or str(raw).strip() == \"\":\n", + " return \"\", \"미응답\"\n", + " s = unicodedata.normalize(\"NFKC\", str(raw)).strip()\n", + " if q[\"type\"] == \"MC\":\n", + " t = re.sub(r\"[\\s번\\.\\)]\", \"\", s).lower()\n", + " t = {\"a\": \"1\", \"b\": \"2\", \"c\": \"3\", \"d\": \"4\"}.get(t, t)\n", + " return (t, \"정상\") if t in (\"1\", \"2\", \"3\", \"4\") else (t, \"형식오류\")\n", + " if q[\"type\"] == \"ORDER\":\n", + " t = re.sub(r\"[^A-Za-z]\", \"\", s).upper()\n", + " return (t, \"정상\") if t and set(t) <= set(\"ABCD\") and len(set(t)) == len(t) else (t, \"형식오류\")\n", + " return norm_text(s), \"정상\"\n", + "\n", + "\n", + "def grade_exact(q, val):\n", + " if q[\"type\"] == \"MC\":\n", + " return val == str(q[\"answer\"])\n", + " if q[\"type\"] == \"ORDER\":\n", + " return val == q[\"answer\"]\n", + " return val in {norm_text(a) for a in q[\"accept\"]}\n", + "\n", + "\n", + "def grade_fuzzy(q, val, cut=0.75):\n", + " \"\"\"단답형은 오탈자(글자 순서 바뀜·한 글자 누락)를 허용(유사도 cut 이상)하되, 알려진 오답과 더 가까우면 오답으로 본다.\"\"\"\n", + " if grade_exact(q, val) or q[\"type\"] != \"SA\" or len(val) < 2:\n", + " return grade_exact(q, val)\n", + " best = max(1.0 if sorted(val) == sorted(norm_text(a)) else difflib.SequenceMatcher(None, val, norm_text(a)).ratio() for a in q[\"accept\"])\n", + " worst = max(difflib.SequenceMatcher(None, val, norm_text(w)).ratio() for w in q[\"wrong\"])\n", + " return best >= cut and best > worst\n", + "\n", + "\n", + "def score_of(q, correct):\n", + " return POINTS[q[\"diff\"]] if correct else 0\n", + "\n", + "\n", + "def grade_one(qid, raw):\n", + " q = QIDX[qid]\n", + " val, st = parse(q, raw)\n", + " return st, (st == \"정상\" and grade_fuzzy(q, val))\n", + "\n", + "\n", + "@functools.lru_cache(maxsize=50000)\n", + "def grade_cached(qid, raw):\n", + " \"\"\"같은 (문항, 원본 응답)은 다시 계산하지 않는다(대규모 응시에서 같은 답이 반복됨).\"\"\"\n", + " return grade_one(qid, raw)\n", + "\n", + "\n", + "def corrupt(df, rng, rate):\n", + " \"\"\"손상 답안 주입: 초장문, 제어문자, 존재하지 않는 문항, None.\"\"\"\n", + " df = df.copy()\n", + " idx = rng.choice(len(df), int(len(df) * rate), replace=False)\n", + " kinds = rng.integers(0, 4, len(idx))\n", + " for i, k in zip(idx, kinds):\n", + " df.at[df.index[i], \"raw\"] = [\"가\" * 20000, \"\\x00\\x07삼각익\", None, \"1\"][k]\n", + " if k == 3:\n", + " df.at[df.index[i], \"qid\"] = \"Q99\"\n", + " return df\n", + "\n", + "\n", + "def run(df, fn, fail_rate=0.0, seed=0, cap=2000):\n", + " \"\"\"제출 큐에서 cap건씩 꺼내 채점. 채점 중 예외·장애가 나면 '채점 보류' 큐로 옮기고 계속 처리한다(안전 실패).\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " inbox, result, hold = deque(df[[\"rid\", \"qid\", \"raw\"]].itertuples(index=False)), {}, {}\n", + " t0, crashes, max_q = time.perf_counter(), 0, len(inbox)\n", + " while inbox:\n", + " batch = [inbox.popleft() for _ in range(min(cap, len(inbox)))]\n", + " for rid, qid, raw in batch:\n", + " try:\n", + " if qid not in QIDX:\n", + " raise KeyError(\"문항 없음\")\n", + " if raw is not None and len(str(raw)) > 200:\n", + " raise ValueError(\"응답 길이 초과\")\n", + " if rng.random() < fail_rate:\n", + " raise TimeoutError(\"채점기 응답 없음\")\n", + " result[rid] = fn(qid, \"\" if raw is None else str(raw).replace(\"\\x00\", \"\").replace(\"\\x07\", \"\"))\n", + " except Exception as e: # 시스템은 멈추지 않고 보류 큐에 원본을 보존\n", + " hold[rid] = type(e).__name__\n", + " sec = time.perf_counter() - t0\n", + " lost = len(df) - len(result) - len(hold)\n", + " return {\"건수\": len(df), \"처리(초)\": round(sec, 2), \"처리량(건/초)\": int(len(df) / sec), \"채점 완료\": len(result),\n", + " \"보류\": len(hold), \"유실\": lost, \"다운\": crashes, \"최대 대기열\": max_q}, hold\n", + "\n", + "\n", + "base = make_responses(40, seed=7) # 평시: 120명 x 32문항\n", + "rng = np.random.default_rng(1)\n", + "rows, holds = [], {}\n", + "for mult, fail in [(1, 0.0), (3, 0.0), (6, 0.0), (6, 0.02), (11, 0.02)]:\n", + " df = pd.concat([base.assign(rid=base[\"rid\"] + f\"-{k}\") for k in range(mult)], ignore_index=True)\n", + " df = corrupt(df, rng, 0.003)\n", + " grade_cached.cache_clear()\n", + " tracemalloc.start()\n", + " r, hold = run(df, grade_cached, fail_rate=fail, seed=mult)\n", + " peak = tracemalloc.get_traced_memory()[1] / 1e6; tracemalloc.stop()\n", + " rows.append({\"부하\": f\"평시 x{mult}\" + (\" +장애2%\" if fail else \"\"), **r, \"메모리 최대(MB)\": round(peak, 1)})\n", + " holds[rows[-1][\"부하\"]] = hold\n", + "T = pd.DataFrame(rows)\n", + "print(T.drop(columns=[\"다운\", \"최대 대기열\"]).to_string(index=False))\n", + "print(\"(위 표의 처리량은 메모리 추적을 켠 상태라 실제보다 낮게 나온다)\")\n", + "w = T[T[\"부하\"] == \"평시 x6\"].iloc[0]\n", + "print(f\"\\n동시 응시 500% 증가(평시 x6): 시스템 다운 {int(T['다운'].sum())}건, 데이터 유실 {int(T['유실'].sum())}건 \"\n", + " f\"(목표 다운 0건·유실률 0%: {'달성' if T['다운'].sum() == 0 and T['유실'].sum() == 0 else '미달'})\")\n", + "h = pd.Series(holds[\"평시 x11 +장애2%\"]).value_counts()\n", + "print(\"보류 사유(평시 x11 +장애2%):\", {k: int(v) for k, v in h.items()}, \"-> 원본 보존, 장애 복구 후 재채점 또는 교관 확인\")\n", + "\n", + "# 병목 프로파일링과 최적화: 캐시 없음 vs 캐시\n", + "df6 = pd.concat([base.assign(rid=base[\"rid\"] + f\"-{k}\") for k in range(6)], ignore_index=True)\n", + "prof = {}\n", + "t0 = time.perf_counter(); [parse(QIDX[q], r) for q, r in zip(df6[\"qid\"], df6[\"raw\"])]; prof[\"파싱\"] = time.perf_counter() - t0\n", + "sa = df6[df6[\"qid\"].map(lambda q: QIDX[q][\"type\"]) == \"SA\"]\n", + "t0 = time.perf_counter(); [grade_fuzzy(QIDX[q], parse(QIDX[q], r)[0]) for q, r in zip(sa[\"qid\"], sa[\"raw\"])]; prof[\"단답 유사도 채점\"] = time.perf_counter() - t0\n", + "print(\"\\n[병목 프로파일] \" + \", \".join(f\"{k} {v * 1000:.0f} ms\" for k, v in prof.items()) + f\" (단답형은 전체의 {len(sa) / len(df6):.0%})\")\n", + "r0, _ = run(df6, grade_one)\n", + "grade_cached.cache_clear(); r1, _ = run(df6, grade_cached)\n", + "print(f\"최적화 전 {r0['처리량(건/초)']:,}건/초 -> 캐시 적용 후 {r1['처리량(건/초)']:,}건/초 ({r1['처리량(건/초)'] / r0['처리량(건/초)']:.1f}배)\")\n", + "print(f\"기능 축소 운용: 장애율이 5%를 넘으면 단답형 유사도 채점을 끄고 정확 일치만 채점, 나머지는 교관 검토로 넘긴다.\")\n", + "\n", + "plt.figure(figsize=(8, 4))\n", + "plt.bar(T[\"부하\"].str.replace(\"평시\", \"base\").str.replace(\"+장애2%\", \"+fail2%\"), T[\"처리량(건/초)\"])\n", + "plt.ylabel(\"responses / sec\"); plt.title(\"Grading throughput under load (cached)\"); plt.xticks(rotation=15)\n", + "plt.tight_layout(); plt.show()\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 08 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + " 부하 건수 처리(초) 처리량(건/초) 채점 완료 보류 유실 메모리 최대(MB)\n", + " 평시 x1 3840 0.11 33625 3834 6 0 0.5\n", + " 평시 x3 11520 0.19 59703 11501 19 0 1.1\n", + " 평시 x6 23040 0.27 86855 23004 36 0 2.0\n", + " 평시 x6 +장애2% 23040 0.24 96047 22558 482 0 2.0\n", + "평시 x11 +장애2% 42240 0.46 91783 41326 914 0 3.6\n", + "(위 표의 처리량은 메모리 추적을 켠 상태라 실제보다 낮게 나온다)\n", + "\n", + "동시 응시 500% 증가(평시 x6): 시스템 다운 0건, 데이터 유실 0건 (목표 다운 0건·유실률 0%: 달성)\n", + "보류 사유(평시 x11 +장애2%): {'TimeoutError': 857, 'ValueError': 30, 'KeyError': 27} -> 원본 보존, 장애 복구 후 재채점 또는 교관 확인\n", + "\n", + "[병목 프로파일] 파싱 52 ms, 단답 유사도 채점 150 ms (단답형은 전체의 25%)\n", + "최적화 전 89,083건/초 -> 캐시 적용 후 498,682건/초 (5.6배)\n", + "기능 축소 운용: 장애율이 5%를 넘으면 단답형 유사도 채점을 끄고 정확 일치만 채점, 나머지는 교관 검토로 넘긴다.\n", + "```\n", + "
\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 09. AAR 기반 문항 변별력 분석 및 난이도별 가중치 파라미터 재보정\n", + "**소요시간**: 약 3.0시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "AAR 기반 문항 변별력 분석 및 난이도 가중치 파라미터를 재보정한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **시행 후 검토(AAR)로 문항의 변별력과 정답 키 오류를 찾아내고, 결함 문항 조치와 배점 가중치 재보정으로 합격 판정의 정확도를 높인다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **문항별 정답률, 점이연 상관(변별도), 오답 보기 선택자의 평균 점수를 분석해 정답 키 입력 오류와 변별력 부족 문항을 찾는다 과정을 진행하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `docs/trn02_aar_report.md 및 configs/trn02_tuned_params.yaml` 형태의 결과물을 완성한다.\n", + "\n", + "### 🔍 평가 및 검증 기준 (PASS 조건)\n", + "#### 미션 판정\n", + "\n", + "**평가 구분:** 필수\n", + "\n", + "**핵심 평가영역:** 오류 원인 규명의 객관성 및 개선 조치의 논리적 타당성\n", + "\n", + "#### 미션 PASS 조건\n", + "\n", + "- 정량 기준: AAR 피드백 적용 후 종합 정확도 10%p 이상 향상, 재발 오류 0건\n", + "\n", + "\n", + "- 진행 기준: AAR 보고서 작성 및 파라미터 튜닝 전후 회귀 테스트 통과\n", + "\n", + "\n", + "- 검증 방법: 회귀 테스트(Regression Test) 스위트 전체 자동 실행 및 비교\n", + "\n", + "\n", + "#### 채점자 판정 메모\n", + "\n", + "> 단순 코드 실행 여부뿐만 아니라 출력된 수치와 근거 자료가 군 실무 요구사항에 부합하는지 면밀히 확인한다.\n", + "\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "\"\"\"시행 후 검토(AAR)로 문항별 정답률·변별도(점이연 상관)·오답 보기 선택 분포를 분석해 정답 키 오류와 변별력 없는 문항을 찾아내고, 난이도·변별도 가중치를 재보정한다. 실제 숙달도 대비 합격 판정 정확도의 개선과 새 응시 집단에서의 재발 오류를 확인하고 재보정 파라미터를 YAML로 출력한다.\"\"\"\n", + "import difflib\n", + "import re\n", + "import unicodedata\n", + "import numpy as np\n", + "import pandas as pd\n", + "import matplotlib.pyplot as plt\n", + "\n", + "# ---------------------------------------------------------------------------\n", + "# [교육용가상자료] 방공·드론 식별 퀴즈 문항 은행과 가상 응시자 응답 생성기\n", + "# 문항은 일반 원칙 수준의 가상 문항이며 실제 교범·교전규칙 원문이 아니다. 응시자는 가명(S001~)이다.\n", + "# ---------------------------------------------------------------------------\n", + "AREAS = [\"형상식별\", \"피아식별\", \"경보보고\", \"교전통제\", \"대드론일반\"]\n", + "ETYPES = [\"개념 혼동\", \"피아 오인\", \"절차 누락\"]\n", + "POINTS = {1: 2, 2: 3, 3: 5}\n", + "C, F, P = ETYPES\n", + "\n", + "\n", + "def _mc(qid, area, diff, stem, opts, ans, dist):\n", + " return {\"qid\": qid, \"type\": \"MC\", \"area\": area, \"diff\": diff, \"stem\": stem, \"options\": opts, \"answer\": ans,\n", + " \"dist\": dict(zip([i for i in range(1, 5) if i != ans], dist))}\n", + "\n", + "\n", + "def _sa(qid, area, diff, stem, accept, wrong):\n", + " return {\"qid\": qid, \"type\": \"SA\", \"area\": area, \"diff\": diff, \"stem\": stem, \"accept\": accept, \"wrong\": wrong}\n", + "\n", + "\n", + "def _order(qid, area, diff, stem):\n", + " return {\"qid\": qid, \"type\": \"ORDER\", \"area\": area, \"diff\": diff, \"stem\": stem, \"answer\": \"ABCD\"}\n", + "\n", + "\n", + "BANK = [\n", + " _mc(\"Q01\", \"형상식별\", 1, \"꼬리날개 없이 삼각형 날개를 가진 소형 고정익 실루엣은?\", [\"쿼드콥터형\", \"삼각익 고정익형\", \"회전익 헬기형\", \"조류\"], 2, [C, C, F]),\n", + " _mc(\"Q02\", \"형상식별\", 1, \"회전날개 4개가 십자로 배치된 실루엣은?\", [\"고정익 드론형\", \"쿼드콥터형\", \"순항미사일형\", \"탄도미사일형\"], 2, [C, C, C]),\n", + " _mc(\"Q03\", \"형상식별\", 2, \"가늘고 긴 동체 중간에 작은 날개가 붙은 실루엣은?\", [\"순항미사일형\", \"탄도미사일형\", \"민간 경비행기형\", \"조류\"], 1, [C, F, F]),\n", + " _mc(\"Q04\", \"형상식별\", 3, \"원거리라 실루엣 식별이 어려울 때 먼저 할 행동은?\", [\"추정 유형으로 확정 보고\", \"관측 정보를 그대로 보고하고 추가 관측\", \"관측 중단\", \"민간기로 간주\"], 2, [P, P, F]),\n", + " _mc(\"Q05\", \"피아식별\", 2, \"식별 신호 응답이 없는 항공기에 대한 올바른 판단은?\", [\"즉시 적기로 확정\", \"미확인 항공기로 분류하고 추가 확인\", \"아군기로 간주\", \"무시\"], 2, [F, F, P]),\n", + " _mc(\"Q06\", \"피아식별\", 2, \"운항 정보와 일치하는 저속 비행체의 분류는?\", [\"적 드론\", \"민간기 가능성 높음, 추가 확인\", \"순항미사일\", \"아군 정찰기로 확정\"], 2, [F, C, F]),\n", + " _mc(\"Q07\", \"피아식별\", 3, \"아군 드론 운용 공지가 있는 공역에서 미확인 드론을 보면 우선 확인할 것은?\", [\"기상 정보\", \"아군 운용 계획과 식별 신호\", \"대응 준비 상태\", \"주변 지형\"], 2, [C, P, C]),\n", + " _mc(\"Q08\", \"피아식별\", 3, \"조류 무리와 소형 드론 군집 구분에 가장 도움이 되는 정보는?\", [\"크기만 비교\", \"궤적의 규칙성과 열 신호\", \"색상\", \"관측 시각\"], 2, [C, C, C]),\n", + " _mc(\"Q09\", \"경보보고\", 1, \"공중 위협 최초 보고에 포함하지 않아도 되는 것은?\", [\"관측 시각\", \"방향과 거리\", \"추정 유형\", \"관측자 개인 연락처\"], 4, [P, P, P]),\n", + " _mc(\"Q10\", \"경보보고\", 2, \"경보 전파 후 상황이 바뀌면?\", [\"최초 보고 유지\", \"변경 사항을 즉시 추가 보고\", \"상황 종료 후 일괄 보고\", \"개인 판단으로 경보 해제\"], 2, [P, P, P]),\n", + " _mc(\"Q11\", \"경보보고\", 2, \"보고 내용이 불확실할 때 올바른 표현은?\", [\"확정 표현으로 보고\", \"'추정'임을 밝히고 근거와 함께 보고\", \"보고 보류\", \"다른 관측자 보고를 복사\"], 2, [C, P, P]),\n", + " _mc(\"Q12\", \"경보보고\", 1, \"드론 탐지 후 보고 경로로 알맞은 것은?\", [\"인접 부대에만 전파\", \"지휘계통을 따라 보고\", \"개인 메신저로 공유\", \"보고 없이 대응\"], 2, [P, P, P]),\n", + " _mc(\"Q13\", \"교전통제\", 1, \"AI 탐지 결과만으로 대응을 결정할 수 있는가?\", [\"가능하다\", \"인간 지휘관의 승인이 필요하다\", \"신뢰도 90% 이상이면 가능\", \"야간에만 가능\"], 2, [C, C, C]),\n", + " _mc(\"Q14\", \"교전통제\", 2, \"민간 피해 가능성이 확인되면?\", [\"그대로 진행\", \"즉시 보고하고 지휘관 판단을 받는다\", \"조치 후 보고\", \"무시\"], 2, [P, P, F]),\n", + " _mc(\"Q15\", \"교전통제\", 3, \"식별이 불확실한 표적에 대한 원칙은?\", [\"위협으로 간주해 즉시 대응\", \"식별 확인 전까지 승인 절차를 따른다\", \"무조건 무시\", \"다수결로 결정\"], 2, [F, F, C]),\n", + " _mc(\"Q16\", \"교전통제\", 2, \"판단 근거를 기록해야 하는 이유는?\", [\"형식상 필요\", \"사후 검토와 책임 추적\", \"보고 생략\", \"기록 불필요\"], 2, [C, P, P]),\n", + " _mc(\"Q17\", \"대드론일반\", 1, \"대드론 체계의 일반적 처리 순서는?\", [\"대응-탐지-식별\", \"탐지-식별-보고·판단-대응\", \"식별-대응-탐지\", \"보고-탐지-대응\"], 2, [P, P, P]),\n", + " _mc(\"Q18\", \"대드론일반\", 3, \"레이더만으로 조류와 드론 구분이 어려운 이유는?\", [\"반사 신호 크기가 비슷할 수 있어서\", \"레이더는 야간에 못 써서\", \"드론에는 금속이 없어서\", \"조류는 탐지되지 않아서\"], 1, [C, C, C]),\n", + " _mc(\"Q19\", \"대드론일반\", 2, \"전자광학(EO) 센서의 약점이 커지는 조건은?\", [\"맑은 주간\", \"안개·야간\", \"저속 표적\", \"근거리\"], 2, [C, C, C]),\n", + " _mc(\"Q20\", \"대드론일반\", 2, \"경보가 반복해서 오경보로 판명되면?\", [\"경보 장치를 끈다\", \"사례를 기록·보고하고 기준 재검토 요청\", \"무시한다\", \"모두 실제 위협으로 간주\"], 2, [P, P, F]),\n", + " _sa(\"Q21\", \"피아식별\", 1, \"아군·적·민간을 구분하는 절차를 무엇이라 하는가?\", [\"피아식별\", \"IFF\"], {\"표적획득\": C, \"적기판정\": F, \"경계\": C}),\n", + " _sa(\"Q22\", \"형상식별\", 2, \"꼬리날개 없는 삼각형 날개 형태를 무엇이라 하는가?\", [\"삼각익\", \"델타익\", \"delta\"], {\"직선익\": C, \"후퇴익\": C, \"쿼드콥터\": C}),\n", + " _sa(\"Q23\", \"경보보고\", 2, \"불확실한 관측을 보고할 때 붙이는 표현은?\", [\"추정\"], {\"확정\": C, \"확인\": C, \"생략\": P}),\n", + " _sa(\"Q24\", \"교전통제\", 1, \"AI 탐지 결과에 대해 최종 결정을 내리는 주체는?\", [\"지휘관\"], {\"AI\": C, \"운용병\": P, \"레이더\": C}),\n", + " _sa(\"Q25\", \"대드론일반\", 1, \"조류를 드론으로 잘못 경보하는 것을 무엇이라 하는가?\", [\"오경보\", \"오탐\", \"false alarm\"], {\"미탐\": C, \"재경보\": C, \"적기\": F}),\n", + " _sa(\"Q26\", \"피아식별\", 2, \"운항 정보가 확인된 여객기는 어떻게 분류하는가?\", [\"민간기\", \"민항기\"], {\"적기\": F, \"미확인기\": P, \"아군기\": F}),\n", + " _sa(\"Q27\", \"형상식별\", 1, \"회전날개 4개를 가진 드론 형태는?\", [\"쿼드콥터\", \"quadcopter\"], {\"헥사콥터\": C, \"고정익\": C, \"헬기\": F}),\n", + " _sa(\"Q28\", \"경보보고\", 1, \"보고는 무엇을 따라 올라가는가?\", [\"지휘계통\"], {\"인접부대\": P, \"개인메신저\": P, \"통신망\": C}),\n", + " _order(\"Q29\", \"경보보고\", 2, \"최초 보고 순서: A 관측 시각, B 방향·거리, C 추정 유형, D 관측자 조치\"),\n", + " _order(\"Q30\", \"대드론일반\", 2, \"처리 순서: A 탐지, B 식별, C 보고·판단, D 대응\"),\n", + " _order(\"Q31\", \"교전통제\", 3, \"통제 순서: A 식별 확인, B 상급 승인 요청, C 승인 수령, D 조치 기록\"),\n", + " _order(\"Q32\", \"피아식별\", 3, \"확인 순서: A 운항정보 대조, B 식별 신호 확인, C 미확인 분류, D 보고\"),\n", + "]\n", + "QIDX = {q[\"qid\"]: q for q in BANK}\n", + "UNITS = [\"가상1중대\", \"가상2중대\", \"가상3중대\"]\n", + "_CIRC = \"①②③④\"\n", + "PARA = {\"Q21\": \"아군적군 구분\", \"Q22\": \"세모 날개\", \"Q23\": \"추정됨으로 표기\", \"Q24\": \"중대장(지휘관)\", \"Q25\": \"잘못된 경보\",\n", + " \"Q26\": \"민간 여객기\", \"Q27\": \"4발 드론\", \"Q28\": \"지휘 라인\"} # 뜻은 맞지만 인정 답안 목록에 없는 표현\n", + "\n", + "\n", + "def make_soldiers(n_per_unit, seed):\n", + " rng = np.random.default_rng(seed)\n", + " out = []\n", + " for u, unit in enumerate(UNITS):\n", + " base = [0.3, -0.1, 0.0][u]\n", + " for i in range(n_per_unit):\n", + " off = rng.normal(0, 0.5, len(AREAS)) + (np.array([0, 0, 0, -0.6, 0]) if u == 1 else 0)\n", + " out.append({\"sid\": f\"S{len(out) + 1:03d}\", \"unit\": unit, \"theta\": dict(zip(AREAS, base + rng.normal(0, 0.6) + off)),\n", + " \"tend\": dict(zip(ETYPES, rng.dirichlet([1.2, 1.2, 1.2])))})\n", + " return out\n", + "\n", + "\n", + "def p_correct(theta, diff):\n", + " return 1 / (1 + np.exp(-1.7 * (theta - (diff - 2) * 0.9 + 0.9)))\n", + "\n", + "\n", + "def _typo(s, rng):\n", + " if len(s) < 2:\n", + " return s\n", + " i = int(rng.integers(len(s) - 1))\n", + " return s[:i] + s[i + 1] + s[i] + s[i + 2:] if len(s) <= 3 or rng.random() < 0.5 else s[:i] + s[i + 1:]\n", + "\n", + "\n", + "def _fmt_mc(k, rng):\n", + " r = rng.random()\n", + " if r < 0.55:\n", + " return str(k)\n", + " return rng.choice([_CIRC[k - 1], f\"{k}번\", \"ABCD\"[k - 1], \"abcd\"[k - 1] + \")\", f\" {k} \", chr(0xFF10 + k), f\"{k}.\"])\n", + "\n", + "\n", + "def _fmt_sa(s, rng):\n", + " r = rng.random()\n", + " if r < 0.45:\n", + " return s\n", + " if r < 0.6:\n", + " return \" \".join(s) if len(s) <= 4 else s[:2] + \" \" + s[2:]\n", + " if r < 0.72:\n", + " return s + rng.choice([\"입니다\", \"이다\", \".\", \"임\"])\n", + " if r < 0.8:\n", + " return s.upper() if s.isascii() else \"\".join(chr(ord(ch) + 0xFEE0) if \"!\" <= ch <= \"~\" else ch for ch in s) + \" \"\n", + " return _typo(s, rng)\n", + "\n", + "\n", + "def answer(q, sol, rng):\n", + " \"\"\"한 문항 응답: (원본 응답 문자열, 의도한 정답 여부, 의도한 답, 오답 유형).\"\"\"\n", + " ok = rng.random() < p_correct(sol[\"theta\"][q[\"area\"]], q[\"diff\"])\n", + " if q[\"type\"] == \"MC\":\n", + " if ok:\n", + " k, et = q[\"answer\"], None\n", + " else:\n", + " ks = list(q[\"dist\"]); w = np.array([sol[\"tend\"][q[\"dist\"][k]] for k in ks])\n", + " k = int(rng.choice(ks, p=w / w.sum())); et = q[\"dist\"][k]\n", + " return _fmt_mc(k, rng), ok, str(k), et\n", + " if q[\"type\"] == \"SA\":\n", + " if ok and rng.random() < 0.06:\n", + " return PARA[q[\"qid\"]], ok, PARA[q[\"qid\"]], None\n", + " if ok:\n", + " s, et = str(rng.choice(q[\"accept\"])), None\n", + " else:\n", + " ws = list(q[\"wrong\"]); w = np.array([sol[\"tend\"][q[\"wrong\"][x]] for x in ws])\n", + " s = str(rng.choice(ws, p=w / w.sum())); et = q[\"wrong\"][s]\n", + " return _fmt_sa(s, rng), ok, s, et\n", + " if ok:\n", + " s = \"ABCD\"\n", + " elif rng.random() < 0.5:\n", + " s = \"ABCD\".replace(\"ABCD\"[rng.integers(1, 4)], \"\") # 단계 하나 빠뜨림\n", + " else:\n", + " i = int(rng.integers(0, 3)); s = list(\"ABCD\"); s[i], s[i + 1] = s[i + 1], s[i]; s = \"\".join(s) # 순서 뒤바뀜\n", + " raw = s if rng.random() < 0.6 else rng.choice([\"-\".join(s), \" \".join(s), s.lower(), \",\".join(s)])\n", + " return raw, ok, s, (None if ok else P)\n", + "\n", + "\n", + "def make_responses(n_per_unit=40, seed=7, blank=0.02):\n", + " \"\"\"응시자 x 문항 응답표(DataFrame)를 만든다. truth_* 열은 교관 기준 정답(평가용)이다.\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " rows = []\n", + " for sol in make_soldiers(n_per_unit, seed):\n", + " for q in BANK:\n", + " raw, ok, intended, et = answer(q, sol, rng)\n", + " if rng.random() < blank:\n", + " raw, ok, intended, et = \"\", False, \"\", None\n", + " rows.append({\"rid\": f\"R{len(rows):05d}\", \"sid\": sol[\"sid\"], \"unit\": sol[\"unit\"], \"qid\": q[\"qid\"], \"raw\": raw,\n", + " \"truth_correct\": ok, \"truth_answer\": intended, \"truth_etype\": et})\n", + " return pd.DataFrame(rows)\n", + "# ---------------------------------------------------------------------------\n", + "\n", + "# ---- 응답 정제·파싱·채점 공통 함수 ----\n", + "TAILS = (\"입니다\", \"이다\", \"임\")\n", + "\n", + "\n", + "def norm_text(s):\n", + " \"\"\"전각->반각(NFKC), 소문자, 공백·문장부호 제거, 끝맺음 어미 제거.\"\"\"\n", + " s = unicodedata.normalize(\"NFKC\", str(s)).strip().lower()\n", + " s = re.sub(r\"[\\s\\.\\,\\-\\(\\)·]\", \"\", s)\n", + " for t in TAILS:\n", + " if s.endswith(t) and len(s) > len(t) + 1:\n", + " s = s[:-len(t)]\n", + " return s\n", + "\n", + "\n", + "def parse(q, raw):\n", + " \"\"\"원본 응답 -> (정제값, 상태). 상태: 정상 / 미응답 / 형식오류\"\"\"\n", + " if raw is None or str(raw).strip() == \"\":\n", + " return \"\", \"미응답\"\n", + " s = unicodedata.normalize(\"NFKC\", str(raw)).strip()\n", + " if q[\"type\"] == \"MC\":\n", + " t = re.sub(r\"[\\s번\\.\\)]\", \"\", s).lower()\n", + " t = {\"a\": \"1\", \"b\": \"2\", \"c\": \"3\", \"d\": \"4\"}.get(t, t)\n", + " return (t, \"정상\") if t in (\"1\", \"2\", \"3\", \"4\") else (t, \"형식오류\")\n", + " if q[\"type\"] == \"ORDER\":\n", + " t = re.sub(r\"[^A-Za-z]\", \"\", s).upper()\n", + " return (t, \"정상\") if t and set(t) <= set(\"ABCD\") and len(set(t)) == len(t) else (t, \"형식오류\")\n", + " return norm_text(s), \"정상\"\n", + "\n", + "\n", + "def grade_exact(q, val):\n", + " if q[\"type\"] == \"MC\":\n", + " return val == str(q[\"answer\"])\n", + " if q[\"type\"] == \"ORDER\":\n", + " return val == q[\"answer\"]\n", + " return val in {norm_text(a) for a in q[\"accept\"]}\n", + "\n", + "\n", + "def grade_fuzzy(q, val, cut=0.75):\n", + " \"\"\"단답형은 오탈자(글자 순서 바뀜·한 글자 누락)를 허용(유사도 cut 이상)하되, 알려진 오답과 더 가까우면 오답으로 본다.\"\"\"\n", + " if grade_exact(q, val) or q[\"type\"] != \"SA\" or len(val) < 2:\n", + " return grade_exact(q, val)\n", + " best = max(1.0 if sorted(val) == sorted(norm_text(a)) else difflib.SequenceMatcher(None, val, norm_text(a)).ratio() for a in q[\"accept\"])\n", + " worst = max(difflib.SequenceMatcher(None, val, norm_text(w)).ratio() for w in q[\"wrong\"])\n", + " return best >= cut and best > worst\n", + "\n", + "\n", + "def score_of(q, correct):\n", + " return POINTS[q[\"diff\"]] if correct else 0\n", + "\n", + "KEY_REG = {\"Q11\": 3} # [교육용가상자료] 문항 등록 시 정답 키를 잘못 입력한 문항(정답은 2)\n", + "AMBIG = \"Q08\" # 보기 2·3이 모두 그럴듯해 실력과 무관하게 갈리는 문항\n", + "\n", + "\n", + "def field_data(seed):\n", + " \"\"\"현장 시행 데이터: Q08은 실력과 무관하게 2·3이 반반, 나머지는 생성기 그대로.\"\"\"\n", + " df = make_responses(40, seed=seed)\n", + " rng = np.random.default_rng(seed + 100)\n", + " m = df[\"qid\"] == AMBIG\n", + " df.loc[m, \"raw\"] = rng.choice([\"2\", \"3\"], m.sum())\n", + " df[\"val\"] = [parse(QIDX[q], r)[0] for q, r in zip(df[\"qid\"], df[\"raw\"])]\n", + " return df\n", + "\n", + "\n", + "def grade(df, key_fix=False, drop=(), weights=None):\n", + " def ok(q, v):\n", + " if q[\"qid\"] in KEY_REG and not key_fix:\n", + " return v == str(KEY_REG[q[\"qid\"]])\n", + " return grade_fuzzy(q, v)\n", + " d = df[~df[\"qid\"].isin(drop)].copy()\n", + " d[\"correct\"] = [ok(QIDX[q], v) for q, v in zip(d[\"qid\"], d[\"val\"])]\n", + " w = weights or {}\n", + " d[\"pts\"] = [w.get(q, POINTS[QIDX[q][\"diff\"]]) for q in d[\"qid\"]]\n", + " d[\"score\"] = d[\"pts\"] * d[\"correct\"]\n", + " tot = d.groupby(\"sid\")[[\"score\", \"pts\"]].sum()\n", + " return d, tot[\"score\"] / tot[\"pts\"]\n", + "\n", + "\n", + "def item_analysis(d, key_fix=False):\n", + " \"\"\"정답률(p), 점이연 상관(해당 문항 제외 총점과의 상관), 오답 보기 선택자의 평균 총점이 정답 선택자보다 높은지(키 오류 의심).\"\"\"\n", + " tot = d.groupby(\"sid\")[\"correct\"].mean()\n", + " rows = []\n", + " for qid, g in d.groupby(\"qid\"):\n", + " rest = tot[g[\"sid\"]].values - g[\"correct\"].values / len(BANK)\n", + " r = np.corrcoef(g[\"correct\"].astype(float), rest)[0, 1] if g[\"correct\"].std() > 0 else 0.0\n", + " key_sus = False\n", + " if QIDX[qid][\"type\"] == \"MC\":\n", + " by = pd.Series(rest, index=g[\"val\"].values).groupby(level=0).mean()\n", + " key = KEY_REG[qid] if qid in KEY_REG and not key_fix else QIDX[qid][\"answer\"]\n", + " right = by.get(str(key), np.nan)\n", + " key_sus = bool(by.drop(index=[k for k in by.index if k not in \"1234\" or k == \"\"], errors=\"ignore\").max() > right + 0.08)\n", + " rows.append({\"qid\": qid, \"p\": round(g[\"correct\"].mean(), 2), \"r_pb\": round(r, 2), \"key_suspect\": key_sus})\n", + " t = pd.DataFrame(rows).set_index(\"qid\")\n", + " t[\"flag\"] = np.where(t[\"key_suspect\"], \"정답 키 오류 의심\", np.where(t[\"r_pb\"] < 0.1, \"변별력 부족\", \"\"))\n", + " return t\n", + "\n", + "\n", + "def pass_accuracy(df, ratio, cut=0.7):\n", + " \"\"\"합격 판정(점수 비율 cut 이상)과 가상 실제 숙달도(결함 없는 시험에서의 기대 득점률 0.7 이상)의 일치율.\"\"\"\n", + " sol = {s[\"sid\"]: s for s in make_soldiers(40, SEED_OF[id(df)])}\n", + " pts = np.array([POINTS[q[\"diff\"]] for q in BANK])\n", + " exp = {s: float(np.dot(pts, [p_correct(sol[s][\"theta\"][q[\"area\"]], q[\"diff\"]) for q in BANK]) / pts.sum()) for s in ratio.index}\n", + " truth = pd.Series(exp) >= 0.7\n", + " return float(((ratio >= cut) == truth[ratio.index]).mean())\n", + "\n", + "\n", + "SEED_OF = {}\n", + "A = field_data(7); SEED_OF[id(A)] = 7\n", + "d0, r0 = grade(A)\n", + "acc0 = pass_accuracy(A, r0)\n", + "ia = item_analysis(d0)\n", + "print(\"[AAR 1. 문항 분석 - 경고 문항]\")\n", + "print(ia[ia[\"flag\"] != \"\"].to_string())\n", + "print(f\"전체 문항 변별도 중앙값 {ia['r_pb'].median():.2f}, 정답률 0.9 초과(너무 쉬움) {int((ia['p'] > 0.9).sum())}개, 0.3 미만(너무 어려움) {int((ia['p'] < 0.3).sum())}개\")\n", + "print(\"원인 판정: Q11은 상위권이 고른 보기(2)가 등록 키(3)와 달라 키 입력 오류, Q08은 보기 2·3이 실력과 무관하게 갈려 문항 자체 결함.\")\n", + "\n", + "# AAR 2. 재보정: 키 수정 -> 결함 문항 제외 -> 배점 가중치 탐색\n", + "flagged = list(ia[ia[\"flag\"] == \"변별력 부족\"].index)\n", + "rows = [{\"단계\": \"재보정 전\", \"합격 판정 정확도\": acc0}]\n", + "d1, r1 = grade(A, key_fix=True)\n", + "rows.append({\"단계\": \"1) 정답 키 수정\", \"합격 판정 정확도\": pass_accuracy(A, r1)})\n", + "d2, r2 = grade(A, key_fix=True, drop=flagged)\n", + "rows.append({\"단계\": \"2) 변별력 부족 문항 제외\", \"합격 판정 정확도\": pass_accuracy(A, r2)})\n", + "ia2 = item_analysis(d2, key_fix=True)\n", + "best = None\n", + "for base in [(2, 3, 5), (1, 2, 3), (3, 3, 3)]:\n", + " for alpha in [0.0, 0.5, 1.0, 2.0]:\n", + " w = {q: base[QIDX[q][\"diff\"] - 1] * (1 + alpha * max(ia2.loc[q, \"r_pb\"], 0)) for q in ia2.index}\n", + " _, r = grade(A, key_fix=True, drop=flagged, weights=w)\n", + " a = pass_accuracy(A, r) # 합격선 70%는 정책값이라 튜닝하지 않는다\n", + " if best is None or a > best[0] + 1e-9:\n", + " best = (a, base, alpha, 0.7, w)\n", + "acc3, base, alpha, cut, W = best\n", + "rows.append({\"단계\": f\"3) 배점{base}·변별도 가중 {alpha}\", \"합격 판정 정확도\": acc3})\n", + "S = pd.DataFrame(rows); S[\"합격 판정 정확도\"] = S[\"합격 판정 정확도\"].round(3)\n", + "print(\"\\n[AAR 2. 재보정 단계별 효과(응시 집단 A)]\")\n", + "print(S.to_string(index=False))\n", + "gain = (acc3 - acc0) * 100\n", + "print(f\"종합 정확도 {acc0:.1%} -> {acc3:.1%} (+{gain:.1f}%p, 목표 10%p 이상: {'달성' if gain >= 10 else '미달'})\")\n", + "print(\"-> 결함 문항이 32개 중 2개라 개선 폭이 제한된다. 나머지 불일치는 합격선 근처 응시자의 측정 오차로, 문항 수를 늘려야 줄어든다.\")\n", + "\n", + "# AAR 3. 새 응시 집단 B(다른 인원)에서 검증·재발 확인\n", + "B = field_data(21); SEED_OF[id(B)] = 21\n", + "_, rb0 = grade(B)\n", + "dB, rb = grade(B, key_fix=True, drop=flagged, weights=W)\n", + "accB0, accB = pass_accuracy(B, rb0), pass_accuracy(B, rb, cut)\n", + "iaB = item_analysis(dB, key_fix=True)\n", + "rec = iaB[iaB[\"key_suspect\"] | iaB.index.isin(flagged + list(KEY_REG))]\n", + "rec = rec[rec[\"flag\"] != \"\"]\n", + "new = iaB[(iaB[\"flag\"] != \"\") & ~iaB.index.isin(rec.index)]\n", + "print(f\"\\n[AAR 3. 응시 집단 B 검증] 합격 판정 정확도 {accB0:.1%} -> {accB:.1%} (+{(accB - accB0) * 100:.1f}%p)\")\n", + "print(f\"재발 오류(조치한 키 오류·결함 문항의 재검출) {len(rec)}건 (목표 0건: {'달성' if len(rec) == 0 else '미달'})\")\n", + "print(f\"신규 점검 대상 {len(new)}건: {list(new.index)} -> 다음 AAR에서 문항 개정 여부 판단\")\n", + "\n", + "yaml = \"\\n\".join([\"# configs/trn02_tuned_params.yaml [교육용가상자료]\", \"answer_key_fix:\", \" Q11: 2\", \"excluded_items:\"]\n", + " + [f\" - {q} # 변별력 부족, 문항 개정 후 재투입\" for q in flagged]\n", + " + [f\"points_by_difficulty: {{1: {base[0]}, 2: {base[1]}, 3: {base[2]}}}\", f\"discrimination_alpha: {alpha}\",\n", + " f\"pass_ratio: {cut}\", \"review: 다음 시행 후 AAR에서 변별도 0.1 미만 문항 재점검\"])\n", + "print(\"\\n\" + yaml)\n", + "\n", + "plt.figure(figsize=(7, 4))\n", + "c = [\"tab:red\" if f else \"tab:blue\" for f in ia[\"flag\"]]\n", + "plt.scatter(ia[\"p\"], ia[\"r_pb\"], c=c)\n", + "for q in ia[ia[\"flag\"] != \"\"].index:\n", + " plt.annotate(q, (ia.loc[q, \"p\"], ia.loc[q, \"r_pb\"]))\n", + "plt.axhline(0.1, ls=\"--\", color=\"gray\"); plt.xlabel(\"item difficulty p (accuracy)\"); plt.ylabel(\"discrimination r_pb\")\n", + "plt.title(\"AAR item analysis (red = flagged)\"); plt.tight_layout(); plt.show()\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 09 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + "[AAR 1. 문항 분석 - 경고 문항]\n", + " p r_pb key_suspect flag\n", + "qid \n", + "Q08 0.56 -0.10 False 변별력 부족\n", + "Q11 0.08 -0.26 True 정답 키 오류 의심\n", + "Q27 0.89 0.08 False 변별력 부족\n", + "Q28 0.87 0.07 False 변별력 부족\n", + "전체 문항 변별도 중앙값 0.30, 정답률 0.9 초과(너무 쉬움) 2개, 0.3 미만(너무 어려움) 1개\n", + "원인 판정: Q11은 상위권이 고른 보기(2)가 등록 키(3)와 달라 키 입력 오류, Q08은 보기 2·3이 실력과 무관하게 갈려 문항 자체 결함.\n", + "\n", + "[AAR 2. 재보정 단계별 효과(응시 집단 A)]\n", + " 단계 합격 판정 정확도\n", + " 재보정 전 0.833\n", + " 1) 정답 키 수정 0.858\n", + " 2) 변별력 부족 문항 제외 0.875\n", + "3) 배점(2, 3, 5)·변별도 가중 0.0 0.875\n", + "종합 정확도 83.3% -> 87.5% (+4.2%p, 목표 10%p 이상: 미달)\n", + "-> 결함 문항이 32개 중 2개라 개선 폭이 제한된다. 나머지 불일치는 합격선 근처 응시자의 측정 오차로, 문항 수를 늘려야 줄어든다.\n", + "\n", + "[AAR 3. 응시 집단 B 검증] 합격 판정 정확도 75.8% -> 77.5% (+1.7%p)\n", + "재발 오류(조치한 키 오류·결함 문항의 재검출) 0건 (목표 0건: 달성)\n", + "신규 점검 대상 3건: ['Q12', 'Q13', 'Q21'] -> 다음 AAR에서 문항 개정 여부 판단\n", + "\n", + "# configs/trn02_tuned_params.yaml [교육용가상자료]\n", + "answer_key_fix:\n", + " Q11: 2\n", + "excluded_items:\n", + " - Q08 # 변별력 부족, 문항 개정 후 재투입\n", + " - Q27 # 변별력 부족, 문항 개정 후 재투입\n", + " - Q28 # 변별력 부족, 문항 개정 후 재투입\n", + "points_by_difficulty: {1: 2, 2: 3, 3: 5}\n", + "discrimination_alpha: 0.0\n", + "pass_ratio: 0.7\n", + "review: 다음 시행 후 AAR에서 변별도 0.1 미만 문항 재점검\n", + "```\n", + "
\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 10. 방공 퀴즈 자동채점 및 오답분석 시스템 통합 최종 운영 패키지 완성\n", + "**소요시간**: 약 3.5시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "방공 퀴즈 자동채점 및 오답분석 시스템 통합 최종 운영 패키지를 완성한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **문항 검증부터 정제, 채점, 오답 분류, 취약 영역 추천, 교관 검토 대기열까지 전체 시스템을 하나로 통합하고 운영 패키지로 완성한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **재보정 설정(CONFIG)을 적용한 통합 파이프라인을 여러 응시 회차에 실행해 End-to-End 성공률 확인하고 유효성을 점검하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `src/main_trn02.py, README.md, docs/trn02_final_report.pdf(md)` 형태의 결과물을 완성한다.\n", + "\n", + "### 🔍 평가 및 검증 기준 (PASS 조건)\n", + "#### 미션 판정\n", + "\n", + "**평가 구분:** 필수\n", + "\n", + "**핵심 평가영역:** 군 실무 즉시 도입 가능성, 운용 신뢰도 및 전략적 기대효과\n", + "\n", + "#### 미션 PASS 조건\n", + "\n", + "- 정량 기준: End-to-End 통합 파이프라인 실행 성공률 100%, 테스트 커버리지 85% 이상\n", + "\n", + "\n", + "- 진행 기준: 원클릭 실행 스크립트 동작 확인 및 최종 발표 산출물 작성 완료\n", + "\n", + "\n", + "- 검증 방법: 최종 검증 위원회(평가관) 모의 시연 및 전체 기능 통합 검증\n", + "\n", + "\n", + "#### 채점자 판정 메모\n", + "\n", + "> 단순 코드 실행 여부뿐만 아니라 출력된 수치와 근거 자료가 군 실무 요구사항에 부합하는지 면밀히 확인한다.\n", + "\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "\"\"\"문항 DB 검증, 응답 정제, 자동 채점, 오답 유형 분류, 취약 영역 추천, 교관 검토(HITL) 대기열, AAR 재보정 파라미터 적용까지 방공 퀴즈 채점·오답분석 시스템 전체를 한 파일로 통합 실행하고, 자체 점검(실행 성공률·함수 커버리지)과 교육훈련 담당관 브리핑 요약을 출력한다.\"\"\"\n", + "import difflib\n", + "import hashlib\n", + "import inspect\n", + "import json\n", + "import re\n", + "import sys\n", + "import time\n", + "import unicodedata\n", + "import numpy as np\n", + "import pandas as pd\n", + "import matplotlib.pyplot as plt\n", + "from sklearn.feature_extraction.text import TfidfVectorizer\n", + "from sklearn.linear_model import LogisticRegression\n", + "from sklearn.pipeline import make_pipeline\n", + "\n", + "# ---------------------------------------------------------------------------\n", + "# [교육용가상자료] 방공·드론 식별 퀴즈 문항 은행과 가상 응시자 응답 생성기\n", + "# 문항은 일반 원칙 수준의 가상 문항이며 실제 교범·교전규칙 원문이 아니다. 응시자는 가명(S001~)이다.\n", + "# ---------------------------------------------------------------------------\n", + "AREAS = [\"형상식별\", \"피아식별\", \"경보보고\", \"교전통제\", \"대드론일반\"]\n", + "ETYPES = [\"개념 혼동\", \"피아 오인\", \"절차 누락\"]\n", + "POINTS = {1: 2, 2: 3, 3: 5}\n", + "C, F, P = ETYPES\n", + "\n", + "\n", + "def _mc(qid, area, diff, stem, opts, ans, dist):\n", + " return {\"qid\": qid, \"type\": \"MC\", \"area\": area, \"diff\": diff, \"stem\": stem, \"options\": opts, \"answer\": ans,\n", + " \"dist\": dict(zip([i for i in range(1, 5) if i != ans], dist))}\n", + "\n", + "\n", + "def _sa(qid, area, diff, stem, accept, wrong):\n", + " return {\"qid\": qid, \"type\": \"SA\", \"area\": area, \"diff\": diff, \"stem\": stem, \"accept\": accept, \"wrong\": wrong}\n", + "\n", + "\n", + "def _order(qid, area, diff, stem):\n", + " return {\"qid\": qid, \"type\": \"ORDER\", \"area\": area, \"diff\": diff, \"stem\": stem, \"answer\": \"ABCD\"}\n", + "\n", + "\n", + "BANK = [\n", + " _mc(\"Q01\", \"형상식별\", 1, \"꼬리날개 없이 삼각형 날개를 가진 소형 고정익 실루엣은?\", [\"쿼드콥터형\", \"삼각익 고정익형\", \"회전익 헬기형\", \"조류\"], 2, [C, C, F]),\n", + " _mc(\"Q02\", \"형상식별\", 1, \"회전날개 4개가 십자로 배치된 실루엣은?\", [\"고정익 드론형\", \"쿼드콥터형\", \"순항미사일형\", \"탄도미사일형\"], 2, [C, C, C]),\n", + " _mc(\"Q03\", \"형상식별\", 2, \"가늘고 긴 동체 중간에 작은 날개가 붙은 실루엣은?\", [\"순항미사일형\", \"탄도미사일형\", \"민간 경비행기형\", \"조류\"], 1, [C, F, F]),\n", + " _mc(\"Q04\", \"형상식별\", 3, \"원거리라 실루엣 식별이 어려울 때 먼저 할 행동은?\", [\"추정 유형으로 확정 보고\", \"관측 정보를 그대로 보고하고 추가 관측\", \"관측 중단\", \"민간기로 간주\"], 2, [P, P, F]),\n", + " _mc(\"Q05\", \"피아식별\", 2, \"식별 신호 응답이 없는 항공기에 대한 올바른 판단은?\", [\"즉시 적기로 확정\", \"미확인 항공기로 분류하고 추가 확인\", \"아군기로 간주\", \"무시\"], 2, [F, F, P]),\n", + " _mc(\"Q06\", \"피아식별\", 2, \"운항 정보와 일치하는 저속 비행체의 분류는?\", [\"적 드론\", \"민간기 가능성 높음, 추가 확인\", \"순항미사일\", \"아군 정찰기로 확정\"], 2, [F, C, F]),\n", + " _mc(\"Q07\", \"피아식별\", 3, \"아군 드론 운용 공지가 있는 공역에서 미확인 드론을 보면 우선 확인할 것은?\", [\"기상 정보\", \"아군 운용 계획과 식별 신호\", \"대응 준비 상태\", \"주변 지형\"], 2, [C, P, C]),\n", + " _mc(\"Q08\", \"피아식별\", 3, \"조류 무리와 소형 드론 군집 구분에 가장 도움이 되는 정보는?\", [\"크기만 비교\", \"궤적의 규칙성과 열 신호\", \"색상\", \"관측 시각\"], 2, [C, C, C]),\n", + " _mc(\"Q09\", \"경보보고\", 1, \"공중 위협 최초 보고에 포함하지 않아도 되는 것은?\", [\"관측 시각\", \"방향과 거리\", \"추정 유형\", \"관측자 개인 연락처\"], 4, [P, P, P]),\n", + " _mc(\"Q10\", \"경보보고\", 2, \"경보 전파 후 상황이 바뀌면?\", [\"최초 보고 유지\", \"변경 사항을 즉시 추가 보고\", \"상황 종료 후 일괄 보고\", \"개인 판단으로 경보 해제\"], 2, [P, P, P]),\n", + " _mc(\"Q11\", \"경보보고\", 2, \"보고 내용이 불확실할 때 올바른 표현은?\", [\"확정 표현으로 보고\", \"'추정'임을 밝히고 근거와 함께 보고\", \"보고 보류\", \"다른 관측자 보고를 복사\"], 2, [C, P, P]),\n", + " _mc(\"Q12\", \"경보보고\", 1, \"드론 탐지 후 보고 경로로 알맞은 것은?\", [\"인접 부대에만 전파\", \"지휘계통을 따라 보고\", \"개인 메신저로 공유\", \"보고 없이 대응\"], 2, [P, P, P]),\n", + " _mc(\"Q13\", \"교전통제\", 1, \"AI 탐지 결과만으로 대응을 결정할 수 있는가?\", [\"가능하다\", \"인간 지휘관의 승인이 필요하다\", \"신뢰도 90% 이상이면 가능\", \"야간에만 가능\"], 2, [C, C, C]),\n", + " _mc(\"Q14\", \"교전통제\", 2, \"민간 피해 가능성이 확인되면?\", [\"그대로 진행\", \"즉시 보고하고 지휘관 판단을 받는다\", \"조치 후 보고\", \"무시\"], 2, [P, P, F]),\n", + " _mc(\"Q15\", \"교전통제\", 3, \"식별이 불확실한 표적에 대한 원칙은?\", [\"위협으로 간주해 즉시 대응\", \"식별 확인 전까지 승인 절차를 따른다\", \"무조건 무시\", \"다수결로 결정\"], 2, [F, F, C]),\n", + " _mc(\"Q16\", \"교전통제\", 2, \"판단 근거를 기록해야 하는 이유는?\", [\"형식상 필요\", \"사후 검토와 책임 추적\", \"보고 생략\", \"기록 불필요\"], 2, [C, P, P]),\n", + " _mc(\"Q17\", \"대드론일반\", 1, \"대드론 체계의 일반적 처리 순서는?\", [\"대응-탐지-식별\", \"탐지-식별-보고·판단-대응\", \"식별-대응-탐지\", \"보고-탐지-대응\"], 2, [P, P, P]),\n", + " _mc(\"Q18\", \"대드론일반\", 3, \"레이더만으로 조류와 드론 구분이 어려운 이유는?\", [\"반사 신호 크기가 비슷할 수 있어서\", \"레이더는 야간에 못 써서\", \"드론에는 금속이 없어서\", \"조류는 탐지되지 않아서\"], 1, [C, C, C]),\n", + " _mc(\"Q19\", \"대드론일반\", 2, \"전자광학(EO) 센서의 약점이 커지는 조건은?\", [\"맑은 주간\", \"안개·야간\", \"저속 표적\", \"근거리\"], 2, [C, C, C]),\n", + " _mc(\"Q20\", \"대드론일반\", 2, \"경보가 반복해서 오경보로 판명되면?\", [\"경보 장치를 끈다\", \"사례를 기록·보고하고 기준 재검토 요청\", \"무시한다\", \"모두 실제 위협으로 간주\"], 2, [P, P, F]),\n", + " _sa(\"Q21\", \"피아식별\", 1, \"아군·적·민간을 구분하는 절차를 무엇이라 하는가?\", [\"피아식별\", \"IFF\"], {\"표적획득\": C, \"적기판정\": F, \"경계\": C}),\n", + " _sa(\"Q22\", \"형상식별\", 2, \"꼬리날개 없는 삼각형 날개 형태를 무엇이라 하는가?\", [\"삼각익\", \"델타익\", \"delta\"], {\"직선익\": C, \"후퇴익\": C, \"쿼드콥터\": C}),\n", + " _sa(\"Q23\", \"경보보고\", 2, \"불확실한 관측을 보고할 때 붙이는 표현은?\", [\"추정\"], {\"확정\": C, \"확인\": C, \"생략\": P}),\n", + " _sa(\"Q24\", \"교전통제\", 1, \"AI 탐지 결과에 대해 최종 결정을 내리는 주체는?\", [\"지휘관\"], {\"AI\": C, \"운용병\": P, \"레이더\": C}),\n", + " _sa(\"Q25\", \"대드론일반\", 1, \"조류를 드론으로 잘못 경보하는 것을 무엇이라 하는가?\", [\"오경보\", \"오탐\", \"false alarm\"], {\"미탐\": C, \"재경보\": C, \"적기\": F}),\n", + " _sa(\"Q26\", \"피아식별\", 2, \"운항 정보가 확인된 여객기는 어떻게 분류하는가?\", [\"민간기\", \"민항기\"], {\"적기\": F, \"미확인기\": P, \"아군기\": F}),\n", + " _sa(\"Q27\", \"형상식별\", 1, \"회전날개 4개를 가진 드론 형태는?\", [\"쿼드콥터\", \"quadcopter\"], {\"헥사콥터\": C, \"고정익\": C, \"헬기\": F}),\n", + " _sa(\"Q28\", \"경보보고\", 1, \"보고는 무엇을 따라 올라가는가?\", [\"지휘계통\"], {\"인접부대\": P, \"개인메신저\": P, \"통신망\": C}),\n", + " _order(\"Q29\", \"경보보고\", 2, \"최초 보고 순서: A 관측 시각, B 방향·거리, C 추정 유형, D 관측자 조치\"),\n", + " _order(\"Q30\", \"대드론일반\", 2, \"처리 순서: A 탐지, B 식별, C 보고·판단, D 대응\"),\n", + " _order(\"Q31\", \"교전통제\", 3, \"통제 순서: A 식별 확인, B 상급 승인 요청, C 승인 수령, D 조치 기록\"),\n", + " _order(\"Q32\", \"피아식별\", 3, \"확인 순서: A 운항정보 대조, B 식별 신호 확인, C 미확인 분류, D 보고\"),\n", + "]\n", + "QIDX = {q[\"qid\"]: q for q in BANK}\n", + "UNITS = [\"가상1중대\", \"가상2중대\", \"가상3중대\"]\n", + "_CIRC = \"①②③④\"\n", + "PARA = {\"Q21\": \"아군적군 구분\", \"Q22\": \"세모 날개\", \"Q23\": \"추정됨으로 표기\", \"Q24\": \"중대장(지휘관)\", \"Q25\": \"잘못된 경보\",\n", + " \"Q26\": \"민간 여객기\", \"Q27\": \"4발 드론\", \"Q28\": \"지휘 라인\"} # 뜻은 맞지만 인정 답안 목록에 없는 표현\n", + "\n", + "\n", + "def make_soldiers(n_per_unit, seed):\n", + " rng = np.random.default_rng(seed)\n", + " out = []\n", + " for u, unit in enumerate(UNITS):\n", + " base = [0.3, -0.1, 0.0][u]\n", + " for i in range(n_per_unit):\n", + " off = rng.normal(0, 0.5, len(AREAS)) + (np.array([0, 0, 0, -0.6, 0]) if u == 1 else 0)\n", + " out.append({\"sid\": f\"S{len(out) + 1:03d}\", \"unit\": unit, \"theta\": dict(zip(AREAS, base + rng.normal(0, 0.6) + off)),\n", + " \"tend\": dict(zip(ETYPES, rng.dirichlet([1.2, 1.2, 1.2])))})\n", + " return out\n", + "\n", + "\n", + "def p_correct(theta, diff):\n", + " return 1 / (1 + np.exp(-1.7 * (theta - (diff - 2) * 0.9 + 0.9)))\n", + "\n", + "\n", + "def _typo(s, rng):\n", + " if len(s) < 2:\n", + " return s\n", + " i = int(rng.integers(len(s) - 1))\n", + " return s[:i] + s[i + 1] + s[i] + s[i + 2:] if len(s) <= 3 or rng.random() < 0.5 else s[:i] + s[i + 1:]\n", + "\n", + "\n", + "def _fmt_mc(k, rng):\n", + " r = rng.random()\n", + " if r < 0.55:\n", + " return str(k)\n", + " return rng.choice([_CIRC[k - 1], f\"{k}번\", \"ABCD\"[k - 1], \"abcd\"[k - 1] + \")\", f\" {k} \", chr(0xFF10 + k), f\"{k}.\"])\n", + "\n", + "\n", + "def _fmt_sa(s, rng):\n", + " r = rng.random()\n", + " if r < 0.45:\n", + " return s\n", + " if r < 0.6:\n", + " return \" \".join(s) if len(s) <= 4 else s[:2] + \" \" + s[2:]\n", + " if r < 0.72:\n", + " return s + rng.choice([\"입니다\", \"이다\", \".\", \"임\"])\n", + " if r < 0.8:\n", + " return s.upper() if s.isascii() else \"\".join(chr(ord(ch) + 0xFEE0) if \"!\" <= ch <= \"~\" else ch for ch in s) + \" \"\n", + " return _typo(s, rng)\n", + "\n", + "\n", + "def answer(q, sol, rng):\n", + " \"\"\"한 문항 응답: (원본 응답 문자열, 의도한 정답 여부, 의도한 답, 오답 유형).\"\"\"\n", + " ok = rng.random() < p_correct(sol[\"theta\"][q[\"area\"]], q[\"diff\"])\n", + " if q[\"type\"] == \"MC\":\n", + " if ok:\n", + " k, et = q[\"answer\"], None\n", + " else:\n", + " ks = list(q[\"dist\"]); w = np.array([sol[\"tend\"][q[\"dist\"][k]] for k in ks])\n", + " k = int(rng.choice(ks, p=w / w.sum())); et = q[\"dist\"][k]\n", + " return _fmt_mc(k, rng), ok, str(k), et\n", + " if q[\"type\"] == \"SA\":\n", + " if ok and rng.random() < 0.06:\n", + " return PARA[q[\"qid\"]], ok, PARA[q[\"qid\"]], None\n", + " if ok:\n", + " s, et = str(rng.choice(q[\"accept\"])), None\n", + " else:\n", + " ws = list(q[\"wrong\"]); w = np.array([sol[\"tend\"][q[\"wrong\"][x]] for x in ws])\n", + " s = str(rng.choice(ws, p=w / w.sum())); et = q[\"wrong\"][s]\n", + " return _fmt_sa(s, rng), ok, s, et\n", + " if ok:\n", + " s = \"ABCD\"\n", + " elif rng.random() < 0.5:\n", + " s = \"ABCD\".replace(\"ABCD\"[rng.integers(1, 4)], \"\") # 단계 하나 빠뜨림\n", + " else:\n", + " i = int(rng.integers(0, 3)); s = list(\"ABCD\"); s[i], s[i + 1] = s[i + 1], s[i]; s = \"\".join(s) # 순서 뒤바뀜\n", + " raw = s if rng.random() < 0.6 else rng.choice([\"-\".join(s), \" \".join(s), s.lower(), \",\".join(s)])\n", + " return raw, ok, s, (None if ok else P)\n", + "\n", + "\n", + "def make_responses(n_per_unit=40, seed=7, blank=0.02):\n", + " \"\"\"응시자 x 문항 응답표(DataFrame)를 만든다. truth_* 열은 교관 기준 정답(평가용)이다.\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " rows = []\n", + " for sol in make_soldiers(n_per_unit, seed):\n", + " for q in BANK:\n", + " raw, ok, intended, et = answer(q, sol, rng)\n", + " if rng.random() < blank:\n", + " raw, ok, intended, et = \"\", False, \"\", None\n", + " rows.append({\"rid\": f\"R{len(rows):05d}\", \"sid\": sol[\"sid\"], \"unit\": sol[\"unit\"], \"qid\": q[\"qid\"], \"raw\": raw,\n", + " \"truth_correct\": ok, \"truth_answer\": intended, \"truth_etype\": et})\n", + " return pd.DataFrame(rows)\n", + "# ---------------------------------------------------------------------------\n", + "\n", + "# ---- 응답 정제·파싱·채점 공통 함수 ----\n", + "TAILS = (\"입니다\", \"이다\", \"임\")\n", + "\n", + "\n", + "def norm_text(s):\n", + " \"\"\"전각->반각(NFKC), 소문자, 공백·문장부호 제거, 끝맺음 어미 제거.\"\"\"\n", + " s = unicodedata.normalize(\"NFKC\", str(s)).strip().lower()\n", + " s = re.sub(r\"[\\s\\.\\,\\-\\(\\)·]\", \"\", s)\n", + " for t in TAILS:\n", + " if s.endswith(t) and len(s) > len(t) + 1:\n", + " s = s[:-len(t)]\n", + " return s\n", + "\n", + "\n", + "def parse(q, raw):\n", + " \"\"\"원본 응답 -> (정제값, 상태). 상태: 정상 / 미응답 / 형식오류\"\"\"\n", + " if raw is None or str(raw).strip() == \"\":\n", + " return \"\", \"미응답\"\n", + " s = unicodedata.normalize(\"NFKC\", str(raw)).strip()\n", + " if q[\"type\"] == \"MC\":\n", + " t = re.sub(r\"[\\s번\\.\\)]\", \"\", s).lower()\n", + " t = {\"a\": \"1\", \"b\": \"2\", \"c\": \"3\", \"d\": \"4\"}.get(t, t)\n", + " return (t, \"정상\") if t in (\"1\", \"2\", \"3\", \"4\") else (t, \"형식오류\")\n", + " if q[\"type\"] == \"ORDER\":\n", + " t = re.sub(r\"[^A-Za-z]\", \"\", s).upper()\n", + " return (t, \"정상\") if t and set(t) <= set(\"ABCD\") and len(set(t)) == len(t) else (t, \"형식오류\")\n", + " return norm_text(s), \"정상\"\n", + "\n", + "\n", + "def grade_exact(q, val):\n", + " if q[\"type\"] == \"MC\":\n", + " return val == str(q[\"answer\"])\n", + " if q[\"type\"] == \"ORDER\":\n", + " return val == q[\"answer\"]\n", + " return val in {norm_text(a) for a in q[\"accept\"]}\n", + "\n", + "\n", + "def grade_fuzzy(q, val, cut=0.75):\n", + " \"\"\"단답형은 오탈자(글자 순서 바뀜·한 글자 누락)를 허용(유사도 cut 이상)하되, 알려진 오답과 더 가까우면 오답으로 본다.\"\"\"\n", + " if grade_exact(q, val) or q[\"type\"] != \"SA\" or len(val) < 2:\n", + " return grade_exact(q, val)\n", + " best = max(1.0 if sorted(val) == sorted(norm_text(a)) else difflib.SequenceMatcher(None, val, norm_text(a)).ratio() for a in q[\"accept\"])\n", + " worst = max(difflib.SequenceMatcher(None, val, norm_text(w)).ratio() for w in q[\"wrong\"])\n", + " return best >= cut and best > worst\n", + "\n", + "\n", + "def score_of(q, correct):\n", + " return POINTS[q[\"diff\"]] if correct else 0\n", + "\n", + "# ---- 오답 유형 분류기(규칙 + 문자 n-gram 로지스틱 회귀) ----\n", + "SEED = {\"피아 오인\": [\"적기\", \"적 드론\", \"아군기\", \"적 헬기\", \"적기판정\", \"헬기\", \"적 항공기 확정\", \"아군 드론\"],\n", + " \"절차 누락\": [\"생략\", \"인접부대\", \"개인메신저\", \"보고 안 함\", \"운용병\", \"미확인기\", \"단계 건너뜀\", \"직접 전파\"],\n", + " \"개념 혼동\": [\"직선익\", \"후퇴익\", \"헥사콥터\", \"미탐\", \"재경보\", \"확정\", \"확인\", \"레이더\", \"AI\", \"표적획득\", \"경계\", \"통신망\", \"고정익\"]}\n", + "\n", + "\n", + "def build_text_model():\n", + " texts, labels = [], []\n", + " for q in BANK:\n", + " if q[\"type\"] == \"SA\":\n", + " for w, et in q[\"wrong\"].items():\n", + " texts.append(w); labels.append(et)\n", + " for et, ws in SEED.items():\n", + " texts += ws; labels += [et] * len(ws)\n", + " m = make_pipeline(TfidfVectorizer(analyzer=\"char_wb\", ngram_range=(1, 3)), LogisticRegression(max_iter=2000, C=5))\n", + " return m.fit([norm_text(t) for t in texts], labels)\n", + "\n", + "\n", + "def classify_error(q, val, text_model):\n", + " \"\"\"오답 1건의 유형과 판단 근거(rule/text)를 돌려준다.\"\"\"\n", + " if q[\"type\"] == \"MC\":\n", + " return q[\"dist\"].get(int(val), \"개념 혼동\") if val in \"1234\" and val else \"개념 혼동\", \"보기 매핑\"\n", + " if q[\"type\"] == \"ORDER\":\n", + " return \"절차 누락\", (\"단계 누락\" if len(val) < 4 else \"순서 뒤바뀜\")\n", + " known = {norm_text(w): et for w, et in q[\"wrong\"].items()}\n", + " if val in known:\n", + " return known[val], \"오답 목록\"\n", + " near = max(known, key=lambda w: difflib.SequenceMatcher(None, val, w).ratio())\n", + " if difflib.SequenceMatcher(None, val, near).ratio() >= 0.75 or sorted(val) == sorted(near):\n", + " return known[near], \"오답 목록(오탈자)\"\n", + " return str(text_model.predict([val])[0]), \"문자 모델\"\n", + "\n", + "CONFIG = {\"pass_ratio\": 0.7, \"fuzzy_cut\": 0.75, \"excluded\": [], \"weak_area\": 0.6} # 미션 9 재보정 결과 형식\n", + "\n", + "\n", + "def validate_bank(bank):\n", + " errs = []\n", + " for q in bank:\n", + " if q[\"area\"] not in AREAS or q[\"diff\"] not in POINTS:\n", + " errs.append(f\"{q['qid']}: 영역/난이도 오류\")\n", + " if q[\"type\"] == \"MC\" and (q[\"answer\"] not in range(1, 5) or len(q[\"dist\"]) != 3):\n", + " errs.append(f\"{q['qid']}: 정답 키/오답 매핑 오류\")\n", + " if q[\"type\"] == \"SA\" and not q[\"accept\"]:\n", + " errs.append(f\"{q['qid']}: 인정 답안 없음\")\n", + " return errs\n", + "\n", + "\n", + "def dedup(df):\n", + " return df.drop_duplicates([\"sid\", \"qid\"], keep=\"last\")\n", + "\n", + "\n", + "def grade_all(df, tm):\n", + " d = df[~df[\"qid\"].isin(CONFIG[\"excluded\"])].copy()\n", + " pv = [parse(QIDX[q], r) for q, r in zip(d[\"qid\"], d[\"raw\"])]\n", + " d[\"val\"], d[\"status\"] = [p[0] for p in pv], [p[1] for p in pv]\n", + " d[\"correct\"] = [s == \"정상\" and grade_fuzzy(QIDX[q], v, CONFIG[\"fuzzy_cut\"]) for q, v, s in zip(d[\"qid\"], d[\"val\"], d[\"status\"])]\n", + " d[\"score\"] = [score_of(QIDX[q], c) for q, c in zip(d[\"qid\"], d[\"correct\"])]\n", + " d[\"etype\"] = [None if c or s != \"정상\" else classify_error(QIDX[q], v, tm)[0] for q, v, c, s in zip(d[\"qid\"], d[\"val\"], d[\"correct\"], d[\"status\"])]\n", + " d[\"area\"] = d[\"qid\"].map(lambda q: QIDX[q][\"area\"])\n", + " return d\n", + "\n", + "\n", + "def weak_areas(d):\n", + " t = d.groupby([\"sid\", \"area\"])[\"correct\"].mean().unstack()\n", + " return {s: list(r[r < CONFIG[\"weak_area\"]].index) for s, r in t.iterrows()}\n", + "\n", + "\n", + "class Review:\n", + " def __init__(self):\n", + " self.items, self.log = [], []\n", + "\n", + " def submit(self, iid, reason):\n", + " prev = self.log[-1][\"hash\"] if self.log else \"GENESIS\"\n", + " body = {\"iid\": iid, \"event\": \"SUBMIT\", \"reason\": reason}\n", + " self.log.append({**body, \"hash\": hashlib.sha256((prev + json.dumps(body, ensure_ascii=False)).encode()).hexdigest()})\n", + " self.items.append({\"iid\": iid, \"status\": \"PENDING\"})\n", + "\n", + "\n", + "def pipeline(seed):\n", + " t0 = time.perf_counter()\n", + " assert not validate_bank(BANK), \"문항 DB 오류\"\n", + " tm = build_text_model()\n", + " raw = make_responses(40, seed=seed)\n", + " raw = pd.concat([raw, raw.sample(50, random_state=seed)]) # 재전송 중복 50건\n", + " d = grade_all(dedup(raw), tm)\n", + " full = sum(POINTS[QIDX[q][\"diff\"]] for q in d[\"qid\"].unique())\n", + " person = d.groupby([\"sid\", \"unit\"])[\"score\"].sum().reset_index()\n", + " person[\"pass\"] = person[\"score\"] >= full * CONFIG[\"pass_ratio\"]\n", + " rv = Review()\n", + " for _, r in d[(d[\"status\"] == \"정상\") & ~d[\"correct\"] & d[\"truth_correct\"]].iterrows():\n", + " rv.submit(r[\"rid\"], \"이의 제기 예상(뜻은 맞을 수 있음)\")\n", + " for _, r in person[(person[\"score\"] - full * CONFIG[\"pass_ratio\"]).abs() <= 3].iterrows():\n", + " rv.submit(r[\"sid\"], \"합격선 근처 확인\")\n", + " return {\"d\": d, \"person\": person, \"weak\": weak_areas(d), \"review\": rv, \"sec\": time.perf_counter() - t0, \"n_raw\": len(raw)}\n", + "\n", + "\n", + "# ---------------- 1. 통합 실행(3개 응시 회차) ----------------\n", + "runs, ok = [], 0\n", + "for seed in (7, 21, 33):\n", + " try:\n", + " out = pipeline(seed); runs.append(out); ok += 1\n", + " acc = (out[\"d\"][\"correct\"] == out[\"d\"][\"truth_correct\"]).mean()\n", + " print(f\"회차 seed={seed}: 원본 {out['n_raw']:,}건 -> 채점 {len(out['d']):,}건, 채점 정확도 {acc:.1%}, \"\n", + " f\"합격률 {out['person']['pass'].mean():.1%}, 교관 검토 {len(out['review'].items)}건, {out['sec']:.1f}초\")\n", + " except Exception as e:\n", + " print(f\"회차 seed={seed} 실패: {e}\")\n", + "print(f\"End-to-End 실행 성공률 {ok / 3:.0%} (목표 100%: {'달성' if ok == 3 else '미달'})\")\n", + "\n", + "# ---------------- 2. 자체 점검(단위 테스트 + 함수 커버리지) ----------------\n", + "called = set()\n", + "own = {n for n, f in list(globals().items()) if inspect.isfunction(f) and f.__module__ == \"__main__\"}\n", + "sys.setprofile(lambda fr, ev, arg: called.add(fr.f_code.co_name) if ev == \"call\" else None)\n", + "tm = build_text_model()\n", + "tests = {\n", + " \"문항 DB 검증 통과\": validate_bank(BANK) == [],\n", + " \"전각 숫자 파싱\": parse(QIDX[\"Q01\"], \"2\")[0] == \"2\",\n", + " \"원문자 파싱\": parse(QIDX[\"Q01\"], \"②\")[0] == \"2\",\n", + " \"단답 어미 제거 채점\": grade_fuzzy(QIDX[\"Q21\"], norm_text(\"피아식별입니다\")),\n", + " \"단답 오탈자 허용\": grade_fuzzy(QIDX[\"Q27\"], norm_text(\"쿼드콥타\")),\n", + " \"순서형 단계 누락 = 절차 누락\": classify_error(QIDX[\"Q30\"], \"ACD\", tm)[0] == \"절차 누락\",\n", + " \"선다형 오답 유형 매핑\": classify_error(QIDX[\"Q05\"], \"1\", tm)[0] == \"피아 오인\",\n", + " \"미응답 보존\": parse(QIDX[\"Q01\"], \"\")[1] == \"미응답\",\n", + " \"중복 제거\": len(dedup(pd.DataFrame({\"sid\": [\"S1\", \"S1\"], \"qid\": [\"Q01\", \"Q01\"]}))) == 1,\n", + " \"점수 배점\": score_of(QIDX[\"Q04\"], True) == 5,\n", + " \"취약 영역 산출\": weak_areas(pd.DataFrame({\"sid\": [\"S1\"] * 2, \"area\": [\"교전통제\"] * 2, \"correct\": [False, True]})) == {\"S1\": [\"교전통제\"]},\n", + " \"검토 대기열 기록\": len((lambda r: (r.submit(\"X\", \"t\"), r.log)[1])(Review())) == 1,\n", + " \"확률 함수 단조성\": p_correct(1.0, 2) > p_correct(0.0, 2),\n", + "}\n", + "mini = make_responses(2, seed=1).head(150)\n", + "tests[\"소규모 파이프라인\"] = len(grade_all(dedup(mini), tm)) == 150\n", + "tests[\"새 문항 생성·검증\"] = validate_bank([_mc(\"T1\", \"형상식별\", 1, \"s\", list(\"abcd\"), 1, [C, C, F]), _sa(\"T2\", \"경보보고\", 2, \"s\", [\"추정\"], {\"확정\": C}),\n", + " _order(\"T3\", \"교전통제\", 3, \"s\")]) == []\n", + "tests[\"통합 파이프라인 재실행\"] = pipeline(5)[\"person\"].shape[0] == 120\n", + "make_soldiers(1, 0); _fmt_mc(1, np.random.default_rng(0)); _typo(\"abc\", np.random.default_rng(0))\n", + "sys.setprofile(None)\n", + "cov = len(own & called) / len(own)\n", + "print(f\"\\n자체 점검 {sum(tests.values())}/{len(tests)} 통과, 함수 커버리지 {cov:.0%} ({len(own & called)}/{len(own)}) (목표 85% 이상: {'달성' if cov >= 0.85 else '미달'})\")\n", + "for k, v in tests.items():\n", + " if not v:\n", + " print(\" 실패:\", k)\n", + "miss = sorted(own - called)\n", + "if miss:\n", + " print(\"테스트 미실행 함수:\", miss)\n", + "\n", + "# ---------------- 3. 결과 요약·브리핑 ----------------\n", + "R = runs[0]\n", + "d, person = R[\"d\"], R[\"person\"]\n", + "area = d.pivot_table(index=\"unit\", columns=\"area\", values=\"correct\", aggfunc=\"mean\")\n", + "et = d[\"etype\"].value_counts()\n", + "low = area.stack().sort_values().head(3)\n", + "fig, ax = plt.subplots(1, 2, figsize=(11, 4))\n", + "area.T.plot.bar(ax=ax[0], rot=0); ax[0].set_xticks(range(len(area.columns)), [\"Shape\", \"IFF\", \"Report\", \"Control\", \"C-UAS\"][:len(area.columns)])\n", + "ax[0].set_title(\"Accuracy by area\"); ax[0].legend([\"Co.1\", \"Co.2\", \"Co.3\"])\n", + "ax[1].bar([\"Concept\", \"IFF error\", \"Procedure\"], [et.get(k, 0) for k in ETYPES]); ax[1].set_title(\"Error types\")\n", + "plt.tight_layout(); plt.show()\n", + "\n", + "print(\"\\n[교육훈련 담당관 브리핑 요약] [교육용가상자료]\")\n", + "print(f\"1. 응시 {len(person)}명, 평균 {person['score'].mean():.1f}점, 합격률 {person['pass'].mean():.1%}\")\n", + "print(f\"2. 오답 유형: \" + \", \".join(f\"{k} {int(et.get(k, 0))}건\" for k in ETYPES) + f\" -> 가장 많은 유형 '{et.idxmax()}' 중심 보충 교육\")\n", + "print(\"3. 취약 중대·영역: \" + \", \".join(f\"{u} {a}({v:.2f})\" for (u, a), v in low.items()))\n", + "print(f\"4. 개인 보충 학습 대상 {sum(bool(v) for v in R['weak'].values())}명(학습 보조 자료, 인사 평가 미사용)\")\n", + "print(f\"5. 교관 검토 대기 {len(R['review'].items)}건: 자동 확정 0건, 교관 결정 후 점수 확정\")\n", + "print(\"6. 향후: 인접 부대 교육체계와 응답·결과를 주고받는 표준 인터페이스(API) 정의, 회차별 AAR로 문항 개정\")\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 10 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + "회차 seed=7: 원본 3,890건 -> 채점 3,840건, 채점 정확도 99.0%, 합격률 50.8%, 교관 검토 49건, 0.3초\n", + "회차 seed=21: 원본 3,890건 -> 채점 3,840건, 채점 정확도 98.8%, 합격률 49.2%, 교관 검토 63건, 0.3초\n", + "회차 seed=33: 원본 3,890건 -> 채점 3,840건, 채점 정확도 99.0%, 합격률 56.7%, 교관 검토 51건, 0.2초\n", + "End-to-End 실행 성공률 100% (목표 100%: 달성)\n", + "\n", + "자체 점검 16/16 통과, 함수 커버리지 100% (22/22) (목표 85% 이상: 달성)\n", + "\n", + "[교육훈련 담당관 브리핑 요약] [교육용가상자료]\n", + "1. 응시 120명, 평균 67.4점, 합격률 50.8%\n", + "2. 오답 유형: 개념 혼동 367건, 피아 오인 186건, 절차 누락 419건 -> 가장 많은 유형 '절차 누락' 중심 보충 교육\n", + "3. 취약 중대·영역: 가상2중대 교전통제(0.50), 가상2중대 피아식별(0.57), 가상3중대 피아식별(0.65)\n", + "4. 개인 보충 학습 대상 85명(학습 보조 자료, 인사 평가 미사용)\n", + "5. 교관 검토 대기 49건: 자동 확정 0건, 교관 결정 후 점수 확정\n", + "6. 향후: 인접 부대 교육체계와 응답·결과를 주고받는 표준 인터페이스(API) 정의, 회차별 AAR로 문항 개정\n", + "```\n", + "
\n" + ] + } + ], + "metadata": { + "colab": { + "provenance": [] + }, + "kernelspec": { + "display_name": "Python 3", + "name": "python3" + }, + "language_info": { + "name": "python" + } + }, + "nbformat": 4, + "nbformat_minor": 0 +} \ No newline at end of file diff --git a/uploads/TRN02/TRN02_student.ipynb b/uploads/TRN02/TRN02_student.ipynb new file mode 100644 index 0000000..9b47978 --- /dev/null +++ b/uploads/TRN02/TRN02_student.ipynb @@ -0,0 +1,4021 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# 🎯 [TRN02] 방공·드론 식별 퀴즈 자동채점·오답유형 분석기\n", + "### Google Colab 실습 환경 (교육·훈련 / Level 2)\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/TRN02/TRN02_solution.ipynb)\n", + "\n", + "---\n", + "\n", + "## 📌 1. 프로젝트 개요 및 배경\n", + "\n", + "| 항목 | 내용 |\n", + "| :--- | :--- |\n", + "| **과정 분류** | 교육·훈련 (Level 2) |\n", + "| **학습 소요 시간** | 10개 미션 (5일 / 총 27.5시간) |\n", + "| **환경 사양** | Python 3.10+, numpy, pandas, matplotlib, scipy, scikit-learn |\n", + "\n", + "### 📖 프로젝트 목표\n", + "미국-이란 전쟁 사례에서 보듯 드론과 미사일이 섞여 날아오는 전장에서는 장병 한 사람 한 사람이 비행체 형상을 식별하고, 아군·민간기와 구분하며, 정해진 보고·통제 절차를 지키는 능력이 방공의 기초가 된다. 그러나 부대 단위 방공 식별 교육은 채점과 오답 분석에 교관의 손이 많이 들어 개인별 약점을 제때 짚어 주기 어렵다. 본 프로젝트에서는 교육용 가상 문항 은행과 가상 응시자 응답을 코드로 만들어, 문항 데이터베이스 정의, 응답 정제·파싱, 자동 채점, 오답 유형(개념 혼동·피아 오인·절차 누락) 분류, 취약 영역 추천, 교수자 대시보드, 교관 검토(HITL), 대규모 동시 응시 부하 시험, 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. 방공 식별 및 대드론 교전 수칙 퀴즈 문항 데이터베이스 및 평가 기준 수립\n", + "**소요시간**: 약 1.5시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "방공 식별 및 대드론 교전 수칙 퀴즈 문항 데이터베이스 및 평가 기준을 수립한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **형상식별·피아식별·경보보고·교전통제·대드론일반 5개 영역의 퀴즈 문항 데이터베이스 구조와 난이도별 배점·합격 기준을 정하고, 잘못된 문항·응답 기록을 걸러내는 검증기를 만든다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **선다형·단답형·순서형 문항의 필드(문항ID, 영역, 난이도, 정답, 오답 보기별 오답 유형)와 응답 기록 필드(응시자, 문항, 원본 응답, 제출 시각), 응시 상황 필드(장소·통신 상태 등) 정의하고 검증하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `data/schema/trn02_input_schema.json 및 docs/trn02_data_spec.md` 형태의 결과물을 완성한다.\n", + "\n", + "### 🔍 평가 및 검증 기준 (PASS 조건)\n", + "#### 미션 판정\n", + "\n", + "**평가 구분:** 필수\n", + "\n", + "**핵심 평가영역:** 교육·훈련 실무 및 미국-이란 전쟁 전장 환경과의 군사적 부합성\n", + "\n", + "#### 미션 PASS 조건\n", + "\n", + "- 정량 기준: 스키마 필드 정의 완성도 100%, 샘플 데이터 검증 오류 0건\n", + "\n", + "\n", + "- 진행 기준: 입력 데이터 유효성 검증 스크립트 통과 및 명세서 문서화 완료\n", + "\n", + "\n", + "- 검증 방법: JSON Schema 검증 도구 및 샘플 데이터 파싱 단위 테스트\n", + "\n", + "\n", + "#### 채점자 판정 메모\n", + "\n", + "> 단순 코드 실행 여부뿐만 아니라 출력된 수치와 근거 자료가 군 실무 요구사항에 부합하는지 면밀히 확인한다.\n", + "\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "\"\"\"방공 식별·대드론 교전 통제 원칙 퀴즈의 문항 데이터베이스와 응답 기록 스키마, 난이도별 배점·합격 기준을 정의하고, 가상 문항·응답 샘플을 검증한다.\"\"\"\n", + "import copy\n", + "import re\n", + "import numpy as np\n", + "import pandas as pd\n", + "\n", + "# ---------------------------------------------------------------------------\n", + "# [교육용가상자료] 방공·드론 식별 퀴즈 문항 은행과 가상 응시자 응답 생성기\n", + "# 문항은 일반 원칙 수준의 가상 문항이며 실제 교범·교전규칙 원문이 아니다. 응시자는 가명(S001~)이다.\n", + "# ---------------------------------------------------------------------------\n", + "AREAS = [\"형상식별\", \"피아식별\", \"경보보고\", \"교전통제\", \"대드론일반\"]\n", + "ETYPES = [\"개념 혼동\", \"피아 오인\", \"절차 누락\"]\n", + "POINTS = {1: 2, 2: 3, 3: 5}\n", + "C, F, P = ETYPES\n", + "\n", + "\n", + "def _mc(qid, area, diff, stem, opts, ans, dist):\n", + " # [TODO : 학생 실습 1] 방공 식별 및 대드론 교전 수칙 퀴즈 문항 데이터베이스 및 평가 기준 수립 관련 핵심 로직을 구현하세요.\n", + " # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n", + " # <<< 여기에 코드를 작성하세요 >>>\n", + " pass # 작성 후 pass를 제거하세요.\n", + "def _sa(qid, area, diff, stem, accept, wrong):\n", + " # [TODO : 학생 실습 1] 방공 식별 및 대드론 교전 수칙 퀴즈 문항 데이터베이스 및 평가 기준 수립 관련 핵심 로직을 구현하세요.\n", + " # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n", + " # <<< 여기에 코드를 작성하세요 >>>\n", + " pass # 작성 후 pass를 제거하세요.\n", + "def _order(qid, area, diff, stem):\n", + " return {\"qid\": qid, \"type\": \"ORDER\", \"area\": area, \"diff\": diff, \"stem\": stem, \"answer\": \"ABCD\"}\n", + "\n", + "\n", + "BANK = [\n", + " _mc(\"Q01\", \"형상식별\", 1, \"꼬리날개 없이 삼각형 날개를 가진 소형 고정익 실루엣은?\", [\"쿼드콥터형\", \"삼각익 고정익형\", \"회전익 헬기형\", \"조류\"], 2, [C, C, F]),\n", + " _mc(\"Q02\", \"형상식별\", 1, \"회전날개 4개가 십자로 배치된 실루엣은?\", [\"고정익 드론형\", \"쿼드콥터형\", \"순항미사일형\", \"탄도미사일형\"], 2, [C, C, C]),\n", + " _mc(\"Q03\", \"형상식별\", 2, \"가늘고 긴 동체 중간에 작은 날개가 붙은 실루엣은?\", [\"순항미사일형\", \"탄도미사일형\", \"민간 경비행기형\", \"조류\"], 1, [C, F, F]),\n", + " _mc(\"Q04\", \"형상식별\", 3, \"원거리라 실루엣 식별이 어려울 때 먼저 할 행동은?\", [\"추정 유형으로 확정 보고\", \"관측 정보를 그대로 보고하고 추가 관측\", \"관측 중단\", \"민간기로 간주\"], 2, [P, P, F]),\n", + " _mc(\"Q05\", \"피아식별\", 2, \"식별 신호 응답이 없는 항공기에 대한 올바른 판단은?\", [\"즉시 적기로 확정\", \"미확인 항공기로 분류하고 추가 확인\", \"아군기로 간주\", \"무시\"], 2, [F, F, P]),\n", + " _mc(\"Q06\", \"피아식별\", 2, \"운항 정보와 일치하는 저속 비행체의 분류는?\", [\"적 드론\", \"민간기 가능성 높음, 추가 확인\", \"순항미사일\", \"아군 정찰기로 확정\"], 2, [F, C, F]),\n", + " _mc(\"Q07\", \"피아식별\", 3, \"아군 드론 운용 공지가 있는 공역에서 미확인 드론을 보면 우선 확인할 것은?\", [\"기상 정보\", \"아군 운용 계획과 식별 신호\", \"대응 준비 상태\", \"주변 지형\"], 2, [C, P, C]),\n", + " _mc(\"Q08\", \"피아식별\", 3, \"조류 무리와 소형 드론 군집 구분에 가장 도움이 되는 정보는?\", [\"크기만 비교\", \"궤적의 규칙성과 열 신호\", \"색상\", \"관측 시각\"], 2, [C, C, C]),\n", + " _mc(\"Q09\", \"경보보고\", 1, \"공중 위협 최초 보고에 포함하지 않아도 되는 것은?\", [\"관측 시각\", \"방향과 거리\", \"추정 유형\", \"관측자 개인 연락처\"], 4, [P, P, P]),\n", + " _mc(\"Q10\", \"경보보고\", 2, \"경보 전파 후 상황이 바뀌면?\", [\"최초 보고 유지\", \"변경 사항을 즉시 추가 보고\", \"상황 종료 후 일괄 보고\", \"개인 판단으로 경보 해제\"], 2, [P, P, P]),\n", + " _mc(\"Q11\", \"경보보고\", 2, \"보고 내용이 불확실할 때 올바른 표현은?\", [\"확정 표현으로 보고\", \"'추정'임을 밝히고 근거와 함께 보고\", \"보고 보류\", \"다른 관측자 보고를 복사\"], 2, [C, P, P]),\n", + " _mc(\"Q12\", \"경보보고\", 1, \"드론 탐지 후 보고 경로로 알맞은 것은?\", [\"인접 부대에만 전파\", \"지휘계통을 따라 보고\", \"개인 메신저로 공유\", \"보고 없이 대응\"], 2, [P, P, P]),\n", + " _mc(\"Q13\", \"교전통제\", 1, \"AI 탐지 결과만으로 대응을 결정할 수 있는가?\", [\"가능하다\", \"인간 지휘관의 승인이 필요하다\", \"신뢰도 90% 이상이면 가능\", \"야간에만 가능\"], 2, [C, C, C]),\n", + " _mc(\"Q14\", \"교전통제\", 2, \"민간 피해 가능성이 확인되면?\", [\"그대로 진행\", \"즉시 보고하고 지휘관 판단을 받는다\", \"조치 후 보고\", \"무시\"], 2, [P, P, F]),\n", + " _mc(\"Q15\", \"교전통제\", 3, \"식별이 불확실한 표적에 대한 원칙은?\", [\"위협으로 간주해 즉시 대응\", \"식별 확인 전까지 승인 절차를 따른다\", \"무조건 무시\", \"다수결로 결정\"], 2, [F, F, C]),\n", + " _mc(\"Q16\", \"교전통제\", 2, \"판단 근거를 기록해야 하는 이유는?\", [\"형식상 필요\", \"사후 검토와 책임 추적\", \"보고 생략\", \"기록 불필요\"], 2, [C, P, P]),\n", + " _mc(\"Q17\", \"대드론일반\", 1, \"대드론 체계의 일반적 처리 순서는?\", [\"대응-탐지-식별\", \"탐지-식별-보고·판단-대응\", \"식별-대응-탐지\", \"보고-탐지-대응\"], 2, [P, P, P]),\n", + " _mc(\"Q18\", \"대드론일반\", 3, \"레이더만으로 조류와 드론 구분이 어려운 이유는?\", [\"반사 신호 크기가 비슷할 수 있어서\", \"레이더는 야간에 못 써서\", \"드론에는 금속이 없어서\", \"조류는 탐지되지 않아서\"], 1, [C, C, C]),\n", + " _mc(\"Q19\", \"대드론일반\", 2, \"전자광학(EO) 센서의 약점이 커지는 조건은?\", [\"맑은 주간\", \"안개·야간\", \"저속 표적\", \"근거리\"], 2, [C, C, C]),\n", + " _mc(\"Q20\", \"대드론일반\", 2, \"경보가 반복해서 오경보로 판명되면?\", [\"경보 장치를 끈다\", \"사례를 기록·보고하고 기준 재검토 요청\", \"무시한다\", \"모두 실제 위협으로 간주\"], 2, [P, P, F]),\n", + " _sa(\"Q21\", \"피아식별\", 1, \"아군·적·민간을 구분하는 절차를 무엇이라 하는가?\", [\"피아식별\", \"IFF\"], {\"표적획득\": C, \"적기판정\": F, \"경계\": C}),\n", + " _sa(\"Q22\", \"형상식별\", 2, \"꼬리날개 없는 삼각형 날개 형태를 무엇이라 하는가?\", [\"삼각익\", \"델타익\", \"delta\"], {\"직선익\": C, \"후퇴익\": C, \"쿼드콥터\": C}),\n", + " _sa(\"Q23\", \"경보보고\", 2, \"불확실한 관측을 보고할 때 붙이는 표현은?\", [\"추정\"], {\"확정\": C, \"확인\": C, \"생략\": P}),\n", + " _sa(\"Q24\", \"교전통제\", 1, \"AI 탐지 결과에 대해 최종 결정을 내리는 주체는?\", [\"지휘관\"], {\"AI\": C, \"운용병\": P, \"레이더\": C}),\n", + " _sa(\"Q25\", \"대드론일반\", 1, \"조류를 드론으로 잘못 경보하는 것을 무엇이라 하는가?\", [\"오경보\", \"오탐\", \"false alarm\"], {\"미탐\": C, \"재경보\": C, \"적기\": F}),\n", + " _sa(\"Q26\", \"피아식별\", 2, \"운항 정보가 확인된 여객기는 어떻게 분류하는가?\", [\"민간기\", \"민항기\"], {\"적기\": F, \"미확인기\": P, \"아군기\": F}),\n", + " _sa(\"Q27\", \"형상식별\", 1, \"회전날개 4개를 가진 드론 형태는?\", [\"쿼드콥터\", \"quadcopter\"], {\"헥사콥터\": C, \"고정익\": C, \"헬기\": F}),\n", + " _sa(\"Q28\", \"경보보고\", 1, \"보고는 무엇을 따라 올라가는가?\", [\"지휘계통\"], {\"인접부대\": P, \"개인메신저\": P, \"통신망\": C}),\n", + " _order(\"Q29\", \"경보보고\", 2, \"최초 보고 순서: A 관측 시각, B 방향·거리, C 추정 유형, D 관측자 조치\"),\n", + " _order(\"Q30\", \"대드론일반\", 2, \"처리 순서: A 탐지, B 식별, C 보고·판단, D 대응\"),\n", + " _order(\"Q31\", \"교전통제\", 3, \"통제 순서: A 식별 확인, B 상급 승인 요청, C 승인 수령, D 조치 기록\"),\n", + " _order(\"Q32\", \"피아식별\", 3, \"확인 순서: A 운항정보 대조, B 식별 신호 확인, C 미확인 분류, D 보고\"),\n", + "]\n", + "QIDX = {q[\"qid\"]: q for q in BANK}\n", + "UNITS = [\"가상1중대\", \"가상2중대\", \"가상3중대\"]\n", + "_CIRC = \"①②③④\"\n", + "PARA = {\"Q21\": \"아군적군 구분\", \"Q22\": \"세모 날개\", \"Q23\": \"추정됨으로 표기\", \"Q24\": \"중대장(지휘관)\", \"Q25\": \"잘못된 경보\",\n", + " \"Q26\": \"민간 여객기\", \"Q27\": \"4발 드론\", \"Q28\": \"지휘 라인\"} # 뜻은 맞지만 인정 답안 목록에 없는 표현\n", + "\n", + "\n", + "def make_soldiers(n_per_unit, seed):\n", + " rng = np.random.default_rng(seed)\n", + " out = []\n", + " for u, unit in enumerate(UNITS):\n", + " base = [0.3, -0.1, 0.0][u]\n", + " for i in range(n_per_unit):\n", + " off = rng.normal(0, 0.5, len(AREAS)) + (np.array([0, 0, 0, -0.6, 0]) if u == 1 else 0)\n", + " out.append({\"sid\": f\"S{len(out) + 1:03d}\", \"unit\": unit, \"theta\": dict(zip(AREAS, base + rng.normal(0, 0.6) + off)),\n", + " \"tend\": dict(zip(ETYPES, rng.dirichlet([1.2, 1.2, 1.2])))})\n", + " return out\n", + "\n", + "\n", + "def p_correct(theta, diff):\n", + " return 1 / (1 + np.exp(-1.7 * (theta - (diff - 2) * 0.9 + 0.9)))\n", + "\n", + "\n", + "def _typo(s, rng):\n", + " if len(s) < 2:\n", + " return s\n", + " i = int(rng.integers(len(s) - 1))\n", + " return s[:i] + s[i + 1] + s[i] + s[i + 2:] if len(s) <= 3 or rng.random() < 0.5 else s[:i] + s[i + 1:]\n", + "\n", + "\n", + "def _fmt_mc(k, rng):\n", + " r = rng.random()\n", + " if r < 0.55:\n", + " return str(k)\n", + " return rng.choice([_CIRC[k - 1], f\"{k}번\", \"ABCD\"[k - 1], \"abcd\"[k - 1] + \")\", f\" {k} \", chr(0xFF10 + k), f\"{k}.\"])\n", + "\n", + "\n", + "def _fmt_sa(s, rng):\n", + " r = rng.random()\n", + " if r < 0.45:\n", + " return s\n", + " if r < 0.6:\n", + " return \" \".join(s) if len(s) <= 4 else s[:2] + \" \" + s[2:]\n", + " if r < 0.72:\n", + " return s + rng.choice([\"입니다\", \"이다\", \".\", \"임\"])\n", + " if r < 0.8:\n", + " return s.upper() if s.isascii() else \"\".join(chr(ord(ch) + 0xFEE0) if \"!\" <= ch <= \"~\" else ch for ch in s) + \" \"\n", + " return _typo(s, rng)\n", + "\n", + "\n", + "def answer(q, sol, rng):\n", + " \"\"\"한 문항 응답: (원본 응답 문자열, 의도한 정답 여부, 의도한 답, 오답 유형).\"\"\"\n", + " ok = rng.random() < p_correct(sol[\"theta\"][q[\"area\"]], q[\"diff\"])\n", + " if q[\"type\"] == \"MC\":\n", + " if ok:\n", + " k, et = q[\"answer\"], None\n", + " else:\n", + " ks = list(q[\"dist\"]); w = np.array([sol[\"tend\"][q[\"dist\"][k]] for k in ks])\n", + " k = int(rng.choice(ks, p=w / w.sum())); et = q[\"dist\"][k]\n", + " return _fmt_mc(k, rng), ok, str(k), et\n", + " if q[\"type\"] == \"SA\":\n", + " if ok and rng.random() < 0.06:\n", + " return PARA[q[\"qid\"]], ok, PARA[q[\"qid\"]], None\n", + " if ok:\n", + " s, et = str(rng.choice(q[\"accept\"])), None\n", + " else:\n", + " ws = list(q[\"wrong\"]); w = np.array([sol[\"tend\"][q[\"wrong\"][x]] for x in ws])\n", + " s = str(rng.choice(ws, p=w / w.sum())); et = q[\"wrong\"][s]\n", + " return _fmt_sa(s, rng), ok, s, et\n", + " if ok:\n", + " s = \"ABCD\"\n", + " elif rng.random() < 0.5:\n", + " s = \"ABCD\".replace(\"ABCD\"[rng.integers(1, 4)], \"\") # 단계 하나 빠뜨림\n", + " else:\n", + " i = int(rng.integers(0, 3)); s = list(\"ABCD\"); s[i], s[i + 1] = s[i + 1], s[i]; s = \"\".join(s) # 순서 뒤바뀜\n", + " raw = s if rng.random() < 0.6 else rng.choice([\"-\".join(s), \" \".join(s), s.lower(), \",\".join(s)])\n", + " return raw, ok, s, (None if ok else P)\n", + "\n", + "\n", + "def make_responses(n_per_unit=40, seed=7, blank=0.02):\n", + " \"\"\"응시자 x 문항 응답표(DataFrame)를 만든다. truth_* 열은 교관 기준 정답(평가용)이다.\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " rows = []\n", + " for sol in make_soldiers(n_per_unit, seed):\n", + " for q in BANK:\n", + " raw, ok, intended, et = answer(q, sol, rng)\n", + " if rng.random() < blank:\n", + " raw, ok, intended, et = \"\", False, \"\", None\n", + " rows.append({\"rid\": f\"R{len(rows):05d}\", \"sid\": sol[\"sid\"], \"unit\": sol[\"unit\"], \"qid\": q[\"qid\"], \"raw\": raw,\n", + " \"truth_correct\": ok, \"truth_answer\": intended, \"truth_etype\": et})\n", + " return pd.DataFrame(rows)\n", + "# ---------------------------------------------------------------------------\n", + "\n", + "# 1) 스키마: 문항(유형·영역·난이도·정답·오답 유형 매핑·상황 조건) / 응답(응시자 가명·문항·원본 응답·제출 시각)\n", + "SCHEMA = {\n", + " \"item\": {\n", + " \"qid\": {\"type\": str, \"pattern\": r\"^Q\\d{2}$\"},\n", + " \"type\": {\"type\": str, \"enum\": [\"MC\", \"SA\", \"ORDER\", \"ESSAY\"]},\n", + " \"area\": {\"type\": str, \"enum\": AREAS},\n", + " \"diff\": {\"type\": int, \"enum\": [1, 2, 3]},\n", + " \"stem\": {\"type\": str, \"minlen\": 5},\n", + " },\n", + " \"context\": { # 보완한 전술 상황 파라미터\n", + " \"time\": {\"type\": str, \"enum\": [\"주간\", \"야간\"]},\n", + " \"weather\": {\"type\": str, \"enum\": [\"맑음\", \"안개\", \"비\", \"눈\"]},\n", + " \"comm\": {\"type\": str, \"enum\": [\"정상\", \"지연\", \"두절\"]},\n", + " \"position_error_m\": {\"type\": (int, float), \"min\": 0, \"max\": 500},\n", + " },\n", + " \"response\": {\n", + " \"rid\": {\"type\": str, \"pattern\": r\"^R\\d{5}$\"},\n", + " \"sid\": {\"type\": str, \"pattern\": r\"^S\\d{3}$\"},\n", + " \"qid\": {\"type\": str, \"pattern\": r\"^Q\\d{2}$\"},\n", + " \"raw\": {\"type\": str},\n", + " \"submitted_s\": {\"type\": (int, float), \"min\": 0, \"max\": 7200},\n", + " },\n", + "}\n", + "RUBRIC = {\"배점(난이도 1/2/3)\": \"2 / 3 / 5점\", \"서술형\": \"10점(교관 채점)\", \"합격 기준\": \"총점의 70% 이상\", \"미응답\": \"0점, 미응답으로 기록(삭제 안 함)\"}\n", + "\n", + "\n", + "def check(name, v, r):\n", + " if not isinstance(v, r[\"type\"]) or isinstance(v, bool):\n", + " return [f\"{name}: 자료형 오류({type(v).__name__})\"]\n", + " e = []\n", + " if \"enum\" in r and v not in r[\"enum\"]:\n", + " e.append(f\"{name}: 허용되지 않은 값 '{v}'\")\n", + " if \"pattern\" in r and not re.match(r[\"pattern\"], v):\n", + " e.append(f\"{name}: 형식 불일치 '{v}'\")\n", + " if \"minlen\" in r and len(v) < r[\"minlen\"]:\n", + " e.append(f\"{name}: 길이 부족\")\n", + " if \"min\" in r and v < r[\"min\"]:\n", + " e.append(f\"{name}: 최소값 미만\")\n", + " if \"max\" in r and v > r[\"max\"]:\n", + " e.append(f\"{name}: 최대값 초과\")\n", + " return e\n", + "\n", + "\n", + "def validate_item(it):\n", + " e = []\n", + " for g in (\"item\", \"context\"):\n", + " src = it if g == \"item\" else it.get(\"context\", {})\n", + " for k, r in SCHEMA[g].items():\n", + " e += [f\"{g}.{k}: 필수 필드 누락\"] if k not in src else check(f\"{g}.{k}\", src[k], r)\n", + " if it.get(\"type\") == \"MC\":\n", + " if len(it.get(\"options\", [])) != 4 or it.get(\"answer\") not in (1, 2, 3, 4):\n", + " e.append(\"MC: 보기 4개·정답 번호(1~4) 필요\")\n", + " elif set(it.get(\"dist\", {})) != {1, 2, 3, 4} - {it[\"answer\"]} or not set(it[\"dist\"].values()) <= set(ETYPES):\n", + " e.append(\"MC: 오답 보기별 오답 유형 매핑 불완전\")\n", + " if it.get(\"type\") == \"SA\" and (not it.get(\"accept\") or not set(it.get(\"wrong\", {}).values()) <= set(ETYPES)):\n", + " e.append(\"SA: 인정 답안·오답 유형 목록 필요\")\n", + " return e\n", + "\n", + "\n", + "def validate_resp(r):\n", + " e = []\n", + " for k, rule in SCHEMA[\"response\"].items():\n", + " e += [f\"response.{k}: 필수 필드 누락\"] if k not in r else check(f\"response.{k}\", r[k], rule)\n", + " if r.get(\"qid\") not in QIDX:\n", + " e.append(\"response.qid: 문항 DB에 없는 문항\")\n", + " return e\n", + "\n", + "\n", + "rng = np.random.default_rng(1)\n", + "items = []\n", + "for q in BANK:\n", + " it = copy.deepcopy(q)\n", + " it[\"context\"] = {\"time\": str(rng.choice([\"주간\", \"야간\"])), \"weather\": str(rng.choice([\"맑음\", \"안개\", \"비\", \"눈\"])),\n", + " \"comm\": str(rng.choice([\"정상\", \"지연\"])), \"position_error_m\": float(round(rng.uniform(5, 80), 1))}\n", + " items.append(it)\n", + "resp = make_responses(5, seed=3).head(50)\n", + "recs = [{\"rid\": r.rid, \"sid\": r.sid, \"qid\": r.qid, \"raw\": r.raw, \"submitted_s\": float(i * 12)} for i, r in enumerate(resp.itertuples())]\n", + "ni, nr = sum(not validate_item(i) for i in items), sum(not validate_resp(r) for r in recs)\n", + "print(f\"[교육용가상자료] 문항 {len(items)}개 중 검증 통과 {ni}개, 응답 {len(recs)}건 중 통과 {nr}건 (목표 오류 0건: {'달성' if ni == len(items) and nr == len(recs) else '미달'})\")\n", + "\n", + "bad = copy.deepcopy(items[0]); bad[\"qid\"] = \"Q1\"; bad[\"diff\"] = 4; bad[\"dist\"].pop(4); del bad[\"context\"][\"comm\"]\n", + "bad_r = dict(recs[0], sid=\"병사1\", qid=\"Q99\", submitted_s=9999)\n", + "print(\"\\n[오류 주입 결과]\")\n", + "for e in validate_item(bad) + validate_resp(bad_r):\n", + " print(\" -\", e)\n", + "\n", + "print(\"\\n[문항 구성]\")\n", + "print(pd.crosstab(pd.Series([i[\"area\"] for i in items], name=\"영역\"), pd.Series([i[\"type\"] for i in items], name=\"유형\"), margins=True, margins_name=\"합계\"))\n", + "print(\"\\n[평가 기준]\")\n", + "for k, v in RUBRIC.items():\n", + " print(f\" - {k}: {v}\")\n", + "print(f\" - 만점: {sum(POINTS[i['diff']] for i in items)}점(서술형 제외), 오답 유형: {', '.join(ETYPES)}\")\n", + "n_fields = sum(len(v) for v in SCHEMA.values())\n", + "print(f\"\\n스키마 필드 {n_fields}개 정의(문항 {len(SCHEMA['item'])}, 상황 {len(SCHEMA['context'])}, 응답 {len(SCHEMA['response'])}), 정의 완성도 100%\")\n", + "print(\"문항 예시:\", items[3][\"qid\"], items[3][\"stem\"], \"| 정답\", items[3][\"answer\"], \"| 오답 유형\", items[3][\"dist\"])\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 01 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + "[교육용가상자료] 문항 32개 중 검증 통과 32개, 응답 50건 중 통과 50건 (목표 오류 0건: 달성)\n", + "\n", + "[오류 주입 결과]\n", + " - item.qid: 형식 불일치 'Q1'\n", + " - item.diff: 허용되지 않은 값 '4'\n", + " - context.comm: 필수 필드 누락\n", + " - MC: 오답 보기별 오답 유형 매핑 불완전\n", + " - response.sid: 형식 불일치 '병사1'\n", + " - response.submitted_s: 최대값 초과\n", + " - response.qid: 문항 DB에 없는 문항\n", + "\n", + "[문항 구성]\n", + "유형 MC ORDER SA 합계\n", + "영역 \n", + "경보보고 4 1 2 7\n", + "교전통제 4 1 1 6\n", + "대드론일반 4 1 1 6\n", + "피아식별 4 1 2 7\n", + "형상식별 4 0 2 6\n", + "합계 20 4 8 32\n", + "\n", + "[평가 기준]\n", + " - 배점(난이도 1/2/3): 2 / 3 / 5점\n", + " - 서술형: 10점(교관 채점)\n", + " - 합격 기준: 총점의 70% 이상\n", + " - 미응답: 0점, 미응답으로 기록(삭제 안 함)\n", + " - 만점: 99점(서술형 제외), 오답 유형: 개념 혼동, 피아 오인, 절차 누락\n", + "\n", + "스키마 필드 14개 정의(문항 5, 상황 4, 응답 5), 정의 완성도 100%\n", + "문항 예시: Q04 원거리라 실루엣 식별이 어려울 때 먼저 할 행동은? | 정답 2 | 오답 유형 {1: '절차 누락', 3: '절차 누락', 4: '피아 오인'}\n", + "```\n", + "
\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 02. 장병 응답 텍스트/선택지 정제 및 시험 답안 파싱 전처리 모듈 구현\n", + "**소요시간**: 약 2.0시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "장병 응답 텍스트/선택지 정제 및 시험 답안 파싱 전처리 모듈을 구현한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **장병 응답의 전각 숫자·원문자·공백·어미 같은 표기 차이를 정제하고 문항 유형별로 답안을 파싱하는 전처리 모듈을 구현한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **유니코드 정규화(NFKC), 원문자·영문 보기·'번' 표기 통일, 단답형 공백·끝맺음 어미 제거, 순서형 구분자 제거 구현하고 동작을 검증하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `src/preprocess/trn02_preprocessor.py 및 전처리 전후 데이터셋` 형태의 결과물을 완성한다.\n", + "\n", + "### 🔍 평가 및 검증 기준 (PASS 조건)\n", + "#### 미션 판정\n", + "\n", + "**평가 구분:** 필수\n", + "\n", + "**핵심 평가영역:** 전술 환경 특유의 노이즈(스푸핑, 반사파, 센서 단절 등) 제거 타당성\n", + "\n", + "#### 미션 PASS 조건\n", + "\n", + "- 정량 기준: 데이터 정제 처리 속도 1,000건/초 이상, 전처리 오류율 0%\n", + "\n", + "\n", + "- 진행 기준: 정제 파이프라인 자동화 및 단위 테스트 케이스 5종 통과\n", + "\n", + "\n", + "- 검증 방법: pytest 기반 전처리 정합성 테스트 및 결측치 보정율 확인\n", + "\n", + "\n", + "#### 채점자 판정 메모\n", + "\n", + "> 단순 코드 실행 여부뿐만 아니라 출력된 수치와 근거 자료가 군 실무 요구사항에 부합하는지 면밀히 확인한다.\n", + "\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "\"\"\"장병 응답의 전각·원문자·공백·어미 등 표기 차이를 정제하고 선다형·단답형·순서형 답안을 파싱하며, 통신 상태에 따라 재전송 중복을 제거하고 미응답·형식 오류를 분리하는 전처리 모듈을 구현한다. 처리 속도와 단위 테스트 5종을 확인한다.\"\"\"\n", + "import difflib\n", + "import re\n", + "import time\n", + "import unicodedata\n", + "import numpy as np\n", + "import pandas as pd\n", + "import matplotlib.pyplot as plt\n", + "\n", + "# ---------------------------------------------------------------------------\n", + "# [교육용가상자료] 방공·드론 식별 퀴즈 문항 은행과 가상 응시자 응답 생성기\n", + "# 문항은 일반 원칙 수준의 가상 문항이며 실제 교범·교전규칙 원문이 아니다. 응시자는 가명(S001~)이다.\n", + "# ---------------------------------------------------------------------------\n", + "AREAS = [\"형상식별\", \"피아식별\", \"경보보고\", \"교전통제\", \"대드론일반\"]\n", + "ETYPES = [\"개념 혼동\", \"피아 오인\", \"절차 누락\"]\n", + "POINTS = {1: 2, 2: 3, 3: 5}\n", + "C, F, P = ETYPES\n", + "\n", + "\n", + "def _mc(qid, area, diff, stem, opts, ans, dist):\n", + " # [TODO : 학생 실습 2] 장병 응답 텍스트/선택지 정제 및 시험 답안 파싱 전처리 모듈 구현 관련 핵심 로직을 구현하세요.\n", + " # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n", + " # <<< 여기에 코드를 작성하세요 >>>\n", + " pass # 작성 후 pass를 제거하세요.\n", + "def _sa(qid, area, diff, stem, accept, wrong):\n", + " # [TODO : 학생 실습 2] 장병 응답 텍스트/선택지 정제 및 시험 답안 파싱 전처리 모듈 구현 관련 핵심 로직을 구현하세요.\n", + " # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n", + " # <<< 여기에 코드를 작성하세요 >>>\n", + " pass # 작성 후 pass를 제거하세요.\n", + "def _order(qid, area, diff, stem):\n", + " return {\"qid\": qid, \"type\": \"ORDER\", \"area\": area, \"diff\": diff, \"stem\": stem, \"answer\": \"ABCD\"}\n", + "\n", + "\n", + "BANK = [\n", + " _mc(\"Q01\", \"형상식별\", 1, \"꼬리날개 없이 삼각형 날개를 가진 소형 고정익 실루엣은?\", [\"쿼드콥터형\", \"삼각익 고정익형\", \"회전익 헬기형\", \"조류\"], 2, [C, C, F]),\n", + " _mc(\"Q02\", \"형상식별\", 1, \"회전날개 4개가 십자로 배치된 실루엣은?\", [\"고정익 드론형\", \"쿼드콥터형\", \"순항미사일형\", \"탄도미사일형\"], 2, [C, C, C]),\n", + " _mc(\"Q03\", \"형상식별\", 2, \"가늘고 긴 동체 중간에 작은 날개가 붙은 실루엣은?\", [\"순항미사일형\", \"탄도미사일형\", \"민간 경비행기형\", \"조류\"], 1, [C, F, F]),\n", + " _mc(\"Q04\", \"형상식별\", 3, \"원거리라 실루엣 식별이 어려울 때 먼저 할 행동은?\", [\"추정 유형으로 확정 보고\", \"관측 정보를 그대로 보고하고 추가 관측\", \"관측 중단\", \"민간기로 간주\"], 2, [P, P, F]),\n", + " _mc(\"Q05\", \"피아식별\", 2, \"식별 신호 응답이 없는 항공기에 대한 올바른 판단은?\", [\"즉시 적기로 확정\", \"미확인 항공기로 분류하고 추가 확인\", \"아군기로 간주\", \"무시\"], 2, [F, F, P]),\n", + " _mc(\"Q06\", \"피아식별\", 2, \"운항 정보와 일치하는 저속 비행체의 분류는?\", [\"적 드론\", \"민간기 가능성 높음, 추가 확인\", \"순항미사일\", \"아군 정찰기로 확정\"], 2, [F, C, F]),\n", + " _mc(\"Q07\", \"피아식별\", 3, \"아군 드론 운용 공지가 있는 공역에서 미확인 드론을 보면 우선 확인할 것은?\", [\"기상 정보\", \"아군 운용 계획과 식별 신호\", \"대응 준비 상태\", \"주변 지형\"], 2, [C, P, C]),\n", + " _mc(\"Q08\", \"피아식별\", 3, \"조류 무리와 소형 드론 군집 구분에 가장 도움이 되는 정보는?\", [\"크기만 비교\", \"궤적의 규칙성과 열 신호\", \"색상\", \"관측 시각\"], 2, [C, C, C]),\n", + " _mc(\"Q09\", \"경보보고\", 1, \"공중 위협 최초 보고에 포함하지 않아도 되는 것은?\", [\"관측 시각\", \"방향과 거리\", \"추정 유형\", \"관측자 개인 연락처\"], 4, [P, P, P]),\n", + " _mc(\"Q10\", \"경보보고\", 2, \"경보 전파 후 상황이 바뀌면?\", [\"최초 보고 유지\", \"변경 사항을 즉시 추가 보고\", \"상황 종료 후 일괄 보고\", \"개인 판단으로 경보 해제\"], 2, [P, P, P]),\n", + " _mc(\"Q11\", \"경보보고\", 2, \"보고 내용이 불확실할 때 올바른 표현은?\", [\"확정 표현으로 보고\", \"'추정'임을 밝히고 근거와 함께 보고\", \"보고 보류\", \"다른 관측자 보고를 복사\"], 2, [C, P, P]),\n", + " _mc(\"Q12\", \"경보보고\", 1, \"드론 탐지 후 보고 경로로 알맞은 것은?\", [\"인접 부대에만 전파\", \"지휘계통을 따라 보고\", \"개인 메신저로 공유\", \"보고 없이 대응\"], 2, [P, P, P]),\n", + " _mc(\"Q13\", \"교전통제\", 1, \"AI 탐지 결과만으로 대응을 결정할 수 있는가?\", [\"가능하다\", \"인간 지휘관의 승인이 필요하다\", \"신뢰도 90% 이상이면 가능\", \"야간에만 가능\"], 2, [C, C, C]),\n", + " _mc(\"Q14\", \"교전통제\", 2, \"민간 피해 가능성이 확인되면?\", [\"그대로 진행\", \"즉시 보고하고 지휘관 판단을 받는다\", \"조치 후 보고\", \"무시\"], 2, [P, P, F]),\n", + " _mc(\"Q15\", \"교전통제\", 3, \"식별이 불확실한 표적에 대한 원칙은?\", [\"위협으로 간주해 즉시 대응\", \"식별 확인 전까지 승인 절차를 따른다\", \"무조건 무시\", \"다수결로 결정\"], 2, [F, F, C]),\n", + " _mc(\"Q16\", \"교전통제\", 2, \"판단 근거를 기록해야 하는 이유는?\", [\"형식상 필요\", \"사후 검토와 책임 추적\", \"보고 생략\", \"기록 불필요\"], 2, [C, P, P]),\n", + " _mc(\"Q17\", \"대드론일반\", 1, \"대드론 체계의 일반적 처리 순서는?\", [\"대응-탐지-식별\", \"탐지-식별-보고·판단-대응\", \"식별-대응-탐지\", \"보고-탐지-대응\"], 2, [P, P, P]),\n", + " _mc(\"Q18\", \"대드론일반\", 3, \"레이더만으로 조류와 드론 구분이 어려운 이유는?\", [\"반사 신호 크기가 비슷할 수 있어서\", \"레이더는 야간에 못 써서\", \"드론에는 금속이 없어서\", \"조류는 탐지되지 않아서\"], 1, [C, C, C]),\n", + " _mc(\"Q19\", \"대드론일반\", 2, \"전자광학(EO) 센서의 약점이 커지는 조건은?\", [\"맑은 주간\", \"안개·야간\", \"저속 표적\", \"근거리\"], 2, [C, C, C]),\n", + " _mc(\"Q20\", \"대드론일반\", 2, \"경보가 반복해서 오경보로 판명되면?\", [\"경보 장치를 끈다\", \"사례를 기록·보고하고 기준 재검토 요청\", \"무시한다\", \"모두 실제 위협으로 간주\"], 2, [P, P, F]),\n", + " _sa(\"Q21\", \"피아식별\", 1, \"아군·적·민간을 구분하는 절차를 무엇이라 하는가?\", [\"피아식별\", \"IFF\"], {\"표적획득\": C, \"적기판정\": F, \"경계\": C}),\n", + " _sa(\"Q22\", \"형상식별\", 2, \"꼬리날개 없는 삼각형 날개 형태를 무엇이라 하는가?\", [\"삼각익\", \"델타익\", \"delta\"], {\"직선익\": C, \"후퇴익\": C, \"쿼드콥터\": C}),\n", + " _sa(\"Q23\", \"경보보고\", 2, \"불확실한 관측을 보고할 때 붙이는 표현은?\", [\"추정\"], {\"확정\": C, \"확인\": C, \"생략\": P}),\n", + " _sa(\"Q24\", \"교전통제\", 1, \"AI 탐지 결과에 대해 최종 결정을 내리는 주체는?\", [\"지휘관\"], {\"AI\": C, \"운용병\": P, \"레이더\": C}),\n", + " _sa(\"Q25\", \"대드론일반\", 1, \"조류를 드론으로 잘못 경보하는 것을 무엇이라 하는가?\", [\"오경보\", \"오탐\", \"false alarm\"], {\"미탐\": C, \"재경보\": C, \"적기\": F}),\n", + " _sa(\"Q26\", \"피아식별\", 2, \"운항 정보가 확인된 여객기는 어떻게 분류하는가?\", [\"민간기\", \"민항기\"], {\"적기\": F, \"미확인기\": P, \"아군기\": F}),\n", + " _sa(\"Q27\", \"형상식별\", 1, \"회전날개 4개를 가진 드론 형태는?\", [\"쿼드콥터\", \"quadcopter\"], {\"헥사콥터\": C, \"고정익\": C, \"헬기\": F}),\n", + " _sa(\"Q28\", \"경보보고\", 1, \"보고는 무엇을 따라 올라가는가?\", [\"지휘계통\"], {\"인접부대\": P, \"개인메신저\": P, \"통신망\": C}),\n", + " _order(\"Q29\", \"경보보고\", 2, \"최초 보고 순서: A 관측 시각, B 방향·거리, C 추정 유형, D 관측자 조치\"),\n", + " _order(\"Q30\", \"대드론일반\", 2, \"처리 순서: A 탐지, B 식별, C 보고·판단, D 대응\"),\n", + " _order(\"Q31\", \"교전통제\", 3, \"통제 순서: A 식별 확인, B 상급 승인 요청, C 승인 수령, D 조치 기록\"),\n", + " _order(\"Q32\", \"피아식별\", 3, \"확인 순서: A 운항정보 대조, B 식별 신호 확인, C 미확인 분류, D 보고\"),\n", + "]\n", + "QIDX = {q[\"qid\"]: q for q in BANK}\n", + "UNITS = [\"가상1중대\", \"가상2중대\", \"가상3중대\"]\n", + "_CIRC = \"①②③④\"\n", + "PARA = {\"Q21\": \"아군적군 구분\", \"Q22\": \"세모 날개\", \"Q23\": \"추정됨으로 표기\", \"Q24\": \"중대장(지휘관)\", \"Q25\": \"잘못된 경보\",\n", + " \"Q26\": \"민간 여객기\", \"Q27\": \"4발 드론\", \"Q28\": \"지휘 라인\"} # 뜻은 맞지만 인정 답안 목록에 없는 표현\n", + "\n", + "\n", + "def make_soldiers(n_per_unit, seed):\n", + " rng = np.random.default_rng(seed)\n", + " out = []\n", + " for u, unit in enumerate(UNITS):\n", + " base = [0.3, -0.1, 0.0][u]\n", + " for i in range(n_per_unit):\n", + " off = rng.normal(0, 0.5, len(AREAS)) + (np.array([0, 0, 0, -0.6, 0]) if u == 1 else 0)\n", + " out.append({\"sid\": f\"S{len(out) + 1:03d}\", \"unit\": unit, \"theta\": dict(zip(AREAS, base + rng.normal(0, 0.6) + off)),\n", + " \"tend\": dict(zip(ETYPES, rng.dirichlet([1.2, 1.2, 1.2])))})\n", + " return out\n", + "\n", + "\n", + "def p_correct(theta, diff):\n", + " return 1 / (1 + np.exp(-1.7 * (theta - (diff - 2) * 0.9 + 0.9)))\n", + "\n", + "\n", + "def _typo(s, rng):\n", + " if len(s) < 2:\n", + " return s\n", + " i = int(rng.integers(len(s) - 1))\n", + " return s[:i] + s[i + 1] + s[i] + s[i + 2:] if len(s) <= 3 or rng.random() < 0.5 else s[:i] + s[i + 1:]\n", + "\n", + "\n", + "def _fmt_mc(k, rng):\n", + " r = rng.random()\n", + " if r < 0.55:\n", + " return str(k)\n", + " return rng.choice([_CIRC[k - 1], f\"{k}번\", \"ABCD\"[k - 1], \"abcd\"[k - 1] + \")\", f\" {k} \", chr(0xFF10 + k), f\"{k}.\"])\n", + "\n", + "\n", + "def _fmt_sa(s, rng):\n", + " r = rng.random()\n", + " if r < 0.45:\n", + " return s\n", + " if r < 0.6:\n", + " return \" \".join(s) if len(s) <= 4 else s[:2] + \" \" + s[2:]\n", + " if r < 0.72:\n", + " return s + rng.choice([\"입니다\", \"이다\", \".\", \"임\"])\n", + " if r < 0.8:\n", + " return s.upper() if s.isascii() else \"\".join(chr(ord(ch) + 0xFEE0) if \"!\" <= ch <= \"~\" else ch for ch in s) + \" \"\n", + " return _typo(s, rng)\n", + "\n", + "\n", + "def answer(q, sol, rng):\n", + " \"\"\"한 문항 응답: (원본 응답 문자열, 의도한 정답 여부, 의도한 답, 오답 유형).\"\"\"\n", + " ok = rng.random() < p_correct(sol[\"theta\"][q[\"area\"]], q[\"diff\"])\n", + " if q[\"type\"] == \"MC\":\n", + " if ok:\n", + " k, et = q[\"answer\"], None\n", + " else:\n", + " ks = list(q[\"dist\"]); w = np.array([sol[\"tend\"][q[\"dist\"][k]] for k in ks])\n", + " k = int(rng.choice(ks, p=w / w.sum())); et = q[\"dist\"][k]\n", + " return _fmt_mc(k, rng), ok, str(k), et\n", + " if q[\"type\"] == \"SA\":\n", + " if ok and rng.random() < 0.06:\n", + " return PARA[q[\"qid\"]], ok, PARA[q[\"qid\"]], None\n", + " if ok:\n", + " s, et = str(rng.choice(q[\"accept\"])), None\n", + " else:\n", + " ws = list(q[\"wrong\"]); w = np.array([sol[\"tend\"][q[\"wrong\"][x]] for x in ws])\n", + " s = str(rng.choice(ws, p=w / w.sum())); et = q[\"wrong\"][s]\n", + " return _fmt_sa(s, rng), ok, s, et\n", + " if ok:\n", + " s = \"ABCD\"\n", + " elif rng.random() < 0.5:\n", + " s = \"ABCD\".replace(\"ABCD\"[rng.integers(1, 4)], \"\") # 단계 하나 빠뜨림\n", + " else:\n", + " i = int(rng.integers(0, 3)); s = list(\"ABCD\"); s[i], s[i + 1] = s[i + 1], s[i]; s = \"\".join(s) # 순서 뒤바뀜\n", + " raw = s if rng.random() < 0.6 else rng.choice([\"-\".join(s), \" \".join(s), s.lower(), \",\".join(s)])\n", + " return raw, ok, s, (None if ok else P)\n", + "\n", + "\n", + "def make_responses(n_per_unit=40, seed=7, blank=0.02):\n", + " \"\"\"응시자 x 문항 응답표(DataFrame)를 만든다. truth_* 열은 교관 기준 정답(평가용)이다.\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " rows = []\n", + " for sol in make_soldiers(n_per_unit, seed):\n", + " for q in BANK:\n", + " raw, ok, intended, et = answer(q, sol, rng)\n", + " if rng.random() < blank:\n", + " raw, ok, intended, et = \"\", False, \"\", None\n", + " rows.append({\"rid\": f\"R{len(rows):05d}\", \"sid\": sol[\"sid\"], \"unit\": sol[\"unit\"], \"qid\": q[\"qid\"], \"raw\": raw,\n", + " \"truth_correct\": ok, \"truth_answer\": intended, \"truth_etype\": et})\n", + " return pd.DataFrame(rows)\n", + "# ---------------------------------------------------------------------------\n", + "\n", + "# ---- 응답 정제·파싱·채점 공통 함수 ----\n", + "TAILS = (\"입니다\", \"이다\", \"임\")\n", + "\n", + "\n", + "def norm_text(s):\n", + " \"\"\"전각->반각(NFKC), 소문자, 공백·문장부호 제거, 끝맺음 어미 제거.\"\"\"\n", + " s = unicodedata.normalize(\"NFKC\", str(s)).strip().lower()\n", + " s = re.sub(r\"[\\s\\.\\,\\-\\(\\)·]\", \"\", s)\n", + " for t in TAILS:\n", + " if s.endswith(t) and len(s) > len(t) + 1:\n", + " s = s[:-len(t)]\n", + " return s\n", + "\n", + "\n", + "def parse(q, raw):\n", + " \"\"\"원본 응답 -> (정제값, 상태). 상태: 정상 / 미응답 / 형식오류\"\"\"\n", + " if raw is None or str(raw).strip() == \"\":\n", + " return \"\", \"미응답\"\n", + " s = unicodedata.normalize(\"NFKC\", str(raw)).strip()\n", + " if q[\"type\"] == \"MC\":\n", + " t = re.sub(r\"[\\s번\\.\\)]\", \"\", s).lower()\n", + " t = {\"a\": \"1\", \"b\": \"2\", \"c\": \"3\", \"d\": \"4\"}.get(t, t)\n", + " return (t, \"정상\") if t in (\"1\", \"2\", \"3\", \"4\") else (t, \"형식오류\")\n", + " if q[\"type\"] == \"ORDER\":\n", + " t = re.sub(r\"[^A-Za-z]\", \"\", s).upper()\n", + " return (t, \"정상\") if t and set(t) <= set(\"ABCD\") and len(set(t)) == len(t) else (t, \"형식오류\")\n", + " return norm_text(s), \"정상\"\n", + "\n", + "\n", + "def grade_exact(q, val):\n", + " if q[\"type\"] == \"MC\":\n", + " return val == str(q[\"answer\"])\n", + " if q[\"type\"] == \"ORDER\":\n", + " return val == q[\"answer\"]\n", + " return val in {norm_text(a) for a in q[\"accept\"]}\n", + "\n", + "\n", + "def grade_fuzzy(q, val, cut=0.75):\n", + " \"\"\"단답형은 오탈자(글자 순서 바뀜·한 글자 누락)를 허용(유사도 cut 이상)하되, 알려진 오답과 더 가까우면 오답으로 본다.\"\"\"\n", + " if grade_exact(q, val) or q[\"type\"] != \"SA\" or len(val) < 2:\n", + " return grade_exact(q, val)\n", + " best = max(1.0 if sorted(val) == sorted(norm_text(a)) else difflib.SequenceMatcher(None, val, norm_text(a)).ratio() for a in q[\"accept\"])\n", + " worst = max(difflib.SequenceMatcher(None, val, norm_text(w)).ratio() for w in q[\"wrong\"])\n", + " return best >= cut and best > worst\n", + "\n", + "\n", + "def score_of(q, correct):\n", + " return POINTS[q[\"diff\"]] if correct else 0\n", + "\n", + "DUP_WINDOW_S = {\"정상\": 5, \"지연\": 30} # 통신 상태별 재전송 판단 시간(동적 임계값)\n", + "\n", + "\n", + "def inject_noise(df, seed=1):\n", + " \"\"\"[교육용가상자료] 재전송 중복·형식 오류·제출 시각·통신 상태를 덧붙인다.\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " df = df.copy()\n", + " df[\"comm\"] = np.where(df[\"unit\"] == \"가상2중대\", \"지연\", \"정상\")\n", + " df[\"ts\"] = np.arange(len(df)) * 3.0\n", + " bad = rng.choice(len(df), 20, replace=False)\n", + " df.loc[bad, \"raw\"] = rng.choice([\"7\", \"모름\", \"??\"], 20)\n", + " df.loc[bad, \"truth_correct\"] = False\n", + " dup = df.sample(frac=0.03, random_state=seed).copy()\n", + " dup[\"ts\"] = dup[\"ts\"] + np.where(dup[\"comm\"] == \"지연\", 20.0, 2.0)\n", + " dup[\"rid\"] = dup[\"rid\"] + \"D\"\n", + " return pd.concat([df, dup]).sort_values(\"ts\").reset_index(drop=True)\n", + "\n", + "\n", + "def preprocess(df):\n", + " \"\"\"중복 제거 -> 파싱 -> 상태 분류. 미응답은 삭제하지 않고 상태로 남긴다(결측 보정).\"\"\"\n", + " df = df.sort_values(\"ts\")\n", + " win = df[\"comm\"].map(DUP_WINDOW_S)\n", + " prev_ts = df.groupby([\"sid\", \"qid\"])[\"ts\"].shift(1)\n", + " df = df.assign(dup=(df[\"ts\"] - prev_ts) <= win)\n", + " keep = df[~df.groupby([\"sid\", \"qid\"])[\"dup\"].shift(-1, fill_value=False)] # 재전송이면 마지막 제출만 유지\n", + " pv = [parse(QIDX[q], r) for q, r in zip(keep[\"qid\"], keep[\"raw\"])]\n", + " return keep.assign(val=[p[0] for p in pv], status=[p[1] for p in pv]).drop(columns=\"dup\"), len(df) - len(keep)\n", + "\n", + "\n", + "data = inject_noise(make_responses(40, seed=7))\n", + "preprocess(data.head(100))\n", + "t0 = time.perf_counter()\n", + "clean, n_dup = preprocess(data)\n", + "dt = time.perf_counter() - t0\n", + "print(f\"응답 {len(data):,}건 전처리 {dt * 1000:.0f} ms, {len(data) / dt:,.0f}건/초 (목표 1,000건/초 이상: {'달성' if len(data) / dt >= 1000 else '미달'})\")\n", + "print(f\"재전송 중복 제거 {n_dup}건, 남은 응답 {len(clean):,}건(응시자 120명 x 32문항 = 3,840건과 일치: {len(clean) == 3840})\")\n", + "print(\"상태 분포:\", clean[\"status\"].value_counts().to_dict())\n", + "err = clean[(clean[\"status\"] == \"정상\") & clean.apply(lambda r: QIDX[r.qid][\"type\"] == \"MC\" and r.val not in \"1234\", axis=1)]\n", + "print(f\"전처리 오류(정상인데 파싱 실패) {len(err)}건, 오류율 {len(err) / len(clean):.1%}\")\n", + "\n", + "# 단위 테스트 5종\n", + "Q = QIDX\n", + "tests = {\n", + " \"T1 전각 숫자 '3' -> 3\": parse(Q[\"Q01\"], \"3\")[0] == \"3\",\n", + " \"T2 원문자·영문·'번' 표기 통일\": [parse(Q[\"Q01\"], s)[0] for s in [\"③\", \"c)\", \"3번\", \" 3 \"]] == [\"3\"] * 4,\n", + " \"T3 단답 공백·어미 제거\": parse(Q[\"Q24\"], \"지 휘 관 입니다\")[0] == \"지휘관\" and parse(Q[\"Q21\"], \"IFF\")[0] == \"iff\",\n", + " \"T4 순서형 구분자 제거\": parse(Q[\"Q30\"], \"a-b-c-d\") == (\"ABCD\", \"정상\") and parse(Q[\"Q30\"], \"A,B,B,D\")[1] == \"형식오류\",\n", + " \"T5 미응답 보존(결측 보정율 100%)\": (clean[\"status\"] == \"미응답\").sum() == (data.drop_duplicates([\"sid\", \"qid\"])[\"raw\"] == \"\").sum(),\n", + "}\n", + "for k, v in tests.items():\n", + " print(f\"[{'PASS' if v else 'FAIL'}] {k}\")\n", + "print(f\"단위 테스트 {sum(tests.values())}/5 통과\")\n", + "\n", + "print(\"\\n[전처리 전후 예시]\")\n", + "ex = clean[(clean[\"raw\"] != clean[\"val\"]) & clean[\"qid\"].isin([\"Q05\", \"Q13\", \"Q21\", \"Q22\", \"Q24\", \"Q25\", \"Q29\", \"Q31\"])].groupby(\"qid\").head(1)\n", + "print(ex[[\"qid\", \"raw\", \"val\", \"status\"]].rename(columns={\"raw\": \"원본\", \"val\": \"정제값\", \"status\": \"상태\"}).to_string(index=False))\n", + "print(\"통신 지연 중대는 재전송 판단 시간을 30초로 넓혀 중복 제출을 놓치지 않는다.\")\n", + "\n", + "st = clean.groupby([\"unit\", \"status\"]).size().unstack(fill_value=0)\n", + "st.index = [\"Co.1\", \"Co.2 (delayed comm)\", \"Co.3\"]\n", + "st.columns = st.columns.map({\"정상\": \"valid\", \"미응답\": \"blank\", \"형식오류\": \"format error\"})\n", + "st.plot(kind=\"bar\", stacked=True, figsize=(7, 4))\n", + "plt.title(\"Response status after preprocessing (synthetic)\"); plt.ylabel(\"responses\"); plt.xticks(rotation=0)\n", + "plt.tight_layout(); plt.show()\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 02 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + "응답 3,955건 전처리 18 ms, 220,239건/초 (목표 1,000건/초 이상: 달성)\n", + "재전송 중복 제거 115건, 남은 응답 3,840건(응시자 120명 x 32문항 = 3,840건과 일치: True)\n", + "상태 분포: {'정상': 3753, '미응답': 71, '형식오류': 16}\n", + "전처리 오류(정상인데 파싱 실패) 0건, 오류율 0.0%\n", + "[PASS] T1 전각 숫자 '3' -> 3\n", + "[PASS] T2 원문자·영문·'번' 표기 통일\n", + "[PASS] T3 단답 공백·어미 제거\n", + "[PASS] T4 순서형 구분자 제거\n", + "[PASS] T5 미응답 보존(결측 보정율 100%)\n", + "단위 테스트 5/5 통과\n", + "\n", + "[전처리 전후 예시]\n", + "qid 원본 정제값 상태\n", + "Q05 b) 2 정상\n", + "Q13 3 3 정상\n", + "Q21 피아식별임 피아식별 정상\n", + "Q25 잘못된 경보 잘못된경보 정상\n", + "Q31 A C D ACD 정상\n", + "Q22 de lta delta 정상\n", + "Q24 지 휘 관 지휘관 정상\n", + "Q29 A,B,C,D ABCD 정상\n", + "통신 지연 중대는 재전송 판단 시간을 30초로 넓혀 중복 제출을 놓치지 않는다.\n", + "```\n", + "
\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 03. 선다형 및 단답형 방공 퀴즈 자동 채점 Baseline 로직 구현\n", + "**소요시간**: 약 2.5시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "선다형 및 단답형 방공 퀴즈 자동 채점 Baseline 로직을 구현한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **선다형·단답형·순서형 퀴즈를 자동 채점하는 Baseline을 구현하고 교관 기준 판정과 비교해 정확도를 확인한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **정확 일치 채점과 오탈자(글자 순서 바뀜·한 글자 누락)를 허용하는 유사도 채점을 구현하고, 유사도 기준값을 바꿔 가며 단답형 채점 정확도를 비교 수행하고 검증하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 5개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `src/models/trn02_baseline_model.py 및 기본 추론 결과 보고서` 형태의 결과물을 완성한다.\n", + "\n", + "### 🔍 평가 및 검증 기준 (PASS 조건)\n", + "#### 미션 판정\n", + "\n", + "**평가 구분:** 필수\n", + "\n", + "**핵심 평가영역:** 알고리즘의 논리적 흐름 및 군사적 판단 기준의 투명성\n", + "\n", + "#### 미션 PASS 조건\n", + "\n", + "- 정량 기준: 기본 데이터셋 대상 탐지/처리 정확도 75% 이상 달성\n", + "\n", + "\n", + "- 진행 기준: 단위 기능 동작 확인 및 기본 시나리오 실행 로그 생성\n", + "\n", + "\n", + "- 검증 방법: 기준 벤치마크 데이터셋 대상 추론 결과 오차율 비교\n", + "\n", + "\n", + "#### 채점자 판정 메모\n", + "\n", + "> 단순 코드 실행 여부뿐만 아니라 출력된 수치와 근거 자료가 군 실무 요구사항에 부합하는지 면밀히 확인한다.\n", + "\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "\"\"\"선다형·단답형·순서형 방공 퀴즈를 자동 채점하는 Baseline을 구현한다. 정확 일치 채점과 오탈자 허용 채점의 정확도(교관 기준 대비)·처리 지연을 비교하고, 응시자별 점수와 합격 여부, 실행 로그를 출력한다.\"\"\"\n", + "import difflib\n", + "import re\n", + "import time\n", + "import unicodedata\n", + "import numpy as np\n", + "import pandas as pd\n", + "import matplotlib.pyplot as plt\n", + "\n", + "# ---------------------------------------------------------------------------\n", + "# [교육용가상자료] 방공·드론 식별 퀴즈 문항 은행과 가상 응시자 응답 생성기\n", + "# 문항은 일반 원칙 수준의 가상 문항이며 실제 교범·교전규칙 원문이 아니다. 응시자는 가명(S001~)이다.\n", + "# ---------------------------------------------------------------------------\n", + "AREAS = [\"형상식별\", \"피아식별\", \"경보보고\", \"교전통제\", \"대드론일반\"]\n", + "ETYPES = [\"개념 혼동\", \"피아 오인\", \"절차 누락\"]\n", + "POINTS = {1: 2, 2: 3, 3: 5}\n", + "C, F, P = ETYPES\n", + "\n", + "\n", + "def _mc(qid, area, diff, stem, opts, ans, dist):\n", + " # [TODO : 학생 실습 3] 선다형 및 단답형 방공 퀴즈 자동 채점 Baseline 로직 구현 관련 핵심 로직을 구현하세요.\n", + " # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n", + " # <<< 여기에 코드를 작성하세요 >>>\n", + " pass # 작성 후 pass를 제거하세요.\n", + "def _sa(qid, area, diff, stem, accept, wrong):\n", + " # [TODO : 학생 실습 3] 선다형 및 단답형 방공 퀴즈 자동 채점 Baseline 로직 구현 관련 핵심 로직을 구현하세요.\n", + " # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n", + " # <<< 여기에 코드를 작성하세요 >>>\n", + " pass # 작성 후 pass를 제거하세요.\n", + "def _order(qid, area, diff, stem):\n", + " return {\"qid\": qid, \"type\": \"ORDER\", \"area\": area, \"diff\": diff, \"stem\": stem, \"answer\": \"ABCD\"}\n", + "\n", + "\n", + "BANK = [\n", + " _mc(\"Q01\", \"형상식별\", 1, \"꼬리날개 없이 삼각형 날개를 가진 소형 고정익 실루엣은?\", [\"쿼드콥터형\", \"삼각익 고정익형\", \"회전익 헬기형\", \"조류\"], 2, [C, C, F]),\n", + " _mc(\"Q02\", \"형상식별\", 1, \"회전날개 4개가 십자로 배치된 실루엣은?\", [\"고정익 드론형\", \"쿼드콥터형\", \"순항미사일형\", \"탄도미사일형\"], 2, [C, C, C]),\n", + " _mc(\"Q03\", \"형상식별\", 2, \"가늘고 긴 동체 중간에 작은 날개가 붙은 실루엣은?\", [\"순항미사일형\", \"탄도미사일형\", \"민간 경비행기형\", \"조류\"], 1, [C, F, F]),\n", + " _mc(\"Q04\", \"형상식별\", 3, \"원거리라 실루엣 식별이 어려울 때 먼저 할 행동은?\", [\"추정 유형으로 확정 보고\", \"관측 정보를 그대로 보고하고 추가 관측\", \"관측 중단\", \"민간기로 간주\"], 2, [P, P, F]),\n", + " _mc(\"Q05\", \"피아식별\", 2, \"식별 신호 응답이 없는 항공기에 대한 올바른 판단은?\", [\"즉시 적기로 확정\", \"미확인 항공기로 분류하고 추가 확인\", \"아군기로 간주\", \"무시\"], 2, [F, F, P]),\n", + " _mc(\"Q06\", \"피아식별\", 2, \"운항 정보와 일치하는 저속 비행체의 분류는?\", [\"적 드론\", \"민간기 가능성 높음, 추가 확인\", \"순항미사일\", \"아군 정찰기로 확정\"], 2, [F, C, F]),\n", + " _mc(\"Q07\", \"피아식별\", 3, \"아군 드론 운용 공지가 있는 공역에서 미확인 드론을 보면 우선 확인할 것은?\", [\"기상 정보\", \"아군 운용 계획과 식별 신호\", \"대응 준비 상태\", \"주변 지형\"], 2, [C, P, C]),\n", + " _mc(\"Q08\", \"피아식별\", 3, \"조류 무리와 소형 드론 군집 구분에 가장 도움이 되는 정보는?\", [\"크기만 비교\", \"궤적의 규칙성과 열 신호\", \"색상\", \"관측 시각\"], 2, [C, C, C]),\n", + " _mc(\"Q09\", \"경보보고\", 1, \"공중 위협 최초 보고에 포함하지 않아도 되는 것은?\", [\"관측 시각\", \"방향과 거리\", \"추정 유형\", \"관측자 개인 연락처\"], 4, [P, P, P]),\n", + " _mc(\"Q10\", \"경보보고\", 2, \"경보 전파 후 상황이 바뀌면?\", [\"최초 보고 유지\", \"변경 사항을 즉시 추가 보고\", \"상황 종료 후 일괄 보고\", \"개인 판단으로 경보 해제\"], 2, [P, P, P]),\n", + " _mc(\"Q11\", \"경보보고\", 2, \"보고 내용이 불확실할 때 올바른 표현은?\", [\"확정 표현으로 보고\", \"'추정'임을 밝히고 근거와 함께 보고\", \"보고 보류\", \"다른 관측자 보고를 복사\"], 2, [C, P, P]),\n", + " _mc(\"Q12\", \"경보보고\", 1, \"드론 탐지 후 보고 경로로 알맞은 것은?\", [\"인접 부대에만 전파\", \"지휘계통을 따라 보고\", \"개인 메신저로 공유\", \"보고 없이 대응\"], 2, [P, P, P]),\n", + " _mc(\"Q13\", \"교전통제\", 1, \"AI 탐지 결과만으로 대응을 결정할 수 있는가?\", [\"가능하다\", \"인간 지휘관의 승인이 필요하다\", \"신뢰도 90% 이상이면 가능\", \"야간에만 가능\"], 2, [C, C, C]),\n", + " _mc(\"Q14\", \"교전통제\", 2, \"민간 피해 가능성이 확인되면?\", [\"그대로 진행\", \"즉시 보고하고 지휘관 판단을 받는다\", \"조치 후 보고\", \"무시\"], 2, [P, P, F]),\n", + " _mc(\"Q15\", \"교전통제\", 3, \"식별이 불확실한 표적에 대한 원칙은?\", [\"위협으로 간주해 즉시 대응\", \"식별 확인 전까지 승인 절차를 따른다\", \"무조건 무시\", \"다수결로 결정\"], 2, [F, F, C]),\n", + " _mc(\"Q16\", \"교전통제\", 2, \"판단 근거를 기록해야 하는 이유는?\", [\"형식상 필요\", \"사후 검토와 책임 추적\", \"보고 생략\", \"기록 불필요\"], 2, [C, P, P]),\n", + " _mc(\"Q17\", \"대드론일반\", 1, \"대드론 체계의 일반적 처리 순서는?\", [\"대응-탐지-식별\", \"탐지-식별-보고·판단-대응\", \"식별-대응-탐지\", \"보고-탐지-대응\"], 2, [P, P, P]),\n", + " _mc(\"Q18\", \"대드론일반\", 3, \"레이더만으로 조류와 드론 구분이 어려운 이유는?\", [\"반사 신호 크기가 비슷할 수 있어서\", \"레이더는 야간에 못 써서\", \"드론에는 금속이 없어서\", \"조류는 탐지되지 않아서\"], 1, [C, C, C]),\n", + " _mc(\"Q19\", \"대드론일반\", 2, \"전자광학(EO) 센서의 약점이 커지는 조건은?\", [\"맑은 주간\", \"안개·야간\", \"저속 표적\", \"근거리\"], 2, [C, C, C]),\n", + " _mc(\"Q20\", \"대드론일반\", 2, \"경보가 반복해서 오경보로 판명되면?\", [\"경보 장치를 끈다\", \"사례를 기록·보고하고 기준 재검토 요청\", \"무시한다\", \"모두 실제 위협으로 간주\"], 2, [P, P, F]),\n", + " _sa(\"Q21\", \"피아식별\", 1, \"아군·적·민간을 구분하는 절차를 무엇이라 하는가?\", [\"피아식별\", \"IFF\"], {\"표적획득\": C, \"적기판정\": F, \"경계\": C}),\n", + " _sa(\"Q22\", \"형상식별\", 2, \"꼬리날개 없는 삼각형 날개 형태를 무엇이라 하는가?\", [\"삼각익\", \"델타익\", \"delta\"], {\"직선익\": C, \"후퇴익\": C, \"쿼드콥터\": C}),\n", + " _sa(\"Q23\", \"경보보고\", 2, \"불확실한 관측을 보고할 때 붙이는 표현은?\", [\"추정\"], {\"확정\": C, \"확인\": C, \"생략\": P}),\n", + " _sa(\"Q24\", \"교전통제\", 1, \"AI 탐지 결과에 대해 최종 결정을 내리는 주체는?\", [\"지휘관\"], {\"AI\": C, \"운용병\": P, \"레이더\": C}),\n", + " _sa(\"Q25\", \"대드론일반\", 1, \"조류를 드론으로 잘못 경보하는 것을 무엇이라 하는가?\", [\"오경보\", \"오탐\", \"false alarm\"], {\"미탐\": C, \"재경보\": C, \"적기\": F}),\n", + " _sa(\"Q26\", \"피아식별\", 2, \"운항 정보가 확인된 여객기는 어떻게 분류하는가?\", [\"민간기\", \"민항기\"], {\"적기\": F, \"미확인기\": P, \"아군기\": F}),\n", + " _sa(\"Q27\", \"형상식별\", 1, \"회전날개 4개를 가진 드론 형태는?\", [\"쿼드콥터\", \"quadcopter\"], {\"헥사콥터\": C, \"고정익\": C, \"헬기\": F}),\n", + " _sa(\"Q28\", \"경보보고\", 1, \"보고는 무엇을 따라 올라가는가?\", [\"지휘계통\"], {\"인접부대\": P, \"개인메신저\": P, \"통신망\": C}),\n", + " _order(\"Q29\", \"경보보고\", 2, \"최초 보고 순서: A 관측 시각, B 방향·거리, C 추정 유형, D 관측자 조치\"),\n", + " _order(\"Q30\", \"대드론일반\", 2, \"처리 순서: A 탐지, B 식별, C 보고·판단, D 대응\"),\n", + " _order(\"Q31\", \"교전통제\", 3, \"통제 순서: A 식별 확인, B 상급 승인 요청, C 승인 수령, D 조치 기록\"),\n", + " _order(\"Q32\", \"피아식별\", 3, \"확인 순서: A 운항정보 대조, B 식별 신호 확인, C 미확인 분류, D 보고\"),\n", + "]\n", + "QIDX = {q[\"qid\"]: q for q in BANK}\n", + "UNITS = [\"가상1중대\", \"가상2중대\", \"가상3중대\"]\n", + "_CIRC = \"①②③④\"\n", + "PARA = {\"Q21\": \"아군적군 구분\", \"Q22\": \"세모 날개\", \"Q23\": \"추정됨으로 표기\", \"Q24\": \"중대장(지휘관)\", \"Q25\": \"잘못된 경보\",\n", + " \"Q26\": \"민간 여객기\", \"Q27\": \"4발 드론\", \"Q28\": \"지휘 라인\"} # 뜻은 맞지만 인정 답안 목록에 없는 표현\n", + "\n", + "\n", + "def make_soldiers(n_per_unit, seed):\n", + " rng = np.random.default_rng(seed)\n", + " out = []\n", + " for u, unit in enumerate(UNITS):\n", + " base = [0.3, -0.1, 0.0][u]\n", + " for i in range(n_per_unit):\n", + " off = rng.normal(0, 0.5, len(AREAS)) + (np.array([0, 0, 0, -0.6, 0]) if u == 1 else 0)\n", + " out.append({\"sid\": f\"S{len(out) + 1:03d}\", \"unit\": unit, \"theta\": dict(zip(AREAS, base + rng.normal(0, 0.6) + off)),\n", + " \"tend\": dict(zip(ETYPES, rng.dirichlet([1.2, 1.2, 1.2])))})\n", + " return out\n", + "\n", + "\n", + "def p_correct(theta, diff):\n", + " return 1 / (1 + np.exp(-1.7 * (theta - (diff - 2) * 0.9 + 0.9)))\n", + "\n", + "\n", + "def _typo(s, rng):\n", + " if len(s) < 2:\n", + " return s\n", + " i = int(rng.integers(len(s) - 1))\n", + " return s[:i] + s[i + 1] + s[i] + s[i + 2:] if len(s) <= 3 or rng.random() < 0.5 else s[:i] + s[i + 1:]\n", + "\n", + "\n", + "def _fmt_mc(k, rng):\n", + " r = rng.random()\n", + " if r < 0.55:\n", + " return str(k)\n", + " return rng.choice([_CIRC[k - 1], f\"{k}번\", \"ABCD\"[k - 1], \"abcd\"[k - 1] + \")\", f\" {k} \", chr(0xFF10 + k), f\"{k}.\"])\n", + "\n", + "\n", + "def _fmt_sa(s, rng):\n", + " r = rng.random()\n", + " if r < 0.45:\n", + " return s\n", + " if r < 0.6:\n", + " return \" \".join(s) if len(s) <= 4 else s[:2] + \" \" + s[2:]\n", + " if r < 0.72:\n", + " return s + rng.choice([\"입니다\", \"이다\", \".\", \"임\"])\n", + " if r < 0.8:\n", + " return s.upper() if s.isascii() else \"\".join(chr(ord(ch) + 0xFEE0) if \"!\" <= ch <= \"~\" else ch for ch in s) + \" \"\n", + " return _typo(s, rng)\n", + "\n", + "\n", + "def answer(q, sol, rng):\n", + " \"\"\"한 문항 응답: (원본 응답 문자열, 의도한 정답 여부, 의도한 답, 오답 유형).\"\"\"\n", + " ok = rng.random() < p_correct(sol[\"theta\"][q[\"area\"]], q[\"diff\"])\n", + " if q[\"type\"] == \"MC\":\n", + " if ok:\n", + " k, et = q[\"answer\"], None\n", + " else:\n", + " ks = list(q[\"dist\"]); w = np.array([sol[\"tend\"][q[\"dist\"][k]] for k in ks])\n", + " k = int(rng.choice(ks, p=w / w.sum())); et = q[\"dist\"][k]\n", + " return _fmt_mc(k, rng), ok, str(k), et\n", + " if q[\"type\"] == \"SA\":\n", + " if ok and rng.random() < 0.06:\n", + " return PARA[q[\"qid\"]], ok, PARA[q[\"qid\"]], None\n", + " if ok:\n", + " s, et = str(rng.choice(q[\"accept\"])), None\n", + " else:\n", + " ws = list(q[\"wrong\"]); w = np.array([sol[\"tend\"][q[\"wrong\"][x]] for x in ws])\n", + " s = str(rng.choice(ws, p=w / w.sum())); et = q[\"wrong\"][s]\n", + " return _fmt_sa(s, rng), ok, s, et\n", + " if ok:\n", + " s = \"ABCD\"\n", + " elif rng.random() < 0.5:\n", + " s = \"ABCD\".replace(\"ABCD\"[rng.integers(1, 4)], \"\") # 단계 하나 빠뜨림\n", + " else:\n", + " i = int(rng.integers(0, 3)); s = list(\"ABCD\"); s[i], s[i + 1] = s[i + 1], s[i]; s = \"\".join(s) # 순서 뒤바뀜\n", + " raw = s if rng.random() < 0.6 else rng.choice([\"-\".join(s), \" \".join(s), s.lower(), \",\".join(s)])\n", + " return raw, ok, s, (None if ok else P)\n", + "\n", + "\n", + "def make_responses(n_per_unit=40, seed=7, blank=0.02):\n", + " \"\"\"응시자 x 문항 응답표(DataFrame)를 만든다. truth_* 열은 교관 기준 정답(평가용)이다.\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " rows = []\n", + " for sol in make_soldiers(n_per_unit, seed):\n", + " for q in BANK:\n", + " raw, ok, intended, et = answer(q, sol, rng)\n", + " if rng.random() < blank:\n", + " raw, ok, intended, et = \"\", False, \"\", None\n", + " rows.append({\"rid\": f\"R{len(rows):05d}\", \"sid\": sol[\"sid\"], \"unit\": sol[\"unit\"], \"qid\": q[\"qid\"], \"raw\": raw,\n", + " \"truth_correct\": ok, \"truth_answer\": intended, \"truth_etype\": et})\n", + " return pd.DataFrame(rows)\n", + "# ---------------------------------------------------------------------------\n", + "\n", + "# ---- 응답 정제·파싱·채점 공통 함수 ----\n", + "TAILS = (\"입니다\", \"이다\", \"임\")\n", + "\n", + "\n", + "def norm_text(s):\n", + " \"\"\"전각->반각(NFKC), 소문자, 공백·문장부호 제거, 끝맺음 어미 제거.\"\"\"\n", + " s = unicodedata.normalize(\"NFKC\", str(s)).strip().lower()\n", + " s = re.sub(r\"[\\s\\.\\,\\-\\(\\)·]\", \"\", s)\n", + " for t in TAILS:\n", + " if s.endswith(t) and len(s) > len(t) + 1:\n", + " s = s[:-len(t)]\n", + " return s\n", + "\n", + "\n", + "def parse(q, raw):\n", + " \"\"\"원본 응답 -> (정제값, 상태). 상태: 정상 / 미응답 / 형식오류\"\"\"\n", + " if raw is None or str(raw).strip() == \"\":\n", + " return \"\", \"미응답\"\n", + " s = unicodedata.normalize(\"NFKC\", str(raw)).strip()\n", + " if q[\"type\"] == \"MC\":\n", + " t = re.sub(r\"[\\s번\\.\\)]\", \"\", s).lower()\n", + " t = {\"a\": \"1\", \"b\": \"2\", \"c\": \"3\", \"d\": \"4\"}.get(t, t)\n", + " return (t, \"정상\") if t in (\"1\", \"2\", \"3\", \"4\") else (t, \"형식오류\")\n", + " if q[\"type\"] == \"ORDER\":\n", + " t = re.sub(r\"[^A-Za-z]\", \"\", s).upper()\n", + " return (t, \"정상\") if t and set(t) <= set(\"ABCD\") and len(set(t)) == len(t) else (t, \"형식오류\")\n", + " return norm_text(s), \"정상\"\n", + "\n", + "\n", + "def grade_exact(q, val):\n", + " if q[\"type\"] == \"MC\":\n", + " return val == str(q[\"answer\"])\n", + " if q[\"type\"] == \"ORDER\":\n", + " return val == q[\"answer\"]\n", + " return val in {norm_text(a) for a in q[\"accept\"]}\n", + "\n", + "\n", + "def grade_fuzzy(q, val, cut=0.75):\n", + " \"\"\"단답형은 오탈자(글자 순서 바뀜·한 글자 누락)를 허용(유사도 cut 이상)하되, 알려진 오답과 더 가까우면 오답으로 본다.\"\"\"\n", + " if grade_exact(q, val) or q[\"type\"] != \"SA\" or len(val) < 2:\n", + " return grade_exact(q, val)\n", + " best = max(1.0 if sorted(val) == sorted(norm_text(a)) else difflib.SequenceMatcher(None, val, norm_text(a)).ratio() for a in q[\"accept\"])\n", + " worst = max(difflib.SequenceMatcher(None, val, norm_text(w)).ratio() for w in q[\"wrong\"])\n", + " return best >= cut and best > worst\n", + "\n", + "\n", + "def score_of(q, correct):\n", + " return POINTS[q[\"diff\"]] if correct else 0\n", + "\n", + "PASS_RATE = 0.7\n", + "df = make_responses(40, seed=7)\n", + "df[\"type\"] = df[\"qid\"].map(lambda q: QIDX[q][\"type\"])\n", + "pv = [parse(QIDX[q], r) for q, r in zip(df[\"qid\"], df[\"raw\"])]\n", + "df[\"val\"] = [p[0] for p in pv]\n", + "\n", + "t0 = time.perf_counter()\n", + "df[\"exact\"] = [grade_exact(QIDX[q], v) for q, v in zip(df[\"qid\"], df[\"val\"])]\n", + "t1 = time.perf_counter()\n", + "df[\"fuzzy\"] = [grade_fuzzy(QIDX[q], v) for q, v in zip(df[\"qid\"], df[\"val\"])]\n", + "t2 = time.perf_counter()\n", + "\n", + "rows = []\n", + "for name, col, t in [(\"정확 일치\", \"exact\", t1 - t0), (\"오탈자 허용\", \"fuzzy\", t2 - t1)]:\n", + " rows.append({\"채점 방식\": name, \"채점 정확도\": (df[col] == df[\"truth_correct\"]).mean(),\n", + " **{f\"{k} 정확도\": (g[col] == g[\"truth_correct\"]).mean() for k, g in df.groupby(\"type\")},\n", + " \"건당 ms\": t / len(df) * 1000})\n", + "res = pd.DataFrame(rows)\n", + "print(f\"[교육용가상자료] 응시자 120명 x 32문항 = {len(df):,}건 채점 (교관 기준 정답과 비교)\")\n", + "print(res.round(4).to_string(index=False))\n", + "acc = res.loc[1, \"채점 정확도\"]\n", + "print(f\"\\n목표(채점 정확도 75% 이상): {'달성' if acc >= 0.75 else '미달'} ({acc:.1%}), 건당 처리 {res.loc[1, '건당 ms']:.3f} ms\")\n", + "\n", + "print(\"\\n[정확 일치 채점이 틀린 사례(오탈자·띄어쓰기)]\")\n", + "miss = df[(df[\"exact\"] != df[\"truth_correct\"])].groupby(\"qid\").head(1).head(5)\n", + "print(miss[[\"qid\", \"raw\", \"val\", \"truth_correct\", \"exact\", \"fuzzy\"]].rename(columns={\"truth_correct\": \"교관 판정\", \"exact\": \"정확일치\", \"fuzzy\": \"오탈자허용\"}).to_string(index=False))\n", + "\n", + "print(\"오탈자 허용 채점도 놓치는 '뜻은 맞지만 인정 답안 목록에 없는 표현'은 이의 제기·교관 검토(미션 7) 대상으로 넘긴다.\")\n", + "print(\"\\n[휴리스틱 1차 튜닝: 유사도 기준값]\")\n", + "sa = df[df[\"type\"] == \"SA\"]\n", + "for cut in [0.6, 0.7, 0.75, 0.8, 0.9]:\n", + " a = np.mean([grade_fuzzy(QIDX[q], v, cut) == t for q, v, t in zip(sa[\"qid\"], sa[\"val\"], sa[\"truth_correct\"])])\n", + " print(f\"기준 {cut}: 단답형 채점 정확도 {a:.3f}\")\n", + "\n", + "df[\"score\"] = [score_of(QIDX[q], c) for q, c in zip(df[\"qid\"], df[\"fuzzy\"])]\n", + "full = sum(POINTS[q[\"diff\"]] for q in BANK)\n", + "sc = df.groupby([\"sid\", \"unit\"])[\"score\"].sum().reset_index()\n", + "sc[\"합격\"] = sc[\"score\"] >= full * PASS_RATE\n", + "print(f\"\\n만점 {full}점, 평균 {sc['score'].mean():.1f}점, 합격률 {sc['합격'].mean():.1%}\")\n", + "print(sc.groupby(\"unit\").agg(평균점수=(\"score\", \"mean\"), 합격률=(\"합격\", \"mean\")).round(2).to_string())\n", + "print(f\"실행 로그: 문항 {len(BANK)}개, 응답 {len(df):,}건, 미응답 {(df['raw'] == '').sum()}건, 채점 예외 0건\")\n", + "\n", + "plt.figure(figsize=(7, 4))\n", + "plt.hist(sc[\"score\"], bins=15, color=\"tab:blue\"); plt.axvline(full * PASS_RATE, color=\"red\", ls=\"--\", label=\"pass line (70%)\")\n", + "plt.xlabel(\"total score\"); plt.ylabel(\"soldiers\"); plt.legend(); plt.title(\"Auto-graded score distribution (synthetic)\"); plt.show()\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 03 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + "[교육용가상자료] 응시자 120명 x 32문항 = 3,840건 채점 (교관 기준 정답과 비교)\n", + " 채점 방식 채점 정확도 MC 정확도 ORDER 정확도 SA 정확도 건당 ms\n", + " 정확 일치 0.9516 1.0 1.0 0.8062 0.0017\n", + "오탈자 허용 0.9901 1.0 1.0 0.9604 0.0070\n", + "\n", + "목표(채점 정확도 75% 이상): 달성 (99.0%), 건당 처리 0.007 ms\n", + "\n", + "[정확 일치 채점이 틀린 사례(오탈자·띄어쓰기)]\n", + "qid raw val 교관 판정 정확일치 오탈자허용\n", + "Q25 잘못된 경보 잘못된경보 True False False\n", + "Q21 아군적군 구분 아군적군구분 True False False\n", + "Q28 지휘통 지휘통 True False True\n", + "Q22 타델익 타델익 True False True\n", + "Q26 민기간 민기간 True False True\n", + "오탈자 허용 채점도 놓치는 '뜻은 맞지만 인정 답안 목록에 없는 표현'은 이의 제기·교관 검토(미션 7) 대상으로 넘긴다.\n", + "\n", + "[휴리스틱 1차 튜닝: 유사도 기준값]\n", + "기준 0.6: 단답형 채점 정확도 0.965\n", + "기준 0.7: 단답형 채점 정확도 0.960\n", + "기준 0.75: 단답형 채점 정확도 0.960\n", + "기준 0.8: 단답형 채점 정확도 0.956\n", + "기준 0.9: 단답형 채점 정확도 0.935\n", + "\n", + "만점 99점, 평균 67.4점, 합격률 50.8%\n", + " 평균점수 합격률\n", + "unit \n", + "가상1중대 75.40 0.68\n", + "가상2중대 58.55 0.28\n", + "가상3중대 68.18 0.57\n", + "실행 로그: 문항 32개, 응답 3,840건, 미응답 72건, 채점 예외 0건\n", + "```\n", + "
\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 04. 오답 유형(개념 혼동, 피아 오인, 절차 누락) 자동 분류 및 패턴 분석 엔진 고도화\n", + "**소요시간**: 약 3.0시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "오답 유형(개념 혼동, 피아 오인, 절차 누락) 자동 분류 엔진을 고도화한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **오답을 개념 혼동·피아 오인·절차 누락 유형으로 자동 분류하는 엔진을 고도화하고 개인·중대별 오답 패턴을 분석한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **선다형은 오답 보기별 유형 매핑, 순서형은 단계 누락·순서 뒤바뀜 규칙, 단답형은 오답 목록 대조와 오탈자 근사 일치에 문자 n-gram 분류 모델을 더 수행하고 검증하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `src/models/trn02_advanced_detector.py 및 예외 탐지 테스트 결과` 형태의 결과물을 완성한다.\n", + "\n", + "### 🔍 평가 및 검증 기준 (PASS 조건)\n", + "#### 미션 판정\n", + "\n", + "**평가 구분:** 필수\n", + "\n", + "**핵심 평가영역:** 기만/교란 공격에 대한 방어 로직의 현실성 및 신뢰성\n", + "\n", + "#### 미션 PASS 조건\n", + "\n", + "- 정량 기준: 복합 위협 탐지 재현율(Recall) 85% 이상, 처리 지연 200ms 이하\n", + "\n", + "\n", + "- 진행 기준: 복합 위협 시나리오 10종에 대한 탐지 테스트 통과\n", + "\n", + "\n", + "- 검증 방법: 합성 위협 데이터셋 주입 시 이상치 탐지 정확도 검증\n", + "\n", + "\n", + "#### 채점자 판정 메모\n", + "\n", + "> 단순 코드 실행 여부뿐만 아니라 출력된 수치와 근거 자료가 군 실무 요구사항에 부합하는지 면밀히 확인한다.\n", + "\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "\"\"\"오답을 개념 혼동·피아 오인·절차 누락 유형으로 자동 분류하는 엔진을 고도화한다. 보기 매핑·순서 규칙·오답 목록 대조에 문자 n-gram 분류 모델을 더해, 복합 오답 시나리오 10종에서 유형별 재현율과 처리 지연을 검증하고 개인·중대별 오답 패턴을 분석한다.\"\"\"\n", + "import difflib\n", + "import re\n", + "import time\n", + "import unicodedata\n", + "import numpy as np\n", + "import pandas as pd\n", + "import matplotlib.pyplot as plt\n", + "from sklearn.feature_extraction.text import TfidfVectorizer\n", + "from sklearn.linear_model import LogisticRegression\n", + "from sklearn.pipeline import make_pipeline\n", + "\n", + "# ---------------------------------------------------------------------------\n", + "# [교육용가상자료] 방공·드론 식별 퀴즈 문항 은행과 가상 응시자 응답 생성기\n", + "# 문항은 일반 원칙 수준의 가상 문항이며 실제 교범·교전규칙 원문이 아니다. 응시자는 가명(S001~)이다.\n", + "# ---------------------------------------------------------------------------\n", + "AREAS = [\"형상식별\", \"피아식별\", \"경보보고\", \"교전통제\", \"대드론일반\"]\n", + "ETYPES = [\"개념 혼동\", \"피아 오인\", \"절차 누락\"]\n", + "POINTS = {1: 2, 2: 3, 3: 5}\n", + "C, F, P = ETYPES\n", + "\n", + "\n", + "def _mc(qid, area, diff, stem, opts, ans, dist):\n", + " # [TODO : 학생 실습 4] 오답 유형(개념 혼동, 피아 오인, 절차 누락) 자동 분류 및 패턴 분석 엔진 고도화 관련 핵심 로직을 구현하세요.\n", + " # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n", + " # <<< 여기에 코드를 작성하세요 >>>\n", + " pass # 작성 후 pass를 제거하세요.\n", + "def _sa(qid, area, diff, stem, accept, wrong):\n", + " # [TODO : 학생 실습 4] 오답 유형(개념 혼동, 피아 오인, 절차 누락) 자동 분류 및 패턴 분석 엔진 고도화 관련 핵심 로직을 구현하세요.\n", + " # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n", + " # <<< 여기에 코드를 작성하세요 >>>\n", + " pass # 작성 후 pass를 제거하세요.\n", + "def _order(qid, area, diff, stem):\n", + " return {\"qid\": qid, \"type\": \"ORDER\", \"area\": area, \"diff\": diff, \"stem\": stem, \"answer\": \"ABCD\"}\n", + "\n", + "\n", + "BANK = [\n", + " _mc(\"Q01\", \"형상식별\", 1, \"꼬리날개 없이 삼각형 날개를 가진 소형 고정익 실루엣은?\", [\"쿼드콥터형\", \"삼각익 고정익형\", \"회전익 헬기형\", \"조류\"], 2, [C, C, F]),\n", + " _mc(\"Q02\", \"형상식별\", 1, \"회전날개 4개가 십자로 배치된 실루엣은?\", [\"고정익 드론형\", \"쿼드콥터형\", \"순항미사일형\", \"탄도미사일형\"], 2, [C, C, C]),\n", + " _mc(\"Q03\", \"형상식별\", 2, \"가늘고 긴 동체 중간에 작은 날개가 붙은 실루엣은?\", [\"순항미사일형\", \"탄도미사일형\", \"민간 경비행기형\", \"조류\"], 1, [C, F, F]),\n", + " _mc(\"Q04\", \"형상식별\", 3, \"원거리라 실루엣 식별이 어려울 때 먼저 할 행동은?\", [\"추정 유형으로 확정 보고\", \"관측 정보를 그대로 보고하고 추가 관측\", \"관측 중단\", \"민간기로 간주\"], 2, [P, P, F]),\n", + " _mc(\"Q05\", \"피아식별\", 2, \"식별 신호 응답이 없는 항공기에 대한 올바른 판단은?\", [\"즉시 적기로 확정\", \"미확인 항공기로 분류하고 추가 확인\", \"아군기로 간주\", \"무시\"], 2, [F, F, P]),\n", + " _mc(\"Q06\", \"피아식별\", 2, \"운항 정보와 일치하는 저속 비행체의 분류는?\", [\"적 드론\", \"민간기 가능성 높음, 추가 확인\", \"순항미사일\", \"아군 정찰기로 확정\"], 2, [F, C, F]),\n", + " _mc(\"Q07\", \"피아식별\", 3, \"아군 드론 운용 공지가 있는 공역에서 미확인 드론을 보면 우선 확인할 것은?\", [\"기상 정보\", \"아군 운용 계획과 식별 신호\", \"대응 준비 상태\", \"주변 지형\"], 2, [C, P, C]),\n", + " _mc(\"Q08\", \"피아식별\", 3, \"조류 무리와 소형 드론 군집 구분에 가장 도움이 되는 정보는?\", [\"크기만 비교\", \"궤적의 규칙성과 열 신호\", \"색상\", \"관측 시각\"], 2, [C, C, C]),\n", + " _mc(\"Q09\", \"경보보고\", 1, \"공중 위협 최초 보고에 포함하지 않아도 되는 것은?\", [\"관측 시각\", \"방향과 거리\", \"추정 유형\", \"관측자 개인 연락처\"], 4, [P, P, P]),\n", + " _mc(\"Q10\", \"경보보고\", 2, \"경보 전파 후 상황이 바뀌면?\", [\"최초 보고 유지\", \"변경 사항을 즉시 추가 보고\", \"상황 종료 후 일괄 보고\", \"개인 판단으로 경보 해제\"], 2, [P, P, P]),\n", + " _mc(\"Q11\", \"경보보고\", 2, \"보고 내용이 불확실할 때 올바른 표현은?\", [\"확정 표현으로 보고\", \"'추정'임을 밝히고 근거와 함께 보고\", \"보고 보류\", \"다른 관측자 보고를 복사\"], 2, [C, P, P]),\n", + " _mc(\"Q12\", \"경보보고\", 1, \"드론 탐지 후 보고 경로로 알맞은 것은?\", [\"인접 부대에만 전파\", \"지휘계통을 따라 보고\", \"개인 메신저로 공유\", \"보고 없이 대응\"], 2, [P, P, P]),\n", + " _mc(\"Q13\", \"교전통제\", 1, \"AI 탐지 결과만으로 대응을 결정할 수 있는가?\", [\"가능하다\", \"인간 지휘관의 승인이 필요하다\", \"신뢰도 90% 이상이면 가능\", \"야간에만 가능\"], 2, [C, C, C]),\n", + " _mc(\"Q14\", \"교전통제\", 2, \"민간 피해 가능성이 확인되면?\", [\"그대로 진행\", \"즉시 보고하고 지휘관 판단을 받는다\", \"조치 후 보고\", \"무시\"], 2, [P, P, F]),\n", + " _mc(\"Q15\", \"교전통제\", 3, \"식별이 불확실한 표적에 대한 원칙은?\", [\"위협으로 간주해 즉시 대응\", \"식별 확인 전까지 승인 절차를 따른다\", \"무조건 무시\", \"다수결로 결정\"], 2, [F, F, C]),\n", + " _mc(\"Q16\", \"교전통제\", 2, \"판단 근거를 기록해야 하는 이유는?\", [\"형식상 필요\", \"사후 검토와 책임 추적\", \"보고 생략\", \"기록 불필요\"], 2, [C, P, P]),\n", + " _mc(\"Q17\", \"대드론일반\", 1, \"대드론 체계의 일반적 처리 순서는?\", [\"대응-탐지-식별\", \"탐지-식별-보고·판단-대응\", \"식별-대응-탐지\", \"보고-탐지-대응\"], 2, [P, P, P]),\n", + " _mc(\"Q18\", \"대드론일반\", 3, \"레이더만으로 조류와 드론 구분이 어려운 이유는?\", [\"반사 신호 크기가 비슷할 수 있어서\", \"레이더는 야간에 못 써서\", \"드론에는 금속이 없어서\", \"조류는 탐지되지 않아서\"], 1, [C, C, C]),\n", + " _mc(\"Q19\", \"대드론일반\", 2, \"전자광학(EO) 센서의 약점이 커지는 조건은?\", [\"맑은 주간\", \"안개·야간\", \"저속 표적\", \"근거리\"], 2, [C, C, C]),\n", + " _mc(\"Q20\", \"대드론일반\", 2, \"경보가 반복해서 오경보로 판명되면?\", [\"경보 장치를 끈다\", \"사례를 기록·보고하고 기준 재검토 요청\", \"무시한다\", \"모두 실제 위협으로 간주\"], 2, [P, P, F]),\n", + " _sa(\"Q21\", \"피아식별\", 1, \"아군·적·민간을 구분하는 절차를 무엇이라 하는가?\", [\"피아식별\", \"IFF\"], {\"표적획득\": C, \"적기판정\": F, \"경계\": C}),\n", + " _sa(\"Q22\", \"형상식별\", 2, \"꼬리날개 없는 삼각형 날개 형태를 무엇이라 하는가?\", [\"삼각익\", \"델타익\", \"delta\"], {\"직선익\": C, \"후퇴익\": C, \"쿼드콥터\": C}),\n", + " _sa(\"Q23\", \"경보보고\", 2, \"불확실한 관측을 보고할 때 붙이는 표현은?\", [\"추정\"], {\"확정\": C, \"확인\": C, \"생략\": P}),\n", + " _sa(\"Q24\", \"교전통제\", 1, \"AI 탐지 결과에 대해 최종 결정을 내리는 주체는?\", [\"지휘관\"], {\"AI\": C, \"운용병\": P, \"레이더\": C}),\n", + " _sa(\"Q25\", \"대드론일반\", 1, \"조류를 드론으로 잘못 경보하는 것을 무엇이라 하는가?\", [\"오경보\", \"오탐\", \"false alarm\"], {\"미탐\": C, \"재경보\": C, \"적기\": F}),\n", + " _sa(\"Q26\", \"피아식별\", 2, \"운항 정보가 확인된 여객기는 어떻게 분류하는가?\", [\"민간기\", \"민항기\"], {\"적기\": F, \"미확인기\": P, \"아군기\": F}),\n", + " _sa(\"Q27\", \"형상식별\", 1, \"회전날개 4개를 가진 드론 형태는?\", [\"쿼드콥터\", \"quadcopter\"], {\"헥사콥터\": C, \"고정익\": C, \"헬기\": F}),\n", + " _sa(\"Q28\", \"경보보고\", 1, \"보고는 무엇을 따라 올라가는가?\", [\"지휘계통\"], {\"인접부대\": P, \"개인메신저\": P, \"통신망\": C}),\n", + " _order(\"Q29\", \"경보보고\", 2, \"최초 보고 순서: A 관측 시각, B 방향·거리, C 추정 유형, D 관측자 조치\"),\n", + " _order(\"Q30\", \"대드론일반\", 2, \"처리 순서: A 탐지, B 식별, C 보고·판단, D 대응\"),\n", + " _order(\"Q31\", \"교전통제\", 3, \"통제 순서: A 식별 확인, B 상급 승인 요청, C 승인 수령, D 조치 기록\"),\n", + " _order(\"Q32\", \"피아식별\", 3, \"확인 순서: A 운항정보 대조, B 식별 신호 확인, C 미확인 분류, D 보고\"),\n", + "]\n", + "QIDX = {q[\"qid\"]: q for q in BANK}\n", + "UNITS = [\"가상1중대\", \"가상2중대\", \"가상3중대\"]\n", + "_CIRC = \"①②③④\"\n", + "PARA = {\"Q21\": \"아군적군 구분\", \"Q22\": \"세모 날개\", \"Q23\": \"추정됨으로 표기\", \"Q24\": \"중대장(지휘관)\", \"Q25\": \"잘못된 경보\",\n", + " \"Q26\": \"민간 여객기\", \"Q27\": \"4발 드론\", \"Q28\": \"지휘 라인\"} # 뜻은 맞지만 인정 답안 목록에 없는 표현\n", + "\n", + "\n", + "def make_soldiers(n_per_unit, seed):\n", + " rng = np.random.default_rng(seed)\n", + " out = []\n", + " for u, unit in enumerate(UNITS):\n", + " base = [0.3, -0.1, 0.0][u]\n", + " for i in range(n_per_unit):\n", + " off = rng.normal(0, 0.5, len(AREAS)) + (np.array([0, 0, 0, -0.6, 0]) if u == 1 else 0)\n", + " out.append({\"sid\": f\"S{len(out) + 1:03d}\", \"unit\": unit, \"theta\": dict(zip(AREAS, base + rng.normal(0, 0.6) + off)),\n", + " \"tend\": dict(zip(ETYPES, rng.dirichlet([1.2, 1.2, 1.2])))})\n", + " return out\n", + "\n", + "\n", + "def p_correct(theta, diff):\n", + " return 1 / (1 + np.exp(-1.7 * (theta - (diff - 2) * 0.9 + 0.9)))\n", + "\n", + "\n", + "def _typo(s, rng):\n", + " if len(s) < 2:\n", + " return s\n", + " i = int(rng.integers(len(s) - 1))\n", + " return s[:i] + s[i + 1] + s[i] + s[i + 2:] if len(s) <= 3 or rng.random() < 0.5 else s[:i] + s[i + 1:]\n", + "\n", + "\n", + "def _fmt_mc(k, rng):\n", + " r = rng.random()\n", + " if r < 0.55:\n", + " return str(k)\n", + " return rng.choice([_CIRC[k - 1], f\"{k}번\", \"ABCD\"[k - 1], \"abcd\"[k - 1] + \")\", f\" {k} \", chr(0xFF10 + k), f\"{k}.\"])\n", + "\n", + "\n", + "def _fmt_sa(s, rng):\n", + " r = rng.random()\n", + " if r < 0.45:\n", + " return s\n", + " if r < 0.6:\n", + " return \" \".join(s) if len(s) <= 4 else s[:2] + \" \" + s[2:]\n", + " if r < 0.72:\n", + " return s + rng.choice([\"입니다\", \"이다\", \".\", \"임\"])\n", + " if r < 0.8:\n", + " return s.upper() if s.isascii() else \"\".join(chr(ord(ch) + 0xFEE0) if \"!\" <= ch <= \"~\" else ch for ch in s) + \" \"\n", + " return _typo(s, rng)\n", + "\n", + "\n", + "def answer(q, sol, rng):\n", + " \"\"\"한 문항 응답: (원본 응답 문자열, 의도한 정답 여부, 의도한 답, 오답 유형).\"\"\"\n", + " ok = rng.random() < p_correct(sol[\"theta\"][q[\"area\"]], q[\"diff\"])\n", + " if q[\"type\"] == \"MC\":\n", + " if ok:\n", + " k, et = q[\"answer\"], None\n", + " else:\n", + " ks = list(q[\"dist\"]); w = np.array([sol[\"tend\"][q[\"dist\"][k]] for k in ks])\n", + " k = int(rng.choice(ks, p=w / w.sum())); et = q[\"dist\"][k]\n", + " return _fmt_mc(k, rng), ok, str(k), et\n", + " if q[\"type\"] == \"SA\":\n", + " if ok and rng.random() < 0.06:\n", + " return PARA[q[\"qid\"]], ok, PARA[q[\"qid\"]], None\n", + " if ok:\n", + " s, et = str(rng.choice(q[\"accept\"])), None\n", + " else:\n", + " ws = list(q[\"wrong\"]); w = np.array([sol[\"tend\"][q[\"wrong\"][x]] for x in ws])\n", + " s = str(rng.choice(ws, p=w / w.sum())); et = q[\"wrong\"][s]\n", + " return _fmt_sa(s, rng), ok, s, et\n", + " if ok:\n", + " s = \"ABCD\"\n", + " elif rng.random() < 0.5:\n", + " s = \"ABCD\".replace(\"ABCD\"[rng.integers(1, 4)], \"\") # 단계 하나 빠뜨림\n", + " else:\n", + " i = int(rng.integers(0, 3)); s = list(\"ABCD\"); s[i], s[i + 1] = s[i + 1], s[i]; s = \"\".join(s) # 순서 뒤바뀜\n", + " raw = s if rng.random() < 0.6 else rng.choice([\"-\".join(s), \" \".join(s), s.lower(), \",\".join(s)])\n", + " return raw, ok, s, (None if ok else P)\n", + "\n", + "\n", + "def make_responses(n_per_unit=40, seed=7, blank=0.02):\n", + " \"\"\"응시자 x 문항 응답표(DataFrame)를 만든다. truth_* 열은 교관 기준 정답(평가용)이다.\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " rows = []\n", + " for sol in make_soldiers(n_per_unit, seed):\n", + " for q in BANK:\n", + " raw, ok, intended, et = answer(q, sol, rng)\n", + " if rng.random() < blank:\n", + " raw, ok, intended, et = \"\", False, \"\", None\n", + " rows.append({\"rid\": f\"R{len(rows):05d}\", \"sid\": sol[\"sid\"], \"unit\": sol[\"unit\"], \"qid\": q[\"qid\"], \"raw\": raw,\n", + " \"truth_correct\": ok, \"truth_answer\": intended, \"truth_etype\": et})\n", + " return pd.DataFrame(rows)\n", + "# ---------------------------------------------------------------------------\n", + "\n", + "# ---- 응답 정제·파싱·채점 공통 함수 ----\n", + "TAILS = (\"입니다\", \"이다\", \"임\")\n", + "\n", + "\n", + "def norm_text(s):\n", + " \"\"\"전각->반각(NFKC), 소문자, 공백·문장부호 제거, 끝맺음 어미 제거.\"\"\"\n", + " s = unicodedata.normalize(\"NFKC\", str(s)).strip().lower()\n", + " s = re.sub(r\"[\\s\\.\\,\\-\\(\\)·]\", \"\", s)\n", + " for t in TAILS:\n", + " if s.endswith(t) and len(s) > len(t) + 1:\n", + " s = s[:-len(t)]\n", + " return s\n", + "\n", + "\n", + "def parse(q, raw):\n", + " \"\"\"원본 응답 -> (정제값, 상태). 상태: 정상 / 미응답 / 형식오류\"\"\"\n", + " if raw is None or str(raw).strip() == \"\":\n", + " return \"\", \"미응답\"\n", + " s = unicodedata.normalize(\"NFKC\", str(raw)).strip()\n", + " if q[\"type\"] == \"MC\":\n", + " t = re.sub(r\"[\\s번\\.\\)]\", \"\", s).lower()\n", + " t = {\"a\": \"1\", \"b\": \"2\", \"c\": \"3\", \"d\": \"4\"}.get(t, t)\n", + " return (t, \"정상\") if t in (\"1\", \"2\", \"3\", \"4\") else (t, \"형식오류\")\n", + " if q[\"type\"] == \"ORDER\":\n", + " t = re.sub(r\"[^A-Za-z]\", \"\", s).upper()\n", + " return (t, \"정상\") if t and set(t) <= set(\"ABCD\") and len(set(t)) == len(t) else (t, \"형식오류\")\n", + " return norm_text(s), \"정상\"\n", + "\n", + "\n", + "def grade_exact(q, val):\n", + " if q[\"type\"] == \"MC\":\n", + " return val == str(q[\"answer\"])\n", + " if q[\"type\"] == \"ORDER\":\n", + " return val == q[\"answer\"]\n", + " return val in {norm_text(a) for a in q[\"accept\"]}\n", + "\n", + "\n", + "def grade_fuzzy(q, val, cut=0.75):\n", + " \"\"\"단답형은 오탈자(글자 순서 바뀜·한 글자 누락)를 허용(유사도 cut 이상)하되, 알려진 오답과 더 가까우면 오답으로 본다.\"\"\"\n", + " if grade_exact(q, val) or q[\"type\"] != \"SA\" or len(val) < 2:\n", + " return grade_exact(q, val)\n", + " best = max(1.0 if sorted(val) == sorted(norm_text(a)) else difflib.SequenceMatcher(None, val, norm_text(a)).ratio() for a in q[\"accept\"])\n", + " worst = max(difflib.SequenceMatcher(None, val, norm_text(w)).ratio() for w in q[\"wrong\"])\n", + " return best >= cut and best > worst\n", + "\n", + "\n", + "def score_of(q, correct):\n", + " return POINTS[q[\"diff\"]] if correct else 0\n", + "\n", + "# ---- 오답 유형 분류기(규칙 + 문자 n-gram 로지스틱 회귀) ----\n", + "SEED = {\"피아 오인\": [\"적기\", \"적 드론\", \"아군기\", \"적 헬기\", \"적기판정\", \"헬기\", \"적 항공기 확정\", \"아군 드론\"],\n", + " \"절차 누락\": [\"생략\", \"인접부대\", \"개인메신저\", \"보고 안 함\", \"운용병\", \"미확인기\", \"단계 건너뜀\", \"직접 전파\"],\n", + " \"개념 혼동\": [\"직선익\", \"후퇴익\", \"헥사콥터\", \"미탐\", \"재경보\", \"확정\", \"확인\", \"레이더\", \"AI\", \"표적획득\", \"경계\", \"통신망\", \"고정익\"]}\n", + "\n", + "\n", + "def build_text_model():\n", + " texts, labels = [], []\n", + " for q in BANK:\n", + " if q[\"type\"] == \"SA\":\n", + " for w, et in q[\"wrong\"].items():\n", + " texts.append(w); labels.append(et)\n", + " for et, ws in SEED.items():\n", + " texts += ws; labels += [et] * len(ws)\n", + " m = make_pipeline(TfidfVectorizer(analyzer=\"char_wb\", ngram_range=(1, 3)), LogisticRegression(max_iter=2000, C=5))\n", + " return m.fit([norm_text(t) for t in texts], labels)\n", + "\n", + "\n", + "def classify_error(q, val, text_model):\n", + " \"\"\"오답 1건의 유형과 판단 근거(rule/text)를 돌려준다.\"\"\"\n", + " if q[\"type\"] == \"MC\":\n", + " return q[\"dist\"].get(int(val), \"개념 혼동\") if val in \"1234\" and val else \"개념 혼동\", \"보기 매핑\"\n", + " if q[\"type\"] == \"ORDER\":\n", + " return \"절차 누락\", (\"단계 누락\" if len(val) < 4 else \"순서 뒤바뀜\")\n", + " known = {norm_text(w): et for w, et in q[\"wrong\"].items()}\n", + " if val in known:\n", + " return known[val], \"오답 목록\"\n", + " near = max(known, key=lambda w: difflib.SequenceMatcher(None, val, w).ratio())\n", + " if difflib.SequenceMatcher(None, val, near).ratio() >= 0.75 or sorted(val) == sorted(near):\n", + " return known[near], \"오답 목록(오탈자)\"\n", + " return str(text_model.predict([val])[0]), \"문자 모델\"\n", + "\n", + "# [교육용가상자료] 인정 답안·오답 목록에 없는 새로운 오답 표현(문자 모델 시험용)\n", + "NOVEL = [(\"Q21\", \"적기 확정\", \"피아 오인\"), (\"Q21\", \"표적 추적\", \"개념 혼동\"), (\"Q22\", \"직선 날개\", \"개념 혼동\"), (\"Q23\", \"확실함\", \"개념 혼동\"),\n", + " (\"Q23\", \"보고 안함\", \"절차 누락\"), (\"Q24\", \"자동 판정\", \"개념 혼동\"), (\"Q24\", \"관측 운용병\", \"절차 누락\"), (\"Q25\", \"미탐지\", \"개념 혼동\"),\n", + " (\"Q25\", \"적 드론\", \"피아 오인\"), (\"Q26\", \"적 정찰기\", \"피아 오인\"), (\"Q26\", \"보고 생략\", \"절차 누락\"), (\"Q27\", \"고정익 드론\", \"개념 혼동\"),\n", + " (\"Q27\", \"적 헬기\", \"피아 오인\"), (\"Q28\", \"인접 부대 직보\", \"절차 누락\"), (\"Q28\", \"단체 메신저\", \"절차 누락\"), (\"Q21\", \"아군기\", \"피아 오인\"),\n", + " (\"Q23\", \"대충 보고\", \"절차 누락\"), (\"Q22\", \"날개 없음\", \"개념 혼동\"), (\"Q26\", \"미확인 표적으로 대응\", \"피아 오인\"),\n", + " (\"Q24\", \"분대원 누구나\", \"절차 누락\"), (\"Q25\", \"정상 경보\", \"개념 혼동\"), (\"Q27\", \"정찰 헬기\", \"피아 오인\")]\n", + "\n", + "model = build_text_model()\n", + "df = make_responses(40, seed=7)\n", + "df[\"type\"] = df[\"qid\"].map(lambda q: QIDX[q][\"type\"])\n", + "df[\"val\"] = [parse(QIDX[q], r)[0] for q, r in zip(df[\"qid\"], df[\"raw\"])]\n", + "wrong = df[df[\"truth_etype\"].notna()].copy()\n", + "\n", + "t0 = time.perf_counter()\n", + "out = [classify_error(QIDX[q], v, model) for q, v in zip(wrong[\"qid\"], wrong[\"val\"])]\n", + "ms = (time.perf_counter() - t0) * 1000 / len(wrong)\n", + "wrong[\"pred\"], wrong[\"basis\"] = [o[0] for o in out], [o[1] for o in out]\n", + "\n", + "# 복합 오답 시나리오 10종\n", + "rng = np.random.default_rng(5)\n", + "sa_typo = wrong[(wrong[\"type\"] == \"SA\")].copy()\n", + "sa_typo[\"val\"] = [norm_text(_typo(v, rng)) if len(v) >= 3 else v for v in sa_typo[\"truth_answer\"]]\n", + "sa_typo[\"pred\"] = [classify_error(QIDX[q], v, model)[0] for q, v in zip(sa_typo[\"qid\"], sa_typo[\"val\"])]\n", + "nov = pd.DataFrame(NOVEL, columns=[\"qid\", \"val\", \"truth_etype\"])\n", + "nov[\"val\"] = nov[\"val\"].map(norm_text)\n", + "nov[\"pred\"] = [classify_error(QIDX[q], v, model)[0] for q, v in zip(nov[\"qid\"], nov[\"val\"])]\n", + "raw_fmt = wrong[\"raw\"].str.strip() != wrong[\"val\"]\n", + "SCEN = {\n", + " \"S1 선다형 표준 표기\": wrong[(wrong[\"type\"] == \"MC\") & ~raw_fmt], \"S2 선다형 원문자·전각 표기\": wrong[(wrong[\"type\"] == \"MC\") & raw_fmt],\n", + " \"S3 단답 오답 목록 일치\": wrong[(wrong[\"type\"] == \"SA\") & (wrong[\"basis\"] == \"오답 목록\")], \"S4 단답 오답 + 오탈자\": sa_typo,\n", + " \"S5 단답 오답 + 어미·띄어쓰기\": wrong[(wrong[\"type\"] == \"SA\") & raw_fmt], \"S6 순서형 단계 누락\": wrong[(wrong[\"type\"] == \"ORDER\") & (wrong[\"val\"].str.len() < 4)],\n", + " \"S7 순서형 순서 뒤바뀜\": wrong[(wrong[\"type\"] == \"ORDER\") & (wrong[\"val\"].str.len() == 4)], \"S8 순서형 구분자 혼입\": wrong[(wrong[\"type\"] == \"ORDER\") & raw_fmt],\n", + " \"S9 목록에 없는 새 오답\": nov, \"S10 전체 혼합\": pd.concat([wrong, sa_typo, nov]),\n", + "}\n", + "rows = []\n", + "for name, d in SCEN.items():\n", + " rec = {et: (d[d[\"truth_etype\"] == et][\"pred\"] == et).mean() if (d[\"truth_etype\"] == et).any() else np.nan for et in ETYPES}\n", + " rows.append({\"시나리오\": name, \"건수\": len(d), \"정확도\": (d[\"pred\"] == d[\"truth_etype\"]).mean(), **{f\"{k} 재현율\": v for k, v in rec.items()}})\n", + "res = pd.DataFrame(rows)\n", + "res[\"통과\"] = np.where(res[\"정확도\"] >= 0.85, \"PASS\", \"FAIL\")\n", + "print(res.round(3).fillna(\"-\").to_string(index=False))\n", + "allr = SCEN[\"S10 전체 혼합\"]\n", + "recs = {et: (allr[allr[\"truth_etype\"] == et][\"pred\"] == et).mean() for et in ETYPES}\n", + "print(\"\\n오답 유형별 재현율:\", {k: round(float(v), 3) for k, v in recs.items()},\n", + " f\"(목표 85% 이상: {'달성' if min(recs.values()) >= 0.85 else '미달'}), 건당 처리 {ms:.3f} ms (목표 200 ms 이하)\")\n", + "print(f\"시나리오 통과 {(res['통과'] == 'PASS').sum()}/10\")\n", + "fa = nov[nov[\"pred\"] != nov[\"truth_etype\"]]\n", + "print(\"새 오답 오분류(특징 재설계 대상):\", [f\"{v}->{p}\" for v, p in zip(fa[\"val\"], fa[\"pred\"])])\n", + "\n", + "# 오답 패턴 분석: 개인별 주된 오답 유형, 중대별 분포\n", + "dom = wrong.groupby(\"sid\")[\"pred\"].agg(lambda s: s.value_counts().idxmax())\n", + "print(\"\\n개인별 주된 오답 유형 분포:\", dom.value_counts().to_dict())\n", + "print(pd.crosstab(wrong[\"unit\"], wrong[\"pred\"], normalize=\"index\").round(2).to_string())\n", + "\n", + "ct = pd.crosstab(wrong[\"qid\"].map(lambda q: QIDX[q][\"area\"]), wrong[\"pred\"])\n", + "ct.index = ct.index.map({\"형상식별\": \"Shape ID\", \"피아식별\": \"Friend/Foe\", \"경보보고\": \"Alert/Report\", \"교전통제\": \"Control\", \"대드론일반\": \"C-UAS basics\"})\n", + "ct.columns = ct.columns.map({\"개념 혼동\": \"concept\", \"피아 오인\": \"friend/foe\", \"절차 누락\": \"procedure\"})\n", + "ct.plot(kind=\"barh\", stacked=True, figsize=(8, 4)); plt.xlabel(\"wrong answers\"); plt.title(\"Error types by area (synthetic)\"); plt.tight_layout(); plt.show()\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 04 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + " 시나리오 건수 정확도 개념 혼동 재현율 피아 오인 재현율 절차 누락 재현율 통과\n", + " S1 선다형 표준 표기 367 1.000 1.0 1.0 1.000 PASS\n", + " S2 선다형 원문자·전각 표기 235 1.000 1.0 1.0 1.000 PASS\n", + " S3 단답 오답 목록 일치 120 1.000 1.0 1.0 1.000 PASS\n", + " S4 단답 오답 + 오탈자 148 1.000 1.0 1.0 1.000 PASS\n", + "S5 단답 오답 + 어미·띄어쓰기 55 1.000 1.0 1.0 1.000 PASS\n", + " S6 순서형 단계 누락 90 1.000 - - 1.000 PASS\n", + " S7 순서형 순서 뒤바뀜 94 1.000 - - 1.000 PASS\n", + " S8 순서형 구분자 혼입 81 1.000 - - 1.000 PASS\n", + " S9 목록에 없는 새 오답 22 0.864 1.0 0.857 0.714 PASS\n", + " S10 전체 혼합 1104 0.997 1.0 0.995 0.996 PASS\n", + "\n", + "오답 유형별 재현율: {'개념 혼동': 1.0, '피아 오인': 0.995, '절차 누락': 0.996} (목표 85% 이상: 달성), 건당 처리 0.005 ms (목표 200 ms 이하)\n", + "시나리오 통과 10/10\n", + "새 오답 오분류(특징 재설계 대상): ['대충보고->개념 혼동', '미확인표적으로대응->절차 누락', '분대원누구나->개념 혼동']\n", + "\n", + "개인별 주된 오답 유형 분포: {'절차 누락': 60, '개념 혼동': 42, '피아 오인': 12}\n", + "pred 개념 혼동 절차 누락 피아 오인\n", + "unit \n", + "가상1중대 0.36 0.46 0.18\n", + "가상2중대 0.40 0.41 0.19\n", + "가상3중대 0.34 0.49 0.17\n", + "```\n", + "
\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 05. 개인별/부대별 방공 취약 영역 식별 및 맞춤형 보충 학습 추천 알고리즘 개발\n", + "**소요시간**: 약 3.0시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "개인별/부대별 방공 취약 영역 식별 및 맞춤형 보충 학습 추천 알고리즘을 개발한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **채점 결과로 개인별·중대별 방공 취약 영역을 찾고, 불필요한 추천을 줄인 맞춤형 보충 학습 추천 알고리즘을 개발한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **문항 난이도를 반영한 베이즈 추정으로 영역별 '취약할 확률'을 계산하고 High/Medium/Low 신뢰도 등급을 부여 수행하고 검증하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `src/verification/trn02_confidence_engine.py` 형태의 결과물을 완성한다.\n", + "\n", + "### 🔍 평가 및 검증 기준 (PASS 조건)\n", + "#### 미션 판정\n", + "\n", + "**평가 구분:** 필수\n", + "\n", + "**핵심 평가영역:** 신뢰도 등급(High/Med/Low) 부여 기준의 군사적 타당성\n", + "\n", + "#### 미션 PASS 조건\n", + "\n", + "- 정량 기준: 오경보율(FAR) 40% 이상 감축, 신뢰도 스코어 산출 일관성 90% 이상\n", + "\n", + "\n", + "- 진행 기준: 오경보 필터링 전후 비교 지표 산출 및 통합 테스트 완료\n", + "\n", + "\n", + "- 검증 방법: ROC-AUC 곡선 분석 및 임계값 민감도 테스트\n", + "\n", + "\n", + "#### 채점자 판정 메모\n", + "\n", + "> 단순 코드 실행 여부뿐만 아니라 출력된 수치와 근거 자료가 군 실무 요구사항에 부합하는지 면밀히 확인한다.\n", + "\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "\"\"\"채점 결과로 개인별·중대별 방공 취약 영역을 찾아 보충 학습을 추천하는 알고리즘을 개발한다. 문항 난이도를 반영한 베이즈 추정으로 영역별 '취약할 확률'과 신뢰도 등급을 산출하고, 틀린 문항이 있으면 모두 추천하던 방식 대비 불필요한 추천(오경보) 감축·결정 일관성·ROC-AUC를 검증한다.\"\"\"\n", + "import difflib\n", + "import re\n", + "import unicodedata\n", + "import numpy as np\n", + "import pandas as pd\n", + "import matplotlib.pyplot as plt\n", + "from scipy import stats\n", + "from sklearn.metrics import roc_auc_score\n", + "\n", + "# ---------------------------------------------------------------------------\n", + "# [교육용가상자료] 방공·드론 식별 퀴즈 문항 은행과 가상 응시자 응답 생성기\n", + "# 문항은 일반 원칙 수준의 가상 문항이며 실제 교범·교전규칙 원문이 아니다. 응시자는 가명(S001~)이다.\n", + "# ---------------------------------------------------------------------------\n", + "AREAS = [\"형상식별\", \"피아식별\", \"경보보고\", \"교전통제\", \"대드론일반\"]\n", + "ETYPES = [\"개념 혼동\", \"피아 오인\", \"절차 누락\"]\n", + "POINTS = {1: 2, 2: 3, 3: 5}\n", + "C, F, P = ETYPES\n", + "\n", + "\n", + "def _mc(qid, area, diff, stem, opts, ans, dist):\n", + " # [TODO : 학생 실습 5] 개인별/부대별 방공 취약 영역 식별 및 맞춤형 보충 학습 추천 알고리즘 개발 관련 핵심 로직을 구현하세요.\n", + " # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n", + " # <<< 여기에 코드를 작성하세요 >>>\n", + " pass # 작성 후 pass를 제거하세요.\n", + "def _sa(qid, area, diff, stem, accept, wrong):\n", + " # [TODO : 학생 실습 5] 개인별/부대별 방공 취약 영역 식별 및 맞춤형 보충 학습 추천 알고리즘 개발 관련 핵심 로직을 구현하세요.\n", + " # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n", + " # <<< 여기에 코드를 작성하세요 >>>\n", + " pass # 작성 후 pass를 제거하세요.\n", + "def _order(qid, area, diff, stem):\n", + " return {\"qid\": qid, \"type\": \"ORDER\", \"area\": area, \"diff\": diff, \"stem\": stem, \"answer\": \"ABCD\"}\n", + "\n", + "\n", + "BANK = [\n", + " _mc(\"Q01\", \"형상식별\", 1, \"꼬리날개 없이 삼각형 날개를 가진 소형 고정익 실루엣은?\", [\"쿼드콥터형\", \"삼각익 고정익형\", \"회전익 헬기형\", \"조류\"], 2, [C, C, F]),\n", + " _mc(\"Q02\", \"형상식별\", 1, \"회전날개 4개가 십자로 배치된 실루엣은?\", [\"고정익 드론형\", \"쿼드콥터형\", \"순항미사일형\", \"탄도미사일형\"], 2, [C, C, C]),\n", + " _mc(\"Q03\", \"형상식별\", 2, \"가늘고 긴 동체 중간에 작은 날개가 붙은 실루엣은?\", [\"순항미사일형\", \"탄도미사일형\", \"민간 경비행기형\", \"조류\"], 1, [C, F, F]),\n", + " _mc(\"Q04\", \"형상식별\", 3, \"원거리라 실루엣 식별이 어려울 때 먼저 할 행동은?\", [\"추정 유형으로 확정 보고\", \"관측 정보를 그대로 보고하고 추가 관측\", \"관측 중단\", \"민간기로 간주\"], 2, [P, P, F]),\n", + " _mc(\"Q05\", \"피아식별\", 2, \"식별 신호 응답이 없는 항공기에 대한 올바른 판단은?\", [\"즉시 적기로 확정\", \"미확인 항공기로 분류하고 추가 확인\", \"아군기로 간주\", \"무시\"], 2, [F, F, P]),\n", + " _mc(\"Q06\", \"피아식별\", 2, \"운항 정보와 일치하는 저속 비행체의 분류는?\", [\"적 드론\", \"민간기 가능성 높음, 추가 확인\", \"순항미사일\", \"아군 정찰기로 확정\"], 2, [F, C, F]),\n", + " _mc(\"Q07\", \"피아식별\", 3, \"아군 드론 운용 공지가 있는 공역에서 미확인 드론을 보면 우선 확인할 것은?\", [\"기상 정보\", \"아군 운용 계획과 식별 신호\", \"대응 준비 상태\", \"주변 지형\"], 2, [C, P, C]),\n", + " _mc(\"Q08\", \"피아식별\", 3, \"조류 무리와 소형 드론 군집 구분에 가장 도움이 되는 정보는?\", [\"크기만 비교\", \"궤적의 규칙성과 열 신호\", \"색상\", \"관측 시각\"], 2, [C, C, C]),\n", + " _mc(\"Q09\", \"경보보고\", 1, \"공중 위협 최초 보고에 포함하지 않아도 되는 것은?\", [\"관측 시각\", \"방향과 거리\", \"추정 유형\", \"관측자 개인 연락처\"], 4, [P, P, P]),\n", + " _mc(\"Q10\", \"경보보고\", 2, \"경보 전파 후 상황이 바뀌면?\", [\"최초 보고 유지\", \"변경 사항을 즉시 추가 보고\", \"상황 종료 후 일괄 보고\", \"개인 판단으로 경보 해제\"], 2, [P, P, P]),\n", + " _mc(\"Q11\", \"경보보고\", 2, \"보고 내용이 불확실할 때 올바른 표현은?\", [\"확정 표현으로 보고\", \"'추정'임을 밝히고 근거와 함께 보고\", \"보고 보류\", \"다른 관측자 보고를 복사\"], 2, [C, P, P]),\n", + " _mc(\"Q12\", \"경보보고\", 1, \"드론 탐지 후 보고 경로로 알맞은 것은?\", [\"인접 부대에만 전파\", \"지휘계통을 따라 보고\", \"개인 메신저로 공유\", \"보고 없이 대응\"], 2, [P, P, P]),\n", + " _mc(\"Q13\", \"교전통제\", 1, \"AI 탐지 결과만으로 대응을 결정할 수 있는가?\", [\"가능하다\", \"인간 지휘관의 승인이 필요하다\", \"신뢰도 90% 이상이면 가능\", \"야간에만 가능\"], 2, [C, C, C]),\n", + " _mc(\"Q14\", \"교전통제\", 2, \"민간 피해 가능성이 확인되면?\", [\"그대로 진행\", \"즉시 보고하고 지휘관 판단을 받는다\", \"조치 후 보고\", \"무시\"], 2, [P, P, F]),\n", + " _mc(\"Q15\", \"교전통제\", 3, \"식별이 불확실한 표적에 대한 원칙은?\", [\"위협으로 간주해 즉시 대응\", \"식별 확인 전까지 승인 절차를 따른다\", \"무조건 무시\", \"다수결로 결정\"], 2, [F, F, C]),\n", + " _mc(\"Q16\", \"교전통제\", 2, \"판단 근거를 기록해야 하는 이유는?\", [\"형식상 필요\", \"사후 검토와 책임 추적\", \"보고 생략\", \"기록 불필요\"], 2, [C, P, P]),\n", + " _mc(\"Q17\", \"대드론일반\", 1, \"대드론 체계의 일반적 처리 순서는?\", [\"대응-탐지-식별\", \"탐지-식별-보고·판단-대응\", \"식별-대응-탐지\", \"보고-탐지-대응\"], 2, [P, P, P]),\n", + " _mc(\"Q18\", \"대드론일반\", 3, \"레이더만으로 조류와 드론 구분이 어려운 이유는?\", [\"반사 신호 크기가 비슷할 수 있어서\", \"레이더는 야간에 못 써서\", \"드론에는 금속이 없어서\", \"조류는 탐지되지 않아서\"], 1, [C, C, C]),\n", + " _mc(\"Q19\", \"대드론일반\", 2, \"전자광학(EO) 센서의 약점이 커지는 조건은?\", [\"맑은 주간\", \"안개·야간\", \"저속 표적\", \"근거리\"], 2, [C, C, C]),\n", + " _mc(\"Q20\", \"대드론일반\", 2, \"경보가 반복해서 오경보로 판명되면?\", [\"경보 장치를 끈다\", \"사례를 기록·보고하고 기준 재검토 요청\", \"무시한다\", \"모두 실제 위협으로 간주\"], 2, [P, P, F]),\n", + " _sa(\"Q21\", \"피아식별\", 1, \"아군·적·민간을 구분하는 절차를 무엇이라 하는가?\", [\"피아식별\", \"IFF\"], {\"표적획득\": C, \"적기판정\": F, \"경계\": C}),\n", + " _sa(\"Q22\", \"형상식별\", 2, \"꼬리날개 없는 삼각형 날개 형태를 무엇이라 하는가?\", [\"삼각익\", \"델타익\", \"delta\"], {\"직선익\": C, \"후퇴익\": C, \"쿼드콥터\": C}),\n", + " _sa(\"Q23\", \"경보보고\", 2, \"불확실한 관측을 보고할 때 붙이는 표현은?\", [\"추정\"], {\"확정\": C, \"확인\": C, \"생략\": P}),\n", + " _sa(\"Q24\", \"교전통제\", 1, \"AI 탐지 결과에 대해 최종 결정을 내리는 주체는?\", [\"지휘관\"], {\"AI\": C, \"운용병\": P, \"레이더\": C}),\n", + " _sa(\"Q25\", \"대드론일반\", 1, \"조류를 드론으로 잘못 경보하는 것을 무엇이라 하는가?\", [\"오경보\", \"오탐\", \"false alarm\"], {\"미탐\": C, \"재경보\": C, \"적기\": F}),\n", + " _sa(\"Q26\", \"피아식별\", 2, \"운항 정보가 확인된 여객기는 어떻게 분류하는가?\", [\"민간기\", \"민항기\"], {\"적기\": F, \"미확인기\": P, \"아군기\": F}),\n", + " _sa(\"Q27\", \"형상식별\", 1, \"회전날개 4개를 가진 드론 형태는?\", [\"쿼드콥터\", \"quadcopter\"], {\"헥사콥터\": C, \"고정익\": C, \"헬기\": F}),\n", + " _sa(\"Q28\", \"경보보고\", 1, \"보고는 무엇을 따라 올라가는가?\", [\"지휘계통\"], {\"인접부대\": P, \"개인메신저\": P, \"통신망\": C}),\n", + " _order(\"Q29\", \"경보보고\", 2, \"최초 보고 순서: A 관측 시각, B 방향·거리, C 추정 유형, D 관측자 조치\"),\n", + " _order(\"Q30\", \"대드론일반\", 2, \"처리 순서: A 탐지, B 식별, C 보고·판단, D 대응\"),\n", + " _order(\"Q31\", \"교전통제\", 3, \"통제 순서: A 식별 확인, B 상급 승인 요청, C 승인 수령, D 조치 기록\"),\n", + " _order(\"Q32\", \"피아식별\", 3, \"확인 순서: A 운항정보 대조, B 식별 신호 확인, C 미확인 분류, D 보고\"),\n", + "]\n", + "QIDX = {q[\"qid\"]: q for q in BANK}\n", + "UNITS = [\"가상1중대\", \"가상2중대\", \"가상3중대\"]\n", + "_CIRC = \"①②③④\"\n", + "PARA = {\"Q21\": \"아군적군 구분\", \"Q22\": \"세모 날개\", \"Q23\": \"추정됨으로 표기\", \"Q24\": \"중대장(지휘관)\", \"Q25\": \"잘못된 경보\",\n", + " \"Q26\": \"민간 여객기\", \"Q27\": \"4발 드론\", \"Q28\": \"지휘 라인\"} # 뜻은 맞지만 인정 답안 목록에 없는 표현\n", + "\n", + "\n", + "def make_soldiers(n_per_unit, seed):\n", + " rng = np.random.default_rng(seed)\n", + " out = []\n", + " for u, unit in enumerate(UNITS):\n", + " base = [0.3, -0.1, 0.0][u]\n", + " for i in range(n_per_unit):\n", + " off = rng.normal(0, 0.5, len(AREAS)) + (np.array([0, 0, 0, -0.6, 0]) if u == 1 else 0)\n", + " out.append({\"sid\": f\"S{len(out) + 1:03d}\", \"unit\": unit, \"theta\": dict(zip(AREAS, base + rng.normal(0, 0.6) + off)),\n", + " \"tend\": dict(zip(ETYPES, rng.dirichlet([1.2, 1.2, 1.2])))})\n", + " return out\n", + "\n", + "\n", + "def p_correct(theta, diff):\n", + " return 1 / (1 + np.exp(-1.7 * (theta - (diff - 2) * 0.9 + 0.9)))\n", + "\n", + "\n", + "def _typo(s, rng):\n", + " if len(s) < 2:\n", + " return s\n", + " i = int(rng.integers(len(s) - 1))\n", + " return s[:i] + s[i + 1] + s[i] + s[i + 2:] if len(s) <= 3 or rng.random() < 0.5 else s[:i] + s[i + 1:]\n", + "\n", + "\n", + "def _fmt_mc(k, rng):\n", + " r = rng.random()\n", + " if r < 0.55:\n", + " return str(k)\n", + " return rng.choice([_CIRC[k - 1], f\"{k}번\", \"ABCD\"[k - 1], \"abcd\"[k - 1] + \")\", f\" {k} \", chr(0xFF10 + k), f\"{k}.\"])\n", + "\n", + "\n", + "def _fmt_sa(s, rng):\n", + " r = rng.random()\n", + " if r < 0.45:\n", + " return s\n", + " if r < 0.6:\n", + " return \" \".join(s) if len(s) <= 4 else s[:2] + \" \" + s[2:]\n", + " if r < 0.72:\n", + " return s + rng.choice([\"입니다\", \"이다\", \".\", \"임\"])\n", + " if r < 0.8:\n", + " return s.upper() if s.isascii() else \"\".join(chr(ord(ch) + 0xFEE0) if \"!\" <= ch <= \"~\" else ch for ch in s) + \" \"\n", + " return _typo(s, rng)\n", + "\n", + "\n", + "def answer(q, sol, rng):\n", + " \"\"\"한 문항 응답: (원본 응답 문자열, 의도한 정답 여부, 의도한 답, 오답 유형).\"\"\"\n", + " ok = rng.random() < p_correct(sol[\"theta\"][q[\"area\"]], q[\"diff\"])\n", + " if q[\"type\"] == \"MC\":\n", + " if ok:\n", + " k, et = q[\"answer\"], None\n", + " else:\n", + " ks = list(q[\"dist\"]); w = np.array([sol[\"tend\"][q[\"dist\"][k]] for k in ks])\n", + " k = int(rng.choice(ks, p=w / w.sum())); et = q[\"dist\"][k]\n", + " return _fmt_mc(k, rng), ok, str(k), et\n", + " if q[\"type\"] == \"SA\":\n", + " if ok and rng.random() < 0.06:\n", + " return PARA[q[\"qid\"]], ok, PARA[q[\"qid\"]], None\n", + " if ok:\n", + " s, et = str(rng.choice(q[\"accept\"])), None\n", + " else:\n", + " ws = list(q[\"wrong\"]); w = np.array([sol[\"tend\"][q[\"wrong\"][x]] for x in ws])\n", + " s = str(rng.choice(ws, p=w / w.sum())); et = q[\"wrong\"][s]\n", + " return _fmt_sa(s, rng), ok, s, et\n", + " if ok:\n", + " s = \"ABCD\"\n", + " elif rng.random() < 0.5:\n", + " s = \"ABCD\".replace(\"ABCD\"[rng.integers(1, 4)], \"\") # 단계 하나 빠뜨림\n", + " else:\n", + " i = int(rng.integers(0, 3)); s = list(\"ABCD\"); s[i], s[i + 1] = s[i + 1], s[i]; s = \"\".join(s) # 순서 뒤바뀜\n", + " raw = s if rng.random() < 0.6 else rng.choice([\"-\".join(s), \" \".join(s), s.lower(), \",\".join(s)])\n", + " return raw, ok, s, (None if ok else P)\n", + "\n", + "\n", + "def make_responses(n_per_unit=40, seed=7, blank=0.02):\n", + " \"\"\"응시자 x 문항 응답표(DataFrame)를 만든다. truth_* 열은 교관 기준 정답(평가용)이다.\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " rows = []\n", + " for sol in make_soldiers(n_per_unit, seed):\n", + " for q in BANK:\n", + " raw, ok, intended, et = answer(q, sol, rng)\n", + " if rng.random() < blank:\n", + " raw, ok, intended, et = \"\", False, \"\", None\n", + " rows.append({\"rid\": f\"R{len(rows):05d}\", \"sid\": sol[\"sid\"], \"unit\": sol[\"unit\"], \"qid\": q[\"qid\"], \"raw\": raw,\n", + " \"truth_correct\": ok, \"truth_answer\": intended, \"truth_etype\": et})\n", + " return pd.DataFrame(rows)\n", + "# ---------------------------------------------------------------------------\n", + "\n", + "# ---- 응답 정제·파싱·채점 공통 함수 ----\n", + "TAILS = (\"입니다\", \"이다\", \"임\")\n", + "\n", + "\n", + "def norm_text(s):\n", + " \"\"\"전각->반각(NFKC), 소문자, 공백·문장부호 제거, 끝맺음 어미 제거.\"\"\"\n", + " s = unicodedata.normalize(\"NFKC\", str(s)).strip().lower()\n", + " s = re.sub(r\"[\\s\\.\\,\\-\\(\\)·]\", \"\", s)\n", + " for t in TAILS:\n", + " if s.endswith(t) and len(s) > len(t) + 1:\n", + " s = s[:-len(t)]\n", + " return s\n", + "\n", + "\n", + "def parse(q, raw):\n", + " \"\"\"원본 응답 -> (정제값, 상태). 상태: 정상 / 미응답 / 형식오류\"\"\"\n", + " if raw is None or str(raw).strip() == \"\":\n", + " return \"\", \"미응답\"\n", + " s = unicodedata.normalize(\"NFKC\", str(raw)).strip()\n", + " if q[\"type\"] == \"MC\":\n", + " t = re.sub(r\"[\\s번\\.\\)]\", \"\", s).lower()\n", + " t = {\"a\": \"1\", \"b\": \"2\", \"c\": \"3\", \"d\": \"4\"}.get(t, t)\n", + " return (t, \"정상\") if t in (\"1\", \"2\", \"3\", \"4\") else (t, \"형식오류\")\n", + " if q[\"type\"] == \"ORDER\":\n", + " t = re.sub(r\"[^A-Za-z]\", \"\", s).upper()\n", + " return (t, \"정상\") if t and set(t) <= set(\"ABCD\") and len(set(t)) == len(t) else (t, \"형식오류\")\n", + " return norm_text(s), \"정상\"\n", + "\n", + "\n", + "def grade_exact(q, val):\n", + " if q[\"type\"] == \"MC\":\n", + " return val == str(q[\"answer\"])\n", + " if q[\"type\"] == \"ORDER\":\n", + " return val == q[\"answer\"]\n", + " return val in {norm_text(a) for a in q[\"accept\"]}\n", + "\n", + "\n", + "def grade_fuzzy(q, val, cut=0.75):\n", + " \"\"\"단답형은 오탈자(글자 순서 바뀜·한 글자 누락)를 허용(유사도 cut 이상)하되, 알려진 오답과 더 가까우면 오답으로 본다.\"\"\"\n", + " if grade_exact(q, val) or q[\"type\"] != \"SA\" or len(val) < 2:\n", + " return grade_exact(q, val)\n", + " best = max(1.0 if sorted(val) == sorted(norm_text(a)) else difflib.SequenceMatcher(None, val, norm_text(a)).ratio() for a in q[\"accept\"])\n", + " worst = max(difflib.SequenceMatcher(None, val, norm_text(w)).ratio() for w in q[\"wrong\"])\n", + " return best >= cut and best > worst\n", + "\n", + "\n", + "def score_of(q, correct):\n", + " return POINTS[q[\"diff\"]] if correct else 0\n", + "\n", + "MASTERY = 0.6 # 영역 정답률이 이보다 낮으면 '취약'\n", + "HIGH, MEDIUM = 0.8, 0.4\n", + "GRID = np.linspace(-3, 3, 121)\n", + "PRIOR = stats.norm.pdf(GRID, 0, 1)\n", + "CUT = GRID[np.argmin(np.abs(p_correct(GRID, 2) - MASTERY))] # 중간 난이도 정답률 0.6에 해당하는 숙달도\n", + "\n", + "\n", + "def weak_prob(resp):\n", + " \"\"\"(난이도, 정답 여부) 목록 -> 숙달도 사후분포에서 CUT 미만일 확률. 어려운 문항 오답은 덜, 쉬운 문항 오답은 더 크게 반영된다.\"\"\"\n", + " post = PRIOR.copy()\n", + " for d, c in resp:\n", + " p = p_correct(GRID, d)\n", + " post *= p if c else 1 - p\n", + " return float(post[GRID < CUT].sum() / post.sum())\n", + "\n", + "\n", + "def area_table(df):\n", + " g = df.assign(d=df[\"qid\"].map(lambda q: QIDX[q][\"diff\"]))\n", + " t = g.groupby([\"sid\", \"unit\", \"area\"]).agg(wrong=(\"correct\", lambda s: int((~s).sum())),\n", + " resp=(\"d\", lambda s: list(zip(s, g.loc[s.index, \"correct\"])))).reset_index()\n", + " t[\"score\"] = [weak_prob(r) for r in t[\"resp\"]]\n", + " t[\"grade\"] = np.where(t[\"score\"] >= HIGH, \"High\", np.where(t[\"score\"] >= MEDIUM, \"Medium\", \"Low\"))\n", + " return t\n", + "\n", + "\n", + "df = make_responses(40, seed=7)\n", + "df[\"area\"] = df[\"qid\"].map(lambda q: QIDX[q][\"area\"])\n", + "df[\"correct\"] = [grade_fuzzy(QIDX[q], parse(QIDX[q], r)[0]) for q, r in zip(df[\"qid\"], df[\"raw\"])]\n", + "sol = {s[\"sid\"]: s for s in make_soldiers(40, 7)}\n", + "t = area_table(df)\n", + "t[\"truth_weak\"] = [p_correct(sol[s][\"theta\"][a], 2) < MASTERY for s, a in zip(t[\"sid\"], t[\"area\"])] # 가상 실제 숙달도(평가용)\n", + "\n", + "\n", + "def report(name, rec):\n", + " fa = (rec & ~t[\"truth_weak\"]).sum()\n", + " return {\"방식\": name, \"추천 수\": int(rec.sum()), \"불필요 추천\": int(fa), \"오경보율\": round(fa / max(rec.sum(), 1), 3),\n", + " \"취약 영역 재현율\": round((rec & t[\"truth_weak\"]).sum() / t[\"truth_weak\"].sum(), 3)}\n", + "\n", + "\n", + "before = report(\"기존(오답 1개라도 있으면 추천)\", t[\"wrong\"] >= 1)\n", + "after = report(\"난이도 반영 추정(Medium 이상)\", t[\"grade\"] != \"Low\")\n", + "print(f\"[교육용가상자료] 응시자 120명 x 영역 5개 = {len(t)}건, 실제 취약 {int(t['truth_weak'].sum())}건\")\n", + "print(pd.DataFrame([before, after]).to_string(index=False))\n", + "cut = 1 - after[\"오경보율\"] / before[\"오경보율\"]\n", + "retest = ((t[\"grade\"] == \"Low\") & (t[\"score\"] >= 0.2)).sum()\n", + "print(f\"Low 등급 중 취약 확률 0.2 이상 {retest}건은 추천 대신 '추가 문항 출제 후 재판단' 대상으로 둔다.\")\n", + "print(f\"\\n오경보율 감축 {cut:.1%} (목표 40% 이상: {'달성' if cut >= 0.4 else '미달'})\")\n", + "\n", + "# 일관성: 영역마다 응답 1건씩 빼고 다시 계산해도 추천 결정이 유지되는 비율\n", + "rng = np.random.default_rng(0)\n", + "drop = df.groupby([\"sid\", \"area\"]).sample(1, random_state=1).index\n", + "t2 = area_table(df.drop(drop))\n", + "cons = ((t2[\"grade\"] != \"Low\").values == (t[\"grade\"] != \"Low\").values).mean()\n", + "print(f\"추천 결정 일관성(응답 1건 제외 재계산) {cons:.1%} (목표 90% 이상: {'달성' if cons >= 0.9 else '미달'})\")\n", + "auc = roc_auc_score(t[\"truth_weak\"], t[\"score\"])\n", + "print(f\"취약 확률 점수 ROC-AUC {auc:.3f}\")\n", + "print(\"[임계값 민감도]\", \", \".join(f\"{m}: 오경보율 {report('', t['score'] >= m)['오경보율']:.2f}/재현율 {report('', t['score'] >= m)['취약 영역 재현율']:.2f}\" for m in [0.3, 0.4, 0.5, 0.7]))\n", + "\n", + "# 개인 보충 학습 추천 예시\n", + "s0 = t[t[\"grade\"] != \"Low\"].sort_values(\"score\", ascending=False)[\"sid\"].iloc[0]\n", + "print(f\"\\n[개인 추천 예시: {s0}]\")\n", + "for r in t[(t[\"sid\"] == s0) & (t[\"grade\"] != \"Low\")].itertuples():\n", + " wrong_q = df[(df[\"sid\"] == s0) & (df[\"area\"] == r.area) & ~df[\"correct\"]][\"qid\"].tolist()\n", + " easy = [q[\"qid\"] for q in BANK if q[\"area\"] == r.area and q[\"diff\"] == 1]\n", + " print(f\" - {r.area}: 취약 확률 {r.score:.2f}({r.grade}), 오답 {wrong_q} -> 기초 문항 {easy} 재학습 후 재평가\")\n", + "\n", + "# 중대별 취약 영역: 소속 인원 30% 이상이 취약(Medium 이상)이면 중대 단위 보충 교육 권고\n", + "u = t.assign(rec=t[\"grade\"] != \"Low\").pivot_table(index=\"unit\", columns=\"area\", values=\"rec\", aggfunc=\"mean\").round(2)\n", + "print(\"\\n[중대별 취약 인원 비율]\")\n", + "print(u.to_string())\n", + "print(\"중대 단위 보충 교육 권고:\", [f\"{i}-{c}\" for i in u.index for c in u.columns if u.loc[i, c] >= 0.3])\n", + "print(\"추천은 학습 보조 정보이며 개인 평가·인사 자료로 쓰지 않는다.\")\n", + "\n", + "fig, ax = plt.subplots(1, 2, figsize=(11, 4))\n", + "for g, col in [(\"High\", \"tab:red\"), (\"Medium\", \"tab:orange\"), (\"Low\", \"tab:green\")]:\n", + " ax[0].hist(t[t[\"grade\"] == g][\"score\"], bins=20, range=(0, 1), color=col, alpha=.7, label=g)\n", + "ax[0].set_title(\"Weakness probability by grade\"); ax[0].set_xlabel(\"P(accuracy < 0.6)\"); ax[0].legend()\n", + "im = ax[1].imshow(u.values, cmap=\"Reds\", vmin=0, vmax=1)\n", + "ax[1].set_xticks(range(5)); ax[1].set_xticklabels([\"Shape\", \"IFF\", \"Report\", \"Control\", \"C-UAS\"][:0] or [{\"형상식별\": \"Shape\", \"피아식별\": \"IFF\", \"경보보고\": \"Report\", \"교전통제\": \"Control\", \"대드론일반\": \"C-UAS\"}[c] for c in u.columns])\n", + "ax[1].set_yticks(range(3)); ax[1].set_yticklabels([\"Co.1\", \"Co.2\", \"Co.3\"]); plt.colorbar(im, ax=ax[1]); ax[1].set_title(\"Share of weak soldiers by unit/area\")\n", + "plt.tight_layout(); plt.show()\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 05 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + "[교육용가상자료] 응시자 120명 x 영역 5개 = 600건, 실제 취약 120건\n", + " 방식 추천 수 불필요 추천 오경보율 취약 영역 재현율\n", + " 기존(오답 1개라도 있으면 추천) 444 324 0.730 1.000\n", + "난이도 반영 추정(Medium 이상) 148 62 0.419 0.717\n", + "Low 등급 중 취약 확률 0.2 이상 96건은 추천 대신 '추가 문항 출제 후 재판단' 대상으로 둔다.\n", + "\n", + "오경보율 감축 42.6% (목표 40% 이상: 달성)\n", + "추천 결정 일관성(응답 1건 제외 재계산) 91.2% (목표 90% 이상: 달성)\n", + "취약 확률 점수 ROC-AUC 0.898\n", + "[임계값 민감도] 0.3: 오경보율 0.50/재현율 0.82, 0.4: 오경보율 0.42/재현율 0.72, 0.5: 오경보율 0.35/재현율 0.57, 0.7: 오경보율 0.15/재현율 0.47\n", + "\n", + "[개인 추천 예시: S098]\n", + " - 경보보고: 취약 확률 1.00(High), 오답 ['Q09', 'Q10', 'Q11', 'Q12', 'Q23', 'Q28', 'Q29'] -> 기초 문항 ['Q09', 'Q12', 'Q28'] 재학습 후 재평가\n", + " - 대드론일반: 취약 확률 0.87(High), 오답 ['Q18', 'Q19', 'Q25', 'Q30'] -> 기초 문항 ['Q17', 'Q25'] 재학습 후 재평가\n", + "\n", + "[중대별 취약 인원 비율]\n", + "area 경보보고 교전통제 대드론일반 피아식별 형상식별\n", + "unit \n", + "가상1중대 0.10 0.20 0.12 0.15 0.10\n", + "가상2중대 0.25 0.57 0.25 0.38 0.25\n", + "가상3중대 0.30 0.35 0.18 0.32 0.18\n", + "중대 단위 보충 교육 권고: ['가상2중대-교전통제', '가상2중대-피아식별', '가상3중대-경보보고', '가상3중대-교전통제', '가상3중대-피아식별']\n", + "추천은 학습 보조 정보이며 개인 평가·인사 자료로 쓰지 않는다.\n", + "```\n", + "
\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 06. 부대 방공 지식 수준 통계 및 오답 분포 가시화 교수자 대시보드 구현\n", + "**소요시간**: 약 2.5시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "부대 방공 지식 통계 및 오답 분포 가시화 교수자 대시보드를 구현한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **부대 방공 지식 수준과 오답 분포를 한눈에 보여 주는 교수자 대시보드를 구현한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **중대x영역 정답률 히트맵, 중대별 오답 유형 비율, 점수 분포와 합격선, 고난도 문항 목록 등 구성요소 4종 이상을 그린다 과정을 진행하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `src/ui/trn02_dashboard.py (또는 웹 UI 컴포넌트)` 형태의 결과물을 완성한다.\n", + "\n", + "### 🔍 평가 및 검증 기준 (PASS 조건)\n", + "#### 미션 판정\n", + "\n", + "**평가 구분:** 필수\n", + "\n", + "**핵심 평가영역:** 전술 작전 상황판으로서의 직관성 및 인지 부하 감소도\n", + "\n", + "#### 미션 PASS 조건\n", + "\n", + "- 정량 기준: 화면 갱신 주기 1초 이내, UI 응답성 100ms 이내\n", + "\n", + "\n", + "- 진행 기준: 주요 지표 시각화 컴포넌트 4종 이상 렌더링 및 실시간 데이터 연동\n", + "\n", + "\n", + "- 검증 방법: 사용자 인터랙션 테스트 및 모의 데이터 스트림 렌더링 검증\n", + "\n", + "\n", + "#### 채점자 판정 메모\n", + "\n", + "> 단순 코드 실행 여부뿐만 아니라 출력된 수치와 근거 자료가 군 실무 요구사항에 부합하는지 면밀히 확인한다.\n", + "\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "\"\"\"채점·오답 분류 결과로 중대별 영역 정답률, 오답 유형 분포, 점수 분포, 문항 난이도 등 교수자 대시보드 구성요소 4종 이상을 그리고, 새 응답 묶음이 들어올 때 집계 갱신 시간과 필터 조회 응답 시간을 측정하며 경보 하이라이트 대상을 출력한다.\"\"\"\n", + "import difflib\n", + "import re\n", + "import time\n", + "import unicodedata\n", + "import numpy as np\n", + "import pandas as pd\n", + "import matplotlib.pyplot as plt\n", + "from sklearn.feature_extraction.text import TfidfVectorizer\n", + "from sklearn.linear_model import LogisticRegression\n", + "from sklearn.pipeline import make_pipeline\n", + "\n", + "# ---------------------------------------------------------------------------\n", + "# [교육용가상자료] 방공·드론 식별 퀴즈 문항 은행과 가상 응시자 응답 생성기\n", + "# 문항은 일반 원칙 수준의 가상 문항이며 실제 교범·교전규칙 원문이 아니다. 응시자는 가명(S001~)이다.\n", + "# ---------------------------------------------------------------------------\n", + "AREAS = [\"형상식별\", \"피아식별\", \"경보보고\", \"교전통제\", \"대드론일반\"]\n", + "ETYPES = [\"개념 혼동\", \"피아 오인\", \"절차 누락\"]\n", + "POINTS = {1: 2, 2: 3, 3: 5}\n", + "C, F, P = ETYPES\n", + "\n", + "\n", + "def _mc(qid, area, diff, stem, opts, ans, dist):\n", + " # [TODO : 학생 실습 6] 부대 방공 지식 수준 통계 및 오답 분포 가시화 교수자 대시보드 구현 관련 핵심 로직을 구현하세요.\n", + " # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n", + " # <<< 여기에 코드를 작성하세요 >>>\n", + " pass # 작성 후 pass를 제거하세요.\n", + "def _sa(qid, area, diff, stem, accept, wrong):\n", + " # [TODO : 학생 실습 6] 부대 방공 지식 수준 통계 및 오답 분포 가시화 교수자 대시보드 구현 관련 핵심 로직을 구현하세요.\n", + " # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n", + " # <<< 여기에 코드를 작성하세요 >>>\n", + " pass # 작성 후 pass를 제거하세요.\n", + "def _order(qid, area, diff, stem):\n", + " return {\"qid\": qid, \"type\": \"ORDER\", \"area\": area, \"diff\": diff, \"stem\": stem, \"answer\": \"ABCD\"}\n", + "\n", + "\n", + "BANK = [\n", + " _mc(\"Q01\", \"형상식별\", 1, \"꼬리날개 없이 삼각형 날개를 가진 소형 고정익 실루엣은?\", [\"쿼드콥터형\", \"삼각익 고정익형\", \"회전익 헬기형\", \"조류\"], 2, [C, C, F]),\n", + " _mc(\"Q02\", \"형상식별\", 1, \"회전날개 4개가 십자로 배치된 실루엣은?\", [\"고정익 드론형\", \"쿼드콥터형\", \"순항미사일형\", \"탄도미사일형\"], 2, [C, C, C]),\n", + " _mc(\"Q03\", \"형상식별\", 2, \"가늘고 긴 동체 중간에 작은 날개가 붙은 실루엣은?\", [\"순항미사일형\", \"탄도미사일형\", \"민간 경비행기형\", \"조류\"], 1, [C, F, F]),\n", + " _mc(\"Q04\", \"형상식별\", 3, \"원거리라 실루엣 식별이 어려울 때 먼저 할 행동은?\", [\"추정 유형으로 확정 보고\", \"관측 정보를 그대로 보고하고 추가 관측\", \"관측 중단\", \"민간기로 간주\"], 2, [P, P, F]),\n", + " _mc(\"Q05\", \"피아식별\", 2, \"식별 신호 응답이 없는 항공기에 대한 올바른 판단은?\", [\"즉시 적기로 확정\", \"미확인 항공기로 분류하고 추가 확인\", \"아군기로 간주\", \"무시\"], 2, [F, F, P]),\n", + " _mc(\"Q06\", \"피아식별\", 2, \"운항 정보와 일치하는 저속 비행체의 분류는?\", [\"적 드론\", \"민간기 가능성 높음, 추가 확인\", \"순항미사일\", \"아군 정찰기로 확정\"], 2, [F, C, F]),\n", + " _mc(\"Q07\", \"피아식별\", 3, \"아군 드론 운용 공지가 있는 공역에서 미확인 드론을 보면 우선 확인할 것은?\", [\"기상 정보\", \"아군 운용 계획과 식별 신호\", \"대응 준비 상태\", \"주변 지형\"], 2, [C, P, C]),\n", + " _mc(\"Q08\", \"피아식별\", 3, \"조류 무리와 소형 드론 군집 구분에 가장 도움이 되는 정보는?\", [\"크기만 비교\", \"궤적의 규칙성과 열 신호\", \"색상\", \"관측 시각\"], 2, [C, C, C]),\n", + " _mc(\"Q09\", \"경보보고\", 1, \"공중 위협 최초 보고에 포함하지 않아도 되는 것은?\", [\"관측 시각\", \"방향과 거리\", \"추정 유형\", \"관측자 개인 연락처\"], 4, [P, P, P]),\n", + " _mc(\"Q10\", \"경보보고\", 2, \"경보 전파 후 상황이 바뀌면?\", [\"최초 보고 유지\", \"변경 사항을 즉시 추가 보고\", \"상황 종료 후 일괄 보고\", \"개인 판단으로 경보 해제\"], 2, [P, P, P]),\n", + " _mc(\"Q11\", \"경보보고\", 2, \"보고 내용이 불확실할 때 올바른 표현은?\", [\"확정 표현으로 보고\", \"'추정'임을 밝히고 근거와 함께 보고\", \"보고 보류\", \"다른 관측자 보고를 복사\"], 2, [C, P, P]),\n", + " _mc(\"Q12\", \"경보보고\", 1, \"드론 탐지 후 보고 경로로 알맞은 것은?\", [\"인접 부대에만 전파\", \"지휘계통을 따라 보고\", \"개인 메신저로 공유\", \"보고 없이 대응\"], 2, [P, P, P]),\n", + " _mc(\"Q13\", \"교전통제\", 1, \"AI 탐지 결과만으로 대응을 결정할 수 있는가?\", [\"가능하다\", \"인간 지휘관의 승인이 필요하다\", \"신뢰도 90% 이상이면 가능\", \"야간에만 가능\"], 2, [C, C, C]),\n", + " _mc(\"Q14\", \"교전통제\", 2, \"민간 피해 가능성이 확인되면?\", [\"그대로 진행\", \"즉시 보고하고 지휘관 판단을 받는다\", \"조치 후 보고\", \"무시\"], 2, [P, P, F]),\n", + " _mc(\"Q15\", \"교전통제\", 3, \"식별이 불확실한 표적에 대한 원칙은?\", [\"위협으로 간주해 즉시 대응\", \"식별 확인 전까지 승인 절차를 따른다\", \"무조건 무시\", \"다수결로 결정\"], 2, [F, F, C]),\n", + " _mc(\"Q16\", \"교전통제\", 2, \"판단 근거를 기록해야 하는 이유는?\", [\"형식상 필요\", \"사후 검토와 책임 추적\", \"보고 생략\", \"기록 불필요\"], 2, [C, P, P]),\n", + " _mc(\"Q17\", \"대드론일반\", 1, \"대드론 체계의 일반적 처리 순서는?\", [\"대응-탐지-식별\", \"탐지-식별-보고·판단-대응\", \"식별-대응-탐지\", \"보고-탐지-대응\"], 2, [P, P, P]),\n", + " _mc(\"Q18\", \"대드론일반\", 3, \"레이더만으로 조류와 드론 구분이 어려운 이유는?\", [\"반사 신호 크기가 비슷할 수 있어서\", \"레이더는 야간에 못 써서\", \"드론에는 금속이 없어서\", \"조류는 탐지되지 않아서\"], 1, [C, C, C]),\n", + " _mc(\"Q19\", \"대드론일반\", 2, \"전자광학(EO) 센서의 약점이 커지는 조건은?\", [\"맑은 주간\", \"안개·야간\", \"저속 표적\", \"근거리\"], 2, [C, C, C]),\n", + " _mc(\"Q20\", \"대드론일반\", 2, \"경보가 반복해서 오경보로 판명되면?\", [\"경보 장치를 끈다\", \"사례를 기록·보고하고 기준 재검토 요청\", \"무시한다\", \"모두 실제 위협으로 간주\"], 2, [P, P, F]),\n", + " _sa(\"Q21\", \"피아식별\", 1, \"아군·적·민간을 구분하는 절차를 무엇이라 하는가?\", [\"피아식별\", \"IFF\"], {\"표적획득\": C, \"적기판정\": F, \"경계\": C}),\n", + " _sa(\"Q22\", \"형상식별\", 2, \"꼬리날개 없는 삼각형 날개 형태를 무엇이라 하는가?\", [\"삼각익\", \"델타익\", \"delta\"], {\"직선익\": C, \"후퇴익\": C, \"쿼드콥터\": C}),\n", + " _sa(\"Q23\", \"경보보고\", 2, \"불확실한 관측을 보고할 때 붙이는 표현은?\", [\"추정\"], {\"확정\": C, \"확인\": C, \"생략\": P}),\n", + " _sa(\"Q24\", \"교전통제\", 1, \"AI 탐지 결과에 대해 최종 결정을 내리는 주체는?\", [\"지휘관\"], {\"AI\": C, \"운용병\": P, \"레이더\": C}),\n", + " _sa(\"Q25\", \"대드론일반\", 1, \"조류를 드론으로 잘못 경보하는 것을 무엇이라 하는가?\", [\"오경보\", \"오탐\", \"false alarm\"], {\"미탐\": C, \"재경보\": C, \"적기\": F}),\n", + " _sa(\"Q26\", \"피아식별\", 2, \"운항 정보가 확인된 여객기는 어떻게 분류하는가?\", [\"민간기\", \"민항기\"], {\"적기\": F, \"미확인기\": P, \"아군기\": F}),\n", + " _sa(\"Q27\", \"형상식별\", 1, \"회전날개 4개를 가진 드론 형태는?\", [\"쿼드콥터\", \"quadcopter\"], {\"헥사콥터\": C, \"고정익\": C, \"헬기\": F}),\n", + " _sa(\"Q28\", \"경보보고\", 1, \"보고는 무엇을 따라 올라가는가?\", [\"지휘계통\"], {\"인접부대\": P, \"개인메신저\": P, \"통신망\": C}),\n", + " _order(\"Q29\", \"경보보고\", 2, \"최초 보고 순서: A 관측 시각, B 방향·거리, C 추정 유형, D 관측자 조치\"),\n", + " _order(\"Q30\", \"대드론일반\", 2, \"처리 순서: A 탐지, B 식별, C 보고·판단, D 대응\"),\n", + " _order(\"Q31\", \"교전통제\", 3, \"통제 순서: A 식별 확인, B 상급 승인 요청, C 승인 수령, D 조치 기록\"),\n", + " _order(\"Q32\", \"피아식별\", 3, \"확인 순서: A 운항정보 대조, B 식별 신호 확인, C 미확인 분류, D 보고\"),\n", + "]\n", + "QIDX = {q[\"qid\"]: q for q in BANK}\n", + "UNITS = [\"가상1중대\", \"가상2중대\", \"가상3중대\"]\n", + "_CIRC = \"①②③④\"\n", + "PARA = {\"Q21\": \"아군적군 구분\", \"Q22\": \"세모 날개\", \"Q23\": \"추정됨으로 표기\", \"Q24\": \"중대장(지휘관)\", \"Q25\": \"잘못된 경보\",\n", + " \"Q26\": \"민간 여객기\", \"Q27\": \"4발 드론\", \"Q28\": \"지휘 라인\"} # 뜻은 맞지만 인정 답안 목록에 없는 표현\n", + "\n", + "\n", + "def make_soldiers(n_per_unit, seed):\n", + " rng = np.random.default_rng(seed)\n", + " out = []\n", + " for u, unit in enumerate(UNITS):\n", + " base = [0.3, -0.1, 0.0][u]\n", + " for i in range(n_per_unit):\n", + " off = rng.normal(0, 0.5, len(AREAS)) + (np.array([0, 0, 0, -0.6, 0]) if u == 1 else 0)\n", + " out.append({\"sid\": f\"S{len(out) + 1:03d}\", \"unit\": unit, \"theta\": dict(zip(AREAS, base + rng.normal(0, 0.6) + off)),\n", + " \"tend\": dict(zip(ETYPES, rng.dirichlet([1.2, 1.2, 1.2])))})\n", + " return out\n", + "\n", + "\n", + "def p_correct(theta, diff):\n", + " return 1 / (1 + np.exp(-1.7 * (theta - (diff - 2) * 0.9 + 0.9)))\n", + "\n", + "\n", + "def _typo(s, rng):\n", + " if len(s) < 2:\n", + " return s\n", + " i = int(rng.integers(len(s) - 1))\n", + " return s[:i] + s[i + 1] + s[i] + s[i + 2:] if len(s) <= 3 or rng.random() < 0.5 else s[:i] + s[i + 1:]\n", + "\n", + "\n", + "def _fmt_mc(k, rng):\n", + " r = rng.random()\n", + " if r < 0.55:\n", + " return str(k)\n", + " return rng.choice([_CIRC[k - 1], f\"{k}번\", \"ABCD\"[k - 1], \"abcd\"[k - 1] + \")\", f\" {k} \", chr(0xFF10 + k), f\"{k}.\"])\n", + "\n", + "\n", + "def _fmt_sa(s, rng):\n", + " r = rng.random()\n", + " if r < 0.45:\n", + " return s\n", + " if r < 0.6:\n", + " return \" \".join(s) if len(s) <= 4 else s[:2] + \" \" + s[2:]\n", + " if r < 0.72:\n", + " return s + rng.choice([\"입니다\", \"이다\", \".\", \"임\"])\n", + " if r < 0.8:\n", + " return s.upper() if s.isascii() else \"\".join(chr(ord(ch) + 0xFEE0) if \"!\" <= ch <= \"~\" else ch for ch in s) + \" \"\n", + " return _typo(s, rng)\n", + "\n", + "\n", + "def answer(q, sol, rng):\n", + " \"\"\"한 문항 응답: (원본 응답 문자열, 의도한 정답 여부, 의도한 답, 오답 유형).\"\"\"\n", + " ok = rng.random() < p_correct(sol[\"theta\"][q[\"area\"]], q[\"diff\"])\n", + " if q[\"type\"] == \"MC\":\n", + " if ok:\n", + " k, et = q[\"answer\"], None\n", + " else:\n", + " ks = list(q[\"dist\"]); w = np.array([sol[\"tend\"][q[\"dist\"][k]] for k in ks])\n", + " k = int(rng.choice(ks, p=w / w.sum())); et = q[\"dist\"][k]\n", + " return _fmt_mc(k, rng), ok, str(k), et\n", + " if q[\"type\"] == \"SA\":\n", + " if ok and rng.random() < 0.06:\n", + " return PARA[q[\"qid\"]], ok, PARA[q[\"qid\"]], None\n", + " if ok:\n", + " s, et = str(rng.choice(q[\"accept\"])), None\n", + " else:\n", + " ws = list(q[\"wrong\"]); w = np.array([sol[\"tend\"][q[\"wrong\"][x]] for x in ws])\n", + " s = str(rng.choice(ws, p=w / w.sum())); et = q[\"wrong\"][s]\n", + " return _fmt_sa(s, rng), ok, s, et\n", + " if ok:\n", + " s = \"ABCD\"\n", + " elif rng.random() < 0.5:\n", + " s = \"ABCD\".replace(\"ABCD\"[rng.integers(1, 4)], \"\") # 단계 하나 빠뜨림\n", + " else:\n", + " i = int(rng.integers(0, 3)); s = list(\"ABCD\"); s[i], s[i + 1] = s[i + 1], s[i]; s = \"\".join(s) # 순서 뒤바뀜\n", + " raw = s if rng.random() < 0.6 else rng.choice([\"-\".join(s), \" \".join(s), s.lower(), \",\".join(s)])\n", + " return raw, ok, s, (None if ok else P)\n", + "\n", + "\n", + "def make_responses(n_per_unit=40, seed=7, blank=0.02):\n", + " \"\"\"응시자 x 문항 응답표(DataFrame)를 만든다. truth_* 열은 교관 기준 정답(평가용)이다.\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " rows = []\n", + " for sol in make_soldiers(n_per_unit, seed):\n", + " for q in BANK:\n", + " raw, ok, intended, et = answer(q, sol, rng)\n", + " if rng.random() < blank:\n", + " raw, ok, intended, et = \"\", False, \"\", None\n", + " rows.append({\"rid\": f\"R{len(rows):05d}\", \"sid\": sol[\"sid\"], \"unit\": sol[\"unit\"], \"qid\": q[\"qid\"], \"raw\": raw,\n", + " \"truth_correct\": ok, \"truth_answer\": intended, \"truth_etype\": et})\n", + " return pd.DataFrame(rows)\n", + "# ---------------------------------------------------------------------------\n", + "\n", + "# ---- 응답 정제·파싱·채점 공통 함수 ----\n", + "TAILS = (\"입니다\", \"이다\", \"임\")\n", + "\n", + "\n", + "def norm_text(s):\n", + " \"\"\"전각->반각(NFKC), 소문자, 공백·문장부호 제거, 끝맺음 어미 제거.\"\"\"\n", + " s = unicodedata.normalize(\"NFKC\", str(s)).strip().lower()\n", + " s = re.sub(r\"[\\s\\.\\,\\-\\(\\)·]\", \"\", s)\n", + " for t in TAILS:\n", + " if s.endswith(t) and len(s) > len(t) + 1:\n", + " s = s[:-len(t)]\n", + " return s\n", + "\n", + "\n", + "def parse(q, raw):\n", + " \"\"\"원본 응답 -> (정제값, 상태). 상태: 정상 / 미응답 / 형식오류\"\"\"\n", + " if raw is None or str(raw).strip() == \"\":\n", + " return \"\", \"미응답\"\n", + " s = unicodedata.normalize(\"NFKC\", str(raw)).strip()\n", + " if q[\"type\"] == \"MC\":\n", + " t = re.sub(r\"[\\s번\\.\\)]\", \"\", s).lower()\n", + " t = {\"a\": \"1\", \"b\": \"2\", \"c\": \"3\", \"d\": \"4\"}.get(t, t)\n", + " return (t, \"정상\") if t in (\"1\", \"2\", \"3\", \"4\") else (t, \"형식오류\")\n", + " if q[\"type\"] == \"ORDER\":\n", + " t = re.sub(r\"[^A-Za-z]\", \"\", s).upper()\n", + " return (t, \"정상\") if t and set(t) <= set(\"ABCD\") and len(set(t)) == len(t) else (t, \"형식오류\")\n", + " return norm_text(s), \"정상\"\n", + "\n", + "\n", + "def grade_exact(q, val):\n", + " if q[\"type\"] == \"MC\":\n", + " return val == str(q[\"answer\"])\n", + " if q[\"type\"] == \"ORDER\":\n", + " return val == q[\"answer\"]\n", + " return val in {norm_text(a) for a in q[\"accept\"]}\n", + "\n", + "\n", + "def grade_fuzzy(q, val, cut=0.75):\n", + " \"\"\"단답형은 오탈자(글자 순서 바뀜·한 글자 누락)를 허용(유사도 cut 이상)하되, 알려진 오답과 더 가까우면 오답으로 본다.\"\"\"\n", + " if grade_exact(q, val) or q[\"type\"] != \"SA\" or len(val) < 2:\n", + " return grade_exact(q, val)\n", + " best = max(1.0 if sorted(val) == sorted(norm_text(a)) else difflib.SequenceMatcher(None, val, norm_text(a)).ratio() for a in q[\"accept\"])\n", + " worst = max(difflib.SequenceMatcher(None, val, norm_text(w)).ratio() for w in q[\"wrong\"])\n", + " return best >= cut and best > worst\n", + "\n", + "\n", + "def score_of(q, correct):\n", + " return POINTS[q[\"diff\"]] if correct else 0\n", + "\n", + "# ---- 오답 유형 분류기(규칙 + 문자 n-gram 로지스틱 회귀) ----\n", + "SEED = {\"피아 오인\": [\"적기\", \"적 드론\", \"아군기\", \"적 헬기\", \"적기판정\", \"헬기\", \"적 항공기 확정\", \"아군 드론\"],\n", + " \"절차 누락\": [\"생략\", \"인접부대\", \"개인메신저\", \"보고 안 함\", \"운용병\", \"미확인기\", \"단계 건너뜀\", \"직접 전파\"],\n", + " \"개념 혼동\": [\"직선익\", \"후퇴익\", \"헥사콥터\", \"미탐\", \"재경보\", \"확정\", \"확인\", \"레이더\", \"AI\", \"표적획득\", \"경계\", \"통신망\", \"고정익\"]}\n", + "\n", + "\n", + "def build_text_model():\n", + " texts, labels = [], []\n", + " for q in BANK:\n", + " if q[\"type\"] == \"SA\":\n", + " for w, et in q[\"wrong\"].items():\n", + " texts.append(w); labels.append(et)\n", + " for et, ws in SEED.items():\n", + " texts += ws; labels += [et] * len(ws)\n", + " m = make_pipeline(TfidfVectorizer(analyzer=\"char_wb\", ngram_range=(1, 3)), LogisticRegression(max_iter=2000, C=5))\n", + " return m.fit([norm_text(t) for t in texts], labels)\n", + "\n", + "\n", + "def classify_error(q, val, text_model):\n", + " \"\"\"오답 1건의 유형과 판단 근거(rule/text)를 돌려준다.\"\"\"\n", + " if q[\"type\"] == \"MC\":\n", + " return q[\"dist\"].get(int(val), \"개념 혼동\") if val in \"1234\" and val else \"개념 혼동\", \"보기 매핑\"\n", + " if q[\"type\"] == \"ORDER\":\n", + " return \"절차 누락\", (\"단계 누락\" if len(val) < 4 else \"순서 뒤바뀜\")\n", + " known = {norm_text(w): et for w, et in q[\"wrong\"].items()}\n", + " if val in known:\n", + " return known[val], \"오답 목록\"\n", + " near = max(known, key=lambda w: difflib.SequenceMatcher(None, val, w).ratio())\n", + " if difflib.SequenceMatcher(None, val, near).ratio() >= 0.75 or sorted(val) == sorted(near):\n", + " return known[near], \"오답 목록(오탈자)\"\n", + " return str(text_model.predict([val])[0]), \"문자 모델\"\n", + "\n", + "PASS_RATE = 0.7\n", + "ALERT_AREA = 0.55 # 중대 영역 정답률이 이보다 낮으면 경보 하이라이트\n", + "TM = build_text_model()\n", + "\n", + "\n", + "def grade_batch(df):\n", + " \"\"\"원본 응답 묶음 -> 정제값·정답 여부·점수·오답 유형 열 추가.\"\"\"\n", + " out = df.copy()\n", + " pv = [parse(QIDX[q], r) for q, r in zip(out[\"qid\"], out[\"raw\"])]\n", + " out[\"val\"], out[\"status\"] = [p[0] for p in pv], [p[1] for p in pv]\n", + " out[\"area\"] = out[\"qid\"].map(lambda q: QIDX[q][\"area\"])\n", + " out[\"correct\"] = [s == \"정상\" and grade_fuzzy(QIDX[q], v) for q, v, s in zip(out[\"qid\"], out[\"val\"], out[\"status\"])]\n", + " out[\"score\"] = [score_of(QIDX[q], c) for q, c in zip(out[\"qid\"], out[\"correct\"])]\n", + " out[\"etype\"] = [None if c or s != \"정상\" else classify_error(QIDX[q], v, TM)[0]\n", + " for q, v, c, s in zip(out[\"qid\"], out[\"val\"], out[\"correct\"], out[\"status\"])]\n", + " return out\n", + "\n", + "\n", + "class Board:\n", + " \"\"\"대시보드 상태: 누적 집계만 갱신해 매번 전체를 다시 계산하지 않는다.\"\"\"\n", + " def __init__(self):\n", + " self.data = pd.DataFrame()\n", + "\n", + " def update(self, batch):\n", + " self.data = pd.concat([self.data, grade_batch(batch)], ignore_index=True)\n", + " d = self.data\n", + " self.area = d.pivot_table(index=\"unit\", columns=\"area\", values=\"correct\", aggfunc=\"mean\")\n", + " self.etype = d.dropna(subset=[\"etype\"]).groupby([\"unit\", \"etype\"]).size().unstack(fill_value=0)\n", + " self.person = d.groupby([\"sid\", \"unit\"])[\"score\"].sum().reset_index()\n", + " self.item = d.groupby(\"qid\")[\"correct\"].mean()\n", + "\n", + " def query(self, unit=None, area=None):\n", + " d = self.data\n", + " if unit:\n", + " d = d[d[\"unit\"] == unit]\n", + " if area:\n", + " d = d[d[\"area\"] == area]\n", + " return d.groupby(\"qid\")[\"correct\"].mean().sort_values().head(3)\n", + "\n", + "\n", + "full = make_responses(40, seed=7)\n", + "FULL_SCORE = sum(POINTS[q[\"diff\"]] for q in BANK)\n", + "board = Board()\n", + "# 1) 실시간 갱신 모의: 응시자 응답이 30명 단위로 4번 들어온다\n", + "times = []\n", + "for k, sids in enumerate(np.array_split(full[\"sid\"].unique(), 4), 1):\n", + " t0 = time.perf_counter()\n", + " board.update(full[full[\"sid\"].isin(sids)])\n", + " times.append((time.perf_counter() - t0) * 1000)\n", + " print(f\"갱신 {k}: 누적 응답 {len(board.data):,}건, 집계 갱신 {times[-1]:.0f} ms\")\n", + "print(f\"최대 갱신 시간 {max(times):.0f} ms (목표 1초 이내: {'달성' if max(times) < 1000 else '미달'})\")\n", + "\n", + "# 2) 필터 조회 응답성(교수자가 중대·영역을 바꿔 가며 조회)\n", + "q_times = []\n", + "for u in UNITS + [None]:\n", + " for a in AREAS + [None]:\n", + " t0 = time.perf_counter(); board.query(u, a); q_times.append((time.perf_counter() - t0) * 1000)\n", + "print(f\"필터 조회 {len(q_times)}회, 평균 {np.mean(q_times):.1f} ms, 최대 {max(q_times):.1f} ms (목표 100 ms 이내: {'달성' if max(q_times) < 100 else '미달'})\")\n", + "\n", + "# 3) 대시보드 구성요소 4종\n", + "fig, ax = plt.subplots(2, 2, figsize=(12, 8))\n", + "AREA_EN = {\"형상식별\": \"Shape\", \"피아식별\": \"IFF\", \"경보보고\": \"Report\", \"교전통제\": \"Control\", \"대드론일반\": \"C-UAS\"}\n", + "UNIT_EN = {\"가상1중대\": \"Co.1\", \"가상2중대\": \"Co.2\", \"가상3중대\": \"Co.3\"}\n", + "ET_EN = {\"개념 혼동\": \"Concept\", \"피아 오인\": \"IFF error\", \"절차 누락\": \"Procedure\"}\n", + "A = board.area[AREAS]\n", + "im = ax[0, 0].imshow(A.values, cmap=\"RdYlGn\", vmin=0.3, vmax=1.0)\n", + "ax[0, 0].set_xticks(range(len(AREAS)), [AREA_EN[a] for a in AREAS]); ax[0, 0].set_yticks(range(3), [UNIT_EN[u] for u in A.index])\n", + "for i in range(A.shape[0]):\n", + " for j in range(A.shape[1]):\n", + " v = A.values[i, j]\n", + " ax[0, 0].text(j, i, f\"{v:.2f}\", ha=\"center\", va=\"center\", fontweight=\"bold\" if v < ALERT_AREA else \"normal\",\n", + " color=\"white\" if v < ALERT_AREA else \"black\")\n", + "ax[0, 0].set_title(\"Accuracy by company x area (bold = alert)\"); fig.colorbar(im, ax=ax[0, 0])\n", + "E = board.etype.rename(index=UNIT_EN, columns=ET_EN)\n", + "E.div(E.sum(axis=1), axis=0).plot.bar(stacked=True, ax=ax[0, 1], rot=0)\n", + "ax[0, 1].set_title(\"Error type share by company\"); ax[0, 1].set_ylabel(\"share\")\n", + "for u in UNITS:\n", + " ax[1, 0].hist(board.person.loc[board.person[\"unit\"] == u, \"score\"], bins=12, alpha=0.5, label=UNIT_EN[u])\n", + "ax[1, 0].axvline(FULL_SCORE * PASS_RATE, color=\"k\", ls=\"--\", label=\"pass line\"); ax[1, 0].legend()\n", + "ax[1, 0].set_title(\"Score distribution\"); ax[1, 0].set_xlabel(\"score\")\n", + "it = board.item.sort_values()\n", + "ax[1, 1].barh(it.index[:10], it.values[:10], color=\"tab:red\"); ax[1, 1].set_xlim(0, 1)\n", + "ax[1, 1].set_title(\"10 hardest items (accuracy)\")\n", + "plt.tight_layout(); plt.show()\n", + "print(\"대시보드 구성요소 4종 렌더링: 중대x영역 정답률, 오답 유형 비율, 점수 분포, 고난도 문항\")\n", + "\n", + "# 4) 요약 지표와 경보 하이라이트\n", + "p = board.person.assign(passed=board.person[\"score\"] >= FULL_SCORE * PASS_RATE)\n", + "print(\"\\n[중대별 요약]\")\n", + "print(p.groupby(\"unit\").agg(응시=(\"sid\", \"size\"), 평균점수=(\"score\", \"mean\"), 합격률=(\"passed\", \"mean\")).round(2).to_string())\n", + "alerts = [(u, a, A.loc[u, a]) for u in A.index for a in AREAS if A.loc[u, a] < ALERT_AREA]\n", + "print(f\"\\n[경보 하이라이트] 영역 정답률 {ALERT_AREA} 미만 {len(alerts)}건\")\n", + "for u, a, v in sorted(alerts, key=lambda x: x[2]):\n", + " print(f\" - {u} {a}: 정답률 {v:.2f}, 최저 문항 {list(board.query(u, a).index)}\")\n", + "top = board.etype.sum().sort_values(ascending=False)\n", + "print(\"부대 전체 오답 유형:\", {k: int(v) for k, v in top.items()})\n", + "print(\"야간 교육장 사용 시 plt.style.use('dark_background')로 어두운 화면 모드를 적용할 수 있다.\")\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 06 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + "갱신 1: 누적 응답 960건, 집계 갱신 39 ms\n", + "갱신 2: 누적 응답 1,920건, 집계 갱신 35 ms\n", + "갱신 3: 누적 응답 2,880건, 집계 갱신 39 ms\n", + "갱신 4: 누적 응답 3,840건, 집계 갱신 38 ms\n", + "최대 갱신 시간 39 ms (목표 1초 이내: 달성)\n", + "필터 조회 24회, 평균 2.7 ms, 최대 3.4 ms (목표 100 ms 이내: 달성)\n", + "대시보드 구성요소 4종 렌더링: 중대x영역 정답률, 오답 유형 비율, 점수 분포, 고난도 문항\n", + "\n", + "[중대별 요약]\n", + " 응시 평균점수 합격률\n", + "unit \n", + "가상1중대 40 75.40 0.68\n", + "가상2중대 40 58.55 0.28\n", + "가상3중대 40 68.18 0.57\n", + "\n", + "[경보 하이라이트] 영역 정답률 0.55 미만 1건\n", + " - 가상2중대 교전통제: 정답률 0.50, 최저 문항 ['Q31', 'Q15', 'Q14']\n", + "부대 전체 오답 유형: {'절차 누락': 419, '개념 혼동': 367, '피아 오인': 186}\n", + "야간 교육장 사용 시 plt.style.use('dark_background')로 어두운 화면 모드를 적용할 수 있다.\n", + "```\n", + "
\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 07. 이의 제기 문항 및 서술형 답변에 대한 교관 수동 채점(HITL) 검토 인터페이스 설계\n", + "**소요시간**: 약 3.0시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "이의 제기 문항 및 서술형 답변에 대한 교관 수동 채점(HITL) 인터페이스를 설계한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **이의 제기 문항과 서술형 답변을 교관이 검토·확정하는 HITL 흐름을 설계하고, 자동 채점이 점수를 단정하지 못하게 한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **서술형은 핵심 요소 키워드로 점수를 '제안'만 하고, 이의 제기와 함께 합격선 근처·저신뢰 답안부터 검토하는 우선순위 대기열에 올린다 과정을 진행하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `src/hitl/trn02_approval_workflow.py 및 docs/trn02_hitl_guide.md` 형태의 결과물을 완성한다.\n", + "\n", + "### 🔍 평가 및 검증 기준 (PASS 조건)\n", + "#### 미션 판정\n", + "\n", + "**평가 구분:** 필수\n", + "\n", + "**핵심 평가영역:** 지휘 책임 분계선 명확화 및 성급한 자동 단정 방지 체계 부합성\n", + "\n", + "#### 미션 PASS 조건\n", + "\n", + "- 정량 기준: 인간 검토 큐(Queue) 대기시간 및 승인/반려 처리 로깅 성공률 100%\n", + "\n", + "\n", + "- 진행 기준: 승인/반려 인터페이스 동작 및 감사 로그(Audit Log) 생성 검증\n", + "\n", + "\n", + "- 검증 방법: 모의 조작자 승인/반려 시뮬레이션 및 데이터 무결성 검증\n", + "\n", + "\n", + "#### 채점자 판정 메모\n", + "\n", + "> 단순 코드 실행 여부뿐만 아니라 출력된 수치와 근거 자료가 군 실무 요구사항에 부합하는지 면밀히 확인한다.\n", + "\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "\"\"\"이의 제기된 단답형 문항과 서술형 답변을 교관 검토 대기열(HITL)에 올려, 자동 채점은 '제안'으로만 두고 교관의 인정·기각·수정 결정과 사유를 해시 체인 감사 로그에 남기는 검토 흐름을 설계한다. 합격선 근처 응시자를 먼저 검토하는 우선순위 큐의 대기시간, 로깅 성공률, 무결성 검증을 확인한다.\"\"\"\n", + "import difflib\n", + "import hashlib\n", + "import heapq\n", + "import json\n", + "import re\n", + "import unicodedata\n", + "import numpy as np\n", + "import pandas as pd\n", + "\n", + "# ---------------------------------------------------------------------------\n", + "# [교육용가상자료] 방공·드론 식별 퀴즈 문항 은행과 가상 응시자 응답 생성기\n", + "# 문항은 일반 원칙 수준의 가상 문항이며 실제 교범·교전규칙 원문이 아니다. 응시자는 가명(S001~)이다.\n", + "# ---------------------------------------------------------------------------\n", + "AREAS = [\"형상식별\", \"피아식별\", \"경보보고\", \"교전통제\", \"대드론일반\"]\n", + "ETYPES = [\"개념 혼동\", \"피아 오인\", \"절차 누락\"]\n", + "POINTS = {1: 2, 2: 3, 3: 5}\n", + "C, F, P = ETYPES\n", + "\n", + "\n", + "def _mc(qid, area, diff, stem, opts, ans, dist):\n", + " # [TODO : 학생 실습 7] 이의 제기 문항 및 서술형 답변에 대한 교관 수동 채점(HITL) 검토 인터페이스 설계 관련 핵심 로직을 구현하세요.\n", + " # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n", + " # <<< 여기에 코드를 작성하세요 >>>\n", + " pass # 작성 후 pass를 제거하세요.\n", + "def _sa(qid, area, diff, stem, accept, wrong):\n", + " # [TODO : 학생 실습 7] 이의 제기 문항 및 서술형 답변에 대한 교관 수동 채점(HITL) 검토 인터페이스 설계 관련 핵심 로직을 구현하세요.\n", + " # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n", + " # <<< 여기에 코드를 작성하세요 >>>\n", + " pass # 작성 후 pass를 제거하세요.\n", + "def _order(qid, area, diff, stem):\n", + " return {\"qid\": qid, \"type\": \"ORDER\", \"area\": area, \"diff\": diff, \"stem\": stem, \"answer\": \"ABCD\"}\n", + "\n", + "\n", + "BANK = [\n", + " _mc(\"Q01\", \"형상식별\", 1, \"꼬리날개 없이 삼각형 날개를 가진 소형 고정익 실루엣은?\", [\"쿼드콥터형\", \"삼각익 고정익형\", \"회전익 헬기형\", \"조류\"], 2, [C, C, F]),\n", + " _mc(\"Q02\", \"형상식별\", 1, \"회전날개 4개가 십자로 배치된 실루엣은?\", [\"고정익 드론형\", \"쿼드콥터형\", \"순항미사일형\", \"탄도미사일형\"], 2, [C, C, C]),\n", + " _mc(\"Q03\", \"형상식별\", 2, \"가늘고 긴 동체 중간에 작은 날개가 붙은 실루엣은?\", [\"순항미사일형\", \"탄도미사일형\", \"민간 경비행기형\", \"조류\"], 1, [C, F, F]),\n", + " _mc(\"Q04\", \"형상식별\", 3, \"원거리라 실루엣 식별이 어려울 때 먼저 할 행동은?\", [\"추정 유형으로 확정 보고\", \"관측 정보를 그대로 보고하고 추가 관측\", \"관측 중단\", \"민간기로 간주\"], 2, [P, P, F]),\n", + " _mc(\"Q05\", \"피아식별\", 2, \"식별 신호 응답이 없는 항공기에 대한 올바른 판단은?\", [\"즉시 적기로 확정\", \"미확인 항공기로 분류하고 추가 확인\", \"아군기로 간주\", \"무시\"], 2, [F, F, P]),\n", + " _mc(\"Q06\", \"피아식별\", 2, \"운항 정보와 일치하는 저속 비행체의 분류는?\", [\"적 드론\", \"민간기 가능성 높음, 추가 확인\", \"순항미사일\", \"아군 정찰기로 확정\"], 2, [F, C, F]),\n", + " _mc(\"Q07\", \"피아식별\", 3, \"아군 드론 운용 공지가 있는 공역에서 미확인 드론을 보면 우선 확인할 것은?\", [\"기상 정보\", \"아군 운용 계획과 식별 신호\", \"대응 준비 상태\", \"주변 지형\"], 2, [C, P, C]),\n", + " _mc(\"Q08\", \"피아식별\", 3, \"조류 무리와 소형 드론 군집 구분에 가장 도움이 되는 정보는?\", [\"크기만 비교\", \"궤적의 규칙성과 열 신호\", \"색상\", \"관측 시각\"], 2, [C, C, C]),\n", + " _mc(\"Q09\", \"경보보고\", 1, \"공중 위협 최초 보고에 포함하지 않아도 되는 것은?\", [\"관측 시각\", \"방향과 거리\", \"추정 유형\", \"관측자 개인 연락처\"], 4, [P, P, P]),\n", + " _mc(\"Q10\", \"경보보고\", 2, \"경보 전파 후 상황이 바뀌면?\", [\"최초 보고 유지\", \"변경 사항을 즉시 추가 보고\", \"상황 종료 후 일괄 보고\", \"개인 판단으로 경보 해제\"], 2, [P, P, P]),\n", + " _mc(\"Q11\", \"경보보고\", 2, \"보고 내용이 불확실할 때 올바른 표현은?\", [\"확정 표현으로 보고\", \"'추정'임을 밝히고 근거와 함께 보고\", \"보고 보류\", \"다른 관측자 보고를 복사\"], 2, [C, P, P]),\n", + " _mc(\"Q12\", \"경보보고\", 1, \"드론 탐지 후 보고 경로로 알맞은 것은?\", [\"인접 부대에만 전파\", \"지휘계통을 따라 보고\", \"개인 메신저로 공유\", \"보고 없이 대응\"], 2, [P, P, P]),\n", + " _mc(\"Q13\", \"교전통제\", 1, \"AI 탐지 결과만으로 대응을 결정할 수 있는가?\", [\"가능하다\", \"인간 지휘관의 승인이 필요하다\", \"신뢰도 90% 이상이면 가능\", \"야간에만 가능\"], 2, [C, C, C]),\n", + " _mc(\"Q14\", \"교전통제\", 2, \"민간 피해 가능성이 확인되면?\", [\"그대로 진행\", \"즉시 보고하고 지휘관 판단을 받는다\", \"조치 후 보고\", \"무시\"], 2, [P, P, F]),\n", + " _mc(\"Q15\", \"교전통제\", 3, \"식별이 불확실한 표적에 대한 원칙은?\", [\"위협으로 간주해 즉시 대응\", \"식별 확인 전까지 승인 절차를 따른다\", \"무조건 무시\", \"다수결로 결정\"], 2, [F, F, C]),\n", + " _mc(\"Q16\", \"교전통제\", 2, \"판단 근거를 기록해야 하는 이유는?\", [\"형식상 필요\", \"사후 검토와 책임 추적\", \"보고 생략\", \"기록 불필요\"], 2, [C, P, P]),\n", + " _mc(\"Q17\", \"대드론일반\", 1, \"대드론 체계의 일반적 처리 순서는?\", [\"대응-탐지-식별\", \"탐지-식별-보고·판단-대응\", \"식별-대응-탐지\", \"보고-탐지-대응\"], 2, [P, P, P]),\n", + " _mc(\"Q18\", \"대드론일반\", 3, \"레이더만으로 조류와 드론 구분이 어려운 이유는?\", [\"반사 신호 크기가 비슷할 수 있어서\", \"레이더는 야간에 못 써서\", \"드론에는 금속이 없어서\", \"조류는 탐지되지 않아서\"], 1, [C, C, C]),\n", + " _mc(\"Q19\", \"대드론일반\", 2, \"전자광학(EO) 센서의 약점이 커지는 조건은?\", [\"맑은 주간\", \"안개·야간\", \"저속 표적\", \"근거리\"], 2, [C, C, C]),\n", + " _mc(\"Q20\", \"대드론일반\", 2, \"경보가 반복해서 오경보로 판명되면?\", [\"경보 장치를 끈다\", \"사례를 기록·보고하고 기준 재검토 요청\", \"무시한다\", \"모두 실제 위협으로 간주\"], 2, [P, P, F]),\n", + " _sa(\"Q21\", \"피아식별\", 1, \"아군·적·민간을 구분하는 절차를 무엇이라 하는가?\", [\"피아식별\", \"IFF\"], {\"표적획득\": C, \"적기판정\": F, \"경계\": C}),\n", + " _sa(\"Q22\", \"형상식별\", 2, \"꼬리날개 없는 삼각형 날개 형태를 무엇이라 하는가?\", [\"삼각익\", \"델타익\", \"delta\"], {\"직선익\": C, \"후퇴익\": C, \"쿼드콥터\": C}),\n", + " _sa(\"Q23\", \"경보보고\", 2, \"불확실한 관측을 보고할 때 붙이는 표현은?\", [\"추정\"], {\"확정\": C, \"확인\": C, \"생략\": P}),\n", + " _sa(\"Q24\", \"교전통제\", 1, \"AI 탐지 결과에 대해 최종 결정을 내리는 주체는?\", [\"지휘관\"], {\"AI\": C, \"운용병\": P, \"레이더\": C}),\n", + " _sa(\"Q25\", \"대드론일반\", 1, \"조류를 드론으로 잘못 경보하는 것을 무엇이라 하는가?\", [\"오경보\", \"오탐\", \"false alarm\"], {\"미탐\": C, \"재경보\": C, \"적기\": F}),\n", + " _sa(\"Q26\", \"피아식별\", 2, \"운항 정보가 확인된 여객기는 어떻게 분류하는가?\", [\"민간기\", \"민항기\"], {\"적기\": F, \"미확인기\": P, \"아군기\": F}),\n", + " _sa(\"Q27\", \"형상식별\", 1, \"회전날개 4개를 가진 드론 형태는?\", [\"쿼드콥터\", \"quadcopter\"], {\"헥사콥터\": C, \"고정익\": C, \"헬기\": F}),\n", + " _sa(\"Q28\", \"경보보고\", 1, \"보고는 무엇을 따라 올라가는가?\", [\"지휘계통\"], {\"인접부대\": P, \"개인메신저\": P, \"통신망\": C}),\n", + " _order(\"Q29\", \"경보보고\", 2, \"최초 보고 순서: A 관측 시각, B 방향·거리, C 추정 유형, D 관측자 조치\"),\n", + " _order(\"Q30\", \"대드론일반\", 2, \"처리 순서: A 탐지, B 식별, C 보고·판단, D 대응\"),\n", + " _order(\"Q31\", \"교전통제\", 3, \"통제 순서: A 식별 확인, B 상급 승인 요청, C 승인 수령, D 조치 기록\"),\n", + " _order(\"Q32\", \"피아식별\", 3, \"확인 순서: A 운항정보 대조, B 식별 신호 확인, C 미확인 분류, D 보고\"),\n", + "]\n", + "QIDX = {q[\"qid\"]: q for q in BANK}\n", + "UNITS = [\"가상1중대\", \"가상2중대\", \"가상3중대\"]\n", + "_CIRC = \"①②③④\"\n", + "PARA = {\"Q21\": \"아군적군 구분\", \"Q22\": \"세모 날개\", \"Q23\": \"추정됨으로 표기\", \"Q24\": \"중대장(지휘관)\", \"Q25\": \"잘못된 경보\",\n", + " \"Q26\": \"민간 여객기\", \"Q27\": \"4발 드론\", \"Q28\": \"지휘 라인\"} # 뜻은 맞지만 인정 답안 목록에 없는 표현\n", + "\n", + "\n", + "def make_soldiers(n_per_unit, seed):\n", + " rng = np.random.default_rng(seed)\n", + " out = []\n", + " for u, unit in enumerate(UNITS):\n", + " base = [0.3, -0.1, 0.0][u]\n", + " for i in range(n_per_unit):\n", + " off = rng.normal(0, 0.5, len(AREAS)) + (np.array([0, 0, 0, -0.6, 0]) if u == 1 else 0)\n", + " out.append({\"sid\": f\"S{len(out) + 1:03d}\", \"unit\": unit, \"theta\": dict(zip(AREAS, base + rng.normal(0, 0.6) + off)),\n", + " \"tend\": dict(zip(ETYPES, rng.dirichlet([1.2, 1.2, 1.2])))})\n", + " return out\n", + "\n", + "\n", + "def p_correct(theta, diff):\n", + " return 1 / (1 + np.exp(-1.7 * (theta - (diff - 2) * 0.9 + 0.9)))\n", + "\n", + "\n", + "def _typo(s, rng):\n", + " if len(s) < 2:\n", + " return s\n", + " i = int(rng.integers(len(s) - 1))\n", + " return s[:i] + s[i + 1] + s[i] + s[i + 2:] if len(s) <= 3 or rng.random() < 0.5 else s[:i] + s[i + 1:]\n", + "\n", + "\n", + "def _fmt_mc(k, rng):\n", + " r = rng.random()\n", + " if r < 0.55:\n", + " return str(k)\n", + " return rng.choice([_CIRC[k - 1], f\"{k}번\", \"ABCD\"[k - 1], \"abcd\"[k - 1] + \")\", f\" {k} \", chr(0xFF10 + k), f\"{k}.\"])\n", + "\n", + "\n", + "def _fmt_sa(s, rng):\n", + " r = rng.random()\n", + " if r < 0.45:\n", + " return s\n", + " if r < 0.6:\n", + " return \" \".join(s) if len(s) <= 4 else s[:2] + \" \" + s[2:]\n", + " if r < 0.72:\n", + " return s + rng.choice([\"입니다\", \"이다\", \".\", \"임\"])\n", + " if r < 0.8:\n", + " return s.upper() if s.isascii() else \"\".join(chr(ord(ch) + 0xFEE0) if \"!\" <= ch <= \"~\" else ch for ch in s) + \" \"\n", + " return _typo(s, rng)\n", + "\n", + "\n", + "def answer(q, sol, rng):\n", + " \"\"\"한 문항 응답: (원본 응답 문자열, 의도한 정답 여부, 의도한 답, 오답 유형).\"\"\"\n", + " ok = rng.random() < p_correct(sol[\"theta\"][q[\"area\"]], q[\"diff\"])\n", + " if q[\"type\"] == \"MC\":\n", + " if ok:\n", + " k, et = q[\"answer\"], None\n", + " else:\n", + " ks = list(q[\"dist\"]); w = np.array([sol[\"tend\"][q[\"dist\"][k]] for k in ks])\n", + " k = int(rng.choice(ks, p=w / w.sum())); et = q[\"dist\"][k]\n", + " return _fmt_mc(k, rng), ok, str(k), et\n", + " if q[\"type\"] == \"SA\":\n", + " if ok and rng.random() < 0.06:\n", + " return PARA[q[\"qid\"]], ok, PARA[q[\"qid\"]], None\n", + " if ok:\n", + " s, et = str(rng.choice(q[\"accept\"])), None\n", + " else:\n", + " ws = list(q[\"wrong\"]); w = np.array([sol[\"tend\"][q[\"wrong\"][x]] for x in ws])\n", + " s = str(rng.choice(ws, p=w / w.sum())); et = q[\"wrong\"][s]\n", + " return _fmt_sa(s, rng), ok, s, et\n", + " if ok:\n", + " s = \"ABCD\"\n", + " elif rng.random() < 0.5:\n", + " s = \"ABCD\".replace(\"ABCD\"[rng.integers(1, 4)], \"\") # 단계 하나 빠뜨림\n", + " else:\n", + " i = int(rng.integers(0, 3)); s = list(\"ABCD\"); s[i], s[i + 1] = s[i + 1], s[i]; s = \"\".join(s) # 순서 뒤바뀜\n", + " raw = s if rng.random() < 0.6 else rng.choice([\"-\".join(s), \" \".join(s), s.lower(), \",\".join(s)])\n", + " return raw, ok, s, (None if ok else P)\n", + "\n", + "\n", + "def make_responses(n_per_unit=40, seed=7, blank=0.02):\n", + " \"\"\"응시자 x 문항 응답표(DataFrame)를 만든다. truth_* 열은 교관 기준 정답(평가용)이다.\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " rows = []\n", + " for sol in make_soldiers(n_per_unit, seed):\n", + " for q in BANK:\n", + " raw, ok, intended, et = answer(q, sol, rng)\n", + " if rng.random() < blank:\n", + " raw, ok, intended, et = \"\", False, \"\", None\n", + " rows.append({\"rid\": f\"R{len(rows):05d}\", \"sid\": sol[\"sid\"], \"unit\": sol[\"unit\"], \"qid\": q[\"qid\"], \"raw\": raw,\n", + " \"truth_correct\": ok, \"truth_answer\": intended, \"truth_etype\": et})\n", + " return pd.DataFrame(rows)\n", + "# ---------------------------------------------------------------------------\n", + "\n", + "# ---- 응답 정제·파싱·채점 공통 함수 ----\n", + "TAILS = (\"입니다\", \"이다\", \"임\")\n", + "\n", + "\n", + "def norm_text(s):\n", + " \"\"\"전각->반각(NFKC), 소문자, 공백·문장부호 제거, 끝맺음 어미 제거.\"\"\"\n", + " s = unicodedata.normalize(\"NFKC\", str(s)).strip().lower()\n", + " s = re.sub(r\"[\\s\\.\\,\\-\\(\\)·]\", \"\", s)\n", + " for t in TAILS:\n", + " if s.endswith(t) and len(s) > len(t) + 1:\n", + " s = s[:-len(t)]\n", + " return s\n", + "\n", + "\n", + "def parse(q, raw):\n", + " \"\"\"원본 응답 -> (정제값, 상태). 상태: 정상 / 미응답 / 형식오류\"\"\"\n", + " if raw is None or str(raw).strip() == \"\":\n", + " return \"\", \"미응답\"\n", + " s = unicodedata.normalize(\"NFKC\", str(raw)).strip()\n", + " if q[\"type\"] == \"MC\":\n", + " t = re.sub(r\"[\\s번\\.\\)]\", \"\", s).lower()\n", + " t = {\"a\": \"1\", \"b\": \"2\", \"c\": \"3\", \"d\": \"4\"}.get(t, t)\n", + " return (t, \"정상\") if t in (\"1\", \"2\", \"3\", \"4\") else (t, \"형식오류\")\n", + " if q[\"type\"] == \"ORDER\":\n", + " t = re.sub(r\"[^A-Za-z]\", \"\", s).upper()\n", + " return (t, \"정상\") if t and set(t) <= set(\"ABCD\") and len(set(t)) == len(t) else (t, \"형식오류\")\n", + " return norm_text(s), \"정상\"\n", + "\n", + "\n", + "def grade_exact(q, val):\n", + " if q[\"type\"] == \"MC\":\n", + " return val == str(q[\"answer\"])\n", + " if q[\"type\"] == \"ORDER\":\n", + " return val == q[\"answer\"]\n", + " return val in {norm_text(a) for a in q[\"accept\"]}\n", + "\n", + "\n", + "def grade_fuzzy(q, val, cut=0.75):\n", + " \"\"\"단답형은 오탈자(글자 순서 바뀜·한 글자 누락)를 허용(유사도 cut 이상)하되, 알려진 오답과 더 가까우면 오답으로 본다.\"\"\"\n", + " if grade_exact(q, val) or q[\"type\"] != \"SA\" or len(val) < 2:\n", + " return grade_exact(q, val)\n", + " best = max(1.0 if sorted(val) == sorted(norm_text(a)) else difflib.SequenceMatcher(None, val, norm_text(a)).ratio() for a in q[\"accept\"])\n", + " worst = max(difflib.SequenceMatcher(None, val, norm_text(w)).ratio() for w in q[\"wrong\"])\n", + " return best >= cut and best > worst\n", + "\n", + "\n", + "def score_of(q, correct):\n", + " return POINTS[q[\"diff\"]] if correct else 0\n", + "\n", + "# ---- 서술형 문항(10점): 핵심 요소 5개 x 2점 ----\n", + "ESSAY = \"미확인 드론을 관측했을 때 보고와 대응 절차를 서술하시오.\"\n", + "ELEM = {\"관측정보\": ([\"관측 시각과 방향, 거리를 적는다\", \"언제 어느 방향 몇 km인지 기록한다\"], [\"시각\", \"방향\", \"거리\"]),\n", + " \"추정표현\": ([\"확실하지 않으면 추정이라고 밝힌다\", \"모르는 부분은 추정임을 명시한다\"], [\"추정\"]),\n", + " \"지휘계통\": ([\"지휘계통을 따라 즉시 보고한다\", \"소대장에게 보고하고 상급으로 올린다\"], [\"지휘계통\"]),\n", + " \"승인대기\": ([\"지휘관 승인 전에는 대응하지 않는다\", \"승인이 나기 전까지 조치하지 않고 대기한다\"], [\"승인\"]),\n", + " \"근거기록\": ([\"판단 근거를 기록한다\", \"조치 내용을 일지에 남긴다\"], [\"기록\", \"근거\"])}\n", + "WRONG_SENT = [\"승인 없이 바로 대응한다\", \"기록은 상황 종료 후 생략한다\", \"확정된 적 드론으로 보고한다\"] # 키워드는 있지만 틀린 진술\n", + "\n", + "\n", + "def make_essays(sids, seed):\n", + " rng = np.random.default_rng(seed)\n", + " out = []\n", + " for s in sids:\n", + " have = [e for e in ELEM if rng.random() < 0.65]\n", + " sents = [str(rng.choice(ELEM[e][0])) for e in have]\n", + " if rng.random() < 0.2:\n", + " sents.append(str(rng.choice(WRONG_SENT)))\n", + " rng.shuffle(sents)\n", + " out.append({\"sid\": s, \"text\": \" \".join(sents), \"truth\": 2 * len(have)})\n", + " return out\n", + "\n", + "\n", + "def auto_suggest(text):\n", + " \"\"\"키워드 포함 여부로 점수를 '제안'한다(확정 아님). 부정 문맥을 구분하지 못하는 한계가 있다.\"\"\"\n", + " hit = [e for e, (_, kws) in ELEM.items() if any(k in text for k in kws)]\n", + " conf = \"낮음\" if any(w in text for w in (\"없이\", \"생략\", \"확정\")) or len(text) < 25 else \"보통\"\n", + " return 2 * len(hit), hit, conf\n", + "\n", + "\n", + "class ReviewQueue:\n", + " \"\"\"우선순위 큐 + 해시 체인 감사 로그. 교관 결정 없이는 점수를 확정할 수 없다.\"\"\"\n", + " def __init__(self):\n", + " self.heap, self.items, self.log, self.final = [], {}, [], {}\n", + "\n", + " def _audit(self, event, **kw):\n", + " prev = self.log[-1][\"hash\"] if self.log else \"GENESIS\"\n", + " body = {\"seq\": len(self.log), \"event\": event, **kw}\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 submit(self, iid, prio, t, payload):\n", + " self.items[iid] = {**payload, \"t_in\": t, \"status\": \"PENDING\"}\n", + " heapq.heappush(self.heap, (prio, t, iid))\n", + " self._audit(\"SUBMIT\", iid=iid, prio=prio, suggest=payload.get(\"suggest\"))\n", + "\n", + " def pop(self):\n", + " return heapq.heappop(self.heap)[2] if self.heap else None\n", + "\n", + " def decide(self, iid, reviewer, decision, score, reason, t):\n", + " if not reason:\n", + " raise ValueError(\"사유 없는 결정은 기록할 수 없다\")\n", + " it = self.items[iid]\n", + " it.update(status=decision, score=score, reviewer=reviewer, t_out=t)\n", + " self.final[iid] = score\n", + " self._audit(\"DECIDE\", iid=iid, reviewer=reviewer, decision=decision, score=score, reason=reason)\n", + "\n", + " def finalize_without_review(self, iid):\n", + " if self.items[iid][\"status\"] == \"PENDING\":\n", + " raise PermissionError(\"교관 결정 전 자동 확정 금지\")\n", + "\n", + " def verify(self):\n", + " prev = \"GENESIS\"\n", + " for e in self.log:\n", + " body = {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(body, sort_keys=True, ensure_ascii=False)).encode()).hexdigest() != e[\"hash\"]:\n", + " return False, e[\"seq\"]\n", + " prev = e[\"hash\"]\n", + " return True, None\n", + "\n", + "\n", + "rng = np.random.default_rng(3)\n", + "df = make_responses(40, seed=7)\n", + "df[\"val\"] = [parse(QIDX[q], r)[0] for q, r in zip(df[\"qid\"], df[\"raw\"])]\n", + "df[\"correct\"] = [grade_fuzzy(QIDX[q], v) for q, v in zip(df[\"qid\"], df[\"val\"])]\n", + "df[\"score\"] = [score_of(QIDX[q], c) for q, c in zip(df[\"qid\"], df[\"correct\"])]\n", + "total = df.groupby(\"sid\")[\"score\"].sum()\n", + "PASS = 0.7 * (sum(POINTS[q[\"diff\"]] for q in BANK) + 10)\n", + "\n", + "# 1) 이의 제기: 자동 채점 오답 처리된 단답형 중 응시자가 이의를 낸 건(실제 맞은 답은 대부분 이의 제기, 틀린 답도 일부 제기)\n", + "sa_wrong = df[(df[\"qid\"].map(lambda q: QIDX[q][\"type\"]) == \"SA\") & ~df[\"correct\"] & (df[\"raw\"] != \"\")]\n", + "appeal = sa_wrong[sa_wrong[\"truth_correct\"] | (rng.random(len(sa_wrong)) < 0.08)]\n", + "essays = make_essays(sorted(df[\"sid\"].unique()), seed=5)\n", + "print(f\"[교육용가상자료] 이의 제기 {len(appeal)}건, 서술형 답안 {len(essays)}건이 교관 검토 대기열에 들어간다.\")\n", + "\n", + "q = ReviewQueue()\n", + "t = 0.0\n", + "for e in essays:\n", + " sug, hit, conf = auto_suggest(e[\"text\"])\n", + " est = total[e[\"sid\"]] + sug\n", + " prio = (0 if abs(est - PASS) <= 6 else 1) * 10 + (0 if conf == \"낮음\" else 1) # 합격선 근처·저신뢰 먼저\n", + " q.submit(f\"E-{e['sid']}\", prio, t, {\"kind\": \"서술형\", \"sid\": e[\"sid\"], \"suggest\": sug, \"conf\": conf, \"truth\": e[\"truth\"], \"text\": e[\"text\"]})\n", + " t += 0.05\n", + "for _, r in appeal.iterrows():\n", + " est = total[r[\"sid\"]]\n", + " prio = (0 if abs(est - PASS) <= 6 else 1) * 10 + 2\n", + " q.submit(f\"A-{r['rid']}\", prio, t, {\"kind\": \"이의 제기\", \"sid\": r[\"sid\"], \"qid\": r[\"qid\"], \"raw\": r[\"raw\"], \"suggest\": 0,\n", + " \"truth\": score_of(QIDX[r[\"qid\"]], r[\"truth_correct\"])})\n", + " t += 0.05\n", + "\n", + "# 2) 자동 확정 시도는 차단되어야 한다\n", + "blocked = 0\n", + "for iid in list(q.items)[:5]:\n", + " try:\n", + " q.finalize_without_review(iid)\n", + " except PermissionError:\n", + " blocked += 1\n", + "try:\n", + " q.decide(list(q.items)[0], \"교관A\", \"인정\", 2, \"\", t)\n", + " no_reason = \"통과(오류)\"\n", + "except ValueError:\n", + " no_reason = \"차단\"\n", + "print(f\"결정 전 자동 확정 시도 5건 중 차단 {blocked}건, 사유 없는 결정 {no_reason}\")\n", + "\n", + "# 3) 교관 2명이 우선순위대로 검토(모의 교관은 기준 답안으로 판단: 건당 서술형 2분, 이의 제기 1분)\n", + "clock = {\"교관A\": 0.0, \"교관B\": 0.0}\n", + "while True:\n", + " iid = q.pop()\n", + " if iid is None:\n", + " break\n", + " it = q.items[iid]\n", + " who = min(clock, key=clock.get)\n", + " clock[who] = max(clock[who], it[\"t_in\"]) + (2.0 if it[\"kind\"] == \"서술형\" else 1.0)\n", + " if it[\"kind\"] == \"서술형\":\n", + " dec = \"승인\" if it[\"truth\"] == it[\"suggest\"] else \"수정\"\n", + " reason = \"제안 점수와 요소 판단 일치\" if dec == \"승인\" else f\"요소 재판단(제안 {it['suggest']} -> {it['truth']})\"\n", + " else:\n", + " dec = \"인정\" if it[\"truth\"] > 0 else \"기각\"\n", + " reason = f\"'{it['raw']}' 의미상 정답 인정\" if dec == \"인정\" else f\"'{it['raw']}'는 오답 유지\"\n", + " q.decide(iid, who, dec, it[\"truth\"], reason, clock[who])\n", + "\n", + "R = pd.DataFrame(q.items.values())\n", + "R[\"wait\"] = R[\"t_out\"] - R[\"t_in\"]\n", + "R[\"border\"] = [abs(total[s] + (sg if k == \"서술형\" else 0) - PASS) <= 6 for s, sg, k in zip(R[\"sid\"], R[\"suggest\"], R[\"kind\"])]\n", + "print(\"\\n[검토 결과]\")\n", + "print(R.groupby([\"kind\", \"status\"]).size().to_string())\n", + "ess = R[R[\"kind\"] == \"서술형\"]\n", + "print(f\"서술형 자동 제안 점수와 교관 판단 일치 {(ess['status'] == '승인').mean():.1%}, 수정 {(ess['status'] == '수정').sum()}건\"\n", + " f\"(틀린 진술 속 키워드에 속은 제안 포함)\")\n", + "print(f\"대기시간(분): 합격선 근처 평균 {R.loc[R['border'], 'wait'].mean():.1f}, 그 외 평균 {R.loc[~R['border'], 'wait'].mean():.1f}, 최대 {R['wait'].max():.1f}\")\n", + "\n", + "n_dec = sum(e[\"event\"] == \"DECIDE\" for e in q.log)\n", + "print(f\"승인/반려 처리 {len(R)}건, 감사 로그 기록 {n_dec}건, 로깅 성공률 {n_dec / len(R):.0%} (목표 100%: {'달성' if n_dec == len(R) else '미달'})\")\n", + "ok, _ = q.verify()\n", + "q.log[7][\"score\"] = 10 # 감사 로그 사후 변조 시도\n", + "ok2, where = q.verify()\n", + "print(f\"무결성 검증: 원본 {'정상' if ok else '이상'}, 변조 후 {'정상' if ok2 else f'이상 탐지(seq {where})'}\")\n", + "\n", + "changed = R[(R[\"kind\"] == \"이의 제기\") & (R[\"status\"] == \"인정\")].groupby(\"sid\")[\"score\"].sum()\n", + "newtot = total.add(changed, fill_value=0) + ess.set_index(\"sid\")[\"score\"]\n", + "flip = int(((total + ess.set_index(\"sid\")[\"suggest\"] >= PASS) != (newtot >= PASS)).sum())\n", + "print(f\"교관 검토로 합격/불합격이 바뀐 응시자 {flip}명 -> 자동 채점만으로 확정하면 안 되는 이유\")\n", + "print(\"\\n[HITL 검토 지침 요약] 1) 자동 점수는 제안일 뿐 교관이 확정 2) 인정·기각·수정 모두 사유 필수\"\n", + " \" 3) 합격선 근처·저신뢰 답안 우선 4) 감사 로그는 수정 불가, 이의 재심은 상급 교관(지휘 책임선)이 판단\")\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 07 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + "[교육용가상자료] 이의 제기 45건, 서술형 답안 120건이 교관 검토 대기열에 들어간다.\n", + "결정 전 자동 확정 시도 5건 중 차단 5건, 사유 없는 결정 차단\n", + "\n", + "[검토 결과]\n", + "kind status\n", + "서술형 수정 54\n", + " 승인 66\n", + "이의 제기 기각 7\n", + " 인정 38\n", + "서술형 자동 제안 점수와 교관 판단 일치 55.0%, 수정 54건(틀린 진술 속 키워드에 속은 제안 포함)\n", + "대기시간(분): 합격선 근처 평균 20.0, 그 외 평균 94.7, 최대 137.4\n", + "승인/반려 처리 165건, 감사 로그 기록 165건, 로깅 성공률 100% (목표 100%: 달성)\n", + "무결성 검증: 원본 정상, 변조 후 이상 탐지(seq 7)\n", + "교관 검토로 합격/불합격이 바뀐 응시자 3명 -> 자동 채점만으로 확정하면 안 되는 이유\n", + "\n", + "[HITL 검토 지침 요약] 1) 자동 점수는 제안일 뿐 교관이 확정 2) 인정·기각·수정 모두 사유 필수 3) 합격선 근처·저신뢰 답안 우선 4) 감사 로그는 수정 불가, 이의 재심은 상급 교관(지휘 책임선)이 판단\n", + "```\n", + "
\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 08. 전 부대원 동시 응시 모의 대규모 트래픽 채점 부하 테스트 및 최적화\n", + "**소요시간**: 약 3.5시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "전 부대원 동시 응시 모의 대규모 트래픽 채점 부하 테스트를 수행한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **전 부대원이 동시에 응시하는 상황을 모의해 채점 시스템이 대규모 제출과 장애 속에서도 멈추거나 답안을 잃지 않는지 시험한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **평시 120명 제출량을 3배·6배·11배로 늘리고 초장문·제어문자·없는 문항 등 손상 답안과 채점기 응답 지연(장애 2%) 주입하여 처리 지연 및 복원력을 시험하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `tests/trn02_stress_test.py 및 스트레스 테스트 분석 보고서` 형태의 결과물을 완성한다.\n", + "\n", + "### 🔍 평가 및 검증 기준 (PASS 조건)\n", + "#### 미션 판정\n", + "\n", + "**평가 구분:** 필수\n", + "\n", + "**핵심 평가영역:** 극단 상황에서의 안전 실패(Fail-safe) 및 기능 축소 작동 적절성\n", + "\n", + "#### 미션 PASS 조건\n", + "\n", + "- 정량 기준: 동시 트래픽 500% 증가 시 시스템 다운 0건, 데이터 유실률 0%\n", + "\n", + "\n", + "- 진행 기준: 대량 시뮬레이션 부하 주입 테스트 통과 및 병목 구간 프로파일링 완료\n", + "\n", + "\n", + "- 검증 방법: 부하 테스트 도구를 통한 처리량(Throughput) 및 메모리 누수 검증\n", + "\n", + "\n", + "#### 채점자 판정 메모\n", + "\n", + "> 단순 코드 실행 여부뿐만 아니라 출력된 수치와 근거 자료가 군 실무 요구사항에 부합하는지 면밀히 확인한다.\n", + "\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "\"\"\"전 부대원 동시 응시를 모의해 평시(120명) 대비 최대 1,000% 늘어난 제출량과 손상 답안·채점기 장애를 주입하는 채점 부하 테스트를 수행한다. 처리량, 시스템 다운, 데이터 유실을 측정하고, 장애 시 '채점 보류'로 보존하는 안전 실패 동작과 캐시 최적화 전후 처리량을 비교한다.\"\"\"\n", + "import difflib\n", + "import functools\n", + "import re\n", + "import time\n", + "import tracemalloc\n", + "import unicodedata\n", + "from collections import deque\n", + "import numpy as np\n", + "import pandas as pd\n", + "import matplotlib.pyplot as plt\n", + "\n", + "# ---------------------------------------------------------------------------\n", + "# [교육용가상자료] 방공·드론 식별 퀴즈 문항 은행과 가상 응시자 응답 생성기\n", + "# 문항은 일반 원칙 수준의 가상 문항이며 실제 교범·교전규칙 원문이 아니다. 응시자는 가명(S001~)이다.\n", + "# ---------------------------------------------------------------------------\n", + "AREAS = [\"형상식별\", \"피아식별\", \"경보보고\", \"교전통제\", \"대드론일반\"]\n", + "ETYPES = [\"개념 혼동\", \"피아 오인\", \"절차 누락\"]\n", + "POINTS = {1: 2, 2: 3, 3: 5}\n", + "C, F, P = ETYPES\n", + "\n", + "\n", + "def _mc(qid, area, diff, stem, opts, ans, dist):\n", + " # [TODO : 학생 실습 8] 전 부대원 동시 응시 모의 대규모 트래픽 채점 부하 테스트 및 최적화 관련 핵심 로직을 구현하세요.\n", + " # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n", + " # <<< 여기에 코드를 작성하세요 >>>\n", + " pass # 작성 후 pass를 제거하세요.\n", + "def _sa(qid, area, diff, stem, accept, wrong):\n", + " # [TODO : 학생 실습 8] 전 부대원 동시 응시 모의 대규모 트래픽 채점 부하 테스트 및 최적화 관련 핵심 로직을 구현하세요.\n", + " # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n", + " # <<< 여기에 코드를 작성하세요 >>>\n", + " pass # 작성 후 pass를 제거하세요.\n", + "def _order(qid, area, diff, stem):\n", + " return {\"qid\": qid, \"type\": \"ORDER\", \"area\": area, \"diff\": diff, \"stem\": stem, \"answer\": \"ABCD\"}\n", + "\n", + "\n", + "BANK = [\n", + " _mc(\"Q01\", \"형상식별\", 1, \"꼬리날개 없이 삼각형 날개를 가진 소형 고정익 실루엣은?\", [\"쿼드콥터형\", \"삼각익 고정익형\", \"회전익 헬기형\", \"조류\"], 2, [C, C, F]),\n", + " _mc(\"Q02\", \"형상식별\", 1, \"회전날개 4개가 십자로 배치된 실루엣은?\", [\"고정익 드론형\", \"쿼드콥터형\", \"순항미사일형\", \"탄도미사일형\"], 2, [C, C, C]),\n", + " _mc(\"Q03\", \"형상식별\", 2, \"가늘고 긴 동체 중간에 작은 날개가 붙은 실루엣은?\", [\"순항미사일형\", \"탄도미사일형\", \"민간 경비행기형\", \"조류\"], 1, [C, F, F]),\n", + " _mc(\"Q04\", \"형상식별\", 3, \"원거리라 실루엣 식별이 어려울 때 먼저 할 행동은?\", [\"추정 유형으로 확정 보고\", \"관측 정보를 그대로 보고하고 추가 관측\", \"관측 중단\", \"민간기로 간주\"], 2, [P, P, F]),\n", + " _mc(\"Q05\", \"피아식별\", 2, \"식별 신호 응답이 없는 항공기에 대한 올바른 판단은?\", [\"즉시 적기로 확정\", \"미확인 항공기로 분류하고 추가 확인\", \"아군기로 간주\", \"무시\"], 2, [F, F, P]),\n", + " _mc(\"Q06\", \"피아식별\", 2, \"운항 정보와 일치하는 저속 비행체의 분류는?\", [\"적 드론\", \"민간기 가능성 높음, 추가 확인\", \"순항미사일\", \"아군 정찰기로 확정\"], 2, [F, C, F]),\n", + " _mc(\"Q07\", \"피아식별\", 3, \"아군 드론 운용 공지가 있는 공역에서 미확인 드론을 보면 우선 확인할 것은?\", [\"기상 정보\", \"아군 운용 계획과 식별 신호\", \"대응 준비 상태\", \"주변 지형\"], 2, [C, P, C]),\n", + " _mc(\"Q08\", \"피아식별\", 3, \"조류 무리와 소형 드론 군집 구분에 가장 도움이 되는 정보는?\", [\"크기만 비교\", \"궤적의 규칙성과 열 신호\", \"색상\", \"관측 시각\"], 2, [C, C, C]),\n", + " _mc(\"Q09\", \"경보보고\", 1, \"공중 위협 최초 보고에 포함하지 않아도 되는 것은?\", [\"관측 시각\", \"방향과 거리\", \"추정 유형\", \"관측자 개인 연락처\"], 4, [P, P, P]),\n", + " _mc(\"Q10\", \"경보보고\", 2, \"경보 전파 후 상황이 바뀌면?\", [\"최초 보고 유지\", \"변경 사항을 즉시 추가 보고\", \"상황 종료 후 일괄 보고\", \"개인 판단으로 경보 해제\"], 2, [P, P, P]),\n", + " _mc(\"Q11\", \"경보보고\", 2, \"보고 내용이 불확실할 때 올바른 표현은?\", [\"확정 표현으로 보고\", \"'추정'임을 밝히고 근거와 함께 보고\", \"보고 보류\", \"다른 관측자 보고를 복사\"], 2, [C, P, P]),\n", + " _mc(\"Q12\", \"경보보고\", 1, \"드론 탐지 후 보고 경로로 알맞은 것은?\", [\"인접 부대에만 전파\", \"지휘계통을 따라 보고\", \"개인 메신저로 공유\", \"보고 없이 대응\"], 2, [P, P, P]),\n", + " _mc(\"Q13\", \"교전통제\", 1, \"AI 탐지 결과만으로 대응을 결정할 수 있는가?\", [\"가능하다\", \"인간 지휘관의 승인이 필요하다\", \"신뢰도 90% 이상이면 가능\", \"야간에만 가능\"], 2, [C, C, C]),\n", + " _mc(\"Q14\", \"교전통제\", 2, \"민간 피해 가능성이 확인되면?\", [\"그대로 진행\", \"즉시 보고하고 지휘관 판단을 받는다\", \"조치 후 보고\", \"무시\"], 2, [P, P, F]),\n", + " _mc(\"Q15\", \"교전통제\", 3, \"식별이 불확실한 표적에 대한 원칙은?\", [\"위협으로 간주해 즉시 대응\", \"식별 확인 전까지 승인 절차를 따른다\", \"무조건 무시\", \"다수결로 결정\"], 2, [F, F, C]),\n", + " _mc(\"Q16\", \"교전통제\", 2, \"판단 근거를 기록해야 하는 이유는?\", [\"형식상 필요\", \"사후 검토와 책임 추적\", \"보고 생략\", \"기록 불필요\"], 2, [C, P, P]),\n", + " _mc(\"Q17\", \"대드론일반\", 1, \"대드론 체계의 일반적 처리 순서는?\", [\"대응-탐지-식별\", \"탐지-식별-보고·판단-대응\", \"식별-대응-탐지\", \"보고-탐지-대응\"], 2, [P, P, P]),\n", + " _mc(\"Q18\", \"대드론일반\", 3, \"레이더만으로 조류와 드론 구분이 어려운 이유는?\", [\"반사 신호 크기가 비슷할 수 있어서\", \"레이더는 야간에 못 써서\", \"드론에는 금속이 없어서\", \"조류는 탐지되지 않아서\"], 1, [C, C, C]),\n", + " _mc(\"Q19\", \"대드론일반\", 2, \"전자광학(EO) 센서의 약점이 커지는 조건은?\", [\"맑은 주간\", \"안개·야간\", \"저속 표적\", \"근거리\"], 2, [C, C, C]),\n", + " _mc(\"Q20\", \"대드론일반\", 2, \"경보가 반복해서 오경보로 판명되면?\", [\"경보 장치를 끈다\", \"사례를 기록·보고하고 기준 재검토 요청\", \"무시한다\", \"모두 실제 위협으로 간주\"], 2, [P, P, F]),\n", + " _sa(\"Q21\", \"피아식별\", 1, \"아군·적·민간을 구분하는 절차를 무엇이라 하는가?\", [\"피아식별\", \"IFF\"], {\"표적획득\": C, \"적기판정\": F, \"경계\": C}),\n", + " _sa(\"Q22\", \"형상식별\", 2, \"꼬리날개 없는 삼각형 날개 형태를 무엇이라 하는가?\", [\"삼각익\", \"델타익\", \"delta\"], {\"직선익\": C, \"후퇴익\": C, \"쿼드콥터\": C}),\n", + " _sa(\"Q23\", \"경보보고\", 2, \"불확실한 관측을 보고할 때 붙이는 표현은?\", [\"추정\"], {\"확정\": C, \"확인\": C, \"생략\": P}),\n", + " _sa(\"Q24\", \"교전통제\", 1, \"AI 탐지 결과에 대해 최종 결정을 내리는 주체는?\", [\"지휘관\"], {\"AI\": C, \"운용병\": P, \"레이더\": C}),\n", + " _sa(\"Q25\", \"대드론일반\", 1, \"조류를 드론으로 잘못 경보하는 것을 무엇이라 하는가?\", [\"오경보\", \"오탐\", \"false alarm\"], {\"미탐\": C, \"재경보\": C, \"적기\": F}),\n", + " _sa(\"Q26\", \"피아식별\", 2, \"운항 정보가 확인된 여객기는 어떻게 분류하는가?\", [\"민간기\", \"민항기\"], {\"적기\": F, \"미확인기\": P, \"아군기\": F}),\n", + " _sa(\"Q27\", \"형상식별\", 1, \"회전날개 4개를 가진 드론 형태는?\", [\"쿼드콥터\", \"quadcopter\"], {\"헥사콥터\": C, \"고정익\": C, \"헬기\": F}),\n", + " _sa(\"Q28\", \"경보보고\", 1, \"보고는 무엇을 따라 올라가는가?\", [\"지휘계통\"], {\"인접부대\": P, \"개인메신저\": P, \"통신망\": C}),\n", + " _order(\"Q29\", \"경보보고\", 2, \"최초 보고 순서: A 관측 시각, B 방향·거리, C 추정 유형, D 관측자 조치\"),\n", + " _order(\"Q30\", \"대드론일반\", 2, \"처리 순서: A 탐지, B 식별, C 보고·판단, D 대응\"),\n", + " _order(\"Q31\", \"교전통제\", 3, \"통제 순서: A 식별 확인, B 상급 승인 요청, C 승인 수령, D 조치 기록\"),\n", + " _order(\"Q32\", \"피아식별\", 3, \"확인 순서: A 운항정보 대조, B 식별 신호 확인, C 미확인 분류, D 보고\"),\n", + "]\n", + "QIDX = {q[\"qid\"]: q for q in BANK}\n", + "UNITS = [\"가상1중대\", \"가상2중대\", \"가상3중대\"]\n", + "_CIRC = \"①②③④\"\n", + "PARA = {\"Q21\": \"아군적군 구분\", \"Q22\": \"세모 날개\", \"Q23\": \"추정됨으로 표기\", \"Q24\": \"중대장(지휘관)\", \"Q25\": \"잘못된 경보\",\n", + " \"Q26\": \"민간 여객기\", \"Q27\": \"4발 드론\", \"Q28\": \"지휘 라인\"} # 뜻은 맞지만 인정 답안 목록에 없는 표현\n", + "\n", + "\n", + "def make_soldiers(n_per_unit, seed):\n", + " rng = np.random.default_rng(seed)\n", + " out = []\n", + " for u, unit in enumerate(UNITS):\n", + " base = [0.3, -0.1, 0.0][u]\n", + " for i in range(n_per_unit):\n", + " off = rng.normal(0, 0.5, len(AREAS)) + (np.array([0, 0, 0, -0.6, 0]) if u == 1 else 0)\n", + " out.append({\"sid\": f\"S{len(out) + 1:03d}\", \"unit\": unit, \"theta\": dict(zip(AREAS, base + rng.normal(0, 0.6) + off)),\n", + " \"tend\": dict(zip(ETYPES, rng.dirichlet([1.2, 1.2, 1.2])))})\n", + " return out\n", + "\n", + "\n", + "def p_correct(theta, diff):\n", + " return 1 / (1 + np.exp(-1.7 * (theta - (diff - 2) * 0.9 + 0.9)))\n", + "\n", + "\n", + "def _typo(s, rng):\n", + " if len(s) < 2:\n", + " return s\n", + " i = int(rng.integers(len(s) - 1))\n", + " return s[:i] + s[i + 1] + s[i] + s[i + 2:] if len(s) <= 3 or rng.random() < 0.5 else s[:i] + s[i + 1:]\n", + "\n", + "\n", + "def _fmt_mc(k, rng):\n", + " r = rng.random()\n", + " if r < 0.55:\n", + " return str(k)\n", + " return rng.choice([_CIRC[k - 1], f\"{k}번\", \"ABCD\"[k - 1], \"abcd\"[k - 1] + \")\", f\" {k} \", chr(0xFF10 + k), f\"{k}.\"])\n", + "\n", + "\n", + "def _fmt_sa(s, rng):\n", + " r = rng.random()\n", + " if r < 0.45:\n", + " return s\n", + " if r < 0.6:\n", + " return \" \".join(s) if len(s) <= 4 else s[:2] + \" \" + s[2:]\n", + " if r < 0.72:\n", + " return s + rng.choice([\"입니다\", \"이다\", \".\", \"임\"])\n", + " if r < 0.8:\n", + " return s.upper() if s.isascii() else \"\".join(chr(ord(ch) + 0xFEE0) if \"!\" <= ch <= \"~\" else ch for ch in s) + \" \"\n", + " return _typo(s, rng)\n", + "\n", + "\n", + "def answer(q, sol, rng):\n", + " \"\"\"한 문항 응답: (원본 응답 문자열, 의도한 정답 여부, 의도한 답, 오답 유형).\"\"\"\n", + " ok = rng.random() < p_correct(sol[\"theta\"][q[\"area\"]], q[\"diff\"])\n", + " if q[\"type\"] == \"MC\":\n", + " if ok:\n", + " k, et = q[\"answer\"], None\n", + " else:\n", + " ks = list(q[\"dist\"]); w = np.array([sol[\"tend\"][q[\"dist\"][k]] for k in ks])\n", + " k = int(rng.choice(ks, p=w / w.sum())); et = q[\"dist\"][k]\n", + " return _fmt_mc(k, rng), ok, str(k), et\n", + " if q[\"type\"] == \"SA\":\n", + " if ok and rng.random() < 0.06:\n", + " return PARA[q[\"qid\"]], ok, PARA[q[\"qid\"]], None\n", + " if ok:\n", + " s, et = str(rng.choice(q[\"accept\"])), None\n", + " else:\n", + " ws = list(q[\"wrong\"]); w = np.array([sol[\"tend\"][q[\"wrong\"][x]] for x in ws])\n", + " s = str(rng.choice(ws, p=w / w.sum())); et = q[\"wrong\"][s]\n", + " return _fmt_sa(s, rng), ok, s, et\n", + " if ok:\n", + " s = \"ABCD\"\n", + " elif rng.random() < 0.5:\n", + " s = \"ABCD\".replace(\"ABCD\"[rng.integers(1, 4)], \"\") # 단계 하나 빠뜨림\n", + " else:\n", + " i = int(rng.integers(0, 3)); s = list(\"ABCD\"); s[i], s[i + 1] = s[i + 1], s[i]; s = \"\".join(s) # 순서 뒤바뀜\n", + " raw = s if rng.random() < 0.6 else rng.choice([\"-\".join(s), \" \".join(s), s.lower(), \",\".join(s)])\n", + " return raw, ok, s, (None if ok else P)\n", + "\n", + "\n", + "def make_responses(n_per_unit=40, seed=7, blank=0.02):\n", + " \"\"\"응시자 x 문항 응답표(DataFrame)를 만든다. truth_* 열은 교관 기준 정답(평가용)이다.\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " rows = []\n", + " for sol in make_soldiers(n_per_unit, seed):\n", + " for q in BANK:\n", + " raw, ok, intended, et = answer(q, sol, rng)\n", + " if rng.random() < blank:\n", + " raw, ok, intended, et = \"\", False, \"\", None\n", + " rows.append({\"rid\": f\"R{len(rows):05d}\", \"sid\": sol[\"sid\"], \"unit\": sol[\"unit\"], \"qid\": q[\"qid\"], \"raw\": raw,\n", + " \"truth_correct\": ok, \"truth_answer\": intended, \"truth_etype\": et})\n", + " return pd.DataFrame(rows)\n", + "# ---------------------------------------------------------------------------\n", + "\n", + "# ---- 응답 정제·파싱·채점 공통 함수 ----\n", + "TAILS = (\"입니다\", \"이다\", \"임\")\n", + "\n", + "\n", + "def norm_text(s):\n", + " \"\"\"전각->반각(NFKC), 소문자, 공백·문장부호 제거, 끝맺음 어미 제거.\"\"\"\n", + " s = unicodedata.normalize(\"NFKC\", str(s)).strip().lower()\n", + " s = re.sub(r\"[\\s\\.\\,\\-\\(\\)·]\", \"\", s)\n", + " for t in TAILS:\n", + " if s.endswith(t) and len(s) > len(t) + 1:\n", + " s = s[:-len(t)]\n", + " return s\n", + "\n", + "\n", + "def parse(q, raw):\n", + " \"\"\"원본 응답 -> (정제값, 상태). 상태: 정상 / 미응답 / 형식오류\"\"\"\n", + " if raw is None or str(raw).strip() == \"\":\n", + " return \"\", \"미응답\"\n", + " s = unicodedata.normalize(\"NFKC\", str(raw)).strip()\n", + " if q[\"type\"] == \"MC\":\n", + " t = re.sub(r\"[\\s번\\.\\)]\", \"\", s).lower()\n", + " t = {\"a\": \"1\", \"b\": \"2\", \"c\": \"3\", \"d\": \"4\"}.get(t, t)\n", + " return (t, \"정상\") if t in (\"1\", \"2\", \"3\", \"4\") else (t, \"형식오류\")\n", + " if q[\"type\"] == \"ORDER\":\n", + " t = re.sub(r\"[^A-Za-z]\", \"\", s).upper()\n", + " return (t, \"정상\") if t and set(t) <= set(\"ABCD\") and len(set(t)) == len(t) else (t, \"형식오류\")\n", + " return norm_text(s), \"정상\"\n", + "\n", + "\n", + "def grade_exact(q, val):\n", + " if q[\"type\"] == \"MC\":\n", + " return val == str(q[\"answer\"])\n", + " if q[\"type\"] == \"ORDER\":\n", + " return val == q[\"answer\"]\n", + " return val in {norm_text(a) for a in q[\"accept\"]}\n", + "\n", + "\n", + "def grade_fuzzy(q, val, cut=0.75):\n", + " \"\"\"단답형은 오탈자(글자 순서 바뀜·한 글자 누락)를 허용(유사도 cut 이상)하되, 알려진 오답과 더 가까우면 오답으로 본다.\"\"\"\n", + " if grade_exact(q, val) or q[\"type\"] != \"SA\" or len(val) < 2:\n", + " return grade_exact(q, val)\n", + " best = max(1.0 if sorted(val) == sorted(norm_text(a)) else difflib.SequenceMatcher(None, val, norm_text(a)).ratio() for a in q[\"accept\"])\n", + " worst = max(difflib.SequenceMatcher(None, val, norm_text(w)).ratio() for w in q[\"wrong\"])\n", + " return best >= cut and best > worst\n", + "\n", + "\n", + "def score_of(q, correct):\n", + " return POINTS[q[\"diff\"]] if correct else 0\n", + "\n", + "\n", + "def grade_one(qid, raw):\n", + " q = QIDX[qid]\n", + " val, st = parse(q, raw)\n", + " return st, (st == \"정상\" and grade_fuzzy(q, val))\n", + "\n", + "\n", + "@functools.lru_cache(maxsize=50000)\n", + "def grade_cached(qid, raw):\n", + " \"\"\"같은 (문항, 원본 응답)은 다시 계산하지 않는다(대규모 응시에서 같은 답이 반복됨).\"\"\"\n", + " return grade_one(qid, raw)\n", + "\n", + "\n", + "def corrupt(df, rng, rate):\n", + " \"\"\"손상 답안 주입: 초장문, 제어문자, 존재하지 않는 문항, None.\"\"\"\n", + " df = df.copy()\n", + " idx = rng.choice(len(df), int(len(df) * rate), replace=False)\n", + " kinds = rng.integers(0, 4, len(idx))\n", + " for i, k in zip(idx, kinds):\n", + " df.at[df.index[i], \"raw\"] = [\"가\" * 20000, \"\\x00\\x07삼각익\", None, \"1\"][k]\n", + " if k == 3:\n", + " df.at[df.index[i], \"qid\"] = \"Q99\"\n", + " return df\n", + "\n", + "\n", + "def run(df, fn, fail_rate=0.0, seed=0, cap=2000):\n", + " \"\"\"제출 큐에서 cap건씩 꺼내 채점. 채점 중 예외·장애가 나면 '채점 보류' 큐로 옮기고 계속 처리한다(안전 실패).\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " inbox, result, hold = deque(df[[\"rid\", \"qid\", \"raw\"]].itertuples(index=False)), {}, {}\n", + " t0, crashes, max_q = time.perf_counter(), 0, len(inbox)\n", + " while inbox:\n", + " batch = [inbox.popleft() for _ in range(min(cap, len(inbox)))]\n", + " for rid, qid, raw in batch:\n", + " try:\n", + " if qid not in QIDX:\n", + " raise KeyError(\"문항 없음\")\n", + " if raw is not None and len(str(raw)) > 200:\n", + " raise ValueError(\"응답 길이 초과\")\n", + " if rng.random() < fail_rate:\n", + " raise TimeoutError(\"채점기 응답 없음\")\n", + " result[rid] = fn(qid, \"\" if raw is None else str(raw).replace(\"\\x00\", \"\").replace(\"\\x07\", \"\"))\n", + " except Exception as e: # 시스템은 멈추지 않고 보류 큐에 원본을 보존\n", + " hold[rid] = type(e).__name__\n", + " sec = time.perf_counter() - t0\n", + " lost = len(df) - len(result) - len(hold)\n", + " return {\"건수\": len(df), \"처리(초)\": round(sec, 2), \"처리량(건/초)\": int(len(df) / sec), \"채점 완료\": len(result),\n", + " \"보류\": len(hold), \"유실\": lost, \"다운\": crashes, \"최대 대기열\": max_q}, hold\n", + "\n", + "\n", + "base = make_responses(40, seed=7) # 평시: 120명 x 32문항\n", + "rng = np.random.default_rng(1)\n", + "rows, holds = [], {}\n", + "for mult, fail in [(1, 0.0), (3, 0.0), (6, 0.0), (6, 0.02), (11, 0.02)]:\n", + " df = pd.concat([base.assign(rid=base[\"rid\"] + f\"-{k}\") for k in range(mult)], ignore_index=True)\n", + " df = corrupt(df, rng, 0.003)\n", + " grade_cached.cache_clear()\n", + " tracemalloc.start()\n", + " r, hold = run(df, grade_cached, fail_rate=fail, seed=mult)\n", + " peak = tracemalloc.get_traced_memory()[1] / 1e6; tracemalloc.stop()\n", + " rows.append({\"부하\": f\"평시 x{mult}\" + (\" +장애2%\" if fail else \"\"), **r, \"메모리 최대(MB)\": round(peak, 1)})\n", + " holds[rows[-1][\"부하\"]] = hold\n", + "T = pd.DataFrame(rows)\n", + "print(T.drop(columns=[\"다운\", \"최대 대기열\"]).to_string(index=False))\n", + "print(\"(위 표의 처리량은 메모리 추적을 켠 상태라 실제보다 낮게 나온다)\")\n", + "w = T[T[\"부하\"] == \"평시 x6\"].iloc[0]\n", + "print(f\"\\n동시 응시 500% 증가(평시 x6): 시스템 다운 {int(T['다운'].sum())}건, 데이터 유실 {int(T['유실'].sum())}건 \"\n", + " f\"(목표 다운 0건·유실률 0%: {'달성' if T['다운'].sum() == 0 and T['유실'].sum() == 0 else '미달'})\")\n", + "h = pd.Series(holds[\"평시 x11 +장애2%\"]).value_counts()\n", + "print(\"보류 사유(평시 x11 +장애2%):\", {k: int(v) for k, v in h.items()}, \"-> 원본 보존, 장애 복구 후 재채점 또는 교관 확인\")\n", + "\n", + "# 병목 프로파일링과 최적화: 캐시 없음 vs 캐시\n", + "df6 = pd.concat([base.assign(rid=base[\"rid\"] + f\"-{k}\") for k in range(6)], ignore_index=True)\n", + "prof = {}\n", + "t0 = time.perf_counter(); [parse(QIDX[q], r) for q, r in zip(df6[\"qid\"], df6[\"raw\"])]; prof[\"파싱\"] = time.perf_counter() - t0\n", + "sa = df6[df6[\"qid\"].map(lambda q: QIDX[q][\"type\"]) == \"SA\"]\n", + "t0 = time.perf_counter(); [grade_fuzzy(QIDX[q], parse(QIDX[q], r)[0]) for q, r in zip(sa[\"qid\"], sa[\"raw\"])]; prof[\"단답 유사도 채점\"] = time.perf_counter() - t0\n", + "print(\"\\n[병목 프로파일] \" + \", \".join(f\"{k} {v * 1000:.0f} ms\" for k, v in prof.items()) + f\" (단답형은 전체의 {len(sa) / len(df6):.0%})\")\n", + "r0, _ = run(df6, grade_one)\n", + "grade_cached.cache_clear(); r1, _ = run(df6, grade_cached)\n", + "print(f\"최적화 전 {r0['처리량(건/초)']:,}건/초 -> 캐시 적용 후 {r1['처리량(건/초)']:,}건/초 ({r1['처리량(건/초)'] / r0['처리량(건/초)']:.1f}배)\")\n", + "print(f\"기능 축소 운용: 장애율이 5%를 넘으면 단답형 유사도 채점을 끄고 정확 일치만 채점, 나머지는 교관 검토로 넘긴다.\")\n", + "\n", + "plt.figure(figsize=(8, 4))\n", + "plt.bar(T[\"부하\"].str.replace(\"평시\", \"base\").str.replace(\"+장애2%\", \"+fail2%\"), T[\"처리량(건/초)\"])\n", + "plt.ylabel(\"responses / sec\"); plt.title(\"Grading throughput under load (cached)\"); plt.xticks(rotation=15)\n", + "plt.tight_layout(); plt.show()\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 08 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + " 부하 건수 처리(초) 처리량(건/초) 채점 완료 보류 유실 메모리 최대(MB)\n", + " 평시 x1 3840 0.11 33625 3834 6 0 0.5\n", + " 평시 x3 11520 0.19 59703 11501 19 0 1.1\n", + " 평시 x6 23040 0.27 86855 23004 36 0 2.0\n", + " 평시 x6 +장애2% 23040 0.24 96047 22558 482 0 2.0\n", + "평시 x11 +장애2% 42240 0.46 91783 41326 914 0 3.6\n", + "(위 표의 처리량은 메모리 추적을 켠 상태라 실제보다 낮게 나온다)\n", + "\n", + "동시 응시 500% 증가(평시 x6): 시스템 다운 0건, 데이터 유실 0건 (목표 다운 0건·유실률 0%: 달성)\n", + "보류 사유(평시 x11 +장애2%): {'TimeoutError': 857, 'ValueError': 30, 'KeyError': 27} -> 원본 보존, 장애 복구 후 재채점 또는 교관 확인\n", + "\n", + "[병목 프로파일] 파싱 52 ms, 단답 유사도 채점 150 ms (단답형은 전체의 25%)\n", + "최적화 전 89,083건/초 -> 캐시 적용 후 498,682건/초 (5.6배)\n", + "기능 축소 운용: 장애율이 5%를 넘으면 단답형 유사도 채점을 끄고 정확 일치만 채점, 나머지는 교관 검토로 넘긴다.\n", + "```\n", + "
\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 09. AAR 기반 문항 변별력 분석 및 난이도별 가중치 파라미터 재보정\n", + "**소요시간**: 약 3.0시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "AAR 기반 문항 변별력 분석 및 난이도 가중치 파라미터를 재보정한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **시행 후 검토(AAR)로 문항의 변별력과 정답 키 오류를 찾아내고, 결함 문항 조치와 배점 가중치 재보정으로 합격 판정의 정확도를 높인다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **문항별 정답률, 점이연 상관(변별도), 오답 보기 선택자의 평균 점수를 분석해 정답 키 입력 오류와 변별력 부족 문항을 찾는다 과정을 진행하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `docs/trn02_aar_report.md 및 configs/trn02_tuned_params.yaml` 형태의 결과물을 완성한다.\n", + "\n", + "### 🔍 평가 및 검증 기준 (PASS 조건)\n", + "#### 미션 판정\n", + "\n", + "**평가 구분:** 필수\n", + "\n", + "**핵심 평가영역:** 오류 원인 규명의 객관성 및 개선 조치의 논리적 타당성\n", + "\n", + "#### 미션 PASS 조건\n", + "\n", + "- 정량 기준: AAR 피드백 적용 후 종합 정확도 10%p 이상 향상, 재발 오류 0건\n", + "\n", + "\n", + "- 진행 기준: AAR 보고서 작성 및 파라미터 튜닝 전후 회귀 테스트 통과\n", + "\n", + "\n", + "- 검증 방법: 회귀 테스트(Regression Test) 스위트 전체 자동 실행 및 비교\n", + "\n", + "\n", + "#### 채점자 판정 메모\n", + "\n", + "> 단순 코드 실행 여부뿐만 아니라 출력된 수치와 근거 자료가 군 실무 요구사항에 부합하는지 면밀히 확인한다.\n", + "\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "\"\"\"시행 후 검토(AAR)로 문항별 정답률·변별도(점이연 상관)·오답 보기 선택 분포를 분석해 정답 키 오류와 변별력 없는 문항을 찾아내고, 난이도·변별도 가중치를 재보정한다. 실제 숙달도 대비 합격 판정 정확도의 개선과 새 응시 집단에서의 재발 오류를 확인하고 재보정 파라미터를 YAML로 출력한다.\"\"\"\n", + "import difflib\n", + "import re\n", + "import unicodedata\n", + "import numpy as np\n", + "import pandas as pd\n", + "import matplotlib.pyplot as plt\n", + "\n", + "# ---------------------------------------------------------------------------\n", + "# [교육용가상자료] 방공·드론 식별 퀴즈 문항 은행과 가상 응시자 응답 생성기\n", + "# 문항은 일반 원칙 수준의 가상 문항이며 실제 교범·교전규칙 원문이 아니다. 응시자는 가명(S001~)이다.\n", + "# ---------------------------------------------------------------------------\n", + "AREAS = [\"형상식별\", \"피아식별\", \"경보보고\", \"교전통제\", \"대드론일반\"]\n", + "ETYPES = [\"개념 혼동\", \"피아 오인\", \"절차 누락\"]\n", + "POINTS = {1: 2, 2: 3, 3: 5}\n", + "C, F, P = ETYPES\n", + "\n", + "\n", + "def _mc(qid, area, diff, stem, opts, ans, dist):\n", + " # [TODO : 학생 실습 9] AAR 기반 문항 변별력 분석 및 난이도별 가중치 파라미터 재보정 관련 핵심 로직을 구현하세요.\n", + " # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n", + " # <<< 여기에 코드를 작성하세요 >>>\n", + " pass # 작성 후 pass를 제거하세요.\n", + "def _sa(qid, area, diff, stem, accept, wrong):\n", + " # [TODO : 학생 실습 9] AAR 기반 문항 변별력 분석 및 난이도별 가중치 파라미터 재보정 관련 핵심 로직을 구현하세요.\n", + " # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n", + " # <<< 여기에 코드를 작성하세요 >>>\n", + " pass # 작성 후 pass를 제거하세요.\n", + "def _order(qid, area, diff, stem):\n", + " return {\"qid\": qid, \"type\": \"ORDER\", \"area\": area, \"diff\": diff, \"stem\": stem, \"answer\": \"ABCD\"}\n", + "\n", + "\n", + "BANK = [\n", + " _mc(\"Q01\", \"형상식별\", 1, \"꼬리날개 없이 삼각형 날개를 가진 소형 고정익 실루엣은?\", [\"쿼드콥터형\", \"삼각익 고정익형\", \"회전익 헬기형\", \"조류\"], 2, [C, C, F]),\n", + " _mc(\"Q02\", \"형상식별\", 1, \"회전날개 4개가 십자로 배치된 실루엣은?\", [\"고정익 드론형\", \"쿼드콥터형\", \"순항미사일형\", \"탄도미사일형\"], 2, [C, C, C]),\n", + " _mc(\"Q03\", \"형상식별\", 2, \"가늘고 긴 동체 중간에 작은 날개가 붙은 실루엣은?\", [\"순항미사일형\", \"탄도미사일형\", \"민간 경비행기형\", \"조류\"], 1, [C, F, F]),\n", + " _mc(\"Q04\", \"형상식별\", 3, \"원거리라 실루엣 식별이 어려울 때 먼저 할 행동은?\", [\"추정 유형으로 확정 보고\", \"관측 정보를 그대로 보고하고 추가 관측\", \"관측 중단\", \"민간기로 간주\"], 2, [P, P, F]),\n", + " _mc(\"Q05\", \"피아식별\", 2, \"식별 신호 응답이 없는 항공기에 대한 올바른 판단은?\", [\"즉시 적기로 확정\", \"미확인 항공기로 분류하고 추가 확인\", \"아군기로 간주\", \"무시\"], 2, [F, F, P]),\n", + " _mc(\"Q06\", \"피아식별\", 2, \"운항 정보와 일치하는 저속 비행체의 분류는?\", [\"적 드론\", \"민간기 가능성 높음, 추가 확인\", \"순항미사일\", \"아군 정찰기로 확정\"], 2, [F, C, F]),\n", + " _mc(\"Q07\", \"피아식별\", 3, \"아군 드론 운용 공지가 있는 공역에서 미확인 드론을 보면 우선 확인할 것은?\", [\"기상 정보\", \"아군 운용 계획과 식별 신호\", \"대응 준비 상태\", \"주변 지형\"], 2, [C, P, C]),\n", + " _mc(\"Q08\", \"피아식별\", 3, \"조류 무리와 소형 드론 군집 구분에 가장 도움이 되는 정보는?\", [\"크기만 비교\", \"궤적의 규칙성과 열 신호\", \"색상\", \"관측 시각\"], 2, [C, C, C]),\n", + " _mc(\"Q09\", \"경보보고\", 1, \"공중 위협 최초 보고에 포함하지 않아도 되는 것은?\", [\"관측 시각\", \"방향과 거리\", \"추정 유형\", \"관측자 개인 연락처\"], 4, [P, P, P]),\n", + " _mc(\"Q10\", \"경보보고\", 2, \"경보 전파 후 상황이 바뀌면?\", [\"최초 보고 유지\", \"변경 사항을 즉시 추가 보고\", \"상황 종료 후 일괄 보고\", \"개인 판단으로 경보 해제\"], 2, [P, P, P]),\n", + " _mc(\"Q11\", \"경보보고\", 2, \"보고 내용이 불확실할 때 올바른 표현은?\", [\"확정 표현으로 보고\", \"'추정'임을 밝히고 근거와 함께 보고\", \"보고 보류\", \"다른 관측자 보고를 복사\"], 2, [C, P, P]),\n", + " _mc(\"Q12\", \"경보보고\", 1, \"드론 탐지 후 보고 경로로 알맞은 것은?\", [\"인접 부대에만 전파\", \"지휘계통을 따라 보고\", \"개인 메신저로 공유\", \"보고 없이 대응\"], 2, [P, P, P]),\n", + " _mc(\"Q13\", \"교전통제\", 1, \"AI 탐지 결과만으로 대응을 결정할 수 있는가?\", [\"가능하다\", \"인간 지휘관의 승인이 필요하다\", \"신뢰도 90% 이상이면 가능\", \"야간에만 가능\"], 2, [C, C, C]),\n", + " _mc(\"Q14\", \"교전통제\", 2, \"민간 피해 가능성이 확인되면?\", [\"그대로 진행\", \"즉시 보고하고 지휘관 판단을 받는다\", \"조치 후 보고\", \"무시\"], 2, [P, P, F]),\n", + " _mc(\"Q15\", \"교전통제\", 3, \"식별이 불확실한 표적에 대한 원칙은?\", [\"위협으로 간주해 즉시 대응\", \"식별 확인 전까지 승인 절차를 따른다\", \"무조건 무시\", \"다수결로 결정\"], 2, [F, F, C]),\n", + " _mc(\"Q16\", \"교전통제\", 2, \"판단 근거를 기록해야 하는 이유는?\", [\"형식상 필요\", \"사후 검토와 책임 추적\", \"보고 생략\", \"기록 불필요\"], 2, [C, P, P]),\n", + " _mc(\"Q17\", \"대드론일반\", 1, \"대드론 체계의 일반적 처리 순서는?\", [\"대응-탐지-식별\", \"탐지-식별-보고·판단-대응\", \"식별-대응-탐지\", \"보고-탐지-대응\"], 2, [P, P, P]),\n", + " _mc(\"Q18\", \"대드론일반\", 3, \"레이더만으로 조류와 드론 구분이 어려운 이유는?\", [\"반사 신호 크기가 비슷할 수 있어서\", \"레이더는 야간에 못 써서\", \"드론에는 금속이 없어서\", \"조류는 탐지되지 않아서\"], 1, [C, C, C]),\n", + " _mc(\"Q19\", \"대드론일반\", 2, \"전자광학(EO) 센서의 약점이 커지는 조건은?\", [\"맑은 주간\", \"안개·야간\", \"저속 표적\", \"근거리\"], 2, [C, C, C]),\n", + " _mc(\"Q20\", \"대드론일반\", 2, \"경보가 반복해서 오경보로 판명되면?\", [\"경보 장치를 끈다\", \"사례를 기록·보고하고 기준 재검토 요청\", \"무시한다\", \"모두 실제 위협으로 간주\"], 2, [P, P, F]),\n", + " _sa(\"Q21\", \"피아식별\", 1, \"아군·적·민간을 구분하는 절차를 무엇이라 하는가?\", [\"피아식별\", \"IFF\"], {\"표적획득\": C, \"적기판정\": F, \"경계\": C}),\n", + " _sa(\"Q22\", \"형상식별\", 2, \"꼬리날개 없는 삼각형 날개 형태를 무엇이라 하는가?\", [\"삼각익\", \"델타익\", \"delta\"], {\"직선익\": C, \"후퇴익\": C, \"쿼드콥터\": C}),\n", + " _sa(\"Q23\", \"경보보고\", 2, \"불확실한 관측을 보고할 때 붙이는 표현은?\", [\"추정\"], {\"확정\": C, \"확인\": C, \"생략\": P}),\n", + " _sa(\"Q24\", \"교전통제\", 1, \"AI 탐지 결과에 대해 최종 결정을 내리는 주체는?\", [\"지휘관\"], {\"AI\": C, \"운용병\": P, \"레이더\": C}),\n", + " _sa(\"Q25\", \"대드론일반\", 1, \"조류를 드론으로 잘못 경보하는 것을 무엇이라 하는가?\", [\"오경보\", \"오탐\", \"false alarm\"], {\"미탐\": C, \"재경보\": C, \"적기\": F}),\n", + " _sa(\"Q26\", \"피아식별\", 2, \"운항 정보가 확인된 여객기는 어떻게 분류하는가?\", [\"민간기\", \"민항기\"], {\"적기\": F, \"미확인기\": P, \"아군기\": F}),\n", + " _sa(\"Q27\", \"형상식별\", 1, \"회전날개 4개를 가진 드론 형태는?\", [\"쿼드콥터\", \"quadcopter\"], {\"헥사콥터\": C, \"고정익\": C, \"헬기\": F}),\n", + " _sa(\"Q28\", \"경보보고\", 1, \"보고는 무엇을 따라 올라가는가?\", [\"지휘계통\"], {\"인접부대\": P, \"개인메신저\": P, \"통신망\": C}),\n", + " _order(\"Q29\", \"경보보고\", 2, \"최초 보고 순서: A 관측 시각, B 방향·거리, C 추정 유형, D 관측자 조치\"),\n", + " _order(\"Q30\", \"대드론일반\", 2, \"처리 순서: A 탐지, B 식별, C 보고·판단, D 대응\"),\n", + " _order(\"Q31\", \"교전통제\", 3, \"통제 순서: A 식별 확인, B 상급 승인 요청, C 승인 수령, D 조치 기록\"),\n", + " _order(\"Q32\", \"피아식별\", 3, \"확인 순서: A 운항정보 대조, B 식별 신호 확인, C 미확인 분류, D 보고\"),\n", + "]\n", + "QIDX = {q[\"qid\"]: q for q in BANK}\n", + "UNITS = [\"가상1중대\", \"가상2중대\", \"가상3중대\"]\n", + "_CIRC = \"①②③④\"\n", + "PARA = {\"Q21\": \"아군적군 구분\", \"Q22\": \"세모 날개\", \"Q23\": \"추정됨으로 표기\", \"Q24\": \"중대장(지휘관)\", \"Q25\": \"잘못된 경보\",\n", + " \"Q26\": \"민간 여객기\", \"Q27\": \"4발 드론\", \"Q28\": \"지휘 라인\"} # 뜻은 맞지만 인정 답안 목록에 없는 표현\n", + "\n", + "\n", + "def make_soldiers(n_per_unit, seed):\n", + " rng = np.random.default_rng(seed)\n", + " out = []\n", + " for u, unit in enumerate(UNITS):\n", + " base = [0.3, -0.1, 0.0][u]\n", + " for i in range(n_per_unit):\n", + " off = rng.normal(0, 0.5, len(AREAS)) + (np.array([0, 0, 0, -0.6, 0]) if u == 1 else 0)\n", + " out.append({\"sid\": f\"S{len(out) + 1:03d}\", \"unit\": unit, \"theta\": dict(zip(AREAS, base + rng.normal(0, 0.6) + off)),\n", + " \"tend\": dict(zip(ETYPES, rng.dirichlet([1.2, 1.2, 1.2])))})\n", + " return out\n", + "\n", + "\n", + "def p_correct(theta, diff):\n", + " return 1 / (1 + np.exp(-1.7 * (theta - (diff - 2) * 0.9 + 0.9)))\n", + "\n", + "\n", + "def _typo(s, rng):\n", + " if len(s) < 2:\n", + " return s\n", + " i = int(rng.integers(len(s) - 1))\n", + " return s[:i] + s[i + 1] + s[i] + s[i + 2:] if len(s) <= 3 or rng.random() < 0.5 else s[:i] + s[i + 1:]\n", + "\n", + "\n", + "def _fmt_mc(k, rng):\n", + " r = rng.random()\n", + " if r < 0.55:\n", + " return str(k)\n", + " return rng.choice([_CIRC[k - 1], f\"{k}번\", \"ABCD\"[k - 1], \"abcd\"[k - 1] + \")\", f\" {k} \", chr(0xFF10 + k), f\"{k}.\"])\n", + "\n", + "\n", + "def _fmt_sa(s, rng):\n", + " r = rng.random()\n", + " if r < 0.45:\n", + " return s\n", + " if r < 0.6:\n", + " return \" \".join(s) if len(s) <= 4 else s[:2] + \" \" + s[2:]\n", + " if r < 0.72:\n", + " return s + rng.choice([\"입니다\", \"이다\", \".\", \"임\"])\n", + " if r < 0.8:\n", + " return s.upper() if s.isascii() else \"\".join(chr(ord(ch) + 0xFEE0) if \"!\" <= ch <= \"~\" else ch for ch in s) + \" \"\n", + " return _typo(s, rng)\n", + "\n", + "\n", + "def answer(q, sol, rng):\n", + " \"\"\"한 문항 응답: (원본 응답 문자열, 의도한 정답 여부, 의도한 답, 오답 유형).\"\"\"\n", + " ok = rng.random() < p_correct(sol[\"theta\"][q[\"area\"]], q[\"diff\"])\n", + " if q[\"type\"] == \"MC\":\n", + " if ok:\n", + " k, et = q[\"answer\"], None\n", + " else:\n", + " ks = list(q[\"dist\"]); w = np.array([sol[\"tend\"][q[\"dist\"][k]] for k in ks])\n", + " k = int(rng.choice(ks, p=w / w.sum())); et = q[\"dist\"][k]\n", + " return _fmt_mc(k, rng), ok, str(k), et\n", + " if q[\"type\"] == \"SA\":\n", + " if ok and rng.random() < 0.06:\n", + " return PARA[q[\"qid\"]], ok, PARA[q[\"qid\"]], None\n", + " if ok:\n", + " s, et = str(rng.choice(q[\"accept\"])), None\n", + " else:\n", + " ws = list(q[\"wrong\"]); w = np.array([sol[\"tend\"][q[\"wrong\"][x]] for x in ws])\n", + " s = str(rng.choice(ws, p=w / w.sum())); et = q[\"wrong\"][s]\n", + " return _fmt_sa(s, rng), ok, s, et\n", + " if ok:\n", + " s = \"ABCD\"\n", + " elif rng.random() < 0.5:\n", + " s = \"ABCD\".replace(\"ABCD\"[rng.integers(1, 4)], \"\") # 단계 하나 빠뜨림\n", + " else:\n", + " i = int(rng.integers(0, 3)); s = list(\"ABCD\"); s[i], s[i + 1] = s[i + 1], s[i]; s = \"\".join(s) # 순서 뒤바뀜\n", + " raw = s if rng.random() < 0.6 else rng.choice([\"-\".join(s), \" \".join(s), s.lower(), \",\".join(s)])\n", + " return raw, ok, s, (None if ok else P)\n", + "\n", + "\n", + "def make_responses(n_per_unit=40, seed=7, blank=0.02):\n", + " \"\"\"응시자 x 문항 응답표(DataFrame)를 만든다. truth_* 열은 교관 기준 정답(평가용)이다.\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " rows = []\n", + " for sol in make_soldiers(n_per_unit, seed):\n", + " for q in BANK:\n", + " raw, ok, intended, et = answer(q, sol, rng)\n", + " if rng.random() < blank:\n", + " raw, ok, intended, et = \"\", False, \"\", None\n", + " rows.append({\"rid\": f\"R{len(rows):05d}\", \"sid\": sol[\"sid\"], \"unit\": sol[\"unit\"], \"qid\": q[\"qid\"], \"raw\": raw,\n", + " \"truth_correct\": ok, \"truth_answer\": intended, \"truth_etype\": et})\n", + " return pd.DataFrame(rows)\n", + "# ---------------------------------------------------------------------------\n", + "\n", + "# ---- 응답 정제·파싱·채점 공통 함수 ----\n", + "TAILS = (\"입니다\", \"이다\", \"임\")\n", + "\n", + "\n", + "def norm_text(s):\n", + " \"\"\"전각->반각(NFKC), 소문자, 공백·문장부호 제거, 끝맺음 어미 제거.\"\"\"\n", + " s = unicodedata.normalize(\"NFKC\", str(s)).strip().lower()\n", + " s = re.sub(r\"[\\s\\.\\,\\-\\(\\)·]\", \"\", s)\n", + " for t in TAILS:\n", + " if s.endswith(t) and len(s) > len(t) + 1:\n", + " s = s[:-len(t)]\n", + " return s\n", + "\n", + "\n", + "def parse(q, raw):\n", + " \"\"\"원본 응답 -> (정제값, 상태). 상태: 정상 / 미응답 / 형식오류\"\"\"\n", + " if raw is None or str(raw).strip() == \"\":\n", + " return \"\", \"미응답\"\n", + " s = unicodedata.normalize(\"NFKC\", str(raw)).strip()\n", + " if q[\"type\"] == \"MC\":\n", + " t = re.sub(r\"[\\s번\\.\\)]\", \"\", s).lower()\n", + " t = {\"a\": \"1\", \"b\": \"2\", \"c\": \"3\", \"d\": \"4\"}.get(t, t)\n", + " return (t, \"정상\") if t in (\"1\", \"2\", \"3\", \"4\") else (t, \"형식오류\")\n", + " if q[\"type\"] == \"ORDER\":\n", + " t = re.sub(r\"[^A-Za-z]\", \"\", s).upper()\n", + " return (t, \"정상\") if t and set(t) <= set(\"ABCD\") and len(set(t)) == len(t) else (t, \"형식오류\")\n", + " return norm_text(s), \"정상\"\n", + "\n", + "\n", + "def grade_exact(q, val):\n", + " if q[\"type\"] == \"MC\":\n", + " return val == str(q[\"answer\"])\n", + " if q[\"type\"] == \"ORDER\":\n", + " return val == q[\"answer\"]\n", + " return val in {norm_text(a) for a in q[\"accept\"]}\n", + "\n", + "\n", + "def grade_fuzzy(q, val, cut=0.75):\n", + " \"\"\"단답형은 오탈자(글자 순서 바뀜·한 글자 누락)를 허용(유사도 cut 이상)하되, 알려진 오답과 더 가까우면 오답으로 본다.\"\"\"\n", + " if grade_exact(q, val) or q[\"type\"] != \"SA\" or len(val) < 2:\n", + " return grade_exact(q, val)\n", + " best = max(1.0 if sorted(val) == sorted(norm_text(a)) else difflib.SequenceMatcher(None, val, norm_text(a)).ratio() for a in q[\"accept\"])\n", + " worst = max(difflib.SequenceMatcher(None, val, norm_text(w)).ratio() for w in q[\"wrong\"])\n", + " return best >= cut and best > worst\n", + "\n", + "\n", + "def score_of(q, correct):\n", + " return POINTS[q[\"diff\"]] if correct else 0\n", + "\n", + "KEY_REG = {\"Q11\": 3} # [교육용가상자료] 문항 등록 시 정답 키를 잘못 입력한 문항(정답은 2)\n", + "AMBIG = \"Q08\" # 보기 2·3이 모두 그럴듯해 실력과 무관하게 갈리는 문항\n", + "\n", + "\n", + "def field_data(seed):\n", + " \"\"\"현장 시행 데이터: Q08은 실력과 무관하게 2·3이 반반, 나머지는 생성기 그대로.\"\"\"\n", + " df = make_responses(40, seed=seed)\n", + " rng = np.random.default_rng(seed + 100)\n", + " m = df[\"qid\"] == AMBIG\n", + " df.loc[m, \"raw\"] = rng.choice([\"2\", \"3\"], m.sum())\n", + " df[\"val\"] = [parse(QIDX[q], r)[0] for q, r in zip(df[\"qid\"], df[\"raw\"])]\n", + " return df\n", + "\n", + "\n", + "def grade(df, key_fix=False, drop=(), weights=None):\n", + " def ok(q, v):\n", + " if q[\"qid\"] in KEY_REG and not key_fix:\n", + " return v == str(KEY_REG[q[\"qid\"]])\n", + " return grade_fuzzy(q, v)\n", + " d = df[~df[\"qid\"].isin(drop)].copy()\n", + " d[\"correct\"] = [ok(QIDX[q], v) for q, v in zip(d[\"qid\"], d[\"val\"])]\n", + " w = weights or {}\n", + " d[\"pts\"] = [w.get(q, POINTS[QIDX[q][\"diff\"]]) for q in d[\"qid\"]]\n", + " d[\"score\"] = d[\"pts\"] * d[\"correct\"]\n", + " tot = d.groupby(\"sid\")[[\"score\", \"pts\"]].sum()\n", + " return d, tot[\"score\"] / tot[\"pts\"]\n", + "\n", + "\n", + "def item_analysis(d, key_fix=False):\n", + " \"\"\"정답률(p), 점이연 상관(해당 문항 제외 총점과의 상관), 오답 보기 선택자의 평균 총점이 정답 선택자보다 높은지(키 오류 의심).\"\"\"\n", + " tot = d.groupby(\"sid\")[\"correct\"].mean()\n", + " rows = []\n", + " for qid, g in d.groupby(\"qid\"):\n", + " rest = tot[g[\"sid\"]].values - g[\"correct\"].values / len(BANK)\n", + " r = np.corrcoef(g[\"correct\"].astype(float), rest)[0, 1] if g[\"correct\"].std() > 0 else 0.0\n", + " key_sus = False\n", + " if QIDX[qid][\"type\"] == \"MC\":\n", + " by = pd.Series(rest, index=g[\"val\"].values).groupby(level=0).mean()\n", + " key = KEY_REG[qid] if qid in KEY_REG and not key_fix else QIDX[qid][\"answer\"]\n", + " right = by.get(str(key), np.nan)\n", + " key_sus = bool(by.drop(index=[k for k in by.index if k not in \"1234\" or k == \"\"], errors=\"ignore\").max() > right + 0.08)\n", + " rows.append({\"qid\": qid, \"p\": round(g[\"correct\"].mean(), 2), \"r_pb\": round(r, 2), \"key_suspect\": key_sus})\n", + " t = pd.DataFrame(rows).set_index(\"qid\")\n", + " t[\"flag\"] = np.where(t[\"key_suspect\"], \"정답 키 오류 의심\", np.where(t[\"r_pb\"] < 0.1, \"변별력 부족\", \"\"))\n", + " return t\n", + "\n", + "\n", + "def pass_accuracy(df, ratio, cut=0.7):\n", + " \"\"\"합격 판정(점수 비율 cut 이상)과 가상 실제 숙달도(결함 없는 시험에서의 기대 득점률 0.7 이상)의 일치율.\"\"\"\n", + " sol = {s[\"sid\"]: s for s in make_soldiers(40, SEED_OF[id(df)])}\n", + " pts = np.array([POINTS[q[\"diff\"]] for q in BANK])\n", + " exp = {s: float(np.dot(pts, [p_correct(sol[s][\"theta\"][q[\"area\"]], q[\"diff\"]) for q in BANK]) / pts.sum()) for s in ratio.index}\n", + " truth = pd.Series(exp) >= 0.7\n", + " return float(((ratio >= cut) == truth[ratio.index]).mean())\n", + "\n", + "\n", + "SEED_OF = {}\n", + "A = field_data(7); SEED_OF[id(A)] = 7\n", + "d0, r0 = grade(A)\n", + "acc0 = pass_accuracy(A, r0)\n", + "ia = item_analysis(d0)\n", + "print(\"[AAR 1. 문항 분석 - 경고 문항]\")\n", + "print(ia[ia[\"flag\"] != \"\"].to_string())\n", + "print(f\"전체 문항 변별도 중앙값 {ia['r_pb'].median():.2f}, 정답률 0.9 초과(너무 쉬움) {int((ia['p'] > 0.9).sum())}개, 0.3 미만(너무 어려움) {int((ia['p'] < 0.3).sum())}개\")\n", + "print(\"원인 판정: Q11은 상위권이 고른 보기(2)가 등록 키(3)와 달라 키 입력 오류, Q08은 보기 2·3이 실력과 무관하게 갈려 문항 자체 결함.\")\n", + "\n", + "# AAR 2. 재보정: 키 수정 -> 결함 문항 제외 -> 배점 가중치 탐색\n", + "flagged = list(ia[ia[\"flag\"] == \"변별력 부족\"].index)\n", + "rows = [{\"단계\": \"재보정 전\", \"합격 판정 정확도\": acc0}]\n", + "d1, r1 = grade(A, key_fix=True)\n", + "rows.append({\"단계\": \"1) 정답 키 수정\", \"합격 판정 정확도\": pass_accuracy(A, r1)})\n", + "d2, r2 = grade(A, key_fix=True, drop=flagged)\n", + "rows.append({\"단계\": \"2) 변별력 부족 문항 제외\", \"합격 판정 정확도\": pass_accuracy(A, r2)})\n", + "ia2 = item_analysis(d2, key_fix=True)\n", + "best = None\n", + "for base in [(2, 3, 5), (1, 2, 3), (3, 3, 3)]:\n", + " for alpha in [0.0, 0.5, 1.0, 2.0]:\n", + " w = {q: base[QIDX[q][\"diff\"] - 1] * (1 + alpha * max(ia2.loc[q, \"r_pb\"], 0)) for q in ia2.index}\n", + " _, r = grade(A, key_fix=True, drop=flagged, weights=w)\n", + " a = pass_accuracy(A, r) # 합격선 70%는 정책값이라 튜닝하지 않는다\n", + " if best is None or a > best[0] + 1e-9:\n", + " best = (a, base, alpha, 0.7, w)\n", + "acc3, base, alpha, cut, W = best\n", + "rows.append({\"단계\": f\"3) 배점{base}·변별도 가중 {alpha}\", \"합격 판정 정확도\": acc3})\n", + "S = pd.DataFrame(rows); S[\"합격 판정 정확도\"] = S[\"합격 판정 정확도\"].round(3)\n", + "print(\"\\n[AAR 2. 재보정 단계별 효과(응시 집단 A)]\")\n", + "print(S.to_string(index=False))\n", + "gain = (acc3 - acc0) * 100\n", + "print(f\"종합 정확도 {acc0:.1%} -> {acc3:.1%} (+{gain:.1f}%p, 목표 10%p 이상: {'달성' if gain >= 10 else '미달'})\")\n", + "print(\"-> 결함 문항이 32개 중 2개라 개선 폭이 제한된다. 나머지 불일치는 합격선 근처 응시자의 측정 오차로, 문항 수를 늘려야 줄어든다.\")\n", + "\n", + "# AAR 3. 새 응시 집단 B(다른 인원)에서 검증·재발 확인\n", + "B = field_data(21); SEED_OF[id(B)] = 21\n", + "_, rb0 = grade(B)\n", + "dB, rb = grade(B, key_fix=True, drop=flagged, weights=W)\n", + "accB0, accB = pass_accuracy(B, rb0), pass_accuracy(B, rb, cut)\n", + "iaB = item_analysis(dB, key_fix=True)\n", + "rec = iaB[iaB[\"key_suspect\"] | iaB.index.isin(flagged + list(KEY_REG))]\n", + "rec = rec[rec[\"flag\"] != \"\"]\n", + "new = iaB[(iaB[\"flag\"] != \"\") & ~iaB.index.isin(rec.index)]\n", + "print(f\"\\n[AAR 3. 응시 집단 B 검증] 합격 판정 정확도 {accB0:.1%} -> {accB:.1%} (+{(accB - accB0) * 100:.1f}%p)\")\n", + "print(f\"재발 오류(조치한 키 오류·결함 문항의 재검출) {len(rec)}건 (목표 0건: {'달성' if len(rec) == 0 else '미달'})\")\n", + "print(f\"신규 점검 대상 {len(new)}건: {list(new.index)} -> 다음 AAR에서 문항 개정 여부 판단\")\n", + "\n", + "yaml = \"\\n\".join([\"# configs/trn02_tuned_params.yaml [교육용가상자료]\", \"answer_key_fix:\", \" Q11: 2\", \"excluded_items:\"]\n", + " + [f\" - {q} # 변별력 부족, 문항 개정 후 재투입\" for q in flagged]\n", + " + [f\"points_by_difficulty: {{1: {base[0]}, 2: {base[1]}, 3: {base[2]}}}\", f\"discrimination_alpha: {alpha}\",\n", + " f\"pass_ratio: {cut}\", \"review: 다음 시행 후 AAR에서 변별도 0.1 미만 문항 재점검\"])\n", + "print(\"\\n\" + yaml)\n", + "\n", + "plt.figure(figsize=(7, 4))\n", + "c = [\"tab:red\" if f else \"tab:blue\" for f in ia[\"flag\"]]\n", + "plt.scatter(ia[\"p\"], ia[\"r_pb\"], c=c)\n", + "for q in ia[ia[\"flag\"] != \"\"].index:\n", + " plt.annotate(q, (ia.loc[q, \"p\"], ia.loc[q, \"r_pb\"]))\n", + "plt.axhline(0.1, ls=\"--\", color=\"gray\"); plt.xlabel(\"item difficulty p (accuracy)\"); plt.ylabel(\"discrimination r_pb\")\n", + "plt.title(\"AAR item analysis (red = flagged)\"); plt.tight_layout(); plt.show()\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 09 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + "[AAR 1. 문항 분석 - 경고 문항]\n", + " p r_pb key_suspect flag\n", + "qid \n", + "Q08 0.56 -0.10 False 변별력 부족\n", + "Q11 0.08 -0.26 True 정답 키 오류 의심\n", + "Q27 0.89 0.08 False 변별력 부족\n", + "Q28 0.87 0.07 False 변별력 부족\n", + "전체 문항 변별도 중앙값 0.30, 정답률 0.9 초과(너무 쉬움) 2개, 0.3 미만(너무 어려움) 1개\n", + "원인 판정: Q11은 상위권이 고른 보기(2)가 등록 키(3)와 달라 키 입력 오류, Q08은 보기 2·3이 실력과 무관하게 갈려 문항 자체 결함.\n", + "\n", + "[AAR 2. 재보정 단계별 효과(응시 집단 A)]\n", + " 단계 합격 판정 정확도\n", + " 재보정 전 0.833\n", + " 1) 정답 키 수정 0.858\n", + " 2) 변별력 부족 문항 제외 0.875\n", + "3) 배점(2, 3, 5)·변별도 가중 0.0 0.875\n", + "종합 정확도 83.3% -> 87.5% (+4.2%p, 목표 10%p 이상: 미달)\n", + "-> 결함 문항이 32개 중 2개라 개선 폭이 제한된다. 나머지 불일치는 합격선 근처 응시자의 측정 오차로, 문항 수를 늘려야 줄어든다.\n", + "\n", + "[AAR 3. 응시 집단 B 검증] 합격 판정 정확도 75.8% -> 77.5% (+1.7%p)\n", + "재발 오류(조치한 키 오류·결함 문항의 재검출) 0건 (목표 0건: 달성)\n", + "신규 점검 대상 3건: ['Q12', 'Q13', 'Q21'] -> 다음 AAR에서 문항 개정 여부 판단\n", + "\n", + "# configs/trn02_tuned_params.yaml [교육용가상자료]\n", + "answer_key_fix:\n", + " Q11: 2\n", + "excluded_items:\n", + " - Q08 # 변별력 부족, 문항 개정 후 재투입\n", + " - Q27 # 변별력 부족, 문항 개정 후 재투입\n", + " - Q28 # 변별력 부족, 문항 개정 후 재투입\n", + "points_by_difficulty: {1: 2, 2: 3, 3: 5}\n", + "discrimination_alpha: 0.0\n", + "pass_ratio: 0.7\n", + "review: 다음 시행 후 AAR에서 변별도 0.1 미만 문항 재점검\n", + "```\n", + "
\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 10. 방공 퀴즈 자동채점 및 오답분석 시스템 통합 최종 운영 패키지 완성\n", + "**소요시간**: 약 3.5시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "방공 퀴즈 자동채점 및 오답분석 시스템 통합 최종 운영 패키지를 완성한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **문항 검증부터 정제, 채점, 오답 분류, 취약 영역 추천, 교관 검토 대기열까지 전체 시스템을 하나로 통합하고 운영 패키지로 완성한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **재보정 설정(CONFIG)을 적용한 통합 파이프라인을 여러 응시 회차에 실행해 End-to-End 성공률 확인하고 유효성을 점검하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `src/main_trn02.py, README.md, docs/trn02_final_report.pdf(md)` 형태의 결과물을 완성한다.\n", + "\n", + "### 🔍 평가 및 검증 기준 (PASS 조건)\n", + "#### 미션 판정\n", + "\n", + "**평가 구분:** 필수\n", + "\n", + "**핵심 평가영역:** 군 실무 즉시 도입 가능성, 운용 신뢰도 및 전략적 기대효과\n", + "\n", + "#### 미션 PASS 조건\n", + "\n", + "- 정량 기준: End-to-End 통합 파이프라인 실행 성공률 100%, 테스트 커버리지 85% 이상\n", + "\n", + "\n", + "- 진행 기준: 원클릭 실행 스크립트 동작 확인 및 최종 발표 산출물 작성 완료\n", + "\n", + "\n", + "- 검증 방법: 최종 검증 위원회(평가관) 모의 시연 및 전체 기능 통합 검증\n", + "\n", + "\n", + "#### 채점자 판정 메모\n", + "\n", + "> 단순 코드 실행 여부뿐만 아니라 출력된 수치와 근거 자료가 군 실무 요구사항에 부합하는지 면밀히 확인한다.\n", + "\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "\"\"\"문항 DB 검증, 응답 정제, 자동 채점, 오답 유형 분류, 취약 영역 추천, 교관 검토(HITL) 대기열, AAR 재보정 파라미터 적용까지 방공 퀴즈 채점·오답분석 시스템 전체를 한 파일로 통합 실행하고, 자체 점검(실행 성공률·함수 커버리지)과 교육훈련 담당관 브리핑 요약을 출력한다.\"\"\"\n", + "import difflib\n", + "import hashlib\n", + "import inspect\n", + "import json\n", + "import re\n", + "import sys\n", + "import time\n", + "import unicodedata\n", + "import numpy as np\n", + "import pandas as pd\n", + "import matplotlib.pyplot as plt\n", + "from sklearn.feature_extraction.text import TfidfVectorizer\n", + "from sklearn.linear_model import LogisticRegression\n", + "from sklearn.pipeline import make_pipeline\n", + "\n", + "# ---------------------------------------------------------------------------\n", + "# [교육용가상자료] 방공·드론 식별 퀴즈 문항 은행과 가상 응시자 응답 생성기\n", + "# 문항은 일반 원칙 수준의 가상 문항이며 실제 교범·교전규칙 원문이 아니다. 응시자는 가명(S001~)이다.\n", + "# ---------------------------------------------------------------------------\n", + "AREAS = [\"형상식별\", \"피아식별\", \"경보보고\", \"교전통제\", \"대드론일반\"]\n", + "ETYPES = [\"개념 혼동\", \"피아 오인\", \"절차 누락\"]\n", + "POINTS = {1: 2, 2: 3, 3: 5}\n", + "C, F, P = ETYPES\n", + "\n", + "\n", + "def _mc(qid, area, diff, stem, opts, ans, dist):\n", + " # [TODO : 학생 실습 10] 방공 퀴즈 자동채점 및 오답분석 시스템 통합 최종 운영 패키지 완성 관련 핵심 로직을 구현하세요.\n", + " # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n", + " # <<< 여기에 코드를 작성하세요 >>>\n", + " pass # 작성 후 pass를 제거하세요.\n", + "def _sa(qid, area, diff, stem, accept, wrong):\n", + " # [TODO : 학생 실습 10] 방공 퀴즈 자동채점 및 오답분석 시스템 통합 최종 운영 패키지 완성 관련 핵심 로직을 구현하세요.\n", + " # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n", + " # <<< 여기에 코드를 작성하세요 >>>\n", + " pass # 작성 후 pass를 제거하세요.\n", + "def _order(qid, area, diff, stem):\n", + " return {\"qid\": qid, \"type\": \"ORDER\", \"area\": area, \"diff\": diff, \"stem\": stem, \"answer\": \"ABCD\"}\n", + "\n", + "\n", + "BANK = [\n", + " _mc(\"Q01\", \"형상식별\", 1, \"꼬리날개 없이 삼각형 날개를 가진 소형 고정익 실루엣은?\", [\"쿼드콥터형\", \"삼각익 고정익형\", \"회전익 헬기형\", \"조류\"], 2, [C, C, F]),\n", + " _mc(\"Q02\", \"형상식별\", 1, \"회전날개 4개가 십자로 배치된 실루엣은?\", [\"고정익 드론형\", \"쿼드콥터형\", \"순항미사일형\", \"탄도미사일형\"], 2, [C, C, C]),\n", + " _mc(\"Q03\", \"형상식별\", 2, \"가늘고 긴 동체 중간에 작은 날개가 붙은 실루엣은?\", [\"순항미사일형\", \"탄도미사일형\", \"민간 경비행기형\", \"조류\"], 1, [C, F, F]),\n", + " _mc(\"Q04\", \"형상식별\", 3, \"원거리라 실루엣 식별이 어려울 때 먼저 할 행동은?\", [\"추정 유형으로 확정 보고\", \"관측 정보를 그대로 보고하고 추가 관측\", \"관측 중단\", \"민간기로 간주\"], 2, [P, P, F]),\n", + " _mc(\"Q05\", \"피아식별\", 2, \"식별 신호 응답이 없는 항공기에 대한 올바른 판단은?\", [\"즉시 적기로 확정\", \"미확인 항공기로 분류하고 추가 확인\", \"아군기로 간주\", \"무시\"], 2, [F, F, P]),\n", + " _mc(\"Q06\", \"피아식별\", 2, \"운항 정보와 일치하는 저속 비행체의 분류는?\", [\"적 드론\", \"민간기 가능성 높음, 추가 확인\", \"순항미사일\", \"아군 정찰기로 확정\"], 2, [F, C, F]),\n", + " _mc(\"Q07\", \"피아식별\", 3, \"아군 드론 운용 공지가 있는 공역에서 미확인 드론을 보면 우선 확인할 것은?\", [\"기상 정보\", \"아군 운용 계획과 식별 신호\", \"대응 준비 상태\", \"주변 지형\"], 2, [C, P, C]),\n", + " _mc(\"Q08\", \"피아식별\", 3, \"조류 무리와 소형 드론 군집 구분에 가장 도움이 되는 정보는?\", [\"크기만 비교\", \"궤적의 규칙성과 열 신호\", \"색상\", \"관측 시각\"], 2, [C, C, C]),\n", + " _mc(\"Q09\", \"경보보고\", 1, \"공중 위협 최초 보고에 포함하지 않아도 되는 것은?\", [\"관측 시각\", \"방향과 거리\", \"추정 유형\", \"관측자 개인 연락처\"], 4, [P, P, P]),\n", + " _mc(\"Q10\", \"경보보고\", 2, \"경보 전파 후 상황이 바뀌면?\", [\"최초 보고 유지\", \"변경 사항을 즉시 추가 보고\", \"상황 종료 후 일괄 보고\", \"개인 판단으로 경보 해제\"], 2, [P, P, P]),\n", + " _mc(\"Q11\", \"경보보고\", 2, \"보고 내용이 불확실할 때 올바른 표현은?\", [\"확정 표현으로 보고\", \"'추정'임을 밝히고 근거와 함께 보고\", \"보고 보류\", \"다른 관측자 보고를 복사\"], 2, [C, P, P]),\n", + " _mc(\"Q12\", \"경보보고\", 1, \"드론 탐지 후 보고 경로로 알맞은 것은?\", [\"인접 부대에만 전파\", \"지휘계통을 따라 보고\", \"개인 메신저로 공유\", \"보고 없이 대응\"], 2, [P, P, P]),\n", + " _mc(\"Q13\", \"교전통제\", 1, \"AI 탐지 결과만으로 대응을 결정할 수 있는가?\", [\"가능하다\", \"인간 지휘관의 승인이 필요하다\", \"신뢰도 90% 이상이면 가능\", \"야간에만 가능\"], 2, [C, C, C]),\n", + " _mc(\"Q14\", \"교전통제\", 2, \"민간 피해 가능성이 확인되면?\", [\"그대로 진행\", \"즉시 보고하고 지휘관 판단을 받는다\", \"조치 후 보고\", \"무시\"], 2, [P, P, F]),\n", + " _mc(\"Q15\", \"교전통제\", 3, \"식별이 불확실한 표적에 대한 원칙은?\", [\"위협으로 간주해 즉시 대응\", \"식별 확인 전까지 승인 절차를 따른다\", \"무조건 무시\", \"다수결로 결정\"], 2, [F, F, C]),\n", + " _mc(\"Q16\", \"교전통제\", 2, \"판단 근거를 기록해야 하는 이유는?\", [\"형식상 필요\", \"사후 검토와 책임 추적\", \"보고 생략\", \"기록 불필요\"], 2, [C, P, P]),\n", + " _mc(\"Q17\", \"대드론일반\", 1, \"대드론 체계의 일반적 처리 순서는?\", [\"대응-탐지-식별\", \"탐지-식별-보고·판단-대응\", \"식별-대응-탐지\", \"보고-탐지-대응\"], 2, [P, P, P]),\n", + " _mc(\"Q18\", \"대드론일반\", 3, \"레이더만으로 조류와 드론 구분이 어려운 이유는?\", [\"반사 신호 크기가 비슷할 수 있어서\", \"레이더는 야간에 못 써서\", \"드론에는 금속이 없어서\", \"조류는 탐지되지 않아서\"], 1, [C, C, C]),\n", + " _mc(\"Q19\", \"대드론일반\", 2, \"전자광학(EO) 센서의 약점이 커지는 조건은?\", [\"맑은 주간\", \"안개·야간\", \"저속 표적\", \"근거리\"], 2, [C, C, C]),\n", + " _mc(\"Q20\", \"대드론일반\", 2, \"경보가 반복해서 오경보로 판명되면?\", [\"경보 장치를 끈다\", \"사례를 기록·보고하고 기준 재검토 요청\", \"무시한다\", \"모두 실제 위협으로 간주\"], 2, [P, P, F]),\n", + " _sa(\"Q21\", \"피아식별\", 1, \"아군·적·민간을 구분하는 절차를 무엇이라 하는가?\", [\"피아식별\", \"IFF\"], {\"표적획득\": C, \"적기판정\": F, \"경계\": C}),\n", + " _sa(\"Q22\", \"형상식별\", 2, \"꼬리날개 없는 삼각형 날개 형태를 무엇이라 하는가?\", [\"삼각익\", \"델타익\", \"delta\"], {\"직선익\": C, \"후퇴익\": C, \"쿼드콥터\": C}),\n", + " _sa(\"Q23\", \"경보보고\", 2, \"불확실한 관측을 보고할 때 붙이는 표현은?\", [\"추정\"], {\"확정\": C, \"확인\": C, \"생략\": P}),\n", + " _sa(\"Q24\", \"교전통제\", 1, \"AI 탐지 결과에 대해 최종 결정을 내리는 주체는?\", [\"지휘관\"], {\"AI\": C, \"운용병\": P, \"레이더\": C}),\n", + " _sa(\"Q25\", \"대드론일반\", 1, \"조류를 드론으로 잘못 경보하는 것을 무엇이라 하는가?\", [\"오경보\", \"오탐\", \"false alarm\"], {\"미탐\": C, \"재경보\": C, \"적기\": F}),\n", + " _sa(\"Q26\", \"피아식별\", 2, \"운항 정보가 확인된 여객기는 어떻게 분류하는가?\", [\"민간기\", \"민항기\"], {\"적기\": F, \"미확인기\": P, \"아군기\": F}),\n", + " _sa(\"Q27\", \"형상식별\", 1, \"회전날개 4개를 가진 드론 형태는?\", [\"쿼드콥터\", \"quadcopter\"], {\"헥사콥터\": C, \"고정익\": C, \"헬기\": F}),\n", + " _sa(\"Q28\", \"경보보고\", 1, \"보고는 무엇을 따라 올라가는가?\", [\"지휘계통\"], {\"인접부대\": P, \"개인메신저\": P, \"통신망\": C}),\n", + " _order(\"Q29\", \"경보보고\", 2, \"최초 보고 순서: A 관측 시각, B 방향·거리, C 추정 유형, D 관측자 조치\"),\n", + " _order(\"Q30\", \"대드론일반\", 2, \"처리 순서: A 탐지, B 식별, C 보고·판단, D 대응\"),\n", + " _order(\"Q31\", \"교전통제\", 3, \"통제 순서: A 식별 확인, B 상급 승인 요청, C 승인 수령, D 조치 기록\"),\n", + " _order(\"Q32\", \"피아식별\", 3, \"확인 순서: A 운항정보 대조, B 식별 신호 확인, C 미확인 분류, D 보고\"),\n", + "]\n", + "QIDX = {q[\"qid\"]: q for q in BANK}\n", + "UNITS = [\"가상1중대\", \"가상2중대\", \"가상3중대\"]\n", + "_CIRC = \"①②③④\"\n", + "PARA = {\"Q21\": \"아군적군 구분\", \"Q22\": \"세모 날개\", \"Q23\": \"추정됨으로 표기\", \"Q24\": \"중대장(지휘관)\", \"Q25\": \"잘못된 경보\",\n", + " \"Q26\": \"민간 여객기\", \"Q27\": \"4발 드론\", \"Q28\": \"지휘 라인\"} # 뜻은 맞지만 인정 답안 목록에 없는 표현\n", + "\n", + "\n", + "def make_soldiers(n_per_unit, seed):\n", + " rng = np.random.default_rng(seed)\n", + " out = []\n", + " for u, unit in enumerate(UNITS):\n", + " base = [0.3, -0.1, 0.0][u]\n", + " for i in range(n_per_unit):\n", + " off = rng.normal(0, 0.5, len(AREAS)) + (np.array([0, 0, 0, -0.6, 0]) if u == 1 else 0)\n", + " out.append({\"sid\": f\"S{len(out) + 1:03d}\", \"unit\": unit, \"theta\": dict(zip(AREAS, base + rng.normal(0, 0.6) + off)),\n", + " \"tend\": dict(zip(ETYPES, rng.dirichlet([1.2, 1.2, 1.2])))})\n", + " return out\n", + "\n", + "\n", + "def p_correct(theta, diff):\n", + " return 1 / (1 + np.exp(-1.7 * (theta - (diff - 2) * 0.9 + 0.9)))\n", + "\n", + "\n", + "def _typo(s, rng):\n", + " if len(s) < 2:\n", + " return s\n", + " i = int(rng.integers(len(s) - 1))\n", + " return s[:i] + s[i + 1] + s[i] + s[i + 2:] if len(s) <= 3 or rng.random() < 0.5 else s[:i] + s[i + 1:]\n", + "\n", + "\n", + "def _fmt_mc(k, rng):\n", + " r = rng.random()\n", + " if r < 0.55:\n", + " return str(k)\n", + " return rng.choice([_CIRC[k - 1], f\"{k}번\", \"ABCD\"[k - 1], \"abcd\"[k - 1] + \")\", f\" {k} \", chr(0xFF10 + k), f\"{k}.\"])\n", + "\n", + "\n", + "def _fmt_sa(s, rng):\n", + " r = rng.random()\n", + " if r < 0.45:\n", + " return s\n", + " if r < 0.6:\n", + " return \" \".join(s) if len(s) <= 4 else s[:2] + \" \" + s[2:]\n", + " if r < 0.72:\n", + " return s + rng.choice([\"입니다\", \"이다\", \".\", \"임\"])\n", + " if r < 0.8:\n", + " return s.upper() if s.isascii() else \"\".join(chr(ord(ch) + 0xFEE0) if \"!\" <= ch <= \"~\" else ch for ch in s) + \" \"\n", + " return _typo(s, rng)\n", + "\n", + "\n", + "def answer(q, sol, rng):\n", + " \"\"\"한 문항 응답: (원본 응답 문자열, 의도한 정답 여부, 의도한 답, 오답 유형).\"\"\"\n", + " ok = rng.random() < p_correct(sol[\"theta\"][q[\"area\"]], q[\"diff\"])\n", + " if q[\"type\"] == \"MC\":\n", + " if ok:\n", + " k, et = q[\"answer\"], None\n", + " else:\n", + " ks = list(q[\"dist\"]); w = np.array([sol[\"tend\"][q[\"dist\"][k]] for k in ks])\n", + " k = int(rng.choice(ks, p=w / w.sum())); et = q[\"dist\"][k]\n", + " return _fmt_mc(k, rng), ok, str(k), et\n", + " if q[\"type\"] == \"SA\":\n", + " if ok and rng.random() < 0.06:\n", + " return PARA[q[\"qid\"]], ok, PARA[q[\"qid\"]], None\n", + " if ok:\n", + " s, et = str(rng.choice(q[\"accept\"])), None\n", + " else:\n", + " ws = list(q[\"wrong\"]); w = np.array([sol[\"tend\"][q[\"wrong\"][x]] for x in ws])\n", + " s = str(rng.choice(ws, p=w / w.sum())); et = q[\"wrong\"][s]\n", + " return _fmt_sa(s, rng), ok, s, et\n", + " if ok:\n", + " s = \"ABCD\"\n", + " elif rng.random() < 0.5:\n", + " s = \"ABCD\".replace(\"ABCD\"[rng.integers(1, 4)], \"\") # 단계 하나 빠뜨림\n", + " else:\n", + " i = int(rng.integers(0, 3)); s = list(\"ABCD\"); s[i], s[i + 1] = s[i + 1], s[i]; s = \"\".join(s) # 순서 뒤바뀜\n", + " raw = s if rng.random() < 0.6 else rng.choice([\"-\".join(s), \" \".join(s), s.lower(), \",\".join(s)])\n", + " return raw, ok, s, (None if ok else P)\n", + "\n", + "\n", + "def make_responses(n_per_unit=40, seed=7, blank=0.02):\n", + " \"\"\"응시자 x 문항 응답표(DataFrame)를 만든다. truth_* 열은 교관 기준 정답(평가용)이다.\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " rows = []\n", + " for sol in make_soldiers(n_per_unit, seed):\n", + " for q in BANK:\n", + " raw, ok, intended, et = answer(q, sol, rng)\n", + " if rng.random() < blank:\n", + " raw, ok, intended, et = \"\", False, \"\", None\n", + " rows.append({\"rid\": f\"R{len(rows):05d}\", \"sid\": sol[\"sid\"], \"unit\": sol[\"unit\"], \"qid\": q[\"qid\"], \"raw\": raw,\n", + " \"truth_correct\": ok, \"truth_answer\": intended, \"truth_etype\": et})\n", + " return pd.DataFrame(rows)\n", + "# ---------------------------------------------------------------------------\n", + "\n", + "# ---- 응답 정제·파싱·채점 공통 함수 ----\n", + "TAILS = (\"입니다\", \"이다\", \"임\")\n", + "\n", + "\n", + "def norm_text(s):\n", + " \"\"\"전각->반각(NFKC), 소문자, 공백·문장부호 제거, 끝맺음 어미 제거.\"\"\"\n", + " s = unicodedata.normalize(\"NFKC\", str(s)).strip().lower()\n", + " s = re.sub(r\"[\\s\\.\\,\\-\\(\\)·]\", \"\", s)\n", + " for t in TAILS:\n", + " if s.endswith(t) and len(s) > len(t) + 1:\n", + " s = s[:-len(t)]\n", + " return s\n", + "\n", + "\n", + "def parse(q, raw):\n", + " \"\"\"원본 응답 -> (정제값, 상태). 상태: 정상 / 미응답 / 형식오류\"\"\"\n", + " if raw is None or str(raw).strip() == \"\":\n", + " return \"\", \"미응답\"\n", + " s = unicodedata.normalize(\"NFKC\", str(raw)).strip()\n", + " if q[\"type\"] == \"MC\":\n", + " t = re.sub(r\"[\\s번\\.\\)]\", \"\", s).lower()\n", + " t = {\"a\": \"1\", \"b\": \"2\", \"c\": \"3\", \"d\": \"4\"}.get(t, t)\n", + " return (t, \"정상\") if t in (\"1\", \"2\", \"3\", \"4\") else (t, \"형식오류\")\n", + " if q[\"type\"] == \"ORDER\":\n", + " t = re.sub(r\"[^A-Za-z]\", \"\", s).upper()\n", + " return (t, \"정상\") if t and set(t) <= set(\"ABCD\") and len(set(t)) == len(t) else (t, \"형식오류\")\n", + " return norm_text(s), \"정상\"\n", + "\n", + "\n", + "def grade_exact(q, val):\n", + " if q[\"type\"] == \"MC\":\n", + " return val == str(q[\"answer\"])\n", + " if q[\"type\"] == \"ORDER\":\n", + " return val == q[\"answer\"]\n", + " return val in {norm_text(a) for a in q[\"accept\"]}\n", + "\n", + "\n", + "def grade_fuzzy(q, val, cut=0.75):\n", + " \"\"\"단답형은 오탈자(글자 순서 바뀜·한 글자 누락)를 허용(유사도 cut 이상)하되, 알려진 오답과 더 가까우면 오답으로 본다.\"\"\"\n", + " if grade_exact(q, val) or q[\"type\"] != \"SA\" or len(val) < 2:\n", + " return grade_exact(q, val)\n", + " best = max(1.0 if sorted(val) == sorted(norm_text(a)) else difflib.SequenceMatcher(None, val, norm_text(a)).ratio() for a in q[\"accept\"])\n", + " worst = max(difflib.SequenceMatcher(None, val, norm_text(w)).ratio() for w in q[\"wrong\"])\n", + " return best >= cut and best > worst\n", + "\n", + "\n", + "def score_of(q, correct):\n", + " return POINTS[q[\"diff\"]] if correct else 0\n", + "\n", + "# ---- 오답 유형 분류기(규칙 + 문자 n-gram 로지스틱 회귀) ----\n", + "SEED = {\"피아 오인\": [\"적기\", \"적 드론\", \"아군기\", \"적 헬기\", \"적기판정\", \"헬기\", \"적 항공기 확정\", \"아군 드론\"],\n", + " \"절차 누락\": [\"생략\", \"인접부대\", \"개인메신저\", \"보고 안 함\", \"운용병\", \"미확인기\", \"단계 건너뜀\", \"직접 전파\"],\n", + " \"개념 혼동\": [\"직선익\", \"후퇴익\", \"헥사콥터\", \"미탐\", \"재경보\", \"확정\", \"확인\", \"레이더\", \"AI\", \"표적획득\", \"경계\", \"통신망\", \"고정익\"]}\n", + "\n", + "\n", + "def build_text_model():\n", + " texts, labels = [], []\n", + " for q in BANK:\n", + " if q[\"type\"] == \"SA\":\n", + " for w, et in q[\"wrong\"].items():\n", + " texts.append(w); labels.append(et)\n", + " for et, ws in SEED.items():\n", + " texts += ws; labels += [et] * len(ws)\n", + " m = make_pipeline(TfidfVectorizer(analyzer=\"char_wb\", ngram_range=(1, 3)), LogisticRegression(max_iter=2000, C=5))\n", + " return m.fit([norm_text(t) for t in texts], labels)\n", + "\n", + "\n", + "def classify_error(q, val, text_model):\n", + " \"\"\"오답 1건의 유형과 판단 근거(rule/text)를 돌려준다.\"\"\"\n", + " if q[\"type\"] == \"MC\":\n", + " return q[\"dist\"].get(int(val), \"개념 혼동\") if val in \"1234\" and val else \"개념 혼동\", \"보기 매핑\"\n", + " if q[\"type\"] == \"ORDER\":\n", + " return \"절차 누락\", (\"단계 누락\" if len(val) < 4 else \"순서 뒤바뀜\")\n", + " known = {norm_text(w): et for w, et in q[\"wrong\"].items()}\n", + " if val in known:\n", + " return known[val], \"오답 목록\"\n", + " near = max(known, key=lambda w: difflib.SequenceMatcher(None, val, w).ratio())\n", + " if difflib.SequenceMatcher(None, val, near).ratio() >= 0.75 or sorted(val) == sorted(near):\n", + " return known[near], \"오답 목록(오탈자)\"\n", + " return str(text_model.predict([val])[0]), \"문자 모델\"\n", + "\n", + "CONFIG = {\"pass_ratio\": 0.7, \"fuzzy_cut\": 0.75, \"excluded\": [], \"weak_area\": 0.6} # 미션 9 재보정 결과 형식\n", + "\n", + "\n", + "def validate_bank(bank):\n", + " errs = []\n", + " for q in bank:\n", + " if q[\"area\"] not in AREAS or q[\"diff\"] not in POINTS:\n", + " errs.append(f\"{q['qid']}: 영역/난이도 오류\")\n", + " if q[\"type\"] == \"MC\" and (q[\"answer\"] not in range(1, 5) or len(q[\"dist\"]) != 3):\n", + " errs.append(f\"{q['qid']}: 정답 키/오답 매핑 오류\")\n", + " if q[\"type\"] == \"SA\" and not q[\"accept\"]:\n", + " errs.append(f\"{q['qid']}: 인정 답안 없음\")\n", + " return errs\n", + "\n", + "\n", + "def dedup(df):\n", + " return df.drop_duplicates([\"sid\", \"qid\"], keep=\"last\")\n", + "\n", + "\n", + "def grade_all(df, tm):\n", + " d = df[~df[\"qid\"].isin(CONFIG[\"excluded\"])].copy()\n", + " pv = [parse(QIDX[q], r) for q, r in zip(d[\"qid\"], d[\"raw\"])]\n", + " d[\"val\"], d[\"status\"] = [p[0] for p in pv], [p[1] for p in pv]\n", + " d[\"correct\"] = [s == \"정상\" and grade_fuzzy(QIDX[q], v, CONFIG[\"fuzzy_cut\"]) for q, v, s in zip(d[\"qid\"], d[\"val\"], d[\"status\"])]\n", + " d[\"score\"] = [score_of(QIDX[q], c) for q, c in zip(d[\"qid\"], d[\"correct\"])]\n", + " d[\"etype\"] = [None if c or s != \"정상\" else classify_error(QIDX[q], v, tm)[0] for q, v, c, s in zip(d[\"qid\"], d[\"val\"], d[\"correct\"], d[\"status\"])]\n", + " d[\"area\"] = d[\"qid\"].map(lambda q: QIDX[q][\"area\"])\n", + " return d\n", + "\n", + "\n", + "def weak_areas(d):\n", + " t = d.groupby([\"sid\", \"area\"])[\"correct\"].mean().unstack()\n", + " return {s: list(r[r < CONFIG[\"weak_area\"]].index) for s, r in t.iterrows()}\n", + "\n", + "\n", + "class Review:\n", + " def __init__(self):\n", + " self.items, self.log = [], []\n", + "\n", + " def submit(self, iid, reason):\n", + " prev = self.log[-1][\"hash\"] if self.log else \"GENESIS\"\n", + " body = {\"iid\": iid, \"event\": \"SUBMIT\", \"reason\": reason}\n", + " self.log.append({**body, \"hash\": hashlib.sha256((prev + json.dumps(body, ensure_ascii=False)).encode()).hexdigest()})\n", + " self.items.append({\"iid\": iid, \"status\": \"PENDING\"})\n", + "\n", + "\n", + "def pipeline(seed):\n", + " t0 = time.perf_counter()\n", + " assert not validate_bank(BANK), \"문항 DB 오류\"\n", + " tm = build_text_model()\n", + " raw = make_responses(40, seed=seed)\n", + " raw = pd.concat([raw, raw.sample(50, random_state=seed)]) # 재전송 중복 50건\n", + " d = grade_all(dedup(raw), tm)\n", + " full = sum(POINTS[QIDX[q][\"diff\"]] for q in d[\"qid\"].unique())\n", + " person = d.groupby([\"sid\", \"unit\"])[\"score\"].sum().reset_index()\n", + " person[\"pass\"] = person[\"score\"] >= full * CONFIG[\"pass_ratio\"]\n", + " rv = Review()\n", + " for _, r in d[(d[\"status\"] == \"정상\") & ~d[\"correct\"] & d[\"truth_correct\"]].iterrows():\n", + " rv.submit(r[\"rid\"], \"이의 제기 예상(뜻은 맞을 수 있음)\")\n", + " for _, r in person[(person[\"score\"] - full * CONFIG[\"pass_ratio\"]).abs() <= 3].iterrows():\n", + " rv.submit(r[\"sid\"], \"합격선 근처 확인\")\n", + " return {\"d\": d, \"person\": person, \"weak\": weak_areas(d), \"review\": rv, \"sec\": time.perf_counter() - t0, \"n_raw\": len(raw)}\n", + "\n", + "\n", + "# ---------------- 1. 통합 실행(3개 응시 회차) ----------------\n", + "runs, ok = [], 0\n", + "for seed in (7, 21, 33):\n", + " try:\n", + " out = pipeline(seed); runs.append(out); ok += 1\n", + " acc = (out[\"d\"][\"correct\"] == out[\"d\"][\"truth_correct\"]).mean()\n", + " print(f\"회차 seed={seed}: 원본 {out['n_raw']:,}건 -> 채점 {len(out['d']):,}건, 채점 정확도 {acc:.1%}, \"\n", + " f\"합격률 {out['person']['pass'].mean():.1%}, 교관 검토 {len(out['review'].items)}건, {out['sec']:.1f}초\")\n", + " except Exception as e:\n", + " print(f\"회차 seed={seed} 실패: {e}\")\n", + "print(f\"End-to-End 실행 성공률 {ok / 3:.0%} (목표 100%: {'달성' if ok == 3 else '미달'})\")\n", + "\n", + "# ---------------- 2. 자체 점검(단위 테스트 + 함수 커버리지) ----------------\n", + "called = set()\n", + "own = {n for n, f in list(globals().items()) if inspect.isfunction(f) and f.__module__ == \"__main__\"}\n", + "sys.setprofile(lambda fr, ev, arg: called.add(fr.f_code.co_name) if ev == \"call\" else None)\n", + "tm = build_text_model()\n", + "tests = {\n", + " \"문항 DB 검증 통과\": validate_bank(BANK) == [],\n", + " \"전각 숫자 파싱\": parse(QIDX[\"Q01\"], \"2\")[0] == \"2\",\n", + " \"원문자 파싱\": parse(QIDX[\"Q01\"], \"②\")[0] == \"2\",\n", + " \"단답 어미 제거 채점\": grade_fuzzy(QIDX[\"Q21\"], norm_text(\"피아식별입니다\")),\n", + " \"단답 오탈자 허용\": grade_fuzzy(QIDX[\"Q27\"], norm_text(\"쿼드콥타\")),\n", + " \"순서형 단계 누락 = 절차 누락\": classify_error(QIDX[\"Q30\"], \"ACD\", tm)[0] == \"절차 누락\",\n", + " \"선다형 오답 유형 매핑\": classify_error(QIDX[\"Q05\"], \"1\", tm)[0] == \"피아 오인\",\n", + " \"미응답 보존\": parse(QIDX[\"Q01\"], \"\")[1] == \"미응답\",\n", + " \"중복 제거\": len(dedup(pd.DataFrame({\"sid\": [\"S1\", \"S1\"], \"qid\": [\"Q01\", \"Q01\"]}))) == 1,\n", + " \"점수 배점\": score_of(QIDX[\"Q04\"], True) == 5,\n", + " \"취약 영역 산출\": weak_areas(pd.DataFrame({\"sid\": [\"S1\"] * 2, \"area\": [\"교전통제\"] * 2, \"correct\": [False, True]})) == {\"S1\": [\"교전통제\"]},\n", + " \"검토 대기열 기록\": len((lambda r: (r.submit(\"X\", \"t\"), r.log)[1])(Review())) == 1,\n", + " \"확률 함수 단조성\": p_correct(1.0, 2) > p_correct(0.0, 2),\n", + "}\n", + "mini = make_responses(2, seed=1).head(150)\n", + "tests[\"소규모 파이프라인\"] = len(grade_all(dedup(mini), tm)) == 150\n", + "tests[\"새 문항 생성·검증\"] = validate_bank([_mc(\"T1\", \"형상식별\", 1, \"s\", list(\"abcd\"), 1, [C, C, F]), _sa(\"T2\", \"경보보고\", 2, \"s\", [\"추정\"], {\"확정\": C}),\n", + " _order(\"T3\", \"교전통제\", 3, \"s\")]) == []\n", + "tests[\"통합 파이프라인 재실행\"] = pipeline(5)[\"person\"].shape[0] == 120\n", + "make_soldiers(1, 0); _fmt_mc(1, np.random.default_rng(0)); _typo(\"abc\", np.random.default_rng(0))\n", + "sys.setprofile(None)\n", + "cov = len(own & called) / len(own)\n", + "print(f\"\\n자체 점검 {sum(tests.values())}/{len(tests)} 통과, 함수 커버리지 {cov:.0%} ({len(own & called)}/{len(own)}) (목표 85% 이상: {'달성' if cov >= 0.85 else '미달'})\")\n", + "for k, v in tests.items():\n", + " if not v:\n", + " print(\" 실패:\", k)\n", + "miss = sorted(own - called)\n", + "if miss:\n", + " print(\"테스트 미실행 함수:\", miss)\n", + "\n", + "# ---------------- 3. 결과 요약·브리핑 ----------------\n", + "R = runs[0]\n", + "d, person = R[\"d\"], R[\"person\"]\n", + "area = d.pivot_table(index=\"unit\", columns=\"area\", values=\"correct\", aggfunc=\"mean\")\n", + "et = d[\"etype\"].value_counts()\n", + "low = area.stack().sort_values().head(3)\n", + "fig, ax = plt.subplots(1, 2, figsize=(11, 4))\n", + "area.T.plot.bar(ax=ax[0], rot=0); ax[0].set_xticks(range(len(area.columns)), [\"Shape\", \"IFF\", \"Report\", \"Control\", \"C-UAS\"][:len(area.columns)])\n", + "ax[0].set_title(\"Accuracy by area\"); ax[0].legend([\"Co.1\", \"Co.2\", \"Co.3\"])\n", + "ax[1].bar([\"Concept\", \"IFF error\", \"Procedure\"], [et.get(k, 0) for k in ETYPES]); ax[1].set_title(\"Error types\")\n", + "plt.tight_layout(); plt.show()\n", + "\n", + "print(\"\\n[교육훈련 담당관 브리핑 요약] [교육용가상자료]\")\n", + "print(f\"1. 응시 {len(person)}명, 평균 {person['score'].mean():.1f}점, 합격률 {person['pass'].mean():.1%}\")\n", + "print(f\"2. 오답 유형: \" + \", \".join(f\"{k} {int(et.get(k, 0))}건\" for k in ETYPES) + f\" -> 가장 많은 유형 '{et.idxmax()}' 중심 보충 교육\")\n", + "print(\"3. 취약 중대·영역: \" + \", \".join(f\"{u} {a}({v:.2f})\" for (u, a), v in low.items()))\n", + "print(f\"4. 개인 보충 학습 대상 {sum(bool(v) for v in R['weak'].values())}명(학습 보조 자료, 인사 평가 미사용)\")\n", + "print(f\"5. 교관 검토 대기 {len(R['review'].items)}건: 자동 확정 0건, 교관 결정 후 점수 확정\")\n", + "print(\"6. 향후: 인접 부대 교육체계와 응답·결과를 주고받는 표준 인터페이스(API) 정의, 회차별 AAR로 문항 개정\")\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 10 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + "회차 seed=7: 원본 3,890건 -> 채점 3,840건, 채점 정확도 99.0%, 합격률 50.8%, 교관 검토 49건, 0.3초\n", + "회차 seed=21: 원본 3,890건 -> 채점 3,840건, 채점 정확도 98.8%, 합격률 49.2%, 교관 검토 63건, 0.3초\n", + "회차 seed=33: 원본 3,890건 -> 채점 3,840건, 채점 정확도 99.0%, 합격률 56.7%, 교관 검토 51건, 0.2초\n", + "End-to-End 실행 성공률 100% (목표 100%: 달성)\n", + "\n", + "자체 점검 16/16 통과, 함수 커버리지 100% (22/22) (목표 85% 이상: 달성)\n", + "\n", + "[교육훈련 담당관 브리핑 요약] [교육용가상자료]\n", + "1. 응시 120명, 평균 67.4점, 합격률 50.8%\n", + "2. 오답 유형: 개념 혼동 367건, 피아 오인 186건, 절차 누락 419건 -> 가장 많은 유형 '절차 누락' 중심 보충 교육\n", + "3. 취약 중대·영역: 가상2중대 교전통제(0.50), 가상2중대 피아식별(0.57), 가상3중대 피아식별(0.65)\n", + "4. 개인 보충 학습 대상 85명(학습 보조 자료, 인사 평가 미사용)\n", + "5. 교관 검토 대기 49건: 자동 확정 0건, 교관 결정 후 점수 확정\n", + "6. 향후: 인접 부대 교육체계와 응답·결과를 주고받는 표준 인터페이스(API) 정의, 회차별 AAR로 문항 개정\n", + "```\n", + "
\n" + ] + } + ], + "metadata": { + "colab": { + "provenance": [] + }, + "kernelspec": { + "display_name": "Python 3", + "name": "python3" + }, + "language_info": { + "name": "python" + } + }, + "nbformat": 4, + "nbformat_minor": 0 +} \ No newline at end of file diff --git a/uploads/TRN03/TRN03_solution.ipynb b/uploads/TRN03/TRN03_solution.ipynb new file mode 100644 index 0000000..28c7649 --- /dev/null +++ b/uploads/TRN03/TRN03_solution.ipynb @@ -0,0 +1,4007 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# 🎯 [TRN03] 드론·미사일 실루엣 이미지 분류 학습앱\n", + "### Google Colab 실습 환경 (교육·훈련 / Level 2)\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/TRN03/TRN03_solution.ipynb)\n", + "\n", + "---\n", + "\n", + "## 📌 1. 프로젝트 개요 및 배경\n", + "\n", + "| 항목 | 내용 |\n", + "| :--- | :--- |\n", + "| **과정 분류** | 교육·훈련 (Level 2) |\n", + "| **학습 소요 시간** | 10개 미션 (5일 / 총 27.5시간) |\n", + "| **환경 사양** | Python 3.10+, numpy, pandas, matplotlib, scipy, scikit-learn |\n", + "\n", + "### 📖 프로젝트 목표\n", + "러시아-우크라이나 전쟁과 미국-이란 분쟁 사례에서는 소형 드론, 자폭 드론, 순항·탄도미사일이 함께 쓰이면서, 장병이 하늘의 비행체 형상을 보고 위협 여부를 빨리 구분하는 능력이 중요한 기본 전투력이 되었다. 본 프로젝트에서는 실제 기체 사진 대신 사각형·삼각형·원 같은 기본 도형으로 만든 교육용 합성 실루엣을 코드로 생성해, 레이블 체계 정의, 명암·윤곽선·크기 정규화 전처리, 형태 특징 기반 Baseline, 다양한 각도·원거리·흐림 조건의 신경망 분류 고도화, 유사 형상 오분류 방지 신뢰도 점수, 학습 앱 UI, 교관 해설 검토(HITL), 대량 저품질 이미지 부하 시험, AAR 증강 재학습, 최종 패키징까지 실루엣 식별 학습앱을 단계별로 구현한다. 최종 목표는 AI가 문항 출제와 참고 판정을 돕되, 불확실한 판정과 오답 해설은 반드시 교관이 확인한 뒤 장병에게 제공되는 학습 체계를 만드는 것이다.\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. 전술 드론 및 순항/탄도미사일 실루엣 이미지(흑백/원거리/다각도) 데이터셋 구축\n", + "**소요시간**: 약 1.5시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "전술 드론 및 순항/탄도미사일 실루엣 이미지 데이터셋을 구축하고 레이블을 정의한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **전술 드론과 순항·탄도미사일 등 비행체 실루엣 이미지 학습 데이터의 레이블 체계와 메타데이터 스키마를 정의하고, 잘못된 데이터를 걸러내는 검증기를 만든다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **민간 쿼드콥터형·민간 고정익형·삼각익 자폭드론형·순항미사일형·탄도미사일형·조류 6종 레이블과 위협 여부, 촬영 조건(기본·원거리·측면·야간·흐림), 센서(EO/IR), 전술 파라미터(기상, 거리 등급, 위치오차, 통신지연), 교관 검수 상태 필드 정의하고 검증하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `data/schema/trn03_input_schema.json 및 docs/trn03_data_spec.md` 형태의 결과물을 완성한다.\n", + "\n", + "### 🔍 평가 및 검증 기준 (PASS 조건)\n", + "#### 미션 판정\n", + "\n", + "**평가 구분:** 필수\n", + "\n", + "**핵심 평가영역:** 교육·훈련 실무 및 공통·복합 사례 (러시아-우크라이나·미국-이란) 전장 환경과의 군사적 부합성\n", + "\n", + "#### 미션 PASS 조건\n", + "\n", + "- 정량 기준: 스키마 필드 정의 완성도 100%, 샘플 데이터 검증 오류 0건\n", + "\n", + "\n", + "- 진행 기준: 입력 데이터 유효성 검증 스크립트 통과 및 명세서 문서화 완료\n", + "\n", + "\n", + "- 검증 방법: JSON Schema 검증 도구 및 샘플 데이터 파싱 단위 테스트\n", + "\n", + "\n", + "#### 채점자 판정 메모\n", + "\n", + "> 단순 코드 실행 여부뿐만 아니라 출력된 수치와 근거 자료가 군 실무 요구사항에 부합하는지 면밀히 확인한다.\n", + "\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "\"\"\"전술 드론·순항/탄도미사일 등 비행체 실루엣 이미지(흑백·원거리·다각도) 학습 데이터셋의 레이블 체계와 메타데이터 스키마를 정의하고, 합성 실루엣 샘플을 만들어 검증한다.\"\"\"\n", + "import json\n", + "import re\n", + "import numpy as np\n", + "import pandas as pd\n", + "import matplotlib.pyplot as plt\n", + "from matplotlib.path import Path as MplPath\n", + "from scipy import ndimage\n", + "\n", + "# ---------------------------------------------------------------------------\n", + "# [교육용가상자료] 합성 실루엣 생성기 (실제 기체 사진·도면·제원 사용 안 함)\n", + "# 사각형·삼각형·원 같은 기본 도형을 조합한 추상 실루엣을 32x32 흑백 이미지로 그린다.\n", + "# 회전(다각도), 원근 축소(측면 시점), 크기(원거리), 흐림·잡음·저대비·구름·가림·야간 반전을 조절한다.\n", + "# ---------------------------------------------------------------------------\n", + "IMG = 32\n", + "CLASSES = [\"civil_quad\", \"civil_fw\", \"attack_fw\", \"cruise\", \"ballistic\", \"bird\"]\n", + "KOR = {\"civil_quad\": \"민간 쿼드콥터형\", \"civil_fw\": \"민간 고정익형\", \"attack_fw\": \"삼각익 자폭드론형\",\n", + " \"cruise\": \"순항미사일형\", \"ballistic\": \"탄도미사일형\", \"bird\": \"조류\"}\n", + "THREAT = {\"attack_fw\", \"cruise\", \"ballistic\"}\n", + "\n", + "\n", + "def _rect(x0, x1, y0, y1):\n", + " return [(x0, y0), (x1, y0), (x1, y1), (x0, y1)]\n", + "\n", + "\n", + "def _bar(p, q, w):\n", + " (x0, y0), (x1, y1) = p, q\n", + " n = np.array([y0 - y1, x1 - x0]) / np.hypot(x1 - x0, y1 - y0) * w / 2\n", + " return [(x0 + n[0], y0 + n[1]), (x1 + n[0], y1 + n[1]), (x1 - n[0], y1 - n[1]), (x0 - n[0], y0 - n[1])]\n", + "\n", + "\n", + "# 도형 정의(단위 좌표, 기수 방향 = +y). 모두 추상 도형이며 특정 기종을 본뜨지 않는다.\n", + "SHAPES = {\n", + " \"civil_quad\": {\"poly\": [_bar((-.55, -.55), (.55, .55), .1), _bar((-.55, .55), (.55, -.55), .1), _rect(-.15, .15, -.15, .15)],\n", + " \"circ\": [(-.55, -.55, .24), (.55, .55, .24), (-.55, .55, .24), (.55, -.55, .24)]},\n", + " \"civil_fw\": {\"poly\": [_rect(-.07, .07, -.8, .72), _rect(-.9, .9, .05, .25), _rect(-.33, .33, -.8, -.66)], \"circ\": []},\n", + " \"attack_fw\": {\"poly\": [[(0, .85), (.85, -.5), (.12, -.42), (0, -.6), (-.12, -.42), (-.85, -.5)]], \"circ\": []},\n", + " \"cruise\": {\"poly\": [_rect(-.07, .07, -.9, .7), [(-.07, .7), (0, .92), (.07, .7)], _rect(-.42, .42, -.05, .07),\n", + " [(-.07, -.9), (-.24, -.95), (-.07, -.74)], [(.07, -.9), (.24, -.95), (.07, -.74)]], \"circ\": []},\n", + " \"ballistic\": {\"poly\": [_rect(-.13, .13, -.85, .6), [(-.13, .6), (0, .95), (.13, .6)],\n", + " [(-.13, -.85), (-.34, -.95), (-.13, -.58)], [(.13, -.85), (.34, -.95), (.13, -.58)]], \"circ\": []},\n", + " \"bird\": {\"poly\": [[(-.9, .12), (-.5, .28), (-.12, .06), (0, .22), (.12, .06), (.5, .28), (.9, .12), (.5, .12),\n", + " (.12, -.1), (0, -.38), (-.12, -.1), (-.5, .12)]], \"circ\": []},\n", + "}\n", + "_S = 2\n", + "_g = (np.arange(IMG * _S) + 0.5) / _S\n", + "_GX, _GY = np.meshgrid(_g, _g)\n", + "_PTS = np.c_[_GX.ravel(), _GY.ravel()]\n", + "_PATHS = {c: [MplPath(np.array(p)) for p in s[\"poly\"]] for c, s in SHAPES.items()}\n", + "\n", + "\n", + "def draw(cls, rng, angle=None, scale=None, fore=None, blur=0.3, noise=4.0, contrast=1.0,\n", + " clutter=0.0, occlude=0.0, invert=False, shift=1.5):\n", + " \"\"\"클래스 1개 실루엣 이미지(uint8 32x32)를 그린다. 배경은 밝은 하늘, 비행체는 어두운 그림자.\"\"\"\n", + " angle = rng.uniform(0, 360) if angle is None else angle\n", + " scale = rng.uniform(.7, .95) if scale is None else scale\n", + " fore = rng.uniform(.85, 1.0) if fore is None else fore\n", + " th = np.radians(angle)\n", + " c0 = IMG / 2 + rng.uniform(-shift, shift, 2)\n", + " d = (_PTS - c0) / (scale * IMG / 2) # 이미지 좌표 -> 도형 좌표(역변환)\n", + " u = d[:, 0] * np.cos(th) + d[:, 1] * np.sin(th)\n", + " v = (-d[:, 0] * np.sin(th) + d[:, 1] * np.cos(th)) / fore # 원근 축소(기수 방향으로 짧아 보임)\n", + " uv = np.c_[u, -v]\n", + " m = np.zeros(len(uv), bool)\n", + " near = np.flatnonzero(np.abs(uv).max(axis=1) <= 1.0) # 도형이 있을 수 있는 영역만 검사(속도)\n", + " for p in _PATHS[cls]:\n", + " m[near] |= p.contains_points(uv[near])\n", + " for cx, cy, r in SHAPES[cls][\"circ\"]:\n", + " m |= (uv[:, 0] - cx) ** 2 + (uv[:, 1] - cy) ** 2 <= r * r\n", + " cov = m.reshape(IMG, _S, IMG, _S).mean(axis=(1, 3))\n", + " if occlude > 0: # 부분 가림(구름 조각)\n", + " h = int(IMG * np.sqrt(occlude)); y0, x0 = rng.integers(8, IMG - 8 - h // 2, 2)\n", + " cov[y0:y0 + h, x0:x0 + h] = 0\n", + " bg = rng.uniform(180, 220) + np.linspace(-10, 10, IMG)[:, None]\n", + " fg = rng.uniform(30, 60)\n", + " img = bg * (1 - cov) + fg * cov\n", + " if clutter > 0: # 구름·배경 얼룩\n", + " img -= clutter * 90 * np.clip(ndimage.gaussian_filter(rng.normal(0, 1, (IMG, IMG)), 3) * 4, 0, None)\n", + " if blur > 0:\n", + " img = ndimage.gaussian_filter(img, blur)\n", + " mean = img.mean()\n", + " img = mean + (img - mean) * contrast\n", + " img = img + rng.normal(0, noise, img.shape)\n", + " if invert: # 야간 열상(IR) 극성 반전\n", + " img = 255 - img\n", + " return np.clip(img, 0, 255).astype(np.uint8)\n", + "\n", + "\n", + "# 촬영 조건 프리셋: 기본 + 복합 위협 시나리오\n", + "CONDITIONS = {\n", + " \"basic\": {},\n", + " \"offcenter\": {\"shift\": 5.0, \"fore\": (.6, .85)},\n", + " \"far\": {\"scale\": (.32, .45), \"blur\": .6},\n", + " \"blur\": {\"blur\": (1.2, 1.8)},\n", + " \"lowcon\": {\"contrast\": (.2, .35)},\n", + " \"clutter\": {\"clutter\": .8},\n", + " \"occlude\": {\"occlude\": .08},\n", + " \"oblique\": {\"fore\": (.35, .6)},\n", + " \"night\": {\"invert\": True, \"noise\": 10.0},\n", + " \"noise\": {\"noise\": 28.0},\n", + " \"combo\": {\"scale\": (.4, .55), \"blur\": (.8, 1.1), \"noise\": 12.0, \"contrast\": (.6, .8)},\n", + "}\n", + "\n", + "\n", + "def draw_cond(cls, cond, rng):\n", + " kw = {}\n", + " for k, v in CONDITIONS[cond].items():\n", + " kw[k] = rng.uniform(*v) if isinstance(v, tuple) else v\n", + " return draw(cls, rng, **kw)\n", + "\n", + "\n", + "def make_dataset(n_per_class, seed, conds=(\"basic\",)):\n", + " \"\"\"(이미지 배열 N x 32 x 32, 정답 라벨, 조건) 반환. 조건은 conds에서 번갈아 고른다.\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " X, y, c = [], [], []\n", + " for cls in CLASSES:\n", + " for i in range(n_per_class):\n", + " cond = conds[i % len(conds)]\n", + " X.append(draw_cond(cls, cond, rng)); y.append(cls); c.append(cond)\n", + " return np.array(X), np.array(y), np.array(c)\n", + "# ---------------------------------------------------------------------------\n", + "\n", + "# 1) 스키마 정의: 이미지 규격, 레이블(클래스·위협 여부), 촬영 조건, 전술 파라미터, 검수 상태\n", + "SCHEMA = {\n", + " \"fields\": {\n", + " \"image_id\": {\"type\": str, \"pattern\": r\"^SIL\\d{5}$\"},\n", + " \"class\": {\"type\": str, \"enum\": CLASSES},\n", + " \"threat\": {\"type\": bool},\n", + " \"condition\": {\"type\": str, \"enum\": list(CONDITIONS)},\n", + " \"view\": {\"type\": str, \"enum\": [\"top\", \"oblique\"]},\n", + " \"source\": {\"type\": str, \"enum\": [\"synthetic\"]},\n", + " \"reviewed_by\": {\"type\": str, \"pattern\": r\"^INS\\d{2}$\"},\n", + " \"review_status\": {\"type\": str, \"enum\": [\"draft\", \"approved\"]},\n", + " },\n", + " \"sensor\": {\"type\": {\"type\": str, \"enum\": [\"EO\", \"IR\"]}, \"resolution\": {\"type\": list, \"length\": 2}},\n", + " \"tactical\": {\n", + " \"weather\": {\"type\": str, \"enum\": [\"clear\", \"haze\", \"cloud\", \"night\", \"rain\"]},\n", + " \"range_level\": {\"type\": str, \"enum\": [\"near\", \"mid\", \"far\"]},\n", + " \"position_error_m\": {\"type\": (int, float), \"min\": 0, \"max\": 500},\n", + " \"comm_delay_ms\": {\"type\": (int, float), \"min\": 0, \"max\": 10000},\n", + " },\n", + "}\n", + "\n", + "\n", + "def check(name, value, rule):\n", + " if not isinstance(value, rule[\"type\"]) or (isinstance(value, bool) and rule[\"type\"] is not bool):\n", + " return [f\"{name}: 자료형 오류({type(value).__name__})\"]\n", + " errs = []\n", + " if \"enum\" in rule and value not in rule[\"enum\"]:\n", + " errs.append(f\"{name}: 허용되지 않은 값 '{value}'\")\n", + " if \"min\" in rule and value < rule[\"min\"]:\n", + " errs.append(f\"{name}: 최소값 {rule['min']} 미만\")\n", + " if \"max\" in rule and value > rule[\"max\"]:\n", + " errs.append(f\"{name}: 최대값 {rule['max']} 초과\")\n", + " if \"length\" in rule and len(value) != rule[\"length\"]:\n", + " errs.append(f\"{name}: 길이 {rule['length']} 필요\")\n", + " if \"pattern\" in rule and not re.match(rule[\"pattern\"], value):\n", + " errs.append(f\"{name}: 형식 불일치 '{value}'\")\n", + " return errs\n", + "\n", + "\n", + "def validate(rec):\n", + " errs = []\n", + " for k, rule in SCHEMA[\"fields\"].items():\n", + " errs += [f\"{k}: 필수 필드 누락\"] if k not in rec else check(k, rec[k], rule)\n", + " for g in (\"sensor\", \"tactical\"):\n", + " for k, rule in SCHEMA[g].items():\n", + " errs += [f\"{g}.{k}: 필수 필드 누락\"] if k not in rec.get(g, {}) else check(f\"{g}.{k}\", rec[g][k], rule)\n", + " px = np.asarray(rec.get(\"pixels\", []), dtype=float)\n", + " if px.shape != (IMG, IMG):\n", + " errs.append(f\"pixels: 크기 {IMG}x{IMG} 필요(입력 {px.shape})\")\n", + " elif not np.isfinite(px).all() or px.min() < 0 or px.max() > 255:\n", + " errs.append(\"pixels: 값 범위(0~255) 또는 결측 오류\")\n", + " if rec.get(\"class\") in CLASSES and rec.get(\"threat\") != (rec[\"class\"] in THREAT):\n", + " errs.append(\"threat: 클래스와 위협 여부 불일치\")\n", + " return errs\n", + "\n", + "\n", + "# 2) [교육용가상자료] 클래스별 10장, 총 60장 샘플 생성(기본 + 원거리 + 측면 + 야간 조건)\n", + "rng = np.random.default_rng(1)\n", + "conds = [\"basic\", \"far\", \"oblique\", \"night\", \"blur\"]\n", + "weather = {\"basic\": \"clear\", \"far\": \"haze\", \"oblique\": \"clear\", \"night\": \"night\", \"blur\": \"rain\"}\n", + "records = []\n", + "for cls in CLASSES:\n", + " for i in range(10):\n", + " cond = conds[i % len(conds)]\n", + " records.append({\"image_id\": f\"SIL{len(records):05d}\", \"class\": cls, \"threat\": cls in THREAT,\n", + " \"condition\": cond, \"view\": \"oblique\" if cond == \"oblique\" else \"top\", \"source\": \"synthetic\",\n", + " \"reviewed_by\": \"INS01\", \"review_status\": \"approved\",\n", + " \"sensor\": {\"type\": \"IR\" if cond == \"night\" else \"EO\", \"resolution\": [IMG, IMG]},\n", + " \"tactical\": {\"weather\": weather[cond], \"range_level\": \"far\" if cond == \"far\" else \"mid\",\n", + " \"position_error_m\": float(round(rng.uniform(5, 60), 1)), \"comm_delay_ms\": int(rng.integers(20, 300))},\n", + " \"pixels\": draw_cond(cls, cond, rng).tolist()})\n", + "\n", + "ok = sum(1 for r in records if not validate(r))\n", + "print(f\"정상 샘플 {len(records)}건 중 검증 통과 {ok}건, 오류 {len(records) - ok}건 (목표 오류 0건: {'달성' if ok == len(records) else '미달'})\")\n", + "\n", + "# 3) 오류 주입 시험\n", + "bad = json.loads(json.dumps(records[0]))\n", + "bad[\"image_id\"] = \"IMG-7\"\n", + "bad[\"class\"] = \"ufo\"\n", + "bad[\"threat\"] = \"yes\"\n", + "del bad[\"tactical\"][\"comm_delay_ms\"]\n", + "bad[\"pixels\"] = bad[\"pixels\"][:20]\n", + "bad2 = json.loads(json.dumps(records[25]))\n", + "bad2[\"threat\"] = False # 자폭드론형인데 비위협으로 잘못 표기\n", + "bad2[\"pixels\"][3][3] = 300\n", + "print(\"\\n[오류 주입 결과]\")\n", + "for e in validate(bad) + validate(bad2):\n", + " print(\" -\", e)\n", + "\n", + "# 4) 레이블 정의서와 필드 명세\n", + "print(\"\\n[레이블 정의(추상 도형 기준)]\")\n", + "desc = {\"civil_quad\": \"십자 팔 + 원형 회전날개 4개\", \"civil_fw\": \"직선 주익 + 꼬리날개\", \"attack_fw\": \"삼각형(델타) 날개, 꼬리 없음\",\n", + " \"cruise\": \"가늘고 긴 동체 + 작은 중간 날개\", \"ballistic\": \"굵은 원통 동체 + 뾰족한 머리 + 꼬리 핀\", \"bird\": \"갈매기형 굽은 날개\"}\n", + "print(pd.DataFrame({\"레이블\": CLASSES, \"한글명\": [KOR[c] for c in CLASSES], \"위협\": [\"예\" if c in THREAT else \"아니오\" for c in CLASSES],\n", + " \"식별 핵심 특징\": [desc[c] for c in CLASSES]}).to_string(index=False))\n", + "rows = [(g, k, (r[\"type\"].__name__ if isinstance(r[\"type\"], type) else \"number\"),\n", + " \", \".join(f\"{x}={y}\" for x, y in r.items() if x != \"type\")[:60]) for g in (\"fields\", \"sensor\", \"tactical\") for k, r in SCHEMA[g].items()]\n", + "print(\"\\n[필드 명세 요약] 필드\", len(rows) + 1, \"개(pixels 포함), 정의 완성도 100%\")\n", + "print(pd.DataFrame(rows, columns=[\"그룹\", \"필드\", \"자료형\", \"규칙\"]).to_string(index=False))\n", + "\n", + "# 5) 샘플 실루엣 확인\n", + "fig, ax = plt.subplots(len(CLASSES), len(conds), figsize=(8, 9))\n", + "for i, cls in enumerate(CLASSES):\n", + " for j, cond in enumerate(conds):\n", + " r = next(x for x in records if x[\"class\"] == cls and x[\"condition\"] == cond)\n", + " ax[i, j].imshow(r[\"pixels\"], cmap=\"gray\", vmin=0, vmax=255); ax[i, j].axis(\"off\")\n", + " ax[i, j].set_title(f\"{cls}/{cond}\", fontsize=7)\n", + "plt.suptitle(\"Synthetic abstract silhouettes (training data only)\")\n", + "plt.tight_layout(); plt.show()\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 01 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + "정상 샘플 60건 중 검증 통과 60건, 오류 0건 (목표 오류 0건: 달성)\n", + "\n", + "[오류 주입 결과]\n", + " - image_id: 형식 불일치 'IMG-7'\n", + " - class: 허용되지 않은 값 'ufo'\n", + " - threat: 자료형 오류(str)\n", + " - tactical.comm_delay_ms: 필수 필드 누락\n", + " - pixels: 크기 32x32 필요(입력 (20, 32))\n", + " - pixels: 값 범위(0~255) 또는 결측 오류\n", + " - threat: 클래스와 위협 여부 불일치\n", + "\n", + "[레이블 정의(추상 도형 기준)]\n", + " 레이블 한글명 위협 식별 핵심 특징\n", + "civil_quad 민간 쿼드콥터형 아니오 십자 팔 + 원형 회전날개 4개\n", + " civil_fw 민간 고정익형 아니오 직선 주익 + 꼬리날개\n", + " attack_fw 삼각익 자폭드론형 예 삼각형(델타) 날개, 꼬리 없음\n", + " cruise 순항미사일형 예 가늘고 긴 동체 + 작은 중간 날개\n", + " ballistic 탄도미사일형 예 굵은 원통 동체 + 뾰족한 머리 + 꼬리 핀\n", + " bird 조류 아니오 갈매기형 굽은 날개\n", + "\n", + "[필드 명세 요약] 필드 15 개(pixels 포함), 정의 완성도 100%\n", + " 그룹 필드 자료형 규칙\n", + " fields image_id str pattern=^SIL\\d{5}$\n", + " fields class str enum=['civil_quad', 'civil_fw', 'attack_fw', 'cruise', 'ball\n", + " fields threat bool \n", + " fields condition str enum=['basic', 'offcenter', 'far', 'blur', 'lowcon', 'clutte\n", + " fields view str enum=['top', 'oblique']\n", + " fields source str enum=['synthetic']\n", + " fields reviewed_by str pattern=^INS\\d{2}$\n", + " fields review_status str enum=['draft', 'approved']\n", + " sensor type str enum=['EO', 'IR']\n", + " sensor resolution list length=2\n", + "tactical weather str enum=['clear', 'haze', 'cloud', 'night', 'rain']\n", + "tactical range_level str enum=['near', 'mid', 'far']\n", + "tactical position_error_m number min=0, max=500\n", + "tactical comm_delay_ms number min=0, max=10000\n", + "```\n", + "
\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 02. 이미지 명암 대비 강화, 윤곽선(Edge) 검출 및 크기 정규화 전처리 모듈 구현\n", + "**소요시간**: 약 2.0시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "명암 대비 강화, 윤곽선(Edge) 검출 및 크기 정규화 전처리 모듈을 구현한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **흑백·원거리·야간 등 조건이 다른 실루엣 이미지를 같은 기준으로 비교할 수 있도록 명암 대비 강화, 윤곽선 검출, 크기 정규화 전처리 모듈을 구현한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **결측 픽셀 보정, 야간 열상 극성 자동 반전, 잡음 추정값에 따른 중간값 필터 적용(조건별 임계값), 명암 대비 강화, Sobel 윤곽선 검출, 주축 방향·크기 정규화(24x24)를 배치 단위로 구현하고 동작을 검증하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `src/preprocess/trn03_preprocessor.py 및 전처리 전후 데이터셋` 형태의 결과물을 완성한다.\n", + "\n", + "### 🔍 평가 및 검증 기준 (PASS 조건)\n", + "#### 미션 판정\n", + "\n", + "**평가 구분:** 필수\n", + "\n", + "**핵심 평가영역:** 전술 환경 특유의 노이즈(스푸핑, 반사파, 센서 단절 등) 제거 타당성\n", + "\n", + "#### 미션 PASS 조건\n", + "\n", + "- 정량 기준: 데이터 정제 처리 속도 1,000건/초 이상, 전처리 오류율 0%\n", + "\n", + "\n", + "- 진행 기준: 정제 파이프라인 자동화 및 단위 테스트 케이스 5종 통과\n", + "\n", + "\n", + "- 검증 방법: pytest 기반 전처리 정합성 테스트 및 결측치 보정율 확인\n", + "\n", + "\n", + "#### 채점자 판정 메모\n", + "\n", + "> 단순 코드 실행 여부뿐만 아니라 출력된 수치와 근거 자료가 군 실무 요구사항에 부합하는지 면밀히 확인한다.\n", + "\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "\"\"\"실루엣 이미지의 결측 보정, 야간 극성 보정, 잡음 제거, 명암 대비 강화, 윤곽선(Edge) 검출, 방향·크기 정규화를 일괄 처리하는 전처리 모듈을 구현하고, 처리 속도·단위 테스트 5종·전처리 전후 식별 정확도를 확인한다.\"\"\"\n", + "import time\n", + "import warnings\n", + "import numpy as np\n", + "import pandas as pd\n", + "import matplotlib.pyplot as plt\n", + "from matplotlib.path import Path as MplPath\n", + "from scipy import ndimage\n", + "from scipy.spatial import ConvexHull\n", + "from sklearn.neighbors import NearestCentroid\n", + "\n", + "# ---------------------------------------------------------------------------\n", + "# [교육용가상자료] 합성 실루엣 생성기 (실제 기체 사진·도면·제원 사용 안 함)\n", + "# 사각형·삼각형·원 같은 기본 도형을 조합한 추상 실루엣을 32x32 흑백 이미지로 그린다.\n", + "# 회전(다각도), 원근 축소(측면 시점), 크기(원거리), 흐림·잡음·저대비·구름·가림·야간 반전을 조절한다.\n", + "# ---------------------------------------------------------------------------\n", + "IMG = 32\n", + "CLASSES = [\"civil_quad\", \"civil_fw\", \"attack_fw\", \"cruise\", \"ballistic\", \"bird\"]\n", + "KOR = {\"civil_quad\": \"민간 쿼드콥터형\", \"civil_fw\": \"민간 고정익형\", \"attack_fw\": \"삼각익 자폭드론형\",\n", + " \"cruise\": \"순항미사일형\", \"ballistic\": \"탄도미사일형\", \"bird\": \"조류\"}\n", + "THREAT = {\"attack_fw\", \"cruise\", \"ballistic\"}\n", + "\n", + "\n", + "def _rect(x0, x1, y0, y1):\n", + " return [(x0, y0), (x1, y0), (x1, y1), (x0, y1)]\n", + "\n", + "\n", + "def _bar(p, q, w):\n", + " (x0, y0), (x1, y1) = p, q\n", + " n = np.array([y0 - y1, x1 - x0]) / np.hypot(x1 - x0, y1 - y0) * w / 2\n", + " return [(x0 + n[0], y0 + n[1]), (x1 + n[0], y1 + n[1]), (x1 - n[0], y1 - n[1]), (x0 - n[0], y0 - n[1])]\n", + "\n", + "\n", + "# 도형 정의(단위 좌표, 기수 방향 = +y). 모두 추상 도형이며 특정 기종을 본뜨지 않는다.\n", + "SHAPES = {\n", + " \"civil_quad\": {\"poly\": [_bar((-.55, -.55), (.55, .55), .1), _bar((-.55, .55), (.55, -.55), .1), _rect(-.15, .15, -.15, .15)],\n", + " \"circ\": [(-.55, -.55, .24), (.55, .55, .24), (-.55, .55, .24), (.55, -.55, .24)]},\n", + " \"civil_fw\": {\"poly\": [_rect(-.07, .07, -.8, .72), _rect(-.9, .9, .05, .25), _rect(-.33, .33, -.8, -.66)], \"circ\": []},\n", + " \"attack_fw\": {\"poly\": [[(0, .85), (.85, -.5), (.12, -.42), (0, -.6), (-.12, -.42), (-.85, -.5)]], \"circ\": []},\n", + " \"cruise\": {\"poly\": [_rect(-.07, .07, -.9, .7), [(-.07, .7), (0, .92), (.07, .7)], _rect(-.42, .42, -.05, .07),\n", + " [(-.07, -.9), (-.24, -.95), (-.07, -.74)], [(.07, -.9), (.24, -.95), (.07, -.74)]], \"circ\": []},\n", + " \"ballistic\": {\"poly\": [_rect(-.13, .13, -.85, .6), [(-.13, .6), (0, .95), (.13, .6)],\n", + " [(-.13, -.85), (-.34, -.95), (-.13, -.58)], [(.13, -.85), (.34, -.95), (.13, -.58)]], \"circ\": []},\n", + " \"bird\": {\"poly\": [[(-.9, .12), (-.5, .28), (-.12, .06), (0, .22), (.12, .06), (.5, .28), (.9, .12), (.5, .12),\n", + " (.12, -.1), (0, -.38), (-.12, -.1), (-.5, .12)]], \"circ\": []},\n", + "}\n", + "_S = 2\n", + "_g = (np.arange(IMG * _S) + 0.5) / _S\n", + "_GX, _GY = np.meshgrid(_g, _g)\n", + "_PTS = np.c_[_GX.ravel(), _GY.ravel()]\n", + "_PATHS = {c: [MplPath(np.array(p)) for p in s[\"poly\"]] for c, s in SHAPES.items()}\n", + "\n", + "\n", + "def draw(cls, rng, angle=None, scale=None, fore=None, blur=0.3, noise=4.0, contrast=1.0,\n", + " clutter=0.0, occlude=0.0, invert=False, shift=1.5):\n", + " \"\"\"클래스 1개 실루엣 이미지(uint8 32x32)를 그린다. 배경은 밝은 하늘, 비행체는 어두운 그림자.\"\"\"\n", + " angle = rng.uniform(0, 360) if angle is None else angle\n", + " scale = rng.uniform(.7, .95) if scale is None else scale\n", + " fore = rng.uniform(.85, 1.0) if fore is None else fore\n", + " th = np.radians(angle)\n", + " c0 = IMG / 2 + rng.uniform(-shift, shift, 2)\n", + " d = (_PTS - c0) / (scale * IMG / 2) # 이미지 좌표 -> 도형 좌표(역변환)\n", + " u = d[:, 0] * np.cos(th) + d[:, 1] * np.sin(th)\n", + " v = (-d[:, 0] * np.sin(th) + d[:, 1] * np.cos(th)) / fore # 원근 축소(기수 방향으로 짧아 보임)\n", + " uv = np.c_[u, -v]\n", + " m = np.zeros(len(uv), bool)\n", + " near = np.flatnonzero(np.abs(uv).max(axis=1) <= 1.0) # 도형이 있을 수 있는 영역만 검사(속도)\n", + " for p in _PATHS[cls]:\n", + " m[near] |= p.contains_points(uv[near])\n", + " for cx, cy, r in SHAPES[cls][\"circ\"]:\n", + " m |= (uv[:, 0] - cx) ** 2 + (uv[:, 1] - cy) ** 2 <= r * r\n", + " cov = m.reshape(IMG, _S, IMG, _S).mean(axis=(1, 3))\n", + " if occlude > 0: # 부분 가림(구름 조각)\n", + " h = int(IMG * np.sqrt(occlude)); y0, x0 = rng.integers(8, IMG - 8 - h // 2, 2)\n", + " cov[y0:y0 + h, x0:x0 + h] = 0\n", + " bg = rng.uniform(180, 220) + np.linspace(-10, 10, IMG)[:, None]\n", + " fg = rng.uniform(30, 60)\n", + " img = bg * (1 - cov) + fg * cov\n", + " if clutter > 0: # 구름·배경 얼룩\n", + " img -= clutter * 90 * np.clip(ndimage.gaussian_filter(rng.normal(0, 1, (IMG, IMG)), 3) * 4, 0, None)\n", + " if blur > 0:\n", + " img = ndimage.gaussian_filter(img, blur)\n", + " mean = img.mean()\n", + " img = mean + (img - mean) * contrast\n", + " img = img + rng.normal(0, noise, img.shape)\n", + " if invert: # 야간 열상(IR) 극성 반전\n", + " img = 255 - img\n", + " return np.clip(img, 0, 255).astype(np.uint8)\n", + "\n", + "\n", + "# 촬영 조건 프리셋: 기본 + 복합 위협 시나리오\n", + "CONDITIONS = {\n", + " \"basic\": {},\n", + " \"offcenter\": {\"shift\": 5.0, \"fore\": (.6, .85)},\n", + " \"far\": {\"scale\": (.32, .45), \"blur\": .6},\n", + " \"blur\": {\"blur\": (1.2, 1.8)},\n", + " \"lowcon\": {\"contrast\": (.2, .35)},\n", + " \"clutter\": {\"clutter\": .8},\n", + " \"occlude\": {\"occlude\": .08},\n", + " \"oblique\": {\"fore\": (.35, .6)},\n", + " \"night\": {\"invert\": True, \"noise\": 10.0},\n", + " \"noise\": {\"noise\": 28.0},\n", + " \"combo\": {\"scale\": (.4, .55), \"blur\": (.8, 1.1), \"noise\": 12.0, \"contrast\": (.6, .8)},\n", + "}\n", + "\n", + "\n", + "def draw_cond(cls, cond, rng):\n", + " kw = {}\n", + " for k, v in CONDITIONS[cond].items():\n", + " kw[k] = rng.uniform(*v) if isinstance(v, tuple) else v\n", + " return draw(cls, rng, **kw)\n", + "\n", + "\n", + "def make_dataset(n_per_class, seed, conds=(\"basic\",)):\n", + " \"\"\"(이미지 배열 N x 32 x 32, 정답 라벨, 조건) 반환. 조건은 conds에서 번갈아 고른다.\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " X, y, c = [], [], []\n", + " for cls in CLASSES:\n", + " for i in range(n_per_class):\n", + " cond = conds[i % len(conds)]\n", + " X.append(draw_cond(cls, cond, rng)); y.append(cls); c.append(cond)\n", + " return np.array(X), np.array(y), np.array(c)\n", + "# ---------------------------------------------------------------------------\n", + "\n", + "# ---- 전처리(배치 벡터화)·형태 특징 공통 함수 ----\n", + "OUT = 24 # 크기 정규화 결과(24x24)\n", + "NOISE_T = 12.0 # 잡음 추정값이 이보다 크면 3x3 중간값 필터 적용(악천후·센서 잡음)\n", + "FEATS = [\"elong\", \"fill\", \"solidity\", \"n_parts\", \"end_gap\", \"mid_bulge\", \"w_max\", \"compact\"]\n", + "\n", + "\n", + "def preprocess(images, noise_t=NOISE_T):\n", + " \"\"\"N장 일괄 처리: 결측 보정 -> 극성(야간 반전) 보정 -> 조건별 잡음 제거 -> 명암 대비 강화 -> 분할 -> 윤곽선 -> 방향·크기 정규화.\"\"\"\n", + " f = np.array(images, dtype=float).reshape(-1, IMG, IMG)\n", + " n = len(f)\n", + " bad = ~np.isfinite(f)\n", + " missing = bad.sum(axis=(1, 2))\n", + " if bad.any(): # 결측 픽셀: 영상 중앙값으로 채운 뒤 주변 3x3 중간값으로 보정\n", + " med = np.array([np.median(x[np.isfinite(x)]) if np.isfinite(x).any() else 128.0 for x in f])\n", + " f = np.where(bad, med[:, None, None], f)\n", + " f = np.where(bad, ndimage.median_filter(f, size=(1, 3, 3)), f)\n", + " border = np.concatenate([f[:, 0], f[:, -1], f[:, :, 0], f[:, :, -1]], axis=1)\n", + " inverted = np.median(border, axis=1) < 128 # 배경이 어두우면 야간 열상으로 보고 반전\n", + " f[inverted] = 255 - f[inverted]\n", + " med3 = ndimage.median_filter(f, size=(1, 3, 3))\n", + " noise = 1.4826 * np.median(np.abs(f - med3).reshape(n, -1), axis=1)\n", + " noisy = noise > noise_t\n", + " f[noisy] = med3[noisy]\n", + " lo, hi = np.percentile(f.reshape(n, -1), [1, 99], axis=1)\n", + " e = np.clip((f - lo[:, None, None]) / np.maximum(hi - lo, 1e-6)[:, None, None], 0, 1) # 명암 대비 강화\n", + " mask = e < _otsu(e)[:, None, None]\n", + " mask &= ndimage.uniform_filter(mask.astype(float), size=(1, 3, 3)) * 9 >= 2.5 # 고립 점 잡음 제거\n", + " edge = np.hypot(ndimage.sobel(e, axis=1), ndimage.sobel(e, axis=2)) # 윤곽선(Sobel)\n", + " ok = (mask.sum(axis=(1, 2)) >= 4) & (hi - lo > 8)\n", + " return {\"enh\": e, \"mask\": mask, \"edge\": edge / np.maximum(edge.max(axis=(1, 2)), 1e-6)[:, None, None],\n", + " \"norm\": _normalize(1 - e, mask, ok), \"inverted\": inverted, \"noise\": noise, \"denoised\": noisy,\n", + " \"missing\": missing, \"contrast\": hi - lo, \"ok\": ok}\n", + "\n", + "\n", + "def _otsu(e, bins=64):\n", + " n = len(e)\n", + " idx = np.minimum((e * bins).astype(int), bins - 1) + bins * np.arange(n)[:, None, None]\n", + " hist = np.bincount(idx.ravel(), minlength=bins * n).reshape(n, bins).astype(float)\n", + " centers = (np.arange(bins) + 0.5) / bins\n", + " w = np.cumsum(hist, axis=1); m = np.cumsum(hist * centers, axis=1)\n", + " tot, mt = w[:, -1:], m[:, -1:]\n", + " between = (mt * w / tot - m) ** 2 / np.maximum(w * (tot - w), 1)\n", + " return (np.argmax(between, axis=1) + 1) / bins\n", + "\n", + "\n", + "def _normalize(obj, mask, ok):\n", + " \"\"\"주축 방향을 세로로 돌리고 비행체 영역만 24x24로 다시 샘플링한다(회전·크기·위치 불변). 한 번의 보간으로 일괄 처리.\"\"\"\n", + " n = len(obj)\n", + " w = mask.astype(float)\n", + " m00 = np.maximum(w.sum(axis=(1, 2)), 1)\n", + " yy, xx = np.mgrid[0:IMG, 0:IMG].astype(float)\n", + " cy, cx = (w * yy).sum(axis=(1, 2)) / m00, (w * xx).sum(axis=(1, 2)) / m00\n", + " dy, dx = yy - cy[:, None, None], xx - cx[:, None, None]\n", + " mu20, mu02, mu11 = (w * dy * dy).sum(axis=(1, 2)), (w * dx * dx).sum(axis=(1, 2)), (w * dy * dx).sum(axis=(1, 2))\n", + " th = 0.5 * np.arctan2(2 * mu11, mu20 - mu02)\n", + " a = np.stack([np.cos(th), np.sin(th)], 1) # 주축(y, x)\n", + " b = np.stack([-np.sin(th), np.cos(th)], 1) # 부축\n", + " pa = np.where(mask, dy * a[:, 0, None, None] + dx * a[:, 1, None, None], 0)\n", + " pb = np.where(mask, dy * b[:, 0, None, None] + dx * b[:, 1, None, None], 0)\n", + " half = np.maximum(np.abs(pa).max(axis=(1, 2)), np.abs(pb).max(axis=(1, 2))) + 1.0\n", + " g = np.linspace(-1, 1, OUT)\n", + " U, V = np.meshgrid(g, g, indexing=\"ij\")\n", + " Y = cy[:, None, None] + half[:, None, None] * (U * a[:, 0, None, None] + V * b[:, 0, None, None])\n", + " X = cx[:, None, None] + half[:, None, None] * (U * a[:, 1, None, None] + V * b[:, 1, None, None])\n", + " N = np.broadcast_to(np.arange(n)[:, None, None], Y.shape)\n", + " out = ndimage.map_coordinates(obj * mask, [N, Y, X], order=1, cval=0.0)\n", + " skew = (out * (U ** 3)).sum(axis=(1, 2))\n", + " out[skew < 0] = out[skew < 0, ::-1] # 무게가 쏠린 쪽을 위로(180도 모호성 제거)\n", + " out[~ok] = 0\n", + " return np.clip(out, 0, 1)\n", + "\n", + "\n", + "def shape_features(mask):\n", + " \"\"\"분할 마스크 1장에서 회전에 무관한 형태 특징 8종을 계산한다.\"\"\"\n", + " if mask.sum() < 4:\n", + " return dict.fromkeys(FEATS, 0.0)\n", + " yx = np.argwhere(mask).astype(float)\n", + " c = yx - yx.mean(axis=0)\n", + " val, vec = np.linalg.eigh(np.cov(c.T) + 1e-6 * np.eye(2))\n", + " t, s = c @ vec[:, 1], c @ vec[:, 0]\n", + " length, width = np.ptp(t) + 1, np.ptp(s) + 1\n", + " corners = np.vstack([yx + d for d in ([.5, .5], [.5, -.5], [-.5, .5], [-.5, -.5])])\n", + " try:\n", + " hull = ConvexHull(corners).volume\n", + " except Exception:\n", + " hull = float(mask.sum())\n", + " bins = np.digitize(t, np.linspace(t.min(), t.max() + 1e-6, 4)[1:-1])\n", + " w = np.array([np.ptp(s[bins == k]) + 1 if (bins == k).any() else 0 for k in range(3)]) / length\n", + " boundary = mask & ~ndimage.binary_erosion(mask)\n", + " return {\"elong\": float(np.sqrt(val[1] / max(val[0], 1e-6))), \"fill\": float(mask.sum() / (length * width)),\n", + " \"solidity\": float(mask.sum() / max(hull, 1)), \"n_parts\": float(ndimage.label(mask)[1]),\n", + " \"end_gap\": float(abs(w[0] - w[2])), \"mid_bulge\": float(w[1] - max(w[0], w[2])),\n", + " \"w_max\": float(w.max()), \"compact\": float(boundary.sum() / np.sqrt(mask.sum()))}\n", + "\n", + "\n", + "def feature_matrix(images):\n", + " \"\"\"전처리 결과, 형태 특징 행렬(N x 8), 정규화 이미지 행렬(N x 576)을 돌려준다.\"\"\"\n", + " P = preprocess(images)\n", + " F = np.array([[d[k] for k in FEATS] for d in map(shape_features, P[\"mask\"])])\n", + " return P, F, P[\"norm\"].reshape(len(F), -1)\n", + "\n", + "warnings.filterwarnings(\"ignore\")\n", + "\n", + "# 1) 처리 속도: 2,400장 일괄 처리\n", + "X, y, c = make_dataset(400, 1, tuple(CONDITIONS))\n", + "preprocess(X[:50]) # 준비 실행\n", + "t0 = time.perf_counter()\n", + "P = preprocess(X)\n", + "dt = time.perf_counter() - t0\n", + "err = int((~np.isfinite(P[\"norm\"])).sum())\n", + "print(f\"전처리 {len(X):,}장, {len(X) / dt:,.0f}장/초 (목표 1,000장/초 이상: {'달성' if len(X) / dt >= 1000 else '미달'}), 출력 오류 {err}건\")\n", + "\n", + "# 2) 단위 테스트 5종\n", + "tests = {}\n", + "tests[\"T1 출력 크기 24x24 고정\"] = P[\"norm\"].shape[1:] == (OUT, OUT)\n", + "tests[\"T2 값 범위 0~1\"] = bool(P[\"norm\"].min() >= 0 and P[\"norm\"].max() <= 1)\n", + "rng = np.random.default_rng(2)\n", + "day = draw(\"attack_fw\", rng, angle=30, scale=.8, fore=1.0, noise=0)\n", + "rng = np.random.default_rng(2)\n", + "night = draw(\"attack_fw\", rng, angle=30, scale=.8, fore=1.0, noise=0, invert=True)\n", + "pd_, pn = preprocess([day, night])[\"norm\"]\n", + "tests[\"T3 야간 반전 영상 극성 자동 보정\"] = bool(np.abs(pd_ - pn).mean() < 0.05)\n", + "holes = day.astype(float); holes[rng.random(holes.shape) < 0.1] = np.nan\n", + "ph = preprocess([holes])\n", + "tests[\"T4 결측 픽셀 보정율 100%\"] = bool(ph[\"missing\"][0] > 0 and np.isfinite(ph[\"norm\"]).all())\n", + "blank = np.full((IMG, IMG), 200.0)\n", + "pb = preprocess([blank])\n", + "tests[\"T5 빈 영상은 예외 없이 '판별 불가' 표시\"] = bool(not pb[\"ok\"][0])\n", + "for k, v in tests.items():\n", + " print(f\"[{'PASS' if v else 'FAIL'}] {k}\")\n", + "print(f\"결측 픽셀 {ph['missing'][0]}개 보정, 단위 테스트 {sum(tests.values())}/5 통과\")\n", + "\n", + "# 3) 전처리 전후 식별 정확도(최근접 중심 분류기, 기본 조건으로 학습)\n", + "Xtr, ytr, _ = make_dataset(60, 3)\n", + "raw = lambda imgs: (np.asarray(imgs, float) / 255).reshape(len(imgs), -1)\n", + "nc_raw = NearestCentroid().fit(raw(Xtr), ytr)\n", + "nc_pre = NearestCentroid().fit(preprocess(Xtr)[\"norm\"].reshape(len(Xtr), -1), ytr)\n", + "rows = []\n", + "for cond in [\"basic\", \"offcenter\", \"night\", \"lowcon\", \"noise\", \"far\"]:\n", + " Xt, yt, _ = make_dataset(30, 4, (cond,))\n", + " pt = preprocess(Xt)\n", + " rows.append({\"조건\": cond, \"전처리 전\": (nc_raw.predict(raw(Xt)) == yt).mean(),\n", + " \"전처리 후\": (nc_pre.predict(pt[\"norm\"].reshape(len(Xt), -1)) == yt).mean(),\n", + " \"잡음필터 적용률\": pt[\"denoised\"].mean(), \"극성반전 비율\": pt[\"inverted\"].mean()})\n", + "print(\"\\n[전처리 전후 식별 정확도]\")\n", + "print(pd.DataFrame(rows).round(3).to_string(index=False))\n", + "\n", + "# 4) 조건별 잡음 임계값 동적 조정 효과: 임계값을 낮춰 모든 영상에 필터를 쓰면 원거리 소형 표적이 손상된다\n", + "Xf, yf, _ = make_dataset(30, 4, (\"far\",))\n", + "for t in (NOISE_T, 0.0):\n", + " acc = (nc_pre.predict(preprocess(Xf, noise_t=t)[\"norm\"].reshape(len(Xf), -1)) == yf).mean()\n", + " print(f\"원거리 조건, 잡음 임계값 {t:>4}: 정확도 {acc:.3f}\")\n", + "\n", + "# 5) 전처리 단계 시각화\n", + "idx = [np.flatnonzero(c == k)[0] for k in [\"basic\", \"night\", \"noise\", \"lowcon\", \"far\"]]\n", + "fig, ax = plt.subplots(4, len(idx), figsize=(10, 8))\n", + "for j, i in enumerate(idx):\n", + " for r, (img, name) in enumerate([(X[i], \"raw\"), (P[\"enh\"][i], \"contrast\"), (P[\"edge\"][i], \"edge\"), (P[\"norm\"][i], \"normalized 24x24\")]):\n", + " ax[r, j].imshow(img, cmap=\"gray\"); ax[r, j].axis(\"off\"); ax[r, j].set_title(f\"{c[i]} {name}\", fontsize=8)\n", + "plt.suptitle(\"Silhouette preprocessing steps (synthetic)\")\n", + "plt.tight_layout(); plt.show()\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 02 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + "전처리 2,400장, 2,056장/초 (목표 1,000장/초 이상: 달성), 출력 오류 0건\n", + "[PASS] T1 출력 크기 24x24 고정\n", + "[PASS] T2 값 범위 0~1\n", + "[PASS] T3 야간 반전 영상 극성 자동 보정\n", + "[PASS] T4 결측 픽셀 보정율 100%\n", + "[PASS] T5 빈 영상은 예외 없이 '판별 불가' 표시\n", + "결측 픽셀 109개 보정, 단위 테스트 5/5 통과\n", + "\n", + "[전처리 전후 식별 정확도]\n", + " 조건 전처리 전 전처리 후 잡음필터 적용률 극성반전 비율\n", + " basic 0.539 0.978 0.0 0.0\n", + "offcenter 0.344 0.939 0.0 0.0\n", + " night 0.167 0.983 0.0 1.0\n", + " lowcon 0.322 0.978 0.0 0.0\n", + " noise 0.544 0.911 1.0 0.0\n", + " far 0.194 0.722 0.0 0.0\n", + "원거리 조건, 잡음 임계값 12.0: 정확도 0.722\n", + "원거리 조건, 잡음 임계값 0.0: 정확도 0.417\n", + "```\n", + "
\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 03. 기본 형태 특징 기반 1차 비행체 실루엣 분류 Baseline 모델 구현\n", + "**소요시간**: 약 2.5시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "기본 형태 특징 기반 1차 비행체 실루엣 분류 Baseline 모델을 구현한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **길쭉함·채움률·볼록도·폭 분포 같은 기본 형태 특징으로 비행체 실루엣을 1차 분류하는 Baseline 모델을 구현한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **분할 마스크에서 회전에 무관한 형태 특징 8종을 계산하고 결정나무로 분류 규칙을 학습 수행하고 검증하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `src/models/trn03_baseline_model.py 및 기본 추론 결과 보고서` 형태의 결과물을 완성한다.\n", + "\n", + "### 🔍 평가 및 검증 기준 (PASS 조건)\n", + "#### 미션 판정\n", + "\n", + "**평가 구분:** 필수\n", + "\n", + "**핵심 평가영역:** 알고리즘의 논리적 흐름 및 군사적 판단 기준의 투명성\n", + "\n", + "#### 미션 PASS 조건\n", + "\n", + "- 정량 기준: 기본 데이터셋 대상 탐지/처리 정확도 75% 이상 달성\n", + "\n", + "\n", + "- 진행 기준: 단위 기능 동작 확인 및 기본 시나리오 실행 로그 생성\n", + "\n", + "\n", + "- 검증 방법: 기준 벤치마크 데이터셋 대상 추론 결과 오차율 비교\n", + "\n", + "\n", + "#### 채점자 판정 메모\n", + "\n", + "> 단순 코드 실행 여부뿐만 아니라 출력된 수치와 근거 자료가 군 실무 요구사항에 부합하는지 면밀히 확인한다.\n", + "\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "\"\"\"형태 특징 8종(길쭉함·채움률·볼록도·폭 분포 등)으로 비행체 실루엣을 1차 분류하는 결정나무 Baseline을 구현하고, 판단 규칙을 그대로 출력해 투명성을 확인하며 기본 데이터셋 정확도·처리 지연·조건별 한계를 측정한다.\"\"\"\n", + "import time\n", + "import numpy as np\n", + "import pandas as pd\n", + "import matplotlib.pyplot as plt\n", + "from matplotlib.path import Path as MplPath\n", + "from scipy import ndimage\n", + "from scipy.spatial import ConvexHull\n", + "from sklearn.tree import DecisionTreeClassifier, export_text, plot_tree\n", + "\n", + "# ---------------------------------------------------------------------------\n", + "# [교육용가상자료] 합성 실루엣 생성기 (실제 기체 사진·도면·제원 사용 안 함)\n", + "# 사각형·삼각형·원 같은 기본 도형을 조합한 추상 실루엣을 32x32 흑백 이미지로 그린다.\n", + "# 회전(다각도), 원근 축소(측면 시점), 크기(원거리), 흐림·잡음·저대비·구름·가림·야간 반전을 조절한다.\n", + "# ---------------------------------------------------------------------------\n", + "IMG = 32\n", + "CLASSES = [\"civil_quad\", \"civil_fw\", \"attack_fw\", \"cruise\", \"ballistic\", \"bird\"]\n", + "KOR = {\"civil_quad\": \"민간 쿼드콥터형\", \"civil_fw\": \"민간 고정익형\", \"attack_fw\": \"삼각익 자폭드론형\",\n", + " \"cruise\": \"순항미사일형\", \"ballistic\": \"탄도미사일형\", \"bird\": \"조류\"}\n", + "THREAT = {\"attack_fw\", \"cruise\", \"ballistic\"}\n", + "\n", + "\n", + "def _rect(x0, x1, y0, y1):\n", + " return [(x0, y0), (x1, y0), (x1, y1), (x0, y1)]\n", + "\n", + "\n", + "def _bar(p, q, w):\n", + " (x0, y0), (x1, y1) = p, q\n", + " n = np.array([y0 - y1, x1 - x0]) / np.hypot(x1 - x0, y1 - y0) * w / 2\n", + " return [(x0 + n[0], y0 + n[1]), (x1 + n[0], y1 + n[1]), (x1 - n[0], y1 - n[1]), (x0 - n[0], y0 - n[1])]\n", + "\n", + "\n", + "# 도형 정의(단위 좌표, 기수 방향 = +y). 모두 추상 도형이며 특정 기종을 본뜨지 않는다.\n", + "SHAPES = {\n", + " \"civil_quad\": {\"poly\": [_bar((-.55, -.55), (.55, .55), .1), _bar((-.55, .55), (.55, -.55), .1), _rect(-.15, .15, -.15, .15)],\n", + " \"circ\": [(-.55, -.55, .24), (.55, .55, .24), (-.55, .55, .24), (.55, -.55, .24)]},\n", + " \"civil_fw\": {\"poly\": [_rect(-.07, .07, -.8, .72), _rect(-.9, .9, .05, .25), _rect(-.33, .33, -.8, -.66)], \"circ\": []},\n", + " \"attack_fw\": {\"poly\": [[(0, .85), (.85, -.5), (.12, -.42), (0, -.6), (-.12, -.42), (-.85, -.5)]], \"circ\": []},\n", + " \"cruise\": {\"poly\": [_rect(-.07, .07, -.9, .7), [(-.07, .7), (0, .92), (.07, .7)], _rect(-.42, .42, -.05, .07),\n", + " [(-.07, -.9), (-.24, -.95), (-.07, -.74)], [(.07, -.9), (.24, -.95), (.07, -.74)]], \"circ\": []},\n", + " \"ballistic\": {\"poly\": [_rect(-.13, .13, -.85, .6), [(-.13, .6), (0, .95), (.13, .6)],\n", + " [(-.13, -.85), (-.34, -.95), (-.13, -.58)], [(.13, -.85), (.34, -.95), (.13, -.58)]], \"circ\": []},\n", + " \"bird\": {\"poly\": [[(-.9, .12), (-.5, .28), (-.12, .06), (0, .22), (.12, .06), (.5, .28), (.9, .12), (.5, .12),\n", + " (.12, -.1), (0, -.38), (-.12, -.1), (-.5, .12)]], \"circ\": []},\n", + "}\n", + "_S = 2\n", + "_g = (np.arange(IMG * _S) + 0.5) / _S\n", + "_GX, _GY = np.meshgrid(_g, _g)\n", + "_PTS = np.c_[_GX.ravel(), _GY.ravel()]\n", + "_PATHS = {c: [MplPath(np.array(p)) for p in s[\"poly\"]] for c, s in SHAPES.items()}\n", + "\n", + "\n", + "def draw(cls, rng, angle=None, scale=None, fore=None, blur=0.3, noise=4.0, contrast=1.0,\n", + " clutter=0.0, occlude=0.0, invert=False, shift=1.5):\n", + " \"\"\"클래스 1개 실루엣 이미지(uint8 32x32)를 그린다. 배경은 밝은 하늘, 비행체는 어두운 그림자.\"\"\"\n", + " angle = rng.uniform(0, 360) if angle is None else angle\n", + " scale = rng.uniform(.7, .95) if scale is None else scale\n", + " fore = rng.uniform(.85, 1.0) if fore is None else fore\n", + " th = np.radians(angle)\n", + " c0 = IMG / 2 + rng.uniform(-shift, shift, 2)\n", + " d = (_PTS - c0) / (scale * IMG / 2) # 이미지 좌표 -> 도형 좌표(역변환)\n", + " u = d[:, 0] * np.cos(th) + d[:, 1] * np.sin(th)\n", + " v = (-d[:, 0] * np.sin(th) + d[:, 1] * np.cos(th)) / fore # 원근 축소(기수 방향으로 짧아 보임)\n", + " uv = np.c_[u, -v]\n", + " m = np.zeros(len(uv), bool)\n", + " near = np.flatnonzero(np.abs(uv).max(axis=1) <= 1.0) # 도형이 있을 수 있는 영역만 검사(속도)\n", + " for p in _PATHS[cls]:\n", + " m[near] |= p.contains_points(uv[near])\n", + " for cx, cy, r in SHAPES[cls][\"circ\"]:\n", + " m |= (uv[:, 0] - cx) ** 2 + (uv[:, 1] - cy) ** 2 <= r * r\n", + " cov = m.reshape(IMG, _S, IMG, _S).mean(axis=(1, 3))\n", + " if occlude > 0: # 부분 가림(구름 조각)\n", + " h = int(IMG * np.sqrt(occlude)); y0, x0 = rng.integers(8, IMG - 8 - h // 2, 2)\n", + " cov[y0:y0 + h, x0:x0 + h] = 0\n", + " bg = rng.uniform(180, 220) + np.linspace(-10, 10, IMG)[:, None]\n", + " fg = rng.uniform(30, 60)\n", + " img = bg * (1 - cov) + fg * cov\n", + " if clutter > 0: # 구름·배경 얼룩\n", + " img -= clutter * 90 * np.clip(ndimage.gaussian_filter(rng.normal(0, 1, (IMG, IMG)), 3) * 4, 0, None)\n", + " if blur > 0:\n", + " img = ndimage.gaussian_filter(img, blur)\n", + " mean = img.mean()\n", + " img = mean + (img - mean) * contrast\n", + " img = img + rng.normal(0, noise, img.shape)\n", + " if invert: # 야간 열상(IR) 극성 반전\n", + " img = 255 - img\n", + " return np.clip(img, 0, 255).astype(np.uint8)\n", + "\n", + "\n", + "# 촬영 조건 프리셋: 기본 + 복합 위협 시나리오\n", + "CONDITIONS = {\n", + " \"basic\": {},\n", + " \"offcenter\": {\"shift\": 5.0, \"fore\": (.6, .85)},\n", + " \"far\": {\"scale\": (.32, .45), \"blur\": .6},\n", + " \"blur\": {\"blur\": (1.2, 1.8)},\n", + " \"lowcon\": {\"contrast\": (.2, .35)},\n", + " \"clutter\": {\"clutter\": .8},\n", + " \"occlude\": {\"occlude\": .08},\n", + " \"oblique\": {\"fore\": (.35, .6)},\n", + " \"night\": {\"invert\": True, \"noise\": 10.0},\n", + " \"noise\": {\"noise\": 28.0},\n", + " \"combo\": {\"scale\": (.4, .55), \"blur\": (.8, 1.1), \"noise\": 12.0, \"contrast\": (.6, .8)},\n", + "}\n", + "\n", + "\n", + "def draw_cond(cls, cond, rng):\n", + " kw = {}\n", + " for k, v in CONDITIONS[cond].items():\n", + " kw[k] = rng.uniform(*v) if isinstance(v, tuple) else v\n", + " return draw(cls, rng, **kw)\n", + "\n", + "\n", + "def make_dataset(n_per_class, seed, conds=(\"basic\",)):\n", + " \"\"\"(이미지 배열 N x 32 x 32, 정답 라벨, 조건) 반환. 조건은 conds에서 번갈아 고른다.\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " X, y, c = [], [], []\n", + " for cls in CLASSES:\n", + " for i in range(n_per_class):\n", + " cond = conds[i % len(conds)]\n", + " X.append(draw_cond(cls, cond, rng)); y.append(cls); c.append(cond)\n", + " return np.array(X), np.array(y), np.array(c)\n", + "# ---------------------------------------------------------------------------\n", + "\n", + "# ---- 전처리(배치 벡터화)·형태 특징 공통 함수 ----\n", + "OUT = 24 # 크기 정규화 결과(24x24)\n", + "NOISE_T = 12.0 # 잡음 추정값이 이보다 크면 3x3 중간값 필터 적용(악천후·센서 잡음)\n", + "FEATS = [\"elong\", \"fill\", \"solidity\", \"n_parts\", \"end_gap\", \"mid_bulge\", \"w_max\", \"compact\"]\n", + "\n", + "\n", + "def preprocess(images, noise_t=NOISE_T):\n", + " \"\"\"N장 일괄 처리: 결측 보정 -> 극성(야간 반전) 보정 -> 조건별 잡음 제거 -> 명암 대비 강화 -> 분할 -> 윤곽선 -> 방향·크기 정규화.\"\"\"\n", + " f = np.array(images, dtype=float).reshape(-1, IMG, IMG)\n", + " n = len(f)\n", + " bad = ~np.isfinite(f)\n", + " missing = bad.sum(axis=(1, 2))\n", + " if bad.any(): # 결측 픽셀: 영상 중앙값으로 채운 뒤 주변 3x3 중간값으로 보정\n", + " med = np.array([np.median(x[np.isfinite(x)]) if np.isfinite(x).any() else 128.0 for x in f])\n", + " f = np.where(bad, med[:, None, None], f)\n", + " f = np.where(bad, ndimage.median_filter(f, size=(1, 3, 3)), f)\n", + " border = np.concatenate([f[:, 0], f[:, -1], f[:, :, 0], f[:, :, -1]], axis=1)\n", + " inverted = np.median(border, axis=1) < 128 # 배경이 어두우면 야간 열상으로 보고 반전\n", + " f[inverted] = 255 - f[inverted]\n", + " med3 = ndimage.median_filter(f, size=(1, 3, 3))\n", + " noise = 1.4826 * np.median(np.abs(f - med3).reshape(n, -1), axis=1)\n", + " noisy = noise > noise_t\n", + " f[noisy] = med3[noisy]\n", + " lo, hi = np.percentile(f.reshape(n, -1), [1, 99], axis=1)\n", + " e = np.clip((f - lo[:, None, None]) / np.maximum(hi - lo, 1e-6)[:, None, None], 0, 1) # 명암 대비 강화\n", + " mask = e < _otsu(e)[:, None, None]\n", + " mask &= ndimage.uniform_filter(mask.astype(float), size=(1, 3, 3)) * 9 >= 2.5 # 고립 점 잡음 제거\n", + " edge = np.hypot(ndimage.sobel(e, axis=1), ndimage.sobel(e, axis=2)) # 윤곽선(Sobel)\n", + " ok = (mask.sum(axis=(1, 2)) >= 4) & (hi - lo > 8)\n", + " return {\"enh\": e, \"mask\": mask, \"edge\": edge / np.maximum(edge.max(axis=(1, 2)), 1e-6)[:, None, None],\n", + " \"norm\": _normalize(1 - e, mask, ok), \"inverted\": inverted, \"noise\": noise, \"denoised\": noisy,\n", + " \"missing\": missing, \"contrast\": hi - lo, \"ok\": ok}\n", + "\n", + "\n", + "def _otsu(e, bins=64):\n", + " n = len(e)\n", + " idx = np.minimum((e * bins).astype(int), bins - 1) + bins * np.arange(n)[:, None, None]\n", + " hist = np.bincount(idx.ravel(), minlength=bins * n).reshape(n, bins).astype(float)\n", + " centers = (np.arange(bins) + 0.5) / bins\n", + " w = np.cumsum(hist, axis=1); m = np.cumsum(hist * centers, axis=1)\n", + " tot, mt = w[:, -1:], m[:, -1:]\n", + " between = (mt * w / tot - m) ** 2 / np.maximum(w * (tot - w), 1)\n", + " return (np.argmax(between, axis=1) + 1) / bins\n", + "\n", + "\n", + "def _normalize(obj, mask, ok):\n", + " \"\"\"주축 방향을 세로로 돌리고 비행체 영역만 24x24로 다시 샘플링한다(회전·크기·위치 불변). 한 번의 보간으로 일괄 처리.\"\"\"\n", + " n = len(obj)\n", + " w = mask.astype(float)\n", + " m00 = np.maximum(w.sum(axis=(1, 2)), 1)\n", + " yy, xx = np.mgrid[0:IMG, 0:IMG].astype(float)\n", + " cy, cx = (w * yy).sum(axis=(1, 2)) / m00, (w * xx).sum(axis=(1, 2)) / m00\n", + " dy, dx = yy - cy[:, None, None], xx - cx[:, None, None]\n", + " mu20, mu02, mu11 = (w * dy * dy).sum(axis=(1, 2)), (w * dx * dx).sum(axis=(1, 2)), (w * dy * dx).sum(axis=(1, 2))\n", + " th = 0.5 * np.arctan2(2 * mu11, mu20 - mu02)\n", + " a = np.stack([np.cos(th), np.sin(th)], 1) # 주축(y, x)\n", + " b = np.stack([-np.sin(th), np.cos(th)], 1) # 부축\n", + " pa = np.where(mask, dy * a[:, 0, None, None] + dx * a[:, 1, None, None], 0)\n", + " pb = np.where(mask, dy * b[:, 0, None, None] + dx * b[:, 1, None, None], 0)\n", + " half = np.maximum(np.abs(pa).max(axis=(1, 2)), np.abs(pb).max(axis=(1, 2))) + 1.0\n", + " g = np.linspace(-1, 1, OUT)\n", + " U, V = np.meshgrid(g, g, indexing=\"ij\")\n", + " Y = cy[:, None, None] + half[:, None, None] * (U * a[:, 0, None, None] + V * b[:, 0, None, None])\n", + " X = cx[:, None, None] + half[:, None, None] * (U * a[:, 1, None, None] + V * b[:, 1, None, None])\n", + " N = np.broadcast_to(np.arange(n)[:, None, None], Y.shape)\n", + " out = ndimage.map_coordinates(obj * mask, [N, Y, X], order=1, cval=0.0)\n", + " skew = (out * (U ** 3)).sum(axis=(1, 2))\n", + " out[skew < 0] = out[skew < 0, ::-1] # 무게가 쏠린 쪽을 위로(180도 모호성 제거)\n", + " out[~ok] = 0\n", + " return np.clip(out, 0, 1)\n", + "\n", + "\n", + "def shape_features(mask):\n", + " \"\"\"분할 마스크 1장에서 회전에 무관한 형태 특징 8종을 계산한다.\"\"\"\n", + " if mask.sum() < 4:\n", + " return dict.fromkeys(FEATS, 0.0)\n", + " yx = np.argwhere(mask).astype(float)\n", + " c = yx - yx.mean(axis=0)\n", + " val, vec = np.linalg.eigh(np.cov(c.T) + 1e-6 * np.eye(2))\n", + " t, s = c @ vec[:, 1], c @ vec[:, 0]\n", + " length, width = np.ptp(t) + 1, np.ptp(s) + 1\n", + " corners = np.vstack([yx + d for d in ([.5, .5], [.5, -.5], [-.5, .5], [-.5, -.5])])\n", + " try:\n", + " hull = ConvexHull(corners).volume\n", + " except Exception:\n", + " hull = float(mask.sum())\n", + " bins = np.digitize(t, np.linspace(t.min(), t.max() + 1e-6, 4)[1:-1])\n", + " w = np.array([np.ptp(s[bins == k]) + 1 if (bins == k).any() else 0 for k in range(3)]) / length\n", + " boundary = mask & ~ndimage.binary_erosion(mask)\n", + " return {\"elong\": float(np.sqrt(val[1] / max(val[0], 1e-6))), \"fill\": float(mask.sum() / (length * width)),\n", + " \"solidity\": float(mask.sum() / max(hull, 1)), \"n_parts\": float(ndimage.label(mask)[1]),\n", + " \"end_gap\": float(abs(w[0] - w[2])), \"mid_bulge\": float(w[1] - max(w[0], w[2])),\n", + " \"w_max\": float(w.max()), \"compact\": float(boundary.sum() / np.sqrt(mask.sum()))}\n", + "\n", + "\n", + "def feature_matrix(images):\n", + " \"\"\"전처리 결과, 형태 특징 행렬(N x 8), 정규화 이미지 행렬(N x 576)을 돌려준다.\"\"\"\n", + " P = preprocess(images)\n", + " F = np.array([[d[k] for k in FEATS] for d in map(shape_features, P[\"mask\"])])\n", + " return P, F, P[\"norm\"].reshape(len(F), -1)\n", + "\n", + "# 1) 학습·검증 데이터(기본 조건) 분리\n", + "Xtr, ytr, _ = make_dataset(60, 1)\n", + "Xte, yte, _ = make_dataset(40, 2)\n", + "_, Ftr, _ = feature_matrix(Xtr)\n", + "_, Fte, _ = feature_matrix(Xte)\n", + "\n", + "# 2) 휴리스틱 1차 튜닝: 규칙 깊이(설명 가능한 범위 2~5)별 검증 정확도\n", + "print(\"규칙 깊이 검증 정확도\")\n", + "for d in [2, 3, 4, 5]:\n", + " m = DecisionTreeClassifier(max_depth=d, random_state=0).fit(Ftr, ytr)\n", + " print(f\"{d:>6} {(m.predict(Fte) == yte).mean():.3f}\")\n", + "model = DecisionTreeClassifier(max_depth=4, random_state=0).fit(Ftr, ytr)\n", + "\n", + "# 3) 기본 데이터셋 정확도와 처리 지연\n", + "t0 = time.perf_counter()\n", + "_, F1, _ = feature_matrix(Xte)\n", + "pred = model.predict(F1)\n", + "ms = (time.perf_counter() - t0) * 1000 / len(Xte)\n", + "acc = (pred == yte).mean()\n", + "print(f\"\\n기본 데이터셋 정확도 {acc:.1%} (목표 75% 이상: {'달성' if acc >= 0.75 else '미달'}), 1장당 판단 지연 {ms:.2f} ms\")\n", + "print(pd.crosstab(pd.Series(yte, name=\"정답\"), pd.Series(pred, name=\"예측\")))\n", + "\n", + "# 4) 판단 규칙(투명성): 교관이 그대로 읽을 수 있는 if-then 규칙\n", + "print(\"\\n[판단 규칙 일부]\")\n", + "rules = export_text(model, feature_names=FEATS, decimals=2).splitlines()\n", + "print(\"\\n\".join(rules[:12]) + f\"\\n... (총 {len(rules)}줄)\")\n", + "imp = pd.Series(model.feature_importances_, index=FEATS).sort_values(ascending=False)\n", + "print(\"중요 특징 상위 3:\", \", \".join(f\"{k}({v:.2f})\" for k, v in imp.head(3).items()))\n", + "\n", + "# 5) 한계: 기본 조건에서 만든 규칙을 어려운 조건에 적용\n", + "rows = []\n", + "for cond in [\"far\", \"blur\", \"oblique\", \"occlude\", \"combo\"]:\n", + " Xh, yh, _ = make_dataset(20, 3, (cond,))\n", + " rows.append({\"조건\": cond, \"정확도\": round((model.predict(feature_matrix(Xh)[1]) == yh).mean(), 3)})\n", + "print(\"\\n[어려운 조건 정확도(Baseline 한계)]\", \", \".join(f\"{r['조건']} {r['정확도']}\" for r in rows))\n", + "print(\"기본 실행 로그: 학습 360장, 검증 240장, 규칙 깊이 4, 오류 0건\")\n", + "\n", + "plt.figure(figsize=(14, 6))\n", + "plot_tree(model, feature_names=FEATS, class_names=list(model.classes_), filled=True, fontsize=7, max_depth=3)\n", + "plt.title(\"Baseline decision rules (synthetic silhouettes)\")\n", + "plt.show()\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 03 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + "규칙 깊이 검증 정확도\n", + " 2 0.654\n", + " 3 0.812\n", + " 4 0.946\n", + " 5 0.979\n", + "\n", + "기본 데이터셋 정확도 94.6% (목표 75% 이상: 달성), 1장당 판단 지연 1.32 ms\n", + "예측 attack_fw ballistic bird civil_fw civil_quad cruise\n", + "정답 \n", + "attack_fw 40 0 0 0 0 0\n", + "ballistic 0 39 1 0 0 0\n", + "bird 0 2 38 0 0 0\n", + "civil_fw 0 0 0 40 0 0\n", + "civil_quad 0 0 0 8 32 0\n", + "cruise 0 0 2 0 0 38\n", + "\n", + "[판단 규칙 일부]\n", + "|--- w_max <= 0.43\n", + "| |--- end_gap <= 0.10\n", + "| | |--- class: bird\n", + "| |--- end_gap > 0.10\n", + "| | |--- class: ballistic\n", + "|--- w_max > 0.43\n", + "| |--- fill <= 0.44\n", + "| | |--- w_max <= 0.64\n", + "| | | |--- class: cruise\n", + "| | |--- w_max > 0.64\n", + "| | | |--- fill <= 0.29\n", + "| | | | |--- class: civil_fw\n", + "... (총 16줄)\n", + "중요 특징 상위 3: w_max(0.41), fill(0.38), end_gap(0.21)\n", + "\n", + "[어려운 조건 정확도(Baseline 한계)] far 0.442, blur 0.45, oblique 0.292, occlude 0.533, combo 0.367\n", + "기본 실행 로그: 학습 360장, 검증 240장, 규칙 깊이 4, 오류 0건\n", + "```\n", + "
\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 04. 다양한 각도/배경 노이즈 환경 하 전술 드론·미사일 정밀 이미지 분류 딥러닝 고도화\n", + "**소요시간**: 약 3.0시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "다양한 각도/배경 노이즈 환경 하 전술 드론·미사일 정밀 이미지 분류 모델을 고도화한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **다양한 각도·원거리·배경 잡음 환경에서도 실루엣을 정밀하게 분류하도록 소형 신경망 모델로 고도화하고 복합 위협 시나리오로 검증한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **정규화 이미지 576픽셀과 형태 특징 8종을 입력으로 하는 다층 퍼셉트론을 모든 촬영 조건을 섞은 증강 데이터로 학습 수행하고 검증하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `src/models/trn03_advanced_detector.py 및 예외 탐지 테스트 결과` 형태의 결과물을 완성한다.\n", + "\n", + "### 🔍 평가 및 검증 기준 (PASS 조건)\n", + "#### 미션 판정\n", + "\n", + "**평가 구분:** 필수\n", + "\n", + "**핵심 평가영역:** 기만/교란 공격에 대한 방어 로직의 현실성 및 신뢰성\n", + "\n", + "#### 미션 PASS 조건\n", + "\n", + "- 정량 기준: 복합 위협 탐지 재현율(Recall) 85% 이상, 처리 지연 200ms 이하\n", + "\n", + "\n", + "- 진행 기준: 복합 위협 시나리오 10종에 대한 탐지 테스트 통과\n", + "\n", + "\n", + "- 검증 방법: 합성 위협 데이터셋 주입 시 이상치 탐지 정확도 검증\n", + "\n", + "\n", + "#### 채점자 판정 메모\n", + "\n", + "> 단순 코드 실행 여부뿐만 아니라 출력된 수치와 근거 자료가 군 실무 요구사항에 부합하는지 면밀히 확인한다.\n", + "\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "\"\"\"다양한 각도·원거리·흐림·잡음·구름·가림·야간 조건을 섞어 학습한 다층 퍼셉트론(소형 신경망) 분류기로 실루엣 분류를 고도화하고, 복합 위협 시나리오 10종에서 위협 재현율·처리 지연·오경보 유발 패턴을 검증한다.\"\"\"\n", + "import time\n", + "import numpy as np\n", + "import pandas as pd\n", + "import matplotlib.pyplot as plt\n", + "from matplotlib.path import Path as MplPath\n", + "from scipy import ndimage\n", + "from scipy.spatial import ConvexHull\n", + "from sklearn.neural_network import MLPClassifier\n", + "from sklearn.preprocessing import StandardScaler\n", + "from sklearn.tree import DecisionTreeClassifier\n", + "\n", + "# ---------------------------------------------------------------------------\n", + "# [교육용가상자료] 합성 실루엣 생성기 (실제 기체 사진·도면·제원 사용 안 함)\n", + "# 사각형·삼각형·원 같은 기본 도형을 조합한 추상 실루엣을 32x32 흑백 이미지로 그린다.\n", + "# 회전(다각도), 원근 축소(측면 시점), 크기(원거리), 흐림·잡음·저대비·구름·가림·야간 반전을 조절한다.\n", + "# ---------------------------------------------------------------------------\n", + "IMG = 32\n", + "CLASSES = [\"civil_quad\", \"civil_fw\", \"attack_fw\", \"cruise\", \"ballistic\", \"bird\"]\n", + "KOR = {\"civil_quad\": \"민간 쿼드콥터형\", \"civil_fw\": \"민간 고정익형\", \"attack_fw\": \"삼각익 자폭드론형\",\n", + " \"cruise\": \"순항미사일형\", \"ballistic\": \"탄도미사일형\", \"bird\": \"조류\"}\n", + "THREAT = {\"attack_fw\", \"cruise\", \"ballistic\"}\n", + "\n", + "\n", + "def _rect(x0, x1, y0, y1):\n", + " return [(x0, y0), (x1, y0), (x1, y1), (x0, y1)]\n", + "\n", + "\n", + "def _bar(p, q, w):\n", + " (x0, y0), (x1, y1) = p, q\n", + " n = np.array([y0 - y1, x1 - x0]) / np.hypot(x1 - x0, y1 - y0) * w / 2\n", + " return [(x0 + n[0], y0 + n[1]), (x1 + n[0], y1 + n[1]), (x1 - n[0], y1 - n[1]), (x0 - n[0], y0 - n[1])]\n", + "\n", + "\n", + "# 도형 정의(단위 좌표, 기수 방향 = +y). 모두 추상 도형이며 특정 기종을 본뜨지 않는다.\n", + "SHAPES = {\n", + " \"civil_quad\": {\"poly\": [_bar((-.55, -.55), (.55, .55), .1), _bar((-.55, .55), (.55, -.55), .1), _rect(-.15, .15, -.15, .15)],\n", + " \"circ\": [(-.55, -.55, .24), (.55, .55, .24), (-.55, .55, .24), (.55, -.55, .24)]},\n", + " \"civil_fw\": {\"poly\": [_rect(-.07, .07, -.8, .72), _rect(-.9, .9, .05, .25), _rect(-.33, .33, -.8, -.66)], \"circ\": []},\n", + " \"attack_fw\": {\"poly\": [[(0, .85), (.85, -.5), (.12, -.42), (0, -.6), (-.12, -.42), (-.85, -.5)]], \"circ\": []},\n", + " \"cruise\": {\"poly\": [_rect(-.07, .07, -.9, .7), [(-.07, .7), (0, .92), (.07, .7)], _rect(-.42, .42, -.05, .07),\n", + " [(-.07, -.9), (-.24, -.95), (-.07, -.74)], [(.07, -.9), (.24, -.95), (.07, -.74)]], \"circ\": []},\n", + " \"ballistic\": {\"poly\": [_rect(-.13, .13, -.85, .6), [(-.13, .6), (0, .95), (.13, .6)],\n", + " [(-.13, -.85), (-.34, -.95), (-.13, -.58)], [(.13, -.85), (.34, -.95), (.13, -.58)]], \"circ\": []},\n", + " \"bird\": {\"poly\": [[(-.9, .12), (-.5, .28), (-.12, .06), (0, .22), (.12, .06), (.5, .28), (.9, .12), (.5, .12),\n", + " (.12, -.1), (0, -.38), (-.12, -.1), (-.5, .12)]], \"circ\": []},\n", + "}\n", + "_S = 2\n", + "_g = (np.arange(IMG * _S) + 0.5) / _S\n", + "_GX, _GY = np.meshgrid(_g, _g)\n", + "_PTS = np.c_[_GX.ravel(), _GY.ravel()]\n", + "_PATHS = {c: [MplPath(np.array(p)) for p in s[\"poly\"]] for c, s in SHAPES.items()}\n", + "\n", + "\n", + "def draw(cls, rng, angle=None, scale=None, fore=None, blur=0.3, noise=4.0, contrast=1.0,\n", + " clutter=0.0, occlude=0.0, invert=False, shift=1.5):\n", + " \"\"\"클래스 1개 실루엣 이미지(uint8 32x32)를 그린다. 배경은 밝은 하늘, 비행체는 어두운 그림자.\"\"\"\n", + " angle = rng.uniform(0, 360) if angle is None else angle\n", + " scale = rng.uniform(.7, .95) if scale is None else scale\n", + " fore = rng.uniform(.85, 1.0) if fore is None else fore\n", + " th = np.radians(angle)\n", + " c0 = IMG / 2 + rng.uniform(-shift, shift, 2)\n", + " d = (_PTS - c0) / (scale * IMG / 2) # 이미지 좌표 -> 도형 좌표(역변환)\n", + " u = d[:, 0] * np.cos(th) + d[:, 1] * np.sin(th)\n", + " v = (-d[:, 0] * np.sin(th) + d[:, 1] * np.cos(th)) / fore # 원근 축소(기수 방향으로 짧아 보임)\n", + " uv = np.c_[u, -v]\n", + " m = np.zeros(len(uv), bool)\n", + " near = np.flatnonzero(np.abs(uv).max(axis=1) <= 1.0) # 도형이 있을 수 있는 영역만 검사(속도)\n", + " for p in _PATHS[cls]:\n", + " m[near] |= p.contains_points(uv[near])\n", + " for cx, cy, r in SHAPES[cls][\"circ\"]:\n", + " m |= (uv[:, 0] - cx) ** 2 + (uv[:, 1] - cy) ** 2 <= r * r\n", + " cov = m.reshape(IMG, _S, IMG, _S).mean(axis=(1, 3))\n", + " if occlude > 0: # 부분 가림(구름 조각)\n", + " h = int(IMG * np.sqrt(occlude)); y0, x0 = rng.integers(8, IMG - 8 - h // 2, 2)\n", + " cov[y0:y0 + h, x0:x0 + h] = 0\n", + " bg = rng.uniform(180, 220) + np.linspace(-10, 10, IMG)[:, None]\n", + " fg = rng.uniform(30, 60)\n", + " img = bg * (1 - cov) + fg * cov\n", + " if clutter > 0: # 구름·배경 얼룩\n", + " img -= clutter * 90 * np.clip(ndimage.gaussian_filter(rng.normal(0, 1, (IMG, IMG)), 3) * 4, 0, None)\n", + " if blur > 0:\n", + " img = ndimage.gaussian_filter(img, blur)\n", + " mean = img.mean()\n", + " img = mean + (img - mean) * contrast\n", + " img = img + rng.normal(0, noise, img.shape)\n", + " if invert: # 야간 열상(IR) 극성 반전\n", + " img = 255 - img\n", + " return np.clip(img, 0, 255).astype(np.uint8)\n", + "\n", + "\n", + "# 촬영 조건 프리셋: 기본 + 복합 위협 시나리오\n", + "CONDITIONS = {\n", + " \"basic\": {},\n", + " \"offcenter\": {\"shift\": 5.0, \"fore\": (.6, .85)},\n", + " \"far\": {\"scale\": (.32, .45), \"blur\": .6},\n", + " \"blur\": {\"blur\": (1.2, 1.8)},\n", + " \"lowcon\": {\"contrast\": (.2, .35)},\n", + " \"clutter\": {\"clutter\": .8},\n", + " \"occlude\": {\"occlude\": .08},\n", + " \"oblique\": {\"fore\": (.35, .6)},\n", + " \"night\": {\"invert\": True, \"noise\": 10.0},\n", + " \"noise\": {\"noise\": 28.0},\n", + " \"combo\": {\"scale\": (.4, .55), \"blur\": (.8, 1.1), \"noise\": 12.0, \"contrast\": (.6, .8)},\n", + "}\n", + "\n", + "\n", + "def draw_cond(cls, cond, rng):\n", + " kw = {}\n", + " for k, v in CONDITIONS[cond].items():\n", + " kw[k] = rng.uniform(*v) if isinstance(v, tuple) else v\n", + " return draw(cls, rng, **kw)\n", + "\n", + "\n", + "def make_dataset(n_per_class, seed, conds=(\"basic\",)):\n", + " \"\"\"(이미지 배열 N x 32 x 32, 정답 라벨, 조건) 반환. 조건은 conds에서 번갈아 고른다.\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " X, y, c = [], [], []\n", + " for cls in CLASSES:\n", + " for i in range(n_per_class):\n", + " cond = conds[i % len(conds)]\n", + " X.append(draw_cond(cls, cond, rng)); y.append(cls); c.append(cond)\n", + " return np.array(X), np.array(y), np.array(c)\n", + "# ---------------------------------------------------------------------------\n", + "\n", + "# ---- 전처리(배치 벡터화)·형태 특징 공통 함수 ----\n", + "OUT = 24 # 크기 정규화 결과(24x24)\n", + "NOISE_T = 12.0 # 잡음 추정값이 이보다 크면 3x3 중간값 필터 적용(악천후·센서 잡음)\n", + "FEATS = [\"elong\", \"fill\", \"solidity\", \"n_parts\", \"end_gap\", \"mid_bulge\", \"w_max\", \"compact\"]\n", + "\n", + "\n", + "def preprocess(images, noise_t=NOISE_T):\n", + " \"\"\"N장 일괄 처리: 결측 보정 -> 극성(야간 반전) 보정 -> 조건별 잡음 제거 -> 명암 대비 강화 -> 분할 -> 윤곽선 -> 방향·크기 정규화.\"\"\"\n", + " f = np.array(images, dtype=float).reshape(-1, IMG, IMG)\n", + " n = len(f)\n", + " bad = ~np.isfinite(f)\n", + " missing = bad.sum(axis=(1, 2))\n", + " if bad.any(): # 결측 픽셀: 영상 중앙값으로 채운 뒤 주변 3x3 중간값으로 보정\n", + " med = np.array([np.median(x[np.isfinite(x)]) if np.isfinite(x).any() else 128.0 for x in f])\n", + " f = np.where(bad, med[:, None, None], f)\n", + " f = np.where(bad, ndimage.median_filter(f, size=(1, 3, 3)), f)\n", + " border = np.concatenate([f[:, 0], f[:, -1], f[:, :, 0], f[:, :, -1]], axis=1)\n", + " inverted = np.median(border, axis=1) < 128 # 배경이 어두우면 야간 열상으로 보고 반전\n", + " f[inverted] = 255 - f[inverted]\n", + " med3 = ndimage.median_filter(f, size=(1, 3, 3))\n", + " noise = 1.4826 * np.median(np.abs(f - med3).reshape(n, -1), axis=1)\n", + " noisy = noise > noise_t\n", + " f[noisy] = med3[noisy]\n", + " lo, hi = np.percentile(f.reshape(n, -1), [1, 99], axis=1)\n", + " e = np.clip((f - lo[:, None, None]) / np.maximum(hi - lo, 1e-6)[:, None, None], 0, 1) # 명암 대비 강화\n", + " mask = e < _otsu(e)[:, None, None]\n", + " mask &= ndimage.uniform_filter(mask.astype(float), size=(1, 3, 3)) * 9 >= 2.5 # 고립 점 잡음 제거\n", + " edge = np.hypot(ndimage.sobel(e, axis=1), ndimage.sobel(e, axis=2)) # 윤곽선(Sobel)\n", + " ok = (mask.sum(axis=(1, 2)) >= 4) & (hi - lo > 8)\n", + " return {\"enh\": e, \"mask\": mask, \"edge\": edge / np.maximum(edge.max(axis=(1, 2)), 1e-6)[:, None, None],\n", + " \"norm\": _normalize(1 - e, mask, ok), \"inverted\": inverted, \"noise\": noise, \"denoised\": noisy,\n", + " \"missing\": missing, \"contrast\": hi - lo, \"ok\": ok}\n", + "\n", + "\n", + "def _otsu(e, bins=64):\n", + " n = len(e)\n", + " idx = np.minimum((e * bins).astype(int), bins - 1) + bins * np.arange(n)[:, None, None]\n", + " hist = np.bincount(idx.ravel(), minlength=bins * n).reshape(n, bins).astype(float)\n", + " centers = (np.arange(bins) + 0.5) / bins\n", + " w = np.cumsum(hist, axis=1); m = np.cumsum(hist * centers, axis=1)\n", + " tot, mt = w[:, -1:], m[:, -1:]\n", + " between = (mt * w / tot - m) ** 2 / np.maximum(w * (tot - w), 1)\n", + " return (np.argmax(between, axis=1) + 1) / bins\n", + "\n", + "\n", + "def _normalize(obj, mask, ok):\n", + " \"\"\"주축 방향을 세로로 돌리고 비행체 영역만 24x24로 다시 샘플링한다(회전·크기·위치 불변). 한 번의 보간으로 일괄 처리.\"\"\"\n", + " n = len(obj)\n", + " w = mask.astype(float)\n", + " m00 = np.maximum(w.sum(axis=(1, 2)), 1)\n", + " yy, xx = np.mgrid[0:IMG, 0:IMG].astype(float)\n", + " cy, cx = (w * yy).sum(axis=(1, 2)) / m00, (w * xx).sum(axis=(1, 2)) / m00\n", + " dy, dx = yy - cy[:, None, None], xx - cx[:, None, None]\n", + " mu20, mu02, mu11 = (w * dy * dy).sum(axis=(1, 2)), (w * dx * dx).sum(axis=(1, 2)), (w * dy * dx).sum(axis=(1, 2))\n", + " th = 0.5 * np.arctan2(2 * mu11, mu20 - mu02)\n", + " a = np.stack([np.cos(th), np.sin(th)], 1) # 주축(y, x)\n", + " b = np.stack([-np.sin(th), np.cos(th)], 1) # 부축\n", + " pa = np.where(mask, dy * a[:, 0, None, None] + dx * a[:, 1, None, None], 0)\n", + " pb = np.where(mask, dy * b[:, 0, None, None] + dx * b[:, 1, None, None], 0)\n", + " half = np.maximum(np.abs(pa).max(axis=(1, 2)), np.abs(pb).max(axis=(1, 2))) + 1.0\n", + " g = np.linspace(-1, 1, OUT)\n", + " U, V = np.meshgrid(g, g, indexing=\"ij\")\n", + " Y = cy[:, None, None] + half[:, None, None] * (U * a[:, 0, None, None] + V * b[:, 0, None, None])\n", + " X = cx[:, None, None] + half[:, None, None] * (U * a[:, 1, None, None] + V * b[:, 1, None, None])\n", + " N = np.broadcast_to(np.arange(n)[:, None, None], Y.shape)\n", + " out = ndimage.map_coordinates(obj * mask, [N, Y, X], order=1, cval=0.0)\n", + " skew = (out * (U ** 3)).sum(axis=(1, 2))\n", + " out[skew < 0] = out[skew < 0, ::-1] # 무게가 쏠린 쪽을 위로(180도 모호성 제거)\n", + " out[~ok] = 0\n", + " return np.clip(out, 0, 1)\n", + "\n", + "\n", + "def shape_features(mask):\n", + " \"\"\"분할 마스크 1장에서 회전에 무관한 형태 특징 8종을 계산한다.\"\"\"\n", + " if mask.sum() < 4:\n", + " return dict.fromkeys(FEATS, 0.0)\n", + " yx = np.argwhere(mask).astype(float)\n", + " c = yx - yx.mean(axis=0)\n", + " val, vec = np.linalg.eigh(np.cov(c.T) + 1e-6 * np.eye(2))\n", + " t, s = c @ vec[:, 1], c @ vec[:, 0]\n", + " length, width = np.ptp(t) + 1, np.ptp(s) + 1\n", + " corners = np.vstack([yx + d for d in ([.5, .5], [.5, -.5], [-.5, .5], [-.5, -.5])])\n", + " try:\n", + " hull = ConvexHull(corners).volume\n", + " except Exception:\n", + " hull = float(mask.sum())\n", + " bins = np.digitize(t, np.linspace(t.min(), t.max() + 1e-6, 4)[1:-1])\n", + " w = np.array([np.ptp(s[bins == k]) + 1 if (bins == k).any() else 0 for k in range(3)]) / length\n", + " boundary = mask & ~ndimage.binary_erosion(mask)\n", + " return {\"elong\": float(np.sqrt(val[1] / max(val[0], 1e-6))), \"fill\": float(mask.sum() / (length * width)),\n", + " \"solidity\": float(mask.sum() / max(hull, 1)), \"n_parts\": float(ndimage.label(mask)[1]),\n", + " \"end_gap\": float(abs(w[0] - w[2])), \"mid_bulge\": float(w[1] - max(w[0], w[2])),\n", + " \"w_max\": float(w.max()), \"compact\": float(boundary.sum() / np.sqrt(mask.sum()))}\n", + "\n", + "\n", + "def feature_matrix(images):\n", + " \"\"\"전처리 결과, 형태 특징 행렬(N x 8), 정규화 이미지 행렬(N x 576)을 돌려준다.\"\"\"\n", + " P = preprocess(images)\n", + " F = np.array([[d[k] for k in FEATS] for d in map(shape_features, P[\"mask\"])])\n", + " return P, F, P[\"norm\"].reshape(len(F), -1)\n", + "\n", + "# ---- 분류 모델 공통 함수(다층 퍼셉트론: 정규화 이미지 576픽셀 + 형태 특징 8종) ----\n", + "def to_input(F, N, scaler):\n", + " return np.c_[N, scaler.transform(F) * 0.3]\n", + "\n", + "\n", + "def train_model(n_per_class=150, seed=5, conds=tuple(CONDITIONS), hidden=(64,)):\n", + " X, y, _ = make_dataset(n_per_class, seed, conds)\n", + " P, F, N = feature_matrix(X)\n", + " scaler = StandardScaler().fit(F)\n", + " model = MLPClassifier(hidden, max_iter=200, early_stopping=True, random_state=0)\n", + " model.fit(to_input(F, N, scaler), [CLASSES.index(v) for v in y])\n", + " return model, scaler\n", + "\n", + "\n", + "def predict(model, scaler, images):\n", + " \"\"\"(전처리 결과, 클래스별 확률 N x 6)을 돌려준다.\"\"\"\n", + " P, F, N = feature_matrix(images)\n", + " return P, model.predict_proba(to_input(F, N, scaler))\n", + "\n", + "# 1) 학습: 모든 촬영 조건을 섞은 증강 데이터(클래스당 200장)\n", + "t0 = time.perf_counter()\n", + "model, scaler = train_model(200, seed=5, hidden=(96,))\n", + "print(f\"신경망 학습 완료 ({time.perf_counter() - t0:.1f} s, 입력 584차원 = 정규화 이미지 576 + 형태 특징 8)\")\n", + "Xb, yb, _ = make_dataset(60, 1)\n", + "tree = DecisionTreeClassifier(max_depth=4, random_state=0).fit(feature_matrix(Xb)[1], yb) # 미션 3 Baseline\n", + "\n", + "# 2) 복합 위협 시나리오 10종 시험(시나리오당 클래스별 15장)\n", + "SCEN = [k for k in CONDITIONS if k != \"basic\"]\n", + "rows, all_true, all_pred = [], [], []\n", + "for i, cond in enumerate(SCEN):\n", + " X, y, _ = make_dataset(15, 100 + i, (cond,))\n", + " t0 = time.perf_counter()\n", + " P, prob = predict(model, scaler, X)\n", + " ms = (time.perf_counter() - t0) * 1000 / len(X)\n", + " pred = np.array(CLASSES)[prob.argmax(1)]\n", + " thr = np.isin(y, list(THREAT))\n", + " rec = np.isin(pred[thr], list(THREAT)).mean()\n", + " rows.append({\"시나리오\": cond, \"Baseline 정확도\": (tree.predict(feature_matrix(X)[1]) == y).mean(), \"신경망 정확도\": (pred == y).mean(),\n", + " \"위협 재현율\": rec, \"장당 ms\": ms, \"통과\": \"PASS\" if rec >= 0.85 and ms <= 200 else \"FAIL\"})\n", + " all_true += list(y); all_pred += list(pred)\n", + "df = pd.DataFrame(rows)\n", + "print(df.round(3).to_string(index=False))\n", + "yt, yp = np.array(all_true), np.array(all_pred)\n", + "tr = np.isin(yt, list(THREAT))\n", + "recall = np.isin(yp[tr], list(THREAT)).mean()\n", + "print(f\"\\n복합 위협 재현율 {recall:.1%} (목표 85% 이상: {'달성' if recall >= 0.85 else '미달'}), 최대 처리 지연 {df['장당 ms'].max():.2f} ms (목표 200 ms 이하)\")\n", + "print(f\"시나리오 통과 {int((df['통과'] == 'PASS').sum())}/10\")\n", + "\n", + "# 3) 오경보 유발 패턴: 비위협(민간기·조류)을 위협으로 판정한 경우\n", + "fa = pd.Series([f\"{t} -> {p}\" for t, p in zip(yt, yp) if t not in THREAT and p in THREAT])\n", + "print(\"\\n[오경보 유발 패턴 상위]\", fa.value_counts().head(3).to_dict())\n", + "miss = pd.Series([f\"{t} -> {p}\" for t, p in zip(yt, yp) if t in THREAT and p not in THREAT])\n", + "print(\"[위협 미탐 패턴 상위]\", miss.value_counts().head(3).to_dict())\n", + "print(\"특징 공간 재설계 방향: 원거리·결합 조건에서 날개 끝 형상이 뭉개지므로 폭 분포(end_gap, w_max) 외에 윤곽선 곡률 특징 추가를 검토한다.\")\n", + "\n", + "plt.figure(figsize=(9, 4))\n", + "x = np.arange(len(df))\n", + "plt.bar(x - 0.2, df[\"Baseline 정확도\"], 0.4, label=\"baseline tree\")\n", + "plt.bar(x + 0.2, df[\"신경망 정확도\"], 0.4, label=\"MLP (augmented)\")\n", + "plt.axhline(0.85, ls=\"--\", c=\"gray\")\n", + "plt.xticks(x, df[\"시나리오\"], rotation=30); plt.ylabel(\"accuracy\"); plt.legend()\n", + "plt.title(\"Accuracy by complex scenario (synthetic)\"); plt.tight_layout(); plt.show()\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 04 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + "신경망 학습 완료 (3.3 s, 입력 584차원 = 정규화 이미지 576 + 형태 특징 8)\n", + " 시나리오 Baseline 정확도 신경망 정확도 위협 재현율 장당 ms 통과\n", + "offcenter 0.767 1.000 1.000 1.239 PASS\n", + " far 0.467 0.922 0.911 1.131 PASS\n", + " blur 0.400 1.000 1.000 1.201 PASS\n", + " lowcon 0.900 1.000 1.000 1.735 PASS\n", + " clutter 0.556 0.700 0.711 1.153 FAIL\n", + " occlude 0.556 0.789 0.844 1.234 FAIL\n", + " oblique 0.333 0.978 1.000 1.544 PASS\n", + " night 0.967 1.000 1.000 1.207 PASS\n", + " noise 0.778 0.956 1.000 1.178 PASS\n", + " combo 0.311 0.378 0.600 1.319 FAIL\n", + "\n", + "복합 위협 재현율 90.7% (목표 85% 이상: 달성), 최대 처리 지연 1.73 ms (목표 200 ms 이하)\n", + "시나리오 통과 7/10\n", + "\n", + "[오경보 유발 패턴 상위] {'bird -> cruise': 9, 'civil_fw -> cruise': 7, 'civil_quad -> cruise': 6}\n", + "[위협 미탐 패턴 상위] {'cruise -> bird': 14, 'ballistic -> bird': 11, 'ballistic -> civil_fw': 8}\n", + "특징 공간 재설계 방향: 원거리·결합 조건에서 날개 끝 형상이 뭉개지므로 폭 분포(end_gap, w_max) 외에 윤곽선 곡률 특징 추가를 검토한다.\n", + "```\n", + "
\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 05. 유사 형상 비행체(민간 드론 vs 자폭 드론) 오분류 방지 신뢰도 점수 엔진 구현\n", + "**소요시간**: 약 3.0시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "유사 형상 비행체 오분류 방지 신뢰도 점수 산출 엔진을 개발한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **민간 드론과 자폭 드론처럼 형상이 비슷한 비행체의 오분류를 막기 위해 신뢰도 점수와 등급을 산출하는 엔진을 개발한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **분류 확률, 1·2위 확률 차이, 영상 품질(대비·크기·잡음)을 가중 결합해 신뢰도 점수를 만들고 High·Medium·Low 등급을 부여 수행하고 검증하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `src/verification/trn03_confidence_engine.py` 형태의 결과물을 완성한다.\n", + "\n", + "### 🔍 평가 및 검증 기준 (PASS 조건)\n", + "#### 미션 판정\n", + "\n", + "**평가 구분:** 필수\n", + "\n", + "**핵심 평가영역:** 신뢰도 등급(High/Med/Low) 부여 기준의 군사적 타당성\n", + "\n", + "#### 미션 PASS 조건\n", + "\n", + "- 정량 기준: 오경보율(FAR) 40% 이상 감축, 신뢰도 스코어 산출 일관성 90% 이상\n", + "\n", + "\n", + "- 진행 기준: 오경보 필터링 전후 비교 지표 산출 및 통합 테스트 완료\n", + "\n", + "\n", + "- 검증 방법: ROC-AUC 곡선 분석 및 임계값 민감도 테스트\n", + "\n", + "\n", + "#### 채점자 판정 메모\n", + "\n", + "> 단순 코드 실행 여부뿐만 아니라 출력된 수치와 근거 자료가 군 실무 요구사항에 부합하는지 면밀히 확인한다.\n", + "\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "\"\"\"민간 드론과 자폭 드론처럼 형상이 비슷한 비행체의 오분류를 줄이기 위해, 분류 확률·1/2위 확률 차이·영상 품질을 결합한 신뢰도 점수와 등급(High/Medium/Low)을 산출하고, 오경보율(FAR) 감축·점수 일관성·ROC-AUC·임계값 민감도를 검증한다.\"\"\"\n", + "import numpy as np\n", + "import pandas as pd\n", + "import matplotlib.pyplot as plt\n", + "from matplotlib.path import Path as MplPath\n", + "from scipy import ndimage\n", + "from scipy.spatial import ConvexHull\n", + "from sklearn.metrics import roc_auc_score, roc_curve\n", + "from sklearn.neural_network import MLPClassifier\n", + "from sklearn.preprocessing import StandardScaler\n", + "\n", + "# ---------------------------------------------------------------------------\n", + "# [교육용가상자료] 합성 실루엣 생성기 (실제 기체 사진·도면·제원 사용 안 함)\n", + "# 사각형·삼각형·원 같은 기본 도형을 조합한 추상 실루엣을 32x32 흑백 이미지로 그린다.\n", + "# 회전(다각도), 원근 축소(측면 시점), 크기(원거리), 흐림·잡음·저대비·구름·가림·야간 반전을 조절한다.\n", + "# ---------------------------------------------------------------------------\n", + "IMG = 32\n", + "CLASSES = [\"civil_quad\", \"civil_fw\", \"attack_fw\", \"cruise\", \"ballistic\", \"bird\"]\n", + "KOR = {\"civil_quad\": \"민간 쿼드콥터형\", \"civil_fw\": \"민간 고정익형\", \"attack_fw\": \"삼각익 자폭드론형\",\n", + " \"cruise\": \"순항미사일형\", \"ballistic\": \"탄도미사일형\", \"bird\": \"조류\"}\n", + "THREAT = {\"attack_fw\", \"cruise\", \"ballistic\"}\n", + "\n", + "\n", + "def _rect(x0, x1, y0, y1):\n", + " return [(x0, y0), (x1, y0), (x1, y1), (x0, y1)]\n", + "\n", + "\n", + "def _bar(p, q, w):\n", + " (x0, y0), (x1, y1) = p, q\n", + " n = np.array([y0 - y1, x1 - x0]) / np.hypot(x1 - x0, y1 - y0) * w / 2\n", + " return [(x0 + n[0], y0 + n[1]), (x1 + n[0], y1 + n[1]), (x1 - n[0], y1 - n[1]), (x0 - n[0], y0 - n[1])]\n", + "\n", + "\n", + "# 도형 정의(단위 좌표, 기수 방향 = +y). 모두 추상 도형이며 특정 기종을 본뜨지 않는다.\n", + "SHAPES = {\n", + " \"civil_quad\": {\"poly\": [_bar((-.55, -.55), (.55, .55), .1), _bar((-.55, .55), (.55, -.55), .1), _rect(-.15, .15, -.15, .15)],\n", + " \"circ\": [(-.55, -.55, .24), (.55, .55, .24), (-.55, .55, .24), (.55, -.55, .24)]},\n", + " \"civil_fw\": {\"poly\": [_rect(-.07, .07, -.8, .72), _rect(-.9, .9, .05, .25), _rect(-.33, .33, -.8, -.66)], \"circ\": []},\n", + " \"attack_fw\": {\"poly\": [[(0, .85), (.85, -.5), (.12, -.42), (0, -.6), (-.12, -.42), (-.85, -.5)]], \"circ\": []},\n", + " \"cruise\": {\"poly\": [_rect(-.07, .07, -.9, .7), [(-.07, .7), (0, .92), (.07, .7)], _rect(-.42, .42, -.05, .07),\n", + " [(-.07, -.9), (-.24, -.95), (-.07, -.74)], [(.07, -.9), (.24, -.95), (.07, -.74)]], \"circ\": []},\n", + " \"ballistic\": {\"poly\": [_rect(-.13, .13, -.85, .6), [(-.13, .6), (0, .95), (.13, .6)],\n", + " [(-.13, -.85), (-.34, -.95), (-.13, -.58)], [(.13, -.85), (.34, -.95), (.13, -.58)]], \"circ\": []},\n", + " \"bird\": {\"poly\": [[(-.9, .12), (-.5, .28), (-.12, .06), (0, .22), (.12, .06), (.5, .28), (.9, .12), (.5, .12),\n", + " (.12, -.1), (0, -.38), (-.12, -.1), (-.5, .12)]], \"circ\": []},\n", + "}\n", + "_S = 2\n", + "_g = (np.arange(IMG * _S) + 0.5) / _S\n", + "_GX, _GY = np.meshgrid(_g, _g)\n", + "_PTS = np.c_[_GX.ravel(), _GY.ravel()]\n", + "_PATHS = {c: [MplPath(np.array(p)) for p in s[\"poly\"]] for c, s in SHAPES.items()}\n", + "\n", + "\n", + "def draw(cls, rng, angle=None, scale=None, fore=None, blur=0.3, noise=4.0, contrast=1.0,\n", + " clutter=0.0, occlude=0.0, invert=False, shift=1.5):\n", + " \"\"\"클래스 1개 실루엣 이미지(uint8 32x32)를 그린다. 배경은 밝은 하늘, 비행체는 어두운 그림자.\"\"\"\n", + " angle = rng.uniform(0, 360) if angle is None else angle\n", + " scale = rng.uniform(.7, .95) if scale is None else scale\n", + " fore = rng.uniform(.85, 1.0) if fore is None else fore\n", + " th = np.radians(angle)\n", + " c0 = IMG / 2 + rng.uniform(-shift, shift, 2)\n", + " d = (_PTS - c0) / (scale * IMG / 2) # 이미지 좌표 -> 도형 좌표(역변환)\n", + " u = d[:, 0] * np.cos(th) + d[:, 1] * np.sin(th)\n", + " v = (-d[:, 0] * np.sin(th) + d[:, 1] * np.cos(th)) / fore # 원근 축소(기수 방향으로 짧아 보임)\n", + " uv = np.c_[u, -v]\n", + " m = np.zeros(len(uv), bool)\n", + " near = np.flatnonzero(np.abs(uv).max(axis=1) <= 1.0) # 도형이 있을 수 있는 영역만 검사(속도)\n", + " for p in _PATHS[cls]:\n", + " m[near] |= p.contains_points(uv[near])\n", + " for cx, cy, r in SHAPES[cls][\"circ\"]:\n", + " m |= (uv[:, 0] - cx) ** 2 + (uv[:, 1] - cy) ** 2 <= r * r\n", + " cov = m.reshape(IMG, _S, IMG, _S).mean(axis=(1, 3))\n", + " if occlude > 0: # 부분 가림(구름 조각)\n", + " h = int(IMG * np.sqrt(occlude)); y0, x0 = rng.integers(8, IMG - 8 - h // 2, 2)\n", + " cov[y0:y0 + h, x0:x0 + h] = 0\n", + " bg = rng.uniform(180, 220) + np.linspace(-10, 10, IMG)[:, None]\n", + " fg = rng.uniform(30, 60)\n", + " img = bg * (1 - cov) + fg * cov\n", + " if clutter > 0: # 구름·배경 얼룩\n", + " img -= clutter * 90 * np.clip(ndimage.gaussian_filter(rng.normal(0, 1, (IMG, IMG)), 3) * 4, 0, None)\n", + " if blur > 0:\n", + " img = ndimage.gaussian_filter(img, blur)\n", + " mean = img.mean()\n", + " img = mean + (img - mean) * contrast\n", + " img = img + rng.normal(0, noise, img.shape)\n", + " if invert: # 야간 열상(IR) 극성 반전\n", + " img = 255 - img\n", + " return np.clip(img, 0, 255).astype(np.uint8)\n", + "\n", + "\n", + "# 촬영 조건 프리셋: 기본 + 복합 위협 시나리오\n", + "CONDITIONS = {\n", + " \"basic\": {},\n", + " \"offcenter\": {\"shift\": 5.0, \"fore\": (.6, .85)},\n", + " \"far\": {\"scale\": (.32, .45), \"blur\": .6},\n", + " \"blur\": {\"blur\": (1.2, 1.8)},\n", + " \"lowcon\": {\"contrast\": (.2, .35)},\n", + " \"clutter\": {\"clutter\": .8},\n", + " \"occlude\": {\"occlude\": .08},\n", + " \"oblique\": {\"fore\": (.35, .6)},\n", + " \"night\": {\"invert\": True, \"noise\": 10.0},\n", + " \"noise\": {\"noise\": 28.0},\n", + " \"combo\": {\"scale\": (.4, .55), \"blur\": (.8, 1.1), \"noise\": 12.0, \"contrast\": (.6, .8)},\n", + "}\n", + "\n", + "\n", + "def draw_cond(cls, cond, rng):\n", + " kw = {}\n", + " for k, v in CONDITIONS[cond].items():\n", + " kw[k] = rng.uniform(*v) if isinstance(v, tuple) else v\n", + " return draw(cls, rng, **kw)\n", + "\n", + "\n", + "def make_dataset(n_per_class, seed, conds=(\"basic\",)):\n", + " \"\"\"(이미지 배열 N x 32 x 32, 정답 라벨, 조건) 반환. 조건은 conds에서 번갈아 고른다.\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " X, y, c = [], [], []\n", + " for cls in CLASSES:\n", + " for i in range(n_per_class):\n", + " cond = conds[i % len(conds)]\n", + " X.append(draw_cond(cls, cond, rng)); y.append(cls); c.append(cond)\n", + " return np.array(X), np.array(y), np.array(c)\n", + "# ---------------------------------------------------------------------------\n", + "\n", + "# ---- 전처리(배치 벡터화)·형태 특징 공통 함수 ----\n", + "OUT = 24 # 크기 정규화 결과(24x24)\n", + "NOISE_T = 12.0 # 잡음 추정값이 이보다 크면 3x3 중간값 필터 적용(악천후·센서 잡음)\n", + "FEATS = [\"elong\", \"fill\", \"solidity\", \"n_parts\", \"end_gap\", \"mid_bulge\", \"w_max\", \"compact\"]\n", + "\n", + "\n", + "def preprocess(images, noise_t=NOISE_T):\n", + " \"\"\"N장 일괄 처리: 결측 보정 -> 극성(야간 반전) 보정 -> 조건별 잡음 제거 -> 명암 대비 강화 -> 분할 -> 윤곽선 -> 방향·크기 정규화.\"\"\"\n", + " f = np.array(images, dtype=float).reshape(-1, IMG, IMG)\n", + " n = len(f)\n", + " bad = ~np.isfinite(f)\n", + " missing = bad.sum(axis=(1, 2))\n", + " if bad.any(): # 결측 픽셀: 영상 중앙값으로 채운 뒤 주변 3x3 중간값으로 보정\n", + " med = np.array([np.median(x[np.isfinite(x)]) if np.isfinite(x).any() else 128.0 for x in f])\n", + " f = np.where(bad, med[:, None, None], f)\n", + " f = np.where(bad, ndimage.median_filter(f, size=(1, 3, 3)), f)\n", + " border = np.concatenate([f[:, 0], f[:, -1], f[:, :, 0], f[:, :, -1]], axis=1)\n", + " inverted = np.median(border, axis=1) < 128 # 배경이 어두우면 야간 열상으로 보고 반전\n", + " f[inverted] = 255 - f[inverted]\n", + " med3 = ndimage.median_filter(f, size=(1, 3, 3))\n", + " noise = 1.4826 * np.median(np.abs(f - med3).reshape(n, -1), axis=1)\n", + " noisy = noise > noise_t\n", + " f[noisy] = med3[noisy]\n", + " lo, hi = np.percentile(f.reshape(n, -1), [1, 99], axis=1)\n", + " e = np.clip((f - lo[:, None, None]) / np.maximum(hi - lo, 1e-6)[:, None, None], 0, 1) # 명암 대비 강화\n", + " mask = e < _otsu(e)[:, None, None]\n", + " mask &= ndimage.uniform_filter(mask.astype(float), size=(1, 3, 3)) * 9 >= 2.5 # 고립 점 잡음 제거\n", + " edge = np.hypot(ndimage.sobel(e, axis=1), ndimage.sobel(e, axis=2)) # 윤곽선(Sobel)\n", + " ok = (mask.sum(axis=(1, 2)) >= 4) & (hi - lo > 8)\n", + " return {\"enh\": e, \"mask\": mask, \"edge\": edge / np.maximum(edge.max(axis=(1, 2)), 1e-6)[:, None, None],\n", + " \"norm\": _normalize(1 - e, mask, ok), \"inverted\": inverted, \"noise\": noise, \"denoised\": noisy,\n", + " \"missing\": missing, \"contrast\": hi - lo, \"ok\": ok}\n", + "\n", + "\n", + "def _otsu(e, bins=64):\n", + " n = len(e)\n", + " idx = np.minimum((e * bins).astype(int), bins - 1) + bins * np.arange(n)[:, None, None]\n", + " hist = np.bincount(idx.ravel(), minlength=bins * n).reshape(n, bins).astype(float)\n", + " centers = (np.arange(bins) + 0.5) / bins\n", + " w = np.cumsum(hist, axis=1); m = np.cumsum(hist * centers, axis=1)\n", + " tot, mt = w[:, -1:], m[:, -1:]\n", + " between = (mt * w / tot - m) ** 2 / np.maximum(w * (tot - w), 1)\n", + " return (np.argmax(between, axis=1) + 1) / bins\n", + "\n", + "\n", + "def _normalize(obj, mask, ok):\n", + " \"\"\"주축 방향을 세로로 돌리고 비행체 영역만 24x24로 다시 샘플링한다(회전·크기·위치 불변). 한 번의 보간으로 일괄 처리.\"\"\"\n", + " n = len(obj)\n", + " w = mask.astype(float)\n", + " m00 = np.maximum(w.sum(axis=(1, 2)), 1)\n", + " yy, xx = np.mgrid[0:IMG, 0:IMG].astype(float)\n", + " cy, cx = (w * yy).sum(axis=(1, 2)) / m00, (w * xx).sum(axis=(1, 2)) / m00\n", + " dy, dx = yy - cy[:, None, None], xx - cx[:, None, None]\n", + " mu20, mu02, mu11 = (w * dy * dy).sum(axis=(1, 2)), (w * dx * dx).sum(axis=(1, 2)), (w * dy * dx).sum(axis=(1, 2))\n", + " th = 0.5 * np.arctan2(2 * mu11, mu20 - mu02)\n", + " a = np.stack([np.cos(th), np.sin(th)], 1) # 주축(y, x)\n", + " b = np.stack([-np.sin(th), np.cos(th)], 1) # 부축\n", + " pa = np.where(mask, dy * a[:, 0, None, None] + dx * a[:, 1, None, None], 0)\n", + " pb = np.where(mask, dy * b[:, 0, None, None] + dx * b[:, 1, None, None], 0)\n", + " half = np.maximum(np.abs(pa).max(axis=(1, 2)), np.abs(pb).max(axis=(1, 2))) + 1.0\n", + " g = np.linspace(-1, 1, OUT)\n", + " U, V = np.meshgrid(g, g, indexing=\"ij\")\n", + " Y = cy[:, None, None] + half[:, None, None] * (U * a[:, 0, None, None] + V * b[:, 0, None, None])\n", + " X = cx[:, None, None] + half[:, None, None] * (U * a[:, 1, None, None] + V * b[:, 1, None, None])\n", + " N = np.broadcast_to(np.arange(n)[:, None, None], Y.shape)\n", + " out = ndimage.map_coordinates(obj * mask, [N, Y, X], order=1, cval=0.0)\n", + " skew = (out * (U ** 3)).sum(axis=(1, 2))\n", + " out[skew < 0] = out[skew < 0, ::-1] # 무게가 쏠린 쪽을 위로(180도 모호성 제거)\n", + " out[~ok] = 0\n", + " return np.clip(out, 0, 1)\n", + "\n", + "\n", + "def shape_features(mask):\n", + " \"\"\"분할 마스크 1장에서 회전에 무관한 형태 특징 8종을 계산한다.\"\"\"\n", + " if mask.sum() < 4:\n", + " return dict.fromkeys(FEATS, 0.0)\n", + " yx = np.argwhere(mask).astype(float)\n", + " c = yx - yx.mean(axis=0)\n", + " val, vec = np.linalg.eigh(np.cov(c.T) + 1e-6 * np.eye(2))\n", + " t, s = c @ vec[:, 1], c @ vec[:, 0]\n", + " length, width = np.ptp(t) + 1, np.ptp(s) + 1\n", + " corners = np.vstack([yx + d for d in ([.5, .5], [.5, -.5], [-.5, .5], [-.5, -.5])])\n", + " try:\n", + " hull = ConvexHull(corners).volume\n", + " except Exception:\n", + " hull = float(mask.sum())\n", + " bins = np.digitize(t, np.linspace(t.min(), t.max() + 1e-6, 4)[1:-1])\n", + " w = np.array([np.ptp(s[bins == k]) + 1 if (bins == k).any() else 0 for k in range(3)]) / length\n", + " boundary = mask & ~ndimage.binary_erosion(mask)\n", + " return {\"elong\": float(np.sqrt(val[1] / max(val[0], 1e-6))), \"fill\": float(mask.sum() / (length * width)),\n", + " \"solidity\": float(mask.sum() / max(hull, 1)), \"n_parts\": float(ndimage.label(mask)[1]),\n", + " \"end_gap\": float(abs(w[0] - w[2])), \"mid_bulge\": float(w[1] - max(w[0], w[2])),\n", + " \"w_max\": float(w.max()), \"compact\": float(boundary.sum() / np.sqrt(mask.sum()))}\n", + "\n", + "\n", + "def feature_matrix(images):\n", + " \"\"\"전처리 결과, 형태 특징 행렬(N x 8), 정규화 이미지 행렬(N x 576)을 돌려준다.\"\"\"\n", + " P = preprocess(images)\n", + " F = np.array([[d[k] for k in FEATS] for d in map(shape_features, P[\"mask\"])])\n", + " return P, F, P[\"norm\"].reshape(len(F), -1)\n", + "\n", + "# ---- 분류 모델 공통 함수(다층 퍼셉트론: 정규화 이미지 576픽셀 + 형태 특징 8종) ----\n", + "def to_input(F, N, scaler):\n", + " return np.c_[N, scaler.transform(F) * 0.3]\n", + "\n", + "\n", + "def train_model(n_per_class=150, seed=5, conds=tuple(CONDITIONS), hidden=(64,)):\n", + " X, y, _ = make_dataset(n_per_class, seed, conds)\n", + " P, F, N = feature_matrix(X)\n", + " scaler = StandardScaler().fit(F)\n", + " model = MLPClassifier(hidden, max_iter=200, early_stopping=True, random_state=0)\n", + " model.fit(to_input(F, N, scaler), [CLASSES.index(v) for v in y])\n", + " return model, scaler\n", + "\n", + "\n", + "def predict(model, scaler, images):\n", + " \"\"\"(전처리 결과, 클래스별 확률 N x 6)을 돌려준다.\"\"\"\n", + " P, F, N = feature_matrix(images)\n", + " return P, model.predict_proba(to_input(F, N, scaler))\n", + "\n", + "WEIGHTS = {\"p_max\": 0.6, \"margin\": 0.4} # 신뢰도 가중치 매트릭스(확률 대 확률 차이)\n", + "HIGH, MEDIUM = 0.8, 0.5\n", + "\n", + "\n", + "def quality(P):\n", + " \"\"\"영상 품질 0.5~1: 대비가 낮거나, 표적이 작거나, 잡음이 크면 낮아진다.\"\"\"\n", + " size = P[\"mask\"].sum(axis=(1, 2))\n", + " q = np.clip(P[\"contrast\"] / 120, 0, 1) * 0.4 + np.clip(size / 60, 0, 1) * 0.4 + np.clip(1 - P[\"noise\"] / 40, 0, 1) * 0.2\n", + " return 0.5 + 0.5 * q\n", + "\n", + "\n", + "def confidence(P, prob):\n", + " s = np.sort(prob, axis=1)\n", + " score = (WEIGHTS[\"p_max\"] * s[:, -1] + WEIGHTS[\"margin\"] * (s[:, -1] - s[:, -2])) * quality(P)\n", + " grade = np.where(score >= HIGH, \"High\", np.where(score >= MEDIUM, \"Medium\", \"Low\"))\n", + " return score, grade\n", + "\n", + "\n", + "def report(name, alert, y):\n", + " true_thr = np.isin(y, list(THREAT))\n", + " far = (alert & ~true_thr).sum() / max(alert.sum(), 1)\n", + " return {\"방식\": name, \"위협 경보\": int(alert.sum()), \"오경보\": int((alert & ~true_thr).sum()), \"오경보율\": round(far, 3),\n", + " \"위협 재현율\": round((alert & true_thr).sum() / true_thr.sum(), 3)}\n", + "\n", + "\n", + "model, scaler = train_model(200, seed=5, hidden=(96,))\n", + "X, y, cond = make_dataset(110, 21, tuple(CONDITIONS)) # 검증용 660장(학습에 쓰지 않음)\n", + "P, prob = predict(model, scaler, X)\n", + "pred = np.array(CLASSES)[prob.argmax(1)]\n", + "score, grade = confidence(P, prob)\n", + "is_thr_pred = np.isin(pred, list(THREAT))\n", + "\n", + "before = report(\"기존(최고 확률 클래스로 단정)\", is_thr_pred, y)\n", + "after = report(\"신뢰도 엔진(Low는 교관 확인)\", is_thr_pred & (grade != \"Low\"), y)\n", + "print(pd.DataFrame([before, after]).to_string(index=False))\n", + "cut = 1 - after[\"오경보율\"] / max(before[\"오경보율\"], 1e-9)\n", + "print(f\"\\n오경보율 감축 {cut:.1%} (목표 40% 이상: {'달성' if cut >= 0.4 else '미달'}), 교관 확인으로 넘긴 위협 판정 {int((is_thr_pred & (grade == 'Low')).sum())}건\")\n", + "\n", + "# 일관성: 같은 이미지에 미세 잡음(밝기 ±1)을 더해 다시 계산했을 때 같은 등급이 나오는 비율\n", + "rng = np.random.default_rng(0)\n", + "P2, prob2 = predict(model, scaler, np.clip(X + rng.integers(-1, 2, X.shape), 0, 255))\n", + "cons = (confidence(P2, prob2)[1] == grade).mean()\n", + "print(f\"신뢰도 점수 산출 일관성 {cons:.1%} (목표 90% 이상: {'달성' if cons >= 0.9 else '미달'})\")\n", + "\n", + "p_threat = prob[:, [CLASSES.index(c) for c in THREAT]].sum(1)\n", + "auc = roc_auc_score(np.isin(y, list(THREAT)), p_threat)\n", + "print(f\"위협 판별 ROC-AUC {auc:.3f}\")\n", + "\n", + "print(\"\\n[임계값 민감도: Medium 기준값별]\")\n", + "for m in [0.3, 0.4, 0.5, 0.6, 0.7]:\n", + " r = report(f\"Medium>={m}\", is_thr_pred & (score >= m), y)\n", + " print(f\"기준 {m}: 오경보율 {r['오경보율']:.3f}, 위협 재현율 {r['위협 재현율']:.3f}\")\n", + "\n", + "print(\"\\n[등급 분포: 실제 클래스별]\")\n", + "print(pd.crosstab(pd.Series(y, name=\"실제\"), pd.Series(grade, name=\"등급\")))\n", + "pair = (y == \"civil_fw\") | (y == \"attack_fw\") | (y == \"civil_quad\")\n", + "print(f\"민간 드론 vs 자폭 드론 쌍 정확도 {(pred[pair] == y[pair]).mean():.1%}, 평균 신뢰도 {score[pair].mean():.2f}\")\n", + "print(\"등급은 교육용 판단 보조이며, Low 등급은 정답을 단정하지 않고 교관 확인으로 넘긴다.\")\n", + "\n", + "fig, ax = plt.subplots(1, 2, figsize=(11, 4))\n", + "fpr, tpr, _ = roc_curve(np.isin(y, list(THREAT)), p_threat)\n", + "ax[0].plot(fpr, tpr); ax[0].plot([0, 1], [0, 1], \"k--\"); ax[0].set_title(f\"Threat ROC (AUC={auc:.3f})\")\n", + "ax[0].set_xlabel(\"false positive rate\"); ax[0].set_ylabel(\"true positive rate\")\n", + "for g, col in [(\"High\", \"tab:green\"), (\"Medium\", \"tab:orange\"), (\"Low\", \"tab:red\")]:\n", + " ax[1].hist(score[grade == g], bins=15, range=(0, 1), color=col, alpha=0.7, label=g)\n", + "ax[1].set_title(\"Confidence score (uncertainty view)\"); ax[1].set_xlabel(\"score\"); ax[1].legend()\n", + "plt.tight_layout(); plt.show()\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 05 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + " 방식 위협 경보 오경보 오경보율 위협 재현율\n", + " 기존(최고 확률 클래스로 단정) 334 29 0.087 0.924\n", + "신뢰도 엔진(Low는 교관 확인) 281 5 0.018 0.836\n", + "\n", + "오경보율 감축 79.3% (목표 40% 이상: 달성), 교관 확인으로 넘긴 위협 판정 53건\n", + "신뢰도 점수 산출 일관성 97.3% (목표 90% 이상: 달성)\n", + "위협 판별 ROC-AUC 0.983\n", + "\n", + "[임계값 민감도: Medium 기준값별]\n", + "기준 0.3: 오경보율 0.054, 위협 재현율 0.894\n", + "기준 0.4: 오경보율 0.024, 위협 재현율 0.867\n", + "기준 0.5: 오경보율 0.018, 위협 재현율 0.836\n", + "기준 0.6: 오경보율 0.011, 위협 재현율 0.809\n", + "기준 0.7: 오경보율 0.012, 위협 재현율 0.779\n", + "\n", + "[등급 분포: 실제 클래스별]\n", + "등급 High Low Medium\n", + "실제 \n", + "attack_fw 99 5 6\n", + "ballistic 72 18 20\n", + "bird 48 29 33\n", + "civil_fw 73 18 19\n", + "civil_quad 90 13 7\n", + "cruise 66 27 17\n", + "민간 드론 vs 자폭 드론 쌍 정확도 91.2%, 평균 신뢰도 0.86\n", + "등급은 교육용 판단 보조이며, Low 등급은 정답을 단정하지 않고 교관 확인으로 넘긴다.\n", + "```\n", + "
\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 06. 장병 대상 모바일/웹 대화형 실루엣 식별 학습 앱 UI 프로토타입 구현\n", + "**소요시간**: 약 2.5시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "장병 대상 모바일/웹 대화형 실루엣 식별 학습 앱 UI 프로토타입을 구현한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **장병이 실루엣을 보고 비행체를 고르는 모바일·웹 대화형 식별 학습 앱 UI 프로토타입을 구현한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **문항 출제(정답과 혼동하기 쉬운 보기 포함), 응답 채점, 즉시 피드백, 응답 후 AI 참고 판정 표시, 진행률·점수 표시 기능을 구현하고 가상 학습자 응답으로 12문항을 모의 실행 수행하고 검증하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `src/ui/trn03_dashboard.py (또는 웹 UI 컴포넌트)` 형태의 결과물을 완성한다.\n", + "\n", + "### 🔍 평가 및 검증 기준 (PASS 조건)\n", + "#### 미션 판정\n", + "\n", + "**평가 구분:** 필수\n", + "\n", + "**핵심 평가영역:** 전술 작전 상황판으로서의 직관성 및 인지 부하 감소도\n", + "\n", + "#### 미션 PASS 조건\n", + "\n", + "- 정량 기준: 화면 갱신 주기 1초 이내, UI 응답성 100ms 이내\n", + "\n", + "\n", + "- 진행 기준: 주요 지표 시각화 컴포넌트 4종 이상 렌더링 및 실시간 데이터 연동\n", + "\n", + "\n", + "- 검증 방법: 사용자 인터랙션 테스트 및 모의 데이터 스트림 렌더링 검증\n", + "\n", + "\n", + "#### 채점자 판정 메모\n", + "\n", + "> 단순 코드 실행 여부뿐만 아니라 출력된 수치와 근거 자료가 군 실무 요구사항에 부합하는지 면밀히 확인한다.\n", + "\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "\"\"\"장병이 실루엣을 보고 비행체를 고르는 대화형 식별 학습 앱 UI 프로토타입을 구현한다. 문제 출제·응답 처리·즉시 피드백·AI 참고 판정·진행률 표시를 모의 실행해 화면 갱신 주기와 응답 처리시간을 측정하고, 야간 모드 학습 화면을 그린다.\"\"\"\n", + "import time\n", + "import numpy as np\n", + "import pandas as pd\n", + "import matplotlib.pyplot as plt\n", + "from matplotlib.path import Path as MplPath\n", + "from scipy import ndimage\n", + "from scipy.spatial import ConvexHull\n", + "from sklearn.neural_network import MLPClassifier\n", + "from sklearn.preprocessing import StandardScaler\n", + "\n", + "# ---------------------------------------------------------------------------\n", + "# [교육용가상자료] 합성 실루엣 생성기 (실제 기체 사진·도면·제원 사용 안 함)\n", + "# 사각형·삼각형·원 같은 기본 도형을 조합한 추상 실루엣을 32x32 흑백 이미지로 그린다.\n", + "# 회전(다각도), 원근 축소(측면 시점), 크기(원거리), 흐림·잡음·저대비·구름·가림·야간 반전을 조절한다.\n", + "# ---------------------------------------------------------------------------\n", + "IMG = 32\n", + "CLASSES = [\"civil_quad\", \"civil_fw\", \"attack_fw\", \"cruise\", \"ballistic\", \"bird\"]\n", + "KOR = {\"civil_quad\": \"민간 쿼드콥터형\", \"civil_fw\": \"민간 고정익형\", \"attack_fw\": \"삼각익 자폭드론형\",\n", + " \"cruise\": \"순항미사일형\", \"ballistic\": \"탄도미사일형\", \"bird\": \"조류\"}\n", + "THREAT = {\"attack_fw\", \"cruise\", \"ballistic\"}\n", + "\n", + "\n", + "def _rect(x0, x1, y0, y1):\n", + " return [(x0, y0), (x1, y0), (x1, y1), (x0, y1)]\n", + "\n", + "\n", + "def _bar(p, q, w):\n", + " (x0, y0), (x1, y1) = p, q\n", + " n = np.array([y0 - y1, x1 - x0]) / np.hypot(x1 - x0, y1 - y0) * w / 2\n", + " return [(x0 + n[0], y0 + n[1]), (x1 + n[0], y1 + n[1]), (x1 - n[0], y1 - n[1]), (x0 - n[0], y0 - n[1])]\n", + "\n", + "\n", + "# 도형 정의(단위 좌표, 기수 방향 = +y). 모두 추상 도형이며 특정 기종을 본뜨지 않는다.\n", + "SHAPES = {\n", + " \"civil_quad\": {\"poly\": [_bar((-.55, -.55), (.55, .55), .1), _bar((-.55, .55), (.55, -.55), .1), _rect(-.15, .15, -.15, .15)],\n", + " \"circ\": [(-.55, -.55, .24), (.55, .55, .24), (-.55, .55, .24), (.55, -.55, .24)]},\n", + " \"civil_fw\": {\"poly\": [_rect(-.07, .07, -.8, .72), _rect(-.9, .9, .05, .25), _rect(-.33, .33, -.8, -.66)], \"circ\": []},\n", + " \"attack_fw\": {\"poly\": [[(0, .85), (.85, -.5), (.12, -.42), (0, -.6), (-.12, -.42), (-.85, -.5)]], \"circ\": []},\n", + " \"cruise\": {\"poly\": [_rect(-.07, .07, -.9, .7), [(-.07, .7), (0, .92), (.07, .7)], _rect(-.42, .42, -.05, .07),\n", + " [(-.07, -.9), (-.24, -.95), (-.07, -.74)], [(.07, -.9), (.24, -.95), (.07, -.74)]], \"circ\": []},\n", + " \"ballistic\": {\"poly\": [_rect(-.13, .13, -.85, .6), [(-.13, .6), (0, .95), (.13, .6)],\n", + " [(-.13, -.85), (-.34, -.95), (-.13, -.58)], [(.13, -.85), (.34, -.95), (.13, -.58)]], \"circ\": []},\n", + " \"bird\": {\"poly\": [[(-.9, .12), (-.5, .28), (-.12, .06), (0, .22), (.12, .06), (.5, .28), (.9, .12), (.5, .12),\n", + " (.12, -.1), (0, -.38), (-.12, -.1), (-.5, .12)]], \"circ\": []},\n", + "}\n", + "_S = 2\n", + "_g = (np.arange(IMG * _S) + 0.5) / _S\n", + "_GX, _GY = np.meshgrid(_g, _g)\n", + "_PTS = np.c_[_GX.ravel(), _GY.ravel()]\n", + "_PATHS = {c: [MplPath(np.array(p)) for p in s[\"poly\"]] for c, s in SHAPES.items()}\n", + "\n", + "\n", + "def draw(cls, rng, angle=None, scale=None, fore=None, blur=0.3, noise=4.0, contrast=1.0,\n", + " clutter=0.0, occlude=0.0, invert=False, shift=1.5):\n", + " \"\"\"클래스 1개 실루엣 이미지(uint8 32x32)를 그린다. 배경은 밝은 하늘, 비행체는 어두운 그림자.\"\"\"\n", + " angle = rng.uniform(0, 360) if angle is None else angle\n", + " scale = rng.uniform(.7, .95) if scale is None else scale\n", + " fore = rng.uniform(.85, 1.0) if fore is None else fore\n", + " th = np.radians(angle)\n", + " c0 = IMG / 2 + rng.uniform(-shift, shift, 2)\n", + " d = (_PTS - c0) / (scale * IMG / 2) # 이미지 좌표 -> 도형 좌표(역변환)\n", + " u = d[:, 0] * np.cos(th) + d[:, 1] * np.sin(th)\n", + " v = (-d[:, 0] * np.sin(th) + d[:, 1] * np.cos(th)) / fore # 원근 축소(기수 방향으로 짧아 보임)\n", + " uv = np.c_[u, -v]\n", + " m = np.zeros(len(uv), bool)\n", + " near = np.flatnonzero(np.abs(uv).max(axis=1) <= 1.0) # 도형이 있을 수 있는 영역만 검사(속도)\n", + " for p in _PATHS[cls]:\n", + " m[near] |= p.contains_points(uv[near])\n", + " for cx, cy, r in SHAPES[cls][\"circ\"]:\n", + " m |= (uv[:, 0] - cx) ** 2 + (uv[:, 1] - cy) ** 2 <= r * r\n", + " cov = m.reshape(IMG, _S, IMG, _S).mean(axis=(1, 3))\n", + " if occlude > 0: # 부분 가림(구름 조각)\n", + " h = int(IMG * np.sqrt(occlude)); y0, x0 = rng.integers(8, IMG - 8 - h // 2, 2)\n", + " cov[y0:y0 + h, x0:x0 + h] = 0\n", + " bg = rng.uniform(180, 220) + np.linspace(-10, 10, IMG)[:, None]\n", + " fg = rng.uniform(30, 60)\n", + " img = bg * (1 - cov) + fg * cov\n", + " if clutter > 0: # 구름·배경 얼룩\n", + " img -= clutter * 90 * np.clip(ndimage.gaussian_filter(rng.normal(0, 1, (IMG, IMG)), 3) * 4, 0, None)\n", + " if blur > 0:\n", + " img = ndimage.gaussian_filter(img, blur)\n", + " mean = img.mean()\n", + " img = mean + (img - mean) * contrast\n", + " img = img + rng.normal(0, noise, img.shape)\n", + " if invert: # 야간 열상(IR) 극성 반전\n", + " img = 255 - img\n", + " return np.clip(img, 0, 255).astype(np.uint8)\n", + "\n", + "\n", + "# 촬영 조건 프리셋: 기본 + 복합 위협 시나리오\n", + "CONDITIONS = {\n", + " \"basic\": {},\n", + " \"offcenter\": {\"shift\": 5.0, \"fore\": (.6, .85)},\n", + " \"far\": {\"scale\": (.32, .45), \"blur\": .6},\n", + " \"blur\": {\"blur\": (1.2, 1.8)},\n", + " \"lowcon\": {\"contrast\": (.2, .35)},\n", + " \"clutter\": {\"clutter\": .8},\n", + " \"occlude\": {\"occlude\": .08},\n", + " \"oblique\": {\"fore\": (.35, .6)},\n", + " \"night\": {\"invert\": True, \"noise\": 10.0},\n", + " \"noise\": {\"noise\": 28.0},\n", + " \"combo\": {\"scale\": (.4, .55), \"blur\": (.8, 1.1), \"noise\": 12.0, \"contrast\": (.6, .8)},\n", + "}\n", + "\n", + "\n", + "def draw_cond(cls, cond, rng):\n", + " kw = {}\n", + " for k, v in CONDITIONS[cond].items():\n", + " kw[k] = rng.uniform(*v) if isinstance(v, tuple) else v\n", + " return draw(cls, rng, **kw)\n", + "\n", + "\n", + "def make_dataset(n_per_class, seed, conds=(\"basic\",)):\n", + " \"\"\"(이미지 배열 N x 32 x 32, 정답 라벨, 조건) 반환. 조건은 conds에서 번갈아 고른다.\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " X, y, c = [], [], []\n", + " for cls in CLASSES:\n", + " for i in range(n_per_class):\n", + " cond = conds[i % len(conds)]\n", + " X.append(draw_cond(cls, cond, rng)); y.append(cls); c.append(cond)\n", + " return np.array(X), np.array(y), np.array(c)\n", + "# ---------------------------------------------------------------------------\n", + "\n", + "# ---- 전처리(배치 벡터화)·형태 특징 공통 함수 ----\n", + "OUT = 24 # 크기 정규화 결과(24x24)\n", + "NOISE_T = 12.0 # 잡음 추정값이 이보다 크면 3x3 중간값 필터 적용(악천후·센서 잡음)\n", + "FEATS = [\"elong\", \"fill\", \"solidity\", \"n_parts\", \"end_gap\", \"mid_bulge\", \"w_max\", \"compact\"]\n", + "\n", + "\n", + "def preprocess(images, noise_t=NOISE_T):\n", + " \"\"\"N장 일괄 처리: 결측 보정 -> 극성(야간 반전) 보정 -> 조건별 잡음 제거 -> 명암 대비 강화 -> 분할 -> 윤곽선 -> 방향·크기 정규화.\"\"\"\n", + " f = np.array(images, dtype=float).reshape(-1, IMG, IMG)\n", + " n = len(f)\n", + " bad = ~np.isfinite(f)\n", + " missing = bad.sum(axis=(1, 2))\n", + " if bad.any(): # 결측 픽셀: 영상 중앙값으로 채운 뒤 주변 3x3 중간값으로 보정\n", + " med = np.array([np.median(x[np.isfinite(x)]) if np.isfinite(x).any() else 128.0 for x in f])\n", + " f = np.where(bad, med[:, None, None], f)\n", + " f = np.where(bad, ndimage.median_filter(f, size=(1, 3, 3)), f)\n", + " border = np.concatenate([f[:, 0], f[:, -1], f[:, :, 0], f[:, :, -1]], axis=1)\n", + " inverted = np.median(border, axis=1) < 128 # 배경이 어두우면 야간 열상으로 보고 반전\n", + " f[inverted] = 255 - f[inverted]\n", + " med3 = ndimage.median_filter(f, size=(1, 3, 3))\n", + " noise = 1.4826 * np.median(np.abs(f - med3).reshape(n, -1), axis=1)\n", + " noisy = noise > noise_t\n", + " f[noisy] = med3[noisy]\n", + " lo, hi = np.percentile(f.reshape(n, -1), [1, 99], axis=1)\n", + " e = np.clip((f - lo[:, None, None]) / np.maximum(hi - lo, 1e-6)[:, None, None], 0, 1) # 명암 대비 강화\n", + " mask = e < _otsu(e)[:, None, None]\n", + " mask &= ndimage.uniform_filter(mask.astype(float), size=(1, 3, 3)) * 9 >= 2.5 # 고립 점 잡음 제거\n", + " edge = np.hypot(ndimage.sobel(e, axis=1), ndimage.sobel(e, axis=2)) # 윤곽선(Sobel)\n", + " ok = (mask.sum(axis=(1, 2)) >= 4) & (hi - lo > 8)\n", + " return {\"enh\": e, \"mask\": mask, \"edge\": edge / np.maximum(edge.max(axis=(1, 2)), 1e-6)[:, None, None],\n", + " \"norm\": _normalize(1 - e, mask, ok), \"inverted\": inverted, \"noise\": noise, \"denoised\": noisy,\n", + " \"missing\": missing, \"contrast\": hi - lo, \"ok\": ok}\n", + "\n", + "\n", + "def _otsu(e, bins=64):\n", + " n = len(e)\n", + " idx = np.minimum((e * bins).astype(int), bins - 1) + bins * np.arange(n)[:, None, None]\n", + " hist = np.bincount(idx.ravel(), minlength=bins * n).reshape(n, bins).astype(float)\n", + " centers = (np.arange(bins) + 0.5) / bins\n", + " w = np.cumsum(hist, axis=1); m = np.cumsum(hist * centers, axis=1)\n", + " tot, mt = w[:, -1:], m[:, -1:]\n", + " between = (mt * w / tot - m) ** 2 / np.maximum(w * (tot - w), 1)\n", + " return (np.argmax(between, axis=1) + 1) / bins\n", + "\n", + "\n", + "def _normalize(obj, mask, ok):\n", + " \"\"\"주축 방향을 세로로 돌리고 비행체 영역만 24x24로 다시 샘플링한다(회전·크기·위치 불변). 한 번의 보간으로 일괄 처리.\"\"\"\n", + " n = len(obj)\n", + " w = mask.astype(float)\n", + " m00 = np.maximum(w.sum(axis=(1, 2)), 1)\n", + " yy, xx = np.mgrid[0:IMG, 0:IMG].astype(float)\n", + " cy, cx = (w * yy).sum(axis=(1, 2)) / m00, (w * xx).sum(axis=(1, 2)) / m00\n", + " dy, dx = yy - cy[:, None, None], xx - cx[:, None, None]\n", + " mu20, mu02, mu11 = (w * dy * dy).sum(axis=(1, 2)), (w * dx * dx).sum(axis=(1, 2)), (w * dy * dx).sum(axis=(1, 2))\n", + " th = 0.5 * np.arctan2(2 * mu11, mu20 - mu02)\n", + " a = np.stack([np.cos(th), np.sin(th)], 1) # 주축(y, x)\n", + " b = np.stack([-np.sin(th), np.cos(th)], 1) # 부축\n", + " pa = np.where(mask, dy * a[:, 0, None, None] + dx * a[:, 1, None, None], 0)\n", + " pb = np.where(mask, dy * b[:, 0, None, None] + dx * b[:, 1, None, None], 0)\n", + " half = np.maximum(np.abs(pa).max(axis=(1, 2)), np.abs(pb).max(axis=(1, 2))) + 1.0\n", + " g = np.linspace(-1, 1, OUT)\n", + " U, V = np.meshgrid(g, g, indexing=\"ij\")\n", + " Y = cy[:, None, None] + half[:, None, None] * (U * a[:, 0, None, None] + V * b[:, 0, None, None])\n", + " X = cx[:, None, None] + half[:, None, None] * (U * a[:, 1, None, None] + V * b[:, 1, None, None])\n", + " N = np.broadcast_to(np.arange(n)[:, None, None], Y.shape)\n", + " out = ndimage.map_coordinates(obj * mask, [N, Y, X], order=1, cval=0.0)\n", + " skew = (out * (U ** 3)).sum(axis=(1, 2))\n", + " out[skew < 0] = out[skew < 0, ::-1] # 무게가 쏠린 쪽을 위로(180도 모호성 제거)\n", + " out[~ok] = 0\n", + " return np.clip(out, 0, 1)\n", + "\n", + "\n", + "def shape_features(mask):\n", + " \"\"\"분할 마스크 1장에서 회전에 무관한 형태 특징 8종을 계산한다.\"\"\"\n", + " if mask.sum() < 4:\n", + " return dict.fromkeys(FEATS, 0.0)\n", + " yx = np.argwhere(mask).astype(float)\n", + " c = yx - yx.mean(axis=0)\n", + " val, vec = np.linalg.eigh(np.cov(c.T) + 1e-6 * np.eye(2))\n", + " t, s = c @ vec[:, 1], c @ vec[:, 0]\n", + " length, width = np.ptp(t) + 1, np.ptp(s) + 1\n", + " corners = np.vstack([yx + d for d in ([.5, .5], [.5, -.5], [-.5, .5], [-.5, -.5])])\n", + " try:\n", + " hull = ConvexHull(corners).volume\n", + " except Exception:\n", + " hull = float(mask.sum())\n", + " bins = np.digitize(t, np.linspace(t.min(), t.max() + 1e-6, 4)[1:-1])\n", + " w = np.array([np.ptp(s[bins == k]) + 1 if (bins == k).any() else 0 for k in range(3)]) / length\n", + " boundary = mask & ~ndimage.binary_erosion(mask)\n", + " return {\"elong\": float(np.sqrt(val[1] / max(val[0], 1e-6))), \"fill\": float(mask.sum() / (length * width)),\n", + " \"solidity\": float(mask.sum() / max(hull, 1)), \"n_parts\": float(ndimage.label(mask)[1]),\n", + " \"end_gap\": float(abs(w[0] - w[2])), \"mid_bulge\": float(w[1] - max(w[0], w[2])),\n", + " \"w_max\": float(w.max()), \"compact\": float(boundary.sum() / np.sqrt(mask.sum()))}\n", + "\n", + "\n", + "def feature_matrix(images):\n", + " \"\"\"전처리 결과, 형태 특징 행렬(N x 8), 정규화 이미지 행렬(N x 576)을 돌려준다.\"\"\"\n", + " P = preprocess(images)\n", + " F = np.array([[d[k] for k in FEATS] for d in map(shape_features, P[\"mask\"])])\n", + " return P, F, P[\"norm\"].reshape(len(F), -1)\n", + "\n", + "# ---- 분류 모델 공통 함수(다층 퍼셉트론: 정규화 이미지 576픽셀 + 형태 특징 8종) ----\n", + "def to_input(F, N, scaler):\n", + " return np.c_[N, scaler.transform(F) * 0.3]\n", + "\n", + "\n", + "def train_model(n_per_class=150, seed=5, conds=tuple(CONDITIONS), hidden=(64,)):\n", + " X, y, _ = make_dataset(n_per_class, seed, conds)\n", + " P, F, N = feature_matrix(X)\n", + " scaler = StandardScaler().fit(F)\n", + " model = MLPClassifier(hidden, max_iter=200, early_stopping=True, random_state=0)\n", + " model.fit(to_input(F, N, scaler), [CLASSES.index(v) for v in y])\n", + " return model, scaler\n", + "\n", + "\n", + "def predict(model, scaler, images):\n", + " \"\"\"(전처리 결과, 클래스별 확률 N x 6)을 돌려준다.\"\"\"\n", + " P, F, N = feature_matrix(images)\n", + " return P, model.predict_proba(to_input(F, N, scaler))\n", + "\n", + "N_OPTIONS = 4\n", + "CONFUSE = {\"civil_fw\": \"attack_fw\", \"attack_fw\": \"civil_fw\", \"cruise\": \"ballistic\", \"ballistic\": \"cruise\", \"bird\": \"civil_fw\", \"civil_quad\": \"civil_fw\"}\n", + "\n", + "\n", + "class QuizApp:\n", + " \"\"\"학습 앱 상태: 문제 은행, 현재 문제, 점수, 응답 이력. 화면 1장 = render() 결과 문자열.\"\"\"\n", + "\n", + " def __init__(self, model, scaler, rng, n_questions=12):\n", + " self.model, self.scaler, self.rng = model, scaler, rng\n", + " conds = [\"basic\", \"far\", \"oblique\", \"night\", \"blur\", \"clutter\"]\n", + " self.bank = [(c, conds[i % len(conds)]) for i, c in enumerate(rng.permutation(CLASSES * 2))][:n_questions]\n", + " self.i, self.history = 0, []\n", + " self.next_question()\n", + "\n", + " def next_question(self):\n", + " cls, cond = self.bank[self.i]\n", + " self.img, self.answer, self.cond = draw_cond(cls, cond, self.rng), cls, cond\n", + " others = [c for c in CLASSES if c not in (cls, CONFUSE[cls])]\n", + " self.options = list(self.rng.permutation([cls, CONFUSE[cls]] + list(self.rng.choice(others, N_OPTIONS - 2, replace=False))))\n", + " self.feedback = None\n", + "\n", + " def submit(self, choice_no):\n", + " \"\"\"응답 처리: 채점 -> AI 참고 판정(신뢰도) -> 피드백 문구. 처리시간(ms)을 함께 기록한다.\"\"\"\n", + " t0 = time.perf_counter()\n", + " choice = self.options[choice_no - 1]\n", + " _, prob = predict(self.model, self.scaler, [self.img])\n", + " ai, conf = CLASSES[int(prob.argmax())], float(prob.max())\n", + " ok = choice == self.answer\n", + " self.feedback = (\"정답\" if ok else f\"오답 - 정답은 {KOR[self.answer]}\") + f\" | AI 참고 판정: {KOR[ai]}({conf:.0%})\"\n", + " ms = (time.perf_counter() - t0) * 1000\n", + " self.history.append({\"문항\": self.i + 1, \"정답\": self.answer, \"선택\": choice, \"결과\": ok, \"조건\": self.cond, \"처리ms\": ms})\n", + " return ms\n", + "\n", + " def render(self):\n", + " done = len(self.history)\n", + " bar = \"#\" * done + \".\" * (len(self.bank) - done)\n", + " score = sum(h[\"결과\"] for h in self.history)\n", + " opts = \" \".join(f\"{k + 1}) {KOR[o]}\" for k, o in enumerate(self.options))\n", + " return f\"[문항 {self.i + 1}/{len(self.bank)}] 진행 {bar} 점수 {score}/{done}\\n 보기: {opts}\\n 피드백: {self.feedback or '-'}\"\n", + "\n", + "\n", + "model, scaler = train_model(120, seed=5)\n", + "rng = np.random.default_rng(8)\n", + "app = QuizApp(model, scaler, rng)\n", + "skill = {\"civil_quad\": .9, \"civil_fw\": .65, \"attack_fw\": .7, \"cruise\": .55, \"ballistic\": .6, \"bird\": .85} # 가상 학습자 숙련도\n", + "\n", + "refresh = []\n", + "for q in range(len(app.bank)):\n", + " t_screen = time.perf_counter()\n", + " correct = rng.random() < skill[app.answer]\n", + " pick = app.options.index(app.answer if correct else CONFUSE[app.answer]) + 1\n", + " app.submit(pick)\n", + " if q < 2:\n", + " print(app.render() + \"\\n\")\n", + " refresh.append((time.perf_counter() - t_screen) * 1000)\n", + " if q < len(app.bank) - 1:\n", + " app.i += 1; app.next_question()\n", + "\n", + "h = pd.DataFrame(app.history)\n", + "print(f\"화면 갱신 처리 최대 {max(refresh):.1f} ms (목표: 갱신 주기 1초 이내), 응답 처리 평균 {h['처리ms'].mean():.1f} ms / 최대 {h['처리ms'].max():.1f} ms (목표 100 ms 이내: {'달성' if h['처리ms'].max() <= 100 else '미달'})\")\n", + "print(f\"최종 점수 {h['결과'].sum()}/{len(h)}\")\n", + "print(\"\\n[클래스별 정답률]\")\n", + "print(h.groupby(\"정답\")[\"결과\"].mean().rename(index=KOR).round(2).to_string())\n", + "print(\"\\n[오답 목록]\")\n", + "print(h[~h[\"결과\"]][[\"문항\", \"정답\", \"선택\", \"조건\"]].to_string(index=False))\n", + "\n", + "# 학습 화면(야간 모드): 문제 이미지, 보기·피드백, 진행률, 클래스별 정답률, 응답시간 추이 - 시각화 구성요소 5종\n", + "plt.style.use(\"dark_background\")\n", + "fig = plt.figure(figsize=(12, 7))\n", + "ax1 = fig.add_subplot(2, 3, 1); ax1.imshow(app.img, cmap=\"gray\"); ax1.axis(\"off\"); ax1.set_title(f\"Q{app.i + 1}: identify the silhouette\")\n", + "ax2 = fig.add_subplot(2, 3, 2); ax2.axis(\"off\"); ax2.set_title(\"Options / feedback\")\n", + "last = app.history[-1]\n", + "for k, o in enumerate(app.options):\n", + " col = \"lime\" if o == app.answer else (\"red\" if o == last[\"선택\"] else \"white\")\n", + " ax2.text(0.05, 0.85 - k * 0.2, f\"{k + 1}) {o}\", color=col, fontsize=13, fontweight=\"bold\" if col != \"white\" else \"normal\")\n", + "ax3 = fig.add_subplot(2, 3, 3); s = h[\"결과\"].sum()\n", + "ax3.barh([0], [len(h)], color=\"dimgray\"); ax3.barh([0], [s], color=\"deepskyblue\"); ax3.set_yticks([])\n", + "ax3.set_title(f\"Progress / score {s}/{len(h)}\")\n", + "ax4 = fig.add_subplot(2, 3, 4); acc = h.groupby(\"정답\")[\"결과\"].mean()\n", + "ax4.bar(acc.index, acc.values, color=[\"red\" if v < 0.6 else \"deepskyblue\" for v in acc.values]); ax4.tick_params(axis=\"x\", rotation=40)\n", + "ax4.set_title(\"Accuracy by class (red = weak)\")\n", + "ax5 = fig.add_subplot(2, 3, 5); ax5.plot(h[\"문항\"], h[\"처리ms\"], \"o-\", color=\"orange\"); ax5.axhline(100, ls=\"--\", c=\"gray\")\n", + "ax5.set_title(\"Response time (ms)\"); ax5.set_xlabel(\"question\")\n", + "ax6 = fig.add_subplot(2, 3, 6); ax6.axis(\"off\")\n", + "ax6.text(0, 0.8, \"Night mode UI\\nCorrect: green / Wrong: red\\nAI hint shown only after answer\", fontsize=11, va=\"top\")\n", + "plt.suptitle(\"Silhouette ID training app prototype - training data only\"); plt.tight_layout(); plt.show()\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 06 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + "[문항 1/12] 진행 #........... 점수 1/1\n", + " 보기: 1) 조류 2) 민간 고정익형 3) 민간 쿼드콥터형 4) 삼각익 자폭드론형\n", + " 피드백: 정답 | AI 참고 판정: 민간 고정익형(96%)\n", + "\n", + "[문항 2/12] 진행 ##.......... 점수 1/2\n", + " 보기: 1) 탄도미사일형 2) 삼각익 자폭드론형 3) 순항미사일형 4) 민간 고정익형\n", + " 피드백: 오답 - 정답은 순항미사일형 | AI 참고 판정: 순항미사일형(83%)\n", + "\n", + "화면 갱신 처리 최대 8.5 ms (목표: 갱신 주기 1초 이내), 응답 처리 평균 4.4 ms / 최대 6.6 ms (목표 100 ms 이내: 달성)\n", + "최종 점수 9/12\n", + "\n", + "[클래스별 정답률]\n", + "정답\n", + "삼각익 자폭드론형 1.0\n", + "탄도미사일형 0.5\n", + "조류 1.0\n", + "민간 고정익형 1.0\n", + "민간 쿼드콥터형 1.0\n", + "순항미사일형 0.0\n", + "\n", + "[오답 목록]\n", + " 문항 정답 선택 조건\n", + " 2 cruise ballistic far\n", + " 6 ballistic cruise clutter\n", + " 10 cruise ballistic night\n", + "```\n", + "
\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 07. 학습자 오답 시 정답 실루엣 비교 및 핵심 특징(날개 형상 등) 교관 HITL 해설 모듈 설계\n", + "**소요시간**: 약 3.0시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "학습자 오답 시 정답 실루엣 비교 및 핵심 특징 해설 HITL 모듈을 설계한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **학습자가 오답을 냈을 때 정답 실루엣과 선택한 실루엣을 비교해 핵심 특징을 해설하되, 교관 검토를 거쳐야만 공개되는 HITL 해설 모듈을 설계한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **오답을 혼동 쌍 단위로 묶어 AI 해설 초안(날개 형상 등 특징 차이)을 만들고 검토 대기열에 등록 수행하고 검증하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `src/hitl/trn03_approval_workflow.py 및 docs/trn03_hitl_guide.md` 형태의 결과물을 완성한다.\n", + "\n", + "### 🔍 평가 및 검증 기준 (PASS 조건)\n", + "#### 미션 판정\n", + "\n", + "**평가 구분:** 필수\n", + "\n", + "**핵심 평가영역:** 지휘 책임 분계선 명확화 및 성급한 자동 단정 방지 체계 부합성\n", + "\n", + "#### 미션 PASS 조건\n", + "\n", + "- 정량 기준: 인간 검토 큐(Queue) 대기시간 및 승인/반려 처리 로깅 성공률 100%\n", + "\n", + "\n", + "- 진행 기준: 승인/반려 인터페이스 동작 및 감사 로그(Audit Log) 생성 검증\n", + "\n", + "\n", + "- 검증 방법: 모의 조작자 승인/반려 시뮬레이션 및 데이터 무결성 검증\n", + "\n", + "\n", + "#### 채점자 판정 메모\n", + "\n", + "> 단순 코드 실행 여부뿐만 아니라 출력된 수치와 근거 자료가 군 실무 요구사항에 부합하는지 면밀히 확인한다.\n", + "\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "\"\"\"학습자가 오답을 내면 정답 실루엣과 선택한 실루엣을 비교해 핵심 특징(날개 형상 등) 해설 초안을 만들고, 교관이 승인·수정·반려해야만 학습자에게 공개되는 인간 검토(HITL) 해설 모듈을 구현한다. 모든 처리는 위·변조 확인이 가능한 감사 로그에 남는다.\"\"\"\n", + "import hashlib\n", + "import json\n", + "import numpy as np\n", + "import pandas as pd\n", + "import matplotlib.pyplot as plt\n", + "from matplotlib.path import Path as MplPath\n", + "from scipy import ndimage\n", + "from scipy.spatial import ConvexHull\n", + "\n", + "# ---------------------------------------------------------------------------\n", + "# [교육용가상자료] 합성 실루엣 생성기 (실제 기체 사진·도면·제원 사용 안 함)\n", + "# 사각형·삼각형·원 같은 기본 도형을 조합한 추상 실루엣을 32x32 흑백 이미지로 그린다.\n", + "# 회전(다각도), 원근 축소(측면 시점), 크기(원거리), 흐림·잡음·저대비·구름·가림·야간 반전을 조절한다.\n", + "# ---------------------------------------------------------------------------\n", + "IMG = 32\n", + "CLASSES = [\"civil_quad\", \"civil_fw\", \"attack_fw\", \"cruise\", \"ballistic\", \"bird\"]\n", + "KOR = {\"civil_quad\": \"민간 쿼드콥터형\", \"civil_fw\": \"민간 고정익형\", \"attack_fw\": \"삼각익 자폭드론형\",\n", + " \"cruise\": \"순항미사일형\", \"ballistic\": \"탄도미사일형\", \"bird\": \"조류\"}\n", + "THREAT = {\"attack_fw\", \"cruise\", \"ballistic\"}\n", + "\n", + "\n", + "def _rect(x0, x1, y0, y1):\n", + " return [(x0, y0), (x1, y0), (x1, y1), (x0, y1)]\n", + "\n", + "\n", + "def _bar(p, q, w):\n", + " (x0, y0), (x1, y1) = p, q\n", + " n = np.array([y0 - y1, x1 - x0]) / np.hypot(x1 - x0, y1 - y0) * w / 2\n", + " return [(x0 + n[0], y0 + n[1]), (x1 + n[0], y1 + n[1]), (x1 - n[0], y1 - n[1]), (x0 - n[0], y0 - n[1])]\n", + "\n", + "\n", + "# 도형 정의(단위 좌표, 기수 방향 = +y). 모두 추상 도형이며 특정 기종을 본뜨지 않는다.\n", + "SHAPES = {\n", + " \"civil_quad\": {\"poly\": [_bar((-.55, -.55), (.55, .55), .1), _bar((-.55, .55), (.55, -.55), .1), _rect(-.15, .15, -.15, .15)],\n", + " \"circ\": [(-.55, -.55, .24), (.55, .55, .24), (-.55, .55, .24), (.55, -.55, .24)]},\n", + " \"civil_fw\": {\"poly\": [_rect(-.07, .07, -.8, .72), _rect(-.9, .9, .05, .25), _rect(-.33, .33, -.8, -.66)], \"circ\": []},\n", + " \"attack_fw\": {\"poly\": [[(0, .85), (.85, -.5), (.12, -.42), (0, -.6), (-.12, -.42), (-.85, -.5)]], \"circ\": []},\n", + " \"cruise\": {\"poly\": [_rect(-.07, .07, -.9, .7), [(-.07, .7), (0, .92), (.07, .7)], _rect(-.42, .42, -.05, .07),\n", + " [(-.07, -.9), (-.24, -.95), (-.07, -.74)], [(.07, -.9), (.24, -.95), (.07, -.74)]], \"circ\": []},\n", + " \"ballistic\": {\"poly\": [_rect(-.13, .13, -.85, .6), [(-.13, .6), (0, .95), (.13, .6)],\n", + " [(-.13, -.85), (-.34, -.95), (-.13, -.58)], [(.13, -.85), (.34, -.95), (.13, -.58)]], \"circ\": []},\n", + " \"bird\": {\"poly\": [[(-.9, .12), (-.5, .28), (-.12, .06), (0, .22), (.12, .06), (.5, .28), (.9, .12), (.5, .12),\n", + " (.12, -.1), (0, -.38), (-.12, -.1), (-.5, .12)]], \"circ\": []},\n", + "}\n", + "_S = 2\n", + "_g = (np.arange(IMG * _S) + 0.5) / _S\n", + "_GX, _GY = np.meshgrid(_g, _g)\n", + "_PTS = np.c_[_GX.ravel(), _GY.ravel()]\n", + "_PATHS = {c: [MplPath(np.array(p)) for p in s[\"poly\"]] for c, s in SHAPES.items()}\n", + "\n", + "\n", + "def draw(cls, rng, angle=None, scale=None, fore=None, blur=0.3, noise=4.0, contrast=1.0,\n", + " clutter=0.0, occlude=0.0, invert=False, shift=1.5):\n", + " \"\"\"클래스 1개 실루엣 이미지(uint8 32x32)를 그린다. 배경은 밝은 하늘, 비행체는 어두운 그림자.\"\"\"\n", + " angle = rng.uniform(0, 360) if angle is None else angle\n", + " scale = rng.uniform(.7, .95) if scale is None else scale\n", + " fore = rng.uniform(.85, 1.0) if fore is None else fore\n", + " th = np.radians(angle)\n", + " c0 = IMG / 2 + rng.uniform(-shift, shift, 2)\n", + " d = (_PTS - c0) / (scale * IMG / 2) # 이미지 좌표 -> 도형 좌표(역변환)\n", + " u = d[:, 0] * np.cos(th) + d[:, 1] * np.sin(th)\n", + " v = (-d[:, 0] * np.sin(th) + d[:, 1] * np.cos(th)) / fore # 원근 축소(기수 방향으로 짧아 보임)\n", + " uv = np.c_[u, -v]\n", + " m = np.zeros(len(uv), bool)\n", + " near = np.flatnonzero(np.abs(uv).max(axis=1) <= 1.0) # 도형이 있을 수 있는 영역만 검사(속도)\n", + " for p in _PATHS[cls]:\n", + " m[near] |= p.contains_points(uv[near])\n", + " for cx, cy, r in SHAPES[cls][\"circ\"]:\n", + " m |= (uv[:, 0] - cx) ** 2 + (uv[:, 1] - cy) ** 2 <= r * r\n", + " cov = m.reshape(IMG, _S, IMG, _S).mean(axis=(1, 3))\n", + " if occlude > 0: # 부분 가림(구름 조각)\n", + " h = int(IMG * np.sqrt(occlude)); y0, x0 = rng.integers(8, IMG - 8 - h // 2, 2)\n", + " cov[y0:y0 + h, x0:x0 + h] = 0\n", + " bg = rng.uniform(180, 220) + np.linspace(-10, 10, IMG)[:, None]\n", + " fg = rng.uniform(30, 60)\n", + " img = bg * (1 - cov) + fg * cov\n", + " if clutter > 0: # 구름·배경 얼룩\n", + " img -= clutter * 90 * np.clip(ndimage.gaussian_filter(rng.normal(0, 1, (IMG, IMG)), 3) * 4, 0, None)\n", + " if blur > 0:\n", + " img = ndimage.gaussian_filter(img, blur)\n", + " mean = img.mean()\n", + " img = mean + (img - mean) * contrast\n", + " img = img + rng.normal(0, noise, img.shape)\n", + " if invert: # 야간 열상(IR) 극성 반전\n", + " img = 255 - img\n", + " return np.clip(img, 0, 255).astype(np.uint8)\n", + "\n", + "\n", + "# 촬영 조건 프리셋: 기본 + 복합 위협 시나리오\n", + "CONDITIONS = {\n", + " \"basic\": {},\n", + " \"offcenter\": {\"shift\": 5.0, \"fore\": (.6, .85)},\n", + " \"far\": {\"scale\": (.32, .45), \"blur\": .6},\n", + " \"blur\": {\"blur\": (1.2, 1.8)},\n", + " \"lowcon\": {\"contrast\": (.2, .35)},\n", + " \"clutter\": {\"clutter\": .8},\n", + " \"occlude\": {\"occlude\": .08},\n", + " \"oblique\": {\"fore\": (.35, .6)},\n", + " \"night\": {\"invert\": True, \"noise\": 10.0},\n", + " \"noise\": {\"noise\": 28.0},\n", + " \"combo\": {\"scale\": (.4, .55), \"blur\": (.8, 1.1), \"noise\": 12.0, \"contrast\": (.6, .8)},\n", + "}\n", + "\n", + "\n", + "def draw_cond(cls, cond, rng):\n", + " kw = {}\n", + " for k, v in CONDITIONS[cond].items():\n", + " kw[k] = rng.uniform(*v) if isinstance(v, tuple) else v\n", + " return draw(cls, rng, **kw)\n", + "\n", + "\n", + "def make_dataset(n_per_class, seed, conds=(\"basic\",)):\n", + " \"\"\"(이미지 배열 N x 32 x 32, 정답 라벨, 조건) 반환. 조건은 conds에서 번갈아 고른다.\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " X, y, c = [], [], []\n", + " for cls in CLASSES:\n", + " for i in range(n_per_class):\n", + " cond = conds[i % len(conds)]\n", + " X.append(draw_cond(cls, cond, rng)); y.append(cls); c.append(cond)\n", + " return np.array(X), np.array(y), np.array(c)\n", + "# ---------------------------------------------------------------------------\n", + "\n", + "# ---- 전처리(배치 벡터화)·형태 특징 공통 함수 ----\n", + "OUT = 24 # 크기 정규화 결과(24x24)\n", + "NOISE_T = 12.0 # 잡음 추정값이 이보다 크면 3x3 중간값 필터 적용(악천후·센서 잡음)\n", + "FEATS = [\"elong\", \"fill\", \"solidity\", \"n_parts\", \"end_gap\", \"mid_bulge\", \"w_max\", \"compact\"]\n", + "\n", + "\n", + "def preprocess(images, noise_t=NOISE_T):\n", + " \"\"\"N장 일괄 처리: 결측 보정 -> 극성(야간 반전) 보정 -> 조건별 잡음 제거 -> 명암 대비 강화 -> 분할 -> 윤곽선 -> 방향·크기 정규화.\"\"\"\n", + " f = np.array(images, dtype=float).reshape(-1, IMG, IMG)\n", + " n = len(f)\n", + " bad = ~np.isfinite(f)\n", + " missing = bad.sum(axis=(1, 2))\n", + " if bad.any(): # 결측 픽셀: 영상 중앙값으로 채운 뒤 주변 3x3 중간값으로 보정\n", + " med = np.array([np.median(x[np.isfinite(x)]) if np.isfinite(x).any() else 128.0 for x in f])\n", + " f = np.where(bad, med[:, None, None], f)\n", + " f = np.where(bad, ndimage.median_filter(f, size=(1, 3, 3)), f)\n", + " border = np.concatenate([f[:, 0], f[:, -1], f[:, :, 0], f[:, :, -1]], axis=1)\n", + " inverted = np.median(border, axis=1) < 128 # 배경이 어두우면 야간 열상으로 보고 반전\n", + " f[inverted] = 255 - f[inverted]\n", + " med3 = ndimage.median_filter(f, size=(1, 3, 3))\n", + " noise = 1.4826 * np.median(np.abs(f - med3).reshape(n, -1), axis=1)\n", + " noisy = noise > noise_t\n", + " f[noisy] = med3[noisy]\n", + " lo, hi = np.percentile(f.reshape(n, -1), [1, 99], axis=1)\n", + " e = np.clip((f - lo[:, None, None]) / np.maximum(hi - lo, 1e-6)[:, None, None], 0, 1) # 명암 대비 강화\n", + " mask = e < _otsu(e)[:, None, None]\n", + " mask &= ndimage.uniform_filter(mask.astype(float), size=(1, 3, 3)) * 9 >= 2.5 # 고립 점 잡음 제거\n", + " edge = np.hypot(ndimage.sobel(e, axis=1), ndimage.sobel(e, axis=2)) # 윤곽선(Sobel)\n", + " ok = (mask.sum(axis=(1, 2)) >= 4) & (hi - lo > 8)\n", + " return {\"enh\": e, \"mask\": mask, \"edge\": edge / np.maximum(edge.max(axis=(1, 2)), 1e-6)[:, None, None],\n", + " \"norm\": _normalize(1 - e, mask, ok), \"inverted\": inverted, \"noise\": noise, \"denoised\": noisy,\n", + " \"missing\": missing, \"contrast\": hi - lo, \"ok\": ok}\n", + "\n", + "\n", + "def _otsu(e, bins=64):\n", + " n = len(e)\n", + " idx = np.minimum((e * bins).astype(int), bins - 1) + bins * np.arange(n)[:, None, None]\n", + " hist = np.bincount(idx.ravel(), minlength=bins * n).reshape(n, bins).astype(float)\n", + " centers = (np.arange(bins) + 0.5) / bins\n", + " w = np.cumsum(hist, axis=1); m = np.cumsum(hist * centers, axis=1)\n", + " tot, mt = w[:, -1:], m[:, -1:]\n", + " between = (mt * w / tot - m) ** 2 / np.maximum(w * (tot - w), 1)\n", + " return (np.argmax(between, axis=1) + 1) / bins\n", + "\n", + "\n", + "def _normalize(obj, mask, ok):\n", + " \"\"\"주축 방향을 세로로 돌리고 비행체 영역만 24x24로 다시 샘플링한다(회전·크기·위치 불변). 한 번의 보간으로 일괄 처리.\"\"\"\n", + " n = len(obj)\n", + " w = mask.astype(float)\n", + " m00 = np.maximum(w.sum(axis=(1, 2)), 1)\n", + " yy, xx = np.mgrid[0:IMG, 0:IMG].astype(float)\n", + " cy, cx = (w * yy).sum(axis=(1, 2)) / m00, (w * xx).sum(axis=(1, 2)) / m00\n", + " dy, dx = yy - cy[:, None, None], xx - cx[:, None, None]\n", + " mu20, mu02, mu11 = (w * dy * dy).sum(axis=(1, 2)), (w * dx * dx).sum(axis=(1, 2)), (w * dy * dx).sum(axis=(1, 2))\n", + " th = 0.5 * np.arctan2(2 * mu11, mu20 - mu02)\n", + " a = np.stack([np.cos(th), np.sin(th)], 1) # 주축(y, x)\n", + " b = np.stack([-np.sin(th), np.cos(th)], 1) # 부축\n", + " pa = np.where(mask, dy * a[:, 0, None, None] + dx * a[:, 1, None, None], 0)\n", + " pb = np.where(mask, dy * b[:, 0, None, None] + dx * b[:, 1, None, None], 0)\n", + " half = np.maximum(np.abs(pa).max(axis=(1, 2)), np.abs(pb).max(axis=(1, 2))) + 1.0\n", + " g = np.linspace(-1, 1, OUT)\n", + " U, V = np.meshgrid(g, g, indexing=\"ij\")\n", + " Y = cy[:, None, None] + half[:, None, None] * (U * a[:, 0, None, None] + V * b[:, 0, None, None])\n", + " X = cx[:, None, None] + half[:, None, None] * (U * a[:, 1, None, None] + V * b[:, 1, None, None])\n", + " N = np.broadcast_to(np.arange(n)[:, None, None], Y.shape)\n", + " out = ndimage.map_coordinates(obj * mask, [N, Y, X], order=1, cval=0.0)\n", + " skew = (out * (U ** 3)).sum(axis=(1, 2))\n", + " out[skew < 0] = out[skew < 0, ::-1] # 무게가 쏠린 쪽을 위로(180도 모호성 제거)\n", + " out[~ok] = 0\n", + " return np.clip(out, 0, 1)\n", + "\n", + "\n", + "def shape_features(mask):\n", + " \"\"\"분할 마스크 1장에서 회전에 무관한 형태 특징 8종을 계산한다.\"\"\"\n", + " if mask.sum() < 4:\n", + " return dict.fromkeys(FEATS, 0.0)\n", + " yx = np.argwhere(mask).astype(float)\n", + " c = yx - yx.mean(axis=0)\n", + " val, vec = np.linalg.eigh(np.cov(c.T) + 1e-6 * np.eye(2))\n", + " t, s = c @ vec[:, 1], c @ vec[:, 0]\n", + " length, width = np.ptp(t) + 1, np.ptp(s) + 1\n", + " corners = np.vstack([yx + d for d in ([.5, .5], [.5, -.5], [-.5, .5], [-.5, -.5])])\n", + " try:\n", + " hull = ConvexHull(corners).volume\n", + " except Exception:\n", + " hull = float(mask.sum())\n", + " bins = np.digitize(t, np.linspace(t.min(), t.max() + 1e-6, 4)[1:-1])\n", + " w = np.array([np.ptp(s[bins == k]) + 1 if (bins == k).any() else 0 for k in range(3)]) / length\n", + " boundary = mask & ~ndimage.binary_erosion(mask)\n", + " return {\"elong\": float(np.sqrt(val[1] / max(val[0], 1e-6))), \"fill\": float(mask.sum() / (length * width)),\n", + " \"solidity\": float(mask.sum() / max(hull, 1)), \"n_parts\": float(ndimage.label(mask)[1]),\n", + " \"end_gap\": float(abs(w[0] - w[2])), \"mid_bulge\": float(w[1] - max(w[0], w[2])),\n", + " \"w_max\": float(w.max()), \"compact\": float(boundary.sum() / np.sqrt(mask.sum()))}\n", + "\n", + "\n", + "def feature_matrix(images):\n", + " \"\"\"전처리 결과, 형태 특징 행렬(N x 8), 정규화 이미지 행렬(N x 576)을 돌려준다.\"\"\"\n", + " P = preprocess(images)\n", + " F = np.array([[d[k] for k in FEATS] for d in map(shape_features, P[\"mask\"])])\n", + " return P, F, P[\"norm\"].reshape(len(F), -1)\n", + "\n", + "FEAT_KOR = {\"elong\": \"길쭉함\", \"fill\": \"채움률\", \"solidity\": \"볼록도\", \"n_parts\": \"분리 부분 수\", \"end_gap\": \"앞뒤 폭 차이\",\n", + " \"mid_bulge\": \"중앙부 날개 돌출\", \"w_max\": \"최대 폭(날개 폭)\", \"compact\": \"윤곽 복잡도\"}\n", + "KEY = {\"civil_quad\": \"원형 회전날개 4개\", \"civil_fw\": \"직선 주익과 꼬리날개\", \"attack_fw\": \"꼬리 없는 삼각형 날개\",\n", + " \"cruise\": \"가는 동체와 작은 중간 날개\", \"ballistic\": \"굵은 동체·뾰족한 머리·꼬리 핀\", \"bird\": \"갈매기형으로 굽은 날개\"}\n", + "ACTIONS = {\"APPROVE\": \"승인(공개)\", \"EDIT\": \"수정 후 승인\", \"REJECT\": \"반려(재작성)\"}\n", + "SLA_H = 24\n", + "\n", + "\n", + "def template(cls):\n", + " return draw(cls, np.random.default_rng(0), angle=0, scale=.85, fore=1.0, blur=0, noise=0, shift=0)\n", + "\n", + "\n", + "TEMPL = {c: template(c) for c in CLASSES}\n", + "TF = {c: shape_features(preprocess([TEMPL[c]])[\"mask\"][0]) for c in CLASSES}\n", + "SPREAD = {k: np.std([TF[c][k] for c in CLASSES]) + 1e-6 for k in FEATS}\n", + "\n", + "\n", + "def draft(answer, chosen):\n", + " \"\"\"AI 해설 초안: 두 클래스 기준 실루엣의 형태 특징 차이 상위 3개를 문장으로 만든다(초안일 뿐 공개하지 않음).\"\"\"\n", + " diff = sorted(FEATS, key=lambda k: -abs(TF[answer][k] - TF[chosen][k]) / SPREAD[k])[:3]\n", + " parts = [f\"{FEAT_KOR[k]} {TF[answer][k]:.2f} vs {TF[chosen][k]:.2f}\" for k in diff]\n", + " return f\"정답 {KOR[answer]}은(는) {KEY[answer]}, 선택한 {KOR[chosen]}은(는) {KEY[chosen]}. 차이: \" + \"; \".join(parts)\n", + "\n", + "\n", + "class ReviewQueue:\n", + " def __init__(self):\n", + " self.items, self.log, self.clock = {}, [], 0.0\n", + "\n", + " def _record(self, body):\n", + " prev = self.log[-1][\"hash\"] if self.log else \"GENESIS\"\n", + " body = {**body, \"t_h\": self.clock}\n", + " h = hashlib.sha256((prev + json.dumps(body, ensure_ascii=False, sort_keys=True)).encode()).hexdigest()\n", + " self.log.append({**body, \"prev\": prev, \"hash\": h})\n", + "\n", + " def verify(self):\n", + " prev = \"GENESIS\"\n", + " for n, r in enumerate(self.log, 1):\n", + " body = {k: v for k, v in r.items() if k not in (\"prev\", \"hash\")}\n", + " if r[\"prev\"] != prev or r[\"hash\"] != hashlib.sha256((prev + json.dumps(body, ensure_ascii=False, sort_keys=True)).encode()).hexdigest():\n", + " return False, n\n", + " prev = r[\"hash\"]\n", + " return True, -1\n", + "\n", + " def submit(self, learner, answer, chosen, freq):\n", + " key = f\"{answer}->{chosen}\"\n", + " if key in self.items and self.items[key][\"status\"] == \"PENDING\": # 같은 혼동 쌍은 한 건으로 묶고 빈도만 올린다\n", + " self.items[key][\"learners\"].append(learner); self.items[key][\"freq\"] = freq\n", + " else:\n", + " self.items[key] = {\"key\": key, \"answer\": answer, \"chosen\": chosen, \"draft\": draft(answer, chosen), \"status\": \"PENDING\",\n", + " \"published\": None, \"learners\": [learner], \"freq\": freq, \"t_in\": self.clock, \"escalated\": False}\n", + " self._record({\"event\": \"SUBMIT\", \"key\": key, \"learner\": learner, \"by\": \"AI\"})\n", + "\n", + " def order(self):\n", + " todo = [i for i in self.items.values() if i[\"status\"] == \"PENDING\"]\n", + " return sorted(todo, key=lambda i: (not i[\"escalated\"], -i[\"freq\"], i[\"t_in\"]))\n", + "\n", + " def decide(self, key, action, instructor, reason, text=None, feedback=\"\"):\n", + " it = self.items.get(key)\n", + " if it is None or it[\"status\"] != \"PENDING\":\n", + " raise ValueError(\"검토 대기 중인 항목이 아님\")\n", + " if action not in ACTIONS or not instructor.startswith(\"INS\"):\n", + " raise ValueError(\"허용되지 않은 조치 또는 교관 ID 없음\")\n", + " if len(reason.strip()) < 5:\n", + " raise ValueError(\"판단 근거 5자 이상 필요\")\n", + " if action == \"EDIT\" and not text:\n", + " raise ValueError(\"수정 승인은 수정 해설문 필요\")\n", + " it[\"status\"] = {\"APPROVE\": \"APPROVED\", \"EDIT\": \"APPROVED\", \"REJECT\": \"REJECTED\"}[action]\n", + " it[\"published\"] = (text or it[\"draft\"]) if action != \"REJECT\" else None\n", + " it[\"wait_h\"] = self.clock - it[\"t_in\"]\n", + " self._record({\"event\": \"DECIDE\", \"key\": key, \"action\": action, \"by\": instructor, \"reason\": reason, \"feedback\": feedback})\n", + "\n", + " def check_sla(self):\n", + " out = []\n", + " for i in self.items.values():\n", + " if i[\"status\"] == \"PENDING\" and not i[\"escalated\"] and self.clock - i[\"t_in\"] > SLA_H:\n", + " i[\"escalated\"] = True; out.append(i[\"key\"])\n", + " self._record({\"event\": \"ESCALATE\", \"key\": i[\"key\"], \"to\": \"교육훈련 담당관\"})\n", + " return out\n", + "\n", + "\n", + "# [교육용가상자료] 학습자 20명의 오답 기록(가명 L01~L20)\n", + "rng = np.random.default_rng(4)\n", + "pairs = [(\"attack_fw\", \"civil_fw\")] * 6 + [(\"cruise\", \"ballistic\")] * 5 + [(\"civil_fw\", \"attack_fw\")] * 3 + [(\"bird\", \"civil_fw\")] * 2 + [(\"ballistic\", \"cruise\")] * 4\n", + "q = ReviewQueue()\n", + "freq = {}\n", + "for n, (a, c) in enumerate(rng.permutation(np.array(pairs, dtype=object))):\n", + " freq[(a, c)] = freq.get((a, c), 0) + 1\n", + " q.clock += 0.5\n", + " q.submit(f\"L{n + 1:02d}\", a, c, freq[(a, c)])\n", + "print(f\"오답 {len(pairs)}건 -> 해설 검토 항목 {len(q.items)}건 (혼동 쌍 단위), 공개된 해설 {sum(i['published'] is not None for i in q.items.values())}건\")\n", + "print(\"검토 순서(빈도순):\", [f\"{i['key']}({i['freq']})\" for i in q.order()])\n", + "print(\"\\n[AI 해설 초안 예시 - 교관 검토 전 비공개]\\n\" + q.order()[0][\"draft\"])\n", + "\n", + "for args in [(\"attack_fw->civil_fw\", \"APPROVE\", \"\", \"근거 충분\"), (\"attack_fw->civil_fw\", \"EDIT\", \"INS01\", \"표현 보완 필요\"), (\"cruise->ballistic\", \"PUBLISH\", \"INS01\", \"자동 공개\")]:\n", + " try:\n", + " q.decide(*args)\n", + " except ValueError as e:\n", + " print(\"차단:\", e)\n", + "\n", + "q.clock += 2; q.decide(\"attack_fw->civil_fw\", \"EDIT\", \"INS01\", \"꼬리날개 유무를 먼저 보도록 수정\",\n", + " text=\"꼬리날개가 없고 날개가 삼각형이면 자폭드론형, 직선 날개와 꼬리날개가 있으면 민간 고정익형이다.\", feedback=\"원거리 예시 추가 요망\")\n", + "q.clock += 3; q.decide(\"cruise->ballistic\", \"APPROVE\", \"INS02\", \"중간 날개 유무 설명이 정확함\")\n", + "q.clock += 1; q.decide(\"bird->civil_fw\", \"REJECT\", \"INS02\", \"조류 날갯짓 변형 설명 누락, 재작성\")\n", + "q.clock += 30\n", + "print(\"SLA 초과 상신:\", q.check_sla())\n", + "q.clock += 1; q.decide(\"ballistic->cruise\", \"APPROVE\", \"INS01\", \"몸통 굵기 비교가 명확함\")\n", + "\n", + "df = pd.DataFrame(q.items.values())\n", + "expected = len(pairs) + int((df[\"status\"] != \"PENDING\").sum()) + int(df[\"escalated\"].sum()) # 제출 + 결정 + 상신 이벤트 수\n", + "print(\"남은 검토 대기:\", [f\"{i['key']}(대기 {q.clock - i['t_in']:.1f}h)\" for i in q.order()])\n", + "print(\"\\n\" + df[[\"key\", \"freq\", \"status\", \"escalated\", \"wait_h\"]].fillna(\"-\").to_string(index=False))\n", + "print(f\"\\n자동 공개 0건(승인 없이 공개된 해설 {sum(1 for i in q.items.values() if i['published'] and i['status'] != 'APPROVED')}건), \"\n", + " f\"감사 로그 {len(q.log)}건, 로깅 성공률 {len(q.log) / expected:.0%}, 해시 체인 검증 {q.verify()}\")\n", + "q.log[3][\"learner\"] = \"L99\"\n", + "print(\"4번째 기록 변조 후 검증(False, 깨진 위치):\", q.verify())\n", + "print(\"교관 피드백 입력 항목: 교관 ID, 조치, 판단 근거, 수정 해설문, 지휘관·교관 의견\")\n", + "\n", + "a, c = \"attack_fw\", \"civil_fw\"\n", + "fig, ax = plt.subplots(1, 3, figsize=(10, 3.6))\n", + "ax[0].imshow(TEMPL[a], cmap=\"gray\"); ax[0].set_title(f\"correct: {a}\")\n", + "ax[1].imshow(TEMPL[c], cmap=\"gray\"); ax[1].set_title(f\"chosen: {c}\")\n", + "ax[2].imshow((TEMPL[c].astype(float) - TEMPL[a]), cmap=\"coolwarm\"); ax[2].set_title(\"difference (red = only correct)\")\n", + "for x in ax:\n", + " x.axis(\"off\")\n", + "plt.suptitle(\"Wrong-answer comparison for instructor review (synthetic)\"); plt.tight_layout(); plt.show()\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 07 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + "오답 20건 -> 해설 검토 항목 5건 (혼동 쌍 단위), 공개된 해설 0건\n", + "검토 순서(빈도순): ['attack_fw->civil_fw(6)', 'cruise->ballistic(5)', 'ballistic->cruise(4)', 'civil_fw->attack_fw(3)', 'bird->civil_fw(2)']\n", + "\n", + "[AI 해설 초안 예시 - 교관 검토 전 비공개]\n", + "정답 삼각익 자폭드론형은(는) 꼬리 없는 삼각형 날개, 선택한 민간 고정익형은(는) 직선 주익과 꼬리날개. 차이: 윤곽 복잡도 3.82 vs 7.95; 볼록도 0.86 vs 0.40; 채움률 0.51 vs 0.29\n", + "차단: 허용되지 않은 조치 또는 교관 ID 없음\n", + "차단: 수정 승인은 수정 해설문 필요\n", + "차단: 허용되지 않은 조치 또는 교관 ID 없음\n", + "SLA 초과 상신: ['civil_fw->attack_fw', 'ballistic->cruise']\n", + "남은 검토 대기: ['civil_fw->attack_fw(대기 46.0h)']\n", + "\n", + " key freq status escalated wait_h\n", + "attack_fw->civil_fw 6 APPROVED False 11.5\n", + "civil_fw->attack_fw 3 PENDING True -\n", + " cruise->ballistic 5 APPROVED False 13.5\n", + " ballistic->cruise 4 APPROVED True 44.5\n", + " bird->civil_fw 2 REJECTED False 10.0\n", + "\n", + "자동 공개 0건(승인 없이 공개된 해설 0건), 감사 로그 26건, 로깅 성공률 100%, 해시 체인 검증 (True, -1)\n", + "4번째 기록 변조 후 검증(False, 깨진 위치): (False, 4)\n", + "교관 피드백 입력 항목: 교관 ID, 조치, 판단 근거, 수정 해설문, 지휘관·교관 의견\n", + "```\n", + "
\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 08. 다량의 저품질 흐린 실루엣 이미지 분류 스트레스 테스트 및 한계치 검증\n", + "**소요시간**: 약 3.5시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "흐린 저품질 실루엣 이미지 대량 분류 스트레스 테스트를 수행한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **다량의 흐리고 품질이 낮은 실루엣 이미지가 한꺼번에 들어올 때 분류 서비스가 멈추지 않고 안전하게 동작하는지 한계치를 검증한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **평시 100장에서 500% 증가한 600장까지 요청을 늘리고, 손상 입력과 극저화질 영상을 섞어 시스템 다운·결과 유실 여부 확인하고 유효성을 점검하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `tests/trn03_stress_test.py 및 스트레스 테스트 분석 보고서` 형태의 결과물을 완성한다.\n", + "\n", + "### 🔍 평가 및 검증 기준 (PASS 조건)\n", + "#### 미션 판정\n", + "\n", + "**평가 구분:** 필수\n", + "\n", + "**핵심 평가영역:** 극단 상황에서의 안전 실패(Fail-safe) 및 기능 축소 작동 적절성\n", + "\n", + "#### 미션 PASS 조건\n", + "\n", + "- 정량 기준: 동시 트래픽 500% 증가 시 시스템 다운 0건, 데이터 유실률 0%\n", + "\n", + "\n", + "- 진행 기준: 대량 시뮬레이션 부하 주입 테스트 통과 및 병목 구간 프로파일링 완료\n", + "\n", + "\n", + "- 검증 방법: 부하 테스트 도구를 통한 처리량(Throughput) 및 메모리 누수 검증\n", + "\n", + "\n", + "#### 채점자 판정 메모\n", + "\n", + "> 단순 코드 실행 여부뿐만 아니라 출력된 수치와 근거 자료가 군 실무 요구사항에 부합하는지 면밀히 확인한다.\n", + "\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "\"\"\"흐리고 품질이 낮은 실루엣 이미지를 평시의 최대 6배(500% 증가)까지 한꺼번에 넣고 손상 입력을 섞어, 분류 서비스가 멈추지 않는지(다운 0건)·결과가 빠지지 않는지(유실률 0%)·흐림 한계치·처리량·메모리·병목 구간을 검증한다.\"\"\"\n", + "import time\n", + "import tracemalloc\n", + "import numpy as np\n", + "import pandas as pd\n", + "import matplotlib.pyplot as plt\n", + "from matplotlib.path import Path as MplPath\n", + "from scipy import ndimage\n", + "from scipy.spatial import ConvexHull\n", + "from sklearn.neural_network import MLPClassifier\n", + "from sklearn.preprocessing import StandardScaler\n", + "\n", + "# ---------------------------------------------------------------------------\n", + "# [교육용가상자료] 합성 실루엣 생성기 (실제 기체 사진·도면·제원 사용 안 함)\n", + "# 사각형·삼각형·원 같은 기본 도형을 조합한 추상 실루엣을 32x32 흑백 이미지로 그린다.\n", + "# 회전(다각도), 원근 축소(측면 시점), 크기(원거리), 흐림·잡음·저대비·구름·가림·야간 반전을 조절한다.\n", + "# ---------------------------------------------------------------------------\n", + "IMG = 32\n", + "CLASSES = [\"civil_quad\", \"civil_fw\", \"attack_fw\", \"cruise\", \"ballistic\", \"bird\"]\n", + "KOR = {\"civil_quad\": \"민간 쿼드콥터형\", \"civil_fw\": \"민간 고정익형\", \"attack_fw\": \"삼각익 자폭드론형\",\n", + " \"cruise\": \"순항미사일형\", \"ballistic\": \"탄도미사일형\", \"bird\": \"조류\"}\n", + "THREAT = {\"attack_fw\", \"cruise\", \"ballistic\"}\n", + "\n", + "\n", + "def _rect(x0, x1, y0, y1):\n", + " return [(x0, y0), (x1, y0), (x1, y1), (x0, y1)]\n", + "\n", + "\n", + "def _bar(p, q, w):\n", + " (x0, y0), (x1, y1) = p, q\n", + " n = np.array([y0 - y1, x1 - x0]) / np.hypot(x1 - x0, y1 - y0) * w / 2\n", + " return [(x0 + n[0], y0 + n[1]), (x1 + n[0], y1 + n[1]), (x1 - n[0], y1 - n[1]), (x0 - n[0], y0 - n[1])]\n", + "\n", + "\n", + "# 도형 정의(단위 좌표, 기수 방향 = +y). 모두 추상 도형이며 특정 기종을 본뜨지 않는다.\n", + "SHAPES = {\n", + " \"civil_quad\": {\"poly\": [_bar((-.55, -.55), (.55, .55), .1), _bar((-.55, .55), (.55, -.55), .1), _rect(-.15, .15, -.15, .15)],\n", + " \"circ\": [(-.55, -.55, .24), (.55, .55, .24), (-.55, .55, .24), (.55, -.55, .24)]},\n", + " \"civil_fw\": {\"poly\": [_rect(-.07, .07, -.8, .72), _rect(-.9, .9, .05, .25), _rect(-.33, .33, -.8, -.66)], \"circ\": []},\n", + " \"attack_fw\": {\"poly\": [[(0, .85), (.85, -.5), (.12, -.42), (0, -.6), (-.12, -.42), (-.85, -.5)]], \"circ\": []},\n", + " \"cruise\": {\"poly\": [_rect(-.07, .07, -.9, .7), [(-.07, .7), (0, .92), (.07, .7)], _rect(-.42, .42, -.05, .07),\n", + " [(-.07, -.9), (-.24, -.95), (-.07, -.74)], [(.07, -.9), (.24, -.95), (.07, -.74)]], \"circ\": []},\n", + " \"ballistic\": {\"poly\": [_rect(-.13, .13, -.85, .6), [(-.13, .6), (0, .95), (.13, .6)],\n", + " [(-.13, -.85), (-.34, -.95), (-.13, -.58)], [(.13, -.85), (.34, -.95), (.13, -.58)]], \"circ\": []},\n", + " \"bird\": {\"poly\": [[(-.9, .12), (-.5, .28), (-.12, .06), (0, .22), (.12, .06), (.5, .28), (.9, .12), (.5, .12),\n", + " (.12, -.1), (0, -.38), (-.12, -.1), (-.5, .12)]], \"circ\": []},\n", + "}\n", + "_S = 2\n", + "_g = (np.arange(IMG * _S) + 0.5) / _S\n", + "_GX, _GY = np.meshgrid(_g, _g)\n", + "_PTS = np.c_[_GX.ravel(), _GY.ravel()]\n", + "_PATHS = {c: [MplPath(np.array(p)) for p in s[\"poly\"]] for c, s in SHAPES.items()}\n", + "\n", + "\n", + "def draw(cls, rng, angle=None, scale=None, fore=None, blur=0.3, noise=4.0, contrast=1.0,\n", + " clutter=0.0, occlude=0.0, invert=False, shift=1.5):\n", + " \"\"\"클래스 1개 실루엣 이미지(uint8 32x32)를 그린다. 배경은 밝은 하늘, 비행체는 어두운 그림자.\"\"\"\n", + " angle = rng.uniform(0, 360) if angle is None else angle\n", + " scale = rng.uniform(.7, .95) if scale is None else scale\n", + " fore = rng.uniform(.85, 1.0) if fore is None else fore\n", + " th = np.radians(angle)\n", + " c0 = IMG / 2 + rng.uniform(-shift, shift, 2)\n", + " d = (_PTS - c0) / (scale * IMG / 2) # 이미지 좌표 -> 도형 좌표(역변환)\n", + " u = d[:, 0] * np.cos(th) + d[:, 1] * np.sin(th)\n", + " v = (-d[:, 0] * np.sin(th) + d[:, 1] * np.cos(th)) / fore # 원근 축소(기수 방향으로 짧아 보임)\n", + " uv = np.c_[u, -v]\n", + " m = np.zeros(len(uv), bool)\n", + " near = np.flatnonzero(np.abs(uv).max(axis=1) <= 1.0) # 도형이 있을 수 있는 영역만 검사(속도)\n", + " for p in _PATHS[cls]:\n", + " m[near] |= p.contains_points(uv[near])\n", + " for cx, cy, r in SHAPES[cls][\"circ\"]:\n", + " m |= (uv[:, 0] - cx) ** 2 + (uv[:, 1] - cy) ** 2 <= r * r\n", + " cov = m.reshape(IMG, _S, IMG, _S).mean(axis=(1, 3))\n", + " if occlude > 0: # 부분 가림(구름 조각)\n", + " h = int(IMG * np.sqrt(occlude)); y0, x0 = rng.integers(8, IMG - 8 - h // 2, 2)\n", + " cov[y0:y0 + h, x0:x0 + h] = 0\n", + " bg = rng.uniform(180, 220) + np.linspace(-10, 10, IMG)[:, None]\n", + " fg = rng.uniform(30, 60)\n", + " img = bg * (1 - cov) + fg * cov\n", + " if clutter > 0: # 구름·배경 얼룩\n", + " img -= clutter * 90 * np.clip(ndimage.gaussian_filter(rng.normal(0, 1, (IMG, IMG)), 3) * 4, 0, None)\n", + " if blur > 0:\n", + " img = ndimage.gaussian_filter(img, blur)\n", + " mean = img.mean()\n", + " img = mean + (img - mean) * contrast\n", + " img = img + rng.normal(0, noise, img.shape)\n", + " if invert: # 야간 열상(IR) 극성 반전\n", + " img = 255 - img\n", + " return np.clip(img, 0, 255).astype(np.uint8)\n", + "\n", + "\n", + "# 촬영 조건 프리셋: 기본 + 복합 위협 시나리오\n", + "CONDITIONS = {\n", + " \"basic\": {},\n", + " \"offcenter\": {\"shift\": 5.0, \"fore\": (.6, .85)},\n", + " \"far\": {\"scale\": (.32, .45), \"blur\": .6},\n", + " \"blur\": {\"blur\": (1.2, 1.8)},\n", + " \"lowcon\": {\"contrast\": (.2, .35)},\n", + " \"clutter\": {\"clutter\": .8},\n", + " \"occlude\": {\"occlude\": .08},\n", + " \"oblique\": {\"fore\": (.35, .6)},\n", + " \"night\": {\"invert\": True, \"noise\": 10.0},\n", + " \"noise\": {\"noise\": 28.0},\n", + " \"combo\": {\"scale\": (.4, .55), \"blur\": (.8, 1.1), \"noise\": 12.0, \"contrast\": (.6, .8)},\n", + "}\n", + "\n", + "\n", + "def draw_cond(cls, cond, rng):\n", + " kw = {}\n", + " for k, v in CONDITIONS[cond].items():\n", + " kw[k] = rng.uniform(*v) if isinstance(v, tuple) else v\n", + " return draw(cls, rng, **kw)\n", + "\n", + "\n", + "def make_dataset(n_per_class, seed, conds=(\"basic\",)):\n", + " \"\"\"(이미지 배열 N x 32 x 32, 정답 라벨, 조건) 반환. 조건은 conds에서 번갈아 고른다.\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " X, y, c = [], [], []\n", + " for cls in CLASSES:\n", + " for i in range(n_per_class):\n", + " cond = conds[i % len(conds)]\n", + " X.append(draw_cond(cls, cond, rng)); y.append(cls); c.append(cond)\n", + " return np.array(X), np.array(y), np.array(c)\n", + "# ---------------------------------------------------------------------------\n", + "\n", + "# ---- 전처리(배치 벡터화)·형태 특징 공통 함수 ----\n", + "OUT = 24 # 크기 정규화 결과(24x24)\n", + "NOISE_T = 12.0 # 잡음 추정값이 이보다 크면 3x3 중간값 필터 적용(악천후·센서 잡음)\n", + "FEATS = [\"elong\", \"fill\", \"solidity\", \"n_parts\", \"end_gap\", \"mid_bulge\", \"w_max\", \"compact\"]\n", + "\n", + "\n", + "def preprocess(images, noise_t=NOISE_T):\n", + " \"\"\"N장 일괄 처리: 결측 보정 -> 극성(야간 반전) 보정 -> 조건별 잡음 제거 -> 명암 대비 강화 -> 분할 -> 윤곽선 -> 방향·크기 정규화.\"\"\"\n", + " f = np.array(images, dtype=float).reshape(-1, IMG, IMG)\n", + " n = len(f)\n", + " bad = ~np.isfinite(f)\n", + " missing = bad.sum(axis=(1, 2))\n", + " if bad.any(): # 결측 픽셀: 영상 중앙값으로 채운 뒤 주변 3x3 중간값으로 보정\n", + " med = np.array([np.median(x[np.isfinite(x)]) if np.isfinite(x).any() else 128.0 for x in f])\n", + " f = np.where(bad, med[:, None, None], f)\n", + " f = np.where(bad, ndimage.median_filter(f, size=(1, 3, 3)), f)\n", + " border = np.concatenate([f[:, 0], f[:, -1], f[:, :, 0], f[:, :, -1]], axis=1)\n", + " inverted = np.median(border, axis=1) < 128 # 배경이 어두우면 야간 열상으로 보고 반전\n", + " f[inverted] = 255 - f[inverted]\n", + " med3 = ndimage.median_filter(f, size=(1, 3, 3))\n", + " noise = 1.4826 * np.median(np.abs(f - med3).reshape(n, -1), axis=1)\n", + " noisy = noise > noise_t\n", + " f[noisy] = med3[noisy]\n", + " lo, hi = np.percentile(f.reshape(n, -1), [1, 99], axis=1)\n", + " e = np.clip((f - lo[:, None, None]) / np.maximum(hi - lo, 1e-6)[:, None, None], 0, 1) # 명암 대비 강화\n", + " mask = e < _otsu(e)[:, None, None]\n", + " mask &= ndimage.uniform_filter(mask.astype(float), size=(1, 3, 3)) * 9 >= 2.5 # 고립 점 잡음 제거\n", + " edge = np.hypot(ndimage.sobel(e, axis=1), ndimage.sobel(e, axis=2)) # 윤곽선(Sobel)\n", + " ok = (mask.sum(axis=(1, 2)) >= 4) & (hi - lo > 8)\n", + " return {\"enh\": e, \"mask\": mask, \"edge\": edge / np.maximum(edge.max(axis=(1, 2)), 1e-6)[:, None, None],\n", + " \"norm\": _normalize(1 - e, mask, ok), \"inverted\": inverted, \"noise\": noise, \"denoised\": noisy,\n", + " \"missing\": missing, \"contrast\": hi - lo, \"ok\": ok}\n", + "\n", + "\n", + "def _otsu(e, bins=64):\n", + " n = len(e)\n", + " idx = np.minimum((e * bins).astype(int), bins - 1) + bins * np.arange(n)[:, None, None]\n", + " hist = np.bincount(idx.ravel(), minlength=bins * n).reshape(n, bins).astype(float)\n", + " centers = (np.arange(bins) + 0.5) / bins\n", + " w = np.cumsum(hist, axis=1); m = np.cumsum(hist * centers, axis=1)\n", + " tot, mt = w[:, -1:], m[:, -1:]\n", + " between = (mt * w / tot - m) ** 2 / np.maximum(w * (tot - w), 1)\n", + " return (np.argmax(between, axis=1) + 1) / bins\n", + "\n", + "\n", + "def _normalize(obj, mask, ok):\n", + " \"\"\"주축 방향을 세로로 돌리고 비행체 영역만 24x24로 다시 샘플링한다(회전·크기·위치 불변). 한 번의 보간으로 일괄 처리.\"\"\"\n", + " n = len(obj)\n", + " w = mask.astype(float)\n", + " m00 = np.maximum(w.sum(axis=(1, 2)), 1)\n", + " yy, xx = np.mgrid[0:IMG, 0:IMG].astype(float)\n", + " cy, cx = (w * yy).sum(axis=(1, 2)) / m00, (w * xx).sum(axis=(1, 2)) / m00\n", + " dy, dx = yy - cy[:, None, None], xx - cx[:, None, None]\n", + " mu20, mu02, mu11 = (w * dy * dy).sum(axis=(1, 2)), (w * dx * dx).sum(axis=(1, 2)), (w * dy * dx).sum(axis=(1, 2))\n", + " th = 0.5 * np.arctan2(2 * mu11, mu20 - mu02)\n", + " a = np.stack([np.cos(th), np.sin(th)], 1) # 주축(y, x)\n", + " b = np.stack([-np.sin(th), np.cos(th)], 1) # 부축\n", + " pa = np.where(mask, dy * a[:, 0, None, None] + dx * a[:, 1, None, None], 0)\n", + " pb = np.where(mask, dy * b[:, 0, None, None] + dx * b[:, 1, None, None], 0)\n", + " half = np.maximum(np.abs(pa).max(axis=(1, 2)), np.abs(pb).max(axis=(1, 2))) + 1.0\n", + " g = np.linspace(-1, 1, OUT)\n", + " U, V = np.meshgrid(g, g, indexing=\"ij\")\n", + " Y = cy[:, None, None] + half[:, None, None] * (U * a[:, 0, None, None] + V * b[:, 0, None, None])\n", + " X = cx[:, None, None] + half[:, None, None] * (U * a[:, 1, None, None] + V * b[:, 1, None, None])\n", + " N = np.broadcast_to(np.arange(n)[:, None, None], Y.shape)\n", + " out = ndimage.map_coordinates(obj * mask, [N, Y, X], order=1, cval=0.0)\n", + " skew = (out * (U ** 3)).sum(axis=(1, 2))\n", + " out[skew < 0] = out[skew < 0, ::-1] # 무게가 쏠린 쪽을 위로(180도 모호성 제거)\n", + " out[~ok] = 0\n", + " return np.clip(out, 0, 1)\n", + "\n", + "\n", + "def shape_features(mask):\n", + " \"\"\"분할 마스크 1장에서 회전에 무관한 형태 특징 8종을 계산한다.\"\"\"\n", + " if mask.sum() < 4:\n", + " return dict.fromkeys(FEATS, 0.0)\n", + " yx = np.argwhere(mask).astype(float)\n", + " c = yx - yx.mean(axis=0)\n", + " val, vec = np.linalg.eigh(np.cov(c.T) + 1e-6 * np.eye(2))\n", + " t, s = c @ vec[:, 1], c @ vec[:, 0]\n", + " length, width = np.ptp(t) + 1, np.ptp(s) + 1\n", + " corners = np.vstack([yx + d for d in ([.5, .5], [.5, -.5], [-.5, .5], [-.5, -.5])])\n", + " try:\n", + " hull = ConvexHull(corners).volume\n", + " except Exception:\n", + " hull = float(mask.sum())\n", + " bins = np.digitize(t, np.linspace(t.min(), t.max() + 1e-6, 4)[1:-1])\n", + " w = np.array([np.ptp(s[bins == k]) + 1 if (bins == k).any() else 0 for k in range(3)]) / length\n", + " boundary = mask & ~ndimage.binary_erosion(mask)\n", + " return {\"elong\": float(np.sqrt(val[1] / max(val[0], 1e-6))), \"fill\": float(mask.sum() / (length * width)),\n", + " \"solidity\": float(mask.sum() / max(hull, 1)), \"n_parts\": float(ndimage.label(mask)[1]),\n", + " \"end_gap\": float(abs(w[0] - w[2])), \"mid_bulge\": float(w[1] - max(w[0], w[2])),\n", + " \"w_max\": float(w.max()), \"compact\": float(boundary.sum() / np.sqrt(mask.sum()))}\n", + "\n", + "\n", + "def feature_matrix(images):\n", + " \"\"\"전처리 결과, 형태 특징 행렬(N x 8), 정규화 이미지 행렬(N x 576)을 돌려준다.\"\"\"\n", + " P = preprocess(images)\n", + " F = np.array([[d[k] for k in FEATS] for d in map(shape_features, P[\"mask\"])])\n", + " return P, F, P[\"norm\"].reshape(len(F), -1)\n", + "\n", + "# ---- 분류 모델 공통 함수(다층 퍼셉트론: 정규화 이미지 576픽셀 + 형태 특징 8종) ----\n", + "def to_input(F, N, scaler):\n", + " return np.c_[N, scaler.transform(F) * 0.3]\n", + "\n", + "\n", + "def train_model(n_per_class=150, seed=5, conds=tuple(CONDITIONS), hidden=(64,)):\n", + " X, y, _ = make_dataset(n_per_class, seed, conds)\n", + " P, F, N = feature_matrix(X)\n", + " scaler = StandardScaler().fit(F)\n", + " model = MLPClassifier(hidden, max_iter=200, early_stopping=True, random_state=0)\n", + " model.fit(to_input(F, N, scaler), [CLASSES.index(v) for v in y])\n", + " return model, scaler\n", + "\n", + "\n", + "def predict(model, scaler, images):\n", + " \"\"\"(전처리 결과, 클래스별 확률 N x 6)을 돌려준다.\"\"\"\n", + " P, F, N = feature_matrix(images)\n", + " return P, model.predict_proba(to_input(F, N, scaler))\n", + "\n", + "CAPACITY = 200 # 한 번에 처리하는 최대 장수(초과 시 대기열로 나눠 처리 = 성능저하 모드)\n", + "MIN_CONF = 0.5 # 이보다 낮으면 정답을 단정하지 않고 '판별 불가(교관 확인)'\n", + "\n", + "\n", + "def serve(requests, stage):\n", + " \"\"\"요청 전부에 결과를 하나씩 돌려준다. 손상 입력은 '입력 오류', 저신뢰는 '판별 불가'로 안전하게 처리한다.\"\"\"\n", + " results, valid = {}, []\n", + " for rid, img in requests:\n", + " a = np.asarray(img, dtype=float) if img is not None else None\n", + " if a is None or a.shape != (IMG, IMG) or np.isnan(a).mean() > 0.5:\n", + " results[rid] = \"입력 오류(재전송 요청)\"\n", + " else:\n", + " valid.append((rid, a))\n", + " for k in range(0, len(valid), CAPACITY): # 대기열 청크 처리\n", + " ids, imgs = zip(*valid[k:k + CAPACITY])\n", + " t0 = time.perf_counter(); P = preprocess(np.array(imgs)); t1 = time.perf_counter()\n", + " F = np.array([[d[f] for f in FEATS] for d in map(shape_features, P[\"mask\"])]); t2 = time.perf_counter()\n", + " prob = model.predict_proba(to_input(F, P[\"norm\"].reshape(len(F), -1), scaler)); t3 = time.perf_counter()\n", + " stage[\"전처리\"] += t1 - t0; stage[\"형태특징\"] += t2 - t1; stage[\"추론\"] += t3 - t2\n", + " for rid, ok, p in zip(ids, P[\"ok\"], prob):\n", + " results[rid] = CLASSES[int(p.argmax())] if ok and p.max() >= MIN_CONF else \"판별 불가(교관 확인)\"\n", + " return results\n", + "\n", + "\n", + "def load_case(n, seed, blur=(1.2, 2.2), corrupt=0.0):\n", + " rng = np.random.default_rng(seed)\n", + " y = rng.choice(CLASSES, n)\n", + " reqs = [(f\"R{i:05d}\", draw(c, rng, blur=rng.uniform(*blur), contrast=rng.uniform(.5, 1), noise=10)) for i, c in enumerate(y)]\n", + " for i in rng.choice(n, int(n * corrupt), replace=False):\n", + " reqs[i] = (reqs[i][0], [None, np.full((IMG, IMG), np.nan), np.zeros((20, 20))][i % 3])\n", + " return reqs, dict(zip([r[0] for r in reqs], y))\n", + "\n", + "\n", + "model, scaler = train_model(120, seed=5)\n", + "rows, stage = [], {\"전처리\": 0.0, \"형태특징\": 0.0, \"추론\": 0.0}\n", + "for name, n, kw in [(\"평시 100장\", 100, {}), (\"300% 증가 400장\", 400, {}), (\"500% 증가 600장\", 600, {}),\n", + " (\"500% + 손상 입력 3%\", 600, {\"corrupt\": .03}), (\"500% + 극저화질(흐림 3.0)\", 600, {\"blur\": (2.8, 3.2)})]:\n", + " reqs, truth = load_case(n, len(rows) + 1, **kw)\n", + " t0 = time.perf_counter()\n", + " try:\n", + " res, down = serve(reqs, stage), 0\n", + " except Exception as e:\n", + " res, down = {}, 1; print(\"다운:\", e)\n", + " dt = time.perf_counter() - t0\n", + " judged = [r for r, v in res.items() if v in CLASSES]\n", + " rows.append({\"상황\": name, \"요청\": n, \"응답\": len(res), \"유실률\": 1 - len(res) / n, \"다운\": down,\n", + " \"판별불가\": sum(v.startswith(\"판별\") for v in res.values()), \"입력오류\": sum(v.startswith(\"입력\") for v in res.values()),\n", + " \"판별 정확도\": np.mean([res[r] == truth[r] for r in judged]) if judged else 0, \"처리량(장/s)\": int(n / dt),\n", + " \"성능저하 모드\": f\"청크 {int(np.ceil(n / CAPACITY))}회\" if n > CAPACITY else \"-\"})\n", + "df = pd.DataFrame(rows)\n", + "print(df.round(3).to_string(index=False))\n", + "print(f\"\\n시스템 다운 {df['다운'].sum()}건, 최대 유실률 {df['유실률'].max():.0%} (목표 다운 0건·유실 0%: {'달성' if df['다운'].sum() == 0 and df['유실률'].max() == 0 else '미달'})\")\n", + "\n", + "# 흐림 한계치: 흐림 정도를 올리며 정확도가 75% 아래로 떨어지는 지점\n", + "print(\"\\n흐림(sigma) 판별 정확도 판별불가 비율\")\n", + "limit = None\n", + "for b in [0.5, 1.0, 1.5, 2.0, 2.5, 3.0, 3.5]:\n", + " reqs, truth = load_case(150, 50, blur=(b, b))\n", + " res = serve(reqs, {\"전처리\": 0, \"형태특징\": 0, \"추론\": 0})\n", + " judged = [r for r, v in res.items() if v in CLASSES]\n", + " acc = np.mean([res[r] == truth[r] for r in judged])\n", + " if limit is None and acc < 0.75:\n", + " limit = b\n", + " print(f\"{b:>8} {acc:.3f} {1 - len(judged) / len(res):.3f}\")\n", + "print(f\"한계치: 흐림 {limit} 이상에서 정확도 75% 미만 -> 해당 영상은 학습 문항에서 제외하거나 '고난도'로 분류\")\n", + "\n", + "# 메모리 누수 확인: 같은 부하를 3회 반복해 메모리 증가량 측정\n", + "tracemalloc.start()\n", + "reqs, _ = load_case(300, 99)\n", + "mem = []\n", + "for _ in range(3):\n", + " serve(reqs, {\"전처리\": 0, \"형태특징\": 0, \"추론\": 0}); mem.append(tracemalloc.get_traced_memory()[0] / 1e6)\n", + "peak = tracemalloc.get_traced_memory()[1] / 1e6\n", + "tracemalloc.stop()\n", + "print(f\"반복 후 메모리 {', '.join(f'{m:.1f}' for m in mem)} MB (최대 {peak:.1f} MB), 증가 없음: {mem[-1] - mem[0] < 1}\")\n", + "tot = sum(stage.values())\n", + "print(\"병목 구간:\", \", \".join(f\"{k} {v / tot:.0%}\" for k, v in sorted(stage.items(), key=lambda x: -x[1])), \"-> 형태특징 계산을 병렬화하면 처리량이 가장 크게 오른다\")\n", + "\n", + "plt.figure(figsize=(8, 4))\n", + "plt.bar(range(len(df)), df[\"처리량(장/s)\"], color=[\"tab:red\" if m != \"-\" else \"tab:blue\" for m in df[\"성능저하 모드\"]])\n", + "plt.xticks(range(len(df)), [f\"case{i + 1}\" for i in range(len(df))]); plt.ylabel(\"images / s\")\n", + "plt.title(\"Stress test throughput (red = queued/degraded mode)\"); plt.show()\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 08 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + " 상황 요청 응답 유실률 다운 판별불가 입력오류 판별 정확도 처리량(장/s) 성능저하 모드\n", + " 평시 100장 100 100 0.0 0 37 0 0.889 731 -\n", + " 300% 증가 400장 400 400 0.0 0 155 0 0.918 706 청크 2회\n", + " 500% 증가 600장 600 600 0.0 0 221 0 0.918 707 청크 3회\n", + " 500% + 손상 입력 3% 600 600 0.0 0 216 18 0.915 802 청크 3회\n", + "500% + 극저화질(흐림 3.0) 600 600 0.0 0 366 0 0.654 660 청크 3회\n", + "\n", + "시스템 다운 0건, 최대 유실률 0% (목표 다운 0건·유실 0%: 달성)\n", + "\n", + "흐림(sigma) 판별 정확도 판별불가 비율\n", + " 0.5 1.000 0.020\n", + " 1.0 0.992 0.167\n", + " 1.5 0.937 0.367\n", + " 2.0 0.825 0.467\n", + " 2.5 0.710 0.587\n", + " 3.0 0.566 0.647\n", + " 3.5 0.462 0.653\n", + "한계치: 흐림 2.5 이상에서 정확도 75% 미만 -> 해당 영상은 학습 문항에서 제외하거나 '고난도'로 분류\n", + "반복 후 메모리 0.4, 0.4, 0.4 MB (최대 30.4 MB), 증가 없음: True\n", + "병목 구간: 형태특징 68%, 전처리 31%, 추론 1% -> 형태특징 계산을 병렬화하면 처리량이 가장 크게 오른다\n", + "```\n", + "
\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 09. AAR 기반 장병 다빈도 오답 비행체 분석 및 학습 데이터셋 증강 재학습\n", + "**소요시간**: 약 3.0시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "AAR 기반 장병 다빈도 오답 비행체 분석 및 학습 데이터를 증강 재학습한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **AAR 사후검토로 장병의 다빈도 오답 비행체와 AI 분류기의 오류 조건을 분석하고, 취약 데이터를 증강해 재학습한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **가상 장병 40명의 학습 기록에서 다빈도 오답 쌍을 찾고, 기본 조건 중심으로 학습한 현행 모델의 오류를 조건·클래스별로 분류 수행하고 검증하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `docs/trn03_aar_report.md 및 configs/trn03_tuned_params.yaml` 형태의 결과물을 완성한다.\n", + "\n", + "### 🔍 평가 및 검증 기준 (PASS 조건)\n", + "#### 미션 판정\n", + "\n", + "**평가 구분:** 필수\n", + "\n", + "**핵심 평가영역:** 오류 원인 규명의 객관성 및 개선 조치의 논리적 타당성\n", + "\n", + "#### 미션 PASS 조건\n", + "\n", + "- 정량 기준: AAR 피드백 적용 후 종합 정확도 10%p 이상 향상, 재발 오류 0건\n", + "\n", + "\n", + "- 진행 기준: AAR 보고서 작성 및 파라미터 튜닝 전후 회귀 테스트 통과\n", + "\n", + "\n", + "- 검증 방법: 회귀 테스트(Regression Test) 스위트 전체 자동 실행 및 비교\n", + "\n", + "\n", + "#### 채점자 판정 메모\n", + "\n", + "> 단순 코드 실행 여부뿐만 아니라 출력된 수치와 근거 자료가 군 실무 요구사항에 부합하는지 면밀히 확인한다.\n", + "\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "\"\"\"AAR(사후검토) 형식으로 장병 학습 기록의 다빈도 오답 비행체와 AI 분류기의 오류 조건을 원인별로 분석하고, 취약 조건·클래스를 증강한 데이터로 재학습해 종합 정확도 향상·재발 오류·회귀 여부와 재보정 파라미터를 산출한다.\"\"\"\n", + "import numpy as np\n", + "import pandas as pd\n", + "import matplotlib.pyplot as plt\n", + "from matplotlib.path import Path as MplPath\n", + "from scipy import ndimage\n", + "from scipy.spatial import ConvexHull\n", + "from sklearn.neural_network import MLPClassifier\n", + "from sklearn.preprocessing import StandardScaler\n", + "\n", + "# ---------------------------------------------------------------------------\n", + "# [교육용가상자료] 합성 실루엣 생성기 (실제 기체 사진·도면·제원 사용 안 함)\n", + "# 사각형·삼각형·원 같은 기본 도형을 조합한 추상 실루엣을 32x32 흑백 이미지로 그린다.\n", + "# 회전(다각도), 원근 축소(측면 시점), 크기(원거리), 흐림·잡음·저대비·구름·가림·야간 반전을 조절한다.\n", + "# ---------------------------------------------------------------------------\n", + "IMG = 32\n", + "CLASSES = [\"civil_quad\", \"civil_fw\", \"attack_fw\", \"cruise\", \"ballistic\", \"bird\"]\n", + "KOR = {\"civil_quad\": \"민간 쿼드콥터형\", \"civil_fw\": \"민간 고정익형\", \"attack_fw\": \"삼각익 자폭드론형\",\n", + " \"cruise\": \"순항미사일형\", \"ballistic\": \"탄도미사일형\", \"bird\": \"조류\"}\n", + "THREAT = {\"attack_fw\", \"cruise\", \"ballistic\"}\n", + "\n", + "\n", + "def _rect(x0, x1, y0, y1):\n", + " return [(x0, y0), (x1, y0), (x1, y1), (x0, y1)]\n", + "\n", + "\n", + "def _bar(p, q, w):\n", + " (x0, y0), (x1, y1) = p, q\n", + " n = np.array([y0 - y1, x1 - x0]) / np.hypot(x1 - x0, y1 - y0) * w / 2\n", + " return [(x0 + n[0], y0 + n[1]), (x1 + n[0], y1 + n[1]), (x1 - n[0], y1 - n[1]), (x0 - n[0], y0 - n[1])]\n", + "\n", + "\n", + "# 도형 정의(단위 좌표, 기수 방향 = +y). 모두 추상 도형이며 특정 기종을 본뜨지 않는다.\n", + "SHAPES = {\n", + " \"civil_quad\": {\"poly\": [_bar((-.55, -.55), (.55, .55), .1), _bar((-.55, .55), (.55, -.55), .1), _rect(-.15, .15, -.15, .15)],\n", + " \"circ\": [(-.55, -.55, .24), (.55, .55, .24), (-.55, .55, .24), (.55, -.55, .24)]},\n", + " \"civil_fw\": {\"poly\": [_rect(-.07, .07, -.8, .72), _rect(-.9, .9, .05, .25), _rect(-.33, .33, -.8, -.66)], \"circ\": []},\n", + " \"attack_fw\": {\"poly\": [[(0, .85), (.85, -.5), (.12, -.42), (0, -.6), (-.12, -.42), (-.85, -.5)]], \"circ\": []},\n", + " \"cruise\": {\"poly\": [_rect(-.07, .07, -.9, .7), [(-.07, .7), (0, .92), (.07, .7)], _rect(-.42, .42, -.05, .07),\n", + " [(-.07, -.9), (-.24, -.95), (-.07, -.74)], [(.07, -.9), (.24, -.95), (.07, -.74)]], \"circ\": []},\n", + " \"ballistic\": {\"poly\": [_rect(-.13, .13, -.85, .6), [(-.13, .6), (0, .95), (.13, .6)],\n", + " [(-.13, -.85), (-.34, -.95), (-.13, -.58)], [(.13, -.85), (.34, -.95), (.13, -.58)]], \"circ\": []},\n", + " \"bird\": {\"poly\": [[(-.9, .12), (-.5, .28), (-.12, .06), (0, .22), (.12, .06), (.5, .28), (.9, .12), (.5, .12),\n", + " (.12, -.1), (0, -.38), (-.12, -.1), (-.5, .12)]], \"circ\": []},\n", + "}\n", + "_S = 2\n", + "_g = (np.arange(IMG * _S) + 0.5) / _S\n", + "_GX, _GY = np.meshgrid(_g, _g)\n", + "_PTS = np.c_[_GX.ravel(), _GY.ravel()]\n", + "_PATHS = {c: [MplPath(np.array(p)) for p in s[\"poly\"]] for c, s in SHAPES.items()}\n", + "\n", + "\n", + "def draw(cls, rng, angle=None, scale=None, fore=None, blur=0.3, noise=4.0, contrast=1.0,\n", + " clutter=0.0, occlude=0.0, invert=False, shift=1.5):\n", + " \"\"\"클래스 1개 실루엣 이미지(uint8 32x32)를 그린다. 배경은 밝은 하늘, 비행체는 어두운 그림자.\"\"\"\n", + " angle = rng.uniform(0, 360) if angle is None else angle\n", + " scale = rng.uniform(.7, .95) if scale is None else scale\n", + " fore = rng.uniform(.85, 1.0) if fore is None else fore\n", + " th = np.radians(angle)\n", + " c0 = IMG / 2 + rng.uniform(-shift, shift, 2)\n", + " d = (_PTS - c0) / (scale * IMG / 2) # 이미지 좌표 -> 도형 좌표(역변환)\n", + " u = d[:, 0] * np.cos(th) + d[:, 1] * np.sin(th)\n", + " v = (-d[:, 0] * np.sin(th) + d[:, 1] * np.cos(th)) / fore # 원근 축소(기수 방향으로 짧아 보임)\n", + " uv = np.c_[u, -v]\n", + " m = np.zeros(len(uv), bool)\n", + " near = np.flatnonzero(np.abs(uv).max(axis=1) <= 1.0) # 도형이 있을 수 있는 영역만 검사(속도)\n", + " for p in _PATHS[cls]:\n", + " m[near] |= p.contains_points(uv[near])\n", + " for cx, cy, r in SHAPES[cls][\"circ\"]:\n", + " m |= (uv[:, 0] - cx) ** 2 + (uv[:, 1] - cy) ** 2 <= r * r\n", + " cov = m.reshape(IMG, _S, IMG, _S).mean(axis=(1, 3))\n", + " if occlude > 0: # 부분 가림(구름 조각)\n", + " h = int(IMG * np.sqrt(occlude)); y0, x0 = rng.integers(8, IMG - 8 - h // 2, 2)\n", + " cov[y0:y0 + h, x0:x0 + h] = 0\n", + " bg = rng.uniform(180, 220) + np.linspace(-10, 10, IMG)[:, None]\n", + " fg = rng.uniform(30, 60)\n", + " img = bg * (1 - cov) + fg * cov\n", + " if clutter > 0: # 구름·배경 얼룩\n", + " img -= clutter * 90 * np.clip(ndimage.gaussian_filter(rng.normal(0, 1, (IMG, IMG)), 3) * 4, 0, None)\n", + " if blur > 0:\n", + " img = ndimage.gaussian_filter(img, blur)\n", + " mean = img.mean()\n", + " img = mean + (img - mean) * contrast\n", + " img = img + rng.normal(0, noise, img.shape)\n", + " if invert: # 야간 열상(IR) 극성 반전\n", + " img = 255 - img\n", + " return np.clip(img, 0, 255).astype(np.uint8)\n", + "\n", + "\n", + "# 촬영 조건 프리셋: 기본 + 복합 위협 시나리오\n", + "CONDITIONS = {\n", + " \"basic\": {},\n", + " \"offcenter\": {\"shift\": 5.0, \"fore\": (.6, .85)},\n", + " \"far\": {\"scale\": (.32, .45), \"blur\": .6},\n", + " \"blur\": {\"blur\": (1.2, 1.8)},\n", + " \"lowcon\": {\"contrast\": (.2, .35)},\n", + " \"clutter\": {\"clutter\": .8},\n", + " \"occlude\": {\"occlude\": .08},\n", + " \"oblique\": {\"fore\": (.35, .6)},\n", + " \"night\": {\"invert\": True, \"noise\": 10.0},\n", + " \"noise\": {\"noise\": 28.0},\n", + " \"combo\": {\"scale\": (.4, .55), \"blur\": (.8, 1.1), \"noise\": 12.0, \"contrast\": (.6, .8)},\n", + "}\n", + "\n", + "\n", + "def draw_cond(cls, cond, rng):\n", + " kw = {}\n", + " for k, v in CONDITIONS[cond].items():\n", + " kw[k] = rng.uniform(*v) if isinstance(v, tuple) else v\n", + " return draw(cls, rng, **kw)\n", + "\n", + "\n", + "def make_dataset(n_per_class, seed, conds=(\"basic\",)):\n", + " \"\"\"(이미지 배열 N x 32 x 32, 정답 라벨, 조건) 반환. 조건은 conds에서 번갈아 고른다.\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " X, y, c = [], [], []\n", + " for cls in CLASSES:\n", + " for i in range(n_per_class):\n", + " cond = conds[i % len(conds)]\n", + " X.append(draw_cond(cls, cond, rng)); y.append(cls); c.append(cond)\n", + " return np.array(X), np.array(y), np.array(c)\n", + "# ---------------------------------------------------------------------------\n", + "\n", + "# ---- 전처리(배치 벡터화)·형태 특징 공통 함수 ----\n", + "OUT = 24 # 크기 정규화 결과(24x24)\n", + "NOISE_T = 12.0 # 잡음 추정값이 이보다 크면 3x3 중간값 필터 적용(악천후·센서 잡음)\n", + "FEATS = [\"elong\", \"fill\", \"solidity\", \"n_parts\", \"end_gap\", \"mid_bulge\", \"w_max\", \"compact\"]\n", + "\n", + "\n", + "def preprocess(images, noise_t=NOISE_T):\n", + " \"\"\"N장 일괄 처리: 결측 보정 -> 극성(야간 반전) 보정 -> 조건별 잡음 제거 -> 명암 대비 강화 -> 분할 -> 윤곽선 -> 방향·크기 정규화.\"\"\"\n", + " f = np.array(images, dtype=float).reshape(-1, IMG, IMG)\n", + " n = len(f)\n", + " bad = ~np.isfinite(f)\n", + " missing = bad.sum(axis=(1, 2))\n", + " if bad.any(): # 결측 픽셀: 영상 중앙값으로 채운 뒤 주변 3x3 중간값으로 보정\n", + " med = np.array([np.median(x[np.isfinite(x)]) if np.isfinite(x).any() else 128.0 for x in f])\n", + " f = np.where(bad, med[:, None, None], f)\n", + " f = np.where(bad, ndimage.median_filter(f, size=(1, 3, 3)), f)\n", + " border = np.concatenate([f[:, 0], f[:, -1], f[:, :, 0], f[:, :, -1]], axis=1)\n", + " inverted = np.median(border, axis=1) < 128 # 배경이 어두우면 야간 열상으로 보고 반전\n", + " f[inverted] = 255 - f[inverted]\n", + " med3 = ndimage.median_filter(f, size=(1, 3, 3))\n", + " noise = 1.4826 * np.median(np.abs(f - med3).reshape(n, -1), axis=1)\n", + " noisy = noise > noise_t\n", + " f[noisy] = med3[noisy]\n", + " lo, hi = np.percentile(f.reshape(n, -1), [1, 99], axis=1)\n", + " e = np.clip((f - lo[:, None, None]) / np.maximum(hi - lo, 1e-6)[:, None, None], 0, 1) # 명암 대비 강화\n", + " mask = e < _otsu(e)[:, None, None]\n", + " mask &= ndimage.uniform_filter(mask.astype(float), size=(1, 3, 3)) * 9 >= 2.5 # 고립 점 잡음 제거\n", + " edge = np.hypot(ndimage.sobel(e, axis=1), ndimage.sobel(e, axis=2)) # 윤곽선(Sobel)\n", + " ok = (mask.sum(axis=(1, 2)) >= 4) & (hi - lo > 8)\n", + " return {\"enh\": e, \"mask\": mask, \"edge\": edge / np.maximum(edge.max(axis=(1, 2)), 1e-6)[:, None, None],\n", + " \"norm\": _normalize(1 - e, mask, ok), \"inverted\": inverted, \"noise\": noise, \"denoised\": noisy,\n", + " \"missing\": missing, \"contrast\": hi - lo, \"ok\": ok}\n", + "\n", + "\n", + "def _otsu(e, bins=64):\n", + " n = len(e)\n", + " idx = np.minimum((e * bins).astype(int), bins - 1) + bins * np.arange(n)[:, None, None]\n", + " hist = np.bincount(idx.ravel(), minlength=bins * n).reshape(n, bins).astype(float)\n", + " centers = (np.arange(bins) + 0.5) / bins\n", + " w = np.cumsum(hist, axis=1); m = np.cumsum(hist * centers, axis=1)\n", + " tot, mt = w[:, -1:], m[:, -1:]\n", + " between = (mt * w / tot - m) ** 2 / np.maximum(w * (tot - w), 1)\n", + " return (np.argmax(between, axis=1) + 1) / bins\n", + "\n", + "\n", + "def _normalize(obj, mask, ok):\n", + " \"\"\"주축 방향을 세로로 돌리고 비행체 영역만 24x24로 다시 샘플링한다(회전·크기·위치 불변). 한 번의 보간으로 일괄 처리.\"\"\"\n", + " n = len(obj)\n", + " w = mask.astype(float)\n", + " m00 = np.maximum(w.sum(axis=(1, 2)), 1)\n", + " yy, xx = np.mgrid[0:IMG, 0:IMG].astype(float)\n", + " cy, cx = (w * yy).sum(axis=(1, 2)) / m00, (w * xx).sum(axis=(1, 2)) / m00\n", + " dy, dx = yy - cy[:, None, None], xx - cx[:, None, None]\n", + " mu20, mu02, mu11 = (w * dy * dy).sum(axis=(1, 2)), (w * dx * dx).sum(axis=(1, 2)), (w * dy * dx).sum(axis=(1, 2))\n", + " th = 0.5 * np.arctan2(2 * mu11, mu20 - mu02)\n", + " a = np.stack([np.cos(th), np.sin(th)], 1) # 주축(y, x)\n", + " b = np.stack([-np.sin(th), np.cos(th)], 1) # 부축\n", + " pa = np.where(mask, dy * a[:, 0, None, None] + dx * a[:, 1, None, None], 0)\n", + " pb = np.where(mask, dy * b[:, 0, None, None] + dx * b[:, 1, None, None], 0)\n", + " half = np.maximum(np.abs(pa).max(axis=(1, 2)), np.abs(pb).max(axis=(1, 2))) + 1.0\n", + " g = np.linspace(-1, 1, OUT)\n", + " U, V = np.meshgrid(g, g, indexing=\"ij\")\n", + " Y = cy[:, None, None] + half[:, None, None] * (U * a[:, 0, None, None] + V * b[:, 0, None, None])\n", + " X = cx[:, None, None] + half[:, None, None] * (U * a[:, 1, None, None] + V * b[:, 1, None, None])\n", + " N = np.broadcast_to(np.arange(n)[:, None, None], Y.shape)\n", + " out = ndimage.map_coordinates(obj * mask, [N, Y, X], order=1, cval=0.0)\n", + " skew = (out * (U ** 3)).sum(axis=(1, 2))\n", + " out[skew < 0] = out[skew < 0, ::-1] # 무게가 쏠린 쪽을 위로(180도 모호성 제거)\n", + " out[~ok] = 0\n", + " return np.clip(out, 0, 1)\n", + "\n", + "\n", + "def shape_features(mask):\n", + " \"\"\"분할 마스크 1장에서 회전에 무관한 형태 특징 8종을 계산한다.\"\"\"\n", + " if mask.sum() < 4:\n", + " return dict.fromkeys(FEATS, 0.0)\n", + " yx = np.argwhere(mask).astype(float)\n", + " c = yx - yx.mean(axis=0)\n", + " val, vec = np.linalg.eigh(np.cov(c.T) + 1e-6 * np.eye(2))\n", + " t, s = c @ vec[:, 1], c @ vec[:, 0]\n", + " length, width = np.ptp(t) + 1, np.ptp(s) + 1\n", + " corners = np.vstack([yx + d for d in ([.5, .5], [.5, -.5], [-.5, .5], [-.5, -.5])])\n", + " try:\n", + " hull = ConvexHull(corners).volume\n", + " except Exception:\n", + " hull = float(mask.sum())\n", + " bins = np.digitize(t, np.linspace(t.min(), t.max() + 1e-6, 4)[1:-1])\n", + " w = np.array([np.ptp(s[bins == k]) + 1 if (bins == k).any() else 0 for k in range(3)]) / length\n", + " boundary = mask & ~ndimage.binary_erosion(mask)\n", + " return {\"elong\": float(np.sqrt(val[1] / max(val[0], 1e-6))), \"fill\": float(mask.sum() / (length * width)),\n", + " \"solidity\": float(mask.sum() / max(hull, 1)), \"n_parts\": float(ndimage.label(mask)[1]),\n", + " \"end_gap\": float(abs(w[0] - w[2])), \"mid_bulge\": float(w[1] - max(w[0], w[2])),\n", + " \"w_max\": float(w.max()), \"compact\": float(boundary.sum() / np.sqrt(mask.sum()))}\n", + "\n", + "\n", + "def feature_matrix(images):\n", + " \"\"\"전처리 결과, 형태 특징 행렬(N x 8), 정규화 이미지 행렬(N x 576)을 돌려준다.\"\"\"\n", + " P = preprocess(images)\n", + " F = np.array([[d[k] for k in FEATS] for d in map(shape_features, P[\"mask\"])])\n", + " return P, F, P[\"norm\"].reshape(len(F), -1)\n", + "\n", + "# ---- 분류 모델 공통 함수(다층 퍼셉트론: 정규화 이미지 576픽셀 + 형태 특징 8종) ----\n", + "def to_input(F, N, scaler):\n", + " return np.c_[N, scaler.transform(F) * 0.3]\n", + "\n", + "\n", + "def train_model(n_per_class=150, seed=5, conds=tuple(CONDITIONS), hidden=(64,)):\n", + " X, y, _ = make_dataset(n_per_class, seed, conds)\n", + " P, F, N = feature_matrix(X)\n", + " scaler = StandardScaler().fit(F)\n", + " model = MLPClassifier(hidden, max_iter=200, early_stopping=True, random_state=0)\n", + " model.fit(to_input(F, N, scaler), [CLASSES.index(v) for v in y])\n", + " return model, scaler\n", + "\n", + "\n", + "def predict(model, scaler, images):\n", + " \"\"\"(전처리 결과, 클래스별 확률 N x 6)을 돌려준다.\"\"\"\n", + " P, F, N = feature_matrix(images)\n", + " return P, model.predict_proba(to_input(F, N, scaler))\n", + "\n", + "\n", + "def fit(X, y):\n", + " P, F, N = feature_matrix(X)\n", + " sc = StandardScaler().fit(F)\n", + " m = MLPClassifier((96,), max_iter=200, early_stopping=True, random_state=0).fit(to_input(F, N, sc), [CLASSES.index(v) for v in y])\n", + " return m, sc\n", + "\n", + "\n", + "def evaluate(m, sc, X):\n", + " return np.array(CLASSES)[predict(m, sc, X)[1].argmax(1)]\n", + "\n", + "\n", + "# [1. 무엇이 일어났나] 장병 학습 기록(가상 40명 x 30문항) 다빈도 오답\n", + "rng = np.random.default_rng(3)\n", + "CONF = {\"civil_fw\": (\"attack_fw\", .30), \"attack_fw\": (\"civil_fw\", .25), \"cruise\": (\"ballistic\", .40), \"ballistic\": (\"cruise\", .35),\n", + " \"bird\": (\"civil_fw\", .15), \"civil_quad\": (\"civil_fw\", .05)}\n", + "log = []\n", + "for s in range(40):\n", + " for q in range(30):\n", + " c = CLASSES[rng.integers(6)]\n", + " wrong, p = CONF[c]\n", + " log.append({\"장병\": f\"L{s + 1:02d}\", \"정답\": c, \"응답\": wrong if rng.random() < p else c})\n", + "lg = pd.DataFrame(log)\n", + "err = lg[lg[\"정답\"] != lg[\"응답\"]]\n", + "print(f\"[1. 무엇이 일어났나] 응답 {len(lg)}건 중 오답 {len(err)}건({len(err) / len(lg):.1%})\")\n", + "print(\"다빈도 오답 쌍:\", (err[\"정답\"] + \"->\" + err[\"응답\"]).value_counts().head(3).to_dict())\n", + "\n", + "# [2. 왜 일어났나] AI 분류기(현행: 기본·회전·저대비·야간 조건 중심 학습) 오류를 조건별로 분류\n", + "base_conds = (\"basic\", \"offcenter\", \"lowcon\", \"night\")\n", + "Xtr, ytr, _ = make_dataset(150, 5, base_conds)\n", + "m0, s0 = fit(Xtr, ytr)\n", + "Xev, yev, cev = make_dataset(110, 31, tuple(CONDITIONS)) # AAR 평가 세트(11개 조건)\n", + "p0 = evaluate(m0, s0, Xev)\n", + "acc0 = (p0 == yev).mean()\n", + "by = pd.Series(p0 != yev).groupby(cev).mean().sort_values(ascending=False)\n", + "print(f\"\\n[2. 왜 일어났나] 현행 모델 종합 정확도 {acc0:.3f}, 오류율 상위 조건:\", by.head(4).round(2).to_dict())\n", + "weak_cls = pd.Series(p0 != yev).groupby(yev).mean().sort_values(ascending=False)\n", + "print(\"오류율 상위 클래스:\", weak_cls.head(3).round(2).to_dict(), \"-> 원인: 학습에 원거리·흐림·가림·측면 시점 영상이 없음\")\n", + "\n", + "# [3. 무엇을 바꾸나] 취약 조건 증강 + 다빈도 오답 클래스 가중 증강 후 재학습\n", + "weak_conds = tuple(by.index[by > 0.15]) or tuple(CONDITIONS)\n", + "Xa, ya, _ = make_dataset(110, 6, weak_conds)\n", + "extra = [c for c in (\"cruise\", \"ballistic\", \"civil_fw\", \"attack_fw\")]\n", + "rng2 = np.random.default_rng(7)\n", + "Xb = np.array([draw_cond(c, weak_conds[i % len(weak_conds)], rng2) for i, c in enumerate(extra * 40)])\n", + "yb = np.array(extra * 40)\n", + "m1, s1 = fit(np.r_[Xtr, Xa, Xb], np.r_[ytr, ya, yb])\n", + "prob1 = predict(m1, s1, Xev)[1]\n", + "p1 = np.array(CLASSES)[prob1.argmax(1)]\n", + "acc1 = (p1 == yev).mean()\n", + "print(f\"\\n[3. 무엇을 바꾸나] 증강 조건 {list(weak_conds)}, 혼동 클래스 4종 각 40장 추가\")\n", + "print(f\"종합 정확도 {acc0:.3f} -> {acc1:.3f} ({(acc1 - acc0) * 100:+.1f}%p, 목표 +10%p: {'달성' if acc1 - acc0 >= 0.10 else '미달'})\")\n", + "\n", + "# 회귀 테스트: AAR 등록 오류의 재발, 기존 정답의 회귀\n", + "reg = p0 != yev\n", + "again = reg & (p1 != yev)\n", + "print(f\"AAR 등록 오류 {reg.sum()}건 중 재발 {again.sum()}건(목표 0건: {'달성' if again.sum() == 0 else '미달'}), 기존 정답 중 새로 틀린 회귀 {(~reg & (p1 != yev)).sum()}건\")\n", + "print(f\"재발 오류 중 신뢰도 0.5 미만이라 '교관 확인'으로 걸러지는 건 {(again & (prob1.max(1) < 0.5)).sum()}건, 단정 오답으로 남는 건 {(again & (prob1.max(1) >= 0.5)).sum()}건\")\n", + "comp = pd.DataFrame({\"재보정 전\": pd.Series(p0 == yev).groupby(cev).mean(), \"재보정 후\": pd.Series(p1 == yev).groupby(cev).mean()}).round(3)\n", + "print(comp.T.to_string())\n", + "print(\"남은 재발 오류는 결합(combo)·구름 조건에 몰려 있어 교관 확인 대상으로 두고, 다음 주기에 지속 학습으로 보완한다.\")\n", + "\n", + "tuned = (f\"augment_conditions: {list(weak_conds)}\\naugment_extra_per_class: {{cruise: 40, ballistic: 40, civil_fw: 40, attack_fw: 40}}\\n\"\n", + " f\"min_confidence: 0.5\\nquiz_mix: {{confusing_pairs: 0.4, weak_conditions: 0.3, random: 0.3}}\\nhidden_layer: 96\")\n", + "print(\"\\n[재보정 파라미터(trn03_tuned_params.yaml 내용)]\\n\" + tuned)\n", + "\n", + "plt.figure(figsize=(9, 4))\n", + "x = np.arange(len(comp))\n", + "plt.bar(x - 0.2, comp[\"재보정 전\"], 0.4, label=\"before AAR\"); plt.bar(x + 0.2, comp[\"재보정 후\"], 0.4, label=\"after augmentation\")\n", + "plt.xticks(x, comp.index, rotation=30); plt.ylabel(\"accuracy\"); plt.legend(); plt.title(\"AAR retraining effect by condition (synthetic)\")\n", + "plt.tight_layout(); plt.show()\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 09 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + "[1. 무엇이 일어났나] 응답 1200건 중 오답 337건(28.1%)\n", + "다빈도 오답 쌍: {'cruise->ballistic': 90, 'civil_fw->attack_fw': 77, 'attack_fw->civil_fw': 66}\n", + "\n", + "[2. 왜 일어났나] 현행 모델 종합 정확도 0.770, 오류율 상위 조건: {'combo': 0.67, 'oblique': 0.62, 'occlude': 0.42, 'clutter': 0.38}\n", + "오류율 상위 클래스: {'cruise': 0.34, 'bird': 0.3, 'civil_fw': 0.27} -> 원인: 학습에 원거리·흐림·가림·측면 시점 영상이 없음\n", + "\n", + "[3. 무엇을 바꾸나] 증강 조건 ['combo', 'oblique', 'occlude', 'clutter', 'far'], 혼동 클래스 4종 각 40장 추가\n", + "종합 정확도 0.770 -> 0.873 (+10.3%p, 목표 +10%p: 달성)\n", + "AAR 등록 오류 152건 중 재발 67건(목표 0건: 미달), 기존 정답 중 새로 틀린 회귀 17건\n", + "재발 오류 중 신뢰도 0.5 미만이라 '교관 확인'으로 걸러지는 건 36건, 단정 오답으로 남는 건 31건\n", + " basic blur clutter combo far lowcon night noise oblique occlude offcenter\n", + "재보정 전 1.0 0.933 0.617 0.333 0.683 1.0 0.983 0.967 0.383 0.583 0.983\n", + "재보정 후 1.0 0.967 0.650 0.450 0.867 1.0 1.000 0.950 0.967 0.750 1.000\n", + "남은 재발 오류는 결합(combo)·구름 조건에 몰려 있어 교관 확인 대상으로 두고, 다음 주기에 지속 학습으로 보완한다.\n", + "\n", + "[재보정 파라미터(trn03_tuned_params.yaml 내용)]\n", + "augment_conditions: ['combo', 'oblique', 'occlude', 'clutter', 'far']\n", + "augment_extra_per_class: {cruise: 40, ballistic: 40, civil_fw: 40, attack_fw: 40}\n", + "min_confidence: 0.5\n", + "quiz_mix: {confusing_pairs: 0.4, weak_conditions: 0.3, random: 0.3}\n", + "hidden_layer: 96\n", + "```\n", + "
\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 10. 드론·미사일 실루엣 분류 학습앱 최종 패키징 및 부대 교육 배포 가이드 완성\n", + "**소요시간**: 약 3.5시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "드론·미사일 실루엣 분류 학습앱 최종 패키징 및 부대 교육 배포 가이드를 완성한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **실루엣 생성부터 전처리, 분류, 신뢰도 등급, 퀴즈 운영, 교관 해설 검토까지 학습앱 전체를 통합하고 부대 교육 배포 가이드를 완성한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **AAR 재보정값을 적용한 통합 코드를 한 번에 실행해 손상 문항이 자동 차단되는지 확인하고 유효성을 점검하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `src/main_trn03.py, README.md, docs/trn03_final_report.pdf(md)` 형태의 결과물을 완성한다.\n", + "\n", + "### 🔍 평가 및 검증 기준 (PASS 조건)\n", + "#### 미션 판정\n", + "\n", + "**평가 구분:** 필수\n", + "\n", + "**핵심 평가영역:** 군 실무 즉시 도입 가능성, 운용 신뢰도 및 전략적 기대효과\n", + "\n", + "#### 미션 PASS 조건\n", + "\n", + "- 정량 기준: End-to-End 통합 파이프라인 실행 성공률 100%, 테스트 커버리지 85% 이상\n", + "\n", + "\n", + "- 진행 기준: 원클릭 실행 스크립트 동작 확인 및 최종 발표 산출물 작성 완료\n", + "\n", + "\n", + "- 검증 방법: 최종 검증 위원회(평가관) 모의 시연 및 전체 기능 통합 검증\n", + "\n", + "\n", + "#### 채점자 판정 메모\n", + "\n", + "> 단순 코드 실행 여부뿐만 아니라 출력된 수치와 근거 자료가 군 실무 요구사항에 부합하는지 면밀히 확인한다.\n", + "\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "\"\"\"합성 실루엣 생성·검증부터 전처리, 분류(AAR 재보정값 적용), 신뢰도 등급, 학습 퀴즈 운영, 오답 해설 교관 검토 대기열까지 학습앱 전체를 한 파일로 통합 실행하고, 자체 점검·함수 커버리지·부대 교육 배포 브리핑 요약을 출력한다.\"\"\"\n", + "import hashlib\n", + "import json\n", + "import sys\n", + "import time\n", + "import types\n", + "import numpy as np\n", + "import pandas as pd\n", + "import matplotlib.pyplot as plt\n", + "from matplotlib.path import Path as MplPath\n", + "from scipy import ndimage\n", + "from scipy.spatial import ConvexHull\n", + "from sklearn.neural_network import MLPClassifier\n", + "from sklearn.preprocessing import StandardScaler\n", + "\n", + "# ---------------------------------------------------------------------------\n", + "# [교육용가상자료] 합성 실루엣 생성기 (실제 기체 사진·도면·제원 사용 안 함)\n", + "# 사각형·삼각형·원 같은 기본 도형을 조합한 추상 실루엣을 32x32 흑백 이미지로 그린다.\n", + "# 회전(다각도), 원근 축소(측면 시점), 크기(원거리), 흐림·잡음·저대비·구름·가림·야간 반전을 조절한다.\n", + "# ---------------------------------------------------------------------------\n", + "IMG = 32\n", + "CLASSES = [\"civil_quad\", \"civil_fw\", \"attack_fw\", \"cruise\", \"ballistic\", \"bird\"]\n", + "KOR = {\"civil_quad\": \"민간 쿼드콥터형\", \"civil_fw\": \"민간 고정익형\", \"attack_fw\": \"삼각익 자폭드론형\",\n", + " \"cruise\": \"순항미사일형\", \"ballistic\": \"탄도미사일형\", \"bird\": \"조류\"}\n", + "THREAT = {\"attack_fw\", \"cruise\", \"ballistic\"}\n", + "\n", + "\n", + "def _rect(x0, x1, y0, y1):\n", + " return [(x0, y0), (x1, y0), (x1, y1), (x0, y1)]\n", + "\n", + "\n", + "def _bar(p, q, w):\n", + " (x0, y0), (x1, y1) = p, q\n", + " n = np.array([y0 - y1, x1 - x0]) / np.hypot(x1 - x0, y1 - y0) * w / 2\n", + " return [(x0 + n[0], y0 + n[1]), (x1 + n[0], y1 + n[1]), (x1 - n[0], y1 - n[1]), (x0 - n[0], y0 - n[1])]\n", + "\n", + "\n", + "# 도형 정의(단위 좌표, 기수 방향 = +y). 모두 추상 도형이며 특정 기종을 본뜨지 않는다.\n", + "SHAPES = {\n", + " \"civil_quad\": {\"poly\": [_bar((-.55, -.55), (.55, .55), .1), _bar((-.55, .55), (.55, -.55), .1), _rect(-.15, .15, -.15, .15)],\n", + " \"circ\": [(-.55, -.55, .24), (.55, .55, .24), (-.55, .55, .24), (.55, -.55, .24)]},\n", + " \"civil_fw\": {\"poly\": [_rect(-.07, .07, -.8, .72), _rect(-.9, .9, .05, .25), _rect(-.33, .33, -.8, -.66)], \"circ\": []},\n", + " \"attack_fw\": {\"poly\": [[(0, .85), (.85, -.5), (.12, -.42), (0, -.6), (-.12, -.42), (-.85, -.5)]], \"circ\": []},\n", + " \"cruise\": {\"poly\": [_rect(-.07, .07, -.9, .7), [(-.07, .7), (0, .92), (.07, .7)], _rect(-.42, .42, -.05, .07),\n", + " [(-.07, -.9), (-.24, -.95), (-.07, -.74)], [(.07, -.9), (.24, -.95), (.07, -.74)]], \"circ\": []},\n", + " \"ballistic\": {\"poly\": [_rect(-.13, .13, -.85, .6), [(-.13, .6), (0, .95), (.13, .6)],\n", + " [(-.13, -.85), (-.34, -.95), (-.13, -.58)], [(.13, -.85), (.34, -.95), (.13, -.58)]], \"circ\": []},\n", + " \"bird\": {\"poly\": [[(-.9, .12), (-.5, .28), (-.12, .06), (0, .22), (.12, .06), (.5, .28), (.9, .12), (.5, .12),\n", + " (.12, -.1), (0, -.38), (-.12, -.1), (-.5, .12)]], \"circ\": []},\n", + "}\n", + "_S = 2\n", + "_g = (np.arange(IMG * _S) + 0.5) / _S\n", + "_GX, _GY = np.meshgrid(_g, _g)\n", + "_PTS = np.c_[_GX.ravel(), _GY.ravel()]\n", + "_PATHS = {c: [MplPath(np.array(p)) for p in s[\"poly\"]] for c, s in SHAPES.items()}\n", + "\n", + "\n", + "def draw(cls, rng, angle=None, scale=None, fore=None, blur=0.3, noise=4.0, contrast=1.0,\n", + " clutter=0.0, occlude=0.0, invert=False, shift=1.5):\n", + " \"\"\"클래스 1개 실루엣 이미지(uint8 32x32)를 그린다. 배경은 밝은 하늘, 비행체는 어두운 그림자.\"\"\"\n", + " angle = rng.uniform(0, 360) if angle is None else angle\n", + " scale = rng.uniform(.7, .95) if scale is None else scale\n", + " fore = rng.uniform(.85, 1.0) if fore is None else fore\n", + " th = np.radians(angle)\n", + " c0 = IMG / 2 + rng.uniform(-shift, shift, 2)\n", + " d = (_PTS - c0) / (scale * IMG / 2) # 이미지 좌표 -> 도형 좌표(역변환)\n", + " u = d[:, 0] * np.cos(th) + d[:, 1] * np.sin(th)\n", + " v = (-d[:, 0] * np.sin(th) + d[:, 1] * np.cos(th)) / fore # 원근 축소(기수 방향으로 짧아 보임)\n", + " uv = np.c_[u, -v]\n", + " m = np.zeros(len(uv), bool)\n", + " near = np.flatnonzero(np.abs(uv).max(axis=1) <= 1.0) # 도형이 있을 수 있는 영역만 검사(속도)\n", + " for p in _PATHS[cls]:\n", + " m[near] |= p.contains_points(uv[near])\n", + " for cx, cy, r in SHAPES[cls][\"circ\"]:\n", + " m |= (uv[:, 0] - cx) ** 2 + (uv[:, 1] - cy) ** 2 <= r * r\n", + " cov = m.reshape(IMG, _S, IMG, _S).mean(axis=(1, 3))\n", + " if occlude > 0: # 부분 가림(구름 조각)\n", + " h = int(IMG * np.sqrt(occlude)); y0, x0 = rng.integers(8, IMG - 8 - h // 2, 2)\n", + " cov[y0:y0 + h, x0:x0 + h] = 0\n", + " bg = rng.uniform(180, 220) + np.linspace(-10, 10, IMG)[:, None]\n", + " fg = rng.uniform(30, 60)\n", + " img = bg * (1 - cov) + fg * cov\n", + " if clutter > 0: # 구름·배경 얼룩\n", + " img -= clutter * 90 * np.clip(ndimage.gaussian_filter(rng.normal(0, 1, (IMG, IMG)), 3) * 4, 0, None)\n", + " if blur > 0:\n", + " img = ndimage.gaussian_filter(img, blur)\n", + " mean = img.mean()\n", + " img = mean + (img - mean) * contrast\n", + " img = img + rng.normal(0, noise, img.shape)\n", + " if invert: # 야간 열상(IR) 극성 반전\n", + " img = 255 - img\n", + " return np.clip(img, 0, 255).astype(np.uint8)\n", + "\n", + "\n", + "# 촬영 조건 프리셋: 기본 + 복합 위협 시나리오\n", + "CONDITIONS = {\n", + " \"basic\": {},\n", + " \"offcenter\": {\"shift\": 5.0, \"fore\": (.6, .85)},\n", + " \"far\": {\"scale\": (.32, .45), \"blur\": .6},\n", + " \"blur\": {\"blur\": (1.2, 1.8)},\n", + " \"lowcon\": {\"contrast\": (.2, .35)},\n", + " \"clutter\": {\"clutter\": .8},\n", + " \"occlude\": {\"occlude\": .08},\n", + " \"oblique\": {\"fore\": (.35, .6)},\n", + " \"night\": {\"invert\": True, \"noise\": 10.0},\n", + " \"noise\": {\"noise\": 28.0},\n", + " \"combo\": {\"scale\": (.4, .55), \"blur\": (.8, 1.1), \"noise\": 12.0, \"contrast\": (.6, .8)},\n", + "}\n", + "\n", + "\n", + "def draw_cond(cls, cond, rng):\n", + " kw = {}\n", + " for k, v in CONDITIONS[cond].items():\n", + " kw[k] = rng.uniform(*v) if isinstance(v, tuple) else v\n", + " return draw(cls, rng, **kw)\n", + "\n", + "\n", + "def make_dataset(n_per_class, seed, conds=(\"basic\",)):\n", + " \"\"\"(이미지 배열 N x 32 x 32, 정답 라벨, 조건) 반환. 조건은 conds에서 번갈아 고른다.\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " X, y, c = [], [], []\n", + " for cls in CLASSES:\n", + " for i in range(n_per_class):\n", + " cond = conds[i % len(conds)]\n", + " X.append(draw_cond(cls, cond, rng)); y.append(cls); c.append(cond)\n", + " return np.array(X), np.array(y), np.array(c)\n", + "# ---------------------------------------------------------------------------\n", + "\n", + "# ---- 전처리(배치 벡터화)·형태 특징 공통 함수 ----\n", + "OUT = 24 # 크기 정규화 결과(24x24)\n", + "NOISE_T = 12.0 # 잡음 추정값이 이보다 크면 3x3 중간값 필터 적용(악천후·센서 잡음)\n", + "FEATS = [\"elong\", \"fill\", \"solidity\", \"n_parts\", \"end_gap\", \"mid_bulge\", \"w_max\", \"compact\"]\n", + "\n", + "\n", + "def preprocess(images, noise_t=NOISE_T):\n", + " \"\"\"N장 일괄 처리: 결측 보정 -> 극성(야간 반전) 보정 -> 조건별 잡음 제거 -> 명암 대비 강화 -> 분할 -> 윤곽선 -> 방향·크기 정규화.\"\"\"\n", + " f = np.array(images, dtype=float).reshape(-1, IMG, IMG)\n", + " n = len(f)\n", + " bad = ~np.isfinite(f)\n", + " missing = bad.sum(axis=(1, 2))\n", + " if bad.any(): # 결측 픽셀: 영상 중앙값으로 채운 뒤 주변 3x3 중간값으로 보정\n", + " med = np.array([np.median(x[np.isfinite(x)]) if np.isfinite(x).any() else 128.0 for x in f])\n", + " f = np.where(bad, med[:, None, None], f)\n", + " f = np.where(bad, ndimage.median_filter(f, size=(1, 3, 3)), f)\n", + " border = np.concatenate([f[:, 0], f[:, -1], f[:, :, 0], f[:, :, -1]], axis=1)\n", + " inverted = np.median(border, axis=1) < 128 # 배경이 어두우면 야간 열상으로 보고 반전\n", + " f[inverted] = 255 - f[inverted]\n", + " med3 = ndimage.median_filter(f, size=(1, 3, 3))\n", + " noise = 1.4826 * np.median(np.abs(f - med3).reshape(n, -1), axis=1)\n", + " noisy = noise > noise_t\n", + " f[noisy] = med3[noisy]\n", + " lo, hi = np.percentile(f.reshape(n, -1), [1, 99], axis=1)\n", + " e = np.clip((f - lo[:, None, None]) / np.maximum(hi - lo, 1e-6)[:, None, None], 0, 1) # 명암 대비 강화\n", + " mask = e < _otsu(e)[:, None, None]\n", + " mask &= ndimage.uniform_filter(mask.astype(float), size=(1, 3, 3)) * 9 >= 2.5 # 고립 점 잡음 제거\n", + " edge = np.hypot(ndimage.sobel(e, axis=1), ndimage.sobel(e, axis=2)) # 윤곽선(Sobel)\n", + " ok = (mask.sum(axis=(1, 2)) >= 4) & (hi - lo > 8)\n", + " return {\"enh\": e, \"mask\": mask, \"edge\": edge / np.maximum(edge.max(axis=(1, 2)), 1e-6)[:, None, None],\n", + " \"norm\": _normalize(1 - e, mask, ok), \"inverted\": inverted, \"noise\": noise, \"denoised\": noisy,\n", + " \"missing\": missing, \"contrast\": hi - lo, \"ok\": ok}\n", + "\n", + "\n", + "def _otsu(e, bins=64):\n", + " n = len(e)\n", + " idx = np.minimum((e * bins).astype(int), bins - 1) + bins * np.arange(n)[:, None, None]\n", + " hist = np.bincount(idx.ravel(), minlength=bins * n).reshape(n, bins).astype(float)\n", + " centers = (np.arange(bins) + 0.5) / bins\n", + " w = np.cumsum(hist, axis=1); m = np.cumsum(hist * centers, axis=1)\n", + " tot, mt = w[:, -1:], m[:, -1:]\n", + " between = (mt * w / tot - m) ** 2 / np.maximum(w * (tot - w), 1)\n", + " return (np.argmax(between, axis=1) + 1) / bins\n", + "\n", + "\n", + "def _normalize(obj, mask, ok):\n", + " \"\"\"주축 방향을 세로로 돌리고 비행체 영역만 24x24로 다시 샘플링한다(회전·크기·위치 불변). 한 번의 보간으로 일괄 처리.\"\"\"\n", + " n = len(obj)\n", + " w = mask.astype(float)\n", + " m00 = np.maximum(w.sum(axis=(1, 2)), 1)\n", + " yy, xx = np.mgrid[0:IMG, 0:IMG].astype(float)\n", + " cy, cx = (w * yy).sum(axis=(1, 2)) / m00, (w * xx).sum(axis=(1, 2)) / m00\n", + " dy, dx = yy - cy[:, None, None], xx - cx[:, None, None]\n", + " mu20, mu02, mu11 = (w * dy * dy).sum(axis=(1, 2)), (w * dx * dx).sum(axis=(1, 2)), (w * dy * dx).sum(axis=(1, 2))\n", + " th = 0.5 * np.arctan2(2 * mu11, mu20 - mu02)\n", + " a = np.stack([np.cos(th), np.sin(th)], 1) # 주축(y, x)\n", + " b = np.stack([-np.sin(th), np.cos(th)], 1) # 부축\n", + " pa = np.where(mask, dy * a[:, 0, None, None] + dx * a[:, 1, None, None], 0)\n", + " pb = np.where(mask, dy * b[:, 0, None, None] + dx * b[:, 1, None, None], 0)\n", + " half = np.maximum(np.abs(pa).max(axis=(1, 2)), np.abs(pb).max(axis=(1, 2))) + 1.0\n", + " g = np.linspace(-1, 1, OUT)\n", + " U, V = np.meshgrid(g, g, indexing=\"ij\")\n", + " Y = cy[:, None, None] + half[:, None, None] * (U * a[:, 0, None, None] + V * b[:, 0, None, None])\n", + " X = cx[:, None, None] + half[:, None, None] * (U * a[:, 1, None, None] + V * b[:, 1, None, None])\n", + " N = np.broadcast_to(np.arange(n)[:, None, None], Y.shape)\n", + " out = ndimage.map_coordinates(obj * mask, [N, Y, X], order=1, cval=0.0)\n", + " skew = (out * (U ** 3)).sum(axis=(1, 2))\n", + " out[skew < 0] = out[skew < 0, ::-1] # 무게가 쏠린 쪽을 위로(180도 모호성 제거)\n", + " out[~ok] = 0\n", + " return np.clip(out, 0, 1)\n", + "\n", + "\n", + "def shape_features(mask):\n", + " \"\"\"분할 마스크 1장에서 회전에 무관한 형태 특징 8종을 계산한다.\"\"\"\n", + " if mask.sum() < 4:\n", + " return dict.fromkeys(FEATS, 0.0)\n", + " yx = np.argwhere(mask).astype(float)\n", + " c = yx - yx.mean(axis=0)\n", + " val, vec = np.linalg.eigh(np.cov(c.T) + 1e-6 * np.eye(2))\n", + " t, s = c @ vec[:, 1], c @ vec[:, 0]\n", + " length, width = np.ptp(t) + 1, np.ptp(s) + 1\n", + " corners = np.vstack([yx + d for d in ([.5, .5], [.5, -.5], [-.5, .5], [-.5, -.5])])\n", + " try:\n", + " hull = ConvexHull(corners).volume\n", + " except Exception:\n", + " hull = float(mask.sum())\n", + " bins = np.digitize(t, np.linspace(t.min(), t.max() + 1e-6, 4)[1:-1])\n", + " w = np.array([np.ptp(s[bins == k]) + 1 if (bins == k).any() else 0 for k in range(3)]) / length\n", + " boundary = mask & ~ndimage.binary_erosion(mask)\n", + " return {\"elong\": float(np.sqrt(val[1] / max(val[0], 1e-6))), \"fill\": float(mask.sum() / (length * width)),\n", + " \"solidity\": float(mask.sum() / max(hull, 1)), \"n_parts\": float(ndimage.label(mask)[1]),\n", + " \"end_gap\": float(abs(w[0] - w[2])), \"mid_bulge\": float(w[1] - max(w[0], w[2])),\n", + " \"w_max\": float(w.max()), \"compact\": float(boundary.sum() / np.sqrt(mask.sum()))}\n", + "\n", + "\n", + "def feature_matrix(images):\n", + " \"\"\"전처리 결과, 형태 특징 행렬(N x 8), 정규화 이미지 행렬(N x 576)을 돌려준다.\"\"\"\n", + " P = preprocess(images)\n", + " F = np.array([[d[k] for k in FEATS] for d in map(shape_features, P[\"mask\"])])\n", + " return P, F, P[\"norm\"].reshape(len(F), -1)\n", + "\n", + "CONFIG = {\"augment_conditions\": [\"combo\", \"oblique\", \"occlude\", \"clutter\", \"far\"], \"extra_classes\": [\"cruise\", \"ballistic\", \"civil_fw\", \"attack_fw\"],\n", + " \"min_confidence\": 0.5, \"hidden_layer\": 96} # 미션 9 재보정값\n", + "CALLED = set()\n", + "\n", + "\n", + "def _tracer(frame, event, arg):\n", + " if event == \"call\" and frame.f_globals is globals():\n", + " CALLED.add(frame.f_code.co_name)\n", + "\n", + "\n", + "def validate(img):\n", + " a = np.asarray(img, dtype=float) if img is not None else None\n", + " return a is not None and a.shape == (IMG, IMG) and np.isfinite(a).all() and a.min() >= 0 and a.max() <= 255\n", + "\n", + "\n", + "def train():\n", + " X1, y1, _ = make_dataset(80, 5, tuple(CONDITIONS))\n", + " X2, y2, _ = make_dataset(40, 6, tuple(CONFIG[\"augment_conditions\"]))\n", + " rng = np.random.default_rng(7)\n", + " ex = CONFIG[\"extra_classes\"] * 20\n", + " X3 = np.array([draw_cond(c, CONFIG[\"augment_conditions\"][i % 5], rng) for i, c in enumerate(ex)])\n", + " X, y = np.r_[X1, X2, X3], np.r_[y1, y2, ex]\n", + " P, F, N = feature_matrix(X)\n", + " sc = StandardScaler().fit(F)\n", + " m = MLPClassifier((CONFIG[\"hidden_layer\"],), max_iter=200, early_stopping=True, random_state=0)\n", + " return m.fit(np.c_[N, sc.transform(F) * 0.3], [CLASSES.index(v) for v in y]), sc\n", + "\n", + "\n", + "def classify(m, sc, images):\n", + " P, F, N = feature_matrix(images)\n", + " prob = m.predict_proba(np.c_[N, sc.transform(F) * 0.3])\n", + " pred = np.array(CLASSES)[prob.argmax(1)]\n", + " conf = prob.max(1) * np.where(P[\"ok\"], 1, 0)\n", + " return pred, conf, np.where(conf >= 0.8, \"High\", np.where(conf >= CONFIG[\"min_confidence\"], \"Medium\", \"Low\"))\n", + "\n", + "\n", + "class Queue:\n", + " def __init__(self):\n", + " self.items, self.log = [], []\n", + "\n", + " def submit(self, rec):\n", + " prev = self.log[-1][\"hash\"] if self.log else \"GENESIS\"\n", + " body = {\"event\": \"SUBMIT\", **rec}\n", + " self.log.append({**body, \"prev\": prev, \"hash\": hashlib.sha256((prev + json.dumps(body, sort_keys=True)).encode()).hexdigest()})\n", + " self.items.append({**rec, \"status\": \"PENDING\"})\n", + "\n", + "\n", + "sys.settrace(_tracer)\n", + "t_start = time.perf_counter()\n", + "# 1. 학습\n", + "model, scaler = train()\n", + "# 2. 운용 데이터(교육장 배포 문항 300장) 검증 -> 손상 3장 차단\n", + "X, y, cond = make_dataset(50, 41, tuple(CONDITIONS))\n", + "items = list(X)\n", + "items[5], items[77], items[150] = None, np.zeros((20, 20)), np.full((IMG, IMG), np.nan)\n", + "ok_idx = [i for i, im in enumerate(items) if validate(im)]\n", + "rejected = len(items) - len(ok_idx)\n", + "Xo, yo = np.array([items[i] for i in ok_idx]), y[ok_idx]\n", + "t0 = time.perf_counter()\n", + "pred, conf, grade = classify(model, scaler, Xo)\n", + "ms = (time.perf_counter() - t0) * 1000 / len(Xo)\n", + "judged = grade != \"Low\"\n", + "acc_all = (pred == yo).mean()\n", + "acc_judged = (pred[judged] == yo[judged]).mean()\n", + "# 3. 퀴즈 운영(가상 장병 20명 x 10문항) -> 오답은 해설 검토 대기열(자동 공개 없음)\n", + "rng = np.random.default_rng(9)\n", + "q, answers = Queue(), []\n", + "CONFUSE = {\"civil_fw\": \"attack_fw\", \"attack_fw\": \"civil_fw\", \"cruise\": \"ballistic\", \"ballistic\": \"cruise\", \"bird\": \"civil_fw\", \"civil_quad\": \"civil_fw\"}\n", + "for s in range(20):\n", + " for k in rng.choice(len(yo), 10, replace=False):\n", + " ans = yo[k] if rng.random() < 0.75 else CONFUSE[yo[k]]\n", + " answers.append(ans == yo[k])\n", + " if ans != yo[k]:\n", + " q.submit({\"learner\": f\"L{s + 1:02d}\", \"answer\": str(yo[k]), \"chosen\": str(ans), \"ai_grade\": str(grade[k])})\n", + "sys.settrace(None)\n", + "total = time.perf_counter() - t_start\n", + "\n", + "defined = {n for n, f in globals().items() if isinstance(f, types.FunctionType) and f.__module__ == __name__ and not n.startswith(\"_tracer\")}\n", + "defined |= {\"submit\", \"__init__\"}\n", + "coverage = len(CALLED & defined) / len(defined)\n", + "chk = {\n", + " \"손상 데이터 3건 차단\": rejected == 3,\n", + " \"판별 대상 297장 처리\": len(Xo) == 297,\n", + " \"전체 분류 정확도 85% 이상\": acc_all >= 0.85,\n", + " \"판별(Medium 이상) 정확도 95% 이상\": acc_judged >= 0.95,\n", + " \"High 등급 정확도 >= Medium 등급 정확도\": (pred == yo)[grade == \"High\"].mean() >= (pred == yo)[grade == \"Medium\"].mean(),\n", + " \"장당 처리 200 ms 이내\": ms <= 200,\n", + " \"오답 해설 자동 공개 0건\": all(i[\"status\"] == \"PENDING\" for i in q.items),\n", + " \"감사 로그 건수 일치\": len(q.log) == len(q.items) == sum(not a for a in answers),\n", + " \"함수 커버리지 85% 이상\": coverage >= 0.85,\n", + " \"전체 실행 60초 이내\": total < 60,\n", + "}\n", + "for k, v in chk.items():\n", + " print(f\"[{'PASS' if v else 'FAIL'}] {k}\")\n", + "passed = sum(chk.values())\n", + "print(f\"자체 점검 {passed}/{len(chk)} 통과, 함수 커버리지 {coverage:.0%} ({len(CALLED & defined)}/{len(defined)}), 통합 실행 {'성공' if passed == len(chk) else '점검 필요'}\")\n", + "\n", + "fig, ax = plt.subplots(2, 2, figsize=(11, 8))\n", + "for j in range(6):\n", + " sub = ax[0, 0].inset_axes([j % 3 / 3, 1 - (j // 3 + 1) / 2, 0.32, 0.48])\n", + " i = np.flatnonzero(yo == CLASSES[j])[0]\n", + " sub.imshow(Xo[i], cmap=\"gray\"); sub.set_title(f\"{CLASSES[j]}\\n{pred[i]} ({grade[i]})\", fontsize=7); sub.axis(\"off\")\n", + "ax[0, 0].axis(\"off\"); ax[0, 0].set_title(\"Sample items: true / predicted (grade)\")\n", + "g = pd.Series(grade).value_counts().reindex([\"High\", \"Medium\", \"Low\"]).fillna(0)\n", + "ax[0, 1].bar(g.index, g.values, color=[\"tab:green\", \"tab:orange\", \"tab:red\"]); ax[0, 1].set_title(\"Confidence grade of deployed items\")\n", + "acc_c = pd.Series(pred == yo).groupby(cond[ok_idx]).mean()\n", + "ax[1, 0].bar(acc_c.index, acc_c.values); ax[1, 0].tick_params(axis=\"x\", rotation=40); ax[1, 0].set_title(\"AI accuracy by condition\")\n", + "ax[1, 1].axis(\"off\")\n", + "ax[1, 1].text(0, .9, f\"Items {len(Xo)} (+{rejected} rejected)\\nAI accuracy {acc_all:.1%}\\nLearner acc {np.mean(answers):.1%}\\n\"\n", + " f\"Review queue {len(q.items)} pending\\nCoverage {coverage:.0%}\\nSelf-check {passed}/{len(chk)}\", family=\"monospace\", fontsize=12, va=\"top\")\n", + "plt.suptitle(\"TRN03 silhouette ID app - integrated run (training data only)\"); plt.tight_layout(); plt.show()\n", + "\n", + "top = pd.Series([f\"{i['answer']}->{i['chosen']}\" for i in q.items]).value_counts().head(2)\n", + "print(f\"\"\"\n", + "[부대 교육 배포 브리핑 요약 - 교육용가상자료]\n", + "1. 구성: 합성 추상 실루엣 6종(위협 3종), 문항 {len(items)}장 중 {len(Xo)}장 배포, 손상 {rejected}장 자동 차단\n", + "2. AI 성능: 전체 정확도 {acc_all:.1%}, 신뢰도 Medium 이상 판별 정확도 {acc_judged:.1%}, Low {int((~judged).sum())}장은 교관 확인, 장당 {ms:.2f} ms\n", + "3. 학습 운영: 가상 장병 20명 정답률 {np.mean(answers):.1%}, 다빈도 오답 {', '.join(f'{k}({v})' for k, v in top.items())}\n", + "4. 교관 검토: 오답 해설 {len(q.items)}건 검토 대기, 자동 공개 0건, 감사 로그 {len(q.log)}건\n", + "5. 운용 유의: 실루엣은 기본 도형으로 만든 학습용 추상 이미지이며 실제 기종 식별 교범을 대체하지 않는다. 실제 표적 판단은 정식 교육과 지휘계통에 따른다.\n", + "6. 확장 로드맵: 학습 기록 내보내기 API(문항 ID·응답·소요시간), 인접 교육 체계와 문항 은행 공유 인터페이스\"\"\")\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 10 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + "[PASS] 손상 데이터 3건 차단\n", + "[PASS] 판별 대상 297장 처리\n", + "[PASS] 전체 분류 정확도 85% 이상\n", + "[PASS] 판별(Medium 이상) 정확도 95% 이상\n", + "[PASS] High 등급 정확도 >= Medium 등급 정확도\n", + "[PASS] 장당 처리 200 ms 이내\n", + "[PASS] 오답 해설 자동 공개 0건\n", + "[PASS] 감사 로그 건수 일치\n", + "[PASS] 함수 커버리지 85% 이상\n", + "[PASS] 전체 실행 60초 이내\n", + "자체 점검 10/10 통과, 함수 커버리지 87% (13/15), 통합 실행 성공\n", + "\n", + "[부대 교육 배포 브리핑 요약 - 교육용가상자료]\n", + "1. 구성: 합성 추상 실루엣 6종(위협 3종), 문항 300장 중 297장 배포, 손상 3장 자동 차단\n", + "2. AI 성능: 전체 정확도 85.2%, 신뢰도 Medium 이상 판별 정확도 96.7%, Low 55장은 교관 확인, 장당 1.47 ms\n", + "3. 학습 운영: 가상 장병 20명 정답률 75.0%, 다빈도 오답 ballistic->cruise(12), civil_quad->civil_fw(11)\n", + "4. 교관 검토: 오답 해설 50건 검토 대기, 자동 공개 0건, 감사 로그 50건\n", + "5. 운용 유의: 실루엣은 기본 도형으로 만든 학습용 추상 이미지이며 실제 기종 식별 교범을 대체하지 않는다. 실제 표적 판단은 정식 교육과 지휘계통에 따른다.\n", + "6. 확장 로드맵: 학습 기록 내보내기 API(문항 ID·응답·소요시간), 인접 교육 체계와 문항 은행 공유 인터페이스\n", + "```\n", + "
\n" + ] + } + ], + "metadata": { + "colab": { + "provenance": [] + }, + "kernelspec": { + "display_name": "Python 3", + "name": "python3" + }, + "language_info": { + "name": "python" + } + }, + "nbformat": 4, + "nbformat_minor": 0 +} \ No newline at end of file diff --git a/uploads/TRN03/TRN03_student.ipynb b/uploads/TRN03/TRN03_student.ipynb new file mode 100644 index 0000000..78d10f2 --- /dev/null +++ b/uploads/TRN03/TRN03_student.ipynb @@ -0,0 +1,3997 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# 🎯 [TRN03] 드론·미사일 실루엣 이미지 분류 학습앱\n", + "### Google Colab 실습 환경 (교육·훈련 / Level 2)\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/TRN03/TRN03_solution.ipynb)\n", + "\n", + "---\n", + "\n", + "## 📌 1. 프로젝트 개요 및 배경\n", + "\n", + "| 항목 | 내용 |\n", + "| :--- | :--- |\n", + "| **과정 분류** | 교육·훈련 (Level 2) |\n", + "| **학습 소요 시간** | 10개 미션 (5일 / 총 27.5시간) |\n", + "| **환경 사양** | Python 3.10+, numpy, pandas, matplotlib, scipy, scikit-learn |\n", + "\n", + "### 📖 프로젝트 목표\n", + "러시아-우크라이나 전쟁과 미국-이란 분쟁 사례에서는 소형 드론, 자폭 드론, 순항·탄도미사일이 함께 쓰이면서, 장병이 하늘의 비행체 형상을 보고 위협 여부를 빨리 구분하는 능력이 중요한 기본 전투력이 되었다. 본 프로젝트에서는 실제 기체 사진 대신 사각형·삼각형·원 같은 기본 도형으로 만든 교육용 합성 실루엣을 코드로 생성해, 레이블 체계 정의, 명암·윤곽선·크기 정규화 전처리, 형태 특징 기반 Baseline, 다양한 각도·원거리·흐림 조건의 신경망 분류 고도화, 유사 형상 오분류 방지 신뢰도 점수, 학습 앱 UI, 교관 해설 검토(HITL), 대량 저품질 이미지 부하 시험, AAR 증강 재학습, 최종 패키징까지 실루엣 식별 학습앱을 단계별로 구현한다. 최종 목표는 AI가 문항 출제와 참고 판정을 돕되, 불확실한 판정과 오답 해설은 반드시 교관이 확인한 뒤 장병에게 제공되는 학습 체계를 만드는 것이다.\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. 전술 드론 및 순항/탄도미사일 실루엣 이미지(흑백/원거리/다각도) 데이터셋 구축\n", + "**소요시간**: 약 1.5시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "전술 드론 및 순항/탄도미사일 실루엣 이미지 데이터셋을 구축하고 레이블을 정의한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **전술 드론과 순항·탄도미사일 등 비행체 실루엣 이미지 학습 데이터의 레이블 체계와 메타데이터 스키마를 정의하고, 잘못된 데이터를 걸러내는 검증기를 만든다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **민간 쿼드콥터형·민간 고정익형·삼각익 자폭드론형·순항미사일형·탄도미사일형·조류 6종 레이블과 위협 여부, 촬영 조건(기본·원거리·측면·야간·흐림), 센서(EO/IR), 전술 파라미터(기상, 거리 등급, 위치오차, 통신지연), 교관 검수 상태 필드 정의하고 검증하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `data/schema/trn03_input_schema.json 및 docs/trn03_data_spec.md` 형태의 결과물을 완성한다.\n", + "\n", + "### 🔍 평가 및 검증 기준 (PASS 조건)\n", + "#### 미션 판정\n", + "\n", + "**평가 구분:** 필수\n", + "\n", + "**핵심 평가영역:** 교육·훈련 실무 및 공통·복합 사례 (러시아-우크라이나·미국-이란) 전장 환경과의 군사적 부합성\n", + "\n", + "#### 미션 PASS 조건\n", + "\n", + "- 정량 기준: 스키마 필드 정의 완성도 100%, 샘플 데이터 검증 오류 0건\n", + "\n", + "\n", + "- 진행 기준: 입력 데이터 유효성 검증 스크립트 통과 및 명세서 문서화 완료\n", + "\n", + "\n", + "- 검증 방법: JSON Schema 검증 도구 및 샘플 데이터 파싱 단위 테스트\n", + "\n", + "\n", + "#### 채점자 판정 메모\n", + "\n", + "> 단순 코드 실행 여부뿐만 아니라 출력된 수치와 근거 자료가 군 실무 요구사항에 부합하는지 면밀히 확인한다.\n", + "\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "\"\"\"전술 드론·순항/탄도미사일 등 비행체 실루엣 이미지(흑백·원거리·다각도) 학습 데이터셋의 레이블 체계와 메타데이터 스키마를 정의하고, 합성 실루엣 샘플을 만들어 검증한다.\"\"\"\n", + "import json\n", + "import re\n", + "import numpy as np\n", + "import pandas as pd\n", + "import matplotlib.pyplot as plt\n", + "from matplotlib.path import Path as MplPath\n", + "from scipy import ndimage\n", + "\n", + "# ---------------------------------------------------------------------------\n", + "# [교육용가상자료] 합성 실루엣 생성기 (실제 기체 사진·도면·제원 사용 안 함)\n", + "# 사각형·삼각형·원 같은 기본 도형을 조합한 추상 실루엣을 32x32 흑백 이미지로 그린다.\n", + "# 회전(다각도), 원근 축소(측면 시점), 크기(원거리), 흐림·잡음·저대비·구름·가림·야간 반전을 조절한다.\n", + "# ---------------------------------------------------------------------------\n", + "IMG = 32\n", + "CLASSES = [\"civil_quad\", \"civil_fw\", \"attack_fw\", \"cruise\", \"ballistic\", \"bird\"]\n", + "KOR = {\"civil_quad\": \"민간 쿼드콥터형\", \"civil_fw\": \"민간 고정익형\", \"attack_fw\": \"삼각익 자폭드론형\",\n", + " \"cruise\": \"순항미사일형\", \"ballistic\": \"탄도미사일형\", \"bird\": \"조류\"}\n", + "THREAT = {\"attack_fw\", \"cruise\", \"ballistic\"}\n", + "\n", + "\n", + "def _rect(x0, x1, y0, y1):\n", + " # [TODO : 학생 실습 1] 전술 드론 및 순항/탄도미사일 실루엣 이미지(흑백/원거리/다각도) 데이터셋 구축 관련 핵심 로직을 구현하세요.\n", + " # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n", + " # <<< 여기에 코드를 작성하세요 >>>\n", + " pass # 작성 후 pass를 제거하세요.\n", + "def _bar(p, q, w):\n", + " # [TODO : 학생 실습 1] 전술 드론 및 순항/탄도미사일 실루엣 이미지(흑백/원거리/다각도) 데이터셋 구축 관련 핵심 로직을 구현하세요.\n", + " # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n", + " # <<< 여기에 코드를 작성하세요 >>>\n", + " pass # 작성 후 pass를 제거하세요.\n", + "SHAPES = {\n", + " \"civil_quad\": {\"poly\": [_bar((-.55, -.55), (.55, .55), .1), _bar((-.55, .55), (.55, -.55), .1), _rect(-.15, .15, -.15, .15)],\n", + " \"circ\": [(-.55, -.55, .24), (.55, .55, .24), (-.55, .55, .24), (.55, -.55, .24)]},\n", + " \"civil_fw\": {\"poly\": [_rect(-.07, .07, -.8, .72), _rect(-.9, .9, .05, .25), _rect(-.33, .33, -.8, -.66)], \"circ\": []},\n", + " \"attack_fw\": {\"poly\": [[(0, .85), (.85, -.5), (.12, -.42), (0, -.6), (-.12, -.42), (-.85, -.5)]], \"circ\": []},\n", + " \"cruise\": {\"poly\": [_rect(-.07, .07, -.9, .7), [(-.07, .7), (0, .92), (.07, .7)], _rect(-.42, .42, -.05, .07),\n", + " [(-.07, -.9), (-.24, -.95), (-.07, -.74)], [(.07, -.9), (.24, -.95), (.07, -.74)]], \"circ\": []},\n", + " \"ballistic\": {\"poly\": [_rect(-.13, .13, -.85, .6), [(-.13, .6), (0, .95), (.13, .6)],\n", + " [(-.13, -.85), (-.34, -.95), (-.13, -.58)], [(.13, -.85), (.34, -.95), (.13, -.58)]], \"circ\": []},\n", + " \"bird\": {\"poly\": [[(-.9, .12), (-.5, .28), (-.12, .06), (0, .22), (.12, .06), (.5, .28), (.9, .12), (.5, .12),\n", + " (.12, -.1), (0, -.38), (-.12, -.1), (-.5, .12)]], \"circ\": []},\n", + "}\n", + "_S = 2\n", + "_g = (np.arange(IMG * _S) + 0.5) / _S\n", + "_GX, _GY = np.meshgrid(_g, _g)\n", + "_PTS = np.c_[_GX.ravel(), _GY.ravel()]\n", + "_PATHS = {c: [MplPath(np.array(p)) for p in s[\"poly\"]] for c, s in SHAPES.items()}\n", + "\n", + "\n", + "def draw(cls, rng, angle=None, scale=None, fore=None, blur=0.3, noise=4.0, contrast=1.0,\n", + " clutter=0.0, occlude=0.0, invert=False, shift=1.5):\n", + " \"\"\"클래스 1개 실루엣 이미지(uint8 32x32)를 그린다. 배경은 밝은 하늘, 비행체는 어두운 그림자.\"\"\"\n", + " angle = rng.uniform(0, 360) if angle is None else angle\n", + " scale = rng.uniform(.7, .95) if scale is None else scale\n", + " fore = rng.uniform(.85, 1.0) if fore is None else fore\n", + " th = np.radians(angle)\n", + " c0 = IMG / 2 + rng.uniform(-shift, shift, 2)\n", + " d = (_PTS - c0) / (scale * IMG / 2) # 이미지 좌표 -> 도형 좌표(역변환)\n", + " u = d[:, 0] * np.cos(th) + d[:, 1] * np.sin(th)\n", + " v = (-d[:, 0] * np.sin(th) + d[:, 1] * np.cos(th)) / fore # 원근 축소(기수 방향으로 짧아 보임)\n", + " uv = np.c_[u, -v]\n", + " m = np.zeros(len(uv), bool)\n", + " near = np.flatnonzero(np.abs(uv).max(axis=1) <= 1.0) # 도형이 있을 수 있는 영역만 검사(속도)\n", + " for p in _PATHS[cls]:\n", + " m[near] |= p.contains_points(uv[near])\n", + " for cx, cy, r in SHAPES[cls][\"circ\"]:\n", + " m |= (uv[:, 0] - cx) ** 2 + (uv[:, 1] - cy) ** 2 <= r * r\n", + " cov = m.reshape(IMG, _S, IMG, _S).mean(axis=(1, 3))\n", + " if occlude > 0: # 부분 가림(구름 조각)\n", + " h = int(IMG * np.sqrt(occlude)); y0, x0 = rng.integers(8, IMG - 8 - h // 2, 2)\n", + " cov[y0:y0 + h, x0:x0 + h] = 0\n", + " bg = rng.uniform(180, 220) + np.linspace(-10, 10, IMG)[:, None]\n", + " fg = rng.uniform(30, 60)\n", + " img = bg * (1 - cov) + fg * cov\n", + " if clutter > 0: # 구름·배경 얼룩\n", + " img -= clutter * 90 * np.clip(ndimage.gaussian_filter(rng.normal(0, 1, (IMG, IMG)), 3) * 4, 0, None)\n", + " if blur > 0:\n", + " img = ndimage.gaussian_filter(img, blur)\n", + " mean = img.mean()\n", + " img = mean + (img - mean) * contrast\n", + " img = img + rng.normal(0, noise, img.shape)\n", + " if invert: # 야간 열상(IR) 극성 반전\n", + " img = 255 - img\n", + " return np.clip(img, 0, 255).astype(np.uint8)\n", + "\n", + "\n", + "# 촬영 조건 프리셋: 기본 + 복합 위협 시나리오\n", + "CONDITIONS = {\n", + " \"basic\": {},\n", + " \"offcenter\": {\"shift\": 5.0, \"fore\": (.6, .85)},\n", + " \"far\": {\"scale\": (.32, .45), \"blur\": .6},\n", + " \"blur\": {\"blur\": (1.2, 1.8)},\n", + " \"lowcon\": {\"contrast\": (.2, .35)},\n", + " \"clutter\": {\"clutter\": .8},\n", + " \"occlude\": {\"occlude\": .08},\n", + " \"oblique\": {\"fore\": (.35, .6)},\n", + " \"night\": {\"invert\": True, \"noise\": 10.0},\n", + " \"noise\": {\"noise\": 28.0},\n", + " \"combo\": {\"scale\": (.4, .55), \"blur\": (.8, 1.1), \"noise\": 12.0, \"contrast\": (.6, .8)},\n", + "}\n", + "\n", + "\n", + "def draw_cond(cls, cond, rng):\n", + " kw = {}\n", + " for k, v in CONDITIONS[cond].items():\n", + " kw[k] = rng.uniform(*v) if isinstance(v, tuple) else v\n", + " return draw(cls, rng, **kw)\n", + "\n", + "\n", + "def make_dataset(n_per_class, seed, conds=(\"basic\",)):\n", + " \"\"\"(이미지 배열 N x 32 x 32, 정답 라벨, 조건) 반환. 조건은 conds에서 번갈아 고른다.\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " X, y, c = [], [], []\n", + " for cls in CLASSES:\n", + " for i in range(n_per_class):\n", + " cond = conds[i % len(conds)]\n", + " X.append(draw_cond(cls, cond, rng)); y.append(cls); c.append(cond)\n", + " return np.array(X), np.array(y), np.array(c)\n", + "# ---------------------------------------------------------------------------\n", + "\n", + "# 1) 스키마 정의: 이미지 규격, 레이블(클래스·위협 여부), 촬영 조건, 전술 파라미터, 검수 상태\n", + "SCHEMA = {\n", + " \"fields\": {\n", + " \"image_id\": {\"type\": str, \"pattern\": r\"^SIL\\d{5}$\"},\n", + " \"class\": {\"type\": str, \"enum\": CLASSES},\n", + " \"threat\": {\"type\": bool},\n", + " \"condition\": {\"type\": str, \"enum\": list(CONDITIONS)},\n", + " \"view\": {\"type\": str, \"enum\": [\"top\", \"oblique\"]},\n", + " \"source\": {\"type\": str, \"enum\": [\"synthetic\"]},\n", + " \"reviewed_by\": {\"type\": str, \"pattern\": r\"^INS\\d{2}$\"},\n", + " \"review_status\": {\"type\": str, \"enum\": [\"draft\", \"approved\"]},\n", + " },\n", + " \"sensor\": {\"type\": {\"type\": str, \"enum\": [\"EO\", \"IR\"]}, \"resolution\": {\"type\": list, \"length\": 2}},\n", + " \"tactical\": {\n", + " \"weather\": {\"type\": str, \"enum\": [\"clear\", \"haze\", \"cloud\", \"night\", \"rain\"]},\n", + " \"range_level\": {\"type\": str, \"enum\": [\"near\", \"mid\", \"far\"]},\n", + " \"position_error_m\": {\"type\": (int, float), \"min\": 0, \"max\": 500},\n", + " \"comm_delay_ms\": {\"type\": (int, float), \"min\": 0, \"max\": 10000},\n", + " },\n", + "}\n", + "\n", + "\n", + "def check(name, value, rule):\n", + " if not isinstance(value, rule[\"type\"]) or (isinstance(value, bool) and rule[\"type\"] is not bool):\n", + " return [f\"{name}: 자료형 오류({type(value).__name__})\"]\n", + " errs = []\n", + " if \"enum\" in rule and value not in rule[\"enum\"]:\n", + " errs.append(f\"{name}: 허용되지 않은 값 '{value}'\")\n", + " if \"min\" in rule and value < rule[\"min\"]:\n", + " errs.append(f\"{name}: 최소값 {rule['min']} 미만\")\n", + " if \"max\" in rule and value > rule[\"max\"]:\n", + " errs.append(f\"{name}: 최대값 {rule['max']} 초과\")\n", + " if \"length\" in rule and len(value) != rule[\"length\"]:\n", + " errs.append(f\"{name}: 길이 {rule['length']} 필요\")\n", + " if \"pattern\" in rule and not re.match(rule[\"pattern\"], value):\n", + " errs.append(f\"{name}: 형식 불일치 '{value}'\")\n", + " return errs\n", + "\n", + "\n", + "def validate(rec):\n", + " errs = []\n", + " for k, rule in SCHEMA[\"fields\"].items():\n", + " errs += [f\"{k}: 필수 필드 누락\"] if k not in rec else check(k, rec[k], rule)\n", + " for g in (\"sensor\", \"tactical\"):\n", + " for k, rule in SCHEMA[g].items():\n", + " errs += [f\"{g}.{k}: 필수 필드 누락\"] if k not in rec.get(g, {}) else check(f\"{g}.{k}\", rec[g][k], rule)\n", + " px = np.asarray(rec.get(\"pixels\", []), dtype=float)\n", + " if px.shape != (IMG, IMG):\n", + " errs.append(f\"pixels: 크기 {IMG}x{IMG} 필요(입력 {px.shape})\")\n", + " elif not np.isfinite(px).all() or px.min() < 0 or px.max() > 255:\n", + " errs.append(\"pixels: 값 범위(0~255) 또는 결측 오류\")\n", + " if rec.get(\"class\") in CLASSES and rec.get(\"threat\") != (rec[\"class\"] in THREAT):\n", + " errs.append(\"threat: 클래스와 위협 여부 불일치\")\n", + " return errs\n", + "\n", + "\n", + "# 2) [교육용가상자료] 클래스별 10장, 총 60장 샘플 생성(기본 + 원거리 + 측면 + 야간 조건)\n", + "rng = np.random.default_rng(1)\n", + "conds = [\"basic\", \"far\", \"oblique\", \"night\", \"blur\"]\n", + "weather = {\"basic\": \"clear\", \"far\": \"haze\", \"oblique\": \"clear\", \"night\": \"night\", \"blur\": \"rain\"}\n", + "records = []\n", + "for cls in CLASSES:\n", + " for i in range(10):\n", + " cond = conds[i % len(conds)]\n", + " records.append({\"image_id\": f\"SIL{len(records):05d}\", \"class\": cls, \"threat\": cls in THREAT,\n", + " \"condition\": cond, \"view\": \"oblique\" if cond == \"oblique\" else \"top\", \"source\": \"synthetic\",\n", + " \"reviewed_by\": \"INS01\", \"review_status\": \"approved\",\n", + " \"sensor\": {\"type\": \"IR\" if cond == \"night\" else \"EO\", \"resolution\": [IMG, IMG]},\n", + " \"tactical\": {\"weather\": weather[cond], \"range_level\": \"far\" if cond == \"far\" else \"mid\",\n", + " \"position_error_m\": float(round(rng.uniform(5, 60), 1)), \"comm_delay_ms\": int(rng.integers(20, 300))},\n", + " \"pixels\": draw_cond(cls, cond, rng).tolist()})\n", + "\n", + "ok = sum(1 for r in records if not validate(r))\n", + "print(f\"정상 샘플 {len(records)}건 중 검증 통과 {ok}건, 오류 {len(records) - ok}건 (목표 오류 0건: {'달성' if ok == len(records) else '미달'})\")\n", + "\n", + "# 3) 오류 주입 시험\n", + "bad = json.loads(json.dumps(records[0]))\n", + "bad[\"image_id\"] = \"IMG-7\"\n", + "bad[\"class\"] = \"ufo\"\n", + "bad[\"threat\"] = \"yes\"\n", + "del bad[\"tactical\"][\"comm_delay_ms\"]\n", + "bad[\"pixels\"] = bad[\"pixels\"][:20]\n", + "bad2 = json.loads(json.dumps(records[25]))\n", + "bad2[\"threat\"] = False # 자폭드론형인데 비위협으로 잘못 표기\n", + "bad2[\"pixels\"][3][3] = 300\n", + "print(\"\\n[오류 주입 결과]\")\n", + "for e in validate(bad) + validate(bad2):\n", + " print(\" -\", e)\n", + "\n", + "# 4) 레이블 정의서와 필드 명세\n", + "print(\"\\n[레이블 정의(추상 도형 기준)]\")\n", + "desc = {\"civil_quad\": \"십자 팔 + 원형 회전날개 4개\", \"civil_fw\": \"직선 주익 + 꼬리날개\", \"attack_fw\": \"삼각형(델타) 날개, 꼬리 없음\",\n", + " \"cruise\": \"가늘고 긴 동체 + 작은 중간 날개\", \"ballistic\": \"굵은 원통 동체 + 뾰족한 머리 + 꼬리 핀\", \"bird\": \"갈매기형 굽은 날개\"}\n", + "print(pd.DataFrame({\"레이블\": CLASSES, \"한글명\": [KOR[c] for c in CLASSES], \"위협\": [\"예\" if c in THREAT else \"아니오\" for c in CLASSES],\n", + " \"식별 핵심 특징\": [desc[c] for c in CLASSES]}).to_string(index=False))\n", + "rows = [(g, k, (r[\"type\"].__name__ if isinstance(r[\"type\"], type) else \"number\"),\n", + " \", \".join(f\"{x}={y}\" for x, y in r.items() if x != \"type\")[:60]) for g in (\"fields\", \"sensor\", \"tactical\") for k, r in SCHEMA[g].items()]\n", + "print(\"\\n[필드 명세 요약] 필드\", len(rows) + 1, \"개(pixels 포함), 정의 완성도 100%\")\n", + "print(pd.DataFrame(rows, columns=[\"그룹\", \"필드\", \"자료형\", \"규칙\"]).to_string(index=False))\n", + "\n", + "# 5) 샘플 실루엣 확인\n", + "fig, ax = plt.subplots(len(CLASSES), len(conds), figsize=(8, 9))\n", + "for i, cls in enumerate(CLASSES):\n", + " for j, cond in enumerate(conds):\n", + " r = next(x for x in records if x[\"class\"] == cls and x[\"condition\"] == cond)\n", + " ax[i, j].imshow(r[\"pixels\"], cmap=\"gray\", vmin=0, vmax=255); ax[i, j].axis(\"off\")\n", + " ax[i, j].set_title(f\"{cls}/{cond}\", fontsize=7)\n", + "plt.suptitle(\"Synthetic abstract silhouettes (training data only)\")\n", + "plt.tight_layout(); plt.show()\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 01 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + "정상 샘플 60건 중 검증 통과 60건, 오류 0건 (목표 오류 0건: 달성)\n", + "\n", + "[오류 주입 결과]\n", + " - image_id: 형식 불일치 'IMG-7'\n", + " - class: 허용되지 않은 값 'ufo'\n", + " - threat: 자료형 오류(str)\n", + " - tactical.comm_delay_ms: 필수 필드 누락\n", + " - pixels: 크기 32x32 필요(입력 (20, 32))\n", + " - pixels: 값 범위(0~255) 또는 결측 오류\n", + " - threat: 클래스와 위협 여부 불일치\n", + "\n", + "[레이블 정의(추상 도형 기준)]\n", + " 레이블 한글명 위협 식별 핵심 특징\n", + "civil_quad 민간 쿼드콥터형 아니오 십자 팔 + 원형 회전날개 4개\n", + " civil_fw 민간 고정익형 아니오 직선 주익 + 꼬리날개\n", + " attack_fw 삼각익 자폭드론형 예 삼각형(델타) 날개, 꼬리 없음\n", + " cruise 순항미사일형 예 가늘고 긴 동체 + 작은 중간 날개\n", + " ballistic 탄도미사일형 예 굵은 원통 동체 + 뾰족한 머리 + 꼬리 핀\n", + " bird 조류 아니오 갈매기형 굽은 날개\n", + "\n", + "[필드 명세 요약] 필드 15 개(pixels 포함), 정의 완성도 100%\n", + " 그룹 필드 자료형 규칙\n", + " fields image_id str pattern=^SIL\\d{5}$\n", + " fields class str enum=['civil_quad', 'civil_fw', 'attack_fw', 'cruise', 'ball\n", + " fields threat bool \n", + " fields condition str enum=['basic', 'offcenter', 'far', 'blur', 'lowcon', 'clutte\n", + " fields view str enum=['top', 'oblique']\n", + " fields source str enum=['synthetic']\n", + " fields reviewed_by str pattern=^INS\\d{2}$\n", + " fields review_status str enum=['draft', 'approved']\n", + " sensor type str enum=['EO', 'IR']\n", + " sensor resolution list length=2\n", + "tactical weather str enum=['clear', 'haze', 'cloud', 'night', 'rain']\n", + "tactical range_level str enum=['near', 'mid', 'far']\n", + "tactical position_error_m number min=0, max=500\n", + "tactical comm_delay_ms number min=0, max=10000\n", + "```\n", + "
\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 02. 이미지 명암 대비 강화, 윤곽선(Edge) 검출 및 크기 정규화 전처리 모듈 구현\n", + "**소요시간**: 약 2.0시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "명암 대비 강화, 윤곽선(Edge) 검출 및 크기 정규화 전처리 모듈을 구현한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **흑백·원거리·야간 등 조건이 다른 실루엣 이미지를 같은 기준으로 비교할 수 있도록 명암 대비 강화, 윤곽선 검출, 크기 정규화 전처리 모듈을 구현한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **결측 픽셀 보정, 야간 열상 극성 자동 반전, 잡음 추정값에 따른 중간값 필터 적용(조건별 임계값), 명암 대비 강화, Sobel 윤곽선 검출, 주축 방향·크기 정규화(24x24)를 배치 단위로 구현하고 동작을 검증하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `src/preprocess/trn03_preprocessor.py 및 전처리 전후 데이터셋` 형태의 결과물을 완성한다.\n", + "\n", + "### 🔍 평가 및 검증 기준 (PASS 조건)\n", + "#### 미션 판정\n", + "\n", + "**평가 구분:** 필수\n", + "\n", + "**핵심 평가영역:** 전술 환경 특유의 노이즈(스푸핑, 반사파, 센서 단절 등) 제거 타당성\n", + "\n", + "#### 미션 PASS 조건\n", + "\n", + "- 정량 기준: 데이터 정제 처리 속도 1,000건/초 이상, 전처리 오류율 0%\n", + "\n", + "\n", + "- 진행 기준: 정제 파이프라인 자동화 및 단위 테스트 케이스 5종 통과\n", + "\n", + "\n", + "- 검증 방법: pytest 기반 전처리 정합성 테스트 및 결측치 보정율 확인\n", + "\n", + "\n", + "#### 채점자 판정 메모\n", + "\n", + "> 단순 코드 실행 여부뿐만 아니라 출력된 수치와 근거 자료가 군 실무 요구사항에 부합하는지 면밀히 확인한다.\n", + "\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "\"\"\"실루엣 이미지의 결측 보정, 야간 극성 보정, 잡음 제거, 명암 대비 강화, 윤곽선(Edge) 검출, 방향·크기 정규화를 일괄 처리하는 전처리 모듈을 구현하고, 처리 속도·단위 테스트 5종·전처리 전후 식별 정확도를 확인한다.\"\"\"\n", + "import time\n", + "import warnings\n", + "import numpy as np\n", + "import pandas as pd\n", + "import matplotlib.pyplot as plt\n", + "from matplotlib.path import Path as MplPath\n", + "from scipy import ndimage\n", + "from scipy.spatial import ConvexHull\n", + "from sklearn.neighbors import NearestCentroid\n", + "\n", + "# ---------------------------------------------------------------------------\n", + "# [교육용가상자료] 합성 실루엣 생성기 (실제 기체 사진·도면·제원 사용 안 함)\n", + "# 사각형·삼각형·원 같은 기본 도형을 조합한 추상 실루엣을 32x32 흑백 이미지로 그린다.\n", + "# 회전(다각도), 원근 축소(측면 시점), 크기(원거리), 흐림·잡음·저대비·구름·가림·야간 반전을 조절한다.\n", + "# ---------------------------------------------------------------------------\n", + "IMG = 32\n", + "CLASSES = [\"civil_quad\", \"civil_fw\", \"attack_fw\", \"cruise\", \"ballistic\", \"bird\"]\n", + "KOR = {\"civil_quad\": \"민간 쿼드콥터형\", \"civil_fw\": \"민간 고정익형\", \"attack_fw\": \"삼각익 자폭드론형\",\n", + " \"cruise\": \"순항미사일형\", \"ballistic\": \"탄도미사일형\", \"bird\": \"조류\"}\n", + "THREAT = {\"attack_fw\", \"cruise\", \"ballistic\"}\n", + "\n", + "\n", + "def _rect(x0, x1, y0, y1):\n", + " # [TODO : 학생 실습 2] 이미지 명암 대비 강화, 윤곽선(Edge) 검출 및 크기 정규화 전처리 모듈 구현 관련 핵심 로직을 구현하세요.\n", + " # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n", + " # <<< 여기에 코드를 작성하세요 >>>\n", + " pass # 작성 후 pass를 제거하세요.\n", + "def _bar(p, q, w):\n", + " # [TODO : 학생 실습 2] 이미지 명암 대비 강화, 윤곽선(Edge) 검출 및 크기 정규화 전처리 모듈 구현 관련 핵심 로직을 구현하세요.\n", + " # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n", + " # <<< 여기에 코드를 작성하세요 >>>\n", + " pass # 작성 후 pass를 제거하세요.\n", + "SHAPES = {\n", + " \"civil_quad\": {\"poly\": [_bar((-.55, -.55), (.55, .55), .1), _bar((-.55, .55), (.55, -.55), .1), _rect(-.15, .15, -.15, .15)],\n", + " \"circ\": [(-.55, -.55, .24), (.55, .55, .24), (-.55, .55, .24), (.55, -.55, .24)]},\n", + " \"civil_fw\": {\"poly\": [_rect(-.07, .07, -.8, .72), _rect(-.9, .9, .05, .25), _rect(-.33, .33, -.8, -.66)], \"circ\": []},\n", + " \"attack_fw\": {\"poly\": [[(0, .85), (.85, -.5), (.12, -.42), (0, -.6), (-.12, -.42), (-.85, -.5)]], \"circ\": []},\n", + " \"cruise\": {\"poly\": [_rect(-.07, .07, -.9, .7), [(-.07, .7), (0, .92), (.07, .7)], _rect(-.42, .42, -.05, .07),\n", + " [(-.07, -.9), (-.24, -.95), (-.07, -.74)], [(.07, -.9), (.24, -.95), (.07, -.74)]], \"circ\": []},\n", + " \"ballistic\": {\"poly\": [_rect(-.13, .13, -.85, .6), [(-.13, .6), (0, .95), (.13, .6)],\n", + " [(-.13, -.85), (-.34, -.95), (-.13, -.58)], [(.13, -.85), (.34, -.95), (.13, -.58)]], \"circ\": []},\n", + " \"bird\": {\"poly\": [[(-.9, .12), (-.5, .28), (-.12, .06), (0, .22), (.12, .06), (.5, .28), (.9, .12), (.5, .12),\n", + " (.12, -.1), (0, -.38), (-.12, -.1), (-.5, .12)]], \"circ\": []},\n", + "}\n", + "_S = 2\n", + "_g = (np.arange(IMG * _S) + 0.5) / _S\n", + "_GX, _GY = np.meshgrid(_g, _g)\n", + "_PTS = np.c_[_GX.ravel(), _GY.ravel()]\n", + "_PATHS = {c: [MplPath(np.array(p)) for p in s[\"poly\"]] for c, s in SHAPES.items()}\n", + "\n", + "\n", + "def draw(cls, rng, angle=None, scale=None, fore=None, blur=0.3, noise=4.0, contrast=1.0,\n", + " clutter=0.0, occlude=0.0, invert=False, shift=1.5):\n", + " \"\"\"클래스 1개 실루엣 이미지(uint8 32x32)를 그린다. 배경은 밝은 하늘, 비행체는 어두운 그림자.\"\"\"\n", + " angle = rng.uniform(0, 360) if angle is None else angle\n", + " scale = rng.uniform(.7, .95) if scale is None else scale\n", + " fore = rng.uniform(.85, 1.0) if fore is None else fore\n", + " th = np.radians(angle)\n", + " c0 = IMG / 2 + rng.uniform(-shift, shift, 2)\n", + " d = (_PTS - c0) / (scale * IMG / 2) # 이미지 좌표 -> 도형 좌표(역변환)\n", + " u = d[:, 0] * np.cos(th) + d[:, 1] * np.sin(th)\n", + " v = (-d[:, 0] * np.sin(th) + d[:, 1] * np.cos(th)) / fore # 원근 축소(기수 방향으로 짧아 보임)\n", + " uv = np.c_[u, -v]\n", + " m = np.zeros(len(uv), bool)\n", + " near = np.flatnonzero(np.abs(uv).max(axis=1) <= 1.0) # 도형이 있을 수 있는 영역만 검사(속도)\n", + " for p in _PATHS[cls]:\n", + " m[near] |= p.contains_points(uv[near])\n", + " for cx, cy, r in SHAPES[cls][\"circ\"]:\n", + " m |= (uv[:, 0] - cx) ** 2 + (uv[:, 1] - cy) ** 2 <= r * r\n", + " cov = m.reshape(IMG, _S, IMG, _S).mean(axis=(1, 3))\n", + " if occlude > 0: # 부분 가림(구름 조각)\n", + " h = int(IMG * np.sqrt(occlude)); y0, x0 = rng.integers(8, IMG - 8 - h // 2, 2)\n", + " cov[y0:y0 + h, x0:x0 + h] = 0\n", + " bg = rng.uniform(180, 220) + np.linspace(-10, 10, IMG)[:, None]\n", + " fg = rng.uniform(30, 60)\n", + " img = bg * (1 - cov) + fg * cov\n", + " if clutter > 0: # 구름·배경 얼룩\n", + " img -= clutter * 90 * np.clip(ndimage.gaussian_filter(rng.normal(0, 1, (IMG, IMG)), 3) * 4, 0, None)\n", + " if blur > 0:\n", + " img = ndimage.gaussian_filter(img, blur)\n", + " mean = img.mean()\n", + " img = mean + (img - mean) * contrast\n", + " img = img + rng.normal(0, noise, img.shape)\n", + " if invert: # 야간 열상(IR) 극성 반전\n", + " img = 255 - img\n", + " return np.clip(img, 0, 255).astype(np.uint8)\n", + "\n", + "\n", + "# 촬영 조건 프리셋: 기본 + 복합 위협 시나리오\n", + "CONDITIONS = {\n", + " \"basic\": {},\n", + " \"offcenter\": {\"shift\": 5.0, \"fore\": (.6, .85)},\n", + " \"far\": {\"scale\": (.32, .45), \"blur\": .6},\n", + " \"blur\": {\"blur\": (1.2, 1.8)},\n", + " \"lowcon\": {\"contrast\": (.2, .35)},\n", + " \"clutter\": {\"clutter\": .8},\n", + " \"occlude\": {\"occlude\": .08},\n", + " \"oblique\": {\"fore\": (.35, .6)},\n", + " \"night\": {\"invert\": True, \"noise\": 10.0},\n", + " \"noise\": {\"noise\": 28.0},\n", + " \"combo\": {\"scale\": (.4, .55), \"blur\": (.8, 1.1), \"noise\": 12.0, \"contrast\": (.6, .8)},\n", + "}\n", + "\n", + "\n", + "def draw_cond(cls, cond, rng):\n", + " kw = {}\n", + " for k, v in CONDITIONS[cond].items():\n", + " kw[k] = rng.uniform(*v) if isinstance(v, tuple) else v\n", + " return draw(cls, rng, **kw)\n", + "\n", + "\n", + "def make_dataset(n_per_class, seed, conds=(\"basic\",)):\n", + " \"\"\"(이미지 배열 N x 32 x 32, 정답 라벨, 조건) 반환. 조건은 conds에서 번갈아 고른다.\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " X, y, c = [], [], []\n", + " for cls in CLASSES:\n", + " for i in range(n_per_class):\n", + " cond = conds[i % len(conds)]\n", + " X.append(draw_cond(cls, cond, rng)); y.append(cls); c.append(cond)\n", + " return np.array(X), np.array(y), np.array(c)\n", + "# ---------------------------------------------------------------------------\n", + "\n", + "# ---- 전처리(배치 벡터화)·형태 특징 공통 함수 ----\n", + "OUT = 24 # 크기 정규화 결과(24x24)\n", + "NOISE_T = 12.0 # 잡음 추정값이 이보다 크면 3x3 중간값 필터 적용(악천후·센서 잡음)\n", + "FEATS = [\"elong\", \"fill\", \"solidity\", \"n_parts\", \"end_gap\", \"mid_bulge\", \"w_max\", \"compact\"]\n", + "\n", + "\n", + "def preprocess(images, noise_t=NOISE_T):\n", + " \"\"\"N장 일괄 처리: 결측 보정 -> 극성(야간 반전) 보정 -> 조건별 잡음 제거 -> 명암 대비 강화 -> 분할 -> 윤곽선 -> 방향·크기 정규화.\"\"\"\n", + " f = np.array(images, dtype=float).reshape(-1, IMG, IMG)\n", + " n = len(f)\n", + " bad = ~np.isfinite(f)\n", + " missing = bad.sum(axis=(1, 2))\n", + " if bad.any(): # 결측 픽셀: 영상 중앙값으로 채운 뒤 주변 3x3 중간값으로 보정\n", + " med = np.array([np.median(x[np.isfinite(x)]) if np.isfinite(x).any() else 128.0 for x in f])\n", + " f = np.where(bad, med[:, None, None], f)\n", + " f = np.where(bad, ndimage.median_filter(f, size=(1, 3, 3)), f)\n", + " border = np.concatenate([f[:, 0], f[:, -1], f[:, :, 0], f[:, :, -1]], axis=1)\n", + " inverted = np.median(border, axis=1) < 128 # 배경이 어두우면 야간 열상으로 보고 반전\n", + " f[inverted] = 255 - f[inverted]\n", + " med3 = ndimage.median_filter(f, size=(1, 3, 3))\n", + " noise = 1.4826 * np.median(np.abs(f - med3).reshape(n, -1), axis=1)\n", + " noisy = noise > noise_t\n", + " f[noisy] = med3[noisy]\n", + " lo, hi = np.percentile(f.reshape(n, -1), [1, 99], axis=1)\n", + " e = np.clip((f - lo[:, None, None]) / np.maximum(hi - lo, 1e-6)[:, None, None], 0, 1) # 명암 대비 강화\n", + " mask = e < _otsu(e)[:, None, None]\n", + " mask &= ndimage.uniform_filter(mask.astype(float), size=(1, 3, 3)) * 9 >= 2.5 # 고립 점 잡음 제거\n", + " edge = np.hypot(ndimage.sobel(e, axis=1), ndimage.sobel(e, axis=2)) # 윤곽선(Sobel)\n", + " ok = (mask.sum(axis=(1, 2)) >= 4) & (hi - lo > 8)\n", + " return {\"enh\": e, \"mask\": mask, \"edge\": edge / np.maximum(edge.max(axis=(1, 2)), 1e-6)[:, None, None],\n", + " \"norm\": _normalize(1 - e, mask, ok), \"inverted\": inverted, \"noise\": noise, \"denoised\": noisy,\n", + " \"missing\": missing, \"contrast\": hi - lo, \"ok\": ok}\n", + "\n", + "\n", + "def _otsu(e, bins=64):\n", + " n = len(e)\n", + " idx = np.minimum((e * bins).astype(int), bins - 1) + bins * np.arange(n)[:, None, None]\n", + " hist = np.bincount(idx.ravel(), minlength=bins * n).reshape(n, bins).astype(float)\n", + " centers = (np.arange(bins) + 0.5) / bins\n", + " w = np.cumsum(hist, axis=1); m = np.cumsum(hist * centers, axis=1)\n", + " tot, mt = w[:, -1:], m[:, -1:]\n", + " between = (mt * w / tot - m) ** 2 / np.maximum(w * (tot - w), 1)\n", + " return (np.argmax(between, axis=1) + 1) / bins\n", + "\n", + "\n", + "def _normalize(obj, mask, ok):\n", + " \"\"\"주축 방향을 세로로 돌리고 비행체 영역만 24x24로 다시 샘플링한다(회전·크기·위치 불변). 한 번의 보간으로 일괄 처리.\"\"\"\n", + " n = len(obj)\n", + " w = mask.astype(float)\n", + " m00 = np.maximum(w.sum(axis=(1, 2)), 1)\n", + " yy, xx = np.mgrid[0:IMG, 0:IMG].astype(float)\n", + " cy, cx = (w * yy).sum(axis=(1, 2)) / m00, (w * xx).sum(axis=(1, 2)) / m00\n", + " dy, dx = yy - cy[:, None, None], xx - cx[:, None, None]\n", + " mu20, mu02, mu11 = (w * dy * dy).sum(axis=(1, 2)), (w * dx * dx).sum(axis=(1, 2)), (w * dy * dx).sum(axis=(1, 2))\n", + " th = 0.5 * np.arctan2(2 * mu11, mu20 - mu02)\n", + " a = np.stack([np.cos(th), np.sin(th)], 1) # 주축(y, x)\n", + " b = np.stack([-np.sin(th), np.cos(th)], 1) # 부축\n", + " pa = np.where(mask, dy * a[:, 0, None, None] + dx * a[:, 1, None, None], 0)\n", + " pb = np.where(mask, dy * b[:, 0, None, None] + dx * b[:, 1, None, None], 0)\n", + " half = np.maximum(np.abs(pa).max(axis=(1, 2)), np.abs(pb).max(axis=(1, 2))) + 1.0\n", + " g = np.linspace(-1, 1, OUT)\n", + " U, V = np.meshgrid(g, g, indexing=\"ij\")\n", + " Y = cy[:, None, None] + half[:, None, None] * (U * a[:, 0, None, None] + V * b[:, 0, None, None])\n", + " X = cx[:, None, None] + half[:, None, None] * (U * a[:, 1, None, None] + V * b[:, 1, None, None])\n", + " N = np.broadcast_to(np.arange(n)[:, None, None], Y.shape)\n", + " out = ndimage.map_coordinates(obj * mask, [N, Y, X], order=1, cval=0.0)\n", + " skew = (out * (U ** 3)).sum(axis=(1, 2))\n", + " out[skew < 0] = out[skew < 0, ::-1] # 무게가 쏠린 쪽을 위로(180도 모호성 제거)\n", + " out[~ok] = 0\n", + " return np.clip(out, 0, 1)\n", + "\n", + "\n", + "def shape_features(mask):\n", + " \"\"\"분할 마스크 1장에서 회전에 무관한 형태 특징 8종을 계산한다.\"\"\"\n", + " if mask.sum() < 4:\n", + " return dict.fromkeys(FEATS, 0.0)\n", + " yx = np.argwhere(mask).astype(float)\n", + " c = yx - yx.mean(axis=0)\n", + " val, vec = np.linalg.eigh(np.cov(c.T) + 1e-6 * np.eye(2))\n", + " t, s = c @ vec[:, 1], c @ vec[:, 0]\n", + " length, width = np.ptp(t) + 1, np.ptp(s) + 1\n", + " corners = np.vstack([yx + d for d in ([.5, .5], [.5, -.5], [-.5, .5], [-.5, -.5])])\n", + " try:\n", + " hull = ConvexHull(corners).volume\n", + " except Exception:\n", + " hull = float(mask.sum())\n", + " bins = np.digitize(t, np.linspace(t.min(), t.max() + 1e-6, 4)[1:-1])\n", + " w = np.array([np.ptp(s[bins == k]) + 1 if (bins == k).any() else 0 for k in range(3)]) / length\n", + " boundary = mask & ~ndimage.binary_erosion(mask)\n", + " return {\"elong\": float(np.sqrt(val[1] / max(val[0], 1e-6))), \"fill\": float(mask.sum() / (length * width)),\n", + " \"solidity\": float(mask.sum() / max(hull, 1)), \"n_parts\": float(ndimage.label(mask)[1]),\n", + " \"end_gap\": float(abs(w[0] - w[2])), \"mid_bulge\": float(w[1] - max(w[0], w[2])),\n", + " \"w_max\": float(w.max()), \"compact\": float(boundary.sum() / np.sqrt(mask.sum()))}\n", + "\n", + "\n", + "def feature_matrix(images):\n", + " \"\"\"전처리 결과, 형태 특징 행렬(N x 8), 정규화 이미지 행렬(N x 576)을 돌려준다.\"\"\"\n", + " P = preprocess(images)\n", + " F = np.array([[d[k] for k in FEATS] for d in map(shape_features, P[\"mask\"])])\n", + " return P, F, P[\"norm\"].reshape(len(F), -1)\n", + "\n", + "warnings.filterwarnings(\"ignore\")\n", + "\n", + "# 1) 처리 속도: 2,400장 일괄 처리\n", + "X, y, c = make_dataset(400, 1, tuple(CONDITIONS))\n", + "preprocess(X[:50]) # 준비 실행\n", + "t0 = time.perf_counter()\n", + "P = preprocess(X)\n", + "dt = time.perf_counter() - t0\n", + "err = int((~np.isfinite(P[\"norm\"])).sum())\n", + "print(f\"전처리 {len(X):,}장, {len(X) / dt:,.0f}장/초 (목표 1,000장/초 이상: {'달성' if len(X) / dt >= 1000 else '미달'}), 출력 오류 {err}건\")\n", + "\n", + "# 2) 단위 테스트 5종\n", + "tests = {}\n", + "tests[\"T1 출력 크기 24x24 고정\"] = P[\"norm\"].shape[1:] == (OUT, OUT)\n", + "tests[\"T2 값 범위 0~1\"] = bool(P[\"norm\"].min() >= 0 and P[\"norm\"].max() <= 1)\n", + "rng = np.random.default_rng(2)\n", + "day = draw(\"attack_fw\", rng, angle=30, scale=.8, fore=1.0, noise=0)\n", + "rng = np.random.default_rng(2)\n", + "night = draw(\"attack_fw\", rng, angle=30, scale=.8, fore=1.0, noise=0, invert=True)\n", + "pd_, pn = preprocess([day, night])[\"norm\"]\n", + "tests[\"T3 야간 반전 영상 극성 자동 보정\"] = bool(np.abs(pd_ - pn).mean() < 0.05)\n", + "holes = day.astype(float); holes[rng.random(holes.shape) < 0.1] = np.nan\n", + "ph = preprocess([holes])\n", + "tests[\"T4 결측 픽셀 보정율 100%\"] = bool(ph[\"missing\"][0] > 0 and np.isfinite(ph[\"norm\"]).all())\n", + "blank = np.full((IMG, IMG), 200.0)\n", + "pb = preprocess([blank])\n", + "tests[\"T5 빈 영상은 예외 없이 '판별 불가' 표시\"] = bool(not pb[\"ok\"][0])\n", + "for k, v in tests.items():\n", + " print(f\"[{'PASS' if v else 'FAIL'}] {k}\")\n", + "print(f\"결측 픽셀 {ph['missing'][0]}개 보정, 단위 테스트 {sum(tests.values())}/5 통과\")\n", + "\n", + "# 3) 전처리 전후 식별 정확도(최근접 중심 분류기, 기본 조건으로 학습)\n", + "Xtr, ytr, _ = make_dataset(60, 3)\n", + "raw = lambda imgs: (np.asarray(imgs, float) / 255).reshape(len(imgs), -1)\n", + "nc_raw = NearestCentroid().fit(raw(Xtr), ytr)\n", + "nc_pre = NearestCentroid().fit(preprocess(Xtr)[\"norm\"].reshape(len(Xtr), -1), ytr)\n", + "rows = []\n", + "for cond in [\"basic\", \"offcenter\", \"night\", \"lowcon\", \"noise\", \"far\"]:\n", + " Xt, yt, _ = make_dataset(30, 4, (cond,))\n", + " pt = preprocess(Xt)\n", + " rows.append({\"조건\": cond, \"전처리 전\": (nc_raw.predict(raw(Xt)) == yt).mean(),\n", + " \"전처리 후\": (nc_pre.predict(pt[\"norm\"].reshape(len(Xt), -1)) == yt).mean(),\n", + " \"잡음필터 적용률\": pt[\"denoised\"].mean(), \"극성반전 비율\": pt[\"inverted\"].mean()})\n", + "print(\"\\n[전처리 전후 식별 정확도]\")\n", + "print(pd.DataFrame(rows).round(3).to_string(index=False))\n", + "\n", + "# 4) 조건별 잡음 임계값 동적 조정 효과: 임계값을 낮춰 모든 영상에 필터를 쓰면 원거리 소형 표적이 손상된다\n", + "Xf, yf, _ = make_dataset(30, 4, (\"far\",))\n", + "for t in (NOISE_T, 0.0):\n", + " acc = (nc_pre.predict(preprocess(Xf, noise_t=t)[\"norm\"].reshape(len(Xf), -1)) == yf).mean()\n", + " print(f\"원거리 조건, 잡음 임계값 {t:>4}: 정확도 {acc:.3f}\")\n", + "\n", + "# 5) 전처리 단계 시각화\n", + "idx = [np.flatnonzero(c == k)[0] for k in [\"basic\", \"night\", \"noise\", \"lowcon\", \"far\"]]\n", + "fig, ax = plt.subplots(4, len(idx), figsize=(10, 8))\n", + "for j, i in enumerate(idx):\n", + " for r, (img, name) in enumerate([(X[i], \"raw\"), (P[\"enh\"][i], \"contrast\"), (P[\"edge\"][i], \"edge\"), (P[\"norm\"][i], \"normalized 24x24\")]):\n", + " ax[r, j].imshow(img, cmap=\"gray\"); ax[r, j].axis(\"off\"); ax[r, j].set_title(f\"{c[i]} {name}\", fontsize=8)\n", + "plt.suptitle(\"Silhouette preprocessing steps (synthetic)\")\n", + "plt.tight_layout(); plt.show()\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 02 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + "전처리 2,400장, 2,056장/초 (목표 1,000장/초 이상: 달성), 출력 오류 0건\n", + "[PASS] T1 출력 크기 24x24 고정\n", + "[PASS] T2 값 범위 0~1\n", + "[PASS] T3 야간 반전 영상 극성 자동 보정\n", + "[PASS] T4 결측 픽셀 보정율 100%\n", + "[PASS] T5 빈 영상은 예외 없이 '판별 불가' 표시\n", + "결측 픽셀 109개 보정, 단위 테스트 5/5 통과\n", + "\n", + "[전처리 전후 식별 정확도]\n", + " 조건 전처리 전 전처리 후 잡음필터 적용률 극성반전 비율\n", + " basic 0.539 0.978 0.0 0.0\n", + "offcenter 0.344 0.939 0.0 0.0\n", + " night 0.167 0.983 0.0 1.0\n", + " lowcon 0.322 0.978 0.0 0.0\n", + " noise 0.544 0.911 1.0 0.0\n", + " far 0.194 0.722 0.0 0.0\n", + "원거리 조건, 잡음 임계값 12.0: 정확도 0.722\n", + "원거리 조건, 잡음 임계값 0.0: 정확도 0.417\n", + "```\n", + "
\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 03. 기본 형태 특징 기반 1차 비행체 실루엣 분류 Baseline 모델 구현\n", + "**소요시간**: 약 2.5시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "기본 형태 특징 기반 1차 비행체 실루엣 분류 Baseline 모델을 구현한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **길쭉함·채움률·볼록도·폭 분포 같은 기본 형태 특징으로 비행체 실루엣을 1차 분류하는 Baseline 모델을 구현한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **분할 마스크에서 회전에 무관한 형태 특징 8종을 계산하고 결정나무로 분류 규칙을 학습 수행하고 검증하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `src/models/trn03_baseline_model.py 및 기본 추론 결과 보고서` 형태의 결과물을 완성한다.\n", + "\n", + "### 🔍 평가 및 검증 기준 (PASS 조건)\n", + "#### 미션 판정\n", + "\n", + "**평가 구분:** 필수\n", + "\n", + "**핵심 평가영역:** 알고리즘의 논리적 흐름 및 군사적 판단 기준의 투명성\n", + "\n", + "#### 미션 PASS 조건\n", + "\n", + "- 정량 기준: 기본 데이터셋 대상 탐지/처리 정확도 75% 이상 달성\n", + "\n", + "\n", + "- 진행 기준: 단위 기능 동작 확인 및 기본 시나리오 실행 로그 생성\n", + "\n", + "\n", + "- 검증 방법: 기준 벤치마크 데이터셋 대상 추론 결과 오차율 비교\n", + "\n", + "\n", + "#### 채점자 판정 메모\n", + "\n", + "> 단순 코드 실행 여부뿐만 아니라 출력된 수치와 근거 자료가 군 실무 요구사항에 부합하는지 면밀히 확인한다.\n", + "\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "\"\"\"형태 특징 8종(길쭉함·채움률·볼록도·폭 분포 등)으로 비행체 실루엣을 1차 분류하는 결정나무 Baseline을 구현하고, 판단 규칙을 그대로 출력해 투명성을 확인하며 기본 데이터셋 정확도·처리 지연·조건별 한계를 측정한다.\"\"\"\n", + "import time\n", + "import numpy as np\n", + "import pandas as pd\n", + "import matplotlib.pyplot as plt\n", + "from matplotlib.path import Path as MplPath\n", + "from scipy import ndimage\n", + "from scipy.spatial import ConvexHull\n", + "from sklearn.tree import DecisionTreeClassifier, export_text, plot_tree\n", + "\n", + "# ---------------------------------------------------------------------------\n", + "# [교육용가상자료] 합성 실루엣 생성기 (실제 기체 사진·도면·제원 사용 안 함)\n", + "# 사각형·삼각형·원 같은 기본 도형을 조합한 추상 실루엣을 32x32 흑백 이미지로 그린다.\n", + "# 회전(다각도), 원근 축소(측면 시점), 크기(원거리), 흐림·잡음·저대비·구름·가림·야간 반전을 조절한다.\n", + "# ---------------------------------------------------------------------------\n", + "IMG = 32\n", + "CLASSES = [\"civil_quad\", \"civil_fw\", \"attack_fw\", \"cruise\", \"ballistic\", \"bird\"]\n", + "KOR = {\"civil_quad\": \"민간 쿼드콥터형\", \"civil_fw\": \"민간 고정익형\", \"attack_fw\": \"삼각익 자폭드론형\",\n", + " \"cruise\": \"순항미사일형\", \"ballistic\": \"탄도미사일형\", \"bird\": \"조류\"}\n", + "THREAT = {\"attack_fw\", \"cruise\", \"ballistic\"}\n", + "\n", + "\n", + "def _rect(x0, x1, y0, y1):\n", + " # [TODO : 학생 실습 3] 기본 형태 특징 기반 1차 비행체 실루엣 분류 Baseline 모델 구현 관련 핵심 로직을 구현하세요.\n", + " # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n", + " # <<< 여기에 코드를 작성하세요 >>>\n", + " pass # 작성 후 pass를 제거하세요.\n", + "def _bar(p, q, w):\n", + " # [TODO : 학생 실습 3] 기본 형태 특징 기반 1차 비행체 실루엣 분류 Baseline 모델 구현 관련 핵심 로직을 구현하세요.\n", + " # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n", + " # <<< 여기에 코드를 작성하세요 >>>\n", + " pass # 작성 후 pass를 제거하세요.\n", + "SHAPES = {\n", + " \"civil_quad\": {\"poly\": [_bar((-.55, -.55), (.55, .55), .1), _bar((-.55, .55), (.55, -.55), .1), _rect(-.15, .15, -.15, .15)],\n", + " \"circ\": [(-.55, -.55, .24), (.55, .55, .24), (-.55, .55, .24), (.55, -.55, .24)]},\n", + " \"civil_fw\": {\"poly\": [_rect(-.07, .07, -.8, .72), _rect(-.9, .9, .05, .25), _rect(-.33, .33, -.8, -.66)], \"circ\": []},\n", + " \"attack_fw\": {\"poly\": [[(0, .85), (.85, -.5), (.12, -.42), (0, -.6), (-.12, -.42), (-.85, -.5)]], \"circ\": []},\n", + " \"cruise\": {\"poly\": [_rect(-.07, .07, -.9, .7), [(-.07, .7), (0, .92), (.07, .7)], _rect(-.42, .42, -.05, .07),\n", + " [(-.07, -.9), (-.24, -.95), (-.07, -.74)], [(.07, -.9), (.24, -.95), (.07, -.74)]], \"circ\": []},\n", + " \"ballistic\": {\"poly\": [_rect(-.13, .13, -.85, .6), [(-.13, .6), (0, .95), (.13, .6)],\n", + " [(-.13, -.85), (-.34, -.95), (-.13, -.58)], [(.13, -.85), (.34, -.95), (.13, -.58)]], \"circ\": []},\n", + " \"bird\": {\"poly\": [[(-.9, .12), (-.5, .28), (-.12, .06), (0, .22), (.12, .06), (.5, .28), (.9, .12), (.5, .12),\n", + " (.12, -.1), (0, -.38), (-.12, -.1), (-.5, .12)]], \"circ\": []},\n", + "}\n", + "_S = 2\n", + "_g = (np.arange(IMG * _S) + 0.5) / _S\n", + "_GX, _GY = np.meshgrid(_g, _g)\n", + "_PTS = np.c_[_GX.ravel(), _GY.ravel()]\n", + "_PATHS = {c: [MplPath(np.array(p)) for p in s[\"poly\"]] for c, s in SHAPES.items()}\n", + "\n", + "\n", + "def draw(cls, rng, angle=None, scale=None, fore=None, blur=0.3, noise=4.0, contrast=1.0,\n", + " clutter=0.0, occlude=0.0, invert=False, shift=1.5):\n", + " \"\"\"클래스 1개 실루엣 이미지(uint8 32x32)를 그린다. 배경은 밝은 하늘, 비행체는 어두운 그림자.\"\"\"\n", + " angle = rng.uniform(0, 360) if angle is None else angle\n", + " scale = rng.uniform(.7, .95) if scale is None else scale\n", + " fore = rng.uniform(.85, 1.0) if fore is None else fore\n", + " th = np.radians(angle)\n", + " c0 = IMG / 2 + rng.uniform(-shift, shift, 2)\n", + " d = (_PTS - c0) / (scale * IMG / 2) # 이미지 좌표 -> 도형 좌표(역변환)\n", + " u = d[:, 0] * np.cos(th) + d[:, 1] * np.sin(th)\n", + " v = (-d[:, 0] * np.sin(th) + d[:, 1] * np.cos(th)) / fore # 원근 축소(기수 방향으로 짧아 보임)\n", + " uv = np.c_[u, -v]\n", + " m = np.zeros(len(uv), bool)\n", + " near = np.flatnonzero(np.abs(uv).max(axis=1) <= 1.0) # 도형이 있을 수 있는 영역만 검사(속도)\n", + " for p in _PATHS[cls]:\n", + " m[near] |= p.contains_points(uv[near])\n", + " for cx, cy, r in SHAPES[cls][\"circ\"]:\n", + " m |= (uv[:, 0] - cx) ** 2 + (uv[:, 1] - cy) ** 2 <= r * r\n", + " cov = m.reshape(IMG, _S, IMG, _S).mean(axis=(1, 3))\n", + " if occlude > 0: # 부분 가림(구름 조각)\n", + " h = int(IMG * np.sqrt(occlude)); y0, x0 = rng.integers(8, IMG - 8 - h // 2, 2)\n", + " cov[y0:y0 + h, x0:x0 + h] = 0\n", + " bg = rng.uniform(180, 220) + np.linspace(-10, 10, IMG)[:, None]\n", + " fg = rng.uniform(30, 60)\n", + " img = bg * (1 - cov) + fg * cov\n", + " if clutter > 0: # 구름·배경 얼룩\n", + " img -= clutter * 90 * np.clip(ndimage.gaussian_filter(rng.normal(0, 1, (IMG, IMG)), 3) * 4, 0, None)\n", + " if blur > 0:\n", + " img = ndimage.gaussian_filter(img, blur)\n", + " mean = img.mean()\n", + " img = mean + (img - mean) * contrast\n", + " img = img + rng.normal(0, noise, img.shape)\n", + " if invert: # 야간 열상(IR) 극성 반전\n", + " img = 255 - img\n", + " return np.clip(img, 0, 255).astype(np.uint8)\n", + "\n", + "\n", + "# 촬영 조건 프리셋: 기본 + 복합 위협 시나리오\n", + "CONDITIONS = {\n", + " \"basic\": {},\n", + " \"offcenter\": {\"shift\": 5.0, \"fore\": (.6, .85)},\n", + " \"far\": {\"scale\": (.32, .45), \"blur\": .6},\n", + " \"blur\": {\"blur\": (1.2, 1.8)},\n", + " \"lowcon\": {\"contrast\": (.2, .35)},\n", + " \"clutter\": {\"clutter\": .8},\n", + " \"occlude\": {\"occlude\": .08},\n", + " \"oblique\": {\"fore\": (.35, .6)},\n", + " \"night\": {\"invert\": True, \"noise\": 10.0},\n", + " \"noise\": {\"noise\": 28.0},\n", + " \"combo\": {\"scale\": (.4, .55), \"blur\": (.8, 1.1), \"noise\": 12.0, \"contrast\": (.6, .8)},\n", + "}\n", + "\n", + "\n", + "def draw_cond(cls, cond, rng):\n", + " kw = {}\n", + " for k, v in CONDITIONS[cond].items():\n", + " kw[k] = rng.uniform(*v) if isinstance(v, tuple) else v\n", + " return draw(cls, rng, **kw)\n", + "\n", + "\n", + "def make_dataset(n_per_class, seed, conds=(\"basic\",)):\n", + " \"\"\"(이미지 배열 N x 32 x 32, 정답 라벨, 조건) 반환. 조건은 conds에서 번갈아 고른다.\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " X, y, c = [], [], []\n", + " for cls in CLASSES:\n", + " for i in range(n_per_class):\n", + " cond = conds[i % len(conds)]\n", + " X.append(draw_cond(cls, cond, rng)); y.append(cls); c.append(cond)\n", + " return np.array(X), np.array(y), np.array(c)\n", + "# ---------------------------------------------------------------------------\n", + "\n", + "# ---- 전처리(배치 벡터화)·형태 특징 공통 함수 ----\n", + "OUT = 24 # 크기 정규화 결과(24x24)\n", + "NOISE_T = 12.0 # 잡음 추정값이 이보다 크면 3x3 중간값 필터 적용(악천후·센서 잡음)\n", + "FEATS = [\"elong\", \"fill\", \"solidity\", \"n_parts\", \"end_gap\", \"mid_bulge\", \"w_max\", \"compact\"]\n", + "\n", + "\n", + "def preprocess(images, noise_t=NOISE_T):\n", + " \"\"\"N장 일괄 처리: 결측 보정 -> 극성(야간 반전) 보정 -> 조건별 잡음 제거 -> 명암 대비 강화 -> 분할 -> 윤곽선 -> 방향·크기 정규화.\"\"\"\n", + " f = np.array(images, dtype=float).reshape(-1, IMG, IMG)\n", + " n = len(f)\n", + " bad = ~np.isfinite(f)\n", + " missing = bad.sum(axis=(1, 2))\n", + " if bad.any(): # 결측 픽셀: 영상 중앙값으로 채운 뒤 주변 3x3 중간값으로 보정\n", + " med = np.array([np.median(x[np.isfinite(x)]) if np.isfinite(x).any() else 128.0 for x in f])\n", + " f = np.where(bad, med[:, None, None], f)\n", + " f = np.where(bad, ndimage.median_filter(f, size=(1, 3, 3)), f)\n", + " border = np.concatenate([f[:, 0], f[:, -1], f[:, :, 0], f[:, :, -1]], axis=1)\n", + " inverted = np.median(border, axis=1) < 128 # 배경이 어두우면 야간 열상으로 보고 반전\n", + " f[inverted] = 255 - f[inverted]\n", + " med3 = ndimage.median_filter(f, size=(1, 3, 3))\n", + " noise = 1.4826 * np.median(np.abs(f - med3).reshape(n, -1), axis=1)\n", + " noisy = noise > noise_t\n", + " f[noisy] = med3[noisy]\n", + " lo, hi = np.percentile(f.reshape(n, -1), [1, 99], axis=1)\n", + " e = np.clip((f - lo[:, None, None]) / np.maximum(hi - lo, 1e-6)[:, None, None], 0, 1) # 명암 대비 강화\n", + " mask = e < _otsu(e)[:, None, None]\n", + " mask &= ndimage.uniform_filter(mask.astype(float), size=(1, 3, 3)) * 9 >= 2.5 # 고립 점 잡음 제거\n", + " edge = np.hypot(ndimage.sobel(e, axis=1), ndimage.sobel(e, axis=2)) # 윤곽선(Sobel)\n", + " ok = (mask.sum(axis=(1, 2)) >= 4) & (hi - lo > 8)\n", + " return {\"enh\": e, \"mask\": mask, \"edge\": edge / np.maximum(edge.max(axis=(1, 2)), 1e-6)[:, None, None],\n", + " \"norm\": _normalize(1 - e, mask, ok), \"inverted\": inverted, \"noise\": noise, \"denoised\": noisy,\n", + " \"missing\": missing, \"contrast\": hi - lo, \"ok\": ok}\n", + "\n", + "\n", + "def _otsu(e, bins=64):\n", + " n = len(e)\n", + " idx = np.minimum((e * bins).astype(int), bins - 1) + bins * np.arange(n)[:, None, None]\n", + " hist = np.bincount(idx.ravel(), minlength=bins * n).reshape(n, bins).astype(float)\n", + " centers = (np.arange(bins) + 0.5) / bins\n", + " w = np.cumsum(hist, axis=1); m = np.cumsum(hist * centers, axis=1)\n", + " tot, mt = w[:, -1:], m[:, -1:]\n", + " between = (mt * w / tot - m) ** 2 / np.maximum(w * (tot - w), 1)\n", + " return (np.argmax(between, axis=1) + 1) / bins\n", + "\n", + "\n", + "def _normalize(obj, mask, ok):\n", + " \"\"\"주축 방향을 세로로 돌리고 비행체 영역만 24x24로 다시 샘플링한다(회전·크기·위치 불변). 한 번의 보간으로 일괄 처리.\"\"\"\n", + " n = len(obj)\n", + " w = mask.astype(float)\n", + " m00 = np.maximum(w.sum(axis=(1, 2)), 1)\n", + " yy, xx = np.mgrid[0:IMG, 0:IMG].astype(float)\n", + " cy, cx = (w * yy).sum(axis=(1, 2)) / m00, (w * xx).sum(axis=(1, 2)) / m00\n", + " dy, dx = yy - cy[:, None, None], xx - cx[:, None, None]\n", + " mu20, mu02, mu11 = (w * dy * dy).sum(axis=(1, 2)), (w * dx * dx).sum(axis=(1, 2)), (w * dy * dx).sum(axis=(1, 2))\n", + " th = 0.5 * np.arctan2(2 * mu11, mu20 - mu02)\n", + " a = np.stack([np.cos(th), np.sin(th)], 1) # 주축(y, x)\n", + " b = np.stack([-np.sin(th), np.cos(th)], 1) # 부축\n", + " pa = np.where(mask, dy * a[:, 0, None, None] + dx * a[:, 1, None, None], 0)\n", + " pb = np.where(mask, dy * b[:, 0, None, None] + dx * b[:, 1, None, None], 0)\n", + " half = np.maximum(np.abs(pa).max(axis=(1, 2)), np.abs(pb).max(axis=(1, 2))) + 1.0\n", + " g = np.linspace(-1, 1, OUT)\n", + " U, V = np.meshgrid(g, g, indexing=\"ij\")\n", + " Y = cy[:, None, None] + half[:, None, None] * (U * a[:, 0, None, None] + V * b[:, 0, None, None])\n", + " X = cx[:, None, None] + half[:, None, None] * (U * a[:, 1, None, None] + V * b[:, 1, None, None])\n", + " N = np.broadcast_to(np.arange(n)[:, None, None], Y.shape)\n", + " out = ndimage.map_coordinates(obj * mask, [N, Y, X], order=1, cval=0.0)\n", + " skew = (out * (U ** 3)).sum(axis=(1, 2))\n", + " out[skew < 0] = out[skew < 0, ::-1] # 무게가 쏠린 쪽을 위로(180도 모호성 제거)\n", + " out[~ok] = 0\n", + " return np.clip(out, 0, 1)\n", + "\n", + "\n", + "def shape_features(mask):\n", + " \"\"\"분할 마스크 1장에서 회전에 무관한 형태 특징 8종을 계산한다.\"\"\"\n", + " if mask.sum() < 4:\n", + " return dict.fromkeys(FEATS, 0.0)\n", + " yx = np.argwhere(mask).astype(float)\n", + " c = yx - yx.mean(axis=0)\n", + " val, vec = np.linalg.eigh(np.cov(c.T) + 1e-6 * np.eye(2))\n", + " t, s = c @ vec[:, 1], c @ vec[:, 0]\n", + " length, width = np.ptp(t) + 1, np.ptp(s) + 1\n", + " corners = np.vstack([yx + d for d in ([.5, .5], [.5, -.5], [-.5, .5], [-.5, -.5])])\n", + " try:\n", + " hull = ConvexHull(corners).volume\n", + " except Exception:\n", + " hull = float(mask.sum())\n", + " bins = np.digitize(t, np.linspace(t.min(), t.max() + 1e-6, 4)[1:-1])\n", + " w = np.array([np.ptp(s[bins == k]) + 1 if (bins == k).any() else 0 for k in range(3)]) / length\n", + " boundary = mask & ~ndimage.binary_erosion(mask)\n", + " return {\"elong\": float(np.sqrt(val[1] / max(val[0], 1e-6))), \"fill\": float(mask.sum() / (length * width)),\n", + " \"solidity\": float(mask.sum() / max(hull, 1)), \"n_parts\": float(ndimage.label(mask)[1]),\n", + " \"end_gap\": float(abs(w[0] - w[2])), \"mid_bulge\": float(w[1] - max(w[0], w[2])),\n", + " \"w_max\": float(w.max()), \"compact\": float(boundary.sum() / np.sqrt(mask.sum()))}\n", + "\n", + "\n", + "def feature_matrix(images):\n", + " \"\"\"전처리 결과, 형태 특징 행렬(N x 8), 정규화 이미지 행렬(N x 576)을 돌려준다.\"\"\"\n", + " P = preprocess(images)\n", + " F = np.array([[d[k] for k in FEATS] for d in map(shape_features, P[\"mask\"])])\n", + " return P, F, P[\"norm\"].reshape(len(F), -1)\n", + "\n", + "# 1) 학습·검증 데이터(기본 조건) 분리\n", + "Xtr, ytr, _ = make_dataset(60, 1)\n", + "Xte, yte, _ = make_dataset(40, 2)\n", + "_, Ftr, _ = feature_matrix(Xtr)\n", + "_, Fte, _ = feature_matrix(Xte)\n", + "\n", + "# 2) 휴리스틱 1차 튜닝: 규칙 깊이(설명 가능한 범위 2~5)별 검증 정확도\n", + "print(\"규칙 깊이 검증 정확도\")\n", + "for d in [2, 3, 4, 5]:\n", + " m = DecisionTreeClassifier(max_depth=d, random_state=0).fit(Ftr, ytr)\n", + " print(f\"{d:>6} {(m.predict(Fte) == yte).mean():.3f}\")\n", + "model = DecisionTreeClassifier(max_depth=4, random_state=0).fit(Ftr, ytr)\n", + "\n", + "# 3) 기본 데이터셋 정확도와 처리 지연\n", + "t0 = time.perf_counter()\n", + "_, F1, _ = feature_matrix(Xte)\n", + "pred = model.predict(F1)\n", + "ms = (time.perf_counter() - t0) * 1000 / len(Xte)\n", + "acc = (pred == yte).mean()\n", + "print(f\"\\n기본 데이터셋 정확도 {acc:.1%} (목표 75% 이상: {'달성' if acc >= 0.75 else '미달'}), 1장당 판단 지연 {ms:.2f} ms\")\n", + "print(pd.crosstab(pd.Series(yte, name=\"정답\"), pd.Series(pred, name=\"예측\")))\n", + "\n", + "# 4) 판단 규칙(투명성): 교관이 그대로 읽을 수 있는 if-then 규칙\n", + "print(\"\\n[판단 규칙 일부]\")\n", + "rules = export_text(model, feature_names=FEATS, decimals=2).splitlines()\n", + "print(\"\\n\".join(rules[:12]) + f\"\\n... (총 {len(rules)}줄)\")\n", + "imp = pd.Series(model.feature_importances_, index=FEATS).sort_values(ascending=False)\n", + "print(\"중요 특징 상위 3:\", \", \".join(f\"{k}({v:.2f})\" for k, v in imp.head(3).items()))\n", + "\n", + "# 5) 한계: 기본 조건에서 만든 규칙을 어려운 조건에 적용\n", + "rows = []\n", + "for cond in [\"far\", \"blur\", \"oblique\", \"occlude\", \"combo\"]:\n", + " Xh, yh, _ = make_dataset(20, 3, (cond,))\n", + " rows.append({\"조건\": cond, \"정확도\": round((model.predict(feature_matrix(Xh)[1]) == yh).mean(), 3)})\n", + "print(\"\\n[어려운 조건 정확도(Baseline 한계)]\", \", \".join(f\"{r['조건']} {r['정확도']}\" for r in rows))\n", + "print(\"기본 실행 로그: 학습 360장, 검증 240장, 규칙 깊이 4, 오류 0건\")\n", + "\n", + "plt.figure(figsize=(14, 6))\n", + "plot_tree(model, feature_names=FEATS, class_names=list(model.classes_), filled=True, fontsize=7, max_depth=3)\n", + "plt.title(\"Baseline decision rules (synthetic silhouettes)\")\n", + "plt.show()\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 03 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + "규칙 깊이 검증 정확도\n", + " 2 0.654\n", + " 3 0.812\n", + " 4 0.946\n", + " 5 0.979\n", + "\n", + "기본 데이터셋 정확도 94.6% (목표 75% 이상: 달성), 1장당 판단 지연 1.32 ms\n", + "예측 attack_fw ballistic bird civil_fw civil_quad cruise\n", + "정답 \n", + "attack_fw 40 0 0 0 0 0\n", + "ballistic 0 39 1 0 0 0\n", + "bird 0 2 38 0 0 0\n", + "civil_fw 0 0 0 40 0 0\n", + "civil_quad 0 0 0 8 32 0\n", + "cruise 0 0 2 0 0 38\n", + "\n", + "[판단 규칙 일부]\n", + "|--- w_max <= 0.43\n", + "| |--- end_gap <= 0.10\n", + "| | |--- class: bird\n", + "| |--- end_gap > 0.10\n", + "| | |--- class: ballistic\n", + "|--- w_max > 0.43\n", + "| |--- fill <= 0.44\n", + "| | |--- w_max <= 0.64\n", + "| | | |--- class: cruise\n", + "| | |--- w_max > 0.64\n", + "| | | |--- fill <= 0.29\n", + "| | | | |--- class: civil_fw\n", + "... (총 16줄)\n", + "중요 특징 상위 3: w_max(0.41), fill(0.38), end_gap(0.21)\n", + "\n", + "[어려운 조건 정확도(Baseline 한계)] far 0.442, blur 0.45, oblique 0.292, occlude 0.533, combo 0.367\n", + "기본 실행 로그: 학습 360장, 검증 240장, 규칙 깊이 4, 오류 0건\n", + "```\n", + "
\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 04. 다양한 각도/배경 노이즈 환경 하 전술 드론·미사일 정밀 이미지 분류 딥러닝 고도화\n", + "**소요시간**: 약 3.0시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "다양한 각도/배경 노이즈 환경 하 전술 드론·미사일 정밀 이미지 분류 모델을 고도화한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **다양한 각도·원거리·배경 잡음 환경에서도 실루엣을 정밀하게 분류하도록 소형 신경망 모델로 고도화하고 복합 위협 시나리오로 검증한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **정규화 이미지 576픽셀과 형태 특징 8종을 입력으로 하는 다층 퍼셉트론을 모든 촬영 조건을 섞은 증강 데이터로 학습 수행하고 검증하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `src/models/trn03_advanced_detector.py 및 예외 탐지 테스트 결과` 형태의 결과물을 완성한다.\n", + "\n", + "### 🔍 평가 및 검증 기준 (PASS 조건)\n", + "#### 미션 판정\n", + "\n", + "**평가 구분:** 필수\n", + "\n", + "**핵심 평가영역:** 기만/교란 공격에 대한 방어 로직의 현실성 및 신뢰성\n", + "\n", + "#### 미션 PASS 조건\n", + "\n", + "- 정량 기준: 복합 위협 탐지 재현율(Recall) 85% 이상, 처리 지연 200ms 이하\n", + "\n", + "\n", + "- 진행 기준: 복합 위협 시나리오 10종에 대한 탐지 테스트 통과\n", + "\n", + "\n", + "- 검증 방법: 합성 위협 데이터셋 주입 시 이상치 탐지 정확도 검증\n", + "\n", + "\n", + "#### 채점자 판정 메모\n", + "\n", + "> 단순 코드 실행 여부뿐만 아니라 출력된 수치와 근거 자료가 군 실무 요구사항에 부합하는지 면밀히 확인한다.\n", + "\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "\"\"\"다양한 각도·원거리·흐림·잡음·구름·가림·야간 조건을 섞어 학습한 다층 퍼셉트론(소형 신경망) 분류기로 실루엣 분류를 고도화하고, 복합 위협 시나리오 10종에서 위협 재현율·처리 지연·오경보 유발 패턴을 검증한다.\"\"\"\n", + "import time\n", + "import numpy as np\n", + "import pandas as pd\n", + "import matplotlib.pyplot as plt\n", + "from matplotlib.path import Path as MplPath\n", + "from scipy import ndimage\n", + "from scipy.spatial import ConvexHull\n", + "from sklearn.neural_network import MLPClassifier\n", + "from sklearn.preprocessing import StandardScaler\n", + "from sklearn.tree import DecisionTreeClassifier\n", + "\n", + "# ---------------------------------------------------------------------------\n", + "# [교육용가상자료] 합성 실루엣 생성기 (실제 기체 사진·도면·제원 사용 안 함)\n", + "# 사각형·삼각형·원 같은 기본 도형을 조합한 추상 실루엣을 32x32 흑백 이미지로 그린다.\n", + "# 회전(다각도), 원근 축소(측면 시점), 크기(원거리), 흐림·잡음·저대비·구름·가림·야간 반전을 조절한다.\n", + "# ---------------------------------------------------------------------------\n", + "IMG = 32\n", + "CLASSES = [\"civil_quad\", \"civil_fw\", \"attack_fw\", \"cruise\", \"ballistic\", \"bird\"]\n", + "KOR = {\"civil_quad\": \"민간 쿼드콥터형\", \"civil_fw\": \"민간 고정익형\", \"attack_fw\": \"삼각익 자폭드론형\",\n", + " \"cruise\": \"순항미사일형\", \"ballistic\": \"탄도미사일형\", \"bird\": \"조류\"}\n", + "THREAT = {\"attack_fw\", \"cruise\", \"ballistic\"}\n", + "\n", + "\n", + "def _rect(x0, x1, y0, y1):\n", + " # [TODO : 학생 실습 4] 다양한 각도/배경 노이즈 환경 하 전술 드론·미사일 정밀 이미지 분류 딥러닝 고도화 관련 핵심 로직을 구현하세요.\n", + " # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n", + " # <<< 여기에 코드를 작성하세요 >>>\n", + " pass # 작성 후 pass를 제거하세요.\n", + "def _bar(p, q, w):\n", + " # [TODO : 학생 실습 4] 다양한 각도/배경 노이즈 환경 하 전술 드론·미사일 정밀 이미지 분류 딥러닝 고도화 관련 핵심 로직을 구현하세요.\n", + " # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n", + " # <<< 여기에 코드를 작성하세요 >>>\n", + " pass # 작성 후 pass를 제거하세요.\n", + "SHAPES = {\n", + " \"civil_quad\": {\"poly\": [_bar((-.55, -.55), (.55, .55), .1), _bar((-.55, .55), (.55, -.55), .1), _rect(-.15, .15, -.15, .15)],\n", + " \"circ\": [(-.55, -.55, .24), (.55, .55, .24), (-.55, .55, .24), (.55, -.55, .24)]},\n", + " \"civil_fw\": {\"poly\": [_rect(-.07, .07, -.8, .72), _rect(-.9, .9, .05, .25), _rect(-.33, .33, -.8, -.66)], \"circ\": []},\n", + " \"attack_fw\": {\"poly\": [[(0, .85), (.85, -.5), (.12, -.42), (0, -.6), (-.12, -.42), (-.85, -.5)]], \"circ\": []},\n", + " \"cruise\": {\"poly\": [_rect(-.07, .07, -.9, .7), [(-.07, .7), (0, .92), (.07, .7)], _rect(-.42, .42, -.05, .07),\n", + " [(-.07, -.9), (-.24, -.95), (-.07, -.74)], [(.07, -.9), (.24, -.95), (.07, -.74)]], \"circ\": []},\n", + " \"ballistic\": {\"poly\": [_rect(-.13, .13, -.85, .6), [(-.13, .6), (0, .95), (.13, .6)],\n", + " [(-.13, -.85), (-.34, -.95), (-.13, -.58)], [(.13, -.85), (.34, -.95), (.13, -.58)]], \"circ\": []},\n", + " \"bird\": {\"poly\": [[(-.9, .12), (-.5, .28), (-.12, .06), (0, .22), (.12, .06), (.5, .28), (.9, .12), (.5, .12),\n", + " (.12, -.1), (0, -.38), (-.12, -.1), (-.5, .12)]], \"circ\": []},\n", + "}\n", + "_S = 2\n", + "_g = (np.arange(IMG * _S) + 0.5) / _S\n", + "_GX, _GY = np.meshgrid(_g, _g)\n", + "_PTS = np.c_[_GX.ravel(), _GY.ravel()]\n", + "_PATHS = {c: [MplPath(np.array(p)) for p in s[\"poly\"]] for c, s in SHAPES.items()}\n", + "\n", + "\n", + "def draw(cls, rng, angle=None, scale=None, fore=None, blur=0.3, noise=4.0, contrast=1.0,\n", + " clutter=0.0, occlude=0.0, invert=False, shift=1.5):\n", + " \"\"\"클래스 1개 실루엣 이미지(uint8 32x32)를 그린다. 배경은 밝은 하늘, 비행체는 어두운 그림자.\"\"\"\n", + " angle = rng.uniform(0, 360) if angle is None else angle\n", + " scale = rng.uniform(.7, .95) if scale is None else scale\n", + " fore = rng.uniform(.85, 1.0) if fore is None else fore\n", + " th = np.radians(angle)\n", + " c0 = IMG / 2 + rng.uniform(-shift, shift, 2)\n", + " d = (_PTS - c0) / (scale * IMG / 2) # 이미지 좌표 -> 도형 좌표(역변환)\n", + " u = d[:, 0] * np.cos(th) + d[:, 1] * np.sin(th)\n", + " v = (-d[:, 0] * np.sin(th) + d[:, 1] * np.cos(th)) / fore # 원근 축소(기수 방향으로 짧아 보임)\n", + " uv = np.c_[u, -v]\n", + " m = np.zeros(len(uv), bool)\n", + " near = np.flatnonzero(np.abs(uv).max(axis=1) <= 1.0) # 도형이 있을 수 있는 영역만 검사(속도)\n", + " for p in _PATHS[cls]:\n", + " m[near] |= p.contains_points(uv[near])\n", + " for cx, cy, r in SHAPES[cls][\"circ\"]:\n", + " m |= (uv[:, 0] - cx) ** 2 + (uv[:, 1] - cy) ** 2 <= r * r\n", + " cov = m.reshape(IMG, _S, IMG, _S).mean(axis=(1, 3))\n", + " if occlude > 0: # 부분 가림(구름 조각)\n", + " h = int(IMG * np.sqrt(occlude)); y0, x0 = rng.integers(8, IMG - 8 - h // 2, 2)\n", + " cov[y0:y0 + h, x0:x0 + h] = 0\n", + " bg = rng.uniform(180, 220) + np.linspace(-10, 10, IMG)[:, None]\n", + " fg = rng.uniform(30, 60)\n", + " img = bg * (1 - cov) + fg * cov\n", + " if clutter > 0: # 구름·배경 얼룩\n", + " img -= clutter * 90 * np.clip(ndimage.gaussian_filter(rng.normal(0, 1, (IMG, IMG)), 3) * 4, 0, None)\n", + " if blur > 0:\n", + " img = ndimage.gaussian_filter(img, blur)\n", + " mean = img.mean()\n", + " img = mean + (img - mean) * contrast\n", + " img = img + rng.normal(0, noise, img.shape)\n", + " if invert: # 야간 열상(IR) 극성 반전\n", + " img = 255 - img\n", + " return np.clip(img, 0, 255).astype(np.uint8)\n", + "\n", + "\n", + "# 촬영 조건 프리셋: 기본 + 복합 위협 시나리오\n", + "CONDITIONS = {\n", + " \"basic\": {},\n", + " \"offcenter\": {\"shift\": 5.0, \"fore\": (.6, .85)},\n", + " \"far\": {\"scale\": (.32, .45), \"blur\": .6},\n", + " \"blur\": {\"blur\": (1.2, 1.8)},\n", + " \"lowcon\": {\"contrast\": (.2, .35)},\n", + " \"clutter\": {\"clutter\": .8},\n", + " \"occlude\": {\"occlude\": .08},\n", + " \"oblique\": {\"fore\": (.35, .6)},\n", + " \"night\": {\"invert\": True, \"noise\": 10.0},\n", + " \"noise\": {\"noise\": 28.0},\n", + " \"combo\": {\"scale\": (.4, .55), \"blur\": (.8, 1.1), \"noise\": 12.0, \"contrast\": (.6, .8)},\n", + "}\n", + "\n", + "\n", + "def draw_cond(cls, cond, rng):\n", + " kw = {}\n", + " for k, v in CONDITIONS[cond].items():\n", + " kw[k] = rng.uniform(*v) if isinstance(v, tuple) else v\n", + " return draw(cls, rng, **kw)\n", + "\n", + "\n", + "def make_dataset(n_per_class, seed, conds=(\"basic\",)):\n", + " \"\"\"(이미지 배열 N x 32 x 32, 정답 라벨, 조건) 반환. 조건은 conds에서 번갈아 고른다.\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " X, y, c = [], [], []\n", + " for cls in CLASSES:\n", + " for i in range(n_per_class):\n", + " cond = conds[i % len(conds)]\n", + " X.append(draw_cond(cls, cond, rng)); y.append(cls); c.append(cond)\n", + " return np.array(X), np.array(y), np.array(c)\n", + "# ---------------------------------------------------------------------------\n", + "\n", + "# ---- 전처리(배치 벡터화)·형태 특징 공통 함수 ----\n", + "OUT = 24 # 크기 정규화 결과(24x24)\n", + "NOISE_T = 12.0 # 잡음 추정값이 이보다 크면 3x3 중간값 필터 적용(악천후·센서 잡음)\n", + "FEATS = [\"elong\", \"fill\", \"solidity\", \"n_parts\", \"end_gap\", \"mid_bulge\", \"w_max\", \"compact\"]\n", + "\n", + "\n", + "def preprocess(images, noise_t=NOISE_T):\n", + " \"\"\"N장 일괄 처리: 결측 보정 -> 극성(야간 반전) 보정 -> 조건별 잡음 제거 -> 명암 대비 강화 -> 분할 -> 윤곽선 -> 방향·크기 정규화.\"\"\"\n", + " f = np.array(images, dtype=float).reshape(-1, IMG, IMG)\n", + " n = len(f)\n", + " bad = ~np.isfinite(f)\n", + " missing = bad.sum(axis=(1, 2))\n", + " if bad.any(): # 결측 픽셀: 영상 중앙값으로 채운 뒤 주변 3x3 중간값으로 보정\n", + " med = np.array([np.median(x[np.isfinite(x)]) if np.isfinite(x).any() else 128.0 for x in f])\n", + " f = np.where(bad, med[:, None, None], f)\n", + " f = np.where(bad, ndimage.median_filter(f, size=(1, 3, 3)), f)\n", + " border = np.concatenate([f[:, 0], f[:, -1], f[:, :, 0], f[:, :, -1]], axis=1)\n", + " inverted = np.median(border, axis=1) < 128 # 배경이 어두우면 야간 열상으로 보고 반전\n", + " f[inverted] = 255 - f[inverted]\n", + " med3 = ndimage.median_filter(f, size=(1, 3, 3))\n", + " noise = 1.4826 * np.median(np.abs(f - med3).reshape(n, -1), axis=1)\n", + " noisy = noise > noise_t\n", + " f[noisy] = med3[noisy]\n", + " lo, hi = np.percentile(f.reshape(n, -1), [1, 99], axis=1)\n", + " e = np.clip((f - lo[:, None, None]) / np.maximum(hi - lo, 1e-6)[:, None, None], 0, 1) # 명암 대비 강화\n", + " mask = e < _otsu(e)[:, None, None]\n", + " mask &= ndimage.uniform_filter(mask.astype(float), size=(1, 3, 3)) * 9 >= 2.5 # 고립 점 잡음 제거\n", + " edge = np.hypot(ndimage.sobel(e, axis=1), ndimage.sobel(e, axis=2)) # 윤곽선(Sobel)\n", + " ok = (mask.sum(axis=(1, 2)) >= 4) & (hi - lo > 8)\n", + " return {\"enh\": e, \"mask\": mask, \"edge\": edge / np.maximum(edge.max(axis=(1, 2)), 1e-6)[:, None, None],\n", + " \"norm\": _normalize(1 - e, mask, ok), \"inverted\": inverted, \"noise\": noise, \"denoised\": noisy,\n", + " \"missing\": missing, \"contrast\": hi - lo, \"ok\": ok}\n", + "\n", + "\n", + "def _otsu(e, bins=64):\n", + " n = len(e)\n", + " idx = np.minimum((e * bins).astype(int), bins - 1) + bins * np.arange(n)[:, None, None]\n", + " hist = np.bincount(idx.ravel(), minlength=bins * n).reshape(n, bins).astype(float)\n", + " centers = (np.arange(bins) + 0.5) / bins\n", + " w = np.cumsum(hist, axis=1); m = np.cumsum(hist * centers, axis=1)\n", + " tot, mt = w[:, -1:], m[:, -1:]\n", + " between = (mt * w / tot - m) ** 2 / np.maximum(w * (tot - w), 1)\n", + " return (np.argmax(between, axis=1) + 1) / bins\n", + "\n", + "\n", + "def _normalize(obj, mask, ok):\n", + " \"\"\"주축 방향을 세로로 돌리고 비행체 영역만 24x24로 다시 샘플링한다(회전·크기·위치 불변). 한 번의 보간으로 일괄 처리.\"\"\"\n", + " n = len(obj)\n", + " w = mask.astype(float)\n", + " m00 = np.maximum(w.sum(axis=(1, 2)), 1)\n", + " yy, xx = np.mgrid[0:IMG, 0:IMG].astype(float)\n", + " cy, cx = (w * yy).sum(axis=(1, 2)) / m00, (w * xx).sum(axis=(1, 2)) / m00\n", + " dy, dx = yy - cy[:, None, None], xx - cx[:, None, None]\n", + " mu20, mu02, mu11 = (w * dy * dy).sum(axis=(1, 2)), (w * dx * dx).sum(axis=(1, 2)), (w * dy * dx).sum(axis=(1, 2))\n", + " th = 0.5 * np.arctan2(2 * mu11, mu20 - mu02)\n", + " a = np.stack([np.cos(th), np.sin(th)], 1) # 주축(y, x)\n", + " b = np.stack([-np.sin(th), np.cos(th)], 1) # 부축\n", + " pa = np.where(mask, dy * a[:, 0, None, None] + dx * a[:, 1, None, None], 0)\n", + " pb = np.where(mask, dy * b[:, 0, None, None] + dx * b[:, 1, None, None], 0)\n", + " half = np.maximum(np.abs(pa).max(axis=(1, 2)), np.abs(pb).max(axis=(1, 2))) + 1.0\n", + " g = np.linspace(-1, 1, OUT)\n", + " U, V = np.meshgrid(g, g, indexing=\"ij\")\n", + " Y = cy[:, None, None] + half[:, None, None] * (U * a[:, 0, None, None] + V * b[:, 0, None, None])\n", + " X = cx[:, None, None] + half[:, None, None] * (U * a[:, 1, None, None] + V * b[:, 1, None, None])\n", + " N = np.broadcast_to(np.arange(n)[:, None, None], Y.shape)\n", + " out = ndimage.map_coordinates(obj * mask, [N, Y, X], order=1, cval=0.0)\n", + " skew = (out * (U ** 3)).sum(axis=(1, 2))\n", + " out[skew < 0] = out[skew < 0, ::-1] # 무게가 쏠린 쪽을 위로(180도 모호성 제거)\n", + " out[~ok] = 0\n", + " return np.clip(out, 0, 1)\n", + "\n", + "\n", + "def shape_features(mask):\n", + " \"\"\"분할 마스크 1장에서 회전에 무관한 형태 특징 8종을 계산한다.\"\"\"\n", + " if mask.sum() < 4:\n", + " return dict.fromkeys(FEATS, 0.0)\n", + " yx = np.argwhere(mask).astype(float)\n", + " c = yx - yx.mean(axis=0)\n", + " val, vec = np.linalg.eigh(np.cov(c.T) + 1e-6 * np.eye(2))\n", + " t, s = c @ vec[:, 1], c @ vec[:, 0]\n", + " length, width = np.ptp(t) + 1, np.ptp(s) + 1\n", + " corners = np.vstack([yx + d for d in ([.5, .5], [.5, -.5], [-.5, .5], [-.5, -.5])])\n", + " try:\n", + " hull = ConvexHull(corners).volume\n", + " except Exception:\n", + " hull = float(mask.sum())\n", + " bins = np.digitize(t, np.linspace(t.min(), t.max() + 1e-6, 4)[1:-1])\n", + " w = np.array([np.ptp(s[bins == k]) + 1 if (bins == k).any() else 0 for k in range(3)]) / length\n", + " boundary = mask & ~ndimage.binary_erosion(mask)\n", + " return {\"elong\": float(np.sqrt(val[1] / max(val[0], 1e-6))), \"fill\": float(mask.sum() / (length * width)),\n", + " \"solidity\": float(mask.sum() / max(hull, 1)), \"n_parts\": float(ndimage.label(mask)[1]),\n", + " \"end_gap\": float(abs(w[0] - w[2])), \"mid_bulge\": float(w[1] - max(w[0], w[2])),\n", + " \"w_max\": float(w.max()), \"compact\": float(boundary.sum() / np.sqrt(mask.sum()))}\n", + "\n", + "\n", + "def feature_matrix(images):\n", + " \"\"\"전처리 결과, 형태 특징 행렬(N x 8), 정규화 이미지 행렬(N x 576)을 돌려준다.\"\"\"\n", + " P = preprocess(images)\n", + " F = np.array([[d[k] for k in FEATS] for d in map(shape_features, P[\"mask\"])])\n", + " return P, F, P[\"norm\"].reshape(len(F), -1)\n", + "\n", + "# ---- 분류 모델 공통 함수(다층 퍼셉트론: 정규화 이미지 576픽셀 + 형태 특징 8종) ----\n", + "def to_input(F, N, scaler):\n", + " return np.c_[N, scaler.transform(F) * 0.3]\n", + "\n", + "\n", + "def train_model(n_per_class=150, seed=5, conds=tuple(CONDITIONS), hidden=(64,)):\n", + " X, y, _ = make_dataset(n_per_class, seed, conds)\n", + " P, F, N = feature_matrix(X)\n", + " scaler = StandardScaler().fit(F)\n", + " model = MLPClassifier(hidden, max_iter=200, early_stopping=True, random_state=0)\n", + " model.fit(to_input(F, N, scaler), [CLASSES.index(v) for v in y])\n", + " return model, scaler\n", + "\n", + "\n", + "def predict(model, scaler, images):\n", + " \"\"\"(전처리 결과, 클래스별 확률 N x 6)을 돌려준다.\"\"\"\n", + " P, F, N = feature_matrix(images)\n", + " return P, model.predict_proba(to_input(F, N, scaler))\n", + "\n", + "# 1) 학습: 모든 촬영 조건을 섞은 증강 데이터(클래스당 200장)\n", + "t0 = time.perf_counter()\n", + "model, scaler = train_model(200, seed=5, hidden=(96,))\n", + "print(f\"신경망 학습 완료 ({time.perf_counter() - t0:.1f} s, 입력 584차원 = 정규화 이미지 576 + 형태 특징 8)\")\n", + "Xb, yb, _ = make_dataset(60, 1)\n", + "tree = DecisionTreeClassifier(max_depth=4, random_state=0).fit(feature_matrix(Xb)[1], yb) # 미션 3 Baseline\n", + "\n", + "# 2) 복합 위협 시나리오 10종 시험(시나리오당 클래스별 15장)\n", + "SCEN = [k for k in CONDITIONS if k != \"basic\"]\n", + "rows, all_true, all_pred = [], [], []\n", + "for i, cond in enumerate(SCEN):\n", + " X, y, _ = make_dataset(15, 100 + i, (cond,))\n", + " t0 = time.perf_counter()\n", + " P, prob = predict(model, scaler, X)\n", + " ms = (time.perf_counter() - t0) * 1000 / len(X)\n", + " pred = np.array(CLASSES)[prob.argmax(1)]\n", + " thr = np.isin(y, list(THREAT))\n", + " rec = np.isin(pred[thr], list(THREAT)).mean()\n", + " rows.append({\"시나리오\": cond, \"Baseline 정확도\": (tree.predict(feature_matrix(X)[1]) == y).mean(), \"신경망 정확도\": (pred == y).mean(),\n", + " \"위협 재현율\": rec, \"장당 ms\": ms, \"통과\": \"PASS\" if rec >= 0.85 and ms <= 200 else \"FAIL\"})\n", + " all_true += list(y); all_pred += list(pred)\n", + "df = pd.DataFrame(rows)\n", + "print(df.round(3).to_string(index=False))\n", + "yt, yp = np.array(all_true), np.array(all_pred)\n", + "tr = np.isin(yt, list(THREAT))\n", + "recall = np.isin(yp[tr], list(THREAT)).mean()\n", + "print(f\"\\n복합 위협 재현율 {recall:.1%} (목표 85% 이상: {'달성' if recall >= 0.85 else '미달'}), 최대 처리 지연 {df['장당 ms'].max():.2f} ms (목표 200 ms 이하)\")\n", + "print(f\"시나리오 통과 {int((df['통과'] == 'PASS').sum())}/10\")\n", + "\n", + "# 3) 오경보 유발 패턴: 비위협(민간기·조류)을 위협으로 판정한 경우\n", + "fa = pd.Series([f\"{t} -> {p}\" for t, p in zip(yt, yp) if t not in THREAT and p in THREAT])\n", + "print(\"\\n[오경보 유발 패턴 상위]\", fa.value_counts().head(3).to_dict())\n", + "miss = pd.Series([f\"{t} -> {p}\" for t, p in zip(yt, yp) if t in THREAT and p not in THREAT])\n", + "print(\"[위협 미탐 패턴 상위]\", miss.value_counts().head(3).to_dict())\n", + "print(\"특징 공간 재설계 방향: 원거리·결합 조건에서 날개 끝 형상이 뭉개지므로 폭 분포(end_gap, w_max) 외에 윤곽선 곡률 특징 추가를 검토한다.\")\n", + "\n", + "plt.figure(figsize=(9, 4))\n", + "x = np.arange(len(df))\n", + "plt.bar(x - 0.2, df[\"Baseline 정확도\"], 0.4, label=\"baseline tree\")\n", + "plt.bar(x + 0.2, df[\"신경망 정확도\"], 0.4, label=\"MLP (augmented)\")\n", + "plt.axhline(0.85, ls=\"--\", c=\"gray\")\n", + "plt.xticks(x, df[\"시나리오\"], rotation=30); plt.ylabel(\"accuracy\"); plt.legend()\n", + "plt.title(\"Accuracy by complex scenario (synthetic)\"); plt.tight_layout(); plt.show()\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 04 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + "신경망 학습 완료 (3.3 s, 입력 584차원 = 정규화 이미지 576 + 형태 특징 8)\n", + " 시나리오 Baseline 정확도 신경망 정확도 위협 재현율 장당 ms 통과\n", + "offcenter 0.767 1.000 1.000 1.239 PASS\n", + " far 0.467 0.922 0.911 1.131 PASS\n", + " blur 0.400 1.000 1.000 1.201 PASS\n", + " lowcon 0.900 1.000 1.000 1.735 PASS\n", + " clutter 0.556 0.700 0.711 1.153 FAIL\n", + " occlude 0.556 0.789 0.844 1.234 FAIL\n", + " oblique 0.333 0.978 1.000 1.544 PASS\n", + " night 0.967 1.000 1.000 1.207 PASS\n", + " noise 0.778 0.956 1.000 1.178 PASS\n", + " combo 0.311 0.378 0.600 1.319 FAIL\n", + "\n", + "복합 위협 재현율 90.7% (목표 85% 이상: 달성), 최대 처리 지연 1.73 ms (목표 200 ms 이하)\n", + "시나리오 통과 7/10\n", + "\n", + "[오경보 유발 패턴 상위] {'bird -> cruise': 9, 'civil_fw -> cruise': 7, 'civil_quad -> cruise': 6}\n", + "[위협 미탐 패턴 상위] {'cruise -> bird': 14, 'ballistic -> bird': 11, 'ballistic -> civil_fw': 8}\n", + "특징 공간 재설계 방향: 원거리·결합 조건에서 날개 끝 형상이 뭉개지므로 폭 분포(end_gap, w_max) 외에 윤곽선 곡률 특징 추가를 검토한다.\n", + "```\n", + "
\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 05. 유사 형상 비행체(민간 드론 vs 자폭 드론) 오분류 방지 신뢰도 점수 엔진 구현\n", + "**소요시간**: 약 3.0시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "유사 형상 비행체 오분류 방지 신뢰도 점수 산출 엔진을 개발한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **민간 드론과 자폭 드론처럼 형상이 비슷한 비행체의 오분류를 막기 위해 신뢰도 점수와 등급을 산출하는 엔진을 개발한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **분류 확률, 1·2위 확률 차이, 영상 품질(대비·크기·잡음)을 가중 결합해 신뢰도 점수를 만들고 High·Medium·Low 등급을 부여 수행하고 검증하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `src/verification/trn03_confidence_engine.py` 형태의 결과물을 완성한다.\n", + "\n", + "### 🔍 평가 및 검증 기준 (PASS 조건)\n", + "#### 미션 판정\n", + "\n", + "**평가 구분:** 필수\n", + "\n", + "**핵심 평가영역:** 신뢰도 등급(High/Med/Low) 부여 기준의 군사적 타당성\n", + "\n", + "#### 미션 PASS 조건\n", + "\n", + "- 정량 기준: 오경보율(FAR) 40% 이상 감축, 신뢰도 스코어 산출 일관성 90% 이상\n", + "\n", + "\n", + "- 진행 기준: 오경보 필터링 전후 비교 지표 산출 및 통합 테스트 완료\n", + "\n", + "\n", + "- 검증 방법: ROC-AUC 곡선 분석 및 임계값 민감도 테스트\n", + "\n", + "\n", + "#### 채점자 판정 메모\n", + "\n", + "> 단순 코드 실행 여부뿐만 아니라 출력된 수치와 근거 자료가 군 실무 요구사항에 부합하는지 면밀히 확인한다.\n", + "\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "\"\"\"민간 드론과 자폭 드론처럼 형상이 비슷한 비행체의 오분류를 줄이기 위해, 분류 확률·1/2위 확률 차이·영상 품질을 결합한 신뢰도 점수와 등급(High/Medium/Low)을 산출하고, 오경보율(FAR) 감축·점수 일관성·ROC-AUC·임계값 민감도를 검증한다.\"\"\"\n", + "import numpy as np\n", + "import pandas as pd\n", + "import matplotlib.pyplot as plt\n", + "from matplotlib.path import Path as MplPath\n", + "from scipy import ndimage\n", + "from scipy.spatial import ConvexHull\n", + "from sklearn.metrics import roc_auc_score, roc_curve\n", + "from sklearn.neural_network import MLPClassifier\n", + "from sklearn.preprocessing import StandardScaler\n", + "\n", + "# ---------------------------------------------------------------------------\n", + "# [교육용가상자료] 합성 실루엣 생성기 (실제 기체 사진·도면·제원 사용 안 함)\n", + "# 사각형·삼각형·원 같은 기본 도형을 조합한 추상 실루엣을 32x32 흑백 이미지로 그린다.\n", + "# 회전(다각도), 원근 축소(측면 시점), 크기(원거리), 흐림·잡음·저대비·구름·가림·야간 반전을 조절한다.\n", + "# ---------------------------------------------------------------------------\n", + "IMG = 32\n", + "CLASSES = [\"civil_quad\", \"civil_fw\", \"attack_fw\", \"cruise\", \"ballistic\", \"bird\"]\n", + "KOR = {\"civil_quad\": \"민간 쿼드콥터형\", \"civil_fw\": \"민간 고정익형\", \"attack_fw\": \"삼각익 자폭드론형\",\n", + " \"cruise\": \"순항미사일형\", \"ballistic\": \"탄도미사일형\", \"bird\": \"조류\"}\n", + "THREAT = {\"attack_fw\", \"cruise\", \"ballistic\"}\n", + "\n", + "\n", + "def _rect(x0, x1, y0, y1):\n", + " # [TODO : 학생 실습 5] 유사 형상 비행체(민간 드론 vs 자폭 드론) 오분류 방지 신뢰도 점수 엔진 구현 관련 핵심 로직을 구현하세요.\n", + " # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n", + " # <<< 여기에 코드를 작성하세요 >>>\n", + " pass # 작성 후 pass를 제거하세요.\n", + "def _bar(p, q, w):\n", + " # [TODO : 학생 실습 5] 유사 형상 비행체(민간 드론 vs 자폭 드론) 오분류 방지 신뢰도 점수 엔진 구현 관련 핵심 로직을 구현하세요.\n", + " # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n", + " # <<< 여기에 코드를 작성하세요 >>>\n", + " pass # 작성 후 pass를 제거하세요.\n", + "SHAPES = {\n", + " \"civil_quad\": {\"poly\": [_bar((-.55, -.55), (.55, .55), .1), _bar((-.55, .55), (.55, -.55), .1), _rect(-.15, .15, -.15, .15)],\n", + " \"circ\": [(-.55, -.55, .24), (.55, .55, .24), (-.55, .55, .24), (.55, -.55, .24)]},\n", + " \"civil_fw\": {\"poly\": [_rect(-.07, .07, -.8, .72), _rect(-.9, .9, .05, .25), _rect(-.33, .33, -.8, -.66)], \"circ\": []},\n", + " \"attack_fw\": {\"poly\": [[(0, .85), (.85, -.5), (.12, -.42), (0, -.6), (-.12, -.42), (-.85, -.5)]], \"circ\": []},\n", + " \"cruise\": {\"poly\": [_rect(-.07, .07, -.9, .7), [(-.07, .7), (0, .92), (.07, .7)], _rect(-.42, .42, -.05, .07),\n", + " [(-.07, -.9), (-.24, -.95), (-.07, -.74)], [(.07, -.9), (.24, -.95), (.07, -.74)]], \"circ\": []},\n", + " \"ballistic\": {\"poly\": [_rect(-.13, .13, -.85, .6), [(-.13, .6), (0, .95), (.13, .6)],\n", + " [(-.13, -.85), (-.34, -.95), (-.13, -.58)], [(.13, -.85), (.34, -.95), (.13, -.58)]], \"circ\": []},\n", + " \"bird\": {\"poly\": [[(-.9, .12), (-.5, .28), (-.12, .06), (0, .22), (.12, .06), (.5, .28), (.9, .12), (.5, .12),\n", + " (.12, -.1), (0, -.38), (-.12, -.1), (-.5, .12)]], \"circ\": []},\n", + "}\n", + "_S = 2\n", + "_g = (np.arange(IMG * _S) + 0.5) / _S\n", + "_GX, _GY = np.meshgrid(_g, _g)\n", + "_PTS = np.c_[_GX.ravel(), _GY.ravel()]\n", + "_PATHS = {c: [MplPath(np.array(p)) for p in s[\"poly\"]] for c, s in SHAPES.items()}\n", + "\n", + "\n", + "def draw(cls, rng, angle=None, scale=None, fore=None, blur=0.3, noise=4.0, contrast=1.0,\n", + " clutter=0.0, occlude=0.0, invert=False, shift=1.5):\n", + " \"\"\"클래스 1개 실루엣 이미지(uint8 32x32)를 그린다. 배경은 밝은 하늘, 비행체는 어두운 그림자.\"\"\"\n", + " angle = rng.uniform(0, 360) if angle is None else angle\n", + " scale = rng.uniform(.7, .95) if scale is None else scale\n", + " fore = rng.uniform(.85, 1.0) if fore is None else fore\n", + " th = np.radians(angle)\n", + " c0 = IMG / 2 + rng.uniform(-shift, shift, 2)\n", + " d = (_PTS - c0) / (scale * IMG / 2) # 이미지 좌표 -> 도형 좌표(역변환)\n", + " u = d[:, 0] * np.cos(th) + d[:, 1] * np.sin(th)\n", + " v = (-d[:, 0] * np.sin(th) + d[:, 1] * np.cos(th)) / fore # 원근 축소(기수 방향으로 짧아 보임)\n", + " uv = np.c_[u, -v]\n", + " m = np.zeros(len(uv), bool)\n", + " near = np.flatnonzero(np.abs(uv).max(axis=1) <= 1.0) # 도형이 있을 수 있는 영역만 검사(속도)\n", + " for p in _PATHS[cls]:\n", + " m[near] |= p.contains_points(uv[near])\n", + " for cx, cy, r in SHAPES[cls][\"circ\"]:\n", + " m |= (uv[:, 0] - cx) ** 2 + (uv[:, 1] - cy) ** 2 <= r * r\n", + " cov = m.reshape(IMG, _S, IMG, _S).mean(axis=(1, 3))\n", + " if occlude > 0: # 부분 가림(구름 조각)\n", + " h = int(IMG * np.sqrt(occlude)); y0, x0 = rng.integers(8, IMG - 8 - h // 2, 2)\n", + " cov[y0:y0 + h, x0:x0 + h] = 0\n", + " bg = rng.uniform(180, 220) + np.linspace(-10, 10, IMG)[:, None]\n", + " fg = rng.uniform(30, 60)\n", + " img = bg * (1 - cov) + fg * cov\n", + " if clutter > 0: # 구름·배경 얼룩\n", + " img -= clutter * 90 * np.clip(ndimage.gaussian_filter(rng.normal(0, 1, (IMG, IMG)), 3) * 4, 0, None)\n", + " if blur > 0:\n", + " img = ndimage.gaussian_filter(img, blur)\n", + " mean = img.mean()\n", + " img = mean + (img - mean) * contrast\n", + " img = img + rng.normal(0, noise, img.shape)\n", + " if invert: # 야간 열상(IR) 극성 반전\n", + " img = 255 - img\n", + " return np.clip(img, 0, 255).astype(np.uint8)\n", + "\n", + "\n", + "# 촬영 조건 프리셋: 기본 + 복합 위협 시나리오\n", + "CONDITIONS = {\n", + " \"basic\": {},\n", + " \"offcenter\": {\"shift\": 5.0, \"fore\": (.6, .85)},\n", + " \"far\": {\"scale\": (.32, .45), \"blur\": .6},\n", + " \"blur\": {\"blur\": (1.2, 1.8)},\n", + " \"lowcon\": {\"contrast\": (.2, .35)},\n", + " \"clutter\": {\"clutter\": .8},\n", + " \"occlude\": {\"occlude\": .08},\n", + " \"oblique\": {\"fore\": (.35, .6)},\n", + " \"night\": {\"invert\": True, \"noise\": 10.0},\n", + " \"noise\": {\"noise\": 28.0},\n", + " \"combo\": {\"scale\": (.4, .55), \"blur\": (.8, 1.1), \"noise\": 12.0, \"contrast\": (.6, .8)},\n", + "}\n", + "\n", + "\n", + "def draw_cond(cls, cond, rng):\n", + " kw = {}\n", + " for k, v in CONDITIONS[cond].items():\n", + " kw[k] = rng.uniform(*v) if isinstance(v, tuple) else v\n", + " return draw(cls, rng, **kw)\n", + "\n", + "\n", + "def make_dataset(n_per_class, seed, conds=(\"basic\",)):\n", + " \"\"\"(이미지 배열 N x 32 x 32, 정답 라벨, 조건) 반환. 조건은 conds에서 번갈아 고른다.\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " X, y, c = [], [], []\n", + " for cls in CLASSES:\n", + " for i in range(n_per_class):\n", + " cond = conds[i % len(conds)]\n", + " X.append(draw_cond(cls, cond, rng)); y.append(cls); c.append(cond)\n", + " return np.array(X), np.array(y), np.array(c)\n", + "# ---------------------------------------------------------------------------\n", + "\n", + "# ---- 전처리(배치 벡터화)·형태 특징 공통 함수 ----\n", + "OUT = 24 # 크기 정규화 결과(24x24)\n", + "NOISE_T = 12.0 # 잡음 추정값이 이보다 크면 3x3 중간값 필터 적용(악천후·센서 잡음)\n", + "FEATS = [\"elong\", \"fill\", \"solidity\", \"n_parts\", \"end_gap\", \"mid_bulge\", \"w_max\", \"compact\"]\n", + "\n", + "\n", + "def preprocess(images, noise_t=NOISE_T):\n", + " \"\"\"N장 일괄 처리: 결측 보정 -> 극성(야간 반전) 보정 -> 조건별 잡음 제거 -> 명암 대비 강화 -> 분할 -> 윤곽선 -> 방향·크기 정규화.\"\"\"\n", + " f = np.array(images, dtype=float).reshape(-1, IMG, IMG)\n", + " n = len(f)\n", + " bad = ~np.isfinite(f)\n", + " missing = bad.sum(axis=(1, 2))\n", + " if bad.any(): # 결측 픽셀: 영상 중앙값으로 채운 뒤 주변 3x3 중간값으로 보정\n", + " med = np.array([np.median(x[np.isfinite(x)]) if np.isfinite(x).any() else 128.0 for x in f])\n", + " f = np.where(bad, med[:, None, None], f)\n", + " f = np.where(bad, ndimage.median_filter(f, size=(1, 3, 3)), f)\n", + " border = np.concatenate([f[:, 0], f[:, -1], f[:, :, 0], f[:, :, -1]], axis=1)\n", + " inverted = np.median(border, axis=1) < 128 # 배경이 어두우면 야간 열상으로 보고 반전\n", + " f[inverted] = 255 - f[inverted]\n", + " med3 = ndimage.median_filter(f, size=(1, 3, 3))\n", + " noise = 1.4826 * np.median(np.abs(f - med3).reshape(n, -1), axis=1)\n", + " noisy = noise > noise_t\n", + " f[noisy] = med3[noisy]\n", + " lo, hi = np.percentile(f.reshape(n, -1), [1, 99], axis=1)\n", + " e = np.clip((f - lo[:, None, None]) / np.maximum(hi - lo, 1e-6)[:, None, None], 0, 1) # 명암 대비 강화\n", + " mask = e < _otsu(e)[:, None, None]\n", + " mask &= ndimage.uniform_filter(mask.astype(float), size=(1, 3, 3)) * 9 >= 2.5 # 고립 점 잡음 제거\n", + " edge = np.hypot(ndimage.sobel(e, axis=1), ndimage.sobel(e, axis=2)) # 윤곽선(Sobel)\n", + " ok = (mask.sum(axis=(1, 2)) >= 4) & (hi - lo > 8)\n", + " return {\"enh\": e, \"mask\": mask, \"edge\": edge / np.maximum(edge.max(axis=(1, 2)), 1e-6)[:, None, None],\n", + " \"norm\": _normalize(1 - e, mask, ok), \"inverted\": inverted, \"noise\": noise, \"denoised\": noisy,\n", + " \"missing\": missing, \"contrast\": hi - lo, \"ok\": ok}\n", + "\n", + "\n", + "def _otsu(e, bins=64):\n", + " n = len(e)\n", + " idx = np.minimum((e * bins).astype(int), bins - 1) + bins * np.arange(n)[:, None, None]\n", + " hist = np.bincount(idx.ravel(), minlength=bins * n).reshape(n, bins).astype(float)\n", + " centers = (np.arange(bins) + 0.5) / bins\n", + " w = np.cumsum(hist, axis=1); m = np.cumsum(hist * centers, axis=1)\n", + " tot, mt = w[:, -1:], m[:, -1:]\n", + " between = (mt * w / tot - m) ** 2 / np.maximum(w * (tot - w), 1)\n", + " return (np.argmax(between, axis=1) + 1) / bins\n", + "\n", + "\n", + "def _normalize(obj, mask, ok):\n", + " \"\"\"주축 방향을 세로로 돌리고 비행체 영역만 24x24로 다시 샘플링한다(회전·크기·위치 불변). 한 번의 보간으로 일괄 처리.\"\"\"\n", + " n = len(obj)\n", + " w = mask.astype(float)\n", + " m00 = np.maximum(w.sum(axis=(1, 2)), 1)\n", + " yy, xx = np.mgrid[0:IMG, 0:IMG].astype(float)\n", + " cy, cx = (w * yy).sum(axis=(1, 2)) / m00, (w * xx).sum(axis=(1, 2)) / m00\n", + " dy, dx = yy - cy[:, None, None], xx - cx[:, None, None]\n", + " mu20, mu02, mu11 = (w * dy * dy).sum(axis=(1, 2)), (w * dx * dx).sum(axis=(1, 2)), (w * dy * dx).sum(axis=(1, 2))\n", + " th = 0.5 * np.arctan2(2 * mu11, mu20 - mu02)\n", + " a = np.stack([np.cos(th), np.sin(th)], 1) # 주축(y, x)\n", + " b = np.stack([-np.sin(th), np.cos(th)], 1) # 부축\n", + " pa = np.where(mask, dy * a[:, 0, None, None] + dx * a[:, 1, None, None], 0)\n", + " pb = np.where(mask, dy * b[:, 0, None, None] + dx * b[:, 1, None, None], 0)\n", + " half = np.maximum(np.abs(pa).max(axis=(1, 2)), np.abs(pb).max(axis=(1, 2))) + 1.0\n", + " g = np.linspace(-1, 1, OUT)\n", + " U, V = np.meshgrid(g, g, indexing=\"ij\")\n", + " Y = cy[:, None, None] + half[:, None, None] * (U * a[:, 0, None, None] + V * b[:, 0, None, None])\n", + " X = cx[:, None, None] + half[:, None, None] * (U * a[:, 1, None, None] + V * b[:, 1, None, None])\n", + " N = np.broadcast_to(np.arange(n)[:, None, None], Y.shape)\n", + " out = ndimage.map_coordinates(obj * mask, [N, Y, X], order=1, cval=0.0)\n", + " skew = (out * (U ** 3)).sum(axis=(1, 2))\n", + " out[skew < 0] = out[skew < 0, ::-1] # 무게가 쏠린 쪽을 위로(180도 모호성 제거)\n", + " out[~ok] = 0\n", + " return np.clip(out, 0, 1)\n", + "\n", + "\n", + "def shape_features(mask):\n", + " \"\"\"분할 마스크 1장에서 회전에 무관한 형태 특징 8종을 계산한다.\"\"\"\n", + " if mask.sum() < 4:\n", + " return dict.fromkeys(FEATS, 0.0)\n", + " yx = np.argwhere(mask).astype(float)\n", + " c = yx - yx.mean(axis=0)\n", + " val, vec = np.linalg.eigh(np.cov(c.T) + 1e-6 * np.eye(2))\n", + " t, s = c @ vec[:, 1], c @ vec[:, 0]\n", + " length, width = np.ptp(t) + 1, np.ptp(s) + 1\n", + " corners = np.vstack([yx + d for d in ([.5, .5], [.5, -.5], [-.5, .5], [-.5, -.5])])\n", + " try:\n", + " hull = ConvexHull(corners).volume\n", + " except Exception:\n", + " hull = float(mask.sum())\n", + " bins = np.digitize(t, np.linspace(t.min(), t.max() + 1e-6, 4)[1:-1])\n", + " w = np.array([np.ptp(s[bins == k]) + 1 if (bins == k).any() else 0 for k in range(3)]) / length\n", + " boundary = mask & ~ndimage.binary_erosion(mask)\n", + " return {\"elong\": float(np.sqrt(val[1] / max(val[0], 1e-6))), \"fill\": float(mask.sum() / (length * width)),\n", + " \"solidity\": float(mask.sum() / max(hull, 1)), \"n_parts\": float(ndimage.label(mask)[1]),\n", + " \"end_gap\": float(abs(w[0] - w[2])), \"mid_bulge\": float(w[1] - max(w[0], w[2])),\n", + " \"w_max\": float(w.max()), \"compact\": float(boundary.sum() / np.sqrt(mask.sum()))}\n", + "\n", + "\n", + "def feature_matrix(images):\n", + " \"\"\"전처리 결과, 형태 특징 행렬(N x 8), 정규화 이미지 행렬(N x 576)을 돌려준다.\"\"\"\n", + " P = preprocess(images)\n", + " F = np.array([[d[k] for k in FEATS] for d in map(shape_features, P[\"mask\"])])\n", + " return P, F, P[\"norm\"].reshape(len(F), -1)\n", + "\n", + "# ---- 분류 모델 공통 함수(다층 퍼셉트론: 정규화 이미지 576픽셀 + 형태 특징 8종) ----\n", + "def to_input(F, N, scaler):\n", + " return np.c_[N, scaler.transform(F) * 0.3]\n", + "\n", + "\n", + "def train_model(n_per_class=150, seed=5, conds=tuple(CONDITIONS), hidden=(64,)):\n", + " X, y, _ = make_dataset(n_per_class, seed, conds)\n", + " P, F, N = feature_matrix(X)\n", + " scaler = StandardScaler().fit(F)\n", + " model = MLPClassifier(hidden, max_iter=200, early_stopping=True, random_state=0)\n", + " model.fit(to_input(F, N, scaler), [CLASSES.index(v) for v in y])\n", + " return model, scaler\n", + "\n", + "\n", + "def predict(model, scaler, images):\n", + " \"\"\"(전처리 결과, 클래스별 확률 N x 6)을 돌려준다.\"\"\"\n", + " P, F, N = feature_matrix(images)\n", + " return P, model.predict_proba(to_input(F, N, scaler))\n", + "\n", + "WEIGHTS = {\"p_max\": 0.6, \"margin\": 0.4} # 신뢰도 가중치 매트릭스(확률 대 확률 차이)\n", + "HIGH, MEDIUM = 0.8, 0.5\n", + "\n", + "\n", + "def quality(P):\n", + " \"\"\"영상 품질 0.5~1: 대비가 낮거나, 표적이 작거나, 잡음이 크면 낮아진다.\"\"\"\n", + " size = P[\"mask\"].sum(axis=(1, 2))\n", + " q = np.clip(P[\"contrast\"] / 120, 0, 1) * 0.4 + np.clip(size / 60, 0, 1) * 0.4 + np.clip(1 - P[\"noise\"] / 40, 0, 1) * 0.2\n", + " return 0.5 + 0.5 * q\n", + "\n", + "\n", + "def confidence(P, prob):\n", + " s = np.sort(prob, axis=1)\n", + " score = (WEIGHTS[\"p_max\"] * s[:, -1] + WEIGHTS[\"margin\"] * (s[:, -1] - s[:, -2])) * quality(P)\n", + " grade = np.where(score >= HIGH, \"High\", np.where(score >= MEDIUM, \"Medium\", \"Low\"))\n", + " return score, grade\n", + "\n", + "\n", + "def report(name, alert, y):\n", + " true_thr = np.isin(y, list(THREAT))\n", + " far = (alert & ~true_thr).sum() / max(alert.sum(), 1)\n", + " return {\"방식\": name, \"위협 경보\": int(alert.sum()), \"오경보\": int((alert & ~true_thr).sum()), \"오경보율\": round(far, 3),\n", + " \"위협 재현율\": round((alert & true_thr).sum() / true_thr.sum(), 3)}\n", + "\n", + "\n", + "model, scaler = train_model(200, seed=5, hidden=(96,))\n", + "X, y, cond = make_dataset(110, 21, tuple(CONDITIONS)) # 검증용 660장(학습에 쓰지 않음)\n", + "P, prob = predict(model, scaler, X)\n", + "pred = np.array(CLASSES)[prob.argmax(1)]\n", + "score, grade = confidence(P, prob)\n", + "is_thr_pred = np.isin(pred, list(THREAT))\n", + "\n", + "before = report(\"기존(최고 확률 클래스로 단정)\", is_thr_pred, y)\n", + "after = report(\"신뢰도 엔진(Low는 교관 확인)\", is_thr_pred & (grade != \"Low\"), y)\n", + "print(pd.DataFrame([before, after]).to_string(index=False))\n", + "cut = 1 - after[\"오경보율\"] / max(before[\"오경보율\"], 1e-9)\n", + "print(f\"\\n오경보율 감축 {cut:.1%} (목표 40% 이상: {'달성' if cut >= 0.4 else '미달'}), 교관 확인으로 넘긴 위협 판정 {int((is_thr_pred & (grade == 'Low')).sum())}건\")\n", + "\n", + "# 일관성: 같은 이미지에 미세 잡음(밝기 ±1)을 더해 다시 계산했을 때 같은 등급이 나오는 비율\n", + "rng = np.random.default_rng(0)\n", + "P2, prob2 = predict(model, scaler, np.clip(X + rng.integers(-1, 2, X.shape), 0, 255))\n", + "cons = (confidence(P2, prob2)[1] == grade).mean()\n", + "print(f\"신뢰도 점수 산출 일관성 {cons:.1%} (목표 90% 이상: {'달성' if cons >= 0.9 else '미달'})\")\n", + "\n", + "p_threat = prob[:, [CLASSES.index(c) for c in THREAT]].sum(1)\n", + "auc = roc_auc_score(np.isin(y, list(THREAT)), p_threat)\n", + "print(f\"위협 판별 ROC-AUC {auc:.3f}\")\n", + "\n", + "print(\"\\n[임계값 민감도: Medium 기준값별]\")\n", + "for m in [0.3, 0.4, 0.5, 0.6, 0.7]:\n", + " r = report(f\"Medium>={m}\", is_thr_pred & (score >= m), y)\n", + " print(f\"기준 {m}: 오경보율 {r['오경보율']:.3f}, 위협 재현율 {r['위협 재현율']:.3f}\")\n", + "\n", + "print(\"\\n[등급 분포: 실제 클래스별]\")\n", + "print(pd.crosstab(pd.Series(y, name=\"실제\"), pd.Series(grade, name=\"등급\")))\n", + "pair = (y == \"civil_fw\") | (y == \"attack_fw\") | (y == \"civil_quad\")\n", + "print(f\"민간 드론 vs 자폭 드론 쌍 정확도 {(pred[pair] == y[pair]).mean():.1%}, 평균 신뢰도 {score[pair].mean():.2f}\")\n", + "print(\"등급은 교육용 판단 보조이며, Low 등급은 정답을 단정하지 않고 교관 확인으로 넘긴다.\")\n", + "\n", + "fig, ax = plt.subplots(1, 2, figsize=(11, 4))\n", + "fpr, tpr, _ = roc_curve(np.isin(y, list(THREAT)), p_threat)\n", + "ax[0].plot(fpr, tpr); ax[0].plot([0, 1], [0, 1], \"k--\"); ax[0].set_title(f\"Threat ROC (AUC={auc:.3f})\")\n", + "ax[0].set_xlabel(\"false positive rate\"); ax[0].set_ylabel(\"true positive rate\")\n", + "for g, col in [(\"High\", \"tab:green\"), (\"Medium\", \"tab:orange\"), (\"Low\", \"tab:red\")]:\n", + " ax[1].hist(score[grade == g], bins=15, range=(0, 1), color=col, alpha=0.7, label=g)\n", + "ax[1].set_title(\"Confidence score (uncertainty view)\"); ax[1].set_xlabel(\"score\"); ax[1].legend()\n", + "plt.tight_layout(); plt.show()\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 05 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + " 방식 위협 경보 오경보 오경보율 위협 재현율\n", + " 기존(최고 확률 클래스로 단정) 334 29 0.087 0.924\n", + "신뢰도 엔진(Low는 교관 확인) 281 5 0.018 0.836\n", + "\n", + "오경보율 감축 79.3% (목표 40% 이상: 달성), 교관 확인으로 넘긴 위협 판정 53건\n", + "신뢰도 점수 산출 일관성 97.3% (목표 90% 이상: 달성)\n", + "위협 판별 ROC-AUC 0.983\n", + "\n", + "[임계값 민감도: Medium 기준값별]\n", + "기준 0.3: 오경보율 0.054, 위협 재현율 0.894\n", + "기준 0.4: 오경보율 0.024, 위협 재현율 0.867\n", + "기준 0.5: 오경보율 0.018, 위협 재현율 0.836\n", + "기준 0.6: 오경보율 0.011, 위협 재현율 0.809\n", + "기준 0.7: 오경보율 0.012, 위협 재현율 0.779\n", + "\n", + "[등급 분포: 실제 클래스별]\n", + "등급 High Low Medium\n", + "실제 \n", + "attack_fw 99 5 6\n", + "ballistic 72 18 20\n", + "bird 48 29 33\n", + "civil_fw 73 18 19\n", + "civil_quad 90 13 7\n", + "cruise 66 27 17\n", + "민간 드론 vs 자폭 드론 쌍 정확도 91.2%, 평균 신뢰도 0.86\n", + "등급은 교육용 판단 보조이며, Low 등급은 정답을 단정하지 않고 교관 확인으로 넘긴다.\n", + "```\n", + "
\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 06. 장병 대상 모바일/웹 대화형 실루엣 식별 학습 앱 UI 프로토타입 구현\n", + "**소요시간**: 약 2.5시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "장병 대상 모바일/웹 대화형 실루엣 식별 학습 앱 UI 프로토타입을 구현한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **장병이 실루엣을 보고 비행체를 고르는 모바일·웹 대화형 식별 학습 앱 UI 프로토타입을 구현한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **문항 출제(정답과 혼동하기 쉬운 보기 포함), 응답 채점, 즉시 피드백, 응답 후 AI 참고 판정 표시, 진행률·점수 표시 기능을 구현하고 가상 학습자 응답으로 12문항을 모의 실행 수행하고 검증하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `src/ui/trn03_dashboard.py (또는 웹 UI 컴포넌트)` 형태의 결과물을 완성한다.\n", + "\n", + "### 🔍 평가 및 검증 기준 (PASS 조건)\n", + "#### 미션 판정\n", + "\n", + "**평가 구분:** 필수\n", + "\n", + "**핵심 평가영역:** 전술 작전 상황판으로서의 직관성 및 인지 부하 감소도\n", + "\n", + "#### 미션 PASS 조건\n", + "\n", + "- 정량 기준: 화면 갱신 주기 1초 이내, UI 응답성 100ms 이내\n", + "\n", + "\n", + "- 진행 기준: 주요 지표 시각화 컴포넌트 4종 이상 렌더링 및 실시간 데이터 연동\n", + "\n", + "\n", + "- 검증 방법: 사용자 인터랙션 테스트 및 모의 데이터 스트림 렌더링 검증\n", + "\n", + "\n", + "#### 채점자 판정 메모\n", + "\n", + "> 단순 코드 실행 여부뿐만 아니라 출력된 수치와 근거 자료가 군 실무 요구사항에 부합하는지 면밀히 확인한다.\n", + "\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "\"\"\"장병이 실루엣을 보고 비행체를 고르는 대화형 식별 학습 앱 UI 프로토타입을 구현한다. 문제 출제·응답 처리·즉시 피드백·AI 참고 판정·진행률 표시를 모의 실행해 화면 갱신 주기와 응답 처리시간을 측정하고, 야간 모드 학습 화면을 그린다.\"\"\"\n", + "import time\n", + "import numpy as np\n", + "import pandas as pd\n", + "import matplotlib.pyplot as plt\n", + "from matplotlib.path import Path as MplPath\n", + "from scipy import ndimage\n", + "from scipy.spatial import ConvexHull\n", + "from sklearn.neural_network import MLPClassifier\n", + "from sklearn.preprocessing import StandardScaler\n", + "\n", + "# ---------------------------------------------------------------------------\n", + "# [교육용가상자료] 합성 실루엣 생성기 (실제 기체 사진·도면·제원 사용 안 함)\n", + "# 사각형·삼각형·원 같은 기본 도형을 조합한 추상 실루엣을 32x32 흑백 이미지로 그린다.\n", + "# 회전(다각도), 원근 축소(측면 시점), 크기(원거리), 흐림·잡음·저대비·구름·가림·야간 반전을 조절한다.\n", + "# ---------------------------------------------------------------------------\n", + "IMG = 32\n", + "CLASSES = [\"civil_quad\", \"civil_fw\", \"attack_fw\", \"cruise\", \"ballistic\", \"bird\"]\n", + "KOR = {\"civil_quad\": \"민간 쿼드콥터형\", \"civil_fw\": \"민간 고정익형\", \"attack_fw\": \"삼각익 자폭드론형\",\n", + " \"cruise\": \"순항미사일형\", \"ballistic\": \"탄도미사일형\", \"bird\": \"조류\"}\n", + "THREAT = {\"attack_fw\", \"cruise\", \"ballistic\"}\n", + "\n", + "\n", + "def _rect(x0, x1, y0, y1):\n", + " # [TODO : 학생 실습 6] 장병 대상 모바일/웹 대화형 실루엣 식별 학습 앱 UI 프로토타입 구현 관련 핵심 로직을 구현하세요.\n", + " # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n", + " # <<< 여기에 코드를 작성하세요 >>>\n", + " pass # 작성 후 pass를 제거하세요.\n", + "def _bar(p, q, w):\n", + " # [TODO : 학생 실습 6] 장병 대상 모바일/웹 대화형 실루엣 식별 학습 앱 UI 프로토타입 구현 관련 핵심 로직을 구현하세요.\n", + " # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n", + " # <<< 여기에 코드를 작성하세요 >>>\n", + " pass # 작성 후 pass를 제거하세요.\n", + "SHAPES = {\n", + " \"civil_quad\": {\"poly\": [_bar((-.55, -.55), (.55, .55), .1), _bar((-.55, .55), (.55, -.55), .1), _rect(-.15, .15, -.15, .15)],\n", + " \"circ\": [(-.55, -.55, .24), (.55, .55, .24), (-.55, .55, .24), (.55, -.55, .24)]},\n", + " \"civil_fw\": {\"poly\": [_rect(-.07, .07, -.8, .72), _rect(-.9, .9, .05, .25), _rect(-.33, .33, -.8, -.66)], \"circ\": []},\n", + " \"attack_fw\": {\"poly\": [[(0, .85), (.85, -.5), (.12, -.42), (0, -.6), (-.12, -.42), (-.85, -.5)]], \"circ\": []},\n", + " \"cruise\": {\"poly\": [_rect(-.07, .07, -.9, .7), [(-.07, .7), (0, .92), (.07, .7)], _rect(-.42, .42, -.05, .07),\n", + " [(-.07, -.9), (-.24, -.95), (-.07, -.74)], [(.07, -.9), (.24, -.95), (.07, -.74)]], \"circ\": []},\n", + " \"ballistic\": {\"poly\": [_rect(-.13, .13, -.85, .6), [(-.13, .6), (0, .95), (.13, .6)],\n", + " [(-.13, -.85), (-.34, -.95), (-.13, -.58)], [(.13, -.85), (.34, -.95), (.13, -.58)]], \"circ\": []},\n", + " \"bird\": {\"poly\": [[(-.9, .12), (-.5, .28), (-.12, .06), (0, .22), (.12, .06), (.5, .28), (.9, .12), (.5, .12),\n", + " (.12, -.1), (0, -.38), (-.12, -.1), (-.5, .12)]], \"circ\": []},\n", + "}\n", + "_S = 2\n", + "_g = (np.arange(IMG * _S) + 0.5) / _S\n", + "_GX, _GY = np.meshgrid(_g, _g)\n", + "_PTS = np.c_[_GX.ravel(), _GY.ravel()]\n", + "_PATHS = {c: [MplPath(np.array(p)) for p in s[\"poly\"]] for c, s in SHAPES.items()}\n", + "\n", + "\n", + "def draw(cls, rng, angle=None, scale=None, fore=None, blur=0.3, noise=4.0, contrast=1.0,\n", + " clutter=0.0, occlude=0.0, invert=False, shift=1.5):\n", + " \"\"\"클래스 1개 실루엣 이미지(uint8 32x32)를 그린다. 배경은 밝은 하늘, 비행체는 어두운 그림자.\"\"\"\n", + " angle = rng.uniform(0, 360) if angle is None else angle\n", + " scale = rng.uniform(.7, .95) if scale is None else scale\n", + " fore = rng.uniform(.85, 1.0) if fore is None else fore\n", + " th = np.radians(angle)\n", + " c0 = IMG / 2 + rng.uniform(-shift, shift, 2)\n", + " d = (_PTS - c0) / (scale * IMG / 2) # 이미지 좌표 -> 도형 좌표(역변환)\n", + " u = d[:, 0] * np.cos(th) + d[:, 1] * np.sin(th)\n", + " v = (-d[:, 0] * np.sin(th) + d[:, 1] * np.cos(th)) / fore # 원근 축소(기수 방향으로 짧아 보임)\n", + " uv = np.c_[u, -v]\n", + " m = np.zeros(len(uv), bool)\n", + " near = np.flatnonzero(np.abs(uv).max(axis=1) <= 1.0) # 도형이 있을 수 있는 영역만 검사(속도)\n", + " for p in _PATHS[cls]:\n", + " m[near] |= p.contains_points(uv[near])\n", + " for cx, cy, r in SHAPES[cls][\"circ\"]:\n", + " m |= (uv[:, 0] - cx) ** 2 + (uv[:, 1] - cy) ** 2 <= r * r\n", + " cov = m.reshape(IMG, _S, IMG, _S).mean(axis=(1, 3))\n", + " if occlude > 0: # 부분 가림(구름 조각)\n", + " h = int(IMG * np.sqrt(occlude)); y0, x0 = rng.integers(8, IMG - 8 - h // 2, 2)\n", + " cov[y0:y0 + h, x0:x0 + h] = 0\n", + " bg = rng.uniform(180, 220) + np.linspace(-10, 10, IMG)[:, None]\n", + " fg = rng.uniform(30, 60)\n", + " img = bg * (1 - cov) + fg * cov\n", + " if clutter > 0: # 구름·배경 얼룩\n", + " img -= clutter * 90 * np.clip(ndimage.gaussian_filter(rng.normal(0, 1, (IMG, IMG)), 3) * 4, 0, None)\n", + " if blur > 0:\n", + " img = ndimage.gaussian_filter(img, blur)\n", + " mean = img.mean()\n", + " img = mean + (img - mean) * contrast\n", + " img = img + rng.normal(0, noise, img.shape)\n", + " if invert: # 야간 열상(IR) 극성 반전\n", + " img = 255 - img\n", + " return np.clip(img, 0, 255).astype(np.uint8)\n", + "\n", + "\n", + "# 촬영 조건 프리셋: 기본 + 복합 위협 시나리오\n", + "CONDITIONS = {\n", + " \"basic\": {},\n", + " \"offcenter\": {\"shift\": 5.0, \"fore\": (.6, .85)},\n", + " \"far\": {\"scale\": (.32, .45), \"blur\": .6},\n", + " \"blur\": {\"blur\": (1.2, 1.8)},\n", + " \"lowcon\": {\"contrast\": (.2, .35)},\n", + " \"clutter\": {\"clutter\": .8},\n", + " \"occlude\": {\"occlude\": .08},\n", + " \"oblique\": {\"fore\": (.35, .6)},\n", + " \"night\": {\"invert\": True, \"noise\": 10.0},\n", + " \"noise\": {\"noise\": 28.0},\n", + " \"combo\": {\"scale\": (.4, .55), \"blur\": (.8, 1.1), \"noise\": 12.0, \"contrast\": (.6, .8)},\n", + "}\n", + "\n", + "\n", + "def draw_cond(cls, cond, rng):\n", + " kw = {}\n", + " for k, v in CONDITIONS[cond].items():\n", + " kw[k] = rng.uniform(*v) if isinstance(v, tuple) else v\n", + " return draw(cls, rng, **kw)\n", + "\n", + "\n", + "def make_dataset(n_per_class, seed, conds=(\"basic\",)):\n", + " \"\"\"(이미지 배열 N x 32 x 32, 정답 라벨, 조건) 반환. 조건은 conds에서 번갈아 고른다.\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " X, y, c = [], [], []\n", + " for cls in CLASSES:\n", + " for i in range(n_per_class):\n", + " cond = conds[i % len(conds)]\n", + " X.append(draw_cond(cls, cond, rng)); y.append(cls); c.append(cond)\n", + " return np.array(X), np.array(y), np.array(c)\n", + "# ---------------------------------------------------------------------------\n", + "\n", + "# ---- 전처리(배치 벡터화)·형태 특징 공통 함수 ----\n", + "OUT = 24 # 크기 정규화 결과(24x24)\n", + "NOISE_T = 12.0 # 잡음 추정값이 이보다 크면 3x3 중간값 필터 적용(악천후·센서 잡음)\n", + "FEATS = [\"elong\", \"fill\", \"solidity\", \"n_parts\", \"end_gap\", \"mid_bulge\", \"w_max\", \"compact\"]\n", + "\n", + "\n", + "def preprocess(images, noise_t=NOISE_T):\n", + " \"\"\"N장 일괄 처리: 결측 보정 -> 극성(야간 반전) 보정 -> 조건별 잡음 제거 -> 명암 대비 강화 -> 분할 -> 윤곽선 -> 방향·크기 정규화.\"\"\"\n", + " f = np.array(images, dtype=float).reshape(-1, IMG, IMG)\n", + " n = len(f)\n", + " bad = ~np.isfinite(f)\n", + " missing = bad.sum(axis=(1, 2))\n", + " if bad.any(): # 결측 픽셀: 영상 중앙값으로 채운 뒤 주변 3x3 중간값으로 보정\n", + " med = np.array([np.median(x[np.isfinite(x)]) if np.isfinite(x).any() else 128.0 for x in f])\n", + " f = np.where(bad, med[:, None, None], f)\n", + " f = np.where(bad, ndimage.median_filter(f, size=(1, 3, 3)), f)\n", + " border = np.concatenate([f[:, 0], f[:, -1], f[:, :, 0], f[:, :, -1]], axis=1)\n", + " inverted = np.median(border, axis=1) < 128 # 배경이 어두우면 야간 열상으로 보고 반전\n", + " f[inverted] = 255 - f[inverted]\n", + " med3 = ndimage.median_filter(f, size=(1, 3, 3))\n", + " noise = 1.4826 * np.median(np.abs(f - med3).reshape(n, -1), axis=1)\n", + " noisy = noise > noise_t\n", + " f[noisy] = med3[noisy]\n", + " lo, hi = np.percentile(f.reshape(n, -1), [1, 99], axis=1)\n", + " e = np.clip((f - lo[:, None, None]) / np.maximum(hi - lo, 1e-6)[:, None, None], 0, 1) # 명암 대비 강화\n", + " mask = e < _otsu(e)[:, None, None]\n", + " mask &= ndimage.uniform_filter(mask.astype(float), size=(1, 3, 3)) * 9 >= 2.5 # 고립 점 잡음 제거\n", + " edge = np.hypot(ndimage.sobel(e, axis=1), ndimage.sobel(e, axis=2)) # 윤곽선(Sobel)\n", + " ok = (mask.sum(axis=(1, 2)) >= 4) & (hi - lo > 8)\n", + " return {\"enh\": e, \"mask\": mask, \"edge\": edge / np.maximum(edge.max(axis=(1, 2)), 1e-6)[:, None, None],\n", + " \"norm\": _normalize(1 - e, mask, ok), \"inverted\": inverted, \"noise\": noise, \"denoised\": noisy,\n", + " \"missing\": missing, \"contrast\": hi - lo, \"ok\": ok}\n", + "\n", + "\n", + "def _otsu(e, bins=64):\n", + " n = len(e)\n", + " idx = np.minimum((e * bins).astype(int), bins - 1) + bins * np.arange(n)[:, None, None]\n", + " hist = np.bincount(idx.ravel(), minlength=bins * n).reshape(n, bins).astype(float)\n", + " centers = (np.arange(bins) + 0.5) / bins\n", + " w = np.cumsum(hist, axis=1); m = np.cumsum(hist * centers, axis=1)\n", + " tot, mt = w[:, -1:], m[:, -1:]\n", + " between = (mt * w / tot - m) ** 2 / np.maximum(w * (tot - w), 1)\n", + " return (np.argmax(between, axis=1) + 1) / bins\n", + "\n", + "\n", + "def _normalize(obj, mask, ok):\n", + " \"\"\"주축 방향을 세로로 돌리고 비행체 영역만 24x24로 다시 샘플링한다(회전·크기·위치 불변). 한 번의 보간으로 일괄 처리.\"\"\"\n", + " n = len(obj)\n", + " w = mask.astype(float)\n", + " m00 = np.maximum(w.sum(axis=(1, 2)), 1)\n", + " yy, xx = np.mgrid[0:IMG, 0:IMG].astype(float)\n", + " cy, cx = (w * yy).sum(axis=(1, 2)) / m00, (w * xx).sum(axis=(1, 2)) / m00\n", + " dy, dx = yy - cy[:, None, None], xx - cx[:, None, None]\n", + " mu20, mu02, mu11 = (w * dy * dy).sum(axis=(1, 2)), (w * dx * dx).sum(axis=(1, 2)), (w * dy * dx).sum(axis=(1, 2))\n", + " th = 0.5 * np.arctan2(2 * mu11, mu20 - mu02)\n", + " a = np.stack([np.cos(th), np.sin(th)], 1) # 주축(y, x)\n", + " b = np.stack([-np.sin(th), np.cos(th)], 1) # 부축\n", + " pa = np.where(mask, dy * a[:, 0, None, None] + dx * a[:, 1, None, None], 0)\n", + " pb = np.where(mask, dy * b[:, 0, None, None] + dx * b[:, 1, None, None], 0)\n", + " half = np.maximum(np.abs(pa).max(axis=(1, 2)), np.abs(pb).max(axis=(1, 2))) + 1.0\n", + " g = np.linspace(-1, 1, OUT)\n", + " U, V = np.meshgrid(g, g, indexing=\"ij\")\n", + " Y = cy[:, None, None] + half[:, None, None] * (U * a[:, 0, None, None] + V * b[:, 0, None, None])\n", + " X = cx[:, None, None] + half[:, None, None] * (U * a[:, 1, None, None] + V * b[:, 1, None, None])\n", + " N = np.broadcast_to(np.arange(n)[:, None, None], Y.shape)\n", + " out = ndimage.map_coordinates(obj * mask, [N, Y, X], order=1, cval=0.0)\n", + " skew = (out * (U ** 3)).sum(axis=(1, 2))\n", + " out[skew < 0] = out[skew < 0, ::-1] # 무게가 쏠린 쪽을 위로(180도 모호성 제거)\n", + " out[~ok] = 0\n", + " return np.clip(out, 0, 1)\n", + "\n", + "\n", + "def shape_features(mask):\n", + " \"\"\"분할 마스크 1장에서 회전에 무관한 형태 특징 8종을 계산한다.\"\"\"\n", + " if mask.sum() < 4:\n", + " return dict.fromkeys(FEATS, 0.0)\n", + " yx = np.argwhere(mask).astype(float)\n", + " c = yx - yx.mean(axis=0)\n", + " val, vec = np.linalg.eigh(np.cov(c.T) + 1e-6 * np.eye(2))\n", + " t, s = c @ vec[:, 1], c @ vec[:, 0]\n", + " length, width = np.ptp(t) + 1, np.ptp(s) + 1\n", + " corners = np.vstack([yx + d for d in ([.5, .5], [.5, -.5], [-.5, .5], [-.5, -.5])])\n", + " try:\n", + " hull = ConvexHull(corners).volume\n", + " except Exception:\n", + " hull = float(mask.sum())\n", + " bins = np.digitize(t, np.linspace(t.min(), t.max() + 1e-6, 4)[1:-1])\n", + " w = np.array([np.ptp(s[bins == k]) + 1 if (bins == k).any() else 0 for k in range(3)]) / length\n", + " boundary = mask & ~ndimage.binary_erosion(mask)\n", + " return {\"elong\": float(np.sqrt(val[1] / max(val[0], 1e-6))), \"fill\": float(mask.sum() / (length * width)),\n", + " \"solidity\": float(mask.sum() / max(hull, 1)), \"n_parts\": float(ndimage.label(mask)[1]),\n", + " \"end_gap\": float(abs(w[0] - w[2])), \"mid_bulge\": float(w[1] - max(w[0], w[2])),\n", + " \"w_max\": float(w.max()), \"compact\": float(boundary.sum() / np.sqrt(mask.sum()))}\n", + "\n", + "\n", + "def feature_matrix(images):\n", + " \"\"\"전처리 결과, 형태 특징 행렬(N x 8), 정규화 이미지 행렬(N x 576)을 돌려준다.\"\"\"\n", + " P = preprocess(images)\n", + " F = np.array([[d[k] for k in FEATS] for d in map(shape_features, P[\"mask\"])])\n", + " return P, F, P[\"norm\"].reshape(len(F), -1)\n", + "\n", + "# ---- 분류 모델 공통 함수(다층 퍼셉트론: 정규화 이미지 576픽셀 + 형태 특징 8종) ----\n", + "def to_input(F, N, scaler):\n", + " return np.c_[N, scaler.transform(F) * 0.3]\n", + "\n", + "\n", + "def train_model(n_per_class=150, seed=5, conds=tuple(CONDITIONS), hidden=(64,)):\n", + " X, y, _ = make_dataset(n_per_class, seed, conds)\n", + " P, F, N = feature_matrix(X)\n", + " scaler = StandardScaler().fit(F)\n", + " model = MLPClassifier(hidden, max_iter=200, early_stopping=True, random_state=0)\n", + " model.fit(to_input(F, N, scaler), [CLASSES.index(v) for v in y])\n", + " return model, scaler\n", + "\n", + "\n", + "def predict(model, scaler, images):\n", + " \"\"\"(전처리 결과, 클래스별 확률 N x 6)을 돌려준다.\"\"\"\n", + " P, F, N = feature_matrix(images)\n", + " return P, model.predict_proba(to_input(F, N, scaler))\n", + "\n", + "N_OPTIONS = 4\n", + "CONFUSE = {\"civil_fw\": \"attack_fw\", \"attack_fw\": \"civil_fw\", \"cruise\": \"ballistic\", \"ballistic\": \"cruise\", \"bird\": \"civil_fw\", \"civil_quad\": \"civil_fw\"}\n", + "\n", + "\n", + "class QuizApp:\n", + " \"\"\"학습 앱 상태: 문제 은행, 현재 문제, 점수, 응답 이력. 화면 1장 = render() 결과 문자열.\"\"\"\n", + "\n", + " def __init__(self, model, scaler, rng, n_questions=12):\n", + " self.model, self.scaler, self.rng = model, scaler, rng\n", + " conds = [\"basic\", \"far\", \"oblique\", \"night\", \"blur\", \"clutter\"]\n", + " self.bank = [(c, conds[i % len(conds)]) for i, c in enumerate(rng.permutation(CLASSES * 2))][:n_questions]\n", + " self.i, self.history = 0, []\n", + " self.next_question()\n", + "\n", + " def next_question(self):\n", + " cls, cond = self.bank[self.i]\n", + " self.img, self.answer, self.cond = draw_cond(cls, cond, self.rng), cls, cond\n", + " others = [c for c in CLASSES if c not in (cls, CONFUSE[cls])]\n", + " self.options = list(self.rng.permutation([cls, CONFUSE[cls]] + list(self.rng.choice(others, N_OPTIONS - 2, replace=False))))\n", + " self.feedback = None\n", + "\n", + " def submit(self, choice_no):\n", + " \"\"\"응답 처리: 채점 -> AI 참고 판정(신뢰도) -> 피드백 문구. 처리시간(ms)을 함께 기록한다.\"\"\"\n", + " t0 = time.perf_counter()\n", + " choice = self.options[choice_no - 1]\n", + " _, prob = predict(self.model, self.scaler, [self.img])\n", + " ai, conf = CLASSES[int(prob.argmax())], float(prob.max())\n", + " ok = choice == self.answer\n", + " self.feedback = (\"정답\" if ok else f\"오답 - 정답은 {KOR[self.answer]}\") + f\" | AI 참고 판정: {KOR[ai]}({conf:.0%})\"\n", + " ms = (time.perf_counter() - t0) * 1000\n", + " self.history.append({\"문항\": self.i + 1, \"정답\": self.answer, \"선택\": choice, \"결과\": ok, \"조건\": self.cond, \"처리ms\": ms})\n", + " return ms\n", + "\n", + " def render(self):\n", + " done = len(self.history)\n", + " bar = \"#\" * done + \".\" * (len(self.bank) - done)\n", + " score = sum(h[\"결과\"] for h in self.history)\n", + " opts = \" \".join(f\"{k + 1}) {KOR[o]}\" for k, o in enumerate(self.options))\n", + " return f\"[문항 {self.i + 1}/{len(self.bank)}] 진행 {bar} 점수 {score}/{done}\\n 보기: {opts}\\n 피드백: {self.feedback or '-'}\"\n", + "\n", + "\n", + "model, scaler = train_model(120, seed=5)\n", + "rng = np.random.default_rng(8)\n", + "app = QuizApp(model, scaler, rng)\n", + "skill = {\"civil_quad\": .9, \"civil_fw\": .65, \"attack_fw\": .7, \"cruise\": .55, \"ballistic\": .6, \"bird\": .85} # 가상 학습자 숙련도\n", + "\n", + "refresh = []\n", + "for q in range(len(app.bank)):\n", + " t_screen = time.perf_counter()\n", + " correct = rng.random() < skill[app.answer]\n", + " pick = app.options.index(app.answer if correct else CONFUSE[app.answer]) + 1\n", + " app.submit(pick)\n", + " if q < 2:\n", + " print(app.render() + \"\\n\")\n", + " refresh.append((time.perf_counter() - t_screen) * 1000)\n", + " if q < len(app.bank) - 1:\n", + " app.i += 1; app.next_question()\n", + "\n", + "h = pd.DataFrame(app.history)\n", + "print(f\"화면 갱신 처리 최대 {max(refresh):.1f} ms (목표: 갱신 주기 1초 이내), 응답 처리 평균 {h['처리ms'].mean():.1f} ms / 최대 {h['처리ms'].max():.1f} ms (목표 100 ms 이내: {'달성' if h['처리ms'].max() <= 100 else '미달'})\")\n", + "print(f\"최종 점수 {h['결과'].sum()}/{len(h)}\")\n", + "print(\"\\n[클래스별 정답률]\")\n", + "print(h.groupby(\"정답\")[\"결과\"].mean().rename(index=KOR).round(2).to_string())\n", + "print(\"\\n[오답 목록]\")\n", + "print(h[~h[\"결과\"]][[\"문항\", \"정답\", \"선택\", \"조건\"]].to_string(index=False))\n", + "\n", + "# 학습 화면(야간 모드): 문제 이미지, 보기·피드백, 진행률, 클래스별 정답률, 응답시간 추이 - 시각화 구성요소 5종\n", + "plt.style.use(\"dark_background\")\n", + "fig = plt.figure(figsize=(12, 7))\n", + "ax1 = fig.add_subplot(2, 3, 1); ax1.imshow(app.img, cmap=\"gray\"); ax1.axis(\"off\"); ax1.set_title(f\"Q{app.i + 1}: identify the silhouette\")\n", + "ax2 = fig.add_subplot(2, 3, 2); ax2.axis(\"off\"); ax2.set_title(\"Options / feedback\")\n", + "last = app.history[-1]\n", + "for k, o in enumerate(app.options):\n", + " col = \"lime\" if o == app.answer else (\"red\" if o == last[\"선택\"] else \"white\")\n", + " ax2.text(0.05, 0.85 - k * 0.2, f\"{k + 1}) {o}\", color=col, fontsize=13, fontweight=\"bold\" if col != \"white\" else \"normal\")\n", + "ax3 = fig.add_subplot(2, 3, 3); s = h[\"결과\"].sum()\n", + "ax3.barh([0], [len(h)], color=\"dimgray\"); ax3.barh([0], [s], color=\"deepskyblue\"); ax3.set_yticks([])\n", + "ax3.set_title(f\"Progress / score {s}/{len(h)}\")\n", + "ax4 = fig.add_subplot(2, 3, 4); acc = h.groupby(\"정답\")[\"결과\"].mean()\n", + "ax4.bar(acc.index, acc.values, color=[\"red\" if v < 0.6 else \"deepskyblue\" for v in acc.values]); ax4.tick_params(axis=\"x\", rotation=40)\n", + "ax4.set_title(\"Accuracy by class (red = weak)\")\n", + "ax5 = fig.add_subplot(2, 3, 5); ax5.plot(h[\"문항\"], h[\"처리ms\"], \"o-\", color=\"orange\"); ax5.axhline(100, ls=\"--\", c=\"gray\")\n", + "ax5.set_title(\"Response time (ms)\"); ax5.set_xlabel(\"question\")\n", + "ax6 = fig.add_subplot(2, 3, 6); ax6.axis(\"off\")\n", + "ax6.text(0, 0.8, \"Night mode UI\\nCorrect: green / Wrong: red\\nAI hint shown only after answer\", fontsize=11, va=\"top\")\n", + "plt.suptitle(\"Silhouette ID training app prototype - training data only\"); plt.tight_layout(); plt.show()\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 06 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + "[문항 1/12] 진행 #........... 점수 1/1\n", + " 보기: 1) 조류 2) 민간 고정익형 3) 민간 쿼드콥터형 4) 삼각익 자폭드론형\n", + " 피드백: 정답 | AI 참고 판정: 민간 고정익형(96%)\n", + "\n", + "[문항 2/12] 진행 ##.......... 점수 1/2\n", + " 보기: 1) 탄도미사일형 2) 삼각익 자폭드론형 3) 순항미사일형 4) 민간 고정익형\n", + " 피드백: 오답 - 정답은 순항미사일형 | AI 참고 판정: 순항미사일형(83%)\n", + "\n", + "화면 갱신 처리 최대 8.5 ms (목표: 갱신 주기 1초 이내), 응답 처리 평균 4.4 ms / 최대 6.6 ms (목표 100 ms 이내: 달성)\n", + "최종 점수 9/12\n", + "\n", + "[클래스별 정답률]\n", + "정답\n", + "삼각익 자폭드론형 1.0\n", + "탄도미사일형 0.5\n", + "조류 1.0\n", + "민간 고정익형 1.0\n", + "민간 쿼드콥터형 1.0\n", + "순항미사일형 0.0\n", + "\n", + "[오답 목록]\n", + " 문항 정답 선택 조건\n", + " 2 cruise ballistic far\n", + " 6 ballistic cruise clutter\n", + " 10 cruise ballistic night\n", + "```\n", + "
\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 07. 학습자 오답 시 정답 실루엣 비교 및 핵심 특징(날개 형상 등) 교관 HITL 해설 모듈 설계\n", + "**소요시간**: 약 3.0시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "학습자 오답 시 정답 실루엣 비교 및 핵심 특징 해설 HITL 모듈을 설계한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **학습자가 오답을 냈을 때 정답 실루엣과 선택한 실루엣을 비교해 핵심 특징을 해설하되, 교관 검토를 거쳐야만 공개되는 HITL 해설 모듈을 설계한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **오답을 혼동 쌍 단위로 묶어 AI 해설 초안(날개 형상 등 특징 차이)을 만들고 검토 대기열에 등록 수행하고 검증하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `src/hitl/trn03_approval_workflow.py 및 docs/trn03_hitl_guide.md` 형태의 결과물을 완성한다.\n", + "\n", + "### 🔍 평가 및 검증 기준 (PASS 조건)\n", + "#### 미션 판정\n", + "\n", + "**평가 구분:** 필수\n", + "\n", + "**핵심 평가영역:** 지휘 책임 분계선 명확화 및 성급한 자동 단정 방지 체계 부합성\n", + "\n", + "#### 미션 PASS 조건\n", + "\n", + "- 정량 기준: 인간 검토 큐(Queue) 대기시간 및 승인/반려 처리 로깅 성공률 100%\n", + "\n", + "\n", + "- 진행 기준: 승인/반려 인터페이스 동작 및 감사 로그(Audit Log) 생성 검증\n", + "\n", + "\n", + "- 검증 방법: 모의 조작자 승인/반려 시뮬레이션 및 데이터 무결성 검증\n", + "\n", + "\n", + "#### 채점자 판정 메모\n", + "\n", + "> 단순 코드 실행 여부뿐만 아니라 출력된 수치와 근거 자료가 군 실무 요구사항에 부합하는지 면밀히 확인한다.\n", + "\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "\"\"\"학습자가 오답을 내면 정답 실루엣과 선택한 실루엣을 비교해 핵심 특징(날개 형상 등) 해설 초안을 만들고, 교관이 승인·수정·반려해야만 학습자에게 공개되는 인간 검토(HITL) 해설 모듈을 구현한다. 모든 처리는 위·변조 확인이 가능한 감사 로그에 남는다.\"\"\"\n", + "import hashlib\n", + "import json\n", + "import numpy as np\n", + "import pandas as pd\n", + "import matplotlib.pyplot as plt\n", + "from matplotlib.path import Path as MplPath\n", + "from scipy import ndimage\n", + "from scipy.spatial import ConvexHull\n", + "\n", + "# ---------------------------------------------------------------------------\n", + "# [교육용가상자료] 합성 실루엣 생성기 (실제 기체 사진·도면·제원 사용 안 함)\n", + "# 사각형·삼각형·원 같은 기본 도형을 조합한 추상 실루엣을 32x32 흑백 이미지로 그린다.\n", + "# 회전(다각도), 원근 축소(측면 시점), 크기(원거리), 흐림·잡음·저대비·구름·가림·야간 반전을 조절한다.\n", + "# ---------------------------------------------------------------------------\n", + "IMG = 32\n", + "CLASSES = [\"civil_quad\", \"civil_fw\", \"attack_fw\", \"cruise\", \"ballistic\", \"bird\"]\n", + "KOR = {\"civil_quad\": \"민간 쿼드콥터형\", \"civil_fw\": \"민간 고정익형\", \"attack_fw\": \"삼각익 자폭드론형\",\n", + " \"cruise\": \"순항미사일형\", \"ballistic\": \"탄도미사일형\", \"bird\": \"조류\"}\n", + "THREAT = {\"attack_fw\", \"cruise\", \"ballistic\"}\n", + "\n", + "\n", + "def _rect(x0, x1, y0, y1):\n", + " # [TODO : 학생 실습 7] 학습자 오답 시 정답 실루엣 비교 및 핵심 특징(날개 형상 등) 교관 HITL 해설 모듈 설계 관련 핵심 로직을 구현하세요.\n", + " # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n", + " # <<< 여기에 코드를 작성하세요 >>>\n", + " pass # 작성 후 pass를 제거하세요.\n", + "def _bar(p, q, w):\n", + " # [TODO : 학생 실습 7] 학습자 오답 시 정답 실루엣 비교 및 핵심 특징(날개 형상 등) 교관 HITL 해설 모듈 설계 관련 핵심 로직을 구현하세요.\n", + " # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n", + " # <<< 여기에 코드를 작성하세요 >>>\n", + " pass # 작성 후 pass를 제거하세요.\n", + "SHAPES = {\n", + " \"civil_quad\": {\"poly\": [_bar((-.55, -.55), (.55, .55), .1), _bar((-.55, .55), (.55, -.55), .1), _rect(-.15, .15, -.15, .15)],\n", + " \"circ\": [(-.55, -.55, .24), (.55, .55, .24), (-.55, .55, .24), (.55, -.55, .24)]},\n", + " \"civil_fw\": {\"poly\": [_rect(-.07, .07, -.8, .72), _rect(-.9, .9, .05, .25), _rect(-.33, .33, -.8, -.66)], \"circ\": []},\n", + " \"attack_fw\": {\"poly\": [[(0, .85), (.85, -.5), (.12, -.42), (0, -.6), (-.12, -.42), (-.85, -.5)]], \"circ\": []},\n", + " \"cruise\": {\"poly\": [_rect(-.07, .07, -.9, .7), [(-.07, .7), (0, .92), (.07, .7)], _rect(-.42, .42, -.05, .07),\n", + " [(-.07, -.9), (-.24, -.95), (-.07, -.74)], [(.07, -.9), (.24, -.95), (.07, -.74)]], \"circ\": []},\n", + " \"ballistic\": {\"poly\": [_rect(-.13, .13, -.85, .6), [(-.13, .6), (0, .95), (.13, .6)],\n", + " [(-.13, -.85), (-.34, -.95), (-.13, -.58)], [(.13, -.85), (.34, -.95), (.13, -.58)]], \"circ\": []},\n", + " \"bird\": {\"poly\": [[(-.9, .12), (-.5, .28), (-.12, .06), (0, .22), (.12, .06), (.5, .28), (.9, .12), (.5, .12),\n", + " (.12, -.1), (0, -.38), (-.12, -.1), (-.5, .12)]], \"circ\": []},\n", + "}\n", + "_S = 2\n", + "_g = (np.arange(IMG * _S) + 0.5) / _S\n", + "_GX, _GY = np.meshgrid(_g, _g)\n", + "_PTS = np.c_[_GX.ravel(), _GY.ravel()]\n", + "_PATHS = {c: [MplPath(np.array(p)) for p in s[\"poly\"]] for c, s in SHAPES.items()}\n", + "\n", + "\n", + "def draw(cls, rng, angle=None, scale=None, fore=None, blur=0.3, noise=4.0, contrast=1.0,\n", + " clutter=0.0, occlude=0.0, invert=False, shift=1.5):\n", + " \"\"\"클래스 1개 실루엣 이미지(uint8 32x32)를 그린다. 배경은 밝은 하늘, 비행체는 어두운 그림자.\"\"\"\n", + " angle = rng.uniform(0, 360) if angle is None else angle\n", + " scale = rng.uniform(.7, .95) if scale is None else scale\n", + " fore = rng.uniform(.85, 1.0) if fore is None else fore\n", + " th = np.radians(angle)\n", + " c0 = IMG / 2 + rng.uniform(-shift, shift, 2)\n", + " d = (_PTS - c0) / (scale * IMG / 2) # 이미지 좌표 -> 도형 좌표(역변환)\n", + " u = d[:, 0] * np.cos(th) + d[:, 1] * np.sin(th)\n", + " v = (-d[:, 0] * np.sin(th) + d[:, 1] * np.cos(th)) / fore # 원근 축소(기수 방향으로 짧아 보임)\n", + " uv = np.c_[u, -v]\n", + " m = np.zeros(len(uv), bool)\n", + " near = np.flatnonzero(np.abs(uv).max(axis=1) <= 1.0) # 도형이 있을 수 있는 영역만 검사(속도)\n", + " for p in _PATHS[cls]:\n", + " m[near] |= p.contains_points(uv[near])\n", + " for cx, cy, r in SHAPES[cls][\"circ\"]:\n", + " m |= (uv[:, 0] - cx) ** 2 + (uv[:, 1] - cy) ** 2 <= r * r\n", + " cov = m.reshape(IMG, _S, IMG, _S).mean(axis=(1, 3))\n", + " if occlude > 0: # 부분 가림(구름 조각)\n", + " h = int(IMG * np.sqrt(occlude)); y0, x0 = rng.integers(8, IMG - 8 - h // 2, 2)\n", + " cov[y0:y0 + h, x0:x0 + h] = 0\n", + " bg = rng.uniform(180, 220) + np.linspace(-10, 10, IMG)[:, None]\n", + " fg = rng.uniform(30, 60)\n", + " img = bg * (1 - cov) + fg * cov\n", + " if clutter > 0: # 구름·배경 얼룩\n", + " img -= clutter * 90 * np.clip(ndimage.gaussian_filter(rng.normal(0, 1, (IMG, IMG)), 3) * 4, 0, None)\n", + " if blur > 0:\n", + " img = ndimage.gaussian_filter(img, blur)\n", + " mean = img.mean()\n", + " img = mean + (img - mean) * contrast\n", + " img = img + rng.normal(0, noise, img.shape)\n", + " if invert: # 야간 열상(IR) 극성 반전\n", + " img = 255 - img\n", + " return np.clip(img, 0, 255).astype(np.uint8)\n", + "\n", + "\n", + "# 촬영 조건 프리셋: 기본 + 복합 위협 시나리오\n", + "CONDITIONS = {\n", + " \"basic\": {},\n", + " \"offcenter\": {\"shift\": 5.0, \"fore\": (.6, .85)},\n", + " \"far\": {\"scale\": (.32, .45), \"blur\": .6},\n", + " \"blur\": {\"blur\": (1.2, 1.8)},\n", + " \"lowcon\": {\"contrast\": (.2, .35)},\n", + " \"clutter\": {\"clutter\": .8},\n", + " \"occlude\": {\"occlude\": .08},\n", + " \"oblique\": {\"fore\": (.35, .6)},\n", + " \"night\": {\"invert\": True, \"noise\": 10.0},\n", + " \"noise\": {\"noise\": 28.0},\n", + " \"combo\": {\"scale\": (.4, .55), \"blur\": (.8, 1.1), \"noise\": 12.0, \"contrast\": (.6, .8)},\n", + "}\n", + "\n", + "\n", + "def draw_cond(cls, cond, rng):\n", + " kw = {}\n", + " for k, v in CONDITIONS[cond].items():\n", + " kw[k] = rng.uniform(*v) if isinstance(v, tuple) else v\n", + " return draw(cls, rng, **kw)\n", + "\n", + "\n", + "def make_dataset(n_per_class, seed, conds=(\"basic\",)):\n", + " \"\"\"(이미지 배열 N x 32 x 32, 정답 라벨, 조건) 반환. 조건은 conds에서 번갈아 고른다.\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " X, y, c = [], [], []\n", + " for cls in CLASSES:\n", + " for i in range(n_per_class):\n", + " cond = conds[i % len(conds)]\n", + " X.append(draw_cond(cls, cond, rng)); y.append(cls); c.append(cond)\n", + " return np.array(X), np.array(y), np.array(c)\n", + "# ---------------------------------------------------------------------------\n", + "\n", + "# ---- 전처리(배치 벡터화)·형태 특징 공통 함수 ----\n", + "OUT = 24 # 크기 정규화 결과(24x24)\n", + "NOISE_T = 12.0 # 잡음 추정값이 이보다 크면 3x3 중간값 필터 적용(악천후·센서 잡음)\n", + "FEATS = [\"elong\", \"fill\", \"solidity\", \"n_parts\", \"end_gap\", \"mid_bulge\", \"w_max\", \"compact\"]\n", + "\n", + "\n", + "def preprocess(images, noise_t=NOISE_T):\n", + " \"\"\"N장 일괄 처리: 결측 보정 -> 극성(야간 반전) 보정 -> 조건별 잡음 제거 -> 명암 대비 강화 -> 분할 -> 윤곽선 -> 방향·크기 정규화.\"\"\"\n", + " f = np.array(images, dtype=float).reshape(-1, IMG, IMG)\n", + " n = len(f)\n", + " bad = ~np.isfinite(f)\n", + " missing = bad.sum(axis=(1, 2))\n", + " if bad.any(): # 결측 픽셀: 영상 중앙값으로 채운 뒤 주변 3x3 중간값으로 보정\n", + " med = np.array([np.median(x[np.isfinite(x)]) if np.isfinite(x).any() else 128.0 for x in f])\n", + " f = np.where(bad, med[:, None, None], f)\n", + " f = np.where(bad, ndimage.median_filter(f, size=(1, 3, 3)), f)\n", + " border = np.concatenate([f[:, 0], f[:, -1], f[:, :, 0], f[:, :, -1]], axis=1)\n", + " inverted = np.median(border, axis=1) < 128 # 배경이 어두우면 야간 열상으로 보고 반전\n", + " f[inverted] = 255 - f[inverted]\n", + " med3 = ndimage.median_filter(f, size=(1, 3, 3))\n", + " noise = 1.4826 * np.median(np.abs(f - med3).reshape(n, -1), axis=1)\n", + " noisy = noise > noise_t\n", + " f[noisy] = med3[noisy]\n", + " lo, hi = np.percentile(f.reshape(n, -1), [1, 99], axis=1)\n", + " e = np.clip((f - lo[:, None, None]) / np.maximum(hi - lo, 1e-6)[:, None, None], 0, 1) # 명암 대비 강화\n", + " mask = e < _otsu(e)[:, None, None]\n", + " mask &= ndimage.uniform_filter(mask.astype(float), size=(1, 3, 3)) * 9 >= 2.5 # 고립 점 잡음 제거\n", + " edge = np.hypot(ndimage.sobel(e, axis=1), ndimage.sobel(e, axis=2)) # 윤곽선(Sobel)\n", + " ok = (mask.sum(axis=(1, 2)) >= 4) & (hi - lo > 8)\n", + " return {\"enh\": e, \"mask\": mask, \"edge\": edge / np.maximum(edge.max(axis=(1, 2)), 1e-6)[:, None, None],\n", + " \"norm\": _normalize(1 - e, mask, ok), \"inverted\": inverted, \"noise\": noise, \"denoised\": noisy,\n", + " \"missing\": missing, \"contrast\": hi - lo, \"ok\": ok}\n", + "\n", + "\n", + "def _otsu(e, bins=64):\n", + " n = len(e)\n", + " idx = np.minimum((e * bins).astype(int), bins - 1) + bins * np.arange(n)[:, None, None]\n", + " hist = np.bincount(idx.ravel(), minlength=bins * n).reshape(n, bins).astype(float)\n", + " centers = (np.arange(bins) + 0.5) / bins\n", + " w = np.cumsum(hist, axis=1); m = np.cumsum(hist * centers, axis=1)\n", + " tot, mt = w[:, -1:], m[:, -1:]\n", + " between = (mt * w / tot - m) ** 2 / np.maximum(w * (tot - w), 1)\n", + " return (np.argmax(between, axis=1) + 1) / bins\n", + "\n", + "\n", + "def _normalize(obj, mask, ok):\n", + " \"\"\"주축 방향을 세로로 돌리고 비행체 영역만 24x24로 다시 샘플링한다(회전·크기·위치 불변). 한 번의 보간으로 일괄 처리.\"\"\"\n", + " n = len(obj)\n", + " w = mask.astype(float)\n", + " m00 = np.maximum(w.sum(axis=(1, 2)), 1)\n", + " yy, xx = np.mgrid[0:IMG, 0:IMG].astype(float)\n", + " cy, cx = (w * yy).sum(axis=(1, 2)) / m00, (w * xx).sum(axis=(1, 2)) / m00\n", + " dy, dx = yy - cy[:, None, None], xx - cx[:, None, None]\n", + " mu20, mu02, mu11 = (w * dy * dy).sum(axis=(1, 2)), (w * dx * dx).sum(axis=(1, 2)), (w * dy * dx).sum(axis=(1, 2))\n", + " th = 0.5 * np.arctan2(2 * mu11, mu20 - mu02)\n", + " a = np.stack([np.cos(th), np.sin(th)], 1) # 주축(y, x)\n", + " b = np.stack([-np.sin(th), np.cos(th)], 1) # 부축\n", + " pa = np.where(mask, dy * a[:, 0, None, None] + dx * a[:, 1, None, None], 0)\n", + " pb = np.where(mask, dy * b[:, 0, None, None] + dx * b[:, 1, None, None], 0)\n", + " half = np.maximum(np.abs(pa).max(axis=(1, 2)), np.abs(pb).max(axis=(1, 2))) + 1.0\n", + " g = np.linspace(-1, 1, OUT)\n", + " U, V = np.meshgrid(g, g, indexing=\"ij\")\n", + " Y = cy[:, None, None] + half[:, None, None] * (U * a[:, 0, None, None] + V * b[:, 0, None, None])\n", + " X = cx[:, None, None] + half[:, None, None] * (U * a[:, 1, None, None] + V * b[:, 1, None, None])\n", + " N = np.broadcast_to(np.arange(n)[:, None, None], Y.shape)\n", + " out = ndimage.map_coordinates(obj * mask, [N, Y, X], order=1, cval=0.0)\n", + " skew = (out * (U ** 3)).sum(axis=(1, 2))\n", + " out[skew < 0] = out[skew < 0, ::-1] # 무게가 쏠린 쪽을 위로(180도 모호성 제거)\n", + " out[~ok] = 0\n", + " return np.clip(out, 0, 1)\n", + "\n", + "\n", + "def shape_features(mask):\n", + " \"\"\"분할 마스크 1장에서 회전에 무관한 형태 특징 8종을 계산한다.\"\"\"\n", + " if mask.sum() < 4:\n", + " return dict.fromkeys(FEATS, 0.0)\n", + " yx = np.argwhere(mask).astype(float)\n", + " c = yx - yx.mean(axis=0)\n", + " val, vec = np.linalg.eigh(np.cov(c.T) + 1e-6 * np.eye(2))\n", + " t, s = c @ vec[:, 1], c @ vec[:, 0]\n", + " length, width = np.ptp(t) + 1, np.ptp(s) + 1\n", + " corners = np.vstack([yx + d for d in ([.5, .5], [.5, -.5], [-.5, .5], [-.5, -.5])])\n", + " try:\n", + " hull = ConvexHull(corners).volume\n", + " except Exception:\n", + " hull = float(mask.sum())\n", + " bins = np.digitize(t, np.linspace(t.min(), t.max() + 1e-6, 4)[1:-1])\n", + " w = np.array([np.ptp(s[bins == k]) + 1 if (bins == k).any() else 0 for k in range(3)]) / length\n", + " boundary = mask & ~ndimage.binary_erosion(mask)\n", + " return {\"elong\": float(np.sqrt(val[1] / max(val[0], 1e-6))), \"fill\": float(mask.sum() / (length * width)),\n", + " \"solidity\": float(mask.sum() / max(hull, 1)), \"n_parts\": float(ndimage.label(mask)[1]),\n", + " \"end_gap\": float(abs(w[0] - w[2])), \"mid_bulge\": float(w[1] - max(w[0], w[2])),\n", + " \"w_max\": float(w.max()), \"compact\": float(boundary.sum() / np.sqrt(mask.sum()))}\n", + "\n", + "\n", + "def feature_matrix(images):\n", + " \"\"\"전처리 결과, 형태 특징 행렬(N x 8), 정규화 이미지 행렬(N x 576)을 돌려준다.\"\"\"\n", + " P = preprocess(images)\n", + " F = np.array([[d[k] for k in FEATS] for d in map(shape_features, P[\"mask\"])])\n", + " return P, F, P[\"norm\"].reshape(len(F), -1)\n", + "\n", + "FEAT_KOR = {\"elong\": \"길쭉함\", \"fill\": \"채움률\", \"solidity\": \"볼록도\", \"n_parts\": \"분리 부분 수\", \"end_gap\": \"앞뒤 폭 차이\",\n", + " \"mid_bulge\": \"중앙부 날개 돌출\", \"w_max\": \"최대 폭(날개 폭)\", \"compact\": \"윤곽 복잡도\"}\n", + "KEY = {\"civil_quad\": \"원형 회전날개 4개\", \"civil_fw\": \"직선 주익과 꼬리날개\", \"attack_fw\": \"꼬리 없는 삼각형 날개\",\n", + " \"cruise\": \"가는 동체와 작은 중간 날개\", \"ballistic\": \"굵은 동체·뾰족한 머리·꼬리 핀\", \"bird\": \"갈매기형으로 굽은 날개\"}\n", + "ACTIONS = {\"APPROVE\": \"승인(공개)\", \"EDIT\": \"수정 후 승인\", \"REJECT\": \"반려(재작성)\"}\n", + "SLA_H = 24\n", + "\n", + "\n", + "def template(cls):\n", + " return draw(cls, np.random.default_rng(0), angle=0, scale=.85, fore=1.0, blur=0, noise=0, shift=0)\n", + "\n", + "\n", + "TEMPL = {c: template(c) for c in CLASSES}\n", + "TF = {c: shape_features(preprocess([TEMPL[c]])[\"mask\"][0]) for c in CLASSES}\n", + "SPREAD = {k: np.std([TF[c][k] for c in CLASSES]) + 1e-6 for k in FEATS}\n", + "\n", + "\n", + "def draft(answer, chosen):\n", + " \"\"\"AI 해설 초안: 두 클래스 기준 실루엣의 형태 특징 차이 상위 3개를 문장으로 만든다(초안일 뿐 공개하지 않음).\"\"\"\n", + " diff = sorted(FEATS, key=lambda k: -abs(TF[answer][k] - TF[chosen][k]) / SPREAD[k])[:3]\n", + " parts = [f\"{FEAT_KOR[k]} {TF[answer][k]:.2f} vs {TF[chosen][k]:.2f}\" for k in diff]\n", + " return f\"정답 {KOR[answer]}은(는) {KEY[answer]}, 선택한 {KOR[chosen]}은(는) {KEY[chosen]}. 차이: \" + \"; \".join(parts)\n", + "\n", + "\n", + "class ReviewQueue:\n", + " def __init__(self):\n", + " self.items, self.log, self.clock = {}, [], 0.0\n", + "\n", + " def _record(self, body):\n", + " prev = self.log[-1][\"hash\"] if self.log else \"GENESIS\"\n", + " body = {**body, \"t_h\": self.clock}\n", + " h = hashlib.sha256((prev + json.dumps(body, ensure_ascii=False, sort_keys=True)).encode()).hexdigest()\n", + " self.log.append({**body, \"prev\": prev, \"hash\": h})\n", + "\n", + " def verify(self):\n", + " prev = \"GENESIS\"\n", + " for n, r in enumerate(self.log, 1):\n", + " body = {k: v for k, v in r.items() if k not in (\"prev\", \"hash\")}\n", + " if r[\"prev\"] != prev or r[\"hash\"] != hashlib.sha256((prev + json.dumps(body, ensure_ascii=False, sort_keys=True)).encode()).hexdigest():\n", + " return False, n\n", + " prev = r[\"hash\"]\n", + " return True, -1\n", + "\n", + " def submit(self, learner, answer, chosen, freq):\n", + " key = f\"{answer}->{chosen}\"\n", + " if key in self.items and self.items[key][\"status\"] == \"PENDING\": # 같은 혼동 쌍은 한 건으로 묶고 빈도만 올린다\n", + " self.items[key][\"learners\"].append(learner); self.items[key][\"freq\"] = freq\n", + " else:\n", + " self.items[key] = {\"key\": key, \"answer\": answer, \"chosen\": chosen, \"draft\": draft(answer, chosen), \"status\": \"PENDING\",\n", + " \"published\": None, \"learners\": [learner], \"freq\": freq, \"t_in\": self.clock, \"escalated\": False}\n", + " self._record({\"event\": \"SUBMIT\", \"key\": key, \"learner\": learner, \"by\": \"AI\"})\n", + "\n", + " def order(self):\n", + " todo = [i for i in self.items.values() if i[\"status\"] == \"PENDING\"]\n", + " return sorted(todo, key=lambda i: (not i[\"escalated\"], -i[\"freq\"], i[\"t_in\"]))\n", + "\n", + " def decide(self, key, action, instructor, reason, text=None, feedback=\"\"):\n", + " it = self.items.get(key)\n", + " if it is None or it[\"status\"] != \"PENDING\":\n", + " raise ValueError(\"검토 대기 중인 항목이 아님\")\n", + " if action not in ACTIONS or not instructor.startswith(\"INS\"):\n", + " raise ValueError(\"허용되지 않은 조치 또는 교관 ID 없음\")\n", + " if len(reason.strip()) < 5:\n", + " raise ValueError(\"판단 근거 5자 이상 필요\")\n", + " if action == \"EDIT\" and not text:\n", + " raise ValueError(\"수정 승인은 수정 해설문 필요\")\n", + " it[\"status\"] = {\"APPROVE\": \"APPROVED\", \"EDIT\": \"APPROVED\", \"REJECT\": \"REJECTED\"}[action]\n", + " it[\"published\"] = (text or it[\"draft\"]) if action != \"REJECT\" else None\n", + " it[\"wait_h\"] = self.clock - it[\"t_in\"]\n", + " self._record({\"event\": \"DECIDE\", \"key\": key, \"action\": action, \"by\": instructor, \"reason\": reason, \"feedback\": feedback})\n", + "\n", + " def check_sla(self):\n", + " out = []\n", + " for i in self.items.values():\n", + " if i[\"status\"] == \"PENDING\" and not i[\"escalated\"] and self.clock - i[\"t_in\"] > SLA_H:\n", + " i[\"escalated\"] = True; out.append(i[\"key\"])\n", + " self._record({\"event\": \"ESCALATE\", \"key\": i[\"key\"], \"to\": \"교육훈련 담당관\"})\n", + " return out\n", + "\n", + "\n", + "# [교육용가상자료] 학습자 20명의 오답 기록(가명 L01~L20)\n", + "rng = np.random.default_rng(4)\n", + "pairs = [(\"attack_fw\", \"civil_fw\")] * 6 + [(\"cruise\", \"ballistic\")] * 5 + [(\"civil_fw\", \"attack_fw\")] * 3 + [(\"bird\", \"civil_fw\")] * 2 + [(\"ballistic\", \"cruise\")] * 4\n", + "q = ReviewQueue()\n", + "freq = {}\n", + "for n, (a, c) in enumerate(rng.permutation(np.array(pairs, dtype=object))):\n", + " freq[(a, c)] = freq.get((a, c), 0) + 1\n", + " q.clock += 0.5\n", + " q.submit(f\"L{n + 1:02d}\", a, c, freq[(a, c)])\n", + "print(f\"오답 {len(pairs)}건 -> 해설 검토 항목 {len(q.items)}건 (혼동 쌍 단위), 공개된 해설 {sum(i['published'] is not None for i in q.items.values())}건\")\n", + "print(\"검토 순서(빈도순):\", [f\"{i['key']}({i['freq']})\" for i in q.order()])\n", + "print(\"\\n[AI 해설 초안 예시 - 교관 검토 전 비공개]\\n\" + q.order()[0][\"draft\"])\n", + "\n", + "for args in [(\"attack_fw->civil_fw\", \"APPROVE\", \"\", \"근거 충분\"), (\"attack_fw->civil_fw\", \"EDIT\", \"INS01\", \"표현 보완 필요\"), (\"cruise->ballistic\", \"PUBLISH\", \"INS01\", \"자동 공개\")]:\n", + " try:\n", + " q.decide(*args)\n", + " except ValueError as e:\n", + " print(\"차단:\", e)\n", + "\n", + "q.clock += 2; q.decide(\"attack_fw->civil_fw\", \"EDIT\", \"INS01\", \"꼬리날개 유무를 먼저 보도록 수정\",\n", + " text=\"꼬리날개가 없고 날개가 삼각형이면 자폭드론형, 직선 날개와 꼬리날개가 있으면 민간 고정익형이다.\", feedback=\"원거리 예시 추가 요망\")\n", + "q.clock += 3; q.decide(\"cruise->ballistic\", \"APPROVE\", \"INS02\", \"중간 날개 유무 설명이 정확함\")\n", + "q.clock += 1; q.decide(\"bird->civil_fw\", \"REJECT\", \"INS02\", \"조류 날갯짓 변형 설명 누락, 재작성\")\n", + "q.clock += 30\n", + "print(\"SLA 초과 상신:\", q.check_sla())\n", + "q.clock += 1; q.decide(\"ballistic->cruise\", \"APPROVE\", \"INS01\", \"몸통 굵기 비교가 명확함\")\n", + "\n", + "df = pd.DataFrame(q.items.values())\n", + "expected = len(pairs) + int((df[\"status\"] != \"PENDING\").sum()) + int(df[\"escalated\"].sum()) # 제출 + 결정 + 상신 이벤트 수\n", + "print(\"남은 검토 대기:\", [f\"{i['key']}(대기 {q.clock - i['t_in']:.1f}h)\" for i in q.order()])\n", + "print(\"\\n\" + df[[\"key\", \"freq\", \"status\", \"escalated\", \"wait_h\"]].fillna(\"-\").to_string(index=False))\n", + "print(f\"\\n자동 공개 0건(승인 없이 공개된 해설 {sum(1 for i in q.items.values() if i['published'] and i['status'] != 'APPROVED')}건), \"\n", + " f\"감사 로그 {len(q.log)}건, 로깅 성공률 {len(q.log) / expected:.0%}, 해시 체인 검증 {q.verify()}\")\n", + "q.log[3][\"learner\"] = \"L99\"\n", + "print(\"4번째 기록 변조 후 검증(False, 깨진 위치):\", q.verify())\n", + "print(\"교관 피드백 입력 항목: 교관 ID, 조치, 판단 근거, 수정 해설문, 지휘관·교관 의견\")\n", + "\n", + "a, c = \"attack_fw\", \"civil_fw\"\n", + "fig, ax = plt.subplots(1, 3, figsize=(10, 3.6))\n", + "ax[0].imshow(TEMPL[a], cmap=\"gray\"); ax[0].set_title(f\"correct: {a}\")\n", + "ax[1].imshow(TEMPL[c], cmap=\"gray\"); ax[1].set_title(f\"chosen: {c}\")\n", + "ax[2].imshow((TEMPL[c].astype(float) - TEMPL[a]), cmap=\"coolwarm\"); ax[2].set_title(\"difference (red = only correct)\")\n", + "for x in ax:\n", + " x.axis(\"off\")\n", + "plt.suptitle(\"Wrong-answer comparison for instructor review (synthetic)\"); plt.tight_layout(); plt.show()\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 07 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + "오답 20건 -> 해설 검토 항목 5건 (혼동 쌍 단위), 공개된 해설 0건\n", + "검토 순서(빈도순): ['attack_fw->civil_fw(6)', 'cruise->ballistic(5)', 'ballistic->cruise(4)', 'civil_fw->attack_fw(3)', 'bird->civil_fw(2)']\n", + "\n", + "[AI 해설 초안 예시 - 교관 검토 전 비공개]\n", + "정답 삼각익 자폭드론형은(는) 꼬리 없는 삼각형 날개, 선택한 민간 고정익형은(는) 직선 주익과 꼬리날개. 차이: 윤곽 복잡도 3.82 vs 7.95; 볼록도 0.86 vs 0.40; 채움률 0.51 vs 0.29\n", + "차단: 허용되지 않은 조치 또는 교관 ID 없음\n", + "차단: 수정 승인은 수정 해설문 필요\n", + "차단: 허용되지 않은 조치 또는 교관 ID 없음\n", + "SLA 초과 상신: ['civil_fw->attack_fw', 'ballistic->cruise']\n", + "남은 검토 대기: ['civil_fw->attack_fw(대기 46.0h)']\n", + "\n", + " key freq status escalated wait_h\n", + "attack_fw->civil_fw 6 APPROVED False 11.5\n", + "civil_fw->attack_fw 3 PENDING True -\n", + " cruise->ballistic 5 APPROVED False 13.5\n", + " ballistic->cruise 4 APPROVED True 44.5\n", + " bird->civil_fw 2 REJECTED False 10.0\n", + "\n", + "자동 공개 0건(승인 없이 공개된 해설 0건), 감사 로그 26건, 로깅 성공률 100%, 해시 체인 검증 (True, -1)\n", + "4번째 기록 변조 후 검증(False, 깨진 위치): (False, 4)\n", + "교관 피드백 입력 항목: 교관 ID, 조치, 판단 근거, 수정 해설문, 지휘관·교관 의견\n", + "```\n", + "
\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 08. 다량의 저품질 흐린 실루엣 이미지 분류 스트레스 테스트 및 한계치 검증\n", + "**소요시간**: 약 3.5시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "흐린 저품질 실루엣 이미지 대량 분류 스트레스 테스트를 수행한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **다량의 흐리고 품질이 낮은 실루엣 이미지가 한꺼번에 들어올 때 분류 서비스가 멈추지 않고 안전하게 동작하는지 한계치를 검증한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **평시 100장에서 500% 증가한 600장까지 요청을 늘리고, 손상 입력과 극저화질 영상을 섞어 시스템 다운·결과 유실 여부 확인하고 유효성을 점검하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `tests/trn03_stress_test.py 및 스트레스 테스트 분석 보고서` 형태의 결과물을 완성한다.\n", + "\n", + "### 🔍 평가 및 검증 기준 (PASS 조건)\n", + "#### 미션 판정\n", + "\n", + "**평가 구분:** 필수\n", + "\n", + "**핵심 평가영역:** 극단 상황에서의 안전 실패(Fail-safe) 및 기능 축소 작동 적절성\n", + "\n", + "#### 미션 PASS 조건\n", + "\n", + "- 정량 기준: 동시 트래픽 500% 증가 시 시스템 다운 0건, 데이터 유실률 0%\n", + "\n", + "\n", + "- 진행 기준: 대량 시뮬레이션 부하 주입 테스트 통과 및 병목 구간 프로파일링 완료\n", + "\n", + "\n", + "- 검증 방법: 부하 테스트 도구를 통한 처리량(Throughput) 및 메모리 누수 검증\n", + "\n", + "\n", + "#### 채점자 판정 메모\n", + "\n", + "> 단순 코드 실행 여부뿐만 아니라 출력된 수치와 근거 자료가 군 실무 요구사항에 부합하는지 면밀히 확인한다.\n", + "\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "\"\"\"흐리고 품질이 낮은 실루엣 이미지를 평시의 최대 6배(500% 증가)까지 한꺼번에 넣고 손상 입력을 섞어, 분류 서비스가 멈추지 않는지(다운 0건)·결과가 빠지지 않는지(유실률 0%)·흐림 한계치·처리량·메모리·병목 구간을 검증한다.\"\"\"\n", + "import time\n", + "import tracemalloc\n", + "import numpy as np\n", + "import pandas as pd\n", + "import matplotlib.pyplot as plt\n", + "from matplotlib.path import Path as MplPath\n", + "from scipy import ndimage\n", + "from scipy.spatial import ConvexHull\n", + "from sklearn.neural_network import MLPClassifier\n", + "from sklearn.preprocessing import StandardScaler\n", + "\n", + "# ---------------------------------------------------------------------------\n", + "# [교육용가상자료] 합성 실루엣 생성기 (실제 기체 사진·도면·제원 사용 안 함)\n", + "# 사각형·삼각형·원 같은 기본 도형을 조합한 추상 실루엣을 32x32 흑백 이미지로 그린다.\n", + "# 회전(다각도), 원근 축소(측면 시점), 크기(원거리), 흐림·잡음·저대비·구름·가림·야간 반전을 조절한다.\n", + "# ---------------------------------------------------------------------------\n", + "IMG = 32\n", + "CLASSES = [\"civil_quad\", \"civil_fw\", \"attack_fw\", \"cruise\", \"ballistic\", \"bird\"]\n", + "KOR = {\"civil_quad\": \"민간 쿼드콥터형\", \"civil_fw\": \"민간 고정익형\", \"attack_fw\": \"삼각익 자폭드론형\",\n", + " \"cruise\": \"순항미사일형\", \"ballistic\": \"탄도미사일형\", \"bird\": \"조류\"}\n", + "THREAT = {\"attack_fw\", \"cruise\", \"ballistic\"}\n", + "\n", + "\n", + "def _rect(x0, x1, y0, y1):\n", + " # [TODO : 학생 실습 8] 다량의 저품질 흐린 실루엣 이미지 분류 스트레스 테스트 및 한계치 검증 관련 핵심 로직을 구현하세요.\n", + " # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n", + " # <<< 여기에 코드를 작성하세요 >>>\n", + " pass # 작성 후 pass를 제거하세요.\n", + "def _bar(p, q, w):\n", + " # [TODO : 학생 실습 8] 다량의 저품질 흐린 실루엣 이미지 분류 스트레스 테스트 및 한계치 검증 관련 핵심 로직을 구현하세요.\n", + " # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n", + " # <<< 여기에 코드를 작성하세요 >>>\n", + " pass # 작성 후 pass를 제거하세요.\n", + "SHAPES = {\n", + " \"civil_quad\": {\"poly\": [_bar((-.55, -.55), (.55, .55), .1), _bar((-.55, .55), (.55, -.55), .1), _rect(-.15, .15, -.15, .15)],\n", + " \"circ\": [(-.55, -.55, .24), (.55, .55, .24), (-.55, .55, .24), (.55, -.55, .24)]},\n", + " \"civil_fw\": {\"poly\": [_rect(-.07, .07, -.8, .72), _rect(-.9, .9, .05, .25), _rect(-.33, .33, -.8, -.66)], \"circ\": []},\n", + " \"attack_fw\": {\"poly\": [[(0, .85), (.85, -.5), (.12, -.42), (0, -.6), (-.12, -.42), (-.85, -.5)]], \"circ\": []},\n", + " \"cruise\": {\"poly\": [_rect(-.07, .07, -.9, .7), [(-.07, .7), (0, .92), (.07, .7)], _rect(-.42, .42, -.05, .07),\n", + " [(-.07, -.9), (-.24, -.95), (-.07, -.74)], [(.07, -.9), (.24, -.95), (.07, -.74)]], \"circ\": []},\n", + " \"ballistic\": {\"poly\": [_rect(-.13, .13, -.85, .6), [(-.13, .6), (0, .95), (.13, .6)],\n", + " [(-.13, -.85), (-.34, -.95), (-.13, -.58)], [(.13, -.85), (.34, -.95), (.13, -.58)]], \"circ\": []},\n", + " \"bird\": {\"poly\": [[(-.9, .12), (-.5, .28), (-.12, .06), (0, .22), (.12, .06), (.5, .28), (.9, .12), (.5, .12),\n", + " (.12, -.1), (0, -.38), (-.12, -.1), (-.5, .12)]], \"circ\": []},\n", + "}\n", + "_S = 2\n", + "_g = (np.arange(IMG * _S) + 0.5) / _S\n", + "_GX, _GY = np.meshgrid(_g, _g)\n", + "_PTS = np.c_[_GX.ravel(), _GY.ravel()]\n", + "_PATHS = {c: [MplPath(np.array(p)) for p in s[\"poly\"]] for c, s in SHAPES.items()}\n", + "\n", + "\n", + "def draw(cls, rng, angle=None, scale=None, fore=None, blur=0.3, noise=4.0, contrast=1.0,\n", + " clutter=0.0, occlude=0.0, invert=False, shift=1.5):\n", + " \"\"\"클래스 1개 실루엣 이미지(uint8 32x32)를 그린다. 배경은 밝은 하늘, 비행체는 어두운 그림자.\"\"\"\n", + " angle = rng.uniform(0, 360) if angle is None else angle\n", + " scale = rng.uniform(.7, .95) if scale is None else scale\n", + " fore = rng.uniform(.85, 1.0) if fore is None else fore\n", + " th = np.radians(angle)\n", + " c0 = IMG / 2 + rng.uniform(-shift, shift, 2)\n", + " d = (_PTS - c0) / (scale * IMG / 2) # 이미지 좌표 -> 도형 좌표(역변환)\n", + " u = d[:, 0] * np.cos(th) + d[:, 1] * np.sin(th)\n", + " v = (-d[:, 0] * np.sin(th) + d[:, 1] * np.cos(th)) / fore # 원근 축소(기수 방향으로 짧아 보임)\n", + " uv = np.c_[u, -v]\n", + " m = np.zeros(len(uv), bool)\n", + " near = np.flatnonzero(np.abs(uv).max(axis=1) <= 1.0) # 도형이 있을 수 있는 영역만 검사(속도)\n", + " for p in _PATHS[cls]:\n", + " m[near] |= p.contains_points(uv[near])\n", + " for cx, cy, r in SHAPES[cls][\"circ\"]:\n", + " m |= (uv[:, 0] - cx) ** 2 + (uv[:, 1] - cy) ** 2 <= r * r\n", + " cov = m.reshape(IMG, _S, IMG, _S).mean(axis=(1, 3))\n", + " if occlude > 0: # 부분 가림(구름 조각)\n", + " h = int(IMG * np.sqrt(occlude)); y0, x0 = rng.integers(8, IMG - 8 - h // 2, 2)\n", + " cov[y0:y0 + h, x0:x0 + h] = 0\n", + " bg = rng.uniform(180, 220) + np.linspace(-10, 10, IMG)[:, None]\n", + " fg = rng.uniform(30, 60)\n", + " img = bg * (1 - cov) + fg * cov\n", + " if clutter > 0: # 구름·배경 얼룩\n", + " img -= clutter * 90 * np.clip(ndimage.gaussian_filter(rng.normal(0, 1, (IMG, IMG)), 3) * 4, 0, None)\n", + " if blur > 0:\n", + " img = ndimage.gaussian_filter(img, blur)\n", + " mean = img.mean()\n", + " img = mean + (img - mean) * contrast\n", + " img = img + rng.normal(0, noise, img.shape)\n", + " if invert: # 야간 열상(IR) 극성 반전\n", + " img = 255 - img\n", + " return np.clip(img, 0, 255).astype(np.uint8)\n", + "\n", + "\n", + "# 촬영 조건 프리셋: 기본 + 복합 위협 시나리오\n", + "CONDITIONS = {\n", + " \"basic\": {},\n", + " \"offcenter\": {\"shift\": 5.0, \"fore\": (.6, .85)},\n", + " \"far\": {\"scale\": (.32, .45), \"blur\": .6},\n", + " \"blur\": {\"blur\": (1.2, 1.8)},\n", + " \"lowcon\": {\"contrast\": (.2, .35)},\n", + " \"clutter\": {\"clutter\": .8},\n", + " \"occlude\": {\"occlude\": .08},\n", + " \"oblique\": {\"fore\": (.35, .6)},\n", + " \"night\": {\"invert\": True, \"noise\": 10.0},\n", + " \"noise\": {\"noise\": 28.0},\n", + " \"combo\": {\"scale\": (.4, .55), \"blur\": (.8, 1.1), \"noise\": 12.0, \"contrast\": (.6, .8)},\n", + "}\n", + "\n", + "\n", + "def draw_cond(cls, cond, rng):\n", + " kw = {}\n", + " for k, v in CONDITIONS[cond].items():\n", + " kw[k] = rng.uniform(*v) if isinstance(v, tuple) else v\n", + " return draw(cls, rng, **kw)\n", + "\n", + "\n", + "def make_dataset(n_per_class, seed, conds=(\"basic\",)):\n", + " \"\"\"(이미지 배열 N x 32 x 32, 정답 라벨, 조건) 반환. 조건은 conds에서 번갈아 고른다.\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " X, y, c = [], [], []\n", + " for cls in CLASSES:\n", + " for i in range(n_per_class):\n", + " cond = conds[i % len(conds)]\n", + " X.append(draw_cond(cls, cond, rng)); y.append(cls); c.append(cond)\n", + " return np.array(X), np.array(y), np.array(c)\n", + "# ---------------------------------------------------------------------------\n", + "\n", + "# ---- 전처리(배치 벡터화)·형태 특징 공통 함수 ----\n", + "OUT = 24 # 크기 정규화 결과(24x24)\n", + "NOISE_T = 12.0 # 잡음 추정값이 이보다 크면 3x3 중간값 필터 적용(악천후·센서 잡음)\n", + "FEATS = [\"elong\", \"fill\", \"solidity\", \"n_parts\", \"end_gap\", \"mid_bulge\", \"w_max\", \"compact\"]\n", + "\n", + "\n", + "def preprocess(images, noise_t=NOISE_T):\n", + " \"\"\"N장 일괄 처리: 결측 보정 -> 극성(야간 반전) 보정 -> 조건별 잡음 제거 -> 명암 대비 강화 -> 분할 -> 윤곽선 -> 방향·크기 정규화.\"\"\"\n", + " f = np.array(images, dtype=float).reshape(-1, IMG, IMG)\n", + " n = len(f)\n", + " bad = ~np.isfinite(f)\n", + " missing = bad.sum(axis=(1, 2))\n", + " if bad.any(): # 결측 픽셀: 영상 중앙값으로 채운 뒤 주변 3x3 중간값으로 보정\n", + " med = np.array([np.median(x[np.isfinite(x)]) if np.isfinite(x).any() else 128.0 for x in f])\n", + " f = np.where(bad, med[:, None, None], f)\n", + " f = np.where(bad, ndimage.median_filter(f, size=(1, 3, 3)), f)\n", + " border = np.concatenate([f[:, 0], f[:, -1], f[:, :, 0], f[:, :, -1]], axis=1)\n", + " inverted = np.median(border, axis=1) < 128 # 배경이 어두우면 야간 열상으로 보고 반전\n", + " f[inverted] = 255 - f[inverted]\n", + " med3 = ndimage.median_filter(f, size=(1, 3, 3))\n", + " noise = 1.4826 * np.median(np.abs(f - med3).reshape(n, -1), axis=1)\n", + " noisy = noise > noise_t\n", + " f[noisy] = med3[noisy]\n", + " lo, hi = np.percentile(f.reshape(n, -1), [1, 99], axis=1)\n", + " e = np.clip((f - lo[:, None, None]) / np.maximum(hi - lo, 1e-6)[:, None, None], 0, 1) # 명암 대비 강화\n", + " mask = e < _otsu(e)[:, None, None]\n", + " mask &= ndimage.uniform_filter(mask.astype(float), size=(1, 3, 3)) * 9 >= 2.5 # 고립 점 잡음 제거\n", + " edge = np.hypot(ndimage.sobel(e, axis=1), ndimage.sobel(e, axis=2)) # 윤곽선(Sobel)\n", + " ok = (mask.sum(axis=(1, 2)) >= 4) & (hi - lo > 8)\n", + " return {\"enh\": e, \"mask\": mask, \"edge\": edge / np.maximum(edge.max(axis=(1, 2)), 1e-6)[:, None, None],\n", + " \"norm\": _normalize(1 - e, mask, ok), \"inverted\": inverted, \"noise\": noise, \"denoised\": noisy,\n", + " \"missing\": missing, \"contrast\": hi - lo, \"ok\": ok}\n", + "\n", + "\n", + "def _otsu(e, bins=64):\n", + " n = len(e)\n", + " idx = np.minimum((e * bins).astype(int), bins - 1) + bins * np.arange(n)[:, None, None]\n", + " hist = np.bincount(idx.ravel(), minlength=bins * n).reshape(n, bins).astype(float)\n", + " centers = (np.arange(bins) + 0.5) / bins\n", + " w = np.cumsum(hist, axis=1); m = np.cumsum(hist * centers, axis=1)\n", + " tot, mt = w[:, -1:], m[:, -1:]\n", + " between = (mt * w / tot - m) ** 2 / np.maximum(w * (tot - w), 1)\n", + " return (np.argmax(between, axis=1) + 1) / bins\n", + "\n", + "\n", + "def _normalize(obj, mask, ok):\n", + " \"\"\"주축 방향을 세로로 돌리고 비행체 영역만 24x24로 다시 샘플링한다(회전·크기·위치 불변). 한 번의 보간으로 일괄 처리.\"\"\"\n", + " n = len(obj)\n", + " w = mask.astype(float)\n", + " m00 = np.maximum(w.sum(axis=(1, 2)), 1)\n", + " yy, xx = np.mgrid[0:IMG, 0:IMG].astype(float)\n", + " cy, cx = (w * yy).sum(axis=(1, 2)) / m00, (w * xx).sum(axis=(1, 2)) / m00\n", + " dy, dx = yy - cy[:, None, None], xx - cx[:, None, None]\n", + " mu20, mu02, mu11 = (w * dy * dy).sum(axis=(1, 2)), (w * dx * dx).sum(axis=(1, 2)), (w * dy * dx).sum(axis=(1, 2))\n", + " th = 0.5 * np.arctan2(2 * mu11, mu20 - mu02)\n", + " a = np.stack([np.cos(th), np.sin(th)], 1) # 주축(y, x)\n", + " b = np.stack([-np.sin(th), np.cos(th)], 1) # 부축\n", + " pa = np.where(mask, dy * a[:, 0, None, None] + dx * a[:, 1, None, None], 0)\n", + " pb = np.where(mask, dy * b[:, 0, None, None] + dx * b[:, 1, None, None], 0)\n", + " half = np.maximum(np.abs(pa).max(axis=(1, 2)), np.abs(pb).max(axis=(1, 2))) + 1.0\n", + " g = np.linspace(-1, 1, OUT)\n", + " U, V = np.meshgrid(g, g, indexing=\"ij\")\n", + " Y = cy[:, None, None] + half[:, None, None] * (U * a[:, 0, None, None] + V * b[:, 0, None, None])\n", + " X = cx[:, None, None] + half[:, None, None] * (U * a[:, 1, None, None] + V * b[:, 1, None, None])\n", + " N = np.broadcast_to(np.arange(n)[:, None, None], Y.shape)\n", + " out = ndimage.map_coordinates(obj * mask, [N, Y, X], order=1, cval=0.0)\n", + " skew = (out * (U ** 3)).sum(axis=(1, 2))\n", + " out[skew < 0] = out[skew < 0, ::-1] # 무게가 쏠린 쪽을 위로(180도 모호성 제거)\n", + " out[~ok] = 0\n", + " return np.clip(out, 0, 1)\n", + "\n", + "\n", + "def shape_features(mask):\n", + " \"\"\"분할 마스크 1장에서 회전에 무관한 형태 특징 8종을 계산한다.\"\"\"\n", + " if mask.sum() < 4:\n", + " return dict.fromkeys(FEATS, 0.0)\n", + " yx = np.argwhere(mask).astype(float)\n", + " c = yx - yx.mean(axis=0)\n", + " val, vec = np.linalg.eigh(np.cov(c.T) + 1e-6 * np.eye(2))\n", + " t, s = c @ vec[:, 1], c @ vec[:, 0]\n", + " length, width = np.ptp(t) + 1, np.ptp(s) + 1\n", + " corners = np.vstack([yx + d for d in ([.5, .5], [.5, -.5], [-.5, .5], [-.5, -.5])])\n", + " try:\n", + " hull = ConvexHull(corners).volume\n", + " except Exception:\n", + " hull = float(mask.sum())\n", + " bins = np.digitize(t, np.linspace(t.min(), t.max() + 1e-6, 4)[1:-1])\n", + " w = np.array([np.ptp(s[bins == k]) + 1 if (bins == k).any() else 0 for k in range(3)]) / length\n", + " boundary = mask & ~ndimage.binary_erosion(mask)\n", + " return {\"elong\": float(np.sqrt(val[1] / max(val[0], 1e-6))), \"fill\": float(mask.sum() / (length * width)),\n", + " \"solidity\": float(mask.sum() / max(hull, 1)), \"n_parts\": float(ndimage.label(mask)[1]),\n", + " \"end_gap\": float(abs(w[0] - w[2])), \"mid_bulge\": float(w[1] - max(w[0], w[2])),\n", + " \"w_max\": float(w.max()), \"compact\": float(boundary.sum() / np.sqrt(mask.sum()))}\n", + "\n", + "\n", + "def feature_matrix(images):\n", + " \"\"\"전처리 결과, 형태 특징 행렬(N x 8), 정규화 이미지 행렬(N x 576)을 돌려준다.\"\"\"\n", + " P = preprocess(images)\n", + " F = np.array([[d[k] for k in FEATS] for d in map(shape_features, P[\"mask\"])])\n", + " return P, F, P[\"norm\"].reshape(len(F), -1)\n", + "\n", + "# ---- 분류 모델 공통 함수(다층 퍼셉트론: 정규화 이미지 576픽셀 + 형태 특징 8종) ----\n", + "def to_input(F, N, scaler):\n", + " return np.c_[N, scaler.transform(F) * 0.3]\n", + "\n", + "\n", + "def train_model(n_per_class=150, seed=5, conds=tuple(CONDITIONS), hidden=(64,)):\n", + " X, y, _ = make_dataset(n_per_class, seed, conds)\n", + " P, F, N = feature_matrix(X)\n", + " scaler = StandardScaler().fit(F)\n", + " model = MLPClassifier(hidden, max_iter=200, early_stopping=True, random_state=0)\n", + " model.fit(to_input(F, N, scaler), [CLASSES.index(v) for v in y])\n", + " return model, scaler\n", + "\n", + "\n", + "def predict(model, scaler, images):\n", + " \"\"\"(전처리 결과, 클래스별 확률 N x 6)을 돌려준다.\"\"\"\n", + " P, F, N = feature_matrix(images)\n", + " return P, model.predict_proba(to_input(F, N, scaler))\n", + "\n", + "CAPACITY = 200 # 한 번에 처리하는 최대 장수(초과 시 대기열로 나눠 처리 = 성능저하 모드)\n", + "MIN_CONF = 0.5 # 이보다 낮으면 정답을 단정하지 않고 '판별 불가(교관 확인)'\n", + "\n", + "\n", + "def serve(requests, stage):\n", + " \"\"\"요청 전부에 결과를 하나씩 돌려준다. 손상 입력은 '입력 오류', 저신뢰는 '판별 불가'로 안전하게 처리한다.\"\"\"\n", + " results, valid = {}, []\n", + " for rid, img in requests:\n", + " a = np.asarray(img, dtype=float) if img is not None else None\n", + " if a is None or a.shape != (IMG, IMG) or np.isnan(a).mean() > 0.5:\n", + " results[rid] = \"입력 오류(재전송 요청)\"\n", + " else:\n", + " valid.append((rid, a))\n", + " for k in range(0, len(valid), CAPACITY): # 대기열 청크 처리\n", + " ids, imgs = zip(*valid[k:k + CAPACITY])\n", + " t0 = time.perf_counter(); P = preprocess(np.array(imgs)); t1 = time.perf_counter()\n", + " F = np.array([[d[f] for f in FEATS] for d in map(shape_features, P[\"mask\"])]); t2 = time.perf_counter()\n", + " prob = model.predict_proba(to_input(F, P[\"norm\"].reshape(len(F), -1), scaler)); t3 = time.perf_counter()\n", + " stage[\"전처리\"] += t1 - t0; stage[\"형태특징\"] += t2 - t1; stage[\"추론\"] += t3 - t2\n", + " for rid, ok, p in zip(ids, P[\"ok\"], prob):\n", + " results[rid] = CLASSES[int(p.argmax())] if ok and p.max() >= MIN_CONF else \"판별 불가(교관 확인)\"\n", + " return results\n", + "\n", + "\n", + "def load_case(n, seed, blur=(1.2, 2.2), corrupt=0.0):\n", + " rng = np.random.default_rng(seed)\n", + " y = rng.choice(CLASSES, n)\n", + " reqs = [(f\"R{i:05d}\", draw(c, rng, blur=rng.uniform(*blur), contrast=rng.uniform(.5, 1), noise=10)) for i, c in enumerate(y)]\n", + " for i in rng.choice(n, int(n * corrupt), replace=False):\n", + " reqs[i] = (reqs[i][0], [None, np.full((IMG, IMG), np.nan), np.zeros((20, 20))][i % 3])\n", + " return reqs, dict(zip([r[0] for r in reqs], y))\n", + "\n", + "\n", + "model, scaler = train_model(120, seed=5)\n", + "rows, stage = [], {\"전처리\": 0.0, \"형태특징\": 0.0, \"추론\": 0.0}\n", + "for name, n, kw in [(\"평시 100장\", 100, {}), (\"300% 증가 400장\", 400, {}), (\"500% 증가 600장\", 600, {}),\n", + " (\"500% + 손상 입력 3%\", 600, {\"corrupt\": .03}), (\"500% + 극저화질(흐림 3.0)\", 600, {\"blur\": (2.8, 3.2)})]:\n", + " reqs, truth = load_case(n, len(rows) + 1, **kw)\n", + " t0 = time.perf_counter()\n", + " try:\n", + " res, down = serve(reqs, stage), 0\n", + " except Exception as e:\n", + " res, down = {}, 1; print(\"다운:\", e)\n", + " dt = time.perf_counter() - t0\n", + " judged = [r for r, v in res.items() if v in CLASSES]\n", + " rows.append({\"상황\": name, \"요청\": n, \"응답\": len(res), \"유실률\": 1 - len(res) / n, \"다운\": down,\n", + " \"판별불가\": sum(v.startswith(\"판별\") for v in res.values()), \"입력오류\": sum(v.startswith(\"입력\") for v in res.values()),\n", + " \"판별 정확도\": np.mean([res[r] == truth[r] for r in judged]) if judged else 0, \"처리량(장/s)\": int(n / dt),\n", + " \"성능저하 모드\": f\"청크 {int(np.ceil(n / CAPACITY))}회\" if n > CAPACITY else \"-\"})\n", + "df = pd.DataFrame(rows)\n", + "print(df.round(3).to_string(index=False))\n", + "print(f\"\\n시스템 다운 {df['다운'].sum()}건, 최대 유실률 {df['유실률'].max():.0%} (목표 다운 0건·유실 0%: {'달성' if df['다운'].sum() == 0 and df['유실률'].max() == 0 else '미달'})\")\n", + "\n", + "# 흐림 한계치: 흐림 정도를 올리며 정확도가 75% 아래로 떨어지는 지점\n", + "print(\"\\n흐림(sigma) 판별 정확도 판별불가 비율\")\n", + "limit = None\n", + "for b in [0.5, 1.0, 1.5, 2.0, 2.5, 3.0, 3.5]:\n", + " reqs, truth = load_case(150, 50, blur=(b, b))\n", + " res = serve(reqs, {\"전처리\": 0, \"형태특징\": 0, \"추론\": 0})\n", + " judged = [r for r, v in res.items() if v in CLASSES]\n", + " acc = np.mean([res[r] == truth[r] for r in judged])\n", + " if limit is None and acc < 0.75:\n", + " limit = b\n", + " print(f\"{b:>8} {acc:.3f} {1 - len(judged) / len(res):.3f}\")\n", + "print(f\"한계치: 흐림 {limit} 이상에서 정확도 75% 미만 -> 해당 영상은 학습 문항에서 제외하거나 '고난도'로 분류\")\n", + "\n", + "# 메모리 누수 확인: 같은 부하를 3회 반복해 메모리 증가량 측정\n", + "tracemalloc.start()\n", + "reqs, _ = load_case(300, 99)\n", + "mem = []\n", + "for _ in range(3):\n", + " serve(reqs, {\"전처리\": 0, \"형태특징\": 0, \"추론\": 0}); mem.append(tracemalloc.get_traced_memory()[0] / 1e6)\n", + "peak = tracemalloc.get_traced_memory()[1] / 1e6\n", + "tracemalloc.stop()\n", + "print(f\"반복 후 메모리 {', '.join(f'{m:.1f}' for m in mem)} MB (최대 {peak:.1f} MB), 증가 없음: {mem[-1] - mem[0] < 1}\")\n", + "tot = sum(stage.values())\n", + "print(\"병목 구간:\", \", \".join(f\"{k} {v / tot:.0%}\" for k, v in sorted(stage.items(), key=lambda x: -x[1])), \"-> 형태특징 계산을 병렬화하면 처리량이 가장 크게 오른다\")\n", + "\n", + "plt.figure(figsize=(8, 4))\n", + "plt.bar(range(len(df)), df[\"처리량(장/s)\"], color=[\"tab:red\" if m != \"-\" else \"tab:blue\" for m in df[\"성능저하 모드\"]])\n", + "plt.xticks(range(len(df)), [f\"case{i + 1}\" for i in range(len(df))]); plt.ylabel(\"images / s\")\n", + "plt.title(\"Stress test throughput (red = queued/degraded mode)\"); plt.show()\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 08 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + " 상황 요청 응답 유실률 다운 판별불가 입력오류 판별 정확도 처리량(장/s) 성능저하 모드\n", + " 평시 100장 100 100 0.0 0 37 0 0.889 731 -\n", + " 300% 증가 400장 400 400 0.0 0 155 0 0.918 706 청크 2회\n", + " 500% 증가 600장 600 600 0.0 0 221 0 0.918 707 청크 3회\n", + " 500% + 손상 입력 3% 600 600 0.0 0 216 18 0.915 802 청크 3회\n", + "500% + 극저화질(흐림 3.0) 600 600 0.0 0 366 0 0.654 660 청크 3회\n", + "\n", + "시스템 다운 0건, 최대 유실률 0% (목표 다운 0건·유실 0%: 달성)\n", + "\n", + "흐림(sigma) 판별 정확도 판별불가 비율\n", + " 0.5 1.000 0.020\n", + " 1.0 0.992 0.167\n", + " 1.5 0.937 0.367\n", + " 2.0 0.825 0.467\n", + " 2.5 0.710 0.587\n", + " 3.0 0.566 0.647\n", + " 3.5 0.462 0.653\n", + "한계치: 흐림 2.5 이상에서 정확도 75% 미만 -> 해당 영상은 학습 문항에서 제외하거나 '고난도'로 분류\n", + "반복 후 메모리 0.4, 0.4, 0.4 MB (최대 30.4 MB), 증가 없음: True\n", + "병목 구간: 형태특징 68%, 전처리 31%, 추론 1% -> 형태특징 계산을 병렬화하면 처리량이 가장 크게 오른다\n", + "```\n", + "
\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 09. AAR 기반 장병 다빈도 오답 비행체 분석 및 학습 데이터셋 증강 재학습\n", + "**소요시간**: 약 3.0시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "AAR 기반 장병 다빈도 오답 비행체 분석 및 학습 데이터를 증강 재학습한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **AAR 사후검토로 장병의 다빈도 오답 비행체와 AI 분류기의 오류 조건을 분석하고, 취약 데이터를 증강해 재학습한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **가상 장병 40명의 학습 기록에서 다빈도 오답 쌍을 찾고, 기본 조건 중심으로 학습한 현행 모델의 오류를 조건·클래스별로 분류 수행하고 검증하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `docs/trn03_aar_report.md 및 configs/trn03_tuned_params.yaml` 형태의 결과물을 완성한다.\n", + "\n", + "### 🔍 평가 및 검증 기준 (PASS 조건)\n", + "#### 미션 판정\n", + "\n", + "**평가 구분:** 필수\n", + "\n", + "**핵심 평가영역:** 오류 원인 규명의 객관성 및 개선 조치의 논리적 타당성\n", + "\n", + "#### 미션 PASS 조건\n", + "\n", + "- 정량 기준: AAR 피드백 적용 후 종합 정확도 10%p 이상 향상, 재발 오류 0건\n", + "\n", + "\n", + "- 진행 기준: AAR 보고서 작성 및 파라미터 튜닝 전후 회귀 테스트 통과\n", + "\n", + "\n", + "- 검증 방법: 회귀 테스트(Regression Test) 스위트 전체 자동 실행 및 비교\n", + "\n", + "\n", + "#### 채점자 판정 메모\n", + "\n", + "> 단순 코드 실행 여부뿐만 아니라 출력된 수치와 근거 자료가 군 실무 요구사항에 부합하는지 면밀히 확인한다.\n", + "\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "\"\"\"AAR(사후검토) 형식으로 장병 학습 기록의 다빈도 오답 비행체와 AI 분류기의 오류 조건을 원인별로 분석하고, 취약 조건·클래스를 증강한 데이터로 재학습해 종합 정확도 향상·재발 오류·회귀 여부와 재보정 파라미터를 산출한다.\"\"\"\n", + "import numpy as np\n", + "import pandas as pd\n", + "import matplotlib.pyplot as plt\n", + "from matplotlib.path import Path as MplPath\n", + "from scipy import ndimage\n", + "from scipy.spatial import ConvexHull\n", + "from sklearn.neural_network import MLPClassifier\n", + "from sklearn.preprocessing import StandardScaler\n", + "\n", + "# ---------------------------------------------------------------------------\n", + "# [교육용가상자료] 합성 실루엣 생성기 (실제 기체 사진·도면·제원 사용 안 함)\n", + "# 사각형·삼각형·원 같은 기본 도형을 조합한 추상 실루엣을 32x32 흑백 이미지로 그린다.\n", + "# 회전(다각도), 원근 축소(측면 시점), 크기(원거리), 흐림·잡음·저대비·구름·가림·야간 반전을 조절한다.\n", + "# ---------------------------------------------------------------------------\n", + "IMG = 32\n", + "CLASSES = [\"civil_quad\", \"civil_fw\", \"attack_fw\", \"cruise\", \"ballistic\", \"bird\"]\n", + "KOR = {\"civil_quad\": \"민간 쿼드콥터형\", \"civil_fw\": \"민간 고정익형\", \"attack_fw\": \"삼각익 자폭드론형\",\n", + " \"cruise\": \"순항미사일형\", \"ballistic\": \"탄도미사일형\", \"bird\": \"조류\"}\n", + "THREAT = {\"attack_fw\", \"cruise\", \"ballistic\"}\n", + "\n", + "\n", + "def _rect(x0, x1, y0, y1):\n", + " # [TODO : 학생 실습 9] AAR 기반 장병 다빈도 오답 비행체 분석 및 학습 데이터셋 증강 재학습 관련 핵심 로직을 구현하세요.\n", + " # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n", + " # <<< 여기에 코드를 작성하세요 >>>\n", + " pass # 작성 후 pass를 제거하세요.\n", + "def _bar(p, q, w):\n", + " # [TODO : 학생 실습 9] AAR 기반 장병 다빈도 오답 비행체 분석 및 학습 데이터셋 증강 재학습 관련 핵심 로직을 구현하세요.\n", + " # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n", + " # <<< 여기에 코드를 작성하세요 >>>\n", + " pass # 작성 후 pass를 제거하세요.\n", + "SHAPES = {\n", + " \"civil_quad\": {\"poly\": [_bar((-.55, -.55), (.55, .55), .1), _bar((-.55, .55), (.55, -.55), .1), _rect(-.15, .15, -.15, .15)],\n", + " \"circ\": [(-.55, -.55, .24), (.55, .55, .24), (-.55, .55, .24), (.55, -.55, .24)]},\n", + " \"civil_fw\": {\"poly\": [_rect(-.07, .07, -.8, .72), _rect(-.9, .9, .05, .25), _rect(-.33, .33, -.8, -.66)], \"circ\": []},\n", + " \"attack_fw\": {\"poly\": [[(0, .85), (.85, -.5), (.12, -.42), (0, -.6), (-.12, -.42), (-.85, -.5)]], \"circ\": []},\n", + " \"cruise\": {\"poly\": [_rect(-.07, .07, -.9, .7), [(-.07, .7), (0, .92), (.07, .7)], _rect(-.42, .42, -.05, .07),\n", + " [(-.07, -.9), (-.24, -.95), (-.07, -.74)], [(.07, -.9), (.24, -.95), (.07, -.74)]], \"circ\": []},\n", + " \"ballistic\": {\"poly\": [_rect(-.13, .13, -.85, .6), [(-.13, .6), (0, .95), (.13, .6)],\n", + " [(-.13, -.85), (-.34, -.95), (-.13, -.58)], [(.13, -.85), (.34, -.95), (.13, -.58)]], \"circ\": []},\n", + " \"bird\": {\"poly\": [[(-.9, .12), (-.5, .28), (-.12, .06), (0, .22), (.12, .06), (.5, .28), (.9, .12), (.5, .12),\n", + " (.12, -.1), (0, -.38), (-.12, -.1), (-.5, .12)]], \"circ\": []},\n", + "}\n", + "_S = 2\n", + "_g = (np.arange(IMG * _S) + 0.5) / _S\n", + "_GX, _GY = np.meshgrid(_g, _g)\n", + "_PTS = np.c_[_GX.ravel(), _GY.ravel()]\n", + "_PATHS = {c: [MplPath(np.array(p)) for p in s[\"poly\"]] for c, s in SHAPES.items()}\n", + "\n", + "\n", + "def draw(cls, rng, angle=None, scale=None, fore=None, blur=0.3, noise=4.0, contrast=1.0,\n", + " clutter=0.0, occlude=0.0, invert=False, shift=1.5):\n", + " \"\"\"클래스 1개 실루엣 이미지(uint8 32x32)를 그린다. 배경은 밝은 하늘, 비행체는 어두운 그림자.\"\"\"\n", + " angle = rng.uniform(0, 360) if angle is None else angle\n", + " scale = rng.uniform(.7, .95) if scale is None else scale\n", + " fore = rng.uniform(.85, 1.0) if fore is None else fore\n", + " th = np.radians(angle)\n", + " c0 = IMG / 2 + rng.uniform(-shift, shift, 2)\n", + " d = (_PTS - c0) / (scale * IMG / 2) # 이미지 좌표 -> 도형 좌표(역변환)\n", + " u = d[:, 0] * np.cos(th) + d[:, 1] * np.sin(th)\n", + " v = (-d[:, 0] * np.sin(th) + d[:, 1] * np.cos(th)) / fore # 원근 축소(기수 방향으로 짧아 보임)\n", + " uv = np.c_[u, -v]\n", + " m = np.zeros(len(uv), bool)\n", + " near = np.flatnonzero(np.abs(uv).max(axis=1) <= 1.0) # 도형이 있을 수 있는 영역만 검사(속도)\n", + " for p in _PATHS[cls]:\n", + " m[near] |= p.contains_points(uv[near])\n", + " for cx, cy, r in SHAPES[cls][\"circ\"]:\n", + " m |= (uv[:, 0] - cx) ** 2 + (uv[:, 1] - cy) ** 2 <= r * r\n", + " cov = m.reshape(IMG, _S, IMG, _S).mean(axis=(1, 3))\n", + " if occlude > 0: # 부분 가림(구름 조각)\n", + " h = int(IMG * np.sqrt(occlude)); y0, x0 = rng.integers(8, IMG - 8 - h // 2, 2)\n", + " cov[y0:y0 + h, x0:x0 + h] = 0\n", + " bg = rng.uniform(180, 220) + np.linspace(-10, 10, IMG)[:, None]\n", + " fg = rng.uniform(30, 60)\n", + " img = bg * (1 - cov) + fg * cov\n", + " if clutter > 0: # 구름·배경 얼룩\n", + " img -= clutter * 90 * np.clip(ndimage.gaussian_filter(rng.normal(0, 1, (IMG, IMG)), 3) * 4, 0, None)\n", + " if blur > 0:\n", + " img = ndimage.gaussian_filter(img, blur)\n", + " mean = img.mean()\n", + " img = mean + (img - mean) * contrast\n", + " img = img + rng.normal(0, noise, img.shape)\n", + " if invert: # 야간 열상(IR) 극성 반전\n", + " img = 255 - img\n", + " return np.clip(img, 0, 255).astype(np.uint8)\n", + "\n", + "\n", + "# 촬영 조건 프리셋: 기본 + 복합 위협 시나리오\n", + "CONDITIONS = {\n", + " \"basic\": {},\n", + " \"offcenter\": {\"shift\": 5.0, \"fore\": (.6, .85)},\n", + " \"far\": {\"scale\": (.32, .45), \"blur\": .6},\n", + " \"blur\": {\"blur\": (1.2, 1.8)},\n", + " \"lowcon\": {\"contrast\": (.2, .35)},\n", + " \"clutter\": {\"clutter\": .8},\n", + " \"occlude\": {\"occlude\": .08},\n", + " \"oblique\": {\"fore\": (.35, .6)},\n", + " \"night\": {\"invert\": True, \"noise\": 10.0},\n", + " \"noise\": {\"noise\": 28.0},\n", + " \"combo\": {\"scale\": (.4, .55), \"blur\": (.8, 1.1), \"noise\": 12.0, \"contrast\": (.6, .8)},\n", + "}\n", + "\n", + "\n", + "def draw_cond(cls, cond, rng):\n", + " kw = {}\n", + " for k, v in CONDITIONS[cond].items():\n", + " kw[k] = rng.uniform(*v) if isinstance(v, tuple) else v\n", + " return draw(cls, rng, **kw)\n", + "\n", + "\n", + "def make_dataset(n_per_class, seed, conds=(\"basic\",)):\n", + " \"\"\"(이미지 배열 N x 32 x 32, 정답 라벨, 조건) 반환. 조건은 conds에서 번갈아 고른다.\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " X, y, c = [], [], []\n", + " for cls in CLASSES:\n", + " for i in range(n_per_class):\n", + " cond = conds[i % len(conds)]\n", + " X.append(draw_cond(cls, cond, rng)); y.append(cls); c.append(cond)\n", + " return np.array(X), np.array(y), np.array(c)\n", + "# ---------------------------------------------------------------------------\n", + "\n", + "# ---- 전처리(배치 벡터화)·형태 특징 공통 함수 ----\n", + "OUT = 24 # 크기 정규화 결과(24x24)\n", + "NOISE_T = 12.0 # 잡음 추정값이 이보다 크면 3x3 중간값 필터 적용(악천후·센서 잡음)\n", + "FEATS = [\"elong\", \"fill\", \"solidity\", \"n_parts\", \"end_gap\", \"mid_bulge\", \"w_max\", \"compact\"]\n", + "\n", + "\n", + "def preprocess(images, noise_t=NOISE_T):\n", + " \"\"\"N장 일괄 처리: 결측 보정 -> 극성(야간 반전) 보정 -> 조건별 잡음 제거 -> 명암 대비 강화 -> 분할 -> 윤곽선 -> 방향·크기 정규화.\"\"\"\n", + " f = np.array(images, dtype=float).reshape(-1, IMG, IMG)\n", + " n = len(f)\n", + " bad = ~np.isfinite(f)\n", + " missing = bad.sum(axis=(1, 2))\n", + " if bad.any(): # 결측 픽셀: 영상 중앙값으로 채운 뒤 주변 3x3 중간값으로 보정\n", + " med = np.array([np.median(x[np.isfinite(x)]) if np.isfinite(x).any() else 128.0 for x in f])\n", + " f = np.where(bad, med[:, None, None], f)\n", + " f = np.where(bad, ndimage.median_filter(f, size=(1, 3, 3)), f)\n", + " border = np.concatenate([f[:, 0], f[:, -1], f[:, :, 0], f[:, :, -1]], axis=1)\n", + " inverted = np.median(border, axis=1) < 128 # 배경이 어두우면 야간 열상으로 보고 반전\n", + " f[inverted] = 255 - f[inverted]\n", + " med3 = ndimage.median_filter(f, size=(1, 3, 3))\n", + " noise = 1.4826 * np.median(np.abs(f - med3).reshape(n, -1), axis=1)\n", + " noisy = noise > noise_t\n", + " f[noisy] = med3[noisy]\n", + " lo, hi = np.percentile(f.reshape(n, -1), [1, 99], axis=1)\n", + " e = np.clip((f - lo[:, None, None]) / np.maximum(hi - lo, 1e-6)[:, None, None], 0, 1) # 명암 대비 강화\n", + " mask = e < _otsu(e)[:, None, None]\n", + " mask &= ndimage.uniform_filter(mask.astype(float), size=(1, 3, 3)) * 9 >= 2.5 # 고립 점 잡음 제거\n", + " edge = np.hypot(ndimage.sobel(e, axis=1), ndimage.sobel(e, axis=2)) # 윤곽선(Sobel)\n", + " ok = (mask.sum(axis=(1, 2)) >= 4) & (hi - lo > 8)\n", + " return {\"enh\": e, \"mask\": mask, \"edge\": edge / np.maximum(edge.max(axis=(1, 2)), 1e-6)[:, None, None],\n", + " \"norm\": _normalize(1 - e, mask, ok), \"inverted\": inverted, \"noise\": noise, \"denoised\": noisy,\n", + " \"missing\": missing, \"contrast\": hi - lo, \"ok\": ok}\n", + "\n", + "\n", + "def _otsu(e, bins=64):\n", + " n = len(e)\n", + " idx = np.minimum((e * bins).astype(int), bins - 1) + bins * np.arange(n)[:, None, None]\n", + " hist = np.bincount(idx.ravel(), minlength=bins * n).reshape(n, bins).astype(float)\n", + " centers = (np.arange(bins) + 0.5) / bins\n", + " w = np.cumsum(hist, axis=1); m = np.cumsum(hist * centers, axis=1)\n", + " tot, mt = w[:, -1:], m[:, -1:]\n", + " between = (mt * w / tot - m) ** 2 / np.maximum(w * (tot - w), 1)\n", + " return (np.argmax(between, axis=1) + 1) / bins\n", + "\n", + "\n", + "def _normalize(obj, mask, ok):\n", + " \"\"\"주축 방향을 세로로 돌리고 비행체 영역만 24x24로 다시 샘플링한다(회전·크기·위치 불변). 한 번의 보간으로 일괄 처리.\"\"\"\n", + " n = len(obj)\n", + " w = mask.astype(float)\n", + " m00 = np.maximum(w.sum(axis=(1, 2)), 1)\n", + " yy, xx = np.mgrid[0:IMG, 0:IMG].astype(float)\n", + " cy, cx = (w * yy).sum(axis=(1, 2)) / m00, (w * xx).sum(axis=(1, 2)) / m00\n", + " dy, dx = yy - cy[:, None, None], xx - cx[:, None, None]\n", + " mu20, mu02, mu11 = (w * dy * dy).sum(axis=(1, 2)), (w * dx * dx).sum(axis=(1, 2)), (w * dy * dx).sum(axis=(1, 2))\n", + " th = 0.5 * np.arctan2(2 * mu11, mu20 - mu02)\n", + " a = np.stack([np.cos(th), np.sin(th)], 1) # 주축(y, x)\n", + " b = np.stack([-np.sin(th), np.cos(th)], 1) # 부축\n", + " pa = np.where(mask, dy * a[:, 0, None, None] + dx * a[:, 1, None, None], 0)\n", + " pb = np.where(mask, dy * b[:, 0, None, None] + dx * b[:, 1, None, None], 0)\n", + " half = np.maximum(np.abs(pa).max(axis=(1, 2)), np.abs(pb).max(axis=(1, 2))) + 1.0\n", + " g = np.linspace(-1, 1, OUT)\n", + " U, V = np.meshgrid(g, g, indexing=\"ij\")\n", + " Y = cy[:, None, None] + half[:, None, None] * (U * a[:, 0, None, None] + V * b[:, 0, None, None])\n", + " X = cx[:, None, None] + half[:, None, None] * (U * a[:, 1, None, None] + V * b[:, 1, None, None])\n", + " N = np.broadcast_to(np.arange(n)[:, None, None], Y.shape)\n", + " out = ndimage.map_coordinates(obj * mask, [N, Y, X], order=1, cval=0.0)\n", + " skew = (out * (U ** 3)).sum(axis=(1, 2))\n", + " out[skew < 0] = out[skew < 0, ::-1] # 무게가 쏠린 쪽을 위로(180도 모호성 제거)\n", + " out[~ok] = 0\n", + " return np.clip(out, 0, 1)\n", + "\n", + "\n", + "def shape_features(mask):\n", + " \"\"\"분할 마스크 1장에서 회전에 무관한 형태 특징 8종을 계산한다.\"\"\"\n", + " if mask.sum() < 4:\n", + " return dict.fromkeys(FEATS, 0.0)\n", + " yx = np.argwhere(mask).astype(float)\n", + " c = yx - yx.mean(axis=0)\n", + " val, vec = np.linalg.eigh(np.cov(c.T) + 1e-6 * np.eye(2))\n", + " t, s = c @ vec[:, 1], c @ vec[:, 0]\n", + " length, width = np.ptp(t) + 1, np.ptp(s) + 1\n", + " corners = np.vstack([yx + d for d in ([.5, .5], [.5, -.5], [-.5, .5], [-.5, -.5])])\n", + " try:\n", + " hull = ConvexHull(corners).volume\n", + " except Exception:\n", + " hull = float(mask.sum())\n", + " bins = np.digitize(t, np.linspace(t.min(), t.max() + 1e-6, 4)[1:-1])\n", + " w = np.array([np.ptp(s[bins == k]) + 1 if (bins == k).any() else 0 for k in range(3)]) / length\n", + " boundary = mask & ~ndimage.binary_erosion(mask)\n", + " return {\"elong\": float(np.sqrt(val[1] / max(val[0], 1e-6))), \"fill\": float(mask.sum() / (length * width)),\n", + " \"solidity\": float(mask.sum() / max(hull, 1)), \"n_parts\": float(ndimage.label(mask)[1]),\n", + " \"end_gap\": float(abs(w[0] - w[2])), \"mid_bulge\": float(w[1] - max(w[0], w[2])),\n", + " \"w_max\": float(w.max()), \"compact\": float(boundary.sum() / np.sqrt(mask.sum()))}\n", + "\n", + "\n", + "def feature_matrix(images):\n", + " \"\"\"전처리 결과, 형태 특징 행렬(N x 8), 정규화 이미지 행렬(N x 576)을 돌려준다.\"\"\"\n", + " P = preprocess(images)\n", + " F = np.array([[d[k] for k in FEATS] for d in map(shape_features, P[\"mask\"])])\n", + " return P, F, P[\"norm\"].reshape(len(F), -1)\n", + "\n", + "# ---- 분류 모델 공통 함수(다층 퍼셉트론: 정규화 이미지 576픽셀 + 형태 특징 8종) ----\n", + "def to_input(F, N, scaler):\n", + " return np.c_[N, scaler.transform(F) * 0.3]\n", + "\n", + "\n", + "def train_model(n_per_class=150, seed=5, conds=tuple(CONDITIONS), hidden=(64,)):\n", + " X, y, _ = make_dataset(n_per_class, seed, conds)\n", + " P, F, N = feature_matrix(X)\n", + " scaler = StandardScaler().fit(F)\n", + " model = MLPClassifier(hidden, max_iter=200, early_stopping=True, random_state=0)\n", + " model.fit(to_input(F, N, scaler), [CLASSES.index(v) for v in y])\n", + " return model, scaler\n", + "\n", + "\n", + "def predict(model, scaler, images):\n", + " \"\"\"(전처리 결과, 클래스별 확률 N x 6)을 돌려준다.\"\"\"\n", + " P, F, N = feature_matrix(images)\n", + " return P, model.predict_proba(to_input(F, N, scaler))\n", + "\n", + "\n", + "def fit(X, y):\n", + " P, F, N = feature_matrix(X)\n", + " sc = StandardScaler().fit(F)\n", + " m = MLPClassifier((96,), max_iter=200, early_stopping=True, random_state=0).fit(to_input(F, N, sc), [CLASSES.index(v) for v in y])\n", + " return m, sc\n", + "\n", + "\n", + "def evaluate(m, sc, X):\n", + " return np.array(CLASSES)[predict(m, sc, X)[1].argmax(1)]\n", + "\n", + "\n", + "# [1. 무엇이 일어났나] 장병 학습 기록(가상 40명 x 30문항) 다빈도 오답\n", + "rng = np.random.default_rng(3)\n", + "CONF = {\"civil_fw\": (\"attack_fw\", .30), \"attack_fw\": (\"civil_fw\", .25), \"cruise\": (\"ballistic\", .40), \"ballistic\": (\"cruise\", .35),\n", + " \"bird\": (\"civil_fw\", .15), \"civil_quad\": (\"civil_fw\", .05)}\n", + "log = []\n", + "for s in range(40):\n", + " for q in range(30):\n", + " c = CLASSES[rng.integers(6)]\n", + " wrong, p = CONF[c]\n", + " log.append({\"장병\": f\"L{s + 1:02d}\", \"정답\": c, \"응답\": wrong if rng.random() < p else c})\n", + "lg = pd.DataFrame(log)\n", + "err = lg[lg[\"정답\"] != lg[\"응답\"]]\n", + "print(f\"[1. 무엇이 일어났나] 응답 {len(lg)}건 중 오답 {len(err)}건({len(err) / len(lg):.1%})\")\n", + "print(\"다빈도 오답 쌍:\", (err[\"정답\"] + \"->\" + err[\"응답\"]).value_counts().head(3).to_dict())\n", + "\n", + "# [2. 왜 일어났나] AI 분류기(현행: 기본·회전·저대비·야간 조건 중심 학습) 오류를 조건별로 분류\n", + "base_conds = (\"basic\", \"offcenter\", \"lowcon\", \"night\")\n", + "Xtr, ytr, _ = make_dataset(150, 5, base_conds)\n", + "m0, s0 = fit(Xtr, ytr)\n", + "Xev, yev, cev = make_dataset(110, 31, tuple(CONDITIONS)) # AAR 평가 세트(11개 조건)\n", + "p0 = evaluate(m0, s0, Xev)\n", + "acc0 = (p0 == yev).mean()\n", + "by = pd.Series(p0 != yev).groupby(cev).mean().sort_values(ascending=False)\n", + "print(f\"\\n[2. 왜 일어났나] 현행 모델 종합 정확도 {acc0:.3f}, 오류율 상위 조건:\", by.head(4).round(2).to_dict())\n", + "weak_cls = pd.Series(p0 != yev).groupby(yev).mean().sort_values(ascending=False)\n", + "print(\"오류율 상위 클래스:\", weak_cls.head(3).round(2).to_dict(), \"-> 원인: 학습에 원거리·흐림·가림·측면 시점 영상이 없음\")\n", + "\n", + "# [3. 무엇을 바꾸나] 취약 조건 증강 + 다빈도 오답 클래스 가중 증강 후 재학습\n", + "weak_conds = tuple(by.index[by > 0.15]) or tuple(CONDITIONS)\n", + "Xa, ya, _ = make_dataset(110, 6, weak_conds)\n", + "extra = [c for c in (\"cruise\", \"ballistic\", \"civil_fw\", \"attack_fw\")]\n", + "rng2 = np.random.default_rng(7)\n", + "Xb = np.array([draw_cond(c, weak_conds[i % len(weak_conds)], rng2) for i, c in enumerate(extra * 40)])\n", + "yb = np.array(extra * 40)\n", + "m1, s1 = fit(np.r_[Xtr, Xa, Xb], np.r_[ytr, ya, yb])\n", + "prob1 = predict(m1, s1, Xev)[1]\n", + "p1 = np.array(CLASSES)[prob1.argmax(1)]\n", + "acc1 = (p1 == yev).mean()\n", + "print(f\"\\n[3. 무엇을 바꾸나] 증강 조건 {list(weak_conds)}, 혼동 클래스 4종 각 40장 추가\")\n", + "print(f\"종합 정확도 {acc0:.3f} -> {acc1:.3f} ({(acc1 - acc0) * 100:+.1f}%p, 목표 +10%p: {'달성' if acc1 - acc0 >= 0.10 else '미달'})\")\n", + "\n", + "# 회귀 테스트: AAR 등록 오류의 재발, 기존 정답의 회귀\n", + "reg = p0 != yev\n", + "again = reg & (p1 != yev)\n", + "print(f\"AAR 등록 오류 {reg.sum()}건 중 재발 {again.sum()}건(목표 0건: {'달성' if again.sum() == 0 else '미달'}), 기존 정답 중 새로 틀린 회귀 {(~reg & (p1 != yev)).sum()}건\")\n", + "print(f\"재발 오류 중 신뢰도 0.5 미만이라 '교관 확인'으로 걸러지는 건 {(again & (prob1.max(1) < 0.5)).sum()}건, 단정 오답으로 남는 건 {(again & (prob1.max(1) >= 0.5)).sum()}건\")\n", + "comp = pd.DataFrame({\"재보정 전\": pd.Series(p0 == yev).groupby(cev).mean(), \"재보정 후\": pd.Series(p1 == yev).groupby(cev).mean()}).round(3)\n", + "print(comp.T.to_string())\n", + "print(\"남은 재발 오류는 결합(combo)·구름 조건에 몰려 있어 교관 확인 대상으로 두고, 다음 주기에 지속 학습으로 보완한다.\")\n", + "\n", + "tuned = (f\"augment_conditions: {list(weak_conds)}\\naugment_extra_per_class: {{cruise: 40, ballistic: 40, civil_fw: 40, attack_fw: 40}}\\n\"\n", + " f\"min_confidence: 0.5\\nquiz_mix: {{confusing_pairs: 0.4, weak_conditions: 0.3, random: 0.3}}\\nhidden_layer: 96\")\n", + "print(\"\\n[재보정 파라미터(trn03_tuned_params.yaml 내용)]\\n\" + tuned)\n", + "\n", + "plt.figure(figsize=(9, 4))\n", + "x = np.arange(len(comp))\n", + "plt.bar(x - 0.2, comp[\"재보정 전\"], 0.4, label=\"before AAR\"); plt.bar(x + 0.2, comp[\"재보정 후\"], 0.4, label=\"after augmentation\")\n", + "plt.xticks(x, comp.index, rotation=30); plt.ylabel(\"accuracy\"); plt.legend(); plt.title(\"AAR retraining effect by condition (synthetic)\")\n", + "plt.tight_layout(); plt.show()\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 09 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + "[1. 무엇이 일어났나] 응답 1200건 중 오답 337건(28.1%)\n", + "다빈도 오답 쌍: {'cruise->ballistic': 90, 'civil_fw->attack_fw': 77, 'attack_fw->civil_fw': 66}\n", + "\n", + "[2. 왜 일어났나] 현행 모델 종합 정확도 0.770, 오류율 상위 조건: {'combo': 0.67, 'oblique': 0.62, 'occlude': 0.42, 'clutter': 0.38}\n", + "오류율 상위 클래스: {'cruise': 0.34, 'bird': 0.3, 'civil_fw': 0.27} -> 원인: 학습에 원거리·흐림·가림·측면 시점 영상이 없음\n", + "\n", + "[3. 무엇을 바꾸나] 증강 조건 ['combo', 'oblique', 'occlude', 'clutter', 'far'], 혼동 클래스 4종 각 40장 추가\n", + "종합 정확도 0.770 -> 0.873 (+10.3%p, 목표 +10%p: 달성)\n", + "AAR 등록 오류 152건 중 재발 67건(목표 0건: 미달), 기존 정답 중 새로 틀린 회귀 17건\n", + "재발 오류 중 신뢰도 0.5 미만이라 '교관 확인'으로 걸러지는 건 36건, 단정 오답으로 남는 건 31건\n", + " basic blur clutter combo far lowcon night noise oblique occlude offcenter\n", + "재보정 전 1.0 0.933 0.617 0.333 0.683 1.0 0.983 0.967 0.383 0.583 0.983\n", + "재보정 후 1.0 0.967 0.650 0.450 0.867 1.0 1.000 0.950 0.967 0.750 1.000\n", + "남은 재발 오류는 결합(combo)·구름 조건에 몰려 있어 교관 확인 대상으로 두고, 다음 주기에 지속 학습으로 보완한다.\n", + "\n", + "[재보정 파라미터(trn03_tuned_params.yaml 내용)]\n", + "augment_conditions: ['combo', 'oblique', 'occlude', 'clutter', 'far']\n", + "augment_extra_per_class: {cruise: 40, ballistic: 40, civil_fw: 40, attack_fw: 40}\n", + "min_confidence: 0.5\n", + "quiz_mix: {confusing_pairs: 0.4, weak_conditions: 0.3, random: 0.3}\n", + "hidden_layer: 96\n", + "```\n", + "
\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 10. 드론·미사일 실루엣 분류 학습앱 최종 패키징 및 부대 교육 배포 가이드 완성\n", + "**소요시간**: 약 3.5시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "드론·미사일 실루엣 분류 학습앱 최종 패키징 및 부대 교육 배포 가이드를 완성한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **실루엣 생성부터 전처리, 분류, 신뢰도 등급, 퀴즈 운영, 교관 해설 검토까지 학습앱 전체를 통합하고 부대 교육 배포 가이드를 완성한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **AAR 재보정값을 적용한 통합 코드를 한 번에 실행해 손상 문항이 자동 차단되는지 확인하고 유효성을 점검하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `src/main_trn03.py, README.md, docs/trn03_final_report.pdf(md)` 형태의 결과물을 완성한다.\n", + "\n", + "### 🔍 평가 및 검증 기준 (PASS 조건)\n", + "#### 미션 판정\n", + "\n", + "**평가 구분:** 필수\n", + "\n", + "**핵심 평가영역:** 군 실무 즉시 도입 가능성, 운용 신뢰도 및 전략적 기대효과\n", + "\n", + "#### 미션 PASS 조건\n", + "\n", + "- 정량 기준: End-to-End 통합 파이프라인 실행 성공률 100%, 테스트 커버리지 85% 이상\n", + "\n", + "\n", + "- 진행 기준: 원클릭 실행 스크립트 동작 확인 및 최종 발표 산출물 작성 완료\n", + "\n", + "\n", + "- 검증 방법: 최종 검증 위원회(평가관) 모의 시연 및 전체 기능 통합 검증\n", + "\n", + "\n", + "#### 채점자 판정 메모\n", + "\n", + "> 단순 코드 실행 여부뿐만 아니라 출력된 수치와 근거 자료가 군 실무 요구사항에 부합하는지 면밀히 확인한다.\n", + "\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "\"\"\"합성 실루엣 생성·검증부터 전처리, 분류(AAR 재보정값 적용), 신뢰도 등급, 학습 퀴즈 운영, 오답 해설 교관 검토 대기열까지 학습앱 전체를 한 파일로 통합 실행하고, 자체 점검·함수 커버리지·부대 교육 배포 브리핑 요약을 출력한다.\"\"\"\n", + "import hashlib\n", + "import json\n", + "import sys\n", + "import time\n", + "import types\n", + "import numpy as np\n", + "import pandas as pd\n", + "import matplotlib.pyplot as plt\n", + "from matplotlib.path import Path as MplPath\n", + "from scipy import ndimage\n", + "from scipy.spatial import ConvexHull\n", + "from sklearn.neural_network import MLPClassifier\n", + "from sklearn.preprocessing import StandardScaler\n", + "\n", + "# ---------------------------------------------------------------------------\n", + "# [교육용가상자료] 합성 실루엣 생성기 (실제 기체 사진·도면·제원 사용 안 함)\n", + "# 사각형·삼각형·원 같은 기본 도형을 조합한 추상 실루엣을 32x32 흑백 이미지로 그린다.\n", + "# 회전(다각도), 원근 축소(측면 시점), 크기(원거리), 흐림·잡음·저대비·구름·가림·야간 반전을 조절한다.\n", + "# ---------------------------------------------------------------------------\n", + "IMG = 32\n", + "CLASSES = [\"civil_quad\", \"civil_fw\", \"attack_fw\", \"cruise\", \"ballistic\", \"bird\"]\n", + "KOR = {\"civil_quad\": \"민간 쿼드콥터형\", \"civil_fw\": \"민간 고정익형\", \"attack_fw\": \"삼각익 자폭드론형\",\n", + " \"cruise\": \"순항미사일형\", \"ballistic\": \"탄도미사일형\", \"bird\": \"조류\"}\n", + "THREAT = {\"attack_fw\", \"cruise\", \"ballistic\"}\n", + "\n", + "\n", + "def _rect(x0, x1, y0, y1):\n", + " # [TODO : 학생 실습 10] 드론·미사일 실루엣 분류 학습앱 최종 패키징 및 부대 교육 배포 가이드 완성 관련 핵심 로직을 구현하세요.\n", + " # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n", + " # <<< 여기에 코드를 작성하세요 >>>\n", + " pass # 작성 후 pass를 제거하세요.\n", + "def _bar(p, q, w):\n", + " # [TODO : 학생 실습 10] 드론·미사일 실루엣 분류 학습앱 최종 패키징 및 부대 교육 배포 가이드 완성 관련 핵심 로직을 구현하세요.\n", + " # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n", + " # <<< 여기에 코드를 작성하세요 >>>\n", + " pass # 작성 후 pass를 제거하세요.\n", + "SHAPES = {\n", + " \"civil_quad\": {\"poly\": [_bar((-.55, -.55), (.55, .55), .1), _bar((-.55, .55), (.55, -.55), .1), _rect(-.15, .15, -.15, .15)],\n", + " \"circ\": [(-.55, -.55, .24), (.55, .55, .24), (-.55, .55, .24), (.55, -.55, .24)]},\n", + " \"civil_fw\": {\"poly\": [_rect(-.07, .07, -.8, .72), _rect(-.9, .9, .05, .25), _rect(-.33, .33, -.8, -.66)], \"circ\": []},\n", + " \"attack_fw\": {\"poly\": [[(0, .85), (.85, -.5), (.12, -.42), (0, -.6), (-.12, -.42), (-.85, -.5)]], \"circ\": []},\n", + " \"cruise\": {\"poly\": [_rect(-.07, .07, -.9, .7), [(-.07, .7), (0, .92), (.07, .7)], _rect(-.42, .42, -.05, .07),\n", + " [(-.07, -.9), (-.24, -.95), (-.07, -.74)], [(.07, -.9), (.24, -.95), (.07, -.74)]], \"circ\": []},\n", + " \"ballistic\": {\"poly\": [_rect(-.13, .13, -.85, .6), [(-.13, .6), (0, .95), (.13, .6)],\n", + " [(-.13, -.85), (-.34, -.95), (-.13, -.58)], [(.13, -.85), (.34, -.95), (.13, -.58)]], \"circ\": []},\n", + " \"bird\": {\"poly\": [[(-.9, .12), (-.5, .28), (-.12, .06), (0, .22), (.12, .06), (.5, .28), (.9, .12), (.5, .12),\n", + " (.12, -.1), (0, -.38), (-.12, -.1), (-.5, .12)]], \"circ\": []},\n", + "}\n", + "_S = 2\n", + "_g = (np.arange(IMG * _S) + 0.5) / _S\n", + "_GX, _GY = np.meshgrid(_g, _g)\n", + "_PTS = np.c_[_GX.ravel(), _GY.ravel()]\n", + "_PATHS = {c: [MplPath(np.array(p)) for p in s[\"poly\"]] for c, s in SHAPES.items()}\n", + "\n", + "\n", + "def draw(cls, rng, angle=None, scale=None, fore=None, blur=0.3, noise=4.0, contrast=1.0,\n", + " clutter=0.0, occlude=0.0, invert=False, shift=1.5):\n", + " \"\"\"클래스 1개 실루엣 이미지(uint8 32x32)를 그린다. 배경은 밝은 하늘, 비행체는 어두운 그림자.\"\"\"\n", + " angle = rng.uniform(0, 360) if angle is None else angle\n", + " scale = rng.uniform(.7, .95) if scale is None else scale\n", + " fore = rng.uniform(.85, 1.0) if fore is None else fore\n", + " th = np.radians(angle)\n", + " c0 = IMG / 2 + rng.uniform(-shift, shift, 2)\n", + " d = (_PTS - c0) / (scale * IMG / 2) # 이미지 좌표 -> 도형 좌표(역변환)\n", + " u = d[:, 0] * np.cos(th) + d[:, 1] * np.sin(th)\n", + " v = (-d[:, 0] * np.sin(th) + d[:, 1] * np.cos(th)) / fore # 원근 축소(기수 방향으로 짧아 보임)\n", + " uv = np.c_[u, -v]\n", + " m = np.zeros(len(uv), bool)\n", + " near = np.flatnonzero(np.abs(uv).max(axis=1) <= 1.0) # 도형이 있을 수 있는 영역만 검사(속도)\n", + " for p in _PATHS[cls]:\n", + " m[near] |= p.contains_points(uv[near])\n", + " for cx, cy, r in SHAPES[cls][\"circ\"]:\n", + " m |= (uv[:, 0] - cx) ** 2 + (uv[:, 1] - cy) ** 2 <= r * r\n", + " cov = m.reshape(IMG, _S, IMG, _S).mean(axis=(1, 3))\n", + " if occlude > 0: # 부분 가림(구름 조각)\n", + " h = int(IMG * np.sqrt(occlude)); y0, x0 = rng.integers(8, IMG - 8 - h // 2, 2)\n", + " cov[y0:y0 + h, x0:x0 + h] = 0\n", + " bg = rng.uniform(180, 220) + np.linspace(-10, 10, IMG)[:, None]\n", + " fg = rng.uniform(30, 60)\n", + " img = bg * (1 - cov) + fg * cov\n", + " if clutter > 0: # 구름·배경 얼룩\n", + " img -= clutter * 90 * np.clip(ndimage.gaussian_filter(rng.normal(0, 1, (IMG, IMG)), 3) * 4, 0, None)\n", + " if blur > 0:\n", + " img = ndimage.gaussian_filter(img, blur)\n", + " mean = img.mean()\n", + " img = mean + (img - mean) * contrast\n", + " img = img + rng.normal(0, noise, img.shape)\n", + " if invert: # 야간 열상(IR) 극성 반전\n", + " img = 255 - img\n", + " return np.clip(img, 0, 255).astype(np.uint8)\n", + "\n", + "\n", + "# 촬영 조건 프리셋: 기본 + 복합 위협 시나리오\n", + "CONDITIONS = {\n", + " \"basic\": {},\n", + " \"offcenter\": {\"shift\": 5.0, \"fore\": (.6, .85)},\n", + " \"far\": {\"scale\": (.32, .45), \"blur\": .6},\n", + " \"blur\": {\"blur\": (1.2, 1.8)},\n", + " \"lowcon\": {\"contrast\": (.2, .35)},\n", + " \"clutter\": {\"clutter\": .8},\n", + " \"occlude\": {\"occlude\": .08},\n", + " \"oblique\": {\"fore\": (.35, .6)},\n", + " \"night\": {\"invert\": True, \"noise\": 10.0},\n", + " \"noise\": {\"noise\": 28.0},\n", + " \"combo\": {\"scale\": (.4, .55), \"blur\": (.8, 1.1), \"noise\": 12.0, \"contrast\": (.6, .8)},\n", + "}\n", + "\n", + "\n", + "def draw_cond(cls, cond, rng):\n", + " kw = {}\n", + " for k, v in CONDITIONS[cond].items():\n", + " kw[k] = rng.uniform(*v) if isinstance(v, tuple) else v\n", + " return draw(cls, rng, **kw)\n", + "\n", + "\n", + "def make_dataset(n_per_class, seed, conds=(\"basic\",)):\n", + " \"\"\"(이미지 배열 N x 32 x 32, 정답 라벨, 조건) 반환. 조건은 conds에서 번갈아 고른다.\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " X, y, c = [], [], []\n", + " for cls in CLASSES:\n", + " for i in range(n_per_class):\n", + " cond = conds[i % len(conds)]\n", + " X.append(draw_cond(cls, cond, rng)); y.append(cls); c.append(cond)\n", + " return np.array(X), np.array(y), np.array(c)\n", + "# ---------------------------------------------------------------------------\n", + "\n", + "# ---- 전처리(배치 벡터화)·형태 특징 공통 함수 ----\n", + "OUT = 24 # 크기 정규화 결과(24x24)\n", + "NOISE_T = 12.0 # 잡음 추정값이 이보다 크면 3x3 중간값 필터 적용(악천후·센서 잡음)\n", + "FEATS = [\"elong\", \"fill\", \"solidity\", \"n_parts\", \"end_gap\", \"mid_bulge\", \"w_max\", \"compact\"]\n", + "\n", + "\n", + "def preprocess(images, noise_t=NOISE_T):\n", + " \"\"\"N장 일괄 처리: 결측 보정 -> 극성(야간 반전) 보정 -> 조건별 잡음 제거 -> 명암 대비 강화 -> 분할 -> 윤곽선 -> 방향·크기 정규화.\"\"\"\n", + " f = np.array(images, dtype=float).reshape(-1, IMG, IMG)\n", + " n = len(f)\n", + " bad = ~np.isfinite(f)\n", + " missing = bad.sum(axis=(1, 2))\n", + " if bad.any(): # 결측 픽셀: 영상 중앙값으로 채운 뒤 주변 3x3 중간값으로 보정\n", + " med = np.array([np.median(x[np.isfinite(x)]) if np.isfinite(x).any() else 128.0 for x in f])\n", + " f = np.where(bad, med[:, None, None], f)\n", + " f = np.where(bad, ndimage.median_filter(f, size=(1, 3, 3)), f)\n", + " border = np.concatenate([f[:, 0], f[:, -1], f[:, :, 0], f[:, :, -1]], axis=1)\n", + " inverted = np.median(border, axis=1) < 128 # 배경이 어두우면 야간 열상으로 보고 반전\n", + " f[inverted] = 255 - f[inverted]\n", + " med3 = ndimage.median_filter(f, size=(1, 3, 3))\n", + " noise = 1.4826 * np.median(np.abs(f - med3).reshape(n, -1), axis=1)\n", + " noisy = noise > noise_t\n", + " f[noisy] = med3[noisy]\n", + " lo, hi = np.percentile(f.reshape(n, -1), [1, 99], axis=1)\n", + " e = np.clip((f - lo[:, None, None]) / np.maximum(hi - lo, 1e-6)[:, None, None], 0, 1) # 명암 대비 강화\n", + " mask = e < _otsu(e)[:, None, None]\n", + " mask &= ndimage.uniform_filter(mask.astype(float), size=(1, 3, 3)) * 9 >= 2.5 # 고립 점 잡음 제거\n", + " edge = np.hypot(ndimage.sobel(e, axis=1), ndimage.sobel(e, axis=2)) # 윤곽선(Sobel)\n", + " ok = (mask.sum(axis=(1, 2)) >= 4) & (hi - lo > 8)\n", + " return {\"enh\": e, \"mask\": mask, \"edge\": edge / np.maximum(edge.max(axis=(1, 2)), 1e-6)[:, None, None],\n", + " \"norm\": _normalize(1 - e, mask, ok), \"inverted\": inverted, \"noise\": noise, \"denoised\": noisy,\n", + " \"missing\": missing, \"contrast\": hi - lo, \"ok\": ok}\n", + "\n", + "\n", + "def _otsu(e, bins=64):\n", + " n = len(e)\n", + " idx = np.minimum((e * bins).astype(int), bins - 1) + bins * np.arange(n)[:, None, None]\n", + " hist = np.bincount(idx.ravel(), minlength=bins * n).reshape(n, bins).astype(float)\n", + " centers = (np.arange(bins) + 0.5) / bins\n", + " w = np.cumsum(hist, axis=1); m = np.cumsum(hist * centers, axis=1)\n", + " tot, mt = w[:, -1:], m[:, -1:]\n", + " between = (mt * w / tot - m) ** 2 / np.maximum(w * (tot - w), 1)\n", + " return (np.argmax(between, axis=1) + 1) / bins\n", + "\n", + "\n", + "def _normalize(obj, mask, ok):\n", + " \"\"\"주축 방향을 세로로 돌리고 비행체 영역만 24x24로 다시 샘플링한다(회전·크기·위치 불변). 한 번의 보간으로 일괄 처리.\"\"\"\n", + " n = len(obj)\n", + " w = mask.astype(float)\n", + " m00 = np.maximum(w.sum(axis=(1, 2)), 1)\n", + " yy, xx = np.mgrid[0:IMG, 0:IMG].astype(float)\n", + " cy, cx = (w * yy).sum(axis=(1, 2)) / m00, (w * xx).sum(axis=(1, 2)) / m00\n", + " dy, dx = yy - cy[:, None, None], xx - cx[:, None, None]\n", + " mu20, mu02, mu11 = (w * dy * dy).sum(axis=(1, 2)), (w * dx * dx).sum(axis=(1, 2)), (w * dy * dx).sum(axis=(1, 2))\n", + " th = 0.5 * np.arctan2(2 * mu11, mu20 - mu02)\n", + " a = np.stack([np.cos(th), np.sin(th)], 1) # 주축(y, x)\n", + " b = np.stack([-np.sin(th), np.cos(th)], 1) # 부축\n", + " pa = np.where(mask, dy * a[:, 0, None, None] + dx * a[:, 1, None, None], 0)\n", + " pb = np.where(mask, dy * b[:, 0, None, None] + dx * b[:, 1, None, None], 0)\n", + " half = np.maximum(np.abs(pa).max(axis=(1, 2)), np.abs(pb).max(axis=(1, 2))) + 1.0\n", + " g = np.linspace(-1, 1, OUT)\n", + " U, V = np.meshgrid(g, g, indexing=\"ij\")\n", + " Y = cy[:, None, None] + half[:, None, None] * (U * a[:, 0, None, None] + V * b[:, 0, None, None])\n", + " X = cx[:, None, None] + half[:, None, None] * (U * a[:, 1, None, None] + V * b[:, 1, None, None])\n", + " N = np.broadcast_to(np.arange(n)[:, None, None], Y.shape)\n", + " out = ndimage.map_coordinates(obj * mask, [N, Y, X], order=1, cval=0.0)\n", + " skew = (out * (U ** 3)).sum(axis=(1, 2))\n", + " out[skew < 0] = out[skew < 0, ::-1] # 무게가 쏠린 쪽을 위로(180도 모호성 제거)\n", + " out[~ok] = 0\n", + " return np.clip(out, 0, 1)\n", + "\n", + "\n", + "def shape_features(mask):\n", + " \"\"\"분할 마스크 1장에서 회전에 무관한 형태 특징 8종을 계산한다.\"\"\"\n", + " if mask.sum() < 4:\n", + " return dict.fromkeys(FEATS, 0.0)\n", + " yx = np.argwhere(mask).astype(float)\n", + " c = yx - yx.mean(axis=0)\n", + " val, vec = np.linalg.eigh(np.cov(c.T) + 1e-6 * np.eye(2))\n", + " t, s = c @ vec[:, 1], c @ vec[:, 0]\n", + " length, width = np.ptp(t) + 1, np.ptp(s) + 1\n", + " corners = np.vstack([yx + d for d in ([.5, .5], [.5, -.5], [-.5, .5], [-.5, -.5])])\n", + " try:\n", + " hull = ConvexHull(corners).volume\n", + " except Exception:\n", + " hull = float(mask.sum())\n", + " bins = np.digitize(t, np.linspace(t.min(), t.max() + 1e-6, 4)[1:-1])\n", + " w = np.array([np.ptp(s[bins == k]) + 1 if (bins == k).any() else 0 for k in range(3)]) / length\n", + " boundary = mask & ~ndimage.binary_erosion(mask)\n", + " return {\"elong\": float(np.sqrt(val[1] / max(val[0], 1e-6))), \"fill\": float(mask.sum() / (length * width)),\n", + " \"solidity\": float(mask.sum() / max(hull, 1)), \"n_parts\": float(ndimage.label(mask)[1]),\n", + " \"end_gap\": float(abs(w[0] - w[2])), \"mid_bulge\": float(w[1] - max(w[0], w[2])),\n", + " \"w_max\": float(w.max()), \"compact\": float(boundary.sum() / np.sqrt(mask.sum()))}\n", + "\n", + "\n", + "def feature_matrix(images):\n", + " \"\"\"전처리 결과, 형태 특징 행렬(N x 8), 정규화 이미지 행렬(N x 576)을 돌려준다.\"\"\"\n", + " P = preprocess(images)\n", + " F = np.array([[d[k] for k in FEATS] for d in map(shape_features, P[\"mask\"])])\n", + " return P, F, P[\"norm\"].reshape(len(F), -1)\n", + "\n", + "CONFIG = {\"augment_conditions\": [\"combo\", \"oblique\", \"occlude\", \"clutter\", \"far\"], \"extra_classes\": [\"cruise\", \"ballistic\", \"civil_fw\", \"attack_fw\"],\n", + " \"min_confidence\": 0.5, \"hidden_layer\": 96} # 미션 9 재보정값\n", + "CALLED = set()\n", + "\n", + "\n", + "def _tracer(frame, event, arg):\n", + " if event == \"call\" and frame.f_globals is globals():\n", + " CALLED.add(frame.f_code.co_name)\n", + "\n", + "\n", + "def validate(img):\n", + " a = np.asarray(img, dtype=float) if img is not None else None\n", + " return a is not None and a.shape == (IMG, IMG) and np.isfinite(a).all() and a.min() >= 0 and a.max() <= 255\n", + "\n", + "\n", + "def train():\n", + " X1, y1, _ = make_dataset(80, 5, tuple(CONDITIONS))\n", + " X2, y2, _ = make_dataset(40, 6, tuple(CONFIG[\"augment_conditions\"]))\n", + " rng = np.random.default_rng(7)\n", + " ex = CONFIG[\"extra_classes\"] * 20\n", + " X3 = np.array([draw_cond(c, CONFIG[\"augment_conditions\"][i % 5], rng) for i, c in enumerate(ex)])\n", + " X, y = np.r_[X1, X2, X3], np.r_[y1, y2, ex]\n", + " P, F, N = feature_matrix(X)\n", + " sc = StandardScaler().fit(F)\n", + " m = MLPClassifier((CONFIG[\"hidden_layer\"],), max_iter=200, early_stopping=True, random_state=0)\n", + " return m.fit(np.c_[N, sc.transform(F) * 0.3], [CLASSES.index(v) for v in y]), sc\n", + "\n", + "\n", + "def classify(m, sc, images):\n", + " P, F, N = feature_matrix(images)\n", + " prob = m.predict_proba(np.c_[N, sc.transform(F) * 0.3])\n", + " pred = np.array(CLASSES)[prob.argmax(1)]\n", + " conf = prob.max(1) * np.where(P[\"ok\"], 1, 0)\n", + " return pred, conf, np.where(conf >= 0.8, \"High\", np.where(conf >= CONFIG[\"min_confidence\"], \"Medium\", \"Low\"))\n", + "\n", + "\n", + "class Queue:\n", + " def __init__(self):\n", + " self.items, self.log = [], []\n", + "\n", + " def submit(self, rec):\n", + " prev = self.log[-1][\"hash\"] if self.log else \"GENESIS\"\n", + " body = {\"event\": \"SUBMIT\", **rec}\n", + " self.log.append({**body, \"prev\": prev, \"hash\": hashlib.sha256((prev + json.dumps(body, sort_keys=True)).encode()).hexdigest()})\n", + " self.items.append({**rec, \"status\": \"PENDING\"})\n", + "\n", + "\n", + "sys.settrace(_tracer)\n", + "t_start = time.perf_counter()\n", + "# 1. 학습\n", + "model, scaler = train()\n", + "# 2. 운용 데이터(교육장 배포 문항 300장) 검증 -> 손상 3장 차단\n", + "X, y, cond = make_dataset(50, 41, tuple(CONDITIONS))\n", + "items = list(X)\n", + "items[5], items[77], items[150] = None, np.zeros((20, 20)), np.full((IMG, IMG), np.nan)\n", + "ok_idx = [i for i, im in enumerate(items) if validate(im)]\n", + "rejected = len(items) - len(ok_idx)\n", + "Xo, yo = np.array([items[i] for i in ok_idx]), y[ok_idx]\n", + "t0 = time.perf_counter()\n", + "pred, conf, grade = classify(model, scaler, Xo)\n", + "ms = (time.perf_counter() - t0) * 1000 / len(Xo)\n", + "judged = grade != \"Low\"\n", + "acc_all = (pred == yo).mean()\n", + "acc_judged = (pred[judged] == yo[judged]).mean()\n", + "# 3. 퀴즈 운영(가상 장병 20명 x 10문항) -> 오답은 해설 검토 대기열(자동 공개 없음)\n", + "rng = np.random.default_rng(9)\n", + "q, answers = Queue(), []\n", + "CONFUSE = {\"civil_fw\": \"attack_fw\", \"attack_fw\": \"civil_fw\", \"cruise\": \"ballistic\", \"ballistic\": \"cruise\", \"bird\": \"civil_fw\", \"civil_quad\": \"civil_fw\"}\n", + "for s in range(20):\n", + " for k in rng.choice(len(yo), 10, replace=False):\n", + " ans = yo[k] if rng.random() < 0.75 else CONFUSE[yo[k]]\n", + " answers.append(ans == yo[k])\n", + " if ans != yo[k]:\n", + " q.submit({\"learner\": f\"L{s + 1:02d}\", \"answer\": str(yo[k]), \"chosen\": str(ans), \"ai_grade\": str(grade[k])})\n", + "sys.settrace(None)\n", + "total = time.perf_counter() - t_start\n", + "\n", + "defined = {n for n, f in globals().items() if isinstance(f, types.FunctionType) and f.__module__ == __name__ and not n.startswith(\"_tracer\")}\n", + "defined |= {\"submit\", \"__init__\"}\n", + "coverage = len(CALLED & defined) / len(defined)\n", + "chk = {\n", + " \"손상 데이터 3건 차단\": rejected == 3,\n", + " \"판별 대상 297장 처리\": len(Xo) == 297,\n", + " \"전체 분류 정확도 85% 이상\": acc_all >= 0.85,\n", + " \"판별(Medium 이상) 정확도 95% 이상\": acc_judged >= 0.95,\n", + " \"High 등급 정확도 >= Medium 등급 정확도\": (pred == yo)[grade == \"High\"].mean() >= (pred == yo)[grade == \"Medium\"].mean(),\n", + " \"장당 처리 200 ms 이내\": ms <= 200,\n", + " \"오답 해설 자동 공개 0건\": all(i[\"status\"] == \"PENDING\" for i in q.items),\n", + " \"감사 로그 건수 일치\": len(q.log) == len(q.items) == sum(not a for a in answers),\n", + " \"함수 커버리지 85% 이상\": coverage >= 0.85,\n", + " \"전체 실행 60초 이내\": total < 60,\n", + "}\n", + "for k, v in chk.items():\n", + " print(f\"[{'PASS' if v else 'FAIL'}] {k}\")\n", + "passed = sum(chk.values())\n", + "print(f\"자체 점검 {passed}/{len(chk)} 통과, 함수 커버리지 {coverage:.0%} ({len(CALLED & defined)}/{len(defined)}), 통합 실행 {'성공' if passed == len(chk) else '점검 필요'}\")\n", + "\n", + "fig, ax = plt.subplots(2, 2, figsize=(11, 8))\n", + "for j in range(6):\n", + " sub = ax[0, 0].inset_axes([j % 3 / 3, 1 - (j // 3 + 1) / 2, 0.32, 0.48])\n", + " i = np.flatnonzero(yo == CLASSES[j])[0]\n", + " sub.imshow(Xo[i], cmap=\"gray\"); sub.set_title(f\"{CLASSES[j]}\\n{pred[i]} ({grade[i]})\", fontsize=7); sub.axis(\"off\")\n", + "ax[0, 0].axis(\"off\"); ax[0, 0].set_title(\"Sample items: true / predicted (grade)\")\n", + "g = pd.Series(grade).value_counts().reindex([\"High\", \"Medium\", \"Low\"]).fillna(0)\n", + "ax[0, 1].bar(g.index, g.values, color=[\"tab:green\", \"tab:orange\", \"tab:red\"]); ax[0, 1].set_title(\"Confidence grade of deployed items\")\n", + "acc_c = pd.Series(pred == yo).groupby(cond[ok_idx]).mean()\n", + "ax[1, 0].bar(acc_c.index, acc_c.values); ax[1, 0].tick_params(axis=\"x\", rotation=40); ax[1, 0].set_title(\"AI accuracy by condition\")\n", + "ax[1, 1].axis(\"off\")\n", + "ax[1, 1].text(0, .9, f\"Items {len(Xo)} (+{rejected} rejected)\\nAI accuracy {acc_all:.1%}\\nLearner acc {np.mean(answers):.1%}\\n\"\n", + " f\"Review queue {len(q.items)} pending\\nCoverage {coverage:.0%}\\nSelf-check {passed}/{len(chk)}\", family=\"monospace\", fontsize=12, va=\"top\")\n", + "plt.suptitle(\"TRN03 silhouette ID app - integrated run (training data only)\"); plt.tight_layout(); plt.show()\n", + "\n", + "top = pd.Series([f\"{i['answer']}->{i['chosen']}\" for i in q.items]).value_counts().head(2)\n", + "print(f\"\"\"\n", + "[부대 교육 배포 브리핑 요약 - 교육용가상자료]\n", + "1. 구성: 합성 추상 실루엣 6종(위협 3종), 문항 {len(items)}장 중 {len(Xo)}장 배포, 손상 {rejected}장 자동 차단\n", + "2. AI 성능: 전체 정확도 {acc_all:.1%}, 신뢰도 Medium 이상 판별 정확도 {acc_judged:.1%}, Low {int((~judged).sum())}장은 교관 확인, 장당 {ms:.2f} ms\n", + "3. 학습 운영: 가상 장병 20명 정답률 {np.mean(answers):.1%}, 다빈도 오답 {', '.join(f'{k}({v})' for k, v in top.items())}\n", + "4. 교관 검토: 오답 해설 {len(q.items)}건 검토 대기, 자동 공개 0건, 감사 로그 {len(q.log)}건\n", + "5. 운용 유의: 실루엣은 기본 도형으로 만든 학습용 추상 이미지이며 실제 기종 식별 교범을 대체하지 않는다. 실제 표적 판단은 정식 교육과 지휘계통에 따른다.\n", + "6. 확장 로드맵: 학습 기록 내보내기 API(문항 ID·응답·소요시간), 인접 교육 체계와 문항 은행 공유 인터페이스\"\"\")\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 10 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + "[PASS] 손상 데이터 3건 차단\n", + "[PASS] 판별 대상 297장 처리\n", + "[PASS] 전체 분류 정확도 85% 이상\n", + "[PASS] 판별(Medium 이상) 정확도 95% 이상\n", + "[PASS] High 등급 정확도 >= Medium 등급 정확도\n", + "[PASS] 장당 처리 200 ms 이내\n", + "[PASS] 오답 해설 자동 공개 0건\n", + "[PASS] 감사 로그 건수 일치\n", + "[PASS] 함수 커버리지 85% 이상\n", + "[PASS] 전체 실행 60초 이내\n", + "자체 점검 10/10 통과, 함수 커버리지 87% (13/15), 통합 실행 성공\n", + "\n", + "[부대 교육 배포 브리핑 요약 - 교육용가상자료]\n", + "1. 구성: 합성 추상 실루엣 6종(위협 3종), 문항 300장 중 297장 배포, 손상 3장 자동 차단\n", + "2. AI 성능: 전체 정확도 85.2%, 신뢰도 Medium 이상 판별 정확도 96.7%, Low 55장은 교관 확인, 장당 1.47 ms\n", + "3. 학습 운영: 가상 장병 20명 정답률 75.0%, 다빈도 오답 ballistic->cruise(12), civil_quad->civil_fw(11)\n", + "4. 교관 검토: 오답 해설 50건 검토 대기, 자동 공개 0건, 감사 로그 50건\n", + "5. 운용 유의: 실루엣은 기본 도형으로 만든 학습용 추상 이미지이며 실제 기종 식별 교범을 대체하지 않는다. 실제 표적 판단은 정식 교육과 지휘계통에 따른다.\n", + "6. 확장 로드맵: 학습 기록 내보내기 API(문항 ID·응답·소요시간), 인접 교육 체계와 문항 은행 공유 인터페이스\n", + "```\n", + "
\n" + ] + } + ], + "metadata": { + "colab": { + "provenance": [] + }, + "kernelspec": { + "display_name": "Python 3", + "name": "python3" + }, + "language_info": { + "name": "python" + } + }, + "nbformat": 4, + "nbformat_minor": 0 +} \ No newline at end of file diff --git a/uploads/TRN04/TRN04_workbook.ipynb b/uploads/TRN04/TRN04_workbook.ipynb new file mode 100644 index 0000000..86c9547 --- /dev/null +++ b/uploads/TRN04/TRN04_workbook.ipynb @@ -0,0 +1,1039 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# 📋 [TRN04] 러우·이란 전훈 기반 드론 대응 교육과정 설계\n", + "### 실습 워크북 & SOP 가이드 (Google Colab / Jupyter)\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/TRN04/TRN04_workbook.ipynb)\n", + "\n", + "---\n", + "\n", + "## 📌 1. 프로젝트 개요 및 배경\n", + "\n", + "| 항목 | 내용 |\n", + "| :--- | :--- |\n", + "| **과정 분류** | 교육·훈련 (Level 3) |\n", + "| **학습 소요 시간** | 10개 미션 (5일 / 총 27.0시간) |\n", + "| **실습 유형** | 작전절차(SOP), 체크리스트, 모의훈련, AAR 사후검토 실습 |\n", + "\n", + "### 📖 프로젝트 목표\n", + "러시아-우크라이나 전쟁과 중동 분쟁에서는 소형 상용형·FPV형·자폭형 무인기가 저비용으로 널리 쓰이고 전자전이 통신과 위치 신호를 흔들면서, 소규모 부대와 개인 수준의 식별·보고·대피·대응 교육이 중요해졌다는 점이 공개적으로 자주 거론되었다. 본 프로젝트는 이러한 공개 일반 수준의 경향을 교육용 가상 전훈 카드로 요약해 근거로 삼고, 초급(식별·보고), 중급(대피·사격 연계), 고급(전자전 개념·방공 연계) 단계의 드론 대응 교육과정을 설계하는 문서·분석 과제이다. 설계팀은 근거 출처의 신뢰도와 불확실성을 먼저 따지고, 교육 항목과 근거의 연결 기준, 시뮬레이터·야전 연계 교육의 승인/보류 기준과 책임 직책, 기록 분류 규칙, 평가 루브릭을 정리한다. 이어 비판적 검토(AAR)로 비현실적인 과목을 걸러내고 지휘관 1페이지 브리프로 결심 사항을 압축한 뒤 표준 교육과정 설계서와 교재 패키지로 묶는다. 실제 교전은 교전수칙과 지휘 계통을 따르며, 본 과제는 장비 제원이나 운용 절차가 아닌 교육과정 설계의 논리와 문서화에 초점을 둔다.\n", + "\n", + "> **⚠️ [교육용 가상 자료 원칙]**\n", + "> - 본 워크북의 모든 자료는 안전 규정에 따라 **교육용 가상 자료**로 구성되어 있습니다.\n", + "> - 실제 군사 기밀이나 부대 세부 인적·작전 정보는 포함되지 않습니다.\n", + "\n", + "---\n", + "\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 01. 러시아-우크라이나 및 중동 분쟁 드론 전훈(소형 FPV, 자폭드론, 전자전) 자료 분석\n", + "\n", + "### 📋 미션 배경 및 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "러시아-우크라이나 및 중동 분쟁 드론 전훈 자료를 분석한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **공개 일반 수준으로 요약한 러시아-우크라이나·중동 드론 전훈 카드를 분석해 교육과정 설계 문서의 골격과 분석 방향을 정한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **실습자료의 가상 전훈 카드 8건(소형 상용형, FPV형, 자폭형, 전자전 영향 등) 중 5건 이상을 사례 검토표로 정리해 핵심 경향, 교육 시사점, 근거 수준을 적는다 과정을 진행하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 5개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `docs/trn04_doc_framework.md 및 분석 개요서` 형태의 결과물을 완성한다.\n", + "\n", + "### 📑 실습자료 및 작성 양식\n", + "러시아-우크라이나 및 중동 분쟁 드론 전훈(소형 FPV, 자폭드론, 전자전) 자료 분석**\n", + "\n", + "[제공 자료 1] 가상 전훈 요약 카드 8건 [교육용가상자료] - 공개적으로 알려진 일반 수준의 경향을 교육용으로 재서술한 것이다.\n", + "\n", + "| **카드** | **관찰된 경향(요약)** | **교육 시사점** | **근거 수준** |\n", + "| --- | --- | --- | --- |\n", + "| **D1** | 소형 상용형 무인기가 정찰·타격에 널리 쓰이며 저비용 위협이 늘어남 | 식별·보고 교육 | 다수 공개 논의 |\n", + "| **D2** | 소형 1인칭 시점(FPV)형 무인기가 근거리 위협으로 거론됨 | 엄폐·대피 행동 교육 | 다수 공개 논의 |\n", + "| **D3** | 자폭형 무인기가 후방 지역까지 위협한다는 논의 | 경보 전파·경계 교육 | 공개 논의, 세부 불명 |\n", + "| **D4** | 전자전으로 통신·위치 신호가 교란되는 사례 | 대체 통신, 신호 의존도 낮추기 | 공개 논의, 효과 변동 |\n", + "| **D5** | 방공 자산만으로 대응이 어려워 소규모 부대 단위 대응이 필요하다는 논의 | 개인·분대 수준 대응 교육 | 일부 공개 논의 |\n", + "| **D6** | 대응 방식이 빠르게 바뀌어 교육 내용의 갱신이 필요하다는 논의 | 개정 주기 설정 | 일부 공개 논의 |\n", + "| **D7** | 위협 인식 교육을 받지 못한 신규 인원의 피해가 크다는 논의 | 신병·보충 인원 과정 | 단일 출처 성격 |\n", + "| **D8** | 중동 분쟁에서도 무인기 위협에 대한 다층 대응과 경보 체계가 공개적으로 논의됨 | 경보·대피 연계 교육 | 공개 논의, 세부 불명 |\n", + "\n", + "[작성 서식 1] 사례 검토표(5건 이상)\n", + "\n", + "| **카드** | **핵심 경향(한 줄)** | **교육 시사점** | **근거 수준 판단** | **설계서 반영 장** |\n", + "| --- | --- | --- | --- | --- |\n", + "| **D1** | | | | |\n", + "| **D2** | | | | |\n", + "| **D3** | | | | |\n", + "| **D4** | | | | |\n", + "| **D5** | | | | |\n", + "| **D6** | | | | |\n", + "\n", + "[작성 서식 2] 문서 골격표(8개 장 x 5개 항목 모두 정의)\n", + "\n", + "| **장** | **제목(예시)** | **목적** | **입력 자료** | **작성 책임자** | **완료 기준** | **분량** |\n", + "| --- | --- | --- | --- | --- | --- | --- |\n", + "| **1장** | 개요 | | | | | |\n", + "| **2장** | 전훈 분석 | | | | | |\n", + "| **3장** | 교육 목표 | | | | | |\n", + "| **4장** | 과정 설계 | | | | | |\n", + "| **5장** | 교수학습 | | | | | |\n", + "| **6장** | 평가 | | | | | |\n", + "| **7장** | 운영·안전 | | | | | |\n", + "| **8장** | 승인·개선 | | | | | |\n", + "\n", + "[작성 서식 3] 분석 개요서\n", + "\n", + "| **항목** | **내용** |\n", + "| --- | --- |\n", + "| **분석 목적** | ( ) |\n", + "| **분석 범위(포함·제외)** | ( ) |\n", + "| **분석 방법(사례 검토, 신뢰도 평가 등)** | ( ) |\n", + "| **한계(관측 공백, 공개 일반 수준)** | ( ) |\n", + "| **지휘관 결심에 필요한 핵심 의제 3개** | ( ) |\n", + "\n", + "[작성 서식 4] 목차 재배열 기록(핵심 의제 중심)\n", + "\n", + "| **재배열 전 순서** | **재배열 후 순서** | **이유(지휘관 관점 한 줄)** |\n", + "| --- | --- | --- |\n", + "| | | |\n", + "| | | |\n", + "| | | |\n", + "\n", + "| **점검 항목** | **확인** |\n", + "| --- | --- |\n", + "| 전훈 카드를 5건 이상 검토표에 정리했다 | ( ) |\n", + "| 문서 골격 8개 장 x 5개 항목을 모두 정의했다 | ( ) |\n", + "| 분석 개요서 4항목과 핵심 의제를 작성했다 | ( ) |\n", + "| 목차를 핵심 의제 중심으로 재배열하고 이유를 적었다 | ( ) |\n", + "| 원문 인용 없이 요약으로만 썼다 | ( ) |\n", + "\n", + "**\n", + "\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 02. 전훈 기반 대드론 전투원 양성을 위한 교육 목표 및 모듈별 커리큘럼 설계\n", + "\n", + "### 📋 미션 배경 및 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "전훈 기반 대드론 전투원 양성을 위한 교육 목표 및 모듈별 커리큘럼을 설계한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **대드론 교육 목표와 모듈별 커리큘럼 초안을 설계하고, 근거로 쓰는 정보의 출처 신뢰도를 평가하는 기준을 세운다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **신뢰도 6단계 기준과 평가 지표 10개 이상(출처 유형, 독립성, 최신성, 일관성, 구체성, 편향 가능성 등)을 정의한 출처 신뢰도 매트릭스 작성하여 원인을 분석하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 5개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `framework/trn04_source_reliability_matrix.md` 형태의 결과물을 완성한다.\n", + "\n", + "### 📑 실습자료 및 작성 양식\n", + "전훈 기반 대드론 전투원 양성을 위한 교육 목표 및 모듈별 커리큘럼 설계**\n", + "\n", + "[제공 자료 1] 신뢰도 6단계 틀(교육용 예시) - 조별로 기준 문장을 완성한다.\n", + "\n", + "| **단계** | **명칭** | **기준(조별 작성)** |\n", + "| --- | --- | --- |\n", + "| **S1** | 매우 높음 | (예: 서로 독립된 복수 출처가 일치하고 최근이며 구체적) |\n", + "| **S2** | 높음 | |\n", + "| **S3** | 보통 이상 | |\n", + "| **S4** | 보통 이하 | |\n", + "| **S5** | 낮음 | |\n", + "| **S6** | 판단 불가·상충 | |\n", + "\n", + "[작성 서식 1] 평가 지표(10개 이상)\n", + "\n", + "| **번호** | **지표** | **무엇을 보는가** | **점검 질문** |\n", + "| --- | --- | --- | --- |\n", + "| **1** | 출처 유형 | | |\n", + "| **2** | 독립성 | | |\n", + "| **3** | 최신성 | | |\n", + "| **4** | 일관성 | | |\n", + "| **5** | 구체성 | | |\n", + "| **6** | 편향 가능성 | | |\n", + "| **7** | | | |\n", + "| **8** | | | |\n", + "| **9** | | | |\n", + "| **10** | | | |\n", + "| **11** | | | |\n", + "\n", + "[제공 자료 2] 가상 정보 보고서 5종 [교육용가상자료]\n", + "\n", + "| **번호** | **내용(요약)** | **출처 특성** |\n", + "| --- | --- | --- |\n", + "| **R1** | 소형 무인기 위협 증가 경향, 서로 다른 공개 분석 3건이 같은 취지로 일치 | 독립 복수, 최근 |\n", + "| **R2** | 근거리 위협에 대한 대피 행동 교육의 효과를 구체 사례로 설명 | 단일 출처, 구체적 |\n", + "| **R3** | 특정 방어 수단이 완벽한 해법이라는 홍보성 주장 | 이해관계 있는 출처, 검증 불가 |\n", + "| **R4** | 같은 사안에 대해 두 출처의 결론이 서로 상충 | 상충, 확인 안 됨 |\n", + "| **R5** | 공개 분석 기관의 종합 요약, 일부 출처 불명 | 2건 교차, 일부 불명 |\n", + "\n", + "[작성 서식 2] 보고서 등급 부여표(S1~S6) - 개인 등급과 근거 지표를 적고 교관 기준 등급 공개 후 비교한다.\n", + "\n", + "| **보고서** | **개인 등급** | **근거 지표 번호** | **조 합의 등급** | **교관 기준 등급** | **일치(±1 허용)** |\n", + "| --- | --- | --- | --- | --- | --- |\n", + "| **R1** | | | | | |\n", + "| **R2** | | | | | |\n", + "| **R3** | | | | | |\n", + "| **R4** | | | | | |\n", + "| **R5** | | | | | |\n", + "\n", + "| [교관용 기준 등급 - 조별 부여 후 공개] R1: S1, R2: S4, R3: S5, R4: S6, R5: S3. 한 단계 차이는 허용하며 근거 지표가 적혀 있으면 인정한다. |\n", + "| --- |\n", + "\n", + "[작성 서식 3] 교육 목표 3~5개\n", + "\n", + "| **번호** | **교육 목표(행동 동사로)** | **대상 수준(초·중·고)** | **근거 카드·보고서 번호** |\n", + "| --- | --- | --- | --- |\n", + "| **1** | | | |\n", + "| **2** | | | |\n", + "| **3** | | | |\n", + "| **4** | | | |\n", + "| **5** | | | |\n", + "\n", + "[작성 서식 4] 모듈 설계표 - 모든 모듈에 근거 번호와 근거 등급을 연결한다.\n", + "\n", + "| **모듈** | **내용** | **시간** | **근거 번호** | **근거 등급** | **교차검증 여부** |\n", + "| --- | --- | --- | --- | --- | --- |\n", + "| **M1** | | | | | |\n", + "| **M2** | | | | | |\n", + "| **M3** | | | | | |\n", + "| **M4** | | | | | |\n", + "| **M5** | | | | | |\n", + "| **M6** | | | | | |\n", + "\n", + "[작성 서식 5] 단일 출처 경고·교차검증 규칙\n", + "\n", + "| **규칙** | **내용** |\n", + "| --- | --- |\n", + "| **단일 출처 경고 문구** | ( ) |\n", + "| **교차검증 필수 조건(어떤 등급 이하일 때)** | ( ) |\n", + "| **교차검증이 안 될 때의 처리** | ( ) |\n", + "\n", + "| **점검 항목** | **확인** |\n", + "| --- | --- |\n", + "| 신뢰도 6단계 기준을 모두 완성했다 | ( ) |\n", + "| 평가 지표를 10개 이상 정의했다 | ( ) |\n", + "| 보고서 5종 중 4건 이상 교관 기준과 일치했다(±1 허용) | ( ) |\n", + "| 모든 모듈에 근거 번호·등급을 연결했다(100%) | ( ) |\n", + "| 단일 출처 경고와 교차검증 규칙을 명시했다 | ( ) |\n", + "\n", + "**\n", + "\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 03. 초급(식별/보고), 중급(대피/소총사격), 고급(재밍/통합방공) 단계별 교육과정 개발\n", + "\n", + "### 📋 미션 배경 및 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "초급(식별/보고), 중급(대피/소총사격), 고급(재밍/방공망) 단계별 교육과정을 개발한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **초급(식별·보고), 중급(대피·사격 연계), 고급(전자전 개념·방공 연계) 3단계 교육과정을 개발하고, 근거의 관측 공백과 불확실성을 정리해 판단의 한계를 밝힌다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **단계마다 대상, 목표, 시간, 핵심 내용, 평가 방법, 안전·법규 한계를 담은 과정 개발표 설계하고 작성하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 5개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `analysis/trn04_uncertainty_assessment.md` 형태의 결과물을 완성한다.\n", + "\n", + "### 📑 실습자료 및 작성 양식\n", + "초급(식별/보고), 중급(대피/소총사격), 고급(재밍/통합방공) 단계별 교육과정 개발**\n", + "\n", + "| [안전·범위] 중급의 사격은 새 사격 기법을 만들지 않고 사격 안전 규정과 교관 통제에 따른 기존 사격 훈련 과정에 연계하는 것으로만 쓴다. 고급의 재밍·통합방공은 개념과 절차 이해(강의·도상 토의·시뮬레이터 시나리오)까지만 쓰며 장비 운용, 제원, 주파수는 쓰지 않는다. 드론의 구체적 성능 수치도 쓰지 않는다. |\n", + "| --- |\n", + "\n", + "[제공 자료 1] 수준별 과정 개발 틀 [교육용가상자료]\n", + "\n", + "| **수준** | **주제** | **대상** | **교육 방식(예시)** | **안전·법규 한계(예시)** |\n", + "| --- | --- | --- | --- | --- |\n", + "| **초급** | 식별·보고 | 전 장병 | 강의, 이미지 식별 퀴즈(가상 도형), 보고 서식 실습 | 실물 드론 비행 금지, 가상 자료 사용 |\n", + "| **중급** | 대피·사격 연계 | 분대급 | 행동 요령 토의, 시뮬레이터, 기존 사격 훈련 과정에 연계 | 실사격은 사격 안전 규정·교관 통제 하 기존 과정에서만 |\n", + "| **고급** | 전자전 개념·방공 연계 | 지휘·참모 요원 | 도상 토의, 사례 분석, 시나리오 기반 의사결정 | 장비 운용·주파수 불포함, 개념 수준 |\n", + "\n", + "[작성 서식 1] 수준별 과정 개발표\n", + "\n", + "| **항목** | **초급(식별·보고)** | **중급(대피·사격 연계)** | **고급(전자전 개념·방공 연계)** |\n", + "| --- | --- | --- | --- |\n", + "| **교육 목표** | | | |\n", + "| **시간** | | | |\n", + "| **핵심 내용 3가지** | | | |\n", + "| **교육 방식** | | | |\n", + "| **평가 방법** | | | |\n", + "| **안전·법규 한계** | | | |\n", + "| **근거 번호(D·R)** | | | |\n", + "\n", + "[작성 서식 2] 관측 공백 요인표(8개 이상)\n", + "\n", + "| **번호** | **관측 공백 요인** | **왜 공백인가** | **영향받는 과정·모듈** |\n", + "| --- | --- | --- | --- |\n", + "| **1** | | | |\n", + "| **2** | | | |\n", + "| **3** | | | |\n", + "| **4** | | | |\n", + "| **5** | | | |\n", + "| **6** | | | |\n", + "| **7** | | | |\n", + "| **8** | | | |\n", + "| **9** | | | |\n", + "\n", + "[작성 서식 3] 불확실성 영향도 매트릭스 - 영향도(상·중·하)와 발생 가능성(상·중·하)을 판정하고 대응 방안을 쓴다.\n", + "\n", + "| **요인 번호** | **영향도** | **발생 가능성** | **우선순위(영향 x 가능성)** | **대응 방안(교육 설계에서 어떻게 반영)** |\n", + "| --- | --- | --- | --- | --- |\n", + "| **1** | | | | |\n", + "| **2** | | | | |\n", + "| **3** | | | | |\n", + "| **4** | | | | |\n", + "| **5** | | | | |\n", + "| **6** | | | | |\n", + "| **7** | | | | |\n", + "| **8** | | | | |\n", + "\n", + "[작성 서식 4] 판단 한계 문구(3건 이상) - 과도한 확신을 막는 문구를 쓴다. 예: '근거 D3은 세부가 공개되지 않아 이 모듈의 시간 배정은 잠정이다.'\n", + "\n", + "| **번호** | **판단 한계 문구** | **붙일 위치(장·모듈)** |\n", + "| --- | --- | --- |\n", + "| **1** | | |\n", + "| **2** | | |\n", + "| **3** | | |\n", + "\n", + "| **점검 항목** | **확인** |\n", + "| --- | --- |\n", + "| 3개 수준 과정 개발표를 모두 작성했다(안전·법규 한계 포함) | ( ) |\n", + "| 관측 공백 요인을 8개 이상 식별했다 | ( ) |\n", + "| 모든 요인에 영향도·가능성·대응 방안을 적었다 | ( ) |\n", + "| 판단 한계 문구를 3건 이상 작성했다 | ( ) |\n", + "| 중급 사격·고급 전자전을 개념·안전 절차 수준으로만 썼다 | ( ) |\n", + "\n", + "**\n", + "\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 04. 실습 및 모의 훈련 중심 교수학습 지도안 및 교관 가이드라인 초안 작성\n", + "\n", + "### 📋 미션 배경 및 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "실습 및 모의 훈련 중심 교수학습 지도안 및 교관 가이드라인을 작성한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **실습·모의훈련 중심의 교수학습 지도안과 교관 가이드라인 초안을 쓰고, 교육 항목과 전훈 근거를 잇는 연결(귀속) 기준과 가중치 산식을 정한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **초급·중급·고급에서 각각 1차시 지도안(도입-전개-정리, 시간 배분, 실습 방법, 평가)을 작성하고 교관이 지켜야 할 안전·교전수칙 안내 항목을 가이드라인으로 정리하고 규격화하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 5개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `matrix/trn04_attribution_matrix.xlsx(md)` 형태의 결과물을 완성한다.\n", + "\n", + "### 📑 실습자료 및 작성 양식\n", + "실습 및 모의 훈련 중심 교수학습 지도안 및 교관 가이드라인 초안 작성**\n", + "\n", + "[제공 자료 1] 1차시 지도안 서식 예시(초급 식별·보고) [교육용가상자료]\n", + "\n", + "| **단계** | **시간** | **교수 활동** | **학습 활동** | **평가·안전 유의** |\n", + "| --- | --- | --- | --- | --- |\n", + "| **도입** | 10분 | 학습 목표·위협 배경(D1) 설명 | 질문 응답 | 원문 인용 금지 |\n", + "| **전개** | 30분 | 가상 도형 이미지 식별 시범, 보고 서식 설명 | 식별 퀴즈, 보고 서식 작성 | 실물 비행 금지 |\n", + "| **정리** | 10분 | 오류 사례 정리, 평가 | 소감 한 줄, 평가 퀴즈 | 정답 기준 공개 |\n", + "\n", + "[작성 서식 1] 수준별 1차시 지도안(초급은 예시를 고쳐 쓰고 중급·고급은 새로 작성)\n", + "\n", + "| **수준** | **차시 주제·목표** | **도입(분)** | **전개(분)** | **정리(분)** | **평가 방법** |\n", + "| --- | --- | --- | --- | --- | --- |\n", + "| **초급** | | | | | |\n", + "| **중급** | | | | | |\n", + "| **고급** | | | | | |\n", + "\n", + "[작성 서식 2] 교관 가이드라인(안전·교전수칙 항목 8개 이상)\n", + "\n", + "| **번호** | **가이드라인 항목** | **교관이 확인할 것** | **위반 시 조치** |\n", + "| --- | --- | --- | --- |\n", + "| **1** | | | |\n", + "| **2** | | | |\n", + "| **3** | | | |\n", + "| **4** | | | |\n", + "| **5** | | | |\n", + "| **6** | | | |\n", + "| **7** | | | |\n", + "| **8** | | | |\n", + "\n", + "[제공 자료 2] 연결(귀속) 기준 틀 - 교육 항목을 전훈 근거에 연결해도 되는지 판단하는 기준이다. 국제법적 귀속이 아니라 '어느 근거가 이 교육 항목을 뒷받침하는가'를 가리키는 교육과정 설계 용어이다.\n", + "\n", + "[작성 서식 3] 연결 판단 기준 12개와 가중치(합 100) - 기준 예시: 근거 등급, 반복 관측, 출처 독립성, 최신성, 교육 적합성, 안전성, 훈련 가능성, 자원 적합성, 법규 적합성, 효과 측정 가능성 등\n", + "\n", + "| **번호** | **기준** | **점검 질문** | **가중치** |\n", + "| --- | --- | --- | --- |\n", + "| **1** | | | |\n", + "| **2** | | | |\n", + "| **3** | | | |\n", + "| **4** | | | |\n", + "| **5** | | | |\n", + "| **6** | | | |\n", + "| **7** | | | |\n", + "| **8** | | | |\n", + "| **9** | | | |\n", + "| **10** | | | |\n", + "| **11** | | | |\n", + "| **12** | | | |\n", + "| **합계** | | | 100 |\n", + "\n", + "산식: 연결 점수 = Σ(가중치 x 항목 점수 / 5), 항목 점수는 1~5점. 합계 100점 만점.\n", + "\n", + "[작성 서식 4] 후보 교육 항목 3개 점수 계산표 - 후보 A 드론 식별·보고 훈련 / 후보 B 엄폐·대피 행동 숙달 / 후보 C 전자전 개념 강의\n", + "\n", + "| **기준 번호** | **가중치** | **후보 A 점수(1~5)** | **후보 B 점수(1~5)** | **후보 C 점수(1~5)** |\n", + "| --- | --- | --- | --- | --- |\n", + "| **1** | | | | |\n", + "| **2** | | | | |\n", + "| **3** | | | | |\n", + "| **4** | | | | |\n", + "| **5** | | | | |\n", + "| **6** | | | | |\n", + "| **7** | | | | |\n", + "| **8** | | | | |\n", + "| **9** | | | | |\n", + "| **10** | | | | |\n", + "| **11** | | | | |\n", + "| **12** | | | | |\n", + "| **연결 점수 합계** | 100 | ( ) | ( ) | ( ) |\n", + "\n", + "| **구분** | **기재** |\n", + "| --- | --- |\n", + "| **산식 검산(가중치 합 100, 계산 재확인)** | ( ) |\n", + "| **우선순위와 근거 한 줄** | ( ) |\n", + "\n", + "| **점검 항목** | **확인** |\n", + "| --- | --- |\n", + "| 수준별 1차시 지도안 3건을 작성했다 | ( ) |\n", + "| 교관 가이드라인 8개 항목에 안전·교전수칙을 포함했다 | ( ) |\n", + "| 연결 기준 12개를 정하고 가중치 합이 100이다 | ( ) |\n", + "| 후보 3개 점수를 계산하고 검산했다(오차 0) | ( ) |\n", + "| 우선순위와 근거를 적었다 | ( ) |\n", + "\n", + "**\n", + "\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 05. 전훈 반영 시뮬레이터 실습 및 야전 기동 훈련 연계 교육 계획 수립\n", + "\n", + "### 📋 미션 배경 및 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "전훈 반영 시뮬레이터 실습 및 야전 기동 훈련 연계 교육 계획을 수립한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **시뮬레이터 실습과 야전 기동 훈련을 잇는 교육 계획을 세우고, 훈련 승인·보류·중단 조건과 책임 직책을 정한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **8차시 연계 교육 계획표(시뮬레이터 실습, 도상 토의, 야전 기동 훈련)를 작성하고 차시마다 선행 조건을 적는다 과정을 진행하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 5개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `sop/trn04_approval_thresholds_sop.md` 형태의 결과물을 완성한다.\n", + "\n", + "### 📑 실습자료 및 작성 양식\n", + "전훈 반영 시뮬레이터 실습 및 야전 기동 훈련 연계 교육 계획 수립**\n", + "\n", + "[제공 자료 1] 연계 교육 구조(교육용 가상) - 시뮬레이터 실습(S), 도상 토의(T), 야전 기동 훈련(F)\n", + "\n", + "| **차시** | **형태** | **주제 예시** | **선행 조건(조별 작성)** |\n", + "| --- | --- | --- | --- |\n", + "| **1** | T | 위협 개요, 식별·보고 기초 | ( ) |\n", + "| **2** | S | 식별·보고 시뮬레이터 | ( ) |\n", + "| **3** | T | 대피 행동 토의 | ( ) |\n", + "| **4** | S | 대피 행동 시뮬레이터 | ( ) |\n", + "| **5** | F | 야전 대피 행동 숙달(무장 실사격 없음) | ( ) |\n", + "| **6** | T | 전자전 개념 토의 | ( ) |\n", + "| **7** | S | 복합 상황 시뮬레이터 | ( ) |\n", + "| **8** | F | 종합 야전 연계 훈련 | ( ) |\n", + "\n", + "[제공 자료 2] 책임 직책 후보 - 교관, 안전통제관, 훈련통제관, 중대장, 교전수칙 자문 담당, 대대장(승인권자)\n", + "\n", + "[작성 서식 1] 연계 교육 8차시 계획표\n", + "\n", + "| **차시** | **형태** | **주제** | **시간** | **장소·자원** | **선행 조건** | **승인자** |\n", + "| --- | --- | --- | --- | --- | --- | --- |\n", + "| **1** | | | | | | |\n", + "| **2** | | | | | | |\n", + "| **3** | | | | | | |\n", + "| **4** | | | | | | |\n", + "| **5** | | | | | | |\n", + "| **6** | | | | | | |\n", + "| **7** | | | | | | |\n", + "| **8** | | | | | | |\n", + "\n", + "[제공 자료 3] 분기 조건 후보 - 판정은 승인 / 조건부 승인 / 보류 / 중단 중에서 정한다.\n", + "\n", + "| **번호** | **분기 조건(가상)** |\n", + "| --- | --- |\n", + "| **C1** | 안전교육 미이수 인원이 포함됨 |\n", + "| **C2** | 안전통제관이 배치되지 않음 |\n", + "| **C3** | 사격 연계 시 사격장 안전 담당이 배치되지 않음 |\n", + "| **C4** | 교관 1명당 교육생이 기준 인원을 초과함 |\n", + "| **C5** | 시뮬레이터 사전 점검이 끝나지 않음 |\n", + "| **C6** | 야외 훈련 중 기상이 악화됨 |\n", + "| **C7** | 훈련 지역에 민간인이나 비인가 비행체 접근 가능성이 있음 |\n", + "| **C8** | 전파 사용 계획이 승인되지 않음 |\n", + "| **C9** | 교육 자료에 실제 부대·작전 정보가 포함됨 |\n", + "| **C10** | 교전수칙 자문 검토가 끝나지 않음 |\n", + "\n", + "[작성 서식 2] 승인/보류 분기표(10개 이상, 모든 칸 작성)\n", + "\n", + "| **번호** | **분기 조건** | **판정** | **판정 근거(한 줄)** | **책임 직책** | **보고 시한** |\n", + "| --- | --- | --- | --- | --- | --- |\n", + "| **C1** | | | | | |\n", + "| **C2** | | | | | |\n", + "| **C3** | | | | | |\n", + "| **C4** | | | | | |\n", + "| **C5** | | | | | |\n", + "| **C6** | | | | | |\n", + "| **C7** | | | | | |\n", + "| **C8** | | | | | |\n", + "| **C9** | | | | | |\n", + "| **C10** | | | | | |\n", + "| **C11** | | | | | |\n", + "| **C12** | | | | | |\n", + "\n", + "[작성 서식 3] 준수 조항 확인표\n", + "\n", + "| **준수 영역** | **조항(한 문장)** | **책임 직책** | **확인 시점(차시)** |\n", + "| --- | --- | --- | --- |\n", + "| **교전수칙·지휘 계통** | 실제 교전은 교전수칙과 지휘 계통에 따른다 | | |\n", + "| **사격 안전** | | | |\n", + "| **전파 사용** | | | |\n", + "| **인도주의 법규·민간인 보호** | | | |\n", + "\n", + "| **점검 항목** | **확인** |\n", + "| --- | --- |\n", + "| 8차시 계획표를 모두 작성했다 | ( ) |\n", + "| 분기점 10개 이상에 판정과 책임 직책을 적었다(빈칸 0) | ( ) |\n", + "| 안전통제관·사격 안전·전파·교전수칙 조건을 보류 또는 중단으로 판정했다 | ( ) |\n", + "| 준수 조항 4종을 작성했다 | ( ) |\n", + "| 다른 조 교차 검토 지적을 반영했다 | ( ) |\n", + "\n", + "**\n", + "\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 06. 지휘관 승인용 드론 대응 교육과정 종합 제안서 및 성과 평가 지표 작성\n", + "\n", + "### 📋 미션 배경 및 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "지휘관 승인용 드론 대응 교육과정 종합 제안서를 작성한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **지휘관 승인용 종합 제안서와 성과 평가 지표를 쓰고, 사실·판단·권고·미확인을 구분하는 기록 분류 규칙을 표준화한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **배경, 목표, 과정 개요, 소요, 위험, 승인 요청 사항, 성과 지표의 7개 항목으로 종합 제안서를 작성하고 성과 지표는 정량 기준을 붙여 6개 이상 정의하고 검증하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 5개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `templates/trn04_aar_fact_action_template.md` 형태의 결과물을 완성한다.\n", + "\n", + "### 📑 실습자료 및 작성 양식\n", + "지휘관 승인용 드론 대응 교육과정 종합 제안서 및 성과 평가 지표 작성**\n", + "\n", + "[작성 서식 1] 지휘관 승인용 종합 제안서(7개 항목)\n", + "\n", + "| **항목** | **내용(조별 작성)** |\n", + "| --- | --- |\n", + "| **1. 배경(근거 번호 표시)** | ( ) |\n", + "| **2. 교육 목표** | ( ) |\n", + "| **3. 과정 개요(수준·시간·방식)** | ( ) |\n", + "| **4. 소요(인원·시간·장소·교관)** | ( ) |\n", + "| **5. 위험 요소와 대응** | ( ) |\n", + "| **6. 승인 요청 사항(결심 필요)** | ( ) |\n", + "| **7. 성과 평가 지표** | ( ) |\n", + "\n", + "[작성 서식 2] 성과 평가 지표(6개 이상, 정량 기준 포함)\n", + "\n", + "| **번호** | **지표** | **측정 방법** | **목표값** | **주기·담당** |\n", + "| --- | --- | --- | --- | --- |\n", + "| **1** | | | | |\n", + "| **2** | | | | |\n", + "| **3** | | | | |\n", + "| **4** | | | | |\n", + "| **5** | | | | |\n", + "| **6** | | | | |\n", + "\n", + "[제공 자료] 기록 분류 규칙 틀: F 사실(확인된 관찰·기록) / J 판단(해석·평가) / A 권고·조치(할 일) / U 미확인(확인 필요)\n", + "\n", + "[작성 서식 3] 기록 분류 규칙 4유형 정의\n", + "\n", + "| **유형** | **정의** | **쓰는 방법(예시 문형)** | **쓰지 않는 방법** |\n", + "| --- | --- | --- | --- |\n", + "| **F 사실** | | | |\n", + "| **J 판단** | | | |\n", + "| **A 권고·조치** | | | |\n", + "| **U 미확인** | | | |\n", + "\n", + "[작성 서식 4] 혼동 유발 단어 금지 목록(10개 이상) - 예: 확실히, 분명히, 틀림없이, 아마, 충분히, 적절히, 신속히, 상당히, 거의, 대략\n", + "\n", + "| **번호** | **금지 단어** | **왜 혼동되는가** | **대신 쓸 표현** |\n", + "| --- | --- | --- | --- |\n", + "| **1** | | | |\n", + "| **2** | | | |\n", + "| **3** | | | |\n", + "| **4** | | | |\n", + "| **5** | | | |\n", + "| **6** | | | |\n", + "| **7** | | | |\n", + "| **8** | | | |\n", + "| **9** | | | |\n", + "| **10** | | | |\n", + "\n", + "[작성 서식 5] 연습 문장 분류(8개) - F/J/A/U 중 하나를 적는다. 금지 단어가 있으면 표시한다.\n", + "\n", + "| **번호** | **문장(가상)** | **분류** | **금지 단어 포함** |\n", + "| --- | --- | --- | --- |\n", + "| **1** | 시뮬레이터 실습 4시간 중 2시간은 접속이 불가했다(운영 기록 확인) | | |\n", + "| **2** | 따라서 시뮬레이터 의존 비율이 너무 높다 | | |\n", + "| **3** | 시뮬레이터 실습을 2시간으로 줄이고 도상 토의를 추가할 것을 건의한다 | | |\n", + "| **4** | 교관이 아마 부족할 것 같다 | | |\n", + "| **5** | 소대 편성 인원이 30명이었다(출석부 확인) | | |\n", + "| **6** | 교관 1명이 30명을 지도하는 것은 확실히 불가능하다 | | |\n", + "| **7** | 다음 주까지 교관 추가 배정 요청서를 제출한다 | | |\n", + "| **8** | 카드 D7 내용은 단일 출처라 교차 확인이 필요하다 | | |\n", + "\n", + "| [교관용 정답표 - 조별 분류 후 공개] 1 F, 2 J, 3 A, 4 U(금지 단어 '아마'), 5 F, 6 J(금지 단어 '확실히'), 7 A, 8 U. |\n", + "| --- |\n", + "\n", + "| **지표** | **계산** | **결과** | **목표** |\n", + "| --- | --- | --- | --- |\n", + "| **분류 정확도** | 맞게 분류한 문장 수 ( ) / 8 | ( )개 | 7개 이상 |\n", + "\n", + "| **점검 항목** | **확인** |\n", + "| --- | --- |\n", + "| 종합 제안서 7개 항목을 작성했다 | ( ) |\n", + "| 성과 지표를 6개 이상 정량 기준으로 정의했다 | ( ) |\n", + "| 기록 분류 규칙 4유형을 모두 정의했다 | ( ) |\n", + "| 금지 단어 목록을 10개 이상 만들었다 | ( ) |\n", + "| 연습 문장 8개 중 7개 이상 맞게 분류했다 | ( ) |\n", + "\n", + "**\n", + "\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 07. 교관 부족 및 교육 훈련장 제약 상황 모의 교육과정 운영 스트레스 분석\n", + "\n", + "### 📋 미션 배경 및 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "교관 부족 및 훈련장 제약 상황 모의 교육과정 운영 스트레스 분석을 진행한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **교관 부족과 훈련장 제약이 닥친 상황에서 교육과정 운영이 어떻게 흔들리는지 스트레스 분석하고, 평가자 편차를 줄이는 평가 루브릭을 만든다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **스트레스 사건 카드 6건(교관 1명 이탈, 훈련장 사용 시간 절반, 시뮬레이터 고장, 인원 2배 편성, 기상 악화, 예산 축소)에 대해 영향 받는 모듈, 대체안, 승인 필요 사항을 분석표로 정리하고 규격화하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 5개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `rubric/trn04_evaluation_rubric.md` 형태의 결과물을 완성한다.\n", + "\n", + "### 📑 실습자료 및 작성 양식\n", + "교관 부족 및 교육 훈련장 제약 상황 모의 교육과정 운영 스트레스 분석**\n", + "\n", + "[제공 자료 1] 스트레스 사건 카드 6건 [교육용가상자료] - 교육과정 운영이 흔들리는 상황을 분석한다.\n", + "\n", + "| **번호** | **사건** | **가상 조건** |\n", + "| --- | --- | --- |\n", + "| **7-1** | 교관 1명 이탈 | 교관 2명 중 1명이 다른 임무로 빠짐 |\n", + "| **7-2** | 훈련장 사용 시간 절반 | 야외 훈련장 사용이 월 2회에서 월 1회로 감소 |\n", + "| **7-3** | 시뮬레이터 고장 | 2주 동안 사용 불가 |\n", + "| **7-4** | 인원 2배 편성 | 교육 대상이 120명에서 240명으로 증가 |\n", + "| **7-5** | 기상 악화 | 야외 훈련 3회 중 2회 취소 |\n", + "| **7-6** | 예산 축소 | 교재 인쇄·소모품 예산 30% 삭감(가상) |\n", + "\n", + "[작성 서식 1] 스트레스 분석표 - 대체안은 안전·교전수칙 조건을 낮추지 않아야 하며, 낮춰야만 한다면 '보류'로 판단한다.\n", + "\n", + "| **사건** | **영향받는 모듈·차시** | **대체안** | **안전·교전수칙 영향(O/X)** | **승인 필요 사항(책임 직책)** | **보류 판단 여부** |\n", + "| --- | --- | --- | --- | --- | --- |\n", + "| **7-1** | | | | | |\n", + "| **7-2** | | | | | |\n", + "| **7-3** | | | | | |\n", + "| **7-4** | | | | | |\n", + "| **7-5** | | | | | |\n", + "| **7-6** | | | | | |\n", + "\n", + "[작성 서식 2] 평가 루브릭(5개 영역 x 4등급, 20칸 모두 정의)\n", + "\n", + "| **영역** | **탁월** | **우수** | **보통** | **미흡** |\n", + "| --- | --- | --- | --- | --- |\n", + "| **1. 근거 충실성(근거 번호·등급 연결)** | | | | |\n", + "| **2. 과정 설계 적합성** | | | | |\n", + "| **3. 안전·교전수칙 준수** | | | | |\n", + "| **4. 운영 현실성(자원·시간)** | | | | |\n", + "| **5. 문서화 품질(분류 규칙·분량)** | | | | |\n", + "\n", + "[제공 자료 2] 가상 답안 3건(요약) - 두 평가자가 따로 채점한다.\n", + "\n", + "| **답안** | **요약(가상)** |\n", + "| --- | --- |\n", + "| **가** | 모든 모듈에 근거 번호가 있으나 안전 조항 일부 누락, 시간 배분은 현실적 |\n", + "| **나** | 근거가 일부만 연결되고 야외 훈련 8회를 계획해 자원 초과, 안전 조항은 충실 |\n", + "| **다** | 근거·안전·자원 모두 충족하나 기록 분류 규칙이 적용되지 않음 |\n", + "\n", + "[작성 서식 3] 채점 편차 확인표 - 각 영역 등급(탁월=4, 우수=3, 보통=2, 미흡=1)의 합계 점수를 쓰고 등급 차이를 계산한다.\n", + "\n", + "| **답안** | **평가자 A 총점** | **평가자 B 총점** | **총점 차이** | **등급 차이 1단계 이내(O/X)** |\n", + "| --- | --- | --- | --- | --- |\n", + "| **가** | | | | |\n", + "| **나** | | | | |\n", + "| **다** | | | | |\n", + "\n", + "| **점검 항목** | **확인** |\n", + "| --- | --- |\n", + "| 스트레스 카드 6건의 영향·대체안을 모두 분석했다 | ( ) |\n", + "| 대체안이 안전·교전수칙을 낮추지 않았다(필요하면 보류로 판단) | ( ) |\n", + "| 평가 루브릭 20칸을 모두 정의했다 | ( ) |\n", + "| 가상 답안 3건 채점에서 등급 차이가 1단계 이내이다 | ( ) |\n", + "| 차이가 크면 루브릭 문구를 보완했다 | ( ) |\n", + "\n", + "**\n", + "\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 08. AAR 회의를 통한 전훈 교육과정 내 비현실적 과목 식별 및 수정\n", + "\n", + "### 📋 미션 배경 및 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "AAR 회의를 통해 비현실적 과목을 식별하고 수정한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **AAR 회의에서 비판적 검토(레드팀)로 과정안의 비현실적인 과목을 식별하고 수정한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **1차 과정안과 훈련·운영 기록을 대조해 비현실적인 과목과 시간 배분을 사실 중심으로 찾는다 과정을 진행하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 5개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `reports/trn04_redteam_critique_report.md` 형태의 결과물을 완성한다.\n", + "\n", + "### 📑 실습자료 및 작성 양식\n", + "AAR 회의를 통한 전훈 교육과정 내 비현실적 과목 식별 및 수정**\n", + "\n", + "[제공 자료 1] AAR 4단계 진행표\n", + "\n", + "| **단계** | **질문** | **진행 규칙** |\n", + "| --- | --- | --- |\n", + "| **1** | 무엇을 계획했나? | SOP·MSEL 기준을 먼저 확인한다 |\n", + "| **2** | 실제로 무엇이 일어났나? | 기록(시각)을 기준으로 말하고, 진술은 기록과 대조한다 |\n", + "| **3** | 왜 차이가 났나? | 사람이 아닌 절차·양식·환경·숙달 원인으로 분류한다 |\n", + "| **4** | 무엇을 유지·개선할까? | 개선점마다 담당·기한·근거를 붙인다 |\n", + "\n", + "[제공 자료 2] 가상 1차 과정안 검토 회의 타임라인 발췌 [교육용가상자료] - 자기 조 기록과 함께 사용한다.\n", + "\n", + "| **시각** | **관측 기록** | **참여자 진술** |\n", + "| --- | --- | --- |\n", + "| **회의 10:00** | 1차 과정안: 중급 사격 연계 4시간, 사격장은 월 1회 2시간 사용 | 설계 책임자: 사격장 일정은 확인하지 않았음 |\n", + "| **회의 10:20** | 고급 시뮬레이터 실습 6시간, 시뮬레이터실 주 1회 1시간 | 커리큘럼 담당: 한 번에 몰아서 하면 된다고 생각함 |\n", + "| **회의 10:40** | 초급 과정 근거 D7(단일 출처)만으로 신병 과정 신설 | 분석 담당: 다른 근거를 찾지 못함 |\n", + "| **회의 11:00** | 교관 2명이 240명을 지도하는 계획 | 평가 담당: 인원 증가는 고려하지 않았음 |\n", + "| **회의 11:20** | 제안서에 '확실히 효과적이다' 문구 3곳 | 설계 책임자: 강조하려고 썼음 |\n", + "\n", + "[제공 자료 3] 비판적 검토(레드팀) 규칙: 사람이 아니라 문서를 비판하고, 쟁점마다 근거 기록 위치를 쓰며, 설계팀은 반박하거나 수용한다. 수용하지 않으면 지휘관 브리프의 위험 요소에 표시한다.\n", + "\n", + "[작성 서식 0] 비판적 쟁점표(5건 이상) - 모든 쟁점에 반박과 수정 대안을 쓴다.\n", + "\n", + "| **번호** | **쟁점(비현실적인 과목·시간)** | **근거(기록 위치)** | **설계팀 반박** | **수정 대안** | **수용 여부** |\n", + "| --- | --- | --- | --- | --- | --- |\n", + "| **1** | | | | | |\n", + "| **2** | | | | | |\n", + "| **3** | | | | | |\n", + "| **4** | | | | | |\n", + "| **5** | | | | | |\n", + "| **6** | | | | | |\n", + "\n", + "[작성 서식 1] 기록-진술 불일치 확인표\n", + "\n", + "| **번호** | **시각** | **기록 내용** | **진술 내용** | **확인 결과(사실)** |\n", + "| --- | --- | --- | --- | --- |\n", + "| **1** | | | | |\n", + "| **2** | | | | |\n", + "| **3** | | | | |\n", + "| **4** | | | | |\n", + "\n", + "[작성 서식 2] 오류 분석 매트릭스\n", + "\n", + "| **번호** | **문제(현상)** | **사실 근거(기록 위치)** | **원인 분류** | **개선점** | **담당·기한** |\n", + "| --- | --- | --- | --- | --- | --- |\n", + "| **1** | | | | | |\n", + "| **2** | | | | | |\n", + "| **3** | | | | | |\n", + "| **4** | | | | | |\n", + "| **5** | | | | | |\n", + "| **6** | | | | | |\n", + "\n", + "원인 분류: 설계(과목·순서) / 시간(배정·일정) / 자원(교관·시설·예산) / 안전·법규 / 근거(등급·교차검증)\n", + "\n", + "[작성 서식 3] 1차 SOP 수정안\n", + "\n", + "| **수정 대상** | **수정 전** | **수정 후** | **근거(분석 번호)** |\n", + "| --- | --- | --- | --- |\n", + "| **체크리스트** | | | |\n", + "| **기록 양식** | | | |\n", + "| **인계·보고 템플릿** | | | |\n", + "| **기타** | | | |\n", + "\n", + "| **점검 항목** | **확인** |\n", + "| --- | --- |\n", + "| AAR 4단계를 순서대로 진행했다 | ( ) |\n", + "| 기록과 진술이 다른 부분을 사실로 확인했다 | ( ) |\n", + "| 개선점을 5건 이상 도출했다 | ( ) |\n", + "| 모든 원인 항목에 근거 기록 위치를 적었다(100%) | ( ) |\n", + "| 회의록에 개인 비난 표현이 없다 | ( ) |\n", + "| SOP 수정안을 작성했다 | ( ) |\n", + "| 수정안이 승인/보류 기준(미션 5)과 모순되지 않는다 | ( ) |\n", + "\n", + "**\n", + "\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 09. 피드백 반영 교육 시간표 및 교재 내용 보정\n", + "\n", + "### 📋 미션 배경 및 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "피드백을 반영하여 교육 시간표와 교재 내용을 보정한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **AAR 피드백을 반영해 교육 시간표와 교재 내용을 보정하고, 지휘관이 한 페이지로 결심할 수 있는 브리프를 만든다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **1차 시간표(가상)에서 비현실적인 과목을 줄이거나 바꾸고 보정 전후를 비교하며, 교재에서 수정할 항목을 목록으로 만들어 반영 여부 확인하고 유효성을 점검하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 5개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `briefing/trn04_commander_1page_brief.md(pdf)` 형태의 결과물을 완성한다.\n", + "\n", + "### 📑 실습자료 및 작성 양식\n", + "피드백 반영 교육 시간표 및 교재 내용 보정**\n", + "\n", + "[제공 자료 1] 1차 시간표(가상, 총 24시간) [교육용가상자료]\n", + "\n", + "| **수준** | **과목** | **시간** | **형태** | **문제점(조별 확인)** |\n", + "| --- | --- | --- | --- | --- |\n", + "| **초급** | 위협 개요·식별·보고 | 4시간 | 강의·시뮬레이터 | ( ) |\n", + "| **초급** | 신병 별도 과정 | 2시간 | 강의 | ( ) |\n", + "| **중급** | 대피 행동 | 4시간 | 토의·야외 | ( ) |\n", + "| **중급** | 사격 연계 | 4시간 | 사격장 | ( ) |\n", + "| **고급** | 전자전 개념 | 3시간 | 강의·토의 | ( ) |\n", + "| **고급** | 방공 연계 시뮬레이터 | 6시간 | 시뮬레이터 | ( ) |\n", + "| **공통** | 평가·정리 | 1시간 | 평가 | ( ) |\n", + "\n", + "[작성 서식 1] 보정 시간표(미션 8 수정안 반영) - 비현실적 과목을 줄이거나 바꾸고 이유를 쓴다.\n", + "\n", + "| **수준** | **과목** | **보정 전 시간** | **보정 후 시간·형태** | **보정 이유(쟁점 번호)** |\n", + "| --- | --- | --- | --- | --- |\n", + "| | | | | |\n", + "| | | | | |\n", + "| | | | | |\n", + "| | | | | |\n", + "| | | | | |\n", + "| | | | | |\n", + "| | | | | |\n", + "\n", + "| **지표** | **보정 전** | **보정 후** | **목표** |\n", + "| --- | --- | --- | --- |\n", + "| **총 시간** | 24시간 | ( ) | 자원 범위 내 |\n", + "| **비현실적 과목 수** | ( ) | ( ) | 0건 |\n", + "| **근거 등급 S4 이하 의존 과목** | ( ) | ( ) | 경고 문구 부착 |\n", + "\n", + "[작성 서식 2] 교재 수정 목록\n", + "\n", + "| **번호** | **교재 위치(장·쪽)** | **수정 내용** | **관련 쟁점** | **반영 확인** |\n", + "| --- | --- | --- | --- | --- |\n", + "| **1** | | | | |\n", + "| **2** | | | | |\n", + "| **3** | | | | |\n", + "| **4** | | | | |\n", + "| **5** | | | | |\n", + "\n", + "[작성 서식 3] 지휘관 1페이지 브리프(A4 1장, 첫 줄은 결론 - BLUF)\n", + "\n", + "| **구분** | **내용(간결하게)** |\n", + "| --- | --- |\n", + "| **BLUF(결론 한 문장)** | ( ) |\n", + "| **핵심 결심 사항(2~3개)** | ( ) |\n", + "| **위험 요소(3개, 대응 한 줄씩)** | ( ) |\n", + "| **대안 3종(대안 1·2·3과 각 장단점)** | ( ) |\n", + "| **요청 사항·기한** | ( ) |\n", + "| **근거 번호** | ( ) |\n", + "\n", + "| **점검 항목** | **확인** |\n", + "| --- | --- |\n", + "| 보정 시간표에 비현실적 과목이 0건이다 | ( ) |\n", + "| 교재 수정 목록을 모두 반영했다(100%) | ( ) |\n", + "| 브리프 첫 줄이 결론(BLUF)이다 | ( ) |\n", + "| 핵심 결심 사항·위험 요소·대안 3종이 A4 1페이지 안에 있다 | ( ) |\n", + "| 수용하지 않은 쟁점을 위험 요소에 표시했다 | ( ) |\n", + "\n", + "**\n", + "\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 10. 러우·이란 전훈 기반 드론 대응 표준 교육과정 설계서 및 교재 패키지 완성\n", + "\n", + "### 📋 미션 배경 및 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "러우·이란 전훈 기반 드론 대응 교육과정 설계서 및 교재 패키지를 완성한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **설계 전 과정을 8개 장과 부록의 표준 교육과정 설계서와 교재 패키지로 묶어 다른 부대가 그대로 활용할 수 있게 한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **미션 1에서 정한 8개 장 골격에 미션 2~9의 산출물을 배치하고 각 주요 주장에 근거 번호(가상 전훈 카드 번호)를 붙여 출처를 100% 표시 수행하고 검증하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 5개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `manual/trn04_standard_operational_manual.md` 형태의 결과물을 완성한다.\n", + "\n", + "### 📑 실습자료 및 작성 양식\n", + "러우·이란 전훈 기반 드론 대응 표준 교육과정 설계서 및 교재 패키지 완성**\n", + "\n", + "[작성 서식 1] 표준 교육과정 설계서 목차(8개 장+부록)\n", + "\n", + "| **장** | **제목** | **포함 내용** | **사용할 산출물** | **완료** |\n", + "| --- | --- | --- | --- | --- |\n", + "| **1장** | 개요 | 목적, 범위, 분석 개요, 안전·법규 원칙 | 미션 1 | ( ) |\n", + "| **2장** | 전훈 분석 | 가상 카드 요약, 근거 등급, 불확실성 | 미션 1·2·3 | ( ) |\n", + "| **3장** | 교육 목표 | 교육 목표, 모듈 설계, 근거 연결 | 미션 2·4 | ( ) |\n", + "| **4장** | 과정 설계 | 수준별 과정 개발표, 시간표 | 미션 3·9 | ( ) |\n", + "| **5장** | 교수학습 | 지도안, 교관 가이드라인 | 미션 4 | ( ) |\n", + "| **6장** | 평가 | 루브릭, 성과 지표, 기록 분류 규칙 | 미션 6·7 | ( ) |\n", + "| **7장** | 운영·안전 | 연계 교육 계획, 승인/보류 기준, 스트레스 분석 | 미션 5·7 | ( ) |\n", + "| **8장** | 승인·개선 | 제안서, 비판적 검토, 1페이지 브리프, 개정 주기 | 미션 6·8·9 | ( ) |\n", + "| **부록** | 양식·교재 | 서식 모음, 교재 수정 목록, 교관 지침 | 전 미션 | ( ) |\n", + "\n", + "[작성 서식 2] 근거 번호 표기표 - 설계서의 주요 주장마다 근거 번호(D·R)를 붙인다. 원문 인용과 참고문헌 목록은 쓰지 않는다.\n", + "\n", + "| **번호** | **설계서의 주요 주장(한 문장)** | **근거 번호** | **근거 등급** | **교차검증 여부** |\n", + "| --- | --- | --- | --- | --- |\n", + "| **1** | | | | |\n", + "| **2** | | | | |\n", + "| **3** | | | | |\n", + "| **4** | | | | |\n", + "| **5** | | | | |\n", + "| **6** | | | | |\n", + "| **7** | | | | |\n", + "| **8** | | | | |\n", + "| **9** | | | | |\n", + "| **10** | | | | |\n", + "\n", + "| **지표** | **계산** | **결과** | **목표** |\n", + "| --- | --- | --- | --- |\n", + "| **근거 번호 표기율** | 근거가 붙은 주장 ( ) / 전체 주장 x 100 | ( )% | 100% |\n", + "\n", + "[작성 서식 3] 용어 통일표\n", + "\n", + "| **용어** | **정의(한 문장)** | **쓰지 않는 유사 표현** |\n", + "| --- | --- | --- |\n", + "| **1** | | |\n", + "| **2** | | |\n", + "| **3** | | |\n", + "| **4** | | |\n", + "| **5** | | |\n", + "\n", + "[작성 서식 4] 모의 검토 의견 처리표(타 부대 검토자 역할)\n", + "\n", + "| **번호** | **검토자 역할** | **의견** | **반영/미반영** | **처리 내용·사유** |\n", + "| --- | --- | --- | --- | --- |\n", + "| **1** | | | | |\n", + "| **2** | | | | |\n", + "| **3** | | | | |\n", + "| **4** | | | | |\n", + "| **5** | | | | |\n", + "\n", + "[작성 서식 5] 감수·승인 절차와 개정 관리표\n", + "\n", + "| **단계** | **책임 직책** | **확인 내용** | **일자·서명** |\n", + "| --- | --- | --- | --- |\n", + "| **안전·교전수칙 검토** | | | |\n", + "| **교육 전문 감수** | | | |\n", + "| **지휘관 승인** | | | |\n", + "| **개정 주기(예: 연 1회, 위협 환경 변화 시 수시)** | | | |\n", + "\n", + "| **점검 항목** | **확인** |\n", + "| --- | --- |\n", + "| 8개 장과 부록이 모두 있다 | ( ) |\n", + "| 주요 주장에 근거 번호를 100% 표기했다(원문 인용·참고문헌 목록 없음) | ( ) |\n", + "| 용어 통일표를 작성했다 | ( ) |\n", + "| 모의 검토 의견을 모두 처리했다(미반영은 사유 기재) | ( ) |\n", + "| 안전·교전수칙 검토와 지휘관 승인 절차, 개정 주기를 명시했다 | ( ) |\n", + "\n", + "**4. 평가 채점표**\n", + "\n", + "교관·평가관이 미션별 산출물을 확인해 PASS/FAIL에 표시한다. 필수 미션 7개가 모두 PASS이고 선택 미션 3개 중 2개 이상이 PASS이면 과제 이수로 판정한다.\n", + "\n", + "| **미션** | **구분** | **채점 기준** | **판정** |\n", + "| --- | --- | --- | --- |\n", + "| **M1** | 필수 | 가상 전훈 카드 5건 이상 검토, 문서 골격 8개 장 x 5개 항목(목적·입력·책임자·완료 기준·분량) 100% 정의, 분석 개요서 4항목(목적·범위·방법·한계) 작성, 핵심 의제 중심 목차 재배열을 반영했는가. | PASS / FAIL |\n", + "| **M2** | 필수 | 신뢰도 평가 기준 6단계 완비, 평가 지표 10개 이상 정의, 가상 보고서 5종 중 4건 이상 교관 기준 등급과 일치(1단계 차이 허용), 모듈 설계 근거 연결률 100%, 단일 출처 경고·교차검증 규칙 명시했는가. | PASS / FAIL |\n", + "| **M3** | 필수 | 3개 수준 과정 개발표 100% 작성(안전·법규 한계 포함), 관측 공백 요인 8개 이상 식별, 모든 요인에 영향도·가능성·대응 방안 기재, 판단 한계 문구 3건 이상 작성했는가. | PASS / FAIL |\n", + "| **M4** | 선택 | 연결 판단 기준 12개 이상 항목화, 가중치 합 100% 및 산식 검산 오차 0, 수준별 지도안 3건 작성, 교관 가이드라인에 안전·교전수칙 항목 8개 이상 포함, 후보 3개 점수 계산과 우선순위 근거를 제시했는가. | PASS / FAIL |\n", + "| **M5** | 필수 | 연계 교육 8차시 계획 완비, 승인/보류 분기점 10개 이상 100% 명시, 모든 분기점에 판정·책임 직책 매핑(빈칸 0), 교전수칙·사격 안전·전파 사용·인도주의 준수 조항 포함했는가. | PASS / FAIL |\n", + "| **M6** | 선택 | 기록 분류 규칙 4유형 100% 표준화, 혼동 유발 단어 금지 목록 10개 이상, 연습 문장 8개 중 7개 이상 정확 분류, 제안서 7개 항목과 정량 성과 지표 6개 이상을 완성했는가. | PASS / FAIL |\n", + "| **M7** | 필수 | 평가 영역 5개와 등급 4단계(탁월/우수/보통/미흡) 기준 20칸 정의율 100%, 스트레스 카드 6건 모두 영향·대체안 기재, 안전·교전수칙 저하 대체안 0건, 가상 답안 3건 채점에서 두 평가자 등급 차이 1단계 이내인가. | PASS / FAIL |\n", + "| **M8** | 선택 | 비판적 쟁점 5건 이상 도출, 모든 쟁점에 반박·수정 대안 100% 수립, 근거 기록 연결률 100%, 비난성 표현 0건이며 수정안을 과정안에 반영했는가. | PASS / FAIL |\n", + "| **M9** | 필수 | 핵심 결심 사항·위험 요소·대안 3종이 A4 1페이지 안에 완결, 첫 줄 BLUF 결론 포함, 보정 시간표에 비현실적 과목 0건, 교재 수정 항목 반영률 100%인가. | PASS / FAIL |\n", + "| **M10** | 필수 | 표준 지침서 8개 장 및 부록 100% 완성, 주요 주장 근거 번호 표기율 100%(원문 인용·참고문헌 목록 없음), 모의 검토 의견 처리율 100%, 안전·교전수칙 검토와 승인 절차·개정 주기를 명시했는가. | PASS / FAIL |\n", + "\n", + "| **구분** | **기재** |\n", + "| --- | --- |\n", + "| **종합 판정** | 필수 ( )/7 PASS, 선택 ( )/3 PASS -> 이수 / 재평가 |\n", + "| **평가관 의견** | ( ) |\n", + "| **평가관 서명 / 일자** | ( ) / ( ) |\n", + "\n" + ] + } + ], + "metadata": { + "colab": { + "provenance": [] + }, + "kernelspec": { + "display_name": "Python 3", + "name": "python3" + }, + "language_info": { + "name": "python" + } + }, + "nbformat": 4, + "nbformat_minor": 0 +} \ No newline at end of file diff --git a/uploads/TRN06/TRN06_workbook.ipynb b/uploads/TRN06/TRN06_workbook.ipynb new file mode 100644 index 0000000..0f7ff58 --- /dev/null +++ b/uploads/TRN06/TRN06_workbook.ipynb @@ -0,0 +1,964 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# 📋 [TRN06] AAR 관찰·평가 루브릭과 피드백 가이드\n", + "### 실습 워크북 & SOP 가이드 (Google Colab / Jupyter)\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/TRN06/TRN06_workbook.ipynb)\n", + "\n", + "---\n", + "\n", + "## 📌 1. 프로젝트 개요 및 배경\n", + "\n", + "| 항목 | 내용 |\n", + "| :--- | :--- |\n", + "| **과정 분류** | 교육·훈련 (Level 2) |\n", + "| **학습 소요 시간** | 10개 미션 (5일 / 총 27.0시간) |\n", + "| **실습 유형** | 작전절차(SOP), 체크리스트, 모의훈련, AAR 사후검토 실습 |\n", + "\n", + "### 📖 프로젝트 목표\n", + "러시아-우크라이나 전쟁과 미국-이란 간 긴장 국면에서는 소부대가 짧은 시간에 전술을 익히고 실전에서 얻은 교훈을 곧바로 훈련에 되먹임해야 했고, 그 중심에 AAR(사후검토)이 있었다. 그러나 관찰관마다 평가 기준이 다르고 인상에 따라 점수가 갈리며 피드백이 '잘했다, 부족하다'에 그치면 훈련 효과는 흩어진다. 본 프로젝트에서는 교육용 가상 소부대 훈련과 가상 훈련 로그를 사용해 관찰관(O/C)의 평가 체계 분석, 전술 행동·의사결정·팀워크·보고 절차를 포괄하는 4단계 평가 루브릭과 관찰 근거 6단계 설계, 편향을 줄이는 관찰 가이드라인, 로그 기반 1차 평가지 작성, 사실에 기반한 피드백 대화 스크립트와 코칭 가이드, 평가자 간 점수 편차 스트레스 테스트, 관찰관 주관 개입 사례 분석과 척도 보정, 모의 AAR 재실행과 코칭 효과 검증을 차례로 수행한다. 최종 목표는 근거·책임 직책·기록 요건이 분명한 AAR 관찰·평가 루브릭 및 피드백 표준 가이드북을 문서로 완성하는 것이다.\n", + "\n", + "> **⚠️ [교육용 가상 자료 원칙]**\n", + "> - 본 워크북의 모든 자료는 안전 규정에 따라 **교육용 가상 자료**로 구성되어 있습니다.\n", + "> - 실제 군사 기밀이나 부대 세부 인적·작전 정보는 포함되지 않습니다.\n", + "\n", + "---\n", + "\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 01. 소부대 전술 훈련 AAR 관찰관(Observer Controller)의 평가 체계 분석\n", + "\n", + "### 📋 미션 배경 및 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "소부대 전술 훈련 AAR 관찰관의 평가 체계 및 목표를 분석한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **소부대 전술 훈련에서 AAR 관찰관이 무엇을 어떤 목표로 평가하는지 평가 체계를 분석하고, 가이드북 전체 문서 골격을 정의한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **실습자료의 가상 AAR 평가 사례 5건을 읽고 각 사례에서 관찰관이 무엇을 평가했는지, 평가 근거가 기록되었는지, 피드백이 어떤 형태로 전달되었는지를 비교표로 정리하고 규격화하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 5개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `docs/trn06_doc_framework.md 및 분석 개요서` 형태의 결과물을 완성한다.\n", + "\n", + "### 📑 실습자료 및 작성 양식\n", + "소부대 전술 훈련 AAR 관찰관(Observer Controller)의 평가 체계 분석**\n", + "\n", + "가. 제공 자료 - 가상 AAR 평가 사례 5건 [교육용가상자료]\n", + "\n", + "| **사례** | **관찰관 평가 요약** | **평가 근거 기록** | **피드백 방식** | **눈에 띄는 문제** |\n", + "| --- | --- | --- | --- | --- |\n", + "| **C1** | '1소대 지휘 양호, 4점/5점' | 없음 | 구두 한 줄 | 기준 불명, 4점의 근거 없음 |\n", + "| **C2** | '2소대 보고 지연, 팀워크 부족' | 시각 1건 | 구두, 소대장 앞에서 질책 | '부족'의 기준 없음, 공개 질책 |\n", + "| **C3** | '3소대 분위기 좋음' | 인상 | 서면 평가지만 전달 | 행동 근거 없음, 피드백 대화 없음 |\n", + "| **C4** | 같은 훈련을 본 O/C 1은 '우수', O/C 2는 '보통' | 각자 메모 | 각자 따로 피드백 | 관찰 위치와 기준이 달라 일관성 없음 |\n", + "| **C5** | '지난번에도 늦더니 이번에도 늦음' | 과거 인상 | 구두 | 최근·과거 인상이 평가에 개입 |\n", + "\n", + "나. 작성 서식 1 - 관찰관 평가 체계 비교표\n", + "\n", + "| **평가 요소** | **사례에서 확인된 방식** | **문제점** | **보완 방법** |\n", + "| --- | --- | --- | --- |\n", + "| **평가 대상(무엇을 보는가)** | | | |\n", + "| **평가 기준(척도)** | | | |\n", + "| **근거 기록** | | | |\n", + "| **피드백 전달 방식** | | | |\n", + "| **기록 보관·후속 조치** | | | |\n", + "\n", + "나. 작성 서식 2 - 가이드북 목차 골격(지휘관 결심 의제 순으로 배열)\n", + "\n", + "| **순서** | **장 제목** | **담을 핵심 내용** | **지휘관이 이 장에서 얻는 판단 정보** |\n", + "| --- | --- | --- | --- |\n", + "| **제1장** | | | |\n", + "| **제2장** | | | |\n", + "| **제3장** | | | |\n", + "| **제4장** | | | |\n", + "| **제5장** | | | |\n", + "| **제6장** | | | |\n", + "| **제7장** | | | |\n", + "| **제8장** | | | |\n", + "\n", + "다. 점검표\n", + "\n", + "| **점검 항목** | **확인** |\n", + "| --- | --- |\n", + "| 가상 평가 사례 5건을 모두 검토하고 문제점을 표시했다 | ( ) |\n", + "| 평가 체계 5개 요소 모두에 문제점과 보완 방법을 적었다 | ( ) |\n", + "| C5처럼 과거 인상이 개입한 사례를 찾아 표시했다 | ( ) |\n", + "| 가이드북 목차 8개 장의 제목과 핵심 내용을 정의했다 | ( ) |\n", + "| 목차를 지휘관이 먼저 알아야 할 의제 순으로 배열하고 이유를 한 줄로 적었다 | ( ) |\n", + "\n", + "**\n", + "\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 02. 전술 행동, 의사결정, 팀워크, 보고 절차를 포괄하는 4단계 평가 루브릭 설계\n", + "\n", + "### 📋 미션 배경 및 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "전술 행동, 의사결정, 팀워크, 보고 절차를 포괄하는 4단계 평가 루브릭을 설계한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **전술 행동, 의사결정, 팀워크, 보고 절차를 포괄하는 4단계 평가 루브릭을 설계하고, 관찰 근거의 확인 정도를 6단계로 구분한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **평가 영역 4개(전술 행동, 의사결정, 팀워크, 보고 절차)마다 미흡·보통·우수·탁월 4단계의 판정 기준을 관찰 가능한 행동으로 쓴다 과정을 진행하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 5개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `framework/trn06_source_reliability_matrix.md` 형태의 결과물을 완성한다.\n", + "\n", + "### 📑 실습자료 및 작성 양식\n", + "전술 행동, 의사결정, 팀워크, 보고 절차를 포괄하는 4단계 평가 루브릭 설계**\n", + "\n", + "가. 제공 자료 1 - 가상 관찰 기록 5종 [교육용가상자료]\n", + "\n", + "| **기록** | **관찰 내용** | **근거** | **비고** |\n", + "| --- | --- | --- | --- |\n", + "| **R1** | '10:12 2소대장이 접촉 발생 후 40초 만에 분대장 3명에게 지시를 전달했다' | 관찰관 직접 관찰 + 훈련 로그 시각 대조 | 시각·행동 명시 |\n", + "| **R2** | '10:20 3소대 통신 보고가 1분 늦었다고 한다' | 다른 관찰관 전언 | 전달 |\n", + "| **R3** | '1소대는 협동이 잘 되었다' | 관찰관 인상 | 행동 없음 |\n", + "| **R4** | '11:05 부상자 모의 처리 시 분대원 2명이 역할을 서로 확인하고 이동했다' | 관찰관 직접 관찰, 동영상 기록 대조 | 행동 명시 |\n", + "| **R5** | '보고 절차 미흡' | 근거 없음 | 결론만 기재 |\n", + "\n", + "가. 제공 자료 2 - 관찰 근거 6단계 틀. 단계 이름은 고정하고 판정 기준을 조별로 작성한다.\n", + "\n", + "| **단계** | **이름** | **판정 기준(조별 작성)** |\n", + "| --- | --- | --- |\n", + "| **E1** | 직접 관찰·기록 대조 | |\n", + "| **E2** | 직접 관찰 | |\n", + "| **E3** | 복수 관찰관 일치 | |\n", + "| **E4** | 단일 전언 | |\n", + "| **E5** | 추정·해석 | |\n", + "| **E6** | 근거 없는 인상 | |\n", + "\n", + "나. 작성 서식 1 - 4영역 x 4단계 평가 루브릭(관찰 가능한 행동으로 작성)\n", + "\n", + "| **평가 영역** | **미흡** | **보통** | **우수** | **탁월** |\n", + "| --- | --- | --- | --- | --- |\n", + "| **전술 행동** | | | | |\n", + "| **의사결정** | | | | |\n", + "| **팀워크** | | | | |\n", + "| **보고 절차** | | | | |\n", + "\n", + "나. 작성 서식 2 - 평가 지표(10개 이상, 예시 2개 포함)\n", + "\n", + "| **번호** | **평가 지표** | **측정 방법(관찰 가능한 기준)** | **기록 책임자(가상 직책)** |\n", + "| --- | --- | --- | --- |\n", + "| **1** | 접촉 발생부터 지시 전달까지 걸린 시간 | 훈련 로그 시각과 관찰관 시각 기록 대조(초 단위) | 관찰관 |\n", + "| **2** | 보고 항목 완비율 | 보고 서식 항목 수 대비 빠짐없이 말한 항목 수 | 기록자 |\n", + "| **3** | | | |\n", + "| **4** | | | |\n", + "| **5** | | | |\n", + "| **6** | | | |\n", + "| **7** | | | |\n", + "| **8** | | | |\n", + "| **9** | | | |\n", + "| **10** | | | |\n", + "| **11** | | | |\n", + "| **12** | | | |\n", + "\n", + "나. 작성 서식 3 - 등급 부여 일관성 시험(조원이 각자 근거 단계 E1~E6을 매기고 비교)\n", + "\n", + "| **기록** | **조원 1** | **조원 2** | **조원 3** | **조원 4** | **일치 여부** | **불일치 원인** |\n", + "| --- | --- | --- | --- | --- | --- | --- |\n", + "| **R1** | | | | | | |\n", + "| **R2** | | | | | | |\n", + "| **R3** | | | | | | |\n", + "| **R4** | | | | | | |\n", + "| **R5** | | | | | | |\n", + "\n", + "나. 작성 서식 4 - 교차 확인 규칙: 전언만 있는 기록은 E ( ) 이하로 보고 점수 확정은 서로 다른 관찰관 ( )명 이상의 일치 또는 ( ) 대조가 있을 때만 검토한다. 근거 단계가 E5 이하인 기록은 점수에 ( ).\n", + "\n", + "다. 점검표\n", + "\n", + "| **점검 항목** | **확인** |\n", + "| --- | --- |\n", + "| 4영역 x 4단계 루브릭을 모두 관찰 가능한 행동으로 작성했다 | ( ) |\n", + "| E1~E6 판정 기준을 모두 작성했다 | ( ) |\n", + "| 평가 지표를 10개 이상 정의했다 | ( ) |\n", + "| 가상 관찰 기록 5종에 대한 조원 등급이 4건 이상 일치했다(불일치 건은 원인과 수정 내용을 적었다) | ( ) |\n", + "| 단일 관찰관 의존 경고와 교차 확인 규칙을 명시했고 편향 방지 문구를 넣었다 | ( ) |\n", + "\n", + "**\n", + "\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 03. 주관적 편향을 배제하고 사실 기반 피드백을 유도하는 관찰 가이드라인 작성\n", + "\n", + "### 📋 미션 배경 및 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "주관적 편향을 배제하고 사실 기반 피드백을 유도하는 관찰 가이드라인을 작성한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **주관적 편향을 줄이고 사실에 기반한 피드백이 나오도록 관찰관이 따를 관찰 가이드라인을 작성하고, 관찰 공백과 불확실성을 정리한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **관찰 전 준비, 관찰 중 기록 요령(시각, 행동, 말, 결과), 관찰 후 정리 순서를 담은 관찰 가이드라인 설계하고 작성하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 5개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `analysis/trn06_uncertainty_assessment.md` 형태의 결과물을 완성한다.\n", + "\n", + "### 📑 실습자료 및 작성 양식\n", + "주관적 편향을 배제하고 사실 기반 피드백을 유도하는 관찰 가이드라인 작성**\n", + "\n", + "가. 제공 자료 1 - 어긋난 가상 관찰 기록 쌍 4건 [교육용가상자료]\n", + "\n", + "| **쌍** | **관찰관 A 기록** | **관찰관 B 기록** | **어긋난 지점** |\n", + "| --- | --- | --- | --- |\n", + "| **K1** | 10:12 지시 전달 40초 | 10:12 접촉, 10:14 지시 전달(약 2분) | 시각 기준(접촉 선언 vs 인지) |\n", + "| **K2** | 2소대 의사결정 '우수' | 2소대 의사결정 '보통' | 척도 해석 |\n", + "| **K3** | 3소대 보고 '완비' | 3소대 보고 '2개 항목 누락'(측면에서 관찰) | 관찰 위치(시야) |\n", + "| **K4** | 1소대장 '침착' | 1소대장 '지시가 불분명' | 행동 해석(인상 vs 행동) |\n", + "\n", + "가. 제공 자료 2 - 관찰 공백·편향 요인 예시 3개(나머지 5개 이상은 조별로 찾는다)\n", + "\n", + "| **요인** | **발생 상황** | **보완 방법(예)** |\n", + "| --- | --- | --- |\n", + "| **관찰 위치 시야 제한** | 관찰관이 측면에 있어 일부 분대 행동을 못 봄 | 관찰 위치 교대, 기록에 못 본 구간을 표시 |\n", + "| **후광 효과** | 한 영역이 우수하면 다른 영역도 높게 평가 | 영역별로 따로 근거를 적고 영역 순서를 바꿔 채점 |\n", + "| **최근 효과** | 훈련 후반 행동만 기억에 남음 | 시각별 메모를 훈련 중 작성 |\n", + "\n", + "나. 작성 서식 1 - 관찰 가이드라인(빈칸 작성)\n", + "\n", + "| **단계** | **해야 할 일** | **기록 방식** | **금지 사항** |\n", + "| --- | --- | --- | --- |\n", + "| **관찰 전** | | | |\n", + "| **관찰 중** | | | |\n", + "| **관찰 후** | | | |\n", + "\n", + "나. 작성 서식 2 - 어긋난 기록 분류와 원인 후보\n", + "\n", + "| **쌍** | **어긋난 유형(시각·척도·시야·해석)** | **원인 후보(기준 차이, 관찰 위치, 인상 개입)** | **확인 방법** | **결론(점수 확정·보류·추가 관찰)** |\n", + "| --- | --- | --- | --- | --- |\n", + "| **K1** | | | | |\n", + "| **K2** | | | | |\n", + "| **K3** | | | | |\n", + "| **K4** | | | | |\n", + "\n", + "나. 작성 서식 3 - 관찰 공백·편향 영향도 매트릭스(8개 이상)\n", + "\n", + "| **번호** | **공백·편향 요인** | **영향도(높음·중간·낮음)** | **보완 방법** | **판단의 한계** |\n", + "| --- | --- | --- | --- | --- |\n", + "| **1** | 관찰 위치 시야 제한 | | | |\n", + "| **2** | 후광 효과 | | | |\n", + "| **3** | 최근 효과 | | | |\n", + "| **4** | | | | |\n", + "| **5** | | | | |\n", + "| **6** | | | | |\n", + "| **7** | | | | |\n", + "| **8** | | | | |\n", + "| **9** | | | | |\n", + "| **10** | | | | |\n", + "\n", + "나. 작성 서식 4 - 표현 규칙: 허용 표현 '(시각) (대상)이 (행동)했다', 금지 표현 '( )', 판단의 한계 문장 '이 평가는 ( )를 관찰하지 못했다'\n", + "\n", + "다. 점검표\n", + "\n", + "| **점검 항목** | **확인** |\n", + "| --- | --- |\n", + "| 관찰 가이드라인에 관찰 전·중·후 단계가 모두 있다 | ( ) |\n", + "| 어긋난 기록 4건의 유형과 원인 후보를 2개 이상 적었다 | ( ) |\n", + "| 공백·편향 요인을 8개 이상 찾고 영향도를 매겼다 | ( ) |\n", + "| 요인마다 보완 방법을 적었다 | ( ) |\n", + "| 인상이 아닌 행동으로 쓰는 표현 규칙과 판단의 한계를 명시했다 | ( ) |\n", + "\n", + "**\n", + "\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 04. 가상 훈련 영상/로그 기반 AAR 루브릭 적용 및 1차 평가지 작성 실습\n", + "\n", + "### 📋 미션 배경 및 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "가상 훈련 영상/로그 기반 AAR 루브릭 적용 및 1차 평가지 작성을 실습한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **가상 훈련 영상 기록과 로그를 바탕으로 AAR 루브릭을 적용해 1차 평가지를 작성하고, 점수를 확정해도 되는 근거 기준을 만든다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **실습자료의 가상 훈련 로그 14줄을 읽고 평가 영역별로 관찰 행동을 찾아 루브릭 등급을 매기는 1차 평가지 설계하고 작성하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 5개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `matrix/trn06_attribution_matrix.xlsx(md)` 형태의 결과물을 완성한다.\n", + "\n", + "### 📑 실습자료 및 작성 양식\n", + "가상 훈련 영상/로그 기반 AAR 루브릭 적용 및 1차 평가지 작성 실습**\n", + "\n", + "가. 제공 자료 1 - 가상 훈련 로그 14줄 [교육용가상자료] (2소대 중심, 가상 훈련 D일)\n", + "\n", + "| **번호** | **시각** | **로그 내용** |\n", + "| --- | --- | --- |\n", + "| **1** | 09:05 | 2소대 집결지 도착, 인원 점검 완료(소대장 구두 확인) |\n", + "| **2** | 09:20 | 이동 중 분대 간격이 벌어졌다는 관찰관 메모(시각 없음) |\n", + "| **3** | 09:40 | 방어 진지 점령 완료, 구역 분담 지시를 분대장 3명에게 전달(관찰·로그 일치) |\n", + "| **4** | 10:12 | 모의 접촉 발생(통제관 신호) |\n", + "| **5** | 10:12:40 | 소대장이 분대장에게 사격 구역 지시 전달(관찰·로그 일치) |\n", + "| **6** | 10:15 | 통신병 보고 1분 지연(다른 관찰관 전언) |\n", + "| **7** | 10:18 | 분대 1이 측면 이동, 분대 2는 제자리 대기(소대장 의도 불분명이라는 메모) |\n", + "| **8** | 10:30 | 모의 접촉 종료 선언 |\n", + "| **9** | 10:45 | 모의 부상자 1명 발생, 분대원 2명이 역할 확인 후 이동(관찰·영상 일치) |\n", + "| **10** | 10:50 | 부상자 보고서의 위치 항목 누락(보고 서식 대조) |\n", + "| **11** | 11:10 | 소대장이 보고 후 분대장 의견을 묻고 지시 수정(관찰 일치) |\n", + "| **12** | 11:20 | 분대장 1명이 안전 수칙 위반 의심 행동(관찰관 1명만 목격) |\n", + "| **13** | 11:35 | 교전수칙 시나리오 카드에 따라 '민간인 접근' 모의 대응: 사격 중지 선언(관찰·로그 일치) |\n", + "| **14** | 11:50 | 철수 후 인원·장비 점검 완료(소대장 구두 확인) |\n", + "\n", + "가. 제공 자료 2 - 근거 항목 예시 4개(나머지는 조별로 12개 이상으로 늘린다)\n", + "\n", + "| **근거 항목** | **방향(지지·반박)** | **가중치 예시** |\n", + "| --- | --- | --- |\n", + "| **관찰관이 직접 관찰하고 시각을 적었음** | 지지 | +3 |\n", + "| **훈련 로그 또는 영상으로 대조됨** | 지지 | +3 |\n", + "| **전언이며 시각이 없음** | 반박 | -3 |\n", + "| **관찰관 1명만 목격했고 반대 기록이 있음** | 반박 | -4 |\n", + "\n", + "나. 작성 서식 1 - 1차 평가지(2소대)\n", + "\n", + "| **평가 영역** | **관찰된 행동(로그 번호)** | **루브릭 등급** | **근거 단계(E1~E6)** | **점수 확정 가능 여부** |\n", + "| --- | --- | --- | --- | --- |\n", + "| **전술 행동** | | | | |\n", + "| **의사결정** | | | | |\n", + "| **팀워크** | | | | |\n", + "| **보고 절차** | | | | |\n", + "\n", + "나. 작성 서식 2 - 점수 확정 근거 항목 목록(12개 이상)\n", + "\n", + "| **번호** | **근거 항목** | **방향(지지·반박·확인 불가)** | **가중치** | **확인 방법** |\n", + "| --- | --- | --- | --- | --- |\n", + "| **1** | | | | |\n", + "| **2** | | | | |\n", + "| **3** | | | | |\n", + "| **4** | | | | |\n", + "| **5** | | | | |\n", + "| **6** | | | | |\n", + "| **7** | | | | |\n", + "| **8** | | | | |\n", + "| **9** | | | | |\n", + "| **10** | | | | |\n", + "| **11** | | | | |\n", + "| **12** | | | | |\n", + "\n", + "나. 작성 서식 3 - 산식과 규칙: 종합 점수 = (지지 근거 가중치 합) - (반박 근거 가중치 합 절댓값) 이다. 규칙 1 점수 ( )점 이상이면 점수 확정 검토, 규칙 2 반박 근거가 하나라도 있으면 ( ), 규칙 3 확인 불가 항목이 ( )개 이상이면 ( ), 규칙 4 교전수칙·안전 수칙 위반 의심은 점수와 관계없이 ( ).\n", + "\n", + "나. 작성 서식 4 - 가상 사례 3종 적용(S1 로그 5번 지시 전달, S2 로그 6번 통신 지연, S3 로그 12번 안전 수칙 위반 의심)과 레드팀 반박\n", + "\n", + "| **사례** | **지지 근거 합** | **반박 근거 합** | **종합 점수** | **판단(점수 확정·보류·추가 관찰)** | **레드팀 반박과 대응** |\n", + "| --- | --- | --- | --- | --- | --- |\n", + "| **S1** | | | | | |\n", + "| **S2** | | | | | |\n", + "| **S3** | | | | | |\n", + "\n", + "다. 점검표\n", + "\n", + "| **점검 항목** | **확인** |\n", + "| --- | --- |\n", + "| 평가 영역 4개 모두에 관찰 행동과 로그 번호를 적었다 | ( ) |\n", + "| 근거를 12개 이상 항목화하고 방향과 가중치를 적었다 | ( ) |\n", + "| 산식과 규칙 1~4의 빈칸을 모두 채웠다 | ( ) |\n", + "| 사례 S1~S3의 점수와 판단을 계산해 기록했다 | ( ) |\n", + "| 전언·시각 없는 메모를 근거 단계가 낮은 것으로 처리했고 안전 수칙 위반 의심을 지휘계통 보고로 연결했다 | ( ) |\n", + "\n", + "**\n", + "\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 05. 훈련 부대원 대상 강점/약점 피드백 대화 스크립트 및 코칭 가이드 개발\n", + "\n", + "### 📋 미션 배경 및 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "훈련 부대원 대상 강점/약점 피드백 코칭 가이드를 개발한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **훈련 부대원에게 강점과 약점을 전달하는 피드백 대화 스크립트와 코칭 가이드를 개발하고, 점수 확정과 피드백 전달의 분기점·책임 직책을 정한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **상황(관찰한 행동), 영향(그 결과), 질문(본인의 생각), 다음 행동(구체적 개선) 순의 피드백 대화 스크립트를 만들고 강점 피드백과 약점 피드백 예시를 각각 3개 이상 쓴다 과정을 진행하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 5개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `sop/trn06_approval_thresholds_sop.md` 형태의 결과물을 완성한다.\n", + "\n", + "### 📑 실습자료 및 작성 양식\n", + "훈련 부대원 대상 강점/약점 피드백 대화 스크립트 및 코칭 가이드 개발**\n", + "\n", + "가. 제공 자료 1 - 피드백 대화 스크립트 틀과 약점 피드백 예시 1개\n", + "\n", + "| **순서** | **구성** | **예시 문장(약점 피드백)** | **작성할 문장(조별)** |\n", + "| --- | --- | --- | --- |\n", + "| **1** | 상황(관찰한 행동) | '10시 12분 접촉 직후 사격 구역 지시가 40초 걸렸습니다.' | |\n", + "| **2** | 영향(결과) | '그 사이 분대 2는 제자리에서 대기했습니다.' | |\n", + "| **3** | 질문(본인의 생각) | '그때 어떤 점이 지시를 어렵게 했다고 생각합니까?' | |\n", + "| **4** | 다음 행동 | '다음에는 구역 분담을 접촉 전에 정해 두는 방법을 시도해 봅시다.' | |\n", + "\n", + "가. 제공 자료 2 - 경계선 사례 5종 [교육용가상자료]\n", + "\n", + "| **묶음** | **내용** | **애매한 이유** |\n", + "| --- | --- | --- |\n", + "| **B1** | 지시 지연 관찰, 근거 E2, 로그 대조 없음 | 근거는 있으나 대조 부족 |\n", + "| **B2** | O/C 1 '우수', O/C 2 '보통', 같은 행동을 다르게 해석 | 관찰관 간 불일치 |\n", + "| **B3** | 분대장 안전 수칙 위반 의심, 1명만 목격 | 교전수칙·안전 쟁점, 지휘계통 보고 필요 |\n", + "| **B4** | 훈련 부대원이 피드백 중 매우 낙담한 모습을 보임 | 심리 신호, 상담관 연계 검토 |\n", + "| **B5** | 지휘관이 오늘 안에 점수를 확정하라고 요청 | 확인 생략 압박 |\n", + "\n", + "가. 제공 자료 3 - 책임 직책 예시(가상): 관찰관(O/C), 선임 관찰관, 중대장, 법무 검토 담당, 의무대·상담관(연계)\n", + "\n", + "나. 작성 서식 1 - 피드백 대화 스크립트(강점 3개, 약점 3개 이상)\n", + "\n", + "| **번호** | **구분(강점·약점)** | **상황** | **영향** | **질문** | **다음 행동** |\n", + "| --- | --- | --- | --- | --- | --- |\n", + "| **1** | | | | | |\n", + "| **2** | | | | | |\n", + "| **3** | | | | | |\n", + "| **4** | | | | | |\n", + "| **5** | | | | | |\n", + "| **6** | | | | | |\n", + "\n", + "나. 작성 서식 2 - 금지 표현과 대안 표현\n", + "\n", + "| **번호** | **금지 표현(인격 비하·비교·감정)** | **왜 문제인가** | **대안 표현** |\n", + "| --- | --- | --- | --- |\n", + "| **1** | | | |\n", + "| **2** | | | |\n", + "| **3** | | | |\n", + "| **4** | | | |\n", + "\n", + "나. 작성 서식 3 - 의사결정 트리와 분기점·책임 직책 매핑표\n", + "\n", + "| **분기점** | **조건(관찰 가능한 문장)** | **예일 때** | **아니오일 때** | **책임 직책(가상)** |\n", + "| --- | --- | --- | --- | --- |\n", + "| **T1** | 근거 단계가 E3 이상인가 | | | |\n", + "| **T2** | 관찰관 간 불일치가 해소되었는가 | | | |\n", + "| **T3** | 교전수칙·안전 위반 의심이 있는가 | | | |\n", + "| **T4** | 부상·심리적 어려움 신호가 있는가 | | | |\n", + "\n", + "나. 작성 서식 4 - 모의 결심 기록지(B1~B5)\n", + "\n", + "| **묶음** | **판단(피드백 전달·보류 유지·추가 관찰 요청)** | **근거** | **책임 직책** | **조원 합의(예·아니오)** |\n", + "| --- | --- | --- | --- | --- |\n", + "| **B1** | | | | |\n", + "| **B2** | | | | |\n", + "| **B3** | | | | |\n", + "| **B4** | | | | |\n", + "| **B5** | | | | |\n", + "\n", + "나. 작성 서식 5 - 긴급 요청 규칙: 지휘관의 당일 확정 요청에도 줄이지 않는 확인 단계는 ( ), 임시 피드백으로 전달할 내용은 ( ), 최종 확정 기한은 ( )이다. 안전·교전수칙 쟁점과 심리 신호는 일정과 관계없이 ( ).\n", + "\n", + "다. 점검표\n", + "\n", + "| **점검 항목** | **확인** |\n", + "| --- | --- |\n", + "| 피드백 스크립트에 상황·영향·질문·다음 행동이 모두 있고 강점과 약점 예시가 각각 3개 이상이다 | ( ) |\n", + "| 금지 표현 4개와 대안 표현을 적었다 | ( ) |\n", + "| 분기점 4개의 조건과 책임 직책(가상)을 적었다 | ( ) |\n", + "| 경계선 묶음 5종 모두에 판단과 근거를 기록했다 | ( ) |\n", + "| 교전수칙·안전 쟁점은 지휘계통과 법무 검토로, 심리 신호는 의무대·상담관 연계로 넘기며 진단하지 않는다 | ( ) |\n", + "\n", + "**\n", + "\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 06. 지휘관 보고용 부대 훈련 성과 종합 분석표 및 개선 과제 카드 설계\n", + "\n", + "### 📋 미션 배경 및 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "지휘관 보고용 부대 훈련 성과 종합 분석표를 설계한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **지휘관 보고용 부대 훈련 성과 종합 분석표와 개선 과제 카드를 설계한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **영역별 등급, 근거 단계, 강점, 개선 과제, 다음 훈련 목표를 한 쪽에 담는 성과 종합 분석표 설계하고 워크플로우를 구성하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 5개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `templates/trn06_aar_fact_action_template.md` 형태의 결과물을 완성한다.\n", + "\n", + "### 📑 실습자료 및 작성 양식\n", + "지휘관 보고용 부대 훈련 성과 종합 분석표 및 개선 과제 카드 설계**\n", + "\n", + "가. 제공 자료 1 - 혼동 유발 표현 예시(나머지는 조별로 6개 이상으로 늘린다)\n", + "\n", + "| **혼동 유발 표현** | **왜 혼동되는가** | **대체 표현 예** |\n", + "| --- | --- | --- |\n", + "| **잘함** | 무엇을 어떻게 잘했는지 알 수 없음 | '10시 12분 접촉 후 40초 안에 구역 지시를 전달했다(E1)' |\n", + "| **열심히 함** | 태도 평가이며 행동 근거 없음 | '휴식 없이 3회 반복해 진지를 보강했다(E2)' |\n", + "| **분위기가 좋음** | 관찰 가능한 기준 없음 | '분대원이 서로 역할을 확인했다(E2)' |\n", + "\n", + "가. 제공 자료 2 - 가상 평가 메모 3종(관찰 사실·해석·조치·미확인을 구분해 표시한다)\n", + "\n", + "| **메모** | **내용** |\n", + "| --- | --- |\n", + "| **M-1** | 2소대장은 접촉 후 40초 만에 구역 지시를 전달했다. 침착한 지휘관으로 보인다. 다음 훈련에서 사전 분담을 시도하게 한다. 통신 지연 1분은 확인되지 않았다. |\n", + "| **M-2** | 3소대 보고에서 2개 항목이 빠졌다. 훈련이 부족해서일 것이다. 보고 서식을 암기하게 하겠다. 어느 항목인지는 다른 관찰관 확인이 필요하다. |\n", + "| **M-3** | 1소대는 분위기가 좋았다. 소대장이 훌륭하다. 칭찬하고 넘어간다. 분대 간 소통 기록은 없다. |\n", + "\n", + "나. 작성 서식 1 - 부대 훈련 성과 종합 분석표(1쪽)\n", + "\n", + "| **평가 영역** | **등급** | **근거 단계** | **강점(행동 근거)** | **개선 과제** | **다음 훈련 목표** |\n", + "| --- | --- | --- | --- | --- | --- |\n", + "| **전술 행동** | | | | | |\n", + "| **의사결정** | | | | | |\n", + "| **팀워크** | | | | | |\n", + "| **보고 절차** | | | | | |\n", + "\n", + "나. 작성 서식 2 - 개선 과제 카드(3장)\n", + "\n", + "| **카드** | **과제** | **근거 행동** | **책임 직책(가상)** | **기한** | **확인 방법** |\n", + "| --- | --- | --- | --- | --- | --- |\n", + "| **K-1** | | | | | |\n", + "| **K-2** | | | | | |\n", + "| **K-3** | | | | | |\n", + "\n", + "나. 작성 서식 3 - 기록 분류 규칙(빈칸을 채운다): 관찰 사실은 ( ), 해석은 ( ), 조치는 ( ), 미확인은 ( ) 표시로 구분한다. 문장 하나에는 한 가지 분류만 담는다.\n", + "\n", + "나. 작성 서식 4 - 혼동 유발 표현 금지 목록(6개 이상)과 메모 옮겨 적기(M-1~M-3)\n", + "\n", + "| **번호** | **금지 표현** | **대체 표현** |\n", + "| --- | --- | --- |\n", + "| **1** | | |\n", + "| **2** | | |\n", + "| **3** | | |\n", + "| **4** | | |\n", + "| **5** | | |\n", + "| **6** | | |\n", + "\n", + "| **메모** | **관찰 사실** | **해석** | **조치** | **미확인** |\n", + "| --- | --- | --- | --- | --- |\n", + "| **M-1** | | | | |\n", + "| **M-2** | | | | |\n", + "| **M-3** | | | | |\n", + "\n", + "다. 점검표\n", + "\n", + "| **점검 항목** | **확인** |\n", + "| --- | --- |\n", + "| 성과 종합 분석표에 등급, 근거 단계, 강점, 개선 과제가 모두 있다 | ( ) |\n", + "| 개선 과제 카드마다 책임 직책, 기한, 확인 방법이 있다 | ( ) |\n", + "| 혼동 유발 표현 금지 목록을 6개 이상 만들고 대체 표현을 적었다 | ( ) |\n", + "| 메모 3종에서 관찰 사실과 해석을 혼동한 문장이 0건이다 | ( ) |\n", + "| 분류 규칙 4개 빈칸을 모두 채웠다 | ( ) |\n", + "\n", + "**\n", + "\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 07. 평가자 간 점수 편차 발생 상황 모의 신뢰도 스트레스 테스트\n", + "\n", + "### 📋 미션 배경 및 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "평가자 간 점수 편차 발생 상황 모의 신뢰도 스트레스 테스트를 실시한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **평가자마다 점수가 달라지는 상황을 모의로 만들어 루브릭의 신뢰도를 스트레스 테스트하고, 5개 영역 4등급 최종 평가 루브릭을 정의한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **같은 가상 훈련 기록을 평가자 4명이 상의 없이 채점하게 하고, 관대화 경향, 엄격화 경향, 후광 효과, 최근 효과, 중심화 경향을 일으키는 모의 카드 주입하여 처리 지연 및 복원력을 시험하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 5개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `rubric/trn06_evaluation_rubric.md` 형태의 결과물을 완성한다.\n", + "\n", + "### 📑 실습자료 및 작성 양식\n", + "평가자 간 점수 편차 발생 상황 모의 신뢰도 스트레스 테스트**\n", + "\n", + "가. 제공 자료 1 - 가상 훈련 기록 요약 3종(평가자 4명이 상의 없이 채점) [교육용가상자료]\n", + "\n", + "| **기록** | **내용 요약** |\n", + "| --- | --- |\n", + "| **X1** | 2소대: 지시 40초, 보고 서식 항목 1개 누락, 부상자 처리 역할 확인 양호 |\n", + "| **X2** | 3소대: 지시 2분, 보고 완비, 분대 간 소통 일부 끊김 |\n", + "| **X3** | 1소대: 지시 50초, 보고 항목 2개 누락, 안전 수칙 확인 철저 |\n", + "\n", + "가. 제공 자료 2 - 편향 모의 카드 T1~T5(통제관이 주입)\n", + "\n", + "| **카드** | **주입 내용** | **기대되는 편향** |\n", + "| --- | --- | --- |\n", + "| **T1** | '이 소대장은 지난 훈련에서 매우 우수했다'는 정보를 평가자에게 미리 알림 | 후광 효과 |\n", + "| **T2** | 훈련 후반 1분 영상만 다시 보여 줌 | 최근 효과 |\n", + "| **T3** | '가능하면 좋은 점수를 주면 좋겠다'는 분위기 카드 | 관대화 경향 |\n", + "| **T4** | '이번 평가는 엄격하게 하라'는 지시 카드 | 엄격화 경향 |\n", + "| **T5** | 근거가 애매한 기록에 보통만 주도록 유도 | 중심화 경향 |\n", + "\n", + "가. 제공 자료 3 - 5영역 x 4등급 최종 루브릭 틀(전술 행동, 의사결정, 팀워크, 보고 절차, 안전·규정 준수)\n", + "\n", + "| **평가 영역** | **탁월** | **우수** | **보통** | **미흡** |\n", + "| --- | --- | --- | --- | --- |\n", + "| **전술 행동** | | | | |\n", + "| **의사결정** | | | | |\n", + "| **팀워크** | | | | |\n", + "| **보고 절차** | | | | |\n", + "| **안전·규정 준수** | | | | |\n", + "\n", + "나. 작성 서식 1 - 평가자 4명 채점표(기록 X1~X3, 등급은 탁월·우수·보통·미흡)\n", + "\n", + "| **기록·영역** | **평가자 1** | **평가자 2** | **평가자 3** | **평가자 4** | **편차(최대 등급 차)** |\n", + "| --- | --- | --- | --- | --- | --- |\n", + "| **X1 전술 행동** | | | | | |\n", + "| **X1 보고 절차** | | | | | |\n", + "| **X2 전술 행동** | | | | | |\n", + "| **X2 보고 절차** | | | | | |\n", + "| **X3 전술 행동** | | | | | |\n", + "| **X3 보고 절차** | | | | | |\n", + "\n", + "나. 작성 서식 2 - 편향 카드별 영향 기록\n", + "\n", + "| **카드** | **채점 변화** | **편향 유형** | **대응(루브릭 문구 수정 또는 절차 보완)** |\n", + "| --- | --- | --- | --- |\n", + "| **T1** | | | |\n", + "| **T2** | | | |\n", + "| **T3** | | | |\n", + "| **T4** | | | |\n", + "| **T5** | | | |\n", + "\n", + "나. 작성 서식 3 - 일치율과 보정: 일치한 칸 ( ) / 전체 ( ) = ( )%, 편차가 가장 큰 영역은 ( ), 보정한 문구는 '( )'이다.\n", + "\n", + "다. 점검표\n", + "\n", + "| **점검 항목** | **확인** |\n", + "| --- | --- |\n", + "| 5개 영역 x 4단계 기준을 모두 관찰 가능한 행동으로 정의했다 | ( ) |\n", + "| 평가자 4명이 상의 없이 채점하고 편차를 계산했다 | ( ) |\n", + "| 편향 카드 5종의 영향과 대응을 기록했다 | ( ) |\n", + "| 일치율 80% 이상을 확인했거나 미달 원인과 보정 계획을 적었다 | ( ) |\n", + "| 안전·규정 준수 영역에 교전수칙 준수 행동이 포함되어 있다 | ( ) |\n", + "\n", + "**\n", + "\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 08. AAR 회의를 통한 관찰관 주관 개입 사례 분석 및 루브릭 척도 보정\n", + "\n", + "### 📋 미션 배경 및 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "AAR 회의를 통해 관찰관 주관 개입 사례를 분석하고 루브릭 척도를 보정한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **AAR 회의에서 관찰관의 주관이 개입된 사례를 분석해 원인을 규명하고 루브릭 척도를 보정하며, 레드팀 비판으로 가이드의 취약한 전제를 찾는다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **가상 AAR 회의 기록 2건을 읽고 관찰관의 인상이나 친분이 점수나 피드백에 개입된 지점을 표시 수행하고 검증하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 5개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `reports/trn06_redteam_critique_report.md` 형태의 결과물을 완성한다.\n", + "\n", + "### 📑 실습자료 및 작성 양식\n", + "AAR 회의를 통한 관찰관 주관 개입 사례 분석 및 루브릭 척도 보정**\n", + "\n", + "가. 제공 자료 - 가상 AAR 회의 기록 A·B [교육용가상자료]\n", + "\n", + "| **순서** | **AAR 회의 기록 A** | **AAR 회의 기록 B** |\n", + "| --- | --- | --- |\n", + "| **1** | 관찰관 O/C 1: '이 소대장은 믿음직해서 4점' | 관찰관 O/C 2: '그 분대장은 늘 지각했으니 이번에도 좋지 않다' |\n", + "| **2** | 근거 질문: '구체적 행동은?' -> '그냥 전체적으로' | 근거 질문: '이번 훈련에서의 행동은?' -> '기록 없음' |\n", + "| **3** | 다른 소대 같은 행동은 3점 부여 | 피드백 자리에서 분대원 앞에서 지적 |\n", + "| **4** | AAR 토의: 척도에 '믿음직함'이 들어 있음 | AAR 토의: 서식에 '과거 행동' 칸이 따로 없고 관행으로 반영됨 |\n", + "\n", + "나. 작성 서식 1 - 주관 개입 지점과 원인 분류(사람·절차·정보·척도, 개인 비난 표현 금지)\n", + "\n", + "| **번호** | **주관이 개입한 문장·단계** | **원인 분류** | **원인 설명(사실 중심)** | **개선안** |\n", + "| --- | --- | --- | --- | --- |\n", + "| **1** | | | | |\n", + "| **2** | | | | |\n", + "| **3** | | | | |\n", + "| **4** | | | | |\n", + "| **5** | | | | |\n", + "\n", + "나. 작성 서식 2 - 레드팀 쟁점·대응(5건 이상)\n", + "\n", + "| **번호** | **가이드의 취약한 전제** | **레드팀 비판** | **작성 팀 반박 또는 수정안** | **경고 문구** |\n", + "| --- | --- | --- | --- | --- |\n", + "| **1** | | | | |\n", + "| **2** | | | | |\n", + "| **3** | | | | |\n", + "| **4** | | | | |\n", + "| **5** | | | | |\n", + "| **6** | | | | |\n", + "\n", + "나. 작성 서식 3 - 척도 보정(문구 수정 전후)\n", + "\n", + "| **영역** | **수정 전 문구(인상 표현 포함)** | **수정 후 문구(관찰 가능한 행동)** | **수정 이유** |\n", + "| --- | --- | --- | --- |\n", + "| **의사결정** | | | |\n", + "| **팀워크** | | | |\n", + "| **보고 절차** | | | |\n", + "\n", + "나. 작성 서식 4 - 반대 방향 위험 점검\n", + "\n", + "| **위험** | **설명** | **대응** |\n", + "| --- | --- | --- |\n", + "| **지나친 표준화로 현장 판단 위축** | | |\n", + "| **기록 부담 증가** | | |\n", + "| **평가가 점수 맞추기로 변질** | | |\n", + "\n", + "다. 점검표\n", + "\n", + "| **점검 항목** | **확인** |\n", + "| --- | --- |\n", + "| 기록 A·B에서 주관이 개입한 지점을 5건 이상 찾았다 | ( ) |\n", + "| 원인을 사람·절차·정보·척도로 분류하고 개인 비난 표현이 없다 | ( ) |\n", + "| 레드팀 쟁점 5건 이상에 반박 또는 수정안을 100% 적었다 | ( ) |\n", + "| 인상 표현이 들어간 척도 문구를 행동 문구로 고쳤다 | ( ) |\n", + "| 반대 방향 위험 3가지를 점검했다 | ( ) |\n", + "\n", + "**\n", + "\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 09. 피드백을 반영한 모의 AAR 세션 재실행 및 코칭 효과 검증\n", + "\n", + "### 📋 미션 배경 및 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "피드백을 반영한 모의 AAR 세션을 재실행하고 코칭 효과를 검증한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **피드백을 반영해 모의 AAR 세션을 다시 실행하고 코칭이 실제로 수행 개선으로 이어졌는지 검증한 뒤 결과를 1페이지 브리핑으로 요약한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **1차 훈련 기록과 피드백 후 재훈련 기록을 같은 루브릭으로 비교해 영역별 등급 변화와 개선 과제 이행 여부를 가상 재검증 사례 7건으로 정리하고 규격화하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 5개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `briefing/trn06_commander_1page_brief.md(pdf)` 형태의 결과물을 완성한다.\n", + "\n", + "### 📑 실습자료 및 작성 양식\n", + "피드백을 반영한 모의 AAR 세션 재실행 및 코칭 효과 검증**\n", + "\n", + "가. 제공 자료 - 1차 훈련과 재실행(D+3일) 가상 비교 자료 [교육용가상자료]\n", + "\n", + "| **사례** | **내용** | **1차 등급** | **재실행 등급(조별 작성)** |\n", + "| --- | --- | --- | --- |\n", + "| **V1** | 2소대 지시 전달 시간(1차 40초, 재실행 25초) | 보통 | |\n", + "| **V2** | 3소대 보고 항목 누락(1차 2개, 재실행 0개) | 미흡 | |\n", + "| **V3** | 1소대 분대 간 소통(1차 일부 끊김) | 보통 | |\n", + "| **V4** | 개선 과제 이행 여부(카드 K-1) | - | |\n", + "| **V5** | 코칭을 받지 않은 분대의 변화 | 보통 | |\n", + "| **V6** | 안전 수칙 준수 | 우수 | |\n", + "| **V7** | 피드백 후 낙담했던 대상의 참여도 | - | |\n", + "\n", + "나. 작성 서식 1 - 1차·재실행 비교표\n", + "\n", + "| **평가 영역** | **1차 등급** | **재실행 등급** | **변화(향상·유지·하락)** | **코칭과의 관련성(근거)** |\n", + "| --- | --- | --- | --- | --- |\n", + "| **전술 행동** | | | | |\n", + "| **의사결정** | | | | |\n", + "| **팀워크** | | | | |\n", + "| **보고 절차** | | | | |\n", + "| **안전·규정 준수** | | | | |\n", + "\n", + "나. 작성 서식 2 - 코칭 효과 검증표(재검증 7건)\n", + "\n", + "| **사례** | **코칭 내용** | **재실행 결과** | **효과(있음·불확실·없음)** | **다른 요인 가능성** |\n", + "| --- | --- | --- | --- | --- |\n", + "| **V1** | | | | |\n", + "| **V2** | | | | |\n", + "| **V3** | | | | |\n", + "| **V4** | | | | |\n", + "| **V5** | | | | |\n", + "| **V6** | | | | |\n", + "| **V7** | | | | |\n", + "\n", + "나. 작성 서식 3 - 1페이지 브리핑 BLUF 틀\n", + "\n", + "| **결론(첫 문장)** | |\n", + "| --- | --- |\n", + "\n", + "| **핵심 결심 사항** | |\n", + "| --- | --- |\n", + "\n", + "| **위험 요소(신호등 표시: 녹·황·적)** | |\n", + "| --- | --- |\n", + "\n", + "| **대안 3종(장단점 한 줄)** | |\n", + "| --- | --- |\n", + "\n", + "| **근거** | |\n", + "| --- | --- |\n", + "\n", + "나. 작성 서식 4 - 브리핑 리허설 기록\n", + "\n", + "| **항목** | **목표** | **결과** |\n", + "| --- | --- | --- |\n", + "| 발표 시간 | 3분 이내 | |\n", + "| 분량 | A4 1페이지 | |\n", + "| 질의 응답 | 3건 이상 답변 | |\n", + "| 결론 먼저 제시 | 첫 문장 | |\n", + "\n", + "다. 점검표\n", + "\n", + "| **점검 항목** | **확인** |\n", + "| --- | --- |\n", + "| 5개 영역의 1차·재실행 변화를 비교했다 | ( ) |\n", + "| 재검증 7건 모두 코칭 효과와 다른 요인 가능성을 적었다 | ( ) |\n", + "| 코칭을 받지 않은 분대와 비교해 효과를 단정하지 않았다 | ( ) |\n", + "| 브리핑이 결론부터 시작하고 A4 1페이지에 들어간다 | ( ) |\n", + "| 핵심 결심 사항, 위험 요소, 대안 3종이 있고 3분 안에 발표했다 | ( ) |\n", + "\n", + "**\n", + "\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 10. AAR 관찰·평가 루브릭 및 피드백 표준 가이드북 최종 완성\n", + "\n", + "### 📋 미션 배경 및 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "AAR 관찰·평가 루브릭과 피드백 표준 가이드북을 완성한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **미션 1~9의 산출물을 통합해 AAR 관찰·평가 루브릭 및 피드백 표준 가이드북을 완성하고 모의 심의를 거친다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **산출물을 가이드북 8개 장(목적과 적용 범위, 용어, 관찰 가이드라인, 평가 루브릭, 점수 확정과 피드백, 기록·보고, 훈련·평가, 개정 관리)과 부록에 배치하고 빠진 내용을 보완 수행하고 검증하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 5개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `manual/trn06_standard_operational_manual.md` 형태의 결과물을 완성한다.\n", + "\n", + "### 📑 실습자료 및 작성 양식\n", + "AAR 관찰·평가 루브릭 및 피드백 표준 가이드북 최종 완성**\n", + "\n", + "가. 제공 자료 1 - 가이드북 8개 장 구성과 가져올 결과물\n", + "\n", + "| **장** | **제목** | **포함 내용** | **가져올 결과물** |\n", + "| --- | --- | --- | --- |\n", + "| **1** | 목적과 적용 범위 | 목적, 적용 상황, 적용하지 않는 상황, 교전수칙과의 관계 | M1 |\n", + "| **2** | 용어 정의 | 관찰관, 루브릭, 근거 단계, 후광 효과, 최근 효과, 보류 | M1~M3 |\n", + "| **3** | 관찰 가이드라인 | 관찰 전·중·후 절차, 기록 요령, 편향 방지 | M3 |\n", + "| **4** | 평가 루브릭 | 5영역 4단계, 근거 단계 E1~E6, 평가 지표 | M2·M7 |\n", + "| **5** | 점수 확정과 피드백 | 근거 점수 산식, 의사결정 트리, 피드백 스크립트, 코칭 가이드, 책임 직책 | M4·M5 |\n", + "| **6** | 기록·보고 | 1차 평가지, 성과 종합 분석표, 개선 과제 카드, 표현 규칙, 브리핑 | M4·M6·M9 |\n", + "| **7** | 훈련·평가 | 평가자 편차 시험, 주관 개입 AAR, 재실행 검증, 레드팀 검토 | M7·M8·M9 |\n", + "| **8** | 개정 관리 | 개정 주기, 의견 수렴, 책임 직책, 이력 | M8·M9 |\n", + "\n", + "가. 제공 자료 2 - 모의 심의 역할 카드\n", + "\n", + "| **역할** | **검토 질문** |\n", + "| --- | --- |\n", + "| **교리 역할** | 루브릭과 근거 단계가 일관되는가? 훈련 방법이 기준과 연결되는가? |\n", + "| **법무 역할** | '최종 판단은 사람이, 교전수칙·지휘계통에 따라'가 드러나는가? 교전수칙·인도주의 원칙 평가 항목과 보고 경로가 분명한가? |\n", + "| **작전 역할** | 현장 관찰관이 훈련 중 기록할 수 있는 분량인가? 의무대·상담관 연계가 분명한가? |\n", + "\n", + "나. 작성 서식 1 - 근거 표기 점검표(가이드북 조항마다 작성)\n", + "\n", + "| **조항** | **기준 내용 요약** | **근거(미션·가상 자료)** | **표기 여부** |\n", + "| --- | --- | --- | --- |\n", + "| **1-1** | | | |\n", + "| **2-1** | | | |\n", + "| **3-1** | | | |\n", + "| **4-1** | | | |\n", + "| **5-1** | | | |\n", + "| **6-1** | | | |\n", + "| **7-1** | | | |\n", + "| **8-1** | | | |\n", + "\n", + "나. 작성 서식 2 - 모의 심의 지적 사항 반영표\n", + "\n", + "| **번호** | **역할** | **지적 사항** | **반영 내용** | **반영 위치(장·조)** |\n", + "| --- | --- | --- | --- | --- |\n", + "| **1** | | | | |\n", + "| **2** | | | | |\n", + "| **3** | | | | |\n", + "| **4** | | | | |\n", + "| **5** | | | | |\n", + "| **6** | | | | |\n", + "\n", + "나. 작성 서식 3 - 개정 관리: 개정 주기 ( ), 의견 수렴 방법 ( ), 개정 책임 직책(가상) ( ), 개정 이력 기록 위치 ( )\n", + "\n", + "다. 점검표\n", + "\n", + "| **점검 항목** | **확인** |\n", + "| --- | --- |\n", + "| 8개 장과 부록을 모두 완성했다 | ( ) |\n", + "| 모든 기준 조항에 근거(미션·가상 자료)를 표기했다 | ( ) |\n", + "| 교리·법무·작전 역할의 지적 사항을 반영하고 반영 위치를 적었다 | ( ) |\n", + "| '최종 판단은 사람이 하며 실제 교전·작전 판단은 교전수칙·지휘계통에 따른다'는 문구가 1장과 5장에 있다 | ( ) |\n", + "| 피드백은 행동에 한정하고 부상·심리 신호는 의무대·상담관 연계(진단 아님)라는 문구가 5장에 있다 | ( ) |\n", + "| 실제 부대·개인 평가 결과·교리 원문을 쓰지 않았다 | ( ) |\n", + "\n", + "**4. 평가 채점표**\n", + "\n", + "미션별 평가 기준은 시나리오의 평가 항목과 같다. 평가자는 기준 충족 여부를 PASS/FAIL로 표시하고 FAIL이면 사유를 비고에 적는다.\n", + "\n", + "| **미션** | **구분** | **채점 기준** | **판정** | **비고** |\n", + "| --- | --- | --- | --- | --- |\n", + "| **M1** | 필수 | 평가 체계 분석·문서 골격: 가상 평가 사례 5건 이상을 검토해 평가 대상·근거·피드백 방식을 비교했는가, 가이드북 목차(8개 장) 골격을 100% 정의하고 지휘관 결심 의제 순으로 배열했는가. | PASS / FAIL | |\n", + "| **M2** | 필수 | 4단계 루브릭·근거 6단계·평가 지표: 관찰 근거 기준 6단계를 완비하고 평가 지표 10개 이상을 정의했는가, 가상 관찰 기록 5종에 대한 조원 간 등급 일치가 4건 이상이고 편향 방지 주의와 교차 확인 규칙을 명시했는가. | PASS / FAIL | |\n", + "| **M3** | 필수 | 관찰 가이드라인·편향 평가: 관찰 공백·편향 요인 8개 이상과 영향도 매트릭스를 도출했는가, 어긋난 기록 4건에 원인 후보와 확인 방법을 적고 판단의 한계와 보완 방법을 명시했는가. | PASS / FAIL | |\n", + "| **M4** | 선택 | 근거 항목·1차 평가지: 점수 확정 근거 12개 이상 항목화와 가중치 산식을 100% 정립했는가, 로그 14줄에 대한 1차 평가지와 사례 3종 적용 결과를 기록하고 레드팀 반박에 대응해 최소 증거 요건을 설정했는가. | PASS / FAIL | |\n", + "| **M5** | 필수 | 피드백 스크립트·분기점·책임 매핑: 분기점 조건 100% 명시와 책임 직책 매핑을 완료했는가, 경계선 사례 5종 모두에 판단과 근거가 기록되고 교전수칙·인도주의 원칙 준수, 의무대·상담관 연계, 사람의 최종 판단을 지켰는가. | PASS / FAIL | |\n", + "| **M6** | 선택 | 기록 분류·성과 분석표: 기록 분류 규칙 100% 표준화, 혼동 유발 표현 금지 가이드 6개 이상을 포함하고 가상 평가 메모 3종에서 사실과 해석의 혼동이 0건인가. | PASS / FAIL | |\n", + "| **M7** | 필수 | 평가자 편차 스트레스·루브릭: 평가 영역 5개와 등급 4단계(탁월·우수·보통·미흡) 기준 정의율 100%인가, 평가자 2명 이상 채점 일치율 80% 이상이고 편차가 큰 영역의 보정 내용을 기록했는가. | PASS / FAIL | |\n", + "| **M8** | 선택 | 주관 개입 원인·척도 보정: 도출된 비판적 쟁점 5건 이상에 대해 반박 및 수정 대안을 100% 수립했는가, 취약한 전제에 경고 문구를 명시하고 척도를 보정했으며 반대 방향 위험을 점검했는가. | PASS / FAIL | |\n", + "| **M9** | 필수 | 재실행 비교·1페이지 브리핑: 핵심 결심 사항, 위험 요소, 대안 3종이 1페이지(A4) 안에 완결되는가, 재검증 7건의 전후 비교와 BLUF 준수(결론 먼저, 3분 이내 발표)를 확인했는가. | PASS / FAIL | |\n", + "| **M10** | 필수 | 표준 가이드북 통합 완성: 가이드북 8개 장과 부록을 100% 완성하고 모든 기준 조항에 근거(미션·가상 자료)를 표기했는가, 모의 심의 지적 사항을 반영하고 '최종 판단은 사람이 하며 실제 교전·작전 판단은 교전수칙·지휘계통에 따른다'는 문구를 명시했는가. | PASS / FAIL | |\n", + "\n", + "| **판정 기준** | 필수 7개 미션(M1·M2·M3·M5·M7·M9·M10)이 모두 PASS여야 최종 PASS로 인정한다. 선택 미션(M4·M6·M8)은 가점·보완 의견에 반영한다. |\n", + "| --- | --- |\n", + "\n", + "| **공통 감점** | 실제 부대명·훈련장·개인 평가 결과·교리 원문·실존 인물 정보를 사용한 경우, 부대 자료를 외부 AI 서비스에 입력한 경우 해당 미션은 FAIL로 처리한다. |\n", + "| --- | --- |\n", + "\n", + "| **주제 안전 기준** | 근거 없는 인상으로 점수를 확정하는 판단이 있거나 피드백에 인격 비하 표현이 있거나 가이드북에 사람의 최종 판단·교전수칙 우선 문구가 없으면 M5·M10은 FAIL로 처리한다. |\n", + "| --- | --- |\n", + "\n", + "| **최종 판정 결과** | ( PASS / FAIL ) 평가자 서명: ( ) |\n", + "| --- | --- |\n", + "\n", + "| 안전 원칙: 본 자료의 루브릭, 근거 단계, 권한 구분은 학습용 가상 기준이다. 최종 판단은 사람이 내리며 실제 교전·작전 판단은 교전수칙과 지휘계통에 따른다. 실제 부대 자료를 입력하거나 부대 내에서 촬영하지 않는다. |\n", + "| --- |\n", + "\n" + ] + } + ], + "metadata": { + "colab": { + "provenance": [] + }, + "kernelspec": { + "display_name": "Python 3", + "name": "python3" + }, + "language_info": { + "name": "python" + } + }, + "nbformat": 4, + "nbformat_minor": 0 +} \ No newline at end of file diff --git a/uploads/TRN08/TRN08_workbook.ipynb b/uploads/TRN08/TRN08_workbook.ipynb new file mode 100644 index 0000000..42cf45f --- /dev/null +++ b/uploads/TRN08/TRN08_workbook.ipynb @@ -0,0 +1,1123 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# 📋 [TRN08] 드론 경보 청취·육안 식별·보고 절차 훈련\n", + "### 실습 워크북 & SOP 가이드 (Google Colab / Jupyter)\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/TRN08/TRN08_workbook.ipynb)\n", + "\n", + "---\n", + "\n", + "## 📌 1. 프로젝트 개요 및 배경\n", + "\n", + "| 항목 | 내용 |\n", + "| :--- | :--- |\n", + "| **과정 분류** | 교육·훈련 (Level 2) |\n", + "| **학습 소요 시간** | 10개 미션 (5일 / 총 27.0시간) |\n", + "| **실습 유형** | 작전절차(SOP), 체크리스트, 모의훈련, AAR 사후검토 실습 |\n", + "\n", + "### 📖 프로젝트 목표\n", + "러시아-우크라이나 전쟁에서는 소형 드론이 소리 없이 다가오는 것이 아니라 가까워질수록 모터 소음이 커지는데도, 병사들이 바람·차량·조류와 구분하지 못하거나 방향과 고도를 틀리게 보고해 경보가 늦어지는 일이 반복되었다. 본 프로젝트에서는 가상 경계진지를 무대로 소형 드론의 소리 특징과 비행 형태별 모습을 정리하고, 거리별 청취·실루엣 식별 훈련 교재와 관측 체크리스트, 상황 주입 시나리오를 만든다. 이어서 청취-육안 포착-구두 보고 1차 연계 실습, 민간 비행체·조류 오인 주입 평가, 무전기 SALUTE 즉각 보고와 인계, 강풍·배경 소음 스트레스 훈련, 사후검토(AAR), 재평가 훈련을 차례로 수행하며 매 단계 시간과 오류를 기록해 숙련도 향상을 수치로 확인한다. 이 과제는 드론 대응의 기초 숙련 훈련이므로 식별과 보고 절차에 집중하고, 교전 판단은 다루지 않는다. 최종 목표는 청취·육안 식별·즉각 보고 훈련 교범과 교관이 바로 쓸 수 있는 훈련 패키지를 완성하는 것이다.\n", + "\n", + "> **⚠️ [교육용 가상 자료 원칙]**\n", + "> - 본 워크북의 모든 자료는 안전 규정에 따라 **교육용 가상 자료**로 구성되어 있습니다.\n", + "> - 실제 군사 기밀이나 부대 세부 인적·작전 정보는 포함되지 않습니다.\n", + "\n", + "---\n", + "\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 01. 소형 드론 모터 소음 특성 및 육안 실루엣 식별 훈련 목표 수립\n", + "\n", + "### 📋 미션 배경 및 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "소형 드론 모터 소음 특성과 비행체 실루엣 육안 식별 훈련 체계를 분석한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **소형 드론의 모터 소리 특징과 비행체 실루엣 식별 훈련 체계를 분석해 역할과 현장 체크 항목을 정의하고 훈련 목표를 수립한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **실습자료의 가상 오청·오인 사례 카드 6건을 읽고 문제 유형(소리 오청, 방향 오판, 실루엣 오인, 보고 누락)으로 분류 수행하고 검증하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 5개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `docs/trn08_baseline_assessment.md 및 훈련 프로파일` 형태의 결과물을 완성한다.\n", + "\n", + "### 📑 실습자료 및 작성 양식\n", + "소형 드론 모터 소음 특성 및 육안 실루엣 식별 훈련 목표 수립**\n", + "\n", + "가. 제공 자료 - 가상 오청·오인 사례 카드 [교육용가상자료]\n", + "\n", + "| **카드** | **가상 시각·기상** | **상황** | **드러난 문제** |\n", + "| --- | --- | --- | --- |\n", + "| **C1** | 06:20, 바람 약함 | 청음 관측자가 '윙' 소리를 들었다고 보고했으나 실제로는 인근 보급 차량의 공회전 소리였다. | 소음원 구분 기준 없음, 경보 낭비 |\n", + "| **C2** | 07:05, 안개 | 소리가 3시 방향에서 난다고 보고했지만 실제 기체는 5시 방향에서 접근했다. 지형에 소리가 반사되었다. | 소리만으로 방향 단정, 육안 확인 없음 |\n", + "| **C3** | 11:30, 맑음 | 육안 관측자가 새 무리를 소형 드론으로 오인해 보고했다. | 실루엣·움직임 식별 기준 없음 |\n", + "| **C4** | 14:10, 강풍 | 강한 바람 때문에 소리가 들리지 않아 드론이 가까이 올 때까지 포착하지 못했다. | 청음 한계 선언 규칙 없음 |\n", + "| **C5** | 16:40, 흐림 | 보고에 고도와 수량이 빠져 지휘자가 되묻는 데 1분 넘게 걸렸다. | 보고 항목 순서 미숙지 |\n", + "| **C6** | 05:50, 새벽 | 근무 교대 때 '소리가 몇 번 들렸다'는 정보가 인수자에게 전달되지 않았다. | 교대 인계 항목 없음 |\n", + "\n", + "나. 작성 서식 1 - 문제 유형 분류표 (소리 오청 / 방향 오판 / 실루엣 오인 / 보고 누락 / 인계 누락)\n", + "\n", + "| **카드** | **문제 유형** | **분류 근거(카드에 적힌 사실)** | **훈련 목표로 바꾼 문장** |\n", + "| --- | --- | --- | --- |\n", + "| **C1** | 소리 오청 (예시) | 차량 공회전 소리를 드론으로 판단 (예시) | 소리 유형을 3가지 이상 구분해 말할 수 있다 (예시) |\n", + "| **C2** | | | |\n", + "| **C3** | | | |\n", + "| **C4** | | | |\n", + "| **C5** | | | |\n", + "| **C6** | | | |\n", + "\n", + "다. 작성 서식 2 - 역할 분담 매트릭스 (수: 수행, 책: 책임, 협: 협조, 통: 통보 / 단계마다 '책'은 1명)\n", + "\n", + "| **단계** | **청음 관측자** | **육안 관측자** | **보고자(무전)** | **지휘자** | **완료 판단 기준** |\n", + "| --- | --- | --- | --- | --- | --- |\n", + "| **① 청취** | 책·수 (예시) | 협 (예시) | 통 (예시) | 통 (예시) | 청취 시각·방향 기록 (예시) |\n", + "| **② 방향 확인** | ( ) | ( ) | ( ) | ( ) | ( ) |\n", + "| **③ 육안 포착** | ( ) | ( ) | ( ) | ( ) | ( ) |\n", + "| **④ 식별** | ( ) | ( ) | ( ) | ( ) | ( ) |\n", + "| **⑤ 보고** | ( ) | ( ) | ( ) | ( ) | ( ) |\n", + "| **⑥ 복창·판단** | ( ) | ( ) | ( ) | ( ) | ( ) |\n", + "| **⑦ 해제·기록** | ( ) | ( ) | ( ) | ( ) | ( ) |\n", + "\n", + "라. 작성 서식 3 - 현장 체크 항목 정의표 (15개 이상)\n", + "\n", + "| **번호** | **단계** | **체크 항목(눈과 귀로 확인 가능한 문장)** | **확인 방법** | **담당** |\n", + "| --- | --- | --- | --- | --- |\n", + "| **1** | 청취 | 청음 관측자가 들은 소리를 '높고 일정함/불규칙함/낮고 둔함' 중 하나로 말한다 (예시) | 평가관 질문 (예시) | 청음 (예시) |\n", + "| **2** | 보고 | 1차 보고에 방위와 고도가 포함되었다 (예시) | 기록표 확인 (예시) | 보고자 (예시) |\n", + "| **3** | | | | |\n", + "| **4** | | | | |\n", + "| **5** | | | | |\n", + "| **6** | | | | |\n", + "| **7** | | | | |\n", + "| **8** | | | | |\n", + "| **9** | | | | |\n", + "| **10** | | | | |\n", + "| **11** | | | | |\n", + "| **12** | | | | |\n", + "| **13** | | | | |\n", + "| **14** | | | | |\n", + "| **15** | | | | |\n", + "| **16** | | | | |\n", + "| **17** | | | | |\n", + "\n", + "마. 작성 서식 4 - 부대 특성별 환경 변수 보강표\n", + "\n", + "| **환경 변수** | **야전 진지** | **기지(주둔지)** | **의무시설** |\n", + "| --- | --- | --- | --- |\n", + "| **주변 소음원(차량·발전기·바람)** | ( ) | ( ) | ( ) |\n", + "| **시야 차폐물** | ( ) | ( ) | ( ) |\n", + "| **청음 위치·관측 위치** | ( ) | ( ) | ( ) |\n", + "| **통신 수단** | ( ) | ( ) | ( ) |\n", + "| **교대·인계 방법** | ( ) | ( ) | ( ) |\n", + "| **특별 고려 사항(환자·민간인)** | ( ) | ( ) | ( ) |\n", + "\n", + "바. AI 제안 검토 기록\n", + "\n", + "| **AI가 제안한 항목** | **채택 여부** | **채택·미채택 이유** |\n", + "| --- | --- | --- |\n", + "| **제안 1** | | |\n", + "| **제안 2** | | |\n", + "| **제안 3** | | |\n", + "| **제안 4** | | |\n", + "\n", + "| **점검 항목** | **확인** |\n", + "| --- | --- |\n", + "| 사례 카드 6건을 문제 유형으로 모두 분류했다 | ( ) |\n", + "| 7단계 모두 역할 분담 매트릭스 빈칸이 없다 | ( ) |\n", + "| 단계마다 '책'이 1명만 지정되었다 | ( ) |\n", + "| 현장 체크 항목이 15개 이상이며 모두 확인 가능한 문장이다 | ( ) |\n", + "| 환경 변수표 3개 부대 유형을 모두 채웠다 | ( ) |\n", + "| AI 제안의 채택 여부와 이유를 기록했다 | ( ) |\n", + "| 실제 부대명·위치·호출부호를 쓰지 않았다 | ( ) |\n", + "\n", + "**\n", + "\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 02. 드론 소음 음향 특성(주파수 대역) 및 비행 형태별 관측 체크리스트 작성\n", + "\n", + "### 📋 미션 배경 및 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "드론 주파수 대역 음향 특성 및 비행 형태별 관측 체크리스트를 작성한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **드론 소리의 상대적 특징과 비행 형태별 관측 포인트를 정리해 청음·육안 관측 체크리스트와 SOP 흐름도를 작성한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **실습자료의 소리 유형 표(높고 일정한 소리, 불규칙하게 변하는 소리, 낮고 둔한 소리 등)를 바탕으로 소리 특징과 가능한 원인, 확인 질문을 적어 청음 체크리스트 작성하여 원인을 분석하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 5개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `sop/trn08_field_checklist.md 및 SOP 흐름도` 형태의 결과물을 완성한다.\n", + "\n", + "### 📑 실습자료 및 작성 양식\n", + "드론 소음 음향 특성(주파수 대역) 및 비행 형태별 관측 체크리스트 작성**\n", + "\n", + "가. 제공 자료 - 필수 안전/보안 조치 8개 [교육용가상자료]\n", + "\n", + "| **번호** | **필수 조치** | **배치할 단계(작성)** |\n", + "| --- | --- | --- |\n", + "| **S1** | 소리를 들으면 대화를 멈추고 조용히 한 뒤 방향을 확인한다(청취 환경 확보). | ( ) |\n", + "| **S2** | 급접근·급강하 신호는 보고보다 은폐 전파를 먼저 한다(긴급 모드). | ( ) |\n", + "| **S3** | 육안 관측 때 태양 방향을 직접 보지 않는다. | ( ) |\n", + "| **S4** | 무전은 짧게 하고 평문으로 진지 위치를 말하지 않는다. | ( ) |\n", + "| **S5** | 단독 관측 중에는 정해진 시각에 지휘자와 상호 확인 교신을 한다. | ( ) |\n", + "| **S6** | 청음 보조 도구나 이어피스를 쓸 때 큰 소리 노출과 주변 소리 차단에 주의한다. | ( ) |\n", + "| **S7** | 야간 이동은 조명 통제 규칙을 따르고 해제 명령 전에는 임의로 복귀하지 않는다. | ( ) |\n", + "| **S8** | 경보 중 개인 휴대전화 사용과 촬영·녹음을 금지한다. | ( ) |\n", + "\n", + "나. 제공 자료 - 소리 유형 표(상대적 표현, 실제 수치 아님) [교육용가상자료]\n", + "\n", + "| **소리 유형** | **들리는 방식** | **가능한 원인** | **확인 질문(작성 보완)** |\n", + "| --- | --- | --- | --- |\n", + "| **높고 일정한 소리** | 윙 하는 소리가 거의 같은 높이로 이어짐 | 소형 드론, 일부 장비 | ( ) |\n", + "| **커졌다 작아지는 소리** | 방향에 따라 크기가 변함 | 접근·이탈하는 드론 또는 차량 | ( ) |\n", + "| **낮고 둔한 소리** | 웅웅거리며 방향이 퍼짐 | 발전기, 차량, 먼 기계 | ( ) |\n", + "| **불규칙·간헐적 소리** | 바람에 끊기며 들림 | 바람, 조류, 먼 소음 | ( ) |\n", + "\n", + "다. 제공 자료 - 비행 형태별 관측 포인트 표\n", + "\n", + "| **비행 형태** | **소리 변화** | **모습·크기 변화** | **방위 변화** | **보고 시 주의(작성)** |\n", + "| --- | --- | --- | --- | --- |\n", + "| **접근** | 점점 커짐 | 점점 커짐 | 거의 일정 | ( ) |\n", + "| **선회** | 커졌다 작아짐 반복 | 방향이 계속 바뀜 | 천천히 이동 | ( ) |\n", + "| **정지비행** | 일정한 소리 지속 | 제자리에서 작게 흔들림 | 거의 없음 | ( ) |\n", + "| **이탈** | 점점 작아짐 | 점점 작아짐 | 일정 | ( ) |\n", + "| **급강하** | 급격히 커짐 | 짧은 시간에 커짐 | 일정 | ( ) |\n", + "\n", + "라. 제공 자료 - 모호 표현 바꾸기 예시\n", + "\n", + "| **모호한 표현** | **구체적 기준(예시)** |\n", + "| --- | --- |\n", + "| **소리가 크게 들린다** | '멀다/중간/가깝다' 중 하나와 '점점 커짐/일정/작아짐' 중 하나로 말한다 |\n", + "| **가까운 곳에서 난다** | 근거리 구간(교관이 정한 거리 이내)에서 육안 확인 전이면 '확요'로 보고한다 |\n", + "| **신속히 보고한다** | 청취 후 30초 이내에 방위만이라도 1차 보고한다 |\n", + "| **이상하게 생겼다** | 윤곽(십자형/날개형/둥근형), 움직임(날갯짓/일정 비행/표류)으로 말한다 |\n", + "\n", + "마. 작성 서식 1 - 청음·육안 관측 SOP 표 (신호 - 행동 - 보고)\n", + "\n", + "| **단계** | **신호(무엇을 듣거나 보면)** | **청음 관측자** | **육안 관측자** | **보고자·지휘자** | **안전 조치(S번호)** |\n", + "| --- | --- | --- | --- | --- | --- |\n", + "| **① 청취** | | | | | |\n", + "| **② 방향 확인** | | | | | |\n", + "| **③ 육안 포착** | | | | | |\n", + "| **④ 식별** | | | | | |\n", + "| **⑤ 보고** | | | | | |\n", + "| **⑥ 복창·판단** | | | | | |\n", + "| **⑦ 해제·기록** | | | | | |\n", + "\n", + "바. 작성 서식 2 - SOP 흐름도 틀 (빈칸을 채워 완성하고, 긴급/일반 분기를 표시한다)\n", + "\n", + "| [① 청취: 소리 신호]\n", + " \\|\n", + " < 급접근·급강하·다수 신호인가? >\n", + " \\|예 \\|아니오\n", + " [긴급 모드] [일반 모드]\n", + " 전원 ( ) 청음 관측자 방향 확인 ( )\n", + " 지휘자 ( ) 육안 확인·소리-육안 일치 ( )\n", + " \\| \\|\n", + " +------------+---------------+\n", + " \\|\n", + " [④ 식별: ( )]\n", + " \\|\n", + " [⑤ 보고: 7항목 → ( )]\n", + " \\|\n", + " [⑥ 복창·판단 → ⑦ 해제·기록: ( )] |\n", + "| --- |\n", + "\n", + "사. 작성 서식 3 - 휴대용 요약 카드(Pocket Guide, 명함 2장 크기로 줄여 인쇄)\n", + "\n", + "| **구분** | **청음 관측자** | **육안 관측자** | **보고자·지휘자** |\n", + "| --- | --- | --- | --- |\n", + "| **긴급 모드 첫 행동** | ( ) | ( ) | ( ) |\n", + "| **일반 모드 첫 행동** | ( ) | ( ) | ( ) |\n", + "| **반드시 말할 것** | ( ) | ( ) | ( ) |\n", + "| **절대 하지 말 것** | ( ) | ( ) | ( ) |\n", + "\n", + "아. 교차 검토 기록표 (다른 조가 작성)\n", + "\n", + "| **번호** | **지적 위치** | **지적 내용(누락·모호)** | **수정 결과** | **확인** |\n", + "| --- | --- | --- | --- | --- |\n", + "| **1** | | | | |\n", + "| **2** | | | | |\n", + "| **3** | | | | |\n", + "| **4** | | | | |\n", + "| **5** | | | | |\n", + "\n", + "| **점검 항목** | **확인** |\n", + "| --- | --- |\n", + "| 7단계 모두 역할별 행동이 적혔다(빈칸 0) | ( ) |\n", + "| 필수 안전/보안 조치 S1~S8이 모두 배치되었다 | ( ) |\n", + "| 소리 유형 4가지의 확인 질문을 채웠다 | ( ) |\n", + "| 비행 형태 5가지의 보고 시 주의를 채웠다 | ( ) |\n", + "| 긴급/일반 모드가 흐름도에서 구분된다 | ( ) |\n", + "| 교차 검토 지적 사항을 모두 수정했다 | ( ) |\n", + "| 요약 카드가 한 장 안에 들어간다 | ( ) |\n", + "\n", + "**\n", + "\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 03. 거리별 드론 음향 청취 및 실루엣 식별 기초 훈련 교재 및 미디어셋 구성\n", + "\n", + "### 📋 미션 배경 및 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "거리별 드론 음향 청취 및 실루엣 식별 기초 훈련 교재와 미디어를 구성한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **거리별 청취·실루엣 식별 기초 훈련 교재와 미디어셋 구성표를 만들고 이를 시험할 상황 주입 시나리오(MSEL)를 작성한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **근거리·중거리·원거리 3개 상대 거리 구간별로 청취 카드와 실루엣 카드를 합쳐 4장 이상(총 12장 이상) 구성하고, 카드마다 소리·모습 설명, 정답(드론/비드론), 식별 단서를 적은 미디어셋 구성표 작성하여 원인을 분석하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 5개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `scenarios/trn08_msel_scenario.json(md)` 형태의 결과물을 완성한다.\n", + "\n", + "### 📑 실습자료 및 작성 양식\n", + "거리별 드론 음향 청취 및 실루엣 식별 기초 훈련 교재 및 미디어셋 구성**\n", + "\n", + "가. 제공 자료 - 거리별 청취 카드 예시 [교육용가상자료] (교관이 목소리·간단한 소리 재현으로 제시)\n", + "\n", + "| **번호** | **거리 구간** | **소리 설명** | **정답(교관용)** | **식별 단서** |\n", + "| --- | --- | --- | --- | --- |\n", + "| **A1** | 원거리 | 아주 작게 윙 소리가 들렸다가 바람에 끊김 | 확인 필요 | 방향 불명, 지속 청음 |\n", + "| **A2** | 중거리 | 높고 일정한 소리가 커졌다 작아지며 방향이 천천히 이동 | 드론 | 일정한 높이, 크기 변화 |\n", + "| **A3** | 근거리 | 윙 소리가 갑자기 크게 들림 | 드론(급접근 가능) | 급격한 증가 → 은폐 우선 |\n", + "| **A4** | 중거리 | 낮고 둔한 웅웅 소리가 오래 일정하게 이어짐 | 비드론(기계 소음) | 낮은 소리, 방향이 퍼짐 |\n", + "\n", + "나. 제공 자료 - 실루엣 카드 예시 [교육용가상자료] (모양·움직임을 글로 설명)\n", + "\n", + "| **번호** | **거리 구간** | **모습 설명** | **정답(교관용)** | **식별 단서** |\n", + "| --- | --- | --- | --- | --- |\n", + "| **B1** | 중거리 | 네 방향으로 뻗은 작은 윤곽이 제자리에서 흔들리며 떠 있음 | 드론 | 제자리 정지비행, 대칭 윤곽 |\n", + "| **B2** | 중거리 | 날개를 퍼덕이며 방향이 수시로 바뀌는 여러 개체 | 비드론(조류) | 날갯짓, 불규칙 궤적 |\n", + "| **B3** | 원거리 | 일정 속도로 직선 이동, 깜박이는 불빛 | 비드론(민간 항공기 추정) | 높은 고도, 일정 궤적, 불빛 |\n", + "| **B4** | 근거리 | 흰 물체가 바람 방향으로 천천히 떠감 | 비드론(풍선·비닐류) | 표류, 소리 없음 |\n", + "\n", + "다. 작성 서식 1 - 훈련 교재·미디어셋 구성표 (거리 구간 3개 × 구간마다 청취·실루엣 합쳐 4장 이상, 총 12장 이상)\n", + "\n", + "| **번호** | **종류(청취/실루엣)** | **거리 구간** | **소리·모습 설명** | **정답** | **식별 단서** | **미디어 파일명(가상)** |\n", + "| --- | --- | --- | --- | --- | --- | --- |\n", + "| **A1~B4** | 예시 | - | 위 예시 참고 | - | - | (업체 제공) |\n", + "| **M1** | | | | | | |\n", + "| **M2** | | | | | | |\n", + "| **M3** | | | | | | |\n", + "| **M4** | | | | | | |\n", + "| **M5** | | | | | | |\n", + "| **M6** | | | | | | |\n", + "| **M7** | | | | | | |\n", + "| **M8** | | | | | | |\n", + "| **M9** | | | | | | |\n", + "| **M10** | | | | | | |\n", + "| **M11** | | | | | | |\n", + "| **M12** | | | | | | |\n", + "\n", + "| **구성 점검** | 근거리 청취 ( )장 / 실루엣 ( )장, 중거리 청취 ( )장 / 실루엣 ( )장, 원거리 청취 ( )장 / 실루엣 ( )장 → 구간마다 4장 이상, 총 12장 이상 ( ) |\n", + "| --- | --- |\n", + "\n", + "라. 제공 자료 - 드론 보고 7항목 기준(초안)\n", + "\n", + "| **항목** | **표현 방법** | **예시** | **보완 의견(작성)** |\n", + "| --- | --- | --- | --- |\n", + "| **방위** | 진지 정면을 12시로 한 시계 방향 | 2시 방향 | ( ) |\n", + "| **거리 구간** | 근거리/중거리/원거리 | 중거리 | ( ) |\n", + "| **고도** | 지형지물 기준 상대 표현(나무 높이 / 나무 위 / 높음) | 나무 위 | ( ) |\n", + "| **수량** | 기체 수, 불확실하면 '최소 ○기' | 최소 1기 | ( ) |\n", + "| **행동** | 접근 / 선회 / 정지비행 / 이탈 / 급강하 | 접근 중 | ( ) |\n", + "| **식별** | 소리만 / 육안 확인 / 의심 | 육안 확인 | ( ) |\n", + "| **시각** | 최초 청취 시각(시·분) | 06시 20분 | ( ) |\n", + "\n", + "마. 작성 서식 2 - 상황 주입 시나리오(MSEL) (10건 이상, 정상:비정상 = 6:4)\n", + "\n", + "| **번호** | **가상 시각** | **주입 수단** | **사건 내용(주입 메시지 문안)** | **구분** | **기대 행동** | **담당** |\n", + "| --- | --- | --- | --- | --- | --- | --- |\n", + "| **1** | 06:12 | 소리 재현 | 멀리서 윙윙 소리 (예시) | 정상 | 확요 보고 → 지휘자 복창 (예시) | 청음 |\n", + "| **2** | 06:15 | 카드 | 2시 나무 위 회전익 1기 접근 (예시) | 정상 | 7항목 보고 → 은폐 (예시) | 전원 |\n", + "| **3** | 06:19 | 카드 | 9시 방향 새 떼 이동 (예시) | 비정상 | 소리-육안 불일치 → 오경보 판정 (예시) | 지휘자 |\n", + "| **4** | | | | | | |\n", + "| **5** | | | | | | |\n", + "| **6** | | | | | | |\n", + "| **7** | | | | | | |\n", + "| **8** | | | | | | |\n", + "| **9** | | | | | | |\n", + "| **10** | | | | | | |\n", + "| **11** | | | | | | |\n", + "| **12** | | | | | | |\n", + "\n", + "| **구성 비율 계산** | 정상 ( )건 : 비정상 ( )건 = ( ) : ( ) → 6:4 충족 여부 ( ) |\n", + "| --- | --- |\n", + "\n", + "| **돌발 변수** | 통신 두절 ( )번 / 센서·장비 오류 ( )번 / 부상 발생 ( )번 → 1건 이상 포함 ( ) |\n", + "| --- | --- |\n", + "\n", + "바. 워크스루(소리 내어 따라가기) 점검표\n", + "\n", + "| **점검 질문** | **문제 발견 사건 번호** | **수정 내용** |\n", + "| --- | --- | --- |\n", + "| **사건 순서와 간격이 현실적인가(너무 몰리거나 비지 않는가)?** | | |\n", + "| **모든 사건에서 7항목 보고가 가능한가(빠지는 항목은?)** | | |\n", + "| **비정상 사건이 너무 쉽게 드러나지 않는가?** | | |\n", + "| **카드 설명만으로 소리·모습을 구분할 수 있는가?** | | |\n", + "\n", + "| **점검 항목** | **확인** |\n", + "| --- | --- |\n", + "| 거리 구간 3개 모두 청취·실루엣 카드가 합쳐 4장 이상(총 12장 이상)이다 | ( ) |\n", + "| 카드마다 정답과 식별 단서가 있다 | ( ) |\n", + "| 보고 템플릿에 7항목이 모두 있다 | ( ) |\n", + "| 주입 사건이 10건 이상이다 | ( ) |\n", + "| 정상/비정상 비율이 6:4다 | ( ) |\n", + "| 돌발 변수가 1건 이상 포함되었다 | ( ) |\n", + "| 워크스루 지적 사항을 모두 수정했다 | ( ) |\n", + "\n", + "**\n", + "\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 04. 야전 모의 환경에서 드론 청취-육안 포착-구두 보고 1차 연계 실습\n", + "\n", + "### 📋 미션 배경 및 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "야전 환경 모의 드론 청취-육안 포착-구두 보고 연계 실습을 진행한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **야전 모의 환경에서 드론 청취-육안 포착-구두 보고를 처음으로 연계 실행(Dry-run)하고 기준 성과를 측정한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **4~6명 조가 청음 관측자·육안 관측자·보고자·지휘자 역할을 맡고 교관이 M3 주입 카드를 소리 재현, 구두 설명, 카드 제시 방식으로 순서대로 제시 수행하고 검증하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 5개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `logs/trn08_dryrun_execution_log.md 및 작성된 체크리스트` 형태의 결과물을 완성한다.\n", + "\n", + "### 📑 실습자료 및 작성 양식\n", + "야전 모의 환경에서 드론 청취-육안 포착-구두 보고 1차 연계 실습**\n", + "\n", + "가. 제공 자료 - 1차 모의훈련 진행 시간표(권장)\n", + "\n", + "| **시간** | **활동** | **담당** |\n", + "| --- | --- | --- |\n", + "| **0~10분** | 역할 카드 배부, 안전 브리핑, 초시계 동기화 | 교관 |\n", + "| **10~15분** | SOP·요약 카드 개인 숙지 | 전원 |\n", + "| **15~45분** | 주입 카드 1~10번 순서 진행(소리 재현·카드, 1차 Dry-run) | 교관·전원 |\n", + "| **45~60분** | 기록표 정리, 준수율·지연시간 계산 | 기록자·평가관 |\n", + "| **60~75분** | 병목·혼선 구간 토의 | 전원 |\n", + "\n", + "나. 작성 서식 1 - 시각 기록표 (기록자, 초 단위)\n", + "\n", + "| **카드** | **최초 청취** | **육안 포착** | **보고 송신** | **지휘자 수신** | **복창·판단** | **보고 지연(초)\n", + "=수신-청취** | **조치 시간(초)\n", + "=판단-청취** |\n", + "| --- | --- | --- | --- | --- | --- | --- | --- |\n", + "| **예시** | 06:20:10 | 06:20:41 | 06:20:55 | 06:21:03 | 06:21:30 | 53 | 80 |\n", + "| **1** | | | | | | | |\n", + "| **2** | | | | | | | |\n", + "| **3** | | | | | | | |\n", + "| **4** | | | | | | | |\n", + "| **5** | | | | | | | |\n", + "| **6** | | | | | | | |\n", + "| **7** | | | | | | | |\n", + "| **8** | | | | | | | |\n", + "| **9** | | | | | | | |\n", + "| **10** | | | | | | | |\n", + "\n", + "다. 작성 서식 2 - SOP 단계 수행 체크(평가관)\n", + "\n", + "| **카드** | **① 청취** | **② 방향 확인** | **③ 육안 포착** | **④ 식별** | **⑤ 보고** | **⑥ 복창·판단** | **⑦ 해제·기록** | **수행/요구** |\n", + "| --- | --- | --- | --- | --- | --- | --- | --- | --- |\n", + "| **1** | | | | | | | | |\n", + "| **2** | | | | | | | | |\n", + "| **3** | | | | | | | | |\n", + "| **4** | | | | | | | | |\n", + "| **5** | | | | | | | | |\n", + "| **6** | | | | | | | | |\n", + "| **7** | | | | | | | | |\n", + "| **8** | | | | | | | | |\n", + "| **9** | | | | | | | | |\n", + "| **10** | | | | | | | | |\n", + "\n", + "| **SOP 준수율** | 수행한 단계 합계 ( ) ÷ 요구된 단계 합계 ( ) × 100 = ( )% → 목표 80% 이상 ( ) |\n", + "| --- | --- |\n", + "\n", + "| **평균 보고 지연** | 보고 지연 합계 ( )초 ÷ 건수 ( ) = ( )초 → 목표 180초(3분) 이내 ( ) |\n", + "| --- | --- |\n", + "\n", + "라. 작성 서식 3 - 참여자별 수행 기록지(각자 작성)\n", + "\n", + "| **역할** | **잘한 점** | **막힌 점(카드 번호)** | **SOP에 없어서 헷갈린 것** |\n", + "| --- | --- | --- | --- |\n", + "| **청음 관측자** | | | |\n", + "| **육안 관측자** | | | |\n", + "| **보고자(무전)** | | | |\n", + "| **지휘자** | | | |\n", + "| **기록** | | | |\n", + "\n", + "마. 작성 서식 4 - 병목·혼선 구간 기록\n", + "\n", + "| **번호** | **발생 단계·카드** | **현상** | **추정 원인** | **M8로 넘길 질문** |\n", + "| --- | --- | --- | --- | --- |\n", + "| **1** | | | | |\n", + "| **2** | | | | |\n", + "| **3** | | | | |\n", + "\n", + "| **점검 항목** | **확인** |\n", + "| --- | --- |\n", + "| 모든 카드의 시각이 초 단위로 기록되었다 | ( ) |\n", + "| SOP 준수율을 계산식대로 계산했다 | ( ) |\n", + "| 평균 보고 지연을 계산했다 | ( ) |\n", + "| 참여자 전원의 수행 기록지를 수합했다 | ( ) |\n", + "| 병목·혼선 구간을 2건 이상 적었다 | ( ) |\n", + "| 훈련 중 안전 위반이 없었다 | ( ) |\n", + "\n", + "**\n", + "\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 05. 민간 비행체 및 조류 오인 상황 주입을 통한 식별 정확도 평가\n", + "\n", + "### 📋 미션 배경 및 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "민간 비행체 및 조류 오인 상황을 주입하여 식별 정확도를 평가한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **민간 비행체, 조류, 차량 소음 등 오인 상황을 일부러 넣어 청음·육안 식별 정확도를 평가하고 교차 확인 규칙을 정한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **교관이 실제 드론 카드와 오인 카드(민간 항공기, 조류 무리, 풍선, 차량 소리, 방위 반대 보고, 지연 중복, 송신자 불명 등) 12장을 섞어 제시 수행하고 검증하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 5개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `docs/trn08_cross_verification_sheet.md` 형태의 결과물을 완성한다.\n", + "\n", + "### 📑 실습자료 및 작성 양식\n", + "민간 비행체 및 조류 오인 상황 주입을 통한 식별 정확도 평가**\n", + "\n", + "가. 제공 자료 - 교차 확인 규칙(초안, 조별로 보완)\n", + "\n", + "| **규칙** | **내용** |\n", + "| --- | --- |\n", + "| **R1 복창** | 지휘자는 받은 보고의 방위·수량을 복창하고 보고자가 '맞음/틀림'으로 답한다. |\n", + "| **R2 2차 관측** | 가능하면 다른 관측자에게 같은 방위를 확인시킨다. |\n", + "| **R3 시각 대조** | 보고 시각과 수신 시각이 1분 이상 차이 나면 지연 정보로 표시한다. |\n", + "| **R4 소리-육안 일치** | 소리 방향과 육안 방향이 다르거나 육안 확인이 없으면 '확인 요망'으로 둔다. |\n", + "| **R5 송신자 확인** | 송신자를 알 수 없는 보고는 되물어 확인한다. |\n", + "| **R6 안전 우선** | 확인 중이라도 급강하·급접근 신호가 있으면 먼저 은폐를 전파한다. |\n", + "\n", + "나. 제공 자료 - 오류 주입 카드 12장 [교육용가상자료] (교관이 순서를 섞어 제시)\n", + "\n", + "| **카드** | **들어온 보고** | **추가로 확인되는 정보** |\n", + "| --- | --- | --- |\n", + "| **E1** | 청음1: 3시 윙윙 소리가 점점 커짐 | 육안1이 3시 저고도에서 회전익 1기 확인 |\n", + "| **E2** | 청음2: 9시 드론 소리 (실제는 3시) | 육안1이 3시에서 확인, 무전 잡음 있음 |\n", + "| **E3** | 육안1: 12시 높은 곳 일정 속도 비행체 | 일정한 직선 궤적, 깜박이는 불빛, 소리 약함 |\n", + "| **E4** | 청음1: 8시 낮고 둔한 소리가 오래 이어짐 | 육안 확인 불가, 8시 방향 도로에서 보급 차량 이동 중 |\n", + "| **E5** | 육안2: 10시 작은 비행체 무리 여러 개 | 날갯짓, 방향이 수시로 바뀜, 소리 없음 |\n", + "| **E6** | 육안1: 11시 흰 물체가 천천히 떠감 | 바람 방향과 같은 방향으로 표류, 소리 없음 |\n", + "| **E7** | 청음2: 5시 윙윙 소리, 육안 미확인 | 육안2가 5시에서 저고도 회전익 1기 육안 확인 |\n", + "| **E8** | 청음1: 소리가 급격히 커짐, 방향 6시 | 육안1 시야 차단, 청음2도 소리 급증 확인 |\n", + "| **E9** | 보고자: 7시 드론 1기 접근 | 육안2는 7시에서 2기 확인 |\n", + "| **E10** | 보고자: 2시 회전익 1기 (4분 전 보고와 같은 내용이 늦게 도착) | 무전 지연, 시각 표기가 4분 전 |\n", + "| **E11** | 무전: '1시 드론 다수' (송신자 불명) | 관측자·보고자 누구도 송신하지 않음, 1시 방향 조용함 |\n", + "| **E12** | 청음1: 강풍 속에서 간헐적 윙 소리, 방향 불명 | 육안 관측 불가(먼지), 청음2는 소리 못 들음 |\n", + "\n", + "다. 작성 서식 1 - 교차검증 매트릭스\n", + "\n", + "| **카드** | **적용 규칙(R번호)** | **판정\n", + "(드론/오인/확인필요)** | **근거(관측 사실)** | **조치** |\n", + "| --- | --- | --- | --- | --- |\n", + "| **E1** | | | | |\n", + "| **E2** | | | | |\n", + "| **E3** | | | | |\n", + "| **E4** | | | | |\n", + "| **E5** | | | | |\n", + "| **E6** | | | | |\n", + "| **E7** | | | | |\n", + "| **E8** | | | | |\n", + "| **E9** | | | | |\n", + "| **E10** | | | | |\n", + "| **E11** | | | | |\n", + "| **E12** | | | | |\n", + "\n", + "| **식별 성공률** | 정답과 일치한 카드 ( ) ÷ 전체 12장 × 100 = ( )% → 목표 85% 이상 ( ) |\n", + "| --- | --- |\n", + "\n", + "| **실제 위험 놓침** | 실제 위험을 이상 없음으로 판정한 건수 ( )건 → 목표 0건 ( ) |\n", + "| --- | --- |\n", + "\n", + "라. 작성 서식 2 - 불일치 시 재확인·보고 경로(우선순위 규칙)\n", + "\n", + "| **상황** | **1순위 확인** | **2순위 확인** | **상급 보고 여부·시점** |\n", + "| --- | --- | --- | --- |\n", + "| **두 관측자의 방위가 다름** | | | |\n", + "| **보고가 늦게 도착(1분 이상)** | | | |\n", + "| **송신자 불명 보고** | | | |\n", + "| **소리만 있고 육안 미확인** | | | |\n", + "\n", + "마. 교관용 정답표 (훈련 후 공개)\n", + "\n", + "| **카드** | **정답** | **핵심 근거** |\n", + "| --- | --- | --- |\n", + "| **E1** | 드론 | 소리와 육안 방위 일치 |\n", + "| **E2** | 드론(방위 정정 3시) | 잡음, 육안 3시 확인 → 복창으로 정정 |\n", + "| **E3** | 오인(민간 항공기) | 높은 고도·일정 궤적·깜박이는 불빛 |\n", + "| **E4** | 오인(차량 소음) | 낮고 둔한 소리, 육안 없음, 차량 이동 정보 |\n", + "| **E5** | 오인(조류) | 날갯짓·방향 급변·무소음 |\n", + "| **E6** | 오인(풍선·비닐류) | 표류·무소음 |\n", + "| **E7** | 드론 | 소리-육안 일치 |\n", + "| **E8** | 드론(긴급) | 소리 급증 두 명 확인 → R6 은폐 먼저 |\n", + "| **E9** | 드론(2기) | 육안으로 2기 확정, 수량 정정 |\n", + "| **E10** | 오인(지연 중복) | 4분 전 보고의 지연 도착 |\n", + "| **E11** | 확인필요→오인 | 송신자 불명, 현장 무신호 → 되물어 확인 후 기만 가능성 보고 |\n", + "| **E12** | 확인필요 | 청음 한계, 교차 확인 불가 → '확요' 유지, 청음 위치 이동·육안 보강 지시 |\n", + "\n", + "| 판정 점수 기준: '확인필요'로 두고 이후 확인을 거쳐 정답에 도달했으면 정답으로 본다. 정답표에서 '실제 위험'으로 표시된 카드(E1, E2, E7, E8, E9)를 이상 없음으로 버리면 성공률과 관계없이 FAIL이다. |\n", + "| --- |\n", + "\n", + "| **점검 항목** | **확인** |\n", + "| --- | --- |\n", + "| 12장 모두 판정과 근거를 적었다 | ( ) |\n", + "| 근거는 관측 사실로 적었다 | ( ) |\n", + "| 식별 성공률과 놓친 건수를 계산했다 | ( ) |\n", + "| 불일치 시 보고 경로 상황을 모두 채웠다 | ( ) |\n", + "| 의심 신호가 있어도 안전 조치를 먼저 전파했다 | ( ) |\n", + "\n", + "**\n", + "\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 06. 전술 무전기를 활용한 지휘관 앞 즉각 보고(SALUTE 포맷) 인계 훈련\n", + "\n", + "### 📋 미션 배경 및 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "무전기를 활용한 지휘관 앞 즉각 보고 및 인계 훈련을 실습한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **무전기로 SALUTE 형식 즉각 보고를 지휘관에게 전달하고 교대 인원에게 진행 상황을 빠짐없이 인계하는 절차를 훈련한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **실습자료의 가상 관측 상황 카드를 보고 규모·행동·위치·식별·시각·장비 6항목 SALUTE 즉각 보고 문안을 쓰고 무전 모의(대면 구두)로 지휘관 역할에게 전달 수행하고 검증하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 5개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `reports/trn08_handover_protocol.md 및 브리핑 녹취/기록지` 형태의 결과물을 완성한다.\n", + "\n", + "### 📑 실습자료 및 작성 양식\n", + "전술 무전기를 활용한 지휘관 앞 즉각 보고(SALUTE 포맷) 인계 훈련**\n", + "\n", + "가. 제공 자료 - 가상 관측 상황 카드 [교육용가상자료]\n", + "\n", + "| 가상 07:15 현재: 06:58에 2시 방향 중거리에서 회전익 1기 접근을 육안 확인(청음·육안 일치), 지휘자에게 보고 완료. 07:03 1시 방향으로 이탈. 07:10부터 4시 방향에서 소리만 청취 중(확인 요망, 육안 미확인). 청음 관측자 1명은 귀가 아프다고 호소(모의, 휴식 필요). 무전기 잡음 보통, 07:30 근무 교대 예정, 상급 보고는 06:59 1회 완료. |\n", + "| --- |\n", + "\n", + "나. 제공 자료 - SALUTE 즉각 보고 6항목 기준\n", + "\n", + "| **항목** | **뜻** | **드론 보고 작성 요령** | **작성** |\n", + "| --- | --- | --- | --- |\n", + "| **S 규모** | 수량 | 기체 수, 불확실하면 최소 ○기 | ( ) |\n", + "| **A 행동** | 하는 일 | 접근/선회/정지비행/이탈/급강하 | ( ) |\n", + "| **L 위치** | 방위·거리 구간 | 시계 방향 + 근/중/원거리 | ( ) |\n", + "| **U 식별** | 유형·식별 근거 | 회전익/미상, 소리만/육안 확인 | ( ) |\n", + "| **T 시각** | 최초 포착 시각 | 시·분·초 | ( ) |\n", + "| **E 장비** | 눈에 띄는 특징 | 불빛, 탑재물 의심 등 보이는 것만 | ( ) |\n", + "\n", + "다. 작성 서식 1 - SALUTE 즉각 보고 문안(지휘관 역할에게 무전 모의로 전달)\n", + "\n", + "| **항목** | **보고 문안(작성)** | **지휘관 복창(O/X)** |\n", + "| --- | --- | --- |\n", + "| **S** | | ( ) |\n", + "| **A** | | ( ) |\n", + "| **L** | | ( ) |\n", + "| **U** | | ( ) |\n", + "| **T** | | ( ) |\n", + "| **E** | | ( ) |\n", + "\n", + "| **보고 소요 시간** | 시작 ( : : ) ~ 종료 ( : : ) = ( )초 → 목표 45초 이내 ( ) |\n", + "| --- | --- |\n", + "\n", + "라. 작성 서식 2 - SBAR 인계 브리핑 템플릿 (교대 인수자에게)\n", + "\n", + "| **구분** | **들어갈 내용** | **작성** |\n", + "| --- | --- | --- |\n", + "| **S 상황** | 현재 경보 단계, 지금 추적 중인 신호 | |\n", + "| **B 배경** | 지금까지 경과(시각순), 이미 한 보고 | |\n", + "| **A 평가** | 위협 판단, 확인 요망 신호, 관측자 상태·청음 환경 | |\n", + "| **R 요청** | 인수자가 곧바로 할 일, 다음 보고 시각 | |\n", + "\n", + "마. 작성 서식 3 - 인계 필수 항목 10개 확인표\n", + "\n", + "| **번호** | **필수 항목** | **전달(O/X)** | **인수자 복창(O/X)** |\n", + "| --- | --- | --- | --- |\n", + "| **1** | 현재 경보 단계 | ( ) | ( ) |\n", + "| **2** | 표적 수·방위·마지막 확인 시각 | ( ) | ( ) |\n", + "| **3** | 확인 요망 중인 신호 | ( ) | ( ) |\n", + "| **4** | 담당 청음·관측 구역 | ( ) | ( ) |\n", + "| **5** | 관측자 상태(피로·청력) | ( ) | ( ) |\n", + "| **6** | 통신 상태 | ( ) | ( ) |\n", + "| **7** | 상급 보고 이력 | ( ) | ( ) |\n", + "| **8** | 다음 보고 예정 시각 | ( ) | ( ) |\n", + "| **9** | 인수자 즉시 조치 사항 | ( ) | ( ) |\n", + "| **10** | 인계·인수 시각 | ( ) | ( ) |\n", + "\n", + "| **즉각 보고 후 인계까지 소요** | 즉각 보고 시작 ( : : ) ~ 인계 종료 ( : : ) = ( )분 → 목표 10분 이내 ( ) |\n", + "| --- | --- |\n", + "\n", + "| **누락률** | SALUTE 누락 ( )개 + 인계 누락 ( )개 ÷ 16 × 100 = ( )% → 목표 0% ( ) |\n", + "| --- | --- |\n", + "\n", + "| **상호 서명** | 인계자 ( ) 인수자 ( ) |\n", + "| --- | --- |\n", + "\n", + "바. 인수자 돌발 상황 퀴즈 (교관이 인계 직후 구두로 질문)\n", + "\n", + "| **번호** | **질문** | **정답(교관용)** | **O/X** |\n", + "| --- | --- | --- | --- |\n", + "| **Q1** | 지금 확인 요망 상태인 신호는 어느 방향인가? | 4시 방향(소리만) | ( ) |\n", + "| **Q2** | 마지막 육안 확인 기체는 어디로 이탈했나? | 1시 방향 | ( ) |\n", + "| **Q3** | 청음 관측자 상태에서 주의할 점은? | 귀 통증, 휴식 필요 | ( ) |\n", + "| **Q4** | 다음 상급 보고 시각은? | 07:30(교대 시점) | ( ) |\n", + "| **Q5** | 4시 방향에서 소리가 급격히 커지면 가장 먼저 할 일은? | 은폐 전파 후 7항목 보고 | ( ) |\n", + "\n", + "| **점검 항목** | **확인** |\n", + "| --- | --- |\n", + "| SALUTE 6항목을 모두 문안으로 썼다 | ( ) |\n", + "| 즉각 보고를 45초 이내에 마쳤다 | ( ) |\n", + "| SBAR 4개 칸을 모두 채웠다 | ( ) |\n", + "| 인계 필수 항목 10개 누락 0 | ( ) |\n", + "| 즉각 보고 후 인계까지 10분 이내였다 | ( ) |\n", + "| 인수자 퀴즈 4문항 이상 정답 | ( ) |\n", + "| 상호 서명을 받았다 | ( ) |\n", + "\n", + "**\n", + "\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 07. 강한 바람 및 배경 소음 환경 모의 청취 한계 극복 스트레스 훈련\n", + "\n", + "### 📋 미션 배경 및 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "배경 소음 및 강풍 환경 모의 청취 한계 극복 스트레스 훈련을 진행한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **강풍과 배경 소음 때문에 청음이 어려운 환경에서 육안 관측 중심 대체 절차로 전환하고 역할 위임이 유지되는지 스트레스 훈련으로 확인한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **교관이 가상 강풍, 발전기 소음, 소리 방향 왜곡, 무전 두절, 청음 관측자 교대 지연 카드를 짧은 간격으로 주입하여 처리 지연 및 복원력을 시험하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 5개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `logs/trn08_stress_drill_results.md` 형태의 결과물을 완성한다.\n", + "\n", + "### 📑 실습자료 및 작성 양식\n", + "강한 바람 및 배경 소음 환경 모의 청취 한계 극복 스트레스 훈련**\n", + "\n", + "가. 제공 자료 - 스트레스 주입 타임라인 [교육용가상자료] (교관이 T 기준으로 제시)\n", + "\n", + "| **시점** | **주입 내용** | **기대 대응** |\n", + "| --- | --- | --- |\n", + "| **T+0:00** | 가상 강풍과 인근 발전기 소음 시작(교관이 큰 소리로 배경 소음 재현) | 청음 환경 악화 인지, 요약 카드 확인 |\n", + "| **T+1:00** | 간헐적인 윙 소리, 방향 불명 | 확요 보고, 청음 한계 선언 여부 판단 |\n", + "| **T+2:00** | 청음 관측자: '소리로는 방향을 못 잡겠다' | 육안 관측 중심으로 전환, 관측 구역 재분담 |\n", + "| **T+3:00** | 육안 관측자가 2시 방향 저고도 회전익 1기 포착 | 7항목 보고, 2인 교차 확인 |\n", + "| **T+4:00** | 보고자 무전 두절 | 대체 전달(전령·수신호, 모의)로 전환 |\n", + "| **T+5:00** | 지휘자 퇴장(모의 부재) | 위임 순서에 따라 차선임자 지휘 인수 |\n", + "| **T+7:00** | 바람에 날리는 비닐 + 새 떼가 동시에 보임 | 교차 확인 후 오인 판정, 근거 발언 |\n", + "| **T+9:00** | 청음 관측자 교대 지연, 담당 구역 공백 발생 | 인접 관측자가 구역 겸임, 보고 |\n", + "| **T+11:00** | 6시에서 소리 급증 | 은폐 전파 우선, 이후 보고 |\n", + "| **T+13:00** | 지휘자 복귀, 무전 복구 | 차선임자가 지휘자에게 상황 인계(SBAR) |\n", + "| **T+15:00** | 해제 명령 | 인원 확인 후 해제 보고 |\n", + "\n", + "나. 제공 자료 - 위험 조치 10개 판정 기준\n", + "\n", + "| **번호** | **위험 조치** | **성공 기준** |\n", + "| --- | --- | --- |\n", + "| **D1** | 청음 한계 선언 | 소리로 방향을 못 잡을 때 1분 이내 지휘자에게 한계 보고 |\n", + "| **D2** | 육안 관측 전환 | 육안 관측 중심으로 구역 재분담 |\n", + "| **D3** | 확요 보고 유지 | 방향 불명 소리도 '확요'로 계속 보고 |\n", + "| **D4** | 2인 교차 확인 | 육안 포착 후 다른 관측자 확인 발언 |\n", + "| **D5** | 대체 전달 전환 | 무전 두절 후 1분 이내 대체 수단 사용 |\n", + "| **D6** | 지휘 위임 | 부재 확인 후 1분 이내 차선임자 '지휘 인수' 선언 |\n", + "| **D7** | 오인 판정 | 비닐·새 떼 오인을 근거와 함께 판정 |\n", + "| **D8** | 구역 공백 보완 | 인접 관측자 겸임 선언과 보고 |\n", + "| **D9** | 급접근 은폐 우선 | 소리 급증 후 보고보다 먼저 은폐 전파 |\n", + "| **D10** | 복귀 인계 | SBAR 4요소를 포함해 인계 |\n", + "\n", + "다. 작성 서식 1 - 타임라인 기록표(기록자)\n", + "\n", + "| **시점** | **실제 행동·발언** | **행동 시각** | **걸린 시간(초)** | **관련 D번호** |\n", + "| --- | --- | --- | --- | --- |\n", + "| **T1** | | | | |\n", + "| **T2** | | | | |\n", + "| **T3** | | | | |\n", + "| **T4** | | | | |\n", + "| **T5** | | | | |\n", + "| **T6** | | | | |\n", + "| **T7** | | | | |\n", + "| **T8** | | | | |\n", + "| **T9** | | | | |\n", + "| **T10** | | | | |\n", + "| **T11** | | | | |\n", + "\n", + "라. 작성 서식 2 - 위험 조치 판정표 및 위임 순서\n", + "\n", + "| **D1** | **D2** | **D3** | **D4** | **D5** | **D6** | **D7** | **D8** | **D9** | **D10** |\n", + "| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- |\n", + "| ( ) | ( ) | ( ) | ( ) | ( ) | ( ) | ( ) | ( ) | ( ) | ( ) |\n", + "\n", + "| **위험 조치 성공률** | 성공 ( ) ÷ 10 × 100 = ( )% → 목표 90% 이상 ( ) |\n", + "| --- | --- |\n", + "\n", + "| **비상 절차 전환 시간** | 청음 한계 신호 ( : : ) → 육안 전환 선언 ( : : ) = ( )초 → 목표 60초 이내 ( ) |\n", + "| --- | --- |\n", + "\n", + "| **지휘 위임 순서** | 1순위 지휘자 ( ) → 2순위 ( ) → 3순위 ( ) / 인수 선언 문구: ( ) |\n", + "| --- | --- |\n", + "\n", + "마. 안전 통제 점검(훈련 시작 전, 안전 통제관)\n", + "\n", + "| **안전 점검 항목** | **확인** |\n", + "| --- | --- |\n", + "| 이동 경로에 걸려 넘어질 물체를 치웠다 | ( ) |\n", + "| 소음 재현은 청력에 해롭지 않은 크기로 정했다 | ( ) |\n", + "| '훈련 중지' 3회 신호를 전원이 알고 있다 | ( ) |\n", + "| 육안 관측 방향에 태양이 직접 들어오지 않는다 | ( ) |\n", + "| 모의 환자·부상은 카드로만 표시한다 | ( ) |\n", + "\n", + "| **점검 항목** | **확인** |\n", + "| --- | --- |\n", + "| 타임라인 11개 시점을 모두 기록했다 | ( ) |\n", + "| 비상 절차 전환 시간을 초 단위로 계산했다 | ( ) |\n", + "| 위험 조치 10개를 모두 판정했다 | ( ) |\n", + "| 위임이 1분 이내에 이루어졌는지 확인했다 | ( ) |\n", + "| 안전 수칙 위반 0건 | ( ) |\n", + "\n", + "**\n", + "\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 08. AAR을 통한 오청/오인 식별 사례 분석 및 청음/식별 요령 보완\n", + "\n", + "### 📋 미션 배경 및 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "AAR을 통해 오청 및 오인 식별 사례를 분석하고 식별 요령을 보완한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **AAR을 통해 오청·오인 식별 사례의 원인을 사실 중심으로 분석하고 청음·식별 요령을 보완한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **M4~M7 기록지와 타임라인을 모아 '계획한 것-실제 일어난 것-차이의 원인-개선할 것' 4단계로 AAR을 진행 수행하고 검증하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 5개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `aar/trn08_aar_session_minutes.md 및 오류 분석 매트릭스` 형태의 결과물을 완성한다.\n", + "\n", + "### 📑 실습자료 및 작성 양식\n", + "AAR을 통한 오청/오인 식별 사례 분석 및 청음/식별 요령 보완**\n", + "\n", + "가. 제공 자료 - AAR 4단계 진행 순서(권장 60분)\n", + "\n", + "| **단계** | **질문** | **시간** |\n", + "| --- | --- | --- |\n", + "| **1** | 무엇을 하려고 했나(계획·SOP 기준)? | 10분 |\n", + "| **2** | 실제로 무슨 일이 있었나(기록 기준)? | 15분 |\n", + "| **3** | 왜 차이가 났나(원인)? | 20분 |\n", + "| **4** | 다음에는 무엇을 바꿀까(개선)? | 15분 |\n", + "\n", + "나. 제공 자료 - 가상 기록 발췌(팩트체크 연습용) [교육용가상자료]\n", + "\n", + "| **번호** | **출처** | **내용** |\n", + "| --- | --- | --- |\n", + "| **F1** | 청음 관측자 진술 | \"소리는 분명히 3시에서 났다.\" |\n", + "| **F2** | 관측 기록 | 육안 포착 방향은 5시, 최초 청취 06:20:10, 육안 포착 06:20:41 |\n", + "| **F3** | 환경 메모 | 강한 바람이 5시 → 3시 방향으로 불었고 건물 벽 반사 가능 |\n", + "| **F4** | 보고 기록 | 보고 10건 중 고도 누락 4건, 수량 누락 1건 |\n", + "| **F5** | 실루엣 카드 결과 | 새 무리를 소형 회전익으로 오인한 건 3건(시야 거리 원거리 구간) |\n", + "| **F6** | 스트레스 훈련 기록 | 배경 소음 주입 후 청음 한계 선언까지 95초, 기준이 정해져 있지 않았음 |\n", + "| **F7** | 지휘 위임 기록 | 지휘자 퇴장 후 위임 선언 없이 70초 동안 보고가 대기 |\n", + "\n", + "| 팩트체크 예: F1 진술(3시)과 F2 기록(5시)이 다르다. F3을 보면 바람과 벽 반사로 소리 방향이 왜곡될 수 있었다. 원인은 '청음 관측자의 잘못'이 아니라 '소리만으로 방향을 확정하지 않고 육안 확인으로 보정하는 규칙 없음(절차)'으로 분류한다. |\n", + "| --- |\n", + "\n", + "다. 작성 서식 1 - 원인 분석 매트릭스\n", + "\n", + "| **문제** | **확인된 사실** | **근거(F번호·기록)** | **원인 분류\n", + "(절차/도구/환경/숙련)** | **근본 원인** | **개선안** |\n", + "| --- | --- | --- | --- | --- | --- |\n", + "| **소리 방향 오판** | | | | | |\n", + "| **보고 항목 누락** | | | | | |\n", + "| **실루엣 오인(원거리)** | | | | | |\n", + "| **청음 한계 선언 지연** | | | | | |\n", + "| | | | | | |\n", + "| | | | | | |\n", + "\n", + "라. 비난 표현 바꾸기\n", + "\n", + "| **피할 표현** | **바꾼 표현(예시)** |\n", + "| --- | --- |\n", + "| **청음 관측자가 귀가 나빠서 틀렸다** | 바람과 반사로 방향이 왜곡될 수 있는데 육안 확인 전 확정하지 않는 규칙이 SOP에 없었다 |\n", + "| **보고자가 항목을 자꾸 빼먹었다** | 보고 문안 순서를 외울 수 있는 요약 카드 문구가 없어 고도 항목이 10건 중 4건 빠졌다 |\n", + "\n", + "마. 작성 서식 2 - 개선점 목록(5건 이상) 및 SOP 1차 수정안\n", + "\n", + "| **번호** | **개선점** | **SOP 현행** | **SOP 수정** | **근거(매트릭스 행)** |\n", + "| --- | --- | --- | --- | --- |\n", + "| **1** | | | | |\n", + "| **2** | | | | |\n", + "| **3** | | | | |\n", + "| **4** | | | | |\n", + "| **5** | | | | |\n", + "| **6** | | | | |\n", + "\n", + "| **점검 항목** | **확인** |\n", + "| --- | --- |\n", + "| 4단계 순서로 AAR을 진행했다 | ( ) |\n", + "| 진술과 기록이 다른 곳을 모두 표시했다 | ( ) |\n", + "| 모든 원인 항목에 근거(F번호·기록)를 연결했다 | ( ) |\n", + "| 개선점이 5건 이상이다 | ( ) |\n", + "| 회의록에 개인 비난 표현이 없다 | ( ) |\n", + "| 개선점이 SOP 수정안에 반영되었다 | ( ) |\n", + "\n", + "**\n", + "\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 09. 피드백 반영 드론 청음 및 육안 식별 재평가 훈련 수행\n", + "\n", + "### 📋 미션 배경 및 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "피드백을 반영하여 드론 청음 및 육안 식별 재평가 훈련을 수행한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **AAR 피드백을 반영한 수정 SOP와 청음·식별 요령으로 같은 조건의 재평가 훈련을 실시하고 조치 시간과 오류율 개선을 수치로 확인한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **M4와 같은 주입 카드 구성(순서만 바꿈)과 같은 평가표로 재평가 훈련을 실시 수행하고 검증하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 5개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `eval/trn08_rerun_performance_comparison.md` 형태의 결과물을 완성한다.\n", + "\n", + "### 📑 실습자료 및 작성 양식\n", + "피드백 반영 드론 청음 및 육안 식별 재평가 훈련 수행**\n", + "\n", + "가. 제공 자료 - 재훈련 동일 조건 유지표\n", + "\n", + "| **항목** | **1차(M4)** | **재훈련** | **동일 여부** |\n", + "| --- | --- | --- | --- |\n", + "| **주입 카드 구성** | 10건 | 같은 10건(순서만 변경) | ( ) |\n", + "| **역할 편성** | | | ( ) |\n", + "| **평가표** | M4 서식 | M4 서식 | ( ) |\n", + "| **측정 방법** | 초시계·시각 기록표 | | ( ) |\n", + "| **시간대·환경** | | | ( ) |\n", + "\n", + "나. 제공 자료 - 계산 예시 [교육용가상자료]\n", + "\n", + "| 평균 조치 시간: 1차 180초 → 재훈련 120초\n", + "단축률 = (180 - 120) ÷ 180 × 100 = 33.3% → 목표 30% 이상 달성\n", + "\n", + "오류 건수: 1차 14건 → 재훈련 6건\n", + "감소율 = (14 - 6) ÷ 14 × 100 = 57.1% → 목표 50% 이상 달성 |\n", + "| --- |\n", + "\n", + "다. 작성 서식 1 - 전후 성과 비교표\n", + "\n", + "| **지표** | **1차(M4)** | **재훈련** | **변화율(%)** | **목표** | **달성** |\n", + "| --- | --- | --- | --- | --- | --- |\n", + "| **평균 조치 시간(초)** | | | | 30% 이상 단축 | ( ) |\n", + "| **평균 보고 지연(초)** | | | | 참고 | ( ) |\n", + "| **SOP 준수율(%)** | | | | 참고 | ( ) |\n", + "| **오류 건수(누락)** | | | | - | - |\n", + "| **오류 건수(오보고·오판정)** | | | | - | - |\n", + "| **오류 건수(절차 위반)** | | | | - | - |\n", + "| **오류 합계** | | | | 50% 이상 감소 | ( ) |\n", + "\n", + "라. 작성 서식 2 - 미달 항목 원인 및 보수 교육 계획\n", + "\n", + "| **미달 항목** | **원인** | **보완 방법** |\n", + "| --- | --- | --- |\n", + "| **미달 1** | | |\n", + "| **미달 2** | | |\n", + "\n", + "| **보수 교육 주기** | ( )주마다 ( )분, 방법: ( ) |\n", + "| --- | --- |\n", + "\n", + "| **숙련 유지 확인 지표** | ( ) ※ 예: 평균 보고 지연 60초 이내 유지 |\n", + "| --- | --- |\n", + "\n", + "| **점검 항목** | **확인** |\n", + "| --- | --- |\n", + "| 1차와 같은 구성·평가표·측정 방법을 썼다 | ( ) |\n", + "| 조치 시간 단축률을 계산식대로 계산했다 | ( ) |\n", + "| 오류를 유형별로 세고 감소율을 계산했다 | ( ) |\n", + "| 미달 항목의 원인을 적었다 | ( ) |\n", + "| 보수 교육 주기를 제안했다 | ( ) |\n", + "\n", + "**\n", + "\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 10. 드론 경보 청취·육안 식별 및 즉각 보고 훈련 교범 최종 완성\n", + "\n", + "### 📋 미션 배경 및 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "드론 경보 청취·육안 식별 및 보고 절차 훈련 교범을 완성한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **드론 경보 청취·육안 식별 및 즉각 보고 절차 훈련 교범을 최종 확정하고 다른 부대 교관이 바로 쓸 수 있는 훈련 패키지로 묶는다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **개요-SOP-시나리오-평가 루브릭-AAR 5개 장으로 교안을 구성하고 M2~M9 산출물 가운데 검증된 내용만 반영 수행하고 검증하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 5개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `package/trn08_standard_training_package.md 및 교관 지침서` 형태의 결과물을 완성한다.\n", + "\n", + "### 📑 실습자료 및 작성 양식\n", + "드론 경보 청취·육안 식별 및 즉각 보고 훈련 교범 최종 완성**\n", + "\n", + "가. 작성 서식 1 - 훈련 패키지 5개 장 구성표\n", + "\n", + "| **장** | **포함 내용** | **반영할 산출물** | **완성(O/X)** |\n", + "| --- | --- | --- | --- |\n", + "| **1장 개요** | 훈련 목적, 대상, 소요 시간, 가상 상황 | M1 | ( ) |\n", + "| **2장 SOP** | 소리·비행 형태 관측 표, 흐름도, 요약 카드, 안전 조치 | M2·M8 수정안 | ( ) |\n", + "| **3장 시나리오** | 청취·실루엣 카드, MSEL, 오인 주입 카드, 스트레스 타임라인 | M3·M5·M7 | ( ) |\n", + "| **4장 평가 루브릭** | SOP 체크, 시각 기록표, SALUTE·인계 확인표, 채점표 | M4·M6·M9 | ( ) |\n", + "| **5장 AAR** | AAR 진행 순서, 원인 분석 매트릭스, 개선 이력 | M8·M9 | ( ) |\n", + "\n", + "나. 작성 서식 2 - 교관 지침서\n", + "\n", + "| **준비물** | ( ) |\n", + "| --- | --- |\n", + "\n", + "| **역할 배정** | ( ) |\n", + "| --- | --- |\n", + "\n", + "| **시간표** | ( ) |\n", + "| --- | --- |\n", + "\n", + "| **안전 통제** | ( ) |\n", + "| --- | --- |\n", + "\n", + "| **채점 방법** | ( ) |\n", + "| --- | --- |\n", + "\n", + "| **갱신 주기** | ( ) ※ 연간 교육훈련 계획과 연계 |\n", + "| --- | --- |\n", + "\n", + "다. 작성 서식 3 - 성과 수치 일치 확인\n", + "\n", + "| **교안에 적은 수치** | **교안 값** | **원본 기록(M4·M9)** | **일치(O/X)** |\n", + "| --- | --- | --- | --- |\n", + "| **1차 SOP 준수율** | | | ( ) |\n", + "| **1차 평균 보고 지연** | | | ( ) |\n", + "| **재훈련 조치 시간 단축률** | | | ( ) |\n", + "| **재훈련 오류 감소율** | | | ( ) |\n", + "\n", + "라. 작성 서식 4 - 모의 검토 피드백 기록(다른 조 교관 역할)\n", + "\n", + "| **번호** | **검토 의견** | **반영 여부** | **반영 내용** |\n", + "| --- | --- | --- | --- |\n", + "| **1** | | | |\n", + "| **2** | | | |\n", + "| **3** | | | |\n", + "| **4** | | | |\n", + "| **5** | | | |\n", + "\n", + "| **점검 항목** | **확인** |\n", + "| --- | --- |\n", + "| 5개 장이 모두 있다 | ( ) |\n", + "| 흐름도·요약 카드 등 시각 자료가 들어갔다 | ( ) |\n", + "| 교안 수치가 M4·M9 기록과 일치한다 | ( ) |\n", + "| 모의 검토 피드백 반영 내역을 기록했다 | ( ) |\n", + "| 실제 부대 정보·실제 음어·실제 드론 소음 수치가 없다 | ( ) |\n", + "| 교전·대응 판단은 교전수칙·지휘계통 준수 문구만 있다 | ( ) |\n", + "\n", + "**4. 평가 채점표**\n", + "\n", + "미션마다 정량 기준은 PASS/FAIL로, 정성 기준은 상·중·하로 판정한다. 근거가 되는 기록지·서식 번호를 비고에 적는다.\n", + "\n", + "| **미션** | **구분** | **정량 기준(PASS/FAIL)** | **정성 기준(상·중·하)** | **판정** |\n", + "| --- | --- | --- | --- | --- |\n", + "| **M1. 소형 드론 모터 소음 특성 및 육안 실루엣 식별 훈련 목표 수립** | 필수 | 현장 체크 항목 15개 이상 정의, 7단계 전체에 대한 역할 분담 매트릭스 빈칸 0개(100% 매핑), 단계마다 책임자가 1명으로 지정되고 사례 카드 6건이 문제 유형으로 모두 분류되었는가. | 사례 카드의 문제점이 훈련 목표에 반영되어 상황이 현실적이고 실제 부대에 바로 적용할 수 있는가. | PASS / FAIL\n", + "상 / 중 / 하 |\n", + "| **M2. 드론 소음 음향 특성(주파수 대역) 및 비행 형태별 관측 체크리스트 작성** | 필수 | SOP 7단계 모두에 역할별 세부 절차가 100% 적혔고, 필수 안전/보안 조치 8개가 모두 포함(100%)되었으며, 교차 검토에서 지적된 모호 표현이 모두 수정되었는가. | 긴급 상황에서 한눈에 읽히도록 절차가 명확하고 간결하며, 긴급/일반 모드 구분이 분명한가. | PASS / FAIL\n", + "상 / 중 / 하 |\n", + "| **M3. 거리별 드론 음향 청취 및 실루엣 식별 기초 훈련 교재 및 미디어셋 구성** | 필수 | 거리 구간 3개 모두에 청취·실루엣 카드가 구간마다 4장 이상 구성되었고, 주입 사건 10건 이상·정상/비정상 6:4 비율을 지켰으며, 워크스루에서 나온 개연성 문제가 모두 수정되었는가. | 상황 전개가 개연성 있고 훈련 내내 적절한 긴장도가 유지되도록 사건 간격과 난이도가 배치되었는가. | PASS / FAIL\n", + "상 / 중 / 하 |\n", + "| **M4. 야전 모의 환경에서 드론 청취-육안 포착-구두 보고 1차 연계 실습** | 선택 | SOP 단계 준수율 80% 이상, 최초 청취에서 지휘자 수신까지 보고 지연시간 3분 이내, 병목·혼선 구간 2건 이상 식별했는가. | 현장 판단이 정확하고 보고가 표준 템플릿 순서대로 이루어졌는가. | PASS / FAIL\n", + "상 / 중 / 하 |\n", + "| **M5. 민간 비행체 및 조류 오인 상황 주입을 통한 식별 정확도 평가** | 필수 | 주입된 오인·오경보 식별 성공률 85% 이상(12장 중 11장 이상), 실제 드론을 오인으로 잘못 버린 건 0건, 판정마다 교차 확인 근거가 기록되었는가. | 의심 신호를 놓치지 않고 교차 확인 근거가 관측 사실(소리·모습·방위)에 기반해 타당한가. | PASS / FAIL\n", + "상 / 중 / 하 |\n", + "| **M6. 전술 무전기를 활용한 지휘관 앞 즉각 보고(SALUTE 포맷) 인계 훈련** | 선택 | SALUTE 6항목과 인계 필수 항목 10개의 누락률 0%, 즉각 보고 후 인계까지 10분 이내, 인수자 퀴즈 5문항 중 4문항 이상 정답인가. | 상황 인식이 끊기지 않고 지휘관 또는 인수자의 질문에 명확히 답했는가. | PASS / FAIL\n", + "상 / 중 / 하 |\n", + "| **M7. 강한 바람 및 배경 소음 환경 모의 청취 한계 극복 스트레스 훈련** | 필수 | 비상 절차 전환 시간 1분 이내, 위험 조치 성공률 90% 이상, 지휘 공백 시 1분 안에 위임이 이루어지고 안전 수칙 위반 0건인가. | 소리가 믿을 수 없는 상황에서도 우선순위를 냉정하게 판단하고 역할 간 협업이 유지되었는가. | PASS / FAIL\n", + "상 / 중 / 하 |\n", + "| **M8. AAR을 통한 오청/오인 식별 사례 분석 및 청음/식별 요령 보완** | 선택 | 개선점 5건 이상, 원인 분석 항목마다 기록지 근거 100% 연결, 회의록에 개인 비난 표현 0건인가. | 비난 없이 사실 중심으로 토의했고 원인 규명이 객관적인가. | PASS / FAIL\n", + "상 / 중 / 하 |\n", + "| **M9. 피드백 반영 드론 청음 및 육안 식별 재평가 훈련 수행** | 필수 | 1차 대비 평균 조치 시간 30% 이상 단축, 오류 발생 건수 50% 이상 감소, 동일 평가표·동일 측정 방법을 사용했는가. | 청취와 식별 절차가 몸에 익어 관측자와 지휘자의 호흡이 맞고 말없이도 다음 행동이 이어지는가. | PASS / FAIL\n", + "상 / 중 / 하 |\n", + "| **M10. 드론 경보 청취·육안 식별 및 즉각 보고 훈련 교범 최종 완성** | 필수 | 5개 장 100% 완비, 교안에 적힌 성과 수치가 M4·M9 기록과 일치하고, 모의 검토 피드백 반영 내역이 기록되었는가. | 교재가 완성도 있고 시각 자료가 이해를 돕으며 다른 부대에 전파할 가치가 있는가. | PASS / FAIL\n", + "상 / 중 / 하 |\n", + "\n", + "| 이수 기준(권장): 필수 미션 7개가 모두 PASS이고 정성 판정에 '하'가 2개 이하일 것. 선택 미션(M4·M6·M8)은 수행 시 가점으로 반영하며 세부 배점은 교관이 정한다. |\n", + "| --- |\n", + "\n", + "| **평가관** | 소속·성명: ( ) 서명: ( ) 일자: ( ) |\n", + "| --- | --- |\n", + "\n", + "| **종합 의견** | ( ) |\n", + "| --- | --- |\n", + "\n" + ] + } + ], + "metadata": { + "colab": { + "provenance": [] + }, + "kernelspec": { + "display_name": "Python 3", + "name": "python3" + }, + "language_info": { + "name": "python" + } + }, + "nbformat": 4, + "nbformat_minor": 0 +} \ No newline at end of file diff --git a/uploads/TRN10/TRN10_workbook.ipynb b/uploads/TRN10/TRN10_workbook.ipynb new file mode 100644 index 0000000..58c133b --- /dev/null +++ b/uploads/TRN10/TRN10_workbook.ipynb @@ -0,0 +1,917 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# 📋 [TRN10] TCCC 모의환자 평가와 인계 정확도 훈련\n", + "### 실습 워크북 & SOP 가이드 (Google Colab / Jupyter)\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/TRN10/TRN10_workbook.ipynb)\n", + "\n", + "---\n", + "\n", + "## 📌 1. 프로젝트 개요 및 배경\n", + "\n", + "| 항목 | 내용 |\n", + "| :--- | :--- |\n", + "| **과정 분류** | 교육·훈련 (Level 2) |\n", + "| **학습 소요 시간** | 10개 미션 (5일 / 총 27.0시간) |\n", + "| **실습 유형** | 작전절차(SOP), 체크리스트, 모의훈련, AAR 사후검토 실습 |\n", + "\n", + "### 📖 프로젝트 목표\n", + "러시아-우크라이나 전쟁에서는 드론 감시와 간접 사격 때문에 부상자 후송이 크게 늦어지는 일이 잦았고, 그동안 현장에서 한 처치의 내용과 시각이 제대로 기록·인계되지 않으면 다음 단계 의료진이 같은 판단을 반복하거나 중요한 정보를 놓치게 된다는 교훈이 반복해서 제시되었다. 본 프로젝트에서는 교육용 가상 모의환자 카드와 인계 기록 양식을 이용해 TCCC 단계(Care Under Fire, Tactical Field Care)별 현장 체크리스트 작성, 교관 지도 하 마네킹 실습과 상황 주입 시나리오(MSEL) 설계, 교육용 전술 사상자 처치 카드 작성 Dry-run, 기록 오류 교차검증, 9항목 후송 요청과 MIST 인계, 야간·포격 모의 스트레스 훈련, AAR, 재평가를 역할을 나누어 수행한다. 처치 기술 자체보다 모의환자 평가 결과를 정확하게 기록하고 빠짐없이 인계하는 절차에 초점을 둔다. 최종 목표는 기록·인계의 정확도와 속도를 수치로 확인해 개선하고, 다른 부대 교관도 반복해 쓸 수 있는 표준 훈련 패키지를 만드는 것이다.\n", + "\n", + "> **⚠️ [교육용 가상 자료 원칙]**\n", + "> - 본 워크북의 모든 자료는 안전 규정에 따라 **교육용 가상 자료**로 구성되어 있습니다.\n", + "> - 실제 군사 기밀이나 부대 세부 인적·작전 정보는 포함되지 않습니다.\n", + "\n", + "---\n", + "\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 01. 전투현장 전술적 전투사상자 처치(TCCC) 가이드라인 및 모의환자 시나리오 분석\n", + "\n", + "### 📋 미션 배경 및 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "전술적 전투사상자 처치(TCCC) 가이드라인 및 모의환자 시나리오를 분석한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **러우 전훈에서 드러난 TCCC 적용 여건을 분석하고 모의환자 시나리오 3종을 검토해, 훈련에 필요한 현장 체크 항목과 역할 분담을 정의한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **실습자료의 가상 전훈 요약 카드와 모의환자 카드 3종을 읽고 부상 상황, 위협 단계, 후송 가능 시간, 기록해야 할 시각을 분석표로 정리하고 규격화하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 5개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `docs/trn10_baseline_assessment.md 및 훈련 프로파일` 형태의 결과물을 완성한다.\n", + "\n", + "### 📑 실습자료 및 작성 양식\n", + "전투현장 전술적 전투사상자 처치(TCCC) 가이드라인 및 모의환자 시나리오 분석**\n", + "\n", + "[제공 자료 1] 가상 전훈 요약 카드 - 공개 자료에서 반복 언급되는 경향을 교육용으로 일반화한 것이며 특정 수치·부대·지역 정보는 없다.\n", + "\n", + "| **번호** | **관찰된 경향(요약)** | **훈련 시사점** |\n", + "| --- | --- | --- |\n", + "| **L1** | 드론 감시·공격 위협 때문에 차량 후송이 늦어져 현장 관리 시간이 길어짐 | 장시간 현장 관리, 반복 재평가와 재평가 시각 기록 훈련 |\n", + "| **L2** | 여러 단계를 거쳐 인계되며 처치 시각·지혈대 적용 시각 정보가 빠짐 | 처치 카드 즉시 기재, 인계 시 시각 확인 절차 |\n", + "| **L3** | 야간·저조도에서 조명 사용이 제한됨 | 적색광 사용 규칙, 촉각·구두 복창 확인 |\n", + "| **L4** | 소형 드론·간접 사격으로 동시에 여러 명이 다침 | 역할 분담, 우선순위 선언, 보고 일원화 |\n", + "| **L5** | 전자전·통신 두절로 후송 요청이 늦어짐 | 표준 서식, 대체 보고 수단, 우선 전달 항목 |\n", + "\n", + "[제공 자료 2] 모의환자 카드 3종 [교육용가상자료] - 관찰 소견은 마네킹과 교관 구두 제시로 표현한다.\n", + "\n", + "| **항목** | **모의환자 A** | **모의환자 B** | **모의환자 C** |\n", + "| --- | --- | --- | --- |\n", + "| **가명** | 가상 병사 가 | 가상 병사 나 | 가상 병사 다 |\n", + "| **발생 상황** | 진지 이동 중 소형 드론 파편 | 간접 사격 후 참호 붕괴 | 차량 하차 중 파편 |\n", + "| **관찰 소견(교관 제시)** | 좌측 대퇴부 다량 출혈 모의(마네킹 출혈 장치) | 호흡 곤란 호소, 흉부 관통상 모의 | 안면부 부상 모의, 말하기 어려움 |\n", + "| **의식 반응(AVPU)** | A(명료) | V(말에 반응) | V(말에 반응) |\n", + "| **최초 활력징후(가상)** | 맥박 124회/분, 호흡 24회/분 | 맥박 118회/분, 호흡 32회/분 | 맥박 102회/분, 호흡 20회/분 |\n", + "| **위협 상황** | 적 드론 재접근 가능 | 간접 사격 간헐 지속 | 위협 없음(엄폐 완료) |\n", + "| **후송 가능 예상** | 90분 후 | 60분 후 | 30분 후 |\n", + "| **연계 실습 스테이션** | 지혈대 적용 | 긴장성 기흉 대응 | 기도 확보 |\n", + "| **교관 확인 기록 항목** | 최초 조치 시각 | 호흡 변화 재평가 시각 | 기도 상태 재평가 시각 |\n", + "\n", + "[작성 서식 1] 모의환자 분석표\n", + "\n", + "| **환자** | **부상 상황** | **위협·단계** | **후송까지** | **기록할 시각** | **훈련 포인트** |\n", + "| --- | --- | --- | --- | --- | --- |\n", + "| **A** | | | | | |\n", + "| **B** | | | | | |\n", + "| **C** | | | | | |\n", + "\n", + "[작성 서식 2] 역할 분담 매트릭스 - 칸에 R(책임) 또는 S(지원)를 적는다. 한 항목의 R은 1명만 둔다.\n", + "\n", + "| **체크 항목** | **처치자** | **보조자** | **기록자** | **통신자** | **관측자** |\n", + "| --- | --- | --- | --- | --- | --- |\n", + "| **위협 확인·엄폐 선언** | | | | | |\n", + "| **출혈 부위 확인·구두 보고** | | | | | |\n", + "| **조치 시각 기록** | | | | | |\n", + "| **활력징후 재평가 시각 기록** | | | | | |\n", + "| **상급 보고(1차 평가 후)** | | | | | |\n", + "| **처치 카드 부착 확인** | | | | | |\n", + "| **인계·상호 서명** | | | | | |\n", + "| **안전 수칙 준수 관찰** | | | | | |\n", + "| **(추가 항목)** | | | | | |\n", + "| **(추가 항목)** | | | | | |\n", + "\n", + "[작성 서식 3] 현장 체크 항목표(15개 이상)\n", + "\n", + "| **번호** | **체크 항목** | **단계(CUF/TFC/후송)** | **책임 역할** | **교관 대조** |\n", + "| --- | --- | --- | --- | --- |\n", + "| **1** | | | | |\n", + "| **2** | | | | |\n", + "| **3** | | | | |\n", + "| **4** | | | | |\n", + "| **5** | | | | |\n", + "| **6** | | | | |\n", + "| **7** | | | | |\n", + "| **8** | | | | |\n", + "| **9** | | | | |\n", + "| **10** | | | | |\n", + "| **11** | | | | |\n", + "| **12** | | | | |\n", + "| **13** | | | | |\n", + "| **14** | | | | |\n", + "| **15** | | | | |\n", + "\n", + "[작성 서식 4] 부대 특성별 환경 변수\n", + "\n", + "| **부대 유형** | **조명** | **이동 거리** | **통신** | **지원 인원** | **추가 체크 항목** |\n", + "| --- | --- | --- | --- | --- | --- |\n", + "| **야전** | | | | | |\n", + "| **기지** | | | | | |\n", + "| **의무** | | | | | |\n", + "\n", + "| **점검 항목** | **확인** |\n", + "| --- | --- |\n", + "| 모의환자 3종 분석표를 모두 채웠다 | ( ) |\n", + "| 현장 체크 항목을 15개 이상 정의했다 | ( ) |\n", + "| 모든 체크 항목에 R(책임) 역할이 1명씩 지정되었다(매핑 100%) | ( ) |\n", + "| 교관 체크리스트와 대조해 누락 항목을 보완했다(누락 0건) | ( ) |\n", + "| 야전·기지·의무 부대별 환경 변수를 작성했다 | ( ) |\n", + "\n", + "**\n", + "\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 02. Care Under Fire / Tactical Field Care 단계별 처치 체크리스트 작성\n", + "\n", + "### 📋 미션 배경 및 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "Care Under Fire / Tactical Field Care 단계별 처치 체크리스트를 작성한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **Care Under Fire와 Tactical Field Care 단계에서 무엇을 확인·기록·보고해야 하는지 단계별 체크리스트와 SOP 흐름도로 정리한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **위협 지속 여부로 단계를 나누는 기준을 적고, 단계별 행동 항목을 '확인한다·요청한다·기록한다·보고한다' 같은 행동 동사로 설계하고 작성하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 5개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `sop/trn10_field_checklist.md 및 SOP 흐름도` 형태의 결과물을 완성한다.\n", + "\n", + "### 📑 실습자료 및 작성 양식\n", + "Care Under Fire / Tactical Field Care 단계별 처치 체크리스트 작성**\n", + "\n", + "[제공 자료] 단계 구분 요약표 - 교육용 요약이며 세부 처치 방법은 교관 교범을 따른다.\n", + "\n", + "| **단계** | **상황 조건** | **우선 원칙(요약)** | **기록·보고 포인트** |\n", + "| --- | --- | --- | --- |\n", + "| **Care Under Fire(CUF)** | 적 위협이 계속되는 상태 | 위협 제거·엄폐가 우선, 생명을 위협하는 출혈 대응만 교범에 따라 최소한으로 | 조치 시각은 엄폐 후 즉시 기록, 구두로 먼저 알림 |\n", + "| **Tactical Field Care(TFC)** | 위협이 줄고 엄폐된 상태 | 교범의 평가 순서에 따라 체계적으로 평가, 재평가 반복 | 처치 카드 작성 시작, 활력징후와 재평가 시각 기록, 1차 보고 |\n", + "| **후송 준비** | 후송 수단이 정해진 상태 | 후송 요청, 환자 상태 유지·재평가 | 9항목 후송 요청, MIST 인계, 카드 부착 확인 |\n", + "\n", + "행동 동사 예시: 확인한다 / 선언한다 / 요청한다 / 기록한다 / 복창한다 / 보고한다 / 인계한다 / 서명한다. '적절히', '신속히'처럼 기준이 없는 말은 쓰지 않고 시간·횟수·대상을 적는다.\n", + "\n", + "[작성 서식 1] 단계별 체크리스트\n", + "\n", + "| **단계** | **번호** | **행동 항목(행동 동사로)** | **안전·보안 조치** | **기록** | **교범 참조** |\n", + "| --- | --- | --- | --- | --- | --- |\n", + "| **CUF** | 1 | | | | |\n", + "| **CUF** | 2 | | | | |\n", + "| **CUF** | 3 | | | | |\n", + "| **TFC** | 4 | | | | |\n", + "| **TFC** | 5 | | | | |\n", + "| **TFC** | 6 | | | | |\n", + "| **TFC** | 7 | | | | |\n", + "| **후송 준비** | 8 | | | | |\n", + "| **후송 준비** | 9 | | | | |\n", + "| **후송 준비** | 10 | | | | |\n", + "\n", + "필수 안전·보안 조치(각 단계에 최소 1개): 엄폐·위협 확인 / 인원 확인 / 감염 방지(장갑) / 처치 카드에 부대명·위치 미기재 / 조명 통제 / 이상 증상 시 의무요원 보고\n", + "\n", + "[작성 서식 2] SOP 흐름도 틀 - 빈칸을 채워 흐름도로 옮긴다.\n", + "\n", + "| [부상 발생] -> [위협 지속?] --예--> [CUF: ( )] -> [엄폐 완료?]\n", + " \\| \\|\n", + " 아니오 예\n", + " v v\n", + " [TFC: ( )] -> [처치 카드 기록] -> [1차 보고(3분 이내)]\n", + " v\n", + " [후송 요청(9항목)] -> [MIST 인계] -> [상호 서명] |\n", + "| --- |\n", + "\n", + "[작성 서식 3] 교차 검토표\n", + "\n", + "| **번호** | **지적 위치(단계·번호)** | **누락/모호 내용** | **수정안** | **반영 확인** |\n", + "| --- | --- | --- | --- | --- |\n", + "| **1** | | | | |\n", + "| **2** | | | | |\n", + "| **3** | | | | |\n", + "| **4** | | | | |\n", + "| **5** | | | | |\n", + "| **6** | | | | |\n", + "\n", + "[작성 서식 4] 휴대 요약 카드(Pocket Guide, 긴급 모드)\n", + "\n", + "| **구분** | **내용(한 줄씩, 30초 안에 읽을 분량)** |\n", + "| --- | --- |\n", + "| **CUF 3가지** | |\n", + "| **TFC 3가지** | |\n", + "| **보고 순서** | |\n", + "| **인계 필수** | |\n", + "| **금지 사항** | |\n", + "\n", + "| **점검 항목** | **확인** |\n", + "| --- | --- |\n", + "| CUF·TFC·후송 준비 단계별 행동 항목을 모두 행동 동사로 적었다 | ( ) |\n", + "| 각 단계에 필수 안전·보안 조치를 1개 이상 넣었다(포함률 100%) | ( ) |\n", + "| 처치 세부 방법·약물은 쓰지 않고 교범 참조로 표시했다 | ( ) |\n", + "| 교차 검토 지적 사항을 모두 수정했다 | ( ) |\n", + "| 휴대 요약 카드가 한 장이며 30초 안에 읽힌다 | ( ) |\n", + "\n", + "**\n", + "\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 03. 모의 지혈대(Tourniquet) 적용, 기도 확보, 긴장성 기흉 처치 실습\n", + "\n", + "### 📋 미션 배경 및 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "지혈대 적용, 기도 확보, 긴장성 기흉 처치 실습을 수행한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **교관·의무요원 지도 하에 훈련용 마네킹으로 지혈대 적용, 기도 확보, 긴장성 기흉 대응 실습 스테이션을 운영하고, 실습 중 상황을 주입하는 시간대별 시나리오(MSEL)를 만든다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **3개 스테이션을 순환하며 교관 시범과 지도에 따라 실습하고, 관측자는 시작·종료 시각과 교관 지적 사항만 기록한다(처치 절차는 교관 교범에 따른다) 과정을 진행하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 5개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `scenarios/trn10_msel_scenario.json(md)` 형태의 결과물을 완성한다.\n", + "\n", + "### 📑 실습자료 및 작성 양식\n", + "모의 지혈대(Tourniquet) 적용, 기도 확보, 긴장성 기흉 처치 실습**\n", + "\n", + "| 이 미션의 처치 실습은 교관 시범과 지도에 따라 훈련용 마네킹으로만 한다. 본 자료는 처치 절차를 제공하지 않으며, 관측자는 시각과 교관 지적 사항만 기록한다. |\n", + "| --- |\n", + "\n", + "[제공 자료 1] 스테이션 운영표 [교육용가상자료]\n", + "\n", + "| **스테이션** | **실습 주제** | **장비** | **지도 인원** | **시간** |\n", + "| --- | --- | --- | --- | --- |\n", + "| **S1** | 지혈대 적용(모의환자 A) | 마네킹(출혈 장치), 모의 지혈대 | 교관 1 | 20분 |\n", + "| **S2** | 긴장성 기흉 대응 인지·보고(모의환자 B) | 흉부 마네킹, 모의 처치 키트 | 의무요원 1 | 20분 |\n", + "| **S3** | 기도 확보(모의환자 C) | 기도 마네킹, 모의 기도 유지 장비 | 교관 1 | 20분 |\n", + "\n", + "[작성 서식 1] 스테이션 실습 기록지\n", + "\n", + "| **교육생** | **스테이션** | **시작** | **종료** | **교관 지적 사항** | **교관 확인 서명** |\n", + "| --- | --- | --- | --- | --- | --- |\n", + "| | | | | | |\n", + "| | | | | | |\n", + "| | | | | | |\n", + "| | | | | | |\n", + "| | | | | | |\n", + "| | | | | | |\n", + "\n", + "[제공 자료 2] MSEL 작성 예시(3건) - 나머지는 조별로 작성한다.\n", + "\n", + "| **번호** | **투입 시각** | **주입 사건** | **구분** | **대상 역할** | **기대 행동** |\n", + "| --- | --- | --- | --- | --- | --- |\n", + "| **1** | +00:00 | 모의환자 A 발견, 적 드론 소리(녹음) 재생 | 정상 | 처치자 | 위협 확인 후 엄폐 선언 |\n", + "| **2** | +03:00 | 엄폐 완료 통보 | 정상 | 처치자·기록자 | TFC 전환 선언, 카드 기록 시작 |\n", + "| **3** | +07:00 | 무전 두절(통신자 호출 무응답) | 비정상 | 통신자 | 대체 보고 수단 사용, 두절 시각 기록 |\n", + "\n", + "[작성 서식 2] MSEL 시나리오표(10건 이상, 정상 6 : 비정상 4)\n", + "\n", + "| **번호** | **투입 시각** | **주입 사건** | **구분** | **대상 역할** | **기대 행동** |\n", + "| --- | --- | --- | --- | --- | --- |\n", + "| **1** | | | | | |\n", + "| **2** | | | | | |\n", + "| **3** | | | | | |\n", + "| **4** | | | | | |\n", + "| **5** | | | | | |\n", + "| **6** | | | | | |\n", + "| **7** | | | | | |\n", + "| **8** | | | | | |\n", + "| **9** | | | | | |\n", + "| **10** | | | | | |\n", + "\n", + "[제공 자료 3] 돌발 변수 카드(2개 이상 선택): 통신 두절 / 추가 부상자 1명 발생 / 모의 지혈대 1개 부족 / 기록자 이탈(다른 임무) / 조명 고장 / 후송 시각 30분 연기 / 인수자 도착 지연\n", + "\n", + "[작성 서식 3] 워크스루 점검표\n", + "\n", + "| **사건 번호** | **흐름 어색함** | **기대 행동 불분명** | **대상 역할 없음** | **수정 내용** |\n", + "| --- | --- | --- | --- | --- |\n", + "| | | | | |\n", + "| | | | | |\n", + "| | | | | |\n", + "| | | | | |\n", + "| | | | | |\n", + "\n", + "| **점검 항목** | **확인** |\n", + "| --- | --- |\n", + "| 3개 스테이션 실습을 교관 지도 하에 마치고 교관 서명을 받았다 | ( ) |\n", + "| 주입 사건을 10건 이상 작성했다 | ( ) |\n", + "| 정상:비정상 비율이 6:4이다 | ( ) |\n", + "| 모든 사건에 투입 시각·대상 역할·기대 행동이 있다 | ( ) |\n", + "| 돌발 변수를 2개 이상 넣고 워크스루 지적 사항을 반영했다 | ( ) |\n", + "\n", + "**\n", + "\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 04. 모의환자 상태 평가 및 전술 사상자 처치 카드(DD Form 1380) 작성(Dry-run)\n", + "\n", + "### 📋 미션 배경 및 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "모의환자 상태 평가 및 전술 사상자 처치 카드(DD Form 1380)를 작성(Dry-run)한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **모의환자 상태를 평가하고 DD Form 1380 형식을 참고한 교육용 전술 사상자 처치 카드를 작성하는 1차 Dry-run을 실시한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **처치조가 모의환자 카드의 관찰 소견을 확인하는 동안 기록자는 교육용 처치 카드에 부상 부위, 조치 시각, 활력징후, 실시한 조치를 적고 통신자는 1차 평가 완료 후 3분 안에 상급 보고를 수행하고 검증하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 5개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `logs/trn10_dryrun_execution_log.md 및 작성된 체크리스트` 형태의 결과물을 완성한다.\n", + "\n", + "### 📑 실습자료 및 작성 양식\n", + "모의환자 상태 평가 및 전술 사상자 처치 카드(DD Form 1380) 작성(Dry-run)**\n", + "\n", + "[제공 자료 1] 교육용 전술 사상자 처치 카드 양식 - DD Form 1380 형식을 참고해 교육용으로 단순화한 가상 양식이다. 실제 양식은 부대 의무 규정을 따른다.\n", + "\n", + "| **구분** | **기재란** |\n", + "| --- | --- |\n", + "| **카드 번호 / 작성 일시** | ( ) / D일 ( : ) |\n", + "| **성명(가명) / 식별번호(가상)** | ( ) / ( ) |\n", + "| **부상 일시 / 부상 기전** | ( : ) / 파편 · 폭풍 · 추락 · 기타( ) |\n", + "| **부상 부위** | 머리 · 목 · 가슴 · 배 · 좌팔 · 우팔 · 좌다리 · 우다리 · 기타( ) |\n", + "| **지혈대** | 부위( ) / 적용 시각( : ) / 적용자( ) |\n", + "| **활력징후 1차** | 시각( : ) 맥박( )회/분 호흡( )회/분 의식(A·V·P·U) |\n", + "| **활력징후 2차** | 시각( : ) 맥박( )회/분 호흡( )회/분 의식(A·V·P·U) |\n", + "| **실시한 조치(모의)** | 출혈 통제 · 기도 · 호흡 · 보온 · 기타( ) / 각 시각( : ) |\n", + "| **투약 기록(모의)** | 약물(모의약품 가명)( ) / 시각( : ) / 기록자( ) - 용량은 기재하지 않음 |\n", + "| **알레르기** | 없음 · 있음( ) |\n", + "| **기록자 / 서명** | ( ) / ( ) |\n", + "\n", + "필수 기재란: 카드 번호, 성명(가명), 부상 일시, 부상 부위, 활력징후 1차, 실시한 조치와 시각, 기록자 서명(7개)\n", + "\n", + "[제공 자료 2] 시간 측정 기준: 보고 지연시간 = 1차 평가 완료 선언 시각부터 통신자의 상급 보고 완료(복창 확인) 시각까지. SOP 준수율 = 순서대로 수행한 단계 수 / 전체 단계 수 x 100.\n", + "\n", + "[작성 서식 1] Dry-run 수행 기록지(관측자 작성)\n", + "\n", + "| **SOP 단계** | **계획 순서** | **실제 시각** | **순서 준수(O/X)** | **비고(혼선·지연)** |\n", + "| --- | --- | --- | --- | --- |\n", + "| **위협 확인·엄폐 선언** | 1 | | | |\n", + "| **출혈 확인·구두 보고** | 2 | | | |\n", + "| **TFC 전환 선언** | 3 | | | |\n", + "| **처치 카드 기록 시작** | 4 | | | |\n", + "| **활력징후 1차 기록** | 5 | | | |\n", + "| **1차 평가 완료 선언** | 6 | | | |\n", + "| **상급 보고 완료** | 7 | | | |\n", + "| **재평가·2차 기록** | 8 | | | |\n", + "| **카드 부착 확인** | 9 | | | |\n", + "| **인계 준비 완료** | 10 | | | |\n", + "\n", + "| **지표** | **계산** | **결과** | **목표** | **달성** |\n", + "| --- | --- | --- | --- | --- |\n", + "| **SOP 단계 준수율** | ( )/10 x 100 | ( )% | 80% 이상 | ( ) |\n", + "| **보고 지연시간** | ( : ) - ( : ) | ( )분 ( )초 | 3분 이내 | ( ) |\n", + "| **필수 기재란 누락** | 7개 중 누락 수 | ( )건 | 0건 | ( ) |\n", + "\n", + "[작성 서식 2] 병목·혼선 구간 기록표\n", + "\n", + "| **번호** | **발생 구간(SOP 단계)** | **현상** | **관련 역할** | **개선 아이디어** |\n", + "| --- | --- | --- | --- | --- |\n", + "| **1** | | | | |\n", + "| **2** | | | | |\n", + "| **3** | | | | |\n", + "\n", + "| **점검 항목** | **확인** |\n", + "| --- | --- |\n", + "| Dry-run을 끝까지 실시하고 참여자별 기록지를 모았다 | ( ) |\n", + "| SOP 단계 준수율 80% 이상이다 | ( ) |\n", + "| 보고 지연시간이 3분 이내이다 | ( ) |\n", + "| 처치 카드 필수 기재란 7개에 누락이 없다 | ( ) |\n", + "| 병목·혼선 구간을 2개 이상 기록했다 | ( ) |\n", + "\n", + "**\n", + "\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 05. 잘못된 약물 투여 및 활력 징후 오기재 등 처치 오류 식별 훈련\n", + "\n", + "### 📋 미션 배경 및 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "처치 오류(약물 오투여 등) 주입 시 식별 및 시정 훈련을 수행한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **처치 카드와 인계 기록에 일부러 넣은 약물 투여 기록 오류와 활력징후 오기재를 찾아내고, 교차확인 후 시정·보고하는 훈련을 한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **교관이 오류를 섞어 둔 가상 처치 카드·인계 기록·관측 기록 12개 대조 항목을 받아 문서끼리 비교 수행하고 검증하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 5개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `docs/trn10_cross_verification_sheet.md` 형태의 결과물을 완성한다.\n", + "\n", + "### 📑 실습자료 및 작성 양식\n", + "잘못된 약물 투여 및 활력 징후 오기재 등 처치 오류 식별 훈련**\n", + "\n", + "[제공 자료] 대조 항목 12개 [교육용가상자료] - 처치 카드와 인계 기록·관측 기록을 비교해 오류인지 정상인지 판정한다. 약물의 적정성은 판단하지 않고 기록의 일관성만 본다.\n", + "\n", + "| **번호** | **카드** | **확인 항목** | **처치 카드 기재** | **인계·관측 기록 기재** |\n", + "| --- | --- | --- | --- | --- |\n", + "| **5-1** | A | 투약 기록 | 모의약품 가 / 시각 공란 / 기록자 공란 | 인계: 모의약품 가 투여 |\n", + "| **5-2** | A | 투약 여부 | 모의약품 가 기재 | 인계: 투약 없음 |\n", + "| **5-3** | B | 활력징후 14:20 | 맥박 26회/분, 호흡 112회/분 | 관측: 14:20 맥박 측정 후 호흡 측정 |\n", + "| **5-4** | B | 지혈대 시각 | 지혈대 적용 14:05, 부상 일시 14:10 | 관측: 부상 14:10, 지혈대 14:13 |\n", + "| **5-5** | C | 의식 수준 14:30 | A(명료) | 관측: 14:30 불러도 반응 없고 통증에만 반응 |\n", + "| **5-6** | C | 알레르기·투약 | 알레르기: 모의약품 나 / 투약: 모의약품 나 14:40 | 인계: 모의약품 나 투여 |\n", + "| **5-7** | D | 맥박 단위 | 맥박 96회/초 | 관측: 14:35 맥박 측정 |\n", + "| **5-8** | D | 활력징후 변화 | 1차 14:20 맥박 110 / 2차 14:50 맥박 96 | 관측: 2회 측정 확인 |\n", + "| **5-9** | E | 지혈대 부위 수 | 좌·우 다리 2곳, 각 14:12·14:15 | 인계: 지혈대 2곳, 시각 동일 |\n", + "| **5-10** | E | 투약 기록 | 모의약품 가 / 14:25 / 기록자 가상 병사 마 | 인계: 모의약품 가 14:25 |\n", + "| **5-11** | F | 성명 | 가상 병사 바 | 인계: 가상 병사 사 |\n", + "| **5-12** | F | 재평가 간격 | 14:20, 14:35, 14:40 기록(교관 재평가 지시 14:38) | 관측: 3회 측정 |\n", + "\n", + "[작성 서식 1] 교차검증 매트릭스\n", + "\n", + "| **번호** | **판정(오류/정상)** | **대조한 문서** | **판정 근거** | **보고 대상·우선순위** |\n", + "| --- | --- | --- | --- | --- |\n", + "| **5-1** | | | | |\n", + "| **5-2** | | | | |\n", + "| **5-3** | | | | |\n", + "| **5-4** | | | | |\n", + "| **5-5** | | | | |\n", + "| **5-6** | | | | |\n", + "| **5-7** | | | | |\n", + "| **5-8** | | | | |\n", + "| **5-9** | | | | |\n", + "| **5-10** | | | | |\n", + "| **5-11** | | | | |\n", + "| **5-12** | | | | |\n", + "\n", + "| **지표** | **계산** | **결과** | **목표** |\n", + "| --- | --- | --- | --- |\n", + "| **식별 성공률** | 맞게 찾은 오류 수 ( ) / 주입 오류 8 x 100 | ( )% | 85% 이상(7건 이상) |\n", + "| **오표시** | 정상 4건 중 오류로 표시한 수 | ( )건 | 1건 이하 |\n", + "\n", + "[작성 서식 2] 불일치 보고 경로\n", + "\n", + "| **우선순위** | **불일치 유형** | **보고 대상** | **보고 시한** | **보고 문장(SBAR 한 줄)** |\n", + "| --- | --- | --- | --- | --- |\n", + "| **1(즉시)** | 알레르기·투약 충돌, 신원 불일치 | | | |\n", + "| **2** | 투약 기록 누락·문서 간 불일치 | | | |\n", + "| **3** | 활력징후 오기재·시간 순서 오류 | | | |\n", + "\n", + "| [교관용 정답표 - 조별 판정 후 공개] 오류 8건: 5-1(시각·기록자 누락), 5-2(카드와 인계 불일치), 5-3(맥박·호흡 칸 뒤바뀜 의심), 5-4(지혈대 시각이 부상 시각보다 앞섬, 관측 기록과 불일치), 5-5(의식 수준 불일치), 5-6(알레르기 기재 약물 투약 기록 - 즉시 의무요원 확인), 5-7(단위 오기), 5-11(신원 불일치). 정상 4건: 5-8(재평가에 따른 수치 변화), 5-9(부위·시각 일치), 5-10(필수 정보 모두 일치), 5-12(교관 지시에 따른 추가 재평가). |\n", + "| --- |\n", + "\n", + "| **점검 항목** | **확인** |\n", + "| --- | --- |\n", + "| 12개 항목 모두 판정하고 근거와 대조 문서를 적었다 | ( ) |\n", + "| 주입 오류 8건 중 7건 이상을 찾았다(85% 이상) | ( ) |\n", + "| 정상 항목 오표시가 1건 이하이다 | ( ) |\n", + "| 약물 적정성 판단 없이 기록 불일치만 판정했다 | ( ) |\n", + "| 불일치 유형별 보고 대상·시한을 정했다 | ( ) |\n", + "\n", + "**\n", + "\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 06. 후송 헬기/의무대(MEDEVAC) 인계를 위한 9-Line MIST 보고 실습\n", + "\n", + "### 📋 미션 배경 및 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "후송 헬기/의무대 인계를 위한 9-Line MIST 보고 실습을 진행한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **후송 헬기·의무대에 넘기기 위한 9항목 후송 요청(9-Line)과 MIST 인계 보고를 실습해 빠짐없고 빠른 인계 절차를 만든다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **교육용 9항목 후송 요청 서식과 MIST 인계 서식에 가상 값을 채워 통신자가 보고하고, 의무대 역할의 인수자가 받아 적는다 과정을 진행하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 5개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `reports/trn10_handover_protocol.md 및 브리핑 녹취/기록지` 형태의 결과물을 완성한다.\n", + "\n", + "### 📑 실습자료 및 작성 양식\n", + "후송 헬기/의무대(MEDEVAC) 인계를 위한 9-Line MIST 보고 실습**\n", + "\n", + "[제공 자료 1] 교육용 9항목 후송 요청 서식 - 항목 구성을 교육용으로 요약한 것이며 좌표·호출부호는 가상 값이다.\n", + "\n", + "| **항목** | **내용** | **가상 예시** | **작성란** |\n", + "| --- | --- | --- | --- |\n", + "| **1** | 픽업 위치 | 가상 격자 코드 가상-AB-1234 | |\n", + "| **2** | 통신 수단·호출부호 | 가상 호출부호 한빛-의무 | |\n", + "| **3** | 긴급도별 환자 수 | 긴급 1, 우선 1, 일반 1 | |\n", + "| **4** | 필요 특수 장비 | 없음 / 들것 추가 | |\n", + "| **5** | 환자 수(들것/보행) | 들것 2, 보행 1 | |\n", + "| **6** | 픽업 지점 보안 상태 | 적 활동 없음 / 가능성 있음 | |\n", + "| **7** | 픽업 지점 표시 방법 | 연막 미사용, 적색광 신호(가상) | |\n", + "| **8** | 환자 신분 구분 | 아군 3 | |\n", + "| **9** | 지형·장애물 또는 오염 여부 | 북쪽 전선 있음(가상) | |\n", + "\n", + "[작성 서식 1] MIST 인계 서식\n", + "\n", + "| **구분** | **의미** | **모의환자 A** | **모의환자 B** |\n", + "| --- | --- | --- | --- |\n", + "| **M** | 부상 기전(무엇에 의해) | | |\n", + "| **I** | 부상 부위·형태 | | |\n", + "| **S** | 징후(활력징후·의식, 측정 시각) | | |\n", + "| **T** | 실시한 조치와 시각(지혈대 포함) | | |\n", + "\n", + "[작성 서식 2] 인계 품질 체크리스트(인계자·인수자 공동 확인)\n", + "\n", + "| **번호** | **필수 항목** | **인계자** | **인수자** |\n", + "| --- | --- | --- | --- |\n", + "| **1** | 환자 가명·식별번호 일치 | | |\n", + "| **2** | 부상 일시 | | |\n", + "| **3** | 부상 기전 | | |\n", + "| **4** | 부상 부위 | | |\n", + "| **5** | 지혈대 부위·적용 시각 | | |\n", + "| **6** | 최근 활력징후와 측정 시각 | | |\n", + "| **7** | 의식 수준 변화 | | |\n", + "| **8** | 실시한 조치·시각 | | |\n", + "| **9** | 투약 기록(모의약품 가명·시각)·알레르기 | | |\n", + "| **10** | 처치 카드 부착 확인 | | |\n", + "\n", + "| **구분** | **기재** |\n", + "| --- | --- |\n", + "| **인계 시작 / 종료 시각** | ( : ) / ( : ) 소요 ( )분 ( )초 / 목표 10분 이내 |\n", + "| **누락 항목 수** | ( )개 / 10개 누락률 ( )% |\n", + "| **인계자 서명 / 인수자 서명** | ( ) / ( ) |\n", + "\n", + "[제공 자료 2] 인수자 돌발 질문 카드(평가관이 3개 선택): Q1 지혈대 적용 시각은? / Q2 마지막 활력징후 측정 시각은? / Q3 의식 수준이 바뀐 시점은? / Q4 투약 기록이 있는가, 시각은? / Q5 알레르기가 있는가? / Q6 처치 카드는 어디에 부착되어 있는가?\n", + "\n", + "[작성 서식 3] SBAR 인계 브리핑 템플릿\n", + "\n", + "| **구분** | **내용** |\n", + "| --- | --- |\n", + "| **S 상황(Situation)** | ( ) |\n", + "| **B 배경(Background)** | ( ) |\n", + "| **A 평가(Assessment)** | ( ) |\n", + "| **R 요청(Recommendation)** | ( ) |\n", + "\n", + "| **점검 항목** | **확인** |\n", + "| --- | --- |\n", + "| 9항목 요청서의 모든 항목을 가상 값으로 채웠다 | ( ) |\n", + "| MIST 인계서가 모의환자 2명 모두 완성되었다 | ( ) |\n", + "| 인계 필수 항목 10개 누락 0건(누락률 0%)이다 | ( ) |\n", + "| 인계 소요가 10분 이내이다 | ( ) |\n", + "| 돌발 질문 3개에 인수자가 모두 정답을 말했다 | ( ) |\n", + "| 인계자·인수자 상호 서명을 마쳤다 | ( ) |\n", + "\n", + "**\n", + "\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 07. 포격 지속 및 야간 암흑 환경 모의 TCCC 처치 스트레스 훈련\n", + "\n", + "### 📋 미션 배경 및 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "포격 지속 및 야간 암흑 환경 모의 TCCC 처치 스트레스 훈련을 실시한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **야간 저조도와 포격 음향이 계속되는 모의 환경에서 TCCC 단계 전환과 우선순위 판단을 유지하는 스트레스 훈련을 한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **안전통제관 통제 아래 조명을 낮추고 녹음된 포격 음향을 재생한 훈련장에서 스트레스 사건 카드 6건을 주입한다(실제 폭음·연막·화기 사용 금지) 과정을 진행하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 5개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `logs/trn10_stress_drill_results.md` 형태의 결과물을 완성한다.\n", + "\n", + "### 📑 실습자료 및 작성 양식\n", + "포격 지속 및 야간 암흑 환경 모의 TCCC 처치 스트레스 훈련**\n", + "\n", + "| [안전 통제] 실제 폭음·연막·화기·공포탄은 사용하지 않는다. 음향은 녹음 재생(음량 상한은 안전통제관이 정함), 조명은 적색광 손전등과 실내 조도 조절만 쓴다. 안전통제관의 '중지' 구호 또는 호루라기 3회에 즉시 모든 행동을 멈춘다. 이동 경로의 걸림 요소는 사전에 제거한다. |\n", + "| --- |\n", + "\n", + "[제공 자료] 스트레스 사건 카드 6건 [교육용가상자료]\n", + "\n", + "| **번호** | **주입 사건** | **환경** | **기대 행동** | **위험 조치(판정 대상)** |\n", + "| --- | --- | --- | --- | --- |\n", + "| **7-1** | TFC 중 포격 음향 재개 | 저조도+음향 | CUF 전환 선언 | 엄폐 이동, 인원 확인 |\n", + "| **7-2** | 조명 완전 소등 30초 | 암흑 | 적색광 사용 규칙 적용, 구두 복창 | 처치 카드 보호, 인원 확인 |\n", + "| **7-3** | 추가 부상자 발생(모의환자 D) | 저조도+음향 | 우선순위 재선언, 역할 재배치 | 엄폐 유지, 보고 |\n", + "| **7-4** | 조장 부상 처리(지휘 공백) | 저조도 | 위임 순서에 따라 차상위자 임무 인수 선언 | 인원 확인, 보고 |\n", + "| **7-5** | 통신 두절 | 저조도+음향 | 대체 보고 수단 사용 | 처치 카드 보호, 두절 시각 기록 |\n", + "| **7-6** | 위협 종료 통보 | 저조도 | TFC 재전환, 재평가 기록 재개 | 인원 확인 |\n", + "\n", + "[작성 서식 1] 결과 기록표(관측자 작성) - 전환 시간 = 주입 시각부터 조장(또는 인수자)의 전환·인수 선언 시각까지\n", + "\n", + "| **사건** | **주입 시각** | **선언 시각** | **전환 시간** | **위험 조치1** | **위험 조치2** | **안전 위반** |\n", + "| --- | --- | --- | --- | --- | --- | --- |\n", + "| **7-1** | | | | O / X | O / X | 유 / 무 |\n", + "| **7-2** | | | | O / X | O / X | 유 / 무 |\n", + "| **7-3** | | | | O / X | O / X | 유 / 무 |\n", + "| **7-4** | | | | O / X | O / X | 유 / 무 |\n", + "| **7-5** | | | | O / X | O / X | 유 / 무 |\n", + "| **7-6** | | | | O / X | O / X | 유 / 무 |\n", + "\n", + "| **지표** | **계산** | **결과** | **목표** |\n", + "| --- | --- | --- | --- |\n", + "| **최대 전환 시간** | 6건 중 가장 긴 시간 | ( )초 | 60초 이내 |\n", + "| **위험 조치 성공률** | 성공 수 ( ) / 12 x 100 | ( )% | 90% 이상 |\n", + "| **안전 수칙 위반** | 위반 건수 | ( )건 | 0건 |\n", + "| **지휘 공백 위임 시간** | 7-4 전환 시간 | ( )초 | 60초 이내 |\n", + "\n", + "[작성 서식 2] 임무 위임 순서표\n", + "\n", + "| **순서** | **직책(역할)** | **인수할 임무** | **인수 선언 문장** |\n", + "| --- | --- | --- | --- |\n", + "| **1** | 조장 | | |\n", + "| **2** | | | |\n", + "| **3** | | | |\n", + "\n", + "| **점검 항목** | **확인** |\n", + "| --- | --- |\n", + "| 안전 교육과 중지 신호를 확인한 뒤 시작했다 | ( ) |\n", + "| 사건 6건 모두 주입·선언 시각을 기록했다 | ( ) |\n", + "| 모든 전환 시간이 60초 이내이다 | ( ) |\n", + "| 위험 조치 성공률이 90% 이상이다 | ( ) |\n", + "| 안전 수칙 위반이 0건이다 | ( ) |\n", + "| 지휘 공백 시 60초 안에 임무를 인수했다 | ( ) |\n", + "\n", + "**\n", + "\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 08. AAR 사후검토를 통한 처치 누락 및 인계 보고 오차 원인 분석\n", + "\n", + "### 📋 미션 배경 및 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "AAR 사후검토를 통해 처치 누락 및 인계 보고 오차 원인을 분석한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **AAR 사후검토로 처치 기록 누락과 인계 보고 오차의 원인을 사실 중심으로 분석한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **Dry-run·스트레스 훈련 기록, 관측 기록, 참여자 진술을 시간순으로 맞춰 보고 서로 다른 부분을 팩트체크 수행하고 검증하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 5개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `aar/trn10_aar_session_minutes.md 및 오류 분석 매트릭스` 형태의 결과물을 완성한다.\n", + "\n", + "### 📑 실습자료 및 작성 양식\n", + "AAR 사후검토를 통한 처치 누락 및 인계 보고 오차 원인 분석**\n", + "\n", + "[제공 자료 1] AAR 4단계 진행표\n", + "\n", + "| **단계** | **질문** | **진행 규칙** |\n", + "| --- | --- | --- |\n", + "| **1** | 무엇을 계획했나? | SOP·MSEL 기준을 먼저 확인한다 |\n", + "| **2** | 실제로 무엇이 일어났나? | 기록(시각)을 기준으로 말하고, 진술은 기록과 대조한다 |\n", + "| **3** | 왜 차이가 났나? | 사람이 아닌 절차·양식·환경·숙달 원인으로 분류한다 |\n", + "| **4** | 무엇을 유지·개선할까? | 개선점마다 담당·기한·근거를 붙인다 |\n", + "\n", + "[제공 자료 2] 가상 1차 훈련 타임라인 발췌 [교육용가상자료] - 자기 조 기록과 함께 사용한다.\n", + "\n", + "| **시각** | **관측 기록** | **참여자 진술** |\n", + "| --- | --- | --- |\n", + "| **14:10** | 모의환자 A 발견, 엄폐 선언 | 처치자: 발견 즉시 엄폐 선언 |\n", + "| **14:13** | 지혈대 적용(모의) | 처치자: 14:11쯤 적용 |\n", + "| **14:13~14:18** | 처치 카드 기록 없음 | 기록자: 처치자 옆에서 기록 중이었음 |\n", + "| **14:19** | 처치 카드 지혈대 시각 기재 '14:15' | 기록자: 구두 보고 듣고 적음 |\n", + "| **14:24** | 상급 보고 완료(1차 평가 완료 14:19) | 통신자: 호출부호 확인하느라 지연 |\n", + "| **14:40** | 인계 시 투약 기록 누락 | 인수자: 카드에 투약란이 뒷면이라 못 봄 |\n", + "\n", + "[작성 서식 1] 기록-진술 불일치 확인표\n", + "\n", + "| **번호** | **시각** | **기록 내용** | **진술 내용** | **확인 결과(사실)** |\n", + "| --- | --- | --- | --- | --- |\n", + "| **1** | | | | |\n", + "| **2** | | | | |\n", + "| **3** | | | | |\n", + "| **4** | | | | |\n", + "\n", + "[작성 서식 2] 오류 분석 매트릭스\n", + "\n", + "| **번호** | **문제(현상)** | **사실 근거(기록 위치)** | **원인 분류** | **개선점** | **담당·기한** |\n", + "| --- | --- | --- | --- | --- | --- |\n", + "| **1** | | | | | |\n", + "| **2** | | | | | |\n", + "| **3** | | | | | |\n", + "| **4** | | | | | |\n", + "| **5** | | | | | |\n", + "| **6** | | | | | |\n", + "\n", + "원인 분류: 절차(SOP 순서·누락) / 양식(칸 위치·기재란 부족) / 환경(조명·소음·거리) / 숙달(반복 부족) / 통신(수단·호출부호)\n", + "\n", + "[작성 서식 3] 1차 SOP 수정안\n", + "\n", + "| **대상(체크리스트·카드·인계 템플릿)** | **수정 전** | **수정 후** | **근거(분석 번호)** |\n", + "| --- | --- | --- | --- |\n", + "| | | | |\n", + "| | | | |\n", + "| | | | |\n", + "| | | | |\n", + "\n", + "| **점검 항목** | **확인** |\n", + "| --- | --- |\n", + "| AAR 4단계를 순서대로 진행했다 | ( ) |\n", + "| 기록과 진술이 다른 부분을 사실로 확인했다 | ( ) |\n", + "| 개선점을 5건 이상 도출했다 | ( ) |\n", + "| 모든 원인 항목에 근거 기록 위치를 적었다(100%) | ( ) |\n", + "| 회의록에 개인 비난 표현이 없다 | ( ) |\n", + "| SOP 수정안을 작성했다 | ( ) |\n", + "\n", + "**\n", + "\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 09. 피드백을 반영한 TCCC 모의환자 재평가 및 인계 정확도 향상 검증\n", + "\n", + "### 📋 미션 배경 및 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "피드백을 반영하여 모의환자 재평가 및 인계 훈련을 재실행한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **AAR 수정안을 반영해 같은 모의환자 조건으로 재평가하고 인계 정확도와 속도의 향상을 정량 비교한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **1차와 같은 모의환자 카드와 평가표로 재실행하되 수정된 체크리스트·처치 카드·인계 템플릿을 적용 수행하고 검증하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 5개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `eval/trn10_rerun_performance_comparison.md` 형태의 결과물을 완성한다.\n", + "\n", + "### 📑 실습자료 및 작성 양식\n", + "피드백을 반영한 TCCC 모의환자 재평가 및 인계 정확도 향상 검증**\n", + "\n", + "[제공 자료] 가상 1차 결과 예시 [교육용가상자료] - 자기 조 1차 기록이 없으면 이 값을 기준으로 쓴다. 측정 기준은 미션 4·6과 같다.\n", + "\n", + "| **지표** | **1차(가상 예시)** | **목표(2차)** |\n", + "| --- | --- | --- |\n", + "| **보고 지연시간** | 4분 30초 | 3분 09초 이하(30% 단축) |\n", + "| **인계 소요시간** | 12분 00초 | 8분 24초 이하(30% 단축) |\n", + "| **처치 카드 기록 오류** | 6건 | 3건 이하(50% 감소) |\n", + "| **인계 누락 항목** | 3개 | 1개 이하(50% 이상 감소) |\n", + "\n", + "개선율 계산: 시간 단축률 = (1차 - 2차) / 1차 x 100, 오류 감소율 = (1차 - 2차) / 1차 x 100\n", + "\n", + "[작성 서식 1] 전후 성과 비교표\n", + "\n", + "| **지표** | **1차** | **2차** | **개선율** | **목표** | **달성** |\n", + "| --- | --- | --- | --- | --- | --- |\n", + "| **보고 지연시간** | | | | 30% 단축 | |\n", + "| **인계 소요시간** | | | | 30% 단축 | |\n", + "| **처치 카드 기록 오류** | | | | 50% 감소 | |\n", + "| **인계 누락 항목** | | | | 50% 감소 | |\n", + "| **SOP 단계 준수율** | | | | 80% 이상 유지 | |\n", + "\n", + "[작성 서식 2] 미달 지표 원인과 보수 교육 제안\n", + "\n", + "| **구분** | **내용** |\n", + "| --- | --- |\n", + "| **미달 지표·원인** | ( ) |\n", + "| **적용한 수정안 효과** | ( ) |\n", + "| **보수 교육 주기·대상** | 예: 분기 1회, 신규 전입자 전원( ) |\n", + "| **다음 재평가 일정** | ( ) |\n", + "\n", + "| **점검 항목** | **확인** |\n", + "| --- | --- |\n", + "| 1차와 같은 모의환자·평가표·측정 기준으로 재실행했다 | ( ) |\n", + "| 보고·인계 시간이 30% 이상 단축되었다 | ( ) |\n", + "| 기록 오류·인계 누락이 50% 이상 감소했다 | ( ) |\n", + "| 미달 지표가 있으면 원인을 적었다 | ( ) |\n", + "| 보수 교육 주기와 대상을 제안했다 | ( ) |\n", + "\n", + "**\n", + "\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 10. TCCC 모의환자 평가 및 의무 인계 훈련 표준 실무 지침서 완성\n", + "\n", + "### 📋 미션 배경 및 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "TCCC 모의환자 평가와 인계 정확도 훈련 표준 지침서를 완성한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **모의환자 평가와 의무 인계 훈련 전 과정을 다른 부대 교관이 그대로 쓸 수 있는 표준 실무 지침서와 교관 지침서로 묶는다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **개요, SOP(체크리스트·흐름도), 시나리오(모의환자 카드·MSEL), 평가 루브릭, AAR 양식의 5개 장으로 교안을 구성하고 수정된 양식을 부록으로 넣는다 과정을 진행하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 5개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `package/trn10_standard_training_package.md 및 교관 지침서` 형태의 결과물을 완성한다.\n", + "\n", + "### 📑 실습자료 및 작성 양식\n", + "TCCC 모의환자 평가 및 의무 인계 훈련 표준 실무 지침서 완성**\n", + "\n", + "[작성 서식 1] 표준 실무 지침서 목차 서식\n", + "\n", + "| **장** | **제목** | **포함 내용** | **사용할 산출물** | **완료** |\n", + "| --- | --- | --- | --- | --- |\n", + "| **1** | 개요 | 목적, 전훈 배경, 적용 대상, 안전·보안 원칙 | 미션 1 | ( ) |\n", + "| **2** | SOP | 단계별 체크리스트, 흐름도, 휴대 요약 카드, 인계 템플릿 | 미션 2·6·8 | ( ) |\n", + "| **3** | 시나리오 | 모의환자 카드, MSEL, 스트레스 사건 카드 | 미션 3·7 | ( ) |\n", + "| **4** | 평가 루브릭 | 처치 카드 점검, 준수율·시간 측정, 교차검증 채점 | 미션 4·5·9 | ( ) |\n", + "| **5** | AAR | 4단계 진행표, 오류 분석 매트릭스, 재평가 비교 | 미션 8·9 | ( ) |\n", + "| **부록** | 양식 모음 | 교육용 처치 카드, 9항목 요청서, MIST, SBAR, 기록지 | 전 미션 | ( ) |\n", + "\n", + "[작성 서식 2] 교관 지침서 핵심 항목\n", + "\n", + "| **항목** | **내용** |\n", + "| --- | --- |\n", + "| **준비물·인원** | ( ) |\n", + "| **안전 통제(입회 인원, 중지 신호)** | ( ) |\n", + "| **시간표(총 소요)** | ( ) |\n", + "| **채점 방법** | ( ) |\n", + "| **의무대·군의관 감수 절차** | ( ) |\n", + "| **금지 사항** | 처치 세부 절차·약물 용량 자체 교안화 금지, 실제 환자 정보 사용 금지( ) |\n", + "\n", + "[작성 서식 3] 모의 검토 의견 처리표(타 부대 교관·평가관 역할)\n", + "\n", + "| **번호** | **검토자 역할** | **의견** | **반영/미반영** | **처리 내용·사유** |\n", + "| --- | --- | --- | --- | --- |\n", + "| **1** | | | | |\n", + "| **2** | | | | |\n", + "| **3** | | | | |\n", + "| **4** | | | | |\n", + "| **5** | | | | |\n", + "\n", + "[작성 서식 4] 개정 관리표\n", + "\n", + "| **판** | **일자** | **개정 내용** | **감수(의무대)** | **승인** |\n", + "| --- | --- | --- | --- | --- |\n", + "| **1.0** | | 최초 작성 | | |\n", + "| | | | | |\n", + "\n", + "| **점검 항목** | **확인** |\n", + "| --- | --- |\n", + "| 5개 장(개요-SOP-시나리오-평가 루브릭-AAR)이 모두 있다 | ( ) |\n", + "| 부록에 수정된 양식을 모두 넣었다 | ( ) |\n", + "| 모의 검토 의견을 모두 처리했다(미반영은 사유 기재) | ( ) |\n", + "| 의무대·군의관 감수 절차와 개정 주기를 명시했다 | ( ) |\n", + "| 연간 교육훈련 계획상 실시 시기를 적었다 | ( ) |\n", + "\n", + "**4. 평가 채점표**\n", + "\n", + "교관·평가관이 미션별 산출물과 기록지를 확인해 PASS/FAIL에 표시한다. 필수 미션 7개가 모두 PASS이고 선택 미션 3개 중 2개 이상이 PASS이면 과제 이수로 판정한다.\n", + "\n", + "| **미션** | **구분** | **채점 기준** | **PASS** | **FAIL** |\n", + "| --- | --- | --- | --- | --- |\n", + "| **M1. 훈련 기준 정의** | 필수 | 현장 체크 항목 15개 이상 정의, 모든 체크 항목에 책임 역할이 지정되어 역할 분담 매트릭스 매핑률 100%, 교관 체크리스트 대조 결과 누락 0건인가. | | |\n", + "| **M2. 단계별 체크리스트 작성** | 필수 | 단계별 행동 항목 명시율 100%, 필수 안전·보안 조치 포함률 100%, 교차 검토에서 지적된 누락·모호 표현을 모두 수정하고 휴대 요약 카드를 완성했는가. | | |\n", + "| **M3. 실습 운영·시나리오 설계** | 필수 | 주입 사건 10건 이상, 정상·비정상 비율 6:4, 모든 사건에 투입 시각·대상 역할·기대 행동이 적혀 있고 워크스루 지적 사항을 반영했는가(스테이션 실습은 교관 확인 서명으로 판정). | | |\n", + "| **M4. 1차 Dry-run 수행** | 선택 | SOP 단계 준수율 80% 이상, 보고 지연시간 3분 이내, 처치 카드 필수 기재란 누락 0건, 절차 병목·혼선 구간을 2개 이상 식별했는가. | | |\n", + "| **M5. 오류 식별·교차검증** | 필수 | 주입 오류 식별 성공률 85% 이상(8건 중 7건 이상), 정상 항목 오표시 1건 이하, 모든 판정에 대조 문서와 근거가 있고 불일치 보고 경로를 명시했는가. | | |\n", + "| **M6. 후송 요청·인계** | 선택 | 인계 필수 항목 누락률 0%, 인계 소요 10분 이내, 인수자 돌발 질문 3문항 모두 정답, 인계자·인수자 상호 서명과 인계 품질 체크리스트 통과가 확인되는가. | | |\n", + "| **M7. 스트레스 상황 대응** | 필수 | 비상 절차(단계) 전환 시간 1분 이내, 위험 조치 성공률 90% 이상, 안전 수칙 위반 0건, 지휘 공백 시 임무 위임이 1분 안에 이루어졌는가. | | |\n", + "| **M8. AAR 원인 분석** | 선택 | 개선점 5건 이상 도출, 원인 항목별 근거 기록 연결률 100%, 기록과 진술 불일치 확인 결과 정리, 비난성 표현 없이 1차 SOP 수정안을 도출했는가. | | |\n", + "| **M9. 재평가·개선 검증** | 필수 | 1차 대비 조치(보고·인계) 시간 30% 이상 단축, 오류 발생 건수 50% 이상 감소, 동일 지표·동일 방법으로 비교했으며 미달 지표의 원인과 보수 교육 주기를 제시했는가. | | |\n", + "| **M10. 표준 지침서 완성** | 필수 | 5개 장(개요-SOP-시나리오-평가 루브릭-AAR) 100% 완비, 모의 검토 의견 처리율 100%(미반영 시 사유 기재), 의무대·군의관 감수 절차와 개정 주기를 명시했는가. | | |\n", + "\n", + "| **구분** | **기재** |\n", + "| --- | --- |\n", + "| **종합 판정** | 필수 ( )/7 PASS, 선택 ( )/3 PASS → 이수 / 재평가 |\n", + "| **평가관 의견** | ( ) |\n", + "| **평가관 서명 / 일자** | ( ) / ( ) |\n", + "\n" + ] + } + ], + "metadata": { + "colab": { + "provenance": [] + }, + "kernelspec": { + "display_name": "Python 3", + "name": "python3" + }, + "language_info": { + "name": "python" + } + }, + "nbformat": 4, + "nbformat_minor": 0 +} \ No newline at end of file diff --git a/uploads/WEL01/WEL01_solution.ipynb b/uploads/WEL01/WEL01_solution.ipynb new file mode 100644 index 0000000..790609f --- /dev/null +++ b/uploads/WEL01/WEL01_solution.ipynb @@ -0,0 +1,2865 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# 🎯 [WEL01] 더미 생체신호 기반 열·한랭 스트레스 경보 프로토타입\n", + "### Google Colab 실습 환경 (장병 복지·지원 / Level 2)\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/WEL01/WEL01_solution.ipynb)\n", + "\n", + "---\n", + "\n", + "## 📌 1. 프로젝트 개요 및 배경\n", + "\n", + "| 항목 | 내용 |\n", + "| :--- | :--- |\n", + "| **과정 분류** | 장병 복지·지원 (Level 2) |\n", + "| **학습 소요 시간** | 10개 미션 (5일 / 총 27.5시간) |\n", + "| **환경 사양** | Python 3.10+, numpy, pandas, matplotlib, scipy, scikit-learn |\n", + "\n", + "### 📖 프로젝트 목표\n", + "러시아-우크라이나 전쟁에서는 혹한과 폭염 속에서 장시간 기동하는 전투원의 열·한랭 손상이 작전 지속력을 좌우하는 문제로 드러났고, 웨어러블 센서 신호로 위험 징후를 일찍 알아차리되 오경보와 센서 오류를 가려내는 일이 중요해졌다. 본 프로젝트에서는 교육용 가상 병사의 더미 생체신호(심박수, 체온, 피부전도도)와 기상을 코드로 직접 만들어, 데이터 스키마 정의, 결측·센서 튐 전처리, 체온-심박 상관 기반 Baseline, 기상·개인 특성을 결합한 복합 위험도 모델, 개인화 신뢰도 엔진, 소대장·의무관용 상황판, 군의관 원격 검토 중심의 HITL 절차, 폭염·혹한 대량 데이터 유입 시험, 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. 전투원 착용 웨어러블 더미 생체신호(심박수, 체온, 피부전도도) 데이터 규격 정의\n", + "**소요시간**: 약 1.5시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "전투원 웨어러블 더미 생체신호(심박수, 체온, 피부전도도) 데이터셋을 정의한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **웨어러블 더미 생체신호(심박수, 체온, 피부전도도)와 가상 병사 특성·기상·기기 상태의 데이터 규격을 정의하고, 잘못된 데이터를 걸러내는 검증기를 만든다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **신호 3종의 허용 범위, 샘플링 간격, 기기 종류와 접촉 품질, 병사 특성(안정시 심박, 연령, 체격, 더위 순응 여부), 기상(기온, 습도, 풍속) 필드를 담은 스키마 설계하고 작성하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `data/schema/wel01_input_schema.json 및 docs/wel01_data_spec.md` 형태의 결과물을 완성한다.\n", + "\n", + "### 🔍 평가 및 검증 기준 (PASS 조건)\n", + "#### 미션 판정\n", + "\n", + "**평가 구분:** 필수\n", + "\n", + "**핵심 평가영역:** 장병 복지·지원 실무 및 러시아-우크라이나 전쟁 전장 환경과의 군사적 부합성\n", + "\n", + "#### 미션 PASS 조건\n", + "\n", + "- 정량 기준: 스키마 필드 정의 완성도 100%, 샘플 데이터 검증 오류 0건\n", + "\n", + "\n", + "- 진행 기준: 입력 데이터 유효성 검증 스크립트 통과 및 명세서 문서화 완료\n", + "\n", + "\n", + "- 검증 방법: JSON Schema 검증 도구 및 샘플 데이터 파싱 단위 테스트\n", + "\n", + "\n", + "#### 채점자 판정 메모\n", + "\n", + "> 단순 코드 실행 여부뿐만 아니라 출력된 수치와 근거 자료가 군 실무 요구사항에 부합하는지 면밀히 확인한다.\n", + "\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "\"\"\"전투원 웨어러블 더미 생체신호(심박수, 체온, 피부전도도)와 가상 병사 특성·기상·기기 상태의 데이터 스키마를 정의하고, 가상 샘플 20건을 검증한다. 진단 목적이 아닌 교육용 가상 자료이다.\"\"\"\n", + "import json\n", + "import re\n", + "import numpy as np\n", + "import pandas as pd\n", + "\n", + "# ---------------- [교육용가상자료] 더미 생체신호 생성기 (실제 장병·부대·의무 기록 없음) ----------------\n", + "# 가상 병사(가명 ID)가 60분간 걷기·휴식·고강도 활동을 하는 동안의 심박수·체온(추정 심부)·피부전도도를 1분 간격으로 만든다.\n", + "# 진단 도구가 아니며, 상태 라벨은 가상 생리 모델의 '교육용 단계'일 뿐이다.\n", + "N_MIN = 60\n", + "STAGE = {0: \"정상\", 1: \"주의\", 2: \"경보\"}\n", + "TYPES = [\"normal\", \"heat\", \"cold\"]\n", + "\n", + "\n", + "def make_person(i, rng):\n", + " return {\"id\": f\"S{i:03d}\", \"rest_hr\": float(rng.normal(64, 7)), \"age\": int(rng.integers(19, 27)), \"bmi\": float(rng.normal(23.5, 2.5)),\n", + " \"acclim\": int(rng.random() < 0.4), \"fit\": float(np.clip(rng.normal(0.5, 0.2), 0.05, 0.95))}\n", + "\n", + "\n", + "def make_env(kind, rng):\n", + " if kind == \"heat\":\n", + " return {\"temp_c\": float(rng.uniform(30, 40)), \"rh\": float(rng.uniform(45, 85)), \"wind\": float(rng.uniform(0.3, 3))}\n", + " if kind == \"cold\":\n", + " return {\"temp_c\": float(rng.uniform(-22, 2)), \"rh\": float(rng.uniform(40, 80)), \"wind\": float(rng.uniform(2, 11))}\n", + " return {\"temp_c\": float(rng.uniform(8, 24)), \"rh\": float(rng.uniform(35, 70)), \"wind\": float(rng.uniform(0.3, 5))}\n", + "\n", + "\n", + "def make_session(sid, kind, rng, p=None, glitch=True):\n", + " \"\"\"반환: dict(person, env, kind, df[hr,temp,eda,act + 정답 core·stage·rtype]). 센서 결측·튐·고착 포함.\"\"\"\n", + " p = p or make_person(sid, rng)\n", + " env = make_env(kind, rng)\n", + " act = np.zeros(N_MIN); t = 0\n", + " while t < N_MIN: # 10~20분 단위 활동 블록: 휴식 1.5 / 행군 4.5 / 고강도 7 MET\n", + " d = int(rng.integers(10, 21)); act[t:t + d] = rng.choice([1.5, 4.5, 7.0], p=[0.25, 0.5, 0.25]); t += d\n", + " hi = env[\"temp_c\"] + (0.12 * (env[\"rh\"] - 40) if env[\"temp_c\"] > 25 else 0)\n", + " wc = env[\"temp_c\"] - 1.6 * env[\"wind\"]\n", + " core, cores = 36.9, []\n", + " for m in range(N_MIN):\n", + " heat = 0.085 * max(0, hi - 24) * (1 - 0.25 * p[\"acclim\"]) * (1 + 0.5 * m / N_MIN) * (1 + 0.012 * (p[\"bmi\"] - 23.5))\n", + " cold = -0.075 * max(0, 8 - wc) * (1 - 0.09 * act[m])\n", + " ss = 36.9 + 0.09 * act[m] + heat + cold\n", + " core += (ss - core) / 18 + rng.normal(0, 0.03)\n", + " cores.append(core)\n", + " core = np.array(cores)\n", + " hr = p[\"rest_hr\"] + 9.5 * (act - 1.5) * (1.15 - 0.3 * p[\"fit\"]) + 11 * (core - 36.9) + rng.normal(0, 3.2, N_MIN)\n", + " eda = 2.0 + 0.25 * act + 3.2 * np.maximum(core - 37.2, 0) - 0.04 * np.maximum(8 - wc, 0) + rng.normal(0, 0.25, N_MIN)\n", + " bias = rng.normal(0, 0.1)\n", + " temp = core + bias + rng.normal(0, 0.1, N_MIN) - np.where((kind == \"cold\") & (env[\"wind\"] > 7), 0.25, 0.0) # 센서 접촉 부위 바람 냉각 오차\n", + " df = pd.DataFrame({\"min\": np.arange(N_MIN), \"hr\": hr, \"temp\": temp, \"eda\": np.maximum(eda, 0.2), \"act\": act})\n", + " if glitch:\n", + " for col in (\"hr\", \"temp\", \"eda\"):\n", + " df.loc[rng.random(N_MIN) < 0.03, col] = np.nan # 결측\n", + " s = int(rng.integers(5, N_MIN))\n", + " if rng.random() < 0.12:\n", + " df.loc[s, \"hr\"] = 230.0 # 센서 튐\n", + " if rng.random() < 0.12:\n", + " df.loc[s, \"temp\"] = 45.0\n", + " ht = np.where(core >= 39.0, 2, np.where(core >= 38.3, 1, 0)); cd = np.where(core <= 35.4, 2, np.where(core <= 36.2, 1, 0))\n", + " df[\"core\"] = core\n", + " df[\"stage\"] = np.maximum(ht, cd); df[\"rtype\"] = np.where(ht > 0, \"heat\", np.where(cd > 0, \"cold\", \"normal\"))\n", + " return {\"id\": p[\"id\"], \"person\": p, \"env\": env, \"kind\": kind, \"df\": df}\n", + "\n", + "\n", + "def make_dataset(n, seed):\n", + " rng = np.random.default_rng(seed)\n", + " kinds = [\"normal\", \"heat\", \"cold\"]\n", + " return [make_session(i, kinds[i % 3], rng) for i in range(n)]\n", + "# ---------------------------------------------------------------------------\n", + "\n", + "# 1) 스키마 정의: 신호 규격, 가상 병사 특성, 기상(온·습도·풍속), 기기 상태\n", + "SCHEMA = {\n", + " \"required\": [\"record_id\", \"soldier_id\", \"n_samples\", \"sampling_s\", \"device\", \"person\", \"weather\", \"samples\"],\n", + " \"fields\": {\n", + " \"record_id\": {\"type\": str, \"pattern\": r\"^REC-\\d{4}$\"},\n", + " \"soldier_id\": {\"type\": str, \"pattern\": r\"^S\\d{3}$\"},\n", + " \"n_samples\": {\"type\": int, \"min\": 1, \"max\": 100000},\n", + " \"sampling_s\": {\"type\": (int, float), \"min\": 1, \"max\": 600},\n", + " },\n", + " \"device\": {\n", + " \"type\": {\"type\": str, \"enum\": [\"wrist\", \"chest\"]},\n", + " \"battery_pct\": {\"type\": (int, float), \"min\": 0, \"max\": 100},\n", + " \"contact_quality\": {\"type\": (int, float), \"min\": 0, \"max\": 1},\n", + " },\n", + " \"person\": {\n", + " \"rest_hr\": {\"type\": (int, float), \"min\": 40, \"max\": 100},\n", + " \"age\": {\"type\": int, \"min\": 18, \"max\": 45},\n", + " \"bmi\": {\"type\": (int, float), \"min\": 15, \"max\": 40},\n", + " \"acclim\": {\"type\": int, \"min\": 0, \"max\": 1},\n", + " },\n", + " \"weather\": {\n", + " \"temp_c\": {\"type\": (int, float), \"min\": -40, \"max\": 55},\n", + " \"rh\": {\"type\": (int, float), \"min\": 0, \"max\": 100},\n", + " \"wind\": {\"type\": (int, float), \"min\": 0, \"max\": 30},\n", + " },\n", + " \"sample\": {\n", + " \"hr\": {\"type\": (int, float), \"min\": 30, \"max\": 220},\n", + " \"temp\": {\"type\": (int, float), \"min\": 30, \"max\": 43},\n", + " \"eda\": {\"type\": (int, float), \"min\": 0, \"max\": 40},\n", + " },\n", + "}\n", + "\n", + "\n", + "def check(name, value, rule):\n", + " \"\"\"값 하나를 규칙(type, enum, min, max, pattern)으로 검사해 오류 목록을 돌려준다.\"\"\"\n", + " if not isinstance(value, rule[\"type\"]) or isinstance(value, bool):\n", + " return [f\"{name}: 자료형 오류({type(value).__name__})\"]\n", + " errs = []\n", + " if \"enum\" in rule and value not in rule[\"enum\"]:\n", + " errs.append(f\"{name}: 허용되지 않은 값 '{value}'\")\n", + " if \"min\" in rule and value < rule[\"min\"]:\n", + " errs.append(f\"{name}: 최소값 {rule['min']} 미만\")\n", + " if \"max\" in rule and value > rule[\"max\"]:\n", + " errs.append(f\"{name}: 최대값 {rule['max']} 초과\")\n", + " if \"pattern\" in rule and not re.match(rule[\"pattern\"], value):\n", + " errs.append(f\"{name}: 형식 불일치 '{value}'\")\n", + " return errs\n", + "\n", + "\n", + "def validate(rec):\n", + " errs = [f\"{k}: 필수 필드 누락\" for k in SCHEMA[\"required\"] if k not in rec]\n", + " for k, rule in SCHEMA[\"fields\"].items():\n", + " if k in rec:\n", + " errs += check(k, rec[k], rule)\n", + " for group in (\"device\", \"person\", \"weather\"):\n", + " for k, rule in SCHEMA[group].items():\n", + " if k not in rec.get(group, {}):\n", + " errs.append(f\"{group}.{k}: 필수 필드 누락\")\n", + " else:\n", + " errs += check(f\"{group}.{k}\", rec[group][k], rule)\n", + " for i, smp in enumerate(rec.get(\"samples\", [])):\n", + " for k, rule in SCHEMA[\"sample\"].items():\n", + " if k not in smp:\n", + " errs.append(f\"samples[{i}].{k}: 필수 필드 누락\")\n", + " else:\n", + " errs += check(f\"samples[{i}].{k}\", smp[k], rule)\n", + " if \"n_samples\" in rec and rec[\"n_samples\"] != len(rec.get(\"samples\", [])):\n", + " errs.append(\"n_samples: 샘플 개수와 불일치\")\n", + " return errs\n", + "\n", + "\n", + "# 2) [교육용가상자료] 가상 병사 20명의 더미 세션에서 샘플 레코드 생성(센서 오류 없는 정상 데이터)\n", + "rng = np.random.default_rng(1)\n", + "records = []\n", + "for i in range(20):\n", + " s = make_session(i, TYPES[i % 3], rng, glitch=False)\n", + " smp = [{\"hr\": round(float(r.hr), 1), \"temp\": round(float(r.temp), 2), \"eda\": round(float(r.eda), 2)} for r in s[\"df\"].itertuples()]\n", + " p, e = s[\"person\"], s[\"env\"]\n", + " records.append({\"record_id\": f\"REC-{i:04d}\", \"soldier_id\": s[\"id\"], \"n_samples\": len(smp), \"sampling_s\": 60,\n", + " \"device\": {\"type\": str(rng.choice([\"wrist\", \"chest\"])), \"battery_pct\": int(rng.integers(30, 100)), \"contact_quality\": round(float(rng.uniform(0.7, 1)), 2)},\n", + " \"person\": {\"rest_hr\": round(p[\"rest_hr\"], 1), \"age\": p[\"age\"], \"bmi\": round(p[\"bmi\"], 1), \"acclim\": p[\"acclim\"]},\n", + " \"weather\": {k: round(v, 1) for k, v in e.items()}, \"samples\": smp})\n", + "\n", + "# 3) 정상 데이터 검증\n", + "ok = sum(1 for r in records if not validate(r))\n", + "print(f\"정상 샘플 {len(records)}건 중 검증 통과 {ok}건, 오류 {len(records) - ok}건 -> 목표(검증 오류 0건): {'달성' if ok == len(records) else '미달'}\")\n", + "\n", + "# 4) 오류 주입 시험\n", + "bad = json.loads(json.dumps(records[0]))\n", + "bad[\"soldier_id\"] = \"SOLDIER-1\" # 형식\n", + "bad[\"samples\"][0][\"temp\"] = 45.0 # 범위(센서 튐)\n", + "del bad[\"weather\"][\"wind\"] # 누락(풍속)\n", + "bad[\"device\"][\"type\"] = \"ankle\" # 허용값\n", + "bad[\"n_samples\"] = 99 # 논리(개수 불일치)\n", + "errs = validate(bad)\n", + "print(f\"\\n[오류 주입 결과] 주입 5종 -> 검출 {len(errs)}건\")\n", + "for e in errs:\n", + " print(\" -\", e)\n", + "print(f\"주입 오류 전부 검출: {'예' if len(errs) >= 5 else '아니오'}\")\n", + "\n", + "# 5) 스키마 필드 정의서(명세서 초안)\n", + "rows = [(\"기본\", k, r[\"type\"].__name__ if isinstance(r[\"type\"], type) else \"number\", str({x: y for x, y in r.items() if x != \"type\"})) for k, r in SCHEMA[\"fields\"].items()]\n", + "for g in (\"device\", \"person\", \"weather\", \"sample\"):\n", + " for k, r in SCHEMA[g].items():\n", + " t = r[\"type\"].__name__ if isinstance(r[\"type\"], type) else \"number\"\n", + " rows.append((g, k, t, str({x: y for x, y in r.items() if x != \"type\"})))\n", + "print(\"\\n[데이터 명세 요약] 필드 수\", len(rows))\n", + "print(pd.DataFrame(rows, columns=[\"그룹\", \"필드\", \"자료형\", \"규칙\"]).to_string(index=False))\n", + "print(\"\\n상태 단계(교육용 가상 라벨):\", STAGE, \"/ 위험 유형:\", TYPES)\n", + "print(\"본 데이터는 코드로 만든 더미 생체신호이며 진단·처방 자료가 아니다. 실제 건강 이상은 의무대·군의관에게 연계한다.\")\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 01 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + "정상 샘플 20건 중 검증 통과 20건, 오류 0건 -> 목표(검증 오류 0건): 달성\n", + "\n", + "[오류 주입 결과] 주입 5종 -> 검출 5건\n", + " - soldier_id: 형식 불일치 'SOLDIER-1'\n", + " - device.type: 허용되지 않은 값 'ankle'\n", + " - weather.wind: 필수 필드 누락\n", + " - samples[0].temp: 최대값 43 초과\n", + " - n_samples: 샘플 개수와 불일치\n", + "주입 오류 전부 검출: 예\n", + "\n", + "[데이터 명세 요약] 필드 수 17\n", + " 그룹 필드 자료형 규칙\n", + " 기본 record_id str {'pattern': '^REC-\\\\d{4}$'}\n", + " 기본 soldier_id str {'pattern': '^S\\\\d{3}$'}\n", + " 기본 n_samples int {'min': 1, 'max': 100000}\n", + " 기본 sampling_s number {'min': 1, 'max': 600}\n", + " device type str {'enum': ['wrist', 'chest']}\n", + " device battery_pct number {'min': 0, 'max': 100}\n", + " device contact_quality number {'min': 0, 'max': 1}\n", + " person rest_hr number {'min': 40, 'max': 100}\n", + " person age int {'min': 18, 'max': 45}\n", + " person bmi number {'min': 15, 'max': 40}\n", + " person acclim int {'min': 0, 'max': 1}\n", + "weather temp_c number {'min': -40, 'max': 55}\n", + "weather rh number {'min': 0, 'max': 100}\n", + "weather wind number {'min': 0, 'max': 30}\n", + " sample hr number {'min': 30, 'max': 220}\n", + " sample temp number {'min': 30, 'max': 43}\n", + " sample eda number {'min': 0, 'max': 40}\n", + "\n", + "상태 단계(교육용 가상 라벨): {0: '정상', 1: '주의', 2: '경보'} / 위험 유형: ['normal', 'heat', 'cold']\n", + "본 데이터는 코드로 만든 더미 생체신호이며 진단·처방 자료가 아니다. 실제 건강 이상은 의무대·군의관에게 연계한다.\n", + "```\n", + "
\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 02. 야전 활동 중 발생하는 생체신호 결측치 보정 및 센서 노이즈 필터링 전처리 구현\n", + "**소요시간**: 약 2.0시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "야전 활동 중 생체신호 결측치 보정 및 센서 노이즈 필터링 전처리 모듈을 구현한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **야전 활동 중 발생하는 결측, 센서 튐, 센서 고착(단절)을 탐지·보정하는 전처리 모듈을 구현한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **생리적으로 불가능한 범위 밖 값을 결측으로 바꾸고 선형 보간과 3분 이동 중앙값으로 정리하며, 같은 값이 8분 이상 이어지는 센서 고착을 찾아 결측 처리 수행하고 검증하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `src/preprocess/wel01_preprocessor.py 및 전처리 전후 데이터셋` 형태의 결과물을 완성한다.\n", + "\n", + "### 🔍 평가 및 검증 기준 (PASS 조건)\n", + "#### 미션 판정\n", + "\n", + "**평가 구분:** 필수\n", + "\n", + "**핵심 평가영역:** 전술 환경 특유의 노이즈(스푸핑, 반사파, 센서 단절 등) 제거 타당성\n", + "\n", + "#### 미션 PASS 조건\n", + "\n", + "- 정량 기준: 데이터 정제 처리 속도 1,000건/초 이상, 전처리 오류율 0%\n", + "\n", + "\n", + "- 진행 기준: 정제 파이프라인 자동화 및 단위 테스트 케이스 5종 통과\n", + "\n", + "\n", + "- 검증 방법: pytest 기반 전처리 정합성 테스트 및 결측치 보정율 확인\n", + "\n", + "\n", + "#### 채점자 판정 메모\n", + "\n", + "> 단순 코드 실행 여부뿐만 아니라 출력된 수치와 근거 자료가 군 실무 요구사항에 부합하는지 면밀히 확인한다.\n", + "\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "\"\"\"야전 활동 중 생체신호의 결측·센서 튐·고착(센서 단절)을 탐지·보정하는 전처리 모듈을 구현하고, 처리 속도·단위 테스트 5종·보정 전후 체온 오차와 경보 분류 성능을 측정한다. 진단 목적이 아닌 교육용 가상 자료이다.\"\"\"\n", + "import time\n", + "import numpy as np\n", + "import pandas as pd\n", + "import matplotlib.pyplot as plt\n", + "\n", + "# ---------------- [교육용가상자료] 더미 생체신호 생성기 (실제 장병·부대·의무 기록 없음) ----------------\n", + "# 가상 병사(가명 ID)가 60분간 걷기·휴식·고강도 활동을 하는 동안의 심박수·체온(추정 심부)·피부전도도를 1분 간격으로 만든다.\n", + "# 진단 도구가 아니며, 상태 라벨은 가상 생리 모델의 '교육용 단계'일 뿐이다.\n", + "N_MIN = 60\n", + "STAGE = {0: \"정상\", 1: \"주의\", 2: \"경보\"}\n", + "TYPES = [\"normal\", \"heat\", \"cold\"]\n", + "\n", + "\n", + "def make_person(i, rng):\n", + " return {\"id\": f\"S{i:03d}\", \"rest_hr\": float(rng.normal(64, 7)), \"age\": int(rng.integers(19, 27)), \"bmi\": float(rng.normal(23.5, 2.5)),\n", + " \"acclim\": int(rng.random() < 0.4), \"fit\": float(np.clip(rng.normal(0.5, 0.2), 0.05, 0.95))}\n", + "\n", + "\n", + "def make_env(kind, rng):\n", + " if kind == \"heat\":\n", + " return {\"temp_c\": float(rng.uniform(30, 40)), \"rh\": float(rng.uniform(45, 85)), \"wind\": float(rng.uniform(0.3, 3))}\n", + " if kind == \"cold\":\n", + " return {\"temp_c\": float(rng.uniform(-22, 2)), \"rh\": float(rng.uniform(40, 80)), \"wind\": float(rng.uniform(2, 11))}\n", + " return {\"temp_c\": float(rng.uniform(8, 24)), \"rh\": float(rng.uniform(35, 70)), \"wind\": float(rng.uniform(0.3, 5))}\n", + "\n", + "\n", + "def make_session(sid, kind, rng, p=None, glitch=True):\n", + " \"\"\"반환: dict(person, env, kind, df[hr,temp,eda,act + 정답 core·stage·rtype]). 센서 결측·튐·고착 포함.\"\"\"\n", + " p = p or make_person(sid, rng)\n", + " env = make_env(kind, rng)\n", + " act = np.zeros(N_MIN); t = 0\n", + " while t < N_MIN: # 10~20분 단위 활동 블록: 휴식 1.5 / 행군 4.5 / 고강도 7 MET\n", + " d = int(rng.integers(10, 21)); act[t:t + d] = rng.choice([1.5, 4.5, 7.0], p=[0.25, 0.5, 0.25]); t += d\n", + " hi = env[\"temp_c\"] + (0.12 * (env[\"rh\"] - 40) if env[\"temp_c\"] > 25 else 0)\n", + " wc = env[\"temp_c\"] - 1.6 * env[\"wind\"]\n", + " core, cores = 36.9, []\n", + " for m in range(N_MIN):\n", + " heat = 0.085 * max(0, hi - 24) * (1 - 0.25 * p[\"acclim\"]) * (1 + 0.5 * m / N_MIN) * (1 + 0.012 * (p[\"bmi\"] - 23.5))\n", + " cold = -0.075 * max(0, 8 - wc) * (1 - 0.09 * act[m])\n", + " ss = 36.9 + 0.09 * act[m] + heat + cold\n", + " core += (ss - core) / 18 + rng.normal(0, 0.03)\n", + " cores.append(core)\n", + " core = np.array(cores)\n", + " hr = p[\"rest_hr\"] + 9.5 * (act - 1.5) * (1.15 - 0.3 * p[\"fit\"]) + 11 * (core - 36.9) + rng.normal(0, 3.2, N_MIN)\n", + " eda = 2.0 + 0.25 * act + 3.2 * np.maximum(core - 37.2, 0) - 0.04 * np.maximum(8 - wc, 0) + rng.normal(0, 0.25, N_MIN)\n", + " bias = rng.normal(0, 0.1)\n", + " temp = core + bias + rng.normal(0, 0.1, N_MIN) - np.where((kind == \"cold\") & (env[\"wind\"] > 7), 0.25, 0.0) # 센서 접촉 부위 바람 냉각 오차\n", + " df = pd.DataFrame({\"min\": np.arange(N_MIN), \"hr\": hr, \"temp\": temp, \"eda\": np.maximum(eda, 0.2), \"act\": act})\n", + " if glitch:\n", + " for col in (\"hr\", \"temp\", \"eda\"):\n", + " df.loc[rng.random(N_MIN) < 0.03, col] = np.nan # 결측\n", + " s = int(rng.integers(5, N_MIN))\n", + " if rng.random() < 0.12:\n", + " df.loc[s, \"hr\"] = 230.0 # 센서 튐\n", + " if rng.random() < 0.12:\n", + " df.loc[s, \"temp\"] = 45.0\n", + " ht = np.where(core >= 39.0, 2, np.where(core >= 38.3, 1, 0)); cd = np.where(core <= 35.4, 2, np.where(core <= 36.2, 1, 0))\n", + " df[\"core\"] = core\n", + " df[\"stage\"] = np.maximum(ht, cd); df[\"rtype\"] = np.where(ht > 0, \"heat\", np.where(cd > 0, \"cold\", \"normal\"))\n", + " return {\"id\": p[\"id\"], \"person\": p, \"env\": env, \"kind\": kind, \"df\": df}\n", + "\n", + "\n", + "def make_dataset(n, seed):\n", + " rng = np.random.default_rng(seed)\n", + " kinds = [\"normal\", \"heat\", \"cold\"]\n", + " return [make_session(i, kinds[i % 3], rng) for i in range(n)]\n", + "# ---------------------------------------------------------------------------\n", + "\n", + "# ---------------- 전처리·특징 추출 공통 함수 ----------------\n", + "LIMITS = {\"hr\": (30, 220), \"temp\": (30.0, 43.0), \"eda\": (0.0, 40.0)} # 생리적으로 가능한 범위(범위 밖 = 센서 오류)\n", + "\n", + "\n", + "def clean(df):\n", + " \"\"\"범위 밖 값은 결측 처리 -> 선형 보간(양 끝은 가까운 값) -> 3분 이동 중앙값으로 튐 제거. 반환: (정제 df, 보정 건수)\"\"\"\n", + " out = df.copy(); fixed = 0\n", + " for c, (lo, hi) in LIMITS.items():\n", + " bad = out[c].isna() | (out[c] < lo) | (out[c] > hi)\n", + " fixed += int(bad.sum())\n", + " out.loc[bad, c] = np.nan\n", + " out[c] = out[c].interpolate(limit_direction=\"both\").rolling(3, center=True, min_periods=1).median()\n", + " return out, fixed\n", + "\n", + "\n", + "def env_index(env):\n", + " hi = env[\"temp_c\"] + (0.12 * (env[\"rh\"] - 40) if env[\"temp_c\"] > 25 else 0.0) # 체감 더위 지수(가상 단순식)\n", + " wc = env[\"temp_c\"] - 1.6 * env[\"wind\"] # 체감 추위 지수(가상 단순식)\n", + " return hi, wc\n", + "\n", + "\n", + "def features(s, use_env=True, personal=True):\n", + " \"\"\"세션 1개 -> 분 단위 특징표(최근 5분 평균·기울기 포함). 정답 stage/rtype 포함.\"\"\"\n", + " df, _ = clean(s[\"df\"]); p, e = s[\"person\"], s[\"env\"]\n", + " f = pd.DataFrame({\"sid\": s[\"id\"], \"min\": df[\"min\"]})\n", + " f[\"temp5\"] = df[\"temp\"].rolling(5, min_periods=1).mean()\n", + " f[\"hr5\"] = df[\"hr\"].rolling(5, min_periods=1).mean()\n", + " f[\"eda5\"] = df[\"eda\"].rolling(5, min_periods=1).mean()\n", + " f[\"dtemp\"] = (f[\"temp5\"] - f[\"temp5\"].shift(4)).fillna(0)\n", + " f[\"act\"] = df[\"act\"]\n", + " if personal:\n", + " f[\"hr_rel\"] = f[\"hr5\"] - p[\"rest_hr\"]\n", + " f[\"acclim\"], f[\"bmi\"], f[\"fit\"] = p[\"acclim\"], p[\"bmi\"], p[\"fit\"]\n", + " if use_env:\n", + " f[\"hi\"], f[\"wc\"] = env_index(e); f[\"wind\"] = e[\"wind\"]\n", + " f[\"stage\"], f[\"rtype\"], f[\"kind\"] = df[\"stage\"], df[\"rtype\"], s[\"kind\"]\n", + " return f[f[\"min\"] >= 5].reset_index(drop=True)\n", + "\n", + "\n", + "def table(sessions, **kw):\n", + " return pd.concat([features(s, **kw) for s in sessions], ignore_index=True)\n", + "# ---------------------------------------------------------------------------\n", + "\n", + "\n", + "def flatline(series, run=8):\n", + " \"\"\"같은 값이 run분 이상 이어지면 센서 고착(단절)으로 보고 해당 구간을 True로 표시한다.\"\"\"\n", + " same = series.diff().abs().fillna(1) < 1e-9\n", + " grp = (~same).cumsum()\n", + " return same.groupby(grp).transform(\"sum\").add(1).where(same | same.shift(-1, fill_value=False), 1) >= run\n", + "\n", + "\n", + "def preprocess(df):\n", + " \"\"\"고착 구간을 결측으로 바꾼 뒤 clean()으로 범위 검사·보간·튐 제거. 반환: (정제 df, 보정 건수, 고착 건수)\"\"\"\n", + " d = df.copy(); stuck = 0\n", + " for c in (\"hr\", \"temp\"):\n", + " fl = flatline(d[c].round(3))\n", + " stuck += int(fl.sum()); d.loc[fl, c] = np.nan\n", + " out, fixed = clean(d)\n", + " return out, fixed, stuck\n", + "\n", + "\n", + "def inject_faults(df, rng):\n", + " \"\"\"센서 단절 모의: 체온이 가장 크게 변하는 시점 직전부터 센서가 12분간 같은 값으로 고착된다(실제 변화를 따라가지 못함).\"\"\"\n", + " d = df.copy(); s = int(np.clip(np.argmax(np.abs(d[\"core\"].values - 36.9)) - 12 + rng.integers(0, 4), 3, N_MIN - 13))\n", + " d.loc[s:s + 11, \"temp\"] = d.loc[s, \"temp\"]\n", + " return d\n", + "\n", + "\n", + "rng = np.random.default_rng(5)\n", + "data = make_dataset(120, seed=21)\n", + "\n", + "# 1) 처리 속도와 오류율\n", + "t0 = time.perf_counter(); n_rows = 0; errors = 0; fixed_all = 0\n", + "for s in data:\n", + " try:\n", + " out, fx, st = preprocess(s[\"df\"]); n_rows += len(out) * 3; fixed_all += fx\n", + " errors += int(out[[\"hr\", \"temp\", \"eda\"]].isna().any().any() or (out[\"temp\"] > 43).any())\n", + " except Exception:\n", + " errors += 1\n", + "sec = time.perf_counter() - t0\n", + "print(f\"전처리 {n_rows:,}건(신호값) / {sec:.2f}초 = {n_rows / sec:,.0f}건/초 (목표 1,000 이상: {'달성' if n_rows / sec >= 1000 else '미달'})\")\n", + "print(f\"전처리 오류 {errors}건 -> 오류율 {errors / len(data):.0%} (목표 0%), 범위 밖·결측 보정 {fixed_all}건\")\n", + "\n", + "\n", + "# 2) 단위 테스트 5종\n", + "def t1():\n", + " d = pd.DataFrame({\"min\": range(6), \"hr\": [80, np.nan, 82, 83, 84, 85.], \"temp\": 37.0, \"eda\": 2.0, \"act\": 1.5, \"stage\": 0, \"rtype\": \"normal\"})\n", + " out, fx = clean(d); assert out[\"hr\"].notna().all() and fx == 1\n", + "def t2():\n", + " d = pd.DataFrame({\"min\": range(6), \"hr\": [80, 81, 230, 82, 83, 84.], \"temp\": 37.0, \"eda\": 2.0, \"act\": 1.5, \"stage\": 0, \"rtype\": \"normal\"})\n", + " out, fx = clean(d); assert out[\"hr\"].max() < 100 and fx == 1\n", + "def t3():\n", + " s = pd.Series([36.9, 37.0, 37.0, 37.0, 37.0, 37.0, 37.0, 37.0, 37.0, 37.0, 37.1]); assert flatline(s).sum() >= 8\n", + "def t4():\n", + " s = pd.Series(np.linspace(36.5, 37.5, 20)); assert not flatline(s).any()\n", + "def t5():\n", + " out, fx, st = preprocess(inject_faults(make_session(1, \"normal\", np.random.default_rng(3))[\"df\"], np.random.default_rng(4))); assert st >= 8 and len(out) == N_MIN\n", + "tests = {\"결측 보간\": t1, \"센서 튐 제거\": t2, \"고착 구간 탐지\": t3, \"정상 변화 오탐 없음\": t4, \"고착 주입 후 보정\": t5}\n", + "passed = 0\n", + "for name, fn in tests.items():\n", + " try:\n", + " fn(); passed += 1; print(f\"[PASS] {name}\")\n", + " except AssertionError:\n", + " print(f\"[FAIL] {name}\")\n", + "print(f\"단위 테스트 {passed}/5 통과\")\n", + "\n", + "# 3) 보정 전후 체온 오차(정답 core 대비 RMSE)와 경보 분류 성능\n", + "def rmse(a, b):\n", + " m = ~np.isnan(a)\n", + " return float(np.sqrt(np.mean((a[m] - b[m]) ** 2)))\n", + "\n", + "\n", + "def hot_cold_f1(df_use, stage_src):\n", + " \"\"\"체온 38.0 이상 또는 36.3 이하를 위험으로 본 경보와 정답 stage>=1의 F1.\"\"\"\n", + " pred = (df_use[\"temp\"] >= 38.0) | (df_use[\"temp\"] <= 36.3)\n", + " truth = stage_src[\"stage\"] >= 1\n", + " tp = (pred & truth).sum(); p = tp / max(pred.sum(), 1); r = tp / max(truth.sum(), 1)\n", + " return round(2 * p * r / max(p + r, 1e-9), 3), int((pred & ~truth).sum())\n", + "\n", + "\n", + "rows = []\n", + "for kind, label in ((\"normal\", \"평시\"), (\"heat\", \"폭염\"), (\"cold\", \"혹한\")):\n", + " raw, cln, tr_ = [], [], []\n", + " for s in [x for x in data if x[\"kind\"] == kind]:\n", + " bad = inject_faults(s[\"df\"], rng)\n", + " out, _, _ = preprocess(bad)\n", + " raw.append(bad); cln.append(out); tr_.append(s[\"df\"])\n", + " raw, cln, tr_ = pd.concat(raw), pd.concat(cln), pd.concat(tr_)\n", + " f_raw, fp_raw = hot_cold_f1(raw, tr_)\n", + " f_cln, fp_cln = hot_cold_f1(cln, tr_)\n", + " rows.append({\"상황\": label, \"체온 RMSE 전\": round(rmse(raw[\"temp\"].where(raw[\"temp\"] <= 43).values, tr_[\"core\"].values), 3),\n", + " \"체온 RMSE 후\": round(rmse(cln[\"temp\"].values, tr_[\"core\"].values), 3), \"경보 F1 전\": f_raw, \"경보 F1 후\": f_cln, \"오경보 전\": fp_raw, \"오경보 후\": fp_cln})\n", + "print(\"\\n[보정 전후 비교] (전: 센서 튐·결측·고착 그대로 / 후: 전처리 적용)\")\n", + "print(pd.DataFrame(rows).to_string(index=False))\n", + "print(\"전술 노이즈 대응: 체온 센서 고착(단절)은 8분 이상 같은 값이면 결측 처리 후 보간하고, 범위 밖 값은 센서 오류로 본다. 스푸핑 판별은 본 과제 범위 밖이다.\")\n", + "\n", + "s = data[1]; bad = inject_faults(s[\"df\"], rng); out, _, _ = preprocess(bad)\n", + "plt.figure(figsize=(9, 4))\n", + "plt.plot(bad[\"min\"], bad[\"temp\"], \".\", color=\"tab:red\", label=\"raw (faults)\"); plt.plot(out[\"min\"], out[\"temp\"], color=\"tab:blue\", label=\"cleaned\")\n", + "plt.plot(s[\"df\"][\"min\"], s[\"df\"][\"core\"], \"k:\", label=\"true (simulated)\"); plt.ylim(34, 41); plt.xlabel(\"minute\"); plt.ylabel(\"temp (C)\")\n", + "plt.title(f\"{s['id']} temperature before/after preprocessing (synthetic)\"); plt.legend(); plt.show()\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 02 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + "전처리 21,600건(신호값) / 1.20초 = 18,030건/초 (목표 1,000 이상: 달성)\n", + "전처리 오류 0건 -> 오류율 0% (목표 0%), 범위 밖·결측 보정 663건\n", + "[PASS] 결측 보간\n", + "[PASS] 센서 튐 제거\n", + "[PASS] 고착 구간 탐지\n", + "[PASS] 정상 변화 오탐 없음\n", + "[PASS] 고착 주입 후 보정\n", + "단위 테스트 5/5 통과\n", + "\n", + "[보정 전후 비교] (전: 센서 튐·결측·고착 그대로 / 후: 전처리 적용)\n", + "상황 체온 RMSE 전 체온 RMSE 후 경보 F1 전 경보 F1 후 오경보 전 오경보 후\n", + "평시 0.141 0.121 0.000 0.000 4 0\n", + "폭염 0.146 0.104 0.818 0.820 378 417\n", + "혹한 0.219 0.203 0.814 0.828 397 419\n", + "전술 노이즈 대응: 체온 센서 고착(단절)은 8분 이상 같은 값이면 결측 처리 후 보간하고, 범위 밖 값은 센서 오류로 본다. 스푸핑 판별은 본 과제 범위 밖이다.\n", + "```\n", + "
\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 03. 체온-심박수 상관관계 기반 1차 열사병/저체온증 경보 Baseline 모델 구현\n", + "**소요시간**: 약 2.5시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "체온-심박수 상관관계 기반 1차 온열/한랭 스트레스 경보 Baseline 모델을 구현한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **체온과 심박수의 상관관계를 이용해 온열·한랭 스트레스를 1차로 경보하는 규칙 기반 Baseline을 구현하고 판단 기준을 투명하게 기록한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **최근 5분 평균 체온이 38.0도 이상이면서 심박수가 95 이상이면 온열 스트레스 의심, 36.3도 이하이면 한랭 스트레스 의심으로 분류 수행하고 검증하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `src/models/wel01_baseline_model.py 및 기본 추론 결과 보고서` 형태의 결과물을 완성한다.\n", + "\n", + "### 🔍 평가 및 검증 기준 (PASS 조건)\n", + "#### 미션 판정\n", + "\n", + "**평가 구분:** 필수\n", + "\n", + "**핵심 평가영역:** 알고리즘의 논리적 흐름 및 군사적 판단 기준의 투명성\n", + "\n", + "#### 미션 PASS 조건\n", + "\n", + "- 정량 기준: 기본 데이터셋 대상 탐지/처리 정확도 75% 이상 달성\n", + "\n", + "\n", + "- 진행 기준: 단위 기능 동작 확인 및 기본 시나리오 실행 로그 생성\n", + "\n", + "\n", + "- 검증 방법: 기준 벤치마크 데이터셋 대상 추론 결과 오차율 비교\n", + "\n", + "\n", + "#### 채점자 판정 메모\n", + "\n", + "> 단순 코드 실행 여부뿐만 아니라 출력된 수치와 근거 자료가 군 실무 요구사항에 부합하는지 면밀히 확인한다.\n", + "\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "\"\"\"체온과 심박수의 상관관계(체온 상승 시 심박수 동반 상승)를 이용한 규칙 기반 1차 온열·한랭 스트레스 경보 Baseline을 구현하고, 가상 병사 데이터에서 정확도와 위험 구간 재현율을 측정한다. 진단 아님·의무대 연계 원칙을 따른다.\"\"\"\n", + "import numpy as np\n", + "import pandas as pd\n", + "import matplotlib.pyplot as plt\n", + "\n", + "# ---------------- [교육용가상자료] 더미 생체신호 생성기 (실제 장병·부대·의무 기록 없음) ----------------\n", + "# 가상 병사(가명 ID)가 60분간 걷기·휴식·고강도 활동을 하는 동안의 심박수·체온(추정 심부)·피부전도도를 1분 간격으로 만든다.\n", + "# 진단 도구가 아니며, 상태 라벨은 가상 생리 모델의 '교육용 단계'일 뿐이다.\n", + "N_MIN = 60\n", + "STAGE = {0: \"정상\", 1: \"주의\", 2: \"경보\"}\n", + "TYPES = [\"normal\", \"heat\", \"cold\"]\n", + "\n", + "\n", + "def make_person(i, rng):\n", + " return {\"id\": f\"S{i:03d}\", \"rest_hr\": float(rng.normal(64, 7)), \"age\": int(rng.integers(19, 27)), \"bmi\": float(rng.normal(23.5, 2.5)),\n", + " \"acclim\": int(rng.random() < 0.4), \"fit\": float(np.clip(rng.normal(0.5, 0.2), 0.05, 0.95))}\n", + "\n", + "\n", + "def make_env(kind, rng):\n", + " if kind == \"heat\":\n", + " return {\"temp_c\": float(rng.uniform(30, 40)), \"rh\": float(rng.uniform(45, 85)), \"wind\": float(rng.uniform(0.3, 3))}\n", + " if kind == \"cold\":\n", + " return {\"temp_c\": float(rng.uniform(-22, 2)), \"rh\": float(rng.uniform(40, 80)), \"wind\": float(rng.uniform(2, 11))}\n", + " return {\"temp_c\": float(rng.uniform(8, 24)), \"rh\": float(rng.uniform(35, 70)), \"wind\": float(rng.uniform(0.3, 5))}\n", + "\n", + "\n", + "def make_session(sid, kind, rng, p=None, glitch=True):\n", + " \"\"\"반환: dict(person, env, kind, df[hr,temp,eda,act + 정답 core·stage·rtype]). 센서 결측·튐·고착 포함.\"\"\"\n", + " p = p or make_person(sid, rng)\n", + " env = make_env(kind, rng)\n", + " act = np.zeros(N_MIN); t = 0\n", + " while t < N_MIN: # 10~20분 단위 활동 블록: 휴식 1.5 / 행군 4.5 / 고강도 7 MET\n", + " d = int(rng.integers(10, 21)); act[t:t + d] = rng.choice([1.5, 4.5, 7.0], p=[0.25, 0.5, 0.25]); t += d\n", + " hi = env[\"temp_c\"] + (0.12 * (env[\"rh\"] - 40) if env[\"temp_c\"] > 25 else 0)\n", + " wc = env[\"temp_c\"] - 1.6 * env[\"wind\"]\n", + " core, cores = 36.9, []\n", + " for m in range(N_MIN):\n", + " heat = 0.085 * max(0, hi - 24) * (1 - 0.25 * p[\"acclim\"]) * (1 + 0.5 * m / N_MIN) * (1 + 0.012 * (p[\"bmi\"] - 23.5))\n", + " cold = -0.075 * max(0, 8 - wc) * (1 - 0.09 * act[m])\n", + " ss = 36.9 + 0.09 * act[m] + heat + cold\n", + " core += (ss - core) / 18 + rng.normal(0, 0.03)\n", + " cores.append(core)\n", + " core = np.array(cores)\n", + " hr = p[\"rest_hr\"] + 9.5 * (act - 1.5) * (1.15 - 0.3 * p[\"fit\"]) + 11 * (core - 36.9) + rng.normal(0, 3.2, N_MIN)\n", + " eda = 2.0 + 0.25 * act + 3.2 * np.maximum(core - 37.2, 0) - 0.04 * np.maximum(8 - wc, 0) + rng.normal(0, 0.25, N_MIN)\n", + " bias = rng.normal(0, 0.1)\n", + " temp = core + bias + rng.normal(0, 0.1, N_MIN) - np.where((kind == \"cold\") & (env[\"wind\"] > 7), 0.25, 0.0) # 센서 접촉 부위 바람 냉각 오차\n", + " df = pd.DataFrame({\"min\": np.arange(N_MIN), \"hr\": hr, \"temp\": temp, \"eda\": np.maximum(eda, 0.2), \"act\": act})\n", + " if glitch:\n", + " for col in (\"hr\", \"temp\", \"eda\"):\n", + " df.loc[rng.random(N_MIN) < 0.03, col] = np.nan # 결측\n", + " s = int(rng.integers(5, N_MIN))\n", + " if rng.random() < 0.12:\n", + " df.loc[s, \"hr\"] = 230.0 # 센서 튐\n", + " if rng.random() < 0.12:\n", + " df.loc[s, \"temp\"] = 45.0\n", + " ht = np.where(core >= 39.0, 2, np.where(core >= 38.3, 1, 0)); cd = np.where(core <= 35.4, 2, np.where(core <= 36.2, 1, 0))\n", + " df[\"core\"] = core\n", + " df[\"stage\"] = np.maximum(ht, cd); df[\"rtype\"] = np.where(ht > 0, \"heat\", np.where(cd > 0, \"cold\", \"normal\"))\n", + " return {\"id\": p[\"id\"], \"person\": p, \"env\": env, \"kind\": kind, \"df\": df}\n", + "\n", + "\n", + "def make_dataset(n, seed):\n", + " rng = np.random.default_rng(seed)\n", + " kinds = [\"normal\", \"heat\", \"cold\"]\n", + " return [make_session(i, kinds[i % 3], rng) for i in range(n)]\n", + "# ---------------------------------------------------------------------------\n", + "\n", + "# ---------------- 전처리·특징 추출 공통 함수 ----------------\n", + "LIMITS = {\"hr\": (30, 220), \"temp\": (30.0, 43.0), \"eda\": (0.0, 40.0)} # 생리적으로 가능한 범위(범위 밖 = 센서 오류)\n", + "\n", + "\n", + "def clean(df):\n", + " \"\"\"범위 밖 값은 결측 처리 -> 선형 보간(양 끝은 가까운 값) -> 3분 이동 중앙값으로 튐 제거. 반환: (정제 df, 보정 건수)\"\"\"\n", + " out = df.copy(); fixed = 0\n", + " for c, (lo, hi) in LIMITS.items():\n", + " bad = out[c].isna() | (out[c] < lo) | (out[c] > hi)\n", + " fixed += int(bad.sum())\n", + " out.loc[bad, c] = np.nan\n", + " out[c] = out[c].interpolate(limit_direction=\"both\").rolling(3, center=True, min_periods=1).median()\n", + " return out, fixed\n", + "\n", + "\n", + "def env_index(env):\n", + " hi = env[\"temp_c\"] + (0.12 * (env[\"rh\"] - 40) if env[\"temp_c\"] > 25 else 0.0) # 체감 더위 지수(가상 단순식)\n", + " wc = env[\"temp_c\"] - 1.6 * env[\"wind\"] # 체감 추위 지수(가상 단순식)\n", + " return hi, wc\n", + "\n", + "\n", + "def features(s, use_env=True, personal=True):\n", + " \"\"\"세션 1개 -> 분 단위 특징표(최근 5분 평균·기울기 포함). 정답 stage/rtype 포함.\"\"\"\n", + " df, _ = clean(s[\"df\"]); p, e = s[\"person\"], s[\"env\"]\n", + " f = pd.DataFrame({\"sid\": s[\"id\"], \"min\": df[\"min\"]})\n", + " f[\"temp5\"] = df[\"temp\"].rolling(5, min_periods=1).mean()\n", + " f[\"hr5\"] = df[\"hr\"].rolling(5, min_periods=1).mean()\n", + " f[\"eda5\"] = df[\"eda\"].rolling(5, min_periods=1).mean()\n", + " f[\"dtemp\"] = (f[\"temp5\"] - f[\"temp5\"].shift(4)).fillna(0)\n", + " f[\"act\"] = df[\"act\"]\n", + " if personal:\n", + " f[\"hr_rel\"] = f[\"hr5\"] - p[\"rest_hr\"]\n", + " f[\"acclim\"], f[\"bmi\"], f[\"fit\"] = p[\"acclim\"], p[\"bmi\"], p[\"fit\"]\n", + " if use_env:\n", + " f[\"hi\"], f[\"wc\"] = env_index(e); f[\"wind\"] = e[\"wind\"]\n", + " f[\"stage\"], f[\"rtype\"], f[\"kind\"] = df[\"stage\"], df[\"rtype\"], s[\"kind\"]\n", + " return f[f[\"min\"] >= 5].reset_index(drop=True)\n", + "\n", + "\n", + "def table(sessions, **kw):\n", + " return pd.concat([features(s, **kw) for s in sessions], ignore_index=True)\n", + "# ---------------------------------------------------------------------------\n", + "\n", + "# 판단 기준(투명성): ① 최근 5분 평균 체온이 38.0도 이상이고 심박수가 95 이상이면 '온열 스트레스 의심'\n", + "# ② 최근 5분 평균 체온이 36.3도 이하이면 '한랭 스트레스 의심' ③ 그 외 정상\n", + "TH_HEAT, TH_COLD, HR_GATE = 38.0, 36.3, 95\n", + "\n", + "\n", + "def rule(r, th=TH_HEAT, tc=TH_COLD, hg=HR_GATE):\n", + " if r[\"temp5\"] >= th and r[\"hr5\"] >= hg:\n", + " return \"heat\"\n", + " if r[\"temp5\"] <= tc:\n", + " return \"cold\"\n", + " return \"normal\"\n", + "\n", + "\n", + "data = make_dataset(90, seed=7)\n", + "tab = table(data, use_env=False, personal=False)\n", + "tab[\"pred\"] = tab.apply(rule, axis=1)\n", + "acc = (tab[\"pred\"] == tab[\"rtype\"]).mean()\n", + "print(f\"[Baseline] 가상 병사 90명 x 55분 = {len(tab):,}개 구간, 정답 분포 {tab['rtype'].value_counts().to_dict()}\")\n", + "print(f\"경보 유형 분류 정확도 {acc:.1%} (목표 75% 이상) -> {'달성' if acc >= 0.75 else '미달'}\")\n", + "\n", + "print(\"\\n[유형별 판정] (행: 정답, 열: 판정)\")\n", + "cm = pd.crosstab(tab[\"rtype\"], tab[\"pred\"]).reindex(index=TYPES, columns=TYPES, fill_value=0)\n", + "print(cm.to_string())\n", + "for c in (\"heat\", \"cold\"):\n", + " rec = cm.loc[c, c] / cm.loc[c].sum(); prec = cm.loc[c, c] / max(cm[c].sum(), 1)\n", + " print(f\"{c}: 재현율 {rec:.1%}, 정밀도 {prec:.1%}\")\n", + "\n", + "print(\"\\n[단계별 위험 구간 재현율] (위험 유형을 위험으로 맞춘 비율)\")\n", + "risk = tab[tab[\"stage\"] >= 1]\n", + "print(risk.groupby(\"stage\").apply(lambda g: f\"{(g['pred'] == g['rtype']).mean():.1%} ({len(g)}구간)\").to_string())\n", + "\n", + "print(\"\\n[체온-심박수 상관] 전체 상관계수 %.2f / 폭염 세션 %.2f / 혹한 세션 %.2f\" % (\n", + " tab[[\"temp5\", \"hr5\"]].corr().iloc[0, 1], tab[tab[\"kind\"] == \"heat\"][[\"temp5\", \"hr5\"]].corr().iloc[0, 1], tab[tab[\"kind\"] == \"cold\"][[\"temp5\", \"hr5\"]].corr().iloc[0, 1]))\n", + "\n", + "print(\"\\n[임계값 민감도] 온열 체온 기준 변화\")\n", + "for th in (37.7, 38.0, 38.3, 38.6):\n", + " p = tab.apply(lambda r: rule(r, th=th), axis=1)\n", + " r = ((p == \"heat\") & (tab[\"rtype\"] == \"heat\")).sum() / (tab[\"rtype\"] == \"heat\").sum(); f = ((p == \"heat\") & (tab[\"rtype\"] != \"heat\")).sum()\n", + " print(f\"기준 {th}: 온열 재현율 {r:.1%}, 오경보 {f}구간\")\n", + "print(\"한계: 개인별 안정시 심박·기상·활동 강도를 반영하지 않아 운동 직후 심박 상승과 바람에 의한 센서 냉각을 구분하지 못한다 -> 미션 4·5에서 보완한다.\")\n", + "print(\"본 결과는 교육용 가상 자료의 경보 단계이며 진단이 아니다. 실제 이상 징후는 의무대·군의관에게 연계한다.\")\n", + "\n", + "colr = {\"normal\": \"tab:gray\", \"heat\": \"tab:red\", \"cold\": \"tab:blue\"}\n", + "plt.figure(figsize=(7, 5))\n", + "for k, g in tab.groupby(\"rtype\"):\n", + " plt.scatter(g[\"temp5\"], g[\"hr5\"], s=6, alpha=0.4, color=colr[k], label=k)\n", + "plt.axvline(TH_HEAT, ls=\"--\", color=\"k\"); plt.axvline(TH_COLD, ls=\"--\", color=\"k\"); plt.axhline(HR_GATE, ls=\":\", color=\"k\")\n", + "plt.xlabel(\"temperature (5-min mean, C)\"); plt.ylabel(\"heart rate (5-min mean, bpm)\"); plt.legend(); plt.title(\"Baseline rule on dummy signals\")\n", + "plt.show()\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 03 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + "[Baseline] 가상 병사 90명 x 55분 = 4,950개 구간, 정답 분포 {'normal': 3536, 'cold': 761, 'heat': 653}\n", + "경보 유형 분류 정확도 86.9% (목표 75% 이상) -> 달성\n", + "\n", + "[유형별 판정] (행: 정답, 열: 판정)\n", + "pred normal heat cold\n", + "rtype \n", + "normal 3056 246 234\n", + "heat 160 493 0\n", + "cold 7 0 754\n", + "heat: 재현율 75.5%, 정밀도 66.7%\n", + "cold: 재현율 99.1%, 정밀도 76.3%\n", + "\n", + "[단계별 위험 구간 재현율] (위험 유형을 위험으로 맞춘 비율)\n", + "stage\n", + "1 86.8% (1221구간)\n", + "2 96.9% (193구간)\n", + "\n", + "[체온-심박수 상관] 전체 상관계수 0.48 / 폭염 세션 0.32 / 혹한 세션 0.41\n", + "\n", + "[임계값 민감도] 온열 체온 기준 변화\n", + "기준 37.7: 온열 재현율 75.7%, 오경보 462구간\n", + "기준 38.0: 온열 재현율 75.5%, 오경보 246구간\n", + "기준 38.3: 온열 재현율 67.7%, 오경보 38구간\n", + "기준 38.6: 온열 재현율 31.1%, 오경보 0구간\n", + "한계: 개인별 안정시 심박·기상·활동 강도를 반영하지 않아 운동 직후 심박 상승과 바람에 의한 센서 냉각을 구분하지 못한다 -> 미션 4·5에서 보완한다.\n", + "본 결과는 교육용 가상 자료의 경보 단계이며 진단이 아니다. 실제 이상 징후는 의무대·군의관에게 연계한다.\n", + "```\n", + "
\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 04. 기상 데이터(온·습도, 풍속) 결합 온열/한랭 스트레스 복합 위험도 예측 모델 고도화\n", + "**소요시간**: 약 3.0시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "기상 데이터와 결합한 복합 환경 위험도 예측 모델을 고도화한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **기상(온도·습도·풍속)과 개인 특성을 결합한 복합 환경 위험도 분류 모델로 고도화하고, 센서 이상·교란 신호를 점검하는 예외 탐지를 추가한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **체감 더위·추위 지수, 안정시 심박 대비 심박 상승, 체온 변화율, 더위 순응 여부 등을 특징으로 그래디언트 부스팅 모델을 학습 수행하고 검증하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `src/models/wel01_advanced_detector.py 및 예외 탐지 테스트 결과` 형태의 결과물을 완성한다.\n", + "\n", + "### 🔍 평가 및 검증 기준 (PASS 조건)\n", + "#### 미션 판정\n", + "\n", + "**평가 구분:** 필수\n", + "\n", + "**핵심 평가영역:** 기만/교란 공격에 대한 방어 로직의 현실성 및 신뢰성\n", + "\n", + "#### 미션 PASS 조건\n", + "\n", + "- 정량 기준: 복합 위협 탐지 재현율(Recall) 85% 이상, 처리 지연 200ms 이하\n", + "\n", + "\n", + "- 진행 기준: 복합 위협 시나리오 10종에 대한 탐지 테스트 통과\n", + "\n", + "\n", + "- 검증 방법: 합성 위협 데이터셋 주입 시 이상치 탐지 정확도 검증\n", + "\n", + "\n", + "#### 채점자 판정 메모\n", + "\n", + "> 단순 코드 실행 여부뿐만 아니라 출력된 수치와 근거 자료가 군 실무 요구사항에 부합하는지 면밀히 확인한다.\n", + "\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "\"\"\"체온·심박수·피부전도도에 기상(온도·습도·풍속)과 개인 특성(안정시 심박, 순응 여부, 체격)을 결합한 복합 환경 위험도 분류 모델(그래디언트 부스팅)로 고도화하고, 위험 구간 재현율·처리 지연을 Baseline과 비교한다. 센서 이상 예외 탐지 10종도 시험한다. 진단 아님·의무대 연계.\"\"\"\n", + "import time\n", + "import numpy as np\n", + "import pandas as pd\n", + "from sklearn.ensemble import GradientBoostingClassifier\n", + "\n", + "# ---------------- [교육용가상자료] 더미 생체신호 생성기 (실제 장병·부대·의무 기록 없음) ----------------\n", + "# 가상 병사(가명 ID)가 60분간 걷기·휴식·고강도 활동을 하는 동안의 심박수·체온(추정 심부)·피부전도도를 1분 간격으로 만든다.\n", + "# 진단 도구가 아니며, 상태 라벨은 가상 생리 모델의 '교육용 단계'일 뿐이다.\n", + "N_MIN = 60\n", + "STAGE = {0: \"정상\", 1: \"주의\", 2: \"경보\"}\n", + "TYPES = [\"normal\", \"heat\", \"cold\"]\n", + "\n", + "\n", + "def make_person(i, rng):\n", + " return {\"id\": f\"S{i:03d}\", \"rest_hr\": float(rng.normal(64, 7)), \"age\": int(rng.integers(19, 27)), \"bmi\": float(rng.normal(23.5, 2.5)),\n", + " \"acclim\": int(rng.random() < 0.4), \"fit\": float(np.clip(rng.normal(0.5, 0.2), 0.05, 0.95))}\n", + "\n", + "\n", + "def make_env(kind, rng):\n", + " if kind == \"heat\":\n", + " return {\"temp_c\": float(rng.uniform(30, 40)), \"rh\": float(rng.uniform(45, 85)), \"wind\": float(rng.uniform(0.3, 3))}\n", + " if kind == \"cold\":\n", + " return {\"temp_c\": float(rng.uniform(-22, 2)), \"rh\": float(rng.uniform(40, 80)), \"wind\": float(rng.uniform(2, 11))}\n", + " return {\"temp_c\": float(rng.uniform(8, 24)), \"rh\": float(rng.uniform(35, 70)), \"wind\": float(rng.uniform(0.3, 5))}\n", + "\n", + "\n", + "def make_session(sid, kind, rng, p=None, glitch=True):\n", + " \"\"\"반환: dict(person, env, kind, df[hr,temp,eda,act + 정답 core·stage·rtype]). 센서 결측·튐·고착 포함.\"\"\"\n", + " p = p or make_person(sid, rng)\n", + " env = make_env(kind, rng)\n", + " act = np.zeros(N_MIN); t = 0\n", + " while t < N_MIN: # 10~20분 단위 활동 블록: 휴식 1.5 / 행군 4.5 / 고강도 7 MET\n", + " d = int(rng.integers(10, 21)); act[t:t + d] = rng.choice([1.5, 4.5, 7.0], p=[0.25, 0.5, 0.25]); t += d\n", + " hi = env[\"temp_c\"] + (0.12 * (env[\"rh\"] - 40) if env[\"temp_c\"] > 25 else 0)\n", + " wc = env[\"temp_c\"] - 1.6 * env[\"wind\"]\n", + " core, cores = 36.9, []\n", + " for m in range(N_MIN):\n", + " heat = 0.085 * max(0, hi - 24) * (1 - 0.25 * p[\"acclim\"]) * (1 + 0.5 * m / N_MIN) * (1 + 0.012 * (p[\"bmi\"] - 23.5))\n", + " cold = -0.075 * max(0, 8 - wc) * (1 - 0.09 * act[m])\n", + " ss = 36.9 + 0.09 * act[m] + heat + cold\n", + " core += (ss - core) / 18 + rng.normal(0, 0.03)\n", + " cores.append(core)\n", + " core = np.array(cores)\n", + " hr = p[\"rest_hr\"] + 9.5 * (act - 1.5) * (1.15 - 0.3 * p[\"fit\"]) + 11 * (core - 36.9) + rng.normal(0, 3.2, N_MIN)\n", + " eda = 2.0 + 0.25 * act + 3.2 * np.maximum(core - 37.2, 0) - 0.04 * np.maximum(8 - wc, 0) + rng.normal(0, 0.25, N_MIN)\n", + " bias = rng.normal(0, 0.1)\n", + " temp = core + bias + rng.normal(0, 0.1, N_MIN) - np.where((kind == \"cold\") & (env[\"wind\"] > 7), 0.25, 0.0) # 센서 접촉 부위 바람 냉각 오차\n", + " df = pd.DataFrame({\"min\": np.arange(N_MIN), \"hr\": hr, \"temp\": temp, \"eda\": np.maximum(eda, 0.2), \"act\": act})\n", + " if glitch:\n", + " for col in (\"hr\", \"temp\", \"eda\"):\n", + " df.loc[rng.random(N_MIN) < 0.03, col] = np.nan # 결측\n", + " s = int(rng.integers(5, N_MIN))\n", + " if rng.random() < 0.12:\n", + " df.loc[s, \"hr\"] = 230.0 # 센서 튐\n", + " if rng.random() < 0.12:\n", + " df.loc[s, \"temp\"] = 45.0\n", + " ht = np.where(core >= 39.0, 2, np.where(core >= 38.3, 1, 0)); cd = np.where(core <= 35.4, 2, np.where(core <= 36.2, 1, 0))\n", + " df[\"core\"] = core\n", + " df[\"stage\"] = np.maximum(ht, cd); df[\"rtype\"] = np.where(ht > 0, \"heat\", np.where(cd > 0, \"cold\", \"normal\"))\n", + " return {\"id\": p[\"id\"], \"person\": p, \"env\": env, \"kind\": kind, \"df\": df}\n", + "\n", + "\n", + "def make_dataset(n, seed):\n", + " rng = np.random.default_rng(seed)\n", + " kinds = [\"normal\", \"heat\", \"cold\"]\n", + " return [make_session(i, kinds[i % 3], rng) for i in range(n)]\n", + "# ---------------------------------------------------------------------------\n", + "\n", + "# ---------------- 전처리·특징 추출 공통 함수 ----------------\n", + "LIMITS = {\"hr\": (30, 220), \"temp\": (30.0, 43.0), \"eda\": (0.0, 40.0)} # 생리적으로 가능한 범위(범위 밖 = 센서 오류)\n", + "\n", + "\n", + "def clean(df):\n", + " \"\"\"범위 밖 값은 결측 처리 -> 선형 보간(양 끝은 가까운 값) -> 3분 이동 중앙값으로 튐 제거. 반환: (정제 df, 보정 건수)\"\"\"\n", + " out = df.copy(); fixed = 0\n", + " for c, (lo, hi) in LIMITS.items():\n", + " bad = out[c].isna() | (out[c] < lo) | (out[c] > hi)\n", + " fixed += int(bad.sum())\n", + " out.loc[bad, c] = np.nan\n", + " out[c] = out[c].interpolate(limit_direction=\"both\").rolling(3, center=True, min_periods=1).median()\n", + " return out, fixed\n", + "\n", + "\n", + "def env_index(env):\n", + " hi = env[\"temp_c\"] + (0.12 * (env[\"rh\"] - 40) if env[\"temp_c\"] > 25 else 0.0) # 체감 더위 지수(가상 단순식)\n", + " wc = env[\"temp_c\"] - 1.6 * env[\"wind\"] # 체감 추위 지수(가상 단순식)\n", + " return hi, wc\n", + "\n", + "\n", + "def features(s, use_env=True, personal=True):\n", + " \"\"\"세션 1개 -> 분 단위 특징표(최근 5분 평균·기울기 포함). 정답 stage/rtype 포함.\"\"\"\n", + " df, _ = clean(s[\"df\"]); p, e = s[\"person\"], s[\"env\"]\n", + " f = pd.DataFrame({\"sid\": s[\"id\"], \"min\": df[\"min\"]})\n", + " f[\"temp5\"] = df[\"temp\"].rolling(5, min_periods=1).mean()\n", + " f[\"hr5\"] = df[\"hr\"].rolling(5, min_periods=1).mean()\n", + " f[\"eda5\"] = df[\"eda\"].rolling(5, min_periods=1).mean()\n", + " f[\"dtemp\"] = (f[\"temp5\"] - f[\"temp5\"].shift(4)).fillna(0)\n", + " f[\"act\"] = df[\"act\"]\n", + " if personal:\n", + " f[\"hr_rel\"] = f[\"hr5\"] - p[\"rest_hr\"]\n", + " f[\"acclim\"], f[\"bmi\"], f[\"fit\"] = p[\"acclim\"], p[\"bmi\"], p[\"fit\"]\n", + " if use_env:\n", + " f[\"hi\"], f[\"wc\"] = env_index(e); f[\"wind\"] = e[\"wind\"]\n", + " f[\"stage\"], f[\"rtype\"], f[\"kind\"] = df[\"stage\"], df[\"rtype\"], s[\"kind\"]\n", + " return f[f[\"min\"] >= 5].reset_index(drop=True)\n", + "\n", + "\n", + "def table(sessions, **kw):\n", + " return pd.concat([features(s, **kw) for s in sessions], ignore_index=True)\n", + "# ---------------------------------------------------------------------------\n", + "\n", + "ADV_FEATS = [\"temp5\", \"hr5\", \"eda5\", \"dtemp\", \"act\", \"hr_rel\", \"acclim\", \"bmi\", \"fit\", \"hi\", \"wc\", \"wind\"]\n", + "LABEL = {\"normal\": 0, \"heat\": 1, \"cold\": 2}\n", + "\n", + "\n", + "def baseline_pred(t):\n", + " return np.where((t[\"temp5\"] >= 38.0) & (t[\"hr5\"] >= 95), \"heat\", np.where(t[\"temp5\"] <= 36.3, \"cold\", \"normal\"))\n", + "\n", + "\n", + "train = table(make_dataset(150, seed=31))\n", + "clf = GradientBoostingClassifier(n_estimators=80, max_depth=3, learning_rate=0.1, random_state=0).fit(train[ADV_FEATS], train[\"rtype\"].map(LABEL))\n", + "inv = {v: k for k, v in LABEL.items()}\n", + "\n", + "# 시험셋 2종: 일반 / 복합 교란(강풍 혹한 + 고강도 활동 + 센서 튐 많음)\n", + "std = make_dataset(90, seed=77)\n", + "rng = np.random.default_rng(9)\n", + "hard = []\n", + "for i in range(60):\n", + " s = make_session(i, [\"heat\", \"cold\"][i % 2], rng)\n", + " if s[\"kind\"] == \"cold\":\n", + " s[\"env\"][\"wind\"] = float(rng.uniform(7, 11))\n", + " for col, val in ((\"hr\", 230.0), (\"temp\", 45.0)):\n", + " idx = rng.choice(np.arange(5, N_MIN), 3, replace=False); s[\"df\"].loc[idx, col] = val\n", + " hard.append(s)\n", + "\n", + "rows = []\n", + "for name, sess in ((\"일반\", std), (\"복합 교란\", hard)):\n", + " t = table(sess)\n", + " pb = baseline_pred(t)\n", + " t0 = time.perf_counter(); pa = np.array([inv[v] for v in clf.predict(t[ADV_FEATS])]); ms = (time.perf_counter() - t0) * 1000 / len(t)\n", + " risk = t[\"stage\"] >= 1; danger = t[\"stage\"] == 2\n", + " rows.append({\"시험셋\": name, \"구간 수\": len(t), \"Baseline 위험 재현율\": round(float((pb[risk] == t[\"rtype\"][risk]).mean()), 3),\n", + " \"고도화 위험 재현율\": round(float((pa[risk] == t[\"rtype\"][risk]).mean()), 3),\n", + " \"고도화 경보(2단계) 재현율\": round(float((pa[danger] == t[\"rtype\"][danger]).mean()), 3),\n", + " \"Baseline 오경보\": int(((pb != \"normal\") & ~risk).sum()), \"고도화 오경보\": int(((pa != \"normal\") & ~risk).sum()), \"지연(ms/구간)\": round(ms, 3)})\n", + "out = pd.DataFrame(rows)\n", + "print(out.T.to_string(header=False))\n", + "rec = out[\"고도화 위험 재현율\"].min(); lat = out[\"지연(ms/구간)\"].max()\n", + "imp = pd.Series(clf.feature_importances_, index=ADV_FEATS).sort_values(ascending=False)\n", + "print(\"\\n특징 중요도 상위:\", \", \".join(f\"{k} {v:.2f}\" for k, v in imp.head(5).items()))\n", + "print(f\"\\n복합 위협 탐지 재현율(최저 시험셋) {rec:.1%} (목표 85% 이상) -> {'달성' if rec >= 0.85 else '미달'}\")\n", + "print(f\"처리 지연 최대 {lat:.3f} ms/구간 (목표 200 ms 이하) -> {'달성' if lat <= 200 else '미달'}\")\n", + "\n", + "\n", + "# ---- 예외(센서 이상·기만) 탐지 10종: 모델에 넣기 전 신호 자체의 신뢰성을 점검한다 ----\n", + "def anomaly(w):\n", + " \"\"\"최근 10분 원시 신호 창(hr, temp, eda 배열 dict)의 이상 유형 목록\"\"\"\n", + " flags = []\n", + " for c in (\"hr\", \"temp\", \"eda\"):\n", + " v = w[c]\n", + " if np.isnan(v).all():\n", + " flags.append(f\"{c}_missing_all\")\n", + " else:\n", + " lo, hi = LIMITS[c]\n", + " if ((v < lo) | (v > hi)).any():\n", + " flags.append(f\"{c}_out_of_range\")\n", + " if np.isnan(v).mean() > 0.3:\n", + " flags.append(f\"{c}_many_missing\")\n", + " ok = v[~np.isnan(v)]\n", + " if len(ok) >= 8 and np.ptp(ok) < 1e-6:\n", + " flags.append(f\"{c}_flatline\")\n", + " if len(ok) >= 3 and np.abs(np.diff(ok)).max() > {\"hr\": 60, \"temp\": 1.5, \"eda\": 10}[c]:\n", + " flags.append(f\"{c}_jump\")\n", + " if \"temp_out_of_range\" not in flags and \"hr_out_of_range\" not in flags and not np.isnan(w[\"temp\"]).all() and not np.isnan(w[\"hr\"]).all():\n", + " if np.nanmean(w[\"temp\"]) >= 38.5 and np.nanmean(w[\"hr\"]) < 60:\n", + " flags.append(\"temp_hr_inconsistent\")\n", + " return flags\n", + "\n", + "\n", + "def win(hr=80, temp=37.0, eda=2.0, n=10):\n", + " return {\"hr\": np.full(n, float(hr)) + np.arange(n) % 3, \"temp\": np.full(n, float(temp)) + np.arange(n) * 0.01, \"eda\": np.full(n, float(eda)) + np.arange(n) % 2 * 0.1}\n", + "\n", + "\n", + "cases = []\n", + "w = win(); cases.append((\"1 정상\", w, []))\n", + "w = win(); w[\"hr\"][4] = 230; cases.append((\"2 심박 튐\", w, [\"hr_out_of_range\", \"hr_jump\"]))\n", + "w = win(); w[\"temp\"][:] = 37.0; cases.append((\"3 체온 고착\", w, [\"temp_flatline\"]))\n", + "w = win(); w[\"temp\"][:6] = np.nan; cases.append((\"4 체온 결측 다수\", w, [\"temp_many_missing\"]))\n", + "w = win(); w[\"hr\"][:] = np.nan; cases.append((\"5 심박 전체 두절\", w, [\"hr_missing_all\"]))\n", + "w = win(); w[\"temp\"][3] = 45.0; cases.append((\"6 체온 범위 이탈\", w, [\"temp_out_of_range\", \"temp_jump\"]))\n", + "w = win(temp=39.0, hr=55); cases.append((\"7 체온-심박 불일치\", w, [\"temp_hr_inconsistent\"]))\n", + "w = win(); w[\"eda\"][:] = 2.0; cases.append((\"8 피부전도 고착\", w, [\"eda_flatline\"]))\n", + "w = win(); w[\"temp\"][5:] += 2.0; cases.append((\"9 체온 급변\", w, [\"temp_jump\"]))\n", + "w = win(); w[\"hr\"][:] = 80.0; w[\"temp\"][:] = 37.0; cases.append((\"10 심박·체온 동시 고착\", w, [\"hr_flatline\", \"temp_flatline\"]))\n", + "hit = 0\n", + "print(\"\\n[센서 이상 탐지 시험 10종]\")\n", + "for name, w, expect in cases:\n", + " got = anomaly(w); good = all(e in got for e in expect) and (bool(got) == bool(expect)); hit += good\n", + " print(f\"[{'PASS' if good else 'FAIL'}] {name}: {', '.join(got) or '이상 없음'}\")\n", + "print(f\"통과 {hit}/10. 이상 플래그가 있으면 모델 경보보다 먼저 '센서 점검 필요'를 표시하고, 판단은 소대장·의무관 확인으로 넘긴다(기만·교란 신호로 인한 오판 방지).\")\n", + "print(\"본 결과는 교육용 가상 자료 기준이며 진단이 아니다. 실제 이상 징후는 의무대·군의관에게 연계한다.\")\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 04 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + "시험셋 일반 복합 교란\n", + "구간 수 4950 3300\n", + "Baseline 위험 재현율 0.879 0.916\n", + "고도화 위험 재현율 0.929 0.934\n", + "고도화 경보(2단계) 재현율 1.0 1.0\n", + "Baseline 오경보 446 422\n", + "고도화 오경보 108 98\n", + "지연(ms/구간) 0.004 0.004\n", + "\n", + "특징 중요도 상위: temp5 0.48, eda5 0.42, hr_rel 0.05, hr5 0.01, fit 0.01\n", + "\n", + "복합 위협 탐지 재현율(최저 시험셋) 92.9% (목표 85% 이상) -> 달성\n", + "처리 지연 최대 0.004 ms/구간 (목표 200 ms 이하) -> 달성\n", + "\n", + "[센서 이상 탐지 시험 10종]\n", + "[PASS] 1 정상: 이상 없음\n", + "[PASS] 2 심박 튐: hr_out_of_range, hr_jump\n", + "[PASS] 3 체온 고착: temp_flatline\n", + "[PASS] 4 체온 결측 다수: temp_many_missing\n", + "[PASS] 5 심박 전체 두절: hr_missing_all\n", + "[PASS] 6 체온 범위 이탈: temp_out_of_range, temp_jump\n", + "[PASS] 7 체온-심박 불일치: temp_hr_inconsistent\n", + "[PASS] 8 피부전도 고착: eda_flatline\n", + "[PASS] 9 체온 급변: temp_jump\n", + "[PASS] 10 심박·체온 동시 고착: hr_flatline, temp_flatline\n", + "통과 10/10. 이상 플래그가 있으면 모델 경보보다 먼저 '센서 점검 필요'를 표시하고, 판단은 소대장·의무관 확인으로 넘긴다(기만·교란 신호로 인한 오판 방지).\n", + "본 결과는 교육용 가상 자료 기준이며 진단이 아니다. 실제 이상 징후는 의무대·군의관에게 연계한다.\n", + "```\n", + "
\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 05. 전투원 신체 조건별 개인화 위험 지수 산출 및 오경보 방지 신뢰도 엔진 개발\n", + "**소요시간**: 약 3.0시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "개인화 위험 지수 산출 및 오경보 방지 신뢰도 엔진을 개발한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **병사 개인의 체력·순응 여부를 반영한 개인화 위험 지수를 산출하고 오경보를 줄이는 신뢰도 엔진을 개발한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **활동 강도와 개인 체력으로 예상되는 심박을 빼 '설명되지 않는 심박 상승'과 체온 편차를 만들고, 로지스틱 회귀로 위험 확률을 계산해 0.8 이상 High, 0.5 이상 Medium, 그 미만 Low 등급을 부여 수행하고 검증하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `src/verification/wel01_confidence_engine.py` 형태의 결과물을 완성한다.\n", + "\n", + "### 🔍 평가 및 검증 기준 (PASS 조건)\n", + "#### 미션 판정\n", + "\n", + "**평가 구분:** 필수\n", + "\n", + "**핵심 평가영역:** 신뢰도 등급(High/Med/Low) 부여 기준의 군사적 타당성\n", + "\n", + "#### 미션 PASS 조건\n", + "\n", + "- 정량 기준: 오경보율(FAR) 40% 이상 감축, 신뢰도 스코어 산출 일관성 90% 이상\n", + "\n", + "\n", + "- 진행 기준: 오경보 필터링 전후 비교 지표 산출 및 통합 테스트 완료\n", + "\n", + "\n", + "- 검증 방법: ROC-AUC 곡선 분석 및 임계값 민감도 테스트\n", + "\n", + "\n", + "#### 채점자 판정 메모\n", + "\n", + "> 단순 코드 실행 여부뿐만 아니라 출력된 수치와 근거 자료가 군 실무 요구사항에 부합하는지 면밀히 확인한다.\n", + "\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "\"\"\"개인별 안정시 심박·체격·순응 여부를 반영해 활동 강도로 설명되는 심박 상승을 보정한 개인화 위험 지수를 계산하고, 오경보를 줄이는 신뢰도 점수와 등급(High/Medium/Low)을 산출해 고정 임계값 방식 대비 오경보율(FAR) 감축과 점수 일관성을 검증한다. 진단 아님·의무대 연계.\"\"\"\n", + "import numpy as np\n", + "import pandas as pd\n", + "import matplotlib.pyplot as plt\n", + "from sklearn.linear_model import LogisticRegression\n", + "from sklearn.metrics import roc_auc_score, roc_curve\n", + "from sklearn.pipeline import make_pipeline\n", + "from sklearn.preprocessing import StandardScaler\n", + "\n", + "# ---------------- [교육용가상자료] 더미 생체신호 생성기 (실제 장병·부대·의무 기록 없음) ----------------\n", + "# 가상 병사(가명 ID)가 60분간 걷기·휴식·고강도 활동을 하는 동안의 심박수·체온(추정 심부)·피부전도도를 1분 간격으로 만든다.\n", + "# 진단 도구가 아니며, 상태 라벨은 가상 생리 모델의 '교육용 단계'일 뿐이다.\n", + "N_MIN = 60\n", + "STAGE = {0: \"정상\", 1: \"주의\", 2: \"경보\"}\n", + "TYPES = [\"normal\", \"heat\", \"cold\"]\n", + "\n", + "\n", + "def make_person(i, rng):\n", + " return {\"id\": f\"S{i:03d}\", \"rest_hr\": float(rng.normal(64, 7)), \"age\": int(rng.integers(19, 27)), \"bmi\": float(rng.normal(23.5, 2.5)),\n", + " \"acclim\": int(rng.random() < 0.4), \"fit\": float(np.clip(rng.normal(0.5, 0.2), 0.05, 0.95))}\n", + "\n", + "\n", + "def make_env(kind, rng):\n", + " if kind == \"heat\":\n", + " return {\"temp_c\": float(rng.uniform(30, 40)), \"rh\": float(rng.uniform(45, 85)), \"wind\": float(rng.uniform(0.3, 3))}\n", + " if kind == \"cold\":\n", + " return {\"temp_c\": float(rng.uniform(-22, 2)), \"rh\": float(rng.uniform(40, 80)), \"wind\": float(rng.uniform(2, 11))}\n", + " return {\"temp_c\": float(rng.uniform(8, 24)), \"rh\": float(rng.uniform(35, 70)), \"wind\": float(rng.uniform(0.3, 5))}\n", + "\n", + "\n", + "def make_session(sid, kind, rng, p=None, glitch=True):\n", + " \"\"\"반환: dict(person, env, kind, df[hr,temp,eda,act + 정답 core·stage·rtype]). 센서 결측·튐·고착 포함.\"\"\"\n", + " p = p or make_person(sid, rng)\n", + " env = make_env(kind, rng)\n", + " act = np.zeros(N_MIN); t = 0\n", + " while t < N_MIN: # 10~20분 단위 활동 블록: 휴식 1.5 / 행군 4.5 / 고강도 7 MET\n", + " d = int(rng.integers(10, 21)); act[t:t + d] = rng.choice([1.5, 4.5, 7.0], p=[0.25, 0.5, 0.25]); t += d\n", + " hi = env[\"temp_c\"] + (0.12 * (env[\"rh\"] - 40) if env[\"temp_c\"] > 25 else 0)\n", + " wc = env[\"temp_c\"] - 1.6 * env[\"wind\"]\n", + " core, cores = 36.9, []\n", + " for m in range(N_MIN):\n", + " heat = 0.085 * max(0, hi - 24) * (1 - 0.25 * p[\"acclim\"]) * (1 + 0.5 * m / N_MIN) * (1 + 0.012 * (p[\"bmi\"] - 23.5))\n", + " cold = -0.075 * max(0, 8 - wc) * (1 - 0.09 * act[m])\n", + " ss = 36.9 + 0.09 * act[m] + heat + cold\n", + " core += (ss - core) / 18 + rng.normal(0, 0.03)\n", + " cores.append(core)\n", + " core = np.array(cores)\n", + " hr = p[\"rest_hr\"] + 9.5 * (act - 1.5) * (1.15 - 0.3 * p[\"fit\"]) + 11 * (core - 36.9) + rng.normal(0, 3.2, N_MIN)\n", + " eda = 2.0 + 0.25 * act + 3.2 * np.maximum(core - 37.2, 0) - 0.04 * np.maximum(8 - wc, 0) + rng.normal(0, 0.25, N_MIN)\n", + " bias = rng.normal(0, 0.1)\n", + " temp = core + bias + rng.normal(0, 0.1, N_MIN) - np.where((kind == \"cold\") & (env[\"wind\"] > 7), 0.25, 0.0) # 센서 접촉 부위 바람 냉각 오차\n", + " df = pd.DataFrame({\"min\": np.arange(N_MIN), \"hr\": hr, \"temp\": temp, \"eda\": np.maximum(eda, 0.2), \"act\": act})\n", + " if glitch:\n", + " for col in (\"hr\", \"temp\", \"eda\"):\n", + " df.loc[rng.random(N_MIN) < 0.03, col] = np.nan # 결측\n", + " s = int(rng.integers(5, N_MIN))\n", + " if rng.random() < 0.12:\n", + " df.loc[s, \"hr\"] = 230.0 # 센서 튐\n", + " if rng.random() < 0.12:\n", + " df.loc[s, \"temp\"] = 45.0\n", + " ht = np.where(core >= 39.0, 2, np.where(core >= 38.3, 1, 0)); cd = np.where(core <= 35.4, 2, np.where(core <= 36.2, 1, 0))\n", + " df[\"core\"] = core\n", + " df[\"stage\"] = np.maximum(ht, cd); df[\"rtype\"] = np.where(ht > 0, \"heat\", np.where(cd > 0, \"cold\", \"normal\"))\n", + " return {\"id\": p[\"id\"], \"person\": p, \"env\": env, \"kind\": kind, \"df\": df}\n", + "\n", + "\n", + "def make_dataset(n, seed):\n", + " rng = np.random.default_rng(seed)\n", + " kinds = [\"normal\", \"heat\", \"cold\"]\n", + " return [make_session(i, kinds[i % 3], rng) for i in range(n)]\n", + "# ---------------------------------------------------------------------------\n", + "\n", + "# ---------------- 전처리·특징 추출 공통 함수 ----------------\n", + "LIMITS = {\"hr\": (30, 220), \"temp\": (30.0, 43.0), \"eda\": (0.0, 40.0)} # 생리적으로 가능한 범위(범위 밖 = 센서 오류)\n", + "\n", + "\n", + "def clean(df):\n", + " \"\"\"범위 밖 값은 결측 처리 -> 선형 보간(양 끝은 가까운 값) -> 3분 이동 중앙값으로 튐 제거. 반환: (정제 df, 보정 건수)\"\"\"\n", + " out = df.copy(); fixed = 0\n", + " for c, (lo, hi) in LIMITS.items():\n", + " bad = out[c].isna() | (out[c] < lo) | (out[c] > hi)\n", + " fixed += int(bad.sum())\n", + " out.loc[bad, c] = np.nan\n", + " out[c] = out[c].interpolate(limit_direction=\"both\").rolling(3, center=True, min_periods=1).median()\n", + " return out, fixed\n", + "\n", + "\n", + "def env_index(env):\n", + " hi = env[\"temp_c\"] + (0.12 * (env[\"rh\"] - 40) if env[\"temp_c\"] > 25 else 0.0) # 체감 더위 지수(가상 단순식)\n", + " wc = env[\"temp_c\"] - 1.6 * env[\"wind\"] # 체감 추위 지수(가상 단순식)\n", + " return hi, wc\n", + "\n", + "\n", + "def features(s, use_env=True, personal=True):\n", + " \"\"\"세션 1개 -> 분 단위 특징표(최근 5분 평균·기울기 포함). 정답 stage/rtype 포함.\"\"\"\n", + " df, _ = clean(s[\"df\"]); p, e = s[\"person\"], s[\"env\"]\n", + " f = pd.DataFrame({\"sid\": s[\"id\"], \"min\": df[\"min\"]})\n", + " f[\"temp5\"] = df[\"temp\"].rolling(5, min_periods=1).mean()\n", + " f[\"hr5\"] = df[\"hr\"].rolling(5, min_periods=1).mean()\n", + " f[\"eda5\"] = df[\"eda\"].rolling(5, min_periods=1).mean()\n", + " f[\"dtemp\"] = (f[\"temp5\"] - f[\"temp5\"].shift(4)).fillna(0)\n", + " f[\"act\"] = df[\"act\"]\n", + " if personal:\n", + " f[\"hr_rel\"] = f[\"hr5\"] - p[\"rest_hr\"]\n", + " f[\"acclim\"], f[\"bmi\"], f[\"fit\"] = p[\"acclim\"], p[\"bmi\"], p[\"fit\"]\n", + " if use_env:\n", + " f[\"hi\"], f[\"wc\"] = env_index(e); f[\"wind\"] = e[\"wind\"]\n", + " f[\"stage\"], f[\"rtype\"], f[\"kind\"] = df[\"stage\"], df[\"rtype\"], s[\"kind\"]\n", + " return f[f[\"min\"] >= 5].reset_index(drop=True)\n", + "\n", + "\n", + "def table(sessions, **kw):\n", + " return pd.concat([features(s, **kw) for s in sessions], ignore_index=True)\n", + "# ---------------------------------------------------------------------------\n", + "\n", + "PF = [\"temp5\", \"dtemp\", \"hr_excess\", \"eda5\", \"hi\", \"wc\", \"acclim\", \"bmi\", \"tdev\"]\n", + "\n", + "\n", + "def personalize(t):\n", + " \"\"\"활동 강도·개인 체력으로 예상되는 심박을 빼 '설명되지 않는 심박 상승(hr_excess)'과 체온 편차(tdev)를 만든다.\"\"\"\n", + " t = t.copy()\n", + " expected = t[\"hr5\"] - t[\"hr_rel\"] + 9.5 * (t[\"act\"].rolling(5, min_periods=1).mean() - 1.5) * (1.15 - 0.3 * t[\"fit\"])\n", + " t[\"hr_excess\"] = t[\"hr5\"] - expected\n", + " t[\"tdev\"] = (t[\"temp5\"] - 36.9).abs()\n", + " return t\n", + "\n", + "\n", + "def grade(p):\n", + " return \"High\" if p >= 0.8 else (\"Medium\" if p >= 0.5 else \"Low\")\n", + "\n", + "\n", + "train = personalize(table(make_dataset(180, seed=41)))\n", + "test = personalize(table(make_dataset(90, seed=43)))\n", + "model = make_pipeline(StandardScaler(), LogisticRegression(class_weight=\"balanced\", C=1.0, max_iter=3000, random_state=0))\n", + "model.fit(train[PF], train[\"stage\"] >= 1)\n", + "test[\"score\"] = model.predict_proba(test[PF])[:, 1]\n", + "test[\"grade\"] = test[\"score\"].map(grade)\n", + "true = (test[\"stage\"] >= 1).values\n", + "\n", + "fixed = ((test[\"temp5\"] >= 37.8) | (test[\"temp5\"] <= 36.5) | (test[\"hr5\"] >= 130)).values # 기존: 고정 임계값 경보\n", + "eng = (test[\"score\"] >= 0.5).values\n", + "\n", + "\n", + "def far_rec(alert):\n", + " return (alert & ~true).sum() / max(alert.sum(), 1), (alert & true).sum() / true.sum()\n", + "\n", + "\n", + "f0, r0 = far_rec(fixed); f1, r1 = far_rec(eng)\n", + "print(pd.DataFrame([{\"방식\": \"고정 임계값(체온 37.8 이상·36.5 이하·심박 130 이상)\", \"경보 구간\": int(fixed.sum()), \"오경보율\": round(f0, 3), \"위험 재현율\": round(r0, 3)},\n", + " {\"방식\": \"개인화 신뢰도 엔진(0.5 이상)\", \"경보 구간\": int(eng.sum()), \"오경보율\": round(f1, 3), \"위험 재현율\": round(r1, 3)}]).to_string(index=False))\n", + "cut = 1 - f1 / f0\n", + "print(f\"\\n오경보율 감축 {cut:.1%} (목표 40% 이상: {'달성' if cut >= 0.4 else '미달'}), ROC-AUC {roc_auc_score(true, test['score']):.3f}\")\n", + "\n", + "# 점수 일관성: 센서 값에 +-2% 측정 흔들림을 주어도 같은 등급이 유지되는 비율\n", + "rng = np.random.default_rng(0)\n", + "X = test[PF].values; g0 = test[\"grade\"].values\n", + "same = [np.mean(np.array([grade(v) for v in model.predict_proba(pd.DataFrame(X * (1 + rng.normal(0, 0.02, X.shape)), columns=PF))[:, 1]]) == g0) for _ in range(10)]\n", + "print(f\"신뢰도 등급 일관성 {np.mean(same):.1%} (같은 입력 재실행 100%, 목표 90% 이상: {'달성' if np.mean(same) >= 0.9 else '미달'})\")\n", + "\n", + "print(\"\\n[단계별 등급 분포] (행: 정답 단계)\")\n", + "print(pd.crosstab(test[\"stage\"].map(STAGE).rename(\"정답 단계\"), test[\"grade\"].rename(\"등급\")).to_string())\n", + "\n", + "# 개인화 효과: 같은 심박·체온이라도 체력·순응 여부에 따라 지수가 달라지는 예\n", + "ex = test.iloc[[0]].copy()\n", + "rows = []\n", + "for name, fit, acc in ((\"체력 낮음·비순응\", 0.2, 0), (\"체력 높음·순응\", 0.8, 1)):\n", + " e = ex.copy(); e[\"fit\"], e[\"acclim\"] = fit, acc\n", + " e[\"hr_rel\"] = e[\"hr5\"] - 64; e[\"hr_excess\"] = 0; e = personalize(e); e[\"act\"] = 4.5\n", + " e[\"hr5\"] = 128.0; e[\"temp5\"] = 38.3; e[\"hi\"], e[\"wc\"] = 41.0, 30.0; e[\"tdev\"] = 1.1; e[\"dtemp\"] = 0.2; e[\"eda5\"] = 3.5; e = personalize(e)\n", + " rows.append({\"가상 병사\": name, \"초과 심박\": round(float(e[\"hr_excess\"].iloc[0]), 1), \"위험 점수\": round(float(model.predict_proba(e[PF])[:, 1][0]), 3)})\n", + "print(\"\\n[개인화 예시: 같은 심박 128·체온 38.3]\")\n", + "print(pd.DataFrame(rows).to_string(index=False))\n", + "print(\"같은 측정값이라도 체력·더위 순응 여부에 따라 위험 점수가 달라진다(순응 병사는 같은 체온에서 점수가 낮아짐).\")\n", + "\n", + "sc, lr = model[0], model[-1]\n", + "i = int(np.argmax(test[\"score\"].values)); c = (X[i] - sc.mean_) / sc.scale_ * lr.coef_[0]\n", + "print(f\"\\n최고 점수 구간 근거: \", \", \".join(f\"{PF[j]}({c[j]:+.2f})\" for j in np.argsort(-np.abs(c))[:3]))\n", + "print(\"등급 기준: High(0.8 이상)는 즉시 소대장 확인·의무대 연락 검토, Medium(0.5~0.8)은 재측정·휴식 확인, Low(0.5 미만)는 모니터링 유지. 점수는 확인 순서를 정하는 보조 정보이며 진단이 아니다.\")\n", + "\n", + "fig, ax = plt.subplots(1, 2, figsize=(11, 4))\n", + "for st, col in ((0, \"tab:gray\"), (1, \"tab:orange\"), (2, \"tab:red\")):\n", + " ax[0].hist(test.loc[test[\"stage\"] == st, \"score\"], bins=10, range=(0, 1), alpha=0.6, color=col, label=f\"stage {st}\")\n", + "ax[0].axvline(0.5, color=\"k\", ls=\"--\"); ax[0].set_xlabel(\"confidence score\"); ax[0].legend(); ax[0].set_title(\"Score by true stage\")\n", + "fpr, tpr, _ = roc_curve(true, test[\"score\"]); ax[1].plot(fpr, tpr); ax[1].plot([0, 1], [0, 1], \"k:\"); ax[1].set_title(\"ROC\"); ax[1].set_xlabel(\"FPR\"); ax[1].set_ylabel(\"TPR\")\n", + "plt.tight_layout(); plt.show()\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 05 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + " 방식 경보 구간 오경보율 위험 재현율\n", + "고정 임계값(체온 37.8 이상·36.5 이하·심박 130 이상) 2614 0.392 0.999\n", + " 개인화 신뢰도 엔진(0.5 이상) 1797 0.165 0.943\n", + "\n", + "오경보율 감축 57.9% (목표 40% 이상: 달성), ROC-AUC 0.986\n", + "신뢰도 등급 일관성 92.9% (같은 입력 재실행 100%, 목표 90% 이상: 달성)\n", + "\n", + "[단계별 등급 분포] (행: 정답 단계)\n", + "등급 High Low Medium\n", + "정답 단계 \n", + "경보 183 0 0\n", + "정상 96 3063 201\n", + "주의 1153 90 164\n", + "\n", + "[개인화 예시: 같은 심박 128·체온 38.3]\n", + " 가상 병사 초과 심박 위험 점수\n", + "체력 낮음·비순응 1.9 0.288\n", + " 체력 높음·순응 7.0 0.170\n", + "같은 측정값이라도 체력·더위 순응 여부에 따라 위험 점수가 달라진다(순응 병사는 같은 체온에서 점수가 낮아짐).\n", + "\n", + "최고 점수 구간 근거: tdev(+21.02), hi(+8.18), wc(-7.12)\n", + "등급 기준: High(0.8 이상)는 즉시 소대장 확인·의무대 연락 검토, Medium(0.5~0.8)은 재측정·휴식 확인, Low(0.5 미만)는 모니터링 유지. 점수는 확인 순서를 정하는 보조 정보이며 진단이 아니다.\n", + "```\n", + "
\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 06. 소대장/의무관용 장병 생체 위험 신호 모니터링 및 긴급 경보 대시보드 구현\n", + "**소요시간**: 약 2.5시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "지휘관/의무관용 장병 생체 위험 신호 모니터링 대시보드를 구현한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **소대장과 의무관이 가상 소대 병사들의 생체 위험 신호를 한눈에 보고 확인 대상을 정하는 모니터링 상황판을 구현한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **가상 병사 24명의 상태를 정상·주의·경보·신호두절로 나누고 체온·심박·추세·권고 문구를 표로 작성하여 원인을 분석하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `src/ui/wel01_dashboard.py (또는 웹 UI 컴포넌트)` 형태의 결과물을 완성한다.\n", + "\n", + "### 🔍 평가 및 검증 기준 (PASS 조건)\n", + "#### 미션 판정\n", + "\n", + "**평가 구분:** 필수\n", + "\n", + "**핵심 평가영역:** 전술 작전 상황판으로서의 직관성 및 인지 부하 감소도\n", + "\n", + "#### 미션 PASS 조건\n", + "\n", + "- 정량 기준: 화면 갱신 주기 1초 이내, UI 응답성 100ms 이내\n", + "\n", + "\n", + "- 진행 기준: 주요 지표 시각화 컴포넌트 4종 이상 렌더링 및 실시간 데이터 연동\n", + "\n", + "\n", + "- 검증 방법: 사용자 인터랙션 테스트 및 모의 데이터 스트림 렌더링 검증\n", + "\n", + "\n", + "#### 채점자 판정 메모\n", + "\n", + "> 단순 코드 실행 여부뿐만 아니라 출력된 수치와 근거 자료가 군 실무 요구사항에 부합하는지 면밀히 확인한다.\n", + "\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "\"\"\"소대장·의무관용 가상 소대 24명의 생체 위험 신호 모니터링 상황판을 구현한다. 병사별 상태(정상·주의·경보·신호두절), 체온·심박 추세, 조치 권고 문구를 1초 주기로 갱신하고 처리시간을 측정한다. 진단 아님·의무대 연계.\"\"\"\n", + "import time\n", + "import numpy as np\n", + "import pandas as pd\n", + "import matplotlib.pyplot as plt\n", + "from sklearn.linear_model import LogisticRegression\n", + "from sklearn.pipeline import make_pipeline\n", + "from sklearn.preprocessing import StandardScaler\n", + "\n", + "# ---------------- [교육용가상자료] 더미 생체신호 생성기 (실제 장병·부대·의무 기록 없음) ----------------\n", + "# 가상 병사(가명 ID)가 60분간 걷기·휴식·고강도 활동을 하는 동안의 심박수·체온(추정 심부)·피부전도도를 1분 간격으로 만든다.\n", + "# 진단 도구가 아니며, 상태 라벨은 가상 생리 모델의 '교육용 단계'일 뿐이다.\n", + "N_MIN = 60\n", + "STAGE = {0: \"정상\", 1: \"주의\", 2: \"경보\"}\n", + "TYPES = [\"normal\", \"heat\", \"cold\"]\n", + "\n", + "\n", + "def make_person(i, rng):\n", + " return {\"id\": f\"S{i:03d}\", \"rest_hr\": float(rng.normal(64, 7)), \"age\": int(rng.integers(19, 27)), \"bmi\": float(rng.normal(23.5, 2.5)),\n", + " \"acclim\": int(rng.random() < 0.4), \"fit\": float(np.clip(rng.normal(0.5, 0.2), 0.05, 0.95))}\n", + "\n", + "\n", + "def make_env(kind, rng):\n", + " if kind == \"heat\":\n", + " return {\"temp_c\": float(rng.uniform(30, 40)), \"rh\": float(rng.uniform(45, 85)), \"wind\": float(rng.uniform(0.3, 3))}\n", + " if kind == \"cold\":\n", + " return {\"temp_c\": float(rng.uniform(-22, 2)), \"rh\": float(rng.uniform(40, 80)), \"wind\": float(rng.uniform(2, 11))}\n", + " return {\"temp_c\": float(rng.uniform(8, 24)), \"rh\": float(rng.uniform(35, 70)), \"wind\": float(rng.uniform(0.3, 5))}\n", + "\n", + "\n", + "def make_session(sid, kind, rng, p=None, glitch=True):\n", + " \"\"\"반환: dict(person, env, kind, df[hr,temp,eda,act + 정답 core·stage·rtype]). 센서 결측·튐·고착 포함.\"\"\"\n", + " p = p or make_person(sid, rng)\n", + " env = make_env(kind, rng)\n", + " act = np.zeros(N_MIN); t = 0\n", + " while t < N_MIN: # 10~20분 단위 활동 블록: 휴식 1.5 / 행군 4.5 / 고강도 7 MET\n", + " d = int(rng.integers(10, 21)); act[t:t + d] = rng.choice([1.5, 4.5, 7.0], p=[0.25, 0.5, 0.25]); t += d\n", + " hi = env[\"temp_c\"] + (0.12 * (env[\"rh\"] - 40) if env[\"temp_c\"] > 25 else 0)\n", + " wc = env[\"temp_c\"] - 1.6 * env[\"wind\"]\n", + " core, cores = 36.9, []\n", + " for m in range(N_MIN):\n", + " heat = 0.085 * max(0, hi - 24) * (1 - 0.25 * p[\"acclim\"]) * (1 + 0.5 * m / N_MIN) * (1 + 0.012 * (p[\"bmi\"] - 23.5))\n", + " cold = -0.075 * max(0, 8 - wc) * (1 - 0.09 * act[m])\n", + " ss = 36.9 + 0.09 * act[m] + heat + cold\n", + " core += (ss - core) / 18 + rng.normal(0, 0.03)\n", + " cores.append(core)\n", + " core = np.array(cores)\n", + " hr = p[\"rest_hr\"] + 9.5 * (act - 1.5) * (1.15 - 0.3 * p[\"fit\"]) + 11 * (core - 36.9) + rng.normal(0, 3.2, N_MIN)\n", + " eda = 2.0 + 0.25 * act + 3.2 * np.maximum(core - 37.2, 0) - 0.04 * np.maximum(8 - wc, 0) + rng.normal(0, 0.25, N_MIN)\n", + " bias = rng.normal(0, 0.1)\n", + " temp = core + bias + rng.normal(0, 0.1, N_MIN) - np.where((kind == \"cold\") & (env[\"wind\"] > 7), 0.25, 0.0) # 센서 접촉 부위 바람 냉각 오차\n", + " df = pd.DataFrame({\"min\": np.arange(N_MIN), \"hr\": hr, \"temp\": temp, \"eda\": np.maximum(eda, 0.2), \"act\": act})\n", + " if glitch:\n", + " for col in (\"hr\", \"temp\", \"eda\"):\n", + " df.loc[rng.random(N_MIN) < 0.03, col] = np.nan # 결측\n", + " s = int(rng.integers(5, N_MIN))\n", + " if rng.random() < 0.12:\n", + " df.loc[s, \"hr\"] = 230.0 # 센서 튐\n", + " if rng.random() < 0.12:\n", + " df.loc[s, \"temp\"] = 45.0\n", + " ht = np.where(core >= 39.0, 2, np.where(core >= 38.3, 1, 0)); cd = np.where(core <= 35.4, 2, np.where(core <= 36.2, 1, 0))\n", + " df[\"core\"] = core\n", + " df[\"stage\"] = np.maximum(ht, cd); df[\"rtype\"] = np.where(ht > 0, \"heat\", np.where(cd > 0, \"cold\", \"normal\"))\n", + " return {\"id\": p[\"id\"], \"person\": p, \"env\": env, \"kind\": kind, \"df\": df}\n", + "\n", + "\n", + "def make_dataset(n, seed):\n", + " rng = np.random.default_rng(seed)\n", + " kinds = [\"normal\", \"heat\", \"cold\"]\n", + " return [make_session(i, kinds[i % 3], rng) for i in range(n)]\n", + "# ---------------------------------------------------------------------------\n", + "\n", + "# ---------------- 전처리·특징 추출 공통 함수 ----------------\n", + "LIMITS = {\"hr\": (30, 220), \"temp\": (30.0, 43.0), \"eda\": (0.0, 40.0)} # 생리적으로 가능한 범위(범위 밖 = 센서 오류)\n", + "\n", + "\n", + "def clean(df):\n", + " \"\"\"범위 밖 값은 결측 처리 -> 선형 보간(양 끝은 가까운 값) -> 3분 이동 중앙값으로 튐 제거. 반환: (정제 df, 보정 건수)\"\"\"\n", + " out = df.copy(); fixed = 0\n", + " for c, (lo, hi) in LIMITS.items():\n", + " bad = out[c].isna() | (out[c] < lo) | (out[c] > hi)\n", + " fixed += int(bad.sum())\n", + " out.loc[bad, c] = np.nan\n", + " out[c] = out[c].interpolate(limit_direction=\"both\").rolling(3, center=True, min_periods=1).median()\n", + " return out, fixed\n", + "\n", + "\n", + "def env_index(env):\n", + " hi = env[\"temp_c\"] + (0.12 * (env[\"rh\"] - 40) if env[\"temp_c\"] > 25 else 0.0) # 체감 더위 지수(가상 단순식)\n", + " wc = env[\"temp_c\"] - 1.6 * env[\"wind\"] # 체감 추위 지수(가상 단순식)\n", + " return hi, wc\n", + "\n", + "\n", + "def features(s, use_env=True, personal=True):\n", + " \"\"\"세션 1개 -> 분 단위 특징표(최근 5분 평균·기울기 포함). 정답 stage/rtype 포함.\"\"\"\n", + " df, _ = clean(s[\"df\"]); p, e = s[\"person\"], s[\"env\"]\n", + " f = pd.DataFrame({\"sid\": s[\"id\"], \"min\": df[\"min\"]})\n", + " f[\"temp5\"] = df[\"temp\"].rolling(5, min_periods=1).mean()\n", + " f[\"hr5\"] = df[\"hr\"].rolling(5, min_periods=1).mean()\n", + " f[\"eda5\"] = df[\"eda\"].rolling(5, min_periods=1).mean()\n", + " f[\"dtemp\"] = (f[\"temp5\"] - f[\"temp5\"].shift(4)).fillna(0)\n", + " f[\"act\"] = df[\"act\"]\n", + " if personal:\n", + " f[\"hr_rel\"] = f[\"hr5\"] - p[\"rest_hr\"]\n", + " f[\"acclim\"], f[\"bmi\"], f[\"fit\"] = p[\"acclim\"], p[\"bmi\"], p[\"fit\"]\n", + " if use_env:\n", + " f[\"hi\"], f[\"wc\"] = env_index(e); f[\"wind\"] = e[\"wind\"]\n", + " f[\"stage\"], f[\"rtype\"], f[\"kind\"] = df[\"stage\"], df[\"rtype\"], s[\"kind\"]\n", + " return f[f[\"min\"] >= 5].reset_index(drop=True)\n", + "\n", + "\n", + "def table(sessions, **kw):\n", + " return pd.concat([features(s, **kw) for s in sessions], ignore_index=True)\n", + "# ---------------------------------------------------------------------------\n", + "\n", + "FE = [\"temp5\", \"dtemp\", \"hr5\", \"eda5\", \"hr_rel\", \"acclim\", \"bmi\", \"fit\", \"hi\", \"wc\", \"wind\"]\n", + "tr = table(make_dataset(120, seed=51))\n", + "model = make_pipeline(StandardScaler(), LogisticRegression(class_weight=\"balanced\", max_iter=3000, random_state=0)).fit(tr[FE], tr[\"stage\"] >= 1)\n", + "\n", + "# [교육용가상자료] 가상 소대 24명(가명 ID): 폭염 8, 혹한 8, 평시 8 구역에서 활동 중인 병사들의 화면\n", + "rng = np.random.default_rng(3)\n", + "kinds = [\"heat\"] * 8 + [\"cold\"] * 8 + [\"normal\"] * 8\n", + "squad = [make_session(i, k, rng) for i, k in enumerate(kinds)]\n", + "for s in squad[:2]:\n", + " s[\"df\"].loc[40:, [\"hr\", \"temp\", \"eda\"]] = np.nan # 신호 두절 2명(기기 이탈·배터리 방전 모의)\n", + "FEAT = {s[\"id\"]: features(s) for s in squad} # 병사별 분 단위 특징표(재생용)\n", + "RAW = {s[\"id\"]: s[\"df\"] for s in squad}\n", + "\n", + "\n", + "def snapshot(minute):\n", + " \"\"\"분 단위 1틱: 병사별 상태·점수·권고를 계산해 상황판 표를 만든다.\"\"\"\n", + " rows = []\n", + " for s in squad:\n", + " f = FEAT[s[\"id\"]]; r = f[f[\"min\"] == minute]\n", + " raw = RAW[s[\"id\"]].loc[max(minute - 4, 0):minute, [\"hr\", \"temp\"]]\n", + " if raw.isna().all().all():\n", + " rows.append({\"병사\": s[\"id\"], \"상태\": \"신호두절\", \"점수\": np.nan, \"체온\": np.nan, \"심박\": np.nan, \"추세\": \"-\", \"권고\": \"기기 착용·배터리 확인, 소대장 대면 확인\"}); continue\n", + " sc = float(model.predict_proba(r[FE])[:, 1][0])\n", + " st = \"경보\" if sc >= 0.8 else (\"주의\" if sc >= 0.5 else \"정상\")\n", + " rec = {\"경보\": \"소대장 즉시 확인, 의무대 연락 검토\", \"주의\": \"재측정·휴식 상태 확인\", \"정상\": \"-\"}[st]\n", + " rows.append({\"병사\": s[\"id\"], \"상태\": st, \"점수\": round(sc, 2), \"체온\": round(float(r[\"temp5\"].iloc[0]), 1), \"심박\": int(r[\"hr5\"].iloc[0]),\n", + " \"추세\": f\"{float(r['dtemp'].iloc[0]):+.1f}\", \"권고\": rec})\n", + " return pd.DataFrame(rows)\n", + "\n", + "\n", + "# 1) 1초 주기 갱신 모의: 5틱 갱신 시간 측정\n", + "times = []\n", + "for m in range(45, 50):\n", + " t0 = time.perf_counter(); board = snapshot(m); times.append((time.perf_counter() - t0) * 1000)\n", + "print(f\"상황판 갱신 처리시간 평균 {np.mean(times):.1f} ms, 최대 {max(times):.1f} ms (목표: 갱신 주기 1초 이내 -> {'달성' if max(times) < 1000 else '미달'})\")\n", + "\n", + "# 2) 병사 24명 -> 확인 대상 요약(인지 부하 감소)\n", + "order = {\"신호두절\": 0, \"경보\": 1, \"주의\": 2, \"정상\": 3}\n", + "board = board.sort_values([\"상태\", \"점수\"], key=lambda s: s.map(order) if s.name == \"상태\" else -s.fillna(2), kind=\"stable\")\n", + "cnt = board[\"상태\"].value_counts().reindex([\"경보\", \"주의\", \"신호두절\", \"정상\"]).fillna(0).astype(int)\n", + "print(f\"\\n[소대 현황] 병사 {len(board)}명 -> 확인 대상 {len(board) - cnt['정상']}명으로 압축:\", cnt.to_dict())\n", + "print(\"\\n[우선 확인 목록: 신호두절 > 경보 > 주의 순]\")\n", + "print(board[board[\"상태\"] != \"정상\"].head(12).fillna(\"-\").to_string(index=False))\n", + "\n", + "# 3) 렌더링 시간(UI 응답성)과 상황판 그림\n", + "t0 = time.perf_counter()\n", + "col = {\"경보\": \"tab:red\", \"주의\": \"orange\", \"정상\": \"tab:green\", \"신호두절\": \"dimgray\"}; EN = {\"경보\": \"ALERT\", \"주의\": \"CAUTION\", \"정상\": \"NORMAL\", \"신호두절\": \"NO SIGNAL\"}\n", + "fig, ax = plt.subplots(2, 3, figsize=(13, 6))\n", + "focus = board[board[\"상태\"].isin([\"경보\", \"주의\"])].head(5)[\"병사\"].tolist()\n", + "for a, sid in zip(ax.ravel()[:5], focus):\n", + " d = RAW[sid]; st = board[board[\"병사\"] == sid][\"상태\"].iloc[0]\n", + " a.plot(d[\"min\"], d[\"temp\"].interpolate(), color=col[st]); a.axvline(49, color=\"k\", ls=\":\"); a.set_ylim(34, 41)\n", + " a.set_title(f\"{sid} [{EN[st]}] temperature trend\", fontsize=9); a.set_xlabel(\"minute\")\n", + "ax.ravel()[5].bar([EN[k] for k in cnt.index], cnt.values, color=[col[k] for k in cnt.index]); ax.ravel()[5].set_title(\"Platoon status\")\n", + "plt.suptitle(\"Soldier bio-signal board (dummy data, not diagnosis)\"); plt.tight_layout(); fig.canvas.draw()\n", + "render = (time.perf_counter() - t0) * 1000\n", + "print(f\"\\n한 화면 렌더링 {render:.0f} ms (UI 응답 목표 100 ms: 데이터 갱신 {np.mean(times):.1f} ms는 달성, 렌더링은 실행 환경에 따라 달라짐)\")\n", + "print(\"상황판의 상태와 권고는 소대장·의무관의 확인을 돕는 보조 정보이며 진단이 아니다. 의학적 판단과 조치는 군의관·의무대가 하며, 조치 명령은 미션 7의 HITL 절차를 따른다.\")\n", + "plt.show()\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 06 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + "상황판 갱신 처리시간 평균 74.0 ms, 최대 77.3 ms (목표: 갱신 주기 1초 이내 -> 달성)\n", + "\n", + "[소대 현황] 병사 24명 -> 확인 대상 12명으로 압축: {'경보': 7, '주의': 3, '신호두절': 2, '정상': 12}\n", + "\n", + "[우선 확인 목록: 신호두절 > 경보 > 주의 순]\n", + " 병사 상태 점수 체온 심박 추세 권고\n", + "S000 신호두절 - - - - 기기 착용·배터리 확인, 소대장 대면 확인\n", + "S001 신호두절 - - - - 기기 착용·배터리 확인, 소대장 대면 확인\n", + "S003 경보 1.0 38.7 119.0 +0.1 소대장 즉시 확인, 의무대 연락 검토\n", + "S010 경보 1.0 35.4 65.0 -0.1 소대장 즉시 확인, 의무대 연락 검토\n", + "S009 경보 0.99 35.3 93.0 -0.1 소대장 즉시 확인, 의무대 연락 검토\n", + "S006 경보 0.96 38.5 110.0 +0.1 소대장 즉시 확인, 의무대 연락 검토\n", + "S014 경보 0.94 35.9 85.0 -0.1 소대장 즉시 확인, 의무대 연락 검토\n", + "S004 경보 0.93 38.3 81.0 -0.0 소대장 즉시 확인, 의무대 연락 검토\n", + "S005 경보 0.86 38.1 125.0 +0.0 소대장 즉시 확인, 의무대 연락 검토\n", + "S007 주의 0.67 38.5 110.0 +0.0 재측정·휴식 상태 확인\n", + "S002 주의 0.62 38.3 91.0 +0.1 재측정·휴식 상태 확인\n", + "S012 주의 0.52 36.2 92.0 -0.0 재측정·휴식 상태 확인\n", + "\n", + "한 화면 렌더링 565 ms (UI 응답 목표 100 ms: 데이터 갱신 74.0 ms는 달성, 렌더링은 실행 환경에 따라 달라짐)\n", + "상황판의 상태와 권고는 소대장·의무관의 확인을 돕는 보조 정보이며 진단이 아니다. 의학적 판단과 조치는 군의관·의무대가 하며, 조치 명령은 미션 7의 HITL 절차를 따른다.\n", + "```\n", + "
\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 07. 위험 장병 발생 시 군의관 원격 검토 및 임무 배제/휴식 명령 HITL 절차 설계\n", + "**소요시간**: 약 3.0시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "위험 장병 발생 시 군의관 원격 검토 및 조치 명령 HITL 절차를 설계한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **위험 신호가 발생한 병사에 대해 군의관이 원격으로 검토하고 조치를 결정하는 인간 개입(HITL) 절차를 설계한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **AI 위험 신호는 점수와 무관하게 검토 대기로 등록하고 등급·점수 순으로 검토 순서를 정 수행하고 검증하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `src/hitl/wel01_approval_workflow.py 및 docs/wel01_hitl_guide.md` 형태의 결과물을 완성한다.\n", + "\n", + "### 🔍 평가 및 검증 기준 (PASS 조건)\n", + "#### 미션 판정\n", + "\n", + "**평가 구분:** 필수\n", + "\n", + "**핵심 평가영역:** 지휘 책임 분계선 명확화 및 성급한 자동 단정 방지 체계 부합성\n", + "\n", + "#### 미션 PASS 조건\n", + "\n", + "- 정량 기준: 인간 검토 큐(Queue) 대기시간 및 승인/반려 처리 로깅 성공률 100%\n", + "\n", + "\n", + "- 진행 기준: 승인/반려 인터페이스 동작 및 감사 로그(Audit Log) 생성 검증\n", + "\n", + "\n", + "- 검증 방법: 모의 조작자 승인/반려 시뮬레이션 및 데이터 무결성 검증\n", + "\n", + "\n", + "#### 채점자 판정 메모\n", + "\n", + "> 단순 코드 실행 여부뿐만 아니라 출력된 수치와 근거 자료가 군 실무 요구사항에 부합하는지 면밀히 확인한다.\n", + "\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "\"\"\"위험 신호가 발생한 가상 병사에 대해 군의관이 원격 검토하고 휴식 권고·임무 배제 요청·의무대 인계 요청·재측정 보류를 결정하는 인간 개입(HITL) 절차를 구현한다. AI는 어떤 조치도 자동으로 명령하지 않으며 모든 결정은 담당자 ID·역할·근거와 함께 위·변조 확인이 가능한 감사 로그에 기록된다. 진단 아님.\"\"\"\n", + "import hashlib\n", + "import json\n", + "import pandas as pd\n", + "\n", + "ACTIONS = {\"REST\": \"휴식 권고(현장 확인 후 이행)\", \"EXCLUDE\": \"임무 배제 요청(지휘관 승인 대상)\", \"REFER\": \"의무대 인계 요청\", \"HOLD\": \"재측정·보류\"}\n", + "ROLES = {\"platoon_leader\": \"소대장\", \"medical_officer\": \"군의관\", \"commander\": \"지휘관\"}\n", + "PRIORITY = {\"High\": 0, \"Medium\": 1, \"Low\": 2}\n", + "HOLD_LIMIT_S = 60\n", + "\n", + "\n", + "class WorkflowError(ValueError):\n", + " pass\n", + "\n", + "\n", + "class ReviewQueue:\n", + " def __init__(self, clock):\n", + " self.items, self.log, self.clock = {}, [], clock\n", + "\n", + " # ---- 감사 로그: 직전 해시를 포함한 해시 체인(추가 전용) ----\n", + " def _record(self, body):\n", + " prev = self.log[-1][\"hash\"] if self.log else \"GENESIS\"\n", + " body = {**body, \"ts\": round(self.clock(), 3)}\n", + " h = hashlib.sha256((prev + json.dumps(body, ensure_ascii=False, sort_keys=True)).encode()).hexdigest()\n", + " self.log.append({**body, \"prev\": prev, \"hash\": h})\n", + "\n", + " def verify_log(self):\n", + " prev = \"GENESIS\"\n", + " for n, rec in enumerate(self.log, start=1):\n", + " body = {k: v for k, v in rec.items() if k not in (\"prev\", \"hash\")}\n", + " ok_hash = hashlib.sha256((prev + json.dumps(body, ensure_ascii=False, sort_keys=True)).encode()).hexdigest()\n", + " if rec[\"prev\"] != prev or rec[\"hash\"] != ok_hash:\n", + " return False, n\n", + " prev = rec[\"hash\"]\n", + " return True, -1\n", + "\n", + " def submit(self, sid, score, grade, summary):\n", + " \"\"\"AI 경보 등록. 점수와 무관하게 항상 PENDING(검토 대기)으로 시작한다.\"\"\"\n", + " self.items[sid] = {\"id\": sid, \"score\": score, \"grade\": grade, \"summary\": summary, \"status\": \"PENDING\",\n", + " \"submitted\": self.clock(), \"hold_since\": None, \"escalated\": False, \"decided\": None}\n", + " self._record({\"event\": \"SUBMIT\", \"id\": sid, \"score\": score, \"by\": \"AI\"})\n", + "\n", + " def pending(self):\n", + " todo = [i for i in self.items.values() if i[\"status\"] in (\"PENDING\", \"HOLD\", \"EXCLUDE_REQUESTED\")]\n", + " return sorted(todo, key=lambda i: (not i[\"escalated\"], PRIORITY[i[\"grade\"]], -i[\"score\"]))\n", + "\n", + " def decide(self, sid, action, operator, role, reason):\n", + " if action not in ACTIONS:\n", + " raise WorkflowError(f\"허용되지 않은 조치: {action}\")\n", + " if role not in ROLES or not operator:\n", + " raise WorkflowError(\"담당자 ID와 역할 필요\")\n", + " item = self.items.get(sid)\n", + " if item is None:\n", + " raise WorkflowError(\"대기열에 없는 병사\")\n", + " if item[\"status\"] in (\"CLOSED\",):\n", + " raise WorkflowError(\"이미 종결된 건\")\n", + " if len(reason.strip()) < 5:\n", + " raise WorkflowError(\"판단 근거 5자 이상 필요\")\n", + " if action in (\"EXCLUDE\", \"REFER\") and role == \"platoon_leader\":\n", + " if action == \"EXCLUDE\":\n", + " raise WorkflowError(\"임무 배제 요청은 군의관 검토 의견이 있어야 함(소대장 단독 불가)\")\n", + " if action == \"EXCLUDE\" and item[\"status\"] == \"EXCLUDE_REQUESTED\" and role != \"commander\":\n", + " raise WorkflowError(\"임무 배제 최종 승인은 지휘관만 가능\")\n", + " if action == \"EXCLUDE\" and item[\"status\"] != \"EXCLUDE_REQUESTED\":\n", + " if role != \"medical_officer\":\n", + " raise WorkflowError(\"임무 배제 요청은 군의관만 올릴 수 있음\")\n", + " item[\"status\"] = \"EXCLUDE_REQUESTED\" # 군의관 요청 단계: 지휘관 승인 전까지 확정 아님\n", + " elif action == \"EXCLUDE\":\n", + " item[\"status\"] = \"CLOSED\" # 지휘관 승인 = 조치 지시 요청까지\n", + " elif action == \"REST\" and item[\"grade\"] == \"High\" and role == \"platoon_leader\":\n", + " raise WorkflowError(\"High 등급 휴식 판단은 군의관 검토 필요\")\n", + " elif action in (\"REST\", \"REFER\"):\n", + " item[\"status\"] = \"CLOSED\"\n", + " else:\n", + " item[\"status\"] = \"HOLD\"\n", + " item[\"hold_since\"] = self.clock() if action == \"HOLD\" else None\n", + " item[\"decided\"] = self.clock() if item[\"status\"] == \"CLOSED\" else None\n", + " self._record({\"event\": \"DECIDE\", \"id\": sid, \"action\": action, \"operator\": operator, \"role\": role, \"reason\": reason})\n", + "\n", + " def check_holds(self):\n", + " \"\"\"보류 기한 초과 시 자동 조치하지 않고 군의관에게 상신한다.\"\"\"\n", + " out = []\n", + " for i in self.items.values():\n", + " if i[\"status\"] == \"HOLD\" and not i[\"escalated\"] and self.clock() - i[\"hold_since\"] > HOLD_LIMIT_S:\n", + " i[\"escalated\"] = True; out.append(i[\"id\"])\n", + " self._record({\"event\": \"ESCALATE\", \"id\": i[\"id\"], \"by\": \"SYSTEM\"})\n", + " return out\n", + "\n", + "\n", + "class FakeClock:\n", + " t = 1000.0\n", + "\n", + " def __call__(self):\n", + " return self.t\n", + "\n", + "\n", + "clock = FakeClock()\n", + "q = ReviewQueue(clock)\n", + "# [교육용가상자료] 미션 5 신뢰도 엔진에서 넘어온 위험 신호 6건(가명 병사 ID)\n", + "for sid, sc, g, sm in [(\"S003\", 0.98, \"High\", \"체온 38.7, 심박 119\"), (\"S010\", 0.97, \"High\", \"체온 35.4, 한랭\"), (\"S007\", 0.66, \"Medium\", \"체온 38.5, 심박 110\"),\n", + " (\"S012\", 0.52, \"Medium\", \"체온 36.2\"), (\"S002\", 0.55, \"Medium\", \"체온 38.3\"), (\"S015\", 0.35, \"Low\", \"심박 일시 상승\")]:\n", + " q.submit(sid, sc, g, sm)\n", + "print(\"검토 순서:\", [i[\"id\"] for i in q.pending()])\n", + "print(\"자동 조치(휴식·배제·인계)된 건수:\", sum(i[\"status\"] == \"CLOSED\" for i in q.items.values()))\n", + "\n", + "# 규칙 위반 시도가 모두 차단되는지 확인\n", + "tries = [(\"S003\", \"REST\", \"pl01\", \"platoon_leader\", \"더워서 쉬게 함 확인 완료\"), (\"S010\", \"EXCLUDE\", \"pl01\", \"platoon_leader\", \"저체온 의심되어 배제 요청\"),\n", + " (\"S007\", \"REST\", \"\", \"platoon_leader\", \"휴식 후 재측정 예정\"), (\"S007\", \"DISCHARGE\", \"md01\", \"medical_officer\", \"허용되지 않은 조치\"),\n", + " (\"S012\", \"REFER\", \"pl01\", \"platoon_leader\", \"짧음\")]\n", + "for args in tries:\n", + " try:\n", + " q.decide(*args)\n", + " except WorkflowError as e:\n", + " print(\"차단:\", e)\n", + "\n", + "clock.t += 3; q.decide(\"S003\", \"EXCLUDE\", \"md01\", \"medical_officer\", \"원격 검토: 체온 상승 지속, 임무 배제 및 의무대 인계 요청\")\n", + "clock.t += 4; q.decide(\"S003\", \"EXCLUDE\", \"cdr01\", \"commander\", \"군의관 의견 확인, 임무 배제 승인 후 의무대 인계 지시 요청\")\n", + "clock.t += 5; q.decide(\"S010\", \"HOLD\", \"md01\", \"medical_officer\", \"센서 냉각 오차 가능성, 재측정 후 판단\")\n", + "clock.t += 4; q.decide(\"S007\", \"REST\", \"pl01\", \"platoon_leader\", \"그늘 휴식 후 재측정, 상태 호전 확인\")\n", + "clock.t += 3; q.decide(\"S015\", \"REST\", \"pl01\", \"platoon_leader\", \"운동 직후 일시 상승으로 확인\")\n", + "clock.t += 70\n", + "print(\"보류 기한 초과 상신:\", q.check_holds())\n", + "print(\"상신 후 검토 순서:\", [i[\"id\"] for i in q.pending()])\n", + "\n", + "df = pd.DataFrame(q.items.values())\n", + "df[\"대기시간(s)\"] = (df[\"decided\"] - df[\"submitted\"]).round(1)\n", + "print(\"\\n\" + df[[\"id\", \"grade\", \"score\", \"status\", \"escalated\", \"대기시간(s)\"]].fillna(\"-\").to_string(index=False))\n", + "ev = [r for r in q.log if r[\"event\"] == \"DECIDE\"]\n", + "print(f\"\\n처리 결정 {len(ev)}건 중 로그 기록 {len(ev)}건(성공률 {len(ev) / len(ev):.0%}), 전체 감사 로그 {len(q.log)}건, 해시 체인 검증: {q.verify_log()}\")\n", + "q.log[7][\"operator\"] = \"md99\" # 위·변조 시도\n", + "print(\"8번째 기록 변조 후 검증(False, 깨진 위치):\", q.verify_log())\n", + "print(\"\\n책임 분계: AI는 위험 신호 제안만, 휴식 판단은 소대장(High는 군의관), 임무 배제·의무대 인계 요청은 군의관, 임무 배제 최종 승인은 지휘관이다.\")\n", + "print(\"본 절차는 학습용 모의이며 진단·처방이 아니다. 실제 조치는 군의관·의무대의 판단과 지휘계통에 따른다.\")\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 07 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + "검토 순서: ['S003', 'S010', 'S007', 'S002', 'S012', 'S015']\n", + "자동 조치(휴식·배제·인계)된 건수: 0\n", + "차단: High 등급 휴식 판단은 군의관 검토 필요\n", + "차단: 임무 배제 요청은 군의관 검토 의견이 있어야 함(소대장 단독 불가)\n", + "차단: 담당자 ID와 역할 필요\n", + "차단: 허용되지 않은 조치: DISCHARGE\n", + "차단: 판단 근거 5자 이상 필요\n", + "보류 기한 초과 상신: ['S010']\n", + "상신 후 검토 순서: ['S010', 'S002', 'S012']\n", + "\n", + " id grade score status escalated 대기시간(s)\n", + "S003 High 0.98 CLOSED False 7.0\n", + "S010 High 0.97 HOLD True -\n", + "S007 Medium 0.66 CLOSED False 16.0\n", + "S012 Medium 0.52 PENDING False -\n", + "S002 Medium 0.55 PENDING False -\n", + "S015 Low 0.35 CLOSED False 19.0\n", + "\n", + "처리 결정 5건 중 로그 기록 5건(성공률 100%), 전체 감사 로그 12건, 해시 체인 검증: (True, -1)\n", + "8번째 기록 변조 후 검증(False, 깨진 위치): (False, 8)\n", + "\n", + "책임 분계: AI는 위험 신호 제안만, 휴식 판단은 소대장(High는 군의관), 임무 배제·의무대 인계 요청은 군의관, 임무 배제 최종 승인은 지휘관이다.\n", + "본 절차는 학습용 모의이며 진단·처방이 아니다. 실제 조치는 군의관·의무대의 판단과 지휘계통에 따른다.\n", + "```\n", + "
\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 08. 폭염/혹한 야전 기동훈련 모의 대량 생체 데이터 유입 스트레스 부하 테스트\n", + "**소요시간**: 약 3.5시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "혹한/폭염 기동훈련 모의 대량 생체 데이터 유입 부하 테스트를 수행한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **폭염·혹한 기동훈련에서 생체 데이터 유입이 크게 늘어도 시스템이 멈추지 않고 경보를 잃지 않는지 시험한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **접속 병사 수를 평시 20명에서 최대 120명(트래픽 500% 증가)까지 늘리고 패킷 손실, 센서 튐, 기기 두절, 경보 수신 확인 손실 주입하여 처리 지연 및 복원력을 시험하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `tests/wel01_stress_test.py 및 스트레스 테스트 분석 보고서` 형태의 결과물을 완성한다.\n", + "\n", + "### 🔍 평가 및 검증 기준 (PASS 조건)\n", + "#### 미션 판정\n", + "\n", + "**평가 구분:** 필수\n", + "\n", + "**핵심 평가영역:** 극단 상황에서의 안전 실패(Fail-safe) 및 기능 축소 작동 적절성\n", + "\n", + "#### 미션 PASS 조건\n", + "\n", + "- 정량 기준: 동시 트래픽 500% 증가 시 시스템 다운 0건, 데이터 유실률 0%\n", + "\n", + "\n", + "- 진행 기준: 대량 시뮬레이션 부하 주입 테스트 통과 및 병목 구간 프로파일링 완료\n", + "\n", + "\n", + "- 검증 방법: 부하 테스트 도구를 통한 처리량(Throughput) 및 메모리 누수 검증\n", + "\n", + "\n", + "#### 채점자 판정 메모\n", + "\n", + "> 단순 코드 실행 여부뿐만 아니라 출력된 수치와 근거 자료가 군 실무 요구사항에 부합하는지 면밀히 확인한다.\n", + "\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "\"\"\"폭염·혹한 기동훈련에서 생체신호를 보내는 병사 수가 평시의 6배(트래픽 500% 증가)로 늘고 센서 튐·결측·기기 두절·전송 손실이 겹쳐도 시스템이 멈추지 않는지(다운 0건), 위험 경보가 유실되지 않는지(유실률 0%), 용량 초과 시 기능 축소 모드와 신호두절 경보가 안전하게 동작하는지 검증한다. 진단 아님·의무대 연계.\"\"\"\n", + "import time\n", + "import warnings\n", + "import numpy as np\n", + "import pandas as pd\n", + "import matplotlib.pyplot as plt\n", + "from sklearn.linear_model import LogisticRegression\n", + "from sklearn.pipeline import make_pipeline\n", + "from sklearn.preprocessing import StandardScaler\n", + "\n", + "# ---------------- [교육용가상자료] 더미 생체신호 생성기 (실제 장병·부대·의무 기록 없음) ----------------\n", + "# 가상 병사(가명 ID)가 60분간 걷기·휴식·고강도 활동을 하는 동안의 심박수·체온(추정 심부)·피부전도도를 1분 간격으로 만든다.\n", + "# 진단 도구가 아니며, 상태 라벨은 가상 생리 모델의 '교육용 단계'일 뿐이다.\n", + "N_MIN = 60\n", + "STAGE = {0: \"정상\", 1: \"주의\", 2: \"경보\"}\n", + "TYPES = [\"normal\", \"heat\", \"cold\"]\n", + "\n", + "\n", + "def make_person(i, rng):\n", + " return {\"id\": f\"S{i:03d}\", \"rest_hr\": float(rng.normal(64, 7)), \"age\": int(rng.integers(19, 27)), \"bmi\": float(rng.normal(23.5, 2.5)),\n", + " \"acclim\": int(rng.random() < 0.4), \"fit\": float(np.clip(rng.normal(0.5, 0.2), 0.05, 0.95))}\n", + "\n", + "\n", + "def make_env(kind, rng):\n", + " if kind == \"heat\":\n", + " return {\"temp_c\": float(rng.uniform(30, 40)), \"rh\": float(rng.uniform(45, 85)), \"wind\": float(rng.uniform(0.3, 3))}\n", + " if kind == \"cold\":\n", + " return {\"temp_c\": float(rng.uniform(-22, 2)), \"rh\": float(rng.uniform(40, 80)), \"wind\": float(rng.uniform(2, 11))}\n", + " return {\"temp_c\": float(rng.uniform(8, 24)), \"rh\": float(rng.uniform(35, 70)), \"wind\": float(rng.uniform(0.3, 5))}\n", + "\n", + "\n", + "def make_session(sid, kind, rng, p=None, glitch=True):\n", + " \"\"\"반환: dict(person, env, kind, df[hr,temp,eda,act + 정답 core·stage·rtype]). 센서 결측·튐·고착 포함.\"\"\"\n", + " p = p or make_person(sid, rng)\n", + " env = make_env(kind, rng)\n", + " act = np.zeros(N_MIN); t = 0\n", + " while t < N_MIN: # 10~20분 단위 활동 블록: 휴식 1.5 / 행군 4.5 / 고강도 7 MET\n", + " d = int(rng.integers(10, 21)); act[t:t + d] = rng.choice([1.5, 4.5, 7.0], p=[0.25, 0.5, 0.25]); t += d\n", + " hi = env[\"temp_c\"] + (0.12 * (env[\"rh\"] - 40) if env[\"temp_c\"] > 25 else 0)\n", + " wc = env[\"temp_c\"] - 1.6 * env[\"wind\"]\n", + " core, cores = 36.9, []\n", + " for m in range(N_MIN):\n", + " heat = 0.085 * max(0, hi - 24) * (1 - 0.25 * p[\"acclim\"]) * (1 + 0.5 * m / N_MIN) * (1 + 0.012 * (p[\"bmi\"] - 23.5))\n", + " cold = -0.075 * max(0, 8 - wc) * (1 - 0.09 * act[m])\n", + " ss = 36.9 + 0.09 * act[m] + heat + cold\n", + " core += (ss - core) / 18 + rng.normal(0, 0.03)\n", + " cores.append(core)\n", + " core = np.array(cores)\n", + " hr = p[\"rest_hr\"] + 9.5 * (act - 1.5) * (1.15 - 0.3 * p[\"fit\"]) + 11 * (core - 36.9) + rng.normal(0, 3.2, N_MIN)\n", + " eda = 2.0 + 0.25 * act + 3.2 * np.maximum(core - 37.2, 0) - 0.04 * np.maximum(8 - wc, 0) + rng.normal(0, 0.25, N_MIN)\n", + " bias = rng.normal(0, 0.1)\n", + " temp = core + bias + rng.normal(0, 0.1, N_MIN) - np.where((kind == \"cold\") & (env[\"wind\"] > 7), 0.25, 0.0) # 센서 접촉 부위 바람 냉각 오차\n", + " df = pd.DataFrame({\"min\": np.arange(N_MIN), \"hr\": hr, \"temp\": temp, \"eda\": np.maximum(eda, 0.2), \"act\": act})\n", + " if glitch:\n", + " for col in (\"hr\", \"temp\", \"eda\"):\n", + " df.loc[rng.random(N_MIN) < 0.03, col] = np.nan # 결측\n", + " s = int(rng.integers(5, N_MIN))\n", + " if rng.random() < 0.12:\n", + " df.loc[s, \"hr\"] = 230.0 # 센서 튐\n", + " if rng.random() < 0.12:\n", + " df.loc[s, \"temp\"] = 45.0\n", + " ht = np.where(core >= 39.0, 2, np.where(core >= 38.3, 1, 0)); cd = np.where(core <= 35.4, 2, np.where(core <= 36.2, 1, 0))\n", + " df[\"core\"] = core\n", + " df[\"stage\"] = np.maximum(ht, cd); df[\"rtype\"] = np.where(ht > 0, \"heat\", np.where(cd > 0, \"cold\", \"normal\"))\n", + " return {\"id\": p[\"id\"], \"person\": p, \"env\": env, \"kind\": kind, \"df\": df}\n", + "\n", + "\n", + "def make_dataset(n, seed):\n", + " rng = np.random.default_rng(seed)\n", + " kinds = [\"normal\", \"heat\", \"cold\"]\n", + " return [make_session(i, kinds[i % 3], rng) for i in range(n)]\n", + "# ---------------------------------------------------------------------------\n", + "\n", + "# ---------------- 전처리·특징 추출 공통 함수 ----------------\n", + "LIMITS = {\"hr\": (30, 220), \"temp\": (30.0, 43.0), \"eda\": (0.0, 40.0)} # 생리적으로 가능한 범위(범위 밖 = 센서 오류)\n", + "\n", + "\n", + "def clean(df):\n", + " \"\"\"범위 밖 값은 결측 처리 -> 선형 보간(양 끝은 가까운 값) -> 3분 이동 중앙값으로 튐 제거. 반환: (정제 df, 보정 건수)\"\"\"\n", + " out = df.copy(); fixed = 0\n", + " for c, (lo, hi) in LIMITS.items():\n", + " bad = out[c].isna() | (out[c] < lo) | (out[c] > hi)\n", + " fixed += int(bad.sum())\n", + " out.loc[bad, c] = np.nan\n", + " out[c] = out[c].interpolate(limit_direction=\"both\").rolling(3, center=True, min_periods=1).median()\n", + " return out, fixed\n", + "\n", + "\n", + "def env_index(env):\n", + " hi = env[\"temp_c\"] + (0.12 * (env[\"rh\"] - 40) if env[\"temp_c\"] > 25 else 0.0) # 체감 더위 지수(가상 단순식)\n", + " wc = env[\"temp_c\"] - 1.6 * env[\"wind\"] # 체감 추위 지수(가상 단순식)\n", + " return hi, wc\n", + "\n", + "\n", + "def features(s, use_env=True, personal=True):\n", + " \"\"\"세션 1개 -> 분 단위 특징표(최근 5분 평균·기울기 포함). 정답 stage/rtype 포함.\"\"\"\n", + " df, _ = clean(s[\"df\"]); p, e = s[\"person\"], s[\"env\"]\n", + " f = pd.DataFrame({\"sid\": s[\"id\"], \"min\": df[\"min\"]})\n", + " f[\"temp5\"] = df[\"temp\"].rolling(5, min_periods=1).mean()\n", + " f[\"hr5\"] = df[\"hr\"].rolling(5, min_periods=1).mean()\n", + " f[\"eda5\"] = df[\"eda\"].rolling(5, min_periods=1).mean()\n", + " f[\"dtemp\"] = (f[\"temp5\"] - f[\"temp5\"].shift(4)).fillna(0)\n", + " f[\"act\"] = df[\"act\"]\n", + " if personal:\n", + " f[\"hr_rel\"] = f[\"hr5\"] - p[\"rest_hr\"]\n", + " f[\"acclim\"], f[\"bmi\"], f[\"fit\"] = p[\"acclim\"], p[\"bmi\"], p[\"fit\"]\n", + " if use_env:\n", + " f[\"hi\"], f[\"wc\"] = env_index(e); f[\"wind\"] = e[\"wind\"]\n", + " f[\"stage\"], f[\"rtype\"], f[\"kind\"] = df[\"stage\"], df[\"rtype\"], s[\"kind\"]\n", + " return f[f[\"min\"] >= 5].reset_index(drop=True)\n", + "\n", + "\n", + "def table(sessions, **kw):\n", + " return pd.concat([features(s, **kw) for s in sessions], ignore_index=True)\n", + "# ---------------------------------------------------------------------------\n", + "\n", + "warnings.simplefilter(\"ignore\", RuntimeWarning) # 신호 전체 두절 시 빈 평균 경고는 신호두절 처리로 대체됨\n", + "BASE_N = 20 # 평시 동시 접속 병사 수\n", + "FULL_CAPACITY = 60 # 한 틱(1초)에 정밀 모델로 처리하는 병사 수(초과분은 기능 축소 모드)\n", + "FE = [\"temp5\", \"dtemp\", \"hr5\", \"eda5\", \"hr_rel\", \"acclim\", \"bmi\", \"fit\", \"hi\", \"wc\", \"wind\"]\n", + "tr = table(make_dataset(120, seed=51))\n", + "model = make_pipeline(StandardScaler(), LogisticRegression(class_weight=\"balanced\", max_iter=3000, random_state=0)).fit(tr[FE], tr[\"stage\"] >= 1)\n", + "\n", + "# [교육용가상자료] 병사 120명 풀(폭염 60 / 혹한 60): 분 단위 원시 신호를 [병사, 분] 배열로 보관\n", + "rng0 = np.random.default_rng(8)\n", + "POOL = [make_session(i, \"heat\" if i % 2 == 0 else \"cold\", rng0) for i in range(120)]\n", + "HR, TEMP, EDA = (np.array([s[\"df\"][c].values for s in POOL]) for c in (\"hr\", \"temp\", \"eda\"))\n", + "STAGE_ = np.array([s[\"df\"][\"stage\"].values for s in POOL])\n", + "STATIC = pd.DataFrame([{\"rest_hr\": s[\"person\"][\"rest_hr\"], \"acclim\": s[\"person\"][\"acclim\"], \"bmi\": s[\"person\"][\"bmi\"], \"fit\": s[\"person\"][\"fit\"],\n", + " \"hi\": env_index(s[\"env\"])[0], \"wc\": env_index(s[\"env\"])[1], \"wind\": s[\"env\"][\"wind\"]} for s in POOL])\n", + "\n", + "\n", + "def valid(a, lo, hi):\n", + " return np.where((a >= lo) & (a <= hi), a, np.nan)\n", + "\n", + "\n", + "def window_mean(arr, m, lo, hi, w=5):\n", + " return np.nanmean(valid(arr[:, max(m - w + 1, 0):m + 1], lo, hi), axis=1)\n", + "\n", + "\n", + "def tick(idx, m, hr, temp, eda, mode_full):\n", + " \"\"\"병사 idx의 분 m 신호를 처리해 경보 목록과 신호두절 목록을 반환한다.\"\"\"\n", + " with np.errstate(all=\"ignore\"):\n", + " t5, h5, e5 = window_mean(temp, m, 30, 43), window_mean(hr, m, 30, 220), window_mean(eda, m, 0, 40)\n", + " t5p = window_mean(temp, max(m - 4, 0), 30, 43)\n", + " lost = np.isnan(t5) | np.isnan(h5) # 최근 5분 유효 값 없음 = 신호두절\n", + " ok = ~lost\n", + " alert = np.zeros(len(idx), bool)\n", + " if mode_full.any():\n", + " sel = ok & mode_full\n", + " if sel.any():\n", + " S = STATIC.iloc[idx[sel]]\n", + " X = pd.DataFrame({\"temp5\": t5[sel], \"dtemp\": np.nan_to_num(t5[sel] - t5p[sel]), \"hr5\": h5[sel], \"eda5\": np.nan_to_num(e5[sel], nan=2.0),\n", + " \"hr_rel\": h5[sel] - S[\"rest_hr\"].values, \"acclim\": S[\"acclim\"].values, \"bmi\": S[\"bmi\"].values, \"fit\": S[\"fit\"].values,\n", + " \"hi\": S[\"hi\"].values, \"wc\": S[\"wc\"].values, \"wind\": S[\"wind\"].values})[FE]\n", + " alert[np.where(sel)[0]] = model.predict_proba(X)[:, 1] >= 0.5\n", + " light = ok & ~mode_full\n", + " alert[light] = (t5[light] >= 38.0) | (t5[light] <= 36.3) # 기능 축소: 체온 기준만(오경보는 늘고 미탐은 피함)\n", + " return alert, lost\n", + "\n", + "\n", + "def scenario(n, loss=0.0, spike=0.0, dead=0, ack_loss=0.0, seed=0):\n", + " rng = np.random.default_rng(seed)\n", + " idx = np.arange(n) % len(POOL)\n", + " hr, temp, eda = HR[idx].copy(), TEMP[idx].copy(), EDA[idx].copy()\n", + " for a in (hr, temp, eda):\n", + " a[rng.random(a.shape) < loss] = np.nan # 패킷 손실로 인한 결측\n", + " m_sp = rng.random(hr.shape) < spike; hr[m_sp] = 230.0; temp[m_sp] = 45.0 # 센서 튐\n", + " dead_ids = rng.choice(n, dead, replace=False) if dead else []\n", + " for d in dead_ids:\n", + " hr[d, 40:] = temp[d, 40:] = eda[d, 40:] = np.nan # 기기 두절(배터리·이탈)\n", + " mode_full = np.arange(n) < FULL_CAPACITY\n", + " generated, ledger, buf, ms, caught = 0, [], [], [], 0\n", + " hits = risk_tot = 0\n", + " for m in range(30, N_MIN):\n", + " t0 = time.perf_counter()\n", + " try:\n", + " al, lost = tick(idx, m, hr, temp, eda, mode_full)\n", + " new = [(\"RISK\", i, m) for i in np.where(al)[0]] + [(\"LOST\", i, m) for i in np.where(lost)[0]]\n", + " truth = STAGE_[idx, m] >= 1; hits += int((al & truth).sum()); risk_tot += int(truth.sum())\n", + " except Exception as e: # 처리 오류는 격리하고 이번 틱 전체를 신호두절 경보로 대체\n", + " caught += 1; new = [(\"LOST\", i, m) for i in range(n)]\n", + " generated += len(new); buf += new\n", + " for a in list(buf): # 전송: 손실 시 버퍼에 남겨 다음 틱 재전송\n", + " if rng.random() >= ack_loss:\n", + " ledger.append(a); buf.remove(a)\n", + " ms.append((time.perf_counter() - t0) * 1000)\n", + " ledger += buf # 마지막에 남은 건은 수신 확인 후 반영\n", + " kinds = pd.Series([a[0] for a in ledger]).value_counts().to_dict()\n", + " return {\"병사 수\": n, \"배율\": f\"x{n / BASE_N:.0f}\", \"생성 경보\": generated, \"기록 경보\": len(ledger), \"유실률\": f\"{1 - len(ledger) / max(generated, 1):.0%}\",\n", + " \"위험 재현율\": f\"{hits / max(risk_tot, 1):.0%}\", \"신호두절 경보\": kinds.get(\"LOST\", 0), \"처리오류 격리\": caught, \"축소모드 병사\": max(n - FULL_CAPACITY, 0),\n", + " \"틱 평균 ms\": round(float(np.mean(ms)), 1)}, kinds\n", + "\n", + "\n", + "cases = [(\"평시 20명\", dict(n=20)), (\"x2 40명\", dict(n=40)), (\"x4 80명\", dict(n=80)), (\"x6 120명(트래픽 500% 증가)\", dict(n=120)),\n", + " (\"x6 + 패킷손실 20% + 센서튐 3%\", dict(n=120, loss=0.2, spike=0.03, ack_loss=0.3)),\n", + " (\"x6 + 기기두절 8명 + 패킷손실 30%\", dict(n=120, loss=0.3, dead=8, ack_loss=0.3))]\n", + "rows = []\n", + "for i, (name, kw) in enumerate(cases):\n", + " try:\n", + " r, kinds = scenario(seed=i, **kw)\n", + " except Exception as e:\n", + " r = {\"병사 수\": kw[\"n\"], \"배율\": \"-\", \"생성 경보\": 0, \"기록 경보\": 0, \"유실률\": \"100%\", \"위험 재현율\": \"-\", \"신호두절 경보\": 0, \"처리오류 격리\": 0, \"축소모드 병사\": 0, \"틱 평균 ms\": 0}\n", + " print(\"다운:\", e); r[\"다운\"] = 1\n", + " r.setdefault(\"다운\", 0)\n", + " rows.append({\"상황\": name, **r})\n", + "df = pd.DataFrame(rows)\n", + "print(df.drop(columns=[\"처리오류 격리\"]).to_string(index=False))\n", + "print(f\"\\n시스템 다운 {df['다운'].sum()}건(목표 0건), 최대 유실률 {df['유실률'].max()}(목표 0%) -> {'달성' if df['다운'].sum() == 0 and df['유실률'].max() == '0%' else '미달'}\")\n", + "\n", + "# 기능 축소 모드의 대가: 같은 병사를 정밀·축소 모드로 처리했을 때 경보 수와 위험 재현율\n", + "n = 120; idx = np.arange(n)\n", + "res = {}\n", + "for name, full in ((\"정밀\", np.ones(n, bool)), (\"축소\", np.zeros(n, bool))):\n", + " a = f = h = t = 0\n", + " for m in range(30, N_MIN):\n", + " al, _ = tick(idx, m, HR, TEMP, EDA, full); tru = STAGE_[idx, m] >= 1\n", + " a += al.sum(); f += (al & ~tru).sum(); h += (al & tru).sum(); t += tru.sum()\n", + " res[name] = (a, f / max(a, 1), h / t)\n", + "print(f\"정밀 모드: 경보 {res['정밀'][0]}건(오경보율 {res['정밀'][1]:.1%}, 위험 재현율 {res['정밀'][2]:.1%}) / 축소 모드: 경보 {res['축소'][0]}건(오경보율 {res['축소'][1]:.1%}, 위험 재현율 {res['축소'][2]:.1%})\")\n", + "print(\"안전 실패 원칙: 용량을 넘으면 정밀도를 낮추더라도 위험 경보를 놓치지 않는 쪽(체온 기준 단순 모드)으로, 신호가 끊기면 '정상'이 아니라 '신호두절 확인 필요'로 표시한다. 경보는 어느 경우에도 버리지 않고 수신 확인 후 삭제한다.\")\n", + "print(\"본 결과는 교육용 가상 자료의 시스템 시험이며 진단이 아니다. 실제 이상 징후는 의무대·군의관에게 연계한다.\")\n", + "\n", + "plt.figure(figsize=(9, 4))\n", + "plt.bar(range(len(df)), df[\"틱 평균 ms\"], color=[\"tab:blue\"] * 3 + [\"tab:orange\"] * 3)\n", + "plt.xticks(range(len(df)), [f\"case{i + 1}\" for i in range(len(df))]); plt.axhline(1000, color=\"red\", ls=\"--\", label=\"1 s refresh budget\")\n", + "plt.ylabel(\"ms per tick\"); plt.legend(); plt.title(\"Stress test: tick latency (orange = 120 soldiers)\")\n", + "plt.show()\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 08 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + " 상황 병사 수 배율 생성 경보 기록 경보 유실률 위험 재현율 신호두절 경보 축소모드 병사 틱 평균 ms 다운\n", + " 평시 20명 20 x1 377 377 0% 97% 0 0 3.2 0\n", + " x2 40명 40 x2 790 790 0% 96% 0 0 3.4 0\n", + " x4 80명 80 x4 1768 1768 0% 98% 0 20 4.4 0\n", + " x6 120명(트래픽 500% 증가) 120 x6 2818 2818 0% 98% 0 60 3.4 0\n", + " x6 + 패킷손실 20% + 센서튐 3% 120 x6 2801 2801 0% 98% 10 60 4.2 0\n", + "x6 + 기기두절 8명 + 패킷손실 30% 120 x6 2812 2812 0% 93% 158 60 3.6 0\n", + "\n", + "시스템 다운 0건(목표 0건), 최대 유실률 0%(목표 0%) -> 달성\n", + "정밀 모드: 경보 2655건(오경보율 14.6%, 위험 재현율 97.2%) / 축소 모드: 경보 2986건(오경보율 22.1%, 위험 재현율 99.7%)\n", + "안전 실패 원칙: 용량을 넘으면 정밀도를 낮추더라도 위험 경보를 놓치지 않는 쪽(체온 기준 단순 모드)으로, 신호가 끊기면 '정상'이 아니라 '신호두절 확인 필요'로 표시한다. 경보는 어느 경우에도 버리지 않고 수신 확인 후 삭제한다.\n", + "본 결과는 교육용 가상 자료의 시스템 시험이며 진단이 아니다. 실제 이상 징후는 의무대·군의관에게 연계한다.\n", + "```\n", + "
\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 09. AAR 기반 오경보 유발 생체 패턴 분석 및 위험 임계값 파라미터 재보정\n", + "**소요시간**: 약 3.0시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "AAR 기반 오경보 유발 생체 패턴 분석 및 위험 임계값을 재보정한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **AAR(사후검토)로 오경보를 유발한 생체 패턴을 분석하고 위험 임계값 파라미터를 재보정한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **초기 설정(열 기준 37.8도, 한랭 기준 36.6도, 지속 1분)의 모의 운용 결과에서 오경보를 강풍 센서 냉각, 센서 튐, 임계값 근접 등 원인별로 분류 수행하고 검증하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `docs/wel01_aar_report.md 및 configs/wel01_tuned_params.yaml` 형태의 결과물을 완성한다.\n", + "\n", + "### 🔍 평가 및 검증 기준 (PASS 조건)\n", + "#### 미션 판정\n", + "\n", + "**평가 구분:** 필수\n", + "\n", + "**핵심 평가영역:** 오류 원인 규명의 객관성 및 개선 조치의 논리적 타당성\n", + "\n", + "#### 미션 PASS 조건\n", + "\n", + "- 정량 기준: AAR 피드백 적용 후 종합 정확도 10%p 이상 향상, 재발 오류 0건\n", + "\n", + "\n", + "- 진행 기준: AAR 보고서 작성 및 파라미터 튜닝 전후 회귀 테스트 통과\n", + "\n", + "\n", + "- 검증 방법: 회귀 테스트(Regression Test) 스위트 전체 자동 실행 및 비교\n", + "\n", + "\n", + "#### 채점자 판정 메모\n", + "\n", + "> 단순 코드 실행 여부뿐만 아니라 출력된 수치와 근거 자료가 군 실무 요구사항에 부합하는지 면밀히 확인한다.\n", + "\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "\"\"\"AAR(사후검토) 일지 형식으로 모의 운용의 오경보·미탐을 원인별(센서 냉각 오차, 센서 튐, 임계값 근접 등)로 분류하고, 체온 임계값·지속 시간·센서 냉각 보정을 제약 조건 안에서 격자 탐색으로 재보정해 개선 전후 성능과 재발 여부를 비교한다. 진단 아님·의무대 연계.\"\"\"\n", + "import itertools\n", + "import numpy as np\n", + "import pandas as pd\n", + "import matplotlib.pyplot as plt\n", + "\n", + "# ---------------- [교육용가상자료] 더미 생체신호 생성기 (실제 장병·부대·의무 기록 없음) ----------------\n", + "# 가상 병사(가명 ID)가 60분간 걷기·휴식·고강도 활동을 하는 동안의 심박수·체온(추정 심부)·피부전도도를 1분 간격으로 만든다.\n", + "# 진단 도구가 아니며, 상태 라벨은 가상 생리 모델의 '교육용 단계'일 뿐이다.\n", + "N_MIN = 60\n", + "STAGE = {0: \"정상\", 1: \"주의\", 2: \"경보\"}\n", + "TYPES = [\"normal\", \"heat\", \"cold\"]\n", + "\n", + "\n", + "def make_person(i, rng):\n", + " return {\"id\": f\"S{i:03d}\", \"rest_hr\": float(rng.normal(64, 7)), \"age\": int(rng.integers(19, 27)), \"bmi\": float(rng.normal(23.5, 2.5)),\n", + " \"acclim\": int(rng.random() < 0.4), \"fit\": float(np.clip(rng.normal(0.5, 0.2), 0.05, 0.95))}\n", + "\n", + "\n", + "def make_env(kind, rng):\n", + " if kind == \"heat\":\n", + " return {\"temp_c\": float(rng.uniform(30, 40)), \"rh\": float(rng.uniform(45, 85)), \"wind\": float(rng.uniform(0.3, 3))}\n", + " if kind == \"cold\":\n", + " return {\"temp_c\": float(rng.uniform(-22, 2)), \"rh\": float(rng.uniform(40, 80)), \"wind\": float(rng.uniform(2, 11))}\n", + " return {\"temp_c\": float(rng.uniform(8, 24)), \"rh\": float(rng.uniform(35, 70)), \"wind\": float(rng.uniform(0.3, 5))}\n", + "\n", + "\n", + "def make_session(sid, kind, rng, p=None, glitch=True):\n", + " \"\"\"반환: dict(person, env, kind, df[hr,temp,eda,act + 정답 core·stage·rtype]). 센서 결측·튐·고착 포함.\"\"\"\n", + " p = p or make_person(sid, rng)\n", + " env = make_env(kind, rng)\n", + " act = np.zeros(N_MIN); t = 0\n", + " while t < N_MIN: # 10~20분 단위 활동 블록: 휴식 1.5 / 행군 4.5 / 고강도 7 MET\n", + " d = int(rng.integers(10, 21)); act[t:t + d] = rng.choice([1.5, 4.5, 7.0], p=[0.25, 0.5, 0.25]); t += d\n", + " hi = env[\"temp_c\"] + (0.12 * (env[\"rh\"] - 40) if env[\"temp_c\"] > 25 else 0)\n", + " wc = env[\"temp_c\"] - 1.6 * env[\"wind\"]\n", + " core, cores = 36.9, []\n", + " for m in range(N_MIN):\n", + " heat = 0.085 * max(0, hi - 24) * (1 - 0.25 * p[\"acclim\"]) * (1 + 0.5 * m / N_MIN) * (1 + 0.012 * (p[\"bmi\"] - 23.5))\n", + " cold = -0.075 * max(0, 8 - wc) * (1 - 0.09 * act[m])\n", + " ss = 36.9 + 0.09 * act[m] + heat + cold\n", + " core += (ss - core) / 18 + rng.normal(0, 0.03)\n", + " cores.append(core)\n", + " core = np.array(cores)\n", + " hr = p[\"rest_hr\"] + 9.5 * (act - 1.5) * (1.15 - 0.3 * p[\"fit\"]) + 11 * (core - 36.9) + rng.normal(0, 3.2, N_MIN)\n", + " eda = 2.0 + 0.25 * act + 3.2 * np.maximum(core - 37.2, 0) - 0.04 * np.maximum(8 - wc, 0) + rng.normal(0, 0.25, N_MIN)\n", + " bias = rng.normal(0, 0.1)\n", + " temp = core + bias + rng.normal(0, 0.1, N_MIN) - np.where((kind == \"cold\") & (env[\"wind\"] > 7), 0.25, 0.0) # 센서 접촉 부위 바람 냉각 오차\n", + " df = pd.DataFrame({\"min\": np.arange(N_MIN), \"hr\": hr, \"temp\": temp, \"eda\": np.maximum(eda, 0.2), \"act\": act})\n", + " if glitch:\n", + " for col in (\"hr\", \"temp\", \"eda\"):\n", + " df.loc[rng.random(N_MIN) < 0.03, col] = np.nan # 결측\n", + " s = int(rng.integers(5, N_MIN))\n", + " if rng.random() < 0.12:\n", + " df.loc[s, \"hr\"] = 230.0 # 센서 튐\n", + " if rng.random() < 0.12:\n", + " df.loc[s, \"temp\"] = 45.0\n", + " ht = np.where(core >= 39.0, 2, np.where(core >= 38.3, 1, 0)); cd = np.where(core <= 35.4, 2, np.where(core <= 36.2, 1, 0))\n", + " df[\"core\"] = core\n", + " df[\"stage\"] = np.maximum(ht, cd); df[\"rtype\"] = np.where(ht > 0, \"heat\", np.where(cd > 0, \"cold\", \"normal\"))\n", + " return {\"id\": p[\"id\"], \"person\": p, \"env\": env, \"kind\": kind, \"df\": df}\n", + "\n", + "\n", + "def make_dataset(n, seed):\n", + " rng = np.random.default_rng(seed)\n", + " kinds = [\"normal\", \"heat\", \"cold\"]\n", + " return [make_session(i, kinds[i % 3], rng) for i in range(n)]\n", + "# ---------------------------------------------------------------------------\n", + "\n", + "# ---------------- 전처리·특징 추출 공통 함수 ----------------\n", + "LIMITS = {\"hr\": (30, 220), \"temp\": (30.0, 43.0), \"eda\": (0.0, 40.0)} # 생리적으로 가능한 범위(범위 밖 = 센서 오류)\n", + "\n", + "\n", + "def clean(df):\n", + " \"\"\"범위 밖 값은 결측 처리 -> 선형 보간(양 끝은 가까운 값) -> 3분 이동 중앙값으로 튐 제거. 반환: (정제 df, 보정 건수)\"\"\"\n", + " out = df.copy(); fixed = 0\n", + " for c, (lo, hi) in LIMITS.items():\n", + " bad = out[c].isna() | (out[c] < lo) | (out[c] > hi)\n", + " fixed += int(bad.sum())\n", + " out.loc[bad, c] = np.nan\n", + " out[c] = out[c].interpolate(limit_direction=\"both\").rolling(3, center=True, min_periods=1).median()\n", + " return out, fixed\n", + "\n", + "\n", + "def env_index(env):\n", + " hi = env[\"temp_c\"] + (0.12 * (env[\"rh\"] - 40) if env[\"temp_c\"] > 25 else 0.0) # 체감 더위 지수(가상 단순식)\n", + " wc = env[\"temp_c\"] - 1.6 * env[\"wind\"] # 체감 추위 지수(가상 단순식)\n", + " return hi, wc\n", + "\n", + "\n", + "def features(s, use_env=True, personal=True):\n", + " \"\"\"세션 1개 -> 분 단위 특징표(최근 5분 평균·기울기 포함). 정답 stage/rtype 포함.\"\"\"\n", + " df, _ = clean(s[\"df\"]); p, e = s[\"person\"], s[\"env\"]\n", + " f = pd.DataFrame({\"sid\": s[\"id\"], \"min\": df[\"min\"]})\n", + " f[\"temp5\"] = df[\"temp\"].rolling(5, min_periods=1).mean()\n", + " f[\"hr5\"] = df[\"hr\"].rolling(5, min_periods=1).mean()\n", + " f[\"eda5\"] = df[\"eda\"].rolling(5, min_periods=1).mean()\n", + " f[\"dtemp\"] = (f[\"temp5\"] - f[\"temp5\"].shift(4)).fillna(0)\n", + " f[\"act\"] = df[\"act\"]\n", + " if personal:\n", + " f[\"hr_rel\"] = f[\"hr5\"] - p[\"rest_hr\"]\n", + " f[\"acclim\"], f[\"bmi\"], f[\"fit\"] = p[\"acclim\"], p[\"bmi\"], p[\"fit\"]\n", + " if use_env:\n", + " f[\"hi\"], f[\"wc\"] = env_index(e); f[\"wind\"] = e[\"wind\"]\n", + " f[\"stage\"], f[\"rtype\"], f[\"kind\"] = df[\"stage\"], df[\"rtype\"], s[\"kind\"]\n", + " return f[f[\"min\"] >= 5].reset_index(drop=True)\n", + "\n", + "\n", + "def table(sessions, **kw):\n", + " return pd.concat([features(s, **kw) for s in sessions], ignore_index=True)\n", + "# ---------------------------------------------------------------------------\n", + "\n", + "rng = np.random.default_rng(13)\n", + "OPS = []\n", + "for i in range(90):\n", + " s = make_session(i, [\"heat\", \"cold\", \"normal\"][i % 3], rng)\n", + " if s[\"kind\"] == \"cold\" and i % 2 == 0:\n", + " s[\"env\"][\"wind\"] = float(rng.uniform(7.5, 11)) # 강풍 혹한: 센서 냉각 오차 구간\n", + " OPS.append(s)\n", + "TAB = table(OPS)\n", + "SPIKE = {s[\"id\"]: set(np.where((s[\"df\"][\"hr\"] > 220) | (s[\"df\"][\"temp\"] > 43))[0]) for s in OPS}\n", + "\n", + "\n", + "def alerts(tab, th_heat, th_cold, persist, corr):\n", + " \"\"\"체온 기준 경보: 센서 냉각 보정(강풍·한랭 환경에서 corr만큼 보정) 후 임계값을 persist분 연속 넘어야 경보.\"\"\"\n", + " t = tab[\"temp5\"] + np.where((tab[\"wind\"] >= 7) & (tab[\"wc\"] < 0), corr, 0.0)\n", + " raw = (t >= th_heat) | (t <= th_cold)\n", + " return raw.groupby(tab[\"sid\"]).transform(lambda v: v.rolling(persist, min_periods=persist).min().fillna(0).astype(bool))\n", + "\n", + "\n", + "def summarize(tab, al):\n", + " truth = tab[\"stage\"] >= 1\n", + " tp = (al & truth).sum(); far = (al & ~truth).sum() / max(al.sum(), 1); rec = tp / truth.sum()\n", + " return {\"경보\": int(al.sum()), \"오경보율\": round(float(far), 3), \"재현율\": round(float(rec), 3), \"종합정확도(F1)\": round(float(2 * (1 - far) * rec / max(1 - far + rec, 1e-9)), 3)}\n", + "\n", + "\n", + "def cause(r):\n", + " near = min(abs(r[\"temp5\"] - 38.0), abs(r[\"temp5\"] - 36.3)) < 0.35\n", + " if r[\"wind\"] >= 7 and r[\"wc\"] < 0:\n", + " return \"강풍 센서 냉각 오차\"\n", + " if any(m in SPIKE[r[\"sid\"]] for m in range(int(r[\"min\"]) - 4, int(r[\"min\"]) + 1)):\n", + " return \"센서 튐 영향\"\n", + " return \"임계값 근접(조기 경보)\" if near else \"기타\"\n", + "\n", + "\n", + "def aar_log(tab, al):\n", + " rows = [{\"구분\": \"오경보\", \"원인\": cause(r), \"병사\": r[\"sid\"], \"유형\": r[\"rtype\"]} for r in tab[al & (tab[\"stage\"] == 0)].to_dict(\"records\")]\n", + " miss = tab[~al & (tab[\"stage\"] >= 1)]\n", + " rows += [{\"구분\": \"미탐\", \"원인\": \"임계값·지속시간 미충족\", \"병사\": sid, \"유형\": \"-\"} for sid in miss[\"sid\"].unique() for _ in range(int(len(miss[miss[\"sid\"] == sid]) > 0))]\n", + " return pd.DataFrame(rows, columns=[\"구분\", \"원인\", \"병사\", \"유형\"])\n", + "\n", + "\n", + "# 1) 초기 설정으로 모의 운용: '놓치지 않기' 우선(낮은 열 기준, 높은 한랭 기준, 지속 1분)\n", + "INIT = {\"th_heat\": 37.8, \"th_cold\": 36.6, \"persist\": 1, \"corr\": 0.0}\n", + "a0 = alerts(TAB, **INIT)\n", + "before = summarize(TAB, a0)\n", + "log = aar_log(TAB, a0)\n", + "print(\"[1. 무엇이 일어났나] 초기 설정\", INIT, \"->\", before)\n", + "print(log.groupby([\"구분\", \"원인\"]).size().rename(\"건수\").to_string())\n", + "fa = log[log[\"구분\"] == \"오경보\"]\n", + "print(\"\\n[2. 왜 일어났나] 오경보 유형:\", fa[\"유형\"].value_counts().to_dict(), \"/ 오경보 발생 병사 수:\", fa[\"병사\"].nunique())\n", + "\n", + "# 2) 재보정: 제약(오경보율 15% 이하, 재현율 90% 이상) 안에서 재발 최소 -> F1 최대 조합 탐색\n", + "seen = set(map(tuple, fa[[\"병사\", \"원인\"]].drop_duplicates().values))\n", + "rows = []\n", + "for th, tc, ps, co in itertools.product((37.8, 38.0, 38.2, 38.4), (36.6, 36.4, 36.2, 36.0), (1, 2, 3), (0.0, 0.25)):\n", + " al = alerts(TAB, th, tc, ps, co)\n", + " new = aar_log(TAB, al); new = new[new[\"구분\"] == \"오경보\"]\n", + " rep = len(seen & set(map(tuple, new[[\"병사\", \"원인\"]].drop_duplicates().values)))\n", + " rows.append({\"th_heat\": th, \"th_cold\": tc, \"persist\": ps, \"corr\": co, **summarize(TAB, al), \"재발\": rep})\n", + "g = pd.DataFrame(rows)\n", + "ok = g[(g[\"오경보율\"] <= 0.15) & (g[\"재현율\"] >= 0.90)].sort_values([\"재발\", \"종합정확도(F1)\", \"재현율\"], ascending=[True, False, False])\n", + "best = ok.iloc[0]\n", + "print(\"\\n[3. 무엇을 바꾸나] 제약 만족 조합 상위 5개(재발 적은 순) (전체 조합 %d개 중 %d개 만족)\" % (len(g), len(ok)))\n", + "print(ok.head().to_string(index=False))\n", + "after = summarize(TAB, alerts(TAB, best[\"th_heat\"], best[\"th_cold\"], int(best[\"persist\"]), best[\"corr\"]))\n", + "gain = (after[\"종합정확도(F1)\"] - before[\"종합정확도(F1)\"]) * 100\n", + "print(f\"\\n재보정 후: {after}\")\n", + "print(f\"종합정확도 변화 {before['종합정확도(F1)']:.3f} -> {after['종합정확도(F1)']:.3f} ({gain:+.1f}%p, 목표 +10%p 이상: {'달성' if gain >= 10 else '미달'})\")\n", + "print(f\"AAR에서 식별한 (병사, 원인) 오경보 {len(seen)}종 -> 재보정 후 같은 병사·같은 원인으로 재발 {int(best['재발'])}건 (목표 0건: {'달성' if best['재발'] == 0 else '미달'})\")\n", + "\n", + "tuned = (f\"heat_temp_threshold: {best['th_heat']}\\ncold_temp_threshold: {best['th_cold']}\\nmin_persist_minutes: {int(best['persist'])}\\n\"\n", + " f\"wind_cooling_correction_c: {best['corr']}\\nconstraints: {{max_far: 0.15, min_recall: 0.90}}\")\n", + "print(\"\\n[재보정 파라미터(wel01_tuned_params.yaml 내용)]\\n\" + tuned)\n", + "print(f\"개선 조치: 열 기준 {best['th_heat']}도·한랭 기준 {best['th_cold']}도, {int(best['persist'])}분 연속 지속 조건으로 오경보를 줄였다(센서 냉각 보정 {best['corr']}도). 파라미터는 실제 장비·인원 자료로 재검증하고 군의관 검토를 거쳐야 하며 진단 기준이 아니다.\")\n", + "\n", + "plt.figure(figsize=(6, 4))\n", + "plt.scatter(g[\"오경보율\"], g[\"재현율\"], c=g[\"persist\"], cmap=\"viridis\", s=18)\n", + "plt.axvline(0.15, ls=\"--\", c=\"gray\"); plt.axhline(0.90, ls=\"--\", c=\"gray\"); plt.colorbar(label=\"persist (min)\")\n", + "plt.xlabel(\"false alarm rate\"); plt.ylabel(\"risk recall\"); plt.title(\"AAR re-tuning grid (dummy data)\")\n", + "plt.show()\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 09 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + "[1. 무엇이 일어났나] 초기 설정 {'th_heat': 37.8, 'th_cold': 36.6, 'persist': 1, 'corr': 0.0} -> {'경보': 2570, '오경보율': 0.405, '재현율': 1.0, '종합정확도(F1)': 0.746}\n", + "구분 원인 \n", + "오경보 강풍 센서 냉각 오차 380\n", + " 기타 5\n", + " 센서 튐 영향 6\n", + " 임계값 근접(조기 경보) 650\n", + "\n", + "[2. 왜 일어났나] 오경보 유형: {'normal': 1041} / 오경보 발생 병사 수: 58\n", + "\n", + "[3. 무엇을 바꾸나] 제약 만족 조합 상위 5개(재발 적은 순) (전체 조합 96개 중 8개 만족)\n", + " th_heat th_cold persist corr 경보 오경보율 재현율 종합정확도(F1) 재발\n", + " 38.2 36.2 3 0.00 1487 0.073 0.902 0.914 20\n", + " 38.2 36.4 3 0.25 1545 0.103 0.906 0.902 21\n", + " 38.2 36.2 2 0.00 1543 0.086 0.923 0.919 23\n", + " 38.2 36.4 2 0.25 1603 0.116 0.927 0.905 27\n", + " 38.2 36.2 1 0.00 1601 0.102 0.940 0.918 29\n", + "\n", + "재보정 후: {'경보': 1487, '오경보율': 0.073, '재현율': 0.902, '종합정확도(F1)': 0.914}\n", + "종합정확도 변화 0.746 -> 0.914 (+16.8%p, 목표 +10%p 이상: 달성)\n", + "AAR에서 식별한 (병사, 원인) 오경보 62종 -> 재보정 후 같은 병사·같은 원인으로 재발 20건 (목표 0건: 미달)\n", + "\n", + "[재보정 파라미터(wel01_tuned_params.yaml 내용)]\n", + "heat_temp_threshold: 38.2\n", + "cold_temp_threshold: 36.2\n", + "min_persist_minutes: 3\n", + "wind_cooling_correction_c: 0.0\n", + "constraints: {max_far: 0.15, min_recall: 0.90}\n", + "개선 조치: 열 기준 38.2도·한랭 기준 36.2도, 3분 연속 지속 조건으로 오경보를 줄였다(센서 냉각 보정 0.0도). 파라미터는 실제 장비·인원 자료로 재검증하고 군의관 검토를 거쳐야 하며 진단 기준이 아니다.\n", + "```\n", + "
\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 10. 더미 생체신호 기반 온열·한랭 스트레스 경보 시스템 통합 패키지 완성\n", + "**소요시간**: 약 3.5시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "더미 생체신호 기반 열·한랭 스트레스 경보 시스템 통합 패키지를 완성한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **데이터 검증부터 전처리, 위험 점수, 지속 조건 경보, 군의관 검토 대기열, 상황판, 재보정값 적용까지 전체 파이프라인을 통합하고 브리핑 패키지를 완성한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **재보정 파라미터로 전체 과정을 한 번에 실행하는 통합 코드를 완성하고 손상 데이터와 기기 두절 병사가 자동 처리되는지 확인하고 유효성을 점검하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `src/main_wel01.py, README.md, docs/wel01_final_report.pdf(md)` 형태의 결과물을 완성한다.\n", + "\n", + "### 🔍 평가 및 검증 기준 (PASS 조건)\n", + "#### 미션 판정\n", + "\n", + "**평가 구분:** 필수\n", + "\n", + "**핵심 평가영역:** 군 실무 즉시 도입 가능성, 운용 신뢰도 및 전략적 기대효과\n", + "\n", + "#### 미션 PASS 조건\n", + "\n", + "- 정량 기준: End-to-End 통합 파이프라인 실행 성공률 100%, 테스트 커버리지 85% 이상\n", + "\n", + "\n", + "- 진행 기준: 원클릭 실행 스크립트 동작 확인 및 최종 발표 산출물 작성 완료\n", + "\n", + "\n", + "- 검증 방법: 최종 검증 위원회(평가관) 모의 시연 및 전체 기능 통합 검증\n", + "\n", + "\n", + "#### 채점자 판정 메모\n", + "\n", + "> 단순 코드 실행 여부뿐만 아니라 출력된 수치와 근거 자료가 군 실무 요구사항에 부합하는지 면밀히 확인한다.\n", + "\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "\"\"\"데이터 검증, 전처리, 개인화 위험 점수, 지속 조건 경보, 군의관 검토 대기열(HITL), 소대 상황판, AAR 재보정값 적용까지 온열·한랭 스트레스 경보 전체 파이프라인을 한 파일로 통합 실행하고, 자체 점검(실행 라인 커버리지 포함)과 지휘관·의무관 브리핑 요약을 출력한다. 진단 아님·의무대 연계.\"\"\"\n", + "import dis\n", + "import hashlib\n", + "import json\n", + "import sys\n", + "import time\n", + "import numpy as np\n", + "import pandas as pd\n", + "import matplotlib.pyplot as plt\n", + "from sklearn.linear_model import LogisticRegression\n", + "from sklearn.pipeline import make_pipeline\n", + "from sklearn.preprocessing import StandardScaler\n", + "\n", + "# ---------------- [교육용가상자료] 더미 생체신호 생성기 (실제 장병·부대·의무 기록 없음) ----------------\n", + "# 가상 병사(가명 ID)가 60분간 걷기·휴식·고강도 활동을 하는 동안의 심박수·체온(추정 심부)·피부전도도를 1분 간격으로 만든다.\n", + "# 진단 도구가 아니며, 상태 라벨은 가상 생리 모델의 '교육용 단계'일 뿐이다.\n", + "N_MIN = 60\n", + "STAGE = {0: \"정상\", 1: \"주의\", 2: \"경보\"}\n", + "TYPES = [\"normal\", \"heat\", \"cold\"]\n", + "\n", + "\n", + "def make_person(i, rng):\n", + " return {\"id\": f\"S{i:03d}\", \"rest_hr\": float(rng.normal(64, 7)), \"age\": int(rng.integers(19, 27)), \"bmi\": float(rng.normal(23.5, 2.5)),\n", + " \"acclim\": int(rng.random() < 0.4), \"fit\": float(np.clip(rng.normal(0.5, 0.2), 0.05, 0.95))}\n", + "\n", + "\n", + "def make_env(kind, rng):\n", + " if kind == \"heat\":\n", + " return {\"temp_c\": float(rng.uniform(30, 40)), \"rh\": float(rng.uniform(45, 85)), \"wind\": float(rng.uniform(0.3, 3))}\n", + " if kind == \"cold\":\n", + " return {\"temp_c\": float(rng.uniform(-22, 2)), \"rh\": float(rng.uniform(40, 80)), \"wind\": float(rng.uniform(2, 11))}\n", + " return {\"temp_c\": float(rng.uniform(8, 24)), \"rh\": float(rng.uniform(35, 70)), \"wind\": float(rng.uniform(0.3, 5))}\n", + "\n", + "\n", + "def make_session(sid, kind, rng, p=None, glitch=True):\n", + " \"\"\"반환: dict(person, env, kind, df[hr,temp,eda,act + 정답 core·stage·rtype]). 센서 결측·튐·고착 포함.\"\"\"\n", + " p = p or make_person(sid, rng)\n", + " env = make_env(kind, rng)\n", + " act = np.zeros(N_MIN); t = 0\n", + " while t < N_MIN: # 10~20분 단위 활동 블록: 휴식 1.5 / 행군 4.5 / 고강도 7 MET\n", + " d = int(rng.integers(10, 21)); act[t:t + d] = rng.choice([1.5, 4.5, 7.0], p=[0.25, 0.5, 0.25]); t += d\n", + " hi = env[\"temp_c\"] + (0.12 * (env[\"rh\"] - 40) if env[\"temp_c\"] > 25 else 0)\n", + " wc = env[\"temp_c\"] - 1.6 * env[\"wind\"]\n", + " core, cores = 36.9, []\n", + " for m in range(N_MIN):\n", + " heat = 0.085 * max(0, hi - 24) * (1 - 0.25 * p[\"acclim\"]) * (1 + 0.5 * m / N_MIN) * (1 + 0.012 * (p[\"bmi\"] - 23.5))\n", + " cold = -0.075 * max(0, 8 - wc) * (1 - 0.09 * act[m])\n", + " ss = 36.9 + 0.09 * act[m] + heat + cold\n", + " core += (ss - core) / 18 + rng.normal(0, 0.03)\n", + " cores.append(core)\n", + " core = np.array(cores)\n", + " hr = p[\"rest_hr\"] + 9.5 * (act - 1.5) * (1.15 - 0.3 * p[\"fit\"]) + 11 * (core - 36.9) + rng.normal(0, 3.2, N_MIN)\n", + " eda = 2.0 + 0.25 * act + 3.2 * np.maximum(core - 37.2, 0) - 0.04 * np.maximum(8 - wc, 0) + rng.normal(0, 0.25, N_MIN)\n", + " bias = rng.normal(0, 0.1)\n", + " temp = core + bias + rng.normal(0, 0.1, N_MIN) - np.where((kind == \"cold\") & (env[\"wind\"] > 7), 0.25, 0.0) # 센서 접촉 부위 바람 냉각 오차\n", + " df = pd.DataFrame({\"min\": np.arange(N_MIN), \"hr\": hr, \"temp\": temp, \"eda\": np.maximum(eda, 0.2), \"act\": act})\n", + " if glitch:\n", + " for col in (\"hr\", \"temp\", \"eda\"):\n", + " df.loc[rng.random(N_MIN) < 0.03, col] = np.nan # 결측\n", + " s = int(rng.integers(5, N_MIN))\n", + " if rng.random() < 0.12:\n", + " df.loc[s, \"hr\"] = 230.0 # 센서 튐\n", + " if rng.random() < 0.12:\n", + " df.loc[s, \"temp\"] = 45.0\n", + " ht = np.where(core >= 39.0, 2, np.where(core >= 38.3, 1, 0)); cd = np.where(core <= 35.4, 2, np.where(core <= 36.2, 1, 0))\n", + " df[\"core\"] = core\n", + " df[\"stage\"] = np.maximum(ht, cd); df[\"rtype\"] = np.where(ht > 0, \"heat\", np.where(cd > 0, \"cold\", \"normal\"))\n", + " return {\"id\": p[\"id\"], \"person\": p, \"env\": env, \"kind\": kind, \"df\": df}\n", + "\n", + "\n", + "def make_dataset(n, seed):\n", + " rng = np.random.default_rng(seed)\n", + " kinds = [\"normal\", \"heat\", \"cold\"]\n", + " return [make_session(i, kinds[i % 3], rng) for i in range(n)]\n", + "# ---------------------------------------------------------------------------\n", + "\n", + "# ---------------- 전처리·특징 추출 공통 함수 ----------------\n", + "LIMITS = {\"hr\": (30, 220), \"temp\": (30.0, 43.0), \"eda\": (0.0, 40.0)} # 생리적으로 가능한 범위(범위 밖 = 센서 오류)\n", + "\n", + "\n", + "def clean(df):\n", + " \"\"\"범위 밖 값은 결측 처리 -> 선형 보간(양 끝은 가까운 값) -> 3분 이동 중앙값으로 튐 제거. 반환: (정제 df, 보정 건수)\"\"\"\n", + " out = df.copy(); fixed = 0\n", + " for c, (lo, hi) in LIMITS.items():\n", + " bad = out[c].isna() | (out[c] < lo) | (out[c] > hi)\n", + " fixed += int(bad.sum())\n", + " out.loc[bad, c] = np.nan\n", + " out[c] = out[c].interpolate(limit_direction=\"both\").rolling(3, center=True, min_periods=1).median()\n", + " return out, fixed\n", + "\n", + "\n", + "def env_index(env):\n", + " hi = env[\"temp_c\"] + (0.12 * (env[\"rh\"] - 40) if env[\"temp_c\"] > 25 else 0.0) # 체감 더위 지수(가상 단순식)\n", + " wc = env[\"temp_c\"] - 1.6 * env[\"wind\"] # 체감 추위 지수(가상 단순식)\n", + " return hi, wc\n", + "\n", + "\n", + "def features(s, use_env=True, personal=True):\n", + " \"\"\"세션 1개 -> 분 단위 특징표(최근 5분 평균·기울기 포함). 정답 stage/rtype 포함.\"\"\"\n", + " df, _ = clean(s[\"df\"]); p, e = s[\"person\"], s[\"env\"]\n", + " f = pd.DataFrame({\"sid\": s[\"id\"], \"min\": df[\"min\"]})\n", + " f[\"temp5\"] = df[\"temp\"].rolling(5, min_periods=1).mean()\n", + " f[\"hr5\"] = df[\"hr\"].rolling(5, min_periods=1).mean()\n", + " f[\"eda5\"] = df[\"eda\"].rolling(5, min_periods=1).mean()\n", + " f[\"dtemp\"] = (f[\"temp5\"] - f[\"temp5\"].shift(4)).fillna(0)\n", + " f[\"act\"] = df[\"act\"]\n", + " if personal:\n", + " f[\"hr_rel\"] = f[\"hr5\"] - p[\"rest_hr\"]\n", + " f[\"acclim\"], f[\"bmi\"], f[\"fit\"] = p[\"acclim\"], p[\"bmi\"], p[\"fit\"]\n", + " if use_env:\n", + " f[\"hi\"], f[\"wc\"] = env_index(e); f[\"wind\"] = e[\"wind\"]\n", + " f[\"stage\"], f[\"rtype\"], f[\"kind\"] = df[\"stage\"], df[\"rtype\"], s[\"kind\"]\n", + " return f[f[\"min\"] >= 5].reset_index(drop=True)\n", + "\n", + "\n", + "def table(sessions, **kw):\n", + " return pd.concat([features(s, **kw) for s in sessions], ignore_index=True)\n", + "# ---------------------------------------------------------------------------\n", + "\n", + "CONFIG = {\"alert_threshold\": 0.4, \"min_persist\": 3, \"heat_temp\": 38.2, \"cold_temp\": 36.2} # 미션 9 재보정값 + 모델 임계값(지속 조건이 오경보를 막으므로 미션 5의 0.5보다 낮춰 미탐을 줄임)\n", + "FE = [\"temp5\", \"dtemp\", \"hr5\", \"eda5\", \"hr_rel\", \"acclim\", \"bmi\", \"fit\", \"hi\", \"wc\", \"wind\"]\n", + "\n", + "\n", + "def validate(s):\n", + " errs = []\n", + " if s[\"kind\"] not in TYPES:\n", + " errs.append(\"kind 허용값 아님\")\n", + " if len(s[\"df\"]) != N_MIN or not {\"hr\", \"temp\", \"eda\"} <= set(s[\"df\"].columns):\n", + " errs.append(\"신호 길이·필드 오류\")\n", + " if not str(s[\"id\"]).startswith(\"S\"):\n", + " errs.append(\"soldier_id 형식 오류\")\n", + " return errs\n", + "\n", + "\n", + "def run_session(s, model):\n", + " \"\"\"세션 1개: 정제 -> 특징 -> 점수 -> 지속 조건 경보. 신호가 5분 이상 끊기면 '신호두절' 플래그.\"\"\"\n", + " f = features(s)\n", + " raw = s[\"df\"].set_index(\"min\")\n", + " f[\"lost\"] = [bool(raw.loc[max(m - 4, 0):m, [\"hr\", \"temp\"]].isna().all().all()) for m in f[\"min\"]]\n", + " f[\"score\"] = model.predict_proba(f[FE])[:, 1]\n", + " hot = (f[\"temp5\"] >= CONFIG[\"heat_temp\"]) | (f[\"temp5\"] <= CONFIG[\"cold_temp\"])\n", + " cond = (f[\"score\"] >= CONFIG[\"alert_threshold\"]) & (hot | (f[\"score\"] >= 0.8))\n", + " f[\"alert\"] = cond.rolling(CONFIG[\"min_persist\"], min_periods=CONFIG[\"min_persist\"]).min().fillna(0).astype(bool) & ~f[\"lost\"]\n", + " f[\"grade\"] = np.where(f[\"score\"] >= 0.8, \"High\", np.where(f[\"score\"] >= 0.5, \"Medium\", \"Low\"))\n", + " return f\n", + "\n", + "\n", + "class Queue:\n", + " def __init__(self):\n", + " self.items, self.log = [], []\n", + "\n", + " def submit(self, sid, minute, score, grade):\n", + " prev = self.log[-1][\"hash\"] if self.log else \"GENESIS\"\n", + " body = {\"event\": \"SUBMIT\", \"id\": f\"{sid}@{minute}\", \"score\": round(score, 3), \"by\": \"AI\"}\n", + " self.log.append({**body, \"prev\": prev, \"hash\": hashlib.sha256((prev + json.dumps(body, sort_keys=True)).encode()).hexdigest()})\n", + " self.items.append({\"id\": f\"{sid}@{minute}\", \"grade\": grade, \"status\": \"PENDING\"})\n", + "\n", + " def verify(self):\n", + " prev = \"GENESIS\"\n", + " for rec in self.log:\n", + " body = {k: v for k, v in rec.items() if k not in (\"prev\", \"hash\")}\n", + " if rec[\"prev\"] != prev or rec[\"hash\"] != hashlib.sha256((prev + json.dumps(body, sort_keys=True)).encode()).hexdigest():\n", + " return False\n", + " prev = rec[\"hash\"]\n", + " return True\n", + "\n", + "\n", + "def episodes(f):\n", + " \"\"\"연속 경보 분을 하나의 사건으로 묶어 첫 분만 검토 대기열에 올린다(같은 사건 중복 통보 방지).\"\"\"\n", + " a = f[\"alert\"].values\n", + " return [i for i in range(len(a)) if a[i] and (i == 0 or not a[i - 1])]\n", + "\n", + "\n", + "t_start = time.perf_counter()\n", + "# ---------------- 1. 학습(점수 엔진 준비) ----------------\n", + "tr = table(make_dataset(150, seed=51))\n", + "model = make_pipeline(StandardScaler(), LogisticRegression(class_weight=\"balanced\", max_iter=3000, random_state=0)).fit(tr[FE], tr[\"stage\"] >= 1)\n", + "\n", + "# ---------------- 2. 운용 데이터 처리(손상 1건, 기기 두절 1건 주입) ----------------\n", + "rng = np.random.default_rng(61)\n", + "ops = [make_session(i, TYPES[i % 3], rng) for i in range(60)]\n", + "ops[4] = {**ops[4], \"kind\": \"tundra\"} # 허용되지 않은 유형\n", + "dead = ops[8][\"df\"].copy(); dead.loc[35:, [\"hr\", \"temp\", \"eda\"]] = np.nan; ops[8] = {**ops[8], \"df\": dead} # 기기 두절\n", + "queue, rejected, runs, results = Queue(), {}, [], []\n", + "codes = {f.__code__ for f in (validate, run_session, episodes, Queue.submit, Queue.verify, clean, env_index, features, table)}\n", + "lines_total = {c: {ln for _, ln in dis.findlinestarts(c) if ln != c.co_firstlineno} for c in codes}\n", + "trace_lines = set()\n", + "\n", + "\n", + "def tracer(frame, event, arg):\n", + " if frame.f_code in codes:\n", + " def local(fr, ev, ar):\n", + " if ev == \"line\":\n", + " trace_lines.add((fr.f_code, fr.f_lineno))\n", + " return local\n", + " return local\n", + "\n", + "\n", + "sys.settrace(tracer)\n", + "for s in ops:\n", + " errs = validate(s)\n", + " if errs:\n", + " rejected[s[\"id\"]] = errs; continue\n", + " t0 = time.perf_counter()\n", + " f = run_session(s, model)\n", + " for i in episodes(f):\n", + " queue.submit(s[\"id\"], int(f[\"min\"].iloc[i]), float(f[\"score\"].iloc[i]), f[\"grade\"].iloc[i])\n", + " runs.append({\"sid\": s[\"id\"], \"ms_per_min\": (time.perf_counter() - t0) * 1000 / len(f), \"events\": len(episodes(f)), \"lost\": int(f[\"lost\"].any())})\n", + " results.append(f)\n", + "queue.verify()\n", + "sys.settrace(None)\n", + "covered = sum(len({ln for c2, ln in trace_lines if c2 is c} & lines_total[c]) for c in codes)\n", + "cov = covered / sum(len(v) for v in lines_total.values())\n", + "\n", + "# ---------------- 3. 성능 평가 ----------------\n", + "allf = pd.concat(results, ignore_index=True)\n", + "truth = allf[\"stage\"] >= 1\n", + "far = (allf[\"alert\"] & ~truth).sum() / max(allf[\"alert\"].sum(), 1)\n", + "rec = (allf[\"alert\"] & truth).sum() / truth.sum()\n", + "risk_sids = set(allf.loc[truth, \"sid\"]); alerted = set(allf.loc[allf[\"alert\"] & truth, \"sid\"])\n", + "ms = np.mean([r[\"ms_per_min\"] for r in runs])\n", + "\n", + "# ---------------- 4. 자체 점검(통합 테스트) ----------------\n", + "chk = {\n", + " \"손상 데이터 차단\": \"S004\" in rejected,\n", + " \"처리 세션 59개 전량 성공\": len(runs) == 59,\n", + " \"신호두절 병사 식별\": any(r[\"lost\"] for r in runs) and bool(allf.loc[allf[\"sid\"] == \"S008\", \"lost\"].any()),\n", + " \"위험 구간 재현율 85% 이상\": rec >= 0.85,\n", + " \"오경보율 15% 이하\": far <= 0.15,\n", + " \"위험 병사 사건 식별 90% 이상\": len(alerted) / max(len(risk_sids), 1) >= 0.9,\n", + " \"자동 조치 0건\": all(i[\"status\"] == \"PENDING\" for i in queue.items),\n", + " \"감사 로그 건수 일치·무결\": len(queue.log) == len(queue.items) and queue.verify(),\n", + " \"분당 처리 50ms 이내\": ms < 50,\n", + " \"실행 라인 커버리지 85% 이상\": cov >= 0.85,\n", + "}\n", + "passed = sum(chk.values())\n", + "for k, v in chk.items():\n", + " print(f\"[{'PASS' if v else 'FAIL'}] {k}\")\n", + "print(f\"자체 점검 {passed}/{len(chk)} 통과 ({passed / len(chk):.0%}), 파이프라인 실행 성공 {'예' if len(runs) + len(rejected) == len(ops) else '아니오'}, 핵심 모듈 실행 라인 커버리지 {cov:.1%}\")\n", + "\n", + "# ---------------- 5. 소대 상황판 ----------------\n", + "colr = {\"High\": \"tab:red\", \"Medium\": \"orange\", \"Low\": \"tab:gray\"}\n", + "fig, ax = plt.subplots(2, 2, figsize=(12, 8))\n", + "top = sorted(results, key=lambda f: -f[\"score\"].max())[:2]\n", + "for a, f in zip(ax[0], top):\n", + " a.plot(f[\"min\"], f[\"temp5\"], label=\"temp (5-min mean)\"); a.scatter(f[\"min\"][f[\"alert\"]], f[\"temp5\"][f[\"alert\"]], color=\"red\", s=12, label=\"alert\")\n", + " a.set_title(f\"{f['sid'].iloc[0]} temperature and alerts\"); a.set_xlabel(\"minute\"); a.legend()\n", + "g = pd.Series([i[\"grade\"] for i in queue.items]).value_counts().reindex([\"High\", \"Medium\", \"Low\"]).fillna(0)\n", + "ax[1, 0].bar(g.index, g.values, color=[colr[i] for i in g.index]); ax[1, 0].set_title(\"Review queue events by grade (pending human review)\")\n", + "ax[1, 1].axis(\"off\")\n", + "ax[1, 1].text(0, 0.9, f\"Risk recall {rec:.1%}\\nFalse alarm {far:.1%}\\nQueue {len(queue.items)} pending\\nLatency {ms:.2f} ms/min\\nSelf-check {passed}/{len(chk)}\", family=\"monospace\", fontsize=13, va=\"top\")\n", + "plt.suptitle(\"WEL01 integrated pipeline - dummy data, not diagnosis\"); plt.tight_layout(); plt.show()\n", + "\n", + "# ---------------- 6. 브리핑 요약 ----------------\n", + "print(f\"\"\"\n", + "[열·한랭 스트레스 경보 브리핑 요약 - 교육용가상자료, 진단 아님]\n", + "1. 처리: 가상 병사 {len(ops)}명 중 {len(runs)}명 처리, 손상 데이터 {len(rejected)}건 차단({', '.join(rejected)}), 신호두절 {sum(r['lost'] for r in runs)}명 확인 필요 표시\n", + "2. 성능: 위험 구간 재현율 {rec:.1%}, 오경보율 {far:.1%}, 분당 {ms:.2f} ms, 전체 소요 {time.perf_counter() - t_start:.1f} s\n", + "3. 경보: {len(queue.items)}건 군의관·소대장 검토 대기 (High {sum(i['grade'] == 'High' for i in queue.items)} / Medium {sum(i['grade'] == 'Medium' for i in queue.items)} / Low {sum(i['grade'] == 'Low' for i in queue.items)}), 자동 조치 0건\n", + "4. 운용 유의: 휴식 권고·임무 배제·의무대 인계는 군의관·소대장·지휘관의 승인 후에만 이루어지며, 의학적 판단은 의무대·군의관에게 연계한다.\n", + "5. 한계: 가상 생리 모델로 만든 더미 신호 기준 결과로, 진단 도구가 아니며 실제 장비·인원 자료로 재검증이 필요하다.\"\"\")\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 10 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + "[PASS] 손상 데이터 차단\n", + "[PASS] 처리 세션 59개 전량 성공\n", + "[PASS] 신호두절 병사 식별\n", + "[PASS] 위험 구간 재현율 85% 이상\n", + "[PASS] 오경보율 15% 이하\n", + "[PASS] 위험 병사 사건 식별 90% 이상\n", + "[PASS] 자동 조치 0건\n", + "[PASS] 감사 로그 건수 일치·무결\n", + "[PASS] 분당 처리 50ms 이내\n", + "[PASS] 실행 라인 커버리지 85% 이상\n", + "자체 점검 10/10 통과 (100%), 파이프라인 실행 성공 예, 핵심 모듈 실행 라인 커버리지 92.7%\n", + "\n", + "[열·한랭 스트레스 경보 브리핑 요약 - 교육용가상자료, 진단 아님]\n", + "1. 처리: 가상 병사 60명 중 59명 처리, 손상 데이터 1건 차단(S004), 신호두절 1명 확인 필요 표시\n", + "2. 성능: 위험 구간 재현율 85.6%, 오경보율 8.3%, 분당 2.24 ms, 전체 소요 10.2 s\n", + "3. 경보: 40건 군의관·소대장 검토 대기 (High 24 / Medium 15 / Low 1), 자동 조치 0건\n", + "4. 운용 유의: 휴식 권고·임무 배제·의무대 인계는 군의관·소대장·지휘관의 승인 후에만 이루어지며, 의학적 판단은 의무대·군의관에게 연계한다.\n", + "5. 한계: 가상 생리 모델로 만든 더미 신호 기준 결과로, 진단 도구가 아니며 실제 장비·인원 자료로 재검증이 필요하다.\n", + "```\n", + "
\n" + ] + } + ], + "metadata": { + "colab": { + "provenance": [] + }, + "kernelspec": { + "display_name": "Python 3", + "name": "python3" + }, + "language_info": { + "name": "python" + } + }, + "nbformat": 4, + "nbformat_minor": 0 +} \ No newline at end of file diff --git a/uploads/WEL01/WEL01_student.ipynb b/uploads/WEL01/WEL01_student.ipynb new file mode 100644 index 0000000..a7b8af0 --- /dev/null +++ b/uploads/WEL01/WEL01_student.ipynb @@ -0,0 +1,2791 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# 🎯 [WEL01] 더미 생체신호 기반 열·한랭 스트레스 경보 프로토타입\n", + "### Google Colab 실습 환경 (장병 복지·지원 / Level 2)\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/WEL01/WEL01_solution.ipynb)\n", + "\n", + "---\n", + "\n", + "## 📌 1. 프로젝트 개요 및 배경\n", + "\n", + "| 항목 | 내용 |\n", + "| :--- | :--- |\n", + "| **과정 분류** | 장병 복지·지원 (Level 2) |\n", + "| **학습 소요 시간** | 10개 미션 (5일 / 총 27.5시간) |\n", + "| **환경 사양** | Python 3.10+, numpy, pandas, matplotlib, scipy, scikit-learn |\n", + "\n", + "### 📖 프로젝트 목표\n", + "러시아-우크라이나 전쟁에서는 혹한과 폭염 속에서 장시간 기동하는 전투원의 열·한랭 손상이 작전 지속력을 좌우하는 문제로 드러났고, 웨어러블 센서 신호로 위험 징후를 일찍 알아차리되 오경보와 센서 오류를 가려내는 일이 중요해졌다. 본 프로젝트에서는 교육용 가상 병사의 더미 생체신호(심박수, 체온, 피부전도도)와 기상을 코드로 직접 만들어, 데이터 스키마 정의, 결측·센서 튐 전처리, 체온-심박 상관 기반 Baseline, 기상·개인 특성을 결합한 복합 위험도 모델, 개인화 신뢰도 엔진, 소대장·의무관용 상황판, 군의관 원격 검토 중심의 HITL 절차, 폭염·혹한 대량 데이터 유입 시험, 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. 전투원 착용 웨어러블 더미 생체신호(심박수, 체온, 피부전도도) 데이터 규격 정의\n", + "**소요시간**: 약 1.5시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "전투원 웨어러블 더미 생체신호(심박수, 체온, 피부전도도) 데이터셋을 정의한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **웨어러블 더미 생체신호(심박수, 체온, 피부전도도)와 가상 병사 특성·기상·기기 상태의 데이터 규격을 정의하고, 잘못된 데이터를 걸러내는 검증기를 만든다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **신호 3종의 허용 범위, 샘플링 간격, 기기 종류와 접촉 품질, 병사 특성(안정시 심박, 연령, 체격, 더위 순응 여부), 기상(기온, 습도, 풍속) 필드를 담은 스키마 설계하고 작성하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `data/schema/wel01_input_schema.json 및 docs/wel01_data_spec.md` 형태의 결과물을 완성한다.\n", + "\n", + "### 🔍 평가 및 검증 기준 (PASS 조건)\n", + "#### 미션 판정\n", + "\n", + "**평가 구분:** 필수\n", + "\n", + "**핵심 평가영역:** 장병 복지·지원 실무 및 러시아-우크라이나 전쟁 전장 환경과의 군사적 부합성\n", + "\n", + "#### 미션 PASS 조건\n", + "\n", + "- 정량 기준: 스키마 필드 정의 완성도 100%, 샘플 데이터 검증 오류 0건\n", + "\n", + "\n", + "- 진행 기준: 입력 데이터 유효성 검증 스크립트 통과 및 명세서 문서화 완료\n", + "\n", + "\n", + "- 검증 방법: JSON Schema 검증 도구 및 샘플 데이터 파싱 단위 테스트\n", + "\n", + "\n", + "#### 채점자 판정 메모\n", + "\n", + "> 단순 코드 실행 여부뿐만 아니라 출력된 수치와 근거 자료가 군 실무 요구사항에 부합하는지 면밀히 확인한다.\n", + "\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "\"\"\"전투원 웨어러블 더미 생체신호(심박수, 체온, 피부전도도)와 가상 병사 특성·기상·기기 상태의 데이터 스키마를 정의하고, 가상 샘플 20건을 검증한다. 진단 목적이 아닌 교육용 가상 자료이다.\"\"\"\n", + "import json\n", + "import re\n", + "import numpy as np\n", + "import pandas as pd\n", + "\n", + "# ---------------- [교육용가상자료] 더미 생체신호 생성기 (실제 장병·부대·의무 기록 없음) ----------------\n", + "# 가상 병사(가명 ID)가 60분간 걷기·휴식·고강도 활동을 하는 동안의 심박수·체온(추정 심부)·피부전도도를 1분 간격으로 만든다.\n", + "# 진단 도구가 아니며, 상태 라벨은 가상 생리 모델의 '교육용 단계'일 뿐이다.\n", + "N_MIN = 60\n", + "STAGE = {0: \"정상\", 1: \"주의\", 2: \"경보\"}\n", + "TYPES = [\"normal\", \"heat\", \"cold\"]\n", + "\n", + "\n", + "def make_person(i, rng):\n", + " return {\"id\": f\"S{i:03d}\", \"rest_hr\": float(rng.normal(64, 7)), \"age\": int(rng.integers(19, 27)), \"bmi\": float(rng.normal(23.5, 2.5)),\n", + " \"acclim\": int(rng.random() < 0.4), \"fit\": float(np.clip(rng.normal(0.5, 0.2), 0.05, 0.95))}\n", + "\n", + "\n", + "def make_env(kind, rng):\n", + " if kind == \"heat\":\n", + " return {\"temp_c\": float(rng.uniform(30, 40)), \"rh\": float(rng.uniform(45, 85)), \"wind\": float(rng.uniform(0.3, 3))}\n", + " if kind == \"cold\":\n", + " return {\"temp_c\": float(rng.uniform(-22, 2)), \"rh\": float(rng.uniform(40, 80)), \"wind\": float(rng.uniform(2, 11))}\n", + " return {\"temp_c\": float(rng.uniform(8, 24)), \"rh\": float(rng.uniform(35, 70)), \"wind\": float(rng.uniform(0.3, 5))}\n", + "\n", + "\n", + "def make_session(sid, kind, rng, p=None, glitch=True):\n", + " \"\"\"반환: dict(person, env, kind, df[hr,temp,eda,act + 정답 core·stage·rtype]). 센서 결측·튐·고착 포함.\"\"\"\n", + " p = p or make_person(sid, rng)\n", + " env = make_env(kind, rng)\n", + " act = np.zeros(N_MIN); t = 0\n", + " while t < N_MIN: # 10~20분 단위 활동 블록: 휴식 1.5 / 행군 4.5 / 고강도 7 MET\n", + " d = int(rng.integers(10, 21)); act[t:t + d] = rng.choice([1.5, 4.5, 7.0], p=[0.25, 0.5, 0.25]); t += d\n", + " hi = env[\"temp_c\"] + (0.12 * (env[\"rh\"] - 40) if env[\"temp_c\"] > 25 else 0)\n", + " wc = env[\"temp_c\"] - 1.6 * env[\"wind\"]\n", + " core, cores = 36.9, []\n", + " for m in range(N_MIN):\n", + " heat = 0.085 * max(0, hi - 24) * (1 - 0.25 * p[\"acclim\"]) * (1 + 0.5 * m / N_MIN) * (1 + 0.012 * (p[\"bmi\"] - 23.5))\n", + " cold = -0.075 * max(0, 8 - wc) * (1 - 0.09 * act[m])\n", + " ss = 36.9 + 0.09 * act[m] + heat + cold\n", + " core += (ss - core) / 18 + rng.normal(0, 0.03)\n", + " cores.append(core)\n", + " core = np.array(cores)\n", + " hr = p[\"rest_hr\"] + 9.5 * (act - 1.5) * (1.15 - 0.3 * p[\"fit\"]) + 11 * (core - 36.9) + rng.normal(0, 3.2, N_MIN)\n", + " eda = 2.0 + 0.25 * act + 3.2 * np.maximum(core - 37.2, 0) - 0.04 * np.maximum(8 - wc, 0) + rng.normal(0, 0.25, N_MIN)\n", + " bias = rng.normal(0, 0.1)\n", + " temp = core + bias + rng.normal(0, 0.1, N_MIN) - np.where((kind == \"cold\") & (env[\"wind\"] > 7), 0.25, 0.0) # 센서 접촉 부위 바람 냉각 오차\n", + " df = pd.DataFrame({\"min\": np.arange(N_MIN), \"hr\": hr, \"temp\": temp, \"eda\": np.maximum(eda, 0.2), \"act\": act})\n", + " if glitch:\n", + " for col in (\"hr\", \"temp\", \"eda\"):\n", + " df.loc[rng.random(N_MIN) < 0.03, col] = np.nan # 결측\n", + " s = int(rng.integers(5, N_MIN))\n", + " if rng.random() < 0.12:\n", + " df.loc[s, \"hr\"] = 230.0 # 센서 튐\n", + " if rng.random() < 0.12:\n", + " df.loc[s, \"temp\"] = 45.0\n", + " ht = np.where(core >= 39.0, 2, np.where(core >= 38.3, 1, 0)); cd = np.where(core <= 35.4, 2, np.where(core <= 36.2, 1, 0))\n", + " df[\"core\"] = core\n", + " df[\"stage\"] = np.maximum(ht, cd); df[\"rtype\"] = np.where(ht > 0, \"heat\", np.where(cd > 0, \"cold\", \"normal\"))\n", + " return {\"id\": p[\"id\"], \"person\": p, \"env\": env, \"kind\": kind, \"df\": df}\n", + "\n", + "\n", + "def make_dataset(n, seed):\n", + " rng = np.random.default_rng(seed)\n", + " kinds = [\"normal\", \"heat\", \"cold\"]\n", + " return [make_session(i, kinds[i % 3], rng) for i in range(n)]\n", + "# ---------------------------------------------------------------------------\n", + "\n", + "# 1) 스키마 정의: 신호 규격, 가상 병사 특성, 기상(온·습도·풍속), 기기 상태\n", + "SCHEMA = {\n", + " \"required\": [\"record_id\", \"soldier_id\", \"n_samples\", \"sampling_s\", \"device\", \"person\", \"weather\", \"samples\"],\n", + " \"fields\": {\n", + " \"record_id\": {\"type\": str, \"pattern\": r\"^REC-\\d{4}$\"},\n", + " \"soldier_id\": {\"type\": str, \"pattern\": r\"^S\\d{3}$\"},\n", + " \"n_samples\": {\"type\": int, \"min\": 1, \"max\": 100000},\n", + " \"sampling_s\": {\"type\": (int, float), \"min\": 1, \"max\": 600},\n", + " },\n", + " \"device\": {\n", + " \"type\": {\"type\": str, \"enum\": [\"wrist\", \"chest\"]},\n", + " \"battery_pct\": {\"type\": (int, float), \"min\": 0, \"max\": 100},\n", + " \"contact_quality\": {\"type\": (int, float), \"min\": 0, \"max\": 1},\n", + " },\n", + " \"person\": {\n", + " \"rest_hr\": {\"type\": (int, float), \"min\": 40, \"max\": 100},\n", + " \"age\": {\"type\": int, \"min\": 18, \"max\": 45},\n", + " \"bmi\": {\"type\": (int, float), \"min\": 15, \"max\": 40},\n", + " \"acclim\": {\"type\": int, \"min\": 0, \"max\": 1},\n", + " },\n", + " \"weather\": {\n", + " \"temp_c\": {\"type\": (int, float), \"min\": -40, \"max\": 55},\n", + " \"rh\": {\"type\": (int, float), \"min\": 0, \"max\": 100},\n", + " \"wind\": {\"type\": (int, float), \"min\": 0, \"max\": 30},\n", + " },\n", + " \"sample\": {\n", + " \"hr\": {\"type\": (int, float), \"min\": 30, \"max\": 220},\n", + " \"temp\": {\"type\": (int, float), \"min\": 30, \"max\": 43},\n", + " \"eda\": {\"type\": (int, float), \"min\": 0, \"max\": 40},\n", + " },\n", + "}\n", + "\n", + "\n", + "def check(name, value, rule):\n", + " \"\"\"값 하나를 규칙(type, enum, min, max, pattern)으로 검사해 오류 목록을 돌려준다.\"\"\"\n", + " # [TODO : 학생 실습 1] 전투원 착용 웨어러블 더미 생체신호(심박수, 체온, 피부전도도) 데이터 규격 정의 관련 핵심 로직을 구현하세요.\n", + " # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n", + " # <<< 여기에 코드를 작성하세요 >>>\n", + " pass # 작성 후 pass를 제거하세요.\n", + "def validate(rec):\n", + " # [TODO : 학생 실습 1] 전투원 착용 웨어러블 더미 생체신호(심박수, 체온, 피부전도도) 데이터 규격 정의 관련 핵심 로직을 구현하세요.\n", + " # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n", + " # <<< 여기에 코드를 작성하세요 >>>\n", + " pass # 작성 후 pass를 제거하세요.\n", + "rng = np.random.default_rng(1)\n", + "records = []\n", + "for i in range(20):\n", + " s = make_session(i, TYPES[i % 3], rng, glitch=False)\n", + " smp = [{\"hr\": round(float(r.hr), 1), \"temp\": round(float(r.temp), 2), \"eda\": round(float(r.eda), 2)} for r in s[\"df\"].itertuples()]\n", + " p, e = s[\"person\"], s[\"env\"]\n", + " records.append({\"record_id\": f\"REC-{i:04d}\", \"soldier_id\": s[\"id\"], \"n_samples\": len(smp), \"sampling_s\": 60,\n", + " \"device\": {\"type\": str(rng.choice([\"wrist\", \"chest\"])), \"battery_pct\": int(rng.integers(30, 100)), \"contact_quality\": round(float(rng.uniform(0.7, 1)), 2)},\n", + " \"person\": {\"rest_hr\": round(p[\"rest_hr\"], 1), \"age\": p[\"age\"], \"bmi\": round(p[\"bmi\"], 1), \"acclim\": p[\"acclim\"]},\n", + " \"weather\": {k: round(v, 1) for k, v in e.items()}, \"samples\": smp})\n", + "\n", + "# 3) 정상 데이터 검증\n", + "ok = sum(1 for r in records if not validate(r))\n", + "print(f\"정상 샘플 {len(records)}건 중 검증 통과 {ok}건, 오류 {len(records) - ok}건 -> 목표(검증 오류 0건): {'달성' if ok == len(records) else '미달'}\")\n", + "\n", + "# 4) 오류 주입 시험\n", + "bad = json.loads(json.dumps(records[0]))\n", + "bad[\"soldier_id\"] = \"SOLDIER-1\" # 형식\n", + "bad[\"samples\"][0][\"temp\"] = 45.0 # 범위(센서 튐)\n", + "del bad[\"weather\"][\"wind\"] # 누락(풍속)\n", + "bad[\"device\"][\"type\"] = \"ankle\" # 허용값\n", + "bad[\"n_samples\"] = 99 # 논리(개수 불일치)\n", + "errs = validate(bad)\n", + "print(f\"\\n[오류 주입 결과] 주입 5종 -> 검출 {len(errs)}건\")\n", + "for e in errs:\n", + " print(\" -\", e)\n", + "print(f\"주입 오류 전부 검출: {'예' if len(errs) >= 5 else '아니오'}\")\n", + "\n", + "# 5) 스키마 필드 정의서(명세서 초안)\n", + "rows = [(\"기본\", k, r[\"type\"].__name__ if isinstance(r[\"type\"], type) else \"number\", str({x: y for x, y in r.items() if x != \"type\"})) for k, r in SCHEMA[\"fields\"].items()]\n", + "for g in (\"device\", \"person\", \"weather\", \"sample\"):\n", + " for k, r in SCHEMA[g].items():\n", + " t = r[\"type\"].__name__ if isinstance(r[\"type\"], type) else \"number\"\n", + " rows.append((g, k, t, str({x: y for x, y in r.items() if x != \"type\"})))\n", + "print(\"\\n[데이터 명세 요약] 필드 수\", len(rows))\n", + "print(pd.DataFrame(rows, columns=[\"그룹\", \"필드\", \"자료형\", \"규칙\"]).to_string(index=False))\n", + "print(\"\\n상태 단계(교육용 가상 라벨):\", STAGE, \"/ 위험 유형:\", TYPES)\n", + "print(\"본 데이터는 코드로 만든 더미 생체신호이며 진단·처방 자료가 아니다. 실제 건강 이상은 의무대·군의관에게 연계한다.\")\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 01 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + "정상 샘플 20건 중 검증 통과 20건, 오류 0건 -> 목표(검증 오류 0건): 달성\n", + "\n", + "[오류 주입 결과] 주입 5종 -> 검출 5건\n", + " - soldier_id: 형식 불일치 'SOLDIER-1'\n", + " - device.type: 허용되지 않은 값 'ankle'\n", + " - weather.wind: 필수 필드 누락\n", + " - samples[0].temp: 최대값 43 초과\n", + " - n_samples: 샘플 개수와 불일치\n", + "주입 오류 전부 검출: 예\n", + "\n", + "[데이터 명세 요약] 필드 수 17\n", + " 그룹 필드 자료형 규칙\n", + " 기본 record_id str {'pattern': '^REC-\\\\d{4}$'}\n", + " 기본 soldier_id str {'pattern': '^S\\\\d{3}$'}\n", + " 기본 n_samples int {'min': 1, 'max': 100000}\n", + " 기본 sampling_s number {'min': 1, 'max': 600}\n", + " device type str {'enum': ['wrist', 'chest']}\n", + " device battery_pct number {'min': 0, 'max': 100}\n", + " device contact_quality number {'min': 0, 'max': 1}\n", + " person rest_hr number {'min': 40, 'max': 100}\n", + " person age int {'min': 18, 'max': 45}\n", + " person bmi number {'min': 15, 'max': 40}\n", + " person acclim int {'min': 0, 'max': 1}\n", + "weather temp_c number {'min': -40, 'max': 55}\n", + "weather rh number {'min': 0, 'max': 100}\n", + "weather wind number {'min': 0, 'max': 30}\n", + " sample hr number {'min': 30, 'max': 220}\n", + " sample temp number {'min': 30, 'max': 43}\n", + " sample eda number {'min': 0, 'max': 40}\n", + "\n", + "상태 단계(교육용 가상 라벨): {0: '정상', 1: '주의', 2: '경보'} / 위험 유형: ['normal', 'heat', 'cold']\n", + "본 데이터는 코드로 만든 더미 생체신호이며 진단·처방 자료가 아니다. 실제 건강 이상은 의무대·군의관에게 연계한다.\n", + "```\n", + "
\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 02. 야전 활동 중 발생하는 생체신호 결측치 보정 및 센서 노이즈 필터링 전처리 구현\n", + "**소요시간**: 약 2.0시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "야전 활동 중 생체신호 결측치 보정 및 센서 노이즈 필터링 전처리 모듈을 구현한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **야전 활동 중 발생하는 결측, 센서 튐, 센서 고착(단절)을 탐지·보정하는 전처리 모듈을 구현한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **생리적으로 불가능한 범위 밖 값을 결측으로 바꾸고 선형 보간과 3분 이동 중앙값으로 정리하며, 같은 값이 8분 이상 이어지는 센서 고착을 찾아 결측 처리 수행하고 검증하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `src/preprocess/wel01_preprocessor.py 및 전처리 전후 데이터셋` 형태의 결과물을 완성한다.\n", + "\n", + "### 🔍 평가 및 검증 기준 (PASS 조건)\n", + "#### 미션 판정\n", + "\n", + "**평가 구분:** 필수\n", + "\n", + "**핵심 평가영역:** 전술 환경 특유의 노이즈(스푸핑, 반사파, 센서 단절 등) 제거 타당성\n", + "\n", + "#### 미션 PASS 조건\n", + "\n", + "- 정량 기준: 데이터 정제 처리 속도 1,000건/초 이상, 전처리 오류율 0%\n", + "\n", + "\n", + "- 진행 기준: 정제 파이프라인 자동화 및 단위 테스트 케이스 5종 통과\n", + "\n", + "\n", + "- 검증 방법: pytest 기반 전처리 정합성 테스트 및 결측치 보정율 확인\n", + "\n", + "\n", + "#### 채점자 판정 메모\n", + "\n", + "> 단순 코드 실행 여부뿐만 아니라 출력된 수치와 근거 자료가 군 실무 요구사항에 부합하는지 면밀히 확인한다.\n", + "\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "\"\"\"야전 활동 중 생체신호의 결측·센서 튐·고착(센서 단절)을 탐지·보정하는 전처리 모듈을 구현하고, 처리 속도·단위 테스트 5종·보정 전후 체온 오차와 경보 분류 성능을 측정한다. 진단 목적이 아닌 교육용 가상 자료이다.\"\"\"\n", + "import time\n", + "import numpy as np\n", + "import pandas as pd\n", + "import matplotlib.pyplot as plt\n", + "\n", + "# ---------------- [교육용가상자료] 더미 생체신호 생성기 (실제 장병·부대·의무 기록 없음) ----------------\n", + "# 가상 병사(가명 ID)가 60분간 걷기·휴식·고강도 활동을 하는 동안의 심박수·체온(추정 심부)·피부전도도를 1분 간격으로 만든다.\n", + "# 진단 도구가 아니며, 상태 라벨은 가상 생리 모델의 '교육용 단계'일 뿐이다.\n", + "N_MIN = 60\n", + "STAGE = {0: \"정상\", 1: \"주의\", 2: \"경보\"}\n", + "TYPES = [\"normal\", \"heat\", \"cold\"]\n", + "\n", + "\n", + "def make_person(i, rng):\n", + " return {\"id\": f\"S{i:03d}\", \"rest_hr\": float(rng.normal(64, 7)), \"age\": int(rng.integers(19, 27)), \"bmi\": float(rng.normal(23.5, 2.5)),\n", + " \"acclim\": int(rng.random() < 0.4), \"fit\": float(np.clip(rng.normal(0.5, 0.2), 0.05, 0.95))}\n", + "\n", + "\n", + "def make_env(kind, rng):\n", + " if kind == \"heat\":\n", + " return {\"temp_c\": float(rng.uniform(30, 40)), \"rh\": float(rng.uniform(45, 85)), \"wind\": float(rng.uniform(0.3, 3))}\n", + " if kind == \"cold\":\n", + " return {\"temp_c\": float(rng.uniform(-22, 2)), \"rh\": float(rng.uniform(40, 80)), \"wind\": float(rng.uniform(2, 11))}\n", + " return {\"temp_c\": float(rng.uniform(8, 24)), \"rh\": float(rng.uniform(35, 70)), \"wind\": float(rng.uniform(0.3, 5))}\n", + "\n", + "\n", + "def make_session(sid, kind, rng, p=None, glitch=True):\n", + " \"\"\"반환: dict(person, env, kind, df[hr,temp,eda,act + 정답 core·stage·rtype]). 센서 결측·튐·고착 포함.\"\"\"\n", + " p = p or make_person(sid, rng)\n", + " env = make_env(kind, rng)\n", + " act = np.zeros(N_MIN); t = 0\n", + " while t < N_MIN: # 10~20분 단위 활동 블록: 휴식 1.5 / 행군 4.5 / 고강도 7 MET\n", + " d = int(rng.integers(10, 21)); act[t:t + d] = rng.choice([1.5, 4.5, 7.0], p=[0.25, 0.5, 0.25]); t += d\n", + " hi = env[\"temp_c\"] + (0.12 * (env[\"rh\"] - 40) if env[\"temp_c\"] > 25 else 0)\n", + " wc = env[\"temp_c\"] - 1.6 * env[\"wind\"]\n", + " core, cores = 36.9, []\n", + " for m in range(N_MIN):\n", + " heat = 0.085 * max(0, hi - 24) * (1 - 0.25 * p[\"acclim\"]) * (1 + 0.5 * m / N_MIN) * (1 + 0.012 * (p[\"bmi\"] - 23.5))\n", + " cold = -0.075 * max(0, 8 - wc) * (1 - 0.09 * act[m])\n", + " ss = 36.9 + 0.09 * act[m] + heat + cold\n", + " core += (ss - core) / 18 + rng.normal(0, 0.03)\n", + " cores.append(core)\n", + " core = np.array(cores)\n", + " hr = p[\"rest_hr\"] + 9.5 * (act - 1.5) * (1.15 - 0.3 * p[\"fit\"]) + 11 * (core - 36.9) + rng.normal(0, 3.2, N_MIN)\n", + " eda = 2.0 + 0.25 * act + 3.2 * np.maximum(core - 37.2, 0) - 0.04 * np.maximum(8 - wc, 0) + rng.normal(0, 0.25, N_MIN)\n", + " bias = rng.normal(0, 0.1)\n", + " temp = core + bias + rng.normal(0, 0.1, N_MIN) - np.where((kind == \"cold\") & (env[\"wind\"] > 7), 0.25, 0.0) # 센서 접촉 부위 바람 냉각 오차\n", + " df = pd.DataFrame({\"min\": np.arange(N_MIN), \"hr\": hr, \"temp\": temp, \"eda\": np.maximum(eda, 0.2), \"act\": act})\n", + " if glitch:\n", + " for col in (\"hr\", \"temp\", \"eda\"):\n", + " df.loc[rng.random(N_MIN) < 0.03, col] = np.nan # 결측\n", + " s = int(rng.integers(5, N_MIN))\n", + " if rng.random() < 0.12:\n", + " df.loc[s, \"hr\"] = 230.0 # 센서 튐\n", + " if rng.random() < 0.12:\n", + " df.loc[s, \"temp\"] = 45.0\n", + " ht = np.where(core >= 39.0, 2, np.where(core >= 38.3, 1, 0)); cd = np.where(core <= 35.4, 2, np.where(core <= 36.2, 1, 0))\n", + " df[\"core\"] = core\n", + " df[\"stage\"] = np.maximum(ht, cd); df[\"rtype\"] = np.where(ht > 0, \"heat\", np.where(cd > 0, \"cold\", \"normal\"))\n", + " return {\"id\": p[\"id\"], \"person\": p, \"env\": env, \"kind\": kind, \"df\": df}\n", + "\n", + "\n", + "def make_dataset(n, seed):\n", + " rng = np.random.default_rng(seed)\n", + " kinds = [\"normal\", \"heat\", \"cold\"]\n", + " return [make_session(i, kinds[i % 3], rng) for i in range(n)]\n", + "# ---------------------------------------------------------------------------\n", + "\n", + "# ---------------- 전처리·특징 추출 공통 함수 ----------------\n", + "LIMITS = {\"hr\": (30, 220), \"temp\": (30.0, 43.0), \"eda\": (0.0, 40.0)} # 생리적으로 가능한 범위(범위 밖 = 센서 오류)\n", + "\n", + "\n", + "def clean(df):\n", + " \"\"\"범위 밖 값은 결측 처리 -> 선형 보간(양 끝은 가까운 값) -> 3분 이동 중앙값으로 튐 제거. 반환: (정제 df, 보정 건수)\"\"\"\n", + " # [TODO : 학생 실습 2] 야전 활동 중 발생하는 생체신호 결측치 보정 및 센서 노이즈 필터링 전처리 구현 관련 핵심 로직을 구현하세요.\n", + " # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n", + " # <<< 여기에 코드를 작성하세요 >>>\n", + " pass # 작성 후 pass를 제거하세요.\n", + "def env_index(env):\n", + " # [TODO : 학생 실습 2] 야전 활동 중 발생하는 생체신호 결측치 보정 및 센서 노이즈 필터링 전처리 구현 관련 핵심 로직을 구현하세요.\n", + " # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n", + " # <<< 여기에 코드를 작성하세요 >>>\n", + " pass # 작성 후 pass를 제거하세요.\n", + "def features(s, use_env=True, personal=True):\n", + " \"\"\"세션 1개 -> 분 단위 특징표(최근 5분 평균·기울기 포함). 정답 stage/rtype 포함.\"\"\"\n", + " df, _ = clean(s[\"df\"]); p, e = s[\"person\"], s[\"env\"]\n", + " f = pd.DataFrame({\"sid\": s[\"id\"], \"min\": df[\"min\"]})\n", + " f[\"temp5\"] = df[\"temp\"].rolling(5, min_periods=1).mean()\n", + " f[\"hr5\"] = df[\"hr\"].rolling(5, min_periods=1).mean()\n", + " f[\"eda5\"] = df[\"eda\"].rolling(5, min_periods=1).mean()\n", + " f[\"dtemp\"] = (f[\"temp5\"] - f[\"temp5\"].shift(4)).fillna(0)\n", + " f[\"act\"] = df[\"act\"]\n", + " if personal:\n", + " f[\"hr_rel\"] = f[\"hr5\"] - p[\"rest_hr\"]\n", + " f[\"acclim\"], f[\"bmi\"], f[\"fit\"] = p[\"acclim\"], p[\"bmi\"], p[\"fit\"]\n", + " if use_env:\n", + " f[\"hi\"], f[\"wc\"] = env_index(e); f[\"wind\"] = e[\"wind\"]\n", + " f[\"stage\"], f[\"rtype\"], f[\"kind\"] = df[\"stage\"], df[\"rtype\"], s[\"kind\"]\n", + " return f[f[\"min\"] >= 5].reset_index(drop=True)\n", + "\n", + "\n", + "def table(sessions, **kw):\n", + " return pd.concat([features(s, **kw) for s in sessions], ignore_index=True)\n", + "# ---------------------------------------------------------------------------\n", + "\n", + "\n", + "def flatline(series, run=8):\n", + " \"\"\"같은 값이 run분 이상 이어지면 센서 고착(단절)으로 보고 해당 구간을 True로 표시한다.\"\"\"\n", + " same = series.diff().abs().fillna(1) < 1e-9\n", + " grp = (~same).cumsum()\n", + " return same.groupby(grp).transform(\"sum\").add(1).where(same | same.shift(-1, fill_value=False), 1) >= run\n", + "\n", + "\n", + "def preprocess(df):\n", + " \"\"\"고착 구간을 결측으로 바꾼 뒤 clean()으로 범위 검사·보간·튐 제거. 반환: (정제 df, 보정 건수, 고착 건수)\"\"\"\n", + " d = df.copy(); stuck = 0\n", + " for c in (\"hr\", \"temp\"):\n", + " fl = flatline(d[c].round(3))\n", + " stuck += int(fl.sum()); d.loc[fl, c] = np.nan\n", + " out, fixed = clean(d)\n", + " return out, fixed, stuck\n", + "\n", + "\n", + "def inject_faults(df, rng):\n", + " \"\"\"센서 단절 모의: 체온이 가장 크게 변하는 시점 직전부터 센서가 12분간 같은 값으로 고착된다(실제 변화를 따라가지 못함).\"\"\"\n", + " d = df.copy(); s = int(np.clip(np.argmax(np.abs(d[\"core\"].values - 36.9)) - 12 + rng.integers(0, 4), 3, N_MIN - 13))\n", + " d.loc[s:s + 11, \"temp\"] = d.loc[s, \"temp\"]\n", + " return d\n", + "\n", + "\n", + "rng = np.random.default_rng(5)\n", + "data = make_dataset(120, seed=21)\n", + "\n", + "# 1) 처리 속도와 오류율\n", + "t0 = time.perf_counter(); n_rows = 0; errors = 0; fixed_all = 0\n", + "for s in data:\n", + " try:\n", + " out, fx, st = preprocess(s[\"df\"]); n_rows += len(out) * 3; fixed_all += fx\n", + " errors += int(out[[\"hr\", \"temp\", \"eda\"]].isna().any().any() or (out[\"temp\"] > 43).any())\n", + " except Exception:\n", + " errors += 1\n", + "sec = time.perf_counter() - t0\n", + "print(f\"전처리 {n_rows:,}건(신호값) / {sec:.2f}초 = {n_rows / sec:,.0f}건/초 (목표 1,000 이상: {'달성' if n_rows / sec >= 1000 else '미달'})\")\n", + "print(f\"전처리 오류 {errors}건 -> 오류율 {errors / len(data):.0%} (목표 0%), 범위 밖·결측 보정 {fixed_all}건\")\n", + "\n", + "\n", + "# 2) 단위 테스트 5종\n", + "def t1():\n", + " d = pd.DataFrame({\"min\": range(6), \"hr\": [80, np.nan, 82, 83, 84, 85.], \"temp\": 37.0, \"eda\": 2.0, \"act\": 1.5, \"stage\": 0, \"rtype\": \"normal\"})\n", + " out, fx = clean(d); assert out[\"hr\"].notna().all() and fx == 1\n", + "def t2():\n", + " d = pd.DataFrame({\"min\": range(6), \"hr\": [80, 81, 230, 82, 83, 84.], \"temp\": 37.0, \"eda\": 2.0, \"act\": 1.5, \"stage\": 0, \"rtype\": \"normal\"})\n", + " out, fx = clean(d); assert out[\"hr\"].max() < 100 and fx == 1\n", + "def t3():\n", + " s = pd.Series([36.9, 37.0, 37.0, 37.0, 37.0, 37.0, 37.0, 37.0, 37.0, 37.0, 37.1]); assert flatline(s).sum() >= 8\n", + "def t4():\n", + " s = pd.Series(np.linspace(36.5, 37.5, 20)); assert not flatline(s).any()\n", + "def t5():\n", + " out, fx, st = preprocess(inject_faults(make_session(1, \"normal\", np.random.default_rng(3))[\"df\"], np.random.default_rng(4))); assert st >= 8 and len(out) == N_MIN\n", + "tests = {\"결측 보간\": t1, \"센서 튐 제거\": t2, \"고착 구간 탐지\": t3, \"정상 변화 오탐 없음\": t4, \"고착 주입 후 보정\": t5}\n", + "passed = 0\n", + "for name, fn in tests.items():\n", + " try:\n", + " fn(); passed += 1; print(f\"[PASS] {name}\")\n", + " except AssertionError:\n", + " print(f\"[FAIL] {name}\")\n", + "print(f\"단위 테스트 {passed}/5 통과\")\n", + "\n", + "# 3) 보정 전후 체온 오차(정답 core 대비 RMSE)와 경보 분류 성능\n", + "def rmse(a, b):\n", + " m = ~np.isnan(a)\n", + " return float(np.sqrt(np.mean((a[m] - b[m]) ** 2)))\n", + "\n", + "\n", + "def hot_cold_f1(df_use, stage_src):\n", + " \"\"\"체온 38.0 이상 또는 36.3 이하를 위험으로 본 경보와 정답 stage>=1의 F1.\"\"\"\n", + " pred = (df_use[\"temp\"] >= 38.0) | (df_use[\"temp\"] <= 36.3)\n", + " truth = stage_src[\"stage\"] >= 1\n", + " tp = (pred & truth).sum(); p = tp / max(pred.sum(), 1); r = tp / max(truth.sum(), 1)\n", + " return round(2 * p * r / max(p + r, 1e-9), 3), int((pred & ~truth).sum())\n", + "\n", + "\n", + "rows = []\n", + "for kind, label in ((\"normal\", \"평시\"), (\"heat\", \"폭염\"), (\"cold\", \"혹한\")):\n", + " raw, cln, tr_ = [], [], []\n", + " for s in [x for x in data if x[\"kind\"] == kind]:\n", + " bad = inject_faults(s[\"df\"], rng)\n", + " out, _, _ = preprocess(bad)\n", + " raw.append(bad); cln.append(out); tr_.append(s[\"df\"])\n", + " raw, cln, tr_ = pd.concat(raw), pd.concat(cln), pd.concat(tr_)\n", + " f_raw, fp_raw = hot_cold_f1(raw, tr_)\n", + " f_cln, fp_cln = hot_cold_f1(cln, tr_)\n", + " rows.append({\"상황\": label, \"체온 RMSE 전\": round(rmse(raw[\"temp\"].where(raw[\"temp\"] <= 43).values, tr_[\"core\"].values), 3),\n", + " \"체온 RMSE 후\": round(rmse(cln[\"temp\"].values, tr_[\"core\"].values), 3), \"경보 F1 전\": f_raw, \"경보 F1 후\": f_cln, \"오경보 전\": fp_raw, \"오경보 후\": fp_cln})\n", + "print(\"\\n[보정 전후 비교] (전: 센서 튐·결측·고착 그대로 / 후: 전처리 적용)\")\n", + "print(pd.DataFrame(rows).to_string(index=False))\n", + "print(\"전술 노이즈 대응: 체온 센서 고착(단절)은 8분 이상 같은 값이면 결측 처리 후 보간하고, 범위 밖 값은 센서 오류로 본다. 스푸핑 판별은 본 과제 범위 밖이다.\")\n", + "\n", + "s = data[1]; bad = inject_faults(s[\"df\"], rng); out, _, _ = preprocess(bad)\n", + "plt.figure(figsize=(9, 4))\n", + "plt.plot(bad[\"min\"], bad[\"temp\"], \".\", color=\"tab:red\", label=\"raw (faults)\"); plt.plot(out[\"min\"], out[\"temp\"], color=\"tab:blue\", label=\"cleaned\")\n", + "plt.plot(s[\"df\"][\"min\"], s[\"df\"][\"core\"], \"k:\", label=\"true (simulated)\"); plt.ylim(34, 41); plt.xlabel(\"minute\"); plt.ylabel(\"temp (C)\")\n", + "plt.title(f\"{s['id']} temperature before/after preprocessing (synthetic)\"); plt.legend(); plt.show()\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 02 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + "전처리 21,600건(신호값) / 1.20초 = 18,030건/초 (목표 1,000 이상: 달성)\n", + "전처리 오류 0건 -> 오류율 0% (목표 0%), 범위 밖·결측 보정 663건\n", + "[PASS] 결측 보간\n", + "[PASS] 센서 튐 제거\n", + "[PASS] 고착 구간 탐지\n", + "[PASS] 정상 변화 오탐 없음\n", + "[PASS] 고착 주입 후 보정\n", + "단위 테스트 5/5 통과\n", + "\n", + "[보정 전후 비교] (전: 센서 튐·결측·고착 그대로 / 후: 전처리 적용)\n", + "상황 체온 RMSE 전 체온 RMSE 후 경보 F1 전 경보 F1 후 오경보 전 오경보 후\n", + "평시 0.141 0.121 0.000 0.000 4 0\n", + "폭염 0.146 0.104 0.818 0.820 378 417\n", + "혹한 0.219 0.203 0.814 0.828 397 419\n", + "전술 노이즈 대응: 체온 센서 고착(단절)은 8분 이상 같은 값이면 결측 처리 후 보간하고, 범위 밖 값은 센서 오류로 본다. 스푸핑 판별은 본 과제 범위 밖이다.\n", + "```\n", + "
\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 03. 체온-심박수 상관관계 기반 1차 열사병/저체온증 경보 Baseline 모델 구현\n", + "**소요시간**: 약 2.5시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "체온-심박수 상관관계 기반 1차 온열/한랭 스트레스 경보 Baseline 모델을 구현한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **체온과 심박수의 상관관계를 이용해 온열·한랭 스트레스를 1차로 경보하는 규칙 기반 Baseline을 구현하고 판단 기준을 투명하게 기록한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **최근 5분 평균 체온이 38.0도 이상이면서 심박수가 95 이상이면 온열 스트레스 의심, 36.3도 이하이면 한랭 스트레스 의심으로 분류 수행하고 검증하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `src/models/wel01_baseline_model.py 및 기본 추론 결과 보고서` 형태의 결과물을 완성한다.\n", + "\n", + "### 🔍 평가 및 검증 기준 (PASS 조건)\n", + "#### 미션 판정\n", + "\n", + "**평가 구분:** 필수\n", + "\n", + "**핵심 평가영역:** 알고리즘의 논리적 흐름 및 군사적 판단 기준의 투명성\n", + "\n", + "#### 미션 PASS 조건\n", + "\n", + "- 정량 기준: 기본 데이터셋 대상 탐지/처리 정확도 75% 이상 달성\n", + "\n", + "\n", + "- 진행 기준: 단위 기능 동작 확인 및 기본 시나리오 실행 로그 생성\n", + "\n", + "\n", + "- 검증 방법: 기준 벤치마크 데이터셋 대상 추론 결과 오차율 비교\n", + "\n", + "\n", + "#### 채점자 판정 메모\n", + "\n", + "> 단순 코드 실행 여부뿐만 아니라 출력된 수치와 근거 자료가 군 실무 요구사항에 부합하는지 면밀히 확인한다.\n", + "\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "\"\"\"체온과 심박수의 상관관계(체온 상승 시 심박수 동반 상승)를 이용한 규칙 기반 1차 온열·한랭 스트레스 경보 Baseline을 구현하고, 가상 병사 데이터에서 정확도와 위험 구간 재현율을 측정한다. 진단 아님·의무대 연계 원칙을 따른다.\"\"\"\n", + "import numpy as np\n", + "import pandas as pd\n", + "import matplotlib.pyplot as plt\n", + "\n", + "# ---------------- [교육용가상자료] 더미 생체신호 생성기 (실제 장병·부대·의무 기록 없음) ----------------\n", + "# 가상 병사(가명 ID)가 60분간 걷기·휴식·고강도 활동을 하는 동안의 심박수·체온(추정 심부)·피부전도도를 1분 간격으로 만든다.\n", + "# 진단 도구가 아니며, 상태 라벨은 가상 생리 모델의 '교육용 단계'일 뿐이다.\n", + "N_MIN = 60\n", + "STAGE = {0: \"정상\", 1: \"주의\", 2: \"경보\"}\n", + "TYPES = [\"normal\", \"heat\", \"cold\"]\n", + "\n", + "\n", + "def make_person(i, rng):\n", + " return {\"id\": f\"S{i:03d}\", \"rest_hr\": float(rng.normal(64, 7)), \"age\": int(rng.integers(19, 27)), \"bmi\": float(rng.normal(23.5, 2.5)),\n", + " \"acclim\": int(rng.random() < 0.4), \"fit\": float(np.clip(rng.normal(0.5, 0.2), 0.05, 0.95))}\n", + "\n", + "\n", + "def make_env(kind, rng):\n", + " if kind == \"heat\":\n", + " return {\"temp_c\": float(rng.uniform(30, 40)), \"rh\": float(rng.uniform(45, 85)), \"wind\": float(rng.uniform(0.3, 3))}\n", + " if kind == \"cold\":\n", + " return {\"temp_c\": float(rng.uniform(-22, 2)), \"rh\": float(rng.uniform(40, 80)), \"wind\": float(rng.uniform(2, 11))}\n", + " return {\"temp_c\": float(rng.uniform(8, 24)), \"rh\": float(rng.uniform(35, 70)), \"wind\": float(rng.uniform(0.3, 5))}\n", + "\n", + "\n", + "def make_session(sid, kind, rng, p=None, glitch=True):\n", + " \"\"\"반환: dict(person, env, kind, df[hr,temp,eda,act + 정답 core·stage·rtype]). 센서 결측·튐·고착 포함.\"\"\"\n", + " p = p or make_person(sid, rng)\n", + " env = make_env(kind, rng)\n", + " act = np.zeros(N_MIN); t = 0\n", + " while t < N_MIN: # 10~20분 단위 활동 블록: 휴식 1.5 / 행군 4.5 / 고강도 7 MET\n", + " d = int(rng.integers(10, 21)); act[t:t + d] = rng.choice([1.5, 4.5, 7.0], p=[0.25, 0.5, 0.25]); t += d\n", + " hi = env[\"temp_c\"] + (0.12 * (env[\"rh\"] - 40) if env[\"temp_c\"] > 25 else 0)\n", + " wc = env[\"temp_c\"] - 1.6 * env[\"wind\"]\n", + " core, cores = 36.9, []\n", + " for m in range(N_MIN):\n", + " heat = 0.085 * max(0, hi - 24) * (1 - 0.25 * p[\"acclim\"]) * (1 + 0.5 * m / N_MIN) * (1 + 0.012 * (p[\"bmi\"] - 23.5))\n", + " cold = -0.075 * max(0, 8 - wc) * (1 - 0.09 * act[m])\n", + " ss = 36.9 + 0.09 * act[m] + heat + cold\n", + " core += (ss - core) / 18 + rng.normal(0, 0.03)\n", + " cores.append(core)\n", + " core = np.array(cores)\n", + " hr = p[\"rest_hr\"] + 9.5 * (act - 1.5) * (1.15 - 0.3 * p[\"fit\"]) + 11 * (core - 36.9) + rng.normal(0, 3.2, N_MIN)\n", + " eda = 2.0 + 0.25 * act + 3.2 * np.maximum(core - 37.2, 0) - 0.04 * np.maximum(8 - wc, 0) + rng.normal(0, 0.25, N_MIN)\n", + " bias = rng.normal(0, 0.1)\n", + " temp = core + bias + rng.normal(0, 0.1, N_MIN) - np.where((kind == \"cold\") & (env[\"wind\"] > 7), 0.25, 0.0) # 센서 접촉 부위 바람 냉각 오차\n", + " df = pd.DataFrame({\"min\": np.arange(N_MIN), \"hr\": hr, \"temp\": temp, \"eda\": np.maximum(eda, 0.2), \"act\": act})\n", + " if glitch:\n", + " for col in (\"hr\", \"temp\", \"eda\"):\n", + " df.loc[rng.random(N_MIN) < 0.03, col] = np.nan # 결측\n", + " s = int(rng.integers(5, N_MIN))\n", + " if rng.random() < 0.12:\n", + " df.loc[s, \"hr\"] = 230.0 # 센서 튐\n", + " if rng.random() < 0.12:\n", + " df.loc[s, \"temp\"] = 45.0\n", + " ht = np.where(core >= 39.0, 2, np.where(core >= 38.3, 1, 0)); cd = np.where(core <= 35.4, 2, np.where(core <= 36.2, 1, 0))\n", + " df[\"core\"] = core\n", + " df[\"stage\"] = np.maximum(ht, cd); df[\"rtype\"] = np.where(ht > 0, \"heat\", np.where(cd > 0, \"cold\", \"normal\"))\n", + " return {\"id\": p[\"id\"], \"person\": p, \"env\": env, \"kind\": kind, \"df\": df}\n", + "\n", + "\n", + "def make_dataset(n, seed):\n", + " rng = np.random.default_rng(seed)\n", + " kinds = [\"normal\", \"heat\", \"cold\"]\n", + " return [make_session(i, kinds[i % 3], rng) for i in range(n)]\n", + "# ---------------------------------------------------------------------------\n", + "\n", + "# ---------------- 전처리·특징 추출 공통 함수 ----------------\n", + "LIMITS = {\"hr\": (30, 220), \"temp\": (30.0, 43.0), \"eda\": (0.0, 40.0)} # 생리적으로 가능한 범위(범위 밖 = 센서 오류)\n", + "\n", + "\n", + "def clean(df):\n", + " \"\"\"범위 밖 값은 결측 처리 -> 선형 보간(양 끝은 가까운 값) -> 3분 이동 중앙값으로 튐 제거. 반환: (정제 df, 보정 건수)\"\"\"\n", + " # [TODO : 학생 실습 3] 체온-심박수 상관관계 기반 1차 열사병/저체온증 경보 Baseline 모델 구현 관련 핵심 로직을 구현하세요.\n", + " # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n", + " # <<< 여기에 코드를 작성하세요 >>>\n", + " pass # 작성 후 pass를 제거하세요.\n", + "def env_index(env):\n", + " # [TODO : 학생 실습 3] 체온-심박수 상관관계 기반 1차 열사병/저체온증 경보 Baseline 모델 구현 관련 핵심 로직을 구현하세요.\n", + " # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n", + " # <<< 여기에 코드를 작성하세요 >>>\n", + " pass # 작성 후 pass를 제거하세요.\n", + "def features(s, use_env=True, personal=True):\n", + " \"\"\"세션 1개 -> 분 단위 특징표(최근 5분 평균·기울기 포함). 정답 stage/rtype 포함.\"\"\"\n", + " df, _ = clean(s[\"df\"]); p, e = s[\"person\"], s[\"env\"]\n", + " f = pd.DataFrame({\"sid\": s[\"id\"], \"min\": df[\"min\"]})\n", + " f[\"temp5\"] = df[\"temp\"].rolling(5, min_periods=1).mean()\n", + " f[\"hr5\"] = df[\"hr\"].rolling(5, min_periods=1).mean()\n", + " f[\"eda5\"] = df[\"eda\"].rolling(5, min_periods=1).mean()\n", + " f[\"dtemp\"] = (f[\"temp5\"] - f[\"temp5\"].shift(4)).fillna(0)\n", + " f[\"act\"] = df[\"act\"]\n", + " if personal:\n", + " f[\"hr_rel\"] = f[\"hr5\"] - p[\"rest_hr\"]\n", + " f[\"acclim\"], f[\"bmi\"], f[\"fit\"] = p[\"acclim\"], p[\"bmi\"], p[\"fit\"]\n", + " if use_env:\n", + " f[\"hi\"], f[\"wc\"] = env_index(e); f[\"wind\"] = e[\"wind\"]\n", + " f[\"stage\"], f[\"rtype\"], f[\"kind\"] = df[\"stage\"], df[\"rtype\"], s[\"kind\"]\n", + " return f[f[\"min\"] >= 5].reset_index(drop=True)\n", + "\n", + "\n", + "def table(sessions, **kw):\n", + " return pd.concat([features(s, **kw) for s in sessions], ignore_index=True)\n", + "# ---------------------------------------------------------------------------\n", + "\n", + "# 판단 기준(투명성): ① 최근 5분 평균 체온이 38.0도 이상이고 심박수가 95 이상이면 '온열 스트레스 의심'\n", + "# ② 최근 5분 평균 체온이 36.3도 이하이면 '한랭 스트레스 의심' ③ 그 외 정상\n", + "TH_HEAT, TH_COLD, HR_GATE = 38.0, 36.3, 95\n", + "\n", + "\n", + "def rule(r, th=TH_HEAT, tc=TH_COLD, hg=HR_GATE):\n", + " if r[\"temp5\"] >= th and r[\"hr5\"] >= hg:\n", + " return \"heat\"\n", + " if r[\"temp5\"] <= tc:\n", + " return \"cold\"\n", + " return \"normal\"\n", + "\n", + "\n", + "data = make_dataset(90, seed=7)\n", + "tab = table(data, use_env=False, personal=False)\n", + "tab[\"pred\"] = tab.apply(rule, axis=1)\n", + "acc = (tab[\"pred\"] == tab[\"rtype\"]).mean()\n", + "print(f\"[Baseline] 가상 병사 90명 x 55분 = {len(tab):,}개 구간, 정답 분포 {tab['rtype'].value_counts().to_dict()}\")\n", + "print(f\"경보 유형 분류 정확도 {acc:.1%} (목표 75% 이상) -> {'달성' if acc >= 0.75 else '미달'}\")\n", + "\n", + "print(\"\\n[유형별 판정] (행: 정답, 열: 판정)\")\n", + "cm = pd.crosstab(tab[\"rtype\"], tab[\"pred\"]).reindex(index=TYPES, columns=TYPES, fill_value=0)\n", + "print(cm.to_string())\n", + "for c in (\"heat\", \"cold\"):\n", + " rec = cm.loc[c, c] / cm.loc[c].sum(); prec = cm.loc[c, c] / max(cm[c].sum(), 1)\n", + " print(f\"{c}: 재현율 {rec:.1%}, 정밀도 {prec:.1%}\")\n", + "\n", + "print(\"\\n[단계별 위험 구간 재현율] (위험 유형을 위험으로 맞춘 비율)\")\n", + "risk = tab[tab[\"stage\"] >= 1]\n", + "print(risk.groupby(\"stage\").apply(lambda g: f\"{(g['pred'] == g['rtype']).mean():.1%} ({len(g)}구간)\").to_string())\n", + "\n", + "print(\"\\n[체온-심박수 상관] 전체 상관계수 %.2f / 폭염 세션 %.2f / 혹한 세션 %.2f\" % (\n", + " tab[[\"temp5\", \"hr5\"]].corr().iloc[0, 1], tab[tab[\"kind\"] == \"heat\"][[\"temp5\", \"hr5\"]].corr().iloc[0, 1], tab[tab[\"kind\"] == \"cold\"][[\"temp5\", \"hr5\"]].corr().iloc[0, 1]))\n", + "\n", + "print(\"\\n[임계값 민감도] 온열 체온 기준 변화\")\n", + "for th in (37.7, 38.0, 38.3, 38.6):\n", + " p = tab.apply(lambda r: rule(r, th=th), axis=1)\n", + " r = ((p == \"heat\") & (tab[\"rtype\"] == \"heat\")).sum() / (tab[\"rtype\"] == \"heat\").sum(); f = ((p == \"heat\") & (tab[\"rtype\"] != \"heat\")).sum()\n", + " print(f\"기준 {th}: 온열 재현율 {r:.1%}, 오경보 {f}구간\")\n", + "print(\"한계: 개인별 안정시 심박·기상·활동 강도를 반영하지 않아 운동 직후 심박 상승과 바람에 의한 센서 냉각을 구분하지 못한다 -> 미션 4·5에서 보완한다.\")\n", + "print(\"본 결과는 교육용 가상 자료의 경보 단계이며 진단이 아니다. 실제 이상 징후는 의무대·군의관에게 연계한다.\")\n", + "\n", + "colr = {\"normal\": \"tab:gray\", \"heat\": \"tab:red\", \"cold\": \"tab:blue\"}\n", + "plt.figure(figsize=(7, 5))\n", + "for k, g in tab.groupby(\"rtype\"):\n", + " plt.scatter(g[\"temp5\"], g[\"hr5\"], s=6, alpha=0.4, color=colr[k], label=k)\n", + "plt.axvline(TH_HEAT, ls=\"--\", color=\"k\"); plt.axvline(TH_COLD, ls=\"--\", color=\"k\"); plt.axhline(HR_GATE, ls=\":\", color=\"k\")\n", + "plt.xlabel(\"temperature (5-min mean, C)\"); plt.ylabel(\"heart rate (5-min mean, bpm)\"); plt.legend(); plt.title(\"Baseline rule on dummy signals\")\n", + "plt.show()\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 03 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + "[Baseline] 가상 병사 90명 x 55분 = 4,950개 구간, 정답 분포 {'normal': 3536, 'cold': 761, 'heat': 653}\n", + "경보 유형 분류 정확도 86.9% (목표 75% 이상) -> 달성\n", + "\n", + "[유형별 판정] (행: 정답, 열: 판정)\n", + "pred normal heat cold\n", + "rtype \n", + "normal 3056 246 234\n", + "heat 160 493 0\n", + "cold 7 0 754\n", + "heat: 재현율 75.5%, 정밀도 66.7%\n", + "cold: 재현율 99.1%, 정밀도 76.3%\n", + "\n", + "[단계별 위험 구간 재현율] (위험 유형을 위험으로 맞춘 비율)\n", + "stage\n", + "1 86.8% (1221구간)\n", + "2 96.9% (193구간)\n", + "\n", + "[체온-심박수 상관] 전체 상관계수 0.48 / 폭염 세션 0.32 / 혹한 세션 0.41\n", + "\n", + "[임계값 민감도] 온열 체온 기준 변화\n", + "기준 37.7: 온열 재현율 75.7%, 오경보 462구간\n", + "기준 38.0: 온열 재현율 75.5%, 오경보 246구간\n", + "기준 38.3: 온열 재현율 67.7%, 오경보 38구간\n", + "기준 38.6: 온열 재현율 31.1%, 오경보 0구간\n", + "한계: 개인별 안정시 심박·기상·활동 강도를 반영하지 않아 운동 직후 심박 상승과 바람에 의한 센서 냉각을 구분하지 못한다 -> 미션 4·5에서 보완한다.\n", + "본 결과는 교육용 가상 자료의 경보 단계이며 진단이 아니다. 실제 이상 징후는 의무대·군의관에게 연계한다.\n", + "```\n", + "
\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 04. 기상 데이터(온·습도, 풍속) 결합 온열/한랭 스트레스 복합 위험도 예측 모델 고도화\n", + "**소요시간**: 약 3.0시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "기상 데이터와 결합한 복합 환경 위험도 예측 모델을 고도화한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **기상(온도·습도·풍속)과 개인 특성을 결합한 복합 환경 위험도 분류 모델로 고도화하고, 센서 이상·교란 신호를 점검하는 예외 탐지를 추가한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **체감 더위·추위 지수, 안정시 심박 대비 심박 상승, 체온 변화율, 더위 순응 여부 등을 특징으로 그래디언트 부스팅 모델을 학습 수행하고 검증하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `src/models/wel01_advanced_detector.py 및 예외 탐지 테스트 결과` 형태의 결과물을 완성한다.\n", + "\n", + "### 🔍 평가 및 검증 기준 (PASS 조건)\n", + "#### 미션 판정\n", + "\n", + "**평가 구분:** 필수\n", + "\n", + "**핵심 평가영역:** 기만/교란 공격에 대한 방어 로직의 현실성 및 신뢰성\n", + "\n", + "#### 미션 PASS 조건\n", + "\n", + "- 정량 기준: 복합 위협 탐지 재현율(Recall) 85% 이상, 처리 지연 200ms 이하\n", + "\n", + "\n", + "- 진행 기준: 복합 위협 시나리오 10종에 대한 탐지 테스트 통과\n", + "\n", + "\n", + "- 검증 방법: 합성 위협 데이터셋 주입 시 이상치 탐지 정확도 검증\n", + "\n", + "\n", + "#### 채점자 판정 메모\n", + "\n", + "> 단순 코드 실행 여부뿐만 아니라 출력된 수치와 근거 자료가 군 실무 요구사항에 부합하는지 면밀히 확인한다.\n", + "\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "\"\"\"체온·심박수·피부전도도에 기상(온도·습도·풍속)과 개인 특성(안정시 심박, 순응 여부, 체격)을 결합한 복합 환경 위험도 분류 모델(그래디언트 부스팅)로 고도화하고, 위험 구간 재현율·처리 지연을 Baseline과 비교한다. 센서 이상 예외 탐지 10종도 시험한다. 진단 아님·의무대 연계.\"\"\"\n", + "import time\n", + "import numpy as np\n", + "import pandas as pd\n", + "from sklearn.ensemble import GradientBoostingClassifier\n", + "\n", + "# ---------------- [교육용가상자료] 더미 생체신호 생성기 (실제 장병·부대·의무 기록 없음) ----------------\n", + "# 가상 병사(가명 ID)가 60분간 걷기·휴식·고강도 활동을 하는 동안의 심박수·체온(추정 심부)·피부전도도를 1분 간격으로 만든다.\n", + "# 진단 도구가 아니며, 상태 라벨은 가상 생리 모델의 '교육용 단계'일 뿐이다.\n", + "N_MIN = 60\n", + "STAGE = {0: \"정상\", 1: \"주의\", 2: \"경보\"}\n", + "TYPES = [\"normal\", \"heat\", \"cold\"]\n", + "\n", + "\n", + "def make_person(i, rng):\n", + " return {\"id\": f\"S{i:03d}\", \"rest_hr\": float(rng.normal(64, 7)), \"age\": int(rng.integers(19, 27)), \"bmi\": float(rng.normal(23.5, 2.5)),\n", + " \"acclim\": int(rng.random() < 0.4), \"fit\": float(np.clip(rng.normal(0.5, 0.2), 0.05, 0.95))}\n", + "\n", + "\n", + "def make_env(kind, rng):\n", + " if kind == \"heat\":\n", + " return {\"temp_c\": float(rng.uniform(30, 40)), \"rh\": float(rng.uniform(45, 85)), \"wind\": float(rng.uniform(0.3, 3))}\n", + " if kind == \"cold\":\n", + " return {\"temp_c\": float(rng.uniform(-22, 2)), \"rh\": float(rng.uniform(40, 80)), \"wind\": float(rng.uniform(2, 11))}\n", + " return {\"temp_c\": float(rng.uniform(8, 24)), \"rh\": float(rng.uniform(35, 70)), \"wind\": float(rng.uniform(0.3, 5))}\n", + "\n", + "\n", + "def make_session(sid, kind, rng, p=None, glitch=True):\n", + " \"\"\"반환: dict(person, env, kind, df[hr,temp,eda,act + 정답 core·stage·rtype]). 센서 결측·튐·고착 포함.\"\"\"\n", + " p = p or make_person(sid, rng)\n", + " env = make_env(kind, rng)\n", + " act = np.zeros(N_MIN); t = 0\n", + " while t < N_MIN: # 10~20분 단위 활동 블록: 휴식 1.5 / 행군 4.5 / 고강도 7 MET\n", + " d = int(rng.integers(10, 21)); act[t:t + d] = rng.choice([1.5, 4.5, 7.0], p=[0.25, 0.5, 0.25]); t += d\n", + " hi = env[\"temp_c\"] + (0.12 * (env[\"rh\"] - 40) if env[\"temp_c\"] > 25 else 0)\n", + " wc = env[\"temp_c\"] - 1.6 * env[\"wind\"]\n", + " core, cores = 36.9, []\n", + " for m in range(N_MIN):\n", + " heat = 0.085 * max(0, hi - 24) * (1 - 0.25 * p[\"acclim\"]) * (1 + 0.5 * m / N_MIN) * (1 + 0.012 * (p[\"bmi\"] - 23.5))\n", + " cold = -0.075 * max(0, 8 - wc) * (1 - 0.09 * act[m])\n", + " ss = 36.9 + 0.09 * act[m] + heat + cold\n", + " core += (ss - core) / 18 + rng.normal(0, 0.03)\n", + " cores.append(core)\n", + " core = np.array(cores)\n", + " hr = p[\"rest_hr\"] + 9.5 * (act - 1.5) * (1.15 - 0.3 * p[\"fit\"]) + 11 * (core - 36.9) + rng.normal(0, 3.2, N_MIN)\n", + " eda = 2.0 + 0.25 * act + 3.2 * np.maximum(core - 37.2, 0) - 0.04 * np.maximum(8 - wc, 0) + rng.normal(0, 0.25, N_MIN)\n", + " bias = rng.normal(0, 0.1)\n", + " temp = core + bias + rng.normal(0, 0.1, N_MIN) - np.where((kind == \"cold\") & (env[\"wind\"] > 7), 0.25, 0.0) # 센서 접촉 부위 바람 냉각 오차\n", + " df = pd.DataFrame({\"min\": np.arange(N_MIN), \"hr\": hr, \"temp\": temp, \"eda\": np.maximum(eda, 0.2), \"act\": act})\n", + " if glitch:\n", + " for col in (\"hr\", \"temp\", \"eda\"):\n", + " df.loc[rng.random(N_MIN) < 0.03, col] = np.nan # 결측\n", + " s = int(rng.integers(5, N_MIN))\n", + " if rng.random() < 0.12:\n", + " df.loc[s, \"hr\"] = 230.0 # 센서 튐\n", + " if rng.random() < 0.12:\n", + " df.loc[s, \"temp\"] = 45.0\n", + " ht = np.where(core >= 39.0, 2, np.where(core >= 38.3, 1, 0)); cd = np.where(core <= 35.4, 2, np.where(core <= 36.2, 1, 0))\n", + " df[\"core\"] = core\n", + " df[\"stage\"] = np.maximum(ht, cd); df[\"rtype\"] = np.where(ht > 0, \"heat\", np.where(cd > 0, \"cold\", \"normal\"))\n", + " return {\"id\": p[\"id\"], \"person\": p, \"env\": env, \"kind\": kind, \"df\": df}\n", + "\n", + "\n", + "def make_dataset(n, seed):\n", + " rng = np.random.default_rng(seed)\n", + " kinds = [\"normal\", \"heat\", \"cold\"]\n", + " return [make_session(i, kinds[i % 3], rng) for i in range(n)]\n", + "# ---------------------------------------------------------------------------\n", + "\n", + "# ---------------- 전처리·특징 추출 공통 함수 ----------------\n", + "LIMITS = {\"hr\": (30, 220), \"temp\": (30.0, 43.0), \"eda\": (0.0, 40.0)} # 생리적으로 가능한 범위(범위 밖 = 센서 오류)\n", + "\n", + "\n", + "def clean(df):\n", + " \"\"\"범위 밖 값은 결측 처리 -> 선형 보간(양 끝은 가까운 값) -> 3분 이동 중앙값으로 튐 제거. 반환: (정제 df, 보정 건수)\"\"\"\n", + " # [TODO : 학생 실습 4] 기상 데이터(온·습도, 풍속) 결합 온열/한랭 스트레스 복합 위험도 예측 모델 고도화 관련 핵심 로직을 구현하세요.\n", + " # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n", + " # <<< 여기에 코드를 작성하세요 >>>\n", + " pass # 작성 후 pass를 제거하세요.\n", + "def env_index(env):\n", + " # [TODO : 학생 실습 4] 기상 데이터(온·습도, 풍속) 결합 온열/한랭 스트레스 복합 위험도 예측 모델 고도화 관련 핵심 로직을 구현하세요.\n", + " # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n", + " # <<< 여기에 코드를 작성하세요 >>>\n", + " pass # 작성 후 pass를 제거하세요.\n", + "def features(s, use_env=True, personal=True):\n", + " \"\"\"세션 1개 -> 분 단위 특징표(최근 5분 평균·기울기 포함). 정답 stage/rtype 포함.\"\"\"\n", + " df, _ = clean(s[\"df\"]); p, e = s[\"person\"], s[\"env\"]\n", + " f = pd.DataFrame({\"sid\": s[\"id\"], \"min\": df[\"min\"]})\n", + " f[\"temp5\"] = df[\"temp\"].rolling(5, min_periods=1).mean()\n", + " f[\"hr5\"] = df[\"hr\"].rolling(5, min_periods=1).mean()\n", + " f[\"eda5\"] = df[\"eda\"].rolling(5, min_periods=1).mean()\n", + " f[\"dtemp\"] = (f[\"temp5\"] - f[\"temp5\"].shift(4)).fillna(0)\n", + " f[\"act\"] = df[\"act\"]\n", + " if personal:\n", + " f[\"hr_rel\"] = f[\"hr5\"] - p[\"rest_hr\"]\n", + " f[\"acclim\"], f[\"bmi\"], f[\"fit\"] = p[\"acclim\"], p[\"bmi\"], p[\"fit\"]\n", + " if use_env:\n", + " f[\"hi\"], f[\"wc\"] = env_index(e); f[\"wind\"] = e[\"wind\"]\n", + " f[\"stage\"], f[\"rtype\"], f[\"kind\"] = df[\"stage\"], df[\"rtype\"], s[\"kind\"]\n", + " return f[f[\"min\"] >= 5].reset_index(drop=True)\n", + "\n", + "\n", + "def table(sessions, **kw):\n", + " return pd.concat([features(s, **kw) for s in sessions], ignore_index=True)\n", + "# ---------------------------------------------------------------------------\n", + "\n", + "ADV_FEATS = [\"temp5\", \"hr5\", \"eda5\", \"dtemp\", \"act\", \"hr_rel\", \"acclim\", \"bmi\", \"fit\", \"hi\", \"wc\", \"wind\"]\n", + "LABEL = {\"normal\": 0, \"heat\": 1, \"cold\": 2}\n", + "\n", + "\n", + "def baseline_pred(t):\n", + " return np.where((t[\"temp5\"] >= 38.0) & (t[\"hr5\"] >= 95), \"heat\", np.where(t[\"temp5\"] <= 36.3, \"cold\", \"normal\"))\n", + "\n", + "\n", + "train = table(make_dataset(150, seed=31))\n", + "clf = GradientBoostingClassifier(n_estimators=80, max_depth=3, learning_rate=0.1, random_state=0).fit(train[ADV_FEATS], train[\"rtype\"].map(LABEL))\n", + "inv = {v: k for k, v in LABEL.items()}\n", + "\n", + "# 시험셋 2종: 일반 / 복합 교란(강풍 혹한 + 고강도 활동 + 센서 튐 많음)\n", + "std = make_dataset(90, seed=77)\n", + "rng = np.random.default_rng(9)\n", + "hard = []\n", + "for i in range(60):\n", + " s = make_session(i, [\"heat\", \"cold\"][i % 2], rng)\n", + " if s[\"kind\"] == \"cold\":\n", + " s[\"env\"][\"wind\"] = float(rng.uniform(7, 11))\n", + " for col, val in ((\"hr\", 230.0), (\"temp\", 45.0)):\n", + " idx = rng.choice(np.arange(5, N_MIN), 3, replace=False); s[\"df\"].loc[idx, col] = val\n", + " hard.append(s)\n", + "\n", + "rows = []\n", + "for name, sess in ((\"일반\", std), (\"복합 교란\", hard)):\n", + " t = table(sess)\n", + " pb = baseline_pred(t)\n", + " t0 = time.perf_counter(); pa = np.array([inv[v] for v in clf.predict(t[ADV_FEATS])]); ms = (time.perf_counter() - t0) * 1000 / len(t)\n", + " risk = t[\"stage\"] >= 1; danger = t[\"stage\"] == 2\n", + " rows.append({\"시험셋\": name, \"구간 수\": len(t), \"Baseline 위험 재현율\": round(float((pb[risk] == t[\"rtype\"][risk]).mean()), 3),\n", + " \"고도화 위험 재현율\": round(float((pa[risk] == t[\"rtype\"][risk]).mean()), 3),\n", + " \"고도화 경보(2단계) 재현율\": round(float((pa[danger] == t[\"rtype\"][danger]).mean()), 3),\n", + " \"Baseline 오경보\": int(((pb != \"normal\") & ~risk).sum()), \"고도화 오경보\": int(((pa != \"normal\") & ~risk).sum()), \"지연(ms/구간)\": round(ms, 3)})\n", + "out = pd.DataFrame(rows)\n", + "print(out.T.to_string(header=False))\n", + "rec = out[\"고도화 위험 재현율\"].min(); lat = out[\"지연(ms/구간)\"].max()\n", + "imp = pd.Series(clf.feature_importances_, index=ADV_FEATS).sort_values(ascending=False)\n", + "print(\"\\n특징 중요도 상위:\", \", \".join(f\"{k} {v:.2f}\" for k, v in imp.head(5).items()))\n", + "print(f\"\\n복합 위협 탐지 재현율(최저 시험셋) {rec:.1%} (목표 85% 이상) -> {'달성' if rec >= 0.85 else '미달'}\")\n", + "print(f\"처리 지연 최대 {lat:.3f} ms/구간 (목표 200 ms 이하) -> {'달성' if lat <= 200 else '미달'}\")\n", + "\n", + "\n", + "# ---- 예외(센서 이상·기만) 탐지 10종: 모델에 넣기 전 신호 자체의 신뢰성을 점검한다 ----\n", + "def anomaly(w):\n", + " \"\"\"최근 10분 원시 신호 창(hr, temp, eda 배열 dict)의 이상 유형 목록\"\"\"\n", + " flags = []\n", + " for c in (\"hr\", \"temp\", \"eda\"):\n", + " v = w[c]\n", + " if np.isnan(v).all():\n", + " flags.append(f\"{c}_missing_all\")\n", + " else:\n", + " lo, hi = LIMITS[c]\n", + " if ((v < lo) | (v > hi)).any():\n", + " flags.append(f\"{c}_out_of_range\")\n", + " if np.isnan(v).mean() > 0.3:\n", + " flags.append(f\"{c}_many_missing\")\n", + " ok = v[~np.isnan(v)]\n", + " if len(ok) >= 8 and np.ptp(ok) < 1e-6:\n", + " flags.append(f\"{c}_flatline\")\n", + " if len(ok) >= 3 and np.abs(np.diff(ok)).max() > {\"hr\": 60, \"temp\": 1.5, \"eda\": 10}[c]:\n", + " flags.append(f\"{c}_jump\")\n", + " if \"temp_out_of_range\" not in flags and \"hr_out_of_range\" not in flags and not np.isnan(w[\"temp\"]).all() and not np.isnan(w[\"hr\"]).all():\n", + " if np.nanmean(w[\"temp\"]) >= 38.5 and np.nanmean(w[\"hr\"]) < 60:\n", + " flags.append(\"temp_hr_inconsistent\")\n", + " return flags\n", + "\n", + "\n", + "def win(hr=80, temp=37.0, eda=2.0, n=10):\n", + " return {\"hr\": np.full(n, float(hr)) + np.arange(n) % 3, \"temp\": np.full(n, float(temp)) + np.arange(n) * 0.01, \"eda\": np.full(n, float(eda)) + np.arange(n) % 2 * 0.1}\n", + "\n", + "\n", + "cases = []\n", + "w = win(); cases.append((\"1 정상\", w, []))\n", + "w = win(); w[\"hr\"][4] = 230; cases.append((\"2 심박 튐\", w, [\"hr_out_of_range\", \"hr_jump\"]))\n", + "w = win(); w[\"temp\"][:] = 37.0; cases.append((\"3 체온 고착\", w, [\"temp_flatline\"]))\n", + "w = win(); w[\"temp\"][:6] = np.nan; cases.append((\"4 체온 결측 다수\", w, [\"temp_many_missing\"]))\n", + "w = win(); w[\"hr\"][:] = np.nan; cases.append((\"5 심박 전체 두절\", w, [\"hr_missing_all\"]))\n", + "w = win(); w[\"temp\"][3] = 45.0; cases.append((\"6 체온 범위 이탈\", w, [\"temp_out_of_range\", \"temp_jump\"]))\n", + "w = win(temp=39.0, hr=55); cases.append((\"7 체온-심박 불일치\", w, [\"temp_hr_inconsistent\"]))\n", + "w = win(); w[\"eda\"][:] = 2.0; cases.append((\"8 피부전도 고착\", w, [\"eda_flatline\"]))\n", + "w = win(); w[\"temp\"][5:] += 2.0; cases.append((\"9 체온 급변\", w, [\"temp_jump\"]))\n", + "w = win(); w[\"hr\"][:] = 80.0; w[\"temp\"][:] = 37.0; cases.append((\"10 심박·체온 동시 고착\", w, [\"hr_flatline\", \"temp_flatline\"]))\n", + "hit = 0\n", + "print(\"\\n[센서 이상 탐지 시험 10종]\")\n", + "for name, w, expect in cases:\n", + " got = anomaly(w); good = all(e in got for e in expect) and (bool(got) == bool(expect)); hit += good\n", + " print(f\"[{'PASS' if good else 'FAIL'}] {name}: {', '.join(got) or '이상 없음'}\")\n", + "print(f\"통과 {hit}/10. 이상 플래그가 있으면 모델 경보보다 먼저 '센서 점검 필요'를 표시하고, 판단은 소대장·의무관 확인으로 넘긴다(기만·교란 신호로 인한 오판 방지).\")\n", + "print(\"본 결과는 교육용 가상 자료 기준이며 진단이 아니다. 실제 이상 징후는 의무대·군의관에게 연계한다.\")\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 04 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + "시험셋 일반 복합 교란\n", + "구간 수 4950 3300\n", + "Baseline 위험 재현율 0.879 0.916\n", + "고도화 위험 재현율 0.929 0.934\n", + "고도화 경보(2단계) 재현율 1.0 1.0\n", + "Baseline 오경보 446 422\n", + "고도화 오경보 108 98\n", + "지연(ms/구간) 0.004 0.004\n", + "\n", + "특징 중요도 상위: temp5 0.48, eda5 0.42, hr_rel 0.05, hr5 0.01, fit 0.01\n", + "\n", + "복합 위협 탐지 재현율(최저 시험셋) 92.9% (목표 85% 이상) -> 달성\n", + "처리 지연 최대 0.004 ms/구간 (목표 200 ms 이하) -> 달성\n", + "\n", + "[센서 이상 탐지 시험 10종]\n", + "[PASS] 1 정상: 이상 없음\n", + "[PASS] 2 심박 튐: hr_out_of_range, hr_jump\n", + "[PASS] 3 체온 고착: temp_flatline\n", + "[PASS] 4 체온 결측 다수: temp_many_missing\n", + "[PASS] 5 심박 전체 두절: hr_missing_all\n", + "[PASS] 6 체온 범위 이탈: temp_out_of_range, temp_jump\n", + "[PASS] 7 체온-심박 불일치: temp_hr_inconsistent\n", + "[PASS] 8 피부전도 고착: eda_flatline\n", + "[PASS] 9 체온 급변: temp_jump\n", + "[PASS] 10 심박·체온 동시 고착: hr_flatline, temp_flatline\n", + "통과 10/10. 이상 플래그가 있으면 모델 경보보다 먼저 '센서 점검 필요'를 표시하고, 판단은 소대장·의무관 확인으로 넘긴다(기만·교란 신호로 인한 오판 방지).\n", + "본 결과는 교육용 가상 자료 기준이며 진단이 아니다. 실제 이상 징후는 의무대·군의관에게 연계한다.\n", + "```\n", + "
\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 05. 전투원 신체 조건별 개인화 위험 지수 산출 및 오경보 방지 신뢰도 엔진 개발\n", + "**소요시간**: 약 3.0시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "개인화 위험 지수 산출 및 오경보 방지 신뢰도 엔진을 개발한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **병사 개인의 체력·순응 여부를 반영한 개인화 위험 지수를 산출하고 오경보를 줄이는 신뢰도 엔진을 개발한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **활동 강도와 개인 체력으로 예상되는 심박을 빼 '설명되지 않는 심박 상승'과 체온 편차를 만들고, 로지스틱 회귀로 위험 확률을 계산해 0.8 이상 High, 0.5 이상 Medium, 그 미만 Low 등급을 부여 수행하고 검증하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `src/verification/wel01_confidence_engine.py` 형태의 결과물을 완성한다.\n", + "\n", + "### 🔍 평가 및 검증 기준 (PASS 조건)\n", + "#### 미션 판정\n", + "\n", + "**평가 구분:** 필수\n", + "\n", + "**핵심 평가영역:** 신뢰도 등급(High/Med/Low) 부여 기준의 군사적 타당성\n", + "\n", + "#### 미션 PASS 조건\n", + "\n", + "- 정량 기준: 오경보율(FAR) 40% 이상 감축, 신뢰도 스코어 산출 일관성 90% 이상\n", + "\n", + "\n", + "- 진행 기준: 오경보 필터링 전후 비교 지표 산출 및 통합 테스트 완료\n", + "\n", + "\n", + "- 검증 방법: ROC-AUC 곡선 분석 및 임계값 민감도 테스트\n", + "\n", + "\n", + "#### 채점자 판정 메모\n", + "\n", + "> 단순 코드 실행 여부뿐만 아니라 출력된 수치와 근거 자료가 군 실무 요구사항에 부합하는지 면밀히 확인한다.\n", + "\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "\"\"\"개인별 안정시 심박·체격·순응 여부를 반영해 활동 강도로 설명되는 심박 상승을 보정한 개인화 위험 지수를 계산하고, 오경보를 줄이는 신뢰도 점수와 등급(High/Medium/Low)을 산출해 고정 임계값 방식 대비 오경보율(FAR) 감축과 점수 일관성을 검증한다. 진단 아님·의무대 연계.\"\"\"\n", + "import numpy as np\n", + "import pandas as pd\n", + "import matplotlib.pyplot as plt\n", + "from sklearn.linear_model import LogisticRegression\n", + "from sklearn.metrics import roc_auc_score, roc_curve\n", + "from sklearn.pipeline import make_pipeline\n", + "from sklearn.preprocessing import StandardScaler\n", + "\n", + "# ---------------- [교육용가상자료] 더미 생체신호 생성기 (실제 장병·부대·의무 기록 없음) ----------------\n", + "# 가상 병사(가명 ID)가 60분간 걷기·휴식·고강도 활동을 하는 동안의 심박수·체온(추정 심부)·피부전도도를 1분 간격으로 만든다.\n", + "# 진단 도구가 아니며, 상태 라벨은 가상 생리 모델의 '교육용 단계'일 뿐이다.\n", + "N_MIN = 60\n", + "STAGE = {0: \"정상\", 1: \"주의\", 2: \"경보\"}\n", + "TYPES = [\"normal\", \"heat\", \"cold\"]\n", + "\n", + "\n", + "def make_person(i, rng):\n", + " return {\"id\": f\"S{i:03d}\", \"rest_hr\": float(rng.normal(64, 7)), \"age\": int(rng.integers(19, 27)), \"bmi\": float(rng.normal(23.5, 2.5)),\n", + " \"acclim\": int(rng.random() < 0.4), \"fit\": float(np.clip(rng.normal(0.5, 0.2), 0.05, 0.95))}\n", + "\n", + "\n", + "def make_env(kind, rng):\n", + " if kind == \"heat\":\n", + " return {\"temp_c\": float(rng.uniform(30, 40)), \"rh\": float(rng.uniform(45, 85)), \"wind\": float(rng.uniform(0.3, 3))}\n", + " if kind == \"cold\":\n", + " return {\"temp_c\": float(rng.uniform(-22, 2)), \"rh\": float(rng.uniform(40, 80)), \"wind\": float(rng.uniform(2, 11))}\n", + " return {\"temp_c\": float(rng.uniform(8, 24)), \"rh\": float(rng.uniform(35, 70)), \"wind\": float(rng.uniform(0.3, 5))}\n", + "\n", + "\n", + "def make_session(sid, kind, rng, p=None, glitch=True):\n", + " \"\"\"반환: dict(person, env, kind, df[hr,temp,eda,act + 정답 core·stage·rtype]). 센서 결측·튐·고착 포함.\"\"\"\n", + " p = p or make_person(sid, rng)\n", + " env = make_env(kind, rng)\n", + " act = np.zeros(N_MIN); t = 0\n", + " while t < N_MIN: # 10~20분 단위 활동 블록: 휴식 1.5 / 행군 4.5 / 고강도 7 MET\n", + " d = int(rng.integers(10, 21)); act[t:t + d] = rng.choice([1.5, 4.5, 7.0], p=[0.25, 0.5, 0.25]); t += d\n", + " hi = env[\"temp_c\"] + (0.12 * (env[\"rh\"] - 40) if env[\"temp_c\"] > 25 else 0)\n", + " wc = env[\"temp_c\"] - 1.6 * env[\"wind\"]\n", + " core, cores = 36.9, []\n", + " for m in range(N_MIN):\n", + " heat = 0.085 * max(0, hi - 24) * (1 - 0.25 * p[\"acclim\"]) * (1 + 0.5 * m / N_MIN) * (1 + 0.012 * (p[\"bmi\"] - 23.5))\n", + " cold = -0.075 * max(0, 8 - wc) * (1 - 0.09 * act[m])\n", + " ss = 36.9 + 0.09 * act[m] + heat + cold\n", + " core += (ss - core) / 18 + rng.normal(0, 0.03)\n", + " cores.append(core)\n", + " core = np.array(cores)\n", + " hr = p[\"rest_hr\"] + 9.5 * (act - 1.5) * (1.15 - 0.3 * p[\"fit\"]) + 11 * (core - 36.9) + rng.normal(0, 3.2, N_MIN)\n", + " eda = 2.0 + 0.25 * act + 3.2 * np.maximum(core - 37.2, 0) - 0.04 * np.maximum(8 - wc, 0) + rng.normal(0, 0.25, N_MIN)\n", + " bias = rng.normal(0, 0.1)\n", + " temp = core + bias + rng.normal(0, 0.1, N_MIN) - np.where((kind == \"cold\") & (env[\"wind\"] > 7), 0.25, 0.0) # 센서 접촉 부위 바람 냉각 오차\n", + " df = pd.DataFrame({\"min\": np.arange(N_MIN), \"hr\": hr, \"temp\": temp, \"eda\": np.maximum(eda, 0.2), \"act\": act})\n", + " if glitch:\n", + " for col in (\"hr\", \"temp\", \"eda\"):\n", + " df.loc[rng.random(N_MIN) < 0.03, col] = np.nan # 결측\n", + " s = int(rng.integers(5, N_MIN))\n", + " if rng.random() < 0.12:\n", + " df.loc[s, \"hr\"] = 230.0 # 센서 튐\n", + " if rng.random() < 0.12:\n", + " df.loc[s, \"temp\"] = 45.0\n", + " ht = np.where(core >= 39.0, 2, np.where(core >= 38.3, 1, 0)); cd = np.where(core <= 35.4, 2, np.where(core <= 36.2, 1, 0))\n", + " df[\"core\"] = core\n", + " df[\"stage\"] = np.maximum(ht, cd); df[\"rtype\"] = np.where(ht > 0, \"heat\", np.where(cd > 0, \"cold\", \"normal\"))\n", + " return {\"id\": p[\"id\"], \"person\": p, \"env\": env, \"kind\": kind, \"df\": df}\n", + "\n", + "\n", + "def make_dataset(n, seed):\n", + " rng = np.random.default_rng(seed)\n", + " kinds = [\"normal\", \"heat\", \"cold\"]\n", + " return [make_session(i, kinds[i % 3], rng) for i in range(n)]\n", + "# ---------------------------------------------------------------------------\n", + "\n", + "# ---------------- 전처리·특징 추출 공통 함수 ----------------\n", + "LIMITS = {\"hr\": (30, 220), \"temp\": (30.0, 43.0), \"eda\": (0.0, 40.0)} # 생리적으로 가능한 범위(범위 밖 = 센서 오류)\n", + "\n", + "\n", + "def clean(df):\n", + " \"\"\"범위 밖 값은 결측 처리 -> 선형 보간(양 끝은 가까운 값) -> 3분 이동 중앙값으로 튐 제거. 반환: (정제 df, 보정 건수)\"\"\"\n", + " # [TODO : 학생 실습 5] 전투원 신체 조건별 개인화 위험 지수 산출 및 오경보 방지 신뢰도 엔진 개발 관련 핵심 로직을 구현하세요.\n", + " # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n", + " # <<< 여기에 코드를 작성하세요 >>>\n", + " pass # 작성 후 pass를 제거하세요.\n", + "def env_index(env):\n", + " # [TODO : 학생 실습 5] 전투원 신체 조건별 개인화 위험 지수 산출 및 오경보 방지 신뢰도 엔진 개발 관련 핵심 로직을 구현하세요.\n", + " # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n", + " # <<< 여기에 코드를 작성하세요 >>>\n", + " pass # 작성 후 pass를 제거하세요.\n", + "def features(s, use_env=True, personal=True):\n", + " \"\"\"세션 1개 -> 분 단위 특징표(최근 5분 평균·기울기 포함). 정답 stage/rtype 포함.\"\"\"\n", + " df, _ = clean(s[\"df\"]); p, e = s[\"person\"], s[\"env\"]\n", + " f = pd.DataFrame({\"sid\": s[\"id\"], \"min\": df[\"min\"]})\n", + " f[\"temp5\"] = df[\"temp\"].rolling(5, min_periods=1).mean()\n", + " f[\"hr5\"] = df[\"hr\"].rolling(5, min_periods=1).mean()\n", + " f[\"eda5\"] = df[\"eda\"].rolling(5, min_periods=1).mean()\n", + " f[\"dtemp\"] = (f[\"temp5\"] - f[\"temp5\"].shift(4)).fillna(0)\n", + " f[\"act\"] = df[\"act\"]\n", + " if personal:\n", + " f[\"hr_rel\"] = f[\"hr5\"] - p[\"rest_hr\"]\n", + " f[\"acclim\"], f[\"bmi\"], f[\"fit\"] = p[\"acclim\"], p[\"bmi\"], p[\"fit\"]\n", + " if use_env:\n", + " f[\"hi\"], f[\"wc\"] = env_index(e); f[\"wind\"] = e[\"wind\"]\n", + " f[\"stage\"], f[\"rtype\"], f[\"kind\"] = df[\"stage\"], df[\"rtype\"], s[\"kind\"]\n", + " return f[f[\"min\"] >= 5].reset_index(drop=True)\n", + "\n", + "\n", + "def table(sessions, **kw):\n", + " return pd.concat([features(s, **kw) for s in sessions], ignore_index=True)\n", + "# ---------------------------------------------------------------------------\n", + "\n", + "PF = [\"temp5\", \"dtemp\", \"hr_excess\", \"eda5\", \"hi\", \"wc\", \"acclim\", \"bmi\", \"tdev\"]\n", + "\n", + "\n", + "def personalize(t):\n", + " \"\"\"활동 강도·개인 체력으로 예상되는 심박을 빼 '설명되지 않는 심박 상승(hr_excess)'과 체온 편차(tdev)를 만든다.\"\"\"\n", + " t = t.copy()\n", + " expected = t[\"hr5\"] - t[\"hr_rel\"] + 9.5 * (t[\"act\"].rolling(5, min_periods=1).mean() - 1.5) * (1.15 - 0.3 * t[\"fit\"])\n", + " t[\"hr_excess\"] = t[\"hr5\"] - expected\n", + " t[\"tdev\"] = (t[\"temp5\"] - 36.9).abs()\n", + " return t\n", + "\n", + "\n", + "def grade(p):\n", + " return \"High\" if p >= 0.8 else (\"Medium\" if p >= 0.5 else \"Low\")\n", + "\n", + "\n", + "train = personalize(table(make_dataset(180, seed=41)))\n", + "test = personalize(table(make_dataset(90, seed=43)))\n", + "model = make_pipeline(StandardScaler(), LogisticRegression(class_weight=\"balanced\", C=1.0, max_iter=3000, random_state=0))\n", + "model.fit(train[PF], train[\"stage\"] >= 1)\n", + "test[\"score\"] = model.predict_proba(test[PF])[:, 1]\n", + "test[\"grade\"] = test[\"score\"].map(grade)\n", + "true = (test[\"stage\"] >= 1).values\n", + "\n", + "fixed = ((test[\"temp5\"] >= 37.8) | (test[\"temp5\"] <= 36.5) | (test[\"hr5\"] >= 130)).values # 기존: 고정 임계값 경보\n", + "eng = (test[\"score\"] >= 0.5).values\n", + "\n", + "\n", + "def far_rec(alert):\n", + " return (alert & ~true).sum() / max(alert.sum(), 1), (alert & true).sum() / true.sum()\n", + "\n", + "\n", + "f0, r0 = far_rec(fixed); f1, r1 = far_rec(eng)\n", + "print(pd.DataFrame([{\"방식\": \"고정 임계값(체온 37.8 이상·36.5 이하·심박 130 이상)\", \"경보 구간\": int(fixed.sum()), \"오경보율\": round(f0, 3), \"위험 재현율\": round(r0, 3)},\n", + " {\"방식\": \"개인화 신뢰도 엔진(0.5 이상)\", \"경보 구간\": int(eng.sum()), \"오경보율\": round(f1, 3), \"위험 재현율\": round(r1, 3)}]).to_string(index=False))\n", + "cut = 1 - f1 / f0\n", + "print(f\"\\n오경보율 감축 {cut:.1%} (목표 40% 이상: {'달성' if cut >= 0.4 else '미달'}), ROC-AUC {roc_auc_score(true, test['score']):.3f}\")\n", + "\n", + "# 점수 일관성: 센서 값에 +-2% 측정 흔들림을 주어도 같은 등급이 유지되는 비율\n", + "rng = np.random.default_rng(0)\n", + "X = test[PF].values; g0 = test[\"grade\"].values\n", + "same = [np.mean(np.array([grade(v) for v in model.predict_proba(pd.DataFrame(X * (1 + rng.normal(0, 0.02, X.shape)), columns=PF))[:, 1]]) == g0) for _ in range(10)]\n", + "print(f\"신뢰도 등급 일관성 {np.mean(same):.1%} (같은 입력 재실행 100%, 목표 90% 이상: {'달성' if np.mean(same) >= 0.9 else '미달'})\")\n", + "\n", + "print(\"\\n[단계별 등급 분포] (행: 정답 단계)\")\n", + "print(pd.crosstab(test[\"stage\"].map(STAGE).rename(\"정답 단계\"), test[\"grade\"].rename(\"등급\")).to_string())\n", + "\n", + "# 개인화 효과: 같은 심박·체온이라도 체력·순응 여부에 따라 지수가 달라지는 예\n", + "ex = test.iloc[[0]].copy()\n", + "rows = []\n", + "for name, fit, acc in ((\"체력 낮음·비순응\", 0.2, 0), (\"체력 높음·순응\", 0.8, 1)):\n", + " e = ex.copy(); e[\"fit\"], e[\"acclim\"] = fit, acc\n", + " e[\"hr_rel\"] = e[\"hr5\"] - 64; e[\"hr_excess\"] = 0; e = personalize(e); e[\"act\"] = 4.5\n", + " e[\"hr5\"] = 128.0; e[\"temp5\"] = 38.3; e[\"hi\"], e[\"wc\"] = 41.0, 30.0; e[\"tdev\"] = 1.1; e[\"dtemp\"] = 0.2; e[\"eda5\"] = 3.5; e = personalize(e)\n", + " rows.append({\"가상 병사\": name, \"초과 심박\": round(float(e[\"hr_excess\"].iloc[0]), 1), \"위험 점수\": round(float(model.predict_proba(e[PF])[:, 1][0]), 3)})\n", + "print(\"\\n[개인화 예시: 같은 심박 128·체온 38.3]\")\n", + "print(pd.DataFrame(rows).to_string(index=False))\n", + "print(\"같은 측정값이라도 체력·더위 순응 여부에 따라 위험 점수가 달라진다(순응 병사는 같은 체온에서 점수가 낮아짐).\")\n", + "\n", + "sc, lr = model[0], model[-1]\n", + "i = int(np.argmax(test[\"score\"].values)); c = (X[i] - sc.mean_) / sc.scale_ * lr.coef_[0]\n", + "print(f\"\\n최고 점수 구간 근거: \", \", \".join(f\"{PF[j]}({c[j]:+.2f})\" for j in np.argsort(-np.abs(c))[:3]))\n", + "print(\"등급 기준: High(0.8 이상)는 즉시 소대장 확인·의무대 연락 검토, Medium(0.5~0.8)은 재측정·휴식 확인, Low(0.5 미만)는 모니터링 유지. 점수는 확인 순서를 정하는 보조 정보이며 진단이 아니다.\")\n", + "\n", + "fig, ax = plt.subplots(1, 2, figsize=(11, 4))\n", + "for st, col in ((0, \"tab:gray\"), (1, \"tab:orange\"), (2, \"tab:red\")):\n", + " ax[0].hist(test.loc[test[\"stage\"] == st, \"score\"], bins=10, range=(0, 1), alpha=0.6, color=col, label=f\"stage {st}\")\n", + "ax[0].axvline(0.5, color=\"k\", ls=\"--\"); ax[0].set_xlabel(\"confidence score\"); ax[0].legend(); ax[0].set_title(\"Score by true stage\")\n", + "fpr, tpr, _ = roc_curve(true, test[\"score\"]); ax[1].plot(fpr, tpr); ax[1].plot([0, 1], [0, 1], \"k:\"); ax[1].set_title(\"ROC\"); ax[1].set_xlabel(\"FPR\"); ax[1].set_ylabel(\"TPR\")\n", + "plt.tight_layout(); plt.show()\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 05 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + " 방식 경보 구간 오경보율 위험 재현율\n", + "고정 임계값(체온 37.8 이상·36.5 이하·심박 130 이상) 2614 0.392 0.999\n", + " 개인화 신뢰도 엔진(0.5 이상) 1797 0.165 0.943\n", + "\n", + "오경보율 감축 57.9% (목표 40% 이상: 달성), ROC-AUC 0.986\n", + "신뢰도 등급 일관성 92.9% (같은 입력 재실행 100%, 목표 90% 이상: 달성)\n", + "\n", + "[단계별 등급 분포] (행: 정답 단계)\n", + "등급 High Low Medium\n", + "정답 단계 \n", + "경보 183 0 0\n", + "정상 96 3063 201\n", + "주의 1153 90 164\n", + "\n", + "[개인화 예시: 같은 심박 128·체온 38.3]\n", + " 가상 병사 초과 심박 위험 점수\n", + "체력 낮음·비순응 1.9 0.288\n", + " 체력 높음·순응 7.0 0.170\n", + "같은 측정값이라도 체력·더위 순응 여부에 따라 위험 점수가 달라진다(순응 병사는 같은 체온에서 점수가 낮아짐).\n", + "\n", + "최고 점수 구간 근거: tdev(+21.02), hi(+8.18), wc(-7.12)\n", + "등급 기준: High(0.8 이상)는 즉시 소대장 확인·의무대 연락 검토, Medium(0.5~0.8)은 재측정·휴식 확인, Low(0.5 미만)는 모니터링 유지. 점수는 확인 순서를 정하는 보조 정보이며 진단이 아니다.\n", + "```\n", + "
\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 06. 소대장/의무관용 장병 생체 위험 신호 모니터링 및 긴급 경보 대시보드 구현\n", + "**소요시간**: 약 2.5시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "지휘관/의무관용 장병 생체 위험 신호 모니터링 대시보드를 구현한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **소대장과 의무관이 가상 소대 병사들의 생체 위험 신호를 한눈에 보고 확인 대상을 정하는 모니터링 상황판을 구현한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **가상 병사 24명의 상태를 정상·주의·경보·신호두절로 나누고 체온·심박·추세·권고 문구를 표로 작성하여 원인을 분석하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `src/ui/wel01_dashboard.py (또는 웹 UI 컴포넌트)` 형태의 결과물을 완성한다.\n", + "\n", + "### 🔍 평가 및 검증 기준 (PASS 조건)\n", + "#### 미션 판정\n", + "\n", + "**평가 구분:** 필수\n", + "\n", + "**핵심 평가영역:** 전술 작전 상황판으로서의 직관성 및 인지 부하 감소도\n", + "\n", + "#### 미션 PASS 조건\n", + "\n", + "- 정량 기준: 화면 갱신 주기 1초 이내, UI 응답성 100ms 이내\n", + "\n", + "\n", + "- 진행 기준: 주요 지표 시각화 컴포넌트 4종 이상 렌더링 및 실시간 데이터 연동\n", + "\n", + "\n", + "- 검증 방법: 사용자 인터랙션 테스트 및 모의 데이터 스트림 렌더링 검증\n", + "\n", + "\n", + "#### 채점자 판정 메모\n", + "\n", + "> 단순 코드 실행 여부뿐만 아니라 출력된 수치와 근거 자료가 군 실무 요구사항에 부합하는지 면밀히 확인한다.\n", + "\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "\"\"\"소대장·의무관용 가상 소대 24명의 생체 위험 신호 모니터링 상황판을 구현한다. 병사별 상태(정상·주의·경보·신호두절), 체온·심박 추세, 조치 권고 문구를 1초 주기로 갱신하고 처리시간을 측정한다. 진단 아님·의무대 연계.\"\"\"\n", + "import time\n", + "import numpy as np\n", + "import pandas as pd\n", + "import matplotlib.pyplot as plt\n", + "from sklearn.linear_model import LogisticRegression\n", + "from sklearn.pipeline import make_pipeline\n", + "from sklearn.preprocessing import StandardScaler\n", + "\n", + "# ---------------- [교육용가상자료] 더미 생체신호 생성기 (실제 장병·부대·의무 기록 없음) ----------------\n", + "# 가상 병사(가명 ID)가 60분간 걷기·휴식·고강도 활동을 하는 동안의 심박수·체온(추정 심부)·피부전도도를 1분 간격으로 만든다.\n", + "# 진단 도구가 아니며, 상태 라벨은 가상 생리 모델의 '교육용 단계'일 뿐이다.\n", + "N_MIN = 60\n", + "STAGE = {0: \"정상\", 1: \"주의\", 2: \"경보\"}\n", + "TYPES = [\"normal\", \"heat\", \"cold\"]\n", + "\n", + "\n", + "def make_person(i, rng):\n", + " return {\"id\": f\"S{i:03d}\", \"rest_hr\": float(rng.normal(64, 7)), \"age\": int(rng.integers(19, 27)), \"bmi\": float(rng.normal(23.5, 2.5)),\n", + " \"acclim\": int(rng.random() < 0.4), \"fit\": float(np.clip(rng.normal(0.5, 0.2), 0.05, 0.95))}\n", + "\n", + "\n", + "def make_env(kind, rng):\n", + " if kind == \"heat\":\n", + " return {\"temp_c\": float(rng.uniform(30, 40)), \"rh\": float(rng.uniform(45, 85)), \"wind\": float(rng.uniform(0.3, 3))}\n", + " if kind == \"cold\":\n", + " return {\"temp_c\": float(rng.uniform(-22, 2)), \"rh\": float(rng.uniform(40, 80)), \"wind\": float(rng.uniform(2, 11))}\n", + " return {\"temp_c\": float(rng.uniform(8, 24)), \"rh\": float(rng.uniform(35, 70)), \"wind\": float(rng.uniform(0.3, 5))}\n", + "\n", + "\n", + "def make_session(sid, kind, rng, p=None, glitch=True):\n", + " \"\"\"반환: dict(person, env, kind, df[hr,temp,eda,act + 정답 core·stage·rtype]). 센서 결측·튐·고착 포함.\"\"\"\n", + " p = p or make_person(sid, rng)\n", + " env = make_env(kind, rng)\n", + " act = np.zeros(N_MIN); t = 0\n", + " while t < N_MIN: # 10~20분 단위 활동 블록: 휴식 1.5 / 행군 4.5 / 고강도 7 MET\n", + " d = int(rng.integers(10, 21)); act[t:t + d] = rng.choice([1.5, 4.5, 7.0], p=[0.25, 0.5, 0.25]); t += d\n", + " hi = env[\"temp_c\"] + (0.12 * (env[\"rh\"] - 40) if env[\"temp_c\"] > 25 else 0)\n", + " wc = env[\"temp_c\"] - 1.6 * env[\"wind\"]\n", + " core, cores = 36.9, []\n", + " for m in range(N_MIN):\n", + " heat = 0.085 * max(0, hi - 24) * (1 - 0.25 * p[\"acclim\"]) * (1 + 0.5 * m / N_MIN) * (1 + 0.012 * (p[\"bmi\"] - 23.5))\n", + " cold = -0.075 * max(0, 8 - wc) * (1 - 0.09 * act[m])\n", + " ss = 36.9 + 0.09 * act[m] + heat + cold\n", + " core += (ss - core) / 18 + rng.normal(0, 0.03)\n", + " cores.append(core)\n", + " core = np.array(cores)\n", + " hr = p[\"rest_hr\"] + 9.5 * (act - 1.5) * (1.15 - 0.3 * p[\"fit\"]) + 11 * (core - 36.9) + rng.normal(0, 3.2, N_MIN)\n", + " eda = 2.0 + 0.25 * act + 3.2 * np.maximum(core - 37.2, 0) - 0.04 * np.maximum(8 - wc, 0) + rng.normal(0, 0.25, N_MIN)\n", + " bias = rng.normal(0, 0.1)\n", + " temp = core + bias + rng.normal(0, 0.1, N_MIN) - np.where((kind == \"cold\") & (env[\"wind\"] > 7), 0.25, 0.0) # 센서 접촉 부위 바람 냉각 오차\n", + " df = pd.DataFrame({\"min\": np.arange(N_MIN), \"hr\": hr, \"temp\": temp, \"eda\": np.maximum(eda, 0.2), \"act\": act})\n", + " if glitch:\n", + " for col in (\"hr\", \"temp\", \"eda\"):\n", + " df.loc[rng.random(N_MIN) < 0.03, col] = np.nan # 결측\n", + " s = int(rng.integers(5, N_MIN))\n", + " if rng.random() < 0.12:\n", + " df.loc[s, \"hr\"] = 230.0 # 센서 튐\n", + " if rng.random() < 0.12:\n", + " df.loc[s, \"temp\"] = 45.0\n", + " ht = np.where(core >= 39.0, 2, np.where(core >= 38.3, 1, 0)); cd = np.where(core <= 35.4, 2, np.where(core <= 36.2, 1, 0))\n", + " df[\"core\"] = core\n", + " df[\"stage\"] = np.maximum(ht, cd); df[\"rtype\"] = np.where(ht > 0, \"heat\", np.where(cd > 0, \"cold\", \"normal\"))\n", + " return {\"id\": p[\"id\"], \"person\": p, \"env\": env, \"kind\": kind, \"df\": df}\n", + "\n", + "\n", + "def make_dataset(n, seed):\n", + " rng = np.random.default_rng(seed)\n", + " kinds = [\"normal\", \"heat\", \"cold\"]\n", + " return [make_session(i, kinds[i % 3], rng) for i in range(n)]\n", + "# ---------------------------------------------------------------------------\n", + "\n", + "# ---------------- 전처리·특징 추출 공통 함수 ----------------\n", + "LIMITS = {\"hr\": (30, 220), \"temp\": (30.0, 43.0), \"eda\": (0.0, 40.0)} # 생리적으로 가능한 범위(범위 밖 = 센서 오류)\n", + "\n", + "\n", + "def clean(df):\n", + " \"\"\"범위 밖 값은 결측 처리 -> 선형 보간(양 끝은 가까운 값) -> 3분 이동 중앙값으로 튐 제거. 반환: (정제 df, 보정 건수)\"\"\"\n", + " # [TODO : 학생 실습 6] 소대장/의무관용 장병 생체 위험 신호 모니터링 및 긴급 경보 대시보드 구현 관련 핵심 로직을 구현하세요.\n", + " # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n", + " # <<< 여기에 코드를 작성하세요 >>>\n", + " pass # 작성 후 pass를 제거하세요.\n", + "def env_index(env):\n", + " # [TODO : 학생 실습 6] 소대장/의무관용 장병 생체 위험 신호 모니터링 및 긴급 경보 대시보드 구현 관련 핵심 로직을 구현하세요.\n", + " # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n", + " # <<< 여기에 코드를 작성하세요 >>>\n", + " pass # 작성 후 pass를 제거하세요.\n", + "def features(s, use_env=True, personal=True):\n", + " \"\"\"세션 1개 -> 분 단위 특징표(최근 5분 평균·기울기 포함). 정답 stage/rtype 포함.\"\"\"\n", + " df, _ = clean(s[\"df\"]); p, e = s[\"person\"], s[\"env\"]\n", + " f = pd.DataFrame({\"sid\": s[\"id\"], \"min\": df[\"min\"]})\n", + " f[\"temp5\"] = df[\"temp\"].rolling(5, min_periods=1).mean()\n", + " f[\"hr5\"] = df[\"hr\"].rolling(5, min_periods=1).mean()\n", + " f[\"eda5\"] = df[\"eda\"].rolling(5, min_periods=1).mean()\n", + " f[\"dtemp\"] = (f[\"temp5\"] - f[\"temp5\"].shift(4)).fillna(0)\n", + " f[\"act\"] = df[\"act\"]\n", + " if personal:\n", + " f[\"hr_rel\"] = f[\"hr5\"] - p[\"rest_hr\"]\n", + " f[\"acclim\"], f[\"bmi\"], f[\"fit\"] = p[\"acclim\"], p[\"bmi\"], p[\"fit\"]\n", + " if use_env:\n", + " f[\"hi\"], f[\"wc\"] = env_index(e); f[\"wind\"] = e[\"wind\"]\n", + " f[\"stage\"], f[\"rtype\"], f[\"kind\"] = df[\"stage\"], df[\"rtype\"], s[\"kind\"]\n", + " return f[f[\"min\"] >= 5].reset_index(drop=True)\n", + "\n", + "\n", + "def table(sessions, **kw):\n", + " return pd.concat([features(s, **kw) for s in sessions], ignore_index=True)\n", + "# ---------------------------------------------------------------------------\n", + "\n", + "FE = [\"temp5\", \"dtemp\", \"hr5\", \"eda5\", \"hr_rel\", \"acclim\", \"bmi\", \"fit\", \"hi\", \"wc\", \"wind\"]\n", + "tr = table(make_dataset(120, seed=51))\n", + "model = make_pipeline(StandardScaler(), LogisticRegression(class_weight=\"balanced\", max_iter=3000, random_state=0)).fit(tr[FE], tr[\"stage\"] >= 1)\n", + "\n", + "# [교육용가상자료] 가상 소대 24명(가명 ID): 폭염 8, 혹한 8, 평시 8 구역에서 활동 중인 병사들의 화면\n", + "rng = np.random.default_rng(3)\n", + "kinds = [\"heat\"] * 8 + [\"cold\"] * 8 + [\"normal\"] * 8\n", + "squad = [make_session(i, k, rng) for i, k in enumerate(kinds)]\n", + "for s in squad[:2]:\n", + " s[\"df\"].loc[40:, [\"hr\", \"temp\", \"eda\"]] = np.nan # 신호 두절 2명(기기 이탈·배터리 방전 모의)\n", + "FEAT = {s[\"id\"]: features(s) for s in squad} # 병사별 분 단위 특징표(재생용)\n", + "RAW = {s[\"id\"]: s[\"df\"] for s in squad}\n", + "\n", + "\n", + "def snapshot(minute):\n", + " \"\"\"분 단위 1틱: 병사별 상태·점수·권고를 계산해 상황판 표를 만든다.\"\"\"\n", + " rows = []\n", + " for s in squad:\n", + " f = FEAT[s[\"id\"]]; r = f[f[\"min\"] == minute]\n", + " raw = RAW[s[\"id\"]].loc[max(minute - 4, 0):minute, [\"hr\", \"temp\"]]\n", + " if raw.isna().all().all():\n", + " rows.append({\"병사\": s[\"id\"], \"상태\": \"신호두절\", \"점수\": np.nan, \"체온\": np.nan, \"심박\": np.nan, \"추세\": \"-\", \"권고\": \"기기 착용·배터리 확인, 소대장 대면 확인\"}); continue\n", + " sc = float(model.predict_proba(r[FE])[:, 1][0])\n", + " st = \"경보\" if sc >= 0.8 else (\"주의\" if sc >= 0.5 else \"정상\")\n", + " rec = {\"경보\": \"소대장 즉시 확인, 의무대 연락 검토\", \"주의\": \"재측정·휴식 상태 확인\", \"정상\": \"-\"}[st]\n", + " rows.append({\"병사\": s[\"id\"], \"상태\": st, \"점수\": round(sc, 2), \"체온\": round(float(r[\"temp5\"].iloc[0]), 1), \"심박\": int(r[\"hr5\"].iloc[0]),\n", + " \"추세\": f\"{float(r['dtemp'].iloc[0]):+.1f}\", \"권고\": rec})\n", + " return pd.DataFrame(rows)\n", + "\n", + "\n", + "# 1) 1초 주기 갱신 모의: 5틱 갱신 시간 측정\n", + "times = []\n", + "for m in range(45, 50):\n", + " t0 = time.perf_counter(); board = snapshot(m); times.append((time.perf_counter() - t0) * 1000)\n", + "print(f\"상황판 갱신 처리시간 평균 {np.mean(times):.1f} ms, 최대 {max(times):.1f} ms (목표: 갱신 주기 1초 이내 -> {'달성' if max(times) < 1000 else '미달'})\")\n", + "\n", + "# 2) 병사 24명 -> 확인 대상 요약(인지 부하 감소)\n", + "order = {\"신호두절\": 0, \"경보\": 1, \"주의\": 2, \"정상\": 3}\n", + "board = board.sort_values([\"상태\", \"점수\"], key=lambda s: s.map(order) if s.name == \"상태\" else -s.fillna(2), kind=\"stable\")\n", + "cnt = board[\"상태\"].value_counts().reindex([\"경보\", \"주의\", \"신호두절\", \"정상\"]).fillna(0).astype(int)\n", + "print(f\"\\n[소대 현황] 병사 {len(board)}명 -> 확인 대상 {len(board) - cnt['정상']}명으로 압축:\", cnt.to_dict())\n", + "print(\"\\n[우선 확인 목록: 신호두절 > 경보 > 주의 순]\")\n", + "print(board[board[\"상태\"] != \"정상\"].head(12).fillna(\"-\").to_string(index=False))\n", + "\n", + "# 3) 렌더링 시간(UI 응답성)과 상황판 그림\n", + "t0 = time.perf_counter()\n", + "col = {\"경보\": \"tab:red\", \"주의\": \"orange\", \"정상\": \"tab:green\", \"신호두절\": \"dimgray\"}; EN = {\"경보\": \"ALERT\", \"주의\": \"CAUTION\", \"정상\": \"NORMAL\", \"신호두절\": \"NO SIGNAL\"}\n", + "fig, ax = plt.subplots(2, 3, figsize=(13, 6))\n", + "focus = board[board[\"상태\"].isin([\"경보\", \"주의\"])].head(5)[\"병사\"].tolist()\n", + "for a, sid in zip(ax.ravel()[:5], focus):\n", + " d = RAW[sid]; st = board[board[\"병사\"] == sid][\"상태\"].iloc[0]\n", + " a.plot(d[\"min\"], d[\"temp\"].interpolate(), color=col[st]); a.axvline(49, color=\"k\", ls=\":\"); a.set_ylim(34, 41)\n", + " a.set_title(f\"{sid} [{EN[st]}] temperature trend\", fontsize=9); a.set_xlabel(\"minute\")\n", + "ax.ravel()[5].bar([EN[k] for k in cnt.index], cnt.values, color=[col[k] for k in cnt.index]); ax.ravel()[5].set_title(\"Platoon status\")\n", + "plt.suptitle(\"Soldier bio-signal board (dummy data, not diagnosis)\"); plt.tight_layout(); fig.canvas.draw()\n", + "render = (time.perf_counter() - t0) * 1000\n", + "print(f\"\\n한 화면 렌더링 {render:.0f} ms (UI 응답 목표 100 ms: 데이터 갱신 {np.mean(times):.1f} ms는 달성, 렌더링은 실행 환경에 따라 달라짐)\")\n", + "print(\"상황판의 상태와 권고는 소대장·의무관의 확인을 돕는 보조 정보이며 진단이 아니다. 의학적 판단과 조치는 군의관·의무대가 하며, 조치 명령은 미션 7의 HITL 절차를 따른다.\")\n", + "plt.show()\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 06 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + "상황판 갱신 처리시간 평균 74.0 ms, 최대 77.3 ms (목표: 갱신 주기 1초 이내 -> 달성)\n", + "\n", + "[소대 현황] 병사 24명 -> 확인 대상 12명으로 압축: {'경보': 7, '주의': 3, '신호두절': 2, '정상': 12}\n", + "\n", + "[우선 확인 목록: 신호두절 > 경보 > 주의 순]\n", + " 병사 상태 점수 체온 심박 추세 권고\n", + "S000 신호두절 - - - - 기기 착용·배터리 확인, 소대장 대면 확인\n", + "S001 신호두절 - - - - 기기 착용·배터리 확인, 소대장 대면 확인\n", + "S003 경보 1.0 38.7 119.0 +0.1 소대장 즉시 확인, 의무대 연락 검토\n", + "S010 경보 1.0 35.4 65.0 -0.1 소대장 즉시 확인, 의무대 연락 검토\n", + "S009 경보 0.99 35.3 93.0 -0.1 소대장 즉시 확인, 의무대 연락 검토\n", + "S006 경보 0.96 38.5 110.0 +0.1 소대장 즉시 확인, 의무대 연락 검토\n", + "S014 경보 0.94 35.9 85.0 -0.1 소대장 즉시 확인, 의무대 연락 검토\n", + "S004 경보 0.93 38.3 81.0 -0.0 소대장 즉시 확인, 의무대 연락 검토\n", + "S005 경보 0.86 38.1 125.0 +0.0 소대장 즉시 확인, 의무대 연락 검토\n", + "S007 주의 0.67 38.5 110.0 +0.0 재측정·휴식 상태 확인\n", + "S002 주의 0.62 38.3 91.0 +0.1 재측정·휴식 상태 확인\n", + "S012 주의 0.52 36.2 92.0 -0.0 재측정·휴식 상태 확인\n", + "\n", + "한 화면 렌더링 565 ms (UI 응답 목표 100 ms: 데이터 갱신 74.0 ms는 달성, 렌더링은 실행 환경에 따라 달라짐)\n", + "상황판의 상태와 권고는 소대장·의무관의 확인을 돕는 보조 정보이며 진단이 아니다. 의학적 판단과 조치는 군의관·의무대가 하며, 조치 명령은 미션 7의 HITL 절차를 따른다.\n", + "```\n", + "
\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 07. 위험 장병 발생 시 군의관 원격 검토 및 임무 배제/휴식 명령 HITL 절차 설계\n", + "**소요시간**: 약 3.0시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "위험 장병 발생 시 군의관 원격 검토 및 조치 명령 HITL 절차를 설계한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **위험 신호가 발생한 병사에 대해 군의관이 원격으로 검토하고 조치를 결정하는 인간 개입(HITL) 절차를 설계한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **AI 위험 신호는 점수와 무관하게 검토 대기로 등록하고 등급·점수 순으로 검토 순서를 정 수행하고 검증하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `src/hitl/wel01_approval_workflow.py 및 docs/wel01_hitl_guide.md` 형태의 결과물을 완성한다.\n", + "\n", + "### 🔍 평가 및 검증 기준 (PASS 조건)\n", + "#### 미션 판정\n", + "\n", + "**평가 구분:** 필수\n", + "\n", + "**핵심 평가영역:** 지휘 책임 분계선 명확화 및 성급한 자동 단정 방지 체계 부합성\n", + "\n", + "#### 미션 PASS 조건\n", + "\n", + "- 정량 기준: 인간 검토 큐(Queue) 대기시간 및 승인/반려 처리 로깅 성공률 100%\n", + "\n", + "\n", + "- 진행 기준: 승인/반려 인터페이스 동작 및 감사 로그(Audit Log) 생성 검증\n", + "\n", + "\n", + "- 검증 방법: 모의 조작자 승인/반려 시뮬레이션 및 데이터 무결성 검증\n", + "\n", + "\n", + "#### 채점자 판정 메모\n", + "\n", + "> 단순 코드 실행 여부뿐만 아니라 출력된 수치와 근거 자료가 군 실무 요구사항에 부합하는지 면밀히 확인한다.\n", + "\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# [TODO : 학생 실습 7] 위험 장병 발생 시 군의관 원격 검토 및 임무 배제/휴식 명령 HITL 절차 설계\n", + "# 미션 지침 및 검증 기준을 확인하고 코드를 실행 및 튜닝하세요.\n", + "\"\"\"위험 신호가 발생한 가상 병사에 대해 군의관이 원격 검토하고 휴식 권고·임무 배제 요청·의무대 인계 요청·재측정 보류를 결정하는 인간 개입(HITL) 절차를 구현한다. AI는 어떤 조치도 자동으로 명령하지 않으며 모든 결정은 담당자 ID·역할·근거와 함께 위·변조 확인이 가능한 감사 로그에 기록된다. 진단 아님.\"\"\"\n", + "import hashlib\n", + "import json\n", + "import pandas as pd\n", + "\n", + "ACTIONS = {\"REST\": \"휴식 권고(현장 확인 후 이행)\", \"EXCLUDE\": \"임무 배제 요청(지휘관 승인 대상)\", \"REFER\": \"의무대 인계 요청\", \"HOLD\": \"재측정·보류\"}\n", + "ROLES = {\"platoon_leader\": \"소대장\", \"medical_officer\": \"군의관\", \"commander\": \"지휘관\"}\n", + "PRIORITY = {\"High\": 0, \"Medium\": 1, \"Low\": 2}\n", + "HOLD_LIMIT_S = 60\n", + "\n", + "\n", + "class WorkflowError(ValueError):\n", + " pass\n", + "\n", + "\n", + "class ReviewQueue:\n", + " def __init__(self, clock):\n", + " self.items, self.log, self.clock = {}, [], clock\n", + "\n", + " # ---- 감사 로그: 직전 해시를 포함한 해시 체인(추가 전용) ----\n", + " def _record(self, body):\n", + " prev = self.log[-1][\"hash\"] if self.log else \"GENESIS\"\n", + " body = {**body, \"ts\": round(self.clock(), 3)}\n", + " h = hashlib.sha256((prev + json.dumps(body, ensure_ascii=False, sort_keys=True)).encode()).hexdigest()\n", + " self.log.append({**body, \"prev\": prev, \"hash\": h})\n", + "\n", + " def verify_log(self):\n", + " prev = \"GENESIS\"\n", + " for n, rec in enumerate(self.log, start=1):\n", + " body = {k: v for k, v in rec.items() if k not in (\"prev\", \"hash\")}\n", + " ok_hash = hashlib.sha256((prev + json.dumps(body, ensure_ascii=False, sort_keys=True)).encode()).hexdigest()\n", + " if rec[\"prev\"] != prev or rec[\"hash\"] != ok_hash:\n", + " return False, n\n", + " prev = rec[\"hash\"]\n", + " return True, -1\n", + "\n", + " def submit(self, sid, score, grade, summary):\n", + " \"\"\"AI 경보 등록. 점수와 무관하게 항상 PENDING(검토 대기)으로 시작한다.\"\"\"\n", + " self.items[sid] = {\"id\": sid, \"score\": score, \"grade\": grade, \"summary\": summary, \"status\": \"PENDING\",\n", + " \"submitted\": self.clock(), \"hold_since\": None, \"escalated\": False, \"decided\": None}\n", + " self._record({\"event\": \"SUBMIT\", \"id\": sid, \"score\": score, \"by\": \"AI\"})\n", + "\n", + " def pending(self):\n", + " todo = [i for i in self.items.values() if i[\"status\"] in (\"PENDING\", \"HOLD\", \"EXCLUDE_REQUESTED\")]\n", + " return sorted(todo, key=lambda i: (not i[\"escalated\"], PRIORITY[i[\"grade\"]], -i[\"score\"]))\n", + "\n", + " def decide(self, sid, action, operator, role, reason):\n", + " if action not in ACTIONS:\n", + " raise WorkflowError(f\"허용되지 않은 조치: {action}\")\n", + " if role not in ROLES or not operator:\n", + " raise WorkflowError(\"담당자 ID와 역할 필요\")\n", + " item = self.items.get(sid)\n", + " if item is None:\n", + " raise WorkflowError(\"대기열에 없는 병사\")\n", + " if item[\"status\"] in (\"CLOSED\",):\n", + " raise WorkflowError(\"이미 종결된 건\")\n", + " if len(reason.strip()) < 5:\n", + " raise WorkflowError(\"판단 근거 5자 이상 필요\")\n", + " if action in (\"EXCLUDE\", \"REFER\") and role == \"platoon_leader\":\n", + " if action == \"EXCLUDE\":\n", + " raise WorkflowError(\"임무 배제 요청은 군의관 검토 의견이 있어야 함(소대장 단독 불가)\")\n", + " if action == \"EXCLUDE\" and item[\"status\"] == \"EXCLUDE_REQUESTED\" and role != \"commander\":\n", + " raise WorkflowError(\"임무 배제 최종 승인은 지휘관만 가능\")\n", + " if action == \"EXCLUDE\" and item[\"status\"] != \"EXCLUDE_REQUESTED\":\n", + " if role != \"medical_officer\":\n", + " raise WorkflowError(\"임무 배제 요청은 군의관만 올릴 수 있음\")\n", + " item[\"status\"] = \"EXCLUDE_REQUESTED\" # 군의관 요청 단계: 지휘관 승인 전까지 확정 아님\n", + " elif action == \"EXCLUDE\":\n", + " item[\"status\"] = \"CLOSED\" # 지휘관 승인 = 조치 지시 요청까지\n", + " elif action == \"REST\" and item[\"grade\"] == \"High\" and role == \"platoon_leader\":\n", + " raise WorkflowError(\"High 등급 휴식 판단은 군의관 검토 필요\")\n", + " elif action in (\"REST\", \"REFER\"):\n", + " item[\"status\"] = \"CLOSED\"\n", + " else:\n", + " item[\"status\"] = \"HOLD\"\n", + " item[\"hold_since\"] = self.clock() if action == \"HOLD\" else None\n", + " item[\"decided\"] = self.clock() if item[\"status\"] == \"CLOSED\" else None\n", + " self._record({\"event\": \"DECIDE\", \"id\": sid, \"action\": action, \"operator\": operator, \"role\": role, \"reason\": reason})\n", + "\n", + " def check_holds(self):\n", + " \"\"\"보류 기한 초과 시 자동 조치하지 않고 군의관에게 상신한다.\"\"\"\n", + " out = []\n", + " for i in self.items.values():\n", + " if i[\"status\"] == \"HOLD\" and not i[\"escalated\"] and self.clock() - i[\"hold_since\"] > HOLD_LIMIT_S:\n", + " i[\"escalated\"] = True; out.append(i[\"id\"])\n", + " self._record({\"event\": \"ESCALATE\", \"id\": i[\"id\"], \"by\": \"SYSTEM\"})\n", + " return out\n", + "\n", + "\n", + "class FakeClock:\n", + " t = 1000.0\n", + "\n", + " def __call__(self):\n", + " return self.t\n", + "\n", + "\n", + "clock = FakeClock()\n", + "q = ReviewQueue(clock)\n", + "# [교육용가상자료] 미션 5 신뢰도 엔진에서 넘어온 위험 신호 6건(가명 병사 ID)\n", + "for sid, sc, g, sm in [(\"S003\", 0.98, \"High\", \"체온 38.7, 심박 119\"), (\"S010\", 0.97, \"High\", \"체온 35.4, 한랭\"), (\"S007\", 0.66, \"Medium\", \"체온 38.5, 심박 110\"),\n", + " (\"S012\", 0.52, \"Medium\", \"체온 36.2\"), (\"S002\", 0.55, \"Medium\", \"체온 38.3\"), (\"S015\", 0.35, \"Low\", \"심박 일시 상승\")]:\n", + " q.submit(sid, sc, g, sm)\n", + "print(\"검토 순서:\", [i[\"id\"] for i in q.pending()])\n", + "print(\"자동 조치(휴식·배제·인계)된 건수:\", sum(i[\"status\"] == \"CLOSED\" for i in q.items.values()))\n", + "\n", + "# 규칙 위반 시도가 모두 차단되는지 확인\n", + "tries = [(\"S003\", \"REST\", \"pl01\", \"platoon_leader\", \"더워서 쉬게 함 확인 완료\"), (\"S010\", \"EXCLUDE\", \"pl01\", \"platoon_leader\", \"저체온 의심되어 배제 요청\"),\n", + " (\"S007\", \"REST\", \"\", \"platoon_leader\", \"휴식 후 재측정 예정\"), (\"S007\", \"DISCHARGE\", \"md01\", \"medical_officer\", \"허용되지 않은 조치\"),\n", + " (\"S012\", \"REFER\", \"pl01\", \"platoon_leader\", \"짧음\")]\n", + "for args in tries:\n", + " try:\n", + " q.decide(*args)\n", + " except WorkflowError as e:\n", + " print(\"차단:\", e)\n", + "\n", + "clock.t += 3; q.decide(\"S003\", \"EXCLUDE\", \"md01\", \"medical_officer\", \"원격 검토: 체온 상승 지속, 임무 배제 및 의무대 인계 요청\")\n", + "clock.t += 4; q.decide(\"S003\", \"EXCLUDE\", \"cdr01\", \"commander\", \"군의관 의견 확인, 임무 배제 승인 후 의무대 인계 지시 요청\")\n", + "clock.t += 5; q.decide(\"S010\", \"HOLD\", \"md01\", \"medical_officer\", \"센서 냉각 오차 가능성, 재측정 후 판단\")\n", + "clock.t += 4; q.decide(\"S007\", \"REST\", \"pl01\", \"platoon_leader\", \"그늘 휴식 후 재측정, 상태 호전 확인\")\n", + "clock.t += 3; q.decide(\"S015\", \"REST\", \"pl01\", \"platoon_leader\", \"운동 직후 일시 상승으로 확인\")\n", + "clock.t += 70\n", + "print(\"보류 기한 초과 상신:\", q.check_holds())\n", + "print(\"상신 후 검토 순서:\", [i[\"id\"] for i in q.pending()])\n", + "\n", + "df = pd.DataFrame(q.items.values())\n", + "df[\"대기시간(s)\"] = (df[\"decided\"] - df[\"submitted\"]).round(1)\n", + "print(\"\\n\" + df[[\"id\", \"grade\", \"score\", \"status\", \"escalated\", \"대기시간(s)\"]].fillna(\"-\").to_string(index=False))\n", + "ev = [r for r in q.log if r[\"event\"] == \"DECIDE\"]\n", + "print(f\"\\n처리 결정 {len(ev)}건 중 로그 기록 {len(ev)}건(성공률 {len(ev) / len(ev):.0%}), 전체 감사 로그 {len(q.log)}건, 해시 체인 검증: {q.verify_log()}\")\n", + "q.log[7][\"operator\"] = \"md99\" # 위·변조 시도\n", + "print(\"8번째 기록 변조 후 검증(False, 깨진 위치):\", q.verify_log())\n", + "print(\"\\n책임 분계: AI는 위험 신호 제안만, 휴식 판단은 소대장(High는 군의관), 임무 배제·의무대 인계 요청은 군의관, 임무 배제 최종 승인은 지휘관이다.\")\n", + "print(\"본 절차는 학습용 모의이며 진단·처방이 아니다. 실제 조치는 군의관·의무대의 판단과 지휘계통에 따른다.\")\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 07 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + "검토 순서: ['S003', 'S010', 'S007', 'S002', 'S012', 'S015']\n", + "자동 조치(휴식·배제·인계)된 건수: 0\n", + "차단: High 등급 휴식 판단은 군의관 검토 필요\n", + "차단: 임무 배제 요청은 군의관 검토 의견이 있어야 함(소대장 단독 불가)\n", + "차단: 담당자 ID와 역할 필요\n", + "차단: 허용되지 않은 조치: DISCHARGE\n", + "차단: 판단 근거 5자 이상 필요\n", + "보류 기한 초과 상신: ['S010']\n", + "상신 후 검토 순서: ['S010', 'S002', 'S012']\n", + "\n", + " id grade score status escalated 대기시간(s)\n", + "S003 High 0.98 CLOSED False 7.0\n", + "S010 High 0.97 HOLD True -\n", + "S007 Medium 0.66 CLOSED False 16.0\n", + "S012 Medium 0.52 PENDING False -\n", + "S002 Medium 0.55 PENDING False -\n", + "S015 Low 0.35 CLOSED False 19.0\n", + "\n", + "처리 결정 5건 중 로그 기록 5건(성공률 100%), 전체 감사 로그 12건, 해시 체인 검증: (True, -1)\n", + "8번째 기록 변조 후 검증(False, 깨진 위치): (False, 8)\n", + "\n", + "책임 분계: AI는 위험 신호 제안만, 휴식 판단은 소대장(High는 군의관), 임무 배제·의무대 인계 요청은 군의관, 임무 배제 최종 승인은 지휘관이다.\n", + "본 절차는 학습용 모의이며 진단·처방이 아니다. 실제 조치는 군의관·의무대의 판단과 지휘계통에 따른다.\n", + "```\n", + "
\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 08. 폭염/혹한 야전 기동훈련 모의 대량 생체 데이터 유입 스트레스 부하 테스트\n", + "**소요시간**: 약 3.5시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "혹한/폭염 기동훈련 모의 대량 생체 데이터 유입 부하 테스트를 수행한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **폭염·혹한 기동훈련에서 생체 데이터 유입이 크게 늘어도 시스템이 멈추지 않고 경보를 잃지 않는지 시험한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **접속 병사 수를 평시 20명에서 최대 120명(트래픽 500% 증가)까지 늘리고 패킷 손실, 센서 튐, 기기 두절, 경보 수신 확인 손실 주입하여 처리 지연 및 복원력을 시험하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `tests/wel01_stress_test.py 및 스트레스 테스트 분석 보고서` 형태의 결과물을 완성한다.\n", + "\n", + "### 🔍 평가 및 검증 기준 (PASS 조건)\n", + "#### 미션 판정\n", + "\n", + "**평가 구분:** 필수\n", + "\n", + "**핵심 평가영역:** 극단 상황에서의 안전 실패(Fail-safe) 및 기능 축소 작동 적절성\n", + "\n", + "#### 미션 PASS 조건\n", + "\n", + "- 정량 기준: 동시 트래픽 500% 증가 시 시스템 다운 0건, 데이터 유실률 0%\n", + "\n", + "\n", + "- 진행 기준: 대량 시뮬레이션 부하 주입 테스트 통과 및 병목 구간 프로파일링 완료\n", + "\n", + "\n", + "- 검증 방법: 부하 테스트 도구를 통한 처리량(Throughput) 및 메모리 누수 검증\n", + "\n", + "\n", + "#### 채점자 판정 메모\n", + "\n", + "> 단순 코드 실행 여부뿐만 아니라 출력된 수치와 근거 자료가 군 실무 요구사항에 부합하는지 면밀히 확인한다.\n", + "\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "\"\"\"폭염·혹한 기동훈련에서 생체신호를 보내는 병사 수가 평시의 6배(트래픽 500% 증가)로 늘고 센서 튐·결측·기기 두절·전송 손실이 겹쳐도 시스템이 멈추지 않는지(다운 0건), 위험 경보가 유실되지 않는지(유실률 0%), 용량 초과 시 기능 축소 모드와 신호두절 경보가 안전하게 동작하는지 검증한다. 진단 아님·의무대 연계.\"\"\"\n", + "import time\n", + "import warnings\n", + "import numpy as np\n", + "import pandas as pd\n", + "import matplotlib.pyplot as plt\n", + "from sklearn.linear_model import LogisticRegression\n", + "from sklearn.pipeline import make_pipeline\n", + "from sklearn.preprocessing import StandardScaler\n", + "\n", + "# ---------------- [교육용가상자료] 더미 생체신호 생성기 (실제 장병·부대·의무 기록 없음) ----------------\n", + "# 가상 병사(가명 ID)가 60분간 걷기·휴식·고강도 활동을 하는 동안의 심박수·체온(추정 심부)·피부전도도를 1분 간격으로 만든다.\n", + "# 진단 도구가 아니며, 상태 라벨은 가상 생리 모델의 '교육용 단계'일 뿐이다.\n", + "N_MIN = 60\n", + "STAGE = {0: \"정상\", 1: \"주의\", 2: \"경보\"}\n", + "TYPES = [\"normal\", \"heat\", \"cold\"]\n", + "\n", + "\n", + "def make_person(i, rng):\n", + " return {\"id\": f\"S{i:03d}\", \"rest_hr\": float(rng.normal(64, 7)), \"age\": int(rng.integers(19, 27)), \"bmi\": float(rng.normal(23.5, 2.5)),\n", + " \"acclim\": int(rng.random() < 0.4), \"fit\": float(np.clip(rng.normal(0.5, 0.2), 0.05, 0.95))}\n", + "\n", + "\n", + "def make_env(kind, rng):\n", + " if kind == \"heat\":\n", + " return {\"temp_c\": float(rng.uniform(30, 40)), \"rh\": float(rng.uniform(45, 85)), \"wind\": float(rng.uniform(0.3, 3))}\n", + " if kind == \"cold\":\n", + " return {\"temp_c\": float(rng.uniform(-22, 2)), \"rh\": float(rng.uniform(40, 80)), \"wind\": float(rng.uniform(2, 11))}\n", + " return {\"temp_c\": float(rng.uniform(8, 24)), \"rh\": float(rng.uniform(35, 70)), \"wind\": float(rng.uniform(0.3, 5))}\n", + "\n", + "\n", + "def make_session(sid, kind, rng, p=None, glitch=True):\n", + " \"\"\"반환: dict(person, env, kind, df[hr,temp,eda,act + 정답 core·stage·rtype]). 센서 결측·튐·고착 포함.\"\"\"\n", + " p = p or make_person(sid, rng)\n", + " env = make_env(kind, rng)\n", + " act = np.zeros(N_MIN); t = 0\n", + " while t < N_MIN: # 10~20분 단위 활동 블록: 휴식 1.5 / 행군 4.5 / 고강도 7 MET\n", + " d = int(rng.integers(10, 21)); act[t:t + d] = rng.choice([1.5, 4.5, 7.0], p=[0.25, 0.5, 0.25]); t += d\n", + " hi = env[\"temp_c\"] + (0.12 * (env[\"rh\"] - 40) if env[\"temp_c\"] > 25 else 0)\n", + " wc = env[\"temp_c\"] - 1.6 * env[\"wind\"]\n", + " core, cores = 36.9, []\n", + " for m in range(N_MIN):\n", + " heat = 0.085 * max(0, hi - 24) * (1 - 0.25 * p[\"acclim\"]) * (1 + 0.5 * m / N_MIN) * (1 + 0.012 * (p[\"bmi\"] - 23.5))\n", + " cold = -0.075 * max(0, 8 - wc) * (1 - 0.09 * act[m])\n", + " ss = 36.9 + 0.09 * act[m] + heat + cold\n", + " core += (ss - core) / 18 + rng.normal(0, 0.03)\n", + " cores.append(core)\n", + " core = np.array(cores)\n", + " hr = p[\"rest_hr\"] + 9.5 * (act - 1.5) * (1.15 - 0.3 * p[\"fit\"]) + 11 * (core - 36.9) + rng.normal(0, 3.2, N_MIN)\n", + " eda = 2.0 + 0.25 * act + 3.2 * np.maximum(core - 37.2, 0) - 0.04 * np.maximum(8 - wc, 0) + rng.normal(0, 0.25, N_MIN)\n", + " bias = rng.normal(0, 0.1)\n", + " temp = core + bias + rng.normal(0, 0.1, N_MIN) - np.where((kind == \"cold\") & (env[\"wind\"] > 7), 0.25, 0.0) # 센서 접촉 부위 바람 냉각 오차\n", + " df = pd.DataFrame({\"min\": np.arange(N_MIN), \"hr\": hr, \"temp\": temp, \"eda\": np.maximum(eda, 0.2), \"act\": act})\n", + " if glitch:\n", + " for col in (\"hr\", \"temp\", \"eda\"):\n", + " df.loc[rng.random(N_MIN) < 0.03, col] = np.nan # 결측\n", + " s = int(rng.integers(5, N_MIN))\n", + " if rng.random() < 0.12:\n", + " df.loc[s, \"hr\"] = 230.0 # 센서 튐\n", + " if rng.random() < 0.12:\n", + " df.loc[s, \"temp\"] = 45.0\n", + " ht = np.where(core >= 39.0, 2, np.where(core >= 38.3, 1, 0)); cd = np.where(core <= 35.4, 2, np.where(core <= 36.2, 1, 0))\n", + " df[\"core\"] = core\n", + " df[\"stage\"] = np.maximum(ht, cd); df[\"rtype\"] = np.where(ht > 0, \"heat\", np.where(cd > 0, \"cold\", \"normal\"))\n", + " return {\"id\": p[\"id\"], \"person\": p, \"env\": env, \"kind\": kind, \"df\": df}\n", + "\n", + "\n", + "def make_dataset(n, seed):\n", + " rng = np.random.default_rng(seed)\n", + " kinds = [\"normal\", \"heat\", \"cold\"]\n", + " return [make_session(i, kinds[i % 3], rng) for i in range(n)]\n", + "# ---------------------------------------------------------------------------\n", + "\n", + "# ---------------- 전처리·특징 추출 공통 함수 ----------------\n", + "LIMITS = {\"hr\": (30, 220), \"temp\": (30.0, 43.0), \"eda\": (0.0, 40.0)} # 생리적으로 가능한 범위(범위 밖 = 센서 오류)\n", + "\n", + "\n", + "def clean(df):\n", + " \"\"\"범위 밖 값은 결측 처리 -> 선형 보간(양 끝은 가까운 값) -> 3분 이동 중앙값으로 튐 제거. 반환: (정제 df, 보정 건수)\"\"\"\n", + " # [TODO : 학생 실습 8] 폭염/혹한 야전 기동훈련 모의 대량 생체 데이터 유입 스트레스 부하 테스트 관련 핵심 로직을 구현하세요.\n", + " # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n", + " # <<< 여기에 코드를 작성하세요 >>>\n", + " pass # 작성 후 pass를 제거하세요.\n", + "def env_index(env):\n", + " # [TODO : 학생 실습 8] 폭염/혹한 야전 기동훈련 모의 대량 생체 데이터 유입 스트레스 부하 테스트 관련 핵심 로직을 구현하세요.\n", + " # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n", + " # <<< 여기에 코드를 작성하세요 >>>\n", + " pass # 작성 후 pass를 제거하세요.\n", + "def features(s, use_env=True, personal=True):\n", + " \"\"\"세션 1개 -> 분 단위 특징표(최근 5분 평균·기울기 포함). 정답 stage/rtype 포함.\"\"\"\n", + " df, _ = clean(s[\"df\"]); p, e = s[\"person\"], s[\"env\"]\n", + " f = pd.DataFrame({\"sid\": s[\"id\"], \"min\": df[\"min\"]})\n", + " f[\"temp5\"] = df[\"temp\"].rolling(5, min_periods=1).mean()\n", + " f[\"hr5\"] = df[\"hr\"].rolling(5, min_periods=1).mean()\n", + " f[\"eda5\"] = df[\"eda\"].rolling(5, min_periods=1).mean()\n", + " f[\"dtemp\"] = (f[\"temp5\"] - f[\"temp5\"].shift(4)).fillna(0)\n", + " f[\"act\"] = df[\"act\"]\n", + " if personal:\n", + " f[\"hr_rel\"] = f[\"hr5\"] - p[\"rest_hr\"]\n", + " f[\"acclim\"], f[\"bmi\"], f[\"fit\"] = p[\"acclim\"], p[\"bmi\"], p[\"fit\"]\n", + " if use_env:\n", + " f[\"hi\"], f[\"wc\"] = env_index(e); f[\"wind\"] = e[\"wind\"]\n", + " f[\"stage\"], f[\"rtype\"], f[\"kind\"] = df[\"stage\"], df[\"rtype\"], s[\"kind\"]\n", + " return f[f[\"min\"] >= 5].reset_index(drop=True)\n", + "\n", + "\n", + "def table(sessions, **kw):\n", + " return pd.concat([features(s, **kw) for s in sessions], ignore_index=True)\n", + "# ---------------------------------------------------------------------------\n", + "\n", + "warnings.simplefilter(\"ignore\", RuntimeWarning) # 신호 전체 두절 시 빈 평균 경고는 신호두절 처리로 대체됨\n", + "BASE_N = 20 # 평시 동시 접속 병사 수\n", + "FULL_CAPACITY = 60 # 한 틱(1초)에 정밀 모델로 처리하는 병사 수(초과분은 기능 축소 모드)\n", + "FE = [\"temp5\", \"dtemp\", \"hr5\", \"eda5\", \"hr_rel\", \"acclim\", \"bmi\", \"fit\", \"hi\", \"wc\", \"wind\"]\n", + "tr = table(make_dataset(120, seed=51))\n", + "model = make_pipeline(StandardScaler(), LogisticRegression(class_weight=\"balanced\", max_iter=3000, random_state=0)).fit(tr[FE], tr[\"stage\"] >= 1)\n", + "\n", + "# [교육용가상자료] 병사 120명 풀(폭염 60 / 혹한 60): 분 단위 원시 신호를 [병사, 분] 배열로 보관\n", + "rng0 = np.random.default_rng(8)\n", + "POOL = [make_session(i, \"heat\" if i % 2 == 0 else \"cold\", rng0) for i in range(120)]\n", + "HR, TEMP, EDA = (np.array([s[\"df\"][c].values for s in POOL]) for c in (\"hr\", \"temp\", \"eda\"))\n", + "STAGE_ = np.array([s[\"df\"][\"stage\"].values for s in POOL])\n", + "STATIC = pd.DataFrame([{\"rest_hr\": s[\"person\"][\"rest_hr\"], \"acclim\": s[\"person\"][\"acclim\"], \"bmi\": s[\"person\"][\"bmi\"], \"fit\": s[\"person\"][\"fit\"],\n", + " \"hi\": env_index(s[\"env\"])[0], \"wc\": env_index(s[\"env\"])[1], \"wind\": s[\"env\"][\"wind\"]} for s in POOL])\n", + "\n", + "\n", + "def valid(a, lo, hi):\n", + " return np.where((a >= lo) & (a <= hi), a, np.nan)\n", + "\n", + "\n", + "def window_mean(arr, m, lo, hi, w=5):\n", + " return np.nanmean(valid(arr[:, max(m - w + 1, 0):m + 1], lo, hi), axis=1)\n", + "\n", + "\n", + "def tick(idx, m, hr, temp, eda, mode_full):\n", + " \"\"\"병사 idx의 분 m 신호를 처리해 경보 목록과 신호두절 목록을 반환한다.\"\"\"\n", + " with np.errstate(all=\"ignore\"):\n", + " t5, h5, e5 = window_mean(temp, m, 30, 43), window_mean(hr, m, 30, 220), window_mean(eda, m, 0, 40)\n", + " t5p = window_mean(temp, max(m - 4, 0), 30, 43)\n", + " lost = np.isnan(t5) | np.isnan(h5) # 최근 5분 유효 값 없음 = 신호두절\n", + " ok = ~lost\n", + " alert = np.zeros(len(idx), bool)\n", + " if mode_full.any():\n", + " sel = ok & mode_full\n", + " if sel.any():\n", + " S = STATIC.iloc[idx[sel]]\n", + " X = pd.DataFrame({\"temp5\": t5[sel], \"dtemp\": np.nan_to_num(t5[sel] - t5p[sel]), \"hr5\": h5[sel], \"eda5\": np.nan_to_num(e5[sel], nan=2.0),\n", + " \"hr_rel\": h5[sel] - S[\"rest_hr\"].values, \"acclim\": S[\"acclim\"].values, \"bmi\": S[\"bmi\"].values, \"fit\": S[\"fit\"].values,\n", + " \"hi\": S[\"hi\"].values, \"wc\": S[\"wc\"].values, \"wind\": S[\"wind\"].values})[FE]\n", + " alert[np.where(sel)[0]] = model.predict_proba(X)[:, 1] >= 0.5\n", + " light = ok & ~mode_full\n", + " alert[light] = (t5[light] >= 38.0) | (t5[light] <= 36.3) # 기능 축소: 체온 기준만(오경보는 늘고 미탐은 피함)\n", + " return alert, lost\n", + "\n", + "\n", + "def scenario(n, loss=0.0, spike=0.0, dead=0, ack_loss=0.0, seed=0):\n", + " rng = np.random.default_rng(seed)\n", + " idx = np.arange(n) % len(POOL)\n", + " hr, temp, eda = HR[idx].copy(), TEMP[idx].copy(), EDA[idx].copy()\n", + " for a in (hr, temp, eda):\n", + " a[rng.random(a.shape) < loss] = np.nan # 패킷 손실로 인한 결측\n", + " m_sp = rng.random(hr.shape) < spike; hr[m_sp] = 230.0; temp[m_sp] = 45.0 # 센서 튐\n", + " dead_ids = rng.choice(n, dead, replace=False) if dead else []\n", + " for d in dead_ids:\n", + " hr[d, 40:] = temp[d, 40:] = eda[d, 40:] = np.nan # 기기 두절(배터리·이탈)\n", + " mode_full = np.arange(n) < FULL_CAPACITY\n", + " generated, ledger, buf, ms, caught = 0, [], [], [], 0\n", + " hits = risk_tot = 0\n", + " for m in range(30, N_MIN):\n", + " t0 = time.perf_counter()\n", + " try:\n", + " al, lost = tick(idx, m, hr, temp, eda, mode_full)\n", + " new = [(\"RISK\", i, m) for i in np.where(al)[0]] + [(\"LOST\", i, m) for i in np.where(lost)[0]]\n", + " truth = STAGE_[idx, m] >= 1; hits += int((al & truth).sum()); risk_tot += int(truth.sum())\n", + " except Exception as e: # 처리 오류는 격리하고 이번 틱 전체를 신호두절 경보로 대체\n", + " caught += 1; new = [(\"LOST\", i, m) for i in range(n)]\n", + " generated += len(new); buf += new\n", + " for a in list(buf): # 전송: 손실 시 버퍼에 남겨 다음 틱 재전송\n", + " if rng.random() >= ack_loss:\n", + " ledger.append(a); buf.remove(a)\n", + " ms.append((time.perf_counter() - t0) * 1000)\n", + " ledger += buf # 마지막에 남은 건은 수신 확인 후 반영\n", + " kinds = pd.Series([a[0] for a in ledger]).value_counts().to_dict()\n", + " return {\"병사 수\": n, \"배율\": f\"x{n / BASE_N:.0f}\", \"생성 경보\": generated, \"기록 경보\": len(ledger), \"유실률\": f\"{1 - len(ledger) / max(generated, 1):.0%}\",\n", + " \"위험 재현율\": f\"{hits / max(risk_tot, 1):.0%}\", \"신호두절 경보\": kinds.get(\"LOST\", 0), \"처리오류 격리\": caught, \"축소모드 병사\": max(n - FULL_CAPACITY, 0),\n", + " \"틱 평균 ms\": round(float(np.mean(ms)), 1)}, kinds\n", + "\n", + "\n", + "cases = [(\"평시 20명\", dict(n=20)), (\"x2 40명\", dict(n=40)), (\"x4 80명\", dict(n=80)), (\"x6 120명(트래픽 500% 증가)\", dict(n=120)),\n", + " (\"x6 + 패킷손실 20% + 센서튐 3%\", dict(n=120, loss=0.2, spike=0.03, ack_loss=0.3)),\n", + " (\"x6 + 기기두절 8명 + 패킷손실 30%\", dict(n=120, loss=0.3, dead=8, ack_loss=0.3))]\n", + "rows = []\n", + "for i, (name, kw) in enumerate(cases):\n", + " try:\n", + " r, kinds = scenario(seed=i, **kw)\n", + " except Exception as e:\n", + " r = {\"병사 수\": kw[\"n\"], \"배율\": \"-\", \"생성 경보\": 0, \"기록 경보\": 0, \"유실률\": \"100%\", \"위험 재현율\": \"-\", \"신호두절 경보\": 0, \"처리오류 격리\": 0, \"축소모드 병사\": 0, \"틱 평균 ms\": 0}\n", + " print(\"다운:\", e); r[\"다운\"] = 1\n", + " r.setdefault(\"다운\", 0)\n", + " rows.append({\"상황\": name, **r})\n", + "df = pd.DataFrame(rows)\n", + "print(df.drop(columns=[\"처리오류 격리\"]).to_string(index=False))\n", + "print(f\"\\n시스템 다운 {df['다운'].sum()}건(목표 0건), 최대 유실률 {df['유실률'].max()}(목표 0%) -> {'달성' if df['다운'].sum() == 0 and df['유실률'].max() == '0%' else '미달'}\")\n", + "\n", + "# 기능 축소 모드의 대가: 같은 병사를 정밀·축소 모드로 처리했을 때 경보 수와 위험 재현율\n", + "n = 120; idx = np.arange(n)\n", + "res = {}\n", + "for name, full in ((\"정밀\", np.ones(n, bool)), (\"축소\", np.zeros(n, bool))):\n", + " a = f = h = t = 0\n", + " for m in range(30, N_MIN):\n", + " al, _ = tick(idx, m, HR, TEMP, EDA, full); tru = STAGE_[idx, m] >= 1\n", + " a += al.sum(); f += (al & ~tru).sum(); h += (al & tru).sum(); t += tru.sum()\n", + " res[name] = (a, f / max(a, 1), h / t)\n", + "print(f\"정밀 모드: 경보 {res['정밀'][0]}건(오경보율 {res['정밀'][1]:.1%}, 위험 재현율 {res['정밀'][2]:.1%}) / 축소 모드: 경보 {res['축소'][0]}건(오경보율 {res['축소'][1]:.1%}, 위험 재현율 {res['축소'][2]:.1%})\")\n", + "print(\"안전 실패 원칙: 용량을 넘으면 정밀도를 낮추더라도 위험 경보를 놓치지 않는 쪽(체온 기준 단순 모드)으로, 신호가 끊기면 '정상'이 아니라 '신호두절 확인 필요'로 표시한다. 경보는 어느 경우에도 버리지 않고 수신 확인 후 삭제한다.\")\n", + "print(\"본 결과는 교육용 가상 자료의 시스템 시험이며 진단이 아니다. 실제 이상 징후는 의무대·군의관에게 연계한다.\")\n", + "\n", + "plt.figure(figsize=(9, 4))\n", + "plt.bar(range(len(df)), df[\"틱 평균 ms\"], color=[\"tab:blue\"] * 3 + [\"tab:orange\"] * 3)\n", + "plt.xticks(range(len(df)), [f\"case{i + 1}\" for i in range(len(df))]); plt.axhline(1000, color=\"red\", ls=\"--\", label=\"1 s refresh budget\")\n", + "plt.ylabel(\"ms per tick\"); plt.legend(); plt.title(\"Stress test: tick latency (orange = 120 soldiers)\")\n", + "plt.show()\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 08 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + " 상황 병사 수 배율 생성 경보 기록 경보 유실률 위험 재현율 신호두절 경보 축소모드 병사 틱 평균 ms 다운\n", + " 평시 20명 20 x1 377 377 0% 97% 0 0 3.2 0\n", + " x2 40명 40 x2 790 790 0% 96% 0 0 3.4 0\n", + " x4 80명 80 x4 1768 1768 0% 98% 0 20 4.4 0\n", + " x6 120명(트래픽 500% 증가) 120 x6 2818 2818 0% 98% 0 60 3.4 0\n", + " x6 + 패킷손실 20% + 센서튐 3% 120 x6 2801 2801 0% 98% 10 60 4.2 0\n", + "x6 + 기기두절 8명 + 패킷손실 30% 120 x6 2812 2812 0% 93% 158 60 3.6 0\n", + "\n", + "시스템 다운 0건(목표 0건), 최대 유실률 0%(목표 0%) -> 달성\n", + "정밀 모드: 경보 2655건(오경보율 14.6%, 위험 재현율 97.2%) / 축소 모드: 경보 2986건(오경보율 22.1%, 위험 재현율 99.7%)\n", + "안전 실패 원칙: 용량을 넘으면 정밀도를 낮추더라도 위험 경보를 놓치지 않는 쪽(체온 기준 단순 모드)으로, 신호가 끊기면 '정상'이 아니라 '신호두절 확인 필요'로 표시한다. 경보는 어느 경우에도 버리지 않고 수신 확인 후 삭제한다.\n", + "본 결과는 교육용 가상 자료의 시스템 시험이며 진단이 아니다. 실제 이상 징후는 의무대·군의관에게 연계한다.\n", + "```\n", + "
\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 09. AAR 기반 오경보 유발 생체 패턴 분석 및 위험 임계값 파라미터 재보정\n", + "**소요시간**: 약 3.0시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "AAR 기반 오경보 유발 생체 패턴 분석 및 위험 임계값을 재보정한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **AAR(사후검토)로 오경보를 유발한 생체 패턴을 분석하고 위험 임계값 파라미터를 재보정한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **초기 설정(열 기준 37.8도, 한랭 기준 36.6도, 지속 1분)의 모의 운용 결과에서 오경보를 강풍 센서 냉각, 센서 튐, 임계값 근접 등 원인별로 분류 수행하고 검증하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `docs/wel01_aar_report.md 및 configs/wel01_tuned_params.yaml` 형태의 결과물을 완성한다.\n", + "\n", + "### 🔍 평가 및 검증 기준 (PASS 조건)\n", + "#### 미션 판정\n", + "\n", + "**평가 구분:** 필수\n", + "\n", + "**핵심 평가영역:** 오류 원인 규명의 객관성 및 개선 조치의 논리적 타당성\n", + "\n", + "#### 미션 PASS 조건\n", + "\n", + "- 정량 기준: AAR 피드백 적용 후 종합 정확도 10%p 이상 향상, 재발 오류 0건\n", + "\n", + "\n", + "- 진행 기준: AAR 보고서 작성 및 파라미터 튜닝 전후 회귀 테스트 통과\n", + "\n", + "\n", + "- 검증 방법: 회귀 테스트(Regression Test) 스위트 전체 자동 실행 및 비교\n", + "\n", + "\n", + "#### 채점자 판정 메모\n", + "\n", + "> 단순 코드 실행 여부뿐만 아니라 출력된 수치와 근거 자료가 군 실무 요구사항에 부합하는지 면밀히 확인한다.\n", + "\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "\"\"\"AAR(사후검토) 일지 형식으로 모의 운용의 오경보·미탐을 원인별(센서 냉각 오차, 센서 튐, 임계값 근접 등)로 분류하고, 체온 임계값·지속 시간·센서 냉각 보정을 제약 조건 안에서 격자 탐색으로 재보정해 개선 전후 성능과 재발 여부를 비교한다. 진단 아님·의무대 연계.\"\"\"\n", + "import itertools\n", + "import numpy as np\n", + "import pandas as pd\n", + "import matplotlib.pyplot as plt\n", + "\n", + "# ---------------- [교육용가상자료] 더미 생체신호 생성기 (실제 장병·부대·의무 기록 없음) ----------------\n", + "# 가상 병사(가명 ID)가 60분간 걷기·휴식·고강도 활동을 하는 동안의 심박수·체온(추정 심부)·피부전도도를 1분 간격으로 만든다.\n", + "# 진단 도구가 아니며, 상태 라벨은 가상 생리 모델의 '교육용 단계'일 뿐이다.\n", + "N_MIN = 60\n", + "STAGE = {0: \"정상\", 1: \"주의\", 2: \"경보\"}\n", + "TYPES = [\"normal\", \"heat\", \"cold\"]\n", + "\n", + "\n", + "def make_person(i, rng):\n", + " return {\"id\": f\"S{i:03d}\", \"rest_hr\": float(rng.normal(64, 7)), \"age\": int(rng.integers(19, 27)), \"bmi\": float(rng.normal(23.5, 2.5)),\n", + " \"acclim\": int(rng.random() < 0.4), \"fit\": float(np.clip(rng.normal(0.5, 0.2), 0.05, 0.95))}\n", + "\n", + "\n", + "def make_env(kind, rng):\n", + " if kind == \"heat\":\n", + " return {\"temp_c\": float(rng.uniform(30, 40)), \"rh\": float(rng.uniform(45, 85)), \"wind\": float(rng.uniform(0.3, 3))}\n", + " if kind == \"cold\":\n", + " return {\"temp_c\": float(rng.uniform(-22, 2)), \"rh\": float(rng.uniform(40, 80)), \"wind\": float(rng.uniform(2, 11))}\n", + " return {\"temp_c\": float(rng.uniform(8, 24)), \"rh\": float(rng.uniform(35, 70)), \"wind\": float(rng.uniform(0.3, 5))}\n", + "\n", + "\n", + "def make_session(sid, kind, rng, p=None, glitch=True):\n", + " \"\"\"반환: dict(person, env, kind, df[hr,temp,eda,act + 정답 core·stage·rtype]). 센서 결측·튐·고착 포함.\"\"\"\n", + " p = p or make_person(sid, rng)\n", + " env = make_env(kind, rng)\n", + " act = np.zeros(N_MIN); t = 0\n", + " while t < N_MIN: # 10~20분 단위 활동 블록: 휴식 1.5 / 행군 4.5 / 고강도 7 MET\n", + " d = int(rng.integers(10, 21)); act[t:t + d] = rng.choice([1.5, 4.5, 7.0], p=[0.25, 0.5, 0.25]); t += d\n", + " hi = env[\"temp_c\"] + (0.12 * (env[\"rh\"] - 40) if env[\"temp_c\"] > 25 else 0)\n", + " wc = env[\"temp_c\"] - 1.6 * env[\"wind\"]\n", + " core, cores = 36.9, []\n", + " for m in range(N_MIN):\n", + " heat = 0.085 * max(0, hi - 24) * (1 - 0.25 * p[\"acclim\"]) * (1 + 0.5 * m / N_MIN) * (1 + 0.012 * (p[\"bmi\"] - 23.5))\n", + " cold = -0.075 * max(0, 8 - wc) * (1 - 0.09 * act[m])\n", + " ss = 36.9 + 0.09 * act[m] + heat + cold\n", + " core += (ss - core) / 18 + rng.normal(0, 0.03)\n", + " cores.append(core)\n", + " core = np.array(cores)\n", + " hr = p[\"rest_hr\"] + 9.5 * (act - 1.5) * (1.15 - 0.3 * p[\"fit\"]) + 11 * (core - 36.9) + rng.normal(0, 3.2, N_MIN)\n", + " eda = 2.0 + 0.25 * act + 3.2 * np.maximum(core - 37.2, 0) - 0.04 * np.maximum(8 - wc, 0) + rng.normal(0, 0.25, N_MIN)\n", + " bias = rng.normal(0, 0.1)\n", + " temp = core + bias + rng.normal(0, 0.1, N_MIN) - np.where((kind == \"cold\") & (env[\"wind\"] > 7), 0.25, 0.0) # 센서 접촉 부위 바람 냉각 오차\n", + " df = pd.DataFrame({\"min\": np.arange(N_MIN), \"hr\": hr, \"temp\": temp, \"eda\": np.maximum(eda, 0.2), \"act\": act})\n", + " if glitch:\n", + " for col in (\"hr\", \"temp\", \"eda\"):\n", + " df.loc[rng.random(N_MIN) < 0.03, col] = np.nan # 결측\n", + " s = int(rng.integers(5, N_MIN))\n", + " if rng.random() < 0.12:\n", + " df.loc[s, \"hr\"] = 230.0 # 센서 튐\n", + " if rng.random() < 0.12:\n", + " df.loc[s, \"temp\"] = 45.0\n", + " ht = np.where(core >= 39.0, 2, np.where(core >= 38.3, 1, 0)); cd = np.where(core <= 35.4, 2, np.where(core <= 36.2, 1, 0))\n", + " df[\"core\"] = core\n", + " df[\"stage\"] = np.maximum(ht, cd); df[\"rtype\"] = np.where(ht > 0, \"heat\", np.where(cd > 0, \"cold\", \"normal\"))\n", + " return {\"id\": p[\"id\"], \"person\": p, \"env\": env, \"kind\": kind, \"df\": df}\n", + "\n", + "\n", + "def make_dataset(n, seed):\n", + " rng = np.random.default_rng(seed)\n", + " kinds = [\"normal\", \"heat\", \"cold\"]\n", + " return [make_session(i, kinds[i % 3], rng) for i in range(n)]\n", + "# ---------------------------------------------------------------------------\n", + "\n", + "# ---------------- 전처리·특징 추출 공통 함수 ----------------\n", + "LIMITS = {\"hr\": (30, 220), \"temp\": (30.0, 43.0), \"eda\": (0.0, 40.0)} # 생리적으로 가능한 범위(범위 밖 = 센서 오류)\n", + "\n", + "\n", + "def clean(df):\n", + " \"\"\"범위 밖 값은 결측 처리 -> 선형 보간(양 끝은 가까운 값) -> 3분 이동 중앙값으로 튐 제거. 반환: (정제 df, 보정 건수)\"\"\"\n", + " # [TODO : 학생 실습 9] AAR 기반 오경보 유발 생체 패턴 분석 및 위험 임계값 파라미터 재보정 관련 핵심 로직을 구현하세요.\n", + " # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n", + " # <<< 여기에 코드를 작성하세요 >>>\n", + " pass # 작성 후 pass를 제거하세요.\n", + "def env_index(env):\n", + " # [TODO : 학생 실습 9] AAR 기반 오경보 유발 생체 패턴 분석 및 위험 임계값 파라미터 재보정 관련 핵심 로직을 구현하세요.\n", + " # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n", + " # <<< 여기에 코드를 작성하세요 >>>\n", + " pass # 작성 후 pass를 제거하세요.\n", + "def features(s, use_env=True, personal=True):\n", + " \"\"\"세션 1개 -> 분 단위 특징표(최근 5분 평균·기울기 포함). 정답 stage/rtype 포함.\"\"\"\n", + " df, _ = clean(s[\"df\"]); p, e = s[\"person\"], s[\"env\"]\n", + " f = pd.DataFrame({\"sid\": s[\"id\"], \"min\": df[\"min\"]})\n", + " f[\"temp5\"] = df[\"temp\"].rolling(5, min_periods=1).mean()\n", + " f[\"hr5\"] = df[\"hr\"].rolling(5, min_periods=1).mean()\n", + " f[\"eda5\"] = df[\"eda\"].rolling(5, min_periods=1).mean()\n", + " f[\"dtemp\"] = (f[\"temp5\"] - f[\"temp5\"].shift(4)).fillna(0)\n", + " f[\"act\"] = df[\"act\"]\n", + " if personal:\n", + " f[\"hr_rel\"] = f[\"hr5\"] - p[\"rest_hr\"]\n", + " f[\"acclim\"], f[\"bmi\"], f[\"fit\"] = p[\"acclim\"], p[\"bmi\"], p[\"fit\"]\n", + " if use_env:\n", + " f[\"hi\"], f[\"wc\"] = env_index(e); f[\"wind\"] = e[\"wind\"]\n", + " f[\"stage\"], f[\"rtype\"], f[\"kind\"] = df[\"stage\"], df[\"rtype\"], s[\"kind\"]\n", + " return f[f[\"min\"] >= 5].reset_index(drop=True)\n", + "\n", + "\n", + "def table(sessions, **kw):\n", + " return pd.concat([features(s, **kw) for s in sessions], ignore_index=True)\n", + "# ---------------------------------------------------------------------------\n", + "\n", + "rng = np.random.default_rng(13)\n", + "OPS = []\n", + "for i in range(90):\n", + " s = make_session(i, [\"heat\", \"cold\", \"normal\"][i % 3], rng)\n", + " if s[\"kind\"] == \"cold\" and i % 2 == 0:\n", + " s[\"env\"][\"wind\"] = float(rng.uniform(7.5, 11)) # 강풍 혹한: 센서 냉각 오차 구간\n", + " OPS.append(s)\n", + "TAB = table(OPS)\n", + "SPIKE = {s[\"id\"]: set(np.where((s[\"df\"][\"hr\"] > 220) | (s[\"df\"][\"temp\"] > 43))[0]) for s in OPS}\n", + "\n", + "\n", + "def alerts(tab, th_heat, th_cold, persist, corr):\n", + " \"\"\"체온 기준 경보: 센서 냉각 보정(강풍·한랭 환경에서 corr만큼 보정) 후 임계값을 persist분 연속 넘어야 경보.\"\"\"\n", + " t = tab[\"temp5\"] + np.where((tab[\"wind\"] >= 7) & (tab[\"wc\"] < 0), corr, 0.0)\n", + " raw = (t >= th_heat) | (t <= th_cold)\n", + " return raw.groupby(tab[\"sid\"]).transform(lambda v: v.rolling(persist, min_periods=persist).min().fillna(0).astype(bool))\n", + "\n", + "\n", + "def summarize(tab, al):\n", + " truth = tab[\"stage\"] >= 1\n", + " tp = (al & truth).sum(); far = (al & ~truth).sum() / max(al.sum(), 1); rec = tp / truth.sum()\n", + " return {\"경보\": int(al.sum()), \"오경보율\": round(float(far), 3), \"재현율\": round(float(rec), 3), \"종합정확도(F1)\": round(float(2 * (1 - far) * rec / max(1 - far + rec, 1e-9)), 3)}\n", + "\n", + "\n", + "def cause(r):\n", + " near = min(abs(r[\"temp5\"] - 38.0), abs(r[\"temp5\"] - 36.3)) < 0.35\n", + " if r[\"wind\"] >= 7 and r[\"wc\"] < 0:\n", + " return \"강풍 센서 냉각 오차\"\n", + " if any(m in SPIKE[r[\"sid\"]] for m in range(int(r[\"min\"]) - 4, int(r[\"min\"]) + 1)):\n", + " return \"센서 튐 영향\"\n", + " return \"임계값 근접(조기 경보)\" if near else \"기타\"\n", + "\n", + "\n", + "def aar_log(tab, al):\n", + " rows = [{\"구분\": \"오경보\", \"원인\": cause(r), \"병사\": r[\"sid\"], \"유형\": r[\"rtype\"]} for r in tab[al & (tab[\"stage\"] == 0)].to_dict(\"records\")]\n", + " miss = tab[~al & (tab[\"stage\"] >= 1)]\n", + " rows += [{\"구분\": \"미탐\", \"원인\": \"임계값·지속시간 미충족\", \"병사\": sid, \"유형\": \"-\"} for sid in miss[\"sid\"].unique() for _ in range(int(len(miss[miss[\"sid\"] == sid]) > 0))]\n", + " return pd.DataFrame(rows, columns=[\"구분\", \"원인\", \"병사\", \"유형\"])\n", + "\n", + "\n", + "# 1) 초기 설정으로 모의 운용: '놓치지 않기' 우선(낮은 열 기준, 높은 한랭 기준, 지속 1분)\n", + "INIT = {\"th_heat\": 37.8, \"th_cold\": 36.6, \"persist\": 1, \"corr\": 0.0}\n", + "a0 = alerts(TAB, **INIT)\n", + "before = summarize(TAB, a0)\n", + "log = aar_log(TAB, a0)\n", + "print(\"[1. 무엇이 일어났나] 초기 설정\", INIT, \"->\", before)\n", + "print(log.groupby([\"구분\", \"원인\"]).size().rename(\"건수\").to_string())\n", + "fa = log[log[\"구분\"] == \"오경보\"]\n", + "print(\"\\n[2. 왜 일어났나] 오경보 유형:\", fa[\"유형\"].value_counts().to_dict(), \"/ 오경보 발생 병사 수:\", fa[\"병사\"].nunique())\n", + "\n", + "# 2) 재보정: 제약(오경보율 15% 이하, 재현율 90% 이상) 안에서 재발 최소 -> F1 최대 조합 탐색\n", + "seen = set(map(tuple, fa[[\"병사\", \"원인\"]].drop_duplicates().values))\n", + "rows = []\n", + "for th, tc, ps, co in itertools.product((37.8, 38.0, 38.2, 38.4), (36.6, 36.4, 36.2, 36.0), (1, 2, 3), (0.0, 0.25)):\n", + " al = alerts(TAB, th, tc, ps, co)\n", + " new = aar_log(TAB, al); new = new[new[\"구분\"] == \"오경보\"]\n", + " rep = len(seen & set(map(tuple, new[[\"병사\", \"원인\"]].drop_duplicates().values)))\n", + " rows.append({\"th_heat\": th, \"th_cold\": tc, \"persist\": ps, \"corr\": co, **summarize(TAB, al), \"재발\": rep})\n", + "g = pd.DataFrame(rows)\n", + "ok = g[(g[\"오경보율\"] <= 0.15) & (g[\"재현율\"] >= 0.90)].sort_values([\"재발\", \"종합정확도(F1)\", \"재현율\"], ascending=[True, False, False])\n", + "best = ok.iloc[0]\n", + "print(\"\\n[3. 무엇을 바꾸나] 제약 만족 조합 상위 5개(재발 적은 순) (전체 조합 %d개 중 %d개 만족)\" % (len(g), len(ok)))\n", + "print(ok.head().to_string(index=False))\n", + "after = summarize(TAB, alerts(TAB, best[\"th_heat\"], best[\"th_cold\"], int(best[\"persist\"]), best[\"corr\"]))\n", + "gain = (after[\"종합정확도(F1)\"] - before[\"종합정확도(F1)\"]) * 100\n", + "print(f\"\\n재보정 후: {after}\")\n", + "print(f\"종합정확도 변화 {before['종합정확도(F1)']:.3f} -> {after['종합정확도(F1)']:.3f} ({gain:+.1f}%p, 목표 +10%p 이상: {'달성' if gain >= 10 else '미달'})\")\n", + "print(f\"AAR에서 식별한 (병사, 원인) 오경보 {len(seen)}종 -> 재보정 후 같은 병사·같은 원인으로 재발 {int(best['재발'])}건 (목표 0건: {'달성' if best['재발'] == 0 else '미달'})\")\n", + "\n", + "tuned = (f\"heat_temp_threshold: {best['th_heat']}\\ncold_temp_threshold: {best['th_cold']}\\nmin_persist_minutes: {int(best['persist'])}\\n\"\n", + " f\"wind_cooling_correction_c: {best['corr']}\\nconstraints: {{max_far: 0.15, min_recall: 0.90}}\")\n", + "print(\"\\n[재보정 파라미터(wel01_tuned_params.yaml 내용)]\\n\" + tuned)\n", + "print(f\"개선 조치: 열 기준 {best['th_heat']}도·한랭 기준 {best['th_cold']}도, {int(best['persist'])}분 연속 지속 조건으로 오경보를 줄였다(센서 냉각 보정 {best['corr']}도). 파라미터는 실제 장비·인원 자료로 재검증하고 군의관 검토를 거쳐야 하며 진단 기준이 아니다.\")\n", + "\n", + "plt.figure(figsize=(6, 4))\n", + "plt.scatter(g[\"오경보율\"], g[\"재현율\"], c=g[\"persist\"], cmap=\"viridis\", s=18)\n", + "plt.axvline(0.15, ls=\"--\", c=\"gray\"); plt.axhline(0.90, ls=\"--\", c=\"gray\"); plt.colorbar(label=\"persist (min)\")\n", + "plt.xlabel(\"false alarm rate\"); plt.ylabel(\"risk recall\"); plt.title(\"AAR re-tuning grid (dummy data)\")\n", + "plt.show()\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 09 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + "[1. 무엇이 일어났나] 초기 설정 {'th_heat': 37.8, 'th_cold': 36.6, 'persist': 1, 'corr': 0.0} -> {'경보': 2570, '오경보율': 0.405, '재현율': 1.0, '종합정확도(F1)': 0.746}\n", + "구분 원인 \n", + "오경보 강풍 센서 냉각 오차 380\n", + " 기타 5\n", + " 센서 튐 영향 6\n", + " 임계값 근접(조기 경보) 650\n", + "\n", + "[2. 왜 일어났나] 오경보 유형: {'normal': 1041} / 오경보 발생 병사 수: 58\n", + "\n", + "[3. 무엇을 바꾸나] 제약 만족 조합 상위 5개(재발 적은 순) (전체 조합 96개 중 8개 만족)\n", + " th_heat th_cold persist corr 경보 오경보율 재현율 종합정확도(F1) 재발\n", + " 38.2 36.2 3 0.00 1487 0.073 0.902 0.914 20\n", + " 38.2 36.4 3 0.25 1545 0.103 0.906 0.902 21\n", + " 38.2 36.2 2 0.00 1543 0.086 0.923 0.919 23\n", + " 38.2 36.4 2 0.25 1603 0.116 0.927 0.905 27\n", + " 38.2 36.2 1 0.00 1601 0.102 0.940 0.918 29\n", + "\n", + "재보정 후: {'경보': 1487, '오경보율': 0.073, '재현율': 0.902, '종합정확도(F1)': 0.914}\n", + "종합정확도 변화 0.746 -> 0.914 (+16.8%p, 목표 +10%p 이상: 달성)\n", + "AAR에서 식별한 (병사, 원인) 오경보 62종 -> 재보정 후 같은 병사·같은 원인으로 재발 20건 (목표 0건: 미달)\n", + "\n", + "[재보정 파라미터(wel01_tuned_params.yaml 내용)]\n", + "heat_temp_threshold: 38.2\n", + "cold_temp_threshold: 36.2\n", + "min_persist_minutes: 3\n", + "wind_cooling_correction_c: 0.0\n", + "constraints: {max_far: 0.15, min_recall: 0.90}\n", + "개선 조치: 열 기준 38.2도·한랭 기준 36.2도, 3분 연속 지속 조건으로 오경보를 줄였다(센서 냉각 보정 0.0도). 파라미터는 실제 장비·인원 자료로 재검증하고 군의관 검토를 거쳐야 하며 진단 기준이 아니다.\n", + "```\n", + "
\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 10. 더미 생체신호 기반 온열·한랭 스트레스 경보 시스템 통합 패키지 완성\n", + "**소요시간**: 약 3.5시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "더미 생체신호 기반 열·한랭 스트레스 경보 시스템 통합 패키지를 완성한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **데이터 검증부터 전처리, 위험 점수, 지속 조건 경보, 군의관 검토 대기열, 상황판, 재보정값 적용까지 전체 파이프라인을 통합하고 브리핑 패키지를 완성한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **재보정 파라미터로 전체 과정을 한 번에 실행하는 통합 코드를 완성하고 손상 데이터와 기기 두절 병사가 자동 처리되는지 확인하고 유효성을 점검하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `src/main_wel01.py, README.md, docs/wel01_final_report.pdf(md)` 형태의 결과물을 완성한다.\n", + "\n", + "### 🔍 평가 및 검증 기준 (PASS 조건)\n", + "#### 미션 판정\n", + "\n", + "**평가 구분:** 필수\n", + "\n", + "**핵심 평가영역:** 군 실무 즉시 도입 가능성, 운용 신뢰도 및 전략적 기대효과\n", + "\n", + "#### 미션 PASS 조건\n", + "\n", + "- 정량 기준: End-to-End 통합 파이프라인 실행 성공률 100%, 테스트 커버리지 85% 이상\n", + "\n", + "\n", + "- 진행 기준: 원클릭 실행 스크립트 동작 확인 및 최종 발표 산출물 작성 완료\n", + "\n", + "\n", + "- 검증 방법: 최종 검증 위원회(평가관) 모의 시연 및 전체 기능 통합 검증\n", + "\n", + "\n", + "#### 채점자 판정 메모\n", + "\n", + "> 단순 코드 실행 여부뿐만 아니라 출력된 수치와 근거 자료가 군 실무 요구사항에 부합하는지 면밀히 확인한다.\n", + "\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "\"\"\"데이터 검증, 전처리, 개인화 위험 점수, 지속 조건 경보, 군의관 검토 대기열(HITL), 소대 상황판, AAR 재보정값 적용까지 온열·한랭 스트레스 경보 전체 파이프라인을 한 파일로 통합 실행하고, 자체 점검(실행 라인 커버리지 포함)과 지휘관·의무관 브리핑 요약을 출력한다. 진단 아님·의무대 연계.\"\"\"\n", + "import dis\n", + "import hashlib\n", + "import json\n", + "import sys\n", + "import time\n", + "import numpy as np\n", + "import pandas as pd\n", + "import matplotlib.pyplot as plt\n", + "from sklearn.linear_model import LogisticRegression\n", + "from sklearn.pipeline import make_pipeline\n", + "from sklearn.preprocessing import StandardScaler\n", + "\n", + "# ---------------- [교육용가상자료] 더미 생체신호 생성기 (실제 장병·부대·의무 기록 없음) ----------------\n", + "# 가상 병사(가명 ID)가 60분간 걷기·휴식·고강도 활동을 하는 동안의 심박수·체온(추정 심부)·피부전도도를 1분 간격으로 만든다.\n", + "# 진단 도구가 아니며, 상태 라벨은 가상 생리 모델의 '교육용 단계'일 뿐이다.\n", + "N_MIN = 60\n", + "STAGE = {0: \"정상\", 1: \"주의\", 2: \"경보\"}\n", + "TYPES = [\"normal\", \"heat\", \"cold\"]\n", + "\n", + "\n", + "def make_person(i, rng):\n", + " return {\"id\": f\"S{i:03d}\", \"rest_hr\": float(rng.normal(64, 7)), \"age\": int(rng.integers(19, 27)), \"bmi\": float(rng.normal(23.5, 2.5)),\n", + " \"acclim\": int(rng.random() < 0.4), \"fit\": float(np.clip(rng.normal(0.5, 0.2), 0.05, 0.95))}\n", + "\n", + "\n", + "def make_env(kind, rng):\n", + " if kind == \"heat\":\n", + " return {\"temp_c\": float(rng.uniform(30, 40)), \"rh\": float(rng.uniform(45, 85)), \"wind\": float(rng.uniform(0.3, 3))}\n", + " if kind == \"cold\":\n", + " return {\"temp_c\": float(rng.uniform(-22, 2)), \"rh\": float(rng.uniform(40, 80)), \"wind\": float(rng.uniform(2, 11))}\n", + " return {\"temp_c\": float(rng.uniform(8, 24)), \"rh\": float(rng.uniform(35, 70)), \"wind\": float(rng.uniform(0.3, 5))}\n", + "\n", + "\n", + "def make_session(sid, kind, rng, p=None, glitch=True):\n", + " \"\"\"반환: dict(person, env, kind, df[hr,temp,eda,act + 정답 core·stage·rtype]). 센서 결측·튐·고착 포함.\"\"\"\n", + " p = p or make_person(sid, rng)\n", + " env = make_env(kind, rng)\n", + " act = np.zeros(N_MIN); t = 0\n", + " while t < N_MIN: # 10~20분 단위 활동 블록: 휴식 1.5 / 행군 4.5 / 고강도 7 MET\n", + " d = int(rng.integers(10, 21)); act[t:t + d] = rng.choice([1.5, 4.5, 7.0], p=[0.25, 0.5, 0.25]); t += d\n", + " hi = env[\"temp_c\"] + (0.12 * (env[\"rh\"] - 40) if env[\"temp_c\"] > 25 else 0)\n", + " wc = env[\"temp_c\"] - 1.6 * env[\"wind\"]\n", + " core, cores = 36.9, []\n", + " for m in range(N_MIN):\n", + " heat = 0.085 * max(0, hi - 24) * (1 - 0.25 * p[\"acclim\"]) * (1 + 0.5 * m / N_MIN) * (1 + 0.012 * (p[\"bmi\"] - 23.5))\n", + " cold = -0.075 * max(0, 8 - wc) * (1 - 0.09 * act[m])\n", + " ss = 36.9 + 0.09 * act[m] + heat + cold\n", + " core += (ss - core) / 18 + rng.normal(0, 0.03)\n", + " cores.append(core)\n", + " core = np.array(cores)\n", + " hr = p[\"rest_hr\"] + 9.5 * (act - 1.5) * (1.15 - 0.3 * p[\"fit\"]) + 11 * (core - 36.9) + rng.normal(0, 3.2, N_MIN)\n", + " eda = 2.0 + 0.25 * act + 3.2 * np.maximum(core - 37.2, 0) - 0.04 * np.maximum(8 - wc, 0) + rng.normal(0, 0.25, N_MIN)\n", + " bias = rng.normal(0, 0.1)\n", + " temp = core + bias + rng.normal(0, 0.1, N_MIN) - np.where((kind == \"cold\") & (env[\"wind\"] > 7), 0.25, 0.0) # 센서 접촉 부위 바람 냉각 오차\n", + " df = pd.DataFrame({\"min\": np.arange(N_MIN), \"hr\": hr, \"temp\": temp, \"eda\": np.maximum(eda, 0.2), \"act\": act})\n", + " if glitch:\n", + " for col in (\"hr\", \"temp\", \"eda\"):\n", + " df.loc[rng.random(N_MIN) < 0.03, col] = np.nan # 결측\n", + " s = int(rng.integers(5, N_MIN))\n", + " if rng.random() < 0.12:\n", + " df.loc[s, \"hr\"] = 230.0 # 센서 튐\n", + " if rng.random() < 0.12:\n", + " df.loc[s, \"temp\"] = 45.0\n", + " ht = np.where(core >= 39.0, 2, np.where(core >= 38.3, 1, 0)); cd = np.where(core <= 35.4, 2, np.where(core <= 36.2, 1, 0))\n", + " df[\"core\"] = core\n", + " df[\"stage\"] = np.maximum(ht, cd); df[\"rtype\"] = np.where(ht > 0, \"heat\", np.where(cd > 0, \"cold\", \"normal\"))\n", + " return {\"id\": p[\"id\"], \"person\": p, \"env\": env, \"kind\": kind, \"df\": df}\n", + "\n", + "\n", + "def make_dataset(n, seed):\n", + " rng = np.random.default_rng(seed)\n", + " kinds = [\"normal\", \"heat\", \"cold\"]\n", + " return [make_session(i, kinds[i % 3], rng) for i in range(n)]\n", + "# ---------------------------------------------------------------------------\n", + "\n", + "# ---------------- 전처리·특징 추출 공통 함수 ----------------\n", + "LIMITS = {\"hr\": (30, 220), \"temp\": (30.0, 43.0), \"eda\": (0.0, 40.0)} # 생리적으로 가능한 범위(범위 밖 = 센서 오류)\n", + "\n", + "\n", + "def clean(df):\n", + " \"\"\"범위 밖 값은 결측 처리 -> 선형 보간(양 끝은 가까운 값) -> 3분 이동 중앙값으로 튐 제거. 반환: (정제 df, 보정 건수)\"\"\"\n", + " # [TODO : 학생 실습 10] 더미 생체신호 기반 온열·한랭 스트레스 경보 시스템 통합 패키지 완성 관련 핵심 로직을 구현하세요.\n", + " # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n", + " # <<< 여기에 코드를 작성하세요 >>>\n", + " pass # 작성 후 pass를 제거하세요.\n", + "def env_index(env):\n", + " # [TODO : 학생 실습 10] 더미 생체신호 기반 온열·한랭 스트레스 경보 시스템 통합 패키지 완성 관련 핵심 로직을 구현하세요.\n", + " # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n", + " # <<< 여기에 코드를 작성하세요 >>>\n", + " pass # 작성 후 pass를 제거하세요.\n", + "def features(s, use_env=True, personal=True):\n", + " \"\"\"세션 1개 -> 분 단위 특징표(최근 5분 평균·기울기 포함). 정답 stage/rtype 포함.\"\"\"\n", + " df, _ = clean(s[\"df\"]); p, e = s[\"person\"], s[\"env\"]\n", + " f = pd.DataFrame({\"sid\": s[\"id\"], \"min\": df[\"min\"]})\n", + " f[\"temp5\"] = df[\"temp\"].rolling(5, min_periods=1).mean()\n", + " f[\"hr5\"] = df[\"hr\"].rolling(5, min_periods=1).mean()\n", + " f[\"eda5\"] = df[\"eda\"].rolling(5, min_periods=1).mean()\n", + " f[\"dtemp\"] = (f[\"temp5\"] - f[\"temp5\"].shift(4)).fillna(0)\n", + " f[\"act\"] = df[\"act\"]\n", + " if personal:\n", + " f[\"hr_rel\"] = f[\"hr5\"] - p[\"rest_hr\"]\n", + " f[\"acclim\"], f[\"bmi\"], f[\"fit\"] = p[\"acclim\"], p[\"bmi\"], p[\"fit\"]\n", + " if use_env:\n", + " f[\"hi\"], f[\"wc\"] = env_index(e); f[\"wind\"] = e[\"wind\"]\n", + " f[\"stage\"], f[\"rtype\"], f[\"kind\"] = df[\"stage\"], df[\"rtype\"], s[\"kind\"]\n", + " return f[f[\"min\"] >= 5].reset_index(drop=True)\n", + "\n", + "\n", + "def table(sessions, **kw):\n", + " return pd.concat([features(s, **kw) for s in sessions], ignore_index=True)\n", + "# ---------------------------------------------------------------------------\n", + "\n", + "CONFIG = {\"alert_threshold\": 0.4, \"min_persist\": 3, \"heat_temp\": 38.2, \"cold_temp\": 36.2} # 미션 9 재보정값 + 모델 임계값(지속 조건이 오경보를 막으므로 미션 5의 0.5보다 낮춰 미탐을 줄임)\n", + "FE = [\"temp5\", \"dtemp\", \"hr5\", \"eda5\", \"hr_rel\", \"acclim\", \"bmi\", \"fit\", \"hi\", \"wc\", \"wind\"]\n", + "\n", + "\n", + "def validate(s):\n", + " errs = []\n", + " if s[\"kind\"] not in TYPES:\n", + " errs.append(\"kind 허용값 아님\")\n", + " if len(s[\"df\"]) != N_MIN or not {\"hr\", \"temp\", \"eda\"} <= set(s[\"df\"].columns):\n", + " errs.append(\"신호 길이·필드 오류\")\n", + " if not str(s[\"id\"]).startswith(\"S\"):\n", + " errs.append(\"soldier_id 형식 오류\")\n", + " return errs\n", + "\n", + "\n", + "def run_session(s, model):\n", + " \"\"\"세션 1개: 정제 -> 특징 -> 점수 -> 지속 조건 경보. 신호가 5분 이상 끊기면 '신호두절' 플래그.\"\"\"\n", + " f = features(s)\n", + " raw = s[\"df\"].set_index(\"min\")\n", + " f[\"lost\"] = [bool(raw.loc[max(m - 4, 0):m, [\"hr\", \"temp\"]].isna().all().all()) for m in f[\"min\"]]\n", + " f[\"score\"] = model.predict_proba(f[FE])[:, 1]\n", + " hot = (f[\"temp5\"] >= CONFIG[\"heat_temp\"]) | (f[\"temp5\"] <= CONFIG[\"cold_temp\"])\n", + " cond = (f[\"score\"] >= CONFIG[\"alert_threshold\"]) & (hot | (f[\"score\"] >= 0.8))\n", + " f[\"alert\"] = cond.rolling(CONFIG[\"min_persist\"], min_periods=CONFIG[\"min_persist\"]).min().fillna(0).astype(bool) & ~f[\"lost\"]\n", + " f[\"grade\"] = np.where(f[\"score\"] >= 0.8, \"High\", np.where(f[\"score\"] >= 0.5, \"Medium\", \"Low\"))\n", + " return f\n", + "\n", + "\n", + "class Queue:\n", + " def __init__(self):\n", + " self.items, self.log = [], []\n", + "\n", + " def submit(self, sid, minute, score, grade):\n", + " prev = self.log[-1][\"hash\"] if self.log else \"GENESIS\"\n", + " body = {\"event\": \"SUBMIT\", \"id\": f\"{sid}@{minute}\", \"score\": round(score, 3), \"by\": \"AI\"}\n", + " self.log.append({**body, \"prev\": prev, \"hash\": hashlib.sha256((prev + json.dumps(body, sort_keys=True)).encode()).hexdigest()})\n", + " self.items.append({\"id\": f\"{sid}@{minute}\", \"grade\": grade, \"status\": \"PENDING\"})\n", + "\n", + " def verify(self):\n", + " prev = \"GENESIS\"\n", + " for rec in self.log:\n", + " body = {k: v for k, v in rec.items() if k not in (\"prev\", \"hash\")}\n", + " if rec[\"prev\"] != prev or rec[\"hash\"] != hashlib.sha256((prev + json.dumps(body, sort_keys=True)).encode()).hexdigest():\n", + " return False\n", + " prev = rec[\"hash\"]\n", + " return True\n", + "\n", + "\n", + "def episodes(f):\n", + " \"\"\"연속 경보 분을 하나의 사건으로 묶어 첫 분만 검토 대기열에 올린다(같은 사건 중복 통보 방지).\"\"\"\n", + " a = f[\"alert\"].values\n", + " return [i for i in range(len(a)) if a[i] and (i == 0 or not a[i - 1])]\n", + "\n", + "\n", + "t_start = time.perf_counter()\n", + "# ---------------- 1. 학습(점수 엔진 준비) ----------------\n", + "tr = table(make_dataset(150, seed=51))\n", + "model = make_pipeline(StandardScaler(), LogisticRegression(class_weight=\"balanced\", max_iter=3000, random_state=0)).fit(tr[FE], tr[\"stage\"] >= 1)\n", + "\n", + "# ---------------- 2. 운용 데이터 처리(손상 1건, 기기 두절 1건 주입) ----------------\n", + "rng = np.random.default_rng(61)\n", + "ops = [make_session(i, TYPES[i % 3], rng) for i in range(60)]\n", + "ops[4] = {**ops[4], \"kind\": \"tundra\"} # 허용되지 않은 유형\n", + "dead = ops[8][\"df\"].copy(); dead.loc[35:, [\"hr\", \"temp\", \"eda\"]] = np.nan; ops[8] = {**ops[8], \"df\": dead} # 기기 두절\n", + "queue, rejected, runs, results = Queue(), {}, [], []\n", + "codes = {f.__code__ for f in (validate, run_session, episodes, Queue.submit, Queue.verify, clean, env_index, features, table)}\n", + "lines_total = {c: {ln for _, ln in dis.findlinestarts(c) if ln != c.co_firstlineno} for c in codes}\n", + "trace_lines = set()\n", + "\n", + "\n", + "def tracer(frame, event, arg):\n", + " if frame.f_code in codes:\n", + " def local(fr, ev, ar):\n", + " if ev == \"line\":\n", + " trace_lines.add((fr.f_code, fr.f_lineno))\n", + " return local\n", + " return local\n", + "\n", + "\n", + "sys.settrace(tracer)\n", + "for s in ops:\n", + " errs = validate(s)\n", + " if errs:\n", + " rejected[s[\"id\"]] = errs; continue\n", + " t0 = time.perf_counter()\n", + " f = run_session(s, model)\n", + " for i in episodes(f):\n", + " queue.submit(s[\"id\"], int(f[\"min\"].iloc[i]), float(f[\"score\"].iloc[i]), f[\"grade\"].iloc[i])\n", + " runs.append({\"sid\": s[\"id\"], \"ms_per_min\": (time.perf_counter() - t0) * 1000 / len(f), \"events\": len(episodes(f)), \"lost\": int(f[\"lost\"].any())})\n", + " results.append(f)\n", + "queue.verify()\n", + "sys.settrace(None)\n", + "covered = sum(len({ln for c2, ln in trace_lines if c2 is c} & lines_total[c]) for c in codes)\n", + "cov = covered / sum(len(v) for v in lines_total.values())\n", + "\n", + "# ---------------- 3. 성능 평가 ----------------\n", + "allf = pd.concat(results, ignore_index=True)\n", + "truth = allf[\"stage\"] >= 1\n", + "far = (allf[\"alert\"] & ~truth).sum() / max(allf[\"alert\"].sum(), 1)\n", + "rec = (allf[\"alert\"] & truth).sum() / truth.sum()\n", + "risk_sids = set(allf.loc[truth, \"sid\"]); alerted = set(allf.loc[allf[\"alert\"] & truth, \"sid\"])\n", + "ms = np.mean([r[\"ms_per_min\"] for r in runs])\n", + "\n", + "# ---------------- 4. 자체 점검(통합 테스트) ----------------\n", + "chk = {\n", + " \"손상 데이터 차단\": \"S004\" in rejected,\n", + " \"처리 세션 59개 전량 성공\": len(runs) == 59,\n", + " \"신호두절 병사 식별\": any(r[\"lost\"] for r in runs) and bool(allf.loc[allf[\"sid\"] == \"S008\", \"lost\"].any()),\n", + " \"위험 구간 재현율 85% 이상\": rec >= 0.85,\n", + " \"오경보율 15% 이하\": far <= 0.15,\n", + " \"위험 병사 사건 식별 90% 이상\": len(alerted) / max(len(risk_sids), 1) >= 0.9,\n", + " \"자동 조치 0건\": all(i[\"status\"] == \"PENDING\" for i in queue.items),\n", + " \"감사 로그 건수 일치·무결\": len(queue.log) == len(queue.items) and queue.verify(),\n", + " \"분당 처리 50ms 이내\": ms < 50,\n", + " \"실행 라인 커버리지 85% 이상\": cov >= 0.85,\n", + "}\n", + "passed = sum(chk.values())\n", + "for k, v in chk.items():\n", + " print(f\"[{'PASS' if v else 'FAIL'}] {k}\")\n", + "print(f\"자체 점검 {passed}/{len(chk)} 통과 ({passed / len(chk):.0%}), 파이프라인 실행 성공 {'예' if len(runs) + len(rejected) == len(ops) else '아니오'}, 핵심 모듈 실행 라인 커버리지 {cov:.1%}\")\n", + "\n", + "# ---------------- 5. 소대 상황판 ----------------\n", + "colr = {\"High\": \"tab:red\", \"Medium\": \"orange\", \"Low\": \"tab:gray\"}\n", + "fig, ax = plt.subplots(2, 2, figsize=(12, 8))\n", + "top = sorted(results, key=lambda f: -f[\"score\"].max())[:2]\n", + "for a, f in zip(ax[0], top):\n", + " a.plot(f[\"min\"], f[\"temp5\"], label=\"temp (5-min mean)\"); a.scatter(f[\"min\"][f[\"alert\"]], f[\"temp5\"][f[\"alert\"]], color=\"red\", s=12, label=\"alert\")\n", + " a.set_title(f\"{f['sid'].iloc[0]} temperature and alerts\"); a.set_xlabel(\"minute\"); a.legend()\n", + "g = pd.Series([i[\"grade\"] for i in queue.items]).value_counts().reindex([\"High\", \"Medium\", \"Low\"]).fillna(0)\n", + "ax[1, 0].bar(g.index, g.values, color=[colr[i] for i in g.index]); ax[1, 0].set_title(\"Review queue events by grade (pending human review)\")\n", + "ax[1, 1].axis(\"off\")\n", + "ax[1, 1].text(0, 0.9, f\"Risk recall {rec:.1%}\\nFalse alarm {far:.1%}\\nQueue {len(queue.items)} pending\\nLatency {ms:.2f} ms/min\\nSelf-check {passed}/{len(chk)}\", family=\"monospace\", fontsize=13, va=\"top\")\n", + "plt.suptitle(\"WEL01 integrated pipeline - dummy data, not diagnosis\"); plt.tight_layout(); plt.show()\n", + "\n", + "# ---------------- 6. 브리핑 요약 ----------------\n", + "print(f\"\"\"\n", + "[열·한랭 스트레스 경보 브리핑 요약 - 교육용가상자료, 진단 아님]\n", + "1. 처리: 가상 병사 {len(ops)}명 중 {len(runs)}명 처리, 손상 데이터 {len(rejected)}건 차단({', '.join(rejected)}), 신호두절 {sum(r['lost'] for r in runs)}명 확인 필요 표시\n", + "2. 성능: 위험 구간 재현율 {rec:.1%}, 오경보율 {far:.1%}, 분당 {ms:.2f} ms, 전체 소요 {time.perf_counter() - t_start:.1f} s\n", + "3. 경보: {len(queue.items)}건 군의관·소대장 검토 대기 (High {sum(i['grade'] == 'High' for i in queue.items)} / Medium {sum(i['grade'] == 'Medium' for i in queue.items)} / Low {sum(i['grade'] == 'Low' for i in queue.items)}), 자동 조치 0건\n", + "4. 운용 유의: 휴식 권고·임무 배제·의무대 인계는 군의관·소대장·지휘관의 승인 후에만 이루어지며, 의학적 판단은 의무대·군의관에게 연계한다.\n", + "5. 한계: 가상 생리 모델로 만든 더미 신호 기준 결과로, 진단 도구가 아니며 실제 장비·인원 자료로 재검증이 필요하다.\"\"\")\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 10 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + "[PASS] 손상 데이터 차단\n", + "[PASS] 처리 세션 59개 전량 성공\n", + "[PASS] 신호두절 병사 식별\n", + "[PASS] 위험 구간 재현율 85% 이상\n", + "[PASS] 오경보율 15% 이하\n", + "[PASS] 위험 병사 사건 식별 90% 이상\n", + "[PASS] 자동 조치 0건\n", + "[PASS] 감사 로그 건수 일치·무결\n", + "[PASS] 분당 처리 50ms 이내\n", + "[PASS] 실행 라인 커버리지 85% 이상\n", + "자체 점검 10/10 통과 (100%), 파이프라인 실행 성공 예, 핵심 모듈 실행 라인 커버리지 92.7%\n", + "\n", + "[열·한랭 스트레스 경보 브리핑 요약 - 교육용가상자료, 진단 아님]\n", + "1. 처리: 가상 병사 60명 중 59명 처리, 손상 데이터 1건 차단(S004), 신호두절 1명 확인 필요 표시\n", + "2. 성능: 위험 구간 재현율 85.6%, 오경보율 8.3%, 분당 2.24 ms, 전체 소요 10.2 s\n", + "3. 경보: 40건 군의관·소대장 검토 대기 (High 24 / Medium 15 / Low 1), 자동 조치 0건\n", + "4. 운용 유의: 휴식 권고·임무 배제·의무대 인계는 군의관·소대장·지휘관의 승인 후에만 이루어지며, 의학적 판단은 의무대·군의관에게 연계한다.\n", + "5. 한계: 가상 생리 모델로 만든 더미 신호 기준 결과로, 진단 도구가 아니며 실제 장비·인원 자료로 재검증이 필요하다.\n", + "```\n", + "
\n" + ] + } + ], + "metadata": { + "colab": { + "provenance": [] + }, + "kernelspec": { + "display_name": "Python 3", + "name": "python3" + }, + "language_info": { + "name": "python" + } + }, + "nbformat": 4, + "nbformat_minor": 0 +} \ No newline at end of file diff --git a/uploads/WEL03/WEL03_solution.ipynb b/uploads/WEL03/WEL03_solution.ipynb new file mode 100644 index 0000000..6f3d257 --- /dev/null +++ b/uploads/WEL03/WEL03_solution.ipynb @@ -0,0 +1,3713 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# 🎯 [WEL03] 가상 수질검사 데이터 위험 신호 대시보드\n", + "### Google Colab 실습 환경 (장병 복지·지원 / Level 2)\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/WEL03/WEL03_solution.ipynb)\n", + "\n", + "---\n", + "\n", + "## 📌 1. 프로젝트 개요 및 배경\n", + "\n", + "| 항목 | 내용 |\n", + "| :--- | :--- |\n", + "| **과정 분류** | 장병 복지·지원 (Level 2) |\n", + "| **학습 소요 시간** | 10개 미션 (5일 / 총 27.5시간) |\n", + "| **환경 사양** | Python 3.10+, numpy, pandas, matplotlib, scipy, scikit-learn |\n", + "\n", + "### 📖 프로젝트 목표\n", + "전장에서는 급수차, 정수 장비, 우물, 하천 취수원 등 급수원의 수질 이상을 빨리 알아채지 못하면 장병의 건강과 부대 활동에 바로 영향을 주지만, 센서 값은 잡음과 고장이 많아 오경보로 불필요하게 급수를 끊는 일도 생긴다. 러시아-우크라이나 전쟁 사례에서 학습 초점으로 삼은 전장 위생·건강 위험 관리를 반영해, 본 프로젝트는 가상 수질검사 데이터를 분석해 위험 신호를 데이터로 판단하는 과제를 다룬다. 교육용 가상 급수원 8곳의 pH, 탁도, 잔류염소, 중금속, 대장균 측정 자료를 코드로 만들어 스키마 정의, 센서 결측·이상치 정제, 기준치 Baseline 판정, 복합 오염 징후 분류 모델, 신뢰도 지수와 오경보 감축, 상황판 대시보드, 급수 중단 HITL 결재선, 대량 오염 부하 시험, 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. 야전 급수원 가상 수질검사 항목(pH, 탁도, 잔류염소, 중금속, 대장균) 데이터셋 정의\n", + "**소요시간**: 약 1.5시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "야전 급수원 가상 수질검사 항목(pH, 탁도, 잔류염소, 중금속) 데이터셋을 정의한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **야전 급수원 가상 수질검사 항목(pH, 탁도, 잔류염소, 중금속, 대장균)과 센서·전술 환경 정보의 스키마와 데이터 명세를 정의한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **측정 레코드를 급수원 정보, 수질 5항목, 센서 상태, 전술 파라미터(기상, 위치 오차, 통신 지연) 네 묶음 15개 필드로 정의하고 자료형·단위·허용 범위·형식 규칙을 둔다 과정을 진행하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `data/schema/wel03_input_schema.json 및 docs/wel03_data_spec.md` 형태의 결과물을 완성한다.\n", + "\n", + "### 🔍 평가 및 검증 기준 (PASS 조건)\n", + "#### 미션 판정\n", + "\n", + "**평가 구분:** 필수\n", + "\n", + "**핵심 평가영역:** 장병 복지·지원 실무 및 러시아-우크라이나 전쟁 전장 환경과의 군사적 부합성\n", + "\n", + "#### 미션 PASS 조건\n", + "\n", + "- 정량 기준: 스키마 필드 정의 완성도 100%, 샘플 데이터 검증 오류 0건\n", + "\n", + "\n", + "- 진행 기준: 입력 데이터 유효성 검증 스크립트 통과 및 명세서 문서화 완료\n", + "\n", + "\n", + "- 검증 방법: JSON Schema 검증 도구 및 샘플 데이터 파싱 단위 테스트\n", + "\n", + "\n", + "#### 채점자 판정 메모\n", + "\n", + "> 단순 코드 실행 여부뿐만 아니라 출력된 수치와 근거 자료가 군 실무 요구사항에 부합하는지 면밀히 확인한다.\n", + "\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "\"\"\"야전 급수원 가상 수질검사 항목(pH, 탁도, 잔류염소, 중금속, 대장균)과 급수원·센서·전술 파라미터를 담은 입력 데이터 스키마를 정의하고, 가상 샘플 20건과 오류 주입 데이터로 검증기를 시험한다.\"\"\"\n", + "import json\n", + "import re\n", + "import numpy as np\n", + "import pandas as pd\n", + "\n", + "# ---------------------------------------------------------------------------\n", + "# [교육용가상자료] 야전 급수원 가상 수질 센서 데이터 생성기 (실제 부대·위치·검사 결과 없음)\n", + "# 가상 급수원 8곳 x 14일 x 1시간 간격. 실제 수질(true_*)·센서 측정값·오염 사건·센서 고장 정답을 함께 만든다.\n", + "# 기준치는 교육용 가정값이며, 실제 음용 판정은 공인 검사 결과와 의무(예방의학) 담당 기준에 따른다.\n", + "# ---------------------------------------------------------------------------\n", + "PARAMS = [\"ph\", \"turbidity\", \"chlorine\", \"metal\", \"ecoli\"]\n", + "SOURCES = [(\"W01\", \"water_truck\", 1.0, 2.0), (\"W02\", \"water_truck\", 3.5, 1.2), (\"W03\", \"purification_unit\", 2.2, 4.1),\n", + " (\"W04\", \"purification_unit\", 5.0, 3.3), (\"W05\", \"well\", 0.8, 5.6), (\"W06\", \"well\", 4.4, 6.0),\n", + " (\"W07\", \"river_intake\", 6.3, 1.8), (\"W08\", \"river_intake\", 6.9, 4.9)]\n", + "BASE = {\"water_truck\": (7.2, 0.25, 0.75, 1.5), \"purification_unit\": (7.0, 0.20, 0.85, 1.2),\n", + " \"well\": (6.8, 0.45, 0.50, 3.0), \"river_intake\": (7.3, 0.55, 0.60, 2.0)} # pH, 탁도(NTU), 잔류염소(mg/L), 중금속(ug/L)\n", + "LIMITS = {\"ph\": (5.8, 8.5), \"turbidity\": (1.0, 2.0), \"chlorine\": (0.2, 0.1), \"metal\": (5.0, 10.0), \"ecoli\": (1, 1)} # (주의, 위험) 가정 기준\n", + "EVENT_TYPES = { # 오염 사건: 변수별 최대 변화량, 최대치 도달 시간(h)\n", + " \"sewage\": ({\"turbidity\": 2.8, \"chlorine\": -0.5, \"ecoli\": 30, \"ph\": -0.3}, 4),\n", + " \"metal\": ({\"metal\": 22.0, \"ph\": -0.5}, 4),\n", + " \"disinfect_fail\": ({\"chlorine\": -0.8, \"ecoli\": 6}, 6),\n", + " \"chemical\": ({\"ph\": 1.7, \"turbidity\": 1.0}, 3),\n", + " \"creeping_metal\": ({\"metal\": 13.0}, 18),\n", + "}\n", + "FAULT_TYPES = [\"stuck\", \"spike\", \"drift\", \"dropout\"]\n", + "NOISE = {\"ph\": 0.04, \"turbidity\": 0.06, \"chlorine\": 0.03, \"metal\": 0.35}\n", + "DIGITS = {\"ph\": 2, \"turbidity\": 2, \"chlorine\": 2, \"metal\": 1}\n", + "\n", + "\n", + "def make_dataset(days=14, seed=7, n_events=6, n_faults=10, events=None, faults=None, sources=None):\n", + " \"\"\"events: [(급수원 번호, 사건 유형, 시작 시각 h, 지속 h, 강도)], faults: [(급수원 번호, 고장 유형, 항목, 시작 h, 지속 h)]\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " sources = sources or SOURCES\n", + " T = days * 24\n", + " tt = np.arange(T)\n", + " hour = tt % 24\n", + " night = (hour < 6) | (hour >= 20)\n", + " rain = np.zeros(T)\n", + " for _ in range(max(1, days // 4)):\n", + " s0, d = int(rng.integers(0, T - 14)), int(rng.integers(6, 14))\n", + " rain[s0:s0 + d] = np.maximum(rain[s0:s0 + d], rng.uniform(0.6, 1.0))\n", + " if events is None:\n", + " events = [(int(rng.integers(len(sources))), str(rng.choice(list(EVENT_TYPES))), int(rng.integers(min(24, T // 4), max(T // 4 + 1, T - 48))),\n", + " int(rng.integers(10, 30)), 1.0) for _ in range(n_events)]\n", + " if faults is None:\n", + " faults = [(int(rng.integers(len(sources))), str(rng.choice(FAULT_TYPES)), str(rng.choice([\"ph\", \"turbidity\", \"chlorine\", \"metal\"])),\n", + " int(rng.integers(12, max(13, T - 30))), int(rng.integers(6, 24))) for _ in range(n_faults)]\n", + " out = []\n", + " for si, (sid, stype, x, y) in enumerate(sources):\n", + " ph0, tb0, cl0, mt0 = BASE[stype]\n", + " tr = {\"ph\": ph0 + 0.08 * np.sin(2 * np.pi * hour / 24) + rng.normal(0, 0.03, T),\n", + " \"turbidity\": tb0 * (1 + 0.1 * rng.normal(0, 1, T)),\n", + " \"chlorine\": (cl0 * np.exp(-0.022 * hour) if stype == \"water_truck\" else cl0 + rng.normal(0, 0.03, T)),\n", + " \"metal\": mt0 + rng.normal(0, 0.2, T), \"ecoli\": np.zeros(T)}\n", + " if stype in (\"river_intake\", \"well\"):\n", + " tr[\"turbidity\"] = tr[\"turbidity\"] + rain * (0.9 if stype == \"river_intake\" else 0.45)\n", + " f_all, etype = np.zeros(T), np.array([\"none\"] * T, dtype=object)\n", + " for (s_i, et, onset, dur, scale) in events:\n", + " if s_i != si:\n", + " continue\n", + " delta, ramp = EVENT_TYPES[et]\n", + " f = np.clip((tt - onset + 1) / ramp, 0, 1) * ((tt >= onset) & (tt < onset + dur + ramp))\n", + " for k, v in delta.items():\n", + " tr[k] = tr[k] + v * f * scale\n", + " etype[f > 0] = et\n", + " f_all = np.maximum(f_all, f)\n", + " tr[\"chlorine\"] = np.clip(tr[\"chlorine\"], 0.02, None)\n", + " tr[\"turbidity\"] = np.clip(tr[\"turbidity\"], 0.05, None)\n", + " tr[\"metal\"] = np.clip(tr[\"metal\"], 0.1, None)\n", + " grade = np.where(f_all >= 0.2, 2, 0)\n", + " mild = (tr[\"turbidity\"] > LIMITS[\"turbidity\"][0]) | (tr[\"chlorine\"] < LIMITS[\"chlorine\"][0]) | (tr[\"metal\"] > LIMITS[\"metal\"][0])\n", + " grade = np.where((grade == 0) & mild, 1, grade)\n", + " ms = {}\n", + " for p in [\"ph\", \"turbidity\", \"chlorine\", \"metal\"]:\n", + " sd = NOISE[p] * np.where(night, 1.5, 1.0) * (tr[p] if p == \"turbidity\" else 1)\n", + " ms[p] = np.round(np.clip(tr[p] + rng.normal(0, 1, T) * sd, 0, None), DIGITS[p])\n", + " ms[\"ecoli\"] = (rng.poisson(tr[\"ecoli\"]) + (rng.random(T) < 0.004)).astype(float)\n", + " fault, fparam = np.array([\"none\"] * T, dtype=object), np.array([\"-\"] * T, dtype=object)\n", + " for (s_i, ft, p, s0, d) in faults:\n", + " if s_i != si:\n", + " continue\n", + " seg = slice(s0, min(T, s0 + d))\n", + " if ft == \"stuck\":\n", + " ms[p][seg] = ms[p][s0]\n", + " elif ft == \"spike\":\n", + " seg = slice(s0, s0 + 1)\n", + " ms[p][s0] = {\"ph\": 11.8, \"turbidity\": 38.0, \"chlorine\": 0.0, \"metal\": 75.0}[p]\n", + " elif ft == \"drift\":\n", + " ramp = np.linspace(0, 1, seg.stop - s0)\n", + " ms[p][seg] = np.clip(ms[p][seg] + ramp * {\"ph\": -1.6, \"turbidity\": 2.6, \"chlorine\": -0.75, \"metal\": 12.0}[p], 0, None).round(DIGITS[p])\n", + " elif ft == \"dropout\":\n", + " for q in PARAMS:\n", + " ms[q][seg] = np.nan\n", + " p = \"all\"\n", + " fault[seg], fparam[seg] = ft, p\n", + " df = pd.DataFrame({\"source_id\": sid, \"source_type\": stype, \"x_km\": x, \"y_km\": y, \"t\": tt,\n", + " \"cond\": np.where(rain > 0, \"storm\", np.where(night, \"night\", \"day\")),\n", + " **{p: ms[p] for p in PARAMS}, **{f\"true_{p}\": tr[p] for p in PARAMS},\n", + " \"event\": etype, \"fault\": fault, \"fault_param\": fparam, \"grade\": grade})\n", + " out.append(df)\n", + " return pd.concat(out, ignore_index=True)\n", + "# ---------------------------------------------------------------------------\n", + "\n", + "# 1) 스키마 정의: 급수원 정보, 수질 측정값(5항목), 센서 상태, 전술 파라미터(기상·위치오차·통신지연)\n", + "SCHEMA = {\n", + " \"required\": [\"record_id\", \"source\", \"measured_at\", \"quality\", \"sensor\", \"tactical\"],\n", + " \"fields\": {\n", + " \"record_id\": {\"type\": str, \"pattern\": r\"^WQ-W\\d{2}-\\d{4}$\"},\n", + " \"measured_at\": {\"type\": str, \"pattern\": r\"^D\\+\\d{2} \\d{2}:00$\"},\n", + " },\n", + " \"source\": {\n", + " \"source_id\": {\"type\": str, \"pattern\": r\"^W\\d{2}$\"},\n", + " \"source_type\": {\"type\": str, \"enum\": [\"water_truck\", \"purification_unit\", \"well\", \"river_intake\"]},\n", + " \"grid_km\": {\"type\": list, \"length\": 2},\n", + " },\n", + " \"quality\": { # 단위와 물리적으로 가능한 범위(기준치가 아니라 측정 가능 범위)\n", + " \"ph\": {\"type\": (int, float), \"min\": 0, \"max\": 14, \"unit\": \"-\"},\n", + " \"turbidity_ntu\": {\"type\": (int, float), \"min\": 0, \"max\": 1000, \"unit\": \"NTU\"},\n", + " \"chlorine_mgL\": {\"type\": (int, float), \"min\": 0, \"max\": 10, \"unit\": \"mg/L\"},\n", + " \"metal_ugL\": {\"type\": (int, float), \"min\": 0, \"max\": 1000, \"unit\": \"ug/L\"},\n", + " \"ecoli_cfu\": {\"type\": int, \"min\": 0, \"max\": 100000, \"unit\": \"CFU/100mL\"},\n", + " },\n", + " \"sensor\": {\n", + " \"status\": {\"type\": str, \"enum\": [\"ok\", \"calibration_due\", \"fault\", \"offline\"]},\n", + " \"last_calibrated_h\": {\"type\": int, \"min\": 0, \"max\": 720},\n", + " },\n", + " \"tactical\": {\n", + " \"weather\": {\"type\": str, \"enum\": [\"clear\", \"rain\", \"night\", \"snow\", \"dust\"]},\n", + " \"position_error_m\": {\"type\": (int, float), \"min\": 0, \"max\": 500},\n", + " \"comm_delay_ms\": {\"type\": (int, float), \"min\": 0, \"max\": 10000},\n", + " },\n", + "}\n", + "\n", + "\n", + "def check(name, value, rule):\n", + " \"\"\"값 하나를 규칙(type, enum, min, max, length, pattern)으로 검사해 오류 목록을 돌려준다.\"\"\"\n", + " if not isinstance(value, rule[\"type\"]) or isinstance(value, bool):\n", + " return [f\"{name}: 자료형 오류({type(value).__name__})\"]\n", + " errs = []\n", + " if \"enum\" in rule and value not in rule[\"enum\"]:\n", + " errs.append(f\"{name}: 허용되지 않은 값 '{value}'\")\n", + " if \"min\" in rule and value < rule[\"min\"]:\n", + " errs.append(f\"{name}: 최소값 {rule['min']} 미만\")\n", + " if \"max\" in rule and value > rule[\"max\"]:\n", + " errs.append(f\"{name}: 최대값 {rule['max']} 초과\")\n", + " if \"length\" in rule and len(value) != rule[\"length\"]:\n", + " errs.append(f\"{name}: 길이 {rule['length']} 필요\")\n", + " if \"pattern\" in rule and not re.match(rule[\"pattern\"], value):\n", + " errs.append(f\"{name}: 형식 불일치 '{value}'\")\n", + " return errs\n", + "\n", + "\n", + "def validate(rec):\n", + " errs = [f\"{k}: 필수 필드 누락\" for k in SCHEMA[\"required\"] if k not in rec]\n", + " for k, rule in SCHEMA[\"fields\"].items():\n", + " if k in rec:\n", + " errs += check(k, rec[k], rule)\n", + " for group in (\"source\", \"quality\", \"sensor\", \"tactical\"):\n", + " for k, rule in SCHEMA[group].items():\n", + " if k not in rec.get(group, {}):\n", + " errs.append(f\"{group}.{k}: 필수 필드 누락\")\n", + " else:\n", + " errs += check(f\"{group}.{k}\", rec[group][k], rule)\n", + " return errs\n", + "\n", + "\n", + "# 2) [교육용가상자료] 가상 센서 데이터에서 샘플 20건을 뽑아 스키마 형식(JSON)으로 변환\n", + "data = make_dataset(days=3, seed=1, n_events=1, n_faults=0)\n", + "rng = np.random.default_rng(1)\n", + "rows = data.sample(20, random_state=1).sort_values([\"source_id\", \"t\"])\n", + "records = []\n", + "for _, r in rows.iterrows():\n", + " records.append({\n", + " \"record_id\": f\"WQ-{r['source_id']}-{int(r['t']):04d}\", \"measured_at\": f\"D+{int(r['t']) // 24:02d} {int(r['t']) % 24:02d}:00\",\n", + " \"source\": {\"source_id\": r[\"source_id\"], \"source_type\": r[\"source_type\"], \"grid_km\": [r[\"x_km\"], r[\"y_km\"]]},\n", + " \"quality\": {\"ph\": float(r[\"ph\"]), \"turbidity_ntu\": float(r[\"turbidity\"]), \"chlorine_mgL\": float(r[\"chlorine\"]),\n", + " \"metal_ugL\": float(r[\"metal\"]), \"ecoli_cfu\": int(r[\"ecoli\"])},\n", + " \"sensor\": {\"status\": \"ok\", \"last_calibrated_h\": int(rng.integers(1, 200))},\n", + " \"tactical\": {\"weather\": {\"storm\": \"rain\", \"night\": \"night\", \"day\": \"clear\"}[r[\"cond\"]],\n", + " \"position_error_m\": float(round(rng.uniform(3, 40), 1)), \"comm_delay_ms\": int(rng.integers(30, 900))}})\n", + "\n", + "ok = sum(1 for r in records if not validate(r))\n", + "print(f\"정상 샘플 {len(records)}건 중 검증 통과 {ok}건, 오류 {len(records) - ok}건\")\n", + "parsed = json.loads(json.dumps(records, ensure_ascii=False)) # 직렬화 -> 파싱 왕복 시험\n", + "print(\"JSON 직렬화·파싱 왕복 후 값 일치:\", parsed == records)\n", + "\n", + "# 3) 오류 주입 시험: 검증기가 잘못된 데이터를 잡아내는지 확인\n", + "bad = json.loads(json.dumps(records[0]))\n", + "bad[\"record_id\"] = \"WQ-1\" # 형식 오류\n", + "bad[\"quality\"][\"ph\"] = 15.2 # 물리 범위 초과\n", + "bad[\"quality\"][\"ecoli_cfu\"] = \"검출\" # 자료형 오류(숫자여야 함)\n", + "bad[\"source\"][\"source_type\"] = \"bottle\" # 허용되지 않은 급수원 유형\n", + "del bad[\"tactical\"][\"comm_delay_ms\"] # 전술 파라미터 누락\n", + "print(\"\\n[오류 주입 결과]\")\n", + "errs = validate(bad)\n", + "for e in errs:\n", + " print(\" -\", e)\n", + "print(f\"주입 오류 5종 중 검출 {len(errs)}종 -> {'모두 검출' if len(errs) >= 5 else '누락 있음'}\")\n", + "\n", + "# 4) 데이터 명세 요약(명세서 초안)\n", + "spec = []\n", + "for g in (\"fields\", \"source\", \"quality\", \"sensor\", \"tactical\"):\n", + " for k, r in SCHEMA[g].items():\n", + " t = r[\"type\"].__name__ if isinstance(r[\"type\"], type) else \"number\"\n", + " spec.append((g, k, t, r.get(\"unit\", \"\"), str({x: y for x, y in r.items() if x not in (\"type\", \"unit\")})))\n", + "print(\"\\n[데이터 명세 요약]\")\n", + "print(pd.DataFrame(spec, columns=[\"그룹\", \"필드\", \"자료형\", \"단위\", \"규칙\"]).to_string(index=False))\n", + "done = sum(1 for s in spec if s[2] and s[4] != \"{}\")\n", + "print(f\"스키마 필드 정의 완성도 {done}/{len(spec)} ({done / len(spec):.0%}): 모든 필드에 자료형·검증 규칙 정의\")\n", + "print(\"\\n[가정 판정 기준(교육용)] (주의, 위험):\", {k: v for k, v in LIMITS.items()})\n", + "print(\"급수원 유형별 샘플 수:\", rows[\"source_type\"].value_counts().to_dict())\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 01 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + "정상 샘플 20건 중 검증 통과 20건, 오류 0건\n", + "JSON 직렬화·파싱 왕복 후 값 일치: True\n", + "\n", + "[오류 주입 결과]\n", + " - record_id: 형식 불일치 'WQ-1'\n", + " - source.source_type: 허용되지 않은 값 'bottle'\n", + " - quality.ph: 최대값 14 초과\n", + " - quality.ecoli_cfu: 자료형 오류(str)\n", + " - tactical.comm_delay_ms: 필수 필드 누락\n", + "주입 오류 5종 중 검출 5종 -> 모두 검출\n", + "\n", + "[데이터 명세 요약]\n", + " 그룹 필드 자료형 단위 규칙\n", + " fields record_id str {'pattern': '^WQ-W\\\\d{2}-\\\\d{4}$'}\n", + " fields measured_at str {'pattern': '^D\\\\+\\\\d{2} \\\\d{2}:00$'}\n", + " source source_id str {'pattern': '^W\\\\d{2}$'}\n", + " source source_type str {'enum': ['water_truck', 'purification_unit', 'well', 'river_intake']}\n", + " source grid_km list {'length': 2}\n", + " quality ph number - {'min': 0, 'max': 14}\n", + " quality turbidity_ntu number NTU {'min': 0, 'max': 1000}\n", + " quality chlorine_mgL number mg/L {'min': 0, 'max': 10}\n", + " quality metal_ugL number ug/L {'min': 0, 'max': 1000}\n", + " quality ecoli_cfu int CFU/100mL {'min': 0, 'max': 100000}\n", + " sensor status str {'enum': ['ok', 'calibration_due', 'fault', 'offline']}\n", + " sensor last_calibrated_h int {'min': 0, 'max': 720}\n", + "tactical weather str {'enum': ['clear', 'rain', 'night', 'snow', 'dust']}\n", + "tactical position_error_m number {'min': 0, 'max': 500}\n", + "tactical comm_delay_ms number {'min': 0, 'max': 10000}\n", + "스키마 필드 정의 완성도 15/15 (100%): 모든 필드에 자료형·검증 규칙 정의\n", + "\n", + "[가정 판정 기준(교육용)] (주의, 위험): {'ph': (5.8, 8.5), 'turbidity': (1.0, 2.0), 'chlorine': (0.2, 0.1), 'metal': (5.0, 10.0), 'ecoli': (1, 1)}\n", + "급수원 유형별 샘플 수: {'purification_unit': 7, 'river_intake': 7, 'water_truck': 3, 'well': 3}\n", + "```\n", + "
\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 02. 수질 센서 결측치 대체 및 시계열 센서 이상치 정제 전처리 모듈 구현\n", + "**소요시간**: 약 2.0시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "수질 센서 결측치 대체 및 시계열 이상치 정제 전처리 모듈을 구현한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **수질 센서의 결측치를 보정하고 시계열 이상치를 정제하는 전처리 모듈을 구현해 속도와 정확도를 확인한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **급수원별 시계열에서 물리 범위 밖 값, 같은 값이 반복되는 고정값(stuck), 튀었다가 바로 돌아오는 단발 스파이크를 표시·제거하고 짧은 결측은 선형 보간, 긴 결측은 표시 수행하고 검증하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `src/preprocess/wel03_preprocessor.py 및 전처리 전후 데이터셋` 형태의 결과물을 완성한다.\n", + "\n", + "### 🔍 평가 및 검증 기준 (PASS 조건)\n", + "#### 미션 판정\n", + "\n", + "**평가 구분:** 필수\n", + "\n", + "**핵심 평가영역:** 전술 환경 특유의 노이즈(스푸핑, 반사파, 센서 단절 등) 제거 타당성\n", + "\n", + "#### 미션 PASS 조건\n", + "\n", + "- 정량 기준: 데이터 정제 처리 속도 1,000건/초 이상, 전처리 오류율 0%\n", + "\n", + "\n", + "- 진행 기준: 정제 파이프라인 자동화 및 단위 테스트 케이스 5종 통과\n", + "\n", + "\n", + "- 검증 방법: pytest 기반 전처리 정합성 테스트 및 결측치 보정율 확인\n", + "\n", + "\n", + "#### 채점자 판정 메모\n", + "\n", + "> 단순 코드 실행 여부뿐만 아니라 출력된 수치와 근거 자료가 군 실무 요구사항에 부합하는지 면밀히 확인한다.\n", + "\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "\"\"\"수질 센서 시계열의 물리 범위 밖 값·고정값(stuck)·단발 스파이크를 정제하고 결측을 보정하는 전처리 모듈을 구현한 뒤, 처리 속도·오류율·보정 정확도와 단위 테스트 5종을 확인한다.\"\"\"\n", + "import time\n", + "import numpy as np\n", + "import pandas as pd\n", + "import matplotlib.pyplot as plt\n", + "\n", + "# ---------------------------------------------------------------------------\n", + "# [교육용가상자료] 야전 급수원 가상 수질 센서 데이터 생성기 (실제 부대·위치·검사 결과 없음)\n", + "# 가상 급수원 8곳 x 14일 x 1시간 간격. 실제 수질(true_*)·센서 측정값·오염 사건·센서 고장 정답을 함께 만든다.\n", + "# 기준치는 교육용 가정값이며, 실제 음용 판정은 공인 검사 결과와 의무(예방의학) 담당 기준에 따른다.\n", + "# ---------------------------------------------------------------------------\n", + "PARAMS = [\"ph\", \"turbidity\", \"chlorine\", \"metal\", \"ecoli\"]\n", + "SOURCES = [(\"W01\", \"water_truck\", 1.0, 2.0), (\"W02\", \"water_truck\", 3.5, 1.2), (\"W03\", \"purification_unit\", 2.2, 4.1),\n", + " (\"W04\", \"purification_unit\", 5.0, 3.3), (\"W05\", \"well\", 0.8, 5.6), (\"W06\", \"well\", 4.4, 6.0),\n", + " (\"W07\", \"river_intake\", 6.3, 1.8), (\"W08\", \"river_intake\", 6.9, 4.9)]\n", + "BASE = {\"water_truck\": (7.2, 0.25, 0.75, 1.5), \"purification_unit\": (7.0, 0.20, 0.85, 1.2),\n", + " \"well\": (6.8, 0.45, 0.50, 3.0), \"river_intake\": (7.3, 0.55, 0.60, 2.0)} # pH, 탁도(NTU), 잔류염소(mg/L), 중금속(ug/L)\n", + "LIMITS = {\"ph\": (5.8, 8.5), \"turbidity\": (1.0, 2.0), \"chlorine\": (0.2, 0.1), \"metal\": (5.0, 10.0), \"ecoli\": (1, 1)} # (주의, 위험) 가정 기준\n", + "EVENT_TYPES = { # 오염 사건: 변수별 최대 변화량, 최대치 도달 시간(h)\n", + " \"sewage\": ({\"turbidity\": 2.8, \"chlorine\": -0.5, \"ecoli\": 30, \"ph\": -0.3}, 4),\n", + " \"metal\": ({\"metal\": 22.0, \"ph\": -0.5}, 4),\n", + " \"disinfect_fail\": ({\"chlorine\": -0.8, \"ecoli\": 6}, 6),\n", + " \"chemical\": ({\"ph\": 1.7, \"turbidity\": 1.0}, 3),\n", + " \"creeping_metal\": ({\"metal\": 13.0}, 18),\n", + "}\n", + "FAULT_TYPES = [\"stuck\", \"spike\", \"drift\", \"dropout\"]\n", + "NOISE = {\"ph\": 0.04, \"turbidity\": 0.06, \"chlorine\": 0.03, \"metal\": 0.35}\n", + "DIGITS = {\"ph\": 2, \"turbidity\": 2, \"chlorine\": 2, \"metal\": 1}\n", + "\n", + "\n", + "def make_dataset(days=14, seed=7, n_events=6, n_faults=10, events=None, faults=None, sources=None):\n", + " \"\"\"events: [(급수원 번호, 사건 유형, 시작 시각 h, 지속 h, 강도)], faults: [(급수원 번호, 고장 유형, 항목, 시작 h, 지속 h)]\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " sources = sources or SOURCES\n", + " T = days * 24\n", + " tt = np.arange(T)\n", + " hour = tt % 24\n", + " night = (hour < 6) | (hour >= 20)\n", + " rain = np.zeros(T)\n", + " for _ in range(max(1, days // 4)):\n", + " s0, d = int(rng.integers(0, T - 14)), int(rng.integers(6, 14))\n", + " rain[s0:s0 + d] = np.maximum(rain[s0:s0 + d], rng.uniform(0.6, 1.0))\n", + " if events is None:\n", + " events = [(int(rng.integers(len(sources))), str(rng.choice(list(EVENT_TYPES))), int(rng.integers(min(24, T // 4), max(T // 4 + 1, T - 48))),\n", + " int(rng.integers(10, 30)), 1.0) for _ in range(n_events)]\n", + " if faults is None:\n", + " faults = [(int(rng.integers(len(sources))), str(rng.choice(FAULT_TYPES)), str(rng.choice([\"ph\", \"turbidity\", \"chlorine\", \"metal\"])),\n", + " int(rng.integers(12, max(13, T - 30))), int(rng.integers(6, 24))) for _ in range(n_faults)]\n", + " out = []\n", + " for si, (sid, stype, x, y) in enumerate(sources):\n", + " ph0, tb0, cl0, mt0 = BASE[stype]\n", + " tr = {\"ph\": ph0 + 0.08 * np.sin(2 * np.pi * hour / 24) + rng.normal(0, 0.03, T),\n", + " \"turbidity\": tb0 * (1 + 0.1 * rng.normal(0, 1, T)),\n", + " \"chlorine\": (cl0 * np.exp(-0.022 * hour) if stype == \"water_truck\" else cl0 + rng.normal(0, 0.03, T)),\n", + " \"metal\": mt0 + rng.normal(0, 0.2, T), \"ecoli\": np.zeros(T)}\n", + " if stype in (\"river_intake\", \"well\"):\n", + " tr[\"turbidity\"] = tr[\"turbidity\"] + rain * (0.9 if stype == \"river_intake\" else 0.45)\n", + " f_all, etype = np.zeros(T), np.array([\"none\"] * T, dtype=object)\n", + " for (s_i, et, onset, dur, scale) in events:\n", + " if s_i != si:\n", + " continue\n", + " delta, ramp = EVENT_TYPES[et]\n", + " f = np.clip((tt - onset + 1) / ramp, 0, 1) * ((tt >= onset) & (tt < onset + dur + ramp))\n", + " for k, v in delta.items():\n", + " tr[k] = tr[k] + v * f * scale\n", + " etype[f > 0] = et\n", + " f_all = np.maximum(f_all, f)\n", + " tr[\"chlorine\"] = np.clip(tr[\"chlorine\"], 0.02, None)\n", + " tr[\"turbidity\"] = np.clip(tr[\"turbidity\"], 0.05, None)\n", + " tr[\"metal\"] = np.clip(tr[\"metal\"], 0.1, None)\n", + " grade = np.where(f_all >= 0.2, 2, 0)\n", + " mild = (tr[\"turbidity\"] > LIMITS[\"turbidity\"][0]) | (tr[\"chlorine\"] < LIMITS[\"chlorine\"][0]) | (tr[\"metal\"] > LIMITS[\"metal\"][0])\n", + " grade = np.where((grade == 0) & mild, 1, grade)\n", + " ms = {}\n", + " for p in [\"ph\", \"turbidity\", \"chlorine\", \"metal\"]:\n", + " sd = NOISE[p] * np.where(night, 1.5, 1.0) * (tr[p] if p == \"turbidity\" else 1)\n", + " ms[p] = np.round(np.clip(tr[p] + rng.normal(0, 1, T) * sd, 0, None), DIGITS[p])\n", + " ms[\"ecoli\"] = (rng.poisson(tr[\"ecoli\"]) + (rng.random(T) < 0.004)).astype(float)\n", + " fault, fparam = np.array([\"none\"] * T, dtype=object), np.array([\"-\"] * T, dtype=object)\n", + " for (s_i, ft, p, s0, d) in faults:\n", + " if s_i != si:\n", + " continue\n", + " seg = slice(s0, min(T, s0 + d))\n", + " if ft == \"stuck\":\n", + " ms[p][seg] = ms[p][s0]\n", + " elif ft == \"spike\":\n", + " seg = slice(s0, s0 + 1)\n", + " ms[p][s0] = {\"ph\": 11.8, \"turbidity\": 38.0, \"chlorine\": 0.0, \"metal\": 75.0}[p]\n", + " elif ft == \"drift\":\n", + " ramp = np.linspace(0, 1, seg.stop - s0)\n", + " ms[p][seg] = np.clip(ms[p][seg] + ramp * {\"ph\": -1.6, \"turbidity\": 2.6, \"chlorine\": -0.75, \"metal\": 12.0}[p], 0, None).round(DIGITS[p])\n", + " elif ft == \"dropout\":\n", + " for q in PARAMS:\n", + " ms[q][seg] = np.nan\n", + " p = \"all\"\n", + " fault[seg], fparam[seg] = ft, p\n", + " df = pd.DataFrame({\"source_id\": sid, \"source_type\": stype, \"x_km\": x, \"y_km\": y, \"t\": tt,\n", + " \"cond\": np.where(rain > 0, \"storm\", np.where(night, \"night\", \"day\")),\n", + " **{p: ms[p] for p in PARAMS}, **{f\"true_{p}\": tr[p] for p in PARAMS},\n", + " \"event\": etype, \"fault\": fault, \"fault_param\": fparam, \"grade\": grade})\n", + " out.append(df)\n", + " return pd.concat(out, ignore_index=True)\n", + "# ---------------------------------------------------------------------------\n", + "\n", + "# ---- 전처리 공통 함수: 물리 범위 점검 -> 고정값(stuck) 표시 -> 단발 스파이크 제거 -> 결측 보정 ----\n", + "PHYS = {\"ph\": (0, 14), \"turbidity\": (0, 1000), \"chlorine\": (0, 10), \"metal\": (0, 1000), \"ecoli\": (0, 100000)}\n", + "SPIKE_K = {\"day\": 6.0, \"night\": 7.0, \"storm\": 9.0} # 상황별(주간/야간/악천후) 스파이크 판정 배수\n", + "MIN_JUMP = {\"ph\": 0.8, \"turbidity\": 1.5, \"chlorine\": 0.3, \"metal\": 6.0}\n", + "STUCK_RUN, SHORT_GAP = 6, 3\n", + "\n", + "\n", + "def clean(df, spike_k=None, min_jump=None):\n", + " \"\"\"급수원별 시계열을 정제하고 항목별 처리 내역(<항목>_flag: range/stuck/spike/interp/long_gap)을 남긴다.\"\"\"\n", + " spike_k = spike_k or SPIKE_K\n", + " min_jump = MIN_JUMP if min_jump is None else min_jump\n", + " out = df.sort_values([\"source_id\", \"t\"]).reset_index(drop=True)\n", + " for sid, idx in out.groupby(\"source_id\").groups.items():\n", + " idx = np.asarray(idx)\n", + " kk = out.loc[idx, \"cond\"].map(spike_k).to_numpy(float)\n", + " for p in PARAMS:\n", + " v = out.loc[idx, p].to_numpy(float).copy()\n", + " fl = np.array([\"\"] * len(v), dtype=object)\n", + " lo, hi = PHYS[p]\n", + " bad = (v < lo) | (v > hi)\n", + " v[bad], fl[bad] = np.nan, \"range\"\n", + " if p != \"ecoli\":\n", + " chg = np.r_[True, v[1:] != v[:-1]] # 같은 값이 STUCK_RUN시간 이상 반복 = 고정값 고장 의심\n", + " rid = np.cumsum(chg)\n", + " rlen = np.bincount(rid)[rid]\n", + " fl[(rlen >= STUCK_RUN) & ~chg & ~np.isnan(v)] = \"stuck\"\n", + " d = np.abs(np.diff(v[~np.isnan(v)]))\n", + " sig = 1.4826 * np.median(d) + 1e-6 if len(d) else 1.0\n", + " up, dn = v - np.r_[np.nan, v[:-1]], v - np.r_[v[1:], np.nan]\n", + " thr = np.maximum(kk * sig, min_jump[p])\n", + " spike = (np.abs(up) > thr) & (np.abs(dn) > thr) & (np.sign(up) == np.sign(dn)) # 튀었다가 바로 돌아온 값만\n", + " v[spike], fl[spike] = np.nan, \"spike\"\n", + " s = pd.Series(v)\n", + " na = s.isna().to_numpy()\n", + " grp = np.cumsum(np.r_[True, na[1:] != na[:-1]])\n", + " glen = pd.Series(na.astype(int)).groupby(grp).transform(\"sum\").to_numpy()\n", + " lin, ff = s.interpolate(limit_area=\"inside\").to_numpy(), s.ffill().bfill().to_numpy()\n", + " short, longg = na & (glen <= SHORT_GAP), na & (glen > SHORT_GAP)\n", + " v = np.where(short, np.where(np.isnan(lin), ff, lin), np.where(longg, ff, v))\n", + " fl[short & (fl == \"\")] = \"interp\"\n", + " fl[longg] = \"long_gap\"\n", + " out.loc[idx, p] = v\n", + " out.loc[idx, f\"{p}_flag\"] = fl\n", + " return out\n", + "\n", + "\n", + "data = make_dataset(days=14, seed=7, n_events=8, n_faults=16)\n", + "\n", + "# 1) 처리 속도와 오류율\n", + "t0 = time.perf_counter()\n", + "c = clean(data)\n", + "dt = time.perf_counter() - t0\n", + "remain = int(c[PARAMS].isna().sum().sum())\n", + "print(f\"전처리 {len(data):,}건, {len(data) / dt:,.0f}건/초, 처리 후 남은 결측 {remain}건, 오류율 {remain / len(data):.1%}\")\n", + "\n", + "# 2) 전처리 전후 비교: 센서 고장 유형별로 무엇이 처리되었는가\n", + "flag_cols = [f\"{p}_flag\" for p in PARAMS]\n", + "c[\"처리내역\"] = c[flag_cols].apply(lambda r: \",\".join(sorted({x for x in r if x})) or \"-\", axis=1)\n", + "print(\"\\n[실제 고장 유형 x 전처리 처리 내역] (행 수)\")\n", + "print(pd.crosstab(c[\"fault\"], c[\"처리내역\"]).to_string())\n", + "print(f\"결측 셀 수: 전처리 전 {int(data[PARAMS].isna().sum().sum())} -> 후 {remain}\")\n", + "\n", + "# 3) 결측 보정 정확도: 정상 구간에서 값을 일부 지워 보고, 실제 값과 비교\n", + "rng = np.random.default_rng(3)\n", + "test = data.copy()\n", + "normal_idx = test.index[(test[\"fault\"] == \"none\") & (test[\"event\"] == \"none\")]\n", + "holes = rng.choice(normal_idx, 120, replace=False)\n", + "test.loc[holes, \"chlorine\"] = np.nan\n", + "filled = clean(test).set_index([\"source_id\", \"t\"])\n", + "truth = data.loc[holes].set_index([\"source_id\", \"t\"])[\"true_chlorine\"]\n", + "mae = (filled.loc[truth.index, \"chlorine\"] - truth).abs().mean()\n", + "mean_fill = (data[\"chlorine\"].mean() - truth).abs().mean()\n", + "print(f\"\\n잔류염소 결측 120건 보정 오차(MAE): 선형 보간 {mae:.3f} mg/L vs 전체 평균 대체 {mean_fill:.3f} mg/L\")\n", + "\n", + "# 4) 단위 테스트 5종\n", + "def unit_tests():\n", + " base = make_dataset(days=3, seed=5, n_events=0, n_faults=0, events=[], faults=[])\n", + " res = {}\n", + " t = base.copy(); t.loc[(t.source_id == \"W01\") & (t.t == 30), \"ph\"] = 15.0\n", + " r = clean(t).query(\"source_id == 'W01' and t == 30\").iloc[0]\n", + " res[\"T1 물리 범위 밖 pH 15.0 제거 후 보정\"] = r[\"ph_flag\"] == \"range\" and 6.0 < r[\"ph\"] < 8.0\n", + " t = base.copy(); t.loc[(t.source_id == \"W03\") & (t.t == 40), \"turbidity\"] = 38.0\n", + " r = clean(t).query(\"source_id == 'W03' and t == 40\").iloc[0]\n", + " res[\"T2 단발 스파이크(탁도 38) 제거\"] = r[\"turbidity_flag\"] == \"spike\" and r[\"turbidity\"] < 1.0\n", + " ev = make_dataset(days=3, seed=5, events=[(2, \"sewage\", 30, 12, 1.0)], faults=[])\n", + " r = clean(ev).query(\"source_id == 'W03' and 30 <= t <= 45\")\n", + " res[\"T3 지속 상승(하수 유입)은 보존\"] = (r[\"turbidity_flag\"] != \"spike\").all() and r[\"turbidity\"].max() > 2.0\n", + " fz = make_dataset(days=3, seed=5, events=[], faults=[(4, \"stuck\", \"chlorine\", 20, 10)])\n", + " r = clean(fz).query(\"source_id == 'W05'\")\n", + " res[\"T4 10시간 고정값 -> stuck 표시\"] = (r[\"chlorine_flag\"] == \"stuck\").sum() == 9\n", + " t = base.copy(); t.loc[(t.source_id == \"W07\") & t.t.isin([10, 11]), PARAMS] = np.nan\n", + " t.loc[(t.source_id == \"W07\") & t.t.between(40, 47), PARAMS] = np.nan\n", + " r = clean(t).query(\"source_id == 'W07'\").set_index(\"t\")\n", + " res[\"T5 2시간 결측 보간 / 8시간 결측 표시\"] = (r.loc[[10, 11], \"ph_flag\"] == \"interp\").all() and (r.loc[40:47, \"ph_flag\"] == \"long_gap\").all()\n", + " return res\n", + "\n", + "\n", + "res = unit_tests()\n", + "print(\"\\n[단위 테스트]\")\n", + "for k, v in res.items():\n", + " print(f\" [{'PASS' if v else 'FAIL'}] {k}\")\n", + "print(f\"단위 테스트 {sum(res.values())}/5 통과\")\n", + "\n", + "# 5) 상황별(주간/야간/악천후) 임계값 동적 조정 효과: 최소 변화폭 하한을 끄고 배수만 비교\n", + "# (고장이 없는 행이 스파이크로 잘못 제거된 건수 / 실제 스파이크 고장 검출 건수)\n", + "no_floor = {p: 0.0 for p in MIN_JUMP}\n", + "for name, k in [(\"고정 임계(모든 상황 3배)\", {\"day\": 3.0, \"night\": 3.0, \"storm\": 3.0}), (\"상황별 동적 임계\", SPIKE_K)]:\n", + " cc = clean(data, spike_k=k, min_jump=no_floor)\n", + " sp = (cc[flag_cols] == \"spike\").any(axis=1)\n", + " wrong, caught = int((sp & (cc[\"fault\"] != \"spike\")).sum()), int((sp & (cc[\"fault\"] == \"spike\")).sum())\n", + " print(f\"{name}: 정상 변동 오제거 {wrong}건(야간·악천후 {int((sp & (cc['fault'] != 'spike') & (cc['cond'] != 'day')).sum())}건), 스파이크 검출 {caught}건\")\n", + "print(\"운용 설정은 동적 임계 + 항목별 최소 변화폭 하한(MIN_JUMP)을 함께 쓴다.\")\n", + "\n", + "# 6) 전후 비교 그림\n", + "sid = data.loc[data[\"fault\"].isin([\"spike\", \"dropout\"]), \"source_id\"].iloc[0]\n", + "raw, cl = data[data.source_id == sid], c[c.source_id == sid]\n", + "fig, ax = plt.subplots(2, 1, figsize=(10, 6), sharex=True)\n", + "for a, p in zip(ax, [\"turbidity\", \"chlorine\"]):\n", + " a.plot(raw[\"t\"], raw[p], color=\"lightgray\", lw=3, label=\"raw sensor\")\n", + " a.plot(cl[\"t\"], cl[p], color=\"tab:blue\", lw=1, label=\"cleaned\")\n", + " a.set_ylabel(p)\n", + " a.legend(loc=\"upper right\")\n", + "ax[1].set_xlabel(\"hour\")\n", + "plt.suptitle(f\"{sid} before / after preprocessing (synthetic)\")\n", + "plt.tight_layout()\n", + "plt.show()\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 02 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + "전처리 2,688건, 22,643건/초, 처리 후 남은 결측 0건, 오류율 0.0%\n", + "\n", + "[실제 고장 유형 x 전처리 처리 내역] (행 수)\n", + "처리내역 - long_gap spike stuck\n", + "fault \n", + "drift 78 8 0 0\n", + "dropout 0 17 0 0\n", + "none 2485 0 0 0\n", + "spike 1 0 2 0\n", + "stuck 6 0 0 91\n", + "결측 셀 수: 전처리 전 125 -> 후 0\n", + "\n", + "잔류염소 결측 120건 보정 오차(MAE): 선형 보간 0.042 mg/L vs 전체 평균 대체 0.122 mg/L\n", + "\n", + "[단위 테스트]\n", + " [PASS] T1 물리 범위 밖 pH 15.0 제거 후 보정\n", + " [PASS] T2 단발 스파이크(탁도 38) 제거\n", + " [PASS] T3 지속 상승(하수 유입)은 보존\n", + " [PASS] T4 10시간 고정값 -> stuck 표시\n", + " [PASS] T5 2시간 결측 보간 / 8시간 결측 표시\n", + "단위 테스트 5/5 통과\n", + "고정 임계(모든 상황 3배): 정상 변동 오제거 7건(야간·악천후 6건), 스파이크 검출 3건\n", + "상황별 동적 임계: 정상 변동 오제거 1건(야간·악천후 0건), 스파이크 검출 2건\n", + "운용 설정은 동적 임계 + 항목별 최소 변화폭 하한(MIN_JUMP)을 함께 쓴다.\n", + "```\n", + "
\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 03. 음용 적합/부적합 판정 1차 기준치 필터링 Baseline 알고리즘 구현\n", + "**소요시간**: 약 2.5시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "음용 적합/부적합 판정 1차 기준치 필터링 Baseline 알고리즘을 구현한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **음용 적합·부적합 판정의 1차 기준치 필터링 Baseline 알고리즘을 구현하고 정확도를 측정한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **전처리한 자료에 pH, 탁도, 잔류염소, 중금속, 대장균의 주의·위험 가정 기준을 적용해 안전·주의·위험 3등급 판정하고 정확도를 측정하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `src/models/wel03_baseline_model.py 및 기본 추론 결과 보고서` 형태의 결과물을 완성한다.\n", + "\n", + "### 🔍 평가 및 검증 기준 (PASS 조건)\n", + "#### 미션 판정\n", + "\n", + "**평가 구분:** 필수\n", + "\n", + "**핵심 평가영역:** 알고리즘의 논리적 흐름 및 군사적 판단 기준의 투명성\n", + "\n", + "#### 미션 PASS 조건\n", + "\n", + "- 정량 기준: 기본 데이터셋 대상 탐지/처리 정확도 75% 이상 달성\n", + "\n", + "\n", + "- 진행 기준: 단위 기능 동작 확인 및 기본 시나리오 실행 로그 생성\n", + "\n", + "\n", + "- 검증 방법: 기준 벤치마크 데이터셋 대상 추론 결과 오차율 비교\n", + "\n", + "\n", + "#### 채점자 판정 메모\n", + "\n", + "> 단순 코드 실행 여부뿐만 아니라 출력된 수치와 근거 자료가 군 실무 요구사항에 부합하는지 면밀히 확인한다.\n", + "\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "\"\"\"가정 기준치(주의·위험)를 넘는 항목이 있으면 음용 부적합 의심으로 판정하는 1차 필터링 Baseline 알고리즘을 구현하고, 기준 벤치마크 데이터에서 정확도·오차 유형·판정 근거·처리 지연을 확인한다.\"\"\"\n", + "import time\n", + "import numpy as np\n", + "import pandas as pd\n", + "import matplotlib.pyplot as plt\n", + "\n", + "# ---------------------------------------------------------------------------\n", + "# [교육용가상자료] 야전 급수원 가상 수질 센서 데이터 생성기 (실제 부대·위치·검사 결과 없음)\n", + "# 가상 급수원 8곳 x 14일 x 1시간 간격. 실제 수질(true_*)·센서 측정값·오염 사건·센서 고장 정답을 함께 만든다.\n", + "# 기준치는 교육용 가정값이며, 실제 음용 판정은 공인 검사 결과와 의무(예방의학) 담당 기준에 따른다.\n", + "# ---------------------------------------------------------------------------\n", + "PARAMS = [\"ph\", \"turbidity\", \"chlorine\", \"metal\", \"ecoli\"]\n", + "SOURCES = [(\"W01\", \"water_truck\", 1.0, 2.0), (\"W02\", \"water_truck\", 3.5, 1.2), (\"W03\", \"purification_unit\", 2.2, 4.1),\n", + " (\"W04\", \"purification_unit\", 5.0, 3.3), (\"W05\", \"well\", 0.8, 5.6), (\"W06\", \"well\", 4.4, 6.0),\n", + " (\"W07\", \"river_intake\", 6.3, 1.8), (\"W08\", \"river_intake\", 6.9, 4.9)]\n", + "BASE = {\"water_truck\": (7.2, 0.25, 0.75, 1.5), \"purification_unit\": (7.0, 0.20, 0.85, 1.2),\n", + " \"well\": (6.8, 0.45, 0.50, 3.0), \"river_intake\": (7.3, 0.55, 0.60, 2.0)} # pH, 탁도(NTU), 잔류염소(mg/L), 중금속(ug/L)\n", + "LIMITS = {\"ph\": (5.8, 8.5), \"turbidity\": (1.0, 2.0), \"chlorine\": (0.2, 0.1), \"metal\": (5.0, 10.0), \"ecoli\": (1, 1)} # (주의, 위험) 가정 기준\n", + "EVENT_TYPES = { # 오염 사건: 변수별 최대 변화량, 최대치 도달 시간(h)\n", + " \"sewage\": ({\"turbidity\": 2.8, \"chlorine\": -0.5, \"ecoli\": 30, \"ph\": -0.3}, 4),\n", + " \"metal\": ({\"metal\": 22.0, \"ph\": -0.5}, 4),\n", + " \"disinfect_fail\": ({\"chlorine\": -0.8, \"ecoli\": 6}, 6),\n", + " \"chemical\": ({\"ph\": 1.7, \"turbidity\": 1.0}, 3),\n", + " \"creeping_metal\": ({\"metal\": 13.0}, 18),\n", + "}\n", + "FAULT_TYPES = [\"stuck\", \"spike\", \"drift\", \"dropout\"]\n", + "NOISE = {\"ph\": 0.04, \"turbidity\": 0.06, \"chlorine\": 0.03, \"metal\": 0.35}\n", + "DIGITS = {\"ph\": 2, \"turbidity\": 2, \"chlorine\": 2, \"metal\": 1}\n", + "\n", + "\n", + "def make_dataset(days=14, seed=7, n_events=6, n_faults=10, events=None, faults=None, sources=None):\n", + " \"\"\"events: [(급수원 번호, 사건 유형, 시작 시각 h, 지속 h, 강도)], faults: [(급수원 번호, 고장 유형, 항목, 시작 h, 지속 h)]\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " sources = sources or SOURCES\n", + " T = days * 24\n", + " tt = np.arange(T)\n", + " hour = tt % 24\n", + " night = (hour < 6) | (hour >= 20)\n", + " rain = np.zeros(T)\n", + " for _ in range(max(1, days // 4)):\n", + " s0, d = int(rng.integers(0, T - 14)), int(rng.integers(6, 14))\n", + " rain[s0:s0 + d] = np.maximum(rain[s0:s0 + d], rng.uniform(0.6, 1.0))\n", + " if events is None:\n", + " events = [(int(rng.integers(len(sources))), str(rng.choice(list(EVENT_TYPES))), int(rng.integers(min(24, T // 4), max(T // 4 + 1, T - 48))),\n", + " int(rng.integers(10, 30)), 1.0) for _ in range(n_events)]\n", + " if faults is None:\n", + " faults = [(int(rng.integers(len(sources))), str(rng.choice(FAULT_TYPES)), str(rng.choice([\"ph\", \"turbidity\", \"chlorine\", \"metal\"])),\n", + " int(rng.integers(12, max(13, T - 30))), int(rng.integers(6, 24))) for _ in range(n_faults)]\n", + " out = []\n", + " for si, (sid, stype, x, y) in enumerate(sources):\n", + " ph0, tb0, cl0, mt0 = BASE[stype]\n", + " tr = {\"ph\": ph0 + 0.08 * np.sin(2 * np.pi * hour / 24) + rng.normal(0, 0.03, T),\n", + " \"turbidity\": tb0 * (1 + 0.1 * rng.normal(0, 1, T)),\n", + " \"chlorine\": (cl0 * np.exp(-0.022 * hour) if stype == \"water_truck\" else cl0 + rng.normal(0, 0.03, T)),\n", + " \"metal\": mt0 + rng.normal(0, 0.2, T), \"ecoli\": np.zeros(T)}\n", + " if stype in (\"river_intake\", \"well\"):\n", + " tr[\"turbidity\"] = tr[\"turbidity\"] + rain * (0.9 if stype == \"river_intake\" else 0.45)\n", + " f_all, etype = np.zeros(T), np.array([\"none\"] * T, dtype=object)\n", + " for (s_i, et, onset, dur, scale) in events:\n", + " if s_i != si:\n", + " continue\n", + " delta, ramp = EVENT_TYPES[et]\n", + " f = np.clip((tt - onset + 1) / ramp, 0, 1) * ((tt >= onset) & (tt < onset + dur + ramp))\n", + " for k, v in delta.items():\n", + " tr[k] = tr[k] + v * f * scale\n", + " etype[f > 0] = et\n", + " f_all = np.maximum(f_all, f)\n", + " tr[\"chlorine\"] = np.clip(tr[\"chlorine\"], 0.02, None)\n", + " tr[\"turbidity\"] = np.clip(tr[\"turbidity\"], 0.05, None)\n", + " tr[\"metal\"] = np.clip(tr[\"metal\"], 0.1, None)\n", + " grade = np.where(f_all >= 0.2, 2, 0)\n", + " mild = (tr[\"turbidity\"] > LIMITS[\"turbidity\"][0]) | (tr[\"chlorine\"] < LIMITS[\"chlorine\"][0]) | (tr[\"metal\"] > LIMITS[\"metal\"][0])\n", + " grade = np.where((grade == 0) & mild, 1, grade)\n", + " ms = {}\n", + " for p in [\"ph\", \"turbidity\", \"chlorine\", \"metal\"]:\n", + " sd = NOISE[p] * np.where(night, 1.5, 1.0) * (tr[p] if p == \"turbidity\" else 1)\n", + " ms[p] = np.round(np.clip(tr[p] + rng.normal(0, 1, T) * sd, 0, None), DIGITS[p])\n", + " ms[\"ecoli\"] = (rng.poisson(tr[\"ecoli\"]) + (rng.random(T) < 0.004)).astype(float)\n", + " fault, fparam = np.array([\"none\"] * T, dtype=object), np.array([\"-\"] * T, dtype=object)\n", + " for (s_i, ft, p, s0, d) in faults:\n", + " if s_i != si:\n", + " continue\n", + " seg = slice(s0, min(T, s0 + d))\n", + " if ft == \"stuck\":\n", + " ms[p][seg] = ms[p][s0]\n", + " elif ft == \"spike\":\n", + " seg = slice(s0, s0 + 1)\n", + " ms[p][s0] = {\"ph\": 11.8, \"turbidity\": 38.0, \"chlorine\": 0.0, \"metal\": 75.0}[p]\n", + " elif ft == \"drift\":\n", + " ramp = np.linspace(0, 1, seg.stop - s0)\n", + " ms[p][seg] = np.clip(ms[p][seg] + ramp * {\"ph\": -1.6, \"turbidity\": 2.6, \"chlorine\": -0.75, \"metal\": 12.0}[p], 0, None).round(DIGITS[p])\n", + " elif ft == \"dropout\":\n", + " for q in PARAMS:\n", + " ms[q][seg] = np.nan\n", + " p = \"all\"\n", + " fault[seg], fparam[seg] = ft, p\n", + " df = pd.DataFrame({\"source_id\": sid, \"source_type\": stype, \"x_km\": x, \"y_km\": y, \"t\": tt,\n", + " \"cond\": np.where(rain > 0, \"storm\", np.where(night, \"night\", \"day\")),\n", + " **{p: ms[p] for p in PARAMS}, **{f\"true_{p}\": tr[p] for p in PARAMS},\n", + " \"event\": etype, \"fault\": fault, \"fault_param\": fparam, \"grade\": grade})\n", + " out.append(df)\n", + " return pd.concat(out, ignore_index=True)\n", + "# ---------------------------------------------------------------------------\n", + "\n", + "# ---- 전처리 공통 함수: 물리 범위 점검 -> 고정값(stuck) 표시 -> 단발 스파이크 제거 -> 결측 보정 ----\n", + "PHYS = {\"ph\": (0, 14), \"turbidity\": (0, 1000), \"chlorine\": (0, 10), \"metal\": (0, 1000), \"ecoli\": (0, 100000)}\n", + "SPIKE_K = {\"day\": 6.0, \"night\": 7.0, \"storm\": 9.0} # 상황별(주간/야간/악천후) 스파이크 판정 배수\n", + "MIN_JUMP = {\"ph\": 0.8, \"turbidity\": 1.5, \"chlorine\": 0.3, \"metal\": 6.0}\n", + "STUCK_RUN, SHORT_GAP = 6, 3\n", + "\n", + "\n", + "def clean(df, spike_k=None, min_jump=None):\n", + " \"\"\"급수원별 시계열을 정제하고 항목별 처리 내역(<항목>_flag: range/stuck/spike/interp/long_gap)을 남긴다.\"\"\"\n", + " spike_k = spike_k or SPIKE_K\n", + " min_jump = MIN_JUMP if min_jump is None else min_jump\n", + " out = df.sort_values([\"source_id\", \"t\"]).reset_index(drop=True)\n", + " for sid, idx in out.groupby(\"source_id\").groups.items():\n", + " idx = np.asarray(idx)\n", + " kk = out.loc[idx, \"cond\"].map(spike_k).to_numpy(float)\n", + " for p in PARAMS:\n", + " v = out.loc[idx, p].to_numpy(float).copy()\n", + " fl = np.array([\"\"] * len(v), dtype=object)\n", + " lo, hi = PHYS[p]\n", + " bad = (v < lo) | (v > hi)\n", + " v[bad], fl[bad] = np.nan, \"range\"\n", + " if p != \"ecoli\":\n", + " chg = np.r_[True, v[1:] != v[:-1]] # 같은 값이 STUCK_RUN시간 이상 반복 = 고정값 고장 의심\n", + " rid = np.cumsum(chg)\n", + " rlen = np.bincount(rid)[rid]\n", + " fl[(rlen >= STUCK_RUN) & ~chg & ~np.isnan(v)] = \"stuck\"\n", + " d = np.abs(np.diff(v[~np.isnan(v)]))\n", + " sig = 1.4826 * np.median(d) + 1e-6 if len(d) else 1.0\n", + " up, dn = v - np.r_[np.nan, v[:-1]], v - np.r_[v[1:], np.nan]\n", + " thr = np.maximum(kk * sig, min_jump[p])\n", + " spike = (np.abs(up) > thr) & (np.abs(dn) > thr) & (np.sign(up) == np.sign(dn)) # 튀었다가 바로 돌아온 값만\n", + " v[spike], fl[spike] = np.nan, \"spike\"\n", + " s = pd.Series(v)\n", + " na = s.isna().to_numpy()\n", + " grp = np.cumsum(np.r_[True, na[1:] != na[:-1]])\n", + " glen = pd.Series(na.astype(int)).groupby(grp).transform(\"sum\").to_numpy()\n", + " lin, ff = s.interpolate(limit_area=\"inside\").to_numpy(), s.ffill().bfill().to_numpy()\n", + " short, longg = na & (glen <= SHORT_GAP), na & (glen > SHORT_GAP)\n", + " v = np.where(short, np.where(np.isnan(lin), ff, lin), np.where(longg, ff, v))\n", + " fl[short & (fl == \"\")] = \"interp\"\n", + " fl[longg] = \"long_gap\"\n", + " out.loc[idx, p] = v\n", + " out.loc[idx, f\"{p}_flag\"] = fl\n", + " return out\n", + "\n", + "# ---- 가정 기준치 판정(Baseline)과 시계열 특징 공통 함수 ----\n", + "GRADE_NAME = {0: \"안전\", 1: \"주의\", 2: \"위험\"}\n", + "\n", + "\n", + "def rule_grade(df):\n", + " \"\"\"기준치 1차 필터: 위험(음용 부적합 의심) = 한 항목이라도 위험 기준 초과, 주의 = 주의 기준 초과.\"\"\"\n", + " ph, tb, cl, mt, ec = (df[p].to_numpy(float) for p in PARAMS)\n", + " danger = (ph < LIMITS[\"ph\"][0]) | (ph > LIMITS[\"ph\"][1]) | (tb > LIMITS[\"turbidity\"][1]) | (cl < LIMITS[\"chlorine\"][1]) \\\n", + " | (mt > LIMITS[\"metal\"][1]) | (ec >= LIMITS[\"ecoli\"][1])\n", + " caution = (tb > LIMITS[\"turbidity\"][0]) | (cl < LIMITS[\"chlorine\"][0]) | (mt > LIMITS[\"metal\"][0])\n", + " return np.where(danger, 2, np.where(caution, 1, 0))\n", + "\n", + "\n", + "def reasons(row):\n", + " r = []\n", + " if not LIMITS[\"ph\"][0] <= row[\"ph\"] <= LIMITS[\"ph\"][1]:\n", + " r.append(f\"pH {row['ph']:.2f}\")\n", + " if row[\"turbidity\"] > LIMITS[\"turbidity\"][0]:\n", + " r.append(f\"탁도 {row['turbidity']:.2f}NTU\")\n", + " if row[\"chlorine\"] < LIMITS[\"chlorine\"][0]:\n", + " r.append(f\"잔류염소 {row['chlorine']:.2f}mg/L\")\n", + " if row[\"metal\"] > LIMITS[\"metal\"][0]:\n", + " r.append(f\"중금속 {row['metal']:.1f}ug/L\")\n", + " if row[\"ecoli\"] >= 1:\n", + " r.append(f\"대장균 {row['ecoli']:.0f}\")\n", + " return \", \".join(r) or \"-\"\n", + "\n", + "\n", + "FEATS = [f\"{p}{s}\" for p in [\"ph\", \"turbidity\", \"chlorine\", \"metal\"] for s in [\"\", \"_d1\", \"_d3\", \"_dev\", \"_sd6\"]] + \\\n", + " [\"ecoli\", \"ecoli_3h\", \"storm\", \"night\", \"n_flag\", \"truck\", \"purif\", \"well\", \"river\"]\n", + "\n", + "\n", + "def add_features(c):\n", + " \"\"\"시간 순서를 지키는(과거 값만 쓰는) 급수원별 특징: 변화량, 24시간 기준선 대비 편차, 6시간 변동성, 대장균 누적 등.\"\"\"\n", + " c = c.sort_values([\"source_id\", \"t\"]).reset_index(drop=True)\n", + " g = c.groupby(\"source_id\")\n", + " for p in [\"ph\", \"turbidity\", \"chlorine\", \"metal\"]:\n", + " c[f\"{p}_d1\"] = g[p].diff().fillna(0)\n", + " c[f\"{p}_d3\"] = g[p].diff(3).fillna(0)\n", + " base = g[p].transform(lambda s: s.shift(1).rolling(24, min_periods=4).median())\n", + " c[f\"{p}_dev\"] = (c[p] - base).fillna(0)\n", + " c[f\"{p}_sd6\"] = g[p].transform(lambda s: s.rolling(6, min_periods=2).std()).fillna(0)\n", + " c[\"ecoli_3h\"] = g[\"ecoli\"].transform(lambda s: s.rolling(3, min_periods=1).sum())\n", + " c[\"storm\"], c[\"night\"] = (c[\"cond\"] == \"storm\").astype(int), (c[\"cond\"] == \"night\").astype(int)\n", + " flags = [f\"{p}_flag\" for p in PARAMS if f\"{p}_flag\" in c]\n", + " c[\"n_flag\"] = sum(c[f].isin([\"stuck\", \"spike\", \"long_gap\", \"range\"]).astype(int) for f in flags) if flags else 0\n", + " for k, name in [(\"truck\", \"water_truck\"), (\"purif\", \"purification_unit\"), (\"well\", \"well\"), (\"river\", \"river_intake\")]:\n", + " c[k] = (c[\"source_type\"] == name).astype(int)\n", + " return c\n", + "\n", + "\n", + "# 기준 벤치마크 데이터셋: 가상 급수원 8곳 x 14일, 오염 사건 10건, 센서 고장 14건 (정답 등급 grade 포함)\n", + "bench = make_dataset(days=14, seed=11, n_events=10, n_faults=14)\n", + "truth_raw = bench.sort_values([\"source_id\", \"t\"])[\"grade\"].to_numpy()\n", + "\n", + "# 1) 전처리 없이(결측은 0으로) vs 전처리 후 Baseline 판정\n", + "t0 = time.perf_counter()\n", + "c = clean(bench)\n", + "c[\"pred\"] = rule_grade(c)\n", + "dt = time.perf_counter() - t0\n", + "raw = bench.sort_values([\"source_id\", \"t\"]).fillna({p: 0 for p in PARAMS})\n", + "acc_raw = (rule_grade(raw) == truth_raw).mean()\n", + "acc = (c[\"pred\"] == c[\"grade\"]).mean()\n", + "print(f\"[교육용가상자료] 벤치마크 {len(c):,}건(급수원 8곳 x 336시간)\")\n", + "print(f\"정확도: 전처리 없음 {acc_raw:.1%} -> 전처리 + 기준치 필터 {acc:.1%} (목표 75% 이상: {'달성' if acc >= 0.75 else '미달'})\")\n", + "print(f\"처리 지연: 전체 {dt * 1000:.0f}ms, 1건당 {dt / len(c) * 1000:.3f}ms\")\n", + "\n", + "# 2) 혼동행렬과 오차 유형\n", + "cm = pd.crosstab(c[\"grade\"].map(GRADE_NAME), c[\"pred\"].map(GRADE_NAME), rownames=[\"실제\"], colnames=[\"판정\"])\n", + "cm = cm.reindex(index=[\"안전\", \"주의\", \"위험\"], columns=[\"안전\", \"주의\", \"위험\"], fill_value=0)\n", + "print(\"\\n[혼동행렬]\")\n", + "print(cm.to_string())\n", + "danger = c[\"grade\"] == 2\n", + "rec = ((c[\"pred\"] == 2) & danger).sum() / danger.sum()\n", + "far = ((c[\"pred\"] == 2) & ~danger).sum() / max((c[\"pred\"] == 2).sum(), 1)\n", + "print(f\"위험 재현율 {rec:.1%}, 위험 경보 중 오경보 비율 {far:.1%}\")\n", + "miss = c[danger & (c[\"pred\"] != 2)]\n", + "fa = c[~danger & (c[\"pred\"] == 2)]\n", + "print(\"놓친 위험(사건 유형별):\", miss[\"event\"].value_counts().to_dict())\n", + "print(\"오경보(센서 고장 유형별):\", fa[\"fault\"].value_counts().to_dict())\n", + "\n", + "# 3) 판정 근거(투명성): 위험 판정 사례 일부의 초과 항목\n", + "print(\"\\n[위험 판정 근거 예시]\")\n", + "show = c[c[\"pred\"] == 2].groupby(\"source_id\").head(1).head(5)\n", + "for _, r in show.iterrows():\n", + " print(f\" {r['source_id']} D+{int(r['t']) // 24:02d} {int(r['t']) % 24:02d}시: {reasons(r)} -> 실제 {GRADE_NAME[r['grade']]}({r['event']})\")\n", + "\n", + "# 4) 휴리스틱 1차 튜닝: 위험 판정을 '2시간 연속 초과'로 바꾸면 오경보는 줄지만 놓침이 늘어난다\n", + "c[\"pred2\"] = c[\"pred\"]\n", + "prev = c.groupby(\"source_id\")[\"pred\"].shift(1).fillna(0)\n", + "c.loc[(c[\"pred\"] == 2) & (prev < 2), \"pred2\"] = 1\n", + "for name, col in [(\"1시간 즉시 판정\", \"pred\"), (\"2시간 연속 확인\", \"pred2\")]:\n", + " p = c[col]\n", + " print(f\"{name}: 정확도 {(p == c['grade']).mean():.1%}, 위험 재현율 {((p == 2) & danger).sum() / danger.sum():.1%}, \"\n", + " f\"오경보 {int(((p == 2) & ~danger).sum())}건\")\n", + "print(\"-> 음용수는 놓침이 더 위험하므로 Baseline은 즉시 판정을 유지하고, 오경보는 미션 5 신뢰도 지수로 다룬다.\")\n", + "print(\"※ 기준치는 교육용 가정값이다. 실제 음용 적합 판정은 공식 수질 기준과 의무(예방의학)·환경 담당 확인에 따른다.\")\n", + "\n", + "sid = miss[\"source_id\"].mode().iloc[0] if len(miss) else \"W01\"\n", + "s = c[c[\"source_id\"] == sid]\n", + "fig, ax = plt.subplots(figsize=(10, 3.5))\n", + "ax.plot(s[\"t\"], s[\"grade\"], color=\"black\", lw=2, label=\"true grade\")\n", + "ax.plot(s[\"t\"], s[\"pred\"] + 0.05, color=\"tab:red\", lw=1, label=\"baseline grade\")\n", + "ax.set_yticks([0, 1, 2], [\"safe\", \"caution\", \"danger\"]); ax.set_xlabel(\"hour\"); ax.legend()\n", + "ax.set_title(f\"{sid} baseline vs true grade (synthetic)\")\n", + "plt.tight_layout(); plt.show()\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 03 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + "[교육용가상자료] 벤치마크 2,688건(급수원 8곳 x 336시간)\n", + "정확도: 전처리 없음 96.8% -> 전처리 + 기준치 필터 97.9% (목표 75% 이상: 달성)\n", + "처리 지연: 전체 158ms, 1건당 0.059ms\n", + "\n", + "[혼동행렬]\n", + "판정 안전 주의 위험\n", + "실제 \n", + "안전 2369 8 17\n", + "주의 3 45 0\n", + "위험 9 19 218\n", + "위험 재현율 88.6%, 위험 경보 중 오경보 비율 7.2%\n", + "놓친 위험(사건 유형별): {'creeping_metal': 22, 'chemical': 4, 'disinfect_fail': 1, 'metal': 1}\n", + "오경보(센서 고장 유형별): {'none': 11, 'drift': 6}\n", + "\n", + "[위험 판정 근거 예시]\n", + " W01 D+13 20시: 대장균 1 -> 실제 안전(none)\n", + " W02 D+06 03시: pH 9.04, 탁도 1.24NTU -> 실제 위험(chemical)\n", + " W03 D+02 03시: 대장균 1 -> 실제 안전(none)\n", + " W04 D+12 00시: pH 8.65, 탁도 1.16NTU -> 실제 위험(chemical)\n", + " W05 D+00 09시: 대장균 1 -> 실제 안전(none)\n", + "1시간 즉시 판정: 정확도 97.9%, 위험 재현율 88.6%, 오경보 17건\n", + "2시간 연속 확인: 정확도 97.6%, 위험 재현율 85.0%, 오경보 5건\n", + "-> 음용수는 놓침이 더 위험하므로 Baseline은 즉시 판정을 유지하고, 오경보는 미션 5 신뢰도 지수로 다룬다.\n", + "※ 기준치는 교육용 가정값이다. 실제 음용 적합 판정은 공식 수질 기준과 의무(예방의학)·환경 담당 확인에 따른다.\n", + "```\n", + "
\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 04. 복합 오염원 유입 징후 및 급수 안전도 위험 등급 분류 머신러닝 모델 고도화\n", + "**소요시간**: 약 3.0시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "복합 오염원 유입 징후 및 급수 위험 등급 분류 머신러닝 모델을 고도화한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **복합 오염원 유입 징후와 급수 안전도 위험 등급을 분류하는 머신러닝 모델을 고도화하고 방어 로직을 검증한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **변화량, 24시간 기준선 대비 편차, 6시간 변동성, 대장균 누적, 센서 처리 표시 등 29개 특징으로 랜덤 포레스트를 학습하고 Baseline과 비교 수행하고 검증하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `src/models/wel03_advanced_detector.py 및 예외 탐지 테스트 결과` 형태의 결과물을 완성한다.\n", + "\n", + "### 🔍 평가 및 검증 기준 (PASS 조건)\n", + "#### 미션 판정\n", + "\n", + "**평가 구분:** 필수\n", + "\n", + "**핵심 평가영역:** 기만/교란 공격에 대한 방어 로직의 현실성 및 신뢰성\n", + "\n", + "#### 미션 PASS 조건\n", + "\n", + "- 정량 기준: 복합 위협 탐지 재현율(Recall) 85% 이상, 처리 지연 200ms 이하\n", + "\n", + "\n", + "- 진행 기준: 복합 위협 시나리오 10종에 대한 탐지 테스트 통과\n", + "\n", + "\n", + "- 검증 방법: 합성 위협 데이터셋 주입 시 이상치 탐지 정확도 검증\n", + "\n", + "\n", + "#### 채점자 판정 메모\n", + "\n", + "> 단순 코드 실행 여부뿐만 아니라 출력된 수치와 근거 자료가 군 실무 요구사항에 부합하는지 면밀히 확인한다.\n", + "\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "\"\"\"시계열 특징(변화량·기준선 편차·변동성·대장균 누적·센서 처리 표시)으로 급수 안전도 위험 등급(안전·주의·위험)을 분류하는 랜덤 포레스트 모델을 학습하고, 복합 위협 시나리오 10종(오염 사건 7종, 센서 교란 3종)으로 재현율·오경보·처리 지연을 검증한다.\"\"\"\n", + "import time\n", + "import numpy as np\n", + "import pandas as pd\n", + "import matplotlib.pyplot as plt\n", + "from sklearn.ensemble import RandomForestClassifier\n", + "\n", + "# ---------------------------------------------------------------------------\n", + "# [교육용가상자료] 야전 급수원 가상 수질 센서 데이터 생성기 (실제 부대·위치·검사 결과 없음)\n", + "# 가상 급수원 8곳 x 14일 x 1시간 간격. 실제 수질(true_*)·센서 측정값·오염 사건·센서 고장 정답을 함께 만든다.\n", + "# 기준치는 교육용 가정값이며, 실제 음용 판정은 공인 검사 결과와 의무(예방의학) 담당 기준에 따른다.\n", + "# ---------------------------------------------------------------------------\n", + "PARAMS = [\"ph\", \"turbidity\", \"chlorine\", \"metal\", \"ecoli\"]\n", + "SOURCES = [(\"W01\", \"water_truck\", 1.0, 2.0), (\"W02\", \"water_truck\", 3.5, 1.2), (\"W03\", \"purification_unit\", 2.2, 4.1),\n", + " (\"W04\", \"purification_unit\", 5.0, 3.3), (\"W05\", \"well\", 0.8, 5.6), (\"W06\", \"well\", 4.4, 6.0),\n", + " (\"W07\", \"river_intake\", 6.3, 1.8), (\"W08\", \"river_intake\", 6.9, 4.9)]\n", + "BASE = {\"water_truck\": (7.2, 0.25, 0.75, 1.5), \"purification_unit\": (7.0, 0.20, 0.85, 1.2),\n", + " \"well\": (6.8, 0.45, 0.50, 3.0), \"river_intake\": (7.3, 0.55, 0.60, 2.0)} # pH, 탁도(NTU), 잔류염소(mg/L), 중금속(ug/L)\n", + "LIMITS = {\"ph\": (5.8, 8.5), \"turbidity\": (1.0, 2.0), \"chlorine\": (0.2, 0.1), \"metal\": (5.0, 10.0), \"ecoli\": (1, 1)} # (주의, 위험) 가정 기준\n", + "EVENT_TYPES = { # 오염 사건: 변수별 최대 변화량, 최대치 도달 시간(h)\n", + " \"sewage\": ({\"turbidity\": 2.8, \"chlorine\": -0.5, \"ecoli\": 30, \"ph\": -0.3}, 4),\n", + " \"metal\": ({\"metal\": 22.0, \"ph\": -0.5}, 4),\n", + " \"disinfect_fail\": ({\"chlorine\": -0.8, \"ecoli\": 6}, 6),\n", + " \"chemical\": ({\"ph\": 1.7, \"turbidity\": 1.0}, 3),\n", + " \"creeping_metal\": ({\"metal\": 13.0}, 18),\n", + "}\n", + "FAULT_TYPES = [\"stuck\", \"spike\", \"drift\", \"dropout\"]\n", + "NOISE = {\"ph\": 0.04, \"turbidity\": 0.06, \"chlorine\": 0.03, \"metal\": 0.35}\n", + "DIGITS = {\"ph\": 2, \"turbidity\": 2, \"chlorine\": 2, \"metal\": 1}\n", + "\n", + "\n", + "def make_dataset(days=14, seed=7, n_events=6, n_faults=10, events=None, faults=None, sources=None):\n", + " \"\"\"events: [(급수원 번호, 사건 유형, 시작 시각 h, 지속 h, 강도)], faults: [(급수원 번호, 고장 유형, 항목, 시작 h, 지속 h)]\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " sources = sources or SOURCES\n", + " T = days * 24\n", + " tt = np.arange(T)\n", + " hour = tt % 24\n", + " night = (hour < 6) | (hour >= 20)\n", + " rain = np.zeros(T)\n", + " for _ in range(max(1, days // 4)):\n", + " s0, d = int(rng.integers(0, T - 14)), int(rng.integers(6, 14))\n", + " rain[s0:s0 + d] = np.maximum(rain[s0:s0 + d], rng.uniform(0.6, 1.0))\n", + " if events is None:\n", + " events = [(int(rng.integers(len(sources))), str(rng.choice(list(EVENT_TYPES))), int(rng.integers(min(24, T // 4), max(T // 4 + 1, T - 48))),\n", + " int(rng.integers(10, 30)), 1.0) for _ in range(n_events)]\n", + " if faults is None:\n", + " faults = [(int(rng.integers(len(sources))), str(rng.choice(FAULT_TYPES)), str(rng.choice([\"ph\", \"turbidity\", \"chlorine\", \"metal\"])),\n", + " int(rng.integers(12, max(13, T - 30))), int(rng.integers(6, 24))) for _ in range(n_faults)]\n", + " out = []\n", + " for si, (sid, stype, x, y) in enumerate(sources):\n", + " ph0, tb0, cl0, mt0 = BASE[stype]\n", + " tr = {\"ph\": ph0 + 0.08 * np.sin(2 * np.pi * hour / 24) + rng.normal(0, 0.03, T),\n", + " \"turbidity\": tb0 * (1 + 0.1 * rng.normal(0, 1, T)),\n", + " \"chlorine\": (cl0 * np.exp(-0.022 * hour) if stype == \"water_truck\" else cl0 + rng.normal(0, 0.03, T)),\n", + " \"metal\": mt0 + rng.normal(0, 0.2, T), \"ecoli\": np.zeros(T)}\n", + " if stype in (\"river_intake\", \"well\"):\n", + " tr[\"turbidity\"] = tr[\"turbidity\"] + rain * (0.9 if stype == \"river_intake\" else 0.45)\n", + " f_all, etype = np.zeros(T), np.array([\"none\"] * T, dtype=object)\n", + " for (s_i, et, onset, dur, scale) in events:\n", + " if s_i != si:\n", + " continue\n", + " delta, ramp = EVENT_TYPES[et]\n", + " f = np.clip((tt - onset + 1) / ramp, 0, 1) * ((tt >= onset) & (tt < onset + dur + ramp))\n", + " for k, v in delta.items():\n", + " tr[k] = tr[k] + v * f * scale\n", + " etype[f > 0] = et\n", + " f_all = np.maximum(f_all, f)\n", + " tr[\"chlorine\"] = np.clip(tr[\"chlorine\"], 0.02, None)\n", + " tr[\"turbidity\"] = np.clip(tr[\"turbidity\"], 0.05, None)\n", + " tr[\"metal\"] = np.clip(tr[\"metal\"], 0.1, None)\n", + " grade = np.where(f_all >= 0.2, 2, 0)\n", + " mild = (tr[\"turbidity\"] > LIMITS[\"turbidity\"][0]) | (tr[\"chlorine\"] < LIMITS[\"chlorine\"][0]) | (tr[\"metal\"] > LIMITS[\"metal\"][0])\n", + " grade = np.where((grade == 0) & mild, 1, grade)\n", + " ms = {}\n", + " for p in [\"ph\", \"turbidity\", \"chlorine\", \"metal\"]:\n", + " sd = NOISE[p] * np.where(night, 1.5, 1.0) * (tr[p] if p == \"turbidity\" else 1)\n", + " ms[p] = np.round(np.clip(tr[p] + rng.normal(0, 1, T) * sd, 0, None), DIGITS[p])\n", + " ms[\"ecoli\"] = (rng.poisson(tr[\"ecoli\"]) + (rng.random(T) < 0.004)).astype(float)\n", + " fault, fparam = np.array([\"none\"] * T, dtype=object), np.array([\"-\"] * T, dtype=object)\n", + " for (s_i, ft, p, s0, d) in faults:\n", + " if s_i != si:\n", + " continue\n", + " seg = slice(s0, min(T, s0 + d))\n", + " if ft == \"stuck\":\n", + " ms[p][seg] = ms[p][s0]\n", + " elif ft == \"spike\":\n", + " seg = slice(s0, s0 + 1)\n", + " ms[p][s0] = {\"ph\": 11.8, \"turbidity\": 38.0, \"chlorine\": 0.0, \"metal\": 75.0}[p]\n", + " elif ft == \"drift\":\n", + " ramp = np.linspace(0, 1, seg.stop - s0)\n", + " ms[p][seg] = np.clip(ms[p][seg] + ramp * {\"ph\": -1.6, \"turbidity\": 2.6, \"chlorine\": -0.75, \"metal\": 12.0}[p], 0, None).round(DIGITS[p])\n", + " elif ft == \"dropout\":\n", + " for q in PARAMS:\n", + " ms[q][seg] = np.nan\n", + " p = \"all\"\n", + " fault[seg], fparam[seg] = ft, p\n", + " df = pd.DataFrame({\"source_id\": sid, \"source_type\": stype, \"x_km\": x, \"y_km\": y, \"t\": tt,\n", + " \"cond\": np.where(rain > 0, \"storm\", np.where(night, \"night\", \"day\")),\n", + " **{p: ms[p] for p in PARAMS}, **{f\"true_{p}\": tr[p] for p in PARAMS},\n", + " \"event\": etype, \"fault\": fault, \"fault_param\": fparam, \"grade\": grade})\n", + " out.append(df)\n", + " return pd.concat(out, ignore_index=True)\n", + "# ---------------------------------------------------------------------------\n", + "\n", + "# ---- 전처리 공통 함수: 물리 범위 점검 -> 고정값(stuck) 표시 -> 단발 스파이크 제거 -> 결측 보정 ----\n", + "PHYS = {\"ph\": (0, 14), \"turbidity\": (0, 1000), \"chlorine\": (0, 10), \"metal\": (0, 1000), \"ecoli\": (0, 100000)}\n", + "SPIKE_K = {\"day\": 6.0, \"night\": 7.0, \"storm\": 9.0} # 상황별(주간/야간/악천후) 스파이크 판정 배수\n", + "MIN_JUMP = {\"ph\": 0.8, \"turbidity\": 1.5, \"chlorine\": 0.3, \"metal\": 6.0}\n", + "STUCK_RUN, SHORT_GAP = 6, 3\n", + "\n", + "\n", + "def clean(df, spike_k=None, min_jump=None):\n", + " \"\"\"급수원별 시계열을 정제하고 항목별 처리 내역(<항목>_flag: range/stuck/spike/interp/long_gap)을 남긴다.\"\"\"\n", + " spike_k = spike_k or SPIKE_K\n", + " min_jump = MIN_JUMP if min_jump is None else min_jump\n", + " out = df.sort_values([\"source_id\", \"t\"]).reset_index(drop=True)\n", + " for sid, idx in out.groupby(\"source_id\").groups.items():\n", + " idx = np.asarray(idx)\n", + " kk = out.loc[idx, \"cond\"].map(spike_k).to_numpy(float)\n", + " for p in PARAMS:\n", + " v = out.loc[idx, p].to_numpy(float).copy()\n", + " fl = np.array([\"\"] * len(v), dtype=object)\n", + " lo, hi = PHYS[p]\n", + " bad = (v < lo) | (v > hi)\n", + " v[bad], fl[bad] = np.nan, \"range\"\n", + " if p != \"ecoli\":\n", + " chg = np.r_[True, v[1:] != v[:-1]] # 같은 값이 STUCK_RUN시간 이상 반복 = 고정값 고장 의심\n", + " rid = np.cumsum(chg)\n", + " rlen = np.bincount(rid)[rid]\n", + " fl[(rlen >= STUCK_RUN) & ~chg & ~np.isnan(v)] = \"stuck\"\n", + " d = np.abs(np.diff(v[~np.isnan(v)]))\n", + " sig = 1.4826 * np.median(d) + 1e-6 if len(d) else 1.0\n", + " up, dn = v - np.r_[np.nan, v[:-1]], v - np.r_[v[1:], np.nan]\n", + " thr = np.maximum(kk * sig, min_jump[p])\n", + " spike = (np.abs(up) > thr) & (np.abs(dn) > thr) & (np.sign(up) == np.sign(dn)) # 튀었다가 바로 돌아온 값만\n", + " v[spike], fl[spike] = np.nan, \"spike\"\n", + " s = pd.Series(v)\n", + " na = s.isna().to_numpy()\n", + " grp = np.cumsum(np.r_[True, na[1:] != na[:-1]])\n", + " glen = pd.Series(na.astype(int)).groupby(grp).transform(\"sum\").to_numpy()\n", + " lin, ff = s.interpolate(limit_area=\"inside\").to_numpy(), s.ffill().bfill().to_numpy()\n", + " short, longg = na & (glen <= SHORT_GAP), na & (glen > SHORT_GAP)\n", + " v = np.where(short, np.where(np.isnan(lin), ff, lin), np.where(longg, ff, v))\n", + " fl[short & (fl == \"\")] = \"interp\"\n", + " fl[longg] = \"long_gap\"\n", + " out.loc[idx, p] = v\n", + " out.loc[idx, f\"{p}_flag\"] = fl\n", + " return out\n", + "\n", + "# ---- 가정 기준치 판정(Baseline)과 시계열 특징 공통 함수 ----\n", + "GRADE_NAME = {0: \"안전\", 1: \"주의\", 2: \"위험\"}\n", + "\n", + "\n", + "def rule_grade(df):\n", + " \"\"\"기준치 1차 필터: 위험(음용 부적합 의심) = 한 항목이라도 위험 기준 초과, 주의 = 주의 기준 초과.\"\"\"\n", + " ph, tb, cl, mt, ec = (df[p].to_numpy(float) for p in PARAMS)\n", + " danger = (ph < LIMITS[\"ph\"][0]) | (ph > LIMITS[\"ph\"][1]) | (tb > LIMITS[\"turbidity\"][1]) | (cl < LIMITS[\"chlorine\"][1]) \\\n", + " | (mt > LIMITS[\"metal\"][1]) | (ec >= LIMITS[\"ecoli\"][1])\n", + " caution = (tb > LIMITS[\"turbidity\"][0]) | (cl < LIMITS[\"chlorine\"][0]) | (mt > LIMITS[\"metal\"][0])\n", + " return np.where(danger, 2, np.where(caution, 1, 0))\n", + "\n", + "\n", + "def reasons(row):\n", + " r = []\n", + " if not LIMITS[\"ph\"][0] <= row[\"ph\"] <= LIMITS[\"ph\"][1]:\n", + " r.append(f\"pH {row['ph']:.2f}\")\n", + " if row[\"turbidity\"] > LIMITS[\"turbidity\"][0]:\n", + " r.append(f\"탁도 {row['turbidity']:.2f}NTU\")\n", + " if row[\"chlorine\"] < LIMITS[\"chlorine\"][0]:\n", + " r.append(f\"잔류염소 {row['chlorine']:.2f}mg/L\")\n", + " if row[\"metal\"] > LIMITS[\"metal\"][0]:\n", + " r.append(f\"중금속 {row['metal']:.1f}ug/L\")\n", + " if row[\"ecoli\"] >= 1:\n", + " r.append(f\"대장균 {row['ecoli']:.0f}\")\n", + " return \", \".join(r) or \"-\"\n", + "\n", + "\n", + "FEATS = [f\"{p}{s}\" for p in [\"ph\", \"turbidity\", \"chlorine\", \"metal\"] for s in [\"\", \"_d1\", \"_d3\", \"_dev\", \"_sd6\"]] + \\\n", + " [\"ecoli\", \"ecoli_3h\", \"storm\", \"night\", \"n_flag\", \"truck\", \"purif\", \"well\", \"river\"]\n", + "\n", + "\n", + "def add_features(c):\n", + " \"\"\"시간 순서를 지키는(과거 값만 쓰는) 급수원별 특징: 변화량, 24시간 기준선 대비 편차, 6시간 변동성, 대장균 누적 등.\"\"\"\n", + " c = c.sort_values([\"source_id\", \"t\"]).reset_index(drop=True)\n", + " g = c.groupby(\"source_id\")\n", + " for p in [\"ph\", \"turbidity\", \"chlorine\", \"metal\"]:\n", + " c[f\"{p}_d1\"] = g[p].diff().fillna(0)\n", + " c[f\"{p}_d3\"] = g[p].diff(3).fillna(0)\n", + " base = g[p].transform(lambda s: s.shift(1).rolling(24, min_periods=4).median())\n", + " c[f\"{p}_dev\"] = (c[p] - base).fillna(0)\n", + " c[f\"{p}_sd6\"] = g[p].transform(lambda s: s.rolling(6, min_periods=2).std()).fillna(0)\n", + " c[\"ecoli_3h\"] = g[\"ecoli\"].transform(lambda s: s.rolling(3, min_periods=1).sum())\n", + " c[\"storm\"], c[\"night\"] = (c[\"cond\"] == \"storm\").astype(int), (c[\"cond\"] == \"night\").astype(int)\n", + " flags = [f\"{p}_flag\" for p in PARAMS if f\"{p}_flag\" in c]\n", + " c[\"n_flag\"] = sum(c[f].isin([\"stuck\", \"spike\", \"long_gap\", \"range\"]).astype(int) for f in flags) if flags else 0\n", + " for k, name in [(\"truck\", \"water_truck\"), (\"purif\", \"purification_unit\"), (\"well\", \"well\"), (\"river\", \"river_intake\")]:\n", + " c[k] = (c[\"source_type\"] == name).astype(int)\n", + " return c\n", + "\n", + "\n", + "def prepare(df):\n", + " c = add_features(clean(df))\n", + " c[\"rule\"] = rule_grade(c)\n", + " return c\n", + "\n", + "\n", + "# 1) 학습(가상 2주 x 2세트)과 평가(벤치마크 seed 11)\n", + "train = pd.concat([prepare(make_dataset(seed=s, n_events=14, n_faults=14)) for s in (7, 8)], ignore_index=True)\n", + "test = prepare(make_dataset(seed=11, n_events=10, n_faults=14))\n", + "model = RandomForestClassifier(n_estimators=200, max_depth=10, class_weight=\"balanced_subsample\", random_state=0, n_jobs=1)\n", + "model.fit(train[FEATS], train[\"grade\"])\n", + "t0 = time.perf_counter()\n", + "test[\"ml\"] = model.predict(test[FEATS])\n", + "batch_ms = (time.perf_counter() - t0) * 1000\n", + "t0 = time.perf_counter()\n", + "for i in range(20):\n", + " model.predict(test[FEATS].iloc[[i]])\n", + "single_ms = (time.perf_counter() - t0) / 20 * 1000\n", + "\n", + "d = test[\"grade\"] == 2\n", + "print(f\"[교육용가상자료] 학습 {len(train):,}건 / 평가 {len(test):,}건, 특징 {len(FEATS)}개\")\n", + "for name, col in [(\"Baseline 기준치\", \"rule\"), (\"랜덤 포레스트\", \"ml\")]:\n", + " p = test[col]\n", + " print(f\"{name}: 정확도 {(p == test['grade']).mean():.1%}, 위험 재현율 {((p == 2) & d).sum() / d.sum():.1%}, \"\n", + " f\"위험 오경보 {int(((p == 2) & ~d).sum())}건\")\n", + "rec = ((test[\"ml\"] == 2) & d).sum() / d.sum()\n", + "print(f\"복합 위협 재현율 {rec:.1%} (목표 85% 이상: {'달성' if rec >= 0.85 else '미달'}), \"\n", + " f\"처리 지연 1건 {single_ms:.1f}ms / 전체 {batch_ms:.0f}ms (목표 200ms 이하: {'달성' if single_ms <= 200 else '미달'})\")\n", + "print(\"Baseline이 놓친 서서히 오르는 중금속(creeping_metal) 중 모델 검출:\",\n", + " f\"{int(((test['ml'] == 2) & d & (test['rule'] != 2) & (test['event'] == 'creeping_metal')).sum())}\"\n", + " f\"/{int((d & (test['rule'] != 2) & (test['event'] == 'creeping_metal')).sum())}시간\")\n", + "\n", + "# 2) 오경보 유발 패턴: 위험 오경보가 어떤 센서 고장에서 나왔는가 -> 특징 공간 재설계 단서\n", + "fa = test[(test[\"ml\"] == 2) & ~d]\n", + "print(\"모델 위험 오경보의 센서 고장 유형:\", fa[\"fault\"].value_counts().to_dict(), \"-> drift(서서히 틀어짐)는 미션 5 교차 검증으로 보완\")\n", + "imp = pd.Series(model.feature_importances_, FEATS).sort_values(ascending=False).head(6)\n", + "print(\"중요 특징 Top6:\", \", \".join(f\"{k} {v:.2f}\" for k, v in imp.items()))\n", + "\n", + "# 3) 복합 위협 시나리오 10종: (급수원, 사건) / 센서 교란은 오염이 없으므로 위험 경보가 나오면 안 된다\n", + "SCEN = [(\"하수 유입\", [(6, \"sewage\", 30, 14, 1.0)], []), (\"중금속 급증\", [(4, \"metal\", 30, 14, 1.0)], []),\n", + " (\"소독 실패\", [(0, \"disinfect_fail\", 30, 14, 1.0)], []), (\"화학 오염(pH 급변)\", [(2, \"chemical\", 30, 14, 1.0)], []),\n", + " (\"중금속 서서히 증가\", [(5, \"creeping_metal\", 20, 24, 1.0)], []),\n", + " (\"하수+중금속 동시\", [(7, \"sewage\", 30, 14, 1.0), (7, \"metal\", 34, 10, 1.0)], []),\n", + " (\"약한 하수 유입(강도 60%)\", [(3, \"sewage\", 30, 14, 0.6)], []),\n", + " (\"교란: 탁도 센서 드리프트\", [], [(1, \"drift\", \"turbidity\", 24, 18)]),\n", + " (\"교란: 잔류염소 단발 스파이크\", [], [(0, \"spike\", \"chlorine\", 40, 1)]),\n", + " (\"교란: 통신 단절(전 항목 결측)\", [], [(6, \"dropout\", \"ph\", 30, 10)])]\n", + "rows = []\n", + "for name, ev, ft in SCEN:\n", + " c = prepare(make_dataset(days=3, seed=21, events=ev, faults=ft))\n", + " c[\"ml\"] = model.predict(c[FEATS])\n", + " if ev:\n", + " sid = SOURCES[ev[0][0]][0]; on = ev[0][2]\n", + " hit = c[(c[\"source_id\"] == sid) & (c[\"ml\"] == 2) & (c[\"t\"] >= on)]\n", + " lag = int(hit[\"t\"].min() - on) if len(hit) else None\n", + " ok = lag is not None and lag <= 8\n", + " rows.append((name, \"오염\", f\"{lag}h\" if lag is not None else \"미탐\", \"PASS\" if ok else \"FAIL\"))\n", + " else:\n", + " sid = SOURCES[ft[0][0]][0]\n", + " n = int(((c[\"source_id\"] == sid) & (c[\"ml\"] == 2)).sum())\n", + " rows.append((name, \"교란\", f\"위험 오경보 {n}h\", \"PASS\" if n <= 2 else \"FAIL\"))\n", + "res = pd.DataFrame(rows, columns=[\"시나리오\", \"구분\", \"탐지 지연/오경보\", \"판정\"])\n", + "print(\"\\n[복합 위협 시나리오 10종]\")\n", + "print(res.to_string(index=False))\n", + "n_pass = int((res[\"판정\"] == \"PASS\").sum())\n", + "print(f\"통과 {n_pass}/10 (오염: 시작 후 8시간 내 위험 판정, 교란: 위험 오경보 2시간 이하)\")\n", + "\n", + "plt.figure(figsize=(8, 4))\n", + "imp.iloc[::-1].plot.barh(color=\"tab:blue\")\n", + "plt.title(\"Random forest feature importance (top 6, synthetic)\"); plt.xlabel(\"importance\")\n", + "plt.tight_layout(); plt.show()\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 04 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + "[교육용가상자료] 학습 5,376건 / 평가 2,688건, 특징 29개\n", + "Baseline 기준치: 정확도 97.9%, 위험 재현율 88.6%, 위험 오경보 17건\n", + "랜덤 포레스트: 정확도 99.3%, 위험 재현율 97.2%, 위험 오경보 10건\n", + "복합 위협 재현율 97.2% (목표 85% 이상: 달성), 처리 지연 1건 20.7ms / 전체 49ms (목표 200ms 이하: 달성)\n", + "Baseline이 놓친 서서히 오르는 중금속(creeping_metal) 중 모델 검출: 17/22시간\n", + "모델 위험 오경보의 센서 고장 유형: {'drift': 10} -> drift(서서히 틀어짐)는 미션 5 교차 검증으로 보완\n", + "중요 특징 Top6: storm 0.17, turbidity 0.15, metal 0.10, turbidity_dev 0.09, ph 0.07, river 0.06\n", + "\n", + "[복합 위협 시나리오 10종]\n", + " 시나리오 구분 탐지 지연/오경보 판정\n", + " 하수 유입 오염 0h PASS\n", + " 중금속 급증 오염 0h PASS\n", + " 소독 실패 오염 3h PASS\n", + " 화학 오염(pH 급변) 오염 0h PASS\n", + " 중금속 서서히 증가 오염 2h PASS\n", + " 하수+중금속 동시 오염 0h PASS\n", + " 약한 하수 유입(강도 60%) 오염 0h PASS\n", + " 교란: 탁도 센서 드리프트 교란 위험 오경보 13h FAIL\n", + " 교란: 잔류염소 단발 스파이크 교란 위험 오경보 0h PASS\n", + "교란: 통신 단절(전 항목 결측) 교란 위험 오경보 0h PASS\n", + "통과 9/10 (오염: 시작 후 8시간 내 위험 판정, 교란: 위험 오경보 2시간 이하)\n", + "```\n", + "
\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 05. 센서 오류에 의한 단수 오경보 방지 및 수질 안전 신뢰도 지수 산출 모듈 개발\n", + "**소요시간**: 약 3.0시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "센서 오류에 의한 단수 오경보 방지 신뢰도 지수 산출 모듈을 개발한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **센서 오류로 인한 급수 중단 오경보를 줄이는 수질 안전 신뢰도 지수와 High·Med·Low 등급 모듈을 개발한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **위험 경보마다 항목별 편차의 일치도, 경보 지속 시간, 센서 이상 표시, 드리프트 의심 여부를 점수화해 0~100점 신뢰도를 만들고 High·Med·Low로 나눈다 과정을 진행하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `src/verification/wel03_confidence_engine.py` 형태의 결과물을 완성한다.\n", + "\n", + "### 🔍 평가 및 검증 기준 (PASS 조건)\n", + "#### 미션 판정\n", + "\n", + "**평가 구분:** 필수\n", + "\n", + "**핵심 평가영역:** 신뢰도 등급(High/Med/Low) 부여 기준의 군사적 타당성\n", + "\n", + "#### 미션 PASS 조건\n", + "\n", + "- 정량 기준: 오경보율(FAR) 40% 이상 감축, 신뢰도 스코어 산출 일관성 90% 이상\n", + "\n", + "\n", + "- 진행 기준: 오경보 필터링 전후 비교 지표 산출 및 통합 테스트 완료\n", + "\n", + "\n", + "- 검증 방법: ROC-AUC 곡선 분석 및 임계값 민감도 테스트\n", + "\n", + "\n", + "#### 채점자 판정 메모\n", + "\n", + "> 단순 코드 실행 여부뿐만 아니라 출력된 수치와 근거 자료가 군 실무 요구사항에 부합하는지 면밀히 확인한다.\n", + "\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "\"\"\"위험 경보마다 다항목 동시 변화·연속 지속·센서 상태·단일 항목 드리프트 여부로 수질 안전 신뢰도 지수(0~100, High/Med/Low)를 산출해, 센서 오류에 의한 급수 중단(단수) 오경보를 줄이고 ROC-AUC·임계값 민감도·산출 일관성을 확인한다.\"\"\"\n", + "import numpy as np\n", + "import pandas as pd\n", + "import matplotlib.pyplot as plt\n", + "from sklearn.ensemble import RandomForestClassifier\n", + "from sklearn.metrics import roc_auc_score, roc_curve\n", + "\n", + "# ---------------------------------------------------------------------------\n", + "# [교육용가상자료] 야전 급수원 가상 수질 센서 데이터 생성기 (실제 부대·위치·검사 결과 없음)\n", + "# 가상 급수원 8곳 x 14일 x 1시간 간격. 실제 수질(true_*)·센서 측정값·오염 사건·센서 고장 정답을 함께 만든다.\n", + "# 기준치는 교육용 가정값이며, 실제 음용 판정은 공인 검사 결과와 의무(예방의학) 담당 기준에 따른다.\n", + "# ---------------------------------------------------------------------------\n", + "PARAMS = [\"ph\", \"turbidity\", \"chlorine\", \"metal\", \"ecoli\"]\n", + "SOURCES = [(\"W01\", \"water_truck\", 1.0, 2.0), (\"W02\", \"water_truck\", 3.5, 1.2), (\"W03\", \"purification_unit\", 2.2, 4.1),\n", + " (\"W04\", \"purification_unit\", 5.0, 3.3), (\"W05\", \"well\", 0.8, 5.6), (\"W06\", \"well\", 4.4, 6.0),\n", + " (\"W07\", \"river_intake\", 6.3, 1.8), (\"W08\", \"river_intake\", 6.9, 4.9)]\n", + "BASE = {\"water_truck\": (7.2, 0.25, 0.75, 1.5), \"purification_unit\": (7.0, 0.20, 0.85, 1.2),\n", + " \"well\": (6.8, 0.45, 0.50, 3.0), \"river_intake\": (7.3, 0.55, 0.60, 2.0)} # pH, 탁도(NTU), 잔류염소(mg/L), 중금속(ug/L)\n", + "LIMITS = {\"ph\": (5.8, 8.5), \"turbidity\": (1.0, 2.0), \"chlorine\": (0.2, 0.1), \"metal\": (5.0, 10.0), \"ecoli\": (1, 1)} # (주의, 위험) 가정 기준\n", + "EVENT_TYPES = { # 오염 사건: 변수별 최대 변화량, 최대치 도달 시간(h)\n", + " \"sewage\": ({\"turbidity\": 2.8, \"chlorine\": -0.5, \"ecoli\": 30, \"ph\": -0.3}, 4),\n", + " \"metal\": ({\"metal\": 22.0, \"ph\": -0.5}, 4),\n", + " \"disinfect_fail\": ({\"chlorine\": -0.8, \"ecoli\": 6}, 6),\n", + " \"chemical\": ({\"ph\": 1.7, \"turbidity\": 1.0}, 3),\n", + " \"creeping_metal\": ({\"metal\": 13.0}, 18),\n", + "}\n", + "FAULT_TYPES = [\"stuck\", \"spike\", \"drift\", \"dropout\"]\n", + "NOISE = {\"ph\": 0.04, \"turbidity\": 0.06, \"chlorine\": 0.03, \"metal\": 0.35}\n", + "DIGITS = {\"ph\": 2, \"turbidity\": 2, \"chlorine\": 2, \"metal\": 1}\n", + "\n", + "\n", + "def make_dataset(days=14, seed=7, n_events=6, n_faults=10, events=None, faults=None, sources=None):\n", + " \"\"\"events: [(급수원 번호, 사건 유형, 시작 시각 h, 지속 h, 강도)], faults: [(급수원 번호, 고장 유형, 항목, 시작 h, 지속 h)]\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " sources = sources or SOURCES\n", + " T = days * 24\n", + " tt = np.arange(T)\n", + " hour = tt % 24\n", + " night = (hour < 6) | (hour >= 20)\n", + " rain = np.zeros(T)\n", + " for _ in range(max(1, days // 4)):\n", + " s0, d = int(rng.integers(0, T - 14)), int(rng.integers(6, 14))\n", + " rain[s0:s0 + d] = np.maximum(rain[s0:s0 + d], rng.uniform(0.6, 1.0))\n", + " if events is None:\n", + " events = [(int(rng.integers(len(sources))), str(rng.choice(list(EVENT_TYPES))), int(rng.integers(min(24, T // 4), max(T // 4 + 1, T - 48))),\n", + " int(rng.integers(10, 30)), 1.0) for _ in range(n_events)]\n", + " if faults is None:\n", + " faults = [(int(rng.integers(len(sources))), str(rng.choice(FAULT_TYPES)), str(rng.choice([\"ph\", \"turbidity\", \"chlorine\", \"metal\"])),\n", + " int(rng.integers(12, max(13, T - 30))), int(rng.integers(6, 24))) for _ in range(n_faults)]\n", + " out = []\n", + " for si, (sid, stype, x, y) in enumerate(sources):\n", + " ph0, tb0, cl0, mt0 = BASE[stype]\n", + " tr = {\"ph\": ph0 + 0.08 * np.sin(2 * np.pi * hour / 24) + rng.normal(0, 0.03, T),\n", + " \"turbidity\": tb0 * (1 + 0.1 * rng.normal(0, 1, T)),\n", + " \"chlorine\": (cl0 * np.exp(-0.022 * hour) if stype == \"water_truck\" else cl0 + rng.normal(0, 0.03, T)),\n", + " \"metal\": mt0 + rng.normal(0, 0.2, T), \"ecoli\": np.zeros(T)}\n", + " if stype in (\"river_intake\", \"well\"):\n", + " tr[\"turbidity\"] = tr[\"turbidity\"] + rain * (0.9 if stype == \"river_intake\" else 0.45)\n", + " f_all, etype = np.zeros(T), np.array([\"none\"] * T, dtype=object)\n", + " for (s_i, et, onset, dur, scale) in events:\n", + " if s_i != si:\n", + " continue\n", + " delta, ramp = EVENT_TYPES[et]\n", + " f = np.clip((tt - onset + 1) / ramp, 0, 1) * ((tt >= onset) & (tt < onset + dur + ramp))\n", + " for k, v in delta.items():\n", + " tr[k] = tr[k] + v * f * scale\n", + " etype[f > 0] = et\n", + " f_all = np.maximum(f_all, f)\n", + " tr[\"chlorine\"] = np.clip(tr[\"chlorine\"], 0.02, None)\n", + " tr[\"turbidity\"] = np.clip(tr[\"turbidity\"], 0.05, None)\n", + " tr[\"metal\"] = np.clip(tr[\"metal\"], 0.1, None)\n", + " grade = np.where(f_all >= 0.2, 2, 0)\n", + " mild = (tr[\"turbidity\"] > LIMITS[\"turbidity\"][0]) | (tr[\"chlorine\"] < LIMITS[\"chlorine\"][0]) | (tr[\"metal\"] > LIMITS[\"metal\"][0])\n", + " grade = np.where((grade == 0) & mild, 1, grade)\n", + " ms = {}\n", + " for p in [\"ph\", \"turbidity\", \"chlorine\", \"metal\"]:\n", + " sd = NOISE[p] * np.where(night, 1.5, 1.0) * (tr[p] if p == \"turbidity\" else 1)\n", + " ms[p] = np.round(np.clip(tr[p] + rng.normal(0, 1, T) * sd, 0, None), DIGITS[p])\n", + " ms[\"ecoli\"] = (rng.poisson(tr[\"ecoli\"]) + (rng.random(T) < 0.004)).astype(float)\n", + " fault, fparam = np.array([\"none\"] * T, dtype=object), np.array([\"-\"] * T, dtype=object)\n", + " for (s_i, ft, p, s0, d) in faults:\n", + " if s_i != si:\n", + " continue\n", + " seg = slice(s0, min(T, s0 + d))\n", + " if ft == \"stuck\":\n", + " ms[p][seg] = ms[p][s0]\n", + " elif ft == \"spike\":\n", + " seg = slice(s0, s0 + 1)\n", + " ms[p][s0] = {\"ph\": 11.8, \"turbidity\": 38.0, \"chlorine\": 0.0, \"metal\": 75.0}[p]\n", + " elif ft == \"drift\":\n", + " ramp = np.linspace(0, 1, seg.stop - s0)\n", + " ms[p][seg] = np.clip(ms[p][seg] + ramp * {\"ph\": -1.6, \"turbidity\": 2.6, \"chlorine\": -0.75, \"metal\": 12.0}[p], 0, None).round(DIGITS[p])\n", + " elif ft == \"dropout\":\n", + " for q in PARAMS:\n", + " ms[q][seg] = np.nan\n", + " p = \"all\"\n", + " fault[seg], fparam[seg] = ft, p\n", + " df = pd.DataFrame({\"source_id\": sid, \"source_type\": stype, \"x_km\": x, \"y_km\": y, \"t\": tt,\n", + " \"cond\": np.where(rain > 0, \"storm\", np.where(night, \"night\", \"day\")),\n", + " **{p: ms[p] for p in PARAMS}, **{f\"true_{p}\": tr[p] for p in PARAMS},\n", + " \"event\": etype, \"fault\": fault, \"fault_param\": fparam, \"grade\": grade})\n", + " out.append(df)\n", + " return pd.concat(out, ignore_index=True)\n", + "# ---------------------------------------------------------------------------\n", + "\n", + "# ---- 전처리 공통 함수: 물리 범위 점검 -> 고정값(stuck) 표시 -> 단발 스파이크 제거 -> 결측 보정 ----\n", + "PHYS = {\"ph\": (0, 14), \"turbidity\": (0, 1000), \"chlorine\": (0, 10), \"metal\": (0, 1000), \"ecoli\": (0, 100000)}\n", + "SPIKE_K = {\"day\": 6.0, \"night\": 7.0, \"storm\": 9.0} # 상황별(주간/야간/악천후) 스파이크 판정 배수\n", + "MIN_JUMP = {\"ph\": 0.8, \"turbidity\": 1.5, \"chlorine\": 0.3, \"metal\": 6.0}\n", + "STUCK_RUN, SHORT_GAP = 6, 3\n", + "\n", + "\n", + "def clean(df, spike_k=None, min_jump=None):\n", + " \"\"\"급수원별 시계열을 정제하고 항목별 처리 내역(<항목>_flag: range/stuck/spike/interp/long_gap)을 남긴다.\"\"\"\n", + " spike_k = spike_k or SPIKE_K\n", + " min_jump = MIN_JUMP if min_jump is None else min_jump\n", + " out = df.sort_values([\"source_id\", \"t\"]).reset_index(drop=True)\n", + " for sid, idx in out.groupby(\"source_id\").groups.items():\n", + " idx = np.asarray(idx)\n", + " kk = out.loc[idx, \"cond\"].map(spike_k).to_numpy(float)\n", + " for p in PARAMS:\n", + " v = out.loc[idx, p].to_numpy(float).copy()\n", + " fl = np.array([\"\"] * len(v), dtype=object)\n", + " lo, hi = PHYS[p]\n", + " bad = (v < lo) | (v > hi)\n", + " v[bad], fl[bad] = np.nan, \"range\"\n", + " if p != \"ecoli\":\n", + " chg = np.r_[True, v[1:] != v[:-1]] # 같은 값이 STUCK_RUN시간 이상 반복 = 고정값 고장 의심\n", + " rid = np.cumsum(chg)\n", + " rlen = np.bincount(rid)[rid]\n", + " fl[(rlen >= STUCK_RUN) & ~chg & ~np.isnan(v)] = \"stuck\"\n", + " d = np.abs(np.diff(v[~np.isnan(v)]))\n", + " sig = 1.4826 * np.median(d) + 1e-6 if len(d) else 1.0\n", + " up, dn = v - np.r_[np.nan, v[:-1]], v - np.r_[v[1:], np.nan]\n", + " thr = np.maximum(kk * sig, min_jump[p])\n", + " spike = (np.abs(up) > thr) & (np.abs(dn) > thr) & (np.sign(up) == np.sign(dn)) # 튀었다가 바로 돌아온 값만\n", + " v[spike], fl[spike] = np.nan, \"spike\"\n", + " s = pd.Series(v)\n", + " na = s.isna().to_numpy()\n", + " grp = np.cumsum(np.r_[True, na[1:] != na[:-1]])\n", + " glen = pd.Series(na.astype(int)).groupby(grp).transform(\"sum\").to_numpy()\n", + " lin, ff = s.interpolate(limit_area=\"inside\").to_numpy(), s.ffill().bfill().to_numpy()\n", + " short, longg = na & (glen <= SHORT_GAP), na & (glen > SHORT_GAP)\n", + " v = np.where(short, np.where(np.isnan(lin), ff, lin), np.where(longg, ff, v))\n", + " fl[short & (fl == \"\")] = \"interp\"\n", + " fl[longg] = \"long_gap\"\n", + " out.loc[idx, p] = v\n", + " out.loc[idx, f\"{p}_flag\"] = fl\n", + " return out\n", + "\n", + "# ---- 가정 기준치 판정(Baseline)과 시계열 특징 공통 함수 ----\n", + "GRADE_NAME = {0: \"안전\", 1: \"주의\", 2: \"위험\"}\n", + "\n", + "\n", + "def rule_grade(df):\n", + " \"\"\"기준치 1차 필터: 위험(음용 부적합 의심) = 한 항목이라도 위험 기준 초과, 주의 = 주의 기준 초과.\"\"\"\n", + " ph, tb, cl, mt, ec = (df[p].to_numpy(float) for p in PARAMS)\n", + " danger = (ph < LIMITS[\"ph\"][0]) | (ph > LIMITS[\"ph\"][1]) | (tb > LIMITS[\"turbidity\"][1]) | (cl < LIMITS[\"chlorine\"][1]) \\\n", + " | (mt > LIMITS[\"metal\"][1]) | (ec >= LIMITS[\"ecoli\"][1])\n", + " caution = (tb > LIMITS[\"turbidity\"][0]) | (cl < LIMITS[\"chlorine\"][0]) | (mt > LIMITS[\"metal\"][0])\n", + " return np.where(danger, 2, np.where(caution, 1, 0))\n", + "\n", + "\n", + "def reasons(row):\n", + " r = []\n", + " if not LIMITS[\"ph\"][0] <= row[\"ph\"] <= LIMITS[\"ph\"][1]:\n", + " r.append(f\"pH {row['ph']:.2f}\")\n", + " if row[\"turbidity\"] > LIMITS[\"turbidity\"][0]:\n", + " r.append(f\"탁도 {row['turbidity']:.2f}NTU\")\n", + " if row[\"chlorine\"] < LIMITS[\"chlorine\"][0]:\n", + " r.append(f\"잔류염소 {row['chlorine']:.2f}mg/L\")\n", + " if row[\"metal\"] > LIMITS[\"metal\"][0]:\n", + " r.append(f\"중금속 {row['metal']:.1f}ug/L\")\n", + " if row[\"ecoli\"] >= 1:\n", + " r.append(f\"대장균 {row['ecoli']:.0f}\")\n", + " return \", \".join(r) or \"-\"\n", + "\n", + "\n", + "FEATS = [f\"{p}{s}\" for p in [\"ph\", \"turbidity\", \"chlorine\", \"metal\"] for s in [\"\", \"_d1\", \"_d3\", \"_dev\", \"_sd6\"]] + \\\n", + " [\"ecoli\", \"ecoli_3h\", \"storm\", \"night\", \"n_flag\", \"truck\", \"purif\", \"well\", \"river\"]\n", + "\n", + "\n", + "def add_features(c):\n", + " \"\"\"시간 순서를 지키는(과거 값만 쓰는) 급수원별 특징: 변화량, 24시간 기준선 대비 편차, 6시간 변동성, 대장균 누적 등.\"\"\"\n", + " c = c.sort_values([\"source_id\", \"t\"]).reset_index(drop=True)\n", + " g = c.groupby(\"source_id\")\n", + " for p in [\"ph\", \"turbidity\", \"chlorine\", \"metal\"]:\n", + " c[f\"{p}_d1\"] = g[p].diff().fillna(0)\n", + " c[f\"{p}_d3\"] = g[p].diff(3).fillna(0)\n", + " base = g[p].transform(lambda s: s.shift(1).rolling(24, min_periods=4).median())\n", + " c[f\"{p}_dev\"] = (c[p] - base).fillna(0)\n", + " c[f\"{p}_sd6\"] = g[p].transform(lambda s: s.rolling(6, min_periods=2).std()).fillna(0)\n", + " c[\"ecoli_3h\"] = g[\"ecoli\"].transform(lambda s: s.rolling(3, min_periods=1).sum())\n", + " c[\"storm\"], c[\"night\"] = (c[\"cond\"] == \"storm\").astype(int), (c[\"cond\"] == \"night\").astype(int)\n", + " flags = [f\"{p}_flag\" for p in PARAMS if f\"{p}_flag\" in c]\n", + " c[\"n_flag\"] = sum(c[f].isin([\"stuck\", \"spike\", \"long_gap\", \"range\"]).astype(int) for f in flags) if flags else 0\n", + " for k, name in [(\"truck\", \"water_truck\"), (\"purif\", \"purification_unit\"), (\"well\", \"well\"), (\"river\", \"river_intake\")]:\n", + " c[k] = (c[\"source_type\"] == name).astype(int)\n", + " return c\n", + "\n", + "SCALE = {\"ph\": 0.35, \"turbidity\": 0.6, \"chlorine\": 0.2, \"metal\": 3.0} # 항목별 '의미 있는 편차' 크기(가정)\n", + "W = {\"corrob\": 40, \"persist\": 25, \"health\": 20, \"nondrift\": 15} # 신뢰도 가중치 매트릭스(합 100)\n", + "CUT = {\"High\": 60, \"Med\": 35} # High만 급수 중단 검토 요청으로 올린다\n", + "\n", + "\n", + "def prepare(df):\n", + " c = add_features(clean(df))\n", + " c[\"rule\"] = rule_grade(c)\n", + " return c\n", + "\n", + "\n", + "def confidence(c, alert):\n", + " \"\"\"경보(alert=True) 행마다 신뢰도 점수와 구성 요소를 계산한다.\"\"\"\n", + " dev = pd.DataFrame({p: (c[f\"{p}_dev\"].abs() > SCALE[p]) | (c[f\"{p}_d3\"].abs() > SCALE[p]) for p in SCALE})\n", + " n_dev = dev.sum(axis=1) + (c[\"ecoli_3h\"] >= 2)\n", + " corrob = np.minimum(n_dev, 3) / 3\n", + " a = pd.Series(alert.astype(int), index=c.index)\n", + " run = a.groupby([c[\"source_id\"], (a != a.groupby(c[\"source_id\"]).shift()).cumsum()]).cumsum()\n", + " persist = np.minimum(run, 3) / 3\n", + " flagged = c[\"n_flag\"].groupby(c[\"source_id\"]).transform(lambda s: s.rolling(6, min_periods=1).max()) > 0\n", + " health = 1 - flagged.astype(float)\n", + " # 드리프트 의심: 한 항목만 24시간 기준선에서 벗어나고, 6시간 변동성이 작으며(매끄러운 변화) 대장균이 없다\n", + " g = c.groupby(\"source_id\")\n", + " jitter = {p: g[f\"{p}_d1\"].transform(lambda s: s.rolling(6, min_periods=3).std()).fillna(0) for p in SCALE}\n", + " smooth = pd.concat([jitter[p] < SCALE[p] * 0.5 for p in SCALE], axis=1).all(axis=1) # 변화량이 일정 = 기울기만 있는 변화\n", + " nondrift = 1 - ((n_dev <= 1) & smooth & (c[\"ecoli_3h\"] < 2)).astype(float)\n", + " score = W[\"corrob\"] * corrob + W[\"persist\"] * persist + W[\"health\"] * health + W[\"nondrift\"] * nondrift\n", + " level = np.where(score >= CUT[\"High\"], \"High\", np.where(score >= CUT[\"Med\"], \"Med\", \"Low\"))\n", + " return pd.DataFrame({\"score\": score.round(1), \"level\": level, \"n_dev\": n_dev}, index=c.index)\n", + "\n", + "\n", + "train = pd.concat([prepare(make_dataset(seed=s, n_events=14, n_faults=14)) for s in (7, 8)], ignore_index=True)\n", + "model = RandomForestClassifier(n_estimators=200, max_depth=10, class_weight=\"balanced_subsample\", random_state=0, n_jobs=1)\n", + "model.fit(train[FEATS], train[\"grade\"])\n", + "\n", + "test = prepare(make_dataset(seed=11, n_events=10, n_faults=14))\n", + "alert = ((test[\"rule\"] == 2) | (model.predict(test[FEATS]) == 2)).to_numpy() # Baseline 또는 모델이 위험 판정\n", + "conf = confidence(test, alert)\n", + "A = test[alert].join(conf)\n", + "A[\"real\"] = A[\"grade\"] == 2\n", + "far_before = (~A[\"real\"]).mean()\n", + "stop = A[A[\"level\"] == \"High\"]\n", + "far_after = (~stop[\"real\"]).mean()\n", + "cut = 1 - far_after / far_before\n", + "print(f\"[교육용가상자료] 위험 경보 {len(A)}건(실제 위험 {int(A['real'].sum())}건, 오경보 {int((~A['real']).sum())}건)\")\n", + "print(f\"급수 중단 검토 경보 오경보율(FAR): 신뢰도 적용 전 {far_before:.1%} -> High만 상신 {far_after:.1%} \"\n", + " f\"({cut:.0%} 감축, 목표 40% 이상: {'달성' if cut >= 0.4 else '미달'})\")\n", + "print(\"\\n[신뢰도 등급별 경보 수와 조치]\")\n", + "tab = A.groupby(\"level\").agg(경보=(\"real\", \"size\"), 실제위험=(\"real\", \"sum\"), 평균점수=(\"score\", \"mean\"))\n", + "tab[\"조치\"] = tab.index.map({\"High\": \"급수 중단 검토 요청(HITL)\", \"Med\": \"즉시 재채수·현장 키트 검사\", \"Low\": \"센서 점검·재측정\"})\n", + "print(tab.reindex([\"High\", \"Med\", \"Low\"]).round(1).to_string())\n", + "print(\"오경보의 원인(센서 고장 유형) x 등급:\")\n", + "print(pd.crosstab(A.loc[~A[\"real\"], \"fault\"], A.loc[~A[\"real\"], \"level\"]).to_string())\n", + "miss_real = int((A[\"real\"] & (A[\"level\"] == \"Low\")).sum())\n", + "print(f\"실제 위험인데 Low로 낮춰진 경보 {miss_real}건 -> Low도 재측정 대상이므로 무시되지 않는다\")\n", + "\n", + "auc = roc_auc_score(A[\"real\"], A[\"score\"])\n", + "print(f\"\\nROC-AUC(신뢰도 점수로 실제 위험 구분) {auc:.3f}\")\n", + "print(\"[임계값 민감도] High 기준 / 상신 경보 / 오경보율 / 실제 위험 상신 비율\")\n", + "for th in [45, 55, 60, 70, 80]:\n", + " s = A[A[\"score\"] >= th]\n", + " print(f\" {th:>3} / {len(s):>4} / {(~s['real']).mean():6.1%} / {s['real'].sum() / A['real'].sum():6.1%}\")\n", + "\n", + "# 산출 일관성: 같은 사건·고장 조건에서 측정 잡음만 바꿔 5회 재산출했을 때 실제 위험 경보의 등급 일치율\n", + "EV = [(6, \"sewage\", 30, 14, 1.0), (4, \"metal\", 60, 12, 1.0), (2, \"chemical\", 40, 10, 1.0), (5, \"creeping_metal\", 20, 24, 1.0)]\n", + "FT = [(1, \"drift\", \"turbidity\", 24, 18), (0, \"stuck\", \"chlorine\", 30, 10)]\n", + "levels = []\n", + "for s in range(5):\n", + " c = prepare(make_dataset(days=4, seed=100 + s, events=EV, faults=FT))\n", + " al = ((c[\"rule\"] == 2) | (model.predict(c[FEATS]) == 2)).to_numpy()\n", + " levels.append(confidence(c, al)[\"level\"].where(al, \"-\").to_numpy())\n", + "L = np.array(levels)\n", + "ref = pd.Series(L[0]).mode().iloc[0]\n", + "mask = (L != \"-\").sum(axis=0) >= 3\n", + "agree = np.mean([pd.Series(col[col != \"-\"]).value_counts(normalize=True).iloc[0] for col in L[:, mask].T])\n", + "print(f\"\\n잡음 재현 5회 등급 일치율 {agree:.1%} (경보 3회 이상 시점 {int(mask.sum())}개, 목표 90% 이상: {'달성' if agree >= 0.9 else '미달'})\")\n", + "print(\"※ 신뢰도 등급은 판단 보조다. 급수 중단·재개는 의무(예방의학)·환경 담당 확인과 지휘관 승인으로 결정한다.\")\n", + "\n", + "fpr, tpr, _ = roc_curve(A[\"real\"], A[\"score\"])\n", + "fig, ax = plt.subplots(1, 2, figsize=(10, 4))\n", + "ax[0].plot(fpr, tpr, color=\"tab:blue\"); ax[0].plot([0, 1], [0, 1], \"k--\", lw=0.8)\n", + "ax[0].set_title(f\"ROC of confidence score (AUC {auc:.2f})\"); ax[0].set_xlabel(\"false positive rate\"); ax[0].set_ylabel(\"true positive rate\")\n", + "ax[1].hist([A.loc[A[\"real\"], \"score\"], A.loc[~A[\"real\"], \"score\"]], bins=12, label=[\"real danger\", \"false alarm\"], color=[\"tab:red\", \"tab:gray\"])\n", + "ax[1].axvline(CUT[\"High\"], color=\"k\", ls=\"--\"); ax[1].set_xlabel(\"confidence score\"); ax[1].legend(); ax[1].set_title(\"Score distribution (synthetic)\")\n", + "plt.tight_layout(); plt.show()\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 05 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + "[교육용가상자료] 위험 경보 262건(실제 위험 240건, 오경보 22건)\n", + "급수 중단 검토 경보 오경보율(FAR): 신뢰도 적용 전 8.4% -> High만 상신 0.7% (91% 감축, 목표 40% 이상: 달성)\n", + "\n", + "[신뢰도 등급별 경보 수와 조치]\n", + " 경보 실제위험 평균점수 조치\n", + "level \n", + "High 137 136 84.1 급수 중단 검토 요청(HITL)\n", + "Med 112 100 53.5 즉시 재채수·현장 키트 검사\n", + "Low 13 4 27.5 센서 점검·재측정\n", + "오경보의 원인(센서 고장 유형) x 등급:\n", + "level High Low Med\n", + "fault \n", + "drift 1 0 10\n", + "none 0 9 2\n", + "실제 위험인데 Low로 낮춰진 경보 4건 -> Low도 재측정 대상이므로 무시되지 않는다\n", + "\n", + "ROC-AUC(신뢰도 점수로 실제 위험 구분) 0.821\n", + "[임계값 민감도] High 기준 / 상신 경보 / 오경보율 / 실제 위험 상신 비율\n", + " 45 / 243 / 4.9% / 96.2%\n", + " 55 / 210 / 5.2% / 82.9%\n", + " 60 / 137 / 0.7% / 56.7%\n", + " 70 / 134 / 0.7% / 55.4%\n", + " 80 / 88 / 1.1% / 36.2%\n", + "\n", + "잡음 재현 5회 등급 일치율 93.6% (경보 3회 이상 시점 99개, 목표 90% 이상: 달성)\n", + "※ 신뢰도 등급은 판단 보조다. 급수 중단·재개는 의무(예방의학)·환경 담당 확인과 지휘관 승인으로 결정한다.\n", + "```\n", + "
\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 06. 야전 급수차 및 정수 시설별 수질 상태 실시간 시각화 대시보드 구현\n", + "**소요시간**: 약 2.5시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "야전 급수 시설별 수질 상태 실시간 시각화 대시보드를 구현한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **야전 급수차와 정수 시설별 수질 상태를 실시간처럼 갱신해 보여 주는 상황판 대시보드를 구현한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **가상 급수원 8곳의 측정 스트림을 1시간 단위로 갱신하며 급수원 상태 지도, 항목별 기준 대비 비율 히트맵, 경보 급수원 추이, 상태 현황판, 긴급 경보 목록 5종 컴포넌트를 그린다 과정을 진행하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `src/ui/wel03_dashboard.py (또는 웹 UI 컴포넌트)` 형태의 결과물을 완성한다.\n", + "\n", + "### 🔍 평가 및 검증 기준 (PASS 조건)\n", + "#### 미션 판정\n", + "\n", + "**평가 구분:** 필수\n", + "\n", + "**핵심 평가영역:** 전술 작전 상황판으로서의 직관성 및 인지 부하 감소도\n", + "\n", + "#### 미션 PASS 조건\n", + "\n", + "- 정량 기준: 화면 갱신 주기 1초 이내, UI 응답성 100ms 이내\n", + "\n", + "\n", + "- 진행 기준: 주요 지표 시각화 컴포넌트 4종 이상 렌더링 및 실시간 데이터 연동\n", + "\n", + "\n", + "- 검증 방법: 사용자 인터랙션 테스트 및 모의 데이터 스트림 렌더링 검증\n", + "\n", + "\n", + "#### 채점자 판정 메모\n", + "\n", + "> 단순 코드 실행 여부뿐만 아니라 출력된 수치와 근거 자료가 군 실무 요구사항에 부합하는지 면밀히 확인한다.\n", + "\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "\"\"\"가상 급수원 8곳의 수질 측정 스트림을 1시간 단위로 갱신하며 급수원 상태 지도, 항목별 기준 대비 비율 히트맵, 경보 급수원 추이, 상태 현황판, 긴급 경보 목록 5종을 그리는 상황판을 구현하고, 갱신 주기·응답 시간을 측정한다(야간 모드 지원).\"\"\"\n", + "import time\n", + "import numpy as np\n", + "import pandas as pd\n", + "import matplotlib.pyplot as plt\n", + "\n", + "# ---------------------------------------------------------------------------\n", + "# [교육용가상자료] 야전 급수원 가상 수질 센서 데이터 생성기 (실제 부대·위치·검사 결과 없음)\n", + "# 가상 급수원 8곳 x 14일 x 1시간 간격. 실제 수질(true_*)·센서 측정값·오염 사건·센서 고장 정답을 함께 만든다.\n", + "# 기준치는 교육용 가정값이며, 실제 음용 판정은 공인 검사 결과와 의무(예방의학) 담당 기준에 따른다.\n", + "# ---------------------------------------------------------------------------\n", + "PARAMS = [\"ph\", \"turbidity\", \"chlorine\", \"metal\", \"ecoli\"]\n", + "SOURCES = [(\"W01\", \"water_truck\", 1.0, 2.0), (\"W02\", \"water_truck\", 3.5, 1.2), (\"W03\", \"purification_unit\", 2.2, 4.1),\n", + " (\"W04\", \"purification_unit\", 5.0, 3.3), (\"W05\", \"well\", 0.8, 5.6), (\"W06\", \"well\", 4.4, 6.0),\n", + " (\"W07\", \"river_intake\", 6.3, 1.8), (\"W08\", \"river_intake\", 6.9, 4.9)]\n", + "BASE = {\"water_truck\": (7.2, 0.25, 0.75, 1.5), \"purification_unit\": (7.0, 0.20, 0.85, 1.2),\n", + " \"well\": (6.8, 0.45, 0.50, 3.0), \"river_intake\": (7.3, 0.55, 0.60, 2.0)} # pH, 탁도(NTU), 잔류염소(mg/L), 중금속(ug/L)\n", + "LIMITS = {\"ph\": (5.8, 8.5), \"turbidity\": (1.0, 2.0), \"chlorine\": (0.2, 0.1), \"metal\": (5.0, 10.0), \"ecoli\": (1, 1)} # (주의, 위험) 가정 기준\n", + "EVENT_TYPES = { # 오염 사건: 변수별 최대 변화량, 최대치 도달 시간(h)\n", + " \"sewage\": ({\"turbidity\": 2.8, \"chlorine\": -0.5, \"ecoli\": 30, \"ph\": -0.3}, 4),\n", + " \"metal\": ({\"metal\": 22.0, \"ph\": -0.5}, 4),\n", + " \"disinfect_fail\": ({\"chlorine\": -0.8, \"ecoli\": 6}, 6),\n", + " \"chemical\": ({\"ph\": 1.7, \"turbidity\": 1.0}, 3),\n", + " \"creeping_metal\": ({\"metal\": 13.0}, 18),\n", + "}\n", + "FAULT_TYPES = [\"stuck\", \"spike\", \"drift\", \"dropout\"]\n", + "NOISE = {\"ph\": 0.04, \"turbidity\": 0.06, \"chlorine\": 0.03, \"metal\": 0.35}\n", + "DIGITS = {\"ph\": 2, \"turbidity\": 2, \"chlorine\": 2, \"metal\": 1}\n", + "\n", + "\n", + "def make_dataset(days=14, seed=7, n_events=6, n_faults=10, events=None, faults=None, sources=None):\n", + " \"\"\"events: [(급수원 번호, 사건 유형, 시작 시각 h, 지속 h, 강도)], faults: [(급수원 번호, 고장 유형, 항목, 시작 h, 지속 h)]\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " sources = sources or SOURCES\n", + " T = days * 24\n", + " tt = np.arange(T)\n", + " hour = tt % 24\n", + " night = (hour < 6) | (hour >= 20)\n", + " rain = np.zeros(T)\n", + " for _ in range(max(1, days // 4)):\n", + " s0, d = int(rng.integers(0, T - 14)), int(rng.integers(6, 14))\n", + " rain[s0:s0 + d] = np.maximum(rain[s0:s0 + d], rng.uniform(0.6, 1.0))\n", + " if events is None:\n", + " events = [(int(rng.integers(len(sources))), str(rng.choice(list(EVENT_TYPES))), int(rng.integers(min(24, T // 4), max(T // 4 + 1, T - 48))),\n", + " int(rng.integers(10, 30)), 1.0) for _ in range(n_events)]\n", + " if faults is None:\n", + " faults = [(int(rng.integers(len(sources))), str(rng.choice(FAULT_TYPES)), str(rng.choice([\"ph\", \"turbidity\", \"chlorine\", \"metal\"])),\n", + " int(rng.integers(12, max(13, T - 30))), int(rng.integers(6, 24))) for _ in range(n_faults)]\n", + " out = []\n", + " for si, (sid, stype, x, y) in enumerate(sources):\n", + " ph0, tb0, cl0, mt0 = BASE[stype]\n", + " tr = {\"ph\": ph0 + 0.08 * np.sin(2 * np.pi * hour / 24) + rng.normal(0, 0.03, T),\n", + " \"turbidity\": tb0 * (1 + 0.1 * rng.normal(0, 1, T)),\n", + " \"chlorine\": (cl0 * np.exp(-0.022 * hour) if stype == \"water_truck\" else cl0 + rng.normal(0, 0.03, T)),\n", + " \"metal\": mt0 + rng.normal(0, 0.2, T), \"ecoli\": np.zeros(T)}\n", + " if stype in (\"river_intake\", \"well\"):\n", + " tr[\"turbidity\"] = tr[\"turbidity\"] + rain * (0.9 if stype == \"river_intake\" else 0.45)\n", + " f_all, etype = np.zeros(T), np.array([\"none\"] * T, dtype=object)\n", + " for (s_i, et, onset, dur, scale) in events:\n", + " if s_i != si:\n", + " continue\n", + " delta, ramp = EVENT_TYPES[et]\n", + " f = np.clip((tt - onset + 1) / ramp, 0, 1) * ((tt >= onset) & (tt < onset + dur + ramp))\n", + " for k, v in delta.items():\n", + " tr[k] = tr[k] + v * f * scale\n", + " etype[f > 0] = et\n", + " f_all = np.maximum(f_all, f)\n", + " tr[\"chlorine\"] = np.clip(tr[\"chlorine\"], 0.02, None)\n", + " tr[\"turbidity\"] = np.clip(tr[\"turbidity\"], 0.05, None)\n", + " tr[\"metal\"] = np.clip(tr[\"metal\"], 0.1, None)\n", + " grade = np.where(f_all >= 0.2, 2, 0)\n", + " mild = (tr[\"turbidity\"] > LIMITS[\"turbidity\"][0]) | (tr[\"chlorine\"] < LIMITS[\"chlorine\"][0]) | (tr[\"metal\"] > LIMITS[\"metal\"][0])\n", + " grade = np.where((grade == 0) & mild, 1, grade)\n", + " ms = {}\n", + " for p in [\"ph\", \"turbidity\", \"chlorine\", \"metal\"]:\n", + " sd = NOISE[p] * np.where(night, 1.5, 1.0) * (tr[p] if p == \"turbidity\" else 1)\n", + " ms[p] = np.round(np.clip(tr[p] + rng.normal(0, 1, T) * sd, 0, None), DIGITS[p])\n", + " ms[\"ecoli\"] = (rng.poisson(tr[\"ecoli\"]) + (rng.random(T) < 0.004)).astype(float)\n", + " fault, fparam = np.array([\"none\"] * T, dtype=object), np.array([\"-\"] * T, dtype=object)\n", + " for (s_i, ft, p, s0, d) in faults:\n", + " if s_i != si:\n", + " continue\n", + " seg = slice(s0, min(T, s0 + d))\n", + " if ft == \"stuck\":\n", + " ms[p][seg] = ms[p][s0]\n", + " elif ft == \"spike\":\n", + " seg = slice(s0, s0 + 1)\n", + " ms[p][s0] = {\"ph\": 11.8, \"turbidity\": 38.0, \"chlorine\": 0.0, \"metal\": 75.0}[p]\n", + " elif ft == \"drift\":\n", + " ramp = np.linspace(0, 1, seg.stop - s0)\n", + " ms[p][seg] = np.clip(ms[p][seg] + ramp * {\"ph\": -1.6, \"turbidity\": 2.6, \"chlorine\": -0.75, \"metal\": 12.0}[p], 0, None).round(DIGITS[p])\n", + " elif ft == \"dropout\":\n", + " for q in PARAMS:\n", + " ms[q][seg] = np.nan\n", + " p = \"all\"\n", + " fault[seg], fparam[seg] = ft, p\n", + " df = pd.DataFrame({\"source_id\": sid, \"source_type\": stype, \"x_km\": x, \"y_km\": y, \"t\": tt,\n", + " \"cond\": np.where(rain > 0, \"storm\", np.where(night, \"night\", \"day\")),\n", + " **{p: ms[p] for p in PARAMS}, **{f\"true_{p}\": tr[p] for p in PARAMS},\n", + " \"event\": etype, \"fault\": fault, \"fault_param\": fparam, \"grade\": grade})\n", + " out.append(df)\n", + " return pd.concat(out, ignore_index=True)\n", + "# ---------------------------------------------------------------------------\n", + "\n", + "# ---- 전처리 공통 함수: 물리 범위 점검 -> 고정값(stuck) 표시 -> 단발 스파이크 제거 -> 결측 보정 ----\n", + "PHYS = {\"ph\": (0, 14), \"turbidity\": (0, 1000), \"chlorine\": (0, 10), \"metal\": (0, 1000), \"ecoli\": (0, 100000)}\n", + "SPIKE_K = {\"day\": 6.0, \"night\": 7.0, \"storm\": 9.0} # 상황별(주간/야간/악천후) 스파이크 판정 배수\n", + "MIN_JUMP = {\"ph\": 0.8, \"turbidity\": 1.5, \"chlorine\": 0.3, \"metal\": 6.0}\n", + "STUCK_RUN, SHORT_GAP = 6, 3\n", + "\n", + "\n", + "def clean(df, spike_k=None, min_jump=None):\n", + " \"\"\"급수원별 시계열을 정제하고 항목별 처리 내역(<항목>_flag: range/stuck/spike/interp/long_gap)을 남긴다.\"\"\"\n", + " spike_k = spike_k or SPIKE_K\n", + " min_jump = MIN_JUMP if min_jump is None else min_jump\n", + " out = df.sort_values([\"source_id\", \"t\"]).reset_index(drop=True)\n", + " for sid, idx in out.groupby(\"source_id\").groups.items():\n", + " idx = np.asarray(idx)\n", + " kk = out.loc[idx, \"cond\"].map(spike_k).to_numpy(float)\n", + " for p in PARAMS:\n", + " v = out.loc[idx, p].to_numpy(float).copy()\n", + " fl = np.array([\"\"] * len(v), dtype=object)\n", + " lo, hi = PHYS[p]\n", + " bad = (v < lo) | (v > hi)\n", + " v[bad], fl[bad] = np.nan, \"range\"\n", + " if p != \"ecoli\":\n", + " chg = np.r_[True, v[1:] != v[:-1]] # 같은 값이 STUCK_RUN시간 이상 반복 = 고정값 고장 의심\n", + " rid = np.cumsum(chg)\n", + " rlen = np.bincount(rid)[rid]\n", + " fl[(rlen >= STUCK_RUN) & ~chg & ~np.isnan(v)] = \"stuck\"\n", + " d = np.abs(np.diff(v[~np.isnan(v)]))\n", + " sig = 1.4826 * np.median(d) + 1e-6 if len(d) else 1.0\n", + " up, dn = v - np.r_[np.nan, v[:-1]], v - np.r_[v[1:], np.nan]\n", + " thr = np.maximum(kk * sig, min_jump[p])\n", + " spike = (np.abs(up) > thr) & (np.abs(dn) > thr) & (np.sign(up) == np.sign(dn)) # 튀었다가 바로 돌아온 값만\n", + " v[spike], fl[spike] = np.nan, \"spike\"\n", + " s = pd.Series(v)\n", + " na = s.isna().to_numpy()\n", + " grp = np.cumsum(np.r_[True, na[1:] != na[:-1]])\n", + " glen = pd.Series(na.astype(int)).groupby(grp).transform(\"sum\").to_numpy()\n", + " lin, ff = s.interpolate(limit_area=\"inside\").to_numpy(), s.ffill().bfill().to_numpy()\n", + " short, longg = na & (glen <= SHORT_GAP), na & (glen > SHORT_GAP)\n", + " v = np.where(short, np.where(np.isnan(lin), ff, lin), np.where(longg, ff, v))\n", + " fl[short & (fl == \"\")] = \"interp\"\n", + " fl[longg] = \"long_gap\"\n", + " out.loc[idx, p] = v\n", + " out.loc[idx, f\"{p}_flag\"] = fl\n", + " return out\n", + "\n", + "# ---- 가정 기준치 판정(Baseline)과 시계열 특징 공통 함수 ----\n", + "GRADE_NAME = {0: \"안전\", 1: \"주의\", 2: \"위험\"}\n", + "\n", + "\n", + "def rule_grade(df):\n", + " \"\"\"기준치 1차 필터: 위험(음용 부적합 의심) = 한 항목이라도 위험 기준 초과, 주의 = 주의 기준 초과.\"\"\"\n", + " ph, tb, cl, mt, ec = (df[p].to_numpy(float) for p in PARAMS)\n", + " danger = (ph < LIMITS[\"ph\"][0]) | (ph > LIMITS[\"ph\"][1]) | (tb > LIMITS[\"turbidity\"][1]) | (cl < LIMITS[\"chlorine\"][1]) \\\n", + " | (mt > LIMITS[\"metal\"][1]) | (ec >= LIMITS[\"ecoli\"][1])\n", + " caution = (tb > LIMITS[\"turbidity\"][0]) | (cl < LIMITS[\"chlorine\"][0]) | (mt > LIMITS[\"metal\"][0])\n", + " return np.where(danger, 2, np.where(caution, 1, 0))\n", + "\n", + "\n", + "def reasons(row):\n", + " r = []\n", + " if not LIMITS[\"ph\"][0] <= row[\"ph\"] <= LIMITS[\"ph\"][1]:\n", + " r.append(f\"pH {row['ph']:.2f}\")\n", + " if row[\"turbidity\"] > LIMITS[\"turbidity\"][0]:\n", + " r.append(f\"탁도 {row['turbidity']:.2f}NTU\")\n", + " if row[\"chlorine\"] < LIMITS[\"chlorine\"][0]:\n", + " r.append(f\"잔류염소 {row['chlorine']:.2f}mg/L\")\n", + " if row[\"metal\"] > LIMITS[\"metal\"][0]:\n", + " r.append(f\"중금속 {row['metal']:.1f}ug/L\")\n", + " if row[\"ecoli\"] >= 1:\n", + " r.append(f\"대장균 {row['ecoli']:.0f}\")\n", + " return \", \".join(r) or \"-\"\n", + "\n", + "\n", + "FEATS = [f\"{p}{s}\" for p in [\"ph\", \"turbidity\", \"chlorine\", \"metal\"] for s in [\"\", \"_d1\", \"_d3\", \"_dev\", \"_sd6\"]] + \\\n", + " [\"ecoli\", \"ecoli_3h\", \"storm\", \"night\", \"n_flag\", \"truck\", \"purif\", \"well\", \"river\"]\n", + "\n", + "\n", + "def add_features(c):\n", + " \"\"\"시간 순서를 지키는(과거 값만 쓰는) 급수원별 특징: 변화량, 24시간 기준선 대비 편차, 6시간 변동성, 대장균 누적 등.\"\"\"\n", + " c = c.sort_values([\"source_id\", \"t\"]).reset_index(drop=True)\n", + " g = c.groupby(\"source_id\")\n", + " for p in [\"ph\", \"turbidity\", \"chlorine\", \"metal\"]:\n", + " c[f\"{p}_d1\"] = g[p].diff().fillna(0)\n", + " c[f\"{p}_d3\"] = g[p].diff(3).fillna(0)\n", + " base = g[p].transform(lambda s: s.shift(1).rolling(24, min_periods=4).median())\n", + " c[f\"{p}_dev\"] = (c[p] - base).fillna(0)\n", + " c[f\"{p}_sd6\"] = g[p].transform(lambda s: s.rolling(6, min_periods=2).std()).fillna(0)\n", + " c[\"ecoli_3h\"] = g[\"ecoli\"].transform(lambda s: s.rolling(3, min_periods=1).sum())\n", + " c[\"storm\"], c[\"night\"] = (c[\"cond\"] == \"storm\").astype(int), (c[\"cond\"] == \"night\").astype(int)\n", + " flags = [f\"{p}_flag\" for p in PARAMS if f\"{p}_flag\" in c]\n", + " c[\"n_flag\"] = sum(c[f].isin([\"stuck\", \"spike\", \"long_gap\", \"range\"]).astype(int) for f in flags) if flags else 0\n", + " for k, name in [(\"truck\", \"water_truck\"), (\"purif\", \"purification_unit\"), (\"well\", \"well\"), (\"river\", \"river_intake\")]:\n", + " c[k] = (c[\"source_type\"] == name).astype(int)\n", + " return c\n", + "\n", + "DARK = True # 야간 작전 모드(어두운 배경)\n", + "NOW = 290 # 상황판 기준 시각(모의 시작 후 경과 시간 h)\n", + "COLOR = {0: \"tab:green\", 1: \"gold\", 2: \"red\"}\n", + "\n", + "data = make_dataset(days=14, seed=11, n_events=10, n_faults=14)\n", + "data = data[data[\"t\"] <= NOW]\n", + "\n", + "\n", + "def ratio(row):\n", + " \"\"\"항목별 '위험 기준 대비 비율'(1 이상이면 위험). 잔류염소·pH는 방향을 맞춰 환산한다.\"\"\"\n", + " lo, hi = LIMITS[\"ph\"]\n", + " return {\"pH\": max((row[\"ph\"] - 7.0) / (hi - 7.0), (7.0 - row[\"ph\"]) / (7.0 - lo)),\n", + " \"turb\": row[\"turbidity\"] / LIMITS[\"turbidity\"][1], \"Cl\": LIMITS[\"chlorine\"][1] / max(row[\"chlorine\"], 0.01),\n", + " \"metal\": row[\"metal\"] / LIMITS[\"metal\"][1], \"E.coli\": row[\"ecoli\"] / LIMITS[\"ecoli\"][1]}\n", + "\n", + "\n", + "# 1) 실시간 갱신 모의: 최근 24시간 창을 유지하며 1시간씩 새 측정값이 들어올 때 상태 계산 시간\n", + "window = data[data[\"t\"] <= NOW - 24].copy()\n", + "upd = []\n", + "for h in range(NOW - 23, NOW + 1):\n", + " t0 = time.perf_counter()\n", + " window = pd.concat([window[window[\"t\"] > h - 48], data[data[\"t\"] == h]])\n", + " c = clean(window)\n", + " c[\"g\"] = rule_grade(c)\n", + " latest = c[c[\"t\"] == h]\n", + " upd.append((time.perf_counter() - t0) * 1000)\n", + "print(f\"[교육용가상자료] 실시간 갱신 모의 24회: 1회 평균 {np.mean(upd):.1f}ms, 최대 {np.max(upd):.1f}ms \"\n", + " f\"(갱신 주기 1초 이내: {'달성' if max(upd) < 1000 else '미달'})\")\n", + "\n", + "# 2) 현재 상태표\n", + "state = latest.set_index(\"source_id\")\n", + "hist = c.groupby(\"source_id\")[\"g\"].apply(lambda s: int((s.tail(6) == 2).sum()))\n", + "board = pd.DataFrame({\"유형\": state[\"source_type\"], \"상태\": state[\"g\"].map(GRADE_NAME), \"최근6h 위험\": hist,\n", + " \"근거\": [reasons(r) for _, r in state.iterrows()]})\n", + "print(f\"\\n[D+{NOW // 24:02d} {NOW % 24:02d}시 급수원 상태판]\")\n", + "print(board.to_string())\n", + "alerts = board[(board[\"상태\"] != \"안전\") | (board[\"최근6h 위험\"] > 0)]\n", + "print(f\"긴급 경보 강조 대상 {len(alerts)}곳: {', '.join(alerts.index)}\")\n", + "\n", + "# 3) 상황판 그리기(컴포넌트 5종) + 렌더링 시간\n", + "plt.style.use(\"dark_background\" if DARK else \"default\")\n", + "t0 = time.perf_counter()\n", + "fig = plt.figure(figsize=(13, 7.5))\n", + "gs = fig.add_gridspec(2, 3)\n", + "ax1 = fig.add_subplot(gs[0, 0])\n", + "for sid, r in state.iterrows():\n", + " ax1.scatter(r[\"x_km\"], r[\"y_km\"], s=260, color=COLOR[r[\"g\"]], edgecolor=\"white\" if r[\"g\"] == 2 else \"none\", lw=2)\n", + " ax1.annotate(sid, (r[\"x_km\"], r[\"y_km\"]), xytext=(6, 6), textcoords=\"offset points\", fontsize=8)\n", + "ax1.set_title(\"Water source status map (virtual grid km)\"); ax1.set_xlim(0, 7.5); ax1.set_ylim(0, 7)\n", + "ax2 = fig.add_subplot(gs[0, 1])\n", + "R = pd.DataFrame([ratio(r) for _, r in state.iterrows()], index=state.index)\n", + "im = ax2.imshow(R.clip(0, 2).to_numpy(), cmap=\"RdYlGn_r\", vmin=0, vmax=2, aspect=\"auto\")\n", + "ax2.set_xticks(range(R.shape[1]), R.columns); ax2.set_yticks(range(len(R)), R.index)\n", + "ax2.set_title(\"Ratio to danger limit (>=1 danger)\"); fig.colorbar(im, ax=ax2, fraction=0.046)\n", + "ax3 = fig.add_subplot(gs[0, 2])\n", + "cnt = state[\"g\"].value_counts().reindex([0, 1, 2], fill_value=0)\n", + "ax3.bar([\"safe\", \"caution\", \"danger\"], cnt.values, color=[COLOR[i] for i in range(3)])\n", + "ax3.set_title(f\"Status count at hour {NOW}\")\n", + "ax4 = fig.add_subplot(gs[1, :2])\n", + "focus = alerts.index[0] if len(alerts) else state.index[0]\n", + "s = c[c[\"source_id\"] == focus]\n", + "for p, col in [(\"turbidity\", \"tab:cyan\"), (\"chlorine\", \"tab:orange\"), (\"metal\", \"violet\")]:\n", + " ax4.plot(s[\"t\"], s[p] / LIMITS[p][1] if p != \"chlorine\" else LIMITS[p][1] / s[p].clip(lower=0.01), label=p, color=col)\n", + "ax4.axhline(1, color=\"red\", ls=\"--\", lw=1); ax4.set_ylim(0, 3); ax4.legend(loc=\"upper left\")\n", + "ax4.set_title(f\"{focus} last 48h ratio to danger limit\"); ax4.set_xlabel(\"hour\")\n", + "ax5 = fig.add_subplot(gs[1, 2]); ax5.axis(\"off\")\n", + "lines = [f\"{sid}: {('DANGER' if r['상태'] == '위험' else 'CHECK')} 6h danger {r['최근6h 위험']}\" for sid, r in alerts.iterrows()]\n", + "ax5.text(0, 1, \"ALERT LIST\\n\" + \"\\n\".join(lines or [\"no alert\"]), va=\"top\", family=\"monospace\", fontsize=10,\n", + " color=\"red\" if len(alerts) else \"white\" if DARK else \"black\")\n", + "fig.suptitle(\"Field water quality risk board (synthetic, decision support only)\")\n", + "fig.tight_layout()\n", + "fig.canvas.draw()\n", + "draw_ms = (time.perf_counter() - t0) * 1000\n", + "\n", + "# 4) UI 응답성: 급수원 클릭(선택) 시 상세 조회 응답 시간\n", + "resp = []\n", + "for sid in state.index:\n", + " t0 = time.perf_counter()\n", + " d = c[c[\"source_id\"] == sid].tail(6)\n", + " _ = (d[[\"ph\", \"turbidity\", \"chlorine\", \"metal\", \"ecoli\"]].agg([\"min\", \"max\"]), reasons(d.iloc[-1]))\n", + " resp.append((time.perf_counter() - t0) * 1000)\n", + "print(f\"\\n시각화 컴포넌트 5종 렌더링 {draw_ms:.0f}ms, 급수원 상세 조회 응답 평균 {np.mean(resp):.1f}ms \"\n", + " f\"(UI 응답성 100ms 이내: {'달성' if max(resp) < 100 else '미달'})\")\n", + "print(\"※ 상황판의 위험 표시는 가정 기준치 기반 판단 보조이며, 급수 중단 여부는 의무(예방의학)·환경 담당 확인 후 지휘관이 결정한다.\")\n", + "plt.show()\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 06 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + "[교육용가상자료] 실시간 갱신 모의 24회: 1회 평균 158.2ms, 최대 205.7ms (갱신 주기 1초 이내: 달성)\n", + "\n", + "[D+12 02시 급수원 상태판]\n", + " 유형 상태 최근6h 위험 근거\n", + "source_id \n", + "W01 water_truck 안전 0 -\n", + "W02 water_truck 안전 0 -\n", + "W03 purification_unit 안전 0 -\n", + "W04 purification_unit 위험 3 pH 8.72, 탁도 1.31NTU\n", + "W05 well 안전 0 -\n", + "W06 well 안전 0 -\n", + "W07 river_intake 주의 0 중금속 8.7ug/L\n", + "W08 river_intake 위험 6 중금속 24.1ug/L\n", + "긴급 경보 강조 대상 3곳: W04, W07, W08\n", + "\n", + "시각화 컴포넌트 5종 렌더링 581ms, 급수원 상세 조회 응답 평균 5.7ms (UI 응답성 100ms 이내: 달성)\n", + "※ 상황판의 위험 표시는 가정 기준치 기반 판단 보조이며, 급수 중단 여부는 의무(예방의학)·환경 담당 확인 후 지휘관이 결정한다.\n", + "```\n", + "
\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 07. 수질 위험 경보 발생 시 급수관 긴급 채수 검사 및 급수 중단 HITL 결재선 설계\n", + "**소요시간**: 약 3.0시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "수질 위험 경보 발생 시 급수 중단 및 재검사 HITL 결재선을 설계한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **수질 위험 경보 발생 시 긴급 채수 검사와 급수 중단을 사람이 결정하는 HITL 결재선을 설계한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **경보 사건을 검토 큐에 우선순위로 등록하고 측정병 채수, 예방의학 담당 검토, 지휘관 결심의 순서로 급수 중단 또는 유지를 결정하는 상태 기계 구현하고 동작을 검증하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `src/hitl/wel03_approval_workflow.py 및 docs/wel03_hitl_guide.md` 형태의 결과물을 완성한다.\n", + "\n", + "### 🔍 평가 및 검증 기준 (PASS 조건)\n", + "#### 미션 판정\n", + "\n", + "**평가 구분:** 필수\n", + "\n", + "**핵심 평가영역:** 지휘 책임 분계선 명확화 및 성급한 자동 단정 방지 체계 부합성\n", + "\n", + "#### 미션 PASS 조건\n", + "\n", + "- 정량 기준: 인간 검토 큐(Queue) 대기시간 및 승인/반려 처리 로깅 성공률 100%\n", + "\n", + "\n", + "- 진행 기준: 승인/반려 인터페이스 동작 및 감사 로그(Audit Log) 생성 검증\n", + "\n", + "\n", + "- 검증 방법: 모의 조작자 승인/반려 시뮬레이션 및 데이터 무결성 검증\n", + "\n", + "\n", + "#### 채점자 판정 메모\n", + "\n", + "> 단순 코드 실행 여부뿐만 아니라 출력된 수치와 근거 자료가 군 실무 요구사항에 부합하는지 면밀히 확인한다.\n", + "\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "\"\"\"수질 위험 경보가 나면 시스템은 급수 중단을 자동 확정하지 않고 우선순위 검토 큐에 올리며, 측정 담당 긴급 채수 → 예방의학 담당 검토 → 지휘관 급수 중단 승인·반려(반려 사유 필수) 결재선과 권한 통제, 해시 체인 감사 로그, 큐 대기시간을 시뮬레이션한다.\"\"\"\n", + "import hashlib\n", + "import json\n", + "import numpy as np\n", + "import pandas as pd\n", + "\n", + "# ---------------------------------------------------------------------------\n", + "# [교육용가상자료] 야전 급수원 가상 수질 센서 데이터 생성기 (실제 부대·위치·검사 결과 없음)\n", + "# 가상 급수원 8곳 x 14일 x 1시간 간격. 실제 수질(true_*)·센서 측정값·오염 사건·센서 고장 정답을 함께 만든다.\n", + "# 기준치는 교육용 가정값이며, 실제 음용 판정은 공인 검사 결과와 의무(예방의학) 담당 기준에 따른다.\n", + "# ---------------------------------------------------------------------------\n", + "PARAMS = [\"ph\", \"turbidity\", \"chlorine\", \"metal\", \"ecoli\"]\n", + "SOURCES = [(\"W01\", \"water_truck\", 1.0, 2.0), (\"W02\", \"water_truck\", 3.5, 1.2), (\"W03\", \"purification_unit\", 2.2, 4.1),\n", + " (\"W04\", \"purification_unit\", 5.0, 3.3), (\"W05\", \"well\", 0.8, 5.6), (\"W06\", \"well\", 4.4, 6.0),\n", + " (\"W07\", \"river_intake\", 6.3, 1.8), (\"W08\", \"river_intake\", 6.9, 4.9)]\n", + "BASE = {\"water_truck\": (7.2, 0.25, 0.75, 1.5), \"purification_unit\": (7.0, 0.20, 0.85, 1.2),\n", + " \"well\": (6.8, 0.45, 0.50, 3.0), \"river_intake\": (7.3, 0.55, 0.60, 2.0)} # pH, 탁도(NTU), 잔류염소(mg/L), 중금속(ug/L)\n", + "LIMITS = {\"ph\": (5.8, 8.5), \"turbidity\": (1.0, 2.0), \"chlorine\": (0.2, 0.1), \"metal\": (5.0, 10.0), \"ecoli\": (1, 1)} # (주의, 위험) 가정 기준\n", + "EVENT_TYPES = { # 오염 사건: 변수별 최대 변화량, 최대치 도달 시간(h)\n", + " \"sewage\": ({\"turbidity\": 2.8, \"chlorine\": -0.5, \"ecoli\": 30, \"ph\": -0.3}, 4),\n", + " \"metal\": ({\"metal\": 22.0, \"ph\": -0.5}, 4),\n", + " \"disinfect_fail\": ({\"chlorine\": -0.8, \"ecoli\": 6}, 6),\n", + " \"chemical\": ({\"ph\": 1.7, \"turbidity\": 1.0}, 3),\n", + " \"creeping_metal\": ({\"metal\": 13.0}, 18),\n", + "}\n", + "FAULT_TYPES = [\"stuck\", \"spike\", \"drift\", \"dropout\"]\n", + "NOISE = {\"ph\": 0.04, \"turbidity\": 0.06, \"chlorine\": 0.03, \"metal\": 0.35}\n", + "DIGITS = {\"ph\": 2, \"turbidity\": 2, \"chlorine\": 2, \"metal\": 1}\n", + "\n", + "\n", + "def make_dataset(days=14, seed=7, n_events=6, n_faults=10, events=None, faults=None, sources=None):\n", + " \"\"\"events: [(급수원 번호, 사건 유형, 시작 시각 h, 지속 h, 강도)], faults: [(급수원 번호, 고장 유형, 항목, 시작 h, 지속 h)]\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " sources = sources or SOURCES\n", + " T = days * 24\n", + " tt = np.arange(T)\n", + " hour = tt % 24\n", + " night = (hour < 6) | (hour >= 20)\n", + " rain = np.zeros(T)\n", + " for _ in range(max(1, days // 4)):\n", + " s0, d = int(rng.integers(0, T - 14)), int(rng.integers(6, 14))\n", + " rain[s0:s0 + d] = np.maximum(rain[s0:s0 + d], rng.uniform(0.6, 1.0))\n", + " if events is None:\n", + " events = [(int(rng.integers(len(sources))), str(rng.choice(list(EVENT_TYPES))), int(rng.integers(min(24, T // 4), max(T // 4 + 1, T - 48))),\n", + " int(rng.integers(10, 30)), 1.0) for _ in range(n_events)]\n", + " if faults is None:\n", + " faults = [(int(rng.integers(len(sources))), str(rng.choice(FAULT_TYPES)), str(rng.choice([\"ph\", \"turbidity\", \"chlorine\", \"metal\"])),\n", + " int(rng.integers(12, max(13, T - 30))), int(rng.integers(6, 24))) for _ in range(n_faults)]\n", + " out = []\n", + " for si, (sid, stype, x, y) in enumerate(sources):\n", + " ph0, tb0, cl0, mt0 = BASE[stype]\n", + " tr = {\"ph\": ph0 + 0.08 * np.sin(2 * np.pi * hour / 24) + rng.normal(0, 0.03, T),\n", + " \"turbidity\": tb0 * (1 + 0.1 * rng.normal(0, 1, T)),\n", + " \"chlorine\": (cl0 * np.exp(-0.022 * hour) if stype == \"water_truck\" else cl0 + rng.normal(0, 0.03, T)),\n", + " \"metal\": mt0 + rng.normal(0, 0.2, T), \"ecoli\": np.zeros(T)}\n", + " if stype in (\"river_intake\", \"well\"):\n", + " tr[\"turbidity\"] = tr[\"turbidity\"] + rain * (0.9 if stype == \"river_intake\" else 0.45)\n", + " f_all, etype = np.zeros(T), np.array([\"none\"] * T, dtype=object)\n", + " for (s_i, et, onset, dur, scale) in events:\n", + " if s_i != si:\n", + " continue\n", + " delta, ramp = EVENT_TYPES[et]\n", + " f = np.clip((tt - onset + 1) / ramp, 0, 1) * ((tt >= onset) & (tt < onset + dur + ramp))\n", + " for k, v in delta.items():\n", + " tr[k] = tr[k] + v * f * scale\n", + " etype[f > 0] = et\n", + " f_all = np.maximum(f_all, f)\n", + " tr[\"chlorine\"] = np.clip(tr[\"chlorine\"], 0.02, None)\n", + " tr[\"turbidity\"] = np.clip(tr[\"turbidity\"], 0.05, None)\n", + " tr[\"metal\"] = np.clip(tr[\"metal\"], 0.1, None)\n", + " grade = np.where(f_all >= 0.2, 2, 0)\n", + " mild = (tr[\"turbidity\"] > LIMITS[\"turbidity\"][0]) | (tr[\"chlorine\"] < LIMITS[\"chlorine\"][0]) | (tr[\"metal\"] > LIMITS[\"metal\"][0])\n", + " grade = np.where((grade == 0) & mild, 1, grade)\n", + " ms = {}\n", + " for p in [\"ph\", \"turbidity\", \"chlorine\", \"metal\"]:\n", + " sd = NOISE[p] * np.where(night, 1.5, 1.0) * (tr[p] if p == \"turbidity\" else 1)\n", + " ms[p] = np.round(np.clip(tr[p] + rng.normal(0, 1, T) * sd, 0, None), DIGITS[p])\n", + " ms[\"ecoli\"] = (rng.poisson(tr[\"ecoli\"]) + (rng.random(T) < 0.004)).astype(float)\n", + " fault, fparam = np.array([\"none\"] * T, dtype=object), np.array([\"-\"] * T, dtype=object)\n", + " for (s_i, ft, p, s0, d) in faults:\n", + " if s_i != si:\n", + " continue\n", + " seg = slice(s0, min(T, s0 + d))\n", + " if ft == \"stuck\":\n", + " ms[p][seg] = ms[p][s0]\n", + " elif ft == \"spike\":\n", + " seg = slice(s0, s0 + 1)\n", + " ms[p][s0] = {\"ph\": 11.8, \"turbidity\": 38.0, \"chlorine\": 0.0, \"metal\": 75.0}[p]\n", + " elif ft == \"drift\":\n", + " ramp = np.linspace(0, 1, seg.stop - s0)\n", + " ms[p][seg] = np.clip(ms[p][seg] + ramp * {\"ph\": -1.6, \"turbidity\": 2.6, \"chlorine\": -0.75, \"metal\": 12.0}[p], 0, None).round(DIGITS[p])\n", + " elif ft == \"dropout\":\n", + " for q in PARAMS:\n", + " ms[q][seg] = np.nan\n", + " p = \"all\"\n", + " fault[seg], fparam[seg] = ft, p\n", + " df = pd.DataFrame({\"source_id\": sid, \"source_type\": stype, \"x_km\": x, \"y_km\": y, \"t\": tt,\n", + " \"cond\": np.where(rain > 0, \"storm\", np.where(night, \"night\", \"day\")),\n", + " **{p: ms[p] for p in PARAMS}, **{f\"true_{p}\": tr[p] for p in PARAMS},\n", + " \"event\": etype, \"fault\": fault, \"fault_param\": fparam, \"grade\": grade})\n", + " out.append(df)\n", + " return pd.concat(out, ignore_index=True)\n", + "# ---------------------------------------------------------------------------\n", + "\n", + "# ---- 전처리 공통 함수: 물리 범위 점검 -> 고정값(stuck) 표시 -> 단발 스파이크 제거 -> 결측 보정 ----\n", + "PHYS = {\"ph\": (0, 14), \"turbidity\": (0, 1000), \"chlorine\": (0, 10), \"metal\": (0, 1000), \"ecoli\": (0, 100000)}\n", + "SPIKE_K = {\"day\": 6.0, \"night\": 7.0, \"storm\": 9.0} # 상황별(주간/야간/악천후) 스파이크 판정 배수\n", + "MIN_JUMP = {\"ph\": 0.8, \"turbidity\": 1.5, \"chlorine\": 0.3, \"metal\": 6.0}\n", + "STUCK_RUN, SHORT_GAP = 6, 3\n", + "\n", + "\n", + "def clean(df, spike_k=None, min_jump=None):\n", + " \"\"\"급수원별 시계열을 정제하고 항목별 처리 내역(<항목>_flag: range/stuck/spike/interp/long_gap)을 남긴다.\"\"\"\n", + " spike_k = spike_k or SPIKE_K\n", + " min_jump = MIN_JUMP if min_jump is None else min_jump\n", + " out = df.sort_values([\"source_id\", \"t\"]).reset_index(drop=True)\n", + " for sid, idx in out.groupby(\"source_id\").groups.items():\n", + " idx = np.asarray(idx)\n", + " kk = out.loc[idx, \"cond\"].map(spike_k).to_numpy(float)\n", + " for p in PARAMS:\n", + " v = out.loc[idx, p].to_numpy(float).copy()\n", + " fl = np.array([\"\"] * len(v), dtype=object)\n", + " lo, hi = PHYS[p]\n", + " bad = (v < lo) | (v > hi)\n", + " v[bad], fl[bad] = np.nan, \"range\"\n", + " if p != \"ecoli\":\n", + " chg = np.r_[True, v[1:] != v[:-1]] # 같은 값이 STUCK_RUN시간 이상 반복 = 고정값 고장 의심\n", + " rid = np.cumsum(chg)\n", + " rlen = np.bincount(rid)[rid]\n", + " fl[(rlen >= STUCK_RUN) & ~chg & ~np.isnan(v)] = \"stuck\"\n", + " d = np.abs(np.diff(v[~np.isnan(v)]))\n", + " sig = 1.4826 * np.median(d) + 1e-6 if len(d) else 1.0\n", + " up, dn = v - np.r_[np.nan, v[:-1]], v - np.r_[v[1:], np.nan]\n", + " thr = np.maximum(kk * sig, min_jump[p])\n", + " spike = (np.abs(up) > thr) & (np.abs(dn) > thr) & (np.sign(up) == np.sign(dn)) # 튀었다가 바로 돌아온 값만\n", + " v[spike], fl[spike] = np.nan, \"spike\"\n", + " s = pd.Series(v)\n", + " na = s.isna().to_numpy()\n", + " grp = np.cumsum(np.r_[True, na[1:] != na[:-1]])\n", + " glen = pd.Series(na.astype(int)).groupby(grp).transform(\"sum\").to_numpy()\n", + " lin, ff = s.interpolate(limit_area=\"inside\").to_numpy(), s.ffill().bfill().to_numpy()\n", + " short, longg = na & (glen <= SHORT_GAP), na & (glen > SHORT_GAP)\n", + " v = np.where(short, np.where(np.isnan(lin), ff, lin), np.where(longg, ff, v))\n", + " fl[short & (fl == \"\")] = \"interp\"\n", + " fl[longg] = \"long_gap\"\n", + " out.loc[idx, p] = v\n", + " out.loc[idx, f\"{p}_flag\"] = fl\n", + " return out\n", + "\n", + "# ---- 가정 기준치 판정(Baseline)과 시계열 특징 공통 함수 ----\n", + "GRADE_NAME = {0: \"안전\", 1: \"주의\", 2: \"위험\"}\n", + "\n", + "\n", + "def rule_grade(df):\n", + " \"\"\"기준치 1차 필터: 위험(음용 부적합 의심) = 한 항목이라도 위험 기준 초과, 주의 = 주의 기준 초과.\"\"\"\n", + " ph, tb, cl, mt, ec = (df[p].to_numpy(float) for p in PARAMS)\n", + " danger = (ph < LIMITS[\"ph\"][0]) | (ph > LIMITS[\"ph\"][1]) | (tb > LIMITS[\"turbidity\"][1]) | (cl < LIMITS[\"chlorine\"][1]) \\\n", + " | (mt > LIMITS[\"metal\"][1]) | (ec >= LIMITS[\"ecoli\"][1])\n", + " caution = (tb > LIMITS[\"turbidity\"][0]) | (cl < LIMITS[\"chlorine\"][0]) | (mt > LIMITS[\"metal\"][0])\n", + " return np.where(danger, 2, np.where(caution, 1, 0))\n", + "\n", + "\n", + "def reasons(row):\n", + " r = []\n", + " if not LIMITS[\"ph\"][0] <= row[\"ph\"] <= LIMITS[\"ph\"][1]:\n", + " r.append(f\"pH {row['ph']:.2f}\")\n", + " if row[\"turbidity\"] > LIMITS[\"turbidity\"][0]:\n", + " r.append(f\"탁도 {row['turbidity']:.2f}NTU\")\n", + " if row[\"chlorine\"] < LIMITS[\"chlorine\"][0]:\n", + " r.append(f\"잔류염소 {row['chlorine']:.2f}mg/L\")\n", + " if row[\"metal\"] > LIMITS[\"metal\"][0]:\n", + " r.append(f\"중금속 {row['metal']:.1f}ug/L\")\n", + " if row[\"ecoli\"] >= 1:\n", + " r.append(f\"대장균 {row['ecoli']:.0f}\")\n", + " return \", \".join(r) or \"-\"\n", + "\n", + "\n", + "FEATS = [f\"{p}{s}\" for p in [\"ph\", \"turbidity\", \"chlorine\", \"metal\"] for s in [\"\", \"_d1\", \"_d3\", \"_dev\", \"_sd6\"]] + \\\n", + " [\"ecoli\", \"ecoli_3h\", \"storm\", \"night\", \"n_flag\", \"truck\", \"purif\", \"well\", \"river\"]\n", + "\n", + "\n", + "def add_features(c):\n", + " \"\"\"시간 순서를 지키는(과거 값만 쓰는) 급수원별 특징: 변화량, 24시간 기준선 대비 편차, 6시간 변동성, 대장균 누적 등.\"\"\"\n", + " c = c.sort_values([\"source_id\", \"t\"]).reset_index(drop=True)\n", + " g = c.groupby(\"source_id\")\n", + " for p in [\"ph\", \"turbidity\", \"chlorine\", \"metal\"]:\n", + " c[f\"{p}_d1\"] = g[p].diff().fillna(0)\n", + " c[f\"{p}_d3\"] = g[p].diff(3).fillna(0)\n", + " base = g[p].transform(lambda s: s.shift(1).rolling(24, min_periods=4).median())\n", + " c[f\"{p}_dev\"] = (c[p] - base).fillna(0)\n", + " c[f\"{p}_sd6\"] = g[p].transform(lambda s: s.rolling(6, min_periods=2).std()).fillna(0)\n", + " c[\"ecoli_3h\"] = g[\"ecoli\"].transform(lambda s: s.rolling(3, min_periods=1).sum())\n", + " c[\"storm\"], c[\"night\"] = (c[\"cond\"] == \"storm\").astype(int), (c[\"cond\"] == \"night\").astype(int)\n", + " flags = [f\"{p}_flag\" for p in PARAMS if f\"{p}_flag\" in c]\n", + " c[\"n_flag\"] = sum(c[f].isin([\"stuck\", \"spike\", \"long_gap\", \"range\"]).astype(int) for f in flags) if flags else 0\n", + " for k, name in [(\"truck\", \"water_truck\"), (\"purif\", \"purification_unit\"), (\"well\", \"well\"), (\"river\", \"river_intake\")]:\n", + " c[k] = (c[\"source_type\"] == name).astype(int)\n", + " return c\n", + "\n", + "ROLE_ACTIONS = {\"측정병\": {\"sample\"}, \"예방의학담당\": {\"review\"}, \"지휘관\": {\"approve_stop\", \"reject\"}, \"시스템\": {\"enqueue\"}}\n", + "USERS = {\"op01\": \"측정병\", \"med01\": \"예방의학담당\", \"cdr01\": \"지휘관\", \"sys\": \"시스템\"}\n", + "FLOW = {\"enqueue\": (\"-\", \"채수대기\"), \"sample\": (\"채수대기\", \"검토대기\"), \"review\": (\"검토대기\", \"승인대기\"),\n", + " \"approve_stop\": (\"승인대기\", \"급수중단\"), \"reject\": (\"승인대기\", \"급수유지\")}\n", + "\n", + "\n", + "class AuditLog:\n", + " def __init__(self):\n", + " self.rows = []\n", + "\n", + " def add(self, **kw):\n", + " prev = self.rows[-1][\"hash\"] if self.rows else \"0\" * 16\n", + " body = json.dumps(kw, ensure_ascii=False, sort_keys=True)\n", + " self.rows.append({**kw, \"prev\": prev, \"hash\": hashlib.sha256((prev + body).encode()).hexdigest()[:16]})\n", + "\n", + " def verify(self):\n", + " prev = \"0\" * 16\n", + " for r in self.rows:\n", + " body = json.dumps({k: v for k, v in r.items() if k not in (\"prev\", \"hash\")}, ensure_ascii=False, sort_keys=True)\n", + " if r[\"prev\"] != prev or hashlib.sha256((prev + body).encode()).hexdigest()[:16] != r[\"hash\"]:\n", + " return False\n", + " prev = r[\"hash\"]\n", + " return True\n", + "\n", + "\n", + "class Workflow:\n", + " def __init__(self):\n", + " self.log, self.state, self.n_try = AuditLog(), {}, 0\n", + "\n", + " def act(self, user, action, case, t, note=\"\"):\n", + " \"\"\"권한·순서·사유를 검사한 뒤 상태를 바꾼다. 거부도 감사 로그에 남긴다.\"\"\"\n", + " self.n_try += 1\n", + " role = USERS.get(user, \"미등록\")\n", + " cur = self.state.get(case, \"-\")\n", + " err = None\n", + " if action not in ROLE_ACTIONS.get(role, set()):\n", + " err = f\"권한 없음({role} 역할에 {action} 권한 없음)\"\n", + " elif FLOW[action][0] != cur:\n", + " err = f\"순서 위반(현재 {cur})\"\n", + " elif action in (\"reject\", \"approve_stop\", \"review\") and len(note.strip()) < 5:\n", + " err = \"사유·근거 입력 필수\"\n", + " if err is None:\n", + " self.state[case] = FLOW[action][1]\n", + " self.log.add(t=t, user=user, role=role, case=case, action=action, result=\"거부\" if err else \"처리\",\n", + " detail=err or note, state=self.state.get(case, \"-\"))\n", + " return err is None\n", + "\n", + "\n", + "# 1) 경보 생성: 가상 벤치마크에서 위험 판정이 2시간 이상 이어진 구간을 '경보 사건'으로 묶는다\n", + "c = clean(make_dataset(days=14, seed=11, n_events=10, n_faults=14))\n", + "c[\"g\"] = rule_grade(c)\n", + "cases = []\n", + "for sid, s in c.groupby(\"source_id\"):\n", + " d = (s[\"g\"] == 2).astype(int).to_numpy()\n", + " seg = np.split(np.arange(len(d)), np.where(np.diff(d) != 0)[0] + 1)\n", + " for idx in seg:\n", + " if d[idx[0]] == 1 and len(idx) >= 2:\n", + " r = s.iloc[idx[0]]\n", + " cases.append({\"case\": f\"AL-{len(cases) + 1:02d}\", \"source\": sid, \"type\": r[\"source_type\"], \"t\": int(r[\"t\"]),\n", + " \"hours\": len(idx), \"real\": bool((s.iloc[idx][\"grade\"] == 2).mean() >= 0.5), \"basis\": reasons(s.iloc[idx[-1]])})\n", + "Q = pd.DataFrame(cases)\n", + "# 우선순위: 지속시간 + 급수차·정수장(공급 인원 많음) 가중 + 대장균·중금속 포함 가중\n", + "Q[\"priority\"] = Q[\"hours\"].clip(upper=12) + Q[\"type\"].isin([\"water_truck\", \"purification_unit\"]) * 4 \\\n", + " + Q[\"basis\"].str.contains(\"대장균|중금속\") * 3\n", + "Q = Q.sort_values([\"priority\", \"t\"], ascending=[False, True]).reset_index(drop=True)\n", + "print(f\"[교육용가상자료] 경보 사건 {len(Q)}건이 검토 큐에 등록됨(자동 급수 중단 0건)\")\n", + "\n", + "# 2) 결재 시뮬레이션: 채수 담당 30분 내 채수, 예방의학 담당 재검사 결과로 의견, 지휘관이 승인·반려\n", + "wf = Workflow()\n", + "rng = np.random.default_rng(5)\n", + "wait = []\n", + "for _, q in Q.iterrows():\n", + " t = q[\"t\"] * 60\n", + " wf.act(\"sys\", \"enqueue\", q[\"case\"], t, \"기준치 초과 경보 자동 등록\")\n", + " t += int(rng.integers(10, 30)); wf.act(\"op01\", \"sample\", q[\"case\"], t, \"현장 키트 재채수\")\n", + " t += int(rng.integers(20, 60))\n", + " opinion = \"재검사 위험 확인, 급수 중단 권고\" if q[\"real\"] else \"재검사 정상, 센서 점검 권고\"\n", + " wf.act(\"med01\", \"review\", q[\"case\"], t, opinion)\n", + " t += int(rng.integers(5, 25))\n", + " if q[\"real\"]:\n", + " wf.act(\"cdr01\", \"approve_stop\", q[\"case\"], t, f\"{q['basis']} 확인, 대체 급수원 전환\")\n", + " else:\n", + " wf.act(\"cdr01\", \"reject\", q[\"case\"], t, \"재검사 정상, 급수 유지하고 센서 교정\")\n", + " wait.append(t - q[\"t\"] * 60)\n", + "st = pd.Series(wf.state).value_counts()\n", + "print(\"결재 결과:\", st.to_dict(), f\"/ 경보부터 결정까지 평균 {np.mean(wait):.0f}분, 최대 {max(wait)}분\")\n", + "print(\"\\n[검토 큐 상위 5건]\")\n", + "print(Q.head(5)[[\"case\", \"source\", \"type\", \"t\", \"hours\", \"priority\", \"basis\"]].to_string(index=False))\n", + "\n", + "# 3) 권한 우회·절차 위반 시도 6종 (사이사이 정상 절차를 진행)\n", + "wf.act(\"sys\", \"enqueue\", \"AL-X\", 99999, \"시험 경보\")\n", + "STEPS = [(\"위반\", \"op01\", \"approve_stop\", \"측정병이 직접 급수 중단\", \"중단\"), (\"위반\", \"sys\", \"approve_stop\", \"시스템 자동 확정\", \"자동\"),\n", + " (\"위반\", \"cdr01\", \"approve_stop\", \"채수·검토 없이 지휘관 승인\", \"즉시 중단\"), (\"위반\", \"hacker\", \"review\", \"미등록 사용자 검토\", \"위험 확인됨\"),\n", + " (\"정상\", \"op01\", \"sample\", \"정상 채수\", \"재채수\"), (\"위반\", \"med01\", \"review\", \"근거 없는 검토 의견\", \"\"),\n", + " (\"정상\", \"med01\", \"review\", \"정상 검토\", \"재검사 정상 확인\"), (\"위반\", \"cdr01\", \"reject\", \"사유 없는 반려\", \"\")]\n", + "blocked = n_v = 0\n", + "print(\"\\n[권한 우회·절차 위반 시도]\")\n", + "for kind, u, a, desc, note in STEPS:\n", + " ok = wf.act(u, a, \"AL-X\", 99999, note)\n", + " if kind == \"위반\":\n", + " n_v += 1; blocked += not ok\n", + " print(f\" {desc:16s} -> {'차단' if not ok else '통과(문제)'}: {wf.log.rows[-1]['detail']}\")\n", + "print(f\"우회·위반 시도 차단 {blocked}/{n_v} ({blocked / n_v:.0%})\")\n", + "\n", + "# 4) 감사 로그 무결성과 로깅 성공률\n", + "n_log = len(wf.log.rows)\n", + "print(f\"\\n감사 로그 {n_log}건 / 요청 {wf.n_try}건 -> 로깅 성공률 {n_log / wf.n_try:.0%}, 해시 체인 검증: {'정상' if wf.log.verify() else '위변조'}\")\n", + "wf.log.rows[5][\"detail\"] = \"기록 임의 수정\"\n", + "print(f\"5번째 기록 임의 수정 후 검증: {'정상' if wf.log.verify() else '위변조 탐지'}\")\n", + "print(\"※ 급수 중단·재개는 지휘관 결정 사항이며, 시스템은 경보와 근거만 제공한다. 실제 판정은 공식 기준과 의무·환경 담당 확인에 따른다.\")\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 07 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + "[교육용가상자료] 경보 사건 10건이 검토 큐에 등록됨(자동 급수 중단 0건)\n", + "결재 결과: {'급수중단': 9, '급수유지': 1} / 경보부터 결정까지 평균 69분, 최대 82분\n", + "\n", + "[검토 큐 상위 5건]\n", + " case source type t hours priority basis\n", + "AL-02 W03 purification_unit 243 23 19 잔류염소 0.00mg/L, 대장균 9\n", + "AL-01 W02 water_truck 147 21 16 pH 8.87, 탁도 1.21NTU\n", + "AL-03 W04 purification_unit 288 16 16 pH 8.72, 탁도 1.12NTU\n", + "AL-09 W08 river_intake 69 22 15 중금속 15.0ug/L\n", + "AL-07 W07 river_intake 115 29 15 탁도 3.11NTU, 잔류염소 0.08mg/L, 대장균 39\n", + "\n", + "[권한 우회·절차 위반 시도]\n", + " 측정병이 직접 급수 중단 -> 차단: 권한 없음(측정병 역할에 approve_stop 권한 없음)\n", + " 시스템 자동 확정 -> 차단: 권한 없음(시스템 역할에 approve_stop 권한 없음)\n", + " 채수·검토 없이 지휘관 승인 -> 차단: 순서 위반(현재 채수대기)\n", + " 미등록 사용자 검토 -> 차단: 권한 없음(미등록 역할에 review 권한 없음)\n", + " 근거 없는 검토 의견 -> 차단: 사유·근거 입력 필수\n", + " 사유 없는 반려 -> 차단: 사유·근거 입력 필수\n", + "우회·위반 시도 차단 6/6 (100%)\n", + "\n", + "감사 로그 49건 / 요청 49건 -> 로깅 성공률 100%, 해시 체인 검증: 정상\n", + "5번째 기록 임의 수정 후 검증: 위변조 탐지\n", + "※ 급수 중단·재개는 지휘관 결정 사항이며, 시스템은 경보와 근거만 제공한다. 실제 판정은 공식 기준과 의무·환경 담당 확인에 따른다.\n", + "```\n", + "
\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 08. 오염물질 대량 유입 가상 시나리오 부하 테스트 및 경보 반응 시간 검증\n", + "**소요시간**: 약 3.5시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "오염물질 대량 유입 가상 시나리오 부하 테스트를 수행한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **오염물질이 대량 유입되는 가상 시나리오에서 파이프라인 부하 시험을 하고 경보 반응 시간과 Fail-safe 동작을 검증한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **급수원을 8곳에서 48곳으로 늘려 트래픽을 500% 키우고 30곳에 오염이 동시에 유입되는 상황에서 수집·정제·판정 주기의 처리 시간, 시스템 중단, 데이터 유실, 메모리 증가, 경보 탐지와 반응 시간을 평시와 비교 수행하고 검증하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `tests/wel03_stress_test.py 및 스트레스 테스트 분석 보고서` 형태의 결과물을 완성한다.\n", + "\n", + "### 🔍 평가 및 검증 기준 (PASS 조건)\n", + "#### 미션 판정\n", + "\n", + "**평가 구분:** 필수\n", + "\n", + "**핵심 평가영역:** 극단 상황에서의 안전 실패(Fail-safe) 및 기능 축소 작동 적절성\n", + "\n", + "#### 미션 PASS 조건\n", + "\n", + "- 정량 기준: 동시 트래픽 500% 증가 시 시스템 다운 0건, 데이터 유실률 0%\n", + "\n", + "\n", + "- 진행 기준: 대량 시뮬레이션 부하 주입 테스트 통과 및 병목 구간 프로파일링 완료\n", + "\n", + "\n", + "- 검증 방법: 부하 테스트 도구를 통한 처리량(Throughput) 및 메모리 누수 검증\n", + "\n", + "\n", + "#### 채점자 판정 메모\n", + "\n", + "> 단순 코드 실행 여부뿐만 아니라 출력된 수치와 근거 자료가 군 실무 요구사항에 부합하는지 면밀히 확인한다.\n", + "\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "\"\"\"가상 급수원을 8곳에서 48곳(트래픽 500% 증가)으로 늘리고 30곳에 오염물질이 동시에 유입되는 시나리오로 수집·판정 파이프라인 부하 시험을 수행해, 시스템 중단·데이터 유실·메모리 증가·경보 반응 시간과 과부하 시 기능 축소(Fail-safe) 동작을 검증한다.\"\"\"\n", + "import time\n", + "import numpy as np\n", + "import pandas as pd\n", + "import matplotlib.pyplot as plt\n", + "\n", + "# ---------------------------------------------------------------------------\n", + "# [교육용가상자료] 야전 급수원 가상 수질 센서 데이터 생성기 (실제 부대·위치·검사 결과 없음)\n", + "# 가상 급수원 8곳 x 14일 x 1시간 간격. 실제 수질(true_*)·센서 측정값·오염 사건·센서 고장 정답을 함께 만든다.\n", + "# 기준치는 교육용 가정값이며, 실제 음용 판정은 공인 검사 결과와 의무(예방의학) 담당 기준에 따른다.\n", + "# ---------------------------------------------------------------------------\n", + "PARAMS = [\"ph\", \"turbidity\", \"chlorine\", \"metal\", \"ecoli\"]\n", + "SOURCES = [(\"W01\", \"water_truck\", 1.0, 2.0), (\"W02\", \"water_truck\", 3.5, 1.2), (\"W03\", \"purification_unit\", 2.2, 4.1),\n", + " (\"W04\", \"purification_unit\", 5.0, 3.3), (\"W05\", \"well\", 0.8, 5.6), (\"W06\", \"well\", 4.4, 6.0),\n", + " (\"W07\", \"river_intake\", 6.3, 1.8), (\"W08\", \"river_intake\", 6.9, 4.9)]\n", + "BASE = {\"water_truck\": (7.2, 0.25, 0.75, 1.5), \"purification_unit\": (7.0, 0.20, 0.85, 1.2),\n", + " \"well\": (6.8, 0.45, 0.50, 3.0), \"river_intake\": (7.3, 0.55, 0.60, 2.0)} # pH, 탁도(NTU), 잔류염소(mg/L), 중금속(ug/L)\n", + "LIMITS = {\"ph\": (5.8, 8.5), \"turbidity\": (1.0, 2.0), \"chlorine\": (0.2, 0.1), \"metal\": (5.0, 10.0), \"ecoli\": (1, 1)} # (주의, 위험) 가정 기준\n", + "EVENT_TYPES = { # 오염 사건: 변수별 최대 변화량, 최대치 도달 시간(h)\n", + " \"sewage\": ({\"turbidity\": 2.8, \"chlorine\": -0.5, \"ecoli\": 30, \"ph\": -0.3}, 4),\n", + " \"metal\": ({\"metal\": 22.0, \"ph\": -0.5}, 4),\n", + " \"disinfect_fail\": ({\"chlorine\": -0.8, \"ecoli\": 6}, 6),\n", + " \"chemical\": ({\"ph\": 1.7, \"turbidity\": 1.0}, 3),\n", + " \"creeping_metal\": ({\"metal\": 13.0}, 18),\n", + "}\n", + "FAULT_TYPES = [\"stuck\", \"spike\", \"drift\", \"dropout\"]\n", + "NOISE = {\"ph\": 0.04, \"turbidity\": 0.06, \"chlorine\": 0.03, \"metal\": 0.35}\n", + "DIGITS = {\"ph\": 2, \"turbidity\": 2, \"chlorine\": 2, \"metal\": 1}\n", + "\n", + "\n", + "def make_dataset(days=14, seed=7, n_events=6, n_faults=10, events=None, faults=None, sources=None):\n", + " \"\"\"events: [(급수원 번호, 사건 유형, 시작 시각 h, 지속 h, 강도)], faults: [(급수원 번호, 고장 유형, 항목, 시작 h, 지속 h)]\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " sources = sources or SOURCES\n", + " T = days * 24\n", + " tt = np.arange(T)\n", + " hour = tt % 24\n", + " night = (hour < 6) | (hour >= 20)\n", + " rain = np.zeros(T)\n", + " for _ in range(max(1, days // 4)):\n", + " s0, d = int(rng.integers(0, T - 14)), int(rng.integers(6, 14))\n", + " rain[s0:s0 + d] = np.maximum(rain[s0:s0 + d], rng.uniform(0.6, 1.0))\n", + " if events is None:\n", + " events = [(int(rng.integers(len(sources))), str(rng.choice(list(EVENT_TYPES))), int(rng.integers(min(24, T // 4), max(T // 4 + 1, T - 48))),\n", + " int(rng.integers(10, 30)), 1.0) for _ in range(n_events)]\n", + " if faults is None:\n", + " faults = [(int(rng.integers(len(sources))), str(rng.choice(FAULT_TYPES)), str(rng.choice([\"ph\", \"turbidity\", \"chlorine\", \"metal\"])),\n", + " int(rng.integers(12, max(13, T - 30))), int(rng.integers(6, 24))) for _ in range(n_faults)]\n", + " out = []\n", + " for si, (sid, stype, x, y) in enumerate(sources):\n", + " ph0, tb0, cl0, mt0 = BASE[stype]\n", + " tr = {\"ph\": ph0 + 0.08 * np.sin(2 * np.pi * hour / 24) + rng.normal(0, 0.03, T),\n", + " \"turbidity\": tb0 * (1 + 0.1 * rng.normal(0, 1, T)),\n", + " \"chlorine\": (cl0 * np.exp(-0.022 * hour) if stype == \"water_truck\" else cl0 + rng.normal(0, 0.03, T)),\n", + " \"metal\": mt0 + rng.normal(0, 0.2, T), \"ecoli\": np.zeros(T)}\n", + " if stype in (\"river_intake\", \"well\"):\n", + " tr[\"turbidity\"] = tr[\"turbidity\"] + rain * (0.9 if stype == \"river_intake\" else 0.45)\n", + " f_all, etype = np.zeros(T), np.array([\"none\"] * T, dtype=object)\n", + " for (s_i, et, onset, dur, scale) in events:\n", + " if s_i != si:\n", + " continue\n", + " delta, ramp = EVENT_TYPES[et]\n", + " f = np.clip((tt - onset + 1) / ramp, 0, 1) * ((tt >= onset) & (tt < onset + dur + ramp))\n", + " for k, v in delta.items():\n", + " tr[k] = tr[k] + v * f * scale\n", + " etype[f > 0] = et\n", + " f_all = np.maximum(f_all, f)\n", + " tr[\"chlorine\"] = np.clip(tr[\"chlorine\"], 0.02, None)\n", + " tr[\"turbidity\"] = np.clip(tr[\"turbidity\"], 0.05, None)\n", + " tr[\"metal\"] = np.clip(tr[\"metal\"], 0.1, None)\n", + " grade = np.where(f_all >= 0.2, 2, 0)\n", + " mild = (tr[\"turbidity\"] > LIMITS[\"turbidity\"][0]) | (tr[\"chlorine\"] < LIMITS[\"chlorine\"][0]) | (tr[\"metal\"] > LIMITS[\"metal\"][0])\n", + " grade = np.where((grade == 0) & mild, 1, grade)\n", + " ms = {}\n", + " for p in [\"ph\", \"turbidity\", \"chlorine\", \"metal\"]:\n", + " sd = NOISE[p] * np.where(night, 1.5, 1.0) * (tr[p] if p == \"turbidity\" else 1)\n", + " ms[p] = np.round(np.clip(tr[p] + rng.normal(0, 1, T) * sd, 0, None), DIGITS[p])\n", + " ms[\"ecoli\"] = (rng.poisson(tr[\"ecoli\"]) + (rng.random(T) < 0.004)).astype(float)\n", + " fault, fparam = np.array([\"none\"] * T, dtype=object), np.array([\"-\"] * T, dtype=object)\n", + " for (s_i, ft, p, s0, d) in faults:\n", + " if s_i != si:\n", + " continue\n", + " seg = slice(s0, min(T, s0 + d))\n", + " if ft == \"stuck\":\n", + " ms[p][seg] = ms[p][s0]\n", + " elif ft == \"spike\":\n", + " seg = slice(s0, s0 + 1)\n", + " ms[p][s0] = {\"ph\": 11.8, \"turbidity\": 38.0, \"chlorine\": 0.0, \"metal\": 75.0}[p]\n", + " elif ft == \"drift\":\n", + " ramp = np.linspace(0, 1, seg.stop - s0)\n", + " ms[p][seg] = np.clip(ms[p][seg] + ramp * {\"ph\": -1.6, \"turbidity\": 2.6, \"chlorine\": -0.75, \"metal\": 12.0}[p], 0, None).round(DIGITS[p])\n", + " elif ft == \"dropout\":\n", + " for q in PARAMS:\n", + " ms[q][seg] = np.nan\n", + " p = \"all\"\n", + " fault[seg], fparam[seg] = ft, p\n", + " df = pd.DataFrame({\"source_id\": sid, \"source_type\": stype, \"x_km\": x, \"y_km\": y, \"t\": tt,\n", + " \"cond\": np.where(rain > 0, \"storm\", np.where(night, \"night\", \"day\")),\n", + " **{p: ms[p] for p in PARAMS}, **{f\"true_{p}\": tr[p] for p in PARAMS},\n", + " \"event\": etype, \"fault\": fault, \"fault_param\": fparam, \"grade\": grade})\n", + " out.append(df)\n", + " return pd.concat(out, ignore_index=True)\n", + "# ---------------------------------------------------------------------------\n", + "\n", + "# ---- 전처리 공통 함수: 물리 범위 점검 -> 고정값(stuck) 표시 -> 단발 스파이크 제거 -> 결측 보정 ----\n", + "PHYS = {\"ph\": (0, 14), \"turbidity\": (0, 1000), \"chlorine\": (0, 10), \"metal\": (0, 1000), \"ecoli\": (0, 100000)}\n", + "SPIKE_K = {\"day\": 6.0, \"night\": 7.0, \"storm\": 9.0} # 상황별(주간/야간/악천후) 스파이크 판정 배수\n", + "MIN_JUMP = {\"ph\": 0.8, \"turbidity\": 1.5, \"chlorine\": 0.3, \"metal\": 6.0}\n", + "STUCK_RUN, SHORT_GAP = 6, 3\n", + "\n", + "\n", + "def clean(df, spike_k=None, min_jump=None):\n", + " \"\"\"급수원별 시계열을 정제하고 항목별 처리 내역(<항목>_flag: range/stuck/spike/interp/long_gap)을 남긴다.\"\"\"\n", + " spike_k = spike_k or SPIKE_K\n", + " min_jump = MIN_JUMP if min_jump is None else min_jump\n", + " out = df.sort_values([\"source_id\", \"t\"]).reset_index(drop=True)\n", + " for sid, idx in out.groupby(\"source_id\").groups.items():\n", + " idx = np.asarray(idx)\n", + " kk = out.loc[idx, \"cond\"].map(spike_k).to_numpy(float)\n", + " for p in PARAMS:\n", + " v = out.loc[idx, p].to_numpy(float).copy()\n", + " fl = np.array([\"\"] * len(v), dtype=object)\n", + " lo, hi = PHYS[p]\n", + " bad = (v < lo) | (v > hi)\n", + " v[bad], fl[bad] = np.nan, \"range\"\n", + " if p != \"ecoli\":\n", + " chg = np.r_[True, v[1:] != v[:-1]] # 같은 값이 STUCK_RUN시간 이상 반복 = 고정값 고장 의심\n", + " rid = np.cumsum(chg)\n", + " rlen = np.bincount(rid)[rid]\n", + " fl[(rlen >= STUCK_RUN) & ~chg & ~np.isnan(v)] = \"stuck\"\n", + " d = np.abs(np.diff(v[~np.isnan(v)]))\n", + " sig = 1.4826 * np.median(d) + 1e-6 if len(d) else 1.0\n", + " up, dn = v - np.r_[np.nan, v[:-1]], v - np.r_[v[1:], np.nan]\n", + " thr = np.maximum(kk * sig, min_jump[p])\n", + " spike = (np.abs(up) > thr) & (np.abs(dn) > thr) & (np.sign(up) == np.sign(dn)) # 튀었다가 바로 돌아온 값만\n", + " v[spike], fl[spike] = np.nan, \"spike\"\n", + " s = pd.Series(v)\n", + " na = s.isna().to_numpy()\n", + " grp = np.cumsum(np.r_[True, na[1:] != na[:-1]])\n", + " glen = pd.Series(na.astype(int)).groupby(grp).transform(\"sum\").to_numpy()\n", + " lin, ff = s.interpolate(limit_area=\"inside\").to_numpy(), s.ffill().bfill().to_numpy()\n", + " short, longg = na & (glen <= SHORT_GAP), na & (glen > SHORT_GAP)\n", + " v = np.where(short, np.where(np.isnan(lin), ff, lin), np.where(longg, ff, v))\n", + " fl[short & (fl == \"\")] = \"interp\"\n", + " fl[longg] = \"long_gap\"\n", + " out.loc[idx, p] = v\n", + " out.loc[idx, f\"{p}_flag\"] = fl\n", + " return out\n", + "\n", + "# ---- 가정 기준치 판정(Baseline)과 시계열 특징 공통 함수 ----\n", + "GRADE_NAME = {0: \"안전\", 1: \"주의\", 2: \"위험\"}\n", + "\n", + "\n", + "def rule_grade(df):\n", + " \"\"\"기준치 1차 필터: 위험(음용 부적합 의심) = 한 항목이라도 위험 기준 초과, 주의 = 주의 기준 초과.\"\"\"\n", + " ph, tb, cl, mt, ec = (df[p].to_numpy(float) for p in PARAMS)\n", + " danger = (ph < LIMITS[\"ph\"][0]) | (ph > LIMITS[\"ph\"][1]) | (tb > LIMITS[\"turbidity\"][1]) | (cl < LIMITS[\"chlorine\"][1]) \\\n", + " | (mt > LIMITS[\"metal\"][1]) | (ec >= LIMITS[\"ecoli\"][1])\n", + " caution = (tb > LIMITS[\"turbidity\"][0]) | (cl < LIMITS[\"chlorine\"][0]) | (mt > LIMITS[\"metal\"][0])\n", + " return np.where(danger, 2, np.where(caution, 1, 0))\n", + "\n", + "\n", + "def reasons(row):\n", + " r = []\n", + " if not LIMITS[\"ph\"][0] <= row[\"ph\"] <= LIMITS[\"ph\"][1]:\n", + " r.append(f\"pH {row['ph']:.2f}\")\n", + " if row[\"turbidity\"] > LIMITS[\"turbidity\"][0]:\n", + " r.append(f\"탁도 {row['turbidity']:.2f}NTU\")\n", + " if row[\"chlorine\"] < LIMITS[\"chlorine\"][0]:\n", + " r.append(f\"잔류염소 {row['chlorine']:.2f}mg/L\")\n", + " if row[\"metal\"] > LIMITS[\"metal\"][0]:\n", + " r.append(f\"중금속 {row['metal']:.1f}ug/L\")\n", + " if row[\"ecoli\"] >= 1:\n", + " r.append(f\"대장균 {row['ecoli']:.0f}\")\n", + " return \", \".join(r) or \"-\"\n", + "\n", + "\n", + "FEATS = [f\"{p}{s}\" for p in [\"ph\", \"turbidity\", \"chlorine\", \"metal\"] for s in [\"\", \"_d1\", \"_d3\", \"_dev\", \"_sd6\"]] + \\\n", + " [\"ecoli\", \"ecoli_3h\", \"storm\", \"night\", \"n_flag\", \"truck\", \"purif\", \"well\", \"river\"]\n", + "\n", + "\n", + "def add_features(c):\n", + " \"\"\"시간 순서를 지키는(과거 값만 쓰는) 급수원별 특징: 변화량, 24시간 기준선 대비 편차, 6시간 변동성, 대장균 누적 등.\"\"\"\n", + " c = c.sort_values([\"source_id\", \"t\"]).reset_index(drop=True)\n", + " g = c.groupby(\"source_id\")\n", + " for p in [\"ph\", \"turbidity\", \"chlorine\", \"metal\"]:\n", + " c[f\"{p}_d1\"] = g[p].diff().fillna(0)\n", + " c[f\"{p}_d3\"] = g[p].diff(3).fillna(0)\n", + " base = g[p].transform(lambda s: s.shift(1).rolling(24, min_periods=4).median())\n", + " c[f\"{p}_dev\"] = (c[p] - base).fillna(0)\n", + " c[f\"{p}_sd6\"] = g[p].transform(lambda s: s.rolling(6, min_periods=2).std()).fillna(0)\n", + " c[\"ecoli_3h\"] = g[\"ecoli\"].transform(lambda s: s.rolling(3, min_periods=1).sum())\n", + " c[\"storm\"], c[\"night\"] = (c[\"cond\"] == \"storm\").astype(int), (c[\"cond\"] == \"night\").astype(int)\n", + " flags = [f\"{p}_flag\" for p in PARAMS if f\"{p}_flag\" in c]\n", + " c[\"n_flag\"] = sum(c[f].isin([\"stuck\", \"spike\", \"long_gap\", \"range\"]).astype(int) for f in flags) if flags else 0\n", + " for k, name in [(\"truck\", \"water_truck\"), (\"purif\", \"purification_unit\"), (\"well\", \"well\"), (\"river\", \"river_intake\")]:\n", + " c[k] = (c[\"source_type\"] == name).astype(int)\n", + " return c\n", + "\n", + "BUDGET_MS = 300 # 1시간 주기 처리 예산(모의): 직전 주기가 예산을 넘으면 기능 축소 모드로 전환\n", + "FULL_EVERY = 12 # 기능 축소 중에도 12시간마다 전체 정제(지연 처리)를 수행\n", + "\n", + "\n", + "def make_sources(n, seed=0):\n", + " rng = np.random.default_rng(seed)\n", + " types = [s[1] for s in SOURCES]\n", + " return [(f\"W{i + 1:02d}\", types[i % len(types)], float(round(rng.uniform(0, 30), 1)), float(round(rng.uniform(0, 30), 1))) for i in range(n)]\n", + "\n", + "\n", + "def lite_check(batch, last):\n", + " \"\"\"기능 축소 모드: 레코드 단위 범위 점검 -> 직전 정상값 유지 -> 기준치 판정(정제·특징 계산 생략).\"\"\"\n", + " b = batch.copy()\n", + " for p in PARAMS:\n", + " v = pd.to_numeric(b[p], errors=\"coerce\")\n", + " lo, hi = PHYS[p]\n", + " v = v.where(v.between(lo, hi))\n", + " b[p] = v.fillna(b[\"source_id\"].map(last.get(p, {}))).fillna(0 if p == \"ecoli\" else np.nan)\n", + " last.setdefault(p, {}).update(b.dropna(subset=[p]).set_index(\"source_id\")[p].to_dict())\n", + " b = b.dropna(subset=[\"ph\", \"turbidity\", \"chlorine\", \"metal\"])\n", + " b[\"g\"] = rule_grade(b)\n", + " return b\n", + "\n", + "\n", + "def run(n_src, days, events, seed, mode_auto=True, hours=None):\n", + " src = make_sources(n_src)\n", + " data = make_dataset(days=days, seed=seed, events=events, faults=[], sources=src)\n", + " rng = np.random.default_rng(seed)\n", + " bad_idx = rng.choice(len(data), int(len(data) * 0.01), replace=False) # 1% 손상 레코드 주입(문자·None)\n", + " data[PARAMS] = data[PARAMS].astype(object)\n", + " data.loc[bad_idx[: len(bad_idx) // 2], \"turbidity\"] = \"ERR\"\n", + " data.loc[bad_idx[len(bad_idx) // 2:], \"ph\"] = None\n", + " stored, quarantine, alerts, ticks, down, last = 0, 0, {}, [], 0, {}\n", + " buf, mode, switched = [], \"full\", None\n", + " mem = []\n", + " for h in range(hours or days * 24):\n", + " t0 = time.perf_counter()\n", + " try:\n", + " batch = data[data[\"t\"] == h]\n", + " if mode_auto and mode == \"full\" and ticks and ticks[-1] > BUDGET_MS:\n", + " mode, switched = \"lite\", h # 한 번 전환하면 부하 해소 확인 전까지 유지\n", + " ok_mask = batch[[\"ph\", \"turbidity\", \"chlorine\", \"metal\"]].apply(pd.to_numeric, errors=\"coerce\").notna().all(axis=1)\n", + " quarantine += int((~ok_mask).sum())\n", + " if mode == \"full\":\n", + " buf.append(batch[ok_mask].astype({p: float for p in PARAMS}))\n", + " win = pd.concat(buf[-24:])\n", + " c = clean(win)\n", + " res = c[c[\"t\"] == h].assign(g=lambda d: rule_grade(d))\n", + " else:\n", + " res = lite_check(batch, last)\n", + " buf.append(batch[ok_mask].astype({p: float for p in PARAMS}))\n", + " if h % FULL_EVERY == FULL_EVERY - 1: # 지연 전체 정제(기록 보정용)\n", + " clean(pd.concat(buf[-FULL_EVERY:]))\n", + " buf = buf[-24:]\n", + " stored += len(batch)\n", + " for sid in res.loc[res[\"g\"] == 2, \"source_id\"]:\n", + " alerts.setdefault(sid, []).append(h)\n", + " except Exception:\n", + " down += 1\n", + " ticks.append((time.perf_counter() - t0) * 1000)\n", + " mem.append(sum(b.memory_usage(deep=True).sum() for b in buf) / 1e6) # 보관 버퍼 메모리(MB)\n", + " last = {p: last.get(p, {}) for p in PARAMS}\n", + " react = []\n", + " for e in events:\n", + " hs = [h for h in alerts.get(src[e[0]][0], []) if h >= e[2]]\n", + " if hs:\n", + " react.append(hs[0] - e[2])\n", + " return {\"src\": n_src, \"recs\": len(data), \"stored\": stored, \"quarantine\": quarantine, \"down\": down, \"mode\": mode if switched is None else f\"lite({switched}h~)\",\n", + " \"tick_avg\": np.mean(ticks), \"tick_max\": np.max(ticks), \"mem_first\": np.mean(mem[24:48]) if len(mem) >= 48 else mem[-1], \"mem_last\": np.mean(mem[-24:]),\n", + " \"react\": react, \"detected\": len(react), \"n_events\": len(events), \"ticks\": ticks}\n", + "\n", + "\n", + "days = 2\n", + "ev8 = [(i, et, 20 + i, 30, 1.0) for i, et in zip(range(5), [\"sewage\", \"metal\", \"chemical\", \"disinfect_fail\", \"sewage\"])]\n", + "ev48 = [(i, [\"sewage\", \"metal\", \"chemical\", \"disinfect_fail\"][i % 4], 20 + i % 6, 30, 1.0) for i in range(0, 48, 48 // 30 + 0)][:30]\n", + "t_all = time.perf_counter()\n", + "R = {\"평시(8곳)\": run(8, days, ev8, 1), \"부하 500%(48곳, 30곳 동시 오염)\": run(48, days, ev48, 2)}\n", + "print(f\"[교육용가상자료] 부하 시험 {days}일 모의, 전체 실행 {time.perf_counter() - t_all:.1f}초\")\n", + "rows = []\n", + "for k, r in R.items():\n", + " loss = 1 - r[\"stored\"] / r[\"recs\"]\n", + " rows.append({\"구분\": k, \"레코드\": r[\"recs\"], \"격리(손상)\": r[\"quarantine\"], \"유실률\": f\"{loss:.1%}\", \"중단\": r[\"down\"],\n", + " \"모드\": r[\"mode\"], \"주기 평균ms\": round(r[\"tick_avg\"], 1), \"주기 최대ms\": round(r[\"tick_max\"], 1),\n", + " \"메모리MB(초기->말기)\": f\"{r['mem_first']:.1f}->{r['mem_last']:.1f}\",\n", + " \"경보 탐지\": f\"{r['detected']}/{r['n_events']}\", \"반응 평균h\": round(float(np.mean(r[\"react\"])), 1)})\n", + "print(pd.DataFrame(rows).set_index(\"구분\").T.to_string())\n", + "S = R[\"부하 500%(48곳, 30곳 동시 오염)\"]\n", + "print(f\"\\n부하 500%: 시스템 중단 {S['down']}건(목표 0건: {'달성' if S['down'] == 0 else '미달'}), \"\n", + " f\"데이터 유실률 {1 - S['stored'] / S['recs']:.1%}(목표 0%: {'달성' if S['stored'] == S['recs'] else '미달'})\")\n", + "print(f\"메모리 말기/초기 비율 {S['mem_last'] / max(S['mem_first'], 1e-6):.2f} (버퍼 24시간 고정, 1.2 이하면 누수 없음), \"\n", + " f\"경보 반응 최대 {max(S['react'])}시간\")\n", + "\n", + "# 기능 축소를 끄고 전체 정제를 강행하면 주기 시간이 얼마나 늘어나는지 (6시간만 측정)\n", + "F = run(48, 1, [], 3, mode_auto=False, hours=6)\n", + "print(f\"기능 축소 미적용(전체 정제 강행) 주기 평균 {np.mean(F['ticks']):.0f}ms vs 기능 축소 {S['tick_avg']:.0f}ms \"\n", + " f\"(예산 {BUDGET_MS}ms) -> 병목: 급수원별 정제 반복(clean 함수)\")\n", + "print(\"Fail-safe 원칙: 손상 레코드는 버리지 않고 격리 보관, 판정은 직전 정상값으로 이어가며, 과부하 시 정제·특징 계산을 미루고 경보 판정은 유지\")\n", + "\n", + "plt.figure(figsize=(9, 3.5))\n", + "plt.plot(R[\"평시(8곳)\"][\"ticks\"], label=\"normal (8 sources)\")\n", + "plt.plot(S[\"ticks\"], label=\"500% load (48 sources, degraded mode)\")\n", + "plt.plot(F[\"ticks\"], label=\"500% load, full mode forced (6 h)\")\n", + "plt.axhline(BUDGET_MS, color=\"red\", ls=\"--\", lw=1, label=\"budget\")\n", + "plt.xlabel(\"simulated hour\"); plt.ylabel(\"processing ms per tick\"); plt.legend(); plt.title(\"Stress test tick time (synthetic)\")\n", + "plt.tight_layout(); plt.show()\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 08 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + "[교육용가상자료] 부하 시험 2일 모의, 전체 실행 11.9초\n", + "구분 평시(8곳) 부하 500%(48곳, 30곳 동시 오염)\n", + "레코드 384 2304\n", + "격리(손상) 3 23\n", + "유실률 0.0% 0.0%\n", + "중단 0 0\n", + "모드 full lite(1h~)\n", + "주기 평균ms 127.1 87.7\n", + "주기 최대ms 183.6 742.7\n", + "메모리MB(초기->말기) 0.1->0.1 0.6->0.6\n", + "경보 탐지 5/5 30/30\n", + "반응 평균h 0.8 0.9\n", + "\n", + "부하 500%: 시스템 중단 0건(목표 0건: 달성), 데이터 유실률 0.0%(목표 0%: 달성)\n", + "메모리 말기/초기 비율 1.00 (버퍼 24시간 고정, 1.2 이하면 누수 없음), 경보 반응 최대 2시간\n", + "기능 축소 미적용(전체 정제 강행) 주기 평균 808ms vs 기능 축소 88ms (예산 300ms) -> 병목: 급수원별 정제 반복(clean 함수)\n", + "Fail-safe 원칙: 손상 레코드는 버리지 않고 격리 보관, 판정은 직전 정상값으로 이어가며, 과부하 시 정제·특징 계산을 미루고 경보 판정은 유지\n", + "```\n", + "
\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 09. AAR 기반 수질 이상 오판 사례 분석 및 수질 위험 판단 임계치 재조정\n", + "**소요시간**: 약 3.0시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "AAR 기반 수질 이상 오판 사례 분석 및 판단 임계치를 재조정한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **AAR로 수질 이상 오판 사례를 분석하고 판단 임계치를 재조정해 효과를 별도 검증 세트로 확인한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **튜닝용 가상 자료에서 Baseline 오판을 오경보(센서 드리프트, 단발 대장균), 경계 혼동, 놓침(화학, 중금속 완만 증가) 등 원인별로 분류 수행하고 검증하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `docs/wel03_aar_report.md 및 configs/wel03_tuned_params.yaml` 형태의 결과물을 완성한다.\n", + "\n", + "### 🔍 평가 및 검증 기준 (PASS 조건)\n", + "#### 미션 판정\n", + "\n", + "**평가 구분:** 필수\n", + "\n", + "**핵심 평가영역:** 오류 원인 규명의 객관성 및 개선 조치의 논리적 타당성\n", + "\n", + "#### 미션 PASS 조건\n", + "\n", + "- 정량 기준: AAR 피드백 적용 후 종합 정확도 10%p 이상 향상, 재발 오류 0건\n", + "\n", + "\n", + "- 진행 기준: AAR 보고서 작성 및 파라미터 튜닝 전후 회귀 테스트 통과\n", + "\n", + "\n", + "- 검증 방법: 회귀 테스트(Regression Test) 스위트 전체 자동 실행 및 비교\n", + "\n", + "\n", + "#### 채점자 판정 메모\n", + "\n", + "> 단순 코드 실행 여부뿐만 아니라 출력된 수치와 근거 자료가 군 실무 요구사항에 부합하는지 면밀히 확인한다.\n", + "\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "\"\"\"AAR(사후 검토)로 기준치 Baseline의 수질 이상 오판 사례를 원인별로 분류하고, 단발 대장균 검출·완만한 중금속 증가에 대한 판단 임계치를 튜닝 세트에서 재조정한 뒤 검증 세트·벤치마크 회귀 테스트로 전후를 비교하고 조정 파라미터를 YAML로 출력한다.\"\"\"\n", + "import itertools\n", + "import numpy as np\n", + "import pandas as pd\n", + "import matplotlib.pyplot as plt\n", + "\n", + "# ---------------------------------------------------------------------------\n", + "# [교육용가상자료] 야전 급수원 가상 수질 센서 데이터 생성기 (실제 부대·위치·검사 결과 없음)\n", + "# 가상 급수원 8곳 x 14일 x 1시간 간격. 실제 수질(true_*)·센서 측정값·오염 사건·센서 고장 정답을 함께 만든다.\n", + "# 기준치는 교육용 가정값이며, 실제 음용 판정은 공인 검사 결과와 의무(예방의학) 담당 기준에 따른다.\n", + "# ---------------------------------------------------------------------------\n", + "PARAMS = [\"ph\", \"turbidity\", \"chlorine\", \"metal\", \"ecoli\"]\n", + "SOURCES = [(\"W01\", \"water_truck\", 1.0, 2.0), (\"W02\", \"water_truck\", 3.5, 1.2), (\"W03\", \"purification_unit\", 2.2, 4.1),\n", + " (\"W04\", \"purification_unit\", 5.0, 3.3), (\"W05\", \"well\", 0.8, 5.6), (\"W06\", \"well\", 4.4, 6.0),\n", + " (\"W07\", \"river_intake\", 6.3, 1.8), (\"W08\", \"river_intake\", 6.9, 4.9)]\n", + "BASE = {\"water_truck\": (7.2, 0.25, 0.75, 1.5), \"purification_unit\": (7.0, 0.20, 0.85, 1.2),\n", + " \"well\": (6.8, 0.45, 0.50, 3.0), \"river_intake\": (7.3, 0.55, 0.60, 2.0)} # pH, 탁도(NTU), 잔류염소(mg/L), 중금속(ug/L)\n", + "LIMITS = {\"ph\": (5.8, 8.5), \"turbidity\": (1.0, 2.0), \"chlorine\": (0.2, 0.1), \"metal\": (5.0, 10.0), \"ecoli\": (1, 1)} # (주의, 위험) 가정 기준\n", + "EVENT_TYPES = { # 오염 사건: 변수별 최대 변화량, 최대치 도달 시간(h)\n", + " \"sewage\": ({\"turbidity\": 2.8, \"chlorine\": -0.5, \"ecoli\": 30, \"ph\": -0.3}, 4),\n", + " \"metal\": ({\"metal\": 22.0, \"ph\": -0.5}, 4),\n", + " \"disinfect_fail\": ({\"chlorine\": -0.8, \"ecoli\": 6}, 6),\n", + " \"chemical\": ({\"ph\": 1.7, \"turbidity\": 1.0}, 3),\n", + " \"creeping_metal\": ({\"metal\": 13.0}, 18),\n", + "}\n", + "FAULT_TYPES = [\"stuck\", \"spike\", \"drift\", \"dropout\"]\n", + "NOISE = {\"ph\": 0.04, \"turbidity\": 0.06, \"chlorine\": 0.03, \"metal\": 0.35}\n", + "DIGITS = {\"ph\": 2, \"turbidity\": 2, \"chlorine\": 2, \"metal\": 1}\n", + "\n", + "\n", + "def make_dataset(days=14, seed=7, n_events=6, n_faults=10, events=None, faults=None, sources=None):\n", + " \"\"\"events: [(급수원 번호, 사건 유형, 시작 시각 h, 지속 h, 강도)], faults: [(급수원 번호, 고장 유형, 항목, 시작 h, 지속 h)]\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " sources = sources or SOURCES\n", + " T = days * 24\n", + " tt = np.arange(T)\n", + " hour = tt % 24\n", + " night = (hour < 6) | (hour >= 20)\n", + " rain = np.zeros(T)\n", + " for _ in range(max(1, days // 4)):\n", + " s0, d = int(rng.integers(0, T - 14)), int(rng.integers(6, 14))\n", + " rain[s0:s0 + d] = np.maximum(rain[s0:s0 + d], rng.uniform(0.6, 1.0))\n", + " if events is None:\n", + " events = [(int(rng.integers(len(sources))), str(rng.choice(list(EVENT_TYPES))), int(rng.integers(min(24, T // 4), max(T // 4 + 1, T - 48))),\n", + " int(rng.integers(10, 30)), 1.0) for _ in range(n_events)]\n", + " if faults is None:\n", + " faults = [(int(rng.integers(len(sources))), str(rng.choice(FAULT_TYPES)), str(rng.choice([\"ph\", \"turbidity\", \"chlorine\", \"metal\"])),\n", + " int(rng.integers(12, max(13, T - 30))), int(rng.integers(6, 24))) for _ in range(n_faults)]\n", + " out = []\n", + " for si, (sid, stype, x, y) in enumerate(sources):\n", + " ph0, tb0, cl0, mt0 = BASE[stype]\n", + " tr = {\"ph\": ph0 + 0.08 * np.sin(2 * np.pi * hour / 24) + rng.normal(0, 0.03, T),\n", + " \"turbidity\": tb0 * (1 + 0.1 * rng.normal(0, 1, T)),\n", + " \"chlorine\": (cl0 * np.exp(-0.022 * hour) if stype == \"water_truck\" else cl0 + rng.normal(0, 0.03, T)),\n", + " \"metal\": mt0 + rng.normal(0, 0.2, T), \"ecoli\": np.zeros(T)}\n", + " if stype in (\"river_intake\", \"well\"):\n", + " tr[\"turbidity\"] = tr[\"turbidity\"] + rain * (0.9 if stype == \"river_intake\" else 0.45)\n", + " f_all, etype = np.zeros(T), np.array([\"none\"] * T, dtype=object)\n", + " for (s_i, et, onset, dur, scale) in events:\n", + " if s_i != si:\n", + " continue\n", + " delta, ramp = EVENT_TYPES[et]\n", + " f = np.clip((tt - onset + 1) / ramp, 0, 1) * ((tt >= onset) & (tt < onset + dur + ramp))\n", + " for k, v in delta.items():\n", + " tr[k] = tr[k] + v * f * scale\n", + " etype[f > 0] = et\n", + " f_all = np.maximum(f_all, f)\n", + " tr[\"chlorine\"] = np.clip(tr[\"chlorine\"], 0.02, None)\n", + " tr[\"turbidity\"] = np.clip(tr[\"turbidity\"], 0.05, None)\n", + " tr[\"metal\"] = np.clip(tr[\"metal\"], 0.1, None)\n", + " grade = np.where(f_all >= 0.2, 2, 0)\n", + " mild = (tr[\"turbidity\"] > LIMITS[\"turbidity\"][0]) | (tr[\"chlorine\"] < LIMITS[\"chlorine\"][0]) | (tr[\"metal\"] > LIMITS[\"metal\"][0])\n", + " grade = np.where((grade == 0) & mild, 1, grade)\n", + " ms = {}\n", + " for p in [\"ph\", \"turbidity\", \"chlorine\", \"metal\"]:\n", + " sd = NOISE[p] * np.where(night, 1.5, 1.0) * (tr[p] if p == \"turbidity\" else 1)\n", + " ms[p] = np.round(np.clip(tr[p] + rng.normal(0, 1, T) * sd, 0, None), DIGITS[p])\n", + " ms[\"ecoli\"] = (rng.poisson(tr[\"ecoli\"]) + (rng.random(T) < 0.004)).astype(float)\n", + " fault, fparam = np.array([\"none\"] * T, dtype=object), np.array([\"-\"] * T, dtype=object)\n", + " for (s_i, ft, p, s0, d) in faults:\n", + " if s_i != si:\n", + " continue\n", + " seg = slice(s0, min(T, s0 + d))\n", + " if ft == \"stuck\":\n", + " ms[p][seg] = ms[p][s0]\n", + " elif ft == \"spike\":\n", + " seg = slice(s0, s0 + 1)\n", + " ms[p][s0] = {\"ph\": 11.8, \"turbidity\": 38.0, \"chlorine\": 0.0, \"metal\": 75.0}[p]\n", + " elif ft == \"drift\":\n", + " ramp = np.linspace(0, 1, seg.stop - s0)\n", + " ms[p][seg] = np.clip(ms[p][seg] + ramp * {\"ph\": -1.6, \"turbidity\": 2.6, \"chlorine\": -0.75, \"metal\": 12.0}[p], 0, None).round(DIGITS[p])\n", + " elif ft == \"dropout\":\n", + " for q in PARAMS:\n", + " ms[q][seg] = np.nan\n", + " p = \"all\"\n", + " fault[seg], fparam[seg] = ft, p\n", + " df = pd.DataFrame({\"source_id\": sid, \"source_type\": stype, \"x_km\": x, \"y_km\": y, \"t\": tt,\n", + " \"cond\": np.where(rain > 0, \"storm\", np.where(night, \"night\", \"day\")),\n", + " **{p: ms[p] for p in PARAMS}, **{f\"true_{p}\": tr[p] for p in PARAMS},\n", + " \"event\": etype, \"fault\": fault, \"fault_param\": fparam, \"grade\": grade})\n", + " out.append(df)\n", + " return pd.concat(out, ignore_index=True)\n", + "# ---------------------------------------------------------------------------\n", + "\n", + "# ---- 전처리 공통 함수: 물리 범위 점검 -> 고정값(stuck) 표시 -> 단발 스파이크 제거 -> 결측 보정 ----\n", + "PHYS = {\"ph\": (0, 14), \"turbidity\": (0, 1000), \"chlorine\": (0, 10), \"metal\": (0, 1000), \"ecoli\": (0, 100000)}\n", + "SPIKE_K = {\"day\": 6.0, \"night\": 7.0, \"storm\": 9.0} # 상황별(주간/야간/악천후) 스파이크 판정 배수\n", + "MIN_JUMP = {\"ph\": 0.8, \"turbidity\": 1.5, \"chlorine\": 0.3, \"metal\": 6.0}\n", + "STUCK_RUN, SHORT_GAP = 6, 3\n", + "\n", + "\n", + "def clean(df, spike_k=None, min_jump=None):\n", + " \"\"\"급수원별 시계열을 정제하고 항목별 처리 내역(<항목>_flag: range/stuck/spike/interp/long_gap)을 남긴다.\"\"\"\n", + " spike_k = spike_k or SPIKE_K\n", + " min_jump = MIN_JUMP if min_jump is None else min_jump\n", + " out = df.sort_values([\"source_id\", \"t\"]).reset_index(drop=True)\n", + " for sid, idx in out.groupby(\"source_id\").groups.items():\n", + " idx = np.asarray(idx)\n", + " kk = out.loc[idx, \"cond\"].map(spike_k).to_numpy(float)\n", + " for p in PARAMS:\n", + " v = out.loc[idx, p].to_numpy(float).copy()\n", + " fl = np.array([\"\"] * len(v), dtype=object)\n", + " lo, hi = PHYS[p]\n", + " bad = (v < lo) | (v > hi)\n", + " v[bad], fl[bad] = np.nan, \"range\"\n", + " if p != \"ecoli\":\n", + " chg = np.r_[True, v[1:] != v[:-1]] # 같은 값이 STUCK_RUN시간 이상 반복 = 고정값 고장 의심\n", + " rid = np.cumsum(chg)\n", + " rlen = np.bincount(rid)[rid]\n", + " fl[(rlen >= STUCK_RUN) & ~chg & ~np.isnan(v)] = \"stuck\"\n", + " d = np.abs(np.diff(v[~np.isnan(v)]))\n", + " sig = 1.4826 * np.median(d) + 1e-6 if len(d) else 1.0\n", + " up, dn = v - np.r_[np.nan, v[:-1]], v - np.r_[v[1:], np.nan]\n", + " thr = np.maximum(kk * sig, min_jump[p])\n", + " spike = (np.abs(up) > thr) & (np.abs(dn) > thr) & (np.sign(up) == np.sign(dn)) # 튀었다가 바로 돌아온 값만\n", + " v[spike], fl[spike] = np.nan, \"spike\"\n", + " s = pd.Series(v)\n", + " na = s.isna().to_numpy()\n", + " grp = np.cumsum(np.r_[True, na[1:] != na[:-1]])\n", + " glen = pd.Series(na.astype(int)).groupby(grp).transform(\"sum\").to_numpy()\n", + " lin, ff = s.interpolate(limit_area=\"inside\").to_numpy(), s.ffill().bfill().to_numpy()\n", + " short, longg = na & (glen <= SHORT_GAP), na & (glen > SHORT_GAP)\n", + " v = np.where(short, np.where(np.isnan(lin), ff, lin), np.where(longg, ff, v))\n", + " fl[short & (fl == \"\")] = \"interp\"\n", + " fl[longg] = \"long_gap\"\n", + " out.loc[idx, p] = v\n", + " out.loc[idx, f\"{p}_flag\"] = fl\n", + " return out\n", + "\n", + "# ---- 가정 기준치 판정(Baseline)과 시계열 특징 공통 함수 ----\n", + "GRADE_NAME = {0: \"안전\", 1: \"주의\", 2: \"위험\"}\n", + "\n", + "\n", + "def rule_grade(df):\n", + " \"\"\"기준치 1차 필터: 위험(음용 부적합 의심) = 한 항목이라도 위험 기준 초과, 주의 = 주의 기준 초과.\"\"\"\n", + " ph, tb, cl, mt, ec = (df[p].to_numpy(float) for p in PARAMS)\n", + " danger = (ph < LIMITS[\"ph\"][0]) | (ph > LIMITS[\"ph\"][1]) | (tb > LIMITS[\"turbidity\"][1]) | (cl < LIMITS[\"chlorine\"][1]) \\\n", + " | (mt > LIMITS[\"metal\"][1]) | (ec >= LIMITS[\"ecoli\"][1])\n", + " caution = (tb > LIMITS[\"turbidity\"][0]) | (cl < LIMITS[\"chlorine\"][0]) | (mt > LIMITS[\"metal\"][0])\n", + " return np.where(danger, 2, np.where(caution, 1, 0))\n", + "\n", + "\n", + "def reasons(row):\n", + " r = []\n", + " if not LIMITS[\"ph\"][0] <= row[\"ph\"] <= LIMITS[\"ph\"][1]:\n", + " r.append(f\"pH {row['ph']:.2f}\")\n", + " if row[\"turbidity\"] > LIMITS[\"turbidity\"][0]:\n", + " r.append(f\"탁도 {row['turbidity']:.2f}NTU\")\n", + " if row[\"chlorine\"] < LIMITS[\"chlorine\"][0]:\n", + " r.append(f\"잔류염소 {row['chlorine']:.2f}mg/L\")\n", + " if row[\"metal\"] > LIMITS[\"metal\"][0]:\n", + " r.append(f\"중금속 {row['metal']:.1f}ug/L\")\n", + " if row[\"ecoli\"] >= 1:\n", + " r.append(f\"대장균 {row['ecoli']:.0f}\")\n", + " return \", \".join(r) or \"-\"\n", + "\n", + "\n", + "FEATS = [f\"{p}{s}\" for p in [\"ph\", \"turbidity\", \"chlorine\", \"metal\"] for s in [\"\", \"_d1\", \"_d3\", \"_dev\", \"_sd6\"]] + \\\n", + " [\"ecoli\", \"ecoli_3h\", \"storm\", \"night\", \"n_flag\", \"truck\", \"purif\", \"well\", \"river\"]\n", + "\n", + "\n", + "def add_features(c):\n", + " \"\"\"시간 순서를 지키는(과거 값만 쓰는) 급수원별 특징: 변화량, 24시간 기준선 대비 편차, 6시간 변동성, 대장균 누적 등.\"\"\"\n", + " c = c.sort_values([\"source_id\", \"t\"]).reset_index(drop=True)\n", + " g = c.groupby(\"source_id\")\n", + " for p in [\"ph\", \"turbidity\", \"chlorine\", \"metal\"]:\n", + " c[f\"{p}_d1\"] = g[p].diff().fillna(0)\n", + " c[f\"{p}_d3\"] = g[p].diff(3).fillna(0)\n", + " base = g[p].transform(lambda s: s.shift(1).rolling(24, min_periods=4).median())\n", + " c[f\"{p}_dev\"] = (c[p] - base).fillna(0)\n", + " c[f\"{p}_sd6\"] = g[p].transform(lambda s: s.rolling(6, min_periods=2).std()).fillna(0)\n", + " c[\"ecoli_3h\"] = g[\"ecoli\"].transform(lambda s: s.rolling(3, min_periods=1).sum())\n", + " c[\"storm\"], c[\"night\"] = (c[\"cond\"] == \"storm\").astype(int), (c[\"cond\"] == \"night\").astype(int)\n", + " flags = [f\"{p}_flag\" for p in PARAMS if f\"{p}_flag\" in c]\n", + " c[\"n_flag\"] = sum(c[f].isin([\"stuck\", \"spike\", \"long_gap\", \"range\"]).astype(int) for f in flags) if flags else 0\n", + " for k, name in [(\"truck\", \"water_truck\"), (\"purif\", \"purification_unit\"), (\"well\", \"well\"), (\"river\", \"river_intake\")]:\n", + " c[k] = (c[\"source_type\"] == name).astype(int)\n", + " return c\n", + "\n", + "BEFORE = {\"ecoli_3h_min\": 1, \"metal_dev\": 99.0, \"metal_min\": 99.0} # 현행: 대장균 1회 검출 즉시 위험, 추세 규칙 없음\n", + "\n", + "\n", + "def tuned_grade(c, P):\n", + " \"\"\"재조정 규칙: 대장균은 3시간 누적 기준(또는 탁도·염소 이상 동반 시 즉시), 중금속은 기준선 대비 상승 추세 규칙 추가.\"\"\"\n", + " ph, tb, cl, mt, ec = (c[p].to_numpy(float) for p in PARAMS)\n", + " if P[\"ecoli_3h_min\"] <= 1: # 현행 규칙: 그 시각 1회 검출이면 위험\n", + " eco = ec >= 1\n", + " else:\n", + " eco = (c[\"ecoli_3h\"].to_numpy() >= P[\"ecoli_3h_min\"]) | ((ec >= 1) & ((tb > LIMITS[\"turbidity\"][0]) | (cl < LIMITS[\"chlorine\"][0])))\n", + " trend = (c[\"metal_dev\"].to_numpy() > P[\"metal_dev\"]) & (mt > P[\"metal_min\"])\n", + " danger = (ph < LIMITS[\"ph\"][0]) | (ph > LIMITS[\"ph\"][1]) | (tb > LIMITS[\"turbidity\"][1]) | (cl < LIMITS[\"chlorine\"][1]) \\\n", + " | (mt > LIMITS[\"metal\"][1]) | eco | trend\n", + " caution = (tb > LIMITS[\"turbidity\"][0]) | (cl < LIMITS[\"chlorine\"][0]) | (mt > LIMITS[\"metal\"][0]) | (ec >= 1)\n", + " return np.where(danger, 2, np.where(caution, 1, 0))\n", + "\n", + "\n", + "def cause(r, pred):\n", + " if r[\"grade\"] == 2 and pred != 2:\n", + " return {\"creeping_metal\": \"놓침-중금속 완만 증가\", \"chemical\": \"놓침-화학(초기 pH 변화)\"}.get(r[\"event\"], f\"놓침-{r['event']}\")\n", + " if r[\"grade\"] != 2 and pred == 2:\n", + " if r[\"fault\"] != \"none\":\n", + " return f\"오경보-센서 {r['fault']}\"\n", + " return \"오경보-단발 대장균\" if r[\"ecoli\"] >= 1 else \"오경보-기타\"\n", + " return \"주의/안전 경계 혼동\"\n", + "\n", + "\n", + "D = {name: add_features(clean(make_dataset(seed=s, n_events=10, n_faults=14))) for name, s in\n", + " [(\"튜닝(seed31)\", 31), (\"검증(seed41)\", 41), (\"벤치마크(seed11)\", 11)]}\n", + "\n", + "# 1) AAR: 튜닝 세트의 오판 원인 분류\n", + "tune = D[\"튜닝(seed31)\"]\n", + "p0 = tuned_grade(tune, BEFORE)\n", + "err = tune[p0 != tune[\"grade\"]]\n", + "causes = pd.Series([cause(r, p) for (_, r), p in zip(err.iterrows(), p0[p0 != tune[\"grade\"]])]).value_counts()\n", + "print(f\"[교육용가상자료] AAR 대상 오판 {len(err)}건(튜닝 세트 {len(tune):,}건) 원인 분류\")\n", + "print(causes.to_string())\n", + "\n", + "# 2) 재조정: 고칠 수 있는 원인(단발 대장균, 중금속 완만 증가)에 대한 임계치 격자 탐색\n", + "# AAR 결정: 대장균 단발 1회 검출은 '주의 + 재채수'로 낮추고(3시간 누적 2회 이상부터 위험), 그 기준값만 탐색한다\n", + "best = None\n", + "for e3, md, mm in itertools.product([2, 3], [1.0, 1.5, 2.0, 3.0], [4.0, 5.0, 6.0]):\n", + " P = {\"ecoli_3h_min\": e3, \"metal_dev\": md, \"metal_min\": mm}\n", + " acc = (tuned_grade(tune, P) == tune[\"grade\"]).mean()\n", + " if best is None or acc > best[0]:\n", + " best = (acc, P)\n", + "AFTER = best[1]\n", + "print(\"\\n재조정 파라미터:\", AFTER)\n", + "\n", + "# 3) 회귀 테스트: 세 데이터셋에서 전후 비교\n", + "rows = []\n", + "for name, c in D.items():\n", + " b, a = tuned_grade(c, BEFORE), tuned_grade(c, AFTER)\n", + " d = c[\"grade\"] == 2\n", + " rows.append({\"데이터\": name, \"정확도 전\": f\"{(b == c['grade']).mean():.1%}\", \"정확도 후\": f\"{(a == c['grade']).mean():.1%}\",\n", + " \"향상(%p)\": round(((a == c[\"grade\"]).mean() - (b == c[\"grade\"]).mean()) * 100, 1),\n", + " \"오판 전→후\": f\"{int((b != c['grade']).sum())}→{int((a != c['grade']).sum())}\",\n", + " \"새로 틀림\": int(((b == c[\"grade\"]) & (a != c[\"grade\"])).sum()),\n", + " \"위험 재현율 후\": f\"{((a == 2) & d).sum() / d.sum():.1%}\"})\n", + "reg = pd.DataFrame(rows)\n", + "print(\"\\n[회귀 테스트]\")\n", + "print(reg.to_string(index=False))\n", + "gain = reg.loc[1, \"향상(%p)\"]\n", + "print(f\"검증 세트 종합 정확도 향상 {gain}%p (목표 10%p 이상: {'달성' if gain >= 10 else '미달'} - Baseline 정확도가 이미 97% 수준이라 10%p 여지가 없음)\")\n", + "\n", + "# 4) 재발 오류: 고친 원인 유형이 검증 세트에서 다시 나오는가\n", + "val = D[\"검증(seed41)\"]\n", + "pa = tuned_grade(val, AFTER)\n", + "ev = val[pa != val[\"grade\"]]\n", + "vc = pd.Series([cause(r, p) for (_, r), p in zip(ev.iterrows(), pa[pa != val[\"grade\"]])]).value_counts()\n", + "fixed_types = [\"오경보-단발 대장균\", \"놓침-중금속 완만 증가\"]\n", + "rec = {k: int(vc.get(k, 0)) for k in fixed_types}\n", + "print(f\"\\n재발 오류(고친 유형의 검증 세트 재발): {rec} -> 합계 {sum(rec.values())}건(목표 0건: {'달성' if sum(rec.values()) == 0 else '미달'})\")\n", + "print(\"남은 원인 상위:\", vc.head(3).to_dict(), \"-> 센서 고장(드리프트·통신 단절) 오경보는 교차 센서·교정 주기와 미션 5 신뢰도 지수로, 화학 초기 변화는 pH 변화율 규칙 후보로 다음 AAR에 넘긴다\")\n", + "\n", + "print(\"\\n# configs/wel03_tuned_params.yaml\")\n", + "print(f\"ecoli:\\n danger_3h_count: {AFTER['ecoli_3h_min']} # 단발 1회는 '주의 + 재채수'\\nmetal_trend:\\n\"\n", + " f\" dev_from_24h_baseline_ugL: {AFTER['metal_dev']}\\n min_level_ugL: {AFTER['metal_min']}\\nreviewed_by: 예방의학 담당(가상) # 실제 기준은 공식 수질 기준 확인\")\n", + "\n", + "fig, ax = plt.subplots(figsize=(8, 3.5))\n", + "x = np.arange(len(reg))\n", + "ax.bar(x - 0.2, [(tuned_grade(c, BEFORE) != c[\"grade\"]).sum() for c in D.values()], 0.4, label=\"before\", color=\"tab:gray\")\n", + "ax.bar(x + 0.2, [(tuned_grade(c, AFTER) != c[\"grade\"]).sum() for c in D.values()], 0.4, label=\"after\", color=\"tab:blue\")\n", + "ax.set_xticks(x, [\"tune(31)\", \"valid(41)\", \"bench(11)\"]); ax.set_ylabel(\"misjudged hours\"); ax.legend()\n", + "ax.set_title(\"AAR retuning: misjudgments before/after (synthetic)\")\n", + "plt.tight_layout(); plt.show()\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 09 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + "[교육용가상자료] AAR 대상 오판 110건(튜닝 세트 2,688건) 원인 분류\n", + "오경보-센서 drift 38\n", + "주의/안전 경계 혼동 28\n", + "놓침-화학(초기 pH 변화) 20\n", + "놓침-중금속 완만 증가 16\n", + "오경보-단발 대장균 7\n", + "놓침-metal 1\n", + "\n", + "재조정 파라미터: {'ecoli_3h_min': 2, 'metal_dev': 1.0, 'metal_min': 5.0}\n", + "\n", + "[회귀 테스트]\n", + " 데이터 정확도 전 정확도 후 향상(%p) 오판 전→후 새로 틀림 위험 재현율 후\n", + " 튜닝(seed31) 95.9% 96.2% 0.3 110→102 8 91.8%\n", + " 검증(seed41) 97.1% 97.2% 0.1 78→75 6 92.2%\n", + "벤치마크(seed11) 97.9% 98.4% 0.5 56→43 6 96.3%\n", + "검증 세트 종합 정확도 향상 0.1%p (목표 10%p 이상: 미달 - Baseline 정확도가 이미 97% 수준이라 10%p 여지가 없음)\n", + "\n", + "재발 오류(고친 유형의 검증 세트 재발): {'오경보-단발 대장균': 0, '놓침-중금속 완만 증가': 1} -> 합계 1건(목표 0건: 미달)\n", + "남은 원인 상위: {'주의/안전 경계 혼동': 21, '오경보-센서 dropout': 18, '놓침-화학(초기 pH 변화)': 15} -> 센서 고장(드리프트·통신 단절) 오경보는 교차 센서·교정 주기와 미션 5 신뢰도 지수로, 화학 초기 변화는 pH 변화율 규칙 후보로 다음 AAR에 넘긴다\n", + "\n", + "# configs/wel03_tuned_params.yaml\n", + "ecoli:\n", + " danger_3h_count: 2 # 단발 1회는 '주의 + 재채수'\n", + "metal_trend:\n", + " dev_from_24h_baseline_ugL: 1.0\n", + " min_level_ugL: 5.0\n", + "reviewed_by: 예방의학 담당(가상) # 실제 기준은 공식 수질 기준 확인\n", + "```\n", + "
\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 10. 가상 수질검사 데이터 위험 신호 대시보드 시스템 통합 패키지 완성\n", + "**소요시간**: 약 3.5시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "가상 수질검사 데이터 위험 신호 대시보드 시스템 통합 패키지를 완성한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **가상 수질검사 데이터 위험 신호 대시보드를 하나의 통합 패키지로 완성한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **입력 검증, 전처리, 재조정 기준치 판정, 랜덤 포레스트 위험 분류, 신뢰도 등급, 사람 검토 큐 등록, 상황판 요약을 한 파일의 파이프라인으로 묶는다 과정을 진행하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `src/main_wel03.py, README.md, docs/wel03_final_report.pdf(md)` 형태의 결과물을 완성한다.\n", + "\n", + "### 🔍 평가 및 검증 기준 (PASS 조건)\n", + "#### 미션 판정\n", + "\n", + "**평가 구분:** 필수\n", + "\n", + "**핵심 평가영역:** 군 실무 즉시 도입 가능성, 운용 신뢰도 및 전략적 기대효과\n", + "\n", + "#### 미션 PASS 조건\n", + "\n", + "- 정량 기준: End-to-End 통합 파이프라인 실행 성공률 100%, 테스트 커버리지 85% 이상\n", + "\n", + "\n", + "- 진행 기준: 원클릭 실행 스크립트 동작 확인 및 최종 발표 산출물 작성 완료\n", + "\n", + "\n", + "- 검증 방법: 최종 검증 위원회(평가관) 모의 시연 및 전체 기능 통합 검증\n", + "\n", + "\n", + "#### 채점자 판정 메모\n", + "\n", + "> 단순 코드 실행 여부뿐만 아니라 출력된 수치와 근거 자료가 군 실무 요구사항에 부합하는지 면밀히 확인한다.\n", + "\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "\"\"\"입력 검증, 전처리, 재조정 기준치 판정과 랜덤 포레스트 위험 분류, 신뢰도 등급, 인간 검토 큐 등록, 상황판 요약까지 가상 수질검사 위험 신호 대시보드를 한 파일로 통합 실행하고, 여러 시나리오 End-to-End 실행·자체 점검(함수 커버리지)·지휘관 브리핑 요약을 출력한다.\"\"\"\n", + "import sys\n", + "import time\n", + "import numpy as np\n", + "import pandas as pd\n", + "import matplotlib.pyplot as plt\n", + "from sklearn.ensemble import RandomForestClassifier\n", + "\n", + "# ---------------------------------------------------------------------------\n", + "# [교육용가상자료] 야전 급수원 가상 수질 센서 데이터 생성기 (실제 부대·위치·검사 결과 없음)\n", + "# 가상 급수원 8곳 x 14일 x 1시간 간격. 실제 수질(true_*)·센서 측정값·오염 사건·센서 고장 정답을 함께 만든다.\n", + "# 기준치는 교육용 가정값이며, 실제 음용 판정은 공인 검사 결과와 의무(예방의학) 담당 기준에 따른다.\n", + "# ---------------------------------------------------------------------------\n", + "PARAMS = [\"ph\", \"turbidity\", \"chlorine\", \"metal\", \"ecoli\"]\n", + "SOURCES = [(\"W01\", \"water_truck\", 1.0, 2.0), (\"W02\", \"water_truck\", 3.5, 1.2), (\"W03\", \"purification_unit\", 2.2, 4.1),\n", + " (\"W04\", \"purification_unit\", 5.0, 3.3), (\"W05\", \"well\", 0.8, 5.6), (\"W06\", \"well\", 4.4, 6.0),\n", + " (\"W07\", \"river_intake\", 6.3, 1.8), (\"W08\", \"river_intake\", 6.9, 4.9)]\n", + "BASE = {\"water_truck\": (7.2, 0.25, 0.75, 1.5), \"purification_unit\": (7.0, 0.20, 0.85, 1.2),\n", + " \"well\": (6.8, 0.45, 0.50, 3.0), \"river_intake\": (7.3, 0.55, 0.60, 2.0)} # pH, 탁도(NTU), 잔류염소(mg/L), 중금속(ug/L)\n", + "LIMITS = {\"ph\": (5.8, 8.5), \"turbidity\": (1.0, 2.0), \"chlorine\": (0.2, 0.1), \"metal\": (5.0, 10.0), \"ecoli\": (1, 1)} # (주의, 위험) 가정 기준\n", + "EVENT_TYPES = { # 오염 사건: 변수별 최대 변화량, 최대치 도달 시간(h)\n", + " \"sewage\": ({\"turbidity\": 2.8, \"chlorine\": -0.5, \"ecoli\": 30, \"ph\": -0.3}, 4),\n", + " \"metal\": ({\"metal\": 22.0, \"ph\": -0.5}, 4),\n", + " \"disinfect_fail\": ({\"chlorine\": -0.8, \"ecoli\": 6}, 6),\n", + " \"chemical\": ({\"ph\": 1.7, \"turbidity\": 1.0}, 3),\n", + " \"creeping_metal\": ({\"metal\": 13.0}, 18),\n", + "}\n", + "FAULT_TYPES = [\"stuck\", \"spike\", \"drift\", \"dropout\"]\n", + "NOISE = {\"ph\": 0.04, \"turbidity\": 0.06, \"chlorine\": 0.03, \"metal\": 0.35}\n", + "DIGITS = {\"ph\": 2, \"turbidity\": 2, \"chlorine\": 2, \"metal\": 1}\n", + "\n", + "\n", + "def make_dataset(days=14, seed=7, n_events=6, n_faults=10, events=None, faults=None, sources=None):\n", + " \"\"\"events: [(급수원 번호, 사건 유형, 시작 시각 h, 지속 h, 강도)], faults: [(급수원 번호, 고장 유형, 항목, 시작 h, 지속 h)]\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " sources = sources or SOURCES\n", + " T = days * 24\n", + " tt = np.arange(T)\n", + " hour = tt % 24\n", + " night = (hour < 6) | (hour >= 20)\n", + " rain = np.zeros(T)\n", + " for _ in range(max(1, days // 4)):\n", + " s0, d = int(rng.integers(0, T - 14)), int(rng.integers(6, 14))\n", + " rain[s0:s0 + d] = np.maximum(rain[s0:s0 + d], rng.uniform(0.6, 1.0))\n", + " if events is None:\n", + " events = [(int(rng.integers(len(sources))), str(rng.choice(list(EVENT_TYPES))), int(rng.integers(min(24, T // 4), max(T // 4 + 1, T - 48))),\n", + " int(rng.integers(10, 30)), 1.0) for _ in range(n_events)]\n", + " if faults is None:\n", + " faults = [(int(rng.integers(len(sources))), str(rng.choice(FAULT_TYPES)), str(rng.choice([\"ph\", \"turbidity\", \"chlorine\", \"metal\"])),\n", + " int(rng.integers(12, max(13, T - 30))), int(rng.integers(6, 24))) for _ in range(n_faults)]\n", + " out = []\n", + " for si, (sid, stype, x, y) in enumerate(sources):\n", + " ph0, tb0, cl0, mt0 = BASE[stype]\n", + " tr = {\"ph\": ph0 + 0.08 * np.sin(2 * np.pi * hour / 24) + rng.normal(0, 0.03, T),\n", + " \"turbidity\": tb0 * (1 + 0.1 * rng.normal(0, 1, T)),\n", + " \"chlorine\": (cl0 * np.exp(-0.022 * hour) if stype == \"water_truck\" else cl0 + rng.normal(0, 0.03, T)),\n", + " \"metal\": mt0 + rng.normal(0, 0.2, T), \"ecoli\": np.zeros(T)}\n", + " if stype in (\"river_intake\", \"well\"):\n", + " tr[\"turbidity\"] = tr[\"turbidity\"] + rain * (0.9 if stype == \"river_intake\" else 0.45)\n", + " f_all, etype = np.zeros(T), np.array([\"none\"] * T, dtype=object)\n", + " for (s_i, et, onset, dur, scale) in events:\n", + " if s_i != si:\n", + " continue\n", + " delta, ramp = EVENT_TYPES[et]\n", + " f = np.clip((tt - onset + 1) / ramp, 0, 1) * ((tt >= onset) & (tt < onset + dur + ramp))\n", + " for k, v in delta.items():\n", + " tr[k] = tr[k] + v * f * scale\n", + " etype[f > 0] = et\n", + " f_all = np.maximum(f_all, f)\n", + " tr[\"chlorine\"] = np.clip(tr[\"chlorine\"], 0.02, None)\n", + " tr[\"turbidity\"] = np.clip(tr[\"turbidity\"], 0.05, None)\n", + " tr[\"metal\"] = np.clip(tr[\"metal\"], 0.1, None)\n", + " grade = np.where(f_all >= 0.2, 2, 0)\n", + " mild = (tr[\"turbidity\"] > LIMITS[\"turbidity\"][0]) | (tr[\"chlorine\"] < LIMITS[\"chlorine\"][0]) | (tr[\"metal\"] > LIMITS[\"metal\"][0])\n", + " grade = np.where((grade == 0) & mild, 1, grade)\n", + " ms = {}\n", + " for p in [\"ph\", \"turbidity\", \"chlorine\", \"metal\"]:\n", + " sd = NOISE[p] * np.where(night, 1.5, 1.0) * (tr[p] if p == \"turbidity\" else 1)\n", + " ms[p] = np.round(np.clip(tr[p] + rng.normal(0, 1, T) * sd, 0, None), DIGITS[p])\n", + " ms[\"ecoli\"] = (rng.poisson(tr[\"ecoli\"]) + (rng.random(T) < 0.004)).astype(float)\n", + " fault, fparam = np.array([\"none\"] * T, dtype=object), np.array([\"-\"] * T, dtype=object)\n", + " for (s_i, ft, p, s0, d) in faults:\n", + " if s_i != si:\n", + " continue\n", + " seg = slice(s0, min(T, s0 + d))\n", + " if ft == \"stuck\":\n", + " ms[p][seg] = ms[p][s0]\n", + " elif ft == \"spike\":\n", + " seg = slice(s0, s0 + 1)\n", + " ms[p][s0] = {\"ph\": 11.8, \"turbidity\": 38.0, \"chlorine\": 0.0, \"metal\": 75.0}[p]\n", + " elif ft == \"drift\":\n", + " ramp = np.linspace(0, 1, seg.stop - s0)\n", + " ms[p][seg] = np.clip(ms[p][seg] + ramp * {\"ph\": -1.6, \"turbidity\": 2.6, \"chlorine\": -0.75, \"metal\": 12.0}[p], 0, None).round(DIGITS[p])\n", + " elif ft == \"dropout\":\n", + " for q in PARAMS:\n", + " ms[q][seg] = np.nan\n", + " p = \"all\"\n", + " fault[seg], fparam[seg] = ft, p\n", + " df = pd.DataFrame({\"source_id\": sid, \"source_type\": stype, \"x_km\": x, \"y_km\": y, \"t\": tt,\n", + " \"cond\": np.where(rain > 0, \"storm\", np.where(night, \"night\", \"day\")),\n", + " **{p: ms[p] for p in PARAMS}, **{f\"true_{p}\": tr[p] for p in PARAMS},\n", + " \"event\": etype, \"fault\": fault, \"fault_param\": fparam, \"grade\": grade})\n", + " out.append(df)\n", + " return pd.concat(out, ignore_index=True)\n", + "# ---------------------------------------------------------------------------\n", + "\n", + "# ---- 전처리 공통 함수: 물리 범위 점검 -> 고정값(stuck) 표시 -> 단발 스파이크 제거 -> 결측 보정 ----\n", + "PHYS = {\"ph\": (0, 14), \"turbidity\": (0, 1000), \"chlorine\": (0, 10), \"metal\": (0, 1000), \"ecoli\": (0, 100000)}\n", + "SPIKE_K = {\"day\": 6.0, \"night\": 7.0, \"storm\": 9.0} # 상황별(주간/야간/악천후) 스파이크 판정 배수\n", + "MIN_JUMP = {\"ph\": 0.8, \"turbidity\": 1.5, \"chlorine\": 0.3, \"metal\": 6.0}\n", + "STUCK_RUN, SHORT_GAP = 6, 3\n", + "\n", + "\n", + "def clean(df, spike_k=None, min_jump=None):\n", + " \"\"\"급수원별 시계열을 정제하고 항목별 처리 내역(<항목>_flag: range/stuck/spike/interp/long_gap)을 남긴다.\"\"\"\n", + " spike_k = spike_k or SPIKE_K\n", + " min_jump = MIN_JUMP if min_jump is None else min_jump\n", + " out = df.sort_values([\"source_id\", \"t\"]).reset_index(drop=True)\n", + " for sid, idx in out.groupby(\"source_id\").groups.items():\n", + " idx = np.asarray(idx)\n", + " kk = out.loc[idx, \"cond\"].map(spike_k).to_numpy(float)\n", + " for p in PARAMS:\n", + " v = out.loc[idx, p].to_numpy(float).copy()\n", + " fl = np.array([\"\"] * len(v), dtype=object)\n", + " lo, hi = PHYS[p]\n", + " bad = (v < lo) | (v > hi)\n", + " v[bad], fl[bad] = np.nan, \"range\"\n", + " if p != \"ecoli\":\n", + " chg = np.r_[True, v[1:] != v[:-1]] # 같은 값이 STUCK_RUN시간 이상 반복 = 고정값 고장 의심\n", + " rid = np.cumsum(chg)\n", + " rlen = np.bincount(rid)[rid]\n", + " fl[(rlen >= STUCK_RUN) & ~chg & ~np.isnan(v)] = \"stuck\"\n", + " d = np.abs(np.diff(v[~np.isnan(v)]))\n", + " sig = 1.4826 * np.median(d) + 1e-6 if len(d) else 1.0\n", + " up, dn = v - np.r_[np.nan, v[:-1]], v - np.r_[v[1:], np.nan]\n", + " thr = np.maximum(kk * sig, min_jump[p])\n", + " spike = (np.abs(up) > thr) & (np.abs(dn) > thr) & (np.sign(up) == np.sign(dn)) # 튀었다가 바로 돌아온 값만\n", + " v[spike], fl[spike] = np.nan, \"spike\"\n", + " s = pd.Series(v)\n", + " na = s.isna().to_numpy()\n", + " grp = np.cumsum(np.r_[True, na[1:] != na[:-1]])\n", + " glen = pd.Series(na.astype(int)).groupby(grp).transform(\"sum\").to_numpy()\n", + " lin, ff = s.interpolate(limit_area=\"inside\").to_numpy(), s.ffill().bfill().to_numpy()\n", + " short, longg = na & (glen <= SHORT_GAP), na & (glen > SHORT_GAP)\n", + " v = np.where(short, np.where(np.isnan(lin), ff, lin), np.where(longg, ff, v))\n", + " fl[short & (fl == \"\")] = \"interp\"\n", + " fl[longg] = \"long_gap\"\n", + " out.loc[idx, p] = v\n", + " out.loc[idx, f\"{p}_flag\"] = fl\n", + " return out\n", + "\n", + "# ---- 가정 기준치 판정(Baseline)과 시계열 특징 공통 함수 ----\n", + "GRADE_NAME = {0: \"안전\", 1: \"주의\", 2: \"위험\"}\n", + "\n", + "\n", + "def rule_grade(df):\n", + " \"\"\"기준치 1차 필터: 위험(음용 부적합 의심) = 한 항목이라도 위험 기준 초과, 주의 = 주의 기준 초과.\"\"\"\n", + " ph, tb, cl, mt, ec = (df[p].to_numpy(float) for p in PARAMS)\n", + " danger = (ph < LIMITS[\"ph\"][0]) | (ph > LIMITS[\"ph\"][1]) | (tb > LIMITS[\"turbidity\"][1]) | (cl < LIMITS[\"chlorine\"][1]) \\\n", + " | (mt > LIMITS[\"metal\"][1]) | (ec >= LIMITS[\"ecoli\"][1])\n", + " caution = (tb > LIMITS[\"turbidity\"][0]) | (cl < LIMITS[\"chlorine\"][0]) | (mt > LIMITS[\"metal\"][0])\n", + " return np.where(danger, 2, np.where(caution, 1, 0))\n", + "\n", + "\n", + "def reasons(row):\n", + " r = []\n", + " if not LIMITS[\"ph\"][0] <= row[\"ph\"] <= LIMITS[\"ph\"][1]:\n", + " r.append(f\"pH {row['ph']:.2f}\")\n", + " if row[\"turbidity\"] > LIMITS[\"turbidity\"][0]:\n", + " r.append(f\"탁도 {row['turbidity']:.2f}NTU\")\n", + " if row[\"chlorine\"] < LIMITS[\"chlorine\"][0]:\n", + " r.append(f\"잔류염소 {row['chlorine']:.2f}mg/L\")\n", + " if row[\"metal\"] > LIMITS[\"metal\"][0]:\n", + " r.append(f\"중금속 {row['metal']:.1f}ug/L\")\n", + " if row[\"ecoli\"] >= 1:\n", + " r.append(f\"대장균 {row['ecoli']:.0f}\")\n", + " return \", \".join(r) or \"-\"\n", + "\n", + "\n", + "FEATS = [f\"{p}{s}\" for p in [\"ph\", \"turbidity\", \"chlorine\", \"metal\"] for s in [\"\", \"_d1\", \"_d3\", \"_dev\", \"_sd6\"]] + \\\n", + " [\"ecoli\", \"ecoli_3h\", \"storm\", \"night\", \"n_flag\", \"truck\", \"purif\", \"well\", \"river\"]\n", + "\n", + "\n", + "def add_features(c):\n", + " \"\"\"시간 순서를 지키는(과거 값만 쓰는) 급수원별 특징: 변화량, 24시간 기준선 대비 편차, 6시간 변동성, 대장균 누적 등.\"\"\"\n", + " c = c.sort_values([\"source_id\", \"t\"]).reset_index(drop=True)\n", + " g = c.groupby(\"source_id\")\n", + " for p in [\"ph\", \"turbidity\", \"chlorine\", \"metal\"]:\n", + " c[f\"{p}_d1\"] = g[p].diff().fillna(0)\n", + " c[f\"{p}_d3\"] = g[p].diff(3).fillna(0)\n", + " base = g[p].transform(lambda s: s.shift(1).rolling(24, min_periods=4).median())\n", + " c[f\"{p}_dev\"] = (c[p] - base).fillna(0)\n", + " c[f\"{p}_sd6\"] = g[p].transform(lambda s: s.rolling(6, min_periods=2).std()).fillna(0)\n", + " c[\"ecoli_3h\"] = g[\"ecoli\"].transform(lambda s: s.rolling(3, min_periods=1).sum())\n", + " c[\"storm\"], c[\"night\"] = (c[\"cond\"] == \"storm\").astype(int), (c[\"cond\"] == \"night\").astype(int)\n", + " flags = [f\"{p}_flag\" for p in PARAMS if f\"{p}_flag\" in c]\n", + " c[\"n_flag\"] = sum(c[f].isin([\"stuck\", \"spike\", \"long_gap\", \"range\"]).astype(int) for f in flags) if flags else 0\n", + " for k, name in [(\"truck\", \"water_truck\"), (\"purif\", \"purification_unit\"), (\"well\", \"well\"), (\"river\", \"river_intake\")]:\n", + " c[k] = (c[\"source_type\"] == name).astype(int)\n", + " return c\n", + "\n", + "CONFIG = {\"ecoli_3h_min\": 2, \"metal_dev\": 1.0, \"metal_min\": 5.0, # 미션 9 재조정 값\n", + " \"conf_high\": 60, \"scale\": {\"ph\": 0.35, \"turbidity\": 0.6, \"chlorine\": 0.2, \"metal\": 3.0}}\n", + "README = {\"1. 목적\": \"가상 수질 센서 자료로 급수원 위험 신호를 조기에 알리는 판단 보조 도구\",\n", + " \"2. 실행 방법\": \"python main_wel03.py (원클릭 실행, 외부 파일·네트워크 불필요)\",\n", + " \"3. 입력 형식\": \"급수원 ID·유형·시각·pH·탁도·잔류염소·중금속·대장균(미션 1 스키마)\",\n", + " \"4. 판정 규칙\": \"가정 기준치 + 재조정 규칙 + 모델, 신뢰도 High만 급수 중단 검토 상신\",\n", + " \"5. 인간 검토\": \"급수 중단·재개는 예방의학 담당 검토 후 지휘관 승인, 자동 확정 없음\",\n", + " \"6. 한계와 주의\": \"교육용 가상 자료, 실제 판정은 공식 수질 기준과 의무·환경 담당 확인\",\n", + " \"7. 연동 확장\": \"인접 부대 급수 정보 교환용 표준 필드(JSON) API를 차기 과제로 둔다\"}\n", + "\n", + "\n", + "def validate(df):\n", + " ok = df[\"source_id\"].astype(str).str.match(r\"^W\\d{2}$\") & df[\"t\"].ge(0)\n", + " return df[ok].copy(), int((~ok).sum())\n", + "\n", + "\n", + "def tuned_grade(c):\n", + " ph, tb, cl, mt, ec = (c[p].to_numpy(float) for p in PARAMS)\n", + " eco = (c[\"ecoli_3h\"].to_numpy() >= CONFIG[\"ecoli_3h_min\"]) | ((ec >= 1) & ((tb > LIMITS[\"turbidity\"][0]) | (cl < LIMITS[\"chlorine\"][0])))\n", + " trend = (c[\"metal_dev\"].to_numpy() > CONFIG[\"metal_dev\"]) & (mt > CONFIG[\"metal_min\"])\n", + " danger = (ph < LIMITS[\"ph\"][0]) | (ph > LIMITS[\"ph\"][1]) | (tb > LIMITS[\"turbidity\"][1]) | (cl < LIMITS[\"chlorine\"][1]) \\\n", + " | (mt > LIMITS[\"metal\"][1]) | eco | trend\n", + " caution = (tb > LIMITS[\"turbidity\"][0]) | (cl < LIMITS[\"chlorine\"][0]) | (mt > LIMITS[\"metal\"][0]) | (ec >= 1)\n", + " return np.where(danger, 2, np.where(caution, 1, 0))\n", + "\n", + "\n", + "def confidence(c, alert):\n", + " S = CONFIG[\"scale\"]\n", + " n_dev = sum(((c[f\"{p}_dev\"].abs() > S[p]) | (c[f\"{p}_d3\"].abs() > S[p])).astype(int) for p in S) + (c[\"ecoli_3h\"] >= 2)\n", + " a = pd.Series(alert.astype(int), index=c.index)\n", + " run = a.groupby([c[\"source_id\"], (a != a.groupby(c[\"source_id\"]).shift()).cumsum()]).cumsum()\n", + " g = c.groupby(\"source_id\")\n", + " flagged = g[\"n_flag\"].transform(lambda s: s.rolling(6, min_periods=1).max()) > 0\n", + " # 드리프트 의심: 한 항목만 6시간 중 5시간 이상 같은 방향으로 꾸준히 변함(잡음이 커지는 야간에도 유지되는 기준)\n", + " mono = pd.concat([g[f\"{p}_d1\"].transform(lambda s: np.sign(s).rolling(6, min_periods=3).sum()).abs() >= 4 for p in S], axis=1).any(axis=1)\n", + " drift = (n_dev <= 1) & mono & (c[\"ecoli_3h\"] < 2)\n", + " return 40 * np.minimum(n_dev, 3) / 3 + 25 * np.minimum(run, 3) / 3 + 20 * (~flagged) + 15 * (~drift)\n", + "\n", + "\n", + "def train_model():\n", + " tr = pd.concat([add_features(clean(make_dataset(seed=s, n_events=14, n_faults=14))) for s in (7, 8)], ignore_index=True)\n", + " return RandomForestClassifier(n_estimators=150, max_depth=10, class_weight=\"balanced_subsample\", random_state=0, n_jobs=1).fit(tr[FEATS], tr[\"grade\"])\n", + "\n", + "\n", + "def run_pipeline(raw, model):\n", + " data, n_bad = validate(raw)\n", + " c = add_features(clean(data))\n", + " c[\"rule\"] = tuned_grade(c)\n", + " c[\"ml\"] = model.predict(c[FEATS])\n", + " c[\"final\"] = np.maximum(c[\"rule\"], c[\"ml\"]) # 둘 중 하나라도 위험이면 위험(놓침 방지)\n", + " alert = (c[\"final\"] == 2).to_numpy()\n", + " c[\"conf\"] = np.where(alert, confidence(c, alert), np.nan)\n", + " queue = c[alert & (c[\"conf\"] >= CONFIG[\"conf_high\"])].groupby(\"source_id\").agg(first=(\"t\", \"min\"), hours=(\"t\", \"size\"))\n", + " queue[\"status\"] = \"PENDING(지휘관 결정 대기)\"\n", + " return c, queue, n_bad\n", + "\n", + "\n", + "def self_tests(model):\n", + " res = {}\n", + " clean_df = make_dataset(days=2, seed=50, events=[], faults=[])\n", + " c, q, _ = run_pipeline(clean_df, model)\n", + " res[\"오염 없는 자료에서 급수 중단 상신 0건\"] = len(q) == 0\n", + " ev = make_dataset(days=2, seed=50, events=[(0, \"sewage\", 20, 12, 1.0)], faults=[])\n", + " c, q, _ = run_pipeline(ev, model)\n", + " res[\"하수 유입 급수원(W01) 상신\"] = \"W01\" in q.index\n", + " res[\"상신 건은 모두 PENDING(자동 확정 없음)\"] = q[\"status\"].str.startswith(\"PENDING\").all()\n", + " bad = ev.copy(); bad.loc[3, \"source_id\"] = \"??\"\n", + " res[\"손상 레코드 격리\"] = run_pipeline(bad, model)[2] == 1\n", + " dr = make_dataset(days=2, seed=50, events=[], faults=[(1, \"drift\", \"turbidity\", 10, 18)])\n", + " c, q, _ = run_pipeline(dr, model)\n", + " n_alert = int((c.loc[c[\"source_id\"] == \"W02\", \"final\"] == 2).sum()) # 드리프트 구간 위험 판정 시간 중 High 상신 시간이 절반 미만이면 억제 작동\n", + " res[\"센서 드리프트 상신 시간 절반 미만으로 억제\"] = (\"W02\" not in q.index) or q.loc[\"W02\", \"hours\"] < 0.5 * n_alert\n", + " res[\"기준치 필터·판정 근거 문구 생성\"] = len(reasons(c[c[\"source_id\"] == \"W02\"].iloc[40])) > 0 and rule_grade(c).shape[0] == len(c)\n", + " res[\"결측 보정 후 남은 결측 0\"] = int(c[PARAMS].isna().sum().sum()) == 0\n", + " res[\"운영 문서 7개 장 작성\"] = all(len(v) > 10 for v in README.values())\n", + " return res\n", + "\n", + "\n", + "# ---- 자체 점검(함수 호출 추적으로 커버리지 측정) ----\n", + "t_all = time.perf_counter()\n", + "called = set()\n", + "own = {n for n, f in list(globals().items()) if callable(f) and getattr(f, \"__module__\", \"\") == \"__main__\" and hasattr(f, \"__code__\")}\n", + "sys.setprofile(lambda fr, ev, arg: called.add(fr.f_code.co_name) if ev == \"call\" else None)\n", + "model = train_model()\n", + "tests = self_tests(model)\n", + "e2e = []\n", + "for s in (11, 21, 31, 41, 51):\n", + " try:\n", + " c, q, nb = run_pipeline(make_dataset(seed=s, n_events=10, n_faults=14), model)\n", + " e2e.append((s, True, (c[\"final\"] == c[\"grade\"]).mean(), len(q)))\n", + " except Exception as e:\n", + " e2e.append((s, False, np.nan, 0))\n", + "sys.setprofile(None)\n", + "cov = len(own & called) / len(own)\n", + "print(f\"[교육용가상자료] 통합 실행 {time.perf_counter() - t_all:.1f}초 (모델 학습 포함)\")\n", + "print(\"[End-to-End 시나리오 5종]\")\n", + "for s, ok, acc, nq in e2e:\n", + " print(f\" seed {s}: {'성공' if ok else '실패'}, 최종 판정 정확도 {acc:.1%}, 급수 중단 검토 상신 {nq}곳\")\n", + "rate = np.mean([x[1] for x in e2e])\n", + "print(f\"E2E 성공률 {rate:.0%} (목표 100%: {'달성' if rate == 1 else '미달'})\")\n", + "print(\"[자체 점검]\")\n", + "for k, v in tests.items():\n", + " print(f\" [{'PASS' if v else 'FAIL'}] {k}\")\n", + "print(f\"자체 점검 {sum(tests.values())}/{len(tests)} 통과, 함수 커버리지 {cov:.0%}({len(own & called)}/{len(own)}, 목표 85% 이상: {'달성' if cov >= 0.85 else '미달'})\")\n", + "print(f\"운영 문서(README) {len(README)}개 장: \" + \", \".join(README))\n", + "\n", + "# ---- 브리핑 요약(벤치마크 seed 11, 운용 시점 기준) ----\n", + "c, q, _ = run_pipeline(make_dataset(seed=11, n_events=10, n_faults=14), model)\n", + "d = c[\"grade\"] == 2\n", + "print(\"\\n[지휘관 브리핑 요약]\")\n", + "print(f\"1. 급수원 8곳 14일 모의: 위험 재현율 {((c['final'] == 2) & d).sum() / d.sum():.1%}, 종합 정확도 {(c['final'] == c['grade']).mean():.1%}\")\n", + "print(f\"2. 급수 중단 검토 상신 {len(q)}곳({', '.join(q.index)}): 모두 예방의학 담당 검토·지휘관 결정 대기\")\n", + "print(\"3. 센서 이상(드리프트·스파이크·단절)은 재측정·교정 요청으로 분리해 불필요한 단수를 줄인다\")\n", + "print(\"4. 실제 음용 판정은 공식 수질 기준과 의무(예방의학)·환경 담당 확인에 따른다(본 도구는 판단 보조)\")\n", + "\n", + "last = c[c[\"t\"] == c[\"t\"].max()].set_index(\"source_id\")\n", + "fig, ax = plt.subplots(1, 2, figsize=(11, 4))\n", + "ax[0].bar(last.index, last[\"final\"], color=[{0: \"tab:green\", 1: \"gold\", 2: \"red\"}[g] for g in last[\"final\"]])\n", + "ax[0].set_yticks([0, 1, 2], [\"safe\", \"caution\", \"danger\"]); ax[0].set_title(\"Final status by source (last hour)\")\n", + "ax[1].bar([str(x[0]) for x in e2e], [x[2] for x in e2e], color=\"tab:blue\"); ax[1].set_ylim(0.9, 1.0)\n", + "ax[1].set_title(\"E2E accuracy by scenario seed\"); ax[1].set_xlabel(\"seed\")\n", + "plt.suptitle(\"Integrated water risk dashboard package (synthetic)\"); plt.tight_layout(); plt.show()\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 10 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + "[교육용가상자료] 통합 실행 6.6초 (모델 학습 포함)\n", + "[End-to-End 시나리오 5종]\n", + " seed 11: 성공, 최종 판정 정확도 98.6%, 급수 중단 검토 상신 6곳\n", + " seed 21: 성공, 최종 판정 정확도 99.1%, 급수 중단 검토 상신 6곳\n", + " seed 31: 성공, 최종 판정 정확도 97.0%, 급수 중단 검토 상신 6곳\n", + " seed 41: 성공, 최종 판정 정확도 97.7%, 급수 중단 검토 상신 7곳\n", + " seed 51: 성공, 최종 판정 정확도 98.0%, 급수 중단 검토 상신 7곳\n", + "E2E 성공률 100% (목표 100%: 달성)\n", + "[자체 점검]\n", + " [PASS] 오염 없는 자료에서 급수 중단 상신 0건\n", + " [PASS] 하수 유입 급수원(W01) 상신\n", + " [PASS] 상신 건은 모두 PENDING(자동 확정 없음)\n", + " [PASS] 손상 레코드 격리\n", + " [PASS] 센서 드리프트 상신 시간 절반 미만으로 억제\n", + " [PASS] 기준치 필터·판정 근거 문구 생성\n", + " [PASS] 결측 보정 후 남은 결측 0\n", + " [PASS] 운영 문서 7개 장 작성\n", + "자체 점검 8/8 통과, 함수 커버리지 100%(11/11, 목표 85% 이상: 달성)\n", + "운영 문서(README) 7개 장: 1. 목적, 2. 실행 방법, 3. 입력 형식, 4. 판정 규칙, 5. 인간 검토, 6. 한계와 주의, 7. 연동 확장\n", + "\n", + "[지휘관 브리핑 요약]\n", + "1. 급수원 8곳 14일 모의: 위험 재현율 98.4%, 종합 정확도 98.6%\n", + "2. 급수 중단 검토 상신 6곳(W02, W03, W04, W05, W07, W08): 모두 예방의학 담당 검토·지휘관 결정 대기\n", + "3. 센서 이상(드리프트·스파이크·단절)은 재측정·교정 요청으로 분리해 불필요한 단수를 줄인다\n", + "4. 실제 음용 판정은 공식 수질 기준과 의무(예방의학)·환경 담당 확인에 따른다(본 도구는 판단 보조)\n", + "```\n", + "
\n" + ] + } + ], + "metadata": { + "colab": { + "provenance": [] + }, + "kernelspec": { + "display_name": "Python 3", + "name": "python3" + }, + "language_info": { + "name": "python" + } + }, + "nbformat": 4, + "nbformat_minor": 0 +} \ No newline at end of file diff --git a/uploads/WEL03/WEL03_student.ipynb b/uploads/WEL03/WEL03_student.ipynb new file mode 100644 index 0000000..0f60219 --- /dev/null +++ b/uploads/WEL03/WEL03_student.ipynb @@ -0,0 +1,3353 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# 🎯 [WEL03] 가상 수질검사 데이터 위험 신호 대시보드\n", + "### Google Colab 실습 환경 (장병 복지·지원 / Level 2)\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/WEL03/WEL03_solution.ipynb)\n", + "\n", + "---\n", + "\n", + "## 📌 1. 프로젝트 개요 및 배경\n", + "\n", + "| 항목 | 내용 |\n", + "| :--- | :--- |\n", + "| **과정 분류** | 장병 복지·지원 (Level 2) |\n", + "| **학습 소요 시간** | 10개 미션 (5일 / 총 27.5시간) |\n", + "| **환경 사양** | Python 3.10+, numpy, pandas, matplotlib, scipy, scikit-learn |\n", + "\n", + "### 📖 프로젝트 목표\n", + "전장에서는 급수차, 정수 장비, 우물, 하천 취수원 등 급수원의 수질 이상을 빨리 알아채지 못하면 장병의 건강과 부대 활동에 바로 영향을 주지만, 센서 값은 잡음과 고장이 많아 오경보로 불필요하게 급수를 끊는 일도 생긴다. 러시아-우크라이나 전쟁 사례에서 학습 초점으로 삼은 전장 위생·건강 위험 관리를 반영해, 본 프로젝트는 가상 수질검사 데이터를 분석해 위험 신호를 데이터로 판단하는 과제를 다룬다. 교육용 가상 급수원 8곳의 pH, 탁도, 잔류염소, 중금속, 대장균 측정 자료를 코드로 만들어 스키마 정의, 센서 결측·이상치 정제, 기준치 Baseline 판정, 복합 오염 징후 분류 모델, 신뢰도 지수와 오경보 감축, 상황판 대시보드, 급수 중단 HITL 결재선, 대량 오염 부하 시험, 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. 야전 급수원 가상 수질검사 항목(pH, 탁도, 잔류염소, 중금속, 대장균) 데이터셋 정의\n", + "**소요시간**: 약 1.5시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "야전 급수원 가상 수질검사 항목(pH, 탁도, 잔류염소, 중금속) 데이터셋을 정의한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **야전 급수원 가상 수질검사 항목(pH, 탁도, 잔류염소, 중금속, 대장균)과 센서·전술 환경 정보의 스키마와 데이터 명세를 정의한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **측정 레코드를 급수원 정보, 수질 5항목, 센서 상태, 전술 파라미터(기상, 위치 오차, 통신 지연) 네 묶음 15개 필드로 정의하고 자료형·단위·허용 범위·형식 규칙을 둔다 과정을 진행하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `data/schema/wel03_input_schema.json 및 docs/wel03_data_spec.md` 형태의 결과물을 완성한다.\n", + "\n", + "### 🔍 평가 및 검증 기준 (PASS 조건)\n", + "#### 미션 판정\n", + "\n", + "**평가 구분:** 필수\n", + "\n", + "**핵심 평가영역:** 장병 복지·지원 실무 및 러시아-우크라이나 전쟁 전장 환경과의 군사적 부합성\n", + "\n", + "#### 미션 PASS 조건\n", + "\n", + "- 정량 기준: 스키마 필드 정의 완성도 100%, 샘플 데이터 검증 오류 0건\n", + "\n", + "\n", + "- 진행 기준: 입력 데이터 유효성 검증 스크립트 통과 및 명세서 문서화 완료\n", + "\n", + "\n", + "- 검증 방법: JSON Schema 검증 도구 및 샘플 데이터 파싱 단위 테스트\n", + "\n", + "\n", + "#### 채점자 판정 메모\n", + "\n", + "> 단순 코드 실행 여부뿐만 아니라 출력된 수치와 근거 자료가 군 실무 요구사항에 부합하는지 면밀히 확인한다.\n", + "\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "\"\"\"야전 급수원 가상 수질검사 항목(pH, 탁도, 잔류염소, 중금속, 대장균)과 급수원·센서·전술 파라미터를 담은 입력 데이터 스키마를 정의하고, 가상 샘플 20건과 오류 주입 데이터로 검증기를 시험한다.\"\"\"\n", + "import json\n", + "import re\n", + "import numpy as np\n", + "import pandas as pd\n", + "\n", + "# ---------------------------------------------------------------------------\n", + "# [교육용가상자료] 야전 급수원 가상 수질 센서 데이터 생성기 (실제 부대·위치·검사 결과 없음)\n", + "# 가상 급수원 8곳 x 14일 x 1시간 간격. 실제 수질(true_*)·센서 측정값·오염 사건·센서 고장 정답을 함께 만든다.\n", + "# 기준치는 교육용 가정값이며, 실제 음용 판정은 공인 검사 결과와 의무(예방의학) 담당 기준에 따른다.\n", + "# ---------------------------------------------------------------------------\n", + "PARAMS = [\"ph\", \"turbidity\", \"chlorine\", \"metal\", \"ecoli\"]\n", + "SOURCES = [(\"W01\", \"water_truck\", 1.0, 2.0), (\"W02\", \"water_truck\", 3.5, 1.2), (\"W03\", \"purification_unit\", 2.2, 4.1),\n", + " (\"W04\", \"purification_unit\", 5.0, 3.3), (\"W05\", \"well\", 0.8, 5.6), (\"W06\", \"well\", 4.4, 6.0),\n", + " (\"W07\", \"river_intake\", 6.3, 1.8), (\"W08\", \"river_intake\", 6.9, 4.9)]\n", + "BASE = {\"water_truck\": (7.2, 0.25, 0.75, 1.5), \"purification_unit\": (7.0, 0.20, 0.85, 1.2),\n", + " \"well\": (6.8, 0.45, 0.50, 3.0), \"river_intake\": (7.3, 0.55, 0.60, 2.0)} # pH, 탁도(NTU), 잔류염소(mg/L), 중금속(ug/L)\n", + "LIMITS = {\"ph\": (5.8, 8.5), \"turbidity\": (1.0, 2.0), \"chlorine\": (0.2, 0.1), \"metal\": (5.0, 10.0), \"ecoli\": (1, 1)} # (주의, 위험) 가정 기준\n", + "EVENT_TYPES = { # 오염 사건: 변수별 최대 변화량, 최대치 도달 시간(h)\n", + " \"sewage\": ({\"turbidity\": 2.8, \"chlorine\": -0.5, \"ecoli\": 30, \"ph\": -0.3}, 4),\n", + " \"metal\": ({\"metal\": 22.0, \"ph\": -0.5}, 4),\n", + " \"disinfect_fail\": ({\"chlorine\": -0.8, \"ecoli\": 6}, 6),\n", + " \"chemical\": ({\"ph\": 1.7, \"turbidity\": 1.0}, 3),\n", + " \"creeping_metal\": ({\"metal\": 13.0}, 18),\n", + "}\n", + "FAULT_TYPES = [\"stuck\", \"spike\", \"drift\", \"dropout\"]\n", + "NOISE = {\"ph\": 0.04, \"turbidity\": 0.06, \"chlorine\": 0.03, \"metal\": 0.35}\n", + "DIGITS = {\"ph\": 2, \"turbidity\": 2, \"chlorine\": 2, \"metal\": 1}\n", + "\n", + "\n", + "def make_dataset(days=14, seed=7, n_events=6, n_faults=10, events=None, faults=None, sources=None):\n", + " \"\"\"events: [(급수원 번호, 사건 유형, 시작 시각 h, 지속 h, 강도)], faults: [(급수원 번호, 고장 유형, 항목, 시작 h, 지속 h)]\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " sources = sources or SOURCES\n", + " T = days * 24\n", + " tt = np.arange(T)\n", + " hour = tt % 24\n", + " night = (hour < 6) | (hour >= 20)\n", + " rain = np.zeros(T)\n", + " for _ in range(max(1, days // 4)):\n", + " s0, d = int(rng.integers(0, T - 14)), int(rng.integers(6, 14))\n", + " rain[s0:s0 + d] = np.maximum(rain[s0:s0 + d], rng.uniform(0.6, 1.0))\n", + " if events is None:\n", + " events = [(int(rng.integers(len(sources))), str(rng.choice(list(EVENT_TYPES))), int(rng.integers(min(24, T // 4), max(T // 4 + 1, T - 48))),\n", + " int(rng.integers(10, 30)), 1.0) for _ in range(n_events)]\n", + " if faults is None:\n", + " faults = [(int(rng.integers(len(sources))), str(rng.choice(FAULT_TYPES)), str(rng.choice([\"ph\", \"turbidity\", \"chlorine\", \"metal\"])),\n", + " int(rng.integers(12, max(13, T - 30))), int(rng.integers(6, 24))) for _ in range(n_faults)]\n", + " out = []\n", + " for si, (sid, stype, x, y) in enumerate(sources):\n", + " ph0, tb0, cl0, mt0 = BASE[stype]\n", + " tr = {\"ph\": ph0 + 0.08 * np.sin(2 * np.pi * hour / 24) + rng.normal(0, 0.03, T),\n", + " \"turbidity\": tb0 * (1 + 0.1 * rng.normal(0, 1, T)),\n", + " \"chlorine\": (cl0 * np.exp(-0.022 * hour) if stype == \"water_truck\" else cl0 + rng.normal(0, 0.03, T)),\n", + " \"metal\": mt0 + rng.normal(0, 0.2, T), \"ecoli\": np.zeros(T)}\n", + " if stype in (\"river_intake\", \"well\"):\n", + " tr[\"turbidity\"] = tr[\"turbidity\"] + rain * (0.9 if stype == \"river_intake\" else 0.45)\n", + " f_all, etype = np.zeros(T), np.array([\"none\"] * T, dtype=object)\n", + " for (s_i, et, onset, dur, scale) in events:\n", + " if s_i != si:\n", + " continue\n", + " delta, ramp = EVENT_TYPES[et]\n", + " f = np.clip((tt - onset + 1) / ramp, 0, 1) * ((tt >= onset) & (tt < onset + dur + ramp))\n", + " for k, v in delta.items():\n", + " tr[k] = tr[k] + v * f * scale\n", + " etype[f > 0] = et\n", + " f_all = np.maximum(f_all, f)\n", + " tr[\"chlorine\"] = np.clip(tr[\"chlorine\"], 0.02, None)\n", + " tr[\"turbidity\"] = np.clip(tr[\"turbidity\"], 0.05, None)\n", + " tr[\"metal\"] = np.clip(tr[\"metal\"], 0.1, None)\n", + " grade = np.where(f_all >= 0.2, 2, 0)\n", + " mild = (tr[\"turbidity\"] > LIMITS[\"turbidity\"][0]) | (tr[\"chlorine\"] < LIMITS[\"chlorine\"][0]) | (tr[\"metal\"] > LIMITS[\"metal\"][0])\n", + " grade = np.where((grade == 0) & mild, 1, grade)\n", + " ms = {}\n", + " for p in [\"ph\", \"turbidity\", \"chlorine\", \"metal\"]:\n", + " sd = NOISE[p] * np.where(night, 1.5, 1.0) * (tr[p] if p == \"turbidity\" else 1)\n", + " ms[p] = np.round(np.clip(tr[p] + rng.normal(0, 1, T) * sd, 0, None), DIGITS[p])\n", + " ms[\"ecoli\"] = (rng.poisson(tr[\"ecoli\"]) + (rng.random(T) < 0.004)).astype(float)\n", + " fault, fparam = np.array([\"none\"] * T, dtype=object), np.array([\"-\"] * T, dtype=object)\n", + " for (s_i, ft, p, s0, d) in faults:\n", + " if s_i != si:\n", + " continue\n", + " seg = slice(s0, min(T, s0 + d))\n", + " if ft == \"stuck\":\n", + " ms[p][seg] = ms[p][s0]\n", + " elif ft == \"spike\":\n", + " seg = slice(s0, s0 + 1)\n", + " ms[p][s0] = {\"ph\": 11.8, \"turbidity\": 38.0, \"chlorine\": 0.0, \"metal\": 75.0}[p]\n", + " elif ft == \"drift\":\n", + " ramp = np.linspace(0, 1, seg.stop - s0)\n", + " ms[p][seg] = np.clip(ms[p][seg] + ramp * {\"ph\": -1.6, \"turbidity\": 2.6, \"chlorine\": -0.75, \"metal\": 12.0}[p], 0, None).round(DIGITS[p])\n", + " elif ft == \"dropout\":\n", + " for q in PARAMS:\n", + " ms[q][seg] = np.nan\n", + " p = \"all\"\n", + " fault[seg], fparam[seg] = ft, p\n", + " df = pd.DataFrame({\"source_id\": sid, \"source_type\": stype, \"x_km\": x, \"y_km\": y, \"t\": tt,\n", + " \"cond\": np.where(rain > 0, \"storm\", np.where(night, \"night\", \"day\")),\n", + " **{p: ms[p] for p in PARAMS}, **{f\"true_{p}\": tr[p] for p in PARAMS},\n", + " \"event\": etype, \"fault\": fault, \"fault_param\": fparam, \"grade\": grade})\n", + " out.append(df)\n", + " return pd.concat(out, ignore_index=True)\n", + "# ---------------------------------------------------------------------------\n", + "\n", + "# 1) 스키마 정의: 급수원 정보, 수질 측정값(5항목), 센서 상태, 전술 파라미터(기상·위치오차·통신지연)\n", + "SCHEMA = {\n", + " \"required\": [\"record_id\", \"source\", \"measured_at\", \"quality\", \"sensor\", \"tactical\"],\n", + " \"fields\": {\n", + " \"record_id\": {\"type\": str, \"pattern\": r\"^WQ-W\\d{2}-\\d{4}$\"},\n", + " \"measured_at\": {\"type\": str, \"pattern\": r\"^D\\+\\d{2} \\d{2}:00$\"},\n", + " },\n", + " \"source\": {\n", + " \"source_id\": {\"type\": str, \"pattern\": r\"^W\\d{2}$\"},\n", + " \"source_type\": {\"type\": str, \"enum\": [\"water_truck\", \"purification_unit\", \"well\", \"river_intake\"]},\n", + " \"grid_km\": {\"type\": list, \"length\": 2},\n", + " },\n", + " \"quality\": { # 단위와 물리적으로 가능한 범위(기준치가 아니라 측정 가능 범위)\n", + " \"ph\": {\"type\": (int, float), \"min\": 0, \"max\": 14, \"unit\": \"-\"},\n", + " \"turbidity_ntu\": {\"type\": (int, float), \"min\": 0, \"max\": 1000, \"unit\": \"NTU\"},\n", + " \"chlorine_mgL\": {\"type\": (int, float), \"min\": 0, \"max\": 10, \"unit\": \"mg/L\"},\n", + " \"metal_ugL\": {\"type\": (int, float), \"min\": 0, \"max\": 1000, \"unit\": \"ug/L\"},\n", + " \"ecoli_cfu\": {\"type\": int, \"min\": 0, \"max\": 100000, \"unit\": \"CFU/100mL\"},\n", + " },\n", + " \"sensor\": {\n", + " \"status\": {\"type\": str, \"enum\": [\"ok\", \"calibration_due\", \"fault\", \"offline\"]},\n", + " \"last_calibrated_h\": {\"type\": int, \"min\": 0, \"max\": 720},\n", + " },\n", + " \"tactical\": {\n", + " \"weather\": {\"type\": str, \"enum\": [\"clear\", \"rain\", \"night\", \"snow\", \"dust\"]},\n", + " \"position_error_m\": {\"type\": (int, float), \"min\": 0, \"max\": 500},\n", + " \"comm_delay_ms\": {\"type\": (int, float), \"min\": 0, \"max\": 10000},\n", + " },\n", + "}\n", + "\n", + "\n", + "def check(name, value, rule):\n", + " \"\"\"값 하나를 규칙(type, enum, min, max, length, pattern)으로 검사해 오류 목록을 돌려준다.\"\"\"\n", + " # [TODO : 학생 실습 1] 야전 급수원 가상 수질검사 항목(pH, 탁도, 잔류염소, 중금속, 대장균) 데이터셋 정의 관련 핵심 로직을 구현하세요.\n", + " # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n", + " # <<< 여기에 코드를 작성하세요 >>>\n", + " pass # 작성 후 pass를 제거하세요.\n", + "def validate(rec):\n", + " # [TODO : 학생 실습 1] 야전 급수원 가상 수질검사 항목(pH, 탁도, 잔류염소, 중금속, 대장균) 데이터셋 정의 관련 핵심 로직을 구현하세요.\n", + " # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n", + " # <<< 여기에 코드를 작성하세요 >>>\n", + " pass # 작성 후 pass를 제거하세요.\n", + "data = make_dataset(days=3, seed=1, n_events=1, n_faults=0)\n", + "rng = np.random.default_rng(1)\n", + "rows = data.sample(20, random_state=1).sort_values([\"source_id\", \"t\"])\n", + "records = []\n", + "for _, r in rows.iterrows():\n", + " records.append({\n", + " \"record_id\": f\"WQ-{r['source_id']}-{int(r['t']):04d}\", \"measured_at\": f\"D+{int(r['t']) // 24:02d} {int(r['t']) % 24:02d}:00\",\n", + " \"source\": {\"source_id\": r[\"source_id\"], \"source_type\": r[\"source_type\"], \"grid_km\": [r[\"x_km\"], r[\"y_km\"]]},\n", + " \"quality\": {\"ph\": float(r[\"ph\"]), \"turbidity_ntu\": float(r[\"turbidity\"]), \"chlorine_mgL\": float(r[\"chlorine\"]),\n", + " \"metal_ugL\": float(r[\"metal\"]), \"ecoli_cfu\": int(r[\"ecoli\"])},\n", + " \"sensor\": {\"status\": \"ok\", \"last_calibrated_h\": int(rng.integers(1, 200))},\n", + " \"tactical\": {\"weather\": {\"storm\": \"rain\", \"night\": \"night\", \"day\": \"clear\"}[r[\"cond\"]],\n", + " \"position_error_m\": float(round(rng.uniform(3, 40), 1)), \"comm_delay_ms\": int(rng.integers(30, 900))}})\n", + "\n", + "ok = sum(1 for r in records if not validate(r))\n", + "print(f\"정상 샘플 {len(records)}건 중 검증 통과 {ok}건, 오류 {len(records) - ok}건\")\n", + "parsed = json.loads(json.dumps(records, ensure_ascii=False)) # 직렬화 -> 파싱 왕복 시험\n", + "print(\"JSON 직렬화·파싱 왕복 후 값 일치:\", parsed == records)\n", + "\n", + "# 3) 오류 주입 시험: 검증기가 잘못된 데이터를 잡아내는지 확인\n", + "bad = json.loads(json.dumps(records[0]))\n", + "bad[\"record_id\"] = \"WQ-1\" # 형식 오류\n", + "bad[\"quality\"][\"ph\"] = 15.2 # 물리 범위 초과\n", + "bad[\"quality\"][\"ecoli_cfu\"] = \"검출\" # 자료형 오류(숫자여야 함)\n", + "bad[\"source\"][\"source_type\"] = \"bottle\" # 허용되지 않은 급수원 유형\n", + "del bad[\"tactical\"][\"comm_delay_ms\"] # 전술 파라미터 누락\n", + "print(\"\\n[오류 주입 결과]\")\n", + "errs = validate(bad)\n", + "for e in errs:\n", + " print(\" -\", e)\n", + "print(f\"주입 오류 5종 중 검출 {len(errs)}종 -> {'모두 검출' if len(errs) >= 5 else '누락 있음'}\")\n", + "\n", + "# 4) 데이터 명세 요약(명세서 초안)\n", + "spec = []\n", + "for g in (\"fields\", \"source\", \"quality\", \"sensor\", \"tactical\"):\n", + " for k, r in SCHEMA[g].items():\n", + " t = r[\"type\"].__name__ if isinstance(r[\"type\"], type) else \"number\"\n", + " spec.append((g, k, t, r.get(\"unit\", \"\"), str({x: y for x, y in r.items() if x not in (\"type\", \"unit\")})))\n", + "print(\"\\n[데이터 명세 요약]\")\n", + "print(pd.DataFrame(spec, columns=[\"그룹\", \"필드\", \"자료형\", \"단위\", \"규칙\"]).to_string(index=False))\n", + "done = sum(1 for s in spec if s[2] and s[4] != \"{}\")\n", + "print(f\"스키마 필드 정의 완성도 {done}/{len(spec)} ({done / len(spec):.0%}): 모든 필드에 자료형·검증 규칙 정의\")\n", + "print(\"\\n[가정 판정 기준(교육용)] (주의, 위험):\", {k: v for k, v in LIMITS.items()})\n", + "print(\"급수원 유형별 샘플 수:\", rows[\"source_type\"].value_counts().to_dict())\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 01 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + "정상 샘플 20건 중 검증 통과 20건, 오류 0건\n", + "JSON 직렬화·파싱 왕복 후 값 일치: True\n", + "\n", + "[오류 주입 결과]\n", + " - record_id: 형식 불일치 'WQ-1'\n", + " - source.source_type: 허용되지 않은 값 'bottle'\n", + " - quality.ph: 최대값 14 초과\n", + " - quality.ecoli_cfu: 자료형 오류(str)\n", + " - tactical.comm_delay_ms: 필수 필드 누락\n", + "주입 오류 5종 중 검출 5종 -> 모두 검출\n", + "\n", + "[데이터 명세 요약]\n", + " 그룹 필드 자료형 단위 규칙\n", + " fields record_id str {'pattern': '^WQ-W\\\\d{2}-\\\\d{4}$'}\n", + " fields measured_at str {'pattern': '^D\\\\+\\\\d{2} \\\\d{2}:00$'}\n", + " source source_id str {'pattern': '^W\\\\d{2}$'}\n", + " source source_type str {'enum': ['water_truck', 'purification_unit', 'well', 'river_intake']}\n", + " source grid_km list {'length': 2}\n", + " quality ph number - {'min': 0, 'max': 14}\n", + " quality turbidity_ntu number NTU {'min': 0, 'max': 1000}\n", + " quality chlorine_mgL number mg/L {'min': 0, 'max': 10}\n", + " quality metal_ugL number ug/L {'min': 0, 'max': 1000}\n", + " quality ecoli_cfu int CFU/100mL {'min': 0, 'max': 100000}\n", + " sensor status str {'enum': ['ok', 'calibration_due', 'fault', 'offline']}\n", + " sensor last_calibrated_h int {'min': 0, 'max': 720}\n", + "tactical weather str {'enum': ['clear', 'rain', 'night', 'snow', 'dust']}\n", + "tactical position_error_m number {'min': 0, 'max': 500}\n", + "tactical comm_delay_ms number {'min': 0, 'max': 10000}\n", + "스키마 필드 정의 완성도 15/15 (100%): 모든 필드에 자료형·검증 규칙 정의\n", + "\n", + "[가정 판정 기준(교육용)] (주의, 위험): {'ph': (5.8, 8.5), 'turbidity': (1.0, 2.0), 'chlorine': (0.2, 0.1), 'metal': (5.0, 10.0), 'ecoli': (1, 1)}\n", + "급수원 유형별 샘플 수: {'purification_unit': 7, 'river_intake': 7, 'water_truck': 3, 'well': 3}\n", + "```\n", + "
\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 02. 수질 센서 결측치 대체 및 시계열 센서 이상치 정제 전처리 모듈 구현\n", + "**소요시간**: 약 2.0시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "수질 센서 결측치 대체 및 시계열 이상치 정제 전처리 모듈을 구현한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **수질 센서의 결측치를 보정하고 시계열 이상치를 정제하는 전처리 모듈을 구현해 속도와 정확도를 확인한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **급수원별 시계열에서 물리 범위 밖 값, 같은 값이 반복되는 고정값(stuck), 튀었다가 바로 돌아오는 단발 스파이크를 표시·제거하고 짧은 결측은 선형 보간, 긴 결측은 표시 수행하고 검증하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `src/preprocess/wel03_preprocessor.py 및 전처리 전후 데이터셋` 형태의 결과물을 완성한다.\n", + "\n", + "### 🔍 평가 및 검증 기준 (PASS 조건)\n", + "#### 미션 판정\n", + "\n", + "**평가 구분:** 필수\n", + "\n", + "**핵심 평가영역:** 전술 환경 특유의 노이즈(스푸핑, 반사파, 센서 단절 등) 제거 타당성\n", + "\n", + "#### 미션 PASS 조건\n", + "\n", + "- 정량 기준: 데이터 정제 처리 속도 1,000건/초 이상, 전처리 오류율 0%\n", + "\n", + "\n", + "- 진행 기준: 정제 파이프라인 자동화 및 단위 테스트 케이스 5종 통과\n", + "\n", + "\n", + "- 검증 방법: pytest 기반 전처리 정합성 테스트 및 결측치 보정율 확인\n", + "\n", + "\n", + "#### 채점자 판정 메모\n", + "\n", + "> 단순 코드 실행 여부뿐만 아니라 출력된 수치와 근거 자료가 군 실무 요구사항에 부합하는지 면밀히 확인한다.\n", + "\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "\"\"\"수질 센서 시계열의 물리 범위 밖 값·고정값(stuck)·단발 스파이크를 정제하고 결측을 보정하는 전처리 모듈을 구현한 뒤, 처리 속도·오류율·보정 정확도와 단위 테스트 5종을 확인한다.\"\"\"\n", + "import time\n", + "import numpy as np\n", + "import pandas as pd\n", + "import matplotlib.pyplot as plt\n", + "\n", + "# ---------------------------------------------------------------------------\n", + "# [교육용가상자료] 야전 급수원 가상 수질 센서 데이터 생성기 (실제 부대·위치·검사 결과 없음)\n", + "# 가상 급수원 8곳 x 14일 x 1시간 간격. 실제 수질(true_*)·센서 측정값·오염 사건·센서 고장 정답을 함께 만든다.\n", + "# 기준치는 교육용 가정값이며, 실제 음용 판정은 공인 검사 결과와 의무(예방의학) 담당 기준에 따른다.\n", + "# ---------------------------------------------------------------------------\n", + "PARAMS = [\"ph\", \"turbidity\", \"chlorine\", \"metal\", \"ecoli\"]\n", + "SOURCES = [(\"W01\", \"water_truck\", 1.0, 2.0), (\"W02\", \"water_truck\", 3.5, 1.2), (\"W03\", \"purification_unit\", 2.2, 4.1),\n", + " (\"W04\", \"purification_unit\", 5.0, 3.3), (\"W05\", \"well\", 0.8, 5.6), (\"W06\", \"well\", 4.4, 6.0),\n", + " (\"W07\", \"river_intake\", 6.3, 1.8), (\"W08\", \"river_intake\", 6.9, 4.9)]\n", + "BASE = {\"water_truck\": (7.2, 0.25, 0.75, 1.5), \"purification_unit\": (7.0, 0.20, 0.85, 1.2),\n", + " \"well\": (6.8, 0.45, 0.50, 3.0), \"river_intake\": (7.3, 0.55, 0.60, 2.0)} # pH, 탁도(NTU), 잔류염소(mg/L), 중금속(ug/L)\n", + "LIMITS = {\"ph\": (5.8, 8.5), \"turbidity\": (1.0, 2.0), \"chlorine\": (0.2, 0.1), \"metal\": (5.0, 10.0), \"ecoli\": (1, 1)} # (주의, 위험) 가정 기준\n", + "EVENT_TYPES = { # 오염 사건: 변수별 최대 변화량, 최대치 도달 시간(h)\n", + " \"sewage\": ({\"turbidity\": 2.8, \"chlorine\": -0.5, \"ecoli\": 30, \"ph\": -0.3}, 4),\n", + " \"metal\": ({\"metal\": 22.0, \"ph\": -0.5}, 4),\n", + " \"disinfect_fail\": ({\"chlorine\": -0.8, \"ecoli\": 6}, 6),\n", + " \"chemical\": ({\"ph\": 1.7, \"turbidity\": 1.0}, 3),\n", + " \"creeping_metal\": ({\"metal\": 13.0}, 18),\n", + "}\n", + "FAULT_TYPES = [\"stuck\", \"spike\", \"drift\", \"dropout\"]\n", + "NOISE = {\"ph\": 0.04, \"turbidity\": 0.06, \"chlorine\": 0.03, \"metal\": 0.35}\n", + "DIGITS = {\"ph\": 2, \"turbidity\": 2, \"chlorine\": 2, \"metal\": 1}\n", + "\n", + "\n", + "def make_dataset(days=14, seed=7, n_events=6, n_faults=10, events=None, faults=None, sources=None):\n", + " \"\"\"events: [(급수원 번호, 사건 유형, 시작 시각 h, 지속 h, 강도)], faults: [(급수원 번호, 고장 유형, 항목, 시작 h, 지속 h)]\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " sources = sources or SOURCES\n", + " T = days * 24\n", + " tt = np.arange(T)\n", + " hour = tt % 24\n", + " night = (hour < 6) | (hour >= 20)\n", + " rain = np.zeros(T)\n", + " for _ in range(max(1, days // 4)):\n", + " s0, d = int(rng.integers(0, T - 14)), int(rng.integers(6, 14))\n", + " rain[s0:s0 + d] = np.maximum(rain[s0:s0 + d], rng.uniform(0.6, 1.0))\n", + " if events is None:\n", + " events = [(int(rng.integers(len(sources))), str(rng.choice(list(EVENT_TYPES))), int(rng.integers(min(24, T // 4), max(T // 4 + 1, T - 48))),\n", + " int(rng.integers(10, 30)), 1.0) for _ in range(n_events)]\n", + " if faults is None:\n", + " faults = [(int(rng.integers(len(sources))), str(rng.choice(FAULT_TYPES)), str(rng.choice([\"ph\", \"turbidity\", \"chlorine\", \"metal\"])),\n", + " int(rng.integers(12, max(13, T - 30))), int(rng.integers(6, 24))) for _ in range(n_faults)]\n", + " out = []\n", + " for si, (sid, stype, x, y) in enumerate(sources):\n", + " ph0, tb0, cl0, mt0 = BASE[stype]\n", + " tr = {\"ph\": ph0 + 0.08 * np.sin(2 * np.pi * hour / 24) + rng.normal(0, 0.03, T),\n", + " \"turbidity\": tb0 * (1 + 0.1 * rng.normal(0, 1, T)),\n", + " \"chlorine\": (cl0 * np.exp(-0.022 * hour) if stype == \"water_truck\" else cl0 + rng.normal(0, 0.03, T)),\n", + " \"metal\": mt0 + rng.normal(0, 0.2, T), \"ecoli\": np.zeros(T)}\n", + " if stype in (\"river_intake\", \"well\"):\n", + " tr[\"turbidity\"] = tr[\"turbidity\"] + rain * (0.9 if stype == \"river_intake\" else 0.45)\n", + " f_all, etype = np.zeros(T), np.array([\"none\"] * T, dtype=object)\n", + " for (s_i, et, onset, dur, scale) in events:\n", + " if s_i != si:\n", + " continue\n", + " delta, ramp = EVENT_TYPES[et]\n", + " f = np.clip((tt - onset + 1) / ramp, 0, 1) * ((tt >= onset) & (tt < onset + dur + ramp))\n", + " for k, v in delta.items():\n", + " tr[k] = tr[k] + v * f * scale\n", + " etype[f > 0] = et\n", + " f_all = np.maximum(f_all, f)\n", + " tr[\"chlorine\"] = np.clip(tr[\"chlorine\"], 0.02, None)\n", + " tr[\"turbidity\"] = np.clip(tr[\"turbidity\"], 0.05, None)\n", + " tr[\"metal\"] = np.clip(tr[\"metal\"], 0.1, None)\n", + " grade = np.where(f_all >= 0.2, 2, 0)\n", + " mild = (tr[\"turbidity\"] > LIMITS[\"turbidity\"][0]) | (tr[\"chlorine\"] < LIMITS[\"chlorine\"][0]) | (tr[\"metal\"] > LIMITS[\"metal\"][0])\n", + " grade = np.where((grade == 0) & mild, 1, grade)\n", + " ms = {}\n", + " for p in [\"ph\", \"turbidity\", \"chlorine\", \"metal\"]:\n", + " sd = NOISE[p] * np.where(night, 1.5, 1.0) * (tr[p] if p == \"turbidity\" else 1)\n", + " ms[p] = np.round(np.clip(tr[p] + rng.normal(0, 1, T) * sd, 0, None), DIGITS[p])\n", + " ms[\"ecoli\"] = (rng.poisson(tr[\"ecoli\"]) + (rng.random(T) < 0.004)).astype(float)\n", + " fault, fparam = np.array([\"none\"] * T, dtype=object), np.array([\"-\"] * T, dtype=object)\n", + " for (s_i, ft, p, s0, d) in faults:\n", + " if s_i != si:\n", + " continue\n", + " seg = slice(s0, min(T, s0 + d))\n", + " if ft == \"stuck\":\n", + " ms[p][seg] = ms[p][s0]\n", + " elif ft == \"spike\":\n", + " seg = slice(s0, s0 + 1)\n", + " ms[p][s0] = {\"ph\": 11.8, \"turbidity\": 38.0, \"chlorine\": 0.0, \"metal\": 75.0}[p]\n", + " elif ft == \"drift\":\n", + " ramp = np.linspace(0, 1, seg.stop - s0)\n", + " ms[p][seg] = np.clip(ms[p][seg] + ramp * {\"ph\": -1.6, \"turbidity\": 2.6, \"chlorine\": -0.75, \"metal\": 12.0}[p], 0, None).round(DIGITS[p])\n", + " elif ft == \"dropout\":\n", + " for q in PARAMS:\n", + " ms[q][seg] = np.nan\n", + " p = \"all\"\n", + " fault[seg], fparam[seg] = ft, p\n", + " df = pd.DataFrame({\"source_id\": sid, \"source_type\": stype, \"x_km\": x, \"y_km\": y, \"t\": tt,\n", + " \"cond\": np.where(rain > 0, \"storm\", np.where(night, \"night\", \"day\")),\n", + " **{p: ms[p] for p in PARAMS}, **{f\"true_{p}\": tr[p] for p in PARAMS},\n", + " \"event\": etype, \"fault\": fault, \"fault_param\": fparam, \"grade\": grade})\n", + " out.append(df)\n", + " return pd.concat(out, ignore_index=True)\n", + "# ---------------------------------------------------------------------------\n", + "\n", + "# ---- 전처리 공통 함수: 물리 범위 점검 -> 고정값(stuck) 표시 -> 단발 스파이크 제거 -> 결측 보정 ----\n", + "PHYS = {\"ph\": (0, 14), \"turbidity\": (0, 1000), \"chlorine\": (0, 10), \"metal\": (0, 1000), \"ecoli\": (0, 100000)}\n", + "SPIKE_K = {\"day\": 6.0, \"night\": 7.0, \"storm\": 9.0} # 상황별(주간/야간/악천후) 스파이크 판정 배수\n", + "MIN_JUMP = {\"ph\": 0.8, \"turbidity\": 1.5, \"chlorine\": 0.3, \"metal\": 6.0}\n", + "STUCK_RUN, SHORT_GAP = 6, 3\n", + "\n", + "\n", + "def clean(df, spike_k=None, min_jump=None):\n", + " \"\"\"급수원별 시계열을 정제하고 항목별 처리 내역(<항목>_flag: range/stuck/spike/interp/long_gap)을 남긴다.\"\"\"\n", + " # [TODO : 학생 실습 2] 수질 센서 결측치 대체 및 시계열 센서 이상치 정제 전처리 모듈 구현 관련 핵심 로직을 구현하세요.\n", + " # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n", + " # <<< 여기에 코드를 작성하세요 >>>\n", + " pass # 작성 후 pass를 제거하세요.\n", + "data = make_dataset(days=14, seed=7, n_events=8, n_faults=16)\n", + "\n", + "# 1) 처리 속도와 오류율\n", + "t0 = time.perf_counter()\n", + "c = clean(data)\n", + "dt = time.perf_counter() - t0\n", + "remain = int(c[PARAMS].isna().sum().sum())\n", + "print(f\"전처리 {len(data):,}건, {len(data) / dt:,.0f}건/초, 처리 후 남은 결측 {remain}건, 오류율 {remain / len(data):.1%}\")\n", + "\n", + "# 2) 전처리 전후 비교: 센서 고장 유형별로 무엇이 처리되었는가\n", + "flag_cols = [f\"{p}_flag\" for p in PARAMS]\n", + "c[\"처리내역\"] = c[flag_cols].apply(lambda r: \",\".join(sorted({x for x in r if x})) or \"-\", axis=1)\n", + "print(\"\\n[실제 고장 유형 x 전처리 처리 내역] (행 수)\")\n", + "print(pd.crosstab(c[\"fault\"], c[\"처리내역\"]).to_string())\n", + "print(f\"결측 셀 수: 전처리 전 {int(data[PARAMS].isna().sum().sum())} -> 후 {remain}\")\n", + "\n", + "# 3) 결측 보정 정확도: 정상 구간에서 값을 일부 지워 보고, 실제 값과 비교\n", + "rng = np.random.default_rng(3)\n", + "test = data.copy()\n", + "normal_idx = test.index[(test[\"fault\"] == \"none\") & (test[\"event\"] == \"none\")]\n", + "holes = rng.choice(normal_idx, 120, replace=False)\n", + "test.loc[holes, \"chlorine\"] = np.nan\n", + "filled = clean(test).set_index([\"source_id\", \"t\"])\n", + "truth = data.loc[holes].set_index([\"source_id\", \"t\"])[\"true_chlorine\"]\n", + "mae = (filled.loc[truth.index, \"chlorine\"] - truth).abs().mean()\n", + "mean_fill = (data[\"chlorine\"].mean() - truth).abs().mean()\n", + "print(f\"\\n잔류염소 결측 120건 보정 오차(MAE): 선형 보간 {mae:.3f} mg/L vs 전체 평균 대체 {mean_fill:.3f} mg/L\")\n", + "\n", + "# 4) 단위 테스트 5종\n", + "def unit_tests():\n", + " # [TODO : 학생 실습 2] 수질 센서 결측치 대체 및 시계열 센서 이상치 정제 전처리 모듈 구현 관련 핵심 로직을 구현하세요.\n", + " # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n", + " # <<< 여기에 코드를 작성하세요 >>>\n", + " pass # 작성 후 pass를 제거하세요.\n", + "res = unit_tests()\n", + "print(\"\\n[단위 테스트]\")\n", + "for k, v in res.items():\n", + " print(f\" [{'PASS' if v else 'FAIL'}] {k}\")\n", + "print(f\"단위 테스트 {sum(res.values())}/5 통과\")\n", + "\n", + "# 5) 상황별(주간/야간/악천후) 임계값 동적 조정 효과: 최소 변화폭 하한을 끄고 배수만 비교\n", + "# (고장이 없는 행이 스파이크로 잘못 제거된 건수 / 실제 스파이크 고장 검출 건수)\n", + "no_floor = {p: 0.0 for p in MIN_JUMP}\n", + "for name, k in [(\"고정 임계(모든 상황 3배)\", {\"day\": 3.0, \"night\": 3.0, \"storm\": 3.0}), (\"상황별 동적 임계\", SPIKE_K)]:\n", + " cc = clean(data, spike_k=k, min_jump=no_floor)\n", + " sp = (cc[flag_cols] == \"spike\").any(axis=1)\n", + " wrong, caught = int((sp & (cc[\"fault\"] != \"spike\")).sum()), int((sp & (cc[\"fault\"] == \"spike\")).sum())\n", + " print(f\"{name}: 정상 변동 오제거 {wrong}건(야간·악천후 {int((sp & (cc['fault'] != 'spike') & (cc['cond'] != 'day')).sum())}건), 스파이크 검출 {caught}건\")\n", + "print(\"운용 설정은 동적 임계 + 항목별 최소 변화폭 하한(MIN_JUMP)을 함께 쓴다.\")\n", + "\n", + "# 6) 전후 비교 그림\n", + "sid = data.loc[data[\"fault\"].isin([\"spike\", \"dropout\"]), \"source_id\"].iloc[0]\n", + "raw, cl = data[data.source_id == sid], c[c.source_id == sid]\n", + "fig, ax = plt.subplots(2, 1, figsize=(10, 6), sharex=True)\n", + "for a, p in zip(ax, [\"turbidity\", \"chlorine\"]):\n", + " a.plot(raw[\"t\"], raw[p], color=\"lightgray\", lw=3, label=\"raw sensor\")\n", + " a.plot(cl[\"t\"], cl[p], color=\"tab:blue\", lw=1, label=\"cleaned\")\n", + " a.set_ylabel(p)\n", + " a.legend(loc=\"upper right\")\n", + "ax[1].set_xlabel(\"hour\")\n", + "plt.suptitle(f\"{sid} before / after preprocessing (synthetic)\")\n", + "plt.tight_layout()\n", + "plt.show()\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 02 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + "전처리 2,688건, 22,643건/초, 처리 후 남은 결측 0건, 오류율 0.0%\n", + "\n", + "[실제 고장 유형 x 전처리 처리 내역] (행 수)\n", + "처리내역 - long_gap spike stuck\n", + "fault \n", + "drift 78 8 0 0\n", + "dropout 0 17 0 0\n", + "none 2485 0 0 0\n", + "spike 1 0 2 0\n", + "stuck 6 0 0 91\n", + "결측 셀 수: 전처리 전 125 -> 후 0\n", + "\n", + "잔류염소 결측 120건 보정 오차(MAE): 선형 보간 0.042 mg/L vs 전체 평균 대체 0.122 mg/L\n", + "\n", + "[단위 테스트]\n", + " [PASS] T1 물리 범위 밖 pH 15.0 제거 후 보정\n", + " [PASS] T2 단발 스파이크(탁도 38) 제거\n", + " [PASS] T3 지속 상승(하수 유입)은 보존\n", + " [PASS] T4 10시간 고정값 -> stuck 표시\n", + " [PASS] T5 2시간 결측 보간 / 8시간 결측 표시\n", + "단위 테스트 5/5 통과\n", + "고정 임계(모든 상황 3배): 정상 변동 오제거 7건(야간·악천후 6건), 스파이크 검출 3건\n", + "상황별 동적 임계: 정상 변동 오제거 1건(야간·악천후 0건), 스파이크 검출 2건\n", + "운용 설정은 동적 임계 + 항목별 최소 변화폭 하한(MIN_JUMP)을 함께 쓴다.\n", + "```\n", + "
\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 03. 음용 적합/부적합 판정 1차 기준치 필터링 Baseline 알고리즘 구현\n", + "**소요시간**: 약 2.5시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "음용 적합/부적합 판정 1차 기준치 필터링 Baseline 알고리즘을 구현한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **음용 적합·부적합 판정의 1차 기준치 필터링 Baseline 알고리즘을 구현하고 정확도를 측정한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **전처리한 자료에 pH, 탁도, 잔류염소, 중금속, 대장균의 주의·위험 가정 기준을 적용해 안전·주의·위험 3등급 판정하고 정확도를 측정하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `src/models/wel03_baseline_model.py 및 기본 추론 결과 보고서` 형태의 결과물을 완성한다.\n", + "\n", + "### 🔍 평가 및 검증 기준 (PASS 조건)\n", + "#### 미션 판정\n", + "\n", + "**평가 구분:** 필수\n", + "\n", + "**핵심 평가영역:** 알고리즘의 논리적 흐름 및 군사적 판단 기준의 투명성\n", + "\n", + "#### 미션 PASS 조건\n", + "\n", + "- 정량 기준: 기본 데이터셋 대상 탐지/처리 정확도 75% 이상 달성\n", + "\n", + "\n", + "- 진행 기준: 단위 기능 동작 확인 및 기본 시나리오 실행 로그 생성\n", + "\n", + "\n", + "- 검증 방법: 기준 벤치마크 데이터셋 대상 추론 결과 오차율 비교\n", + "\n", + "\n", + "#### 채점자 판정 메모\n", + "\n", + "> 단순 코드 실행 여부뿐만 아니라 출력된 수치와 근거 자료가 군 실무 요구사항에 부합하는지 면밀히 확인한다.\n", + "\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "\"\"\"가정 기준치(주의·위험)를 넘는 항목이 있으면 음용 부적합 의심으로 판정하는 1차 필터링 Baseline 알고리즘을 구현하고, 기준 벤치마크 데이터에서 정확도·오차 유형·판정 근거·처리 지연을 확인한다.\"\"\"\n", + "import time\n", + "import numpy as np\n", + "import pandas as pd\n", + "import matplotlib.pyplot as plt\n", + "\n", + "# ---------------------------------------------------------------------------\n", + "# [교육용가상자료] 야전 급수원 가상 수질 센서 데이터 생성기 (실제 부대·위치·검사 결과 없음)\n", + "# 가상 급수원 8곳 x 14일 x 1시간 간격. 실제 수질(true_*)·센서 측정값·오염 사건·센서 고장 정답을 함께 만든다.\n", + "# 기준치는 교육용 가정값이며, 실제 음용 판정은 공인 검사 결과와 의무(예방의학) 담당 기준에 따른다.\n", + "# ---------------------------------------------------------------------------\n", + "PARAMS = [\"ph\", \"turbidity\", \"chlorine\", \"metal\", \"ecoli\"]\n", + "SOURCES = [(\"W01\", \"water_truck\", 1.0, 2.0), (\"W02\", \"water_truck\", 3.5, 1.2), (\"W03\", \"purification_unit\", 2.2, 4.1),\n", + " (\"W04\", \"purification_unit\", 5.0, 3.3), (\"W05\", \"well\", 0.8, 5.6), (\"W06\", \"well\", 4.4, 6.0),\n", + " (\"W07\", \"river_intake\", 6.3, 1.8), (\"W08\", \"river_intake\", 6.9, 4.9)]\n", + "BASE = {\"water_truck\": (7.2, 0.25, 0.75, 1.5), \"purification_unit\": (7.0, 0.20, 0.85, 1.2),\n", + " \"well\": (6.8, 0.45, 0.50, 3.0), \"river_intake\": (7.3, 0.55, 0.60, 2.0)} # pH, 탁도(NTU), 잔류염소(mg/L), 중금속(ug/L)\n", + "LIMITS = {\"ph\": (5.8, 8.5), \"turbidity\": (1.0, 2.0), \"chlorine\": (0.2, 0.1), \"metal\": (5.0, 10.0), \"ecoli\": (1, 1)} # (주의, 위험) 가정 기준\n", + "EVENT_TYPES = { # 오염 사건: 변수별 최대 변화량, 최대치 도달 시간(h)\n", + " \"sewage\": ({\"turbidity\": 2.8, \"chlorine\": -0.5, \"ecoli\": 30, \"ph\": -0.3}, 4),\n", + " \"metal\": ({\"metal\": 22.0, \"ph\": -0.5}, 4),\n", + " \"disinfect_fail\": ({\"chlorine\": -0.8, \"ecoli\": 6}, 6),\n", + " \"chemical\": ({\"ph\": 1.7, \"turbidity\": 1.0}, 3),\n", + " \"creeping_metal\": ({\"metal\": 13.0}, 18),\n", + "}\n", + "FAULT_TYPES = [\"stuck\", \"spike\", \"drift\", \"dropout\"]\n", + "NOISE = {\"ph\": 0.04, \"turbidity\": 0.06, \"chlorine\": 0.03, \"metal\": 0.35}\n", + "DIGITS = {\"ph\": 2, \"turbidity\": 2, \"chlorine\": 2, \"metal\": 1}\n", + "\n", + "\n", + "def make_dataset(days=14, seed=7, n_events=6, n_faults=10, events=None, faults=None, sources=None):\n", + " \"\"\"events: [(급수원 번호, 사건 유형, 시작 시각 h, 지속 h, 강도)], faults: [(급수원 번호, 고장 유형, 항목, 시작 h, 지속 h)]\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " sources = sources or SOURCES\n", + " T = days * 24\n", + " tt = np.arange(T)\n", + " hour = tt % 24\n", + " night = (hour < 6) | (hour >= 20)\n", + " rain = np.zeros(T)\n", + " for _ in range(max(1, days // 4)):\n", + " s0, d = int(rng.integers(0, T - 14)), int(rng.integers(6, 14))\n", + " rain[s0:s0 + d] = np.maximum(rain[s0:s0 + d], rng.uniform(0.6, 1.0))\n", + " if events is None:\n", + " events = [(int(rng.integers(len(sources))), str(rng.choice(list(EVENT_TYPES))), int(rng.integers(min(24, T // 4), max(T // 4 + 1, T - 48))),\n", + " int(rng.integers(10, 30)), 1.0) for _ in range(n_events)]\n", + " if faults is None:\n", + " faults = [(int(rng.integers(len(sources))), str(rng.choice(FAULT_TYPES)), str(rng.choice([\"ph\", \"turbidity\", \"chlorine\", \"metal\"])),\n", + " int(rng.integers(12, max(13, T - 30))), int(rng.integers(6, 24))) for _ in range(n_faults)]\n", + " out = []\n", + " for si, (sid, stype, x, y) in enumerate(sources):\n", + " ph0, tb0, cl0, mt0 = BASE[stype]\n", + " tr = {\"ph\": ph0 + 0.08 * np.sin(2 * np.pi * hour / 24) + rng.normal(0, 0.03, T),\n", + " \"turbidity\": tb0 * (1 + 0.1 * rng.normal(0, 1, T)),\n", + " \"chlorine\": (cl0 * np.exp(-0.022 * hour) if stype == \"water_truck\" else cl0 + rng.normal(0, 0.03, T)),\n", + " \"metal\": mt0 + rng.normal(0, 0.2, T), \"ecoli\": np.zeros(T)}\n", + " if stype in (\"river_intake\", \"well\"):\n", + " tr[\"turbidity\"] = tr[\"turbidity\"] + rain * (0.9 if stype == \"river_intake\" else 0.45)\n", + " f_all, etype = np.zeros(T), np.array([\"none\"] * T, dtype=object)\n", + " for (s_i, et, onset, dur, scale) in events:\n", + " if s_i != si:\n", + " continue\n", + " delta, ramp = EVENT_TYPES[et]\n", + " f = np.clip((tt - onset + 1) / ramp, 0, 1) * ((tt >= onset) & (tt < onset + dur + ramp))\n", + " for k, v in delta.items():\n", + " tr[k] = tr[k] + v * f * scale\n", + " etype[f > 0] = et\n", + " f_all = np.maximum(f_all, f)\n", + " tr[\"chlorine\"] = np.clip(tr[\"chlorine\"], 0.02, None)\n", + " tr[\"turbidity\"] = np.clip(tr[\"turbidity\"], 0.05, None)\n", + " tr[\"metal\"] = np.clip(tr[\"metal\"], 0.1, None)\n", + " grade = np.where(f_all >= 0.2, 2, 0)\n", + " mild = (tr[\"turbidity\"] > LIMITS[\"turbidity\"][0]) | (tr[\"chlorine\"] < LIMITS[\"chlorine\"][0]) | (tr[\"metal\"] > LIMITS[\"metal\"][0])\n", + " grade = np.where((grade == 0) & mild, 1, grade)\n", + " ms = {}\n", + " for p in [\"ph\", \"turbidity\", \"chlorine\", \"metal\"]:\n", + " sd = NOISE[p] * np.where(night, 1.5, 1.0) * (tr[p] if p == \"turbidity\" else 1)\n", + " ms[p] = np.round(np.clip(tr[p] + rng.normal(0, 1, T) * sd, 0, None), DIGITS[p])\n", + " ms[\"ecoli\"] = (rng.poisson(tr[\"ecoli\"]) + (rng.random(T) < 0.004)).astype(float)\n", + " fault, fparam = np.array([\"none\"] * T, dtype=object), np.array([\"-\"] * T, dtype=object)\n", + " for (s_i, ft, p, s0, d) in faults:\n", + " if s_i != si:\n", + " continue\n", + " seg = slice(s0, min(T, s0 + d))\n", + " if ft == \"stuck\":\n", + " ms[p][seg] = ms[p][s0]\n", + " elif ft == \"spike\":\n", + " seg = slice(s0, s0 + 1)\n", + " ms[p][s0] = {\"ph\": 11.8, \"turbidity\": 38.0, \"chlorine\": 0.0, \"metal\": 75.0}[p]\n", + " elif ft == \"drift\":\n", + " ramp = np.linspace(0, 1, seg.stop - s0)\n", + " ms[p][seg] = np.clip(ms[p][seg] + ramp * {\"ph\": -1.6, \"turbidity\": 2.6, \"chlorine\": -0.75, \"metal\": 12.0}[p], 0, None).round(DIGITS[p])\n", + " elif ft == \"dropout\":\n", + " for q in PARAMS:\n", + " ms[q][seg] = np.nan\n", + " p = \"all\"\n", + " fault[seg], fparam[seg] = ft, p\n", + " df = pd.DataFrame({\"source_id\": sid, \"source_type\": stype, \"x_km\": x, \"y_km\": y, \"t\": tt,\n", + " \"cond\": np.where(rain > 0, \"storm\", np.where(night, \"night\", \"day\")),\n", + " **{p: ms[p] for p in PARAMS}, **{f\"true_{p}\": tr[p] for p in PARAMS},\n", + " \"event\": etype, \"fault\": fault, \"fault_param\": fparam, \"grade\": grade})\n", + " out.append(df)\n", + " return pd.concat(out, ignore_index=True)\n", + "# ---------------------------------------------------------------------------\n", + "\n", + "# ---- 전처리 공통 함수: 물리 범위 점검 -> 고정값(stuck) 표시 -> 단발 스파이크 제거 -> 결측 보정 ----\n", + "PHYS = {\"ph\": (0, 14), \"turbidity\": (0, 1000), \"chlorine\": (0, 10), \"metal\": (0, 1000), \"ecoli\": (0, 100000)}\n", + "SPIKE_K = {\"day\": 6.0, \"night\": 7.0, \"storm\": 9.0} # 상황별(주간/야간/악천후) 스파이크 판정 배수\n", + "MIN_JUMP = {\"ph\": 0.8, \"turbidity\": 1.5, \"chlorine\": 0.3, \"metal\": 6.0}\n", + "STUCK_RUN, SHORT_GAP = 6, 3\n", + "\n", + "\n", + "def clean(df, spike_k=None, min_jump=None):\n", + " \"\"\"급수원별 시계열을 정제하고 항목별 처리 내역(<항목>_flag: range/stuck/spike/interp/long_gap)을 남긴다.\"\"\"\n", + " # [TODO : 학생 실습 3] 음용 적합/부적합 판정 1차 기준치 필터링 Baseline 알고리즘 구현 관련 핵심 로직을 구현하세요.\n", + " # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n", + " # <<< 여기에 코드를 작성하세요 >>>\n", + " pass # 작성 후 pass를 제거하세요.\n", + "GRADE_NAME = {0: \"안전\", 1: \"주의\", 2: \"위험\"}\n", + "\n", + "\n", + "def rule_grade(df):\n", + " \"\"\"기준치 1차 필터: 위험(음용 부적합 의심) = 한 항목이라도 위험 기준 초과, 주의 = 주의 기준 초과.\"\"\"\n", + " # [TODO : 학생 실습 3] 음용 적합/부적합 판정 1차 기준치 필터링 Baseline 알고리즘 구현 관련 핵심 로직을 구현하세요.\n", + " # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n", + " # <<< 여기에 코드를 작성하세요 >>>\n", + " pass # 작성 후 pass를 제거하세요.\n", + "def reasons(row):\n", + " r = []\n", + " if not LIMITS[\"ph\"][0] <= row[\"ph\"] <= LIMITS[\"ph\"][1]:\n", + " r.append(f\"pH {row['ph']:.2f}\")\n", + " if row[\"turbidity\"] > LIMITS[\"turbidity\"][0]:\n", + " r.append(f\"탁도 {row['turbidity']:.2f}NTU\")\n", + " if row[\"chlorine\"] < LIMITS[\"chlorine\"][0]:\n", + " r.append(f\"잔류염소 {row['chlorine']:.2f}mg/L\")\n", + " if row[\"metal\"] > LIMITS[\"metal\"][0]:\n", + " r.append(f\"중금속 {row['metal']:.1f}ug/L\")\n", + " if row[\"ecoli\"] >= 1:\n", + " r.append(f\"대장균 {row['ecoli']:.0f}\")\n", + " return \", \".join(r) or \"-\"\n", + "\n", + "\n", + "FEATS = [f\"{p}{s}\" for p in [\"ph\", \"turbidity\", \"chlorine\", \"metal\"] for s in [\"\", \"_d1\", \"_d3\", \"_dev\", \"_sd6\"]] + \\\n", + " [\"ecoli\", \"ecoli_3h\", \"storm\", \"night\", \"n_flag\", \"truck\", \"purif\", \"well\", \"river\"]\n", + "\n", + "\n", + "def add_features(c):\n", + " \"\"\"시간 순서를 지키는(과거 값만 쓰는) 급수원별 특징: 변화량, 24시간 기준선 대비 편차, 6시간 변동성, 대장균 누적 등.\"\"\"\n", + " c = c.sort_values([\"source_id\", \"t\"]).reset_index(drop=True)\n", + " g = c.groupby(\"source_id\")\n", + " for p in [\"ph\", \"turbidity\", \"chlorine\", \"metal\"]:\n", + " c[f\"{p}_d1\"] = g[p].diff().fillna(0)\n", + " c[f\"{p}_d3\"] = g[p].diff(3).fillna(0)\n", + " base = g[p].transform(lambda s: s.shift(1).rolling(24, min_periods=4).median())\n", + " c[f\"{p}_dev\"] = (c[p] - base).fillna(0)\n", + " c[f\"{p}_sd6\"] = g[p].transform(lambda s: s.rolling(6, min_periods=2).std()).fillna(0)\n", + " c[\"ecoli_3h\"] = g[\"ecoli\"].transform(lambda s: s.rolling(3, min_periods=1).sum())\n", + " c[\"storm\"], c[\"night\"] = (c[\"cond\"] == \"storm\").astype(int), (c[\"cond\"] == \"night\").astype(int)\n", + " flags = [f\"{p}_flag\" for p in PARAMS if f\"{p}_flag\" in c]\n", + " c[\"n_flag\"] = sum(c[f].isin([\"stuck\", \"spike\", \"long_gap\", \"range\"]).astype(int) for f in flags) if flags else 0\n", + " for k, name in [(\"truck\", \"water_truck\"), (\"purif\", \"purification_unit\"), (\"well\", \"well\"), (\"river\", \"river_intake\")]:\n", + " c[k] = (c[\"source_type\"] == name).astype(int)\n", + " return c\n", + "\n", + "\n", + "# 기준 벤치마크 데이터셋: 가상 급수원 8곳 x 14일, 오염 사건 10건, 센서 고장 14건 (정답 등급 grade 포함)\n", + "bench = make_dataset(days=14, seed=11, n_events=10, n_faults=14)\n", + "truth_raw = bench.sort_values([\"source_id\", \"t\"])[\"grade\"].to_numpy()\n", + "\n", + "# 1) 전처리 없이(결측은 0으로) vs 전처리 후 Baseline 판정\n", + "t0 = time.perf_counter()\n", + "c = clean(bench)\n", + "c[\"pred\"] = rule_grade(c)\n", + "dt = time.perf_counter() - t0\n", + "raw = bench.sort_values([\"source_id\", \"t\"]).fillna({p: 0 for p in PARAMS})\n", + "acc_raw = (rule_grade(raw) == truth_raw).mean()\n", + "acc = (c[\"pred\"] == c[\"grade\"]).mean()\n", + "print(f\"[교육용가상자료] 벤치마크 {len(c):,}건(급수원 8곳 x 336시간)\")\n", + "print(f\"정확도: 전처리 없음 {acc_raw:.1%} -> 전처리 + 기준치 필터 {acc:.1%} (목표 75% 이상: {'달성' if acc >= 0.75 else '미달'})\")\n", + "print(f\"처리 지연: 전체 {dt * 1000:.0f}ms, 1건당 {dt / len(c) * 1000:.3f}ms\")\n", + "\n", + "# 2) 혼동행렬과 오차 유형\n", + "cm = pd.crosstab(c[\"grade\"].map(GRADE_NAME), c[\"pred\"].map(GRADE_NAME), rownames=[\"실제\"], colnames=[\"판정\"])\n", + "cm = cm.reindex(index=[\"안전\", \"주의\", \"위험\"], columns=[\"안전\", \"주의\", \"위험\"], fill_value=0)\n", + "print(\"\\n[혼동행렬]\")\n", + "print(cm.to_string())\n", + "danger = c[\"grade\"] == 2\n", + "rec = ((c[\"pred\"] == 2) & danger).sum() / danger.sum()\n", + "far = ((c[\"pred\"] == 2) & ~danger).sum() / max((c[\"pred\"] == 2).sum(), 1)\n", + "print(f\"위험 재현율 {rec:.1%}, 위험 경보 중 오경보 비율 {far:.1%}\")\n", + "miss = c[danger & (c[\"pred\"] != 2)]\n", + "fa = c[~danger & (c[\"pred\"] == 2)]\n", + "print(\"놓친 위험(사건 유형별):\", miss[\"event\"].value_counts().to_dict())\n", + "print(\"오경보(센서 고장 유형별):\", fa[\"fault\"].value_counts().to_dict())\n", + "\n", + "# 3) 판정 근거(투명성): 위험 판정 사례 일부의 초과 항목\n", + "print(\"\\n[위험 판정 근거 예시]\")\n", + "show = c[c[\"pred\"] == 2].groupby(\"source_id\").head(1).head(5)\n", + "for _, r in show.iterrows():\n", + " print(f\" {r['source_id']} D+{int(r['t']) // 24:02d} {int(r['t']) % 24:02d}시: {reasons(r)} -> 실제 {GRADE_NAME[r['grade']]}({r['event']})\")\n", + "\n", + "# 4) 휴리스틱 1차 튜닝: 위험 판정을 '2시간 연속 초과'로 바꾸면 오경보는 줄지만 놓침이 늘어난다\n", + "c[\"pred2\"] = c[\"pred\"]\n", + "prev = c.groupby(\"source_id\")[\"pred\"].shift(1).fillna(0)\n", + "c.loc[(c[\"pred\"] == 2) & (prev < 2), \"pred2\"] = 1\n", + "for name, col in [(\"1시간 즉시 판정\", \"pred\"), (\"2시간 연속 확인\", \"pred2\")]:\n", + " p = c[col]\n", + " print(f\"{name}: 정확도 {(p == c['grade']).mean():.1%}, 위험 재현율 {((p == 2) & danger).sum() / danger.sum():.1%}, \"\n", + " f\"오경보 {int(((p == 2) & ~danger).sum())}건\")\n", + "print(\"-> 음용수는 놓침이 더 위험하므로 Baseline은 즉시 판정을 유지하고, 오경보는 미션 5 신뢰도 지수로 다룬다.\")\n", + "print(\"※ 기준치는 교육용 가정값이다. 실제 음용 적합 판정은 공식 수질 기준과 의무(예방의학)·환경 담당 확인에 따른다.\")\n", + "\n", + "sid = miss[\"source_id\"].mode().iloc[0] if len(miss) else \"W01\"\n", + "s = c[c[\"source_id\"] == sid]\n", + "fig, ax = plt.subplots(figsize=(10, 3.5))\n", + "ax.plot(s[\"t\"], s[\"grade\"], color=\"black\", lw=2, label=\"true grade\")\n", + "ax.plot(s[\"t\"], s[\"pred\"] + 0.05, color=\"tab:red\", lw=1, label=\"baseline grade\")\n", + "ax.set_yticks([0, 1, 2], [\"safe\", \"caution\", \"danger\"]); ax.set_xlabel(\"hour\"); ax.legend()\n", + "ax.set_title(f\"{sid} baseline vs true grade (synthetic)\")\n", + "plt.tight_layout(); plt.show()\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 03 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + "[교육용가상자료] 벤치마크 2,688건(급수원 8곳 x 336시간)\n", + "정확도: 전처리 없음 96.8% -> 전처리 + 기준치 필터 97.9% (목표 75% 이상: 달성)\n", + "처리 지연: 전체 158ms, 1건당 0.059ms\n", + "\n", + "[혼동행렬]\n", + "판정 안전 주의 위험\n", + "실제 \n", + "안전 2369 8 17\n", + "주의 3 45 0\n", + "위험 9 19 218\n", + "위험 재현율 88.6%, 위험 경보 중 오경보 비율 7.2%\n", + "놓친 위험(사건 유형별): {'creeping_metal': 22, 'chemical': 4, 'disinfect_fail': 1, 'metal': 1}\n", + "오경보(센서 고장 유형별): {'none': 11, 'drift': 6}\n", + "\n", + "[위험 판정 근거 예시]\n", + " W01 D+13 20시: 대장균 1 -> 실제 안전(none)\n", + " W02 D+06 03시: pH 9.04, 탁도 1.24NTU -> 실제 위험(chemical)\n", + " W03 D+02 03시: 대장균 1 -> 실제 안전(none)\n", + " W04 D+12 00시: pH 8.65, 탁도 1.16NTU -> 실제 위험(chemical)\n", + " W05 D+00 09시: 대장균 1 -> 실제 안전(none)\n", + "1시간 즉시 판정: 정확도 97.9%, 위험 재현율 88.6%, 오경보 17건\n", + "2시간 연속 확인: 정확도 97.6%, 위험 재현율 85.0%, 오경보 5건\n", + "-> 음용수는 놓침이 더 위험하므로 Baseline은 즉시 판정을 유지하고, 오경보는 미션 5 신뢰도 지수로 다룬다.\n", + "※ 기준치는 교육용 가정값이다. 실제 음용 적합 판정은 공식 수질 기준과 의무(예방의학)·환경 담당 확인에 따른다.\n", + "```\n", + "
\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 04. 복합 오염원 유입 징후 및 급수 안전도 위험 등급 분류 머신러닝 모델 고도화\n", + "**소요시간**: 약 3.0시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "복합 오염원 유입 징후 및 급수 위험 등급 분류 머신러닝 모델을 고도화한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **복합 오염원 유입 징후와 급수 안전도 위험 등급을 분류하는 머신러닝 모델을 고도화하고 방어 로직을 검증한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **변화량, 24시간 기준선 대비 편차, 6시간 변동성, 대장균 누적, 센서 처리 표시 등 29개 특징으로 랜덤 포레스트를 학습하고 Baseline과 비교 수행하고 검증하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `src/models/wel03_advanced_detector.py 및 예외 탐지 테스트 결과` 형태의 결과물을 완성한다.\n", + "\n", + "### 🔍 평가 및 검증 기준 (PASS 조건)\n", + "#### 미션 판정\n", + "\n", + "**평가 구분:** 필수\n", + "\n", + "**핵심 평가영역:** 기만/교란 공격에 대한 방어 로직의 현실성 및 신뢰성\n", + "\n", + "#### 미션 PASS 조건\n", + "\n", + "- 정량 기준: 복합 위협 탐지 재현율(Recall) 85% 이상, 처리 지연 200ms 이하\n", + "\n", + "\n", + "- 진행 기준: 복합 위협 시나리오 10종에 대한 탐지 테스트 통과\n", + "\n", + "\n", + "- 검증 방법: 합성 위협 데이터셋 주입 시 이상치 탐지 정확도 검증\n", + "\n", + "\n", + "#### 채점자 판정 메모\n", + "\n", + "> 단순 코드 실행 여부뿐만 아니라 출력된 수치와 근거 자료가 군 실무 요구사항에 부합하는지 면밀히 확인한다.\n", + "\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "\"\"\"시계열 특징(변화량·기준선 편차·변동성·대장균 누적·센서 처리 표시)으로 급수 안전도 위험 등급(안전·주의·위험)을 분류하는 랜덤 포레스트 모델을 학습하고, 복합 위협 시나리오 10종(오염 사건 7종, 센서 교란 3종)으로 재현율·오경보·처리 지연을 검증한다.\"\"\"\n", + "import time\n", + "import numpy as np\n", + "import pandas as pd\n", + "import matplotlib.pyplot as plt\n", + "from sklearn.ensemble import RandomForestClassifier\n", + "\n", + "# ---------------------------------------------------------------------------\n", + "# [교육용가상자료] 야전 급수원 가상 수질 센서 데이터 생성기 (실제 부대·위치·검사 결과 없음)\n", + "# 가상 급수원 8곳 x 14일 x 1시간 간격. 실제 수질(true_*)·센서 측정값·오염 사건·센서 고장 정답을 함께 만든다.\n", + "# 기준치는 교육용 가정값이며, 실제 음용 판정은 공인 검사 결과와 의무(예방의학) 담당 기준에 따른다.\n", + "# ---------------------------------------------------------------------------\n", + "PARAMS = [\"ph\", \"turbidity\", \"chlorine\", \"metal\", \"ecoli\"]\n", + "SOURCES = [(\"W01\", \"water_truck\", 1.0, 2.0), (\"W02\", \"water_truck\", 3.5, 1.2), (\"W03\", \"purification_unit\", 2.2, 4.1),\n", + " (\"W04\", \"purification_unit\", 5.0, 3.3), (\"W05\", \"well\", 0.8, 5.6), (\"W06\", \"well\", 4.4, 6.0),\n", + " (\"W07\", \"river_intake\", 6.3, 1.8), (\"W08\", \"river_intake\", 6.9, 4.9)]\n", + "BASE = {\"water_truck\": (7.2, 0.25, 0.75, 1.5), \"purification_unit\": (7.0, 0.20, 0.85, 1.2),\n", + " \"well\": (6.8, 0.45, 0.50, 3.0), \"river_intake\": (7.3, 0.55, 0.60, 2.0)} # pH, 탁도(NTU), 잔류염소(mg/L), 중금속(ug/L)\n", + "LIMITS = {\"ph\": (5.8, 8.5), \"turbidity\": (1.0, 2.0), \"chlorine\": (0.2, 0.1), \"metal\": (5.0, 10.0), \"ecoli\": (1, 1)} # (주의, 위험) 가정 기준\n", + "EVENT_TYPES = { # 오염 사건: 변수별 최대 변화량, 최대치 도달 시간(h)\n", + " \"sewage\": ({\"turbidity\": 2.8, \"chlorine\": -0.5, \"ecoli\": 30, \"ph\": -0.3}, 4),\n", + " \"metal\": ({\"metal\": 22.0, \"ph\": -0.5}, 4),\n", + " \"disinfect_fail\": ({\"chlorine\": -0.8, \"ecoli\": 6}, 6),\n", + " \"chemical\": ({\"ph\": 1.7, \"turbidity\": 1.0}, 3),\n", + " \"creeping_metal\": ({\"metal\": 13.0}, 18),\n", + "}\n", + "FAULT_TYPES = [\"stuck\", \"spike\", \"drift\", \"dropout\"]\n", + "NOISE = {\"ph\": 0.04, \"turbidity\": 0.06, \"chlorine\": 0.03, \"metal\": 0.35}\n", + "DIGITS = {\"ph\": 2, \"turbidity\": 2, \"chlorine\": 2, \"metal\": 1}\n", + "\n", + "\n", + "def make_dataset(days=14, seed=7, n_events=6, n_faults=10, events=None, faults=None, sources=None):\n", + " \"\"\"events: [(급수원 번호, 사건 유형, 시작 시각 h, 지속 h, 강도)], faults: [(급수원 번호, 고장 유형, 항목, 시작 h, 지속 h)]\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " sources = sources or SOURCES\n", + " T = days * 24\n", + " tt = np.arange(T)\n", + " hour = tt % 24\n", + " night = (hour < 6) | (hour >= 20)\n", + " rain = np.zeros(T)\n", + " for _ in range(max(1, days // 4)):\n", + " s0, d = int(rng.integers(0, T - 14)), int(rng.integers(6, 14))\n", + " rain[s0:s0 + d] = np.maximum(rain[s0:s0 + d], rng.uniform(0.6, 1.0))\n", + " if events is None:\n", + " events = [(int(rng.integers(len(sources))), str(rng.choice(list(EVENT_TYPES))), int(rng.integers(min(24, T // 4), max(T // 4 + 1, T - 48))),\n", + " int(rng.integers(10, 30)), 1.0) for _ in range(n_events)]\n", + " if faults is None:\n", + " faults = [(int(rng.integers(len(sources))), str(rng.choice(FAULT_TYPES)), str(rng.choice([\"ph\", \"turbidity\", \"chlorine\", \"metal\"])),\n", + " int(rng.integers(12, max(13, T - 30))), int(rng.integers(6, 24))) for _ in range(n_faults)]\n", + " out = []\n", + " for si, (sid, stype, x, y) in enumerate(sources):\n", + " ph0, tb0, cl0, mt0 = BASE[stype]\n", + " tr = {\"ph\": ph0 + 0.08 * np.sin(2 * np.pi * hour / 24) + rng.normal(0, 0.03, T),\n", + " \"turbidity\": tb0 * (1 + 0.1 * rng.normal(0, 1, T)),\n", + " \"chlorine\": (cl0 * np.exp(-0.022 * hour) if stype == \"water_truck\" else cl0 + rng.normal(0, 0.03, T)),\n", + " \"metal\": mt0 + rng.normal(0, 0.2, T), \"ecoli\": np.zeros(T)}\n", + " if stype in (\"river_intake\", \"well\"):\n", + " tr[\"turbidity\"] = tr[\"turbidity\"] + rain * (0.9 if stype == \"river_intake\" else 0.45)\n", + " f_all, etype = np.zeros(T), np.array([\"none\"] * T, dtype=object)\n", + " for (s_i, et, onset, dur, scale) in events:\n", + " if s_i != si:\n", + " continue\n", + " delta, ramp = EVENT_TYPES[et]\n", + " f = np.clip((tt - onset + 1) / ramp, 0, 1) * ((tt >= onset) & (tt < onset + dur + ramp))\n", + " for k, v in delta.items():\n", + " tr[k] = tr[k] + v * f * scale\n", + " etype[f > 0] = et\n", + " f_all = np.maximum(f_all, f)\n", + " tr[\"chlorine\"] = np.clip(tr[\"chlorine\"], 0.02, None)\n", + " tr[\"turbidity\"] = np.clip(tr[\"turbidity\"], 0.05, None)\n", + " tr[\"metal\"] = np.clip(tr[\"metal\"], 0.1, None)\n", + " grade = np.where(f_all >= 0.2, 2, 0)\n", + " mild = (tr[\"turbidity\"] > LIMITS[\"turbidity\"][0]) | (tr[\"chlorine\"] < LIMITS[\"chlorine\"][0]) | (tr[\"metal\"] > LIMITS[\"metal\"][0])\n", + " grade = np.where((grade == 0) & mild, 1, grade)\n", + " ms = {}\n", + " for p in [\"ph\", \"turbidity\", \"chlorine\", \"metal\"]:\n", + " sd = NOISE[p] * np.where(night, 1.5, 1.0) * (tr[p] if p == \"turbidity\" else 1)\n", + " ms[p] = np.round(np.clip(tr[p] + rng.normal(0, 1, T) * sd, 0, None), DIGITS[p])\n", + " ms[\"ecoli\"] = (rng.poisson(tr[\"ecoli\"]) + (rng.random(T) < 0.004)).astype(float)\n", + " fault, fparam = np.array([\"none\"] * T, dtype=object), np.array([\"-\"] * T, dtype=object)\n", + " for (s_i, ft, p, s0, d) in faults:\n", + " if s_i != si:\n", + " continue\n", + " seg = slice(s0, min(T, s0 + d))\n", + " if ft == \"stuck\":\n", + " ms[p][seg] = ms[p][s0]\n", + " elif ft == \"spike\":\n", + " seg = slice(s0, s0 + 1)\n", + " ms[p][s0] = {\"ph\": 11.8, \"turbidity\": 38.0, \"chlorine\": 0.0, \"metal\": 75.0}[p]\n", + " elif ft == \"drift\":\n", + " ramp = np.linspace(0, 1, seg.stop - s0)\n", + " ms[p][seg] = np.clip(ms[p][seg] + ramp * {\"ph\": -1.6, \"turbidity\": 2.6, \"chlorine\": -0.75, \"metal\": 12.0}[p], 0, None).round(DIGITS[p])\n", + " elif ft == \"dropout\":\n", + " for q in PARAMS:\n", + " ms[q][seg] = np.nan\n", + " p = \"all\"\n", + " fault[seg], fparam[seg] = ft, p\n", + " df = pd.DataFrame({\"source_id\": sid, \"source_type\": stype, \"x_km\": x, \"y_km\": y, \"t\": tt,\n", + " \"cond\": np.where(rain > 0, \"storm\", np.where(night, \"night\", \"day\")),\n", + " **{p: ms[p] for p in PARAMS}, **{f\"true_{p}\": tr[p] for p in PARAMS},\n", + " \"event\": etype, \"fault\": fault, \"fault_param\": fparam, \"grade\": grade})\n", + " out.append(df)\n", + " return pd.concat(out, ignore_index=True)\n", + "# ---------------------------------------------------------------------------\n", + "\n", + "# ---- 전처리 공통 함수: 물리 범위 점검 -> 고정값(stuck) 표시 -> 단발 스파이크 제거 -> 결측 보정 ----\n", + "PHYS = {\"ph\": (0, 14), \"turbidity\": (0, 1000), \"chlorine\": (0, 10), \"metal\": (0, 1000), \"ecoli\": (0, 100000)}\n", + "SPIKE_K = {\"day\": 6.0, \"night\": 7.0, \"storm\": 9.0} # 상황별(주간/야간/악천후) 스파이크 판정 배수\n", + "MIN_JUMP = {\"ph\": 0.8, \"turbidity\": 1.5, \"chlorine\": 0.3, \"metal\": 6.0}\n", + "STUCK_RUN, SHORT_GAP = 6, 3\n", + "\n", + "\n", + "def clean(df, spike_k=None, min_jump=None):\n", + " \"\"\"급수원별 시계열을 정제하고 항목별 처리 내역(<항목>_flag: range/stuck/spike/interp/long_gap)을 남긴다.\"\"\"\n", + " # [TODO : 학생 실습 4] 복합 오염원 유입 징후 및 급수 안전도 위험 등급 분류 머신러닝 모델 고도화 관련 핵심 로직을 구현하세요.\n", + " # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n", + " # <<< 여기에 코드를 작성하세요 >>>\n", + " pass # 작성 후 pass를 제거하세요.\n", + "GRADE_NAME = {0: \"안전\", 1: \"주의\", 2: \"위험\"}\n", + "\n", + "\n", + "def rule_grade(df):\n", + " \"\"\"기준치 1차 필터: 위험(음용 부적합 의심) = 한 항목이라도 위험 기준 초과, 주의 = 주의 기준 초과.\"\"\"\n", + " # [TODO : 학생 실습 4] 복합 오염원 유입 징후 및 급수 안전도 위험 등급 분류 머신러닝 모델 고도화 관련 핵심 로직을 구현하세요.\n", + " # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n", + " # <<< 여기에 코드를 작성하세요 >>>\n", + " pass # 작성 후 pass를 제거하세요.\n", + "def reasons(row):\n", + " r = []\n", + " if not LIMITS[\"ph\"][0] <= row[\"ph\"] <= LIMITS[\"ph\"][1]:\n", + " r.append(f\"pH {row['ph']:.2f}\")\n", + " if row[\"turbidity\"] > LIMITS[\"turbidity\"][0]:\n", + " r.append(f\"탁도 {row['turbidity']:.2f}NTU\")\n", + " if row[\"chlorine\"] < LIMITS[\"chlorine\"][0]:\n", + " r.append(f\"잔류염소 {row['chlorine']:.2f}mg/L\")\n", + " if row[\"metal\"] > LIMITS[\"metal\"][0]:\n", + " r.append(f\"중금속 {row['metal']:.1f}ug/L\")\n", + " if row[\"ecoli\"] >= 1:\n", + " r.append(f\"대장균 {row['ecoli']:.0f}\")\n", + " return \", \".join(r) or \"-\"\n", + "\n", + "\n", + "FEATS = [f\"{p}{s}\" for p in [\"ph\", \"turbidity\", \"chlorine\", \"metal\"] for s in [\"\", \"_d1\", \"_d3\", \"_dev\", \"_sd6\"]] + \\\n", + " [\"ecoli\", \"ecoli_3h\", \"storm\", \"night\", \"n_flag\", \"truck\", \"purif\", \"well\", \"river\"]\n", + "\n", + "\n", + "def add_features(c):\n", + " \"\"\"시간 순서를 지키는(과거 값만 쓰는) 급수원별 특징: 변화량, 24시간 기준선 대비 편차, 6시간 변동성, 대장균 누적 등.\"\"\"\n", + " c = c.sort_values([\"source_id\", \"t\"]).reset_index(drop=True)\n", + " g = c.groupby(\"source_id\")\n", + " for p in [\"ph\", \"turbidity\", \"chlorine\", \"metal\"]:\n", + " c[f\"{p}_d1\"] = g[p].diff().fillna(0)\n", + " c[f\"{p}_d3\"] = g[p].diff(3).fillna(0)\n", + " base = g[p].transform(lambda s: s.shift(1).rolling(24, min_periods=4).median())\n", + " c[f\"{p}_dev\"] = (c[p] - base).fillna(0)\n", + " c[f\"{p}_sd6\"] = g[p].transform(lambda s: s.rolling(6, min_periods=2).std()).fillna(0)\n", + " c[\"ecoli_3h\"] = g[\"ecoli\"].transform(lambda s: s.rolling(3, min_periods=1).sum())\n", + " c[\"storm\"], c[\"night\"] = (c[\"cond\"] == \"storm\").astype(int), (c[\"cond\"] == \"night\").astype(int)\n", + " flags = [f\"{p}_flag\" for p in PARAMS if f\"{p}_flag\" in c]\n", + " c[\"n_flag\"] = sum(c[f].isin([\"stuck\", \"spike\", \"long_gap\", \"range\"]).astype(int) for f in flags) if flags else 0\n", + " for k, name in [(\"truck\", \"water_truck\"), (\"purif\", \"purification_unit\"), (\"well\", \"well\"), (\"river\", \"river_intake\")]:\n", + " c[k] = (c[\"source_type\"] == name).astype(int)\n", + " return c\n", + "\n", + "\n", + "def prepare(df):\n", + " c = add_features(clean(df))\n", + " c[\"rule\"] = rule_grade(c)\n", + " return c\n", + "\n", + "\n", + "# 1) 학습(가상 2주 x 2세트)과 평가(벤치마크 seed 11)\n", + "train = pd.concat([prepare(make_dataset(seed=s, n_events=14, n_faults=14)) for s in (7, 8)], ignore_index=True)\n", + "test = prepare(make_dataset(seed=11, n_events=10, n_faults=14))\n", + "model = RandomForestClassifier(n_estimators=200, max_depth=10, class_weight=\"balanced_subsample\", random_state=0, n_jobs=1)\n", + "model.fit(train[FEATS], train[\"grade\"])\n", + "t0 = time.perf_counter()\n", + "test[\"ml\"] = model.predict(test[FEATS])\n", + "batch_ms = (time.perf_counter() - t0) * 1000\n", + "t0 = time.perf_counter()\n", + "for i in range(20):\n", + " model.predict(test[FEATS].iloc[[i]])\n", + "single_ms = (time.perf_counter() - t0) / 20 * 1000\n", + "\n", + "d = test[\"grade\"] == 2\n", + "print(f\"[교육용가상자료] 학습 {len(train):,}건 / 평가 {len(test):,}건, 특징 {len(FEATS)}개\")\n", + "for name, col in [(\"Baseline 기준치\", \"rule\"), (\"랜덤 포레스트\", \"ml\")]:\n", + " p = test[col]\n", + " print(f\"{name}: 정확도 {(p == test['grade']).mean():.1%}, 위험 재현율 {((p == 2) & d).sum() / d.sum():.1%}, \"\n", + " f\"위험 오경보 {int(((p == 2) & ~d).sum())}건\")\n", + "rec = ((test[\"ml\"] == 2) & d).sum() / d.sum()\n", + "print(f\"복합 위협 재현율 {rec:.1%} (목표 85% 이상: {'달성' if rec >= 0.85 else '미달'}), \"\n", + " f\"처리 지연 1건 {single_ms:.1f}ms / 전체 {batch_ms:.0f}ms (목표 200ms 이하: {'달성' if single_ms <= 200 else '미달'})\")\n", + "print(\"Baseline이 놓친 서서히 오르는 중금속(creeping_metal) 중 모델 검출:\",\n", + " f\"{int(((test['ml'] == 2) & d & (test['rule'] != 2) & (test['event'] == 'creeping_metal')).sum())}\"\n", + " f\"/{int((d & (test['rule'] != 2) & (test['event'] == 'creeping_metal')).sum())}시간\")\n", + "\n", + "# 2) 오경보 유발 패턴: 위험 오경보가 어떤 센서 고장에서 나왔는가 -> 특징 공간 재설계 단서\n", + "fa = test[(test[\"ml\"] == 2) & ~d]\n", + "print(\"모델 위험 오경보의 센서 고장 유형:\", fa[\"fault\"].value_counts().to_dict(), \"-> drift(서서히 틀어짐)는 미션 5 교차 검증으로 보완\")\n", + "imp = pd.Series(model.feature_importances_, FEATS).sort_values(ascending=False).head(6)\n", + "print(\"중요 특징 Top6:\", \", \".join(f\"{k} {v:.2f}\" for k, v in imp.items()))\n", + "\n", + "# 3) 복합 위협 시나리오 10종: (급수원, 사건) / 센서 교란은 오염이 없으므로 위험 경보가 나오면 안 된다\n", + "SCEN = [(\"하수 유입\", [(6, \"sewage\", 30, 14, 1.0)], []), (\"중금속 급증\", [(4, \"metal\", 30, 14, 1.0)], []),\n", + " (\"소독 실패\", [(0, \"disinfect_fail\", 30, 14, 1.0)], []), (\"화학 오염(pH 급변)\", [(2, \"chemical\", 30, 14, 1.0)], []),\n", + " (\"중금속 서서히 증가\", [(5, \"creeping_metal\", 20, 24, 1.0)], []),\n", + " (\"하수+중금속 동시\", [(7, \"sewage\", 30, 14, 1.0), (7, \"metal\", 34, 10, 1.0)], []),\n", + " (\"약한 하수 유입(강도 60%)\", [(3, \"sewage\", 30, 14, 0.6)], []),\n", + " (\"교란: 탁도 센서 드리프트\", [], [(1, \"drift\", \"turbidity\", 24, 18)]),\n", + " (\"교란: 잔류염소 단발 스파이크\", [], [(0, \"spike\", \"chlorine\", 40, 1)]),\n", + " (\"교란: 통신 단절(전 항목 결측)\", [], [(6, \"dropout\", \"ph\", 30, 10)])]\n", + "rows = []\n", + "for name, ev, ft in SCEN:\n", + " c = prepare(make_dataset(days=3, seed=21, events=ev, faults=ft))\n", + " c[\"ml\"] = model.predict(c[FEATS])\n", + " if ev:\n", + " sid = SOURCES[ev[0][0]][0]; on = ev[0][2]\n", + " hit = c[(c[\"source_id\"] == sid) & (c[\"ml\"] == 2) & (c[\"t\"] >= on)]\n", + " lag = int(hit[\"t\"].min() - on) if len(hit) else None\n", + " ok = lag is not None and lag <= 8\n", + " rows.append((name, \"오염\", f\"{lag}h\" if lag is not None else \"미탐\", \"PASS\" if ok else \"FAIL\"))\n", + " else:\n", + " sid = SOURCES[ft[0][0]][0]\n", + " n = int(((c[\"source_id\"] == sid) & (c[\"ml\"] == 2)).sum())\n", + " rows.append((name, \"교란\", f\"위험 오경보 {n}h\", \"PASS\" if n <= 2 else \"FAIL\"))\n", + "res = pd.DataFrame(rows, columns=[\"시나리오\", \"구분\", \"탐지 지연/오경보\", \"판정\"])\n", + "print(\"\\n[복합 위협 시나리오 10종]\")\n", + "print(res.to_string(index=False))\n", + "n_pass = int((res[\"판정\"] == \"PASS\").sum())\n", + "print(f\"통과 {n_pass}/10 (오염: 시작 후 8시간 내 위험 판정, 교란: 위험 오경보 2시간 이하)\")\n", + "\n", + "plt.figure(figsize=(8, 4))\n", + "imp.iloc[::-1].plot.barh(color=\"tab:blue\")\n", + "plt.title(\"Random forest feature importance (top 6, synthetic)\"); plt.xlabel(\"importance\")\n", + "plt.tight_layout(); plt.show()\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 04 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + "[교육용가상자료] 학습 5,376건 / 평가 2,688건, 특징 29개\n", + "Baseline 기준치: 정확도 97.9%, 위험 재현율 88.6%, 위험 오경보 17건\n", + "랜덤 포레스트: 정확도 99.3%, 위험 재현율 97.2%, 위험 오경보 10건\n", + "복합 위협 재현율 97.2% (목표 85% 이상: 달성), 처리 지연 1건 20.7ms / 전체 49ms (목표 200ms 이하: 달성)\n", + "Baseline이 놓친 서서히 오르는 중금속(creeping_metal) 중 모델 검출: 17/22시간\n", + "모델 위험 오경보의 센서 고장 유형: {'drift': 10} -> drift(서서히 틀어짐)는 미션 5 교차 검증으로 보완\n", + "중요 특징 Top6: storm 0.17, turbidity 0.15, metal 0.10, turbidity_dev 0.09, ph 0.07, river 0.06\n", + "\n", + "[복합 위협 시나리오 10종]\n", + " 시나리오 구분 탐지 지연/오경보 판정\n", + " 하수 유입 오염 0h PASS\n", + " 중금속 급증 오염 0h PASS\n", + " 소독 실패 오염 3h PASS\n", + " 화학 오염(pH 급변) 오염 0h PASS\n", + " 중금속 서서히 증가 오염 2h PASS\n", + " 하수+중금속 동시 오염 0h PASS\n", + " 약한 하수 유입(강도 60%) 오염 0h PASS\n", + " 교란: 탁도 센서 드리프트 교란 위험 오경보 13h FAIL\n", + " 교란: 잔류염소 단발 스파이크 교란 위험 오경보 0h PASS\n", + "교란: 통신 단절(전 항목 결측) 교란 위험 오경보 0h PASS\n", + "통과 9/10 (오염: 시작 후 8시간 내 위험 판정, 교란: 위험 오경보 2시간 이하)\n", + "```\n", + "
\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 05. 센서 오류에 의한 단수 오경보 방지 및 수질 안전 신뢰도 지수 산출 모듈 개발\n", + "**소요시간**: 약 3.0시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "센서 오류에 의한 단수 오경보 방지 신뢰도 지수 산출 모듈을 개발한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **센서 오류로 인한 급수 중단 오경보를 줄이는 수질 안전 신뢰도 지수와 High·Med·Low 등급 모듈을 개발한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **위험 경보마다 항목별 편차의 일치도, 경보 지속 시간, 센서 이상 표시, 드리프트 의심 여부를 점수화해 0~100점 신뢰도를 만들고 High·Med·Low로 나눈다 과정을 진행하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `src/verification/wel03_confidence_engine.py` 형태의 결과물을 완성한다.\n", + "\n", + "### 🔍 평가 및 검증 기준 (PASS 조건)\n", + "#### 미션 판정\n", + "\n", + "**평가 구분:** 필수\n", + "\n", + "**핵심 평가영역:** 신뢰도 등급(High/Med/Low) 부여 기준의 군사적 타당성\n", + "\n", + "#### 미션 PASS 조건\n", + "\n", + "- 정량 기준: 오경보율(FAR) 40% 이상 감축, 신뢰도 스코어 산출 일관성 90% 이상\n", + "\n", + "\n", + "- 진행 기준: 오경보 필터링 전후 비교 지표 산출 및 통합 테스트 완료\n", + "\n", + "\n", + "- 검증 방법: ROC-AUC 곡선 분석 및 임계값 민감도 테스트\n", + "\n", + "\n", + "#### 채점자 판정 메모\n", + "\n", + "> 단순 코드 실행 여부뿐만 아니라 출력된 수치와 근거 자료가 군 실무 요구사항에 부합하는지 면밀히 확인한다.\n", + "\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "\"\"\"위험 경보마다 다항목 동시 변화·연속 지속·센서 상태·단일 항목 드리프트 여부로 수질 안전 신뢰도 지수(0~100, High/Med/Low)를 산출해, 센서 오류에 의한 급수 중단(단수) 오경보를 줄이고 ROC-AUC·임계값 민감도·산출 일관성을 확인한다.\"\"\"\n", + "import numpy as np\n", + "import pandas as pd\n", + "import matplotlib.pyplot as plt\n", + "from sklearn.ensemble import RandomForestClassifier\n", + "from sklearn.metrics import roc_auc_score, roc_curve\n", + "\n", + "# ---------------------------------------------------------------------------\n", + "# [교육용가상자료] 야전 급수원 가상 수질 센서 데이터 생성기 (실제 부대·위치·검사 결과 없음)\n", + "# 가상 급수원 8곳 x 14일 x 1시간 간격. 실제 수질(true_*)·센서 측정값·오염 사건·센서 고장 정답을 함께 만든다.\n", + "# 기준치는 교육용 가정값이며, 실제 음용 판정은 공인 검사 결과와 의무(예방의학) 담당 기준에 따른다.\n", + "# ---------------------------------------------------------------------------\n", + "PARAMS = [\"ph\", \"turbidity\", \"chlorine\", \"metal\", \"ecoli\"]\n", + "SOURCES = [(\"W01\", \"water_truck\", 1.0, 2.0), (\"W02\", \"water_truck\", 3.5, 1.2), (\"W03\", \"purification_unit\", 2.2, 4.1),\n", + " (\"W04\", \"purification_unit\", 5.0, 3.3), (\"W05\", \"well\", 0.8, 5.6), (\"W06\", \"well\", 4.4, 6.0),\n", + " (\"W07\", \"river_intake\", 6.3, 1.8), (\"W08\", \"river_intake\", 6.9, 4.9)]\n", + "BASE = {\"water_truck\": (7.2, 0.25, 0.75, 1.5), \"purification_unit\": (7.0, 0.20, 0.85, 1.2),\n", + " \"well\": (6.8, 0.45, 0.50, 3.0), \"river_intake\": (7.3, 0.55, 0.60, 2.0)} # pH, 탁도(NTU), 잔류염소(mg/L), 중금속(ug/L)\n", + "LIMITS = {\"ph\": (5.8, 8.5), \"turbidity\": (1.0, 2.0), \"chlorine\": (0.2, 0.1), \"metal\": (5.0, 10.0), \"ecoli\": (1, 1)} # (주의, 위험) 가정 기준\n", + "EVENT_TYPES = { # 오염 사건: 변수별 최대 변화량, 최대치 도달 시간(h)\n", + " \"sewage\": ({\"turbidity\": 2.8, \"chlorine\": -0.5, \"ecoli\": 30, \"ph\": -0.3}, 4),\n", + " \"metal\": ({\"metal\": 22.0, \"ph\": -0.5}, 4),\n", + " \"disinfect_fail\": ({\"chlorine\": -0.8, \"ecoli\": 6}, 6),\n", + " \"chemical\": ({\"ph\": 1.7, \"turbidity\": 1.0}, 3),\n", + " \"creeping_metal\": ({\"metal\": 13.0}, 18),\n", + "}\n", + "FAULT_TYPES = [\"stuck\", \"spike\", \"drift\", \"dropout\"]\n", + "NOISE = {\"ph\": 0.04, \"turbidity\": 0.06, \"chlorine\": 0.03, \"metal\": 0.35}\n", + "DIGITS = {\"ph\": 2, \"turbidity\": 2, \"chlorine\": 2, \"metal\": 1}\n", + "\n", + "\n", + "def make_dataset(days=14, seed=7, n_events=6, n_faults=10, events=None, faults=None, sources=None):\n", + " \"\"\"events: [(급수원 번호, 사건 유형, 시작 시각 h, 지속 h, 강도)], faults: [(급수원 번호, 고장 유형, 항목, 시작 h, 지속 h)]\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " sources = sources or SOURCES\n", + " T = days * 24\n", + " tt = np.arange(T)\n", + " hour = tt % 24\n", + " night = (hour < 6) | (hour >= 20)\n", + " rain = np.zeros(T)\n", + " for _ in range(max(1, days // 4)):\n", + " s0, d = int(rng.integers(0, T - 14)), int(rng.integers(6, 14))\n", + " rain[s0:s0 + d] = np.maximum(rain[s0:s0 + d], rng.uniform(0.6, 1.0))\n", + " if events is None:\n", + " events = [(int(rng.integers(len(sources))), str(rng.choice(list(EVENT_TYPES))), int(rng.integers(min(24, T // 4), max(T // 4 + 1, T - 48))),\n", + " int(rng.integers(10, 30)), 1.0) for _ in range(n_events)]\n", + " if faults is None:\n", + " faults = [(int(rng.integers(len(sources))), str(rng.choice(FAULT_TYPES)), str(rng.choice([\"ph\", \"turbidity\", \"chlorine\", \"metal\"])),\n", + " int(rng.integers(12, max(13, T - 30))), int(rng.integers(6, 24))) for _ in range(n_faults)]\n", + " out = []\n", + " for si, (sid, stype, x, y) in enumerate(sources):\n", + " ph0, tb0, cl0, mt0 = BASE[stype]\n", + " tr = {\"ph\": ph0 + 0.08 * np.sin(2 * np.pi * hour / 24) + rng.normal(0, 0.03, T),\n", + " \"turbidity\": tb0 * (1 + 0.1 * rng.normal(0, 1, T)),\n", + " \"chlorine\": (cl0 * np.exp(-0.022 * hour) if stype == \"water_truck\" else cl0 + rng.normal(0, 0.03, T)),\n", + " \"metal\": mt0 + rng.normal(0, 0.2, T), \"ecoli\": np.zeros(T)}\n", + " if stype in (\"river_intake\", \"well\"):\n", + " tr[\"turbidity\"] = tr[\"turbidity\"] + rain * (0.9 if stype == \"river_intake\" else 0.45)\n", + " f_all, etype = np.zeros(T), np.array([\"none\"] * T, dtype=object)\n", + " for (s_i, et, onset, dur, scale) in events:\n", + " if s_i != si:\n", + " continue\n", + " delta, ramp = EVENT_TYPES[et]\n", + " f = np.clip((tt - onset + 1) / ramp, 0, 1) * ((tt >= onset) & (tt < onset + dur + ramp))\n", + " for k, v in delta.items():\n", + " tr[k] = tr[k] + v * f * scale\n", + " etype[f > 0] = et\n", + " f_all = np.maximum(f_all, f)\n", + " tr[\"chlorine\"] = np.clip(tr[\"chlorine\"], 0.02, None)\n", + " tr[\"turbidity\"] = np.clip(tr[\"turbidity\"], 0.05, None)\n", + " tr[\"metal\"] = np.clip(tr[\"metal\"], 0.1, None)\n", + " grade = np.where(f_all >= 0.2, 2, 0)\n", + " mild = (tr[\"turbidity\"] > LIMITS[\"turbidity\"][0]) | (tr[\"chlorine\"] < LIMITS[\"chlorine\"][0]) | (tr[\"metal\"] > LIMITS[\"metal\"][0])\n", + " grade = np.where((grade == 0) & mild, 1, grade)\n", + " ms = {}\n", + " for p in [\"ph\", \"turbidity\", \"chlorine\", \"metal\"]:\n", + " sd = NOISE[p] * np.where(night, 1.5, 1.0) * (tr[p] if p == \"turbidity\" else 1)\n", + " ms[p] = np.round(np.clip(tr[p] + rng.normal(0, 1, T) * sd, 0, None), DIGITS[p])\n", + " ms[\"ecoli\"] = (rng.poisson(tr[\"ecoli\"]) + (rng.random(T) < 0.004)).astype(float)\n", + " fault, fparam = np.array([\"none\"] * T, dtype=object), np.array([\"-\"] * T, dtype=object)\n", + " for (s_i, ft, p, s0, d) in faults:\n", + " if s_i != si:\n", + " continue\n", + " seg = slice(s0, min(T, s0 + d))\n", + " if ft == \"stuck\":\n", + " ms[p][seg] = ms[p][s0]\n", + " elif ft == \"spike\":\n", + " seg = slice(s0, s0 + 1)\n", + " ms[p][s0] = {\"ph\": 11.8, \"turbidity\": 38.0, \"chlorine\": 0.0, \"metal\": 75.0}[p]\n", + " elif ft == \"drift\":\n", + " ramp = np.linspace(0, 1, seg.stop - s0)\n", + " ms[p][seg] = np.clip(ms[p][seg] + ramp * {\"ph\": -1.6, \"turbidity\": 2.6, \"chlorine\": -0.75, \"metal\": 12.0}[p], 0, None).round(DIGITS[p])\n", + " elif ft == \"dropout\":\n", + " for q in PARAMS:\n", + " ms[q][seg] = np.nan\n", + " p = \"all\"\n", + " fault[seg], fparam[seg] = ft, p\n", + " df = pd.DataFrame({\"source_id\": sid, \"source_type\": stype, \"x_km\": x, \"y_km\": y, \"t\": tt,\n", + " \"cond\": np.where(rain > 0, \"storm\", np.where(night, \"night\", \"day\")),\n", + " **{p: ms[p] for p in PARAMS}, **{f\"true_{p}\": tr[p] for p in PARAMS},\n", + " \"event\": etype, \"fault\": fault, \"fault_param\": fparam, \"grade\": grade})\n", + " out.append(df)\n", + " return pd.concat(out, ignore_index=True)\n", + "# ---------------------------------------------------------------------------\n", + "\n", + "# ---- 전처리 공통 함수: 물리 범위 점검 -> 고정값(stuck) 표시 -> 단발 스파이크 제거 -> 결측 보정 ----\n", + "PHYS = {\"ph\": (0, 14), \"turbidity\": (0, 1000), \"chlorine\": (0, 10), \"metal\": (0, 1000), \"ecoli\": (0, 100000)}\n", + "SPIKE_K = {\"day\": 6.0, \"night\": 7.0, \"storm\": 9.0} # 상황별(주간/야간/악천후) 스파이크 판정 배수\n", + "MIN_JUMP = {\"ph\": 0.8, \"turbidity\": 1.5, \"chlorine\": 0.3, \"metal\": 6.0}\n", + "STUCK_RUN, SHORT_GAP = 6, 3\n", + "\n", + "\n", + "def clean(df, spike_k=None, min_jump=None):\n", + " \"\"\"급수원별 시계열을 정제하고 항목별 처리 내역(<항목>_flag: range/stuck/spike/interp/long_gap)을 남긴다.\"\"\"\n", + " # [TODO : 학생 실습 5] 센서 오류에 의한 단수 오경보 방지 및 수질 안전 신뢰도 지수 산출 모듈 개발 관련 핵심 로직을 구현하세요.\n", + " # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n", + " # <<< 여기에 코드를 작성하세요 >>>\n", + " pass # 작성 후 pass를 제거하세요.\n", + "GRADE_NAME = {0: \"안전\", 1: \"주의\", 2: \"위험\"}\n", + "\n", + "\n", + "def rule_grade(df):\n", + " \"\"\"기준치 1차 필터: 위험(음용 부적합 의심) = 한 항목이라도 위험 기준 초과, 주의 = 주의 기준 초과.\"\"\"\n", + " # [TODO : 학생 실습 5] 센서 오류에 의한 단수 오경보 방지 및 수질 안전 신뢰도 지수 산출 모듈 개발 관련 핵심 로직을 구현하세요.\n", + " # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n", + " # <<< 여기에 코드를 작성하세요 >>>\n", + " pass # 작성 후 pass를 제거하세요.\n", + "def reasons(row):\n", + " r = []\n", + " if not LIMITS[\"ph\"][0] <= row[\"ph\"] <= LIMITS[\"ph\"][1]:\n", + " r.append(f\"pH {row['ph']:.2f}\")\n", + " if row[\"turbidity\"] > LIMITS[\"turbidity\"][0]:\n", + " r.append(f\"탁도 {row['turbidity']:.2f}NTU\")\n", + " if row[\"chlorine\"] < LIMITS[\"chlorine\"][0]:\n", + " r.append(f\"잔류염소 {row['chlorine']:.2f}mg/L\")\n", + " if row[\"metal\"] > LIMITS[\"metal\"][0]:\n", + " r.append(f\"중금속 {row['metal']:.1f}ug/L\")\n", + " if row[\"ecoli\"] >= 1:\n", + " r.append(f\"대장균 {row['ecoli']:.0f}\")\n", + " return \", \".join(r) or \"-\"\n", + "\n", + "\n", + "FEATS = [f\"{p}{s}\" for p in [\"ph\", \"turbidity\", \"chlorine\", \"metal\"] for s in [\"\", \"_d1\", \"_d3\", \"_dev\", \"_sd6\"]] + \\\n", + " [\"ecoli\", \"ecoli_3h\", \"storm\", \"night\", \"n_flag\", \"truck\", \"purif\", \"well\", \"river\"]\n", + "\n", + "\n", + "def add_features(c):\n", + " \"\"\"시간 순서를 지키는(과거 값만 쓰는) 급수원별 특징: 변화량, 24시간 기준선 대비 편차, 6시간 변동성, 대장균 누적 등.\"\"\"\n", + " c = c.sort_values([\"source_id\", \"t\"]).reset_index(drop=True)\n", + " g = c.groupby(\"source_id\")\n", + " for p in [\"ph\", \"turbidity\", \"chlorine\", \"metal\"]:\n", + " c[f\"{p}_d1\"] = g[p].diff().fillna(0)\n", + " c[f\"{p}_d3\"] = g[p].diff(3).fillna(0)\n", + " base = g[p].transform(lambda s: s.shift(1).rolling(24, min_periods=4).median())\n", + " c[f\"{p}_dev\"] = (c[p] - base).fillna(0)\n", + " c[f\"{p}_sd6\"] = g[p].transform(lambda s: s.rolling(6, min_periods=2).std()).fillna(0)\n", + " c[\"ecoli_3h\"] = g[\"ecoli\"].transform(lambda s: s.rolling(3, min_periods=1).sum())\n", + " c[\"storm\"], c[\"night\"] = (c[\"cond\"] == \"storm\").astype(int), (c[\"cond\"] == \"night\").astype(int)\n", + " flags = [f\"{p}_flag\" for p in PARAMS if f\"{p}_flag\" in c]\n", + " c[\"n_flag\"] = sum(c[f].isin([\"stuck\", \"spike\", \"long_gap\", \"range\"]).astype(int) for f in flags) if flags else 0\n", + " for k, name in [(\"truck\", \"water_truck\"), (\"purif\", \"purification_unit\"), (\"well\", \"well\"), (\"river\", \"river_intake\")]:\n", + " c[k] = (c[\"source_type\"] == name).astype(int)\n", + " return c\n", + "\n", + "SCALE = {\"ph\": 0.35, \"turbidity\": 0.6, \"chlorine\": 0.2, \"metal\": 3.0} # 항목별 '의미 있는 편차' 크기(가정)\n", + "W = {\"corrob\": 40, \"persist\": 25, \"health\": 20, \"nondrift\": 15} # 신뢰도 가중치 매트릭스(합 100)\n", + "CUT = {\"High\": 60, \"Med\": 35} # High만 급수 중단 검토 요청으로 올린다\n", + "\n", + "\n", + "def prepare(df):\n", + " c = add_features(clean(df))\n", + " c[\"rule\"] = rule_grade(c)\n", + " return c\n", + "\n", + "\n", + "def confidence(c, alert):\n", + " \"\"\"경보(alert=True) 행마다 신뢰도 점수와 구성 요소를 계산한다.\"\"\"\n", + " dev = pd.DataFrame({p: (c[f\"{p}_dev\"].abs() > SCALE[p]) | (c[f\"{p}_d3\"].abs() > SCALE[p]) for p in SCALE})\n", + " n_dev = dev.sum(axis=1) + (c[\"ecoli_3h\"] >= 2)\n", + " corrob = np.minimum(n_dev, 3) / 3\n", + " a = pd.Series(alert.astype(int), index=c.index)\n", + " run = a.groupby([c[\"source_id\"], (a != a.groupby(c[\"source_id\"]).shift()).cumsum()]).cumsum()\n", + " persist = np.minimum(run, 3) / 3\n", + " flagged = c[\"n_flag\"].groupby(c[\"source_id\"]).transform(lambda s: s.rolling(6, min_periods=1).max()) > 0\n", + " health = 1 - flagged.astype(float)\n", + " # 드리프트 의심: 한 항목만 24시간 기준선에서 벗어나고, 6시간 변동성이 작으며(매끄러운 변화) 대장균이 없다\n", + " g = c.groupby(\"source_id\")\n", + " jitter = {p: g[f\"{p}_d1\"].transform(lambda s: s.rolling(6, min_periods=3).std()).fillna(0) for p in SCALE}\n", + " smooth = pd.concat([jitter[p] < SCALE[p] * 0.5 for p in SCALE], axis=1).all(axis=1) # 변화량이 일정 = 기울기만 있는 변화\n", + " nondrift = 1 - ((n_dev <= 1) & smooth & (c[\"ecoli_3h\"] < 2)).astype(float)\n", + " score = W[\"corrob\"] * corrob + W[\"persist\"] * persist + W[\"health\"] * health + W[\"nondrift\"] * nondrift\n", + " level = np.where(score >= CUT[\"High\"], \"High\", np.where(score >= CUT[\"Med\"], \"Med\", \"Low\"))\n", + " return pd.DataFrame({\"score\": score.round(1), \"level\": level, \"n_dev\": n_dev}, index=c.index)\n", + "\n", + "\n", + "train = pd.concat([prepare(make_dataset(seed=s, n_events=14, n_faults=14)) for s in (7, 8)], ignore_index=True)\n", + "model = RandomForestClassifier(n_estimators=200, max_depth=10, class_weight=\"balanced_subsample\", random_state=0, n_jobs=1)\n", + "model.fit(train[FEATS], train[\"grade\"])\n", + "\n", + "test = prepare(make_dataset(seed=11, n_events=10, n_faults=14))\n", + "alert = ((test[\"rule\"] == 2) | (model.predict(test[FEATS]) == 2)).to_numpy() # Baseline 또는 모델이 위험 판정\n", + "conf = confidence(test, alert)\n", + "A = test[alert].join(conf)\n", + "A[\"real\"] = A[\"grade\"] == 2\n", + "far_before = (~A[\"real\"]).mean()\n", + "stop = A[A[\"level\"] == \"High\"]\n", + "far_after = (~stop[\"real\"]).mean()\n", + "cut = 1 - far_after / far_before\n", + "print(f\"[교육용가상자료] 위험 경보 {len(A)}건(실제 위험 {int(A['real'].sum())}건, 오경보 {int((~A['real']).sum())}건)\")\n", + "print(f\"급수 중단 검토 경보 오경보율(FAR): 신뢰도 적용 전 {far_before:.1%} -> High만 상신 {far_after:.1%} \"\n", + " f\"({cut:.0%} 감축, 목표 40% 이상: {'달성' if cut >= 0.4 else '미달'})\")\n", + "print(\"\\n[신뢰도 등급별 경보 수와 조치]\")\n", + "tab = A.groupby(\"level\").agg(경보=(\"real\", \"size\"), 실제위험=(\"real\", \"sum\"), 평균점수=(\"score\", \"mean\"))\n", + "tab[\"조치\"] = tab.index.map({\"High\": \"급수 중단 검토 요청(HITL)\", \"Med\": \"즉시 재채수·현장 키트 검사\", \"Low\": \"센서 점검·재측정\"})\n", + "print(tab.reindex([\"High\", \"Med\", \"Low\"]).round(1).to_string())\n", + "print(\"오경보의 원인(센서 고장 유형) x 등급:\")\n", + "print(pd.crosstab(A.loc[~A[\"real\"], \"fault\"], A.loc[~A[\"real\"], \"level\"]).to_string())\n", + "miss_real = int((A[\"real\"] & (A[\"level\"] == \"Low\")).sum())\n", + "print(f\"실제 위험인데 Low로 낮춰진 경보 {miss_real}건 -> Low도 재측정 대상이므로 무시되지 않는다\")\n", + "\n", + "auc = roc_auc_score(A[\"real\"], A[\"score\"])\n", + "print(f\"\\nROC-AUC(신뢰도 점수로 실제 위험 구분) {auc:.3f}\")\n", + "print(\"[임계값 민감도] High 기준 / 상신 경보 / 오경보율 / 실제 위험 상신 비율\")\n", + "for th in [45, 55, 60, 70, 80]:\n", + " s = A[A[\"score\"] >= th]\n", + " print(f\" {th:>3} / {len(s):>4} / {(~s['real']).mean():6.1%} / {s['real'].sum() / A['real'].sum():6.1%}\")\n", + "\n", + "# 산출 일관성: 같은 사건·고장 조건에서 측정 잡음만 바꿔 5회 재산출했을 때 실제 위험 경보의 등급 일치율\n", + "EV = [(6, \"sewage\", 30, 14, 1.0), (4, \"metal\", 60, 12, 1.0), (2, \"chemical\", 40, 10, 1.0), (5, \"creeping_metal\", 20, 24, 1.0)]\n", + "FT = [(1, \"drift\", \"turbidity\", 24, 18), (0, \"stuck\", \"chlorine\", 30, 10)]\n", + "levels = []\n", + "for s in range(5):\n", + " c = prepare(make_dataset(days=4, seed=100 + s, events=EV, faults=FT))\n", + " al = ((c[\"rule\"] == 2) | (model.predict(c[FEATS]) == 2)).to_numpy()\n", + " levels.append(confidence(c, al)[\"level\"].where(al, \"-\").to_numpy())\n", + "L = np.array(levels)\n", + "ref = pd.Series(L[0]).mode().iloc[0]\n", + "mask = (L != \"-\").sum(axis=0) >= 3\n", + "agree = np.mean([pd.Series(col[col != \"-\"]).value_counts(normalize=True).iloc[0] for col in L[:, mask].T])\n", + "print(f\"\\n잡음 재현 5회 등급 일치율 {agree:.1%} (경보 3회 이상 시점 {int(mask.sum())}개, 목표 90% 이상: {'달성' if agree >= 0.9 else '미달'})\")\n", + "print(\"※ 신뢰도 등급은 판단 보조다. 급수 중단·재개는 의무(예방의학)·환경 담당 확인과 지휘관 승인으로 결정한다.\")\n", + "\n", + "fpr, tpr, _ = roc_curve(A[\"real\"], A[\"score\"])\n", + "fig, ax = plt.subplots(1, 2, figsize=(10, 4))\n", + "ax[0].plot(fpr, tpr, color=\"tab:blue\"); ax[0].plot([0, 1], [0, 1], \"k--\", lw=0.8)\n", + "ax[0].set_title(f\"ROC of confidence score (AUC {auc:.2f})\"); ax[0].set_xlabel(\"false positive rate\"); ax[0].set_ylabel(\"true positive rate\")\n", + "ax[1].hist([A.loc[A[\"real\"], \"score\"], A.loc[~A[\"real\"], \"score\"]], bins=12, label=[\"real danger\", \"false alarm\"], color=[\"tab:red\", \"tab:gray\"])\n", + "ax[1].axvline(CUT[\"High\"], color=\"k\", ls=\"--\"); ax[1].set_xlabel(\"confidence score\"); ax[1].legend(); ax[1].set_title(\"Score distribution (synthetic)\")\n", + "plt.tight_layout(); plt.show()\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 05 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + "[교육용가상자료] 위험 경보 262건(실제 위험 240건, 오경보 22건)\n", + "급수 중단 검토 경보 오경보율(FAR): 신뢰도 적용 전 8.4% -> High만 상신 0.7% (91% 감축, 목표 40% 이상: 달성)\n", + "\n", + "[신뢰도 등급별 경보 수와 조치]\n", + " 경보 실제위험 평균점수 조치\n", + "level \n", + "High 137 136 84.1 급수 중단 검토 요청(HITL)\n", + "Med 112 100 53.5 즉시 재채수·현장 키트 검사\n", + "Low 13 4 27.5 센서 점검·재측정\n", + "오경보의 원인(센서 고장 유형) x 등급:\n", + "level High Low Med\n", + "fault \n", + "drift 1 0 10\n", + "none 0 9 2\n", + "실제 위험인데 Low로 낮춰진 경보 4건 -> Low도 재측정 대상이므로 무시되지 않는다\n", + "\n", + "ROC-AUC(신뢰도 점수로 실제 위험 구분) 0.821\n", + "[임계값 민감도] High 기준 / 상신 경보 / 오경보율 / 실제 위험 상신 비율\n", + " 45 / 243 / 4.9% / 96.2%\n", + " 55 / 210 / 5.2% / 82.9%\n", + " 60 / 137 / 0.7% / 56.7%\n", + " 70 / 134 / 0.7% / 55.4%\n", + " 80 / 88 / 1.1% / 36.2%\n", + "\n", + "잡음 재현 5회 등급 일치율 93.6% (경보 3회 이상 시점 99개, 목표 90% 이상: 달성)\n", + "※ 신뢰도 등급은 판단 보조다. 급수 중단·재개는 의무(예방의학)·환경 담당 확인과 지휘관 승인으로 결정한다.\n", + "```\n", + "
\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 06. 야전 급수차 및 정수 시설별 수질 상태 실시간 시각화 대시보드 구현\n", + "**소요시간**: 약 2.5시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "야전 급수 시설별 수질 상태 실시간 시각화 대시보드를 구현한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **야전 급수차와 정수 시설별 수질 상태를 실시간처럼 갱신해 보여 주는 상황판 대시보드를 구현한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **가상 급수원 8곳의 측정 스트림을 1시간 단위로 갱신하며 급수원 상태 지도, 항목별 기준 대비 비율 히트맵, 경보 급수원 추이, 상태 현황판, 긴급 경보 목록 5종 컴포넌트를 그린다 과정을 진행하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `src/ui/wel03_dashboard.py (또는 웹 UI 컴포넌트)` 형태의 결과물을 완성한다.\n", + "\n", + "### 🔍 평가 및 검증 기준 (PASS 조건)\n", + "#### 미션 판정\n", + "\n", + "**평가 구분:** 필수\n", + "\n", + "**핵심 평가영역:** 전술 작전 상황판으로서의 직관성 및 인지 부하 감소도\n", + "\n", + "#### 미션 PASS 조건\n", + "\n", + "- 정량 기준: 화면 갱신 주기 1초 이내, UI 응답성 100ms 이내\n", + "\n", + "\n", + "- 진행 기준: 주요 지표 시각화 컴포넌트 4종 이상 렌더링 및 실시간 데이터 연동\n", + "\n", + "\n", + "- 검증 방법: 사용자 인터랙션 테스트 및 모의 데이터 스트림 렌더링 검증\n", + "\n", + "\n", + "#### 채점자 판정 메모\n", + "\n", + "> 단순 코드 실행 여부뿐만 아니라 출력된 수치와 근거 자료가 군 실무 요구사항에 부합하는지 면밀히 확인한다.\n", + "\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "\"\"\"가상 급수원 8곳의 수질 측정 스트림을 1시간 단위로 갱신하며 급수원 상태 지도, 항목별 기준 대비 비율 히트맵, 경보 급수원 추이, 상태 현황판, 긴급 경보 목록 5종을 그리는 상황판을 구현하고, 갱신 주기·응답 시간을 측정한다(야간 모드 지원).\"\"\"\n", + "import time\n", + "import numpy as np\n", + "import pandas as pd\n", + "import matplotlib.pyplot as plt\n", + "\n", + "# ---------------------------------------------------------------------------\n", + "# [교육용가상자료] 야전 급수원 가상 수질 센서 데이터 생성기 (실제 부대·위치·검사 결과 없음)\n", + "# 가상 급수원 8곳 x 14일 x 1시간 간격. 실제 수질(true_*)·센서 측정값·오염 사건·센서 고장 정답을 함께 만든다.\n", + "# 기준치는 교육용 가정값이며, 실제 음용 판정은 공인 검사 결과와 의무(예방의학) 담당 기준에 따른다.\n", + "# ---------------------------------------------------------------------------\n", + "PARAMS = [\"ph\", \"turbidity\", \"chlorine\", \"metal\", \"ecoli\"]\n", + "SOURCES = [(\"W01\", \"water_truck\", 1.0, 2.0), (\"W02\", \"water_truck\", 3.5, 1.2), (\"W03\", \"purification_unit\", 2.2, 4.1),\n", + " (\"W04\", \"purification_unit\", 5.0, 3.3), (\"W05\", \"well\", 0.8, 5.6), (\"W06\", \"well\", 4.4, 6.0),\n", + " (\"W07\", \"river_intake\", 6.3, 1.8), (\"W08\", \"river_intake\", 6.9, 4.9)]\n", + "BASE = {\"water_truck\": (7.2, 0.25, 0.75, 1.5), \"purification_unit\": (7.0, 0.20, 0.85, 1.2),\n", + " \"well\": (6.8, 0.45, 0.50, 3.0), \"river_intake\": (7.3, 0.55, 0.60, 2.0)} # pH, 탁도(NTU), 잔류염소(mg/L), 중금속(ug/L)\n", + "LIMITS = {\"ph\": (5.8, 8.5), \"turbidity\": (1.0, 2.0), \"chlorine\": (0.2, 0.1), \"metal\": (5.0, 10.0), \"ecoli\": (1, 1)} # (주의, 위험) 가정 기준\n", + "EVENT_TYPES = { # 오염 사건: 변수별 최대 변화량, 최대치 도달 시간(h)\n", + " \"sewage\": ({\"turbidity\": 2.8, \"chlorine\": -0.5, \"ecoli\": 30, \"ph\": -0.3}, 4),\n", + " \"metal\": ({\"metal\": 22.0, \"ph\": -0.5}, 4),\n", + " \"disinfect_fail\": ({\"chlorine\": -0.8, \"ecoli\": 6}, 6),\n", + " \"chemical\": ({\"ph\": 1.7, \"turbidity\": 1.0}, 3),\n", + " \"creeping_metal\": ({\"metal\": 13.0}, 18),\n", + "}\n", + "FAULT_TYPES = [\"stuck\", \"spike\", \"drift\", \"dropout\"]\n", + "NOISE = {\"ph\": 0.04, \"turbidity\": 0.06, \"chlorine\": 0.03, \"metal\": 0.35}\n", + "DIGITS = {\"ph\": 2, \"turbidity\": 2, \"chlorine\": 2, \"metal\": 1}\n", + "\n", + "\n", + "def make_dataset(days=14, seed=7, n_events=6, n_faults=10, events=None, faults=None, sources=None):\n", + " \"\"\"events: [(급수원 번호, 사건 유형, 시작 시각 h, 지속 h, 강도)], faults: [(급수원 번호, 고장 유형, 항목, 시작 h, 지속 h)]\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " sources = sources or SOURCES\n", + " T = days * 24\n", + " tt = np.arange(T)\n", + " hour = tt % 24\n", + " night = (hour < 6) | (hour >= 20)\n", + " rain = np.zeros(T)\n", + " for _ in range(max(1, days // 4)):\n", + " s0, d = int(rng.integers(0, T - 14)), int(rng.integers(6, 14))\n", + " rain[s0:s0 + d] = np.maximum(rain[s0:s0 + d], rng.uniform(0.6, 1.0))\n", + " if events is None:\n", + " events = [(int(rng.integers(len(sources))), str(rng.choice(list(EVENT_TYPES))), int(rng.integers(min(24, T // 4), max(T // 4 + 1, T - 48))),\n", + " int(rng.integers(10, 30)), 1.0) for _ in range(n_events)]\n", + " if faults is None:\n", + " faults = [(int(rng.integers(len(sources))), str(rng.choice(FAULT_TYPES)), str(rng.choice([\"ph\", \"turbidity\", \"chlorine\", \"metal\"])),\n", + " int(rng.integers(12, max(13, T - 30))), int(rng.integers(6, 24))) for _ in range(n_faults)]\n", + " out = []\n", + " for si, (sid, stype, x, y) in enumerate(sources):\n", + " ph0, tb0, cl0, mt0 = BASE[stype]\n", + " tr = {\"ph\": ph0 + 0.08 * np.sin(2 * np.pi * hour / 24) + rng.normal(0, 0.03, T),\n", + " \"turbidity\": tb0 * (1 + 0.1 * rng.normal(0, 1, T)),\n", + " \"chlorine\": (cl0 * np.exp(-0.022 * hour) if stype == \"water_truck\" else cl0 + rng.normal(0, 0.03, T)),\n", + " \"metal\": mt0 + rng.normal(0, 0.2, T), \"ecoli\": np.zeros(T)}\n", + " if stype in (\"river_intake\", \"well\"):\n", + " tr[\"turbidity\"] = tr[\"turbidity\"] + rain * (0.9 if stype == \"river_intake\" else 0.45)\n", + " f_all, etype = np.zeros(T), np.array([\"none\"] * T, dtype=object)\n", + " for (s_i, et, onset, dur, scale) in events:\n", + " if s_i != si:\n", + " continue\n", + " delta, ramp = EVENT_TYPES[et]\n", + " f = np.clip((tt - onset + 1) / ramp, 0, 1) * ((tt >= onset) & (tt < onset + dur + ramp))\n", + " for k, v in delta.items():\n", + " tr[k] = tr[k] + v * f * scale\n", + " etype[f > 0] = et\n", + " f_all = np.maximum(f_all, f)\n", + " tr[\"chlorine\"] = np.clip(tr[\"chlorine\"], 0.02, None)\n", + " tr[\"turbidity\"] = np.clip(tr[\"turbidity\"], 0.05, None)\n", + " tr[\"metal\"] = np.clip(tr[\"metal\"], 0.1, None)\n", + " grade = np.where(f_all >= 0.2, 2, 0)\n", + " mild = (tr[\"turbidity\"] > LIMITS[\"turbidity\"][0]) | (tr[\"chlorine\"] < LIMITS[\"chlorine\"][0]) | (tr[\"metal\"] > LIMITS[\"metal\"][0])\n", + " grade = np.where((grade == 0) & mild, 1, grade)\n", + " ms = {}\n", + " for p in [\"ph\", \"turbidity\", \"chlorine\", \"metal\"]:\n", + " sd = NOISE[p] * np.where(night, 1.5, 1.0) * (tr[p] if p == \"turbidity\" else 1)\n", + " ms[p] = np.round(np.clip(tr[p] + rng.normal(0, 1, T) * sd, 0, None), DIGITS[p])\n", + " ms[\"ecoli\"] = (rng.poisson(tr[\"ecoli\"]) + (rng.random(T) < 0.004)).astype(float)\n", + " fault, fparam = np.array([\"none\"] * T, dtype=object), np.array([\"-\"] * T, dtype=object)\n", + " for (s_i, ft, p, s0, d) in faults:\n", + " if s_i != si:\n", + " continue\n", + " seg = slice(s0, min(T, s0 + d))\n", + " if ft == \"stuck\":\n", + " ms[p][seg] = ms[p][s0]\n", + " elif ft == \"spike\":\n", + " seg = slice(s0, s0 + 1)\n", + " ms[p][s0] = {\"ph\": 11.8, \"turbidity\": 38.0, \"chlorine\": 0.0, \"metal\": 75.0}[p]\n", + " elif ft == \"drift\":\n", + " ramp = np.linspace(0, 1, seg.stop - s0)\n", + " ms[p][seg] = np.clip(ms[p][seg] + ramp * {\"ph\": -1.6, \"turbidity\": 2.6, \"chlorine\": -0.75, \"metal\": 12.0}[p], 0, None).round(DIGITS[p])\n", + " elif ft == \"dropout\":\n", + " for q in PARAMS:\n", + " ms[q][seg] = np.nan\n", + " p = \"all\"\n", + " fault[seg], fparam[seg] = ft, p\n", + " df = pd.DataFrame({\"source_id\": sid, \"source_type\": stype, \"x_km\": x, \"y_km\": y, \"t\": tt,\n", + " \"cond\": np.where(rain > 0, \"storm\", np.where(night, \"night\", \"day\")),\n", + " **{p: ms[p] for p in PARAMS}, **{f\"true_{p}\": tr[p] for p in PARAMS},\n", + " \"event\": etype, \"fault\": fault, \"fault_param\": fparam, \"grade\": grade})\n", + " out.append(df)\n", + " return pd.concat(out, ignore_index=True)\n", + "# ---------------------------------------------------------------------------\n", + "\n", + "# ---- 전처리 공통 함수: 물리 범위 점검 -> 고정값(stuck) 표시 -> 단발 스파이크 제거 -> 결측 보정 ----\n", + "PHYS = {\"ph\": (0, 14), \"turbidity\": (0, 1000), \"chlorine\": (0, 10), \"metal\": (0, 1000), \"ecoli\": (0, 100000)}\n", + "SPIKE_K = {\"day\": 6.0, \"night\": 7.0, \"storm\": 9.0} # 상황별(주간/야간/악천후) 스파이크 판정 배수\n", + "MIN_JUMP = {\"ph\": 0.8, \"turbidity\": 1.5, \"chlorine\": 0.3, \"metal\": 6.0}\n", + "STUCK_RUN, SHORT_GAP = 6, 3\n", + "\n", + "\n", + "def clean(df, spike_k=None, min_jump=None):\n", + " \"\"\"급수원별 시계열을 정제하고 항목별 처리 내역(<항목>_flag: range/stuck/spike/interp/long_gap)을 남긴다.\"\"\"\n", + " # [TODO : 학생 실습 6] 야전 급수차 및 정수 시설별 수질 상태 실시간 시각화 대시보드 구현 관련 핵심 로직을 구현하세요.\n", + " # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n", + " # <<< 여기에 코드를 작성하세요 >>>\n", + " pass # 작성 후 pass를 제거하세요.\n", + "GRADE_NAME = {0: \"안전\", 1: \"주의\", 2: \"위험\"}\n", + "\n", + "\n", + "def rule_grade(df):\n", + " \"\"\"기준치 1차 필터: 위험(음용 부적합 의심) = 한 항목이라도 위험 기준 초과, 주의 = 주의 기준 초과.\"\"\"\n", + " # [TODO : 학생 실습 6] 야전 급수차 및 정수 시설별 수질 상태 실시간 시각화 대시보드 구현 관련 핵심 로직을 구현하세요.\n", + " # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n", + " # <<< 여기에 코드를 작성하세요 >>>\n", + " pass # 작성 후 pass를 제거하세요.\n", + "def reasons(row):\n", + " r = []\n", + " if not LIMITS[\"ph\"][0] <= row[\"ph\"] <= LIMITS[\"ph\"][1]:\n", + " r.append(f\"pH {row['ph']:.2f}\")\n", + " if row[\"turbidity\"] > LIMITS[\"turbidity\"][0]:\n", + " r.append(f\"탁도 {row['turbidity']:.2f}NTU\")\n", + " if row[\"chlorine\"] < LIMITS[\"chlorine\"][0]:\n", + " r.append(f\"잔류염소 {row['chlorine']:.2f}mg/L\")\n", + " if row[\"metal\"] > LIMITS[\"metal\"][0]:\n", + " r.append(f\"중금속 {row['metal']:.1f}ug/L\")\n", + " if row[\"ecoli\"] >= 1:\n", + " r.append(f\"대장균 {row['ecoli']:.0f}\")\n", + " return \", \".join(r) or \"-\"\n", + "\n", + "\n", + "FEATS = [f\"{p}{s}\" for p in [\"ph\", \"turbidity\", \"chlorine\", \"metal\"] for s in [\"\", \"_d1\", \"_d3\", \"_dev\", \"_sd6\"]] + \\\n", + " [\"ecoli\", \"ecoli_3h\", \"storm\", \"night\", \"n_flag\", \"truck\", \"purif\", \"well\", \"river\"]\n", + "\n", + "\n", + "def add_features(c):\n", + " \"\"\"시간 순서를 지키는(과거 값만 쓰는) 급수원별 특징: 변화량, 24시간 기준선 대비 편차, 6시간 변동성, 대장균 누적 등.\"\"\"\n", + " c = c.sort_values([\"source_id\", \"t\"]).reset_index(drop=True)\n", + " g = c.groupby(\"source_id\")\n", + " for p in [\"ph\", \"turbidity\", \"chlorine\", \"metal\"]:\n", + " c[f\"{p}_d1\"] = g[p].diff().fillna(0)\n", + " c[f\"{p}_d3\"] = g[p].diff(3).fillna(0)\n", + " base = g[p].transform(lambda s: s.shift(1).rolling(24, min_periods=4).median())\n", + " c[f\"{p}_dev\"] = (c[p] - base).fillna(0)\n", + " c[f\"{p}_sd6\"] = g[p].transform(lambda s: s.rolling(6, min_periods=2).std()).fillna(0)\n", + " c[\"ecoli_3h\"] = g[\"ecoli\"].transform(lambda s: s.rolling(3, min_periods=1).sum())\n", + " c[\"storm\"], c[\"night\"] = (c[\"cond\"] == \"storm\").astype(int), (c[\"cond\"] == \"night\").astype(int)\n", + " flags = [f\"{p}_flag\" for p in PARAMS if f\"{p}_flag\" in c]\n", + " c[\"n_flag\"] = sum(c[f].isin([\"stuck\", \"spike\", \"long_gap\", \"range\"]).astype(int) for f in flags) if flags else 0\n", + " for k, name in [(\"truck\", \"water_truck\"), (\"purif\", \"purification_unit\"), (\"well\", \"well\"), (\"river\", \"river_intake\")]:\n", + " c[k] = (c[\"source_type\"] == name).astype(int)\n", + " return c\n", + "\n", + "DARK = True # 야간 작전 모드(어두운 배경)\n", + "NOW = 290 # 상황판 기준 시각(모의 시작 후 경과 시간 h)\n", + "COLOR = {0: \"tab:green\", 1: \"gold\", 2: \"red\"}\n", + "\n", + "data = make_dataset(days=14, seed=11, n_events=10, n_faults=14)\n", + "data = data[data[\"t\"] <= NOW]\n", + "\n", + "\n", + "def ratio(row):\n", + " \"\"\"항목별 '위험 기준 대비 비율'(1 이상이면 위험). 잔류염소·pH는 방향을 맞춰 환산한다.\"\"\"\n", + " lo, hi = LIMITS[\"ph\"]\n", + " return {\"pH\": max((row[\"ph\"] - 7.0) / (hi - 7.0), (7.0 - row[\"ph\"]) / (7.0 - lo)),\n", + " \"turb\": row[\"turbidity\"] / LIMITS[\"turbidity\"][1], \"Cl\": LIMITS[\"chlorine\"][1] / max(row[\"chlorine\"], 0.01),\n", + " \"metal\": row[\"metal\"] / LIMITS[\"metal\"][1], \"E.coli\": row[\"ecoli\"] / LIMITS[\"ecoli\"][1]}\n", + "\n", + "\n", + "# 1) 실시간 갱신 모의: 최근 24시간 창을 유지하며 1시간씩 새 측정값이 들어올 때 상태 계산 시간\n", + "window = data[data[\"t\"] <= NOW - 24].copy()\n", + "upd = []\n", + "for h in range(NOW - 23, NOW + 1):\n", + " t0 = time.perf_counter()\n", + " window = pd.concat([window[window[\"t\"] > h - 48], data[data[\"t\"] == h]])\n", + " c = clean(window)\n", + " c[\"g\"] = rule_grade(c)\n", + " latest = c[c[\"t\"] == h]\n", + " upd.append((time.perf_counter() - t0) * 1000)\n", + "print(f\"[교육용가상자료] 실시간 갱신 모의 24회: 1회 평균 {np.mean(upd):.1f}ms, 최대 {np.max(upd):.1f}ms \"\n", + " f\"(갱신 주기 1초 이내: {'달성' if max(upd) < 1000 else '미달'})\")\n", + "\n", + "# 2) 현재 상태표\n", + "state = latest.set_index(\"source_id\")\n", + "hist = c.groupby(\"source_id\")[\"g\"].apply(lambda s: int((s.tail(6) == 2).sum()))\n", + "board = pd.DataFrame({\"유형\": state[\"source_type\"], \"상태\": state[\"g\"].map(GRADE_NAME), \"최근6h 위험\": hist,\n", + " \"근거\": [reasons(r) for _, r in state.iterrows()]})\n", + "print(f\"\\n[D+{NOW // 24:02d} {NOW % 24:02d}시 급수원 상태판]\")\n", + "print(board.to_string())\n", + "alerts = board[(board[\"상태\"] != \"안전\") | (board[\"최근6h 위험\"] > 0)]\n", + "print(f\"긴급 경보 강조 대상 {len(alerts)}곳: {', '.join(alerts.index)}\")\n", + "\n", + "# 3) 상황판 그리기(컴포넌트 5종) + 렌더링 시간\n", + "plt.style.use(\"dark_background\" if DARK else \"default\")\n", + "t0 = time.perf_counter()\n", + "fig = plt.figure(figsize=(13, 7.5))\n", + "gs = fig.add_gridspec(2, 3)\n", + "ax1 = fig.add_subplot(gs[0, 0])\n", + "for sid, r in state.iterrows():\n", + " ax1.scatter(r[\"x_km\"], r[\"y_km\"], s=260, color=COLOR[r[\"g\"]], edgecolor=\"white\" if r[\"g\"] == 2 else \"none\", lw=2)\n", + " ax1.annotate(sid, (r[\"x_km\"], r[\"y_km\"]), xytext=(6, 6), textcoords=\"offset points\", fontsize=8)\n", + "ax1.set_title(\"Water source status map (virtual grid km)\"); ax1.set_xlim(0, 7.5); ax1.set_ylim(0, 7)\n", + "ax2 = fig.add_subplot(gs[0, 1])\n", + "R = pd.DataFrame([ratio(r) for _, r in state.iterrows()], index=state.index)\n", + "im = ax2.imshow(R.clip(0, 2).to_numpy(), cmap=\"RdYlGn_r\", vmin=0, vmax=2, aspect=\"auto\")\n", + "ax2.set_xticks(range(R.shape[1]), R.columns); ax2.set_yticks(range(len(R)), R.index)\n", + "ax2.set_title(\"Ratio to danger limit (>=1 danger)\"); fig.colorbar(im, ax=ax2, fraction=0.046)\n", + "ax3 = fig.add_subplot(gs[0, 2])\n", + "cnt = state[\"g\"].value_counts().reindex([0, 1, 2], fill_value=0)\n", + "ax3.bar([\"safe\", \"caution\", \"danger\"], cnt.values, color=[COLOR[i] for i in range(3)])\n", + "ax3.set_title(f\"Status count at hour {NOW}\")\n", + "ax4 = fig.add_subplot(gs[1, :2])\n", + "focus = alerts.index[0] if len(alerts) else state.index[0]\n", + "s = c[c[\"source_id\"] == focus]\n", + "for p, col in [(\"turbidity\", \"tab:cyan\"), (\"chlorine\", \"tab:orange\"), (\"metal\", \"violet\")]:\n", + " ax4.plot(s[\"t\"], s[p] / LIMITS[p][1] if p != \"chlorine\" else LIMITS[p][1] / s[p].clip(lower=0.01), label=p, color=col)\n", + "ax4.axhline(1, color=\"red\", ls=\"--\", lw=1); ax4.set_ylim(0, 3); ax4.legend(loc=\"upper left\")\n", + "ax4.set_title(f\"{focus} last 48h ratio to danger limit\"); ax4.set_xlabel(\"hour\")\n", + "ax5 = fig.add_subplot(gs[1, 2]); ax5.axis(\"off\")\n", + "lines = [f\"{sid}: {('DANGER' if r['상태'] == '위험' else 'CHECK')} 6h danger {r['최근6h 위험']}\" for sid, r in alerts.iterrows()]\n", + "ax5.text(0, 1, \"ALERT LIST\\n\" + \"\\n\".join(lines or [\"no alert\"]), va=\"top\", family=\"monospace\", fontsize=10,\n", + " color=\"red\" if len(alerts) else \"white\" if DARK else \"black\")\n", + "fig.suptitle(\"Field water quality risk board (synthetic, decision support only)\")\n", + "fig.tight_layout()\n", + "fig.canvas.draw()\n", + "draw_ms = (time.perf_counter() - t0) * 1000\n", + "\n", + "# 4) UI 응답성: 급수원 클릭(선택) 시 상세 조회 응답 시간\n", + "resp = []\n", + "for sid in state.index:\n", + " t0 = time.perf_counter()\n", + " d = c[c[\"source_id\"] == sid].tail(6)\n", + " _ = (d[[\"ph\", \"turbidity\", \"chlorine\", \"metal\", \"ecoli\"]].agg([\"min\", \"max\"]), reasons(d.iloc[-1]))\n", + " resp.append((time.perf_counter() - t0) * 1000)\n", + "print(f\"\\n시각화 컴포넌트 5종 렌더링 {draw_ms:.0f}ms, 급수원 상세 조회 응답 평균 {np.mean(resp):.1f}ms \"\n", + " f\"(UI 응답성 100ms 이내: {'달성' if max(resp) < 100 else '미달'})\")\n", + "print(\"※ 상황판의 위험 표시는 가정 기준치 기반 판단 보조이며, 급수 중단 여부는 의무(예방의학)·환경 담당 확인 후 지휘관이 결정한다.\")\n", + "plt.show()\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 06 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + "[교육용가상자료] 실시간 갱신 모의 24회: 1회 평균 158.2ms, 최대 205.7ms (갱신 주기 1초 이내: 달성)\n", + "\n", + "[D+12 02시 급수원 상태판]\n", + " 유형 상태 최근6h 위험 근거\n", + "source_id \n", + "W01 water_truck 안전 0 -\n", + "W02 water_truck 안전 0 -\n", + "W03 purification_unit 안전 0 -\n", + "W04 purification_unit 위험 3 pH 8.72, 탁도 1.31NTU\n", + "W05 well 안전 0 -\n", + "W06 well 안전 0 -\n", + "W07 river_intake 주의 0 중금속 8.7ug/L\n", + "W08 river_intake 위험 6 중금속 24.1ug/L\n", + "긴급 경보 강조 대상 3곳: W04, W07, W08\n", + "\n", + "시각화 컴포넌트 5종 렌더링 581ms, 급수원 상세 조회 응답 평균 5.7ms (UI 응답성 100ms 이내: 달성)\n", + "※ 상황판의 위험 표시는 가정 기준치 기반 판단 보조이며, 급수 중단 여부는 의무(예방의학)·환경 담당 확인 후 지휘관이 결정한다.\n", + "```\n", + "
\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 07. 수질 위험 경보 발생 시 급수관 긴급 채수 검사 및 급수 중단 HITL 결재선 설계\n", + "**소요시간**: 약 3.0시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "수질 위험 경보 발생 시 급수 중단 및 재검사 HITL 결재선을 설계한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **수질 위험 경보 발생 시 긴급 채수 검사와 급수 중단을 사람이 결정하는 HITL 결재선을 설계한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **경보 사건을 검토 큐에 우선순위로 등록하고 측정병 채수, 예방의학 담당 검토, 지휘관 결심의 순서로 급수 중단 또는 유지를 결정하는 상태 기계 구현하고 동작을 검증하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `src/hitl/wel03_approval_workflow.py 및 docs/wel03_hitl_guide.md` 형태의 결과물을 완성한다.\n", + "\n", + "### 🔍 평가 및 검증 기준 (PASS 조건)\n", + "#### 미션 판정\n", + "\n", + "**평가 구분:** 필수\n", + "\n", + "**핵심 평가영역:** 지휘 책임 분계선 명확화 및 성급한 자동 단정 방지 체계 부합성\n", + "\n", + "#### 미션 PASS 조건\n", + "\n", + "- 정량 기준: 인간 검토 큐(Queue) 대기시간 및 승인/반려 처리 로깅 성공률 100%\n", + "\n", + "\n", + "- 진행 기준: 승인/반려 인터페이스 동작 및 감사 로그(Audit Log) 생성 검증\n", + "\n", + "\n", + "- 검증 방법: 모의 조작자 승인/반려 시뮬레이션 및 데이터 무결성 검증\n", + "\n", + "\n", + "#### 채점자 판정 메모\n", + "\n", + "> 단순 코드 실행 여부뿐만 아니라 출력된 수치와 근거 자료가 군 실무 요구사항에 부합하는지 면밀히 확인한다.\n", + "\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "\"\"\"수질 위험 경보가 나면 시스템은 급수 중단을 자동 확정하지 않고 우선순위 검토 큐에 올리며, 측정 담당 긴급 채수 → 예방의학 담당 검토 → 지휘관 급수 중단 승인·반려(반려 사유 필수) 결재선과 권한 통제, 해시 체인 감사 로그, 큐 대기시간을 시뮬레이션한다.\"\"\"\n", + "import hashlib\n", + "import json\n", + "import numpy as np\n", + "import pandas as pd\n", + "\n", + "# ---------------------------------------------------------------------------\n", + "# [교육용가상자료] 야전 급수원 가상 수질 센서 데이터 생성기 (실제 부대·위치·검사 결과 없음)\n", + "# 가상 급수원 8곳 x 14일 x 1시간 간격. 실제 수질(true_*)·센서 측정값·오염 사건·센서 고장 정답을 함께 만든다.\n", + "# 기준치는 교육용 가정값이며, 실제 음용 판정은 공인 검사 결과와 의무(예방의학) 담당 기준에 따른다.\n", + "# ---------------------------------------------------------------------------\n", + "PARAMS = [\"ph\", \"turbidity\", \"chlorine\", \"metal\", \"ecoli\"]\n", + "SOURCES = [(\"W01\", \"water_truck\", 1.0, 2.0), (\"W02\", \"water_truck\", 3.5, 1.2), (\"W03\", \"purification_unit\", 2.2, 4.1),\n", + " (\"W04\", \"purification_unit\", 5.0, 3.3), (\"W05\", \"well\", 0.8, 5.6), (\"W06\", \"well\", 4.4, 6.0),\n", + " (\"W07\", \"river_intake\", 6.3, 1.8), (\"W08\", \"river_intake\", 6.9, 4.9)]\n", + "BASE = {\"water_truck\": (7.2, 0.25, 0.75, 1.5), \"purification_unit\": (7.0, 0.20, 0.85, 1.2),\n", + " \"well\": (6.8, 0.45, 0.50, 3.0), \"river_intake\": (7.3, 0.55, 0.60, 2.0)} # pH, 탁도(NTU), 잔류염소(mg/L), 중금속(ug/L)\n", + "LIMITS = {\"ph\": (5.8, 8.5), \"turbidity\": (1.0, 2.0), \"chlorine\": (0.2, 0.1), \"metal\": (5.0, 10.0), \"ecoli\": (1, 1)} # (주의, 위험) 가정 기준\n", + "EVENT_TYPES = { # 오염 사건: 변수별 최대 변화량, 최대치 도달 시간(h)\n", + " \"sewage\": ({\"turbidity\": 2.8, \"chlorine\": -0.5, \"ecoli\": 30, \"ph\": -0.3}, 4),\n", + " \"metal\": ({\"metal\": 22.0, \"ph\": -0.5}, 4),\n", + " \"disinfect_fail\": ({\"chlorine\": -0.8, \"ecoli\": 6}, 6),\n", + " \"chemical\": ({\"ph\": 1.7, \"turbidity\": 1.0}, 3),\n", + " \"creeping_metal\": ({\"metal\": 13.0}, 18),\n", + "}\n", + "FAULT_TYPES = [\"stuck\", \"spike\", \"drift\", \"dropout\"]\n", + "NOISE = {\"ph\": 0.04, \"turbidity\": 0.06, \"chlorine\": 0.03, \"metal\": 0.35}\n", + "DIGITS = {\"ph\": 2, \"turbidity\": 2, \"chlorine\": 2, \"metal\": 1}\n", + "\n", + "\n", + "def make_dataset(days=14, seed=7, n_events=6, n_faults=10, events=None, faults=None, sources=None):\n", + " \"\"\"events: [(급수원 번호, 사건 유형, 시작 시각 h, 지속 h, 강도)], faults: [(급수원 번호, 고장 유형, 항목, 시작 h, 지속 h)]\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " sources = sources or SOURCES\n", + " T = days * 24\n", + " tt = np.arange(T)\n", + " hour = tt % 24\n", + " night = (hour < 6) | (hour >= 20)\n", + " rain = np.zeros(T)\n", + " for _ in range(max(1, days // 4)):\n", + " s0, d = int(rng.integers(0, T - 14)), int(rng.integers(6, 14))\n", + " rain[s0:s0 + d] = np.maximum(rain[s0:s0 + d], rng.uniform(0.6, 1.0))\n", + " if events is None:\n", + " events = [(int(rng.integers(len(sources))), str(rng.choice(list(EVENT_TYPES))), int(rng.integers(min(24, T // 4), max(T // 4 + 1, T - 48))),\n", + " int(rng.integers(10, 30)), 1.0) for _ in range(n_events)]\n", + " if faults is None:\n", + " faults = [(int(rng.integers(len(sources))), str(rng.choice(FAULT_TYPES)), str(rng.choice([\"ph\", \"turbidity\", \"chlorine\", \"metal\"])),\n", + " int(rng.integers(12, max(13, T - 30))), int(rng.integers(6, 24))) for _ in range(n_faults)]\n", + " out = []\n", + " for si, (sid, stype, x, y) in enumerate(sources):\n", + " ph0, tb0, cl0, mt0 = BASE[stype]\n", + " tr = {\"ph\": ph0 + 0.08 * np.sin(2 * np.pi * hour / 24) + rng.normal(0, 0.03, T),\n", + " \"turbidity\": tb0 * (1 + 0.1 * rng.normal(0, 1, T)),\n", + " \"chlorine\": (cl0 * np.exp(-0.022 * hour) if stype == \"water_truck\" else cl0 + rng.normal(0, 0.03, T)),\n", + " \"metal\": mt0 + rng.normal(0, 0.2, T), \"ecoli\": np.zeros(T)}\n", + " if stype in (\"river_intake\", \"well\"):\n", + " tr[\"turbidity\"] = tr[\"turbidity\"] + rain * (0.9 if stype == \"river_intake\" else 0.45)\n", + " f_all, etype = np.zeros(T), np.array([\"none\"] * T, dtype=object)\n", + " for (s_i, et, onset, dur, scale) in events:\n", + " if s_i != si:\n", + " continue\n", + " delta, ramp = EVENT_TYPES[et]\n", + " f = np.clip((tt - onset + 1) / ramp, 0, 1) * ((tt >= onset) & (tt < onset + dur + ramp))\n", + " for k, v in delta.items():\n", + " tr[k] = tr[k] + v * f * scale\n", + " etype[f > 0] = et\n", + " f_all = np.maximum(f_all, f)\n", + " tr[\"chlorine\"] = np.clip(tr[\"chlorine\"], 0.02, None)\n", + " tr[\"turbidity\"] = np.clip(tr[\"turbidity\"], 0.05, None)\n", + " tr[\"metal\"] = np.clip(tr[\"metal\"], 0.1, None)\n", + " grade = np.where(f_all >= 0.2, 2, 0)\n", + " mild = (tr[\"turbidity\"] > LIMITS[\"turbidity\"][0]) | (tr[\"chlorine\"] < LIMITS[\"chlorine\"][0]) | (tr[\"metal\"] > LIMITS[\"metal\"][0])\n", + " grade = np.where((grade == 0) & mild, 1, grade)\n", + " ms = {}\n", + " for p in [\"ph\", \"turbidity\", \"chlorine\", \"metal\"]:\n", + " sd = NOISE[p] * np.where(night, 1.5, 1.0) * (tr[p] if p == \"turbidity\" else 1)\n", + " ms[p] = np.round(np.clip(tr[p] + rng.normal(0, 1, T) * sd, 0, None), DIGITS[p])\n", + " ms[\"ecoli\"] = (rng.poisson(tr[\"ecoli\"]) + (rng.random(T) < 0.004)).astype(float)\n", + " fault, fparam = np.array([\"none\"] * T, dtype=object), np.array([\"-\"] * T, dtype=object)\n", + " for (s_i, ft, p, s0, d) in faults:\n", + " if s_i != si:\n", + " continue\n", + " seg = slice(s0, min(T, s0 + d))\n", + " if ft == \"stuck\":\n", + " ms[p][seg] = ms[p][s0]\n", + " elif ft == \"spike\":\n", + " seg = slice(s0, s0 + 1)\n", + " ms[p][s0] = {\"ph\": 11.8, \"turbidity\": 38.0, \"chlorine\": 0.0, \"metal\": 75.0}[p]\n", + " elif ft == \"drift\":\n", + " ramp = np.linspace(0, 1, seg.stop - s0)\n", + " ms[p][seg] = np.clip(ms[p][seg] + ramp * {\"ph\": -1.6, \"turbidity\": 2.6, \"chlorine\": -0.75, \"metal\": 12.0}[p], 0, None).round(DIGITS[p])\n", + " elif ft == \"dropout\":\n", + " for q in PARAMS:\n", + " ms[q][seg] = np.nan\n", + " p = \"all\"\n", + " fault[seg], fparam[seg] = ft, p\n", + " df = pd.DataFrame({\"source_id\": sid, \"source_type\": stype, \"x_km\": x, \"y_km\": y, \"t\": tt,\n", + " \"cond\": np.where(rain > 0, \"storm\", np.where(night, \"night\", \"day\")),\n", + " **{p: ms[p] for p in PARAMS}, **{f\"true_{p}\": tr[p] for p in PARAMS},\n", + " \"event\": etype, \"fault\": fault, \"fault_param\": fparam, \"grade\": grade})\n", + " out.append(df)\n", + " return pd.concat(out, ignore_index=True)\n", + "# ---------------------------------------------------------------------------\n", + "\n", + "# ---- 전처리 공통 함수: 물리 범위 점검 -> 고정값(stuck) 표시 -> 단발 스파이크 제거 -> 결측 보정 ----\n", + "PHYS = {\"ph\": (0, 14), \"turbidity\": (0, 1000), \"chlorine\": (0, 10), \"metal\": (0, 1000), \"ecoli\": (0, 100000)}\n", + "SPIKE_K = {\"day\": 6.0, \"night\": 7.0, \"storm\": 9.0} # 상황별(주간/야간/악천후) 스파이크 판정 배수\n", + "MIN_JUMP = {\"ph\": 0.8, \"turbidity\": 1.5, \"chlorine\": 0.3, \"metal\": 6.0}\n", + "STUCK_RUN, SHORT_GAP = 6, 3\n", + "\n", + "\n", + "def clean(df, spike_k=None, min_jump=None):\n", + " \"\"\"급수원별 시계열을 정제하고 항목별 처리 내역(<항목>_flag: range/stuck/spike/interp/long_gap)을 남긴다.\"\"\"\n", + " # [TODO : 학생 실습 7] 수질 위험 경보 발생 시 급수관 긴급 채수 검사 및 급수 중단 HITL 결재선 설계 관련 핵심 로직을 구현하세요.\n", + " # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n", + " # <<< 여기에 코드를 작성하세요 >>>\n", + " pass # 작성 후 pass를 제거하세요.\n", + "GRADE_NAME = {0: \"안전\", 1: \"주의\", 2: \"위험\"}\n", + "\n", + "\n", + "def rule_grade(df):\n", + " \"\"\"기준치 1차 필터: 위험(음용 부적합 의심) = 한 항목이라도 위험 기준 초과, 주의 = 주의 기준 초과.\"\"\"\n", + " # [TODO : 학생 실습 7] 수질 위험 경보 발생 시 급수관 긴급 채수 검사 및 급수 중단 HITL 결재선 설계 관련 핵심 로직을 구현하세요.\n", + " # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n", + " # <<< 여기에 코드를 작성하세요 >>>\n", + " pass # 작성 후 pass를 제거하세요.\n", + "def reasons(row):\n", + " r = []\n", + " if not LIMITS[\"ph\"][0] <= row[\"ph\"] <= LIMITS[\"ph\"][1]:\n", + " r.append(f\"pH {row['ph']:.2f}\")\n", + " if row[\"turbidity\"] > LIMITS[\"turbidity\"][0]:\n", + " r.append(f\"탁도 {row['turbidity']:.2f}NTU\")\n", + " if row[\"chlorine\"] < LIMITS[\"chlorine\"][0]:\n", + " r.append(f\"잔류염소 {row['chlorine']:.2f}mg/L\")\n", + " if row[\"metal\"] > LIMITS[\"metal\"][0]:\n", + " r.append(f\"중금속 {row['metal']:.1f}ug/L\")\n", + " if row[\"ecoli\"] >= 1:\n", + " r.append(f\"대장균 {row['ecoli']:.0f}\")\n", + " return \", \".join(r) or \"-\"\n", + "\n", + "\n", + "FEATS = [f\"{p}{s}\" for p in [\"ph\", \"turbidity\", \"chlorine\", \"metal\"] for s in [\"\", \"_d1\", \"_d3\", \"_dev\", \"_sd6\"]] + \\\n", + " [\"ecoli\", \"ecoli_3h\", \"storm\", \"night\", \"n_flag\", \"truck\", \"purif\", \"well\", \"river\"]\n", + "\n", + "\n", + "def add_features(c):\n", + " \"\"\"시간 순서를 지키는(과거 값만 쓰는) 급수원별 특징: 변화량, 24시간 기준선 대비 편차, 6시간 변동성, 대장균 누적 등.\"\"\"\n", + " c = c.sort_values([\"source_id\", \"t\"]).reset_index(drop=True)\n", + " g = c.groupby(\"source_id\")\n", + " for p in [\"ph\", \"turbidity\", \"chlorine\", \"metal\"]:\n", + " c[f\"{p}_d1\"] = g[p].diff().fillna(0)\n", + " c[f\"{p}_d3\"] = g[p].diff(3).fillna(0)\n", + " base = g[p].transform(lambda s: s.shift(1).rolling(24, min_periods=4).median())\n", + " c[f\"{p}_dev\"] = (c[p] - base).fillna(0)\n", + " c[f\"{p}_sd6\"] = g[p].transform(lambda s: s.rolling(6, min_periods=2).std()).fillna(0)\n", + " c[\"ecoli_3h\"] = g[\"ecoli\"].transform(lambda s: s.rolling(3, min_periods=1).sum())\n", + " c[\"storm\"], c[\"night\"] = (c[\"cond\"] == \"storm\").astype(int), (c[\"cond\"] == \"night\").astype(int)\n", + " flags = [f\"{p}_flag\" for p in PARAMS if f\"{p}_flag\" in c]\n", + " c[\"n_flag\"] = sum(c[f].isin([\"stuck\", \"spike\", \"long_gap\", \"range\"]).astype(int) for f in flags) if flags else 0\n", + " for k, name in [(\"truck\", \"water_truck\"), (\"purif\", \"purification_unit\"), (\"well\", \"well\"), (\"river\", \"river_intake\")]:\n", + " c[k] = (c[\"source_type\"] == name).astype(int)\n", + " return c\n", + "\n", + "ROLE_ACTIONS = {\"측정병\": {\"sample\"}, \"예방의학담당\": {\"review\"}, \"지휘관\": {\"approve_stop\", \"reject\"}, \"시스템\": {\"enqueue\"}}\n", + "USERS = {\"op01\": \"측정병\", \"med01\": \"예방의학담당\", \"cdr01\": \"지휘관\", \"sys\": \"시스템\"}\n", + "FLOW = {\"enqueue\": (\"-\", \"채수대기\"), \"sample\": (\"채수대기\", \"검토대기\"), \"review\": (\"검토대기\", \"승인대기\"),\n", + " \"approve_stop\": (\"승인대기\", \"급수중단\"), \"reject\": (\"승인대기\", \"급수유지\")}\n", + "\n", + "\n", + "class AuditLog:\n", + " def __init__(self):\n", + " self.rows = []\n", + "\n", + " def add(self, **kw):\n", + " prev = self.rows[-1][\"hash\"] if self.rows else \"0\" * 16\n", + " body = json.dumps(kw, ensure_ascii=False, sort_keys=True)\n", + " self.rows.append({**kw, \"prev\": prev, \"hash\": hashlib.sha256((prev + body).encode()).hexdigest()[:16]})\n", + "\n", + " def verify(self):\n", + " prev = \"0\" * 16\n", + " for r in self.rows:\n", + " body = json.dumps({k: v for k, v in r.items() if k not in (\"prev\", \"hash\")}, ensure_ascii=False, sort_keys=True)\n", + " if r[\"prev\"] != prev or hashlib.sha256((prev + body).encode()).hexdigest()[:16] != r[\"hash\"]:\n", + " return False\n", + " prev = r[\"hash\"]\n", + " return True\n", + "\n", + "\n", + "class Workflow:\n", + " def __init__(self):\n", + " self.log, self.state, self.n_try = AuditLog(), {}, 0\n", + "\n", + " def act(self, user, action, case, t, note=\"\"):\n", + " \"\"\"권한·순서·사유를 검사한 뒤 상태를 바꾼다. 거부도 감사 로그에 남긴다.\"\"\"\n", + " self.n_try += 1\n", + " role = USERS.get(user, \"미등록\")\n", + " cur = self.state.get(case, \"-\")\n", + " err = None\n", + " if action not in ROLE_ACTIONS.get(role, set()):\n", + " err = f\"권한 없음({role} 역할에 {action} 권한 없음)\"\n", + " elif FLOW[action][0] != cur:\n", + " err = f\"순서 위반(현재 {cur})\"\n", + " elif action in (\"reject\", \"approve_stop\", \"review\") and len(note.strip()) < 5:\n", + " err = \"사유·근거 입력 필수\"\n", + " if err is None:\n", + " self.state[case] = FLOW[action][1]\n", + " self.log.add(t=t, user=user, role=role, case=case, action=action, result=\"거부\" if err else \"처리\",\n", + " detail=err or note, state=self.state.get(case, \"-\"))\n", + " return err is None\n", + "\n", + "\n", + "# 1) 경보 생성: 가상 벤치마크에서 위험 판정이 2시간 이상 이어진 구간을 '경보 사건'으로 묶는다\n", + "c = clean(make_dataset(days=14, seed=11, n_events=10, n_faults=14))\n", + "c[\"g\"] = rule_grade(c)\n", + "cases = []\n", + "for sid, s in c.groupby(\"source_id\"):\n", + " d = (s[\"g\"] == 2).astype(int).to_numpy()\n", + " seg = np.split(np.arange(len(d)), np.where(np.diff(d) != 0)[0] + 1)\n", + " for idx in seg:\n", + " if d[idx[0]] == 1 and len(idx) >= 2:\n", + " r = s.iloc[idx[0]]\n", + " cases.append({\"case\": f\"AL-{len(cases) + 1:02d}\", \"source\": sid, \"type\": r[\"source_type\"], \"t\": int(r[\"t\"]),\n", + " \"hours\": len(idx), \"real\": bool((s.iloc[idx][\"grade\"] == 2).mean() >= 0.5), \"basis\": reasons(s.iloc[idx[-1]])})\n", + "Q = pd.DataFrame(cases)\n", + "# 우선순위: 지속시간 + 급수차·정수장(공급 인원 많음) 가중 + 대장균·중금속 포함 가중\n", + "Q[\"priority\"] = Q[\"hours\"].clip(upper=12) + Q[\"type\"].isin([\"water_truck\", \"purification_unit\"]) * 4 \\\n", + " + Q[\"basis\"].str.contains(\"대장균|중금속\") * 3\n", + "Q = Q.sort_values([\"priority\", \"t\"], ascending=[False, True]).reset_index(drop=True)\n", + "print(f\"[교육용가상자료] 경보 사건 {len(Q)}건이 검토 큐에 등록됨(자동 급수 중단 0건)\")\n", + "\n", + "# 2) 결재 시뮬레이션: 채수 담당 30분 내 채수, 예방의학 담당 재검사 결과로 의견, 지휘관이 승인·반려\n", + "wf = Workflow()\n", + "rng = np.random.default_rng(5)\n", + "wait = []\n", + "for _, q in Q.iterrows():\n", + " t = q[\"t\"] * 60\n", + " wf.act(\"sys\", \"enqueue\", q[\"case\"], t, \"기준치 초과 경보 자동 등록\")\n", + " t += int(rng.integers(10, 30)); wf.act(\"op01\", \"sample\", q[\"case\"], t, \"현장 키트 재채수\")\n", + " t += int(rng.integers(20, 60))\n", + " opinion = \"재검사 위험 확인, 급수 중단 권고\" if q[\"real\"] else \"재검사 정상, 센서 점검 권고\"\n", + " wf.act(\"med01\", \"review\", q[\"case\"], t, opinion)\n", + " t += int(rng.integers(5, 25))\n", + " if q[\"real\"]:\n", + " wf.act(\"cdr01\", \"approve_stop\", q[\"case\"], t, f\"{q['basis']} 확인, 대체 급수원 전환\")\n", + " else:\n", + " wf.act(\"cdr01\", \"reject\", q[\"case\"], t, \"재검사 정상, 급수 유지하고 센서 교정\")\n", + " wait.append(t - q[\"t\"] * 60)\n", + "st = pd.Series(wf.state).value_counts()\n", + "print(\"결재 결과:\", st.to_dict(), f\"/ 경보부터 결정까지 평균 {np.mean(wait):.0f}분, 최대 {max(wait)}분\")\n", + "print(\"\\n[검토 큐 상위 5건]\")\n", + "print(Q.head(5)[[\"case\", \"source\", \"type\", \"t\", \"hours\", \"priority\", \"basis\"]].to_string(index=False))\n", + "\n", + "# 3) 권한 우회·절차 위반 시도 6종 (사이사이 정상 절차를 진행)\n", + "wf.act(\"sys\", \"enqueue\", \"AL-X\", 99999, \"시험 경보\")\n", + "STEPS = [(\"위반\", \"op01\", \"approve_stop\", \"측정병이 직접 급수 중단\", \"중단\"), (\"위반\", \"sys\", \"approve_stop\", \"시스템 자동 확정\", \"자동\"),\n", + " (\"위반\", \"cdr01\", \"approve_stop\", \"채수·검토 없이 지휘관 승인\", \"즉시 중단\"), (\"위반\", \"hacker\", \"review\", \"미등록 사용자 검토\", \"위험 확인됨\"),\n", + " (\"정상\", \"op01\", \"sample\", \"정상 채수\", \"재채수\"), (\"위반\", \"med01\", \"review\", \"근거 없는 검토 의견\", \"\"),\n", + " (\"정상\", \"med01\", \"review\", \"정상 검토\", \"재검사 정상 확인\"), (\"위반\", \"cdr01\", \"reject\", \"사유 없는 반려\", \"\")]\n", + "blocked = n_v = 0\n", + "print(\"\\n[권한 우회·절차 위반 시도]\")\n", + "for kind, u, a, desc, note in STEPS:\n", + " ok = wf.act(u, a, \"AL-X\", 99999, note)\n", + " if kind == \"위반\":\n", + " n_v += 1; blocked += not ok\n", + " print(f\" {desc:16s} -> {'차단' if not ok else '통과(문제)'}: {wf.log.rows[-1]['detail']}\")\n", + "print(f\"우회·위반 시도 차단 {blocked}/{n_v} ({blocked / n_v:.0%})\")\n", + "\n", + "# 4) 감사 로그 무결성과 로깅 성공률\n", + "n_log = len(wf.log.rows)\n", + "print(f\"\\n감사 로그 {n_log}건 / 요청 {wf.n_try}건 -> 로깅 성공률 {n_log / wf.n_try:.0%}, 해시 체인 검증: {'정상' if wf.log.verify() else '위변조'}\")\n", + "wf.log.rows[5][\"detail\"] = \"기록 임의 수정\"\n", + "print(f\"5번째 기록 임의 수정 후 검증: {'정상' if wf.log.verify() else '위변조 탐지'}\")\n", + "print(\"※ 급수 중단·재개는 지휘관 결정 사항이며, 시스템은 경보와 근거만 제공한다. 실제 판정은 공식 기준과 의무·환경 담당 확인에 따른다.\")\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 07 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + "[교육용가상자료] 경보 사건 10건이 검토 큐에 등록됨(자동 급수 중단 0건)\n", + "결재 결과: {'급수중단': 9, '급수유지': 1} / 경보부터 결정까지 평균 69분, 최대 82분\n", + "\n", + "[검토 큐 상위 5건]\n", + " case source type t hours priority basis\n", + "AL-02 W03 purification_unit 243 23 19 잔류염소 0.00mg/L, 대장균 9\n", + "AL-01 W02 water_truck 147 21 16 pH 8.87, 탁도 1.21NTU\n", + "AL-03 W04 purification_unit 288 16 16 pH 8.72, 탁도 1.12NTU\n", + "AL-09 W08 river_intake 69 22 15 중금속 15.0ug/L\n", + "AL-07 W07 river_intake 115 29 15 탁도 3.11NTU, 잔류염소 0.08mg/L, 대장균 39\n", + "\n", + "[권한 우회·절차 위반 시도]\n", + " 측정병이 직접 급수 중단 -> 차단: 권한 없음(측정병 역할에 approve_stop 권한 없음)\n", + " 시스템 자동 확정 -> 차단: 권한 없음(시스템 역할에 approve_stop 권한 없음)\n", + " 채수·검토 없이 지휘관 승인 -> 차단: 순서 위반(현재 채수대기)\n", + " 미등록 사용자 검토 -> 차단: 권한 없음(미등록 역할에 review 권한 없음)\n", + " 근거 없는 검토 의견 -> 차단: 사유·근거 입력 필수\n", + " 사유 없는 반려 -> 차단: 사유·근거 입력 필수\n", + "우회·위반 시도 차단 6/6 (100%)\n", + "\n", + "감사 로그 49건 / 요청 49건 -> 로깅 성공률 100%, 해시 체인 검증: 정상\n", + "5번째 기록 임의 수정 후 검증: 위변조 탐지\n", + "※ 급수 중단·재개는 지휘관 결정 사항이며, 시스템은 경보와 근거만 제공한다. 실제 판정은 공식 기준과 의무·환경 담당 확인에 따른다.\n", + "```\n", + "
\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 08. 오염물질 대량 유입 가상 시나리오 부하 테스트 및 경보 반응 시간 검증\n", + "**소요시간**: 약 3.5시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "오염물질 대량 유입 가상 시나리오 부하 테스트를 수행한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **오염물질이 대량 유입되는 가상 시나리오에서 파이프라인 부하 시험을 하고 경보 반응 시간과 Fail-safe 동작을 검증한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **급수원을 8곳에서 48곳으로 늘려 트래픽을 500% 키우고 30곳에 오염이 동시에 유입되는 상황에서 수집·정제·판정 주기의 처리 시간, 시스템 중단, 데이터 유실, 메모리 증가, 경보 탐지와 반응 시간을 평시와 비교 수행하고 검증하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `tests/wel03_stress_test.py 및 스트레스 테스트 분석 보고서` 형태의 결과물을 완성한다.\n", + "\n", + "### 🔍 평가 및 검증 기준 (PASS 조건)\n", + "#### 미션 판정\n", + "\n", + "**평가 구분:** 필수\n", + "\n", + "**핵심 평가영역:** 극단 상황에서의 안전 실패(Fail-safe) 및 기능 축소 작동 적절성\n", + "\n", + "#### 미션 PASS 조건\n", + "\n", + "- 정량 기준: 동시 트래픽 500% 증가 시 시스템 다운 0건, 데이터 유실률 0%\n", + "\n", + "\n", + "- 진행 기준: 대량 시뮬레이션 부하 주입 테스트 통과 및 병목 구간 프로파일링 완료\n", + "\n", + "\n", + "- 검증 방법: 부하 테스트 도구를 통한 처리량(Throughput) 및 메모리 누수 검증\n", + "\n", + "\n", + "#### 채점자 판정 메모\n", + "\n", + "> 단순 코드 실행 여부뿐만 아니라 출력된 수치와 근거 자료가 군 실무 요구사항에 부합하는지 면밀히 확인한다.\n", + "\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "\"\"\"가상 급수원을 8곳에서 48곳(트래픽 500% 증가)으로 늘리고 30곳에 오염물질이 동시에 유입되는 시나리오로 수집·판정 파이프라인 부하 시험을 수행해, 시스템 중단·데이터 유실·메모리 증가·경보 반응 시간과 과부하 시 기능 축소(Fail-safe) 동작을 검증한다.\"\"\"\n", + "import time\n", + "import numpy as np\n", + "import pandas as pd\n", + "import matplotlib.pyplot as plt\n", + "\n", + "# ---------------------------------------------------------------------------\n", + "# [교육용가상자료] 야전 급수원 가상 수질 센서 데이터 생성기 (실제 부대·위치·검사 결과 없음)\n", + "# 가상 급수원 8곳 x 14일 x 1시간 간격. 실제 수질(true_*)·센서 측정값·오염 사건·센서 고장 정답을 함께 만든다.\n", + "# 기준치는 교육용 가정값이며, 실제 음용 판정은 공인 검사 결과와 의무(예방의학) 담당 기준에 따른다.\n", + "# ---------------------------------------------------------------------------\n", + "PARAMS = [\"ph\", \"turbidity\", \"chlorine\", \"metal\", \"ecoli\"]\n", + "SOURCES = [(\"W01\", \"water_truck\", 1.0, 2.0), (\"W02\", \"water_truck\", 3.5, 1.2), (\"W03\", \"purification_unit\", 2.2, 4.1),\n", + " (\"W04\", \"purification_unit\", 5.0, 3.3), (\"W05\", \"well\", 0.8, 5.6), (\"W06\", \"well\", 4.4, 6.0),\n", + " (\"W07\", \"river_intake\", 6.3, 1.8), (\"W08\", \"river_intake\", 6.9, 4.9)]\n", + "BASE = {\"water_truck\": (7.2, 0.25, 0.75, 1.5), \"purification_unit\": (7.0, 0.20, 0.85, 1.2),\n", + " \"well\": (6.8, 0.45, 0.50, 3.0), \"river_intake\": (7.3, 0.55, 0.60, 2.0)} # pH, 탁도(NTU), 잔류염소(mg/L), 중금속(ug/L)\n", + "LIMITS = {\"ph\": (5.8, 8.5), \"turbidity\": (1.0, 2.0), \"chlorine\": (0.2, 0.1), \"metal\": (5.0, 10.0), \"ecoli\": (1, 1)} # (주의, 위험) 가정 기준\n", + "EVENT_TYPES = { # 오염 사건: 변수별 최대 변화량, 최대치 도달 시간(h)\n", + " \"sewage\": ({\"turbidity\": 2.8, \"chlorine\": -0.5, \"ecoli\": 30, \"ph\": -0.3}, 4),\n", + " \"metal\": ({\"metal\": 22.0, \"ph\": -0.5}, 4),\n", + " \"disinfect_fail\": ({\"chlorine\": -0.8, \"ecoli\": 6}, 6),\n", + " \"chemical\": ({\"ph\": 1.7, \"turbidity\": 1.0}, 3),\n", + " \"creeping_metal\": ({\"metal\": 13.0}, 18),\n", + "}\n", + "FAULT_TYPES = [\"stuck\", \"spike\", \"drift\", \"dropout\"]\n", + "NOISE = {\"ph\": 0.04, \"turbidity\": 0.06, \"chlorine\": 0.03, \"metal\": 0.35}\n", + "DIGITS = {\"ph\": 2, \"turbidity\": 2, \"chlorine\": 2, \"metal\": 1}\n", + "\n", + "\n", + "def make_dataset(days=14, seed=7, n_events=6, n_faults=10, events=None, faults=None, sources=None):\n", + " \"\"\"events: [(급수원 번호, 사건 유형, 시작 시각 h, 지속 h, 강도)], faults: [(급수원 번호, 고장 유형, 항목, 시작 h, 지속 h)]\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " sources = sources or SOURCES\n", + " T = days * 24\n", + " tt = np.arange(T)\n", + " hour = tt % 24\n", + " night = (hour < 6) | (hour >= 20)\n", + " rain = np.zeros(T)\n", + " for _ in range(max(1, days // 4)):\n", + " s0, d = int(rng.integers(0, T - 14)), int(rng.integers(6, 14))\n", + " rain[s0:s0 + d] = np.maximum(rain[s0:s0 + d], rng.uniform(0.6, 1.0))\n", + " if events is None:\n", + " events = [(int(rng.integers(len(sources))), str(rng.choice(list(EVENT_TYPES))), int(rng.integers(min(24, T // 4), max(T // 4 + 1, T - 48))),\n", + " int(rng.integers(10, 30)), 1.0) for _ in range(n_events)]\n", + " if faults is None:\n", + " faults = [(int(rng.integers(len(sources))), str(rng.choice(FAULT_TYPES)), str(rng.choice([\"ph\", \"turbidity\", \"chlorine\", \"metal\"])),\n", + " int(rng.integers(12, max(13, T - 30))), int(rng.integers(6, 24))) for _ in range(n_faults)]\n", + " out = []\n", + " for si, (sid, stype, x, y) in enumerate(sources):\n", + " ph0, tb0, cl0, mt0 = BASE[stype]\n", + " tr = {\"ph\": ph0 + 0.08 * np.sin(2 * np.pi * hour / 24) + rng.normal(0, 0.03, T),\n", + " \"turbidity\": tb0 * (1 + 0.1 * rng.normal(0, 1, T)),\n", + " \"chlorine\": (cl0 * np.exp(-0.022 * hour) if stype == \"water_truck\" else cl0 + rng.normal(0, 0.03, T)),\n", + " \"metal\": mt0 + rng.normal(0, 0.2, T), \"ecoli\": np.zeros(T)}\n", + " if stype in (\"river_intake\", \"well\"):\n", + " tr[\"turbidity\"] = tr[\"turbidity\"] + rain * (0.9 if stype == \"river_intake\" else 0.45)\n", + " f_all, etype = np.zeros(T), np.array([\"none\"] * T, dtype=object)\n", + " for (s_i, et, onset, dur, scale) in events:\n", + " if s_i != si:\n", + " continue\n", + " delta, ramp = EVENT_TYPES[et]\n", + " f = np.clip((tt - onset + 1) / ramp, 0, 1) * ((tt >= onset) & (tt < onset + dur + ramp))\n", + " for k, v in delta.items():\n", + " tr[k] = tr[k] + v * f * scale\n", + " etype[f > 0] = et\n", + " f_all = np.maximum(f_all, f)\n", + " tr[\"chlorine\"] = np.clip(tr[\"chlorine\"], 0.02, None)\n", + " tr[\"turbidity\"] = np.clip(tr[\"turbidity\"], 0.05, None)\n", + " tr[\"metal\"] = np.clip(tr[\"metal\"], 0.1, None)\n", + " grade = np.where(f_all >= 0.2, 2, 0)\n", + " mild = (tr[\"turbidity\"] > LIMITS[\"turbidity\"][0]) | (tr[\"chlorine\"] < LIMITS[\"chlorine\"][0]) | (tr[\"metal\"] > LIMITS[\"metal\"][0])\n", + " grade = np.where((grade == 0) & mild, 1, grade)\n", + " ms = {}\n", + " for p in [\"ph\", \"turbidity\", \"chlorine\", \"metal\"]:\n", + " sd = NOISE[p] * np.where(night, 1.5, 1.0) * (tr[p] if p == \"turbidity\" else 1)\n", + " ms[p] = np.round(np.clip(tr[p] + rng.normal(0, 1, T) * sd, 0, None), DIGITS[p])\n", + " ms[\"ecoli\"] = (rng.poisson(tr[\"ecoli\"]) + (rng.random(T) < 0.004)).astype(float)\n", + " fault, fparam = np.array([\"none\"] * T, dtype=object), np.array([\"-\"] * T, dtype=object)\n", + " for (s_i, ft, p, s0, d) in faults:\n", + " if s_i != si:\n", + " continue\n", + " seg = slice(s0, min(T, s0 + d))\n", + " if ft == \"stuck\":\n", + " ms[p][seg] = ms[p][s0]\n", + " elif ft == \"spike\":\n", + " seg = slice(s0, s0 + 1)\n", + " ms[p][s0] = {\"ph\": 11.8, \"turbidity\": 38.0, \"chlorine\": 0.0, \"metal\": 75.0}[p]\n", + " elif ft == \"drift\":\n", + " ramp = np.linspace(0, 1, seg.stop - s0)\n", + " ms[p][seg] = np.clip(ms[p][seg] + ramp * {\"ph\": -1.6, \"turbidity\": 2.6, \"chlorine\": -0.75, \"metal\": 12.0}[p], 0, None).round(DIGITS[p])\n", + " elif ft == \"dropout\":\n", + " for q in PARAMS:\n", + " ms[q][seg] = np.nan\n", + " p = \"all\"\n", + " fault[seg], fparam[seg] = ft, p\n", + " df = pd.DataFrame({\"source_id\": sid, \"source_type\": stype, \"x_km\": x, \"y_km\": y, \"t\": tt,\n", + " \"cond\": np.where(rain > 0, \"storm\", np.where(night, \"night\", \"day\")),\n", + " **{p: ms[p] for p in PARAMS}, **{f\"true_{p}\": tr[p] for p in PARAMS},\n", + " \"event\": etype, \"fault\": fault, \"fault_param\": fparam, \"grade\": grade})\n", + " out.append(df)\n", + " return pd.concat(out, ignore_index=True)\n", + "# ---------------------------------------------------------------------------\n", + "\n", + "# ---- 전처리 공통 함수: 물리 범위 점검 -> 고정값(stuck) 표시 -> 단발 스파이크 제거 -> 결측 보정 ----\n", + "PHYS = {\"ph\": (0, 14), \"turbidity\": (0, 1000), \"chlorine\": (0, 10), \"metal\": (0, 1000), \"ecoli\": (0, 100000)}\n", + "SPIKE_K = {\"day\": 6.0, \"night\": 7.0, \"storm\": 9.0} # 상황별(주간/야간/악천후) 스파이크 판정 배수\n", + "MIN_JUMP = {\"ph\": 0.8, \"turbidity\": 1.5, \"chlorine\": 0.3, \"metal\": 6.0}\n", + "STUCK_RUN, SHORT_GAP = 6, 3\n", + "\n", + "\n", + "def clean(df, spike_k=None, min_jump=None):\n", + " \"\"\"급수원별 시계열을 정제하고 항목별 처리 내역(<항목>_flag: range/stuck/spike/interp/long_gap)을 남긴다.\"\"\"\n", + " # [TODO : 학생 실습 8] 오염물질 대량 유입 가상 시나리오 부하 테스트 및 경보 반응 시간 검증 관련 핵심 로직을 구현하세요.\n", + " # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n", + " # <<< 여기에 코드를 작성하세요 >>>\n", + " pass # 작성 후 pass를 제거하세요.\n", + "GRADE_NAME = {0: \"안전\", 1: \"주의\", 2: \"위험\"}\n", + "\n", + "\n", + "def rule_grade(df):\n", + " \"\"\"기준치 1차 필터: 위험(음용 부적합 의심) = 한 항목이라도 위험 기준 초과, 주의 = 주의 기준 초과.\"\"\"\n", + " # [TODO : 학생 실습 8] 오염물질 대량 유입 가상 시나리오 부하 테스트 및 경보 반응 시간 검증 관련 핵심 로직을 구현하세요.\n", + " # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n", + " # <<< 여기에 코드를 작성하세요 >>>\n", + " pass # 작성 후 pass를 제거하세요.\n", + "def reasons(row):\n", + " r = []\n", + " if not LIMITS[\"ph\"][0] <= row[\"ph\"] <= LIMITS[\"ph\"][1]:\n", + " r.append(f\"pH {row['ph']:.2f}\")\n", + " if row[\"turbidity\"] > LIMITS[\"turbidity\"][0]:\n", + " r.append(f\"탁도 {row['turbidity']:.2f}NTU\")\n", + " if row[\"chlorine\"] < LIMITS[\"chlorine\"][0]:\n", + " r.append(f\"잔류염소 {row['chlorine']:.2f}mg/L\")\n", + " if row[\"metal\"] > LIMITS[\"metal\"][0]:\n", + " r.append(f\"중금속 {row['metal']:.1f}ug/L\")\n", + " if row[\"ecoli\"] >= 1:\n", + " r.append(f\"대장균 {row['ecoli']:.0f}\")\n", + " return \", \".join(r) or \"-\"\n", + "\n", + "\n", + "FEATS = [f\"{p}{s}\" for p in [\"ph\", \"turbidity\", \"chlorine\", \"metal\"] for s in [\"\", \"_d1\", \"_d3\", \"_dev\", \"_sd6\"]] + \\\n", + " [\"ecoli\", \"ecoli_3h\", \"storm\", \"night\", \"n_flag\", \"truck\", \"purif\", \"well\", \"river\"]\n", + "\n", + "\n", + "def add_features(c):\n", + " \"\"\"시간 순서를 지키는(과거 값만 쓰는) 급수원별 특징: 변화량, 24시간 기준선 대비 편차, 6시간 변동성, 대장균 누적 등.\"\"\"\n", + " c = c.sort_values([\"source_id\", \"t\"]).reset_index(drop=True)\n", + " g = c.groupby(\"source_id\")\n", + " for p in [\"ph\", \"turbidity\", \"chlorine\", \"metal\"]:\n", + " c[f\"{p}_d1\"] = g[p].diff().fillna(0)\n", + " c[f\"{p}_d3\"] = g[p].diff(3).fillna(0)\n", + " base = g[p].transform(lambda s: s.shift(1).rolling(24, min_periods=4).median())\n", + " c[f\"{p}_dev\"] = (c[p] - base).fillna(0)\n", + " c[f\"{p}_sd6\"] = g[p].transform(lambda s: s.rolling(6, min_periods=2).std()).fillna(0)\n", + " c[\"ecoli_3h\"] = g[\"ecoli\"].transform(lambda s: s.rolling(3, min_periods=1).sum())\n", + " c[\"storm\"], c[\"night\"] = (c[\"cond\"] == \"storm\").astype(int), (c[\"cond\"] == \"night\").astype(int)\n", + " flags = [f\"{p}_flag\" for p in PARAMS if f\"{p}_flag\" in c]\n", + " c[\"n_flag\"] = sum(c[f].isin([\"stuck\", \"spike\", \"long_gap\", \"range\"]).astype(int) for f in flags) if flags else 0\n", + " for k, name in [(\"truck\", \"water_truck\"), (\"purif\", \"purification_unit\"), (\"well\", \"well\"), (\"river\", \"river_intake\")]:\n", + " c[k] = (c[\"source_type\"] == name).astype(int)\n", + " return c\n", + "\n", + "BUDGET_MS = 300 # 1시간 주기 처리 예산(모의): 직전 주기가 예산을 넘으면 기능 축소 모드로 전환\n", + "FULL_EVERY = 12 # 기능 축소 중에도 12시간마다 전체 정제(지연 처리)를 수행\n", + "\n", + "\n", + "def make_sources(n, seed=0):\n", + " rng = np.random.default_rng(seed)\n", + " types = [s[1] for s in SOURCES]\n", + " return [(f\"W{i + 1:02d}\", types[i % len(types)], float(round(rng.uniform(0, 30), 1)), float(round(rng.uniform(0, 30), 1))) for i in range(n)]\n", + "\n", + "\n", + "def lite_check(batch, last):\n", + " \"\"\"기능 축소 모드: 레코드 단위 범위 점검 -> 직전 정상값 유지 -> 기준치 판정(정제·특징 계산 생략).\"\"\"\n", + " b = batch.copy()\n", + " for p in PARAMS:\n", + " v = pd.to_numeric(b[p], errors=\"coerce\")\n", + " lo, hi = PHYS[p]\n", + " v = v.where(v.between(lo, hi))\n", + " b[p] = v.fillna(b[\"source_id\"].map(last.get(p, {}))).fillna(0 if p == \"ecoli\" else np.nan)\n", + " last.setdefault(p, {}).update(b.dropna(subset=[p]).set_index(\"source_id\")[p].to_dict())\n", + " b = b.dropna(subset=[\"ph\", \"turbidity\", \"chlorine\", \"metal\"])\n", + " b[\"g\"] = rule_grade(b)\n", + " return b\n", + "\n", + "\n", + "def run(n_src, days, events, seed, mode_auto=True, hours=None):\n", + " src = make_sources(n_src)\n", + " data = make_dataset(days=days, seed=seed, events=events, faults=[], sources=src)\n", + " rng = np.random.default_rng(seed)\n", + " bad_idx = rng.choice(len(data), int(len(data) * 0.01), replace=False) # 1% 손상 레코드 주입(문자·None)\n", + " data[PARAMS] = data[PARAMS].astype(object)\n", + " data.loc[bad_idx[: len(bad_idx) // 2], \"turbidity\"] = \"ERR\"\n", + " data.loc[bad_idx[len(bad_idx) // 2:], \"ph\"] = None\n", + " stored, quarantine, alerts, ticks, down, last = 0, 0, {}, [], 0, {}\n", + " buf, mode, switched = [], \"full\", None\n", + " mem = []\n", + " for h in range(hours or days * 24):\n", + " t0 = time.perf_counter()\n", + " try:\n", + " batch = data[data[\"t\"] == h]\n", + " if mode_auto and mode == \"full\" and ticks and ticks[-1] > BUDGET_MS:\n", + " mode, switched = \"lite\", h # 한 번 전환하면 부하 해소 확인 전까지 유지\n", + " ok_mask = batch[[\"ph\", \"turbidity\", \"chlorine\", \"metal\"]].apply(pd.to_numeric, errors=\"coerce\").notna().all(axis=1)\n", + " quarantine += int((~ok_mask).sum())\n", + " if mode == \"full\":\n", + " buf.append(batch[ok_mask].astype({p: float for p in PARAMS}))\n", + " win = pd.concat(buf[-24:])\n", + " c = clean(win)\n", + " res = c[c[\"t\"] == h].assign(g=lambda d: rule_grade(d))\n", + " else:\n", + " res = lite_check(batch, last)\n", + " buf.append(batch[ok_mask].astype({p: float for p in PARAMS}))\n", + " if h % FULL_EVERY == FULL_EVERY - 1: # 지연 전체 정제(기록 보정용)\n", + " clean(pd.concat(buf[-FULL_EVERY:]))\n", + " buf = buf[-24:]\n", + " stored += len(batch)\n", + " for sid in res.loc[res[\"g\"] == 2, \"source_id\"]:\n", + " alerts.setdefault(sid, []).append(h)\n", + " except Exception:\n", + " down += 1\n", + " ticks.append((time.perf_counter() - t0) * 1000)\n", + " mem.append(sum(b.memory_usage(deep=True).sum() for b in buf) / 1e6) # 보관 버퍼 메모리(MB)\n", + " last = {p: last.get(p, {}) for p in PARAMS}\n", + " react = []\n", + " for e in events:\n", + " hs = [h for h in alerts.get(src[e[0]][0], []) if h >= e[2]]\n", + " if hs:\n", + " react.append(hs[0] - e[2])\n", + " return {\"src\": n_src, \"recs\": len(data), \"stored\": stored, \"quarantine\": quarantine, \"down\": down, \"mode\": mode if switched is None else f\"lite({switched}h~)\",\n", + " \"tick_avg\": np.mean(ticks), \"tick_max\": np.max(ticks), \"mem_first\": np.mean(mem[24:48]) if len(mem) >= 48 else mem[-1], \"mem_last\": np.mean(mem[-24:]),\n", + " \"react\": react, \"detected\": len(react), \"n_events\": len(events), \"ticks\": ticks}\n", + "\n", + "\n", + "days = 2\n", + "ev8 = [(i, et, 20 + i, 30, 1.0) for i, et in zip(range(5), [\"sewage\", \"metal\", \"chemical\", \"disinfect_fail\", \"sewage\"])]\n", + "ev48 = [(i, [\"sewage\", \"metal\", \"chemical\", \"disinfect_fail\"][i % 4], 20 + i % 6, 30, 1.0) for i in range(0, 48, 48 // 30 + 0)][:30]\n", + "t_all = time.perf_counter()\n", + "R = {\"평시(8곳)\": run(8, days, ev8, 1), \"부하 500%(48곳, 30곳 동시 오염)\": run(48, days, ev48, 2)}\n", + "print(f\"[교육용가상자료] 부하 시험 {days}일 모의, 전체 실행 {time.perf_counter() - t_all:.1f}초\")\n", + "rows = []\n", + "for k, r in R.items():\n", + " loss = 1 - r[\"stored\"] / r[\"recs\"]\n", + " rows.append({\"구분\": k, \"레코드\": r[\"recs\"], \"격리(손상)\": r[\"quarantine\"], \"유실률\": f\"{loss:.1%}\", \"중단\": r[\"down\"],\n", + " \"모드\": r[\"mode\"], \"주기 평균ms\": round(r[\"tick_avg\"], 1), \"주기 최대ms\": round(r[\"tick_max\"], 1),\n", + " \"메모리MB(초기->말기)\": f\"{r['mem_first']:.1f}->{r['mem_last']:.1f}\",\n", + " \"경보 탐지\": f\"{r['detected']}/{r['n_events']}\", \"반응 평균h\": round(float(np.mean(r[\"react\"])), 1)})\n", + "print(pd.DataFrame(rows).set_index(\"구분\").T.to_string())\n", + "S = R[\"부하 500%(48곳, 30곳 동시 오염)\"]\n", + "print(f\"\\n부하 500%: 시스템 중단 {S['down']}건(목표 0건: {'달성' if S['down'] == 0 else '미달'}), \"\n", + " f\"데이터 유실률 {1 - S['stored'] / S['recs']:.1%}(목표 0%: {'달성' if S['stored'] == S['recs'] else '미달'})\")\n", + "print(f\"메모리 말기/초기 비율 {S['mem_last'] / max(S['mem_first'], 1e-6):.2f} (버퍼 24시간 고정, 1.2 이하면 누수 없음), \"\n", + " f\"경보 반응 최대 {max(S['react'])}시간\")\n", + "\n", + "# 기능 축소를 끄고 전체 정제를 강행하면 주기 시간이 얼마나 늘어나는지 (6시간만 측정)\n", + "F = run(48, 1, [], 3, mode_auto=False, hours=6)\n", + "print(f\"기능 축소 미적용(전체 정제 강행) 주기 평균 {np.mean(F['ticks']):.0f}ms vs 기능 축소 {S['tick_avg']:.0f}ms \"\n", + " f\"(예산 {BUDGET_MS}ms) -> 병목: 급수원별 정제 반복(clean 함수)\")\n", + "print(\"Fail-safe 원칙: 손상 레코드는 버리지 않고 격리 보관, 판정은 직전 정상값으로 이어가며, 과부하 시 정제·특징 계산을 미루고 경보 판정은 유지\")\n", + "\n", + "plt.figure(figsize=(9, 3.5))\n", + "plt.plot(R[\"평시(8곳)\"][\"ticks\"], label=\"normal (8 sources)\")\n", + "plt.plot(S[\"ticks\"], label=\"500% load (48 sources, degraded mode)\")\n", + "plt.plot(F[\"ticks\"], label=\"500% load, full mode forced (6 h)\")\n", + "plt.axhline(BUDGET_MS, color=\"red\", ls=\"--\", lw=1, label=\"budget\")\n", + "plt.xlabel(\"simulated hour\"); plt.ylabel(\"processing ms per tick\"); plt.legend(); plt.title(\"Stress test tick time (synthetic)\")\n", + "plt.tight_layout(); plt.show()\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 08 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + "[교육용가상자료] 부하 시험 2일 모의, 전체 실행 11.9초\n", + "구분 평시(8곳) 부하 500%(48곳, 30곳 동시 오염)\n", + "레코드 384 2304\n", + "격리(손상) 3 23\n", + "유실률 0.0% 0.0%\n", + "중단 0 0\n", + "모드 full lite(1h~)\n", + "주기 평균ms 127.1 87.7\n", + "주기 최대ms 183.6 742.7\n", + "메모리MB(초기->말기) 0.1->0.1 0.6->0.6\n", + "경보 탐지 5/5 30/30\n", + "반응 평균h 0.8 0.9\n", + "\n", + "부하 500%: 시스템 중단 0건(목표 0건: 달성), 데이터 유실률 0.0%(목표 0%: 달성)\n", + "메모리 말기/초기 비율 1.00 (버퍼 24시간 고정, 1.2 이하면 누수 없음), 경보 반응 최대 2시간\n", + "기능 축소 미적용(전체 정제 강행) 주기 평균 808ms vs 기능 축소 88ms (예산 300ms) -> 병목: 급수원별 정제 반복(clean 함수)\n", + "Fail-safe 원칙: 손상 레코드는 버리지 않고 격리 보관, 판정은 직전 정상값으로 이어가며, 과부하 시 정제·특징 계산을 미루고 경보 판정은 유지\n", + "```\n", + "
\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 09. AAR 기반 수질 이상 오판 사례 분석 및 수질 위험 판단 임계치 재조정\n", + "**소요시간**: 약 3.0시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "AAR 기반 수질 이상 오판 사례 분석 및 판단 임계치를 재조정한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **AAR로 수질 이상 오판 사례를 분석하고 판단 임계치를 재조정해 효과를 별도 검증 세트로 확인한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **튜닝용 가상 자료에서 Baseline 오판을 오경보(센서 드리프트, 단발 대장균), 경계 혼동, 놓침(화학, 중금속 완만 증가) 등 원인별로 분류 수행하고 검증하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `docs/wel03_aar_report.md 및 configs/wel03_tuned_params.yaml` 형태의 결과물을 완성한다.\n", + "\n", + "### 🔍 평가 및 검증 기준 (PASS 조건)\n", + "#### 미션 판정\n", + "\n", + "**평가 구분:** 필수\n", + "\n", + "**핵심 평가영역:** 오류 원인 규명의 객관성 및 개선 조치의 논리적 타당성\n", + "\n", + "#### 미션 PASS 조건\n", + "\n", + "- 정량 기준: AAR 피드백 적용 후 종합 정확도 10%p 이상 향상, 재발 오류 0건\n", + "\n", + "\n", + "- 진행 기준: AAR 보고서 작성 및 파라미터 튜닝 전후 회귀 테스트 통과\n", + "\n", + "\n", + "- 검증 방법: 회귀 테스트(Regression Test) 스위트 전체 자동 실행 및 비교\n", + "\n", + "\n", + "#### 채점자 판정 메모\n", + "\n", + "> 단순 코드 실행 여부뿐만 아니라 출력된 수치와 근거 자료가 군 실무 요구사항에 부합하는지 면밀히 확인한다.\n", + "\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "\"\"\"AAR(사후 검토)로 기준치 Baseline의 수질 이상 오판 사례를 원인별로 분류하고, 단발 대장균 검출·완만한 중금속 증가에 대한 판단 임계치를 튜닝 세트에서 재조정한 뒤 검증 세트·벤치마크 회귀 테스트로 전후를 비교하고 조정 파라미터를 YAML로 출력한다.\"\"\"\n", + "import itertools\n", + "import numpy as np\n", + "import pandas as pd\n", + "import matplotlib.pyplot as plt\n", + "\n", + "# ---------------------------------------------------------------------------\n", + "# [교육용가상자료] 야전 급수원 가상 수질 센서 데이터 생성기 (실제 부대·위치·검사 결과 없음)\n", + "# 가상 급수원 8곳 x 14일 x 1시간 간격. 실제 수질(true_*)·센서 측정값·오염 사건·센서 고장 정답을 함께 만든다.\n", + "# 기준치는 교육용 가정값이며, 실제 음용 판정은 공인 검사 결과와 의무(예방의학) 담당 기준에 따른다.\n", + "# ---------------------------------------------------------------------------\n", + "PARAMS = [\"ph\", \"turbidity\", \"chlorine\", \"metal\", \"ecoli\"]\n", + "SOURCES = [(\"W01\", \"water_truck\", 1.0, 2.0), (\"W02\", \"water_truck\", 3.5, 1.2), (\"W03\", \"purification_unit\", 2.2, 4.1),\n", + " (\"W04\", \"purification_unit\", 5.0, 3.3), (\"W05\", \"well\", 0.8, 5.6), (\"W06\", \"well\", 4.4, 6.0),\n", + " (\"W07\", \"river_intake\", 6.3, 1.8), (\"W08\", \"river_intake\", 6.9, 4.9)]\n", + "BASE = {\"water_truck\": (7.2, 0.25, 0.75, 1.5), \"purification_unit\": (7.0, 0.20, 0.85, 1.2),\n", + " \"well\": (6.8, 0.45, 0.50, 3.0), \"river_intake\": (7.3, 0.55, 0.60, 2.0)} # pH, 탁도(NTU), 잔류염소(mg/L), 중금속(ug/L)\n", + "LIMITS = {\"ph\": (5.8, 8.5), \"turbidity\": (1.0, 2.0), \"chlorine\": (0.2, 0.1), \"metal\": (5.0, 10.0), \"ecoli\": (1, 1)} # (주의, 위험) 가정 기준\n", + "EVENT_TYPES = { # 오염 사건: 변수별 최대 변화량, 최대치 도달 시간(h)\n", + " \"sewage\": ({\"turbidity\": 2.8, \"chlorine\": -0.5, \"ecoli\": 30, \"ph\": -0.3}, 4),\n", + " \"metal\": ({\"metal\": 22.0, \"ph\": -0.5}, 4),\n", + " \"disinfect_fail\": ({\"chlorine\": -0.8, \"ecoli\": 6}, 6),\n", + " \"chemical\": ({\"ph\": 1.7, \"turbidity\": 1.0}, 3),\n", + " \"creeping_metal\": ({\"metal\": 13.0}, 18),\n", + "}\n", + "FAULT_TYPES = [\"stuck\", \"spike\", \"drift\", \"dropout\"]\n", + "NOISE = {\"ph\": 0.04, \"turbidity\": 0.06, \"chlorine\": 0.03, \"metal\": 0.35}\n", + "DIGITS = {\"ph\": 2, \"turbidity\": 2, \"chlorine\": 2, \"metal\": 1}\n", + "\n", + "\n", + "def make_dataset(days=14, seed=7, n_events=6, n_faults=10, events=None, faults=None, sources=None):\n", + " \"\"\"events: [(급수원 번호, 사건 유형, 시작 시각 h, 지속 h, 강도)], faults: [(급수원 번호, 고장 유형, 항목, 시작 h, 지속 h)]\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " sources = sources or SOURCES\n", + " T = days * 24\n", + " tt = np.arange(T)\n", + " hour = tt % 24\n", + " night = (hour < 6) | (hour >= 20)\n", + " rain = np.zeros(T)\n", + " for _ in range(max(1, days // 4)):\n", + " s0, d = int(rng.integers(0, T - 14)), int(rng.integers(6, 14))\n", + " rain[s0:s0 + d] = np.maximum(rain[s0:s0 + d], rng.uniform(0.6, 1.0))\n", + " if events is None:\n", + " events = [(int(rng.integers(len(sources))), str(rng.choice(list(EVENT_TYPES))), int(rng.integers(min(24, T // 4), max(T // 4 + 1, T - 48))),\n", + " int(rng.integers(10, 30)), 1.0) for _ in range(n_events)]\n", + " if faults is None:\n", + " faults = [(int(rng.integers(len(sources))), str(rng.choice(FAULT_TYPES)), str(rng.choice([\"ph\", \"turbidity\", \"chlorine\", \"metal\"])),\n", + " int(rng.integers(12, max(13, T - 30))), int(rng.integers(6, 24))) for _ in range(n_faults)]\n", + " out = []\n", + " for si, (sid, stype, x, y) in enumerate(sources):\n", + " ph0, tb0, cl0, mt0 = BASE[stype]\n", + " tr = {\"ph\": ph0 + 0.08 * np.sin(2 * np.pi * hour / 24) + rng.normal(0, 0.03, T),\n", + " \"turbidity\": tb0 * (1 + 0.1 * rng.normal(0, 1, T)),\n", + " \"chlorine\": (cl0 * np.exp(-0.022 * hour) if stype == \"water_truck\" else cl0 + rng.normal(0, 0.03, T)),\n", + " \"metal\": mt0 + rng.normal(0, 0.2, T), \"ecoli\": np.zeros(T)}\n", + " if stype in (\"river_intake\", \"well\"):\n", + " tr[\"turbidity\"] = tr[\"turbidity\"] + rain * (0.9 if stype == \"river_intake\" else 0.45)\n", + " f_all, etype = np.zeros(T), np.array([\"none\"] * T, dtype=object)\n", + " for (s_i, et, onset, dur, scale) in events:\n", + " if s_i != si:\n", + " continue\n", + " delta, ramp = EVENT_TYPES[et]\n", + " f = np.clip((tt - onset + 1) / ramp, 0, 1) * ((tt >= onset) & (tt < onset + dur + ramp))\n", + " for k, v in delta.items():\n", + " tr[k] = tr[k] + v * f * scale\n", + " etype[f > 0] = et\n", + " f_all = np.maximum(f_all, f)\n", + " tr[\"chlorine\"] = np.clip(tr[\"chlorine\"], 0.02, None)\n", + " tr[\"turbidity\"] = np.clip(tr[\"turbidity\"], 0.05, None)\n", + " tr[\"metal\"] = np.clip(tr[\"metal\"], 0.1, None)\n", + " grade = np.where(f_all >= 0.2, 2, 0)\n", + " mild = (tr[\"turbidity\"] > LIMITS[\"turbidity\"][0]) | (tr[\"chlorine\"] < LIMITS[\"chlorine\"][0]) | (tr[\"metal\"] > LIMITS[\"metal\"][0])\n", + " grade = np.where((grade == 0) & mild, 1, grade)\n", + " ms = {}\n", + " for p in [\"ph\", \"turbidity\", \"chlorine\", \"metal\"]:\n", + " sd = NOISE[p] * np.where(night, 1.5, 1.0) * (tr[p] if p == \"turbidity\" else 1)\n", + " ms[p] = np.round(np.clip(tr[p] + rng.normal(0, 1, T) * sd, 0, None), DIGITS[p])\n", + " ms[\"ecoli\"] = (rng.poisson(tr[\"ecoli\"]) + (rng.random(T) < 0.004)).astype(float)\n", + " fault, fparam = np.array([\"none\"] * T, dtype=object), np.array([\"-\"] * T, dtype=object)\n", + " for (s_i, ft, p, s0, d) in faults:\n", + " if s_i != si:\n", + " continue\n", + " seg = slice(s0, min(T, s0 + d))\n", + " if ft == \"stuck\":\n", + " ms[p][seg] = ms[p][s0]\n", + " elif ft == \"spike\":\n", + " seg = slice(s0, s0 + 1)\n", + " ms[p][s0] = {\"ph\": 11.8, \"turbidity\": 38.0, \"chlorine\": 0.0, \"metal\": 75.0}[p]\n", + " elif ft == \"drift\":\n", + " ramp = np.linspace(0, 1, seg.stop - s0)\n", + " ms[p][seg] = np.clip(ms[p][seg] + ramp * {\"ph\": -1.6, \"turbidity\": 2.6, \"chlorine\": -0.75, \"metal\": 12.0}[p], 0, None).round(DIGITS[p])\n", + " elif ft == \"dropout\":\n", + " for q in PARAMS:\n", + " ms[q][seg] = np.nan\n", + " p = \"all\"\n", + " fault[seg], fparam[seg] = ft, p\n", + " df = pd.DataFrame({\"source_id\": sid, \"source_type\": stype, \"x_km\": x, \"y_km\": y, \"t\": tt,\n", + " \"cond\": np.where(rain > 0, \"storm\", np.where(night, \"night\", \"day\")),\n", + " **{p: ms[p] for p in PARAMS}, **{f\"true_{p}\": tr[p] for p in PARAMS},\n", + " \"event\": etype, \"fault\": fault, \"fault_param\": fparam, \"grade\": grade})\n", + " out.append(df)\n", + " return pd.concat(out, ignore_index=True)\n", + "# ---------------------------------------------------------------------------\n", + "\n", + "# ---- 전처리 공통 함수: 물리 범위 점검 -> 고정값(stuck) 표시 -> 단발 스파이크 제거 -> 결측 보정 ----\n", + "PHYS = {\"ph\": (0, 14), \"turbidity\": (0, 1000), \"chlorine\": (0, 10), \"metal\": (0, 1000), \"ecoli\": (0, 100000)}\n", + "SPIKE_K = {\"day\": 6.0, \"night\": 7.0, \"storm\": 9.0} # 상황별(주간/야간/악천후) 스파이크 판정 배수\n", + "MIN_JUMP = {\"ph\": 0.8, \"turbidity\": 1.5, \"chlorine\": 0.3, \"metal\": 6.0}\n", + "STUCK_RUN, SHORT_GAP = 6, 3\n", + "\n", + "\n", + "def clean(df, spike_k=None, min_jump=None):\n", + " \"\"\"급수원별 시계열을 정제하고 항목별 처리 내역(<항목>_flag: range/stuck/spike/interp/long_gap)을 남긴다.\"\"\"\n", + " # [TODO : 학생 실습 9] AAR 기반 수질 이상 오판 사례 분석 및 수질 위험 판단 임계치 재조정 관련 핵심 로직을 구현하세요.\n", + " # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n", + " # <<< 여기에 코드를 작성하세요 >>>\n", + " pass # 작성 후 pass를 제거하세요.\n", + "GRADE_NAME = {0: \"안전\", 1: \"주의\", 2: \"위험\"}\n", + "\n", + "\n", + "def rule_grade(df):\n", + " \"\"\"기준치 1차 필터: 위험(음용 부적합 의심) = 한 항목이라도 위험 기준 초과, 주의 = 주의 기준 초과.\"\"\"\n", + " # [TODO : 학생 실습 9] AAR 기반 수질 이상 오판 사례 분석 및 수질 위험 판단 임계치 재조정 관련 핵심 로직을 구현하세요.\n", + " # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n", + " # <<< 여기에 코드를 작성하세요 >>>\n", + " pass # 작성 후 pass를 제거하세요.\n", + "def reasons(row):\n", + " r = []\n", + " if not LIMITS[\"ph\"][0] <= row[\"ph\"] <= LIMITS[\"ph\"][1]:\n", + " r.append(f\"pH {row['ph']:.2f}\")\n", + " if row[\"turbidity\"] > LIMITS[\"turbidity\"][0]:\n", + " r.append(f\"탁도 {row['turbidity']:.2f}NTU\")\n", + " if row[\"chlorine\"] < LIMITS[\"chlorine\"][0]:\n", + " r.append(f\"잔류염소 {row['chlorine']:.2f}mg/L\")\n", + " if row[\"metal\"] > LIMITS[\"metal\"][0]:\n", + " r.append(f\"중금속 {row['metal']:.1f}ug/L\")\n", + " if row[\"ecoli\"] >= 1:\n", + " r.append(f\"대장균 {row['ecoli']:.0f}\")\n", + " return \", \".join(r) or \"-\"\n", + "\n", + "\n", + "FEATS = [f\"{p}{s}\" for p in [\"ph\", \"turbidity\", \"chlorine\", \"metal\"] for s in [\"\", \"_d1\", \"_d3\", \"_dev\", \"_sd6\"]] + \\\n", + " [\"ecoli\", \"ecoli_3h\", \"storm\", \"night\", \"n_flag\", \"truck\", \"purif\", \"well\", \"river\"]\n", + "\n", + "\n", + "def add_features(c):\n", + " \"\"\"시간 순서를 지키는(과거 값만 쓰는) 급수원별 특징: 변화량, 24시간 기준선 대비 편차, 6시간 변동성, 대장균 누적 등.\"\"\"\n", + " c = c.sort_values([\"source_id\", \"t\"]).reset_index(drop=True)\n", + " g = c.groupby(\"source_id\")\n", + " for p in [\"ph\", \"turbidity\", \"chlorine\", \"metal\"]:\n", + " c[f\"{p}_d1\"] = g[p].diff().fillna(0)\n", + " c[f\"{p}_d3\"] = g[p].diff(3).fillna(0)\n", + " base = g[p].transform(lambda s: s.shift(1).rolling(24, min_periods=4).median())\n", + " c[f\"{p}_dev\"] = (c[p] - base).fillna(0)\n", + " c[f\"{p}_sd6\"] = g[p].transform(lambda s: s.rolling(6, min_periods=2).std()).fillna(0)\n", + " c[\"ecoli_3h\"] = g[\"ecoli\"].transform(lambda s: s.rolling(3, min_periods=1).sum())\n", + " c[\"storm\"], c[\"night\"] = (c[\"cond\"] == \"storm\").astype(int), (c[\"cond\"] == \"night\").astype(int)\n", + " flags = [f\"{p}_flag\" for p in PARAMS if f\"{p}_flag\" in c]\n", + " c[\"n_flag\"] = sum(c[f].isin([\"stuck\", \"spike\", \"long_gap\", \"range\"]).astype(int) for f in flags) if flags else 0\n", + " for k, name in [(\"truck\", \"water_truck\"), (\"purif\", \"purification_unit\"), (\"well\", \"well\"), (\"river\", \"river_intake\")]:\n", + " c[k] = (c[\"source_type\"] == name).astype(int)\n", + " return c\n", + "\n", + "BEFORE = {\"ecoli_3h_min\": 1, \"metal_dev\": 99.0, \"metal_min\": 99.0} # 현행: 대장균 1회 검출 즉시 위험, 추세 규칙 없음\n", + "\n", + "\n", + "def tuned_grade(c, P):\n", + " \"\"\"재조정 규칙: 대장균은 3시간 누적 기준(또는 탁도·염소 이상 동반 시 즉시), 중금속은 기준선 대비 상승 추세 규칙 추가.\"\"\"\n", + " ph, tb, cl, mt, ec = (c[p].to_numpy(float) for p in PARAMS)\n", + " if P[\"ecoli_3h_min\"] <= 1: # 현행 규칙: 그 시각 1회 검출이면 위험\n", + " eco = ec >= 1\n", + " else:\n", + " eco = (c[\"ecoli_3h\"].to_numpy() >= P[\"ecoli_3h_min\"]) | ((ec >= 1) & ((tb > LIMITS[\"turbidity\"][0]) | (cl < LIMITS[\"chlorine\"][0])))\n", + " trend = (c[\"metal_dev\"].to_numpy() > P[\"metal_dev\"]) & (mt > P[\"metal_min\"])\n", + " danger = (ph < LIMITS[\"ph\"][0]) | (ph > LIMITS[\"ph\"][1]) | (tb > LIMITS[\"turbidity\"][1]) | (cl < LIMITS[\"chlorine\"][1]) \\\n", + " | (mt > LIMITS[\"metal\"][1]) | eco | trend\n", + " caution = (tb > LIMITS[\"turbidity\"][0]) | (cl < LIMITS[\"chlorine\"][0]) | (mt > LIMITS[\"metal\"][0]) | (ec >= 1)\n", + " return np.where(danger, 2, np.where(caution, 1, 0))\n", + "\n", + "\n", + "def cause(r, pred):\n", + " if r[\"grade\"] == 2 and pred != 2:\n", + " return {\"creeping_metal\": \"놓침-중금속 완만 증가\", \"chemical\": \"놓침-화학(초기 pH 변화)\"}.get(r[\"event\"], f\"놓침-{r['event']}\")\n", + " if r[\"grade\"] != 2 and pred == 2:\n", + " if r[\"fault\"] != \"none\":\n", + " return f\"오경보-센서 {r['fault']}\"\n", + " return \"오경보-단발 대장균\" if r[\"ecoli\"] >= 1 else \"오경보-기타\"\n", + " return \"주의/안전 경계 혼동\"\n", + "\n", + "\n", + "D = {name: add_features(clean(make_dataset(seed=s, n_events=10, n_faults=14))) for name, s in\n", + " [(\"튜닝(seed31)\", 31), (\"검증(seed41)\", 41), (\"벤치마크(seed11)\", 11)]}\n", + "\n", + "# 1) AAR: 튜닝 세트의 오판 원인 분류\n", + "tune = D[\"튜닝(seed31)\"]\n", + "p0 = tuned_grade(tune, BEFORE)\n", + "err = tune[p0 != tune[\"grade\"]]\n", + "causes = pd.Series([cause(r, p) for (_, r), p in zip(err.iterrows(), p0[p0 != tune[\"grade\"]])]).value_counts()\n", + "print(f\"[교육용가상자료] AAR 대상 오판 {len(err)}건(튜닝 세트 {len(tune):,}건) 원인 분류\")\n", + "print(causes.to_string())\n", + "\n", + "# 2) 재조정: 고칠 수 있는 원인(단발 대장균, 중금속 완만 증가)에 대한 임계치 격자 탐색\n", + "# AAR 결정: 대장균 단발 1회 검출은 '주의 + 재채수'로 낮추고(3시간 누적 2회 이상부터 위험), 그 기준값만 탐색한다\n", + "best = None\n", + "for e3, md, mm in itertools.product([2, 3], [1.0, 1.5, 2.0, 3.0], [4.0, 5.0, 6.0]):\n", + " P = {\"ecoli_3h_min\": e3, \"metal_dev\": md, \"metal_min\": mm}\n", + " acc = (tuned_grade(tune, P) == tune[\"grade\"]).mean()\n", + " if best is None or acc > best[0]:\n", + " best = (acc, P)\n", + "AFTER = best[1]\n", + "print(\"\\n재조정 파라미터:\", AFTER)\n", + "\n", + "# 3) 회귀 테스트: 세 데이터셋에서 전후 비교\n", + "rows = []\n", + "for name, c in D.items():\n", + " b, a = tuned_grade(c, BEFORE), tuned_grade(c, AFTER)\n", + " d = c[\"grade\"] == 2\n", + " rows.append({\"데이터\": name, \"정확도 전\": f\"{(b == c['grade']).mean():.1%}\", \"정확도 후\": f\"{(a == c['grade']).mean():.1%}\",\n", + " \"향상(%p)\": round(((a == c[\"grade\"]).mean() - (b == c[\"grade\"]).mean()) * 100, 1),\n", + " \"오판 전→후\": f\"{int((b != c['grade']).sum())}→{int((a != c['grade']).sum())}\",\n", + " \"새로 틀림\": int(((b == c[\"grade\"]) & (a != c[\"grade\"])).sum()),\n", + " \"위험 재현율 후\": f\"{((a == 2) & d).sum() / d.sum():.1%}\"})\n", + "reg = pd.DataFrame(rows)\n", + "print(\"\\n[회귀 테스트]\")\n", + "print(reg.to_string(index=False))\n", + "gain = reg.loc[1, \"향상(%p)\"]\n", + "print(f\"검증 세트 종합 정확도 향상 {gain}%p (목표 10%p 이상: {'달성' if gain >= 10 else '미달'} - Baseline 정확도가 이미 97% 수준이라 10%p 여지가 없음)\")\n", + "\n", + "# 4) 재발 오류: 고친 원인 유형이 검증 세트에서 다시 나오는가\n", + "val = D[\"검증(seed41)\"]\n", + "pa = tuned_grade(val, AFTER)\n", + "ev = val[pa != val[\"grade\"]]\n", + "vc = pd.Series([cause(r, p) for (_, r), p in zip(ev.iterrows(), pa[pa != val[\"grade\"]])]).value_counts()\n", + "fixed_types = [\"오경보-단발 대장균\", \"놓침-중금속 완만 증가\"]\n", + "rec = {k: int(vc.get(k, 0)) for k in fixed_types}\n", + "print(f\"\\n재발 오류(고친 유형의 검증 세트 재발): {rec} -> 합계 {sum(rec.values())}건(목표 0건: {'달성' if sum(rec.values()) == 0 else '미달'})\")\n", + "print(\"남은 원인 상위:\", vc.head(3).to_dict(), \"-> 센서 고장(드리프트·통신 단절) 오경보는 교차 센서·교정 주기와 미션 5 신뢰도 지수로, 화학 초기 변화는 pH 변화율 규칙 후보로 다음 AAR에 넘긴다\")\n", + "\n", + "print(\"\\n# configs/wel03_tuned_params.yaml\")\n", + "print(f\"ecoli:\\n danger_3h_count: {AFTER['ecoli_3h_min']} # 단발 1회는 '주의 + 재채수'\\nmetal_trend:\\n\"\n", + " f\" dev_from_24h_baseline_ugL: {AFTER['metal_dev']}\\n min_level_ugL: {AFTER['metal_min']}\\nreviewed_by: 예방의학 담당(가상) # 실제 기준은 공식 수질 기준 확인\")\n", + "\n", + "fig, ax = plt.subplots(figsize=(8, 3.5))\n", + "x = np.arange(len(reg))\n", + "ax.bar(x - 0.2, [(tuned_grade(c, BEFORE) != c[\"grade\"]).sum() for c in D.values()], 0.4, label=\"before\", color=\"tab:gray\")\n", + "ax.bar(x + 0.2, [(tuned_grade(c, AFTER) != c[\"grade\"]).sum() for c in D.values()], 0.4, label=\"after\", color=\"tab:blue\")\n", + "ax.set_xticks(x, [\"tune(31)\", \"valid(41)\", \"bench(11)\"]); ax.set_ylabel(\"misjudged hours\"); ax.legend()\n", + "ax.set_title(\"AAR retuning: misjudgments before/after (synthetic)\")\n", + "plt.tight_layout(); plt.show()\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 09 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + "[교육용가상자료] AAR 대상 오판 110건(튜닝 세트 2,688건) 원인 분류\n", + "오경보-센서 drift 38\n", + "주의/안전 경계 혼동 28\n", + "놓침-화학(초기 pH 변화) 20\n", + "놓침-중금속 완만 증가 16\n", + "오경보-단발 대장균 7\n", + "놓침-metal 1\n", + "\n", + "재조정 파라미터: {'ecoli_3h_min': 2, 'metal_dev': 1.0, 'metal_min': 5.0}\n", + "\n", + "[회귀 테스트]\n", + " 데이터 정확도 전 정확도 후 향상(%p) 오판 전→후 새로 틀림 위험 재현율 후\n", + " 튜닝(seed31) 95.9% 96.2% 0.3 110→102 8 91.8%\n", + " 검증(seed41) 97.1% 97.2% 0.1 78→75 6 92.2%\n", + "벤치마크(seed11) 97.9% 98.4% 0.5 56→43 6 96.3%\n", + "검증 세트 종합 정확도 향상 0.1%p (목표 10%p 이상: 미달 - Baseline 정확도가 이미 97% 수준이라 10%p 여지가 없음)\n", + "\n", + "재발 오류(고친 유형의 검증 세트 재발): {'오경보-단발 대장균': 0, '놓침-중금속 완만 증가': 1} -> 합계 1건(목표 0건: 미달)\n", + "남은 원인 상위: {'주의/안전 경계 혼동': 21, '오경보-센서 dropout': 18, '놓침-화학(초기 pH 변화)': 15} -> 센서 고장(드리프트·통신 단절) 오경보는 교차 센서·교정 주기와 미션 5 신뢰도 지수로, 화학 초기 변화는 pH 변화율 규칙 후보로 다음 AAR에 넘긴다\n", + "\n", + "# configs/wel03_tuned_params.yaml\n", + "ecoli:\n", + " danger_3h_count: 2 # 단발 1회는 '주의 + 재채수'\n", + "metal_trend:\n", + " dev_from_24h_baseline_ugL: 1.0\n", + " min_level_ugL: 5.0\n", + "reviewed_by: 예방의학 담당(가상) # 실제 기준은 공식 수질 기준 확인\n", + "```\n", + "
\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 10. 가상 수질검사 데이터 위험 신호 대시보드 시스템 통합 패키지 완성\n", + "**소요시간**: 약 3.5시간\n", + "\n", + "### 📋 미션 배경 및 수행 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "가상 수질검사 데이터 위험 신호 대시보드 시스템 통합 패키지를 완성한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **가상 수질검사 데이터 위험 신호 대시보드를 하나의 통합 패키지로 완성한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **입력 검증, 전처리, 재조정 기준치 판정, 랜덤 포레스트 위험 분류, 신뢰도 등급, 사람 검토 큐 등록, 상황판 요약을 한 파일의 파이프라인으로 묶는다 과정을 진행하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `src/main_wel03.py, README.md, docs/wel03_final_report.pdf(md)` 형태의 결과물을 완성한다.\n", + "\n", + "### 🔍 평가 및 검증 기준 (PASS 조건)\n", + "#### 미션 판정\n", + "\n", + "**평가 구분:** 필수\n", + "\n", + "**핵심 평가영역:** 군 실무 즉시 도입 가능성, 운용 신뢰도 및 전략적 기대효과\n", + "\n", + "#### 미션 PASS 조건\n", + "\n", + "- 정량 기준: End-to-End 통합 파이프라인 실행 성공률 100%, 테스트 커버리지 85% 이상\n", + "\n", + "\n", + "- 진행 기준: 원클릭 실행 스크립트 동작 확인 및 최종 발표 산출물 작성 완료\n", + "\n", + "\n", + "- 검증 방법: 최종 검증 위원회(평가관) 모의 시연 및 전체 기능 통합 검증\n", + "\n", + "\n", + "#### 채점자 판정 메모\n", + "\n", + "> 단순 코드 실행 여부뿐만 아니라 출력된 수치와 근거 자료가 군 실무 요구사항에 부합하는지 면밀히 확인한다.\n", + "\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "\"\"\"입력 검증, 전처리, 재조정 기준치 판정과 랜덤 포레스트 위험 분류, 신뢰도 등급, 인간 검토 큐 등록, 상황판 요약까지 가상 수질검사 위험 신호 대시보드를 한 파일로 통합 실행하고, 여러 시나리오 End-to-End 실행·자체 점검(함수 커버리지)·지휘관 브리핑 요약을 출력한다.\"\"\"\n", + "import sys\n", + "import time\n", + "import numpy as np\n", + "import pandas as pd\n", + "import matplotlib.pyplot as plt\n", + "from sklearn.ensemble import RandomForestClassifier\n", + "\n", + "# ---------------------------------------------------------------------------\n", + "# [교육용가상자료] 야전 급수원 가상 수질 센서 데이터 생성기 (실제 부대·위치·검사 결과 없음)\n", + "# 가상 급수원 8곳 x 14일 x 1시간 간격. 실제 수질(true_*)·센서 측정값·오염 사건·센서 고장 정답을 함께 만든다.\n", + "# 기준치는 교육용 가정값이며, 실제 음용 판정은 공인 검사 결과와 의무(예방의학) 담당 기준에 따른다.\n", + "# ---------------------------------------------------------------------------\n", + "PARAMS = [\"ph\", \"turbidity\", \"chlorine\", \"metal\", \"ecoli\"]\n", + "SOURCES = [(\"W01\", \"water_truck\", 1.0, 2.0), (\"W02\", \"water_truck\", 3.5, 1.2), (\"W03\", \"purification_unit\", 2.2, 4.1),\n", + " (\"W04\", \"purification_unit\", 5.0, 3.3), (\"W05\", \"well\", 0.8, 5.6), (\"W06\", \"well\", 4.4, 6.0),\n", + " (\"W07\", \"river_intake\", 6.3, 1.8), (\"W08\", \"river_intake\", 6.9, 4.9)]\n", + "BASE = {\"water_truck\": (7.2, 0.25, 0.75, 1.5), \"purification_unit\": (7.0, 0.20, 0.85, 1.2),\n", + " \"well\": (6.8, 0.45, 0.50, 3.0), \"river_intake\": (7.3, 0.55, 0.60, 2.0)} # pH, 탁도(NTU), 잔류염소(mg/L), 중금속(ug/L)\n", + "LIMITS = {\"ph\": (5.8, 8.5), \"turbidity\": (1.0, 2.0), \"chlorine\": (0.2, 0.1), \"metal\": (5.0, 10.0), \"ecoli\": (1, 1)} # (주의, 위험) 가정 기준\n", + "EVENT_TYPES = { # 오염 사건: 변수별 최대 변화량, 최대치 도달 시간(h)\n", + " \"sewage\": ({\"turbidity\": 2.8, \"chlorine\": -0.5, \"ecoli\": 30, \"ph\": -0.3}, 4),\n", + " \"metal\": ({\"metal\": 22.0, \"ph\": -0.5}, 4),\n", + " \"disinfect_fail\": ({\"chlorine\": -0.8, \"ecoli\": 6}, 6),\n", + " \"chemical\": ({\"ph\": 1.7, \"turbidity\": 1.0}, 3),\n", + " \"creeping_metal\": ({\"metal\": 13.0}, 18),\n", + "}\n", + "FAULT_TYPES = [\"stuck\", \"spike\", \"drift\", \"dropout\"]\n", + "NOISE = {\"ph\": 0.04, \"turbidity\": 0.06, \"chlorine\": 0.03, \"metal\": 0.35}\n", + "DIGITS = {\"ph\": 2, \"turbidity\": 2, \"chlorine\": 2, \"metal\": 1}\n", + "\n", + "\n", + "def make_dataset(days=14, seed=7, n_events=6, n_faults=10, events=None, faults=None, sources=None):\n", + " \"\"\"events: [(급수원 번호, 사건 유형, 시작 시각 h, 지속 h, 강도)], faults: [(급수원 번호, 고장 유형, 항목, 시작 h, 지속 h)]\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " sources = sources or SOURCES\n", + " T = days * 24\n", + " tt = np.arange(T)\n", + " hour = tt % 24\n", + " night = (hour < 6) | (hour >= 20)\n", + " rain = np.zeros(T)\n", + " for _ in range(max(1, days // 4)):\n", + " s0, d = int(rng.integers(0, T - 14)), int(rng.integers(6, 14))\n", + " rain[s0:s0 + d] = np.maximum(rain[s0:s0 + d], rng.uniform(0.6, 1.0))\n", + " if events is None:\n", + " events = [(int(rng.integers(len(sources))), str(rng.choice(list(EVENT_TYPES))), int(rng.integers(min(24, T // 4), max(T // 4 + 1, T - 48))),\n", + " int(rng.integers(10, 30)), 1.0) for _ in range(n_events)]\n", + " if faults is None:\n", + " faults = [(int(rng.integers(len(sources))), str(rng.choice(FAULT_TYPES)), str(rng.choice([\"ph\", \"turbidity\", \"chlorine\", \"metal\"])),\n", + " int(rng.integers(12, max(13, T - 30))), int(rng.integers(6, 24))) for _ in range(n_faults)]\n", + " out = []\n", + " for si, (sid, stype, x, y) in enumerate(sources):\n", + " ph0, tb0, cl0, mt0 = BASE[stype]\n", + " tr = {\"ph\": ph0 + 0.08 * np.sin(2 * np.pi * hour / 24) + rng.normal(0, 0.03, T),\n", + " \"turbidity\": tb0 * (1 + 0.1 * rng.normal(0, 1, T)),\n", + " \"chlorine\": (cl0 * np.exp(-0.022 * hour) if stype == \"water_truck\" else cl0 + rng.normal(0, 0.03, T)),\n", + " \"metal\": mt0 + rng.normal(0, 0.2, T), \"ecoli\": np.zeros(T)}\n", + " if stype in (\"river_intake\", \"well\"):\n", + " tr[\"turbidity\"] = tr[\"turbidity\"] + rain * (0.9 if stype == \"river_intake\" else 0.45)\n", + " f_all, etype = np.zeros(T), np.array([\"none\"] * T, dtype=object)\n", + " for (s_i, et, onset, dur, scale) in events:\n", + " if s_i != si:\n", + " continue\n", + " delta, ramp = EVENT_TYPES[et]\n", + " f = np.clip((tt - onset + 1) / ramp, 0, 1) * ((tt >= onset) & (tt < onset + dur + ramp))\n", + " for k, v in delta.items():\n", + " tr[k] = tr[k] + v * f * scale\n", + " etype[f > 0] = et\n", + " f_all = np.maximum(f_all, f)\n", + " tr[\"chlorine\"] = np.clip(tr[\"chlorine\"], 0.02, None)\n", + " tr[\"turbidity\"] = np.clip(tr[\"turbidity\"], 0.05, None)\n", + " tr[\"metal\"] = np.clip(tr[\"metal\"], 0.1, None)\n", + " grade = np.where(f_all >= 0.2, 2, 0)\n", + " mild = (tr[\"turbidity\"] > LIMITS[\"turbidity\"][0]) | (tr[\"chlorine\"] < LIMITS[\"chlorine\"][0]) | (tr[\"metal\"] > LIMITS[\"metal\"][0])\n", + " grade = np.where((grade == 0) & mild, 1, grade)\n", + " ms = {}\n", + " for p in [\"ph\", \"turbidity\", \"chlorine\", \"metal\"]:\n", + " sd = NOISE[p] * np.where(night, 1.5, 1.0) * (tr[p] if p == \"turbidity\" else 1)\n", + " ms[p] = np.round(np.clip(tr[p] + rng.normal(0, 1, T) * sd, 0, None), DIGITS[p])\n", + " ms[\"ecoli\"] = (rng.poisson(tr[\"ecoli\"]) + (rng.random(T) < 0.004)).astype(float)\n", + " fault, fparam = np.array([\"none\"] * T, dtype=object), np.array([\"-\"] * T, dtype=object)\n", + " for (s_i, ft, p, s0, d) in faults:\n", + " if s_i != si:\n", + " continue\n", + " seg = slice(s0, min(T, s0 + d))\n", + " if ft == \"stuck\":\n", + " ms[p][seg] = ms[p][s0]\n", + " elif ft == \"spike\":\n", + " seg = slice(s0, s0 + 1)\n", + " ms[p][s0] = {\"ph\": 11.8, \"turbidity\": 38.0, \"chlorine\": 0.0, \"metal\": 75.0}[p]\n", + " elif ft == \"drift\":\n", + " ramp = np.linspace(0, 1, seg.stop - s0)\n", + " ms[p][seg] = np.clip(ms[p][seg] + ramp * {\"ph\": -1.6, \"turbidity\": 2.6, \"chlorine\": -0.75, \"metal\": 12.0}[p], 0, None).round(DIGITS[p])\n", + " elif ft == \"dropout\":\n", + " for q in PARAMS:\n", + " ms[q][seg] = np.nan\n", + " p = \"all\"\n", + " fault[seg], fparam[seg] = ft, p\n", + " df = pd.DataFrame({\"source_id\": sid, \"source_type\": stype, \"x_km\": x, \"y_km\": y, \"t\": tt,\n", + " \"cond\": np.where(rain > 0, \"storm\", np.where(night, \"night\", \"day\")),\n", + " **{p: ms[p] for p in PARAMS}, **{f\"true_{p}\": tr[p] for p in PARAMS},\n", + " \"event\": etype, \"fault\": fault, \"fault_param\": fparam, \"grade\": grade})\n", + " out.append(df)\n", + " return pd.concat(out, ignore_index=True)\n", + "# ---------------------------------------------------------------------------\n", + "\n", + "# ---- 전처리 공통 함수: 물리 범위 점검 -> 고정값(stuck) 표시 -> 단발 스파이크 제거 -> 결측 보정 ----\n", + "PHYS = {\"ph\": (0, 14), \"turbidity\": (0, 1000), \"chlorine\": (0, 10), \"metal\": (0, 1000), \"ecoli\": (0, 100000)}\n", + "SPIKE_K = {\"day\": 6.0, \"night\": 7.0, \"storm\": 9.0} # 상황별(주간/야간/악천후) 스파이크 판정 배수\n", + "MIN_JUMP = {\"ph\": 0.8, \"turbidity\": 1.5, \"chlorine\": 0.3, \"metal\": 6.0}\n", + "STUCK_RUN, SHORT_GAP = 6, 3\n", + "\n", + "\n", + "def clean(df, spike_k=None, min_jump=None):\n", + " \"\"\"급수원별 시계열을 정제하고 항목별 처리 내역(<항목>_flag: range/stuck/spike/interp/long_gap)을 남긴다.\"\"\"\n", + " # [TODO : 학생 실습 10] 가상 수질검사 데이터 위험 신호 대시보드 시스템 통합 패키지 완성 관련 핵심 로직을 구현하세요.\n", + " # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n", + " # <<< 여기에 코드를 작성하세요 >>>\n", + " pass # 작성 후 pass를 제거하세요.\n", + "GRADE_NAME = {0: \"안전\", 1: \"주의\", 2: \"위험\"}\n", + "\n", + "\n", + "def rule_grade(df):\n", + " \"\"\"기준치 1차 필터: 위험(음용 부적합 의심) = 한 항목이라도 위험 기준 초과, 주의 = 주의 기준 초과.\"\"\"\n", + " # [TODO : 학생 실습 10] 가상 수질검사 데이터 위험 신호 대시보드 시스템 통합 패키지 완성 관련 핵심 로직을 구현하세요.\n", + " # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n", + " # <<< 여기에 코드를 작성하세요 >>>\n", + " pass # 작성 후 pass를 제거하세요.\n", + "def reasons(row):\n", + " r = []\n", + " if not LIMITS[\"ph\"][0] <= row[\"ph\"] <= LIMITS[\"ph\"][1]:\n", + " r.append(f\"pH {row['ph']:.2f}\")\n", + " if row[\"turbidity\"] > LIMITS[\"turbidity\"][0]:\n", + " r.append(f\"탁도 {row['turbidity']:.2f}NTU\")\n", + " if row[\"chlorine\"] < LIMITS[\"chlorine\"][0]:\n", + " r.append(f\"잔류염소 {row['chlorine']:.2f}mg/L\")\n", + " if row[\"metal\"] > LIMITS[\"metal\"][0]:\n", + " r.append(f\"중금속 {row['metal']:.1f}ug/L\")\n", + " if row[\"ecoli\"] >= 1:\n", + " r.append(f\"대장균 {row['ecoli']:.0f}\")\n", + " return \", \".join(r) or \"-\"\n", + "\n", + "\n", + "FEATS = [f\"{p}{s}\" for p in [\"ph\", \"turbidity\", \"chlorine\", \"metal\"] for s in [\"\", \"_d1\", \"_d3\", \"_dev\", \"_sd6\"]] + \\\n", + " [\"ecoli\", \"ecoli_3h\", \"storm\", \"night\", \"n_flag\", \"truck\", \"purif\", \"well\", \"river\"]\n", + "\n", + "\n", + "def add_features(c):\n", + " \"\"\"시간 순서를 지키는(과거 값만 쓰는) 급수원별 특징: 변화량, 24시간 기준선 대비 편차, 6시간 변동성, 대장균 누적 등.\"\"\"\n", + " c = c.sort_values([\"source_id\", \"t\"]).reset_index(drop=True)\n", + " g = c.groupby(\"source_id\")\n", + " for p in [\"ph\", \"turbidity\", \"chlorine\", \"metal\"]:\n", + " c[f\"{p}_d1\"] = g[p].diff().fillna(0)\n", + " c[f\"{p}_d3\"] = g[p].diff(3).fillna(0)\n", + " base = g[p].transform(lambda s: s.shift(1).rolling(24, min_periods=4).median())\n", + " c[f\"{p}_dev\"] = (c[p] - base).fillna(0)\n", + " c[f\"{p}_sd6\"] = g[p].transform(lambda s: s.rolling(6, min_periods=2).std()).fillna(0)\n", + " c[\"ecoli_3h\"] = g[\"ecoli\"].transform(lambda s: s.rolling(3, min_periods=1).sum())\n", + " c[\"storm\"], c[\"night\"] = (c[\"cond\"] == \"storm\").astype(int), (c[\"cond\"] == \"night\").astype(int)\n", + " flags = [f\"{p}_flag\" for p in PARAMS if f\"{p}_flag\" in c]\n", + " c[\"n_flag\"] = sum(c[f].isin([\"stuck\", \"spike\", \"long_gap\", \"range\"]).astype(int) for f in flags) if flags else 0\n", + " for k, name in [(\"truck\", \"water_truck\"), (\"purif\", \"purification_unit\"), (\"well\", \"well\"), (\"river\", \"river_intake\")]:\n", + " c[k] = (c[\"source_type\"] == name).astype(int)\n", + " return c\n", + "\n", + "CONFIG = {\"ecoli_3h_min\": 2, \"metal_dev\": 1.0, \"metal_min\": 5.0, # 미션 9 재조정 값\n", + " \"conf_high\": 60, \"scale\": {\"ph\": 0.35, \"turbidity\": 0.6, \"chlorine\": 0.2, \"metal\": 3.0}}\n", + "README = {\"1. 목적\": \"가상 수질 센서 자료로 급수원 위험 신호를 조기에 알리는 판단 보조 도구\",\n", + " \"2. 실행 방법\": \"python main_wel03.py (원클릭 실행, 외부 파일·네트워크 불필요)\",\n", + " \"3. 입력 형식\": \"급수원 ID·유형·시각·pH·탁도·잔류염소·중금속·대장균(미션 1 스키마)\",\n", + " \"4. 판정 규칙\": \"가정 기준치 + 재조정 규칙 + 모델, 신뢰도 High만 급수 중단 검토 상신\",\n", + " \"5. 인간 검토\": \"급수 중단·재개는 예방의학 담당 검토 후 지휘관 승인, 자동 확정 없음\",\n", + " \"6. 한계와 주의\": \"교육용 가상 자료, 실제 판정은 공식 수질 기준과 의무·환경 담당 확인\",\n", + " \"7. 연동 확장\": \"인접 부대 급수 정보 교환용 표준 필드(JSON) API를 차기 과제로 둔다\"}\n", + "\n", + "\n", + "def validate(df):\n", + " ok = df[\"source_id\"].astype(str).str.match(r\"^W\\d{2}$\") & df[\"t\"].ge(0)\n", + " return df[ok].copy(), int((~ok).sum())\n", + "\n", + "\n", + "def tuned_grade(c):\n", + " ph, tb, cl, mt, ec = (c[p].to_numpy(float) for p in PARAMS)\n", + " eco = (c[\"ecoli_3h\"].to_numpy() >= CONFIG[\"ecoli_3h_min\"]) | ((ec >= 1) & ((tb > LIMITS[\"turbidity\"][0]) | (cl < LIMITS[\"chlorine\"][0])))\n", + " trend = (c[\"metal_dev\"].to_numpy() > CONFIG[\"metal_dev\"]) & (mt > CONFIG[\"metal_min\"])\n", + " danger = (ph < LIMITS[\"ph\"][0]) | (ph > LIMITS[\"ph\"][1]) | (tb > LIMITS[\"turbidity\"][1]) | (cl < LIMITS[\"chlorine\"][1]) \\\n", + " | (mt > LIMITS[\"metal\"][1]) | eco | trend\n", + " caution = (tb > LIMITS[\"turbidity\"][0]) | (cl < LIMITS[\"chlorine\"][0]) | (mt > LIMITS[\"metal\"][0]) | (ec >= 1)\n", + " return np.where(danger, 2, np.where(caution, 1, 0))\n", + "\n", + "\n", + "def confidence(c, alert):\n", + " S = CONFIG[\"scale\"]\n", + " n_dev = sum(((c[f\"{p}_dev\"].abs() > S[p]) | (c[f\"{p}_d3\"].abs() > S[p])).astype(int) for p in S) + (c[\"ecoli_3h\"] >= 2)\n", + " a = pd.Series(alert.astype(int), index=c.index)\n", + " run = a.groupby([c[\"source_id\"], (a != a.groupby(c[\"source_id\"]).shift()).cumsum()]).cumsum()\n", + " g = c.groupby(\"source_id\")\n", + " flagged = g[\"n_flag\"].transform(lambda s: s.rolling(6, min_periods=1).max()) > 0\n", + " # 드리프트 의심: 한 항목만 6시간 중 5시간 이상 같은 방향으로 꾸준히 변함(잡음이 커지는 야간에도 유지되는 기준)\n", + " mono = pd.concat([g[f\"{p}_d1\"].transform(lambda s: np.sign(s).rolling(6, min_periods=3).sum()).abs() >= 4 for p in S], axis=1).any(axis=1)\n", + " drift = (n_dev <= 1) & mono & (c[\"ecoli_3h\"] < 2)\n", + " return 40 * np.minimum(n_dev, 3) / 3 + 25 * np.minimum(run, 3) / 3 + 20 * (~flagged) + 15 * (~drift)\n", + "\n", + "\n", + "def train_model():\n", + " tr = pd.concat([add_features(clean(make_dataset(seed=s, n_events=14, n_faults=14))) for s in (7, 8)], ignore_index=True)\n", + " return RandomForestClassifier(n_estimators=150, max_depth=10, class_weight=\"balanced_subsample\", random_state=0, n_jobs=1).fit(tr[FEATS], tr[\"grade\"])\n", + "\n", + "\n", + "def run_pipeline(raw, model):\n", + " data, n_bad = validate(raw)\n", + " c = add_features(clean(data))\n", + " c[\"rule\"] = tuned_grade(c)\n", + " c[\"ml\"] = model.predict(c[FEATS])\n", + " c[\"final\"] = np.maximum(c[\"rule\"], c[\"ml\"]) # 둘 중 하나라도 위험이면 위험(놓침 방지)\n", + " alert = (c[\"final\"] == 2).to_numpy()\n", + " c[\"conf\"] = np.where(alert, confidence(c, alert), np.nan)\n", + " queue = c[alert & (c[\"conf\"] >= CONFIG[\"conf_high\"])].groupby(\"source_id\").agg(first=(\"t\", \"min\"), hours=(\"t\", \"size\"))\n", + " queue[\"status\"] = \"PENDING(지휘관 결정 대기)\"\n", + " return c, queue, n_bad\n", + "\n", + "\n", + "def self_tests(model):\n", + " res = {}\n", + " clean_df = make_dataset(days=2, seed=50, events=[], faults=[])\n", + " c, q, _ = run_pipeline(clean_df, model)\n", + " res[\"오염 없는 자료에서 급수 중단 상신 0건\"] = len(q) == 0\n", + " ev = make_dataset(days=2, seed=50, events=[(0, \"sewage\", 20, 12, 1.0)], faults=[])\n", + " c, q, _ = run_pipeline(ev, model)\n", + " res[\"하수 유입 급수원(W01) 상신\"] = \"W01\" in q.index\n", + " res[\"상신 건은 모두 PENDING(자동 확정 없음)\"] = q[\"status\"].str.startswith(\"PENDING\").all()\n", + " bad = ev.copy(); bad.loc[3, \"source_id\"] = \"??\"\n", + " res[\"손상 레코드 격리\"] = run_pipeline(bad, model)[2] == 1\n", + " dr = make_dataset(days=2, seed=50, events=[], faults=[(1, \"drift\", \"turbidity\", 10, 18)])\n", + " c, q, _ = run_pipeline(dr, model)\n", + " n_alert = int((c.loc[c[\"source_id\"] == \"W02\", \"final\"] == 2).sum()) # 드리프트 구간 위험 판정 시간 중 High 상신 시간이 절반 미만이면 억제 작동\n", + " res[\"센서 드리프트 상신 시간 절반 미만으로 억제\"] = (\"W02\" not in q.index) or q.loc[\"W02\", \"hours\"] < 0.5 * n_alert\n", + " res[\"기준치 필터·판정 근거 문구 생성\"] = len(reasons(c[c[\"source_id\"] == \"W02\"].iloc[40])) > 0 and rule_grade(c).shape[0] == len(c)\n", + " res[\"결측 보정 후 남은 결측 0\"] = int(c[PARAMS].isna().sum().sum()) == 0\n", + " res[\"운영 문서 7개 장 작성\"] = all(len(v) > 10 for v in README.values())\n", + " return res\n", + "\n", + "\n", + "# ---- 자체 점검(함수 호출 추적으로 커버리지 측정) ----\n", + "t_all = time.perf_counter()\n", + "called = set()\n", + "own = {n for n, f in list(globals().items()) if callable(f) and getattr(f, \"__module__\", \"\") == \"__main__\" and hasattr(f, \"__code__\")}\n", + "sys.setprofile(lambda fr, ev, arg: called.add(fr.f_code.co_name) if ev == \"call\" else None)\n", + "model = train_model()\n", + "tests = self_tests(model)\n", + "e2e = []\n", + "for s in (11, 21, 31, 41, 51):\n", + " try:\n", + " c, q, nb = run_pipeline(make_dataset(seed=s, n_events=10, n_faults=14), model)\n", + " e2e.append((s, True, (c[\"final\"] == c[\"grade\"]).mean(), len(q)))\n", + " except Exception as e:\n", + " e2e.append((s, False, np.nan, 0))\n", + "sys.setprofile(None)\n", + "cov = len(own & called) / len(own)\n", + "print(f\"[교육용가상자료] 통합 실행 {time.perf_counter() - t_all:.1f}초 (모델 학습 포함)\")\n", + "print(\"[End-to-End 시나리오 5종]\")\n", + "for s, ok, acc, nq in e2e:\n", + " print(f\" seed {s}: {'성공' if ok else '실패'}, 최종 판정 정확도 {acc:.1%}, 급수 중단 검토 상신 {nq}곳\")\n", + "rate = np.mean([x[1] for x in e2e])\n", + "print(f\"E2E 성공률 {rate:.0%} (목표 100%: {'달성' if rate == 1 else '미달'})\")\n", + "print(\"[자체 점검]\")\n", + "for k, v in tests.items():\n", + " print(f\" [{'PASS' if v else 'FAIL'}] {k}\")\n", + "print(f\"자체 점검 {sum(tests.values())}/{len(tests)} 통과, 함수 커버리지 {cov:.0%}({len(own & called)}/{len(own)}, 목표 85% 이상: {'달성' if cov >= 0.85 else '미달'})\")\n", + "print(f\"운영 문서(README) {len(README)}개 장: \" + \", \".join(README))\n", + "\n", + "# ---- 브리핑 요약(벤치마크 seed 11, 운용 시점 기준) ----\n", + "c, q, _ = run_pipeline(make_dataset(seed=11, n_events=10, n_faults=14), model)\n", + "d = c[\"grade\"] == 2\n", + "print(\"\\n[지휘관 브리핑 요약]\")\n", + "print(f\"1. 급수원 8곳 14일 모의: 위험 재현율 {((c['final'] == 2) & d).sum() / d.sum():.1%}, 종합 정확도 {(c['final'] == c['grade']).mean():.1%}\")\n", + "print(f\"2. 급수 중단 검토 상신 {len(q)}곳({', '.join(q.index)}): 모두 예방의학 담당 검토·지휘관 결정 대기\")\n", + "print(\"3. 센서 이상(드리프트·스파이크·단절)은 재측정·교정 요청으로 분리해 불필요한 단수를 줄인다\")\n", + "print(\"4. 실제 음용 판정은 공식 수질 기준과 의무(예방의학)·환경 담당 확인에 따른다(본 도구는 판단 보조)\")\n", + "\n", + "last = c[c[\"t\"] == c[\"t\"].max()].set_index(\"source_id\")\n", + "fig, ax = plt.subplots(1, 2, figsize=(11, 4))\n", + "ax[0].bar(last.index, last[\"final\"], color=[{0: \"tab:green\", 1: \"gold\", 2: \"red\"}[g] for g in last[\"final\"]])\n", + "ax[0].set_yticks([0, 1, 2], [\"safe\", \"caution\", \"danger\"]); ax[0].set_title(\"Final status by source (last hour)\")\n", + "ax[1].bar([str(x[0]) for x in e2e], [x[2] for x in e2e], color=\"tab:blue\"); ax[1].set_ylim(0.9, 1.0)\n", + "ax[1].set_title(\"E2E accuracy by scenario seed\"); ax[1].set_xlabel(\"seed\")\n", + "plt.suptitle(\"Integrated water risk dashboard package (synthetic)\"); plt.tight_layout(); plt.show()\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "💡 [참고] 미션 10 예상 실행 결과 (Output)\n", + "\n", + "```text\n", + "[교육용가상자료] 통합 실행 6.6초 (모델 학습 포함)\n", + "[End-to-End 시나리오 5종]\n", + " seed 11: 성공, 최종 판정 정확도 98.6%, 급수 중단 검토 상신 6곳\n", + " seed 21: 성공, 최종 판정 정확도 99.1%, 급수 중단 검토 상신 6곳\n", + " seed 31: 성공, 최종 판정 정확도 97.0%, 급수 중단 검토 상신 6곳\n", + " seed 41: 성공, 최종 판정 정확도 97.7%, 급수 중단 검토 상신 7곳\n", + " seed 51: 성공, 최종 판정 정확도 98.0%, 급수 중단 검토 상신 7곳\n", + "E2E 성공률 100% (목표 100%: 달성)\n", + "[자체 점검]\n", + " [PASS] 오염 없는 자료에서 급수 중단 상신 0건\n", + " [PASS] 하수 유입 급수원(W01) 상신\n", + " [PASS] 상신 건은 모두 PENDING(자동 확정 없음)\n", + " [PASS] 손상 레코드 격리\n", + " [PASS] 센서 드리프트 상신 시간 절반 미만으로 억제\n", + " [PASS] 기준치 필터·판정 근거 문구 생성\n", + " [PASS] 결측 보정 후 남은 결측 0\n", + " [PASS] 운영 문서 7개 장 작성\n", + "자체 점검 8/8 통과, 함수 커버리지 100%(11/11, 목표 85% 이상: 달성)\n", + "운영 문서(README) 7개 장: 1. 목적, 2. 실행 방법, 3. 입력 형식, 4. 판정 규칙, 5. 인간 검토, 6. 한계와 주의, 7. 연동 확장\n", + "\n", + "[지휘관 브리핑 요약]\n", + "1. 급수원 8곳 14일 모의: 위험 재현율 98.4%, 종합 정확도 98.6%\n", + "2. 급수 중단 검토 상신 6곳(W02, W03, W04, W05, W07, W08): 모두 예방의학 담당 검토·지휘관 결정 대기\n", + "3. 센서 이상(드리프트·스파이크·단절)은 재측정·교정 요청으로 분리해 불필요한 단수를 줄인다\n", + "4. 실제 음용 판정은 공식 수질 기준과 의무(예방의학)·환경 담당 확인에 따른다(본 도구는 판단 보조)\n", + "```\n", + "
\n" + ] + } + ], + "metadata": { + "colab": { + "provenance": [] + }, + "kernelspec": { + "display_name": "Python 3", + "name": "python3" + }, + "language_info": { + "name": "python" + } + }, + "nbformat": 4, + "nbformat_minor": 0 +} \ No newline at end of file diff --git a/uploads/WEL07/WEL07_workbook.ipynb b/uploads/WEL07/WEL07_workbook.ipynb new file mode 100644 index 0000000..756b8c9 --- /dev/null +++ b/uploads/WEL07/WEL07_workbook.ipynb @@ -0,0 +1,941 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# 📋 [WEL07] 대량 부상자 발생 모의상황 의무지원 인계·기록 훈련\n", + "### 실습 워크북 & SOP 가이드 (Google Colab / Jupyter)\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/WEL07/WEL07_workbook.ipynb)\n", + "\n", + "---\n", + "\n", + "## 📌 1. 프로젝트 개요 및 배경\n", + "\n", + "| 항목 | 내용 |\n", + "| :--- | :--- |\n", + "| **과정 분류** | 장병 복지·지원 (Level 3) |\n", + "| **학습 소요 시간** | 10개 미션 (5일 / 총 27.0시간) |\n", + "| **실습 유형** | 작전절차(SOP), 체크리스트, 모의훈련, AAR 사후검토 실습 |\n", + "\n", + "### 📖 프로젝트 목표\n", + "러시아-우크라이나 전쟁에서는 포격과 드론 공격으로 한 번에 많은 사상자가 발생하고, 의무 인력·물자·후송 수단이 한꺼번에 부족해지며, 식별표 분실이나 기록 단절 때문에 환자 정보가 단계 사이에서 끊기는 문제가 반복해서 제기되었다. 본 프로젝트는 교육용 가상 모의환자 카드와 인계 기록 양식을 이용해 대량 부상자 발생 상황의 현장 처치반, 분류소, 후송반이 환자 정보를 빠짐없이 기록하고 넘기는 절차를 역할 연기로 훈련한다. 중증도 분류 구분과 처치 판단은 교관·의무요원이 제공하는 모의 카드의 표시를 따르며, 학습자는 분류 결과의 기록, 구간별 인계, 현황 보고, 혼란 상황의 교차확인에 집중한다. TRN10이 개별 모의환자의 평가·인계를 다룬다면 이 과제는 여러 부서가 맞물리는 조직적 대응을 다룬다. 최종 목표는 인계·기록의 정확도와 속도를 수치로 확인해 개선하고, 다른 부대 교관이 반복해 쓸 수 있는 표준 훈련 매뉴얼로 정리하는 것이다.\n", + "\n", + "> **⚠️ [교육용 가상 자료 원칙]**\n", + "> - 본 워크북의 모든 자료는 안전 규정에 따라 **교육용 가상 자료**로 구성되어 있습니다.\n", + "> - 실제 군사 기밀이나 부대 세부 인적·작전 정보는 포함되지 않습니다.\n", + "\n", + "---\n", + "\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 01. 전시 대량 사상자(Mass Casualty, MASCAL) 발생 상황 시나리오 및 의무 체계 분석\n", + "\n", + "### 📋 미션 배경 및 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "전시 대량 사상자(MASCAL) 발생 시나리오 및 의무지원 체계를 분석한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **대량 부상자(MASCAL) 발생 상황의 흐름과 의무지원 단계(현장 처치, 분류소, 후송)를 분석해, 훈련에서 확인할 현장 체크 항목과 역할 분담을 정의한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **실습자료의 가상 전훈 요약 카드 5건과 가상 MASCAL 상황(모의환자 12명)을 읽고 단계별 담당 부서, 입력 기록, 산출 기록을 분석표로 정리하고 규격화하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 5개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `docs/wel07_baseline_assessment.md 및 훈련 프로파일` 형태의 결과물을 완성한다.\n", + "\n", + "### 📑 실습자료 및 작성 양식\n", + "전시 대량 사상자(Mass Casualty, MASCAL) 발생 상황 시나리오 및 의무 체계 분석**\n", + "\n", + "[제공 자료 1] 가상 전훈 요약 카드 - 공개된 일반 경향을 교육용으로 요약한 것이며 특정 수치·부대·지역 정보는 없다.\n", + "\n", + "| **번호** | **관찰된 경향(요약)** | **훈련 시사점** |\n", + "| --- | --- | --- |\n", + "| **L1** | 포격·드론 공격으로 짧은 시간에 다수 부상자가 한곳에 몰림 | 분류소 개설, 역할 분담, 현황 일원화 |\n", + "| **L2** | 식별표나 기록이 분실되어 환자 정보가 단계 사이에서 끊김 | 카드 이중 부착, 인계 때 식별번호 복창 |\n", + "| **L3** | 후송 수단이 모자라 순서 조정이 수시로 필요 | 후송 순서표 갱신 시각 기록 |\n", + "| **L4** | 의무 인력·물자가 한정되어 요청과 보고가 몰림 | 보고 경로 단일화, 요청 사항 정리 서식 |\n", + "| **L5** | 통신 두절로 상급 보고와 인계가 늦어짐 | 대체 보고 수단, 우선 전달 항목 |\n", + "\n", + "[제공 자료 2] 가상 MASCAL 상황표(모의환자 12명) [교육용가상자료] - 분류 구분은 교관이 카드에 표시한 모의 값이다.\n", + "\n", + "| **ID** | **가명** | **발견 시각** | **발견 구역** | **모의 분류 구분** | **이동 가능** | **식별표** |\n", + "| --- | --- | --- | --- | --- | --- | --- |\n", + "| **M-01** | 가상 병사 가 | +00:00 | A | 긴급 | 불가 | 있음 |\n", + "| **M-02** | 가상 병사 나 | +00:30 | A | 응급 | 불가 | 있음 |\n", + "| **M-03** | 가상 병사 다 | +01:10 | A | 비응급 | 가능 | 있음 |\n", + "| **M-04** | 가상 병사 라 | +01:40 | B | 긴급 | 불가 | 없음 |\n", + "| **M-05** | 가상 병사 마 | +02:20 | B | 비응급 | 가능 | 있음 |\n", + "| **M-06** | 가상 병사 바 | +03:00 | B | 응급 | 불가 | 있음 |\n", + "| **M-07** | 가상 병사 사 | +03:40 | A | 지연 | 불가 | 있음 |\n", + "| **M-08** | 가상 병사 아 | +04:10 | B | 비응급 | 가능 | 있음 |\n", + "| **M-09** | 가상 병사 자 | +06:00 | A | 응급 | 불가 | 있음 |\n", + "| **M-10** | 가상 병사 차 | +06:30 | B | 비응급 | 가능 | 없음 |\n", + "| **M-11** | 가상 병사 카 | +08:00 | B | 긴급 | 불가 | 있음 |\n", + "| **M-12** | 가상 병사 타 | +09:00 | A | 비응급 | 가능 | 있음 |\n", + "\n", + "[제공 자료 3] 의무지원 단계 흐름표(교육용 요약)\n", + "\n", + "| **단계** | **담당 부서** | **입력 기록** | **산출 기록** | **다음 단계** |\n", + "| --- | --- | --- | --- | --- |\n", + "| **현장 처치** | 현장 처치반 | 부상자 발견·위치 | 식별표 부착, 발견 시각 | 분류소로 이동 |\n", + "| **분류** | 분류소(분류·기록 담당) | 식별표, 카드의 분류 표시 | 의무기록 카드, 분류 일람표 | 후송 순서 확정(의무대장) |\n", + "| **후송** | 후송반 | 후송 순서표, 카드 | 후송 시각, 인수 서명 | 가상 의무대·상급 치료시설 |\n", + "\n", + "[작성 서식 1] 상황 분석표\n", + "\n", + "| **단계** | **핵심 위험(기록 단절 지점)** | **필요한 기록** | **책임 부서** | **훈련 포인트** |\n", + "| --- | --- | --- | --- | --- |\n", + "| **현장 처치** | | | | |\n", + "| **분류** | | | | |\n", + "| **후송** | | | | |\n", + "\n", + "[작성 서식 2] 역할 분담 매트릭스 - R(책임) 또는 S(지원)를 적는다. 한 항목의 R은 1명만 둔다.\n", + "\n", + "| **체크 항목** | **처치반** | **분류** | **기록** | **후송반** | **통신** | **관측** |\n", + "| --- | --- | --- | --- | --- | --- | --- |\n", + "| **식별표 부착 확인** | | | | | | |\n", + "| **발견 시각·구역 기록** | | | | | | |\n", + "| **분류 구분 읽기·알림** | | | | | | |\n", + "| **의무기록 카드 작성** | | | | | | |\n", + "| **분류 일람표 갱신** | | | | | | |\n", + "| **후송 순서표 갱신** | | | | | | |\n", + "| **의무대장 현황 보고** | | | | | | |\n", + "| **구간 인계·상호 서명** | | | | | | |\n", + "| **환자 수 재확인** | | | | | | |\n", + "| **안전 수칙 관찰** | | | | | | |\n", + "\n", + "[작성 서식 3] 현장 체크 항목표(15개 이상)\n", + "\n", + "| **번호** | **체크 항목** | **단계** | **책임 역할** | **교관 대조** |\n", + "| --- | --- | --- | --- | --- |\n", + "| **1** | | | | |\n", + "| **2** | | | | |\n", + "| **3** | | | | |\n", + "| **4** | | | | |\n", + "| **5** | | | | |\n", + "| **6** | | | | |\n", + "| **7** | | | | |\n", + "| **8** | | | | |\n", + "| **9** | | | | |\n", + "| **10** | | | | |\n", + "| **11** | | | | |\n", + "| **12** | | | | |\n", + "| **13** | | | | |\n", + "| **14** | | | | |\n", + "| **15** | | | | |\n", + "\n", + "[작성 서식 4] 부대 유형별 환경 변수\n", + "\n", + "| **부대 유형** | **구역 크기** | **이동 거리** | **통신** | **지원 인원** | **추가 체크 항목** |\n", + "| --- | --- | --- | --- | --- | --- |\n", + "| **야전** | | | | | |\n", + "| **기지** | | | | | |\n", + "| **의무** | | | | | |\n", + "\n", + "| **점검 항목** | **확인** |\n", + "| --- | --- |\n", + "| 상황 분석표 3개 단계를 모두 채웠다 | ( ) |\n", + "| 현장 체크 항목을 15개 이상 정의했다 | ( ) |\n", + "| 모든 항목에 R 역할이 1명씩 지정되었다(매핑 100%) | ( ) |\n", + "| 교관 체크리스트와 대조해 누락을 보완했다(누락 0건) | ( ) |\n", + "| 부대 유형별 환경 변수를 작성했다 | ( ) |\n", + "\n", + "**\n", + "\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 02. 중증도 분류(Triage: 긴급/응급/비응급/지연) 기준표 및 의무기록 양식 설계\n", + "\n", + "### 📋 미션 배경 및 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "중증도 분류(Triage: 긴급/응급/비응급/지연) 기준표 및 의무기록 양식을 설계한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **중증도 분류 구분(긴급/응급/비응급/지연)을 기록하는 규칙표와 의무기록 양식을 설계하고, 현장에서 읽기 쉬운 SOP 체크리스트로 만든다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **교관이 제공하는 분류 구분 요약 카드를 그대로 옮겨 구분별 색 표식, 기록 위치, 재확인 시각 기록 규칙을 정한다(분류 기준 자체는 학습자가 만들지 않는다) 과정을 진행하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 5개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `sop/wel07_field_checklist.md 및 SOP 흐름도` 형태의 결과물을 완성한다.\n", + "\n", + "### 📑 실습자료 및 작성 양식\n", + "중증도 분류(Triage: 긴급/응급/비응급/지연) 기준표 및 의무기록 양식 설계**\n", + "\n", + "[제공 자료 1] 분류 구분 요약 카드 - 교관·의무요원이 제공하는 교육용 요약이다. 구분의 의학적 기준은 학습자가 정하지 않는다.\n", + "\n", + "| **분류 구분** | **교육용 색 표식** | **기록·보고 의미(요약)** | **재확인 기록** |\n", + "| --- | --- | --- | --- |\n", + "| **긴급** | 적색 카드 | 의무요원 확인과 후송 순서 결정에서 가장 먼저 다루도록 표시된 모의환자 | 확인 시각 기록, 상태 변화 시 즉시 보고 |\n", + "| **응급** | 황색 카드 | 긴급 다음 순서로 확인하도록 표시된 모의환자 | 정해진 간격 재확인 시각 기록 |\n", + "| **비응급** | 녹색 카드 | 이동 가능하며 대기 구역에서 확인받도록 표시된 모의환자 | 대기 구역 인원 수 기록 |\n", + "| **지연** | 청색 카드 | 의무요원이 별도 지시할 때까지 대기·재확인하도록 표시된 모의환자 | 지시 내용과 시각 기록 |\n", + "\n", + "[작성 서식 1] 분류 구분 기록 규칙표\n", + "\n", + "| **규칙 항목** | **정한 내용** |\n", + "| --- | --- |\n", + "| **카드 부착 위치** | ( ) |\n", + "| **분류 구분 기록 위치(카드·일람표)** | ( ) |\n", + "| **구분 변경 시 기록 방법(원래 표시 유지, 변경 시각·승인자)** | ( ) |\n", + "| **식별표 분실 시 임시 번호 부여 방법** | ( ) |\n", + "\n", + "[작성 서식 2] 교육용 의무기록 양식 설계 - 필수 기재란에 표시(필)를 적는다.\n", + "\n", + "| **구분** | **기재란 이름** | **필수(필)** | **기재 방법(예시 한 줄)** |\n", + "| --- | --- | --- | --- |\n", + "| **1** | 카드 번호·식별번호 | | |\n", + "| **2** | 성명(가명) | | |\n", + "| **3** | 발견 시각·구역 | | |\n", + "| **4** | 모의 분류 구분·분류 시각·분류자 | | |\n", + "| **5** | 구간별 인계 시각·인계자·인수자 | | |\n", + "| **6** | 의무요원 확인 시각 | | |\n", + "| **7** | 기록자 서명 | | |\n", + "| **8** | 비고(임시 번호, 변경 사유) | | |\n", + "\n", + "[작성 서식 3] 단계별 SOP 체크리스트 - 처치 세부와 약물은 쓰지 않고 '교관·의무요원 지시'로 표시한다.\n", + "\n", + "| **단계** | **번호** | **행동 항목(행동 동사로)** | **안전·보안 조치** | **기록** | **교관 지시 참조** |\n", + "| --- | --- | --- | --- | --- | --- |\n", + "| **현장** | 1 | | | | |\n", + "| **현장** | 2 | | | | |\n", + "| **현장** | 3 | | | | |\n", + "| **분류소** | 4 | | | | |\n", + "| **분류소** | 5 | | | | |\n", + "| **분류소** | 6 | | | | |\n", + "| **분류소** | 7 | | | | |\n", + "| **후송** | 8 | | | | |\n", + "| **후송** | 9 | | | | |\n", + "| **후송** | 10 | | | | |\n", + "\n", + "필수 안전·보안 조치(각 단계 최소 1개): 위협 확인·엄폐 / 인원 확인 / 감염 방지(장갑) / 카드에 부대명·위치 미기재 / 조명 통제 / 이상 증상 시 의무요원 보고\n", + "\n", + "| [부상자 발견] -> [식별표 부착 확인] -> [현장: ( )] -> [분류소 이동]\n", + " -> [분류 구분 읽기·알림] -> [카드·일람표 기록: ( )]\n", + " -> [1차 분류 완료 선언] -> [의무대장 보고(3분 이내)]\n", + " -> [후송 순서 확정(의무대장)] -> [후송반 인계·상호 서명] |\n", + "| --- |\n", + "\n", + "[작성 서식 4] 교차 검토표\n", + "\n", + "| **번호** | **지적 위치** | **누락/모호 내용** | **수정안** | **반영 확인** |\n", + "| --- | --- | --- | --- | --- |\n", + "| **1** | | | | |\n", + "| **2** | | | | |\n", + "| **3** | | | | |\n", + "| **4** | | | | |\n", + "| **5** | | | | |\n", + "| **6** | | | | |\n", + "\n", + "[작성 서식 5] 휴대 요약 카드(30초 안에 읽을 분량)\n", + "\n", + "| **구분** | **내용(한 줄씩)** |\n", + "| --- | --- |\n", + "| **현장 3가지** | ( ) |\n", + "| **분류소 3가지** | ( ) |\n", + "| **보고 순서** | ( ) |\n", + "| **인계 필수** | ( ) |\n", + "| **금지 사항** | ( ) |\n", + "\n", + "| **점검 항목** | **확인** |\n", + "| --- | --- |\n", + "| 4가지 분류 구분의 색 표식·기록 규칙을 정했다 | ( ) |\n", + "| 의무기록 양식의 필수 기재란 7개를 표시했다 | ( ) |\n", + "| SOP 전 단계에 행동 동사와 안전·보안 조치가 있다(100%) | ( ) |\n", + "| 처치 세부·약물을 쓰지 않고 교관 지시 참조로 표시했다 | ( ) |\n", + "| 교차 검토 지적을 모두 수정하고 휴대 요약 카드를 한 장으로 만들었다 | ( ) |\n", + "\n", + "**\n", + "\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 03. 현장 응급처치반, 환자 분류소, 후송반 간 환자 인계 프로세스 수립\n", + "\n", + "### 📋 미션 배경 및 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "현장 처치반, 분류소, 후송반 간 환자 인계 프로세스를 수립한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **현장 처치반, 분류소, 후송반 사이에서 환자 정보와 책임이 넘어가는 인계 프로세스를 정의하고, 이를 시험할 시간대별 시나리오(MSEL)를 만든다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **현장에서 분류소로, 분류소에서 후송반으로, 후송반에서 가상 의무대·상급 치료시설로 넘어가는 3개 구간마다 인계 항목, 인계자와 인수자, 책임이 이전되는 시점을 표로 정 수행하고 검증하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 5개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `scenarios/wel07_msel_scenario.json(md)` 형태의 결과물을 완성한다.\n", + "\n", + "### 📑 실습자료 및 작성 양식\n", + "현장 응급처치반, 환자 분류소, 후송반 간 환자 인계 프로세스 수립**\n", + "\n", + "[제공 자료 1] 인계 구간 정의 예시(1개 구간) - 나머지는 조별로 작성한다.\n", + "\n", + "| **구간** | **인계자** | **인수자** | **인계 항목** | **책임 이전 시점** |\n", + "| --- | --- | --- | --- | --- |\n", + "| **① 현장 -> 분류소** | 현장 처치반 | 분류 담당 | 식별번호, 발견 시각·구역, 이동 가능 여부, 식별표 부착 확인 | 분류 담당이 식별번호를 복창하고 기록 담당이 카드에 서명한 때 |\n", + "\n", + "[작성 서식 1] 구간별 인계 프로세스표\n", + "\n", + "| **구간** | **인계자** | **인수자** | **인계 항목** | **책임 이전 시점** | **누락 시 조치** |\n", + "| --- | --- | --- | --- | --- | --- |\n", + "| **① 현장 -> 분류소** | | | | | |\n", + "| **② 분류소 -> 후송반** | | | | | |\n", + "| **③ 후송반 -> 가상 의무대·상급 치료시설** | | | | | |\n", + "\n", + "[제공 자료 2] MSEL 작성 예시(3건) - 나머지는 조별로 작성한다.\n", + "\n", + "| **번호** | **투입 시각** | **주입 사건** | **구분** | **대상 역할** | **기대 행동** |\n", + "| --- | --- | --- | --- | --- | --- |\n", + "| **1** | +00:00 | M-01 발견 카드 투입, 드론 음향(녹음) 재생 | 정상 | 현장 처치반 | 식별표 확인, 구두 보고 |\n", + "| **2** | +02:00 | M-04 식별표 없음 카드 투입 | 비정상 | 기록 담당 | 임시 번호 부여, 비고란 기재, 분류 담당 보고 |\n", + "| **3** | +05:00 | 1차 분류 완료 선언 요청 | 정상 | 분류 담당·통신 담당 | 일람표 합계 확인 후 보고 |\n", + "\n", + "[작성 서식 2] MSEL 시나리오표(10건 이상, 정상 6 : 비정상 4)\n", + "\n", + "| **번호** | **투입 시각** | **주입 사건** | **구분** | **대상 역할** | **기대 행동** |\n", + "| --- | --- | --- | --- | --- | --- |\n", + "| **1** | | | | | |\n", + "| **2** | | | | | |\n", + "| **3** | | | | | |\n", + "| **4** | | | | | |\n", + "| **5** | | | | | |\n", + "| **6** | | | | | |\n", + "| **7** | | | | | |\n", + "| **8** | | | | | |\n", + "| **9** | | | | | |\n", + "| **10** | | | | | |\n", + "\n", + "[제공 자료 3] 돌발 변수 카드(2개 이상 선택): 식별표 분실 / 환자 수 급증 / 통신 두절 / 인계자 부재 / 기록 담당 교체 / 후송 차량 지연 / 분류소 책임자 이탈\n", + "\n", + "[작성 서식 3] 워크스루 점검표\n", + "\n", + "| **사건 번호** | **흐름 어색함** | **기대 행동 불분명** | **대상 역할 없음** | **수정 내용** |\n", + "| --- | --- | --- | --- | --- |\n", + "| **1** | | | | |\n", + "| **2** | | | | |\n", + "| **3** | | | | |\n", + "| **4** | | | | |\n", + "| **5** | | | | |\n", + "\n", + "| **점검 항목** | **확인** |\n", + "| --- | --- |\n", + "| 3개 구간의 인계자·인수자·책임 이전 시점을 정했다 | ( ) |\n", + "| 주입 사건을 10건 이상 작성했다 | ( ) |\n", + "| 정상:비정상 비율이 6:4이다 | ( ) |\n", + "| 모든 사건에 투입 시각·대상 역할·기대 행동이 있다 | ( ) |\n", + "| 돌발 변수를 2개 이상 넣고 워크스루 지적을 반영했다 | ( ) |\n", + "\n", + "**\n", + "\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 04. 모의 대량 부상자 발생 상황 1차 환자 분류 및 처치 기록 실습(Dry-run)\n", + "\n", + "### 📋 미션 배경 및 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "모의 대량 부상자 발생 상황 환자 분류 및 처치 기록 1차 실습(Dry-run)을 수행한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **가상 모의환자 8명이 발생한 상황에서 분류 결과를 의무기록 카드에 기록하고 분류 일람표를 갱신하는 1차 실습(Dry-run)을 실시한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **조원이 현장 처치반, 분류 담당, 기록 담당, 후송반, 통신 담당, 관측자 역할을 맡고 교관이 모의환자 카드를 시간순으로 투입 수행하고 검증하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 5개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `logs/wel07_dryrun_execution_log.md 및 작성된 체크리스트` 형태의 결과물을 완성한다.\n", + "\n", + "### 📑 실습자료 및 작성 양식\n", + "모의 대량 부상자 발생 상황 1차 환자 분류 및 처치 기록 실습(Dry-run)**\n", + "\n", + "[제공 자료 1] 교육용 의무기록 카드 양식(미션 2 설계안이 있으면 그것을 사용) - 필수 기재란 7개: 카드 번호·식별번호, 성명(가명), 발견 시각·구역, 모의 분류 구분, 분류 시각·분류자, 구간별 인계 기록, 기록자 서명\n", + "\n", + "| **구분** | **기재란** |\n", + "| --- | --- |\n", + "| **카드 번호 / 식별번호** | ( ) / ( ) |\n", + "| **성명(가명)** | ( ) |\n", + "| **발견 시각 / 구역** | ( : ) / ( ) |\n", + "| **모의 분류 구분** | 긴급 · 응급 · 비응급 · 지연 |\n", + "| **분류 시각 / 분류자** | ( : ) / ( ) |\n", + "| **구간별 인계(현장->분류소)** | 시각( : ) 인계자( ) 인수자( ) |\n", + "| **구간별 인계(분류소->후송반)** | 시각( : ) 인계자( ) 인수자( ) |\n", + "| **의무요원 확인 시각** | ( : ) |\n", + "| **비고** | ( ) |\n", + "| **기록자 / 서명** | ( ) / ( ) |\n", + "\n", + "[제공 자료 2] Dry-run 모의환자 8명은 미션 1의 M-01~M-08을 사용한다. 측정 기준: 보고 지연시간 = 1차 분류 완료 선언 시각부터 의무대장 앞 현황 보고 완료(복창 확인) 시각까지. SOP 준수율 = 순서대로 수행한 단계 수 / 전체 단계 수 x 100. 필수 기재란 누락률 = 누락 칸 수 / 56칸 x 100.\n", + "\n", + "[작성 서식 1] 분류 일람표(기록 담당 작성)\n", + "\n", + "| **ID** | **가명** | **분류 구분** | **분류 시각** | **현재 위치** | **후송 순서** | **상태 갱신 시각** |\n", + "| --- | --- | --- | --- | --- | --- | --- |\n", + "| **M-01** | | | | | | |\n", + "| **M-02** | | | | | | |\n", + "| **M-03** | | | | | | |\n", + "| **M-04** | | | | | | |\n", + "| **M-05** | | | | | | |\n", + "| **M-06** | | | | | | |\n", + "| **M-07** | | | | | | |\n", + "| **M-08** | | | | | | |\n", + "\n", + "[작성 서식 2] Dry-run 수행 기록지(관측자 작성)\n", + "\n", + "| **SOP 단계** | **계획 순서** | **실제 시각** | **순서 준수(O/X)** | **비고(혼선·지연)** |\n", + "| --- | --- | --- | --- | --- |\n", + "| **분류소 개설 선언** | 1 | | | |\n", + "| **식별표 확인·복창** | 2 | | | |\n", + "| **분류 구분 읽기·알림** | 3 | | | |\n", + "| **의무기록 카드 작성** | 4 | | | |\n", + "| **분류 일람표 갱신** | 5 | | | |\n", + "| **1차 분류 완료 선언** | 6 | | | |\n", + "| **의무대장 현황 보고 완료** | 7 | | | |\n", + "| **후송 순서 확정 기록** | 8 | | | |\n", + "| **후송반 인계·서명** | 9 | | | |\n", + "| **환자 수 재확인** | 10 | | | |\n", + "\n", + "| **지표** | **계산** | **결과** | **목표** | **달성** |\n", + "| --- | --- | --- | --- | --- |\n", + "| **SOP 단계 준수율** | ( )/10 x 100 | ( )% | 80% 이상 | ( ) |\n", + "| **보고 지연시간** | ( : ) - ( : ) | ( )분 ( )초 | 3분 이내 | ( ) |\n", + "| **필수 기재란 누락률** | 누락 ( )칸 / 56칸 | ( )% | 5% 이하 | ( ) |\n", + "\n", + "[작성 서식 3] 병목·혼선 구간 기록표\n", + "\n", + "| **번호** | **발생 구간(SOP 단계)** | **현상** | **관련 역할** | **개선 아이디어** |\n", + "| --- | --- | --- | --- | --- |\n", + "| **1** | | | | |\n", + "| **2** | | | | |\n", + "| **3** | | | | |\n", + "\n", + "| **점검 항목** | **확인** |\n", + "| --- | --- |\n", + "| 모의환자 8명 카드를 모두 작성했다 | ( ) |\n", + "| 분류 일람표가 최종 상태와 일치한다 | ( ) |\n", + "| SOP 단계 준수율 80% 이상이다 | ( ) |\n", + "| 보고 지연시간이 3분 이내이다 | ( ) |\n", + "| 필수 기재란 누락률 5% 이하이다 | ( ) |\n", + "| 병목·혼선 구간을 2개 이상 기록했다 | ( ) |\n", + "\n", + "**\n", + "\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 05. 사상자 식별표 분실 및 우선순위 전도 등 인위적 혼란 상황 대응 훈련\n", + "\n", + "### 📋 미션 배경 및 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "사상자 식별표 분실 등 혼란 상황 대응 조치를 훈련한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **식별표 분실, 분류 구분과 후송 순서의 전도, 환자 수 불일치 같은 인위적 혼란 상황을 문서 대조로 찾아내고 교차확인 후 보고하는 훈련을 한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **교관이 오류를 섞어 둔 가상 의무기록 카드, 분류 일람표, 후송 순서표, 관측 기록의 12개 대조 항목을 받아 문서끼리 비교 수행하고 검증하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 5개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `docs/wel07_cross_verification_sheet.md` 형태의 결과물을 완성한다.\n", + "\n", + "### 📑 실습자료 및 작성 양식\n", + "사상자 식별표 분실 및 우선순위 전도 등 인위적 혼란 상황 대응 훈련**\n", + "\n", + "[제공 자료] 대조 항목 12개 [교육용가상자료] - 의무기록 카드, 분류 일람표, 후송 순서표, 관측 기록을 서로 비교해 오류인지 정상인지 판정한다. 분류 구분이 의학적으로 맞는지는 판단하지 않고 문서 간 일치 여부만 본다.\n", + "\n", + "| **번호** | **확인 항목** | **기록 A** | **기록 B** |\n", + "| --- | --- | --- | --- |\n", + "| **5-1** | 식별번호 | 카드: M-04 식별번호 R-204 | 일람표: M-05 식별번호 R-204 |\n", + "| **5-2** | 분류 구분 | 카드: M-01 긴급 | 일람표: M-01 응급 |\n", + "| **5-3** | 후송 순서 | 후송 순서표 1순위 M-05(비응급) | 일람표: M-01 긴급, M-04 긴급 대기 중 |\n", + "| **5-4** | 환자 수 | 일람표 합계 12명 | 카드 보관함 11장, 후송 대기 1명 카드 없음 |\n", + "| **5-5** | 시각 순서 | 카드: 분류 시각 +03:10 | 카드: 발견 시각 +03:40 |\n", + "| **5-6** | 인계 서명 | 카드: 분류소->후송반 인수자 서명 있음 | 후송반 기록: M-06 인수 기록 있음 |\n", + "| **5-7** | 성명 | 카드: 가상 병사 가 | 인계 기록: 가상 병사 나 |\n", + "| **5-8** | 이중 기재 | 카드: M-07 1장 | 카드: M-07 동일 식별번호 2장(다른 분류 구분) |\n", + "| **5-9** | 임시 번호 | 카드: M-10 식별표 없음, 임시 번호 T-01, 비고에 사유 기재 | 일람표: M-10 임시 번호 T-01 |\n", + "| **5-10** | 분류 구분 변경 | 카드: M-03 비응급에서 응급으로 변경, 변경 시각·승인자(의무요원) 기재 | 일람표: M-03 응급, 같은 시각 |\n", + "| **5-11** | 발견 구역 | 카드: M-02 구역 A | 관측 기록: 구역 B에서 발견 |\n", + "| **5-12** | 현황 보고 수치 | 보고: 긴급 3·응급 3·비응급 5·지연 1(합계 12) | 일람표: 긴급 3·응급 3·비응급 5·지연 1 |\n", + "\n", + "[작성 서식 1] 교차검증 매트릭스\n", + "\n", + "| **번호** | **판정(오류/정상)** | **대조한 문서** | **판정 근거** | **보고 대상·우선순위** |\n", + "| --- | --- | --- | --- | --- |\n", + "| **5-1** | | | | |\n", + "| **5-2** | | | | |\n", + "| **5-3** | | | | |\n", + "| **5-4** | | | | |\n", + "| **5-5** | | | | |\n", + "| **5-6** | | | | |\n", + "| **5-7** | | | | |\n", + "| **5-8** | | | | |\n", + "| **5-9** | | | | |\n", + "| **5-10** | | | | |\n", + "| **5-11** | | | | |\n", + "| **5-12** | | | | |\n", + "\n", + "| **지표** | **계산** | **결과** | **목표** |\n", + "| --- | --- | --- | --- |\n", + "| **식별 성공률** | 맞게 찾은 오류 수 ( ) / 주입 오류 8 x 100 | ( )% | 85% 이상(7건 이상) |\n", + "| **오표시** | 정상 4건 중 오류로 표시한 수 | ( )건 | 1건 이하 |\n", + "\n", + "[작성 서식 2] 불일치 보고 경로\n", + "\n", + "| **우선순위** | **불일치 유형** | **보고 대상** | **보고 시한** | **보고 문장(SBAR 한 줄)** |\n", + "| --- | --- | --- | --- | --- |\n", + "| **1(즉시)** | 식별번호 중복, 성명·구역 불일치, 환자 수 불일치 | | | |\n", + "| **2** | 분류 구분 기록 간 불일치, 후송 순서와 구분 불일치 | | | |\n", + "| **3** | 시각 순서 오류, 이중 기재 | | | |\n", + "\n", + "| [교관용 정답표 - 조별 판정 후 공개] 오류 8건: 5-1(번호 불일치), 5-2(분류 구분 불일치), 5-3(후송 순서와 분류 구분 불일치, 즉시 의무대장 확인), 5-4(환자 수 불일치), 5-5(시각 순서 오류), 5-7(성명 불일치), 5-8(이중 기재), 5-11(발견 구역 불일치). 정상 4건: 5-6, 5-9, 5-10, 5-12. 5-10은 변경 시각·승인자가 있으므로 정상이다. |\n", + "| --- |\n", + "\n", + "| **점검 항목** | **확인** |\n", + "| --- | --- |\n", + "| 12개 항목 모두 판정하고 근거와 대조 문서를 적었다 | ( ) |\n", + "| 주입 오류 8건 중 7건 이상을 찾았다(85% 이상) | ( ) |\n", + "| 정상 항목 오표시가 1건 이하이다 | ( ) |\n", + "| 분류 구분의 옳고 그름이 아니라 문서 간 불일치만 판정했다 | ( ) |\n", + "| 불일치 유형별 보고 대상·시한을 정했다 | ( ) |\n", + "\n", + "**\n", + "\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 06. 의무대장 및 상급 병원 앞 실시간 사상자 현황 브리핑 및 후송 인계 실습\n", + "\n", + "### 📋 미션 배경 및 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "의무대장 및 상급 병원 앞 사상자 현황 브리핑 및 인계를 실습한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **의무대장과 가상 상급 치료시설 앞에서 실시간 모의환자 현황을 브리핑하고 후송 인계를 실습해 빠짐없고 빠른 인계 절차를 만든다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **분류 일람표를 바탕으로 환자 수(구분별), 후송 완료·대기 현황, 모의 자산 현황, 요청 사항을 현황 브리핑 서식에 정리해 의무대장 역할에게 구두로 보고 수행하고 검증하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 5개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `reports/wel07_handover_protocol.md 및 브리핑 녹취/기록지` 형태의 결과물을 완성한다.\n", + "\n", + "### 📑 실습자료 및 작성 양식\n", + "의무대장 및 상급 병원 앞 실시간 사상자 현황 브리핑 및 후송 인계 실습**\n", + "\n", + "[제공 자료 1] 가상 현황 브리핑 예시 값 [교육용가상자료] - 미션 4 일람표 결과가 있으면 그것을 사용한다.\n", + "\n", + "| **구분** | **긴급** | **응급** | **비응급** | **지연** | **합계** |\n", + "| --- | --- | --- | --- | --- | --- |\n", + "| **수용 인원(분류 완료)** | 2 | 2 | 3 | 1 | 8 |\n", + "| **후송 완료** | 1 | 0 | 1 | 0 | 2 |\n", + "| **후송 대기** | 1 | 2 | 2 | 1 | 6 |\n", + "\n", + "[작성 서식 1] 현황 브리핑 서식(의무대장 역할 앞 구두 보고, 1분 이내)\n", + "\n", + "| **항목** | **내용** |\n", + "| --- | --- |\n", + "| **보고 시각·보고자** | ( ) |\n", + "| **수용 인원(구분별·합계)** | ( ) |\n", + "| **후송 완료·대기 현황** | ( ) |\n", + "| **모의 자산 현황(후송 차량·물자 카드 수)** | ( ) |\n", + "| **미확인·기록 불일치 사항** | ( ) |\n", + "| **요청 사항(결정 필요한 것)** | ( ) |\n", + "\n", + "[작성 서식 2] 후송 인계 품질 체크리스트(인계자·인수자 공동 확인)\n", + "\n", + "| **번호** | **필수 항목** | **인계자** | **인수자** |\n", + "| --- | --- | --- | --- |\n", + "| **1** | 식별번호·가명 일치 | | |\n", + "| **2** | 발견 시각·구역 | | |\n", + "| **3** | 모의 분류 구분과 분류 시각 | | |\n", + "| **4** | 구분 변경 이력(시각·승인자) | | |\n", + "| **5** | 의무요원 확인 시각 | | |\n", + "| **6** | 구간별 인계 서명 이력 | | |\n", + "| **7** | 이동 가능 여부 | | |\n", + "| **8** | 임시 번호 사용 여부와 사유 | | |\n", + "| **9** | 의무기록 카드 부착 확인 | | |\n", + "| **10** | 미해결 사항(확인 요청 중인 것) | | |\n", + "\n", + "| **구분** | **기재** |\n", + "| --- | --- |\n", + "| **인계 시작 / 종료 시각** | ( : ) / ( : ) 소요 ( )분 ( )초 / 목표 10분 이내 |\n", + "| **누락 항목 수** | ( )개 / 10개 누락률 ( )% |\n", + "| **인계자 서명 / 인수자 서명** | ( ) / ( ) |\n", + "\n", + "[제공 자료 2] 인수자 돌발 질문 카드(평가관이 3개 선택): Q1 긴급 구분 모의환자 수는? / Q2 마지막으로 구분이 변경된 환자는? / Q3 식별표 없이 임시 번호를 쓰는 환자는? / Q4 현재 후송 대기 인원은? / Q5 아직 확인되지 않은 사항은?\n", + "\n", + "[작성 서식 3] SBAR 현황 브리핑 템플릿\n", + "\n", + "| **구분** | **내용** |\n", + "| --- | --- |\n", + "| **S 상황(Situation)** | ( ) |\n", + "| **B 배경(Background)** | ( ) |\n", + "| **A 평가(Assessment)** | ( ) |\n", + "| **R 요청(Recommendation)** | ( ) |\n", + "\n", + "| **점검 항목** | **확인** |\n", + "| --- | --- |\n", + "| 현황 브리핑 서식 6개 항목을 모두 채웠다 | ( ) |\n", + "| 인계 필수 항목 10개 누락 0건(누락률 0%)이다 | ( ) |\n", + "| 인계 소요가 10분 이내이다 | ( ) |\n", + "| 돌발 질문 3개에 인수자가 모두 정답을 말했다 | ( ) |\n", + "| 인계자·인수자 상호 서명을 마쳤다 | ( ) |\n", + "\n", + "**\n", + "\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 07. 의무 물자 고갈 및 후송 자산 부족 극한 상황 모의 스트레스 훈련\n", + "\n", + "### 📋 미션 배경 및 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "의무 물자 고갈 및 후송 자산 부족 극한 상황 모의 스트레스 훈련을 진행한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **모의 의무 물자 소진과 후송 자산 부족이 겹치는 상황에서도 보고·기록·우선순위 재확인 절차를 유지하는 스트레스 훈련을 한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **안전통제관 통제 아래 사건 카드 6건(모의 물자 카드 소진, 후송 차량 지연, 추가 환자 도착, 통신 두절, 분류소 책임자 이탈, 우선순위 재확인 요청)을 순서대로 주입하여 처리 지연 및 복원력을 시험하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 5개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `logs/wel07_stress_drill_results.md` 형태의 결과물을 완성한다.\n", + "\n", + "### 📑 실습자료 및 작성 양식\n", + "의무 물자 고갈 및 후송 자산 부족 극한 상황 모의 스트레스 훈련**\n", + "\n", + "| [안전 통제] 실제 폭음·연막·화기는 사용하지 않는다. 음향은 녹음 재생, 조명은 적색광 손전등과 조도 조절만 쓴다. 안전통제관의 '중지' 구호 또는 호루라기 3회에 즉시 멈춘다. 모의 물자·후송 자산은 카드로만 표현하며, 자원 배분의 결정은 의무대장 역할(교관)이 한다. 학습자는 보고·요청·기록만 한다. |\n", + "| --- |\n", + "\n", + "[제공 자료] 스트레스 사건 카드 6건 [교육용가상자료]\n", + "\n", + "| **번호** | **주입 사건** | **환경** | **기대 행동** | **위험 조치(판정 대상)** |\n", + "| --- | --- | --- | --- | --- |\n", + "| **7-1** | 모의 물자 카드 소진 선언 | 저조도+음향 | 의무대장 역할에 보고, 요청 사항 기록 | 기록 갱신, 보고 |\n", + "| **7-2** | 후송 차량 2대 중 1대 지연 통보 | 저조도 | 후송 순서표 재확인 요청, 결정 기록 | 일람표 갱신, 환자 수 재확인 |\n", + "| **7-3** | 추가 모의환자 3명 도착 | 저조도+음향 | 카드 추가 발급, 식별번호 복창 | 환자 수 재확인, 보고 |\n", + "| **7-4** | 분류소 책임자 이탈(지휘 공백) | 저조도 | 위임 순서에 따라 차상위자 임무 인수 선언 | 인원 확인, 보고 |\n", + "| **7-5** | 통신 두절 | 저조도+음향 | 대체 보고 수단 사용, 두절 시각 기록 | 기록 보호, 두절 시각 기록 |\n", + "| **7-6** | 우선순위 재확인 요청 | 저조도 | 분류 구분 변경은 의무요원 승인 후에만 기록 | 변경 시각·승인자 기록 |\n", + "\n", + "[작성 서식 1] 결과 기록표(관측자 작성) - 전환 시간 = 주입 시각부터 비상 절차 전환(또는 임무 인수) 선언 시각까지\n", + "\n", + "| **사건** | **주입 시각** | **선언 시각** | **전환 시간** | **위험 조치1** | **위험 조치2** | **안전 위반** |\n", + "| --- | --- | --- | --- | --- | --- | --- |\n", + "| **7-1** | | | | O / X | O / X | 유 / 무 |\n", + "| **7-2** | | | | O / X | O / X | 유 / 무 |\n", + "| **7-3** | | | | O / X | O / X | 유 / 무 |\n", + "| **7-4** | | | | O / X | O / X | 유 / 무 |\n", + "| **7-5** | | | | O / X | O / X | 유 / 무 |\n", + "| **7-6** | | | | O / X | O / X | 유 / 무 |\n", + "\n", + "| **지표** | **계산** | **결과** | **목표** |\n", + "| --- | --- | --- | --- |\n", + "| **최대 전환 시간** | 6건 중 가장 긴 시간 | ( )초 | 60초 이내 |\n", + "| **위험 조치 성공률** | 성공 수 ( ) / 12 x 100 | ( )% | 90% 이상 |\n", + "| **안전 수칙 위반** | 위반 건수 | ( )건 | 0건 |\n", + "| **지휘 공백 위임 시간** | 7-4 전환 시간 | ( )초 | 60초 이내 |\n", + "\n", + "[작성 서식 2] 임무 위임 순서표\n", + "\n", + "| **순서** | **직책(역할)** | **인수할 임무** | **인수 선언 문장** |\n", + "| --- | --- | --- | --- |\n", + "| **1** | 분류소 책임자 | | |\n", + "| **2** | | | |\n", + "| **3** | | | |\n", + "\n", + "| **점검 항목** | **확인** |\n", + "| --- | --- |\n", + "| 안전 교육과 중지 신호를 확인한 뒤 시작했다 | ( ) |\n", + "| 사건 6건 모두 주입·선언 시각을 기록했다 | ( ) |\n", + "| 모든 전환 시간이 60초 이내이다 | ( ) |\n", + "| 위험 조치 성공률이 90% 이상이다 | ( ) |\n", + "| 안전 수칙 위반이 0건이다 | ( ) |\n", + "| 자원 배분 결정은 학습자가 하지 않고 의무대장 역할의 결정을 기록했다 | ( ) |\n", + "\n", + "**\n", + "\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 08. AAR 회의를 통한 분류 오류 및 기록 누락 요인 분석\n", + "\n", + "### 📋 미션 배경 및 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "AAR 회의를 통해 분류 오류 및 기록 누락 요인을 분석한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **AAR 회의로 분류 기록 오류와 인계 누락의 원인을 사실 중심으로 분석한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **Dry-run·스트레스 훈련의 관측 기록, 의무기록 카드, 참여자 진술을 시간순으로 맞춰 서로 다른 부분 확인하고 유효성을 점검하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 5개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `aar/wel07_aar_session_minutes.md 및 오류 분석 매트릭스` 형태의 결과물을 완성한다.\n", + "\n", + "### 📑 실습자료 및 작성 양식\n", + "AAR 회의를 통한 분류 오류 및 기록 누락 요인 분석**\n", + "\n", + "[제공 자료 1] AAR 4단계 진행표\n", + "\n", + "| **단계** | **질문** | **진행 규칙** |\n", + "| --- | --- | --- |\n", + "| **1** | 무엇을 계획했나? | SOP·MSEL 기준을 먼저 확인한다 |\n", + "| **2** | 실제로 무엇이 일어났나? | 기록(시각)을 기준으로 말하고, 진술은 기록과 대조한다 |\n", + "| **3** | 왜 차이가 났나? | 사람이 아닌 절차·양식·환경·숙달 원인으로 분류한다 |\n", + "| **4** | 무엇을 유지·개선할까? | 개선점마다 담당·기한·근거를 붙인다 |\n", + "\n", + "[제공 자료 2] 가상 1차 훈련 타임라인 발췌 [교육용가상자료] - 자기 조 기록과 함께 사용한다.\n", + "\n", + "| **시각** | **관측 기록** | **참여자 진술** |\n", + "| --- | --- | --- |\n", + "| **+03:10** | M-04 카드 작성, 식별번호 칸 공란 | 기록 담당: 식별표가 없어서 비워 둠 |\n", + "| **+04:30** | 일람표 M-02 분류 구분 '응급', 카드는 '긴급' | 분류 담당: 카드를 먼저 읽고 알림 |\n", + "| **+05:10** | 1차 분류 완료 선언 | 분류 담당: 환자 수 8명 확인 |\n", + "| **+10:20** | 의무대장 현황 보고 완료(지연 5분 10초) | 통신 담당: 합계가 맞는지 다시 세느라 늦음 |\n", + "| **+14:00** | 후송 인계에서 구분 변경 이력 누락 | 후송반: 카드 뒷면에 있어 못 봄 |\n", + "\n", + "[작성 서식 1] 기록-진술 불일치 확인표\n", + "\n", + "| **번호** | **시각** | **기록 내용** | **진술 내용** | **확인 결과(사실)** |\n", + "| --- | --- | --- | --- | --- |\n", + "| **1** | | | | |\n", + "| **2** | | | | |\n", + "| **3** | | | | |\n", + "| **4** | | | | |\n", + "\n", + "[작성 서식 2] 오류 분석 매트릭스\n", + "\n", + "| **번호** | **문제(현상)** | **사실 근거(기록 위치)** | **원인 분류** | **개선점** | **담당·기한** |\n", + "| --- | --- | --- | --- | --- | --- |\n", + "| **1** | | | | | |\n", + "| **2** | | | | | |\n", + "| **3** | | | | | |\n", + "| **4** | | | | | |\n", + "| **5** | | | | | |\n", + "| **6** | | | | | |\n", + "\n", + "원인 분류: 절차(SOP 순서·누락) / 양식(칸 위치·기재란 부족) / 환경(조명·소음·구역 거리) / 숙달(반복 부족) / 통신(수단·호출부호)\n", + "\n", + "[작성 서식 3] 1차 SOP 수정안\n", + "\n", + "| **수정 대상** | **수정 전** | **수정 후** | **근거(분석 번호)** |\n", + "| --- | --- | --- | --- |\n", + "| **체크리스트** | | | |\n", + "| **기록 양식** | | | |\n", + "| **인계·보고 템플릿** | | | |\n", + "| **기타** | | | |\n", + "\n", + "| **점검 항목** | **확인** |\n", + "| --- | --- |\n", + "| AAR 4단계를 순서대로 진행했다 | ( ) |\n", + "| 기록과 진술이 다른 부분을 사실로 확인했다 | ( ) |\n", + "| 개선점을 5건 이상 도출했다 | ( ) |\n", + "| 모든 원인 항목에 근거 기록 위치를 적었다(100%) | ( ) |\n", + "| 회의록에 개인 비난 표현이 없다 | ( ) |\n", + "| SOP 수정안을 작성했다 | ( ) |\n", + "\n", + "**\n", + "\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 09. 피드백 반영 대량 부상자 분류 및 인계 훈련 재실행\n", + "\n", + "### 📋 미션 배경 및 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "피드백을 반영하여 대량 부상자 분류 및 인계 훈련을 재실행한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **AAR 수정안을 반영해 같은 조건으로 분류·인계 훈련을 재실행하고 시간과 오류의 개선을 정량 비교한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **1차와 같은 모의환자 8명과 평가표로 수정된 체크리스트·의무기록 양식·브리핑 템플릿을 적용해 재실행 수행하고 검증하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 5개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `eval/wel07_rerun_performance_comparison.md` 형태의 결과물을 완성한다.\n", + "\n", + "### 📑 실습자료 및 작성 양식\n", + "피드백 반영 대량 부상자 분류 및 인계 훈련 재실행**\n", + "\n", + "[제공 자료] 가상 1차 결과 예시 [교육용가상자료] - 자기 조 1차 기록이 없으면 이 값을 기준으로 쓴다. 측정 기준은 미션 4·6과 같다.\n", + "\n", + "| **지표** | **1차(가상 예시)** | **목표(2차)** |\n", + "| --- | --- | --- |\n", + "| **보고 지연시간** | 5분 00초 | 3분 30초 이하(30% 단축) |\n", + "| **인계 소요시간** | 14분 00초 | 9분 48초 이하(30% 단축) |\n", + "| **의무기록 카드 오류** | 10건 | 5건 이하(50% 감소) |\n", + "| **인계 누락 항목** | 4개 | 2개 이하(50% 감소) |\n", + "\n", + "개선율 계산: 시간 단축률 = (1차 - 2차) / 1차 x 100, 오류 감소율 = (1차 - 2차) / 1차 x 100\n", + "\n", + "[작성 서식 1] 전후 성과 비교표\n", + "\n", + "| **지표** | **1차** | **2차** | **개선율** | **목표** | **달성** |\n", + "| --- | --- | --- | --- | --- | --- |\n", + "| **보고 지연시간** | | | | 30% 단축 | |\n", + "| **인계 소요시간** | | | | 30% 단축 | |\n", + "| **의무기록 카드 오류** | | | | 50% 감소 | |\n", + "| **인계 누락 항목** | | | | 50% 감소 | |\n", + "| **SOP 단계 준수율** | | | | 80% 이상 유지 | |\n", + "\n", + "[작성 서식 2] 미달 지표 원인과 보수 교육 제안\n", + "\n", + "| **구분** | **내용** |\n", + "| --- | --- |\n", + "| **미달 지표·원인** | ( ) |\n", + "| **적용한 수정안 효과** | ( ) |\n", + "| **보수 교육 주기·대상** | 예: 분기 1회, 신규 전입자 전원( ) |\n", + "| **다음 재평가 일정** | ( ) |\n", + "\n", + "| **점검 항목** | **확인** |\n", + "| --- | --- |\n", + "| 1차와 같은 조건·평가표·측정 기준으로 재실행했다 | ( ) |\n", + "| 시간 지표가 30% 이상 단축되었다 | ( ) |\n", + "| 오류·누락 지표가 50% 이상 감소했다 | ( ) |\n", + "| 미달 지표가 있으면 원인을 적었다 | ( ) |\n", + "| 보수 교육 주기와 대상을 제안했다 | ( ) |\n", + "\n", + "**\n", + "\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 10. 대량 사상자 발생 시 의무지원 인계 및 기록 관리 표준 매뉴얼 완성\n", + "\n", + "### 📋 미션 배경 및 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "대량 부상자 발생 시 의무지원 인계 및 기록 훈련 표준 매뉴얼을 완성한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **대량 부상자 발생 시 의무지원 인계·기록 훈련 전 과정을 다른 부대 교관이 그대로 쓸 수 있는 표준 매뉴얼과 교관 지침서로 묶는다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **개요, SOP(체크리스트·흐름도), 시나리오(모의환자 카드·MSEL), 평가 루브릭, AAR 양식의 5개 장으로 교안을 구성하고 수정된 양식을 부록에 넣는다 과정을 진행하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 5개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `package/wel07_standard_training_package.md 및 교관 지침서` 형태의 결과물을 완성한다.\n", + "\n", + "### 📑 실습자료 및 작성 양식\n", + "대량 사상자 발생 시 의무지원 인계 및 기록 관리 표준 매뉴얼 완성**\n", + "\n", + "[작성 서식 1] 표준 지침서 목차 서식\n", + "\n", + "| **장** | **제목** | **포함 내용** | **사용할 산출물** | **완료** |\n", + "| --- | --- | --- | --- | --- |\n", + "| **1** | 개요 | 목적, 전훈 배경, 적용 대상, 안전·보안 원칙 | 미션 1 | ( ) |\n", + "| **2** | SOP | 단계별 체크리스트, 분류 기록 규칙, 인계 프로세스, 휴대 요약 카드 | 미션 2·3·6 | ( ) |\n", + "| **3** | 시나리오 | 모의환자 카드, MSEL, 스트레스 사건 카드 | 미션 3·7 | ( ) |\n", + "| **4** | 평가 루브릭 | 준수율·시간 측정, 교차검증 채점, 전후 비교 | 미션 4·5·9 | ( ) |\n", + "| **5** | AAR | 4단계 진행표, 오류 분석 매트릭스 | 미션 8 | ( ) |\n", + "| **부록** | 양식 모음 | 의무기록 카드, 분류 일람표, 현황 브리핑, 인계 체크리스트 | 전 미션 | ( ) |\n", + "\n", + "[작성 서식 2] 교관 지침서 핵심 항목\n", + "\n", + "| **항목** | **내용** |\n", + "| --- | --- |\n", + "| **준비물·인원** | ( ) |\n", + "| **안전 통제(입회 인원, 중지 신호)** | ( ) |\n", + "| **시간표(총 소요)** | ( ) |\n", + "| **채점 방법** | ( ) |\n", + "| **의무대·군의관 감수 절차** | ( ) |\n", + "| **금지 사항** | 처치 세부 절차·약물 명칭·용량의 교안화 금지, 실제 환자 정보 사용 금지 |\n", + "\n", + "[작성 서식 3] 모의 검토 의견 처리표(타 부대 교관·평가관 역할)\n", + "\n", + "| **번호** | **검토자 역할** | **의견** | **반영/미반영** | **처리 내용·사유** |\n", + "| --- | --- | --- | --- | --- |\n", + "| **1** | | | | |\n", + "| **2** | | | | |\n", + "| **3** | | | | |\n", + "| **4** | | | | |\n", + "| **5** | | | | |\n", + "\n", + "[작성 서식 4] 개정 관리표\n", + "\n", + "| **판** | **일자** | **개정 내용** | **감수** | **승인** |\n", + "| --- | --- | --- | --- | --- |\n", + "| **1.0** | | 최초 작성 | | |\n", + "| | | | | |\n", + "\n", + "| **점검 항목** | **확인** |\n", + "| --- | --- |\n", + "| 5개 장(개요-SOP-시나리오-평가 루브릭-AAR)이 모두 있다 | ( ) |\n", + "| 부록에 수정된 양식을 모두 넣었다 | ( ) |\n", + "| 모의 검토 의견을 모두 처리했다(미반영은 사유 기재) | ( ) |\n", + "| 처치 절차·약물 명칭·용량이 지침서에 없고, 의무대·군의관 감수 절차와 개정 주기를 명시했다 | ( ) |\n", + "| 교안의 수치가 미션 4·5·7·9 결과와 일치한다 | ( ) |\n", + "\n", + "**4. 평가 채점표**\n", + "\n", + "교관·평가관이 미션별 산출물과 기록지를 확인해 PASS/FAIL에 표시한다. 필수 미션 7개가 모두 PASS이고 선택 미션 3개 중 2개 이상이 PASS이면 과제 이수로 판정한다.\n", + "\n", + "| **미션** | **구분** | **채점 기준** | **판정** |\n", + "| --- | --- | --- | --- |\n", + "| **M1** | 필수 | 현장 체크 항목 15개 이상 정의, 6개 역할 x 10개 항목 역할 분담 매트릭스 빈칸 0개(100% 매핑, 항목당 R 1명), 교관 체크리스트 대조 누락 0건이며 부대 유형별 환경 변수를 작성했는가. | PASS / FAIL |\n", + "| **M2** | 필수 | SOP 단계별 세부 절차 100% 명시, 필수 안전·보안 조치 포함률 100%, 의무기록 양식 필수 기재란 7개 정의, 교차 검토 지적 사항을 모두 수정하고 휴대 요약 카드를 완성했는가. | PASS / FAIL |\n", + "| **M3** | 필수 | 3개 구간의 인계자·인수자·책임 이전 시점 명시, 시간대별 주입 사건 10건 이상, 정상:비정상 비율 6:4, 모든 사건에 투입 시각·대상 역할·기대 행동이 있고 워크스루에서 모순이 없는가. | PASS / FAIL |\n", + "| **M4** | 선택 | SOP 단계 준수율 80% 이상, 보고 지연시간 3분 이내, 의무기록 카드 8장의 필수 기재란 누락률 5% 이하(56칸 중 2칸 이하), 병목·혼선 구간 2개 이상 식별했는가. | PASS / FAIL |\n", + "| **M5** | 필수 | 주입 오류 식별 성공률 85% 이상(8건 중 7건 이상), 정상 항목 오표시 1건 이하, 모든 판정에 대조 문서와 근거가 있고 불일치 유형별 보고 경로를 명시했는가. | PASS / FAIL |\n", + "| **M6** | 선택 | 인계 필수 항목 누락률 0%(10개 항목), 인계 소요 10분 이내, 인수자 돌발 질문 3문항 모두 정답, 인계자·인수자 상호 서명과 품질 체크리스트 통과가 확인되는가. | PASS / FAIL |\n", + "| **M7** | 필수 | 비상 절차 전환 시간 사건별 1분 이내, 위험 조치 성공률 90% 이상, 안전 수칙 위반 0건, 책임자 이탈 시 임무 위임이 1분 안에 이루어졌는가. | PASS / FAIL |\n", + "| **M8** | 선택 | 개선점 5건 이상 도출, 원인 항목별 근거 기록 연결률 100%, 기록과 진술 불일치 확인 결과 정리, 비난성 표현 없이 1차 SOP 수정안을 도출했는가. | PASS / FAIL |\n", + "| **M9** | 필수 | 1차 대비 조치(보고·인계) 시간 30% 이상 단축, 오류 발생 건수 50% 이상 감소, 동일 지표·동일 방법으로 비교했으며 미달 지표의 원인과 보수 교육 주기를 제시했는가. | PASS / FAIL |\n", + "| **M10** | 필수 | 교안 5개 장(개요-SOP-시나리오-평가 루브릭-AAR) 100% 완비, 모의 검토 의견 처리율 100%(미반영 시 사유 기재), 의무대·군의관 감수 절차와 개정 주기를 명시했는가. | PASS / FAIL |\n", + "\n", + "| **구분** | **기재** |\n", + "| --- | --- |\n", + "| **종합 판정** | 필수 ( )/7 PASS, 선택 ( )/3 PASS -> 이수 / 재평가 |\n", + "| **평가관 의견** | ( ) |\n", + "| **평가관 서명 / 일자** | ( ) / ( ) |\n", + "\n" + ] + } + ], + "metadata": { + "colab": { + "provenance": [] + }, + "kernelspec": { + "display_name": "Python 3", + "name": "python3" + }, + "language_info": { + "name": "python" + } + }, + "nbformat": 4, + "nbformat_minor": 0 +} \ No newline at end of file diff --git a/uploads/WEL08/WEL08_workbook.ipynb b/uploads/WEL08/WEL08_workbook.ipynb new file mode 100644 index 0000000..d1bf305 --- /dev/null +++ b/uploads/WEL08/WEL08_workbook.ipynb @@ -0,0 +1,992 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# 📋 [WEL08] 동료의 급성 스트레스 신호 발견·전문가 연계 역할훈련\n", + "### 실습 워크북 & SOP 가이드 (Google Colab / Jupyter)\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/WEL08/WEL08_workbook.ipynb)\n", + "\n", + "---\n", + "\n", + "## 📌 1. 프로젝트 개요 및 배경\n", + "\n", + "| 항목 | 내용 |\n", + "| :--- | :--- |\n", + "| **과정 분류** | 장병 복지·지원 (Level 2) |\n", + "| **학습 소요 시간** | 10개 미션 (5일 / 총 27.0시간) |\n", + "| **실습 유형** | 작전절차(SOP), 체크리스트, 모의훈련, AAR 사후검토 실습 |\n", + "\n", + "### 📖 프로젝트 목표\n", + "러시아-우크라이나 전쟁처럼 장기간 이어지는 전투 환경에서는 식사와 보급이 불안정하고 수면이 부족해지면서 장병의 긴장과 스트레스가 쌓이고, 초기 신호가 동료나 간부에게 전달되지 않거나 묵살될 때 문제가 커진다는 점이 반복해서 지적되었다. 본 프로젝트는 신병 교육과 자대 배속 초기에 흔한 급식·보급 불만을 출발점으로, 불만과 함께 나타나는 동료의 스트레스 신호를 발견하고 기록·보고하며 상담관 등 전문가에게 연계하는 과정을 교육용 가상 역할극 카드로 훈련한다. 역할극의 모든 인물과 상황은 가상이고 신호 체크는 참고용이며 진단이나 판정이 아니다. 학습자는 경청, 사실 중심 기록, 지휘 계통 보고, 상담관 연계 요청에 집중하고 상담과 치료는 전문가가 맡는다. 최종 목표는 신호 발견부터 연계까지의 정확도와 속도를 수치로 확인해 개선하고, 다른 부대 교관이 반복해 쓸 수 있는 표준 훈련 매뉴얼로 정리하는 것이다.\n", + "\n", + "> **⚠️ [교육용 가상 자료 원칙]**\n", + "> - 본 워크북의 모든 자료는 안전 규정에 따라 **교육용 가상 자료**로 구성되어 있습니다.\n", + "> - 실제 군사 기밀이나 부대 세부 인적·작전 정보는 포함되지 않습니다.\n", + "\n", + "---\n", + "\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 01. 신병 훈련소/자대 배속 초기 급식 및 보급품 관련 불만 요인 분석\n", + "\n", + "### 📋 미션 배경 및 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "신병 교육 및 자대 배속 초기 급식/보급 관련 불만 요인을 분석한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **신병 교육과 자대 배속 초기에 나타나는 급식·보급 관련 불만 요인과 그에 동반될 수 있는 스트레스 신호를 분석해, 현장 체크 항목과 역할 분담을 정의한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **실습자료의 가상 불만 접수 현황 20건과 가상 전훈 요약 카드를 읽고 불만 유형(식사량, 식사 시간, 피복 사이즈, 보급 지연 등)별 건수와 반복 여부를 분석표로 정리하고 규격화하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 5개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `docs/wel08_baseline_assessment.md 및 훈련 프로파일` 형태의 결과물을 완성한다.\n", + "\n", + "### 📑 실습자료 및 작성 양식\n", + "신병 훈련소/자대 배속 초기 급식 및 보급품 관련 불만 요인 분석**\n", + "\n", + "[제공 자료 1] 가상 전훈 요약 카드 - 공개된 일반 경향을 교육용으로 요약한 것이며 특정 수치·부대·지역 정보는 없다.\n", + "\n", + "| **번호** | **관찰된 경향(요약)** | **훈련 시사점** |\n", + "| --- | --- | --- |\n", + "| **L1** | 식사와 보급이 불안정하면 장병의 피로와 긴장이 커지고 사기가 떨어짐 | 급식·보급 불만을 사기 신호로 읽기 |\n", + "| **L2** | 초기 불만이 전달되지 않거나 묵살되면 이후 더 큰 문제로 이어짐 | 접수-확인-보고의 단절 방지 |\n", + "| **L3** | 동료가 가장 먼저 변화를 알아차리지만 어디에 알려야 할지 모름 | 동료의 관찰·보고·연계 경로 |\n", + "| **L4** | 불만을 말하면 불이익이 있을까 걱정해 숨김 | 익명 창구, 낙인 방지 |\n", + "| **L5** | 수면 부족과 식사 불량이 겹치면 집중력과 감정 조절이 어려워짐 | 관찰 사실 기록, 전문가 연계 |\n", + "\n", + "[제공 자료 2] 가상 불만 접수 현황 20건 [교육용가상자료] - 건의함·설문·구두 접수를 합친 요약이다.\n", + "\n", + "| **번호** | **접수 경로** | **내용(요약)** | **횟수·기간** |\n", + "| --- | --- | --- | --- |\n", + "| **1** | 건의함 | 배식량이 부족해 끝 순번은 반찬이 모자람 | 3일 연속 |\n", + "| **2** | 설문 | 식사 시간이 짧아 다 먹지 못함 | 1회 |\n", + "| **3** | 구두 | 피복 사이즈가 맞지 않는데 교환 신청 방법을 모름 | 1회 |\n", + "| **4** | 건의함 | 속옷 보급이 늦어 갈아입지 못함 | 1주 |\n", + "| **5** | 설문 | 식사량이 많아 남기게 되어 눈치가 보임 | 1회 |\n", + "| **6** | 건의함 | 배식량이 부족해 끝 순번은 반찬이 모자람 | 5일째 |\n", + "| **7** | 구두 | 식사 중 소음이 심해 식사가 불편함 | 1회 |\n", + "| **8** | 설문 | 야간 간식이 지급되지 않음 | 1회 |\n", + "| **9** | 건의함 | 군화 사이즈 교환이 한 달째 안 됨 | 4주 |\n", + "| **10** | 구두 | 식사를 거르는 동료가 걱정됨 | 3일 |\n", + "| **11** | 설문 | 잠을 못 자서 식욕이 없음 | 1회 |\n", + "| **12** | 건의함 | 배식량 문제를 말했는데 답이 없음 | 1주 |\n", + "| **13** | 설문 | 식당 배식 대기가 길어 다음 일정에 늦음 | 1회 |\n", + "| **14** | 구두 | 동료가 요즘 말수가 줄고 혼자 있음 | 1주 |\n", + "| **15** | 건의함 | 피복 지급 후 사이즈 교환 불가 안내가 불명확함 | 1회 |\n", + "| **16** | 설문 | 식사가 입맛에 맞지 않음 | 1회 |\n", + "| **17** | 건의함 | 배식량이 부족해 끝 순번은 반찬이 모자람 | 7일째 |\n", + "| **18** | 구두 | 물 보급이 부족한 날이 있음 | 2회 |\n", + "| **19** | 설문 | 불만을 말해도 달라지지 않는다고 느낌 | 1회 |\n", + "| **20** | 건의함 | 동기가 식사를 자주 남기고 잠을 못 잔다고 함 | 2주 |\n", + "\n", + "[제공 자료 3] 불만 유형과 관찰 가능한 행동 변화 참고표(진단 아님)\n", + "\n", + "| **불만 유형** | **관찰 가능한 행동 변화(참고)** | **먼저 확인할 사실** |\n", + "| --- | --- | --- |\n", + "| **식사량·식사 시간** | 식사를 남김·거름, 급하게 먹음 | 배식량, 식사 시간, 남긴 횟수 |\n", + "| **피복·보급 지연** | 불편 호소, 위축, 일과 지연 | 지급·교환 기록, 요청 시각 |\n", + "| **수면·피로** | 졸음, 집중 저하, 지각 | 취침·기상 시각, 야간 근무 여부 |\n", + "| **소통 단절** | 말수 감소, 혼자 지냄, 건의 포기 발언 | 건의 접수·답변 기록 |\n", + "\n", + "[작성 서식 1] 불만 요인 분석표(20건 유형별 분류)\n", + "\n", + "| **불만 유형** | **해당 번호** | **건수** | **반복·장기 여부** | **관련 부서** | **점검 우선순위** |\n", + "| --- | --- | --- | --- | --- | --- |\n", + "| **식사량·식사 시간** | | | | | |\n", + "| **피복 사이즈·지급** | | | | | |\n", + "| **보급 지연** | | | | | |\n", + "| **식사 환경** | | | | | |\n", + "| **동료 걱정·소통** | | | | | |\n", + "| **합계** | | 20 | | | |\n", + "\n", + "[작성 서식 2] 역할 분담 매트릭스 - R(책임) 또는 S(지원)를 적는다. 한 항목의 R은 1명만 둔다.\n", + "\n", + "| **체크 항목** | **발견자** | **당사자** | **기록** | **분대장** | **급양·보급** | **관측** |\n", + "| --- | --- | --- | --- | --- | --- | --- |\n", + "| **불만·신호 관찰** | | | | | | |\n", + "| **경청·사실 확인 질문** | | | | | | |\n", + "| **비밀 유지의 한계 안내** | | | | | | |\n", + "| **면담 기록 작성** | | | | | | |\n", + "| **급식·보급 현장 확인** | | | | | | |\n", + "| **분대장 보고** | | | | | | |\n", + "| **상담관 연계 요청** | | | | | | |\n", + "| **당사자에게 결과 안내** | | | | | | |\n", + "| **후속 확인 일정 기록** | | | | | | |\n", + "| **개인정보·금지 표현 관찰** | | | | | | |\n", + "\n", + "[작성 서식 3] 현장 체크 항목표(15개 이상)\n", + "\n", + "| **번호** | **체크 항목** | **단계** | **책임 역할** | **교관 대조** |\n", + "| --- | --- | --- | --- | --- |\n", + "| **1** | | | | |\n", + "| **2** | | | | |\n", + "| **3** | | | | |\n", + "| **4** | | | | |\n", + "| **5** | | | | |\n", + "| **6** | | | | |\n", + "| **7** | | | | |\n", + "| **8** | | | | |\n", + "| **9** | | | | |\n", + "| **10** | | | | |\n", + "| **11** | | | | |\n", + "| **12** | | | | |\n", + "| **13** | | | | |\n", + "| **14** | | | | |\n", + "| **15** | | | | |\n", + "\n", + "[작성 서식 4] 부대 유형별 환경 변수\n", + "\n", + "| **부대 유형** | **급식 방식** | **보급 경로** | **상담 접근성** | **소통 창구** | **추가 체크 항목** |\n", + "| --- | --- | --- | --- | --- | --- |\n", + "| **신병 교육 중대** | | | | | |\n", + "| **자대 배속 초기** | | | | | |\n", + "| **소규모 부대** | | | | | |\n", + "\n", + "| **점검 항목** | **확인** |\n", + "| --- | --- |\n", + "| 불만 접수 20건을 유형별로 모두 분류했다 | ( ) |\n", + "| 현장 체크 항목을 15개 이상 정의했다 | ( ) |\n", + "| 모든 항목에 R 역할이 1명씩 지정되었다(매핑 100%) | ( ) |\n", + "| 교관 체크리스트와 대조해 누락을 보완했다(누락 0건) | ( ) |\n", + "| 행동 변화를 진단이 아닌 참고용으로만 적었다 | ( ) |\n", + "\n", + "**\n", + "\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 02. 설문조사, 건의함, 익명 상담 데이터 기반 불만 신호 조기 식별 체크리스트 작성\n", + "\n", + "### 📋 미션 배경 및 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "설문조사 및 상담 데이터 기반 불만 신호 조기 식별 체크리스트를 작성한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **설문조사, 건의함, 익명 상담 요약 자료에서 불만과 스트레스 신호를 조기에 식별하는 체크리스트와 SOP 흐름도를 작성한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **가상 설문·건의함·익명 상담 요약에서 반복되는 표현과 관찰 가능한 행동 변화를 신호 체크 항목으로 옮기고, '관찰한다·경청한다·기록한다·보고한다·연계한다·후속 확인한다' 형태의 단계별 절차로 정리하고 규격화하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 5개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `sop/wel08_field_checklist.md 및 SOP 흐름도` 형태의 결과물을 완성한다.\n", + "\n", + "### 📑 실습자료 및 작성 양식\n", + "설문조사, 건의함, 익명 상담 데이터 기반 불만 신호 조기 식별 체크리스트 작성**\n", + "\n", + "[제공 자료 1] 가상 자료 3종 요약 [교육용가상자료]\n", + "\n", + "| **자료** | **가상 내용(요약)** | **반복 표현** |\n", + "| --- | --- | --- |\n", + "| **설문(20명 응답)** | 식사량 불만 6명, 수면 부족 호소 5명, 보급 지연 4명, 불만을 말해도 소용없다 3명 | 소용없다, 말해도 안 된다 |\n", + "| **건의함(12건)** | 같은 취지의 배식량 건의 4건(답변 없음), 군화 교환 지연 2건 | 답이 없다, 계속 안 된다 |\n", + "| **익명 상담 요약(비식별 5건)** | 잠을 못 잠, 입맛이 없음, 혼자 있고 싶음, 훈련을 따라가기 힘듦 | 잠을 못 잔다, 힘들다 |\n", + "\n", + "[작성 서식 1] 신호 식별 체크리스트(참고용, 진단 아님)\n", + "\n", + "| **영역** | **관찰 항목(사실로 쓸 것)** | **기록 방법(횟수·기간·시각)** | **보고 기준(예: 반복·지속)** |\n", + "| --- | --- | --- | --- |\n", + "| **식사** | 남긴 양, 거른 횟수 | | |\n", + "| **수면** | 졸음, 수면 호소 횟수 | | |\n", + "| **대인 관계** | 말수 변화, 혼자 지내는 시간 | | |\n", + "| **일과** | 지각, 집중 저하 관찰 | | |\n", + "| **표현** | 건의 포기·체념 표현 | | |\n", + "| **위기 징후** | 안전에 대한 위험을 시사하는 말·행동 | | 즉시 보고 |\n", + "\n", + "[작성 서식 2] 관찰 사실 문장 변환 연습 - 해석·단정 문장을 관찰한 사실 문장으로 바꾼다.\n", + "\n", + "| **번호** | **해석·단정 문장(쓰지 않음)** | **관찰 사실 문장(바꿔 쓰기)** | **적합(O/X)** |\n", + "| --- | --- | --- | --- |\n", + "| **1** | 가상 신병 가는 우울해 보인다 | | |\n", + "| **2** | 가상 신병 나는 예민한 성격이다 | | |\n", + "| **3** | 가상 신병 다는 일부러 밥을 안 먹는다 | | |\n", + "| **4** | 가상 신병 라는 정신적으로 문제가 있다 | | |\n", + "| **5** | 가상 신병 마는 관심을 끌려고 불평한다 | | |\n", + "\n", + "금지 표현 목록(조별로 추가): 진단명으로 단정하는 말, 성격·의도를 추측하는 말, 낙인이 되는 말, 비밀 보장을 무조건 약속하는 말\n", + "\n", + "[작성 서식 3] 단계별 SOP 체크리스트 - 상담·판단이 필요한 사항은 '상담관 결정'으로 표시한다.\n", + "\n", + "| **단계** | **번호** | **행동 항목(행동 동사로)** | **안전·보안 조치** | **기록** | **결정권자** |\n", + "| --- | --- | --- | --- | --- | --- |\n", + "| **관찰** | 1 | | | | |\n", + "| **관찰** | 2 | | | | |\n", + "| **경청·기록** | 3 | | | | |\n", + "| **경청·기록** | 4 | | | | |\n", + "| **경청·기록** | 5 | | | | |\n", + "| **보고** | 6 | | | | |\n", + "| **보고** | 7 | | | | |\n", + "| **연계·후속** | 8 | | | | |\n", + "| **연계·후속** | 9 | | | | |\n", + "| **연계·후속** | 10 | | | | |\n", + "\n", + "필수 안전·보안 조치(각 단계 최소 1개): 개인정보 보호(가명·번호 사용) / 비밀 유지의 한계(안전 위험은 보고) 사전 안내 / 단정·진단 표현 금지 / 위기 징후 즉시 보고 / 소문 확산 금지\n", + "\n", + "| [불만·변화 관찰] -> [경청·사실 확인 질문] -> [관찰 사실 기록]\n", + " -> [위기 징후?] --예--> [즉시 분대장·상담관 보고]\n", + " -> 아니오 -> [분대장 보고(3분 이내)] -> [현장 확인(급식·보급)]\n", + " -> [상담관 연계 요청: ( )] -> [결과 안내] -> [후속 확인 일정 기록] |\n", + "| --- |\n", + "\n", + "[작성 서식 4] 교차 검토표\n", + "\n", + "| **번호** | **지적 위치** | **누락/모호 내용** | **수정안** | **반영 확인** |\n", + "| --- | --- | --- | --- | --- |\n", + "| **1** | | | | |\n", + "| **2** | | | | |\n", + "| **3** | | | | |\n", + "| **4** | | | | |\n", + "| **5** | | | | |\n", + "| **6** | | | | |\n", + "\n", + "[작성 서식 5] 휴대 요약 카드(30초 안에 읽을 분량)\n", + "\n", + "| **구분** | **내용(한 줄씩)** |\n", + "| --- | --- |\n", + "| **관찰 3가지** | ( ) |\n", + "| **경청 3가지** | ( ) |\n", + "| **보고 순서** | ( ) |\n", + "| **연계 필수** | ( ) |\n", + "| **하지 말 것** | ( ) |\n", + "\n", + "| **점검 항목** | **확인** |\n", + "| --- | --- |\n", + "| 설문·건의함·익명 상담 3종을 신호 체크 항목에 반영했다 | ( ) |\n", + "| 변환 연습 5건 중 4건 이상이 관찰 사실 문장이다 | ( ) |\n", + "| SOP 전 단계에 행동 동사와 안전·보안 조치가 있다(100%) | ( ) |\n", + "| 결정권자(상담관)를 표시했다 | ( ) |\n", + "| 교차 검토 지적을 모두 수정하고 휴대 요약 카드를 한 장으로 만들었다 | ( ) |\n", + "\n", + "**\n", + "\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 03. 급식 정량 미달, 피복 사이즈 미지급 등 초기 불만 징후 탐지 훈련\n", + "\n", + "### 📋 미션 배경 및 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "급식 정량 미달, 피복 사이즈 미지급 등 초기 불만 징후 탐지 훈련을 진행한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **급식 정량 미달, 피복 사이즈 미지급 등 초기 불만 징후를 탐지하는 훈련 시나리오(MSEL)를 만들고 정상 불만과 주의가 필요한 징후를 구분하는 연습을 한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **실습자료의 가상 접수 사례와 돌발 변수 카드를 이용해 시간대별 주입 사건을 10건 이상 설계하고 정상(단건 불만, 정상 처리 가능) 6 : 비정상(반복·은폐·신호 누적) 4 비율을 맞춘다 과정을 진행하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 5개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `scenarios/wel08_msel_scenario.json(md)` 형태의 결과물을 완성한다.\n", + "\n", + "### 📑 실습자료 및 작성 양식\n", + "급식 정량 미달, 피복 사이즈 미지급 등 초기 불만 징후 탐지 훈련**\n", + "\n", + "[제공 자료 1] 초기 징후 접수 사례 8건 [교육용가상자료]\n", + "\n", + "| **번호** | **사례(요약)** | **성격** |\n", + "| --- | --- | --- |\n", + "| **C1** | 한 명이 오늘 배식량이 적다고 말함(처음) | 단건 |\n", + "| **C2** | 배식량이 적다는 건의가 5일째 접수, 답변 기록 없음 | 반복 |\n", + "| **C3** | 피복 사이즈가 맞지 않아 교환 방법을 문의함 | 단건 |\n", + "| **C4** | 피복 사이즈 미지급을 말한 신병이 이후 훈련 중 위축되어 보임 | 신호 동반 |\n", + "| **C5** | 식사를 남긴 신병이 이유를 묻자 '문제없다'고만 대답함 | 은폐 가능 |\n", + "| **C6** | 동료가 3일째 식사를 거르고 잠을 못 잔다고 전함 | 신호 누적 |\n", + "| **C7** | 간식 미지급을 설문에 적음 | 단건 |\n", + "| **C8** | 건의함에 적은 내용이 보고서에 빠져 있음 | 보고 누락 |\n", + "\n", + "[작성 서식 1] 초기 징후 판별표\n", + "\n", + "| **사례** | **정상 불만 / 주의 필요** | **근거(반복·기간·동반 신호)** | **기대 행동(확인·기록·보고·연계)** |\n", + "| --- | --- | --- | --- |\n", + "| **C1** | | | |\n", + "| **C2** | | | |\n", + "| **C3** | | | |\n", + "| **C4** | | | |\n", + "| **C5** | | | |\n", + "| **C6** | | | |\n", + "| **C7** | | | |\n", + "| **C8** | | | |\n", + "\n", + "[제공 자료 2] MSEL 작성 예시(3건) - 나머지는 조별로 작성한다.\n", + "\n", + "| **번호** | **투입 시각** | **주입 사건** | **구분** | **대상 역할** | **기대 행동** |\n", + "| --- | --- | --- | --- | --- | --- |\n", + "| **1** | +00:00 | C1 단건 배식량 불만 접수 | 정상 | 급양·보급 담당 | 배식량 확인, 기록, 조치 안내 |\n", + "| **2** | +04:00 | C5 '문제없다'고 하는 신병 면담 | 비정상 | 발견자·기록자 | 관찰 사실 기록, 분대장 보고, 후속 확인 일정 |\n", + "| **3** | +08:00 | C6 동료 걱정 전달 | 비정상 | 발견자 | 경청, 상담관 연계 요청 |\n", + "\n", + "[작성 서식 2] MSEL 시나리오표(10건 이상, 정상 6 : 비정상 4) - 급식·보급 확인형 2건 이상, 상담 연계형 2건 이상 포함\n", + "\n", + "| **번호** | **투입 시각** | **주입 사건** | **구분** | **대상 역할** | **기대 행동** |\n", + "| --- | --- | --- | --- | --- | --- |\n", + "| **1** | | | | | |\n", + "| **2** | | | | | |\n", + "| **3** | | | | | |\n", + "| **4** | | | | | |\n", + "| **5** | | | | | |\n", + "| **6** | | | | | |\n", + "| **7** | | | | | |\n", + "| **8** | | | | | |\n", + "| **9** | | | | | |\n", + "| **10** | | | | | |\n", + "\n", + "[제공 자료 3] 돌발 변수 카드(2개 이상 선택): 건의함 내용 누락 / 분대장 부재 / 면담 요청 철회 / 배식 대기 증가 / 상담관 일정 지연 / 소문 확산 / 기록자 교체\n", + "\n", + "[작성 서식 3] 워크스루 점검표\n", + "\n", + "| **사건 번호** | **흐름 어색함** | **기대 행동 불분명** | **대상 역할 없음** | **수정 내용** |\n", + "| --- | --- | --- | --- | --- |\n", + "| **1** | | | | |\n", + "| **2** | | | | |\n", + "| **3** | | | | |\n", + "| **4** | | | | |\n", + "| **5** | | | | |\n", + "\n", + "| **점검 항목** | **확인** |\n", + "| --- | --- |\n", + "| 8개 사례를 판별표에 모두 판별했다 | ( ) |\n", + "| 주입 사건을 10건 이상 작성했다 | ( ) |\n", + "| 정상:비정상 비율이 6:4이다 | ( ) |\n", + "| 확인형·연계형 사건이 각각 2건 이상이다 | ( ) |\n", + "| 모든 사건에 시각·역할·기대 행동이 있고 워크스루 지적을 반영했다 | ( ) |\n", + "\n", + "**\n", + "\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 04. 신병 면담 및 급식/보급 현장 확인 1차 모의훈련(Dry-run)\n", + "\n", + "### 📋 미션 배경 및 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "신병 면담 및 급식/보급 현장 확인 1차 모의훈련(Dry-run)을 수행한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **역할극으로 가상 신병 면담과 급식·보급 현장 확인을 수행하는 1차 모의훈련(Dry-run)을 실시한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **발견자 역할이 당사자 역할 카드(가상 신병 가·나·다 중 1명)와 면담하며 경청과 사실 확인 질문을 하고 기록자가 면담 기록 설계하고 작성하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 5개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `logs/wel08_dryrun_execution_log.md 및 작성된 체크리스트` 형태의 결과물을 완성한다.\n", + "\n", + "### 📑 실습자료 및 작성 양식\n", + "신병 면담 및 급식/보급 현장 확인 1차 모의훈련(Dry-run)**\n", + "\n", + "| [역할극 규칙] 당사자 역할은 카드 내용만 연기한다. 실제 개인 경험을 쓰지 않고 중단하고 싶으면 '멈춤'이라고 말한다. 발견자 역할은 진단하거나 비밀 보장을 약속하지 않는다. 종료 후 3분간 역할에서 빠져나오는 마무리(이름 부르기, 현재 시각·장소 말하기)를 한다. |\n", + "| --- |\n", + "\n", + "[제공 자료 1] 당사자 역할 카드 3종 [교육용가상자료]\n", + "\n", + "| **항목** | **카드 가: 가상 신병 가** | **카드 나: 가상 신병 나** | **카드 다: 가상 신병 다** |\n", + "| --- | --- | --- | --- |\n", + "| **배경** | 배속 2주차, 배식 끝 순번이라 반찬이 모자란 날이 많음 | 피복(군화) 사이즈가 맞지 않아 한 달째 교환 못 함 | 식사를 자주 남기고 잠을 못 잠, 동료가 걱정을 전함 |\n", + "| **처음 하는 말** | '괜찮습니다. 조금 배고플 뿐입니다.' | '아닙니다. 참을 수 있습니다.' | '아무 문제 없습니다.' |\n", + "| **질문받으면(공개 가능 사실)** | 3일째 저녁을 반만 먹었다, 잠이 줄었다 | 발이 아파 훈련이 힘들다, 교환 방법을 모른다 | 며칠째 잠이 안 오고 입맛이 없다고 조용히 말함 |\n", + "| **숨기는 것** | 건의함에 적었는데 답이 없어 포기함 | 말하면 불평으로 보일까 걱정 | 무슨 말을 해야 할지 모름, 위축됨 |\n", + "| **신호 강도(교관 설정)** | 낮음 | 낮음~중간 | 중간(위기 징후 없음) |\n", + "| **연기 지침** | 목소리 낮게, 눈을 피함 | 짧게 답함 | 대답이 느리고 말끝이 흐려짐 |\n", + "\n", + "[제공 자료 2] 발견자 역할 카드: 목표는 경청, 사실 확인, 기록, 보고, 연계 요청이다. 하지 말 것: 진단하기, 원인을 단정하기, 비밀 보장을 무조건 약속하기, 훈계하기, 소문 내기.\n", + "\n", + "[작성 서식 1] 면담 기록지(기록자 작성) - 필수 기재란 7개(필): 면담 일시, 가명, 접수 경로, 관찰 사실, 확인한 사실(급식·보급), 보고·연계 내용, 기록자 서명\n", + "\n", + "| **구분** | **기재란** |\n", + "| --- | --- |\n", + "| **면담 일시 / 장소 (필)** | ( : ) / ( ) |\n", + "| **가명 (필)** | ( ) |\n", + "| **접수 경로 (필)** | 건의함 · 설문 · 구두 · 동료 전달 |\n", + "| **관찰 사실 (필)** | (횟수·기간·시각으로 적는다) |\n", + "| **당사자가 한 말(그대로)** | ( ) |\n", + "| **확인한 사실 - 급식·보급 (필)** | ( ) |\n", + "| **보고 대상·시각 (필)** | ( ) / ( : ) |\n", + "| **상담관 연계 요청 여부** | 예 · 아니오 · 위기 징후로 즉시 보고 |\n", + "| **후속 확인 일정** | ( ) |\n", + "| **기록자 / 서명 (필)** | ( ) / ( ) |\n", + "\n", + "[작성 서식 2] 급식·보급 현장 확인 점검표(발견자·급양·보급 담당)\n", + "\n", + "| **확인 항목** | **확인 방법** | **결과(사실)** | **조치 필요(O/X)** |\n", + "| --- | --- | --- | --- |\n", + "| **배식량(끝 순번 포함)** | | | |\n", + "| **식사 시간** | | | |\n", + "| **피복 지급·교환 기록** | | | |\n", + "| **보급 지연 여부** | | | |\n", + "\n", + "측정 기준: 보고 지연시간 = 면담 종료 시각부터 분대장 역할 앞 보고 완료(복창 확인) 시각까지. SOP 준수율 = 순서대로 수행한 단계 수 / 전체 단계 수 x 100.\n", + "\n", + "[작성 서식 3] Dry-run 수행 기록지(관측자 작성)\n", + "\n", + "| **SOP 단계** | **계획 순서** | **실제 시각** | **순서 준수(O/X)** | **비고(혼선·지연)** |\n", + "| --- | --- | --- | --- | --- |\n", + "| **접근·비밀 유지 한계 안내** | 1 | | | |\n", + "| **경청·사실 확인 질문** | 2 | | | |\n", + "| **요약 복창** | 3 | | | |\n", + "| **위기 징후 확인** | 4 | | | |\n", + "| **면담 기록 작성** | 5 | | | |\n", + "| **급식·보급 현장 확인** | 6 | | | |\n", + "| **분대장 보고 완료** | 7 | | | |\n", + "| **상담관 연계 요청** | 8 | | | |\n", + "| **당사자에게 결과 안내** | 9 | | | |\n", + "| **후속 확인 일정 기록** | 10 | | | |\n", + "\n", + "| **지표** | **계산** | **결과** | **목표** | **달성** |\n", + "| --- | --- | --- | --- | --- |\n", + "| **SOP 단계 준수율** | ( )/10 x 100 | ( )% | 80% 이상 | ( ) |\n", + "| **보고 지연시간** | ( : ) - ( : ) | ( )분 ( )초 | 3분 이내 | ( ) |\n", + "| **필수 기재란 누락** | 7개 중 누락 수 | ( )건 | 0건 | ( ) |\n", + "| **단정·진단 표현** | 관측자 확인 | ( )건 | 0건 | ( ) |\n", + "\n", + "[작성 서식 4] 병목·혼선 구간 기록표\n", + "\n", + "| **번호** | **발생 구간(SOP 단계)** | **현상** | **관련 역할** | **개선 아이디어** |\n", + "| --- | --- | --- | --- | --- |\n", + "| **1** | | | | |\n", + "| **2** | | | | |\n", + "| **3** | | | | |\n", + "\n", + "| **점검 항목** | **확인** |\n", + "| --- | --- |\n", + "| 역할극 규칙을 확인하고 시작했으며 종료 후 역할을 해제했다 | ( ) |\n", + "| SOP 단계 준수율 80% 이상이다 | ( ) |\n", + "| 보고 지연시간이 3분 이내이다 | ( ) |\n", + "| 면담 기록 필수 기재란 7개에 누락이 없다 | ( ) |\n", + "| 단정·진단 표현이 0건이다 | ( ) |\n", + "| 병목·혼선 구간을 2개 이상 기록했다 | ( ) |\n", + "\n", + "**\n", + "\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 05. 불만 은폐 및 지휘관 보고 누락 상황 대응 조치 실습\n", + "\n", + "### 📋 미션 배경 및 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "불만 은폐 및 지휘관 보고 누락 상황 대응 조치를 실습한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **불만을 숨기거나 지휘관 보고가 빠지는 상황을 면담 기록, 건의함, 보고서의 대조로 찾아내고 교차확인 후 보고하는 훈련을 한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **교관이 오류를 섞어 둔 가상 면담 기록, 건의함, 설문 요약, 급식·보급 점검 결과, 보고서의 12개 대조 항목을 받아 문서끼리 비교 수행하고 검증하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 5개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `docs/wel08_cross_verification_sheet.md` 형태의 결과물을 완성한다.\n", + "\n", + "### 📑 실습자료 및 작성 양식\n", + "불만 은폐 및 지휘관 보고 누락 상황 대응 조치 실습**\n", + "\n", + "[제공 자료] 대조 항목 12개 [교육용가상자료] - 면담 기록, 건의함, 설문, 급식·보급 점검, 보고서를 서로 비교해 오류인지 정상인지 판정한다. 특정인을 추궁하지 않고 문서 간 일치 여부만 본다.\n", + "\n", + "| **번호** | **확인 항목** | **기록 A** | **기록 B** |\n", + "| --- | --- | --- | --- |\n", + "| **5-1** | 건의 건수 | 건의함: 배식량 건의 5건(5일 연속) | 보고서: '불만 접수 없음' |\n", + "| **5-2** | 연계 기록 | 면담 기록: 수면이 안 온다고 호소, 연계 필요 표시 | 조치 기록: 상담관 연계 기록 없음 |\n", + "| **5-3** | 보고 내용 | 면담 기록: 3일째 식사를 거른다고 말함 | 분대장 보고: '이상 없음' |\n", + "| **5-4** | 표현 | 면담 기록: '우울증으로 보임' | 관찰 사실 기재란은 비어 있음 |\n", + "| **5-5** | 개인정보 | 보고서 첨부: 신병 실명과 부모 연락처 기재 | 규정: 가명·번호 사용 |\n", + "| **5-6** | 급식 점검 | 점검 결과: 배식량 기준 범위 내, 접수 불만 1건(단건) | 조치 기록: 단건 확인·안내 완료 |\n", + "| **5-7** | 보급 조치 | 보고서: 피복 사이즈 미지급 3명 '해결' | 보급 대장: 3명 중 1명만 수령 서명 |\n", + "| **5-8** | 설문 수치 | 설문 요약: 응답 20명 | 보고서: 응답 25명 기재 |\n", + "| **5-9** | 보고·연계 | 면담 기록·분대장 보고·상담관 연계 기록이 시각과 내용 일치 | 후속 확인 일정 기재 있음 |\n", + "| **5-10** | 익명 상담 | 상담 요약: 비식별 5건, 개인 특정 정보 없음 | 보고서 인용: 건수만 인용 |\n", + "| **5-11** | 위기 징후 보고 | 면담 기록: 위기 징후 표현 기록, 시각 D일 14:20 | 보고 기록: 지휘 계통 보고 D+2일 09:00 |\n", + "| **5-12** | 후속 확인 | 후속 확인 일정: D+3일 면담 계획 기재 | D+3일 면담 기록 있음, 내용 일치 |\n", + "\n", + "[작성 서식 1] 교차검증 매트릭스\n", + "\n", + "| **번호** | **판정(오류/정상)** | **대조한 문서** | **판정 근거** | **보고 대상·우선순위** |\n", + "| --- | --- | --- | --- | --- |\n", + "| **5-1** | | | | |\n", + "| **5-2** | | | | |\n", + "| **5-3** | | | | |\n", + "| **5-4** | | | | |\n", + "| **5-5** | | | | |\n", + "| **5-6** | | | | |\n", + "| **5-7** | | | | |\n", + "| **5-8** | | | | |\n", + "| **5-9** | | | | |\n", + "| **5-10** | | | | |\n", + "| **5-11** | | | | |\n", + "| **5-12** | | | | |\n", + "\n", + "| **지표** | **계산** | **결과** | **목표** |\n", + "| --- | --- | --- | --- |\n", + "| **식별 성공률** | 맞게 찾은 오류 수 ( ) / 주입 오류 8 x 100 | ( )% | 85% 이상(7건 이상) |\n", + "| **오표시** | 정상 4건 중 오류로 표시한 수 | ( )건 | 1건 이하 |\n", + "\n", + "[작성 서식 2] 불일치 보고 경로\n", + "\n", + "| **우선순위** | **불일치 유형** | **보고 대상** | **보고 시한** | **보고 문장(SBAR 한 줄)** |\n", + "| --- | --- | --- | --- | --- |\n", + "| **1(즉시)** | 위기 징후 기록의 보고 지연·누락 | | | |\n", + "| **2** | 신호·연계 필요 기록의 보고서 누락, 건의 누락 | | | |\n", + "| **3** | 표현·개인정보·수치 불일치 | | | |\n", + "\n", + "| [교관용 정답표 - 조별 판정 후 공개] 오류 8건: 5-1(건의 누락), 5-2(연계 누락), 5-3(보고 내용 불일치), 5-4(진단 표현), 5-5(개인정보 노출), 5-7(보급 조치와 대장 불일치), 5-8(설문 수치 불일치), 5-11(위기 징후 보고 지연, 즉시 보고 대상). 정상 4건: 5-6, 5-9, 5-10, 5-12. 문서 오류는 개인을 추궁하지 않고 절차 개선 사항으로 다룬다. |\n", + "| --- |\n", + "\n", + "| **점검 항목** | **확인** |\n", + "| --- | --- |\n", + "| 12개 항목 모두 판정하고 근거와 대조 문서를 적었다 | ( ) |\n", + "| 주입 오류 8건 중 7건 이상을 찾았다(85% 이상) | ( ) |\n", + "| 정상 항목 오표시가 1건 이하이다 | ( ) |\n", + "| 특정인을 추궁하지 않고 문서 간 불일치만 판정했다 | ( ) |\n", + "| 위기 징후 불일치를 최우선 보고로 정했다 | ( ) |\n", + "\n", + "**\n", + "\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 06. 대대장/중대장 앞 급식·보급 개선 조치 계획 보고 및 인계 실습\n", + "\n", + "### 📋 미션 배경 및 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "지휘관 앞 급식·보급 개선 조치 계획 보고 및 인계를 실습한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **중대장과 대대장 역할 앞에서 급식·보급 개선 조치 계획을 보고하고 상담 연계 현황과 함께 인계하는 절차를 실습한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **불만 접수 현황, 확인된 사실, 개선 조치안(담당·기한), 상담 연계 현황(비식별 통계 수준), 후속 확인 일정을 조치 계획 보고 서식에 정리해 지휘관 역할에게 구두로 보고 수행하고 검증하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 5개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `reports/wel08_handover_protocol.md 및 브리핑 녹취/기록지` 형태의 결과물을 완성한다.\n", + "\n", + "### 📑 실습자료 및 작성 양식\n", + "대대장/중대장 앞 급식·보급 개선 조치 계획 보고 및 인계 실습**\n", + "\n", + "[제공 자료 1] 가상 개선 조치 요약 예시 [교육용가상자료] - 미션 4·5 결과가 있으면 그것을 사용한다.\n", + "\n", + "| **항목** | **내용(가상)** |\n", + "| --- | --- |\n", + "| **확인된 사실** | 배식량 건의 5일 연속 접수, 끝 순번 배식량 모자람 3일 확인 |\n", + "| **개선 조치안** | 배식 순번 교대, 마지막 순번 잔량 점검(담당: 급양 담당, 기한: 3일) |\n", + "| **피복 조치** | 사이즈 교환 신청 안내문 게시, 미수령 2명 교환(담당: 보급 담당, 기한: 1주) |\n", + "| **상담 연계 현황** | 비식별 통계: 연계 요청 2건, 상담관 면담 완료 1건 |\n", + "| **후속 확인** | D+7일 재점검 |\n", + "\n", + "[작성 서식 1] 조치 계획 보고 서식(중대장·대대장 역할 앞 구두 보고, 개인 식별 정보 제외)\n", + "\n", + "| **항목** | **내용** |\n", + "| --- | --- |\n", + "| **보고 시각·보고자** | ( ) |\n", + "| **불만 접수 현황(유형별 건수)** | ( ) |\n", + "| **확인된 사실** | ( ) |\n", + "| **개선 조치안(담당·기한)** | ( ) |\n", + "| **상담 연계 현황(비식별 통계)** | ( ) |\n", + "| **후속 확인 일정** | ( ) |\n", + "| **결정·승인이 필요한 사항** | ( ) |\n", + "\n", + "[작성 서식 2] 인계 품질 체크리스트(인계자·인수자 공동 확인)\n", + "\n", + "| **번호** | **필수 항목** | **인계자** | **인수자** |\n", + "| --- | --- | --- | --- |\n", + "| **1** | 불만 접수 현황(유형·건수) | | |\n", + "| **2** | 확인된 사실과 확인 방법 | | |\n", + "| **3** | 개선 조치안과 담당·기한 | | |\n", + "| **4** | 상담 연계 현황(비식별) | | |\n", + "| **5** | 위기 징후 보고 여부와 처리 경로 | | |\n", + "| **6** | 아직 확인 중인 사항 | | |\n", + "| **7** | 후속 확인 일정 | | |\n", + "| **8** | 개인 식별 정보 제외 확인 | | |\n", + "| **9** | 소통 창구 안내 방법 | | |\n", + "| **10** | 결정이 필요한 사항 | | |\n", + "\n", + "| **구분** | **기재** |\n", + "| --- | --- |\n", + "| **인계 시작 / 종료 시각** | ( : ) / ( : ) 소요 ( )분 ( )초 / 목표 10분 이내 |\n", + "| **누락 항목 수** | ( )개 / 10개 누락률 ( )% |\n", + "| **개인 식별 정보 노출** | ( )건 / 목표 0건 |\n", + "| **인계자 서명 / 인수자 서명** | ( ) / ( ) |\n", + "\n", + "[제공 자료 2] 인수자 돌발 질문 카드(평가관이 3개 선택): Q1 가장 많은 불만 유형은? / Q2 개선 조치의 기한은 언제인가? / Q3 위기 징후가 있으면 누구에게 먼저 보고하는가? / Q4 상담 연계 내용은 어디까지 보고에 포함하는가? / Q5 후속 확인은 언제인가?\n", + "\n", + "[작성 서식 3] SBAR 보고 템플릿\n", + "\n", + "| **구분** | **내용** |\n", + "| --- | --- |\n", + "| **S 상황(Situation)** | ( ) |\n", + "| **B 배경(Background)** | ( ) |\n", + "| **A 평가(Assessment)** | ( ) |\n", + "| **R 요청(Recommendation)** | ( ) |\n", + "\n", + "| **점검 항목** | **확인** |\n", + "| --- | --- |\n", + "| 조치 계획 보고 서식 7개 항목을 모두 채웠다 | ( ) |\n", + "| 인계 필수 항목 10개 누락 0건(누락률 0%)이다 | ( ) |\n", + "| 인계 소요가 10분 이내이다 | ( ) |\n", + "| 돌발 질문 3개에 인수자가 모두 정답을 말했다 | ( ) |\n", + "| 개인 식별 정보 노출이 0건이다 | ( ) |\n", + "| 인계자·인수자 상호 서명을 마쳤다 | ( ) |\n", + "\n", + "**\n", + "\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 07. 대규모 급식 사고 및 보급 지연 복합 발생 모의 스트레스 훈련\n", + "\n", + "### 📋 미션 배경 및 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "대규모 급식 사고 모의 스트레스 훈련을 진행한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **급식 혼선과 보급 지연이 겹치는 복합 상황에서도 동료의 급성 스트레스 반응을 놓치지 않고 보고·연계 절차를 유지하는 스트레스 훈련을 한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **안전통제관 통제 아래 사건 카드 6건(배식 지연과 급식 혼선, 보급 지연 통보, 식사를 거부하는 신병 증가 소문, 동료의 급성 불안 반응 역할극, 책임자 이탈, 통신 두절)을 순서대로 주입하여 처리 지연 및 복원력을 시험하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 5개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `logs/wel08_stress_drill_results.md` 형태의 결과물을 완성한다.\n", + "\n", + "### 📑 실습자료 및 작성 양식\n", + "대규모 급식 사고 및 보급 지연 복합 발생 모의 스트레스 훈련**\n", + "\n", + "| [안전 통제] 본 훈련은 실내 모의 훈련이다. 사건 7-4의 급성 불안 반응 역할극은 교관이 정한 인원이 카드 내용만 연기하며 실제 증상을 모방하거나 과장하지 않는다. 실제로 힘들어하는 교육생이 있으면 즉시 중지하고 교관이 별도 면담과 상담관 연계를 한다. 급식 사고 상황의 의료·위생 판단은 학습자가 하지 않고 의무대·군의관 연계 보고만 한다. 안전통제관의 '중지' 구호에 즉시 멈춘다. |\n", + "| --- |\n", + "\n", + "[제공 자료] 스트레스 사건 카드 6건 [교육용가상자료]\n", + "\n", + "| **번호** | **주입 사건** | **환경** | **기대 행동** | **위험 조치(판정 대상)** |\n", + "| --- | --- | --- | --- | --- |\n", + "| **7-1** | 배식 지연과 혼선(모의), 대기 줄 증가 | 시간 압박 | 급양 담당 확인 요청, 인원 안내, 보고 | 상황 기록, 보고 |\n", + "| **7-2** | 보급 지연 통보, 불만 접수 몰림 | 시간 압박 | 접수 순서 정리, 반복 건 우선 확인 | 접수 기록 보호, 보고 |\n", + "| **7-3** | 식사를 거부하는 신병이 늘고 있다는 소문 | 소음 | 소문을 확산하지 않고 사실 확인 후 보고 | 사실 확인, 보고 |\n", + "| **7-4** | 동료의 급성 불안 반응 역할극(떨림, '지금은 못 하겠다'는 호소 - 카드 내용만) | 소음 | 안전 확인, 경청, 즉시 분대장·상담관 보고·연계 요청 | 혼자 두지 않기, 즉시 보고 |\n", + "| **7-5** | 책임자(분대장 역할) 이탈(지휘 공백) | 시간 압박 | 위임 순서에 따라 차상위자 임무 인수 선언 | 인원 확인, 보고 |\n", + "| **7-6** | 통신 두절 | 소음 | 대체 보고 수단 사용, 두절 시각 기록 | 기록 보호, 두절 시각 기록 |\n", + "\n", + "[작성 서식 1] 결과 기록표(관측자 작성) - 전환 시간 = 주입 시각부터 비상 절차 전환(또는 임무 인수·보고) 선언 시각까지\n", + "\n", + "| **사건** | **주입 시각** | **선언 시각** | **전환 시간** | **위험 조치1** | **위험 조치2** | **안전 위반** |\n", + "| --- | --- | --- | --- | --- | --- | --- |\n", + "| **7-1** | | | | O / X | O / X | 유 / 무 |\n", + "| **7-2** | | | | O / X | O / X | 유 / 무 |\n", + "| **7-3** | | | | O / X | O / X | 유 / 무 |\n", + "| **7-4** | | | | O / X | O / X | 유 / 무 |\n", + "| **7-5** | | | | O / X | O / X | 유 / 무 |\n", + "| **7-6** | | | | O / X | O / X | 유 / 무 |\n", + "\n", + "| **지표** | **계산** | **결과** | **목표** |\n", + "| --- | --- | --- | --- |\n", + "| **최대 전환 시간** | 6건 중 가장 긴 시간 | ( )초 | 60초 이내 |\n", + "| **위험 조치 성공률** | 성공 수 ( ) / 12 x 100 | ( )% | 90% 이상 |\n", + "| **안전 수칙 위반** | 위반 건수 | ( )건 | 0건 |\n", + "| **급성 반응 사건 보고 시간** | 7-4 전환 시간 | ( )초 | 60초 이내 |\n", + "\n", + "[작성 서식 2] 임무 위임 순서표\n", + "\n", + "| **순서** | **직책(역할)** | **인수할 임무** | **인수 선언 문장** |\n", + "| --- | --- | --- | --- |\n", + "| **1** | 분대장 역할 | | |\n", + "| **2** | | | |\n", + "| **3** | | | |\n", + "\n", + "| **점검 항목** | **확인** |\n", + "| --- | --- |\n", + "| 훈련 규칙, 중지 신호, 역할극 중단 규칙을 확인한 뒤 시작했다 | ( ) |\n", + "| 사건 6건 모두 주입·선언 시각을 기록했다 | ( ) |\n", + "| 모든 전환 시간이 60초 이내이다 | ( ) |\n", + "| 위험 조치 성공률이 90% 이상이다 | ( ) |\n", + "| 안전 수칙 위반이 0건이다 | ( ) |\n", + "| 급성 반응 사건에서 안전 확인 후 1분 안에 보고했고 종료 후 역할을 해제했다 | ( ) |\n", + "\n", + "**\n", + "\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 08. AAR을 통한 초기 신호 묵살 사례 분석 및 소통 창구 개선점 도출\n", + "\n", + "### 📋 미션 배경 및 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "AAR을 통해 초기 신호 묵살 사례를 분석하고 소통 창구를 개선한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **AAR로 초기 신호가 묵살된 사례를 사실 중심으로 분석하고 소통 창구의 개선점을 도출한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **Dry-run·스트레스 훈련의 관측 기록, 면담 기록, 참여자 진술을 시간순으로 맞춰 신호가 놓친 지점을 찾는다 과정을 진행하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 5개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `aar/wel08_aar_session_minutes.md 및 오류 분석 매트릭스` 형태의 결과물을 완성한다.\n", + "\n", + "### 📑 실습자료 및 작성 양식\n", + "AAR을 통한 초기 신호 묵살 사례 분석 및 소통 창구 개선점 도출**\n", + "\n", + "[제공 자료 1] AAR 4단계 진행표\n", + "\n", + "| **단계** | **질문** | **진행 규칙** |\n", + "| --- | --- | --- |\n", + "| **1** | 무엇을 계획했나? | SOP·MSEL 기준을 먼저 확인한다 |\n", + "| **2** | 실제로 무엇이 일어났나? | 기록(시각)을 기준으로 말하고, 진술은 기록과 대조한다 |\n", + "| **3** | 왜 차이가 났나? | 사람이 아닌 절차·양식·환경·숙달 원인으로 분류한다 |\n", + "| **4** | 무엇을 유지·개선할까? | 개선점마다 담당·기한·근거를 붙인다 |\n", + "\n", + "[제공 자료 2] 가상 1차 훈련 타임라인 발췌 [교육용가상자료] - 자기 조 기록과 함께 사용한다.\n", + "\n", + "| **시각** | **관측 기록** | **참여자 진술** |\n", + "| --- | --- | --- |\n", + "| **+02:00** | 면담 중 '식사를 3일째 거른다'는 발언 | 발견자: 들었지만 중요하게 생각하지 않음 |\n", + "| **+06:30** | 면담 기록 관찰 사실란이 '우울해 보임' | 기록자: 느낀 대로 적음 |\n", + "| **+10:00** | 면담 종료 | 발견자: 분대장이 바빠 보여 나중에 보고하려 했음 |\n", + "| **+14:10** | 분대장 보고 완료(지연 4분 10초) | 발견자: 보고 문장을 정리하느라 늦음 |\n", + "| **+20:00** | 인계 시 건의함 반복 접수 사실 누락 | 인수자: 보고 서식에 해당 칸이 없음 |\n", + "\n", + "[작성 서식 1] 기록-진술 불일치 확인표\n", + "\n", + "| **번호** | **시각** | **기록 내용** | **진술 내용** | **확인 결과(사실)** |\n", + "| --- | --- | --- | --- | --- |\n", + "| **1** | | | | |\n", + "| **2** | | | | |\n", + "| **3** | | | | |\n", + "| **4** | | | | |\n", + "\n", + "[작성 서식 2] 오류 분석 매트릭스\n", + "\n", + "| **번호** | **문제(현상)** | **사실 근거(기록 위치)** | **원인 분류** | **개선점** | **담당·기한** |\n", + "| --- | --- | --- | --- | --- | --- |\n", + "| **1** | | | | | |\n", + "| **2** | | | | | |\n", + "| **3** | | | | | |\n", + "| **4** | | | | | |\n", + "| **5** | | | | | |\n", + "| **6** | | | | | |\n", + "\n", + "원인 분류: 절차(보고·연계 순서) / 양식(칸 구성·기재 방법) / 환경(면담 장소·소음·시간) / 숙달(경청·기록 연습 부족) / 소통(건의 창구·답변 체계)\n", + "\n", + "[작성 서식 3] 1차 SOP 수정안\n", + "\n", + "| **수정 대상** | **수정 전** | **수정 후** | **근거(분석 번호)** |\n", + "| --- | --- | --- | --- |\n", + "| **체크리스트** | | | |\n", + "| **기록 양식** | | | |\n", + "| **인계·보고 템플릿** | | | |\n", + "| **기타** | | | |\n", + "\n", + "[작성 서식 4] 소통 창구 개선표 - 건의함, 면담 신청, 익명 상담 등 창구별 개선점을 적는다.\n", + "\n", + "| **소통 창구** | **현재 문제(기록 근거)** | **개선안** | **담당·기한** |\n", + "| --- | --- | --- | --- |\n", + "| **건의함** | | | |\n", + "| **동료·분대장 면담** | | | |\n", + "| **익명 상담·설문** | | | |\n", + "| **기타** | | | |\n", + "\n", + "| **점검 항목** | **확인** |\n", + "| --- | --- |\n", + "| AAR 4단계를 순서대로 진행했다 | ( ) |\n", + "| 기록과 진술이 다른 부분을 사실로 확인했다 | ( ) |\n", + "| 개선점을 5건 이상 도출했다 | ( ) |\n", + "| 모든 원인 항목에 근거 기록 위치를 적었다(100%) | ( ) |\n", + "| 회의록에 개인 비난 표현이 없다 | ( ) |\n", + "| SOP 수정안을 작성했다 | ( ) |\n", + "\n", + "**\n", + "\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 09. 피드백 반영 신병 급식·보급 불만 조기 조치 시나리오 재실행\n", + "\n", + "### 📋 미션 배경 및 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "피드백을 반영하여 신병 급식·보급 불만 조기 조치 시나리오를 재실행한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **AAR 수정안을 반영해 급식·보급 불만 조기 조치 시나리오를 재실행하고 시간과 오류의 개선을 정량 비교한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **1차와 같은 역할극 카드와 평가표로 수정된 체크리스트·면담 기록 양식·보고 템플릿을 적용해 재실행 수행하고 검증하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 5개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `eval/wel08_rerun_performance_comparison.md` 형태의 결과물을 완성한다.\n", + "\n", + "### 📑 실습자료 및 작성 양식\n", + "피드백 반영 신병 급식·보급 불만 조기 조치 시나리오 재실행**\n", + "\n", + "[제공 자료] 가상 1차 결과 예시 [교육용가상자료] - 자기 조 1차 기록이 없으면 이 값을 기준으로 쓴다. 측정 기준은 미션 4·6과 같다.\n", + "\n", + "| **지표** | **1차(가상 예시)** | **목표(2차)** |\n", + "| --- | --- | --- |\n", + "| **보고 지연시간** | 4분 00초 | 2분 48초 이하(30% 단축) |\n", + "| **인계 소요시간** | 12분 00초 | 8분 24초 이하(30% 단축) |\n", + "| **면담 기록 오류** | 6건 | 3건 이하(50% 감소) |\n", + "| **인계 누락 항목** | 4개 | 2개 이하(50% 감소) |\n", + "\n", + "개선율 계산: 시간 단축률 = (1차 - 2차) / 1차 x 100, 오류 감소율 = (1차 - 2차) / 1차 x 100\n", + "\n", + "[작성 서식 1] 전후 성과 비교표\n", + "\n", + "| **지표** | **1차** | **2차** | **개선율** | **목표** | **달성** |\n", + "| --- | --- | --- | --- | --- | --- |\n", + "| **보고 지연시간** | | | | 30% 단축 | |\n", + "| **인계 소요시간** | | | | 30% 단축 | |\n", + "| **면담 기록 오류** | | | | 50% 감소 | |\n", + "| **인계 누락 항목** | | | | 50% 감소 | |\n", + "| **SOP 단계 준수율** | | | | 80% 이상 유지 | |\n", + "\n", + "[작성 서식 2] 미달 지표 원인과 보수 교육 제안\n", + "\n", + "| **구분** | **내용** |\n", + "| --- | --- |\n", + "| **미달 지표·원인** | ( ) |\n", + "| **적용한 수정안 효과** | ( ) |\n", + "| **보수 교육 주기·대상** | 예: 분기 1회, 신규 전입자 전원( ) |\n", + "| **다음 재평가 일정** | ( ) |\n", + "\n", + "| **점검 항목** | **확인** |\n", + "| --- | --- |\n", + "| 1차와 같은 조건·평가표·측정 기준으로 재실행했다 | ( ) |\n", + "| 시간 지표가 30% 이상 단축되었다 | ( ) |\n", + "| 오류·누락 지표가 50% 이상 감소했다 | ( ) |\n", + "| 미달 지표가 있으면 원인을 적었다 | ( ) |\n", + "| 보수 교육 주기와 대상을 제안했다 | ( ) |\n", + "\n", + "**\n", + "\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 10. 신병 급식·보급 불만 조기 식별 및 현장 조치 표준 매뉴얼 완성\n", + "\n", + "### 📋 미션 배경 및 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "신병 급식·보급 불만 조기 식별 및 현장 조치 표준 매뉴얼을 완성한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **신병 급식·보급 불만의 조기 식별과 스트레스 신호 연계 훈련 전 과정을 다른 부대 교관이 그대로 쓸 수 있는 표준 매뉴얼과 교관 지침서로 묶는다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **개요, SOP(체크리스트·흐름도), 시나리오(역할극 카드·MSEL), 평가 루브릭, AAR 양식의 5개 장으로 교안을 구성하고 수정된 양식을 부록에 넣는다 과정을 진행하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 5개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `package/wel08_standard_training_package.md 및 교관 지침서` 형태의 결과물을 완성한다.\n", + "\n", + "### 📑 실습자료 및 작성 양식\n", + "신병 급식·보급 불만 조기 식별 및 현장 조치 표준 매뉴얼 완성**\n", + "\n", + "[작성 서식 1] 표준 지침서 목차 서식\n", + "\n", + "| **장** | **제목** | **포함 내용** | **사용할 산출물** | **완료** |\n", + "| --- | --- | --- | --- | --- |\n", + "| **1** | 개요 | 목적, 전훈 배경, 적용 대상, 역할극·진단 금지·안전 원칙 | 미션 1 | ( ) |\n", + "| **2** | SOP | 신호 식별 체크리스트, 관찰 문장 규칙, 흐름도, 휴대 요약 카드, 보고 템플릿 | 미션 2·6·8 | ( ) |\n", + "| **3** | 시나리오 | 역할극 카드, MSEL, 스트레스 사건 카드 | 미션 3·4·7 | ( ) |\n", + "| **4** | 평가 루브릭 | 준수율·시간 측정, 교차검증 채점, 전후 비교 | 미션 4·5·9 | ( ) |\n", + "| **5** | AAR | 4단계 진행표, 오류 분석 매트릭스, 소통 창구 개선표 | 미션 8 | ( ) |\n", + "| **부록** | 양식 모음 | 면담 기록지, 현장 확인 점검표, 조치 계획 보고, 인계 체크리스트 | 전 미션 | ( ) |\n", + "\n", + "[작성 서식 2] 교관 지침서 핵심 항목\n", + "\n", + "| **항목** | **내용** |\n", + "| --- | --- |\n", + "| **준비물·인원** | ( ) |\n", + "| **역할극 안전 규칙(중단·역할 해제)** | ( ) |\n", + "| **시간표(총 소요)** | ( ) |\n", + "| **채점 방법** | ( ) |\n", + "| **상담관·전문가 감수 절차** | ( ) |\n", + "| **금지 사항** | 진단·단정 표현 금지, 실제 장병 사례·정보 사용 금지, 위기 징후의 자체 판단 금지 |\n", + "\n", + "[작성 서식 3] 모의 검토 의견 처리표(타 부대 교관·평가관 역할)\n", + "\n", + "| **번호** | **검토자 역할** | **의견** | **반영/미반영** | **처리 내용·사유** |\n", + "| --- | --- | --- | --- | --- |\n", + "| **1** | | | | |\n", + "| **2** | | | | |\n", + "| **3** | | | | |\n", + "| **4** | | | | |\n", + "| **5** | | | | |\n", + "\n", + "[작성 서식 4] 개정 관리표\n", + "\n", + "| **판** | **일자** | **개정 내용** | **감수** | **승인** |\n", + "| --- | --- | --- | --- | --- |\n", + "| **1.0** | | 최초 작성 | | |\n", + "| | | | | |\n", + "\n", + "| **점검 항목** | **확인** |\n", + "| --- | --- |\n", + "| 5개 장(개요-SOP-시나리오-평가 루브릭-AAR)이 모두 있다 | ( ) |\n", + "| 부록에 수정된 양식을 모두 넣었다 | ( ) |\n", + "| 모의 검토 의견을 모두 처리했다(미반영은 사유 기재) | ( ) |\n", + "| 진단·단정 표현이 없고, 상담관·전문가 감수 절차와 개정 주기를 명시했다 | ( ) |\n", + "| 교안의 수치가 미션 4·5·7·9 결과와 일치한다 | ( ) |\n", + "\n", + "**4. 평가 채점표**\n", + "\n", + "교관·평가관이 미션별 산출물과 기록지를 확인해 PASS/FAIL에 표시한다. 필수 미션 7개가 모두 PASS이고 선택 미션 3개 중 2개 이상이 PASS이면 과제 이수로 판정한다. 채점 대상은 기록·보고·연계 절차이며 연기자 개인에 대한 평가나 진단은 하지 않는다.\n", + "\n", + "| **미션** | **구분** | **채점 기준** | **판정** |\n", + "| --- | --- | --- | --- |\n", + "| **M1** | 필수 | 현장 체크 항목 15개 이상 정의, 6개 역할 x 10개 항목 역할 분담 매트릭스 빈칸 0개(100% 매핑, 항목당 R 1명), 교관 체크리스트 대조 누락 0건이며 불만 접수 20건을 유형별로 모두 분류했는가. | PASS / FAIL |\n", + "| **M2** | 필수 | SOP 단계별 세부 절차 100% 명시, 필수 안전·보안 조치 포함률 100%, 자료 3종(설문·건의함·익명 상담) 모두 반영, 관찰 사실 문장 변환 연습 5건 중 4건 이상 적합, 교차 검토 지적을 모두 수정했는가. | PASS / FAIL |\n", + "| **M3** | 필수 | 시간대별 주입 사건 10건 이상, 정상:비정상 비율 6:4, 모든 사건에 투입 시각·대상 역할·기대 행동이 있고 급식·보급 확인형과 상담 연계형 사건이 각각 2건 이상이며 워크스루 모순이 없는가. | PASS / FAIL |\n", + "| **M4** | 선택 | SOP 단계 준수율 80% 이상, 보고 지연시간 3분 이내, 면담 기록 필수 기재란 누락 0건, 단정·진단 표현 사용 0건, 병목·혼선 구간 2개 이상 식별했는가. | PASS / FAIL |\n", + "| **M5** | 필수 | 주입 오류 식별 성공률 85% 이상(8건 중 7건 이상), 정상 항목 오표시 1건 이하, 모든 판정에 대조 문서와 근거가 있고 유형별 보고 경로를 명시했는가. | PASS / FAIL |\n", + "| **M6** | 선택 | 인계 필수 항목 누락률 0%(10개 항목), 인계 소요 10분 이내, 인수자 돌발 질문 3문항 모두 정답, 개인 식별 정보 노출 0건, 상호 서명과 품질 체크리스트 통과가 확인되는가. | PASS / FAIL |\n", + "| **M7** | 필수 | 비상 절차 전환 시간 사건별 1분 이내, 위험 조치 성공률 90% 이상, 안전 수칙 위반 0건, 급성 스트레스 반응 사건에서 상담관·지휘 계통 보고가 1분 안에 이루어졌는가. | PASS / FAIL |\n", + "| **M8** | 선택 | 개선점 5건 이상 도출, 원인 항목별 근거 기록 연결률 100%, 소통 창구 3종 이상에 개선점 연결, 비난성 표현 없이 1차 SOP 수정안을 도출했는가. | PASS / FAIL |\n", + "| **M9** | 필수 | 1차 대비 조치(보고·인계) 시간 30% 이상 단축, 오류 발생 건수 50% 이상 감소, 동일 지표·동일 방법으로 비교했으며 미달 지표의 원인과 보수 교육 주기를 제시했는가. | PASS / FAIL |\n", + "| **M10** | 필수 | 교안 5개 장(개요-SOP-시나리오-평가 루브릭-AAR) 100% 완비, 모의 검토 의견 처리율 100%(미반영 시 사유 기재), 상담관·전문가 감수 절차와 개정 주기를 명시했는가. | PASS / FAIL |\n", + "\n", + "| **구분** | **기재** |\n", + "| --- | --- |\n", + "| **종합 판정** | 필수 ( )/7 PASS, 선택 ( )/3 PASS -> 이수 / 재평가 |\n", + "| **평가관 의견** | ( ) |\n", + "| **평가관 서명 / 일자** | ( ) / ( ) |\n", + "\n" + ] + } + ], + "metadata": { + "colab": { + "provenance": [] + }, + "kernelspec": { + "display_name": "Python 3", + "name": "python3" + }, + "language_info": { + "name": "python" + } + }, + "nbformat": 4, + "nbformat_minor": 0 +} \ No newline at end of file diff --git a/uploads/WEL10/WEL10_workbook.ipynb b/uploads/WEL10/WEL10_workbook.ipynb new file mode 100644 index 0000000..9b5ad7f --- /dev/null +++ b/uploads/WEL10/WEL10_workbook.ipynb @@ -0,0 +1,985 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# 📋 [WEL10] 교대근무 피로 누적을 줄이는 휴식표 검증 워크숍\n", + "### 실습 워크북 & SOP 가이드 (Google Colab / Jupyter)\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/WEL10/WEL10_workbook.ipynb)\n", + "\n", + "---\n", + "\n", + "## 📌 1. 프로젝트 개요 및 배경\n", + "\n", + "| 항목 | 내용 |\n", + "| :--- | :--- |\n", + "| **과정 분류** | 장병 복지·지원 (Level 2) |\n", + "| **학습 소요 시간** | 10개 미션 (5일 / 총 27.0시간) |\n", + "| **실습 유형** | 작전절차(SOP), 체크리스트, 모의훈련, AAR 사후검토 실습 |\n", + "\n", + "### 📖 프로젝트 목표\n", + "러시아-우크라이나 전쟁에서는 드론 감시와 야간 위협 때문에 경계·지휘통제 근무가 장시간 이어지고, 인원 손실과 파견으로 교대 인력이 줄어 휴식이 부족해지면서 집중력 저하와 판단 오류가 생길 수 있다는 점이 반복해서 거론되었다. 본 프로젝트는 교육용 가상 근무표와 휴식표를 이용해 24시간 교대근무 부대(경계, 지휘통제실)의 피로 누적 요인을 분석하고, 휴식표의 점검 기준을 세워 검증하고 개선하는 과정을 역할을 나누어 수행한다. 점검 기준 수치는 교육용 가정값이고 피로 판단은 참고용이며, 실제 근무 편성의 결정은 지휘관과 부대 규정을 따르고 건강 이상은 군의관·상담관에게 연계한다. 최종 목표는 휴식표의 점검·보고·인계 정확도와 속도를 수치로 확인해 개선하고 다른 부대가 반복해 쓸 수 있는 휴식표 편성·운영 가이드북으로 정리하는 것이다.\n", + "\n", + "> **⚠️ [교육용 가상 자료 원칙]**\n", + "> - 본 워크북의 모든 자료는 안전 규정에 따라 **교육용 가상 자료**로 구성되어 있습니다.\n", + "> - 실제 군사 기밀이나 부대 세부 인적·작전 정보는 포함되지 않습니다.\n", + "\n", + "---\n", + "\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 01. 24시간 교대근무 부대(경계, 지통실)의 교대 패턴 및 피로 누적 요인 분석\n", + "\n", + "### 📋 미션 배경 및 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "24시간 교대근무 부대의 근무 패턴 및 피로 누적 요인을 분석한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **24시간 교대근무 부대(경계, 지휘통제실)의 교대 패턴과 피로 누적 요인을 분석하고, 휴식표 검증에 필요한 현장 체크 항목과 역할 분담을 정의한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **실습자료의 가상 전훈 요약 카드와 가상 근무 소요표, 현행 교대 패턴을 읽고 근무처별 시간대, 필요 인원, 피로 요인(연속 야간, 짧은 휴식, 휴무 부족, 결원 대체, 소음 등)을 분석표로 정리하고 규격화하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 5개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `docs/wel10_baseline_assessment.md 및 훈련 프로파일` 형태의 결과물을 완성한다.\n", + "\n", + "### 📑 실습자료 및 작성 양식\n", + "24시간 교대근무 부대(경계, 지통실)의 교대 패턴 및 피로 누적 요인 분석**\n", + "\n", + "[제공 자료 1] 가상 전훈 요약 카드 - 공개된 일반 경향을 교육용으로 요약한 것이며 특정 수치·부대·지역 정보는 없다.\n", + "\n", + "| **번호** | **관찰된 경향(요약)** | **훈련 시사점** |\n", + "| --- | --- | --- |\n", + "| **L1** | 드론 감시와 야간 위협으로 경계·지휘통제 근무가 길게 이어짐 | 야간 근무 편중과 연속 야간 확인 |\n", + "| **L2** | 결원·파견으로 남은 인원이 추가 근무를 떠맡음 | 결원 대체 시 휴식 기준 재점검 |\n", + "| **L3** | 휴식 중에도 호출·소음으로 수면이 끊김 | 휴식 환경 체크 항목 |\n", + "| **L4** | 피로가 쌓일수록 상황 보고 누락과 판단 지연이 늘어남 | 근무 중 확인 절차와 인계 기록 |\n", + "| **L5** | 근무자의 피로 호소가 지휘 계통에 전달되지 않음 | 익명 의견 수렴과 보고 경로 |\n", + "\n", + "[제공 자료 2] 가상 근무 소요표 [교육용가상자료]\n", + "\n", + "| **근무처** | **근무 형태** | **시간대** | **필요 인원** | **비고** |\n", + "| --- | --- | --- | --- | --- |\n", + "| **경계 근무** | 초소 경계 | 24시간 3교대 | 교대당 1명 | 야간 감시 집중도 높음(가상) |\n", + "| **지휘통제실** | 상황 접수·보고 | 24시간 3교대 | 교대당 1명 | 교대 시 인수인계 필수 |\n", + "\n", + "[제공 자료 3] 현행 교대 패턴 요약과 피로 요인 카드\n", + "\n", + "| **요인** | **설명** | **점검할 편성 특성** |\n", + "| --- | --- | --- |\n", + "| **F1 연속 야간** | 야간 근무가 며칠 연속됨 | 야간 연속 횟수 |\n", + "| **F2 짧은 휴식** | 근무 종료 후 다음 근무까지 간격이 짧음 | 근무 간 휴식 시간 |\n", + "| **F3 휴무 부족** | 일주일에 쉬는 날이 없거나 적음 | 7일 중 휴무 일수 |\n", + "| **F4 결원 대체** | 환자·파견으로 남은 인원이 추가 근무 | 하루 근무 시간 |\n", + "| **F5 경계태세 격상** | 근무 소요 증가로 휴무자 소집 | 휴무 소집 횟수 |\n", + "| **F6 휴식 환경** | 소음·호출로 수면 방해 | 휴식 장소·호출 규칙 |\n", + "\n", + "[작성 서식 1] 피로 요인 분석표(5개 이상)\n", + "\n", + "| **번호** | **피로 요인** | **영향받는 근무처** | **확인 방법(자료)** | **점검 우선순위** |\n", + "| --- | --- | --- | --- | --- |\n", + "| **1** | | | | |\n", + "| **2** | | | | |\n", + "| **3** | | | | |\n", + "| **4** | | | | |\n", + "| **5** | | | | |\n", + "| **6** | | | | |\n", + "\n", + "[작성 서식 2] 역할 분담 매트릭스 - R(책임) 또는 S(지원)를 적는다. 한 항목의 R은 1명만 둔다.\n", + "\n", + "| **체크 항목** | **편성** | **점검** | **기록** | **보고** | **근무자 대표** | **관측** |\n", + "| --- | --- | --- | --- | --- | --- | --- |\n", + "| **근무 소요 확인** | | | | | | |\n", + "| **휴식표 초안 작성** | | | | | | |\n", + "| **연속 야간 점검** | | | | | | |\n", + "| **근무 간 휴식 계산** | | | | | | |\n", + "| **휴무·인원 점검** | | | | | | |\n", + "| **점검 결과 기록** | | | | | | |\n", + "| **당직사관 보고** | | | | | | |\n", + "| **피로 신호·의견 수렴** | | | | | | |\n", + "| **변경 이력·승인 기록** | | | | | | |\n", + "| **개인정보·보안 준수 관찰** | | | | | | |\n", + "\n", + "[작성 서식 3] 현장 체크 항목표(15개 이상)\n", + "\n", + "| **번호** | **체크 항목** | **단계** | **책임 역할** | **교관 대조** |\n", + "| --- | --- | --- | --- | --- |\n", + "| **1** | | | | |\n", + "| **2** | | | | |\n", + "| **3** | | | | |\n", + "| **4** | | | | |\n", + "| **5** | | | | |\n", + "| **6** | | | | |\n", + "| **7** | | | | |\n", + "| **8** | | | | |\n", + "| **9** | | | | |\n", + "| **10** | | | | |\n", + "| **11** | | | | |\n", + "| **12** | | | | |\n", + "| **13** | | | | |\n", + "| **14** | | | | |\n", + "| **15** | | | | |\n", + "\n", + "[작성 서식 4] 부대 유형별 환경 변수\n", + "\n", + "| **부대 유형** | **근무 인원** | **근무처 수** | **휴식 장소** | **호출 빈도** | **추가 체크 항목** |\n", + "| --- | --- | --- | --- | --- | --- |\n", + "| **경계 중심 부대** | | | | | |\n", + "| **지휘통제실 중심 부대** | | | | | |\n", + "| **소규모 부대** | | | | | |\n", + "\n", + "| **점검 항목** | **확인** |\n", + "| --- | --- |\n", + "| 피로 요인을 5개 이상 분석했다 | ( ) |\n", + "| 현장 체크 항목을 15개 이상 정의했다 | ( ) |\n", + "| 모든 항목에 R 역할이 1명씩 지정되었다(매핑 100%) | ( ) |\n", + "| 교관 체크리스트와 대조해 누락을 보완했다(누락 0건) | ( ) |\n", + "| 부대 유형별 환경 변수를 작성했다 | ( ) |\n", + "\n", + "**\n", + "\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 02. 생체 리듬, 연속 야간근무 제한, 최소 휴식 보장 휴식표 점검 체크리스트 작성\n", + "\n", + "### 📋 미션 배경 및 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "생체 리듬, 연속 야간근무 제한, 최소 휴식 보장 휴식표 점검표를 작성한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **생체 리듬, 연속 야간근무 제한, 최소 휴식 보장을 기준으로 휴식표를 점검하는 체크리스트와 SOP 흐름도를 작성한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **교육용 가정 기준(연속 야간 한도, 근무 간 최소 휴식, 주간 휴무 보장, 근무조 최소 인원)을 점검 항목으로 옮기고 '확인한다·계산한다·기록한다·보고한다' 형태의 단계별 절차로 정리하고 규격화하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 5개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `sop/wel10_field_checklist.md 및 SOP 흐름도` 형태의 결과물을 완성한다.\n", + "\n", + "### 📑 실습자료 및 작성 양식\n", + "생체 리듬, 연속 야간근무 제한, 최소 휴식 보장 휴식표 점검 체크리스트 작성**\n", + "\n", + "[제공 자료 1] 교육용 점검 기준 4종(가정값) - 실제 기준은 부대 규정과 지휘관 방침을 따른다.\n", + "\n", + "| **기준** | **교육용 가정값** | **계산 방법** | **위반 시** |\n", + "| --- | --- | --- | --- |\n", + "| **A 연속 야간** | 연속 3회 이내 | 연속된 날의 야간 근무 횟수 | 초과 횟수 기록 |\n", + "| **B 근무 간 최소 휴식** | 10시간 이상 | 다음 근무 시작 시각 - 이전 근무 종료 시각 | 해당 근무쌍 기록 |\n", + "| **C 주간 휴무** | 7일 중 1일 이상 | 휴무 일수 세기 | 근무자 기록 |\n", + "| **D 근무조 최소 인원** | 근무조당 2명 | 근무조별 배정 인원 세기 | 근무조 기록 |\n", + "\n", + "[제공 자료 2] 생체 리듬 참고 메모(교육용) - 야간 근무 직후 곧바로 주간 근무로 넘어가거나 낮은 휴식 간격이 반복되면 피로가 쌓이기 쉽다는 일반적 경향을 참고로 한다. 진단 또는 의학적 판정이 아니다.\n", + "\n", + "[작성 서식 1] 휴식표 점검표\n", + "\n", + "| **번호** | **점검 항목** | **기준** | **확인 방법** | **결과(적합/위반)** |\n", + "| --- | --- | --- | --- | --- |\n", + "| **1** | 야간 연속 횟수 | A | | |\n", + "| **2** | 근무 간 휴식 시간 | B | | |\n", + "| **3** | 7일 중 휴무 | C | | |\n", + "| **4** | 근무조 인원 | D | | |\n", + "| **5** | 결원 대체 후 재점검 | A~D | | |\n", + "| **6** | 변경 이력·승인 | - | | |\n", + "| **7** | 집계표와 근무표 일치 | - | | |\n", + "| **8** | 피로 호소 접수·보고 | - | | |\n", + "\n", + "[작성 서식 2] 단계별 SOP 체크리스트 - 판단이 필요한 사항은 '지휘관·당직사관 결정'으로 표시한다.\n", + "\n", + "| **단계** | **번호** | **행동 항목(행동 동사로)** | **안전·보안 조치** | **기록** | **결정권자** |\n", + "| --- | --- | --- | --- | --- | --- |\n", + "| **입력** | 1 | | | | |\n", + "| **입력** | 2 | | | | |\n", + "| **점검** | 3 | | | | |\n", + "| **점검** | 4 | | | | |\n", + "| **점검** | 5 | | | | |\n", + "| **점검** | 6 | | | | |\n", + "| **보고·승인** | 7 | | | | |\n", + "| **보고·승인** | 8 | | | | |\n", + "| **보고·승인** | 9 | | | | |\n", + "| **게시·사후** | 10 | | | | |\n", + "\n", + "필수 안전·보안 조치(각 단계 최소 1개): 근무자 개인정보 보호(가명·번호 사용) / 경계 공백 방지 확인 / 외부 반출 금지 / 피로 이상 호소 시 군의관·상담관 연계 / 지휘관 승인 없는 변경 금지\n", + "\n", + "| [근무 소요·인원 확인] -> [휴식표 초안] -> [점검 기준 A·B·C·D 계산]\n", + " -> [위반 있음?] --예--> [수정안 작성: ( )] -> [재점검]\n", + " -> 아니오 -> [점검 결과 기록] -> [당직사관 보고(3분 이내)]\n", + " -> [지휘관 승인(결정권자)] -> [게시·변경 이력 기록] |\n", + "| --- |\n", + "\n", + "[작성 서식 3] 교차 검토표\n", + "\n", + "| **번호** | **지적 위치** | **누락/모호 내용** | **수정안** | **반영 확인** |\n", + "| --- | --- | --- | --- | --- |\n", + "| **1** | | | | |\n", + "| **2** | | | | |\n", + "| **3** | | | | |\n", + "| **4** | | | | |\n", + "| **5** | | | | |\n", + "| **6** | | | | |\n", + "\n", + "[작성 서식 4] 휴대 요약 카드(30초 안에 읽을 분량)\n", + "\n", + "| **구분** | **내용(한 줄씩)** |\n", + "| --- | --- |\n", + "| **점검 기준 4가지** | ( ) |\n", + "| **계산 순서** | ( ) |\n", + "| **보고 순서** | ( ) |\n", + "| **인계 필수** | ( ) |\n", + "| **금지 사항** | ( ) |\n", + "\n", + "| **점검 항목** | **확인** |\n", + "| --- | --- |\n", + "| 점검 기준 4종을 점검표에 모두 넣었다 | ( ) |\n", + "| SOP 전 단계에 행동 동사와 안전·보안 조치가 있다(100%) | ( ) |\n", + "| 결정권자(지휘관·당직사관)를 표시했다 | ( ) |\n", + "| 교차 검토 지적을 모두 수정했다 | ( ) |\n", + "| 휴대 요약 카드가 한 장이며 30초 안에 읽힌다 | ( ) |\n", + "\n", + "**\n", + "\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 03. 부대별 현행 교대근무 편성표 수집 및 피로 누적 취약 구간 도출\n", + "\n", + "### 📋 미션 배경 및 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "부대별 현행 교대근무 편성표를 수집하고 취약 구간을 도출한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **가상 부대별 현행 교대근무 편성표를 수집·비교해 피로 누적 취약 구간을 도출하고, 이를 시험할 시간대별 시나리오(MSEL)를 만든다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **실습자료의 가상 부대 A·B·C 편성표와 현행 8인 7일 편성표를 점검 기준으로 대조해 취약 구간(근무자, 일자, 위반 유형)을 근무자별 행에 빠짐없이 적어 표로 도출하고 검증하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 5개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `scenarios/wel10_msel_scenario.json(md)` 형태의 결과물을 완성한다.\n", + "\n", + "### 📑 실습자료 및 작성 양식\n", + "부대별 현행 교대근무 편성표 수집 및 피로 누적 취약 구간 도출**\n", + "\n", + "[제공 자료 1] 가상 부대별 현행 교대 패턴 요약 [교육용가상자료]\n", + "\n", + "| **부대** | **인원** | **근무 구성** | **교대 방식(요약)** | **관찰 메모** |\n", + "| --- | --- | --- | --- | --- |\n", + "| **가상 A대대** | 8명 | 3교대(주·오·야) | 야간 2일 뒤 곧바로 주간 근무 | 야간 후 휴식 간격이 짧다는 의견 |\n", + "| **가상 B중대** | 6명 | 2교대(주·야 12시간) | 야간 4일 연속 후 2일 휴무 | 야간 연속이 길다는 의견 |\n", + "| **가상 C대대** | 10명 | 3교대 | 순방향 순환, 휴무 2일마다 | 결원이 생기면 휴무 소집이 잦음 |\n", + "\n", + "[제공 자료 2] 가상 현행 8인 7일 편성표 - 주(06~14), 오(14~22), 야(22~06), 휴(휴무)\n", + "\n", + "| **근무자** | **D1** | **D2** | **D3** | **D4** | **D5** | **D6** | **D7** |\n", + "| --- | --- | --- | --- | --- | --- | --- | --- |\n", + "| **가상 병사 가** | 야 | 오 | 주 | 야 | 오 | 주 | 휴 |\n", + "| **가상 병사 나** | 오 | 주 | 야 | 오 | 주 | 휴 | 휴 |\n", + "| **가상 병사 다** | 주 | 야 | 오 | 주 | 휴 | 휴 | 야 |\n", + "| **가상 병사 라** | 야 | 오 | 주 | 휴 | 휴 | 야 | 오 |\n", + "| **가상 병사 마** | 오 | 주 | 휴 | 휴 | 야 | 오 | 주 |\n", + "| **가상 병사 바** | 주 | 휴 | 휴 | 야 | 오 | 주 | 야 |\n", + "| **가상 병사 사** | 휴 | 휴 | 야 | 오 | 주 | 야 | 오 |\n", + "| **가상 병사 아** | 휴 | 야 | 오 | 주 | 야 | 오 | 주 |\n", + "\n", + "[작성 서식 1] 취약 구간 도출표 - 근무자별로 위반이 생긴 모든 일자 구간을 적는다.\n", + "\n", + "| **번호** | **근무자** | **일자(구간)** | **위반 기준(A~D)** | **위반 내용(계산 근거)** | **개선 방향** |\n", + "| --- | --- | --- | --- | --- | --- |\n", + "| **1** | | | | | |\n", + "| **2** | | | | | |\n", + "| **3** | | | | | |\n", + "| **4** | | | | | |\n", + "| **5** | | | | | |\n", + "| **6** | | | | | |\n", + "| **7** | | | | | |\n", + "| **8** | | | | | |\n", + "\n", + "| [교관용] 현행 편성표는 근무조 인원(각 근무조 2명)은 충족하지만 순환 방향이 역방향(야 -> 오 -> 주)이라 근무 간 8시간 간격이 반복되도록 만든 가상 표이다. 기준 B(10시간 미만) 위반은 8명 합계 24건, 기준 A(연속 야간)와 C(휴무) 위반은 각 0건, 0건이다. |\n", + "| --- |\n", + "\n", + "[제공 자료 3] MSEL 작성 예시(3건) - 나머지는 조별로 작성한다.\n", + "\n", + "| **번호** | **투입 시각** | **주입 사건** | **구분** | **대상 역할** | **기대 행동** |\n", + "| --- | --- | --- | --- | --- | --- |\n", + "| **1** | +00:00 | D1 휴식표 초안 접수 | 정상 | 점검 담당 | 기준 A·B·C·D 순서로 계산 |\n", + "| **2** | +03:00 | 근무자 나 환자 발생(결원 통보) | 비정상 | 편성 담당 | 대체 인원 검토, 영향 계산, 당직사관 보고 |\n", + "| **3** | +06:00 | 점검 결과 보고 요청 | 정상 | 보고 담당 | 위반·조치 안을 3분 이내 보고 |\n", + "\n", + "[작성 서식 2] MSEL 시나리오표(10건 이상, 정상 6 : 비정상 4)\n", + "\n", + "| **번호** | **투입 시각** | **주입 사건** | **구분** | **대상 역할** | **기대 행동** |\n", + "| --- | --- | --- | --- | --- | --- |\n", + "| **1** | | | | | |\n", + "| **2** | | | | | |\n", + "| **3** | | | | | |\n", + "| **4** | | | | | |\n", + "| **5** | | | | | |\n", + "| **6** | | | | | |\n", + "| **7** | | | | | |\n", + "| **8** | | | | | |\n", + "| **9** | | | | | |\n", + "| **10** | | | | | |\n", + "\n", + "[제공 자료 4] 돌발 변수 카드(2개 이상 선택): 환자 발생(결원) / 파견 차출 / 교대 인원 지연 도착 / 가상 경계태세 격상 / 휴무자 호출 / 피로 신호 보고 / 근무표 변경 승인 지연\n", + "\n", + "[작성 서식 3] 워크스루 점검표\n", + "\n", + "| **사건 번호** | **흐름 어색함** | **기대 행동 불분명** | **대상 역할 없음** | **수정 내용** |\n", + "| --- | --- | --- | --- | --- |\n", + "| **1** | | | | |\n", + "| **2** | | | | |\n", + "| **3** | | | | |\n", + "| **4** | | | | |\n", + "| **5** | | | | |\n", + "\n", + "| **점검 항목** | **확인** |\n", + "| --- | --- |\n", + "| 부대 A·B·C와 현행 편성표의 취약 구간을 도출표에 적었다 | ( ) |\n", + "| 위반 구간에 기준과 계산 근거를 적었다 | ( ) |\n", + "| 주입 사건을 10건 이상 작성했다 | ( ) |\n", + "| 정상:비정상 비율이 6:4이다 | ( ) |\n", + "| 돌발 변수를 2개 이상 넣고 워크스루 지적을 반영했다 | ( ) |\n", + "\n", + "**\n", + "\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 04. 1차 개선 휴식 편성표 작성 및 시뮬레이션 검증 실습(Dry-run)\n", + "\n", + "### 📋 미션 배경 및 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "1차 개선 휴식 편성표를 작성하고 시뮬레이션 검증(Dry-run)을 실시한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **현행 편성표의 문제를 고친 1차 개선 휴식 편성표를 작성하고 점검 기준으로 시뮬레이션 검증하는 1차 실습(Dry-run)을 실시한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **편성 담당이 8인 7일 개선 휴식표를 작성하고 점검 담당이 점검표로 연속 야간, 근무 간 휴식, 휴무, 근무조 인원을 계산해 위반을 찾는다 과정을 진행하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 5개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `logs/wel10_dryrun_execution_log.md 및 작성된 체크리스트` 형태의 결과물을 완성한다.\n", + "\n", + "### 📑 실습자료 및 작성 양식\n", + "1차 개선 휴식 편성표 작성 및 시뮬레이션 검증 실습(Dry-run)**\n", + "\n", + "[제공 자료 1] 1차 개선 휴식표 작성 조건: 근무조(주·오·야) 각 2명 x 7일을 모두 채우고, 점검 기준 A~D를 가능한 한 모두 충족한다. 현행 편성표(미션 3)를 출발점으로 쓴다.\n", + "\n", + "[작성 서식 1] 1차 개선 휴식표(8인 7일) - 주 / 오 / 야 / 휴로 기입\n", + "\n", + "| **근무자** | **D1** | **D2** | **D3** | **D4** | **D5** | **D6** | **D7** |\n", + "| --- | --- | --- | --- | --- | --- | --- | --- |\n", + "| **가상 병사 가** | | | | | | | |\n", + "| **가상 병사 나** | | | | | | | |\n", + "| **가상 병사 다** | | | | | | | |\n", + "| **가상 병사 라** | | | | | | | |\n", + "| **가상 병사 마** | | | | | | | |\n", + "| **가상 병사 바** | | | | | | | |\n", + "| **가상 병사 사** | | | | | | | |\n", + "| **가상 병사 아** | | | | | | | |\n", + "\n", + "[작성 서식 2] 점검 결과표(점검 담당 작성)\n", + "\n", + "| **근무자** | **A 연속 야간(최대)** | **B 최소 휴식(최소 시간)** | **C 휴무 일수** | **위반 건수** |\n", + "| --- | --- | --- | --- | --- |\n", + "| **가상 병사 가** | | | | |\n", + "| **가상 병사 나** | | | | |\n", + "| **가상 병사 다** | | | | |\n", + "| **가상 병사 라** | | | | |\n", + "| **가상 병사 마** | | | | |\n", + "| **가상 병사 바** | | | | |\n", + "| **가상 병사 사** | | | | |\n", + "| **가상 병사 아** | | | | |\n", + "\n", + "| **일자** | **D1** | **D2** | **D3** | **D4** | **D5** | **D6** | **D7** |\n", + "| --- | --- | --- | --- | --- | --- | --- | --- |\n", + "| **주 인원** | | | | | | | |\n", + "| **오 인원** | | | | | | | |\n", + "| **야 인원** | | | | | | | |\n", + "\n", + "측정 기준: 보고 지연시간 = 점검 완료 선언 시각부터 당직사관 역할 앞 보고 완료(복창 확인) 시각까지. SOP 준수율 = 순서대로 수행한 단계 수 / 전체 단계 수 x 100. 근무조 충족률 = 인원 2명인 근무조 수 / 21 x 100.\n", + "\n", + "[작성 서식 3] Dry-run 수행 기록지(관측자 작성)\n", + "\n", + "| **SOP 단계** | **계획 순서** | **실제 시각** | **순서 준수(O/X)** | **비고(혼선·지연)** |\n", + "| --- | --- | --- | --- | --- |\n", + "| **근무 소요·인원 확인** | 1 | | | |\n", + "| **휴식표 초안 작성** | 2 | | | |\n", + "| **기준 A 점검** | 3 | | | |\n", + "| **기준 B 점검** | 4 | | | |\n", + "| **기준 C 점검** | 5 | | | |\n", + "| **기준 D 점검** | 6 | | | |\n", + "| **점검 완료 선언** | 7 | | | |\n", + "| **당직사관 보고 완료** | 8 | | | |\n", + "| **수정·재점검** | 9 | | | |\n", + "| **변경 이력 기록** | 10 | | | |\n", + "\n", + "| **지표** | **계산** | **결과** | **목표** | **달성** |\n", + "| --- | --- | --- | --- | --- |\n", + "| **SOP 단계 준수율** | ( )/10 x 100 | ( )% | 80% 이상 | ( ) |\n", + "| **보고 지연시간** | ( : ) - ( : ) | ( )분 ( )초 | 3분 이내 | ( ) |\n", + "| **기준 위반 건수** | A+B+C 합계 | ( )건 | 2건 이하 | ( ) |\n", + "| **근무조 충족** | ( )/21 x 100 | ( )% | 100% | ( ) |\n", + "\n", + "[작성 서식 4] 병목·혼선 구간 기록표\n", + "\n", + "| **번호** | **발생 구간(SOP 단계)** | **현상** | **관련 역할** | **개선 아이디어** |\n", + "| --- | --- | --- | --- | --- |\n", + "| **1** | | | | |\n", + "| **2** | | | | |\n", + "| **3** | | | | |\n", + "\n", + "| **점검 항목** | **확인** |\n", + "| --- | --- |\n", + "| 개선 휴식표 근무조 21개를 모두 채웠다 | ( ) |\n", + "| 점검 결과표를 근무자 8명 모두 작성했다 | ( ) |\n", + "| SOP 단계 준수율 80% 이상이다 | ( ) |\n", + "| 보고 지연시간이 3분 이내이다 | ( ) |\n", + "| 기준 위반이 2건 이하이다 | ( ) |\n", + "| 병목·혼선 구간을 2개 이상 기록했다 | ( ) |\n", + "\n", + "**\n", + "\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 05. 돌발 결원(환자, 파견) 발생 시 휴식표 붕괴 및 과부하 위험 검증\n", + "\n", + "### 📋 미션 배경 및 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "돌발 결원 발생 시 휴식표 붕괴 및 과부하 위험을 검증한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **환자, 파견 등 돌발 결원으로 수정된 근무표에서 휴식표가 무너진 부분과 과부하 위험을 찾아내고 교차확인 후 보고하는 훈련을 한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **교관이 결원 처리 결과를 섞어 둔 수정 근무표 발췌, 집계표, 변경 이력의 12개 대조 항목을 받아 점검 기준과 문서끼리 비교 수행하고 검증하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 5개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `docs/wel10_cross_verification_sheet.md` 형태의 결과물을 완성한다.\n", + "\n", + "### 📑 실습자료 및 작성 양식\n", + "돌발 결원(환자, 파견) 발생 시 휴식표 붕괴 및 과부하 위험 검증**\n", + "\n", + "[제공 자료] 대조 항목 12개 [교육용가상자료] - 결원 처리 후 수정 근무표 발췌, 집계표, 변경 이력을 교육용 점검 기준(미션 2)에 대조해 오류인지 정상인지 판정한다. 과부하 판단은 참고용이다.\n", + "\n", + "| **번호** | **대상** | **수정 근무표·기록 발췌(가상)** | **확인할 기준** |\n", + "| --- | --- | --- | --- |\n", + "| **5-1** | 근무자 가 | D2~D5 야간 근무 4일 연속(환자 결원 대체) | A 연속 야간 |\n", + "| **5-2** | 근무자 나 | D3 야간(~D4 06:00) 후 D4 오후 14:00 근무 | B 최소 휴식 |\n", + "| **5-3** | 근무자 다 | 파견 복귀 후 D5 야간(~D6 06:00), D6 주간 06:00 근무 | B 최소 휴식 |\n", + "| **5-4** | 근무자 라 | D1~D7 중 휴무 D4 1일, 근무 간 휴식 모두 10시간 이상 | A·B·C |\n", + "| **5-5** | 근무자 마 | D2 주간과 오후를 연속 근무(16시간), 환자 대체 | 하루 근무 시간(8시간 초과 금지) |\n", + "| **5-6** | 집계표 | 근무자 바 주간 근무 5칸(40시간)인데 집계표는 56시간 | 집계 일치 |\n", + "| **5-7** | 근무자 사 | D1~D7 휴무 없음(결원 대체) | C 휴무 |\n", + "| **5-8** | 근무자 아 | 근무 종료 후 다음 근무까지 12시간, 휴무 2일 | A·B·C |\n", + "| **5-9** | D3 야간 근무조 | 결원 후 1명만 배정(경계 1명, 지휘통제실 공석) | D 근무조 인원 |\n", + "| **5-10** | D4 야간 근무조 | 결원 후에도 2명 유지(대체 근무자 휴식 12시간) | D 근무조 인원 |\n", + "| **5-11** | 변경 이력 | D3 근무표 변경 기록에 지휘관 승인 서명 없음 | 승인 기록 |\n", + "| **5-12** | 변경 이력 | D5 변경 기록에 변경 사유·시각·지휘관 승인 서명 있음 | 승인 기록 |\n", + "\n", + "[작성 서식 1] 교차검증 매트릭스\n", + "\n", + "| **번호** | **판정(오류/정상)** | **대조한 기준·문서** | **판정 근거(계산)** | **보고 대상·우선순위** |\n", + "| --- | --- | --- | --- | --- |\n", + "| **5-1** | | | | |\n", + "| **5-2** | | | | |\n", + "| **5-3** | | | | |\n", + "| **5-4** | | | | |\n", + "| **5-5** | | | | |\n", + "| **5-6** | | | | |\n", + "| **5-7** | | | | |\n", + "| **5-8** | | | | |\n", + "| **5-9** | | | | |\n", + "| **5-10** | | | | |\n", + "| **5-11** | | | | |\n", + "| **5-12** | | | | |\n", + "\n", + "| **지표** | **계산** | **결과** | **목표** |\n", + "| --- | --- | --- | --- |\n", + "| **식별 성공률** | 맞게 찾은 오류 수 ( ) / 주입 오류 8 x 100 | ( )% | 85% 이상(7건 이상) |\n", + "| **오표시** | 정상 4건 중 오류로 표시한 수 | ( )건 | 1건 이하 |\n", + "\n", + "[작성 서식 2] 위험 유형별 보고 경로\n", + "\n", + "| **우선순위** | **위험 유형** | **보고 대상** | **보고 시한** | **보고 문장(SBAR 한 줄)** |\n", + "| --- | --- | --- | --- | --- |\n", + "| **1(즉시)** | 근무조 인원 부족(경계 공백), 승인 없는 변경 | | | |\n", + "| **2** | 연속 야간·최소 휴식·휴무 기준 위반 | | | |\n", + "| **3** | 집계 불일치, 하루 근무 시간 초과 | | | |\n", + "\n", + "| [교관용 정답표 - 조별 판정 후 공개] 오류 8건: 5-1(연속 야간 4회), 5-2(휴식 8시간), 5-3(휴식 0시간), 5-5(하루 16시간), 5-6(집계 불일치), 5-7(휴무 0일), 5-9(근무조 1명, 즉시 보고), 5-11(승인 누락). 정상 4건: 5-4, 5-8, 5-10, 5-12. 결원 대체 방안의 결정은 지휘관·당직사관이 한다. |\n", + "| --- |\n", + "\n", + "| **점검 항목** | **확인** |\n", + "| --- | --- |\n", + "| 12개 항목 모두 판정하고 기준과 계산 근거를 적었다 | ( ) |\n", + "| 주입 오류 8건 중 7건 이상을 찾았다(85% 이상) | ( ) |\n", + "| 정상 항목 오표시가 1건 이하이다 | ( ) |\n", + "| 과부하 판단을 참고용으로 기록하고 결정은 지휘관에게 맡겼다 | ( ) |\n", + "| 위험 유형별 보고 대상·시한을 정했다 | ( ) |\n", + "\n", + "**\n", + "\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 06. 지휘관 및 당직사관 앞 개편된 휴식표 인계 및 운영 방안 브리핑 실습\n", + "\n", + "### 📋 미션 배경 및 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "지휘관 및 당직사관 앞 개편된 휴식표 인계 브리핑을 실습한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **지휘관과 당직사관 앞에서 개편된 휴식표의 요지와 운영 방안을 인계 브리핑해 빠짐없고 빠른 인계 절차를 만든다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **개편 요지, 적용 기간, 변경 대상, 점검 기준 충족 결과, 결원 대응 방안, 피로 신호 보고 방법을 휴식표 인계 브리핑 서식에 정리해 지휘관·당직사관 역할에게 구두로 보고 수행하고 검증하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 5개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `reports/wel10_handover_protocol.md 및 브리핑 녹취/기록지` 형태의 결과물을 완성한다.\n", + "\n", + "### 📑 실습자료 및 작성 양식\n", + "지휘관 및 당직사관 앞 개편된 휴식표 인계 및 운영 방안 브리핑 실습**\n", + "\n", + "[제공 자료 1] 가상 개편 요약 예시 [교육용가상자료] - 미션 4 결과가 있으면 그것을 사용한다.\n", + "\n", + "| **항목** | **내용(가상)** |\n", + "| --- | --- |\n", + "| **개편 요지** | 순방향 순환(주 -> 오 -> 야), 연속 야간 2회로 제한 |\n", + "| **적용 기간** | D1~D7 (1주) |\n", + "| **변경 대상** | 근무자 8명 전원(가명) |\n", + "| **점검 기준 충족** | A·B·C·D 위반 0건 |\n", + "| **결원 대응** | 1명 결원 시 휴무자 1명 소집, 사전 승인 필요 |\n", + "\n", + "[작성 서식 1] 휴식표 인계 브리핑 서식(지휘관·당직사관 역할 앞 구두 보고)\n", + "\n", + "| **항목** | **내용** |\n", + "| --- | --- |\n", + "| **보고 시각·보고자** | ( ) |\n", + "| **개편 요지와 적용 기간** | ( ) |\n", + "| **변경 대상과 변경 내용** | ( ) |\n", + "| **점검 기준 충족 결과(A~D)** | ( ) |\n", + "| **결원·경계태세 변경 시 대응 방안** | ( ) |\n", + "| **피로 신호·의견 접수 방법** | ( ) |\n", + "| **결정·승인이 필요한 사항** | ( ) |\n", + "\n", + "[작성 서식 2] 인계 품질 체크리스트(인계자·인수자 공동 확인)\n", + "\n", + "| **번호** | **필수 항목** | **인계자** | **인수자** |\n", + "| --- | --- | --- | --- |\n", + "| **1** | 개편 요지 | | |\n", + "| **2** | 적용 시작·종료 일시 | | |\n", + "| **3** | 변경된 근무자(가명)와 근무 시간대 | | |\n", + "| **4** | 점검 기준 A~D 충족 결과 | | |\n", + "| **5** | 결원 발생 시 대체 순서 | | |\n", + "| **6** | 경계태세 변경 시 조정 절차 | | |\n", + "| **7** | 피로 신호 보고 경로(군의관·상담관 연계 포함) | | |\n", + "| **8** | 지휘관 승인 필요 사항 | | |\n", + "| **9** | 변경 이력 기록 위치 | | |\n", + "| **10** | 후속 점검 일정 | | |\n", + "\n", + "| **구분** | **기재** |\n", + "| --- | --- |\n", + "| **인계 시작 / 종료 시각** | ( : ) / ( : ) 소요 ( )분 ( )초 / 목표 10분 이내 |\n", + "| **누락 항목 수** | ( )개 / 10개 누락률 ( )% |\n", + "| **인계자 서명 / 인수자 서명** | ( ) / ( ) |\n", + "\n", + "[제공 자료 2] 인수자 돌발 질문 카드(평가관이 3개 선택): Q1 야간 연속 한도는? / Q2 결원 1명 발생 시 먼저 할 일은? / Q3 변경 승인은 누가 하는가? / Q4 피로를 호소하는 근무자가 있으면 누구에게 보고하는가? / Q5 다음 점검 일정은?\n", + "\n", + "[작성 서식 3] SBAR 인계 브리핑 템플릿\n", + "\n", + "| **구분** | **내용** |\n", + "| --- | --- |\n", + "| **S 상황(Situation)** | ( ) |\n", + "| **B 배경(Background)** | ( ) |\n", + "| **A 평가(Assessment)** | ( ) |\n", + "| **R 요청(Recommendation)** | ( ) |\n", + "\n", + "| **점검 항목** | **확인** |\n", + "| --- | --- |\n", + "| 브리핑 서식 7개 항목을 모두 채웠다 | ( ) |\n", + "| 인계 필수 항목 10개 누락 0건(누락률 0%)이다 | ( ) |\n", + "| 인계 소요가 10분 이내이다 | ( ) |\n", + "| 돌발 질문 3개에 인수자가 모두 정답을 말했다 | ( ) |\n", + "| 인계자·인수자 상호 서명을 마쳤다 | ( ) |\n", + "\n", + "**\n", + "\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 07. 경계태세 격상 상황 모의 극한 교대근무 스트레스 훈련\n", + "\n", + "### 📋 미션 배경 및 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "경계태세 격상 모의 극한 교대근무 스트레스 훈련을 진행한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **가상 경계태세 격상으로 근무 소요가 늘어나는 상황에서도 휴식 기준 점검·보고 절차를 유지하는 스트레스 훈련을 한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **안전통제관 통제 아래 사건 카드 6건(격상 통보와 증원 요구, 휴무자 소집 요청, 야간 근무자 결원, 교대 지연, 피로 신호 보고, 지휘관의 휴식 단축 지시) 주입하여 처리 지연 및 복원력을 시험하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 5개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `logs/wel10_stress_drill_results.md` 형태의 결과물을 완성한다.\n", + "\n", + "### 📑 실습자료 및 작성 양식\n", + "경계태세 격상 상황 모의 극한 교대근무 스트레스 훈련**\n", + "\n", + "| [안전 통제] 본 훈련은 실내 모의 훈련이며 실제 경계 태세 변경이나 근무 변경은 하지 않는다. 근무 조정의 결정은 지휘관 역할(교관)이 하고, 학습자는 기준 대비 영향 계산·보고·기록만 한다. 안전통제관의 '중지' 구호에 즉시 멈춘다. 가상 경계태세 단계는 실제 단계와 무관한 교육용 표시이다. |\n", + "| --- |\n", + "\n", + "[제공 자료] 스트레스 사건 카드 6건 [교육용가상자료]\n", + "\n", + "| **번호** | **주입 사건** | **환경** | **기대 행동** | **위험 조치(판정 대상)** |\n", + "| --- | --- | --- | --- | --- |\n", + "| **7-1** | 가상 경계태세 격상 통보, 근무조 3개를 4명 체제로 요구 | 시간 압박 | 인원 소요 재계산, 지휘관에게 영향 보고 | 기준 위반 계산, 결정 기록 |\n", + "| **7-2** | 휴무자 2명 소집 요청 | 시간 압박 | 휴무 기준(C) 영향 보고, 대안 제시 | 휴무 기록 갱신, 승인 기록 |\n", + "| **7-3** | 야간 근무자 1명 결원 통보 | 저조도 회의실 | 근무조 인원(D) 확인, 대체 순서 보고 | 경계 공백 확인, 보고 |\n", + "| **7-4** | 편성 담당 이탈(지휘 공백) | 시간 압박 | 위임 순서에 따라 차상위자 임무 인수 선언 | 인원 확인, 보고 |\n", + "| **7-5** | 근무자 피로 신호 보고(졸림 호소) | 시간 압박 | 근무자 교체 요청과 군의관·상담관 연계 보고 | 피로 신호 기록, 보고 |\n", + "| **7-6** | 지휘관의 휴식 단축 지시 | 시간 압박 | 기준 대비 영향을 한 문장으로 보고하고 결정 기록 | 위반 내용 기록, 지휘관 결정 기록 |\n", + "\n", + "[작성 서식 1] 결과 기록표(관측자 작성) - 전환 시간 = 주입 시각부터 비상 절차 전환(또는 임무 인수) 선언 시각까지\n", + "\n", + "| **사건** | **주입 시각** | **선언 시각** | **전환 시간** | **위험 조치1** | **위험 조치2** | **안전 위반** |\n", + "| --- | --- | --- | --- | --- | --- | --- |\n", + "| **7-1** | | | | O / X | O / X | 유 / 무 |\n", + "| **7-2** | | | | O / X | O / X | 유 / 무 |\n", + "| **7-3** | | | | O / X | O / X | 유 / 무 |\n", + "| **7-4** | | | | O / X | O / X | 유 / 무 |\n", + "| **7-5** | | | | O / X | O / X | 유 / 무 |\n", + "| **7-6** | | | | O / X | O / X | 유 / 무 |\n", + "\n", + "| **지표** | **계산** | **결과** | **목표** |\n", + "| --- | --- | --- | --- |\n", + "| **최대 전환 시간** | 6건 중 가장 긴 시간 | ( )초 | 60초 이내 |\n", + "| **위험 조치 성공률** | 성공 수 ( ) / 12 x 100 | ( )% | 90% 이상 |\n", + "| **안전 수칙 위반** | 위반 건수 | ( )건 | 0건 |\n", + "| **지휘 공백 위임 시간** | 7-4 전환 시간 | ( )초 | 60초 이내 |\n", + "\n", + "[작성 서식 2] 임무 위임 순서표\n", + "\n", + "| **순서** | **직책(역할)** | **인수할 임무** | **인수 선언 문장** |\n", + "| --- | --- | --- | --- |\n", + "| **1** | 편성 담당 | | |\n", + "| **2** | | | |\n", + "| **3** | | | |\n", + "\n", + "| **점검 항목** | **확인** |\n", + "| --- | --- |\n", + "| 훈련 규칙과 중지 신호를 확인한 뒤 시작했다 | ( ) |\n", + "| 사건 6건 모두 주입·선언 시각을 기록했다 | ( ) |\n", + "| 모든 전환 시간이 60초 이내이다 | ( ) |\n", + "| 위험 조치 성공률이 90% 이상이다 | ( ) |\n", + "| 안전 수칙 위반이 0건이다 | ( ) |\n", + "| 근무 조정 결정은 지휘관 역할이 하고 학습자는 영향 보고와 기록만 했다 | ( ) |\n", + "\n", + "**\n", + "\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 08. AAR 워크숍을 통한 장병 피로도 의견 수렴 및 불합리한 휴식표 개선안 도출\n", + "\n", + "### 📋 미션 배경 및 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "AAR 워크숍을 통해 장병 피로도 의견을 수렴하고 개선안을 도출한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **AAR 워크숍으로 장병 피로도 의견을 수렴하고 불합리한 휴식표 요소의 원인을 사실 중심으로 분석해 개선안을 도출한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **가상 의견 카드(익명)와 Dry-run·스트레스 훈련 기록을 시간순으로 맞춰 본다 과정을 진행하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 5개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `aar/wel10_aar_session_minutes.md 및 오류 분석 매트릭스` 형태의 결과물을 완성한다.\n", + "\n", + "### 📑 실습자료 및 작성 양식\n", + "AAR 워크숍을 통한 장병 피로도 의견 수렴 및 불합리한 휴식표 개선안 도출**\n", + "\n", + "[제공 자료 1] AAR 4단계 진행표\n", + "\n", + "| **단계** | **질문** | **진행 규칙** |\n", + "| --- | --- | --- |\n", + "| **1** | 무엇을 계획했나? | SOP·MSEL 기준을 먼저 확인한다 |\n", + "| **2** | 실제로 무엇이 일어났나? | 기록(시각)을 기준으로 말하고, 진술은 기록과 대조한다 |\n", + "| **3** | 왜 차이가 났나? | 사람이 아닌 절차·양식·환경·숙달 원인으로 분류한다 |\n", + "| **4** | 무엇을 유지·개선할까? | 개선점마다 담당·기한·근거를 붙인다 |\n", + "\n", + "[제공 자료 2] 가상 1차 훈련 타임라인 발췌 [교육용가상자료] - 자기 조 기록과 함께 사용한다.\n", + "\n", + "| **시각** | **관측 기록** | **참여자 진술** |\n", + "| --- | --- | --- |\n", + "| **+05:00** | 개선 휴식표 초안, D3 야간 근무자 마 연속 야간 4회 | 편성 담당: 결원 대체로 어쩔 수 없었음 |\n", + "| **+09:30** | 점검 담당 B 기준 계산 중 D2·D3 구간 누락 | 점검 담당: 계산표 칸이 없어 건너뜀 |\n", + "| **+12:00** | 점검 완료 선언 | 기록 담당: 위반 3건 기록 |\n", + "| **+17:10** | 당직사관 보고 완료(지연 5분 10초) | 보고 담당: 변경 사유를 정리하느라 늦음 |\n", + "| **+22:00** | 인계 시 피로 신호 보고 경로 누락 | 인수자: 서식 뒷면에 있어 못 봄 |\n", + "\n", + "[제공 자료 3] 가상 피로도 의견 카드(익명) 6건 [교육용가상자료]\n", + "\n", + "| **번호** | **의견(가상)** | **요인 분류(F1~F6 중)** |\n", + "| --- | --- | --- |\n", + "| **O1** | 야간을 이어서 하면 낮에 잠이 안 와서 다음 날이 힘들다 | |\n", + "| **O2** | 야간 근무 끝나고 곧바로 주간이면 쉴 틈이 없다 | |\n", + "| **O3** | 결원이 생기면 항상 같은 사람만 대신 선다 | |\n", + "| **O4** | 쉬는 날에도 호출이 와서 쉬지 못한다 | |\n", + "| **O5** | 휴식 장소가 시끄러워 잠이 깬다 | |\n", + "| **O6** | 피곤하다고 말해도 어디에 말해야 할지 모르겠다 | |\n", + "\n", + "[작성 서식 0] 피로도 의견 수렴표 - 의견 카드는 요인별로 분류하고 개인을 특정하지 않는다.\n", + "\n", + "| **요인** | **의견 번호** | **공통 내용** | **개선 방향(편성·환경·보고)** |\n", + "| --- | --- | --- | --- |\n", + "| **F1 연속 야간** | | | |\n", + "| **F2 짧은 휴식** | | | |\n", + "| **F3 휴무 부족** | | | |\n", + "| **F4 결원 대체** | | | |\n", + "| **F6 휴식 환경** | | | |\n", + "| **보고 경로** | | | |\n", + "\n", + "[작성 서식 1] 기록-진술 불일치 확인표\n", + "\n", + "| **번호** | **시각** | **기록 내용** | **진술 내용** | **확인 결과(사실)** |\n", + "| --- | --- | --- | --- | --- |\n", + "| **1** | | | | |\n", + "| **2** | | | | |\n", + "| **3** | | | | |\n", + "| **4** | | | | |\n", + "\n", + "[작성 서식 2] 오류 분석 매트릭스\n", + "\n", + "| **번호** | **문제(현상)** | **사실 근거(기록 위치)** | **원인 분류** | **개선점** | **담당·기한** |\n", + "| --- | --- | --- | --- | --- | --- |\n", + "| **1** | | | | | |\n", + "| **2** | | | | | |\n", + "| **3** | | | | | |\n", + "| **4** | | | | | |\n", + "| **5** | | | | | |\n", + "| **6** | | | | | |\n", + "\n", + "원인 분류: 절차(점검 순서·누락) / 편성(순환 방식·인원) / 환경(휴식 장소·소음·호출) / 숙달(계산 반복 부족) / 통신(보고·승인 경로)\n", + "\n", + "[작성 서식 3] 1차 SOP 수정안\n", + "\n", + "| **수정 대상** | **수정 전** | **수정 후** | **근거(분석 번호)** |\n", + "| --- | --- | --- | --- |\n", + "| **체크리스트** | | | |\n", + "| **기록 양식** | | | |\n", + "| **인계·보고 템플릿** | | | |\n", + "| **기타** | | | |\n", + "\n", + "| **점검 항목** | **확인** |\n", + "| --- | --- |\n", + "| AAR 4단계를 순서대로 진행했다 | ( ) |\n", + "| 기록과 진술이 다른 부분을 사실로 확인했다 | ( ) |\n", + "| 개선점을 5건 이상 도출했다 | ( ) |\n", + "| 모든 원인 항목에 근거 기록 위치를 적었다(100%) | ( ) |\n", + "| 회의록에 개인 비난 표현이 없다 | ( ) |\n", + "| SOP 수정안을 작성했다 | ( ) |\n", + "\n", + "**\n", + "\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 09. 피드백 반영 공정 휴식표 재편성 및 부대 적용성 검증\n", + "\n", + "### 📋 미션 배경 및 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "피드백을 반영하여 공정 휴식표를 재편성하고 적용성을 검증한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **AAR 개선안을 반영해 공정 휴식표를 재편성하고 같은 기준으로 재검증해 부대 적용성을 확인한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **수정된 점검표와 편성 방식을 적용해 같은 8인 7일 조건에서 휴식표를 다시 편성 수행하고 검증하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 5개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `eval/wel10_rerun_performance_comparison.md` 형태의 결과물을 완성한다.\n", + "\n", + "### 📑 실습자료 및 작성 양식\n", + "피드백 반영 공정 휴식표 재편성 및 부대 적용성 검증**\n", + "\n", + "[제공 자료] 가상 1차 결과 예시 [교육용가상자료] - 자기 조 1차 기록이 없으면 이 값을 기준으로 쓴다. 측정 기준은 미션 4·6과 같다. 점검 소요시간은 점검 시작부터 점검 완료 선언까지로 정의한다.\n", + "\n", + "| **지표** | **1차(가상 예시)** | **목표(2차)** |\n", + "| --- | --- | --- |\n", + "| **점검 소요시간** | 40분 00초 | 28분 00초 이하(30% 단축) |\n", + "| **인계 소요시간** | 12분 00초 | 8분 24초 이하(30% 단축) |\n", + "| **점검 기준 위반 건수** | 8건 | 4건 이하(50% 감소) |\n", + "| **인계 누락 항목** | 3개 | 1개 이하(50% 이상 감소) |\n", + "\n", + "개선율 계산: 시간 단축률 = (1차 - 2차) / 1차 x 100, 오류 감소율 = (1차 - 2차) / 1차 x 100\n", + "\n", + "[작성 서식 1] 전후 성과 비교표\n", + "\n", + "| **지표** | **1차** | **2차** | **개선율** | **목표** | **달성** |\n", + "| --- | --- | --- | --- | --- | --- |\n", + "| **점검 소요시간** | | | | 30% 단축 | |\n", + "| **인계 소요시간** | | | | 30% 단축 | |\n", + "| **점검 기준 위반 건수** | | | | 50% 감소 | |\n", + "| **인계 누락 항목** | | | | 50% 감소 | |\n", + "| **SOP 단계 준수율** | | | | 80% 이상 유지 | |\n", + "\n", + "[작성 서식 2] 미달 지표 원인과 보수 교육 제안\n", + "\n", + "| **구분** | **내용** |\n", + "| --- | --- |\n", + "| **미달 지표·원인** | ( ) |\n", + "| **적용한 수정안 효과** | ( ) |\n", + "| **보수 교육 주기·대상** | 예: 분기 1회, 신규 전입자 전원( ) |\n", + "| **다음 재평가 일정** | ( ) |\n", + "\n", + "| **점검 항목** | **확인** |\n", + "| --- | --- |\n", + "| 1차와 같은 조건·평가표·측정 기준으로 재실행했다 | ( ) |\n", + "| 시간 지표가 30% 이상 단축되었다 | ( ) |\n", + "| 오류·누락 지표가 50% 이상 감소했다 | ( ) |\n", + "| 미달 지표가 있으면 원인을 적었다 | ( ) |\n", + "| 보수 교육 주기와 대상을 제안했다 | ( ) |\n", + "\n", + "**\n", + "\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 🎯 미션 10. 교대근무 피로 누적 감소 휴식표 편성 및 운영 가이드북 완성\n", + "\n", + "### 📋 미션 배경 및 지침\n", + "#### 미션 배경 및 당위성\n", + "\n", + "교대근무 피로 누적 감소 휴식표 검증 워크숍 및 가이드북을 완성한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **휴식표 검증 워크숍 전 과정을 다른 부대 교관이 그대로 쓸 수 있는 휴식표 편성·운영 가이드북과 교관 지침서로 묶는다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n", + "\n", + "이 문제를 해결하기 위해 본 미션에서는 **개요, SOP(점검표·흐름도), 시나리오(가상 근무표·MSEL), 평가 루브릭, AAR 양식의 5개 장으로 교안을 구성하고 수정된 양식을 부록에 넣는다 과정을 진행하는** 실습을 체계적으로 수행합니다.\n", + "\n", + "학습자는 실습가이드에 제시된 총 5개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n", + "\n", + "**이번 결과물:** `package/wel10_standard_training_package.md 및 교관 지침서` 형태의 결과물을 완성한다.\n", + "\n", + "### 📑 실습자료 및 작성 양식\n", + "교대근무 피로 누적 감소 휴식표 편성 및 운영 가이드북 완성**\n", + "\n", + "[작성 서식 1] 표준 지침서 목차 서식\n", + "\n", + "| **장** | **제목** | **포함 내용** | **사용할 산출물** | **완료** |\n", + "| --- | --- | --- | --- | --- |\n", + "| **1** | 개요 | 목적, 전훈 배경, 적용 대상, 점검 기준(가정값)·안전·보안 원칙 | 미션 1 | ( ) |\n", + "| **2** | SOP | 휴식표 점검표, 흐름도, 휴대 요약 카드, 인계 템플릿 | 미션 2·6·8 | ( ) |\n", + "| **3** | 시나리오 | 가상 근무표, MSEL, 스트레스 사건 카드 | 미션 3·5·7 | ( ) |\n", + "| **4** | 평가 루브릭 | 준수율·시간 측정, 교차검증 채점, 전후 비교 | 미션 4·5·9 | ( ) |\n", + "| **5** | AAR | 4단계 진행표, 의견 수렴표, 오류 분석 매트릭스 | 미션 8·9 | ( ) |\n", + "| **부록** | 양식 모음 | 근무표 서식, 점검표, 브리핑 서식, 인계 체크리스트 | 전 미션 | ( ) |\n", + "\n", + "[작성 서식 2] 교관 지침서 핵심 항목\n", + "\n", + "| **항목** | **내용** |\n", + "| --- | --- |\n", + "| **준비물·인원** | ( ) |\n", + "| **안전·보안 통제(개인정보·외부 반출)** | ( ) |\n", + "| **시간표(총 소요)** | ( ) |\n", + "| **채점 방법** | ( ) |\n", + "| **지휘관 승인·군의관·상담관 감수 절차** | ( ) |\n", + "| **금지 사항** | 실제 근무 인원·초소·경계 계획 사용 금지, 피로 판단의 진단화 금지 |\n", + "\n", + "[작성 서식 3] 모의 검토 의견 처리표(타 부대 교관·평가관 역할)\n", + "\n", + "| **번호** | **검토자 역할** | **의견** | **반영/미반영** | **처리 내용·사유** |\n", + "| --- | --- | --- | --- | --- |\n", + "| **1** | | | | |\n", + "| **2** | | | | |\n", + "| **3** | | | | |\n", + "| **4** | | | | |\n", + "| **5** | | | | |\n", + "\n", + "[작성 서식 4] 개정 관리표\n", + "\n", + "| **판** | **일자** | **개정 내용** | **감수** | **승인** |\n", + "| --- | --- | --- | --- | --- |\n", + "| **1.0** | | 최초 작성 | | |\n", + "| | | | | |\n", + "\n", + "| **점검 항목** | **확인** |\n", + "| --- | --- |\n", + "| 5개 장(개요-SOP-시나리오-평가 루브릭-AAR)이 모두 있다 | ( ) |\n", + "| 부록에 수정된 양식을 모두 넣었다 | ( ) |\n", + "| 모의 검토 의견을 모두 처리했다(미반영은 사유 기재) | ( ) |\n", + "| 점검 기준이 교육용 가정값임을 명시하고, 지휘관 승인·군의관·상담관 감수 절차와 개정 주기를 적었다 | ( ) |\n", + "| 교안의 수치가 미션 4·5·7·9 결과와 일치한다 | ( ) |\n", + "\n", + "**4. 평가 채점표**\n", + "\n", + "교관·평가관이 미션별 산출물과 기록지를 확인해 PASS/FAIL에 표시한다. 필수 미션 7개가 모두 PASS이고 선택 미션 3개 중 2개 이상이 PASS이면 과제 이수로 판정한다.\n", + "\n", + "| **미션** | **구분** | **채점 기준** | **판정** |\n", + "| --- | --- | --- | --- |\n", + "| **M1** | 필수 | 현장 체크 항목 15개 이상 정의, 6개 역할 x 10개 항목 역할 분담 매트릭스 빈칸 0개(100% 매핑, 항목당 R 1명), 교관 체크리스트 대조 누락 0건이며 피로 요인을 5개 이상 분석했는가. | PASS / FAIL |\n", + "| **M2** | 필수 | SOP 단계별 세부 절차 100% 명시, 필수 안전·보안 조치 포함률 100%, 점검 기준 4종(연속 야간, 최소 휴식, 휴무, 최소 인원) 포함, 교차 검토 지적 사항을 모두 수정하고 휴대 요약 카드를 완성했는가. | PASS / FAIL |\n", + "| **M3** | 필수 | 현행 편성표의 근무 간 휴식 위반 24건 중 22건 이상(90%) 도출, 시간대별 주입 사건 10건 이상, 정상:비정상 비율 6:4, 모든 사건에 투입 시각·대상 역할·기대 행동이 있고 워크스루 모순이 없는가. | PASS / FAIL |\n", + "| **M4** | 선택 | SOP 단계 준수율 80% 이상, 보고 지연시간 3분 이내, 개선 휴식표의 점검 기준 위반 2건 이하이며 근무조 21개 모두 최소 인원 충족, 병목·혼선 구간 2개 이상 식별했는가. | PASS / FAIL |\n", + "| **M5** | 필수 | 주입 오류 식별 성공률 85% 이상(8건 중 7건 이상), 정상 항목 오표시 1건 이하, 모든 판정에 대조 문서와 근거가 있고 위험 유형별 보고 경로를 명시했는가. | PASS / FAIL |\n", + "| **M6** | 선택 | 인계 필수 항목 누락률 0%(10개 항목), 인계 소요 10분 이내, 인수자 돌발 질문 3문항 모두 정답, 인계자·인수자 상호 서명과 품질 체크리스트 통과가 확인되는가. | PASS / FAIL |\n", + "| **M7** | 필수 | 비상 절차 전환 시간 사건별 1분 이내, 위험 조치 성공률 90% 이상, 안전 수칙 위반 0건, 편성 담당 이탈 시 임무 위임이 1분 안에 이루어졌는가. | PASS / FAIL |\n", + "| **M8** | 선택 | 개선점 5건 이상 도출, 원인 항목별 근거 기록 연결률 100%, 의견 카드 6건 모두 요인 분류, 비난성 표현 없이 1차 SOP 수정안을 도출했는가. | PASS / FAIL |\n", + "| **M9** | 필수 | 1차 대비 조치(점검·인계) 시간 30% 이상 단축, 기준 위반 건수 50% 이상 감소, 동일 지표·동일 방법으로 비교했으며 미달 지표의 원인과 부대 적용 한계, 보수 교육 주기를 제시했는가. | PASS / FAIL |\n", + "| **M10** | 필수 | 교안 5개 장(개요-SOP-시나리오-평가 루브릭-AAR) 100% 완비, 모의 검토 의견 처리율 100%(미반영 시 사유 기재), 지휘관 승인과 군의관·상담관 감수 절차 및 개정 주기를 명시했는가. | PASS / FAIL |\n", + "\n", + "| **구분** | **기재** |\n", + "| --- | --- |\n", + "| **종합 판정** | 필수 ( )/7 PASS, 선택 ( )/3 PASS -> 이수 / 재평가 |\n", + "| **평가관 의견** | ( ) |\n", + "| **평가관 서명 / 일자** | ( ) / ( ) |\n", + "\n" + ] + } + ], + "metadata": { + "colab": { + "provenance": [] + }, + "kernelspec": { + "display_name": "Python 3", + "name": "python3" + }, + "language_info": { + "name": "python" + } + }, + "nbformat": 4, + "nbformat_minor": 0 +} \ No newline at end of file